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

@@ -14,6 +14,9 @@ class AuditAction(StrEnum):
APPROVAL_APPROVE = "approval.approve"
APPROVAL_REJECT = "approval.reject"
LEGACY_SYNC_PROJECTS = "sync_projects"
LEGACY_SYNC_TASKS = "sync_tasks"
RISK_EVENT_ACTION = "risk_event_action"
REPORT_PUSH = "report_push"
class AuditRiskLevel(StrEnum):

View File

@@ -58,6 +58,8 @@ class WorkTask(Base, TimestampMixin):
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
blocker: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL)
external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
class Procurement(Base, TimestampMixin):
@@ -233,11 +235,32 @@ class RiskEvent(Base, TimestampMixin):
owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
detected_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
assigned_to: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
closed_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
review_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
mitigation: Mapped[str | None] = mapped_column(Text, nullable=True)
evidence: Mapped[dict | None] = mapped_column(JSON, nullable=True)
class RiskEventAction(Base):
__tablename__ = "risk_event_actions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
risk_event_id: Mapped[int] = mapped_column(Integer, index=True)
action: Mapped[str] = mapped_column(String(64), index=True)
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True)
from_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
to_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
assigned_to: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
class LegacySyncRun(Base, TimestampMixin):
__tablename__ = "legacy_sync_runs"
@@ -253,3 +276,21 @@ class LegacySyncRun(Base, TimestampMixin):
skipped_count: Mapped[int] = mapped_column(Integer, default=0)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
class ReportPushRun(Base, TimestampMixin):
__tablename__ = "report_push_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
report_type: Mapped[str] = mapped_column(String(64), index=True)
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
receive_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
receive_id_type: Mapped[str] = mapped_column(String(64), index=True)
status: Mapped[str] = mapped_column(String(32), index=True)
task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True)
provider_response: Mapped[dict | None] = mapped_column(JSON, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
queued_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

View File

@@ -33,7 +33,7 @@ def list_records(
return {
BusinessResponseKey.DOMAIN: domain,
BusinessResponseKey.TOTAL: total,
BusinessResponseKey.ITEMS: mask_configured(items),
BusinessResponseKey.ITEMS: mask_configured(items, domain=domain),
}
@@ -47,7 +47,8 @@ def get_record(
return {
BusinessResponseKey.DOMAIN: domain,
BusinessResponseKey.DATA: mask_configured(
BusinessService(db).get_record(domain, record_id)
BusinessService(db).get_record(domain, record_id),
domain=domain,
),
}
except KeyError as exc:
@@ -72,7 +73,7 @@ def create_record(
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return {
BusinessResponseKey.DOMAIN: domain,
BusinessResponseKey.DATA: mask_configured(data),
BusinessResponseKey.DATA: mask_configured(data, domain=domain),
}
@@ -96,5 +97,5 @@ def update_record(
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return {
BusinessResponseKey.DOMAIN: domain,
BusinessResponseKey.DATA: mask_configured(data),
BusinessResponseKey.DATA: mask_configured(data, domain=domain),
}

View File

@@ -7,8 +7,16 @@ from app.modules.approvals.constants import ApprovalStatus
from app.modules.approvals.models import ApprovalRequest
from app.modules.audit.models import AuditLog
from app.modules.business.constants import DONE_STATUSES, PROJECT_CLOSED_STATUSES, StatusValue
from app.modules.business.models import Project, RiskEvent, WorkReport, WorkTask
from app.modules.business.models import (
LegacySyncRun,
Project,
ReportPushRun,
RiskEvent,
WorkReport,
WorkTask,
)
from app.modules.business.service import serialize_model
from app.modules.reports.constants import ReportPushStatus
from app.modules.risk.service import RiskService
@@ -27,9 +35,21 @@ class DashboardService:
ApprovalRequest.status == ApprovalStatus.PENDING,
)
open_risk_events = self._count(RiskEvent, RiskEvent.status == StatusValue.OPEN)
unassigned_open_risks = self._count(
RiskEvent,
RiskEvent.status == StatusValue.OPEN,
RiskEvent.assigned_to.is_(None),
)
failed_push_runs = self._count(ReportPushRun, ReportPushRun.status == ReportPushStatus.FAILED)
latest_reports = self.db.execute(
select(WorkReport).order_by(WorkReport.id.desc()).limit(5)
).scalars()
latest_push_runs = self.db.execute(
select(ReportPushRun).order_by(ReportPushRun.id.desc()).limit(10)
).scalars()
latest_sync_runs = self.db.execute(
select(LegacySyncRun).order_by(LegacySyncRun.id.desc()).limit(10)
).scalars()
latest_audit_logs = self.db.execute(
select(AuditLog).order_by(AuditLog.id.desc()).limit(10)
).scalars()
@@ -40,6 +60,8 @@ class DashboardService:
"open_tasks": open_tasks,
"pending_approvals": pending_approvals,
"open_risk_events": open_risk_events,
"unassigned_open_risks": unassigned_open_risks,
"failed_push_runs": failed_push_runs,
"risk_level": risk_summary["risk_level"],
"risk_score": float(risk_summary["risk_score"]),
},
@@ -51,6 +73,8 @@ class DashboardService:
"supplier_risks": len(risk_summary["supplier_risks"]),
},
"latest_reports": [serialize_model(item) for item in latest_reports],
"latest_push_runs": [serialize_model(item) for item in latest_push_runs],
"latest_sync_runs": [serialize_model(item) for item in latest_sync_runs],
"latest_audit_logs": [serialize_model(item) for item in latest_audit_logs],
}

View File

@@ -96,12 +96,36 @@ class FeishuEventService:
)
comment = value.get("comment")
actor = _approval_operator(payload)
event_identity = _approval_event_identity(payload, ticket_id, decision, actor)
if not self._register_event(event_identity):
ticket = ApprovalService(self.db).get_by_ticket(ticket_id)
return {
FeishuResponseKey.OK: True,
FeishuResponseKey.HANDLED: True,
FeishuResponseKey.DUPLICATE: True,
FeishuResponseKey.RESULT: {
"ticket_id": ticket.ticket_id,
"status": ticket.status,
"approver": ticket.approver,
},
}
ticket = ApprovalService(self.db).decide(
ticket_id,
actor,
approved=decision == "approve",
comment=str(comment) if comment is not None else None,
)
self.feishu.audit.log(
AuditLogCreate(
actor=actor,
source=AuditSource.FEISHU,
action=AuditAction.FEISHU_WEBHOOK_EVENT,
target_type="approval_card_action",
target_id=ticket_id,
request_payload=payload,
response_payload={"status": ticket.status, "decision": decision},
)
)
return {
FeishuResponseKey.OK: True,
FeishuResponseKey.HANDLED: True,
@@ -184,3 +208,20 @@ def _approval_operator(payload: dict[str, Any]) -> str:
or operator.get(FeishuPayloadKey.USER_ID)
or ActorValue.FEISHU
)
def _approval_event_identity(
payload: dict[str, Any],
ticket_id: str,
decision: str,
actor: str,
) -> dict[str, str | None]:
header = payload.get(FeishuPayloadKey.HEADER) or {}
event_id = header.get(FeishuPayloadKey.EVENT_ID)
stable_id = event_id or f"{ticket_id}:{decision}:{actor}"
return {
FeishuEventReceiptKey.EVENT_KEY: f"{FeishuEventSource.WEBHOOK}:approval:{stable_id}",
FeishuEventReceiptKey.SOURCE: FeishuEventSource.WEBHOOK,
FeishuEventReceiptKey.EVENT_ID: str(event_id) if event_id else None,
FeishuEventReceiptKey.MESSAGE_ID: None,
}

