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 fastapi import APIRouter
from app.core.constants import ApiResponseKey, ApiStatus
from app.modules.ai_agent.routes import router as ai_router from app.modules.ai_agent.routes import router as ai_router
from app.modules.approvals.routes import router as approvals_router from app.modules.approvals.routes import router as approvals_router
from app.modules.audit.routes import router as audit_router from app.modules.audit.routes import router as audit_router
@@ -16,7 +17,7 @@ api_router = APIRouter()
def health_check() -> dict[str, str]: def health_check() -> dict[str, str]:
"""Return basic API health status.""" """Return basic API health status."""
return {"status": "ok"} return {ApiResponseKey.STATUS: ApiStatus.OK}
api_router.include_router(business_router, prefix="/business", tags=["business"]) 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 import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from app.core.constants import ActorValue from app.core.constants import (
ActorValue,
DEFAULT_MODEL_PROVIDER = "noop" ConfigErrorDetail,
DEFAULT_MODEL_PROVIDER,
DEFAULT_OPENCLAW_ACTION_JSON,
)
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -48,7 +51,9 @@ class Settings(BaseSettings):
openclaw_api_key: str | None = None openclaw_api_key: str | None = None
openclaw_gateway_token: str | None = None openclaw_gateway_token: str | None = None
openclaw_allowed_tools: list[str] = Field(default_factory=list) 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_base_url: str = "http://127.0.0.1:2073/v1"
hermes_api_key: str | None = None hermes_api_key: str | None = None
hermes_model: str = "hermes-agent" hermes_model: str = "hermes-agent"
@@ -75,7 +80,7 @@ class Settings(BaseSettings):
if text.startswith("["): if text.startswith("["):
data = json.loads(text) data = json.loads(text)
if not isinstance(data, list): 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 [str(item).strip() for item in data if str(item).strip()]
return [item.strip() for item in text.split(",") if item.strip()] return [item.strip() for item in text.split(",") if item.strip()]
@@ -96,9 +101,9 @@ class Settings(BaseSettings):
if isinstance(value, str): if isinstance(value, str):
data = json.loads(value) data = json.loads(value)
if not isinstance(data, dict): 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()} 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 @lru_cache

View File

@@ -17,4 +17,28 @@ class HttpHeader(StrEnum):
X_APPROVAL_API_KEY = "X-Approval-API-Key" 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}" 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 fastapi import Header, HTTPException, status
from app.core.config import get_settings from app.core.config import get_settings
from app.core.constants import HttpHeader from app.core.constants import HttpHeader, SecurityErrorDetail
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -23,10 +23,13 @@ def require_api_key(
if not settings.api_key: if not settings.api_key:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 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): 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) return ApiPrincipal(actor=settings.api_actor)
@@ -42,7 +45,7 @@ def require_approval_api_key(
if not settings.approval_api_key: if not settings.approval_api_key:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="APPROVAL_API_KEY is required", detail=SecurityErrorDetail.APPROVAL_API_KEY_REQUIRED,
) )
if ( if (
not x_approval_api_key not x_approval_api_key
@@ -50,7 +53,7 @@ def require_approval_api_key(
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid approval API key", detail=SecurityErrorDetail.INVALID_APPROVAL_API_KEY,
) )
return ApiPrincipal(actor=settings.approval_api_actor) return ApiPrincipal(actor=settings.approval_api_actor)
@@ -67,11 +70,11 @@ def require_audit_api_key(
if not settings.audit_api_key: if not settings.audit_api_key:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 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): if not x_audit_api_key or not compare_digest(x_audit_api_key, settings.audit_api_key):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid audit API key", detail=SecurityErrorDetail.INVALID_AUDIT_API_KEY,
) )
return ApiPrincipal(actor=settings.audit_api_actor) return ApiPrincipal(actor=settings.audit_api_actor)

View File

