Files
AgentLab/backend/app/api/source_versions.py
T
ThearchyHelios 941723b17c 端到端验收修复与指标出具:
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人
- 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效
- 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释
- 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标
- 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
2026-10-15 07:15:37 +08:00

200 lines
9.2 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.deps import signer_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:
"""开了登录是登录的人;关登录时请求体的 signed_by 优先,其次 X-Actor(api/deps.signer_of)。"""
return signer_of(body.get("signed_by"), header, _SIGNED_MAX)
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}