View File

@@ -3,6 +3,7 @@ from enum import StrEnum
class LegacyQueryName(StrEnum):
PROJECTS = "projects"
TASKS = "tasks"
class LegacyResponseKey(StrEnum):
@@ -19,6 +20,7 @@ class LegacyResponseKey(StrEnum):
REASON = "reason"
SOURCE = "source"
PROJECT = "project"
TASK = "task"
SYNC_RUN_CODE = "sync_run_code"
SOURCE_QUERY = "source_query"
FIELD_MAP = "field_map"
@@ -44,6 +46,22 @@ class LegacyProjectField(StrEnum):
DESCRIPTION = "description"
class LegacyTaskField(StrEnum):
ID = "id"
CODE = "code"
EXTERNAL_ID = "external_id"
SOURCE_SYSTEM = "source_system"
TITLE = "title"
PROJECT_CODE = "project_code"
OWNER = "owner"
STATUS = "status"
PRIORITY = "priority"
DUE_DATE = "due_date"
COMPLETED_AT = "completed_at"
BLOCKER = "blocker"
DESCRIPTION = "description"
class LegacySyncAction(StrEnum):
CREATE = "create"
UPDATE = "update"
@@ -55,6 +73,7 @@ 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."
TASK_QUERY_NOT_CONFIGURED = "LEGACY_TASK_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"
@@ -62,9 +81,12 @@ class LegacyQueryError(StrEnum):
LEGACY_SYNC_RUN_CODE_PREFIX = "SYNC-PROJECTS"
LEGACY_TASK_SYNC_RUN_CODE_PREFIX = "SYNC-TASKS"
LEGACY_PROJECT_QUERY_SOURCE = "LEGACY_PROJECT_QUERY"
LEGACY_TASK_QUERY_SOURCE = "LEGACY_TASK_QUERY"
LEGACY_SYNC_MISSING_ID_REASON = "missing external_id/code"
LEGACY_PROJECT_SYNC_NOTE = "Project sync from readonly legacy MySQL"
LEGACY_TASK_SYNC_NOTE = "Task sync from readonly legacy MySQL"
LEGACY_MYSQL_CONNECTION_FAILED_TEMPLATE = "MySQL connection failed: {error}"
LEGACY_HEALTH_SQL = "SELECT 1"
LEGACY_SELECT_PREFIX = "select"
@@ -72,4 +94,6 @@ LEGACY_SQL_TRAILING_TERMINATOR = ";"
LEGACY_LIMIT_MARKER = " limit "
LEGACY_LIMIT_CLAUSE = " LIMIT :limit"
LEGACY_PROJECT_CODE_TEMPLATE = "{prefix}-{external_id}"
LEGACY_TASK_CODE_TEMPLATE = "{prefix}-{external_id}"
LEGACY_UNNAMED_PROJECT = "未命名项目"
LEGACY_UNNAMED_TASK = "未命名任务"

