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

@@ -5,6 +5,71 @@ class LegacyQueryName(StrEnum):
PROJECTS = "projects"
class LegacyResponseKey(StrEnum):
STATUS = "status"
COLUMNS = "columns"
ROWS = "rows"
ROW_COUNT = "row_count"
DRY_RUN = "dry_run"
CREATED = "created"
UPDATED = "updated"
SKIPPED = "skipped"
ITEMS = "items"
ACTION = "action"
REASON = "reason"
SOURCE = "source"
PROJECT = "project"
SYNC_RUN_CODE = "sync_run_code"
SOURCE_QUERY = "source_query"
FIELD_MAP = "field_map"
LIMIT = "limit"
class LegacyProjectField(StrEnum):
ID = "id"
CODE = "code"
EXTERNAL_ID = "external_id"
SOURCE_SYSTEM = "source_system"
NAME = "name"
OWNER = "owner"
STATUS = "status"
PROGRESS = "progress"
PROGRESS_PERCENT = "progress_percent"
START_DATE = "start_date"
DUE_DATE = "due_date"
BUDGET = "budget"
BUDGET_AMOUNT = "budget_amount"
ACTUAL_COST = "actual_cost"
ACTUAL_AMOUNT = "actual_amount"
DESCRIPTION = "description"
class LegacySyncAction(StrEnum):
CREATE = "create"
UPDATE = "update"
SKIPPED = "skipped"
ALLOWLISTED_INLINE_SQL = "allowlisted_inline_sql"
class LegacyQueryError(StrEnum):
DATABASE_NOT_CONFIGURED = "LEGACY_DATABASE_URL is not configured"
QUERY_NOT_ALLOWED = "Legacy query is not in the configured allowlist"
PROJECT_QUERY_NOT_CONFIGURED = "LEGACY_PROJECT_QUERY is not configured. Configure it first."
ONLY_SELECT_ALLOWED = "Only SELECT statements are allowed"
FORBIDDEN_SQL_TOKEN = "Forbidden SQL token in readonly query"
INVALID_LIMIT = "Invalid readonly query limit"
APP_DB_UNAVAILABLE = "Application database session is not available"
LEGACY_SYNC_RUN_CODE_PREFIX = "SYNC-PROJECTS"
LEGACY_PROJECT_QUERY_SOURCE = "LEGACY_PROJECT_QUERY"
LEGACY_SYNC_MISSING_ID_REASON = "missing external_id/code"
LEGACY_PROJECT_SYNC_NOTE = "Project sync from readonly legacy MySQL"
LEGACY_MYSQL_CONNECTION_FAILED_TEMPLATE = "MySQL connection failed: {error}"
LEGACY_HEALTH_SQL = "SELECT 1"
LEGACY_SELECT_PREFIX = "select"
LEGACY_SQL_TRAILING_TERMINATOR = ";"
LEGACY_LIMIT_MARKER = " limit "
LEGACY_LIMIT_CLAUSE = " LIMIT :limit"
LEGACY_PROJECT_CODE_TEMPLATE = "{prefix}-{external_id}"
LEGACY_UNNAMED_PROJECT = "未命名项目"

View File

@@ -19,16 +19,6 @@ def mysql_health(db: Session = Depends(get_db)) -> dict[str, str]:
return LegacyMySQLService(db).health()
@router.get("/tables")
def list_tables(db: Session = Depends(get_db)) -> dict[str, list[str]]:
return {"tables": LegacyMySQLService(db).list_tables()}
@router.get("/tables/{table_name}")
def describe_table(table_name: str, db: Session = Depends(get_db)) -> dict:
return {"table": table_name, "columns": LegacyMySQLService(db).describe_table(table_name)}
@router.post("/query", response_model=QueryResult)
def readonly_query(payload: ReadonlyQueryRequest, db: Session = Depends(get_db)) -> dict:
service = LegacyMySQLService(db)

View File

