Files
company-ai-platform/app/modules/risk/service.py
JiuContinent 9b87c6a7a3 ```
feat: 添加飞书集成和改进安全配置

- 集成 lark-oapi 库以支持飞书功能
- 改进 CORS 配置验证器以支持 JSON 格式输入
- 添加安全凭证检查逻辑以防止跨域安全问题
- 在 DirectLLMAdapter 中增加响应解析异常处理

fix: 增强查询参数验证和分页限制

- 为多个路由添加 Query 参数验证器
- 实现 bounded_limit 和 bounded_offset 辅助函数
- 设置查询限制范围为 1-500 之间
- 使用 secrets.compare_digest 提升令牌验证安全性

refactor: 调整文档忽略规则和测试配置

- 更新 .gitignore 文件中的文档路径配置
- 在 smoke 测试中添加必要的环境变量配置
- 重构配置验证器以提高类型兼容性
```
2026-07-06 10:27:17 +08:00

330 lines
13 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
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("risk_type") not in GENERATED_RISK_EVENT_TYPES
]
risk_score = (
len(overdue_tasks) * 1
+ len(delayed_projects) * 3
+ len(over_budget_projects) * 4
+ len(fund_risks) * 5
+ len(supplier_risks) * 3
+ len(external_open_events) * 2
)
if risk_score >= 15:
level = RiskLevel.HIGH
elif risk_score >= 5:
level = RiskLevel.MEDIUM
else:
level = RiskLevel.LOW
return {
"risk_level": level,
"risk_score": Decimal(risk_score),
"overdue_tasks": overdue_tasks,
"delayed_projects": delayed_projects,
"over_budget_projects": over_budget_projects,
"fund_risks": fund_risks,
"supplier_risks": supplier_risks,
"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["code"])
).scalar_one_or_none()
if record is None:
record = RiskEvent(**payload)
self.db.add(record)
self.db.flush()
created += 1
action = "created"
elif record.status in CLOSED_RISK_STATUSES:
skipped += 1
items.append({"action": "skipped", "risk_event": serialize_model(record)})
continue
else:
for key, value in payload.items():
if key != "code":
setattr(record, key, value)
updated += 1
action = "updated"
items.append({"action": action, "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={
"created": created,
"updated": updated,
"skipped": skipped,
},
)
)
return {"created": created, "updated": updated, "skipped": skipped, "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(
{
"code": f"RISK-TASK-OVERDUE-{task.id}",
"title": f"任务逾期:{task.title}",
"risk_type": RiskEventType.OVERDUE_TASK,
"risk_level": RiskLevel.MEDIUM,
"status": StatusValue.OPEN,
"source_domain": BusinessDomain.TASKS,
"source_record_id": str(task.id),
"project_code": task.project_code,
"owner": task.owner,
"due_date": task.due_date,
"detected_at": utc_now(),
"description": "任务已超过截止日期且未完成。",
"mitigation": (
"请负责人更新进度、明确阻塞项并给出新的完成时间。"
),
"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(
{
"code": f"RISK-PROJECT-DELAY-{project.id}",
"title": f"项目延期:{project.name}",
"risk_type": RiskEventType.DELAYED_PROJECT,
"risk_level": level,
"status": StatusValue.OPEN,
"source_domain": BusinessDomain.PROJECTS,
"source_record_id": str(project.id),
"project_code": project.code,
"owner": project.owner,
"due_date": project.due_date,
"detected_at": utc_now(),
"description": "项目已超过计划截止日期且未进入完成状态。",
"mitigation": (
"请项目负责人提交延期原因、资源需求和纠偏计划。"
),
"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(
{
"code": f"RISK-PROJECT-BUDGET-{project.id}",
"title": f"项目超预算:{project.name}",
"risk_type": RiskEventType.OVER_BUDGET_PROJECT,
"risk_level": RiskLevel.HIGH,
"status": StatusValue.OPEN,
"source_domain": BusinessDomain.PROJECTS,
"source_record_id": str(project.id),
"project_code": project.code,
"owner": project.owner,
"due_date": project.due_date,
"detected_at": utc_now(),
"description": "项目实际成本已超过预算。",
"mitigation": (
"请复核预算科目、冻结非必要采购并补充审批依据。"
),
"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(
{
"code": f"RISK-FUND-{account.id}",
"title": f"资金低于安全线:{account.name}",
"risk_type": RiskEventType.FUND_SAFETY_LINE,
"risk_level": RiskLevel.HIGH,
"status": StatusValue.OPEN,
"source_domain": BusinessDomain.FUND_ACCOUNTS,
"source_record_id": str(account.id),
"owner": None,
"detected_at": utc_now(),
"description": "账户当前余额低于设置的安全线。",
"mitigation": (
"请财务确认收付款计划,"
"并优先处理关键项目资金安排。"
),
"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(
{
"code": f"RISK-SUPPLIER-{supplier.id}",
"title": f"供应商风险:{supplier.name}",
"risk_type": RiskEventType.SUPPLIER_RISK,
"risk_level": level,
"status": StatusValue.OPEN,
"source_domain": BusinessDomain.SUPPLIERS,
"source_record_id": str(supplier.id),
"owner": supplier.contact,
"detected_at": utc_now(),
"description": "供应商风险等级或黑名单状态需要关注。",
"mitigation": (
"请采购负责人复核供应商准入、履约和替代方案。"
),
"evidence": serialize_model(supplier),
}
)
return payloads