```
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函数统一动作命名格式 - 优化高风险域判断逻辑 ```
This commit is contained in:
@@ -2,7 +2,7 @@ from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.modules.ai_agent.constants import (
|
||||
@@ -11,7 +11,10 @@ from app.modules.ai_agent.constants import (
|
||||
COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS,
|
||||
DIRECT_LLM_API_KEY_MISSING,
|
||||
NOOP_PROVIDER_ANSWER,
|
||||
OPENCLAW_ACTION_NOT_ALLOWED,
|
||||
OPENCLAW_CHAT_PROVIDER_REQUIRED,
|
||||
OPENCLAW_HERMES_PIPELINE,
|
||||
OPENCLAW_TOOL_NOT_ALLOWED,
|
||||
OPENCLAW_TOOL_COMPLETED_ANSWER,
|
||||
UNEXPECTED_HERMES_RESPONSE,
|
||||
AIDefault,
|
||||
@@ -67,12 +70,8 @@ class OpenClawAdapter(AIAdapter):
|
||||
tool = context.get(AIContextKey.OPENCLAW_TOOL)
|
||||
if not tool:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"OpenClaw Gateway is not configured as a chat provider. "
|
||||
"Provide context.openclaw_tool for /tools/invoke, or use "
|
||||
"MODEL_PROVIDER=hermes/openclaw_hermes for AI answers."
|
||||
),
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=OPENCLAW_CHAT_PROVIDER_REQUIRED,
|
||||
)
|
||||
result = self.invoke_tool(
|
||||
tool=str(tool),
|
||||
@@ -96,7 +95,10 @@ class OpenClawAdapter(AIAdapter):
|
||||
healthz = client.get(f"{base_url}{AIHttpPath.HEALTHZ}", headers=headers)
|
||||
readyz = client.get(f"{base_url}{AIHttpPath.READYZ}", headers=headers)
|
||||
return {
|
||||
AIResponseKey.OK: healthz.status_code < 400 and readyz.status_code < 400,
|
||||
AIResponseKey.OK: (
|
||||
healthz.status_code < status.HTTP_400_BAD_REQUEST
|
||||
and readyz.status_code < status.HTTP_400_BAD_REQUEST
|
||||
),
|
||||
AIResponseKey.BASE_URL: base_url,
|
||||
AIResponseKey.HEALTHZ: _response_payload(healthz),
|
||||
AIResponseKey.READYZ: _response_payload(readyz),
|
||||
@@ -121,8 +123,11 @@ class OpenClawAdapter(AIAdapter):
|
||||
url = f"{self._base_url()}{AIHttpPath.TOOLS_INVOKE}"
|
||||
with httpx.Client(timeout=120, trust_env=False) as client:
|
||||
response = client.post(url, json=payload, headers=self._headers())
|
||||
if response.status_code >= 400:
|
||||
raise HTTPException(status_code=502, detail={AIErrorKey.OPENCLAW: response.text})
|
||||
if response.status_code >= status.HTTP_400_BAD_REQUEST:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={AIErrorKey.OPENCLAW: response.text},
|
||||
)
|
||||
return _response_payload(response)
|
||||
|
||||
def _base_url(self) -> str:
|
||||
@@ -138,9 +143,15 @@ class OpenClawAdapter(AIAdapter):
|
||||
|
||||
def _ensure_tool_allowed(self, tool: str, action: str) -> None:
|
||||
if tool not in set(self.settings.openclaw_allowed_tools):
|
||||
raise HTTPException(status_code=403, detail="OpenClaw tool is not allowed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=OPENCLAW_TOOL_NOT_ALLOWED,
|
||||
)
|
||||
if action not in set(self.settings.openclaw_allowed_actions):
|
||||
raise HTTPException(status_code=403, detail="OpenClaw action is not allowed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=OPENCLAW_ACTION_NOT_ALLOWED,
|
||||
)
|
||||
|
||||
|
||||
class HermesAdapter(AIAdapter):
|
||||
@@ -167,8 +178,11 @@ class HermesAdapter(AIAdapter):
|
||||
}
|
||||
with httpx.Client(timeout=300, trust_env=False) as client:
|
||||
response = client.post(url, json=payload, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
raise HTTPException(status_code=502, detail={AIErrorKey.HERMES: response.text})
|
||||
if response.status_code >= status.HTTP_400_BAD_REQUEST:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={AIErrorKey.HERMES: response.text},
|
||||
)
|
||||
data = _chat_completion_payload(response, AIErrorKey.HERMES)
|
||||
try:
|
||||
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
|
||||
@@ -176,7 +190,7 @@ class HermesAdapter(AIAdapter):
|
||||
]
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={
|
||||
AIErrorKey.HERMES: UNEXPECTED_HERMES_RESPONSE,
|
||||
AIResponseKey.RAW: data,
|
||||
@@ -196,7 +210,7 @@ class HermesAdapter(AIAdapter):
|
||||
with httpx.Client(timeout=5, trust_env=False) as client:
|
||||
response = client.get(url, headers=headers)
|
||||
return {
|
||||
AIResponseKey.OK: response.status_code < 400,
|
||||
AIResponseKey.OK: response.status_code < status.HTTP_400_BAD_REQUEST,
|
||||
AIResponseKey.BASE_URL: self.settings.hermes_base_url.rstrip("/"),
|
||||
AIResponseKey.HEALTH: _response_payload(response),
|
||||
}
|
||||
@@ -267,7 +281,7 @@ class OpenClawHermesAdapter(AIAdapter):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={AIErrorKey.OPENCLAW: _error_detail(exc)},
|
||||
) from exc
|
||||
return result
|
||||
@@ -334,7 +348,10 @@ class DirectLLMAdapter(AIAdapter):
|
||||
|
||||
def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
if not self.settings.direct_llm_api_key:
|
||||
raise HTTPException(status_code=503, detail=DIRECT_LLM_API_KEY_MISSING)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=DIRECT_LLM_API_KEY_MISSING,
|
||||
)
|
||||
url = f"{self.settings.direct_llm_base_url.rstrip('/')}{AIHttpPath.CHAT_COMPLETIONS}"
|
||||
headers = {
|
||||
AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format(
|
||||
@@ -347,8 +364,11 @@ class DirectLLMAdapter(AIAdapter):
|
||||
}
|
||||
with httpx.Client(timeout=60, trust_env=False) as client:
|
||||
response = client.post(url, json=payload, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
raise HTTPException(status_code=502, detail={AIErrorKey.DIRECT_LLM: response.text})
|
||||
if response.status_code >= status.HTTP_400_BAD_REQUEST:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={AIErrorKey.DIRECT_LLM: response.text},
|
||||
)
|
||||
data = _chat_completion_payload(response, AIErrorKey.DIRECT_LLM)
|
||||
try:
|
||||
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
|
||||
@@ -356,7 +376,7 @@ class DirectLLMAdapter(AIAdapter):
|
||||
]
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={
|
||||
AIErrorKey.DIRECT_LLM: UNEXPECTED_HERMES_RESPONSE,
|
||||
AIResponseKey.RAW: data,
|
||||
@@ -404,7 +424,7 @@ def _chat_completion_payload(response: httpx.Response, error_key: AIErrorKey) ->
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={
|
||||
error_key: UNEXPECTED_HERMES_RESPONSE,
|
||||
AIResponseKey.RAW: {AIResponseKey.TEXT: response.text},
|
||||
|
||||
@@ -89,6 +89,13 @@ class AIHttpPayloadKey(StrEnum):
|
||||
MESSAGE = "message"
|
||||
|
||||
|
||||
class AIToolAuditKey(StrEnum):
|
||||
TOOL = "tool"
|
||||
ACTION = "action"
|
||||
ARGS = "args"
|
||||
SESSION_KEY = "session_key"
|
||||
|
||||
|
||||
class AIChatRole(StrEnum):
|
||||
SYSTEM = "system"
|
||||
USER = "user"
|
||||
@@ -126,6 +133,14 @@ NOOP_PROVIDER_ANSWER = (
|
||||
OPENCLAW_TOOL_COMPLETED_ANSWER = "OpenClaw tool invocation completed."
|
||||
DIRECT_LLM_API_KEY_MISSING = "DIRECT_LLM_API_KEY is not configured"
|
||||
UNEXPECTED_HERMES_RESPONSE = "Unexpected chat completion response"
|
||||
OPENCLAW_CHAT_PROVIDER_REQUIRED = (
|
||||
"OpenClaw Gateway is not configured as a chat provider. "
|
||||
"Provide context.openclaw_tool for /tools/invoke, or use "
|
||||
"MODEL_PROVIDER=hermes/openclaw_hermes for AI answers."
|
||||
)
|
||||
OPENCLAW_TOOL_NOT_ALLOWED = "OpenClaw tool is not allowed"
|
||||
OPENCLAW_ACTION_NOT_ALLOWED = "OpenClaw action is not allowed"
|
||||
UNSUPPORTED_AI_SKILL_TEMPLATE = "Unsupported AI skill: {skill_id}"
|
||||
|
||||
AI_AUDIT_REDACTED_VALUE = "[REDACTED]"
|
||||
AI_AUDIT_TRUNCATED_VALUE = "[TRUNCATED]"
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.modules.ai_agent.constants import (
|
||||
AI_AUDIT_REDACTED_VALUE,
|
||||
AI_AUDIT_SENSITIVE_KEYS,
|
||||
AI_AUDIT_TRUNCATED_VALUE,
|
||||
AIToolAuditKey,
|
||||
AIProviderName,
|
||||
AIRequestKey,
|
||||
AIResponseKey,
|
||||
@@ -120,10 +121,10 @@ class AIService:
|
||||
target_id=tool,
|
||||
risk_level=AuditRiskLevel.HIGH,
|
||||
request_payload=_audit_safe_payload({
|
||||
"tool": tool,
|
||||
"action": action,
|
||||
"args": args or {},
|
||||
"session_key": session_key,
|
||||
AIToolAuditKey.TOOL: tool,
|
||||
AIToolAuditKey.ACTION: action,
|
||||
AIToolAuditKey.ARGS: args or {},
|
||||
AIToolAuditKey.SESSION_KEY: session_key,
|
||||
}),
|
||||
response_payload=_audit_safe_payload(result),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.modules.ai_agent.constants import UNSUPPORTED_AI_SKILL_TEMPLATE
|
||||
|
||||
|
||||
class AISkillId(StrEnum):
|
||||
"""Stable identifiers for AI capabilities exposed to business modules."""
|
||||
@@ -96,6 +98,6 @@ def get_ai_skill(skill_id: AISkillId | str) -> AISkill:
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Unsupported AI skill: {skill_id}",
|
||||
detail=UNSUPPORTED_AI_SKILL_TEMPLATE.format(skill_id=skill_id),
|
||||
) from exc
|
||||
return AI_SKILLS[normalized_id]
|
||||
|
||||
@@ -19,3 +19,19 @@ class ApprovalErrorDetail(StrEnum):
|
||||
SELF_APPROVAL = "Approval applicant cannot approve their own ticket"
|
||||
NOT_APPROVED = "Approval ticket is not approved for this change"
|
||||
PAYLOAD_MISMATCH = "Approval ticket payload does not match this change"
|
||||
|
||||
|
||||
class ApprovalPayloadKey(StrEnum):
|
||||
TICKET_ID = "ticket_id"
|
||||
STATUS = "status"
|
||||
COMMENT = "comment"
|
||||
RECORD_ID = "record_id"
|
||||
USED_BY = "used_by"
|
||||
USED_AT = "used_at"
|
||||
|
||||
|
||||
APPROVAL_ACTION_SEPARATOR = ":"
|
||||
|
||||
|
||||
def approval_action(action: ApprovalActionValue | str, domain: str) -> str:
|
||||
return f"{action}{APPROVAL_ACTION_SEPARATOR}{domain}"
|
||||
|
||||
@@ -13,7 +13,9 @@ from app.core.time import utc_now
|
||||
from app.modules.approvals.constants import (
|
||||
ApprovalActionValue,
|
||||
ApprovalErrorDetail,
|
||||
ApprovalPayloadKey,
|
||||
ApprovalStatus,
|
||||
approval_action,
|
||||
)
|
||||
from app.modules.approvals.models import ApprovalRequest
|
||||
from app.modules.approvals.schemas import ApprovalCreate
|
||||
@@ -51,7 +53,10 @@ class ApprovalService:
|
||||
target_id=payload.record_id,
|
||||
risk_level=AuditRiskLevel.MEDIUM,
|
||||
request_payload=payload.model_dump(),
|
||||
response_payload={"ticket_id": ticket.ticket_id, "status": ticket.status},
|
||||
response_payload={
|
||||
ApprovalPayloadKey.TICKET_ID: ticket.ticket_id,
|
||||
ApprovalPayloadKey.STATUS: ticket.status,
|
||||
},
|
||||
)
|
||||
)
|
||||
return ticket
|
||||
@@ -109,8 +114,11 @@ class ApprovalService:
|
||||
target_type=ticket.domain,
|
||||
target_id=ticket.record_id,
|
||||
risk_level=AuditRiskLevel.HIGH,
|
||||
request_payload={"ticket_id": ticket_id, "comment": comment},
|
||||
response_payload={"status": ticket.status},
|
||||
request_payload={
|
||||
ApprovalPayloadKey.TICKET_ID: ticket_id,
|
||||
ApprovalPayloadKey.COMMENT: comment,
|
||||
},
|
||||
response_payload={ApprovalPayloadKey.STATUS: ticket.status},
|
||||
)
|
||||
)
|
||||
return ticket
|
||||
@@ -137,12 +145,12 @@ class ApprovalService:
|
||||
)
|
||||
used_at = utc_now()
|
||||
values: dict[str, Any] = {
|
||||
"status": ApprovalStatus.USED,
|
||||
"used_by": actor,
|
||||
"used_at": used_at,
|
||||
ApprovalPayloadKey.STATUS: ApprovalStatus.USED,
|
||||
ApprovalPayloadKey.USED_BY: actor,
|
||||
ApprovalPayloadKey.USED_AT: used_at,
|
||||
}
|
||||
if record_id is not None and not ticket.record_id:
|
||||
values["record_id"] = str(record_id)
|
||||
values[ApprovalPayloadKey.RECORD_ID] = str(record_id)
|
||||
result = self.db.execute(
|
||||
update(ApprovalRequest)
|
||||
.where(
|
||||
@@ -188,7 +196,7 @@ class ApprovalService:
|
||||
return ticket.action in {
|
||||
action,
|
||||
ApprovalActionValue.UPDATE,
|
||||
f"{ApprovalActionValue.UPDATE}:{domain}",
|
||||
approval_action(ApprovalActionValue.UPDATE, domain),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ class AuditAction(StrEnum):
|
||||
AI_PROVIDER_HEALTH = "ai.provider_health"
|
||||
OPENCLAW_TOOLS_INVOKE = "openclaw.tools.invoke"
|
||||
GENERATE_EVENTS = "generate_events"
|
||||
FEISHU_WEBHOOK_EVENT = "webhook_event"
|
||||
FEISHU_LONG_CONNECTION_EVENT = "long_connection_event"
|
||||
FEISHU_SEND_TEXT = "send_text"
|
||||
FEISHU_SEND_CARD = "send_card"
|
||||
APPROVAL_CREATE = "approval.create"
|
||||
|
||||
@@ -35,6 +35,7 @@ class StatusValue(StrEnum):
|
||||
ABNORMAL = "异常"
|
||||
OPEN = "open"
|
||||
RUNNING = "running"
|
||||
RUNNING_CN = "执行中"
|
||||
DRY_RUN = "dry_run"
|
||||
|
||||
|
||||
@@ -62,10 +63,46 @@ class VersionValue(StrEnum):
|
||||
|
||||
|
||||
class BusinessDomain(StrEnum):
|
||||
TASKS = "tasks"
|
||||
PROJECTS = "projects"
|
||||
TASKS = "tasks"
|
||||
PROCUREMENTS = "procurements"
|
||||
EXPENSES = "expenses"
|
||||
FUND_ACCOUNTS = "fund-accounts"
|
||||
POLICIES = "policies"
|
||||
STANDARDS = "standards"
|
||||
PERFORMANCE_METRICS = "performance-metrics"
|
||||
SUPPLIERS = "suppliers"
|
||||
ATTENDANCE_RECORDS = "attendance-records"
|
||||
WORK_REPORTS = "work-reports"
|
||||
RISK_EVENTS = "risk-events"
|
||||
LEGACY_SYNC_RUNS = "legacy-sync-runs"
|
||||
|
||||
|
||||
class BusinessResponseKey(StrEnum):
|
||||
DOMAINS = "domains"
|
||||
DOMAIN = "domain"
|
||||
TOTAL = "total"
|
||||
ITEMS = "items"
|
||||
DATA = "data"
|
||||
|
||||
|
||||
class BusinessPayloadKey(StrEnum):
|
||||
DATA = "data"
|
||||
APPROVAL_TICKET_ID = "approval_ticket_id"
|
||||
|
||||
|
||||
class BusinessField(StrEnum):
|
||||
ID = "id"
|
||||
STATUS = "status"
|
||||
|
||||
|
||||
class BusinessErrorDetail(StrEnum):
|
||||
RECORD_NOT_FOUND = "Record not found"
|
||||
HIGH_RISK_APPROVAL_REQUIRED = "High-risk domain change requires approval_ticket_id"
|
||||
|
||||
|
||||
UNKNOWN_FIELD_TEMPLATE = "Unknown field '{field}'"
|
||||
INVALID_FIELD_VALUE_TEMPLATE = "Invalid value for field '{field}'"
|
||||
|
||||
|
||||
class RiskEventType(StrEnum):
|
||||
|
||||
@@ -1,42 +1,49 @@
|
||||
from sqlalchemy.orm import DeclarativeMeta
|
||||
|
||||
from app.modules.business import models
|
||||
from app.modules.business.constants import BusinessDomain
|
||||
|
||||
|
||||
DOMAIN_MODELS: dict[str, type[DeclarativeMeta]] = {
|
||||
"projects": models.Project,
|
||||
"tasks": models.WorkTask,
|
||||
"procurements": models.Procurement,
|
||||
"expenses": models.Expense,
|
||||
"fund-accounts": models.FundAccount,
|
||||
"policies": models.Policy,
|
||||
"standards": models.Standard,
|
||||
"performance-metrics": models.PerformanceMetric,
|
||||
"suppliers": models.Supplier,
|
||||
"attendance-records": models.AttendanceRecord,
|
||||
"work-reports": models.WorkReport,
|
||||
"risk-events": models.RiskEvent,
|
||||
"legacy-sync-runs": models.LegacySyncRun,
|
||||
DOMAIN_MODELS: dict[BusinessDomain, type[DeclarativeMeta]] = {
|
||||
BusinessDomain.PROJECTS: models.Project,
|
||||
BusinessDomain.TASKS: models.WorkTask,
|
||||
BusinessDomain.PROCUREMENTS: models.Procurement,
|
||||
BusinessDomain.EXPENSES: models.Expense,
|
||||
BusinessDomain.FUND_ACCOUNTS: models.FundAccount,
|
||||
BusinessDomain.POLICIES: models.Policy,
|
||||
BusinessDomain.STANDARDS: models.Standard,
|
||||
BusinessDomain.PERFORMANCE_METRICS: models.PerformanceMetric,
|
||||
BusinessDomain.SUPPLIERS: models.Supplier,
|
||||
BusinessDomain.ATTENDANCE_RECORDS: models.AttendanceRecord,
|
||||
BusinessDomain.WORK_REPORTS: models.WorkReport,
|
||||
BusinessDomain.RISK_EVENTS: models.RiskEvent,
|
||||
BusinessDomain.LEGACY_SYNC_RUNS: models.LegacySyncRun,
|
||||
}
|
||||
|
||||
LOW_RISK_DOMAINS = {
|
||||
"projects",
|
||||
"tasks",
|
||||
"procurements",
|
||||
"expenses",
|
||||
"policies",
|
||||
"standards",
|
||||
"suppliers",
|
||||
"attendance-records",
|
||||
"work-reports",
|
||||
"risk-events",
|
||||
"legacy-sync-runs",
|
||||
}
|
||||
HIGH_RISK_DOMAINS = {"fund-accounts", "performance-metrics"}
|
||||
HIGH_RISK_DOMAINS = frozenset(
|
||||
{
|
||||
BusinessDomain.FUND_ACCOUNTS,
|
||||
BusinessDomain.PERFORMANCE_METRICS,
|
||||
}
|
||||
)
|
||||
LOW_RISK_DOMAINS = frozenset(set(DOMAIN_MODELS) - HIGH_RISK_DOMAINS)
|
||||
|
||||
|
||||
def get_domain_model(domain: str) -> type[DeclarativeMeta]:
|
||||
if domain not in DOMAIN_MODELS:
|
||||
supported = ", ".join(sorted(DOMAIN_MODELS))
|
||||
raise KeyError(f"Unsupported domain '{domain}'. Supported: {supported}")
|
||||
return DOMAIN_MODELS[domain]
|
||||
def normalize_domain(domain: str | BusinessDomain) -> BusinessDomain:
|
||||
try:
|
||||
return BusinessDomain(domain)
|
||||
except ValueError as exc:
|
||||
supported = ", ".join(sorted(item.value for item in DOMAIN_MODELS))
|
||||
raise KeyError(f"Unsupported domain '{domain}'. Supported: {supported}") from exc
|
||||
|
||||
|
||||
def supported_domain_values() -> list[str]:
|
||||
return sorted(item.value for item in DOMAIN_MODELS)
|
||||
|
||||
|
||||
def is_high_risk_domain(domain: str | BusinessDomain) -> bool:
|
||||
return normalize_domain(domain) in HIGH_RISK_DOMAINS
|
||||
|
||||
|
||||
def get_domain_model(domain: str | BusinessDomain) -> type[DeclarativeMeta]:
|
||||
return DOMAIN_MODELS[normalize_domain(domain)]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import status as http_status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.business.registry import DOMAIN_MODELS
|
||||
from app.modules.business.constants import BusinessField, BusinessResponseKey
|
||||
from app.modules.business.registry import supported_domain_values
|
||||
from app.modules.business.schemas import DomainListRead, DomainRecordCreate, DomainRecordUpdate
|
||||
from app.modules.business.service import BusinessService
|
||||
|
||||
@@ -12,7 +14,7 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
@router.get("/domains")
|
||||
def list_domains() -> dict[str, list[str]]:
|
||||
return {"domains": sorted(DOMAIN_MODELS)}
|
||||
return {BusinessResponseKey.DOMAINS: supported_domain_values()}
|
||||
|
||||
|
||||
@router.get("/{domain}", response_model=DomainListRead)
|
||||
@@ -20,22 +22,29 @@ def list_records(
|
||||
domain: str,
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
status: str | None = None,
|
||||
status_filter: str | None = Query(default=None, alias=BusinessField.STATUS),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
try:
|
||||
total, items = BusinessService(db).list_records(domain, limit, offset, status)
|
||||
total, items = BusinessService(db).list_records(domain, limit, offset, status_filter)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {"domain": domain, "total": total, "items": items}
|
||||
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
return {
|
||||
BusinessResponseKey.DOMAIN: domain,
|
||||
BusinessResponseKey.TOTAL: total,
|
||||
BusinessResponseKey.ITEMS: items,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{domain}/{record_id}")
|
||||
def get_record(domain: str, record_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
try:
|
||||
return {"domain": domain, "data": BusinessService(db).get_record(domain, record_id)}
|
||||
return {
|
||||
BusinessResponseKey.DOMAIN: domain,
|
||||
BusinessResponseKey.DATA: BusinessService(db).get_record(domain, record_id),
|
||||
}
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{domain}")
|
||||
@@ -53,8 +62,8 @@ def create_record(
|
||||
payload.approval_ticket_id,
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {"domain": domain, "data": data}
|
||||
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
return {BusinessResponseKey.DOMAIN: domain, BusinessResponseKey.DATA: data}
|
||||
|
||||
|
||||
@router.patch("/{domain}/{record_id}")
|
||||
@@ -74,5 +83,5 @@ def update_record(
|
||||
approval_ticket_id=payload.approval_ticket_id,
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {"domain": domain, "data": data}
|
||||
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
return {BusinessResponseKey.DOMAIN: domain, BusinessResponseKey.DATA: data}
|
||||
|
||||
@@ -15,8 +15,16 @@ from app.core.pagination import bounded_limit, bounded_offset
|
||||
from app.modules.audit.constants import AuditRiskLevel, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.approvals.constants import ApprovalActionValue, approval_action
|
||||
from app.modules.approvals.service import ApprovalService
|
||||
from app.modules.business.registry import HIGH_RISK_DOMAINS, get_domain_model
|
||||
from app.modules.business.registry import get_domain_model, is_high_risk_domain
|
||||
from app.modules.business.constants import (
|
||||
INVALID_FIELD_VALUE_TEMPLATE,
|
||||
UNKNOWN_FIELD_TEMPLATE,
|
||||
BusinessErrorDetail,
|
||||
BusinessField,
|
||||
BusinessPayloadKey,
|
||||
)
|
||||
|
||||
|
||||
def serialize_model(record: Any) -> dict[str, Any]:
|
||||
@@ -51,21 +59,25 @@ def _coerce_column_value(column: Column, value: Any) -> Any:
|
||||
def _model_payload(model: Any, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate keys and coerce values according to model column types."""
|
||||
|
||||
columns = {column.name: column for column in model.__table__.columns if column.name != "id"}
|
||||
columns = {
|
||||
column.name: column
|
||||
for column in model.__table__.columns
|
||||
if column.name != BusinessField.ID
|
||||
}
|
||||
payload: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
column = columns.get(key)
|
||||
if column is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Unknown field '{key}'",
|
||||
detail=UNKNOWN_FIELD_TEMPLATE.format(field=key),
|
||||
)
|
||||
try:
|
||||
payload[key] = _coerce_column_value(column, value)
|
||||
except (ValueError, TypeError, InvalidOperation) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid value for field '{key}'",
|
||||
detail=INVALID_FIELD_VALUE_TEMPLATE.format(field=key),
|
||||
) from exc
|
||||
return payload
|
||||
|
||||
@@ -87,7 +99,7 @@ class BusinessService:
|
||||
model = get_domain_model(domain)
|
||||
stmt: Select = select(model)
|
||||
count_stmt = select(func.count()).select_from(model)
|
||||
if status_filter and hasattr(model, "status"):
|
||||
if status_filter and hasattr(model, BusinessField.STATUS):
|
||||
stmt = stmt.where(model.status == status_filter)
|
||||
count_stmt = count_stmt.where(model.status == status_filter)
|
||||
stmt = stmt.order_by(model.id.desc()).limit(bounded_limit(limit)).offset(
|
||||
@@ -100,7 +112,10 @@ class BusinessService:
|
||||
model = get_domain_model(domain)
|
||||
record = self.db.get(model, record_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Record not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=BusinessErrorDetail.RECORD_NOT_FOUND,
|
||||
)
|
||||
return serialize_model(record)
|
||||
|
||||
def create_record(
|
||||
@@ -111,16 +126,17 @@ class BusinessService:
|
||||
approval_ticket_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
model = get_domain_model(domain)
|
||||
high_risk = is_high_risk_domain(domain)
|
||||
payload = _model_payload(model, data)
|
||||
record = model(**payload)
|
||||
self.db.add(record)
|
||||
if domain in HIGH_RISK_DOMAINS:
|
||||
if high_risk:
|
||||
self.db.flush()
|
||||
self._consume_approval(
|
||||
approval_ticket_id,
|
||||
domain,
|
||||
record.id,
|
||||
f"create:{domain}",
|
||||
approval_action(ApprovalActionValue.CREATE, domain),
|
||||
data,
|
||||
actor,
|
||||
)
|
||||
@@ -131,13 +147,14 @@ class BusinessService:
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.API,
|
||||
action=f"create:{domain}",
|
||||
action=approval_action(ApprovalActionValue.CREATE, domain),
|
||||
target_type=domain,
|
||||
target_id=str(record.id),
|
||||
risk_level=(
|
||||
AuditRiskLevel.HIGH if domain in HIGH_RISK_DOMAINS else AuditRiskLevel.LOW
|
||||
),
|
||||
request_payload={"data": data, "approval_ticket_id": approval_ticket_id},
|
||||
risk_level=AuditRiskLevel.HIGH if high_risk else AuditRiskLevel.LOW,
|
||||
request_payload={
|
||||
BusinessPayloadKey.DATA: data,
|
||||
BusinessPayloadKey.APPROVAL_TICKET_ID: approval_ticket_id,
|
||||
},
|
||||
response_payload=result,
|
||||
)
|
||||
)
|
||||
@@ -152,19 +169,20 @@ class BusinessService:
|
||||
approval_ticket_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
model = get_domain_model(domain)
|
||||
high_risk = is_high_risk_domain(domain)
|
||||
record = self.db.get(model, record_id)
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Record not found",
|
||||
detail=BusinessErrorDetail.RECORD_NOT_FOUND,
|
||||
)
|
||||
payload = _model_payload(model, data)
|
||||
if domain in HIGH_RISK_DOMAINS:
|
||||
if high_risk:
|
||||
self._consume_approval(
|
||||
approval_ticket_id,
|
||||
domain,
|
||||
record_id,
|
||||
f"update:{domain}",
|
||||
approval_action(ApprovalActionValue.UPDATE, domain),
|
||||
data,
|
||||
actor,
|
||||
)
|
||||
@@ -177,13 +195,14 @@ class BusinessService:
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.API,
|
||||
action=f"update:{domain}",
|
||||
action=approval_action(ApprovalActionValue.UPDATE, domain),
|
||||
target_type=domain,
|
||||
target_id=str(record.id),
|
||||
risk_level=(
|
||||
AuditRiskLevel.HIGH if domain in HIGH_RISK_DOMAINS else AuditRiskLevel.LOW
|
||||
),
|
||||
request_payload={"data": data, "approval_ticket_id": approval_ticket_id},
|
||||
risk_level=AuditRiskLevel.HIGH if high_risk else AuditRiskLevel.LOW,
|
||||
request_payload={
|
||||
BusinessPayloadKey.DATA: data,
|
||||
BusinessPayloadKey.APPROVAL_TICKET_ID: approval_ticket_id,
|
||||
},
|
||||
response_payload=result,
|
||||
)
|
||||
)
|
||||
@@ -201,7 +220,7 @@ class BusinessService:
|
||||
if not approval_ticket_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="High-risk domain change requires approval_ticket_id",
|
||||
detail=BusinessErrorDetail.HIGH_RISK_APPROVAL_REQUIRED,
|
||||
)
|
||||
ApprovalService(self.db).consume_for(
|
||||
approval_ticket_id,
|
||||
|
||||
@@ -3,7 +3,7 @@ import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.core.constants import BEARER_TOKEN_TEMPLATE, HttpHeader
|
||||
from app.core.config import get_settings
|
||||
@@ -34,7 +34,10 @@ class FeishuClient:
|
||||
|
||||
def _get_tenant_access_token(self) -> str:
|
||||
if not self._is_configured():
|
||||
raise HTTPException(status_code=503, detail=FEISHU_AUTH_MISSING)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=FEISHU_AUTH_MISSING,
|
||||
)
|
||||
if self._tenant_access_token and time.time() < self._token_expires_at:
|
||||
return self._tenant_access_token
|
||||
|
||||
@@ -48,7 +51,10 @@ class FeishuClient:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get(FeishuPayloadKey.CODE) != FEISHU_SUCCESS_CODE:
|
||||
raise HTTPException(status_code=502, detail={FeishuPayloadKey.FEISHU_ERROR: data})
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={FeishuPayloadKey.FEISHU_ERROR: data},
|
||||
)
|
||||
self._tenant_access_token = data[FeishuPayloadKey.TENANT_ACCESS_TOKEN]
|
||||
expire_seconds = int(
|
||||
data.get(FeishuPayloadKey.EXPIRE, FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS)
|
||||
@@ -87,7 +93,7 @@ class FeishuClient:
|
||||
chat_id = receive_id or self.settings.feishu_default_chat_id
|
||||
if not chat_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=FEISHU_RECEIVE_ID_MISSING,
|
||||
)
|
||||
return self.send_message(
|
||||
@@ -106,7 +112,7 @@ class FeishuClient:
|
||||
chat_id = receive_id or self.settings.feishu_default_chat_id
|
||||
if not chat_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=FEISHU_RECEIVE_ID_MISSING,
|
||||
)
|
||||
return self.send_message(chat_id, receive_id_type, FeishuMessageType.INTERACTIVE, card)
|
||||
|
||||
@@ -9,9 +9,20 @@ from app.core.config import get_settings
|
||||
from app.modules.ai_agent.service import AIService
|
||||
from app.modules.ai_agent.constants import AIResponseKey
|
||||
from app.modules.audit.constants import AuditSource
|
||||
from app.modules.feishu.constants import FeishuCommandKey
|
||||
from app.modules.feishu.constants import (
|
||||
FEISHU_AI_REPLY_TITLE,
|
||||
FEISHU_MENTION_PATTERN,
|
||||
FEISHU_ZERO_WIDTH_SPACE,
|
||||
FeishuCommandKey,
|
||||
FeishuCommandName,
|
||||
FeishuCommandResultKey,
|
||||
FeishuPayloadKey,
|
||||
FeishuReplyType,
|
||||
)
|
||||
from app.modules.reports.constants import ReportResponseKey
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.reports.service import ReportService
|
||||
from app.modules.risk.constants import RiskSummaryKey
|
||||
from app.modules.risk.service import RiskService
|
||||
|
||||
DAILY_REPORT_KEYWORDS = ("日报", "晨报", "经营日报", "经营晨报")
|
||||
@@ -27,7 +38,11 @@ def _parse_content_text(content: Any) -> str:
|
||||
"""Extract plain command text from a Feishu message content payload."""
|
||||
|
||||
if isinstance(content, dict):
|
||||
return str(content.get("text") or content.get("content") or "")
|
||||
return str(
|
||||
content.get(FeishuPayloadKey.TEXT)
|
||||
or content.get(FeishuPayloadKey.CONTENT)
|
||||
or ""
|
||||
)
|
||||
if not isinstance(content, str):
|
||||
return ""
|
||||
try:
|
||||
@@ -35,18 +50,42 @@ def _parse_content_text(content: Any) -> str:
|
||||
except json.JSONDecodeError:
|
||||
return content
|
||||
if isinstance(data, dict):
|
||||
return str(data.get("text") or data.get("content") or "")
|
||||
return str(
|
||||
data.get(FeishuPayloadKey.TEXT)
|
||||
or data.get(FeishuPayloadKey.CONTENT)
|
||||
or ""
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
def _clean_command_text(text: str) -> str:
|
||||
"""Remove mentions and invisible characters from Feishu command text."""
|
||||
|
||||
text = re.sub(r"@\S+", "", text or "")
|
||||
text = text.replace("\u200b", "")
|
||||
text = re.sub(FEISHU_MENTION_PATTERN, "", text or "")
|
||||
text = text.replace(FEISHU_ZERO_WIDTH_SPACE, "")
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _command_result(
|
||||
command: FeishuCommandName,
|
||||
reply_type: FeishuReplyType,
|
||||
title: str,
|
||||
content: str,
|
||||
provider_response: dict[str, Any] | None = None,
|
||||
lines: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {
|
||||
FeishuCommandResultKey.COMMAND: command,
|
||||
FeishuCommandResultKey.REPLY_TYPE: reply_type,
|
||||
FeishuCommandResultKey.TITLE: title,
|
||||
FeishuCommandResultKey.CONTENT: content,
|
||||
FeishuCommandResultKey.PROVIDER_RESPONSE: provider_response,
|
||||
}
|
||||
if lines is not None:
|
||||
result[FeishuCommandResultKey.LINES] = lines
|
||||
return result
|
||||
|
||||
|
||||
class FeishuCommandService:
|
||||
"""Route Feishu text commands to reports, risk summaries, or AI replies."""
|
||||
|
||||
@@ -55,16 +94,20 @@ class FeishuCommandService:
|
||||
self.feishu = FeishuService(db)
|
||||
|
||||
def extract_event_command(self, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
event = payload.get("event") or {}
|
||||
message = event.get("message") or {}
|
||||
event = payload.get(FeishuPayloadKey.EVENT) or {}
|
||||
message = event.get(FeishuPayloadKey.MESSAGE) or {}
|
||||
if not message:
|
||||
return None
|
||||
text = _clean_command_text(_parse_content_text(message.get("content")))
|
||||
text = _clean_command_text(_parse_content_text(message.get(FeishuPayloadKey.CONTENT)))
|
||||
if not text:
|
||||
return None
|
||||
sender = event.get("sender") or {}
|
||||
sender_id = sender.get("sender_id") or {}
|
||||
actor = sender_id.get("open_id") or sender_id.get("user_id") or ActorValue.FEISHU
|
||||
sender = event.get(FeishuPayloadKey.SENDER) or {}
|
||||
sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {}
|
||||
actor = (
|
||||
sender_id.get(FeishuPayloadKey.OPEN_ID)
|
||||
or sender_id.get(FeishuPayloadKey.USER_ID)
|
||||
or ActorValue.FEISHU
|
||||
)
|
||||
return {
|
||||
FeishuCommandKey.TEXT: text,
|
||||
FeishuCommandKey.CHAT_ID: message.get(FeishuCommandKey.CHAT_ID),
|
||||
@@ -84,80 +127,70 @@ class FeishuCommandService:
|
||||
|
||||
if any(keyword in command_text for keyword in DAILY_REPORT_KEYWORDS):
|
||||
report = ReportService(self.db).daily_brief()
|
||||
result = {
|
||||
"command": "daily_brief",
|
||||
"reply_type": "card",
|
||||
"title": report["title"],
|
||||
"content": report["content"],
|
||||
"lines": report["lines"],
|
||||
}
|
||||
if auto_reply:
|
||||
provider_response = self._send_card_if_configured(
|
||||
chat_id,
|
||||
report["title"],
|
||||
report["lines"],
|
||||
report[ReportResponseKey.TITLE],
|
||||
report[ReportResponseKey.LINES],
|
||||
actor,
|
||||
)
|
||||
result["provider_response"] = provider_response
|
||||
return result
|
||||
return _command_result(
|
||||
FeishuCommandName.DAILY_BRIEF,
|
||||
FeishuReplyType.CARD,
|
||||
report[ReportResponseKey.TITLE],
|
||||
report[ReportResponseKey.CONTENT],
|
||||
provider_response,
|
||||
report[ReportResponseKey.LINES],
|
||||
)
|
||||
|
||||
if any(keyword in command_text for keyword in PROJECT_WEEKLY_KEYWORDS):
|
||||
report = ReportService(self.db).project_weekly()
|
||||
result = {
|
||||
"command": "project_weekly",
|
||||
"reply_type": "card",
|
||||
"title": report["title"],
|
||||
"content": report["content"],
|
||||
"lines": report["lines"],
|
||||
}
|
||||
if auto_reply:
|
||||
provider_response = self._send_card_if_configured(
|
||||
chat_id,
|
||||
report["title"],
|
||||
report["lines"],
|
||||
report[ReportResponseKey.TITLE],
|
||||
report[ReportResponseKey.LINES],
|
||||
actor,
|
||||
)
|
||||
result["provider_response"] = provider_response
|
||||
return result
|
||||
return _command_result(
|
||||
FeishuCommandName.PROJECT_WEEKLY,
|
||||
FeishuReplyType.CARD,
|
||||
report[ReportResponseKey.TITLE],
|
||||
report[ReportResponseKey.CONTENT],
|
||||
provider_response,
|
||||
report[ReportResponseKey.LINES],
|
||||
)
|
||||
|
||||
if any(keyword in command_text for keyword in ATTENDANCE_KEYWORDS):
|
||||
report = ReportService(self.db).attendance_summary()
|
||||
result = {
|
||||
"command": "attendance_summary",
|
||||
"reply_type": "card",
|
||||
"title": report["title"],
|
||||
"content": report["content"],
|
||||
"lines": report["lines"],
|
||||
}
|
||||
if auto_reply:
|
||||
provider_response = self._send_card_if_configured(
|
||||
chat_id,
|
||||
report["title"],
|
||||
report["lines"],
|
||||
report[ReportResponseKey.TITLE],
|
||||
report[ReportResponseKey.LINES],
|
||||
actor,
|
||||
)
|
||||
result["provider_response"] = provider_response
|
||||
return result
|
||||
return _command_result(
|
||||
FeishuCommandName.ATTENDANCE_SUMMARY,
|
||||
FeishuReplyType.CARD,
|
||||
report[ReportResponseKey.TITLE],
|
||||
report[ReportResponseKey.CONTENT],
|
||||
provider_response,
|
||||
report[ReportResponseKey.LINES],
|
||||
)
|
||||
|
||||
if any(keyword in command_text for keyword in RISK_KEYWORDS):
|
||||
summary = RiskService(self.db).summary()
|
||||
lines = [
|
||||
f"- 综合风险等级:{summary['risk_level']}",
|
||||
f"- 风险分:{summary['risk_score']}",
|
||||
f"- 逾期任务:{len(summary['overdue_tasks'])}",
|
||||
f"- 延期项目:{len(summary['delayed_projects'])}",
|
||||
f"- 超预算项目:{len(summary['over_budget_projects'])}",
|
||||
f"- 资金风险账户:{len(summary['fund_risks'])}",
|
||||
f"- 供应商风险:{len(summary['supplier_risks'])}",
|
||||
f"- 打开风险事件:{len(summary['open_events'])}",
|
||||
f"- 综合风险等级:{summary[RiskSummaryKey.RISK_LEVEL]}",
|
||||
f"- 风险分:{summary[RiskSummaryKey.RISK_SCORE]}",
|
||||
f"- 逾期任务:{len(summary[RiskSummaryKey.OVERDUE_TASKS])}",
|
||||
f"- 延期项目:{len(summary[RiskSummaryKey.DELAYED_PROJECTS])}",
|
||||
f"- 超预算项目:{len(summary[RiskSummaryKey.OVER_BUDGET_PROJECTS])}",
|
||||
f"- 资金风险账户:{len(summary[RiskSummaryKey.FUND_RISKS])}",
|
||||
f"- 供应商风险:{len(summary[RiskSummaryKey.SUPPLIER_RISKS])}",
|
||||
f"- 打开风险事件:{len(summary[RiskSummaryKey.OPEN_EVENTS])}",
|
||||
]
|
||||
result = {
|
||||
"command": "risk_summary",
|
||||
"reply_type": "card",
|
||||
"title": RISK_TITLE,
|
||||
"content": "\n".join(lines),
|
||||
"lines": lines,
|
||||
}
|
||||
if auto_reply:
|
||||
provider_response = self._send_card_if_configured(
|
||||
chat_id,
|
||||
@@ -165,8 +198,14 @@ class FeishuCommandService:
|
||||
lines,
|
||||
actor,
|
||||
)
|
||||
result["provider_response"] = provider_response
|
||||
return result
|
||||
return _command_result(
|
||||
FeishuCommandName.RISK_SUMMARY,
|
||||
FeishuReplyType.CARD,
|
||||
RISK_TITLE,
|
||||
"\n".join(lines),
|
||||
provider_response,
|
||||
lines,
|
||||
)
|
||||
|
||||
prompt = command_text
|
||||
for prefix in AI_COMMAND_PREFIXES:
|
||||
@@ -186,16 +225,15 @@ class FeishuCommandService:
|
||||
command_text.startswith(prefix) or lowered.startswith(prefix)
|
||||
for prefix in AI_COMMAND_PREFIXES
|
||||
)
|
||||
result = {
|
||||
"command": "ai_ask" if is_explicit_ai else "fallback_ai",
|
||||
"reply_type": "text",
|
||||
"title": "AI 回复",
|
||||
"content": content,
|
||||
}
|
||||
if auto_reply:
|
||||
provider_response = self._send_text_if_configured(chat_id, content, actor)
|
||||
result["provider_response"] = provider_response
|
||||
return result
|
||||
return _command_result(
|
||||
FeishuCommandName.AI_ASK if is_explicit_ai else FeishuCommandName.FALLBACK_AI,
|
||||
FeishuReplyType.TEXT,
|
||||
FEISHU_AI_REPLY_TITLE,
|
||||
content,
|
||||
provider_response,
|
||||
)
|
||||
|
||||
def _send_card_if_configured(
|
||||
self,
|
||||
|
||||
@@ -10,25 +10,44 @@ class FeishuMessageType(StrEnum):
|
||||
INTERACTIVE = "interactive"
|
||||
|
||||
|
||||
class FeishuEventSource(StrEnum):
|
||||
WEBHOOK = "webhook"
|
||||
LONG_CONNECTION = "long_connection"
|
||||
|
||||
|
||||
class FeishuPayloadKey(StrEnum):
|
||||
APP_ID = "app_id"
|
||||
APP_SECRET = "app_secret"
|
||||
CARD = "card"
|
||||
CHALLENGE = "challenge"
|
||||
CODE = "code"
|
||||
CONFIG = "config"
|
||||
CONTENT = "content"
|
||||
DIV = "div"
|
||||
ELEMENTS = "elements"
|
||||
EXPIRE = "expire"
|
||||
FEISHU_ERROR = "feishu_error"
|
||||
HEADER = "header"
|
||||
EVENT = "event"
|
||||
EVENT_ID = "event_id"
|
||||
EVENT_TYPE = "event_type"
|
||||
LARK_MARKDOWN = "lark_md"
|
||||
MESSAGE = "message"
|
||||
MESSAGE_ID = "message_id"
|
||||
MESSAGE_TYPE = "msg_type"
|
||||
OPEN_ID = "open_id"
|
||||
PLAIN_TEXT = "plain_text"
|
||||
RECEIVE_ID = "receive_id"
|
||||
RECEIVE_ID_TYPE = "receive_id_type"
|
||||
MESSAGE_TYPE = "msg_type"
|
||||
CONTENT = "content"
|
||||
TEXT = "text"
|
||||
CODE = "code"
|
||||
SENDER = "sender"
|
||||
SENDER_ID = "sender_id"
|
||||
TAG = "tag"
|
||||
TENANT_ACCESS_TOKEN = "tenant_access_token"
|
||||
EXPIRE = "expire"
|
||||
APP_ID = "app_id"
|
||||
APP_SECRET = "app_secret"
|
||||
FEISHU_ERROR = "feishu_error"
|
||||
CARD = "card"
|
||||
TEXT = "text"
|
||||
TITLE = "title"
|
||||
TOKEN = "token"
|
||||
USER_ID = "user_id"
|
||||
WIDE_SCREEN_MODE = "wide_screen_mode"
|
||||
|
||||
|
||||
class FeishuCommandKey(StrEnum):
|
||||
@@ -37,12 +56,57 @@ class FeishuCommandKey(StrEnum):
|
||||
ACTOR = "actor"
|
||||
|
||||
|
||||
class FeishuResponseKey(StrEnum):
|
||||
OK = "ok"
|
||||
ACCEPTED = "accepted"
|
||||
HANDLED = "handled"
|
||||
DUPLICATE = "duplicate"
|
||||
RESULT = "result"
|
||||
CHALLENGE = "challenge"
|
||||
PROVIDER_RESPONSE = "provider_response"
|
||||
|
||||
|
||||
class FeishuCommandResultKey(StrEnum):
|
||||
COMMAND = "command"
|
||||
REPLY_TYPE = "reply_type"
|
||||
TITLE = "title"
|
||||
CONTENT = "content"
|
||||
LINES = "lines"
|
||||
PROVIDER_RESPONSE = "provider_response"
|
||||
|
||||
|
||||
class FeishuCommandName(StrEnum):
|
||||
DAILY_BRIEF = "daily_brief"
|
||||
PROJECT_WEEKLY = "project_weekly"
|
||||
ATTENDANCE_SUMMARY = "attendance_summary"
|
||||
RISK_SUMMARY = "risk_summary"
|
||||
AI_ASK = "ai_ask"
|
||||
FALLBACK_AI = "fallback_ai"
|
||||
|
||||
|
||||
class FeishuReplyType(StrEnum):
|
||||
CARD = "card"
|
||||
TEXT = "text"
|
||||
|
||||
|
||||
class FeishuEventReceiptKey(StrEnum):
|
||||
EVENT_KEY = "event_key"
|
||||
SOURCE = "source"
|
||||
EVENT_ID = "event_id"
|
||||
MESSAGE_ID = "message_id"
|
||||
|
||||
|
||||
FEISHU_TENANT_TOKEN_PATH = "/auth/v3/tenant_access_token/internal"
|
||||
FEISHU_MESSAGE_PATH = "/im/v1/messages"
|
||||
FEISHU_DEFAULT_OPEN_API_DOMAIN = "https://open.feishu.cn"
|
||||
FEISHU_AUTH_MISSING = "Feishu app credentials are not configured"
|
||||
FEISHU_VERIFICATION_TOKEN_REQUIRED = "FEISHU_VERIFICATION_TOKEN is required"
|
||||
FEISHU_INVALID_TOKEN = "Invalid Feishu token"
|
||||
FEISHU_RECEIVE_ID_MISSING = "receive_id or FEISHU_DEFAULT_CHAT_ID is required"
|
||||
FEISHU_SUCCESS_CODE = 0
|
||||
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS = 7200
|
||||
FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS = 300
|
||||
FEISHU_WEBHOOK_EVENT_ACTION = "webhook_event"
|
||||
FEISHU_LONG_CONNECTION_EVENT_ACTION = "long_connection_event"
|
||||
FEISHU_AI_REPLY_TITLE = "AI 回复"
|
||||
FEISHU_EMPTY_CARD_TEXT = "暂无数据"
|
||||
FEISHU_MENTION_PATTERN = r"@\S+"
|
||||
FEISHU_ZERO_WIDTH_SPACE = "\u200b"
|
||||
|
||||
@@ -4,21 +4,22 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.audit.constants import AuditSource
|
||||
from app.modules.audit.constants import AuditAction, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.feishu.commands import FeishuCommandService
|
||||
from app.modules.feishu.constants import (
|
||||
FEISHU_LONG_CONNECTION_EVENT_ACTION,
|
||||
FEISHU_WEBHOOK_EVENT_ACTION,
|
||||
FeishuCommandKey,
|
||||
FeishuEventReceiptKey,
|
||||
FeishuEventSource,
|
||||
FeishuPayloadKey,
|
||||
FeishuResponseKey,
|
||||
)
|
||||
from app.modules.feishu.models import FeishuEventReceipt
|
||||
from app.modules.feishu.service import FeishuService
|
||||
|
||||
FEISHU_EVENT_ACTIONS = {
|
||||
"webhook": FEISHU_WEBHOOK_EVENT_ACTION,
|
||||
"long_connection": FEISHU_LONG_CONNECTION_EVENT_ACTION,
|
||||
FeishuEventSource.WEBHOOK: AuditAction.FEISHU_WEBHOOK_EVENT,
|
||||
FeishuEventSource.LONG_CONNECTION: AuditAction.FEISHU_LONG_CONNECTION_EVENT,
|
||||
}
|
||||
|
||||
|
||||
@@ -33,41 +34,57 @@ class FeishuEventService:
|
||||
def handle_event(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
source: str,
|
||||
source: str | FeishuEventSource,
|
||||
auto_reply: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
self.feishu.verify_event(payload)
|
||||
challenge = payload.get(FeishuPayloadKey.CHALLENGE)
|
||||
if challenge:
|
||||
return {FeishuResponseKey.CHALLENGE: challenge}
|
||||
source_value = _normalize_source(source)
|
||||
event_identity = _event_identity(payload, source)
|
||||
if event_identity and not self._register_event(event_identity):
|
||||
return {"ok": True, "handled": False, "duplicate": True}
|
||||
return {
|
||||
FeishuResponseKey.OK: True,
|
||||
FeishuResponseKey.HANDLED: False,
|
||||
FeishuResponseKey.DUPLICATE: True,
|
||||
}
|
||||
self.feishu.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=ActorValue.FEISHU,
|
||||
source=AuditSource.FEISHU,
|
||||
action=FEISHU_EVENT_ACTIONS.get(source, FEISHU_WEBHOOK_EVENT_ACTION),
|
||||
target_type=source,
|
||||
target_id=event_identity.get("event_key") if event_identity else None,
|
||||
action=FEISHU_EVENT_ACTIONS[source_value],
|
||||
target_type=source_value,
|
||||
target_id=(
|
||||
event_identity.get(FeishuEventReceiptKey.EVENT_KEY)
|
||||
if event_identity
|
||||
else None
|
||||
),
|
||||
request_payload=payload,
|
||||
response_payload={"accepted": True},
|
||||
response_payload={FeishuResponseKey.ACCEPTED: True},
|
||||
)
|
||||
)
|
||||
command = self.commands.extract_event_command(payload)
|
||||
if not command:
|
||||
return {"ok": True, "handled": False}
|
||||
return {FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: False}
|
||||
result = self.commands.handle_text(
|
||||
command[FeishuCommandKey.TEXT],
|
||||
chat_id=command[FeishuCommandKey.CHAT_ID],
|
||||
actor=command[FeishuCommandKey.ACTOR],
|
||||
auto_reply=auto_reply,
|
||||
)
|
||||
return {"ok": True, "handled": True, "result": result}
|
||||
return {
|
||||
FeishuResponseKey.OK: True,
|
||||
FeishuResponseKey.HANDLED: True,
|
||||
FeishuResponseKey.RESULT: result,
|
||||
}
|
||||
|
||||
def _register_event(self, event_identity: dict[str, str | None]) -> bool:
|
||||
receipt = FeishuEventReceipt(
|
||||
event_key=str(event_identity["event_key"]),
|
||||
source=str(event_identity["source"]),
|
||||
event_id=event_identity.get("event_id"),
|
||||
message_id=event_identity.get("message_id"),
|
||||
event_key=str(event_identity[FeishuEventReceiptKey.EVENT_KEY]),
|
||||
source=str(event_identity[FeishuEventReceiptKey.SOURCE]),
|
||||
event_id=event_identity.get(FeishuEventReceiptKey.EVENT_ID),
|
||||
message_id=event_identity.get(FeishuEventReceiptKey.MESSAGE_ID),
|
||||
)
|
||||
self.db.add(receipt)
|
||||
try:
|
||||
@@ -78,7 +95,15 @@ class FeishuEventService:
|
||||
return True
|
||||
|
||||
|
||||
def _event_identity(payload: dict[str, Any], source: str) -> dict[str, str | None] | None:
|
||||
def _normalize_source(source: str | FeishuEventSource) -> FeishuEventSource:
|
||||
return FeishuEventSource(source)
|
||||
|
||||
|
||||
def _event_identity(
|
||||
payload: dict[str, Any],
|
||||
source: str | FeishuEventSource,
|
||||
) -> dict[str, str | None] | None:
|
||||
source_value = _normalize_source(source)
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
event = payload.get(FeishuPayloadKey.EVENT) or {}
|
||||
message = event.get(FeishuPayloadKey.MESSAGE) or {}
|
||||
@@ -90,11 +115,11 @@ def _event_identity(payload: dict[str, Any], source: str) -> dict[str, str | Non
|
||||
event_type = header.get(FeishuPayloadKey.EVENT_TYPE)
|
||||
event_key = ":".join(
|
||||
str(part)
|
||||
for part in (source, event_type or FeishuPayloadKey.EVENT, stable_id)
|
||||
for part in (source_value, event_type or FeishuPayloadKey.EVENT, stable_id)
|
||||
)
|
||||
return {
|
||||
"event_key": event_key,
|
||||
"source": source,
|
||||
"event_id": str(event_id) if event_id else None,
|
||||
"message_id": str(message_id) if message_id else None,
|
||||
FeishuEventReceiptKey.EVENT_KEY: event_key,
|
||||
FeishuEventReceiptKey.SOURCE: source_value,
|
||||
FeishuEventReceiptKey.EVENT_ID: str(event_id) if event_id else None,
|
||||
FeishuEventReceiptKey.MESSAGE_ID: str(message_id) if message_id else None,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ from urllib.parse import urlsplit
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import SessionLocal
|
||||
from app.modules.feishu.constants import FEISHU_DEFAULT_OPEN_API_DOMAIN, FeishuEventSource
|
||||
from app.modules.feishu.events import FeishuEventService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -13,7 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
def _sdk_domain(base_url: str) -> str:
|
||||
parsed = urlsplit(base_url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return "https://open.feishu.cn"
|
||||
return FEISHU_DEFAULT_OPEN_API_DOMAIN
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
|
||||
@@ -35,7 +36,7 @@ def _handle_message_event(event: Any) -> None:
|
||||
try:
|
||||
result = FeishuEventService(db).handle_event(
|
||||
payload,
|
||||
source="long_connection",
|
||||
source=FeishuEventSource.LONG_CONNECTION,
|
||||
auto_reply=True,
|
||||
)
|
||||
logger.info("Handled Feishu long connection event: %s", result)
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.feishu.commands import FeishuCommandService
|
||||
from app.modules.feishu.constants import FeishuEventSource, FeishuPayloadKey, FeishuResponseKey
|
||||
from app.modules.feishu.events import FeishuEventService
|
||||
from app.modules.feishu.schemas import (
|
||||
FeishuCardMessage,
|
||||
@@ -22,11 +23,11 @@ async def feishu_webhook(request: Request, db: Session = Depends(get_db)) -> dic
|
||||
"""Handle Feishu webhook challenge and text command events."""
|
||||
|
||||
payload = await request.json()
|
||||
service = FeishuService(db)
|
||||
service.verify_event(payload)
|
||||
if payload.get("challenge"):
|
||||
return {"challenge": payload["challenge"]}
|
||||
return FeishuEventService(db).handle_event(payload, source="webhook", auto_reply=True)
|
||||
return FeishuEventService(db).handle_event(
|
||||
payload,
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
auto_reply=True,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/send-text", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
|
||||
@@ -41,7 +42,10 @@ def send_text(
|
||||
receive_id_type=payload.receive_id_type,
|
||||
actor=principal.actor,
|
||||
)
|
||||
return {"ok": result.get("code") == 0, "provider_response": result}
|
||||
return {
|
||||
FeishuResponseKey.OK: result.get(FeishuPayloadKey.CODE) == 0,
|
||||
FeishuResponseKey.PROVIDER_RESPONSE: result,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/send-card", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
|
||||
@@ -56,7 +60,10 @@ def send_card(
|
||||
receive_id_type=payload.receive_id_type,
|
||||
actor=principal.actor,
|
||||
)
|
||||
return {"ok": result.get("code") == 0, "provider_response": result}
|
||||
return {
|
||||
FeishuResponseKey.OK: result.get(FeishuPayloadKey.CODE) == 0,
|
||||
FeishuResponseKey.PROVIDER_RESPONSE: result,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -10,7 +10,13 @@ from app.modules.audit.constants import AuditAction, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.feishu.client import FeishuClient
|
||||
from app.modules.feishu.constants import FeishuPayloadKey, FeishuReceiveIdType
|
||||
from app.modules.feishu.constants import (
|
||||
FEISHU_EMPTY_CARD_TEXT,
|
||||
FEISHU_INVALID_TOKEN,
|
||||
FEISHU_VERIFICATION_TOKEN_REQUIRED,
|
||||
FeishuPayloadKey,
|
||||
FeishuReceiveIdType,
|
||||
)
|
||||
|
||||
|
||||
class FeishuService:
|
||||
@@ -24,17 +30,17 @@ class FeishuService:
|
||||
def verify_event(self, payload: dict[str, Any]) -> None:
|
||||
settings = get_settings()
|
||||
expected = settings.feishu_verification_token
|
||||
header = payload.get("header") or {}
|
||||
token = payload.get("token") or header.get("token")
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
token = payload.get(FeishuPayloadKey.TOKEN) or header.get(FeishuPayloadKey.TOKEN)
|
||||
if not expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="FEISHU_VERIFICATION_TOKEN is required",
|
||||
detail=FEISHU_VERIFICATION_TOKEN_REQUIRED,
|
||||
)
|
||||
if not token or not compare_digest(str(token), expected):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Feishu token",
|
||||
detail=FEISHU_INVALID_TOKEN,
|
||||
)
|
||||
|
||||
def send_text(
|
||||
@@ -86,14 +92,19 @@ class FeishuService:
|
||||
@staticmethod
|
||||
def build_basic_card(title: str, lines: list[str]) -> dict[str, Any]:
|
||||
return {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {"title": {"tag": "plain_text", "content": title}},
|
||||
"elements": [
|
||||
FeishuPayloadKey.CONFIG: {FeishuPayloadKey.WIDE_SCREEN_MODE: True},
|
||||
FeishuPayloadKey.HEADER: {
|
||||
FeishuPayloadKey.TITLE: {
|
||||
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
|
||||
FeishuPayloadKey.CONTENT: title,
|
||||
}
|
||||
},
|
||||
FeishuPayloadKey.ELEMENTS: [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": "\n".join(lines) or "暂无数据",
|
||||
FeishuPayloadKey.TAG: FeishuPayloadKey.DIV,
|
||||
FeishuPayloadKey.TEXT: {
|
||||
FeishuPayloadKey.TAG: FeishuPayloadKey.LARK_MARKDOWN,
|
||||
FeishuPayloadKey.CONTENT: "\n".join(lines) or FEISHU_EMPTY_CARD_TEXT,
|
||||
},
|
||||
}
|
||||
],
|
||||
|
||||
@@ -5,6 +5,71 @@ class LegacyQueryName(StrEnum):
|
||||
PROJECTS = "projects"
|
||||
|
||||
|
||||
class LegacyResponseKey(StrEnum):
|
||||
STATUS = "status"
|
||||
COLUMNS = "columns"
|
||||
ROWS = "rows"
|
||||
ROW_COUNT = "row_count"
|
||||
DRY_RUN = "dry_run"
|
||||
CREATED = "created"
|
||||
UPDATED = "updated"
|
||||
SKIPPED = "skipped"
|
||||
ITEMS = "items"
|
||||
ACTION = "action"
|
||||
REASON = "reason"
|
||||
SOURCE = "source"
|
||||
PROJECT = "project"
|
||||
SYNC_RUN_CODE = "sync_run_code"
|
||||
SOURCE_QUERY = "source_query"
|
||||
FIELD_MAP = "field_map"
|
||||
LIMIT = "limit"
|
||||
|
||||
|
||||
class LegacyProjectField(StrEnum):
|
||||
ID = "id"
|
||||
CODE = "code"
|
||||
EXTERNAL_ID = "external_id"
|
||||
SOURCE_SYSTEM = "source_system"
|
||||
NAME = "name"
|
||||
OWNER = "owner"
|
||||
STATUS = "status"
|
||||
PROGRESS = "progress"
|
||||
PROGRESS_PERCENT = "progress_percent"
|
||||
START_DATE = "start_date"
|
||||
DUE_DATE = "due_date"
|
||||
BUDGET = "budget"
|
||||
BUDGET_AMOUNT = "budget_amount"
|
||||
ACTUAL_COST = "actual_cost"
|
||||
ACTUAL_AMOUNT = "actual_amount"
|
||||
DESCRIPTION = "description"
|
||||
|
||||
|
||||
class LegacySyncAction(StrEnum):
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
SKIPPED = "skipped"
|
||||
ALLOWLISTED_INLINE_SQL = "allowlisted_inline_sql"
|
||||
|
||||
|
||||
class LegacyQueryError(StrEnum):
|
||||
DATABASE_NOT_CONFIGURED = "LEGACY_DATABASE_URL is not configured"
|
||||
QUERY_NOT_ALLOWED = "Legacy query is not in the configured allowlist"
|
||||
PROJECT_QUERY_NOT_CONFIGURED = "LEGACY_PROJECT_QUERY is not configured. Configure it first."
|
||||
ONLY_SELECT_ALLOWED = "Only SELECT statements are allowed"
|
||||
FORBIDDEN_SQL_TOKEN = "Forbidden SQL token in readonly query"
|
||||
INVALID_LIMIT = "Invalid readonly query limit"
|
||||
APP_DB_UNAVAILABLE = "Application database session is not available"
|
||||
|
||||
|
||||
LEGACY_SYNC_RUN_CODE_PREFIX = "SYNC-PROJECTS"
|
||||
LEGACY_PROJECT_QUERY_SOURCE = "LEGACY_PROJECT_QUERY"
|
||||
LEGACY_SYNC_MISSING_ID_REASON = "missing external_id/code"
|
||||
LEGACY_PROJECT_SYNC_NOTE = "Project sync from readonly legacy MySQL"
|
||||
LEGACY_MYSQL_CONNECTION_FAILED_TEMPLATE = "MySQL connection failed: {error}"
|
||||
LEGACY_HEALTH_SQL = "SELECT 1"
|
||||
LEGACY_SELECT_PREFIX = "select"
|
||||
LEGACY_SQL_TRAILING_TERMINATOR = ";"
|
||||
LEGACY_LIMIT_MARKER = " limit "
|
||||
LEGACY_LIMIT_CLAUSE = " LIMIT :limit"
|
||||
LEGACY_PROJECT_CODE_TEMPLATE = "{prefix}-{external_id}"
|
||||
LEGACY_UNNAMED_PROJECT = "未命名项目"
|
||||
|
||||
@@ -19,16 +19,6 @@ def mysql_health(db: Session = Depends(get_db)) -> dict[str, str]:
|
||||
return LegacyMySQLService(db).health()
|
||||
|
||||
|
||||
@router.get("/tables")
|
||||
def list_tables(db: Session = Depends(get_db)) -> dict[str, list[str]]:
|
||||
return {"tables": LegacyMySQLService(db).list_tables()}
|
||||
|
||||
|
||||
@router.get("/tables/{table_name}")
|
||||
def describe_table(table_name: str, db: Session = Depends(get_db)) -> dict:
|
||||
return {"table": table_name, "columns": LegacyMySQLService(db).describe_table(table_name)}
|
||||
|
||||
|
||||
@router.post("/query", response_model=QueryResult)
|
||||
def readonly_query(payload: ReadonlyQueryRequest, db: Session = Depends(get_db)) -> dict:
|
||||
service = LegacyMySQLService(db)
|
||||
|
||||
@@ -3,13 +3,12 @@ from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.engine import Engine, RowMapping
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.constants import ActorValue, ApiStatus
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import legacy_engine
|
||||
from app.core.pagination import bounded_limit
|
||||
@@ -20,7 +19,25 @@ from app.modules.audit.service import AuditService
|
||||
from app.modules.business.constants import BusinessDomain, SourceSystem, StatusValue
|
||||
from app.modules.business.models import LegacySyncRun, Project
|
||||
from app.modules.business.service import serialize_model
|
||||
from app.modules.legacy_mysql.constants import LegacyQueryError, LegacyQueryName
|
||||
from app.modules.legacy_mysql.constants import (
|
||||
LEGACY_PROJECT_QUERY_SOURCE,
|
||||
LEGACY_PROJECT_SYNC_NOTE,
|
||||
LEGACY_SYNC_MISSING_ID_REASON,
|
||||
LEGACY_SYNC_RUN_CODE_PREFIX,
|
||||
LEGACY_HEALTH_SQL,
|
||||
LEGACY_LIMIT_CLAUSE,
|
||||
LEGACY_LIMIT_MARKER,
|
||||
LEGACY_MYSQL_CONNECTION_FAILED_TEMPLATE,
|
||||
LEGACY_PROJECT_CODE_TEMPLATE,
|
||||
LEGACY_SELECT_PREFIX,
|
||||
LEGACY_SQL_TRAILING_TERMINATOR,
|
||||
LEGACY_UNNAMED_PROJECT,
|
||||
LegacyProjectField,
|
||||
LegacyQueryError,
|
||||
LegacyQueryName,
|
||||
LegacyResponseKey,
|
||||
LegacySyncAction,
|
||||
)
|
||||
|
||||
FORBIDDEN_SQL_TOKENS = {
|
||||
"insert",
|
||||
@@ -53,7 +70,7 @@ def _row_to_dict(row: RowMapping) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _normalize_sql(sql: str) -> str:
|
||||
return " ".join(sql.strip().rstrip(";").split()).lower()
|
||||
return " ".join(sql.strip().rstrip(LEGACY_SQL_TRAILING_TERMINATOR).split()).lower()
|
||||
|
||||
|
||||
def _query_name_text(query_name: str | LegacyQueryName | None) -> str:
|
||||
@@ -75,18 +92,24 @@ class LegacyMySQLService:
|
||||
if legacy_engine is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="LEGACY_DATABASE_URL is not configured",
|
||||
detail=LegacyQueryError.DATABASE_NOT_CONFIGURED,
|
||||
)
|
||||
return legacy_engine
|
||||
|
||||
@staticmethod
|
||||
def _ensure_readonly(sql: str) -> None:
|
||||
stripped = sql.strip().lower()
|
||||
if not stripped.startswith("select"):
|
||||
raise HTTPException(status_code=400, detail="Only SELECT statements are allowed")
|
||||
if not stripped.startswith(LEGACY_SELECT_PREFIX):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=LegacyQueryError.ONLY_SELECT_ALLOWED,
|
||||
)
|
||||
tokens = {token.strip(" ;,\n\t") for token in stripped.replace("(", " ").split()}
|
||||
if tokens & FORBIDDEN_SQL_TOKENS:
|
||||
raise HTTPException(status_code=400, detail="Forbidden SQL token in readonly query")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=LegacyQueryError.FORBIDDEN_SQL_TOKEN,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _allowed_queries() -> dict[str, str]:
|
||||
@@ -103,35 +126,13 @@ class LegacyMySQLService:
|
||||
engine = self._ensure_engine()
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
conn.execute(text(LEGACY_HEALTH_SQL))
|
||||
except SQLAlchemyError as exc:
|
||||
raise HTTPException(status_code=503, detail=f"MySQL connection failed: {exc}") from exc
|
||||
return {"status": "ok"}
|
||||
|
||||
def list_tables(self) -> list[str]:
|
||||
engine = self._ensure_engine()
|
||||
return sorted(inspect(engine).get_table_names())
|
||||
|
||||
def describe_table(self, table_name: str) -> list[dict[str, Any]]:
|
||||
engine = self._ensure_engine()
|
||||
inspector = inspect(engine)
|
||||
if table_name not in inspector.get_table_names():
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
columns = []
|
||||
for column in inspector.get_columns(table_name):
|
||||
columns.append(
|
||||
{
|
||||
"name": column["name"],
|
||||
"type": str(column["type"]),
|
||||
"nullable": column.get("nullable", True),
|
||||
"default": (
|
||||
str(column.get("default"))
|
||||
if column.get("default") is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return columns
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=LEGACY_MYSQL_CONNECTION_FAILED_TEMPLATE.format(error=exc),
|
||||
) from exc
|
||||
return {LegacyResponseKey.STATUS: ApiStatus.OK}
|
||||
|
||||
def execute_readonly(
|
||||
self,
|
||||
@@ -176,29 +177,39 @@ class LegacyMySQLService:
|
||||
engine = self._ensure_engine()
|
||||
params = dict(params or {})
|
||||
try:
|
||||
params["limit"] = bounded_limit(params.get("limit", limit))
|
||||
params[LegacyResponseKey.LIMIT] = bounded_limit(
|
||||
params.get(LegacyResponseKey.LIMIT, limit)
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid readonly query limit",
|
||||
detail=LegacyQueryError.INVALID_LIMIT,
|
||||
) from exc
|
||||
limited_sql = sql
|
||||
if " limit " not in sql.lower():
|
||||
limited_sql = f"{sql.rstrip(';')} LIMIT :limit"
|
||||
if LEGACY_LIMIT_MARKER not in sql.lower():
|
||||
limited_sql = f"{sql.rstrip(LEGACY_SQL_TRAILING_TERMINATOR)}{LEGACY_LIMIT_CLAUSE}"
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text(limited_sql), params)
|
||||
rows = [_row_to_dict(row) for row in result.mappings().all()]
|
||||
columns = list(rows[0].keys()) if rows else []
|
||||
return {"columns": columns, "rows": rows, "row_count": len(rows)}
|
||||
return {
|
||||
LegacyResponseKey.COLUMNS: columns,
|
||||
LegacyResponseKey.ROWS: rows,
|
||||
LegacyResponseKey.ROW_COUNT: len(rows),
|
||||
}
|
||||
|
||||
def fetch_default_projects(self, limit: int = 100) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
if not settings.legacy_project_query:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=LegacyQueryError.PROJECT_QUERY_NOT_CONFIGURED,
|
||||
)
|
||||
return self.execute_allowed_query(LegacyQueryName.PROJECTS, {"limit": limit}, limit=limit)
|
||||
return self.execute_allowed_query(
|
||||
LegacyQueryName.PROJECTS,
|
||||
{LegacyResponseKey.LIMIT: limit},
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _value(
|
||||
@@ -214,38 +225,51 @@ class LegacyMySQLService:
|
||||
|
||||
def _project_payload(self, row: dict[str, Any], field_map: dict[str, str]) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
external_id = self._value(row, field_map, "external_id", row.get("id"))
|
||||
raw_code = self._value(row, field_map, "code", None)
|
||||
external_id = self._value(
|
||||
row,
|
||||
field_map,
|
||||
LegacyProjectField.EXTERNAL_ID,
|
||||
row.get(LegacyProjectField.ID),
|
||||
)
|
||||
raw_code = self._value(row, field_map, LegacyProjectField.CODE, None)
|
||||
code = None
|
||||
if raw_code:
|
||||
code = str(raw_code)
|
||||
elif external_id is not None:
|
||||
code = f"{settings.legacy_project_code_prefix}-{external_id}"
|
||||
code = LEGACY_PROJECT_CODE_TEMPLATE.format(
|
||||
prefix=settings.legacy_project_code_prefix,
|
||||
external_id=external_id,
|
||||
)
|
||||
return {
|
||||
"code": code,
|
||||
"external_id": str(external_id) if external_id is not None else code,
|
||||
"source_system": SourceSystem.LEGACY_MYSQL,
|
||||
"name": self._value(row, field_map, "name", "未命名项目"),
|
||||
"owner": self._value(row, field_map, "owner", None),
|
||||
"status": self._value(row, field_map, "status", StatusValue.UNKNOWN),
|
||||
"progress_percent": int(
|
||||
LegacyProjectField.CODE: code,
|
||||
LegacyProjectField.EXTERNAL_ID: str(external_id) if external_id is not None else code,
|
||||
LegacyProjectField.SOURCE_SYSTEM: SourceSystem.LEGACY_MYSQL,
|
||||
LegacyProjectField.NAME: self._value(
|
||||
row,
|
||||
field_map,
|
||||
LegacyProjectField.NAME,
|
||||
LEGACY_UNNAMED_PROJECT,
|
||||
),
|
||||
LegacyProjectField.OWNER: self._value(row, field_map, LegacyProjectField.OWNER, None),
|
||||
LegacyProjectField.STATUS: self._value(row, field_map, LegacyProjectField.STATUS, StatusValue.UNKNOWN),
|
||||
LegacyProjectField.PROGRESS_PERCENT: int(
|
||||
self._value(
|
||||
row,
|
||||
field_map,
|
||||
"progress_percent",
|
||||
row.get("progress") or 0,
|
||||
LegacyProjectField.PROGRESS_PERCENT,
|
||||
row.get(LegacyProjectField.PROGRESS) or 0,
|
||||
)
|
||||
or 0
|
||||
),
|
||||
"start_date": self._value(row, field_map, "start_date", None),
|
||||
"due_date": self._value(row, field_map, "due_date", None),
|
||||
"budget_amount": (
|
||||
self._value(row, field_map, "budget_amount", row.get("budget") or 0) or 0
|
||||
LegacyProjectField.START_DATE: self._value(row, field_map, LegacyProjectField.START_DATE, None),
|
||||
LegacyProjectField.DUE_DATE: self._value(row, field_map, LegacyProjectField.DUE_DATE, None),
|
||||
LegacyProjectField.BUDGET_AMOUNT: (
|
||||
self._value(row, field_map, LegacyProjectField.BUDGET_AMOUNT, row.get(LegacyProjectField.BUDGET) or 0) or 0
|
||||
),
|
||||
"actual_amount": (
|
||||
self._value(row, field_map, "actual_amount", row.get("actual_cost") or 0) or 0
|
||||
LegacyProjectField.ACTUAL_AMOUNT: (
|
||||
self._value(row, field_map, LegacyProjectField.ACTUAL_AMOUNT, row.get(LegacyProjectField.ACTUAL_COST) or 0) or 0
|
||||
),
|
||||
"description": self._value(row, field_map, "description", None),
|
||||
LegacyProjectField.DESCRIPTION: self._value(row, field_map, LegacyProjectField.DESCRIPTION, None),
|
||||
}
|
||||
|
||||
def sync_projects(
|
||||
@@ -259,16 +283,24 @@ class LegacyMySQLService:
|
||||
) -> dict[str, Any]:
|
||||
if self.db is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Application database session is not available",
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=LegacyQueryError.APP_DB_UNAVAILABLE,
|
||||
)
|
||||
|
||||
query_name = source_query_name or LegacyQueryName.PROJECTS
|
||||
if source_query:
|
||||
rows = self.execute_readonly(source_query, {"limit": limit}, limit=limit)["rows"]
|
||||
query_ref = "allowlisted_inline_sql"
|
||||
rows = self.execute_readonly(
|
||||
source_query,
|
||||
{LegacyResponseKey.LIMIT: limit},
|
||||
limit=limit,
|
||||
)[LegacyResponseKey.ROWS]
|
||||
query_ref = LegacySyncAction.ALLOWLISTED_INLINE_SQL
|
||||
else:
|
||||
rows = self.execute_allowed_query(query_name, {"limit": limit}, limit=limit)["rows"]
|
||||
rows = self.execute_allowed_query(
|
||||
query_name,
|
||||
{LegacyResponseKey.LIMIT: limit},
|
||||
limit=limit,
|
||||
)[LegacyResponseKey.ROWS]
|
||||
query_ref = _query_name_text(query_name)
|
||||
field_map = field_map or {}
|
||||
created = 0
|
||||
@@ -278,30 +310,30 @@ class LegacyMySQLService:
|
||||
|
||||
for row in rows:
|
||||
payload = self._project_payload(row, field_map)
|
||||
if not payload["external_id"] and not payload["code"]:
|
||||
if not payload[LegacyProjectField.EXTERNAL_ID] and not payload[LegacyProjectField.CODE]:
|
||||
skipped += 1
|
||||
items.append(
|
||||
{
|
||||
"action": "skipped",
|
||||
"reason": "missing external_id/code",
|
||||
"source": row,
|
||||
LegacyResponseKey.ACTION: LegacySyncAction.SKIPPED,
|
||||
LegacyResponseKey.REASON: LEGACY_SYNC_MISSING_ID_REASON,
|
||||
LegacyResponseKey.SOURCE: row,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
stmt = select(Project).where(
|
||||
Project.source_system == SourceSystem.LEGACY_MYSQL,
|
||||
Project.external_id == payload["external_id"],
|
||||
Project.external_id == payload[LegacyProjectField.EXTERNAL_ID],
|
||||
)
|
||||
record = self.db.execute(stmt).scalar_one_or_none()
|
||||
if record is None:
|
||||
record = self.db.execute(
|
||||
select(Project).where(Project.code == payload["code"])
|
||||
select(Project).where(Project.code == payload[LegacyProjectField.CODE])
|
||||
).scalar_one_or_none()
|
||||
|
||||
if record is None:
|
||||
created += 1
|
||||
action = "create"
|
||||
action = LegacySyncAction.CREATE
|
||||
result = payload
|
||||
if not dry_run:
|
||||
record = Project(**payload)
|
||||
@@ -310,7 +342,7 @@ class LegacyMySQLService:
|
||||
result = serialize_model(record)
|
||||
else:
|
||||
updated += 1
|
||||
action = "update"
|
||||
action = LegacySyncAction.UPDATE
|
||||
if not dry_run:
|
||||
for key, value in payload.items():
|
||||
setattr(record, key, value)
|
||||
@@ -318,33 +350,39 @@ class LegacyMySQLService:
|
||||
result = serialize_model(record)
|
||||
else:
|
||||
result = payload
|
||||
items.append({"action": action, "project": result, "source": row})
|
||||
items.append(
|
||||
{
|
||||
LegacyResponseKey.ACTION: action,
|
||||
LegacyResponseKey.PROJECT: result,
|
||||
LegacyResponseKey.SOURCE: row,
|
||||
}
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
self.db.commit()
|
||||
|
||||
result = {
|
||||
"dry_run": dry_run,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
"items": items,
|
||||
LegacyResponseKey.DRY_RUN: dry_run,
|
||||
LegacyResponseKey.CREATED: created,
|
||||
LegacyResponseKey.UPDATED: updated,
|
||||
LegacyResponseKey.SKIPPED: skipped,
|
||||
LegacyResponseKey.ITEMS: items,
|
||||
}
|
||||
sync_run = LegacySyncRun(
|
||||
code=f"SYNC-PROJECTS-{utc_now():%Y%m%d%H%M%S%f}",
|
||||
code=f"{LEGACY_SYNC_RUN_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}",
|
||||
domain=BusinessDomain.PROJECTS,
|
||||
source_table="LEGACY_PROJECT_QUERY",
|
||||
source_table=LEGACY_PROJECT_QUERY_SOURCE,
|
||||
status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS,
|
||||
finished_at=utc_now(),
|
||||
created_count=created,
|
||||
updated_count=updated,
|
||||
skipped_count=skipped,
|
||||
note="Project sync from readonly legacy MySQL",
|
||||
note=LEGACY_PROJECT_SYNC_NOTE,
|
||||
)
|
||||
self.db.add(sync_run)
|
||||
self.db.commit()
|
||||
self.db.refresh(sync_run)
|
||||
result["sync_run_code"] = sync_run.code
|
||||
result[LegacyResponseKey.SYNC_RUN_CODE] = sync_run.code
|
||||
|
||||
AuditService(self.db).log(
|
||||
AuditLogCreate(
|
||||
@@ -354,13 +392,19 @@ class LegacyMySQLService:
|
||||
target_type=BusinessDomain.PROJECTS,
|
||||
risk_level=AuditRiskLevel.MEDIUM,
|
||||
request_payload={
|
||||
"source_query": query_ref,
|
||||
"field_map": field_map,
|
||||
"limit": limit,
|
||||
"dry_run": dry_run,
|
||||
LegacyResponseKey.SOURCE_QUERY: query_ref,
|
||||
LegacyResponseKey.FIELD_MAP: field_map,
|
||||
LegacyResponseKey.LIMIT: limit,
|
||||
LegacyResponseKey.DRY_RUN: dry_run,
|
||||
},
|
||||
response_payload={
|
||||
key: result[key] for key in ["dry_run", "created", "updated", "skipped"]
|
||||
key: result[key]
|
||||
for key in [
|
||||
LegacyResponseKey.DRY_RUN,
|
||||
LegacyResponseKey.CREATED,
|
||||
LegacyResponseKey.UPDATED,
|
||||
LegacyResponseKey.SKIPPED,
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -52,6 +52,23 @@ class LifecycleResponseKey(StrEnum):
|
||||
PROJECT_LIFECYCLE_REPORT = "project_lifecycle_report"
|
||||
|
||||
|
||||
class ReportResponseKey(StrEnum):
|
||||
REPORT = "report"
|
||||
DATA = "data"
|
||||
TITLE = "title"
|
||||
REPORT_TYPE = "report_type"
|
||||
PERIOD_START = "period_start"
|
||||
PERIOD_END = "period_end"
|
||||
WORK_DATE = "work_date"
|
||||
TOTAL = "total"
|
||||
ABNORMAL_TOTAL = "abnormal_total"
|
||||
STATUS_COUNTS = "status_counts"
|
||||
LINES = "lines"
|
||||
CONTENT = "content"
|
||||
METRICS = "metrics"
|
||||
RISK_SUMMARY = "risk_summary"
|
||||
|
||||
|
||||
class LifecycleAttentionKey(StrEnum):
|
||||
DELAYED_PROJECTS = "delayed_projects"
|
||||
OVER_BUDGET_PROJECTS = "over_budget_projects"
|
||||
@@ -109,6 +126,18 @@ class MetricKey(StrEnum):
|
||||
LEVEL = "level"
|
||||
|
||||
|
||||
class WorkReportMetricKey(StrEnum):
|
||||
PROJECTS_TOTAL = "projects_total"
|
||||
ACTIVE_PROJECTS = "active_projects"
|
||||
TASKS_TOTAL = "tasks_total"
|
||||
TASKS_COMPLETED = "tasks_completed"
|
||||
TASKS_OVERDUE = "tasks_overdue"
|
||||
PROCUREMENTS_PENDING = "procurements_pending"
|
||||
EXPENSES_PENDING = "expenses_pending"
|
||||
ATTENDANCE_TOTAL = "attendance_total"
|
||||
OPEN_RISK_EVENTS = "open_risk_events"
|
||||
|
||||
|
||||
class HealthLevel(StrEnum):
|
||||
HEALTHY = "healthy"
|
||||
ATTENTION = "attention"
|
||||
@@ -133,3 +162,25 @@ class ReportText(StrEnum):
|
||||
RECOMMEND_STABLE = (
|
||||
"当前生命周期指标稳定,建议继续保持周度复盘和风险事件归档。"
|
||||
)
|
||||
|
||||
|
||||
LIFECYCLE_RISK_SCORE_WEIGHTS = {
|
||||
MetricKey.OVERDUE_TASKS: 1,
|
||||
MetricKey.DELAYED_PROJECTS: 3,
|
||||
MetricKey.OVER_BUDGET_PROJECTS: 4,
|
||||
MetricKey.EXTERNAL_OPEN_EVENTS: 2,
|
||||
MetricKey.EXTERNAL_HIGH_EVENTS: 3,
|
||||
}
|
||||
HEALTH_PENALTY_WEIGHTS = {
|
||||
MetricKey.OVERDUE_TASKS: 3,
|
||||
MetricKey.DELAYED_PROJECTS: 8,
|
||||
MetricKey.OVER_BUDGET_PROJECTS: 10,
|
||||
MetricKey.EXTERNAL_HIGH_EVENTS: 8,
|
||||
MetricKey.BUDGET_USAGE_RATE: 0.4,
|
||||
MetricKey.COMPLETION_RATE: 0.1,
|
||||
MetricKey.BLACKLISTED: 10,
|
||||
}
|
||||
HEALTH_SCORE_MAX = 100
|
||||
HEALTH_SCORE_MIN = 0
|
||||
HEALTHY_SCORE_THRESHOLD = 80
|
||||
ATTENTION_SCORE_THRESHOLD = 60
|
||||
|
||||
@@ -35,17 +35,26 @@ from app.modules.business.models import (
|
||||
from app.modules.business.service import serialize_model
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.reports.constants import (
|
||||
ATTENTION_SCORE_THRESHOLD,
|
||||
HEALTH_PENALTY_WEIGHTS,
|
||||
HEALTH_SCORE_MAX,
|
||||
HEALTH_SCORE_MIN,
|
||||
HEALTHY_SCORE_THRESHOLD,
|
||||
LIFECYCLE_RISK_SCORE_WEIGHTS,
|
||||
HealthLevel,
|
||||
LifecycleAttentionKey,
|
||||
LifecycleFilterKey,
|
||||
LifecycleResponseKey,
|
||||
LifecycleSection,
|
||||
MetricKey,
|
||||
ReportResponseKey,
|
||||
ReportStatus,
|
||||
ReportText,
|
||||
ReportTitle,
|
||||
ReportType,
|
||||
WorkReportMetricKey,
|
||||
)
|
||||
from app.modules.risk.constants import RiskSummaryKey, risk_level_for_score
|
||||
from app.modules.risk.service import RiskService
|
||||
|
||||
|
||||
@@ -164,18 +173,22 @@ class ReportService:
|
||||
f"- 待处理费用:{expense_pending}",
|
||||
f"- 当前账户总余额:{_money(fund_total)}",
|
||||
(
|
||||
f"- 今日打卡记录:{attendance['total']},"
|
||||
f"异常:{attendance['abnormal_total']}"
|
||||
f"- 今日打卡记录:{attendance[ReportResponseKey.TOTAL]},"
|
||||
f"异常:{attendance[ReportResponseKey.ABNORMAL_TOTAL]}"
|
||||
),
|
||||
f"- 逾期任务:{len(risk_summary['overdue_tasks'])}",
|
||||
f"- 延期项目:{len(risk_summary['delayed_projects'])}",
|
||||
f"- 超预算项目:{len(risk_summary['over_budget_projects'])}",
|
||||
f"- 资金风险账户:{len(risk_summary['fund_risks'])}",
|
||||
f"- 供应商风险:{len(risk_summary['supplier_risks'])}",
|
||||
f"- 打开风险事件:{len(risk_summary['open_events'])}",
|
||||
f"- 综合风险等级:{risk_summary['risk_level']}",
|
||||
f"- 逾期任务:{len(risk_summary[RiskSummaryKey.OVERDUE_TASKS])}",
|
||||
f"- 延期项目:{len(risk_summary[RiskSummaryKey.DELAYED_PROJECTS])}",
|
||||
f"- 超预算项目:{len(risk_summary[RiskSummaryKey.OVER_BUDGET_PROJECTS])}",
|
||||
f"- 资金风险账户:{len(risk_summary[RiskSummaryKey.FUND_RISKS])}",
|
||||
f"- 供应商风险:{len(risk_summary[RiskSummaryKey.SUPPLIER_RISKS])}",
|
||||
f"- 打开风险事件:{len(risk_summary[RiskSummaryKey.OPEN_EVENTS])}",
|
||||
f"- 综合风险等级:{risk_summary[RiskSummaryKey.RISK_LEVEL]}",
|
||||
]
|
||||
return {"title": "每日经营晨报", "lines": lines, "content": "\n".join(lines)}
|
||||
return {
|
||||
ReportResponseKey.TITLE: ReportTitle.DAILY_BRIEF,
|
||||
ReportResponseKey.LINES: lines,
|
||||
ReportResponseKey.CONTENT: "\n".join(lines),
|
||||
}
|
||||
|
||||
def project_weekly(self) -> dict:
|
||||
active = self._count(
|
||||
@@ -199,7 +212,11 @@ class ReportService:
|
||||
)
|
||||
for item in over_budget[:10]:
|
||||
lines.append(f" - 超预算:{item.get('code')} {item.get('name')}")
|
||||
return {"title": "项目周报", "lines": lines, "content": "\n".join(lines)}
|
||||
return {
|
||||
ReportResponseKey.TITLE: ReportTitle.PROJECT_WEEKLY,
|
||||
ReportResponseKey.LINES: lines,
|
||||
ReportResponseKey.CONTENT: "\n".join(lines),
|
||||
}
|
||||
|
||||
def project_lifecycle_report(
|
||||
self,
|
||||
@@ -559,20 +576,14 @@ class ReportService:
|
||||
*risk_conditions,
|
||||
)
|
||||
risk_score = (
|
||||
overdue_tasks * 1
|
||||
+ delayed_projects * 3
|
||||
+ over_budget_projects * 4
|
||||
+ external_open_events * 2
|
||||
+ external_high_events * 3
|
||||
overdue_tasks * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.OVERDUE_TASKS]
|
||||
+ delayed_projects * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.DELAYED_PROJECTS]
|
||||
+ over_budget_projects * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.OVER_BUDGET_PROJECTS]
|
||||
+ external_open_events * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.EXTERNAL_OPEN_EVENTS]
|
||||
+ external_high_events * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.EXTERNAL_HIGH_EVENTS]
|
||||
)
|
||||
if risk_score >= 15:
|
||||
level = RiskLevel.HIGH
|
||||
elif risk_score >= 5:
|
||||
level = RiskLevel.MEDIUM
|
||||
else:
|
||||
level = RiskLevel.LOW
|
||||
return {
|
||||
MetricKey.RISK_LEVEL: level,
|
||||
MetricKey.RISK_LEVEL: risk_level_for_score(risk_score),
|
||||
MetricKey.RISK_SCORE: risk_score,
|
||||
MetricKey.OVERDUE_TASKS: overdue_tasks,
|
||||
MetricKey.DELAYED_PROJECTS: delayed_projects,
|
||||
@@ -602,19 +613,32 @@ class ReportService:
|
||||
include_global_risk: bool,
|
||||
) -> dict[str, Any]:
|
||||
penalty = (
|
||||
risks[MetricKey.OVERDUE_TASKS] * 3
|
||||
+ risks[MetricKey.DELAYED_PROJECTS] * 8
|
||||
+ risks[MetricKey.OVER_BUDGET_PROJECTS] * 10
|
||||
+ risks[MetricKey.EXTERNAL_HIGH_EVENTS] * 8
|
||||
+ max(0, projects[MetricKey.BUDGET_USAGE_RATE] - 100) * 0.4
|
||||
+ (100 - tasks[MetricKey.COMPLETION_RATE]) * 0.1
|
||||
risks[MetricKey.OVERDUE_TASKS] * HEALTH_PENALTY_WEIGHTS[MetricKey.OVERDUE_TASKS]
|
||||
+ risks[MetricKey.DELAYED_PROJECTS]
|
||||
* HEALTH_PENALTY_WEIGHTS[MetricKey.DELAYED_PROJECTS]
|
||||
+ risks[MetricKey.OVER_BUDGET_PROJECTS]
|
||||
* HEALTH_PENALTY_WEIGHTS[MetricKey.OVER_BUDGET_PROJECTS]
|
||||
+ risks[MetricKey.EXTERNAL_HIGH_EVENTS]
|
||||
* HEALTH_PENALTY_WEIGHTS[MetricKey.EXTERNAL_HIGH_EVENTS]
|
||||
+ max(
|
||||
HEALTH_SCORE_MIN,
|
||||
projects[MetricKey.BUDGET_USAGE_RATE] - HEALTH_SCORE_MAX,
|
||||
)
|
||||
* HEALTH_PENALTY_WEIGHTS[MetricKey.BUDGET_USAGE_RATE]
|
||||
+ (HEALTH_SCORE_MAX - tasks[MetricKey.COMPLETION_RATE])
|
||||
* HEALTH_PENALTY_WEIGHTS[MetricKey.COMPLETION_RATE]
|
||||
)
|
||||
if include_global_risk:
|
||||
penalty += suppliers[MetricKey.BLACKLISTED] * 10
|
||||
score = max(0, min(100, round(100 - penalty, 2)))
|
||||
if score >= 80:
|
||||
penalty += suppliers[MetricKey.BLACKLISTED] * HEALTH_PENALTY_WEIGHTS[
|
||||
MetricKey.BLACKLISTED
|
||||
]
|
||||
score = max(
|
||||
HEALTH_SCORE_MIN,
|
||||
min(HEALTH_SCORE_MAX, round(HEALTH_SCORE_MAX - penalty, 2)),
|
||||
)
|
||||
if score >= HEALTHY_SCORE_THRESHOLD:
|
||||
level = HealthLevel.HEALTHY
|
||||
elif score >= 60:
|
||||
elif score >= ATTENTION_SCORE_THRESHOLD:
|
||||
level = HealthLevel.ATTENTION
|
||||
else:
|
||||
level = HealthLevel.CRITICAL
|
||||
@@ -801,13 +825,13 @@ class ReportService:
|
||||
for status, count in sorted(status_counts.items()):
|
||||
lines.append(f"- {status}:{count}")
|
||||
return {
|
||||
"title": "打卡汇总",
|
||||
"work_date": target_date.isoformat(),
|
||||
"total": total,
|
||||
"abnormal_total": abnormal_total,
|
||||
"status_counts": status_counts,
|
||||
"lines": lines,
|
||||
"content": "\n".join(lines),
|
||||
ReportResponseKey.TITLE: ReportTitle.ATTENDANCE_SUMMARY,
|
||||
ReportResponseKey.WORK_DATE: target_date.isoformat(),
|
||||
ReportResponseKey.TOTAL: total,
|
||||
ReportResponseKey.ABNORMAL_TOTAL: abnormal_total,
|
||||
ReportResponseKey.STATUS_COUNTS: status_counts,
|
||||
ReportResponseKey.LINES: lines,
|
||||
ReportResponseKey.CONTENT: "\n".join(lines),
|
||||
}
|
||||
|
||||
def generate_work_report(
|
||||
@@ -833,14 +857,14 @@ class ReportService:
|
||||
)
|
||||
lines = self._work_report_lines(title, start, end, metrics, risk_summary)
|
||||
report = {
|
||||
"title": title,
|
||||
"report_type": report_type,
|
||||
"period_start": start.isoformat(),
|
||||
"period_end": end.isoformat(),
|
||||
"lines": lines,
|
||||
"content": "\n".join(lines),
|
||||
"metrics": metrics,
|
||||
"risk_summary": risk_summary,
|
||||
ReportResponseKey.TITLE: title,
|
||||
ReportResponseKey.REPORT_TYPE: report_type,
|
||||
ReportResponseKey.PERIOD_START: start.isoformat(),
|
||||
ReportResponseKey.PERIOD_END: end.isoformat(),
|
||||
ReportResponseKey.LINES: lines,
|
||||
ReportResponseKey.CONTENT: "\n".join(lines),
|
||||
ReportResponseKey.METRICS: metrics,
|
||||
ReportResponseKey.RISK_SUMMARY: risk_summary,
|
||||
}
|
||||
|
||||
record_data = None
|
||||
@@ -854,7 +878,7 @@ class ReportService:
|
||||
project_code=project_code,
|
||||
period_start=start,
|
||||
period_end=end,
|
||||
content=report["content"],
|
||||
content=report[ReportResponseKey.CONTENT],
|
||||
metrics=metrics,
|
||||
risk_summary=risk_summary,
|
||||
)
|
||||
@@ -873,7 +897,7 @@ class ReportService:
|
||||
)
|
||||
)
|
||||
|
||||
return {"report": report, "data": record_data}
|
||||
return {ReportResponseKey.REPORT: report, ReportResponseKey.DATA: record_data}
|
||||
|
||||
def _resolve_period(
|
||||
self,
|
||||
@@ -932,19 +956,25 @@ class ReportService:
|
||||
*task_filters,
|
||||
)
|
||||
return {
|
||||
"projects_total": self._count(Project, *project_filters),
|
||||
"active_projects": self._count(
|
||||
WorkReportMetricKey.PROJECTS_TOTAL: self._count(Project, *project_filters),
|
||||
WorkReportMetricKey.ACTIVE_PROJECTS: self._count(
|
||||
Project,
|
||||
Project.status.notin_(PROJECT_CLOSED_STATUSES),
|
||||
*project_filters,
|
||||
),
|
||||
"tasks_total": self._count(WorkTask, *task_filters),
|
||||
"tasks_completed": completed_tasks,
|
||||
"tasks_overdue": overdue_tasks,
|
||||
"procurements_pending": self._count(Procurement, *procurement_filters),
|
||||
"expenses_pending": self._count(Expense, *expense_filters),
|
||||
"attendance_total": self._count(AttendanceRecord, *attendance_filters),
|
||||
"open_risk_events": self._count(RiskEvent, *risk_filters),
|
||||
WorkReportMetricKey.TASKS_TOTAL: self._count(WorkTask, *task_filters),
|
||||
WorkReportMetricKey.TASKS_COMPLETED: completed_tasks,
|
||||
WorkReportMetricKey.TASKS_OVERDUE: overdue_tasks,
|
||||
WorkReportMetricKey.PROCUREMENTS_PENDING: self._count(
|
||||
Procurement,
|
||||
*procurement_filters,
|
||||
),
|
||||
WorkReportMetricKey.EXPENSES_PENDING: self._count(Expense, *expense_filters),
|
||||
WorkReportMetricKey.ATTENDANCE_TOTAL: self._count(
|
||||
AttendanceRecord,
|
||||
*attendance_filters,
|
||||
),
|
||||
WorkReportMetricKey.OPEN_RISK_EVENTS: self._count(RiskEvent, *risk_filters),
|
||||
}
|
||||
|
||||
def _work_report_lines(
|
||||
@@ -958,14 +988,20 @@ class ReportService:
|
||||
return [
|
||||
f"- 报告:{title}",
|
||||
f"- 周期:{start.isoformat()} 至 {end.isoformat()}",
|
||||
f"- 项目:总数 {metrics['projects_total']},活跃 {metrics['active_projects']}",
|
||||
f"- 任务:总数 {metrics['tasks_total']},完成 {metrics['tasks_completed']}",
|
||||
f"- 逾期任务:{metrics['tasks_overdue']}",
|
||||
f"- 待处理采购:{metrics['procurements_pending']}",
|
||||
f"- 待处理费用:{metrics['expenses_pending']}",
|
||||
f"- 打卡记录:{metrics['attendance_total']}",
|
||||
f"- 打开风险事件:{metrics['open_risk_events']}",
|
||||
f"- 综合风险等级:{risk_summary['risk_level']}",
|
||||
(
|
||||
f"- 项目:总数 {metrics[WorkReportMetricKey.PROJECTS_TOTAL]},"
|
||||
f"活跃 {metrics[WorkReportMetricKey.ACTIVE_PROJECTS]}"
|
||||
),
|
||||
(
|
||||
f"- 任务:总数 {metrics[WorkReportMetricKey.TASKS_TOTAL]},"
|
||||
f"完成 {metrics[WorkReportMetricKey.TASKS_COMPLETED]}"
|
||||
),
|
||||
f"- 逾期任务:{metrics[WorkReportMetricKey.TASKS_OVERDUE]}",
|
||||
f"- 待处理采购:{metrics[WorkReportMetricKey.PROCUREMENTS_PENDING]}",
|
||||
f"- 待处理费用:{metrics[WorkReportMetricKey.EXPENSES_PENDING]}",
|
||||
f"- 打卡记录:{metrics[WorkReportMetricKey.ATTENDANCE_TOTAL]}",
|
||||
f"- 打开风险事件:{metrics[WorkReportMetricKey.OPEN_RISK_EVENTS]}",
|
||||
f"- 综合风险等级:{risk_summary[RiskSummaryKey.RISK_LEVEL]}",
|
||||
]
|
||||
|
||||
def push_report(
|
||||
@@ -975,5 +1011,8 @@ class ReportService:
|
||||
receive_id_type: str,
|
||||
actor: str,
|
||||
) -> dict:
|
||||
card = FeishuService.build_basic_card(report["title"], report["lines"])
|
||||
card = FeishuService.build_basic_card(
|
||||
report[ReportResponseKey.TITLE],
|
||||
report[ReportResponseKey.LINES],
|
||||
)
|
||||
return FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor)
|
||||
|
||||
66
app/modules/risk/constants.py
Normal file
66
app/modules/risk/constants.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from enum import StrEnum
|
||||
|
||||
from app.modules.business.constants import RiskLevel
|
||||
|
||||
|
||||
class RiskSummaryKey(StrEnum):
|
||||
RISK_LEVEL = "risk_level"
|
||||
RISK_SCORE = "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"
|
||||
|
||||
|
||||
class RiskGenerationResultKey(StrEnum):
|
||||
CREATED = "created"
|
||||
UPDATED = "updated"
|
||||
SKIPPED = "skipped"
|
||||
ITEMS = "items"
|
||||
ACTION = "action"
|
||||
RISK_EVENT = "risk_event"
|
||||
|
||||
|
||||
class RiskGenerationAction(StrEnum):
|
||||
CREATED = "created"
|
||||
UPDATED = "updated"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class RiskEventPayloadKey(StrEnum):
|
||||
CODE = "code"
|
||||
TITLE = "title"
|
||||
RISK_TYPE = "risk_type"
|
||||
RISK_LEVEL = "risk_level"
|
||||
STATUS = "status"
|
||||
SOURCE_DOMAIN = "source_domain"
|
||||
SOURCE_RECORD_ID = "source_record_id"
|
||||
PROJECT_CODE = "project_code"
|
||||
OWNER = "owner"
|
||||
DUE_DATE = "due_date"
|
||||
DETECTED_AT = "detected_at"
|
||||
DESCRIPTION = "description"
|
||||
MITIGATION = "mitigation"
|
||||
EVIDENCE = "evidence"
|
||||
|
||||
|
||||
RISK_SCORE_WEIGHTS = {
|
||||
RiskSummaryKey.OVERDUE_TASKS: 1,
|
||||
RiskSummaryKey.DELAYED_PROJECTS: 3,
|
||||
RiskSummaryKey.OVER_BUDGET_PROJECTS: 4,
|
||||
RiskSummaryKey.FUND_RISKS: 5,
|
||||
RiskSummaryKey.SUPPLIER_RISKS: 3,
|
||||
RiskSummaryKey.OPEN_EVENTS: 2,
|
||||
}
|
||||
RISK_LEVEL_HIGH_THRESHOLD = 15
|
||||
RISK_LEVEL_MEDIUM_THRESHOLD = 5
|
||||
|
||||
|
||||
def risk_level_for_score(score: int) -> RiskLevel:
|
||||
if score >= RISK_LEVEL_HIGH_THRESHOLD:
|
||||
return RiskLevel.HIGH
|
||||
if score >= RISK_LEVEL_MEDIUM_THRESHOLD:
|
||||
return RiskLevel.MEDIUM
|
||||
return RiskLevel.LOW
|
||||
@@ -3,6 +3,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.risk.constants import RiskGenerationResultKey
|
||||
from app.modules.risk.service import RiskService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
@@ -15,27 +16,27 @@ def risk_summary(db: Session = Depends(get_db)) -> dict:
|
||||
|
||||
@router.get("/overdue-tasks")
|
||||
def overdue_tasks(db: Session = Depends(get_db)) -> dict:
|
||||
return {"items": RiskService(db).overdue_tasks()}
|
||||
return {RiskGenerationResultKey.ITEMS: RiskService(db).overdue_tasks()}
|
||||
|
||||
|
||||
@router.get("/delayed-projects")
|
||||
def delayed_projects(db: Session = Depends(get_db)) -> dict:
|
||||
return {"items": RiskService(db).delayed_projects()}
|
||||
return {RiskGenerationResultKey.ITEMS: RiskService(db).delayed_projects()}
|
||||
|
||||
|
||||
@router.get("/over-budget-projects")
|
||||
def over_budget_projects(db: Session = Depends(get_db)) -> dict:
|
||||
return {"items": RiskService(db).over_budget_projects()}
|
||||
return {RiskGenerationResultKey.ITEMS: RiskService(db).over_budget_projects()}
|
||||
|
||||
|
||||
@router.get("/funds")
|
||||
def fund_risks(db: Session = Depends(get_db)) -> dict:
|
||||
return {"items": RiskService(db).fund_risks()}
|
||||
return {RiskGenerationResultKey.ITEMS: RiskService(db).fund_risks()}
|
||||
|
||||
|
||||
@router.get("/suppliers")
|
||||
def supplier_risks(db: Session = Depends(get_db)) -> dict:
|
||||
return {"items": RiskService(db).supplier_risks()}
|
||||
return {RiskGenerationResultKey.ITEMS: RiskService(db).supplier_risks()}
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
@@ -44,7 +45,12 @@ def risk_events(
|
||||
status: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {"items": RiskService(db).list_events(limit=limit, status_filter=status)}
|
||||
return {
|
||||
RiskGenerationResultKey.ITEMS: RiskService(db).list_events(
|
||||
limit=limit,
|
||||
status_filter=status,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/events/generate")
|
||||
|
||||
@@ -29,6 +29,14 @@ from app.modules.business.constants import (
|
||||
)
|
||||
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:
|
||||
@@ -97,31 +105,26 @@ class RiskService:
|
||||
external_open_events = [
|
||||
item
|
||||
for item in open_events
|
||||
if item.get("risk_type") not in GENERATED_RISK_EVENT_TYPES
|
||||
if item.get(RiskEventPayloadKey.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
|
||||
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]
|
||||
)
|
||||
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,
|
||||
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]:
|
||||
@@ -135,25 +138,35 @@ class RiskService:
|
||||
|
||||
for payload in payloads:
|
||||
record = self.db.execute(
|
||||
select(RiskEvent).where(RiskEvent.code == payload["code"])
|
||||
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 = "created"
|
||||
action = RiskGenerationAction.CREATED
|
||||
elif record.status in CLOSED_RISK_STATUSES:
|
||||
skipped += 1
|
||||
items.append({"action": "skipped", "risk_event": serialize_model(record)})
|
||||
items.append(
|
||||
{
|
||||
RiskGenerationResultKey.ACTION: RiskGenerationAction.SKIPPED,
|
||||
RiskGenerationResultKey.RISK_EVENT: serialize_model(record),
|
||||
}
|
||||
)
|
||||
continue
|
||||
else:
|
||||
for key, value in payload.items():
|
||||
if key != "code":
|
||||
if key != RiskEventPayloadKey.CODE:
|
||||
setattr(record, key, value)
|
||||
updated += 1
|
||||
action = "updated"
|
||||
items.append({"action": action, "risk_event": serialize_model(record)})
|
||||
action = RiskGenerationAction.UPDATED
|
||||
items.append(
|
||||
{
|
||||
RiskGenerationResultKey.ACTION: action,
|
||||
RiskGenerationResultKey.RISK_EVENT: serialize_model(record),
|
||||
}
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
AuditService(self.db).log(
|
||||
@@ -164,13 +177,18 @@ class RiskService:
|
||||
target_type=AuditTargetType.RISK_EVENTS,
|
||||
risk_level=AuditRiskLevel.MEDIUM,
|
||||
response_payload={
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
RiskGenerationResultKey.CREATED: created,
|
||||
RiskGenerationResultKey.UPDATED: updated,
|
||||
RiskGenerationResultKey.SKIPPED: skipped,
|
||||
},
|
||||
)
|
||||
)
|
||||
return {"created": created, "updated": updated, "skipped": skipped, "items": items}
|
||||
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]] = []
|
||||
@@ -191,22 +209,22 @@ class RiskService:
|
||||
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": (
|
||||
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: (
|
||||
"请负责人更新进度、明确阻塞项并给出新的完成时间。"
|
||||
),
|
||||
"evidence": serialize_model(task),
|
||||
RiskEventPayloadKey.EVIDENCE: serialize_model(task),
|
||||
}
|
||||
)
|
||||
return payloads
|
||||
@@ -222,22 +240,22 @@ class RiskService:
|
||||
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": (
|
||||
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: (
|
||||
"请项目负责人提交延期原因、资源需求和纠偏计划。"
|
||||
),
|
||||
"evidence": serialize_model(project),
|
||||
RiskEventPayloadKey.EVIDENCE: serialize_model(project),
|
||||
}
|
||||
)
|
||||
return payloads
|
||||
@@ -251,22 +269,22 @@ class RiskService:
|
||||
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": (
|
||||
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: (
|
||||
"请复核预算科目、冻结非必要采购并补充审批依据。"
|
||||
),
|
||||
"evidence": serialize_model(project),
|
||||
RiskEventPayloadKey.EVIDENCE: serialize_model(project),
|
||||
}
|
||||
)
|
||||
return payloads
|
||||
@@ -277,21 +295,21 @@ class RiskService:
|
||||
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": (
|
||||
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: (
|
||||
"请财务确认收付款计划,"
|
||||
"并优先处理关键项目资金安排。"
|
||||
),
|
||||
"evidence": serialize_model(account),
|
||||
RiskEventPayloadKey.EVIDENCE: serialize_model(account),
|
||||
}
|
||||
)
|
||||
return payloads
|
||||
@@ -310,20 +328,20 @@ class RiskService:
|
||||
)
|
||||
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": (
|
||||
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: (
|
||||
"请采购负责人复核供应商准入、履约和替代方案。"
|
||||
),
|
||||
"evidence": serialize_model(supplier),
|
||||
RiskEventPayloadKey.EVIDENCE: serialize_model(supplier),
|
||||
}
|
||||
)
|
||||
return payloads
|
||||
|
||||
Reference in New Issue
Block a user