修改普通用户的数据类型转换在数据集管理看不见的问题

This commit is contained in:
wangjiming
2026-08-18 14:49:12 +08:00
parent 4e27b98a84
commit 04c3c1412c
12 changed files with 1221 additions and 37 deletions

View File

@@ -17,6 +17,7 @@ import httpx
from app.core.auth import filter_accessible_resource_ids, filter_accessible_resource_ids_batch, get_current_user, has_resource_access, is_admin from app.core.auth import filter_accessible_resource_ids, filter_accessible_resource_ids_batch, get_current_user, has_resource_access, is_admin
from app.core.config import get_settings from app.core.config import get_settings
from app.core.audit import audit_log, AuditActions from app.core.audit import audit_log, AuditActions
from app.core.op_log import op_log, OpModule, OpAction
from app.db.platform_store import get_platform_store from app.db.platform_store import get_platform_store
from app.modules.compute_gateway.client import ComputeNodeClient from app.modules.compute_gateway.client import ComputeNodeClient
from app.modules.compute_gateway.sync import fetch_eval_result_content, poll_compute_jobs_once from app.modules.compute_gateway.sync import fetch_eval_result_content, poll_compute_jobs_once
@@ -904,12 +905,11 @@ async def create_model(payload: dict[str, Any] = Body(...), current_user: dict =
@router.get("/model-manage/{model_id}") @router.get("/model-manage/{model_id}")
async def model_detail(model_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]: async def model_detail(model_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
# 基座模型(配置模型)是平台共享资源,所有登录用户均可查看
try: try:
model = get_platform_store().model(model_id) model = get_platform_store().model(model_id)
except KeyError: except KeyError:
raise fail(404, "model not found") raise fail(404, "model not found")
if not has_resource_access("model", model_id, current_user, "read"):
raise fail(403, "no permission to access this model")
return ok(model) return ok(model)
@@ -919,7 +919,10 @@ async def model_detail(model_id: str, current_user: dict = Depends(get_current_u
target_type="model", target_type="model",
detail_template="更新模型: {model_id}", detail_template="更新模型: {model_id}",
) )
async def update_model(model_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: async def update_model(model_id: str, payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
# 基座模型(配置模型)只有管理员可以编辑
if not is_admin(current_user):
raise fail(403, "只有管理员可以修改模型配置")
try: try:
return ok(get_platform_store().update_model(model_id, payload)) return ok(get_platform_store().update_model(model_id, payload))
except KeyError: except KeyError:
@@ -927,7 +930,10 @@ async def update_model(model_id: str, payload: dict[str, Any] = Body(...)) -> di
@router.put("/model-manage/{model_id}/purpose") @router.put("/model-manage/{model_id}/purpose")
async def update_model_purpose(model_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: async def update_model_purpose(model_id: str, payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
# 基座模型(配置模型)只有管理员可以修改用途
if not is_admin(current_user):
raise fail(403, "只有管理员可以修改模型用途")
try: try:
return ok(get_platform_store().update_model(model_id, {"purpose": payload.get("purpose", "training")})) return ok(get_platform_store().update_model(model_id, {"purpose": payload.get("purpose", "training")}))
except KeyError: except KeyError:
@@ -936,16 +942,15 @@ async def update_model_purpose(model_id: str, payload: dict[str, Any] = Body(...
@router.delete("/model-manage/{model_id}") @router.delete("/model-manage/{model_id}")
async def delete_model(model_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]: async def delete_model(model_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
if not has_resource_access("model", model_id, current_user, "delete"): # 基座模型(配置模型)只有管理员可以删除
raise fail(403, "no permission to delete this model") if not is_admin(current_user):
pending = _require_approval_or_admin("model", model_id, current_user, f"删除模型 {model_id}") raise fail(403, "只有管理员可以删除模型配置")
if pending:
return pending
get_platform_store().delete_model(model_id) get_platform_store().delete_model(model_id)
return ok({"deleted": model_id}) return ok({"deleted": model_id})
@router.post("/model-manage/merge") @router.post("/model-manage/merge")
@op_log(module=OpModule.MODEL_MANAGE, action=OpAction.MERGE, target_type="trained_model", target_name_param="trained_model_id")
async def merge_model(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]: async def merge_model(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
store = get_platform_store() store = get_platform_store()
trained_model_id = str(payload.get("trained_model_id") or payload.get("model_id") or payload.get("model_name") or "") trained_model_id = str(payload.get("trained_model_id") or payload.get("model_id") or payload.get("model_name") or "")
@@ -961,8 +966,7 @@ async def merge_model(payload: dict[str, Any] = Body(...), current_user: dict =
raise fail(404, "trained model not found") raise fail(404, "trained model not found")
if not has_resource_access("trained_model", trained_model["id"], current_user, "execute"): if not has_resource_access("trained_model", trained_model["id"], current_user, "execute"):
raise fail(403, "no permission to merge this trained model") raise fail(403, "no permission to merge this trained model")
if payload.get("base_model_id") and not has_resource_access("model", str(payload["base_model_id"]), current_user, "execute"): # 基座模型(配置模型)是平台共享资源,不需要 ACL 授权即可使用
raise fail(403, "no permission to use merge base model")
base_model_path = payload.get("base_model_path") or (trained_model and trained_model.get("base_model_path")) base_model_path = payload.get("base_model_path") or (trained_model and trained_model.get("base_model_path"))
adapter_path = ( adapter_path = (
payload.get("adapter_path") payload.get("adapter_path")
@@ -1282,6 +1286,7 @@ async def dataset_list(current_user: dict = Depends(get_current_user)) -> dict[s
target_type="dataset", target_type="dataset",
detail_template="创建数据集: {name}", detail_template="创建数据集: {name}",
) )
@op_log(module=OpModule.DATASET, action=OpAction.CREATE, target_type="dataset", target_name_param="name")
async def create_dataset(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]: async def create_dataset(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
payload.setdefault("created_by", current_user.get("id")) payload.setdefault("created_by", current_user.get("id"))
dataset = get_platform_store().create_dataset(payload) dataset = get_platform_store().create_dataset(payload)
@@ -1318,6 +1323,7 @@ async def update_dataset(dataset_id: str, payload: dict[str, Any] = Body(...)) -
target_type="dataset", target_type="dataset",
detail_template="删除数据集: {dataset_id}", detail_template="删除数据集: {dataset_id}",
) )
@op_log(module=OpModule.DATASET, action=OpAction.DELETE, target_type="dataset", target_name_param="dataset_id")
async def delete_dataset(dataset_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]: async def delete_dataset(dataset_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
if not has_resource_access("dataset", dataset_id, current_user, "delete"): if not has_resource_access("dataset", dataset_id, current_user, "delete"):
raise fail(403, "no permission to delete this dataset") raise fail(403, "no permission to delete this dataset")
@@ -1367,8 +1373,7 @@ async def create_fine_tune(payload: dict[str, Any] = Body(...), current_user: di
if not is_admin(current_user): if not is_admin(current_user):
model_id = str(payload.get("base_model") or payload.get("base_model_id") or "") model_id = str(payload.get("base_model") or payload.get("base_model_id") or "")
dataset_id = str(payload.get("train_dataset_id") or "") dataset_id = str(payload.get("train_dataset_id") or "")
if model_id and not has_resource_access("model", model_id, current_user, "execute"): # 基座模型(配置模型)是平台共享资源,不需要 ACL 授权
raise fail(403, "no permission to use this base model")
if dataset_id and not has_resource_access("dataset", dataset_id, current_user, "execute"): if dataset_id and not has_resource_access("dataset", dataset_id, current_user, "execute"):
raise fail(403, "no permission to use this dataset") raise fail(403, "no permission to use this dataset")
try: try:
@@ -1379,6 +1384,7 @@ async def create_fine_tune(payload: dict[str, Any] = Body(...), current_user: di
@router.post("/fine-tune/start") @router.post("/fine-tune/start")
@op_log(module=OpModule.FINE_TUNE, action=OpAction.START, target_type="fine_tune", target_name_param="name", detail_params=["task_id", "base_model", "train_dataset_id"])
async def start_fine_tune( async def start_fine_tune(
payload: dict[str, Any] = Body(...), payload: dict[str, Any] = Body(...),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
@@ -1538,6 +1544,7 @@ async def update_fine_tune(task_id: str, payload: dict[str, Any] = Body(...), cu
@router.post("/fine-tune/stop/{task_id}") @router.post("/fine-tune/stop/{task_id}")
@op_log(module=OpModule.FINE_TUNE, action=OpAction.STOP, target_type="fine_tune", target_name_param="task_id")
async def stop_fine_tune(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]: async def stop_fine_tune(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
store = get_platform_store() store = get_platform_store()
try: try:
@@ -1584,6 +1591,7 @@ async def retry_fine_tune(task_id: str, payload: dict[str, Any] | None = Body(de
@router.delete("/fine-tune/{task_id}") @router.delete("/fine-tune/{task_id}")
@op_log(module=OpModule.FINE_TUNE, action=OpAction.DELETE, target_type="fine_tune", target_name_param="task_id")
async def delete_fine_tune(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]: async def delete_fine_tune(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
if not has_resource_access("fine-tune", task_id, current_user, "delete"): if not has_resource_access("fine-tune", task_id, current_user, "delete"):
raise fail(403, "no permission to delete this task") raise fail(403, "no permission to delete this task")
@@ -1665,6 +1673,7 @@ async def model_eval_detail(task_id: str, current_user: dict = Depends(get_curre
@router.post("/model-eval/start") @router.post("/model-eval/start")
@op_log(module=OpModule.MODEL_EVAL, action=OpAction.START, target_type="eval_task", target_name_param="name", detail_params=["model_id", "dataset_id"])
async def model_eval_start(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]: async def model_eval_start(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
"""Start an evaluation task: submit eval job to compute node.""" """Start an evaluation task: submit eval job to compute node."""
store = get_platform_store() store = get_platform_store()
@@ -1878,11 +1887,7 @@ async def model_compare_list(current_user: dict = Depends(get_current_user)) ->
async def model_compare_create(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]: async def model_compare_create(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
payload.setdefault("created_by", current_user.get("id")) payload.setdefault("created_by", current_user.get("id"))
model_ids = payload.get("model_ids") or payload.get("models") or [] model_ids = payload.get("model_ids") or payload.get("models") or []
if not is_admin(current_user): # 基座模型(配置模型)是平台共享资源,不需要 ACL 授权即可用于推理
for model_id in model_ids:
if isinstance(model_id, dict): model_id = model_id.get("id") or model_id.get("model_id")
if model_id and not has_resource_access("model", str(model_id), current_user, "execute"):
raise fail(403, "no permission to use inference model")
task = get_platform_store().create_compare_task(payload) task = get_platform_store().create_compare_task(payload)
return ok({"id": task["id"]}) return ok({"id": task["id"]})
@@ -1940,17 +1945,35 @@ async def _unload_from_compute_node(store: Any, task: dict[str, Any] | None = No
@router.delete("/model-compare/{task_id}") @router.delete("/model-compare/{task_id}")
@op_log(module=OpModule.INFERENCE, action=OpAction.DELETE, target_type="inference", target_name_param="task_id")
async def model_compare_delete(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]: async def model_compare_delete(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
# 先删记录(快),再 best-effort 释放算力节点上的模型——删除绝不被卸载阻塞 # 先删记录(快),再 best-effort 释放算力节点上的模型——删除绝不被卸载阻塞
try: try:
task = get_platform_store().compare_task(task_id) task = get_platform_store().compare_task(task_id)
except KeyError: except KeyError:
raise fail(404, "compare task not found") raise fail(404, "compare task not found")
if not has_resource_access("compare", task_id, current_user, "delete"): # 先判断是否为任务创建者本人:如果是,直接允许删除(不需要 ACL 授权也不需要审批)
raise fail(403, "no permission to delete inference task") is_owner = False
pending = _require_approval_or_admin("compare", task_id, current_user, f"删除推理任务 {task_id}") payload_obj = task
if pending: if isinstance(payload_obj, dict):
return pending is_owner = (payload_obj.get("created_by") == current_user.get("id"))
else:
try:
import json
payload_str = str(payload_obj.get("payload", "{}") or "{}")
payload_obj = json.loads(payload_str) if payload_str.startswith("{") else {}
is_owner = (payload_obj.get("created_by") == current_user.get("id"))
except (TypeError, ValueError):
pass
# 管理员或任务创建者:直接删除
# 其他用户:需要 ACL delete 权限 + 审批流程
if not is_admin(current_user) and not is_owner:
if not has_resource_access("compare", task_id, current_user, "delete"):
raise fail(403, "no permission to delete inference task")
pending = _require_approval_or_admin("compare", task_id, current_user, f"删除推理任务 {task_id}")
if pending:
return pending
get_platform_store().delete_compare_task(task_id) get_platform_store().delete_compare_task(task_id)
try: try:
await _unload_from_compute_node(get_platform_store(), task=task) await _unload_from_compute_node(get_platform_store(), task=task)
@@ -2013,6 +2036,7 @@ def _invalidate_superseded_models(store: Any, task_id: str, loaded_models: list[
@router.post("/model-compare/{task_id}/load") @router.post("/model-compare/{task_id}/load")
@op_log(module=OpModule.INFERENCE, action=OpAction.START, target_type="inference", target_name_param="task_id")
async def model_compare_load(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]: async def model_compare_load(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
"""异步派发模型加载到算力节点,立即返回。 """异步派发模型加载到算力节点,立即返回。
@@ -2023,8 +2047,13 @@ async def model_compare_load(task_id: str, current_user: dict = Depends(get_curr
try: try:
store = get_platform_store() store = get_platform_store()
task = store.compare_task(task_id) task = store.compare_task(task_id)
if not has_resource_access("compare", task_id, current_user, "execute"): # 先判断是否为任务创建者本人或管理员:如果是,直接允许操作
raise fail(403, "no permission to load inference task") is_owner = False
if isinstance(task, dict):
is_owner = (task.get("created_by") == current_user.get("id"))
if not is_admin(current_user) and not is_owner:
if not has_resource_access("compare", task_id, current_user, "execute"):
raise fail(403, "no permission to load inference task")
models = task.get("models") or [] models = task.get("models") or []
if isinstance(models, str): if isinstance(models, str):
try: try:
@@ -2231,8 +2260,9 @@ async def model_chat_local_status() -> dict[str, Any]:
@router.post("/model-chat/trained/preload") @router.post("/model-chat/trained/preload")
async def model_chat_trained_preload(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]: async def model_chat_trained_preload(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
resource_id = str(payload.get("trained_model_id") or payload.get("model_id") or payload.get("resource_id") or "") resource_id = str(payload.get("trained_model_id") or payload.get("model_id") or payload.get("resource_id") or "")
if resource_id and not has_resource_access("trained_model", resource_id, current_user, "execute") and not has_resource_access("model", resource_id, current_user, "execute"): # 训练模型需要 ACL 授权;基座模型(配置模型)是平台共享资源,不需要 ACL
raise fail(403, "no permission to load this model") if resource_id and not has_resource_access("trained_model", resource_id, current_user, "execute"):
raise fail(403, "no permission to load this trained model")
"""Load a trained model (base + adapter) on the compute node for inference.""" """Load a trained model (base + adapter) on the compute node for inference."""
model_path = (payload.get("model_name_or_path") or "").strip() model_path = (payload.get("model_name_or_path") or "").strip()
if not model_path: if not model_path:
@@ -2365,8 +2395,9 @@ async def archive_node_files(
if not is_admin(current_user): if not is_admin(current_user):
model_id = str(payload.get("model_id") or "") model_id = str(payload.get("model_id") or "")
dataset_id = str(payload.get("dataset_id") or "") dataset_id = str(payload.get("dataset_id") or "")
if not model_id or not has_resource_access("model", model_id, current_user, "execute"): # 基座模型(配置模型)是平台共享资源,不需要 ACL 授权
raise fail(403, "no permission to evaluate this model") if not model_id:
raise fail(400, "model_id is required")
if not dataset_id or not has_resource_access("dataset", dataset_id, current_user, "execute"): if not dataset_id or not has_resource_access("dataset", dataset_id, current_user, "execute"):
raise fail(403, "no permission to evaluate this dataset") raise fail(403, "no permission to evaluate this dataset")
node = next((item for item in store.compute_nodes() if item["id"] == node_id), None) node = next((item for item in store.compute_nodes() if item["id"] == node_id), None)

View File

@@ -180,9 +180,22 @@ def filter_accessible_resource_ids(
accessible = {r["resource_id"] for r in rows} accessible = {r["resource_id"] for r in rows}
if resource_type in OWNER_TABLES: if resource_type in OWNER_TABLES:
table, column = OWNER_TABLES[resource_type] table, column = OWNER_TABLES[resource_type]
with store.connect() as conn: if column == "payload":
owned = conn.execute(f"SELECT id FROM {table} WHERE {column}=?", (user_id,)).fetchall() # payload 是 JSON 字符串,需要查出后解析 created_by
accessible.update(row["id"] for row in owned) with store.connect() as conn:
owned = conn.execute(f"SELECT id, {column} FROM {table}").fetchall()
for row in owned:
try:
import json
payload = json.loads(row[column] or "{}")
if payload.get("created_by") == user_id:
accessible.add(row["id"])
except (TypeError, ValueError):
pass
else:
with store.connect() as conn:
owned = conn.execute(f"SELECT id FROM {table} WHERE {column}=?", (user_id,)).fetchall()
accessible.update(row["id"] for row in owned)
return [rid for rid in all_ids if rid in accessible] return [rid for rid in all_ids if rid in accessible]
@@ -210,8 +223,18 @@ def filter_accessible_resource_ids_batch(
table, column = table_info table, column = table_info
with store.connect() as conn: with store.connect() as conn:
owned = conn.execute( owned = conn.execute(
f"SELECT id FROM {table} WHERE id IN ({placeholders}) AND {column}=?", f"SELECT id, {column} FROM {table} WHERE id IN ({placeholders})",
(*resource_ids, user["id"]), (*resource_ids,),
).fetchall() ).fetchall()
accessible.update(row["id"] for row in owned) for row in owned:
owner = row[column]
# 如果列是 payloadJSON需要解析后提取 created_by
if column == "payload":
try:
import json
owner = json.loads(owner or "{}").get("created_by")
except (TypeError, ValueError):
owner = None
if owner == user["id"]:
accessible.add(row["id"])
return accessible return accessible

View File

@@ -4,6 +4,7 @@ from contextvars import ContextVar
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
import json import json
import logging import logging
import sys
from logging import Handler, LogRecord from logging import Handler, LogRecord
from pathlib import Path from pathlib import Path
import re import re
@@ -353,6 +354,27 @@ def setup_request_logging(app: FastAPI) -> None:
elapsed_ms, elapsed_ms,
request.client.host if request.client else "-", request.client.host if request.client else "-",
) )
# 5xx 系统错误自动写入操作日志(未被 @op_log 覆盖的系统级异常)
if response.status_code >= 500:
try:
from app.core.op_log import log_operation, OpModule, OpStatus
log_operation(
module=OpModule.SYSTEM,
action="request",
target_type="api",
target_name=request.url.path,
status=OpStatus.FAILURE,
error_message=f"HTTP {response.status_code} - 系统内部错误",
error_type="HTTPError",
detail=f'{{"method":"{request.method}","path":"{request.url.path}","status":{response.status_code}}}',
func_name="request_logging_middleware",
request=request,
duration_ms=elapsed_ms,
)
except Exception:
pass # 日志写入失败不影响主流程
response.headers["X-Request-ID"] = request_id response.headers["X-Request-ID"] = request_id
return response return response
except Exception: except Exception:
@@ -364,6 +386,28 @@ def setup_request_logging(app: FastAPI) -> None:
elapsed_ms, elapsed_ms,
request.client.host if request.client else "-", request.client.host if request.client else "-",
) )
# 未被捕获的异常,写入操作日志
try:
import traceback as _tb
from app.core.op_log import log_operation, OpModule, OpStatus
log_operation(
module=OpModule.SYSTEM,
action="request",
target_type="api",
target_name=request.url.path,
status=OpStatus.FAILURE,
error_message=str(sys.exc_info()[1])[:1000] if sys.exc_info()[1] else "未知异常",
error_type=type(sys.exc_info()[1]).__name__ if sys.exc_info()[1] else "UnknownError",
error_traceback="".join(_tb.format_exception(*sys.exc_info()))[:5000],
func_name="request_logging_middleware",
detail=f'{{"method":"{request.method}","path":"{request.url.path}"}}',
request=request,
duration_ms=elapsed_ms,
)
except Exception:
pass # 日志写入失败不影响主流程
raise raise
finally: finally:
request_id_var.reset(token) request_id_var.reset(token)

382
backend/app/core/op_log.py Normal file
View File

@@ -0,0 +1,382 @@
"""
操作日志工具模块
提供 @op_log 装饰器和 log_operation 函数,用于记录用户在各业务模块的详细操作。
自动捕获成功/失败状态、完整报错堆栈、操作耗时等。
核心设计:
- 失败操作必须清晰记录完整异常堆栈traceback
- 记录异常类型(如 RuntimeError / ValueError / ConnectionError
- 记录具体出错的函数名和文件位置,方便定位 bug
- 记录 HTTP 状态码,方便区分用户错误(4xx)和系统错误(5xx)
使用示例:
from app.core.op_log import op_log, OpModule, OpAction
@router.post("/inference/start")
@op_log(module=OpModule.INFERENCE, action=OpAction.START, target_type="inference")
async def start_inference(...):
...
"""
from __future__ import annotations
import asyncio
import functools
import json
import time
import traceback
from datetime import datetime, timezone
from typing import Any, Callable, Optional, TypeVar
from fastapi import Request
from app.core.logging import get_logger, request_id_var
from app.db.platform_store import get_platform_store, new_id, utcnow
logger = get_logger("app.op_log")
F = TypeVar("F", bound=Callable[..., Any])
class OpModule:
"""业务模块常量"""
FINE_TUNE = "fine-tune" # 模型训练
MODEL_EVAL = "model-eval" # 模型评测
INFERENCE = "model-inference" # 模型推理
MODEL_MANAGE = "model-manage" # 模型管理
DATASET = "dataset" # 数据集
DATA_PROCESS = "data-process" # 数据处理
DATA_CONVERT = "data-convert" # 数据类型转换
COMPUTE = "compute" # 算力节点
SYSTEM = "system" # 系统
class OpAction:
"""操作动作常量"""
CREATE = "create"
UPDATE = "update"
DELETE = "delete"
START = "start"
STOP = "stop"
UPLOAD = "upload"
DOWNLOAD = "download"
CONVERT = "convert"
MERGE = "merge"
IMPORT = "import"
LOGIN = "login"
LOGOUT = "logout"
PUBLISH = "publish"
RETRY = "retry"
class OpStatus:
"""操作状态常量"""
SUCCESS = "success"
FAILURE = "failure"
def op_log(
module: str,
action: str,
target_type: str = "",
*,
target_name_param: str = "name",
detail_params: Optional[list[str]] = None,
) -> Callable[[F], F]:
"""
操作日志装饰器
自动记录:
- 谁在什么时间操作了什么
- 成功还是失败
- 失败时记录完整异常堆栈(traceback)、异常类型、异常消息
- 出错的函数名和文件位置,方便定位 bug
- 操作耗时(ms)
- 客户端 IP、请求路径
Args:
module: 业务模块OpModule 常量)
action: 操作动作OpAction 常量)
target_type: 资源类型
target_name_param: 从 kwargs 中提取目标名称的参数名
detail_params: 需要记录到 detail 的参数名列表
"""
def decorator(func: F) -> F:
func_name = f"{func.__module__}.{func.__qualname__}"
if asyncio.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
started_at = time.perf_counter()
trace_id = request_id_var.get("-")
user = _extract_user(args, kwargs)
request = _extract_request(args)
target_name = _get_param(kwargs, target_name_param, "")
target_id = _get_param(kwargs, "task_id", "") or _get_param(kwargs, "dataset_id", "") or _get_param(kwargs, "model_id", "")
detail_dict = _build_detail(detail_params, kwargs)
detail_str = json.dumps(detail_dict, ensure_ascii=False) if detail_dict else ""
try:
result = await func(*args, **kwargs)
elapsed_ms = (time.perf_counter() - started_at) * 1000
if not target_id and isinstance(result, dict):
target_id = str(result.get("id", ""))
_write_log(
module=module,
action=action,
target_type=target_type,
target_id=str(target_id) if target_id else None,
target_name=str(target_name) if target_name else None,
status=OpStatus.SUCCESS,
error_message="",
error_type="",
error_traceback="",
func_name=func_name,
detail=detail_str,
user=user,
request=request,
trace_id=trace_id,
duration_ms=elapsed_ms,
)
return result
except Exception as exc:
elapsed_ms = (time.perf_counter() - started_at) * 1000
# 捕获完整异常堆栈
tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
full_traceback = "".join(tb_lines)
error_msg = str(exc)[:1000]
error_type = type(exc).__name__
_write_log(
module=module,
action=action,
target_type=target_type,
target_id=str(target_id) if target_id else None,
target_name=str(target_name) if target_name else None,
status=OpStatus.FAILURE,
error_message=error_msg,
error_type=error_type,
error_traceback=full_traceback,
func_name=func_name,
detail=detail_str,
user=user,
request=request,
trace_id=trace_id,
duration_ms=elapsed_ms,
)
raise
return async_wrapper # type: ignore
else:
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
started_at = time.perf_counter()
trace_id = request_id_var.get("-")
user = _extract_user(args, kwargs)
request = _extract_request(args)
target_name = _get_param(kwargs, target_name_param, "")
target_id = _get_param(kwargs, "task_id", "") or _get_param(kwargs, "dataset_id", "") or _get_param(kwargs, "model_id", "")
detail_dict = _build_detail(detail_params, kwargs)
detail_str = json.dumps(detail_dict, ensure_ascii=False) if detail_dict else ""
try:
result = func(*args, **kwargs)
elapsed_ms = (time.perf_counter() - started_at) * 1000
if not target_id and isinstance(result, dict):
target_id = str(result.get("id", ""))
_write_log(
module=module,
action=action,
target_type=target_type,
target_id=str(target_id) if target_id else None,
target_name=str(target_name) if target_name else None,
status=OpStatus.SUCCESS,
error_message="",
error_type="",
error_traceback="",
func_name=func_name,
detail=detail_str,
user=user,
request=request,
trace_id=trace_id,
duration_ms=elapsed_ms,
)
return result
except Exception as exc:
elapsed_ms = (time.perf_counter() - started_at) * 1000
tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
full_traceback = "".join(tb_lines)
error_msg = str(exc)[:1000]
error_type = type(exc).__name__
_write_log(
module=module,
action=action,
target_type=target_type,
target_id=str(target_id) if target_id else None,
target_name=str(target_name) if target_name else None,
status=OpStatus.FAILURE,
error_message=error_msg,
error_type=error_type,
error_traceback=full_traceback,
func_name=func_name,
detail=detail_str,
user=user,
request=request,
trace_id=trace_id,
duration_ms=elapsed_ms,
)
raise
return sync_wrapper # type: ignore
return decorator
def log_operation(
*,
module: str,
action: str,
target_type: str = "",
target_id: str = "",
target_name: str = "",
status: str = OpStatus.SUCCESS,
error_message: str = "",
error_type: str = "",
error_traceback: str = "",
detail: str = "",
func_name: str = "",
user: Optional[dict] = None,
request: Optional[Request] = None,
duration_ms: float = 0,
) -> None:
"""手动记录操作日志(不方便用装饰器时使用)"""
trace_id = request_id_var.get("-")
_write_log(
module=module,
action=action,
target_type=target_type,
target_id=target_id or None,
target_name=target_name or None,
status=status,
error_message=error_message,
error_type=error_type,
error_traceback=error_traceback,
func_name=func_name,
detail=detail,
user=user,
request=request,
trace_id=trace_id,
duration_ms=duration_ms,
)
def _build_detail(detail_params: Optional[list[str]], kwargs: dict) -> dict:
"""从 kwargs 中提取需要记录的参数"""
detail_dict = {}
if detail_params:
for p in detail_params:
val = kwargs.get(p)
if val is not None:
detail_dict[p] = str(val)[:200]
return detail_dict
def _extract_user(args: tuple, kwargs: dict) -> Optional[dict]:
"""从函数参数中提取 current_user dict"""
for arg in args:
if isinstance(arg, dict) and "id" in arg and "username" in arg:
return arg
for v in kwargs.values():
if isinstance(v, dict) and "id" in v and "username" in v:
return v
return None
def _extract_request(args: tuple) -> Optional[Request]:
"""从函数参数中提取 Request 对象"""
for arg in args:
if isinstance(arg, Request):
return arg
return None
def _get_param(kwargs: dict, key: str, default: str = "") -> str:
"""安全获取参数值"""
val = kwargs.get(key, default)
if val is None:
return default
return str(val)
def _write_log(
module: str,
action: str,
target_type: str,
target_id: Optional[str],
target_name: Optional[str],
status: str,
error_message: str,
error_type: str,
error_traceback: str,
func_name: str,
detail: str,
user: Optional[dict],
request: Optional[Request],
trace_id: str,
duration_ms: float,
) -> None:
"""写入操作日志到数据库"""
try:
store = get_platform_store()
log_id = new_id("op")
user_id = user.get("id") if user else None
username = user.get("username") if user else None
client_ip = None
req_method = None
req_path = None
if request:
client_ip = request.client.host if request.client else None
req_method = request.method
req_path = request.url.path
with store.connect() as conn:
conn.execute(
"""
INSERT INTO operation_logs
(id, user_id, username, module, action, target_type, target_id,
target_name, status, error_message, error_type, error_traceback,
func_name, detail, client_ip,
request_method, request_path, trace_id, duration_ms, create_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
log_id, user_id, username, module, action,
target_type or None, target_id, target_name,
status,
error_message[:1000] if error_message else None,
error_type or None,
error_traceback[:5000] if error_traceback else None,
func_name or None,
detail[:2000] if detail else None,
client_ip, req_method, req_path, trace_id,
round(duration_ms, 2), utcnow(),
),
)
except Exception:
logger.error("写入操作日志失败 module=%s action=%s", module, action, exc_info=True)

View File

@@ -534,7 +534,7 @@ class PlatformStore:
}, },
) )
for table in ("models", "datasets", "eval_tasks"): for table in ("models", "datasets", "eval_tasks"):
self._ensure_columns(conn, table, {"deleted_at": "TEXT", "deleted_by": "TEXT", "tenant_id": "TEXT", "project_id": "TEXT"}) self._ensure_columns(conn, table, {"deleted_at": "TEXT", "deleted_by": "TEXT", "tenant_id": "TEXT", "project_id": "TEXT", "created_by": "TEXT"})
self._ensure_columns( self._ensure_columns(
conn, conn,
"resource_replicas", "resource_replicas",
@@ -557,6 +557,25 @@ class PlatformStore:
conn.executescript(extra_path.read_text(encoding="utf-8")) conn.executescript(extra_path.read_text(encoding="utf-8"))
# data_convert_tasks 表补充 created_by 字段(用于数据隔离) # data_convert_tasks 表补充 created_by 字段(用于数据隔离)
self._ensure_columns(conn, "data_convert_tasks", {"created_by": "TEXT"}) self._ensure_columns(conn, "data_convert_tasks", {"created_by": "TEXT"})
# 修复历史数据:将 data_convert_tasks.created_by 回填到关联的 datasets 记录
try:
conn.execute("""
UPDATE datasets SET created_by = dct.created_by
FROM data_convert_tasks dct
WHERE datasets.task_id = dct.id
AND datasets.source = 'upload'
AND (datasets.created_by IS NULL OR datasets.created_by = '')
AND dct.created_by IS NOT NULL
AND dct.deleted_at IS NULL
""")
except Exception:
pass # 列不存在时忽略
# 清理超过 7 天的操作日志
try:
cutoff = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()
conn.execute("DELETE FROM operation_logs WHERE create_time < %s", (cutoff,))
except Exception:
pass # 表不存在时忽略,下次启动会建表
def _column_names(self, conn: PgConnection, table_name: str) -> set[str]: def _column_names(self, conn: PgConnection, table_name: str) -> set[str]:
columns = conn.execute( columns = conn.execute(

View File

@@ -74,3 +74,38 @@ CREATE TABLE IF NOT EXISTS retention_policies (
create_by TEXT, create_by TEXT,
updated_at TEXT updated_at TEXT
); );
-- ===================== 操作日志表 =====================
-- 记录用户在每个业务模块的详细操作(成功/失败、报错信息等)
CREATE TABLE IF NOT EXISTS operation_logs (
id TEXT PRIMARY KEY,
user_id TEXT, -- 操作用户 ID
username TEXT, -- 操作用户名(冗余,便于查询)
module TEXT, -- 业务模块fine-tune/model-eval/model-inference/dataset/data-process/data-convert/model-manage
action TEXT, -- 具体动作create/start/stop/delete/upload/convert 等
target_type TEXT, -- 资源类型
target_id TEXT, -- 资源 ID
target_name TEXT, -- 资源名称(便于阅读)
status TEXT NOT NULL, -- success / failure
error_message TEXT, -- 失败时的报错信息
detail TEXT, -- 操作详情 JSON参数摘要
client_ip TEXT, -- 客户端 IP
request_method TEXT, -- HTTP 方法
request_path TEXT, -- 请求路径
trace_id TEXT, -- 链路追踪 ID
duration_ms REAL, -- 耗时(ms)
create_time TEXT -- 操作时间
);
-- 幂等升级 operation_logs 表:新增字段(已存在则跳过)
ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS error_type TEXT; -- 异常类型RuntimeError / ValueError / ConnectionError
ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS error_traceback TEXT; -- 完整异常堆栈
ALTER TABLE operation_logs ADD COLUMN IF NOT EXISTS func_name TEXT; -- 出错的函数名
-- 索引
CREATE INDEX IF NOT EXISTS idx_op_user_time ON operation_logs(user_id, create_time DESC);
CREATE INDEX IF NOT EXISTS idx_op_module_time ON operation_logs(module, create_time DESC);
CREATE INDEX IF NOT EXISTS idx_op_status ON operation_logs(status, create_time DESC);
CREATE INDEX IF NOT EXISTS idx_op_action ON operation_logs(action);
CREATE INDEX IF NOT EXISTS idx_op_create_time ON operation_logs(create_time DESC);
CREATE INDEX IF NOT EXISTS idx_op_error_type ON operation_logs(error_type);

View File

@@ -10,6 +10,7 @@ from fastapi.responses import FileResponse
from app.api.v1.endpoints.platform import ok, fail from app.api.v1.endpoints.platform import ok, fail
from app.core.auth import get_current_user, is_admin from app.core.auth import get_current_user, is_admin
from app.core.op_log import op_log, OpModule, OpAction
from app.db.platform_store import get_platform_store, new_id from app.db.platform_store import get_platform_store, new_id
@@ -89,6 +90,7 @@ def list_tasks(
@router.post("") @router.post("")
@op_log(module=OpModule.DATA_CONVERT, action=OpAction.CREATE, target_type="convert_task", target_name_param="name")
def create_task( def create_task(
payload: dict[str, Any] = Body(...), payload: dict[str, Any] = Body(...),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
@@ -133,6 +135,7 @@ def get_task(
@router.post("/{task_id}/source-files") @router.post("/{task_id}/source-files")
@op_log(module=OpModule.DATA_CONVERT, action=OpAction.UPLOAD, target_type="convert_task", target_name_param="task_id")
async def upload_source_files( async def upload_source_files(
task_id: str, task_id: str,
files: list[UploadFile] = File(...), files: list[UploadFile] = File(...),
@@ -205,6 +208,7 @@ async def upload_source_files(
"size": f"{size_bytes} B", "size": f"{size_bytes} B",
"count": output_count, "count": output_count,
"description": f"由数据类型转换任务 {task_id} 自动导入", "description": f"由数据类型转换任务 {task_id} 自动导入",
"created_by": task.get("created_by") or current_user.get("id"),
}) })
dataset_id = dataset["id"] dataset_id = dataset["id"]
with store.connect() as conn: with store.connect() as conn:
@@ -226,6 +230,7 @@ async def upload_source_files(
@router.post("/{task_id}/run") @router.post("/{task_id}/run")
@op_log(module=OpModule.DATA_CONVERT, action=OpAction.CONVERT, target_type="convert_task", target_name_param="task_id")
def run_convert( def run_convert(
task_id: str, task_id: str,
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
@@ -332,6 +337,7 @@ def import_as_dataset(
"size": f"{size_bytes} B", "size": f"{size_bytes} B",
"count": task["output_count"], "count": task["output_count"],
"description": description, "description": description,
"created_by": task.get("created_by") or (current_user.get("id") if current_user else None),
}) })
dataset_id = dataset["id"] dataset_id = dataset["id"]
with store.connect() as conn: with store.connect() as conn:
@@ -340,6 +346,7 @@ def import_as_dataset(
@router.delete("/{task_id}") @router.delete("/{task_id}")
@op_log(module=OpModule.DATA_CONVERT, action=OpAction.DELETE, target_type="convert_task", target_name_param="task_id")
def delete_task( def delete_task(
task_id: str, task_id: str,
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),

View File

@@ -122,3 +122,143 @@ def audit_logs_export(
media_type="text/csv", media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=audit_logs.csv"}, headers={"Content-Disposition": "attachment; filename=audit_logs.csv"},
) )
# ===================== 操作日志 =====================
@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:
"""操作日志查询:按用户/模块/动作/状态/关键字/时间范围分页过滤。"""
if not is_admin(current_user):
from app.api.v1.endpoints.platform import fail
raise fail(403, "admin permission required")
store = get_platform_store()
conditions = []
params: list = []
if user_id:
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:
"""操作日志统计:总操作数、成功数、失败数、失败率、各模块失败分布、最近错误列表。"""
if not is_admin(current_user):
from app.api.v1.endpoints.platform import fail
raise fail(403, "admin permission required")
store = get_platform_store()
conditions = []
params: list = []
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:
"""返回操作日志中出现的模块列表(用于筛选下拉框)。"""
if not is_admin(current_user):
from app.api.v1.endpoints.platform import fail
raise fail(403, "admin permission required")
store = get_platform_store()
with store.connect() as conn:
rows = conn.execute(
"SELECT DISTINCT module FROM operation_logs WHERE module IS NOT NULL ORDER BY module"
).fetchall()
modules = [{"value": r["module"], "label": r["module"]} for r in rows]
return {"code": 0, "message": "ok", "data": modules}

View File

@@ -0,0 +1,58 @@
import { get } from '../request'
export interface OperationLog {
id: string
user_id?: string
username?: string
module?: string
action?: string
target_type?: string
target_id?: string
target_name?: string
status: string
error_message?: string
error_type?: string
error_traceback?: string
func_name?: string
detail?: string
client_ip?: string
request_method?: string
request_path?: string
trace_id?: string
duration_ms?: number
create_time?: string
}
export interface OperationLogQuery {
user_id?: string
module?: string
action?: string
status?: string
keyword?: string
start_time?: string
end_time?: string
limit?: number
offset?: number
}
export interface OperationLogStats {
total: number
success: number
failure: number
failure_rate: number
module_failures: { module: string; count: number }[]
error_types: { type: string; count: number }[]
recent_errors: OperationLog[]
}
/** 操作日志查询 */
export const getOperationLogs = (query: OperationLogQuery = {}) =>
get<{ items: OperationLog[]; total: number }>('/system/operation-logs', query)
/** 操作日志统计 */
export const getOperationLogStats = (params?: { start_time?: string; end_time?: string }) =>
get<OperationLogStats>('/system/operation-logs/stats', params)
/** 获取操作日志中出现的模块列表 */
export const getOperationLogModules = () =>
get<{ value: string; label: string }[]>('/system/operation-logs/modules')

View File

@@ -81,6 +81,7 @@ const menuGroups: MenuGroup[] = [
{ key: 'projects', label: '项目空间', icon: 'fa-folder', to: '/projects', permission: 'user-settings' }, { key: 'projects', label: '项目空间', icon: 'fa-folder', to: '/projects', permission: 'user-settings' },
{ key: 'resource-acl', label: '资源授权', icon: 'fa-key', to: '/resource-acl', permission: 'user-settings' }, { key: 'resource-acl', label: '资源授权', icon: 'fa-key', to: '/resource-acl', permission: 'user-settings' },
{ key: 'audit-logs', label: '审计日志', icon: 'fa-history', to: '/audit-logs', permission: 'user-settings' }, { key: 'audit-logs', label: '审计日志', icon: 'fa-history', to: '/audit-logs', permission: 'user-settings' },
{ key: 'operation-logs', label: '操作日志', icon: 'fa-list', to: '/operation-logs', permission: 'user-settings' },
{ key: 'approval-templates', label: '审批模板', icon: 'fa-list-alt', to: '/approval-templates', permission: 'user-settings' }, { key: 'approval-templates', label: '审批模板', icon: 'fa-list-alt', to: '/approval-templates', permission: 'user-settings' },
{ key: 'approval-instances', label: '审批中心', icon: 'fa-check-square', to: '/approval-instances', permission: 'user-settings' }, { key: 'approval-instances', label: '审批中心', icon: 'fa-check-square', to: '/approval-instances', permission: 'user-settings' },
], ],

View File

@@ -62,6 +62,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/audit/AuditLogView.vue'), component: () => import('@/views/audit/AuditLogView.vue'),
meta: { title: '审计日志', permission: 'user-settings' }, meta: { title: '审计日志', permission: 'user-settings' },
}, },
{
path: 'operation-logs',
name: 'operation-logs',
component: () => import('@/views/audit/OperationLogView.vue'),
meta: { title: '操作日志', permission: 'user-settings' },
},
{ {
path: 'approval-templates', path: 'approval-templates',
name: 'approval-templates', name: 'approval-templates',
@@ -355,6 +361,7 @@ const permissionBySegment: Record<string, PermissionCode> = {
tenants: 'user-settings', tenants: 'user-settings',
projects: 'user-settings', projects: 'user-settings',
'audit-logs': 'user-settings', 'audit-logs': 'user-settings',
'operation-logs': 'user-settings',
'approval-templates': 'user-settings', 'approval-templates': 'user-settings',
'approval-instances': 'user-settings', 'approval-instances': 'user-settings',
'resource-acl': 'user-settings', 'resource-acl': 'user-settings',

View File

@@ -0,0 +1,437 @@
<script setup lang="ts">
import { onMounted, reactive, ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import {
getOperationLogs,
getOperationLogStats,
type OperationLog,
type OperationLogQuery,
type OperationLogStats,
} from '@/api/modules/operation-log'
const loading = ref(false)
const statsLoading = ref(false)
const logs = ref<OperationLog[]>([])
const total = ref(0)
const stats = ref<OperationLogStats | null>(null)
// 默认筛选:只看失败
const query = reactive<OperationLogQuery>({
user_id: '',
module: '',
action: '',
status: 'failure',
keyword: '',
start_time: '',
end_time: '',
limit: 50,
offset: 0,
})
const timeRange = ref<[string, string] | null>(null)
// 模块选项
const moduleOptions = [
{ value: 'fine-tune', label: '模型训练' },
{ value: 'model-eval', label: '模型评测' },
{ value: 'model-inference', label: '模型推理' },
{ value: 'model-manage', label: '模型管理' },
{ value: 'dataset', label: '数据集' },
{ value: 'data-process', label: '数据处理' },
{ value: 'data-convert', label: '数据类型转换' },
{ value: 'compute', label: '算力节点' },
{ value: 'system', label: '系统' },
]
// 动作选项
const actionOptions = [
{ value: 'create', label: '创建' },
{ value: 'start', label: '启动' },
{ value: 'stop', label: '停止' },
{ value: 'delete', label: '删除' },
{ value: 'upload', label: '上传' },
{ value: 'convert', label: '转换' },
{ value: 'merge', label: '合并' },
{ value: 'download', label: '导出' },
{ value: 'import', label: '导入' },
{ value: 'publish', label: '发布' },
{ value: 'request', label: '系统请求' },
]
// 状态选项
const statusOptions = [
{ value: '', label: '全部' },
{ value: 'failure', label: '仅失败' },
{ value: 'success', label: '仅成功' },
]
// 详情弹窗
const detailVisible = ref(false)
const detailLog = ref<OperationLog | null>(null)
// 是否只看失败
const onlyFailures = computed(() => query.status === 'failure')
function applyTimeRange() {
if (timeRange.value && timeRange.value.length === 2) {
query.start_time = timeRange.value[0]
query.end_time = timeRange.value[1]
} else {
query.start_time = ''
query.end_time = ''
}
}
async function load() {
loading.value = true
try {
const params: OperationLogQuery = { ...query }
Object.keys(params).forEach((k) => {
if (params[k as keyof OperationLogQuery] === '' || params[k as keyof OperationLogQuery] === null) {
delete params[k as keyof OperationLogQuery]
}
})
const res = await getOperationLogs(params)
logs.value = res.items
total.value = res.total
} catch {
ElMessage.error('加载操作日志失败')
} finally {
loading.value = false
}
}
async function loadStats() {
statsLoading.value = true
try {
const params: { start_time?: string; end_time?: string } = {}
if (query.start_time) params.start_time = query.start_time
if (query.end_time) params.end_time = query.end_time
stats.value = await getOperationLogStats(params)
} catch {
// 静默失败
} finally {
statsLoading.value = false
}
}
function handleSearch() {
query.offset = 0
load()
loadStats()
}
function handlePageChange(page: number) {
query.offset = (page - 1) * (query.limit || 50)
load()
}
function showDetail(row: OperationLog) {
detailLog.value = row
detailVisible.value = true
}
function statusTagType(status: string) {
return status === 'success' ? 'success' : 'danger'
}
function moduleLabel(module?: string) {
return moduleOptions.find((m) => m.value === module)?.label || module || '-'
}
function actionLabel(action?: string) {
return actionOptions.find((a) => a.value === action)?.label || action || '-'
}
function toggleOnlyFailures() {
query.status = onlyFailures.value ? '' : 'failure'
handleSearch()
}
onMounted(() => {
load()
loadStats()
})
</script>
<template>
<div class="page">
<div class="page-header">
<h2 class="page-title">系统操作日志</h2>
<el-button :type="onlyFailures ? 'danger' : 'default'" @click="toggleOnlyFailures">
{{ onlyFailures ? '只看失败 ' : '显示全部' }}
</el-button>
</div>
<!-- 统计卡片 -->
<el-row :gutter="12" class="stats-row" v-loading="statsLoading">
<el-col :span="4">
<el-card class="stat-card" shadow="hover">
<div class="stat-value">{{ stats?.total ?? '-' }}</div>
<div class="stat-label">总操作数</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card class="stat-card" shadow="hover">
<div class="stat-value" style="color: #67c23a">{{ stats?.success ?? '-' }}</div>
<div class="stat-label">成功</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card class="stat-card" shadow="hover">
<div class="stat-value" style="color: #f56c6c">{{ stats?.failure ?? '-' }}</div>
<div class="stat-label">失败</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card class="stat-card" shadow="hover">
<div class="stat-value" :style="{ color: (stats?.failure_rate ?? 0) > 5 ? '#f56c6c' : '#909399' }">
{{ stats?.failure_rate ?? '-' }}%
</div>
<div class="stat-label">失败率</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="stat-card" shadow="hover">
<div class="stat-label" style="margin-bottom: 4px">各模块失败分布</div>
<div class="stat-modules">
<el-tag
v-for="m in stats?.module_failures || []"
:key="m.module"
type="danger"
size="small"
style="margin-right: 4px; margin-bottom: 4px"
>
{{ moduleOptions.find((opt) => opt.value === m.module)?.label || m.module }}: {{ m.count }}
</el-tag>
<span v-if="!stats?.module_failures?.length" style="color: #c0c4cc">暂无失败</span>
</div>
</el-card>
</el-col>
</el-row>
<!-- 筛选区域 -->
<el-card class="filter-card">
<el-form :inline="true">
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部状态" clearable style="width: 120px" @change="handleSearch">
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</el-form-item>
<el-form-item label="模块">
<el-select v-model="query.module" placeholder="全部模块" clearable style="width: 150px" @change="handleSearch">
<el-option v-for="m in moduleOptions" :key="m.value" :label="m.label" :value="m.value" />
</el-select>
</el-form-item>
<el-form-item label="动作">
<el-select v-model="query.action" placeholder="全部动作" clearable style="width: 120px" @change="handleSearch">
<el-option v-for="a in actionOptions" :key="a.value" :label="a.label" :value="a.value" />
</el-select>
</el-form-item>
<el-form-item label="报错关键字">
<el-input
v-model="query.keyword"
placeholder="搜索报错信息/异常类型"
clearable
style="width: 220px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="用户">
<el-input v-model="query.user_id" placeholder="用户ID/用户名" clearable style="width: 140px" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="timeRange"
type="datetimerange"
value-format="YYYY-MM-DDTHH:mm:ss"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
clearable
style="width: 360px"
@change="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 日志列表 -->
<el-table
:data="logs"
v-loading="loading"
border
stripe
class="log-table"
:row-class-name="({ row }) => row.status === 'failure' ? 'failure-row' : ''"
>
<el-table-column prop="create_time" label="时间" width="170" align="center">
<template #default="{ row }">
{{ row.create_time ? new Date(row.create_time).toLocaleString('zh-CN') : '-' }}
</template>
</el-table-column>
<el-table-column label="用户" width="110" align="center" show-overflow-tooltip>
<template #default="{ row }">{{ row.username || row.user_id || '-' }}</template>
</el-table-column>
<el-table-column label="模块" width="110" align="center">
<template #default="{ row }">{{ moduleLabel(row.module) }}</template>
</el-table-column>
<el-table-column label="动作" width="90" align="center">
<template #default="{ row }">{{ actionLabel(row.action) }}</template>
</el-table-column>
<el-table-column label="目标" min-width="140" align="center" show-overflow-tooltip>
<template #default="{ row }">{{ row.target_name || row.target_id || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small" effect="dark">
{{ row.status === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="异常类型" width="160" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-tag v-if="row.error_type" type="danger" size="small" effect="plain">{{ row.error_type }}</el-tag>
<span v-else style="color: #c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="报错信息" min-width="250" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.error_message" class="error-text">{{ row.error_message }}</span>
<span v-else style="color: #c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="耗时(ms)" width="90" align="center">
<template #default="{ row }">
<span :style="{ color: row.duration_ms > 3000 ? '#e6a23c' : '' }">
{{ row.duration_ms ? row.duration_ms : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="showDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pager">
<el-pagination
background
layout="total, prev, pager, next"
:total="total"
:page-size="query.limit"
:current-page="Math.floor((query.offset || 0) / (query.limit || 50)) + 1"
@current-change="handlePageChange"
/>
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="操作日志详情" width="850px" top="5vh">
<el-descriptions :column="2" border v-if="detailLog">
<el-descriptions-item label="时间">
{{ detailLog.create_time ? new Date(detailLog.create_time).toLocaleString('zh-CN') : '-' }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTagType(detailLog.status)" size="small" effect="dark">
{{ detailLog.status === 'success' ? '成功' : '失败' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用户">{{ detailLog.username || detailLog.user_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="IP">{{ detailLog.client_ip || '-' }}</el-descriptions-item>
<el-descriptions-item label="模块">{{ moduleLabel(detailLog.module) }}</el-descriptions-item>
<el-descriptions-item label="动作">{{ actionLabel(detailLog.action) }}</el-descriptions-item>
<el-descriptions-item label="目标类型">{{ detailLog.target_type || '-' }}</el-descriptions-item>
<el-descriptions-item label="目标ID">{{ detailLog.target_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="目标名称">{{ detailLog.target_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="耗时">{{ detailLog.duration_ms ? detailLog.duration_ms + ' ms' : '-' }}</el-descriptions-item>
<el-descriptions-item label="出错函数" :span="2">
<span v-if="detailLog.func_name" class="func-name">{{ detailLog.func_name }}</span>
<span v-else style="color: #c0c4cc">-</span>
</el-descriptions-item>
<el-descriptions-item label="请求方法">{{ detailLog.request_method || '-' }}</el-descriptions-item>
<el-descriptions-item label="请求路径">{{ detailLog.request_path || '-' }}</el-descriptions-item>
<el-descriptions-item label="Trace ID" :span="2">{{ detailLog.trace_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="异常类型" :span="2">
<el-tag v-if="detailLog.error_type" type="danger" size="small" effect="dark">{{ detailLog.error_type }}</el-tag>
<span v-else style="color: #c0c4cc">-</span>
</el-descriptions-item>
<el-descriptions-item label="报错信息" :span="2">
<div v-if="detailLog.error_message" class="error-box">{{ detailLog.error_message }}</div>
<span v-else style="color: #c0c4cc">-</span>
</el-descriptions-item>
<el-descriptions-item label="异常堆栈 (Traceback)" :span="2">
<pre v-if="detailLog.error_traceback" class="traceback-box">{{ detailLog.error_traceback }}</pre>
<span v-else style="color: #c0c4cc">无堆栈信息</span>
</el-descriptions-item>
<el-descriptions-item label="操作详情" :span="2">
<pre v-if="detailLog.detail" class="detail-box">{{ detailLog.detail }}</pre>
<span v-else style="color: #c0c4cc">-</span>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.page { padding: 16px; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.page-title { margin: 0; font-size: 18px; }
.stats-row { margin-bottom: 16px; }
.stat-card {
text-align: center;
.stat-value { font-size: 24px; font-weight: bold; }
.stat-label { font-size: 12px; color: #909399; }
.stat-modules { text-align: left; min-height: 40px; }
}
.filter-card { margin-bottom: 16px; }
.log-table { margin-top: 8px; }
.pager { margin-top: 12px; text-align: right; }
.error-text { color: #f56c6c; font-weight: 500; }
.func-name {
font-family: 'Courier New', monospace;
font-size: 12px;
color: #e6a23c;
background: #fdf6ec;
padding: 2px 6px;
border-radius: 3px;
}
.error-box {
color: #f56c6c;
background: #fef0f0;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
word-break: break-all;
}
.traceback-box {
background: #2d2d2d;
color: #f48771;
padding: 12px;
border-radius: 4px;
font-size: 12px;
font-family: 'Courier New', monospace;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
}
.detail-box {
background: #f5f7fa;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
:deep(.failure-row) {
background-color: #fef0f0 !important;
}
:deep(.failure-row:hover > td) {
background-color: #fde2e2 !important;
}
</style>