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:
2026-07-06 15:56:43 +08:00
parent 514b14d390
commit fbd0aaa9e4
34 changed files with 1153 additions and 525 deletions

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter
from app.core.constants import ApiResponseKey, ApiStatus
from app.modules.ai_agent.routes import router as ai_router
from app.modules.approvals.routes import router as approvals_router
from app.modules.audit.routes import router as audit_router
@@ -16,7 +17,7 @@ api_router = APIRouter()
def health_check() -> dict[str, str]:
"""Return basic API health status."""
return {"status": "ok"}
return {ApiResponseKey.STATUS: ApiStatus.OK}
api_router.include_router(business_router, prefix="/business", tags=["business"])

View File

@@ -5,9 +5,12 @@ from typing import Any
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.core.constants import ActorValue
DEFAULT_MODEL_PROVIDER = "noop"
from app.core.constants import (
ActorValue,
ConfigErrorDetail,
DEFAULT_MODEL_PROVIDER,
DEFAULT_OPENCLAW_ACTION_JSON,
)
class Settings(BaseSettings):
@@ -48,7 +51,9 @@ class Settings(BaseSettings):
openclaw_api_key: str | None = None
openclaw_gateway_token: str | None = None
openclaw_allowed_tools: list[str] = Field(default_factory=list)
openclaw_allowed_actions: list[str] = Field(default_factory=lambda: ["json"])
openclaw_allowed_actions: list[str] = Field(
default_factory=lambda: [DEFAULT_OPENCLAW_ACTION_JSON]
)
hermes_base_url: str = "http://127.0.0.1:2073/v1"
hermes_api_key: str | None = None
hermes_model: str = "hermes-agent"
@@ -75,7 +80,7 @@ class Settings(BaseSettings):
if text.startswith("["):
data = json.loads(text)
if not isinstance(data, list):
raise ValueError("CORS_ORIGINS must be a CSV string or JSON list")
raise ValueError(ConfigErrorDetail.CORS_ORIGINS_FORMAT)
return [str(item).strip() for item in data if str(item).strip()]
return [item.strip() for item in text.split(",") if item.strip()]
@@ -96,9 +101,9 @@ class Settings(BaseSettings):
if isinstance(value, str):
data = json.loads(value)
if not isinstance(data, dict):
raise ValueError("LEGACY_ALLOWED_QUERIES must be a JSON object")
raise ValueError(ConfigErrorDetail.LEGACY_ALLOWED_QUERIES_FORMAT)
return {str(key): str(item) for key, item in data.items()}
raise ValueError("LEGACY_ALLOWED_QUERIES must be a JSON object")
raise ValueError(ConfigErrorDetail.LEGACY_ALLOWED_QUERIES_FORMAT)
@lru_cache

View File

@@ -17,4 +17,28 @@ class HttpHeader(StrEnum):
X_APPROVAL_API_KEY = "X-Approval-API-Key"
class ApiResponseKey(StrEnum):
STATUS = "status"
class ApiStatus(StrEnum):
OK = "ok"
class SecurityErrorDetail(StrEnum):
API_KEY_REQUIRED = "API_KEY is required"
INVALID_API_KEY = "Invalid API key"
APPROVAL_API_KEY_REQUIRED = "APPROVAL_API_KEY is required"
INVALID_APPROVAL_API_KEY = "Invalid approval API key"
AUDIT_API_KEY_REQUIRED = "AUDIT_API_KEY is required"
INVALID_AUDIT_API_KEY = "Invalid audit API key"
class ConfigErrorDetail(StrEnum):
CORS_ORIGINS_FORMAT = "CORS_ORIGINS must be a CSV string or JSON list"
LEGACY_ALLOWED_QUERIES_FORMAT = "LEGACY_ALLOWED_QUERIES must be a JSON object"
BEARER_TOKEN_TEMPLATE = "Bearer {token}"
DEFAULT_MODEL_PROVIDER = "noop"
DEFAULT_OPENCLAW_ACTION_JSON = "json"

View File

