Files
company-ai-platform/app/modules/legacy_mysql/service.py
JiuContinent 92f490b97e ```
feat(core): 添加多API密钥支持和配置字段

添加了api_keys、audit_api_keys、approval_api_keys等字段用于支持多个服务密钥,
新增masked_response_fields用于配置响应掩码字段,以及legacy相关配置项。

feat(core): 增强响应数据掩码功能

扩展mask_configured函数支持域名参数,实现更精确的敏感字段掩码控制,
添加自定义掩码字段配置验证器。

feat(scheduler): 添加遗留系统同步调度任务

集成遗留项目和任务同步到定时调度器中,支持通过配置启用或禁用同步功能,
并可设置不同的执行时间计划。

feat(security): 实现多服务密钥认证机制

重构API密钥验证逻辑,支持单个主密钥和多个配置密钥的混合验证模式,
增加服务密钥启用状态检查和角色映射功能。

feat(task_queue): 扩展现有队列任务处理

为日常简报和周报推送任务添加Celery异步处理支持,新增遗留项目和任务同步任务,
统一任务分发接口。

feat(business): 扩展业务模型字段

为工作任务模型添加外部系统标识和外部ID字段,为风险事件模型增加分配、解决、关闭
等相关字段,并创建风险事件操作记录表。

feat(legacy_mysql): 实现遗留任务同步功能

添加遗留任务查询和同步路由,支持从旧MySQL数据库同步任务数据到内部系统,
包括同步结果统计和运行记录。

refactor(dashboard): 更新仪表板统计数据

增加未分配风险和失败推送运行统计,在概览中显示最新的推送和同步运行记录,
完善数据序列化展示。

fix(feishu): 修复审批事件重复处理

实现审批卡片操作事件的唯一性检查,防止重复审批操作,添加事件审计日志记录。
```
2026-07-08 12:05:09 +08:00

633 lines
23 KiB
Python

