含10-22精修: 对账有限重试(retry_count/指数退避/work_version防旧覆盖)+接缝身份(source-app-key空拒收)+应急对账闸让位FlowApplyRecoveryService+同attempt指令不重开终态。SQL未执行。
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 行(值文档与令牌不进对账检查点)
|
||||
@@ -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} > {@code yh.open.services.form-center}
|
||||
* > 枚举 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)) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user