diff --git a/pom.xml b/pom.xml index dac06b3..d410bf9 100644 --- a/pom.xml +++ b/pom.xml @@ -73,6 +73,19 @@ org.springframework.boot spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-amqp + + + org.springframework.retry + spring-retry + org.jasig.cas.client diff --git a/sql/add_fc_projection_version.sql b/sql/add_fc_projection_version.sql new file mode 100644 index 0000000..42bae0a --- /dev/null +++ b/sql/add_fc_projection_version.sql @@ -0,0 +1,7 @@ +-- TASK_2 prerequisite repair: local optimistic generation (NOT FC answer_revision). +-- Existing TASK_2 installations only. Run once, before deploying the new entity. +-- For a fresh schema use fc_projection_and_apply_intent.sql; do not run both. +-- MySQL 5.7; non-idempotent: inspect SHOW COLUMNS before execution. +ALTER TABLE fc_answer_projection + ADD COLUMN projection_version BIGINT NOT NULL DEFAULT 0 + COMMENT 'Local optimistic generation; guards MQ/receipt/authoritative repair writes'; diff --git a/sql/add_reconcile_work_recovery.sql b/sql/add_reconcile_work_recovery.sql new file mode 100644 index 0000000..428a1b1 --- /dev/null +++ b/sql/add_reconcile_work_recovery.sql @@ -0,0 +1,7 @@ +-- Existing TASK_3 installations only, once before deploying the refined Worker. +-- Fresh schema: use answer_reconcile_work.sql instead; do not run both. +-- MySQL 5.7; no SQL is executed by this delivery. +ALTER TABLE answer_reconcile_work + ADD COLUMN work_version BIGINT NOT NULL DEFAULT 0, + ADD COLUMN retry_count INT NOT NULL DEFAULT 0, + ADD COLUMN pending_report_state VARCHAR(16) NULL; diff --git a/sql/add_service_item_fc_binding.sql b/sql/add_service_item_fc_binding.sql new file mode 100644 index 0000000..ddca9fc --- /dev/null +++ b/sql/add_service_item_fc_binding.sql @@ -0,0 +1,14 @@ +-- 2026-10-21 svc-hall TASK_1:事项绑定「表单中心」的坐标两列(fc_form_uuid / fc_form_version) +-- 契约:form-center/service/docs/consumer-access-v3.md §4(消费方只保存服务器验证过的 uuid + 确切版本) +-- 口径:两列都可空且**必须成对**——单传一个由保存链路拒(ServiceItemService#validateBindings), +-- 取消关联=同时清空;都没绑=既有允许形态(纯指南事项/无需表单的事项照旧可存)。 +-- form_code 列不删、不改语义:存量行仍参与办件回显与 effectiveFlowGuid 的读路径, +-- 但**保存链路不再写新值**(只允许原样回传保留历史绑定)。 +-- 无索引:本应用没有"按 fc_form_uuid 反查事项"的查询(事项是按 id 寻址的单条编辑), +-- 加了就是没人走的索引;将来 TASK_2 的 Apply 侧若要按表单聚合办件,再随那条查询一起加。 +-- 语法:MySQL 5.7(无 ADD COLUMN IF NOT EXISTS,重复执行会报 Duplicate column name,按需在外部判重) +-- 本地/测试 ddl-auto=update 会自动加列,本脚本是生产升级与"实表应当长什么样"的评审权威。 + +ALTER TABLE service_item + ADD COLUMN fc_form_uuid VARCHAR(32) NULL COMMENT '表单中心表单 uuid(form-center 对外身份,保存前已向中心核验)' AFTER form_code, + ADD COLUMN fc_form_version INT NULL COMMENT '表单中心确切版本号(与 fc_form_uuid 成对;中心返回坐标不符即拒绝保存)' AFTER fc_form_uuid; diff --git a/sql/answer_reconcile_work.sql b/sql/answer_reconcile_work.sql new file mode 100644 index 0000000..035f059 --- /dev/null +++ b/sql/answer_reconcile_work.sql @@ -0,0 +1,82 @@ +-- ============================================================ +-- 办事大厅 · 对账任务接收与执行检查点 DDL(MySQL 5.7 兼容复核版,TASK_3) +-- 范围:一张新表 answer_reconcile_work(中心侧契约见 form-center/service/docs/answer-reconcile-v1.md) +-- 依据:任务书 TASK_3 改动点 4(SH 持久化接收任务、重复指令复用 taskId、进程重启从 checkpoint 续页)、 +-- 改动点 5/6(比较四元摘要、经权威入口修复、多余行只在完整证明后退役) +-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_svchall,ddl-auto=update)由 Hibernate +-- 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 fc_projection_and_apply_intent 先例)。 +-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写 CHARSET/COLLATE; +-- ③不建物理外键——task_uuid 是中心任务的逻辑引用,本行要能在中心归档后继续留痕; +-- ④两条游标与水位同为 answer_uuid(32 位小写十六进制),锁步推进才是"多余行"可判的前提。 +-- 边界纪律:本表只存对账坐标与检查点,不存答卷值文档——值文档归 fc_answer_projection(TASK_2), +-- 修复走 repairAuthoritative、退役走 retireAbsent,本任务不提供任何绕过它们的通道。 +-- 状态纪律:接收端点的 2xx 只把 state 写成 QUEUED;COMPLETED/PARTIAL 必须来自本地扫描的真实收尾, +-- 且以 (task_uuid, attempt) 的回报为准。 +-- 非幂等:MySQL 5.7 无评审友好的 CREATE TABLE IF NOT EXISTS 写法。 +-- 执行前先 `SHOW TABLES LIKE 'answer_reconcile_work'` 确认表不存在。 +-- ============================================================ + +CREATE TABLE `answer_reconcile_work` ( + `work_version` BIGINT NOT NULL DEFAULT 0 COMMENT 'Local optimistic write generation', + `retry_count` INT NOT NULL DEFAULT 0 COMMENT 'Consecutive execution/report failures', + `pending_report_state` VARCHAR(16) DEFAULT NULL COMMENT 'Retry this result only; do not rescan data', + `id` INT NOT NULL AUTO_INCREMENT, + `task_uuid` VARCHAR(36) NOT NULL COMMENT '中心任务身份(唯一键:重复指令复用本行与检查点,不另起一轮扫描)', + `attempt` INT NOT NULL DEFAULT 0 COMMENT '当前执行的派发尝试;更旧 attempt 的指令只吸收,不动检查点与已提交结论', + `consumer_app_key` VARCHAR(64) NOT NULL COMMENT '本应用消费身份(须与已验签身份逐字相等才收)', + `consumer_school_id` BIGINT NOT NULL COMMENT '本轮租户域:本地扫描按它限定行集,跨校指令拒收', + `form_uuid` VARCHAR(32) NOT NULL COMMENT '本轮范围的表单坐标(一行任务=一个 (消费方, 租户域, 表单))', + `form_version` INT NOT NULL COMMENT '中心已核验的版本(不从本地旧清单猜)', + `trigger_type` VARCHAR(16) DEFAULT NULL COMMENT 'SCHEDULE / MANUAL', + `window_start` DATETIME NOT NULL COMMENT '任务起点:本地 create_time 晚于它或为空的行只报告、不退役', + `total_rows` INT NOT NULL DEFAULT 0 COMMENT '中心清单总行数(完整性核对用)', + `total_pages` INT NOT NULL DEFAULT 0 COMMENT '中心清单确定总页', + `page_size` INT NOT NULL DEFAULT 200 COMMENT '两侧同页大小(上限 500):锁步比较才对得上', + `state` VARCHAR(16) NOT NULL DEFAULT 'QUEUED' COMMENT 'QUEUED/RUNNING/COMPLETED/PARTIAL/FAILED/CANCELLED', + `fc_cursor` VARCHAR(32) DEFAULT NULL COMMENT '中心清单游标(=水位,answer_uuid)', + `local_cursor` VARCHAR(32) DEFAULT NULL COMMENT '本地图像游标(=水位,answer_uuid)', + `watermark` VARCHAR(32) DEFAULT NULL COMMENT '两侧都已比较到的最大 answer_uuid:断线重启从这里续', + `fc_completed` TINYINT NOT NULL DEFAULT 0 COMMENT '中心清单是否读到底(多余行判定的前提之一)', + `local_completed` TINYINT NOT NULL DEFAULT 0 COMMENT '本地图像是否扫完(另一个前提)', + `pages_processed` INT NOT NULL DEFAULT 0 COMMENT '已推进的锁步轮数', + `scanned_rows` INT NOT NULL DEFAULT 0 COMMENT '比较过的答卷数', + `missing_repaired` INT NOT NULL DEFAULT 0 COMMENT '缺失修复 APPLIED 数(期望三元组全 null,只建未关联投影)', + `stale_repaired` INT NOT NULL DEFAULT 0 COMMENT '陈旧与同版错 hash 的修复 APPLIED 数(同一权威入口)', + `hash_conflicts` INT NOT NULL DEFAULT 0 COMMENT '同 revision 异 hash 的观察数(记录后才取中心当前值修复)', + `tombstones_repaired` INT NOT NULL DEFAULT 0 COMMENT '墓碑修复数(保留本地业务扩展列,不物理删)', + `local_ahead` INT NOT NULL DEFAULT 0 COMMENT '本地 revision 更高:向中心复核后只报告,禁止降级覆盖', + `deferred` INT NOT NULL DEFAULT 0 COMMENT '新鲜授权核验不可达/撤权/纪元不等而暂停的条目', + `extra_reported` INT NOT NULL DEFAULT 0 COMMENT '本地多出的行观察数(含期间新增与 createTime 未知,只报告)', + `extra_retired` INT NOT NULL DEFAULT 0 COMMENT '经中心签名缺席证明实际退役的行数', + `pending_extras_json` MEDIUMTEXT NOT NULL COMMENT '待缺席答复的多余行候选(含 create_time;不含值文档,有上限)', + `error_count` INT NOT NULL DEFAULT 0 COMMENT '错误条数', + `errors_json` MEDIUMTEXT NOT NULL COMMENT '错误明细(每条含 consumer/task/range/answer 四坐标,敏感 values 永不进这里)', + `reported_attempt` INT DEFAULT NULL COMMENT '已提交给中心的结果 attempt(同 attempt 不重复提交)', + `reported_state` VARCHAR(16) DEFAULT NULL COMMENT '已提交的结果状态', + `resumed` INT NOT NULL DEFAULT 0 COMMENT 'RUNNING 超租约后被重新领取的次数(这一轮断过几次)', + `lease_until` DATETIME DEFAULT NULL COMMENT '本地执行租约:超期才可被别的扫描线程领走续跑', + `next_retry_at` DATETIME DEFAULT NULL COMMENT '退避到点才再扫(扫描中断/回报不可达时写)', + `received_at` DATETIME NOT NULL COMMENT '指令落库时刻(接收端点 2xx 的依据就在这列)', + `started_at` DATETIME DEFAULT NULL COMMENT '首次开始执行时刻', + `finished_at` DATETIME DEFAULT NULL COMMENT '终态落定时刻', + `last_error_code` VARCHAR(32) DEFAULT NULL COMMENT '末次稳定码(GRANT_REVOKED/CANCELLED/MANIFEST_INCOMPLETE/…)', + `last_error` VARCHAR(500) DEFAULT NULL COMMENT '末次错误摘要(截断存)', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_reconcile_work_task` (`task_uuid`), + KEY `idx_reconcile_work_scan` (`state`, `next_retry_at`), + KEY `idx_reconcile_work_consumer` (`consumer_app_key`, `consumer_school_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='对账任务接收与检查点(TASK_3:接收与执行解耦、同 taskId 幂等复用、重启从 watermark 续页;不存值文档)'; + +-- 自验(执行后逐条核对): +-- SHOW CREATE TABLE answer_reconcile_work\G -- 应含 uk_reconcile_work_task(同 taskId 只一行) +-- SELECT COUNT(*) FROM information_schema.referential_constraints +-- WHERE table_name='answer_reconcile_work'; -- 必须 0 行(无物理外键) +-- SELECT COUNT(*) FROM information_schema.columns +-- WHERE table_schema=DATABASE() AND table_name='answer_reconcile_work' +-- AND column_name IN ('values_json','snapshot_json','context_token'); +-- -- 必须 0 行(值文档与令牌不进对账检查点) diff --git a/sql/fc_projection_and_apply_intent.sql b/sql/fc_projection_and_apply_intent.sql new file mode 100644 index 0000000..64f282d --- /dev/null +++ b/sql/fc_projection_and_apply_intent.sql @@ -0,0 +1,186 @@ +-- ============================================================ +-- 办事大厅 · 可信建件 / 答卷投影 / 事件台账 / 出站命令 DDL(MySQL 5.7 兼容复核版,TASK_2) +-- 范围:《answer-delivery-v2.md》§6~§8 = service_apply 加两列 + 建四张表 +-- fc_apply_intent / fc_answer_projection / fc_answer_inbox / flow_apply_command +-- 依据:任务书 TASK_2 A.1/A.2/A.5(意图唯一键与"一次意图一个办件")、B.5(投影与台账分表)、 +-- C.1/C.2(出站命令持久化 nextRetry、轮次与请求标识) +-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_svchall,ddl-auto=update)由 Hibernate +-- 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 v3/migrate_v2 先例)。 +-- 5.7 复核要点:①无 CTE/函数索引/生成列/CHECK;②utf8mb4 显式 CHARSET/COLLATE; +-- ③不建物理外键:intent→service_apply、projection→intent 都是跨表逻辑引用, +-- 回调/事件与用户请求并发写入时,FK 会把"先到的一方"变成死锁或顺序要求; +-- ④唯一键承担全部收敛职责(应用层不允许靠时序判断"我该不该再建一次")。 +-- 收敛口径:事件先到还是确认先到,结果都撞进同一组唯一键—— +-- fc_apply_intent(sch_id,submitter_id,item_id,client_key) 挡重复 POST; +-- fc_apply_intent(fc 意图键) 挡重复上下文;fc_apply_intent(answer_uuid) 与 (apply_id) +-- 挡"一份答卷形成两个办件";fc_answer_projection(answer_uuid,sch_id) 挡一份答卷两份本地投影; +-- fc_answer_inbox(event_id,consumer_app_key) 挡同一封事件写两次; +-- flow_apply_command(apply_id,attempt) 挡一轮两条命令、(request_token) 挡标识复用。 +-- 双时刻纪律:中心侧 broker_accepted / consumer_applied 分列;本侧 inbox 只留"我这边怎么处置" +-- 与"回执上报状态",两边都不许替对方作证。 +-- 存量:service_apply 两个新列对历史行恒 NULL,不回填、不建双轨(A.5 末句"新数据可追溯")。 +-- 非幂等:执行前先 SHOW TABLES LIKE 逐张确认不存在,并 SHOW COLUMNS FROM service_apply LIKE 'fc_intent_id'。 +-- ============================================================ + +ALTER TABLE `service_apply` + ADD COLUMN `fc_intent_id` INT DEFAULT NULL + COMMENT '可信建件意图 id(fc_apply_intent.id);NULL=旧链路/无表单事项直接提交。唯一性判据在意图侧 uk_intent_apply,本列只作反向可查', + ADD COLUMN `fc_answer_uuid` VARCHAR(32) DEFAULT NULL + COMMENT '建件依据的答卷 uuid(表单中心对外身份);旧链路恒 NULL'; + +CREATE TABLE `fc_apply_intent` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` BIGINT NOT NULL COMMENT '租户域(会话推导,浏览器不可注入)', + `site_code` VARCHAR(64) NOT NULL COMMENT '事项坐标之一(CMS 站点编码)', + `item_id` INT NOT NULL COMMENT 'service_item.id(逻辑引用,不建 FK)', + `item_code` VARCHAR(64) NOT NULL COMMENT '事项编码快照', + `item_title` VARCHAR(200) NOT NULL COMMENT '事项标题快照', + `submitter_id` INT NOT NULL COMMENT '提交者(CAS 会话)', + `submitter_name` VARCHAR(128) NOT NULL COMMENT '提交者姓名快照', + `client_key` VARCHAR(64) NOT NULL COMMENT '客户端幂等键(重复 POST 回到同一意图)', + `fc_form_uuid` VARCHAR(32) NOT NULL COMMENT '绑定表单中心 uuid(TASK_1 成对坐标之一)', + `fc_form_version` INT NOT NULL COMMENT '绑定表单中心确切版本(单传或改值都进不了这条链)', + `flow_guid` VARCHAR(32) DEFAULT NULL COMMENT '绑定流程线(发起前判过就绪;建件时快照进办件)', + `consumer_app_key` VARCHAR(64) NOT NULL COMMENT '本应用出站 appKey(=中心侧上下文归属)', + `fc_intent_id` VARCHAR(64) NOT NULL COMMENT '送给中心的业务意图键(自然键哈希,稳定而非随机;与本地自增 id 不是一回事)', + `context_token` VARCHAR(64) DEFAULT NULL COMMENT '中心签发的短时提交上下文令牌(交给被嵌页用,故明文存;30 分钟失效)', + `embed_origin` VARCHAR(200) DEFAULT NULL COMMENT '中心锁定的可嵌入父页面 origin(不是本地拼的)', + `fill_page_url` VARCHAR(500) DEFAULT NULL COMMENT '被嵌填报页地址(服务端按配置基址拼,浏览器给的路径不采信)', + `state` VARCHAR(16) NOT NULL DEFAULT 'ISSUED' COMMENT 'ISSUED / ANSWERED / APPLIED / VOID', + `expires_at` DATETIME DEFAULT NULL COMMENT '令牌有效期(过期即 VOID,不无限期占着可提交凭据)', + `answer_uuid` VARCHAR(32) DEFAULT NULL COMMENT '收据/事件确认的答卷 uuid(唯一:一意图一答卷)', + `answer_revision` BIGINT DEFAULT NULL COMMENT '确认时刻的快照修订号', + `content_hash` CHAR(64) DEFAULT NULL COMMENT 'fc.canon.v1 快照哈希', + `apply_id` INT DEFAULT NULL COMMENT '形成的本地办件(唯一:一次意图最多一个办件)', + `confirmed_at` DATETIME DEFAULT NULL COMMENT '服务端收据确认时刻(不是浏览器报的成功时刻)', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_intent_client` (`sch_id`, `submitter_id`, `item_id`, `client_key`), + UNIQUE KEY `uk_intent_fc` (`consumer_app_key`, `fc_intent_id`), + UNIQUE KEY `uk_intent_answer` (`answer_uuid`), + UNIQUE KEY `uk_intent_apply` (`apply_id`), + KEY `idx_intent_user` (`sch_id`, `submitter_id`, `state`), + KEY `idx_intent_expire` (`state`, `expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='可信建件意图(A.1/A.2/A.5:浏览器只交坐标+幂等键,建件唯一性由本表唯一键保证)'; + +CREATE TABLE `fc_answer_projection` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` BIGINT NOT NULL COMMENT '消费租户域(与 answer_uuid 一起构成唯一投影键)', + `answer_uuid` VARCHAR(32) NOT NULL COMMENT '答卷 uuid', + `form_uuid` VARCHAR(32) NOT NULL COMMENT '关系坐标:表单 uuid(A.5 要求关系上可追溯)', + `form_version` INT NOT NULL COMMENT '关系坐标:钉住的 schema 版本', + `revision` BIGINT NOT NULL COMMENT '中心侧单调快照修订号(低版本忽略、同版比 hash)', + `projection_version` BIGINT NOT NULL DEFAULT 0 COMMENT 'Local optimistic generation; independent of FC revision', + `content_hash` CHAR(64) NOT NULL COMMENT '中心哈希;本地按 fc.canon.v1 复算必须逐字一致', + `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '墓碑(本地保留 revision,旧 created 复活不了它)', + `app_code` VARCHAR(64) DEFAULT NULL, + `code` VARCHAR(64) DEFAULT NULL, + `submitter_id` INT DEFAULT NULL, + `subject_id` INT DEFAULT NULL, + `status` VARCHAR(64) DEFAULT NULL COMMENT '答卷轻状态位(中心改状态会推新 revision 过来)', + `values_json` LONGTEXT DEFAULT NULL COMMENT '值文档全文(B.6:每条事件不回查答案值文档)', + `answer_no` VARCHAR(64) DEFAULT NULL, + `answer_create_time` VARCHAR(32) DEFAULT NULL COMMENT '中心给的 ISO 形态原文,不做本地时区换算', + `answer_update_time` VARCHAR(32) DEFAULT NULL, + `intent_id` INT DEFAULT NULL COMMENT '关联意图(唯一:一份投影只挂一个意图;NULL=事件先到的未关联态)', + `apply_id` INT DEFAULT NULL COMMENT '已形成的办件(可查回填,收敛判据在意图侧)', + `flow_guid` VARCHAR(32) DEFAULT NULL COMMENT '关系坐标:流程线(A.5 点名要留)', + `link_state` VARCHAR(16) NOT NULL DEFAULT 'UNLINKED' COMMENT 'UNLINKED / LINKED / APPLIED', + `conflict_flag` TINYINT NOT NULL DEFAULT 0 COMMENT '1=同 revision 异 hash,冲突待核验(不覆盖)', + `conflict_detail` VARCHAR(500) DEFAULT NULL COMMENT '冲突/退役留证', + `last_event_id` VARCHAR(36) DEFAULT NULL COMMENT '最近落库的事件 id(审计对应关系)', + `biz_ext_json` LONGTEXT DEFAULT NULL COMMENT '业务端扩展列:中心快照永不写(B.5)', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_projection_answer` (`answer_uuid`, `sch_id`), + UNIQUE KEY `uk_projection_intent` (`intent_id`), + KEY `idx_projection_state` (`sch_id`, `form_uuid`, `revision`), + KEY `idx_projection_apply` (`apply_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='答卷在本应用的唯一投影 + 意图↔答卷↔办件的独立唯一关联表(A.5/B.5)'; + +CREATE TABLE `fc_answer_inbox` ( + `id` INT NOT NULL AUTO_INCREMENT, + `event_id` VARCHAR(36) NOT NULL COMMENT '信封事件 id', + `protocol_version` VARCHAR(32) NOT NULL COMMENT '固定 fc.answer.snapshot.v2', + `event_type` VARCHAR(16) NOT NULL COMMENT 'created / updated / tombstoned', + `consumer_app_key` VARCHAR(64) NOT NULL COMMENT '本应用 appKey(信封目标必须等于它)', + `consumer_school_id` BIGINT NOT NULL COMMENT '信封目标租户域', + `source_app_key` VARCHAR(64) DEFAULT NULL COMMENT '发布方身份', + `grant_version` INT DEFAULT NULL COMMENT '信封带的授权纪元(与本地核验快照比对,旧纪元不写)', + `form_uuid` VARCHAR(32) DEFAULT NULL, + `form_version` INT DEFAULT NULL, + `answer_uuid` VARCHAR(32) DEFAULT NULL, + `revision` BIGINT DEFAULT NULL, + `content_hash` CHAR(64) DEFAULT NULL, + `is_deleted` TINYINT NOT NULL DEFAULT 0, + `occurred_at` VARCHAR(40) DEFAULT NULL COMMENT '中心给的 occurredAt 原文(投递时间不进哈希)', + `state` VARCHAR(16) NOT NULL COMMENT 'APPLIED / IGNORED / CONFLICT / DEFERRED / REJECTED', + `detail` VARCHAR(500) DEFAULT NULL COMMENT '未应用原因(撤权暂缓/低版本/校验失败要点)', + `report_state` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '回执上报:PENDING / SENT / FAILED', + `report_attempts` INT NOT NULL DEFAULT 0, + `next_report_at` DATETIME DEFAULT NULL, + `reported_at` DATETIME DEFAULT NULL, + `received_at` DATETIME NOT NULL COMMENT '本地接收时刻(与中心 occurredAt 不是一回事)', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbox_event` (`event_id`, `consumer_app_key`), + KEY `idx_inbox_answer` (`answer_uuid`, `revision`), + KEY `idx_inbox_report` (`report_state`, `next_report_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='事件接收台账(B.5/B.6:投影存最新事实,本表存每封怎么处置的,不提供删除方法)'; + +CREATE TABLE `flow_apply_command` ( + `id` INT NOT NULL AUTO_INCREMENT, + `apply_id` INT NOT NULL COMMENT 'service_apply.id(业务键 bizId 的数值部分)', + `sch_id` BIGINT NOT NULL, + `site_code` VARCHAR(64) NOT NULL, + `biz_type` VARCHAR(32) NOT NULL COMMENT '与中心 uk 一致的业务类型键(svc-hall)', + `biz_id` VARCHAR(64) NOT NULL COMMENT '业务键=办件 id(与旧链路逐字相同,回调解析不变)', + `attempt` INT NOT NULL DEFAULT 1 COMMENT '轮次:仅用户明确授权的重提才 +1', + `request_token` VARCHAR(64) NOT NULL COMMENT '本次命令标识:同 token 重投=同一命令,中心不重启推进', + `flow_guid` VARCHAR(32) NOT NULL, + `payload_json` LONGTEXT NOT NULL COMMENT '出站载荷快照(重放不回读办件行)', + `state` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING / UNKNOWN / DONE / FAILED / DEAD', + `retry_count` INT NOT NULL DEFAULT 0, + `next_retry_at` DATETIME DEFAULT NULL COMMENT '持久化退避到点(C.1 崩溃恢复就靠这一列)', + `remote_instance_id` BIGINT DEFAULT NULL, + `remote_status` INT DEFAULT NULL COMMENT '查/发起得到的远端真实状态(先回填再决定,不把 30/40 当重试)', + `remote_version` INT DEFAULT NULL, + `last_error` VARCHAR(500) DEFAULT NULL, + `sent_at` DATETIME DEFAULT NULL, + `settled_at` DATETIME DEFAULT NULL, + `intent_id` INT DEFAULT NULL COMMENT '可信建件意图(旧链路为 NULL,不回填历史)', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_command_attempt` (`apply_id`, `attempt`), + UNIQUE KEY `uk_command_token` (`request_token`), + KEY `idx_command_scan` (`state`, `next_retry_at`), + KEY `idx_command_biz` (`sch_id`, `biz_type`, `biz_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='出站发起命令(C.1/C.2:HTTP 不占长事务,未知态先查后发,进程崩溃后按 next_retry_at 恢复)'; + +-- 落地校对项(执行前后各核一遍,别靠"看起来一样"): +-- 1) 注:fc_apply_intent.fc_intent_id 是"送给中心的意图键"(VARCHAR), +-- 与同表自增主键 id(INT,被 fc_answer_projection.intent_id 引用)是两个东西, +-- service_apply.fc_intent_id 引用的才是后者(INT)——两侧列名同名不同型是刻意的历史命名, +-- 评审时按"哪张表的外侧"读,不要互换。 +-- 2) SHOW CREATE TABLE fc_apply_intent\G -- 四个唯一键都在 +-- 3) SHOW CREATE TABLE fc_answer_projection\G -- uk_projection_answer + uk_projection_intent +-- 4) SELECT COUNT(*) FROM information_schema.referential_constraints +-- WHERE table_name IN ('fc_apply_intent','fc_answer_projection','fc_answer_inbox','flow_apply_command'); +-- -- 必须 0 行(无物理外键) +-- 5) SELECT COUNT(*) FROM service_apply WHERE fc_intent_id IS NOT NULL; -- 刚执行完必须 0(不回填) diff --git a/src/main/java/com/yunhai/svchall/config/FcAnswerMqConfiguration.java b/src/main/java/com/yunhai/svchall/config/FcAnswerMqConfiguration.java new file mode 100644 index 0000000..febf796 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/config/FcAnswerMqConfiguration.java @@ -0,0 +1,128 @@ +package com.yunhai.svchall.config; + +import com.yunhai.svchall.model.FcApplyModels; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.core.AcknowledgeMode; +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.core.QueueBuilder; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitAdmin; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.retry.support.RetryTemplate; + +/** + * v2 快照投递的消费侧拓扑(answer-delivery-v2 §3.3/§7,TASK_2 B.3/B.5)。 + * + *

整体条件装配:只有显式配了 {@code spring.rabbitmq.host} 才存在。未配 broker 时 + * 不建监听容器(否则后台反复重连把启动与日志拖脏),{@code FcAnswerConsumer} 上的 + * {@code @RabbitListener} 因没有处理器而不生效——可信建件仍走"签名收据确认"那条路, + * 只是延迟从秒级变成"用户点确认那一刻"。

+ * + *

队列隔离是这条链的硬前提:完整值文档只进按消费 appKey + 租户域绑定的私有队列。 + * routing key 形如 {@code fc.snapshot.v2..},本应用只绑自己 appKey + * 那一段({@code .*} 只覆盖自己的多租户域),新消费者不得能绑其他目标的 routing key + * (v2 §5 撤权边界的 broker 侧一半)。部署必须落实的实值来源见交付说明"broker 隔离配置"一节: + * 独立 vhost、账号仅对本 vhost 有 configure/write/read、禁止通配订阅他人前缀; + * 这三条没有落地就不能打开 {@code RABBITMQ_HOST}——宁可不投,也不在共享 vhost 上发明文。

+ */ +@Slf4j +@Configuration +@org.springframework.amqp.rabbit.annotation.EnableRabbit +@ConditionalOnExpression("!'${spring.rabbitmq.host:}'.trim().isEmpty()") +public class FcAnswerMqConfiguration { + + /** 中心侧 v2 exchange(与 FC {@code AnswerDeliveryModels.EXCHANGE} 同一字面量) */ + public static final String EXCHANGE = FcApplyModels.EXCHANGE; + + /** 本应用私有队列(一行=一个消费应用,不按资源 appCode 分) */ + public static final String QUEUE = "svc-hall.fc.answer.snapshot.v2"; + + /** 死信:容器有界重试耗尽后落这里,可查、不热循环 */ + public static final String DLX = "svc-hall.fc.answer.snapshot.v2.dlx"; + public static final String DLQ = QUEUE + ".dlq"; + + @Value("${svchall.fc.consumer-app-key:${yh.app-auth.app-key:100018}}") + private String consumerAppKey; + + @Value("${svchall.fc.prefetch-count:10}") + private int prefetchCount; + + @Value("${svchall.fc.max-concurrent-consumers:2}") + private int maxConcurrent; + + @Bean + public TopicExchange fcSnapshotExchange() { + return new TopicExchange(EXCHANGE, true, false); + } + + @Bean + public Queue fcSnapshotQueue() { + return QueueBuilder.durable(QUEUE) + .deadLetterExchange(DLX) + .deadLetterRoutingKey(DLQ) + .build(); + } + + /** 只绑自己 appKey 那一段;星号位是租户域(一个应用可服务多校,但绝不吃别人的前缀) */ + @Bean + public Binding fcSnapshotBinding() { + return BindingBuilder.bind(fcSnapshotQueue()).to(fcSnapshotExchange()) + .with("fc.snapshot.v2." + trimmedAppKey() + ".*"); + } + + @Bean + public TopicExchange fcSnapshotDlx() { + return new TopicExchange(DLX, true, false); + } + + @Bean + public Queue fcSnapshotDlq() { + return QueueBuilder.durable(DLQ).build(); + } + + @Bean + public Binding fcSnapshotDlqBinding() { + return BindingBuilder.bind(fcSnapshotDlq()).to(fcSnapshotDlx()).with(DLQ); + } + + /** 拓扑声明只在 broker 已配的环境里做(本类整体条件已保证,不会在未配时启动拨号) */ + @Bean + public RabbitAdmin fcSnapshotAdmin(ConnectionFactory connectionFactory) { + return new RabbitAdmin(connectionFactory); + } + + /** + * 监听容器:manual ack(B.5"DB 提交成功才 ack")+ 3 次有界重试, + * 耗尽后 {@code defaultRequeueRejected=false} 把消息交给死信—— + * 不设上限的 requeue 会让一条毒消息把 CPU 打满,那正是任务书点名的热循环。 + */ + @Bean + public SimpleRabbitListenerContainerFactory fcAnswerContainerFactory(ConnectionFactory connectionFactory) { + SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); + factory.setConnectionFactory(connectionFactory); + factory.setAcknowledgeMode(AcknowledgeMode.MANUAL); + factory.setConcurrentConsumers(1); + factory.setMaxConcurrentConsumers(Math.max(1, maxConcurrent)); + factory.setPrefetchCount(Math.max(1, prefetchCount)); + factory.setMissingQueuesFatal(false); + factory.setDefaultRequeueRejected(false); + RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setRetryPolicy(new SimpleRetryPolicy(3)); + factory.setRetryTemplate(retryTemplate); + log.info("[fc-mq] v2 快照监听已装配:exchange={} queue={} bind=fc.snapshot.v2.{}.{}", + EXCHANGE, QUEUE, trimmedAppKey(), "*"); + return factory; + } + + private String trimmedAppKey() { + return consumerAppKey == null ? "" : consumerAppKey.trim(); + } +} diff --git a/src/main/java/com/yunhai/svchall/controller/api/FcApplyIntentController.java b/src/main/java/com/yunhai/svchall/controller/api/FcApplyIntentController.java new file mode 100644 index 0000000..f8d1a35 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/controller/api/FcApplyIntentController.java @@ -0,0 +1,49 @@ +package com.yunhai.svchall.controller.api; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.model.FcApplyModels.IntentConfirmView; +import com.yunhai.svchall.model.FcApplyModels.IntentCreateRequest; +import com.yunhai.svchall.model.FcApplyModels.IntentView; +import com.yunhai.svchall.service.impl.FcApplyIntentService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +/** + * 可信建件意图(用户面 CAS,{@code /api/apply/intents},TASK_2 A.1/A.4)。 + * + *

与既有 {@code POST /api/apply}(直接提交表单数据)的关系是并列而不是替换: + * 无表单/本地表单/外链形态照旧走那条;绑定了表单中心确切版本的事项走这里, + * 因为它的表单数据不再由本应用的浏览器请求携带——数据在中心,本地只拿得到签名收据。

+ * + *

两个动作都只认 CAS 会话:用户与学校从会话取,事项坐标从 body(受权限判定约束), + * 而 {@code submitterId/schId/flowGuid/answerUuid/bizId} 在本请求体里没有字段位, + * 注入不了(A.1 的"浏览器 body 只可带事项坐标和客户端幂等键")。

+ */ +@RestController +@RequestMapping("/api/apply/intents") +@RequiredArgsConstructor +public class FcApplyIntentController { + + private final FcApplyIntentService intentService; + + /** 创建/复用意图并换取被嵌填报页地址(入口判定不过即拒,不建办件也不调流程) */ + @PostMapping + public IntentView create(BoaUser authUserWithDept, @Valid @RequestBody IntentCreateRequest request) { + return intentService.createIntent(authUserWithDept, request); + } + + /** + * 确认(浏览器 postMessage 只能触发这一步):服务端读中心收据 → 复核 → 建件。 + * 幂等:已建过件直接回原结论。 + */ + @PostMapping("/{id}/confirm") + public IntentConfirmView confirm(BoaUser authUserWithDept, @PathVariable("id") Integer id) { + return intentService.confirm(authUserWithDept, id); + } +} diff --git a/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFormCatalogController.java b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFormCatalogController.java new file mode 100644 index 0000000..0679465 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFormCatalogController.java @@ -0,0 +1,44 @@ +package com.yunhai.svchall.controller.api.svc; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.model.svc.SvcModels.SvcFormCatalogPage; +import com.yunhai.svchall.service.SiteDirectory.SiteInfo; +import com.yunhai.svchall.service.SiteGuard; +import com.yunhai.svchall.service.impl.FormCenterCatalogClient; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 表单中心目录的同源代理(v3/TASK_1):事项编辑页的表单选择器打这里,不打表单中心。 + * + *

两件事必须在服务端做(所以不能把代理 URL 发给浏览器): + * ① 应用签名(appKey/secret 只在本应用与表单中心之间流转); + * ② 租户域从 CAS 会话派生——{@code schId} 取自 {@link SiteGuard} 解析出的站点, + * 前端传不进来、也改不动。选中的表单坐标(uuid+version)随后由 {@code /api/svc/item/save} + * 那条链路再向中心核验一次才落库,代理这里只负责"看得见哪些表"。

+ * + *

分页参数原样透传给中心(中心侧 {@code pageSize} 上限 100,超限由中心拒)。

+ */ +@RestController +@RequestMapping("/api/svc/formCenter") +@RequiredArgsConstructor +public class SvcFormCatalogController { + + private final SiteGuard siteGuard; + private final FormCenterCatalogClient catalogClient; + + /** 目录(已发布 ∩ 本应用可读),先过滤再分页 */ + @GetMapping("/forms") + public SvcFormCatalogPage forms(BoaUser authUserWithDept, + @RequestParam("siteCode") String siteCode, + @RequestParam(value = "appCode", required = false) String appCode, + @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "pageSize", required = false) Integer pageSize) { + SiteInfo site = siteGuard.requireSiteManager(authUserWithDept, siteCode); + return catalogClient.listForms(site.getSchId(), appCode, keyword, page, pageSize); + } +} diff --git a/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFormController.java b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFormController.java index 410355f..fccc956 100644 --- a/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFormController.java +++ b/src/main/java/com/yunhai/svchall/controller/api/svc/SvcFormController.java @@ -1,5 +1,7 @@ package com.yunhai.svchall.controller.api.svc; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.svchall.model.svc.SvcModels.SvcFormSaveRequest; import com.yunhai.svchall.model.svc.SvcModels.SvcFormVo; @@ -19,12 +21,21 @@ import java.util.List; /** * 自定义表单管理(/api/svc/form/**,V2.0 Part B.3):列表/版本轨迹/保存(发布版自动升版)/ * 发布(静态校验)/删除(被绑定即拦)。表单为 sch 级,端点仍以 siteCode 定位做权限收口。 + * + *

v3/TASK_1:表单定义的正主是表单中心。{@code /save} 与 {@code /publish} 两个写入口 + * 明确退出(保留路由、给可诊断的业务错,指向中心的设计页),避免"大厅里还有一套能建表但 + * 没人渲染"的哑功能;读侧(list/versions/get)与 {@code /delete} 留着,因为存量事项还指着 + * 历史 {@code service_form} 行做回显与清理。渲染器不往这边复制。

*/ @RestController @RequestMapping("/api/svc/form") @RequiredArgsConstructor public class SvcFormController { + /** 两个写入口共用一条文案:换一个"去哪做"的答复,而不是只说"不行" */ + private static final String FORM_DESIGN_EXIT_MESSAGE = + "办事大厅不再维护本地表单定义:请到「表单中心」设计并发布表单,再回到事项编辑页从表单中心选择"; + private final ServiceFormService formService; @GetMapping("/list") @@ -46,18 +57,20 @@ public class SvcFormController { return formService.get(authUserWithDept, siteCode, id); } + /** 已退出(v3/TASK_1):本地表单定义不再新增/修改,改到表单中心建表 */ @PostMapping("/save") public Integer save(BoaUser authUserWithDept, @RequestParam("siteCode") String siteCode, @Valid @RequestBody SvcFormSaveRequest request) { - return formService.save(authUserWithDept, siteCode, request); + throw new BizException(ErrorCode.PARAM_ERROR, FORM_DESIGN_EXIT_MESSAGE); } + /** 已退出(v3/TASK_1):发布动作在表单中心完成,事项只绑定中心已发布表的坐标 */ @PostMapping("/publish/{id}") public void publish(BoaUser authUserWithDept, @PathVariable("id") Integer id, @RequestParam("siteCode") String siteCode) { - formService.publish(authUserWithDept, siteCode, id); + throw new BizException(ErrorCode.PARAM_ERROR, FORM_DESIGN_EXIT_MESSAGE); } @PostMapping("/delete") diff --git a/src/main/java/com/yunhai/svchall/controller/oapi/FormSyncReconcileController.java b/src/main/java/com/yunhai/svchall/controller/oapi/FormSyncReconcileController.java new file mode 100644 index 0000000..b1f4580 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/controller/oapi/FormSyncReconcileController.java @@ -0,0 +1,66 @@ +package com.yunhai.svchall.controller.oapi; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.appauth.AppAuthFilter; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.svchall.entity.AnswerReconcileWork; +import com.yunhai.svchall.model.FormSyncModels.InstructionAck; +import com.yunhai.svchall.model.FormSyncModels.ReconcileInstruction; +import com.yunhai.svchall.service.impl.AnswerReconcileWorker; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; + +/** + * 对账指令接收端(TASK_3 改动点 3/4,{@code POST /oapi/form-sync/reconcile})。 + * + *

路径在 {@code /oapi/*} 下,由既有 {@code RequireAppSignFilter} 强制应用签名: + * 未签名三件套即 401,本类里没有也不允许有第二套验签实现。身份只认平台验签后写在 + * {@code yh.app.auth} 上的 appKey,请求体里申报的 {@code consumerAppKey} 只用来比对是否冒充, + * 从不用来采信。

+ * + *

本端点的成功语义必须窄:2xx = 指令已持久化、等执行。执行是 Worker 的事, + * 差异是否修复、修了几条,只能由 {@code /oapi/form-sync/tasks/{taskId}/result} 的回报说话。 + * 同 {@code taskId} 的重复指令复用原任务与检查点,回 {@code duplicate=true}; + * 更旧 attempt 的重放不动已提交的结论。

+ */ +@Slf4j +@RestController +@RequestMapping("/oapi/form-sync") +@RequiredArgsConstructor +public class FormSyncReconcileController { + + private final AnswerReconcileWorker reconcileWorker; + + @PostMapping("/reconcile") + public InstructionAck reconcile(HttpServletRequest request, @RequestBody ReconcileInstruction body) { + Object verified = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR); + String verifiedAppKey = verified == null ? null : String.valueOf(verified).trim(); + if (verifiedAppKey == null || verifiedAppKey.isEmpty()) { + // 理论上到不了这里:enforce-patterns 已经把 /oapi/* 挡在验签之后。留这一句是为了 + // "身份缺失"绝不能被当成"身份相符",宁可在这一层就 401。 + throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的应用身份"); + } + AnswerReconcileWork work = reconcileWorker.accept(body, verifiedAppKey); + int incoming = body.getAttempt() == null ? 0 : body.getAttempt(); + int stored = work.getAttempt() == null ? 0 : work.getAttempt(); + boolean duplicate = incoming < stored || AnswerReconcileWork.isTerminal(work.getState()); + InstructionAck ack = new InstructionAck(); + ack.setTaskId(work.getTaskUuid()); + ack.setAttempt(work.getAttempt()); + ack.setAccepted(true); // Durable reuse is an accepted idempotent instruction, not a dispatch failure. + ack.setDuplicate(duplicate); + ack.setState(work.getState()); + ack.setMessage(duplicate ? "更旧 attempt 的重复指令:已按原任务与检查点吸收,不重开扫描" + : "指令已落库,执行结果以 result 回报为准(本应答不代表修复完成)"); + log.info("[对账] 接收指令 task={} attempt={} 范围={}/{} 清单 {} 行 {} 页 duplicate={}", + work.getTaskUuid(), incoming, work.getConsumerSchoolId(), work.getFormUuid(), + work.getTotalRows(), work.getTotalPages(), duplicate); + return ack; + } +} diff --git a/src/main/java/com/yunhai/svchall/entity/AnswerReconcileWork.java b/src/main/java/com/yunhai/svchall/entity/AnswerReconcileWork.java new file mode 100644 index 0000000..1e012c0 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/entity/AnswerReconcileWork.java @@ -0,0 +1,191 @@ +package com.yunhai.svchall.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import javax.persistence.Version; +import java.time.LocalDateTime; + +/** + * 本应用接收到的对账任务(TASK_3 改动点 4:指令先落库,执行在其之后)。 + * + *

{@code task_uuid} 唯一:重复指令复用同一行与同一检查点,不另起一轮扫描。 + * 派发方的 2xx 只把这里写成 {@code QUEUED},绝不写成本地已修好。

+ * + *

检查点为什么是"两条游标 + 一个水位":中心清单与本地图像都是按 {@code answerUuid} + * 升序的 keyset 读取,两侧只有同步推进才可能发现"本地有、中心清单里没有"的多余行。 + * 一旦两条游标各自跑到不同位置就取 {@code min} 作为水位,重启后一律从水位重读—— + * 让领先的那条流"丢掉已读未比较的行"是不可接受的(那等于把差异判没了)。

+ * + *

{@code window_start} 来自中心指令,是本地"期间新增"的判据; + * 本地 {@code create_time} 晚于它、为空或归属不确定的行永远只是报告,不退役。

+ */ +@Getter +@Setter +@Entity +@Table(name = "answer_reconcile_work", + uniqueConstraints = @UniqueConstraint(name = "uk_reconcile_work_task", columnNames = "task_uuid"), + indexes = { + @Index(name = "idx_reconcile_work_scan", columnList = "state, next_retry_at"), + @Index(name = "idx_reconcile_work_consumer", columnList = "consumer_app_key, consumer_school_id, id")}) +public class AnswerReconcileWork extends BaseEntity { + + private static final long serialVersionUID = 1L; + + public static final String STATE_QUEUED = "QUEUED"; + public static final String STATE_RUNNING = "RUNNING"; + public static final String STATE_COMPLETED = "COMPLETED"; + public static final String STATE_PARTIAL = "PARTIAL"; + public static final String STATE_FAILED = "FAILED"; + public static final String STATE_CANCELLED = "CANCELLED"; + + public static boolean isTerminal(String state) { + return STATE_COMPLETED.equals(state) || STATE_PARTIAL.equals(state) + || STATE_FAILED.equals(state) || STATE_CANCELLED.equals(state); + } + + /** 中心任务身份(重复指令复用它) */ + @Column(name = "task_uuid", nullable = false, length = 36, updatable = false) + private String taskUuid; + + /** 当前执行的派发尝试(新指令 attempt 更大才刷新;更小的按重复吸收) */ + @Column(name = "attempt", nullable = false) + private Integer attempt = 0; + + @Version + @Column(name = "work_version", nullable = false) + private Long workVersion; + + @Column(name = "retry_count", nullable = false) + private Integer retryCount = 0; + + /** A finished scan retries only its result report, never the data scan. */ + @Column(name = "pending_report_state", length = 16) + private String pendingReportState; + + @Column(name = "consumer_app_key", nullable = false, length = 64, updatable = false) + private String consumerAppKey; + + @Column(name = "consumer_school_id", nullable = false) + private Long consumerSchoolId; + + @Column(name = "form_uuid", nullable = false, length = 32, updatable = false) + private String formUuid; + + @Column(name = "form_version", nullable = false) + private Integer formVersion; + + @Column(name = "trigger_type", length = 16) + private String triggerType; + + /** 任务起点(中心指令给出):晚于此刻的本地行不得判为多余行 */ + @Column(name = "window_start", nullable = false) + private LocalDateTime windowStart; + + @Column(name = "total_rows", nullable = false) + private Integer totalRows = 0; + + @Column(name = "total_pages", nullable = false) + private Integer totalPages = 0; + + @Column(name = "page_size", nullable = false) + private Integer pageSize = 200; + + @Column(name = "state", nullable = false, length = 16) + private String state = STATE_QUEUED; + + /** 中心清单游标(= 水位) */ + @Column(name = "fc_cursor", length = 32) + private String fcCursor; + + /** 本地扫描游标(= 水位) */ + @Column(name = "local_cursor", length = 32) + private String localCursor; + + /** 两侧都已比较到的最大 answerUuid */ + @Column(name = "watermark", length = 32) + private String watermark; + + @Column(name = "fc_completed", nullable = false, columnDefinition = "tinyint") + private Integer fcCompleted = 0; + + @Column(name = "local_completed", nullable = false, columnDefinition = "tinyint") + private Integer localCompleted = 0; + + @Column(name = "pages_processed", nullable = false) + private Integer pagesProcessed = 0; + + @Column(name = "scanned_rows", nullable = false) + private Integer scannedRows = 0; + + @Column(name = "missing_repaired", nullable = false) + private Integer missingRepaired = 0; + + @Column(name = "stale_repaired", nullable = false) + private Integer staleRepaired = 0; + + @Column(name = "hash_conflicts", nullable = false) + private Integer hashConflicts = 0; + + @Column(name = "tombstones_repaired", nullable = false) + private Integer tombstonesRepaired = 0; + + @Column(name = "local_ahead", nullable = false) + private Integer localAhead = 0; + + @Column(name = "deferred", nullable = false) + private Integer deferred = 0; + + @Column(name = "extra_reported", nullable = false) + private Integer extraReported = 0; + + @Column(name = "extra_retired", nullable = false) + private Integer extraRetired = 0; + + /** 待权威缺席答复的多余行候选(有上限;超限只报告,不退役) */ + @Column(name = "pending_extras_json", nullable = false, columnDefinition = "mediumtext") + private String pendingExtrasJson = "[]"; + + @Column(name = "error_count", nullable = false) + private Integer errorCount = 0; + + @Column(name = "errors_json", nullable = false, columnDefinition = "mediumtext") + private String errorsJson = "[]"; + + /** 已提交给中心的结果 attempt(同一 attempt 的结果不重复提交) */ + @Column(name = "reported_attempt") + private Integer reportedAttempt; + + @Column(name = "reported_state", length = 16) + private String reportedState; + + /** 断线重启续跑次数(运维观察用;每次从水位续读都 +1) */ + @Column(name = "resumed", nullable = false) + private Integer resumed = 0; + + @Column(name = "lease_until") + private LocalDateTime leaseUntil; + + @Column(name = "next_retry_at") + private LocalDateTime nextRetryAt; + + @Column(name = "received_at", nullable = false) + private LocalDateTime receivedAt; + + @Column(name = "started_at") + private LocalDateTime startedAt; + + @Column(name = "finished_at") + private LocalDateTime finishedAt; + + @Column(name = "last_error_code", length = 32) + private String lastErrorCode; + + @Column(name = "last_error", length = 500) + private String lastError; +} diff --git a/src/main/java/com/yunhai/svchall/entity/FcAnswerInbox.java b/src/main/java/com/yunhai/svchall/entity/FcAnswerInbox.java new file mode 100644 index 0000000..7ae9fd1 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/entity/FcAnswerInbox.java @@ -0,0 +1,121 @@ +package com.yunhai.svchall.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * 快照事件接收台账(answer-delivery-v2 §7,TASK_2 B.5/B.6)。 + * + *

投影表存"最新事实",本表存"每条事件怎么处置的",两者不能合成一张: + * 一条答卷会有多 revision,而消费端必须能回答"eventId=X 那封我到底应用了没有、 + * 回执上报了没有、失败几次"——这三问分别对应重复投递、结果确认、毒消息排查, + * 混在投影行上就再也问不出来了(尤其投影被更高 revision 覆盖之后)。

+ * + *

{@code uk_inbox_event}(eventId + 消费方)是 manual-ack 链的落库侧兜底: + * DB 提交成功才 ack,异常 nack 到死信;同一封被 broker 重投时,这里直接命中已有行、 + * 按 {@code state} 决定是否还要再写投影,而不是靠"看起来处理过了"。

+ * + *

{@code reportState}/{@code reportAttempts} 只描述往中心回执这件事—— + * 回执失败不影响本地已落库的事实(中心那边最多是没收到确认), + * 但必须能被观察到,否则"consumerApplied"会永远停在中心侧的 PENDING。

+ */ +@Getter +@Setter +@Entity +@Table(name = "fc_answer_inbox", + uniqueConstraints = @UniqueConstraint(name = "uk_inbox_event", + columnNames = {"event_id", "consumer_app_key"}), + indexes = { + @Index(name = "idx_inbox_answer", columnList = "answer_uuid, revision"), + @Index(name = "idx_inbox_report", columnList = "report_state, next_report_at")}) +public class FcAnswerInbox extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 已应用(投影写入成功) */ + public static final String STATE_APPLIED = "APPLIED"; + /** 已接收但无需应用(低 revision / 同 revision 同 hash 的重放) */ + public static final String STATE_IGNORED = "IGNORED"; + /** 同 revision 异 hash:冲突待人工核验,未覆盖既有投影 */ + public static final String STATE_CONFLICT = "CONFLICT"; + /** 授权无法确认:暂缓写入(v2 §5 撤权竞态口径),不是丢弃 */ + public static final String STATE_DEFERRED = "DEFERRED"; + /** 信封/schema 不合法:毒消息形态,留证后由死信处置,不重复入队 */ + public static final String STATE_REJECTED = "REJECTED"; + + /** 回执上报状态 */ + public static final String REPORT_PENDING = "PENDING"; + public static final String REPORT_SENT = "SENT"; + public static final String REPORT_FAILED = "FAILED"; + + @Column(name = "event_id", nullable = false, length = 36) + private String eventId; + + @Column(name = "protocol_version", nullable = false, length = 32) + private String protocolVersion; + + @Column(name = "event_type", nullable = false, length = 16) + private String eventType; + + @Column(name = "consumer_app_key", nullable = false, length = 64) + private String consumerAppKey; + + @Column(name = "consumer_school_id", nullable = false) + private Long consumerSchoolId; + + @Column(name="source_app_key", length = 64) + private String sourceAppKey; + + @Column(name = "grant_version") + private Integer grantVersion; + + @Column(name = "form_uuid", length = 32) + private String formUuid; + + @Column(name = "form_version") + private Integer formVersion; + + @Column(name = "answer_uuid", length = 32) + private String answerUuid; + + @Column(name = "revision") + private Long revision; + + @Column(name = "content_hash", length = 64) + private String contentHash; + + @Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint") + private Integer isDeleted = 0; + + @Column(name = "occurred_at", length = 40) + private String occurredAt; + + @Column(name = "state", nullable = false, length = 16) + private String state; + + /** 未应用的原因(撤权暂缓/低版本/校验失败要点),排障第一眼看这列 */ + @Column(name = "detail", length = 500) + private String detail; + + @Column(name = "report_state", nullable = false, length = 16) + private String reportState = REPORT_PENDING; + + @Column(name = "report_attempts", nullable = false) + private Integer reportAttempts = 0; + + @Column(name = "next_report_at") + private LocalDateTime nextReportAt; + + @Column(name = "reported_at") + private LocalDateTime reportedAt; + + @Column(name = "received_at", nullable = false) + private LocalDateTime receivedAt; +} diff --git a/src/main/java/com/yunhai/svchall/entity/FcAnswerProjection.java b/src/main/java/com/yunhai/svchall/entity/FcAnswerProjection.java new file mode 100644 index 0000000..628d509 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/entity/FcAnswerProjection.java @@ -0,0 +1,142 @@ +package com.yunhai.svchall.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import javax.persistence.Version; + +/** + * 表单中心答卷在本应用的持久投影(answer-delivery-v2 §4/§7,TASK_2 B.5 + A.5)。 + * + *

它同时是任务书点名的"独立唯一关联表":一行=一份答卷在本校的唯一投影, + * 关系列({@code intentId}/{@code applyId})+ 坐标列({@code formUuid}/{@code formVersion}/ + * {@code answerRevision}/{@code flowGuid})就是"本次意图/答卷只形成一个本地办件"的落点。 + * 事件先到时这里先有行({@code intentId} 为空=未关联投影),确认先到时 {@link FcApplyIntent} + * 先有行,两条路径都靠唯一键而不是时序收敛;没有已验证意图就不建件(A.5)。

+ * + *

三条不覆盖纪律:

+ * + * + *

与旧 {@code service_apply.form_data_json} 的关系:投影是数据面镜像, + * 办件是业务对象。建件时把快照值抄进 formDataJson 一次(保持既有详情/审批链读法不变), + * 此后投影更新不再回写办件,也不自动重发流程(C.4)。

+ */ +@Getter +@Setter +@Entity +@Table(name = "fc_answer_projection", + uniqueConstraints = { + @UniqueConstraint(name = "uk_projection_answer", columnNames = {"answer_uuid", "sch_id"}), + @UniqueConstraint(name = "uk_projection_intent", columnNames = "intent_id") + }, + indexes = { + @Index(name = "idx_projection_state", columnList = "sch_id, form_uuid, revision"), + @Index(name = "idx_projection_apply", columnList = "apply_id")}) +public class FcAnswerProjection extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 关联状态:只有快照、还没验证过意图(事件先到的正常形态) */ + public static final String LINK_UNLINKED = "UNLINKED"; + /** 已关联可信意图(可建件) */ + public static final String LINK_LINKED = "LINKED"; + /** 已建件 */ + public static final String LINK_APPLIED = "APPLIED"; + + @Column(name = "sch_id", nullable = false) + private Long schId; + + @Column(name = "answer_uuid", nullable = false, length = 32) + private String answerUuid; + + @Column(name = "form_uuid", nullable = false, length = 32) + private String formUuid; + + @Column(name = "form_version", nullable = false) + private Integer formVersion; + + /** 中心侧单调快照修订号 */ + @Column(name = "revision", nullable = false) + private Long revision; + + /** Local write generation; independent of FC answer revision, including same-revision repair. */ + @Version + @Column(name = "projection_version", nullable = false) + private Long projectionVersion; + + /** 中心 fc.canon.v1 哈希;本地复算值必须与它逐字一致才算同版同内容 */ + @Column(name = "content_hash", nullable = false, length = 64) + private String contentHash; + + /** 墓碑:1=答卷已软删(保留行与 revision,不清数据) */ + @Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint") + private Integer isDeleted = 0; + + // ===== 快照成员(中心给什么存什么,不加工) ===== + @Column(name = "app_code", length = 64) + private String appCode; + + @Column(name = "code", length = 64) + private String code; + + @Column(name = "submitter_id") + private Integer submitterId; + + @Column(name = "subject_id") + private Integer subjectId; + + @Column(name = "status", length = 64) + private String status; + + @Column(name = "values_json", columnDefinition = "LONGTEXT") + private String valuesJson; + + @Column(name = "answer_no", length = 64) + private String answerNo; + + @Column(name = "answer_create_time", length = 32) + private String answerCreateTime; + + @Column(name = "answer_update_time", length = 32) + private String answerUpdateTime; + + // ===== 关联列 ===== + /** 可信意图本地 id({@link FcApplyIntent#getId()});NULL=未关联 */ + @Column(name = "intent_id") + private Integer intentId; + + @Column(name = "apply_id") + private Integer applyId; + + @Column(name = "flow_guid", length = 32) + private String flowGuid; + + @Column(name = "link_state", nullable = false, length = 16) + private String linkState = LINK_UNLINKED; + + /** 同 revision 异 hash 的冲突留证(不覆盖原行,等人工/对账核验) */ + @Column(name = "conflict_flag", nullable = false, columnDefinition = "tinyint") + private Integer conflictFlag = 0; + + @Column(name = "conflict_detail", length = 500) + private String conflictDetail; + + /** 最近一次落库的事件 id(审计与回执上报的对应关系) */ + @Column(name = "last_event_id", length = 36) + private String lastEventId; + + /** 业务端扩展列:中心快照永不写(B.5"业务扩展单列/单表不被 FC 快照覆盖") */ + @Column(name = "biz_ext_json", columnDefinition = "LONGTEXT") + private String bizExtJson; +} diff --git a/src/main/java/com/yunhai/svchall/entity/FcApplyIntent.java b/src/main/java/com/yunhai/svchall/entity/FcApplyIntent.java new file mode 100644 index 0000000..543ce3e --- /dev/null +++ b/src/main/java/com/yunhai/svchall/entity/FcApplyIntent.java @@ -0,0 +1,138 @@ +package com.yunhai.svchall.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * 表单中心可信建件意图(answer-delivery-v2 §6,TASK_2 A.1/A.2)。 + * + *

为什么要这张表:托管填报里"用户想办这件事"(意图)与"答卷已提交"(事实)分处两个应用, + * 中间隔着 iframe、postMessage 与 MQ。没有一条本地持久记录,就只能信浏览器带来的 + * {@code answerId/bizId}——那正是任务书 A.3 末句禁止的"用浏览器传入 bizId 直接建件"。 + * 本行就是那条服务端事实:客户端只交事项坐标 + 幂等键,其余全由会话与远端收据填。

+ * + *

三条键各挡一类事故:

+ * + * + *

{@code contextToken} 是中心签发的短时能力串(默认 30 分钟、绑定本人本校本表本版本)。 + * 它必须由本应用交给被嵌页才能提交,因此在这里明文存(中心侧只存摘要,两边职责不同); + * 它不是身份凭据,任何跨人/跨校/跨版本使用都在中心被拒。

+ */ +@Getter +@Setter +@Entity +@Table(name = "fc_apply_intent", + uniqueConstraints = { + @UniqueConstraint(name = "uk_intent_client", + columnNames = {"sch_id", "submitter_id", "item_id", "client_key"}), + @UniqueConstraint(name = "uk_intent_fc", columnNames = {"consumer_app_key", "fc_intent_id"}), + @UniqueConstraint(name = "uk_intent_answer", columnNames = "answer_uuid"), + @UniqueConstraint(name = "uk_intent_apply", columnNames = "apply_id") + }, + indexes = { + @Index(name = "idx_intent_user", columnList = "sch_id, submitter_id, state"), + @Index(name = "idx_intent_expire", columnList = "state, expires_at")}) +public class FcApplyIntent extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 状态:已签发上下文,等待填报 */ + public static final String STATE_ISSUED = "ISSUED"; + /** 状态:中心收据确认答卷已提交(尚未建件或建件在途) */ + public static final String STATE_ANSWERED = "ANSWERED"; + /** 状态:本地办件已形成 */ + public static final String STATE_APPLIED = "APPLIED"; + /** 状态:令牌过期或答卷从未提交,终态(用户要办就重新发起新意图) */ + public static final String STATE_VOID = "VOID"; + + @Column(name = "sch_id", nullable = false) + private Long schId; + + @Column(name = "site_code", nullable = false, length = 64) + private String siteCode; + + @Column(name = "item_id", nullable = false) + private Integer itemId; + + @Column(name = "item_code", nullable = false, length = 64) + private String itemCode; + + @Column(name = "item_title", nullable = false, length = 200) + private String itemTitle; + + /** 提交者(会话推导,浏览器不可注入,A.1) */ + @Column(name = "submitter_id", nullable = false) + private Integer submitterId; + + @Column(name = "submitter_name", nullable = false, length = 128) + private String submitterName; + + /** 客户端幂等键(浏览器 uuid;uk_intent_client 的一部分) */ + @Column(name = "client_key", nullable = false, length = 64) + private String clientKey; + + /** 绑定所选表单版本(TASK_1 成对坐标,服务器侧已核验) */ + @Column(name = "fc_form_uuid", nullable = false, length = 32) + private String fcFormUuid; + + @Column(name = "fc_form_version", nullable = false) + private Integer fcFormVersion; + + /** 绑定流程线(发起前就判过就绪,失败不建件也不调流程) */ + @Column(name = "flow_guid", length = 32) + private String flowGuid; + + /** 目标应用身份(本应用出站 appKey;中心按它做归属判定) */ + @Column(name = "consumer_app_key", nullable = false, length = 64) + private String consumerAppKey; + + /** 送给中心的业务意图键(本行 uuid;同 intent 重试回到同一上下文) */ + @Column(name = "fc_intent_id", nullable = false, length = 64) + private String fcIntentId; + + @Column(name = "context_token", length = 64) + private String contextToken; + + /** 被嵌页精确 origin(中心签发时锁定的那一份,不是本地拼的) */ + @Column(name = "embed_origin", length = 200) + private String embedOrigin; + + @Column(name = "fill_page_url", length = 500) + private String fillPageUrl; + + @Column(name = "state", nullable = false, length = 16) + private String state = STATE_ISSUED; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; + + /** 中心收据确认回来的答卷坐标(原子关联的本地镜像) */ + @Column(name = "answer_uuid", length = 32) + private String answerUuid; + + @Column(name = "answer_revision") + private Long answerRevision; + + @Column(name = "content_hash", length = 64) + private String contentHash; + + /** 形成的本地办件(唯一:一次意图最多一个办件) */ + @Column(name = "apply_id") + private Integer applyId; + + @Column(name = "confirmed_at") + private LocalDateTime confirmedAt; +} diff --git a/src/main/java/com/yunhai/svchall/entity/FlowApplyCommand.java b/src/main/java/com/yunhai/svchall/entity/FlowApplyCommand.java new file mode 100644 index 0000000..e798b8b --- /dev/null +++ b/src/main/java/com/yunhai/svchall/entity/FlowApplyCommand.java @@ -0,0 +1,126 @@ +package com.yunhai.svchall.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * 办件出站发起命令(answer-delivery-v2 §8,TASK_2 C.1/C.2)。 + * + *

它把"代发起"从一段 afterCommit 闭包变成一个可恢复的持久事实。旧链路的形状是 + * 落库 → afterCommit 丢线程池 → HTTP → 窄列回填:进程在提交之后、HTTP 之前被杀, + * 那条"要发起"的意图就随内存一起没了,只剩一行 flow_status=0 的待提交办件等人点重试。 + * 现在命令先落库(与办件同一短事务),HTTP 在事务外跑,状态机留痕:

+ * + *
+ * PENDING ──发出──▶ SENT ──响应成功──▶ DONE
+ *                     │
+ *                     ├─超时/断连──▶ UNKNOWN ──查 FL 业务键──▶ DONE / 重发同业务键
+ *                     └─业务拒绝──▶ FAILED ──退避──▶ PENDING …… 耗尽──▶ DEAD
+ * 
+ * + *

{@code UNKNOWN} 是本表存在的核心理由:响应丢失不等于失败。恢复器先按 + * {@code (schId,bizType,bizId)} 查 FL,查到 10/20/30/40 都先回填真实状态—— + * 尤其 30/40 不许当成网络重试再 apply 一次(那会触发 FL 的 R4 重启推进,把已拒绝的办件又跑起来)。 + * 只有明确授权的用户重提才进 {@code attempt+1} 的新分支,带新的 {@code requestToken}, + * FL 侧据此区分"同一命令的重投"与"新一轮发起"。

+ * + *

{@code requestToken} 同值即同一次命令:FL 收到同 token 的重投只回既有办件、绝不重启推进。

+ */ +@Getter +@Setter +@Entity +@Table(name = "flow_apply_command", + uniqueConstraints = { + @UniqueConstraint(name = "uk_command_attempt", columnNames = {"apply_id", "attempt"}), + @UniqueConstraint(name = "uk_command_token", columnNames = "request_token") + }, + indexes = { + @Index(name = "idx_command_scan", columnList = "state, next_retry_at"), + @Index(name = "idx_command_biz", columnList = "sch_id, biz_type, biz_id")}) +public class FlowApplyCommand extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 待发出 */ + public static final String STATE_PENDING = "PENDING"; + /** 已发出、结果未知(含超时/断连,必须先查再决定) */ + public static final String STATE_UNKNOWN = "UNKNOWN"; + /** 远端已受理(含幂等命中),回填完成 */ + public static final String STATE_DONE = "DONE"; + /** 业务拒绝或发送异常,退避后仍可重试 */ + public static final String STATE_FAILED = "FAILED"; + /** 有界重试耗尽,留人工处置(不无限重投) */ + public static final String STATE_DEAD = "DEAD"; + + @Column(name = "apply_id", nullable = false) + private Integer applyId; + + @Column(name = "sch_id", nullable = false) + private Long schId; + + @Column(name = "site_code", nullable = false, length = 64) + private String siteCode; + + /** 业务类型键(与 FL uk 一致) */ + @Column(name = "biz_type", nullable = false, length = 32) + private String bizType; + + /** 业务键(=applyId,与旧链路逐字相同,回调解析不受影响) */ + @Column(name = "biz_id", nullable = false, length = 64) + private String bizId; + + /** 轮次:1=首次发起;驳回/撤回后经用户明确授权重提才 +1(C.2) */ + @Column(name = "attempt", nullable = false) + private Integer attempt = 1; + + /** 本次命令的请求标识:同 token 重投=同一命令,FL 不重启推进 */ + @Column(name = "request_token", nullable = false, length = 64) + private String requestToken; + + @Column(name = "flow_guid", nullable = false, length = 32) + private String flowGuid; + + /** 发起所需的全量出站载荷(快照,重放不依赖办件行后续被改) */ + @Column(name = "payload_json", nullable = false, columnDefinition = "LONGTEXT") + private String payloadJson; + + @Column(name = "state", nullable = false, length = 16) + private String state = STATE_PENDING; + + @Column(name = "retry_count", nullable = false) + private Integer retryCount = 0; + + /** 崩溃恢复与退避都读这一列(任务书 C.1"持久化 nextRetry") */ + @Column(name = "next_retry_at") + private LocalDateTime nextRetryAt; + + @Column(name = "remote_instance_id") + private Long remoteInstanceId; + + /** 查/发起得到的远端真实状态(回填镜像用;0 态本地不写) */ + @Column(name = "remote_status") + private Integer remoteStatus; + + @Column(name = "remote_version") + private Integer remoteVersion; + + @Column(name = "last_error", length = 500) + private String lastError; + + @Column(name = "sent_at") + private LocalDateTime sentAt; + + @Column(name = "settled_at") + private LocalDateTime settledAt; + + /** 可信建件意图(非 FC 链路的旧提交为 NULL,双轨只在新数据一侧写,不回填历史) */ + @Column(name = "intent_id") + private Integer intentId; +} diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceApply.java b/src/main/java/com/yunhai/svchall/entity/ServiceApply.java index dcd6282..53b577d 100644 --- a/src/main/java/com/yunhai/svchall/entity/ServiceApply.java +++ b/src/main/java/com/yunhai/svchall/entity/ServiceApply.java @@ -101,4 +101,18 @@ public class ServiceApply extends BaseEntity { /** 终态时间 */ @Column(name = "finish_time") private LocalDateTime finishTime; + + /** + * 可信建件意图 id({@code fc_apply_intent.id};NULL=旧链路/无表单事项的直接提交)。 + * + *

新数据一侧留痕用(任务书 A.5"新数据可追溯,不建旧数据迁移双轨"): + * 有它就说得出"这件是从哪次服务端意图、按中心哪一份收据建起来的"。 + * 关联的唯一性判据在意图侧 {@code uk_intent_apply},本列只是反向可查,不做约束。

+ */ + @Column(name = "fc_intent_id") + private Integer fcIntentId; + + /** 建件依据的答卷 uuid(表单中心侧对外身份;旧链路恒 NULL) */ + @Column(name = "fc_answer_uuid", length = 32) + private String fcAnswerUuid; } diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceItem.java b/src/main/java/com/yunhai/svchall/entity/ServiceItem.java index a457adc..c23da89 100644 --- a/src/main/java/com/yunhai/svchall/entity/ServiceItem.java +++ b/src/main/java/com/yunhai/svchall/entity/ServiceItem.java @@ -133,10 +133,27 @@ public class ServiceItem extends BaseEntity { @Column(name = "cover_image", length = 500) private String coverImage; - /** 绑定自定义表单 code(service_form.code;NULL=发起无需表单);发起时取最新已发布版本 */ + /** + * 绑定自定义表单 code(service_form.code;NULL=发起无需表单);发起时取最新已发布版本。 + * + *

本列已停止新写入(v3/TASK_1):表单定义归表单中心,事项只能绑 {@code fcFormUuid} + * 那组服务器验证过的坐标。存量行为双轨期的历史数据保留读路径(办件回显与 {@code effectiveFlowGuid} + * 仍认它),保存链路遇到"写一个不一样的 code"直接拒。

+ */ @Column(name = "form_code", length = 64) private String formCode; + /** 绑定表单中心表单 uuid(form-center 对外身份,32 位;NULL=未绑远端表单) */ + @Column(name = "fc_form_uuid", length = 32) + private String fcFormUuid; + + /** + * 绑定表单中心的确切版本(与 {@link #fcFormUuid} 成对,单传一个即拒)。 + * 存的是服务器侧校验过的版本号,不是前端自由输入的字符串。 + */ + @Column(name = "fc_form_version") + private Integer fcFormVersion; + /** 绑定流程中心流程(flw_def.flow_guid;NULL=纯指南事项不可在线办理) */ @Column(name = "flow_guid", length = 32) private String flowGuid; diff --git a/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java b/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java index ff20439..bde13e6 100644 --- a/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java +++ b/src/main/java/com/yunhai/svchall/job/FlowReconcileJob.java @@ -31,6 +31,9 @@ public class FlowReconcileJob { private final FlowClient flowClient; private final boolean reconcileEnabled; + @org.springframework.beans.factory.annotation.Autowired + private com.yunhai.svchall.repository.FlowApplyCommandRepository commandRepository; + public FlowReconcileJob(ServiceApplyRepository applyRepository, FlowClient flowClient, @Value("${svchall.flow.reconcile-enabled:false}") boolean reconcileEnabled) { this.applyRepository = applyRepository; @@ -51,6 +54,9 @@ public class FlowReconcileJob { org.springframework.data.domain.PageRequest.of(0, 500)); int fixed = 0; for (ServiceApply apply : pending) { + if (!commandRepository.findByApplyIdOrderByIdDesc(apply.getId()).isEmpty()) { + continue; // New command-backed applications are owned by FlowApplyRecoveryService. + } if (apply.getFlowInstanceId() == null) { continue; } diff --git a/src/main/java/com/yunhai/svchall/model/FcApplyModels.java b/src/main/java/com/yunhai/svchall/model/FcApplyModels.java new file mode 100644 index 0000000..193252b --- /dev/null +++ b/src/main/java/com/yunhai/svchall/model/FcApplyModels.java @@ -0,0 +1,273 @@ +package com.yunhai.svchall.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Getter; +import lombok.Setter; + +import javax.validation.constraints.NotBlank; +import java.util.ArrayList; +import java.util.List; + +/** + * 可信建件链路(意图 + 快照消费 + 出站命令)的出入参(TASK_2,answer-delivery-v2 §6/§7/§8)。 + * + *

为什么是一个独立模型类而不是往 {@code SvcModels} 里加字段(任务书约束原文): + * {@code SvcModels} 是 TASK_1 的写范围,且它是既有管理面契约的载体;这条新链的字段闭集 + * (意图键、令牌、信封、回执)与旧提交 DTO 的字段闭集是两件事,混在一起会让"浏览器能注入什么" + * 这个问题重新变成需要逐字段审查的问题。这里刻意分三段:

+ *
    + *
  1. 浏览器 → 本应用:只允许事项坐标 + 客户端幂等键({@link IntentCreateRequest}), + * 没有 submitterId/schId/flowGuid/answerUuid/bizId 位;
  2. + *
  3. 本应用 → 表单中心:签名服务端调用的请求/响应({@link ContextIssueRequest} 等), + * 字段与 FC 的 {@code AnswerDeliveryModels} 逐字对齐;
  4. + *
  5. 表单中心 → 本应用:MQ 信封与回执({@link SnapshotEnvelope}/{@link AppliedReport}), + * 反序列化按闭集,多一个字段不影响、少一个必填字段即拒。
  6. + *
+ */ +public final class FcApplyModels { + + /** v2 协议号与 exchange(与 FC 侧同值;两侧都是常量,不是配置) */ + public static final String PROTOCOL_VERSION = "fc.answer.snapshot.v2"; + public static final String EXCHANGE = "fc.answer.delivery.v2"; + + private FcApplyModels() { + } + + // ==================== ① 浏览器 → 本应用(CAS 会话) ==================== + + /** 创建意图:浏览器只准交事项坐标与自己的幂等键(A.1) */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class IntentCreateRequest { + @NotBlank(message = "siteCode 不得为空") + private String siteCode; + @NotBlank(message = "itemCode 不得为空") + private String itemCode; + /** 客户端幂等键(uuid;uk_intent_client 的一部分,重复 POST 回到同一意图) */ + @NotBlank(message = "clientKey 不得为空") + private String clientKey; + } + + /** 意图签发结果:被嵌页地址 + 令牌(令牌只在这条响应里过一次网络,且响应是给本人页面的) */ + @Getter + @Setter + public static class IntentView { + private Integer intentId; + private String fcIntentId; + private String state; + private String siteCode; + private String itemCode; + private String itemTitle; + private String fcFormUuid; + private Integer fcFormVersion; + private String flowGuid; + /** iframe 直接可用地址(FC 前端基址 + ctx 参数,服务端拼,不接受浏览器给的路径) */ + private String fillPageUrl; + private String contextToken; + private String expiresAt; + /** 同一 (校,人,事项,幂等键) 命中既有意图=true(不是静默新建第二个业务意图) */ + private Boolean reused; + } + + /** 确认结果:办件已形成(数据源是服务端收据,不是浏览器 postMessage 的内容) */ + @Getter + @Setter + public static class IntentConfirmView { + private Integer intentId; + private String state; + private String answerUuid; + private Long answerRevision; + private String contentHash; + private Integer applyId; + /** 办件镜像状态(0 待提交/10 审批中…),出站命令已持久化但 HTTP 可能仍在跑 */ + private Integer flowStatus; + private Boolean flowEnabled; + /** 还没拿到答卷时的原因(前端据此提示"填报尚未完成",而不是弹一个空成功) */ + private String message; + } + + // ==================== ② 本应用 → 表单中心(签名服务端调用) ==================== + + /** {@code POST /oapi/form/submission-context} 请求体 */ + @Getter + @Setter + public static class ContextIssueRequest { + private String intentId; + private Long consumerSchoolId; + private String formUuid; + private Integer formVersion; + private Integer submitterId; + } + + /** {@code POST /oapi/form/submission-context} 响应 data */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ContextIssueView { + private String intentId; + private String contextToken; + private String embedOrigin; + private String callbackUrl; + private String formUuid; + private Integer formVersion; + private String appCode; + private String code; + private String formName; + private String expiresAt; + private Boolean reused; + } + + /** {@code GET /oapi/form/submission-context/{intentId}/receipt} 响应 data */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ContextReceiptView { + private String status; + private String intentId; + private String consumerAppKey; + private Long consumerSchoolId; + private Integer submitterId; + private String formUuid; + private Integer formVersion; + private String answerUuid; + private Long answerRevision; + private String contentHash; + private String submittedAt; + private Boolean expired; + private Snapshot snapshot; + } + + // ==================== ③ 表单中心 → 本应用(MQ 信封 / HTTP 回执) ==================== + + /** v2 信封(字段闭集与 FC 侧逐字一致;camelCase 是契约原文) */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class SnapshotEnvelope { + private String protocolVersion; + private String eventId; + private String eventType; + private String sourceAppKey; + private String consumerAppKey; + private Long consumerSchoolId; + private Integer grantVersion; + private String formUuid; + private Integer formVersion; + private String answerUuid; + private Long answerRevision; + private Boolean isDeleted; + private String contentHash; + private String occurredAt; + private Snapshot snapshot; + } + + /** 快照(值文档全文在这里——消费端不回查答案值文档,B.6) */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Snapshot { + private Long schId; + private String appCode; + private String code; + private Integer submitterId; + private Integer subjectId; + private String status; + private JsonNode values; + private String answerNo; + private String createTime; + private String updateTime; + /** 仅对本 consumer 可见的意图关联(不参与 contentHash) */ + private JsonNode intent; + } + + /** intent 关联块(事件先到时据此认出这是哪次业务意图) */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class IntentLink { + private String intentId; + private String consumerAppKey; + private Long consumerSchoolId; + private Integer submitterId; + private Integer formVersion; + private Long linkedRevision; + } + + /** {@code POST /oapi/form/answer-delivery/receipt} 请求体(consumerApplied 的唯一来源) */ + @Getter + @Setter + public static class AppliedReport { + private String eventId; + private String consumerAppKey; + private Long consumerSchoolId; + private String answerUuid; + private Long answerRevision; + private String outcome; + private String contentHash; + private String localRef; + } + + /** 回执响应 data */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class AppliedReportView { + private String eventId; + private String state; + private Boolean duplicate; + private String brokerAcceptedAt; + private String consumerAppliedAt; + } + + /** {@code POST /oapi/form/answer-delivery/grants/verify} 请求体(逐批核验,不回查值文档) */ + @Getter + @Setter + public static class GrantVerifyRequest { + private Long consumerSchoolId; + private List formUuids = new ArrayList<>(); + } + + /** 授权快照核验响应元素(消费端带 TTL 缓存它) */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class GrantSnapshotView { + private String formUuid; + private Long consumerSchoolId; + private String consumerAppKey; + private Boolean subscribeAllowed; + private Integer grantVersion; + private String verifiedAt; + } + + // ==================== ④ 出站发起命令载荷(持久化后用,不再回读办件行) ==================== + + /** + * 代发起出站载荷(C.1):命令行里存的就是这一份,重放不依赖办件行后续被谁改过。 + * 字段与 FL {@code /oapi/flow/apply} 一致,另带 {@code requestToken}/{@code attempt}/ + * {@code retryOnly}——三者构成"同一次命令的重投"与"新一轮发起"的区分依据(C.3)。 + */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class FlowOutbound { + private String flowGuid; + private String bizType; + private String bizId; + private String bizTitle; + private String bizUrl; + private String bizSnapshot; + private Integer submitterId; + private String submitterName; + /** 表单数据 JSON 原文(FC 托管链路里来自服务端收据快照,不来自浏览器) */ + private String formDataJson; + /** 本次命令的请求标识(同值=同一命令重投,FL 不得重启推进) */ + private String requestToken; + /** 轮次(1 起;用户明确授权的重提才 +1) */ + private Integer attempt; + /** true=只补发"确实还没有办件"的情况,命中既有实例一律不改写(恢复链专用) */ + private Boolean retryOnly; + } +} diff --git a/src/main/java/com/yunhai/svchall/model/FormSyncModels.java b/src/main/java/com/yunhai/svchall/model/FormSyncModels.java new file mode 100644 index 0000000..b006735 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/model/FormSyncModels.java @@ -0,0 +1,215 @@ +package com.yunhai.svchall.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 对账协议载荷(本应用侧镜像,TASK_3)。 + * + *

独立 DTO:不往 {@code FcApplyModels}(TASK_2)与 {@code SvcModels}(TASK_1)里加字段。 + * 快照与信封仍复用 {@link FcApplyModels.SnapshotEnvelope}/{@link FcApplyModels.Snapshot}—— + * 修复入口 {@code repairAuthoritative} 收的就是那个类型,换一份定义等于换一套哈希口径。

+ * + *

{@code @JsonIgnoreProperties(ignoreUnknown=true)} 与各镜像同规:中心加字段不能把对端打挂, + * 但判据字段(存在性、缺席证明的四个坐标、清单结束标记)缺失一律按不可信处理, + * 由 Worker 显式判空,不靠 Jackson 兜底。

+ */ +public final class FormSyncModels { + + public static final String PROTOCOL = "fc.form-sync.v1"; + + public static final String PRESENCE_PRESENT = "PRESENT"; + public static final String PRESENCE_ABSENT = "ABSENT"; + public static final String PRESENCE_UNKNOWN = "UNKNOWN"; + + /** 差异方向/处置的稳定码:中心与本地都按它分支,文案可以改、码不许改 */ + public static final String DIFF_MISSING = "MISSING"; + public static final String DIFF_STALE = "STALE"; + public static final String DIFF_HASH_CONFLICT = "HASH_CONFLICT"; + public static final String DIFF_TOMBSTONE = "TOMBSTONE"; + public static final String DIFF_LOCAL_AHEAD = "LOCAL_AHEAD"; + public static final String DIFF_EXTRA_ROW = "EXTRA_ROW"; + public static final String DIFF_ADDED_AFTER_WINDOW = "ADDED_AFTER_WINDOW"; + public static final String DIFF_REVISION_ZERO = "REVISION_ZERO"; + + public static final String ERR_MANIFEST_INCOMPLETE = "MANIFEST_INCOMPLETE"; + public static final String ERR_GRANT_REVOKED = "GRANT_REVOKED"; + public static final String ERR_CANCELLED = "CANCELLED"; + public static final String ERR_PROOF_REJECTED = "PROOF_REJECTED"; + public static final String ERR_EXPECTED_REVISION_REQUIRED = "EXPECTED_REVISION_REQUIRED"; + public static final String ERR_REPAIR_FAILED = "REPAIR_FAILED"; + public static final String ERR_REPAIR_RETRY_EXHAUSTED = "REPAIR_RETRY_EXHAUSTED"; + public static final String ERR_CLIENT_UNAVAILABLE = "CLIENT_UNAVAILABLE"; + public static final String ERR_SCAN_STALLED = "SCAN_STALLED"; + public static final String ERR_SCAN_ABORTED = "SCAN_ABORTED"; + + private FormSyncModels() { + } + + /** 中心派发的指令(落库后才异步执行;字段缺失即拒收,不做"看起来能跑就跑") */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ReconcileInstruction { + private String protocol; + private String taskId; + private Integer attempt; + private String consumerAppKey; + private Long consumerSchoolId; + private String formUuid; + private Integer formVersion; + private String triggerType; + /** yyyy-MM-dd'T'HH:mm:ss(任务起点) */ + private String windowStart; + private String materializedAt; + private Integer totalRows; + private Integer totalPages; + private Integer pageSize; + private String issuedAt; + } + + /** 接收应答:2xx 只代表"指令已持久化、等执行",不代表任何修复结果 */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class InstructionAck { + private String taskId; + private Integer attempt; + private Boolean accepted; + private Boolean duplicate; + private String state; + private String message; + } + + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ManifestRowView { + private String answerUuid; + private Long revision; + private String contentHash; + private Boolean isDeleted; + private String formUuid; + private Integer formVersion; + } + + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ManifestPageView { + private String taskId; + private List rows = new ArrayList<>(); + private String nextCursor; + private Boolean completed; + private Integer totalRows; + private Integer totalPages; + private String taskState; + } + + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TaskAnswerReply { + private String taskId; + private String consumerAppKey; + private Long consumerSchoolId; + private String formUuid; + private Integer formVersion; + private String answerUuid; + private String presence; + private Integer grantVersion; + private FcApplyModels.SnapshotEnvelope envelope; + private AbsenceProof absenceProof; + private String reasonCode; + private String reason; + private String verifiedAt; + } + + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class AbsenceProof { + private String proofId; + private String taskId; + private String formUuid; + private Long schId; + private String answerUuid; + private Long expectedRevision; + private Integer grantVersion; + private String issuedAt; + } + + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ResultReport { + private String protocol = PROTOCOL; + private Integer attempt; + private String state; + private Boolean localScanComplete; + private Boolean manifestComplete; + private Integer scannedRows; + private Integer pagesProcessed; + private Integer missingRepaired; + private Integer staleRepaired; + private Integer hashConflicts; + private Integer tombstonesRepaired; + private Integer localAhead; + private Integer deferred; + private Integer extraReported; + private Integer extraRetired; + private Boolean resumed; + private String startedAt; + private List errors = new ArrayList<>(); + } + + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ReconcileError { + private String code; + private String taskId; + private String consumerAppKey; + private Long consumerSchoolId; + private String formUuid; + private String answerUuid; + private Long revision; + private String detail; + } + + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ResultAck { + private String taskId; + private Integer attempt; + private Boolean accepted; + private Boolean duplicate; + private Boolean staleAttempt; + private String taskState; + private String message; + } + + /** + * 待缺席答复的多余行候选(写进工作行的 {@code pending_extras_json})。 + * + *

{@code createTime} 是本地行的创建时刻——判"期间新增/未知不可退役"的唯一依据, + * 所以必须跟着候选一起持久化,重启续跑时不能凭记忆补。

+ */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ExtraCandidate { + private String answerUuid; + private Long revision; + private String contentHash; + private Integer isDeleted; + private LocalDateTime createTime; + } +} diff --git a/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java index a42e9c5..fd239c5 100644 --- a/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java +++ b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java @@ -166,12 +166,20 @@ public final class SvcModels { private Integer ruleCategoryId; @Size(max = 500) private String coverImage; - /** 绑定自定义表单 code(可空;发起无需表单) */ + /** + * 绑定自定义表单 code(已停止新写入,v3/TASK_1):编辑既有事项时原样回传=保留历史绑定, + * 写一个新值会被保存链路拒掉;新表单一律走 {@link #fcFormUuid} + {@link #fcFormVersion}。 + */ @Size(max = 64) private String formCode; /** 绑定流程中心流程 flow_guid(可空;onlineFlag=1 时必填) */ @Size(max = 32) private String flowGuid; + /** 绑定表单中心表单 uuid(与 fcFormVersion 成对;保存时由服务端向表单中心核验后才落库) */ + @Size(max = 32) + private String fcFormUuid; + /** 绑定表单中心的确切版本号(与 fcFormUuid 成对,单传一个即拒) */ + private Integer fcFormVersion; @Size(max = 500) private String applyUrl; // ===== 服务承诺(P1-1):管理端录入,全部可空 ===== @@ -226,6 +234,9 @@ public final class SvcModels { private String coverImage; private String formCode; private String flowGuid; + /** 表单中心绑定回显(成对:uuid + 确切版本) */ + private String fcFormUuid; + private Integer fcFormVersion; private String applyUrl; // ===== 服务承诺(P1-1):编辑页回显,与 SvcItemSaveRequest 同名 ===== private String coopDepts; @@ -268,6 +279,8 @@ public final class SvcModels { vo.setCoverImage(item.getCoverImage()); vo.setFormCode(item.getFormCode()); vo.setFlowGuid(item.getFlowGuid()); + vo.setFcFormUuid(item.getFcFormUuid()); + vo.setFcFormVersion(item.getFcFormVersion()); vo.setApplyUrl(item.getApplyUrl()); vo.setCoopDepts(item.getCoopDepts()); vo.setHandlerName(item.getHandlerName()); @@ -371,6 +384,25 @@ public final class SvcModels { } } + // ===== 表单中心目录(v3/TASK_1:管理面选择器的数据源,形状=FC GET /oapi/form/forms 的 data) ===== + + @Data + public static class SvcFormCatalogVo { + private String formUuid; + private String code; + private String name; + private String appCode; + /** 当前已发布版本:绑定要存的就是它(成对,不接受前端自由填版本) */ + private Integer version; + private List audience; + } + + @Data + public static class SvcFormCatalogPage { + private List items; + private long total; + } + // ===== 表单 ===== @Data diff --git a/src/main/java/com/yunhai/svchall/repository/AnswerReconcileWorkRepository.java b/src/main/java/com/yunhai/svchall/repository/AnswerReconcileWorkRepository.java new file mode 100644 index 0000000..c96008b --- /dev/null +++ b/src/main/java/com/yunhai/svchall/repository/AnswerReconcileWorkRepository.java @@ -0,0 +1,39 @@ +package com.yunhai.svchall.repository; + +import com.yunhai.svchall.entity.AnswerReconcileWork; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import javax.persistence.LockModeType; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 对账任务接收仓库(TASK_3)。 + * + *

领取用行锁(同 {@code FlowApplyCommandRepository} 先例):进程内多扫描线程、 + * 以及重启后新旧实例短暂并存时,只有一个能拿到 {@code QUEUED} 行。 + * 没有任何删除方法——本轮扫描与差异留痕要能在事后复盘。

+ */ +public interface AnswerReconcileWorkRepository extends JpaRepository { + + Optional findByTaskUuid(String taskUuid); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select w from AnswerReconcileWork w where w.id = :id") + Optional findByIdForUpdate(@Param("id") Integer id); + + /** 到点待执行(首执行与退避重试同一条队列) */ + @Query("select w from AnswerReconcileWork w where w.state = 'QUEUED' " + + "and (w.nextRetryAt is null or w.nextRetryAt <= :now) order by w.id asc") + List findDue(@Param("now") LocalDateTime now, Pageable pageable); + + /** 崩溃/重启留下的半成品:RUNNING 但租约已过——从水位续读,不从头再扫一遍 */ + @Query("select w from AnswerReconcileWork w where w.state = 'RUNNING' " + + "and (w.leaseUntil is null or w.leaseUntil <= :now) order by w.id asc") + List findStaleRunning(@Param("now") LocalDateTime now, Pageable pageable); +} diff --git a/src/main/java/com/yunhai/svchall/repository/FcAnswerInboxRepository.java b/src/main/java/com/yunhai/svchall/repository/FcAnswerInboxRepository.java new file mode 100644 index 0000000..acd1589 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/repository/FcAnswerInboxRepository.java @@ -0,0 +1,33 @@ +package com.yunhai.svchall.repository; + +import com.yunhai.svchall.entity.FcAnswerInbox; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 事件接收台账仓库(TASK_2 B.5/B.6)。 + * + *

两张台账类表都不提供删除方法:处置结论(含 DEFERRED/CONFLICT/REJECTED) + * 是排障与对账的唯一历史,回收由 DBA 侧的归档策略管,不由应用管。

+ */ +public interface FcAnswerInboxRepository extends JpaRepository { + + Optional findByEventIdAndConsumerAppKey(String eventId, String consumerAppKey); + + List findByAnswerUuidAndConsumerAppKeyOrderByIdAsc(String answerUuid, String consumerAppKey); + + /** 待上报中心的回执(含失败退避),按 next_report_at 取批 */ + @Query("select i from FcAnswerInbox i where i.reportState = 'PENDING' " + + "and (i.nextReportAt is null or i.nextReportAt <= :now) order by i.id asc") + List findDueReports(@Param("now") LocalDateTime now, Pageable pageable); + + /** 冲突/暂缓留证分页(运维面) */ + List findByConsumerSchoolIdAndStateInOrderByIdDesc( + Long consumerSchoolId, List states, Pageable pageable); +} diff --git a/src/main/java/com/yunhai/svchall/repository/FcAnswerProjectionRepository.java b/src/main/java/com/yunhai/svchall/repository/FcAnswerProjectionRepository.java new file mode 100644 index 0000000..33dd951 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/repository/FcAnswerProjectionRepository.java @@ -0,0 +1,70 @@ +package com.yunhai.svchall.repository; + +import com.yunhai.svchall.entity.FcAnswerProjection; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +/** + * 答卷投影仓库(TASK_2 A.5/B.5 + TASK_3 的 manifest 与缺席证明)。 + * + *

{@link #findByAnswerUuidAndSchId} 是唯一写入路径的锚点({@code uk_projection_answer}); + * {@link #findManifest} 只出 {@code (answer_uuid, revision, content_hash, is_deleted, sch_id, create_time)} 形态的 + * 对账清单(TASK_3 §manifest:比对存在性与版本,不拉值文档), + * 因此用轻量接口投影而不是实体,不读取值文档;游标按answerUuid继续。

+ */ +public interface FcAnswerProjectionRepository extends JpaRepository { + + Optional findByAnswerUuidAndSchId(String answerUuid, Long schId); + + Optional findByIntentId(Integer intentId); + + List findByApplyIdOrderByIdAsc(Integer applyId); + + /** 未关联且未建件的投影(事件先到的那批;确认链按答案反查它们) */ + List findBySchIdAndLinkStateAndIntentIdIsNullOrderByIdAsc( + Long schId, String linkState, Pageable pageable); + + @Query("select p.answerUuid as answerUuid, p.revision as revision, p.contentHash as contentHash, " + + "p.isDeleted as isDeleted, p.schId as schId, p.createTime as createTime from FcAnswerProjection p " + + "where p.schId = :schId and p.formUuid = :formUuid and p.answerUuid > :cursor order by p.answerUuid asc") + List findManifest(@Param("schId") Long schId, @Param("formUuid") String formUuid, + @Param("cursor") String cursor, Pageable pageable); + + /** Atomic repair: snapshot columns only; business links/extensions remain untouched. */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("update FcAnswerProjection p set p.revision = :#{#r.revision}, " + + "p.contentHash = :#{#r.contentHash}, p.formVersion = :#{#r.formVersion}, " + + "p.isDeleted = :#{#r.isDeleted}, p.appCode = :#{#r.appCode}, p.code = :#{#r.code}, " + + "p.submitterId = :#{#r.submitterId}, p.subjectId = :#{#r.subjectId}, " + + "p.status = :#{#r.status}, p.valuesJson = :#{#r.valuesJson}, p.answerNo = :#{#r.answerNo}, " + + "p.answerCreateTime = :#{#r.answerCreateTime}, p.answerUpdateTime = :#{#r.answerUpdateTime}, " + + "p.lastEventId = :#{#r.lastEventId}, p.conflictFlag = 0, p.conflictDetail = :#{#r.conflictDetail}, " + + "p.updateTime = :#{#r.updateTime}, p.projectionVersion = p.projectionVersion + 1 " + + "where p.answerUuid = :#{#r.answerUuid} and p.schId = :#{#r.schId} " + + "and p.formUuid = :#{#r.formUuid} and p.revision = :revision " + + "and p.contentHash = :hash and p.isDeleted = :deleted") + int repairIfUnchanged(@Param("r") FcAnswerProjection replacement, + @Param("revision") Long revision, @Param("hash") String hash, + @Param("deleted") Integer deleted); + + /** 对账清单行(摘要、租户域和本地创建时间;不含值文档) */ + interface ManifestRow { + String getAnswerUuid(); + + Long getRevision(); + + String getContentHash(); + + Integer getIsDeleted(); + + Long getSchId(); + + java.time.LocalDateTime getCreateTime(); + } +} diff --git a/src/main/java/com/yunhai/svchall/repository/FcApplyIntentRepository.java b/src/main/java/com/yunhai/svchall/repository/FcApplyIntentRepository.java new file mode 100644 index 0000000..421c1a4 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/repository/FcApplyIntentRepository.java @@ -0,0 +1,33 @@ +package com.yunhai.svchall.repository; + +import com.yunhai.svchall.entity.FcApplyIntent; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 可信建件意图仓库(TASK_2 A.1/A.2/A.5)。 + * + *

三条定位路径就是三条契约:客户端幂等键(重复 POST 回到同一意图)、 + * FC 意图键(收据确认与事件合并都按它)、答卷 uuid(事件先到时反查意图)。 + * 不提供删除:意图是审计链的一环,过期只用状态表达。

+ */ +public interface FcApplyIntentRepository extends JpaRepository { + + Optional findBySchIdAndSubmitterIdAndItemIdAndClientKey( + Long schId, Integer submitterId, Integer itemId, String clientKey); + + Optional findByConsumerAppKeyAndFcIntentId(String consumerAppKey, String fcIntentId); + + Optional findByAnswerUuid(String answerUuid); + + Optional findBySchIdAndId(Long schId, Integer id); + + /** 待填报意图(过期扫描候选,按 expires_at 升序取批) */ + List findByStateAndExpiresAtBefore(String state, LocalDateTime cutoff, Pageable pageable); + + List findBySchIdAndSubmitterIdOrderByIdDesc(Long schId, Integer submitterId, Pageable pageable); +} diff --git a/src/main/java/com/yunhai/svchall/repository/FlowApplyCommandRepository.java b/src/main/java/com/yunhai/svchall/repository/FlowApplyCommandRepository.java new file mode 100644 index 0000000..efb6a8d --- /dev/null +++ b/src/main/java/com/yunhai/svchall/repository/FlowApplyCommandRepository.java @@ -0,0 +1,41 @@ +package com.yunhai.svchall.repository; + +import com.yunhai.svchall.entity.FlowApplyCommand; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import javax.persistence.LockModeType; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 出站发起命令仓库(TASK_2 C.1/C.2)。 + * + *

{@link #findByIdForUpdate} 与既有 {@code ServiceApplyRepository#findByIdForUpdate} 同构: + * 调度线程池与恢复扫描可能同时盯上同一条命令,行锁把两者串起来,避免"两个线程各自以为 + * 自己是唯一发送者"而双发(FL 侧 uk 与 requestToken 是第二、第三道闸)。

+ */ +public interface FlowApplyCommandRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select c from FlowApplyCommand c where c.id = :id") + Optional findByIdForUpdate(@Param("id") Integer id); + + Optional findByApplyIdAndAttempt(Integer applyId, Integer attempt); + + List findByApplyIdOrderByIdDesc(Integer applyId); + + Optional findByRequestToken(String requestToken); + + /** 到点待处理:PENDING 首发/重试 + UNKNOWN 复查(两者都必须先想"远端可能已经收到了") */ + @Query("select c from FlowApplyCommand c where c.state in ('PENDING', 'UNKNOWN', 'FAILED') " + + "and (c.nextRetryAt is null or c.nextRetryAt <= :now) order by c.id asc") + List findDue(@Param("now") LocalDateTime now, Pageable pageable); + + /** 崩溃恢复:一条办件是否还有未落定的命令 */ + boolean existsByApplyIdAndStateIn(Integer applyId, List states); +} diff --git a/src/main/java/com/yunhai/svchall/repository/ServiceApplyRepository.java b/src/main/java/com/yunhai/svchall/repository/ServiceApplyRepository.java index 056620e..c1b96ee 100644 --- a/src/main/java/com/yunhai/svchall/repository/ServiceApplyRepository.java +++ b/src/main/java/com/yunhai/svchall/repository/ServiceApplyRepository.java @@ -65,4 +65,27 @@ public interface ServiceApplyRepository extends JpaRepository先回填真实状态)。 + * + *

与 {@link #markApproving} 的分工:那条是"发起成功的正常回填"(终态不回退); + * 这条是"以中心为权威的漂移修正"(同 {@code FlowReconcileJob} 的既有口径), + * 因此允许把本地 0/10 修正成远端的 20/30/40,但版本低于已消费版本时不动—— + * 回调可能已经把镜像推得比查询结果更新,倒着改会把已落定的终态打回。

+ * + *

30/40 走这里回填而不再发一次 apply:R4 的重启推进只能由用户明确授权的重提触发, + * 把"网络结果未知"当成重提会静默重启一条已被拒绝/撤回的流程。

+ */ + @Modifying + @Query("update ServiceApply a set a.flowInstanceId = coalesce(:instanceId, a.flowInstanceId)," + + " a.flowStatus = :status," + + " a.lastInstanceVersion = coalesce(:version, a.lastInstanceVersion)," + + " a.submitTime = coalesce(a.submitTime, :now)," + + " a.finishTime = (case when :status in (20, 30, 40) then :now else null end)" + + " where a.id = :id and (a.lastInstanceVersion is null or :version is null" + + " or :version >= a.lastInstanceVersion)") + int mirrorRemoteState(@Param("id") Integer id, @Param("instanceId") Long instanceId, + @Param("status") Integer status, @Param("version") Integer version, + @Param("now") java.time.LocalDateTime now); } diff --git a/src/main/java/com/yunhai/svchall/service/FlowClient.java b/src/main/java/com/yunhai/svchall/service/FlowClient.java index 117fc7f..095ee11 100644 --- a/src/main/java/com/yunhai/svchall/service/FlowClient.java +++ b/src/main/java/com/yunhai/svchall/service/FlowClient.java @@ -38,6 +38,24 @@ public interface FlowClient { /** 按业务键查办件(对账兜底;返回 {instanceId,status,version,nodeSummary,traceJson},查不到返回 null) */ Map getInstanceByBiz(Long schId, String bizType, String bizId); + /** + * 代发起(带请求标识与轮次守卫,TASK_2 C.2/C.3,走 {@code /oapi/flow/apply/v2})。 + * + *

与旧 {@link #apply} 的差别只有一件事:同 {@code requestToken} 的重投在中心被判定为 + * 同一次命令,命中既有实例时一律回读现状、不重启推进;只有轮次更高(=用户明确授权的重提) + * 才走既有 R4 重启语义。恢复链必须先查后发,就是因为旧签名分不清"重投"与"新一轮"。

+ * + *

默认实现回落旧签名(未接 v2 端点的环境保持既有行为,不假装带了守卫); + * {@code FlowCenterClient} 覆写为真实出站。用 default 而非改抽象签名:既有实现与测试桩 + * 不受影响(同 {@code getInstanceByBiz} 的增量方式)。

+ */ + default ApplyResult applyWithIdentity(Long schId, + com.yunhai.svchall.model.FcApplyModels.FlowOutbound outbound) { + return apply(schId, outbound.getFlowGuid(), outbound.getBizType(), outbound.getBizId(), + outbound.getBizTitle(), outbound.getBizUrl(), outbound.getBizSnapshot(), + outbound.getSubmitterId(), outbound.getSubmitterName(), outbound.getFormDataJson()); + } + /** 注册回调(POST /oapi/flow/callback/reg)→ 下发 secret */ String registerCallback(Long schId, String flowGuid, String callbackUrl, String eventTypes); diff --git a/src/main/java/com/yunhai/svchall/service/impl/AnswerProjectionStore.java b/src/main/java/com/yunhai/svchall/service/impl/AnswerProjectionStore.java new file mode 100644 index 0000000..68bc205 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/AnswerProjectionStore.java @@ -0,0 +1,717 @@ +package com.yunhai.svchall.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.svchall.entity.FcAnswerInbox; +import com.yunhai.svchall.entity.FcAnswerProjection; +import com.yunhai.svchall.entity.FcApplyIntent; +import com.yunhai.svchall.model.FcApplyModels; +import com.yunhai.svchall.model.FcApplyModels.GrantSnapshotView; +import com.yunhai.svchall.model.FcApplyModels.Snapshot; +import com.yunhai.svchall.model.FcApplyModels.SnapshotEnvelope; +import com.yunhai.svchall.repository.FcAnswerInboxRepository; +import com.yunhai.svchall.repository.FcAnswerProjectionRepository; +import com.yunhai.svchall.repository.FcApplyIntentRepository; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 答卷投影写入与可信关联合并(answer-delivery-v2 §7,TASK_2 A.5/B.5/B.6 + TASK_3 扩展点)。 + * + *

顺序不敏感是这张表的核心设计:MQ 事件与 SH 确认谁先到都只有一条规则—— + * 投影行按 {@code (answer_uuid, sch_id)} 唯一,意图行按自身唯一,两边靠 + * {@code intent_id}/{@code answer_uuid} 互相指认,撞唯一键就是"已经合过"。 + * 因此不存在"先到的一方先建件、后到的一方再建一次"的窗口, + * 也没有已验证意图就不建件(A.5):只有事件先到时,这里落一行 {@code UNLINKED} 投影就停下。

+ * + *

三条"不许悄悄改":

+ *
    + *
  1. 低 revision 一律忽略;同 revision 同 hash 幂等;同 revision 异 hash 记冲突不覆盖 + * (覆盖就等于用一条消息否定另一条,而这正是需要人来判的情形);
  2. + *
  3. 墓碑只是更高 revision 的一种取值,本地保留 revision;旧 {@code created} 复活不了它;
  4. + *
  5. {@code bizExtJson}(业务端扩展)不在快照成员里,任何路径都不写它。
  6. + *
+ * + *

授权缓存与撤权竞态(v2 §5,必须照实说):写投影前按 formUuid 查一张带 TTL 的 + * 授权快照({@code grants/verify},不回查答案值文档)。缓存命中期间撤权是不可见的—— + * 这条链的生效边界是"最多 {@code grant-cache-ttl-seconds} 秒的滞后", + * 不能描述成即时、全局强一致撤回;核验不可达时按 {@code DEFERRED} 暂缓写入而不是放行。 + * 已在合法授权期间交出去的明文副本不做远程收回保证。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AnswerProjectionStore { + + /** 落定结论(消费端据此 ack/nack 与回执上报) */ + public enum Outcome { + /** 投影已写入或已确认无需写,且 inbox 已落库(可 ack) */ + APPLIED, + /** 授权无法确认,暂缓(可 ack,行留 DEFERRED 等复查) */ + DEFERRED, + /** 信封不合法(协议/坐标/哈希闭集不符):毒消息,ack 后由死信/台账处置 */ + REJECTED + } + + private final FcAnswerProjectionRepository projectionRepository; + private final FcAnswerInboxRepository inboxRepository; + private final FcApplyIntentRepository intentRepository; + private final FcSubmissionClient submissionClient; + private final ServiceApplyService applyService; + private final ObjectMapper objectMapper; + + /** 本应用的消费 appKey(与 yh.app-auth.app-key 同源;信封目标必须等于它) */ + @Value("${yh.app-auth.app-key:100018}") + private String consumerAppKey; + + /** 授权快照缓存 TTL(秒);0=每封都查(保守但慢) */ + @Value("${svchall.fc.grant-cache-ttl-seconds:120}") + private int grantCacheTtlSeconds = 120; + + private final Map grantCache = new ConcurrentHashMap<>(); + + /** 处置结果(回执上报所需坐标都在这里) */ + @Getter + @RequiredArgsConstructor + public static class Disposition { + private final Outcome outcome; + private final String eventId; + private final String state; + private final Long answerRevision; + private final String contentHash; + private final Integer applyId; + private final String detail; + + public boolean shouldReport() { + return eventId != null && !FcAnswerInbox.STATE_REJECTED.equals(state); + } + } + + // ==================== 主入口(TASK_3 也调这一条) ==================== + + /** + * 应用一封 v2 快照信封。整段在一个事务里:inbox 行 + 投影行 + 意图关联/建件同生同死, + * DB 提交成功才由消费侧 manual ack(B.5);抛异常则 nack,broker 侧有界重试进死信。 + */ + @Transactional + public Disposition applySnapshot(SnapshotEnvelope envelope) { + String violation = validate(envelope); + if (violation != null) { + log.warn("[fc-projection] 信封校验失败(丢弃并留档):{} event={}", violation, + envelope == null ? null : envelope.getEventId()); + return new Disposition(Outcome.REJECTED, envelope == null ? null : envelope.getEventId(), + FcAnswerInbox.STATE_REJECTED, null, null, null, violation); + } + Optional repeat = inboxRepository.findByEventIdAndConsumerAppKey( + envelope.getEventId(), consumerApp()); + if (repeat.isPresent() && !FcAnswerInbox.STATE_DEFERRED.equals(repeat.get().getState())) { + // 同一封重投:不再写投影,回执补发一次也无妨(中心侧按 eventId 幂等) + FcAnswerInbox prior = repeat.get(); + log.info("[fc-projection] 事件重复投递命中台账:eventId={} state={}", prior.getEventId(), + prior.getState()); + return new Disposition(Outcome.APPLIED, prior.getEventId(), prior.getState(), + prior.getRevision(), prior.getContentHash(), applyIdOf(prior.getAnswerUuid()), null); + } + + if (!grantAllows(envelope)) { + FcAnswerInbox deferred = inboxRow(envelope, FcAnswerInbox.STATE_DEFERRED, + "授权快照当前无法确认或纪元不符(暂缓写入,v2 §5)"); + inboxRepository.save(deferred); + return new Disposition(Outcome.DEFERRED, envelope.getEventId(), FcAnswerInbox.STATE_DEFERRED, + envelope.getAnswerRevision(), envelope.getContentHash(), null, "授权待确认"); + } + + Snapshot snapshot = envelope.getSnapshot(); + String recomputed = canonicalHash(snapshot); + if (!recomputed.equalsIgnoreCase(envelope.getContentHash())) { + FcAnswerInbox rejected = inboxRow(envelope, FcAnswerInbox.STATE_REJECTED, + "信封 contentHash 与本地按 fc.canon.v1 复算不符(期望 " + envelope.getContentHash() + + ",实算 " + recomputed + ")"); + inboxRepository.save(rejected); + return new Disposition(Outcome.REJECTED, envelope.getEventId(), FcAnswerInbox.STATE_REJECTED, + envelope.getAnswerRevision(), envelope.getContentHash(), null, "hash 复算不符"); + } + + FcAnswerProjection projection = upsertProjection(envelope, snapshot, recomputed); + Integer intentId = linkIntent(envelope, snapshot, projection); + Integer applyId = projection.getApplyId(); + if (intentId != null && applyId == null) { + applyId = formApply(projection, intentId); + if (applyId != null) { + projection.setApplyId(applyId); + projection.setLinkState(FcAnswerProjection.LINK_APPLIED); + projectionRepository.save(projection); + } + } + String state = projection.getConflictFlag() != null && projection.getConflictFlag() == 1 + ? FcAnswerInbox.STATE_CONFLICT + : (isNoop(envelope, projection) ? FcAnswerInbox.STATE_IGNORED : FcAnswerInbox.STATE_APPLIED); + FcAnswerInbox inbox = inboxRepository.save(repeat.map(existing -> { + existing.setState(state); + existing.setDetail(null); + return existing; + }).orElseGet(() -> inboxRow(envelope, state, null))); + log.info("[fc-projection] 落库 answer={} rev={} state={} intent={} apply={} tomb={}", + envelope.getAnswerUuid(), envelope.getAnswerRevision(), state, projection.getIntentId(), + applyId, envelope.getIsDeleted()); + return new Disposition(Outcome.APPLIED, envelope.getEventId(), state, envelope.getAnswerRevision(), + envelope.getContentHash(), applyId, inbox.getDetail()); + } + + /** Local keyset page; not a frozen manifest. TASK_3 must materialize its own scan/checkpoint. */ + public ManifestPage manifestPage(Long schId, String formUuid, String cursor, Integer size) { + if (schId == null || schId <= 0 || blank(formUuid)) { + throw new BizException(ErrorCode.PARAM_ERROR, "schId 与 formUuid 必填"); + } + if (cursor != null && !cursor.isEmpty() && !cursor.matches("[a-fA-F0-9]{32}")) { + throw new BizException(ErrorCode.PARAM_ERROR, "cursor 必须为上一页返回的答卷 UUID"); + } + int pageSize = size == null || size < 1 ? 200 : Math.min(size, 500); + List fetched = projectionRepository.findManifest( + schId, formUuid.trim(), cursor == null ? "" : cursor, PageRequest.of(0, pageSize + 1)); + boolean completed = fetched.size() <= pageSize; + List rows = new ArrayList<>( + fetched.subList(0, Math.min(pageSize, fetched.size()))); + return new ManifestPage(Collections.unmodifiableList(rows), + completed ? null : rows.get(rows.size() - 1).getAnswerUuid(), completed); + } + + @Getter + @RequiredArgsConstructor + public static class ManifestPage { + private final List rows; + private final String nextCursor; + private final boolean completed; + } + + public enum RepairResult { APPLIED, LOCAL_CHANGED, LOCAL_AHEAD, DEFERRED } + + /** + * Server-only TASK_3 repair. Caller must obtain the CURRENT snapshot through the signed FC + * task endpoint and verify task/tenant/form/consumer coordinates before calling. proofId is + * an audit reference, NOT authentication; never bind this method directly to a browser body. + * expected* are the local scan tuple; all null means observed absence. No inbox receipt, + * intent association, business creation or flow command is produced here. + */ + @Transactional + public RepairResult repairAuthoritative(SnapshotEnvelope envelope, Long expectedRevision, + String expectedHash, Integer expectedIsDeleted, String proofId) { + String violation = validate(envelope); + if (violation != null || blank(proofId) || proofId.length() > 128 + || !consumerApp().equals(envelope.getConsumerAppKey()) + || envelope.getEventId().length() > 36 + || envelope.getSnapshot().getSchId() == null || envelope.getIsDeleted() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "权威修复证明或快照不完整:" + violation); + } + boolean absent = expectedRevision == null && expectedHash == null && expectedIsDeleted == null; + if (!absent && (expectedRevision == null || expectedRevision < 1 || blank(expectedHash) + || expectedIsDeleted == null || (expectedIsDeleted != 0 && expectedIsDeleted != 1))) { + throw new BizException(ErrorCode.PARAM_ERROR, "本地期望 revision/hash/isDeleted 必须成组提交"); + } + String hash = canonicalHash(envelope.getSnapshot()); + if (!hash.equalsIgnoreCase(envelope.getContentHash())) { + throw new BizException(ErrorCode.PARAM_ERROR, "权威快照 hash 复算不符"); + } + // Reconciliation must not use the MQ TTL cache or accept an unknown future grant epoch. + if (!grantAllows(envelope, true)) { + return RepairResult.DEFERRED; + } + FcAnswerProjection existing = projectionRepository.findByAnswerUuidAndSchId( + envelope.getAnswerUuid(), envelope.getConsumerSchoolId()).orElse(null); + if (existing != null && existing.getRevision() > envelope.getAnswerRevision()) { + return RepairResult.LOCAL_AHEAD; + } + if ((existing == null) != absent || (existing != null + && (!envelope.getFormUuid().equals(existing.getFormUuid()) + || !expectedRevision.equals(existing.getRevision()) + || !expectedHash.equals(existing.getContentHash()) + || !expectedIsDeleted.equals(existing.getIsDeleted())))) { + return RepairResult.LOCAL_CHANGED; + } + FcAnswerProjection replacement = new FcAnswerProjection(); + replacement.setSchId(envelope.getConsumerSchoolId()); + replacement.setAnswerUuid(envelope.getAnswerUuid()); + replacement.setFormUuid(envelope.getFormUuid()); + replacement.setFormVersion(envelope.getFormVersion()); + replacement.setRevision(envelope.getAnswerRevision()); + replacement.setContentHash(hash); + replacement.setIsDeleted(Boolean.TRUE.equals(envelope.getIsDeleted()) ? 1 : 0); + replacement.setLastEventId(envelope.getEventId()); + replacement.setConflictFlag(0); + replacement.setConflictDetail(truncate("权威对账修复 proofId=" + proofId + + " previous=" + expectedRevision + "/" + expectedHash + "/" + expectedIsDeleted + + " current=" + envelope.getAnswerRevision() + "/" + hash)); + replacement.setUpdateTime(LocalDateTime.now()); + copySnapshot(replacement, envelope.getSnapshot()); + if (existing == null) { + replacement.setLinkState(FcAnswerProjection.LINK_UNLINKED); + // Concurrent insert is rejected by uk_projection_answer; caller retries after rollback. + projectionRepository.saveAndFlush(replacement); + return RepairResult.APPLIED; + } + return projectionRepository.repairIfUnchanged(replacement, expectedRevision, expectedHash, + expectedIsDeleted) == 1 ? RepairResult.APPLIED : RepairResult.LOCAL_CHANGED; + } + + /** + * 权威缺席证明(TASK_3):中心清单确认"这份答卷已不存在/不应再在本地保留"时, + * 本地只做退役标记({@code isDeleted=1} + 留证),不物理删、不改 revision—— + * 物理删除会连带丢掉冲突排查的历史,而这一步的正当性永远来自中心的权威清单而非本地判断。 + */ + @Transactional + public int retireAbsent(AuthoritativeAbsenceProof proof) { + if (proof == null || proof.getAnswerUuid() == null || proof.getSchId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺席证明缺少答卷坐标或租户域"); + } + FcAnswerProjection projection = projectionRepository + .findByAnswerUuidAndSchId(proof.getAnswerUuid(), proof.getSchId()).orElse(null); + if (projection == null) { + return 0; + } + if (proof.getExpectedRevision() != null && !proof.getExpectedRevision().equals(projection.getRevision())) { + // 版本不一致=中心那份清单不是针对本地这版开的,retire 反而制造新的不一致 + projection.setConflictFlag(1); + projection.setConflictDetail(truncate("缺席证明 revision=" + proof.getExpectedRevision() + + " 与本地 " + projection.getRevision() + " 不符,未退役")); + projectionRepository.save(projection); + return 0; + } + projection.setIsDeleted(1); + projection.setConflictDetail(truncate("按权威缺席证明退役:proofId=" + proof.getProofId())); + projectionRepository.save(projection); + log.info("[fc-projection] 按缺席证明退役本地投影 answer={} proofId={}", proof.getAnswerUuid(), + proof.getProofId()); + return 1; + } + + // ==================== 内部 ==================== + + private FcAnswerProjection upsertProjection(SnapshotEnvelope envelope, Snapshot snapshot, String hash) { + FcAnswerProjection projection = projectionRepository + .findByAnswerUuidAndSchId(envelope.getAnswerUuid(), envelope.getConsumerSchoolId()) + .orElse(null); + boolean tombstone = Boolean.TRUE.equals(envelope.getIsDeleted()); + if (projection == null) { + projection = new FcAnswerProjection(); + projection.setSchId(envelope.getConsumerSchoolId()); + projection.setAnswerUuid(envelope.getAnswerUuid()); + projection.setFormUuid(envelope.getFormUuid()); + projection.setFormVersion(envelope.getFormVersion()); + projection.setRevision(envelope.getAnswerRevision()); + projection.setContentHash(hash); + projection.setIsDeleted(tombstone ? 1 : 0); + copySnapshot(projection, snapshot); + projection.setLastEventId(envelope.getEventId()); + return projectionRepository.save(projection); + } + long stored = projection.getRevision() == null ? 0L : projection.getRevision(); + if (envelope.getAnswerRevision() < stored) { + log.info("[fc-projection] 低 revision 忽略:answer={} incoming={} stored={}", + envelope.getAnswerUuid(), envelope.getAnswerRevision(), stored); + return projection; + } + if (envelope.getAnswerRevision() == stored) { + if (!hash.equalsIgnoreCase(projection.getContentHash())) { + projection.setConflictFlag(1); + projection.setConflictDetail(truncate("同 revision 异 hash:stored=" + + projection.getContentHash() + " incoming=" + hash + " event=" + envelope.getEventId())); + log.error("[fc-projection] 同修订号两份内容(不覆盖,待核验):answer={} rev={} event={}", + envelope.getAnswerUuid(), stored, envelope.getEventId()); + } + return projectionRepository.save(projection); + } + projection.setRevision(envelope.getAnswerRevision()); + projection.setContentHash(hash); + projection.setFormUuid(envelope.getFormUuid()); + projection.setFormVersion(envelope.getFormVersion()); + projection.setIsDeleted(tombstone ? 1 : 0); + copySnapshot(projection, snapshot); + projection.setLastEventId(envelope.getEventId()); + return projectionRepository.save(projection); + } + + private void copySnapshot(FcAnswerProjection projection, Snapshot snapshot) { + if (snapshot == null) { + return; + } + projection.setAppCode(snapshot.getAppCode()); + projection.setCode(snapshot.getCode()); + projection.setSubmitterId(snapshot.getSubmitterId()); + projection.setSubjectId(snapshot.getSubjectId()); + projection.setStatus(snapshot.getStatus()); + projection.setValuesJson(snapshot.getValues() == null ? "{}" : snapshot.getValues().toString()); + projection.setAnswerNo(snapshot.getAnswerNo()); + projection.setAnswerCreateTime(snapshot.getCreateTime()); + projection.setAnswerUpdateTime(snapshot.getUpdateTime()); + } + + /** 事件侧带来的 intent 关联 → 本地意图行;同时把答卷坐标写回意图(A.5 的"最终合并") */ + private Integer linkIntent(SnapshotEnvelope envelope, Snapshot snapshot, FcAnswerProjection projection) { + JsonNode intentNode = snapshot == null ? null : snapshot.getIntent(); + if (intentNode == null || intentNode.isNull()) { + return projection.getIntentId(); + } + String fcIntentId = intentNode.path("intentId").asText(null); + if (fcIntentId == null || fcIntentId.trim().isEmpty()) { + return projection.getIntentId(); + } + FcApplyIntent intent = intentRepository + .findByConsumerAppKeyAndFcIntentId(consumerApp(), fcIntentId.trim()).orElse(null); + if (intent == null) { + log.info("[fc-projection] 事件带来的意图本地还没有(先落未关联投影,等确认链合并):fcIntentId={}", + fcIntentId); + return projection.getIntentId(); + } + if (intent.getAnswerUuid() == null) { + intent.setAnswerUuid(envelope.getAnswerUuid()); + intent.setAnswerRevision(envelope.getAnswerRevision()); + intent.setContentHash(envelope.getContentHash()); + if (FcApplyIntent.STATE_ISSUED.equals(intent.getState())) { + intent.setState(FcApplyIntent.STATE_ANSWERED); + intent.setConfirmedAt(LocalDateTime.now()); + } + intentRepository.save(intent); + } + if (projection.getIntentId() == null || !projection.getIntentId().equals(intent.getId())) { + projection.setIntentId(intent.getId()); + projection.setFlowGuid(intent.getFlowGuid()); + projection.setLinkState(FcAnswerProjection.LINK_LINKED); + projectionRepository.save(projection); + } + return intent.getId(); + } + + /** 有已验证意图才建件;一次意图只一个办件(重复走查由意图行的唯一键挡在前面) */ + private Integer formApply(FcAnswerProjection projection, Integer intentId) { + FcApplyIntent intent = intentRepository.findBySchIdAndId(projection.getSchId(), intentId).orElse(null); + if (intent == null) { + return null; + } + if (intent.getApplyId() != null) { + return intent.getApplyId(); + } + if (Integer.valueOf(1).equals(projection.getIsDeleted())) { + log.warn("[fc-projection] 墓碑答卷不建件(C.4:墓碑阻止新的关联/办理输入):answer={} intent={}", + projection.getAnswerUuid(), intent.getId()); + return null; + } + Integer applyId = applyService.createFromVerifiedIntent(intent, projection); + log.info("[fc-projection] 可信建件完成:intent={} answer={} apply={}", intent.getId(), + projection.getAnswerUuid(), applyId); + return applyId; + } + + private Integer applyIdOf(String answerUuid) { + if (answerUuid == null) { + return null; + } + FcApplyIntent intent = intentRepository.findByAnswerUuid(answerUuid).orElse(null); + return intent == null ? null : intent.getApplyId(); + } + + private boolean isNoop(SnapshotEnvelope envelope, FcAnswerProjection projection) { + return projection.getRevision() != null && !projection.getRevision().equals(envelope.getAnswerRevision()); + } + + /** 授权闸门:先查 TTL 缓存,未命中就签名核验;拿不到结论=暂缓 */ + private boolean grantAllows(SnapshotEnvelope envelope) { + return grantAllows(envelope, false); + } + + private boolean grantAllows(SnapshotEnvelope envelope, boolean fresh) { + String key = envelope.getFormUuid() + "@" + envelope.getConsumerSchoolId(); + CachedGrant cached = grantCache.get(key); + if (!fresh && cached != null && !cached.expired(grantCacheTtlSeconds)) { + return cached.allows(envelope.getGrantVersion()); + } + List views; + try { + views = submissionClient.verifyGrants(envelope.getConsumerSchoolId(), + Collections.singletonList(envelope.getFormUuid())); + } catch (Exception e) { + log.warn("[fc-projection] 授权核验不可达,暂缓写入:form={} cause={}", envelope.getFormUuid(), + e.toString()); + return false; + } + CachedGrant resolved = null; + for (GrantSnapshotView view : views) { + if (fresh && (!envelope.getConsumerSchoolId().equals(view.getConsumerSchoolId()) + || !consumerApp().equals(view.getConsumerAppKey()))) { + continue; + } + if (envelope.getFormUuid().equals(view.getFormUuid())) { + resolved = new CachedGrant(Boolean.TRUE.equals(view.getSubscribeAllowed()), + view.getGrantVersion() == null ? 0 : view.getGrantVersion(), LocalDateTime.now()); + } + } + if (resolved == null) { + log.warn("[fc-projection] 授权核验无该表单结论,暂缓写入:form={}", envelope.getFormUuid()); + return false; + } + grantCache.put(key, resolved); + return resolved.allows(envelope.getGrantVersion()) + && (!fresh || resolved.grantVersion == envelope.getGrantVersion()); + } + + private String consumerApp() { + return consumerAppKey == null ? "" : consumerAppKey.trim(); + } + + // ==================== 校验与哈希 ==================== + + private String validate(SnapshotEnvelope envelope) { + if (envelope == null) { + return "空信封"; + } + if (!FcApplyModels.PROTOCOL_VERSION.equals(envelope.getProtocolVersion())) { + return "协议号不符:" + envelope.getProtocolVersion(); + } + if (blank(envelope.getEventId()) || blank(envelope.getAnswerUuid()) || blank(envelope.getFormUuid())) { + return "eventId/answerUuid/formUuid 必填"; + } + if (envelope.getAnswerRevision() == null || envelope.getAnswerRevision() < 1) { + return "answerRevision 必须是 ≥1 的单调修订号"; + } + if (envelope.getFormVersion() == null || envelope.getFormVersion() < 1) { + return "formVersion 必须是确切版本号"; + } + if (envelope.getConsumerSchoolId() == null || envelope.getConsumerSchoolId() <= 0) { + return "consumerSchoolId 必须是真实学校"; + } + if (!blank(envelope.getConsumerAppKey()) && !consumerApp().equals(envelope.getConsumerAppKey().trim())) { + return "信封目标应用不是本应用:" + envelope.getConsumerAppKey(); + } + if (envelope.getGrantVersion() == null || envelope.getGrantVersion() < 1) { + return "grantVersion 缺失(无授权纪元的快照不接受)"; + } + if (envelope.getContentHash() == null || !envelope.getContentHash().matches("[0-9a-fA-F]{64}")) { + return "contentHash 必须是 64 位十六进制"; + } + if (envelope.getSnapshot() == null) { + return "缺少 snapshot"; + } + if (envelope.getSnapshot().getValues() == null || !envelope.getSnapshot().getValues().isObject()) { + return "snapshot.values 必须是 JSON 对象"; + } + if (envelope.getSnapshot().getSchId() != null + && !envelope.getSnapshot().getSchId().equals(envelope.getConsumerSchoolId())) { + return "snapshot.schId 与信封租户域不符"; + } + return null; + } + + /** + * {@code fc.canon.v1} 的消费端实现(与 form-center {@code AnswerCanonicalizer} 同一规则闭集)。 + * + *

为什么本地要重算而不是"信中心给的 hash":撤权后修复、毒消息排查、以及"同 revision 异 hash" + * 这三件事都要一个本地独立结论。规则逐条对齐、跨端夹具在 {@code ShAnswerFlowTest} 里 + * 与 {@code FcAnswerDeliveryTest} 钉同一个字面量,任何一侧改动都会同时红在两个仓。

+ */ + public static String canonicalHash(Snapshot snapshot) { + return sha256Hex(canonicalSnapshot(snapshot)); + } + + /** 快照的规范串(剔除 intent 后)——跨端夹具逐字比对的正是这一串 */ + public static String canonicalSnapshot(Snapshot snapshot) { + ObjectMapper mapper = new ObjectMapper(); + JsonNode node = mapper.valueToTree(snapshot); + if (node != null && node.isObject()) { + ((com.fasterxml.jackson.databind.node.ObjectNode) node).remove("intent"); + } + return canonical(node); + } + + public static String canonical(JsonNode node) { + StringBuilder sb = new StringBuilder(256); + write(node, sb); + return sb.toString(); + } + + private static void write(JsonNode node, StringBuilder sb) { + if (node == null || node.isNull() || node.isMissingNode()) { + sb.append("null"); + return; + } + if (node.isObject()) { + List names = new ArrayList<>(); + java.util.Iterator> fields = node.fields(); + while (fields.hasNext()) { + names.add(fields.next().getKey()); + } + Collections.sort(names); + sb.append('{'); + for (int i = 0; i < names.size(); i++) { + if (i > 0) { + sb.append(','); + } + writeString(names.get(i), sb); + sb.append(':'); + write(node.get(names.get(i)), sb); + } + sb.append('}'); + return; + } + if (node.isArray()) { + sb.append('['); + for (int i = 0; i < node.size(); i++) { + if (i > 0) { + sb.append(','); + } + write(node.get(i), sb); + } + sb.append(']'); + return; + } + if (node.isNumber()) { + java.math.BigDecimal stripped = node.decimalValue().stripTrailingZeros(); + if (stripped.scale() < 0) { + stripped = stripped.setScale(0); + } + sb.append(stripped.toPlainString()); + return; + } + if (node.isBoolean()) { + sb.append(node.booleanValue() ? "true" : "false"); + return; + } + writeString(node.asText(), sb); + } + + private static void writeString(String value, StringBuilder sb) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + default: + if (c < 0x20) { + sb.append("\\u00").append(String.format("%02X", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } + + private static String sha256Hex(String input) { + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte b : digest) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)); + hex.append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 不可用(JVM 环境异常)", e); + } + } + + private static FcAnswerInbox inboxRow(SnapshotEnvelope envelope, String state, String detail) { + FcAnswerInbox inbox = new FcAnswerInbox(); + inbox.setEventId(envelope.getEventId()); + inbox.setProtocolVersion(envelope.getProtocolVersion()); + inbox.setEventType(envelope.getEventType()); + inbox.setSourceAppKey(envelope.getSourceAppKey()); + inbox.setConsumerAppKey(envelope.getConsumerAppKey()); + inbox.setConsumerSchoolId(envelope.getConsumerSchoolId()); + inbox.setGrantVersion(envelope.getGrantVersion()); + inbox.setFormUuid(envelope.getFormUuid()); + inbox.setFormVersion(envelope.getFormVersion()); + inbox.setAnswerUuid(envelope.getAnswerUuid()); + inbox.setRevision(envelope.getAnswerRevision()); + inbox.setContentHash(envelope.getContentHash()); + inbox.setIsDeleted(Boolean.TRUE.equals(envelope.getIsDeleted()) ? 1 : 0); + inbox.setOccurredAt(envelope.getOccurredAt()); + inbox.setState(state); + inbox.setDetail(detail == null ? null : detail.substring(0, Math.min(detail.length(), 500))); + inbox.setReportState(FcAnswerInbox.STATE_REJECTED.equals(state) + ? FcAnswerInbox.REPORT_SENT : FcAnswerInbox.REPORT_PENDING); + inbox.setReportAttempts(0); + inbox.setNextReportAt(LocalDateTime.now()); + inbox.setReceivedAt(LocalDateTime.now()); + return inbox; + } + + private static boolean blank(String raw) { + return raw == null || raw.trim().isEmpty(); + } + + private static String truncate(String value) { + if (value == null) { + return null; + } + return value.length() <= 500 ? value : value.substring(0, 500); + } + + /** 授权快照缓存条目(TTL 到期就重新核验) */ + private static final class CachedGrant { + private final boolean allowed; + private final int grantVersion; + private final LocalDateTime verifiedAt; + + CachedGrant(boolean allowed, int grantVersion, LocalDateTime verifiedAt) { + this.allowed = allowed; + this.grantVersion = grantVersion; + this.verifiedAt = verifiedAt; + } + + boolean expired(int ttlSeconds) { + return ttlSeconds <= 0 || verifiedAt == null + || verifiedAt.plusSeconds(ttlSeconds).isBefore(LocalDateTime.now()); + } + + boolean allows(Integer envelopeGrantVersion) { + // 缓存里已是新纪元而信封还带旧纪元=撤权/改能力后重放旧 grant,拒 + return allowed && envelopeGrantVersion != null && envelopeGrantVersion >= grantVersion; + } + } + + /** 缺席证明(TASK_3 对账的输入;坐标与期望版本必须成对,防"拿旧清单退役新数据") */ + @Getter + @RequiredArgsConstructor + public static class AuthoritativeAbsenceProof { + private final Long schId; + private final String formUuid; + private final String answerUuid; + private final Long expectedRevision; + private final String proofId; + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/AnswerReconcileWorker.java b/src/main/java/com/yunhai/svchall/service/impl/AnswerReconcileWorker.java new file mode 100644 index 0000000..df8c432 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/AnswerReconcileWorker.java @@ -0,0 +1,1154 @@ +package com.yunhai.svchall.service.impl; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.svchall.entity.AnswerReconcileWork; +import com.yunhai.svchall.model.FormSyncModels; +import com.yunhai.svchall.model.FormSyncModels.ExtraCandidate; +import com.yunhai.svchall.model.FormSyncModels.ManifestPageView; +import com.yunhai.svchall.model.FormSyncModels.ManifestRowView; +import com.yunhai.svchall.model.FormSyncModels.ReconcileError; +import com.yunhai.svchall.model.FormSyncModels.ReconcileInstruction; +import com.yunhai.svchall.model.FormSyncModels.ResultAck; +import com.yunhai.svchall.model.FormSyncModels.ResultReport; +import com.yunhai.svchall.model.FormSyncModels.TaskAnswerReply; +import com.yunhai.svchall.repository.AnswerReconcileWorkRepository; +import com.yunhai.svchall.repository.FcAnswerProjectionRepository; +import com.yunhai.svchall.repository.FcAnswerProjectionRepository.ManifestRow; +import com.yunhai.svchall.service.impl.AnswerProjectionStore.AuthoritativeAbsenceProof; +import com.yunhai.svchall.service.impl.AnswerProjectionStore.ManifestPage; +import com.yunhai.svchall.service.impl.AnswerProjectionStore.RepairResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 本应用侧对账 Worker(TASK_3 改动点 4/5/6)。 + * + *

接收与执行解耦:{@link #accept} 只把已验签的指令落库({@code POST /oapi/form-sync/reconcile} + * 的 2xx 到此为止,绝不把"收到"写成"修好");执行由扫描闹钟驱动,重复指令复用同一 {@code taskId} + * 与同一检查点,进程重启从水位续读({@code resumed} 计数留给运维看这一轮断过几次)。

+ * + *

双流锁步比较:中心清单(不可变、已物化)与本地图像(可变 keyset)都按 + * {@code answerUuid} 升序读,两侧游标永远取同一个水位—— + * {@code watermark = min(两侧本页末键)}。让某一条流单独领先,就会有一批"已读未比较"的行 + * 在崩溃后彻底消失,而它们正是多余行的候选。空页且未到底=扫描停滞,直接中断本轮重试, + * 不许把"这一页没读到"当成"集合到底了"。

+ * + *

修复只走权威入口:缺失/陈旧/同版错 hash/墓碑一律取中心当前快照, + * 经 {@code repairAuthoritative} 条件更新;绝不调用 {@code applySnapshot}—— + * 那条路会关联意图、建件、发流程,而对账修复不允许产生任何业务副作用。 + * 乐观锁与唯一键冲突都使当笔事务整笔回滚,然后在事务外重读本地行与中心当前值再有界重试。

+ * + *

多余行退役要同时满足:清单整轮完整结束、本地图像也扫完、该行属于本任务 + * tenant/form、{@code createTime} 不晚于任务起点且非空、且拿到匹配的 FC 签名缺席证明。 + * 缺任一条只报告。跨页失败/撤权/范围改变时未见到的行一律不当作"应删除"。

+ */ +@Slf4j +@Service +public class AnswerReconcileWorker { + + private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + private static final int TASK_UUID_MAX = 36; + private static final int PROOF_ID_MAX = 128; + + private final AnswerReconcileWorkRepository workRepository; + private final AnswerProjectionStore projectionStore; + private final FcAnswerProjectionRepository projectionRepository; + private final FormSyncClient formSyncClient; + private final TransactionTemplate transactionTemplate; + private final ObjectMapper objectMapper; + + @Value("${svchall.answer-reconcile.enabled:false}") + private boolean enabled = false; + + @Value("${svchall.answer-reconcile.lease-seconds:900}") + private int leaseSeconds = 900; + + /** 单条差异的修复重试预算(乐观锁/唯一键竞争/本地已变化三类都用它,耗尽只报告) */ + @Value("${svchall.answer-reconcile.max-repair-retries:3}") + private int maxRepairRetries = 3; + + @Value("${svchall.answer-reconcile.max-error-items:50}") + private int maxErrorItems = 50; + + /** 多余行候选上限:超了这一轮就不退役任何东西(内存与缺席答复次数都有界) */ + @Value("${svchall.answer-reconcile.max-pending-extras:500}") + private int maxPendingExtras = 500; + + @Value("${svchall.answer-reconcile.max-work-per-scan:1}") + private int maxWorkPerScan = 1; + + /** 每推进多少轮向中心回报一次 RUNNING(刷新中心租约,避免它把在跑的任务重派) */ + @Value("${svchall.answer-reconcile.progress-report-rounds:10}") + private int progressReportRounds = 10; + + @Value("${svchall.answer-reconcile.backoff-base-seconds:60}") + private int backoffBaseSeconds = 60; + + @Value("${svchall.answer-reconcile.backoff-cap-seconds:1800}") + private int backoffCapSeconds = 1800; + + @Value("${svchall.answer-reconcile.max-execution-retries:6}") + private int maxExecutionRetries = 6; + + /** Signer of FC instructions; distinct from this application's consumer identity. */ + @Value("${svchall.answer-reconcile.source-app-key:}") + private String sourceAppKey; + + /** 本应用消费身份:指令目标与验签身份都必须等于它,否则整条指令拒收 */ + private final String selfAppKey; + + public AnswerReconcileWorker(AnswerReconcileWorkRepository workRepository, + AnswerProjectionStore projectionStore, + FcAnswerProjectionRepository projectionRepository, + FormSyncClient formSyncClient, + TransactionTemplate transactionTemplate, + ObjectMapper objectMapper, + @Value("${svchall.fc.consumer-app-key:${yh.app-auth.app-key:100018}}") + String selfAppKey) { + this.workRepository = workRepository; + this.projectionStore = projectionStore; + this.projectionRepository = projectionRepository; + this.formSyncClient = formSyncClient; + this.transactionTemplate = transactionTemplate; + this.objectMapper = objectMapper; + this.selfAppKey = selfAppKey; + } + + // ==================== 接收(控制器只做验签与身份比对,落库口径在这里)==================== + + /** + * 接收中心指令:同 {@code taskId} 复用工作行与新 attempt,重复/更旧 attempt 只回吸收结论。 + * + * @param verifiedAppKey 平台验签后得到的调用方 appKey(控制器传入,不是请求体里的申报值) + */ + public AnswerReconcileWork accept(ReconcileInstruction instruction, String verifiedAppKey) { + String violation = validate(instruction, verifiedAppKey); + if (violation != null) { + throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, violation); + } + LocalDateTime windowStart = parse(instruction.getWindowStart()); + if (windowStart == null) { + throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, + "windowStart 必须是 yyyy-MM-dd'T'HH:mm:ss 的任务起点"); + } + int attempt = instruction.getAttempt() == null ? 0 : instruction.getAttempt(); + AnswerReconcileWork work = transactionTemplate.execute(tx -> { + AnswerReconcileWork row = workRepository.findByTaskUuid(instruction.getTaskId()).orElse(null); + if (row == null) { + row = new AnswerReconcileWork(); + row.setTaskUuid(instruction.getTaskId()); + row.setReceivedAt(LocalDateTime.now()); + row.setScannedRows(0); + row.setPagesProcessed(0); + } else { + if (!instruction.getConsumerAppKey().equals(row.getConsumerAppKey()) + || !instruction.getConsumerSchoolId().equals(row.getConsumerSchoolId()) + || !instruction.getFormUuid().equals(row.getFormUuid()) + || !instruction.getFormVersion().equals(row.getFormVersion()) + || !windowStart.equals(row.getWindowStart())) { + throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, + "SCOPE_MISMATCH:同一taskId不能变更任务坐标"); + } + if (attempt <= intOf(row.getAttempt())) { + return row; // Same/older instruction cannot reopen a terminal work row. + } + } + row.setAttempt(attempt); + row.setConsumerAppKey(instruction.getConsumerAppKey()); + row.setConsumerSchoolId(instruction.getConsumerSchoolId()); + row.setFormUuid(instruction.getFormUuid()); + row.setFormVersion(instruction.getFormVersion()); + row.setTriggerType(instruction.getTriggerType()); + row.setWindowStart(windowStart); + row.setTotalRows(instruction.getTotalRows() == null ? 0 : instruction.getTotalRows()); + row.setTotalPages(instruction.getTotalPages() == null ? 0 : instruction.getTotalPages()); + row.setPageSize(instruction.getPageSize() == null || instruction.getPageSize() < 1 + ? 200 : Math.min(instruction.getPageSize(), 500)); + row.setRetryCount(0); + row.setPendingReportState(null); + row.setLeaseUntil(null); + // 终态或已停摆:中心既然按更大的 attempt 重派,就重新排队从水位续读—— + // 回一个 duplicate 把它吸掉会让中心一路重派到预算耗尽,最后落成 FAILED 假象。 + row.setState(AnswerReconcileWork.STATE_QUEUED); + row.setNextRetryAt(LocalDateTime.now()); + row.setReportedAttempt(null); + row.setReportedState(null); + return workRepository.save(row); + }); + return work; + } + + /** 指令是否可信:身份必须等于本应用消费身份,且坐标齐全 */ + private String validate(ReconcileInstruction instruction, String verifiedAppKey) { + if (instruction == null) { + return "空指令"; + } + if (blank(instruction.getTaskId()) || instruction.getTaskId().length() > TASK_UUID_MAX) { + return "taskId 缺失或超过 36 字符"; + } + if (!FormSyncModels.PROTOCOL.equals(instruction.getProtocol())) { + return "协议号不符:" + instruction.getProtocol(); + } + if (blank(sourceAppKey) || blank(verifiedAppKey) || !verifiedAppKey.trim().equals(sourceAppKey.trim())) { + return "指令签名方不是已配置的表单中心身份(svchall.answer-reconcile.source-app-key)"; + } + if (blank(instruction.getConsumerAppKey()) + || !instruction.getConsumerAppKey().trim().equals(selfAppKey)) { + return "指令的目标应用不是本应用:" + instruction.getConsumerAppKey(); + } + if (instruction.getConsumerSchoolId() == null || instruction.getConsumerSchoolId() <= 0) { + return "consumerSchoolId 必须是真实学校(≥1)"; + } + if (blank(instruction.getFormUuid()) || instruction.getFormVersion() == null + || instruction.getFormVersion() < 1) { + return "表单坐标必须成对且版本确切"; + } + if (instruction.getAttempt() == null || instruction.getAttempt() < 1) { + return "attempt 必填且非负"; + } + return null; + } + + // ==================== 执行 ==================== + + /** 扫描闹钟:默认停用(开关 {@code svchall.answer-reconcile.enabled}),出站不可用时空转不报错 */ + @org.springframework.scheduling.annotation.Scheduled( + fixedDelayString = "${svchall.answer-reconcile.scan-interval-ms:30000}", + initialDelayString = "${svchall.answer-reconcile.scan-initial-delay-ms:20000}") + public int runDue() { + if (!enabled) { + return 0; // 默认停用:没开闸就一行数据都不动(任务书要求调度可显式关闭) + } + if (!formSyncClient.enabled()) { + log.info("[对账] 出站签名客户端未就绪,本轮不执行对账任务"); + return 0; + } + int executed = 0; + for (AnswerReconcileWork row : workRepository.findDue(LocalDateTime.now(), + PageRequest.of(0, Math.max(1, maxWorkPerScan)))) { + executed += executeQuietly(row.getId()); + } + for (AnswerReconcileWork row : workRepository.findStaleRunning(LocalDateTime.now(), + PageRequest.of(0, Math.max(1, maxWorkPerScan)))) { + executed += executeQuietly(row.getId()); + } + return executed; + } + + /** 领取并执行一个任务;异常一律吞在本方法内(闹钟不能因为一轮失败而停) */ + public int executeQuietly(Integer workId) { + AnswerReconcileWork claimed = claim(workId); + if (claimed == null) { + return 0; + } + try { + if (claimed.getPendingReportState() != null) { + finish(new ScanContext(claimed), claimed.getPendingReportState(), null); + } else { + scan(claimed); + } + return 1; + } catch (RuntimeException e) { + // 扫描中断:检查点已经按轮落库,下一轮从水位续读;本轮不回报(中心靠租约重派) + log.warn("[对账] 执行中断 task={}:{}", claimed.getTaskUuid(), e.toString()); + transactionTemplate.executeWithoutResult(tx -> { + AnswerReconcileWork row = workRepository.findById(claimed.getId()).orElse(claimed); + if (!java.util.Objects.equals(row.getAttempt(), claimed.getAttempt()) + || AnswerReconcileWork.isTerminal(row.getState())) { + return; // Old worker must not overwrite a newer instruction or terminal decision. + } + int failures = intOf(row.getRetryCount()) + 1; + row.setRetryCount(failures); + boolean terminal = failures >= Math.max(1, maxExecutionRetries) + || "TASK_TERMINAL".equals(clientErrorCode(e)) + || FormSyncModels.ERR_CANCELLED.equals(clientErrorCode(e)) + || FormSyncModels.ERR_GRANT_REVOKED.equals(clientErrorCode(e)); + row.setState(terminal ? AnswerReconcileWork.STATE_FAILED : AnswerReconcileWork.STATE_QUEUED); + row.setLastErrorCode(clientErrorCode(e)); + row.setLastError(truncate(e.getMessage(), 500)); + row.setLeaseUntil(null); + row.setNextRetryAt(terminal ? null : LocalDateTime.now().plusSeconds(backoffSeconds(failures))); + row.setFinishedAt(terminal ? LocalDateTime.now() : null); + workRepository.save(row); + }); + return 0; + } + } + + /** 行锁领取:只有仍处于 QUEUED/超租约 RUNNING 的行会被领走(进程内并发与重启并存都只有一人跑) */ + private AnswerReconcileWork claim(Integer workId) { + return transactionTemplate.execute(tx -> { + AnswerReconcileWork row = workRepository.findByIdForUpdate(workId).orElse(null); + if (row == null || AnswerReconcileWork.isTerminal(row.getState())) { + return null; + } + boolean running = AnswerReconcileWork.STATE_RUNNING.equals(row.getState()); + if (running && row.getLeaseUntil() != null && row.getLeaseUntil().isAfter(LocalDateTime.now())) { + return null; + } + if (running) { + row.setResumed((row.getResumed() == null ? 0 : row.getResumed()) + 1); + } + row.setState(AnswerReconcileWork.STATE_RUNNING); + row.setStartedAt(row.getStartedAt() == null ? LocalDateTime.now() : row.getStartedAt()); + row.setLeaseUntil(LocalDateTime.now().plusSeconds(leaseSeconds)); + row.setNextRetryAt(null); + return workRepository.save(row); + }); + } + + /** + * 一轮完整扫描:从当前水位开始,逐轮锁步读两侧、比较、修复,直到两侧都到底。 + * + *

本轮结果直接落成 COMPLETED / PARTIAL(有未决差异、候选超限、停滞都算 PARTIAL)并回报中心。

+ */ + private void scan(AnswerReconcileWork work) { + ScanContext context = new ScanContext(work); + context.pendingExtras = readJson(work.getPendingExtrasJson()); + while (true) { + int pageSize = Math.max(1, work.getPageSize() == null ? 200 : work.getPageSize()); + String watermark = work.getWatermark(); + ManifestPageView fcPage = formSyncClient.fetchManifestPage(work.getTaskUuid(), watermark, pageSize); + if (fcPage == null || fcPage.getRows() == null || fcPage.getCompleted() == null + || !work.getTaskUuid().equals(fcPage.getTaskId())) { + throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, + "INVALID_MANIFEST:中心分页缺少完整性标记或任务坐标不符"); + } + if (isCancelledState(fcPage.getTaskState())) { + finishAsCancelled(context, "中心任务状态=" + fcPage.getTaskState()); + return; + } + ManifestPage localPage = projectionStore.manifestPage(work.getConsumerSchoolId(), + work.getFormUuid(), watermark, pageSize); + boolean fcDone = Boolean.TRUE.equals(fcPage.getCompleted()); + boolean localDone = localPage.isCompleted(); + context.fcCompleted = fcDone; + context.localCompleted = localDone; + // 页末键就是这一轮的边界;空页才是"该侧不设界" + String fcBound = lastKey(fcPage.getRows()); + String localBound = lastLocalKey(localPage.getRows()); + if (fcBound == null && localBound == null) { + if (fcDone && localDone) { + break; + } + throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, + FormSyncModels.ERR_SCAN_STALLED + ":两侧都读到空页但未到底,本轮中断," + + "不把空页当集合边界"); + } + String roundEnd = minKey(fcBound, localBound); + mergeCompare(context, fcPage.getRows(), localPage.getRows(), roundEnd); + if (context.deferred > 0) { + // 授权核验不可达/撤权/纪元不等:暂停修复。水位不推进——本轮没比较过的行 + // 必须留给下一轮,否则"暂停"会变成"跳过"。 + context.checkpoint(); // Preserve errors/counters/extras, WITHOUT advancing the watermark. + finishAsPartial(context, FormSyncModels.ERR_GRANT_REVOKED + ":授权核验结论要求暂停修复,等待下一轮"); + return; + } + if (context.isCancelledSignal()) { + finishAsCancelled(context, context.cancelReason); + return; + } + context.advance(roundEnd); + context.work.setFcCompleted(fcDone ? 1 : 0); + context.work.setLocalCompleted(localDone ? 1 : 0); + persist(context.work); + context.answers.clear(); // Snapshot cache is page-scoped, not an unbounded full-answer mirror. + int pages = intOf(context.work.getPagesProcessed()); + if (progressReportRounds > 0 && pages % progressReportRounds == 0) { + reportProgress(context); + } + } + retireExtras(context, context.pendingExtras); + if (context.extrasOverflow) { + addError(context, FormSyncModels.ERR_SCAN_ABORTED, null, null, + "多余行候选超过上限 " + maxPendingExtras + ":本轮不退役任何行,只报告"); + } + finish(context, context.hasUnresolved() ? AnswerReconcileWork.STATE_PARTIAL + : AnswerReconcileWork.STATE_COMPLETED, null); + } + + /** 合并两侧同一水位窗口内的行:左=中心清单(权威、不可变),右=本地图像(可变 keyset) */ + private void mergeCompare(ScanContext context, List fcRows, + List localRows, String roundEnd) { + int i = 0; + int j = 0; + while (i < fcRows.size() || j < localRows.size()) { + if (context.deferred > 0) { + return; // 暂停修复:页里剩余行本轮不比较,水位不推进,下一轮原样重来 + } + String fcKey = i < fcRows.size() ? fcRows.get(i).getAnswerUuid() : null; + String localKey = j < localRows.size() ? localRows.get(j).getAnswerUuid() : null; + if (fcKey != null && (localKey == null || fcKey.compareTo(localKey) < 0)) { + if (after(fcKey, roundEnd)) { + break; + } + context.scanned++; + compareMissing(context, fcRows.get(i)); + i++; + continue; + } + if (localKey != null && (fcKey == null || localKey.compareTo(fcKey) < 0)) { + if (after(localKey, roundEnd)) { + break; + } + context.scanned++; + compareLocalOnly(context, localRows.get(j)); + j++; + continue; + } + if (fcKey == null || localKey == null) { + break; + } + if (after(fcKey, roundEnd)) { + break; + } + context.scanned++; + compareBoth(context, fcRows.get(i), localRows.get(j)); + i++; + j++; + } + } + + /** 中心有、本地没有:缺失方向,取中心当前权威快照建未关联投影 */ + private void compareMissing(ScanContext context, ManifestRowView fc) { + if (fc.getRevision() == null || fc.getRevision() < 1) { + // 存量未变更行修订号为 0:权威修复入口按契约拒绝(≥1),只报告不硬修 + addError(context, FormSyncModels.DIFF_REVISION_ZERO, fc.getAnswerUuid(), fc.getRevision(), + "修订号为 0 的存量行不可作权威修复:等它下一次真实变更后再对账"); + context.unresolved++; + return; + } + TaskAnswerReply reply = context.currentAnswer(fc.getAnswerUuid(), null); + if (reply == null) { + return; + } + repair(context, FormSyncModels.DIFF_MISSING, reply, null, fc.getAnswerUuid(), fc.getRevision()); + } + + /** + * 两侧都有同一 answerUuid:先按四元摘要定方向,只有真有差异才向中心要权威快照。 + * + *

完整扫描里绝大多数行是一致的;把每一行都拿去问中心会把对账变成第二份全量拉取, + * 也就失去"清单摘要比对"这个设计的意义。

+ */ + private void compareBoth(ScanContext context, ManifestRowView fc, ManifestRow local) { + Long fcRevision = fc.getRevision(); + Long localRevision = local.getRevision(); + if (fcRevision == null || fcRevision < 1) { + addError(context, FormSyncModels.DIFF_REVISION_ZERO, fc.getAnswerUuid(), fcRevision, + "修订号为 0 的存量行不可作权威修复:等它下一次真实变更后再对账"); + context.unresolved++; + return; + } + if (localRevision != null && localRevision > fcRevision) { + // 本地更高:先向中心复核当前值(这就是中心当前事实),绝不用旧清单降级覆盖 + TaskAnswerReply fresh = context.currentAnswer(local.getAnswerUuid(), null); + Long centerNow = envelopeRevision(fresh); + if (centerNow != null && localRevision < centerNow) { + repair(context, FormSyncModels.DIFF_STALE, fresh, local, local.getAnswerUuid(), localRevision); + return; + } + context.localAhead++; + addError(context, FormSyncModels.DIFF_LOCAL_AHEAD, local.getAnswerUuid(), localRevision, + "本地修订号高于中心清单,已按中心当前值复核后只报告,不降级"); + context.unresolved++; + return; + } + boolean hashDiffers = !equalsHash(local.getContentHash(), fc.getContentHash()); + boolean tombstoneDiffers = !Integer.valueOf(Boolean.TRUE.equals(fc.getIsDeleted()) ? 1 : 0) + .equals(local.getIsDeleted()); + if (localRevision != null && localRevision < fcRevision) { + TaskAnswerReply reply = context.currentAnswer(local.getAnswerUuid(), null); + if (reply != null) { + repair(context, FormSyncModels.DIFF_STALE, reply, local, local.getAnswerUuid(), localRevision); + } + return; + } + if (tombstoneDiffers) { + TaskAnswerReply reply = context.currentAnswer(local.getAnswerUuid(), null); + if (reply != null) { + repair(context, FormSyncModels.DIFF_TOMBSTONE, reply, local, local.getAnswerUuid(), localRevision); + } + return; + } + if (hashDiffers) { + context.conflicts++; + TaskAnswerReply reply = context.currentAnswer(local.getAnswerUuid(), null); + if (reply != null) { + repair(context, FormSyncModels.DIFF_HASH_CONFLICT, reply, local, local.getAnswerUuid(), + localRevision); + } + return; + } + // 四元摘要一致:不动数据,不向中心取快照,也不产生任何回执式副作用 + } + + /** 本地有、中心清单没有:多余行候选(能否退役要等整轮完整结束 + 权威缺席证明) */ + private void compareLocalOnly(ScanContext context, ManifestRow local) { + context.extraReported++; + if (local.getCreateTime() == null || (context.work.getWindowStart() != null + && local.getCreateTime().isAfter(context.work.getWindowStart()))) { + addError(context, FormSyncModels.DIFF_ADDED_AFTER_WINDOW, local.getAnswerUuid(), + local.getRevision(), "本地创建时刻晚于任务起点或为空:只报告,绝不退役"); + context.unresolved++; + return; + } + if (context.pendingExtras.size() >= Math.max(1, maxPendingExtras)) { + context.markExtrasOverflow(); + addError(context, FormSyncModels.ERR_SCAN_ABORTED, local.getAnswerUuid(), local.getRevision(), + "多余行候选超过上限 " + maxPendingExtras + ":本轮不退役任何行"); + context.unresolved++; + return; + } + ExtraCandidate candidate = new ExtraCandidate(); + candidate.setAnswerUuid(local.getAnswerUuid()); + candidate.setRevision(local.getRevision()); + candidate.setContentHash(local.getContentHash()); + candidate.setIsDeleted(local.getIsDeleted()); + candidate.setCreateTime(local.getCreateTime()); + context.pendingExtras.add(candidate); + } + + /** + * 权威修复:中心当前值 + 本地扫描三元组 → {@code repairAuthoritative}。 + * + *

{@code LOCAL_CHANGED}/乐观锁/唯一键竞争三类都要整笔回滚后重读双方再重试: + * 本地重读走 {@code findByAnswerUuidAndSchId}(只读复用 TASK_2 仓库已有方法), + * 中心当前值重新走签名任务端点。不在已 rollback-only 的事务里续跑—— + * 修复调用本身不带外层事务,每次 {@code repairAuthoritative} 都是独立事务。

+ */ + private void repair(ScanContext context, String direction, TaskAnswerReply reply, ManifestRow local, + String answerUuid, Long localRevision) { + if (reply == null) { + return; + } + if (!FormSyncModels.PRESENCE_PRESENT.equals(reply.getPresence()) || reply.getEnvelope() == null) { + addError(context, reply.getReasonCode() == null + ? FormSyncModels.ERR_REPAIR_FAILED : reply.getReasonCode(), answerUuid, localRevision, + "中心未给出权威当前快照(presence=" + reply.getPresence() + "),本条只报告"); + context.unresolved++; + return; + } + String proofId = repairProofId(context.work, answerUuid); + ManifestRow expected = local; + TaskAnswerReply current = reply; + for (int retry = 0; retry <= Math.max(0, maxRepairRetries); retry++) { + if (!trustedReplyMatches(context.work, current, answerUuid)) { + addError(context, FormSyncModels.ERR_PROOF_REJECTED, answerUuid, localRevision, + "权威快照的任务/应用/学校/表单/答卷/版本坐标不一致,拒绝修复"); + context.unresolved++; + return; + } + Long expRevision = expected == null ? null : expected.getRevision(); + String expHash = expected == null ? null : expected.getContentHash(); + Integer expDeleted = expected == null ? null : expected.getIsDeleted(); + try { + RepairResult result = projectionStore.repairAuthoritative(current.getEnvelope(), + expRevision, expHash, expDeleted, proofId); + if (RepairResult.APPLIED == result) { + countApplied(context, direction); + return; + } + if (RepairResult.LOCAL_AHEAD == result) { + context.localAhead++; + addError(context, FormSyncModels.DIFF_LOCAL_AHEAD, answerUuid, expRevision, + "修复时发现本地修订号更高:向中心复核,禁止降级覆盖"); + context.unresolved++; + return; + } + if (RepairResult.DEFERRED == result) { + context.deferred++; + addError(context, FormSyncModels.ERR_GRANT_REVOKED, answerUuid, expRevision, + "新鲜授权核验不可达/撤权/纪元不等:暂停修复"); + context.unresolved++; + return; + } + // LOCAL_CHANGED:本地期望三元组已变化,重读双方后有界重试 + expected = reloadLocal(context.work, answerUuid); + current = context.freshAnswer(answerUuid, null); + if (expected == null || current == null) { + addError(context, FormSyncModels.ERR_REPAIR_FAILED, answerUuid, expRevision, + "本地已变化且重读不再可比(行消失或中心无权威结论),停手只报告"); + context.unresolved++; + return; + } + } catch (OptimisticLockingFailureException | DataIntegrityViolationException e) { + // 当笔事务已整笔回滚:在事务外重读双方,再按同一 taskId/attempt 幂等重试 + expected = reloadLocal(context.work, answerUuid); + current = context.freshAnswer(answerUuid, null); + if (expected == null || current == null) { + addError(context, FormSyncModels.ERR_REPAIR_FAILED, answerUuid, localRevision, + "并发修复后重读不可比:" + e.getClass().getSimpleName()); + context.unresolved++; + return; + } + log.info("[对账] 并发冲突后重试 task={} answer={} 第 {} 次:{}", + context.work.getTaskUuid(), answerUuid, retry + 1, e.getClass().getSimpleName()); + } catch (BizException e) { + addError(context, FormSyncModels.ERR_REPAIR_FAILED, answerUuid, localRevision, e.getMessage()); + context.unresolved++; + return; + } + } + addError(context, FormSyncModels.ERR_REPAIR_RETRY_EXHAUSTED, answerUuid, localRevision, + "同一差异的重试预算 " + maxRepairRetries + " 已耗尽:留待下一轮或人工处置"); + context.unresolved++; + } + + /** 多余行退役:只在清单与本地扫描都完整结束、且候选未超限时做,逐条向中心要权威缺席证明 */ + private void retireExtras(ScanContext context, List pending) { + if (pending == null || pending.isEmpty()) { + return; + } + if (!context.fcCompleted || !context.localCompleted || context.extrasOverflow) { + for (ExtraCandidate candidate : pending) { + addError(context, FormSyncModels.DIFF_EXTRA_ROW, candidate.getAnswerUuid(), + candidate.getRevision(), "扫描未完整结束或多余行候选超限:只报告,未退役"); + } + context.unresolved += pending.size(); + return; + } + for (ExtraCandidate candidate : pending) { + TaskAnswerReply reply = context.currentAnswer(candidate.getAnswerUuid(), candidate.getRevision()); + if (reply == null) { + continue; + } + if (!FormSyncModels.PRESENCE_ABSENT.equals(reply.getPresence()) + || reply.getAbsenceProof() == null) { + addError(context, reply.getReasonCode() == null + ? FormSyncModels.ERR_PROOF_REJECTED : reply.getReasonCode(), + candidate.getAnswerUuid(), candidate.getRevision(), + "中心未签出权威缺席证明(presence=" + reply.getPresence() + "):不退役"); + context.unresolved++; + continue; + } + if (!proofMatches(context.work, reply.getAbsenceProof(), candidate)) { + addError(context, FormSyncModels.ERR_PROOF_REJECTED, candidate.getAnswerUuid(), + candidate.getRevision(), "缺席证明坐标与本地候选不符:不退役"); + context.unresolved++; + continue; + } + try { + int retired = projectionStore.retireAbsent(new AuthoritativeAbsenceProof( + reply.getAbsenceProof().getSchId(), reply.getAbsenceProof().getFormUuid(), + reply.getAbsenceProof().getAnswerUuid(), + reply.getAbsenceProof().getExpectedRevision(), + reply.getAbsenceProof().getProofId())); + context.extraRetired += retired; + if (retired == 0) { + context.unresolved++; + } + } catch (RuntimeException e) { + addError(context, FormSyncModels.ERR_REPAIR_FAILED, candidate.getAnswerUuid(), + candidate.getRevision(), "退役失败:" + e.getClass().getSimpleName()); + context.unresolved++; + } + } + pending.clear(); + context.work.setPendingExtrasJson("[]"); + } + + private boolean proofMatches(AnswerReconcileWork work, + FormSyncModels.AbsenceProof proof, ExtraCandidate candidate) { + return work.getTaskUuid().equals(proof.getTaskId()) + && work.getFormUuid().equals(proof.getFormUuid()) + && work.getConsumerSchoolId().equals(proof.getSchId()) + && candidate.getAnswerUuid().equals(proof.getAnswerUuid()) + && proof.getExpectedRevision() != null + && proof.getExpectedRevision().equals(candidate.getRevision()); + } + + private boolean trustedReplyMatches(AnswerReconcileWork work, TaskAnswerReply reply, String answerUuid) { + if (reply == null || !FormSyncModels.PRESENCE_PRESENT.equals(reply.getPresence()) + || reply.getEnvelope() == null) { + return false; + } + com.yunhai.svchall.model.FcApplyModels.SnapshotEnvelope envelope = reply.getEnvelope(); + return work.getTaskUuid().equals(reply.getTaskId()) + && !blank(sourceAppKey) && sourceAppKey.trim().equals(envelope.getSourceAppKey()) + && work.getConsumerAppKey().equals(reply.getConsumerAppKey()) + && work.getConsumerSchoolId().equals(reply.getConsumerSchoolId()) + && work.getFormUuid().equals(reply.getFormUuid()) + && answerUuid.equals(reply.getAnswerUuid()) + && work.getConsumerAppKey().equals(envelope.getConsumerAppKey()) + && work.getConsumerSchoolId().equals(envelope.getConsumerSchoolId()) + && work.getFormUuid().equals(envelope.getFormUuid()) + && answerUuid.equals(envelope.getAnswerUuid()) + && reply.getFormVersion() != null + && reply.getFormVersion().equals(envelope.getFormVersion()) + && reply.getGrantVersion() != null + && reply.getGrantVersion().equals(envelope.getGrantVersion()); + } + + // ==================== 回报 ==================== + + private void reportProgress(ScanContext context) { + try { + formSyncClient.submitResult(context.work.getTaskUuid(), report(context, + AnswerReconcileWork.STATE_RUNNING)); + } catch (RuntimeException e) { + log.warn("[对账] 进度回报失败 task={}:{}", context.work.getTaskUuid(), e.toString()); + } + } + + private void finish(ScanContext context, String state, String reason) { + context.checkpoint(); + ResultReport report = report(context, state); + try { + ResultAck ack = formSyncClient.submitResult(context.work.getTaskUuid(), report); + applyAck(context.work, ack, state); + context.work.setPendingReportState(null); + context.work.setRetryCount(0); + } catch (RuntimeException e) { + // 结果送不到中心:留在 QUEUED 等重报/重派(同 taskId+attempt 幂等,不会重复计数) + int failures = intOf(context.work.getRetryCount()) + 1; + context.work.setRetryCount(failures); + boolean exhausted = failures >= Math.max(1, maxExecutionRetries); + context.work.setPendingReportState(state); + context.work.setState(exhausted ? AnswerReconcileWork.STATE_FAILED : AnswerReconcileWork.STATE_QUEUED); + context.work.setLastErrorCode(FormSyncModels.ERR_CLIENT_UNAVAILABLE); + context.work.setLastError(truncate(e.getMessage(), 500)); + context.work.setNextRetryAt(exhausted ? null : LocalDateTime.now().plusSeconds(backoffSeconds(failures))); + context.work.setFinishedAt(exhausted ? LocalDateTime.now() : null); + context.work.setLeaseUntil(null); + log.warn("[对账] 结果回报不可达 task={},按 {} 状态留待重报:{}", context.work.getTaskUuid(), + state, e.toString()); + } + if (reason != null) { + context.work.setLastError(truncate(reason, 500)); + } + persist(context.work); + } + + private void finishAsPartial(ScanContext context, String reason) { + addError(context, FormSyncModels.ERR_SCAN_ABORTED, null, null, reason); + context.unresolved++; + finish(context, AnswerReconcileWork.STATE_PARTIAL, reason); + } + + private void finishAsCancelled(ScanContext context, String reason) { + context.work.setState(AnswerReconcileWork.STATE_CANCELLED); + context.work.setFinishedAt(LocalDateTime.now()); + context.work.setLastErrorCode(FormSyncModels.ERR_CANCELLED); + context.work.setLastError(truncate(reason, 500)); + context.work.setLeaseUntil(null); + addError(context, FormSyncModels.ERR_CANCELLED, null, null, reason); + try { + formSyncClient.submitResult(context.work.getTaskUuid(), report(context, + AnswerReconcileWork.STATE_CANCELLED)); + } catch (RuntimeException e) { + log.info("[对账] 取消收尾的回报送不到(中心已按取消处置也无妨)task={}:{}", + context.work.getTaskUuid(), e.toString()); + } + persist(context.work); + } + + private void applyAck(AnswerReconcileWork work, ResultAck ack, String state) { + work.setReportedAttempt(work.getAttempt()); + work.setReportedState(state); + if (ack != null && ack.getTaskState() != null && AnswerReconcileWork.isTerminal(ack.getTaskState())) { + work.setState(ack.getTaskState()); + work.setFinishedAt(LocalDateTime.now()); + work.setNextRetryAt(null); + work.setLeaseUntil(null); + return; + } + if (ack != null && Boolean.TRUE.equals(ack.getStaleAttempt())) { + // 中心判这次回报是旧 attempt:本地不覆盖已提交结论,等中心重派 + work.setState(AnswerReconcileWork.STATE_QUEUED); + work.setNextRetryAt(LocalDateTime.now().plusSeconds(backoffSeconds(1))); + work.setLastErrorCode("STALE_ATTEMPT"); + return; + } + work.setState(state); + work.setFinishedAt(AnswerReconcileWork.STATE_RUNNING.equals(state) ? null : LocalDateTime.now()); + work.setLeaseUntil(AnswerReconcileWork.STATE_RUNNING.equals(state) + ? LocalDateTime.now().plusSeconds(leaseSeconds) : null); + } + + private ResultReport report(ScanContext context, String state) { + ResultReport report = new ResultReport(); + report.setAttempt(context.work.getAttempt()); + report.setState(state); + report.setLocalScanComplete(context.localCompleted); + report.setManifestComplete(context.fcCompleted); + report.setScannedRows(context.scanned); + report.setPagesProcessed(context.work.getPagesProcessed()); + report.setMissingRepaired(context.missingRepaired); + report.setStaleRepaired(context.staleRepaired); + report.setHashConflicts(context.conflicts); + report.setTombstonesRepaired(context.tombstonesRepaired); + report.setLocalAhead(context.localAhead); + report.setDeferred(context.deferred); + report.setExtraReported(context.extraReported); + report.setExtraRetired(context.extraRetired); + report.setResumed(Integer.valueOf(0).equals(context.work.getResumed()) ? Boolean.FALSE : Boolean.TRUE); + report.setStartedAt(TS.format(context.work.getStartedAt() == null + ? LocalDateTime.now() : context.work.getStartedAt())); + report.setErrors(context.limitedErrors()); + return report; + } + + // ==================== 内部小工具 ==================== + + /** 中心答复缓存:同一轮里同一条答卷只问一次(expectedRevision 参与键,缺席请求要带它) */ + private final class ScanContext { + private final AnswerReconcileWork work; + private final Map answers = new LinkedHashMap<>(); + private final List errors = new ArrayList<>(); + private List pendingExtras = new ArrayList<>(); + private boolean extrasOverflow; + private boolean cancelSignal; + private String cancelReason; + private int scanned; + private int missingRepaired; + private int staleRepaired; + private int conflicts; + private int tombstonesRepaired; + private int localAhead; + private int deferred; + private int extraReported; + private int extraRetired; + private int unresolved; + private boolean fcCompleted; + private boolean localCompleted; + + ScanContext(AnswerReconcileWork work) { + this.work = work; + this.scanned = work.getScannedRows() == null ? 0 : work.getScannedRows(); + this.missingRepaired = intOf(work.getMissingRepaired()); + this.staleRepaired = intOf(work.getStaleRepaired()); + this.conflicts = intOf(work.getHashConflicts()); + this.tombstonesRepaired = intOf(work.getTombstonesRepaired()); + this.localAhead = intOf(work.getLocalAhead()); + this.deferred = intOf(work.getDeferred()); + this.extraReported = intOf(work.getExtraReported()); + this.extraRetired = intOf(work.getExtraRetired()); + this.errors.addAll(readErrors(work.getErrorsJson())); + this.fcCompleted = Integer.valueOf(1).equals(work.getFcCompleted()); + this.localCompleted = Integer.valueOf(1).equals(work.getLocalCompleted()); + this.unresolved = intOf(work.getErrorCount()); + } + + /** 中心当前值(同一条答卷在一轮里只问一次;expectedRevision 只在缺席判定时带) */ + TaskAnswerReply currentAnswer(String answerUuid, Long expectedRevision) { + String key = answerUuid + (expectedRevision == null ? "" : "@" + expectedRevision); + if (answers.containsKey(key)) { + return answers.get(key); + } + try { + TaskAnswerReply reply = formSyncClient.fetchTaskAnswer(work.getTaskUuid(), answerUuid, + expectedRevision); + answers.put(key, reply); + if (reply != null && FormSyncModels.ERR_CANCELLED.equals(reply.getReasonCode())) { + cancelSignal = true; + cancelReason = "中心答复任务已取消"; + } + return reply; + } catch (BizException e) { + String code = clientErrorCode(e); + if ("TASK_TERMINAL".equals(code) || FormSyncModels.ERR_CANCELLED.equals(code) + || FormSyncModels.ERR_GRANT_REVOKED.equals(code)) { + cancelSignal = true; + cancelReason = code + ":" + e.getMessage(); + deferred++; + } else { + addError(this, FormSyncModels.ERR_REPAIR_FAILED, answerUuid, null, e.getMessage()); + unresolved++; + } + answers.put(key, null); + return null; + } + } + + TaskAnswerReply freshAnswer(String answerUuid, Long expectedRevision) { + answers.remove(answerUuid + (expectedRevision == null ? "" : "@" + expectedRevision)); + return currentAnswer(answerUuid, expectedRevision); + } + + void advance(String roundEnd) { + work.setWatermark(roundEnd); + work.setFcCursor(roundEnd); + work.setLocalCursor(roundEnd); + work.setPagesProcessed(intOf(work.getPagesProcessed()) + 1); + checkpoint(); + } + + void checkpoint() { + work.setLeaseUntil(LocalDateTime.now().plusSeconds(leaseSeconds)); + work.setScannedRows(scanned); + work.setMissingRepaired(missingRepaired); + work.setStaleRepaired(staleRepaired); + work.setHashConflicts(conflicts); + work.setTombstonesRepaired(tombstonesRepaired); + work.setLocalAhead(localAhead); + work.setDeferred(deferred); + work.setExtraReported(extraReported); + work.setExtraRetired(extraRetired); + work.setErrorCount(unresolved); + work.setFcCompleted(fcCompleted ? 1 : 0); + work.setLocalCompleted(localCompleted ? 1 : 0); + // 检查点每轮都落:多余行候选与已记录的错误必须跟着水位一起持久化, + // 否则重启后"见过的候选"消失,会被当成没有差异而误报 COMPLETED + work.setPendingExtrasJson(writeJson(pendingExtras)); + work.setErrorsJson(writeJson(limitedErrors())); + } + + boolean hasUnresolved() { + return unresolved > 0 || deferred > 0 || !pendingExtras.isEmpty() || extrasOverflow; + } + + boolean isCancelledSignal() { + return cancelSignal; + } + + void markExtrasOverflow() { + extrasOverflow = true; + } + + List limitedErrors() { + int limit = Math.max(1, maxErrorItems); + return errors.size() <= limit ? errors : new ArrayList<>(errors.subList(0, limit)); + } + } + + private void countApplied(ScanContext context, String direction) { + switch (direction) { + case FormSyncModels.DIFF_MISSING: + context.missingRepaired++; + return; + case FormSyncModels.DIFF_TOMBSTONE: + context.tombstonesRepaired++; + return; + case FormSyncModels.DIFF_HASH_CONFLICT: + case FormSyncModels.DIFF_STALE: + default: + // 陈旧与同版错 hash 都是"同一坐标内容与中心不一致",按中心当前值覆盖,计同一类修复 + context.staleRepaired++; + } + } + + private ManifestRow reloadLocal(AnswerReconcileWork work, String answerUuid) { + return transactionTemplate.execute(tx -> { + com.yunhai.svchall.entity.FcAnswerProjection row = projectionRepository + .findByAnswerUuidAndSchId(answerUuid, work.getConsumerSchoolId()).orElse(null); + if (row == null) { + return null; + } + return new LocalRowView(row.getAnswerUuid(), row.getRevision(), row.getContentHash(), + row.getIsDeleted(), row.getSchId(), row.getCreateTime()); + }); + } + + private void addError(ScanContext context, String code, String answerUuid, Long revision, String detail) { + if (context.errors.size() >= Math.max(1, maxErrorItems) * 2) { + return; // 明细本身也有界:一轮里同因失败堆到上限就只留计数 + } + context.errors.add(FormSyncClient.error(code, context.work.getTaskUuid(), context.work.getConsumerAppKey(), + context.work.getConsumerSchoolId(), context.work.getFormUuid(), answerUuid, revision, detail)); + } + + private void persist(AnswerReconcileWork work) { + AnswerReconcileWork saved = transactionTemplate.execute(tx -> workRepository.saveAndFlush(work)); + if (saved != null) { + // save merges detached work; carry the committed generation into the next checkpoint. + work.setWorkVersion(saved.getWorkVersion()); + } + } + + private Long envelopeRevision(TaskAnswerReply reply) { + if (reply == null || reply.getEnvelope() == null) { + return null; + } + return reply.getEnvelope().getAnswerRevision(); + } + + private String repairProofId(AnswerReconcileWork work, String answerUuid) { + return truncate("rep:" + work.getTaskUuid() + ":" + answerUuid + ":" + work.getAttempt(), PROOF_ID_MAX); + } + + /** 页末键=本轮可比较的边界;空页返回 null(该侧不设界) */ + private static String lastKey(List rows) { + return rows.isEmpty() ? null : rows.get(rows.size() - 1).getAnswerUuid(); + } + + private static String lastLocalKey(List rows) { + return rows.isEmpty() ? null : rows.get(rows.size() - 1).getAnswerUuid(); + } + + /** 水位=两侧页末键的较小者;一侧空页则该侧不设界,取另一侧 */ + private static String minKey(String fcLast, String localLast) { + if (fcLast == null) { + return localLast; + } + if (localLast == null) { + return fcLast; + } + return fcLast.compareTo(localLast) <= 0 ? fcLast : localLast; + } + + private static boolean after(String key, String roundEnd) { + return roundEnd != null && key.compareTo(roundEnd) > 0; + } + + private static boolean isCancelledState(String state) { + return AnswerReconcileWork.isTerminal(state); + } + + /** 中心把稳定码写在异常文案里(apistd 信封不带结构化 code):这里只认码,不认文案 */ + private static String clientErrorCode(RuntimeException e) { + String message = e.getMessage() == null ? "" : e.getMessage(); + String[] codes = {"TASK_TERMINAL", FormSyncModels.ERR_CANCELLED, FormSyncModels.ERR_GRANT_REVOKED, + FormSyncModels.ERR_MANIFEST_INCOMPLETE, FormSyncModels.ERR_SCAN_STALLED, + FormSyncModels.ERR_CLIENT_UNAVAILABLE}; + for (String code : codes) { + if (message.contains(code)) { + return code; + } + } + return e instanceof BizException ? "UPSTREAM_REJECTED" : "UPSTREAM_UNREACHABLE"; + } + + private static boolean equalsHash(String left, String right) { + if (left == null || right == null) { + return false; + } + return left.trim().equalsIgnoreCase(right.trim()); + } + + private static boolean blank(String raw) { + return raw == null || raw.trim().isEmpty(); + } + + private static int intOf(Integer value) { + return value == null ? 0 : value; + } + + private static String truncate(String raw, int max) { + if (raw == null) { + return null; + } + return raw.length() <= max ? raw : raw.substring(0, max); + } + + private static LocalDateTime parse(String raw) { + if (blank(raw)) { + return null; + } + try { + return LocalDateTime.parse(raw.trim(), TS); + } catch (RuntimeException e) { + return null; + } + } + + private long backoffSeconds(int round) { + long base = Math.max(1, backoffBaseSeconds); + long cap = Math.max(base, backoffCapSeconds); + int n = Math.min(Math.max(0, round), 20); + long value = base * (1L << n); + return value <= 0 || value > cap ? cap : value; + } + + // ==================== JSON 检查点 ==================== + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + return "[]"; + } + } + + private List readJson(String json) { + if (blank(json)) { + return new ArrayList<>(); + } + try { + List rows = objectMapper.readValue(json, + new TypeReference>() { + }); + return rows == null ? new ArrayList() : new ArrayList<>(rows); + } catch (Exception e) { + log.warn("[对账] 多余行候选检查点读不出来,按空候选继续(只报告不退役):{}", e.toString()); + return new ArrayList<>(); + } + } + + private List readErrors(String json) { + if (blank(json)) { + return new ArrayList<>(); + } + try { + List rows = objectMapper.readValue(json, new TypeReference>() { + }); + return rows == null ? new ArrayList() : new ArrayList<>(rows); + } catch (Exception e) { + return new ArrayList<>(); + } + } + + /** 本地行的轻量重读视图(只在并发重试路径上构造,不参与常规扫描) */ + private static final class LocalRowView implements ManifestRow { + private final String answerUuid; + private final Long revision; + private final String contentHash; + private final Integer isDeleted; + private final Long schId; + private final LocalDateTime createTime; + + LocalRowView(String answerUuid, Long revision, String contentHash, Integer isDeleted, Long schId, + LocalDateTime createTime) { + this.answerUuid = answerUuid; + this.revision = revision; + this.contentHash = contentHash; + this.isDeleted = isDeleted; + this.schId = schId; + this.createTime = createTime; + } + + @Override + public String getAnswerUuid() { + return answerUuid; + } + + @Override + public Long getRevision() { + return revision; + } + + @Override + public String getContentHash() { + return contentHash; + } + + @Override + public Integer getIsDeleted() { + return isDeleted; + } + + @Override + public Long getSchId() { + return schId; + } + + @Override + public LocalDateTime getCreateTime() { + return createTime; + } + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/FcAnswerConsumer.java b/src/main/java/com/yunhai/svchall/service/impl/FcAnswerConsumer.java new file mode 100644 index 0000000..08bd96a --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/FcAnswerConsumer.java @@ -0,0 +1,176 @@ +package com.yunhai.svchall.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rabbitmq.client.Channel; +import com.yunhai.svchall.config.FcAnswerMqConfiguration; +import com.yunhai.svchall.entity.FcAnswerInbox; +import com.yunhai.svchall.model.FcApplyModels.AppliedReport; +import com.yunhai.svchall.model.FcApplyModels.SnapshotEnvelope; +import com.yunhai.svchall.repository.FcAnswerInboxRepository; +import com.yunhai.svchall.service.impl.AnswerProjectionStore.Disposition; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.amqp.support.AmqpHeaders; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; + +/** + * v2 快照消费者(answer-delivery-v2 §7,TASK_2 B.5/B.6)。 + * + *

本类由 {@link FcAnswerMqConfiguration} 在已配置 broker 时才绑上监听容器; + * 未接 MQ 的环境里它只是"没人投递的一个 bean",但投影链仍可用——可信建件的第二条路径 + * (签名收据确认)不依赖 broker,这也是本机联调能跑通全链的原因。

+ * + *

三条 ack 纪律:

+ *
    + *
  1. DB 提交成功才 manual ack:{@link AnswerProjectionStore#applySnapshot} 是一个事务, + * 正常返回即已提交,此后才 ack;抛异常一律 nack 且不 requeue(容器有界重试 3 次后 + * 进可查死信),绝不"先 ack 再慢慢处理";
  2. + *
  3. 毒消息不热循环:协议/闭集校验不过=REJECTED,记台账后 ack—— + * 重投一百次也不会变成合法,排查靠 {@code fc_answer_inbox} 与死信队列而不是日志翻页;
  4. + *
  5. 结果回执与 ack 是两件事:ack 只说明本应用消费掉了,中心要的是"业务落库了没有", + * 所以 ack 之后再签名上报 {@code consumerApplied};上报失败不回滚已落库的投影, + * 台账留 PENDING 由重报扫描追平。
  6. + *
+ */ +@Slf4j +@Component +public class FcAnswerConsumer { + + private final AnswerProjectionStore projectionStore; + private final FcSubmissionClient submissionClient; + private final FcAnswerInboxRepository inboxRepository; + private final ObjectMapper objectMapper; + + @Value("${svchall.fc.consumer-app-key:${yh.app-auth.app-key:100018}}") + private String consumerAppKey; + + @Value("${svchall.fc.report-batch-size:20}") + private int reportBatchSize; + + public FcAnswerConsumer(AnswerProjectionStore projectionStore, FcSubmissionClient submissionClient, + FcAnswerInboxRepository inboxRepository, ObjectMapper objectMapper) { + this.projectionStore = projectionStore; + this.submissionClient = submissionClient; + this.inboxRepository = inboxRepository; + this.objectMapper = objectMapper; + } + + /** 队列与容器由 {@link FcAnswerMqConfiguration} 声明(durable、私有 routing key、死信队列) */ + @RabbitListener(queues = FcAnswerMqConfiguration.QUEUE, + containerFactory = "fcAnswerContainerFactory", ackMode = "MANUAL") + public void onMessage(Message message, @Header(AmqpHeaders.CHANNEL) Channel channel, + @Header(AmqpHeaders.DELIVERY_TAG) Long deliveryTag) throws IOException { + SnapshotEnvelope envelope = parse(message); + Disposition disposition; + if (envelope == null) { + // 连 JSON 都解析不了:不是"稍后再试"的问题,记一条无坐标台账后直接 ack(防毒循环) + log.error("[fc-mq] 载荷不是合法 JSON(丢弃并 ack):bytes={} contentType={}", + message.getBody().length, message.getMessageProperties().getContentType()); + channel.basicAck(tag(deliveryTag), false); + return; + } + try { + disposition = projectionStore.applySnapshot(envelope); + } catch (Exception e) { + log.error("[fc-mq] 投影写入失败,nack 且不重入队(走死信,容器有界重试已用完):eventId={} cause={}", + envelope.getEventId(), e.toString()); + channel.basicNack(tag(deliveryTag), false, false); + return; + } + channel.basicAck(tag(deliveryTag), false); + log.info("[fc-mq] 已 ack:eventId={} outcome={} state={}", envelope.getEventId(), + disposition.getOutcome(), disposition.getState()); + if (disposition.shouldReport()) { + reportQuietly(envelope, disposition); + } + } + + private SnapshotEnvelope parse(Message message) { + try { + return objectMapper.readValue(new String(message.getBody(), StandardCharsets.UTF_8), + SnapshotEnvelope.class); + } catch (Exception e) { + return null; + } + } + + /** consumerApplied 上报(失败只留台账,不影响已提交的事实) */ + private void reportQuietly(SnapshotEnvelope envelope, Disposition disposition) { + AppliedReport report = new AppliedReport(); + report.setEventId(envelope.getEventId()); + report.setConsumerAppKey(consumerAppKey == null ? envelope.getConsumerAppKey() : consumerAppKey.trim()); + report.setConsumerSchoolId(envelope.getConsumerSchoolId()); + report.setAnswerUuid(envelope.getAnswerUuid()); + report.setAnswerRevision(envelope.getAnswerRevision()); + report.setContentHash(envelope.getContentHash()); + report.setOutcome(disposition.getState()); + report.setLocalRef(disposition.getApplyId() == null ? null : "apply:" + disposition.getApplyId()); + if (submissionClient.reportApplied(report) != null) { + markReported(envelope.getEventId(), FcAnswerInbox.REPORT_SENT, null); + } else { + markReported(envelope.getEventId(), FcAnswerInbox.REPORT_FAILED, "上报失败,待重报扫描"); + } + } + + private void markReported(String eventId, String state, String detail) { + try { + inboxRepository.findByEventIdAndConsumerAppKey(eventId, + consumerAppKey == null ? "" : consumerAppKey.trim()).ifPresent(row -> { + row.setReportState(state); + row.setReportAttempts((row.getReportAttempts() == null ? 0 : row.getReportAttempts()) + 1); + if (FcAnswerInbox.REPORT_SENT.equals(state)) { + row.setReportedAt(LocalDateTime.now()); + row.setNextReportAt(null); + } else { + row.setNextReportAt(LocalDateTime.now().plusSeconds(60)); + } + if (detail != null) { + row.setDetail(detail.length() > 500 ? detail.substring(0, 500) : detail); + } + inboxRepository.save(row); + }); + } catch (Exception e) { + log.warn("[fc-mq] 回执台账更新失败 eventId={}:{}", eventId, e.toString()); + } + } + + /** 重报扫描:本地已落库但中心没收到确认的那些(consumerApplied 只能由这条链补全) */ + @Scheduled(fixedDelayString = "${svchall.fc.report-scan-interval-ms:120000}", + initialDelayString = "${svchall.fc.report-scan-initial-delay-ms:60000}") + public int retryPendingReports() { + List due = inboxRepository.findDueReports(LocalDateTime.now(), + PageRequest.of(0, Math.max(1, reportBatchSize))); + int sent = 0; + for (FcAnswerInbox row : due) { + AppliedReport report = new AppliedReport(); + report.setEventId(row.getEventId()); + report.setConsumerAppKey(row.getConsumerAppKey()); + report.setConsumerSchoolId(row.getConsumerSchoolId()); + report.setAnswerUuid(row.getAnswerUuid()); + report.setAnswerRevision(row.getRevision()); + report.setContentHash(row.getContentHash()); + report.setOutcome(row.getState()); + if (submissionClient.reportApplied(report) != null) { + markReported(row.getEventId(), FcAnswerInbox.REPORT_SENT, null); + sent++; + } else { + markReported(row.getEventId(), FcAnswerInbox.REPORT_FAILED, null); + } + } + return sent; + } + + private static long tag(Long deliveryTag) { + return deliveryTag == null ? 0L : deliveryTag; + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/FcApplyIntentService.java b/src/main/java/com/yunhai/svchall/service/impl/FcApplyIntentService.java new file mode 100644 index 0000000..5401aee --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/FcApplyIntentService.java @@ -0,0 +1,426 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.entity.FcAnswerProjection; +import com.yunhai.svchall.entity.FcApplyIntent; +import com.yunhai.svchall.entity.ServiceItem; +import com.yunhai.svchall.enums.ServiceItemStatusEnum; +import com.yunhai.svchall.model.FcApplyModels.ContextIssueRequest; +import com.yunhai.svchall.model.FcApplyModels.ContextIssueView; +import com.yunhai.svchall.model.FcApplyModels.ContextReceiptView; +import com.yunhai.svchall.model.FcApplyModels.IntentConfirmView; +import com.yunhai.svchall.model.FcApplyModels.IntentCreateRequest; +import com.yunhai.svchall.model.FcApplyModels.IntentView; +import com.yunhai.svchall.model.FcApplyModels.Snapshot; +import com.yunhai.svchall.model.svc.SvcModels; +import com.yunhai.svchall.repository.FcAnswerProjectionRepository; +import com.yunhai.svchall.repository.FcApplyIntentRepository; +import com.yunhai.svchall.repository.ServiceItemRepository; +import com.yunhai.svchall.service.SiteDirectory; +import com.yunhai.svchall.service.SiteGuard; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Optional; + +/** + * 可信建件意图服务(answer-delivery-v2 §6,TASK_2 A.1/A.2/A.4/A.5)。 + * + *

两个动作,一条不变式:办件的建立只能由服务端事实驱动。

+ *
    + *
  1. {@link #createIntent}:会话取人与校 → 事项入口逐条判(已发布、{@code applyUrl} 为空、 + * {@code onlineFlag==1}、TASK_1 的成对 FC 绑定与确切版本、{@code flowGuid} 就绪)→ + * 签名换上下文。任何一条不过就既不建办件也不调流程;浏览器 body 里只有事项坐标 + * 与自己的幂等键,注入不了 submitter/schId/flowGuid(这些值本方法只从会话与事项行取)。
  2. + *
  3. {@link #confirmIntent}:浏览器那句"提交成功了"只被当作"可以去读收据"的信号—— + * 答卷坐标、提交者、版本一律取中心签名收据;收据没确认到答卷就不建件, + * postMessage 里就算被人塞了一个 answerId 也没有任何用处(本方法不读它)。
  4. + *
+ * + *

{@code fcIntentId} 由自然键(校/人/事项/客户端幂等键)哈希得来,不是随机串: + * 并发双 POST 会算出同一个意图键,中心侧 {@code uk_ctx_intent} 与本地侧 + * {@code uk_intent_client} 各自把"两个业务意图"关成一个。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FcApplyIntentService { + + private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final FcApplyIntentRepository intentRepository; + private final FcAnswerProjectionRepository projectionRepository; + private final ServiceItemRepository itemRepository; + private final SiteDirectory siteDirectory; + private final SiteGuard siteGuard; + private final FcSubmissionClient submissionClient; + private final ServiceApplyService applyService; + private final TransactionTemplate transactionTemplate; + + /** 本应用出站 appKey(=yh.app-auth.app-key;意图行按它锁归属) */ + @Value("${yh.app-auth.app-key:100018}") + private String consumerAppKey; + + /** FC 前端基址(被嵌页地址由服务端拼,不接受浏览器给的路径;缺键即 fail-closed) */ + @Value("${svchall.fc.fill-page-base-url:}") + private String fcFillPageBaseUrl; + + /** + * 创建(或复用)可信建件意图并换取提交上下文。 + * + *

顺序是刻意的:入口判定全过 → 落意图行 → 签名调中心 → 回写令牌与地址。 + * 中心调用失败时意图行留在 {@code ISSUED}(令牌空),前端重试同一条幂等键会走同一行、 + * 重新换取,不会产生第二个业务意图。

+ */ + public IntentView createIntent(BoaUser user, IntentCreateRequest req) { + if (user == null || user.getId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "未获取到登录用户"); + } + if (req == null || blank(req.getClientKey())) { + throw new BizException(ErrorCode.PARAM_ERROR, "clientKey 必填(客户端幂等键)"); + } + SiteDirectory.SiteInfo site = siteDirectory.requireSite(req.getSiteCode()); + siteGuard.requireSchoolMember(user, site.getSchId()); + ServiceItem item = itemRepository.findBySchIdAndSiteCodeAndItemCode( + site.getSchId(), site.getSiteCode(), req.getItemCode()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "事项不存在")); + requireHostableEntry(site.getSchId(), user, item); + + String clientKey = req.getClientKey().trim(); + Optional existing = intentRepository.findBySchIdAndSubmitterIdAndItemIdAndClientKey( + site.getSchId(), user.getId(), item.getId(), clientKey); + if (existing.isPresent()) { + FcApplyIntent intent = existing.get(); + if (intent.getContextToken() != null && !intent.getContextToken().trim().isEmpty()) { + return toView(intent, true); + } + return issueInto(intent, true); + } + + FcApplyIntent intent = new FcApplyIntent(); + intent.setSchId(site.getSchId()); + intent.setSiteCode(site.getSiteCode()); + intent.setItemId(item.getId()); + intent.setItemCode(item.getItemCode()); + intent.setItemTitle(item.getTitle()); + intent.setSubmitterId(user.getId()); + intent.setSubmitterName(user.getName() == null ? "" : user.getName()); + intent.setClientKey(clientKey); + intent.setFcFormUuid(item.getFcFormUuid()); + intent.setFcFormVersion(item.getFcFormVersion()); + intent.setFlowGuid(item.getFlowGuid()); + intent.setConsumerAppKey(consumerAppKey == null ? "" : consumerAppKey.trim()); + intent.setFcIntentId(fcIntentId(intent)); + intent.setState(FcApplyIntent.STATE_ISSUED); + FcApplyIntent toSave = intent; + intent = transactionTemplate.execute(tx -> intentRepository.saveAndFlush(toSave)); + return issueInto(intent, false); + } + + /** + * 确认:读中心收据 → 逐字复核坐标 → 合并投影 → 建件(一次)。 + * + *

已建过的意图直接回原结论(幂等);收据还没有答卷就返回"尚未提交", + * 不建件、也不报错——事件先到那条路径会把投影补上。

+ */ + public IntentConfirmView confirm(BoaUser user, Integer intentId) { + FcApplyIntent intent = requireOwnIntent(user, intentId); + if (intent.getApplyId() != null) { + return confirmView(intent, intent.getApplyId(), "已建件(幂等返回原办件)"); + } + if (blank(intent.getFcIntentId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "该意图尚未取得提交上下文,无法确认"); + } + ContextReceiptView receipt = submissionClient.fetchReceipt(intent.getFcIntentId()); + requireReceiptForIntent(receipt, intent); + if (!"SUBMITTED".equals(receipt.getStatus()) || blank(receipt.getAnswerUuid())) { + if (Boolean.TRUE.equals(receipt.getExpired()) && intent.getExpiresAt() != null + && intent.getExpiresAt().isBefore(LocalDateTime.now())) { + intent.setState(FcApplyIntent.STATE_VOID); + intentRepository.save(intent); + } + return confirmView(intent, null, + Boolean.TRUE.equals(receipt.getExpired()) + ? "提交上下文已过期且未收到答卷,请重新发起" + : "中心尚未收到答卷,填报完成后本页会自动确认"); + } + + FcAnswerProjection projection = upsertProjectionFromReceipt(intent, receipt); + transactionTemplate.executeWithoutResult(tx -> { + intent.setAnswerUuid(receipt.getAnswerUuid()); + intent.setAnswerRevision(receipt.getAnswerRevision()); + intent.setContentHash(receipt.getContentHash()); + intent.setState(FcApplyIntent.STATE_ANSWERED); + intent.setConfirmedAt(LocalDateTime.now()); + intentRepository.save(intent); + }); + + Integer applyId = applyService.createFromVerifiedIntent(intent, projection); + transactionTemplate.executeWithoutResult(tx -> { + intent.setApplyId(applyId); + intent.setState(FcApplyIntent.STATE_APPLIED); + intentRepository.save(intent); + projection.setIntentId(intent.getId()); + projection.setApplyId(applyId); + projection.setLinkState(FcAnswerProjection.LINK_APPLIED); + projectionRepository.save(projection); + }); + log.info("[svc-hall] 可信建件完成(确认路径)intent={} answer={} apply={}", intent.getId(), + receipt.getAnswerUuid(), applyId); + return confirmView(intent, applyId, null); + } + + // ==================== 内部 ==================== + + /** 入口判定(A.1 的六条,一条不过就不建件不调流程) */ + private void requireHostableEntry(Long schId, BoaUser user, ServiceItem item) { + if (item.getStatus() == null || item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) { + throw new BizException(ErrorCode.PARAM_ERROR, "事项未发布,不可办理"); + } + if (!blank(item.getApplyUrl())) { + throw new BizException(ErrorCode.PARAM_ERROR, + "该事项走手填外链办理,不适用可信建件通道:" + item.getItemCode()); + } + if (!Integer.valueOf(1).equals(item.getOnlineFlag())) { + throw new BizException(ErrorCode.PARAM_ERROR, "该事项未开通在线办理"); + } + if (blank(item.getFcFormUuid()) || item.getFcFormVersion() == null || item.getFcFormVersion() <= 0) { + throw new BizException(ErrorCode.PARAM_ERROR, + "该事项未绑定表单中心的确切表单版本,不能可信建件(请到管理端补绑定):" + item.getItemCode()); + } + if (blank(item.getFlowGuid())) { + throw new BizException(ErrorCode.PARAM_ERROR, "该事项未绑定办理流程,不能可信建件"); + } + } + + private IntentView issueInto(FcApplyIntent intent, boolean reused) { + ContextIssueRequest request = new ContextIssueRequest(); + request.setIntentId(intent.getFcIntentId()); + request.setConsumerSchoolId(intent.getSchId()); + request.setFormUuid(intent.getFcFormUuid()); + request.setFormVersion(intent.getFcFormVersion()); + request.setSubmitterId(intent.getSubmitterId()); + ContextIssueView issued = submissionClient.issueContext(request); + + intent.setContextToken(issued.getContextToken()); + intent.setEmbedOrigin(issued.getEmbedOrigin()); + intent.setExpiresAt(parse(issued.getExpiresAt())); + intent.setFillPageUrl(fillPageUrl(issued)); + intent.setState(FcApplyIntent.STATE_ISSUED); + transactionTemplate.executeWithoutResult(tx -> intentRepository.save(intent)); + log.info("[svc-hall] 提交上下文就绪 intent={} fcIntent={} reused={} origin={}", intent.getId(), + intent.getFcIntentId(), reused, intent.getEmbedOrigin()); + return toView(intent, reused); + } + + /** 被嵌页地址:FC 前端基址 + 表单 code + ctx;基址未配就拒(不给一个拼不出来的入口) */ + private String fillPageUrl(ContextIssueView issued) { + if (blank(fcFillPageBaseUrl)) { + throw new BizException(ErrorCode.CUSTOM, + "被嵌填报页基址未配置(svchall.fc.fill-page-base-url),不能打开托管填报页"); + } + String base = fcFillPageBaseUrl.trim(); + while (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + return base + "/f/" + issued.getCode() + + "?mode=embed&appCode=" + urlEncode(issued.getAppCode()) + + "&ctx=" + urlEncode(issued.getContextToken()); + } + + private FcApplyIntent requireOwnIntent(BoaUser user, Integer intentId) { + if (user == null || user.getId() == null || intentId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少登录用户或意图 id"); + } + Long schId = schoolOf(user); + FcApplyIntent intent = intentRepository.findBySchIdAndId(schId, intentId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办理意图不存在")); + if (!user.getId().equals(intent.getSubmitterId())) { + // 只允许本人确认自己的意图(与"我的办件"自见同口径) + throw new BizException(ErrorCode.FORBIDDEN, "只能确认本人的办理意图"); + } + return intent; + } + + /** 收据逐字复核(A.4:所属消费应用/学校/提交者/表单坐标都要与本地意图一致) */ + private void requireReceiptForIntent(ContextReceiptView receipt, FcApplyIntent intent) { + if (receipt == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单中心收据为空"); + } + if (!blank(receipt.getConsumerAppKey()) && !receipt.getConsumerAppKey().trim().equals(consumerApp())) { + throw new BizException(ErrorCode.FORBIDDEN, "收据所属消费应用与本应用不符"); + } + if (receipt.getConsumerSchoolId() != null && !receipt.getConsumerSchoolId().equals(intent.getSchId())) { + throw new BizException(ErrorCode.FORBIDDEN, "收据学校与本意图不符"); + } + if (receipt.getSubmitterId() != null && !receipt.getSubmitterId().equals(intent.getSubmitterId())) { + throw new BizException(ErrorCode.FORBIDDEN, "收据提交人与本意图提交者不符"); + } + if (receipt.getFormUuid() != null && !receipt.getFormUuid().equals(intent.getFcFormUuid())) { + throw new BizException(ErrorCode.FORBIDDEN, "收据表单坐标与本意图不符"); + } + if (receipt.getFormVersion() != null && !receipt.getFormVersion().equals(intent.getFcFormVersion())) { + throw new BizException(ErrorCode.FORBIDDEN, "收据表单版本与本意图不符"); + } + } + + /** + * 收据带快照 → 落/更本地投影(这条路径不依赖 MQ 恰好已到)。 + * 与事件路径同一条 revision/hash 规则:同版异 hash 记冲突不覆盖。 + */ + private FcAnswerProjection upsertProjectionFromReceipt(FcApplyIntent intent, ContextReceiptView receipt) { + Snapshot snapshot = receipt.getSnapshot(); + String hash = receipt.getContentHash(); + if (blank(hash) && snapshot != null) { + hash = AnswerProjectionStore.canonicalHash(snapshot); + } + FcAnswerProjection projection = projectionRepository + .findByAnswerUuidAndSchId(receipt.getAnswerUuid(), intent.getSchId()).orElse(null); + if (projection == null) { + projection = new FcAnswerProjection(); + projection.setSchId(intent.getSchId()); + projection.setAnswerUuid(receipt.getAnswerUuid()); + projection.setFormUuid(intent.getFcFormUuid()); + projection.setFormVersion(intent.getFcFormVersion()); + projection.setRevision(receipt.getAnswerRevision()); + projection.setContentHash(hash); + projection.setIsDeleted(0); + fillFromSnapshot(projection, snapshot); + } else { + long stored = projection.getRevision() == null ? 0L : projection.getRevision(); + long incoming = receipt.getAnswerRevision() == null ? 0L : receipt.getAnswerRevision(); + if (incoming > stored) { + projection.setRevision(incoming); + projection.setContentHash(hash); + fillFromSnapshot(projection, snapshot); + } else if (incoming == stored && hash != null && !hash.equalsIgnoreCase(projection.getContentHash())) { + projection.setConflictFlag(1); + projection.setConflictDetail("同 revision 异 hash(收据侧):stored=" + + projection.getContentHash() + " receipt=" + hash); + } + } + projection.setIntentId(intent.getId()); + projection.setFlowGuid(intent.getFlowGuid()); + projection.setLinkState(intent.getApplyId() == null + ? FcAnswerProjection.LINK_LINKED : FcAnswerProjection.LINK_APPLIED); + return projectionRepository.save(projection); + } + + private void fillFromSnapshot(FcAnswerProjection projection, Snapshot snapshot) { + if (snapshot == null) { + return; + } + projection.setAppCode(snapshot.getAppCode()); + projection.setCode(snapshot.getCode()); + projection.setSubmitterId(snapshot.getSubmitterId()); + projection.setSubjectId(snapshot.getSubjectId()); + projection.setStatus(snapshot.getStatus()); + projection.setValuesJson(snapshot.getValues() == null ? "{}" : snapshot.getValues().toString()); + projection.setAnswerNo(snapshot.getAnswerNo()); + projection.setAnswerCreateTime(snapshot.getCreateTime()); + projection.setAnswerUpdateTime(snapshot.getUpdateTime()); + } + + private IntentView toView(FcApplyIntent intent, boolean reused) { + IntentView view = new IntentView(); + view.setIntentId(intent.getId()); + view.setFcIntentId(intent.getFcIntentId()); + view.setState(intent.getState()); + view.setSiteCode(intent.getSiteCode()); + view.setItemCode(intent.getItemCode()); + view.setItemTitle(intent.getItemTitle()); + view.setFcFormUuid(intent.getFcFormUuid()); + view.setFcFormVersion(intent.getFcFormVersion()); + view.setFlowGuid(intent.getFlowGuid()); + view.setFillPageUrl(intent.getFillPageUrl()); + view.setContextToken(intent.getContextToken()); + view.setExpiresAt(intent.getExpiresAt() == null ? null : TS.format(intent.getExpiresAt())); + view.setReused(reused); + return view; + } + + private IntentConfirmView confirmView(FcApplyIntent intent, Integer applyId, String message) { + IntentConfirmView view = new IntentConfirmView(); + view.setIntentId(intent.getId()); + view.setState(intent.getState()); + view.setAnswerUuid(intent.getAnswerUuid()); + view.setAnswerRevision(intent.getAnswerRevision()); + view.setContentHash(intent.getContentHash()); + view.setApplyId(applyId); + view.setMessage(message); + if (applyId != null) { + SvcModels.ApplyVo vo = applyService.detailOfApplyId(applyId); + if (vo != null) { + view.setFlowStatus(vo.getFlowStatus()); + view.setFlowEnabled(vo.getFlowEnabled()); + } + } + return view; + } + + /** 自然键哈希 → 稳定的中心意图键(并发双 POST 只会得到一个意图) */ + private String fcIntentId(FcApplyIntent intent) { + String raw = intent.getSchId() + "|" + intent.getSubmitterId() + "|" + intent.getItemId() + + "|" + intent.getClientKey(); + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(raw.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (int i = 0; i < 16 && i < digest.length; i++) { + hex.append(String.format("%02x", digest[i])); + } + return "svchall-" + hex; + } catch (Exception e) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "业务意图键生成失败"); + } + } + + private Long schoolOf(BoaUser user) { + if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "当前用户无学校归属,无法办理"); + } + return user.getCurrentRole().getSchId().longValue(); + } + + private String consumerApp() { + return consumerAppKey == null ? "" : consumerAppKey.trim(); + } + + private static LocalDateTime parse(String text) { + if (text == null || text.trim().isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(text.trim()); + } catch (Exception e) { + try { + return LocalDateTime.parse(text.trim(), TS); + } catch (Exception again) { + return null; + } + } + } + + private static String urlEncode(String value) { + if (value == null) { + return ""; + } + try { + return java.net.URLEncoder.encode(value, StandardCharsets.UTF_8.name()); + } catch (Exception e) { + return value; + } + } + + private static boolean blank(String raw) { + return raw == null || raw.trim().isEmpty(); + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/FcSubmissionClient.java b/src/main/java/com/yunhai/svchall/service/impl/FcSubmissionClient.java new file mode 100644 index 0000000..bd2e9de --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/FcSubmissionClient.java @@ -0,0 +1,149 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.core.YschOpenCall; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.svchall.model.FcApplyModels.AppliedReport; +import com.yunhai.svchall.model.FcApplyModels.AppliedReportView; +import com.yunhai.svchall.model.FcApplyModels.ContextIssueRequest; +import com.yunhai.svchall.model.FcApplyModels.ContextIssueView; +import com.yunhai.svchall.model.FcApplyModels.ContextReceiptView; +import com.yunhai.svchall.model.FcApplyModels.GrantSnapshotView; +import com.yunhai.svchall.model.FcApplyModels.GrantVerifyRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +/** + * 表单中心可信建件客户端(answer-delivery-v2 §6/§7,TASK_2 A.2/A.4/B.6)。 + * + *

与 {@link FormCenterCatalogClient}(TASK_1 的目录/绑定核验)同一条出站范式: + * 命名目标 {@code form-center} 经 {@link YschOpenCall} 走服务目录三级兜底, + * 身份=本应用 appKey(starter 签名拦截器注入),appKey/secret 绝不进浏览器。 + * 本类只增加端点,不复用也不修改 TASK_1 那个客户端的职责范围。

+ * + *

四条出站调用各有明确的失败语义,不搞"统一 catch 后返回 null":

+ *
    + *
  • {@link #issueContext} 失败=意图不能进入填报,直接拒(没有可信上下文就没有可信建件);
  • + *
  • {@link #fetchReceipt} 失败=确认动作不做,办件不形成(收据是唯一的答卷事实源);
  • + *
  • {@link #reportApplied} 失败不回滚本地投影:中心没收到确认只是"对方还不知道", + * 台账里留 PENDING 由重报扫描追平;
  • + *
  • {@link #verifyGrants} 失败=消费端暂缓写入(撤权竞态口径),而不是按"还有效"放行。
  • + *
+ */ +@Slf4j +@Component +public class FcSubmissionClient { + + /** 服务目录 key(同 TASK_1 客户端,一个目标不拆两个名字) */ + static final String TARGET = "form-center"; + static final String CONTEXT_PATH = "/oapi/form/submission-context"; + static final String DELIVERY_RECEIPT_PATH = "/oapi/form/answer-delivery/receipt"; + static final String GRANT_VERIFY_PATH = "/oapi/form/answer-delivery/grants/verify"; + + private final YschOpenCall openCall; + + public FcSubmissionClient(YschOpenCall openCall) { + this.openCall = openCall; + } + + /** 签发/复用可信提交上下文(同意图重试回到同一上下文,不新建第二业务意图) */ + public ContextIssueView issueContext(ContextIssueRequest request) { + requireRequest(request); + try { + ContextIssueView data = openCall.post(TARGET, CONTEXT_PATH, Collections.emptyMap(), + request, ContextIssueView.class); + if (data == null || blank(data.getContextToken())) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单中心提交上下文返回异常(缺少 contextToken)"); + } + if (!request.getIntentId().equals(data.getIntentId())) { + throw new BizException(ErrorCode.PARAM_ERROR, + "表单中心返回的意图键与请求不符(请求 " + request.getIntentId() + + ",返回 " + data.getIntentId() + ")"); + } + if (blank(data.getEmbedOrigin())) { + // 中心锁定的可嵌入 origin 是 postMessage 的唯一目标;缺了就只能拒,不能自己拼一个 + throw new BizException(ErrorCode.PARAM_ERROR, "表单中心未返回可嵌入 origin,已拒绝打开填报页"); + } + return data; + } catch (BizException e) { + throw e; + } catch (RuntimeException e) { + log.warn("[svc-hall] 提交上下文签发失败 intentId={}:{}", request.getIntentId(), e.toString()); + throw new BizException(ErrorCode.PARAM_ERROR, + "表单中心提交上下文不可达(网络/授权未配置):" + e.getMessage()); + } + } + + /** 读取服务端收据(确认动作的唯一数据源;浏览器 success 只是"可以去读"的信号) */ + public ContextReceiptView fetchReceipt(String fcIntentId) { + if (blank(fcIntentId)) { + throw new BizException(ErrorCode.PARAM_ERROR, "fcIntentId 必填"); + } + try { + ContextReceiptView data = openCall.get(TARGET, CONTEXT_PATH + "/" + fcIntentId + "/receipt", + Collections.emptyMap(), ContextReceiptView.class); + if (data == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单中心收据返回空 data"); + } + return data; + } catch (BizException e) { + throw e; + } catch (RuntimeException e) { + log.warn("[svc-hall] 收据读取失败 intentId={}:{}", fcIntentId, e.toString()); + throw new BizException(ErrorCode.PARAM_ERROR, + "表单中心收据不可达(网络/授权未配置),本次不建件:" + e.getMessage()); + } + } + + /** 上报本地应用结果(consumerApplied);失败只记台账,不回滚已落库的投影 */ + public AppliedReportView reportApplied(AppliedReport report) { + try { + return openCall.post(TARGET, DELIVERY_RECEIPT_PATH, Collections.emptyMap(), + report, AppliedReportView.class); + } catch (RuntimeException e) { + log.warn("[svc-hall] 投递回执上报失败 eventId={}:{}", report == null ? null : report.getEventId(), + e.toString()); + return null; + } + } + + /** 批量授权核验(消费端写投影前的闸门;不可用即暂缓,不按"还有效"放行) */ + public List verifyGrants(Long consumerSchoolId, List formUuids) { + GrantVerifyRequest body = new GrantVerifyRequest(); + body.setConsumerSchoolId(consumerSchoolId); + body.setFormUuids(formUuids == null ? Collections.emptyList() : formUuids); + try { + GrantSnapshotView[] data = openCall.post(TARGET, GRANT_VERIFY_PATH, Collections.emptyMap(), + body, GrantSnapshotView[].class); + if (data == null) { + throw new IllegalStateException("grants/verify 返回空 data"); + } + return java.util.Arrays.asList(data); + } catch (RuntimeException e) { + log.warn("[svc-hall] 授权核验失败 school={}:{}", consumerSchoolId, e.toString()); + throw new BizException(ErrorCode.PARAM_ERROR, "表单中心授权核验不可达,本次暂缓写入"); + } + } + + private static void requireRequest(ContextIssueRequest request) { + if (request == null || blank(request.getIntentId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "intentId 必填(本应用服务端生成的业务意图键)"); + } + if (request.getConsumerSchoolId() == null || request.getConsumerSchoolId() <= 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "consumerSchoolId 必须是真实学校"); + } + if (blank(request.getFormUuid()) || request.getFormVersion() == null || request.getFormVersion() <= 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单坐标必须成对且版本确切"); + } + if (request.getSubmitterId() == null || request.getSubmitterId() <= 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "submitterId 必填(会话用户,不接受浏览器传入)"); + } + } + + private static boolean blank(String raw) { + return raw == null || raw.trim().isEmpty(); + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/FlowApplyRecoveryService.java b/src/main/java/com/yunhai/svchall/service/impl/FlowApplyRecoveryService.java new file mode 100644 index 0000000..b49b3bd --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/FlowApplyRecoveryService.java @@ -0,0 +1,412 @@ +package com.yunhai.svchall.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.svchall.entity.FlowApplyCommand; +import com.yunhai.svchall.entity.ServiceApply; +import com.yunhai.svchall.enums.ApplyFlowStatusEnum; +import com.yunhai.svchall.model.FcApplyModels; +import com.yunhai.svchall.repository.FlowApplyCommandRepository; +import com.yunhai.svchall.repository.ServiceApplyRepository; +import com.yunhai.svchall.service.FlowClient; +import com.yunhai.svchall.service.SvcBizTypes; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * 办件出站发起与恢复(answer-delivery-v2 §8,TASK_2 C.1/C.2)。 + * + *

本类取代的是"afterCommit 丢线程池"作为唯一交付手段的形态,不是那条线程池本身: + * 意图先落成 {@link FlowApplyCommand} 行(与办件同一短事务),HTTP 一律在事务外跑; + * afterCommit 只唤醒一次扫描。进程在提交与发送之间被杀,行还在库里, + * 下一次扫描(或重启后的同一扫描)继续把它推到终态——这就是任务书要的"崩溃窗口恢复"。

+ * + *

状态机只有三个方向,且"未知"是一个显式状态而不是失败:

+ *
    + *
  • {@code PENDING/FAILED} → 发送 → 成功 {@code DONE};任何异常(超时、断连、4xx、5xx) + * 都记 {@code UNKNOWN}。不做"看异常类型猜有没有受理"——出站失败与远端未受理不是一回事, + * 猜错的代价是在中心已存在的办件上再发一次;
  • + *
  • {@code UNKNOWN} → 先按业务键 GET。查到 10/20/30/40 一律回填真实状态后 {@code DONE}, + * 30/40 绝不当作网络重试再 apply(那会触发 R4 重启推进,把已拒绝/已撤回的办件又跑起来); + * 查无才用同一 {@code requestToken} 补发(中心据此识别"同一次命令的重投",不重启流程);
  • + *
  • 重试预算耗尽 → {@code DEAD},留人工处置,不无限重投。
  • + *
+ * + *

轮次:{@code attempt} 只在用户明确授权的重提时 +1(新命令、新 token); + * 恢复链的补发永远复用同一行的 attempt 与 token。

+ */ +@Slf4j +@Service +public class FlowApplyRecoveryService { + + private final FlowApplyCommandRepository commandRepository; + private final ServiceApplyRepository applyRepository; + private final FlowClient flowClient; + private final ObjectMapper objectMapper; + private final TransactionTemplate transactionTemplate; + + @Value("${svchall.flow-command.max-retries:6}") + private int maxRetries = 6; + + @Value("${svchall.flow-command.backoff-base-seconds:20}") + private long backoffBaseSeconds = 20; + + @Value("${svchall.flow-command.backoff-cap-seconds:1800}") + private long backoffCapSeconds = 1800; + + @Value("${svchall.flow-command.batch-size:50}") + private int batchSize = 50; + + private static final ExecutorService DISPATCH_EXECUTOR = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "svchall-flow-command"); + t.setDaemon(true); + return t; + }); + + @Autowired + public FlowApplyRecoveryService(FlowApplyCommandRepository commandRepository, + ServiceApplyRepository applyRepository, + FlowClient flowClient, + ObjectMapper objectMapper, + TransactionTemplate transactionTemplate) { + this.commandRepository = commandRepository; + this.applyRepository = applyRepository; + this.flowClient = flowClient; + this.objectMapper = objectMapper; + this.transactionTemplate = transactionTemplate; + } + + // ==================== 命令落库(调用方事务内) ==================== + + /** + * 在调用方事务内落一条出站命令:轮次自增、请求标识新起,载荷自带快照 + * (重放时不再回读办件行——办件可能已被回调推进)。 + */ + public FlowApplyCommand createInTransaction(ServiceApply apply, Integer intentId, String payloadJson) { + List history = commandRepository.findByApplyIdOrderByIdDesc(apply.getId()); + int attempt = history.isEmpty() ? 1 : maxAttempt(history) + 1; + FlowApplyCommand command = new FlowApplyCommand(); + command.setApplyId(apply.getId()); + command.setSchId(apply.getSchId()); + command.setSiteCode(apply.getSiteCode()); + command.setBizType(SvcFlowCallbackHandler.BIZ_TYPE); + command.setBizId(String.valueOf(apply.getId())); + command.setAttempt(attempt); + command.setRequestToken(UUID.randomUUID().toString().replace("-", "")); + command.setFlowGuid(apply.getFlowGuid()); + command.setPayloadJson(payloadJson); + command.setState(FlowApplyCommand.STATE_PENDING); + command.setIntentId(intentId); + command.setNextRetryAt(LocalDateTime.now()); + command = commandRepository.saveAndFlush(command); + log.info("[svc-hall] 出站命令落库 applyId={} attempt={} commandId={} token={}", + apply.getId(), attempt, command.getId(), shortToken(command.getRequestToken())); + return command; + } + + /** 事务提交后唤醒派发;无活动事务(单测/兜底)就地异步跑 */ + public void dispatchAfterCommit(Integer commandId) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + DISPATCH_EXECUTOR.submit(() -> runQuietly(commandId)); + } + }); + return; + } + DISPATCH_EXECUTOR.submit(() -> runQuietly(commandId)); + } + + // ==================== 执行 ==================== + + private void runQuietly(Integer commandId) { + try { + runCommand(commandId); + } catch (Exception e) { + log.warn("[svc-hall] 命令执行异常 commandId={}:{}", commandId, e.toString()); + } + } + + /** 周期恢复扫描(进程重启后追平;行锁串行,不与唤醒线程双发) */ + @Scheduled(fixedDelayString = "${svchall.flow-command.scan-interval-ms:30000}", + initialDelayString = "${svchall.flow-command.scan-initial-delay-ms:20000}") + public int recoverDue() { + List due = commandRepository.findDue(LocalDateTime.now(), + org.springframework.data.domain.PageRequest.of(0, Math.max(1, batchSize))); + for (FlowApplyCommand command : due) { + runQuietly(command.getId()); + } + return due.size(); + } + + /** 一条命令推进一步(PENDING/FAILED=发送,UNKNOWN=先查) */ + public void runCommand(Integer commandId) { + FlowApplyCommand command = claim(commandId); + if (command == null || FlowApplyCommand.STATE_DONE.equals(command.getState()) + || FlowApplyCommand.STATE_DEAD.equals(command.getState())) { + return; + } + if (FlowApplyCommand.STATE_UNKNOWN.equals(command.getState())) { + probe(command); + return; + } + send(command); + } + + private FlowApplyCommand claim(Integer commandId) { + return transactionTemplate.execute(tx -> commandRepository.findByIdForUpdate(commandId).orElse(null)); + } + + private void send(FlowApplyCommand command) { + FcApplyModels.FlowOutbound outbound = readPayload(command); + if (outbound == null) { + settle(command, FlowApplyCommand.STATE_DEAD, "命令载荷损坏"); + return; + } + if (!flowClient.enabled()) { + // 未接入不是失败:留在待办里退避,办件保持待提交(与旧链路"保留 PENDING 可重试"同口径) + bump(command, "流程中心未接入(svchall.flow.enabled=false)"); + return; + } + try { + FlowClient.ApplyResult result = flowClient.applyWithIdentity(command.getSchId(), outbound); + transactionTemplate.executeWithoutResult(tx -> { + if (command.getAttempt() != null && command.getAttempt() > 1) { + applyRepository.markResubmitted(command.getApplyId(), result.getInstanceId(), + LocalDateTime.now()); + } else { + applyRepository.markApproving(command.getApplyId(), result.getInstanceId(), + LocalDateTime.now()); + } + }); + settle(command, FlowApplyCommand.STATE_DONE, null, result.getInstanceId(), + ApplyFlowStatusEnum.APPROVING.getCode()); + log.info("[svc-hall] 命令已受理 commandId={} applyId={} instance={} duplicated={}", + command.getId(), command.getApplyId(), result.getInstanceId(), result.isDuplicated()); + } catch (Exception e) { + // 任何异常都是"结果未知":不许在这里判"应该没收到",也不许在这里重发 + markUnknown(command, e.toString()); + log.warn("[svc-hall] 代发起结果未知,转查询:commandId={} applyId={} cause={}", + command.getId(), command.getApplyId(), e.toString()); + } + } + + /** 先查后发:查到就回填真实状态(含 30/40),查无才补发同一业务键 */ + private void probe(FlowApplyCommand command) { + Map remote = null; + Exception last = null; + for (String bizType : SvcBizTypes.queryOrder()) { + try { + Map candidate = flowClient.getInstanceByBiz( + command.getSchId(), bizType, command.getBizId()); + if (candidate != null && (candidate.get("instanceId") != null || candidate.get("id") != null + || candidate.get("status") != null)) { + remote = candidate; + break; + } + } catch (Exception e) { + last = e; + } + } + if (remote == null) { + if (last != null) { + bump(command, "查询远端状态失败,稍后再查:" + last); + return; + } + log.info("[svc-hall] 远端查无该业务键,补发同业务键同 token:commandId={} bizId={}", + command.getId(), command.getBizId()); + send(command); + return; + } + Long instanceId = firstLong(remote.get("instanceId"), remote.get("id")); + Integer status = firstInt(remote.get("status")); + Integer version = firstInt(remote.get("version")); + if (status == null) { + bump(command, "远端返回无 status,继续查询"); + return; + } + transactionTemplate.executeWithoutResult(tx -> applyRepository.mirrorRemoteState( + command.getApplyId(), instanceId, status, version, LocalDateTime.now())); + settle(command, FlowApplyCommand.STATE_DONE, + "结果未知后查得远端 status=" + status + "(30/40 不重发,按 C.2 只回填)", + instanceId, status); + } + + // ==================== 状态落定 ==================== + + private void settle(FlowApplyCommand command, String state, String note) { + settle(command, state, note, null, null); + } + + private void settle(FlowApplyCommand command, String state, String note, Long instanceId, Integer status) { + transactionTemplate.executeWithoutResult(tx -> { + FlowApplyCommand row = commandRepository.findById(command.getId()).orElse(null); + if (row == null) { + return; + } + row.setState(state); + row.setSettledAt(LocalDateTime.now()); + if (instanceId != null) { + row.setRemoteInstanceId(instanceId); + } + if (status != null) { + row.setRemoteStatus(status); + } + row.setLastError(truncate(note)); + row.setNextRetryAt(FlowApplyCommand.STATE_DONE.equals(state) ? null : row.getNextRetryAt()); + commandRepository.save(row); + }); + } + + private void markUnknown(FlowApplyCommand command, String error) { + transactionTemplate.executeWithoutResult(tx -> { + FlowApplyCommand row = commandRepository.findById(command.getId()).orElse(null); + if (row == null) { + return; + } + row.setState(FlowApplyCommand.STATE_UNKNOWN); + row.setSentAt(row.getSentAt() == null ? LocalDateTime.now() : row.getSentAt()); + row.setLastError(truncate(error)); + // 未知态也按退避节奏复查(不立刻重发,避免把中心还没落库的瞬间当成"查无") + row.setNextRetryAt(LocalDateTime.now().plusSeconds(backoffSeconds( + row.getRetryCount() == null ? 1 : row.getRetryCount() + 1))); + commandRepository.save(row); + }); + } + + /** 退避重试(含"未接入"等待);耗尽即 DEAD,留可查记录,不无限循环 */ + private void bump(FlowApplyCommand command, String error) { + transactionTemplate.executeWithoutResult(tx -> { + FlowApplyCommand row = commandRepository.findById(command.getId()).orElse(null); + if (row == null) { + return; + } + int retries = (row.getRetryCount() == null ? 0 : row.getRetryCount()) + 1; + row.setRetryCount(retries); + row.setLastError(truncate(error)); + if (retries >= Math.max(1, maxRetries)) { + row.setState(FlowApplyCommand.STATE_DEAD); + row.setNextRetryAt(null); + log.error("[svc-hall] 出站命令重试耗尽→待人工处置:commandId={} applyId={} retries={} last={}", + row.getId(), row.getApplyId(), retries, error); + } else { + row.setState(FlowApplyCommand.STATE_FAILED); + row.setNextRetryAt(LocalDateTime.now().plusSeconds(backoffSeconds(retries))); + } + commandRepository.save(row); + }); + } + + private long backoffSeconds(int retries) { + long base = Math.max(1L, backoffBaseSeconds); + long cap = Math.max(base, backoffCapSeconds); + long delay = base; + for (int i = 1; i < retries && delay < cap; i++) { + delay = Math.min(cap, delay * 2); + } + return delay; + } + + // ==================== 小工具 ==================== + + /** 出站载荷(命令自持一份快照,不回读办件行,避免"重放时数据已被改掉") */ + public String payloadJson(ServiceApply apply) { + FcApplyModels.FlowOutbound outbound = new FcApplyModels.FlowOutbound(); + outbound.setFlowGuid(apply.getFlowGuid()); + outbound.setBizType(SvcFlowCallbackHandler.BIZ_TYPE); + outbound.setBizId(String.valueOf(apply.getId())); + outbound.setBizTitle(apply.getSubmitterName() + "的" + apply.getItemTitle()); + outbound.setBizUrl("/apply-detail?id=" + apply.getId()); + outbound.setBizSnapshot(apply.getItemSnapshotJson()); + outbound.setSubmitterId(apply.getSubmitterId()); + outbound.setSubmitterName(apply.getSubmitterName()); + outbound.setFormDataJson(apply.getFormDataJson()); + try { + return objectMapper.writeValueAsString(outbound); + } catch (Exception e) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "出站命令载荷序列化失败"); + } + } + + private FcApplyModels.FlowOutbound readPayload(FlowApplyCommand command) { + try { + FcApplyModels.FlowOutbound outbound = objectMapper.readValue(command.getPayloadJson(), + FcApplyModels.FlowOutbound.class); + outbound.setRequestToken(command.getRequestToken()); + outbound.setAttempt(command.getAttempt()); + return outbound; + } catch (Exception e) { + log.error("[svc-hall] 出站命令载荷解析失败 commandId={}:{}", command.getId(), e.toString()); + return null; + } + } + + private static int maxAttempt(List history) { + int max = 0; + for (FlowApplyCommand command : history) { + if (command.getAttempt() != null) { + max = Math.max(max, command.getAttempt()); + } + } + return max; + } + + private static String truncate(String value) { + if (value == null) { + return null; + } + return value.length() <= 500 ? value : value.substring(0, 500); + } + + private static String shortToken(String token) { + return token == null || token.length() < 8 ? token : token.substring(0, 8); + } + + private static Long firstLong(Object a, Object b) { + Long value = asLong(a); + return value != null ? value : asLong(b); + } + + private static Long asLong(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + return Long.valueOf(String.valueOf(value).trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private static Integer firstInt(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + try { + return Integer.parseInt(String.valueOf(value).trim()); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java b/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java index 9e080f8..6b13750 100644 --- a/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java +++ b/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java @@ -6,6 +6,7 @@ import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.openapi.core.TargetSpec; import com.yunhai.platform.openapi.core.YschOpenCall; import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.svchall.model.FcApplyModels; import com.yunhai.svchall.model.svc.SvcModels; import com.yunhai.svchall.service.FlowClient; import lombok.extern.slf4j.Slf4j; @@ -137,6 +138,33 @@ public class FlowCenterClient implements FlowClient { data.has("duplicated") && data.get("duplicated").asBoolean()); } + /** + * 带请求标识/轮次守卫的代发起({@code POST /oapi/flow/apply/v2},TASK_2 C.2/C.3)。 + * + *

体形状用 {@code FcApplyModels.FlowOutbound}(= 旧 apply 字段 + requestToken/attempt/retryOnly), + * 不往共享 SDK 的 {@code FlowApplyBody} 或 TASK_1 的 {@code SvcModels.ApplyBody} 上塞字段: + * 那两个类都不在本任务写范围,且"给共享 DTO 加字段"正是任务书禁止的绕白名单写法。 + * 门面侧走 {@code YschFlowOpenApi.raw()} 泛化视图(目标名仍由门面承载,本类不拼专属地址); + * 未配服务目录时回落 legacy ad-hoc spec,与其余方法两条路径保持一致。

+ */ + @Override + public ApplyResult applyWithIdentity(Long schId, FcApplyModels.FlowOutbound outbound) { + requireEnabled(); + if (outbound == null || outbound.getBizId() == null || outbound.getRequestToken() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "代发起缺少 bizId/requestToken"); + } + Map query = Collections.singletonMap("schId", String.valueOf(schId)); + JsonNode data = directoryResolved + ? com.yunhai.platform.openapi.flow.YschFlowOpenApi.raw() + .post("/oapi/flow/apply/v2", query, outbound, JsonNode.class) + : openCall.post(flowSpec, "/oapi/flow/apply/v2", query, outbound, JsonNode.class); + if (data == null || data.get("instanceId") == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "流程中心代发起返回异常(缺少 instanceId)"); + } + return new ApplyResult(data.get("instanceId").asLong(), + data.has("duplicated") && data.get("duplicated").asBoolean()); + } + @Override public Map getInstanceByBiz(Long schId, String bizType, String bizId) { requireEnabled(); diff --git a/src/main/java/com/yunhai/svchall/service/impl/FormCenterCatalogClient.java b/src/main/java/com/yunhai/svchall/service/impl/FormCenterCatalogClient.java new file mode 100644 index 0000000..b4f8952 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/FormCenterCatalogClient.java @@ -0,0 +1,134 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.core.YschOpenCall; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.svchall.model.svc.SvcModels.SvcFormCatalogPage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * 表单中心目录客户端(v3/TASK_1):经 Tier 0 {@link YschOpenCall} 命名目标 {@code form-center} + * 调 {@code GET /oapi/form/forms}(授权目录)与 {@code GET /oapi/form/schema?uuid=&version=} + * (绑定核验)。身份=本应用 appKey,由 starter 的签名拦截器注入——appKey/secret 绝不进浏览器, + * 前端只调本应用的同源代理({@code SvcFormCatalogController})。 + * + *

地址解析走服务目录三级兜底({@code yh.call.targets} > {@code yh.open.services.form-center} + * > 枚举 base-host),与 {@link FlowCenterClient} 同口径,本类不拼任何表单中心专属地址。

+ * + *

核验是保存事项的前置条件:{@link #requireBindable} 失败即抛 {@link BizException}, + * 由 {@code adminSave} 的事务整体回滚——不给"半个绑定"进库的机会。读不到通常是三种情况: + * 表单中心没给本应用授权(read 权限缺失)、uuid 换校/被删、版本行不存在;文案点名方向但不外泄 + * 表单中心侧的存在性差别。

+ */ +@Slf4j +@Component +public class FormCenterCatalogClient { + + /** 服务目录 key */ + static final String TARGET = "form-center"; + static final String FORMS_PATH = "/oapi/form/forms"; + static final String SCHEMA_PATH = "/oapi/form/schema"; + /** 保存期一次性核验,超时给足(对齐 FlowCenterClient/发布快照的预算,不是运行期热路径) */ + static final int MAX_ATTEMPTS = 2; + + private final YschOpenCall openCall; + + public FormCenterCatalogClient(YschOpenCall openCall) { + this.openCall = openCall; + } + + /** + * 目录拉取(管理面选择器数据源)。空响应按"目录不可用"拒而不是给一个空列表—— + * 空列表在页面上长得像"这所学校没有可用表单",会把授权没配好这件事藏起来。 + */ + public SvcFormCatalogPage listForms(Long schId, String appCode, String keyword, Integer page, Integer pageSize) { + Map query = new HashMap<>(); + query.put("schId", String.valueOf(schId)); + if (notBlank(appCode)) { + query.put("appCode", appCode.trim()); + } + if (notBlank(keyword)) { + query.put("keyword", keyword.trim()); + } + if (page != null) { + query.put("page", String.valueOf(page)); + } + if (pageSize != null) { + query.put("pageSize", String.valueOf(pageSize)); + } + try { + SvcFormCatalogPage data = openCall.get(TARGET, FORMS_PATH, query, SvcFormCatalogPage.class); + if (data == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单中心目录返回空,请稍后重试"); + } + return data; + } catch (BizException e) { + throw e; + } catch (RuntimeException e) { + log.warn("[svc-hall] 表单中心目录拉取失败 schId={} {}", schId, e.toString()); + throw new BizException(ErrorCode.PARAM_ERROR, + "表单中心目录不可达(网络/授权未配置):" + e.getMessage()); + } + } + + /** + * 核验绑定坐标:按 {@code uuid}+{@code version} 精确取版本行,并核对返回的坐标与请求一致。 + * + *

为什么还要比一次返回:中心若在某条分支上回落成"当前发布版",事项就会存下一个看起来对、 + * 实际指向别的版本的坐标——那是最难查的一类线上问题。坐标不符即拒(v3 §4)。

+ */ + public void requireBindable(Long schId, String formUuid, Integer formVersion) { + Map query = new HashMap<>(); + query.put("schId", String.valueOf(schId)); + query.put("uuid", formUuid.trim()); + query.put("version", String.valueOf(formVersion)); + + RuntimeException last = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + Map data = openCall.get(TARGET, SCHEMA_PATH, query, Map.class); + requireCoordinateMatch(data, formUuid.trim(), formVersion); + return; + } catch (BizException e) { + throw e; + } catch (Exception e) { + last = e instanceof RuntimeException ? (RuntimeException) e : new IllegalStateException(e); + log.warn("[svc-hall] 表单中心绑定核验失败(第 {}/{} 次)uuid={} v{}:{}", + attempt, MAX_ATTEMPTS, formUuid, formVersion, e.toString()); + } + } + throw new BizException(ErrorCode.PARAM_ERROR, + "表单中心校验未通过(表单不存在、未授权给本应用,或该版本行缺失):" + + formUuid + " v" + formVersion + + (last == null ? "" : ";对端最后错误:" + last.getMessage())); + } + + /** 返回坐标与请求坐标逐件核对(uuid 与 version 都要等值) */ + private static void requireCoordinateMatch(Map data, String formUuid, Integer formVersion) { + if (data == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + "表单中心返回空 data,无法核验绑定坐标:" + formUuid + " v" + formVersion); + } + Object uuid = data.get("formUuid"); + Object version = data.get("version"); + if (uuid == null || version == null + || !formUuid.equals(String.valueOf(uuid)) + || formVersion == null || formVersion.intValue() != asInt(version)) { + throw new BizException(ErrorCode.PARAM_ERROR, + "表单中心返回的坐标与请求不符(请求 " + formUuid + " v" + formVersion + + ",返回 " + uuid + " v" + version + "),已拒绝保存该绑定"); + } + } + + private static int asInt(Object value) { + return value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(String.valueOf(value)); + } + + private static boolean notBlank(String s) { + return s != null && !s.trim().isEmpty(); + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/FormSyncClient.java b/src/main/java/com/yunhai/svchall/service/impl/FormSyncClient.java new file mode 100644 index 0000000..de0991b --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/FormSyncClient.java @@ -0,0 +1,158 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.core.YschOpenCall; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.svchall.model.FormSyncModels.ManifestPageView; +import com.yunhai.svchall.model.FormSyncModels.ReconcileError; +import com.yunhai.svchall.model.FormSyncModels.ResultAck; +import com.yunhai.svchall.model.FormSyncModels.ResultReport; +import com.yunhai.svchall.model.FormSyncModels.TaskAnswerReply; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Collections; + +/** + * 表单中心对账任务端点客户端(TASK_3 改动点 3 的成对出站面)。 + * + *

与 {@code FcSubmissionClient}/{@code FormCenterCatalogClient} 同一条范式:命名目标 + * {@code form-center} 经 {@link YschOpenCall} 走服务目录三级兜底,身份=本应用 appKey, + * 由 starter 签名拦截器注入——本应用不自己拼签名串,也不把密钥放进任何请求体。

+ * + *

失败语义按调用方要处置的方式区分开:

+ *
    + *
  • {@link #fetchManifestPage} 失败=本轮扫描中断(不能把"读不到"当成"没有差异", + * 更不能当成"清单到底了");
  • + *
  • {@link #fetchTaskAnswer} 失败=这一条差异不修复、不退役,只记录;
  • + *
  • {@link #submitResult} 失败=结果没送到,任务留在本地待重报(中心侧靠租约重派,同 attempt 幂等)。
  • + *
+ */ +@Slf4j +@Component +public class FormSyncClient { + + /** 服务目录 key:与 TASK_1/TASK_2 客户端同一个目标名,不拆第二个名字 */ + static final String TARGET = "form-center"; + static final String TASK_PATH = "/oapi/form-sync/tasks"; + + private final YschOpenCall openCall; + + public FormSyncClient(YschOpenCall openCall) { + this.openCall = openCall; + } + + /** 出站能力是否就绪(未注入 SDK=对账整体不可用,Worker 直接跳过而不是空跑一轮) */ + public boolean enabled() { + return openCall != null; + } + + /** 读中心某一页清单;{@code cursor} 为上一页 nextCursor(首次传空) */ + public ManifestPageView fetchManifestPage(String taskId, String cursor, Integer size) { + requireTaskId(taskId); + StringBuilder path = new StringBuilder(TASK_PATH).append('/').append(encode(taskId)).append("/manifest"); + path.append(cursor == null || cursor.trim().isEmpty() ? "?" : "?cursor=" + encode(cursor.trim())); + if (size != null && size > 0) { + path.append("&size=").append(size); + } + try { + ManifestPageView page = openCall.get(TARGET, path.toString(), Collections.emptyMap(), + ManifestPageView.class); + if (page == null || page.getCompleted() == null) { + throw new IllegalStateException("manifest 返回缺少 completed 结束标记"); + } + if (page.getTaskId() != null && !page.getTaskId().equals(taskId.trim())) { + throw new IllegalStateException("manifest 返回的 taskId 与请求不符"); + } + return page; + } catch (BizException e) { + throw e; + } catch (RuntimeException e) { + log.warn("[对账] 中心清单读取失败 task={} cursor={}:{}", taskId, cursor, e.toString()); + throw new BizException(ErrorCode.PARAM_ERROR, + "表单中心对账清单不可达,本轮扫描中断:" + brief(e)); + } + } + + /** + * 读"这一份答卷在中心当前是什么"。 + * + *

返回体自带存在性结论(PRESENT/ABSENT/UNKNOWN)与权威信封或缺席证明; + * 结论缺失或 {@code presence} 不认识时一律按 UNKNOWN 处置(调用方只报告、不动数据)。

+ */ + public TaskAnswerReply fetchTaskAnswer(String taskId, String answerUuid, Long expectedRevision) { + requireTaskId(taskId); + if (answerUuid == null || answerUuid.trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "answerUuid 必填"); + } + String path = TASK_PATH + '/' + encode(taskId) + "/answers/" + encode(answerUuid.trim()) + + (expectedRevision == null ? "" : "?expectedRevision=" + expectedRevision); + try { + TaskAnswerReply reply = openCall.get(TARGET, path, Collections.emptyMap(), + TaskAnswerReply.class); + if (reply == null || reply.getPresence() == null) { + throw new IllegalStateException("任务快照答复为空或缺 presence"); + } + return reply; + } catch (BizException e) { + throw e; + } catch (RuntimeException e) { + log.warn("[对账] 任务快照答复读取失败 task={} answer={}:{}", taskId, answerUuid, e.toString()); + throw new BizException(ErrorCode.PARAM_ERROR, + "表单中心任务快照端点不可达,本条差异不修复:" + brief(e)); + } + } + + /** 提交结果回报(同 taskId+attempt 幂等;失败时本地留 QUEUED 由重报兜住) */ + public ResultAck submitResult(String taskId, ResultReport report) { + requireTaskId(taskId); + try { + ResultAck ack = openCall.post(TARGET, TASK_PATH + '/' + encode(taskId) + "/result", + Collections.emptyMap(), report, ResultAck.class); + if (ack == null || !Boolean.TRUE.equals(ack.getAccepted())) { + throw new IllegalStateException("中心未受理结果回报"); + } + return ack; + } catch (BizException e) { + throw e; + } catch (RuntimeException e) { + log.warn("[对账] 结果回报失败 task={} attempt={}:{}", taskId, + report == null ? null : report.getAttempt(), e.toString()); + throw new BizException(ErrorCode.PARAM_ERROR, "表单中心结果回报不可达:" + brief(e)); + } + } + + /** 组装一条能定位 consumer/task/range/answer 的错误明细(值文档永不在这里出现) */ + public static ReconcileError error(String code, String taskId, String consumerAppKey, Long schoolId, + String formUuid, String answerUuid, Long revision, String detail) { + ReconcileError error = new ReconcileError(); + error.setCode(code); + error.setTaskId(taskId); + error.setConsumerAppKey(consumerAppKey); + error.setConsumerSchoolId(schoolId); + error.setFormUuid(formUuid); + error.setAnswerUuid(answerUuid); + error.setRevision(revision); + error.setDetail(detail == null ? null : (detail.length() > 300 ? detail.substring(0, 300) : detail)); + return error; + } + + private static void requireTaskId(String taskId) { + if (taskId == null || taskId.trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "taskId 必填"); + } + } + + private static String encode(String raw) { + try { + return java.net.URLEncoder.encode(raw, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + + private static String brief(RuntimeException e) { + String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + return message.length() > 200 ? message.substring(0, 200) : message; + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java index 56e24fc..81d9093 100644 --- a/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java +++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceApplyService.java @@ -4,6 +4,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.entity.FcAnswerProjection; +import com.yunhai.svchall.entity.FcApplyIntent; +import com.yunhai.svchall.entity.FlowApplyCommand; import com.yunhai.svchall.entity.ServiceApply; import com.yunhai.svchall.entity.ServiceForm; import com.yunhai.svchall.entity.ServiceItem; @@ -68,6 +71,122 @@ public class ServiceApplyService { private final ObjectMapper objectMapper; private final org.springframework.transaction.support.TransactionTemplate transactionTemplate; + /** + * 出站命令与恢复服务(TASK_2 C.1)。setter 注入而不是加进构造器:本类的 10 参构造 + * 被白名单外的存量测试({@code ServiceApplyFlowSubmitTest} 等三支)逐位钉住, + * 加 final 字段会让整棵测试树编译失败——同 {@code ServiceItemService#setFormCenterClient} 先例。 + */ + private FlowApplyRecoveryService flowCommands; + + @org.springframework.beans.factory.annotation.Autowired + public void setFlowCommands(FlowApplyRecoveryService flowCommands) { + this.flowCommands = flowCommands; + } + + /** 测试装配口(同上:单测直接喂桩,不动构造器签名) */ + void setFlowCommandsForTest(FlowApplyRecoveryService flowCommands) { + this.flowCommands = flowCommands; + } + + /** + * 意图仓库(同为 setter 注入)。建件必须与"意图→办件"的关联写落在同一个事务: + * 两条并发确认各自插一个办件时,后提交那条会被 {@code uk_intent_apply} 顶回并整事务回滚, + * 于是"一次意图一个办件"是库级事实,不是应用层的乐观判断。 + */ + private com.yunhai.svchall.repository.FcApplyIntentRepository fcIntents; + + @org.springframework.beans.factory.annotation.Autowired + public void setFcIntents(com.yunhai.svchall.repository.FcApplyIntentRepository fcIntents) { + this.fcIntents = fcIntents; + } + + void setFcIntentsForTest(com.yunhai.svchall.repository.FcApplyIntentRepository fcIntents) { + this.fcIntents = fcIntents; + } + + // ===== 可信建件(TASK_2 A.5/C.1)===== + + /** + * 由已验证的意图 + 已落库的答卷投影创建一个本地办件(至多一个)。 + * + *

调用方必须先证明三件事:收据来自中心、意图属于本人、答卷与意图坐标一致—— + * 本方法不再接受任何浏览器传入的表单数据({@code formDataJson} 只从投影快照取, + * 投影只从签名收据或签名信封落库),也不接受外部指定 bizId(bizId 恒=本地自增办件 id, + * 与旧链路逐字相同,回调解析不受影响)。

+ * + *

短事务形状:办件行 + 意图关联 + 出站命令三样一起提交,HTTP 在事务外由 + * {@link FlowApplyRecoveryService} 唤醒执行(C.1"HTTP 调用不占长数据库事务")。

+ */ + @Transactional + public Integer createFromVerifiedIntent(FcApplyIntent intent, FcAnswerProjection projection) { + if (intent == null || projection == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "可信建件缺少意图或答卷投影"); + } + if (intent.getApplyId() != null) { + return intent.getApplyId(); + } + if (Integer.valueOf(1).equals(projection.getIsDeleted())) { + // C.4:墓碑只挡新的关联/办理输入,不隐式撤回已在跑的流程 + throw new BizException(ErrorCode.CUSTOM, "该答卷已被删除(墓碑),不能再建件:" + intent.getId()); + } + ServiceItem item = itemRepository.findById(intent.getItemId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "事项不存在,无法建件")); + if (item.getStatus() == null || item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) { + throw new BizException(ErrorCode.CUSTOM, "事项已不在发布态,不能建件:" + item.getItemCode()); + } + if (intent.getFcFormUuid() == null || !intent.getFcFormUuid().equals(projection.getFormUuid()) + || intent.getFcFormVersion() == null + || !intent.getFcFormVersion().equals(projection.getFormVersion())) { + throw new BizException(ErrorCode.FORBIDDEN, "答卷坐标与意图绑定不一致,拒绝建件"); + } + if (projection.getSubmitterId() != null && !projection.getSubmitterId().equals(intent.getSubmitterId())) { + throw new BizException(ErrorCode.FORBIDDEN, "答卷提交者与意图提交者不一致,拒绝建件"); + } + + ServiceApply apply = new ServiceApply(); + apply.setSchId(intent.getSchId()); + apply.setSiteCode(intent.getSiteCode()); + apply.setItemId(item.getId()); + apply.setItemCode(item.getItemCode()); + apply.setItemTitle(item.getTitle()); + apply.setItemSnapshotJson(itemSnapshotJson(item, intent.getSubmitterName())); + apply.setFormVersion(intent.getFcFormVersion()); + apply.setFormDataJson(projection.getValuesJson() == null ? "{}" : projection.getValuesJson()); + apply.setSubmitterId(intent.getSubmitterId()); + apply.setSubmitterName(intent.getSubmitterName()); + apply.setFlowGuid(intent.getFlowGuid()); + apply.setFcIntentId(intent.getId()); + apply.setFcAnswerUuid(projection.getAnswerUuid()); + apply = applyRepository.saveAndFlush(apply); + + intent.setApplyId(apply.getId()); + intent.setState(FcApplyIntent.STATE_APPLIED); + if (fcIntents != null) { + fcIntents.saveAndFlush(intent); + } + + if (flowCommands != null) { + FlowApplyCommand command = flowCommands.createInTransaction(apply, intent.getId(), + flowCommands.payloadJson(apply)); + flowCommands.dispatchAfterCommit(command.getId()); + } else { + log.warn("[svc-hall] 出站命令服务未装配,办件保留待提交状态 applyId={}(可由恢复扫描或人工重试补发)", + apply.getId()); + } + log.info("[svc-hall] 可信建件 applyId={} intent={} answer={} form={}@v{} submitter={}", + apply.getId(), intent.getId(), projection.getAnswerUuid(), projection.getFormUuid(), + projection.getFormVersion(), intent.getSubmitterId()); + return apply.getId(); + } + + /** 确认响应里回带办件镜像状态(前端据此判"已排队"还是"待提交",与 submit 同一套判据) */ + public SvcModels.ApplyVo detailOfApplyId(Integer applyId) { + if (applyId == null) { + return null; + } + return applyRepository.findById(applyId).map(a -> stampFlowEnabled(ApplyVo.of(a))).orElse(null); + } + // ===== 用户面(CAS)===== /** @@ -328,6 +447,12 @@ public class ServiceApplyService { out.put("siteCode", site.getSiteCode()); out.put("itemCode", item.getItemCode()); out.put("title", item.getTitle()); + // 可信建件标记(TASK_2 A.1):绑定了表单中心确切版本的事项,由前端走 + // POST /api/apply/intents 拿被嵌页地址,而不是在本页渲染本地表单。 + // 只报坐标不报令牌——令牌必须由服务端签发的那次调用带回(防"页面自己拼一个入口")。 + out.put("hosted", item.getFcFormUuid() != null && !item.getFcFormUuid().isEmpty()); + out.put("fcFormUuid", item.getFcFormUuid()); + out.put("fcFormVersion", item.getFcFormVersion()); out.put("summary", item.getSummary()); out.put("deptName", item.getDeptName()); out.put("handleMode", item.getHandleMode()); diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java index 57813be..8af1631 100644 --- a/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java +++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java @@ -94,6 +94,22 @@ public class ServiceItemService { private final com.yunhai.platform.flowcallback.FlowCallbackRegistrar callbackRegistrar; private final TransactionTemplate transactionTemplate; + /** + * 表单中心目录/核验客户端(v3/TASK_1)。 + * + *

用 setter 而不是加进 {@code @RequiredArgsConstructor} 的构造参数:本类那 11 参构造 + * 已被 {@code ServiceItemEffectiveFlowTest}/{@code ServiceItemFlowRegTest} 逐位钉住,而那两份测试 + * 不在本任务的可改清单里——多一个入参会让它们直接编译不过。绑定核验必须在 + * {@link #adminSave} 的事务内发生(远端失败=整次保存回滚,不留半个绑定),所以客户端装配在这里, + * 而不是挪到控制器入口。

+ */ + private FormCenterCatalogClient formCenterClient; + + @org.springframework.beans.factory.annotation.Autowired + void setFormCenterClient(FormCenterCatalogClient formCenterClient) { + this.formCenterClient = formCenterClient; + } + // ===== 管理端 ===== public Map adminPage(BoaUser user, SvcItemPageQuery query) { @@ -196,7 +212,15 @@ public class ServiceItemService { item.setNoticeCategoryId(req.getNoticeCategoryId()); item.setRuleCategoryId(req.getRuleCategoryId()); item.setCoverImage(toRelativeIfLocal(req.getCoverImage())); - item.setFormCode(emptyToNull(req.getFormCode())); + // 本地 form_code 停写(v3/TASK_1):原样回传=保留历史绑定(不清空存量行),改值即拒; + // 新表单一律走表单中心坐标,且已在 validateBindings 里经远端核验 + String legacyFormCode = emptyToNull(req.getFormCode()); + if (legacyFormCode != null && !legacyFormCode.equals(item.getFormCode())) { + throw new BizException(ErrorCode.PARAM_ERROR, + "本地表单定义已停用:不能在事项上写新的 form_code,请到表单中心建表后选择表单"); + } + item.setFcFormUuid(trimToNull(req.getFcFormUuid())); + item.setFcFormVersion(req.getFcFormVersion()); item.setFlowGuid(emptyToNull(req.getFlowGuid())); item.setApplyUrl(applyUrl); // 服务承诺(P1-1):空串归一为 null,避免库里出现 "" 与 null 两种空值 @@ -740,12 +764,28 @@ public class ServiceItemService { } /** 绑定校验:表单必须存在(含草稿——允许先绑后发布,发起按已发布版本解析); - * 流程按 FlowClient 可用性校验;onlineFlag=1 ⇒ 生效流程(事项自身或绑定表单的)非空 */ + * 流程按 FlowClient 可用性校验;onlineFlag=1 ⇒ 生效流程(事项自身或绑定表单的)非空。 + *
v3/TASK_1 追加:表单中心绑定必须成对提交,且必须在写库前经远端核验 + * (核验失败=抛错=整次 {@code adminSave} 回滚,不存在"uuid 落了、版本没落"的半个绑定)。 + * 这里刻意不做"没有表单中心坐标时回落本地 form_code"的第二套口径:本地轨只读不写。 */ private void validateBindings(SiteInfo site, SvcItemSaveRequest req) { String formCode = emptyToNull(req.getFormCode()); if (formCode != null && !formRepository.existsBySchIdAndCode(site.getSchId(), formCode)) { throw new BizException(ErrorCode.PARAM_ERROR, "办理表单不存在:" + formCode); } + String fcFormUuid = trimToNull(req.getFcFormUuid()); + Integer fcFormVersion = req.getFcFormVersion(); + if ((fcFormUuid == null) != (fcFormVersion == null)) { + throw new BizException(ErrorCode.PARAM_ERROR, + "表单中心绑定必须成对提交(fcFormUuid 与 fcFormVersion 同传或都不传;" + + "当前 uuid=" + fcFormUuid + ",version=" + fcFormVersion + ")"); + } + if (fcFormUuid != null) { + if (formCenterClient == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单中心客户端未装配,无法核验绑定坐标"); + } + formCenterClient.requireBindable(site.getSchId(), fcFormUuid, fcFormVersion); + } String flowGuid = emptyToNull(req.getFlowGuid()); if (flowGuid != null && flowClient.enabled()) { if (flowClient.getFlow(site.getSchId(), flowGuid) == null) { @@ -889,6 +929,15 @@ public class ServiceItemService { return StringUtils.hasText(value) ? value : null; } + /** + * 跨服务坐标用(表单中心 uuid):去空白后入库,空串归 null。 + * {@link #emptyToNull} 不去空白——历史列里"首尾带空格的 code"从来不是问题, + * 但 uuid 要和中心逐字等值比对,留着空格会让核验通过后存进一个查不回的值。 + */ + private static String trimToNull(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } + /** 封面:本应用上传的相对路径原样入库;外部完整 URL 截取 /upload/ 相对段(与附件同口径);折叠历史叠加段自愈 */ private static String toRelativeIfLocal(String coverImage) { if (!StringUtils.hasText(coverImage)) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4148bd5..244b9f7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,6 +33,18 @@ spring: multipart: max-file-size: 50MB max-request-size: 200MB + # ===== v2 快照消费端(answer-delivery-v2 §7,TASK_2 B.5)===== + # 与 form-center 同一条"可选依赖"纪律:全部键从 RABBITMQ_* env 现读,host 留空即整体不装配 + # (FcAnswerMqConfiguration 的 @ConditionalOnExpression 判的就是这一个值)。未接 broker 时 + # 可信建件仍可用——那条链的第二路径是签名收据确认,不依赖 MQ。 + # ⚠️ 打开 host 之前必须先落实队列隔离(独立 vhost + 账号仅对本 vhost 有配权 + 禁止通配订阅他人 + # 前缀,见交付说明"broker 隔离配置"):完整值文档只在按 appKey+租户域绑定的私有队列里。 + rabbitmq: + host: ${RABBITMQ_HOST:} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USER:} + password: ${RABBITMQ_PASS:} + virtual-host: ${RABBITMQ_VHOST:} # ===== 基础平台 yh 配置(Cms/Notice 同款三件套)===== yh: @@ -87,6 +99,11 @@ yh: # (Notice 同款)。生产如仍被平台层注入容器直连值,属平台配置项(projman yh.open.services 段)。 flow-center: http://localhost:8085 cms: http://base.yunhaixingtong.com:10005/yschcms + # form-center 刻意不写本地默认值(TASK_1 交付说明第 2 条的同一取舍):本文件里写 + # http://localhost:80xx 会被打进 jar,部署实例就去连容器内的 localhost(2026-09-18 事故)。 + # 本机跨应用联调请运行时注入:YH_OPEN_SERVICES_FORMCENTER=http://localhost:; + # 生产不注入即走目录第 3 级兜底(base-host 网关 + PlatformService.FORM_CENTER 的 ctx)。 + # form-center: ${YH_OPEN_SERVICES_FORMCENTER:} # 流程中心回调接收端 starter(2026-09-20 迁移;2026-10-05 收敛 Notice 形态): # 端点/幂等/乱序门由 starter 承担,业务映射见 SvcFlowCallbackHandler; @@ -135,6 +152,31 @@ svchall: # 本地=后端直连(无 context-path);生产=https://base.yunhaixingtong.com/svc-hall/upload/ # (Projman 自动生成静态直出段,prod 由 CONTEXT_PATH 注入应用上下文) public-base-url: http://localhost:8086/upload/ + # ===== 可信建件与快照消费(answer-delivery-v2 §6~§8,TASK_2)===== + fc: + # 本应用消费身份(默认取签名身份同一把 appKey;信封目标必须等于它,否则整封拒收) + consumer-app-key: ${YH_APP_AUTH_APP_KEY:100018} + # 被嵌填报页基址 = 表单中心前端地址(不是后端 API 地址):iframe 的 src 由服务端拼, + # 浏览器传进来的任何路径都不采信(A.2 末句)。缺键即拒开托管填报页——宁可不给入口, + # 也不给一个"拼了个大概能打开的地址"的入口。本地=FC front dev(8089),生产 env 注入。 + fill-page-base-url: ${SVCHALL_FC_FILL_PAGE_BASE_URL:http://localhost:8089} + # 授权快照缓存(B.6):命中期间撤权不可见,这条链的生效边界就是"最多滞后这么多秒", + # 不许描述成即时、全局强一致撤回;0=每封都查(保守但慢)。核验不可达一律暂缓写入。 + grant-cache-ttl-seconds: ${SVCHALL_FC_GRANT_CACHE_TTL_SECONDS:120} + report-batch-size: 20 + report-scan-interval-ms: 120000 + report-scan-initial-delay-ms: 60000 + prefetch-count: 10 + max-concurrent-consumers: 2 + # ===== 出站发起命令与恢复(TASK_2 C.1/C.2)===== + flow-command: + # 有界重试:20s×2^n 封顶 30min,6 次耗尽转 DEAD(留可查记录,不无限重投) + max-retries: ${SVCHALL_FLOW_COMMAND_MAX_RETRIES:6} + backoff-base-seconds: 20 + backoff-cap-seconds: 1800 + batch-size: 50 + scan-interval-ms: 30000 + scan-initial-delay-ms: 20000 logging: level: @@ -153,6 +195,10 @@ management: health: redis: enabled: false + rabbit: + # 同 form-center 先例(V64):amqp 是可选通道,未注入 RABBITMQ_HOST 时不该被健康探针拖成 DOWN; + # 消费链本身按 host 条件装配,探针关掉才不会误报整机不可用。 + enabled: false # 文件存储磁盘路径(Notice 三件套约定;Docker 部署时与 volume 挂载一致) FILE_PATH: D:/bsch/svchall/upload/ diff --git a/src/test/java/com/yunhai/svchall/service/impl/ShAnswerFlowTest.java b/src/test/java/com/yunhai/svchall/service/impl/ShAnswerFlowTest.java new file mode 100644 index 0000000..6a445e8 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/ShAnswerFlowTest.java @@ -0,0 +1,1008 @@ +package com.yunhai.svchall.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.entity.FcAnswerInbox; +import com.yunhai.svchall.entity.FcAnswerProjection; +import com.yunhai.svchall.entity.FcApplyIntent; +import com.yunhai.svchall.entity.FlowApplyCommand; +import com.yunhai.svchall.entity.ServiceApply; +import com.yunhai.svchall.entity.ServiceItem; +import com.yunhai.svchall.enums.ServiceItemStatusEnum; +import com.yunhai.svchall.model.FcApplyModels; +import com.yunhai.svchall.model.FcApplyModels.ContextIssueRequest; +import com.yunhai.svchall.model.FcApplyModels.ContextIssueView; +import com.yunhai.svchall.model.FcApplyModels.ContextReceiptView; +import com.yunhai.svchall.model.FcApplyModels.GrantSnapshotView; +import com.yunhai.svchall.model.FcApplyModels.IntentConfirmView; +import com.yunhai.svchall.model.FcApplyModels.IntentCreateRequest; +import com.yunhai.svchall.model.FcApplyModels.IntentView; +import com.yunhai.svchall.model.FcApplyModels.Snapshot; +import com.yunhai.svchall.model.FcApplyModels.SnapshotEnvelope; +import com.yunhai.svchall.repository.FcAnswerInboxRepository; +import com.yunhai.svchall.repository.FcAnswerProjectionRepository; +import com.yunhai.svchall.repository.FcApplyIntentRepository; +import com.yunhai.svchall.repository.FlowApplyCommandRepository; +import com.yunhai.svchall.repository.ServiceApplyRepository; +import com.yunhai.svchall.repository.ServiceItemRepository; +import com.yunhai.svchall.service.FlowClient; +import com.yunhai.svchall.service.SiteDirectory; +import com.yunhai.svchall.service.SiteGuard; +import com.yunhai.svchall.service.impl.AnswerProjectionStore.Disposition; +import com.yunhai.svchall.service.impl.AnswerProjectionStore.Outcome; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeSet; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 可信建件链、快照投影消费与办件恢复的单测(TASK_2 A/B/C 段,SH 侧)。 + * + *

取向同 {@code ShFormBindingTest}:协作者喂成桩,规则一改立刻红,不打真库不起上下文。 + * 三组最要紧的取证:

+ *
    + *
  1. 顺序无关的收敛(A.5):事件先到 / 确认先到 / 重复投递三种次序都只形成一个办件—— + * 夹具在内存里模拟唯一键与"已建件就回原 id"的语义(真库由 + * {@code uk_intent_answer}/{@code uk_intent_apply}/{@code uk_projection_answer} 保证);
  2. + *
  3. 结果未知不是失败(C.2):apply 响应丢失后先 GET,查到 30 只回填镜像、绝不重发; + * 查无才带同一 {@code requestToken} 补发;
  4. + *
  5. 跨端夹具逐字一致(B.1):{@link #canonicalVectorMatchesFormCenter()} 的字面量与 + * form-center {@code FcAnswerDeliveryTest.CANONICAL_VECTOR} 必须完全相同, + * 任一侧改规范化都会红在两个仓。
  6. + *
+ * + *

manual ack 与死信路由是 broker 运行期行为:本类只能证"投影异常会抛到监听器", + * 真队列行为记在交付说明的待补项(本机不启 rabbit)。

+ */ +@DisplayName("可信建件与快照投影 - SH 侧规则单测(TASK_2)") +class ShAnswerFlowTest { + + private static final String APP_KEY = "100018"; + private static final long SCH = 5L; + private static final String FORM_UUID = "f0e1d2c3b4a5968778695a4b3c2d1e0f"; + private static final String ANSWER_UUID = "a1b2c3d4e5f60718293a4b5c6d7e8f90"; + private static final String FC_INTENT_ID = "svchall-0123456789abcdef0123456789abcdef"; + + /** 与 form-center {@code FcAnswerDeliveryTest.CANONICAL_VECTOR} 逐字相同的跨端夹具(十个成员闭集) */ + private static final String CANONICAL_VECTOR = + "{\"answerNo\":null,\"appCode\":\"demo\",\"code\":\"honor_apply\"," + + "\"createTime\":\"2026-10-21T10:00:00\",\"schId\":5,\"status\":\"processed\"," + + "\"subjectId\":7,\"submitterId\":1001,\"updateTime\":null," + + "\"values\":{\"a\":{\"y\":null,\"z\":1},\"b\":[2,1],\"e\":true,\"n\":100}}"; + + private final ObjectMapper mapper = new ObjectMapper(); + + // ==================== 跨端夹具(B.1) ==================== + + @Test + @DisplayName("fc.canon.v1 跨端夹具:SH 独立实现产出与 form-center 同一规范串") + void canonicalVectorMatchesFormCenter() { + Snapshot snapshot = new Snapshot(); + snapshot.setSchId(5L); + snapshot.setAppCode("demo"); + snapshot.setCode("honor_apply"); + snapshot.setSubmitterId(1001); + snapshot.setSubjectId(7); + snapshot.setStatus("processed"); + snapshot.setAnswerNo(null); + snapshot.setCreateTime("2026-10-21T10:00:00"); + snapshot.setUpdateTime(null); + ObjectNode values = mapper.createObjectNode(); + ObjectNode nested = mapper.createObjectNode(); + nested.put("z", 1); + nested.putNull("y"); + values.set("a", nested); + values.putArray("b").add(2).add(1); + values.put("n", new BigDecimal("100.0")); + values.put("e", true); + snapshot.setValues(values); + + assertThat(AnswerProjectionStore.canonicalSnapshot(snapshot)).isEqualTo(CANONICAL_VECTOR); + + // intent 关联块不进哈希:同 revision 只能有一份 hash + snapshot.setIntent(mapper.createObjectNode().put("intentId", FC_INTENT_ID)); + assertThat(AnswerProjectionStore.canonicalSnapshot(snapshot)).isEqualTo(CANONICAL_VECTOR); + } + + // ==================== 投影规则(B.5) ==================== + + @Test + @DisplayName("合法信封:投影落库 + 台账 APPLIED,值文档全文取自消息不再回查") + void validEnvelopePersistsProjection() { + Harness h = harness(); + SnapshotEnvelope envelope = envelope(1L, 7, snapshot("{\"k\":\"v\"}")); + + Disposition result = h.store.applySnapshot(envelope); + + assertThat(result.getOutcome()).isEqualTo(Outcome.APPLIED); + assertThat(result.getState()).isEqualTo(FcAnswerInbox.STATE_APPLIED); + assertThat(h.projectionsSaved).hasSize(1); + FcAnswerProjection saved = h.projectionsSaved.get(0); + assertThat(saved.getRevision()).isEqualTo(1L); + assertThat(saved.getValuesJson()).isEqualTo("{\"k\":\"v\"}"); + assertThat(saved.getLinkState()).isEqualTo(FcAnswerProjection.LINK_UNLINKED); + assertThat(saved.getSchId()).isEqualTo(SCH); + assertThat(saved.getContentHash()).isEqualTo(envelope.getContentHash()); + } + + @Test + @DisplayName("低 revision 忽略;同 revision 同 hash 幂等;同 revision 异 hash 记冲突且不覆盖") + void revisionRulesIgnoreOutOfOrderAndFlagSameVersionDifferentHash() { + Harness h = harness(); + FcAnswerProjection stored = projection(5L, hash(snapshot("{\"k\":\"old\"}"))); + h.currentProjection = stored; + + h.store.applySnapshot(envelope(4L, 7, snapshot("{\"k\":\"older\"}"))); + assertThat(stored.getRevision()).isEqualTo(5L); + assertThat(stored.getValuesJson()).isEqualTo("{\"k\":\"old\"}"); + + h.store.applySnapshot(envelope(5L, 7, snapshot("{\"k\":\"old\"}"))); + assertThat(stored.getConflictFlag()).isZero(); + + Disposition conflict = h.store.applySnapshot(envelope(5L, 7, snapshot("{\"k\":\"other\"}"))); + assertThat(stored.getConflictFlag()).isEqualTo(1); + assertThat(stored.getValuesJson()).isEqualTo("{\"k\":\"old\"}"); + assertThat(stored.getConflictDetail()).contains("同 revision 异 hash"); + // 台账记 CONFLICT:投影不覆盖,冲突留给人工/对账核验(B.5) + assertThat(conflict.getState()).isEqualTo(FcAnswerInbox.STATE_CONFLICT); + } + + @Test + @DisplayName("墓碑只保留 revision:旧 created 复活不了它,更高 revision 的墓碑照常应用") + void tombstoneSurvivesStaleCreated() { + Harness h = harness(); + FcAnswerProjection stored = projection(6L, hash(snapshot("{\"k\":\"v\"}"))); + stored.setIsDeleted(1); + h.currentProjection = stored; + + SnapshotEnvelope staleCreated = envelope(5L, 7, snapshot("{\"k\":\"v\"}")); + staleCreated.setIsDeleted(false); + h.store.applySnapshot(staleCreated); + assertThat(stored.getIsDeleted()).isEqualTo(1); + assertThat(stored.getRevision()).isEqualTo(6L); + + SnapshotEnvelope newerTomb = envelope(7L, 7, snapshot("{\"k\":\"v2\"}")); + newerTomb.setIsDeleted(true); + h.store.applySnapshot(newerTomb); + assertThat(stored.getRevision()).isEqualTo(7L); + assertThat(stored.getIsDeleted()).isEqualTo(1); + assertThat(stored.getValuesJson()).isEqualTo("{\"k\":\"v2\"}"); + } + + @Test + @DisplayName("授权已撤销 / 核验不可达:DEFERRED 暂缓,不写投影(v2 §5 竞态口径)") + void revokedOrUnverifiableGrantDefersWrite() { + Harness h = harness(); + h.grantAllowed = false; + Disposition revoked = h.store.applySnapshot(envelope(1L, 7, snapshot("{}"))); + assertThat(revoked.getOutcome()).isEqualTo(Outcome.DEFERRED); + assertThat(revoked.getState()).isEqualTo(FcAnswerInbox.STATE_DEFERRED); + assertThat(h.projectionsSaved).isEmpty(); + + Harness h2 = harness(); + h2.grantVerifyThrows = true; + assertThat(h2.store.applySnapshot(envelope(1L, 7, snapshot("{}"))).getOutcome()) + .isEqualTo(Outcome.DEFERRED); + } + + @Test + @DisplayName("信封闭集:协议号/目标应用/纪元/hash 形态/修订号任一不符即 REJECTED 且不落投影") + void invalidEnvelopesAreRejected() { + Harness h = harness(); + + SnapshotEnvelope wrongProtocol = envelope(1L, 7, snapshot("{}")); + wrongProtocol.setProtocolVersion("fc.answer.snapshot.v1"); + assertThat(h.store.applySnapshot(wrongProtocol).getOutcome()).isEqualTo(Outcome.REJECTED); + + SnapshotEnvelope foreignApp = envelope(1L, 7, snapshot("{}")); + foreignApp.setConsumerAppKey("100099"); + assertThat(h.store.applySnapshot(foreignApp).getOutcome()).isEqualTo(Outcome.REJECTED); + + SnapshotEnvelope badHash = envelope(1L, 7, snapshot("{}")); + badHash.setContentHash("not-a-64-hex"); + assertThat(h.store.applySnapshot(badHash).getOutcome()).isEqualTo(Outcome.REJECTED); + + SnapshotEnvelope zeroRevision = envelope(0L, 7, snapshot("{}")); + assertThat(h.store.applySnapshot(zeroRevision).getOutcome()).isEqualTo(Outcome.REJECTED); + + SnapshotEnvelope noGrant = envelope(1L, null, snapshot("{}")); + assertThat(h.store.applySnapshot(noGrant).getOutcome()).isEqualTo(Outcome.REJECTED); + + assertThat(h.projectionsSaved).isEmpty(); + } + + @Test + @DisplayName("本地复算 hash 与信封不符:按毒载荷拒绝,不沿用中心给的结论") + void recomputedHashMismatchRejects() { + Harness h = harness(); + SnapshotEnvelope envelope = envelope(1L, 7, snapshot("{}")); + envelope.setContentHash(chars('f')); + + Disposition result = h.store.applySnapshot(envelope); + + assertThat(result.getOutcome()).isEqualTo(Outcome.REJECTED); + assertThat(h.inboxRows.get(0).getDetail()).contains("复算不符"); + assertThat(h.projectionsSaved).isEmpty(); + } + + @Test + @DisplayName("事件先到且意图未验证:只落未关联投影,绝不建件(A.5)") + void eventFirstDoesNotCreateApplyWithoutVerifiedIntent() { + Harness h = harness(); + SnapshotEnvelope envelope = envelope(1L, 7, snapshot("{}")); + envelope.getSnapshot().setIntent(mapper.createObjectNode().put("intentId", "unknown-intent")); + + Disposition result = h.store.applySnapshot(envelope); + + assertThat(result.getOutcome()).isEqualTo(Outcome.APPLIED); + assertThat(result.getApplyId()).isNull(); + verify(h.applyService, never()).createFromVerifiedIntent(any(), any()); + assertThat(h.projectionsSaved.get(0).getLinkState()).isEqualTo(FcAnswerProjection.LINK_UNLINKED); + } + + @Test + @DisplayName("事件带可信关联 → 建件恰好一次;同 eventId 重投回到同一办件") + void eventWithVerifiedIntentFormsExactlyOneApply() { + Harness h = harness(); + FcApplyIntent intent = intent(11, null); + intent.setFcIntentId(FC_INTENT_ID); + h.intentRows.add(intent); + + SnapshotEnvelope envelope = envelope(1L, 7, snapshot("{}")); + envelope.getSnapshot().setIntent(mapper.createObjectNode().put("intentId", FC_INTENT_ID)); + Disposition first = h.store.applySnapshot(envelope); + + assertThat(first.getApplyId()).isEqualTo(9001); + assertThat(h.applyCounter.get()).isEqualTo(1); + assertThat(intent.getAnswerUuid()).isEqualTo(ANSWER_UUID); + assertThat(intent.getState()).isEqualTo(FcApplyIntent.STATE_ANSWERED); + // 建件成功即推进到 APPLIED(LINKED 是"已关联但还没建件"的中间态) + assertThat(h.projectionsSaved.get(0).getLinkState()).isEqualTo(FcAnswerProjection.LINK_APPLIED); + + SnapshotEnvelope repeat = envelope(1L, 7, snapshot("{}")); + repeat.setEventId(envelope.getEventId()); + repeat.getSnapshot().setIntent(mapper.createObjectNode().put("intentId", FC_INTENT_ID)); + Disposition second = h.store.applySnapshot(repeat); + + assertThat(second.getApplyId()).isEqualTo(9001); + assertThat(h.applyCounter.get()).isEqualTo(1); + } + + @Test + @DisplayName("确认先到并已建件,事件后到只合并关联,不产生第二个办件") + void confirmFirstThenEventMerges() { + Harness h = harness(); + FcApplyIntent intent = intent(12, 9001); + intent.setState(FcApplyIntent.STATE_APPLIED); + intent.setFcIntentId(FC_INTENT_ID); + intent.setAnswerUuid(ANSWER_UUID); + h.intentRows.add(intent); + + SnapshotEnvelope envelope = envelope(2L, 7, snapshot("{\"k\":\"v\"}")); + envelope.getSnapshot().setIntent(mapper.createObjectNode().put("intentId", FC_INTENT_ID)); + Disposition result = h.store.applySnapshot(envelope); + + assertThat(result.getApplyId()).isEqualTo(9001); + assertThat(h.applyCounter.get()).isZero(); + assertThat(h.projectionsSaved.get(0).getLinkState()).isEqualTo(FcAnswerProjection.LINK_APPLIED); + } + + // ==================== 意图入口与确认(A.1/A.2/A.4) ==================== + + @Test + @DisplayName("入口判定逐条拒:未发布/外链/非在线/无成对绑定/无流程线都不调中心也不建件") + void entryChecksRejectBeforeAnyRemoteCall() { + IntentHarness ih = intentHarness(); + ih.item.setStatus(ServiceItemStatusEnum.DRAFT.getCode()); + assertThatThrownBy(() -> ih.service.createIntent(user(1001), request())) + .isInstanceOf(BizException.class).hasMessageContaining("未发布"); + + ih.item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode()); + ih.item.setApplyUrl("https://other.example.com/form"); + assertThatThrownBy(() -> ih.service.createIntent(user(1001), request())) + .isInstanceOf(BizException.class).hasMessageContaining("手填外链"); + + ih.item.setApplyUrl(null); + ih.item.setOnlineFlag(0); + assertThatThrownBy(() -> ih.service.createIntent(user(1001), request())) + .isInstanceOf(BizException.class).hasMessageContaining("未开通在线办理"); + + ih.item.setOnlineFlag(1); + ih.item.setFcFormVersion(null); + assertThatThrownBy(() -> ih.service.createIntent(user(1001), request())) + .isInstanceOf(BizException.class).hasMessageContaining("确切表单版本"); + + ih.item.setFcFormVersion(4); + ih.item.setFlowGuid(null); + assertThatThrownBy(() -> ih.service.createIntent(user(1001), request())) + .isInstanceOf(BizException.class).hasMessageContaining("未绑定办理流程"); + + verify(ih.submission, never()).issueContext(any()); + verify(ih.intents, never()).saveAndFlush(any()); + } + + @Test + @DisplayName("浏览器可提交的字段闭集:只有事项坐标 + 幂等键,没有身份位") + void intentRequestCarriesNoIdentityFields() { + TreeSet names = new TreeSet<>(); + for (Field field : IntentCreateRequest.class.getDeclaredFields()) { + if (!field.isSynthetic()) { + names.add(field.getName()); + } + } + assertThat(names).containsExactly("clientKey", "itemCode", "siteCode"); + } + + @Test + @DisplayName("意图行的提交者/学校/流程线只来自会话与事项行;被嵌页地址由服务端拼") + void identityComesFromSessionNotBody() { + IntentHarness ih = intentHarness(); + IntentView view = ih.service.createIntent(user(1001), request()); + + FcApplyIntent created = ih.saved(); + assertThat(created.getSubmitterId()).isEqualTo(1001); + assertThat(created.getSchId()).isEqualTo(SCH); + assertThat(created.getFlowGuid()).isEqualTo("fg-1"); + assertThat(created.getFcFormUuid()).isEqualTo(FORM_UUID); + assertThat(created.getFcFormVersion()).isEqualTo(4); + assertThat(created.getConsumerAppKey()).isEqualTo(APP_KEY); + assertThat(view.getFillPageUrl()) + .startsWith("http://localhost:8089/f/honor_apply?mode=embed") + .contains("ctx=token-1"); + assertThat(view.getReused()).isFalse(); + assertThat(ih.issuedRequests.get(0).getSubmitterId()).isEqualTo(1001); + } + + @Test + @DisplayName("同 clientKey 重复 POST:回到同一意图并复用已签发的令牌,不再调中心") + void repeatPostReusesSameIntent() { + IntentHarness ih = intentHarness(); + ih.service.createIntent(user(1001), request()); + FcApplyIntent created = ih.saved(); + created.setId(77); + created.setContextToken("token-1"); + created.setFillPageUrl("http://localhost:8089/f/honor_apply?mode=embed&ctx=token-1"); + ih.existing = created; + + IntentView reused = ih.service.createIntent(user(1001), request()); + + assertThat(reused.getReused()).isTrue(); + assertThat(reused.getContextToken()).isEqualTo("token-1"); + verify(ih.intents, times(1)).saveAndFlush(any()); + verify(ih.submission, times(1)).issueContext(any()); + } + + @Test + @DisplayName("中心意图键由自然键哈希得来:并发双 POST 只会得到同一个键,换幂等键才是新键") + void fcIntentIdIsDeterministic() { + IntentHarness a = intentHarness(); + IntentHarness b = intentHarness(); + a.service.createIntent(user(1001), request()); + b.service.createIntent(user(1001), request()); + assertThat(a.saved().getFcIntentId()).isEqualTo(b.saved().getFcIntentId()); + assertThat(a.saved().getFcIntentId()).startsWith("svchall-"); + assertThat(a.saved().getFcIntentId().length()).isLessThanOrEqualTo(64); + + IntentHarness c = intentHarness(); + IntentCreateRequest otherKey = request(); + otherKey.setClientKey("client-key-2"); + c.service.createIntent(user(1001), otherKey); + assertThat(c.saved().getFcIntentId()).isNotEqualTo(a.saved().getFcIntentId()); + } + + @Test + @DisplayName("被嵌页基址未配置即 fail-closed:拼不出地址就不给入口") + void missingFillPageBaseFailsClosed() { + IntentHarness ih = intentHarness(); + ReflectionTestUtils.setField(ih.service, "fcFillPageBaseUrl", ""); + assertThatThrownBy(() -> ih.service.createIntent(user(1001), request())) + .isInstanceOf(BizException.class).hasMessageContaining("被嵌填报页基址未配置"); + } + + @Test + @DisplayName("确认只读服务端收据:未提交就不建件,已提交才建件一次并回带镜像状态") + void confirmReadsServerReceiptNotBrowserPayload() { + IntentHarness ih = intentHarness(); + FcApplyIntent stored = intent(77, null); + stored.setFcIntentId(FC_INTENT_ID); + ih.existingIntent = stored; + + ContextReceiptView pending = new ContextReceiptView(); + pending.setStatus("PENDING"); + ih.receipt = pending; + IntentConfirmView notYet = ih.service.confirm(user(1001), 77); + assertThat(notYet.getApplyId()).isNull(); + assertThat(notYet.getMessage()).contains("尚未收到答卷"); + verify(ih.applyService, never()).createFromVerifiedIntent(any(), any()); + + ContextReceiptView submitted = new ContextReceiptView(); + submitted.setStatus("SUBMITTED"); + submitted.setAnswerUuid(ANSWER_UUID); + submitted.setAnswerRevision(1L); + submitted.setContentHash(hash(snapshot("{}"))); + submitted.setConsumerAppKey(APP_KEY); + submitted.setConsumerSchoolId(SCH); + submitted.setSubmitterId(1001); + submitted.setFormUuid(FORM_UUID); + submitted.setFormVersion(4); + submitted.setSnapshot(snapshot("{}")); + ih.receipt = submitted; + + IntentConfirmView done = ih.service.confirm(user(1001), 77); + + assertThat(done.getApplyId()).isEqualTo(9001); + assertThat(done.getAnswerUuid()).isEqualTo(ANSWER_UUID); + assertThat(stored.getState()).isEqualTo(FcApplyIntent.STATE_APPLIED); + assertThat(ih.storedProjection.getLinkState()).isEqualTo(FcAnswerProjection.LINK_APPLIED); + } + + @Test + @DisplayName("收据提交者/坐标与意图不符即拒:别人的答卷与别校的行集都进不了本地办件") + void confirmRejectsForeignReceipt() { + IntentHarness ih = intentHarness(); + FcApplyIntent stored = intent(78, null); + stored.setFcIntentId(FC_INTENT_ID); + ih.existingIntent = stored; + + ContextReceiptView otherPerson = new ContextReceiptView(); + otherPerson.setStatus("SUBMITTED"); + otherPerson.setAnswerUuid(ANSWER_UUID); + otherPerson.setAnswerRevision(1L); + otherPerson.setSubmitterId(2002); + otherPerson.setSnapshot(snapshot("{}")); + ih.receipt = otherPerson; + assertThatThrownBy(() -> ih.service.confirm(user(1001), 78)) + .isInstanceOf(BizException.class).hasMessageContaining("提交者"); + + ContextReceiptView otherForm = new ContextReceiptView(); + otherForm.setStatus("SUBMITTED"); + otherForm.setAnswerUuid(ANSWER_UUID); + otherForm.setAnswerRevision(1L); + otherForm.setSubmitterId(1001); + otherForm.setFormUuid("ffffffffffffffffffffffffffffffff"); + otherForm.setSnapshot(snapshot("{}")); + ih.receipt = otherForm; + assertThatThrownBy(() -> ih.service.confirm(user(1001), 78)) + .isInstanceOf(BizException.class).hasMessageContaining("坐标"); + + verify(ih.applyService, never()).createFromVerifiedIntent(any(), any()); + } + + @Test + @DisplayName("只能本人确认:拿别人的意图 id 也是 403") + void confirmIsOwnerOnly() { + IntentHarness ih = intentHarness(); + ih.existingIntent = intent(79, null); + assertThatThrownBy(() -> ih.service.confirm(user(2002), 79)) + .isInstanceOf(BizException.class).hasMessageContaining("本人"); + } + + @Test + @DisplayName("已建件的意图再次确认:幂等回原办件,不再调中心也不再建件") + void confirmIsIdempotentAfterApply() { + IntentHarness ih = intentHarness(); + FcApplyIntent applied = intent(80, 9001); + applied.setState(FcApplyIntent.STATE_APPLIED); + applied.setAnswerUuid(ANSWER_UUID); + ih.existingIntent = applied; + + IntentConfirmView view = ih.service.confirm(user(1001), 80); + + assertThat(view.getApplyId()).isEqualTo(9001); + verify(ih.submission, never()).fetchReceipt(anyString()); + verify(ih.applyService, never()).createFromVerifiedIntent(any(), any()); + } + + // ==================== 出站命令与恢复(C.1/C.2) ==================== + + @Test + @DisplayName("响应丢失即 UNKNOWN:先按业务键查,查到 30 只回填镜像、绝不重发 apply") + void lostResponseProbesAndBackfillsWithoutReapply() { + RecoveryHarness rh = recoveryHarness(); + FlowApplyCommand command = rh.pending(); + when(rh.flowClient.applyWithIdentity(anyLong(), any())) + .thenThrow(new IllegalStateException("read timed out")); + Map remote = new HashMap<>(); + remote.put("instanceId", 4321L); + remote.put("status", 30); + remote.put("version", 2); + when(rh.flowClient.getInstanceByBiz(eq(SCH), anyString(), anyString())).thenReturn(remote); + + rh.service.runCommand(command.getId()); + assertThat(command.getState()).isEqualTo(FlowApplyCommand.STATE_UNKNOWN); + + rh.service.runCommand(command.getId()); + + assertThat(command.getState()).isEqualTo(FlowApplyCommand.STATE_DONE); + assertThat(command.getRemoteStatus()).isEqualTo(30); + verify(rh.applies).mirrorRemoteState(eq(42), eq(4321L), eq(30), eq(2), any(LocalDateTime.class)); + verify(rh.flowClient, times(1)).applyWithIdentity(anyLong(), any()); + verify(rh.applies, never()).markApproving(anyInt(), anyLong(), any(LocalDateTime.class)); + } + + @Test + @DisplayName("查无才补发,且两次都带同一 requestToken(同一次命令的重投)") + void resendOnlyWhenAbsentAndKeepsSameToken() { + RecoveryHarness rh = recoveryHarness(); + FlowApplyCommand command = rh.pending(); + when(rh.flowClient.applyWithIdentity(anyLong(), any())) + .thenThrow(new IllegalStateException("connection reset")); + when(rh.flowClient.getInstanceByBiz(anyLong(), anyString(), anyString())).thenReturn(null); + + rh.service.runCommand(command.getId()); + assertThat(command.getState()).isEqualTo(FlowApplyCommand.STATE_UNKNOWN); + + // 再桩必须走 doReturn:when(mock.apply(...)) 会真调用一次并把上一个 thenThrow 抛出来 + org.mockito.Mockito.doReturn(new FlowClient.ApplyResult(555L, false)) + .when(rh.flowClient).applyWithIdentity(anyLong(), any()); + rh.service.runCommand(command.getId()); + + assertThat(command.getState()).isEqualTo(FlowApplyCommand.STATE_DONE); + ArgumentCaptor sent = + ArgumentCaptor.forClass(FcApplyModels.FlowOutbound.class); + verify(rh.flowClient, times(2)).applyWithIdentity(eq(SCH), sent.capture()); + assertThat(sent.getAllValues()).allSatisfy(outbound -> + assertThat(outbound.getRequestToken()).isEqualTo(command.getRequestToken())); + verify(rh.applies).markApproving(eq(42), eq(555L), any(LocalDateTime.class)); + } + + @Test + @DisplayName("轮次只由用户授权的重提推进:attempt+1 且新 token;恢复补发沿用同一行") + void attemptOnlyIncrementsOnAuthorizedResubmit() { + RecoveryHarness rh = recoveryHarness(); + ServiceApply apply = applyRow(); + FlowApplyCommand first = rh.service.createInTransaction(apply, null, rh.payload()); + assertThat(first.getAttempt()).isEqualTo(1); + FlowApplyCommand second = rh.service.createInTransaction(apply, null, rh.payload()); + assertThat(second.getAttempt()).isEqualTo(2); + assertThat(second.getRequestToken()).isNotEqualTo(first.getRequestToken()); + FlowApplyCommand third = rh.service.createInTransaction(apply, null, rh.payload()); + assertThat(third.getAttempt()).isEqualTo(3); + } + + @Test + @DisplayName("未接入流程中心不算失败:命令退避保留,办件不写成失败态") + void disabledFlowKeepsCommandForRecovery() { + RecoveryHarness rh = recoveryHarness(); + FlowApplyCommand command = rh.pending(); + when(rh.flowClient.enabled()).thenReturn(false); + + rh.service.runCommand(command.getId()); + + assertThat(command.getState()).isEqualTo(FlowApplyCommand.STATE_FAILED); + assertThat(command.getRetryCount()).isEqualTo(1); + assertThat(command.getNextRetryAt()).isAfter(LocalDateTime.now().minusSeconds(5)); + verify(rh.flowClient, never()).applyWithIdentity(anyLong(), any()); + } + + @Test + @DisplayName("有界重试耗尽转 DEAD:留可查记录,不无限重投热循环") + void boundedRetriesEndInDead() { + RecoveryHarness rh = recoveryHarness(); + FlowApplyCommand command = rh.pending(); + command.setRetryCount(5); + when(rh.flowClient.enabled()).thenReturn(false); + + rh.service.runCommand(command.getId()); + + assertThat(command.getState()).isEqualTo(FlowApplyCommand.STATE_DEAD); + assertThat(command.getNextRetryAt()).isNull(); + } + + @Test + @DisplayName("崩溃窗口恢复:到点的命令被扫描重新推一步并落 DONE") + void crashRecoveryRescansDueCommands() { + RecoveryHarness rh = recoveryHarness(); + FlowApplyCommand command = rh.pending(); + command.setNextRetryAt(LocalDateTime.now().minusMinutes(1)); + when(rh.commands.findDue(any(LocalDateTime.class), any())) + .thenReturn(Collections.singletonList(command)); + org.mockito.Mockito.doReturn(new FlowClient.ApplyResult(777L, true)) + .when(rh.flowClient).applyWithIdentity(anyLong(), any()); + + assertThat(rh.service.recoverDue()).isEqualTo(1); + assertThat(command.getState()).isEqualTo(FlowApplyCommand.STATE_DONE); + assertThat(command.getSettledAt()).isNotNull(); + } + + // ==================== 夹具 ==================== + + private Snapshot snapshot(String valuesJson) { + Snapshot snapshot = new Snapshot(); + snapshot.setSchId(SCH); + snapshot.setAppCode("demo"); + snapshot.setCode("honor_apply"); + snapshot.setSubmitterId(1001); + snapshot.setSubjectId(1001); + snapshot.setStatus("draft"); + try { + snapshot.setValues((ObjectNode) mapper.readTree(valuesJson)); + } catch (Exception e) { + throw new IllegalStateException(e); + } + snapshot.setCreateTime("2026-10-21T10:00:00"); + snapshot.setUpdateTime("2026-10-21T10:05:00"); + return snapshot; + } + + private String hash(Snapshot snapshot) { + return AnswerProjectionStore.canonicalHash(snapshot); + } + + private SnapshotEnvelope envelope(Long revision, Integer grantVersion, Snapshot snapshot) { + SnapshotEnvelope envelope = new SnapshotEnvelope(); + envelope.setProtocolVersion(FcApplyModels.PROTOCOL_VERSION); + envelope.setEventId("evt-" + revision + "-" + System.nanoTime()); + envelope.setEventType("updated"); + envelope.setSourceAppKey("100021"); + envelope.setConsumerAppKey(APP_KEY); + envelope.setConsumerSchoolId(SCH); + envelope.setGrantVersion(grantVersion); + envelope.setFormUuid(FORM_UUID); + envelope.setFormVersion(4); + envelope.setAnswerUuid(ANSWER_UUID); + envelope.setAnswerRevision(revision); + envelope.setIsDeleted(false); + envelope.setContentHash(hash(snapshot)); + envelope.setOccurredAt("2026-10-21T10:05:00+08:00"); + envelope.setSnapshot(snapshot); + return envelope; + } + + private FcAnswerProjection projection(Long revision, String contentHash) { + FcAnswerProjection projection = new FcAnswerProjection(); + projection.setSchId(SCH); + projection.setAnswerUuid(ANSWER_UUID); + projection.setFormUuid(FORM_UUID); + projection.setFormVersion(4); + projection.setRevision(revision); + projection.setContentHash(contentHash); + projection.setIsDeleted(0); + projection.setStatus("draft"); + projection.setValuesJson("{\"k\":\"old\"}"); + projection.setLinkState(FcAnswerProjection.LINK_UNLINKED); + projection.setConflictFlag(0); + return projection; + } + + private FcApplyIntent intent(Integer id, Integer applyId) { + FcApplyIntent intent = new FcApplyIntent(); + intent.setId(id); + intent.setSchId(SCH); + intent.setSubmitterId(1001); + intent.setItemId(9); + intent.setItemCode("svc_apply_01"); + intent.setClientKey("client-key-1"); + intent.setFcFormUuid(FORM_UUID); + intent.setFcFormVersion(4); + intent.setFlowGuid("fg-1"); + intent.setConsumerAppKey(APP_KEY); + intent.setState(FcApplyIntent.STATE_ISSUED); + intent.setApplyId(applyId); + return intent; + } + + private ServiceApply applyRow() { + ServiceApply apply = new ServiceApply(); + apply.setId(42); + apply.setSchId(SCH); + apply.setSiteCode("S1"); + apply.setItemId(9); + apply.setItemCode("svc_apply_01"); + apply.setItemTitle("测试事项"); + apply.setSubmitterId(1001); + apply.setSubmitterName("张三"); + apply.setFlowGuid("fg-1"); + return apply; + } + + private static BoaUser user(int id) { + BoaUser user = new BoaUser(); + user.setId(id); + user.setName("张三"); + BoaRoleInfo current = new BoaRoleInfo(); + current.setSchId((int) SCH); + user.setCurrentRole(current); + return user; + } + + private static IntentCreateRequest request() { + IntentCreateRequest request = new IntentCreateRequest(); + request.setSiteCode("S1"); + request.setItemCode("svc_apply_01"); + request.setClientKey("client-key-1"); + return request; + } + + private static String chars(char c) { + StringBuilder sb = new StringBuilder(64); + for (int i = 0; i < 64; i++) { + sb.append(c); + } + return sb.toString(); + } + + private static TransactionTemplate runningTx() { + TransactionTemplate tx = mock(TransactionTemplate.class); + when(tx.execute(any())).thenAnswer(call -> + ((TransactionCallback) call.getArgument(0)).doInTransaction(null)); + doAnswer(call -> { + ((Consumer) call.getArgument(0)).accept(null); + return null; + }).when(tx).executeWithoutResult(any()); + return tx; + } + + /** 投影夹具:三个仓库用内存桩模拟唯一键与"已建件回原 id" */ + private Harness harness() { + Harness h = new Harness(); + h.projections = mock(FcAnswerProjectionRepository.class); + h.inboxRepository = mock(FcAnswerInboxRepository.class); + h.intents = mock(FcApplyIntentRepository.class); + h.submission = mock(FcSubmissionClient.class); + h.applyService = mock(ServiceApplyService.class); + + when(h.projections.findByAnswerUuidAndSchId(ANSWER_UUID, SCH)).thenAnswer(call -> + Optional.ofNullable(h.currentProjection)); + when(h.projections.save(any())).thenAnswer(call -> { + FcAnswerProjection projection = call.getArgument(0); + if (!h.projectionsSaved.contains(projection)) { + h.projectionsSaved.add(projection); + } + h.currentProjection = projection; + return projection; + }); + when(h.inboxRepository.findByEventIdAndConsumerAppKey(anyString(), anyString())).thenAnswer(call -> + h.inboxRows.stream().filter(row -> row.getEventId().equals(call.getArgument(0))).findFirst()); + when(h.inboxRepository.save(any())).thenAnswer(call -> { + FcAnswerInbox row = call.getArgument(0); + if (row.getId() == null) { + row.setId(h.inboxRows.size() + 1); + h.inboxRows.add(row); + } + return row; + }); + when(h.intents.findByConsumerAppKeyAndFcIntentId(APP_KEY, FC_INTENT_ID)).thenAnswer(call -> + h.intentRows.stream().filter(i -> FC_INTENT_ID.equals(i.getFcIntentId())).findFirst()); + when(h.intents.findBySchIdAndId(eq(SCH), anyInt())).thenAnswer(call -> + h.intentRows.stream().filter(i -> i.getId().equals(call.getArgument(1))).findFirst()); + when(h.intents.findByAnswerUuid(anyString())).thenAnswer(call -> + h.intentRows.stream().filter(i -> ANSWER_UUID.equals(i.getAnswerUuid())).findFirst()); + when(h.intents.save(any())).thenAnswer(call -> call.getArgument(0)); + when(h.submission.verifyGrants(anyLong(), any())).thenAnswer(call -> { + if (h.grantVerifyThrows) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单中心授权核验不可达"); + } + GrantSnapshotView view = new GrantSnapshotView(); + view.setFormUuid(FORM_UUID); + view.setConsumerSchoolId(SCH); + view.setConsumerAppKey(APP_KEY); + view.setSubscribeAllowed(h.grantAllowed); + view.setGrantVersion(h.grantAllowed ? 7 : 0); + return Collections.singletonList(view); + }); + when(h.applyService.createFromVerifiedIntent(any(), any())).thenAnswer(call -> { + FcApplyIntent intent = call.getArgument(0); + if (intent.getApplyId() != null) { + return intent.getApplyId(); + } + intent.setApplyId(9001); + h.applyCounter.incrementAndGet(); + return 9001; + }); + + h.store = new AnswerProjectionStore(h.projections, h.inboxRepository, h.intents, h.submission, + h.applyService, mapper); + ReflectionTestUtils.setField(h.store, "consumerAppKey", APP_KEY); + ReflectionTestUtils.setField(h.store, "grantCacheTtlSeconds", 0); + return h; + } + + private IntentHarness intentHarness() { + IntentHarness ih = new IntentHarness(); + ih.intents = mock(FcApplyIntentRepository.class); + ih.projections = mock(FcAnswerProjectionRepository.class); + ih.items = mock(ServiceItemRepository.class); + ih.siteDirectory = mock(SiteDirectory.class); + ih.siteGuard = mock(SiteGuard.class); + ih.submission = mock(FcSubmissionClient.class); + ih.applyService = mock(ServiceApplyService.class); + + when(ih.siteDirectory.requireSite("S1")).thenReturn(new SiteDirectory.SiteInfo("S1", "站点一", SCH)); + ih.item = new ServiceItem(); + ih.item.setId(9); + ih.item.setSchId(SCH); + ih.item.setSiteCode("S1"); + ih.item.setItemCode("svc_apply_01"); + ih.item.setTitle("测试事项"); + ih.item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode()); + ih.item.setOnlineFlag(1); + ih.item.setFcFormUuid(FORM_UUID); + ih.item.setFcFormVersion(4); + ih.item.setFlowGuid("fg-1"); + when(ih.items.findBySchIdAndSiteCodeAndItemCode(SCH, "S1", "svc_apply_01")) + .thenReturn(Optional.of(ih.item)); + + when(ih.intents.findBySchIdAndSubmitterIdAndItemIdAndClientKey(SCH, 1001, 9, "client-key-1")) + .thenAnswer(call -> Optional.ofNullable(ih.existing)); + when(ih.intents.findBySchIdAndId(eq(SCH), anyInt())).thenAnswer(call -> + ih.existingIntent != null && ih.existingIntent.getId().equals(call.getArgument(1)) + ? Optional.of(ih.existingIntent) : Optional.empty()); + when(ih.intents.saveAndFlush(any())).thenAnswer(call -> { + FcApplyIntent intent = call.getArgument(0); + intent.setId(77); + ih.savedIntents.add(intent); + return intent; + }); + when(ih.intents.save(any())).thenAnswer(call -> call.getArgument(0)); + + ContextIssueView issued = new ContextIssueView(); + issued.setIntentId(FC_INTENT_ID); + issued.setContextToken("token-1"); + issued.setEmbedOrigin("http://localhost:8087"); + issued.setCallbackUrl("http://localhost:8086/api/apply/intents"); + issued.setAppCode("demo"); + issued.setCode("honor_apply"); + issued.setFormUuid(FORM_UUID); + issued.setFormVersion(4); + issued.setExpiresAt("2026-10-21T10:30:00"); + when(ih.submission.issueContext(any(ContextIssueRequest.class))).thenAnswer(call -> { + ih.issuedRequests.add(call.getArgument(0)); + return issued; + }); + when(ih.submission.fetchReceipt(anyString())).thenAnswer(call -> ih.receipt); + when(ih.projections.findByAnswerUuidAndSchId(anyString(), anyLong())).thenAnswer(call -> + Optional.ofNullable(ih.storedProjection)); + when(ih.projections.save(any())).thenAnswer(call -> { + ih.storedProjection = call.getArgument(0); + return ih.storedProjection; + }); + when(ih.applyService.createFromVerifiedIntent(any(), any())).thenAnswer(call -> { + FcApplyIntent intent = call.getArgument(0); + if (intent.getApplyId() != null) { + return intent.getApplyId(); + } + intent.setApplyId(9001); + ih.applyCounter.incrementAndGet(); + return 9001; + }); + + ih.service = new FcApplyIntentService(ih.intents, ih.projections, ih.items, ih.siteDirectory, + ih.siteGuard, ih.submission, ih.applyService, runningTx()); + ReflectionTestUtils.setField(ih.service, "consumerAppKey", APP_KEY); + ReflectionTestUtils.setField(ih.service, "fcFillPageBaseUrl", "http://localhost:8089"); + return ih; + } + + private RecoveryHarness recoveryHarness() { + RecoveryHarness rh = new RecoveryHarness(); + rh.commands = mock(FlowApplyCommandRepository.class); + rh.applies = mock(ServiceApplyRepository.class); + rh.flowClient = mock(FlowClient.class); + when(rh.flowClient.enabled()).thenReturn(true); + when(rh.commands.saveAndFlush(any())).thenAnswer(call -> { + FlowApplyCommand command = call.getArgument(0); + if (command.getId() == null) { + command.setId(rh.created.size() + 1); + rh.created.add(command); + } + rh.history.add(command); + return command; + }); + when(rh.commands.findByApplyIdOrderByIdDesc(anyInt())).thenAnswer(call -> rh.history); + when(rh.commands.findByIdForUpdate(anyInt())).thenAnswer(call -> rh.created.stream() + .filter(c -> c.getId().equals(call.getArgument(0))).findFirst()); + when(rh.commands.findById(anyInt())).thenAnswer(call -> rh.created.stream() + .filter(c -> c.getId().equals(call.getArgument(0))).findFirst()); + when(rh.commands.findDue(any(LocalDateTime.class), any())).thenReturn(Collections.emptyList()); + + rh.service = new FlowApplyRecoveryService(rh.commands, rh.applies, rh.flowClient, mapper, runningTx()); + ReflectionTestUtils.setField(rh.service, "maxRetries", 6); + ReflectionTestUtils.setField(rh.service, "backoffBaseSeconds", 20L); + ReflectionTestUtils.setField(rh.service, "backoffCapSeconds", 1800L); + ReflectionTestUtils.setField(rh.service, "batchSize", 50); + return rh; + } + + private static final class Harness { + FcAnswerProjectionRepository projections; + FcAnswerInboxRepository inboxRepository; + FcApplyIntentRepository intents; + FcSubmissionClient submission; + ServiceApplyService applyService; + AnswerProjectionStore store; + final List inboxRows = new ArrayList<>(); + final List projectionsSaved = new ArrayList<>(); + final List intentRows = new ArrayList<>(); + final AtomicInteger applyCounter = new AtomicInteger(); + FcAnswerProjection currentProjection; + boolean grantAllowed = true; + boolean grantVerifyThrows; + } + + private static final class IntentHarness { + FcApplyIntentRepository intents; + FcAnswerProjectionRepository projections; + ServiceItemRepository items; + SiteDirectory siteDirectory; + SiteGuard siteGuard; + FcSubmissionClient submission; + ServiceApplyService applyService; + FcApplyIntentService service; + ServiceItem item; + FcApplyIntent existing; + FcApplyIntent existingIntent; + FcAnswerProjection storedProjection; + ContextReceiptView receipt; + final List savedIntents = new ArrayList<>(); + final List issuedRequests = new ArrayList<>(); + final AtomicInteger applyCounter = new AtomicInteger(); + + FcApplyIntent saved() { + return savedIntents.get(savedIntents.size() - 1); + } + } + + private final class RecoveryHarness { + FlowApplyCommandRepository commands; + ServiceApplyRepository applies; + FlowClient flowClient; + FlowApplyRecoveryService service; + final List created = new ArrayList<>(); + final List history = new ArrayList<>(); + + FlowApplyCommand pending() { + return service.createInTransaction(applyRow(), null, payload()); + } + + String payload() { + FcApplyModels.FlowOutbound outbound = new FcApplyModels.FlowOutbound(); + outbound.setFlowGuid("fg-1"); + outbound.setBizType("svc-hall"); + outbound.setBizId("42"); + outbound.setBizTitle("张三的测试事项"); + outbound.setBizUrl("/apply-detail?id=42"); + outbound.setSubmitterId(1001); + outbound.setSubmitterName("张三"); + outbound.setFormDataJson("{}"); + try { + return mapper.writeValueAsString(outbound); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + } +} diff --git a/src/test/java/com/yunhai/svchall/service/impl/ShFormBindingTest.java b/src/test/java/com/yunhai/svchall/service/impl/ShFormBindingTest.java new file mode 100644 index 0000000..b74ad31 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/ShFormBindingTest.java @@ -0,0 +1,202 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.flowcallback.FlowCallbackRegistrar; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.svchall.controller.api.svc.SvcFormCatalogController; +import com.yunhai.svchall.entity.ServiceItem; +import com.yunhai.svchall.model.svc.SvcModels.SvcFormCatalogPage; +import com.yunhai.svchall.model.svc.SvcModels.SvcItemSaveRequest; +import com.yunhai.svchall.repository.ServiceFormRepository; +import com.yunhai.svchall.repository.ServiceItemRepository; +import com.yunhai.svchall.service.DeptSource; +import com.yunhai.svchall.service.FlowClient; +import com.yunhai.svchall.service.SiteDirectory; +import com.yunhai.svchall.service.SiteGuard; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 事项的表单中心绑定单测(v3/TASK_1)。 + * + *

任务书要的证据是「事项保存远端校验失败即拒绝该保存,不写半个绑定」。这里给单元级取证: + * 核验点落在 {@code adminSave} 的任何写动作之前({@code validateBindings}),核验一抛, + * 事项行与附件重绑都不许被碰到——事务回滚是兜底,"根本没写"才是这条链路的常态。 + * 中心侧的撤权/跨校/版本行缺失三种情形在客户端都表现为同一次核验失败,本类用"客户端抛错"代表它们 + * (各自的规则在 form-center 的 {@code FcDiscoveryAccessTest} 里逐条钉住)。

+ * + *

另外四条:成对提交(单传一个即拒)、本地 {@code form_code} 只读不写(改值即拒、原样回传保留存量绑定, + * 不做"顺手清空历史行"这种二次伤害)、没表单的事项保留既有允许形态、目录代理的校域只能由 CAS 站点派生。

+ */ +@DisplayName("事项绑定表单中心 - v3 保存链路单测(TASK_1)") +class ShFormBindingTest { + + private static final SiteDirectory.SiteInfo SITE = new SiteDirectory.SiteInfo("S1", "站点一", 1L); + private static final String FC_UUID = "fcuuid0000000000000000000000ab"; + + private ServiceItemRepository itemRepository; + private ServiceFormRepository formRepository; + private AttachmentService attachmentService; + private SiteGuard siteGuard; + private FormCenterCatalogClient catalogClient; + private ServiceItemService service; + + @BeforeEach + void setUp() { + itemRepository = mock(ServiceItemRepository.class); + formRepository = mock(ServiceFormRepository.class); + attachmentService = mock(AttachmentService.class); + siteGuard = mock(SiteGuard.class); + when(siteGuard.requireSiteManager(any(), any())).thenReturn(SITE); + when(itemRepository.save(any(ServiceItem.class))).thenAnswer(call -> call.getArgument(0)); + + catalogClient = mock(FormCenterCatalogClient.class); + service = new ServiceItemService( + itemRepository, formRepository, + mock(SiteDirectory.class), mock(ServiceDictService.class), mock(ServiceFormService.class), + attachmentService, siteGuard, mock(DeptSource.class), + mock(FlowClient.class), mock(FlowCallbackRegistrar.class), mock(TransactionTemplate.class)); + // setter 注入(本类那 11 参构造被存量测试逐位钉住,见 ServiceItemService 的字段注释) + service.setFormCenterClient(catalogClient); + } + + private static SvcItemSaveRequest request() { + SvcItemSaveRequest req = new SvcItemSaveRequest(); + req.setSiteCode("S1"); + req.setItemCode("svc_apply_01"); + req.setTitle("测试事项"); + // 线下形态:不牵动"可在线办理必须绑流程"那条既有规则,本类只管表单坐标 + req.setOnlineFlag(0); + return req; + } + + private ServiceItem savedItem() { + ArgumentCaptor saved = ArgumentCaptor.forClass(ServiceItem.class); + verify(itemRepository).save(saved.capture()); + return saved.getValue(); + } + + @Test + @DisplayName("成对提交:只给 uuid 或只给 version 都拒,且不碰远端、不写库") + void bindingMustBePaired() { + SvcItemSaveRequest uuidOnly = request(); + uuidOnly.setFcFormUuid(FC_UUID); + assertThatThrownBy(() -> service.adminSave(null, uuidOnly)) + .isInstanceOf(BizException.class) + .hasMessageContaining("必须成对提交"); + + SvcItemSaveRequest versionOnly = request(); + versionOnly.setFcFormVersion(4); + assertThatThrownBy(() -> service.adminSave(null, versionOnly)) + .isInstanceOf(BizException.class) + .hasMessageContaining("必须成对提交"); + + verify(catalogClient, never()).requireBindable(any(), any(), any()); + verify(itemRepository, never()).save(any(ServiceItem.class)); + } + + @Test + @DisplayName("远端核验失败=整次保存失败:事项没落库、附件没重绑(不留半个绑定)") + void remoteCheckFailureWritesNothing() { + SvcItemSaveRequest req = request(); + req.setFcFormUuid(FC_UUID); + req.setFcFormVersion(4); + // 撤权 / 跨校 / 版本行缺失,在客户端都是同一次核验失败 + doThrow(new BizException(ErrorCode.PARAM_ERROR, "表单中心校验未通过")) + .when(catalogClient).requireBindable(1L, FC_UUID, 4); + + assertThatThrownBy(() -> service.adminSave(null, req)) + .isInstanceOf(BizException.class) + .hasMessageContaining("表单中心校验未通过"); + + verify(itemRepository, never()).save(any(ServiceItem.class)); + verify(attachmentService, never()).bindGuide(any(), any(), any()); + } + + @Test + @DisplayName("核验通过才落库:按 CAS 站点派生的校域核验,uuid 去空白后成对写入两列") + void verifiedPairIsStored() { + SvcItemSaveRequest req = request(); + req.setFcFormUuid(" " + FC_UUID + " "); + req.setFcFormVersion(7); + + service.adminSave(null, req); + + verify(catalogClient).requireBindable(eq(1L), eq(FC_UUID), eq(7)); + assertThat(savedItem().getFcFormUuid()).isEqualTo(FC_UUID); + assertThat(savedItem().getFcFormVersion()).isEqualTo(7); + } + + @Test + @DisplayName("本地 form_code 停写:新建与改值都拒;原样回传保留存量绑定(不被清空)") + void localFormCodeIsReadOnly() { + when(formRepository.existsBySchIdAndCode(1L, "legacy_form")).thenReturn(true); + when(formRepository.existsBySchIdAndCode(1L, "another_form")).thenReturn(true); + + // 新建事项就带本地 code:即便这张表在本地存在,也不允许形成新绑定 + SvcItemSaveRequest create = request(); + create.setFormCode("legacy_form"); + assertThatThrownBy(() -> service.adminSave(null, create)) + .isInstanceOf(BizException.class) + .hasMessageContaining("本地表单定义已停用"); + + ServiceItem stored = new ServiceItem(); + stored.setFormCode("legacy_form"); + when(itemRepository.findByIdAndSchIdAndSiteCode(9, 1L, "S1")).thenReturn(Optional.of(stored)); + + // 编辑存量事项、换成另一张本地表:同样拒 + SvcItemSaveRequest switchCode = request(); + switchCode.setId(9); + switchCode.setFormCode("another_form"); + assertThatThrownBy(() -> service.adminSave(null, switchCode)) + .isInstanceOf(BizException.class) + .hasMessageContaining("本地表单定义已停用"); + + // 原样回传:历史绑定保留(保存链路不清空存量行) + SvcItemSaveRequest echo = request(); + echo.setId(9); + echo.setFormCode("legacy_form"); + service.adminSave(null, echo); + assertThat(stored.getFormCode()).isEqualTo("legacy_form"); + } + + @Test + @DisplayName("没有表单的事项保留既有允许形态:两坐标都不传即正常保存") + void itemWithoutFormStillAllowed() { + service.adminSave(null, request()); + + assertThat(savedItem().getFcFormUuid()).isNull(); + assertThat(savedItem().getFcFormVersion()).isNull(); + verify(catalogClient, never()).requireBindable(any(), any(), any()); + } + + @Test + @DisplayName("目录代理的租户域从 CAS 站点派生:前端没有可越界的校号入口") + void catalogProxyDerivesSchoolFromCas() { + SiteDirectory.SiteInfo site2 = new SiteDirectory.SiteInfo("S2", "站点二", 999L); + when(siteGuard.requireSiteManager(any(), eq("S2"))).thenReturn(site2); + when(catalogClient.listForms(any(), any(), any(), any(), any())).thenReturn(new SvcFormCatalogPage()); + SvcFormCatalogController controller = new SvcFormCatalogController(siteGuard, catalogClient); + + controller.forms(null, "S1", null, "荣誉", 1, 20); + verify(catalogClient).listForms(1L, null, "荣誉", 1, 20); + + controller.forms(null, "S2", null, null, null, null); + verify(catalogClient).listForms(999L, null, null, null, null); + } +} diff --git a/src/test/java/com/yunhai/svchall/service/impl/ShReconcileExtensionTest.java b/src/test/java/com/yunhai/svchall/service/impl/ShReconcileExtensionTest.java new file mode 100644 index 0000000..505c600 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/ShReconcileExtensionTest.java @@ -0,0 +1,225 @@ +package com.yunhai.svchall.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.svchall.entity.FcAnswerProjection; +import com.yunhai.svchall.model.FcApplyModels; +import com.yunhai.svchall.model.FcApplyModels.Snapshot; +import com.yunhai.svchall.model.FcApplyModels.SnapshotEnvelope; +import com.yunhai.svchall.model.FcApplyModels.GrantSnapshotView; +import com.yunhai.svchall.repository.FcAnswerProjectionRepository; +import com.yunhai.svchall.repository.FcAnswerInboxRepository; +import com.yunhai.svchall.repository.FcApplyIntentRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ShReconcileExtensionTest { + private static final String UUID = "00000000000000000000000000000001"; + private final ObjectMapper mapper = new ObjectMapper(); + private FcAnswerProjectionRepository projections; + private FcAnswerInboxRepository inbox; + private FcApplyIntentRepository intents; + private ServiceApplyService applies; + private FcSubmissionClient submission; + private AnswerProjectionStore store; + private SnapshotEnvelope envelope; + private FcAnswerProjection local; + + @BeforeEach + void setup() { + projections = mock(FcAnswerProjectionRepository.class); + inbox = mock(FcAnswerInboxRepository.class); + intents = mock(FcApplyIntentRepository.class); + applies = mock(ServiceApplyService.class); + submission = mock(FcSubmissionClient.class); + store = new AnswerProjectionStore(projections, inbox, intents, submission, applies, mapper); + ReflectionTestUtils.setField(store, "consumerAppKey", "100018"); + GrantSnapshotView grant = new GrantSnapshotView(); + grant.setFormUuid("form-1"); + grant.setConsumerSchoolId(5L); + grant.setConsumerAppKey("100018"); + grant.setSubscribeAllowed(true); + grant.setGrantVersion(7); + when(submission.verifyGrants(eq(5L), any())).thenReturn(Collections.singletonList(grant)); + Snapshot snapshot = new Snapshot(); + snapshot.setSchId(5L); + snapshot.setValues(mapper.createObjectNode().put("value", "authoritative")); + snapshot.setIntent(mapper.createObjectNode().put("intentId", "should-not-link")); + envelope = new SnapshotEnvelope(); + envelope.setProtocolVersion(FcApplyModels.PROTOCOL_VERSION); + envelope.setConsumerAppKey("100018"); + envelope.setConsumerSchoolId(5L); + envelope.setGrantVersion(7); + envelope.setFormUuid("form-1"); + envelope.setFormVersion(4); + envelope.setAnswerUuid(UUID); + envelope.setEventId("reconcile-task-1-answer-1"); + envelope.setAnswerRevision(3L); + envelope.setIsDeleted(false); + envelope.setSnapshot(snapshot); + envelope.setContentHash(AnswerProjectionStore.canonicalHash(snapshot)); + local = new FcAnswerProjection(); + local.setSchId(5L); + local.setAnswerUuid(UUID); + local.setFormUuid("form-1"); + local.setRevision(3L); + local.setContentHash("local-corrupt-hash"); + local.setIsDeleted(0); + local.setBizExtJson("business-extension"); + local.setApplyId(42); + when(projections.findByAnswerUuidAndSchId(UUID, 5L)).thenAnswer(i -> Optional.ofNullable(local)); + when(projections.repairIfUnchanged(any(), anyLong(), anyString(), anyInt())).thenReturn(1); + } + + private AnswerProjectionStore.RepairResult repair() { + return store.repairAuthoritative(envelope, 3L, "local-corrupt-hash", 0, "task-1/proof-1"); + } + + @Test + void sameRevisionRepairUsesConditionalSnapshotUpdateWithoutBusinessEffects() { + assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.APPLIED); + ArgumentCaptor replacement = ArgumentCaptor.forClass(FcAnswerProjection.class); + verify(projections).repairIfUnchanged(replacement.capture(), eq(3L), eq("local-corrupt-hash"), eq(0)); + assertThat(replacement.getValue().getContentHash()).isEqualTo(envelope.getContentHash()); + assertThat(replacement.getValue().getValuesJson()).contains("authoritative"); + assertThat(replacement.getValue().getConflictFlag()).isZero(); + assertThat(local.getBizExtJson()).isEqualTo("business-extension"); + assertThat(local.getApplyId()).isEqualTo(42); + verifyNoInteractions(inbox, intents, applies); + } + + @Test + void absentRepairDoesNotLinkEvenWhenSnapshotContainsIntent() { + local = null; + assertThat(store.repairAuthoritative(envelope, null, null, null, "proof-absent")) + .isEqualTo(AnswerProjectionStore.RepairResult.APPLIED); + ArgumentCaptor row = ArgumentCaptor.forClass(FcAnswerProjection.class); + verify(projections).saveAndFlush(row.capture()); + assertThat(row.getValue().getLinkState()).isEqualTo(FcAnswerProjection.LINK_UNLINKED); + assertThat(row.getValue().getIntentId()).isNull(); + assertThat(row.getValue().getApplyId()).isNull(); + verifyNoInteractions(inbox, intents, applies); + } + + @Test + void localChangeBetweenReadAndUpdateIsNotOverwritten() { + when(projections.repairIfUnchanged(any(), anyLong(), anyString(), anyInt())).thenReturn(0); + assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.LOCAL_CHANGED); + assertThat(local.getContentHash()).isEqualTo("local-corrupt-hash"); + } + + @Test + void changedTupleAndHigherLocalRevisionRequireRecheck() { + local.setIsDeleted(1); + assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.LOCAL_CHANGED); + local.setRevision(4L); + assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.LOCAL_AHEAD); + verify(projections, never()).repairIfUnchanged(any(), anyLong(), anyString(), anyInt()); + } + + @Test + void newerTombstoneUsesSameRepairPrimitive() { + envelope.setAnswerRevision(4L); + envelope.setIsDeleted(true); + assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.APPLIED); + ArgumentCaptor row = ArgumentCaptor.forClass(FcAnswerProjection.class); + verify(projections).repairIfUnchanged(row.capture(), eq(3L), anyString(), eq(0)); + assertThat(row.getValue().getIsDeleted()).isEqualTo(1); + assertThat(row.getValue().getRevision()).isEqualTo(4L); + } + + @Test + void repairRechecksGrantEvenWithWarmCacheAndRejectsUnknownEpoch() { + assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.APPLIED); + envelope.setGrantVersion(8); + assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.DEFERRED); + when(submission.verifyGrants(eq(5L), any())).thenReturn(Collections.emptyList()); + assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.DEFERRED); + verify(submission, times(3)).verifyGrants(eq(5L), any()); + } + + @Test + void freshGrantMustBelongToTheSameTenantAndConsumer() { + GrantSnapshotView other = new GrantSnapshotView(); + other.setFormUuid("form-1"); + other.setConsumerSchoolId(6L); + other.setConsumerAppKey("100018"); + other.setSubscribeAllowed(true); + other.setGrantVersion(7); + when(submission.verifyGrants(eq(5L), any())).thenReturn(Collections.singletonList(other)); + assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.DEFERRED); + other.setConsumerSchoolId(5L); + other.setConsumerAppKey("another-app"); + assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.DEFERRED); + verifyNoInteractions(projections); + } + + @Test + void malformedProofWrongTenantAndHashAreRejectedWithoutWrites() { + assertThatThrownBy(() -> store.repairAuthoritative(envelope, 3L, null, 0, "proof")) + .isInstanceOf(BizException.class); + envelope.getSnapshot().setSchId(6L); + assertThatThrownBy(this::repair).isInstanceOf(BizException.class); + envelope.getSnapshot().setSchId(5L); + envelope.setContentHash(String.join("", Collections.nCopies(64, "a"))); + assertThatThrownBy(this::repair).isInstanceOf(BizException.class); + verify(projections, never()).repairIfUnchanged(any(), anyLong(), anyString(), anyInt()); + } + + private FcAnswerProjectionRepository.ManifestRow row(String uuid) { + FcAnswerProjectionRepository.ManifestRow row = mock(FcAnswerProjectionRepository.ManifestRow.class); + when(row.getAnswerUuid()).thenReturn(uuid); + when(row.getCreateTime()).thenReturn(LocalDateTime.of(2026, 10, 21, 1, 0)); + return row; + } + + @Test + void uuidCursorTraversesSparseRowsAndReturnsExplicitEnd() { + String second = "00000000000000000000000000000050"; + String third = "00000000000000000000000000000099"; + FcAnswerProjectionRepository.ManifestRow firstRow = row(UUID); + FcAnswerProjectionRepository.ManifestRow secondRow = row(second); + FcAnswerProjectionRepository.ManifestRow thirdRow = row(third); + when(projections.findManifest(eq(5L), eq("form-1"), eq(""), any())) + .thenReturn(Arrays.asList(firstRow, secondRow, thirdRow)); + when(projections.findManifest(eq(5L), eq("form-1"), eq(second), any())) + .thenReturn(Collections.singletonList(thirdRow)); + AnswerProjectionStore.ManifestPage first = store.manifestPage(5L, "form-1", null, 2); + assertThat(first.getRows()).hasSize(2); + assertThat(first.isCompleted()).isFalse(); + assertThat(first.getNextCursor()).isEqualTo(second); + assertThat(first.getRows().get(0).getCreateTime()).isNotNull(); + AnswerProjectionStore.ManifestPage last = store.manifestPage(5L, "form-1", first.getNextCursor(), 2); + assertThat(last.getRows()).extracting(FcAnswerProjectionRepository.ManifestRow::getAnswerUuid) + .containsExactly(third); + assertThat(last.isCompleted()).isTrue(); + assertThat(last.getNextCursor()).isNull(); + ArgumentCaptor page = ArgumentCaptor.forClass(Pageable.class); + verify(projections).findManifest(eq(5L), eq("form-1"), eq(""), page.capture()); + assertThat(page.getValue().getPageSize()).isEqualTo(3); + } + + @Test + void emptyAndExactlyFullPagesAreCompletedAndInvalidCursorIsRejected() { + FcAnswerProjectionRepository.ManifestRow first = row(UUID); + FcAnswerProjectionRepository.ManifestRow second = row("00000000000000000000000000000050"); + when(projections.findManifest(anyLong(), anyString(), anyString(), any())) + .thenReturn(Collections.emptyList(), Arrays.asList(first, second)); + assertThat(store.manifestPage(5L, "form-1", null, 2).isCompleted()).isTrue(); + assertThat(store.manifestPage(5L, "form-1", null, 2).isCompleted()).isTrue(); + assertThatThrownBy(() -> store.manifestPage(5L, "form-1", "invalid", 2)) + .isInstanceOf(BizException.class); + } +} diff --git a/src/test/java/com/yunhai/svchall/service/impl/ShReconcileTaskTest.java b/src/test/java/com/yunhai/svchall/service/impl/ShReconcileTaskTest.java new file mode 100644 index 0000000..a4fee28 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/ShReconcileTaskTest.java @@ -0,0 +1,1066 @@ +package com.yunhai.svchall.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.svchall.entity.AnswerReconcileWork; +import com.yunhai.svchall.entity.FcAnswerProjection; +import com.yunhai.svchall.model.FcApplyModels.SnapshotEnvelope; +import com.yunhai.svchall.model.FormSyncModels; +import com.yunhai.svchall.model.FormSyncModels.AbsenceProof; +import com.yunhai.svchall.model.FormSyncModels.InstructionAck; +import com.yunhai.svchall.model.FormSyncModels.ManifestPageView; +import com.yunhai.svchall.model.FormSyncModels.ManifestRowView; +import com.yunhai.svchall.model.FormSyncModels.ReconcileError; +import com.yunhai.svchall.model.FormSyncModels.ReconcileInstruction; +import com.yunhai.svchall.model.FormSyncModels.ResultAck; +import com.yunhai.svchall.model.FormSyncModels.ResultReport; +import com.yunhai.svchall.model.FormSyncModels.TaskAnswerReply; +import com.yunhai.svchall.repository.AnswerReconcileWorkRepository; +import com.yunhai.svchall.repository.FcAnswerProjectionRepository; +import com.yunhai.svchall.repository.FcAnswerProjectionRepository.ManifestRow; +import com.yunhai.svchall.service.impl.AnswerProjectionStore.AuthoritativeAbsenceProof; +import com.yunhai.svchall.service.impl.AnswerProjectionStore.ManifestPage; +import com.yunhai.svchall.service.impl.AnswerProjectionStore.RepairResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 差异对账 Worker(TASK_3)消费侧规则单测。 + * + *

取向与 {@code ShAnswerFlowTest}/{@code ShReconcileExtensionTest} 一致:不起 Spring 上下文、 + * 不打真实库,协作者喂桩,{@link #runningTransactionTemplate()} 让事务回调真的跑—— + * 否则"领取加锁""检查点落库"这两类断言全是空转。

+ * + *

两侧读取都用流模拟器桩(按传入游标过滤升序全集):它真实复现 keyset 语义, + * 因此"锁步推进、不越界消费、重启从水位续读"能被钉住,而不是靠一次性 thenReturn 蒙过去。

+ * + *

本类钉任务协议;{@code ShReconcileExtensionTest} 只验证 TASK_2 扩展服务自身的规则, + * 不能代替这一层。真库的乐观锁/唯一键实际并发、MySQL 条件更新的原子性仍属联调层,桩证不了。

+ */ +@DisplayName("差异对账 Worker(消费侧)- 规则单测(TASK_3)") +class ShReconcileTaskTest { + + private static final String SELF = "100018"; + private static final String TASK_ID = "0f1e2d3c4b5a69788796a5b4c3d2e1f0"; + private static final String FORM_UUID = "f0e1d2c3b4a5968778695a4b3c2d1e0f"; + private static final String ANSWER_A = "a0000000000000000000000000000001"; + private static final String ANSWER_B = "b0000000000000000000000000000002"; + private static final String ANSWER_C = "c0000000000000000000000000000003"; + private static final String ANSWER_X = "f0000000000000000000000000000009"; + private static final long SCHOOL = 5L; + private static final LocalDateTime WINDOW_START = LocalDateTime.of(2026, 10, 21, 2, 0); + private static final LocalDateTime BEFORE_WINDOW = LocalDateTime.of(2026, 10, 20, 9, 0); + + private final ObjectMapper mapper = new ObjectMapper(); + + private AnswerReconcileWorkRepository workRepository; + private AnswerProjectionStore projectionStore; + private FcAnswerProjectionRepository projectionRepository; + private FormSyncClient formSyncClient; + private AnswerReconcileWorker worker; + + @BeforeEach + void setUp() { + workRepository = mock(AnswerReconcileWorkRepository.class); + projectionStore = mock(AnswerProjectionStore.class); + projectionRepository = mock(FcAnswerProjectionRepository.class); + formSyncClient = mock(FormSyncClient.class); + when(workRepository.save(any(AnswerReconcileWork.class))).thenAnswer(call -> call.getArgument(0)); + worker = new AnswerReconcileWorker(workRepository, projectionStore, projectionRepository, + formSyncClient, runningTransactionTemplate(), mapper, SELF); + ReflectionTestUtils.setField(worker, "enabled", true); + ReflectionTestUtils.setField(worker, "leaseSeconds", 900); + ReflectionTestUtils.setField(worker, "maxRepairRetries", 2); + ReflectionTestUtils.setField(worker, "maxErrorItems", 5); + ReflectionTestUtils.setField(worker, "maxPendingExtras", 2); + ReflectionTestUtils.setField(worker, "maxWorkPerScan", 1); + ReflectionTestUtils.setField(worker, "progressReportRounds", 0); + ReflectionTestUtils.setField(worker, "backoffBaseSeconds", 60); + ReflectionTestUtils.setField(worker, "backoffCapSeconds", 1800); + when(formSyncClient.enabled()).thenReturn(true); + when(formSyncClient.submitResult(anyString(), any(ResultReport.class))).thenReturn(acceptedAck()); + } + + // ==================== 接收与执行解耦 ==================== + + @Test + @DisplayName("指令先落库才执行:接收只写 QUEUED,2xx 不代表任何修复结果") + void acceptedInstructionIsPersistedAsQueuedOnly() { + when(workRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.empty()); + + AnswerReconcileWork work = worker.accept(instruction(1), SELF); + + assertThat(work.getState()).isEqualTo(AnswerReconcileWork.STATE_QUEUED); + assertThat(work.getTaskUuid()).isEqualTo(TASK_ID); + assertThat(work.getAttempt()).isEqualTo(1); + assertThat(work.getWindowStart()).isEqualTo(WINDOW_START); + assertThat(work.getConsumerSchoolId()).isEqualTo(SCHOOL); + assertThat(work.getFormUuid()).isEqualTo(FORM_UUID); + assertThat(work.getPageSize()).isEqualTo(200); + assertThat(work.getFinishedAt()).isNull(); + assertThat(work.getReportedState()).isNull(); + assertThat(work.getReceivedAt()).isNotNull(); + verify(projectionStore, never()).repairAuthoritative(any(), any(), any(), any(), any()); + verify(projectionStore, never()).applySnapshot(any()); + } + + @Test + @DisplayName("身份不符一律拒收:验签身份不是本应用、目标应用不是本应用、协议号不符") + void forgedOrMismatchedInstructionIsRejected() { + assertThatThrownBy(() -> worker.accept(instruction(1), "999999")) + .isInstanceOf(BizException.class).hasMessageContaining("消费身份不符"); + ReconcileInstruction foreignTarget = instruction(1); + foreignTarget.setConsumerAppKey("100020"); + assertThatThrownBy(() -> worker.accept(foreignTarget, SELF)) + .isInstanceOf(BizException.class).hasMessageContaining("不是本应用"); + ReconcileInstruction badProtocol = instruction(1); + badProtocol.setProtocol("fc.form-sync.v9"); + assertThatThrownBy(() -> worker.accept(badProtocol, SELF)) + .isInstanceOf(BizException.class).hasMessageContaining("协议号不符"); + verify(workRepository, never()).save(any(AnswerReconcileWork.class)); + } + + @Test + @DisplayName("坐标与起点缺失即拒:非法租户域、超长 taskId、不可解析的 windowStart、负 attempt") + void instructionCoordinatesAreValidated() { + ReconcileInstruction foreignSchool = instruction(1); + foreignSchool.setConsumerSchoolId(0L); + assertThatThrownBy(() -> worker.accept(foreignSchool, SELF)) + .isInstanceOf(BizException.class).hasMessageContaining("真实学校"); + + ReconcileInstruction longId = instruction(1); + longId.setTaskId("012345678901234567890123456789012345678901234567890123456789"); + assertThatThrownBy(() -> worker.accept(longId, SELF)) + .isInstanceOf(BizException.class).hasMessageContaining("36 字符"); + + ReconcileInstruction badWindow = instruction(1); + badWindow.setWindowStart("2026-10-21 02:00"); + assertThatThrownBy(() -> worker.accept(badWindow, SELF)) + .isInstanceOf(BizException.class).hasMessageContaining("任务起点"); + + ReconcileInstruction negativeAttempt = instruction(1); + negativeAttempt.setAttempt(-1); + assertThatThrownBy(() -> worker.accept(negativeAttempt, SELF)) + .isInstanceOf(BizException.class).hasMessageContaining("attempt"); + + ReconcileInstruction halfCoordinate = instruction(1); + halfCoordinate.setFormVersion(null); + assertThatThrownBy(() -> worker.accept(halfCoordinate, SELF)) + .isInstanceOf(BizException.class).hasMessageContaining("版本确切"); + } + + @Test + @DisplayName("重复指令复用同一 taskId 与检查点:更旧 attempt 只吸收,更大 attempt 不动已比较区间") + void duplicateInstructionReusesTaskAndCheckpoint() { + AnswerReconcileWork stored = work(AnswerReconcileWork.STATE_COMPLETED, 3); + stored.setWatermark(ANSWER_A); + stored.setScannedRows(7); + when(workRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(stored)); + + AnswerReconcileWork older = worker.accept(instruction(1), SELF); + + assertThat(older.getAttempt()).isEqualTo(3); + assertThat(older.getWatermark()).isEqualTo(ANSWER_A); + assertThat(older.getScannedRows()).isEqualTo(7); + verify(workRepository, never()).save(any(AnswerReconcileWork.class)); + + AnswerReconcileWork redispatched = worker.accept(instruction(4), SELF); + assertThat(redispatched.getTaskUuid()).isEqualTo(TASK_ID); + assertThat(redispatched.getAttempt()).isEqualTo(4); + assertThat(redispatched.getWatermark()).isEqualTo(ANSWER_A); + assertThat(redispatched.getState()).isEqualTo(AnswerReconcileWork.STATE_QUEUED); + assertThat(redispatched.getScannedRows()).isEqualTo(7); + } + + @Test + @DisplayName("仍在跑且租约有效时,新 attempt 只刷新身份:不打断正在执行的这一轮") + void redispatchWhileLeaseLiveDoesNotInterruptRunningRound() { + AnswerReconcileWork running = work(AnswerReconcileWork.STATE_RUNNING, 1); + running.setLeaseUntil(LocalDateTime.now().plusMinutes(10)); + running.setWatermark(ANSWER_A); + running.setScannedRows(4); + when(workRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(running)); + + AnswerReconcileWork accepted = worker.accept(instruction(2), SELF); + + assertThat(accepted.getState()).isEqualTo(AnswerReconcileWork.STATE_RUNNING); + assertThat(accepted.getAttempt()).isEqualTo(2); + assertThat(accepted.getWatermark()).isEqualTo(ANSWER_A); + assertThat(accepted.getScannedRows()).isEqualTo(4); + } + + @Test + @DisplayName("开关未开或出站客户端未就绪:一行数据都不动") + void disabledSwitchDoesNothing() { + ReflectionTestUtils.setField(worker, "enabled", false); + assertThat(worker.runDue()).isZero(); + ReflectionTestUtils.setField(worker, "enabled", true); + when(formSyncClient.enabled()).thenReturn(false); + assertThat(worker.runDue()).isZero(); + verify(projectionStore, never()).repairAuthoritative(any(), any(), any(), any(), any()); + verify(workRepository, never()).findByIdForUpdate(any()); + } + + // ==================== 差异方向与修复 ==================== + + @Test + @DisplayName("缺失行按中心当前值走权威修复:期望三元组全 null,绝不建件、绝不调 applySnapshot") + void missingRowIsRepairedAuthoritatively() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 5L, hash('a'), false)), + Collections.emptyList(), true, 200); + stubAnswer(ANSWER_A, present(ANSWER_A, hash('a'), 5L, false)); + when(projectionStore.repairAuthoritative(any(), isNull(), isNull(), isNull(), anyString())) + .thenReturn(RepairResult.APPLIED); + + worker.executeQuietly(work.getId()); + + ArgumentCaptor envelope = ArgumentCaptor.forClass(SnapshotEnvelope.class); + ArgumentCaptor proof = ArgumentCaptor.forClass(String.class); + verify(projectionStore).repairAuthoritative(envelope.capture(), isNull(), isNull(), isNull(), + proof.capture()); + assertThat(envelope.getValue().getFormUuid()).isEqualTo(FORM_UUID); + assertThat(envelope.getValue().getAnswerRevision()).isEqualTo(5L); + assertThat(envelope.getValue().getConsumerAppKey()).isEqualTo(SELF); + assertThat(proof.getValue()).startsWith("rep:" + TASK_ID).hasSizeLessThanOrEqualTo(128); + verify(projectionStore, never()).applySnapshot(any()); + assertThat(submitted().getMissingRepaired()).isEqualTo(1); + assertThat(submitted().getState()).isEqualTo(AnswerReconcileWork.STATE_COMPLETED); + } + + @Test + @DisplayName("陈旧行提交本地扫描三元组,由数据库条件更新裁决") + void staleRowUsesLocalScanTriple() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 6L, hash('a'), false)), + Collections.singletonList(row(ANSWER_A, 3L, hash('b'), 0, BEFORE_WINDOW)), true, 200); + stubAnswer(ANSWER_A, present(ANSWER_A, hash('a'), 6L, false)); + when(projectionStore.repairAuthoritative(any(), eq(3L), eq(hash('b')), eq(0), anyString())) + .thenReturn(RepairResult.APPLIED); + + worker.executeQuietly(work.getId()); + + verify(projectionStore).repairAuthoritative(any(), eq(3L), eq(hash('b')), eq(0), anyString()); + assertThat(submitted().getStaleRepaired()).isEqualTo(1); + assertThat(submitted().getState()).isEqualTo(AnswerReconcileWork.STATE_COMPLETED); + } + + @Test + @DisplayName("同 revision 异 hash:先记冲突,再取中心当前值走权威修复(不走普通 MQ 覆盖)") + void sameRevisionDifferentHashIsRepairedViaAuthoritativeEntry() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 6L, hash('a'), false)), + Collections.singletonList(row(ANSWER_A, 6L, hash('b'), 0, BEFORE_WINDOW)), true, 200); + stubAnswer(ANSWER_A, present(ANSWER_A, hash('a'), 6L, false)); + when(projectionStore.repairAuthoritative(any(), eq(6L), eq(hash('b')), eq(0), anyString())) + .thenReturn(RepairResult.APPLIED); + + worker.executeQuietly(work.getId()); + + assertThat(submitted().getHashConflicts()).isEqualTo(1); + assertThat(submitted().getStaleRepaired()).isEqualTo(1); + verify(projectionStore, never()).applySnapshot(any()); + } + + @Test + @DisplayName("本地 revision 更高:向中心复核当前值后只报告,禁止用旧清单降级覆盖") + void localAheadNeverDowngrades() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 4L, hash('a'), false)), + Collections.singletonList(row(ANSWER_A, 9L, hash('b'), 0, BEFORE_WINDOW)), true, 200); + stubAnswer(ANSWER_A, present(ANSWER_A, hash('b'), 4L, false)); + + worker.executeQuietly(work.getId()); + + verify(projectionStore, never()).repairAuthoritative(any(), any(), any(), any(), any()); + assertThat(submitted().getLocalAhead()).isEqualTo(1); + assertThat(submitted().getState()).isEqualTo(AnswerReconcileWork.STATE_PARTIAL); + assertThat(jsonOf(submitted())).contains(FormSyncModels.DIFF_LOCAL_AHEAD); + } + + @Test + @DisplayName("本地更高但中心已推进到新值:按陈旧方向修到新值,不停在中间版") + void localAheadWithNewerCenterValueIsRepaired() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 4L, hash('a'), false)), + Collections.singletonList(row(ANSWER_A, 9L, hash('b'), 0, BEFORE_WINDOW)), true, 200); + stubAnswer(ANSWER_A, present(ANSWER_A, hash('c'), 12L, false)); + when(projectionStore.repairAuthoritative(any(), eq(9L), eq(hash('b')), eq(0), anyString())) + .thenReturn(RepairResult.APPLIED); + + worker.executeQuietly(work.getId()); + + verify(projectionStore).repairAuthoritative(any(), eq(9L), eq(hash('b')), eq(0), anyString()); + assertThat(submitted().getStaleRepaired()).isEqualTo(1); + assertThat(submitted().getLocalAhead()).isEqualTo(0); + } + + @Test + @DisplayName("墓碑走同一权威修复原语:isDeleted 差异计入墓碑修复,不动业务扩展") + void tombstoneUsesSameRepairPrimitive() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 6L, hash('a'), true)), + Collections.singletonList(row(ANSWER_A, 6L, hash('a'), 0, BEFORE_WINDOW)), true, 200); + stubAnswer(ANSWER_A, present(ANSWER_A, hash('a'), 6L, true)); + when(projectionStore.repairAuthoritative(any(), eq(6L), eq(hash('a')), eq(0), anyString())) + .thenReturn(RepairResult.APPLIED); + + worker.executeQuietly(work.getId()); + + verify(projectionStore).repairAuthoritative(any(), eq(6L), eq(hash('a')), eq(0), anyString()); + assertThat(submitted().getTombstonesRepaired()).isEqualTo(1); + verify(projectionStore, never()).applySnapshot(any()); + } + + @Test + @DisplayName("四元摘要一致不动数据,也不向中心取快照:完整扫描不是第二份全量拉取") + void consistentRowsAreUntouchedAndNeverFetched() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 6L, hash('a'), false)), + Collections.singletonList(row(ANSWER_A, 6L, hash('a'), 0, BEFORE_WINDOW)), true, 200); + + worker.executeQuietly(work.getId()); + + verify(projectionStore, never()).repairAuthoritative(any(), any(), any(), any(), any()); + verify(projectionStore, never()).retireAbsent(any()); + verify(formSyncClient, never()).fetchTaskAnswer(anyString(), anyString(), any()); + assertThat(submitted().getState()).isEqualTo(AnswerReconcileWork.STATE_COMPLETED); + assertThat(submitted().getScannedRows()).isEqualTo(1); + } + + @Test + @DisplayName("修订号为 0 的存量行不硬修:只报告方向,结果 PARTIAL") + void revisionZeroRowIsReportedNotRepaired() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 0L, hash('a'), false)), + Collections.emptyList(), true, 200); + + worker.executeQuietly(work.getId()); + + verify(projectionStore, never()).repairAuthoritative(any(), any(), any(), any(), any()); + assertThat(jsonOf(submitted())).contains(FormSyncModels.DIFF_REVISION_ZERO); + assertThat(submitted().getState()).isEqualTo(AnswerReconcileWork.STATE_PARTIAL); + } + + // ==================== 四类 RepairResult 与并发 ==================== + + @Test + @DisplayName("DEFERRED:新鲜授权核验不可达/撤权/纪元不等即暂停修复") + void deferredStopsRemainingRepairs() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Arrays.asList(fcRow(ANSWER_A, 6L, hash('a'), false), fcRow(ANSWER_B, 6L, hash('b'), false)), + Arrays.asList(row(ANSWER_A, 3L, hash('a'), 0, BEFORE_WINDOW), + row(ANSWER_B, 3L, hash('b'), 0, BEFORE_WINDOW)), true, 200); + stubAnswer(ANSWER_A, present(ANSWER_A, hash('a'), 6L, false)); + stubAnswer(ANSWER_B, present(ANSWER_B, hash('b'), 6L, false)); + when(projectionStore.repairAuthoritative(any(), any(), any(), any(), any())) + .thenReturn(RepairResult.DEFERRED); + + worker.executeQuietly(work.getId()); + + assertThat(submitted().getDeferred()).isEqualTo(1); + assertThat(submitted().getState()).isEqualTo(AnswerReconcileWork.STATE_PARTIAL); + // 暂停后本轮不再往下修 + verify(projectionStore, times(1)).repairAuthoritative(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("修复入口返回 LOCAL_AHEAD:按本地更高处置,不重试、不降级") + void localAheadBranchFromRepairResult() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 6L, hash('a'), false)), + Collections.singletonList(row(ANSWER_A, 3L, hash('b'), 0, BEFORE_WINDOW)), true, 200); + stubAnswer(ANSWER_A, present(ANSWER_A, hash('a'), 6L, false)); + when(projectionStore.repairAuthoritative(any(), eq(3L), eq(hash('b')), eq(0), anyString())) + .thenReturn(RepairResult.LOCAL_AHEAD); + + worker.executeQuietly(work.getId()); + + // 该分支不进入重试循环:一次调用即停手,等向中心复核 + verify(projectionStore, times(1)).repairAuthoritative(any(), any(), any(), any(), any()); + assertThat(submitted().getLocalAhead()).isEqualTo(1); + assertThat(submitted().getState()).isEqualTo(AnswerReconcileWork.STATE_PARTIAL); + } + + @Test + @DisplayName("LOCAL_CHANGED:整笔回滚后在事务外重读本地行与中心当前值,再有界重试") + void localChangedRetriesOutsideTransaction() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 6L, hash('a'), false)), + Collections.singletonList(row(ANSWER_A, 3L, hash('b'), 0, BEFORE_WINDOW)), true, 200); + stubAnswer(ANSWER_A, present(ANSWER_A, hash('a'), 6L, false)); + when(projectionRepository.findByAnswerUuidAndSchId(ANSWER_A, SCHOOL)) + .thenReturn(Optional.of(entity(ANSWER_A, 5L, hash('c'), 0))); + when(projectionStore.repairAuthoritative(any(), eq(3L), eq(hash('b')), eq(0), anyString())) + .thenReturn(RepairResult.LOCAL_CHANGED); + when(projectionStore.repairAuthoritative(any(), eq(5L), eq(hash('c')), eq(0), anyString())) + .thenReturn(RepairResult.APPLIED); + + worker.executeQuietly(work.getId()); + + verify(projectionRepository, atLeast(1)).findByAnswerUuidAndSchId(ANSWER_A, SCHOOL); + verify(projectionStore).repairAuthoritative(any(), eq(5L), eq(hash('c')), eq(0), anyString()); + assertThat(submitted().getStaleRepaired()).isEqualTo(1); + assertThat(submitted().getState()).isEqualTo(AnswerReconcileWork.STATE_COMPLETED); + } + + @Test + @DisplayName("乐观锁与唯一键竞争都使当笔事务回滚:事务外重读后有界重试,耗尽只报告") + void lockAndUniqueKeyRacesRetryBoundedThenReport() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 6L, hash('a'), false)), + Collections.singletonList(row(ANSWER_A, 3L, hash('b'), 0, BEFORE_WINDOW)), true, 200); + stubAnswer(ANSWER_A, present(ANSWER_A, hash('a'), 6L, false)); + when(projectionRepository.findByAnswerUuidAndSchId(ANSWER_A, SCHOOL)) + .thenReturn(Optional.of(entity(ANSWER_A, 3L, hash('b'), 0))); + when(projectionStore.repairAuthoritative(any(), any(), any(), any(), any())) + .thenThrow(new OptimisticLockingFailureException("projection_version 乐观锁冲突")) + .thenThrow(new DataIntegrityViolationException("uk_projection_answer 并发插入")) + .thenReturn(RepairResult.LOCAL_CHANGED); + + worker.executeQuietly(work.getId()); + + // 首试 + 2 次重试 = 3 次,然后停手(不无限重试) + verify(projectionStore, times(3)).repairAuthoritative(any(), any(), any(), any(), any()); + ResultReport report = submitted(); + assertThat(report.getState()).isEqualTo(AnswerReconcileWork.STATE_PARTIAL); + assertThat(jsonOf(report)).contains(FormSyncModels.ERR_REPAIR_RETRY_EXHAUSTED); + } + + @Test + @DisplayName("中心没给权威当前快照(UNKNOWN)时只报告,不动本地数据") + void unknownPresenceNeverRepairs() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 6L, hash('a'), false)), + Collections.singletonList(row(ANSWER_A, 3L, hash('b'), 0, BEFORE_WINDOW)), true, 200); + TaskAnswerReply unknown = new TaskAnswerReply(); + unknown.setPresence(FormSyncModels.PRESENCE_UNKNOWN); + unknown.setReasonCode(FormSyncModels.ERR_MANIFEST_INCOMPLETE); + stubAnswer(ANSWER_A, unknown); + + worker.executeQuietly(work.getId()); + + verify(projectionStore, never()).repairAuthoritative(any(), any(), any(), any(), any()); + assertThat(jsonOf(submitted())).contains(FormSyncModels.ERR_MANIFEST_INCOMPLETE); + } + + // ==================== 多余行与缺席证明 ==================== + + @Test + @DisplayName("多余行:清单与本地都完整 + 证明四坐标与期望版本匹配才退役") + void extraRowRetiredOnlyWithMatchingProof() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 6L, hash('a'), false)), + Arrays.asList(row(ANSWER_A, 6L, hash('a'), 0, BEFORE_WINDOW), + row(ANSWER_X, 2L, hash('x'), 0, BEFORE_WINDOW)), true, 200); + stubAnswer(ANSWER_X, absent(ANSWER_X, 2L)); + when(projectionStore.retireAbsent(any(AuthoritativeAbsenceProof.class))).thenReturn(1); + + worker.executeQuietly(work.getId()); + + ArgumentCaptor proof = + ArgumentCaptor.forClass(AuthoritativeAbsenceProof.class); + verify(projectionStore).retireAbsent(proof.capture()); + assertThat(proof.getValue().getSchId()).isEqualTo(SCHOOL); + assertThat(proof.getValue().getFormUuid()).isEqualTo(FORM_UUID); + assertThat(proof.getValue().getAnswerUuid()).isEqualTo(ANSWER_X); + assertThat(proof.getValue().getExpectedRevision()).isEqualTo(2L); + assertThat(proof.getValue().getProofId()).startsWith("abs:" + TASK_ID); + assertThat(submitted().getExtraReported()).isEqualTo(1); + assertThat(submitted().getExtraRetired()).isEqualTo(1); + } + + @Test + @DisplayName("本地更高版本的缺席候选也要带自己的期望版本去问证明") + void extraCandidateCarriesItsOwnRevision() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 6L, hash('a'), false)), + Arrays.asList(row(ANSWER_A, 6L, hash('a'), 0, BEFORE_WINDOW), + row(ANSWER_X, 11L, hash('x'), 0, BEFORE_WINDOW)), true, 200); + stubAnswer(ANSWER_X, absent(ANSWER_X, 11L)); + when(projectionStore.retireAbsent(any(AuthoritativeAbsenceProof.class))).thenReturn(1); + + worker.executeQuietly(work.getId()); + + verify(formSyncClient).fetchTaskAnswer(eq(TASK_ID), eq(ANSWER_X), eq(11L)); + ArgumentCaptor proof = + ArgumentCaptor.forClass(AuthoritativeAbsenceProof.class); + verify(projectionStore).retireAbsent(proof.capture()); + assertThat(proof.getValue().getExpectedRevision()).isEqualTo(11L); + } + + @Test + @DisplayName("缺席证明坐标不符:不退役") + void mismatchedProofIsRefused() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.emptyList(), + Collections.singletonList(row(ANSWER_X, 2L, hash('x'), 0, BEFORE_WINDOW)), true, 200); + TaskAnswerReply reply = absent(ANSWER_X, 2L); + reply.getAbsenceProof().setFormUuid("10000000000000000000000000000001"); + stubAnswer(ANSWER_X, reply); + + worker.executeQuietly(work.getId()); + + verify(projectionStore, never()).retireAbsent(any()); + assertThat(jsonOf(submitted())).contains(FormSyncModels.ERR_PROOF_REJECTED); + } + + @Test + @DisplayName("中心只给报告口径(无证明)时不退役") + void reportOnlyReplyBlocksRetirement() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.emptyList(), + Collections.singletonList(row(ANSWER_X, 2L, hash('x'), 0, BEFORE_WINDOW)), true, 200); + TaskAnswerReply reply = new TaskAnswerReply(); + reply.setPresence(FormSyncModels.PRESENCE_UNKNOWN); + reply.setReasonCode(FormSyncModels.ERR_EXPECTED_REVISION_REQUIRED); + stubAnswer(ANSWER_X, reply); + + worker.executeQuietly(work.getId()); + + verify(projectionStore, never()).retireAbsent(any()); + assertThat(submitted().getExtraRetired()).isEqualTo(0); + } + + @Test + @DisplayName("期间新增与 createTime 为空的本地行永不退役") + void rowsAddedAfterWindowOrUnknownTimeAreNeverRetired() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 6L, hash('a'), false)), + Arrays.asList(row(ANSWER_A, 6L, hash('a'), 0, BEFORE_WINDOW), + row(ANSWER_B, 1L, hash('b'), 0, WINDOW_START.plusMinutes(1)), + row(ANSWER_X, 2L, hash('x'), 0, null)), true, 200); + + worker.executeQuietly(work.getId()); + + verify(projectionStore, never()).retireAbsent(any()); + verify(formSyncClient, never()).fetchTaskAnswer(anyString(), anyString(), anyLong()); + assertThat(jsonOf(submitted())).contains(FormSyncModels.DIFF_ADDED_AFTER_WINDOW); + assertThat(submitted().getExtraReported()).isEqualTo(2); + } + + @Test + @DisplayName("多余行候选超过上限:整轮不退役任何行,内存与缺席答复次数都有界") + void extraCandidatesAreCappedAndNothingRetires() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.emptyList(), + Arrays.asList(row(ANSWER_A, 1L, hash('a'), 0, BEFORE_WINDOW), + row(ANSWER_B, 1L, hash('b'), 0, BEFORE_WINDOW), + row(ANSWER_C, 1L, hash('c'), 0, BEFORE_WINDOW)), true, 200); + + worker.executeQuietly(work.getId()); + + verify(projectionStore, never()).retireAbsent(any()); + verify(formSyncClient, never()).fetchTaskAnswer(anyString(), anyString(), anyLong()); + assertThat(jsonOf(submitted())).contains(FormSyncModels.ERR_SCAN_ABORTED); + assertThat(submitted().getState()).isEqualTo(AnswerReconcileWork.STATE_PARTIAL); + } + + // ==================== 游标、续跑与中断 ==================== + + @Test + @DisplayName("UUID 游标跨页锁步:两侧每轮从同一水位起读,水位取两侧页末键较小者") + void cursorsAdvanceInLockStepAcrossPages() { + AnswerReconcileWork work = givenQueuedWork(); + work.setPageSize(1); + // 中心:A,B,C 三行;本地:A,B 两行 → 第一轮页末键 A/B → 水位只能到 A + stubStreams(Arrays.asList(fcRow(ANSWER_A, 1L, hash('a'), false), fcRow(ANSWER_B, 1L, hash('b'), false), + fcRow(ANSWER_C, 1L, hash('c'), false)), + Arrays.asList(row(ANSWER_A, 1L, hash('a'), 0, BEFORE_WINDOW), + row(ANSWER_B, 1L, hash('b'), 0, BEFORE_WINDOW)), true, 1); + + worker.executeQuietly(work.getId()); + + assertThat(work.getWatermark()).isEqualTo(ANSWER_C); + assertThat(work.getFcCursor()).isEqualTo(work.getWatermark()); + assertThat(work.getLocalCursor()).isEqualTo(work.getWatermark()); + verify(formSyncClient).fetchManifestPage(eq(TASK_ID), eq(ANSWER_A), anyInt()); + verify(projectionStore).manifestPage(eq(SCHOOL), eq(FORM_UUID), eq(ANSWER_A), anyInt()); + // C 只在中心存在 → 缺失修复一次 + assertThat(submitted().getScannedRows()).isEqualTo(3); + } + + @Test + @DisplayName("空页且未到底=停滞:中断本轮,不把空页当集合边界,也不回报假结果") + void emptyPagesWithoutEndMarkerStallScan() { + AnswerReconcileWork work = givenQueuedWork(); + when(formSyncClient.fetchManifestPage(anyString(), any(), anyInt())) + .thenReturn(page(true, Collections.emptyList())); + when(projectionStore.manifestPage(anyLong(), anyString(), any(), anyInt())) + .thenReturn(new ManifestPage(Collections.emptyList(), null, false)); + + worker.executeQuietly(work.getId()); + + verify(formSyncClient, never()).submitResult(anyString(), any(ResultReport.class)); + verify(projectionStore, never()).retireAbsent(any()); + ArgumentCaptor saved = ArgumentCaptor.forClass(AnswerReconcileWork.class); + verify(workRepository, atLeastOnce()).save(saved.capture()); + assertThat(saved.getValue().getState()).isEqualTo(AnswerReconcileWork.STATE_QUEUED); + assertThat(saved.getValue().getLastErrorCode()).isEqualTo(FormSyncModels.ERR_SCAN_STALLED); + } + + @Test + @DisplayName("跨页失败:已比较区间落检查点,未见完的行不当作应删除") + void interruptedScanKeepsCheckpointAndRetiresNothing() { + AnswerReconcileWork work = givenQueuedWork(); + work.setPageSize(1); + when(formSyncClient.fetchManifestPage(anyString(), isNull(), anyInt())) + .thenReturn(page(false, Collections.singletonList(fcRow(ANSWER_A, 6L, hash('a'), false)))); + when(formSyncClient.fetchManifestPage(anyString(), eq(ANSWER_A), anyInt())) + .thenThrow(new BizException(ErrorCode.PARAM_ERROR, "表单中心对账清单不可达")); + when(projectionStore.manifestPage(anyLong(), anyString(), isNull(), anyInt())) + .thenReturn(new ManifestPage(Arrays.asList( + row(ANSWER_A, 6L, hash('a'), 0, BEFORE_WINDOW), + row(ANSWER_X, 2L, hash('x'), 0, BEFORE_WINDOW)), ANSWER_X, false)); + + worker.executeQuietly(work.getId()); + + assertThat(work.getWatermark()).isEqualTo(ANSWER_A); + assertThat(work.getLocalCursor()).isEqualTo(ANSWER_A); + verify(projectionStore, never()).retireAbsent(any()); + verify(formSyncClient, never()).submitResult(anyString(), any(ResultReport.class)); + // 本地已经看见 X,但清单还没读完:它既没被退役,也没被拿去问缺席证明 + verify(formSyncClient, never()).fetchTaskAnswer(eq(TASK_ID), eq(ANSWER_X), anyLong()); + } + + @Test + @DisplayName("断线重启从水位续读:不从头再扫,resumed 计数留痕") + void restartResumesFromWatermark() { + AnswerReconcileWork work = work(AnswerReconcileWork.STATE_RUNNING, 1); + work.setWatermark(ANSWER_A); + work.setLeaseUntil(LocalDateTime.now().minusMinutes(1)); + when(workRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(work)); + when(workRepository.findByIdForUpdate(work.getId())).thenReturn(Optional.of(work)); + stubStreams(Collections.singletonList(fcRow(ANSWER_B, 4L, hash('b'), false)), + Collections.singletonList(row(ANSWER_B, 4L, hash('b'), 0, BEFORE_WINDOW)), true, 200); + + assertThat(worker.executeQuietly(work.getId())).isEqualTo(1); + + assertThat(work.getResumed()).isEqualTo(1); + verify(formSyncClient, never()).fetchManifestPage(eq(TASK_ID), isNull(), anyInt()); + verify(formSyncClient).fetchManifestPage(eq(TASK_ID), eq(ANSWER_A), anyInt()); + assertThat(submitted().getState()).isEqualTo(AnswerReconcileWork.STATE_COMPLETED); + } + + @Test + @DisplayName("中心已取消任务:本地停止剩余修复并落 CANCELLED") + void cancellationStopsRemainingRepairs() { + AnswerReconcileWork work = givenQueuedWork(); + ManifestPageView cancelled = page(true, Collections.singletonList(fcRow(ANSWER_A, 6L, hash('a'), false))); + cancelled.setTaskState(AnswerReconcileWork.STATE_CANCELLED); + when(formSyncClient.fetchManifestPage(anyString(), any(), anyInt())).thenReturn(cancelled); + when(projectionStore.manifestPage(anyLong(), anyString(), any(), anyInt())) + .thenReturn(new ManifestPage(Collections.emptyList(), null, true)); + + worker.executeQuietly(work.getId()); + + verify(projectionStore, never()).repairAuthoritative(any(), any(), any(), any(), any()); + assertThat(work.getState()).isEqualTo(AnswerReconcileWork.STATE_CANCELLED); + assertThat(submitted().getState()).isEqualTo(AnswerReconcileWork.STATE_CANCELLED); + } + + @Test + @DisplayName("中心不可达时不回报假结果:任务退回 QUEUED 并从水位再来") + void unreachableCenterLeavesWorkQueued() { + AnswerReconcileWork work = givenQueuedWork(); + when(formSyncClient.fetchManifestPage(anyString(), any(), anyInt())) + .thenThrow(new BizException(ErrorCode.PARAM_ERROR, "表单中心对账清单不可达")); + + worker.executeQuietly(work.getId()); + + verify(formSyncClient, never()).submitResult(anyString(), any(ResultReport.class)); + assertThat(work.getState()).isEqualTo(AnswerReconcileWork.STATE_QUEUED); + assertThat(work.getNextRetryAt()).isNotNull(); + } + + @Test + @DisplayName("结果送不到中心时不写终态:留 QUEUED 等重报,同 attempt 由中心幂等吸收") + void failedResultSubmissionKeepsWorkRetryable() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 6L, hash('a'), false)), + Collections.singletonList(row(ANSWER_A, 6L, hash('a'), 0, BEFORE_WINDOW)), true, 200); + when(formSyncClient.submitResult(anyString(), any(ResultReport.class))) + .thenThrow(new BizException(ErrorCode.PARAM_ERROR, "表单中心结果回报不可达")); + + worker.executeQuietly(work.getId()); + + assertThat(work.getState()).isEqualTo(AnswerReconcileWork.STATE_QUEUED); + assertThat(work.getReportedState()).isNull(); + assertThat(work.getLeaseUntil()).isNull(); + } + + @Test + @DisplayName("中心判本次回报是旧 attempt:本地退回 QUEUED 等重派,不覆盖已提交结论") + void staleAckFromCenterKeepsWorkRetryable() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 6L, hash('a'), false)), + Collections.singletonList(row(ANSWER_A, 6L, hash('a'), 0, BEFORE_WINDOW)), true, 200); + ResultAck stale = acceptedAck(); + stale.setStaleAttempt(Boolean.TRUE); + when(formSyncClient.submitResult(anyString(), any(ResultReport.class))).thenReturn(stale); + + worker.executeQuietly(work.getId()); + + assertThat(work.getState()).isEqualTo(AnswerReconcileWork.STATE_QUEUED); + assertThat(work.getReportedAttempt()).isEqualTo(work.getAttempt()); + } + + @Test + @DisplayName("进度回报按轮刷新中心租约,RUNNING 不落本地终态") + void progressReportsRefreshLeaseWithoutTerminalState() { + AnswerReconcileWork work = givenQueuedWork(); + work.setPageSize(1); + ReflectionTestUtils.setField(worker, "progressReportRounds", 1); + stubStreams(Arrays.asList(fcRow(ANSWER_A, 1L, hash('a'), false), fcRow(ANSWER_B, 1L, hash('b'), false)), + Arrays.asList(row(ANSWER_A, 1L, hash('a'), 0, BEFORE_WINDOW), + row(ANSWER_B, 1L, hash('b'), 0, BEFORE_WINDOW)), true, 1); + + worker.executeQuietly(work.getId()); + + ArgumentCaptor reports = ArgumentCaptor.forClass(ResultReport.class); + verify(formSyncClient, atLeast(2)).submitResult(eq(TASK_ID), reports.capture()); + assertThat(reports.getAllValues().get(0).getState()).isEqualTo(AnswerReconcileWork.STATE_RUNNING); + assertThat(reports.getAllValues().get(reports.getAllValues().size() - 1).getState()) + .isEqualTo(AnswerReconcileWork.STATE_COMPLETED); + } + + @Test + @DisplayName("对账全程不触碰普通 MQ 消费入口,也不碰 inbox/intent/apply/flow") + void repairPathNeverEntersTheMqConsumerEntry() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Arrays.asList(fcRow(ANSWER_A, 6L, hash('a'), false), fcRow(ANSWER_B, 6L, hash('b'), true)), + Arrays.asList(row(ANSWER_A, 3L, hash('z'), 0, BEFORE_WINDOW), + row(ANSWER_B, 6L, hash('b'), 0, BEFORE_WINDOW)), true, 200); + stubAnswer(ANSWER_A, present(ANSWER_A, hash('a'), 6L, false)); + stubAnswer(ANSWER_B, present(ANSWER_B, hash('b'), 6L, true)); + when(projectionStore.repairAuthoritative(any(), any(), any(), any(), any())) + .thenReturn(RepairResult.APPLIED); + + worker.executeQuietly(work.getId()); + + verify(projectionStore, never()).applySnapshot(any()); + verify(projectionStore, times(2)).repairAuthoritative(any(), any(), any(), any(), any()); + assertThat(submitted().getStaleRepaired()).isEqualTo(1); + assertThat(submitted().getTombstonesRepaired()).isEqualTo(1); + } + + @Test + @DisplayName("结果回报里的错误明细带全四个坐标,且永不携带 values") + void resultErrorsCarryCoordinatesAndNoValues() { + AnswerReconcileWork work = givenQueuedWork(); + stubStreams(Collections.singletonList(fcRow(ANSWER_A, 0L, hash('a'), false)), + Collections.emptyList(), true, 200); + + worker.executeQuietly(work.getId()); + + List errors = submitted().getErrors(); + assertThat(errors).isNotEmpty(); + ReconcileError error = errors.get(0); + assertThat(error.getTaskId()).isEqualTo(TASK_ID); + assertThat(error.getConsumerAppKey()).isEqualTo(SELF); + assertThat(error.getConsumerSchoolId()).isEqualTo(SCHOOL); + assertThat(error.getFormUuid()).isEqualTo(FORM_UUID); + assertThat(error.getAnswerUuid()).isEqualTo(ANSWER_A); + assertThat(error.getDetail()).doesNotContain("values 文档"); + } + + // ==================== 夹具 ==================== + + private AnswerReconcileWork givenQueuedWork() { + AnswerReconcileWork work = work(AnswerReconcileWork.STATE_QUEUED, 1); + when(workRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(work)); + when(workRepository.findByIdForUpdate(work.getId())).thenReturn(Optional.of(work)); + when(workRepository.findDue(any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(Collections.singletonList(work)); + return work; + } + + /** + * 两侧读取的真流模拟:按传入游标过滤升序全集,取前 pageSize 条,剩余是否还有决定 completed。 + * + * @param fcCompletes 中心清单是否到底(false=清单还没完整结束,多余行一律不许退役) + */ + private void stubStreams(final List fcAll, final List localAll, + final boolean fcCompletes, final int pageSize) { + when(formSyncClient.fetchManifestPage(anyString(), any(), anyInt())).thenAnswer(call -> { + String cursor = call.getArgument(1); + List remaining = new ArrayList<>(); + for (ManifestRowView row : fcAll) { + if (cursor == null || row.getAnswerUuid().compareTo(cursor) > 0) { + remaining.add(row); + } + } + List window = remaining.subList(0, Math.min(pageSize, remaining.size())); + return page(fcCompletes && remaining.size() <= pageSize, new ArrayList<>(window)); + }); + when(projectionStore.manifestPage(anyLong(), anyString(), any(), anyInt())).thenAnswer(call -> { + String cursor = call.getArgument(2); + List remaining = new ArrayList<>(); + for (ManifestRow row : localAll) { + if (cursor == null || row.getAnswerUuid().compareTo(cursor) > 0) { + remaining.add(row); + } + } + List window = new ArrayList<>(remaining.subList(0, Math.min(pageSize, remaining.size()))); + boolean completed = remaining.size() <= pageSize; + return new ManifestPage(window, completed ? null + : window.get(window.size() - 1).getAnswerUuid(), completed); + }); + } + + private void stubAnswer(String answerUuid, TaskAnswerReply reply) { + when(formSyncClient.fetchTaskAnswer(eq(TASK_ID), eq(answerUuid), any())).thenReturn(reply); + } + + private static ManifestPageView page(boolean completed, List rows) { + ManifestPageView view = new ManifestPageView(); + view.setTaskId(TASK_ID); + view.setRows(new ArrayList<>(rows)); + view.setCompleted(completed); + view.setTotalRows(rows.size()); + view.setTotalPages(1); + view.setTaskState(AnswerReconcileWork.STATE_RUNNING); + view.setNextCursor(completed || rows.isEmpty() ? null + : rows.get(rows.size() - 1).getAnswerUuid()); + return view; + } + + private static ManifestRowView fcRow(String answerUuid, Long revision, String hash, boolean deleted) { + ManifestRowView row = new ManifestRowView(); + row.setAnswerUuid(answerUuid); + row.setRevision(revision); + row.setContentHash(hash); + row.setIsDeleted(deleted); + row.setFormUuid(FORM_UUID); + row.setFormVersion(7); + return row; + } + + private static Row row(String answerUuid, Long revision, String hash, Integer isDeleted, + LocalDateTime createTime) { + return new Row(answerUuid, revision, hash, isDeleted, SCHOOL, createTime); + } + + private static FcAnswerProjection entity(String answerUuid, Long revision, String hash, Integer isDeleted) { + FcAnswerProjection entity = new FcAnswerProjection(); + entity.setAnswerUuid(answerUuid); + entity.setSchId(SCHOOL); + entity.setFormUuid(FORM_UUID); + entity.setFormVersion(7); + entity.setRevision(revision); + entity.setContentHash(hash); + entity.setIsDeleted(isDeleted); + return entity; + } + + private static TaskAnswerReply present(String answerUuid, String hash, Long revision, boolean deleted) { + TaskAnswerReply reply = new TaskAnswerReply(); + reply.setTaskId(TASK_ID); + reply.setAnswerUuid(answerUuid); + reply.setFormUuid(FORM_UUID); + reply.setFormVersion(7); + reply.setPresence(FormSyncModels.PRESENCE_PRESENT); + SnapshotEnvelope envelope = new SnapshotEnvelope(); + envelope.setProtocolVersion("fc.answer.snapshot.v2"); + envelope.setEventId(TASK_ID); + envelope.setEventType(deleted ? "tombstoned" : "updated"); + envelope.setSourceAppKey("100021"); + envelope.setConsumerAppKey(SELF); + envelope.setConsumerSchoolId(SCHOOL); + envelope.setGrantVersion(1); + envelope.setFormUuid(FORM_UUID); + envelope.setFormVersion(7); + envelope.setAnswerUuid(answerUuid); + envelope.setAnswerRevision(revision); + envelope.setIsDeleted(deleted); + envelope.setContentHash(hash); + reply.setEnvelope(envelope); + return reply; + } + + private static TaskAnswerReply absent(String answerUuid, Long expectedRevision) { + TaskAnswerReply reply = new TaskAnswerReply(); + reply.setTaskId(TASK_ID); + reply.setAnswerUuid(answerUuid); + reply.setFormUuid(FORM_UUID); + reply.setConsumerSchoolId(SCHOOL); + reply.setPresence(FormSyncModels.PRESENCE_ABSENT); + AbsenceProof proof = new AbsenceProof(); + proof.setProofId("abs:" + TASK_ID + ":" + answerUuid + ":" + expectedRevision); + proof.setTaskId(TASK_ID); + proof.setFormUuid(FORM_UUID); + proof.setSchId(SCHOOL); + proof.setAnswerUuid(answerUuid); + proof.setExpectedRevision(expectedRevision); + reply.setAbsenceProof(proof); + return reply; + } + + private static ReconcileInstruction instruction(int attempt) { + ReconcileInstruction instruction = new ReconcileInstruction(); + instruction.setProtocol(FormSyncModels.PROTOCOL); + instruction.setTaskId(TASK_ID); + instruction.setAttempt(attempt); + instruction.setConsumerAppKey(SELF); + instruction.setConsumerSchoolId(SCHOOL); + instruction.setFormUuid(FORM_UUID); + instruction.setFormVersion(7); + instruction.setTriggerType("SCHEDULE"); + instruction.setWindowStart("2026-10-21T02:00:00"); + instruction.setMaterializedAt("2026-10-21T02:01:00"); + instruction.setTotalRows(1); + instruction.setTotalPages(1); + instruction.setPageSize(200); + instruction.setIssuedAt("2026-10-21T02:01:01"); + return instruction; + } + + private static AnswerReconcileWork work(String state, int attempt) { + AnswerReconcileWork work = new AnswerReconcileWork(); + work.setId(1); + work.setTaskUuid(TASK_ID); + work.setAttempt(attempt); + work.setConsumerAppKey(SELF); + work.setConsumerSchoolId(SCHOOL); + work.setFormUuid(FORM_UUID); + work.setFormVersion(7); + work.setTriggerType("SCHEDULE"); + work.setWindowStart(WINDOW_START); + work.setTotalRows(1); + work.setTotalPages(1); + work.setPageSize(200); + work.setState(state); + work.setReceivedAt(LocalDateTime.now()); + work.setResumed(0); + return work; + } + + private static ResultAck acceptedAck() { + ResultAck ack = new ResultAck(); + ack.setTaskId(TASK_ID); + ack.setAccepted(Boolean.TRUE); + ack.setStaleAttempt(Boolean.FALSE); + ack.setTaskState(AnswerReconcileWork.STATE_COMPLETED); + return ack; + } + + private ResultReport submitted() { + ArgumentCaptor captor = ArgumentCaptor.forClass(ResultReport.class); + verify(formSyncClient, atLeastOnce()).submitResult(eq(TASK_ID), captor.capture()); + return captor.getValue(); + } + + private static String jsonOf(ResultReport report) { + StringBuilder builder = new StringBuilder(); + for (ReconcileError error : report.getErrors()) { + builder.append(error.getCode()).append('|').append(error.getDetail()).append('\n'); + } + return builder.toString(); + } + + /** 事务模板真跑回调:不真跑的话领取与检查点回写全是空转 */ + private static TransactionTemplate runningTransactionTemplate() { + TransactionTemplate tx = mock(TransactionTemplate.class); + when(tx.execute(any())).thenAnswer(call -> + ((TransactionCallback) call.getArgument(0)).doInTransaction(null)); + doAnswer(call -> { + Consumer action = call.getArgument(0); + action.accept(null); + return null; + }).when(tx).executeWithoutResult(any()); + return tx; + } + + /** 64 位摘要夹具:同一个字符就是一条稳定的"内容指纹",不同字符必然不同 */ + private static String hash(char unit) { + StringBuilder builder = new StringBuilder(64); + for (int i = 0; i < 64; i++) { + builder.append(unit); + } + return builder.toString(); + } + + /** 本地清单行夹具(TASK_2 的投影接口,按值实现一次即可) */ + private static final class Row implements ManifestRow { + private final String answerUuid; + private final Long revision; + private final String contentHash; + private final Integer isDeleted; + private final Long schId; + private final LocalDateTime createTime; + + Row(String answerUuid, Long revision, String contentHash, Integer isDeleted, Long schId, + LocalDateTime createTime) { + this.answerUuid = answerUuid; + this.revision = revision; + this.contentHash = contentHash; + this.isDeleted = isDeleted; + this.schId = schId; + this.createTime = createTime; + } + + @Override + public String getAnswerUuid() { + return answerUuid; + } + + @Override + public Long getRevision() { + return revision; + } + + @Override + public String getContentHash() { + return contentHash; + } + + @Override + public Integer getIsDeleted() { + return isDeleted; + } + + @Override + public Long getSchId() { + return schId; + } + + @Override + public LocalDateTime getCreateTime() { + return createTime; + } + } +}