refactor(api): 使用常量替代硬编码字符串

- 在health_check接口中使用ApiResponseKey.STATUS和ApiStatus.OK常量
- 替换硬编码的状态返回值为枚举常量

refactor(core): 配置模块错误信息统一使用常量

- 从constants模块导入ConfigErrorDetail并替换CORS_ORIGINS和LEGACY_ALLOWED_QUERIES的验证错误信息
- 配置类中的默认值使用constants中定义的常量

feat(constants): 添加API响应、安全错误和配置错误常量类

- 新增ApiResponseKey用于API状态键名
- 新增ApiStatus用于API状态值
- 新增SecurityErrorDetail用于安全认证错误详情
- 新增ConfigErrorDetail用于配置验证错误详情
- 添加DEFAULT_MODEL_PROVIDER和DEFAULT_OPENCLAW_ACTION_JSON常量

refactor(security): 安全认证模块使用错误常量

- 将硬编码的安全错误信息替换为SecurityErrorDetail常量
- 包括API密钥、审批密钥和审计密钥的相关错误信息

refactor(ai-agent): AI代理适配器改进错误处理

- 将HTTP状态码替换为FastAPI状态常量
- 添加OpenClaw工具和操作的错误常量
- 修复健康检查和工具调用中的状态码比较逻辑
- 添加AIToolAuditKey用于工具审计键名

feat(ai-agent): 扩展AI代理常量定义

- 新增AIToolAuditKey用于工具审计字段
- 添加OpenClaw相关的错误常量如OPENCLAW_CHAT_PROVIDER_REQUIRED等
- 添加UNSUPPORTED_AI_SKILL_TEMPLATE模板字符串

refactor(approvals): 审批模块常量化重构

- 新增ApprovalPayloadKey用于审批载荷字段
- 添加approval_action函数和APPROVAL_ACTION_SEPARATOR分隔符
- 使用常量替换字面量值

feat(audit): 审计模块新增飞书事件动作类型

- 添加FEISHU_WEBHOOK_EVENT和FEISHU_LONG_CONNECTION_EVENT审计动作

refactor(business): 业务模块全面常量化

- 新增BusinessDomain枚举包含所有业务域
- 添加BusinessResponseKey、BusinessPayloadKey等常量类
- 重构DOMAIN_MODELS为frozenset以提高性能
- 添加normalize_domain等辅助函数用于域标准化
- 使用常量替换路由和业务服务中的硬编码字符串
- 添加业务错误常量和字段验证模板

refactor(feishu): 飞书客户端错误处理优化

- 将HTTP状态码替换为FastAPI标准状态常量
- 改进错误处理的一致性

refactor(approvals): 审批服务使用新常量结构