@@ -3,13 +3,12 @@ from decimal import Decimal
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import inspect, text
from sqlalchemy import select, text
from sqlalchemy.engine import Engine, RowMapping
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.constants import ActorValue, ApiStatus
from app.core.config import get_settings
from app.core.database import legacy_engine
from app.core.pagination import bounded_limit
@@ -20,7 +19,25 @@ from app.modules.audit.service import AuditService
from app.modules.business.constants import BusinessDomain, SourceSystem, StatusValue
from app.modules.business.models import LegacySyncRun, Project
from app.modules.business.service import serialize_model
from app.modules.legacy_mysql.constants import LegacyQueryError, LegacyQueryName
from app.modules.legacy_mysql.constants import (
LEGACY_PROJECT_QUERY_SOURCE,
LEGACY_PROJECT_SYNC_NOTE,
LEGACY_SYNC_MISSING_ID_REASON,
LEGACY_SYNC_RUN_CODE_PREFIX,
LEGACY_HEALTH_SQL,
LEGACY_LIMIT_CLAUSE,
LEGACY_LIMIT_MARKER,
LEGACY_MYSQL_CONNECTION_FAILED_TEMPLATE,
LEGACY_PROJECT_CODE_TEMPLATE,
LEGACY_SELECT_PREFIX,
LEGACY_SQL_TRAILING_TERMINATOR,
LEGACY_UNNAMED_PROJECT,
LegacyProjectField,
LegacyQueryError,
LegacyQueryName,
LegacyResponseKey,
LegacySyncAction,
)
FORBIDDEN_SQL_TOKENS = {
"insert",
@@ -53,7 +70,7 @@ def _row_to_dict(row: RowMapping) -> dict[str, Any]:
def _normalize_sql(sql: str) -> str:
return " ".join(sql.strip().rstrip(";").split()).lower()
return " ".join(sql.strip().rstrip(LEGACY_SQL_TRAILING_TERMINATOR).split()).lower()
def _query_name_text(query_name: str | LegacyQueryName | None) -> str:
@@ -75,18 +92,24 @@ class LegacyMySQLService:
if legacy_engine is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="LEGACY_DATABASE_URL is not configured",
detail=LegacyQueryError.DATABASE_NOT_CONFIGURED,
)
return legacy_engine
@staticmethod
def _ensure_readonly(sql: str) -> None:
stripped = sql.strip().lower()
if not stripped.startswith("select"):
raise HTTPException(status_code=400, detail="Only SELECT statements are allowed")
if not stripped.startswith(LEGACY_SELECT_PREFIX):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=LegacyQueryError.ONLY_SELECT_ALLOWED,
)
tokens = {token.strip(" ;,\n\t") for token in stripped.replace("(", " ").split()}
if tokens & FORBIDDEN_SQL_TOKENS:
raise HTTPException(status_code=400, detail="Forbidden SQL token in readonly query")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=LegacyQueryError.FORBIDDEN_SQL_TOKEN,
)
@staticmethod
def _allowed_queries() -> dict[str, str]:
@@ -103,35 +126,13 @@ class LegacyMySQLService:
engine = self._ensure_engine()
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
conn.execute(text(LEGACY_HEALTH_SQL))
except SQLAlchemyError as exc:
raise HTTPException(status_code=503, detail=f"MySQL connection failed: {exc}") from exc
return {"status": "ok"}
def list_tables(self) -> list[str]:
engine = self._ensure_engine()
return sorted(inspect(engine).get_table_names())
def describe_table(self, table_name: str) -> list[dict[str, Any]]:
engine = self._ensure_engine()
inspector = inspect(engine)
if table_name not in inspector.get_table_names():
raise HTTPException(status_code=404, detail="Table not found")
columns = []
for column in inspector.get_columns(table_name):
columns.append(
{
"name": column["name"],
"type": str(column["type"]),
"nullable": column.get("nullable", True),
"default": (
str(column.get("default"))
if column.get("default") is not None
else None
),
}
)
return columns
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=LEGACY_MYSQL_CONNECTION_FAILED_TEMPLATE.format(error=exc),
) from exc
return {LegacyResponseKey.STATUS: ApiStatus.OK}
def execute_readonly(
self,
@@ -176,29 +177,39 @@ class LegacyMySQLService:
engine = self._ensure_engine()
params = dict(params or {})
try:
params["limit"] = bounded_limit(params.get("limit", limit))
params[LegacyResponseKey.LIMIT] = bounded_limit(
params.get(LegacyResponseKey.LIMIT, limit)
)
except (TypeError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid readonly query limit",
detail=LegacyQueryError.INVALID_LIMIT,
) from exc
limited_sql = sql
if " limit " not in sql.lower():
limited_sql = f"{sql.rstrip(';')} LIMIT :limit"
if LEGACY_LIMIT_MARKER not in sql.lower():
limited_sql = f"{sql.rstrip(LEGACY_SQL_TRAILING_TERMINATOR)}{LEGACY_LIMIT_CLAUSE}"
with engine.connect() as conn:
result = conn.execute(text(limited_sql), params)
rows = [_row_to_dict(row) for row in result.mappings().all()]
columns = list(rows[0].keys()) if rows else []
return {"columns": columns, "rows": rows, "row_count": len(rows)}
return {
LegacyResponseKey.COLUMNS: columns,
LegacyResponseKey.ROWS: rows,
LegacyResponseKey.ROW_COUNT: len(rows),
}
def fetch_default_projects(self, limit: int = 100) -> dict[str, Any]:
settings = get_settings()
if not settings.legacy_project_query:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail=LegacyQueryError.PROJECT_QUERY_NOT_CONFIGURED,
)
return self.execute_allowed_query(LegacyQueryName.PROJECTS, {"limit": limit}, limit=limit)
return self.execute_allowed_query(
LegacyQueryName.PROJECTS,
{LegacyResponseKey.LIMIT: limit},
limit=limit,
)
@staticmethod
def _value(
@@ -214,38 +225,51 @@ class LegacyMySQLService:
def _project_payload(self, row: dict[str, Any], field_map: dict[str, str]) -> dict[str, Any]:
settings = get_settings()
external_id = self._value(row, field_map, "external_id", row.get("id"))
raw_code = self._value(row, field_map, "code", None)
external_id = self._value(
row,
field_map,
LegacyProjectField.EXTERNAL_ID,
row.get(LegacyProjectField.ID),
)
raw_code = self._value(row, field_map, LegacyProjectField.CODE, None)
code = None
if raw_code:
code = str(raw_code)
elif external_id is not None:
code = f"{settings.legacy_project_code_prefix}-{external_id}"
code = LEGACY_PROJECT_CODE_TEMPLATE.format(
prefix=settings.legacy_project_code_prefix,
external_id=external_id,
)
return {
"code": code,
"external_id": str(external_id) if external_id is not None else code,
"source_system": SourceSystem.LEGACY_MYSQL,
"name": self._value(row, field_map, "name", "未命名项目"),
"owner": self._value(row, field_map, "owner", None),
"status": self._value(row, field_map, "status", StatusValue.UNKNOWN),
"progress_percent": int(
LegacyProjectField.CODE: code,
LegacyProjectField.EXTERNAL_ID: str(external_id) if external_id is not None else code,
LegacyProjectField.SOURCE_SYSTEM: SourceSystem.LEGACY_MYSQL,
LegacyProjectField.NAME: self._value(
row,
field_map,
LegacyProjectField.NAME,
LEGACY_UNNAMED_PROJECT,
),
LegacyProjectField.OWNER: self._value(row, field_map, LegacyProjectField.OWNER, None),
LegacyProjectField.STATUS: self._value(row, field_map, LegacyProjectField.STATUS, StatusValue.UNKNOWN),
LegacyProjectField.PROGRESS_PERCENT: int(
self._value(
row,
field_map,
"progress_percent",
row.get("progress") or 0,
LegacyProjectField.PROGRESS_PERCENT,
row.get(LegacyProjectField.PROGRESS) or 0,
)
or 0
),
"start_date": self._value(row, field_map, "start_date", None),
"due_date": self._value(row, field_map, "due_date", None),
"budget_amount": (
self._value(row, field_map, "budget_amount", row.get("budget") or 0) or 0
LegacyProjectField.START_DATE: self._value(row, field_map, LegacyProjectField.START_DATE, None),
LegacyProjectField.DUE_DATE: self._value(row, field_map, LegacyProjectField.DUE_DATE, None),
LegacyProjectField.BUDGET_AMOUNT: (
self._value(row, field_map, LegacyProjectField.BUDGET_AMOUNT, row.get(LegacyProjectField.BUDGET) or 0) or 0
),
"actual_amount": (
self._value(row, field_map, "actual_amount", row.get("actual_cost") or 0) or 0
LegacyProjectField.ACTUAL_AMOUNT: (
self._value(row, field_map, LegacyProjectField.ACTUAL_AMOUNT, row.get(LegacyProjectField.ACTUAL_COST) or 0) or 0
),
"description": self._value(row, field_map, "description", None),
LegacyProjectField.DESCRIPTION: self._value(row, field_map, LegacyProjectField.DESCRIPTION, None),
}
def sync_projects(
@@ -259,16 +283,24 @@ class LegacyMySQLService:
) -> dict[str, Any]:
if self.db is None:
raise HTTPException(
status_code=503,
detail="Application database session is not available",
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=LegacyQueryError.APP_DB_UNAVAILABLE,
)
query_name = source_query_name or LegacyQueryName.PROJECTS
if source_query:
rows = self.execute_readonly(source_query, {"limit": limit}, limit=limit)["rows"]
query_ref = "allowlisted_inline_sql"
rows = self.execute_readonly(
source_query,
{LegacyResponseKey.LIMIT: limit},
limit=limit,
)[LegacyResponseKey.ROWS]
query_ref = LegacySyncAction.ALLOWLISTED_INLINE_SQL
else:
rows = self.execute_allowed_query(query_name, {"limit": limit}, limit=limit)["rows"]
rows = self.execute_allowed_query(
query_name,
{LegacyResponseKey.LIMIT: limit},
limit=limit,
)[LegacyResponseKey.ROWS]
query_ref = _query_name_text(query_name)
field_map = field_map or {}
created = 0
@@ -278,30 +310,30 @@ class LegacyMySQLService:
for row in rows:
payload = self._project_payload(row, field_map)
if not payload["external_id"] and not payload["code"]:
if not payload[LegacyProjectField.EXTERNAL_ID] and not payload[LegacyProjectField.CODE]:
skipped += 1
items.append(
{
"action": "skipped",
"reason": "missing external_id/code",
"source": row,
LegacyResponseKey.ACTION: LegacySyncAction.SKIPPED,
LegacyResponseKey.REASON: LEGACY_SYNC_MISSING_ID_REASON,
LegacyResponseKey.SOURCE: row,
}
)
continue
stmt = select(Project).where(
Project.source_system == SourceSystem.LEGACY_MYSQL,
Project.external_id == payload["external_id"],
Project.external_id == payload[LegacyProjectField.EXTERNAL_ID],
)
record = self.db.execute(stmt).scalar_one_or_none()
if record is None:
record = self.db.execute(
select(Project).where(Project.code == payload["code"])
select(Project).where(Project.code == payload[LegacyProjectField.CODE])
).scalar_one_or_none()
if record is None:
created += 1
action = "create"
action = LegacySyncAction.CREATE
result = payload
if not dry_run:
record = Project(**payload)
@@ -310,7 +342,7 @@ class LegacyMySQLService:
result = serialize_model(record)
else:
updated += 1
action = "update"
action = LegacySyncAction.UPDATE
if not dry_run:
for key, value in payload.items():
setattr(record, key, value)
@@ -318,33 +350,39 @@ class LegacyMySQLService:
result = serialize_model(record)
else:
result = payload
items.append({"action": action, "project": result, "source": row})
items.append(
{
LegacyResponseKey.ACTION: action,
LegacyResponseKey.PROJECT: result,
LegacyResponseKey.SOURCE: row,
}
)
if not dry_run:
self.db.commit()
result = {
"dry_run": dry_run,
"created": created,
"updated": updated,
"skipped": skipped,
"items": items,
LegacyResponseKey.DRY_RUN: dry_run,
LegacyResponseKey.CREATED: created,
LegacyResponseKey.UPDATED: updated,
LegacyResponseKey.SKIPPED: skipped,
LegacyResponseKey.ITEMS: items,
}
sync_run = LegacySyncRun(
code=f"SYNC-PROJECTS-{utc_now():%Y%m%d%H%M%S%f}",
code=f"{LEGACY_SYNC_RUN_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}",
domain=BusinessDomain.PROJECTS,
source_table="LEGACY_PROJECT_QUERY",
source_table=LEGACY_PROJECT_QUERY_SOURCE,
status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS,
finished_at=utc_now(),
created_count=created,
updated_count=updated,
skipped_count=skipped,
note="Project sync from readonly legacy MySQL",
note=LEGACY_PROJECT_SYNC_NOTE,
)
self.db.add(sync_run)
self.db.commit()
self.db.refresh(sync_run)
result["sync_run_code"] = sync_run.code
result[LegacyResponseKey.SYNC_RUN_CODE] = sync_run.code
AuditService(self.db).log(
AuditLogCreate(
@@ -354,13 +392,19 @@ class LegacyMySQLService:
target_type=BusinessDomain.PROJECTS,
risk_level=AuditRiskLevel.MEDIUM,
request_payload={
"source_query": query_ref,
"field_map": field_map,
"limit": limit,
"dry_run": dry_run,
LegacyResponseKey.SOURCE_QUERY: query_ref,
LegacyResponseKey.FIELD_MAP: field_map,
LegacyResponseKey.LIMIT: limit,
LegacyResponseKey.DRY_RUN: dry_run,
},
response_payload={
key: result[key] for key in ["dry_run", "created", "updated", "skipped"]
key: result[key]
for key in [
LegacyResponseKey.DRY_RUN,
LegacyResponseKey.CREATED,
LegacyResponseKey.UPDATED,
LegacyResponseKey.SKIPPED,
]
},
)
)