Files
AgentLab/backend/app/api/source_versions.py
T
ThearchyHelios 2cdbf9a733 Excel 导入改造期 3:网格交互、自助修复、按期累积——每月的报表可以累积成一份跨月的表,漂移了点修复按钮就能接着导入,网格上框一块就能写进配方,版本页可以回滚、移除一期、清除原件
按期累积(新 recipe_accumulate;table_versions 扩展)
- 按统计期出的交叉表报表首次导入时默认「按期累积」,由人确认;一期一个构建,两期及以上物化成一份新的 SQLite 快照,不用视图
- 统计期完全相同要勾「替换该期」,部分重叠拒收,中间有空缺要确认;物化前逐期核对库哈希和行号,并集上另加行数守恒、主键唯一、日期落在所属统计期三条核对
- 配方演进分三档:新增列(早期各期为空值)、退役的列和表保留旧值(只在这次才退役时要勾)、不兼容变化只能显式「重新开始累积」
- 快照 id 在累积时含目标配方和模式;同样的输入物化两次库哈希相同,同一文件换名再传直接复用
- 说明按期合并,任何一期没核对过就不写「已核对」;各期维度取值不同、退役列、合计表都有提示,经过数字检查

修复按钮(新 recipe_fixes;执行器给问题带结构化参数)
- 加入标签、移除标签、声明为合计行、分段标题改名、编辑恒等式成员、按锚点忽略行/列/区域外文字、声明占位符、声明隐藏行处理、工作表改名
- 服务端算补丁,先预览新旧配方对照、再应用,可以撤销;补丁封闭,不引入正则、自由文本常量和写死坐标;修复进确认清单
- 只改分段标题、新标题仍含原来选的词时沿用已确认的常量,不算破坏性变更;修复后只查改动过的段的认领,没改的关系交给试运行核对

框选转锚点(新 recipe_select)
- 网格上框选(鼠标、键盘、区域输入),按八种用途换算成按文字认的锚点,立刻干跑比对;配方里不存坐标,泛化不了时列出原因
- 整张表挪位后同一配方照样认得;框进合计行、夹着小计、表下说明都有明确的处理

确认与对照(recipe_confirm、recipe_diff、新 recipe_compare)
- 改配方后确认清单只列有变化的项(同时按本期数据比较),不用每修一次重勾十来项;新旧配方对照界面;按规则重新起草并把名字对齐到现行配方
- 改配方后已体现在配方里的回答保留,矛盾的列出来请人重答
- 「排除的行」写进回执和导入清单:隐藏行、跳过的空行、按配方忽略的行、停止后的文字行、只核对不另存的合计行

版本页(新 source_versions、api/source_versions;前端 VersionsDialog)
- 快照与导入记录列表、启用旧版本(可写理由)、查看清单、移除一期、作废接受、清除原件;写操作带界面看到的当前版本,并发改动回 409,不静默丢期
- 除当前版本和被运行引用的版本外,再保留最近 3 个版本供回滚;遮罩列丢失要明确确认

期 1、期 2 遗留
- 重传撞上被改过的构建文件:能用本次上传恢复就隔离旧文件再恢复,否则回 409,不再是 500
- 跳过空行的列表不再把表下隔一行的说明读成数据;不像说明的单格行要人确认;受影响的配方构建换 id
- 版本存储加进程级文件锁:第二个进程拿不到锁时不清理、不回收,写操作回 503(部署本来就是单进程)
2026-10-02 07:38:44 +08:00

