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:
@@ -1,4 +1,4 @@
|
||||
"""Experiment API(Phase 4):列表 / 详情 / 删除 / 一键复跑。"""
|
||||
"""Experiment API(Phase 4):列表 / 详情 / 删除(单个 + 批量)/ 一键复跑。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import DbSession, ExperimentRepoDep, JobRepoDep
|
||||
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_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:
|
||||
"""列表项视图(body 形状与旧版一致,仅**新增** data_version / job_id / result_bytes)。
|
||||
@@ -85,6 +100,29 @@ def list_experiments(
|
||||
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 详情(含完整结果)")
|
||||
def get_experiment(experiment_id: str, experiment_repo: ExperimentRepoDep) -> dict:
|
||||
exp = experiment_repo.get(experiment_id)
|
||||
|
||||
@@ -6,9 +6,11 @@ import sys
|
||||
from datetime import date, datetime
|
||||
|
||||
import pytest
|
||||
from app.api.experiments import BULK_DELETE_MAX_IDS
|
||||
from app.application.services import job_executor as je
|
||||
from app.application.services.job_executor import execute_job
|
||||
from app.domain.entities.research import (
|
||||
ExperimentRecord,
|
||||
JobRecord,
|
||||
JobStatus,
|
||||
ResearchSpec,
|
||||
@@ -266,6 +268,53 @@ class TestJobsApi:
|
||||
with TestClient(app) as client:
|
||||
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:
|
||||
with TestClient(app) as client:
|
||||
# 先跑一个 job 生成归档,再验证列表与详情
|
||||
|
||||
Reference in New Issue
Block a user