"""业务接入开关与幂等排队;HTTP 不执行导入。""" from uuid import NAMESPACE_URL, uuid5 from sqlalchemy import inspect, select from sqlalchemy.exc import IntegrityError from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError from app.models import TPlusBusinessSyncConfig, TPlusWorkRun, User from app.services.authorization_service import require_permission from app.services.tplus.business_sync import START, preview from app.services.tplus.task_service import history_source_database, new_batch_no from app.services.tplus.workbench import WorkbenchError, digest, work_out def configuration(session): source = history_source_database(session) ready = inspect(session.connection()).has_table(TPlusBusinessSyncConfig.__tablename__) row = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source)) if ready else None return {"enabled": bool(row and row.enabled), "start_date": (row.start_date if row else START).isoformat(), "version": row.version if row else 0, "editable": ready and bool(source), "last_error": row.last_error if row else None} def queue(session, actor_id, start_date, request_key): require_permission(session, session.get(User, actor_id), "tplus:import") source = history_source_database(session) if not source: raise BadRequestError("请先完成 T+ 数据采集") key = str(request_key) found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key)) values = {"start_date": start_date.isoformat()} def existing_run(found): if found.kind != "BUSINESS_SYNC" or found.requested_by != actor_id or found.source_database != source or found.input_json != values: raise BadRequestError("请求键已用于不同任务") return work_out(found) if found: return existing_run(found) run = TPlusWorkRun(run_no=new_batch_no(), request_key=key, source_database=source, requested_by=actor_id, kind="BUSINESS_SYNC", status="QUEUED", input_json=values) try: with session.begin_nested(): session.add(run) session.flush() session.commit() except IntegrityError: session.rollback() found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key)) if found is None: raise ConflictError() from None return existing_run(found) return work_out(run) def save(session, payload, actor_id): require_permission(session, session.get(User, actor_id), "tplus:import") source = history_source_database(session) if not source: raise BadRequestError("请先完成 T+ 数据采集") row = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source).with_for_update()) if (row.version if row else 0) != payload.expected_version: raise ConflictError() if row is None: row = TPlusBusinessSyncConfig(source_database=source, updated_by=actor_id) session.add(row) row.enabled, row.start_date = int(payload.enabled), payload.start_date row.version, row.updated_by, row.last_error = payload.expected_version + 1, actor_id, None try: session.commit() except IntegrityError: session.rollback() raise ConflictError() from None if row.enabled: schedule(session) return configuration(session) def schedule(session): """仅成功快照触发;去重周期与配置版本绑定,不改变原采集结果。""" if not inspect(session.connection()).has_table(TPlusBusinessSyncConfig.__tablename__): return None source = history_source_database(session) config = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source)) if config is None or not config.enabled: return None try: require_permission(session, session.get(User, config.updated_by), "tplus:import") except ForbiddenError: config.last_error = "TASK_AUTHORIZATION_REVOKED" session.commit() return None from app.services.tplus.snapshots import source_manifest try: manifest = list(source_manifest(session, source).items()) except WorkbenchError: config.last_error = "SOURCE_REFRESH_REQUIRED" session.commit() return None key = uuid5(NAMESPACE_URL, digest([source, config.version, manifest, "business-sync-v1"])) result = queue(session, config.updated_by, config.start_date, key) if result["status"] != "FAILED" and config.last_error is not None: config.last_error = None session.commit() return result def readonly_preview(session, start_date=START): return preview(session, history_source_database(session), start_date) def record_schedule_failure(session, source): """保留采集成功状态,同时在接入设置中提示排队异常。""" config = session.scalar(select(TPlusBusinessSyncConfig).where( TPlusBusinessSyncConfig.source_database == source, TPlusBusinessSyncConfig.enabled == 1, ).with_for_update()) if config: config.last_error = "BUSINESS_SYNC_SCHEDULE_FAILED" session.commit() def record_authorization_failure(session, source, actor_id): """仅标记仍由该授权人启用的配置,由调用方提交任务失败状态。""" config = session.scalar(select(TPlusBusinessSyncConfig).where( TPlusBusinessSyncConfig.source_database == source, TPlusBusinessSyncConfig.enabled == 1, TPlusBusinessSyncConfig.updated_by == actor_id, ).with_for_update()) if config: config.last_error = "TASK_AUTHORIZATION_REVOKED" def clear_execution_failure(session, source, actor_id): """当前授权人重试成功后清除旧错误,由调用方提交任务结果。""" config = session.scalar(select(TPlusBusinessSyncConfig).where( TPlusBusinessSyncConfig.source_database == source, TPlusBusinessSyncConfig.enabled == 1, TPlusBusinessSyncConfig.updated_by == actor_id, ).with_for_update()) if config: config.last_error = None