View File

@@ -4,9 +4,12 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.masking import mask_configured
from app.core.security import ApiPrincipal, require_api_key
from app.core.task_queue import enqueue_legacy_project_sync, enqueue_legacy_task_sync
from app.modules.legacy_mysql.schemas import (
LegacyProjectSyncRequest,
LegacyProjectSyncResult,
LegacyTaskSyncRequest,
LegacyTaskSyncResult,
QueryResult,
ReadonlyQueryRequest,
)
@@ -43,6 +46,14 @@ def default_project_query(
return mask_configured(LegacyMySQLService(db).fetch_default_projects(limit=limit))
@router.get("/tasks", response_model=QueryResult)
def default_task_query(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> dict:
return mask_configured(LegacyMySQLService(db).fetch_default_tasks(limit=limit))
@router.post("/projects/sync", response_model=LegacyProjectSyncResult)
def sync_projects(
payload: LegacyProjectSyncRequest,
@@ -58,3 +69,50 @@ def sync_projects(
actor=principal.actor,
)
return mask_configured(result)
@router.post("/projects/sync/enqueue")
def enqueue_sync_projects(
payload: LegacyProjectSyncRequest,
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return enqueue_legacy_project_sync(
source_query=payload.source_query,
source_query_name=payload.source_query_name,
field_map=payload.field_map,
limit=payload.limit,
dry_run=payload.dry_run,
actor=principal.actor,
)
@router.post("/tasks/sync", response_model=LegacyTaskSyncResult)
def sync_tasks(
payload: LegacyTaskSyncRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
result = LegacyMySQLService(db).sync_tasks(
source_query=payload.source_query,
source_query_name=payload.source_query_name,
field_map=payload.field_map,
limit=payload.limit,
dry_run=payload.dry_run,
actor=principal.actor,
)
return mask_configured(result)
@router.post("/tasks/sync/enqueue")
def enqueue_sync_tasks(
payload: LegacyTaskSyncRequest,
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return enqueue_legacy_task_sync(
source_query=payload.source_query,
source_query_name=payload.source_query_name,
field_map=payload.field_map,
limit=payload.limit,
dry_run=payload.dry_run,
actor=principal.actor,
)

View File

@@ -67,3 +67,34 @@ class LegacyProjectSyncResult(BaseModel):
skipped: int
sync_run_code: str | None = None
items: list[dict[str, Any]]
class LegacyTaskSyncRequest(BaseModel):
"""Request body for syncing legacy tasks into the internal ledger."""
source_query: str | None = Field(
default=None,
description="Deprecated: must exactly match a configured readonly query.",
)
source_query_name: str | None = Field(
default=LegacyQueryName.TASKS,
description="Configured readonly query name for task sync.",
)
field_map: dict[str, str] = Field(
default_factory=dict,
description="Map internal task fields to legacy row fields.",
)
limit: int = Field(default=100, ge=1, le=500)
dry_run: bool = True
actor: str = ActorValue.API
class LegacyTaskSyncResult(BaseModel):
"""Summary of a legacy task sync operation."""
dry_run: bool
created: int
updated: int
skipped: int
sync_run_code: str | None = None
items: list[dict[str, Any]]

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

View File

@@ -20,6 +20,20 @@ class ReportStatus(StrEnum):
GENERATED = "已生成"
class ReportPushStatus(StrEnum):
PENDING = "pending"
QUEUED = "queued"
SUCCESS = "success"
FAILED = "failed"
class ReportPushKey(StrEnum):
ITEMS = "items"
CODE = "code"
TASK_ID = "task_id"
STATUS = "status"
class LifecycleSection(StrEnum):
HEALTH = "health"
PROJECTS = "projects"

View File

@@ -58,6 +58,23 @@ def attendance_summary(
return ReportService(db).attendance_summary(work_date)
@router.get("/push-runs")
def list_push_runs(
status: str | None = None,
limit: int = 100,
db: Session = Depends(get_db),
) -> dict:
return {"items": ReportService(db).list_push_runs(status_filter=status, limit=limit)}
@router.get("/push-runs/{code}")
def get_push_run(
code: str,
db: Session = Depends(get_db),
) -> dict:
return ReportService(db).get_push_run(code)
@router.post("/work-reports/generate")
def generate_work_report(
payload: WorkReportGenerateRequest,

View File

@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.pagination import bounded_limit
from app.core.time import utc_now
from app.modules.audit.constants import AuditSource, AuditTargetType
from app.modules.audit.constants import AuditAction, AuditSource, AuditTargetType
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.business.constants import (
@@ -27,6 +27,7 @@ from app.modules.business.models import (
FundAccount,
Procurement,
Project,
ReportPushRun,
RiskEvent,
Supplier,
WorkReport,
@@ -48,6 +49,7 @@ from app.modules.reports.constants import (
LifecycleSection,
MetricKey,
ReportResponseKey,
ReportPushStatus,
ReportStatus,
ReportText,
ReportTitle,
@@ -149,6 +151,85 @@ class ReportService:
stmt = stmt.order_by(model.id.desc())
return list(self.db.execute(stmt.limit(bounded_limit(limit))).scalars())
def create_push_run(
self,
report_type: str,
title: str | None,
receive_id: str | None,
receive_id_type: str,
actor: str,
status: str = ReportPushStatus.PENDING,
) -> ReportPushRun:
record = ReportPushRun(
code=_next_code("PUSH"),
report_type=report_type,
title=title,
receive_id=receive_id,
receive_id_type=receive_id_type,
status=status,
actor=actor,
queued_at=utc_now(),
)
self.db.add(record)
self.db.commit()
self.db.refresh(record)
return record
def update_push_run(
self,
code: str,
status: str,
task_id: str | None = None,
provider_response: dict[str, Any] | None = None,
error_message: str | None = None,
sent: bool = False,
) -> ReportPushRun:
record = self._get_push_run(code)
record.status = status
if task_id is not None:
record.task_id = task_id
if provider_response is not None:
record.provider_response = _json_safe(provider_response)
record.error_message = error_message
if sent:
record.sent_at = utc_now()
self.db.commit()
self.db.refresh(record)
return record
def list_push_runs(
self,
status_filter: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
stmt = select(ReportPushRun).order_by(ReportPushRun.id.desc()).limit(
bounded_limit(limit)
)
if status_filter:
stmt = (
select(ReportPushRun)
.where(ReportPushRun.status == status_filter)
.order_by(ReportPushRun.id.desc())
.limit(bounded_limit(limit))
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def get_push_run(self, code: str) -> dict[str, Any]:
return serialize_model(self._get_push_run(code))
def _get_push_run(self, code: str) -> ReportPushRun:
from fastapi import HTTPException, status
record = self.db.execute(
select(ReportPushRun).where(ReportPushRun.code == code)
).scalar_one_or_none()
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report push run not found",
)
return record
def daily_brief(self) -> dict:
project_count = self._count(Project)
task_count = self._count(WorkTask)
@@ -1010,9 +1091,47 @@ class ReportService:
receive_id: str | None,
receive_id_type: str,
actor: str,
push_run_code: str | None = None,
) -> dict:
report_type = str(report.get(ReportResponseKey.REPORT_TYPE) or report.get("type") or "report")
title = report.get(ReportResponseKey.TITLE)
push_run = (
self._get_push_run(push_run_code)
if push_run_code
else self.create_push_run(
report_type=report_type,
title=title,
receive_id=receive_id,
receive_id_type=receive_id_type,
actor=actor,
)
)
card = FeishuService.build_basic_card(
report[ReportResponseKey.TITLE],
report[ReportResponseKey.LINES],
)
return FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor)
try:
result = FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor)
except Exception as exc:
self.update_push_run(
push_run.code,
ReportPushStatus.FAILED,
error_message=str(exc),
)
raise
self.update_push_run(
push_run.code,
ReportPushStatus.SUCCESS,
provider_response=result,
sent=True,
)
AuditService(self.db).log(
AuditLogCreate(
actor=actor,
source=AuditSource.REPORTS,
action=AuditAction.REPORT_PUSH,
target_id=push_run.code,
response_payload={"status": ReportPushStatus.SUCCESS},
)
)
return result

View File

@@ -46,6 +46,21 @@ class RiskEventPayloadKey(StrEnum):
EVIDENCE = "evidence"
class RiskEventActionValue(StrEnum):
ASSIGN = "assign"
COMMENT = "comment"
RESOLVE = "resolve"
CLOSE = "close"
REOPEN = "reopen"
class RiskEventActionKey(StrEnum):
ACTION = "action"
RISK_EVENT = "risk_event"
ACTION_RECORD = "action_record"
ITEMS = "items"
RISK_SCORE_WEIGHTS = {
RiskSummaryKey.OVERDUE_TASKS: 1,
RiskSummaryKey.DELAYED_PROJECTS: 3,

View File

@@ -4,7 +4,14 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import ApiPrincipal, require_api_key
from app.core.task_queue import enqueue_risk_event_generation
from app.modules.risk.constants import RiskGenerationResultKey
from app.modules.risk.constants import RiskEventActionKey, RiskGenerationResultKey
from app.modules.risk.schemas import (
RiskAssignRequest,
RiskCloseRequest,
RiskCommentRequest,
RiskReopenRequest,
RiskResolveRequest,
)
from app.modules.risk.service import RiskService
router = APIRouter(dependencies=[Depends(require_api_key)])
@@ -66,6 +73,94 @@ def risk_events(
}
@router.get("/events/{event_id}/actions")
def risk_event_actions(
event_id: int,
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> dict:
return {
RiskEventActionKey.ITEMS: RiskService(db).list_actions(
risk_event_id=event_id,
limit=limit,
)
}
@router.post("/events/{event_id}/assign")
def assign_risk_event(
event_id: int,
payload: RiskAssignRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return RiskService(db).assign_event(
event_id,
assigned_to=payload.assigned_to,
comment=payload.comment,
actor=principal.actor,
)
@router.post("/events/{event_id}/comment")
def comment_risk_event(
event_id: int,
payload: RiskCommentRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return RiskService(db).comment_event(
event_id,
comment=payload.comment,
payload=payload.payload,
actor=principal.actor,
)
@router.post("/events/{event_id}/resolve")
def resolve_risk_event(
event_id: int,
payload: RiskResolveRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return RiskService(db).resolve_event(
event_id,
comment=payload.comment,
payload=payload.payload,
actor=principal.actor,
)
@router.post("/events/{event_id}/close")
def close_risk_event(
event_id: int,
payload: RiskCloseRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return RiskService(db).close_event(
event_id,
closed_reason=payload.closed_reason,
review_summary=payload.review_summary,
actor=principal.actor,
)
@router.post("/events/{event_id}/reopen")
def reopen_risk_event(
event_id: int,
payload: RiskReopenRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return RiskService(db).reopen_event(
event_id,
comment=payload.comment,
actor=principal.actor,
)
@router.post("/events/generate")
def generate_risk_events(
db: Session = Depends(get_db),

View File

@@ -0,0 +1,27 @@
from typing import Any
from pydantic import BaseModel, Field
class RiskAssignRequest(BaseModel):
assigned_to: str = Field(..., min_length=1)
comment: str | None = None
class RiskCommentRequest(BaseModel):
comment: str = Field(..., min_length=1)
payload: dict[str, Any] = Field(default_factory=dict)
class RiskResolveRequest(BaseModel):
comment: str | None = None
payload: dict[str, Any] = Field(default_factory=dict)
class RiskCloseRequest(BaseModel):
closed_reason: str = Field(..., min_length=1)
review_summary: str | None = None
class RiskReopenRequest(BaseModel):
comment: str | None = None

View File

@@ -27,10 +27,19 @@ from app.modules.business.constants import (
RiskLevel,
StatusValue,
)
from app.modules.business.models import FundAccount, Project, RiskEvent, Supplier, WorkTask
from app.modules.business.models import (
FundAccount,
Project,
RiskEvent,
RiskEventAction,
Supplier,
WorkTask,
)
from app.modules.business.service import serialize_model
from app.modules.risk.constants import (
RISK_SCORE_WEIGHTS,
RiskEventActionKey,
RiskEventActionValue,
RiskGenerationAction,
RiskGenerationResultKey,
RiskEventPayloadKey,
@@ -95,6 +104,142 @@ class RiskService:
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def list_actions(self, risk_event_id: int, limit: int = 100) -> list[dict[str, Any]]:
self._get_event(risk_event_id)
stmt = (
select(RiskEventAction)
.where(RiskEventAction.risk_event_id == risk_event_id)
.order_by(RiskEventAction.id.desc())
.limit(bounded_limit(limit))
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def assign_event(
self,
risk_event_id: int,
assigned_to: str,
comment: str | None = None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
from_status = record.status
record.assigned_to = assigned_to
action = self._record_action(
record,
RiskEventActionValue.ASSIGN,
actor,
from_status,
record.status,
comment,
{"assigned_to": assigned_to},
)
self.db.commit()
self.db.refresh(record)
self.db.refresh(action)
return self._action_response(record, action)
def comment_event(
self,
risk_event_id: int,
comment: str,
payload: dict[str, Any] | None = None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
action = self._record_action(
record,
RiskEventActionValue.COMMENT,
actor,
record.status,
record.status,
comment,
payload or {},
)
self.db.commit()
self.db.refresh(record)
self.db.refresh(action)
return self._action_response(record, action)
def resolve_event(
self,
risk_event_id: int,
comment: str | None = None,
payload: dict[str, Any] | None = None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
from_status = record.status
record.status = StatusValue.RESOLVED
record.resolved_at = utc_now()
action = self._record_action(
record,
RiskEventActionValue.RESOLVE,
actor,
from_status,
record.status,
comment,
payload or {},
)
self.db.commit()
self.db.refresh(record)
self.db.refresh(action)
return self._action_response(record, action)
def close_event(
self,
risk_event_id: int,
closed_reason: str,
review_summary: str | None = None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
from_status = record.status
now = utc_now()
record.status = StatusValue.CLOSED
record.closed_reason = closed_reason
record.review_summary = review_summary
record.closed_at = now
if record.resolved_at is None:
record.resolved_at = now
action = self._record_action(
record,
RiskEventActionValue.CLOSE,
actor,
from_status,
record.status,
closed_reason,
{"review_summary": review_summary},
)
self.db.commit()
self.db.refresh(record)
self.db.refresh(action)
return self._action_response(record, action)
def reopen_event(
self,
risk_event_id: int,
comment: str | None = None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
from_status = record.status
record.status = StatusValue.OPEN
record.resolved_at = None
record.closed_at = None
action = self._record_action(
record,
RiskEventActionValue.REOPEN,
actor,
from_status,
record.status,
comment,
{},
)
self.db.commit()
self.db.refresh(record)
self.db.refresh(action)
return self._action_response(record, action)
def summary(self) -> dict[str, Any]:
overdue_tasks = self.overdue_tasks()
delayed_projects = self.delayed_projects()
@@ -127,6 +272,62 @@ class RiskService:
RiskSummaryKey.OPEN_EVENTS: open_events,
}
def _get_event(self, risk_event_id: int) -> RiskEvent:
record = self.db.get(RiskEvent, risk_event_id)
if record is None:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Risk event not found")
return record
def _record_action(
self,
record: RiskEvent,
action: str,
actor: str,
from_status: str | None,
to_status: str | None,
comment: str | None,
payload: dict[str, Any],
) -> RiskEventAction:
action_record = RiskEventAction(
code=f"RISK-ACTION-{utc_now():%Y%m%d%H%M%S%f}",
risk_event_id=record.id,
action=action,
actor=actor,
from_status=from_status,
to_status=to_status,
assigned_to=record.assigned_to,
comment=comment,
payload=payload,
)
self.db.add(action_record)
AuditService(self.db).log(
AuditLogCreate(
actor=actor,
source=AuditSource.RISK,
action=AuditAction.RISK_EVENT_ACTION,
target_type=BusinessDomain.RISK_EVENTS,
target_id=str(record.id),
risk_level=AuditRiskLevel.MEDIUM,
request_payload={
RiskEventActionKey.ACTION: action,
"from_status": from_status,
"to_status": to_status,
"comment": comment,
"payload": payload,
},
)
)
return action_record
@staticmethod
def _action_response(record: RiskEvent, action: RiskEventAction) -> dict[str, Any]:
return {
RiskEventActionKey.RISK_EVENT: serialize_model(record),
RiskEventActionKey.ACTION_RECORD: serialize_model(action),
}
def generate_events(self, actor: str = ActorValue.API) -> dict[str, Any]:
"""Generate or refresh risk-event ledger entries from current signals."""