feat(api): 实验归档支持批量删除(含缺失项如实回报)

「实验对比」页需要批量删除,逐个 DELETE 会有 N 次往返且中途失败会留下半删除状态。

- `POST /experiments/bulk-delete`:一次最多 200 个 id(`BULK_DELETE_MAX_IDS`),
  按请求顺序去重;返回 `deleted` / `missing` / `count`,**存在的删掉、不存在的如实列出**,
  不假装全部成功(前端据此提示「N 个已删、M 个不存在」)。
- 路由声明在 `GET /{experiment_id}` 之前,避免被路径参数吞掉。
- 测试 +2:删除与缺失混合场景、id 列表校验(空/超长)。
This commit is contained in:
Simon
2026-10-01 17:57:04 +08:00
parent 48a97c2a12
commit 633176a3d1
2 changed files with 88 additions and 1 deletions
+39 -1
View File
@@ -1,4 +1,4 @@
"""Experiment API(Phase 4):列表 / 详情 / 删除 / 一键复跑。""" """Experiment API(Phase 4):列表 / 详情 / 删除(单个 + 批量)/ 一键复跑。"""
from __future__ import annotations from __future__ import annotations
@@ -7,6 +7,7 @@ from datetime import datetime
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response
from pydantic import BaseModel, Field
from app.api.deps import DbSession, ExperimentRepoDep, JobRepoDep from app.api.deps import DbSession, ExperimentRepoDep, JobRepoDep
from app.application.services.job_executor import new_id, run_job_background from app.application.services.job_executor import new_id, run_job_background
@@ -25,6 +26,20 @@ router = APIRouter(prefix="/experiments", tags=["experiments"])
LIST_DEFAULT_LIMIT = 200 LIST_DEFAULT_LIMIT = 200
LIST_MAX_LIMIT = 1000 LIST_MAX_LIMIT = 1000
# 单次批量删除的 id 上限:够覆盖列表一页(默认 200 条),又不至于让一次请求
# 把整张表拖进内存。超了直接 422(附上限值),不静默截断成前 N 个。
BULK_DELETE_MAX_IDS = 200
class BulkDeleteRequest(BaseModel):
"""批量删除请求体(**必填** id 列表,1~200 个)。"""
ids: list[str] = Field(
min_length=1,
max_length=BULK_DELETE_MAX_IDS,
description=f"要删除的归档 id,1~{BULK_DELETE_MAX_IDS} 个(重复 id 自动去重)",
)
def _experiment_meta(exp: ExperimentRecord | ExperimentSummary) -> dict: def _experiment_meta(exp: ExperimentRecord | ExperimentSummary) -> dict:
"""列表项视图(body 形状与旧版一致,仅**新增** data_version / job_id / result_bytes)。 """列表项视图(body 形状与旧版一致,仅**新增** data_version / job_id / result_bytes)。
@@ -85,6 +100,29 @@ def list_experiments(
return [_experiment_meta(e) for e in rows] return [_experiment_meta(e) for e in rows]
@router.post("/bulk-delete", summary=f"批量删除归档(1~{BULK_DELETE_MAX_IDS} 个,逐个回报)")
def bulk_delete_experiments(
body: BulkDeleteRequest,
session: DbSession,
experiment_repo: ExperimentRepoDep,
) -> dict:
"""批量删除归档,返回 `{"deleted": [...], "missing": [...], "count": n}`。
语义与单个删除完全一致(只删 experiment 行,job 历史保留),另外:
- **重复 id 先按出现顺序去重**(同一个 id 报两次没有意义,也不该算两次成功);
- **不静默跳过**:库里没有的 id 单独放进 `missing`,让界面能如实说
「删了 3 个,2 个没找到(可能已被别处删掉)」——把缺失当成功会更难排查;
- 一次请求内的 id 上限 `BULK_DELETE_MAX_IDS`,超了 Pydantic 直接 422 并带上限值。
"""
ids = list(dict.fromkeys(body.ids))
deleted: list[str] = []
missing: list[str] = []
for experiment_id in ids:
(deleted if experiment_repo.delete(experiment_id) else missing).append(experiment_id)
session.commit()
return {"deleted": deleted, "missing": missing, "count": len(deleted)}
@router.get("/{experiment_id}", summary="Experiment 详情(含完整结果)") @router.get("/{experiment_id}", summary="Experiment 详情(含完整结果)")
def get_experiment(experiment_id: str, experiment_repo: ExperimentRepoDep) -> dict: def get_experiment(experiment_id: str, experiment_repo: ExperimentRepoDep) -> dict:
exp = experiment_repo.get(experiment_id) exp = experiment_repo.get(experiment_id)
+49
View File
@@ -6,9 +6,11 @@ import sys
from datetime import date, datetime from datetime import date, datetime
import pytest import pytest
from app.api.experiments import BULK_DELETE_MAX_IDS
from app.application.services import job_executor as je from app.application.services import job_executor as je
from app.application.services.job_executor import execute_job from app.application.services.job_executor import execute_job
from app.domain.entities.research import ( from app.domain.entities.research import (
ExperimentRecord,
JobRecord, JobRecord,
JobStatus, JobStatus,
ResearchSpec, ResearchSpec,
@@ -266,6 +268,53 @@ class TestJobsApi:
with TestClient(app) as client: with TestClient(app) as client:
assert client.get("/api/jobs/JOB-NOPE").status_code == 404 assert client.get("/api/jobs/JOB-NOPE").status_code == 404
def test_bulk_delete_reports_deleted_and_missing(self, seeded_api_db) -> None:
"""批量删除:成功 / 库里没有的 id **分开回报**,且真的从列表里消失。
为什么坚持分开回报:把「没找到」也算成功,界面上就会显示「已删除 4 个」
而实际只删了 2 个 —— 用户下次刷新发现还在,却查不出原因。
"""
spec_json = _spec().model_dump_json()
with sess_mod.SessionLocal() as session:
repo = SqlAlchemyExperimentRepository(session)
for eid in ("EXP-A", "EXP-B"):
repo.save(
ExperimentRecord(
id=eid,
kind="backtest",
spec_json=spec_json,
result_json="{}",
summary_text="自检用",
created_at=datetime(2026, 10, 1, 10, 0, 0),
)
)
session.commit()
with TestClient(app) as client:
# 重复 id 只算一次(EXP-A 出现两次)
resp = client.post(
"/api/experiments/bulk-delete",
json={"ids": ["EXP-A", "EXP-B", "EXP-A", "EXP-NOPE"]},
)
assert resp.status_code == 200
body = resp.json()
assert body["deleted"] == ["EXP-A", "EXP-B"]
assert body["missing"] == ["EXP-NOPE"]
assert body["count"] == 2
left = {e["id"] for e in client.get("/api/experiments").json()}
assert not ({"EXP-A", "EXP-B"} & left)
def test_bulk_delete_validates_ids(self, seeded_api_db) -> None:
"""空列表 / 超上限 / 缺字段一律 422(不静默截断成前 N 个)。"""
with TestClient(app) as client:
assert client.post("/api/experiments/bulk-delete", json={"ids": []}).status_code == 422
assert client.post("/api/experiments/bulk-delete", json={}).status_code == 422
too_many = [f"EXP-{i}" for i in range(BULK_DELETE_MAX_IDS + 1)]
resp = client.post("/api/experiments/bulk-delete", json={"ids": too_many})
assert resp.status_code == 422
# 错误里必须写清上限,用户才知道要分几批
assert str(BULK_DELETE_MAX_IDS) in resp.text
def test_experiments_list_and_detail(self, seeded_api_db) -> None: def test_experiments_list_and_detail(self, seeded_api_db) -> None:
with TestClient(app) as client: with TestClient(app) as client:
# 先跑一个 job 生成归档,再验证列表与详情 # 先跑一个 job 生成归档,再验证列表与详情