feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -24,6 +24,10 @@ class CurrentUserContext:
is_admin: bool
department_name: str = ""
cost_center: str = ""
position: str = ""
grade: str = ""
employee_no: str = ""
manager_name: str = ""
def get_current_user(
@@ -51,6 +55,22 @@ def get_current_user(
str | None,
Header(description="当前登录人的成本中心。"),
] = None,
x_auth_position: Annotated[
str | None,
Header(description="当前登录人的岗位。"),
] = None,
x_auth_grade: Annotated[
str | None,
Header(description="当前登录人的职级。"),
] = None,
x_auth_employee_no: Annotated[
str | None,
Header(description="当前登录人的员工编号。"),
] = None,
x_auth_manager_name: Annotated[
str | None,
Header(description="当前登录人的直属领导。"),
] = None,
) -> CurrentUserContext:
role_codes = [
_normalize_role_code(item)
@@ -79,6 +99,10 @@ def get_current_user(
is_admin=is_admin,
department_name=(x_auth_department or "").strip(),
cost_center=(x_auth_cost_center or "").strip(),
position=(x_auth_position or "").strip(),
grade=(x_auth_grade or "").strip(),
employee_no=(x_auth_employee_no or "").strip(),
manager_name=(x_auth_manager_name or "").strip(),
)

View File

@@ -2,21 +2,29 @@ from __future__ import annotations
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException, status
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_rule_editor_user,
require_rule_reviewer_user,
)
from app.schemas.agent_asset import (
AgentAssetRead,
AgentAssetRiskRuleDraftUpdate,
AgentAssetRiskRuleFeedbackCreate,
AgentAssetRiskRuleFeedbackRead,
AgentAssetRiskRuleRegenerateRequest,
AgentAssetRiskRuleRevisionCreate,
AgentAssetRiskRuleTemplateGroupRead,
)
from app.services.agent_asset_risk_rule_regeneration import AgentAssetRiskRuleRegenerationService
from app.services.agent_asset_risk_rule_revision import AgentAssetRiskRuleRevisionService
from app.services.agent_assets import AgentAssetService
from app.services.risk_rule_template_catalog import list_risk_rule_template_groups
router = APIRouter(prefix="/agent-assets")
DbSession = Annotated[Session, Depends(get_db)]
@@ -29,6 +37,8 @@ RequestIdHeader = Annotated[
Header(description="外部请求 ID用于串联审计日志和上游调用链。"),
]
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
def _handle_asset_error(exc: Exception) -> NoReturn:
@@ -50,6 +60,16 @@ def _read_asset(db: Session, asset_id: str) -> AgentAssetRead:
return asset
@router.get(
"/risk-rules/templates",
response_model=list[AgentAssetRiskRuleTemplateGroupRead],
summary="查询常见费控风险规则模板",
description="返回模板分组、默认自然语言、字段清单和 DSL 样例;模板只用于预填,不绕过通用生成链路。",
)
def list_risk_rule_templates(_: CurrentUser) -> list[AgentAssetRiskRuleTemplateGroupRead]:
return list_risk_rule_template_groups()
@router.patch(
"/{asset_id}/risk-rules/draft",
response_model=AgentAssetRead,
@@ -101,3 +121,80 @@ def create_risk_rule_revision(
return _read_asset(db, asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rules/regenerate",
response_model=AgentAssetRead,
summary="重新生成风险规则执行模板",
description="把未上线草稿或已上线规则的修订草稿重新解释为 DSL、流程图、风险评分和业务说明。",
)
def regenerate_risk_rule(
asset_id: str,
payload: AgentAssetRiskRuleRegenerateRequest,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
AgentAssetRiskRuleRegenerationService(db).regenerate(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
return _read_asset(db, asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rules/feedback",
response_model=AgentAssetRiskRuleFeedbackRead,
status_code=status.HTTP_201_CREATED,
summary="提交风险规则误判或漏判反馈",
description="普通用户可提交规则误判、漏判或改进反馈;该接口只记录反馈,不直接修改规则。",
)
def create_risk_rule_feedback(
asset_id: str,
payload: AgentAssetRiskRuleFeedbackCreate,
current_user: CurrentUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleFeedbackRead:
try:
return AgentAssetService(db).create_risk_rule_feedback(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/risk-rules/feedback",
response_model=list[AgentAssetRiskRuleFeedbackRead],
summary="查询风险规则反馈记录",
description="高级财务人员或 admin 管理员查看指定风险规则的误判、漏判和改进反馈记录。",
)
def list_risk_rule_feedback(
asset_id: str,
_: RuleReviewerUser,
db: DbSession,
version: Annotated[str | None, Query(max_length=30)] = None,
status_value: Annotated[str | None, Query(alias="status", max_length=30)] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> list[AgentAssetRiskRuleFeedbackRead]:
try:
return AgentAssetService(db).list_risk_rule_feedback(
asset_id,
version=version,
status=status_value,
limit=limit,
)
except Exception as exc:
_handle_asset_error(exc)

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_admin_user
from app.schemas.agent_trace import (
AgentConversationTraceRead,
AgentTraceDetailRead,
AgentTraceListItem,
)
from app.schemas.common import ErrorResponse
from app.services.agent_traces import AgentTraceService
router = APIRouter(prefix="/agent-traces")
DbSession = Annotated[Session, Depends(get_db)]
@router.get(
"",
response_model=list[AgentTraceListItem],
summary="查询 Agent Trace 列表",
description="按 Agent、状态、来源、会话或关键字查询 Agent 链路追踪记录。",
)
def list_agent_traces(
db: DbSession,
_: Annotated[object, Depends(require_admin_user)],
agent: Annotated[str | None, Query(description="Agent 名称过滤。")] = None,
status_value: Annotated[
str | None,
Query(alias="status", description="运行状态过滤。"),
] = None,
source: Annotated[str | None, Query(description="运行来源过滤。")] = None,
conversation_id: Annotated[str | None, Query(description="会话 ID 过滤。")] = None,
keyword: Annotated[str | None, Query(description="Run ID、摘要或语义关键字。")] = None,
limit: Annotated[int, Query(ge=1, le=100, description="返回记录上限。")] = 30,
) -> list[AgentTraceListItem]:
return AgentTraceService(db).list_traces(
agent=agent,
status=status_value,
source=source,
conversation_id=conversation_id,
keyword=keyword,
limit=limit,
)
@router.get(
"/conversations/{conversation_id}",
response_model=AgentConversationTraceRead,
summary="读取会话 Agent Trace",
description="按 `conversation_id` 返回该会话下多轮运行的 trace 详情。",
)
def get_conversation_trace(
conversation_id: str,
db: DbSession,
_: Annotated[object, Depends(require_admin_user)],
) -> AgentConversationTraceRead:
return AgentTraceService(db).get_conversation_trace(conversation_id)
@router.get(
"/{run_id}",
response_model=AgentTraceDetailRead,
summary="读取单次 Agent Trace",
description="按 `run_id` 返回运行摘要、事件时间线、语义解析、工具调用和关联会话消息。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "Trace 运行记录不存在。",
}
},
)
def get_agent_trace(
run_id: str,
db: DbSession,
_: Annotated[object, Depends(require_admin_user)],
) -> AgentTraceDetailRead:
trace = AgentTraceService(db).get_trace(run_id)
if trace is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent trace not found")
return trace

View File

@@ -7,8 +7,10 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
from app.schemas.finance_dashboard import FinanceDashboardRead
from app.schemas.system_dashboard import SystemDashboardRead
from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService
from app.services.finance_dashboard import FinanceDashboardService
from app.services.system_dashboard import SystemDashboardService
@@ -32,6 +34,26 @@ def get_system_dashboard(
return SystemDashboardService(db).build_dashboard(days=days)
@router.get(
"/digital-employee-dashboard",
response_model=DigitalEmployeeDashboardRead,
summary="查询数字员工工作看板",
description="基于数字员工运行记录和工具调用结果聚合每日工作、技能类型、业务产出和近期执行明细。",
)
def get_digital_employee_dashboard(
db: DbSession,
days: Annotated[
int,
Query(ge=1, le=30, description="统计窗口天数。"),
] = 7,
limit: Annotated[
int,
Query(ge=1, le=1000, description="窗口内最多读取的运行记录数。"),
] = 300,
) -> DigitalEmployeeDashboardRead:
return DigitalEmployeeDashboardService(db).build_dashboard(days=days, limit=limit)
@router.get(
"/finance-dashboard",
response_model=FinanceDashboardRead,

View File

@@ -5,8 +5,9 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.auth import (
AuthUserRead,
LoginRequest,
LoginResponse,
SessionFinishRequest,
@@ -39,6 +40,42 @@ def login(payload: LoginRequest, db: DbSession) -> LoginResponse:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
@router.get(
"/me",
response_model=AuthUserRead,
summary="读取当前登录用户",
description="根据当前会话请求头刷新前端登录态中的员工姓名、部门、岗位和职级。",
)
def get_current_auth_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: DbSession,
) -> AuthUserRead:
user = AuthService(db).get_user_snapshot(current_user.username)
if user is not None:
return user
if current_user.is_admin:
name = current_user.name or current_user.username or "系统管理员"
return AuthUserRead(
username=current_user.username or name,
name=name,
role="管理员",
department=current_user.department_name,
departmentName=current_user.department_name,
position=current_user.position or "系统管理员",
grade=current_user.grade,
employeeNo=current_user.employee_no,
managerName=current_user.manager_name,
costCenter=current_user.cost_center,
roleCodes=current_user.role_codes or ["manager"],
email=current_user.username if "@" in current_user.username else f"{current_user.username}@local",
avatar=name[:1].upper(),
isAdmin=True,
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="当前登录用户不存在或已停用")
@router.post(
"/sessions/{session_id}/finish",
response_model=SessionFinishResponse,

View File

@@ -545,6 +545,34 @@ def delete_expense_claim_item_attachment(
return ExpenseClaimAttachmentActionResponse(**payload)
@router.post(
"/claims/{claim_id}/pre-review",
response_model=ExpenseClaimRead,
summary="执行报销单 AI 预审",
description="只执行 AI 预审并回写风险结果,不提交到审批流程。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "草稿信息不完整或状态不允许预审。",
},
},
)
def pre_review_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.pre_review_claim(claim_id, current_user)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.post(
"/claims/{claim_id}/submit",
response_model=ExpenseClaimRead,

View File

@@ -4,6 +4,7 @@ from app.api.v1.endpoints.agent_asset_risk_rules import router as agent_asset_ri
from app.api.v1.endpoints.agent_assets import router as agent_assets_router
from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
from app.api.v1.endpoints.agent_runs import router as agent_runs_router
from app.api.v1.endpoints.agent_traces import router as agent_traces_router
from app.api.v1.endpoints.analytics import router as analytics_router
from app.api.v1.endpoints.audit_logs import router as audit_logs_router
from app.api.v1.endpoints.auth import router as auth_router
@@ -31,6 +32,7 @@ router.include_router(agent_assets_router, tags=["agent-assets"])
router.include_router(agent_asset_risk_rules_router, tags=["agent-assets"])
router.include_router(agent_feedback_router, tags=["agent-feedback"])
router.include_router(agent_runs_router, tags=["agent-runs"])
router.include_router(agent_traces_router, tags=["agent-traces"])
router.include_router(analytics_router, tags=["analytics"])
router.include_router(audit_logs_router, tags=["audit-logs"])
router.include_router(knowledge_router, tags=["knowledge"])