feat: HSAP platform v2 — modular navigation, quality review, audit log, world model simulation
Major changes: - New frontend (platform/web/): Vite + React 18 + TypeScript + Tailwind - 4-module navigation: 数据送标 / 模型管理 / 车队管理 / 系统管理 - Data catalog with charts (DMS/ADAS/Lane 3-tab view) - Quality review workflow (标注质检): Good/Fine/Bad scoring with auto-advance - Audit enhancements: batch operations, rejection categories, Feishu notifications - Operation audit log (操作日志) - World model simulation studio (仿真工坊) - Dataset version management with snapshots and diff - ADAS 7-class dataset integration (138K images organized + compressed) - User management with Feishu integration and pagination - CRUD/search/filter on all pages, card layout redesign - PIL-optimized image overlay rendering - Auto-snapshot on build, in_review workflow stage - Removed embedded algorithm code (now in workspace)
This commit is contained in:
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
from typing import Annotated, Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -12,8 +12,8 @@ from sqlalchemy.orm import Session
|
||||
from as_platform.auth.deps import get_current_user, require_permission
|
||||
from as_platform.auth.feishu import build_authorize_url, exchange_code, is_feishu_configured, verify_state
|
||||
from as_platform.auth.jwt import create_access_token
|
||||
from as_platform.auth.users import get_or_create_dev_user, list_users, set_user_roles, upsert_feishu_user
|
||||
from as_platform.config import DEV_AUTH_ENABLED, FRONTEND_URL
|
||||
from as_platform.auth.users import get_or_create_dev_user, list_users, list_users_paginated, set_user_roles, upsert_feishu_user
|
||||
from as_platform.config import DEV_AUTH_ENABLED, FORCE_DEV_AUTH, FRONTEND_URL
|
||||
from as_platform.db.engine import get_db
|
||||
from as_platform.db.init_db import user_to_dict
|
||||
from as_platform.db.models import User
|
||||
@@ -33,7 +33,7 @@ class SetRolesBody(BaseModel):
|
||||
def auth_config() -> dict[str, Any]:
|
||||
return {
|
||||
"feishu_enabled": is_feishu_configured(),
|
||||
"dev_auth_enabled": DEV_AUTH_ENABLED and not is_feishu_configured(),
|
||||
"dev_auth_enabled": DEV_AUTH_ENABLED and (FORCE_DEV_AUTH or not is_feishu_configured()),
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ def feishu_callback(code: str, state: str, db: Annotated[Session, Depends(get_db
|
||||
user = upsert_feishu_user(db, info)
|
||||
db.commit()
|
||||
token = create_access_token(user.id)
|
||||
# 审计日志
|
||||
from as_platform.audit.log_utils import log_op
|
||||
log_op(user_id=user.id, user_name=user.name, category="auth", action="login",
|
||||
target_type="user", target_id=str(user.id), summary=f"飞书登录: {user.name}")
|
||||
# 避免 BrowserRouter 直达路径 404,统一回根路径并附带 token
|
||||
qs = urlencode({"token": token})
|
||||
return RedirectResponse(f"{FRONTEND_URL}/?{qs}")
|
||||
@@ -63,11 +67,15 @@ def feishu_callback(code: str, state: str, db: Annotated[Session, Depends(get_db
|
||||
|
||||
@router.post("/dev/login")
|
||||
def dev_login(body: DevLoginBody, db: Annotated[Session, Depends(get_db)]):
|
||||
if not DEV_AUTH_ENABLED or is_feishu_configured():
|
||||
if not DEV_AUTH_ENABLED or (not FORCE_DEV_AUTH and is_feishu_configured()):
|
||||
raise HTTPException(403, "开发登录未启用")
|
||||
user = get_or_create_dev_user(db, body.name)
|
||||
db.commit()
|
||||
token = create_access_token(user.id)
|
||||
# 审计日志
|
||||
from as_platform.audit.log_utils import log_op
|
||||
log_op(user_id=user.id, user_name=user.name, category="auth", action="login",
|
||||
target_type="user", target_id=str(user.id), summary=f"开发登录: {user.name}")
|
||||
return {"access_token": token, "user": user_to_dict(user)}
|
||||
|
||||
|
||||
@@ -80,8 +88,13 @@ def auth_me(user: Annotated[User, Depends(get_current_user)]):
|
||||
def auth_list_users(
|
||||
_user: Annotated[User, Depends(require_permission("admin:users"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
search: str = Query(""),
|
||||
role: str = Query(""),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
return {"items": [user_to_dict(u) for u in list_users(db)]}
|
||||
users, total = list_users_paginated(db, search=search, role_code=role, offset=offset, limit=limit)
|
||||
return {"items": [user_to_dict(u) for u in users], "total": total}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/roles")
|
||||
@@ -95,4 +108,8 @@ def auth_set_roles(
|
||||
if not user:
|
||||
raise HTTPException(404, "用户不存在")
|
||||
db.commit()
|
||||
from as_platform.audit.log_utils import log_op
|
||||
log_op(user_id=_user.id, user_name=_user.name, category="system", action="set_roles",
|
||||
target_type="user", target_id=str(user_id), summary=f"修改角色: {user.name} → {body.role_codes}",
|
||||
detail={"user_id": user_id, "roles": body.role_codes})
|
||||
return user_to_dict(user)
|
||||
|
||||
133
platform/as_platform/api/delivery_routes.py
Normal file
133
platform/as_platform/api/delivery_routes.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""平台批次送标申请 API。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from as_platform.auth.deps import get_current_user, require_any_permission
|
||||
from as_platform.db.models import User
|
||||
from as_platform.deliveries import service as delivery_svc
|
||||
|
||||
router = APIRouter(prefix="/api/v1/deliveries", tags=["deliveries"])
|
||||
|
||||
|
||||
class DeliveryBody(BaseModel):
|
||||
project: str = "dms"
|
||||
task: str | None = None
|
||||
mode: str | None = None
|
||||
batch_name: str = ""
|
||||
source_type: str | None = None
|
||||
vehicle_scene: str | None = None
|
||||
collection_start: str | None = None
|
||||
collection_end: str | None = None
|
||||
data_path: str = ""
|
||||
estimated_count: int | None = None
|
||||
remark: str | None = None
|
||||
owner_user_id: int | None = None
|
||||
owner_name: str | None = None
|
||||
|
||||
|
||||
class DeliveryPatchBody(BaseModel):
|
||||
project: str | None = None
|
||||
task: str | None = None
|
||||
mode: str | None = None
|
||||
batch_name: str | None = None
|
||||
source_type: str | None = None
|
||||
vehicle_scene: str | None = None
|
||||
collection_start: str | None = None
|
||||
collection_end: str | None = None
|
||||
data_path: str | None = None
|
||||
estimated_count: int | None = None
|
||||
remark: str | None = None
|
||||
owner_user_id: int | None = None
|
||||
owner_name: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
def api_list_deliveries(
|
||||
_user: Annotated[User, Depends(require_any_permission("read:deliveries", "read:pending", "*"))],
|
||||
status: str | None = Query(None),
|
||||
mine: bool = Query(False),
|
||||
mine_editable: bool = Query(False, description="待我处理:仅草稿/驳回"),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
mine_id = _user.id if mine or mine_editable else None
|
||||
return delivery_svc.list_deliveries(
|
||||
status=status,
|
||||
mine_user_id=mine_id,
|
||||
mine_editable_only=mine_editable,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{delivery_id}")
|
||||
def api_get_delivery(
|
||||
delivery_id: str,
|
||||
_user: Annotated[User, Depends(require_any_permission("read:deliveries", "read:pending", "*"))],
|
||||
) -> dict[str, Any]:
|
||||
row = delivery_svc.get_delivery(delivery_id)
|
||||
if not row:
|
||||
raise HTTPException(404, "送标申请不存在")
|
||||
return row
|
||||
|
||||
|
||||
@router.post("")
|
||||
def api_create_delivery(
|
||||
body: DeliveryBody,
|
||||
user: Annotated[User, Depends(require_any_permission("write:delivery_submit", "*"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return delivery_svc.create_delivery(body.model_dump(), user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.patch("/{delivery_id}")
|
||||
def api_patch_delivery(
|
||||
delivery_id: str,
|
||||
body: DeliveryPatchBody,
|
||||
user: Annotated[User, Depends(require_any_permission("write:delivery_submit", "*"))],
|
||||
) -> dict[str, Any]:
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
try:
|
||||
return delivery_svc.update_delivery(delivery_id, data, user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/{delivery_id}/submit")
|
||||
def api_submit_delivery(
|
||||
delivery_id: str,
|
||||
user: Annotated[User, Depends(require_any_permission("write:delivery_submit", "*"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return delivery_svc.submit_delivery_for_review(delivery_id, user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/{delivery_id}/retry-ingest")
|
||||
def api_retry_delivery_ingest(
|
||||
delivery_id: str,
|
||||
user: Annotated[User, Depends(require_any_permission("write:delivery_submit", "*"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return delivery_svc.retry_delivery_ingest(delivery_id, user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/{delivery_id}")
|
||||
def api_delete_delivery(
|
||||
delivery_id: str,
|
||||
user: Annotated[User, Depends(require_any_permission("write:delivery_submit", "*"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
delivery_svc.delete_delivery(delivery_id, user)
|
||||
return {"ok": True}
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
75
platform/as_platform/api/feishu_routes.py
Normal file
75
platform/as_platform/api/feishu_routes.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""飞书集成 API。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from as_platform.auth.deps import require_any_permission
|
||||
from as_platform.db.models import User
|
||||
from as_platform.config import FEISHU_BITABLE_AUTO_INGEST, FEISHU_BITABLE_WEBHOOK_ENABLED
|
||||
from as_platform.integrations.feishu_bitable import connectivity_check, is_bitable_configured, list_tables
|
||||
from as_platform.integrations.feishu_bitable_ingest import process_pending_ingest
|
||||
from as_platform.integrations.feishu_bitable_sync import backfill_hints
|
||||
from as_platform.integrations.feishu_notify import is_notify_configured
|
||||
from as_platform.jobs.feishu_bitable_sync import run_sync_cycle
|
||||
|
||||
router = APIRouter(prefix="/api/v1/integrations/feishu", tags=["feishu"])
|
||||
|
||||
|
||||
@router.get("/bitable/status")
|
||||
def api_bitable_status(
|
||||
_user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "*"))],
|
||||
) -> dict[str, Any]:
|
||||
return connectivity_check()
|
||||
|
||||
|
||||
@router.get("/bitable/tables")
|
||||
def api_bitable_tables(
|
||||
_user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "*"))],
|
||||
) -> dict[str, Any]:
|
||||
if not is_bitable_configured():
|
||||
return {"items": [], "message": "未配置 BITABLE_APP_TOKEN / TABLE_ID"}
|
||||
return {"items": list_tables()}
|
||||
|
||||
|
||||
@router.post("/bitable/sync")
|
||||
def api_bitable_sync(
|
||||
_user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "*"))],
|
||||
) -> dict[str, Any]:
|
||||
return run_sync_cycle()
|
||||
|
||||
|
||||
@router.post("/bitable/ingest")
|
||||
def api_bitable_ingest(
|
||||
_user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "*"))],
|
||||
) -> dict[str, Any]:
|
||||
"""手动触发「待落盘」入湖(须 FEISHU_BITABLE_AUTO_INGEST=1 或本接口始终可用)。"""
|
||||
return process_pending_ingest()
|
||||
|
||||
|
||||
@router.get("/bitable/config")
|
||||
def api_bitable_config(
|
||||
_user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "*"))],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"bitable_configured": is_bitable_configured(),
|
||||
"notify_configured": is_notify_configured(),
|
||||
"auto_ingest": FEISHU_BITABLE_AUTO_INGEST,
|
||||
"webhook_enabled": FEISHU_BITABLE_WEBHOOK_ENABLED,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/bitable/webhook")
|
||||
def api_bitable_webhook_disabled() -> dict[str, Any]:
|
||||
"""内网默认关闭;有公网穿透后再实现验签入站。"""
|
||||
if not FEISHU_BITABLE_WEBHOOK_ENABLED:
|
||||
return {"ok": False, "message": "FEISHU_BITABLE_WEBHOOK_ENABLED 未开启"}
|
||||
return {"ok": False, "message": "Webhook 处理器尚未实现"}
|
||||
|
||||
|
||||
@router.get("/bitable/backfill-hints")
|
||||
def api_bitable_backfill_hints(
|
||||
_user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "*"))],
|
||||
) -> dict[str, Any]:
|
||||
return backfill_hints()
|
||||
344
platform/as_platform/api/fleet_routes.py
Normal file
344
platform/as_platform/api/fleet_routes.py
Normal file
@@ -0,0 +1,344 @@
|
||||
"""Fleet map API routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from as_platform.auth.deps import get_current_user, require_permission
|
||||
from as_platform.config import AMAP_KEY, FLEET_MAP_ENABLED, MAP_TILE_PROVIDER, TBOX_INGEST_TOKEN
|
||||
from as_platform.db.engine import get_db
|
||||
from as_platform.db.models import User
|
||||
from as_platform.fleet import service as fleet_svc
|
||||
from as_platform.fleet.mock_seed import reseed_demo_fleet, seed_demo_fleet
|
||||
|
||||
router = APIRouter(tags=["fleet"])
|
||||
|
||||
|
||||
class VehicleBody(BaseModel):
|
||||
plate_no: str
|
||||
tbox_device_id: str
|
||||
name: str | None = None
|
||||
team: str | None = None
|
||||
status: str | None = "active"
|
||||
|
||||
|
||||
class VehiclePatchBody(BaseModel):
|
||||
plate_no: str | None = None
|
||||
tbox_device_id: str | None = None
|
||||
name: str | None = None
|
||||
team: str | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class TboxGpsBody(BaseModel):
|
||||
device_id: str
|
||||
lat: float
|
||||
lng: float
|
||||
ts: str | None = None
|
||||
speed_kmh: float | None = None
|
||||
heading: float | None = None
|
||||
plate_no: str | None = None
|
||||
run_signal: str | None = "active"
|
||||
engineer: str | None = None
|
||||
project: str | None = None
|
||||
batch: str | None = None
|
||||
|
||||
|
||||
class TboxGpsBatchBody(BaseModel):
|
||||
points: list[TboxGpsBody] = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
def _check_fleet_enabled() -> None:
|
||||
if not FLEET_MAP_ENABLED:
|
||||
raise HTTPException(503, "车队地图功能未启用 (AS_FLEET_MAP_ENABLED)")
|
||||
|
||||
|
||||
def _verify_tbox_token(x_tbox_token: str | None = None, authorization: str | None = None) -> None:
|
||||
token = (x_tbox_token or "").strip()
|
||||
if not token and authorization:
|
||||
parts = authorization.split(None, 1)
|
||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||
token = parts[1].strip()
|
||||
if not TBOX_INGEST_TOKEN:
|
||||
return
|
||||
if token != TBOX_INGEST_TOKEN:
|
||||
raise HTTPException(401, "T-Box Token 无效")
|
||||
|
||||
|
||||
@router.get("/api/v1/fleet/map-config")
|
||||
def fleet_map_config(_user: Annotated[User, Depends(require_permission("read:fleet"))]) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
tile_provider = MAP_TILE_PROVIDER if MAP_TILE_PROVIDER in ("gaode", "osm") else "gaode"
|
||||
return {
|
||||
"enabled": True,
|
||||
"provider": tile_provider,
|
||||
"tileProvider": tile_provider,
|
||||
"amapKey": AMAP_KEY or "",
|
||||
"pollIntervalSec": 5,
|
||||
"note": "GPS 为 WGS84,高德底图为 GCJ-02,演示轨迹可能有轻微偏移",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v1/fleet/live")
|
||||
def fleet_live(_user: Annotated[User, Depends(require_permission("read:fleet"))], db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
return fleet_svc.get_live_fleet(db)
|
||||
|
||||
|
||||
@router.get("/api/v1/fleet/vehicles")
|
||||
def fleet_vehicles(_user: Annotated[User, Depends(require_permission("read:fleet"))], db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
return {"items": fleet_svc.list_vehicles(db)}
|
||||
|
||||
|
||||
@router.get("/api/v1/fleet/vehicles/{vehicle_id}")
|
||||
def fleet_vehicle_get(
|
||||
vehicle_id: int,
|
||||
_user: Annotated[User, Depends(require_permission("read:fleet"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
item = fleet_svc.get_vehicle(db, vehicle_id)
|
||||
if not item:
|
||||
raise HTTPException(404, "车辆不存在")
|
||||
return item
|
||||
|
||||
|
||||
@router.post("/api/v1/fleet/vehicles")
|
||||
def fleet_vehicle_create(
|
||||
body: VehicleBody,
|
||||
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
try:
|
||||
item = fleet_svc.create_vehicle(db, body.model_dump())
|
||||
db.commit()
|
||||
return {"ok": True, "vehicle": item}
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.patch("/api/v1/fleet/vehicles/{vehicle_id}")
|
||||
def fleet_vehicle_update(
|
||||
vehicle_id: int,
|
||||
body: VehiclePatchBody,
|
||||
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
try:
|
||||
item = fleet_svc.update_vehicle(db, vehicle_id, body.model_dump(exclude_unset=True))
|
||||
db.commit()
|
||||
return {"ok": True, "vehicle": item}
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/api/v1/fleet/vehicles/{vehicle_id}")
|
||||
def fleet_vehicle_delete(
|
||||
vehicle_id: int,
|
||||
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
try:
|
||||
fleet_svc.delete_vehicle(db, vehicle_id)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.get("/api/v1/fleet/runs")
|
||||
def fleet_runs(
|
||||
_user: Annotated[User, Depends(require_permission("read:fleet"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
vehicle_id: int | None = None,
|
||||
status: str | None = None,
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
return fleet_svc.list_runs(db, vehicle_id=vehicle_id, status=status, offset=offset, limit=limit)
|
||||
|
||||
|
||||
@router.get("/api/v1/fleet/runs/{run_id}")
|
||||
def fleet_run_detail(run_id: int, _user: Annotated[User, Depends(require_permission("read:fleet"))], db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
detail = fleet_svc.get_run_detail(db, run_id)
|
||||
if not detail:
|
||||
raise HTTPException(404, "趟次不存在")
|
||||
return detail
|
||||
|
||||
|
||||
@router.get("/api/v1/fleet/runs/{run_id}/track")
|
||||
def fleet_run_track(run_id: int, _user: Annotated[User, Depends(require_permission("read:fleet"))], db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
return fleet_svc.get_run_track_geojson(db, run_id)
|
||||
|
||||
|
||||
@router.get("/api/v1/fleet/summary")
|
||||
def fleet_summary(_user: Annotated[User, Depends(require_permission("read:fleet"))], db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
return fleet_svc.fleet_summary(db)
|
||||
|
||||
|
||||
@router.post("/api/v1/fleet/mock/seed")
|
||||
def fleet_mock_seed(
|
||||
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
created = seed_demo_fleet(db)
|
||||
db.commit()
|
||||
return {"ok": True, "created": created}
|
||||
|
||||
|
||||
@router.post("/api/v1/fleet/mock/reseed")
|
||||
def fleet_mock_reseed(
|
||||
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
"""清空车队演示数据并重新注入(切换区域后使用)。"""
|
||||
_check_fleet_enabled()
|
||||
created = reseed_demo_fleet(db)
|
||||
db.commit()
|
||||
return {"ok": True, "created": created, "region": "changsha"}
|
||||
|
||||
|
||||
@router.post("/api/v1/fleet/mock/tick")
|
||||
def fleet_mock_tick(
|
||||
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
n = fleet_svc.simulate_tick(db)
|
||||
db.commit()
|
||||
return {"ok": True, "advanced": n}
|
||||
|
||||
|
||||
@router.post("/api/v1/fleet/runs/import-gpx")
|
||||
async def fleet_import_gpx(
|
||||
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
vehicle_id: int = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
note: str | None = Form(None),
|
||||
) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
raw = await file.read()
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = raw.decode("utf-8", errors="ignore")
|
||||
try:
|
||||
result = fleet_svc.import_gpx_run(db, vehicle_id=vehicle_id, gpx_content=text, note=note)
|
||||
db.commit()
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.get("/api/v1/fleet/stream")
|
||||
def fleet_stream(
|
||||
_user: Annotated[User, Depends(require_permission("read:fleet"))],
|
||||
) -> StreamingResponse:
|
||||
"""SSE:推送 fleet.gps 事件(需 Redis)。"""
|
||||
_check_fleet_enabled()
|
||||
from as_platform.redis.bus import get_redis
|
||||
|
||||
def event_generator():
|
||||
r = get_redis()
|
||||
if not r:
|
||||
yield f"data: {json.dumps({'event': 'error', 'message': 'redis unavailable'}, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
pubsub = r.pubsub()
|
||||
pubsub.subscribe("as:events")
|
||||
yield f"data: {json.dumps({'event': 'fleet.connected'}, ensure_ascii=False)}\n\n"
|
||||
while True:
|
||||
msg = pubsub.get_message(timeout=25)
|
||||
if msg is None:
|
||||
yield ": ping\n\n"
|
||||
continue
|
||||
if msg.get("type") != "message":
|
||||
continue
|
||||
raw = msg.get("data")
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8", errors="ignore")
|
||||
if isinstance(raw, str) and "fleet." in raw:
|
||||
yield f"data: {raw}\n\n"
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/api/v1/tbox/gps")
|
||||
def tbox_gps(
|
||||
body: TboxGpsBody,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_tbox_token: Annotated[str | None, Header(alias="X-Tbox-Token")] = None,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
_verify_tbox_token(x_tbox_token, authorization)
|
||||
try:
|
||||
fleet_svc.close_idle_runs(db)
|
||||
result = fleet_svc.ingest_tbox_gps(db, body.model_dump())
|
||||
db.commit()
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/api/v1/tbox/gps/batch")
|
||||
def tbox_gps_batch(
|
||||
body: TboxGpsBatchBody,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_tbox_token: Annotated[str | None, Header(alias="X-Tbox-Token")] = None,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
_verify_tbox_token(x_tbox_token, authorization)
|
||||
try:
|
||||
fleet_svc.close_idle_runs(db)
|
||||
result = fleet_svc.ingest_tbox_gps_batch(db, [p.model_dump() for p in body.points])
|
||||
db.commit()
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/api/v1/fleet/runs/import-csv")
|
||||
async def fleet_import_csv(
|
||||
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
vehicle_id: int = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
note: str | None = Form(None),
|
||||
project: str | None = Form(None),
|
||||
batch: str | None = Form(None),
|
||||
) -> dict[str, Any]:
|
||||
_check_fleet_enabled()
|
||||
raw = await file.read()
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = raw.decode("utf-8", errors="ignore")
|
||||
try:
|
||||
result = fleet_svc.import_csv_run(
|
||||
db,
|
||||
vehicle_id=vehicle_id,
|
||||
csv_content=text,
|
||||
note=note,
|
||||
project=project,
|
||||
batch=batch,
|
||||
)
|
||||
db.commit()
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
445
platform/as_platform/api/labeling_routes.py
Normal file
445
platform/as_platform/api/labeling_routes.py
Normal file
@@ -0,0 +1,445 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from as_platform.auth.deps import require_any_permission, require_permission
|
||||
from as_platform.db.models import User
|
||||
from as_platform.labeling.annotate import (
|
||||
campaign_bootstrap,
|
||||
campaign_tasks,
|
||||
get_annotation,
|
||||
resolve_media_file,
|
||||
save_annotation,
|
||||
)
|
||||
from as_platform.labeling.lock import acquire_lock, release_lock, renew_lock
|
||||
from as_platform.labeling.progress import (
|
||||
assign_tasks_even,
|
||||
assign_tasks_explicit,
|
||||
campaign_progress,
|
||||
release_task_assignment,
|
||||
reassign_task,
|
||||
user_is_coordinator,
|
||||
)
|
||||
from as_platform.labeling.service import (
|
||||
assign_campaign,
|
||||
get_campaign,
|
||||
list_campaign_export_jobs,
|
||||
list_labeling_assignees,
|
||||
list_labeling_batches,
|
||||
open_campaign,
|
||||
submit_campaign,
|
||||
trigger_labeling_export,
|
||||
)
|
||||
from as_platform.labeling.vendor_import import import_vendor_zip, list_registry_profiles
|
||||
|
||||
router = APIRouter(tags=["labeling"])
|
||||
|
||||
|
||||
class OpenCampaignBody(BaseModel):
|
||||
project: str = Field(..., pattern="^(dms|lane)$")
|
||||
task: str
|
||||
batch: str
|
||||
mode: str | None = None
|
||||
pack: str | None = None
|
||||
location: str = "inbox"
|
||||
|
||||
|
||||
class AssignCampaignBody(BaseModel):
|
||||
user_id: int | None = None
|
||||
|
||||
|
||||
class AnnotationBody(BaseModel):
|
||||
result: list[dict[str, Any]] | dict[str, Any] | None = None
|
||||
annotations: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class AssignTasksExplicitItem(BaseModel):
|
||||
user_id: int
|
||||
task_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AssignTasksBody(BaseModel):
|
||||
mode: str = "even"
|
||||
user_ids: list[int] | None = None
|
||||
items: list[AssignTasksExplicitItem] | None = None
|
||||
|
||||
|
||||
class ReassignTaskBody(BaseModel):
|
||||
user_id: int
|
||||
|
||||
|
||||
@router.get("/api/v1/labeling/assignees")
|
||||
def api_labeling_assignees(
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
return list_labeling_assignees()
|
||||
|
||||
|
||||
@router.patch("/api/v1/labeling/campaigns/{campaign_id}/assign")
|
||||
def api_assign_campaign(
|
||||
campaign_id: str,
|
||||
body: AssignCampaignBody,
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return assign_campaign(campaign_id, body.user_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "campaign not found") from None
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.get("/api/v1/labeling/batches")
|
||||
def api_labeling_batches(
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
stage: str | None = Query(None),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
return list_labeling_batches(stage=stage, offset=offset, limit=limit)
|
||||
|
||||
|
||||
@router.post("/api/v1/labeling/campaigns/open")
|
||||
def api_open_campaign(
|
||||
body: OpenCampaignBody,
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
return open_campaign(
|
||||
project=body.project,
|
||||
task=body.task,
|
||||
batch=body.batch,
|
||||
mode=body.mode,
|
||||
pack=body.pack,
|
||||
location=body.location,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/labeling/campaigns/{campaign_id}")
|
||||
def api_get_campaign(
|
||||
campaign_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
row = get_campaign(campaign_id)
|
||||
if not row:
|
||||
raise HTTPException(404, "campaign not found")
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/api/v1/labeling/campaigns/{campaign_id}/bootstrap")
|
||||
def api_campaign_bootstrap(
|
||||
campaign_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return campaign_bootstrap(campaign_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "campaign not found") from None
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.get("/api/v1/labeling/campaigns/{campaign_id}/progress")
|
||||
def api_campaign_progress(
|
||||
campaign_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return campaign_progress(campaign_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "campaign not found") from None
|
||||
|
||||
|
||||
@router.post("/api/v1/labeling/campaigns/{campaign_id}/assign-tasks")
|
||||
def api_assign_tasks(
|
||||
campaign_id: str,
|
||||
body: AssignTasksBody,
|
||||
user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
if not user_is_coordinator(user):
|
||||
raise HTTPException(403, "仅协调员可分配任务")
|
||||
try:
|
||||
if body.mode == "explicit":
|
||||
items = [{"user_id": i.user_id, "task_ids": i.task_ids} for i in (body.items or [])]
|
||||
return assign_tasks_explicit(campaign_id, items, assigned_by_user_id=user.id)
|
||||
if not body.user_ids:
|
||||
raise ValueError("even 模式需要 user_ids")
|
||||
return assign_tasks_even(campaign_id, body.user_ids, assigned_by_user_id=user.id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "campaign not found") from None
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.patch("/api/v1/labeling/campaigns/{campaign_id}/assignments/{task_id}")
|
||||
def api_reassign_task(
|
||||
campaign_id: str,
|
||||
task_id: str,
|
||||
body: ReassignTaskBody,
|
||||
user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
if not user_is_coordinator(user):
|
||||
raise HTTPException(403, "仅协调员可改派任务")
|
||||
try:
|
||||
return reassign_task(campaign_id, task_id, body.user_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "assignment not found") from None
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/api/v1/labeling/campaigns/{campaign_id}/assignments/{task_id}")
|
||||
def api_release_assignment(
|
||||
campaign_id: str,
|
||||
task_id: str,
|
||||
user: Annotated[User, Depends(require_any_permission("write:labeling_assign", "read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
if not user_is_coordinator(user):
|
||||
raise HTTPException(403, "仅协调员可释放任务")
|
||||
try:
|
||||
return release_task_assignment(campaign_id, task_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "assignment not found") from None
|
||||
|
||||
|
||||
@router.get("/api/v1/labeling/campaigns/{campaign_id}/tasks")
|
||||
def api_campaign_tasks(
|
||||
campaign_id: str,
|
||||
user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
assignee: str | None = Query(None),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
eff = assignee
|
||||
if eff is None and not user_is_coordinator(user):
|
||||
eff = "me"
|
||||
return campaign_tasks(campaign_id, offset=offset, limit=limit, user=user, assignee=eff)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "campaign not found") from None
|
||||
except PermissionError as e:
|
||||
raise HTTPException(403, str(e)) from e
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.get("/api/v1/labeling/media/{campaign_id}/{file_path:path}")
|
||||
def api_labeling_media(
|
||||
campaign_id: str,
|
||||
file_path: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
):
|
||||
try:
|
||||
target = resolve_media_file(campaign_id, file_path)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "not found") from None
|
||||
except PermissionError:
|
||||
raise HTTPException(403, "forbidden") from None
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return FileResponse(target)
|
||||
|
||||
|
||||
@router.get("/api/v1/labeling/campaigns/{campaign_id}/annotations/{task_id}")
|
||||
def api_get_annotation(
|
||||
campaign_id: str,
|
||||
task_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return get_annotation(campaign_id, task_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "campaign not found") from None
|
||||
|
||||
|
||||
@router.put("/api/v1/labeling/campaigns/{campaign_id}/annotations/{task_id}")
|
||||
def api_put_annotation(
|
||||
campaign_id: str,
|
||||
task_id: str,
|
||||
body: AnnotationBody,
|
||||
user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return save_annotation(campaign_id, task_id, body.model_dump(exclude_none=True), user=user)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "campaign not found") from None
|
||||
except PermissionError as e:
|
||||
raise HTTPException(403, str(e)) from e
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/api/v1/labeling/campaigns/{campaign_id}/export")
|
||||
def api_labeling_export(
|
||||
campaign_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return trigger_labeling_export(campaign_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "campaign not found") from None
|
||||
|
||||
|
||||
@router.get("/api/v1/labeling/campaigns/{campaign_id}/export-jobs")
|
||||
def api_campaign_export_jobs(
|
||||
campaign_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
limit: int = Query(30, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
if not get_campaign(campaign_id):
|
||||
raise HTTPException(404, "campaign not found")
|
||||
return list_campaign_export_jobs(campaign_id, limit=limit)
|
||||
|
||||
|
||||
@router.post("/api/v1/labeling/campaigns/{campaign_id}/submit")
|
||||
def api_campaign_submit(
|
||||
campaign_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return submit_campaign(campaign_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from None
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "campaign not found") from None
|
||||
|
||||
|
||||
@router.get("/api/v1/labeling/registry-profiles")
|
||||
def api_labeling_registry_profiles(
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
return list_registry_profiles()
|
||||
|
||||
|
||||
@router.post("/api/v1/labeling/campaigns/{campaign_id}/import-vendor")
|
||||
async def api_import_vendor(
|
||||
campaign_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("write:labeling_vendor"))],
|
||||
file: UploadFile = File(...),
|
||||
) -> dict[str, Any]:
|
||||
raw = await file.read()
|
||||
try:
|
||||
return import_vendor_zip(campaign_id, raw)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "campaign not found") from None
|
||||
except (zipfile.BadZipFile, ValueError) as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/api/v1/labeling/campaigns/{campaign_id}/tasks/{task_id}/lock")
|
||||
def api_labeling_lock_acquire(
|
||||
campaign_id: str,
|
||||
task_id: str,
|
||||
user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
if not get_campaign(campaign_id):
|
||||
raise HTTPException(404, "campaign not found")
|
||||
result = acquire_lock(campaign_id, task_id, user_id=user.id, user_name=user.name)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(409, detail=result)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/api/v1/labeling/campaigns/{campaign_id}/tasks/{task_id}/lock")
|
||||
def api_labeling_lock_release(
|
||||
campaign_id: str,
|
||||
task_id: str,
|
||||
user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
result = release_lock(campaign_id, task_id, user_id=user.id)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(409, detail=result)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/v1/labeling/campaigns/{campaign_id}/tasks/{task_id}/lock/renew")
|
||||
def api_labeling_lock_renew(
|
||||
campaign_id: str,
|
||||
task_id: str,
|
||||
user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, Any]:
|
||||
result = renew_lock(campaign_id, task_id, user_id=user.id)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(409, detail=result)
|
||||
return result
|
||||
|
||||
|
||||
# ── 标注质检 (Quality Review) ──
|
||||
|
||||
class ReviewScoreBody(BaseModel):
|
||||
image_path: str
|
||||
score: str # good / fine / bad
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class ReviewBatchBody(BaseModel):
|
||||
scores: list[ReviewScoreBody]
|
||||
|
||||
|
||||
@router.get("/api/v1/labeling/campaigns/{campaign_id}/review-queue")
|
||||
def api_review_queue(
|
||||
campaign_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("write:approval_review"))],
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.audit.review import get_review_queue
|
||||
return get_review_queue(campaign_id, offset=offset, limit=limit)
|
||||
|
||||
|
||||
@router.get("/api/v1/labeling/campaigns/{campaign_id}/review-image")
|
||||
def api_review_image(
|
||||
campaign_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("write:approval_review"))],
|
||||
path: str = Query(...),
|
||||
) -> FileResponse:
|
||||
from as_platform.audit.review import get_review_image
|
||||
import tempfile
|
||||
try:
|
||||
# Get class names from registry
|
||||
import yaml
|
||||
from as_platform.data.core import load_wf, proj_root
|
||||
wf = load_wf()
|
||||
root = proj_root(wf, "dms")
|
||||
reg = yaml.safe_load((root / wf["projects"]["dms"]["registry"]).read_text())
|
||||
# Build class_names dict from campaign scope
|
||||
class_names: dict[int, str] = {}
|
||||
# Try to get from the specific task
|
||||
tasks = reg.get("tasks", {})
|
||||
for task_cfg in tasks.values():
|
||||
names = task_cfg.get("names")
|
||||
if isinstance(names, list):
|
||||
for i, n in enumerate(names):
|
||||
class_names[i] = n
|
||||
|
||||
data = get_review_image(campaign_id, path, class_names)
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
tmp.write(data)
|
||||
tmp.close()
|
||||
return FileResponse(tmp.name, media_type="image/jpeg")
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/api/v1/labeling/campaigns/{campaign_id}/review-submit")
|
||||
def api_review_submit(
|
||||
campaign_id: str,
|
||||
body: ReviewBatchBody,
|
||||
user: Annotated[User, Depends(require_permission("write:approval_review"))],
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.audit.review import submit_review_scores
|
||||
items = [s.model_dump() for s in body.scores]
|
||||
return submit_review_scores(campaign_id, items, reviewer_user_id=user.id, reviewer_name=user.name)
|
||||
|
||||
|
||||
@router.get("/api/v1/labeling/campaigns/{campaign_id}/review-progress")
|
||||
def api_review_progress(
|
||||
campaign_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, int]:
|
||||
from as_platform.audit.review import review_progress
|
||||
return review_progress(campaign_id)
|
||||
155
platform/as_platform/api/models_routes.py
Normal file
155
platform/as_platform/api/models_routes.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""模型管理 API 路由 — 模块化前缀 /api/v1/models
|
||||
|
||||
本路由将 training/* 功能以模块化路径重新暴露,同时保留旧路径兼容。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from as_platform.auth.deps import can_submit_action, get_current_user, require_permission
|
||||
from as_platform.db.models import User
|
||||
from as_platform.data.versions import create_snapshot, diff_versions, get_version, list_versions
|
||||
from as_platform.training.service import (
|
||||
TRAINING_ACTIONS,
|
||||
create_training_submission,
|
||||
get_model_registry,
|
||||
get_training_record,
|
||||
list_training_records,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/models", tags=["models"])
|
||||
|
||||
|
||||
class CreateTrainingBody(BaseModel):
|
||||
action: str
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
note: str | None = None
|
||||
|
||||
|
||||
@router.get("/actions")
|
||||
def api_models_actions(_user: Annotated[User, Depends(require_permission("read:jobs"))]) -> dict[str, Any]:
|
||||
"""可用的训练/评估/晋级操作列表"""
|
||||
from as_platform.audit.queue import ACTION_LABELS
|
||||
|
||||
return {
|
||||
"actions": [
|
||||
{"id": action, "label": ACTION_LABELS.get(action, action)}
|
||||
for action in sorted(TRAINING_ACTIONS)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/registry")
|
||||
def api_models_registry(
|
||||
_user: Annotated[User, Depends(require_permission("read:jobs"))],
|
||||
project: str = Query("dms"),
|
||||
task: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""模型注册表"""
|
||||
return get_model_registry(project=project, task=task)
|
||||
|
||||
|
||||
@router.get("/records")
|
||||
def api_models_records(
|
||||
_user: Annotated[User, Depends(require_permission("read:jobs"))],
|
||||
project: str | None = None,
|
||||
kind: str | None = None,
|
||||
status: str | None = None,
|
||||
task: str | None = None,
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
"""训练/评估记录列表"""
|
||||
return list_training_records(
|
||||
project=project, kind=kind, status=status, task=task, offset=offset, limit=limit
|
||||
)
|
||||
|
||||
|
||||
@router.get("/records/{record_id}")
|
||||
def api_models_record(
|
||||
record_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:jobs"))],
|
||||
) -> dict[str, Any]:
|
||||
"""单条训练记录详情"""
|
||||
rec = get_training_record(record_id)
|
||||
if not rec:
|
||||
raise HTTPException(404, "训练记录不存在")
|
||||
return rec
|
||||
|
||||
|
||||
@router.post("/records")
|
||||
def api_models_create_record(
|
||||
body: CreateTrainingBody,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
) -> dict[str, Any]:
|
||||
"""提交训练/评估/晋级申请"""
|
||||
if not can_submit_action(user, body.action):
|
||||
raise HTTPException(403, f"无权提交: {body.action}")
|
||||
try:
|
||||
return create_training_submission(
|
||||
body.action,
|
||||
body.params,
|
||||
submitted_by=user.name,
|
||||
submitted_by_user_id=user.id,
|
||||
note=body.note,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
# ── 数据集版本管理 ──
|
||||
|
||||
class CreateSnapshotBody(BaseModel):
|
||||
project: str = "dms"
|
||||
description: str = ""
|
||||
|
||||
|
||||
@router.get("/datasets")
|
||||
def api_list_dataset_versions(
|
||||
_user: Annotated[User, Depends(require_permission("read:jobs"))],
|
||||
project: str = Query("dms"),
|
||||
) -> dict[str, Any]:
|
||||
return {"items": list_versions(project)}
|
||||
|
||||
|
||||
@router.post("/datasets/snapshot")
|
||||
def api_create_snapshot(
|
||||
body: CreateSnapshotBody,
|
||||
user: Annotated[User, Depends(require_permission("write:approval_submit"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
result = create_snapshot(body.project, body.description, author=user.name)
|
||||
from as_platform.audit.log_utils import log_op
|
||||
log_op(user_id=user.id, user_name=user.name, category="data", action="create_snapshot",
|
||||
target_type="snapshot", target_id=result.get("version_id"), summary=f"创建快照: {result.get('version_id')} ({body.project})")
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e)) from e
|
||||
|
||||
|
||||
@router.get("/datasets/{version_id}")
|
||||
def api_get_version(
|
||||
version_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:jobs"))],
|
||||
project: str = Query("dms"),
|
||||
) -> dict[str, Any]:
|
||||
v = get_version(project, version_id)
|
||||
if not v:
|
||||
raise HTTPException(404, f"版本 {version_id} 不存在")
|
||||
return v
|
||||
|
||||
|
||||
@router.get("/datasets/{version_id}/diff")
|
||||
def api_diff_versions(
|
||||
version_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:jobs"))],
|
||||
compare: str = Query(...),
|
||||
project: str = Query("dms"),
|
||||
) -> dict[str, Any]:
|
||||
result = diff_versions(project, compare, version_id)
|
||||
if "error" in result:
|
||||
raise HTTPException(400, result["error"])
|
||||
return result
|
||||
@@ -23,6 +23,12 @@ from as_platform.agents.graphs.train_promote_flow import run_train_promote_flow
|
||||
from as_platform.agents.tools import TOOL_REGISTRY, invoke_tool
|
||||
from as_platform.agents.trace import get_trace, list_traces
|
||||
from as_platform.api.auth_routes import router as auth_router
|
||||
from as_platform.api.fleet_routes import router as fleet_router
|
||||
from as_platform.api.delivery_routes import router as delivery_router
|
||||
from as_platform.api.feishu_routes import router as feishu_router
|
||||
from as_platform.api.labeling_routes import router as labeling_router
|
||||
from as_platform.api.models_routes import router as models_router
|
||||
from as_platform.api.system_routes import router as system_router
|
||||
from as_platform.audit.queue import (
|
||||
ACTION_LABELS,
|
||||
ACTIONS_REQUIRING_APPROVAL,
|
||||
@@ -34,7 +40,16 @@ from as_platform.audit.queue import (
|
||||
)
|
||||
from as_platform.audit.preview import find_image_ref, list_scope_images, render_overlay, resolve_approval_scope
|
||||
from as_platform.auth.deps import can_submit_action, get_current_user, require_any_permission, require_permission
|
||||
from as_platform.config import IS_POSTGRES, PLATFORM_DIR, PLATFORM_WEB, WORKSPACE
|
||||
from as_platform.config import (
|
||||
FEISHU_BITABLE_SYNC_ENABLED,
|
||||
FEISHU_BITABLE_SYNC_INTERVAL_SEC,
|
||||
FLEET_MOCK_SIMULATE,
|
||||
FLEET_SIM_INTERVAL_SEC,
|
||||
IS_POSTGRES,
|
||||
PLATFORM_DIR,
|
||||
PLATFORM_WEB,
|
||||
WORKSPACE,
|
||||
)
|
||||
from as_platform.data.core import get_catalog, get_pending_report, register_batch, warmup_catalog_cache
|
||||
from as_platform.data.ingest import UnknownFormatError, inspect_uploaded_dataset
|
||||
from as_platform.data.lake import (
|
||||
@@ -42,10 +57,11 @@ from as_platform.data.lake import (
|
||||
get_candidate,
|
||||
link_candidate_analysis_job,
|
||||
list_candidates as list_data_candidates,
|
||||
promote_candidate_to_inbox,
|
||||
write_candidate_upload,
|
||||
)
|
||||
from as_platform.data.organize import organize_batch
|
||||
from as_platform.db.engine import check_connection
|
||||
from as_platform.db.engine import check_connection, session_scope
|
||||
from as_platform.db.init_db import init_database
|
||||
from as_platform.db.models import User
|
||||
from as_platform.jobs.queue import enqueue_job, get_job, list_jobs
|
||||
@@ -59,16 +75,51 @@ from as_platform.training.service import (
|
||||
)
|
||||
|
||||
|
||||
def _feishu_bitable_sync_loop() -> None:
|
||||
import time
|
||||
|
||||
from as_platform.jobs.feishu_bitable_sync import run_sync_cycle
|
||||
|
||||
while True:
|
||||
time.sleep(FEISHU_BITABLE_SYNC_INTERVAL_SEC)
|
||||
try:
|
||||
run_sync_cycle()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _fleet_sim_loop() -> None:
|
||||
import time
|
||||
from as_platform.fleet import service as fleet_svc
|
||||
while True:
|
||||
time.sleep(max(3, FLEET_SIM_INTERVAL_SEC))
|
||||
try:
|
||||
with session_scope() as db:
|
||||
fleet_svc.simulate_tick(db)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
init_database()
|
||||
threading.Thread(target=warmup_catalog_cache, daemon=True, name="catalog-warmup").start()
|
||||
if FLEET_MOCK_SIMULATE:
|
||||
threading.Thread(target=_fleet_sim_loop, daemon=True, name="fleet-sim",).start()
|
||||
if FEISHU_BITABLE_SYNC_ENABLED:
|
||||
threading.Thread(target=_feishu_bitable_sync_loop, daemon=True, name="feishu-bitable-sync").start()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="华胥智能主动安全平台", version="1.1.0", lifespan=lifespan)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||
app.include_router(auth_router)
|
||||
app.include_router(fleet_router)
|
||||
app.include_router(labeling_router)
|
||||
app.include_router(feishu_router)
|
||||
app.include_router(delivery_router)
|
||||
app.include_router(models_router) # 模型管理: /api/v1/models/*
|
||||
app.include_router(system_router) # 系统管理: /api/v1/system/*
|
||||
_UI_DIR: Path | None = None
|
||||
|
||||
|
||||
@@ -152,6 +203,19 @@ def api_pending(_user: Annotated[User, Depends(require_permission("read:pending"
|
||||
return get_pending_report()
|
||||
|
||||
|
||||
@app.get("/api/v1/pending/gates")
|
||||
def api_pending_gates(_user: Annotated[User, Depends(require_permission("read:pending"))]) -> dict[str, Any]:
|
||||
"""ML 自动化 P0:build 门禁与 manifest 对齐说明。"""
|
||||
return {
|
||||
"build_validate": (
|
||||
"python as.py build dms <task> <pack> 入库后默认执行 scripts/validate_dms_tasks.py;"
|
||||
"仅调试可用 --skip-validate"
|
||||
),
|
||||
"manifest_smoke": "bash HSAP/scripts/smoke_manifest_alignment.sh",
|
||||
"pending_cli": "python as.py pending",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/catalog")
|
||||
def api_catalog(
|
||||
_user: Annotated[User, Depends(require_permission("read:catalog"))],
|
||||
@@ -193,9 +257,10 @@ def api_actions(_user: Annotated[User, Depends(require_permission("read:audit"))
|
||||
def api_jobs(
|
||||
_user: Annotated[User, Depends(require_permission("read:jobs"))],
|
||||
status: str | None = None,
|
||||
limit: int = Query(100, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
return {"items": list_jobs(status=status, limit=limit)}
|
||||
return list_jobs(status=status, offset=offset, limit=limit)
|
||||
|
||||
|
||||
@app.get("/api/v1/jobs/{job_id}")
|
||||
@@ -223,9 +288,12 @@ def api_training_records(
|
||||
kind: str | None = None,
|
||||
status: str | None = None,
|
||||
task: str | None = None,
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
return list_training_records(project=project, kind=kind, status=status, task=task, limit=limit)
|
||||
return list_training_records(
|
||||
project=project, kind=kind, status=status, task=task, offset=offset, limit=limit
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/training/records/{job_id}")
|
||||
@@ -310,9 +378,10 @@ def api_agent_invoke(body: AgentInvokeBody, _user: Annotated[User, Depends(get_c
|
||||
def api_list_approvals(
|
||||
_user: Annotated[User, Depends(require_permission("read:audit"))],
|
||||
status: str | None = None,
|
||||
limit: int = Query(100, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
return {"items": list_approvals(status=status, limit=limit)}
|
||||
return list_approvals(status=status, offset=offset, limit=limit)
|
||||
|
||||
|
||||
@app.get("/api/v1/approvals/{record_id}")
|
||||
@@ -346,13 +415,29 @@ def api_approval_preview(
|
||||
"exists": batch_dir.is_dir(),
|
||||
}
|
||||
)
|
||||
params = rec.get("params") or {}
|
||||
title = str(rec.get("action_label") or rec.get("action") or record_id)
|
||||
summary = ""
|
||||
if rec.get("action") == "delivery_ingest":
|
||||
title = f"数据送标入湖 · {params.get('batch_name') or '—'}"
|
||||
parts = [
|
||||
f"项目 {params.get('project') or 'dms'}",
|
||||
f"任务 {params.get('task') or '—'}",
|
||||
f"路径 {params.get('data_path') or '—'}",
|
||||
]
|
||||
if params.get("estimated_count"):
|
||||
parts.append(f"约 {params['estimated_count']} 张")
|
||||
summary = " · ".join(parts)
|
||||
return {
|
||||
"approval": rec,
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
"scope_label": scope.get("scope_label"),
|
||||
"task": scope.get("task"),
|
||||
"pack": scope.get("pack"),
|
||||
"class_names": scope.get("class_names"),
|
||||
"batches": batch_summaries,
|
||||
"delivery": params if rec.get("action") == "delivery_ingest" else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -456,22 +541,29 @@ def api_reject(record_id: str, body: ReviewBody, user: Annotated[User, Depends(r
|
||||
|
||||
@app.post("/api/v1/register-batch")
|
||||
def api_register_batch(body: RegisterBatchBody, user: Annotated[User, Depends(get_current_user)]) -> dict[str, Any]:
|
||||
result = None
|
||||
if body.skip_audit:
|
||||
if not can_submit_action(user, "register_batch"):
|
||||
raise HTTPException(403, "无权登记批次")
|
||||
try:
|
||||
return register_batch(None, body.project, body.task, body.batch, pack=body.pack, stage=body.stage, engineer=body.engineer, location=body.location)
|
||||
result = register_batch(None, body.project, body.task, body.batch, pack=body.pack, stage=body.stage, engineer=body.engineer, location=body.location)
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
if not can_submit_action(user, "register_batch"):
|
||||
raise HTTPException(403, "无权提交登记审核")
|
||||
return submit_approval(
|
||||
"register_batch",
|
||||
body.model_dump(exclude={"submitted_by", "skip_audit"}),
|
||||
submitted_by=user.name,
|
||||
submitted_by_user_id=user.id,
|
||||
note="登记 batch.meta",
|
||||
)
|
||||
else:
|
||||
if not can_submit_action(user, "register_batch"):
|
||||
raise HTTPException(403, "无权提交登记审核")
|
||||
result = submit_approval(
|
||||
"register_batch",
|
||||
body.model_dump(exclude={"submitted_by", "skip_audit"}),
|
||||
submitted_by=user.name,
|
||||
submitted_by_user_id=user.id,
|
||||
note="登记 batch.meta",
|
||||
)
|
||||
# 审计日志
|
||||
from as_platform.audit.log_utils import log_op
|
||||
log_op(user_id=user.id, user_name=user.name, category="data", action="register_batch",
|
||||
target_type="batch", target_id=f"{body.project}/{body.task}/{body.batch}", summary=f"登记批次: {body.project}/{body.task}/{body.batch} → {body.stage}")
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/v1/data/organize")
|
||||
@@ -498,6 +590,7 @@ async def api_upload_file(
|
||||
project: Annotated[str, Form()],
|
||||
user: Annotated[User, Depends(require_any_permission("write:approval_submit", "write:approval_submit:register"))],
|
||||
task: Annotated[str | None, Form()] = None,
|
||||
mode: Annotated[str | None, Form()] = None,
|
||||
file: UploadFile = File(...),
|
||||
) -> dict[str, Any]:
|
||||
if not file.filename:
|
||||
@@ -506,6 +599,7 @@ async def api_upload_file(
|
||||
candidate = create_uploaded_candidate(
|
||||
project=project,
|
||||
task=task,
|
||||
mode=mode,
|
||||
original_name=file.filename,
|
||||
upload_size_bytes=0,
|
||||
submitted_by_name=user.name if user else None,
|
||||
@@ -525,9 +619,10 @@ async def api_upload_file(
|
||||
@app.get("/api/v1/data/candidates")
|
||||
def api_data_candidates(
|
||||
_user: Annotated[User, Depends(require_permission("read:catalog"))],
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
return {"items": list_data_candidates(limit=limit)}
|
||||
return list_data_candidates(offset=offset, limit=limit)
|
||||
|
||||
|
||||
@app.get("/api/v1/data/candidates/{candidate_id}")
|
||||
@@ -538,18 +633,272 @@ def api_data_candidate(candidate_id: str, _user: Annotated[User, Depends(require
|
||||
return item
|
||||
|
||||
|
||||
class PromoteInboxBody(BaseModel):
|
||||
batch: str | None = None
|
||||
mode: str | None = None
|
||||
|
||||
|
||||
@app.post("/api/v1/data/candidates/{candidate_id}/promote-inbox")
|
||||
def api_promote_candidate_inbox(
|
||||
candidate_id: str,
|
||||
body: PromoteInboxBody,
|
||||
_user: Annotated[User, Depends(require_any_permission("write:approval_submit", "write:approval_submit:register"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return promote_candidate_to_inbox(candidate_id, batch=body.batch, mode=body.mode)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e)) from e
|
||||
|
||||
|
||||
@app.get("/api/v1/data/registry-tasks")
|
||||
def api_registry_tasks(
|
||||
_user: Annotated[User, Depends(require_permission("read:catalog"))],
|
||||
project: str = Query("dms"),
|
||||
) -> dict[str, Any]:
|
||||
import yaml
|
||||
|
||||
from as_platform.data.core import load_wf, proj_root
|
||||
|
||||
wf = load_wf()
|
||||
if project != "dms":
|
||||
return {"project": project, "tasks": {}}
|
||||
root = proj_root(wf, "dms")
|
||||
reg_path = root / wf["projects"]["dms"]["registry"]
|
||||
if not reg_path.is_file():
|
||||
return {"project": project, "tasks": {}}
|
||||
reg = yaml.safe_load(reg_path.read_text(encoding="utf-8"))
|
||||
import sys
|
||||
|
||||
scripts = WORKSPACE / "datasets" / "dms" / "scripts"
|
||||
if str(scripts) not in sys.path:
|
||||
sys.path.insert(0, str(scripts))
|
||||
from task_registry import task_defs_for_pending
|
||||
|
||||
return {"project": project, "tasks": task_defs_for_pending(reg)}
|
||||
|
||||
|
||||
@app.get("/api/v1/data/scan-inbox")
|
||||
def api_scan_inbox(
|
||||
_user: Annotated[User, Depends(require_permission("read:catalog"))],
|
||||
project: str = Query("dms"),
|
||||
) -> dict[str, Any]:
|
||||
"""扫描 inbox 目录,返回未登记的新批次。"""
|
||||
from as_platform.data.core import get_pending_report, load_wf, proj_root
|
||||
|
||||
wf = load_wf()
|
||||
root = proj_root(wf, project)
|
||||
inbox = root / "inbox"
|
||||
if not inbox.is_dir():
|
||||
return {"project": project, "items": [], "inbox_path": str(inbox)}
|
||||
|
||||
report = get_pending_report()
|
||||
registered = {b.get("batch", "") for b in report.get("batches", [])}
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for task_dir in sorted(inbox.iterdir()):
|
||||
if not task_dir.is_dir():
|
||||
continue
|
||||
for batch_dir in sorted(task_dir.iterdir()):
|
||||
if not batch_dir.is_dir():
|
||||
continue
|
||||
batch_name = batch_dir.name
|
||||
task_name = task_dir.name
|
||||
if batch_name in registered:
|
||||
continue # 已登记
|
||||
|
||||
# Count images
|
||||
img_count = 0
|
||||
lbl_count = 0
|
||||
has_labels = False
|
||||
for ext in ["*.jpg", "*.jpeg", "*.png", "*.bmp"]:
|
||||
for _ in batch_dir.glob(ext):
|
||||
img_count += 1
|
||||
if (batch_dir / "labels").is_dir():
|
||||
has_labels = True
|
||||
for ext in ["*.txt", "*.json"]:
|
||||
for _ in (batch_dir / "labels").glob(ext):
|
||||
lbl_count += 1
|
||||
|
||||
items.append({
|
||||
"project": project,
|
||||
"task": task_name,
|
||||
"batch": batch_name,
|
||||
"path": str(batch_dir.relative_to(root)),
|
||||
"images": img_count,
|
||||
"labels": lbl_count,
|
||||
"has_labels": has_labels,
|
||||
"stage_hint": "returned" if has_labels and lbl_count > 0 else "raw_pool",
|
||||
})
|
||||
|
||||
return {"project": project, "items": items, "inbox_path": str(inbox)}
|
||||
|
||||
|
||||
@app.get("/api/v1/dashboard")
|
||||
def api_dashboard(_user: Annotated[User, Depends(get_current_user)]) -> dict[str, Any]:
|
||||
"""首页仪表盘聚合数据。"""
|
||||
from as_platform.data.core import get_pending_report, get_catalog
|
||||
from as_platform.jobs.queue import list_jobs
|
||||
|
||||
# 批次统计
|
||||
report = get_pending_report()
|
||||
batches = report.get("batches", []) or []
|
||||
stage_counts: dict[str, int] = {"raw_pool": 0, "out_for_labeling": 0, "labeling_submitted": 0, "returned": 0, "ingested": 0}
|
||||
for b in batches:
|
||||
s = b.get("stage", "raw_pool") if isinstance(b, dict) else "raw_pool"
|
||||
stage_counts[s] = stage_counts.get(s, 0) + 1
|
||||
|
||||
# 审核统计
|
||||
approvals_pending = list_approvals(status="pending", limit=200)
|
||||
pending_approvals = approvals_pending.get("total", 0)
|
||||
|
||||
# Job 统计
|
||||
jobs_data = list_jobs(limit=5)
|
||||
jobs = jobs_data.get("items", []) or []
|
||||
running_jobs = len([j for j in jobs if isinstance(j, dict) and j.get("status") == "running"])
|
||||
|
||||
# 模型统计
|
||||
try:
|
||||
from as_platform.training.service import get_model_registry
|
||||
models = get_model_registry(project="dms")
|
||||
model_count = len((models.get("models") or []) if isinstance(models, dict) else [])
|
||||
except Exception:
|
||||
model_count = 0
|
||||
|
||||
# 训练记录
|
||||
try:
|
||||
from as_platform.training.service import list_training_records
|
||||
records = list_training_records(limit=5)
|
||||
recent_records = (records.get("items") or [])[:5] if isinstance(records, dict) else []
|
||||
except Exception:
|
||||
recent_records = []
|
||||
|
||||
# 车队
|
||||
try:
|
||||
from as_platform.fleet import service as fleet_svc
|
||||
from as_platform.db.engine import session_scope
|
||||
with session_scope() as db:
|
||||
summary = fleet_svc.get_summary(db) if hasattr(fleet_svc, "get_summary") else {}
|
||||
except Exception:
|
||||
summary = {}
|
||||
|
||||
# 最近活动
|
||||
activity: list[dict[str, Any]] = []
|
||||
for j in jobs[:5]:
|
||||
if isinstance(j, dict):
|
||||
activity.append({"type": "job", "id": j.get("id"), "action": j.get("action"), "status": j.get("status"), "time": j.get("created_at")})
|
||||
for a in (approvals_pending.get("items") or [])[:3]:
|
||||
if isinstance(a, dict):
|
||||
activity.append({"type": "approval", "id": a.get("id"), "action": a.get("action_label") or a.get("action"), "status": a.get("status"), "time": a.get("submitted_at")})
|
||||
|
||||
activity.sort(key=lambda x: str(x.get("time") or ""), reverse=True)
|
||||
|
||||
return {
|
||||
"stages": stage_counts,
|
||||
"total_batches": len(batches),
|
||||
"pending_approvals": pending_approvals,
|
||||
"running_jobs": running_jobs,
|
||||
"model_count": model_count,
|
||||
"fleet": summary,
|
||||
"activity": activity[:8],
|
||||
"recent_training": [{"id": r.get("id"), "action": r.get("action"), "status": r.get("status"), "created_at": r.get("created_at")} for r in recent_records if isinstance(r, dict)],
|
||||
}
|
||||
|
||||
|
||||
# ── 世界模型仿真 ──
|
||||
|
||||
class SimulateBody(BaseModel):
|
||||
scene: str = "urban_highway"
|
||||
camera: str = "truck_front"
|
||||
weather: str = "clear"
|
||||
objects: list[str] = Field(default_factory=lambda: ["Pedestrain", "Car", "Truck", "Bus"])
|
||||
density: str = "medium"
|
||||
count: int = 100
|
||||
fov_variant: bool = False
|
||||
note: str = ""
|
||||
|
||||
|
||||
@app.get("/api/v1/simulate/jobs")
|
||||
def api_simulate_jobs(
|
||||
_user: Annotated[User, Depends(get_current_user)],
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.data.simulate import list_jobs
|
||||
return list_jobs(offset=offset, limit=limit)
|
||||
|
||||
|
||||
@app.post("/api/v1/simulate/generate")
|
||||
def api_simulate_generate(
|
||||
body: SimulateBody,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.data.simulate import submit_job
|
||||
return submit_job(body.model_dump(), user_name=user.name)
|
||||
|
||||
|
||||
@app.get("/api/v1/simulate/jobs/{job_id}")
|
||||
def api_simulate_job(
|
||||
job_id: str,
|
||||
_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.data.simulate import get_job
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job 不存在")
|
||||
return job
|
||||
|
||||
|
||||
@app.get("/api/v1/simulate/jobs/{job_id}/images")
|
||||
def api_simulate_job_images(
|
||||
job_id: str,
|
||||
_user: Annotated[User, Depends(get_current_user)],
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(60, ge=1, le=200),
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.data.simulate import get_job_images
|
||||
return get_job_images(job_id, offset=offset, limit=limit)
|
||||
|
||||
|
||||
@app.post("/api/v1/simulate/jobs/{job_id}/ingest")
|
||||
def api_simulate_ingest(
|
||||
job_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
task: str = Query("adas"),
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.data.simulate import ingest_job_to_batch
|
||||
return ingest_job_to_batch(job_id, task=task, user_name=user.name)
|
||||
|
||||
|
||||
def _mount_ui() -> None:
|
||||
global _UI_DIR
|
||||
for ui in (PLATFORM_WEB, PLATFORM_DIR / "web" / "dist"):
|
||||
if (ui / "index.html").is_file():
|
||||
_UI_DIR = ui
|
||||
app.mount("/assets", StaticFiles(directory=str(ui / "assets")), name="ui-assets")
|
||||
return
|
||||
ui = PLATFORM_WEB
|
||||
if (ui / "index.html").is_file():
|
||||
_UI_DIR = ui
|
||||
assets = ui / "assets"
|
||||
if assets.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=str(assets)), name="ui-assets")
|
||||
annotate = ui / "annotate"
|
||||
if annotate.is_dir():
|
||||
app.mount("/annotate", StaticFiles(directory=str(annotate), html=True), name="ui-annotate")
|
||||
return
|
||||
|
||||
|
||||
_mount_ui()
|
||||
|
||||
|
||||
@app.get("/labeling/campaigns/{campaign_id}/annotate", include_in_schema=False)
|
||||
def serve_annotate_app(campaign_id: str):
|
||||
"""标注编辑器页面 — 返回旧 Label Studio 构建产物"""
|
||||
if not _UI_DIR:
|
||||
raise HTTPException(404, "UI not built")
|
||||
annotate_index = _UI_DIR / "annotate" / "index.html"
|
||||
if annotate_index.is_file():
|
||||
return FileResponse(annotate_index)
|
||||
raise HTTPException(404, "标注编辑器未构建")
|
||||
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
def spa_fallback(full_path: str):
|
||||
if full_path.startswith("api/"):
|
||||
|
||||
381
platform/as_platform/api/system_routes.py
Normal file
381
platform/as_platform/api/system_routes.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""系统管理 API 路由 — 模块化前缀 /api/v1/system
|
||||
|
||||
本路由将 audit/jobs/traces/agents 功能以模块化路径重新暴露。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from as_platform.agents.tools import TOOL_REGISTRY, invoke_tool
|
||||
from as_platform.agents.trace import get_trace, list_traces
|
||||
from as_platform.audit.queue import (
|
||||
ACTION_LABELS,
|
||||
ACTIONS_REQUIRING_APPROVAL,
|
||||
REJECTION_CATEGORIES,
|
||||
approve_and_execute,
|
||||
batch_approve,
|
||||
batch_reject,
|
||||
get_approval,
|
||||
list_approvals,
|
||||
reject_approval,
|
||||
submit_approval,
|
||||
)
|
||||
from as_platform.audit.preview import find_image_ref, list_scope_images, resolve_approval_scope
|
||||
from as_platform.auth.deps import can_submit_action, get_current_user, require_any_permission, require_permission
|
||||
from as_platform.auth.feishu import sync_feishu_users_to_db
|
||||
from as_platform.auth.users import list_users, list_users_paginated, set_user_roles
|
||||
from as_platform.db.engine import get_db
|
||||
from as_platform.db.init_db import user_to_dict
|
||||
from as_platform.db.models import User
|
||||
from as_platform.jobs.queue import enqueue_job, get_job, list_jobs
|
||||
|
||||
router = APIRouter(prefix="/api/v1/system", tags=["system"])
|
||||
|
||||
|
||||
# ── Audit / Approvals ──
|
||||
|
||||
@router.get("/audit")
|
||||
def api_system_approvals(
|
||||
_user: Annotated[User, Depends(require_permission("read:audit"))],
|
||||
status: str | None = None,
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
return list_approvals(status=status, offset=offset, limit=limit)
|
||||
|
||||
|
||||
@router.get("/audit/actions")
|
||||
def api_system_actions(_user: Annotated[User, Depends(require_permission("read:audit"))]) -> dict[str, Any]:
|
||||
return {"actions": [{"id": k, "label": ACTION_LABELS.get(k, k)} for k in sorted(ACTIONS_REQUIRING_APPROVAL)]}
|
||||
|
||||
|
||||
class SubmitApprovalBody(BaseModel):
|
||||
action: str
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class ReviewBody(BaseModel):
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class RejectBody(BaseModel):
|
||||
comment: str | None = None
|
||||
rejection_category: str = ""
|
||||
|
||||
|
||||
class BatchReviewBody(BaseModel):
|
||||
ids: list[str] = Field(default_factory=list)
|
||||
comment: str | None = None
|
||||
rejection_category: str = ""
|
||||
|
||||
|
||||
@router.post("/audit/submit")
|
||||
def api_system_submit_approval(
|
||||
body: SubmitApprovalBody,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
) -> dict[str, Any]:
|
||||
if not can_submit_action(user, body.action):
|
||||
raise HTTPException(403, f"无权提交: {body.action}")
|
||||
try:
|
||||
return submit_approval(
|
||||
body.action, body.params,
|
||||
submitted_by=user.name,
|
||||
submitted_by_user_id=user.id,
|
||||
note=body.note,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.get("/audit/{record_id}")
|
||||
def api_system_get_approval(
|
||||
record_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:audit"))],
|
||||
) -> dict[str, Any]:
|
||||
rec = get_approval(record_id)
|
||||
if not rec:
|
||||
raise HTTPException(404, "审核单不存在")
|
||||
return rec
|
||||
|
||||
|
||||
@router.get("/audit/{record_id}/preview")
|
||||
def api_system_approval_preview(
|
||||
record_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:audit"))],
|
||||
) -> dict[str, Any]:
|
||||
rec = get_approval(record_id)
|
||||
if not rec:
|
||||
raise HTTPException(404, "审核单不存在")
|
||||
try:
|
||||
scope = resolve_approval_scope(rec["action"], rec.get("params") or {})
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {
|
||||
"approval": rec,
|
||||
"scope_label": scope.get("scope_label"),
|
||||
"task": scope.get("task"),
|
||||
"pack": scope.get("pack"),
|
||||
"class_names": scope.get("class_names"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/audit/{record_id}/images")
|
||||
def api_system_approval_images(
|
||||
record_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:audit"))],
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(60, ge=1, le=200),
|
||||
) -> dict[str, Any]:
|
||||
rec = get_approval(record_id)
|
||||
if not rec:
|
||||
raise HTTPException(404, "审核单不存在")
|
||||
try:
|
||||
scope = resolve_approval_scope(rec["action"], rec.get("params") or {})
|
||||
return list_scope_images(scope, offset=offset, limit=limit)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/audit/{record_id}/approve")
|
||||
def api_system_approve(
|
||||
record_id: str,
|
||||
body: ReviewBody,
|
||||
user: Annotated[User, Depends(require_permission("write:approval_review"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return approve_and_execute(
|
||||
record_id,
|
||||
reviewed_by=user.name,
|
||||
reviewed_by_user_id=user.id,
|
||||
comment=body.comment,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.get("/audit/rejection-categories")
|
||||
def api_rejection_categories(
|
||||
_user: Annotated[User, Depends(require_permission("read:audit"))],
|
||||
) -> dict[str, Any]:
|
||||
return {"categories": [{"key": k, "label": v} for k, v in REJECTION_CATEGORIES.items()]}
|
||||
|
||||
|
||||
@router.post("/audit/batch-approve")
|
||||
def api_system_batch_approve(
|
||||
body: BatchReviewBody,
|
||||
user: Annotated[User, Depends(require_permission("write:approval_review"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return batch_approve(body.ids, reviewed_by=user.name, reviewed_by_user_id=user.id)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/audit/batch-reject")
|
||||
def api_system_batch_reject(
|
||||
body: BatchReviewBody,
|
||||
user: Annotated[User, Depends(require_permission("write:approval_review"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return batch_reject(body.ids, reviewed_by=user.name, reviewed_by_user_id=user.id,
|
||||
comment=body.comment, rejection_category=body.rejection_category)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/audit/{record_id}/reject")
|
||||
def api_system_reject(
|
||||
record_id: str,
|
||||
body: RejectBody,
|
||||
user: Annotated[User, Depends(require_permission("write:approval_review"))],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return reject_approval(
|
||||
record_id,
|
||||
reviewed_by=user.name,
|
||||
reviewed_by_user_id=user.id,
|
||||
comment=body.comment,
|
||||
rejection_category=body.rejection_category,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
# ── Jobs ──
|
||||
|
||||
@router.get("/jobs")
|
||||
def api_system_jobs(
|
||||
_user: Annotated[User, Depends(require_permission("read:jobs"))],
|
||||
status: str | None = None,
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
return list_jobs(status=status, offset=offset, limit=limit)
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}")
|
||||
def api_system_job(
|
||||
job_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:jobs"))],
|
||||
) -> dict[str, Any]:
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job 不存在")
|
||||
return job
|
||||
|
||||
|
||||
# ── Traces / Execution Logs ──
|
||||
|
||||
@router.get("/traces")
|
||||
def api_system_traces(
|
||||
_user: Annotated[User, Depends(get_current_user)],
|
||||
limit: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
return {"trace_ids": list_traces(limit=limit)}
|
||||
|
||||
|
||||
@router.get("/traces/{trace_id}")
|
||||
def api_system_trace(
|
||||
trace_id: str,
|
||||
_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> dict[str, Any]:
|
||||
spans = get_trace(trace_id)
|
||||
if not spans:
|
||||
raise HTTPException(404, "Trace 不存在")
|
||||
return {"trace_id": trace_id, "spans": spans}
|
||||
|
||||
|
||||
# ── Agent Tools ──
|
||||
|
||||
@router.get("/agents/tools")
|
||||
def api_system_tools(
|
||||
_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> dict[str, Any]:
|
||||
return {"tools": list(TOOL_REGISTRY.keys())}
|
||||
|
||||
|
||||
class ToolInvokeBody(BaseModel):
|
||||
tool: str
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.post("/agents/tools/invoke")
|
||||
def api_system_tool_invoke(
|
||||
body: ToolInvokeBody,
|
||||
_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return {"result": invoke_tool(body.tool, **body.params)}
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
# ── User Management ──
|
||||
|
||||
class SetRolesBody(BaseModel):
|
||||
roles: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
def api_system_users(
|
||||
_user: Annotated[User, Depends(require_permission("admin:users"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
search: str = Query(""),
|
||||
role: str = Query(""),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
users, total = list_users_paginated(db, search=search, role_code=role, offset=offset, limit=limit)
|
||||
return {"items": [user_to_dict(u) for u in users], "total": total}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/roles")
|
||||
def api_system_set_roles(
|
||||
user_id: int,
|
||||
body: SetRolesBody,
|
||||
_user: Annotated[User, Depends(require_permission("admin:users"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
user = set_user_roles(db, user_id, body.roles)
|
||||
if not user:
|
||||
raise HTTPException(404, "用户不存在")
|
||||
return {"ok": True, "user": user_to_dict(user)}
|
||||
|
||||
|
||||
@router.post("/feishu/sync-users")
|
||||
def api_sync_feishu_users(
|
||||
_user: Annotated[User, Depends(require_permission("admin:users"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
result = sync_feishu_users_to_db(db)
|
||||
db.commit()
|
||||
# 审计日志
|
||||
from as_platform.audit.log_utils import log_op
|
||||
log_op(user_id=_user.id, user_name=_user.name, category="system", action="sync_feishu_users",
|
||||
target_type="user", summary=f"飞书用户同步: 新增{result.get('created',0)} 更新{result.get('updated',0)}")
|
||||
return {"ok": True, **result}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"飞书用户同步失败: {e}") from e
|
||||
|
||||
|
||||
# ── Audit Log ──
|
||||
|
||||
@router.get("/audit-log")
|
||||
def api_audit_log(
|
||||
_user: Annotated[User, Depends(require_permission("admin:users"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
user_id: int | None = None,
|
||||
category: str | None = None,
|
||||
action: str | None = None,
|
||||
search: str = Query(""),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=200),
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.db.models import OperationLog
|
||||
q = db.query(OperationLog)
|
||||
if user_id:
|
||||
q = q.filter(OperationLog.user_id == user_id)
|
||||
if category:
|
||||
q = q.filter(OperationLog.category == category)
|
||||
if action:
|
||||
q = q.filter(OperationLog.action == action)
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
from sqlalchemy import or_
|
||||
q = q.filter(or_(OperationLog.summary.ilike(like), OperationLog.user_name.ilike(like)))
|
||||
total = q.count()
|
||||
items = q.order_by(OperationLog.timestamp.desc()).offset(offset).limit(limit).all()
|
||||
return {"items": [log.to_dict() for log in items], "total": total}
|
||||
|
||||
|
||||
@router.get("/audit-log/stats")
|
||||
def api_audit_log_stats(
|
||||
_user: Annotated[User, Depends(require_permission("admin:users"))],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.db.models import OperationLog
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timezone, timedelta
|
||||
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_count = db.query(OperationLog).filter(OperationLog.timestamp >= today).count()
|
||||
top_users = (
|
||||
db.query(OperationLog.user_name, func.count().label("cnt"))
|
||||
.filter(OperationLog.timestamp >= today)
|
||||
.group_by(OperationLog.user_name).order_by(func.count().desc()).limit(5).all()
|
||||
)
|
||||
by_category = (
|
||||
db.query(OperationLog.category, func.count().label("cnt"))
|
||||
.filter(OperationLog.timestamp >= today)
|
||||
.group_by(OperationLog.category).all()
|
||||
)
|
||||
return {
|
||||
"today_count": today_count,
|
||||
"top_users": [{"name": name, "count": cnt} for name, cnt in top_users],
|
||||
"by_category": [{"category": cat, "count": cnt} for cat, cnt in by_category],
|
||||
}
|
||||
Reference in New Issue
Block a user