t1~t3+精修: 事项FC绑定/可信建件(意图+投影+恢复)/答卷对账Worker
build / build-push (push) Successful in 1m21s

含10-22精修: 对账有限重试(retry_count/指数退避/work_version防旧覆盖)+接缝身份(source-app-key空拒收)+应急对账闸让位FlowApplyRecoveryService+同attempt指令不重开终态。SQL未执行。
This commit is contained in:
chenxing
2026-10-22 10:03:07 +08:00
parent 036bdb1f7c
commit 391281582a
45 changed files with 8222 additions and 6 deletions
+13
View File
@@ -73,6 +73,19 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- v2 快照消费端(TASK_2 B.5,answer-delivery-v2 §7):与 form-center 同一条"可选依赖"纪律——
引依赖但不引运行期强耦合。整条消费链由 FcAnswerMqConfiguration 按 spring.rabbitmq.host
条件装配,未配 broker 的环境不建监听容器(不拨号、不重试连接),可信建件仍走签名收据那条路。
spring-retry 是 SimpleRabbitListenerContainerFactory.setRetryTemplate 的硬要求
(spring-rabbit 里它是 optional,不传递),版本由 Boot BOM 管。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<!-- CAS 客户端(UserHome/Cms 同款显式声明) -->
<dependency>
<groupId>org.jasig.cas.client</groupId>
+7
View File
@@ -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';
+7
View File
@@ -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;
+14
View File
@@ -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;
+82
View File
@@ -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;
-- ③<b>不建物理外键</b>——task_uuid 是中心任务的逻辑引用,本行要能在中心归档后继续留痕;
-- ④两条游标与水位同为 answer_uuid(32 位小写十六进制),锁步推进才是"多余行"可判的前提。
-- 边界纪律:本表只存<b>对账坐标与检查点</b>,不存答卷值文档——值文档归 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 '中心任务身份(<b>唯一键</b>:重复指令复用本行与检查点,不另起一轮扫描)',
`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 晚于它或为空的行<b>只报告、不退役</b>',
`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;<b>不含值文档</b>,有上限)',
`error_count` INT NOT NULL DEFAULT 0 COMMENT '错误条数',
`errors_json` MEDIUMTEXT NOT NULL COMMENT '错误明细(每条含 consumer/task/range/answer 四坐标,<b>敏感 values 永不进这里</b>)',
`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 行(值文档与令牌不进对账检查点)
+186
View File
@@ -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;
-- ③<b>不建物理外键</b>:intent→service_apply、projection→intent 都是跨表逻辑引用,
-- 回调/事件与用户请求并发写入时,FK 会把"先到的一方"变成死锁或顺序要求;
-- ④唯一键承担全部收敛职责(应用层不允许靠时序判断"我该不该再建一次")。
-- 收敛口径:<b>事件先到还是确认先到,结果都撞进同一组唯一键</b>——
-- 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,<b>不回填、不建双轨</b>(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(不回填)
@@ -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)。
*
* <p><b>整体条件装配:只有显式配了 {@code spring.rabbitmq.host} 才存在</b>。未配 broker 时
* 不建监听容器(否则后台反复重连把启动与日志拖脏),{@code FcAnswerConsumer} 上的
* {@code @RabbitListener} 因没有处理器而不生效——可信建件仍走"签名收据确认"那条路,
* 只是延迟从秒级变成"用户点确认那一刻"。</p>
*
* <p><b>队列隔离是这条链的硬前提</b>:完整值文档只进<b>按消费 appKey + 租户域绑定的私有队列</b>。
* routing key 形如 {@code fc.snapshot.v2.<consumerAppKey>.<schoolId>},本应用只绑自己 appKey
* 那一段({@code .*} 只覆盖自己的多租户域),<b>新消费者不得能绑其他目标的 routing key</b>
* (v2 §5 撤权边界的 broker 侧一半)。部署必须落实的实值来源见交付说明"broker 隔离配置"一节:
* 独立 vhost、账号仅对本 vhost 有 configure/write/read、禁止通配订阅他人前缀;
* 这三条没有落地就不能打开 {@code RABBITMQ_HOST}——宁可不投,也不在共享 vhost 上发明文。</p>
*/
@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();
}
}
@@ -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)。
*
* <p>与既有 {@code POST /api/apply}(直接提交表单数据)的关系是<b>并列而不是替换</b>:
* 无表单/本地表单/外链形态照旧走那条;绑定了表单中心确切版本的事项走这里,
* 因为它的表单数据不再由本应用的浏览器请求携带——数据在中心,本地只拿得到签名收据。</p>
*
* <p>两个动作都只认 CAS 会话:用户与学校从会话取,事项坐标从 body(受权限判定约束),
* 而 {@code submitterId/schId/flowGuid/answerUuid/bizId} 在本请求体里<b>没有字段位</b>,
* 注入不了(A.1 的"浏览器 body 只可带事项坐标和客户端幂等键")。</p>
*/
@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);
}
}
@@ -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;
/**
* 表单中心目录的<b>同源代理</b>(v3/TASK_1):事项编辑页的表单选择器打这里,不打表单中心。
*
* <p>两件事必须在服务端做(所以不能把代理 URL 发给浏览器):
* ① 应用签名(appKey/secret 只在本应用与表单中心之间流转);
* ② <b>租户域从 CAS 会话派生</b>——{@code schId} 取自 {@link SiteGuard} 解析出的站点,
* 前端传不进来、也改不动。选中的表单坐标(uuid+version)随后由 {@code /api/svc/item/save}
* 那条链路再向中心核验一次才落库,代理这里只负责"看得见哪些表"。</p>
*
* <p>分页参数原样透传给中心(中心侧 {@code pageSize} 上限 100,超限由中心拒)。</p>
*/
@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);
}
}
@@ -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 定位做权限收口。
*
* <p><b>v3/TASK_1:表单定义的正主是表单中心</b>。{@code /save} 与 {@code /publish} 两个写入口
* <b>明确退出</b>(保留路由、给可诊断的业务错,指向中心的设计页),避免"大厅里还有一套能建表但
* 没人渲染"的哑功能;读侧(list/versions/get)与 {@code /delete} 留着,因为存量事项还指着
* 历史 {@code service_form} 行做回显与清理。渲染器不往这边复制。</p>
*/
@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")
@@ -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})。
*
* <p>路径在 {@code /oapi/*} 下,由既有 {@code RequireAppSignFilter} 强制应用签名:
* 未签名三件套即 401,本类里没有也不允许有第二套验签实现。身份只认平台验签后写在
* {@code yh.app.auth} 上的 appKey,请求体里申报的 {@code consumerAppKey} 只用来<b>比对是否冒充</b>,
* 从不用来采信。</p>
*
* <p>本端点的成功语义必须窄:<b>2xx = 指令已持久化、等执行</b>。执行是 Worker 的事,
* 差异是否修复、修了几条,只能由 {@code /oapi/form-sync/tasks/{taskId}/result} 的回报说话。
* 同 {@code taskId} 的重复指令复用原任务与检查点,回 {@code duplicate=true};
* 更旧 attempt 的重放不动已提交的结论。</p>
*/
@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;
}
}
@@ -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:<b>指令先落库,执行在其之后</b>)。
*
* <p>{@code task_uuid} 唯一:重复指令复用同一行与同一检查点,不另起一轮扫描。
* 派发方的 2xx 只把这里写成 {@code QUEUED},绝不写成本地已修好。</p>
*
* <p>检查点为什么是"两条游标 + 一个水位":中心清单与本地图像都是按 {@code answerUuid}
* 升序的 keyset 读取,两侧只有<b>同步</b>推进才可能发现"本地有、中心清单里没有"的多余行。
* 一旦两条游标各自跑到不同位置就取 {@code min} 作为水位,重启后一律从水位重读——
* 让领先的那条流"丢掉已读未比较的行"是不可接受的(那等于把差异判没了)。</p>
*
* <p>{@code window_start} 来自中心指令,是本地"期间新增"的判据;
* 本地 {@code create_time} 晚于它、为空或归属不确定的行永远只是<b>报告</b>,不退役。</p>
*/
@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;
}
@@ -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)。
*
* <p><b>投影表存"最新事实",本表存"每条事件怎么处置的"</b>,两者不能合成一张:
* 一条答卷会有多 revision,而消费端必须能回答"eventId=X 那封我到底应用了没有、
* 回执上报了没有、失败几次"——这三问分别对应重复投递、结果确认、毒消息排查,
* 混在投影行上就再也问不出来了(尤其投影被更高 revision 覆盖之后)。</p>
*
* <p>{@code uk_inbox_event}(eventId + 消费方)是 manual-ack 链的落库侧兜底:
* DB 提交成功才 ack,异常 nack 到死信;同一封被 broker 重投时,这里直接命中已有行、
* 按 {@code state} 决定是否还要再写投影,而不是靠"看起来处理过了"。</p>
*
* <p>{@code reportState}/{@code reportAttempts} 只描述<b>往中心回执</b>这件事——
* 回执失败不影响本地已落库的事实(中心那边最多是没收到确认),
* 但必须能被观察到,否则"consumerApplied"会永远停在中心侧的 PENDING。</p>
*/
@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;
}
@@ -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)。
*
* <p><b>它同时是任务书点名的"独立唯一关联表"</b>:一行=一份答卷在本校的唯一投影,
* 关系列({@code intentId}/{@code applyId})+ 坐标列({@code formUuid}/{@code formVersion}/
* {@code answerRevision}/{@code flowGuid})就是"本次意图/答卷只形成一个本地办件"的落点。
* 事件先到时这里先有行({@code intentId} 为空=未关联投影),确认先到时 {@link FcApplyIntent}
* 先有行,两条路径都靠唯一键而不是时序收敛;<b>没有已验证意图就不建件</b>(A.5)。</p>
*
* <p>三条不覆盖纪律:</p>
* <ul>
* <li>高 revision 才应用,低 revision 忽略;同 revision 同 hash 幂等;
* 同 revision 异 hash → {@code conflictFlag=1} 留证不覆盖(B.5);</li>
* <li>墓碑只在本地保留 revision,<b>不许被旧 created 复活</b>——{@code isDeleted=1} 的行
* 收到更低的 revision 一律忽略;</li>
* <li>{@code bizExtJson} 是业务端扩展列,中心快照永不写它(v2 §4 排除项的本地一侧)。</li>
* </ul>
*
* <p>与旧 {@code service_apply.form_data_json} 的关系:投影是<b>数据面镜像</b>,
* 办件是<b>业务对象</b>。建件时把快照值抄进 formDataJson 一次(保持既有详情/审批链读法不变),
* 此后投影更新不再回写办件,也不自动重发流程(C.4)。</p>
*/
@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;
}
@@ -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)。
*
* <p><b>为什么要这张表</b>:托管填报里"用户想办这件事"(意图)与"答卷已提交"(事实)分处两个应用,
* 中间隔着 iframe、postMessage 与 MQ。没有一条本地持久记录,就只能信浏览器带来的
* {@code answerId/bizId}——那正是任务书 A.3 末句禁止的"用浏览器传入 bizId 直接建件"。
* 本行就是那条服务端事实:客户端只交事项坐标 + 幂等键,其余全由会话与远端收据填。</p>
*
* <p>三条键各挡一类事故:</p>
* <ul>
* <li>{@code uk_intent_client}(校/人/事项/客户端幂等键)——前端重复 POST 不产生第二个业务意图;</li>
* <li>{@code uk_intent_fc}(消费 appKey + FC 意图键)——本行与中心 {@code submission_context} 一对一;</li>
* <li>{@code uk_intent_apply}(答卷 uuid)与 {@code apply_id} 唯一——<b>一份答卷只形成一个办件</b>,
* 这就是任务书要的"独立唯一关联":事件先到时先落 {@link FcAnswerProjection},
* 确认先到时先落本行,两条路径最终都撞进同一组键,靠唯一键而不是靠时序收敛。</li>
* </ul>
*
* <p>{@code contextToken} 是中心签发的短时能力串(默认 30 分钟、绑定本人本校本表本版本)。
* 它必须由本应用交给被嵌页才能提交,因此在这里<b>明文存</b>(中心侧只存摘要,两边职责不同);
* 它不是身份凭据,任何跨人/跨校/跨版本使用都在中心被拒。</p>
*/
@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;
}
@@ -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)。
*
* <p><b>它把"代发起"从一段 afterCommit 闭包变成一个可恢复的持久事实</b>。旧链路的形状是
* 落库 → afterCommit 丢线程池 → HTTP → 窄列回填:进程在提交之后、HTTP 之前被杀,
* 那条"要发起"的意图就随内存一起没了,只剩一行 flow_status=0 的待提交办件等人点重试。
* 现在命令先落库(与办件同一短事务),HTTP 在事务外跑,状态机留痕:</p>
*
* <pre>
* PENDING ──发出──▶ SENT ──响应成功──▶ DONE
* │
* ├─超时/断连──▶ UNKNOWN ──查 FL 业务键──▶ DONE / 重发同业务键
* └─业务拒绝──▶ FAILED ──退避──▶ PENDING …… 耗尽──▶ DEAD
* </pre>
*
* <p>{@code UNKNOWN} 是本表存在的核心理由:<b>响应丢失不等于失败</b>。恢复器先按
* {@code (schId,bizType,bizId)} 查 FL,查到 10/20/30/40 都先回填真实状态——
* 尤其 30/40 不许当成网络重试再 apply 一次(那会触发 FL 的 R4 重启推进,把已拒绝的办件又跑起来)。
* 只有明确授权的用户重提才进 {@code attempt+1} 的新分支,带新的 {@code requestToken},
* FL 侧据此区分"同一命令的重投"与"新一轮发起"。</p>
*
* <p>{@code requestToken} 同值即同一次命令:FL 收到同 token 的重投只回既有办件、绝不重启推进。</p>
*/
@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;
}
@@ -101,4 +101,18 @@ public class ServiceApply extends BaseEntity {
/** 终态时间 */
@Column(name = "finish_time")
private LocalDateTime finishTime;
/**
* 可信建件意图 id({@code fc_apply_intent.id};NULL=旧链路/无表单事项的直接提交)。
*
* <p>新数据一侧留痕用(任务书 A.5"新数据可追溯,不建旧数据迁移双轨"):
* 有它就说得出"这件是从哪次服务端意图、按中心哪一份收据建起来的"。
* 关联的唯一性判据在意图侧 {@code uk_intent_apply},本列只是反向可查,不做约束。</p>
*/
@Column(name = "fc_intent_id")
private Integer fcIntentId;
/** 建件依据的答卷 uuid(表单中心侧对外身份;旧链路恒 NULL) */
@Column(name = "fc_answer_uuid", length = 32)
private String fcAnswerUuid;
}
@@ -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=发起无需表单);发起时取最新已发布版本。
*
* <p><b>本列已停止新写入</b>(v3/TASK_1):表单定义归表单中心,事项只能绑 {@code fcFormUuid}
* 那组服务器验证过的坐标。存量行为双轨期的历史数据保留读路径(办件回显与 {@code effectiveFlowGuid}
* 仍认它),保存链路遇到"写一个不一样的 code"直接拒。</p>
*/
@Column(name = "form_code", length = 64)
private String formCode;
/** 绑定表单中心表单 uuid(form-center 对外身份,32 位;NULL=未绑远端表单) */
@Column(name = "fc_form_uuid", length = 32)
private String fcFormUuid;
/**
* 绑定表单中心的<b>确切版本</b>(与 {@link #fcFormUuid} 成对,单传一个即拒)。
* 存的是服务器侧校验过的版本号,不是前端自由输入的字符串。
*/
@Column(name = "fc_form_version")
private Integer fcFormVersion;
/** 绑定流程中心流程(flw_def.flow_guid;NULL=纯指南事项不可在线办理) */
@Column(name = "flow_guid", length = 32)
private String flowGuid;
@@ -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;
}
@@ -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)。
*
* <p><b>为什么是一个独立模型类而不是往 {@code SvcModels} 里加字段</b>(任务书约束原文):
* {@code SvcModels} 是 TASK_1 的写范围,且它是既有管理面契约的载体;这条新链的字段闭集
* (意图键、令牌、信封、回执)与旧提交 DTO 的字段闭集是两件事,混在一起会让"浏览器能注入什么"
* 这个问题重新变成需要逐字段审查的问题。这里刻意分三段:</p>
* <ol>
* <li><b>浏览器 → 本应用</b>:只允许事项坐标 + 客户端幂等键({@link IntentCreateRequest}),
* 没有 submitterId/schId/flowGuid/answerUuid/bizId 位;</li>
* <li><b>本应用 → 表单中心</b>:签名服务端调用的请求/响应({@link ContextIssueRequest} 等),
* 字段与 FC 的 {@code AnswerDeliveryModels} 逐字对齐;</li>
* <li><b>表单中心 → 本应用</b>:MQ 信封与回执({@link SnapshotEnvelope}/{@link AppliedReport}),
* 反序列化按闭集,多一个字段不影响、少一个必填字段即拒。</li>
* </ol>
*/
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<String> 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):命令行里存的就是<b>这一份</b>,重放不依赖办件行后续被谁改过。
* 字段与 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;
}
}
@@ -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)。
*
* <p>独立 DTO:不往 {@code FcApplyModels}(TASK_2)与 {@code SvcModels}(TASK_1)里加字段。
* 快照与信封仍复用 {@link FcApplyModels.SnapshotEnvelope}/{@link FcApplyModels.Snapshot}——
* 修复入口 {@code repairAuthoritative} 收的就是那个类型,换一份定义等于换一套哈希口径。</p>
*
* <p>{@code @JsonIgnoreProperties(ignoreUnknown=true)} 与各镜像同规:中心加字段不能把对端打挂,
* 但<b>判据字段</b>(存在性、缺席证明的四个坐标、清单结束标记)缺失一律按不可信处理,
* 由 Worker 显式判空,不靠 Jackson 兜底。</p>
*/
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<ManifestRowView> 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<ReconcileError> 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})。
*
* <p>{@code createTime} 是本地行的创建时刻——判"期间新增/未知不可退役"的唯一依据,
* 所以必须跟着候选一起持久化,重启续跑时不能凭记忆补。</p>
*/
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ExtraCandidate {
private String answerUuid;
private Long revision;
private String contentHash;
private Integer isDeleted;
private LocalDateTime createTime;
}
}
@@ -166,12 +166,20 @@ public final class SvcModels {
private Integer ruleCategoryId;
@Size(max = 500)
private String coverImage;
/** 绑定自定义表单 code(可空;发起无需表单) */
/**
* 绑定自定义表单 code(<b>已停止新写入</b>,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<String> audience;
}
@Data
public static class SvcFormCatalogPage {
private List<SvcFormCatalogVo> items;
private long total;
}
// ===== 表单 =====
@Data
@@ -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)。
*
* <p>领取用<b>行锁</b>(同 {@code FlowApplyCommandRepository} 先例):进程内多扫描线程、
* 以及重启后新旧实例短暂并存时,只有一个能拿到 {@code QUEUED} 行。
* 没有任何删除方法——本轮扫描与差异留痕要能在事后复盘。</p>
*/
public interface AnswerReconcileWorkRepository extends JpaRepository<AnswerReconcileWork, Integer> {
Optional<AnswerReconcileWork> findByTaskUuid(String taskUuid);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select w from AnswerReconcileWork w where w.id = :id")
Optional<AnswerReconcileWork> 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<AnswerReconcileWork> 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<AnswerReconcileWork> findStaleRunning(@Param("now") LocalDateTime now, Pageable pageable);
}
@@ -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)。
*
* <p>两张台账类表都<b>不提供删除方法</b>:处置结论(含 DEFERRED/CONFLICT/REJECTED)
* 是排障与对账的唯一历史,回收由 DBA 侧的归档策略管,不由应用管。</p>
*/
public interface FcAnswerInboxRepository extends JpaRepository<FcAnswerInbox, Integer> {
Optional<FcAnswerInbox> findByEventIdAndConsumerAppKey(String eventId, String consumerAppKey);
List<FcAnswerInbox> 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<FcAnswerInbox> findDueReports(@Param("now") LocalDateTime now, Pageable pageable);
/** 冲突/暂缓留证分页(运维面) */
List<FcAnswerInbox> findByConsumerSchoolIdAndStateInOrderByIdDesc(
Long consumerSchoolId, List<String> states, Pageable pageable);
}
@@ -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 与缺席证明)。
*
* <p>{@link #findByAnswerUuidAndSchId} 是唯一写入路径的锚点({@code uk_projection_answer});
* {@link #findManifest} 只出 {@code (answer_uuid, revision, content_hash, is_deleted, sch_id, create_time)} 形态的
* 对账清单(TASK_3 §manifest:比对存在性与版本,不拉值文档),
* 因此用轻量接口投影而不是实体,不读取值文档;游标按answerUuid继续。</p>
*/
public interface FcAnswerProjectionRepository extends JpaRepository<FcAnswerProjection, Integer> {
Optional<FcAnswerProjection> findByAnswerUuidAndSchId(String answerUuid, Long schId);
Optional<FcAnswerProjection> findByIntentId(Integer intentId);
List<FcAnswerProjection> findByApplyIdOrderByIdAsc(Integer applyId);
/** 未关联且未建件的投影(事件先到的那批;确认链按答案反查它们) */
List<FcAnswerProjection> 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<ManifestRow> 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();
}
}
@@ -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)。
*
* <p>三条定位路径就是三条契约:客户端幂等键(重复 POST 回到同一意图)、
* FC 意图键(收据确认与事件合并都按它)、答卷 uuid(事件先到时反查意图)。
* <b>不提供删除</b>:意图是审计链的一环,过期只用状态表达。</p>
*/
public interface FcApplyIntentRepository extends JpaRepository<FcApplyIntent, Integer> {
Optional<FcApplyIntent> findBySchIdAndSubmitterIdAndItemIdAndClientKey(
Long schId, Integer submitterId, Integer itemId, String clientKey);
Optional<FcApplyIntent> findByConsumerAppKeyAndFcIntentId(String consumerAppKey, String fcIntentId);
Optional<FcApplyIntent> findByAnswerUuid(String answerUuid);
Optional<FcApplyIntent> findBySchIdAndId(Long schId, Integer id);
/** 待填报意图(过期扫描候选,按 expires_at 升序取批) */
List<FcApplyIntent> findByStateAndExpiresAtBefore(String state, LocalDateTime cutoff, Pageable pageable);
List<FcApplyIntent> findBySchIdAndSubmitterIdOrderByIdDesc(Long schId, Integer submitterId, Pageable pageable);
}
@@ -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)。
*
* <p>{@link #findByIdForUpdate} 与既有 {@code ServiceApplyRepository#findByIdForUpdate} 同构:
* 调度线程池与恢复扫描可能同时盯上同一条命令,行锁把两者串起来,避免"两个线程各自以为
* 自己是唯一发送者"而双发(FL 侧 uk 与 requestToken 是第二、第三道闸)。</p>
*/
public interface FlowApplyCommandRepository extends JpaRepository<FlowApplyCommand, Integer> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from FlowApplyCommand c where c.id = :id")
Optional<FlowApplyCommand> findByIdForUpdate(@Param("id") Integer id);
Optional<FlowApplyCommand> findByApplyIdAndAttempt(Integer applyId, Integer attempt);
List<FlowApplyCommand> findByApplyIdOrderByIdDesc(Integer applyId);
Optional<FlowApplyCommand> 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<FlowApplyCommand> findDue(@Param("now") LocalDateTime now, Pageable pageable);
/** 崩溃恢复:一条办件是否还有未落定的命令 */
boolean existsByApplyIdAndStateIn(Integer applyId, List<String> states);
}
@@ -65,4 +65,27 @@ public interface ServiceApplyRepository extends JpaRepository<ServiceApply, Inte
+ " where a.id = :id and a.flowStatus in (30, 40)")
int markResubmitted(@Param("id") Integer id, @Param("instanceId") Long instanceId,
@Param("submitTime") java.time.LocalDateTime submitTime);
/**
* 恢复链回填(TASK_2 C.2:命令结果未知时先查 FL 业务键,查到 10/20/30/40 都<b>先回填真实状态</b>)。
*
* <p>与 {@link #markApproving} 的分工:那条是"发起成功的正常回填"(终态不回退);
* 这条是"以中心为权威的漂移修正"(同 {@code FlowReconcileJob} 的既有口径),
* 因此允许把本地 0/10 修正成远端的 20/30/40,但<b>版本低于已消费版本时不动</b>——
* 回调可能已经把镜像推得比查询结果更新,倒着改会把已落定的终态打回。</p>
*
* <p>30/40 走这里回填而<b>不</b>再发一次 apply:R4 的重启推进只能由用户明确授权的重提触发,
* 把"网络结果未知"当成重提会静默重启一条已被拒绝/撤回的流程。</p>
*/
@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);
}
@@ -38,6 +38,24 @@ public interface FlowClient {
/** 按业务键查办件(对账兜底;返回 {instanceId,status,version,nodeSummary,traceJson},查不到返回 null) */
Map<String, Object> getInstanceByBiz(Long schId, String bizType, String bizId);
/**
* 代发起(带请求标识与轮次守卫,TASK_2 C.2/C.3,走 {@code /oapi/flow/apply/v2})。
*
* <p>与旧 {@link #apply} 的差别只有一件事:同 {@code requestToken} 的重投在中心被判定为
* <b>同一次命令</b>,命中既有实例时一律回读现状、不重启推进;只有轮次更高(=用户明确授权的重提)
* 才走既有 R4 重启语义。恢复链必须先查后发,就是因为旧签名分不清"重投"与"新一轮"。</p>
*
* <p>默认实现回落旧签名(未接 v2 端点的环境保持既有行为,不假装带了守卫);
* {@code FlowCenterClient} 覆写为真实出站。用 default 而非改抽象签名:既有实现与测试桩
* 不受影响(同 {@code getInstanceByBiz} 的增量方式)。</p>
*/
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);
@@ -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 扩展点)。
*
* <p><b>顺序不敏感是这张表的核心设计</b>:MQ 事件与 SH 确认谁先到都只有一条规则——
* 投影行按 {@code (answer_uuid, sch_id)} 唯一,意图行按自身唯一,两边靠
* {@code intent_id}/{@code answer_uuid} 互相指认,撞唯一键就是"已经合过"。
* 因此不存在"先到的一方先建件、后到的一方再建一次"的窗口,
* <b>也没有已验证意图就不建件</b>(A.5):只有事件先到时,这里落一行 {@code UNLINKED} 投影就停下。</p>
*
* <p>三条"不许悄悄改":</p>
* <ol>
* <li>低 revision 一律忽略;同 revision 同 hash 幂等;<b>同 revision 异 hash 记冲突不覆盖</b>
* (覆盖就等于用一条消息否定另一条,而这正是需要人来判的情形);</li>
* <li>墓碑只是更高 revision 的一种取值,本地保留 revision;旧 {@code created} 复活不了它;</li>
* <li>{@code bizExtJson}(业务端扩展)不在快照成员里,任何路径都不写它。</li>
* </ol>
*
* <p><b>授权缓存与撤权竞态(v2 §5,必须照实说)</b>:写投影前按 formUuid 查一张带 TTL 的
* 授权快照({@code grants/verify},不回查答案值文档)。缓存命中期间撤权是不可见的——
* 这条链的生效边界是"最多 {@code grant-cache-ttl-seconds} 秒的滞后",
* 不能描述成即时、全局强一致撤回;核验不可达时按 {@code DEFERRED} 暂缓写入而不是放行。
* 已在合法授权期间交出去的明文副本不做远程收回保证。</p>
*/
@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<String, CachedGrant> 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 行 + 投影行 + 意图关联/建件同生同死,
* <b>DB 提交成功才由消费侧 manual ack</b>(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<FcAnswerInbox> 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<FcAnswerProjectionRepository.ManifestRow> fetched = projectionRepository.findManifest(
schId, formUuid.trim(), cursor == null ? "" : cursor, PageRequest.of(0, pageSize + 1));
boolean completed = fetched.size() <= pageSize;
List<FcAnswerProjectionRepository.ManifestRow> 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<FcAnswerProjectionRepository.ManifestRow> 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):中心清单确认"这份答卷已不存在/不应再在本地保留"时,
* 本地只做<b>退役标记</b>({@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<GrantSnapshotView> 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} 同一规则闭集)。
*
* <p>为什么本地要重算而不是"信中心给的 hash":撤权后修复、毒消息排查、以及"同 revision 异 hash"
* 这三件事都要一个<b>本地独立结论</b>。规则逐条对齐、跨端夹具在 {@code ShAnswerFlowTest} 里
* 与 {@code FcAnswerDeliveryTest} 钉<b>同一个字面量</b>,任何一侧改动都会同时红在两个仓。</p>
*/
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<String> names = new ArrayList<>();
java.util.Iterator<Map.Entry<String, JsonNode>> 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;
}
}
File diff suppressed because it is too large Load Diff
@@ -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)。
*
* <p>本类由 {@link FcAnswerMqConfiguration} 在<b>已配置 broker</b> 时才绑上监听容器;
* 未接 MQ 的环境里它只是"没人投递的一个 bean",但投影链仍可用——可信建件的第二条路径
* (签名收据确认)不依赖 broker,这也是本机联调能跑通全链的原因。</p>
*
* <p>三条 ack 纪律:</p>
* <ol>
* <li><b>DB 提交成功才 manual ack</b>:{@link AnswerProjectionStore#applySnapshot} 是一个事务,
* 正常返回即已提交,此后才 ack;抛异常一律 nack 且不 requeue(容器有界重试 3 次后
* 进可查死信),绝不"先 ack 再慢慢处理";</li>
* <li><b>毒消息不热循环</b>:协议/闭集校验不过=REJECTED,记台账后 ack——
* 重投一百次也不会变成合法,排查靠 {@code fc_answer_inbox} 与死信队列而不是日志翻页;</li>
* <li><b>结果回执与 ack 是两件事</b>:ack 只说明本应用消费掉了,中心要的是"业务落库了没有",
* 所以 ack 之后再签名上报 {@code consumerApplied};上报失败不回滚已落库的投影,
* 台账留 PENDING 由重报扫描追平。</li>
* </ol>
*/
@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<FcAnswerInbox> 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;
}
}
@@ -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)。
*
* <p><b>两个动作,一条不变式:办件的建立只能由服务端事实驱动</b>。</p>
* <ol>
* <li>{@link #createIntent}:会话取人与校 → 事项入口逐条判(已发布、{@code applyUrl} 为空、
* {@code onlineFlag==1}、TASK_1 的成对 FC 绑定与确切版本、{@code flowGuid} 就绪)→
* 签名换上下文。任何一条不过就<b>既不建办件也不调流程</b>;浏览器 body 里只有事项坐标
* 与自己的幂等键,注入不了 submitter/schId/flowGuid(这些值本方法只从会话与事项行取)。</li>
* <li>{@link #confirmIntent}:浏览器那句"提交成功了"<b>只被当作"可以去读收据"的信号</b>——
* 答卷坐标、提交者、版本一律取中心签名收据;收据没确认到答卷就不建件,
* postMessage 里就算被人塞了一个 answerId 也没有任何用处(本方法不读它)。</li>
* </ol>
*
* <p>{@code fcIntentId} 由自然键(校/人/事项/客户端幂等键)哈希得来,<b>不是随机串</b>:
* 并发双 POST 会算出同一个意图键,中心侧 {@code uk_ctx_intent} 与本地侧
* {@code uk_intent_client} 各自把"两个业务意图"关成一个。</p>
*/
@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;
/**
* 创建(或复用)可信建件意图并换取提交上下文。
*
* <p>顺序是刻意的:入口判定全过 → 落意图行 → 签名调中心 → 回写令牌与地址。
* 中心调用失败时意图行留在 {@code ISSUED}(令牌空),前端重试同一条幂等键会走同一行、
* 重新换取,不会产生第二个业务意图。</p>
*/
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<FcApplyIntent> 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);
}
/**
* 确认:读中心收据 → 逐字复核坐标 → 合并投影 → 建件(一次)。
*
* <p>已建过的意图直接回原结论(幂等);收据还没有答卷就返回"尚未提交",
* 不建件、也不报错——事件先到那条路径会把投影补上。</p>
*/
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();
}
}
@@ -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)。
*
* <p>与 {@link FormCenterCatalogClient}(TASK_1 的目录/绑定核验)同一条出站范式:
* 命名目标 {@code form-center} 经 {@link YschOpenCall} 走服务目录三级兜底,
* 身份=本应用 appKey(starter 签名拦截器注入),<b>appKey/secret 绝不进浏览器</b>。
* 本类只增加端点,不复用也不修改 TASK_1 那个客户端的职责范围。</p>
*
* <p>四条出站调用各有明确的失败语义,不搞"统一 catch 后返回 null":</p>
* <ul>
* <li>{@link #issueContext} 失败=意图不能进入填报,直接拒(没有可信上下文就没有可信建件);</li>
* <li>{@link #fetchReceipt} 失败=确认动作不做,办件不形成(收据是唯一的答卷事实源);</li>
* <li>{@link #reportApplied} 失败<b>不回滚</b>本地投影:中心没收到确认只是"对方还不知道",
* 台账里留 PENDING 由重报扫描追平;</li>
* <li>{@link #verifyGrants} 失败=消费端<b>暂缓写入</b>(撤权竞态口径),而不是按"还有效"放行。</li>
* </ul>
*/
@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<GrantSnapshotView> verifyGrants(Long consumerSchoolId, List<String> 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();
}
}
@@ -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)。
*
* <p><b>本类取代的是"afterCommit 丢线程池"作为唯一交付手段的形态,不是那条线程池本身</b>:
* 意图先落成 {@link FlowApplyCommand} 行(与办件同一短事务),HTTP 一律在事务外跑;
* afterCommit 只唤醒一次扫描。进程在提交与发送之间被杀,行还在库里,
* 下一次扫描(或重启后的同一扫描)继续把它推到终态——这就是任务书要的"崩溃窗口恢复"。</p>
*
* <p><b>状态机只有三个方向,且"未知"是一个显式状态而不是失败</b>:</p>
* <ul>
* <li>{@code PENDING/FAILED} → 发送 → 成功 {@code DONE};<b>任何</b>异常(超时、断连、4xx、5xx)
* 都记 {@code UNKNOWN}。不做"看异常类型猜有没有受理"——出站失败与远端未受理不是一回事,
* 猜错的代价是在中心已存在的办件上再发一次;</li>
* <li>{@code UNKNOWN} → 先按业务键 GET。查到 10/20/30/40 一律<b>回填真实状态</b>后 {@code DONE},
* <b>30/40 绝不当作网络重试再 apply</b>(那会触发 R4 重启推进,把已拒绝/已撤回的办件又跑起来);
* 查无才用同一 {@code requestToken} 补发(中心据此识别"同一次命令的重投",不重启流程);</li>
* <li>重试预算耗尽 → {@code DEAD},留人工处置,不无限重投。</li>
* </ul>
*
* <p>轮次:{@code attempt} 只在用户明确授权的重提时 +1(新命令、新 token);
* 恢复链的补发永远复用同一行的 attempt 与 token。</p>
*/
@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<FlowApplyCommand> 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<FlowApplyCommand> 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<String, Object> remote = null;
Exception last = null;
for (String bizType : SvcBizTypes.queryOrder()) {
try {
Map<String, Object> 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<FlowApplyCommand> 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;
}
}
}
@@ -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)。
*
* <p>体形状用 {@code FcApplyModels.FlowOutbound}(= 旧 apply 字段 + requestToken/attempt/retryOnly),
* <b>不</b>往共享 SDK 的 {@code FlowApplyBody} 或 TASK_1 的 {@code SvcModels.ApplyBody} 上塞字段:
* 那两个类都不在本任务写范围,且"给共享 DTO 加字段"正是任务书禁止的绕白名单写法。
* 门面侧走 {@code YschFlowOpenApi.raw()} 泛化视图(目标名仍由门面承载,本类不拼专属地址);
* 未配服务目录时回落 legacy ad-hoc spec,与其余方法两条路径保持一致。</p>
*/
@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<String, String> 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<String, Object> getInstanceByBiz(Long schId, String bizType, String bizId) {
requireEnabled();
@@ -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 的签名拦截器注入——<b>appKey/secret 绝不进浏览器</b>,
* 前端只调本应用的同源代理({@code SvcFormCatalogController})。
*
* <p>地址解析走服务目录三级兜底({@code yh.call.targets} &gt; {@code yh.open.services.form-center}
* &gt; 枚举 base-host),与 {@link FlowCenterClient} 同口径,本类不拼任何表单中心专属地址。</p>
*
* <p>核验是<b>保存事项的前置条件</b>:{@link #requireBindable} 失败即抛 {@link BizException},
* 由 {@code adminSave} 的事务整体回滚——不给"半个绑定"进库的机会。读不到通常是三种情况:
* 表单中心没给本应用授权(read 权限缺失)、uuid 换校/被删、版本行不存在;文案点名方向但不外泄
* 表单中心侧的存在性差别。</p>
*/
@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<String, String> 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} 精确取版本行,并核对<b>返回的坐标与请求一致</b>。
*
* <p>为什么还要比一次返回:中心若在某条分支上回落成"当前发布版",事项就会存下一个看起来对、
* 实际指向别的版本的坐标——那是最难查的一类线上问题。坐标不符即拒(v3 §4)。</p>
*/
public void requireBindable(Long schId, String formUuid, Integer formVersion) {
Map<String, String> 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();
}
}
@@ -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 的成对出站面)。
*
* <p>与 {@code FcSubmissionClient}/{@code FormCenterCatalogClient} 同一条范式:命名目标
* {@code form-center} 经 {@link YschOpenCall} 走服务目录三级兜底,身份=本应用 appKey,
* 由 starter 签名拦截器注入——<b>本应用不自己拼签名串,也不把密钥放进任何请求体</b>。</p>
*
* <p>失败语义按调用方要处置的方式区分开:</p>
* <ul>
* <li>{@link #fetchManifestPage} 失败=本轮扫描<b>中断</b>(不能把"读不到"当成"没有差异",
* 更不能当成"清单到底了");</li>
* <li>{@link #fetchTaskAnswer} 失败=这一条差异<b>不修复、不退役</b>,只记录;</li>
* <li>{@link #submitResult} 失败=结果没送到,任务留在本地待重报(中心侧靠租约重派,同 attempt 幂等)。</li>
* </ul>
*/
@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.<String, String>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));
}
}
/**
* 读"这一份答卷在中心当前是什么"。
*
* <p>返回体自带存在性结论(PRESENT/ABSENT/UNKNOWN)与权威信封或缺席证明;
* 结论缺失或 {@code presence} 不认识时一律按 UNKNOWN 处置(调用方只报告、不动数据)。</p>
*/
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.<String, String>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.<String, String>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;
}
}
@@ -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)。<b>setter 注入而不是加进构造器</b>:本类的 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 注入)。建件必须与"意图→办件"的关联写<b>落在同一个事务</b>:
* 两条并发确认各自插一个办件时,后提交那条会被 {@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)=====
/**
* 由<b>已验证</b>的意图 + 已落库的答卷投影创建一个本地办件(至多一个)。
*
* <p>调用方必须先证明三件事:收据来自中心、意图属于本人、答卷与意图坐标一致——
* 本方法不再接受任何浏览器传入的表单数据({@code formDataJson} 只从投影快照取,
* 投影只从签名收据或签名信封落库),也不接受外部指定 bizId(bizId 恒=本地自增办件 id,
* 与旧链路逐字相同,回调解析不受影响)。</p>
*
* <p>短事务形状:办件行 + 意图关联 + 出站命令三样一起提交,HTTP 在事务外由
* {@link FlowApplyRecoveryService} 唤醒执行(C.1"HTTP 调用不占长数据库事务")。</p>
*/
@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());
@@ -94,6 +94,22 @@ public class ServiceItemService {
private final com.yunhai.platform.flowcallback.FlowCallbackRegistrar callbackRegistrar;
private final TransactionTemplate transactionTemplate;
/**
* 表单中心目录/核验客户端(v3/TASK_1)。
*
* <p><b>用 setter 而不是加进 {@code @RequiredArgsConstructor} 的构造参数</b>:本类那 11 参构造
* 已被 {@code ServiceItemEffectiveFlowTest}/{@code ServiceItemFlowRegTest} 逐位钉住,而那两份测试
* 不在本任务的可改清单里——多一个入参会让它们直接编译不过。绑定核验必须在
* {@link #adminSave} 的事务内发生(远端失败=整次保存回滚,不留半个绑定),所以客户端装配在这里,
* 而不是挪到控制器入口。</p>
*/
private FormCenterCatalogClient formCenterClient;
@org.springframework.beans.factory.annotation.Autowired
void setFormCenterClient(FormCenterCatalogClient formCenterClient) {
this.formCenterClient = formCenterClient;
}
// ===== 管理端 =====
public Map<String, Object> 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 ⇒ 生效流程(事项自身或绑定表单的)非空。
* <br>v3/TASK_1 追加:表单中心绑定必须<b>成对</b>提交,且必须在写库前经远端核验
* (核验失败=抛错=整次 {@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)) {
+46
View File
@@ -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 刻意<b>不写本地默认值</b>(TASK_1 交付说明第 2 条的同一取舍):本文件里写
# http://localhost:80xx 会被打进 jar,部署实例就去连容器内的 localhost(2026-09-18 事故)。
# 本机跨应用联调请运行时注入:YH_OPEN_SERVICES_FORMCENTER=http://localhost:<FC 实际端口>;
# 生产不注入即走目录第 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}
# 被嵌填报页基址 = 表单中心<b>前端</b>地址(不是后端 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/
File diff suppressed because it is too large Load Diff
@@ -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)。
*
* <p>任务书要的证据是「<b>事项保存远端校验失败即拒绝该保存,不写半个绑定</b>」。这里给单元级取证:
* 核验点落在 {@code adminSave} 的<em>任何写动作之前</em>({@code validateBindings}),核验一抛,
* 事项行与附件重绑都不许被碰到——事务回滚是兜底,"根本没写"才是这条链路的常态。
* 中心侧的撤权/跨校/版本行缺失三种情形在客户端都表现为同一次核验失败,本类用"客户端抛错"代表它们
* (各自的规则在 form-center 的 {@code FcDiscoveryAccessTest} 里逐条钉住)。</p>
*
* <p>另外四条:成对提交(单传一个即拒)、本地 {@code form_code} 只读不写(改值即拒、原样回传保留存量绑定,
* 不做"顺手清空历史行"这种二次伤害)、没表单的事项保留既有允许形态、目录代理的校域只能由 CAS 站点派生。</p>
*/
@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<ServiceItem> 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);
}
}
@@ -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<FcAnswerProjection> 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<FcAnswerProjection> 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<FcAnswerProjection> 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<Pageable> 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);
}
}
File diff suppressed because it is too large Load Diff