From 08ba3e19d3643610d461286d6f315cd0fd3c9f9e Mon Sep 17 00:00:00 2001 From: chengkai3 Date: Sun, 28 Jun 2026 18:47:18 +0800 Subject: [PATCH] =?UTF-8?q?[fix]:[FL-215][ATP=E6=A8=A1=E5=9E=8B=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8E=BB=E6=8E=89=E7=89=88=E6=9C=AC=E6=A6=82=E5=BF=B5?= =?UTF-8?q?=EF=BC=9A=E4=B8=8A=E4=BC=A0=E6=96=87=E4=BB=B6=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E7=94=A8=E4=BA=8E=E5=B1=95=E7=A4=BA]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- api/app/api/v1/atp_assets.py | 36 ++++ api/app/schemas/atp_asset.py | 13 +- api/app/services/atp_asset_service.py | 237 +++++++++++++++++++++++++- api/tests/test_atp_asset_service.py | 74 ++++++++ web/src/app/admin/atp-models/page.tsx | 15 +- web/src/types/auth.ts | 13 +- 6 files changed, 373 insertions(+), 15 deletions(-) diff --git a/api/app/api/v1/atp_assets.py b/api/app/api/v1/atp_assets.py index 78e0431..10e5134 100644 --- a/api/app/api/v1/atp_assets.py +++ b/api/app/api/v1/atp_assets.py @@ -9,6 +9,7 @@ from ...schemas.atp_asset import ( AtpAssetCreateRequest, AtpAssetDetail, AtpAssetFileListResponse, + AtpAssetFileUploadResponse, AtpAssetListResponse, AtpAssetReleaseCreateRequest, AtpAssetReleaseDetail, @@ -32,6 +33,7 @@ from ...services.atp_asset_service import ( get_release_by_id, get_run_detail, list_assets, + list_asset_files, list_release_files, list_releases, list_runs, @@ -40,6 +42,7 @@ from ...services.atp_asset_service import ( serialize_release_detail, update_asset, update_release, + upload_asset_archive, ) router = APIRouter(prefix="/atp", tags=["atp-assets"], dependencies=[Depends(require_enabled_menu_route)]) @@ -88,6 +91,30 @@ def create_atp_asset_endpoint( return AtpAssetDetail(**created.model_dump()) +@router.post("/assets/{asset_id}/files/upload", response_model=AtpAssetFileUploadResponse) +def upload_atp_asset_files_endpoint( + asset_id: str, + archive: UploadFile = File(...), + current_user: CurrentUser = Depends(require_permission("atp.manage")), + db: Session = Depends(get_db), +) -> AtpAssetFileUploadResponse: + try: + archive_content = archive.file.read() + finally: + try: + archive.file.close() + except Exception: + pass + + return upload_asset_archive( + db, + asset_id=asset_id, + archive_filename=archive.filename or "model.zip", + archive_content=archive_content, + actor_user_id=current_user.user.id, + ) + + @router.get("/assets/{asset_id}", response_model=AtpAssetDetail) def get_atp_asset_detail( asset_id: str, @@ -245,6 +272,15 @@ def get_atp_release_files( return list_release_files(db, release_id=release_id) +@router.get("/assets/{asset_id}/files", response_model=AtpAssetFileListResponse) +def get_atp_asset_files( + asset_id: str, + _: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")), + db: Session = Depends(get_db), +) -> AtpAssetFileListResponse: + return list_asset_files(db, asset_id=asset_id) + + @router.get("/releases/{release_id}/runs", response_model=AtpAssetRunListResponse) def get_atp_release_runs( release_id: str, diff --git a/api/app/schemas/atp_asset.py b/api/app/schemas/atp_asset.py index c818718..0d83507 100644 --- a/api/app/schemas/atp_asset.py +++ b/api/app/schemas/atp_asset.py @@ -26,6 +26,8 @@ class AtpAssetSummary(BaseModel): active_release_no: int | None = None active_release_id: str | None = None active_release_tag: str | None = None + storage_mount_code: str | None = None + storage_root_path: str | None = None release_count: int = 0 run_count: int = 0 last_run_status: AtpAssetRunStatus | None = None @@ -151,13 +153,22 @@ class AtpAssetFileEntry(BaseModel): class AtpAssetFileListResponse(BaseModel): - release_id: str + asset_id: str + release_id: str | None = None storage_mount_code: str storage_root_path: str items: list[AtpAssetFileEntry] total: int +class AtpAssetFileUploadResponse(BaseModel): + asset_id: str + storage_mount_code: str + storage_root_path: str + uploaded_count: int + success: bool = True + + class AtpAssetRunSummary(BaseModel): id: str asset_id: str diff --git a/api/app/services/atp_asset_service.py b/api/app/services/atp_asset_service.py index c10d51a..96f077a 100644 --- a/api/app/services/atp_asset_service.py +++ b/api/app/services/atp_asset_service.py @@ -15,6 +15,7 @@ import time import zipfile from dataclasses import dataclass from typing import Any +from uuid import uuid4 from fastapi import HTTPException, status from sqlalchemy import func, or_, select @@ -29,6 +30,7 @@ from ..schemas.atp_asset import ( AtpAssetDetail, AtpAssetFileEntry, AtpAssetFileListResponse, + AtpAssetFileUploadResponse, AtpAssetListResponse, AtpAssetReleaseCreateRequest, AtpAssetReleaseDetail, @@ -65,6 +67,7 @@ VALID_RUNNER_KIND = {"atp", "egm", "hybrid"} VALID_RUN_STATUS = {"pending", "running", "success", "failed"} LOG_MAX_CHARS = 200_000 ATP_ASSET_RELEASES_ROOT = "/atp-library" +ATP_ASSET_FILES_ROOT = "/atp-library/assets" @dataclass(slots=True) @@ -355,6 +358,14 @@ def _build_release_storage_root(asset_code: str, release_no: int, voltage_level: return normalize_virtual_path(f"{ATP_ASSET_RELEASES_ROOT}/{voltage_segment}/{tower_segment}/r{release_no}") +def _build_asset_storage_root(asset: AtpAsset) -> str: + return normalize_virtual_path(f"{ATP_ASSET_FILES_ROOT}/{asset.id}") + + +def _asset_storage_mount() -> str: + return "main" + + def _write_archive_to_storage( driver: StorageDriver, *, @@ -364,9 +375,9 @@ def _write_archive_to_storage( ) -> int: filename = (archive_filename or "").strip().lower() if filename and not filename.endswith(".zip"): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Release ZIP 包必须是 zip 格式") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="ZIP 包必须是 zip 格式") if not archive_content: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Release ZIP 包不能为空") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="ZIP 包不能为空") driver.ensure_directory(storage_root_path) ensured_directories = {normalize_virtual_path(storage_root_path)} @@ -398,13 +409,41 @@ def _write_archive_to_storage( ) extracted_count += 1 except zipfile.BadZipFile as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Release ZIP 文件损坏: {exc}") from exc + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"ZIP 文件损坏: {exc}") from exc if extracted_count <= 0: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Release ZIP 包中没有可导入文件") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="ZIP 包中没有可导入文件") return extracted_count +def _read_upload_file_bytes(file: Any) -> tuple[str, bytes, str | None]: + filename = (getattr(file, "filename", None) or "").strip() or "upload.zip" + content_type = getattr(file, "content_type", None) + try: + content = file.file.read() + finally: + try: + file.file.close() + except Exception: + pass + return filename, content, content_type + + +def _write_archive_to_asset_storage( + driver: StorageDriver, + *, + asset_storage_root: str, + archive_filename: str, + archive_content: bytes, +) -> int: + return _write_archive_to_storage( + driver, + storage_root_path=asset_storage_root, + archive_filename=archive_filename, + archive_content=archive_content, + ) + + def _resolve_runner_kind_from_tree(tree: StorageTree) -> str: detected_entry = _auto_detect_entry_file(tree) detected_egm_subdir = _auto_detect_egm_subdir(tree) @@ -584,6 +623,8 @@ def serialize_asset( active_release_no=item.active_release_no, active_release_id=active_release.id if active_release else None, active_release_tag=active_release.release_tag if active_release else None, + storage_mount_code=_asset_storage_mount(), + storage_root_path=_build_asset_storage_root(item), release_count=release_count, run_count=run_count, last_run_status=last_run_status, # type: ignore[arg-type] @@ -838,6 +879,108 @@ def create_asset(db: Session, payload: AtpAssetCreateRequest, *, actor_user_id: return serialize_asset(saved, release_count=0, run_count=0, last_run_status=None, last_run_date=None, active_release=None) +def upload_asset_archive( + db: Session, + *, + asset_id: str, + archive_filename: str, + archive_content: bytes, + actor_user_id: str, +) -> AtpAssetFileUploadResponse: + asset = get_asset_by_id(db, asset_id) + if not asset: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found") + + storage_mount_code = _asset_storage_mount() + storage_root_path = _build_asset_storage_root(asset) + mount = _resolve_mount(db, storage_mount_code) + driver = _build_driver_or_400(mount) + root_parent = _parent_virtual_path(storage_root_path) + root_name = PurePosixPath(storage_root_path).name or asset.id + staging_root_path = normalize_virtual_path(f"{root_parent}/{root_name}.__upload__{uuid4().hex}") + backup_root_path = normalize_virtual_path(f"{root_parent}/{root_name}.__backup__{uuid4().hex}") + backup_exists = False + try: + uploaded_count = _write_archive_to_asset_storage( + driver, + asset_storage_root=staging_root_path, + archive_filename=archive_filename, + archive_content=archive_content, + ) + + try: + driver.list_dir(storage_root_path) + backup_exists = True + except StoragePathNotFoundError: + backup_exists = False + + if backup_exists: + driver.move_path( + storage_root_path, + is_dir=True, + target_parent_path=root_parent, + new_name=PurePosixPath(backup_root_path).name, + ) + + try: + driver.move_path( + staging_root_path, + is_dir=True, + target_parent_path=root_parent, + new_name=root_name, + ) + except Exception: + if backup_exists: + try: + driver.move_path( + backup_root_path, + is_dir=True, + target_parent_path=root_parent, + new_name=root_name, + ) + except Exception: + pass + raise + finally: + try: + driver.delete_path(staging_root_path, is_dir=True, recursive=True) + except Exception: + pass + if backup_exists: + try: + driver.delete_path(backup_root_path, is_dir=True, recursive=True) + except Exception: + pass + + try: + tree = _walk_storage_tree(driver, storage_root_path) + except HTTPException: + tree = StorageTree(files=[], directories=[], file_paths=set(), dir_paths=set(), max_depth=0) + + asset.active_release_no = None + asset.update_user = actor_user_id + asset.update_date = utcnow() + db.commit() + + _publish_change( + "asset.files_uploaded", + { + "action": "files_uploaded", + "asset_id": asset.id, + "storage_root_path": storage_root_path, + "file_count": len(tree.files), + }, + ) + + return AtpAssetFileUploadResponse( + asset_id=asset.id, + storage_mount_code=storage_mount_code, + storage_root_path=storage_root_path, + uploaded_count=uploaded_count, + success=True, + ) + + def update_asset( db: Session, asset_id: str, @@ -890,6 +1033,12 @@ def delete_asset(db: Session, asset_id: str) -> bool: return False # Delete physical files for all releases before deleting database records + try: + asset_mount = _resolve_mount(db, _asset_storage_mount()) + asset_driver = _build_driver_or_400(asset_mount) + asset_driver.delete_path(_build_asset_storage_root(item), is_dir=True, recursive=True) + except Exception: + pass for release in item.releases: try: mount = _resolve_mount(db, release.storage_mount_code) @@ -1296,6 +1445,7 @@ def list_release_files(db: Session, *, release_id: str) -> AtpAssetFileListRespo ] merged_items = sorted([*dir_items, *items], key=lambda entry: (not entry.is_dir, entry.relative_path)) return AtpAssetFileListResponse( + asset_id=release.asset_id, release_id=release.id, storage_mount_code=release.storage_mount_code, storage_root_path=release.storage_root_path, @@ -1304,6 +1454,70 @@ def list_release_files(db: Session, *, release_id: str) -> AtpAssetFileListRespo ) +def list_asset_files(db: Session, *, asset_id: str) -> AtpAssetFileListResponse: + asset = get_asset_by_id(db, asset_id) + if not asset: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found") + + storage_mount_code = _asset_storage_mount() + storage_root_path = _build_asset_storage_root(asset) + mount = _resolve_mount(db, storage_mount_code) + driver = _build_driver_or_400(mount) + try: + tree = _walk_storage_tree(driver, storage_root_path) + except HTTPException as exc: + if exc.status_code == status.HTTP_404_NOT_FOUND: + fallback_release = db.execute( + select(AtpAssetRelease) + .options(joinedload(AtpAssetRelease.asset)) + .where(AtpAssetRelease.asset_id == asset.id) + .order_by(AtpAssetRelease.is_active.desc(), AtpAssetRelease.release_no.desc(), AtpAssetRelease.id.desc()) + ).scalars().first() + if fallback_release: + return list_release_files(db, release_id=fallback_release.id) + return AtpAssetFileListResponse( + asset_id=asset.id, + release_id=None, + storage_mount_code=storage_mount_code, + storage_root_path=storage_root_path, + items=[], + total=0, + ) + raise + + items = [ + AtpAssetFileEntry( + relative_path=_relative_from_root(storage_root_path, item.path), + name=item.name, + is_dir=False, + size=item.size, + mime_type=item.mime_type, + file_role=_infer_asset_file_role(asset, _relative_from_root(storage_root_path, item.path), False), + ) + for item in tree.files + ] + dir_items = [ + AtpAssetFileEntry( + relative_path=path, + name=path.split("/")[-1], + is_dir=True, + size=0, + mime_type=None, + file_role=_infer_asset_file_role(asset, path, True), + ) + for path in tree.directories + ] + merged_items = sorted([*dir_items, *items], key=lambda entry: (not entry.is_dir, entry.relative_path)) + return AtpAssetFileListResponse( + asset_id=asset.id, + release_id=None, + storage_mount_code=storage_mount_code, + storage_root_path=storage_root_path, + items=merged_items, + total=len(merged_items), + ) + + def _infer_file_role(release: AtpAssetRelease, relative_path: str, is_dir: bool) -> str | None: if is_dir: if release.egm_subdir and relative_path == release.egm_subdir: @@ -1333,6 +1547,21 @@ def _infer_file_role(release: AtpAssetRelease, relative_path: str, is_dir: bool) return "other" +def _infer_asset_file_role(asset: AtpAsset, relative_path: str, is_dir: bool) -> str | None: + if is_dir: + return None + lower = relative_path.lower() + if lower.endswith(".atp"): + return "atp" + if lower.endswith(".py"): + return "script" + if lower.endswith(("tpbig.exe", "rjtzl.exe")): + return "executable" + if lower.endswith((".doc", ".docx", ".txt", ".md", ".pdf")): + return "doc" + return "other" + + def list_runs( db: Session, *, diff --git a/api/tests/test_atp_asset_service.py b/api/tests/test_atp_asset_service.py index db54c28..ce0ee13 100644 --- a/api/tests/test_atp_asset_service.py +++ b/api/tests/test_atp_asset_service.py @@ -235,6 +235,80 @@ def test_create_release_from_archive_detects_storage_path_conflict(tmp_path) -> session.close() +def test_upload_asset_archive_and_list_asset_files(tmp_path) -> None: + testing_session = _build_sessionmaker() + session: Session = testing_session() + try: + _seed_vfs_mount(session, root_dir=tmp_path / "vfs") + asset = atp_asset_service.create_asset( + session, + AtpAssetCreateRequest( + code="ATP-ASSET-DIRECT-UPLOAD", + name="直接上传模型", + voltage_level="220", + tower_type="sihuita", + scene_type="raoji3", + ), + actor_user_id="tester", + ) + assert asset is not None + + uploaded = atp_asset_service.upload_asset_archive( + session, + asset_id=asset.id, + archive_filename="model.zip", + archive_content=_build_zip({"work.atp": b"ATP INPUT", "docs/readme.txt": b"docs"}), + actor_user_id="tester", + ) + + assert uploaded.success is True + assert uploaded.storage_root_path == f"/atp-library/assets/{asset.id}" + + files = atp_asset_service.list_asset_files(session, asset_id=asset.id) + assert files.asset_id == asset.id + assert files.release_id is None + assert files.total == 3 + assert [item.relative_path for item in files.items] == ["docs", "docs/readme.txt", "work.atp"] + finally: + session.close() + + +def test_list_asset_files_falls_back_to_latest_release(tmp_path) -> None: + testing_session = _build_sessionmaker() + session: Session = testing_session() + try: + _seed_vfs_mount(session, root_dir=tmp_path / "vfs") + asset = atp_asset_service.create_asset( + session, + AtpAssetCreateRequest( + code="ATP-ASSET-LEGACY-FALLBACK", + name="旧版本模型", + voltage_level="220", + tower_type="sihuita", + scene_type="raoji3", + ), + actor_user_id="tester", + ) + assert asset is not None + + release = atp_asset_service.create_release_from_archive( + session, + asset_id=asset.id, + release_tag="v1", + archive_filename="release.zip", + archive_content=_build_zip({"work.atp": b"ATP INPUT", "docs/readme.txt": b"docs"}), + actor_user_id="tester", + ) + + files = atp_asset_service.list_asset_files(session, asset_id=asset.id) + assert files.asset_id == asset.id + assert files.release_id == release.id + assert files.total == 3 + assert [item.relative_path for item in files.items] == ["docs", "docs/readme.txt", "work.atp"] + finally: + session.close() + + def test_list_assets_paginates_after_filtering(tmp_path) -> None: testing_session = _build_sessionmaker() session: Session = testing_session() diff --git a/web/src/app/admin/atp-models/page.tsx b/web/src/app/admin/atp-models/page.tsx index ebe82ed..4dd5f42 100644 --- a/web/src/app/admin/atp-models/page.tsx +++ b/web/src/app/admin/atp-models/page.tsx @@ -19,7 +19,6 @@ import { Breadcrumb, Typography, Upload, - message, type CardProps, } from "antd"; import { UploadOutlined, FolderOutlined, FileOutlined, TableOutlined, FolderOpenOutlined, HomeOutlined } from "@ant-design/icons"; @@ -31,7 +30,7 @@ import { useAuth } from "@/components/auth-provider"; import { useMobileDetection } from "@/hooks/use-mobile-detection"; import { useToastFeedback } from "@/hooks/use-toast-feedback"; import { readApiError } from "@/lib/api"; -import type { AtpAssetListResponse, AtpAssetSummary, AtpAssetFileEntry } from "@/types/auth"; +import type { AtpAssetListResponse, AtpAssetSummary } from "@/types/auth"; const AntCard = Card as unknown as ComponentType>; @@ -229,20 +228,18 @@ export default function AtpModelsPage() { const createdAsset = await response.json(); if (values.files.length > 0) { + const formData = new FormData(); const JSZip = (await import("jszip")).default; const zip = new JSZip(); - for (const file of values.files) { const path = (file as any).webkitRelativePath || file.name; zip.file(path, file); } - const zipBlob = await zip.generateAsync({ type: "blob" }); - const formData = new FormData(); formData.append("archive", zipBlob, "model.zip"); const uploadResponse = await fetchWithAuth( - `/api/v1/atp/assets/${createdAsset.id}/releases/upload`, + `/api/v1/atp/assets/${createdAsset.id}/files/upload`, { method: "POST", body: formData, @@ -536,10 +533,10 @@ export default function AtpModelsPage() { const filesQueries = useQueries({ queries: assetsInCurrentPath.map((asset) => ({ - queryKey: ["atp-asset-files", asset.id, asset.active_release_id], - enabled: Boolean(user && canRead && asset.active_release_id && fileViewPath.length >= 4), + queryKey: ["atp-asset-files", asset.id], + enabled: Boolean(user && canRead && fileViewPath.length >= 4), queryFn: async () => { - const response = await fetchWithAuth(`/api/v1/atp/assets/${asset.id}/releases/${asset.active_release_id}/files`); + const response = await fetchWithAuth(`/api/v1/atp/assets/${asset.id}/files`); if (!response.ok) { return { assetId: asset.id, items: [] }; } diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts index 080a21f..9ec01ca 100644 --- a/web/src/types/auth.ts +++ b/web/src/types/auth.ts @@ -923,6 +923,8 @@ export type AtpAssetSummary = { active_release_no: number | null; active_release_id: string | null; active_release_tag: string | null; + storage_mount_code: string | null; + storage_root_path: string | null; release_count: number; run_count: number; last_run_status: AtpAssetRunStatus | null; @@ -987,13 +989,22 @@ export type AtpAssetFileEntry = { }; export type AtpAssetFileListResponse = { - release_id: string; + asset_id: string; + release_id: string | null; storage_mount_code: string; storage_root_path: string; items: AtpAssetFileEntry[]; total: number; }; +export type AtpAssetFileUploadResponse = { + asset_id: string; + storage_mount_code: string; + storage_root_path: string; + uploaded_count: number; + success: boolean; +}; + export type AtpAssetRunSummary = { id: string; asset_id: string;