```
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:
@@ -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"])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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]"
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
|
||||||
"suppliers",
|
|
||||||
"attendance-records",
|
|
||||||
"work-reports",
|
|
||||||
"risk-events",
|
|
||||||
"legacy-sync-runs",
|
|
||||||
}
|
}
|
||||||
HIGH_RISK_DOMAINS = {"fund-accounts", "performance-metrics"}
|
)
|
||||||
|
LOW_RISK_DOMAINS = frozenset(set(DOMAIN_MODELS) - HIGH_RISK_DOMAINS)
|
||||||
|
|
||||||
|
|
||||||
def get_domain_model(domain: str) -> type[DeclarativeMeta]:
|
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)]
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 = "未命名项目"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
66
app/modules/risk/constants.py
Normal file
66
app/modules/risk/constants.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from app.modules.business.constants import RiskLevel
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSummaryKey(StrEnum):
|
||||||
|
RISK_LEVEL = "risk_level"
|
||||||
|
RISK_SCORE = "risk_score"
|
||||||
|
OVERDUE_TASKS = "overdue_tasks"
|
||||||
|
DELAYED_PROJECTS = "delayed_projects"
|
||||||
|
OVER_BUDGET_PROJECTS = "over_budget_projects"
|
||||||
|
FUND_RISKS = "fund_risks"
|
||||||
|
SUPPLIER_RISKS = "supplier_risks"
|
||||||
|
OPEN_EVENTS = "open_events"
|
||||||
|
|
||||||
|
|
||||||
|
class RiskGenerationResultKey(StrEnum):
|
||||||
|
CREATED = "created"
|
||||||
|
UPDATED = "updated"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
ITEMS = "items"
|
||||||
|
ACTION = "action"
|
||||||
|
RISK_EVENT = "risk_event"
|
||||||
|
|
||||||
|
|
||||||
|
class RiskGenerationAction(StrEnum):
|
||||||
|
CREATED = "created"
|
||||||
|
UPDATED = "updated"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
class RiskEventPayloadKey(StrEnum):
|
||||||
|
CODE = "code"
|
||||||
|
TITLE = "title"
|
||||||
|
RISK_TYPE = "risk_type"
|
||||||
|
RISK_LEVEL = "risk_level"
|
||||||
|
STATUS = "status"
|
||||||
|
SOURCE_DOMAIN = "source_domain"
|
||||||
|
SOURCE_RECORD_ID = "source_record_id"
|
||||||
|
PROJECT_CODE = "project_code"
|
||||||
|
OWNER = "owner"
|
||||||
|
DUE_DATE = "due_date"
|
||||||
|
DETECTED_AT = "detected_at"
|
||||||
|
DESCRIPTION = "description"
|
||||||
|
MITIGATION = "mitigation"
|
||||||
|
EVIDENCE = "evidence"
|
||||||
|
|
||||||
|
|
||||||
|
RISK_SCORE_WEIGHTS = {
|
||||||
|
RiskSummaryKey.OVERDUE_TASKS: 1,
|
||||||
|
RiskSummaryKey.DELAYED_PROJECTS: 3,
|
||||||
|
RiskSummaryKey.OVER_BUDGET_PROJECTS: 4,
|
||||||
|
RiskSummaryKey.FUND_RISKS: 5,
|
||||||
|
RiskSummaryKey.SUPPLIER_RISKS: 3,
|
||||||
|
RiskSummaryKey.OPEN_EVENTS: 2,
|
||||||
|
}
|
||||||
|
RISK_LEVEL_HIGH_THRESHOLD = 15
|
||||||
|
RISK_LEVEL_MEDIUM_THRESHOLD = 5
|
||||||
|
|
||||||
|
|
||||||
|
def risk_level_for_score(score: int) -> RiskLevel:
|
||||||
|
if score >= RISK_LEVEL_HIGH_THRESHOLD:
|
||||||
|
return RiskLevel.HIGH
|
||||||
|
if score >= RISK_LEVEL_MEDIUM_THRESHOLD:
|
||||||
|
return RiskLevel.MEDIUM
|
||||||
|
return RiskLevel.LOW
|
||||||
@@ -3,6 +3,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
11
security_fix_ignore.yml
Normal 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."
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user