Files
AgentLab/backend/app/api/table_imports.py
ThearchyHelios 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)照旧通过。
2026-10-16 10:14:43 +08:00

502 lines
23 KiB
Python
Raw Permalink 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.
"""按配方导入的接口(期 2,WP-5c;P2-SPEC 7.3):暂存、回答问题、改配方、AI 起草、试运行、提交、放弃,
以及「上传新一期」「修改配方」「查看当前配方」。
流程和状态都在 app.data.recipe_imports 里,这里只做三件事:解析请求、取署名、把业务错误转成带机读码的
CodedHTTPException(body 是 {"detail": 中文, "code": 机读码},前端按 code 分支)。
**请求模型里不放会触发 FastAPI 自带 422 的必填项**:它的 body 是 {detail: [...]}、不带 code,前端没法分支。
所以 JSON 请求体一律手工读成 dict(不是 JSON 对象才回 422 body_invalid),各字段由业务代码检查。
路由路径都以 /imports/ 开头,或是 /{source_id}/ 后面跟固定的 reupload、redraft、recipe,与 datasources.py
现有的 /{source_id}、/{source_id}/test|introspect|schema|imports 不重叠;在 main.py 里登记在 datasources
之前,免得 /imports/… 被通配的 {source_id} 先截走。
期 3(WP-5)加了 edits/preview、edits/apply、edits/undo、redraft-rules,PUT recipe 收可选的 origin;只读进程
(没拿到版本存储的守卫)里会写版本存储的入口一律回 503 store_unavailable(第 8 节遗留项 5),body 照样是
{detail, code}:前端把不是 JSON 的 503 当成网络断开。
"""
from __future__ import annotations
import datetime as _dt
import logging
import re
from typing import Any
from fastapi import APIRouter, Depends, File, Form, Header, Request, UploadFile
from fastapi.responses import JSONResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.coded import CodedHTTPException
from app.api.datasources import _NAME_PATTERN, _out, read_upload
from app.api.deps import current_principal, signer_of, signer_verified
from app.data import recipe_imports, table_versions
from app.data.recipe_imports import EditRequest, ImportRefused
from app.data.recipe_types import Acceptance, AiAvailability, PeriodInput, Recipe, Selection
from app.db.base import get_session
from app.db.models import DataSource, ImportStaging, TableRecipe
router = APIRouter(prefix="/api/datasources", tags=["table-imports"])
logger = logging.getLogger(__name__)
_NAME_INVALID = "名称必须以小写字母开头,只能包含字母、数字和下划线(名称会成为工具名的一部分)"
_SIGNED_MAX = 100
#: 业务上的拒绝:ImportRefused 带着状态和机读码;StoreUnavailable 是只读进程拒写(503)
_REFUSALS = (ImportRefused, table_versions.StoreUnavailable)
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]
def _signed(body: dict[str, Any] | None, header: str | None) -> str | None:
"""署名:开了登录是登录的人;关登录时请求体的 signed_by 优先,其次请求头 X-Actor,只原样记下(未认证)。"""
raw = (body or {}).get("signed_by") if isinstance(body, dict) else None
return signer_of(raw, header, _SIGNED_MAX)
def _login_on() -> bool:
return current_principal().mode != "off"
#: JSON 请求体的字节上限和嵌套层数上限(SE-5)。最大的合法配方(200 个标签、几十个分段)也只有一两百 KB、
#: 十来层;更大更深的不是配方,照存会让静态校验跑几十秒、占着解析名额,深层嵌套还会让响应序列化失败
BODY_MAX_BYTES = 1_000_000
BODY_MAX_DEPTH = 32
def _json_depth_over(raw: bytes, limit: int) -> bool:
depth = 0
in_str = esc = False
for ch in raw.decode("utf-8", errors="replace"):
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
elif ch == '"':
in_str = True
elif ch in "[{":
depth += 1
if depth > limit:
return True
elif ch in "]}":
depth -= 1
return False
async def _body(request: Request) -> dict[str, Any]:
"""JSON 请求体。空 body 当作 {};超过字节上限回 413 body_too_large;不是 JSON 对象、嵌套过深回 422 body_invalid。"""
length = request.headers.get("content-length")
if length and length.isdigit() and int(length) > BODY_MAX_BYTES:
raise CodedHTTPException(413, f"请求体超过 {BODY_MAX_BYTES // 1_000_000} MB 的上限", "body_too_large")
raw = await request.body()
if len(raw) > BODY_MAX_BYTES:
raise CodedHTTPException(413, f"请求体超过 {BODY_MAX_BYTES // 1_000_000} MB 的上限", "body_too_large")
if not raw.strip():
return {}
if _json_depth_over(raw, BODY_MAX_DEPTH):
raise CodedHTTPException(422, f"请求体的 JSON 嵌套超过 {BODY_MAX_DEPTH} 层", "body_invalid")
try:
data = await request.json()
except (ValueError, RecursionError):
data = None
if not isinstance(data, dict):
raise CodedHTTPException(422, "请求体必须是一个 JSON 对象", "body_invalid")
return data
async def _staging(session: AsyncSession, staging_id: str) -> ImportStaging:
row = await session.get(ImportStaging, staging_id)
if row is None:
raise CodedHTTPException(404, "这次导入不存在,可能已过期并被清理。请重新开始导入", "staging_not_found")
return row
async def _open_staging(session: AsyncSession, staging_id: str) -> ImportStaging:
"""写操作要的暂存区:先让过期的过期(不能指望只有回收时才过期),再要求它未结束。"""
await table_versions.expire_stagings(session)
row = await _staging(session, staging_id)
if not table_versions.staging_is_open(row):
raise CodedHTTPException(409, recipe_imports._CLOSED, "staging_closed")
return row
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
async def _out_of(session: AsyncSession, staging: ImportStaging) -> dict[str, Any]:
"""StagingOut。提供 AI 起草时才去查模型接入的可用性(不提供时不必多一次查询)。"""
source = await session.get(DataSource, staging.source_id)
pipe = recipe_imports.pipeline()
ai: AiAvailability | None = None
if table_versions.staging_is_open(staging) and recipe_imports.ai_offered(staging):
try:
ai = await pipe.ai_availability(session)
except Exception as e: # noqa: BLE001 - 可用性只是提示,查不到按不可用显示
logger.warning("ai availability failed: %s", type(e).__name__)
ai = AiAvailability(False, reason="暂时无法确认模型接入是否可用。请到「设置 → 模型接入」检查")
return recipe_imports.staging_out(staging, source, ai=ai, units=tuple(pipe.units) or None)
def _unreadable(e: Exception) -> CodedHTTPException:
return CodedHTTPException(400, str(e) or "无法读取这个文件,请确认是未加密的 Excel 文件", "file_unreadable")
# --------------------------------------------------------------------------
# 暂存、查看、放弃
# --------------------------------------------------------------------------
@router.post("/imports/stage", status_code=201)
async def stage(
file: UploadFile | None = File(None),
name: str = Form(""),
description: str = Form(""),
signed_by: str = Form(""),
x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session),
) -> Any:
"""选文件开始一次按配方导入:原件存档、扫描、网格预览、系统发现、规则起草。新名字 kind=first;
同名的简单导入源 kind=switch。"""
from app.data.tabular import UnsupportedTable
if file is None:
raise CodedHTTPException(422, "请选择要导入的文件", "body_invalid")
if not re.match(_NAME_PATTERN, name or ""):
raise CodedHTTPException(400, _NAME_INVALID, "name_invalid")
raw = await read_upload(file)
try:
staging = await recipe_imports.stage_upload(
session, raw=raw, filename=file.filename or name, name=name, description=description or "",
signed_by=_signed({"signed_by": signed_by}, x_actor))
except _REFUSALS as e:
raise _refused(e) from e
except UnsupportedTable as e:
raise _unreadable(e) from e
return JSONResponse(status_code=201, content=await _out_of(session, staging))
@router.get("/imports/{staging_id}")
async def get_staging(staging_id: str, session: AsyncSession = Depends(get_session)) -> Any:
await table_versions.expire_stagings(session)
return await _out_of(session, await _staging(session, staging_id))
@router.delete("/imports/{staging_id}", status_code=204)
async def discard(staging_id: str, session: AsyncSession = Depends(get_session)) -> Response:
staging = await _open_staging(session, staging_id)
try:
await recipe_imports.discard_staging(session, staging)
except _REFUSALS as e:
raise _refused(e) from e
return Response(status_code=204)
# --------------------------------------------------------------------------
# 回答问题、改配方
# --------------------------------------------------------------------------
@router.post("/imports/{staging_id}/answers")
async def answers(staging_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> Any:
body = await _body(request)
given = body.get("answers", {})
if not isinstance(given, dict):
raise CodedHTTPException(422, "回答必须是「问题 → 选项」的对象", "body_invalid")
staging = await _open_staging(session, staging_id)
try:
staging = await recipe_imports.answer(session, staging, given)
except _REFUSALS as e:
raise _refused(e) from e
return await _out_of(session, staging)
@router.put("/imports/{staging_id}/recipe")
async def put_recipe(staging_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> Any:
body = await _body(request)
recipe = body.get("recipe")
if not isinstance(recipe, dict):
raise CodedHTTPException(422, "配方必须是一个 JSON 对象", "body_invalid")
origin = body.get("origin")
if origin not in (None, "rules_redraft"):
raise CodedHTTPException(422, "配方来源只能不填,或填 rules_redraft(采用按规则重新起草的结果)", "body_invalid")
staging = await _open_staging(session, staging_id)
try:
staging = await recipe_imports.put_recipe(session, staging, recipe, origin=origin or "manual")
except _REFUSALS as e:
raise _refused(e) from e
return await _out_of(session, staging)
# --------------------------------------------------------------------------
# 期 3:修复按钮、框选、撤销、按规则重新起草
# --------------------------------------------------------------------------
_EDIT_SHAPE = "请求里要么给修复(fix),要么给框选(selection),二选一"
def _edit_request(body: dict[str, Any]) -> EditRequest:
"""{"fix": {id, option, reason?}} 或 {"selection": {sheet, ref, as, options?}},二选一(9.1)。形状不对回 422
edit_invalid;框选的形状校验交给契约 Selection.from_json(只抛 ValueError),输出时再经 to_json 改回 as。"""
fix, sel = body.get("fix"), body.get("selection")
if (fix is None) == (sel is None):
raise CodedHTTPException(422, _EDIT_SHAPE, "edit_invalid")
if fix is not None:
if (not isinstance(fix, dict) or not isinstance(fix.get("id"), str) or not fix["id"]
or not isinstance(fix.get("option"), str) or not fix["option"]):
raise CodedHTTPException(422, "修复请求要指明是哪条修复建议(id)和选的哪一项(option)", "edit_invalid")
reason = fix.get("reason")
if reason is not None and not isinstance(reason, str):
raise CodedHTTPException(422, "理由必须是一段文字", "edit_invalid")
return EditRequest(kind="fix", fix_id=fix["id"], option=fix["option"], reason=reason)
try:
return EditRequest(kind="selection", selection=Selection.from_json(sel))
except ValueError as e:
raise CodedHTTPException(422, str(e) or "框选的请求格式不对", "edit_invalid") from e
@router.post("/imports/{staging_id}/edits/preview")
async def edits_preview(staging_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> Any:
"""修复或框选的预览(不改工作配方)。seq 原样回传:界面只采用最新一次的回包。"""
body = await _body(request)
req = _edit_request(body)
staging = await _open_staging(session, staging_id)
try:
return await recipe_imports.edit_preview(session, staging, req, seq=body.get("seq"))
except _REFUSALS as e:
raise _refused(e) from e
@router.post("/imports/{staging_id}/edits/apply")
async def edits_apply(staging_id: str, request: Request, x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> Any:
"""应用预览过的修复或框选:选项和理由与预览时逐字相同,expected_sha256 是预览给的 recipe_sha256_after。"""
body = await _body(request)
req = _edit_request(body)
expected = body.get("expected_sha256")
staging = await _open_staging(session, staging_id)
try:
staging = await recipe_imports.edit_apply(
session, staging, req, expected_sha256=expected if isinstance(expected, str) else None,
signed_by=_signed(body, x_actor))
except _REFUSALS as e:
raise _refused(e) from e
return await _out_of(session, staging)
@router.post("/imports/{staging_id}/edits/undo")
async def edits_undo(staging_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> Any:
await _body(request)
staging = await _open_staging(session, staging_id)
try:
staging = await recipe_imports.edit_undo(session, staging)
except _REFUSALS as e:
raise _refused(e) from e
return await _out_of(session, staging)
@router.post("/imports/{staging_id}/redraft-rules")
async def redraft_rules(staging_id: str, request: Request, session: AsyncSession = Depends(get_session)) -> Any:
"""按规则重新起草(不调用模型、不改工作配方),名字对齐到现行配方;「采用」走 PUT recipe(origin=rules_redraft)。"""
from app.data.tabular import UnsupportedTable
await _body(request)
staging = await _open_staging(session, staging_id)
try:
return await recipe_imports.redraft_rules(session, staging)
except _REFUSALS as e:
raise _refused(e) from e
except UnsupportedTable as e:
raise _unreadable(e) from e
# --------------------------------------------------------------------------
# AI 起草
# --------------------------------------------------------------------------
@router.get("/imports/{staging_id}/draft-ai/preview")
async def draft_ai_preview(staging_id: str, session: AsyncSession = Depends(get_session)) -> Any:
staging = await _open_staging(session, staging_id)
try:
return await recipe_imports.ai_preview(session, staging)
except _REFUSALS as e:
raise _refused(e) from e
@router.post("/imports/{staging_id}/draft-ai")
async def draft_ai(staging_id: str, request: Request, x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> Any:
body = await _body(request)
sha = body.get("preview_sha256")
staging = await _open_staging(session, staging_id)
try:
staging = await recipe_imports.run_ai_draft(
session, staging, consent=body.get("consent") is True,
preview_sha256=sha if isinstance(sha, str) else None, signed_by=_signed(body, x_actor))
except _REFUSALS as e:
raise _refused(e) from e
return await _out_of(session, staging)
# --------------------------------------------------------------------------
# 试运行、提交
# --------------------------------------------------------------------------
def _context_inputs(raw: Any, signed_by: str | None) -> dict[str, PeriodInput] | None:
"""{"统计期": {"start": "2026-09-01", "end": "2026-09-30"}} → PeriodInput。不合法回 422 context_invalid。"""
if raw is None or raw == {}:
return None
bad = CodedHTTPException(422, "统计期的录入不对:请填写起止日期(YYYY-MM-DD),起点不能晚于终点", "context_invalid")
if not isinstance(raw, dict):
raise bad
out: dict[str, PeriodInput] = {}
for key, value in raw.items():
if key != recipe_imports.PERIOD_KEY or not isinstance(value, dict):
raise bad
try:
start = _dt.date.fromisoformat(str(value.get("start") or ""))
end = _dt.date.fromisoformat(str(value.get("end") or ""))
except ValueError:
raise bad from None
if start > end:
raise bad
who = value.get("signed_by")
# 开了登录时 signed_by 已经是登录的人,这里填的不看(signer_of 同一条)
out[key] = PeriodInput(start=start, end=end,
signed_by=(str(who).strip()[:_SIGNED_MAX]
if isinstance(who, str) and who.strip() and not _login_on() else signed_by))
return out
@router.post("/imports/{staging_id}/trial")
async def trial(staging_id: str, request: Request, x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> Any:
body = await _body(request)
who = _signed(body, x_actor)
inputs = _context_inputs(body.get("context_inputs"), who)
staging = await _open_staging(session, staging_id)
try:
staging = await recipe_imports.run_trial(session, staging, context_inputs=inputs, signed_by=who)
except _REFUSALS as e:
raise _refused(e) from e
return await _out_of(session, staging)
def _acceptances(raw: Any, who: str | None) -> list[Acceptance]:
if raw is None:
return []
if not isinstance(raw, list):
raise CodedHTTPException(422, "接受理由必须是「核对 → 理由」的列表", "body_invalid")
out = []
for a in raw:
if not isinstance(a, dict) or not isinstance(a.get("check_id"), str):
raise CodedHTTPException(422, "接受理由必须是「核对 → 理由」的列表", "body_invalid")
reason = a.get("reason")
out.append(Acceptance(check_id=a["check_id"], reason=reason if isinstance(reason, str) else "",
signed_by=who))
return out
@router.post("/imports/{staging_id}/commit", status_code=201)
async def commit(staging_id: str, request: Request, x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> Any:
body = await _body(request)
who = _signed(body, x_actor)
confirmations = body.get("confirmations", [])
if not isinstance(confirmations, list) or not all(isinstance(x, str) for x in confirmations):
raise CodedHTTPException(422, "确认项必须是 id 的列表", "body_invalid")
trial_id = body.get("trial_id")
staging = await _open_staging(session, staging_id)
try:
result = await recipe_imports.commit_staging(
session, staging, trial_id=trial_id if isinstance(trial_id, str) else "",
confirmations=confirmations, acceptances=_acceptances(body.get("acceptances"), who), signed_by=who,
signer_verified=signer_verified())
except _REFUSALS as e:
raise _refused(e) from e
source = await session.get(DataSource, result.source_id, populate_existing=True)
return JSONResponse(status_code=201, content={
"source": (await _out(session, source)).model_dump() if source is not None else None,
"import_id": result.import_id, "snapshot_id": result.snapshot_id, "build_id": result.build_id,
"recipe_id": result.recipe_id, "build_reused": result.build_reused, "unchanged": result.unchanged,
# 期 3:同版本数据文件被改过、已用本次的文件恢复;启用后当前版本含几期;直接启用了内容相同的已有版本
"build_restored": result.build_restored, "parts": result.parts, "snapshot_reused": result.snapshot_reused,
})
# --------------------------------------------------------------------------
# 上传新一期、修改配方、当前配方
# --------------------------------------------------------------------------
@router.post("/{source_id}/reupload", status_code=201)
async def reupload(
source_id: str,
file: UploadFile | None = File(None),
signed_by: str = Form(""),
x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session),
) -> Any:
"""上传新一期:按当前配方自动试运行(不调模型),回来时已带回执和差异卡。"""
from app.data.tabular import UnsupportedTable
source = await _source(session, source_id)
if not source.current_recipe_id:
raise CodedHTTPException(409, recipe_imports._NOT_RECIPE, "not_recipe_source")
if file is None:
raise CodedHTTPException(422, "请选择要导入的文件", "body_invalid")
raw = await read_upload(file)
try:
staging = await recipe_imports.reupload(session, source, raw=raw, filename=file.filename or source.name,
signed_by=_signed({"signed_by": signed_by}, x_actor))
except _REFUSALS as e:
raise _refused(e) from e
except UnsupportedTable as e:
raise _unreadable(e) from e
return JSONResponse(status_code=201, content=await _out_of(session, staging))
@router.post("/{source_id}/redraft", status_code=201)
async def redraft(source_id: str, request: Request, x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> Any:
from app.data.tabular import UnsupportedTable
body = await _body(request)
source = await _source(session, source_id)
try:
staging = await recipe_imports.redraft(session, source, signed_by=_signed(body, x_actor))
except _REFUSALS as e:
raise _refused(e) from e
except UnsupportedTable as e:
raise _unreadable(e) from e
return JSONResponse(status_code=201, content=await _out_of(session, staging))
@router.get("/{source_id}/recipe")
async def get_recipe(source_id: str, session: AsyncSession = Depends(get_session)) -> Any:
source = await _source(session, source_id)
row = await session.get(TableRecipe, source.current_recipe_id) if source.current_recipe_id else None
if row is None:
raise CodedHTTPException(409, recipe_imports._NOT_RECIPE, "not_recipe_source")
try:
full = Recipe.model_validate(row.recipe).model_dump(mode="json")
except Exception: # noqa: BLE001 - 存下的配方都是校验过的;万一读不出,原样给出
full = row.recipe
return {
"recipe_id": row.id, "seq": row.seq, "origin": row.origin, "recipe": full,
"recipe_sha256": row.recipe_sha256, "confirmations": list(row.confirmations or []),
"signed_by": row.signed_by, "activated_at": row.activated_at.isoformat() if row.activated_at else None,
}