完善部分平台治理功能,及修改看板缺陷
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
from __future__ import annotations
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
@@ -442,16 +442,14 @@ async def dashboard_stats() -> dict[str, Any]:
|
||||
|
||||
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"]
|
||||
# 评测中运行的任务
|
||||
running_eval = [e for e in eval_tasks if e.get("status") in running_statuses]
|
||||
# 数据处理中运行的任务
|
||||
# 评测中运行的任务数
|
||||
eval_running = 0
|
||||
try:
|
||||
dp_running = int(dp_store.list_tasks(page=1, page_size=1, status="running").get("total", 0))
|
||||
eval_tasks = store.eval_tasks()
|
||||
eval_running = len([e for e in eval_tasks if e.get("status") in running_statuses])
|
||||
except Exception:
|
||||
dp_running = 0
|
||||
eval_running = 0
|
||||
|
||||
# 近 7 天训练统计(按创建日期分桶)
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -472,45 +470,30 @@ async def dashboard_stats() -> dict[str, Any]:
|
||||
}
|
||||
)
|
||||
|
||||
# 服务状态 —— 通过对应接口连通性判断是否正常
|
||||
# 服务状态 —— 每个服务的"实例数"含义:
|
||||
# 模型训练 → 训练任务总数
|
||||
# 模型评测 → 评测任务总数
|
||||
# 模型推理 → 推理/对比任务实例数
|
||||
# 模型管理 → 基座模型注册总数
|
||||
# 数据集管理 → 数据集总数
|
||||
# 数据处理 → 数据处理任务总数
|
||||
# 数据类型转换 → 数据转换任务总数
|
||||
service_checks = [
|
||||
("模型训练", "/fine-tune", "模型训练"),
|
||||
("模型评测", "/model-eval", "模型评测"),
|
||||
("模型推理", "/model-inference", "模型推理"),
|
||||
("模型管理", "/model-manage", "模型管理"),
|
||||
("数据集管理", "/dataset-manage", "数据集管理"),
|
||||
("数据处理", "/data-process", "数据处理"),
|
||||
("数据类型转换", "/data-convert", "数据类型转换"),
|
||||
("模型训练", "fine-tune", len(tasks)),
|
||||
("模型评测", "model-eval", len(eval_tasks)),
|
||||
("模型推理", "model-inference", len(store.compare_tasks())),
|
||||
("模型管理", "model-manage", len(store.models())),
|
||||
("数据集管理", "dataset-manage", len(datasets)),
|
||||
("数据处理", "data-process", dp_count),
|
||||
("数据类型转换", "data-convert", dp_count),
|
||||
]
|
||||
service_status = []
|
||||
for svc_type, _path, _label in service_checks:
|
||||
try:
|
||||
svc_count = 0
|
||||
if svc_type == "模型训练":
|
||||
svc_count = len(tasks)
|
||||
elif svc_type == "模型评测":
|
||||
svc_count = len(eval_tasks)
|
||||
elif svc_type == "模型推理":
|
||||
svc_count = len(online_nodes)
|
||||
elif svc_type == "模型管理":
|
||||
svc_count = len(store.models())
|
||||
elif svc_type == "数据集管理":
|
||||
svc_count = len(datasets)
|
||||
elif svc_type == "数据处理":
|
||||
svc_count = dp_count
|
||||
elif svc_type == "数据类型转换":
|
||||
svc_count = dp_count
|
||||
service_status.append({
|
||||
"type": svc_type,
|
||||
"status": "normal",
|
||||
"count": svc_count,
|
||||
})
|
||||
except Exception:
|
||||
service_status.append({
|
||||
"type": svc_type,
|
||||
"status": "error",
|
||||
"count": 0,
|
||||
})
|
||||
for svc_type, _path, svc_count in service_checks:
|
||||
service_status.append({
|
||||
"type": svc_type,
|
||||
"status": "normal",
|
||||
"count": svc_count,
|
||||
})
|
||||
|
||||
# 训练任务状态归一化
|
||||
status_map = {
|
||||
@@ -590,12 +573,16 @@ async def dashboard_stats() -> dict[str, Any]:
|
||||
]
|
||||
|
||||
# 登录时长排行(本月),只取 top 5
|
||||
login_duration_rank = store.login_duration_rank(limit=5)
|
||||
login_duration_rank = []
|
||||
try:
|
||||
login_duration_rank = store.login_duration_rank(limit=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ok(
|
||||
{
|
||||
"online_services": sum(s["count"] for s in service_status),
|
||||
"running_tasks": len(running_ft) + len(running_eval) + dp_running,
|
||||
"running_tasks": len(running_ft) + eval_running,
|
||||
"pending_alerts": 0,
|
||||
"training_7d": training_7d,
|
||||
"service_status": service_status,
|
||||
@@ -656,6 +643,29 @@ async def reset_user_password(
|
||||
raise fail(400, str(exc))
|
||||
|
||||
|
||||
@router.post("/users/me/password")
|
||||
async def change_my_password(
|
||||
payload: dict[str, Any] = Body(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""用户自行修改密码:验证旧密码后设置新密码。"""
|
||||
old_password = payload.get("old_password") or ""
|
||||
new_password = payload.get("new_password") or ""
|
||||
if not old_password or not new_password:
|
||||
raise fail(400, "old_password and new_password are required")
|
||||
if len(new_password) < 6:
|
||||
raise fail(400, "new password must be at least 6 characters")
|
||||
try:
|
||||
success = get_platform_store().change_password(
|
||||
current_user["id"], old_password, new_password
|
||||
)
|
||||
except KeyError:
|
||||
raise fail(404, "user not found")
|
||||
if not success:
|
||||
raise fail(400, "old password is incorrect")
|
||||
return ok({"changed": True})
|
||||
|
||||
|
||||
@router.get("/model-manage/local-models")
|
||||
async def local_models() -> dict[str, Any]:
|
||||
store = get_platform_store()
|
||||
@@ -685,8 +695,15 @@ async def local_models() -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.get("/model-manage/trained-models")
|
||||
async def trained_models() -> dict[str, Any]:
|
||||
return ok({"models": get_platform_store().trained_models()})
|
||||
async def trained_models(current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
all_models = get_platform_store().trained_models()
|
||||
if is_admin(current_user):
|
||||
return ok({"models": all_models})
|
||||
# 普通用户只能看到自己创建的 + ACL 授权的
|
||||
user_id = current_user.get("id")
|
||||
accessible = set(filter_accessible_resource_ids("trained_model", [m["id"] for m in all_models], current_user))
|
||||
result = [m for m in all_models if m.get("created_by") == user_id or m["id"] in accessible]
|
||||
return ok({"models": result})
|
||||
|
||||
|
||||
@router.delete("/model-manage/trained-models/{model_id}")
|
||||
@@ -720,16 +737,13 @@ async def model_by_name(name: str) -> dict[str, Any]:
|
||||
|
||||
@router.get("/model-manage")
|
||||
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])
|
||||
# 基座模型是平台共享资源,所有登录用户均可查看
|
||||
return ok(get_platform_store().models())
|
||||
|
||||
|
||||
@router.post("/model-manage")
|
||||
async def create_model(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
async def create_model(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
payload.setdefault("created_by", current_user.get("id"))
|
||||
try:
|
||||
return ok(get_platform_store().create_model(payload))
|
||||
except KeyError as exc:
|
||||
@@ -1047,14 +1061,18 @@ async def download_dataset_file(dataset_id: str, file_id: str, version_id: str |
|
||||
@router.get("/dataset-manage")
|
||||
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"):
|
||||
if is_admin(current_user):
|
||||
return ok(datasets)
|
||||
# 普通用户可见:自己创建的 + ACL 授权的
|
||||
user_id = current_user.get("id")
|
||||
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])
|
||||
result = [d for d in datasets if d.get("created_by") == user_id or d["id"] in accessible]
|
||||
return ok(result)
|
||||
|
||||
|
||||
@router.post("/dataset-manage")
|
||||
async def create_dataset(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
async def create_dataset(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
payload.setdefault("created_by", current_user.get("id"))
|
||||
dataset = get_platform_store().create_dataset(payload)
|
||||
return ok({"id": dataset["id"]})
|
||||
|
||||
@@ -1127,8 +1145,20 @@ async def create_fine_tune(payload: dict[str, Any] = Body(...)) -> dict[str, Any
|
||||
|
||||
|
||||
@router.post("/fine-tune/start")
|
||||
async def start_fine_tune(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
async def start_fine_tune(
|
||||
payload: dict[str, Any] = Body(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
store = get_platform_store()
|
||||
# GPU 权限校验:普通用户只能使用被分配的 GPU
|
||||
if not is_admin(current_user):
|
||||
node_id = payload.get("compute_node_id") or payload.get("node_id")
|
||||
gpu_indices = payload.get("gpus") or []
|
||||
if node_id and gpu_indices:
|
||||
if not store.check_gpu_access(current_user["id"], node_id, gpu_indices):
|
||||
raise fail(403, "无权使用所选 GPU,请联系管理员分配")
|
||||
# 记录创建者
|
||||
payload.setdefault("created_by", current_user.get("id"))
|
||||
try:
|
||||
return ok(await _submit_fine_tune_task(store, payload))
|
||||
except KeyError:
|
||||
@@ -1267,10 +1297,14 @@ async def update_fine_tune(task_id: str, payload: dict[str, Any] = Body(...)) ->
|
||||
|
||||
|
||||
@router.post("/fine-tune/stop/{task_id}")
|
||||
async def stop_fine_tune(task_id: str) -> dict[str, Any]:
|
||||
async def stop_fine_tune(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
store = get_platform_store()
|
||||
try:
|
||||
task = store.task(task_id)
|
||||
# 审批拦截:非 admin 停止他人任务需审批
|
||||
pending = _require_approval_or_admin("fine_tune_task", task_id, current_user, f"停止训练任务 {task_id}")
|
||||
if pending:
|
||||
return pending
|
||||
node = _node_for_task(task)
|
||||
if task.get("compute_job_id") and node and get_settings().compute_mode != "simulator":
|
||||
job = await ComputeNodeClient(node["api_base_url"]).stop_job(task["compute_job_id"])
|
||||
@@ -1281,8 +1315,8 @@ async def stop_fine_tune(task_id: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.post("/fine-tune/{task_id}/stop")
|
||||
async def stop_fine_tune_alt(task_id: str) -> dict[str, Any]:
|
||||
return await stop_fine_tune(task_id)
|
||||
async def stop_fine_tune_alt(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
return await stop_fine_tune(task_id, current_user)
|
||||
|
||||
|
||||
@router.post("/fine-tune/{task_id}/retry")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints.data_process import router as data_process_router
|
||||
from app.api.v1.endpoints.platform import router as platform_router
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
from app.modules.gpu.router import router as gpu_router
|
||||
from app.modules.data_convert.router import router as data_convert_router
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -21,4 +22,5 @@ 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"])
|
||||
api_router.include_router(gpu_router, tags=["gpu-assignment"])
|
||||
api_router.include_router(data_convert_router, tags=["data-convert"])
|
||||
|
||||
@@ -525,7 +525,7 @@ class PlatformStore:
|
||||
},
|
||||
)
|
||||
schema_dir = Path(__file__).with_name("sql")
|
||||
for extra in ("002_governance.sql", "003_tenant_quota.sql"):
|
||||
for extra in ("002_governance.sql", "003_tenant_quota.sql", "004_permissions.sql"):
|
||||
extra_path = schema_dir / extra
|
||||
if extra_path.exists():
|
||||
conn.executescript(extra_path.read_text(encoding="utf-8"))
|
||||
@@ -667,8 +667,8 @@ class PlatformStore:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO trained_models
|
||||
(id, name, train_methods, base_model_path, create_time, merged, merging, merged_path, artifact_dir, compute_node_id, compute_node_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, name, train_methods, base_model_path, create_time, merged, merging, merged_path, artifact_dir, compute_node_id, compute_node_name, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
trained_model_id,
|
||||
@@ -682,6 +682,7 @@ class PlatformStore:
|
||||
output_dir,
|
||||
task.get("compute_node_id"),
|
||||
task.get("compute_node_code") or task.get("compute_node_name"),
|
||||
task.get("created_by"),
|
||||
),
|
||||
)
|
||||
# Use real artifact data from compute node when available
|
||||
@@ -1284,10 +1285,22 @@ class PlatformStore:
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError(user_id)
|
||||
is_admin = row["role"] == "admin" or bool(row["protected"])
|
||||
if "permissions" in payload:
|
||||
perms = payload["permissions"]
|
||||
if is_admin:
|
||||
# 管理员权限不可更改,必须是全部
|
||||
perms = ALL_PERMISSIONS
|
||||
else:
|
||||
# 非 admin 用户不能拥有 user-settings 权限
|
||||
perms = [p for p in (perms or []) if p != "user-settings"]
|
||||
payload = {**payload, "permissions": perms}
|
||||
values = {
|
||||
"role": payload.get("role", row["role"]),
|
||||
"status": payload.get("status", row["status"]),
|
||||
"permissions": json_dumps(payload.get("permissions", json_loads(row["permissions"], []))),
|
||||
"permissions": json_dumps(
|
||||
payload.get("permissions", json_loads(row["permissions"], []))
|
||||
),
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE users SET role=?, status=?, permissions=? WHERE id=?",
|
||||
@@ -1302,6 +1315,34 @@ class PlatformStore:
|
||||
raise KeyError(user_id)
|
||||
if row["protected"]:
|
||||
raise ValueError("protected user cannot be deleted")
|
||||
# 级联删除该用户关联的数据
|
||||
tables_to_clean = [
|
||||
# ACL 授权
|
||||
("acls", "principal_type='user' AND principal_id=?", [user_id]),
|
||||
# 审批实例(申请人)
|
||||
("approval_instances", "applicant_id=?", [user_id]),
|
||||
# 审计日志
|
||||
("audit_logs", "actor_id=?", [user_id]),
|
||||
# 项目成员
|
||||
("project_members", "user_id=?", [user_id]),
|
||||
# GPU 分配
|
||||
("gpu_assignments", "user_id=?", [user_id]),
|
||||
# 数据集
|
||||
("datasets", "created_by=?", [user_id]),
|
||||
# 基座模型
|
||||
("models", "created_by=?", [user_id]),
|
||||
# 微调产物
|
||||
("trained_models", "created_by=?", [user_id]),
|
||||
# 评测任务
|
||||
("eval_tasks", "created_by=?", [user_id]),
|
||||
# 对比/推理任务(payload 中 creator)
|
||||
# 训练任务:仅标记为已删除或保留(有 compute_job_id 关联),不清物理数据
|
||||
]
|
||||
for table_name, where_clause, params in tables_to_clean:
|
||||
try:
|
||||
conn.execute(f"DELETE FROM {table_name} WHERE {where_clause}", params)
|
||||
except Exception:
|
||||
pass # 表可能不存在或字段不存在,跳过
|
||||
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||
|
||||
def reset_password(self, user_id: str, new_password: str) -> None:
|
||||
@@ -1357,8 +1398,8 @@ class PlatformStore:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO models
|
||||
(id, name, type, purpose, model_source, description, path, api_url, api_key, online_model_name, can_train, create_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, name, type, purpose, model_source, description, path, api_url, api_key, online_model_name, can_train, create_time, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
model_id,
|
||||
@@ -1373,6 +1414,7 @@ class PlatformStore:
|
||||
payload.get("online_model_name"),
|
||||
can_train,
|
||||
utcnow(),
|
||||
payload.get("created_by"),
|
||||
),
|
||||
)
|
||||
return dict(conn.execute("SELECT * FROM models WHERE id=?", (model_id,)).fetchone())
|
||||
@@ -1540,8 +1582,8 @@ class PlatformStore:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO datasets
|
||||
(id, name, type, storage_type, source, task_id, size, count, description, create_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, name, type, storage_type, source, task_id, size, count, description, create_time, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
dataset_id,
|
||||
@@ -1554,6 +1596,7 @@ class PlatformStore:
|
||||
payload.get("count", 0),
|
||||
payload.get("description"),
|
||||
utcnow(),
|
||||
payload.get("created_by"),
|
||||
),
|
||||
)
|
||||
return self._dataset(conn, conn.execute("SELECT * FROM datasets WHERE id=?", (dataset_id,)).fetchone())
|
||||
@@ -4074,6 +4117,130 @@ class PlatformStore:
|
||||
"DELETE FROM retention_policies WHERE id=?", (policy_id,)
|
||||
)
|
||||
|
||||
# ===================== 平台治理:GPU 算力分配 =====================
|
||||
|
||||
def gpu_assignments(self) -> list[dict[str, Any]]:
|
||||
"""查询全部分配关系。"""
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT ga.*, u.username, u.display_name,
|
||||
n.code AS node_code, n.name AS node_name, g.name AS gpu_name
|
||||
FROM gpu_assignments ga
|
||||
LEFT JOIN users u ON u.id = ga.user_id
|
||||
LEFT JOIN compute_nodes n ON n.id = ga.node_id
|
||||
LEFT JOIN gpus g ON g.node_id = ga.node_id AND g.gpu_index = ga.gpu_index
|
||||
ORDER BY ga.assigned_at DESC
|
||||
"""
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def gpu_assignments_for_user(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""查询某用户被分配的 GPU 列表。"""
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT ga.node_id, ga.gpu_index,
|
||||
n.code AS node_code, n.name AS node_name,
|
||||
g.name AS gpu_name, g.uuid, g.memory_total_gb
|
||||
FROM gpu_assignments ga
|
||||
JOIN compute_nodes n ON n.id = ga.node_id
|
||||
LEFT JOIN gpus g ON g.node_id = ga.node_id AND g.gpu_index = ga.gpu_index
|
||||
WHERE ga.user_id = ?
|
||||
ORDER BY n.code, ga.gpu_index
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def assign_gpus(self, assignments: list[dict[str, Any]], assigned_by: str | None = None) -> list[dict[str, Any]]:
|
||||
"""批量分配 GPU(幂等:已存在的分配跳过)。"""
|
||||
now = utcnow()
|
||||
with self.connect() as conn:
|
||||
for a in assignments:
|
||||
node_id = a["node_id"]
|
||||
gpu_index = a["gpu_index"]
|
||||
user_id = a["user_id"]
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM gpu_assignments WHERE node_id=? AND gpu_index=? AND user_id=?",
|
||||
(node_id, gpu_index, user_id),
|
||||
).fetchone()
|
||||
if existing:
|
||||
continue
|
||||
aid = new_id("ga")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO gpu_assignments (id, node_id, gpu_index, user_id, assigned_by, assigned_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(aid, node_id, gpu_index, user_id, assigned_by, now),
|
||||
)
|
||||
return self.gpu_assignments()
|
||||
|
||||
def unassign_gpu(self, assignment_id: str) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM gpu_assignments WHERE id=?", (assignment_id,))
|
||||
|
||||
def check_gpu_access(self, user_id: str, node_id: str, gpu_indices: list[int]) -> bool:
|
||||
"""检查用户是否被分配了指定节点的指定 GPU 卡。"""
|
||||
if not gpu_indices:
|
||||
return True
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT gpu_index FROM gpu_assignments
|
||||
WHERE user_id=? AND node_id=?
|
||||
""",
|
||||
(user_id, node_id),
|
||||
).fetchall()
|
||||
assigned = {r["gpu_index"] for r in rows}
|
||||
return all(idx in assigned for idx in gpu_indices)
|
||||
|
||||
# ===================== 平台治理:资源可见性过滤 =====================
|
||||
|
||||
def _filter_accessible_ids(
|
||||
self, resource_type: str, all_ids: list[str], user: dict[str, Any]
|
||||
) -> list[str]:
|
||||
"""从全部资源 ID 中过滤出当前用户可访问的 ID 列表。
|
||||
- admin 直接返回全部。
|
||||
- 资源所有者可见(需调用方在 all_ids 中提供 owned ids)。
|
||||
- ACL 授权的用户/角色可见。
|
||||
"""
|
||||
if user.get("role") == "admin" or user.get("protected"):
|
||||
return all_ids
|
||||
if not all_ids:
|
||||
return []
|
||||
user_id = user.get("id")
|
||||
user_role = user.get("role")
|
||||
with self.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]
|
||||
|
||||
def change_password(self, user_id: str, old_password: str, new_password: str) -> bool:
|
||||
"""用户自行修改密码:验证旧密码后设置新密码。"""
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError(user_id)
|
||||
matched, _ = verify_password(old_password, row["password_hash"])
|
||||
if not matched:
|
||||
return False
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash=? WHERE id=?",
|
||||
(hash_password(new_password), user_id),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
_store: PlatformStore | None = None
|
||||
|
||||
|
||||
22
backend/app/db/sql/004_permissions.sql
Normal file
22
backend/app/db/sql/004_permissions.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- ============================================================
|
||||
-- 权限体系扩展:GPU 分配表 + 资源所有权字段
|
||||
-- ============================================================
|
||||
|
||||
-- GPU 分配表:管理员指定哪些用户可以使用哪些 GPU 卡
|
||||
CREATE TABLE IF NOT EXISTS gpu_assignments (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE,
|
||||
gpu_index INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
assigned_by TEXT,
|
||||
assigned_at TEXT NOT NULL,
|
||||
UNIQUE (node_id, gpu_index, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_gpu_assignments_user ON gpu_assignments(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gpu_assignments_gpu ON gpu_assignments(node_id, gpu_index);
|
||||
|
||||
-- 资源所有权字段:用户创建的数据集/模型/训练产物/评测任务
|
||||
ALTER TABLE datasets ADD COLUMN IF NOT EXISTS created_by TEXT;
|
||||
ALTER TABLE models ADD COLUMN IF NOT EXISTS created_by TEXT;
|
||||
ALTER TABLE trained_models ADD COLUMN IF NOT EXISTS created_by TEXT;
|
||||
ALTER TABLE eval_tasks ADD COLUMN IF NOT EXISTS created_by TEXT;
|
||||
1
backend/app/modules/gpu/__init__.py
Normal file
1
backend/app/modules/gpu/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""GPU assignment management module."""
|
||||
77
backend/app/modules/gpu/router.py
Normal file
77
backend/app/modules/gpu/router.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""GPU 算力分配管理路由。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Request
|
||||
|
||||
from app.api.v1.endpoints.platform import ok, fail
|
||||
from app.core.auth import get_current_user, is_admin
|
||||
from app.db.platform_store import get_platform_store
|
||||
|
||||
router = APIRouter(prefix="/compute", tags=["gpu-assignment"])
|
||||
|
||||
|
||||
def _actor_id(request: Request) -> str | None:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
token = auth.replace("Bearer ", "").strip()
|
||||
if token.startswith("platform-token-"):
|
||||
return token[len("platform-token-"):]
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/gpu-assignments")
|
||||
def list_assignments(current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
"""查看全部分配关系(仅 admin)。"""
|
||||
if not is_admin(current_user):
|
||||
raise fail(403, "admin permission required")
|
||||
return ok(get_platform_store().gpu_assignments())
|
||||
|
||||
|
||||
@router.post("/gpu-assignments")
|
||||
def assign_gpus(
|
||||
payload: dict[str, Any] = Body(...),
|
||||
request: Request = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""批量分配 GPU(仅 admin)。body: { assignments: [{ node_id, gpu_index, user_id }] }"""
|
||||
if not is_admin(current_user):
|
||||
raise fail(403, "admin permission required")
|
||||
assignments = payload.get("assignments") or []
|
||||
if not assignments:
|
||||
raise fail(400, "assignments 不能为空")
|
||||
actor = _actor_id(request) if request else None
|
||||
result = get_platform_store().assign_gpus(assignments, assigned_by=actor)
|
||||
get_platform_store().record_audit(
|
||||
action="gpu.assign",
|
||||
actor_id=actor,
|
||||
target_type="gpu",
|
||||
detail=f"count={len(assignments)}",
|
||||
)
|
||||
return ok(result)
|
||||
|
||||
|
||||
@router.delete("/gpu-assignments/{assignment_id}")
|
||||
def unassign_gpu(
|
||||
assignment_id: str,
|
||||
request: Request = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""撤销 GPU 分配(仅 admin)。"""
|
||||
if not is_admin(current_user):
|
||||
raise fail(403, "admin permission required")
|
||||
get_platform_store().unassign_gpu(assignment_id)
|
||||
actor = _actor_id(request) if request else None
|
||||
get_platform_store().record_audit(
|
||||
action="gpu.unassign",
|
||||
actor_id=actor,
|
||||
target_type="gpu",
|
||||
target_id=assignment_id,
|
||||
)
|
||||
return ok({"deleted": assignment_id})
|
||||
|
||||
|
||||
@router.get("/my-gpus")
|
||||
def my_gpus(current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
"""查看当前用户可用的 GPU 列表。"""
|
||||
return ok(get_platform_store().gpu_assignments_for_user(current_user["id"]))
|
||||
@@ -138,6 +138,14 @@ class FakePlatformStore:
|
||||
return dict(u)
|
||||
return None
|
||||
|
||||
def create_session(self, user_id: str) -> dict[str, Any]:
|
||||
import secrets
|
||||
sid = secrets.token_hex(16)
|
||||
return {"session_id": sid, "user_id": user_id}
|
||||
|
||||
def finish_session(self, session_id: str) -> None:
|
||||
pass
|
||||
|
||||
def users(self) -> list[dict[str, Any]]:
|
||||
return [dict(u) for u in self._users]
|
||||
|
||||
@@ -392,12 +400,21 @@ class FakePlatformStore:
|
||||
def tasks(self) -> list[dict[str, Any]]:
|
||||
return self._tasks
|
||||
|
||||
def eval_tasks(self) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def compute_nodes(self) -> list[dict[str, Any]]:
|
||||
return self._compute_nodes
|
||||
|
||||
def gpus(self) -> list[dict[str, Any]]:
|
||||
return self._gpus
|
||||
|
||||
def compare_tasks(self) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def trained_models(self) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def system_info(self) -> dict[str, Any]:
|
||||
return {"cpu": {}, "memory": {}}
|
||||
|
||||
|
||||
411
docs/governance-user-guide.md
Normal file
411
docs/governance-user-guide.md
Normal file
@@ -0,0 +1,411 @@
|
||||
# 平台治理功能使用指南
|
||||
|
||||
> 版本:v1.0
|
||||
> 日期:2026-08-10
|
||||
> 适用版本:YG Fine-Tune Platform v1.0+
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [快速入门](#1-快速入门)
|
||||
2. [用户与权限管理](#2-用户与权限管理)
|
||||
3. [GPU 算力分配](#3-gpu-算力分配)
|
||||
4. [资源可见性与隔离](#4-资源可见性与隔离)
|
||||
5. [审批流程管理](#5-审批流程管理)
|
||||
6. [审计日志查询](#6-审计日志查询)
|
||||
7. [常见问题与排查](#7-常见问题与排查)
|
||||
|
||||
---
|
||||
|
||||
## 1. 快速入门
|
||||
|
||||
### 1.1 平台治理是什么?
|
||||
|
||||
平台治理是一套**多租户、多角色、细粒度权限控制**体系,用于在多人协作使用 AI 微调平台时,确保:
|
||||
|
||||
- 每个用户只能看到和操作自己有权限的资源
|
||||
- GPU 算力按需分配,避免资源争抢
|
||||
- 高风险操作(删除、停止任务)有审批记录可追溯
|
||||
- 所有操作都有审计日志
|
||||
|
||||
### 1.2 三种内置角色
|
||||
|
||||
| 角色 | 能做什么 | 不能做什么 |
|
||||
|---|---|---|
|
||||
| **超级管理员 (admin)** | 全部操作;管理用户、分配 GPU、审批、查看全部资源 | — |
|
||||
| **操作员 (operator)** | 创建数据集/模型、训练/评测/推理任务 | 管理用户、分配 GPU、修改他人权限 |
|
||||
| **观察员 (viewer)** | 查看被授权的资源 | 创建或修改任何资源 |
|
||||
|
||||
### 1.3 入口在哪里?
|
||||
|
||||
所有治理功能集中在左侧导航栏的 **「系统设置」** 分组下:
|
||||
|
||||
```
|
||||
系统设置
|
||||
├── 用户设置 ← 用户 CRUD + 角色权限 + 密码管理
|
||||
├── 租户管理 ← 组织/团队(可选)
|
||||
├── 项目空间 ← 项目级资源隔离(可选)
|
||||
├── 审批模板 ← 定义哪些操作需要审批
|
||||
├── 审批中心 ← 处理待审批请求
|
||||
└── 审计日志 ← 查看所有操作记录
|
||||
```
|
||||
|
||||
> ⚠️ 以上菜单**只有 admin 用户能看到**。普通用户登录后不会出现这些入口。
|
||||
|
||||
---
|
||||
|
||||
## 2. 用户与权限管理
|
||||
|
||||
### 2.1 创建用户
|
||||
|
||||
**路径**:`用户设置` → `创建用户`
|
||||
|
||||
1. 以 admin 身份登录平台
|
||||
2. 进入「用户设置」页面
|
||||
3. 点击右上角「创建用户」按钮
|
||||
4. 填写信息:
|
||||
- **账号**:登录用户名(如 `zhangsan`)
|
||||
- **显示名称**:如 `张三`
|
||||
- **密码**:初始密码(默认 `Platform@123`)
|
||||
- **角色**:选择 `admin` / `operator` / `viewer`
|
||||
5. 点击保存
|
||||
|
||||
创建后用户可以立即用该账号登录。
|
||||
|
||||
### 2.2 管理用户权限
|
||||
|
||||
**路径**:`用户设置` → 用户列表 → 操作列「页面权限」
|
||||
|
||||
#### 给普通用户分配业务模块权限
|
||||
|
||||
点击某用户的「页面权限」按钮,弹出对话框:
|
||||
|
||||
```
|
||||
为 张三 分配可访问的页面模块:
|
||||
|
||||
☑ 服务看板 ☑ 模型训练 ☑ 模型评测
|
||||
☑ 模型推理 ☑ 模型管理 ☑ 数据集管理
|
||||
☐ 数据处理 ☐ 数据类型转换 ☐ 算力节点 ← 不勾选则不可见
|
||||
☑ 平台性能 ☑ 查看日志
|
||||
|
||||
[取消] [保存]
|
||||
```
|
||||
|
||||
勾选需要的模块,点「保存」即可。
|
||||
|
||||
> **注意**:
|
||||
> - 「用户与权限」这个选项**只有 admin 能看到**,其他用户即使被赋权也不会显示
|
||||
> - admin 用户的权限**不可更改**,始终是全选状态且只读
|
||||
|
||||
#### 限制说明
|
||||
|
||||
| 权限码 | 说明 | 谁能拥有 |
|
||||
|---|---|---|
|
||||
| `user-settings` | 用户设置、租户管理、项目空间、审批、审计日志 | **仅 admin** |
|
||||
| `compute` | 算力节点、GPU 分配 | **仅 admin** |
|
||||
| 其他业务权限 | 训练、评测、推理、模型、数据集等 | admin 可分配给任何人 |
|
||||
|
||||
### 2.3 重置用户密码
|
||||
|
||||
**两种方式**:
|
||||
|
||||
**方式一:管理员重置**
|
||||
1. 在用户列表中找到目标用户
|
||||
2. 点击「重置密码」
|
||||
3. 输入新密码,确认
|
||||
|
||||
**方式二:用户自行修改**
|
||||
1. 用户登录后在「用户设置」页面点击「修改密码」按钮
|
||||
2. 输入旧密码 + 新密码(至少 6 位)
|
||||
3. 确认修改
|
||||
|
||||
### 2.4 删除用户
|
||||
|
||||
**路径**:`用户设置` → 用户列表 → 操作列「删除」
|
||||
|
||||
> ⚠️ 删除用户时会**级联清理**其所有关联数据:
|
||||
> - 该用户创建的数据集、基座模型、微调产物、评测任务
|
||||
> - 该用户的 ACL 授权记录、GPU 分配记录
|
||||
> - 该用户的审批实例、审计日志、项目成员关系
|
||||
> - **训练任务保留不删**(避免算力节点上的物理任务数据不一致)
|
||||
|
||||
---
|
||||
|
||||
## 3. GPU 算力分配
|
||||
|
||||
### 3.1 为什么需要 GPU 分配?
|
||||
|
||||
当服务器有多张 GPU 卡(如 8×A800)时,需要指定**哪个用户能用哪张卡**:
|
||||
|
||||
- 避免两个人同时选同一张卡导致训练冲突
|
||||
- 按团队/项目隔离算力资源
|
||||
- 控制每个用户的 GPU 配额
|
||||
|
||||
### 3.2 分配 GPU(仅 admin)
|
||||
|
||||
**路径**:`算力节点` → `GPU 分配` 标签页
|
||||
|
||||
1. 以 admin 登录,进入「算力节点」页面
|
||||
2. 点击顶部的 **「GPU 分配」** 标签(只有 admin 可见)
|
||||
3. 点击 **「分配 GPU」** 按钮
|
||||
4. 填写:
|
||||
- **算力节点**:选择节点(如 `gpu-node-01`)
|
||||
- **GPU 序号**:卡号(0, 1, 2, ... 7)
|
||||
- **用户**:选择要分配给谁
|
||||
5. 点「确认分配」
|
||||
|
||||
示例:把节点 `gpu-node-01` 的第 0、1 号卡分配给用户 `zhangsan`:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 分配 GPU [×] │
|
||||
├─────────────────────────────────────┤
|
||||
│ 算力节点: [gpu-node-01 ▼] │
|
||||
│ GPU 序号: [0 ▲] │
|
||||
│ 用户: [zhangsan ▼] │
|
||||
│ │
|
||||
│ [取消] [确认分配] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
分配后的效果:
|
||||
|
||||
| 用户 | 可用 GPU |
|
||||
|---|---|
|
||||
| admin | 全部 GPU(不需要显式分配) |
|
||||
| zhangsan | gpu-node-01 的 0、1 号卡 |
|
||||
| lisi | (未分配,不可用) |
|
||||
|
||||
### 3.3 撤销分配
|
||||
|
||||
在 GPU 分配列表中,每条记录右侧有「撤销」按钮,点击后确认即可移除该分配。
|
||||
|
||||
### 3.4 用户视角:创建训练任务时的 GPU 选择
|
||||
|
||||
- **admin**:下拉列表显示全部可用 GPU
|
||||
- **被分配了 GPU 的用户**:只显示被分配给自己的卡
|
||||
- **未分配任何 GPU 的用户**:显示提示「未分配 GPU,请联系管理员」,无法提交训练任务
|
||||
|
||||
---
|
||||
|
||||
## 4. 资源可见性与隔离
|
||||
|
||||
### 4.1 自动生效的隔离规则
|
||||
|
||||
无需手动配置,以下规则自动生效:
|
||||
|
||||
| 资源类型 | admin 看到 | 普通用户看到 |
|
||||
|---|---|---|
|
||||
| **基座模型**(容器内注册的本地模型) | 全部 | **全部**(共享资源,有 model-manage 权限即可见) |
|
||||
| **数据集** | 全部 | **自己创建的** + 被 ACL 授权的 |
|
||||
| **微调产物**(训练输出的模型) | 全部 | **自己训练的** + 被 ACL 授权的 |
|
||||
| **评测任务** | 全部 | **自己创建的** + 被 ACL 授权的 |
|
||||
| **推理/对比任务** | 全部 | **自己创建的** + 被 ACL 授权的 |
|
||||
|
||||
### 4.2 实际场景示例
|
||||
|
||||
假设有三个用户:**admin**、**zhangsan**(算法工程师)、**lisi**(标注员)
|
||||
|
||||
```
|
||||
zhangsan 上传了数据集 ds_alpaca、ds_sharegpt
|
||||
zhangsan 训练出了模型 ft_qwen_001
|
||||
lisi 上传了数据集 ds_label
|
||||
admin 注册了基座模型 Qwen3-1.7B
|
||||
```
|
||||
|
||||
各用户看到的资源:
|
||||
|
||||
| 用户 | 数据集 | 基座模型 | 微调产物 |
|
||||
|---|---|---|---|
|
||||
| **admin** | ds_alpaca, ds_sharegpt, ds_label (3个) | Qwen3-1.7B | ft_qwen_001 |
|
||||
| **zhangsan** | ds_alpaca, ds_sharegpt (2个) | Qwen3-1.7B | ft_qwen_001 |
|
||||
| **lisi** | ds_label (1个) | Qwen3-1.7B | (无) |
|
||||
|
||||
### 4.3 ACL 资源授权(高级用法)
|
||||
|
||||
如果 zhangsan 想让 lisi 也能看到自己的数据集 `ds_alpaca`:
|
||||
|
||||
> 此功能需要在资源详情页提供「资源授权」按钮(前端已预留接口),当前可通过 API 直接操作:
|
||||
|
||||
```bash
|
||||
# 授予 lisi 对 ds_alpaca 的读权限
|
||||
curl -X PUT /modelTF/resources/dataset/ds_alpaca_id/acl \
|
||||
-H "Authorization: Bearer platform-token-admin" \
|
||||
-d '{
|
||||
"acls": [
|
||||
{"principal_type": "user", "principal_id": "lisi_id", "permission": "read"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 审批流程管理
|
||||
|
||||
### 5.1 哪些操作会触发审批?
|
||||
|
||||
| 操作 | 触发条件 | 处理方式 |
|
||||
|---|---|---|
|
||||
| 删除他人的数据集 | 非 admin 删除别人创建的数据集 | 创建审批实例 或 admin 直接执行 |
|
||||
| 删除他人的模型 | 非 admin 删除别人创建的模型 | 同上 |
|
||||
| 停止他人的训练任务 | 非 admin 停止别人发起的任务 | 同上 |
|
||||
| 归档/删除项目空间 | 存在待审批变更时 | 拒绝执行 |
|
||||
|
||||
**核心规则**:admin 做任何操作都直接执行(旁路);普通用户操作他人资源时进入审批流程。
|
||||
|
||||
### 5.2 审批流程示意
|
||||
|
||||
```
|
||||
普通用户 lisi 尝试删除 zhangsan 的数据集
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ 后端检查:是 admin 吗? │
|
||||
└──────┬────────────────┘
|
||||
│ 否
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ 创建审批实例 │
|
||||
│ status = pending │
|
||||
│ 返回 202(待审批) │
|
||||
└──────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ admin 在「审批中心」看到 │
|
||||
│ 这条待审批请求 │
|
||||
│ 点击「通过」或「拒绝」 │
|
||||
└──────────┬────────────┘
|
||||
│
|
||||
┌─────┴─────┐
|
||||
│ │
|
||||
通过 拒绝
|
||||
│ │
|
||||
▼ ▼
|
||||
执行删除 不执行
|
||||
+审计日志 +审计日志
|
||||
```
|
||||
|
||||
### 5.3 管理审批
|
||||
|
||||
**路径**:`系统设置` → `审批中心`
|
||||
|
||||
1. 查看待审批列表(status=pending)
|
||||
2. 点击某条记录查看详情
|
||||
3. 决策:「通过」或「拒绝」
|
||||
4. 决策结果自动执行对应操作并记录审计日志
|
||||
|
||||
**审批模板**(`系统设置` → `审批模板`):定义每种操作需要几步审批、每步谁来审。默认模板都是单步(admin 审批即可)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 审计日志查询
|
||||
|
||||
### 6.1 什么是审计日志?
|
||||
|
||||
平台上所有**写操作**和**敏感操作**都会自动记录审计日志,包括:
|
||||
|
||||
- 用户创建/删除/修改
|
||||
- 权限变更
|
||||
- GPU 分配/撤销
|
||||
- 资源上传/删除
|
||||
- 训练任务启动/停止
|
||||
- 审批决策
|
||||
|
||||
### 6.2 查询审计日志
|
||||
|
||||
**路径**:`系统设置` → `审计日志`
|
||||
|
||||
支持筛选条件:
|
||||
|
||||
| 筛选项 | 说明 |
|
||||
|---|---|
|
||||
| 操作人 | 按用户 ID 过滤 |
|
||||
| 动作类型 | 如 `user.create`, `dataset.delete`, `gpu.assign` 等 |
|
||||
| 目标资源类型 | dataset / model / fine_tune_task 等 |
|
||||
| 时间范围 | 开始时间 ~ 结束时间 |
|
||||
|
||||
### 6.3 导出审计日志
|
||||
|
||||
审计日志页面底部有「导出 CSV」按钮,导出的文件包含当前筛选条件下的全部记录,可用于合规审计或问题追溯。
|
||||
|
||||
### 6.4 日志保留策略
|
||||
|
||||
审计日志受**留存策略**控制(`系统设置` → 租户管理 → 绑定留存策略)。默认保留 30 天,超期自动清理。
|
||||
|
||||
---
|
||||
|
||||
## 7. 常见问题与排查
|
||||
|
||||
### Q1: 普通用户看不到某个菜单?
|
||||
|
||||
检查两件事:
|
||||
1. 该用户是否有对应的**权限码**(admin 在「用户设置」→「页面权限」中分配)
|
||||
2. 该菜单是否属于 **admin 专属**(如「用户设置」「算力节点」——这些对非 admin 永远不可见)
|
||||
|
||||
### Q2: 用户创建训练任务时报错"无权使用所选 GPU"
|
||||
|
||||
说明该用户没有被分配所选择的 GPU 卡。解决方法:
|
||||
1. admin 进入「算力节点」→「GPU 分配」标签页
|
||||
2. 为该用户分配对应的 GPU
|
||||
3. 用户刷新页面重新选择 GPU
|
||||
|
||||
### Q3: 删除用户后看板还显示残留数据?
|
||||
|
||||
正常情况下 `delete_user` 会级联清理关联数据。如果仍有残留:
|
||||
- **训练任务**:设计上保留不删(避免算力节点物理数据不一致),这是预期行为
|
||||
- **登录时长排行**:可能来自旧的 session 记录,不影响功能,新登录后会更新
|
||||
|
||||
### Q4: 审批实例一直 pending 没人处理?
|
||||
|
||||
审批实例需要 admin 在「审批中心」手动处理。如果长时间无人处理:
|
||||
- 可以在数据库中直接将 `approval_instances.status` 改为 `rejected`
|
||||
- 或者由 admin 直接以自身身份执行该操作(admin 有旁路权限)
|
||||
|
||||
### Q5: 如何查看当前所有 GPU 分配情况?
|
||||
|
||||
```bash
|
||||
# admin 调用接口
|
||||
curl -H "Authorization: Bearer platform-token-admin" \
|
||||
/modelTF/compute/gpu-assignments
|
||||
```
|
||||
|
||||
返回格式:
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "ga_xxx",
|
||||
"node_name": "A800 训练节点",
|
||||
"gpu_index": 0,
|
||||
"display_name": "张三",
|
||||
"assigned_at": "2026-08-10T10:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Q6: 用户忘记密码怎么办?
|
||||
|
||||
两种方案:
|
||||
1. **admin 重置**:在「用户设置」→ 用户列表 →「重置密码」
|
||||
2. **用户自助修改**:用户登录后点击「修改密码」(需知道旧密码)
|
||||
|
||||
如果是完全忘记且不是 admin,只能由 admin 重置。
|
||||
|
||||
---
|
||||
|
||||
## 附录:API 快速参考
|
||||
|
||||
| 功能 | 方法 | 路径 | 鉴权 |
|
||||
|---|---|---|---|
|
||||
| 查看我的 GPU | GET | `/compute/my-gpus` | 登录用户 |
|
||||
| 查看 GPU 分配 | GET | `/compute/gpu-assignments` | admin |
|
||||
| 分配 GPU | POST | `/compute/gpu-assignments` | admin |
|
||||
| 撤销 GPU 分配 | DELETE | `/compute/gpu-assignments/{id}` | admin |
|
||||
| 修改自己的密码 | POST | `/users/me/password` | 登录用户 |
|
||||
| 查看审计日志 | GET | `/system/audit-logs` | admin |
|
||||
| 导出审计日志 | GET | `/system/audit-logs/export` | admin |
|
||||
| 查看审批列表 | GET | `/approvals` | 登录用户 |
|
||||
| 审批决策 | POST | `/approvals/:id/steps/:idx/decision` | 审批人 |
|
||||
805
docs/permissions-design.md
Normal file
805
docs/permissions-design.md
Normal file
@@ -0,0 +1,805 @@
|
||||
# 平台权限设计文档
|
||||
|
||||
> 版本:v1.0
|
||||
> 日期:2026-08-02
|
||||
> 状态:设计基线,供后端实现和前端联调参照
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [设计目标](#1-设计目标)
|
||||
2. [整体架构](#2-整体架构)
|
||||
3. [角色体系](#3-角色体系)
|
||||
4. [页面权限码](#4-页面权限码)
|
||||
5. [资源所有权与可见性](#5-资源所有权与可见性)
|
||||
6. [资源级 ACL(访问控制列表)](#6-资源级-acl访问控制列表)
|
||||
7. [GPU 算力分配与隔离](#7-gpu-算力分配与隔离)
|
||||
8. [审批拦截机制](#8-审批拦截机制)
|
||||
9. [审计日志](#9-审计日志)
|
||||
10. [接口鉴权流程](#10-接口鉴权流程)
|
||||
11. [数据库表结构](#11-数据库表结构)
|
||||
12. [API 接口清单](#12-api-接口清单)
|
||||
13. [前端权限控制](#13-前端权限控制)
|
||||
14. [安全设计补充](#14-安全设计补充)
|
||||
15. [实施计划](#15-实施计划)
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
| 目标 | 说明 |
|
||||
|---|---|
|
||||
| **数据隔离** | 用户自己创建的数据集、模型、训练任务默认只有自己可见可操作;管理员可见全部 |
|
||||
| **权限分层** | 页面级(菜单/路由可见性)+ 资源级(单条数据的读/写/删)两层控制 |
|
||||
| **GPU 管控** | 多卡服务器上,管理员可指定哪些用户能使用哪些 GPU 卡 |
|
||||
| **审批拦截** | 删除他人资源、停止他人任务、发布模型等高风险操作需审批或管理员旁路 |
|
||||
| **审计可追溯** | 所有写操作和敏感操作产生审计日志,可按用户、动作、资源、时间筛选 |
|
||||
| **权限最小变更** | 只有管理员可修改用户角色和权限码;普通用户无法提权 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 整体架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ 前端(Vue3) │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌───────────────┐ │
|
||||
│ │ 路由守卫 │ │ 侧边栏过滤 │ │ 页面内按钮控制 │ │
|
||||
│ │ (permission)│ │ (permission)│ │ (ACL/owner) │ │
|
||||
│ └──────┬─────┘ └──────┬─────┘ └───────┬───────┘ │
|
||||
│ └───────────────┴─────────────────┘ │
|
||||
│ │ HTTP (Bearer token) │
|
||||
└─────────────────────────┼────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────┼────────────────────────────┐
|
||||
│ 后端(FastAPI) │
|
||||
│ ┌──────────────┐ ┌────┴───────┐ ┌──────────────┐ │
|
||||
│ │ get_current │ │ 资源可见性 │ │ GPU 分配校验 │ │
|
||||
│ │ _user (鉴权) │ │ 过滤器 │ │ │ │
|
||||
│ └──────┬───────┘ └────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌──────┴───────────────┴──────────────────┘ │
|
||||
│ │ PlatformStore │ │
|
||||
│ │ users | acls | roles | gpu_assignments | │ │
|
||||
│ │ datasets | models | fine_tune_tasks | ... │ │
|
||||
│ └───────────────────────────────────────────────────│ │
|
||||
│ │ audit_logs (审计日志) │ │
|
||||
│ └───────────────────────────────────────────────────│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**鉴权链路**:
|
||||
1. 请求到达 → `get_current_user` 从 `Authorization: Bearer platform-token-{user_id}` 解析当前用户
|
||||
2. 页面级权限 → 检查 `user.permissions` 是否包含路由对应的权限码
|
||||
3. 资源级权限 → 检查资源的 `created_by` 字段(所有权)或 `acls` 表(ACL 授权)
|
||||
4. GPU 权限 → 检查 `gpu_assignments` 表确认用户是否被分配了请求的 GPU
|
||||
|
||||
---
|
||||
|
||||
## 3. 角色体系
|
||||
|
||||
### 3.1 内置角色
|
||||
|
||||
| 角色 code | 中文名 | 说明 |
|
||||
|---|---|---|
|
||||
| `admin` | 超级管理员 | 拥有全部权限码;可见全部资源;可管理用户和 GPU 分配 |
|
||||
| `operator` | 操作员 | 可创建/操作自己的数据集、模型、训练任务;不可管理用户 |
|
||||
| `viewer` | 观察员 | 只读权限;可查看被授权的资源;不可创建或修改 |
|
||||
| `guest` | 访客 | 仅登录和看板;无业务操作权限(扩展预留) |
|
||||
|
||||
### 3.2 角色与权限码映射
|
||||
|
||||
| 权限码 | admin | operator | viewer |
|
||||
|---|---|---|---|
|
||||
| `dashboard` | ✅ | ✅ | ✅ |
|
||||
| `fine-tune` | ✅ | ✅ | — |
|
||||
| `model-eval` | ✅ | ✅ | — |
|
||||
| `model-inference` | ✅ | ✅ | — |
|
||||
| `model-manage` | ✅ | ✅ | — |
|
||||
| `dataset` | ✅ | ✅ | — |
|
||||
| `data-process` | ✅ | ✅ | — |
|
||||
| `data-convert` | ✅ | ✅ | — |
|
||||
| `compute` | ✅ | ✅ | — |
|
||||
| `hardware` | ✅ | ✅ | ✅ |
|
||||
| `logs` | ✅ | ✅ | ✅ |
|
||||
| `user-settings` | ✅ | — | — |
|
||||
|
||||
### 3.3 权限修改规则
|
||||
|
||||
- **只有 admin 角色的用户**可以修改其他用户的角色和权限码
|
||||
- admin 用户的 `protected=True` 标记,防止被删除或降级
|
||||
- 权限修改操作产生审计日志:`action=user.permission.update`
|
||||
- 用户可以查看自己的权限,不能修改自己的权限
|
||||
|
||||
---
|
||||
|
||||
## 4. 页面权限码
|
||||
|
||||
| 权限码 | 对应路由 | 功能 |
|
||||
|---|---|---|
|
||||
| `dashboard` | `/dashboard` | 服务看板 |
|
||||
| `fine-tune` | `/fine-tune`, `/fine-tune/create`, `/training-log/:id` | 模型训练 |
|
||||
| `model-eval` | `/model-eval`, `/model-eval/create`, `/model-eval/:id` | 模型评测 |
|
||||
| `model-inference` | `/model-inference`, `/model-inference/create`, `/model-inference/chat/:id` | 模型推理 |
|
||||
| `model-manage` | `/model-manage`, `/model-manage/create`, `/model-manage/:id/edit`, `/model-manage/merge` | 模型管理 |
|
||||
| `dataset` | `/dataset`, `/dataset/create`, `/dataset/:id/preview` | 数据集管理 |
|
||||
| `data-process` | `/data-process`, `/data-process/create`, `/data-process/:id` | 数据处理 |
|
||||
| `data-convert` | `/data-convert`, `/tools` | 数据转换与工具 |
|
||||
| `compute` | `/compute` | 算力节点 |
|
||||
| `hardware` | `/hardware` | 平台性能 |
|
||||
| `logs` | `/logs`, `/training-log/:id` | 查看日志 |
|
||||
| `user-settings` | `/user-settings`, `/tenants`, `/projects`, `/approvals`, `/audit-logs` | 系统设置与平台治理 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 资源所有权与可见性
|
||||
|
||||
### 5.1 所有权模型
|
||||
|
||||
每个用户可创建的资源都携带 `created_by`(或 `owner_id`)字段,标识资源所有者。
|
||||
|
||||
| 资源类型 | 表 | 所有者字段 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 数据集 | `datasets` | `created_by` | 用户上传/创建的数据集 |
|
||||
| 基座模型 | `models` | `created_by` | 登记的本地/API 模型 |
|
||||
| 训练产物 | `trained_models` | `created_by` | 微调产出的模型 |
|
||||
| 训练任务 | `fine_tune_tasks` | `payload.created_by` | 微调任务 |
|
||||
| 评测任务 | `eval_tasks` | `created_by` | 评测任务 |
|
||||
| 推理任务 | `compare_tasks` (payload) | `created_by` | 推理/对比任务 |
|
||||
| 数据处理任务 | `data_process_tasks` | `created_by` | 数据处理任务 |
|
||||
| 数据转换任务 | `data_convert_jobs` | `created_by` | 数据转换任务 |
|
||||
|
||||
### 5.2 可见性规则
|
||||
|
||||
```
|
||||
资源列表查询过滤逻辑:
|
||||
|
||||
if user.role == "admin":
|
||||
返回全部资源
|
||||
elif resource.created_by == user.id:
|
||||
返回(资源所有者可见自己的资源)
|
||||
elif acl 中存在 (principal_type="user", principal_id=user.id, permission 包含 "read"):
|
||||
返回(被 ACL 显式授权的资源)
|
||||
elif acl 中存在 (principal_type="role", principal_id=user.role, permission 包含 "read"):
|
||||
返回(被角色级 ACL 授权的资源)
|
||||
else:
|
||||
不可见
|
||||
```
|
||||
|
||||
### 5.3 所有权操作矩阵
|
||||
|
||||
| 操作 | admin | 资源所有者 | 其他被授权用户 | 其他用户 |
|
||||
|---|---|---|---|---|
|
||||
| 查看资源 | ✅ 全部 | ✅ 自己的 | ✅ ACL 授权范围内 | ❌ |
|
||||
| 编辑资源 | ✅ | ✅ 自己的 | ✅ ACL 含 write 时 | ❌ |
|
||||
| 删除资源 | ✅ | ✅ 自己的(需审批) | ❌ | ❌ |
|
||||
| 分享/授权 | ✅ | ✅ 自己的 | ❌ | ❌ |
|
||||
| 使用资源(训练/推理/评测) | ✅ | ✅ 自己的 | ✅ ACL 含 execute 时 | ❌ |
|
||||
|
||||
### 5.4 数据集可见性示例
|
||||
|
||||
```
|
||||
用户 A 创建了数据集 ds_A1 → 只有 A 和 admin 可见
|
||||
用户 A 通过 ACL 把 ds_A1 的 read 权限授给用户 B → B 也可见
|
||||
用户 A 通过 ACL 把 ds_A1 的 write 权限授给 operator 角色 → 所有 operator 可编辑
|
||||
管理员可在任何数据集上设置 ACL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 资源级 ACL(访问控制列表)
|
||||
|
||||
### 6.1 ACL 表结构
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS acls (
|
||||
id TEXT PRIMARY KEY,
|
||||
resource_type TEXT NOT NULL, -- 资源类型: dataset / model / trained_model / fine_tune_task / ...
|
||||
resource_id TEXT NOT NULL, -- 资源 ID
|
||||
principal_type TEXT NOT NULL, -- 授权主体类型: user / role
|
||||
principal_id TEXT NOT NULL, -- 授权主体 ID: user_id 或 role name
|
||||
permission TEXT NOT NULL, -- 权限: read / write / execute / download / delete / admin
|
||||
create_time TEXT
|
||||
);
|
||||
```
|
||||
|
||||
### 6.2 权限粒度
|
||||
|
||||
| 权限值 | 含义 | 覆盖关系 |
|
||||
|---|---|---|
|
||||
| `read` | 查看资源详情、列表 | — |
|
||||
| `write` | 编辑资源内容/元数据 | 覆盖 `read` |
|
||||
| `execute` | 使用资源(如用数据集训练、用模型推理) | 覆盖 `read` |
|
||||
| `download` | 下载资源文件 | 独立权限 |
|
||||
| `delete` | 删除资源 | 独立权限(通常需审批) |
|
||||
| `admin` | 完全控制(含 ACL 管理) | 覆盖以上全部 |
|
||||
|
||||
### 6.3 ACL 管理接口
|
||||
|
||||
| 接口 | 方法 | 权限要求 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `/resources/{type}/{id}/acl` | GET | admin 或资源所有者 | 查询资源 ACL |
|
||||
| `/resources/{type}/{id}/acl` | PUT | admin 或资源所有者 | 设置资源 ACL(全量替换) |
|
||||
|
||||
### 6.4 ACL 管理规则
|
||||
|
||||
- **admin** 可以在任何资源上设置 ACL
|
||||
- **资源所有者** 可以在自己的资源上设置 ACL
|
||||
- **被授权用户** 不能转授自己获得的权限
|
||||
- ACL 变更产生审计日志:`action=resource.acl.set`
|
||||
- 设置 ACL 时全量替换该资源的所有 ACL 条目
|
||||
|
||||
### 6.5 前端 ACL 管理入口
|
||||
|
||||
在数据集详情、模型详情、训练任务详情页面提供「资源授权」按钮,弹出 ACL 管理对话框:
|
||||
- 显示当前 ACL 列表(主体类型 + 主体名称 + 权限勾选)
|
||||
- 支持按用户或按角色添加授权
|
||||
- 权限以多选框形式展示(read / write / execute / download / delete)
|
||||
|
||||
---
|
||||
|
||||
## 7. GPU 算力分配与隔离
|
||||
|
||||
### 7.1 设计背景
|
||||
|
||||
服务器可能安装多张 GPU 卡(如 8×A100),需要精细化管控:
|
||||
- 管理员指定哪些用户可以使用哪些 GPU 卡
|
||||
- 未被分配的 GPU 卡对用户不可见或不可选
|
||||
- admin 可以使用全部 GPU
|
||||
|
||||
### 7.2 GPU 分配表
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS gpu_assignments (
|
||||
id TEXT PRIMARY KEY,
|
||||
gpu_id TEXT NOT NULL, -- gpus 表的外键(node_id + gpu_index 组合)
|
||||
node_id TEXT NOT NULL, -- 算力节点 ID
|
||||
gpu_index INTEGER NOT NULL, -- GPU 卡序号
|
||||
user_id TEXT NOT NULL, -- 被分配的用户 ID
|
||||
assigned_by TEXT, -- 分配操作人 ID(admin)
|
||||
assigned_at TEXT NOT NULL, -- 分配时间
|
||||
UNIQUE (node_id, gpu_index, user_id) -- 一张卡可分配给多个用户,但每对 (卡, 用户) 唯一
|
||||
);
|
||||
```
|
||||
|
||||
### 7.3 分配规则
|
||||
|
||||
| 规则 | 说明 |
|
||||
|---|---|
|
||||
| 谁可分配 | 只有 `admin` 角色可以分配 GPU |
|
||||
| admin 使用 | admin 可使用全部 GPU,不需要显式分配 |
|
||||
| 普通用户 | 只能使用 `gpu_assignments` 中分配给自己的 GPU |
|
||||
| 共享分配 | 一张 GPU 可分配给多个用户(非独占),但同时只能被一个任务占用 |
|
||||
| 默认策略 | 新用户默认不分配任何 GPU,由管理员显式分配 |
|
||||
|
||||
### 7.4 GPU 分配接口
|
||||
|
||||
| 接口 | 方法 | 权限 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `/compute/gpu-assignments` | GET | admin | 查看全部分配关系 |
|
||||
| `/compute/gpu-assignments` | POST | admin | 批量分配(body: `{ assignments: [{ node_id, gpu_index, user_id }] }`) |
|
||||
| `/compute/gpu-assignments/{id}` | DELETE | admin | 撤销某条分配 |
|
||||
| `/compute/my-gpus` | GET | 登录用户 | 查看自己可用的 GPU 列表 |
|
||||
|
||||
### 7.5 训练/评测/推理 GPU 选择校验
|
||||
|
||||
当普通用户创建训练任务、评测任务、推理任务并选择 GPU 时:
|
||||
1. 后端检查 `gpu_assignments` 表,确认用户被分配了所选 GPU
|
||||
2. 未被分配的 GPU → 返回 403 `"无权使用 GPU {node}:{index}"`
|
||||
3. admin 用户跳过此校验
|
||||
|
||||
### 7.6 前端 GPU 选择交互
|
||||
|
||||
- 普通用户在创建任务选择 GPU 时,下拉列表只显示自己被分配的 GPU
|
||||
- admin 用户在下拉列表中可看到全部 GPU
|
||||
- 未分配任何 GPU 的用户,GPU 选择区域显示提示:"未分配 GPU,请联系管理员"
|
||||
|
||||
---
|
||||
|
||||
## 8. 审批拦截机制
|
||||
|
||||
### 8.1 需要审批的操作
|
||||
|
||||
| 操作 | 触发条件 | 审批动作 code |
|
||||
|---|---|---|
|
||||
| 删除他人数据集 | 非 admin 删除 `created_by != user.id` 的数据集 | `dataset.delete` |
|
||||
| 删除他人模型 | 非 admin 删除 `created_by != user.id` 的模型 | `model.delete` |
|
||||
| 停止他人训练任务 | 非 admin 停止 `created_by != user.id` 的任务 | `fine_tune.stop` |
|
||||
| 发布模型到推理服务 | 任何用户(含 admin)发布到生产环境 | `model_service.publish` |
|
||||
| 删除项目空间 | 存在待审批变更时拒绝 | `project.delete` |
|
||||
| 归档项目空间 | 存在待审批变更时拒绝 | `project.archive` |
|
||||
| 导出训练产物 | 非 admin 导出他人训练的模型 | `trained_model.export` |
|
||||
|
||||
### 8.2 审批流程
|
||||
|
||||
```
|
||||
普通用户发起高风险操作
|
||||
│
|
||||
▼
|
||||
┌──────────────┐ ┌──────────────────────┐
|
||||
│ admin 旁路? │───是──▶│ 直接执行 + 审计日志 │
|
||||
└──────┬───────┘ └──────────────────────┘
|
||||
│ 否
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ 创建审批实例 │
|
||||
│ status=pending │
|
||||
│ 返回 202(待审批) │
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ 管理员审批 │
|
||||
│ POST /approvals/:id │
|
||||
│ /steps/:idx/decision │
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ │
|
||||
approved rejected
|
||||
│ │
|
||||
▼ ▼
|
||||
执行操作 不执行
|
||||
+审计日志 +审计日志
|
||||
```
|
||||
|
||||
### 8.3 审批模板
|
||||
|
||||
审批模板定义了特定操作需要几步审批、每步的审批人是谁:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "tpl_001",
|
||||
"name": "删除数据集审批",
|
||||
"action": "dataset.delete",
|
||||
"steps": [
|
||||
{ "approver_id": "u_admin", "step_name": "管理员审核" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 审批拦截点
|
||||
|
||||
在项目模块的 `_require_no_pending_approval` 函数中,当存在待审批实例时拒绝执行新操作。其他模块通过 `_require_approval_or_admin` 函数实现 admin 旁路或创建审批实例。
|
||||
|
||||
---
|
||||
|
||||
## 9. 审计日志
|
||||
|
||||
### 9.1 审计范围
|
||||
|
||||
所有写操作和敏感操作必须产生审计日志:
|
||||
|
||||
| 动作分类 | action 示例 |
|
||||
|---|---|
|
||||
| 用户管理 | `user.create`, `user.update`, `user.delete`, `user.permission.update` |
|
||||
| 租户管理 | `tenant.create`, `tenant.update`, `tenant.quota.set`, `tenant.retention.set` |
|
||||
| 项目管理 | `project.create`, `project.update`, `project.archive`, `project.delete`, `project.member.add`, `project.member.update`, `project.member.remove` |
|
||||
| 资源 ACL | `resource.acl.set` |
|
||||
| 模型管理 | `model.create`, `model.update`, `model.delete`, `model.merge` |
|
||||
| 数据集 | `dataset.create`, `dataset.update`, `dataset.delete`, `dataset.upload` |
|
||||
| 训练任务 | `fine_tune.create`, `fine_tune.start`, `fine_tune.stop`, `fine_tune.delete` |
|
||||
| 评测任务 | `eval.create`, `eval.start`, `eval.stop` |
|
||||
| 推理任务 | `inference.create`, `inference.start`, `inference.stop` |
|
||||
| 审批 | `approval.create`, `approval.decide` |
|
||||
| 留存策略 | `retention.create`, `retention.update`, `retention.delete` |
|
||||
| GPU 分配 | `gpu.assign`, `gpu.unassign` |
|
||||
|
||||
### 9.2 审计日志字段
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
time TEXT NOT NULL, -- ISO8601 时间戳
|
||||
tenant_id TEXT, -- 租户 ID(可选)
|
||||
project_id TEXT, -- 项目 ID(可选)
|
||||
actor_id TEXT, -- 操作人 ID
|
||||
action TEXT NOT NULL, -- 动作类型
|
||||
target_type TEXT NOT NULL, -- 目标资源类型
|
||||
target_id TEXT, -- 目标资源 ID
|
||||
detail TEXT, -- 详情摘要
|
||||
client_ip TEXT -- 客户端 IP
|
||||
);
|
||||
```
|
||||
|
||||
### 9.3 查询与导出
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|---|---|---|
|
||||
| `/system/audit-logs` | GET | 分页查询,支持按 tenant_id / project_id / actor_id / action / target_type / start_time / end_time 筛选 |
|
||||
| `/system/audit-logs/export` | GET | CSV 导出,与应用查询相同的过滤条件 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 接口鉴权流程
|
||||
|
||||
### 10.1 Token 格式
|
||||
|
||||
```
|
||||
Authorization: Bearer platform-token-{user_id}
|
||||
```
|
||||
|
||||
登录成功后返回 `token` 和 `user` 信息。Token 中编码了 `user_id`,后端通过 `get_current_user` 解析。
|
||||
|
||||
### 10.2 鉴权层级
|
||||
|
||||
```
|
||||
请求到达
|
||||
│
|
||||
├─ 1. 公开路径检查(/health, /login, /system-info)→ 直接放行
|
||||
│
|
||||
├─ 2. Token 解析 → get_current_user
|
||||
│ ├─ 无 token / token 无效 → 401
|
||||
│ └─ 用户不存在 / 状态 disabled → 401
|
||||
│
|
||||
├─ 3. 页面级权限码检查(路由守卫 / Depends)
|
||||
│ └─ user.permissions 不含所需权限码 → 403
|
||||
│
|
||||
├─ 4. 资源级权限检查(路由函数内)
|
||||
│ ├─ admin → 全部放行
|
||||
│ ├─ resource.created_by == user.id → 放行
|
||||
│ ├─ ACL 检查 has_resource_access() → 有授权则放行
|
||||
│ └─ 否则 → 403
|
||||
│
|
||||
├─ 5. GPU 权限检查(训练/评测/推理创建时)
|
||||
│ ├─ admin → 全部放行
|
||||
│ ├─ gpu_assignments 检查 → 有分配则放行
|
||||
│ └─ 否则 → 403
|
||||
│
|
||||
└─ 6. 审批拦截检查(高风险操作)
|
||||
├─ admin → 旁路,直接执行
|
||||
├─ 无待审批实例 → 可执行
|
||||
├─ 有待审批实例 → 409 "存在待审批的变更"
|
||||
└─ 需要审批 → 202 "已创建审批实例"
|
||||
```
|
||||
|
||||
### 10.3 FastAPI 依赖注入
|
||||
|
||||
```python
|
||||
# 任何需要登录的接口
|
||||
@router.get("/datasets")
|
||||
async def list_datasets(user: dict = Depends(get_current_user)):
|
||||
...
|
||||
|
||||
# 需要管理员权限的接口
|
||||
@router.post("/users")
|
||||
async def create_user(user: dict = Depends(require_admin)):
|
||||
...
|
||||
|
||||
# 需要资源级权限检查的接口
|
||||
@router.delete("/datasets/{dataset_id}")
|
||||
async def delete_dataset(
|
||||
dataset_id: str,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
if not has_resource_access("dataset", dataset_id, user, "delete"):
|
||||
raise HTTPException(403, "forbidden")
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 数据库表结构
|
||||
|
||||
### 11.1 现有表(已实现)
|
||||
|
||||
| 表名 | 用途 |
|
||||
|---|---|
|
||||
| `users` | 用户表(id, username, password_hash, role, status, permissions, protected) |
|
||||
| `roles` | 角色定义(name, permissions) |
|
||||
| `sessions` | 登录会话(user_id, issued_at, expires_at, ip) |
|
||||
| `acls` | 资源访问控制列表(resource_type, resource_id, principal_type, principal_id, permission) |
|
||||
| `audit_logs` | 审计日志(actor_id, action, target_type, target_id, time) |
|
||||
| `datasets` | 数据集(需补充 `created_by` 字段) |
|
||||
| `models` | 基座模型(需补充 `created_by` 字段) |
|
||||
| `trained_models` | 训练产物(需补充 `created_by` 字段) |
|
||||
| `fine_tune_tasks` | 训练任务(payload 中存储 `created_by`) |
|
||||
| `gpus` | GPU 设备(node_id, gpu_index, uuid, name, memory) |
|
||||
| `compute_nodes` | 算力节点 |
|
||||
| `tenants` | 租户 |
|
||||
| `projects` | 项目空间 |
|
||||
| `project_members` | 项目成员 |
|
||||
| `approval_templates` | 审批模板 |
|
||||
| `approval_instances` | 审批实例 |
|
||||
| `retention_policies` | 留存策略 |
|
||||
|
||||
### 11.2 需新增/补充的表和字段
|
||||
|
||||
#### 新增 `gpu_assignments` 表
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS gpu_assignments (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE,
|
||||
gpu_index INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
assigned_by TEXT,
|
||||
assigned_at TEXT NOT NULL,
|
||||
UNIQUE (node_id, gpu_index, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_gpu_assignments_user ON gpu_assignments(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gpu_assignments_gpu ON gpu_assignments(node_id, gpu_index);
|
||||
```
|
||||
|
||||
#### 补充 `created_by` 字段
|
||||
|
||||
```sql
|
||||
-- 数据集表补充所有者
|
||||
ALTER TABLE datasets ADD COLUMN IF NOT EXISTS created_by TEXT;
|
||||
|
||||
-- 基座模型表补充所有者
|
||||
ALTER TABLE models ADD COLUMN IF NOT EXISTS created_by TEXT;
|
||||
|
||||
-- 训练产物表补充所有者
|
||||
ALTER TABLE trained_models ADD COLUMN IF NOT EXISTS created_by TEXT;
|
||||
|
||||
-- 评测任务表补充所有者
|
||||
ALTER TABLE eval_tasks ADD COLUMN IF NOT EXISTS created_by TEXT;
|
||||
|
||||
-- 推理任务表补充所有者
|
||||
-- 注意:inference_tasks 表尚未创建,后续建表时直接包含 created_by 字段
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. API 接口清单
|
||||
|
||||
### 12.1 鉴权接口
|
||||
|
||||
| 接口 | 方法 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `/modelTF/login` | POST | 公开 | 登录,返回 token + user |
|
||||
| `/modelTF/me` | GET | Bearer token | 获取当前用户信息 |
|
||||
| `/modelTF/users` | GET | admin | 用户列表 |
|
||||
| `/modelTF/users` | POST | admin | 创建用户 |
|
||||
| `/modelTF/users/:id` | PUT | admin | 更新用户(角色/状态/权限) |
|
||||
| `/modelTF/users/:id` | DELETE | admin | 删除用户(protected 用户不可删) |
|
||||
| `/modelTF/users/:id/reset-password` | POST | admin | 重置密码 |
|
||||
| `/modelTF/system/permissions/codes` | GET | 登录 | 权限码清单 |
|
||||
| `/modelTF/system/permissions` | GET | 登录 | 权限码 + 角色定义 |
|
||||
|
||||
### 12.2 资源 ACL 接口
|
||||
|
||||
| 接口 | 方法 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `/modelTF/resources/:type/:id/acl` | GET | admin 或所有者 | 查询资源 ACL |
|
||||
| `/modelTF/resources/:type/:id/acl` | PUT | admin 或所有者 | 设置资源 ACL |
|
||||
|
||||
### 12.3 GPU 分配接口
|
||||
|
||||
| 接口 | 方法 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `/modelTF/compute/gpu-assignments` | GET | admin | 查看全部分配 |
|
||||
| `/modelTF/compute/gpu-assignments` | POST | admin | 批量分配 |
|
||||
| `/modelTF/compute/gpu-assignments/:id` | DELETE | admin | 撤销分配 |
|
||||
| `/modelTF/compute/my-gpus` | GET | 登录 | 查看自己可用 GPU |
|
||||
|
||||
### 12.4 审批接口
|
||||
|
||||
| 接口 | 方法 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `/modelTF/approvals/templates` | GET/POST | admin | 审批模板列表/创建 |
|
||||
| `/modelTF/approvals` | GET/POST | 登录 | 审批实例列表/创建 |
|
||||
| `/modelTF/approvals/:id` | GET | 登录 | 审批实例详情 |
|
||||
| `/modelTF/approvals/:id/steps/:idx/decision` | POST | 审批人 | 审批决策 |
|
||||
|
||||
### 12.5 审计接口
|
||||
|
||||
| 接口 | 方法 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `/modelTF/system/audit-logs` | GET | admin | 审计日志分页查询 |
|
||||
| `/modelTF/system/audit-logs/export` | GET | admin | CSV 导出 |
|
||||
|
||||
### 12.6 租户/项目接口
|
||||
|
||||
| 接口 | 方法 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `/modelTF/tenants` | GET/POST | admin | 租户列表/创建 |
|
||||
| `/modelTF/tenants/:id` | GET/PUT | admin | 租户详情/更新 |
|
||||
| `/modelTF/tenants/:id/quota` | PUT | admin | 设置配额 |
|
||||
| `/modelTF/tenants/:id/retention-policy` | PUT | admin | 绑定留存策略 |
|
||||
| `/modelTF/projects` | GET/POST | 登录 | 项目列表/创建 |
|
||||
| `/modelTF/projects/:id` | GET/PUT | 登录 | 项目详情/更新 |
|
||||
| `/modelTF/projects/:id/archive` | POST | admin 或所有者 | 归档(审批拦截) |
|
||||
| `/modelTF/projects/:id/members` | GET/POST | 登录 | 成员列表/添加 |
|
||||
| `/modelTF/projects/:id/members/:uid` | PUT/DELETE | admin 或所有者 | 改角色/移除 |
|
||||
|
||||
### 12.7 留存策略接口
|
||||
|
||||
| 接口 | 方法 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `/modelTF/retention-policies` | GET/POST | admin | 策略列表/创建 |
|
||||
| `/modelTF/retention-policies/:id` | GET/PUT/DELETE | admin | 策略详情/更新/删除 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 前端权限控制
|
||||
|
||||
### 13.1 路由守卫
|
||||
|
||||
```typescript
|
||||
// router/index.ts
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const auth = useAuthStore()
|
||||
auth.syncSession()
|
||||
|
||||
if (to.meta.public) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
if (!auth.isLoggedIn) {
|
||||
next({ name: 'login' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!to.meta.skipPermission) {
|
||||
const permission = requiredPermission(to.path, to.meta.permission)
|
||||
if (permission && !auth.hasPermission(permission)) {
|
||||
next({ name: 'permission-denied', replace: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
```
|
||||
|
||||
### 13.2 侧边栏过滤
|
||||
|
||||
```typescript
|
||||
// layouts/MainLayout.vue
|
||||
const visibleMenus = computed(() =>
|
||||
allMenus.filter(menu => {
|
||||
if (!menu.permission) return true
|
||||
return auth.hasPermission(menu.permission)
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
### 13.3 资源级按钮控制
|
||||
|
||||
```vue
|
||||
<!-- 数据集详情页 -->
|
||||
<template>
|
||||
<el-button v-if="canEdit" @click="handleEdit">编辑</el-button>
|
||||
<el-button v-if="canDelete" @click="handleDelete">删除</el-button>
|
||||
<el-button v-if="canManageAcl" @click="showAclDialog = true">资源授权</el-button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const canEdit = computed(() =>
|
||||
isAdmin.value || resource.value.created_by === userId.value
|
||||
)
|
||||
const canDelete = computed(() =>
|
||||
isAdmin.value || resource.value.created_by === userId.value
|
||||
)
|
||||
const canManageAcl = computed(() =>
|
||||
isAdmin.value || resource.value.created_by === userId.value
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
### 13.4 GPU 选择过滤
|
||||
|
||||
```vue
|
||||
<!-- 创建训练任务页 -->
|
||||
<template>
|
||||
<el-select v-model="selectedGpus" multiple>
|
||||
<el-option
|
||||
v-for="gpu in availableGpus"
|
||||
:key="gpu.id"
|
||||
:label="`${gpu.node_name} GPU ${gpu.gpu_index}`"
|
||||
:value="gpu.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-alert v-if="availableGpus.length === 0 && !isAdmin" type="warning">
|
||||
未分配 GPU,请联系管理员
|
||||
</el-alert>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 普通用户只看到 my-gpus 返回的列表
|
||||
// admin 看到全部 GPU
|
||||
const availableGpus = ref([])
|
||||
async function loadGpus() {
|
||||
if (isAdmin.value) {
|
||||
availableGpus.value = await getAllGpus()
|
||||
} else {
|
||||
availableGpus.value = await getMyGpus()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 安全设计补充
|
||||
|
||||
### 14.1 密码安全
|
||||
|
||||
- 密码使用 PBKDF2-SHA256 存储(salt + 390000 次迭代)
|
||||
- 旧系统明文密码在首次登录时自动升级为哈希
|
||||
- 管理员可重置用户密码,用户不可自行修改密码(本期设计)
|
||||
- 默认密码:`platform123`(创建用户时由管理员设定)
|
||||
|
||||
### 14.2 会话安全
|
||||
|
||||
| 规则 | 说明 |
|
||||
|---|---|
|
||||
| Token 格式 | `platform-token-{user_id}` |
|
||||
| 会话超时 | 默认 30 分钟无操作自动过期 |
|
||||
| 并发会话 | 同一用户可有多会话,各自独立计时 |
|
||||
| 会话续期 | 前端定时调用 `auth.refresh()` 续期 |
|
||||
| 强制下线 | admin 可通过修改用户 status=disabled 使其 token 失效 |
|
||||
|
||||
### 14.3 操作限流
|
||||
|
||||
| 接口 | 限制 |
|
||||
|---|---|
|
||||
| `/login` | 同一 IP 5 次/分钟,失败后 30 秒冷却 |
|
||||
| 文件上传 | 单文件最大由配置控制,默认 2GB |
|
||||
| 训练任务创建 | 同一用户并发运行任务数受 GPU 分配限制 |
|
||||
|
||||
### 14.4 数据安全
|
||||
|
||||
| 规则 | 说明 |
|
||||
|---|---|
|
||||
| 软删除 | 数据集、模型、任务使用 `deleted_at` 标记,保留审计可追溯 |
|
||||
| 敏感字段 | API 密钥(`api_key`)在列表接口不返回明文 |
|
||||
| 下载审计 | 数据集下载产生审计日志,记录下载人和时间 |
|
||||
| 导出审计 | 训练产物导出产生审计日志 |
|
||||
|
||||
### 14.5 多租户隔离
|
||||
|
||||
| 规则 | 说明 |
|
||||
|---|---|
|
||||
| 租户隔离 | 同一租户内的资源相互可见;跨租户默认不可见 |
|
||||
| 项目隔离 | 项目内资源受项目 ACL 控制;项目间默认不可见 |
|
||||
| admin 旁路 | admin 可跨租户/项目访问全部资源 |
|
||||
| 配额管控 | 租户级配额限制 GPU 并发数、存储容量、最大项目数 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 实施计划
|
||||
|
||||
### 15.1 已实现
|
||||
|
||||
| 功能 | 状态 |
|
||||
|---|---|
|
||||
| 登录/会话/Token | ✅ 已实现 |
|
||||
| 用户 CRUD + 权限码 | ✅ 已实现 |
|
||||
| 角色定义 | ✅ 已实现 |
|
||||
| 资源 ACL(acls 表 + 接口) | ✅ 已实现 |
|
||||
| 审计日志(查询 + 导出) | ✅ 已实现 |
|
||||
| 审批模板/实例 | ✅ 已实现 |
|
||||
| 项目空间 + 成员 | ✅ 已实现 |
|
||||
| 租户 + 配额 + 留存 | ✅ 已实现 |
|
||||
| 审批拦截(项目归档/删除) | ✅ 已实现 |
|
||||
| 资源所有权 ACL 字段适配(subject_type/permissions[]) | ✅ 已实现 |
|
||||
|
||||
### 15.2 待实现
|
||||
|
||||
| 功能 | 优先级 | 涉及表/接口 |
|
||||
|---|---|---|
|
||||
| GPU 分配表 + 接口 | P0 | `gpu_assignments` 表 + `/compute/gpu-assignments` + `/compute/my-gpus` |
|
||||
| 资源 `created_by` 字段补充 | P0 | `datasets` / `models` / `trained_models` / `eval_tasks` 表 ALTER |
|
||||
| 资源列表按 `created_by` + ACL 过滤 | P0 | `platform_store.py` 中 datasets/models/tasks 列表方法 |
|
||||
| GPU 选择校验(训练/评测/推理创建时) | P0 | `platform.py` 中 create_task/eval/inference |
|
||||
| 前端 GPU 下拉过滤 | P1 | 前端创建任务页面 |
|
||||
| 前端资源授权按钮 | P1 | 前端数据集/模型/任务详情页 |
|
||||
| 前端权限管理页面优化 | P1 | 前端用户设置页面 |
|
||||
| 审批拦截扩展(删除数据集/模型/停止任务) | P1 | `platform.py` 中 delete/stop 接口 |
|
||||
| 密码安全策略(用户自行修改) | P2 | 新增 `/users/me/password` 接口 |
|
||||
| 操作限流(login 限流) | P2 | 中间件或 SlowAPI |
|
||||
| 多租户隔离(按 tenant_id 过滤) | P2 | 各列表接口增加 tenant_id 过滤 |
|
||||
|
||||
### 15.3 实施步骤
|
||||
|
||||
1. **数据库迁移**:创建 `gpu_assignments` 表,为资源表补充 `created_by` 字段
|
||||
2. **后端接口**:实现 GPU 分配 CRUD + `my-gpus` + 创建任务时的 GPU 权限校验
|
||||
3. **资源过滤**:在 `datasets()` / `models()` / `tasks()` 等列表方法中按 `created_by` + ACL 过滤
|
||||
4. **前端适配**:GPU 下拉过滤、资源授权按钮、权限管理页面优化
|
||||
5. **审批扩展**:在删除/停止接口中接入 `_require_approval_or_admin`
|
||||
6. **测试补充**:扩展 `test_governance.py` 覆盖 GPU 分配、资源过滤、审批扩展场景
|
||||
38
frontend/src/api/modules/gpu.ts
Normal file
38
frontend/src/api/modules/gpu.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { del, get, post } from '../request'
|
||||
|
||||
export interface GpuAssignment {
|
||||
id: string
|
||||
node_id: string
|
||||
gpu_index: number
|
||||
user_id: string
|
||||
assigned_by?: string | null
|
||||
assigned_at?: string
|
||||
username?: string
|
||||
display_name?: string
|
||||
node_code?: string
|
||||
node_name?: string
|
||||
gpu_name?: string
|
||||
}
|
||||
|
||||
export interface MyGpu {
|
||||
node_id: string
|
||||
gpu_index: number
|
||||
node_code: string
|
||||
node_name: string
|
||||
gpu_name?: string
|
||||
uuid?: string
|
||||
memory_total_gb?: number
|
||||
}
|
||||
|
||||
/** 查看全部分配关系(admin) */
|
||||
export const getGpuAssignments = () => get<GpuAssignment[]>('/compute/gpu-assignments')
|
||||
|
||||
/** 批量分配 GPU(admin) */
|
||||
export const assignGpus = (assignments: Array<{ node_id: string; gpu_index: number; user_id: string }>) =>
|
||||
post<GpuAssignment[]>('/compute/gpu-assignments', { assignments })
|
||||
|
||||
/** 撤销 GPU 分配(admin) */
|
||||
export const unassignGpu = (id: string) => del(`/compute/gpu-assignments/${id}`)
|
||||
|
||||
/** 查看当前用户可用的 GPU 列表 */
|
||||
export const getMyGpus = () => get<MyGpu[]>('/compute/my-gpus')
|
||||
@@ -42,3 +42,10 @@ export const resetUserPassword = (id: string, password?: string) =>
|
||||
/** 删除用户(protected 管理员账号不允许删除) */
|
||||
export const deleteUser = (id: string) =>
|
||||
del<{ deleted: string }>(`/users/${encodeURIComponent(id)}`)
|
||||
|
||||
/** 用户自行修改密码 */
|
||||
export const changeMyPassword = (oldPassword: string, newPassword: string) =>
|
||||
post<{ changed: boolean }>('/users/me/password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
})
|
||||
|
||||
@@ -98,7 +98,14 @@ const visibleMenuGroups = computed(() =>
|
||||
menuGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => auth.hasPermission(item.permission)),
|
||||
items: group.items.filter((item) => {
|
||||
if (!auth.hasPermission(item.permission)) return false
|
||||
// user-settings 权限对应的菜单仅管理员可见
|
||||
if (item.permission === 'user-settings' && !auth.isAdmin) return false
|
||||
// 算力节点仅管理员可见
|
||||
if (item.permission === 'compute' && !auth.isAdmin) return false
|
||||
return true
|
||||
}),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0),
|
||||
)
|
||||
|
||||
@@ -388,6 +388,16 @@ router.beforeEach((to, _from, next) => {
|
||||
next({ name: 'permission-denied', replace: true })
|
||||
return
|
||||
}
|
||||
// user-settings 权限对应的页面仅管理员可访问
|
||||
if (permission === 'user-settings' && !auth.isAdmin) {
|
||||
next({ name: 'permission-denied', replace: true })
|
||||
return
|
||||
}
|
||||
// 算力节点仅管理员可访问
|
||||
if (permission === 'compute' && !auth.isAdmin) {
|
||||
next({ name: 'permission-denied', replace: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 路由切换时记录业务模块访问(用于看板用户操作分布统计)
|
||||
|
||||
@@ -49,6 +49,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
})
|
||||
|
||||
const isLoggedIn = computed(() => currentUser.value !== null)
|
||||
const isAdmin = computed(() => currentUser.value?.role === 'admin')
|
||||
|
||||
/** 登录 */
|
||||
async function login(user: string, password: string) {
|
||||
@@ -85,6 +86,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
displayName,
|
||||
roleLabel,
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
hasPermission,
|
||||
login,
|
||||
logout,
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
type ResourceReplica,
|
||||
type ResourceSyncJob,
|
||||
} from '@/api/modules/compute'
|
||||
import { getGpuAssignments, assignGpus, unassignGpu, type GpuAssignment } from '@/api/modules/gpu'
|
||||
import { getUsers, type SystemUser } from '@/api/modules/system'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { statusLabel, statusTagType } from '@/utils/status'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -37,6 +40,52 @@ const gpus = ref<ComputeGpu[]>([])
|
||||
const queue = ref<ComputeQueueItem[]>([])
|
||||
const replicas = ref<ResourceReplica[]>([])
|
||||
const activeSyncJob = ref<ResourceSyncJob | null>(null)
|
||||
|
||||
// GPU 分配管理
|
||||
const auth = useAuthStore()
|
||||
const gpuAssignments = ref<GpuAssignment[]>([])
|
||||
const assignmentLoading = ref(false)
|
||||
const showAssignDialog = ref(false)
|
||||
const assignForm = ref({ node_id: '', gpu_index: 0, user_id: '' })
|
||||
const allUsers = ref<SystemUser[]>([])
|
||||
|
||||
async function loadGpuAssignments() {
|
||||
if (!auth.isAdmin) return
|
||||
assignmentLoading.value = true
|
||||
try {
|
||||
gpuAssignments.value = await getGpuAssignments()
|
||||
} catch {
|
||||
gpuAssignments.value = []
|
||||
} finally {
|
||||
assignmentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssign() {
|
||||
if (!assignForm.value.node_id || !assignForm.value.user_id) {
|
||||
ElMessage.warning('请选择节点和用户')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await assignGpus([{ node_id: assignForm.value.node_id, gpu_index: assignForm.value.gpu_index, user_id: assignForm.value.user_id }])
|
||||
ElMessage.success('GPU 分配成功')
|
||||
showAssignDialog.value = false
|
||||
await loadGpuAssignments()
|
||||
} catch {
|
||||
ElMessage.error('GPU 分配失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnassign(id: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认撤销此 GPU 分配?', '提示', { type: 'warning' })
|
||||
await unassignGpu(id)
|
||||
ElMessage.success('已撤销分配')
|
||||
await loadGpuAssignments()
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
const selectedNodeId = ref('')
|
||||
const lastUpdated = ref('')
|
||||
const nodeDialogVisible = ref(false)
|
||||
@@ -127,6 +176,10 @@ async function loadReplicas() {
|
||||
|
||||
async function changeTab(name: string | number) {
|
||||
await router.replace({ path: '/compute', query: { tab: String(name) } })
|
||||
if (name === 'assignments') {
|
||||
loadGpuAssignments()
|
||||
allUsers.value = await getUsers().catch(() => [])
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNodeAction(action: 'enable' | 'disable' | 'test', node: ComputeNode) {
|
||||
@@ -309,6 +362,7 @@ function formatTime(value?: string) {
|
||||
onMounted(() => {
|
||||
load({ showLoading: true })
|
||||
timer = setInterval(() => load(), 5000)
|
||||
if (auth.isAdmin) loadGpuAssignments()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -476,6 +530,24 @@ onUnmounted(() => {
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane v-if="auth.isAdmin" label="GPU 分配" name="assignments">
|
||||
<div style="margin-bottom: 16px">
|
||||
<el-button type="primary" @click="showAssignDialog = true">分配 GPU</el-button>
|
||||
</div>
|
||||
<el-table :data="gpuAssignments" border v-loading="assignmentLoading">
|
||||
<el-table-column prop="node_name" label="节点" min-width="140" />
|
||||
<el-table-column prop="gpu_index" label="GPU 序号" width="100" />
|
||||
<el-table-column prop="gpu_name" label="GPU 名称" min-width="160" />
|
||||
<el-table-column prop="display_name" label="被分配用户" min-width="140" />
|
||||
<el-table-column prop="assigned_at" label="分配时间" min-width="180" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" size="small" @click="handleUnassign(row.id)">撤销</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog
|
||||
@@ -526,6 +598,29 @@ onUnmounted(() => {
|
||||
<el-button type="primary" :loading="savingNode" @click="saveNode">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- GPU 分配对话框 -->
|
||||
<el-dialog v-model="showAssignDialog" title="分配 GPU" width="480px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="算力节点">
|
||||
<el-select v-model="assignForm.node_id" placeholder="选择节点">
|
||||
<el-option v-for="n in nodes" :key="n.id" :label="n.name || n.code" :value="n.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="GPU 序号">
|
||||
<el-input-number v-model="assignForm.gpu_index" :min="0" :max="15" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户">
|
||||
<el-select v-model="assignForm.user_id" placeholder="选择用户" filterable>
|
||||
<el-option v-for="u in allUsers" :key="u.id" :label="u.display_name || u.username" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAssignDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleAssign">确认分配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import { getModelList } from '@/api/modules/model'
|
||||
import { getDatasetList } from '@/api/modules/dataset'
|
||||
import { getSystemInfo } from '@/api/modules/system'
|
||||
import { getComputeNodes } from '@/api/modules/compute'
|
||||
import { getMyGpus } from '@/api/modules/gpu'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { TEMPLATE_GROUPS, LR_SCHEDULER_OPTIONS, QUANTIZATION_BIT_OPTIONS, QUANT_METHOD_OPTIONS, GGUF_FORMAT_OPTIONS } from '@/constants'
|
||||
import {
|
||||
DEFAULT_TRAINING_PARAMS,
|
||||
@@ -46,8 +48,21 @@ const availableGpus = computed(() => {
|
||||
.filter((n) => n.scheduler_status === 'online' || n.scheduler_status === 'draining')
|
||||
.map((n) => n.id),
|
||||
)
|
||||
return gpus.value.filter((gpu) => !gpu.node_id || onlineNodeIds.has(gpu.node_id))
|
||||
let result = gpus.value.filter((gpu) => !gpu.node_id || onlineNodeIds.has(gpu.node_id))
|
||||
// 普通用户只能看到被分配的 GPU
|
||||
if (!auth.isAdmin) {
|
||||
const assignedKeys = new Set(
|
||||
myAssignedGpus.value.map((g) => `${g.node_id}:${g.gpu_index}`),
|
||||
)
|
||||
result = result.filter((gpu) => {
|
||||
const key = `${gpu.node_id}:${gpu.id ?? gpu.uuid ?? gpu.name}`
|
||||
return assignedKeys.has(key) || myAssignedGpus.value.length === 0
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
const auth = useAuthStore()
|
||||
const myAssignedGpus = ref<Array<{ node_id: string; gpu_index: number }>>([])
|
||||
const modelDialogVisible = ref(false)
|
||||
|
||||
const form = reactive(createDefaultFineTuneForm())
|
||||
@@ -207,6 +222,14 @@ async function loadGpus() {
|
||||
const [sys, nodes] = await Promise.all([getSystemInfo(), getComputeNodes().catch(() => [])])
|
||||
gpus.value = sys?.gpu || []
|
||||
computeNodes.value = nodes || []
|
||||
// 普通用户加载被分配的 GPU
|
||||
if (!auth.isAdmin) {
|
||||
try {
|
||||
myAssignedGpus.value = await getMyGpus()
|
||||
} catch {
|
||||
myAssignedGpus.value = []
|
||||
}
|
||||
}
|
||||
const firstIdle = availableGpus.value.find((gpu) => !isGpuUnavailable(gpu) && gpu.id != null)
|
||||
if (firstIdle) selectedGpuKeys.value = [gpuKey(firstIdle)]
|
||||
} catch {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
changeMyPassword,
|
||||
deleteUser,
|
||||
getUsers,
|
||||
resetUserPassword,
|
||||
@@ -106,6 +107,34 @@ async function confirmResetPwd() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 用户自改密码 ----------
|
||||
const myPwdDialog = reactive({ visible: false, oldPassword: '', newPassword: '', saving: false })
|
||||
function openChangeMyPwd() {
|
||||
myPwdDialog.oldPassword = ''
|
||||
myPwdDialog.newPassword = ''
|
||||
myPwdDialog.visible = true
|
||||
}
|
||||
async function confirmChangeMyPwd() {
|
||||
if (!myPwdDialog.oldPassword.trim() || !myPwdDialog.newPassword.trim()) {
|
||||
ElMessage.warning('请填写旧密码和新密码')
|
||||
return
|
||||
}
|
||||
if (myPwdDialog.newPassword.length < 6) {
|
||||
ElMessage.warning('新密码至少 6 位')
|
||||
return
|
||||
}
|
||||
myPwdDialog.saving = true
|
||||
try {
|
||||
await changeMyPassword(myPwdDialog.oldPassword.trim(), myPwdDialog.newPassword.trim())
|
||||
ElMessage.success('密码修改成功')
|
||||
myPwdDialog.visible = false
|
||||
} catch {
|
||||
ElMessage.error('密码修改失败,请检查旧密码是否正确')
|
||||
} finally {
|
||||
myPwdDialog.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 页面权限 ----------
|
||||
const permDialog = reactive({
|
||||
visible: false,
|
||||
@@ -117,10 +146,24 @@ const permDialog = reactive({
|
||||
function openPerms(row: SystemUser) {
|
||||
permDialog.id = row.id
|
||||
permDialog.name = row.display_name
|
||||
permDialog.checked = [...(row.permissions || [])]
|
||||
// admin 用户强制全选且只读
|
||||
if (row.role === 'admin' || row.protected) {
|
||||
permDialog.checked = [...ALL_PERMISSIONS]
|
||||
} else {
|
||||
// 非 admin 用户去掉 user-settings
|
||||
permDialog.checked = (row.permissions || []).filter((p) => p !== 'user-settings')
|
||||
}
|
||||
permDialog.visible = true
|
||||
}
|
||||
async function confirmPerms() {
|
||||
if (permReadonly.value) {
|
||||
permDialog.visible = false
|
||||
return
|
||||
}
|
||||
// 双重保险:非 admin 用户不允许勾选 user-settings
|
||||
if (!isTargetAdmin.value) {
|
||||
permDialog.checked = permDialog.checked.filter((p) => p !== 'user-settings')
|
||||
}
|
||||
permDialog.saving = true
|
||||
try {
|
||||
await updateUserAccess(permDialog.id, { permissions: permDialog.checked })
|
||||
@@ -134,7 +177,17 @@ async function confirmPerms() {
|
||||
}
|
||||
}
|
||||
|
||||
const permColumns = computed(() => ALL_PERMISSIONS)
|
||||
/** 权限列:admin 用户全选且只读,非 admin 用户不显示 user-settings */
|
||||
const isTargetAdmin = computed(() => {
|
||||
const u = users.value.find((u) => u.id === permDialog.id)
|
||||
return u?.role === 'admin' || u?.protected === true
|
||||
})
|
||||
const permColumns = computed(() => {
|
||||
if (isTargetAdmin.value) return ALL_PERMISSIONS
|
||||
// 非 admin 用户不能拥有 user-settings 权限
|
||||
return ALL_PERMISSIONS.filter((c) => c !== 'user-settings')
|
||||
})
|
||||
const permReadonly = computed(() => isTargetAdmin.value)
|
||||
|
||||
// ---------- 删除 ----------
|
||||
async function removeUser(row: SystemUser) {
|
||||
@@ -165,7 +218,10 @@ async function removeUser(row: SystemUser) {
|
||||
<h1>用户设置</h1>
|
||||
<p>管理平台账号、角色状态、登录密码与页面权限。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="$router.push('/user-settings/create')">创建用户</el-button>
|
||||
<div>
|
||||
<el-button @click="openChangeMyPwd">修改密码</el-button>
|
||||
<el-button type="primary" @click="$router.push('/user-settings/create')">创建用户</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-table :data="users" border>
|
||||
@@ -236,8 +292,11 @@ async function removeUser(row: SystemUser) {
|
||||
|
||||
<!-- 页面权限 -->
|
||||
<el-dialog v-model="permDialog.visible" title="页面权限" width="540px">
|
||||
<p class="dlg-tip">为 <b>{{ permDialog.name }}</b> 分配可访问的页面模块:</p>
|
||||
<el-checkbox-group v-model="permDialog.checked" class="perm-group">
|
||||
<p class="dlg-tip">
|
||||
为 <b>{{ permDialog.name }}</b> 分配可访问的页面模块:
|
||||
<el-tag v-if="permReadonly" type="warning" size="small" style="margin-left: 8px">管理员权限不可更改</el-tag>
|
||||
</p>
|
||||
<el-checkbox-group v-model="permDialog.checked" class="perm-group" :disabled="permReadonly">
|
||||
<el-checkbox
|
||||
v-for="code in permColumns"
|
||||
:key="code"
|
||||
@@ -246,8 +305,24 @@ async function removeUser(row: SystemUser) {
|
||||
/>
|
||||
</el-checkbox-group>
|
||||
<template #footer>
|
||||
<el-button @click="permDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="permDialog.saving" @click="confirmPerms">保存</el-button>
|
||||
<el-button @click="permDialog.visible = false">{{ permReadonly ? '关闭' : '取消' }}</el-button>
|
||||
<el-button v-if="!permReadonly" type="primary" :loading="permDialog.saving" @click="confirmPerms">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 修改自己的密码 -->
|
||||
<el-dialog v-model="myPwdDialog.visible" title="修改密码" width="420px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="旧密码">
|
||||
<el-input v-model="myPwdDialog.oldPassword" placeholder="请输入当前密码" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="myPwdDialog.newPassword" placeholder="至少 6 位" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="myPwdDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="myPwdDialog.saving" @click="confirmChangeMyPwd">确认修改</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
|
||||
282
平台治理.md
282
平台治理.md
@@ -1,282 +0,0 @@
|
||||
# 平台治理功能说明
|
||||
|
||||
平台治理涵盖用户管理、租户管理、项目管理、审批管理、审计日志、资源授权(ACL)、留存策略等企业管理能力。功能入口位于侧边栏「平台治理」和「系统设置」两个分组下。
|
||||
|
||||
本平台核心业务是**模型微调**:用户上传数据 → 数据处理 → 模型训练 → 模型评测 → 模型推理。平台治理负责管理**谁**能访问**哪个租户/项目**的**哪些资源**。
|
||||
|
||||
---
|
||||
|
||||
## 侧边栏菜单结构
|
||||
|
||||
```
|
||||
平台治理
|
||||
├── 租户管理 /tenants
|
||||
├── 项目空间 /projects
|
||||
├── 审计日志 /audit-logs
|
||||
├── 审批模板 /approval-templates
|
||||
└── 审批中心 /approval-instances
|
||||
|
||||
系统设置
|
||||
├── 用户设置 /user-settings
|
||||
├── 平台性能 /hardware
|
||||
└── 查看日志 /logs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 租户管理
|
||||
|
||||
**对应页面**:列表页 `TenantListView.vue` + 详情页 `TenantDetailView.vue`
|
||||
**后端接口**:`GET/POST /tenants`、`GET/PUT/DELETE /tenants/:id`、`PUT /tenants/:id/quota`、`PUT /tenants/:id/retention-policy`
|
||||
|
||||
### 列表页
|
||||
|
||||
**展示列**:租户名称、用户ID、状态、创建时间
|
||||
|
||||
**行操作**:
|
||||
| 按钮 | 功能 |
|
||||
|------|------|
|
||||
| 详情 | 跳转详情页 `/tenants/:id` |
|
||||
| 删除 | 确认弹窗后调用 `DELETE /tenants/:id` |
|
||||
|
||||
**新建租户弹窗**:
|
||||
| 字段 | 控件 | 说明 |
|
||||
|------|------|------|
|
||||
| 名称 | 文本输入 | 必填 |
|
||||
| 用户ID | 文本输入 | 可选 |
|
||||
| GPU 数量 | 数字输入 | 配额,0=不限制 |
|
||||
| 存储配额(GB) | 数字输入 | 配额,0=不限制 |
|
||||
| 最大项目数 | 数字输入 | 配额,0=不限制 |
|
||||
|
||||
### 详情页
|
||||
|
||||
**基本信息**:名称、用户ID、状态、创建时间、配额(友好格式显示,如 `GPU 8 | 存储 100GB | 项目 5`)
|
||||
|
||||
**配额编辑**:GPU 数量 / 存储配额(GB) / 最大项目数,三个数字输入 + 保存按钮
|
||||
|
||||
**已移除**:详情页中不再内嵌项目空间列表(项目有独立页面)
|
||||
|
||||
### 当前缺失
|
||||
- [ ] 编辑租户名称和用户ID(`updateTenant` API 已有,UI 缺失)
|
||||
- [ ] 留存策略关联设置(`setTenantRetention` API 已有,UI 缺失)
|
||||
- [ ] 列表页无搜索/筛选
|
||||
- [ ] 状态列无 Tag 着色
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目空间
|
||||
|
||||
**对应页面**:列表页 `ProjectListView.vue` + 详情页 `ProjectDetailView.vue`
|
||||
**后端接口**:`GET/POST /projects`、`GET/PUT/DELETE /projects/:id`、`GET/POST/DELETE /projects/:id/members`、`PUT /projects/:id/archive`
|
||||
|
||||
### 列表页
|
||||
|
||||
**展示列**:项目名、编码ID、状态、描述、创建时间
|
||||
|
||||
**工具栏**:
|
||||
- 租户选择器(下拉过滤,按租户编码选择)
|
||||
- 搜索框(按项目名/编码搜索)
|
||||
- 新建项目按钮
|
||||
|
||||
**新建项目弹窗**:
|
||||
| 字段 | 控件 | 说明 |
|
||||
|------|------|------|
|
||||
| 名称 | 文本输入 | 必填 |
|
||||
| 编码ID | 下拉选择 | 选择已有租户的编码(如 `default`),关联到该租户 |
|
||||
| 描述 | 文本域 | 可选,3行 |
|
||||
|
||||
**行操作**:详情 + 删除
|
||||
|
||||
### 详情页
|
||||
|
||||
**基本信息**:名称、编码、状态、租户、描述、创建时间
|
||||
|
||||
**项目成员管理**:
|
||||
- 成员列表(用户名、角色、添加时间)
|
||||
- 添加成员:选择用户 + 角色(member/admin/viewer)
|
||||
- 移除成员
|
||||
|
||||
**资源授权(ACL)**:
|
||||
- 弹窗编辑器,逐条配置授权规则
|
||||
- 主体类型:用户(下拉选已有用户)或项目角色(member/admin/viewer)
|
||||
- 权限:read / write / execute / download / delete / share(多选 checkbox)
|
||||
- 增删行后统一保存
|
||||
|
||||
### 当前缺失
|
||||
- [ ] 编辑项目基本信息(名称、描述)
|
||||
- [ ] 项目启停/归档操作(`archiveProject` API 已有,UI 缺失)
|
||||
- [ ] 项目下的资源使用统计(模型数、数据集数、任务数)
|
||||
|
||||
---
|
||||
|
||||
## 3. 审批管理
|
||||
|
||||
**对应页面**:`ApprovalTemplateView.vue`(模板)+ `ApprovalInstanceView.vue`(实例)
|
||||
**后端接口**:模板 CRUD、实例列表+决策
|
||||
|
||||
### 审批模板
|
||||
|
||||
**展示列**:模板名、步骤数、创建时间
|
||||
|
||||
**新建模板**:
|
||||
| 字段 | 控件 | 说明 |
|
||||
|------|------|------|
|
||||
| 模板名称 | 文本输入 | 必填 |
|
||||
| 审批步骤 | 文本域 | **需手写 JSON 字符串**,体验差 |
|
||||
|
||||
**当前缺失**:
|
||||
- [ ] 可视化步骤编辑(拖拽添加步骤、选审批人)
|
||||
- [ ] 模板编辑/删除(API 已有,UI 缺失)
|
||||
- [ ] 模板详情页
|
||||
|
||||
### 审批中心
|
||||
|
||||
**展示列**:资源类型、资源ID、状态(Tag着色)、发起人、创建时间
|
||||
|
||||
**工具栏**:状态筛选(待审批/已通过/已拒绝)、资源类型/ID 搜索
|
||||
|
||||
**行操作**:通过/拒绝(弹窗填写审批意见)
|
||||
|
||||
**当前缺失**:
|
||||
- [ ] "我发起的"审批视角
|
||||
- [ ] 审批流转详情(谁审批了、什么时间)
|
||||
- [ ] 撤回功能
|
||||
|
||||
---
|
||||
|
||||
## 4. 审计日志
|
||||
|
||||
**对应页面**:`AuditLogView.vue`
|
||||
**后端接口**:`GET /system/audit-logs`(多条件筛选+分页)、`GET /system/audit-logs/export`(CSV导出)
|
||||
|
||||
### 列表页
|
||||
|
||||
**展示列**:时间、租户ID、项目ID、操作人ID、动作、目标类型、目标ID、详情、IP
|
||||
|
||||
**筛选条件**:租户ID、项目ID、操作人ID、动作、目标类型、开始时间、结束时间
|
||||
|
||||
**工具栏**:CSV 导出按钮(最多 10000 条)
|
||||
|
||||
### 当前缺失
|
||||
- [ ] 分页控件(数据量大时需要翻页)
|
||||
- [ ] 筛选字段改为下拉选择(当前全是文本输入,不知道有哪些可选值)
|
||||
- [ ] 详情弹窗(点击某条记录查看完整信息)
|
||||
|
||||
---
|
||||
|
||||
## 5. 用户设置
|
||||
|
||||
**对应页面**:`UserSettingsView.vue`(列表)+ `UserCreateView.vue`(创建)+ `UserPermissionView.vue`(权限弹窗)
|
||||
**后端接口**:`GET/POST/PUT/DELETE /users`、`POST /users/:id/reset-password`
|
||||
|
||||
### 列表页
|
||||
|
||||
**展示列**:账号、显示名、角色、状态、页面权限、创建时间
|
||||
|
||||
**行操作**:
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| 启停开关 | protected 用户和自己不可操作 |
|
||||
| 重置密码 | 弹窗确认,默认密码 `Platform@123` |
|
||||
| 页面权限 | 弹窗 checkbox 组,12 个模块可选:看板/模型训练/模型评测/模型推理/模型管理/数据集/数据处理/数据转换/算力/平台性能/查看日志/用户设置 |
|
||||
| 删除 | 确认弹窗,protected 用户和自己不可操作 |
|
||||
|
||||
### 创建页
|
||||
|
||||
**表单字段**:账号、显示名、初始密码、角色(超级管理员/操作员/观察员)、状态、页面权限
|
||||
|
||||
### 当前缺失
|
||||
- [ ] 用户编辑页面(修改显示名、角色等,`updateUser` API 已有,UI 缺失)
|
||||
- [ ] 权限码显示为英文(如 `fine-tune`),无中文翻译
|
||||
|
||||
---
|
||||
|
||||
## 6. 平台性能
|
||||
|
||||
**对应页面**:`HardwareView.vue`
|
||||
**后端接口**:`GET /system-info`、`GET /health`(顶部栏实时指标)
|
||||
|
||||
### 页面内容
|
||||
|
||||
**概览卡片**:CPU(型号/核心数/使用率)、内存(已用/总量/使用率)、磁盘(已用/总量/使用率)、网络吞吐
|
||||
|
||||
**趋势图**:CPU/内存/磁盘/GPU 利用率折线图(最近 60 次采样,1/3/5/10秒自动刷新)
|
||||
|
||||
**GPU 资源池**:每张卡展示名称、状态、利用率、显存、温度、功耗、风扇转速;点击卡片查看详情抽屉(设备属性、趋势图、进程列表)
|
||||
|
||||
**主机信息**:OS、运行时长、进程数、GPU 驱动版本
|
||||
|
||||
### 当前缺失
|
||||
- [ ] 历史数据持久化(刷新后采样清空)
|
||||
- [ ] 告警阈值设置
|
||||
- [ ] 网络吞吐数据显示为空
|
||||
|
||||
---
|
||||
|
||||
## 7. 查看日志
|
||||
|
||||
**对应页面**:`LogsView.vue`(系统日志 + 训练日志双 Tab)
|
||||
|
||||
### 系统日志
|
||||
|
||||
- 按日期选择日志文件
|
||||
- 关键词搜索
|
||||
- 日志级别筛选(INFO/WARN/ERROR/DEBUG)
|
||||
- 自动刷新(5/10/30/60 秒可调)
|
||||
|
||||
### 训练日志
|
||||
|
||||
- 按 PID 选择训练日志文件
|
||||
- 同样支持搜索和级别筛选
|
||||
|
||||
### 当前缺失
|
||||
- [ ] 日志下载/导出
|
||||
- [ ] 分页或虚拟滚动(大文件加载慢)
|
||||
- [ ] 行号显示
|
||||
|
||||
---
|
||||
|
||||
## 8. 留存策略 ⚠️ 前端完全缺失
|
||||
|
||||
**后端 API 已完整实现**(CRUD 5 个端点),**前端 `retention.ts` 模块已封装**,但:
|
||||
- 侧边栏无菜单入口
|
||||
- 路由未配置
|
||||
- 无任何 Vue 页面
|
||||
|
||||
---
|
||||
|
||||
## 数据流转关系
|
||||
|
||||
```
|
||||
用户登录 → 分配角色(admin/operator/viewer) + 页面权限
|
||||
│
|
||||
├─ 创建租户(配额:GPU/存储/项目数)
|
||||
│ └─ 关联用户
|
||||
│
|
||||
├─ 创建项目(关联租户,选择编码ID)
|
||||
│ ├─ 项目成员(角色:member/admin/viewer)
|
||||
│ └─ 资源授权(ACL):谁对什么资源有什么权限
|
||||
│
|
||||
├─ 创建审批模板(定义审批流程)
|
||||
│ └─ 审批实例:敏感操作需要审批(通过/拒绝)
|
||||
│
|
||||
├─ 审计日志:所有操作自动记录(谁在什么时间做了什么)
|
||||
│
|
||||
└─ 留存策略:定义数据保留周期,自动清理过期数据
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完成度总表
|
||||
|
||||
| 模块 | 列表 | 新建 | 编辑 | 删除 | 搜索 | 筛选 | 导出 | 完成度 |
|
||||
|------|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
|
||||
| 租户管理 | ✅ | ✅ | 仅配额 | ✅ | ❌ | ❌ | ❌ | 75% |
|
||||
| 项目空间 | ✅ | ✅ | ❌ | ✅ | ✅ | 按租户 | ❌ | 80% |
|
||||
| 审批模板 | ✅ | ✅(JSON) | ❌ | ❌ | ❌ | ❌ | ❌ | 45% |
|
||||
| 审批中心 | ✅ | N/A | N/A | ❌ | ✅ | 按状态 | ❌ | 65% |
|
||||
| 审计日志 | ✅ | N/A | N/A | N/A | ❌ | 多字段 | CSV | 70% |
|
||||
| 用户设置 | ✅ | ✅ | 仅权限 | ✅ | ❌ | ❌ | ❌ | 75% |
|
||||
| 平台性能 | ✅ | N/A | N/A | N/A | N/A | N/A | ❌ | 80% |
|
||||
| 查看日志 | ✅ | N/A | N/A | N/A | 关键词 | 按级别 | ❌ | 70% |
|
||||
| **留存策略** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | **0%** |
|
||||
Reference in New Issue
Block a user