2026-08-03 09:34:08 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-08-21 09:49:48 +08:00
|
|
|
|
import csv
|
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Body, HTTPException, Query, Request, Depends
|
2026-08-03 09:34:08 +08:00
|
|
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
|
|
|
|
|
|
|
|
from app.db.platform_store import ALL_PERMISSIONS, get_platform_store
|
2026-08-12 15:21:23 +08:00
|
|
|
|
from app.core.auth import get_current_user, is_admin
|
2026-08-21 09:49:48 +08:00
|
|
|
|
from app.core.logging import get_client_ip
|
2026-08-03 09:34:08 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/system", tags=["system"])
|
|
|
|
|
|
|
2026-08-21 09:49:48 +08:00
|
|
|
|
_VISIT_MODULES = {
|
|
|
|
|
|
"dashboard",
|
|
|
|
|
|
"fine-tune",
|
|
|
|
|
|
"model-eval",
|
|
|
|
|
|
"model-inference",
|
|
|
|
|
|
"model-manage",
|
|
|
|
|
|
"dataset",
|
|
|
|
|
|
"data-process",
|
|
|
|
|
|
"data-convert",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 09:34:08 +08:00
|
|
|
|
|
2026-08-03 17:24:45 +08:00
|
|
|
|
@router.post("/audit/visit")
|
2026-08-21 09:49:48 +08:00
|
|
|
|
def record_visit(
|
|
|
|
|
|
payload: dict = Body(...),
|
|
|
|
|
|
request: Request = None,
|
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
|
) -> dict:
|
2026-08-03 17:24:45 +08:00
|
|
|
|
"""记录用户访问业务模块的行为,用于看板用户操作分布统计。"""
|
|
|
|
|
|
action = str(payload.get("action") or payload.get("module") or "").strip()
|
|
|
|
|
|
if not action:
|
|
|
|
|
|
return {"code": 0, "message": "ok", "data": {"recorded": False}}
|
2026-08-21 09:49:48 +08:00
|
|
|
|
# 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")
|
2026-08-03 17:24:45 +08:00
|
|
|
|
get_platform_store().record_audit(
|
|
|
|
|
|
action=action,
|
|
|
|
|
|
actor_id=actor_id or None,
|
|
|
|
|
|
target_type="module",
|
|
|
|
|
|
target_id=action,
|
2026-08-21 09:49:48 +08:00
|
|
|
|
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,
|
2026-08-03 17:24:45 +08:00
|
|
|
|
)
|
|
|
|
|
|
return {"code": 0, "message": "ok", "data": {"recorded": True}}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 09:34:08 +08:00
|
|
|
|
@router.get("/permissions/codes")
|
2026-08-12 15:21:23 +08:00
|
|
|
|
def permission_codes(current_user: dict = Depends(get_current_user)) -> dict:
|
2026-08-03 09:34:08 +08:00
|
|
|
|
"""返回平台权限码清单(权限码接口)。"""
|
|
|
|
|
|
return {"code": 0, "message": "ok", "data": {"codes": ALL_PERMISSIONS}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/permissions")
|
2026-08-12 15:21:23 +08:00
|
|
|
|
def permissions_overview(current_user: dict = Depends(get_current_user)) -> dict:
|
2026-08-03 09:34:08 +08:00
|
|
|
|
"""返回权限码清单与角色定义。"""
|
|
|
|
|
|
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="目标类型"),
|
2026-08-19 16:10:02 +08:00
|
|
|
|
target_id: str | None = Query(default=None, description="目标 ID"),
|
|
|
|
|
|
keyword: str | None = Query(default=None, description="目标 ID 或详情关键字"),
|
2026-08-03 09:34:08 +08:00
|
|
|
|
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),
|
2026-08-12 15:21:23 +08:00
|
|
|
|
current_user: dict = Depends(get_current_user),
|
2026-08-03 09:34:08 +08:00
|
|
|
|
) -> dict:
|
2026-08-19 16:10:02 +08:00
|
|
|
|
"""审计日志查询:按组织、操作人、动作、资源、关键字和时间范围分页过滤。"""
|
2026-08-12 15:21:23 +08:00
|
|
|
|
if not is_admin(current_user):
|
|
|
|
|
|
from app.api.v1.endpoints.platform import fail
|
|
|
|
|
|
raise fail(403, "admin permission required")
|
2026-08-03 09:34:08 +08:00
|
|
|
|
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,
|
2026-08-19 16:10:02 +08:00
|
|
|
|
target_id=target_id,
|
|
|
|
|
|
keyword=keyword,
|
2026-08-03 09:34:08 +08:00
|
|
|
|
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="目标类型"),
|
2026-08-19 16:10:02 +08:00
|
|
|
|
target_id: str | None = Query(default=None, description="目标 ID"),
|
|
|
|
|
|
keyword: str | None = Query(default=None, description="目标 ID 或详情关键字"),
|
2026-08-03 09:34:08 +08:00
|
|
|
|
start_time: str | None = Query(default=None, description="ISO8601 起始时间"),
|
|
|
|
|
|
end_time: str | None = Query(default=None, description="ISO8601 结束时间"),
|
2026-08-12 15:21:23 +08:00
|
|
|
|
current_user: dict = Depends(get_current_user),
|
2026-08-03 09:34:08 +08:00
|
|
|
|
) -> StreamingResponse:
|
|
|
|
|
|
"""审计日志导出:返回 CSV 流,与应用查询相同的过滤条件。"""
|
2026-08-12 15:21:23 +08:00
|
|
|
|
if not is_admin(current_user):
|
|
|
|
|
|
from app.api.v1.endpoints.platform import fail
|
|
|
|
|
|
raise fail(403, "admin permission required")
|
2026-08-03 09:34:08 +08:00
|
|
|
|
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,
|
2026-08-19 16:10:02 +08:00
|
|
|
|
target_id=target_id,
|
|
|
|
|
|
keyword=keyword,
|
2026-08-03 09:34:08 +08:00
|
|
|
|
start_time=start_time,
|
|
|
|
|
|
end_time=end_time,
|
|
|
|
|
|
limit=10000,
|
|
|
|
|
|
offset=0,
|
|
|
|
|
|
)
|
|
|
|
|
|
items = result["items"]
|
2026-08-21 09:49:48 +08:00
|
|
|
|
columns = [
|
|
|
|
|
|
"time", "tenant_id", "project_id", "actor_id", "action", "target_type",
|
|
|
|
|
|
"target_id", "detail", "client_ip", "result", "reason", "request_id",
|
|
|
|
|
|
"session_id", "metadata",
|
|
|
|
|
|
]
|
2026-08-03 09:34:08 +08:00
|
|
|
|
|
|
|
|
|
|
def iter_rows():
|
2026-08-21 09:49:48 +08:00
|
|
|
|
buffer = io.StringIO()
|
|
|
|
|
|
writer = csv.writer(buffer)
|
|
|
|
|
|
writer.writerow(columns)
|
|
|
|
|
|
yield buffer.getvalue()
|
2026-08-03 09:34:08 +08:00
|
|
|
|
for row in items:
|
2026-08-21 09:49:48 +08:00
|
|
|
|
buffer.seek(0)
|
|
|
|
|
|
buffer.truncate(0)
|
|
|
|
|
|
writer.writerow([row.get(c, "") or "" for c in columns])
|
|
|
|
|
|
yield buffer.getvalue()
|
2026-08-03 09:34:08 +08:00
|
|
|
|
|
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
|
iter_rows(),
|
|
|
|
|
|
media_type="text/csv",
|
|
|
|
|
|
headers={"Content-Disposition": "attachment; filename=audit_logs.csv"},
|
|
|
|
|
|
)
|
2026-08-18 14:49:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================== 操作日志 =====================
|
|
|
|
|
|
|
2026-08-21 09:49:48 +08:00
|
|
|
|
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 ""))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-18 14:49:12 +08:00
|
|
|
|
@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:
|
2026-08-21 09:49:48 +08:00
|
|
|
|
"""操作日志查询:管理员查全量,普通用户只能查本人记录。"""
|
2026-08-18 14:49:12 +08:00
|
|
|
|
store = get_platform_store()
|
|
|
|
|
|
conditions = []
|
|
|
|
|
|
params: list = []
|
2026-08-21 09:49:48 +08:00
|
|
|
|
_operation_log_scope(current_user, conditions, params)
|
|
|
|
|
|
if user_id and is_admin(current_user):
|
2026-08-18 14:49:12 +08:00
|
|
|
|
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:
|
2026-08-21 09:49:48 +08:00
|
|
|
|
"""操作日志统计:管理员统计全量,普通用户统计本人记录。"""
|
2026-08-18 14:49:12 +08:00
|
|
|
|
store = get_platform_store()
|
|
|
|
|
|
conditions = []
|
|
|
|
|
|
params: list = []
|
2026-08-21 09:49:48 +08:00
|
|
|
|
_operation_log_scope(current_user, conditions, params)
|
2026-08-18 14:49:12 +08:00
|
|
|
|
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()
|
2026-08-21 09:49:48 +08:00
|
|
|
|
conditions: list[str] = []
|
|
|
|
|
|
params: list = []
|
|
|
|
|
|
_operation_log_scope(current_user, conditions, params)
|
|
|
|
|
|
where = " WHERE " + " AND ".join(conditions) if conditions else ""
|
2026-08-18 14:49:12 +08:00
|
|
|
|
with store.connect() as conn:
|
|
|
|
|
|
rows = conn.execute(
|
2026-08-21 09:49:48 +08:00
|
|
|
|
f"SELECT DISTINCT module FROM operation_logs{where}"
|
|
|
|
|
|
+ (" AND" if where else " WHERE")
|
|
|
|
|
|
+ " module IS NOT NULL ORDER BY module",
|
|
|
|
|
|
tuple(params),
|
2026-08-18 14:49:12 +08:00
|
|
|
|
).fetchall()
|
|
|
|
|
|
modules = [{"value": r["module"], "label": r["module"]} for r in rows]
|
|
|
|
|
|
return {"code": 0, "message": "ok", "data": modules}
|