From 633176a3d1ba7bf762bb0458321ca87be0610b15 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 1 Oct 2026 17:57:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E5=AE=9E=E9=AA=8C=E5=BD=92?= =?UTF-8?q?=E6=A1=A3=E6=94=AF=E6=8C=81=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4?= =?UTF-8?q?=EF=BC=88=E5=90=AB=E7=BC=BA=E5=A4=B1=E9=A1=B9=E5=A6=82=E5=AE=9E?= =?UTF-8?q?=E5=9B=9E=E6=8A=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「实验对比」页需要批量删除,逐个 DELETE 会有 N 次往返且中途失败会留下半删除状态。 - `POST /experiments/bulk-delete`:一次最多 200 个 id(`BULK_DELETE_MAX_IDS`), 按请求顺序去重;返回 `deleted` / `missing` / `count`,**存在的删掉、不存在的如实列出**, 不假装全部成功(前端据此提示「N 个已删、M 个不存在」)。 - 路由声明在 `GET /{experiment_id}` 之前,避免被路径参数吞掉。 - 测试 +2:删除与缺失混合场景、id 列表校验(空/超长)。 --- backend/app/api/experiments.py | 40 ++++++++++++++++++++- backend/tests/test_jobs_experiments.py | 49 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/backend/app/api/experiments.py b/backend/app/api/experiments.py index 3d41e69..d6de6f6 100644 --- a/backend/app/api/experiments.py +++ b/backend/app/api/experiments.py @@ -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) diff --git a/backend/tests/test_jobs_experiments.py b/backend/tests/test_jobs_experiments.py index d022126..c0e0e8b 100644 --- a/backend/tests/test_jobs_experiments.py +++ b/backend/tests/test_jobs_experiments.py @@ -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 生成归档,再验证列表与详情