mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:21:58 +00:00
ec5d9398b5
为什么:这四处的 signed_by.verified / signed_by_verified 写死 False。开了登录时署名就是登录的人,封存证据里却记着 「未认证」,和审计表、待办派单、run.origin 的 verified 口径对不上——留痕记错了事实(05-已知遗留 (c))。 做了什么:api/deps 新增 signer_verified()(开了登录、这次请求的身份认证过时为真;关登录、不在请求里为假),和 signer_of 配对。表格导入提交、按期移除 / 作废接受、清除原件三个写接口在请求那一刻算好,和 signed_by 一起往下传到 recipe_imports._manifest、source_versions.manifest_doc / _revoked_record、table_versions.purge_import_raw;没有署名的 (退回暂存区上记的那个)照实记未认证。新参数都带默认值 False:运行里按配方导入一期(files/imports.py,署名取动作确认人 或发起人)没有请求身份,照旧记未认证。已封存的旧清单不改写,读取复验照旧。 测试:test_signer_verified(signer_verified 跟请求身份走;作废记录照给的写;开登录首次导入的清单 verified=True、清除原件 记录 signed_by 是登录的人且 verified=True,请求体里填的名字不看)。关登录的老断言(False)照旧通过。
201 lines
9.4 KiB
Python
201 lines
9.4 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.deps import signer_of, signer_verified
|
||
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),
|
||
signer_verified=signer_verified())
|
||
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), signer_verified=signer_verified())
|
||
except _REFUSALS as e:
|
||
raise _refused(e) from e
|
||
return {"source": await _source_out(session, source_id), **out}
|