from __future__ import annotations import csv import io from fastapi import APIRouter, Body, HTTPException, Query, Request, Depends from fastapi.responses import StreamingResponse from app.db.platform_store import ALL_PERMISSIONS, get_platform_store from app.core.auth import get_current_user, is_admin from app.core.logging import get_client_ip router = APIRouter(prefix="/system", tags=["system"]) _VISIT_MODULES = { "dashboard", "fine-tune", "model-eval", "model-inference", "model-manage", "dataset", "data-process", "data-convert", } @router.post("/audit/visit") def record_visit( payload: dict = Body(...), request: Request = None, current_user: dict = Depends(get_current_user), ) -> dict: """记录用户访问业务模块的行为,用于看板用户操作分布统计。""" action = str(payload.get("action") or payload.get("module") or "").strip() if not action: return {"code": 0, "message": "ok", "data": {"recorded": False}} # Visit statistics are intentionally limited to known module names. This # endpoint must not become a free-form audit-log injection point. if action not in _VISIT_MODULES: return {"code": 0, "message": "ok", "data": {"recorded": False}} actor_id = current_user.get("id") get_platform_store().record_audit( action=action, actor_id=actor_id or None, target_type="module", target_id=action, tenant_id=str(current_user.get("tenant_id") or "") or None, session_id=str(current_user.get("session_id") or "") or None, request_id=(request.headers.get("X-Request-ID") if request else None), detail="module visit", metadata={"source": "frontend", "detail_length": len(str(payload.get("detail") or ""))}, ip=get_client_ip(request) or None, ) return {"code": 0, "message": "ok", "data": {"recorded": True}} @router.get("/permissions/codes") def permission_codes(current_user: dict = Depends(get_current_user)) -> dict: """返回平台权限码清单(权限码接口)。""" return {"code": 0, "message": "ok", "data": {"codes": ALL_PERMISSIONS}} @router.get("/permissions") def permissions_overview(current_user: dict = Depends(get_current_user)) -> dict: """返回权限码清单与角色定义。""" store = get_platform_store() return { "code": 0, "message": "ok", "data": {"codes": ALL_PERMISSIONS, "roles": store.roles()}, } @router.get("/audit-logs") def audit_logs( tenant_id: str | None = Query(default=None, description="租户 ID"), project_id: str | None = Query(default=None, description="项目 ID"), actor_id: str | None = Query(default=None, description="操作人 ID"), action: str | None = Query(default=None, description="动作类型"), target_type: str | None = Query(default=None, description="目标类型"), target_id: str | None = Query(default=None, description="目标 ID"), keyword: str | None = Query(default=None, description="目标 ID 或详情关键字"), start_time: str | None = Query(default=None, description="ISO8601 起始时间"), end_time: str | None = Query(default=None, description="ISO8601 结束时间"), limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), current_user: dict = Depends(get_current_user), ) -> dict: """审计日志查询:按组织、操作人、动作、资源、关键字和时间范围分页过滤。""" if not is_admin(current_user): from app.api.v1.endpoints.platform import fail raise fail(403, "admin permission required") store = get_platform_store() result = store.audit_logs( tenant_id=tenant_id, project_id=project_id, actor_id=actor_id, action=action, target_type=target_type, target_id=target_id, keyword=keyword, start_time=start_time, end_time=end_time, limit=limit, offset=offset, ) return {"code": 0, "message": "ok", "data": result} @router.get("/audit-logs/export") def audit_logs_export( tenant_id: str | None = Query(default=None, description="租户 ID"), project_id: str | None = Query(default=None, description="项目 ID"), actor_id: str | None = Query(default=None, description="操作人 ID"), action: str | None = Query(default=None, description="动作类型"), target_type: str | None = Query(default=None, description="目标类型"), target_id: str | None = Query(default=None, description="目标 ID"), keyword: str | None = Query(default=None, description="目标 ID 或详情关键字"), start_time: str | None = Query(default=None, description="ISO8601 起始时间"), end_time: str | None = Query(default=None, description="ISO8601 结束时间"), current_user: dict = Depends(get_current_user), ) -> StreamingResponse: """审计日志导出:返回 CSV 流,与应用查询相同的过滤条件。""" if not is_admin(current_user): from app.api.v1.endpoints.platform import fail raise fail(403, "admin permission required") store = get_platform_store() result = store.audit_logs( tenant_id=tenant_id, project_id=project_id, actor_id=actor_id, action=action, target_type=target_type, target_id=target_id, keyword=keyword, start_time=start_time, end_time=end_time, limit=10000, offset=0, ) items = result["items"] columns = [ "time", "tenant_id", "project_id", "actor_id", "action", "target_type", "target_id", "detail", "client_ip", "result", "reason", "request_id", "session_id", "metadata", ] def iter_rows(): buffer = io.StringIO() writer = csv.writer(buffer) writer.writerow(columns) yield buffer.getvalue() for row in items: buffer.seek(0) buffer.truncate(0) writer.writerow([row.get(c, "") or "" for c in columns]) yield buffer.getvalue() return StreamingResponse( iter_rows(), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=audit_logs.csv"}, ) # ===================== 操作日志 ===================== def _operation_log_scope(current_user: dict, conditions: list[str], params: list) -> None: """校验操作日志权限,并为普通用户追加本人范围。""" if is_admin(current_user): return if "logs" not in (current_user.get("permissions") or []): raise HTTPException(status_code=403, detail="missing permission: logs") conditions.append("user_id = %s") params.append(str(current_user.get("id") or "")) @router.get("/operation-logs") def operation_logs( user_id: str | None = Query(default=None, description="按用户 ID 筛选"), module: str | None = Query(default=None, description="按模块筛选: fine-tune/model-eval/model-inference/dataset/data-convert/model-manage"), action: str | None = Query(default=None, description="按动作筛选: create/start/stop/delete/upload/convert/merge"), status: str | None = Query(default=None, description="按状态筛选: success/failure(不传则查全部)"), keyword: str | None = Query(default=None, description="关键字搜索报错信息(error_message)"), start_time: str | None = Query(default=None, description="ISO8601 起始时间"), end_time: str | None = Query(default=None, description="ISO8601 结束时间"), limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), current_user: dict = Depends(get_current_user), ) -> dict: """操作日志查询:管理员查全量,普通用户只能查本人记录。""" store = get_platform_store() conditions = [] params: list = [] _operation_log_scope(current_user, conditions, params) if user_id and is_admin(current_user): conditions.append("user_id = %s") params.append(user_id) if module: conditions.append("module = %s") params.append(module) if action: conditions.append("action = %s") params.append(action) if status: conditions.append("status = %s") params.append(status) if keyword: conditions.append("(error_message ILIKE %s OR error_type ILIKE %s)") params.append(f"%{keyword}%") params.append(f"%{keyword}%") if start_time: conditions.append("create_time >= %s") params.append(start_time) if end_time: conditions.append("create_time <= %s") params.append(end_time) where = " WHERE " + " AND ".join(conditions) if conditions else "" with store.connect() as conn: rows = conn.execute( f"SELECT * FROM operation_logs{where} ORDER BY create_time DESC LIMIT %s OFFSET %s", tuple(params + [limit, offset]), ).fetchall() total = conn.execute(f"SELECT COUNT(*) FROM operation_logs{where}", tuple(params)).fetchone()[0] return {"code": 0, "message": "ok", "data": {"items": [dict(r) for r in rows], "total": total}} @router.get("/operation-logs/stats") def operation_logs_stats( start_time: str | None = Query(default=None, description="ISO8601 起始时间"), end_time: str | None = Query(default=None, description="ISO8601 结束时间"), current_user: dict = Depends(get_current_user), ) -> dict: """操作日志统计:管理员统计全量,普通用户统计本人记录。""" store = get_platform_store() conditions = [] params: list = [] _operation_log_scope(current_user, conditions, params) if start_time: conditions.append("create_time >= %s") params.append(start_time) if end_time: conditions.append("create_time <= %s") params.append(end_time) where = " WHERE " + " AND ".join(conditions) if conditions else "" failure_where = where + " AND status = 'failure'" if where else " WHERE status = 'failure'" with store.connect() as conn: # 总计 row = conn.execute( f"SELECT status, COUNT(*) as cnt FROM operation_logs{where} GROUP BY status", tuple(params) ).fetchall() total_count = 0 success_count = 0 failure_count = 0 for r in row: total_count += r["cnt"] if r["status"] == "success": success_count = r["cnt"] elif r["status"] == "failure": failure_count = r["cnt"] failure_rate = round(failure_count / total_count * 100, 2) if total_count > 0 else 0 # 各模块失败数 module_stats = conn.execute( f"SELECT module, COUNT(*) as cnt FROM operation_logs{failure_where} GROUP BY module ORDER BY cnt DESC", tuple(params), ).fetchall() # 各异常类型分布 error_type_stats = conn.execute( f"SELECT error_type, COUNT(*) as cnt FROM operation_logs{failure_where} AND error_type IS NOT NULL GROUP BY error_type ORDER BY cnt DESC LIMIT 10", tuple(params), ).fetchall() # 最近的 10 条错误 recent_errors = conn.execute( f"SELECT * FROM operation_logs{failure_where} ORDER BY create_time DESC LIMIT 10", tuple(params), ).fetchall() return { "code": 0, "message": "ok", "data": { "total": total_count, "success": success_count, "failure": failure_count, "failure_rate": failure_rate, "module_failures": [{"module": r["module"], "count": r["cnt"]} for r in module_stats], "error_types": [{"type": r["error_type"], "count": r["cnt"]} for r in error_type_stats], "recent_errors": [dict(r) for r in recent_errors], }, } @router.get("/operation-logs/modules") def operation_log_modules(current_user: dict = Depends(get_current_user)) -> dict: """返回操作日志中出现的模块列表(用于筛选下拉框)。""" store = get_platform_store() conditions: list[str] = [] params: list = [] _operation_log_scope(current_user, conditions, params) where = " WHERE " + " AND ".join(conditions) if conditions else "" with store.connect() as conn: rows = conn.execute( f"SELECT DISTINCT module FROM operation_logs{where}" + (" AND" if where else " WHERE") + " module IS NOT NULL ORDER BY module", tuple(params), ).fetchall() modules = [{"value": r["module"], "label": r["module"]} for r in rows] return {"code": 0, "message": "ok", "data": modules}