Files
X-Financial/server/src/app/api/v1/endpoints/agent_assets.py

558 lines
18 KiB
Python
Raw Normal View History

2026-05-11 03:51:24 +00:00
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, status
from fastapi.responses import FileResponse
2026-05-11 03:51:24 +00:00
from sqlalchemy.orm import Session
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_admin_user,
require_rule_editor_user,
require_rule_reviewer_user,
)
2026-05-11 03:51:24 +00:00
from app.schemas.agent_asset import (
AgentAssetCreate,
AgentAssetListItem,
AgentAssetOnlyOfficeCallbackRead,
AgentAssetOnlyOfficeCallbackWrite,
AgentAssetOnlyOfficeConfigRead,
2026-05-11 03:51:24 +00:00
AgentAssetRead,
AgentAssetReviewCreate,
AgentAssetReviewRead,
AgentAssetVersionCompareRead,
2026-05-11 03:51:24 +00:00
AgentAssetUpdate,
AgentAssetVersionCreate,
AgentAssetVersionRead,
AgentAssetVersionTimelineItemRead,
2026-05-11 03:51:24 +00:00
)
from app.schemas.common import ErrorResponse
2026-05-11 03:51:24 +00:00
from app.services.agent_assets import AgentAssetService
router = APIRouter(prefix="/agent-assets")
DbSession = Annotated[Session, Depends(get_db)]
ActorHeader = Annotated[
str | None,
Header(description="审计操作者。未传时回退到请求体中的 owner / reviewer 或 `system`。"),
]
RequestIdHeader = Annotated[
str | None,
Header(description="外部请求 ID用于串联审计日志和上游调用链。"),
]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
AdminUser = Annotated[CurrentUserContext, Depends(require_admin_user)]
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
2026-05-11 03:51:24 +00:00
def _handle_asset_error(exc: Exception) -> None:
if isinstance(exc, LookupError):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if isinstance(exc, PermissionError):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if isinstance(exc, ValueError):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
raise exc
@router.get(
"",
response_model=list[AgentAssetListItem],
summary="查询 Agent 资产列表",
description="按资产类型、状态、领域和关键字筛选规则、技能、MCP 与任务资产。",
)
2026-05-11 03:51:24 +00:00
def list_agent_assets(
db: DbSession,
asset_type: Annotated[
str | None,
Query(description="资产类型:`rule`、`skill`、`mcp`、`task`。"),
] = None,
status_value: Annotated[
str | None,
Query(alias="status", description="资产状态筛选。"),
] = None,
domain: Annotated[
str | None,
Query(description="业务领域筛选,例如 `expense`、`ar`、`ap`。"),
] = None,
keyword: Annotated[
str | None,
Query(description="资产编码、名称关键字模糊查询。"),
] = None,
2026-05-11 03:51:24 +00:00
) -> list[AgentAssetListItem]:
return AgentAssetService(db).list_assets(
asset_type=asset_type,
status=status_value,
domain=domain,
keyword=keyword,
)
@router.get(
"/{asset_id}",
response_model=AgentAssetRead,
summary="读取 Agent 资产详情",
description="返回资产当前版本正文、最近版本列表和最近一次审核信息。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "资产不存在。",
}
},
)
2026-05-11 03:51:24 +00:00
def get_agent_asset(asset_id: str, db: DbSession) -> AgentAssetRead:
asset = AgentAssetService(db).get_asset(asset_id)
if asset is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
return asset
@router.get(
"/{asset_id}/spreadsheet/onlyoffice-config",
response_model=AgentAssetOnlyOfficeConfigRead,
summary="读取规则 Excel 的 ONLYOFFICE 配置",
description="为规则详情页中的 Excel 规则表生成 ONLYOFFICE 配置。",
)
def get_agent_asset_spreadsheet_onlyoffice_config(
asset_id: str,
current_user: CurrentUser,
db: DbSession,
version: Annotated[
str | None,
Query(description="可选的规则版本号;不传时默认当前版本。"),
] = None,
) -> AgentAssetOnlyOfficeConfigRead:
try:
return AgentAssetService(db).build_rule_spreadsheet_onlyoffice_config(
asset_id,
current_user,
version=version,
)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/spreadsheet/content",
response_class=FileResponse,
summary="下载或预览规则 Excel 文件",
description="按版本返回规则的 Excel 快照,用于浏览器预览或下载。",
)
def get_agent_asset_spreadsheet_content(
asset_id: str,
_: CurrentUser,
db: DbSession,
version: Annotated[
str | None,
Query(description="可选的规则版本号;不传时默认当前版本。"),
] = None,
) -> FileResponse:
try:
file_path, media_type, filename = AgentAssetService(db).get_rule_spreadsheet_content(
asset_id,
version=version,
)
except Exception as exc:
_handle_asset_error(exc)
return FileResponse(file_path, media_type=media_type, filename=filename)
@router.get(
"/{asset_id}/spreadsheet/onlyoffice/content",
response_class=FileResponse,
summary="供 ONLYOFFICE 读取规则 Excel 源文件",
description="使用短时令牌供 ONLYOFFICE 拉取规则表源文件。",
)
def get_agent_asset_spreadsheet_onlyoffice_content(
asset_id: str,
db: DbSession,
version: Annotated[
str,
Query(min_length=1, description="规则版本号。"),
],
access_token: Annotated[
str,
Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"),
],
) -> FileResponse:
try:
service = AgentAssetService(db)
service.validate_rule_spreadsheet_access_token(asset_id, version, access_token)
file_path, media_type, filename = service.get_rule_spreadsheet_content(
asset_id,
version=version,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
except Exception as exc:
_handle_asset_error(exc)
return FileResponse(file_path, media_type=media_type, filename=filename)
@router.post(
"/{asset_id}/spreadsheet/upload",
response_model=AgentAssetRead,
status_code=status.HTTP_201_CREATED,
summary="上传规则 Excel 文件",
description="为指定规则上传新的 Excel 快照,并自动生成新规则版本。",
)
def upload_agent_asset_spreadsheet(
asset_id: str,
content: Annotated[
bytes,
Body(
media_type="application/octet-stream",
description="待上传的 Excel 文件二进制内容。",
),
],
filename: Annotated[str, Query(min_length=1, description="原始文件名。")],
current_user: RuleEditorUser,
db: DbSession,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).upload_rule_spreadsheet(
asset_id,
filename=filename,
content=content,
actor=current_user.name,
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/spreadsheet/import-content",
response_model=AgentAssetRead,
status_code=status.HTTP_201_CREATED,
summary="导入规则 Excel 表格内容",
description="读取上传 Excel 中的工作表内容,写回当前规则表;保留当前规则文件名与规则身份。",
)
def import_agent_asset_spreadsheet_content(
asset_id: str,
content: Annotated[
bytes,
Body(
media_type="application/octet-stream",
description="待导入的 Excel 文件二进制内容。",
),
],
filename: Annotated[str, Query(min_length=1, description="上传文件原始文件名。")],
current_user: RuleEditorUser,
db: DbSession,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).import_rule_spreadsheet_content(
asset_id,
filename=filename,
content=content,
actor=current_user.name,
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/spreadsheet/onlyoffice/callback",
response_model=AgentAssetOnlyOfficeCallbackRead,
summary="接收规则 Excel 的 ONLYOFFICE 回调",
description="接收 ONLYOFFICE 回写内容,并自动生成新的规则版本。",
)
def handle_agent_asset_spreadsheet_onlyoffice_callback(
asset_id: str,
payload: AgentAssetOnlyOfficeCallbackWrite,
db: DbSession,
version: Annotated[
str,
Query(min_length=1, description="打开编辑器时对应的规则版本号。"),
],
) -> AgentAssetOnlyOfficeCallbackRead:
try:
AgentAssetService(db).handle_rule_spreadsheet_onlyoffice_callback(
asset_id,
version=version,
payload=payload.model_dump(),
)
except Exception as exc:
_handle_asset_error(exc)
return AgentAssetOnlyOfficeCallbackRead()
@router.post(
"",
response_model=AgentAssetRead,
status_code=status.HTTP_201_CREATED,
summary="创建 Agent 资产",
description="创建新的规则、技能、MCP 或任务资产,并自动记录审计日志。",
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "资产编码冲突或请求字段不合法。",
}
},
)
2026-05-11 03:51:24 +00:00
def create_agent_asset(
payload: AgentAssetCreate,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
2026-05-11 03:51:24 +00:00
) -> AgentAssetRead:
try:
return AgentAssetService(db).create_asset(
payload,
actor=(x_actor or payload.owner).strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.patch(
"/{asset_id}",
response_model=AgentAssetRead,
summary="更新 Agent 资产",
description="更新资产基础信息、当前版本、状态和配置,并写入审计日志。",
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "状态更新非法或请求字段不合法。",
},
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "资产或指定版本不存在。",
},
},
)
2026-05-11 03:51:24 +00:00
def update_agent_asset(
asset_id: str,
payload: AgentAssetUpdate,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
2026-05-11 03:51:24 +00:00
) -> AgentAssetRead:
try:
return AgentAssetService(db).update_asset(
asset_id,
payload,
actor=(x_actor or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/versions",
response_model=list[AgentAssetVersionRead],
summary="查询资产版本列表",
description="返回指定资产的版本历史,默认按最近版本优先排序。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "资产不存在。",
}
},
)
2026-05-11 03:51:24 +00:00
def list_agent_asset_versions(
asset_id: str,
db: DbSession,
limit: Annotated[
int,
Query(ge=1, le=100, description="返回版本数量上限。"),
] = 20,
2026-05-11 03:51:24 +00:00
) -> list[AgentAssetVersionRead]:
try:
return AgentAssetService(db).list_versions(asset_id, limit=limit)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/versions",
response_model=AgentAssetVersionRead,
status_code=status.HTTP_201_CREATED,
summary="创建资产版本",
description="为指定资产创建新版本;规则使用 Markdown其他资产使用 JSON 快照。",
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "版本号重复或内容类型不匹配。",
},
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "资产不存在。",
},
},
2026-05-11 03:51:24 +00:00
)
def create_agent_asset_version(
asset_id: str,
payload: AgentAssetVersionCreate,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
2026-05-11 03:51:24 +00:00
) -> AgentAssetVersionRead:
try:
return AgentAssetService(db).create_version(
asset_id,
payload,
actor=(x_actor or payload.created_by).strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/reviews",
response_model=AgentAssetReviewRead,
status_code=status.HTTP_201_CREATED,
summary="创建资产审核记录",
description="为指定资产版本写入审核结果,并联动更新资产状态。",
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "审核参数不合法。",
},
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "资产或版本不存在。",
},
},
2026-05-11 03:51:24 +00:00
)
def create_agent_asset_review(
asset_id: str,
payload: AgentAssetReviewCreate,
current_user: CurrentUser,
2026-05-11 03:51:24 +00:00
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
2026-05-11 03:51:24 +00:00
) -> AgentAssetReviewRead:
try:
role_codes = {item.strip() for item in current_user.role_codes}
if payload.review_status.value == "pending":
if not (
current_user.is_admin
or "manager" in role_codes
or "finance" in role_codes
):
raise PermissionError("只有财务人员或高级管理人员可以提交审核。")
elif not (current_user.is_admin or "manager" in role_codes):
raise PermissionError("只有高级管理人员可以审核规则。")
2026-05-11 03:51:24 +00:00
return AgentAssetService(db).create_review(
asset_id,
payload,
actor=(x_actor or payload.reviewer).strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/activate",
response_model=AgentAssetRead,
summary="激活资产当前版本",
description="将资产当前版本切换为上线状态;规则资产必须已有 `approved` 审核记录。",
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "审核未通过或当前版本未设置。",
},
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "资产不存在。",
},
},
)
2026-05-11 03:51:24 +00:00
def activate_agent_asset(
asset_id: str,
_: RuleReviewerUser,
2026-05-11 03:51:24 +00:00
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
2026-05-11 03:51:24 +00:00
) -> AgentAssetRead:
try:
return AgentAssetService(db).activate_asset(
asset_id,
actor=(x_actor or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/versions/{version}/restore",
response_model=AgentAssetRead,
summary="基于历史版本恢复工作稿",
description="复制指定历史版本内容生成新的工作版本,用于误上线后的快速恢复与重新审核。",
)
def restore_agent_asset_version(
asset_id: str,
version: str,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).restore_version_as_working_copy(
asset_id,
version,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/version-timeline",
response_model=list[AgentAssetVersionTimelineItemRead],
summary="读取规则版本流转时间线",
description="返回规则版本创建、提交审核、审核结果和正式上线等流转事件。",
)
def get_agent_asset_version_timeline(
asset_id: str,
_: CurrentUser,
db: DbSession,
) -> list[AgentAssetVersionTimelineItemRead]:
try:
return AgentAssetService(db).list_version_timeline(asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/versions/compare",
response_model=AgentAssetVersionCompareRead,
summary="比较两个规则表版本",
description="对比两个 Excel 规则表版本的工作表变化与单元格级差异。",
)
def compare_agent_asset_spreadsheet_versions(
asset_id: str,
_: CurrentUser,
db: DbSession,
base_version: Annotated[str, Query(min_length=1, description="基准版本号")],
target_version: Annotated[str, Query(min_length=1, description="对比版本号")],
) -> AgentAssetVersionCompareRead:
try:
return AgentAssetService(db).compare_spreadsheet_versions(
asset_id,
base_version=base_version,
target_version=target_version,
)
except Exception as exc:
_handle_asset_error(exc)