mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 08:51:58 +00:00
2cdbf9a733
按期累积(新 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(部署本来就是单进程)
202 lines
9.1 KiB
Python
202 lines
9.1 KiB
Python
"""上传表格的版本页接口(期 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}
|