按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
140 lines
6.0 KiB
Python
140 lines
6.0 KiB
Python
"""同步管理只读查询;只公开受控元数据,不公开源数据和原异常。"""
|
|
|
|
from app.core.exceptions import NotFoundError
|
|
from app.core.response import PageResult
|
|
from app.core.tplus_db import resolve_tplus_settings
|
|
from app.repositories import tplus_repository as repository
|
|
from app.schemas.tplus import (
|
|
TPlusBatchDetailOut,
|
|
TPlusBatchOut,
|
|
TPlusErrorOut,
|
|
TPlusOverviewOut,
|
|
)
|
|
from app.services.tplus import progress, task_service
|
|
from app.services.tplus.automation import overview as automation_overview
|
|
from app.services.tplus.errors import MESSAGES
|
|
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
|
|
from app.services.tplus.relationship_audit import public_report
|
|
|
|
ERROR_MESSAGES = {
|
|
"READ_ERROR": "源数据读取失败,请检查只读连接和字段映射",
|
|
"STAGE_ERROR": "原始数据保存失败,请核对字段类型后重试",
|
|
"INTERRUPTED": "执行进程中断,可重新读取,已保存数据会自动去重",
|
|
"EXECUTION_ERROR": "任务执行失败,请检查只读连接和字段映射",
|
|
"TYPE_UNRESOLVED": "关联核验所需的单据类型缺失或不唯一",
|
|
}
|
|
_ENTITY_TYPES = {item.entity_type for item in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS)}
|
|
ERROR_MESSAGES.update(MESSAGES)
|
|
|
|
|
|
def batch_out(batch, active_parents: set[str], report=None) -> TPlusBatchOut:
|
|
"""只使用元数据字段组装输出,历史批次范围保持未知。"""
|
|
entities = batch.entity_types_json
|
|
scope_valid = (
|
|
isinstance(entities, list)
|
|
and bool(entities)
|
|
and all(isinstance(item, str) and item in _ENTITY_TYPES for item in entities)
|
|
)
|
|
return TPlusBatchOut(
|
|
batch_no=batch.batch_no,
|
|
task_type="AUDIT" if batch.sync_mode == "AUDIT" else "READ",
|
|
status=batch.status,
|
|
entity_types=entities if scope_valid else None,
|
|
source_record_count=batch.source_record_count,
|
|
staged_record_count=batch.staged_record_count,
|
|
skipped_record_count=batch.skipped_record_count,
|
|
failed_record_count=batch.failed_record_count,
|
|
parent_batch_no=batch.parent_batch_no,
|
|
create_time=batch.create_time,
|
|
started_at=batch.started_at,
|
|
finished_at=batch.finished_at,
|
|
can_retry=batch.status in ("FAILED", "PARTIAL")
|
|
and (batch.sync_mode == "AUDIT" or (batch.sync_mode == "FULL" and scope_valid))
|
|
and batch.batch_no not in active_parents,
|
|
error_summary="任务有异常,请查看异常明细"
|
|
if batch.status in ("FAILED", "PARTIAL")
|
|
else None,
|
|
trigger_type=getattr(batch, "trigger_type", "MANUAL") or "MANUAL",
|
|
automatic_attempt=getattr(batch, "automatic_attempt", 0) or 0,
|
|
progress=progress.public(batch, report),
|
|
)
|
|
|
|
|
|
def get_batch(session, number: str) -> TPlusBatchDetailOut:
|
|
"""按需加载报告,未找到时统一返回 404。"""
|
|
batch = repository.get_batch(session, number, with_result=True)
|
|
if batch is None:
|
|
raise NotFoundError("同步批次不存在")
|
|
out = batch_out(batch, repository.active_retry_parents(session, [number]))
|
|
return TPlusBatchDetailOut(
|
|
**out.model_dump(), audit_result=public_report(batch.audit_result_json)
|
|
if batch.sync_mode == "AUDIT" else None
|
|
)
|
|
|
|
|
|
def list_batches(session, query):
|
|
"""分页批次并批量查询重试状态。"""
|
|
rows, total = repository.list_batches(session, query)
|
|
active = repository.active_retry_parents(session, [row.batch_no for row in rows])
|
|
reports = repository.progress_reports(session, [row.batch_no for row in rows])
|
|
return PageResult[TPlusBatchOut](
|
|
records=[batch_out(row, active, reports.get(row.batch_no)) for row in rows],
|
|
total=total,
|
|
page_num=query.page_num,
|
|
page_size=query.page_size,
|
|
)
|
|
|
|
|
|
def _safe_id(value) -> str | None:
|
|
if isinstance(value, str) and value.isascii() and value.isdigit() and len(value) <= 10:
|
|
return value if int(value) <= 2147483647 else None
|
|
return None
|
|
|
|
|
|
def list_errors(session, number, query):
|
|
"""错误按编码生成固定说明,避免驱动异常泄漏连接参数。"""
|
|
if repository.get_batch(session, number) is None:
|
|
raise NotFoundError("同步批次不存在")
|
|
rows, total = repository.list_errors(session, number, query)
|
|
records = [
|
|
TPlusErrorOut(
|
|
id=row.id,
|
|
entity_type=row.entity_type if row.entity_type in _ENTITY_TYPES else "system",
|
|
source_id=_safe_id(row.source_id),
|
|
error_code=row.error_code if row.error_code in ERROR_MESSAGES else "UNKNOWN",
|
|
error_message=ERROR_MESSAGES.get(row.error_code, "任务未完成,请核对配置后重试"),
|
|
retry_count=row.retry_count,
|
|
create_time=row.create_time,
|
|
)
|
|
for row in rows
|
|
]
|
|
return PageResult[TPlusErrorOut](
|
|
records=records, total=total, page_num=query.page_num, page_size=query.page_size
|
|
)
|
|
|
|
|
|
def overview(session) -> TPlusOverviewOut:
|
|
"""只校验配置,不在页面查询期间建立 T+ 连接。"""
|
|
settings = task_service.get_settings()
|
|
try:
|
|
resolve_tplus_settings(settings)
|
|
configured, message = True, "只读配置已就绪,实际连接结果以任务执行为准"
|
|
except RuntimeError:
|
|
configured = False
|
|
message = (
|
|
"源读取未开启,仍可查看已有采集记录和资料"
|
|
if not settings.t_plus_enabled
|
|
else "T+ 只读配置未就绪,请联系管理员配置"
|
|
)
|
|
latest = repository.latest_audit(session)
|
|
current = repository.current_batch(session)
|
|
return TPlusOverviewOut(
|
|
is_configured=configured,
|
|
configuration_message=message,
|
|
status_counts=repository.status_counts(session),
|
|
total_staged_records=repository.total_staged_records(session),
|
|
current_batch=batch_out(current, set()) if current else None,
|
|
latest_audit=public_report(latest.audit_result_json) if latest else None,
|
|
automation=automation_overview(session, settings=task_service.get_settings()),
|
|
)
|