# Conflicts:
#	backend/app/api/v1/endpoints/platform.py
#	compute/requirements.txt
This commit is contained in:
wuyongtao
2026-08-03 09:42:49 +08:00
67 changed files with 7775 additions and 653 deletions

View File

@@ -8,12 +8,14 @@ import os
import re
import socket
import time
from collections.abc import Iterator, Mapping
from concurrent.futures import ThreadPoolExecutor, as_completed
from contextlib import contextmanager
from copy import deepcopy
from dataclasses import asdict
from pathlib import Path
from threading import BoundedSemaphore, Lock
from typing import Any, Iterator, Literal
from typing import Any, Literal
from urllib.parse import quote, urlsplit
import httpx
@@ -32,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,
@@ -45,9 +48,10 @@ from app.modules.data_process.algorithms import (
is_near_duplicate,
near_duplicate_fingerprint,
parse_text_content,
preprocess_structured_records,
preprocess_structured_records_with_lineage,
remove_document_noise,
score_quality,
structured_json_dumps,
)
from app.modules.data_process.document_chunking import (
DocumentChunk,
@@ -75,9 +79,11 @@ from app.modules.data_process.store import (
NotFoundError,
get_data_process_store,
new_id,
repeat_task_id,
)
from app.schemas.data_process import (
DataProcessRegenerateRequest,
DataProcessRepeatRequest,
DataProcessStatus,
DataProcessTaskCreate,
DataProcessTaskUpdate,
@@ -261,7 +267,14 @@ def _parse_stored_source(source: dict[str, Any]) -> ParsedText:
content = str(source.get("content") or "")
file_format = str(source.get("file_format") or "").lower()
if file_format == "xlsx":
# XLSX 上传阶段已安全解析为 JSONL 后入库。
raw_content = source.get("raw_content")
if isinstance(raw_content, bytes):
return parse_text_content(
raw_content,
filename=str(source.get("name") or "source.xlsx"),
file_format="xlsx",
)
# 兼容原始对象已缺失的历史文件:退化为上传阶段生成的 JSONL。
return parse_text_content(content, file_format="jsonl")
if file_format in {"pdf", "docx", "pptx"}:
# 文档上传阶段已抽取文本,预览阶段只需要对正文切片。
@@ -381,11 +394,12 @@ def _build_preview_items(
seen_near_duplicate_bands: dict[tuple[int, int], list[str]] = {}
items: list[dict[str, Any]] = []
def append_item(item: dict[str, Any]) -> None:
def append_item(item: dict[str, Any], *, dedup_content: str) -> None:
content = str(item.get("edited_content") or "").strip()
if should_clean_invalid and not content:
return
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
# 去重必须基于脱敏前内容,否则不同原文可能在替换 PII 后被错误合并。
content_hash = hashlib.sha256(dedup_content.strip().encode("utf-8")).hexdigest()
if should_deduplicate and content_hash in seen_content_hashes:
return
seen_content_hashes.add(content_hash)
@@ -447,6 +461,7 @@ def _build_preview_items(
continue
for key in band_keys:
seen_near_duplicate_bands.setdefault(key, []).append(content)
dedup_content = content
pii_counts: dict[str, int] = {}
if should_desensitize:
content, pii_counts = desensitize_pii(content)
@@ -476,7 +491,8 @@ def _build_preview_items(
else "original"
),
"quality_score": quality,
}
},
dedup_content=dedup_content,
)
continue
@@ -488,48 +504,76 @@ def _build_preview_items(
"filter_anomaly",
}
source_records = list(parsed.records)
processed_records = preprocess_structured_records(
processed_records = preprocess_structured_records_with_lineage(
source_records,
structured_options,
)
if not processed_records and parsed.text and not source_records:
processed_records = [{"value": parsed.text}]
same_cardinality = len(processed_records) == len(source_records)
for index, record in enumerate(processed_records):
original_record = source_records[index] if same_cardinality else record
original_content = json.dumps(
original_record,
ensure_ascii=False,
separators=(",", ":"),
for processed in processed_records:
source_index = processed.source_index
record = processed.record
original_record = (
source_records[source_index]
if source_index < len(source_records)
else record
)
source_locator = (
deepcopy(parsed.record_locators[source_index])
if source_index < len(parsed.record_locators)
else None
)
original_content = structured_json_dumps(original_record)
pii_counts: dict[str, int] = {}
edited_record = record
dedup_content = (
canonical_record_json(record)
if "normalize_format" in preprocess_options
else structured_json_dumps(record)
)
if should_desensitize:
edited_record, pii_counts = desensitize_structured_record(record)
content = (
canonical_record_json(edited_record)
if "normalize_format" in preprocess_options
else json.dumps(
edited_record,
ensure_ascii=False,
separators=(",", ":"),
)
else structured_json_dumps(edited_record)
)
quality = _preview_quality(content, config)
quality["pii_replacements"] = pii_counts
if source_locator is not None:
quality["source_locator"] = source_locator
source_start = (
source_locator.get("source_start")
if source_locator is not None
else None
)
source_end = (
source_locator.get("source_end")
if source_locator is not None
else None
)
source_start_line = (
source_locator.get("start_line")
if source_locator is not None
else None
)
source_end_line = (
source_locator.get("end_line")
if source_locator is not None
else None
)
append_item(
{
"source_file_id": source["id"],
"original_content": original_content,
"edited_content": content,
"source_start": None,
"source_end": None,
"source_start_line": None,
"source_end_line": None,
"source_start": source_start,
"source_end": source_end,
"source_start_line": source_start_line,
"source_end_line": source_end_line,
"token_count": estimate_token_count(content),
"status": "modified" if content != original_content else "original",
"quality_score": quality,
}
},
dedup_content=dedup_content,
)
return items
@@ -772,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("")
@@ -850,6 +909,135 @@ def prepare_regeneration(
)
def _repeat_file_copies(
store: DataProcessStore,
storage: LocalDataProcessStorage,
source_task_id: str,
request_id: str,
) -> tuple[dict[str, dict[str, str]], list[StagedSourceObject]]:
"""为新任务创建独立的源文件引用,避免删除任一任务时互相影响。"""
target_task_id = repeat_task_id(source_task_id, request_id)
copies: dict[str, dict[str, str]] = {}
staged: list[StagedSourceObject] = []
batch_id = storage.new_batch_id()
for summary in store.list_source_files(source_task_id):
old_file_id = str(summary["id"])
source = store.get_source_file(source_task_id, old_file_id, include_content=True)
new_file_id = new_id("dpsf")
old_reference = str(source.get("storage_object_id") or "")
if old_reference.startswith("local://data-process/"):
staged_object = storage.stage_copy(
batch_id=batch_id,
source_reference=old_reference,
expected_source_task_id=source_task_id,
expected_source_file_id=old_file_id,
task_id=target_task_id,
source_file_id=new_file_id,
version=1,
name=str(source["name"]),
)
staged.append(staged_object)
new_reference = staged_object.reference
elif old_reference.startswith("db://data-process/") or not old_reference:
new_reference = f"db://data-process/{target_task_id}/{new_file_id}/v1"
else:
raise ValueError("源任务包含不受支持的文件存储引用")
copies[old_file_id] = {
"id": new_file_id,
"storage_object_id": new_reference,
}
return copies, staged
def _remove_repeated_storage_objects(
storage: LocalDataProcessStorage,
task_id: str,
staged: list[StagedSourceObject],
copies: dict[str, dict[str, str]],
) -> None:
source_file_ids = {
str(copy["storage_object_id"]): str(copy["id"])
for copy in copies.values()
}
for item in staged:
try:
storage.delete(
item.reference,
expected_task_id=task_id,
expected_source_file_id=source_file_ids[item.reference],
)
except Exception:
logger.exception(
"failed to roll back repeated data process source object task_id=%s",
task_id,
)
@router.post("/{task_id}/repeat", status_code=202)
def repeat_generation(
task_id: str,
payload: DataProcessRepeatRequest,
background_tasks: BackgroundTasks,
store: DataProcessStore = Depends(get_data_process_store),
storage: LocalDataProcessStorage = Depends(get_data_process_storage),
) -> dict[str, Any]:
"""按原任务快照创建独立任务,并立即在后台开始新一批生成。"""
with api_errors():
repeated = store.find_repeated_task(task_id, payload.request_id)
staged: list[StagedSourceObject] = []
target_task_id = repeat_task_id(task_id, payload.request_id)
if repeated is None:
copies, staged = _repeat_file_copies(
store,
storage,
task_id,
payload.request_id,
)
storage.publish(staged)
try:
repeated = store.repeat_task(
task_id,
expected_updated_at=payload.expected_updated_at,
request_id=payload.request_id,
file_copies=copies,
)
except Exception:
_remove_repeated_storage_objects(
storage,
target_task_id,
staged,
copies,
)
raise
if not repeated["created"]:
_remove_repeated_storage_objects(
storage,
target_task_id,
staged,
copies,
)
repeated_task = repeated["task"]
if repeated_task.get("status") == "pending":
try:
started = store.start_generation(target_task_id, replace_existing=True)
background_tasks.add_task(
_run_generation,
store,
target_task_id,
str(started["generation_run_id"]),
)
except ConflictError:
latest = store.get_task(target_task_id)
if latest.get("status") != "running":
raise
repeated["task"] = store.get_task(target_task_id)
repeated["progress"] = store.progress(target_task_id)
return ok(repeated, "已按原配置创建新任务并开始后台生成")
@router.delete("/{task_id}")
def delete_task(
task_id: str,
@@ -919,7 +1107,7 @@ async def upload_source_files(
f"{suffix} is not supported for {process_type} data processing",
)
parsed = parse_text_content(raw, filename=name)
if not parsed.text:
if not parsed.text.strip():
raise fail(400, f"source file is empty: {name}")
batch_size += len(raw)
if batch_size > MAX_SOURCE_BATCH_BYTES:
@@ -934,7 +1122,11 @@ async def upload_source_files(
content=raw,
)
staged.append(staged_object)
record_count = len(parsed.records) or (1 if parsed.text else 0)
record_count = (
len(parsed.records)
if process_type == "structured"
else (1 if parsed.text else 0)
)
prepared.append(
{
"id": source_file_id,
@@ -1362,15 +1554,28 @@ def _prepare_preview_items(
_value(config, "chunk_method", "chunkMethod", "layout_hybrid")
)
is_unstructured = task.get("process_type") == "unstructured"
if is_unstructured and (
needs_unstructured_raw = is_unstructured and (
chunk_method == "layout_hybrid"
or preprocess_options & {"clean_invalid", "clean_invalid_content"}
):
)
has_structured_xlsx = not is_unstructured and any(
str(source.get("file_format") or "").lower() == "xlsx"
for source in sources
)
if needs_unstructured_raw or has_structured_xlsx:
for index, source in enumerate(sources):
if (
chunk_method != "layout_hybrid"
and str(source.get("file_format") or "").lower() != "pdf"
):
source_format = str(source.get("file_format") or "").lower()
needs_structured_xlsx = not is_unstructured and source_format == "xlsx"
needs_layout_raw = is_unstructured and chunk_method == "layout_hybrid"
needs_pdf_noise = (
is_unstructured
and not needs_layout_raw
and source_format == "pdf"
and bool(
preprocess_options & {"clean_invalid", "clean_invalid_content"}
)
)
if not (needs_structured_xlsx or needs_layout_raw or needs_pdf_noise):
continue
storage_object_id = str(source.get("storage_object_id") or "")
actual_size = storage.file_size(
@@ -1379,7 +1584,7 @@ def _prepare_preview_items(
expected_source_file_id=str(source["id"]),
)
if actual_size is None:
if chunk_method == "layout_hybrid":
if needs_layout_raw:
raise InvalidStateError(
"版面结构混合切分无法读取原始文件,请重新上传后再处理"
)
@@ -1396,12 +1601,10 @@ def _prepare_preview_items(
)
)
enriched = dict(source)
if chunk_method == "layout_hybrid":
if needs_structured_xlsx or needs_layout_raw:
enriched["raw_content"] = raw
sources[index] = enriched
continue
if str(source.get("file_format") or "").lower() != "pdf":
continue
pages = extract_pdf_page_texts(raw)
extracted_text = "\n\n".join(page.text for page in pages if page.text)
if extracted_text != str(source.get("content") or ""):
@@ -1413,7 +1616,7 @@ def _prepare_preview_items(
enriched["document_noise_spans"] = detect_pdf_document_noise(pages)
sources[index] = enriched
items = _build_preview_items(task, sources)
if not items and source_file_ids is None:
if not items and source_file_ids is None and is_unstructured:
raise InvalidStateError("source files did not produce preview items")
return items
@@ -1435,6 +1638,7 @@ def _run_preview(
len(source_file_ids),
)
try:
is_unstructured = store.get_task(task_id).get("process_type") == "unstructured"
if not store.mark_preview_running(task_id, preview_run_id):
logger.info(
"data process preview skipped inactive run task_id=%s preview_run_id=%s",
@@ -1461,7 +1665,7 @@ def _run_preview(
storage,
[source_file_id],
)
if not items:
if not items and is_unstructured:
raise InvalidStateError(
f"source file did not produce preview items: {source_file_id}"
)
@@ -1657,8 +1861,13 @@ def update_preview_item(
) -> dict[str, Any]:
with api_errors():
task = store.get_task(task_id)
existing = store.get_preview_item(task_id, preview_id)
update = payload.model_dump(exclude_unset=True, mode="json")
update["quality_score"] = _preview_quality(payload.edited_content, task.get("config") or {})
quality = _preview_quality(payload.edited_content, task.get("config") or {})
source_locator = (existing.get("quality_score") or {}).get("source_locator")
if isinstance(source_locator, Mapping):
quality["source_locator"] = deepcopy(dict(source_locator))
update["quality_score"] = quality
item = store.update_preview_item(
task_id,
preview_id,
@@ -2089,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,

View File

@@ -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
@@ -91,6 +93,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)
@@ -287,8 +318,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")
@@ -307,6 +348,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())
@@ -341,6 +559,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()
@@ -404,8 +637,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")
@@ -421,11 +659,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}")
@@ -445,7 +686,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})
@@ -707,8 +953,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")
@@ -718,11 +968,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}")
@@ -734,7 +987,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})
@@ -759,8 +1017,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")
@@ -953,7 +1215,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})
@@ -993,12 +1260,16 @@ 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:
store = get_platform_store()
task = store.eval_task(task_id)
@@ -1019,9 +1290,11 @@ async def model_eval_detail(task_id: str) -> dict[str, Any]:
break
except Exception:
pass
return ok(task)
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")
@@ -1172,7 +1445,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})

View File

@@ -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"])