@@ -4,7 +4,7 @@ from secrets import compare_digest
from fastapi import Header, HTTPException, status
from app.core.config import get_settings
from app.core.constants import HttpHeader
from app.core.constants import HttpHeader, SecurityErrorDetail
@dataclass(frozen=True)
@@ -23,10 +23,13 @@ def require_api_key(
if not settings.api_key:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="API_KEY is required",
detail=SecurityErrorDetail.API_KEY_REQUIRED,
)
if not x_api_key or not compare_digest(x_api_key, settings.api_key):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=SecurityErrorDetail.INVALID_API_KEY,
)
return ApiPrincipal(actor=settings.api_actor)
@@ -42,7 +45,7 @@ def require_approval_api_key(
if not settings.approval_api_key:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="APPROVAL_API_KEY is required",
detail=SecurityErrorDetail.APPROVAL_API_KEY_REQUIRED,
)
if (
not x_approval_api_key
@@ -50,7 +53,7 @@ def require_approval_api_key(
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid approval API key",
detail=SecurityErrorDetail.INVALID_APPROVAL_API_KEY,
)
return ApiPrincipal(actor=settings.approval_api_actor)
@@ -67,11 +70,11 @@ def require_audit_api_key(
if not settings.audit_api_key:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AUDIT_API_KEY is required",
detail=SecurityErrorDetail.AUDIT_API_KEY_REQUIRED,
)
if not x_audit_api_key or not compare_digest(x_audit_api_key, settings.audit_api_key):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid audit API key",
detail=SecurityErrorDetail.INVALID_AUDIT_API_KEY,
)
return ApiPrincipal(actor=settings.audit_api_actor)

View File

@@ -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},

View File

@@ -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]"

View File

@@ -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),
)

View File

@@ -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]

View File

@@ -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}"

View File

@@ -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),
}

View File

@@ -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"

View File

@@ -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):

View File

@@ -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 = frozenset(
{
BusinessDomain.FUND_ACCOUNTS,
BusinessDomain.PERFORMANCE_METRICS,
}
HIGH_RISK_DOMAINS = {"fund-accounts", "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)]

View File

@@ -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}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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"

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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(

View File

@@ -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,
},
}
],

View File

@@ -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 = "未命名项目"

View File

@@ -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)

View File

@@ -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,
]
},
)
)

View File

@@ -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

View File

@@ -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)

View 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

View File

@@ -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")

View File

@@ -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

View File

@@ -1,11 +1,13 @@
@apiKey = {{$dotenv API_KEY}}
### Health
GET http://127.0.0.1:8010/api/v1/health
X-API-Key: change-me
X-API-Key: {{apiKey}}
### Create project
POST http://127.0.0.1:8010/api/v1/business/projects
Content-Type: application/json
X-API-Key: change-me
X-API-Key: {{apiKey}}
{
"actor": "demo",
@@ -23,12 +25,12 @@ X-API-Key: change-me
### Daily brief
GET http://127.0.0.1:8010/api/v1/reports/daily-brief
X-API-Key: change-me
X-API-Key: {{apiKey}}
### AI ask
POST http://127.0.0.1:8010/api/v1/ai/ask
Content-Type: application/json
X-API-Key: change-me
X-API-Key: {{apiKey}}
{
"actor": "demo",

View File

@@ -19,14 +19,16 @@ os.environ["SCHEDULER_ENABLED"] = "false"
from fastapi.testclient import TestClient
from app.core.constants import HttpHeader
from app.core.database import Base, engine
from app.main import app
from app.modules.business.constants import BusinessField, StatusValue
def request(method: str, url: str, **kwargs):
client = TestClient(app)
headers = kwargs.pop("headers", {})
headers.setdefault("X-API-Key", "test-key")
headers.setdefault(HttpHeader.X_API_KEY, "test-key")
response = getattr(client, method)(url, headers=headers, **kwargs)
response.raise_for_status()
return response
@@ -44,7 +46,7 @@ try:
"code": "P-VERIFY-001",
"name": "Verify Project",
"owner": "tester",
"status": "执行中",
BusinessField.STATUS: StatusValue.RUNNING_CN,
"budget_amount": 1000,
"actual_amount": 200,
},

11
security_fix_ignore.yml Normal file
View File

@@ -0,0 +1,11 @@
ignored_findings:
- id: P0_LOCAL_ENV_CONTAINS_RUNTIME_SECRETS
path: .env
reason: "User explicitly excluded local .env secret cleanup from this remediation pass."
status: ignored
notes: "Do not copy the values into reports or tracked documentation."
- id: P0_DEPLOYMENT_DOC_CONTAINS_GATEWAY_CREDENTIALS
path: docs/deployment/外部项目接入OpenClawHermes网关说明.md
reason: "User explicitly excluded deployment document credential cleanup from this remediation pass."
status: ignored
notes: "Do not copy the values into reports or additional tracked files."

View File

@@ -131,6 +131,19 @@ def test_feishu_webhook_routes_message_event() -> None:
assert AUDIT_REDACTED_VALUE in audit_payload
def test_feishu_webhook_challenge_uses_event_service_verification() -> None:
response = client.post(
"/api/v1/integrations/feishu/webhook",
json={
"challenge": "challenge-token",
"token": "test-feishu-token",
},
)
assert response.status_code == 200
assert response.json()["challenge"] == "challenge-token"
def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None:
monkeypatch.setenv("API_KEY", "")
get_settings.cache_clear()