```
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:
@@ -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,
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user