Files
company-ai-platform/app/modules/risk/service.py
JiuContinent fbd0aaa9e4 ```
refactor(api): 使用常量替代硬编码字符串

- 在health_check接口中使用ApiResponseKey.STATUS和ApiStatus.OK常量
- 替换硬编码的状态返回值为枚举常量

refactor(core): 配置模块错误信息统一使用常量

- 从constants模块导入ConfigErrorDetail并替换CORS_ORIGINS和LEGACY_ALLOWED_QUERIES的验证错误信息
- 配置类中的默认值使用constants中定义的常量

feat(constants): 添加API响应、安全错误和配置错误常量类

- 新增ApiResponseKey用于API状态键名
- 新增ApiStatus用于API状态值
- 新增SecurityErrorDetail用于安全认证错误详情
- 新增ConfigErrorDetail用于配置验证错误详情
- 添加DEFAULT_MODEL_PROVIDER和DEFAULT_OPENCLAW_ACTION_JSON常量

refactor(security): 安全认证模块使用错误常量

- 将硬编码的安全错误信息替换为SecurityErrorDetail常量
- 包括API密钥、审批密钥和审计密钥的相关错误信息

refactor(ai-agent): AI代理适配器改进错误处理

- 将HTTP状态码替换为FastAPI状态常量
- 添加OpenClaw工具和操作的错误常量
- 修复健康检查和工具调用中的状态码比较逻辑
- 添加AIToolAuditKey用于工具审计键名

feat(ai-agent): 扩展AI代理常量定义

- 新增AIToolAuditKey用于工具审计字段
- 添加OpenClaw相关的错误常量如OPENCLAW_CHAT_PROVIDER_REQUIRED等
- 添加UNSUPPORTED_AI_SKILL_TEMPLATE模板字符串

refactor(approvals): 审批模块常量化重构

- 新增ApprovalPayloadKey用于审批载荷字段
- 添加approval_action函数和APPROVAL_ACTION_SEPARATOR分隔符
- 使用常量替换字面量值

feat(audit): 审计模块新增飞书事件动作类型

- 添加FEISHU_WEBHOOK_EVENT和FEISHU_LONG_CONNECTION_EVENT审计动作

refactor(business): 业务模块全面常量化

- 新增BusinessDomain枚举包含所有业务域
- 添加BusinessResponseKey、BusinessPayloadKey等常量类
- 重构DOMAIN_MODELS为frozenset以提高性能
- 添加normalize_domain等辅助函数用于域标准化
- 使用常量替换路由和业务服务中的硬编码字符串
- 添加业务错误常量和字段验证模板

refactor(feishu): 飞书客户端错误处理优化

- 将HTTP状态码替换为FastAPI标准状态常量
- 改进错误处理的一致性

refactor(approvals): 审批服务使用新常量结构

- 使用ApprovalPayloadKey常量重构载荷字段
- 使用approval_action函数统一动作命名格式
- 优化高风险域判断逻辑
```
2026-07-06 15:56:43 +08:00

348 lines
15 KiB
Python

