Files
AgentLab/backend/app/api/tasks.py
T
ThearchyHelios e82d7fa55d 集成接口补齐错误码,开登录时收起 /docs、/redoc、/openapi.json(要搭建者登录)
外部系统只能按中文句子认是哪种错,文案一改就悄悄失灵;开了登录,接口说明仍然不用登录就能读,全部接口的结构对匿名的人
敞开(中间件只拦 /api/ 开头的路径)。

错误码(detail 文字一字不改,老前端照常显示;码都登记在 api/coded.py):
- api/apps.py 13 处:应用不存在(app_not_found,同入口发起时的码;没有授权的人也回它)、版本、实例不存在,钩子没声明、
  没生成密钥,岗位绑定、个人应用草稿、授权、页面参数不合规;派生时工作流、版本不存在(apps/derive.py 的
  DeriveRefused 带上码);视图、入口、补算的拒绝原样带码,只有引擎不发起运行(LaunchRefused)没带码的几种补上
  run_launch_refused
- api/tasks.py 3 处:待办不存在、查看全部要搭建者(role_required);服务层没带码的拒绝按状态补上同一类的码
  (404 task_not_found、403 task_forbidden、409 task_closed、422 task_invalid),没改 process/tasks.py
- api/artifacts.py 5 处:没有权限、这类原文只给搭建者、不存在、内容和哈希对不上
- api/deps.py 的 _forbidden(run_forbidden)、运行和证据接口的角色拒绝(role_required)、个人运行只给拥有者
  (run_private)、问数据(chat_forbidden);ensure_role / 路由级依赖走 auth.require,抽出 auth.refusal 一处判断,
  没登录 401 auth_required、角色不够 403 role_required

/docs:开登录时中间件把 /docs(含子路径)、/redoc、/openapi.json 当成 /api 一样拦,和路由级依赖用同一个 auth.refusal:
匿名 401,不到搭建者 403。关登录照旧(test_provenance_contract.py 的断言不受影响)。

测试 tests/test_integration_errors.py:这五个模块里不许再直接抛 HTTPException(静态检查);应用、待办、工件、角色、运行
拒绝逐类核对 code 和原 detail;/docs 关登录照旧 200,开登录匿名 401、使用者 403、搭建者 200。去掉中间件的改动回跑,
开登录时匿名打开 /docs 是 200,测试失败。
2026-10-16 10:19:50 +08:00

131 lines
5.3 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.
"""待办(BE-2):列表、计数、详情、回复、改派、催办。设计文档 §3.7、§3.10。
身份经 api/deps.current_principal 取;关登录时它没有名字,用自报的署名(X-Actor)补上,回复人一栏才写得出人。
回复被拒回 403 / 404 / 409 / 422,{detail, code},一律带 code(外部系统按它认);表单不通过时另带 errors(同应用入口的
422)。服务层没带码的拒绝按状态补上同一类的码(api/coded.py 的 TASK_*)。
"""
from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, Header, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from app.api.coded import (
ROLE_REQUIRED, TASK_CLOSED, TASK_FORBIDDEN, TASK_INVALID, TASK_NOT_FOUND, CodedHTTPException,
)
from app.api.deps import current_principal, decode_actor
from app.process import tasks as task_service
from app.process.tasks import FormInvalid, TaskRefused
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
def principal_of(x_actor: str | None) -> Any:
return task_service.principal_with_actor(current_principal(), decode_actor(x_actor))
#: 服务层(process/tasks.py、process/actions.py、blueprints/personal.py)没带码的拒绝按状态归到哪一类
_CODE_BY_STATUS = {404: TASK_NOT_FOUND, 403: TASK_FORBIDDEN, 409: TASK_CLOSED, 422: TASK_INVALID}
def refused(e: TaskRefused) -> CodedHTTPException | JSONResponse:
if isinstance(e, FormInvalid):
return JSONResponse({"detail": str(e), "code": e.code, "errors": e.errors}, status_code=422)
return CodedHTTPException(e.status_code, str(e), e.code or _CODE_BY_STATUS.get(e.status_code, TASK_CLOSED))
def _raise(e: TaskRefused) -> Any:
out = refused(e)
if isinstance(out, JSONResponse):
return out
raise out from e
def _csv(value: str | None) -> list[str]:
return [v.strip() for v in (value or "").split(",") if v.strip()]
@router.get("")
async def list_tasks(
assigned: Literal["me", "all"] = "me",
status: str = Query(default="pending", description="pending / resolved / all,可逗号分隔"),
app_id: str | None = None,
run_id: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
x_actor: str | None = Header(default=None),
) -> dict[str, Any]:
principal = principal_of(x_actor)
if assigned == "all" and principal.mode != "off" and not principal.at_least("builder"):
raise CodedHTTPException(403, "只能查看派给自己的待办", ROLE_REQUIRED)
statuses = None if status == "all" else _csv(status) or ["pending"]
items = await task_service.list_tasks(principal, assigned=assigned, statuses=statuses, app_id=app_id,
run_id=run_id, limit=limit)
return {"items": items}
@router.get("/count")
async def count_tasks(mine: int = 1, x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""导航徽标:派给我的、待处理的待办数,按来源分开(apps:应用里的;other:画布、定时任务的审批);latest 是最新一条的开出时间。"""
return await task_service.count(principal_of(x_actor))
@router.get("/{task_id}")
async def get_task(task_id: str, x_actor: str | None = Header(default=None)) -> dict[str, Any]:
principal = principal_of(x_actor)
out = await task_service.detail(task_id, principal)
if out is None or not task_service.visible(out, principal):
raise CodedHTTPException(404, "待办不存在,所属的运行可能已被删除", TASK_NOT_FOUND)
return out
class RespondIn(BaseModel):
decision: str
values: dict[str, Any] | None = None
note: str | None = None
#: 动作确认:确认卡上显示的内容指纹,原样带回
payload_hash: str | None = None
#: 核实「已生效」时填的外部单号
external_id: str | None = None
@router.post("/{task_id}/respond", response_model=None)
async def respond(task_id: str, payload: RespondIn, x_actor: str | None = Header(default=None)) -> Any:
values = dict(payload.values or {})
if payload.external_id:
values["external_id"] = payload.external_id
try:
out = await task_service.respond(task_id, principal_of(x_actor), decision=payload.decision, values=values,
note=payload.note, payload_hash=payload.payload_hash)
except TaskRefused as e:
return _raise(e)
return {"task": out["task"], "resolved": out["resolved"]}
class Target(BaseModel):
kind: Literal["user", "group"]
name: str
class ReassignIn(BaseModel):
to: list[Target] = Field(default_factory=list)
note: str | None = None
@router.post("/{task_id}/reassign", response_model=None)
async def reassign(task_id: str, payload: ReassignIn, x_actor: str | None = Header(default=None)) -> Any:
try:
return await task_service.reassign(task_id, principal_of(x_actor),
to=[t.model_dump() for t in payload.to], note=payload.note)
except TaskRefused as e:
return _raise(e)
@router.post("/{task_id}/remind", response_model=None)
async def remind(task_id: str, x_actor: str | None = Header(default=None)) -> Any:
try:
return await task_service.remind(task_id, principal_of(x_actor))
except TaskRefused as e:
return _raise(e)