from datetime import date, datetime
from decimal import Decimal
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select, text
from sqlalchemy.engine import Engine, RowMapping
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
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
from app.core.time import utc_now
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.business.constants import BusinessDomain, SourceSystem, StatusValue
from app.modules.business.models import LegacySyncRun, Project, WorkTask
from app.modules.business.service import serialize_model
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_TASK_CODE_TEMPLATE,
LEGACY_TASK_QUERY_SOURCE,
LEGACY_TASK_SYNC_NOTE,
LEGACY_TASK_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_TASK,
LEGACY_UNNAMED_PROJECT,
LegacyProjectField,
LegacyQueryError,
LegacyQueryName,
LegacyResponseKey,
LegacySyncAction,
LegacyTaskField,
)
FORBIDDEN_SQL_TOKENS = {
"insert",
"update",
"delete",
"drop",
"alter",
"truncate",
"create",
"replace",
"grant",
"revoke",
}
def _jsonable(value: Any) -> Any:
"""Convert database scalar values into JSON-friendly values."""
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
return value
def _row_to_dict(row: RowMapping) -> dict[str, Any]:
"""Convert a SQLAlchemy row mapping to a serializable dictionary."""
return {key: _jsonable(value) for key, value in row.items()}
def _normalize_sql(sql: str) -> str:
return " ".join(sql.strip().rstrip(LEGACY_SQL_TRAILING_TERMINATOR).split()).lower()
def _query_name_text(query_name: str | LegacyQueryName | None) -> str:
if query_name is None:
return LegacyQueryName.PROJECTS.value
if isinstance(query_name, LegacyQueryName):
return query_name.value
return str(query_name)
class LegacyMySQLService:
"""Read legacy MySQL data and sync projects into the internal ledger."""
def __init__(self, db: Session | None):
self.db = db
@staticmethod
def _ensure_engine() -> Engine:
if legacy_engine is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=LegacyQueryError.DATABASE_NOT_CONFIGURED,
)
return legacy_engine
@staticmethod
def _ensure_readonly(sql: str) -> None:
stripped = sql.strip().lower()
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=status.HTTP_400_BAD_REQUEST,
detail=LegacyQueryError.FORBIDDEN_SQL_TOKEN,
)
@staticmethod
def _allowed_queries() -> dict[str, str]:
settings = get_settings()
queries = {
_query_name_text(name): sql
for name, sql in settings.legacy_allowed_queries.items()
}
if settings.legacy_project_query:
queries.setdefault(LegacyQueryName.PROJECTS.value, settings.legacy_project_query)
if settings.legacy_task_query:
queries.setdefault(LegacyQueryName.TASKS.value, settings.legacy_task_query)
return queries
def health(self) -> dict[str, str]:
engine = self._ensure_engine()
try:
with engine.connect() as conn:
conn.execute(text(LEGACY_HEALTH_SQL))
except SQLAlchemyError as exc:
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,
sql: str,
params: dict[str, Any] | None = None,
limit: int = 100,
) -> dict[str, Any]:
"""Execute a readonly SQL statement only when it matches the allowlist."""
normalized_sql = _normalize_sql(sql)
for allowed_sql in self._allowed_queries().values():
if _normalize_sql(allowed_sql) == normalized_sql:
return self._execute_readonly_sql(allowed_sql, params, limit)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=LegacyQueryError.QUERY_NOT_ALLOWED,
)
def execute_allowed_query(
self,
query_name: str | None,
params: dict[str, Any] | None = None,
limit: int = 100,
) -> dict[str, Any]:
queries = self._allowed_queries()
normalized_name = _query_name_text(query_name)
sql = queries.get(normalized_name)
if not sql:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=LegacyQueryError.QUERY_NOT_ALLOWED,
)
return self._execute_readonly_sql(sql, params, limit)
def _execute_readonly_sql(
self,
sql: str,
params: dict[str, Any] | None = None,
limit: int = 100,
) -> dict[str, Any]:
self._ensure_readonly(sql)
engine = self._ensure_engine()
params = dict(params or {})
try:
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=LegacyQueryError.INVALID_LIMIT,
) from exc
limited_sql = sql
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 {
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=status.HTTP_400_BAD_REQUEST,
detail=LegacyQueryError.PROJECT_QUERY_NOT_CONFIGURED,
)
return self.execute_allowed_query(
LegacyQueryName.PROJECTS,
{LegacyResponseKey.LIMIT: limit},
limit=limit,
)
def fetch_default_tasks(self, limit: int = 100) -> dict[str, Any]:
settings = get_settings()
if not settings.legacy_task_query:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=LegacyQueryError.TASK_QUERY_NOT_CONFIGURED,
)
return self.execute_allowed_query(
LegacyQueryName.TASKS,
{LegacyResponseKey.LIMIT: limit},
limit=limit,
)
@staticmethod
def _value(
row: dict[str, Any],
field_map: dict[str, str],
internal_name: str,
fallback: Any = None,
) -> Any:
source_name = field_map.get(internal_name, internal_name)
if source_name in row:
return row[source_name]
return fallback
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,
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 = LEGACY_PROJECT_CODE_TEMPLATE.format(
prefix=settings.legacy_project_code_prefix,
external_id=external_id,
)
return {
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,
LegacyProjectField.PROGRESS_PERCENT,
row.get(LegacyProjectField.PROGRESS) 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
),
LegacyProjectField.ACTUAL_AMOUNT: (
self._value(row, field_map, LegacyProjectField.ACTUAL_AMOUNT, row.get(LegacyProjectField.ACTUAL_COST) or 0) or 0
),
LegacyProjectField.DESCRIPTION: self._value(row, field_map, LegacyProjectField.DESCRIPTION, None),
}
def _task_payload(self, row: dict[str, Any], field_map: dict[str, str]) -> dict[str, Any]:
settings = get_settings()
external_id = self._value(
row,
field_map,
LegacyTaskField.EXTERNAL_ID,
row.get(LegacyTaskField.ID),
)
raw_code = self._value(row, field_map, LegacyTaskField.CODE, None)
code = None
if raw_code:
code = str(raw_code)
elif external_id is not None:
code = LEGACY_TASK_CODE_TEMPLATE.format(
prefix=settings.legacy_task_code_prefix,
external_id=external_id,
)
return {
LegacyTaskField.CODE: code,
LegacyTaskField.EXTERNAL_ID: str(external_id) if external_id is not None else code,
LegacyTaskField.SOURCE_SYSTEM: SourceSystem.LEGACY_MYSQL,
LegacyTaskField.TITLE: self._value(
row,
field_map,
LegacyTaskField.TITLE,
LEGACY_UNNAMED_TASK,
),
LegacyTaskField.PROJECT_CODE: self._value(
row,
field_map,
LegacyTaskField.PROJECT_CODE,
None,
),
LegacyTaskField.OWNER: self._value(row, field_map, LegacyTaskField.OWNER, None),
LegacyTaskField.STATUS: self._value(
row,
field_map,
LegacyTaskField.STATUS,
StatusValue.TODO,
),
LegacyTaskField.PRIORITY: self._value(
row,
field_map,
LegacyTaskField.PRIORITY,
"P2",
),
LegacyTaskField.DUE_DATE: self._value(row, field_map, LegacyTaskField.DUE_DATE, None),
LegacyTaskField.COMPLETED_AT: self._value(
row,
field_map,
LegacyTaskField.COMPLETED_AT,
None,
),
LegacyTaskField.BLOCKER: self._value(row, field_map, LegacyTaskField.BLOCKER, None),
LegacyTaskField.DESCRIPTION: self._value(
row,
field_map,
LegacyTaskField.DESCRIPTION,
None,
),
}
def sync_projects(
self,
source_query: str | None = None,
source_query_name: str | None = None,
field_map: dict[str, str] | None = None,
limit: int = 100,
dry_run: bool = True,
actor: str = ActorValue.API,
) -> dict[str, Any]:
if self.db is None:
raise HTTPException(
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,
{LegacyResponseKey.LIMIT: limit},
limit=limit,
)[LegacyResponseKey.ROWS]
query_ref = LegacySyncAction.ALLOWLISTED_INLINE_SQL
else:
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
updated = 0
skipped = 0
items: list[dict[str, Any]] = []
for row in rows:
payload = self._project_payload(row, field_map)
if not payload[LegacyProjectField.EXTERNAL_ID] and not payload[LegacyProjectField.CODE]:
skipped += 1
items.append(
{
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[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[LegacyProjectField.CODE])
).scalar_one_or_none()
if record is None:
created += 1
action = LegacySyncAction.CREATE
result = payload
if not dry_run:
record = Project(**payload)
self.db.add(record)
self.db.flush()
result = serialize_model(record)
else:
updated += 1
action = LegacySyncAction.UPDATE
if not dry_run:
for key, value in payload.items():
setattr(record, key, value)
self.db.flush()
result = serialize_model(record)
else:
result = payload
items.append(
{
LegacyResponseKey.ACTION: action,
LegacyResponseKey.PROJECT: result,
LegacyResponseKey.SOURCE: row,
}
)
if not dry_run:
self.db.commit()
result = {
LegacyResponseKey.DRY_RUN: dry_run,
LegacyResponseKey.CREATED: created,
LegacyResponseKey.UPDATED: updated,
LegacyResponseKey.SKIPPED: skipped,
LegacyResponseKey.ITEMS: items,
}
sync_run = LegacySyncRun(
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,
status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS,
finished_at=utc_now(),
created_count=created,
updated_count=updated,
skipped_count=skipped,
note=LEGACY_PROJECT_SYNC_NOTE,
)
self.db.add(sync_run)
self.db.commit()
self.db.refresh(sync_run)
result[LegacyResponseKey.SYNC_RUN_CODE] = sync_run.code
AuditService(self.db).log(
AuditLogCreate(
actor=actor,
source=AuditSource.LEGACY_MYSQL,
action=AuditAction.LEGACY_SYNC_PROJECTS,
target_type=BusinessDomain.PROJECTS,
risk_level=AuditRiskLevel.MEDIUM,
request_payload={
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 [
LegacyResponseKey.DRY_RUN,
LegacyResponseKey.CREATED,
LegacyResponseKey.UPDATED,
LegacyResponseKey.SKIPPED,
]
},
)
)
return result
def sync_tasks(
self,
source_query: str | None = None,
source_query_name: str | None = None,
field_map: dict[str, str] | None = None,
limit: int = 100,
dry_run: bool = True,
actor: str = ActorValue.API,
) -> dict[str, Any]:
if self.db is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=LegacyQueryError.APP_DB_UNAVAILABLE,
)
query_name = source_query_name or LegacyQueryName.TASKS
if source_query:
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,
{LegacyResponseKey.LIMIT: limit},
limit=limit,
)[LegacyResponseKey.ROWS]
query_ref = _query_name_text(query_name)
field_map = field_map or {}
created = 0
updated = 0
skipped = 0
items: list[dict[str, Any]] = []
for row in rows:
payload = self._task_payload(row, field_map)
if not payload[LegacyTaskField.EXTERNAL_ID] and not payload[LegacyTaskField.CODE]:
skipped += 1
items.append(
{
LegacyResponseKey.ACTION: LegacySyncAction.SKIPPED,
LegacyResponseKey.REASON: LEGACY_SYNC_MISSING_ID_REASON,
LegacyResponseKey.SOURCE: row,
}
)
continue
stmt = select(WorkTask).where(
WorkTask.source_system == SourceSystem.LEGACY_MYSQL,
WorkTask.external_id == payload[LegacyTaskField.EXTERNAL_ID],
)
record = self.db.execute(stmt).scalar_one_or_none()
if record is None:
record = self.db.execute(
select(WorkTask).where(WorkTask.code == payload[LegacyTaskField.CODE])
).scalar_one_or_none()
if record is None:
created += 1
action = LegacySyncAction.CREATE
result = payload
if not dry_run:
record = WorkTask(**payload)
self.db.add(record)
self.db.flush()
result = serialize_model(record)
else:
updated += 1
action = LegacySyncAction.UPDATE
if not dry_run:
for key, value in payload.items():
setattr(record, key, value)
self.db.flush()
result = serialize_model(record)
else:
result = payload
items.append(
{
LegacyResponseKey.ACTION: action,
LegacyResponseKey.TASK: result,
LegacyResponseKey.SOURCE: row,
}
)
if not dry_run:
self.db.commit()
result = {
LegacyResponseKey.DRY_RUN: dry_run,
LegacyResponseKey.CREATED: created,
LegacyResponseKey.UPDATED: updated,
LegacyResponseKey.SKIPPED: skipped,
LegacyResponseKey.ITEMS: items,
}
sync_run = LegacySyncRun(
code=f"{LEGACY_TASK_SYNC_RUN_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}",
domain=BusinessDomain.TASKS,
source_table=LEGACY_TASK_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=LEGACY_TASK_SYNC_NOTE,
)
self.db.add(sync_run)
self.db.commit()
self.db.refresh(sync_run)
result[LegacyResponseKey.SYNC_RUN_CODE] = sync_run.code
AuditService(self.db).log(
AuditLogCreate(
actor=actor,
source=AuditSource.LEGACY_MYSQL,
action=AuditAction.LEGACY_SYNC_TASKS,
target_type=BusinessDomain.TASKS,
risk_level=AuditRiskLevel.MEDIUM,
request_payload={
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 [
LegacyResponseKey.DRY_RUN,
LegacyResponseKey.CREATED,
LegacyResponseKey.UPDATED,
LegacyResponseKey.SKIPPED,
]
},
)
)
return result