from datetime import date
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.pagination import bounded_limit
from app.core.time import utc_now
from app.modules.audit.constants import (
AuditAction,
AuditRiskLevel,
AuditSource,
AuditTargetType,
)
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.business.constants import (
CLOSED_RISK_STATUSES,
DONE_STATUSES,
GENERATED_RISK_EVENT_TYPES,
PROJECT_CLOSED_STATUSES,
SUPPLIER_RISK_LEVELS,
BusinessDomain,
RiskEventType,
RiskLevel,
StatusValue,
)
from app.modules.business.models import FundAccount, Project, RiskEvent, Supplier, WorkTask
from app.modules.business.service import serialize_model
from app.modules.risk.constants import (
RISK_SCORE_WEIGHTS,
RiskGenerationAction,
RiskGenerationResultKey,
RiskEventPayloadKey,
RiskSummaryKey,
risk_level_for_score,
)
class RiskService:
"""Evaluate rule-based business risk signals from internal ledgers."""
def __init__(self, db: Session):
self.db = db
def overdue_tasks(self) -> list[dict[str, Any]]:
stmt = select(WorkTask).where(
WorkTask.due_date.is_not(None),
WorkTask.due_date < date.today(),
WorkTask.status.notin_(DONE_STATUSES),
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def delayed_projects(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.due_date.is_not(None),
Project.due_date < date.today(),
Project.status.notin_(PROJECT_CLOSED_STATUSES),
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def over_budget_projects(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.budget_amount > 0,
Project.actual_amount > Project.budget_amount,
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def fund_risks(self) -> list[dict[str, Any]]:
stmt = select(FundAccount).where(FundAccount.current_balance < FundAccount.safety_line)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def supplier_risks(self) -> list[dict[str, Any]]:
stmt = select(Supplier).where(
(Supplier.blacklist_status != StatusValue.NORMAL)
| Supplier.risk_level.in_(SUPPLIER_RISK_LEVELS)
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def list_events(
self,
limit: int = 100,
status_filter: str | None = None,
) -> list[dict[str, Any]]:
limit_value = bounded_limit(limit)
stmt = select(RiskEvent).order_by(RiskEvent.id.desc()).limit(limit_value)
if status_filter:
stmt = (
select(RiskEvent)
.where(RiskEvent.status == status_filter)
.order_by(RiskEvent.id.desc())
.limit(limit_value)
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def summary(self) -> dict[str, Any]:
overdue_tasks = self.overdue_tasks()
delayed_projects = self.delayed_projects()
over_budget_projects = self.over_budget_projects()
fund_risks = self.fund_risks()
supplier_risks = self.supplier_risks()
open_events = self.list_events(status_filter=StatusValue.OPEN)
external_open_events = [
item
for item in open_events
if item.get(RiskEventPayloadKey.RISK_TYPE) not in GENERATED_RISK_EVENT_TYPES
]
risk_score = (
len(overdue_tasks) * RISK_SCORE_WEIGHTS[RiskSummaryKey.OVERDUE_TASKS]
+ len(delayed_projects) * RISK_SCORE_WEIGHTS[RiskSummaryKey.DELAYED_PROJECTS]
+ len(over_budget_projects)
* RISK_SCORE_WEIGHTS[RiskSummaryKey.OVER_BUDGET_PROJECTS]
+ len(fund_risks) * RISK_SCORE_WEIGHTS[RiskSummaryKey.FUND_RISKS]
+ len(supplier_risks) * RISK_SCORE_WEIGHTS[RiskSummaryKey.SUPPLIER_RISKS]
+ len(external_open_events) * RISK_SCORE_WEIGHTS[RiskSummaryKey.OPEN_EVENTS]
)
return {
RiskSummaryKey.RISK_LEVEL: risk_level_for_score(risk_score),
RiskSummaryKey.RISK_SCORE: Decimal(risk_score),
RiskSummaryKey.OVERDUE_TASKS: overdue_tasks,
RiskSummaryKey.DELAYED_PROJECTS: delayed_projects,
RiskSummaryKey.OVER_BUDGET_PROJECTS: over_budget_projects,
RiskSummaryKey.FUND_RISKS: fund_risks,
RiskSummaryKey.SUPPLIER_RISKS: supplier_risks,
RiskSummaryKey.OPEN_EVENTS: open_events,
}
def generate_events(self, actor: str = ActorValue.API) -> dict[str, Any]:
"""Generate or refresh risk-event ledger entries from current signals."""
payloads = self._build_event_payloads()
created = 0
updated = 0
skipped = 0
items: list[dict[str, Any]] = []
for payload in payloads:
record = self.db.execute(
select(RiskEvent).where(RiskEvent.code == payload[RiskEventPayloadKey.CODE])
).scalar_one_or_none()
if record is None:
record = RiskEvent(**payload)
self.db.add(record)
self.db.flush()
created += 1
action = RiskGenerationAction.CREATED
elif record.status in CLOSED_RISK_STATUSES:
skipped += 1
items.append(
{
RiskGenerationResultKey.ACTION: RiskGenerationAction.SKIPPED,
RiskGenerationResultKey.RISK_EVENT: serialize_model(record),
}
)
continue
else:
for key, value in payload.items():
if key != RiskEventPayloadKey.CODE:
setattr(record, key, value)
updated += 1
action = RiskGenerationAction.UPDATED
items.append(
{
RiskGenerationResultKey.ACTION: action,
RiskGenerationResultKey.RISK_EVENT: serialize_model(record),
}
)
self.db.commit()
AuditService(self.db).log(
AuditLogCreate(
actor=actor,
source=AuditSource.RISK,
action=AuditAction.GENERATE_EVENTS,
target_type=AuditTargetType.RISK_EVENTS,
risk_level=AuditRiskLevel.MEDIUM,
response_payload={
RiskGenerationResultKey.CREATED: created,
RiskGenerationResultKey.UPDATED: updated,
RiskGenerationResultKey.SKIPPED: skipped,
},
)
)
return {
RiskGenerationResultKey.CREATED: created,
RiskGenerationResultKey.UPDATED: updated,
RiskGenerationResultKey.SKIPPED: skipped,
RiskGenerationResultKey.ITEMS: items,
}
def _build_event_payloads(self) -> list[dict[str, Any]]:
payloads: list[dict[str, Any]] = []
payloads.extend(self._overdue_task_payloads())
payloads.extend(self._delayed_project_payloads())
payloads.extend(self._over_budget_project_payloads())
payloads.extend(self._fund_risk_payloads())
payloads.extend(self._supplier_risk_payloads())
return payloads
def _overdue_task_payloads(self) -> list[dict[str, Any]]:
stmt = select(WorkTask).where(
WorkTask.due_date.is_not(None),
WorkTask.due_date < date.today(),
WorkTask.status.notin_(DONE_STATUSES),
)
payloads = []
for task in self.db.execute(stmt).scalars():
payloads.append(
{
RiskEventPayloadKey.CODE: f"RISK-TASK-OVERDUE-{task.id}",
RiskEventPayloadKey.TITLE: f"任务逾期:{task.title}",
RiskEventPayloadKey.RISK_TYPE: RiskEventType.OVERDUE_TASK,
RiskEventPayloadKey.RISK_LEVEL: RiskLevel.MEDIUM,
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.TASKS,
RiskEventPayloadKey.SOURCE_RECORD_ID: str(task.id),
RiskEventPayloadKey.PROJECT_CODE: task.project_code,
RiskEventPayloadKey.OWNER: task.owner,
RiskEventPayloadKey.DUE_DATE: task.due_date,
RiskEventPayloadKey.DETECTED_AT: utc_now(),
RiskEventPayloadKey.DESCRIPTION: "任务已超过截止日期且未完成。",
RiskEventPayloadKey.MITIGATION: (
"请负责人更新进度、明确阻塞项并给出新的完成时间。"
),
RiskEventPayloadKey.EVIDENCE: serialize_model(task),
}
)
return payloads
def _delayed_project_payloads(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.due_date.is_not(None),
Project.due_date < date.today(),
Project.status.notin_(PROJECT_CLOSED_STATUSES),
)
payloads = []
for project in self.db.execute(stmt).scalars():
level = RiskLevel.HIGH if project.progress_percent < 80 else RiskLevel.MEDIUM
payloads.append(
{
RiskEventPayloadKey.CODE: f"RISK-PROJECT-DELAY-{project.id}",
RiskEventPayloadKey.TITLE: f"项目延期:{project.name}",
RiskEventPayloadKey.RISK_TYPE: RiskEventType.DELAYED_PROJECT,
RiskEventPayloadKey.RISK_LEVEL: level,
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.PROJECTS,
RiskEventPayloadKey.SOURCE_RECORD_ID: str(project.id),
RiskEventPayloadKey.PROJECT_CODE: project.code,
RiskEventPayloadKey.OWNER: project.owner,
RiskEventPayloadKey.DUE_DATE: project.due_date,
RiskEventPayloadKey.DETECTED_AT: utc_now(),
RiskEventPayloadKey.DESCRIPTION: "项目已超过计划截止日期且未进入完成状态。",
RiskEventPayloadKey.MITIGATION: (
"请项目负责人提交延期原因、资源需求和纠偏计划。"
),
RiskEventPayloadKey.EVIDENCE: serialize_model(project),
}
)
return payloads
def _over_budget_project_payloads(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.budget_amount > 0,
Project.actual_amount > Project.budget_amount,
)
payloads = []
for project in self.db.execute(stmt).scalars():
payloads.append(
{
RiskEventPayloadKey.CODE: f"RISK-PROJECT-BUDGET-{project.id}",
RiskEventPayloadKey.TITLE: f"项目超预算:{project.name}",
RiskEventPayloadKey.RISK_TYPE: RiskEventType.OVER_BUDGET_PROJECT,
RiskEventPayloadKey.RISK_LEVEL: RiskLevel.HIGH,
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.PROJECTS,
RiskEventPayloadKey.SOURCE_RECORD_ID: str(project.id),
RiskEventPayloadKey.PROJECT_CODE: project.code,
RiskEventPayloadKey.OWNER: project.owner,
RiskEventPayloadKey.DUE_DATE: project.due_date,
RiskEventPayloadKey.DETECTED_AT: utc_now(),
RiskEventPayloadKey.DESCRIPTION: "项目实际成本已超过预算。",
RiskEventPayloadKey.MITIGATION: (
"请复核预算科目、冻结非必要采购并补充审批依据。"
),
RiskEventPayloadKey.EVIDENCE: serialize_model(project),
}
)
return payloads
def _fund_risk_payloads(self) -> list[dict[str, Any]]:
stmt = select(FundAccount).where(FundAccount.current_balance < FundAccount.safety_line)
payloads = []
for account in self.db.execute(stmt).scalars():
payloads.append(
{
RiskEventPayloadKey.CODE: f"RISK-FUND-{account.id}",
RiskEventPayloadKey.TITLE: f"资金低于安全线:{account.name}",
RiskEventPayloadKey.RISK_TYPE: RiskEventType.FUND_SAFETY_LINE,
RiskEventPayloadKey.RISK_LEVEL: RiskLevel.HIGH,
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.FUND_ACCOUNTS,
RiskEventPayloadKey.SOURCE_RECORD_ID: str(account.id),
RiskEventPayloadKey.OWNER: None,
RiskEventPayloadKey.DETECTED_AT: utc_now(),
RiskEventPayloadKey.DESCRIPTION: "账户当前余额低于设置的安全线。",
RiskEventPayloadKey.MITIGATION: (
"请财务确认收付款计划,"
"并优先处理关键项目资金安排。"
),
RiskEventPayloadKey.EVIDENCE: serialize_model(account),
}
)
return payloads
def _supplier_risk_payloads(self) -> list[dict[str, Any]]:
stmt = select(Supplier).where(
(Supplier.blacklist_status != StatusValue.NORMAL)
| Supplier.risk_level.in_(SUPPLIER_RISK_LEVELS)
)
payloads = []
for supplier in self.db.execute(stmt).scalars():
level = (
RiskLevel.HIGH
if supplier.blacklist_status != StatusValue.NORMAL
else supplier.risk_level
)
payloads.append(
{
RiskEventPayloadKey.CODE: f"RISK-SUPPLIER-{supplier.id}",
RiskEventPayloadKey.TITLE: f"供应商风险:{supplier.name}",
RiskEventPayloadKey.RISK_TYPE: RiskEventType.SUPPLIER_RISK,
RiskEventPayloadKey.RISK_LEVEL: level,
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.SUPPLIERS,
RiskEventPayloadKey.SOURCE_RECORD_ID: str(supplier.id),
RiskEventPayloadKey.OWNER: supplier.contact,
RiskEventPayloadKey.DETECTED_AT: utc_now(),
RiskEventPayloadKey.DESCRIPTION: "供应商风险等级或黑名单状态需要关注。",
RiskEventPayloadKey.MITIGATION: (
"请采购负责人复核供应商准入、履约和替代方案。"
),
RiskEventPayloadKey.EVIDENCE: serialize_model(supplier),
}
)
return payloads