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)。
+ *
+ * 三条不覆盖纪律:
+ *
+ * - 高 revision 才应用,低 revision 忽略;同 revision 同 hash 幂等;
+ * 同 revision 异 hash → {@code conflictFlag=1} 留证不覆盖(B.5);
+ * - 墓碑只在本地保留 revision,不许被旧 created 复活——{@code isDeleted=1} 的行
+ * 收到更低的 revision 一律忽略;
+ * - {@code bizExtJson} 是业务端扩展列,中心快照永不写它(v2 §4 排除项的本地一侧)。
+ *
+ *
+ * 与旧 {@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 uk_intent_client}(校/人/事项/客户端幂等键)——前端重复 POST 不产生第二个业务意图;
+ * - {@code uk_intent_fc}(消费 appKey + FC 意图键)——本行与中心 {@code submission_context} 一对一;
+ * - {@code uk_intent_apply}(答卷 uuid)与 {@code apply_id} 唯一——一份答卷只形成一个办件,
+ * 这就是任务书要的"独立唯一关联":事件先到时先落 {@link FcAnswerProjection},
+ * 确认先到时先落本行,两条路径最终都撞进同一组键,靠唯一键而不是靠时序收敛。
+ *
+ *
+ * {@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 的字段闭集是两件事,混在一起会让"浏览器能注入什么"
+ * 这个问题重新变成需要逐字段审查的问题。这里刻意分三段:
+ *
+ * - 浏览器 → 本应用:只允许事项坐标 + 客户端幂等键({@link IntentCreateRequest}),
+ * 没有 submitterId/schId/flowGuid/answerUuid/bizId 位;
+ * - 本应用 → 表单中心:签名服务端调用的请求/响应({@link ContextIssueRequest} 等),
+ * 字段与 FC 的 {@code AnswerDeliveryModels} 逐字对齐;
+ * - 表单中心 → 本应用:MQ 信封与回执({@link SnapshotEnvelope}/{@link AppliedReport}),
+ * 反序列化按闭集,多一个字段不影响、少一个必填字段即拒。
+ *
+ */
+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} 投影就停下。
+ *
+ * 三条"不许悄悄改":
+ *
+ * - 低 revision 一律忽略;同 revision 同 hash 幂等;同 revision 异 hash 记冲突不覆盖
+ * (覆盖就等于用一条消息否定另一条,而这正是需要人来判的情形);
+ * - 墓碑只是更高 revision 的一种取值,本地保留 revision;旧 {@code created} 复活不了它;
+ * - {@code bizExtJson}(业务端扩展)不在快照成员里,任何路径都不写它。
+ *
+ *
+ * 授权缓存与撤权竞态(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 纪律:
+ *
+ * - DB 提交成功才 manual ack:{@link AnswerProjectionStore#applySnapshot} 是一个事务,
+ * 正常返回即已提交,此后才 ack;抛异常一律 nack 且不 requeue(容器有界重试 3 次后
+ * 进可查死信),绝不"先 ack 再慢慢处理";
+ * - 毒消息不热循环:协议/闭集校验不过=REJECTED,记台账后 ack——
+ * 重投一百次也不会变成合法,排查靠 {@code fc_answer_inbox} 与死信队列而不是日志翻页;
+ * - 结果回执与 ack 是两件事:ack 只说明本应用消费掉了,中心要的是"业务落库了没有",
+ * 所以 ack 之后再签名上报 {@code consumerApplied};上报失败不回滚已落库的投影,
+ * 台账留 PENDING 由重报扫描追平。
+ *
+ */
+@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)。
+ *
+ * 两个动作,一条不变式:办件的建立只能由服务端事实驱动。
+ *
+ * - {@link #createIntent}:会话取人与校 → 事项入口逐条判(已发布、{@code applyUrl} 为空、
+ * {@code onlineFlag==1}、TASK_1 的成对 FC 绑定与确切版本、{@code flowGuid} 就绪)→
+ * 签名换上下文。任何一条不过就既不建办件也不调流程;浏览器 body 里只有事项坐标
+ * 与自己的幂等键,注入不了 submitter/schId/flowGuid(这些值本方法只从会话与事项行取)。
+ * - {@link #confirmIntent}:浏览器那句"提交成功了"只被当作"可以去读收据"的信号——
+ * 答卷坐标、提交者、版本一律取中心签名收据;收据没确认到答卷就不建件,
+ * postMessage 里就算被人塞了一个 answerId 也没有任何用处(本方法不读它)。
+ *
+ *
+ * {@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}:协作者喂成桩,规则一改立刻红,不打真库不起上下文。
+ * 三组最要紧的取证:
+ *
+ * - 顺序无关的收敛(A.5):事件先到 / 确认先到 / 重复投递三种次序都只形成一个办件——
+ * 夹具在内存里模拟唯一键与"已建件就回原 id"的语义(真库由
+ * {@code uk_intent_answer}/{@code uk_intent_apply}/{@code uk_projection_answer} 保证);
+ * - 结果未知不是失败(C.2):apply 响应丢失后先 GET,查到 30 只回填镜像、绝不重发;
+ * 查无才带同一 {@code requestToken} 补发;
+ * - 跨端夹具逐字一致(B.1):{@link #canonicalVectorMatchesFormCenter()} 的字面量与
+ * form-center {@code FcAnswerDeliveryTest.CANONICAL_VECTOR} 必须完全相同,
+ * 任一侧改规范化都会红在两个仓。
+ *
+ *
+ * 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;
+ }
+ }
+}