[feat]:[FL-194][删除AtpModel系统,保留AtpAsset]

- 删除AtpModel、AtpModelVersion、AtpSimulationRun模型及相关代码
- 删除/api/v1/atp/models API端点
- 将engine status功能迁移到atp_asset_service
- 更新路由和模型注册,移除atp_model引用
- 删除相关测试文件
- 更新fl_analysis_service使用atp_asset_service的_truncate_output

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-27 10:13:51 +08:00
parent 16bdd76eaf
commit b8f61a72aa
14 changed files with 99 additions and 2276 deletions
-2
View File
@@ -4,7 +4,6 @@ from .v1.admin import router as admin_router
from .v1.admin_files import router as admin_files_router
from .v1.ai_chat import router as ai_chat_router
from .v1.atp_assets import router as atp_assets_router
from .v1.atp_models import router as atp_models_router
from .v1.auth import router as auth_router
from .v1.documents import router as documents_router
from .v1.elevation import router as elevation_router
@@ -30,7 +29,6 @@ v1_router.include_router(admin_router)
v1_router.include_router(admin_files_router)
v1_router.include_router(ai_chat_router)
v1_router.include_router(atp_assets_router)
v1_router.include_router(atp_models_router)
v1_router.include_router(documents_router)
v1_router.include_router(task_monitor_router)
v1_router.include_router(scheduled_tasks_router)
+2 -2
View File
@@ -19,8 +19,8 @@ from ...schemas.atp_asset import (
AtpAssetRunListResponse,
AtpAssetRunRequest,
AtpAssetUpdateRequest,
AtpEngineStatusResponse,
)
from ...schemas.atp_model import AtpEngineStatusResponse
from ...services.atp_asset_service import (
activate_release,
create_asset,
@@ -28,6 +28,7 @@ from ...services.atp_asset_service import (
create_release_from_archive,
delete_asset,
get_asset_by_id,
get_engine_status,
get_release_by_id,
get_run_detail,
list_assets,
@@ -40,7 +41,6 @@ from ...services.atp_asset_service import (
update_asset,
update_release,
)
from ...services.atp_model_service import get_engine_status
router = APIRouter(prefix="/atp", tags=["atp-assets"], dependencies=[Depends(require_enabled_menu_route)])
-222
View File
@@ -1,222 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
from ...schemas.atp_model import (
AtpEngineStatusResponse,
AtpModelCreateRequest,
AtpModelListResponse,
AtpModelSummary,
AtpModelUpdateRequest,
AtpModelVersionCreateRequest,
AtpModelVersionDetail,
AtpModelVersionListResponse,
AtpModelVersionUpdateRequest,
AtpSimulationRunDetail,
AtpSimulationRunListResponse,
AtpSimulationRunRequest,
)
from ...services.atp_model_service import (
activate_model_version,
create_model,
create_model_version,
delete_model,
get_engine_status,
get_model_by_id,
get_model_run_detail,
get_model_version_by_id,
list_model_runs,
list_model_versions,
list_models,
run_model_version,
serialize_model,
serialize_version_detail,
update_model,
update_model_version,
)
router = APIRouter(prefix="/atp/models", tags=["atp-models"], dependencies=[Depends(require_enabled_menu_route)])
@router.get("/engine/status", response_model=AtpEngineStatusResponse)
def get_atp_engine_status_endpoint(
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
) -> AtpEngineStatusResponse:
return get_engine_status()
@router.get("", response_model=AtpModelListResponse)
def get_atp_model_list(
keyword: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
db: Session = Depends(get_db),
) -> AtpModelListResponse:
return list_models(db, keyword=keyword, status_filter=status_filter, limit=limit, offset=offset)
@router.post("", response_model=AtpModelSummary)
def create_atp_model_endpoint(
payload: AtpModelCreateRequest,
current_user: CurrentUser = Depends(require_permission("atp.manage")),
db: Session = Depends(get_db),
) -> AtpModelSummary:
created = create_model(db, payload, actor_user_id=current_user.user.id)
if not created:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Model code already exists")
return created
@router.get("/{model_id}", response_model=AtpModelSummary)
def get_atp_model_detail(
model_id: str,
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
db: Session = Depends(get_db),
) -> AtpModelSummary:
item = get_model_by_id(db, model_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
version_count = int(len(item.versions))
run_count = int(len(item.runs))
last_run = item.runs[0] if item.runs else None
return serialize_model(
item,
version_count=version_count,
run_count=run_count,
last_run_status=last_run.status if last_run else None,
last_run_date=last_run.create_date if last_run else None,
)
@router.patch("/{model_id}", response_model=AtpModelSummary)
def update_atp_model_endpoint(
model_id: str,
payload: AtpModelUpdateRequest,
current_user: CurrentUser = Depends(require_permission("atp.manage")),
db: Session = Depends(get_db),
) -> AtpModelSummary:
updated = update_model(db, model_id, payload, actor_user_id=current_user.user.id)
if not updated:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
return updated
@router.delete("/{model_id}")
def delete_atp_model_endpoint(
model_id: str,
_: CurrentUser = Depends(require_permission("atp.manage")),
db: Session = Depends(get_db),
) -> dict[str, bool]:
deleted = delete_model(db, model_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
return {"success": True}
@router.get("/{model_id}/versions", response_model=AtpModelVersionListResponse)
def get_atp_model_versions(
model_id: str,
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
db: Session = Depends(get_db),
) -> AtpModelVersionListResponse:
if not get_model_by_id(db, model_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
return list_model_versions(db, model_id=model_id, limit=limit, offset=offset)
@router.post("/{model_id}/versions", response_model=AtpModelVersionDetail)
def create_atp_model_version_endpoint(
model_id: str,
payload: AtpModelVersionCreateRequest,
current_user: CurrentUser = Depends(require_permission("atp.manage")),
db: Session = Depends(get_db),
) -> AtpModelVersionDetail:
return create_model_version(db, model_id=model_id, payload=payload, actor_user_id=current_user.user.id)
@router.get("/{model_id}/versions/{version_id}", response_model=AtpModelVersionDetail)
def get_atp_model_version_detail(
model_id: str,
version_id: str,
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
db: Session = Depends(get_db),
) -> AtpModelVersionDetail:
item = get_model_version_by_id(db, model_id=model_id, version_id=version_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found")
return serialize_version_detail(item)
@router.patch("/{model_id}/versions/{version_id}", response_model=AtpModelVersionDetail)
def update_atp_model_version_endpoint(
model_id: str,
version_id: str,
payload: AtpModelVersionUpdateRequest,
current_user: CurrentUser = Depends(require_permission("atp.manage")),
db: Session = Depends(get_db),
) -> AtpModelVersionDetail:
return update_model_version(
db,
model_id=model_id,
version_id=version_id,
payload=payload,
actor_user_id=current_user.user.id,
)
@router.post("/{model_id}/versions/{version_id}/activate", response_model=AtpModelSummary)
def activate_atp_model_version_endpoint(
model_id: str,
version_id: str,
current_user: CurrentUser = Depends(require_permission("atp.manage")),
db: Session = Depends(get_db),
) -> AtpModelSummary:
return activate_model_version(
db,
model_id=model_id,
version_id=version_id,
actor_user_id=current_user.user.id,
)
@router.get("/{model_id}/runs", response_model=AtpSimulationRunListResponse)
def get_atp_model_runs(
model_id: str,
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
db: Session = Depends(get_db),
) -> AtpSimulationRunListResponse:
if not get_model_by_id(db, model_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
return list_model_runs(db, model_id=model_id, limit=limit, offset=offset)
@router.post("/{model_id}/runs", response_model=AtpSimulationRunDetail)
def run_atp_model_endpoint(
model_id: str,
payload: AtpSimulationRunRequest,
current_user: CurrentUser = Depends(require_any_permission("atp.run", "atp.manage")),
db: Session = Depends(get_db),
) -> AtpSimulationRunDetail:
return run_model_version(db, model_id=model_id, payload=payload, actor_user_id=current_user.user.id)
@router.get("/{model_id}/runs/{run_id}", response_model=AtpSimulationRunDetail)
def get_atp_model_run_detail(
model_id: str,
run_id: str,
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
db: Session = Depends(get_db),
) -> AtpSimulationRunDetail:
if not get_model_by_id(db, model_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
return get_model_run_detail(db, model_id=model_id, run_id=run_id)
+1 -2
View File
@@ -4,12 +4,11 @@ Import all model modules during package initialization so SQLAlchemy can
resolve string-based relationships regardless of route/service import order.
"""
from . import ai_chat, atp_asset, atp_model, audit_log, auth_session, document, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry
from . import ai_chat, atp_asset, audit_log, auth_session, document, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry
__all__ = [
"ai_chat",
"atp_asset",
"atp_model",
"audit_log",
"auth_session",
"document",
-152
View File
@@ -1,152 +0,0 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import JSON, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..core.database import Base
from .base import utcnow
class AtpModel(Base):
__tablename__ = "atp_model"
__table_args__ = (
UniqueConstraint("code", name="uq_atp_model_code"),
Index("idx_atp_model_status", "status"),
Index("idx_atp_model_source", "source_type"),
)
id: Mapped[str] = mapped_column(
String(32),
primary_key=True,
default=lambda: uuid4().hex,
)
code: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
source_type: Mapped[str] = mapped_column(String(32), default="atpdraw", index=True)
description: Mapped[str] = mapped_column(Text(), default="")
status: Mapped[str] = mapped_column(String(20), default="enabled", index=True)
tags_json: Mapped[list[str]] = mapped_column(JSON, default=list)
latest_version_no: Mapped[int] = mapped_column(Integer, default=0)
active_version_no: Mapped[int | None] = mapped_column(Integer)
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
update_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
onupdate=utcnow,
)
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
versions: Mapped[list[AtpModelVersion]] = relationship(
"AtpModelVersion",
back_populates="model",
lazy="selectin",
cascade="all, delete-orphan",
order_by="AtpModelVersion.version_no.desc()",
)
runs: Mapped[list[AtpSimulationRun]] = relationship(
"AtpSimulationRun",
back_populates="model",
lazy="selectin",
cascade="all, delete-orphan",
order_by="AtpSimulationRun.create_date.desc()",
)
class AtpModelVersion(Base):
__tablename__ = "atp_model_version"
__table_args__ = (
UniqueConstraint("model_id", "version_no", name="uq_atp_model_version_model_no"),
Index("idx_atp_model_version_status", "status"),
Index("idx_atp_model_version_model_status", "model_id", "status"),
Index("idx_atp_model_version_content_hash", "content_hash"),
)
id: Mapped[str] = mapped_column(
String(32),
primary_key=True,
default=lambda: uuid4().hex,
)
model_id: Mapped[str] = mapped_column(
String(32),
ForeignKey("atp_model.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
version_no: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
version_tag: Mapped[str | None] = mapped_column(String(64), index=True)
status: Mapped[str] = mapped_column(String(20), default="draft", index=True)
entry_file: Mapped[str | None] = mapped_column(String(255))
change_note: Mapped[str] = mapped_column(Text(), default="")
artifact_manifest_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
graph_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
atp_text: Mapped[str] = mapped_column(Text(), default="")
content_hash: Mapped[str] = mapped_column(String(64), index=True)
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
update_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
onupdate=utcnow,
)
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
model: Mapped[AtpModel] = relationship("AtpModel", back_populates="versions", lazy="selectin")
runs: Mapped[list[AtpSimulationRun]] = relationship(
"AtpSimulationRun",
back_populates="version",
lazy="selectin",
order_by="AtpSimulationRun.create_date.desc()",
)
class AtpSimulationRun(Base):
__tablename__ = "atp_simulation_run"
__table_args__ = (
Index("idx_atp_simulation_run_status", "status"),
Index("idx_atp_simulation_run_model", "model_id", "create_date"),
)
id: Mapped[str] = mapped_column(
String(32),
primary_key=True,
default=lambda: uuid4().hex,
)
model_id: Mapped[str] = mapped_column(
String(32),
ForeignKey("atp_model.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
version_id: Mapped[str | None] = mapped_column(
String(32),
ForeignKey("atp_model_version.id", ondelete="SET NULL"),
index=True,
)
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
engine_mode: Mapped[str] = mapped_column(String(20), default="wine", index=True)
task_id: Mapped[str | None] = mapped_column(String(128), index=True)
engine_command: Mapped[str | None] = mapped_column(String(1000))
working_dir: Mapped[str | None] = mapped_column(String(1000))
timeout_seconds: Mapped[int] = mapped_column(Integer, default=600)
exit_code: Mapped[int | None] = mapped_column(Integer)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
duration_ms: Mapped[int | None] = mapped_column(Integer)
stdout_text: Mapped[str | None] = mapped_column(Text())
stderr_text: Mapped[str | None] = mapped_column(Text())
error_message: Mapped[str | None] = mapped_column(Text())
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
update_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
onupdate=utcnow,
)
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
model: Mapped[AtpModel] = relationship("AtpModel", back_populates="runs", lazy="selectin")
version: Mapped[AtpModelVersion | None] = relationship("AtpModelVersion", back_populates="runs", lazy="selectin")
+13
View File
@@ -209,3 +209,16 @@ class AtpAssetRunRequest(BaseModel):
class AtpAssetReleaseUploadResponse(BaseModel):
task_id: str
status: str
class AtpEngineStatusResponse(BaseModel):
mode: AtpAssetEngineMode
available: bool
executable_path: str
resolved_executable: str | None = None
storage_root: str
workdir: str
default_timeout_seconds: int
max_timeout_seconds: int
checks: dict[str, dict[str, Any]] = Field(default_factory=dict)
error: str | None = None
-155
View File
@@ -1,155 +0,0 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
AtpModelStatus = Literal["enabled", "disabled"]
AtpModelSourceType = Literal["atpdraw", "atp", "manual"]
AtpModelVersionStatus = Literal["draft", "released", "archived"]
AtpSimulationRunStatus = Literal["pending", "running", "success", "failed"]
AtpEngineMode = Literal["wine", "native"]
class AtpModelSummary(BaseModel):
id: str
code: str
name: str
source_type: AtpModelSourceType
description: str
status: AtpModelStatus
tags_json: list[str] = Field(default_factory=list)
latest_version_no: int = 0
active_version_no: int | None = None
version_count: int = 0
run_count: int = 0
last_run_status: AtpSimulationRunStatus | None = None
last_run_date: datetime | None = None
create_date: datetime
create_user: str | None = None
update_date: datetime
update_user: str | None = None
class AtpModelListResponse(BaseModel):
items: list[AtpModelSummary]
total: int
class AtpModelCreateRequest(BaseModel):
code: str = Field(min_length=1, max_length=64)
name: str = Field(min_length=1, max_length=255)
source_type: AtpModelSourceType = "atpdraw"
description: str = Field(default="", max_length=8000)
status: AtpModelStatus = "enabled"
tags_json: list[str] = Field(default_factory=list, max_length=128)
class AtpModelUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
source_type: AtpModelSourceType | None = None
description: str | None = Field(default=None, max_length=8000)
status: AtpModelStatus | None = None
tags_json: list[str] | None = Field(default=None, max_length=128)
class AtpModelVersionSummary(BaseModel):
id: str
model_id: str
version_no: int
version_tag: str | None = None
status: AtpModelVersionStatus
entry_file: str | None = None
change_note: str
artifact_manifest_json: dict[str, Any] = Field(default_factory=dict)
content_hash: str
atp_text_size: int
create_date: datetime
create_user: str | None = None
update_date: datetime
update_user: str | None = None
class AtpModelVersionDetail(AtpModelVersionSummary):
atp_text: str
graph_json: dict[str, Any] = Field(default_factory=dict)
class AtpModelVersionListResponse(BaseModel):
items: list[AtpModelVersionSummary]
total: int
class AtpModelVersionCreateRequest(BaseModel):
version_tag: str | None = Field(default=None, max_length=64)
status: AtpModelVersionStatus = "released"
entry_file: str | None = Field(default=None, max_length=255)
change_note: str = Field(default="", max_length=8000)
artifact_manifest_json: dict[str, Any] = Field(default_factory=dict)
graph_json: dict[str, Any] = Field(default_factory=dict)
atp_text: str = Field(min_length=1)
class AtpModelVersionUpdateRequest(BaseModel):
version_tag: str | None = Field(default=None, max_length=64)
status: AtpModelVersionStatus | None = None
entry_file: str | None = Field(default=None, max_length=255)
change_note: str | None = Field(default=None, max_length=8000)
artifact_manifest_json: dict[str, Any] | None = None
graph_json: dict[str, Any] | None = None
atp_text: str | None = Field(default=None, min_length=1)
class AtpSimulationRunSummary(BaseModel):
id: str
model_id: str
version_id: str | None = None
version_no: int | None = None
task_id: str | None = None
status: AtpSimulationRunStatus
engine_mode: AtpEngineMode
engine_command: str | None = None
working_dir: str | None = None
timeout_seconds: int
exit_code: int | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
duration_ms: int | None = None
error_message: str | None = None
stdout_size: int = 0
stderr_size: int = 0
create_date: datetime
create_user: str | None = None
class AtpSimulationRunDetail(AtpSimulationRunSummary):
stdout_text: str | None = None
stderr_text: str | None = None
class AtpSimulationRunListResponse(BaseModel):
items: list[AtpSimulationRunSummary]
total: int
class AtpSimulationRunRequest(BaseModel):
version_id: str | None = None
version_no: int | None = Field(default=None, ge=1)
timeout_seconds: int | None = Field(default=None, ge=1)
extra_args: list[str] = Field(default_factory=list, max_length=32)
environment: dict[str, str] = Field(default_factory=dict, max_length=16)
dry_run: bool = False
class AtpEngineStatusResponse(BaseModel):
mode: AtpEngineMode
available: bool
executable_path: str
resolved_executable: str | None = None
storage_root: str
workdir: str
default_timeout_seconds: int
max_timeout_seconds: int
checks: dict[str, dict[str, Any]] = Field(default_factory=dict)
error: str | None = None
+82
View File
@@ -41,8 +41,11 @@ from ..schemas.atp_asset import (
AtpAssetRunSummary,
AtpAssetSummary,
AtpAssetUpdateRequest,
AtpEngineStatusResponse,
)
from .legacy_atp_adapter import build_legacy_atp_status_checks
from .push_service import publish_topic
from .wine_probe import probe_wine_binary
from .storage_driver import (
StorageDriver,
StorageDriverError,
@@ -164,6 +167,85 @@ def _resolve_binary(raw_path: str) -> str | None:
return None
def _resolve_storage_root() -> Path:
root = Path(settings.atp_storage_root).expanduser()
return root.resolve(strict=False)
def _resolve_engine_workdir() -> Path:
configured = Path(settings.atp_engine_workdir).expanduser()
if configured.is_absolute():
return configured.resolve(strict=False)
return (_resolve_storage_root() / configured).resolve(strict=False)
def _resolve_wine_engine_executable() -> tuple[str | None, str | None, str | None]:
wine_binary = _resolve_binary(settings.wine_binary_path)
if not wine_binary:
return None, None, "Wine binary not found"
allowed_root = Path(settings.wine_allowed_root).expanduser().resolve(strict=False)
configured = Path(settings.atp_engine_executable).expanduser()
if not configured.is_absolute():
configured = (allowed_root / configured).resolve(strict=False)
else:
configured = configured.resolve(strict=False)
if not configured.is_relative_to(allowed_root):
return wine_binary, None, f"ATP engine executable must be inside {allowed_root}"
if not configured.exists() or not configured.is_file():
return wine_binary, None, f"ATP engine executable not found: {configured}"
probe = probe_wine_binary(wine_binary)
return wine_binary, str(configured), None if probe.available else (probe.error or "Wine binary unavailable")
def _resolve_native_engine_executable() -> tuple[str | None, str | None]:
resolved = _resolve_binary(settings.atp_engine_executable)
if not resolved:
return None, "ATP engine executable not found"
return resolved, None
def get_engine_status() -> AtpEngineStatusResponse:
mode = _resolve_engine_mode()
storage_root = str(_resolve_storage_root())
workdir = str(_resolve_engine_workdir())
checks = build_legacy_atp_status_checks()
if mode == "wine":
wine_binary, resolved_engine, error = _resolve_wine_engine_executable()
available = error is None
executable_path = settings.atp_engine_executable.strip()
resolved_binary = f"{wine_binary} -> {resolved_engine}" if wine_binary and resolved_engine else wine_binary
return AtpEngineStatusResponse(
mode="wine",
available=available,
executable_path=executable_path,
resolved_executable=resolved_binary,
storage_root=storage_root,
workdir=workdir,
default_timeout_seconds=settings.atp_engine_default_timeout_seconds,
max_timeout_seconds=settings.atp_engine_max_timeout_seconds,
checks=checks,
error=error,
)
resolved_engine, error = _resolve_native_engine_executable()
return AtpEngineStatusResponse(
mode="native",
available=error is None,
executable_path=settings.atp_engine_executable.strip(),
resolved_executable=resolved_engine,
storage_root=storage_root,
workdir=workdir,
default_timeout_seconds=settings.atp_engine_default_timeout_seconds,
max_timeout_seconds=settings.atp_engine_max_timeout_seconds,
checks=checks,
error=error,
)
def _resolve_mount(db: Session, mount_code: str) -> FileStorageMount:
mount = db.execute(
select(FileStorageMount)
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -31,7 +31,7 @@ from ..schemas.fl_analysis import (
FlAnalysisTowerResultListResponse,
FlAnalysisTowerResultSummary,
)
from .atp_model_service import _truncate_output
from .atp_asset_service import _truncate_output
from .fl_analysis_external import execute_external_waveform_tower_analysis, resolve_external_waveform_job
from .legacy_atp_adapter import execute_legacy_atp_tower_analysis, resolve_legacy_atp_job
from .fl_analysis_report import build_report_document, build_report_summary_payload
-20
View File
@@ -1,20 +0,0 @@
from __future__ import annotations
from typing import Any
from ..core.celery_app import celery_app
from ..services.atp_model_service import execute_model_run_job
@celery_app.task(name="app.tasks.atp_model_tasks.execute_atp_model_run_job")
def execute_atp_model_run_job(
run_id: str,
payload_data: dict[str, Any],
actor_user_id: str | None,
) -> dict[str, str]:
execute_model_run_job(
run_id=run_id,
payload_data=payload_data,
actor_user_id=actor_user_id,
)
return {"run_id": run_id, "status": "done"}
-437
View File
@@ -1,437 +0,0 @@
from __future__ import annotations
import io
from pathlib import Path
from types import SimpleNamespace
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from app.core.database import Base
from app.models.atp_model import AtpModel, AtpModelVersion, AtpSimulationRun
from app.models.elevation import ElevationDataImportJob, ElevationDataset, ElevationFileRecord
from app.models.user import User
from app.models.wine import WineRun
from app.schemas.atp_model import AtpSimulationRunRequest
from app.schemas.wine import WineRunRequest
from app.services import atp_model_service, elevation_service, wine_service
class _MemoryStorageDriver:
def __init__(self) -> None:
self.directories: set[str] = set()
self.files: dict[str, bytes] = {}
def ensure_directory(self, path: str) -> None:
self.directories.add(path.rstrip("/") or "/")
def write_file(self, path: str, *, content: bytes, content_type: str | None = None) -> SimpleNamespace:
self.files[path] = content
parent_path = path.rsplit("/", 1)[0] or "/"
return SimpleNamespace(
path=path,
parent_path=parent_path,
name=Path(path).name,
is_dir=False,
size=len(content),
modified_at=None,
mime_type=content_type,
)
def list_dir(self, path: str) -> list[SimpleNamespace]:
prefix = f"{path.rstrip('/')}/"
entries: list[SimpleNamespace] = []
for file_path in sorted(self.files):
if not file_path.startswith(prefix):
continue
suffix = file_path[len(prefix):]
if "/" in suffix:
continue
entries.append(
SimpleNamespace(
path=file_path,
parent_path=path,
name=Path(file_path).name,
is_dir=False,
size=len(self.files[file_path]),
modified_at=None,
mime_type=None,
)
)
return entries
def read_file(self, path: str) -> SimpleNamespace:
return SimpleNamespace(
path=path,
name=Path(path).name,
content=self.files[path],
mime_type=None,
)
def delete_path(self, path: str, *, is_dir: bool, recursive: bool) -> None:
normalized = path.rstrip("/")
if is_dir:
prefix = f"{normalized}/"
for file_path in list(self.files):
if file_path.startswith(prefix):
del self.files[file_path]
self.directories = {item for item in self.directories if item != normalized and not item.startswith(prefix)}
return
self.files.pop(path, None)
def _build_upload(filename: str, content: bytes, content_type: str = "application/octet-stream") -> SimpleNamespace:
return SimpleNamespace(filename=filename, file=io.BytesIO(content), content_type=content_type)
def _build_sessionmaker(*tables):
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(bind=engine, tables=list(tables))
return sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
def test_run_model_version_queues_celery_task(monkeypatch) -> None:
testing_session = _build_sessionmaker(
AtpModel.__table__,
AtpModelVersion.__table__,
AtpSimulationRun.__table__,
)
session: Session = testing_session()
try:
model = AtpModel(
code="ATP-ASYNC-001",
name="异步仿真模型",
source_type="atp",
status="enabled",
latest_version_no=1,
active_version_no=1,
)
session.add(model)
session.flush()
version = AtpModelVersion(
model_id=model.id,
version_no=1,
status="released",
atp_text="sample",
content_hash="hash-v1",
)
session.add(version)
session.commit()
monkeypatch.setattr(
atp_model_service,
"_dispatch_atp_model_run_task",
lambda **_: SimpleNamespace(id="celery-atp-1"),
)
result = atp_model_service.run_model_version(
session,
model_id=model.id,
payload=AtpSimulationRunRequest(version_id=version.id),
actor_user_id="tester",
)
assert result.status == "pending"
assert result.task_id == "celery-atp-1"
saved = session.execute(select(AtpSimulationRun).where(AtpSimulationRun.id == result.id)).scalar_one()
assert saved.task_id == "celery-atp-1"
assert saved.status == "pending"
assert saved.started_at is None
finally:
session.close()
def test_queue_dataset_analysis_reuses_existing_running_task(monkeypatch) -> None:
testing_session = _build_sessionmaker(ElevationDataset.__table__)
session: Session = testing_session()
try:
dataset = ElevationDataset(
code="ELEV-001",
name="样例高程集",
file_format="csv",
mount_code="default",
dataset_dir="/elevation/datasets/ELEV-001",
file_path="/elevation/datasets/ELEV-001/data.csv",
status="active",
usage_status="idle",
)
session.add(dataset)
session.commit()
actor = User(
id="actor-1",
email="actor@example.com",
username="actor",
password_hash="hashed",
status="active",
)
monkeypatch.setattr(
elevation_service,
"_dispatch_elevation_dataset_analysis_task",
lambda **_: SimpleNamespace(id="elev-task-1"),
)
first = elevation_service.queue_dataset_analysis(session, dataset_id=dataset.id, actor=actor)
assert first.queued is True
assert first.task_id == "elev-task-1"
assert first.dataset.analysis_status == "queued"
assert first.dataset.terrain_status == "not_supported"
second = elevation_service.queue_dataset_analysis(session, dataset_id=dataset.id, actor=actor)
assert second.queued is False
assert second.task_id == "elev-task-1"
assert second.detail == "分析任务已存在,无需重复提交。"
finally:
session.close()
def test_queue_dataset_terrain_build_reuses_existing_running_task(monkeypatch) -> None:
testing_session = _build_sessionmaker(ElevationDataset.__table__)
session: Session = testing_session()
try:
dataset = ElevationDataset(
code="ELEV-TERRAIN-001",
name="样例地形集",
file_format="tif",
mount_code="default",
dataset_dir="/elevation/datasets/ELEV-TERRAIN-001",
file_path="/elevation/datasets/ELEV-TERRAIN-001/data.tif",
status="active",
usage_status="idle",
terrain_status="pending",
)
session.add(dataset)
session.commit()
actor = User(
id="actor-1",
email="actor@example.com",
username="actor",
password_hash="hashed",
status="active",
)
monkeypatch.setattr(
elevation_service,
"_dispatch_elevation_dataset_terrain_task",
lambda **_: SimpleNamespace(id="terrain-task-1"),
)
first = elevation_service.queue_dataset_terrain_build(session, dataset_id=dataset.id, actor=actor)
assert first.queued is True
assert first.task_id == "terrain-task-1"
assert first.dataset.terrain_status == "pending"
second = elevation_service.queue_dataset_terrain_build(session, dataset_id=dataset.id, actor=actor)
assert second.queued is False
assert second.task_id == "terrain-task-1"
assert second.detail == "地形瓦片任务已存在,无需重复提交。"
finally:
session.close()
def test_file_record_terrain_layer_and_tile_read_from_record_storage(monkeypatch) -> None:
testing_session = _build_sessionmaker(ElevationFileRecord.__table__)
session: Session = testing_session()
try:
record = ElevationFileRecord(
id="abcdef1234567890abcdef1234567890",
file_name="terrain.tif",
file_path="/elevation/records/ab/cd/terrain.tif",
file_format="tif",
file_size=128,
mount_code="default",
status="active",
terrain_status="ready",
terrain_root_path="/elevation/terrain/records/ab/cd/abcdef1234567890abcdef1234567890",
terrain_url_template="/api/v1/elevation/records/abcdef1234567890abcdef1234567890/terrain/{z}/{x}/{y}.terrain?v=1.0.0",
terrain_min_zoom=0,
terrain_max_zoom=0,
)
session.add(record)
session.commit()
driver = _MemoryStorageDriver()
layer_payload = b'{"tilejson":"2.1.0","format":"heightmap-1.0","version":"1.0.0","scheme":"tms","projection":"EPSG:4326","tiles":["{z}/{x}/{y}.terrain?v=1.0.0"],"minzoom":0,"maxzoom":0}'
driver.write_file(
"/elevation/terrain/records/ab/cd/abcdef1234567890abcdef1234567890/layer.json",
content=layer_payload,
content_type="application/json",
)
driver.write_file(
"/elevation/terrain/records/ab/cd/abcdef1234567890abcdef1234567890/0/0/0.terrain",
content=b"tile-bytes",
content_type="application/octet-stream",
)
monkeypatch.setattr(elevation_service, "_require_mount", lambda *_args, **_kwargs: SimpleNamespace(code="default"))
monkeypatch.setattr(elevation_service, "_build_driver_or_400", lambda *_args, **_kwargs: driver)
layer = elevation_service.get_file_record_terrain_layer(session, record_id=record.id)
tile = elevation_service.get_file_record_terrain_tile(session, record_id=record.id, z=0, x=0, y=0)
assert layer.maxzoom == 0
assert layer.tiles == ["{z}/{x}/{y}.terrain?v=1.0.0"]
assert tile == b"tile-bytes"
finally:
session.close()
def test_import_dataset_data_files_queue_job_and_worker_keeps_preferred_raster(monkeypatch) -> None:
testing_session = _build_sessionmaker(ElevationDataset.__table__, ElevationDataImportJob.__table__)
session: Session = testing_session()
try:
dataset = ElevationDataset(
code="ELEV-IMPORT-001",
name="批量导入样例",
file_format="csv",
mount_code="default",
dataset_dir="/elevation/datasets/ELEV-IMPORT-001",
file_path="/elevation/datasets/ELEV-IMPORT-001/dataset.csv",
status="active",
usage_status="idle",
sample_count=128,
bbox_min_lon=100.0,
bbox_max_lon=120.0,
bbox_min_lat=20.0,
bbox_max_lat=30.0,
analysis_task_id="old-task",
analysis_status="success",
terrain_status="not_supported",
)
session.add(dataset)
session.commit()
actor = User(
id="actor-1",
email="actor@example.com",
username="actor",
password_hash="hashed",
status="active",
)
driver = _MemoryStorageDriver()
import_calls: list[tuple[str, str | None]] = []
analysis_calls: list[tuple[str, str | None]] = []
monkeypatch.setattr(elevation_service, "_require_mount", lambda *_args, **_kwargs: SimpleNamespace(code="default"))
monkeypatch.setattr(elevation_service, "_build_driver_or_400", lambda *_args, **_kwargs: driver)
monkeypatch.setattr(
elevation_service,
"_dispatch_elevation_dataset_data_import_task",
lambda *, import_job_id, actor_user_id: import_calls.append((import_job_id, actor_user_id)) or SimpleNamespace(id="import-task-1"),
)
monkeypatch.setattr(
elevation_service,
"_dispatch_elevation_dataset_analysis_task",
lambda *, dataset_id, actor_user_id: analysis_calls.append((dataset_id, actor_user_id)) or SimpleNamespace(id="new-task"),
)
monkeypatch.setattr(elevation_service, "_publish_elevation_change", lambda *_args, **_kwargs: None)
first = elevation_service.import_dataset_data_files(
session,
dataset_id=dataset.id,
files=[_build_upload("terrain.img", b"img-bytes", "application/octet-stream")],
actor=actor,
trigger_analysis=True,
)
assert first.queued is True
assert first.job.task_id == "import-task-1"
assert first.job.status == "pending"
assert first.job.uploaded_file_count == 1
assert first.job.analysis_task_queued is False
assert import_calls == [(first.job.id, actor.id)]
saved_pending_job = session.get(ElevationDataImportJob, first.job.id)
assert saved_pending_job is not None
assert saved_pending_job.staged_files_json[0]["filename"] == "terrain.img"
assert "content_base64" in saved_pending_job.staged_files_json[0]
second = elevation_service.import_dataset_data_files(
session,
dataset_id=dataset.id,
files=[_build_upload("points.csv", b"lon,lat,elevation\n1,2,3\n", "text/csv")],
actor=actor,
trigger_analysis=True,
)
assert second.queued is False
assert second.job.id == first.job.id
assert second.detail == "导入任务已存在,无需重复提交。"
monkeypatch.setattr(elevation_service, "SessionLocal", testing_session)
elevation_service.execute_dataset_data_import_job(import_job_id=first.job.id, actor_user_id=actor.id)
verification = testing_session()
try:
saved_dataset = verification.get(ElevationDataset, dataset.id)
saved_job = verification.get(ElevationDataImportJob, first.job.id)
assert saved_dataset is not None
assert saved_job is not None
assert saved_job.status == "success"
assert saved_job.progress_percent == 100
assert saved_job.analysis_task_queued is True
assert saved_job.analysis_task_id == "new-task"
assert saved_job.imported_file_count == 1
assert saved_dataset.file_path.endswith("/terrain.img")
assert saved_dataset.file_format == "img"
assert saved_dataset.analysis_status == "queued"
assert saved_dataset.analysis_task_id == "new-task"
assert saved_dataset.sample_count == 0
assert saved_dataset.bbox_min_lon is None
assert saved_dataset.terrain_status == "pending"
assert analysis_calls == [(dataset.id, actor.id)]
assert set(driver.files) == {
"/elevation/datasets/ELEV-IMPORT-001/terrain.img",
}
finally:
verification.close()
finally:
session.close()
def test_wine_create_run_queues_task_and_worker_records_failure(monkeypatch) -> None:
testing_session = _build_sessionmaker(WineRun.__table__)
session: Session = testing_session()
try:
monkeypatch.setattr(wine_service, "_resolve_binary", lambda: "/usr/bin/wine")
monkeypatch.setattr(
wine_service,
"probe_wine_binary",
lambda *_args, **_kwargs: SimpleNamespace(available=True, error=None, version="wine-10.0"),
)
monkeypatch.setattr(wine_service, "_resolve_executable", lambda _path: Path("/tmp/demo.exe"))
monkeypatch.setattr(wine_service, "_resolve_working_dir", lambda _path, _exe: Path("/tmp"))
monkeypatch.setattr(
wine_service,
"_dispatch_wine_run_task",
lambda **_: SimpleNamespace(id="wine-task-1"),
)
created = wine_service.create_run(
session,
payload=WineRunRequest(exe_path="demo.exe", arguments=["/silent"]),
actor_user_id="tester",
)
assert created.status == "pending"
assert created.task_id == "wine-task-1"
monkeypatch.setattr(wine_service, "SessionLocal", testing_session)
monkeypatch.setattr(
wine_service.subprocess,
"run",
lambda *args, **kwargs: SimpleNamespace(returncode=9, stdout="stdout", stderr="stderr"),
)
wine_service.execute_run_job(run_id=created.id, actor_user_id="tester")
saved = session.execute(select(WineRun).where(WineRun.id == created.id)).scalar_one()
session.refresh(saved)
assert saved.status == "failed"
assert saved.exit_code == 9
assert saved.error_message == "Wine process exited with code 9"
assert saved.stdout_text == "stdout"
assert saved.stderr_text == "stderr"
finally:
session.close()
-110
View File
@@ -1,110 +0,0 @@
from __future__ import annotations
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from app.core import database as core_database
from app.core.config import get_settings
from app.core.database import Base
from app.models.atp_model import AtpModel, AtpModelVersion, AtpSimulationRun
from app.schemas.atp_model import AtpSimulationRunRequest
from app.services import atp_model_service
def _build_sessionmaker():
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
bind=engine,
tables=[
AtpModel.__table__,
AtpModelVersion.__table__,
AtpSimulationRun.__table__,
],
)
return sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
def test_run_model_version_dry_run_records_worker_command(monkeypatch, tmp_path) -> None:
testing_session = _build_sessionmaker()
monkeypatch.setattr(core_database, "SessionLocal", testing_session)
monkeypatch.setattr(atp_model_service, "_publish_change", lambda *args, **kwargs: None)
monkeypatch.setattr(atp_model_service, "_resolve_storage_root", lambda: tmp_path / "storage")
monkeypatch.setattr(atp_model_service, "_resolve_engine_workdir", lambda: tmp_path / "runs")
monkeypatch.setattr(
atp_model_service,
"_resolve_wine_engine_executable",
lambda: ("/usr/bin/wine", "/tmp/tpbig.exe", None),
)
session: Session = testing_session()
try:
model = AtpModel(
code="ATP-DRY-001",
name="Dry Run ATP",
source_type="atp",
status="enabled",
latest_version_no=1,
active_version_no=1,
)
session.add(model)
session.flush()
version = AtpModelVersion(
model_id=model.id,
version_no=1,
status="released",
entry_file="case.atp",
atp_text="BEGIN ATP CASE",
content_hash="dry-hash-v1",
)
session.add(version)
session.commit()
result = atp_model_service.run_model_version(
session,
model_id=model.id,
payload=AtpSimulationRunRequest(version_id=version.id, dry_run=True),
actor_user_id="tester",
)
assert result.status == "success"
assert result.engine_command is not None
assert result.engine_command.startswith("/usr/bin/wine /tmp/tpbig.exe ")
assert result.engine_command.endswith("/case.atp")
assert result.working_dir is not None
assert result.stdout_text is not None
saved = session.execute(select(AtpSimulationRun).where(AtpSimulationRun.id == result.id)).scalar_one()
assert saved.status == "success"
assert saved.exit_code == 0
assert saved.error_message is None
assert "dry_run" in (saved.stdout_text or "")
finally:
session.close()
def test_get_engine_status_includes_legacy_asset_checks(monkeypatch, tmp_path) -> None:
allowed_root = tmp_path / "wine-root"
template_root = allowed_root / "ATP" / "templates"
template_root.mkdir(parents=True)
(template_root / "EGM").mkdir()
(allowed_root / "ATP").mkdir(exist_ok=True)
(allowed_root / "ATP" / "tpbig.exe").write_text("binary", encoding="utf-8")
(allowed_root / "ATP" / "rjtzl.exe").write_text("binary", encoding="utf-8")
settings = get_settings()
monkeypatch.setattr(settings, "wine_allowed_root", str(allowed_root))
monkeypatch.setattr(settings, "atp_legacy_root", str(allowed_root / "ATP"))
monkeypatch.setattr(settings, "atp_template_root", str(template_root))
monkeypatch.setattr(settings, "atp_run_root", str(allowed_root / "runs"))
monkeypatch.setattr(settings, "atp_tpbig_executable", "ATP/tpbig.exe")
monkeypatch.setattr(settings, "atp_rjtzl_executable", "ATP/rjtzl.exe")
monkeypatch.setattr(atp_model_service, "_resolve_wine_engine_executable", lambda: ("/usr/bin/wine", "/tmp/tpbig.exe", None))
result = atp_model_service.get_engine_status()
assert "legacy_root" in result.checks
assert result.checks["legacy_root"]["available"] is True
assert result.checks["tpbig_executable"]["available"] is True
assert result.checks["rjtzl_executable"]["available"] is True
assert result.checks["egm_subdir"]["available"] is True
-66
View File
@@ -1,66 +0,0 @@
from __future__ import annotations
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from app.core.database import Base
from app.models.atp_model import AtpModel, AtpModelVersion, AtpSimulationRun
from app.services.atp_model_service import delete_model
def _build_sessionmaker():
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
bind=engine,
tables=[
AtpModel.__table__,
AtpModelVersion.__table__,
AtpSimulationRun.__table__,
],
)
return sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
def test_delete_model_cascades_hidden_versions_and_runs() -> None:
testing_session = _build_sessionmaker()
session: Session = testing_session()
try:
model = AtpModel(
code="ATP-001",
name="示例模型",
source_type="atp",
status="enabled",
latest_version_no=1,
active_version_no=1,
)
session.add(model)
session.flush()
version = AtpModelVersion(
model_id=model.id,
version_no=1,
status="released",
atp_text="sample",
content_hash="hash-v1",
)
session.add(version)
session.flush()
session.add(
AtpSimulationRun(
model_id=model.id,
version_id=version.id,
status="success",
engine_mode="native",
timeout_seconds=60,
)
)
session.commit()
assert delete_model(session, model.id) is True
assert session.execute(select(AtpModel).where(AtpModel.id == model.id)).scalar_one_or_none() is None
assert session.execute(select(AtpModelVersion).where(AtpModelVersion.model_id == model.id)).scalar_one_or_none() is None
assert session.execute(select(AtpSimulationRun).where(AtpSimulationRun.model_id == model.id)).scalar_one_or_none() is None
finally:
session.close()