@@ -2,7 +2,7 @@ from abc import ABC, abstractmethod
from typing import Any from typing import Any
import httpx import httpx
from fastapi import HTTPException from fastapi import HTTPException, status
from app.core.config import Settings, get_settings from app.core.config import Settings, get_settings
from app.modules.ai_agent.constants import ( from app.modules.ai_agent.constants import (
@@ -11,7 +11,10 @@ from app.modules.ai_agent.constants import (
COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS, COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS,
DIRECT_LLM_API_KEY_MISSING, DIRECT_LLM_API_KEY_MISSING,
NOOP_PROVIDER_ANSWER, NOOP_PROVIDER_ANSWER,
OPENCLAW_ACTION_NOT_ALLOWED,
OPENCLAW_CHAT_PROVIDER_REQUIRED,
OPENCLAW_HERMES_PIPELINE, OPENCLAW_HERMES_PIPELINE,
OPENCLAW_TOOL_NOT_ALLOWED,
OPENCLAW_TOOL_COMPLETED_ANSWER, OPENCLAW_TOOL_COMPLETED_ANSWER,
UNEXPECTED_HERMES_RESPONSE, UNEXPECTED_HERMES_RESPONSE,
AIDefault, AIDefault,
@@ -67,12 +70,8 @@ class OpenClawAdapter(AIAdapter):
tool = context.get(AIContextKey.OPENCLAW_TOOL) tool = context.get(AIContextKey.OPENCLAW_TOOL)
if not tool: if not tool:
raise HTTPException( raise HTTPException(
status_code=400, status_code=status.HTTP_400_BAD_REQUEST,
detail=( detail=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."
),
) )
result = self.invoke_tool( result = self.invoke_tool(
tool=str(tool), tool=str(tool),
@@ -96,7 +95,10 @@ class OpenClawAdapter(AIAdapter):
healthz = client.get(f"{base_url}{AIHttpPath.HEALTHZ}", headers=headers) healthz = client.get(f"{base_url}{AIHttpPath.HEALTHZ}", headers=headers)
readyz = client.get(f"{base_url}{AIHttpPath.READYZ}", headers=headers) readyz = client.get(f"{base_url}{AIHttpPath.READYZ}", headers=headers)
return { 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.BASE_URL: base_url,
AIResponseKey.HEALTHZ: _response_payload(healthz), AIResponseKey.HEALTHZ: _response_payload(healthz),
AIResponseKey.READYZ: _response_payload(readyz), AIResponseKey.READYZ: _response_payload(readyz),
@@ -121,8 +123,11 @@ class OpenClawAdapter(AIAdapter):
url = f"{self._base_url()}{AIHttpPath.TOOLS_INVOKE}" url = f"{self._base_url()}{AIHttpPath.TOOLS_INVOKE}"
with httpx.Client(timeout=120, trust_env=False) as client: with httpx.Client(timeout=120, trust_env=False) as client:
response = client.post(url, json=payload, headers=self._headers()) response = client.post(url, json=payload, headers=self._headers())
if response.status_code >= 400: if response.status_code >= status.HTTP_400_BAD_REQUEST:
raise HTTPException(status_code=502, detail={AIErrorKey.OPENCLAW: response.text}) raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail={AIErrorKey.OPENCLAW: response.text},
)
return _response_payload(response) return _response_payload(response)
def _base_url(self) -> str: def _base_url(self) -> str:
@@ -138,9 +143,15 @@ class OpenClawAdapter(AIAdapter):
def _ensure_tool_allowed(self, tool: str, action: str) -> None: def _ensure_tool_allowed(self, tool: str, action: str) -> None:
if tool not in set(self.settings.openclaw_allowed_tools): 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): 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): class HermesAdapter(AIAdapter):
@@ -167,8 +178,11 @@ class HermesAdapter(AIAdapter):
} }
with httpx.Client(timeout=300, trust_env=False) as client: with httpx.Client(timeout=300, trust_env=False) as client:
response = client.post(url, json=payload, headers=headers) response = client.post(url, json=payload, headers=headers)
if response.status_code >= 400: if response.status_code >= status.HTTP_400_BAD_REQUEST:
raise HTTPException(status_code=502, detail={AIErrorKey.HERMES: response.text}) raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail={AIErrorKey.HERMES: response.text},
)
data = _chat_completion_payload(response, AIErrorKey.HERMES) data = _chat_completion_payload(response, AIErrorKey.HERMES)
try: try:
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][ answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
@@ -176,7 +190,7 @@ class HermesAdapter(AIAdapter):
] ]
except (KeyError, IndexError, TypeError) as exc: except (KeyError, IndexError, TypeError) as exc:
raise HTTPException( raise HTTPException(
status_code=502, status_code=status.HTTP_502_BAD_GATEWAY,
detail={ detail={
AIErrorKey.HERMES: UNEXPECTED_HERMES_RESPONSE, AIErrorKey.HERMES: UNEXPECTED_HERMES_RESPONSE,
AIResponseKey.RAW: data, AIResponseKey.RAW: data,
@@ -196,7 +210,7 @@ class HermesAdapter(AIAdapter):
with httpx.Client(timeout=5, trust_env=False) as client: with httpx.Client(timeout=5, trust_env=False) as client:
response = client.get(url, headers=headers) response = client.get(url, headers=headers)
return { 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.BASE_URL: self.settings.hermes_base_url.rstrip("/"),
AIResponseKey.HEALTH: _response_payload(response), AIResponseKey.HEALTH: _response_payload(response),
} }
@@ -267,7 +281,7 @@ class OpenClawHermesAdapter(AIAdapter):
raise raise
except Exception as exc: except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=502, status_code=status.HTTP_502_BAD_GATEWAY,
detail={AIErrorKey.OPENCLAW: _error_detail(exc)}, detail={AIErrorKey.OPENCLAW: _error_detail(exc)},
) from exc ) from exc
return result return result
@@ -334,7 +348,10 @@ class DirectLLMAdapter(AIAdapter):
def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
if not self.settings.direct_llm_api_key: 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}" url = f"{self.settings.direct_llm_base_url.rstrip('/')}{AIHttpPath.CHAT_COMPLETIONS}"
headers = { headers = {
AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format( AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format(
@@ -347,8 +364,11 @@ class DirectLLMAdapter(AIAdapter):
} }
with httpx.Client(timeout=60, trust_env=False) as client: with httpx.Client(timeout=60, trust_env=False) as client:
response = client.post(url, json=payload, headers=headers) response = client.post(url, json=payload, headers=headers)
if response.status_code >= 400: if response.status_code >= status.HTTP_400_BAD_REQUEST:
raise HTTPException(status_code=502, detail={AIErrorKey.DIRECT_LLM: response.text}) raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail={AIErrorKey.DIRECT_LLM: response.text},
)
data = _chat_completion_payload(response, AIErrorKey.DIRECT_LLM) data = _chat_completion_payload(response, AIErrorKey.DIRECT_LLM)
try: try:
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][ answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
@@ -356,7 +376,7 @@ class DirectLLMAdapter(AIAdapter):
] ]
except (KeyError, IndexError, TypeError) as exc: except (KeyError, IndexError, TypeError) as exc:
raise HTTPException( raise HTTPException(
status_code=502, status_code=status.HTTP_502_BAD_GATEWAY,
detail={ detail={
AIErrorKey.DIRECT_LLM: UNEXPECTED_HERMES_RESPONSE, AIErrorKey.DIRECT_LLM: UNEXPECTED_HERMES_RESPONSE,
AIResponseKey.RAW: data, AIResponseKey.RAW: data,
@@ -404,7 +424,7 @@ def _chat_completion_payload(response: httpx.Response, error_key: AIErrorKey) ->
return response.json() return response.json()
except ValueError as exc: except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=502, status_code=status.HTTP_502_BAD_GATEWAY,
detail={ detail={
error_key: UNEXPECTED_HERMES_RESPONSE, error_key: UNEXPECTED_HERMES_RESPONSE,
AIResponseKey.RAW: {AIResponseKey.TEXT: response.text}, AIResponseKey.RAW: {AIResponseKey.TEXT: response.text},

View File

@@ -89,6 +89,13 @@ class AIHttpPayloadKey(StrEnum):
MESSAGE = "message" MESSAGE = "message"
class AIToolAuditKey(StrEnum):
TOOL = "tool"
ACTION = "action"
ARGS = "args"
SESSION_KEY = "session_key"
class AIChatRole(StrEnum): class AIChatRole(StrEnum):
SYSTEM = "system" SYSTEM = "system"
USER = "user" USER = "user"
@@ -126,6 +133,14 @@ NOOP_PROVIDER_ANSWER = (
OPENCLAW_TOOL_COMPLETED_ANSWER = "OpenClaw tool invocation completed." OPENCLAW_TOOL_COMPLETED_ANSWER = "OpenClaw tool invocation completed."
DIRECT_LLM_API_KEY_MISSING = "DIRECT_LLM_API_KEY is not configured" DIRECT_LLM_API_KEY_MISSING = "DIRECT_LLM_API_KEY is not configured"
UNEXPECTED_HERMES_RESPONSE = "Unexpected chat completion response" 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_REDACTED_VALUE = "[REDACTED]"
AI_AUDIT_TRUNCATED_VALUE = "[TRUNCATED]" AI_AUDIT_TRUNCATED_VALUE = "[TRUNCATED]"

View File

@@ -13,6 +13,7 @@ from app.modules.ai_agent.constants import (
AI_AUDIT_REDACTED_VALUE, AI_AUDIT_REDACTED_VALUE,
AI_AUDIT_SENSITIVE_KEYS, AI_AUDIT_SENSITIVE_KEYS,
AI_AUDIT_TRUNCATED_VALUE, AI_AUDIT_TRUNCATED_VALUE,
AIToolAuditKey,
AIProviderName, AIProviderName,
AIRequestKey, AIRequestKey,
AIResponseKey, AIResponseKey,
@@ -120,10 +121,10 @@ class AIService:
target_id=tool, target_id=tool,
risk_level=AuditRiskLevel.HIGH, risk_level=AuditRiskLevel.HIGH,
request_payload=_audit_safe_payload({ request_payload=_audit_safe_payload({
"tool": tool, AIToolAuditKey.TOOL: tool,
"action": action, AIToolAuditKey.ACTION: action,
"args": args or {}, AIToolAuditKey.ARGS: args or {},
"session_key": session_key, AIToolAuditKey.SESSION_KEY: session_key,
}), }),
response_payload=_audit_safe_payload(result), response_payload=_audit_safe_payload(result),
) )

View File

@@ -4,6 +4,8 @@ from typing import Any
from fastapi import HTTPException, status from fastapi import HTTPException, status
from app.modules.ai_agent.constants import UNSUPPORTED_AI_SKILL_TEMPLATE
class AISkillId(StrEnum): class AISkillId(StrEnum):
"""Stable identifiers for AI capabilities exposed to business modules.""" """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: except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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 ) from exc
return AI_SKILLS[normalized_id] return AI_SKILLS[normalized_id]

View File

@@ -19,3 +19,19 @@ class ApprovalErrorDetail(StrEnum):
SELF_APPROVAL = "Approval applicant cannot approve their own ticket" SELF_APPROVAL = "Approval applicant cannot approve their own ticket"
NOT_APPROVED = "Approval ticket is not approved for this change" NOT_APPROVED = "Approval ticket is not approved for this change"
PAYLOAD_MISMATCH = "Approval ticket payload does not match 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 ( from app.modules.approvals.constants import (
ApprovalActionValue, ApprovalActionValue,
ApprovalErrorDetail, ApprovalErrorDetail,
ApprovalPayloadKey,
ApprovalStatus, ApprovalStatus,
approval_action,
) )
from app.modules.approvals.models import ApprovalRequest from app.modules.approvals.models import ApprovalRequest
from app.modules.approvals.schemas import ApprovalCreate from app.modules.approvals.schemas import ApprovalCreate
@@ -51,7 +53,10 @@ class ApprovalService:
target_id=payload.record_id, target_id=payload.record_id,
risk_level=AuditRiskLevel.MEDIUM, risk_level=AuditRiskLevel.MEDIUM,
request_payload=payload.model_dump(), 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 return ticket
@@ -109,8 +114,11 @@ class ApprovalService:
target_type=ticket.domain, target_type=ticket.domain,
target_id=ticket.record_id, target_id=ticket.record_id,
risk_level=AuditRiskLevel.HIGH, risk_level=AuditRiskLevel.HIGH,
request_payload={"ticket_id": ticket_id, "comment": comment}, request_payload={
response_payload={"status": ticket.status}, ApprovalPayloadKey.TICKET_ID: ticket_id,
ApprovalPayloadKey.COMMENT: comment,
},
response_payload={ApprovalPayloadKey.STATUS: ticket.status},
) )
) )
return ticket return ticket
@@ -137,12 +145,12 @@ class ApprovalService:
) )
used_at = utc_now() used_at = utc_now()
values: dict[str, Any] = { values: dict[str, Any] = {
"status": ApprovalStatus.USED, ApprovalPayloadKey.STATUS: ApprovalStatus.USED,
"used_by": actor, ApprovalPayloadKey.USED_BY: actor,
"used_at": used_at, ApprovalPayloadKey.USED_AT: used_at,
} }
if record_id is not None and not ticket.record_id: 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( result = self.db.execute(
update(ApprovalRequest) update(ApprovalRequest)
.where( .where(
@@ -188,7 +196,7 @@ class ApprovalService:
return ticket.action in { return ticket.action in {
action, action,
ApprovalActionValue.UPDATE, 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" AI_PROVIDER_HEALTH = "ai.provider_health"
OPENCLAW_TOOLS_INVOKE = "openclaw.tools.invoke" OPENCLAW_TOOLS_INVOKE = "openclaw.tools.invoke"
GENERATE_EVENTS = "generate_events" GENERATE_EVENTS = "generate_events"
FEISHU_WEBHOOK_EVENT = "webhook_event"
FEISHU_LONG_CONNECTION_EVENT = "long_connection_event"
FEISHU_SEND_TEXT = "send_text" FEISHU_SEND_TEXT = "send_text"
FEISHU_SEND_CARD = "send_card" FEISHU_SEND_CARD = "send_card"
APPROVAL_CREATE = "approval.create" APPROVAL_CREATE = "approval.create"

View File

@@ -35,6 +35,7 @@ class StatusValue(StrEnum):
ABNORMAL = "异常" ABNORMAL = "异常"
OPEN = "open" OPEN = "open"
RUNNING = "running" RUNNING = "running"
RUNNING_CN = "执行中"
DRY_RUN = "dry_run" DRY_RUN = "dry_run"
@@ -62,10 +63,46 @@ class VersionValue(StrEnum):
class BusinessDomain(StrEnum): class BusinessDomain(StrEnum):
TASKS = "tasks"
PROJECTS = "projects" PROJECTS = "projects"
TASKS = "tasks"
PROCUREMENTS = "procurements"
EXPENSES = "expenses"
FUND_ACCOUNTS = "fund-accounts" FUND_ACCOUNTS = "fund-accounts"
POLICIES = "policies"
STANDARDS = "standards"
PERFORMANCE_METRICS = "performance-metrics"
SUPPLIERS = "suppliers" 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): class RiskEventType(StrEnum):

View File

@@ -1,42 +1,49 @@
from sqlalchemy.orm import DeclarativeMeta from sqlalchemy.orm import DeclarativeMeta
from app.modules.business import models from app.modules.business import models
from app.modules.business.constants import BusinessDomain
DOMAIN_MODELS: dict[str, type[DeclarativeMeta]] = { DOMAIN_MODELS: dict[BusinessDomain, type[DeclarativeMeta]] = {
"projects": models.Project, BusinessDomain.PROJECTS: models.Project,
"tasks": models.WorkTask, BusinessDomain.TASKS: models.WorkTask,
"procurements": models.Procurement, BusinessDomain.PROCUREMENTS: models.Procurement,
"expenses": models.Expense, BusinessDomain.EXPENSES: models.Expense,
"fund-accounts": models.FundAccount, BusinessDomain.FUND_ACCOUNTS: models.FundAccount,
"policies": models.Policy, BusinessDomain.POLICIES: models.Policy,
"standards": models.Standard, BusinessDomain.STANDARDS: models.Standard,
"performance-metrics": models.PerformanceMetric, BusinessDomain.PERFORMANCE_METRICS: models.PerformanceMetric,
"suppliers": models.Supplier, BusinessDomain.SUPPLIERS: models.Supplier,
"attendance-records": models.AttendanceRecord, BusinessDomain.ATTENDANCE_RECORDS: models.AttendanceRecord,
"work-reports": models.WorkReport, BusinessDomain.WORK_REPORTS: models.WorkReport,
"risk-events": models.RiskEvent, BusinessDomain.RISK_EVENTS: models.RiskEvent,
"legacy-sync-runs": models.LegacySyncRun, BusinessDomain.LEGACY_SYNC_RUNS: models.LegacySyncRun,
} }
LOW_RISK_DOMAINS = { HIGH_RISK_DOMAINS = frozenset(
"projects", {
"tasks", BusinessDomain.FUND_ACCOUNTS,
"procurements", BusinessDomain.PERFORMANCE_METRICS,
"expenses", }
"policies", )
"standards", LOW_RISK_DOMAINS = frozenset(set(DOMAIN_MODELS) - HIGH_RISK_DOMAINS)
"suppliers",
"attendance-records",
"work-reports",
"risk-events",
"legacy-sync-runs",
}
HIGH_RISK_DOMAINS = {"fund-accounts", "performance-metrics"}
def get_domain_model(domain: str) -> type[DeclarativeMeta]: def normalize_domain(domain: str | BusinessDomain) -> BusinessDomain:
if domain not in DOMAIN_MODELS: try:
supported = ", ".join(sorted(DOMAIN_MODELS)) return BusinessDomain(domain)
raise KeyError(f"Unsupported domain '{domain}'. Supported: {supported}") except ValueError as exc:
return DOMAIN_MODELS[domain] 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 APIRouter, Depends, HTTPException, Query
from fastapi import status as http_status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.security import ApiPrincipal, require_api_key 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.schemas import DomainListRead, DomainRecordCreate, DomainRecordUpdate
from app.modules.business.service import BusinessService from app.modules.business.service import BusinessService
@@ -12,7 +14,7 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("/domains") @router.get("/domains")
def list_domains() -> dict[str, list[str]]: def list_domains() -> dict[str, list[str]]:
return {"domains": sorted(DOMAIN_MODELS)} return {BusinessResponseKey.DOMAINS: supported_domain_values()}
@router.get("/{domain}", response_model=DomainListRead) @router.get("/{domain}", response_model=DomainListRead)
@@ -20,22 +22,29 @@ def list_records(
domain: str, domain: str,
limit: int = Query(default=50, ge=1, le=500), limit: int = Query(default=50, ge=1, le=500),
offset: int = Query(default=0, ge=0), 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), db: Session = Depends(get_db),
) -> dict: ) -> dict:
try: 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: 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
return {"domain": domain, "total": total, "items": items} return {
BusinessResponseKey.DOMAIN: domain,
BusinessResponseKey.TOTAL: total,
BusinessResponseKey.ITEMS: items,
}
@router.get("/{domain}/{record_id}") @router.get("/{domain}/{record_id}")
def get_record(domain: str, record_id: int, db: Session = Depends(get_db)) -> dict: def get_record(domain: str, record_id: int, db: Session = Depends(get_db)) -> dict:
try: 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: 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}") @router.post("/{domain}")
@@ -53,8 +62,8 @@ def create_record(
payload.approval_ticket_id, payload.approval_ticket_id,
) )
except KeyError as exc: 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
return {"domain": domain, "data": data} return {BusinessResponseKey.DOMAIN: domain, BusinessResponseKey.DATA: data}
@router.patch("/{domain}/{record_id}") @router.patch("/{domain}/{record_id}")
@@ -74,5 +83,5 @@ def update_record(
approval_ticket_id=payload.approval_ticket_id, approval_ticket_id=payload.approval_ticket_id,
) )
except KeyError as exc: 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
return {"domain": domain, "data": data} 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.constants import AuditRiskLevel, AuditSource
from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService 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.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]: 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]: def _model_payload(model: Any, data: dict[str, Any]) -> dict[str, Any]:
"""Validate keys and coerce values according to model column types.""" """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] = {} payload: dict[str, Any] = {}
for key, value in data.items(): for key, value in data.items():
column = columns.get(key) column = columns.get(key)
if column is None: if column is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unknown field '{key}'", detail=UNKNOWN_FIELD_TEMPLATE.format(field=key),
) )
try: try:
payload[key] = _coerce_column_value(column, value) payload[key] = _coerce_column_value(column, value)
except (ValueError, TypeError, InvalidOperation) as exc: except (ValueError, TypeError, InvalidOperation) as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid value for field '{key}'", detail=INVALID_FIELD_VALUE_TEMPLATE.format(field=key),
) from exc ) from exc
return payload return payload
@@ -87,7 +99,7 @@ class BusinessService:
model = get_domain_model(domain) model = get_domain_model(domain)
stmt: Select = select(model) stmt: Select = select(model)
count_stmt = select(func.count()).select_from(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) stmt = stmt.where(model.status == status_filter)
count_stmt = count_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( stmt = stmt.order_by(model.id.desc()).limit(bounded_limit(limit)).offset(
@@ -100,7 +112,10 @@ class BusinessService:
model = get_domain_model(domain) model = get_domain_model(domain)
record = self.db.get(model, record_id) record = self.db.get(model, record_id)
if record is None: 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) return serialize_model(record)
def create_record( def create_record(
@@ -111,16 +126,17 @@ class BusinessService:
approval_ticket_id: str | None = None, approval_ticket_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
model = get_domain_model(domain) model = get_domain_model(domain)
high_risk = is_high_risk_domain(domain)
payload = _model_payload(model, data) payload = _model_payload(model, data)
record = model(**payload) record = model(**payload)
self.db.add(record) self.db.add(record)
if domain in HIGH_RISK_DOMAINS: if high_risk:
self.db.flush() self.db.flush()
self._consume_approval( self._consume_approval(
approval_ticket_id, approval_ticket_id,
domain, domain,
record.id, record.id,
f"create:{domain}", approval_action(ApprovalActionValue.CREATE, domain),
data, data,
actor, actor,
) )
@@ -131,13 +147,14 @@ class BusinessService:
AuditLogCreate( AuditLogCreate(
actor=actor, actor=actor,
source=AuditSource.API, source=AuditSource.API,
action=f"create:{domain}", action=approval_action(ApprovalActionValue.CREATE, domain),
target_type=domain, target_type=domain,
target_id=str(record.id), target_id=str(record.id),
risk_level=( risk_level=AuditRiskLevel.HIGH if high_risk else AuditRiskLevel.LOW,
AuditRiskLevel.HIGH if domain in HIGH_RISK_DOMAINS else AuditRiskLevel.LOW request_payload={
), BusinessPayloadKey.DATA: data,
request_payload={"data": data, "approval_ticket_id": approval_ticket_id}, BusinessPayloadKey.APPROVAL_TICKET_ID: approval_ticket_id,
},
response_payload=result, response_payload=result,
) )
) )
@@ -152,19 +169,20 @@ class BusinessService:
approval_ticket_id: str | None = None, approval_ticket_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
model = get_domain_model(domain) model = get_domain_model(domain)
high_risk = is_high_risk_domain(domain)
record = self.db.get(model, record_id) record = self.db.get(model, record_id)
if record is None: if record is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Record not found", detail=BusinessErrorDetail.RECORD_NOT_FOUND,
) )
payload = _model_payload(model, data) payload = _model_payload(model, data)
if domain in HIGH_RISK_DOMAINS: if high_risk:
self._consume_approval( self._consume_approval(
approval_ticket_id, approval_ticket_id,
domain, domain,
record_id, record_id,
f"update:{domain}", approval_action(ApprovalActionValue.UPDATE, domain),
data, data,
actor, actor,
) )
@@ -177,13 +195,14 @@ class BusinessService:
AuditLogCreate( AuditLogCreate(
actor=actor, actor=actor,
source=AuditSource.API, source=AuditSource.API,
action=f"update:{domain}", action=approval_action(ApprovalActionValue.UPDATE, domain),
target_type=domain, target_type=domain,
target_id=str(record.id), target_id=str(record.id),
risk_level=( risk_level=AuditRiskLevel.HIGH if high_risk else AuditRiskLevel.LOW,
AuditRiskLevel.HIGH if domain in HIGH_RISK_DOMAINS else AuditRiskLevel.LOW request_payload={
), BusinessPayloadKey.DATA: data,
request_payload={"data": data, "approval_ticket_id": approval_ticket_id}, BusinessPayloadKey.APPROVAL_TICKET_ID: approval_ticket_id,
},
response_payload=result, response_payload=result,
) )
) )
@@ -201,7 +220,7 @@ class BusinessService:
if not approval_ticket_id: if not approval_ticket_id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, 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( ApprovalService(self.db).consume_for(
approval_ticket_id, approval_ticket_id,

View File

@@ -3,7 +3,7 @@ import time
from typing import Any from typing import Any
import httpx import httpx
from fastapi import HTTPException from fastapi import HTTPException, status
from app.core.constants import BEARER_TOKEN_TEMPLATE, HttpHeader from app.core.constants import BEARER_TOKEN_TEMPLATE, HttpHeader
from app.core.config import get_settings from app.core.config import get_settings
@@ -34,7 +34,10 @@ class FeishuClient:
def _get_tenant_access_token(self) -> str: def _get_tenant_access_token(self) -> str:
if not self._is_configured(): 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: if self._tenant_access_token and time.time() < self._token_expires_at:
return self._tenant_access_token return self._tenant_access_token
@@ -48,7 +51,10 @@ class FeishuClient:
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
if data.get(FeishuPayloadKey.CODE) != FEISHU_SUCCESS_CODE: 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] self._tenant_access_token = data[FeishuPayloadKey.TENANT_ACCESS_TOKEN]
expire_seconds = int( expire_seconds = int(
data.get(FeishuPayloadKey.EXPIRE, FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS) 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 chat_id = receive_id or self.settings.feishu_default_chat_id
if not chat_id: if not chat_id:
raise HTTPException( raise HTTPException(
status_code=400, status_code=status.HTTP_400_BAD_REQUEST,
detail=FEISHU_RECEIVE_ID_MISSING, detail=FEISHU_RECEIVE_ID_MISSING,
) )
return self.send_message( return self.send_message(
@@ -106,7 +112,7 @@ class FeishuClient:
chat_id = receive_id or self.settings.feishu_default_chat_id chat_id = receive_id or self.settings.feishu_default_chat_id
if not chat_id: if not chat_id:
raise HTTPException( raise HTTPException(
status_code=400, status_code=status.HTTP_400_BAD_REQUEST,
detail=FEISHU_RECEIVE_ID_MISSING, detail=FEISHU_RECEIVE_ID_MISSING,
) )
return self.send_message(chat_id, receive_id_type, FeishuMessageType.INTERACTIVE, card) 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.service import AIService
from app.modules.ai_agent.constants import AIResponseKey from app.modules.ai_agent.constants import AIResponseKey
from app.modules.audit.constants import AuditSource 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.feishu.service import FeishuService
from app.modules.reports.service import ReportService from app.modules.reports.service import ReportService
from app.modules.risk.constants import RiskSummaryKey
from app.modules.risk.service import RiskService from app.modules.risk.service import RiskService
DAILY_REPORT_KEYWORDS = ("日报", "晨报", "经营日报", "经营晨报") DAILY_REPORT_KEYWORDS = ("日报", "晨报", "经营日报", "经营晨报")
@@ -27,7 +38,11 @@ def _parse_content_text(content: Any) -> str:
"""Extract plain command text from a Feishu message content payload.""" """Extract plain command text from a Feishu message content payload."""
if isinstance(content, dict): 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): if not isinstance(content, str):
return "" return ""
try: try:
@@ -35,18 +50,42 @@ def _parse_content_text(content: Any) -> str:
except json.JSONDecodeError: except json.JSONDecodeError:
return content return content
if isinstance(data, dict): 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 return content
def _clean_command_text(text: str) -> str: def _clean_command_text(text: str) -> str:
"""Remove mentions and invisible characters from Feishu command text.""" """Remove mentions and invisible characters from Feishu command text."""
text = re.sub(r"@\S+", "", text or "") text = re.sub(FEISHU_MENTION_PATTERN, "", text or "")
text = text.replace("\u200b", "") text = text.replace(FEISHU_ZERO_WIDTH_SPACE, "")
return text.strip() 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: class FeishuCommandService:
"""Route Feishu text commands to reports, risk summaries, or AI replies.""" """Route Feishu text commands to reports, risk summaries, or AI replies."""
@@ -55,16 +94,20 @@ class FeishuCommandService:
self.feishu = FeishuService(db) self.feishu = FeishuService(db)
def extract_event_command(self, payload: dict[str, Any]) -> dict[str, Any] | None: def extract_event_command(self, payload: dict[str, Any]) -> dict[str, Any] | None:
event = payload.get("event") or {} event = payload.get(FeishuPayloadKey.EVENT) or {}
message = event.get("message") or {} message = event.get(FeishuPayloadKey.MESSAGE) or {}
if not message: if not message:
return None 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: if not text:
return None return None
sender = event.get("sender") or {} sender = event.get(FeishuPayloadKey.SENDER) or {}
sender_id = sender.get("sender_id") or {} sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {}
actor = sender_id.get("open_id") or sender_id.get("user_id") or ActorValue.FEISHU actor = (
sender_id.get(FeishuPayloadKey.OPEN_ID)
or sender_id.get(FeishuPayloadKey.USER_ID)
or ActorValue.FEISHU
)
return { return {
FeishuCommandKey.TEXT: text, FeishuCommandKey.TEXT: text,
FeishuCommandKey.CHAT_ID: message.get(FeishuCommandKey.CHAT_ID), 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): if any(keyword in command_text for keyword in DAILY_REPORT_KEYWORDS):
report = ReportService(self.db).daily_brief() 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: if auto_reply:
provider_response = self._send_card_if_configured( provider_response = self._send_card_if_configured(
chat_id, chat_id,
report["title"], report[ReportResponseKey.TITLE],
report["lines"], report[ReportResponseKey.LINES],
actor, actor,
) )
result["provider_response"] = provider_response return _command_result(
return 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): if any(keyword in command_text for keyword in PROJECT_WEEKLY_KEYWORDS):
report = ReportService(self.db).project_weekly() 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: if auto_reply:
provider_response = self._send_card_if_configured( provider_response = self._send_card_if_configured(
chat_id, chat_id,
report["title"], report[ReportResponseKey.TITLE],
report["lines"], report[ReportResponseKey.LINES],
actor, actor,
) )
result["provider_response"] = provider_response return _command_result(
return 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): if any(keyword in command_text for keyword in ATTENDANCE_KEYWORDS):
report = ReportService(self.db).attendance_summary() 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: if auto_reply:
provider_response = self._send_card_if_configured( provider_response = self._send_card_if_configured(
chat_id, chat_id,
report["title"], report[ReportResponseKey.TITLE],
report["lines"], report[ReportResponseKey.LINES],
actor, actor,
) )
result["provider_response"] = provider_response return _command_result(
return 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): if any(keyword in command_text for keyword in RISK_KEYWORDS):
summary = RiskService(self.db).summary() summary = RiskService(self.db).summary()
lines = [ lines = [
f"- 综合风险等级:{summary['risk_level']}", f"- 综合风险等级:{summary[RiskSummaryKey.RISK_LEVEL]}",
f"- 风险分:{summary['risk_score']}", f"- 风险分:{summary[RiskSummaryKey.RISK_SCORE]}",
f"- 逾期任务:{len(summary['overdue_tasks'])}", f"- 逾期任务:{len(summary[RiskSummaryKey.OVERDUE_TASKS])}",
f"- 延期项目:{len(summary['delayed_projects'])}", f"- 延期项目:{len(summary[RiskSummaryKey.DELAYED_PROJECTS])}",
f"- 超预算项目:{len(summary['over_budget_projects'])}", f"- 超预算项目:{len(summary[RiskSummaryKey.OVER_BUDGET_PROJECTS])}",
f"- 资金风险账户:{len(summary['fund_risks'])}", f"- 资金风险账户:{len(summary[RiskSummaryKey.FUND_RISKS])}",
f"- 供应商风险:{len(summary['supplier_risks'])}", f"- 供应商风险:{len(summary[RiskSummaryKey.SUPPLIER_RISKS])}",
f"- 打开风险事件:{len(summary['open_events'])}", 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: if auto_reply:
provider_response = self._send_card_if_configured( provider_response = self._send_card_if_configured(
chat_id, chat_id,
@@ -165,8 +198,14 @@ class FeishuCommandService:
lines, lines,
actor, actor,
) )
result["provider_response"] = provider_response return _command_result(
return result FeishuCommandName.RISK_SUMMARY,
FeishuReplyType.CARD,
RISK_TITLE,
"\n".join(lines),
provider_response,
lines,
)
prompt = command_text prompt = command_text
for prefix in AI_COMMAND_PREFIXES: for prefix in AI_COMMAND_PREFIXES:
@@ -186,16 +225,15 @@ class FeishuCommandService:
command_text.startswith(prefix) or lowered.startswith(prefix) command_text.startswith(prefix) or lowered.startswith(prefix)
for prefix in AI_COMMAND_PREFIXES 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: if auto_reply:
provider_response = self._send_text_if_configured(chat_id, content, actor) provider_response = self._send_text_if_configured(chat_id, content, actor)
result["provider_response"] = provider_response return _command_result(
return 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( def _send_card_if_configured(
self, self,

View File

@@ -10,25 +10,44 @@ class FeishuMessageType(StrEnum):
INTERACTIVE = "interactive" INTERACTIVE = "interactive"
class FeishuEventSource(StrEnum):
WEBHOOK = "webhook"
LONG_CONNECTION = "long_connection"
class FeishuPayloadKey(StrEnum): 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" HEADER = "header"
EVENT = "event" EVENT = "event"
EVENT_ID = "event_id" EVENT_ID = "event_id"
EVENT_TYPE = "event_type" EVENT_TYPE = "event_type"
LARK_MARKDOWN = "lark_md"
MESSAGE = "message" MESSAGE = "message"
MESSAGE_ID = "message_id" MESSAGE_ID = "message_id"
MESSAGE_TYPE = "msg_type"
OPEN_ID = "open_id"
PLAIN_TEXT = "plain_text"
RECEIVE_ID = "receive_id" RECEIVE_ID = "receive_id"
RECEIVE_ID_TYPE = "receive_id_type" RECEIVE_ID_TYPE = "receive_id_type"
MESSAGE_TYPE = "msg_type" SENDER = "sender"
CONTENT = "content" SENDER_ID = "sender_id"
TEXT = "text" TAG = "tag"
CODE = "code"
TENANT_ACCESS_TOKEN = "tenant_access_token" TENANT_ACCESS_TOKEN = "tenant_access_token"
EXPIRE = "expire" TEXT = "text"
APP_ID = "app_id" TITLE = "title"
APP_SECRET = "app_secret" TOKEN = "token"
FEISHU_ERROR = "feishu_error" USER_ID = "user_id"
CARD = "card" WIDE_SCREEN_MODE = "wide_screen_mode"
class FeishuCommandKey(StrEnum): class FeishuCommandKey(StrEnum):
@@ -37,12 +56,57 @@ class FeishuCommandKey(StrEnum):
ACTOR = "actor" 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_TENANT_TOKEN_PATH = "/auth/v3/tenant_access_token/internal"
FEISHU_MESSAGE_PATH = "/im/v1/messages" 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_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_RECEIVE_ID_MISSING = "receive_id or FEISHU_DEFAULT_CHAT_ID is required"
FEISHU_SUCCESS_CODE = 0 FEISHU_SUCCESS_CODE = 0
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS = 7200 FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS = 7200
FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS = 300 FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS = 300
FEISHU_WEBHOOK_EVENT_ACTION = "webhook_event" FEISHU_AI_REPLY_TITLE = "AI 回复"
FEISHU_LONG_CONNECTION_EVENT_ACTION = "long_connection_event" 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 sqlalchemy.orm import Session
from app.core.constants import ActorValue 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.audit.schemas import AuditLogCreate
from app.modules.feishu.commands import FeishuCommandService from app.modules.feishu.commands import FeishuCommandService
from app.modules.feishu.constants import ( from app.modules.feishu.constants import (
FEISHU_LONG_CONNECTION_EVENT_ACTION,
FEISHU_WEBHOOK_EVENT_ACTION,
FeishuCommandKey, FeishuCommandKey,
FeishuEventReceiptKey,
FeishuEventSource,
FeishuPayloadKey, FeishuPayloadKey,
FeishuResponseKey,
) )
from app.modules.feishu.models import FeishuEventReceipt from app.modules.feishu.models import FeishuEventReceipt
from app.modules.feishu.service import FeishuService from app.modules.feishu.service import FeishuService
FEISHU_EVENT_ACTIONS = { FEISHU_EVENT_ACTIONS = {
"webhook": FEISHU_WEBHOOK_EVENT_ACTION, FeishuEventSource.WEBHOOK: AuditAction.FEISHU_WEBHOOK_EVENT,
"long_connection": FEISHU_LONG_CONNECTION_EVENT_ACTION, FeishuEventSource.LONG_CONNECTION: AuditAction.FEISHU_LONG_CONNECTION_EVENT,
} }
@@ -33,41 +34,57 @@ class FeishuEventService:
def handle_event( def handle_event(
self, self,
payload: dict[str, Any], payload: dict[str, Any],
source: str, source: str | FeishuEventSource,
auto_reply: bool = True, auto_reply: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
self.feishu.verify_event(payload) 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) event_identity = _event_identity(payload, source)
if event_identity and not self._register_event(event_identity): 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( self.feishu.audit.log(
AuditLogCreate( AuditLogCreate(
actor=ActorValue.FEISHU, actor=ActorValue.FEISHU,
source=AuditSource.FEISHU, source=AuditSource.FEISHU,
action=FEISHU_EVENT_ACTIONS.get(source, FEISHU_WEBHOOK_EVENT_ACTION), action=FEISHU_EVENT_ACTIONS[source_value],
target_type=source, target_type=source_value,
target_id=event_identity.get("event_key") if event_identity else None, target_id=(
event_identity.get(FeishuEventReceiptKey.EVENT_KEY)
if event_identity
else None
),
request_payload=payload, request_payload=payload,
response_payload={"accepted": True}, response_payload={FeishuResponseKey.ACCEPTED: True},
) )
) )
command = self.commands.extract_event_command(payload) command = self.commands.extract_event_command(payload)
if not command: if not command:
return {"ok": True, "handled": False} return {FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: False}
result = self.commands.handle_text( result = self.commands.handle_text(
command[FeishuCommandKey.TEXT], command[FeishuCommandKey.TEXT],
chat_id=command[FeishuCommandKey.CHAT_ID], chat_id=command[FeishuCommandKey.CHAT_ID],
actor=command[FeishuCommandKey.ACTOR], actor=command[FeishuCommandKey.ACTOR],
auto_reply=auto_reply, 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: def _register_event(self, event_identity: dict[str, str | None]) -> bool:
receipt = FeishuEventReceipt( receipt = FeishuEventReceipt(
event_key=str(event_identity["event_key"]), event_key=str(event_identity[FeishuEventReceiptKey.EVENT_KEY]),
source=str(event_identity["source"]), source=str(event_identity[FeishuEventReceiptKey.SOURCE]),
event_id=event_identity.get("event_id"), event_id=event_identity.get(FeishuEventReceiptKey.EVENT_ID),
message_id=event_identity.get("message_id"), message_id=event_identity.get(FeishuEventReceiptKey.MESSAGE_ID),
) )
self.db.add(receipt) self.db.add(receipt)
try: try:
@@ -78,7 +95,15 @@ class FeishuEventService:
return True 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 {} header = payload.get(FeishuPayloadKey.HEADER) or {}
event = payload.get(FeishuPayloadKey.EVENT) or {} event = payload.get(FeishuPayloadKey.EVENT) or {}
message = event.get(FeishuPayloadKey.MESSAGE) 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_type = header.get(FeishuPayloadKey.EVENT_TYPE)
event_key = ":".join( event_key = ":".join(
str(part) 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 { return {
"event_key": event_key, FeishuEventReceiptKey.EVENT_KEY: event_key,
"source": source, FeishuEventReceiptKey.SOURCE: source_value,
"event_id": str(event_id) if event_id else None, FeishuEventReceiptKey.EVENT_ID: str(event_id) if event_id else None,
"message_id": str(message_id) if message_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.config import get_settings
from app.core.database import SessionLocal 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 from app.modules.feishu.events import FeishuEventService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -13,7 +14,7 @@ logger = logging.getLogger(__name__)
def _sdk_domain(base_url: str) -> str: def _sdk_domain(base_url: str) -> str:
parsed = urlsplit(base_url) parsed = urlsplit(base_url)
if not parsed.scheme or not parsed.netloc: 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}" return f"{parsed.scheme}://{parsed.netloc}"
@@ -35,7 +36,7 @@ def _handle_message_event(event: Any) -> None:
try: try:
result = FeishuEventService(db).handle_event( result = FeishuEventService(db).handle_event(
payload, payload,
source="long_connection", source=FeishuEventSource.LONG_CONNECTION,
auto_reply=True, auto_reply=True,
) )
logger.info("Handled Feishu long connection event: %s", result) 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.database import get_db
from app.core.security import ApiPrincipal, require_api_key from app.core.security import ApiPrincipal, require_api_key
from app.modules.feishu.commands import FeishuCommandService 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.events import FeishuEventService
from app.modules.feishu.schemas import ( from app.modules.feishu.schemas import (
FeishuCardMessage, 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.""" """Handle Feishu webhook challenge and text command events."""
payload = await request.json() payload = await request.json()
service = FeishuService(db) return FeishuEventService(db).handle_event(
service.verify_event(payload) payload,
if payload.get("challenge"): source=FeishuEventSource.WEBHOOK,
return {"challenge": payload["challenge"]} auto_reply=True,
return FeishuEventService(db).handle_event(payload, source="webhook", auto_reply=True) )
@router.post("/send-text", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)]) @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, receive_id_type=payload.receive_id_type,
actor=principal.actor, 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)]) @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, receive_id_type=payload.receive_id_type,
actor=principal.actor, 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( @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.schemas import AuditLogCreate
from app.modules.audit.service import AuditService from app.modules.audit.service import AuditService
from app.modules.feishu.client import FeishuClient 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: class FeishuService:
@@ -24,17 +30,17 @@ class FeishuService:
def verify_event(self, payload: dict[str, Any]) -> None: def verify_event(self, payload: dict[str, Any]) -> None:
settings = get_settings() settings = get_settings()
expected = settings.feishu_verification_token expected = settings.feishu_verification_token
header = payload.get("header") or {} header = payload.get(FeishuPayloadKey.HEADER) or {}
token = payload.get("token") or header.get("token") token = payload.get(FeishuPayloadKey.TOKEN) or header.get(FeishuPayloadKey.TOKEN)
if not expected: if not expected:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 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): if not token or not compare_digest(str(token), expected):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Feishu token", detail=FEISHU_INVALID_TOKEN,
) )
def send_text( def send_text(
@@ -86,14 +92,19 @@ class FeishuService:
@staticmethod @staticmethod
def build_basic_card(title: str, lines: list[str]) -> dict[str, Any]: def build_basic_card(title: str, lines: list[str]) -> dict[str, Any]:
return { return {
"config": {"wide_screen_mode": True}, FeishuPayloadKey.CONFIG: {FeishuPayloadKey.WIDE_SCREEN_MODE: True},
"header": {"title": {"tag": "plain_text", "content": title}}, FeishuPayloadKey.HEADER: {
"elements": [ FeishuPayloadKey.TITLE: {
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
FeishuPayloadKey.CONTENT: title,
}
},
FeishuPayloadKey.ELEMENTS: [
{ {
"tag": "div", FeishuPayloadKey.TAG: FeishuPayloadKey.DIV,
"text": { FeishuPayloadKey.TEXT: {
"tag": "lark_md", FeishuPayloadKey.TAG: FeishuPayloadKey.LARK_MARKDOWN,
"content": "\n".join(lines) or "暂无数据", FeishuPayloadKey.CONTENT: "\n".join(lines) or FEISHU_EMPTY_CARD_TEXT,
}, },
} }
], ],

View File

@@ -5,6 +5,71 @@ class LegacyQueryName(StrEnum):
PROJECTS = "projects" 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): class LegacyQueryError(StrEnum):
DATABASE_NOT_CONFIGURED = "LEGACY_DATABASE_URL is not configured"
QUERY_NOT_ALLOWED = "Legacy query is not in the configured allowlist" QUERY_NOT_ALLOWED = "Legacy query is not in the configured allowlist"
PROJECT_QUERY_NOT_CONFIGURED = "LEGACY_PROJECT_QUERY is not configured. Configure it first." 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() 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) @router.post("/query", response_model=QueryResult)
def readonly_query(payload: ReadonlyQueryRequest, db: Session = Depends(get_db)) -> dict: def readonly_query(payload: ReadonlyQueryRequest, db: Session = Depends(get_db)) -> dict:
service = LegacyMySQLService(db) service = LegacyMySQLService(db)

View File

@@ -3,13 +3,12 @@ from decimal import Decimal
from typing import Any from typing import Any
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import inspect, text from sqlalchemy import select, text
from sqlalchemy.engine import Engine, RowMapping from sqlalchemy.engine import Engine, RowMapping
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import select
from sqlalchemy.orm import Session 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.config import get_settings
from app.core.database import legacy_engine from app.core.database import legacy_engine
from app.core.pagination import bounded_limit 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.constants import BusinessDomain, SourceSystem, StatusValue
from app.modules.business.models import LegacySyncRun, Project from app.modules.business.models import LegacySyncRun, Project
from app.modules.business.service import serialize_model 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 = { FORBIDDEN_SQL_TOKENS = {
"insert", "insert",
@@ -53,7 +70,7 @@ def _row_to_dict(row: RowMapping) -> dict[str, Any]:
def _normalize_sql(sql: str) -> str: 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: def _query_name_text(query_name: str | LegacyQueryName | None) -> str:
@@ -75,18 +92,24 @@ class LegacyMySQLService:
if legacy_engine is None: if legacy_engine is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="LEGACY_DATABASE_URL is not configured", detail=LegacyQueryError.DATABASE_NOT_CONFIGURED,
) )
return legacy_engine return legacy_engine
@staticmethod @staticmethod
def _ensure_readonly(sql: str) -> None: def _ensure_readonly(sql: str) -> None:
stripped = sql.strip().lower() stripped = sql.strip().lower()
if not stripped.startswith("select"): if not stripped.startswith(LEGACY_SELECT_PREFIX):
raise HTTPException(status_code=400, detail="Only SELECT statements are allowed") 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()} tokens = {token.strip(" ;,\n\t") for token in stripped.replace("(", " ").split()}
if tokens & FORBIDDEN_SQL_TOKENS: 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 @staticmethod
def _allowed_queries() -> dict[str, str]: def _allowed_queries() -> dict[str, str]:
@@ -103,35 +126,13 @@ class LegacyMySQLService:
engine = self._ensure_engine() engine = self._ensure_engine()
try: try:
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(text("SELECT 1")) conn.execute(text(LEGACY_HEALTH_SQL))
except SQLAlchemyError as exc: except SQLAlchemyError as exc:
raise HTTPException(status_code=503, detail=f"MySQL connection failed: {exc}") from exc raise HTTPException(
return {"status": "ok"} status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=LEGACY_MYSQL_CONNECTION_FAILED_TEMPLATE.format(error=exc),
def list_tables(self) -> list[str]: ) from exc
engine = self._ensure_engine() return {LegacyResponseKey.STATUS: ApiStatus.OK}
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
def execute_readonly( def execute_readonly(
self, self,
@@ -176,29 +177,39 @@ class LegacyMySQLService:
engine = self._ensure_engine() engine = self._ensure_engine()
params = dict(params or {}) params = dict(params or {})
try: try:
params["limit"] = bounded_limit(params.get("limit", limit)) params[LegacyResponseKey.LIMIT] = bounded_limit(
params.get(LegacyResponseKey.LIMIT, limit)
)
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid readonly query limit", detail=LegacyQueryError.INVALID_LIMIT,
) from exc ) from exc
limited_sql = sql limited_sql = sql
if " limit " not in sql.lower(): if LEGACY_LIMIT_MARKER not in sql.lower():
limited_sql = f"{sql.rstrip(';')} LIMIT :limit" limited_sql = f"{sql.rstrip(LEGACY_SQL_TRAILING_TERMINATOR)}{LEGACY_LIMIT_CLAUSE}"
with engine.connect() as conn: with engine.connect() as conn:
result = conn.execute(text(limited_sql), params) result = conn.execute(text(limited_sql), params)
rows = [_row_to_dict(row) for row in result.mappings().all()] rows = [_row_to_dict(row) for row in result.mappings().all()]
columns = list(rows[0].keys()) if rows else [] 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]: def fetch_default_projects(self, limit: int = 100) -> dict[str, Any]:
settings = get_settings() settings = get_settings()
if not settings.legacy_project_query: if not settings.legacy_project_query:
raise HTTPException( raise HTTPException(
status_code=400, status_code=status.HTTP_400_BAD_REQUEST,
detail=LegacyQueryError.PROJECT_QUERY_NOT_CONFIGURED, 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 @staticmethod
def _value( def _value(
@@ -214,38 +225,51 @@ class LegacyMySQLService:
def _project_payload(self, row: dict[str, Any], field_map: dict[str, str]) -> dict[str, Any]: def _project_payload(self, row: dict[str, Any], field_map: dict[str, str]) -> dict[str, Any]:
settings = get_settings() settings = get_settings()
external_id = self._value(row, field_map, "external_id", row.get("id")) external_id = self._value(
raw_code = self._value(row, field_map, "code", None) row,
field_map,
LegacyProjectField.EXTERNAL_ID,
row.get(LegacyProjectField.ID),
)
raw_code = self._value(row, field_map, LegacyProjectField.CODE, None)
code = None code = None
if raw_code: if raw_code:
code = str(raw_code) code = str(raw_code)
elif external_id is not None: 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 { return {
"code": code, LegacyProjectField.CODE: code,
"external_id": str(external_id) if external_id is not None else code, LegacyProjectField.EXTERNAL_ID: str(external_id) if external_id is not None else code,
"source_system": SourceSystem.LEGACY_MYSQL, LegacyProjectField.SOURCE_SYSTEM: SourceSystem.LEGACY_MYSQL,
"name": self._value(row, field_map, "name", "未命名项目"), LegacyProjectField.NAME: self._value(
"owner": self._value(row, field_map, "owner", None), row,
"status": self._value(row, field_map, "status", StatusValue.UNKNOWN), field_map,
"progress_percent": int( 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( self._value(
row, row,
field_map, field_map,
"progress_percent", LegacyProjectField.PROGRESS_PERCENT,
row.get("progress") or 0, row.get(LegacyProjectField.PROGRESS) or 0,
) )
or 0 or 0
), ),
"start_date": self._value(row, field_map, "start_date", None), LegacyProjectField.START_DATE: self._value(row, field_map, LegacyProjectField.START_DATE, None),
"due_date": self._value(row, field_map, "due_date", None), LegacyProjectField.DUE_DATE: self._value(row, field_map, LegacyProjectField.DUE_DATE, None),
"budget_amount": ( LegacyProjectField.BUDGET_AMOUNT: (
self._value(row, field_map, "budget_amount", row.get("budget") or 0) or 0 self._value(row, field_map, LegacyProjectField.BUDGET_AMOUNT, row.get(LegacyProjectField.BUDGET) or 0) or 0
), ),
"actual_amount": ( LegacyProjectField.ACTUAL_AMOUNT: (
self._value(row, field_map, "actual_amount", row.get("actual_cost") or 0) or 0 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( def sync_projects(
@@ -259,16 +283,24 @@ class LegacyMySQLService:
) -> dict[str, Any]: ) -> dict[str, Any]:
if self.db is None: if self.db is None:
raise HTTPException( raise HTTPException(
status_code=503, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Application database session is not available", detail=LegacyQueryError.APP_DB_UNAVAILABLE,
) )
query_name = source_query_name or LegacyQueryName.PROJECTS query_name = source_query_name or LegacyQueryName.PROJECTS
if source_query: if source_query:
rows = self.execute_readonly(source_query, {"limit": limit}, limit=limit)["rows"] rows = self.execute_readonly(
query_ref = "allowlisted_inline_sql" source_query,
{LegacyResponseKey.LIMIT: limit},
limit=limit,
)[LegacyResponseKey.ROWS]
query_ref = LegacySyncAction.ALLOWLISTED_INLINE_SQL
else: 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) query_ref = _query_name_text(query_name)
field_map = field_map or {} field_map = field_map or {}
created = 0 created = 0
@@ -278,30 +310,30 @@ class LegacyMySQLService:
for row in rows: for row in rows:
payload = self._project_payload(row, field_map) 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 skipped += 1
items.append( items.append(
{ {
"action": "skipped", LegacyResponseKey.ACTION: LegacySyncAction.SKIPPED,
"reason": "missing external_id/code", LegacyResponseKey.REASON: LEGACY_SYNC_MISSING_ID_REASON,
"source": row, LegacyResponseKey.SOURCE: row,
} }
) )
continue continue
stmt = select(Project).where( stmt = select(Project).where(
Project.source_system == SourceSystem.LEGACY_MYSQL, 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() record = self.db.execute(stmt).scalar_one_or_none()
if record is None: if record is None:
record = self.db.execute( record = self.db.execute(
select(Project).where(Project.code == payload["code"]) select(Project).where(Project.code == payload[LegacyProjectField.CODE])
).scalar_one_or_none() ).scalar_one_or_none()
if record is None: if record is None:
created += 1 created += 1
action = "create" action = LegacySyncAction.CREATE
result = payload result = payload
if not dry_run: if not dry_run:
record = Project(**payload) record = Project(**payload)
@@ -310,7 +342,7 @@ class LegacyMySQLService:
result = serialize_model(record) result = serialize_model(record)
else: else:
updated += 1 updated += 1
action = "update" action = LegacySyncAction.UPDATE
if not dry_run: if not dry_run:
for key, value in payload.items(): for key, value in payload.items():
setattr(record, key, value) setattr(record, key, value)
@@ -318,33 +350,39 @@ class LegacyMySQLService:
result = serialize_model(record) result = serialize_model(record)
else: else:
result = payload 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: if not dry_run:
self.db.commit() self.db.commit()
result = { result = {
"dry_run": dry_run, LegacyResponseKey.DRY_RUN: dry_run,
"created": created, LegacyResponseKey.CREATED: created,
"updated": updated, LegacyResponseKey.UPDATED: updated,
"skipped": skipped, LegacyResponseKey.SKIPPED: skipped,
"items": items, LegacyResponseKey.ITEMS: items,
} }
sync_run = LegacySyncRun( 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, domain=BusinessDomain.PROJECTS,
source_table="LEGACY_PROJECT_QUERY", source_table=LEGACY_PROJECT_QUERY_SOURCE,
status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS, status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS,
finished_at=utc_now(), finished_at=utc_now(),
created_count=created, created_count=created,
updated_count=updated, updated_count=updated,
skipped_count=skipped, skipped_count=skipped,
note="Project sync from readonly legacy MySQL", note=LEGACY_PROJECT_SYNC_NOTE,
) )
self.db.add(sync_run) self.db.add(sync_run)
self.db.commit() self.db.commit()
self.db.refresh(sync_run) self.db.refresh(sync_run)
result["sync_run_code"] = sync_run.code result[LegacyResponseKey.SYNC_RUN_CODE] = sync_run.code
AuditService(self.db).log( AuditService(self.db).log(
AuditLogCreate( AuditLogCreate(
@@ -354,13 +392,19 @@ class LegacyMySQLService:
target_type=BusinessDomain.PROJECTS, target_type=BusinessDomain.PROJECTS,
risk_level=AuditRiskLevel.MEDIUM, risk_level=AuditRiskLevel.MEDIUM,
request_payload={ request_payload={
"source_query": query_ref, LegacyResponseKey.SOURCE_QUERY: query_ref,
"field_map": field_map, LegacyResponseKey.FIELD_MAP: field_map,
"limit": limit, LegacyResponseKey.LIMIT: limit,
"dry_run": dry_run, LegacyResponseKey.DRY_RUN: dry_run,
}, },
response_payload={ 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" 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): class LifecycleAttentionKey(StrEnum):
DELAYED_PROJECTS = "delayed_projects" DELAYED_PROJECTS = "delayed_projects"
OVER_BUDGET_PROJECTS = "over_budget_projects" OVER_BUDGET_PROJECTS = "over_budget_projects"
@@ -109,6 +126,18 @@ class MetricKey(StrEnum):
LEVEL = "level" 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): class HealthLevel(StrEnum):
HEALTHY = "healthy" HEALTHY = "healthy"
ATTENTION = "attention" ATTENTION = "attention"
@@ -133,3 +162,25 @@ class ReportText(StrEnum):
RECOMMEND_STABLE = ( 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.business.service import serialize_model
from app.modules.feishu.service import FeishuService from app.modules.feishu.service import FeishuService
from app.modules.reports.constants import ( 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, HealthLevel,
LifecycleAttentionKey, LifecycleAttentionKey,
LifecycleFilterKey, LifecycleFilterKey,
LifecycleResponseKey, LifecycleResponseKey,
LifecycleSection, LifecycleSection,
MetricKey, MetricKey,
ReportResponseKey,
ReportStatus, ReportStatus,
ReportText, ReportText,
ReportTitle, ReportTitle,
ReportType, ReportType,
WorkReportMetricKey,
) )
from app.modules.risk.constants import RiskSummaryKey, risk_level_for_score
from app.modules.risk.service import RiskService from app.modules.risk.service import RiskService
@@ -164,18 +173,22 @@ class ReportService:
f"- 待处理费用:{expense_pending}", f"- 待处理费用:{expense_pending}",
f"- 当前账户总余额:{_money(fund_total)}", f"- 当前账户总余额:{_money(fund_total)}",
( (
f"- 今日打卡记录:{attendance['total']}" f"- 今日打卡记录:{attendance[ReportResponseKey.TOTAL]}"
f"异常:{attendance['abnormal_total']}" f"异常:{attendance[ReportResponseKey.ABNORMAL_TOTAL]}"
), ),
f"- 逾期任务:{len(risk_summary['overdue_tasks'])}", f"- 逾期任务:{len(risk_summary[RiskSummaryKey.OVERDUE_TASKS])}",
f"- 延期项目:{len(risk_summary['delayed_projects'])}", f"- 延期项目:{len(risk_summary[RiskSummaryKey.DELAYED_PROJECTS])}",
f"- 超预算项目:{len(risk_summary['over_budget_projects'])}", f"- 超预算项目:{len(risk_summary[RiskSummaryKey.OVER_BUDGET_PROJECTS])}",
f"- 资金风险账户:{len(risk_summary['fund_risks'])}", f"- 资金风险账户:{len(risk_summary[RiskSummaryKey.FUND_RISKS])}",
f"- 供应商风险:{len(risk_summary['supplier_risks'])}", f"- 供应商风险:{len(risk_summary[RiskSummaryKey.SUPPLIER_RISKS])}",
f"- 打开风险事件:{len(risk_summary['open_events'])}", f"- 打开风险事件:{len(risk_summary[RiskSummaryKey.OPEN_EVENTS])}",
f"- 综合风险等级:{risk_summary['risk_level']}", 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: def project_weekly(self) -> dict:
active = self._count( active = self._count(
@@ -199,7 +212,11 @@ class ReportService:
) )
for item in over_budget[:10]: for item in over_budget[:10]:
lines.append(f" - 超预算:{item.get('code')} {item.get('name')}") 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( def project_lifecycle_report(
self, self,
@@ -559,20 +576,14 @@ class ReportService:
*risk_conditions, *risk_conditions,
) )
risk_score = ( risk_score = (
overdue_tasks * 1 overdue_tasks * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.OVERDUE_TASKS]
+ delayed_projects * 3 + delayed_projects * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.DELAYED_PROJECTS]
+ over_budget_projects * 4 + over_budget_projects * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.OVER_BUDGET_PROJECTS]
+ external_open_events * 2 + external_open_events * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.EXTERNAL_OPEN_EVENTS]
+ external_high_events * 3 + 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 { return {
MetricKey.RISK_LEVEL: level, MetricKey.RISK_LEVEL: risk_level_for_score(risk_score),
MetricKey.RISK_SCORE: risk_score, MetricKey.RISK_SCORE: risk_score,
MetricKey.OVERDUE_TASKS: overdue_tasks, MetricKey.OVERDUE_TASKS: overdue_tasks,
MetricKey.DELAYED_PROJECTS: delayed_projects, MetricKey.DELAYED_PROJECTS: delayed_projects,
@@ -602,19 +613,32 @@ class ReportService:
include_global_risk: bool, include_global_risk: bool,
) -> dict[str, Any]: ) -> dict[str, Any]:
penalty = ( penalty = (
risks[MetricKey.OVERDUE_TASKS] * 3 risks[MetricKey.OVERDUE_TASKS] * HEALTH_PENALTY_WEIGHTS[MetricKey.OVERDUE_TASKS]
+ risks[MetricKey.DELAYED_PROJECTS] * 8 + risks[MetricKey.DELAYED_PROJECTS]
+ risks[MetricKey.OVER_BUDGET_PROJECTS] * 10 * HEALTH_PENALTY_WEIGHTS[MetricKey.DELAYED_PROJECTS]
+ risks[MetricKey.EXTERNAL_HIGH_EVENTS] * 8 + risks[MetricKey.OVER_BUDGET_PROJECTS]
+ max(0, projects[MetricKey.BUDGET_USAGE_RATE] - 100) * 0.4 * HEALTH_PENALTY_WEIGHTS[MetricKey.OVER_BUDGET_PROJECTS]
+ (100 - tasks[MetricKey.COMPLETION_RATE]) * 0.1 + 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: if include_global_risk:
penalty += suppliers[MetricKey.BLACKLISTED] * 10 penalty += suppliers[MetricKey.BLACKLISTED] * HEALTH_PENALTY_WEIGHTS[
score = max(0, min(100, round(100 - penalty, 2))) MetricKey.BLACKLISTED
if score >= 80: ]
score = max(
HEALTH_SCORE_MIN,
min(HEALTH_SCORE_MAX, round(HEALTH_SCORE_MAX - penalty, 2)),
)
if score >= HEALTHY_SCORE_THRESHOLD:
level = HealthLevel.HEALTHY level = HealthLevel.HEALTHY
elif score >= 60: elif score >= ATTENTION_SCORE_THRESHOLD:
level = HealthLevel.ATTENTION level = HealthLevel.ATTENTION
else: else:
level = HealthLevel.CRITICAL level = HealthLevel.CRITICAL
@@ -801,13 +825,13 @@ class ReportService:
for status, count in sorted(status_counts.items()): for status, count in sorted(status_counts.items()):
lines.append(f"- {status}{count}") lines.append(f"- {status}{count}")
return { return {
"title": "打卡汇总", ReportResponseKey.TITLE: ReportTitle.ATTENDANCE_SUMMARY,
"work_date": target_date.isoformat(), ReportResponseKey.WORK_DATE: target_date.isoformat(),
"total": total, ReportResponseKey.TOTAL: total,
"abnormal_total": abnormal_total, ReportResponseKey.ABNORMAL_TOTAL: abnormal_total,
"status_counts": status_counts, ReportResponseKey.STATUS_COUNTS: status_counts,
"lines": lines, ReportResponseKey.LINES: lines,
"content": "\n".join(lines), ReportResponseKey.CONTENT: "\n".join(lines),
} }
def generate_work_report( def generate_work_report(
@@ -833,14 +857,14 @@ class ReportService:
) )
lines = self._work_report_lines(title, start, end, metrics, risk_summary) lines = self._work_report_lines(title, start, end, metrics, risk_summary)
report = { report = {
"title": title, ReportResponseKey.TITLE: title,
"report_type": report_type, ReportResponseKey.REPORT_TYPE: report_type,
"period_start": start.isoformat(), ReportResponseKey.PERIOD_START: start.isoformat(),
"period_end": end.isoformat(), ReportResponseKey.PERIOD_END: end.isoformat(),
"lines": lines, ReportResponseKey.LINES: lines,
"content": "\n".join(lines), ReportResponseKey.CONTENT: "\n".join(lines),
"metrics": metrics, ReportResponseKey.METRICS: metrics,
"risk_summary": risk_summary, ReportResponseKey.RISK_SUMMARY: risk_summary,
} }
record_data = None record_data = None
@@ -854,7 +878,7 @@ class ReportService:
project_code=project_code, project_code=project_code,
period_start=start, period_start=start,
period_end=end, period_end=end,
content=report["content"], content=report[ReportResponseKey.CONTENT],
metrics=metrics, metrics=metrics,
risk_summary=risk_summary, 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( def _resolve_period(
self, self,
@@ -932,19 +956,25 @@ class ReportService:
*task_filters, *task_filters,
) )
return { return {
"projects_total": self._count(Project, *project_filters), WorkReportMetricKey.PROJECTS_TOTAL: self._count(Project, *project_filters),
"active_projects": self._count( WorkReportMetricKey.ACTIVE_PROJECTS: self._count(
Project, Project,
Project.status.notin_(PROJECT_CLOSED_STATUSES), Project.status.notin_(PROJECT_CLOSED_STATUSES),
*project_filters, *project_filters,
), ),
"tasks_total": self._count(WorkTask, *task_filters), WorkReportMetricKey.TASKS_TOTAL: self._count(WorkTask, *task_filters),
"tasks_completed": completed_tasks, WorkReportMetricKey.TASKS_COMPLETED: completed_tasks,
"tasks_overdue": overdue_tasks, WorkReportMetricKey.TASKS_OVERDUE: overdue_tasks,
"procurements_pending": self._count(Procurement, *procurement_filters), WorkReportMetricKey.PROCUREMENTS_PENDING: self._count(
"expenses_pending": self._count(Expense, *expense_filters), Procurement,
"attendance_total": self._count(AttendanceRecord, *attendance_filters), *procurement_filters,
"open_risk_events": self._count(RiskEvent, *risk_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( def _work_report_lines(
@@ -958,14 +988,20 @@ class ReportService:
return [ return [
f"- 报告:{title}", f"- 报告:{title}",
f"- 周期:{start.isoformat()}{end.isoformat()}", f"- 周期:{start.isoformat()}{end.isoformat()}",
f"- 项目:总数 {metrics['projects_total']},活跃 {metrics['active_projects']}", (
f"- 任务:总数 {metrics['tasks_total']},完成 {metrics['tasks_completed']}", f"- 项目:总数 {metrics[WorkReportMetricKey.PROJECTS_TOTAL]}"
f"- 逾期任务:{metrics['tasks_overdue']}", f"活跃 {metrics[WorkReportMetricKey.ACTIVE_PROJECTS]}"
f"- 待处理采购:{metrics['procurements_pending']}", ),
f"- 待处理费用:{metrics['expenses_pending']}", (
f"- 打卡记录:{metrics['attendance_total']}", f"- 任务:总数 {metrics[WorkReportMetricKey.TASKS_TOTAL]}"
f"- 打开风险事件:{metrics['open_risk_events']}", f"完成 {metrics[WorkReportMetricKey.TASKS_COMPLETED]}"
f"- 综合风险等级:{risk_summary['risk_level']}", ),
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( def push_report(
@@ -975,5 +1011,8 @@ class ReportService:
receive_id_type: str, receive_id_type: str,
actor: str, actor: str,
) -> dict: ) -> 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) 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.database import get_db
from app.core.security import ApiPrincipal, require_api_key from app.core.security import ApiPrincipal, require_api_key
from app.modules.risk.constants import RiskGenerationResultKey
from app.modules.risk.service import RiskService from app.modules.risk.service import RiskService
router = APIRouter(dependencies=[Depends(require_api_key)]) router = APIRouter(dependencies=[Depends(require_api_key)])
@@ -15,27 +16,27 @@ def risk_summary(db: Session = Depends(get_db)) -> dict:
@router.get("/overdue-tasks") @router.get("/overdue-tasks")
def overdue_tasks(db: Session = Depends(get_db)) -> dict: 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") @router.get("/delayed-projects")
def delayed_projects(db: Session = Depends(get_db)) -> dict: 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") @router.get("/over-budget-projects")
def over_budget_projects(db: Session = Depends(get_db)) -> dict: 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") @router.get("/funds")
def fund_risks(db: Session = Depends(get_db)) -> dict: 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") @router.get("/suppliers")
def supplier_risks(db: Session = Depends(get_db)) -> dict: 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") @router.get("/events")
@@ -44,7 +45,12 @@ def risk_events(
status: str | None = None, status: str | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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") @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.models import FundAccount, Project, RiskEvent, Supplier, WorkTask
from app.modules.business.service import serialize_model 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: class RiskService:
@@ -97,31 +105,26 @@ class RiskService:
external_open_events = [ external_open_events = [
item item
for item in open_events 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 = ( risk_score = (
len(overdue_tasks) * 1 len(overdue_tasks) * RISK_SCORE_WEIGHTS[RiskSummaryKey.OVERDUE_TASKS]
+ len(delayed_projects) * 3 + len(delayed_projects) * RISK_SCORE_WEIGHTS[RiskSummaryKey.DELAYED_PROJECTS]
+ len(over_budget_projects) * 4 + len(over_budget_projects)
+ len(fund_risks) * 5 * RISK_SCORE_WEIGHTS[RiskSummaryKey.OVER_BUDGET_PROJECTS]
+ len(supplier_risks) * 3 + len(fund_risks) * RISK_SCORE_WEIGHTS[RiskSummaryKey.FUND_RISKS]
+ len(external_open_events) * 2 + 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 { return {
"risk_level": level, RiskSummaryKey.RISK_LEVEL: risk_level_for_score(risk_score),
"risk_score": Decimal(risk_score), RiskSummaryKey.RISK_SCORE: Decimal(risk_score),
"overdue_tasks": overdue_tasks, RiskSummaryKey.OVERDUE_TASKS: overdue_tasks,
"delayed_projects": delayed_projects, RiskSummaryKey.DELAYED_PROJECTS: delayed_projects,
"over_budget_projects": over_budget_projects, RiskSummaryKey.OVER_BUDGET_PROJECTS: over_budget_projects,
"fund_risks": fund_risks, RiskSummaryKey.FUND_RISKS: fund_risks,
"supplier_risks": supplier_risks, RiskSummaryKey.SUPPLIER_RISKS: supplier_risks,
"open_events": open_events, RiskSummaryKey.OPEN_EVENTS: open_events,
} }
def generate_events(self, actor: str = ActorValue.API) -> dict[str, Any]: def generate_events(self, actor: str = ActorValue.API) -> dict[str, Any]:
@@ -135,25 +138,35 @@ class RiskService:
for payload in payloads: for payload in payloads:
record = self.db.execute( record = self.db.execute(
select(RiskEvent).where(RiskEvent.code == payload["code"]) select(RiskEvent).where(RiskEvent.code == payload[RiskEventPayloadKey.CODE])
).scalar_one_or_none() ).scalar_one_or_none()
if record is None: if record is None:
record = RiskEvent(**payload) record = RiskEvent(**payload)
self.db.add(record) self.db.add(record)
self.db.flush() self.db.flush()
created += 1 created += 1
action = "created" action = RiskGenerationAction.CREATED
elif record.status in CLOSED_RISK_STATUSES: elif record.status in CLOSED_RISK_STATUSES:
skipped += 1 skipped += 1
items.append({"action": "skipped", "risk_event": serialize_model(record)}) items.append(
{
RiskGenerationResultKey.ACTION: RiskGenerationAction.SKIPPED,
RiskGenerationResultKey.RISK_EVENT: serialize_model(record),
}
)
continue continue
else: else:
for key, value in payload.items(): for key, value in payload.items():
if key != "code": if key != RiskEventPayloadKey.CODE:
setattr(record, key, value) setattr(record, key, value)
updated += 1 updated += 1
action = "updated" action = RiskGenerationAction.UPDATED
items.append({"action": action, "risk_event": serialize_model(record)}) items.append(
{
RiskGenerationResultKey.ACTION: action,
RiskGenerationResultKey.RISK_EVENT: serialize_model(record),
}
)
self.db.commit() self.db.commit()
AuditService(self.db).log( AuditService(self.db).log(
@@ -164,13 +177,18 @@ class RiskService:
target_type=AuditTargetType.RISK_EVENTS, target_type=AuditTargetType.RISK_EVENTS,
risk_level=AuditRiskLevel.MEDIUM, risk_level=AuditRiskLevel.MEDIUM,
response_payload={ response_payload={
"created": created, RiskGenerationResultKey.CREATED: created,
"updated": updated, RiskGenerationResultKey.UPDATED: updated,
"skipped": skipped, 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]]: def _build_event_payloads(self) -> list[dict[str, Any]]:
payloads: list[dict[str, Any]] = [] payloads: list[dict[str, Any]] = []
@@ -191,22 +209,22 @@ class RiskService:
for task in self.db.execute(stmt).scalars(): for task in self.db.execute(stmt).scalars():
payloads.append( payloads.append(
{ {
"code": f"RISK-TASK-OVERDUE-{task.id}", RiskEventPayloadKey.CODE: f"RISK-TASK-OVERDUE-{task.id}",
"title": f"任务逾期:{task.title}", RiskEventPayloadKey.TITLE: f"任务逾期:{task.title}",
"risk_type": RiskEventType.OVERDUE_TASK, RiskEventPayloadKey.RISK_TYPE: RiskEventType.OVERDUE_TASK,
"risk_level": RiskLevel.MEDIUM, RiskEventPayloadKey.RISK_LEVEL: RiskLevel.MEDIUM,
"status": StatusValue.OPEN, RiskEventPayloadKey.STATUS: StatusValue.OPEN,
"source_domain": BusinessDomain.TASKS, RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.TASKS,
"source_record_id": str(task.id), RiskEventPayloadKey.SOURCE_RECORD_ID: str(task.id),
"project_code": task.project_code, RiskEventPayloadKey.PROJECT_CODE: task.project_code,
"owner": task.owner, RiskEventPayloadKey.OWNER: task.owner,
"due_date": task.due_date, RiskEventPayloadKey.DUE_DATE: task.due_date,
"detected_at": utc_now(), RiskEventPayloadKey.DETECTED_AT: utc_now(),
"description": "任务已超过截止日期且未完成。", RiskEventPayloadKey.DESCRIPTION: "任务已超过截止日期且未完成。",
"mitigation": ( RiskEventPayloadKey.MITIGATION: (
"请负责人更新进度、明确阻塞项并给出新的完成时间。" "请负责人更新进度、明确阻塞项并给出新的完成时间。"
), ),
"evidence": serialize_model(task), RiskEventPayloadKey.EVIDENCE: serialize_model(task),
} }
) )
return payloads return payloads
@@ -222,22 +240,22 @@ class RiskService:
level = RiskLevel.HIGH if project.progress_percent < 80 else RiskLevel.MEDIUM level = RiskLevel.HIGH if project.progress_percent < 80 else RiskLevel.MEDIUM
payloads.append( payloads.append(
{ {
"code": f"RISK-PROJECT-DELAY-{project.id}", RiskEventPayloadKey.CODE: f"RISK-PROJECT-DELAY-{project.id}",
"title": f"项目延期:{project.name}", RiskEventPayloadKey.TITLE: f"项目延期:{project.name}",
"risk_type": RiskEventType.DELAYED_PROJECT, RiskEventPayloadKey.RISK_TYPE: RiskEventType.DELAYED_PROJECT,
"risk_level": level, RiskEventPayloadKey.RISK_LEVEL: level,
"status": StatusValue.OPEN, RiskEventPayloadKey.STATUS: StatusValue.OPEN,
"source_domain": BusinessDomain.PROJECTS, RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.PROJECTS,
"source_record_id": str(project.id), RiskEventPayloadKey.SOURCE_RECORD_ID: str(project.id),
"project_code": project.code, RiskEventPayloadKey.PROJECT_CODE: project.code,
"owner": project.owner, RiskEventPayloadKey.OWNER: project.owner,
"due_date": project.due_date, RiskEventPayloadKey.DUE_DATE: project.due_date,
"detected_at": utc_now(), RiskEventPayloadKey.DETECTED_AT: utc_now(),
"description": "项目已超过计划截止日期且未进入完成状态。", RiskEventPayloadKey.DESCRIPTION: "项目已超过计划截止日期且未进入完成状态。",
"mitigation": ( RiskEventPayloadKey.MITIGATION: (
"请项目负责人提交延期原因、资源需求和纠偏计划。" "请项目负责人提交延期原因、资源需求和纠偏计划。"
), ),
"evidence": serialize_model(project), RiskEventPayloadKey.EVIDENCE: serialize_model(project),
} }
) )
return payloads return payloads
@@ -251,22 +269,22 @@ class RiskService:
for project in self.db.execute(stmt).scalars(): for project in self.db.execute(stmt).scalars():
payloads.append( payloads.append(
{ {
"code": f"RISK-PROJECT-BUDGET-{project.id}", RiskEventPayloadKey.CODE: f"RISK-PROJECT-BUDGET-{project.id}",
"title": f"项目超预算:{project.name}", RiskEventPayloadKey.TITLE: f"项目超预算:{project.name}",
"risk_type": RiskEventType.OVER_BUDGET_PROJECT, RiskEventPayloadKey.RISK_TYPE: RiskEventType.OVER_BUDGET_PROJECT,
"risk_level": RiskLevel.HIGH, RiskEventPayloadKey.RISK_LEVEL: RiskLevel.HIGH,
"status": StatusValue.OPEN, RiskEventPayloadKey.STATUS: StatusValue.OPEN,
"source_domain": BusinessDomain.PROJECTS, RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.PROJECTS,
"source_record_id": str(project.id), RiskEventPayloadKey.SOURCE_RECORD_ID: str(project.id),
"project_code": project.code, RiskEventPayloadKey.PROJECT_CODE: project.code,
"owner": project.owner, RiskEventPayloadKey.OWNER: project.owner,
"due_date": project.due_date, RiskEventPayloadKey.DUE_DATE: project.due_date,
"detected_at": utc_now(), RiskEventPayloadKey.DETECTED_AT: utc_now(),
"description": "项目实际成本已超过预算。", RiskEventPayloadKey.DESCRIPTION: "项目实际成本已超过预算。",
"mitigation": ( RiskEventPayloadKey.MITIGATION: (
"请复核预算科目、冻结非必要采购并补充审批依据。" "请复核预算科目、冻结非必要采购并补充审批依据。"
), ),
"evidence": serialize_model(project), RiskEventPayloadKey.EVIDENCE: serialize_model(project),
} }
) )
return payloads return payloads
@@ -277,21 +295,21 @@ class RiskService:
for account in self.db.execute(stmt).scalars(): for account in self.db.execute(stmt).scalars():
payloads.append( payloads.append(
{ {
"code": f"RISK-FUND-{account.id}", RiskEventPayloadKey.CODE: f"RISK-FUND-{account.id}",
"title": f"资金低于安全线:{account.name}", RiskEventPayloadKey.TITLE: f"资金低于安全线:{account.name}",
"risk_type": RiskEventType.FUND_SAFETY_LINE, RiskEventPayloadKey.RISK_TYPE: RiskEventType.FUND_SAFETY_LINE,
"risk_level": RiskLevel.HIGH, RiskEventPayloadKey.RISK_LEVEL: RiskLevel.HIGH,
"status": StatusValue.OPEN, RiskEventPayloadKey.STATUS: StatusValue.OPEN,
"source_domain": BusinessDomain.FUND_ACCOUNTS, RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.FUND_ACCOUNTS,
"source_record_id": str(account.id), RiskEventPayloadKey.SOURCE_RECORD_ID: str(account.id),
"owner": None, RiskEventPayloadKey.OWNER: None,
"detected_at": utc_now(), RiskEventPayloadKey.DETECTED_AT: utc_now(),
"description": "账户当前余额低于设置的安全线。", RiskEventPayloadKey.DESCRIPTION: "账户当前余额低于设置的安全线。",
"mitigation": ( RiskEventPayloadKey.MITIGATION: (
"请财务确认收付款计划," "请财务确认收付款计划,"
"并优先处理关键项目资金安排。" "并优先处理关键项目资金安排。"
), ),
"evidence": serialize_model(account), RiskEventPayloadKey.EVIDENCE: serialize_model(account),
} }
) )
return payloads return payloads
@@ -310,20 +328,20 @@ class RiskService:
) )
payloads.append( payloads.append(
{ {
"code": f"RISK-SUPPLIER-{supplier.id}", RiskEventPayloadKey.CODE: f"RISK-SUPPLIER-{supplier.id}",
"title": f"供应商风险:{supplier.name}", RiskEventPayloadKey.TITLE: f"供应商风险:{supplier.name}",
"risk_type": RiskEventType.SUPPLIER_RISK, RiskEventPayloadKey.RISK_TYPE: RiskEventType.SUPPLIER_RISK,
"risk_level": level, RiskEventPayloadKey.RISK_LEVEL: level,
"status": StatusValue.OPEN, RiskEventPayloadKey.STATUS: StatusValue.OPEN,
"source_domain": BusinessDomain.SUPPLIERS, RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.SUPPLIERS,
"source_record_id": str(supplier.id), RiskEventPayloadKey.SOURCE_RECORD_ID: str(supplier.id),
"owner": supplier.contact, RiskEventPayloadKey.OWNER: supplier.contact,
"detected_at": utc_now(), RiskEventPayloadKey.DETECTED_AT: utc_now(),
"description": "供应商风险等级或黑名单状态需要关注。", RiskEventPayloadKey.DESCRIPTION: "供应商风险等级或黑名单状态需要关注。",
"mitigation": ( RiskEventPayloadKey.MITIGATION: (
"请采购负责人复核供应商准入、履约和替代方案。" "请采购负责人复核供应商准入、履约和替代方案。"
), ),
"evidence": serialize_model(supplier), RiskEventPayloadKey.EVIDENCE: serialize_model(supplier),
} }
) )
return payloads return payloads

View File

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

View File

@@ -19,14 +19,16 @@ os.environ["SCHEDULER_ENABLED"] = "false"
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.core.constants import HttpHeader
from app.core.database import Base, engine from app.core.database import Base, engine
from app.main import app from app.main import app
from app.modules.business.constants import BusinessField, StatusValue
def request(method: str, url: str, **kwargs): def request(method: str, url: str, **kwargs):
client = TestClient(app) client = TestClient(app)
headers = kwargs.pop("headers", {}) 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 = getattr(client, method)(url, headers=headers, **kwargs)
response.raise_for_status() response.raise_for_status()
return response return response
@@ -44,7 +46,7 @@ try:
"code": "P-VERIFY-001", "code": "P-VERIFY-001",
"name": "Verify Project", "name": "Verify Project",
"owner": "tester", "owner": "tester",
"status": "执行中", BusinessField.STATUS: StatusValue.RUNNING_CN,
"budget_amount": 1000, "budget_amount": 1000,
"actual_amount": 200, "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 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: def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None:
monkeypatch.setenv("API_KEY", "") monkeypatch.setenv("API_KEY", "")
get_settings.cache_clear() get_settings.cache_clear()