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