mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:31:58 +00:00
e82d7fa55d
外部系统只能按中文句子认是哪种错,文案一改就悄悄失灵;开了登录,接口说明仍然不用登录就能读,全部接口的结构对匿名的人 敞开(中间件只拦 /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,测试失败。
131 lines
5.3 KiB
Python
131 lines
5.3 KiB
Python
"""待办(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)
|