"""持久化任务创建和重试,不在 HTTP 事务中读取源库。""" import secrets from contextlib import nullcontext from dataclasses import dataclass from datetime import datetime from sqlalchemy import select, union from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core.config import get_settings from app.core.exceptions import BadRequestError, NotFoundError from app.core.tplus_db import resolve_tplus_settings from app.models import ( TPlusFact, TPlusSourceHead, TPlusSyncBatch, TPlusSyncError, TPlusWorkRun, User, ) from app.repositories import tplus_repository as repository from app.schemas.tplus import TPlusCreateIn, TPlusRetryIn from app.services import audit_service from app.services.audit_service import AuditRecord, RequestMeta from app.services.tplus.errors import MESSAGES @dataclass(frozen=True) class TaskActor: """HTTP 操作者与请求元信息,CLI 调用不传。""" user: User meta: RequestMeta | None = None def new_batch_no() -> str: """生成最长 24 字符的可追溯批次号。""" return f"TP{datetime.now():%Y%m%d%H%M%S}{secrets.token_hex(4)}" def _audit(session, actor, action, data): if actor is None: return nullcontext() return audit_service.audit( session, operator=actor.user, meta=actor.meta, record=AuditRecord("T+数据同步", action, "T+ 数据任务" + action, data), ) def _source_database() -> str: try: return resolve_tplus_settings(get_settings()).t_plus_database except RuntimeError as exc: raise BadRequestError(str(exc)) from None def history_source_database(session: Session) -> str | None: """查询已存资料不要求开启源连接;未配置账套时仅接受唯一历史来源。""" settings = get_settings() if settings.t_plus_database.strip(): return settings.t_plus_database.strip() if settings.t_plus_enabled and settings.t_plus_connection_file: return _source_database() # 不读取私有连接文件,不建源库连接,也不将不同账套的资料混在一起。 statement = union(*( select(model.source_database).where( model.source_database.is_not(None), model.source_database != "" ).distinct() for model in (TPlusSourceHead, TPlusFact, TPlusWorkRun, TPlusSyncBatch) )).limit(2) sources = list(session.scalars(statement)) if len(sources) > 1: raise BadRequestError("已有多个账套,请由管理员明确配置要查看的源账套") return sources[0] if sources else None def _same_intent(batch, mode, entities, parent): if ( batch.sync_mode != mode or sorted(batch.entity_types_json or []) != sorted(entities) or batch.parent_batch_no != parent ): raise BadRequestError("幂等键已用于不同的任务请求") return batch def _insert(session, values): try: with session.begin_nested(): batch = TPlusSyncBatch(**values) session.add(batch) session.flush() return batch, True except IntegrityError: batch = repository.get_request(session, values["request_key"], for_update=True) if batch is None: raise return _same_intent( batch, values["sync_mode"], values["entity_types_json"], values["parent_batch_no"] ), False def _values(payload, database, actor, parent=None): return { "batch_no": new_batch_no(), "request_key": str(payload.request_key), "status": "QUEUED", "sync_mode": "AUDIT" if payload.task_type == "AUDIT" else "FULL", "entity_types_json": sorted(payload.entity_types), "source_database": database, "requested_by": actor.user.id if actor else None, "parent_batch_no": parent, "started_at": None, "source_record_count": 0, "staged_record_count": 0, "failed_record_count": 0, "skipped_record_count": 0, } def create_task(session: Session, payload: TPlusCreateIn, actor: TaskActor | None = None): """原子创建任务;重放已存在的意图不依赖此刻的源连接状态。""" with _audit(session, actor, "创建读取任务", payload.model_dump(mode="json")): existing = repository.get_request(session, str(payload.request_key)) mode = "AUDIT" if payload.task_type == "AUDIT" else "FULL" if existing: batch = _same_intent(existing, mode, payload.entity_types, None) else: batch, _ = _insert(session, _values(payload, _source_database(), actor)) session.commit() return batch def retry_task( session: Session, parent_no: str, payload: TPlusRetryIn, actor: TaskActor | None = None ): """锁父批次后创建一个新任务;不覆盖原批次及异常历史。""" with _audit( session, actor, "重试读取任务", {"parent_batch_no": parent_no, "request_key": str(payload.request_key)}, ): existing = repository.get_request(session, str(payload.request_key)) if existing: if existing.parent_batch_no != parent_no: raise BadRequestError("幂等键已用于不同的重试请求") session.commit() batch = existing else: parent = repository.get_batch(session, parent_no, for_update=True) if parent is None: raise NotFoundError("同步批次不存在") request = _retry_request(parent, payload) active = repository.active_retry(session, parent_no) if active: if active.request_key != str(payload.request_key): raise BadRequestError("该批次已有未完成的重试任务,请刷新列表查看") batch = active else: batch, inserted = _insert( session, _values(request, _source_database(), actor, parent_no) ) if inserted: repository.increment_retry_errors(session, parent_no) session.commit() return batch def _retry_request(parent, payload): if parent.status not in ("FAILED", "PARTIAL"): raise BadRequestError("只能重试失败或部分失败的批次") if parent.sync_mode not in ("FULL", "AUDIT"): raise BadRequestError("该同步方式尚未启用,请重新选择读取任务") if parent.sync_mode != "AUDIT" and parent.entity_types_json is None: raise BadRequestError("历史批次未记录范围,请重新选择读取类型") return TPlusCreateIn( task_type="AUDIT" if parent.sync_mode == "AUDIT" else "READ", entity_types=parent.entity_types_json or [], request_key=payload.request_key, ) def fail_batch(session: Session, batch: TPlusSyncBatch, code: str) -> None: """只保存固定错误摘要;即使原异常含连接密码也不直接落库。""" messages = { "INTERRUPTED": "执行进程中断,请重新读取;已暂存数据会自动去重", "EXECUTION_ERROR": "任务执行失败,请检查只读连接和字段映射后重试", "TYPE_UNRESOLVED": "核验所需单据类型缺失或不唯一,请核对源字典", } message = messages.get(code, "任务未完成,请核对运行配置") message = MESSAGES.get(code, message) batch.status, batch.finished_at, batch.error_message = "FAILED", datetime.now(), message batch.failed_record_count = (batch.failed_record_count or 0) + 1 session.add( TPlusSyncError( batch_no=batch.batch_no, entity_type="system", source_table="system", error_code=code, error_message=message, ) ) session.commit() def recover_running(session: Session) -> None: """仅在持有执行锁时调用;锁证明之前的执行者已经退出。""" for batch in repository.running_batches(session): fail_batch(session, batch, "INTERRUPTED")