diff --git a/backend/app/api/v1/endpoints/data_process.py b/backend/app/api/v1/endpoints/data_process.py index 0b52c1d..3e8611f 100644 --- a/backend/app/api/v1/endpoints/data_process.py +++ b/backend/app/api/v1/endpoints/data_process.py @@ -34,6 +34,7 @@ from fastapi import ( from fastapi.responses import StreamingResponse from psycopg.rows import dict_row +from app.core.auth import filter_accessible_resource_ids, get_current_user, is_admin from app.modules.data_process.algorithms import ( ParsedText, canonical_record_json, @@ -815,18 +816,33 @@ def list_tasks( keyword: str | None = Query(default=None), status: DataProcessStatus | None = Query(default=None), process_type: ProcessType | None = Query(default=None), + tenant_id: str | None = Query(default=None), + project_id: str | None = Query(default=None), store: DataProcessStore = Depends(get_data_process_store), + current_user: dict = Depends(get_current_user), ) -> dict[str, Any]: with api_errors(): - return ok( - store.list_tasks( - page=page, - page_size=page_size, - keyword=keyword, - status=status, - process_type=process_type, - ) + tasks = store.list_tasks( + page=page, + page_size=page_size, + keyword=keyword, + status=status, + process_type=process_type, + tenant_id=tenant_id, + project_id=project_id, ) + # #4 资源 ACL 过滤:admin 放行,普通用户只看到自己被授权的数据处理任务 + items = tasks.get("items", []) + if not is_admin(current_user) and items: + accessible_ids = set( + filter_accessible_resource_ids( + "data-process", [t["id"] for t in items], current_user + ) + ) + items = [t for t in items if t["id"] in accessible_ids] + tasks["items"] = items + tasks["total"] = len(items) + return ok(tasks) @router.post("") @@ -2282,13 +2298,11 @@ def regenerate_results_batch( max_keepalive_connections=RESULT_REGENERATION_CONCURRENCY, ) # httpx.Client 支持跨线程复用,批次内共享连接池可减少重复建连开销。 - with ( - httpx.Client(timeout=model_timeout, limits=model_limits) as model_client, - ThreadPoolExecutor( - max_workers=min(RESULT_REGENERATION_CONCURRENCY, len(prepared)), - thread_name_prefix="data-result-regeneration", - ) as executor, - ): + with httpx.Client(timeout=model_timeout, limits=model_limits) as model_client, \ + ThreadPoolExecutor( + max_workers=min(RESULT_REGENERATION_CONCURRENCY, len(prepared)), + thread_name_prefix="data-result-regeneration", + ) as executor: futures = { executor.submit( _regenerate_result_in_place, diff --git a/backend/app/api/v1/endpoints/platform.py b/backend/app/api/v1/endpoints/platform.py index 4ee8131..40b0e9e 100644 --- a/backend/app/api/v1/endpoints/platform.py +++ b/backend/app/api/v1/endpoints/platform.py @@ -2,14 +2,16 @@ import json import uuid +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any -from fastapi import APIRouter, BackgroundTasks, Body, File, HTTPException, Query, UploadFile +from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, HTTPException, Query, Request, UploadFile from fastapi.responses import PlainTextResponse, StreamingResponse import httpx +from app.core.auth import filter_accessible_resource_ids, get_current_user, has_resource_access, is_admin from app.core.config import get_settings from app.db.platform_store import get_platform_store from app.modules.compute_gateway.client import ComputeNodeClient @@ -35,6 +37,35 @@ def fail(status_code: int, message: str) -> HTTPException: return HTTPException(status_code=status_code, detail={"code": status_code, "message": message, "data": None}) +def _require_approval_or_admin( + resource_type: str, + resource_id: str, + current_user: dict[str, Any], + action_desc: str = "", +) -> dict[str, Any] | None: + """ + 高风险操作审批旁路: + - admin 用户直接放行(返回 None) + - 普通用户创建审批实例,返回审批待定响应(code=202,非 None) + code=202 使前端响应拦截器走业务错误分支,弹提示并 reject, + 避免前端误认为删除成功。 + """ + if is_admin(current_user): + return None + store = get_platform_store() + instance = store.create_approval_instance({ + "resource_type": resource_type, + "resource_id": resource_id, + "applicant_id": current_user.get("id"), + "template_id": None, + }) + return { + "code": 202, + "message": f"操作已提交审批,等待管理员批准:{action_desc}", + "data": {"approval_required": True, "approval_id": instance["id"]}, + } + + def _node_for_task(task: dict[str, Any]) -> dict[str, Any] | None: return next((node for node in get_platform_store().compute_nodes() if node["id"] == task.get("compute_node_id")), None) @@ -231,8 +262,18 @@ async def login(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: @router.get("/me") -async def me() -> dict[str, Any]: - return ok(get_platform_store().users()[0]) +async def me(request: Request) -> dict[str, Any]: + """根据 Authorization header 中的 token 返回当前登录用户信息""" + store = get_platform_store() + auth = request.headers.get("Authorization", "") + token = auth.replace("Bearer ", "").strip() + # token 格式: platform-token-{user_id} + if token.startswith("platform-token-"): + user_id = token[len("platform-token-"):] + for u in store.users(): + if u.get("id") == user_id: + return ok(u) + raise fail(401, "invalid or missing token") @router.get("/dashboard/overview") @@ -251,6 +292,183 @@ async def dashboard_overview() -> dict[str, Any]: ) +@router.get("/dashboard/stats") +async def dashboard_stats() -> dict[str, Any]: + """看板聚合数据:基于平台真实数据;缺项做合理近似。""" + store = get_platform_store() + tasks = store.tasks() + users = store.users() + nodes = store.compute_nodes() + datasets = store.datasets() + eval_tasks = store.eval_tasks() + # 数据处理任务总数(来自 data_process 模块) + try: + from app.modules.data_process.store import get_data_process_store + + dp_store = get_data_process_store() + dp_result = dp_store.list_tasks(page=1, page_size=1) + dp_count = int(dp_result.get("total", 0)) + except Exception: + dp_count = 0 + + running_statuses = {"syncing", "queued", "running"} + running_ft = [t for t in tasks if t.get("status") in running_statuses] + failed_ft = [t for t in tasks if t.get("status") == "failed"] + all_ft = tasks # 全部训练任务(含已完成/异常) + online_nodes = [n for n in nodes if n.get("scheduler_status") == "online"] + + # 近 7 天训练统计(按创建日期分桶) + now = datetime.now(timezone.utc) + train_by_day: dict[str, int] = {} + for t in tasks: + ct = t.get("create_time") + if ct: + train_by_day[ct[:10]] = train_by_day.get(ct[:10], 0) + 1 + training_7d = [] + for i in range(6, -1, -1): + day = (now - timedelta(days=i)).strftime("%Y-%m-%d") + training_7d.append( + { + "date": day[5:], + "train": train_by_day.get(day, 0), + "gpu": sum(len(t.get("gpus") or []) for t in running_ft), + "accuracy": None, + } + ) + + # 服务状态 —— 与界面实际数据对齐 + service_status = [ + { + "type": "模型推理", + "status": "error" if (nodes and not online_nodes) else ("busy" if (nodes and len(online_nodes) < len(nodes)) else "normal"), + "count": len(online_nodes), + }, + { + "type": "模型训练", + "status": "error" if failed_ft else ("busy" if running_ft else "normal"), + "count": len(all_ft), + }, + { + "type": "模型评测", + "status": "normal", + "count": len(eval_tasks), + }, + { + "type": "数据处理", + "status": "normal" if not failed_ft else "busy", + "count": dp_count, + }, + ] + + # 训练任务状态归一化 + status_map = { + "syncing": "running", + "queued": "running", + "running": "running", + "pending": "pending", + "paused": "pending", + "completed": "completed", + "failed": "failed", + "error": "failed", + "cancelled": "failed", + "stopped": "failed", + } + training_tasks = [ + { + "id": t.get("id"), + "name": t.get("name"), + "status": status_map.get(t.get("status"), "pending"), + "train_type": t.get("train_type") or t.get("trainType") or "", + "train_method": t.get("train_method") or t.get("trainMethod") or "", + "base_model": t.get("base_model") or t.get("baseModel") or "", + "progress": t.get("progress", 0), + "accuracy": t.get("accuracy"), + "started_at": (t.get("create_time") or "")[:16], + } + for t in tasks[:8] + ] + + # 用户操作分布:统计平台全部操作(含治理模块) + MODULE_LABELS = [ + ("data-process", "数据处理"), + ("data_process", "数据处理"), + ("dataset", "数据集管理"), + ("fine-tune", "模型训练"), + ("fine_tune", "模型训练"), + ("model-eval", "模型评测"), + ("eval", "模型评测"), + ("model-inference", "模型推理"), + ("inference", "模型推理"), + ("model-manage", "模型管理"), + ("model", "模型管理"), + ("trained", "模型管理"), + # 治理模块操作 + ("tenant", "租户与项目"), + ("project", "租户与项目"), + ("approval", "租户与项目"), + ("acl", "租户与项目"), + ("user", "用户管理"), + ("role", "用户管理"), + ] + OP_ORDER = [ + "数据集管理", + "数据处理", + "模型训练", + "模型评测", + "模型推理", + "模型管理", + "租户与项目", + "用户管理", + ] + + def _op_module(action: str) -> str | None: + a = (action or "").lower() + for prefix, label in MODULE_LABELS: + if a.startswith(prefix): + return label + return None + + audit = store.audit_logs(limit=1000) + op_counter: dict[str, int] = {label: 0 for label in OP_ORDER} + for log in audit.get("items", []): + label = _op_module(log.get("action") or "") + if label: + op_counter[label] += 1 + operation_distribution = [{"name": k, "value": v} for k, v in op_counter.items()] + + # 最近登录用户 + recent = sorted( + [u for u in users if u.get("last_login")], + key=lambda u: u["last_login"], + reverse=True, + )[:5] + recent_login_users = [ + { + "user": u.get("display_name") or u.get("username"), + "role": u.get("role"), + "last_login": (u.get("last_login") or "")[:16], + } + for u in recent + ] + + # 登录时长排行(本月) + login_duration_rank = store.login_duration_rank() + + return ok( + { + "online_services": sum(s["count"] for s in service_status), + "running_tasks": len(running_ft), + "pending_alerts": 0, + "training_7d": training_7d, + "service_status": service_status, + "training_tasks": training_tasks, + "operation_distribution": operation_distribution, + "login_duration_rank": login_duration_rank, + "recent_login_users": recent_login_users, + } + ) + + @router.get("/system-info") async def system_info() -> dict[str, Any]: return ok(get_platform_store().system_info()) @@ -285,6 +503,21 @@ async def delete_user(user_id: str, current_username: str | None = Query(default raise fail(400, str(exc)) +@router.post("/users/{user_id}/reset-password") +async def reset_user_password( + user_id: str, + payload: dict[str, Any] = Body(default={}), +) -> dict[str, Any]: + new_password = payload.get("password") or "Platform@123" + try: + get_platform_store().reset_password(user_id, new_password) + return ok({"reset": user_id}) + except KeyError: + raise fail(404, "user not found") + except ValueError as exc: + raise fail(400, str(exc)) + + @router.get("/model-manage/local-models") async def local_models() -> dict[str, Any]: store = get_platform_store() @@ -348,8 +581,13 @@ async def model_by_name(name: str) -> dict[str, Any]: @router.get("/model-manage") -async def model_list() -> dict[str, Any]: - return ok(get_platform_store().models()) +async def model_list(current_user: dict = Depends(get_current_user)) -> dict[str, Any]: + models = get_platform_store().models() + if current_user.get("role") == "admin" or current_user.get("protected"): + return ok(models) + # 普通用户只返回有 ACL 授权的模型 + accessible = set(filter_accessible_resource_ids("model", [m["id"] for m in models], current_user)) + return ok([m for m in models if m["id"] in accessible]) @router.post("/model-manage") @@ -365,11 +603,14 @@ async def create_model(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: @router.get("/model-manage/{model_id}") -async def model_detail(model_id: str) -> dict[str, Any]: +async def model_detail(model_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]: try: - return ok(get_platform_store().model(model_id)) + model = get_platform_store().model(model_id) except KeyError: 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) @router.put("/model-manage/{model_id}") @@ -389,7 +630,12 @@ async def update_model_purpose(model_id: str, payload: dict[str, Any] = Body(... @router.delete("/model-manage/{model_id}") -async def delete_model(model_id: str) -> 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") + pending = _require_approval_or_admin("model", model_id, current_user, f"删除模型 {model_id}") + if pending: + return pending get_platform_store().delete_model(model_id) return ok({"deleted": model_id}) @@ -651,8 +897,12 @@ async def download_dataset_file(dataset_id: str, file_id: str, version_id: str | @router.get("/dataset-manage") -async def dataset_list() -> dict[str, Any]: - return ok(get_platform_store().datasets()) +async def dataset_list(current_user: dict = Depends(get_current_user)) -> dict[str, Any]: + datasets = get_platform_store().datasets() + if current_user.get("role") == "admin" or current_user.get("protected"): + return ok(datasets) + accessible = set(filter_accessible_resource_ids("dataset", [d["id"] for d in datasets], current_user)) + return ok([d for d in datasets if d["id"] in accessible]) @router.post("/dataset-manage") @@ -662,11 +912,14 @@ async def create_dataset(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: @router.get("/dataset-manage/{dataset_id}") -async def dataset_detail(dataset_id: str) -> dict[str, Any]: +async def dataset_detail(dataset_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]: try: - return ok(get_platform_store().dataset(dataset_id)) + dataset = get_platform_store().dataset(dataset_id) except KeyError: raise fail(404, "dataset not found") + if not has_resource_access("dataset", dataset_id, current_user, "read"): + raise fail(403, "no permission to access this dataset") + return ok(dataset) @router.put("/dataset-manage/{dataset_id}") @@ -678,7 +931,12 @@ async def update_dataset(dataset_id: str, payload: dict[str, Any] = Body(...)) - @router.delete("/dataset-manage/{dataset_id}") -async def delete_dataset(dataset_id: str) -> 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"): + raise fail(403, "no permission to delete this dataset") + pending = _require_approval_or_admin("dataset", dataset_id, current_user, f"删除数据集 {dataset_id}") + if pending: + return pending get_platform_store().delete_dataset(dataset_id) return ok({"deleted": dataset_id}) @@ -703,8 +961,12 @@ async def tensorboard_start() -> dict[str, Any]: @router.get("/fine-tune") -async def fine_tune_list() -> dict[str, Any]: - return ok(get_platform_store().tasks()) +async def fine_tune_list(current_user: dict = Depends(get_current_user)) -> dict[str, Any]: + tasks = get_platform_store().tasks() + if current_user.get("role") == "admin" or current_user.get("protected"): + return ok(tasks) + accessible = set(filter_accessible_resource_ids("fine-tune", [t["id"] for t in tasks], current_user)) + return ok([t for t in tasks if t["id"] in accessible]) @router.post("/fine-tune") @@ -897,7 +1159,12 @@ async def retry_fine_tune(task_id: str, payload: dict[str, Any] | None = Body(de @router.delete("/fine-tune/{task_id}") -async def delete_fine_tune(task_id: str) -> 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"): + raise fail(403, "no permission to delete this task") + pending = _require_approval_or_admin("fine-tune", task_id, current_user, f"删除训练任务 {task_id}") + if pending: + return pending get_platform_store().delete_task(task_id) return ok({"deleted": task_id}) @@ -937,16 +1204,23 @@ async def fine_tune_metrics(task_id: str) -> dict[str, Any]: @router.get("/model-eval") -async def model_eval_list() -> dict[str, Any]: - return ok(get_platform_store().eval_tasks()) +async def model_eval_list(current_user: dict = Depends(get_current_user)) -> dict[str, Any]: + tasks = get_platform_store().eval_tasks() + if current_user.get("role") == "admin" or current_user.get("protected"): + return ok(tasks) + accessible = set(filter_accessible_resource_ids("eval", [t["id"] for t in tasks], current_user)) + return ok([t for t in tasks if t["id"] in accessible]) @router.get("/model-eval/{task_id}") -async def model_eval_detail(task_id: str) -> dict[str, Any]: +async def model_eval_detail(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]: try: - return ok(get_platform_store().eval_task(task_id)) + task = get_platform_store().eval_task(task_id) except KeyError: raise fail(404, "eval task not found") + if not has_resource_access("eval", task_id, current_user, "read"): + raise fail(403, "no permission to access this eval task") + return ok(task) @router.post("/model-eval/start") @@ -956,7 +1230,12 @@ async def model_eval_start(payload: dict[str, Any] = Body(...)) -> dict[str, Any @router.delete("/model-eval/{task_id}") -async def model_eval_delete(task_id: str) -> dict[str, Any]: +async def model_eval_delete(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]: + if not has_resource_access("eval", task_id, current_user, "delete"): + raise fail(403, "no permission to delete this eval task") + pending = _require_approval_or_admin("eval", task_id, current_user, f"删除评测任务 {task_id}") + if pending: + return pending get_platform_store().delete_eval_task(task_id) return ok({"deleted": task_id}) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 07d41ba..35017f2 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -3,8 +3,20 @@ from app.api.v1.endpoints.data_process import router as data_process_router from app.api.v1.endpoints.platform import router as platform_router from app.api.v1.endpoints.health import router as health_router +from app.modules.tenant.router import router as tenant_router +from app.modules.project.router import router as project_router +from app.modules.approval.router import router as approval_router +from app.modules.system.router import router as system_router +from app.modules.retention.router import router as retention_router +from app.modules.resource.router import router as resource_router api_router = APIRouter() api_router.include_router(health_router, tags=["health"]) api_router.include_router(data_process_router, tags=["data-process"]) api_router.include_router(platform_router, tags=["platform"]) +api_router.include_router(system_router, tags=["system"]) +api_router.include_router(tenant_router, tags=["tenant"]) +api_router.include_router(project_router, tags=["project"]) +api_router.include_router(approval_router, tags=["approval"]) +api_router.include_router(retention_router, tags=["retention"]) +api_router.include_router(resource_router, tags=["resource"]) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py new file mode 100644 index 0000000..b5fa2bb --- /dev/null +++ b/backend/app/core/auth.py @@ -0,0 +1,138 @@ +"""鉴权依赖:从 Authorization header 解析当前用户,提供权限校验。""" +from __future__ import annotations + +from typing import Any + +from fastapi import Depends, HTTPException, Query, Request, status + +from app.db.platform_store import get_platform_store + +# 无需鉴权的路径前缀(健康检查、登录等) +PUBLIC_PATHS = ("/health", "/login", "/system-info") + + +def _extract_token(request: Request) -> str | None: + """从 Authorization header 提取 token(格式: Bearer platform-token-{user_id})。""" + auth = request.headers.get("Authorization", "") + token = auth.replace("Bearer ", "").strip() + if token.startswith("platform-token-"): + return token[len("platform-token-"):] + return None + + +def get_current_user(request: Request) -> dict[str, Any]: + """ + FastAPI 依赖:解析当前登录用户。 + - 公开路径(/health, /login 等)直接放行,返回匿名用户。 + - 无 token 或 token 无效时抛 401。 + - admin 用户标记为超级管理员,拥有全部权限。 + """ + path = request.url.path + # 去掉路由前缀后判断 + for prefix in PUBLIC_PATHS: + if path.endswith(prefix): + return {"id": None, "username": "anonymous", "role": "viewer", "permissions": [], "protected": False} + + user_id = _extract_token(request) + if not user_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing or invalid token") + + store = get_platform_store() + for u in store.users(): + if u.get("id") == user_id: + return u + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") + + +def require_admin(current_user: dict[str, Any] = Depends(get_current_user)) -> dict[str, Any]: + """FastAPI 依赖:要求当前用户是管理员(role=admin 或 protected)。""" + if current_user.get("role") == "admin" or current_user.get("protected"): + return current_user + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin permission required") + + +def is_admin(user: dict[str, Any]) -> bool: + """判断用户是否为管理员(admin 角色或 protected 标记)。""" + return user.get("role") == "admin" or user.get("protected", False) + + +def has_resource_access( + resource_type: str, + resource_id: str, + user: dict[str, Any], + permission: str = "read", +) -> bool: + """ + 检查用户对某资源是否有指定权限。 + - admin/protected 用户直接放行(旁路)。 + - 其他用户检查 acls 表中是否有对应授权。 + """ + if user.get("role") == "admin" or user.get("protected"): + return True + + store = get_platform_store() + acls = store.get_acl(resource_type, resource_id) + user_id = user.get("id") + user_role = user.get("role") + + for entry in acls: + # 按 user 授权 + if entry.get("principal_type") == "user" and entry.get("principal_id") == user_id: + if _permission_covers(entry.get("permission"), permission): + return True + # 按 role 授权 + if entry.get("principal_type") == "role" and entry.get("principal_id") == user_role: + if _permission_covers(entry.get("permission"), permission): + return True + return False + + +def _permission_covers(granted: str | None, required: str) -> bool: + """权限覆盖判断:write/execute 覆盖 read;admin 覆盖一切。""" + if not granted: + return False + if granted == "admin": + return True + if granted == required: + return True + # write 覆盖 read + if required == "read" and granted in ("write", "execute"): + return True + return False + + +def filter_accessible_resource_ids( + resource_type: str, + all_ids: list[str], + user: dict[str, Any], +) -> list[str]: + """ + 从全部资源 ID 中过滤出当前用户可访问的 ID 列表。 + - admin 直接返回全部。 + - 普通用户查 acls 表取交集。 + """ + if user.get("role") == "admin" or user.get("protected"): + return all_ids + + if not all_ids: + return [] + + store = get_platform_store() + user_id = user.get("id") + user_role = user.get("role") + + # 查询该用户在该资源类型下有 read 权限的所有 resource_id + with store.connect() as conn: + rows = conn.execute( + """ + SELECT DISTINCT resource_id FROM acls + WHERE resource_type=? AND ( + (principal_type='user' AND principal_id=?) + OR (principal_type='role' AND principal_id=?) + ) + """, + (resource_type, user_id, user_role), + ).fetchall() + + accessible = {r["resource_id"] for r in rows} + return [rid for rid in all_ids if rid in accessible] diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 1b2bfb8..3006963 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -2,6 +2,18 @@ from functools import lru_cache import os +try: + from pathlib import Path as _Path + + from dotenv import load_dotenv + + # 显式指定 backend 目录下的 .env,并强制覆盖已有环境变量, + # 确保远程数据库配置生效,不被本地默认值或残留环境变量影响。 + _env_path = _Path(__file__).resolve().parent.parent.parent / ".env" + load_dotenv(dotenv_path=_env_path, override=True) +except ImportError: + pass + def _int_env(name: str, default: int) -> int: raw = os.getenv(name) diff --git a/backend/app/db/platform_store.py b/backend/app/db/platform_store.py index ab67a5c..1b21f67 100644 --- a/backend/app/db/platform_store.py +++ b/backend/app/db/platform_store.py @@ -14,6 +14,7 @@ from pathlib import Path from typing import Any, Iterator import psycopg +from psycopg_pool import ConnectionPool from app.core.config import get_settings @@ -291,21 +292,54 @@ class PlatformStore: def __init__(self, database_url: str | None = None) -> None: settings = get_settings() self.database_url = _psycopg_url(database_url or settings.database_url) + # Reuse connections via a pool to avoid the TCP+auth handshake on every + # request (notably expensive against the remote PostgreSQL instance). + # TCP keepalive 让操作系统持续保活连接,抵抗远程库空闲静默断连。 + pool_kwargs = { + "keepalives": 1, + "keepalives_idle": 30, + "keepalives_interval": 10, + "keepalives_count": 5, + } + self._pool = ConnectionPool( + conninfo=self.database_url, + kwargs=pool_kwargs, + min_size=2, + max_size=10, + # 借出前校验连接可用性,避免执行 SQL 时才发现 [BAD] 再重建。 + check=ConnectionPool.check_connection, + # 不主动回收空闲连接(远程库约 10s 断,由 keepalive 维持), + # 减少无谓的重建握手。 + max_idle=0, + # 请求最多排队等待 5s,避免雪崩时无限堆积。 + max_waiting=16, + open=False, + ) + # 注意:不要在此调用 pool.wait(),它会阻塞等待 min_size 个连接就绪, + # 在远程库响应慢/超时时会卡死 uvicorn worker 进程,导致所有请求无响应。 + self._pool.open() self.ensure_schema() self.ensure_seed_data() @contextmanager def connect(self) -> Iterator["PgConnection"]: - raw_conn = psycopg.connect(self.database_url) - conn = PgConnection(raw_conn) + with self._pool.connection() as raw_conn: + conn = PgConnection(raw_conn) + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def close_pool(self) -> None: + """Release pooled connections. Safe to call multiple times.""" try: - yield conn - conn.commit() + self._pool.close() except Exception: - conn.rollback() - raise - finally: - conn.close() + pass def ensure_schema(self) -> None: schema_path = Path(__file__).with_name("sql") / "001_platform_runtime.sql" @@ -335,6 +369,11 @@ class PlatformStore: "last_error": "TEXT", }, ) + schema_dir = Path(__file__).with_name("sql") + for extra in ("002_governance.sql", "003_tenant_quota.sql"): + extra_path = schema_dir / extra + if extra_path.exists(): + conn.executescript(extra_path.read_text(encoding="utf-8")) def _column_names(self, conn: PgConnection, table_name: str) -> set[str]: columns = conn.execute( @@ -1095,6 +1134,18 @@ class PlatformStore: raise ValueError("protected user cannot be deleted") conn.execute("DELETE FROM users WHERE id=?", (user_id,)) + def reset_password(self, user_id: str, new_password: str) -> None: + with self.connect() as conn: + row = conn.execute("SELECT protected FROM users WHERE id=?", (user_id,)).fetchone() + if not row: + raise KeyError(user_id) + if row["protected"]: + raise ValueError("protected user cannot reset password") + conn.execute( + "UPDATE users SET password_hash=? WHERE id=?", + (hash_password(new_password), user_id), + ) + def _user(self, row: PgRow) -> dict[str, Any]: return { "id": row["id"], @@ -2891,6 +2942,672 @@ class PlatformStore: ] return {"file": file_name, "content": "\n".join(lines), "size": "1 KB"} + # ===================== 平台治理:角色 ===================== + + def roles(self) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute("SELECT * FROM roles ORDER BY name").fetchall() + return [dict(r) for r in rows] + + # ===================== 平台治理:审计日志 ===================== + + def audit_logs( + self, + *, + tenant_id: str | None = None, + project_id: str | None = None, + actor_id: str | None = None, + action: str | None = None, + target_type: str | None = None, + start_time: str | None = None, + end_time: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> dict[str, Any]: + clauses: list[str] = [] + params: list[Any] = [] + if tenant_id: + clauses.append("tenant_id=?") + params.append(tenant_id) + if project_id: + clauses.append("project_id=?") + params.append(project_id) + if actor_id: + clauses.append("actor_id=?") + params.append(actor_id) + if action: + clauses.append("action=?") + params.append(action) + if target_type: + clauses.append("target_type=?") + params.append(target_type) + if start_time: + clauses.append("time>=?") + params.append(start_time) + if end_time: + clauses.append("time<=?") + params.append(end_time) + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + with self.connect() as conn: + total = conn.execute(f"SELECT COUNT(*) AS c FROM audit_logs{where}", tuple(params)).fetchone()["c"] + params_paged = list(params) + [limit, offset] + rows = conn.execute( + f"SELECT * FROM audit_logs{where} ORDER BY time DESC LIMIT ? OFFSET ?", + tuple(params_paged), + ).fetchall() + return {"total": total, "items": [dict(r) for r in rows]} + + def record_audit( + self, + *, + action: str, + actor_id: str | None = None, + target_type: str | None = None, + target_id: str | None = None, + tenant_id: str | None = None, + project_id: str | None = None, + detail: str | None = None, + ip: str | None = None, + ) -> None: + with self.connect() as conn: + conn.execute( + """ + INSERT INTO audit_logs + (id, tenant_id, project_id, actor_id, action, target_type, target_id, detail, client_ip, time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + new_id("log"), + tenant_id, + project_id, + actor_id, + action, + target_type, + target_id, + detail, + ip, + utcnow(), + ), + ) + + # ===================== 平台治理:会话 ===================== + + def create_session(self, user_id: str, *, ip: str | None = None) -> dict[str, Any]: + sid = new_id("sess") + login_at = utcnow() + with self.connect() as conn: + conn.execute( + "INSERT INTO sessions (id, user_id, username, login_at, create_time) " + "VALUES (%s, %s, (SELECT username FROM users WHERE id=%s), %s, %s)", + (sid, user_id, user_id, login_at, login_at), + ) + return {"session_id": sid, "user_id": user_id, "login_at": login_at} + + def finish_session(self, session_id: str) -> None: + """登出时记录 logout_at 与时长(秒)。""" + logout_at = utcnow() + with self.connect() as conn: + conn.execute( + "UPDATE sessions SET logout_at=%s, " + "duration_seconds=EXTRACT(EPOCH FROM (%s::timestamptz - login_at::timestamptz))::int " + "WHERE id=%s AND logout_at IS NULL", + (logout_at, logout_at, session_id), + ) + + def active_sessions(self, user_id: str) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute( + "SELECT * FROM sessions WHERE user_id=%s AND logout_at IS NULL " + "ORDER BY login_at DESC", + (user_id,), + ).fetchall() + return [dict(r) for r in rows] + + def destroy_session(self, session_id: str) -> None: + with self.connect() as conn: + conn.execute("DELETE FROM sessions WHERE id=%s", (session_id,)) + + def extend_session(self, session_id: str, *, expires_in_seconds: int = 3600 * 8) -> dict[str, Any] | None: + # 兼容旧调用,仅更新 login_at 之后延长的含义在此简化为 no-op 返回现有记录。 + with self.connect() as conn: + row = conn.execute("SELECT * FROM sessions WHERE id=%s", (session_id,)).fetchone() + if not row: + return None + return { + "session_id": session_id, + "user_id": row["user_id"], + "login_at": row["login_at"], + } + + def set_session_user(self, session_id: str, user_id: str) -> None: + with self.connect() as conn: + conn.execute("UPDATE sessions SET user_id=%s WHERE id=%s", (user_id, session_id)) + + def login_duration_rank(self, limit: int = 8, days: int = 30) -> list[dict[str, Any]]: + """登录时长排行:按用户聚合近 N 天的会话时长(小时)。 + + sessions 表列:login_at(TEXT), logout_at(TEXT), duration_seconds(INT)。 + 优先用 duration_seconds;为空时回退计算 now-login_at(未登出)或 logout_at-login_at。 + """ + with self.connect() as conn: + rows = conn.execute( + "SELECT s.user_id, s.login_at, s.logout_at, s.duration_seconds, " + "u.username, u.display_name, u.role " + "FROM sessions s LEFT JOIN users u ON s.user_id = u.id " + "WHERE s.login_at::timestamptz >= NOW() - make_interval(days => %s)", + (days,), + ).fetchall() + now = datetime.now(timezone.utc) + agg: dict[str, dict[str, Any]] = {} + for r in rows: + uid = r["user_id"] or "" + bucket = agg.setdefault( + uid, + { + "user": r["display_name"] or r["username"] or uid, + "role": r["role"] or "", + "total": 0.0, + }, + ) + dur = r["duration_seconds"] + if dur is not None: + bucket["total"] += float(dur) + continue + start = parse_time(r["login_at"]) + end = parse_time(r["logout_at"]) if r["logout_at"] else None + if start and end: + bucket["total"] += max(0, (end - start).total_seconds()) + elif start: + bucket["total"] += max(0, (now - start).total_seconds()) + result = [ + {"user": b["user"], "role": b["role"], "duration": round(b["total"] / 3600, 1)} + for b in agg.values() + ] + result.sort(key=lambda x: x["duration"], reverse=True) + return result[:limit] + + # ===================== 平台治理:审批 ===================== + + def create_approval_template(self, payload: dict[str, Any]) -> dict[str, Any]: + with self.connect() as conn: + tid = new_id("tpl") + conn.execute( + "INSERT INTO approval_templates (id, name, steps, create_time) VALUES (?, ?, ?, ?)", + (tid, payload["name"], json_dumps(payload.get("steps", [])), utcnow()), + ) + return self.approval_template(tid) + + def approval_templates(self) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute("SELECT * FROM approval_templates ORDER BY create_time DESC").fetchall() + return [dict(r) for r in rows] + + def approval_template(self, template_id: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM approval_templates WHERE id=?", (template_id,)).fetchone() + if not row: + raise KeyError(template_id) + return dict(row) + + def update_approval_template(self, template_id: str, payload: dict[str, Any]) -> dict[str, Any]: + fields = {k: v for k, v in payload.items() if k in ("name", "steps")} + if "steps" in fields: + fields["steps"] = json_dumps(fields["steps"]) + if not fields: + return self.approval_template(template_id) + set_clause = ", ".join(f"{k}=?" for k in fields) + params = list(fields.values()) + [template_id] + with self.connect() as conn: + conn.execute(f"UPDATE approval_templates SET {set_clause} WHERE id=?", tuple(params)) + return self.approval_template(template_id) + + def delete_approval_template(self, template_id: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM approval_templates WHERE id=?", (template_id,)).fetchone() + if not row: + raise KeyError(template_id) + conn.execute("DELETE FROM approval_templates WHERE id=?", (template_id,)) + return dict(row) + + def create_approval_instance(self, payload: dict[str, Any]) -> dict[str, Any]: + template_id = payload.get("template_id") + steps = [] + if template_id: + tpl = self.approval_template(template_id) + steps = json_loads(tpl["steps"]) if tpl.get("steps") else [] + with self.connect() as conn: + iid = new_id("appr") + conn.execute( + """ + INSERT INTO approval_instances + (id, template_id, resource_type, resource_id, applicant_id, status, current_step, create_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + iid, + template_id, + payload["resource_type"], + payload["resource_id"], + payload["applicant_id"], + "pending", + 0, + utcnow(), + ), + ) + for idx, step in enumerate(steps): + conn.execute( + "INSERT INTO approval_steps (id, instance_id, step_index, approver_id, status, time) VALUES (?, ?, ?, ?, ?, ?)", + (new_id("step"), iid, idx, step.get("approver_id"), "pending", None), + ) + return self.approval_instance(iid) + + def approval_instances(self, *, status: str | None = None) -> list[dict[str, Any]]: + with self.connect() as conn: + if status: + rows = conn.execute( + "SELECT * FROM approval_instances WHERE status=? ORDER BY create_time DESC", (status,) + ).fetchall() + else: + rows = conn.execute("SELECT * FROM approval_instances ORDER BY create_time DESC").fetchall() + return [dict(r) for r in rows] + + def approval_instance(self, instance_id: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM approval_instances WHERE id=?", (instance_id,)).fetchone() + if not row: + raise KeyError(instance_id) + steps = conn.execute( + "SELECT * FROM approval_steps WHERE instance_id=? ORDER BY step_index", (instance_id,) + ).fetchall() + result = dict(row) + result["steps"] = [dict(s) for s in steps] + return result + + def decide_approval_step(self, instance_id: str, step_index: int, *, approver_id: str, approved: bool, comment: str | None = None) -> dict[str, Any]: + with self.connect() as conn: + inst = conn.execute("SELECT * FROM approval_instances WHERE id=?", (instance_id,)).fetchone() + if not inst: + raise KeyError(instance_id) + if inst["status"] != "pending": + raise ValueError("instance not pending") + step = conn.execute( + "SELECT * FROM approval_steps WHERE instance_id=? AND step_index=?", + (instance_id, step_index), + ).fetchone() + if not step: + raise KeyError("step not found") + if step["status"] != "pending": + raise ValueError("step already decided") + new_status = "approved" if approved else "rejected" + conn.execute( + "UPDATE approval_steps SET status=?, comment=?, time=? WHERE id=?", + (new_status, comment, utcnow(), step["id"]), + ) + if approved: + conn.execute( + "UPDATE approval_instances SET current_step=? WHERE id=?", + (step_index + 1, instance_id), + ) + step_rows = conn.execute( + "SELECT * FROM approval_steps WHERE instance_id=? ORDER BY step_index", (instance_id,) + ).fetchall() + if all(s["status"] == "approved" for s in step_rows): + conn.execute("UPDATE approval_instances SET status='approved' WHERE id=?", (instance_id,)) + else: + conn.execute("UPDATE approval_instances SET status='rejected' WHERE id=?", (instance_id,)) + return self.approval_instance(instance_id) + + # ===================== 平台治理:租户 ===================== + + def tenants(self) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute("SELECT * FROM tenants ORDER BY create_time DESC").fetchall() + return [dict(r) for r in rows] + + def tenant(self, tenant_id: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM tenants WHERE id=?", (tenant_id,)).fetchone() + if not row: + raise KeyError(tenant_id) + return dict(row) + + def create_tenant(self, payload: dict[str, Any]) -> dict[str, Any]: + with self.connect() as conn: + tid = new_id("tnt") + conn.execute( + """ + INSERT INTO tenants (id, name, code, status, owner_user_id, quota, retention_policy_id, create_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + tid, + payload["name"], + payload.get("code"), + "active", + payload.get("owner_user_id"), + json_dumps(payload.get("quota", {})), + payload.get("retention_policy_id"), + utcnow(), + ), + ) + return self.tenant(tid) + + def update_tenant(self, tenant_id: str, payload: dict[str, Any]) -> dict[str, Any]: + fields = {k: v for k, v in payload.items() if k in ("name", "code", "status", "owner_user_id", "quota", "retention_policy_id")} + if "quota" in fields: + fields["quota"] = json_dumps(fields["quota"]) + if not fields: + return self.tenant(tenant_id) + set_clause = ", ".join(f"{k}=?" for k in fields) + params = list(fields.values()) + [tenant_id] + with self.connect() as conn: + conn.execute(f"UPDATE tenants SET {set_clause} WHERE id=?", tuple(params)) + return self.tenant(tenant_id) + + def set_tenant_quota(self, tenant_id: str, quota: dict[str, Any]) -> dict[str, Any]: + with self.connect() as conn: + conn.execute("UPDATE tenants SET quota=? WHERE id=?", (json_dumps(quota), tenant_id)) + return self.tenant(tenant_id) + + def set_tenant_retention(self, tenant_id: str, retention_policy_id: str | None) -> dict[str, Any]: + with self.connect() as conn: + conn.execute("UPDATE tenants SET retention_policy_id=? WHERE id=?", (retention_policy_id, tenant_id)) + return self.tenant(tenant_id) + + def delete_tenant(self, tenant_id: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM tenants WHERE id=?", (tenant_id,)).fetchone() + if not row: + raise KeyError(tenant_id) + conn.execute("DELETE FROM tenants WHERE id=?", (tenant_id,)) + return dict(row) + + def get_acl(self, resource_type: str, resource_id: str) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute( + "SELECT * FROM acls WHERE resource_type=? AND resource_id=?", + (resource_type, resource_id), + ).fetchall() + return [dict(r) for r in rows] + + def set_acl(self, resource_type: str, resource_id: str, entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + with self.connect() as conn: + conn.execute( + "DELETE FROM acls WHERE resource_type=? AND resource_id=?", + (resource_type, resource_id), + ) + for e in entries: + conn.execute( + """ + INSERT INTO acls (id, resource_type, resource_id, principal_type, principal_id, permission, create_time) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + new_id("acl"), + resource_type, + resource_id, + e.get("principal_type"), + e.get("principal_id"), + e.get("permission"), + utcnow(), + ), + ) + rows = conn.execute( + "SELECT * FROM acls WHERE resource_type=? AND resource_id=?", + (resource_type, resource_id), + ).fetchall() + return [dict(r) for r in rows] + + # ===================== 平台治理:项目空间 ===================== + + def projects(self, *, tenant_id: str = "default", status: str | None = None, keyword: str | None = None) -> list[dict[str, Any]]: + clauses = ["tenant_id=?"] + params: list[Any] = [tenant_id] + if status: + clauses.append("status=?") + params.append(status) + if keyword: + clauses.append("(name LIKE ? OR code LIKE ?)") + params.extend([f"%{keyword}%", f"%{keyword}%"]) + with self.connect() as conn: + rows = conn.execute( + f"SELECT * FROM projects WHERE {' AND '.join(clauses)} ORDER BY create_time DESC", + tuple(params), + ).fetchall() + return [dict(r) for r in rows] + + def project(self, project_id: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM projects WHERE id=?", (project_id,)).fetchone() + if not row: + raise KeyError(project_id) + return dict(row) + + def create_project(self, payload: dict[str, Any]) -> dict[str, Any]: + with self.connect() as conn: + pid = new_id("prj") + conn.execute( + """ + INSERT INTO projects (id, tenant_id, name, code, description, quota, status, create_time, create_by, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + pid, + payload.get("tenant_id", "default"), + payload["name"], + payload["code"], + payload.get("description"), + json_dumps(payload.get("quota", {})), + "active", + utcnow(), + payload.get("create_by"), + utcnow(), + ), + ) + return self.project(pid) + + def update_project(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]: + fields = {k: v for k, v in payload.items() if k in ("name", "code", "description", "quota", "status")} + if "quota" in fields: + fields["quota"] = json_dumps(fields["quota"]) + if not fields: + return self.project(project_id) + set_clause = ", ".join(f"{k}=?" for k in fields) + params = list(fields.values()) + [project_id] + with self.connect() as conn: + conn.execute(f"UPDATE projects SET {set_clause} WHERE id=?", tuple(params)) + return self.project(project_id) + + def archive_project(self, project_id: str) -> dict[str, Any]: + with self.connect() as conn: + conn.execute("UPDATE projects SET status='archived' WHERE id=?", (project_id,)) + return self.project(project_id) + + def activate_project(self, project_id: str) -> dict[str, Any]: + with self.connect() as conn: + conn.execute("UPDATE projects SET status='active' WHERE id=?", (project_id,)) + return self.project(project_id) + + def delete_project(self, project_id: str) -> None: + with self.connect() as conn: + conn.execute("DELETE FROM projects WHERE id=?", (project_id,)) + + def project_members(self, project_id: str) -> list[dict[str, Any]]: + with self.connect() as conn: + row = conn.execute("SELECT * FROM projects WHERE id=?", (project_id,)).fetchone() + if not row: + raise KeyError(project_id) + rows = conn.execute( + """ + SELECT pm.*, u.username, u.display_name + FROM project_members pm JOIN users u ON u.id = pm.user_id + WHERE pm.project_id=? + """, + (project_id,), + ).fetchall() + return [dict(r) for r in rows] + + def add_project_member(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]: + user_id = payload["user_id"] + role = payload.get("role", "member") + with self.connect() as conn: + conn.execute( + "INSERT INTO project_members (project_id, user_id, role, create_time) VALUES (?, ?, ?, ?)", + (project_id, user_id, role, utcnow()), + ) + row = conn.execute( + """ + SELECT pm.*, u.username, u.display_name + FROM project_members pm JOIN users u ON u.id = pm.user_id + WHERE pm.project_id=? AND pm.user_id=? + """, + (project_id, user_id), + ).fetchone() + return { + "project_id": row["project_id"], + "user_id": row["user_id"], + "username": row["username"], + "display_name": row["display_name"], + "role": row["role"], + "create_time": row["create_time"], + } + + def update_project_member_role(self, project_id: str, user_id: str, role: str) -> dict[str, Any]: + with self.connect() as conn: + conn.execute( + "UPDATE project_members SET role=? WHERE project_id=? AND user_id=?", + (role, project_id, user_id), + ) + row = conn.execute( + """ + SELECT pm.*, u.username, u.display_name + FROM project_members pm JOIN users u ON u.id = pm.user_id + WHERE pm.project_id=? AND pm.user_id=? + """, + (project_id, user_id), + ).fetchone() + if not row: + raise KeyError(user_id) + return { + "project_id": row["project_id"], + "user_id": row["user_id"], + "username": row["username"], + "display_name": row["display_name"], + "role": row["role"], + "create_time": row["create_time"], + } + + def remove_project_member(self, project_id: str, user_id: str) -> None: + with self.connect() as conn: + conn.execute( + "DELETE FROM project_members WHERE project_id=? AND user_id=?", + (project_id, user_id), + ) + + # ===================== 平台治理:资源 ACL ===================== + + def resource_acl(self, resource_type: str, resource_id: str) -> list[dict[str, Any]]: + """返回资源 ACL,按主体分组,permissions 为数组。""" + rows = self.get_acl(resource_type, resource_id) + grouped: dict[str, dict[str, Any]] = {} + for r in rows: + key = f"{r.get('principal_type')}:{r.get('principal_id')}" + bucket = grouped.setdefault( + key, + { + "subject_type": r.get("principal_type"), + "subject_id": r.get("principal_id"), + "permissions": [], + }, + ) + perm = r.get("permission") + if perm and perm not in bucket["permissions"]: + bucket["permissions"].append(perm) + return list(grouped.values()) + + def set_resource_acl( + self, resource_type: str, resource_id: str, entries: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """按前端格式设置资源 ACL:entries 为 [{subject_type, subject_id, permissions: []}]。""" + flat: list[dict[str, Any]] = [] + for e in entries: + for perm in e.get("permissions") or []: + flat.append( + { + "principal_type": e.get("subject_type"), + "principal_id": e.get("subject_id"), + "permission": perm, + } + ) + self.set_acl(resource_type, resource_id, flat) + return self.resource_acl(resource_type, resource_id) + + # ===================== 平台治理:留存策略 ===================== + + def retention_policies(self) -> list[dict[str, Any]]: + with self.connect() as conn: + rows = conn.execute( + "SELECT * FROM retention_policies ORDER BY create_time DESC" + ).fetchall() + return [dict(r) for r in rows] + + def retention_policy(self, policy_id: str) -> dict[str, Any]: + with self.connect() as conn: + row = conn.execute( + "SELECT * FROM retention_policies WHERE id=?", (policy_id,) + ).fetchone() + if not row: + raise KeyError(policy_id) + return dict(row) + + def create_retention_policy(self, payload: dict[str, Any]) -> dict[str, Any]: + pid = payload.get("id") or new_id("rpol") + with self.connect() as conn: + conn.execute( + """ + INSERT INTO retention_policies + (id, name, scope, rule, status, create_time, create_by, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + pid, + payload["name"], + payload.get("scope"), + payload.get("rule"), + payload.get("status", "active"), + utcnow(), + payload.get("create_by"), + utcnow(), + ), + ) + return self.retention_policy(pid) + + def update_retention_policy( + self, policy_id: str, payload: dict[str, Any] + ) -> dict[str, Any]: + fields = { + k: v + for k, v in payload.items() + if k in ("name", "scope", "rule", "status") + } + if not fields: + return self.retention_policy(policy_id) + fields["updated_at"] = utcnow() + set_clause = ", ".join(f"{k}=?" for k in fields) + params = list(fields.values()) + [policy_id] + with self.connect() as conn: + conn.execute( + f"UPDATE retention_policies SET {set_clause} WHERE id=?", + tuple(params), + ) + return self.retention_policy(policy_id) + + def delete_retention_policy(self, policy_id: str) -> None: + with self.connect() as conn: + conn.execute( + "DELETE FROM retention_policies WHERE id=?", (policy_id,) + ) + _store: PlatformStore | None = None @@ -2900,3 +3617,16 @@ def get_platform_store() -> PlatformStore: if _store is None: _store = PlatformStore() return _store + + +import atexit as _atexit + + +def _close_store_pool() -> None: + global _store + if _store is not None: + _store.close_pool() + _store = None + + +_atexit.register(_close_store_pool) diff --git a/backend/app/db/sql/001_platform_runtime.sql b/backend/app/db/sql/001_platform_runtime.sql index b219b12..e58518f 100644 --- a/backend/app/db/sql/001_platform_runtime.sql +++ b/backend/app/db/sql/001_platform_runtime.sql @@ -282,3 +282,51 @@ CREATE INDEX IF NOT EXISTS idx_sync_jobs_node_status ON resource_sync_jobs(targe CREATE INDEX IF NOT EXISTS idx_eval_tasks_status ON eval_tasks(status); CREATE INDEX IF NOT EXISTS idx_eval_dimensions_active ON eval_dimensions(is_active); CREATE INDEX IF NOT EXISTS idx_compare_tasks_status ON compare_tasks(status); + +-- ===================== Project / Tenant ===================== + +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL DEFAULT 'default', + name TEXT NOT NULL, + code TEXT NOT NULL, + description TEXT, + quota TEXT, + status TEXT NOT NULL DEFAULT 'active', + create_time TEXT NOT NULL, + create_by TEXT, + updated_at TEXT +); + +CREATE TABLE IF NOT EXISTS project_members ( + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', + create_time TEXT NOT NULL, + PRIMARY KEY (project_id, user_id) +); + +CREATE TABLE IF NOT EXISTS roles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + permissions TEXT NOT NULL DEFAULT '[]', + create_time TEXT +); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + ip TEXT +); + +CREATE TABLE IF NOT EXISTS acls ( + id TEXT PRIMARY KEY, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + principal_type TEXT NOT NULL, + principal_id TEXT NOT NULL, + permission TEXT NOT NULL, + create_time TEXT +); diff --git a/backend/app/db/sql/002_governance.sql b/backend/app/db/sql/002_governance.sql new file mode 100644 index 0000000..1070e7f --- /dev/null +++ b/backend/app/db/sql/002_governance.sql @@ -0,0 +1,68 @@ +-- 平台治理:租户 / 审批 / 审计(字段以 platform_store 实际写入为准) +CREATE TABLE IF NOT EXISTS tenants ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + code TEXT, + status TEXT DEFAULT 'active', + owner_user_id TEXT, + quota TEXT, + retention_policy_id TEXT, + create_time TEXT +); + +CREATE TABLE IF NOT EXISTS approval_templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + steps TEXT, + create_time TEXT +); + +CREATE TABLE IF NOT EXISTS approval_instances ( + id TEXT PRIMARY KEY, + template_id TEXT, + resource_type TEXT, + resource_id TEXT, + applicant_id TEXT, + status TEXT DEFAULT 'pending', + current_step INTEGER DEFAULT 0, + create_time TEXT +); + +CREATE TABLE IF NOT EXISTS approval_steps ( + id TEXT PRIMARY KEY, + instance_id TEXT, + step_index INTEGER, + approver_id TEXT, + status TEXT DEFAULT 'pending', + comment TEXT, + time TEXT +); + +CREATE TABLE IF NOT EXISTS audit_logs ( + id TEXT PRIMARY KEY, + tenant_id TEXT, + project_id TEXT, + actor_id TEXT, + action TEXT, + target_type TEXT, + target_id TEXT, + detail TEXT, + client_ip TEXT, + time TEXT +); + +CREATE INDEX IF NOT EXISTS idx_audit_tenant ON audit_logs(tenant_id); +CREATE INDEX IF NOT EXISTS idx_audit_project ON audit_logs(project_id); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action); +CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_logs(time); + +CREATE TABLE IF NOT EXISTS retention_policies ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + scope TEXT, + rule TEXT, + status TEXT DEFAULT 'active', + create_time TEXT, + create_by TEXT, + updated_at TEXT +); diff --git a/backend/app/db/sql/003_tenant_quota.sql b/backend/app/db/sql/003_tenant_quota.sql new file mode 100644 index 0000000..7113343 --- /dev/null +++ b/backend/app/db/sql/003_tenant_quota.sql @@ -0,0 +1,3 @@ +-- 租户配额与保留策略扩展(如后续治理表需补列,可在此追加) +ALTER TABLE tenants ADD COLUMN IF NOT EXISTS gpu_quota TEXT; +ALTER TABLE tenants ADD COLUMN IF NOT EXISTS storage_quota TEXT; diff --git a/backend/app/modules/approval/router.py b/backend/app/modules/approval/router.py new file mode 100644 index 0000000..fda2452 --- /dev/null +++ b/backend/app/modules/approval/router.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from fastapi import APIRouter, Body +from typing import Any + +from app.api.v1.endpoints.platform import ok, fail +from app.db.platform_store import get_platform_store + +router = APIRouter(prefix="/approvals", tags=["approval"]) + + +@router.get("/templates") +def list_templates() -> dict[str, Any]: + return ok(get_platform_store().approval_templates()) + + +@router.post("/templates") +def create_template(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + if not payload.get("name"): + raise fail(400, "name 必填") + return ok(get_platform_store().create_approval_template(payload)) + + +@router.get("/templates/{template_id}") +def get_template(template_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().approval_template(template_id)) + except KeyError: + raise fail(404, "template not found") + + +@router.put("/templates/{template_id}") +def update_template(template_id: str, payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + try: + return ok(get_platform_store().update_approval_template(template_id, payload)) + except KeyError: + raise fail(404, "template not found") + + +@router.delete("/templates/{template_id}") +def delete_template(template_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().delete_approval_template(template_id)) + except KeyError: + raise fail(404, "template not found") + + +@router.get("") +def list_instances(status: str | None = None) -> dict[str, Any]: + return ok(get_platform_store().approval_instances(status=status)) + + +@router.post("") +def create_instance(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + for field in ("resource_type", "resource_id", "applicant_id"): + if not payload.get(field): + raise fail(400, f"{field} 必填") + try: + return ok(get_platform_store().create_approval_instance(payload)) + except KeyError: + raise fail(404, "template not found") + + +@router.get("/{instance_id}") +def get_instance(instance_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().approval_instance(instance_id)) + except KeyError: + raise fail(404, "instance not found") + + +@router.post("/{instance_id}/steps/{step_index}/decision") +def decide( + instance_id: str, + step_index: int, + payload: dict[str, Any] = Body(...), +) -> dict[str, Any]: + if not payload.get("approver_id"): + raise fail(400, "approver_id 必填") + try: + return ok( + get_platform_store().decide_approval_step( + instance_id, + step_index, + approver_id=payload["approver_id"], + approved=bool(payload.get("approved", False)), + comment=payload.get("comment"), + ) + ) + except (KeyError, ValueError) as e: + raise fail(400, str(e)) diff --git a/backend/app/modules/project/router.py b/backend/app/modules/project/router.py new file mode 100644 index 0000000..7e7de8a --- /dev/null +++ b/backend/app/modules/project/router.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from fastapi import APIRouter, Body, Depends, Request +from typing import Any + +from app.api.v1.endpoints.platform import ok, fail +from app.core.auth import filter_accessible_resource_ids, get_current_user, has_resource_access, is_admin +from app.db.platform_store import get_platform_store + +router = APIRouter(prefix="/projects", tags=["project"]) + + +def _actor(request: Request) -> str | None: + auth = request.headers.get("Authorization", "") + token = auth.replace("Bearer ", "").strip() + return token or None + + +def _require_no_pending_approval(resource_type: str, resource_id: str) -> None: + """第 4 周:写操作审批拦截——存在待审批实例时拒绝执行。""" + store = get_platform_store() + pending = [ + i for i in store.approval_instances(status="pending") + if i["resource_type"] == resource_type and i["resource_id"] == resource_id + ] + if pending: + raise fail(409, "存在待审批的变更,请先完成审批") + + +def _require_approval_or_admin( + resource_type: str, + resource_id: str, + current_user: dict[str, Any], + action_desc: str = "", +) -> dict[str, Any] | None: + """高风险操作审批旁路:admin 直接放行,普通用户创建审批实例(code=202)。""" + if is_admin(current_user): + return None + store = get_platform_store() + instance = store.create_approval_instance({ + "resource_type": resource_type, + "resource_id": resource_id, + "applicant_id": current_user.get("id"), + "template_id": None, + }) + return { + "code": 202, + "message": f"操作已提交审批,等待管理员批准:{action_desc}", + "data": {"approval_required": True, "approval_id": instance["id"]}, + } + + +@router.get("") +def list_projects( + tenant_id: str = "default", + status: str | None = None, + keyword: str | None = None, + current_user: dict = Depends(get_current_user), +) -> dict[str, Any]: + store = get_platform_store() + projects = store.projects(tenant_id=tenant_id, status=status, keyword=keyword) + # #1 ACL 过滤:admin 直接放行,普通用户只能看到自己被授权的项目 + accessible_ids = set( + filter_accessible_resource_ids("project", [p["id"] for p in projects], current_user) + ) + filtered = [p for p in projects if p["id"] in accessible_ids] + return ok(filtered) + + +@router.post("") +def create_project(payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]: + store = get_platform_store() + proj = store.create_project(payload) + store.record_audit( + action="project.create", + actor_id=_actor(request) if request else None, + target_type="project", + target_id=proj["id"], + tenant_id=proj.get("tenant_id"), + detail=f"name={proj.get('name')}", + ) + return ok(proj) + + +@router.get("/{project_id}") +def get_project(project_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]: + # #2 访问控制:普通用户无 read 权限则拒绝 + if not has_resource_access("project", project_id, current_user, "read"): + raise fail(403, "no permission to access this project") + try: + return ok(get_platform_store().project(project_id)) + except KeyError: + raise fail(404, "project not found") + + +@router.put("/{project_id}") +def update_project( + project_id: str, + payload: dict[str, Any] = Body(...), + request: Request = None, + current_user: dict = Depends(get_current_user), +) -> dict[str, Any]: + if not has_resource_access("project", project_id, current_user, "write"): + raise fail(403, "no permission to update this project") + store = get_platform_store() + try: + proj = store.update_project(project_id, payload) + except KeyError: + raise fail(404, "project not found") + store.record_audit( + action="project.update", + actor_id=_actor(request) if request else None, + target_type="project", + target_id=project_id, + tenant_id=proj.get("tenant_id"), + detail=f"fields={','.join(payload.keys())}", + ) + return ok(proj) + + +@router.post("/{project_id}/archive") +def archive_project( + project_id: str, + request: Request = None, + current_user: dict = Depends(get_current_user), +) -> dict[str, Any]: + _require_no_pending_approval("project", project_id) + pending = _require_approval_or_admin("project", project_id, current_user, f"归档项目 {project_id}") + if pending: + return pending + store = get_platform_store() + try: + proj = store.archive_project(project_id) + except KeyError: + raise fail(404, "project not found") + store.record_audit( + action="project.archive", + actor_id=_actor(request) if request else None, + target_type="project", + target_id=project_id, + tenant_id=proj.get("tenant_id"), + ) + return ok(proj) + + +@router.delete("/{project_id}") +def delete_project( + project_id: str, + request: Request = None, + current_user: dict = Depends(get_current_user), +) -> dict[str, Any]: + _require_no_pending_approval("project", project_id) + pending = _require_approval_or_admin("project", project_id, current_user, f"删除项目 {project_id}") + if pending: + return pending + store = get_platform_store() + store.delete_project(project_id) + store.record_audit( + action="project.delete", + actor_id=_actor(request) if request else None, + target_type="project", + target_id=project_id, + ) + return ok(None) + + +@router.get("/{project_id}/members") +def list_members(project_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]: + if not has_resource_access("project", project_id, current_user, "read"): + raise fail(403, "no permission to access this project") + try: + return ok(get_platform_store().project_members(project_id)) + except KeyError: + raise fail(404, "project not found") + + +@router.post("/{project_id}/members") +def add_member( + project_id: str, + payload: dict[str, Any] = Body(...), + request: Request = None, + current_user: dict = Depends(get_current_user), +) -> dict[str, Any]: + if not has_resource_access("project", project_id, current_user, "write"): + raise fail(403, "no permission to manage members of this project") + store = get_platform_store() + try: + member = store.add_project_member(project_id, payload) + except KeyError: + raise fail(404, "project not found") + store.record_audit( + action="project.member.add", + actor_id=_actor(request) if request else None, + target_type="project.member", + target_id=project_id, + detail=f"user_id={payload.get('user_id')},role={payload.get('role')}", + ) + return ok(member) + + +@router.put("/{project_id}/members/{user_id}") +def update_member( + project_id: str, + user_id: str, + payload: dict[str, Any] = Body(...), + request: Request = None, + current_user: dict = Depends(get_current_user), +) -> dict[str, Any]: + if not has_resource_access("project", project_id, current_user, "write"): + raise fail(403, "no permission to manage members of this project") + store = get_platform_store() + try: + member = store.update_project_member_role(project_id, user_id, payload) + except KeyError: + raise fail(404, "project or member not found") + store.record_audit( + action="project.member.update", + actor_id=_actor(request) if request else None, + target_type="project.member", + target_id=project_id, + detail=f"user_id={user_id},role={payload.get('role')}", + ) + return ok(member) + + +@router.delete("/{project_id}/members/{user_id}") +def remove_member( + project_id: str, + user_id: str, + request: Request = None, + current_user: dict = Depends(get_current_user), +) -> dict[str, Any]: + if not has_resource_access("project", project_id, current_user, "write"): + raise fail(403, "no permission to manage members of this project") + store = get_platform_store() + store.remove_project_member(project_id, user_id) + store.record_audit( + action="project.member.remove", + actor_id=_actor(request) if request else None, + target_type="project.member", + target_id=project_id, + detail=f"user_id={user_id}", + ) + return ok(None) diff --git a/backend/app/modules/resource/__init__.py b/backend/app/modules/resource/__init__.py new file mode 100644 index 0000000..f2d6755 --- /dev/null +++ b/backend/app/modules/resource/__init__.py @@ -0,0 +1 @@ +"""Resource access control list (ACL) module.""" diff --git a/backend/app/modules/resource/router.py b/backend/app/modules/resource/router.py new file mode 100644 index 0000000..a9988f2 --- /dev/null +++ b/backend/app/modules/resource/router.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from fastapi import APIRouter, Body, Request +from typing import Any + +from app.api.v1.endpoints.platform import ok, fail +from app.db.platform_store import get_platform_store + +router = APIRouter(prefix="/resources", tags=["resource"]) + + +def _actor(request: Request) -> str | None: + auth = request.headers.get("Authorization", "") + token = auth.replace("Bearer ", "").strip() + return token or None + + +@router.get("/{resource_type}/{resource_id}/acl") +def get_acl(resource_type: str, resource_id: str) -> dict[str, Any]: + """查询资源 ACL,返回按主体分组的权限列表。""" + return ok(get_platform_store().resource_acl(resource_type, resource_id)) + + +@router.put("/{resource_type}/{resource_id}/acl") +def set_acl( + resource_type: str, + resource_id: str, + payload: dict[str, Any] = Body(...), + request: Request = None, +) -> dict[str, Any]: + """设置资源 ACL,body: { entries: [{ subject_type, subject_id, permissions: [] }] }""" + entries = payload.get("entries") or [] + result = get_platform_store().set_resource_acl(resource_type, resource_id, entries) + get_platform_store().record_audit( + action="resource.acl.set", + actor_id=_actor(request) if request else None, + target_type=resource_type, + target_id=resource_id, + detail=f"entries={len(entries)}", + ) + return ok(result) diff --git a/backend/app/modules/retention/router.py b/backend/app/modules/retention/router.py new file mode 100644 index 0000000..0031743 --- /dev/null +++ b/backend/app/modules/retention/router.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from fastapi import APIRouter, Body, Request +from typing import Any + +from app.api.v1.endpoints.platform import ok, fail +from app.db.platform_store import get_platform_store + +router = APIRouter(prefix="/retention-policies", tags=["retention"]) + + +def _actor(request: Request) -> str | None: + auth = request.headers.get("Authorization", "") + token = auth.replace("Bearer ", "").strip() + return token or None + + +@router.get("") +def list_policies() -> dict[str, Any]: + return ok(get_platform_store().retention_policies()) + + +@router.post("") +def create_policy(payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]: + if not payload.get("name"): + raise fail(400, "name 必填") + policy = get_platform_store().create_retention_policy(payload) + get_platform_store().record_audit( + action="retention.create", + actor_id=_actor(request) if request else None, + target_type="retention_policy", + target_id=policy["id"], + detail=f"name={policy.get('name')}", + ) + return ok(policy) + + +@router.get("/{policy_id}") +def get_policy(policy_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().retention_policy(policy_id)) + except KeyError: + raise fail(404, "retention policy not found") + + +@router.put("/{policy_id}") +def update_policy( + policy_id: str, payload: dict[str, Any] = Body(...), request: Request = None +) -> dict[str, Any]: + store = get_platform_store() + try: + policy = store.update_retention_policy(policy_id, payload) + except KeyError: + raise fail(404, "retention policy not found") + store.record_audit( + action="retention.update", + actor_id=_actor(request) if request else None, + target_type="retention_policy", + target_id=policy_id, + detail=f"fields={','.join(payload.keys())}", + ) + return ok(policy) + + +@router.delete("/{policy_id}") +def delete_policy(policy_id: str, request: Request = None) -> dict[str, Any]: + store = get_platform_store() + store.delete_retention_policy(policy_id) + store.record_audit( + action="retention.delete", + actor_id=_actor(request) if request else None, + target_type="retention_policy", + target_id=policy_id, + ) + return ok({"deleted": policy_id}) diff --git a/backend/app/modules/system/router.py b/backend/app/modules/system/router.py new file mode 100644 index 0000000..a95d144 --- /dev/null +++ b/backend/app/modules/system/router.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from fastapi import APIRouter, Query +from fastapi.responses import StreamingResponse + +from app.db.platform_store import ALL_PERMISSIONS, get_platform_store + + +router = APIRouter(prefix="/system", tags=["system"]) + + +@router.get("/permissions/codes") +def permission_codes() -> dict: + """返回平台权限码清单(权限码接口)。""" + return {"code": 0, "message": "ok", "data": {"codes": ALL_PERMISSIONS}} + + +@router.get("/permissions") +def permissions_overview() -> 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="目标类型"), + 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), +) -> dict: + """审计日志查询:按租户/项目/操作人/动作/目标类型/时间范围分页过滤。""" + 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, + 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="目标类型"), + start_time: str | None = Query(default=None, description="ISO8601 起始时间"), + end_time: str | None = Query(default=None, description="ISO8601 结束时间"), +) -> StreamingResponse: + """审计日志导出:返回 CSV 流,与应用查询相同的过滤条件。""" + 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, + 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"] + header = ",".join(columns) + "\n" + + def iter_rows(): + yield header + for row in items: + yield ",".join(f'"{str(row.get(c, "") or "")}"' for c in columns) + "\n" + + return StreamingResponse( + iter_rows(), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=audit_logs.csv"}, + ) diff --git a/backend/app/modules/tenant/router.py b/backend/app/modules/tenant/router.py new file mode 100644 index 0000000..73af52e --- /dev/null +++ b/backend/app/modules/tenant/router.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from fastapi import APIRouter, Body, Request +from typing import Any + +from app.api.v1.endpoints.platform import ok, fail +from app.db.platform_store import get_platform_store + +router = APIRouter(prefix="/tenants", tags=["tenant"]) + + +def _actor(request: Request) -> str | None: + auth = request.headers.get("Authorization", "") + token = auth.replace("Bearer ", "").strip() + return token or None + + +@router.get("") +def list_tenants() -> dict[str, Any]: + return ok(get_platform_store().tenants()) + + +@router.post("") +def create_tenant(payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]: + store = get_platform_store() + try: + tenant = store.create_tenant(payload) + except KeyError as e: + raise fail(400, f"missing field: {e}") + store.record_audit( + action="tenant.create", + actor_id=_actor(request) if request else None, + target_type="tenant", + target_id=tenant["id"], + tenant_id=tenant["id"], + detail=f"name={tenant.get('name')}", + ) + return ok(tenant) + + +@router.get("/{tenant_id}") +def get_tenant(tenant_id: str) -> dict[str, Any]: + try: + return ok(get_platform_store().tenant(tenant_id)) + except KeyError: + raise fail(404, "tenant not found") + + +@router.put("/{tenant_id}") +def update_tenant(tenant_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]: + store = get_platform_store() + try: + tenant = store.update_tenant(tenant_id, payload) + except KeyError: + raise fail(404, "tenant not found") + store.record_audit( + action="tenant.update", + actor_id=_actor(request) if request else None, + target_type="tenant", + target_id=tenant_id, + tenant_id=tenant_id, + detail=f"fields={','.join(payload.keys())}", + ) + return ok(tenant) + + +@router.put("/{tenant_id}/quota") +def set_quota(tenant_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]: + store = get_platform_store() + try: + tenant = store.set_tenant_quota(tenant_id, payload.get("quota", {})) + except KeyError: + raise fail(404, "tenant not found") + store.record_audit( + action="tenant.quota.set", + actor_id=_actor(request) if request else None, + target_type="tenant", + target_id=tenant_id, + tenant_id=tenant_id, + ) + return ok(tenant) + + +@router.put("/{tenant_id}/retention-policy") +def set_retention(tenant_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]: + store = get_platform_store() + try: + tenant = store.set_tenant_retention(tenant_id, payload.get("retention_policy_id")) + except KeyError: + raise fail(404, "tenant not found") + store.record_audit( + action="tenant.retention.set", + actor_id=_actor(request) if request else None, + target_type="tenant", + target_id=tenant_id, + tenant_id=tenant_id, + ) + return ok(tenant) + + +@router.delete("/{tenant_id}") +def delete_tenant(tenant_id: str, request: Request = None) -> dict[str, Any]: + store = get_platform_store() + try: + tenant = store.delete_tenant(tenant_id) + except KeyError: + raise fail(404, "tenant not found") + store.record_audit( + action="tenant.delete", + actor_id=_actor(request) if request else None, + target_type="tenant", + target_id=tenant_id, + tenant_id=tenant_id, + detail=f"name={tenant.get('name')}", + ) + return ok(tenant) diff --git a/backend/tests/test_governance.py b/backend/tests/test_governance.py new file mode 100644 index 0000000..023310c --- /dev/null +++ b/backend/tests/test_governance.py @@ -0,0 +1,774 @@ +""" +平台治理功能集成测试 —— 覆盖第 1-4 周交付内容。 + +测试策略: +- 在导入 app 模块前 mock psycopg / psycopg_pool,避免依赖真实数据库驱动 +- 使用 FastAPI TestClient 对真实路由栈发起请求 +- 通过 mock.get_platform_store 替换为内存 FakeStore +- 每周交付内容对应一组 test class,方便分阶段验收 + +覆盖范围: + 第 1 周 — 登录、当前用户、用户列表、权限码、日志查询 + 第 2 周 — 租户、项目、项目成员、资源 ACL + 第 3 周 — 审批实例、审批模板、审计日志查询和导出 + 第 4 周 — 写操作审计、审批拦截、权限校验 +""" + +from __future__ import annotations + +import json +import sys +import types +from contextlib import contextmanager +from typing import Any, Iterator +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +# ============================================================ +# 在导入 app 之前 mock psycopg / psycopg_pool +# ============================================================ + +_psycopg_mock = types.ModuleType("psycopg") +_psycopg_mock.PgConn = type("PgConn", (), {}) +_psycopg_mock.PostgresConnectionPool = MagicMock() +_psycopg_mock.connection = MagicMock() +sys.modules.setdefault("psycopg", _psycopg_mock) + +_psycopg_pool_mock = types.ModuleType("psycopg_pool") +_psycopg_pool_mock.ConnectionPool = MagicMock() +sys.modules.setdefault("psycopg_pool", _psycopg_pool_mock) + +# 现在安全导入 app 模块 +from app.api.v1.endpoints.platform import ok, fail # noqa: E402 +from app.modules.tenant.router import router as tenant_router # noqa: E402 +from app.modules.project.router import router as project_router # noqa: E402 +from app.modules.approval.router import router as approval_router # noqa: E402 +from app.modules.system.router import router as system_router # noqa: E402 +from app.modules.retention.router import router as retention_router # noqa: E402 +from app.modules.resource.router import router as resource_router # noqa: E402 +from app.api.v1.endpoints.platform import router as platform_router # noqa: E402 + +PREFIX = "/modelTF" +ADMIN_TOKEN = "platform-token-u_admin" +OP_TOKEN = "platform-token-u_op" + + +# ============================================================ +# FakePlatformStore —— 内存实现,模拟 PlatformStore 全部治理接口 +# ============================================================ + +class FakePlatformStore: + """平台治理测试专用内存 store,确保测试不连接真实数据库。""" + + def __init__(self) -> None: + self._users: list[dict[str, Any]] = [ + { + "id": "u_admin", + "username": "admin", + "display_name": "Admin", + "role": "admin", + "status": "active", + "permissions": [ + "dashboard", "fine-tune", "model-eval", "model-inference", + "model-manage", "dataset", "data-process", "data-convert", + "compute", "hardware", "logs", "user-settings", + ], + "last_login": "2026-08-01T10:00:00Z", + "protected": True, + }, + { + "id": "u_op", + "username": "operator", + "display_name": "Operator", + "role": "operator", + "status": "active", + "permissions": ["dashboard", "fine-tune"], + "last_login": "2026-08-01T11:00:00Z", + "protected": False, + }, + ] + self._tenants: dict[str, dict[str, Any]] = {} + self._projects: dict[str, dict[str, Any]] = {} + self._members: dict[str, list[dict[str, Any]]] = {} + self._acl: dict[str, list[dict[str, Any]]] = {} + self._audit_logs: list[dict[str, Any]] = [] + self._approval_templates: dict[str, dict[str, Any]] = {} + self._approval_instances: dict[str, dict[str, Any]] = {} + self._retention_policies: dict[str, dict[str, Any]] = {} + self._models: list[dict[str, Any]] = [] + self._datasets: list[dict[str, Any]] = [] + self._tasks: list[dict[str, Any]] = [] + self._compute_nodes: list[dict[str, Any]] = [] + self._gpus: list[dict[str, Any]] = [] + self._sessions: list[dict[str, Any]] = [] + self._seq = 0 + + @contextmanager + def connect(self) -> Iterator[Any]: + class FakeConn: + def execute(self, *a, **kw): + return [] + + def commit(self): + pass + + def rollback(self): + pass + + def close(self): + pass + + yield FakeConn() + + # ---- helpers ---- + + def _next_id(self, prefix: str) -> str: + self._seq += 1 + return f"{prefix}_{self._seq}" + + # ==================== 第1周:登录 / 用户 / 权限码 / 日志 ==================== + + def login(self, username: str, password: str) -> dict[str, Any] | None: + for u in self._users: + if u["username"] == username and u["status"] == "active": + if password in ("admin123", "operator123", "test123"): + return dict(u) + return None + + def users(self) -> list[dict[str, Any]]: + return [dict(u) for u in self._users] + + def create_user(self, payload: dict[str, Any]) -> dict[str, Any]: + u = {"id": self._next_id("u"), "protected": False, **payload} + self._users.append(u) + return u + + def update_user(self, user_id: str, payload: dict[str, Any]) -> dict[str, Any]: + for u in self._users: + if u["id"] == user_id: + u.update(payload) + return u + raise KeyError(user_id) + + def delete_user(self, user_id: str) -> None: + self._users = [u for u in self._users if u["id"] != user_id] + + def roles(self) -> list[dict[str, Any]]: + return [ + {"name": "admin", "display_name": "管理员"}, + {"name": "operator", "display_name": "操作员"}, + {"name": "viewer", "display_name": "访客"}, + ] + + def log_files(self, date: str | None = None) -> list[dict[str, Any]]: + return [{"name": "backend-2026-08-01.log", "size": "1 KB", "date": "2026-08-01"}] + + def log_content(self, file: str) -> dict[str, Any]: + return {"file": file, "content": "[INFO] test line", "size": "1 KB"} + + def training_log_files(self) -> list[dict[str, Any]]: + return [{"task_id": "ft_001", "name": "ft_001.log", "size": "2 KB"}] + + def training_log_content(self, file: str) -> dict[str, Any]: + return {"file": file, "content": "epoch 0 loss 1.0", "size": "2 KB"} + + # ==================== 第2周:租户 / 项目 / 成员 / ACL ==================== + + def tenants(self) -> list[dict[str, Any]]: + return list(self._tenants.values()) + + def tenant(self, tenant_id: str) -> dict[str, Any]: + if tenant_id not in self._tenants: + raise KeyError(tenant_id) + return dict(self._tenants[tenant_id]) + + def create_tenant(self, payload: dict[str, Any]) -> dict[str, Any]: + tid = self._next_id("tnt") + t = {"id": tid, "status": "active", "quota": "{}", "retention_policy_id": None, + "create_time": "2026-08-01T00:00:00Z", **payload} + self._tenants[tid] = t + return dict(t) + + def update_tenant(self, tenant_id: str, payload: dict[str, Any]) -> dict[str, Any]: + self._tenants[tenant_id].update(payload) + return dict(self._tenants[tenant_id]) + + def set_tenant_quota(self, tenant_id: str, quota: dict[str, Any]) -> dict[str, Any]: + self._tenants[tenant_id]["quota"] = json.dumps(quota) + return dict(self._tenants[tenant_id]) + + def set_tenant_retention(self, tenant_id: str, retention_policy_id: str | None) -> dict[str, Any]: + self._tenants[tenant_id]["retention_policy_id"] = retention_policy_id + return dict(self._tenants[tenant_id]) + + def projects(self, *, tenant_id: str = "default", status: str | None = None, keyword: str | None = None) -> list[dict[str, Any]]: + result = [] + for p in self._projects.values(): + if p.get("tenant_id") != tenant_id: + continue + if status and p.get("status") != status: + continue + if keyword and keyword.lower() not in p.get("name", "").lower(): + continue + result.append(dict(p)) + return result + + def project(self, project_id: str) -> dict[str, Any]: + if project_id not in self._projects: + raise KeyError(project_id) + return dict(self._projects[project_id]) + + def create_project(self, payload: dict[str, Any]) -> dict[str, Any]: + pid = self._next_id("prj") + p = {"id": pid, "status": "active", "quota": "{}", "create_time": "2026-08-01T00:00:00Z", **payload} + self._projects[pid] = p + self._members[pid] = [] + return dict(p) + + def update_project(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]: + self._projects[project_id].update(payload) + return dict(self._projects[project_id]) + + def archive_project(self, project_id: str) -> dict[str, Any]: + self._projects[project_id]["status"] = "archived" + return dict(self._projects[project_id]) + + def delete_project(self, project_id: str) -> None: + self._projects.pop(project_id, None) + self._members.pop(project_id, None) + + def project_members(self, project_id: str) -> list[dict[str, Any]]: + return [dict(m) for m in self._members.get(project_id, [])] + + def add_project_member(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]: + m = {"joined_at": "2026-08-01T00:00:00Z", **payload} + self._members.setdefault(project_id, []).append(m) + return m + + def update_project_member_role(self, project_id: str, user_id: str, role: str) -> dict[str, Any]: + for m in self._members.get(project_id, []): + if m["user_id"] == user_id: + m["role"] = role + return m + raise KeyError(user_id) + + def remove_project_member(self, project_id: str, user_id: str) -> None: + self._members[project_id] = [m for m in self._members.get(project_id, []) if m["user_id"] != user_id] + + # ---- ACL ---- + + def get_acl(self, resource_type: str, resource_id: str) -> list[dict[str, Any]]: + key = f"{resource_type}:{resource_id}" + return [dict(a) for a in self._acl.get(key, [])] + + def set_acl(self, resource_type: str, resource_id: str, entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + key = f"{resource_type}:{resource_id}" + self._acl[key] = [dict(e) for e in entries] + return self.get_acl(resource_type, resource_id) + + def resource_acl(self, resource_type: str, resource_id: str) -> list[dict[str, Any]]: + rows = self.get_acl(resource_type, resource_id) + grouped: dict[str, dict[str, Any]] = {} + for r in rows: + k = f"{r.get('principal_type')}:{r.get('principal_id')}" + bucket = grouped.setdefault(k, { + "subject_type": r.get("principal_type"), + "subject_id": r.get("principal_id"), + "permissions": [], + }) + perm = r.get("permission") + if perm and perm not in bucket["permissions"]: + bucket["permissions"].append(perm) + return list(grouped.values()) + + def set_resource_acl(self, resource_type: str, resource_id: str, entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + flat: list[dict[str, Any]] = [] + for e in entries: + for perm in e.get("permissions") or []: + flat.append({ + "principal_type": e.get("subject_type"), + "principal_id": e.get("subject_id"), + "permission": perm, + }) + self.set_acl(resource_type, resource_id, flat) + return self.resource_acl(resource_type, resource_id) + + # ==================== 第3周:审批 / 审计 / 留存 ==================== + + def approval_templates(self) -> list[dict[str, Any]]: + return list(self._approval_templates.values()) + + def create_approval_template(self, payload: dict[str, Any]) -> dict[str, Any]: + tid = payload.get("id") or self._next_id("tpl") + t = {"id": tid, "steps": [], "create_time": "2026-08-01T00:00:00Z", **payload} + self._approval_templates[tid] = t + return dict(t) + + def approval_instances(self, *, status: str | None = None) -> list[dict[str, Any]]: + result = [] + for i in self._approval_instances.values(): + if status and i.get("status") != status: + continue + result.append(dict(i)) + return result + + def approval_instance(self, instance_id: str) -> dict[str, Any]: + if instance_id not in self._approval_instances: + raise KeyError(instance_id) + return dict(self._approval_instances[instance_id]) + + def create_approval_instance(self, payload: dict[str, Any]) -> dict[str, Any]: + iid = self._next_id("appr") + inst = { + "id": iid, + "status": "pending", + "current_step": 0, + "steps": [], + "create_time": "2026-08-01T00:00:00Z", + **payload, + } + self._approval_instances[iid] = inst + return dict(inst) + + def decide_approval_step(self, instance_id: str, step_index: int, *, approver_id: str, approved: bool, comment: str | None = None) -> dict[str, Any]: + inst = self._approval_instances[instance_id] + inst["status"] = "approved" if approved else "rejected" + inst["current_step"] = step_index + 1 + return dict(inst) + + def audit_logs(self, **kw) -> dict[str, Any]: + items = [dict(l) for l in self._audit_logs] + for filter_key in ("tenant_id", "project_id", "actor_id", "action", "target_type"): + val = kw.get(filter_key) + if val: + items = [l for l in items if l.get(filter_key) == val] + limit = kw.get("limit", 50) + offset = kw.get("offset", 0) + total = len(items) + items = items[offset:offset + limit] + return {"items": items, "total": total} + + def record_audit(self, **kw) -> None: + log = {"id": self._next_id("log"), "time": "2026-08-01T12:00:00Z", **kw} + self._audit_logs.append(log) + + # ---- 留存策略 ---- + + def retention_policies(self) -> list[dict[str, Any]]: + return list(self._retention_policies.values()) + + def retention_policy(self, policy_id: str) -> dict[str, Any]: + if policy_id not in self._retention_policies: + raise KeyError(policy_id) + return dict(self._retention_policies[policy_id]) + + def create_retention_policy(self, payload: dict[str, Any]) -> dict[str, Any]: + pid = payload.get("id") or self._next_id("rpol") + p = {"id": pid, "status": "active", "create_time": "2026-08-01T00:00:00Z", **payload} + self._retention_policies[pid] = p + return dict(p) + + def update_retention_policy(self, policy_id: str, payload: dict[str, Any]) -> dict[str, Any]: + self._retention_policies[policy_id].update(payload) + return dict(self._retention_policies[policy_id]) + + def delete_retention_policy(self, policy_id: str) -> None: + self._retention_policies.pop(policy_id, None) + + # ---- dashboard & other stubs ---- + + def login_duration_rank(self, limit: int = 8, days: int = 30) -> list[dict[str, Any]]: + return [{"user": "admin", "role": "admin", "duration": 10.0}] + + def models(self) -> list[dict[str, Any]]: + return self._models + + def datasets(self) -> list[dict[str, Any]]: + return self._datasets + + def tasks(self) -> list[dict[str, Any]]: + return self._tasks + + def compute_nodes(self) -> list[dict[str, Any]]: + return self._compute_nodes + + def gpus(self) -> list[dict[str, Any]]: + return self._gpus + + def system_info(self) -> dict[str, Any]: + return {"cpu": {}, "memory": {}} + + +# ============================================================ +# 测试 fixtures +# ============================================================ + +@pytest.fixture(scope="module") +def fake_store() -> FakePlatformStore: + return FakePlatformStore() + + +def _build_client(store: FakePlatformStore) -> TestClient: + """构建 TestClient,patch 所有治理模块的 get_platform_store。""" + app = FastAPI() + app.include_router(platform_router, prefix=PREFIX) + app.include_router(system_router, prefix=PREFIX) + app.include_router(tenant_router, prefix=PREFIX) + app.include_router(project_router, prefix=PREFIX) + app.include_router(approval_router, prefix=PREFIX) + app.include_router(retention_router, prefix=PREFIX) + app.include_router(resource_router, prefix=PREFIX) + + patches = [ + patch("app.db.platform_store.get_platform_store", return_value=store), + patch("app.core.auth.get_platform_store", return_value=store), + patch("app.api.v1.endpoints.platform.get_platform_store", return_value=store), + patch("app.modules.system.router.get_platform_store", return_value=store), + patch("app.modules.tenant.router.get_platform_store", return_value=store), + patch("app.modules.project.router.get_platform_store", return_value=store), + patch("app.modules.approval.router.get_platform_store", return_value=store), + patch("app.modules.retention.router.get_platform_store", return_value=store), + patch("app.modules.resource.router.get_platform_store", return_value=store), + ] + for p in patches: + p.start() + client = TestClient(app, raise_server_exceptions=False) + client._fake_store = store # type: ignore[attr-defined] + return client + + +@pytest.fixture(scope="module") +def client(fake_store: FakePlatformStore) -> TestClient: + c = _build_client(fake_store) + yield c + + +def _admin_headers() -> dict[str, str]: + return {"Authorization": f"Bearer {ADMIN_TOKEN}"} + + +def _op_headers() -> dict[str, str]: + return {"Authorization": f"Bearer {OP_TOKEN}"} + + +# ============================================================ +# 第 1 周测试:登录、当前用户、用户列表、权限码、日志查询 +# ============================================================ + +class TestWeek1AuthUserPermissionsLogs: + """第 1 周:登录、当前用户、用户列表、权限码、日志查询接口。""" + + def test_login_success(self, client: TestClient): + resp = client.post(f"{PREFIX}/login", json={"username": "admin", "password": "admin123"}) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["token"] == ADMIN_TOKEN + assert data["user"]["username"] == "admin" + + def test_login_invalid(self, client: TestClient): + resp = client.post(f"{PREFIX}/login", json={"username": "admin", "password": "wrong"}) + assert resp.status_code == 401 + + def test_me_with_valid_token(self, client: TestClient): + resp = client.get(f"{PREFIX}/me", headers=_admin_headers()) + assert resp.status_code == 200 + assert resp.json()["data"]["username"] == "admin" + + def test_me_without_token(self, client: TestClient): + resp = client.get(f"{PREFIX}/me") + assert resp.status_code == 401 + + def test_users_list(self, client: TestClient): + resp = client.get(f"{PREFIX}/users", headers=_admin_headers()) + assert resp.status_code == 200 + users = resp.json()["data"] + assert len(users) >= 2 + assert any(u["username"] == "admin" for u in users) + + def test_create_user(self, client: TestClient): + resp = client.post( + f"{PREFIX}/users", + json={"username": "tester", "display_name": "Tester", "role": "viewer", "password": "test123"}, + headers=_admin_headers(), + ) + assert resp.status_code == 200 + assert resp.json()["data"]["username"] == "tester" + + def test_permission_codes(self, client: TestClient): + resp = client.get(f"{PREFIX}/system/permissions/codes") + assert resp.status_code == 200 + codes = resp.json()["data"]["codes"] + assert "dashboard" in codes + assert "user-settings" in codes + + def test_permissions_overview(self, client: TestClient): + resp = client.get(f"{PREFIX}/system/permissions") + assert resp.status_code == 200 + data = resp.json()["data"] + assert "codes" in data + assert "roles" in data + + def test_log_files(self, client: TestClient): + resp = client.get(f"{PREFIX}/log-files", headers=_admin_headers()) + assert resp.status_code == 200 + files = resp.json()["data"] + assert len(files) >= 1 + + def test_log_content(self, client: TestClient): + resp = client.get(f"{PREFIX}/log-content", params={"file": "backend.log"}, headers=_admin_headers()) + assert resp.status_code == 200 + assert "content" in resp.json()["data"] + + def test_training_log_files(self, client: TestClient): + resp = client.get(f"{PREFIX}/training-log-files", headers=_admin_headers()) + assert resp.status_code == 200 + assert len(resp.json()["data"]) >= 1 + + def test_training_log_content(self, client: TestClient): + resp = client.get(f"{PREFIX}/training-log-content", params={"file": "ft_001.log"}, headers=_admin_headers()) + assert resp.status_code == 200 + assert "content" in resp.json()["data"] + + +# ============================================================ +# 第 2 周测试:租户、项目、项目成员、资源 ACL +# ============================================================ + +class TestWeek2TenantProjectACL: + """第 2 周:租户、项目、项目成员、资源 ACL。""" + + def test_tenant_crud(self, client: TestClient): + # 创建 + resp = client.post(f"{PREFIX}/tenants", json={"name": "Tenant-A", "code": "ta"}, headers=_admin_headers()) + assert resp.status_code == 200 + tid = resp.json()["data"]["id"] + # 查列表 + resp = client.get(f"{PREFIX}/tenants", headers=_admin_headers()) + assert resp.status_code == 200 + assert any(t["id"] == tid for t in resp.json()["data"]) + # 查详情 + resp = client.get(f"{PREFIX}/tenants/{tid}", headers=_admin_headers()) + assert resp.status_code == 200 + assert resp.json()["data"]["name"] == "Tenant-A" + # 更新 + resp = client.put(f"{PREFIX}/tenants/{tid}", json={"name": "Tenant-A2"}, headers=_admin_headers()) + assert resp.status_code == 200 + assert resp.json()["data"]["name"] == "Tenant-A2" + + def test_tenant_quota(self, client: TestClient): + resp = client.post(f"{PREFIX}/tenants", json={"name": "Q-Tenant", "code": "qt"}, headers=_admin_headers()) + tid = resp.json()["data"]["id"] + resp = client.put(f"{PREFIX}/tenants/{tid}/quota", json={"quota": {"gpu": 4}}, headers=_admin_headers()) + assert resp.status_code == 200 + + def test_tenant_retention(self, client: TestClient): + resp = client.post(f"{PREFIX}/tenants", json={"name": "R-Tenant", "code": "rt"}, headers=_admin_headers()) + tid = resp.json()["data"]["id"] + resp = client.put(f"{PREFIX}/tenants/{tid}/retention-policy", json={"retention_policy_id": "rpol_1"}, headers=_admin_headers()) + assert resp.status_code == 200 + + def test_project_crud(self, client: TestClient): + # 创建项目 + resp = client.post(f"{PREFIX}/projects", json={"name": "Proj-1", "code": "p1", "tenant_id": "default"}, headers=_admin_headers()) + assert resp.status_code == 200 + pid = resp.json()["data"]["id"] + # 查列表 + resp = client.get(f"{PREFIX}/projects", params={"tenant_id": "default"}, headers=_admin_headers()) + assert resp.status_code == 200 + assert any(p["id"] == pid for p in resp.json()["data"]) + # 查详情 + resp = client.get(f"{PREFIX}/projects/{pid}", headers=_admin_headers()) + assert resp.status_code == 200 + assert resp.json()["data"]["name"] == "Proj-1" + # 更新 + resp = client.put(f"{PREFIX}/projects/{pid}", json={"description": "updated"}, headers=_admin_headers()) + assert resp.status_code == 200 + # 归档 + resp = client.post(f"{PREFIX}/projects/{pid}/archive", headers=_admin_headers()) + assert resp.status_code == 200 + assert resp.json()["data"]["status"] == "archived" + + def test_project_members(self, client: TestClient): + resp = client.post(f"{PREFIX}/projects", json={"name": "Proj-M", "code": "pm", "tenant_id": "default"}, headers=_admin_headers()) + pid = resp.json()["data"]["id"] + # 加成员 + resp = client.post(f"{PREFIX}/projects/{pid}/members", json={"user_id": "u_op", "role": "developer"}, headers=_admin_headers()) + assert resp.status_code == 200 + # 列成员 + resp = client.get(f"{PREFIX}/projects/{pid}/members", headers=_admin_headers()) + assert resp.status_code == 200 + assert len(resp.json()["data"]) >= 1 + # 改角色 + resp = client.put(f"{PREFIX}/projects/{pid}/members/u_op", json={"role": "maintainer"}, headers=_admin_headers()) + assert resp.status_code == 200 + # 删成员 + resp = client.delete(f"{PREFIX}/projects/{pid}/members/u_op", headers=_admin_headers()) + assert resp.status_code == 200 + + def test_resource_acl(self, client: TestClient): + # 设置 ACL + resp = client.put( + f"{PREFIX}/resources/model/m001/acl", + json={"entries": [{"subject_type": "user", "subject_id": "u_op", "permissions": ["read", "write"]}]}, + headers=_admin_headers(), + ) + assert resp.status_code == 200 + result = resp.json()["data"] + assert len(result) == 1 + assert set(result[0]["permissions"]) == {"read", "write"} + # 查询 ACL + resp = client.get(f"{PREFIX}/resources/model/m001/acl", headers=_admin_headers()) + assert resp.status_code == 200 + assert len(resp.json()["data"]) == 1 + + +# ============================================================ +# 第 3 周测试:审批实例、审批模板、审计日志查询和导出 +# ============================================================ + +class TestWeek3ApprovalAudit: + """第 3 周:审批实例、审批模板、审计日志查询和导出。""" + + def test_approval_template_crud(self, client: TestClient): + # 创建模板 + resp = client.post(f"{PREFIX}/approvals/templates", json={"name": "delete-approval", "steps": [{"approver_id": "u_admin", "status": "pending"}]}, headers=_admin_headers()) + assert resp.status_code == 200 + tpl_id = resp.json()["data"]["id"] + # 查列表 + resp = client.get(f"{PREFIX}/approvals/templates", headers=_admin_headers()) + assert resp.status_code == 200 + assert any(t["id"] == tpl_id for t in resp.json()["data"]) + + def test_approval_instance_flow(self, client: TestClient): + # 创建审批实例 + resp = client.post(f"{PREFIX}/approvals", json={ + "resource_type": "dataset", "resource_id": "ds_001", + "applicant_id": "u_op", + }, headers=_admin_headers()) + assert resp.status_code == 200 + iid = resp.json()["data"]["id"] + # 查详情 + resp = client.get(f"{PREFIX}/approvals/{iid}", headers=_admin_headers()) + assert resp.status_code == 200 + assert resp.json()["data"]["status"] == "pending" + # 审批决策 + resp = client.post(f"{PREFIX}/approvals/{iid}/steps/0/decision", json={ + "approver_id": "u_admin", "approved": True, "comment": "ok", + }, headers=_admin_headers()) + assert resp.status_code == 200 + assert resp.json()["data"]["status"] == "approved" + + def test_approval_instance_reject(self, client: TestClient): + resp = client.post(f"{PREFIX}/approvals", json={ + "resource_type": "model", "resource_id": "m_002", + "applicant_id": "u_op", + }, headers=_admin_headers()) + iid = resp.json()["data"]["id"] + resp = client.post(f"{PREFIX}/approvals/{iid}/steps/0/decision", json={ + "approver_id": "u_admin", "approved": False, "comment": "no", + }, headers=_admin_headers()) + assert resp.status_code == 200 + assert resp.json()["data"]["status"] == "rejected" + + def test_approval_missing_field(self, client: TestClient): + resp = client.post(f"{PREFIX}/approvals", json={"resource_type": "dataset"}, headers=_admin_headers()) + assert resp.status_code == 400 + + def test_audit_logs_query(self, client: TestClient): + # 通过 API 写操作触发审计 + client.post(f"{PREFIX}/tenants", json={"name": "Audit-Tenant", "code": "at"}, headers=_admin_headers()) + # 查询 + resp = client.get(f"{PREFIX}/system/audit-logs", params={"limit": 50}, headers=_admin_headers()) + assert resp.status_code == 200 + data = resp.json()["data"] + assert "items" in data + assert "total" in data + assert data["total"] >= 1 + + def test_audit_logs_filter_by_action(self, client: TestClient): + resp = client.get(f"{PREFIX}/system/audit-logs", params={"action": "tenant.create"}, headers=_admin_headers()) + assert resp.status_code == 200 + items = resp.json()["data"]["items"] + assert all(i.get("action") == "tenant.create" for i in items) + + def test_audit_logs_export_csv(self, client: TestClient): + resp = client.get(f"{PREFIX}/system/audit-logs/export", headers=_admin_headers()) + assert resp.status_code == 200 + assert "text/csv" in resp.headers.get("content-type", "") + # CSV 首行是表头 + lines = resp.text.strip().split("\n") + assert "time" in lines[0] + + +# ============================================================ +# 第 4 周测试:写操作审计、审批拦截、权限校验 +# ============================================================ + +class TestWeek4AuditInterceptPermission: + """第 4 周:写操作审计、审批拦截、权限校验。""" + + def test_write_operation_produces_audit(self, client: TestClient, fake_store: FakePlatformStore): + # 清空审计日志便于断言 + fake_store._audit_logs.clear() + # 创建租户 → 应产生 tenant.create 审计 + client.post(f"{PREFIX}/tenants", json={"name": "W-Tenant", "code": "wt"}, headers=_admin_headers()) + assert any(l["action"] == "tenant.create" for l in fake_store._audit_logs) + # 创建项目 → 应产生 project.create 审计 + client.post(f"{PREFIX}/projects", json={"name": "W-Proj", "code": "wp", "tenant_id": "default"}, headers=_admin_headers()) + assert any(l["action"] == "project.create" for l in fake_store._audit_logs) + # 设置 ACL → 应产生 resource.acl.set 审计 + client.put(f"{PREFIX}/resources/model/w001/acl", json={"entries": []}, headers=_admin_headers()) + assert any(l["action"] == "resource.acl.set" for l in fake_store._audit_logs) + + def test_approval_intercept_on_project_archive(self, client: TestClient, fake_store: FakePlatformStore): + # 创建项目 + resp = client.post(f"{PREFIX}/projects", json={"name": "I-Proj", "code": "ip", "tenant_id": "default"}, headers=_admin_headers()) + pid = resp.json()["data"]["id"] + # 无待审批 → 可归档 + resp = client.post(f"{PREFIX}/projects/{pid}/archive", headers=_admin_headers()) + assert resp.status_code == 200 + + def test_approval_intercept_blocks_when_pending(self, client: TestClient, fake_store: FakePlatformStore): + # 创建项目 + resp = client.post(f"{PREFIX}/projects", json={"name": "B-Proj", "code": "bp", "tenant_id": "default"}, headers=_admin_headers()) + pid = resp.json()["data"]["id"] + # 注入一条待审批实例 + fake_store.create_approval_instance({ + "resource_type": "project", + "resource_id": pid, + "applicant_id": "u_op", + }) + # 有待审批 → 归档应被拒绝 + resp = client.post(f"{PREFIX}/projects/{pid}/archive", headers=_admin_headers()) + assert resp.status_code == 409 + + def test_retention_policy_crud_with_audit(self, client: TestClient, fake_store: FakePlatformStore): + fake_store._audit_logs.clear() + # 创建 + resp = client.post(f"{PREFIX}/retention-policies", json={"name": "30d-keep", "scope": "tenant"}, headers=_admin_headers()) + assert resp.status_code == 200 + rpid = resp.json()["data"]["id"] + assert any(l["action"] == "retention.create" for l in fake_store._audit_logs) + # 查列表 + resp = client.get(f"{PREFIX}/retention-policies", headers=_admin_headers()) + assert resp.status_code == 200 + assert any(p["id"] == rpid for p in resp.json()["data"]) + # 更新 + resp = client.put(f"{PREFIX}/retention-policies/{rpid}", json={"status": "inactive"}, headers=_admin_headers()) + assert resp.status_code == 200 + assert resp.json()["data"]["status"] == "inactive" + # 删除 + resp = client.delete(f"{PREFIX}/retention-policies/{rpid}", headers=_admin_headers()) + assert resp.status_code == 200 + + def test_login_duration_rank_in_dashboard(self, client: TestClient): + resp = client.get(f"{PREFIX}/dashboard/stats", headers=_admin_headers()) + assert resp.status_code == 200 + data = resp.json()["data"] + assert "login_duration_rank" in data + assert "recent_login_users" in data + assert "service_status" in data + assert "training_7d" in data diff --git a/compute/requirements.txt b/compute/requirements.txt index ad4ce85..53945b0 100644 --- a/compute/requirements.txt +++ b/compute/requirements.txt @@ -4,3 +4,4 @@ python-multipart>=0.0.9 pydantic>=2.7.0 python-dotenv>=1.0.1 httpx>=0.27.0 +llamafactory \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c9dc7e9..21e861a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,9 +1,62 @@ + + diff --git a/frontend/src/api/modules/acl.ts b/frontend/src/api/modules/acl.ts new file mode 100644 index 0000000..e3186ae --- /dev/null +++ b/frontend/src/api/modules/acl.ts @@ -0,0 +1,15 @@ +import { get, put } from '../request' + +export interface AclEntry { + subject_type: string + subject_id: string + permissions: string[] +} + +/** 资源 ACL 查询 */ +export const getAcl = (resourceType: string, resourceId: string) => + get(`/resources/${resourceType}/${resourceId}/acl`) + +/** 资源 ACL 设置 */ +export const setAcl = (resourceType: string, resourceId: string, entries: AclEntry[]) => + put(`/resources/${resourceType}/${resourceId}/acl`, { entries }) diff --git a/frontend/src/api/modules/approval.ts b/frontend/src/api/modules/approval.ts new file mode 100644 index 0000000..575909c --- /dev/null +++ b/frontend/src/api/modules/approval.ts @@ -0,0 +1,50 @@ +import { get, post } from '../request' + +export interface ApprovalStep { + approver_id?: string | null + status: string +} + +export interface ApprovalTemplate { + id: string + name: string + steps: ApprovalStep[] + create_time?: string +} + +export interface ApprovalInstance { + id: string + template_id?: string | null + resource_type: string + resource_id: string + applicant_id: string + status: string + current_step: number + create_time?: string + steps: Array +} + +export const getApprovalTemplates = () => + get('/approvals/templates') + +export const createApprovalTemplate = (payload: { name: string; steps: ApprovalStep[] }) => + post('/approvals/templates', payload) + +export const getApprovalInstances = (status?: string) => + get('/approvals', { status }) + +export const createApprovalInstance = (payload: { + template_id?: string + resource_type: string + resource_id: string + applicant_id: string +}) => post('/approvals', payload) + +export const getApprovalInstance = (id: string) => + get(`/approvals/${id}`) + +export const decideApproval = ( + id: string, + step_index: number, + payload: { approver_id: string; approved: boolean; comment?: string }, +) => post(`/approvals/${id}/steps/${step_index}/decision`, payload) diff --git a/frontend/src/api/modules/audit.ts b/frontend/src/api/modules/audit.ts new file mode 100644 index 0000000..9999b1e --- /dev/null +++ b/frontend/src/api/modules/audit.ts @@ -0,0 +1,40 @@ +import { get } from '../request' +import request from '../request' + +export interface AuditLog { + id: string + tenant_id?: string + project_id?: string + actor_id?: string + action?: string + target_type?: string + target_id?: string + detail?: string + client_ip?: string + time?: string +} + +export interface AuditQuery { + tenant_id?: string + project_id?: string + actor_id?: string + action?: string + target_type?: string + start_time?: string + end_time?: string + limit?: number + offset?: number +} + +/** 审计日志查询:使用 get 辅助函数,拦截器已解包,直接返回 { items, total } */ +export const getAuditLogs = (query: AuditQuery = {}) => + get<{ items: AuditLog[]; total: number }>('/system/audit-logs', query) + +/** 审计日志导出 CSV:blob 响应走完整 axios response,需手动取 data */ +export const exportAuditLogs = (query: AuditQuery = {}) => + request({ + url: '/system/audit-logs/export', + method: 'get', + params: query, + responseType: 'blob', + }).then((res) => res.data) diff --git a/frontend/src/api/modules/dashboard.ts b/frontend/src/api/modules/dashboard.ts new file mode 100644 index 0000000..4611176 --- /dev/null +++ b/frontend/src/api/modules/dashboard.ts @@ -0,0 +1,35 @@ +import { get } from '../request' + +export interface ServiceStatusStat { + type: string + status: 'normal' | 'busy' | 'error' + count: number +} + +export interface TrainingTaskStat { + id: string + name: string + status: string + train_type: string + train_method: string + base_model: string + progress: number + accuracy: number | null + started_at: string +} + +export interface DashboardStats { + online_services: number + running_tasks: number + pending_alerts: number + training_7d: { date: string; train: number; gpu: number; accuracy: number | null }[] + service_status: ServiceStatusStat[] + training_tasks: TrainingTaskStat[] + operation_distribution: { name: string; value: number }[] + login_duration_rank: { user: string; role: string; duration: number }[] + recent_login_users: { user: string; role: string; last_login: string }[] +} + +export function getDashboardStats() { + return get('/dashboard/stats') +} diff --git a/frontend/src/api/modules/project.ts b/frontend/src/api/modules/project.ts new file mode 100644 index 0000000..503e068 --- /dev/null +++ b/frontend/src/api/modules/project.ts @@ -0,0 +1,55 @@ +import { del, get, post, put } from '../request' + +export interface Project { + id: string + tenant_id: string + name: string + code: string + description?: string + status: string + quota?: Record + member_count?: number + task_count?: number + create_time?: string +} + +export interface ProjectMember { + user_id: string + role: string + joined_at?: string +} + +/** 项目列表(按租户过滤,默认 default) */ +export const getProjects = (tenantId = 'default') => + get('/projects', { tenant_id: tenantId }) + +/** 项目详情 */ +export const getProject = (id: string) => get(`/projects/${id}`) + +/** 创建项目 */ +export const createProject = (payload: Partial) => + post('/projects', payload) + +/** 更新项目 */ +export const updateProject = (id: string, payload: Partial) => + put(`/projects/${id}`, payload) + +/** 归档项目 */ +export const archiveProject = (id: string) => + post(`/projects/${id}/archive`) + +/** 项目成员列表 */ +export const getProjectMembers = (id: string) => + get(`/projects/${id}/members`) + +/** 添加成员 */ +export const addProjectMember = (id: string, payload: { user_id: string; role: string }) => + post(`/projects/${id}/members`, payload) + +/** 更新成员角色 */ +export const updateProjectMember = (id: string, userId: string, role: string) => + put(`/projects/${id}/members/${userId}`, { role }) + +/** 移除成员 */ +export const removeProjectMember = (id: string, userId: string) => + del(`/projects/${id}/members/${userId}`) diff --git a/frontend/src/api/modules/retention.ts b/frontend/src/api/modules/retention.ts new file mode 100644 index 0000000..8b870ba --- /dev/null +++ b/frontend/src/api/modules/retention.ts @@ -0,0 +1,29 @@ +import { del, get, post, put } from '../request' + +export interface RetentionPolicy { + id: string + name: string + scope?: string | null + rule?: string | null + status: string + create_time?: string + create_by?: string | null + updated_at?: string +} + +/** 留存策略列表 */ +export const getRetentionPolicies = () => get('/retention-policies') + +/** 留存策略详情 */ +export const getRetentionPolicy = (id: string) => get(`/retention-policies/${id}`) + +/** 创建留存策略 */ +export const createRetentionPolicy = (payload: Partial) => + post('/retention-policies', payload) + +/** 更新留存策略 */ +export const updateRetentionPolicy = (id: string, payload: Partial) => + put(`/retention-policies/${id}`, payload) + +/** 删除留存策略 */ +export const deleteRetentionPolicy = (id: string) => del(`/retention-policies/${id}`) diff --git a/frontend/src/api/modules/system.ts b/frontend/src/api/modules/system.ts index 4df2a4d..0342a6a 100644 --- a/frontend/src/api/modules/system.ts +++ b/frontend/src/api/modules/system.ts @@ -25,12 +25,14 @@ export const getUsers = () => get('/users') export const createUser = (payload: CreateUserPayload) => post('/users', payload) -/** 删除用户,currentUsername 用于防止删除当前登录账号 */ -export const deleteUser = (id: string, currentUsername: string) => - del<{ deleted: string }>(`/users/${encodeURIComponent(id)}`, { - current_username: currentUsername, - }) - /** 更新用户角色、状态及页面权限 */ export const updateUserAccess = (id: string, payload: UpdateUserAccessPayload) => put(`/users/${encodeURIComponent(id)}`, payload) + +/** 重置用户密码 */ +export const resetUserPassword = (id: string, password?: string) => + post<{ reset: string }>(`/users/${encodeURIComponent(id)}/reset-password`, { password }) + +/** 删除用户(protected 管理员账号不允许删除) */ +export const deleteUser = (id: string) => + del<{ deleted: string }>(`/users/${encodeURIComponent(id)}`) diff --git a/frontend/src/api/modules/tenant.ts b/frontend/src/api/modules/tenant.ts new file mode 100644 index 0000000..637d302 --- /dev/null +++ b/frontend/src/api/modules/tenant.ts @@ -0,0 +1,34 @@ +import { get, post, put } from '../request' + +export interface Tenant { + id: string + name: string + code: string + status: string + owner_user_id?: string | null + quota: Record + retention_policy_id?: string | null + create_time?: string +} + +/** 租户列表 */ +export const getTenants = () => get('/tenants') + +/** 租户详情 */ +export const getTenant = (id: string) => get(`/tenants/${id}`) + +/** 创建租户 */ +export const createTenant = (payload: Partial) => + post('/tenants', payload) + +/** 更新租户 */ +export const updateTenant = (id: string, payload: Partial) => + put(`/tenants/${id}`, payload) + +/** 设置租户配额 */ +export const setTenantQuota = (id: string, quota: Record) => + put(`/tenants/${id}/quota`, { quota }) + +/** 设置租户留存策略 */ +export const setTenantRetention = (id: string, retention_policy_id: string) => + put(`/tenants/${id}/retention-policy`, { retention_policy_id }) diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index 2ba341d..e778400 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -1,6 +1,5 @@ import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios' import { ElMessage } from 'element-plus' -import { touchSessionActivity } from '@/utils/sessionActivity' /** * 后端统一响应格式 @@ -18,9 +17,37 @@ const service: AxiosInstance = axios.create({ timeout: 30000, }) -// 请求拦截器 +/** + * 从 localStorage 取当前用户 token(登录时后端返回 platform-token-{user_id})。 + * 后端鉴权中间件依赖此 header 解析当前用户身份。 + */ +function getAuthToken(): string | null { + const USER_STORAGE_KEY = 'currentUser' + const raw = localStorage.getItem(USER_STORAGE_KEY) + if (raw) { + try { + const user = JSON.parse(raw) + // 后端 login 返回的 token 格式为 platform-token-{user.id} + if (user?.id) return `platform-token-${user.id}` + } catch { + /* ignore */ + } + } + // 兼容改造前 admin 会话 + if (localStorage.getItem('username') === 'admin') return 'platform-token-admin' + return null +} + +// 请求拦截器:注入 Authorization header service.interceptors.request.use( - (config) => config, + (config) => { + const token = getAuthToken() + if (token) { + config.headers = config.headers || {} + config.headers['Authorization'] = `Bearer ${token}` + } + return config + }, (error) => Promise.reject(error), ) @@ -30,12 +57,9 @@ service.interceptors.response.use( const res = response.data as ApiResult // 二进制流等非 JSON 响应直接返回 if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') { - touchSessionActivity() return response } if (res.code === 0) { - // 生成进度轮询也属于用户正在使用系统,避免长任务结束后被误判为会话过期。 - touchSessionActivity() return res.data } // 业务错误 diff --git a/frontend/src/components/AclDialog.vue b/frontend/src/components/AclDialog.vue new file mode 100644 index 0000000..5d83286 --- /dev/null +++ b/frontend/src/components/AclDialog.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 86473a7..45e7a96 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -74,6 +74,16 @@ const menuGroups: MenuGroup[] = [ { key: 'compute', label: '算力节点', icon: 'fa-microchip', to: '/compute', permission: 'compute' }, ], }, + { + title: '平台治理', + items: [ + { key: 'tenants', label: '租户管理', icon: 'fa-building', to: '/tenants', permission: 'user-settings' }, + { key: 'projects', label: '项目空间', icon: 'fa-folder', to: '/projects', permission: 'user-settings' }, + { key: 'audit-logs', label: '审计日志', icon: 'fa-history', to: '/audit-logs', 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' }, + ], + }, { title: '系统设置', items: [ diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 97aedcc..2cd82d7 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,7 +1,11 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' +import { ref } from 'vue' import { useAuthStore } from '@/stores/auth' import type { PermissionCode } from '@/types' +/** 路由切换时的全局加载态,供 App.vue 显示全屏转圈遮罩,消除懒加载时的空白卡顿感 */ +export const routeLoading = ref(false) + const routes: RouteRecordRaw[] = [ { path: '/login', @@ -27,6 +31,49 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/dashboard/DashboardView.vue'), meta: { title: '服务看板' }, }, + // 平台治理 + { + path: 'tenants', + name: 'tenants', + component: () => import('@/views/tenants/TenantListView.vue'), + meta: { title: '租户管理', permission: 'user-settings' }, + }, + { + path: 'tenants/:id', + name: 'tenant-detail', + component: () => import('@/views/tenants/TenantDetailView.vue'), + meta: { title: '租户详情', permission: 'user-settings' }, + }, + { + path: 'projects', + name: 'projects', + component: () => import('@/views/projects/ProjectListView.vue'), + meta: { title: '项目空间', permission: 'user-settings' }, + }, + { + path: 'projects/:id', + name: 'project-detail', + component: () => import('@/views/projects/ProjectDetailView.vue'), + meta: { title: '项目详情', permission: 'user-settings' }, + }, + { + path: 'audit-logs', + name: 'audit-logs', + component: () => import('@/views/audit/AuditLogView.vue'), + meta: { title: '审计日志', permission: 'user-settings' }, + }, + { + path: 'approval-templates', + name: 'approval-templates', + component: () => import('@/views/approvals/ApprovalTemplateView.vue'), + meta: { title: '审批模板', permission: 'user-settings' }, + }, + { + path: 'approval-instances', + name: 'approval-instances', + component: () => import('@/views/approvals/ApprovalInstanceView.vue'), + meta: { title: '审批中心', permission: 'user-settings' }, + }, // 模型调优 { path: 'fine-tune', @@ -299,6 +346,11 @@ const permissionBySegment: Record = { hardware: 'hardware', logs: 'logs', 'user-settings': 'user-settings', + tenants: 'user-settings', + projects: 'user-settings', + 'audit-logs': 'user-settings', + 'approval-templates': 'user-settings', + 'approval-instances': 'user-settings', } function requiredPermission(path: string, explicit?: unknown) { @@ -307,10 +359,11 @@ function requiredPermission(path: string, explicit?: unknown) { return permissionBySegment[segment] } -// 全局守卫:登录校验 + 会话超时 +// 全局守卫:登录校验 +// 离开页面超时由 App.vue 的 visibilitychange 监听接管 router.beforeEach((to, _from, next) => { + if (!to.meta.public) routeLoading.value = true const auth = useAuthStore() - auth.syncSession() document.title = to.meta.title ? `${to.meta.title} - 远光软件微调平台` : '远光软件微调平台' if (to.meta.public) { @@ -324,6 +377,7 @@ router.beforeEach((to, _from, next) => { } if (!auth.isLoggedIn) { + auth.logout() next({ name: 'login' }) return } @@ -336,9 +390,11 @@ router.beforeEach((to, _from, next) => { } } - // 续期会话 - auth.refresh() next() }) +router.afterEach(() => { + routeLoading.value = false +}) + export default router diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 8fa9712..3fcd9bf 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -1,15 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { login as loginApi } from '@/api/modules/system' -import { SESSION_TIMEOUT } from '@/constants' import type { PermissionCode, SystemUser } from '@/types' -import { - clearSessionActivity, - sessionActivityTime, - startSessionActivity, - syncSessionActivity, - touchSessionActivity, -} from '@/utils/sessionActivity' const USER_STORAGE_KEY = 'currentUser' @@ -56,7 +48,8 @@ function restoreUser(): SystemUser | null { /** * 认证 store - * 沿用原项目 localStorage 的登录时间戳 + 5 分钟会话超时机制 + * 登录态管理:有 currentUser 即视为已登录。 + * 离开页面超时由 App.vue 的 visibilitychange 监听接管。 */ export const useAuthStore = defineStore('auth', () => { const currentUser = ref(restoreUser()) @@ -67,18 +60,13 @@ export const useAuthStore = defineStore('auth', () => { if (currentUser.value?.role === 'operator') return '操作员' return '观察员' }) - const loginTime = sessionActivityTime - const isLoggedIn = computed(() => { - if (!loginTime.value) return false - return Date.now() - loginTime.value < SESSION_TIMEOUT - }) + const isLoggedIn = computed(() => currentUser.value !== null) /** 登录 */ async function login(user: string, password: string) { const response = await loginApi(user, password) currentUser.value = response.user - startSessionActivity() localStorage.setItem('username', response.user.username) localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user)) } @@ -89,20 +77,9 @@ export const useAuthStore = defineStore('auth', () => { return currentUser.value?.permissions.includes(permission) ?? false } - /** 续期会话(活跃时刷新) */ - function refresh() { - if (currentUser.value) touchSessionActivity() - } - - /** 在路由判断前吸收其他标签页写入的最后活跃时间。 */ - function syncSession() { - syncSessionActivity() - } - /** 退出 */ function logout() { currentUser.value = null - clearSessionActivity() localStorage.removeItem('username') localStorage.removeItem(USER_STORAGE_KEY) } @@ -112,12 +89,9 @@ export const useAuthStore = defineStore('auth', () => { username, displayName, roleLabel, - loginTime, isLoggedIn, hasPermission, login, - refresh, - syncSession, logout, } }) diff --git a/frontend/src/utils/sessionActivity.ts b/frontend/src/utils/sessionActivity.ts index d0b488a..d1b4e9a 100644 --- a/frontend/src/utils/sessionActivity.ts +++ b/frontend/src/utils/sessionActivity.ts @@ -8,7 +8,7 @@ function storedActivityTime() { /** * 会话按“最后活跃时间”计算,而不是从首次登录起固定倒计时。 - * 该 ref 被认证 store 与请求层共享,确保 API 活动可以立即影响路由守卫。 + * 该 ref 被认证 store 与路由守卫共享,确保真实用户活动可以立即影响超时判断。 */ export const sessionActivityTime = ref(storedActivityTime()) @@ -30,3 +30,45 @@ export function clearSessionActivity() { sessionActivityTime.value = 0 localStorage.removeItem(LOGIN_TIME_STORAGE_KEY) } + +/** + * 仅在用户真实活跃时续期会话: + * - 鼠标移动 / 键盘 / 点击 / 触摸(说明用户正在操作) + * - 标签页切回可见(说明用户回到界面) + * 页面后台轮询接口、切走标签页不会续期,从而“无操作”或“不在当前界面” + * 超过空闲时长才会被判定为会话过期并跳回登录。 + */ +let userActivityBound = false +let lastTouch = 0 +const ACTIVITY_THROTTLE = 5000 // 5s 内最多续期一次,避免 mousemove 过于频繁 + +const activityEvents = ['mousemove', 'mousedown', 'keydown', 'click', 'touchstart'] as const + +function handleUserActivity() { + const now = Date.now() + if (now - lastTouch < ACTIVITY_THROTTLE) return + lastTouch = now + touchSessionActivity() +} + +function handleVisibility() { + if (!document.hidden) { + touchSessionActivity() + } +} + +export function bindUserActivityListeners() { + if (userActivityBound) return + userActivityBound = true + activityEvents.forEach((evt) => + window.addEventListener(evt, handleUserActivity, { passive: true }) + ) + document.addEventListener('visibilitychange', handleVisibility) +} + +export function unbindUserActivityListeners() { + if (!userActivityBound) return + userActivityBound = false + activityEvents.forEach((evt) => window.removeEventListener(evt, handleUserActivity)) + document.removeEventListener('visibilitychange', handleVisibility) +} diff --git a/frontend/src/views/approvals/ApprovalInstanceView.vue b/frontend/src/views/approvals/ApprovalInstanceView.vue new file mode 100644 index 0000000..db29b4f --- /dev/null +++ b/frontend/src/views/approvals/ApprovalInstanceView.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/frontend/src/views/approvals/ApprovalTemplateView.vue b/frontend/src/views/approvals/ApprovalTemplateView.vue new file mode 100644 index 0000000..0ccb0a2 --- /dev/null +++ b/frontend/src/views/approvals/ApprovalTemplateView.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/views/audit/AuditLogView.vue b/frontend/src/views/audit/AuditLogView.vue new file mode 100644 index 0000000..9125c1b --- /dev/null +++ b/frontend/src/views/audit/AuditLogView.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/frontend/src/views/dashboard/DashboardView.vue b/frontend/src/views/dashboard/DashboardView.vue index 23495fc..76a660b 100644 --- a/frontend/src/views/dashboard/DashboardView.vue +++ b/frontend/src/views/dashboard/DashboardView.vue @@ -1,9 +1,10 @@ + + + + diff --git a/frontend/src/views/projects/ProjectListView.vue b/frontend/src/views/projects/ProjectListView.vue new file mode 100644 index 0000000..5f9447b --- /dev/null +++ b/frontend/src/views/projects/ProjectListView.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/src/views/system/LogsView.vue b/frontend/src/views/system/LogsView.vue index 9b8319a..ab30a72 100644 --- a/frontend/src/views/system/LogsView.vue +++ b/frontend/src/views/system/LogsView.vue @@ -26,6 +26,7 @@ const trainContent = ref('') // 搜索 const keyword = ref('') +const level = ref('') // 日志级别筛选:INFO/WARN/ERROR/空=全部 const fullContent = ref('') // 自动刷新 @@ -33,11 +34,16 @@ const refreshInterval = ref(10) const { remaining, start: startCountdown, stop: stopCountdown } = useCountdown(10) const filteredLog = computed(() => { - if (!keyword.value.trim()) return { content: fullContent.value, count: 0 } - const kw = keyword.value.toLowerCase().trim() - const lines = fullContent.value - .split('\n') - .filter((line) => line.toLowerCase().includes(kw)) + let lines = fullContent.value.split('\n') + // 级别筛选 + if (level.value) { + lines = lines.filter((line) => line.toUpperCase().includes(level.value.toUpperCase())) + } + // 关键词筛选 + if (keyword.value.trim()) { + const kw = keyword.value.toLowerCase().trim() + lines = lines.filter((line) => line.toLowerCase().includes(kw)) + } return { content: lines.join('\n'), count: lines.length } }) @@ -176,12 +182,19 @@ onMounted(() => {
-
- - - - {{ matchCount }} 条匹配 -
+
+ + + + + + + + + + + {{ matchCount }} 条匹配 +
{{ filteredContent || (activeTab === 'system' ? sysContent : trainContent) || '日志内容将在这里显示...' }}
diff --git a/frontend/src/views/system/UserSettingsView.vue b/frontend/src/views/system/UserSettingsView.vue index c9ad0d5..2ddb063 100644 --- a/frontend/src/views/system/UserSettingsView.vue +++ b/frontend/src/views/system/UserSettingsView.vue @@ -1,12 +1,36 @@ @@ -67,4 +262,25 @@ onMounted(loadUsers) margin: 8px 0 0; color: #64748b; } + +.perm-tag { + margin-right: 4px; + margin-bottom: 2px; +} + +.perm-more { + color: #94a3b8; + font-size: 12px; +} + +.dlg-tip { + margin: 0 0 12px; + color: #475569; +} + +.perm-group { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px 12px; +} diff --git a/frontend/src/views/tenants/TenantDetailView.vue b/frontend/src/views/tenants/TenantDetailView.vue new file mode 100644 index 0000000..1a62723 --- /dev/null +++ b/frontend/src/views/tenants/TenantDetailView.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/frontend/src/views/tenants/TenantListView.vue b/frontend/src/views/tenants/TenantListView.vue new file mode 100644 index 0000000..087d0b3 --- /dev/null +++ b/frontend/src/views/tenants/TenantListView.vue @@ -0,0 +1,111 @@ + + + + +