202 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""上传表格的版本页接口(期 3,WP-5;P3-SPEC 第 7 节):版本列表、启用旧版本(回滚)、查看清单、移除一期、
作废接受。导入记录列表与清除原件仍在 datasources.py(期 1 的接口,期 3 补了字段和错误码)。
流程在 app.data.source_versions 里,这里只做三件事:手工读请求体(不触发 FastAPI 自带的 422:它的 body 是
{detail: [...]}、不带 code,前端没法分支)、取署名、把业务错误转成带机读码的 CodedHTTPException。
**写操作一律要 confirm: true 和界面看到的当前版本(expected_current_snapshot_id)**(7.7):确认框是按那个版本
写的后果,别人在这期间提交、启用过,锁内比对不上就 409 base_changed,不能照旧切过去。移除、作废还要写理由。
署名取请求体的 signed_by,其次请求头 X-Actor,只原样记下(未认证)。
路径都是 /{source_id}/snapshots… 和 /{source_id}/imports/{import_id}/(manifest|remove|revoke-acceptance),与
datasources.py 的 /{source_id}/imports(GET)、/{source_id}/imports/{import_id}/purge-raw 不重叠;在 main.py 里
登记在 table_imports 之后、datasources 之前。
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Header, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.coded import CodedHTTPException
from app.api.datasources import _out
from app.api.runs import actor_of
from app.data import source_versions, table_versions
from app.data.recipe_imports import ImportRefused
from app.db.base import get_session
from app.db.models import DataSource
router = APIRouter(prefix="/api/datasources", tags=["source-versions"])
_SIGNED_MAX = 100
def _refused(e: Exception) -> CodedHTTPException:
if isinstance(e, table_versions.StoreUnavailable):
return CodedHTTPException(503, str(e), "store_unavailable")
return CodedHTTPException(e.status, e.message, e.code) # type: ignore[attr-defined]
_REFUSALS = (ImportRefused, table_versions.StoreUnavailable)
async def _body(request: Request) -> dict[str, Any]:
raw = await request.body()
if not raw.strip():
return {}
try:
data = await request.json()
except ValueError:
data = None
if not isinstance(data, dict):
raise CodedHTTPException(422, "请求体必须是一个 JSON 对象", "body_invalid")
return data
def _signed(body: dict[str, Any], header: str | None) -> str | None:
raw = body.get("signed_by")
if isinstance(raw, str) and raw.strip():
return raw.strip()[:_SIGNED_MAX]
return actor_of(header)
async def _source(session: AsyncSession, source_id: str) -> DataSource:
row = await session.get(DataSource, source_id)
if row is None:
raise CodedHTTPException(404, "数据源不存在,可能已被删除", "source_not_found")
return row
def _confirmed(body: dict[str, Any]) -> None:
if body.get("confirm") is not True:
raise CodedHTTPException(422, "请在确认框中确认后再操作", "confirm_required")
def _expected_current(body: dict[str, Any]) -> str | None:
"""界面看到的当前版本 id:必须带这个键(值可以是 null:界面看到的就是「没有当前版本」)。"""
if "expected_current_snapshot_id" not in body:
raise CodedHTTPException(422, "请求里缺少界面看到的当前版本,请刷新版本列表后重试", "expected_current_required")
value = body.get("expected_current_snapshot_id")
if value is not None and not isinstance(value, str):
raise CodedHTTPException(422, "当前版本的写法不对,请刷新版本列表后重试", "expected_current_required")
return value
def _reason(body: dict[str, Any], *, required: bool) -> str | None:
raw = body.get("reason")
if raw is not None and not isinstance(raw, str):
raise CodedHTTPException(422, "理由必须是一段文字", "reason_required")
text = (raw or "").strip()
if required and not text:
raise CodedHTTPException(422, "请写明理由", "reason_required")
if len(text) > source_versions.REASON_MAX:
raise CodedHTTPException(422, f"理由不能超过 {source_versions.REASON_MAX} 字", "reason_required")
return text or None
def _ack(body: dict[str, Any]) -> list[str] | None:
raw = body.get("ack_mask_lost")
if raw is None:
return None
if not isinstance(raw, list) or not all(isinstance(x, str) for x in raw):
raise CodedHTTPException(422, "确认丢失的遮罩列要写成列名的列表", "body_invalid")
return list(raw)
async def _source_out(session: AsyncSession, source_id: str) -> dict[str, Any] | None:
row = await session.get(DataSource, source_id, populate_existing=True)
return (await _out(session, row)).model_dump() if row is not None else None
# --------------------------------------------------------------------------
# 列表、清单
# --------------------------------------------------------------------------
@router.get("/{source_id}/snapshots")
async def list_snapshots(source_id: str, session: AsyncSession = Depends(get_session)) -> list[dict[str, Any]]:
"""最近 50 个版本,当前版本排第一(7.1)。手工源返回空列表。"""
return await source_versions.list_snapshots(session, await _source(session, source_id))
@router.get("/{source_id}/snapshots/{snapshot_id}/manifest")
async def snapshot_manifest(source_id: str, snapshot_id: str, session: AsyncSession = Depends(get_session)) -> Any:
source = await _source(session, source_id)
try:
return await source_versions.snapshot_manifest_out(session, source, snapshot_id)
except _REFUSALS as e:
raise _refused(e) from e
@router.get("/{source_id}/imports/{import_id}/manifest")
async def import_manifest(source_id: str, import_id: str, session: AsyncSession = Depends(get_session)) -> Any:
source = await _source(session, source_id)
try:
return await source_versions.import_manifest(session, source, import_id)
except _REFUSALS as e:
raise _refused(e) from e
# --------------------------------------------------------------------------
# 写操作
# --------------------------------------------------------------------------
@router.post("/{source_id}/snapshots/{snapshot_id}/activate")
async def activate(source_id: str, snapshot_id: str, request: Request, x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> Any:
"""启用一个还保留着的旧版本(回滚,7.2)。理由可选。"""
body = await _body(request)
_confirmed(body)
expected = _expected_current(body)
reason = _reason(body, required=False)
ack = _ack(body)
source = await _source(session, source_id)
try:
out = await source_versions.activate(session, source, snapshot_id, expected_current=expected,
ack_mask_lost=ack, reason=reason, signed_by=_signed(body, x_actor))
except _REFUSALS as e:
raise _refused(e) from e
return {"source": await _source_out(session, source_id), **out}
@router.post("/{source_id}/imports/{import_id}/remove")
async def remove_period(source_id: str, import_id: str, request: Request, x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> Any:
"""从当前版本中移除这一期(累积模式,7.4)。配方和导入模式不变。"""
body = await _body(request)
_confirmed(body)
expected = _expected_current(body)
reason = _reason(body, required=True)
source = await _source(session, source_id)
try:
out = await source_versions.remove_period(session, source, import_id, expected_current=expected,
reason=reason or "", signed_by=_signed(body, x_actor))
except _REFUSALS as e:
raise _refused(e) from e
return {"source": await _source_out(session, source_id), **out}
@router.post("/{source_id}/imports/{import_id}/revoke-acceptance")
async def revoke_acceptance(source_id: str, import_id: str, request: Request,
x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> Any:
"""撤回这一期(作废接受,不可恢复,7.5):累积模式移除这一期,替换模式回滚到 revoke_plan 的目标。"""
body = await _body(request)
_confirmed(body)
expected = _expected_current(body)
reason = _reason(body, required=True)
target = body.get("expected_target_snapshot_id")
if target is not None and not isinstance(target, str):
raise CodedHTTPException(422, "回滚目标的写法不对,请刷新后重新确认", "body_invalid")
ack = _ack(body)
source = await _source(session, source_id)
try:
out = await source_versions.revoke_acceptance(
session, source, import_id, expected_current=expected, expected_target=target, ack_mask_lost=ack,
reason=reason or "", signed_by=_signed(body, x_actor))
except _REFUSALS as e:
raise _refused(e) from e
return {"source": await _source_out(session, source_id), **out}