- 使用ApprovalPayloadKey常量重构载荷字段
- 使用approval_action函数统一动作命名格式
- 优化高风险域判断逻辑
```
This commit is contained in:
2026-07-06 15:56:43 +08:00
parent 514b14d390
commit fbd0aaa9e4
34 changed files with 1153 additions and 525 deletions

View File

@@ -35,6 +35,7 @@ class StatusValue(StrEnum):
ABNORMAL = "异常"
OPEN = "open"
RUNNING = "running"
RUNNING_CN = "执行中"
DRY_RUN = "dry_run"
@@ -62,10 +63,46 @@ class VersionValue(StrEnum):
class BusinessDomain(StrEnum):
TASKS = "tasks"
PROJECTS = "projects"
TASKS = "tasks"
PROCUREMENTS = "procurements"
EXPENSES = "expenses"
FUND_ACCOUNTS = "fund-accounts"
POLICIES = "policies"
STANDARDS = "standards"
PERFORMANCE_METRICS = "performance-metrics"
SUPPLIERS = "suppliers"
ATTENDANCE_RECORDS = "attendance-records"
WORK_REPORTS = "work-reports"
RISK_EVENTS = "risk-events"
LEGACY_SYNC_RUNS = "legacy-sync-runs"
class BusinessResponseKey(StrEnum):
DOMAINS = "domains"
DOMAIN = "domain"
TOTAL = "total"
ITEMS = "items"
DATA = "data"
class BusinessPayloadKey(StrEnum):
DATA = "data"
APPROVAL_TICKET_ID = "approval_ticket_id"
class BusinessField(StrEnum):
ID = "id"
STATUS = "status"
class BusinessErrorDetail(StrEnum):
RECORD_NOT_FOUND = "Record not found"
HIGH_RISK_APPROVAL_REQUIRED = "High-risk domain change requires approval_ticket_id"
UNKNOWN_FIELD_TEMPLATE = "Unknown field '{field}'"
INVALID_FIELD_VALUE_TEMPLATE = "Invalid value for field '{field}'"
class RiskEventType(StrEnum):

View File

@@ -1,42 +1,49 @@
from sqlalchemy.orm import DeclarativeMeta
from app.modules.business import models
from app.modules.business.constants import BusinessDomain
DOMAIN_MODELS: dict[str, type[DeclarativeMeta]] = {
"projects": models.Project,
"tasks": models.WorkTask,
"procurements": models.Procurement,
"expenses": models.Expense,
"fund-accounts": models.FundAccount,
"policies": models.Policy,
"standards": models.Standard,
"performance-metrics": models.PerformanceMetric,
"suppliers": models.Supplier,
"attendance-records": models.AttendanceRecord,
"work-reports": models.WorkReport,
"risk-events": models.RiskEvent,
"legacy-sync-runs": models.LegacySyncRun,
DOMAIN_MODELS: dict[BusinessDomain, type[DeclarativeMeta]] = {
BusinessDomain.PROJECTS: models.Project,
BusinessDomain.TASKS: models.WorkTask,
BusinessDomain.PROCUREMENTS: models.Procurement,
BusinessDomain.EXPENSES: models.Expense,
BusinessDomain.FUND_ACCOUNTS: models.FundAccount,
BusinessDomain.POLICIES: models.Policy,
BusinessDomain.STANDARDS: models.Standard,
BusinessDomain.PERFORMANCE_METRICS: models.PerformanceMetric,
BusinessDomain.SUPPLIERS: models.Supplier,
BusinessDomain.ATTENDANCE_RECORDS: models.AttendanceRecord,
BusinessDomain.WORK_REPORTS: models.WorkReport,
BusinessDomain.RISK_EVENTS: models.RiskEvent,
BusinessDomain.LEGACY_SYNC_RUNS: models.LegacySyncRun,
}
LOW_RISK_DOMAINS = {
"projects",
"tasks",
"procurements",
"expenses",
"policies",
"standards",
"suppliers",
"attendance-records",
"work-reports",
"risk-events",
"legacy-sync-runs",
}
HIGH_RISK_DOMAINS = {"fund-accounts", "performance-metrics"}
HIGH_RISK_DOMAINS = frozenset(
{
BusinessDomain.FUND_ACCOUNTS,
BusinessDomain.PERFORMANCE_METRICS,
}
)
LOW_RISK_DOMAINS = frozenset(set(DOMAIN_MODELS) - HIGH_RISK_DOMAINS)
def get_domain_model(domain: str) -> type[DeclarativeMeta]:
if domain not in DOMAIN_MODELS:
supported = ", ".join(sorted(DOMAIN_MODELS))
raise KeyError(f"Unsupported domain '{domain}'. Supported: {supported}")
return DOMAIN_MODELS[domain]
def normalize_domain(domain: str | BusinessDomain) -> BusinessDomain:
try:
return BusinessDomain(domain)
except ValueError as exc:
supported = ", ".join(sorted(item.value for item in DOMAIN_MODELS))
raise KeyError(f"Unsupported domain '{domain}'. Supported: {supported}") from exc
def supported_domain_values() -> list[str]:
return sorted(item.value for item in DOMAIN_MODELS)
def is_high_risk_domain(domain: str | BusinessDomain) -> bool:
return normalize_domain(domain) in HIGH_RISK_DOMAINS
def get_domain_model(domain: str | BusinessDomain) -> type[DeclarativeMeta]:
return DOMAIN_MODELS[normalize_domain(domain)]

View File

@@ -1,9 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import status as http_status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import ApiPrincipal, require_api_key
from app.modules.business.registry import DOMAIN_MODELS
from app.modules.business.constants import BusinessField, BusinessResponseKey
from app.modules.business.registry import supported_domain_values
from app.modules.business.schemas import DomainListRead, DomainRecordCreate, DomainRecordUpdate
from app.modules.business.service import BusinessService
@@ -12,7 +14,7 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("/domains")
def list_domains() -> dict[str, list[str]]:
return {"domains": sorted(DOMAIN_MODELS)}
return {BusinessResponseKey.DOMAINS: supported_domain_values()}
@router.get("/{domain}", response_model=DomainListRead)
@@ -20,22 +22,29 @@ def list_records(
domain: str,
limit: int = Query(default=50, ge=1, le=500),
offset: int = Query(default=0, ge=0),
status: str | None = None,
status_filter: str | None = Query(default=None, alias=BusinessField.STATUS),
db: Session = Depends(get_db),
) -> dict:
try:
total, items = BusinessService(db).list_records(domain, limit, offset, status)
total, items = BusinessService(db).list_records(domain, limit, offset, status_filter)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return {"domain": domain, "total": total, "items": items}
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return {
BusinessResponseKey.DOMAIN: domain,
BusinessResponseKey.TOTAL: total,
BusinessResponseKey.ITEMS: items,
}
@router.get("/{domain}/{record_id}")
def get_record(domain: str, record_id: int, db: Session = Depends(get_db)) -> dict:
try:
return {"domain": domain, "data": BusinessService(db).get_record(domain, record_id)}
return {
BusinessResponseKey.DOMAIN: domain,
BusinessResponseKey.DATA: BusinessService(db).get_record(domain, record_id),
}
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/{domain}")
@@ -53,8 +62,8 @@ def create_record(
payload.approval_ticket_id,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return {"domain": domain, "data": data}
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return {BusinessResponseKey.DOMAIN: domain, BusinessResponseKey.DATA: data}
@router.patch("/{domain}/{record_id}")
@@ -74,5 +83,5 @@ def update_record(
approval_ticket_id=payload.approval_ticket_id,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return {"domain": domain, "data": data}
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return {BusinessResponseKey.DOMAIN: domain, BusinessResponseKey.DATA: data}

View File

@@ -15,8 +15,16 @@ from app.core.pagination import bounded_limit, bounded_offset
from app.modules.audit.constants import AuditRiskLevel, AuditSource
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.approvals.constants import ApprovalActionValue, approval_action
from app.modules.approvals.service import ApprovalService
from app.modules.business.registry import HIGH_RISK_DOMAINS, get_domain_model
from app.modules.business.registry import get_domain_model, is_high_risk_domain
from app.modules.business.constants import (
INVALID_FIELD_VALUE_TEMPLATE,
UNKNOWN_FIELD_TEMPLATE,
BusinessErrorDetail,
BusinessField,
BusinessPayloadKey,
)
def serialize_model(record: Any) -> dict[str, Any]:
@@ -51,21 +59,25 @@ def _coerce_column_value(column: Column, value: Any) -> Any:
def _model_payload(model: Any, data: dict[str, Any]) -> dict[str, Any]:
"""Validate keys and coerce values according to model column types."""
columns = {column.name: column for column in model.__table__.columns if column.name != "id"}
columns = {
column.name: column
for column in model.__table__.columns
if column.name != BusinessField.ID
}
payload: dict[str, Any] = {}
for key, value in data.items():
column = columns.get(key)
if column is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unknown field '{key}'",
detail=UNKNOWN_FIELD_TEMPLATE.format(field=key),
)
try:
payload[key] = _coerce_column_value(column, value)
except (ValueError, TypeError, InvalidOperation) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid value for field '{key}'",
detail=INVALID_FIELD_VALUE_TEMPLATE.format(field=key),
) from exc
return payload
@@ -87,7 +99,7 @@ class BusinessService:
model = get_domain_model(domain)
stmt: Select = select(model)
count_stmt = select(func.count()).select_from(model)
if status_filter and hasattr(model, "status"):
if status_filter and hasattr(model, BusinessField.STATUS):
stmt = stmt.where(model.status == status_filter)
count_stmt = count_stmt.where(model.status == status_filter)
stmt = stmt.order_by(model.id.desc()).limit(bounded_limit(limit)).offset(
@@ -100,7 +112,10 @@ class BusinessService:
model = get_domain_model(domain)
record = self.db.get(model, record_id)
if record is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Record not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=BusinessErrorDetail.RECORD_NOT_FOUND,
)
return serialize_model(record)
def create_record(
@@ -111,16 +126,17 @@ class BusinessService:
approval_ticket_id: str | None = None,
) -> dict[str, Any]:
model = get_domain_model(domain)
high_risk = is_high_risk_domain(domain)
payload = _model_payload(model, data)
record = model(**payload)
self.db.add(record)
if domain in HIGH_RISK_DOMAINS:
if high_risk:
self.db.flush()
self._consume_approval(
approval_ticket_id,
domain,
record.id,
f"create:{domain}",
approval_action(ApprovalActionValue.CREATE, domain),
data,
actor,
)
@@ -131,13 +147,14 @@ class BusinessService:
AuditLogCreate(
actor=actor,
source=AuditSource.API,
action=f"create:{domain}",
action=approval_action(ApprovalActionValue.CREATE, domain),
target_type=domain,
target_id=str(record.id),
risk_level=(
AuditRiskLevel.HIGH if domain in HIGH_RISK_DOMAINS else AuditRiskLevel.LOW
),
request_payload={"data": data, "approval_ticket_id": approval_ticket_id},
risk_level=AuditRiskLevel.HIGH if high_risk else AuditRiskLevel.LOW,
request_payload={
BusinessPayloadKey.DATA: data,
BusinessPayloadKey.APPROVAL_TICKET_ID: approval_ticket_id,
},
response_payload=result,
)
)
@@ -152,19 +169,20 @@ class BusinessService:
approval_ticket_id: str | None = None,
) -> dict[str, Any]:
model = get_domain_model(domain)
high_risk = is_high_risk_domain(domain)
record = self.db.get(model, record_id)
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Record not found",
detail=BusinessErrorDetail.RECORD_NOT_FOUND,
)
payload = _model_payload(model, data)
if domain in HIGH_RISK_DOMAINS:
if high_risk:
self._consume_approval(
approval_ticket_id,
domain,
record_id,
f"update:{domain}",
approval_action(ApprovalActionValue.UPDATE, domain),
data,
actor,
)
@@ -177,13 +195,14 @@ class BusinessService:
AuditLogCreate(
actor=actor,
source=AuditSource.API,
action=f"update:{domain}",
action=approval_action(ApprovalActionValue.UPDATE, domain),
target_type=domain,
target_id=str(record.id),
risk_level=(
AuditRiskLevel.HIGH if domain in HIGH_RISK_DOMAINS else AuditRiskLevel.LOW
),
request_payload={"data": data, "approval_ticket_id": approval_ticket_id},
risk_level=AuditRiskLevel.HIGH if high_risk else AuditRiskLevel.LOW,
request_payload={
BusinessPayloadKey.DATA: data,
BusinessPayloadKey.APPROVAL_TICKET_ID: approval_ticket_id,
},
response_payload=result,
)
)
@@ -201,7 +220,7 @@ class BusinessService:
if not approval_ticket_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="High-risk domain change requires approval_ticket_id",
detail=BusinessErrorDetail.HIGH_RISK_APPROVAL_REQUIRED,
)
ApprovalService(self.db).consume_for(
approval_ticket_id,