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): 修复审批事件重复处理

实现审批卡片操作事件的唯一性检查,防止重复审批操作,添加事件审计日志记录。
```
This commit is contained in:
2026-07-08 12:05:09 +08:00
parent 4d09d8e2e3
commit 92f490b97e
28 changed files with 1746 additions and 35 deletions

View File

@@ -17,13 +17,17 @@ from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource
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
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,
@@ -31,12 +35,14 @@ from app.modules.legacy_mysql.constants import (
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 = {
@@ -120,6 +126,8 @@ class LegacyMySQLService:
}
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]:
@@ -211,6 +219,19 @@ class LegacyMySQLService:
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],
@@ -272,6 +293,68 @@ class LegacyMySQLService:
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,
@@ -409,3 +492,141 @@ class LegacyMySQLService:
)
)
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