[fix/feat]:[FL-82][ATP模型管理改造]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-11 23:45:57 +08:00
parent fac37ddb8d
commit 4328d9fd34
16 changed files with 640 additions and 2480 deletions
+29 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
from sqlalchemy.orm import Session
from ...core.database import get_db
@@ -13,6 +13,7 @@ from ...schemas.atp_asset import (
AtpAssetReleaseCreateRequest,
AtpAssetReleaseDetail,
AtpAssetReleaseListResponse,
AtpAssetReleaseUpdateRequest,
AtpAssetRunDetail,
AtpAssetRunListResponse,
AtpAssetRunRequest,
@@ -23,6 +24,7 @@ from ...services.atp_asset_service import (
activate_release,
create_asset,
create_release,
create_release_from_archive,
delete_asset,
get_asset_by_id,
get_release_by_id,
@@ -150,6 +152,32 @@ def create_atp_asset_release_endpoint(
return create_release(db, asset_id=asset_id, payload=payload, actor_user_id=current_user.user.id)
@router.post("/assets/{asset_id}/releases/upload", response_model=AtpAssetReleaseDetail)
def upload_atp_asset_release_endpoint(
asset_id: str,
release_tag: str | None = Form(default=None),
archive: UploadFile = File(...),
current_user: CurrentUser = Depends(require_permission("atp.manage")),
db: Session = Depends(get_db),
) -> AtpAssetReleaseDetail:
try:
archive_content = archive.file.read()
finally:
try:
archive.file.close()
except Exception:
pass
return create_release_from_archive(
db,
asset_id=asset_id,
release_tag=release_tag,
archive_filename=archive.filename or "release.zip",
archive_content=archive_content,
actor_user_id=current_user.user.id,
)
@router.get("/releases", response_model=AtpAssetReleaseListResponse)
def get_atp_release_list(
active_only: bool = Query(default=False),
-3
View File
@@ -21,7 +21,6 @@ class AtpAssetSummary(BaseModel):
voltage_level: str | None = None
tower_type: str | None = None
scene_type: str | None = None
tags_json: list[str] = Field(default_factory=list)
latest_release_no: int = 0
active_release_no: int | None = None
active_release_id: str | None = None
@@ -53,7 +52,6 @@ class AtpAssetCreateRequest(BaseModel):
voltage_level: str | None = Field(default=None, max_length=16)
tower_type: str | None = Field(default=None, max_length=64)
scene_type: str | None = Field(default=None, max_length=32)
tags_json: list[str] = Field(default_factory=list, max_length=128)
class AtpAssetUpdateRequest(BaseModel):
@@ -63,7 +61,6 @@ class AtpAssetUpdateRequest(BaseModel):
voltage_level: str | None = Field(default=None, max_length=16)
tower_type: str | None = Field(default=None, max_length=64)
scene_type: str | None = Field(default=None, max_length=32)
tags_json: list[str] | None = Field(default=None, max_length=128)
class AtpAssetReleaseSummary(BaseModel):
+156 -17
View File
@@ -2,14 +2,17 @@ from __future__ import annotations
import asyncio
import hashlib
import io
import json
import mimetypes
import os
from pathlib import Path
from pathlib import Path, PurePosixPath
import re
import shutil
import subprocess
import sys
import time
import zipfile
from dataclasses import dataclass
from typing import Any
@@ -58,6 +61,7 @@ VALID_RELEASE_STATUS = {"draft", "released", "archived"}
VALID_RUNNER_KIND = {"atp", "egm", "hybrid"}
VALID_RUN_STATUS = {"pending", "running", "success", "failed"}
LOG_MAX_CHARS = 200_000
ATP_ASSET_RELEASES_ROOT = "/atp-library/releases"
@dataclass(slots=True)
@@ -101,18 +105,6 @@ def _normalize_optional_str(value: str | None) -> str | None:
return normalized or None
def _normalize_tags(values: list[str] | None) -> list[str]:
if not values:
return []
dedup: dict[str, None] = {}
for candidate in values:
normalized = candidate.strip()
if not normalized:
continue
dedup[normalized] = None
return list(dedup.keys())[:128]
def _normalize_relative_path(value: str | None) -> str | None:
normalized = _normalize_optional_str(value)
if normalized is None:
@@ -246,6 +238,96 @@ def _walk_storage_tree(driver: StorageDriver, root_path: str) -> StorageTree:
)
def _parent_virtual_path(path: str) -> str:
normalized = normalize_virtual_path(path)
if normalized == "/":
return "/"
parent = normalized.rsplit("/", 1)[0]
return parent if parent else "/"
def _normalize_archive_member_path(value: str) -> str | None:
normalized = value.replace("\\", "/").strip()
if not normalized:
return None
parts: list[str] = []
for part in PurePosixPath(normalized).parts:
if part in {"", ".", "/"}:
continue
if part == "..":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Archive entry escapes target path: {value}")
parts.append(part)
return "/".join(parts) or None
def _sanitize_storage_segment(value: str, *, fallback: str) -> str:
normalized = _normalize_optional_str(value) or fallback
normalized = re.sub(r"[^A-Za-z0-9._-]+", "-", normalized).strip(".-")
return normalized or fallback
def _build_release_storage_root(asset_code: str, release_no: int) -> str:
asset_segment = _sanitize_storage_segment(asset_code, fallback="asset")
return normalize_virtual_path(f"{ATP_ASSET_RELEASES_ROOT}/{asset_segment}/r{release_no}")
def _write_archive_to_storage(
driver: StorageDriver,
*,
storage_root_path: str,
archive_filename: str,
archive_content: bytes,
) -> 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 格式")
if not archive_content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Release ZIP 包不能为空")
driver.ensure_directory(storage_root_path)
extracted_count = 0
try:
with zipfile.ZipFile(io.BytesIO(archive_content)) as archive:
for member in archive.infolist():
if member.is_dir():
continue
relative_path = _normalize_archive_member_path(member.filename)
if relative_path is None:
continue
target_path = normalize_virtual_path(f"{storage_root_path.rstrip('/')}/{relative_path}")
driver.ensure_directory(_parent_virtual_path(target_path))
try:
content = archive.read(member)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"读取 ZIP 条目失败: {member.filename}: {exc}",
) from exc
driver.write_file(
target_path,
content=content,
content_type=mimetypes.guess_type(relative_path)[0],
)
extracted_count += 1
except zipfile.BadZipFile as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Release ZIP 文件损坏: {exc}") from exc
if extracted_count <= 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Release ZIP 包中没有可导入文件")
return extracted_count
def _resolve_runner_kind_from_tree(tree: StorageTree) -> str:
detected_entry = _auto_detect_entry_file(tree)
detected_egm_subdir = _auto_detect_egm_subdir(tree)
if detected_entry and detected_egm_subdir:
return "hybrid"
if detected_egm_subdir:
return "egm"
return "atp"
def _auto_detect_entry_file(tree: StorageTree) -> str | None:
preferred = "work.atp"
if preferred in tree.file_paths:
@@ -410,7 +492,6 @@ def serialize_asset(
voltage_level=item.voltage_level,
tower_type=item.tower_type,
scene_type=item.scene_type,
tags_json=item.tags_json or [],
latest_release_no=item.latest_release_no,
active_release_no=item.active_release_no,
active_release_id=active_release.id if active_release else None,
@@ -644,7 +725,6 @@ def create_asset(db: Session, payload: AtpAssetCreateRequest, *, actor_user_id:
voltage_level=_normalize_optional_str(payload.voltage_level),
tower_type=_normalize_optional_str(payload.tower_type),
scene_type=_normalize_optional_str(payload.scene_type),
tags_json=_normalize_tags(payload.tags_json),
latest_release_no=0,
active_release_no=None,
create_user=actor_user_id,
@@ -687,8 +767,6 @@ def update_asset(
item.tower_type = _normalize_optional_str(update_data["tower_type"])
if "scene_type" in update_data:
item.scene_type = _normalize_optional_str(update_data["scene_type"])
if "tags_json" in update_data:
item.tags_json = _normalize_tags(update_data["tags_json"])
item.update_user = actor_user_id
item.update_date = utcnow()
@@ -766,6 +844,18 @@ def get_release_by_id(db: Session, release_id: str) -> AtpAssetRelease | None:
).scalar_one_or_none()
def _require_asset_dimensions(asset: AtpAsset) -> tuple[str, str, str]:
voltage_level = _normalize_optional_str(asset.voltage_level)
tower_type = _normalize_optional_str(asset.tower_type)
scene_type = _normalize_optional_str(asset.scene_type)
if not voltage_level or not tower_type or not scene_type:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="请先为模型补齐电压等级、塔型和场景后再创建 Release",
)
return voltage_level, tower_type, scene_type
def create_release(
db: Session,
*,
@@ -842,6 +932,55 @@ def create_release(
return serialize_release_detail(saved)
def create_release_from_archive(
db: Session,
*,
asset_id: str,
release_tag: str | None,
archive_filename: str,
archive_content: bytes,
actor_user_id: str,
) -> AtpAssetReleaseDetail:
asset = get_asset_by_id(db, asset_id)
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
voltage_level, tower_type, scene_type = _require_asset_dimensions(asset)
next_release_no = int(
db.scalar(select(func.max(AtpAssetRelease.release_no)).where(AtpAssetRelease.asset_id == asset_id)) or 0
) + 1
storage_root_path = _build_release_storage_root(asset.code, next_release_no)
mount = _resolve_mount(db, "main")
driver = _build_driver_or_400(mount)
_write_archive_to_storage(
driver,
storage_root_path=storage_root_path,
archive_filename=archive_filename,
archive_content=archive_content,
)
try:
tree = _walk_storage_tree(driver, storage_root_path)
payload = AtpAssetReleaseCreateRequest(
release_tag=_normalize_optional_str(release_tag),
status="released",
voltage_level=voltage_level,
tower_type=tower_type,
scene_type=scene_type,
runner_kind=_resolve_runner_kind_from_tree(tree), # type: ignore[arg-type]
storage_mount_code="main",
storage_root_path=storage_root_path,
)
return create_release(db, asset_id=asset_id, payload=payload, actor_user_id=actor_user_id)
except Exception:
try:
driver.delete_path(storage_root_path, is_dir=True, recursive=True)
except Exception:
pass
raise
def update_release(
db: Session,
*,
+83
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
import io
from pathlib import Path
import zipfile
from fastapi import HTTPException
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
@@ -55,6 +59,14 @@ def _seed_vfs_mount(session: Session, *, root_dir: Path) -> None:
session.commit()
def _build_zip(entries: dict[str, bytes]) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
for path, content in entries.items():
archive.writestr(path, content)
return buffer.getvalue()
def test_create_release_auto_detects_entry_file_and_manifest(tmp_path) -> None:
testing_session = _build_sessionmaker()
session: Session = testing_session()
@@ -94,6 +106,77 @@ def test_create_release_auto_detects_entry_file_and_manifest(tmp_path) -> None:
session.close()
def test_create_release_from_archive_extracts_zip_and_inherits_asset_dimensions(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-UPLOAD",
name="ZIP 导入模型",
voltage_level="220",
tower_type="sihuita",
scene_type="raoji3",
),
actor_user_id="tester",
)
assert asset is not None
created = atp_asset_service.create_release_from_archive(
session,
asset_id=asset.id,
release_tag="首版",
archive_filename="release.zip",
archive_content=_build_zip(
{
"work.atp": b"ATP INPUT",
"EGM/config.txt": b"egm",
}
),
actor_user_id="tester",
)
assert created.release_no == 1
assert created.release_tag == "首版"
assert created.storage_root_path == "/atp-library/releases/ATP-ASSET-UPLOAD/r1"
assert created.entry_file == "work.atp"
assert created.runner_kind == "hybrid"
assert created.voltage_level == "220"
assert created.tower_type == "sihuita"
assert created.scene_type == "raoji3"
assert (tmp_path / "vfs" / "atp-library" / "releases" / "ATP-ASSET-UPLOAD" / "r1" / "work.atp").exists()
assert (tmp_path / "vfs" / "atp-library" / "releases" / "ATP-ASSET-UPLOAD" / "r1" / "EGM" / "config.txt").exists()
finally:
session.close()
def test_create_release_from_archive_requires_asset_dimensions(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-MISSING", name="缺维度模型"),
actor_user_id="tester",
)
assert asset is not None
with pytest.raises(HTTPException, match="电压等级、塔型和场景"):
atp_asset_service.create_release_from_archive(
session,
asset_id=asset.id,
release_tag="r1",
archive_filename="release.zip",
archive_content=_build_zip({"work.atp": b"ATP INPUT"}),
actor_user_id="tester",
)
finally:
session.close()
def test_run_release_dry_run_materializes_directory(tmp_path, monkeypatch) -> None:
testing_session = _build_sessionmaker()
monkeypatch.setattr(core_database, "SessionLocal", testing_session)