Compare commits
21
Commits
4bbcd22d25
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5af47abfa | ||
|
|
a4d220362e | ||
|
|
8edccd5514 | ||
|
|
f816fc390e | ||
|
|
e51201134a | ||
|
|
391281582a | ||
|
|
036bdb1f7c | ||
|
|
b9217d9a2b | ||
|
|
af760913fa | ||
|
|
c1bcbf2f88 | ||
|
|
6f855e57d1 | ||
|
|
6086c4374c | ||
|
|
05d2143ce1 | ||
|
|
ed3c9e6a72 | ||
|
|
fb4bef9d34 | ||
|
|
88978ea701 | ||
|
|
283d4479f5 | ||
|
|
f84ce9c822 | ||
|
|
54682cb72b | ||
|
|
eb0a8ded2f | ||
|
|
f368299eb5 |
+42
-10
@@ -9,13 +9,17 @@ name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ main, codex/core-rc-20261023 ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cache_bust:
|
||||
description: '构建前清空 maven m2 缓存'
|
||||
required: false
|
||||
default: 'false'
|
||||
tests_only:
|
||||
description: '只验证测试和 JAR,不构建推送镜像或通知建档'
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
@@ -25,7 +29,10 @@ jobs:
|
||||
options: --network infra-infra-net
|
||||
steps:
|
||||
- name: 安装 git / curl(alpine)
|
||||
run: apk add --no-cache git curl
|
||||
run: |
|
||||
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||
apk add --no-cache git curl
|
||||
|
||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||
@@ -33,6 +40,10 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src
|
||||
git -C /tmp/src fetch --depth 1 origin "$GITHUB_SHA"
|
||||
git -C /tmp/src checkout --detach FETCH_HEAD
|
||||
test "$(git -C /tmp/src rev-parse HEAD)" = "$GITHUB_SHA"
|
||||
echo "CI_SOURCE_SHA=$GITHUB_SHA"
|
||||
cp -a /tmp/src/. .
|
||||
rm -rf /tmp/src
|
||||
# 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符
|
||||
@@ -55,20 +66,40 @@ jobs:
|
||||
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
||||
run: |
|
||||
set -e
|
||||
docker rm -f mavenbuild 2>/dev/null || true
|
||||
docker run -d --name mavenbuild \
|
||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||
cleanup_ci() {
|
||||
result=$?
|
||||
trap - EXIT
|
||||
docker rm -fv "$BUILD_CONTAINER" >/dev/null 2>&1 || true
|
||||
docker info >/dev/null 2>&1 || { echo 'CI_CONTAINER_CLEANUP=UNVERIFIED' >&2; exit 1; }
|
||||
if docker inspect "$BUILD_CONTAINER" >/dev/null 2>&1; then
|
||||
echo 'CI_CONTAINER_CLEANUP=FAILED' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo 'CI_CONTAINER_CLEANUP=PASS'
|
||||
exit "$result"
|
||||
}
|
||||
trap cleanup_ci EXIT
|
||||
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$BUILD_CONTAINER" \
|
||||
--network infra-infra-net \
|
||||
-v /opt/docker/projman-m2:/root/.m2 \
|
||||
-w /build maven:3.6.3-jdk-8 sleep 3600
|
||||
docker cp "${PWD}/." mavenbuild:/build/
|
||||
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
|
||||
docker exec mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package'
|
||||
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar'
|
||||
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||
docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
|
||||
docker exec "$BUILD_CONTAINER" sh -c 'cd /build && sh ci/test-gate.sh clean package'
|
||||
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
|
||||
mkdir -p "${PWD}/target"
|
||||
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
|
||||
docker rm -f mavenbuild
|
||||
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
|
||||
docker rm -f "$BUILD_CONTAINER"
|
||||
|
||||
- name: 构建并推送镜像
|
||||
if: ${{ success() && github.ref == 'refs/heads/main' && inputs.tests_only != 'true' }}
|
||||
run: |
|
||||
set -e
|
||||
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
@@ -79,6 +110,7 @@ jobs:
|
||||
|
||||
# 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual)
|
||||
- name: 通知 projman 建档(部署由运维手动触发)
|
||||
if: ${{ success() && github.ref == 'refs/heads/main' && inputs.tests_only != 'true' }}
|
||||
run: |
|
||||
curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
||||
@@ -3,3 +3,6 @@ target/
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
|
||||
# 本地调试脚本与探针输出(不入库)
|
||||
local-debug/
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
TESTS=ShAnswerFlowTest,ShReconcileExtensionTest,ShReconcileTaskTest,ShMqEntryDefaultOffTest,ShProjectionRepairSqlGuardTest,ShFormBindingTest,SvcFlowCallbackHandlerTest,FlowReconcileJobTest,ServiceApplyFlowSubmitTest,FlowCenterClientFacadeTest,FlowCenterClientRoutingTest
|
||||
# Surefire 的选择器部分匹配也可能成功;每个指定类必须随源码存在。
|
||||
oldIFS=$IFS
|
||||
IFS=,
|
||||
for test in $TESTS; do
|
||||
[ -n "$(find src/test/java -type f -name "$test.java" -print -quit)" ] || { echo "Missing required test class: $test" >&2; exit 2; }
|
||||
done
|
||||
IFS=$oldIFS
|
||||
if [ "$#" -eq 0 ]; then set -- test; fi
|
||||
exec mvn -B "$@" -Dtest="$TESTS" -DfailIfNoTests=true -Dsurefire.failIfNoSpecifiedTests=true -DforkCount=0 -Dspring.jpa.hibernate.ddl-auto=validate < /dev/null
|
||||
@@ -0,0 +1,53 @@
|
||||
# SH 流程恢复与回调轮次契约(2026-10-23)
|
||||
|
||||
复用既有 flow_apply_command,不新增库列或替代流程架构。FC 签名收据 + HTTP 建件仍是主链,MQ 不作为必选。
|
||||
|
||||
## 请求轮次与实际轮次
|
||||
|
||||
command.attempt 记录用户已授权请求的历史,不根据 duplicated 猜测,也不把远端 applyRound 写回 attempt。
|
||||
FL 实际结果(branch/applyRound/status/version/instanceId)存于原 payload_json 的服务器保留节 `_flOutcome`,
|
||||
原发起材料字段不变;再次发送反序列化 FlowOutbound 时不把该保留节送出。
|
||||
service_apply.flow_result_json 的 applyRound 是已确认镜像轮次;没有字段的旧行初始按 1。
|
||||
|
||||
| FL 原分支 | SH 行为 |
|
||||
|---|---|
|
||||
| CREATED | 按真实状态/版本确认新建;不假定 START 后必然仍为 10 |
|
||||
| IDEMPOTENT | 镜像真实 10/20;本地终态不凭幂等响应回开 |
|
||||
| SAME_REQUEST | 同命令回读,不新建 attempt;30/40 仍为原终态。若是已重启命令的丢响应恢复,只凭同 token+匹配命令轮次修正已存在的远端轮次 |
|
||||
| STALE_ROUND | 保存远端实际轮次与状态;不倒退更高的本地镜像轮次、不回开终态 |
|
||||
| REPLAY_ONLY | 保留远端真实状态;网络恢复不得借此重启 |
|
||||
| RESTARTED | 唯一主动进入新轮的 FL 分支;回填还必须通过轮次、token、实例与版本保护 |
|
||||
| CONCURRENT_CREATED | 当作已存在实例回读,按实际状态确认,不依据本地 attempt 开新轮 |
|
||||
|
||||
未知/缺失 branch 不是成功,转 UNKNOWN 先查真实状态。旧 FL 响应缺 status/version 时同样查询,
|
||||
查询结果必须用 instanceId 与本地命令的 applyRound/requestToken/学校/流程线交叉确认。
|
||||
UNKNOWN 查询异常或证据不符继续 UNKNOWN + 退避,不能降为下一次会盲发的 FAILED;预算耗尽 DEAD。
|
||||
查无才同 token 补发并置 retryOnly=true;只有用户明确重提才可以建立新的请求轮。
|
||||
|
||||
## 用户入口与并发
|
||||
|
||||
ServiceApplyService 在生产装配 flowCommands 时,初次代发起、retry、resubmit 全走同一命令服务;
|
||||
可信 FC 办件缺命令服务直接拒绝,不能绕到旧 apply + markResubmitted 常量 10。
|
||||
用户授权重提在短事务中锁办件,已有尚未落定的新轮命令时复用它,双击不再创建第三轮;
|
||||
真正开始重提的命令 attempt 至少是当前镜像轮次+1。HTTP 不占办件长事务。
|
||||
无 Spring 命令装配的非 FC 历史适配路径保留,不能用于可信 FC 办件。
|
||||
|
||||
领取短锁不覆盖 HTTP,重复请求可能并发;FL 既有 token/轮次守卫吸收重复,
|
||||
SH 结算时锁命令并检查轮次/版本,旧响应既不能覆盖业务终态,也不能倒写较新命令台账。
|
||||
|
||||
## 回调
|
||||
|
||||
FL 在同步事件发布阶段冻结 applyRound/requestToken,AFTER_COMMIT 不用重读到的新轮为旧事件贴标。
|
||||
两个字段在既有回调 data 中传递,无需修改共享 SDK DTO 或数据库。
|
||||
SH 必须同时匹配事件实例、学校、流程线,以及该办件对应的 command.attempt/requestToken;
|
||||
命令必须已发送,若已有 FL 结果证据,其实际 applyRound 必须一致。
|
||||
只有经过这些校验的更高轮次 + 更高实例版本才可以从终态回开;实例版本只承担轮内乱序判据。
|
||||
旧轮即使版本很高,也不能修改新轮终态或结果;同轮终态也不被迟到 task.* 回退。
|
||||
新轮快速结束的回调先到后,较旧的 apply 响应不覆盖它。
|
||||
缺轮次身份的有命令回调保守忽略;真正无命令存量单只走第 1 轮兼容,绝不单凭版本回开终态。
|
||||
|
||||
## 验证边界
|
||||
|
||||
规则测试控制竞争时序,不声称为 MySQL 并发或 160 验收。FL 指定 11 项仍为真实 MySQL 测试;
|
||||
CI 只连接本次 job 的独立 schema,Hibernate 固定 validate。部署必须先 FC/FL 后 SH,
|
||||
以便 SH 严格校验启用前,FL 已能发送冻结轮次身份;存量缺身份积压需单独清点,不回退新数据安全语义。
|
||||
@@ -50,6 +50,14 @@
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<!-- 外观偏好同源读端点 GET /api/appearance(三态 proxy/local/off,默认 proxy 经 YschOpenCall
|
||||
代理 UserHome /oapi/appearance,userhome 地址走平台 application-platform.yml 兜底)。
|
||||
平台件不进聚合包:本地同名控制器已删(2026-10-12 任务 E 收敛,starter 端点同 URL 同语义,
|
||||
含「503 改 sendError 直写」线上修复的等价实现) -->
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-appearance-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 流程中心回调接收端 starter(2026-09-20 迁移):X-Flw 验签 + 幂等/乱序门/同事务推进
|
||||
+ 登记注册器组件化,业务映射见 SvcFlowCallbackHandler -->
|
||||
@@ -57,6 +65,27 @@
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-flow-callback-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<!-- 入站验签走中央密钥链(注册表模式 yh.app-auth.trust-registry)必须显式引 data-redis:
|
||||
yh-app-auth 的该依赖是 optional、不传递(Notice/flow-center 同款)。副作用是
|
||||
RedisHealthIndicator 会被装配,故 yml 里关掉 management.health.redis.enabled,
|
||||
防 Redis 抖动把 /actuator/health 拉成 DOWN(503)。 -->
|
||||
<dependency>
|
||||
<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,18 @@
|
||||
-- 2026-10-20 svc-hall 一期 P1-1:事项"服务承诺"字段(6 个业务字段拆 7 列,收费标准拆 desc/basis)
|
||||
-- 契约:docs/svc-hall一期接口契约-20261020.md §2.1(冻结,字段名全链路统一不得改名)
|
||||
-- 口径:全部可空——存量事项不强制回填,空值语义=未维护(前端必须容忍 null)
|
||||
-- 语法:MySQL 5.7(无 IF NOT EXISTS for ADD COLUMN,重复执行会报 Duplicate column name,按需在外部判重)
|
||||
|
||||
ALTER TABLE service_item
|
||||
ADD COLUMN coop_depts VARCHAR(500) NULL COMMENT '协办部门(顿号分隔文本快照)',
|
||||
ADD COLUMN handler_name VARCHAR(64) NULL COMMENT '经办人员',
|
||||
ADD COLUMN handle_time_desc VARCHAR(200) NULL COMMENT '办理时间(办公时段,区别于办理时限)',
|
||||
ADD COLUMN run_count TINYINT NULL COMMENT '跑现场次数(0=零跑动)',
|
||||
ADD COLUMN express_flag TINYINT NULL COMMENT '是否快递送达:0否/1是(到付)/2是(学校承担)',
|
||||
ADD COLUMN fee_desc VARCHAR(200) NULL COMMENT '收费标准',
|
||||
ADD COLUMN fee_basis VARCHAR(200) NULL COMMENT '收费依据';
|
||||
|
||||
-- 回滚(一条语句整体撤列,索引/视图未引用这些列,可直接删):
|
||||
-- ALTER TABLE service_item
|
||||
-- DROP COLUMN coop_depts, DROP COLUMN handler_name, DROP COLUMN handle_time_desc,
|
||||
-- DROP COLUMN run_count, DROP COLUMN express_flag, DROP COLUMN fee_desc, DROP COLUMN fee_basis;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package com.yunhai.svchall.config;
|
||||
|
||||
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 开放接口签名强制(照 Cms OpenApiAuthEnforceConfiguration 范式):yh-app-auth 的
|
||||
* {@link AppAuthFilter}(order +10,全路由)设计为「无签名头则放行」以兼容浏览器 CAS 流;
|
||||
* 本过滤器(order +11)只挂 /oapi/**,校验其写入的请求属性——已验签放行,否则 401。
|
||||
* /oapi/svc/**(CMS 供数)无用户会话语义,一律应用签名三件套。
|
||||
* 注意:流程中心回调在 /callback/**(不在本过滤器范围),由 yh-flow-callback starter 的 X-Flw 验签过滤器用
|
||||
* X-Flw-* 四头验签,两种签名体系互不干扰。
|
||||
*/
|
||||
@Configuration
|
||||
public class OpenApiAuthEnforceConfiguration {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<RequireAppSignFilter> requireAppSignFilter() {
|
||||
FilterRegistrationBean<RequireAppSignFilter> bean = new FilterRegistrationBean<>();
|
||||
bean.setFilter(new RequireAppSignFilter());
|
||||
bean.addUrlPatterns("/oapi/*");
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11);
|
||||
return bean;
|
||||
}
|
||||
|
||||
public static class RequireAppSignFilter implements Filter {
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
HttpServletRequest req = (HttpServletRequest) request;
|
||||
HttpServletResponse res = (HttpServletResponse) response;
|
||||
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
|
||||
return;
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package com.yunhai.svchall.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.model.hall.HallModels;
|
||||
import com.yunhai.svchall.model.oapi.OapiModels;
|
||||
import com.yunhai.svchall.model.svc.SvcModels;
|
||||
import com.yunhai.svchall.service.SiteDirectory;
|
||||
import com.yunhai.svchall.service.SiteGuard;
|
||||
import com.yunhai.svchall.service.impl.ServiceItemService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -12,12 +13,14 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 公共办事大厅(用户面 /api/hall/**,CAS 登录):给学生/教师/访客申请用的"事项大厅"数据面,
|
||||
* 与 CMS 匿名门户(/oapi/svc/**,appKey 签名)同源同形状——出参直接复用
|
||||
* 公共办事大厅(用户面 /api/hall/**,<b>D7 起匿名只读</b>):给学生/教师/访客申请用的"事项大厅"数据面,
|
||||
* 与 CMS 匿名门户(/oapi/svc/**,appKey 签名)同源同形状——列表/筛选项出参直接复用
|
||||
* {@link OapiModels.ListData} / {@link OapiModels.Filters},不另立 VO(形状单一事实源)。
|
||||
* <p>
|
||||
* 与 oapi 的区别只有两点:①鉴权从"应用签名"换成"CAS + 本校成员",调用者是浏览器;
|
||||
* ②只暴露已发布事项的展示字段,附件/明细等仍走 oapi(门户用)。
|
||||
* 与 oapi 的区别只有两点:①鉴权:{@code /api/hall/**} 不在 {@code yh.sso.url-pattern} 列表内
|
||||
* (D7 匿名面,与 CMS 门户 {@code /portal/**} 同一策略),{@code BoaUser} 参数因此恒可为 null,
|
||||
* 本控制器一律不据它判权限——可见范围只由 siteCode 决定;②只暴露已发布事项的展示字段,
|
||||
* 附件/明细等仍走 oapi(门户用)。
|
||||
* <p>
|
||||
* 新建独立前端 {@code svc-hall-public}(学生/教师/访客办理入口)时,这就是它的列表/筛选数据源;
|
||||
* 管理端(front)不使用本控制器。
|
||||
@@ -29,9 +32,8 @@ public class HallController {
|
||||
|
||||
private final ServiceItemService itemService;
|
||||
private final SiteDirectory siteDirectory;
|
||||
private final SiteGuard siteGuard;
|
||||
|
||||
/** 已发布事项分页(关键词/分类/服务对象/部门筛选;本校成员可见) */
|
||||
/** 已发布事项分页(关键词/分类/服务对象/部门筛选;游客可见) */
|
||||
@GetMapping("/items")
|
||||
public OapiModels.ListData items(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode,
|
||||
@@ -41,7 +43,7 @@ public class HallController {
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
||||
SiteDirectory.SiteInfo site = requireMember(authUserWithDept, siteCode);
|
||||
SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode);
|
||||
return itemService.oapiItems(site.getSchId(), site.getSiteCode(), objectKey, categoryKey,
|
||||
deptGroupId, keyword, page, pageSize);
|
||||
}
|
||||
@@ -50,14 +52,34 @@ public class HallController {
|
||||
@GetMapping("/filters")
|
||||
public OapiModels.Filters filters(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode) {
|
||||
SiteDirectory.SiteInfo site = requireMember(authUserWithDept, siteCode);
|
||||
SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode);
|
||||
return itemService.oapiFilters(site.getSchId(), site.getSiteCode());
|
||||
}
|
||||
|
||||
/** 本校成员 + 站点存在(与办件发起同一口径:只服务本校站点,不做跨校聚合) */
|
||||
private SiteDirectory.SiteInfo requireMember(BoaUser user, String siteCode) {
|
||||
/**
|
||||
* 事项指南详情(一期契约 §3.2,匿名):siteCode + itemCode 均必填、只返回已发布事项;
|
||||
* 出参不含 formCode/flowGuid 与办件人/办件量信息(必填校验与状态判定在 service 侧收口)。
|
||||
*/
|
||||
@GetMapping("/item")
|
||||
public HallModels.ItemGuideVo item(BoaUser authUserWithDept,
|
||||
@RequestParam(value = "siteCode", required = false) String siteCode,
|
||||
@RequestParam(value = "itemCode", required = false) String itemCode) {
|
||||
return itemService.hallItemGuide(siteCode, itemCode);
|
||||
}
|
||||
|
||||
/** 站点信息(含 CMS 主题 raw 透传;用户面弱联动数据源,不保证字段非空) */
|
||||
@GetMapping("/site")
|
||||
public SvcModels.SiteOptionVo site(BoaUser authUserWithDept,
|
||||
@RequestParam("siteCode") String siteCode) {
|
||||
SiteDirectory.SiteInfo site = siteDirectory.requireSite(siteCode);
|
||||
siteGuard.requireSchoolMember(user, site.getSchId());
|
||||
return site;
|
||||
SvcModels.SiteOptionVo option = siteDirectory.findOption(siteCode);
|
||||
// requireSite 已校验存在性,这里理论上非空;防御性兜底用 SiteInfo 补壳
|
||||
if (option == null) {
|
||||
option = new SvcModels.SiteOptionVo();
|
||||
option.setSiteCode(site.getSiteCode());
|
||||
option.setName(site.getName());
|
||||
option.setSchId(site.getSchId());
|
||||
}
|
||||
return option;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.yunhai.svchall.controller.api;
|
||||
|
||||
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.ServiceApply;
|
||||
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
||||
import com.yunhai.svchall.service.ApplyViewGuard;
|
||||
import com.yunhai.svchall.service.impl.ServiceApplyService;
|
||||
import com.yunhai.svchall.service.impl.SvcApprovalClient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 应用内审批代理(P3.1,/api/apply/approval/**,CAS 登录):hall 前端被验签墙挡住不能直调
|
||||
* flow-center oapi,读面(列表/卡片/换址)与写面(通过/驳回)全部经本控制器中转。
|
||||
* <p>
|
||||
* 安全口径(沿 NoticeApprovalClient 范本 + svc-hall 加严,R3.0 P1-01/P1-10 择一取严):
|
||||
* <ul>
|
||||
* <li>身份三字段(operatorId/operatorName/schId)只取 CAS 会话(BoaUser 解析器);
|
||||
* <b>请求参数出现身份字段一律拒绝</b>,防注入改变出站身份;</li>
|
||||
* <li>定位链:requireOperator → findByIdAndSchId(applyId, 会话 schId) → ApplyViewGuard。
|
||||
* 新代理不做超管跨校读(既有 /api/apply/{id} 的超管 findById 分支不带入本代理);
|
||||
* 超管可见(Guard 管理员分支放行)<b>≠可办</b>——写面 byBiz 归属预检对任何身份都不省略;</li>
|
||||
* <li>写面双闸:requireVisible 先行 + byBiz 确认 taskId ∈ 当前人<b>该办件</b>的待办;</li>
|
||||
* <li>参数形态与中心端点同构:taskId/applyId 放 URL 查询参数,opinion/edgeName 放 JSON body;</li>
|
||||
* <li>不代理 /oapi/flow/instances(全校视角,不暴露给用户面)。</li>
|
||||
* </ul>
|
||||
* 本地 flow_status 一律由既有回调链推进,本控制器不写本地状态。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/apply/approval/")
|
||||
@RequiredArgsConstructor
|
||||
public class SvcApprovalController {
|
||||
|
||||
/** 请求参数里的身份字段黑名单(出现即拒——身份只能来自会话) */
|
||||
private static final List<String> IDENTITY_PARAMS =
|
||||
java.util.Arrays.asList("operatorId", "operatorName", "schId", "assigneeId");
|
||||
|
||||
private final SvcApprovalClient approvalClient;
|
||||
private final ServiceApplyRepository applyRepository;
|
||||
private final ApplyViewGuard applyViewGuard;
|
||||
private final ServiceApplyService applyService;
|
||||
|
||||
/** 待我审批(/todo 页"待办"页签数据源;双值双查在 client 内合并) */
|
||||
@GetMapping("Todo")
|
||||
public Map<String, Object> todo(BoaUser user,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
return approvalClient.todoTasks(op.schId, op.userId, page, size);
|
||||
}
|
||||
|
||||
/** 我已审批(P3.5):中心 done,过滤=签名方应用身份(中心侧接缝),信封透传 */
|
||||
@GetMapping("Done")
|
||||
public Map<String, Object> done(BoaUser user,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
return approvalClient.doneTasks(op.schId, op.userId, page, size);
|
||||
}
|
||||
|
||||
/** 抄送我的(P3.5):中心 cc,过滤=签名方应用身份(中心侧接缝),信封透传 */
|
||||
@GetMapping("Cc")
|
||||
public Map<String, Object> cc(BoaUser user,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
return approvalClient.ccMine(op.schId, op.userId, page, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前人在该办件上的待办(详情卡片数据源,0/1/N 均 200 空 tasks)。
|
||||
* 先按会话学校定位办件行再过 Guard——跨校行在此即"办件不存在"。
|
||||
*/
|
||||
@GetMapping("ByBiz")
|
||||
public Map<String, Object> byBiz(@RequestParam("applyId") Integer applyId, BoaUser user,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
ServiceApply apply = requireVisibleApply(applyId, op, user);
|
||||
return approvalClient.byBiz(op.schId, op.userId, String.valueOf(apply.getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批记录抽屉地址(token 只读页,instanceId 形态):本地 flow_instance_id 有值才可能换址——
|
||||
* 未进流程(待提交/发起失败)直接业务错,不打中心。
|
||||
*/
|
||||
@GetMapping("RecordUrl")
|
||||
public Map<String, Object> recordUrl(@RequestParam("applyId") Integer applyId, BoaUser user,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
ServiceApply apply = requireVisibleApply(applyId, op, user);
|
||||
if (apply.getFlowInstanceId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "办件尚未进入流程,暂无审批记录");
|
||||
}
|
||||
return approvalClient.embedRecordUrl(op.schId, apply.getFlowInstanceId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整办理任务页地址(CAS 会话页,无 token):先打 byBiz 确认 taskId 确实在<b>当前人</b>
|
||||
* 该办件的待办里才签发——顺手签发他人 taskId 等于替他人挂办理入口;对端仍按 assigneeId 复核。
|
||||
*/
|
||||
@GetMapping("TaskPageUrl")
|
||||
public Map<String, Object> taskPageUrl(@RequestParam("applyId") Integer applyId,
|
||||
@RequestParam("taskId") Long taskId, BoaUser user,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
ServiceApply apply = requireVisibleApply(applyId, op, user);
|
||||
requireMyPendingTask(apply, taskId, op);
|
||||
return approvalClient.taskPageUrl(op.schId, taskId);
|
||||
}
|
||||
|
||||
/** 通过(body: opinion 可选 / edgeName 多出口可选) */
|
||||
@PostMapping("Approve")
|
||||
public void approve(@RequestParam("applyId") Integer applyId,
|
||||
@RequestParam("taskId") Long taskId, BoaUser user,
|
||||
@RequestBody(required = false) Map<String, String> body,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
ServiceApply apply = requireVisibleApply(applyId, op, user);
|
||||
requireMyPendingTask(apply, taskId, op);
|
||||
approvalClient.approve(taskId, op.schId, op.userId, op.name,
|
||||
body == null ? null : body.get("opinion"),
|
||||
body == null ? null : body.get("edgeName"));
|
||||
}
|
||||
|
||||
/** 拒绝(opinion 必填,前置校验给前端更快反馈;对端仍会强制) */
|
||||
@PostMapping("Reject")
|
||||
public void reject(@RequestParam("applyId") Integer applyId,
|
||||
@RequestParam("taskId") Long taskId, BoaUser user,
|
||||
@RequestBody(required = false) Map<String, String> body,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
String opinion = body == null ? null : body.get("opinion");
|
||||
if (opinion == null || opinion.trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "驳回必须填写意见");
|
||||
}
|
||||
ServiceApply apply = requireVisibleApply(applyId, op, user);
|
||||
requireMyPendingTask(apply, taskId, op);
|
||||
approvalClient.reject(taskId, op.schId, op.userId, op.name, opinion);
|
||||
}
|
||||
|
||||
/**
|
||||
* 我提交的(/todo 页"我提交的"页签数据源):不代理中心——复用本地
|
||||
* {@link ServiceApplyService#myApplies}(数据带事项名/快照等业务字段,列表体验完整;
|
||||
* 中心 instances 是全校视角不可用)。信封显式映射 {@code {total,list}} → 组件
|
||||
* {@code FlowPage{total,content,pageIndex,pageSize,totalPages,hasNext}},参数名
|
||||
* pageSize→size 由本端消化。
|
||||
*/
|
||||
@GetMapping("My")
|
||||
public Map<String, Object> my(BoaUser user,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size,
|
||||
@RequestParam(required = false) Map<String, String> rawParams) {
|
||||
Operator op = requireOperator(user);
|
||||
rejectIdentityParams(rawParams);
|
||||
int pageIndex = page == null || page < 1 ? 1 : page;
|
||||
int pageSize = size == null || size < 1 ? 10 : Math.min(size, 100);
|
||||
Map<String, Object> raw = applyService.myApplies(user, null, pageIndex, pageSize);
|
||||
long total = raw.get("total") instanceof Number ? ((Number) raw.get("total")).longValue() : 0L;
|
||||
Object content = raw.get("list");
|
||||
long totalPages = (total + pageSize - 1) / pageSize;
|
||||
Map<String, Object> out = new java.util.LinkedHashMap<>();
|
||||
out.put("total", total);
|
||||
out.put("content", content);
|
||||
out.put("pageIndex", pageIndex);
|
||||
out.put("pageSize", pageSize);
|
||||
out.put("totalPages", totalPages);
|
||||
out.put("hasNext", pageIndex < totalPages);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 定位链中段:按会话学校取行(跨校=不存在)→ Guard 三分支(提交人/管理员/办理人) */
|
||||
private ServiceApply requireVisibleApply(Integer applyId, Operator op, BoaUser user) {
|
||||
ServiceApply apply = applyRepository.findByIdAndSchId(applyId, op.schId.longValue())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
|
||||
applyViewGuard.requireVisible(apply, user);
|
||||
return apply;
|
||||
}
|
||||
|
||||
/** byBiz 出参里是否含该 taskId(tasks 元素为 {taskId,nodeId,nodeName,arriveTime,outlets}) */
|
||||
@SuppressWarnings("unchecked")
|
||||
private void requireMyPendingTask(ServiceApply apply, Long taskId, Operator op) {
|
||||
Map<String, Object> data = approvalClient.byBiz(op.schId, op.userId, String.valueOf(apply.getId()));
|
||||
Object tasks = data == null ? null : data.get("tasks");
|
||||
boolean mine = false;
|
||||
if (tasks instanceof List) {
|
||||
for (Object row : (List<Object>) tasks) {
|
||||
if (row instanceof Map) {
|
||||
Object id = ((Map<String, Object>) row).get("taskId");
|
||||
if (id instanceof Number && taskId.equals(((Number) id).longValue())) {
|
||||
mine = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!mine) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "该任务不是你在本办件上的待办");
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求参数出现身份字段一律拒绝(身份只能来自 CAS 会话,不接收任何注入) */
|
||||
private static void rejectIdentityParams(Map<String, String> rawParams) {
|
||||
if (rawParams == null) {
|
||||
return;
|
||||
}
|
||||
for (String key : rawParams.keySet()) {
|
||||
if (IDENTITY_PARAMS.contains(key)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"不允许在请求参数中携带身份字段:" + key + "(身份取自登录会话)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录态提参(沿 NoticeApprovalController 口径):登录 → 学校归属 → 姓名兜底账号 */
|
||||
private static Operator requireOperator(BoaUser user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "未登录");
|
||||
}
|
||||
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "当前身份无学校归属");
|
||||
}
|
||||
String name = StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount();
|
||||
return new Operator(user.getId(), user.getCurrentRole().getSchId(), name, user);
|
||||
}
|
||||
|
||||
private static final class Operator {
|
||||
final Integer userId;
|
||||
final Integer schId;
|
||||
final String name;
|
||||
final BoaUser user;
|
||||
|
||||
Operator(Integer userId, Integer schId, String name, BoaUser user) {
|
||||
this.userId = userId;
|
||||
this.schId = schId;
|
||||
this.name = name;
|
||||
this.user = user;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ public class ServiceFlowReg extends BaseEntity {
|
||||
@Column(name = "flow_guid", nullable = false, length = 32)
|
||||
private String flowGuid;
|
||||
|
||||
/** 回调接收端点(= 本应用对外可达地址 + /callback/flow) */
|
||||
/** 回调接收端点(2026-10-05 起 = 平台推导地址 + /oapi/flow/callback;权威镜像在 starter 表 flow_callback_reg) */
|
||||
@Column(name = "callback_url", nullable = false, length = 500)
|
||||
private String callbackUrl;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -145,6 +162,35 @@ public class ServiceItem extends BaseEntity {
|
||||
@Column(name = "apply_url", length = 500)
|
||||
private String applyUrl;
|
||||
|
||||
// 服务承诺字段(P1-1):政务服务化度量口径,空=未维护
|
||||
/** 收费标准 */
|
||||
@Column(name = "fee_desc", length = 200)
|
||||
private String feeDesc;
|
||||
|
||||
/** 收费依据 */
|
||||
@Column(name = "fee_basis", length = 200)
|
||||
private String feeBasis;
|
||||
|
||||
/** 协办部门(顿号分隔文本快照,不做部门树外键) */
|
||||
@Column(name = "coop_depts", length = 500)
|
||||
private String coopDepts;
|
||||
|
||||
/** 经办人员 */
|
||||
@Column(name = "handler_name", length = 64)
|
||||
private String handlerName;
|
||||
|
||||
/** 办理时间(办公时段,区别于 handle_time_limit 办理时限) */
|
||||
@Column(name = "handle_time_desc", length = 200)
|
||||
private String handleTimeDesc;
|
||||
|
||||
/** 跑现场次数(0=零跑动) */
|
||||
@Column(name = "run_count")
|
||||
private Integer runCount;
|
||||
|
||||
/** 是否快递送达:0 否 / 1 是(到付)/ 2 是(学校承担) */
|
||||
@Column(name = "express_flag")
|
||||
private Integer expressFlag;
|
||||
|
||||
/** 管理端自定义排序(列表默认排序第一优先级,升序) */
|
||||
@Column(name = "sort_no", nullable = false)
|
||||
private Integer sortNo = 0;
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.yunhai.svchall.entity.ServiceApply;
|
||||
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
|
||||
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import com.yunhai.svchall.service.impl.SvcFlowCallbackHandler;
|
||||
import com.yunhai.svchall.service.SvcBizTypes;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
@@ -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,12 +54,25 @@ 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;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> remote = flowClient.getInstanceByBiz(
|
||||
apply.getSchId(), SvcFlowCallbackHandler.BIZ_TYPE, String.valueOf(apply.getId()));
|
||||
// 双值双查(P3.2 方案B):先终值后存量,首个命中实例为准——命中判据用内容
|
||||
// (id/status 非空)而非"非 null",对 miss 形态(运行时 data=null / 空载体)都稳
|
||||
Map<String, Object> remote = null;
|
||||
for (String bizType : SvcBizTypes.queryOrder()) {
|
||||
Map<String, Object> candidate = flowClient.getInstanceByBiz(
|
||||
apply.getSchId(), bizType, String.valueOf(apply.getId()));
|
||||
if (candidate != null
|
||||
&& (candidate.get("id") != null || candidate.get("status") != null)) {
|
||||
remote = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (remote == 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,218 @@
|
||||
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 decision;
|
||||
private Integer reportAttempt;
|
||||
private String resultState;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.yunhai.svchall.model.hall;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户面({@code /api/hall/**},D7 匿名只读)出参收口。
|
||||
* 与 {@link com.yunhai.svchall.model.oapi.OapiModels} 的分工:列表/筛选项形状与 CMS 供数同源,
|
||||
* 继续复用 OapiModels;指南详情是**匿名面专属形状**——刻意不含 formCode/flowGuid
|
||||
* (游客不需要,也不暴露流程配置)与任何办件人/办件量信息(一期契约 §1 匿名端点约束)。
|
||||
*/
|
||||
public final class HallModels {
|
||||
|
||||
private HallModels() {
|
||||
}
|
||||
|
||||
/** GET /api/hall/item 出参(一期契约 §3.2;服务承诺字段全部可空) */
|
||||
@Data
|
||||
public static class ItemGuideVo {
|
||||
private Integer id;
|
||||
private String itemCode;
|
||||
private String title;
|
||||
private String summary;
|
||||
private String objectKey;
|
||||
private String categoryKey;
|
||||
private String categoryName;
|
||||
private String deptName;
|
||||
private Integer handleMode;
|
||||
private Integer onlineFlag;
|
||||
/** 非空=外链事项(三态最高优先,前端新窗口打开;与内部表单互斥由前端判定) */
|
||||
private String applyUrl;
|
||||
private String handlePlace;
|
||||
private String handleTimeLimit;
|
||||
private String promiseTimeLimit;
|
||||
private String contactPhone;
|
||||
private String supervisePhone;
|
||||
/** 封面(出参已绝对化) */
|
||||
private String coverImage;
|
||||
private Integer viewCount;
|
||||
private String coopDepts;
|
||||
private String handlerName;
|
||||
private String handleTimeDesc;
|
||||
private Integer runCount;
|
||||
private Integer expressFlag;
|
||||
private String feeDesc;
|
||||
private String feeBasis;
|
||||
/** 三段富文本:入库时已 jsoup 白名单清洗,此处只做 /upload/ 内链绝对化,不再二次清洗 */
|
||||
private String processHtml;
|
||||
private String materialHtml;
|
||||
private String noticeHtml;
|
||||
private List<Attachment> attachments = new ArrayList<>();
|
||||
}
|
||||
|
||||
/** 指南附件(fileUrl 为完整可达地址) */
|
||||
@Data
|
||||
public static class Attachment {
|
||||
private String fileName;
|
||||
private String fileUrl;
|
||||
private Long fileSize;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,18 @@ public final class OapiModels {
|
||||
private Integer handleMode;
|
||||
private Integer onlineFlag;
|
||||
private LocalDateTime publishTime;
|
||||
/** 三态最高优先:非空=外链事项,前端新窗口打开(一期契约 §5) */
|
||||
private String applyUrl;
|
||||
private String promiseTimeLimit;
|
||||
private String handleTimeLimit;
|
||||
/** 服务承诺字段(P1-1,§3.1):全部可空,空=未维护,消费方必须容忍 null */
|
||||
private Integer runCount;
|
||||
private Integer expressFlag;
|
||||
private String coopDepts;
|
||||
private String handlerName;
|
||||
private String handleTimeDesc;
|
||||
private String feeDesc;
|
||||
private String feeBasis;
|
||||
}
|
||||
|
||||
/** GET /oapi/svc/items 出参 */
|
||||
|
||||
@@ -9,6 +9,7 @@ import javax.validation.constraints.Size;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 办事大厅业务模型收口(管理端/用户面 DTO 与 VO;嵌套静态类收口减少文件数)。
|
||||
@@ -39,12 +40,17 @@ public final class SvcModels {
|
||||
private Integer sortNo;
|
||||
}
|
||||
|
||||
/** 站点选项(CMS 站点目录代理,管理端站点选择器) */
|
||||
/** 站点选项(CMS 站点目录代理,管理端站点选择器;T2 起含主题 raw 透传,供用户面弱联动) */
|
||||
@Data
|
||||
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class SiteOptionVo {
|
||||
private String siteCode;
|
||||
private String name;
|
||||
private Long schId;
|
||||
/** CMS 皮肤包名(raw 透传;空=default 由消费方兜底) */
|
||||
private String theme;
|
||||
/** CMS config_json.theme_params 原样键值(raw,消费方按皮肤声明过滤;未配=空对象) */
|
||||
private Map<String, String> themeParams = new java.util.LinkedHashMap<>();
|
||||
}
|
||||
|
||||
// ===== 字典 =====
|
||||
@@ -160,14 +166,42 @@ 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):管理端录入,全部可空 =====
|
||||
/** 协办部门(顿号分隔文本快照) */
|
||||
@Size(max = 500)
|
||||
private String coopDepts;
|
||||
/** 经办人员 */
|
||||
@Size(max = 64)
|
||||
private String handlerName;
|
||||
/** 办理时间(办公时段,区别于办理时限) */
|
||||
@Size(max = 200)
|
||||
private String handleTimeDesc;
|
||||
/** 跑现场次数(0=零跑动) */
|
||||
private Integer runCount;
|
||||
/** 是否快递送达:0 否 / 1 是(到付)/ 2 是(学校承担) */
|
||||
private Integer expressFlag;
|
||||
/** 收费标准 */
|
||||
@Size(max = 200)
|
||||
private String feeDesc;
|
||||
/** 收费依据 */
|
||||
@Size(max = 200)
|
||||
private String feeBasis;
|
||||
private Integer sortNo;
|
||||
/** 指南附件 id 列表(保存时全量重绑 topic_type=svc_item_guide) */
|
||||
private List<Integer> attachmentIds;
|
||||
@@ -200,7 +234,18 @@ 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;
|
||||
private String handlerName;
|
||||
private String handleTimeDesc;
|
||||
private Integer runCount;
|
||||
private Integer expressFlag;
|
||||
private String feeDesc;
|
||||
private String feeBasis;
|
||||
private Integer sortNo;
|
||||
private Integer status;
|
||||
private LocalDateTime publishTime;
|
||||
@@ -234,7 +279,16 @@ 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());
|
||||
vo.setHandleTimeDesc(item.getHandleTimeDesc());
|
||||
vo.setRunCount(item.getRunCount());
|
||||
vo.setExpressFlag(item.getExpressFlag());
|
||||
vo.setFeeDesc(item.getFeeDesc());
|
||||
vo.setFeeBasis(item.getFeeBasis());
|
||||
vo.setSortNo(item.getSortNo());
|
||||
vo.setStatus(item.getStatus());
|
||||
vo.setPublishTime(item.getPublishTime());
|
||||
@@ -330,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,40 @@
|
||||
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> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
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,40 @@
|
||||
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>短事务行锁用于读取和结算持久命令,不跨 HTTP 持有;同命令并发发送仍可能发生。
|
||||
* 远端按 requestToken/attempt 幂等吸收,本地结算再锁命令并按远端轮次/版本防止旧响应倒写。</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);
|
||||
}
|
||||
@@ -54,13 +54,38 @@ public interface ServiceApplyRepository extends JpaRepository<ServiceApply, Inte
|
||||
* 一键重提回填(P2-5/R4):驳回/撤回(30/40)行经 flow-center R4 重启推进后回开为 10。
|
||||
* 与 {@link #markApproving} 的差别:30/40 → 10 是合法回开(markApproving 的终态守卫
|
||||
* 语义是"回调已落定终态不被提交侧盲写打回",重提是用户显式动作);重提是新的提交动作,
|
||||
* submitTime 覆盖为当下、finishTime 清空。WHERE 限定 30/40,并发窗口内若状态已被推进
|
||||
* (如新一轮回调先置 10)则本次为幂等空更新。
|
||||
* submitTime 覆盖为当下、finishTime 清空、<b>旧轮 flowResultJson 清空</b>(P1-03:新轮
|
||||
* 事件版本更高,Handler 已按版本感知接受,旧结果残留会误挡新轮终态/误导详情渲染);
|
||||
* lastInstanceVersion 刻意保留(跨轮乱序防护:旧轮迟到事件仍按版本门丢弃)。
|
||||
* WHERE 限定 30/40,并发窗口内若状态已被推进(如新一轮回调先置 10)则本次为幂等空更新。
|
||||
*/
|
||||
@Modifying
|
||||
@Query("update ServiceApply a set a.flowInstanceId = :instanceId, a.flowStatus = 10,"
|
||||
+ " a.submitTime = :submitTime, a.finishTime = null"
|
||||
+ " a.submitTime = :submitTime, a.finishTime = null, a.flowResultJson = null"
|
||||
+ " 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);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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.ServiceApply;
|
||||
import com.yunhai.svchall.service.impl.SvcFlowCallbackHandler;
|
||||
import com.yunhai.svchall.service.SvcBizTypes;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -94,11 +94,16 @@ public class ApplyViewGuard {
|
||||
return cached.userIds;
|
||||
}
|
||||
List<Integer> assignees = Collections.emptyList();
|
||||
// 双值双查(P3.2 方案B):先终值后存量各查一次(S0-H2 实测中心存量=0,次查为
|
||||
// 防御性兼容),首个命中的实例为准
|
||||
try {
|
||||
Map<String, Object> instance = flowClient.getInstanceByBiz(
|
||||
apply.getSchId(), SvcFlowCallbackHandler.BIZ_TYPE, String.valueOf(apply.getId()));
|
||||
if (instance != null && instance.get("assigneeIds") instanceof List) {
|
||||
assignees = (List<Integer>) instance.get("assigneeIds");
|
||||
for (String bizType : SvcBizTypes.queryOrder()) {
|
||||
Map<String, Object> instance = flowClient.getInstanceByBiz(
|
||||
apply.getSchId(), bizType, String.valueOf(apply.getId()));
|
||||
if (instance != null && instance.get("assigneeIds") instanceof List) {
|
||||
assignees = (List<Integer>) instance.get("assigneeIds");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 流程中心不可达:fail-closed(不放行),日志留痕
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -61,22 +79,29 @@ public interface FlowClient {
|
||||
*/
|
||||
byte[] diagramImage(Long schId, Long instanceId, String flowGuid, String mode);
|
||||
|
||||
/** 代发起结果 */
|
||||
/** v2 结果保留 FL 原始分支、轮次及真实实例状态;旧 apply 构造不伪造 v2 语义。 */
|
||||
class ApplyResult {
|
||||
private final Long instanceId;
|
||||
private final boolean duplicated;
|
||||
private final String branch;
|
||||
private final Integer applyRound;
|
||||
private final Integer status;
|
||||
private final Integer version;
|
||||
|
||||
public ApplyResult(Long instanceId, boolean duplicated) {
|
||||
this.instanceId = instanceId;
|
||||
this.duplicated = duplicated;
|
||||
this(instanceId, duplicated, null, null, null, null);
|
||||
}
|
||||
|
||||
public Long getInstanceId() {
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
public boolean isDuplicated() {
|
||||
return duplicated;
|
||||
public ApplyResult(Long instanceId, boolean duplicated, String branch, Integer applyRound,
|
||||
Integer status, Integer version) {
|
||||
this.instanceId = instanceId; this.duplicated = duplicated; this.branch = branch;
|
||||
this.applyRound = applyRound; this.status = status; this.version = version;
|
||||
}
|
||||
public Long getInstanceId() { return instanceId; }
|
||||
public boolean isDuplicated() { return duplicated; }
|
||||
public String getBranch() { return branch; }
|
||||
public Integer getApplyRound() { return applyRound; }
|
||||
public Integer getStatus() { return status; }
|
||||
public Integer getVersion() { return version; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ public interface SiteDirectory {
|
||||
/** 站点信息(不可达/不存在返回 null——消费方 fail-closed) */
|
||||
SiteInfo resolveSite(String siteCode);
|
||||
|
||||
/**
|
||||
* resolveSite 的全量形态(T2 主题联动):不出站、不裁字段,供用户面站点信息端点
|
||||
* 取 CMS 主题 raw 透传值;不可达/不存在返回 null。
|
||||
*/
|
||||
SvcModels.SiteOptionVo findOption(String siteCode);
|
||||
|
||||
/** 本校可选站点列表(管理端站点选择器;不可达抛业务异常) */
|
||||
List<SvcModels.SiteOptionVo> listSites(Long schId);
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yunhai.svchall.service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* svc-hall 流程业务类型(bizType)终值与存量兼容口径(P3.2 方案B,用户裁决 2026-10-19)。
|
||||
* <p>
|
||||
* 终值以 S0-H1 实核为准:UserBase application_info appKey=100008(svc-hall 生产出站身份)
|
||||
* 注册行 app_code = {@code svc-hall}。存量值 {@code svc_item} 是 2026-10-19 前建件的出站值;
|
||||
* S0-H2 实测中心 flw_instance 已无 svc_item 行,读路径双查属防御性兼容(防未来存量事件重放/
|
||||
* 重提),收敛条件=另一类型不再可能产生事件(覆盖 0 态重试/30/40 重提/积压核清),本轮不收敛单值。
|
||||
*/
|
||||
public final class SvcBizTypes {
|
||||
|
||||
/** 终值(新件出站 + 读路径首查键) */
|
||||
public static final String CURRENT = "svc-hall";
|
||||
|
||||
/** 存量值(2026-10-19 前建件;读路径次查键,回调白名单双值之一) */
|
||||
public static final String LEGACY = "svc_item";
|
||||
|
||||
private SvcBizTypes() {
|
||||
}
|
||||
|
||||
/** 读路径双查序:先终值后存量,结果按序合并(方案B 双值双查补丁的统一取值点) */
|
||||
public static List<String> queryOrder() {
|
||||
return Arrays.asList(CURRENT, LEGACY);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -72,16 +72,17 @@ public class CmsSiteDirectoryImpl implements SiteDirectory {
|
||||
}
|
||||
|
||||
@Override
|
||||
public SiteInfo resolveSite(String siteCode) {
|
||||
public SvcModels.SiteOptionVo findOption(String siteCode) {
|
||||
if (siteCode == null || siteCode.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
// 站点列表按 schId 下发,siteCode → schId 需要反向索引:本地缓存"siteCode → 站点"二级缓存
|
||||
// 站点列表按 schId 下发,siteCode → 站点需要反向索引:本地缓存"siteCode → 站点"二级缓存
|
||||
List<SvcModels.SiteOptionVo> sites = siteCache.getIfPresent(ALL_SITES_KEY);
|
||||
if (sites == null) {
|
||||
try {
|
||||
// 不带 schId = CMS 返回全部启用站点(跨校全量;P0-1 对齐后契约,出参键=siteCode/name/schId,
|
||||
// 由 CmsSiteDirectoryImplTest 冻结)。仅用于 siteCode 反解,权限判定仍走 requireSchoolMember。
|
||||
// 不带 schId = CMS 返回全部启用站点(跨校全量;P0-1 对齐后契约,出参键=siteCode/name/schId
|
||||
// /theme/themeParams,由 CmsSiteDirectoryImplTest 冻结)。仅用于 siteCode 反解与
|
||||
// 用户面站点信息端点,权限判定仍走 requireSchoolMember。
|
||||
sites = fetchSites(null);
|
||||
} catch (Exception e) {
|
||||
log.warn("[svc-hall] CMS 站点目录不可达:{}{}", e.getMessage(), rootCauseBrief(e));
|
||||
@@ -97,12 +98,20 @@ public class CmsSiteDirectoryImpl implements SiteDirectory {
|
||||
String code = siteCode.trim();
|
||||
for (SvcModels.SiteOptionVo site : sites) {
|
||||
if (code.equals(site.getSiteCode())) {
|
||||
return new SiteInfo(site.getSiteCode(), site.getName(), site.getSchId());
|
||||
return site;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SiteInfo resolveSite(String siteCode) {
|
||||
SvcModels.SiteOptionVo option = findOption(siteCode);
|
||||
// SiteInfo 三字段结构不动(T2 决策):主题字段只经 findOption 供用户面端点取用
|
||||
return option == null ? null
|
||||
: new SiteInfo(option.getSiteCode(), option.getName(), option.getSchId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SvcModels.SiteOptionVo> listSites(Long schId) {
|
||||
if (schId == null) {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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>本类<b>整体条件装配</b>:只有显式配了 {@code spring.rabbitmq.host} 才存在(与
|
||||
* {@link FcAnswerMqConfiguration} 同一条件表达式)。未接 MQ 的环境里 bean 不创建,
|
||||
* {@code @RabbitListener} 端点自然不被注册——"MQ 可选"必须落到启动期:注解处理器在
|
||||
* 容器工厂缺失时会直接抛 {@code NoSuchBeanDefinitionException} 打断启动,不会优雅跳过。
|
||||
* 重报扫描随之停摆是安全的:{@code fc_answer_inbox} 的唯一写入口是 {@code #onMessage}
|
||||
* (经 {@code AnswerProjectionStore#applySnapshot}),没消费就没台账,扫描没有可做的事;
|
||||
* 可信建件的第二条路径(签名收据确认)不经过本类。</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
|
||||
@org.springframework.boot.autoconfigure.condition.ConditionalOnExpression(
|
||||
"!'${spring.rabbitmq.host:}'.trim().isEmpty()")
|
||||
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,535 @@
|
||||
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) {
|
||||
return createCommand(apply, intentId, payloadJson, 1);
|
||||
}
|
||||
|
||||
private FlowApplyCommand createCommand(ServiceApply apply, Integer intentId, String payloadJson, int minimumAttempt) {
|
||||
List<FlowApplyCommand> history = commandRepository.findByApplyIdOrderByIdDesc(apply.getId());
|
||||
int attempt = Math.max(minimumAttempt, 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 requestExecution(Integer applyId, boolean resubmit) {
|
||||
FlowApplyCommand selected = transactionTemplate.execute(tx -> {
|
||||
ServiceApply apply = applyRepository.findByIdForUpdate(applyId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
|
||||
List<FlowApplyCommand> history = commandRepository.findByApplyIdOrderByIdDesc(applyId);
|
||||
int localRound = FlowRoundContract.localRound(apply, history, objectMapper);
|
||||
if (resubmit && !java.util.Arrays.asList(30, 40).contains(apply.getFlowStatus())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "仅驳回/已撤回状态的办件可重新提交");
|
||||
}
|
||||
FlowApplyCommand reusable = history.stream()
|
||||
.filter(c -> !resubmit || (intOf(c.getAttempt()) > localRound
|
||||
&& !FlowApplyCommand.STATE_DONE.equals(c.getState()) && !FlowApplyCommand.STATE_DEAD.equals(c.getState())))
|
||||
.max(java.util.Comparator.comparingInt(c -> intOf(c.getAttempt()))).orElse(null);
|
||||
if (reusable != null) return reusable;
|
||||
return createCommand(apply, apply.getFcIntentId(), payloadJson(apply), resubmit ? localRound + 1 : 1);
|
||||
});
|
||||
if (FlowApplyCommand.STATE_DONE.equals(selected.getState())) probe(selected);
|
||||
else runCommand(selected.getId());
|
||||
}
|
||||
|
||||
/** 事务提交后唤醒派发;无活动事务(单测/兜底)就地异步跑 */
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/** 周期恢复扫描;领取短锁不覆盖 HTTP,重复出站由 FL token/轮次守卫吸收。 */
|
||||
@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 {
|
||||
transactionTemplate.executeWithoutResult(tx -> {
|
||||
FlowApplyCommand row = commandRepository.findById(command.getId()).orElseThrow(IllegalStateException::new);
|
||||
row.setSentAt(row.getSentAt() == null ? LocalDateTime.now() : row.getSentAt());
|
||||
commandRepository.save(row);
|
||||
});
|
||||
FlowClient.ApplyResult result = flowClient.applyWithIdentity(command.getSchId(), outbound);
|
||||
if (result == null || !FlowRoundContract.knownBranch(result.getBranch())) {
|
||||
throw new IllegalStateException("FL_APPLY_CONTRACT:缺少或未知 branch,必须先查询真实状态");
|
||||
}
|
||||
int round = result.getApplyRound() == null ? 1 : result.getApplyRound();
|
||||
if (result.getStatus() == null || result.getVersion() == null) {
|
||||
Map<String, Object> remote = flowClient.getInstanceByBiz(command.getSchId(), command.getBizType(), command.getBizId());
|
||||
completeFromQuery(command, remote, result.getInstanceId(), result.getBranch());
|
||||
} else {
|
||||
String remoteToken = round == intOf(command.getAttempt())
|
||||
&& ("CREATED".equals(result.getBranch()) || "RESTARTED".equals(result.getBranch())
|
||||
|| "SAME_REQUEST".equals(result.getBranch())) ? command.getRequestToken() : null;
|
||||
completeRemote(command, result.getInstanceId(), result.getStatus(), result.getVersion(),
|
||||
round, remoteToken, result.getBranch());
|
||||
}
|
||||
|
||||
} 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, true);
|
||||
return;
|
||||
}
|
||||
log.info("[svc-hall] 远端查无该业务键,补发同业务键同 token:commandId={} bizId={}",
|
||||
command.getId(), command.getBizId());
|
||||
outboundRetryOnly(command);
|
||||
send(command);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
completeFromQuery(command, remote, null, null);
|
||||
} catch (RuntimeException e) {
|
||||
bump(command, "远端状态/轮次不可确认,继续查询:" + e.getMessage(), true);
|
||||
}
|
||||
}
|
||||
|
||||
private void outboundRetryOnly(FlowApplyCommand command) {
|
||||
try {
|
||||
com.fasterxml.jackson.databind.node.ObjectNode payload = (com.fasterxml.jackson.databind.node.ObjectNode) objectMapper.readTree(command.getPayloadJson());
|
||||
payload.put("retryOnly", true);
|
||||
command.setPayloadJson(objectMapper.writeValueAsString(payload));
|
||||
transactionTemplate.executeWithoutResult(tx -> commandRepository.save(command));
|
||||
} catch (Exception e) { throw new IllegalStateException("恢复载荷不可更新", e); }
|
||||
}
|
||||
|
||||
private void completeFromQuery(FlowApplyCommand command, Map<String, Object> remote,
|
||||
Long expectedInstance, String branch) {
|
||||
if (remote == null) throw new IllegalStateException("FL_INSTANCE_CONTRACT:无实例");
|
||||
Long id = firstLong(remote.get("instanceId"), remote.get("id"));
|
||||
if (expectedInstance != null && !expectedInstance.equals(id)) {
|
||||
throw new IllegalStateException("FL_INSTANCE_CONTRACT:apply 与查询实例不符");
|
||||
}
|
||||
Integer round = firstInt(remote.get("applyRound"));
|
||||
String token = remote.get("requestToken") == null ? null : String.valueOf(remote.get("requestToken"));
|
||||
boolean ownedRound = round != null && token != null && commandRepository.findByApplyIdOrderByIdDesc(command.getApplyId())
|
||||
.stream().anyMatch(c -> round.equals(c.getAttempt()) && token.equals(c.getRequestToken())
|
||||
&& java.util.Objects.equals(c.getSchId(), command.getSchId())
|
||||
&& java.util.Objects.equals(c.getFlowGuid(), command.getFlowGuid())
|
||||
&& (c.getRemoteInstanceId() == null || c.getRemoteInstanceId().equals(id)));
|
||||
if (!ownedRound) throw new IllegalStateException("FL_INSTANCE_CONTRACT:查询轮次/标识无法匹配本地命令");
|
||||
completeRemote(command, id, firstInt(remote.get("status")), firstInt(remote.get("version")), round, token, branch);
|
||||
}
|
||||
|
||||
private void completeRemote(FlowApplyCommand command, Long instanceId, Integer status, Integer version,
|
||||
int remoteRound, String remoteToken, String branch) {
|
||||
if (instanceId == null || instanceId < 1 || version == null || version < 0 || remoteRound < 1
|
||||
|| !java.util.Arrays.asList(10, 20, 30, 40).contains(status)) {
|
||||
throw new IllegalStateException("FL_INSTANCE_CONTRACT:缺少合法实例/版本/状态/轮次");
|
||||
}
|
||||
if (("CREATED".equals(branch) || "RESTARTED".equals(branch)) && remoteRound != intOf(command.getAttempt())) {
|
||||
throw new IllegalStateException("FL_APPLY_CONTRACT:新轮次与请求 attempt 不符");
|
||||
}
|
||||
transactionTemplate.executeWithoutResult(tx -> {
|
||||
FlowApplyCommand row = commandRepository.findByIdForUpdate(command.getId()).orElseThrow(IllegalStateException::new);
|
||||
int confirmedRound = FlowRoundContract.outcome(row, objectMapper).path("applyRound").asInt(0);
|
||||
if (confirmedRound > remoteRound || (confirmedRound == remoteRound && row.getRemoteVersion() != null
|
||||
&& row.getRemoteVersion() > version)) return; // 较旧 HTTP 响应不能倒写命令台账。
|
||||
if (confirmedRound == remoteRound && version.equals(row.getRemoteVersion()) && row.getRemoteStatus() != null
|
||||
&& ApplyFlowStatusEnum.byCode(row.getRemoteStatus()).terminal() && !status.equals(row.getRemoteStatus())) return;
|
||||
ServiceApply apply = applyRepository.findByIdForUpdate(command.getApplyId()).orElseThrow(IllegalStateException::new);
|
||||
if (apply.getFlowInstanceId() != null && !apply.getFlowInstanceId().equals(instanceId)) {
|
||||
throw new IllegalStateException("FL_INSTANCE_CONTRACT:实例归属与本地绑定不符");
|
||||
}
|
||||
int localRound = FlowRoundContract.localRound(apply,
|
||||
commandRepository.findByApplyIdOrderByIdDesc(command.getApplyId()), objectMapper);
|
||||
int localVersion = apply.getLastInstanceVersion() == null ? 0 : apply.getLastInstanceVersion();
|
||||
boolean provedNewRound = remoteRound > localRound && remoteRound == intOf(command.getAttempt())
|
||||
&& command.getRequestToken().equals(remoteToken)
|
||||
&& (branch == null || "CREATED".equals(branch) || "RESTARTED".equals(branch) || "SAME_REQUEST".equals(branch));
|
||||
boolean reopen = ApplyFlowStatusEnum.byCode(apply.getFlowStatus()).terminal() && status == 10;
|
||||
boolean sameVersionConflict = version == localVersion
|
||||
&& ApplyFlowStatusEnum.byCode(apply.getFlowStatus()).terminal() && !status.equals(apply.getFlowStatus());
|
||||
if (remoteRound >= localRound && version >= localVersion && !sameVersionConflict
|
||||
&& (!reopen || (provedNewRound && version > localVersion))) {
|
||||
boolean advanceRound = remoteRound > localRound;
|
||||
boolean sameVersionTerminal = version == localVersion && ApplyFlowStatusEnum.byCode(apply.getFlowStatus()).terminal();
|
||||
apply.setFlowInstanceId(instanceId);
|
||||
apply.setFlowStatus(status);
|
||||
apply.setLastInstanceVersion(version);
|
||||
apply.setSubmitTime(apply.getSubmitTime() == null || advanceRound ? LocalDateTime.now() : apply.getSubmitTime());
|
||||
apply.setFinishTime(status == 10 ? null : (apply.getFinishTime() == null || advanceRound ? LocalDateTime.now() : apply.getFinishTime()));
|
||||
try {
|
||||
com.fasterxml.jackson.databind.node.ObjectNode result = !advanceRound || sameVersionTerminal
|
||||
? (com.fasterxml.jackson.databind.node.ObjectNode) objectMapper.readTree(
|
||||
apply.getFlowResultJson() == null ? "{}" : apply.getFlowResultJson())
|
||||
: objectMapper.createObjectNode();
|
||||
result.put("applyRound", remoteRound);
|
||||
apply.setFlowResultJson(objectMapper.writeValueAsString(result));
|
||||
} catch (Exception e) { throw new IllegalStateException("本地轮次证据不可写入", e); }
|
||||
applyRepository.save(apply);
|
||||
}
|
||||
try {
|
||||
com.fasterxml.jackson.databind.node.ObjectNode payload = (com.fasterxml.jackson.databind.node.ObjectNode)
|
||||
objectMapper.readTree(row.getPayloadJson());
|
||||
com.fasterxml.jackson.databind.node.ObjectNode outcome = payload.putObject("_flOutcome");
|
||||
outcome.put("branch", branch); outcome.put("applyRound", remoteRound);
|
||||
outcome.put("status", status); outcome.put("version", version); outcome.put("instanceId", instanceId);
|
||||
outcome.put("requestToken", remoteToken);
|
||||
row.setPayloadJson(objectMapper.writeValueAsString(payload));
|
||||
} catch (Exception e) { throw new IllegalStateException("远端分支证据不可写入", e); }
|
||||
row.setRemoteInstanceId(instanceId); row.setRemoteStatus(status); row.setRemoteVersion(version);
|
||||
row.setState(FlowApplyCommand.STATE_DONE); row.setSettledAt(LocalDateTime.now());
|
||||
row.setNextRetryAt(null); row.setLastError(null);
|
||||
commandRepository.save(row);
|
||||
});
|
||||
}
|
||||
|
||||
private static int intOf(Integer value) { return value == null ? 0 : value; }
|
||||
|
||||
// ==================== 状态落定 ====================
|
||||
|
||||
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) {
|
||||
bump(command, error, false);
|
||||
}
|
||||
|
||||
private void bump(FlowApplyCommand command, String error, boolean remainUnknown) {
|
||||
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(remainUnknown ? FlowApplyCommand.STATE_UNKNOWN : 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,13 +6,13 @@ 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;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -25,9 +25,9 @@ import java.util.Map;
|
||||
*
|
||||
* <p>主路径走 SDK 类型化门面 {@code YschFlowOpenApi}(服务目录 key=flow-center:
|
||||
* yh.call.targets > yh.open.services > 枚举 base-host 兜底,《子应用配置优化方案》§4.2
|
||||
* 零专属地址配置);目录未显式配置时回落 legacy 键 {@code svchall.flow.base-url}(ad-hoc
|
||||
* 零专属地址配置);目录未显式配置且提供 legacy 时回落 legacy 键 {@code svchall.flow.base-url}(ad-hoc
|
||||
* TargetSpec,可配带/不带 context-path 两种形态,构造时拆成 host+basePath,迁移期兼容)。
|
||||
* 两条路径的 per-target base-path 剥离语义一致(同 {@link CmsSiteDirectoryImpl})。
|
||||
* 无 legacy 时使用 SDK 可解析目标。两条路径的 per-target base-path 剥离语义一致(同 {@link CmsSiteDirectoryImpl})。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -37,7 +37,7 @@ public class FlowCenterClient implements FlowClient {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final boolean enabled;
|
||||
private final TargetSpec flowSpec;
|
||||
/** 服务目录显式配置了 flow-center(targets/services)则主路径走门面命名目标 */
|
||||
/** 显式目录或无 legacy 时 SDK 可解析 flow-center,则走门面命名目标 */
|
||||
private final boolean directoryResolved;
|
||||
|
||||
public FlowCenterClient(YschOpenCall openCall, ObjectMapper objectMapper,
|
||||
@@ -47,8 +47,11 @@ public class FlowCenterClient implements FlowClient {
|
||||
this.objectMapper = objectMapper;
|
||||
this.enabled = enabled;
|
||||
String base = trimTrailing(baseUrl == null ? "" : baseUrl.trim());
|
||||
this.directoryResolved = openCall != null && openCall.hasTarget(
|
||||
com.yunhai.platform.openapi.core.PlatformService.FLOW_CENTER.key());
|
||||
// 保持 explicit > legacy;没有 legacy 时允许 SDK 枚举/标准网关解析。
|
||||
this.directoryResolved = openCall != null && (openCall.hasTarget(
|
||||
com.yunhai.platform.openapi.core.PlatformService.FLOW_CENTER.key())
|
||||
|| (base.isEmpty() && openCall.canResolve(
|
||||
com.yunhai.platform.openapi.core.PlatformService.FLOW_CENTER.key())));
|
||||
if (directoryResolved) {
|
||||
com.yunhai.platform.openapi.flow.YschFlowOpenApi.setCall(openCall);
|
||||
}
|
||||
@@ -57,12 +60,9 @@ public class FlowCenterClient implements FlowClient {
|
||||
// enabled=false(显式未接入)时不校验——地址本就不需要。
|
||||
throw new IllegalStateException("流程中心调用地址未配置:请配置服务目录 yh.open.services."
|
||||
+ com.yunhai.platform.openapi.core.PlatformService.FLOW_CENTER.key()
|
||||
+ "(推荐,容器直连完整 URL)或回落键 svchall.flow.base-url");
|
||||
+ ",或平台网关 yh.open.base-host,或回落键 svchall.flow.base-url");
|
||||
}
|
||||
URI uri = URI.create(base);
|
||||
String host = uri.getScheme() + "://" + uri.getRawAuthority();
|
||||
String path = uri.getRawPath() == null ? "" : uri.getRawPath();
|
||||
this.flowSpec = TargetSpec.of(host, path);
|
||||
this.flowSpec = !enabled || directoryResolved || base.isEmpty() ? null : TargetSpec.fromUrl(base);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -137,6 +137,43 @@ 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.path("duplicated").asBoolean(), data.path("branch").asText(null),
|
||||
v2Integer(data, "applyRound"), v2Integer(data, "status"), v2Integer(data, "version"));
|
||||
}
|
||||
|
||||
private static Integer v2Integer(JsonNode data, String key) {
|
||||
JsonNode value = data.get(key);
|
||||
if (value == null || value.isNull()) return null;
|
||||
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "FL_APPLY_CONTRACT:" + key + " 必须为整数");
|
||||
}
|
||||
return value.intValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getInstanceByBiz(Long schId, String bizType, String bizId) {
|
||||
requireEnabled();
|
||||
@@ -161,9 +198,10 @@ public class FlowCenterClient implements FlowClient {
|
||||
body.setFlowGuid(flowGuid);
|
||||
body.setCallbackUrl(callbackUrl);
|
||||
body.setEventTypes(eventTypes);
|
||||
// 申报本服务 context-path(从回调 URL path 段推导,去掉尾部 /callback/flow):
|
||||
// 申报本服务 context-path(从回调 URL path 段推导,去掉尾部 /oapi/flow/callback):
|
||||
// 推送方按它剥离后再签应用签名,与本服务验签过滤器剥
|
||||
// contextPath 后的 target 对齐——本地裸地址(无 ctx)推导为空串,行为不变(不下发空串)。
|
||||
// 生产注册路径由 starter FlowCallbackRegistrar 承担(basePath 与 URL 前缀段同源推导)。
|
||||
String basePath = deriveCallbackBasePath(callbackUrl);
|
||||
if (basePath != null && !basePath.isEmpty()) {
|
||||
body.setBasePath(basePath);
|
||||
@@ -293,9 +331,9 @@ public class FlowCenterClient implements FlowClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调 URL → 接收方 context-path:取 URL path 段去掉尾部 "/callback/flow"
|
||||
* (如 /svc-hall/callback/flow → /svc-hall;裸地址 /callback/flow → 空串)。
|
||||
* 包可见:契约测试冻结推导形态。
|
||||
* 回调 URL → 接收方 context-path:取 URL path 段去掉尾部 "/oapi/flow/callback"
|
||||
* (如 /svc-hall/oapi/flow/callback → /svc-hall;裸地址 → 空串)。
|
||||
* 包可见:契约测试冻结推导形态(2026-10-05 随回调端点收敛 Notice 形态同步)。
|
||||
*/
|
||||
static String deriveCallbackBasePath(String callbackUrl) {
|
||||
if (callbackUrl == null) {
|
||||
@@ -306,7 +344,7 @@ public class FlowCenterClient implements FlowClient {
|
||||
if (path == null || path.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
int idx = path.lastIndexOf("/callback/flow");
|
||||
int idx = path.lastIndexOf("/oapi/flow/callback");
|
||||
if (idx > 0) {
|
||||
return path.substring(0, idx);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.svchall.entity.FlowApplyCommand;
|
||||
import com.yunhai.svchall.entity.ServiceApply;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/** 同一处解析持久轮次证据;不把本地请求 attempt 当成远端已接受轮次。 */
|
||||
final class FlowRoundContract {
|
||||
private FlowRoundContract() { }
|
||||
|
||||
static boolean knownBranch(String branch) {
|
||||
return Arrays.asList("CREATED", "IDEMPOTENT", "SAME_REQUEST", "STALE_ROUND", "REPLAY_ONLY",
|
||||
"RESTARTED", "CONCURRENT_CREATED").contains(branch);
|
||||
}
|
||||
|
||||
static JsonNode outcome(FlowApplyCommand command, ObjectMapper mapper) {
|
||||
try { return mapper.readTree(command.getPayloadJson()).path("_flOutcome"); }
|
||||
catch (Exception e) { return mapper.createObjectNode(); }
|
||||
}
|
||||
|
||||
static int localRound(ServiceApply apply, List<FlowApplyCommand> history, ObjectMapper mapper) {
|
||||
int round = 1;
|
||||
try { round = Math.max(round, mapper.readTree(apply.getFlowResultJson()).path("applyRound").asInt(1)); }
|
||||
catch (Exception ignored) { }
|
||||
for (FlowApplyCommand command : history) {
|
||||
if (apply.getFlowInstanceId() != null && apply.getFlowInstanceId().equals(command.getRemoteInstanceId())) {
|
||||
round = Math.max(round, outcome(command, mapper).path("applyRound").asInt(1));
|
||||
}
|
||||
}
|
||||
return round;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -16,13 +19,13 @@ import com.yunhai.svchall.model.svc.SvcModels.ApplyVo;
|
||||
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
||||
import com.yunhai.svchall.repository.ServiceItemRepository;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import com.yunhai.svchall.service.SvcBizTypes;
|
||||
import com.yunhai.svchall.service.SiteDirectory;
|
||||
import com.yunhai.svchall.service.SiteDirectory.SiteInfo;
|
||||
import com.yunhai.svchall.service.SiteGuard;
|
||||
import com.yunhai.svchall.utils.FormValidator;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -68,9 +71,121 @@ public class ServiceApplyService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final org.springframework.transaction.support.TransactionTemplate transactionTemplate;
|
||||
|
||||
/** 办事大厅前端对外地址(bizUrl 落地页用;projman 注入,本地默认 dev server) */
|
||||
@Value("${svchall.front.base-url:http://localhost:8087}")
|
||||
private String frontBaseUrl;
|
||||
/**
|
||||
* 出站命令与恢复服务(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)=====
|
||||
|
||||
@@ -118,7 +233,7 @@ public class ServiceApplyService {
|
||||
apply.setItemId(item.getId());
|
||||
apply.setItemCode(item.getItemCode());
|
||||
apply.setItemTitle(item.getTitle());
|
||||
apply.setItemSnapshotJson(itemSnapshotJson(item));
|
||||
apply.setItemSnapshotJson(itemSnapshotJson(item, user.getName()));
|
||||
apply.setFormVersion(form == null ? null : form.getVersion());
|
||||
apply.setFormDataJson(req.getFormData());
|
||||
apply.setSubmitterId(user.getId());
|
||||
@@ -128,7 +243,7 @@ public class ServiceApplyService {
|
||||
|
||||
// 办件材料绑定(必须落库拿到 applyId 之后再绑)
|
||||
attachmentService.bindApply(site.getSchId(), apply.getId(), req.getAttachments());
|
||||
apply.setItemSnapshotJson(itemSnapshotJson(item, attachmentService
|
||||
apply.setItemSnapshotJson(itemSnapshotJson(item, apply.getSubmitterName(), attachmentService
|
||||
.applyAttachments(site.getSchId(), apply.getId())));
|
||||
apply = applyRepository.save(apply);
|
||||
|
||||
@@ -204,7 +319,7 @@ public class ServiceApplyService {
|
||||
if (apply.getFlowInstanceId() == null) {
|
||||
SvcModels.FlowProgressVo empty = new SvcModels.FlowProgressVo();
|
||||
empty.setInstanceId(null);
|
||||
empty.setBizType(SvcFlowCallbackHandler.BIZ_TYPE);
|
||||
empty.setBizType(SvcBizTypes.CURRENT);
|
||||
empty.setBizId(String.valueOf(apply.getId()));
|
||||
empty.setBizTitle(apply.getItemTitle());
|
||||
empty.setStatus(apply.getFlowStatus());
|
||||
@@ -332,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());
|
||||
@@ -451,6 +572,13 @@ public class ServiceApplyService {
|
||||
}
|
||||
ServiceApply apply = applyRepository.findById(applyId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
|
||||
if (flowCommands != null) {
|
||||
flowCommands.requestExecution(applyId, resubmit);
|
||||
return; // 生产装配一律消费 v2 分支,不能回落到旧 apply + 常量 10。
|
||||
}
|
||||
if (apply.getFcIntentId() != null || apply.getFcAnswerUuid() != null) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "可信办件命令服务未装配,拒绝走旧发起路径");
|
||||
}
|
||||
try {
|
||||
FlowClient.ApplyResult result = flowClient.apply(
|
||||
apply.getSchId(),
|
||||
@@ -458,9 +586,12 @@ public class ServiceApplyService {
|
||||
SvcFlowCallbackHandler.BIZ_TYPE,
|
||||
String.valueOf(apply.getId()),
|
||||
apply.getSubmitterName() + "的" + apply.getItemTitle(),
|
||||
// 前端详情页是 hash 路由 + query 传参(routes.ts path="/apply-detail"、
|
||||
// 办件落地页传裸相对路径(2026-09-26 自指地址优化 Phase 2):绝对化由接收方
|
||||
// 按页面归属查基址承担——解析键=flw_instance.apply_app_code(apply 时由验签身份
|
||||
// appKey 按注册表换得的 app_code 快照),与 bizType 是两个字段、互不影响。
|
||||
// 路径形态仍须 hash 路由 + query 传参(routes.ts path="/apply-detail"、
|
||||
// Apply.vue router.replace("/apply-detail?id=")):路径参数形态会 no-match 空白页
|
||||
frontBaseUrl + "/#/apply-detail?id=" + apply.getId(),
|
||||
"/apply-detail?id=" + apply.getId(),
|
||||
apply.getItemSnapshotJson(),
|
||||
apply.getSubmitterId(),
|
||||
apply.getSubmitterName(),
|
||||
@@ -469,6 +600,14 @@ public class ServiceApplyService {
|
||||
// 无外层事务,用模板事务包住窄列回填( markApproving/markResubmitted)
|
||||
final Integer applyIdFinal = apply.getId();
|
||||
final Long instanceId = result.getInstanceId();
|
||||
// P1-02 回填侧校验:行已有镜像实例而中心返回不同实例 = 第二实例产生(方案B 书面
|
||||
// 接受的存量降级:R4 按 bizType 键 miss 新建)。仅告警留痕不阻断——阻断重提没有
|
||||
// 意义(实例已建),镜像以新实例为准回填。
|
||||
if (apply.getFlowInstanceId() != null && !apply.getFlowInstanceId().equals(instanceId)) {
|
||||
log.warn("[svc-hall] 代发起返回实例与行镜像不一致(第二实例,方案B 已接受降级):"
|
||||
+ "applyId={} oldInstance={} newInstance={}",
|
||||
applyIdFinal, apply.getFlowInstanceId(), instanceId);
|
||||
}
|
||||
transactionTemplate.executeWithoutResult(tx -> {
|
||||
if (resubmit) {
|
||||
applyRepository.markResubmitted(applyIdFinal, instanceId, LocalDateTime.now());
|
||||
@@ -502,11 +641,12 @@ public class ServiceApplyService {
|
||||
}
|
||||
|
||||
/** 事项要点快照(= biz_snapshot 内容源;附件清单仅文件名——flow-center biz_snapshot 内容边界) */
|
||||
private String itemSnapshotJson(ServiceItem item) {
|
||||
return itemSnapshotJson(item, new ArrayList<>());
|
||||
private String itemSnapshotJson(ServiceItem item, String submitterName) {
|
||||
return itemSnapshotJson(item, submitterName, new ArrayList<>());
|
||||
}
|
||||
|
||||
private String itemSnapshotJson(ServiceItem item, List<com.yunhai.svchall.model.svc.SvcModels.SvcItemAttachmentVo> attachments) {
|
||||
private String itemSnapshotJson(ServiceItem item, String submitterName,
|
||||
List<com.yunhai.svchall.model.svc.SvcModels.SvcItemAttachmentVo> attachments) {
|
||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||
snapshot.put("itemTitle", item.getTitle());
|
||||
snapshot.put("summary", item.getSummary());
|
||||
@@ -518,6 +658,17 @@ public class ServiceApplyService {
|
||||
names.add(a.getFileName());
|
||||
}
|
||||
snapshot.put("attachmentNames", names);
|
||||
|
||||
// 约定键:嵌入页「业务信息」唯一渲染源,只保留有值行
|
||||
List<Map<String, Object>> summaryFields = new ArrayList<>();
|
||||
appendSummaryField(summaryFields, "申请人", submitterName);
|
||||
appendSummaryField(summaryFields, "所属部门", item.getDeptName());
|
||||
appendSummaryField(summaryFields, "办结时限", item.getHandleTimeLimit());
|
||||
appendSummaryField(summaryFields, "联系电话", item.getContactPhone());
|
||||
if (!summaryFields.isEmpty()) {
|
||||
snapshot.put("summaryFields", summaryFields);
|
||||
}
|
||||
|
||||
try {
|
||||
return objectMapper.writeValueAsString(snapshot);
|
||||
} catch (Exception e) {
|
||||
@@ -526,6 +677,17 @@ public class ServiceApplyService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 值为空白则不追加(避免嵌入页出现 "-" 行;四项全空则不写 summaryFields 键) */
|
||||
private void appendSummaryField(List<Map<String, Object>> fields, String label, String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> field = new LinkedHashMap<>();
|
||||
field.put("label", label);
|
||||
field.put("value", value);
|
||||
fields.add(field);
|
||||
}
|
||||
|
||||
private Long userSchId(BoaUser user) {
|
||||
if (user == null || user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "当前用户无学校归属,无法操作办件");
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.yunhai.svchall.entity.ServiceItem;
|
||||
import com.yunhai.svchall.enums.HandleModeEnum;
|
||||
import com.yunhai.svchall.enums.ServiceDictTypeEnum;
|
||||
import com.yunhai.svchall.enums.ServiceItemStatusEnum;
|
||||
import com.yunhai.svchall.model.hall.HallModels;
|
||||
import com.yunhai.svchall.model.oapi.OapiModels;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.DeleteRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.IdSortItem;
|
||||
@@ -93,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) {
|
||||
@@ -195,9 +212,25 @@ 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 两种空值
|
||||
item.setCoopDepts(emptyToNull(req.getCoopDepts()));
|
||||
item.setHandlerName(emptyToNull(req.getHandlerName()));
|
||||
item.setHandleTimeDesc(emptyToNull(req.getHandleTimeDesc()));
|
||||
item.setRunCount(req.getRunCount());
|
||||
item.setExpressFlag(req.getExpressFlag());
|
||||
item.setFeeDesc(emptyToNull(req.getFeeDesc()));
|
||||
item.setFeeBasis(emptyToNull(req.getFeeBasis()));
|
||||
item.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
|
||||
item = itemRepository.save(item);
|
||||
|
||||
@@ -389,11 +422,82 @@ public class ServiceItemService {
|
||||
row.setHandleMode(item.getHandleMode());
|
||||
row.setOnlineFlag(item.getOnlineFlag());
|
||||
row.setPublishTime(item.getPublishTime());
|
||||
// 一期契约 §3.1 追加字段:纯文本/数字,不做地址绝对化(coverImage 才是路径)
|
||||
row.setApplyUrl(item.getApplyUrl());
|
||||
row.setPromiseTimeLimit(item.getPromiseTimeLimit());
|
||||
row.setHandleTimeLimit(item.getHandleTimeLimit());
|
||||
row.setRunCount(item.getRunCount());
|
||||
row.setExpressFlag(item.getExpressFlag());
|
||||
row.setCoopDepts(item.getCoopDepts());
|
||||
row.setHandlerName(item.getHandlerName());
|
||||
row.setHandleTimeDesc(item.getHandleTimeDesc());
|
||||
row.setFeeDesc(item.getFeeDesc());
|
||||
row.setFeeBasis(item.getFeeBasis());
|
||||
data.getItems().add(row);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// ===== 用户面事项指南(/api/hall/item,D7 匿名只读)=====
|
||||
|
||||
/**
|
||||
* 匿名事项指南(一期契约 §3.2):siteCode + itemCode 均必填,只返回已发布事项。
|
||||
* 租户由 siteCode 经站点目录解析(游客无 BoaUser,不做成员校验——匿名只读,权限面即 siteCode 本身);
|
||||
* 出参刻意不含 formCode/flowGuid 与任何办件人/办件量信息(§1 匿名端点约束)。
|
||||
*/
|
||||
public HallModels.ItemGuideVo hallItemGuide(String siteCode, String itemCode) {
|
||||
if (!StringUtils.hasText(siteCode) || !StringUtils.hasText(itemCode)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "siteCode 与 itemCode 均为必填");
|
||||
}
|
||||
SiteInfo site = siteDirectory.requireSite(siteCode.trim());
|
||||
ServiceItem item = itemRepository
|
||||
.findBySchIdAndSiteCodeAndItemCode(site.getSchId(), site.getSiteCode(), itemCode.trim())
|
||||
.orElse(null);
|
||||
if (item == null || item.getStatus() == null
|
||||
|| item.getStatus() != ServiceItemStatusEnum.PUBLISHED.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "事项不存在或未发布");
|
||||
}
|
||||
HallModels.ItemGuideVo vo = new HallModels.ItemGuideVo();
|
||||
vo.setId(item.getId());
|
||||
vo.setItemCode(item.getItemCode());
|
||||
vo.setTitle(item.getTitle());
|
||||
vo.setSummary(item.getSummary());
|
||||
vo.setObjectKey(item.getObjectKey());
|
||||
vo.setCategoryKey(item.getCategoryKey());
|
||||
vo.setCategoryName(item.getCategoryKey() == null ? null
|
||||
: dictService.nameMap(site.getSchId(), site.getSiteCode(), ServiceDictTypeEnum.CATEGORY)
|
||||
.get(item.getCategoryKey()));
|
||||
vo.setDeptName(item.getDeptName());
|
||||
vo.setHandleMode(item.getHandleMode());
|
||||
vo.setOnlineFlag(item.getOnlineFlag());
|
||||
vo.setApplyUrl(item.getApplyUrl());
|
||||
vo.setHandlePlace(item.getHandlePlace());
|
||||
vo.setHandleTimeLimit(item.getHandleTimeLimit());
|
||||
vo.setPromiseTimeLimit(item.getPromiseTimeLimit());
|
||||
vo.setContactPhone(item.getContactPhone());
|
||||
vo.setSupervisePhone(item.getSupervisePhone());
|
||||
vo.setCoverImage(FileUtil.absolutizePlain(item.getCoverImage()));
|
||||
vo.setViewCount(item.getViewCount());
|
||||
vo.setCoopDepts(item.getCoopDepts());
|
||||
vo.setHandlerName(item.getHandlerName());
|
||||
vo.setHandleTimeDesc(item.getHandleTimeDesc());
|
||||
vo.setRunCount(item.getRunCount());
|
||||
vo.setExpressFlag(item.getExpressFlag());
|
||||
vo.setFeeDesc(item.getFeeDesc());
|
||||
vo.setFeeBasis(item.getFeeBasis());
|
||||
vo.setProcessHtml(FileUtil.absolutize(item.getProcessHtml()));
|
||||
vo.setMaterialHtml(FileUtil.absolutize(item.getMaterialHtml()));
|
||||
vo.setNoticeHtml(FileUtil.absolutize(item.getNoticeHtml()));
|
||||
for (SvcItemAttachmentVo a : attachmentService.guideAttachments(site.getSchId(), item.getId())) {
|
||||
HallModels.Attachment attachment = new HallModels.Attachment();
|
||||
attachment.setFileName(a.getFileName());
|
||||
attachment.setFileUrl(FileUtil.absolutizePlain(a.getFileUrl()));
|
||||
attachment.setFileSize(a.getFileSize());
|
||||
vo.getAttachments().add(attachment);
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
public OapiModels.Filters oapiFilters(Long schId, String siteCode) {
|
||||
OapiModels.Filters filters = new OapiModels.Filters();
|
||||
Map<String, Long> objectCounts = countMap(itemRepository.countPublishedGroupByObjectKey(schId, siteCode));
|
||||
@@ -660,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) {
|
||||
@@ -809,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)) {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.core.PlatformService;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import com.yunhai.svchall.service.SvcBizTypes;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 应用内审批出站客户端(P3.1,沿 NoticeApprovalClient 形态):YschOpenCall 泛化调用打
|
||||
* flow-center {@code /oapi/flow/tasks|task|embed/**},走服务目录命名目标 flow-center
|
||||
* (yh.call.targets > yh.open.services,与 {@link FlowCenterClient} 同一条解析链)。
|
||||
* <p>
|
||||
* 与 Notice 的差异(P3.1 加严):
|
||||
* <ul>
|
||||
* <li>读面 bizType 不固定单值——{@link SvcBizTypes#queryOrder()} 双值双查(先终值后存量,
|
||||
* P3.2 方案B,防存量事件/重提漏查);</li>
|
||||
* <li>embed 记录页走 <b>instanceId</b> 形态(本地 flow_instance_id 镜像有值才可能换址成功,
|
||||
* bizType+bizId 形态在 bizType 换值后对存量件必然"办件不存在")。</li>
|
||||
* </ul>
|
||||
* 身份口径:operatorId/operatorName/schId 由调用方(controller)从 CAS 会话取,本类不猜身份;
|
||||
* 对端按任务办理人逐字复核。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SvcApprovalClient {
|
||||
|
||||
private static final String TARGET = PlatformService.FLOW_CENTER.key();
|
||||
|
||||
private final YschOpenCall openCall;
|
||||
private final FlowClient flowClient;
|
||||
|
||||
public SvcApprovalClient(ObjectProvider<YschOpenCall> openCallProvider, FlowClient flowClient) {
|
||||
this.openCall = openCallProvider.getIfAvailable();
|
||||
this.flowClient = flowClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的待办分页:双值双查后合并(同一 assignee 在两个 bizType 下都可能有历史待办的过渡期口径;
|
||||
* S0-H2 实测存量=0,合并退化为单查结果)。Map 形态即 flow-center BasePageResult JSON。
|
||||
*/
|
||||
public Map<String, Object> todoTasks(Integer schId, Integer assigneeId, Integer page, Integer size) {
|
||||
requireEnabled();
|
||||
List<Map<String, Object>> pages = new ArrayList<>();
|
||||
for (String bizType : SvcBizTypes.queryOrder()) {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("assigneeId", String.valueOf(assigneeId));
|
||||
query.put("bizType", bizType);
|
||||
if (page != null) {
|
||||
query.put("page", String.valueOf(page));
|
||||
}
|
||||
if (size != null) {
|
||||
query.put("size", String.valueOf(size));
|
||||
}
|
||||
pages.add(openCall.get(TARGET, "/oapi/flow/tasks/todo", query, Map.class));
|
||||
}
|
||||
return mergePages(pages, page, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前人在该办件上的待办(卡片数据源,{@code /oapi/flow/tasks/by-biz})。
|
||||
* <p>双值双查:一个 apply 行只可能命中其中一个 bizType 的实例——按序返回首个
|
||||
* {@code instanceId != null} 的应答;两者皆空(无实例/非办理人/错学校同形)返回首查(终值)
|
||||
* 应答,保持对端"空 tasks 不探测"语义。调用方不得据 tasks 反推他人待办。
|
||||
*/
|
||||
public Map<String, Object> byBiz(Integer schId, Integer operatorId, String bizId) {
|
||||
requireEnabled();
|
||||
Map<String, Object> first = null;
|
||||
for (String bizType : SvcBizTypes.queryOrder()) {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("bizType", bizType);
|
||||
query.put("bizId", bizId);
|
||||
query.put("operatorId", String.valueOf(operatorId));
|
||||
Map<String, Object> data = openCall.get(TARGET, "/oapi/flow/tasks/by-biz", query, Map.class);
|
||||
if (data != null && data.get("instanceId") != null) {
|
||||
return data;
|
||||
}
|
||||
if (first == null) {
|
||||
first = data;
|
||||
}
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的已办(P3.5):{@code /oapi/flow/tasks/done}。中心按<b>签名方应用身份</b>过滤
|
||||
* (d.appKey=本应用 appKey,由 oapi 端从验签层取),出站不传 bizType——天然覆盖新旧
|
||||
* bizType 存量(P2-02 接缝),双值双查在此端点不适用(过滤键不是 bizType)。
|
||||
*/
|
||||
public Map<String, Object> doneTasks(Integer schId, Integer assigneeId, Integer page, Integer size) {
|
||||
requireEnabled();
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("assigneeId", String.valueOf(assigneeId));
|
||||
if (page != null) {
|
||||
query.put("page", String.valueOf(page));
|
||||
}
|
||||
if (size != null) {
|
||||
query.put("size", String.valueOf(size));
|
||||
}
|
||||
return openCall.get(TARGET, "/oapi/flow/tasks/done", query, Map.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 抄送我的(P3.5):{@code /oapi/flow/cc}。过滤接缝同 {@link #doneTasks}(签名 appKey)。
|
||||
*/
|
||||
public Map<String, Object> ccMine(Integer schId, Integer userId, Integer page, Integer size) {
|
||||
requireEnabled();
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("userId", String.valueOf(userId));
|
||||
if (page != null) {
|
||||
query.put("page", String.valueOf(page));
|
||||
}
|
||||
if (size != null) {
|
||||
query.put("size", String.valueOf(size));
|
||||
}
|
||||
return openCall.get(TARGET, "/oapi/flow/cc", query, Map.class);
|
||||
}
|
||||
|
||||
/** 同意(opinion 可空;多出口 edgeName 透传) */
|
||||
public void approve(Long taskId, Integer schId, Integer operatorId, String operatorName,
|
||||
String opinion, String edgeName) {
|
||||
requireEnabled();
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
if (opinion != null && !opinion.trim().isEmpty()) {
|
||||
body.put("opinion", opinion.trim());
|
||||
}
|
||||
if (edgeName != null && !edgeName.trim().isEmpty()) {
|
||||
body.put("edgeName", edgeName.trim());
|
||||
}
|
||||
openCall.post(TARGET, "/oapi/flow/task/" + taskId + "/approve",
|
||||
operatorQuery(schId, operatorId, operatorName), body, Void.class);
|
||||
}
|
||||
|
||||
/** 拒绝(opinion 必填由 controller 前置校验;本方法为薄出站) */
|
||||
public void reject(Long taskId, Integer schId, Integer operatorId, String operatorName, String opinion) {
|
||||
requireEnabled();
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
if (opinion != null && !opinion.trim().isEmpty()) {
|
||||
body.put("opinion", opinion.trim());
|
||||
}
|
||||
openCall.post(TARGET, "/oapi/flow/task/" + taskId + "/reject",
|
||||
operatorQuery(schId, operatorId, operatorName), body, Void.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批记录抽屉地址(token 只读页,{@code /oapi/flow/embed/url?mode=record&instanceId=})。
|
||||
* instanceId 形态:本地 flow_instance_id 镜像即中心实例 ID,天然免疫 bizType 换值。
|
||||
*/
|
||||
public Map<String, Object> embedRecordUrl(Integer schId, Long instanceId) {
|
||||
requireEnabled();
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("mode", "record");
|
||||
query.put("instanceId", String.valueOf(instanceId));
|
||||
return openCall.get(TARGET, "/oapi/flow/embed/url", query, Map.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整办理任务页地址(CAS 会话页,无 token,{@code mode=task&taskId=})。
|
||||
* 任务页不定位办件(仅拼 taskId,免签 CAS 页),办理权由该页按 assigneeId 自校;
|
||||
* 调用方(controller)仍须先做 byBiz 归属预检,不把他人 taskId 散播到前端。
|
||||
*/
|
||||
public Map<String, Object> taskPageUrl(Integer schId, Long taskId) {
|
||||
requireEnabled();
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("mode", "task");
|
||||
query.put("taskId", String.valueOf(taskId));
|
||||
return openCall.get(TARGET, "/oapi/flow/embed/url", query, Map.class);
|
||||
}
|
||||
|
||||
/** 双查分页合并:total 求和、content 拼接、页元数据按首查重算(存量=0 时与单查逐字同形) */
|
||||
private static Map<String, Object> mergePages(List<Map<String, Object>> pages, Integer page, Integer size) {
|
||||
Map<String, Object> merged = new LinkedHashMap<>();
|
||||
long total = 0L;
|
||||
List<Object> content = new ArrayList<>();
|
||||
for (Map<String, Object> p : pages) {
|
||||
if (p == null) {
|
||||
continue;
|
||||
}
|
||||
if (p.get("total") instanceof Number) {
|
||||
total += ((Number) p.get("total")).longValue();
|
||||
}
|
||||
if (p.get("content") instanceof List) {
|
||||
content.addAll((List<?>) p.get("content"));
|
||||
}
|
||||
}
|
||||
merged.put("total", total);
|
||||
merged.put("content", content);
|
||||
int pageIndex = page == null ? 1 : page;
|
||||
int pageSize = size == null ? 10 : size;
|
||||
long totalPages = pageSize <= 0 ? 0 : (total + pageSize - 1) / pageSize;
|
||||
merged.put("pageIndex", pageIndex);
|
||||
merged.put("pageSize", pageSize);
|
||||
merged.put("totalPages", totalPages);
|
||||
merged.put("hasNext", pageIndex < totalPages);
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static Map<String, String> operatorQuery(Integer schId, Integer operatorId, String operatorName) {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("operatorId", String.valueOf(operatorId));
|
||||
query.put("operatorName", operatorName);
|
||||
return query;
|
||||
}
|
||||
|
||||
private void requireEnabled() {
|
||||
if (!flowClient.enabled() || openCall == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "流程中心未接入,审批功能不可用");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
|
||||
import com.yunhai.svchall.entity.ServiceApply;
|
||||
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
|
||||
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
||||
import com.yunhai.svchall.service.SvcBizTypes;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -18,22 +19,30 @@ import java.util.Map;
|
||||
* 流程中心回调业务映射(2026-09-20 迁 yh-flow-callback-spring-boot-starter):
|
||||
* X-Flw 验签(starter 可选过滤器)、ping、必填校验、bizType 过滤、eventId 幂等、
|
||||
* instanceVersion 乱序门由 starter 接收管线承担;本类只做办件镜像状态机推进——
|
||||
* 按 bizId 行锁定位 + 单据级乱序闸门 + 事件映射(submitted/task.*→10;completed→20;
|
||||
* rejected→30;withdrawn→40)+ 终态不回退守卫 + 结果明细快照 + 终态 finishTime。
|
||||
* 按 bizId 行锁定位 + 行级事件绑定校验(P1-02)+ 单据级乱序闸门 + 事件映射
|
||||
* (submitted/task.*→10;completed→20;rejected→30;withdrawn→40)+ 终态不回退守卫
|
||||
* (版本感知,P1-03)+ 结果明细快照 + 终态 finishTime。
|
||||
* 应答语义不变:本方法抛任何异常 → starter 回 5xx(flow-center 持续重试追平,免对账);
|
||||
* 正常返回(含办件不存在/未知事件等不可重试场景)→ 200。
|
||||
* 正常返回(含办件不存在/未知事件/绑定不匹配等不可重试场景)→ 200。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SvcFlowCallbackHandler implements FlowCallbackHandler {
|
||||
|
||||
/** 本应用业务类型(flow-center apply/bizType 固定值,uk(sch_id, biz_type, biz_id)) */
|
||||
public static final String BIZ_TYPE = "svc_item";
|
||||
/**
|
||||
* 本应用业务类型(flow-center apply/bizType,uk(sch_id, biz_type, biz_id))。
|
||||
* P3.2 方案B(2026-10-19)起=终值 {@link SvcBizTypes#CURRENT}(H-1 实核注册 app_code);
|
||||
* 存量 svc_item 由 yml 回调白名单双值承接(读路径双查见 {@link SvcBizTypes#queryOrder()})。
|
||||
*/
|
||||
public static final String BIZ_TYPE = SvcBizTypes.CURRENT;
|
||||
|
||||
private final ServiceApplyRepository applyRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private com.yunhai.svchall.repository.FlowApplyCommandRepository commandRepository;
|
||||
|
||||
@Override
|
||||
public void onEvent(FlowCallbackEvent event) {
|
||||
Integer applyId;
|
||||
@@ -50,6 +59,22 @@ public class SvcFlowCallbackHandler implements FlowCallbackHandler {
|
||||
event.getBizId(), event.getEventId());
|
||||
return;
|
||||
}
|
||||
// 行级事件绑定校验(P1-02):双值白名单允许"收"不等于"该事件属于该行"。不匹配一律
|
||||
// 200 消费但不推进(拒收记日志,不抛 5xx——重推风暴只会刷屏不会追平,且这类错投
|
||||
// 不是重试可修复的)。行 flow_instance_id 为空(首发回调早于 apply 响应回填的合法
|
||||
// 窗口)放行,回填侧(doFlowApply)对实例不一致另有告警。
|
||||
if (event.getSchId() != null && apply.getSchId() != null
|
||||
&& event.getSchId().intValue() != apply.getSchId()) {
|
||||
log.warn("[svc-hall] 回调学校不匹配,拒收:applyId={} applySch={} eventSch={} eventId={}",
|
||||
applyId, apply.getSchId(), event.getSchId(), event.getEventId());
|
||||
return;
|
||||
}
|
||||
if (apply.getFlowInstanceId() != null && event.getInstanceId() != null
|
||||
&& !apply.getFlowInstanceId().equals(event.getInstanceId())) {
|
||||
log.warn("[svc-hall] 回调实例不匹配,拒收:applyId={} applyInstance={} eventInstance={} eventId={}",
|
||||
applyId, apply.getFlowInstanceId(), event.getInstanceId(), event.getEventId());
|
||||
return;
|
||||
}
|
||||
Integer version = event.getInstanceVersion();
|
||||
// 单据级乱序闸门(starter 表级闸门之外的第二道:以行锁后读到的办件版本为准)——
|
||||
// 严格小于已消费版本才丢弃(同版本多事件合法:submitted+task.created 同 v1)
|
||||
@@ -59,36 +84,48 @@ public class SvcFlowCallbackHandler implements FlowCallbackHandler {
|
||||
event.getInstanceId(), version, apply.getLastInstanceVersion());
|
||||
return;
|
||||
}
|
||||
advance(apply, event.getEvent(), version, event);
|
||||
Integer round = verifiedRound(apply, event);
|
||||
if (round == null) return;
|
||||
int previousRound = currentRound(apply);
|
||||
if (round < previousRound) return;
|
||||
advance(apply, event.getEvent(), version, event, round, round > previousRound);
|
||||
log.info("[svc-hall] 办件镜像状态推进 applyId={} instanceId={} event={} → status={}",
|
||||
applyId, event.getInstanceId(), event.getEvent(), apply.getFlowStatus());
|
||||
}
|
||||
|
||||
/** 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态) */
|
||||
private void advance(ServiceApply apply, String event, Integer version, FlowCallbackEvent payload) {
|
||||
/** 只有带匹配命令 attempt/token 的更高轮次才能回开终态;实例版本只负责轮内乱序。 */
|
||||
private void advance(ServiceApply apply, String event, Integer version, FlowCallbackEvent payload, int round, boolean provedNewRound) {
|
||||
Integer status = mapStatus(event);
|
||||
if (status == null) {
|
||||
log.warn("[svc-hall] 未知回调事件类型:{}(忽略)", event);
|
||||
return;
|
||||
}
|
||||
// P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled
|
||||
// 在 completed/rejected 之后落库(flow-center 推送池并发不保序),无守卫会把 20/30 打回 10
|
||||
// 并用 handled 明细覆盖已落定的 flow_result_json。重提不冲突:resubmit 成功后先以
|
||||
// 窄列原子置 10,新一轮事件到达时行已非终态。
|
||||
if (ApplyFlowStatusEnum.byCode(apply.getFlowStatus()).terminal()) {
|
||||
if (!ApplyFlowStatusEnum.byCode(status).terminal()) {
|
||||
log.warn("[svc-hall] 终态不回退:applyId={} flowStatus={} 拒绝非终态事件 {}(v{})",
|
||||
apply.getId(), apply.getFlowStatus(), event, version);
|
||||
return;
|
||||
}
|
||||
if (apply.getFlowResultJson() != null && !apply.getFlowResultJson().isEmpty()) {
|
||||
log.warn("[svc-hall] 终态结果已落定:applyId={} flowStatus={} 拒绝覆盖 flow_result_json(事件 {})",
|
||||
apply.getId(), apply.getFlowStatus(), event);
|
||||
return;
|
||||
boolean newRound = provedNewRound && version != null && apply.getLastInstanceVersion() != null
|
||||
&& version > apply.getLastInstanceVersion();
|
||||
if (!newRound) {
|
||||
// P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled
|
||||
// 在 completed/rejected 之后落库(flow-center 推送池并发不保序),无守卫会把 20/30 打回 10
|
||||
// 并用 handled 明细覆盖已落定的 flow_result_json。
|
||||
if (!ApplyFlowStatusEnum.byCode(status).terminal()) {
|
||||
log.warn("[svc-hall] 终态不回退:applyId={} flowStatus={} 拒绝非终态事件 {}(v{})",
|
||||
apply.getId(), apply.getFlowStatus(), event, version);
|
||||
return;
|
||||
}
|
||||
if (apply.getFlowResultJson() != null && !apply.getFlowResultJson().isEmpty()) {
|
||||
log.warn("[svc-hall] 终态结果已落定:applyId={} flowStatus={} 拒绝覆盖 flow_result_json(事件 {})",
|
||||
apply.getId(), apply.getFlowStatus(), event);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
log.info("[svc-hall] 终态行收到更高版本事件(新轮回调先于回填的竞态窗口,放行):"
|
||||
+ "applyId={} oldStatus={} event={} v{} > v{}",
|
||||
apply.getId(), apply.getFlowStatus(), event, version, apply.getLastInstanceVersion());
|
||||
}
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("event", event);
|
||||
result.put("applyRound", round);
|
||||
Map<String, Object> data = payload.getData();
|
||||
if (data != null) {
|
||||
copyIfPresent(data, result, "result");
|
||||
@@ -108,10 +145,45 @@ public class SvcFlowCallbackHandler implements FlowCallbackHandler {
|
||||
|| status.equals(ApplyFlowStatusEnum.REJECTED.getCode())
|
||||
|| status.equals(ApplyFlowStatusEnum.WITHDRAWN.getCode())) {
|
||||
apply.setFinishTime(LocalDateTime.now());
|
||||
} else if (apply.getFinishTime() != null) {
|
||||
// 非终态推进清残留 finishTime(P1-03:新轮回调先到时行仍带旧轮 finishTime)
|
||||
apply.setFinishTime(null);
|
||||
}
|
||||
applyRepository.save(apply);
|
||||
}
|
||||
|
||||
private int currentRound(ServiceApply apply) {
|
||||
java.util.List<com.yunhai.svchall.entity.FlowApplyCommand> history = commandRepository == null
|
||||
? java.util.Collections.emptyList() : commandRepository.findByApplyIdOrderByIdDesc(apply.getId());
|
||||
return FlowRoundContract.localRound(apply, history, objectMapper);
|
||||
}
|
||||
|
||||
private Integer verifiedRound(ServiceApply apply, FlowCallbackEvent event) {
|
||||
Map<String, Object> data = event.getData();
|
||||
Object rawRound = data == null ? null : data.get("applyRound");
|
||||
String token = data == null || data.get("requestToken") == null ? null : String.valueOf(data.get("requestToken"));
|
||||
java.util.List<com.yunhai.svchall.entity.FlowApplyCommand> history = commandRepository == null
|
||||
? java.util.Collections.emptyList() : commandRepository.findByApplyIdOrderByIdDesc(apply.getId());
|
||||
if (rawRound == null && token == null && history.isEmpty()) return 1; // 真正的无命令存量单;不据版本回开终态。
|
||||
int round;
|
||||
try { round = Integer.parseInt(String.valueOf(rawRound)); }
|
||||
catch (RuntimeException e) { return null; }
|
||||
if (round < 1 || token == null || commandRepository == null) return null;
|
||||
com.yunhai.svchall.entity.FlowApplyCommand command = commandRepository
|
||||
.findByApplyIdAndAttempt(apply.getId(), round).orElse(null);
|
||||
if (command == null || command.getSentAt() == null || !token.equals(command.getRequestToken())
|
||||
|| !java.util.Objects.equals(command.getSchId(), apply.getSchId())
|
||||
|| !java.util.Objects.equals(command.getFlowGuid(), event.getFlowGuid())
|
||||
|| (apply.getFlowGuid() != null && !apply.getFlowGuid().equals(event.getFlowGuid()))
|
||||
|| (command.getRemoteInstanceId() != null && !command.getRemoteInstanceId().equals(event.getInstanceId()))) {
|
||||
log.warn("[svc-hall] 回调轮次/命令/实例不匹配,拒收 applyId={} round={}", apply.getId(), round);
|
||||
return null;
|
||||
}
|
||||
com.fasterxml.jackson.databind.JsonNode outcome = FlowRoundContract.outcome(command, objectMapper);
|
||||
if (outcome.has("applyRound") && outcome.path("applyRound").asInt() != round) return null;
|
||||
return round;
|
||||
}
|
||||
|
||||
private static void copyIfPresent(Map<String, Object> data, Map<String, Object> target, String key) {
|
||||
Object v = data.get(key);
|
||||
if (v != null) {
|
||||
|
||||
@@ -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:
|
||||
@@ -44,9 +56,12 @@ yh:
|
||||
client-host-url: http://localhost:8087
|
||||
allow-headers: all
|
||||
allow-origins: all
|
||||
# 用户面(发起/我的办件)与管理面全在 /api/* 下;本应用无匿名面。
|
||||
# 流程中心回调走 /callback/*(不在 pattern 内,由 FlowCallbackVerifyFilter 验签保护)
|
||||
url-pattern: /api/*
|
||||
# hall 用户面(/api/hall/**)匿名只读——与 CMS 门户 /portal/** 同一策略(D7,一期契约 §1):
|
||||
# 游客可读事项列表/筛选项/事项指南,控制器不依赖 BoaUser 判权限,可见范围由 siteCode 决定。
|
||||
# 列表只拦写入面(发起、我的办件、撤回、重提)、应用内审批、上传、管理面,以及
|
||||
# /api/base/*(页头用户信息与左侧菜单,必须 CAS 会话——契约 §1 表格未列,2026-10-20 补);
|
||||
# 流程中心回调与业务供数都在 /oapi/*(不在 pattern 内,由平台应用签名强制验签保护)
|
||||
url-pattern: /api/apply/*, /api/apply/approval/*, /api/file/*, /api/base/*, /api/svc/*
|
||||
app-auth:
|
||||
enabled: true
|
||||
# 本应用自身身份(客户端签名用)——UserBase 注册后分配(进白名单),占位值上线前必须替换,
|
||||
@@ -55,38 +70,62 @@ yh:
|
||||
# 联调态密钥(与 flow-center trusted-apps["100018"] 逐字一致);
|
||||
# 注意旧占位"裸 32 个 0"会被 YAML 按八进制整数解析成 "0",签名时 SignUtil 校验直接失败,必须带字母+引号
|
||||
app-secret: "svc-hall-dev-secret-100018"
|
||||
# 入站验签白名单:CMS(供数消费方,appKey=100005);流程中心 flow-center 分配 appKey 后在此追加
|
||||
# 提供方强制验签(两级模型 §4.1,flow-center 同款):本地 OpenApiAuthEnforceConfiguration 副本已删,
|
||||
# 换此一行——/oapi/**(含 /oapi/svc/** 供数与 /oapi/flow/callback 回调接收)无签名三件套即 401
|
||||
# (starter 版 RequireAppSignFilter,order +11 行为等价)
|
||||
server:
|
||||
enforce-patterns: [/oapi/*]
|
||||
# 中央密钥链(两级模型 §4.2):trusted-apps 先(确定性兜底),未命中走 SignKeyResolver
|
||||
# (Redis sign:app:* → Caffeine → UserBase 兜底端点)——任何在 UserBase 注册过的 appKey 自动可验。
|
||||
# 需 data-redis 依赖(yh-app-auth 侧 optional 不传递,pom 已显式引入)
|
||||
trust-registry: true
|
||||
# 入站验签白名单(组合链第一层,Redis 停摆时确定性工作):CMS(/oapi/svc/** 消费方,appKey=100005)、
|
||||
# flow-center(回调推送方,appKey=100019 联调态密钥;生产密钥由平台注入)
|
||||
trusted-apps:
|
||||
100005: 2c7885de594742cc840b8762910b2d2e
|
||||
"100019": "flow-center-dev-secret-100019"
|
||||
open:
|
||||
# 身份密钥唯一来源 = yh.app-auth.app-key/app-secret(本文件 app-auth 段),open.* 副本已删
|
||||
base-host: http://base.yunhaixingtong.com
|
||||
base-path: /userbase
|
||||
# 服务目录(子应用配置优化方案 §4.2):跨服务出站唯一地址来源,命名目标走 SDK 门面 /
|
||||
# YschOpenCall。cms 未晋升 PlatformService 枚举(按名直调);flow-center 已入枚举,此处
|
||||
# 显式覆盖为本地端口。完整 URL 拆 host+ctx,per-target base-path 与提供方 context-path
|
||||
# 对齐 → 签名天然一致。生产改容器直连:flow-center=http://flow-center-service:10008/flow-center、
|
||||
# cms=http://yschcms-service:10005/yschcms。
|
||||
base-host: ${YH_OPEN_BASE_HOST:http://open-gateway-service:10013}
|
||||
# 标准后端按 appCode 走统一内部 open-gateway,无需逐应用 URL。
|
||||
# 本地直连只在运行时显式覆盖 yh.open.services.{appCode};不要写入出厂 jar。
|
||||
services:
|
||||
flow-center: http://localhost:8085
|
||||
# 生产零配置:不配则枚举兜底 = base-host(网关)+/flow-center;此处显式覆盖仅本地开发用
|
||||
# (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 实际端口>;
|
||||
# 不配条目则走标准 appCode fallback:base-host 网关 + /form-center(无需枚举)。
|
||||
# form-center: ${YH_OPEN_SERVICES_FORMCENTER:}
|
||||
|
||||
# 流程中心回调接收端 starter(2026-09-20 迁移,X-Flw 验签式):端点保持既有 /callback/flow,
|
||||
# 存量注册的 callback_url 不变、零 secret 轮换;幂等/乱序门由 starter 承担,
|
||||
# 业务映射见 SvcFlowCallbackHandler;secret 镜像在 flow_callback_reg(迁移 SQL 见 sql/)
|
||||
# 流程中心回调接收端 starter(2026-09-20 迁移;2026-10-05 收敛 Notice 形态):
|
||||
# 端点/幂等/乱序门由 starter 承担,业务映射见 SvcFlowCallbackHandler;
|
||||
# 验签由上方 /oapi/* 平台应用签名强制验签负责(X-Flw 不启用;flow-center 推送三件套+X-Flw 双带,
|
||||
# Notice 线上实证此形态);secret 镜像在 flow_callback_reg(迁移 SQL 见 sql/)
|
||||
flow-callback:
|
||||
path: /callback/flow
|
||||
biz-types: [svc_item]
|
||||
verify-flw-signature: true
|
||||
callback-url: ${svchall.public-base-url}/callback/flow
|
||||
path: /oapi/flow/callback
|
||||
# P3.2 方案B(2026-10-19)双值白名单:终值 svc-hall(H-1 实核 app_code)+ 存量 svc_item。
|
||||
# 收敛条件=另一类型不再可能产生事件(覆盖 0 态重试/30/40 重提/积压核清),本轮不收敛单值;
|
||||
# 回滚也保双值。读路径(guard/对账/审批代理)双查见 SvcBizTypes#queryOrder()。
|
||||
biz-types: [svc-hall, svc_item]
|
||||
# 漂移自动核对(Notice 同款):端点路径/ctx 变更后,镜像行有效但登记 URL 与当前期望不一致时
|
||||
# 自动重新注册(会轮换 secret)——2026-10-05 改路径后 160 存量 /callback/flow 行靠它收敛
|
||||
reconcile-on-register: true
|
||||
# 注册地址不再显式配置(callback-url 已删):由 starter 推导
|
||||
# (open-host ?: yh.open.base-host) + /{前缀} + path,前缀三层回落 target → base-path → 运行时自证
|
||||
# context-path(本仓三层均未显式配置 → 生产 ctx=/svc-hall 自证)。
|
||||
# ⇒ 生产 = http://open-gateway-service:10013/svc-hall/oapi/flow/callback(base-host 平台注入)
|
||||
# 改地址回滚:把全地址写回 callback-url 即可(回滚绳),无需回滚镜像。
|
||||
|
||||
# ===== 办事大厅专属配置 =====
|
||||
svchall:
|
||||
# 本应用对外可达基址(回调注册 callbackUrl = {public-base-url}/callback/flow;projman 注入)
|
||||
public-base-url: http://localhost:8086
|
||||
# 办事大厅前端地址(办件 bizUrl 落地页用)
|
||||
front:
|
||||
base-url: http://localhost:8087
|
||||
# (public-base-url 已删 2026-10-05:唯一消费方是回调注册 callback-url 拼接,回调地址改由
|
||||
# yh.flow-callback 三段推导(见上方该段),本应用地址类配置进一步清零;file.public-base-url 是另一键不受影响)
|
||||
# 前端地址 front.base-url 已删(2026-09-26):办件 bizUrl 改传裸相对路径,绝对化由接收方按
|
||||
# 页面归属查基址承担——解析键=flw_instance.apply_app_code(apply 时由验签身份 appKey 按注册表
|
||||
# 换得的 app_code 快照,与 bizType 是两个字段互不影响)——本应用地址类配置清零(自指地址优化 Phase 2)
|
||||
item:
|
||||
# 服务部门"容器组"类型(UserBase GroupTypeEnum,默认 7=教师组;口径承袭 CMS cms.svc.item.dept-group-type)
|
||||
dept-group-type: 7
|
||||
@@ -108,6 +147,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:
|
||||
@@ -121,6 +185,15 @@ management:
|
||||
endpoint:
|
||||
health:
|
||||
show-details: never
|
||||
# 密钥链验签依赖 Redis(sign:app:*)。关掉 Redis 健康指标:Redis 抖动不应把 /actuator/health
|
||||
# 拉成 DOWN(503),否则部署健康检查与探活会误判整机不可用(Notice/flow-center 同口径)
|
||||
health:
|
||||
redis:
|
||||
enabled: false
|
||||
rabbit:
|
||||
# 同 form-center 先例(V64):amqp 是可选通道,未注入 RABBITMQ_HOST 时不该被健康探针拖成 DOWN;
|
||||
# 消费链本身按 host 条件装配,探针关掉才不会误报整机不可用。
|
||||
enabled: false
|
||||
|
||||
# 文件存储磁盘路径(Notice 三件套约定;Docker 部署时与 volume 挂载一致)
|
||||
FILE_PATH: D:/bsch/svchall/upload/
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.yunhai.svchall.controller.api;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.svchall.model.svc.SvcModels;
|
||||
import com.yunhai.svchall.service.SiteDirectory;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 用户面站点信息端点测试(T2 主题联动,2026-10-03 执行方案)。
|
||||
* GET /api/hall/site?siteCode=:出参=SiteOptionVo 全量(siteCode/name/schId/theme/themeParams)。
|
||||
* D7(2026-10-20 一期契约 §1)后 {@code /api/hall/**} 已退出 {@code yh.sso.url-pattern},
|
||||
* 未登录拦截不复存在:BoaUser 在控制器里恒可为 null 且不参与判权,可见范围只由 siteCode 决定;
|
||||
* 因此此处冻结的是"匿名可用 + 站点解析 fail-closed"两态,原先的本校成员拦截用例随守卫下线一并撤除。
|
||||
*/
|
||||
@DisplayName("HallController /api/hall/site - 用户面站点信息(T2 / D7 匿名)")
|
||||
class HallControllerSiteTest {
|
||||
|
||||
private SiteDirectory siteDirectory;
|
||||
private HallController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
siteDirectory = mock(SiteDirectory.class);
|
||||
controller = new HallController(mock(com.yunhai.svchall.service.impl.ServiceItemService.class),
|
||||
siteDirectory);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("游客(无 BoaUser)+ siteCode 命中:出参含 siteCode/name/schId/theme/themeParams")
|
||||
void site_anonymousAndHit_returnsFullOption() {
|
||||
when(siteDirectory.requireSite("S1")).thenReturn(new SiteDirectory.SiteInfo("S1", "站点一", 1L));
|
||||
SvcModels.SiteOptionVo option = new SvcModels.SiteOptionVo();
|
||||
option.setSiteCode("S1");
|
||||
option.setName("站点一");
|
||||
option.setSchId(1L);
|
||||
option.setTheme("campus-glow");
|
||||
option.getThemeParams().put("primary", "#0e8a5f");
|
||||
when(siteDirectory.findOption("S1")).thenReturn(option);
|
||||
|
||||
SvcModels.SiteOptionVo result = controller.site(null, "S1");
|
||||
assertEquals("S1", result.getSiteCode());
|
||||
assertEquals("站点一", result.getName());
|
||||
assertEquals(1L, result.getSchId());
|
||||
assertEquals("campus-glow", result.getTheme());
|
||||
assertEquals("#0e8a5f", result.getThemeParams().get("primary"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("findOption 防御性为 null:用 SiteInfo 补壳返回(三键保底)")
|
||||
void site_nullOption_fallsBackToSiteInfoShell() {
|
||||
when(siteDirectory.requireSite("S1")).thenReturn(new SiteDirectory.SiteInfo("S1", "站点一", 1L));
|
||||
when(siteDirectory.findOption("S1")).thenReturn(null);
|
||||
|
||||
SvcModels.SiteOptionVo result = controller.site(null, "S1");
|
||||
assertNotNull(result);
|
||||
assertEquals("S1", result.getSiteCode());
|
||||
assertEquals("站点一", result.getName());
|
||||
assertEquals(1L, result.getSchId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("siteCode 不存在/服务不可达:requireSite 的业务异常原样上抛(与 items 同口径)")
|
||||
void site_unknownSiteCode_propagatesBizException() {
|
||||
when(siteDirectory.requireSite("NOPE")).thenThrow(
|
||||
new BizException(ErrorCode.PARAM_ERROR, "站点不存在或站点服务不可达:NOPE"));
|
||||
|
||||
BizException e = assertThrows(BizException.class, () -> controller.site(null, "NOPE"));
|
||||
assertTrue(e.getMessage().contains("站点不存在"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("站点 schId 与请求方无归属关系:匿名面不做成员校验,照常返回(D7 口径)")
|
||||
void site_foreignSchId_stillReturns() {
|
||||
when(siteDirectory.requireSite("S1")).thenReturn(new SiteDirectory.SiteInfo("S1", "站点一", 99L));
|
||||
SvcModels.SiteOptionVo option = new SvcModels.SiteOptionVo();
|
||||
option.setSiteCode("S1");
|
||||
option.setName("站点一");
|
||||
option.setSchId(99L);
|
||||
when(siteDirectory.findOption("S1")).thenReturn(option);
|
||||
|
||||
SvcModels.SiteOptionVo result = controller.site(null, "S1");
|
||||
assertEquals(99L, result.getSchId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package com.yunhai.svchall.controller.api;
|
||||
|
||||
import com.yunhai.platform.apistd.spring.GlobalExceptionHandler;
|
||||
import com.yunhai.platform.auth.BoaUserContext;
|
||||
import com.yunhai.platform.auth.spring.BoaUserArgumentResolver;
|
||||
import com.yunhai.platform.auth.spring.CasSsoApiRedirectFilter;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.entity.ServiceApply;
|
||||
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
||||
import com.yunhai.svchall.service.ApplyViewGuard;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import com.yunhai.svchall.service.impl.ServiceApplyService;
|
||||
import com.yunhai.svchall.service.impl.SvcApprovalClient;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* P3.1 审批代理三层测试之 MVC 契约层:MockMvc standalone <b>显式装配</b>
|
||||
* {@link BoaUserArgumentResolver}(身份只来自 {@link BoaUserContext})与
|
||||
* {@link GlobalExceptionHandler}(R3.0 P1-04:范本 NoticeApprovalControllerTest 是方法单测,
|
||||
* 证明不了解析器/advice 装配)。
|
||||
* <p>
|
||||
* 断言口径分开:HTTP 状态恒 200(统一信封);未登录=业务码 3(FORBIDDEN);
|
||||
* 无学校=业务码 2(PARAM);<b>拒绝路径审批出站必须为 0</b>(verifyNoInteractions)。
|
||||
* 另有一组挂真实 {@link CasSsoApiRedirectFilter} 的过滤链用例:API 头未登录=HTTP200+code302
|
||||
* (平台 CAS 口径,非 HTTP 403——R3.0 勘误)。
|
||||
*/
|
||||
@DisplayName("SvcApprovalController - MVC 契约(双闸/身份注入/信封映射)")
|
||||
class SvcApprovalControllerTest {
|
||||
|
||||
private SvcApprovalClient approvalClient;
|
||||
private ServiceApplyRepository applyRepository;
|
||||
private ApplyViewGuard applyViewGuard;
|
||||
private ServiceApplyService applyService;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
approvalClient = mock(SvcApprovalClient.class);
|
||||
applyRepository = mock(ServiceApplyRepository.class);
|
||||
FlowClient flowClient = mock(FlowClient.class);
|
||||
applyViewGuard = new ApplyViewGuard(flowClient);
|
||||
applyService = mock(ServiceApplyService.class);
|
||||
SvcApprovalController controller = new SvcApprovalController(
|
||||
approvalClient, applyRepository, applyViewGuard, applyService);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setCustomArgumentResolvers(new BoaUserArgumentResolver())
|
||||
.setControllerAdvice(new GlobalExceptionHandler(null),
|
||||
new com.yunhai.platform.apistd.spring.ApiResponseAdvice())
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
BoaUserContext.clear();
|
||||
}
|
||||
|
||||
/** 深桩用户(沿 ServiceApplyFlowSubmitTest 形态):roleIds 可指定以驱动 Guard 管理员分支 */
|
||||
private BoaUser user(int id, int schId, Integer... roleIds) {
|
||||
BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
when(user.getId()).thenReturn(id);
|
||||
when(user.getName()).thenReturn("张三");
|
||||
when(user.getAccount()).thenReturn("zhangsan");
|
||||
when(user.getCurrentRole().getSchId()).thenReturn(schId);
|
||||
when(user.getRoleIds()).thenReturn(roleIds == null || roleIds.length == 0 ? java.util.Collections.<Integer>emptyList() : java.util.Arrays.asList(roleIds));
|
||||
when(user.getType()).thenReturn(null);
|
||||
return user;
|
||||
}
|
||||
|
||||
private ServiceApply apply(int id, int schId, Integer submitterId, Long instanceId) {
|
||||
ServiceApply apply = new ServiceApply();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(apply, "id", id);
|
||||
apply.setSchId((long) schId);
|
||||
apply.setSubmitterId(submitterId);
|
||||
apply.setFlowInstanceId(instanceId);
|
||||
return apply;
|
||||
}
|
||||
|
||||
private static Map<String, Object> byBizTasks(Long instanceId, Map<String, Object>... tasks) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("instanceId", instanceId);
|
||||
m.put("tasks", java.util.Arrays.asList(tasks));
|
||||
return m;
|
||||
}
|
||||
|
||||
private static Map<String, Object> task(int taskId) {
|
||||
Map<String, Object> t = new LinkedHashMap<>();
|
||||
t.put("taskId", taskId);
|
||||
t.put("nodeName", "部门负责人");
|
||||
return t;
|
||||
}
|
||||
|
||||
private static Map<String, Object> row(int id) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", id);
|
||||
return m;
|
||||
}
|
||||
|
||||
private static Map<String, Object> pair(Object value) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("total", 0L);
|
||||
m.put("list", value == null ? new java.util.ArrayList<>() : Collections.singletonList(value));
|
||||
return m;
|
||||
}
|
||||
|
||||
/** RecordUrl 换址应答 {pageUrl} */
|
||||
private static Map<String, Object> pageUrl(String url) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("pageUrl", url);
|
||||
return m;
|
||||
}
|
||||
|
||||
// ===== 身份层 =====
|
||||
|
||||
@Test
|
||||
@DisplayName("未登录(无 BoaUser):HTTP200 + 业务码 3,零出站")
|
||||
void todoWithoutLogin() throws Exception {
|
||||
mockMvc.perform(get("/api/apply/approval/Todo"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(3));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("会话无学校归属:HTTP200 + 业务码 2,零出站")
|
||||
void noSchoolIdentity() throws Exception {
|
||||
BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
when(user.getId()).thenReturn(10006);
|
||||
when(user.getCurrentRole()).thenReturn(null);
|
||||
BoaUserContext.set(user);
|
||||
mockMvc.perform(get("/api/apply/approval/Todo"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("请求参数携带身份字段(operatorId/schId/…):一律拒绝,零出站")
|
||||
void identityParamsRejected() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
mockMvc.perform(get("/api/apply/approval/Todo").param("operatorId", "1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42").param("schId", "3"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
mockMvc.perform(post("/api/apply/approval/Approve")
|
||||
.param("applyId", "42").param("taskId", "442").param("operatorName", "李四")
|
||||
.contentType("application/json").content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
verifyNoInteractions(approvalClient, applyRepository);
|
||||
}
|
||||
|
||||
// ===== Todo / My =====
|
||||
|
||||
@Test
|
||||
@DisplayName("Todo:会话身份出站(双值双查在 client),信封透传")
|
||||
void todoHappyPath() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("total", 0);
|
||||
data.put("content", new java.util.ArrayList<Object>());
|
||||
when(approvalClient.todoTasks(3, 10006, 2, 10)).thenReturn(data);
|
||||
mockMvc.perform(get("/api/apply/approval/Todo").param("page", "2").param("size", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Done/Cc(P3.5):会话身份出站,信封透传,身份字段入参拒绝")
|
||||
void doneAndCcEndpoints() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("total", 0);
|
||||
data.put("content", new java.util.ArrayList<>());
|
||||
when(approvalClient.doneTasks(3, 10006, 1, 10)).thenReturn(data);
|
||||
when(approvalClient.ccMine(3, 10006, 1, 10)).thenReturn(data);
|
||||
mockMvc.perform(get("/api/apply/approval/Done"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
mockMvc.perform(get("/api/apply/approval/Cc"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
mockMvc.perform(get("/api/apply/approval/Done").param("assigneeId", "1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("My:{total,list} → FlowPage{total,content,pageIndex,pageSize,totalPages,hasNext} 显式映射")
|
||||
void myEnvelopeMapping() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
Map<String, Object> local = new LinkedHashMap<>();
|
||||
local.put("total", 21L);
|
||||
local.put("list", java.util.Arrays.asList(row(42), row(43)));
|
||||
when(applyService.myApplies(any(), any(), eq(3), eq(10))).thenReturn(local);
|
||||
mockMvc.perform(get("/api/apply/approval/My").param("page", "3").param("size", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(21))
|
||||
.andExpect(jsonPath("$.data.content.length()").value(2))
|
||||
.andExpect(jsonPath("$.data.pageIndex").value(3))
|
||||
.andExpect(jsonPath("$.data.pageSize").value(10))
|
||||
.andExpect(jsonPath("$.data.totalPages").value(3))
|
||||
.andExpect(jsonPath("$.data.hasNext").value(false));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("My 非法页码/页大小归一(page<1→1,size<1→10,size>100→100)")
|
||||
void myNormalizesPaging() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
when(applyService.myApplies(any(), any(), anyInt(), anyInt())).thenReturn(pair(0L));
|
||||
mockMvc.perform(get("/api/apply/approval/My").param("page", "0").param("size", "500"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.pageIndex").value(1))
|
||||
.andExpect(jsonPath("$.data.pageSize").value(100));
|
||||
}
|
||||
|
||||
// ===== ByBiz 双闸 =====
|
||||
|
||||
@Test
|
||||
@DisplayName("ByBiz 跨校/不存在(findByIdAndSchId 空):业务码 2,零审批出站")
|
||||
void byBizCrossSchool() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.empty());
|
||||
mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ByBiz 同校陌生人(Guard 拒):业务码 3,零审批出站")
|
||||
void byBizStrangerRejected() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
ServiceApply row = apply(42, 3, 20001, 139L);
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
// flowClient.enabled()=false → Guard 办理人分支 fail-closed;非提交人/非管理员 → 拒
|
||||
mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(3));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ByBiz 管理员(Guard 放行=只读可见):放行查询,出站=byBiz")
|
||||
void byBizAdminReadonly() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3, 7));
|
||||
ServiceApply row = apply(42, 3, 20001, 139L);
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L));
|
||||
mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.instanceId").value(139));
|
||||
}
|
||||
|
||||
// ===== 写面双闸(Approve/Reject/TaskPageUrl) =====
|
||||
|
||||
@Test
|
||||
@DisplayName("Approve:归属预检不过(管理员可见但非办理人)→ 业务码 3,approve 零出站")
|
||||
void approveGuardFailsForNonAssigneeAdmin() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3, 7));
|
||||
ServiceApply row = apply(42, 3, 20001, 139L);
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L)); // tasks 空=非本人待办
|
||||
mockMvc.perform(post("/api/apply/approval/Approve")
|
||||
.param("applyId", "42").param("taskId", "442")
|
||||
.contentType("application/json").content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(3));
|
||||
verify(approvalClient, never()).approve(any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Approve:预检命中本人待办 → approve 一次(opinion/edgeName 从 body)")
|
||||
void approveHappyPath() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3, 7));
|
||||
ServiceApply row = apply(42, 3, 20001, 139L);
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L, task(442)));
|
||||
mockMvc.perform(post("/api/apply/approval/Approve")
|
||||
.param("applyId", "42").param("taskId", "442")
|
||||
.contentType("application/json")
|
||||
.content("{\"opinion\":\"同意\",\"edgeName\":\"通过\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
verify(approvalClient).approve(442L, 3, 10006, "张三", "同意", "通过");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Reject:空白 opinion 前置拒(业务码 2),零出站")
|
||||
void rejectBlankOpinion() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
mockMvc.perform(post("/api/apply/approval/Reject")
|
||||
.param("applyId", "42").param("taskId", "442")
|
||||
.contentType("application/json").content("{\"opinion\":\" \"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Reject:空 body/缺 opinion → 业务码 2")
|
||||
void rejectMissingBody() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
mockMvc.perform(post("/api/apply/approval/Reject")
|
||||
.param("applyId", "42").param("taskId", "442")
|
||||
.contentType("application/json").content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Reject:预检+opinion 齐 → reject 一次")
|
||||
void rejectHappyPath() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
ServiceApply row = apply(42, 3, 10006, 139L); // 提交人=本人(Guard owner 短路)
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L, task(442)));
|
||||
mockMvc.perform(post("/api/apply/approval/Reject")
|
||||
.param("applyId", "42").param("taskId", "442")
|
||||
.contentType("application/json").content("{\"opinion\":\"材料不全\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
verify(approvalClient).reject(442L, 3, 10006, "张三", "材料不全");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("TaskPageUrl:非本人待办 → 业务码 3,taskPageUrl 零出站")
|
||||
void taskPageUrlNotMyTask() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
ServiceApply row = apply(42, 3, 10006, 139L);
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L, task(999)));
|
||||
mockMvc.perform(get("/api/apply/approval/TaskPageUrl")
|
||||
.param("applyId", "42").param("taskId", "442"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(3));
|
||||
verify(approvalClient, never()).taskPageUrl(any(), any());
|
||||
}
|
||||
|
||||
// ===== RecordUrl =====
|
||||
|
||||
@Test
|
||||
@DisplayName("RecordUrl:未进流程(instanceId 空)→ 业务码 2,零出站")
|
||||
void recordUrlNoInstance() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
ServiceApply row = apply(42, 3, 10006, null);
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
mockMvc.perform(get("/api/apply/approval/RecordUrl").param("applyId", "42"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RecordUrl:instanceId 形态换址(pageUrl 透传)")
|
||||
void recordUrlHappyPath() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
ServiceApply row = apply(42, 3, 10006, 139L);
|
||||
when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row));
|
||||
when(approvalClient.embedRecordUrl(3, 139L)).thenReturn(pageUrl("/embed/instance?token=x"));
|
||||
mockMvc.perform(get("/api/apply/approval/RecordUrl").param("applyId", "42"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.pageUrl").value("/embed/instance?token=x"));
|
||||
verify(approvalClient).embedRecordUrl(3, 139L);
|
||||
}
|
||||
|
||||
// ===== 参数形态(与中心端点同构) =====
|
||||
|
||||
@Test
|
||||
@DisplayName("缺参 applyId:非 0 业务码(GlobalExceptionHandler 兜底,code=6),零出站")
|
||||
void missingParam() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
mockMvc.perform(get("/api/apply/approval/ByBiz"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(6));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("非法数字 applyId=abc:非 0 业务码(code=6),零出站")
|
||||
void illegalNumber() throws Exception {
|
||||
BoaUserContext.set(user(10006, 3));
|
||||
mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "abc"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(6));
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
|
||||
// ===== 真实 Servlet 过滤链(平台 CAS 口径) =====
|
||||
|
||||
/**
|
||||
* 挂真实 {@link CasSsoApiRedirectFilter}(yh-auth starter 同一实现类):API 头(XHR)未登录
|
||||
* → HTTP 200 + code 302 + urlToRedirectTo(平台口径,<b>不是</b> HTTP 403——R3.0 P1-04 勘误)。
|
||||
* 无 API 头的请求过滤器直通(浏览器导航场景走 CAS 重定向链,另由整合测试覆盖)。
|
||||
*/
|
||||
@Test
|
||||
void realFilterChainUnauthenticatedApiGetsCode302() throws Exception {
|
||||
CasSsoApiRedirectFilter filter = new CasSsoApiRedirectFilter(
|
||||
"X-Requested-With", "XMLHttpRequest", "http://sso.yunhaixingtong.com/cas", "http://localhost");
|
||||
MockMvc withFilter = MockMvcBuilders.standaloneSetup(
|
||||
new SvcApprovalController(approvalClient, applyRepository, applyViewGuard, applyService))
|
||||
.setCustomArgumentResolvers(new BoaUserArgumentResolver())
|
||||
.setControllerAdvice(new GlobalExceptionHandler(null),
|
||||
new com.yunhai.platform.apistd.spring.ApiResponseAdvice())
|
||||
.addFilter(filter, "/api/*")
|
||||
.build();
|
||||
withFilter.perform(get("/api/apply/approval/Todo").header("X-Requested-With", "XMLHttpRequest"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(302))
|
||||
.andExpect(jsonPath("$.urlToRedirectTo").exists());
|
||||
verifyNoInteractions(approvalClient);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,16 @@ class FlowReconcileJobTest {
|
||||
flowClient = mock(FlowClient.class);
|
||||
}
|
||||
|
||||
/** C.1 起 Job 依赖出站命令仓判"新命令链办件归恢复服务管";测试注入空历史桩,否则 NPE */
|
||||
private FlowReconcileJob job(boolean enabled) {
|
||||
FlowReconcileJob job = new FlowReconcileJob(applyRepository, flowClient, enabled);
|
||||
com.yunhai.svchall.repository.FlowApplyCommandRepository commands =
|
||||
mock(com.yunhai.svchall.repository.FlowApplyCommandRepository.class);
|
||||
when(commands.findByApplyIdOrderByIdDesc(anyInt())).thenReturn(Collections.emptyList());
|
||||
ReflectionTestUtils.setField(job, "commandRepository", commands);
|
||||
return job;
|
||||
}
|
||||
|
||||
private ServiceApply apply(int status, Integer lastVersion) {
|
||||
ServiceApply apply = new ServiceApply();
|
||||
ReflectionTestUtils.setField(apply, "id", 42);
|
||||
@@ -54,7 +64,7 @@ class FlowReconcileJobTest {
|
||||
|
||||
@Test
|
||||
void disabledByDefaultJobDoesNothing() {
|
||||
FlowReconcileJob job = new FlowReconcileJob(applyRepository, flowClient, false);
|
||||
FlowReconcileJob job = job(false);
|
||||
job.reconcile();
|
||||
verify(flowClient, never()).enabled();
|
||||
verify(applyRepository, never()).findByFlowStatus(anyInt(), any(Pageable.class));
|
||||
@@ -71,7 +81,7 @@ class FlowReconcileJobTest {
|
||||
remote.put("version", 4);
|
||||
when(flowClient.getInstanceByBiz(1L, "svc_item", "42")).thenReturn(remote);
|
||||
|
||||
new FlowReconcileJob(applyRepository, flowClient, true).reconcile();
|
||||
job(true).reconcile();
|
||||
|
||||
assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus());
|
||||
assertEquals(Integer.valueOf(4), apply.getLastInstanceVersion());
|
||||
@@ -90,7 +100,7 @@ class FlowReconcileJobTest {
|
||||
remote.put("version", 2);
|
||||
when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString())).thenReturn(remote);
|
||||
|
||||
new FlowReconcileJob(applyRepository, flowClient, true).reconcile();
|
||||
job(true).reconcile();
|
||||
|
||||
verify(applyRepository, never()).save(any());
|
||||
}
|
||||
@@ -98,7 +108,7 @@ class FlowReconcileJobTest {
|
||||
@Test
|
||||
void enabledJobSkipsWhenFlowCenterDisabled() {
|
||||
when(flowClient.enabled()).thenReturn(false);
|
||||
new FlowReconcileJob(applyRepository, flowClient, true).reconcile();
|
||||
job(true).reconcile();
|
||||
verify(applyRepository, never()).findByFlowStatus(anyInt(), any(Pageable.class));
|
||||
}
|
||||
|
||||
@@ -111,7 +121,7 @@ class FlowReconcileJobTest {
|
||||
when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString()))
|
||||
.thenThrow(new RuntimeException("boom"));
|
||||
|
||||
new FlowReconcileJob(applyRepository, flowClient, true).reconcile();
|
||||
job(true).reconcile();
|
||||
|
||||
verify(applyRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.yunhai.svchall.model.svc;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.svchall.entity.ServiceItem;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemEditVo;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemSaveRequest;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/**
|
||||
* 服务承诺字段(P1-1)的入参与回显契约测试。
|
||||
* <p>
|
||||
* 守两件事:
|
||||
* ① 管理端前端用的字段名 → {@link SvcItemSaveRequest} 能反序列化进来(Jackson 默认忽略未知字段,
|
||||
* 名字写错不会报错、只会静默丢数据,必须显式测);
|
||||
* ② {@link SvcItemEditVo#of} 能把实体上的 7 个字段原样映射到编辑页回显。
|
||||
* <p>
|
||||
* 不测 adminSave 的落库:那需要 mock repository,价值低于上面两条(字段名对齐是最易错点)。
|
||||
*/
|
||||
class SvcItemPromiseFieldsTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
@DisplayName("SaveRequest 能接住前端传来的 7 个承诺字段(字段名不得漂移)")
|
||||
void saveRequestDeserializesPromiseFields() throws Exception {
|
||||
String json = "{"
|
||||
+ "\"coopDepts\":\"组织部、教务处\","
|
||||
+ "\"handlerName\":\"田老师\","
|
||||
+ "\"handleTimeDesc\":\"工作日 08:00-11:45\","
|
||||
+ "\"runCount\":1,"
|
||||
+ "\"expressFlag\":2,"
|
||||
+ "\"feeDesc\":\"50 元\","
|
||||
+ "\"feeBasis\":\"浙价费〔2020〕1 号\""
|
||||
+ "}";
|
||||
|
||||
SvcItemSaveRequest req = MAPPER.readValue(json, SvcItemSaveRequest.class);
|
||||
|
||||
assertEquals("组织部、教务处", req.getCoopDepts());
|
||||
assertEquals("田老师", req.getHandlerName());
|
||||
assertEquals("工作日 08:00-11:45", req.getHandleTimeDesc());
|
||||
assertEquals(1, req.getRunCount());
|
||||
assertEquals(2, req.getExpressFlag());
|
||||
assertEquals("50 元", req.getFeeDesc());
|
||||
assertEquals("浙价费〔2020〕1 号", req.getFeeBasis());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("EditVo.of 把实体 7 个承诺字段映射到回显")
|
||||
void editVoMapsPromiseFields() {
|
||||
ServiceItem item = new ServiceItem();
|
||||
item.setId(12);
|
||||
item.setSiteCode("yhms");
|
||||
item.setItemCode("teacher-onboard");
|
||||
item.setTitle("教师入职办理");
|
||||
item.setCoopDepts("组织部、教务处");
|
||||
item.setHandlerName("田老师");
|
||||
item.setHandleTimeDesc("工作日 08:00-11:45");
|
||||
item.setRunCount(0);
|
||||
item.setExpressFlag(1);
|
||||
item.setFeeDesc("无");
|
||||
item.setFeeBasis(null);
|
||||
|
||||
SvcItemEditVo vo = SvcItemEditVo.of(item);
|
||||
|
||||
assertEquals("组织部、教务处", vo.getCoopDepts());
|
||||
assertEquals("田老师", vo.getHandlerName());
|
||||
assertEquals("工作日 08:00-11:45", vo.getHandleTimeDesc());
|
||||
assertEquals(0, vo.getRunCount());
|
||||
assertEquals(1, vo.getExpressFlag());
|
||||
assertEquals("无", vo.getFeeDesc());
|
||||
assertNull(vo.getFeeBasis());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("承诺字段全空时 SaveRequest 不报错(全部可空)")
|
||||
void saveRequestAllowsNulls() throws Exception {
|
||||
SvcItemSaveRequest req = MAPPER.readValue("{\"title\":\"x\"}", SvcItemSaveRequest.class);
|
||||
|
||||
assertNull(req.getCoopDepts());
|
||||
assertNull(req.getRunCount());
|
||||
assertNull(req.getExpressFlag());
|
||||
assertNull(req.getFeeDesc());
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,48 @@ class CmsSiteDirectoryImplTest {
|
||||
assertThrows(BizException.class, () -> directory.listSites(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("T2 主题联动:theme/themeParams 随全量 VO 透传(findOption),resolveSite 三字段结构不变")
|
||||
void findOption_mapsThemeFieldsThrough() {
|
||||
SvcModels.SiteOptionVo row = vo("S1", "站点一", 1L);
|
||||
row.setTheme("campus-glow");
|
||||
row.getThemeParams().put("primary", "#0e8a5f");
|
||||
when(openCall.getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE)))
|
||||
.thenReturn(Collections.singletonList(row));
|
||||
|
||||
SvcModels.SiteOptionVo option = directory.findOption("S1");
|
||||
assertEquals("S1", option.getSiteCode());
|
||||
assertEquals("campus-glow", option.getTheme());
|
||||
assertEquals("#0e8a5f", option.getThemeParams().get("primary"));
|
||||
// resolveSite 委托 findOption:SiteInfo 三字段结构不动(避免 ripple)
|
||||
SiteDirectory.SiteInfo site = directory.resolveSite("S1");
|
||||
assertEquals("S1", site.getSiteCode());
|
||||
assertEquals("站点一", site.getName());
|
||||
assertEquals(1L, site.getSchId());
|
||||
// 同一全量缓存:再取不重复出站
|
||||
assertNull(directory.findOption("NOPE"));
|
||||
verify(openCall, times(1)).getList(any(TargetSpec.class), eq(PATH), eq(Collections.emptyMap()), eq(VO_TYPE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("T2 兼容性证明:旧三键+未知键 JSON 反序列化不炸(theme=null),新键正常映射")
|
||||
void siteOptionVo_backwardCompatibleDeserialization() throws Exception {
|
||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
// 旧形态(CMS 未升级)+ 未来未知键:不得抛异常(@JsonIgnoreProperties 兜住)
|
||||
SvcModels.SiteOptionVo legacy = mapper.readValue(
|
||||
"{\"siteCode\":\"S1\",\"name\":\"站点一\",\"schId\":2,\"someFutureKey\":true}",
|
||||
SvcModels.SiteOptionVo.class);
|
||||
assertEquals("S1", legacy.getSiteCode());
|
||||
assertNull(legacy.getTheme());
|
||||
// 新形态:theme/themeParams 正常映射
|
||||
SvcModels.SiteOptionVo fresh = mapper.readValue(
|
||||
"{\"siteCode\":\"S2\",\"name\":\"站点二\",\"schId\":3,\"theme\":\"campus-glow\","
|
||||
+ "\"themeParams\":{\"primary\":\"#0e8a5f\"}}",
|
||||
SvcModels.SiteOptionVo.class);
|
||||
assertEquals("campus-glow", fresh.getTheme());
|
||||
assertEquals("#0e8a5f", fresh.getThemeParams().get("primary"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rootCauseBrief:ioError 包装层要透出最内层原因(区分\"对端没起\"与\"读超时\")")
|
||||
void rootCauseBrief_exposesInnermostCause() {
|
||||
|
||||
+13
-6
@@ -6,27 +6,34 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* 回调注册 basePath 推导契约:接收方 context-path 从回调 URL path 段去掉尾部
|
||||
* /callback/flow 推导(部署带 ctx=签名对齐关键;本地裸地址=空串保持旧形态)。
|
||||
* /oapi/flow/callback 推导(部署带 ctx=签名对齐关键;本地裸地址=空串保持旧形态)。
|
||||
* 2026-09-16 修复:注册漏带 basePath,flow-center 推送按整路径签名 →
|
||||
* FlowCallbackVerifyFilter 剥 ctx 后对不上 → 401(本地无 ctx 掩盖)。
|
||||
* 验签过滤器剥 ctx 后对不上 → 401(本地无 ctx 掩盖)。
|
||||
* 2026-10-05:回调端点收敛 Notice 形态(/oapi/flow/callback),剥尾段口径同步。
|
||||
*/
|
||||
class FlowCenterClientCallbackBasePathTest {
|
||||
|
||||
@Test
|
||||
void derive_withContextPath() {
|
||||
assertEquals("/svc-hall",
|
||||
FlowCenterClient.deriveCallbackBasePath("http://svc-hall-service:10007/svc-hall/callback/flow"));
|
||||
FlowCenterClient.deriveCallbackBasePath("http://open-gateway-service:10013/svc-hall/oapi/flow/callback"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void derive_bareLocalAddressIsEmpty() {
|
||||
assertEquals("", FlowCenterClient.deriveCallbackBasePath("http://localhost:8086/callback/flow"));
|
||||
assertEquals("", FlowCenterClient.deriveCallbackBasePath("http://localhost:8086/oapi/flow/callback"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void derive_gatewayForm() {
|
||||
void derive_containerDirectForm() {
|
||||
assertEquals("/svc-hall",
|
||||
FlowCenterClient.deriveCallbackBasePath("http://base.yunhaixingtong.com/svc-hall/callback/flow"));
|
||||
FlowCenterClient.deriveCallbackBasePath("http://svc-hall-service:10007/svc-hall/oapi/flow/callback"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void derive_legacyOldEndpointNotStripped() {
|
||||
// 旧端点尾段不再是合法推导输入:不命中剥尾段即返回空串(显式冻结,不静默兼容旧路径)
|
||||
assertEquals("", FlowCenterClient.deriveCallbackBasePath("http://svc-hall-service:10007/svc-hall/callback/flow"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -127,14 +127,14 @@ class FlowCenterClientFacadeTest {
|
||||
any(FlowCallbackRegBody.class), eq(SvcModels.CallbackRegVo.class))).thenReturn(stub);
|
||||
|
||||
String secret = client().registerCallback(3L, "guid-1",
|
||||
"http://svc-hall-service:10007/svc-hall/callback/flow", null);
|
||||
"http://open-gateway-service:10013/svc-hall/oapi/flow/callback", null);
|
||||
|
||||
assertEquals("secret-1", secret);
|
||||
ArgumentCaptor<FlowCallbackRegBody> body = ArgumentCaptor.forClass(FlowCallbackRegBody.class);
|
||||
verify(openCall).post(eq(TARGET), eq("/oapi/flow/callback/reg"), anyMap(),
|
||||
body.capture(), eq(SvcModels.CallbackRegVo.class));
|
||||
assertEquals("/svc-hall", body.getValue().getBasePath());
|
||||
assertEquals("http://svc-hall-service:10007/svc-hall/callback/flow", body.getValue().getCallbackUrl());
|
||||
assertEquals("http://open-gateway-service:10013/svc-hall/oapi/flow/callback", body.getValue().getCallbackUrl());
|
||||
assertNull(body.getValue().getEventTypes());
|
||||
}
|
||||
|
||||
@@ -287,4 +287,26 @@ class FlowCenterClientFacadeTest {
|
||||
() -> c.listFlows(3L));
|
||||
assertTrue(ex.getMessage().contains("流程中心未接入"), ex.getMessage());
|
||||
}
|
||||
@Test
|
||||
void v2ResultRetainsBranchRoundAndRealTerminalStatus() {
|
||||
com.yunhai.svchall.model.FcApplyModels.FlowOutbound outbound = new com.yunhai.svchall.model.FcApplyModels.FlowOutbound();
|
||||
outbound.setBizId("42"); outbound.setRequestToken("token-1");
|
||||
when(openCall.post(eq(TARGET), eq("/oapi/flow/apply/v2"), anyMap(), any(), eq(com.fasterxml.jackson.databind.JsonNode.class)))
|
||||
.thenReturn(objectMapper.createObjectNode().put("instanceId", 555).put("duplicated", true)
|
||||
.put("branch", "SAME_REQUEST").put("applyRound", 1).put("status", 30).put("version", 4));
|
||||
FlowClient.ApplyResult result = client().applyWithIdentity(3L, outbound);
|
||||
assertEquals("SAME_REQUEST", result.getBranch()); assertEquals(Integer.valueOf(1), result.getApplyRound());
|
||||
assertEquals(Integer.valueOf(30), result.getStatus()); assertEquals(Integer.valueOf(4), result.getVersion());
|
||||
assertEquals(Long.valueOf(555), result.getInstanceId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void v2DoesNotTruncateFractionalRound() {
|
||||
com.yunhai.svchall.model.FcApplyModels.FlowOutbound outbound = new com.yunhai.svchall.model.FcApplyModels.FlowOutbound();
|
||||
outbound.setBizId("42"); outbound.setRequestToken("token-1");
|
||||
when(openCall.post(eq(TARGET), eq("/oapi/flow/apply/v2"), anyMap(), any(), eq(com.fasterxml.jackson.databind.JsonNode.class)))
|
||||
.thenReturn(objectMapper.createObjectNode().put("instanceId", 555).put("branch", "RESTARTED").put("applyRound", 2.5));
|
||||
assertThrows(com.yunhai.platform.apistd.BizException.class, () -> client().applyWithIdentity(3L, outbound));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.openapi.core.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FlowCenterClientRoutingTest {
|
||||
private YschOpenCall call(String gateway) {
|
||||
OpenApiProperties open = new OpenApiProperties();
|
||||
open.setBaseHost(gateway);
|
||||
return new YschOpenCall(new YschCallProperties(), open, new ObjectMapper(), null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledWithoutExplicitTargetUsesSdk() {
|
||||
YschOpenCall call = call("http://gateway:10013");
|
||||
assertFalse(call.hasTarget("flow-center"));
|
||||
FlowCenterClient client = new FlowCenterClient(call, new ObjectMapper(), true, "");
|
||||
assertTrue(client.enabled());
|
||||
assertEquals(true, ReflectionTestUtils.getField(client, "directoryResolved"));
|
||||
assertNull(ReflectionTestUtils.getField(client, "flowSpec"));
|
||||
assertEquals("http://gateway:10013/form-center", call.resolveTarget("form-center").resolveBaseUrl());
|
||||
assertDoesNotThrow(() -> new FormCenterCatalogClient(call));
|
||||
assertDoesNotThrow(() -> new FcSubmissionClient(call));
|
||||
assertDoesNotThrow(() -> new FormSyncClient(call));
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitThenLegacyThenSdkPriority() {
|
||||
YschCallProperties props = new YschCallProperties();
|
||||
props.getTargets().put("flow-center", TargetSpec.of("http://explicit", "/flow-center"));
|
||||
YschOpenCall explicit = new YschOpenCall(props, new ObjectMapper(), null);
|
||||
FlowCenterClient client = new FlowCenterClient(explicit, new ObjectMapper(), true, "http://legacy/ctx");
|
||||
assertEquals(true, ReflectionTestUtils.getField(client, "directoryResolved"));
|
||||
client = new FlowCenterClient(call("http://gateway"), new ObjectMapper(), true, "http://legacy/ctx");
|
||||
assertEquals(false, ReflectionTestUtils.getField(client, "directoryResolved"));
|
||||
TargetSpec spec = (TargetSpec) ReflectionTestUtils.getField(client, "flowSpec");
|
||||
assertEquals("http://legacy/ctx", spec.resolveBaseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledWithoutResolvableAddressFailsButDisabledDoesNot() {
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> new FlowCenterClient(call(null), new ObjectMapper(), true, ""));
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> new FlowCenterClient(call("http://gateway/open-gateway"), new ObjectMapper(), true, ""));
|
||||
assertFalse(new FlowCenterClient(call(null), new ObjectMapper(), false, "").enabled());
|
||||
assertFalse(new FlowCenterClient(call(null), new ObjectMapper(), false, "unused-legacy-value").enabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.svchall.entity.ServiceItem;
|
||||
import com.yunhai.svchall.enums.ServiceDictTypeEnum;
|
||||
import com.yunhai.svchall.enums.ServiceItemStatusEnum;
|
||||
import com.yunhai.svchall.model.hall.HallModels;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcItemAttachmentVo;
|
||||
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 com.yunhai.svchall.utils.FileUtil;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 匿名事项指南出参单测(一期契约 §3.2,D7)。GET /api/hall/item 的控制器只做参数转发,
|
||||
* 必填校验/发布态判定/字段装配全部收口在 {@link ServiceItemService#hallItemGuide},故直测该方法。
|
||||
* 三态:未发布(含不存在)报错、已发布出参齐全(三段富文本 + 6 个服务承诺字段)、缺参报错。
|
||||
*/
|
||||
@DisplayName("ServiceItemService.hallItemGuide - 匿名事项指南(P1-1 / D7)")
|
||||
class OapiItemGuideTest {
|
||||
|
||||
private static final String SITE = "S1";
|
||||
private static final String ITEM_CODE = "teacher-onboard";
|
||||
private static final Long SCH_ID = 1L;
|
||||
private static final Integer ITEM_ID = 12;
|
||||
|
||||
private ServiceItemRepository itemRepository;
|
||||
private AttachmentService attachmentService;
|
||||
private ServiceItemService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
itemRepository = mock(ServiceItemRepository.class);
|
||||
attachmentService = mock(AttachmentService.class);
|
||||
ServiceDictService dictService = mock(ServiceDictService.class);
|
||||
Map<String, String> categoryNames = new HashMap<>();
|
||||
categoryNames.put("org", "组织人事");
|
||||
when(dictService.nameMap(anyLong(), anyString(), any(ServiceDictTypeEnum.class))).thenReturn(categoryNames);
|
||||
SiteDirectory siteDirectory = mock(SiteDirectory.class);
|
||||
when(siteDirectory.requireSite(SITE)).thenReturn(new SiteDirectory.SiteInfo(SITE, "站点一", SCH_ID));
|
||||
service = new ServiceItemService(itemRepository, mock(ServiceFormRepository.class), siteDirectory,
|
||||
dictService, mock(ServiceFormService.class), attachmentService, mock(SiteGuard.class),
|
||||
mock(DeptSource.class), mock(FlowClient.class),
|
||||
mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class),
|
||||
mock(TransactionTemplate.class));
|
||||
FileUtil.init("target/test-hall-guide", "http://localhost:8086/upload/");
|
||||
}
|
||||
|
||||
/** 已发布事项:服务承诺 7 列已维护(feeBasis 故意留空,验证 null 可透传) */
|
||||
private ServiceItem publishedItem() {
|
||||
ServiceItem item = new ServiceItem();
|
||||
item.setId(ITEM_ID);
|
||||
item.setSchId(SCH_ID);
|
||||
item.setSiteCode(SITE);
|
||||
item.setItemCode(ITEM_CODE);
|
||||
item.setTitle("教职工入职手续办理");
|
||||
item.setSummary("面向新入职教职工");
|
||||
item.setObjectKey("teacher");
|
||||
item.setCategoryKey("org");
|
||||
item.setDeptName("人事处");
|
||||
item.setHandleMode(3);
|
||||
item.setOnlineFlag(1);
|
||||
item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode());
|
||||
item.setHandlePlace("行政楼 406 办公室");
|
||||
item.setHandleTimeLimit("3");
|
||||
item.setPromiseTimeLimit("1-3个工作日");
|
||||
item.setContactPhone("86843815");
|
||||
item.setSupervisePhone("86843000");
|
||||
item.setCoverImage("/upload/image/cover.png");
|
||||
item.setViewCount(102);
|
||||
item.setCoopDepts("组织部、教务处");
|
||||
item.setHandlerName("田老师");
|
||||
item.setHandleTimeDesc("工作日 08:00-11:45,13:30-16:15");
|
||||
item.setRunCount(1);
|
||||
item.setExpressFlag(0);
|
||||
item.setFeeDesc("无");
|
||||
item.setProcessHtml("<p>流程</p>");
|
||||
item.setMaterialHtml("<p>材料</p>");
|
||||
item.setNoticeHtml("<p>注意</p>");
|
||||
return item;
|
||||
}
|
||||
|
||||
private void stubFound(ServiceItem item) {
|
||||
when(itemRepository.findBySchIdAndSiteCodeAndItemCode(SCH_ID, SITE, ITEM_CODE))
|
||||
.thenReturn(Optional.of(item));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未发布(草稿)事项:BizException,文案「事项不存在或未发布」")
|
||||
void draftItem_rejected() {
|
||||
ServiceItem draft = publishedItem();
|
||||
draft.setStatus(ServiceItemStatusEnum.DRAFT.getCode());
|
||||
stubFound(draft);
|
||||
|
||||
BizException e = assertThrows(BizException.class, () -> service.hallItemGuide(SITE, ITEM_CODE));
|
||||
assertTrue(e.getMessage().contains("事项不存在或未发布"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("事项不存在:与未发布同一文案(不区分两态,免暴露库容)")
|
||||
void missingItem_rejected() {
|
||||
when(itemRepository.findBySchIdAndSiteCodeAndItemCode(SCH_ID, SITE, ITEM_CODE))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
BizException e = assertThrows(BizException.class, () -> service.hallItemGuide(SITE, ITEM_CODE));
|
||||
assertTrue(e.getMessage().contains("事项不存在或未发布"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已发布:三段富文本 + 6 个服务承诺字段齐全,附件 fileUrl 绝对化")
|
||||
void publishedItem_returnsGuideWithPromiseFields() {
|
||||
stubFound(publishedItem());
|
||||
SvcItemAttachmentVo attachment = new SvcItemAttachmentVo();
|
||||
attachment.setFileName("入会登记表.pdf");
|
||||
attachment.setFileUrl("/upload/file/form.pdf");
|
||||
attachment.setFileSize(214016L);
|
||||
when(attachmentService.guideAttachments(SCH_ID, ITEM_ID))
|
||||
.thenReturn(Collections.singletonList(attachment));
|
||||
|
||||
HallModels.ItemGuideVo vo = service.hallItemGuide(SITE, ITEM_CODE);
|
||||
|
||||
assertEquals(ITEM_ID, vo.getId());
|
||||
assertEquals(ITEM_CODE, vo.getItemCode());
|
||||
assertEquals("<p>流程</p>", vo.getProcessHtml());
|
||||
assertEquals("<p>材料</p>", vo.getMaterialHtml());
|
||||
assertEquals("<p>注意</p>", vo.getNoticeHtml());
|
||||
assertEquals("组织人事", vo.getCategoryName());
|
||||
// P1-1 服务承诺字段(§2.2 出参名,不得自创)
|
||||
assertEquals("组织部、教务处", vo.getCoopDepts());
|
||||
assertEquals("田老师", vo.getHandlerName());
|
||||
assertEquals("工作日 08:00-11:45,13:30-16:15", vo.getHandleTimeDesc());
|
||||
assertEquals(1, vo.getRunCount().intValue());
|
||||
assertEquals(0, vo.getExpressFlag().intValue());
|
||||
assertEquals("无", vo.getFeeDesc());
|
||||
assertNull(vo.getFeeBasis(), "未维护的收费依据透传 null");
|
||||
// 承诺时限/办理时限/外链三态字段(§3.1 同源)
|
||||
assertEquals("1-3个工作日", vo.getPromiseTimeLimit());
|
||||
assertEquals("3", vo.getHandleTimeLimit());
|
||||
assertNull(vo.getApplyUrl(), "内部办理事项无外链");
|
||||
assertEquals("http://localhost:8086/upload/image/cover.png", vo.getCoverImage());
|
||||
assertEquals(1, vo.getAttachments().size());
|
||||
assertEquals("http://localhost:8086/upload/file/form.pdf", vo.getAttachments().get(0).getFileUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("外链事项:applyUrl 原样透传(三态最高优先,前端据此新窗口打开)")
|
||||
void externalApplyUrl_passesThrough() {
|
||||
ServiceItem item = publishedItem();
|
||||
item.setApplyUrl("https://rs.example.edu/onboard");
|
||||
stubFound(item);
|
||||
|
||||
assertEquals("https://rs.example.edu/onboard", service.hallItemGuide(SITE, ITEM_CODE).getApplyUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("itemCode 缺失(null/空串/空白):BizException PARAM_ERROR,不触库")
|
||||
void blankItemCode_rejectedBeforeQuery() {
|
||||
for (String blank : new String[]{null, "", " "}) {
|
||||
BizException e = assertThrows(BizException.class, () -> service.hallItemGuide(SITE, blank));
|
||||
assertTrue(e.getMessage().contains("必填"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("siteCode 缺失:同样 BizException PARAM_ERROR(游客不会拿到跨校列表)")
|
||||
void blankSiteCode_rejected() {
|
||||
BizException e = assertThrows(BizException.class, () -> service.hallItemGuide(null, ITEM_CODE));
|
||||
assertTrue(e.getMessage().contains("必填"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("脱敏:ItemGuideVo 无 formCode/flowGuid 属性,也不携带办件人与办件记录字段")
|
||||
void guideVo_exposesNoBindingOrApplyInfo() {
|
||||
for (String forbidden : new String[]{"formCode", "flowGuid", "submitterName", "submitterNo",
|
||||
"applyId", "applyCount", "deptGroupId"}) {
|
||||
assertFalse(hasField(HallModels.ItemGuideVo.class, forbidden), "匿名指南出参不应含 " + forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasField(Class<?> type, String name) {
|
||||
for (Field field : type.getDeclaredFields()) {
|
||||
if (field.getName().equals(name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,6 @@ class ServiceApplyFlowSubmitTest {
|
||||
new com.yunhai.svchall.service.ApplyViewGuard(flowClient), new ObjectMapper(),
|
||||
new org.springframework.transaction.support.TransactionTemplate(
|
||||
org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class)));
|
||||
ReflectionTestUtils.setField(service, "frontBaseUrl", "http://localhost:8087");
|
||||
|
||||
apply = new ServiceApply();
|
||||
ReflectionTestUtils.setField(apply, "id", 42);
|
||||
@@ -136,7 +135,7 @@ class ServiceApplyFlowSubmitTest {
|
||||
ApplyVo vo = service.submit(user(9, 1), request());
|
||||
assertEquals(ApplyFlowStatusEnum.PENDING.getCode(), vo.getFlowStatus(), "提交响应=PENDING 行,状态异步翻转");
|
||||
ArgumentCaptor<String> bizId = ArgumentCaptor.forClass(String.class);
|
||||
verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc_item"), bizId.capture(),
|
||||
verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc-hall"), bizId.capture(),
|
||||
anyString(), any(), any(), eq(9), eq("张三"), any());
|
||||
assertEquals("42", bizId.getValue(), "bizId=办件 id(uk 幂等键)");
|
||||
verify(applyRepository).markApproving(eq(42), eq(9011L), any(LocalDateTime.class));
|
||||
@@ -144,13 +143,14 @@ class ServiceApplyFlowSubmitTest {
|
||||
|
||||
@Test
|
||||
void submitPassesBizUrlInFrontendHashQueryForm() {
|
||||
// bizUrl 落地页是 svc-hall 前端的 hash 路由 /apply-detail?id=(routes.ts path + Apply.vue
|
||||
// router.replace 同形);拼成路径参数 /apply-detail/{id} 会 no-match 空白页(走查 BP-R3-2)
|
||||
// bizUrl 是裸相对路径(2026-09-26 起不拼本应用前端基址,绝对化由接收方按页面归属承担),
|
||||
// 形态须是 hash 路由 + query 传参(routes.ts path="/apply-detail"、Apply.vue
|
||||
// router.replace 同形);路径参数 /apply-detail/{id} 会 no-match 空白页(走查 BP-R3-2)
|
||||
service.submit(user(9, 1), request());
|
||||
ArgumentCaptor<String> bizUrl = ArgumentCaptor.forClass(String.class);
|
||||
verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc_item"), eq("42"),
|
||||
verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc-hall"), eq("42"),
|
||||
anyString(), bizUrl.capture(), any(), eq(9), eq("张三"), any());
|
||||
assertEquals("http://localhost:8087/#/apply-detail?id=42", bizUrl.getValue());
|
||||
assertEquals("/apply-detail?id=42", bizUrl.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -249,7 +249,7 @@ class ServiceApplyFlowSubmitTest {
|
||||
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
|
||||
service.resubmit(user(9, 1), 42);
|
||||
// 同 bizId=42(flow-center R4 命中 30/40 → 复用同一办件行重启推进)
|
||||
verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc_item"), eq("42"),
|
||||
verify(flowClient).apply(eq(1L), eq("fg-1"), eq("svc-hall"), eq("42"),
|
||||
anyString(), anyString(), any(), eq(9), eq("张三"), any());
|
||||
verify(applyRepository).markResubmitted(eq(42), eq(9011L), any(LocalDateTime.class));
|
||||
}
|
||||
@@ -299,4 +299,26 @@ class ServiceApplyFlowSubmitTest {
|
||||
assertEquals(ApplyFlowStatusEnum.REJECTED.getCode(), apply.getFlowStatus().intValue(),
|
||||
"出站失败保持原态,可再次重提");
|
||||
}
|
||||
@Test
|
||||
void productionResubmitUsesCommandContractWithoutBlindApprovingWrite() {
|
||||
apply.setFlowStatus(30); apply.setFcIntentId(1);
|
||||
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
|
||||
FlowApplyRecoveryService commands = mock(FlowApplyRecoveryService.class);
|
||||
service.setFlowCommandsForTest(commands);
|
||||
service.resubmit(user(9, 1), 42);
|
||||
verify(commands).requestExecution(42, true);
|
||||
verify(flowClient, never()).apply(anyLong(), anyString(), anyString(), anyString(), anyString(), any(), any(), anyInt(), anyString(), any());
|
||||
verify(applyRepository, never()).markResubmitted(anyInt(), anyLong(), any());
|
||||
assertEquals(30, apply.getFlowStatus().intValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void guardedApplyCannotFallBackToLegacyWhenCommandServiceMissing() {
|
||||
apply.setFlowStatus(30); apply.setFcIntentId(1);
|
||||
when(applyRepository.findByIdAndSchId(42, 1L)).thenReturn(Optional.of(apply));
|
||||
assertThrows(BizException.class, () -> service.resubmit(user(9, 1), 42));
|
||||
verify(flowClient, never()).apply(anyLong(), anyString(), anyString(), anyString(), anyString(), any(), any(), anyInt(), anyString(), any());
|
||||
assertEquals(30, apply.getFlowStatus().intValue());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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,47 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.yunhai.svchall.config.FcAnswerMqConfiguration;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Scanner;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* SH 的 MQ 消费入口默认关闭(2026-10-22 决策④:保留实现,不为技术统一而部署)。
|
||||
*
|
||||
* <p>关闭必须落在<b>启动期</b>而不是运行期判断:{@code @RabbitListener} 注解处理器在容器工厂缺失时
|
||||
* 直接抛异常打断启动,所以消费侧两个类都要挂 {@code @ConditionalOnExpression},且 yml 默认值
|
||||
* 必须是空(部署不显式注入 {@code RABBITMQ_HOST} 就没有消费入口)。这三件事缺一,"MQ 可选"
|
||||
* 就退化成"MQ 必部署"。以后真要接入:给环境注入 RABBITMQ_HOST 即可,实现原样保留。</p>
|
||||
*/
|
||||
@DisplayName("MQ 消费入口默认关 - 条件装配与空默认(决策④)")
|
||||
class ShMqEntryDefaultOffTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("消费者与拓扑配置都挂 spring.rabbitmq.host 条件:未配 broker 时 bean 不存在")
|
||||
void consumerEntryIsConditionallyAssembled() {
|
||||
ConditionalOnExpression consumer =
|
||||
FcAnswerConsumer.class.getAnnotation(ConditionalOnExpression.class);
|
||||
assertThat(consumer).as("FcAnswerConsumer 必须条件装配").isNotNull();
|
||||
assertThat(consumer.value()).contains("spring.rabbitmq.host");
|
||||
|
||||
ConditionalOnExpression configuration =
|
||||
FcAnswerMqConfiguration.class.getAnnotation(ConditionalOnExpression.class);
|
||||
assertThat(configuration).as("FcAnswerMqConfiguration 必须条件装配").isNotNull();
|
||||
assertThat(configuration.value()).contains("spring.rabbitmq.host");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("application.yml 默认 RABBITMQ_HOST 为空:不注入就没有消费入口(决策④默认关)")
|
||||
void ymlKeepsRabbitHostEmptyByDefault() {
|
||||
InputStream in = getClass().getClassLoader().getResourceAsStream("application.yml");
|
||||
assertThat(in).as("classpath 上应有 application.yml").isNotNull();
|
||||
String yml = new Scanner(in, StandardCharsets.UTF_8.name()).useDelimiter("\\A").next();
|
||||
assertThat(yml).contains("host: ${RABBITMQ_HOST:}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.yunhai.svchall.entity.FcAnswerProjection;
|
||||
import com.yunhai.svchall.repository.FcAnswerProjectionRepository;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 权威修复的 SQL 护栏(2026-10-22 决策③"提交时固化"的数据库侧一半)。
|
||||
*
|
||||
* <p>{@code repairIfUnchanged} 是对账唯一能改已落库投影的原语。办件受理后的审批证据固化在
|
||||
* {@code service_apply.form_data_json}(建件时抄一次),投影行只是<b>普通投影(数据面镜像)</b>;
|
||||
* 但镜像行上的关联列({@code intent_id}/{@code apply_id}/{@code link_state})与业务扩展列
|
||||
* ({@code biz_ext_json})一旦被修复顺手改写,"这份答卷是哪个办件的受理依据"就断了。
|
||||
* 这条 JPQL 的 SET 子句必须永远只含快照列——用注解字面量钉住,谁往里加关联列就红。</p>
|
||||
*/
|
||||
@DisplayName("权威修复 SQL 护栏 - repairIfUnchanged 只准刷快照列(决策③)")
|
||||
class ShProjectionRepairSqlGuardTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("SET 子句只含快照列+版本自增;WHERE 带期望三元组;关联列/业务扩展列绝不出现")
|
||||
void repairIfUnchangedUpdatesSnapshotColumnsOnly() throws NoSuchMethodException {
|
||||
Query query = FcAnswerProjectionRepository.class
|
||||
.getMethod("repairIfUnchanged", FcAnswerProjection.class, Long.class, String.class,
|
||||
Integer.class)
|
||||
.getAnnotation(Query.class);
|
||||
assertThat(query).as("repairIfUnchanged 必须是 @Query 条件更新").isNotNull();
|
||||
String jpql = query.value();
|
||||
|
||||
// 快照成员 + 本地写代数自增,全部在 SET 里
|
||||
assertThat(jpql)
|
||||
.contains("p.revision =")
|
||||
.contains("p.contentHash =")
|
||||
.contains("p.formVersion =")
|
||||
.contains("p.isDeleted =")
|
||||
.contains("p.valuesJson =")
|
||||
.contains("p.projectionVersion = p.projectionVersion + 1");
|
||||
// 条件更新裁决:期望三元组必须逐字出现在 WHERE
|
||||
assertThat(jpql)
|
||||
.contains("p.revision = :revision")
|
||||
.contains("p.contentHash = :hash")
|
||||
.contains("p.isDeleted = :deleted");
|
||||
// 关联列与业务扩展列不在 UPDATE 里(关联列保护 uk_projection_intent 的语义,扩展列是业务侧自留地)
|
||||
assertThat(jpql)
|
||||
.doesNotContain("intentId")
|
||||
.doesNotContain("applyId")
|
||||
.doesNotContain("linkState")
|
||||
.doesNotContain("bizExtJson");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,271 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* P3.1 应用内审批出站契约测试:端点 URL、参数拼装与<b>双值双查</b>(P3.2 方案B)行为。
|
||||
* 读面 bizType 必须按 {@code [svc-hall, svc_item]} 先终值后存量各查一次(todo 合并、byBiz 择命中);
|
||||
* 写面/换址形态与中心端点同构(taskId 在路径、opinion/edgeName 在 body、embed 记录页 instanceId 形态)。
|
||||
*/
|
||||
@DisplayName("SvcApprovalClient - oapi 契约与双值双查")
|
||||
class SvcApprovalClientTest {
|
||||
|
||||
private static final String TARGET = "flow-center";
|
||||
|
||||
private YschOpenCall openCall;
|
||||
private FlowClient flowClient;
|
||||
private SvcApprovalClient client;
|
||||
|
||||
/** 固定返回的 ObjectProvider 桩(构造注入用,沿 Notice FlowCenterClientTest 形态) */
|
||||
static class FixedProvider<T> implements org.springframework.beans.factory.ObjectProvider<T> {
|
||||
private final T value;
|
||||
|
||||
FixedProvider(T value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getObject() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getObject(Object... args) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getIfAvailable() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T getIfUnique() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
openCall = mock(YschOpenCall.class);
|
||||
flowClient = mock(FlowClient.class);
|
||||
when(flowClient.enabled()).thenReturn(true);
|
||||
client = new SvcApprovalClient(new FixedProvider<>(openCall), flowClient);
|
||||
}
|
||||
|
||||
|
||||
private static Map<String, Object> taskIdRow(int id) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("taskId", id);
|
||||
return m;
|
||||
}
|
||||
|
||||
private static Map<String, Object> page(long total, Object... rows) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("total", total);
|
||||
m.put("content", Arrays.asList(rows));
|
||||
m.put("pageIndex", 1);
|
||||
m.put("pageSize", 10);
|
||||
m.put("totalPages", 1);
|
||||
m.put("hasNext", false);
|
||||
return m;
|
||||
}
|
||||
|
||||
private static Map<String, Object> byBizData(Long instanceId, Object... tasks) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("instanceId", instanceId);
|
||||
m.put("bizType", "svc-hall");
|
||||
m.put("bizId", "42");
|
||||
m.put("status", 10);
|
||||
m.put("currentNodes", "n1");
|
||||
m.put("tasks", Arrays.asList(tasks));
|
||||
return m;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("todo:双值双查(先 svc-hall 后 svc_item)+ 分页合并")
|
||||
void todoQueriesBothBizTypesInOrderAndMerges() {
|
||||
Map<String, Object> cur = page(2, taskIdRow(1), taskIdRow(2));
|
||||
Map<String, Object> legacy = page(1, taskIdRow(3));
|
||||
when(openCall.get(eq(TARGET), eq("/oapi/flow/tasks/todo"), anyMap(), eq(Map.class)))
|
||||
.thenReturn(cur, legacy);
|
||||
|
||||
Map<String, Object> out = client.todoTasks(3, 10006, 1, 10);
|
||||
|
||||
ArgumentCaptor<Map<String, String>> q1 = ArgumentCaptor.forClass(Map.class);
|
||||
ArgumentCaptor<Map<String, String>> q2 = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall, times(2)).get(eq(TARGET), eq("/oapi/flow/tasks/todo"),
|
||||
q1.capture(), eq(Map.class));
|
||||
verify(openCall, times(2)).get(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), anyMap(), any());
|
||||
assertEquals("svc-hall", q1.getAllValues().get(0).get("bizType"));
|
||||
assertEquals("svc_item", q1.getAllValues().get(1).get("bizType"));
|
||||
assertEquals("3", q1.getAllValues().get(0).get("schId"));
|
||||
assertEquals("10006", q1.getAllValues().get(0).get("assigneeId"));
|
||||
assertEquals(3L, out.get("total"));
|
||||
assertEquals(3, ((List<?>) out.get("content")).size());
|
||||
assertEquals(1L, out.get("totalPages"));
|
||||
assertEquals(false, out.get("hasNext"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("byBiz:终值命中即返回(不查存量则不消耗第二次出站——命中即短路)")
|
||||
void byBizReturnsCurrentHit() {
|
||||
Map<String, Object> cur = byBizData(139L, taskIdRow(442));
|
||||
when(openCall.get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), anyMap(), eq(Map.class)))
|
||||
.thenReturn(cur);
|
||||
Map<String, Object> out = client.byBiz(3, 10006, "42");
|
||||
assertEquals(139L, ((Number) out.get("instanceId")).longValue());
|
||||
verify(openCall, times(1)).get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), anyMap(), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("byBiz:终值 miss(instanceId=null)时续查存量并返回命中行")
|
||||
void byBizFallsBackToLegacy() {
|
||||
Map<String, Object> curMiss = byBizData(null);
|
||||
Map<String, Object> legacyHit = byBizData(9011L);
|
||||
legacyHit.put("bizType", "svc_item");
|
||||
when(openCall.get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), anyMap(), eq(Map.class)))
|
||||
.thenReturn(curMiss, legacyHit);
|
||||
Map<String, Object> out = client.byBiz(3, 10006, "42");
|
||||
assertEquals(9011L, ((Number) out.get("instanceId")).longValue());
|
||||
ArgumentCaptor<Map<String, String>> q = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall, times(2)).get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), q.capture(), eq(Map.class));
|
||||
assertEquals("svc-hall", q.getAllValues().get(0).get("bizType"));
|
||||
assertEquals("svc_item", q.getAllValues().get(1).get("bizType"));
|
||||
assertEquals("42", q.getAllValues().get(0).get("bizId"));
|
||||
assertEquals("10006", q.getAllValues().get(0).get("operatorId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("byBiz:双查皆空返回首查应答(保持空 tasks 不探测语义)")
|
||||
void byBizBothMissReturnsFirst() {
|
||||
Map<String, Object> curMiss = byBizData(null);
|
||||
Map<String, Object> legacyMiss = byBizData(null);
|
||||
when(openCall.get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), anyMap(), eq(Map.class)))
|
||||
.thenReturn(curMiss, legacyMiss);
|
||||
Map<String, Object> out = client.byBiz(3, 10006, "42");
|
||||
assertTrue(((List<?>) out.get("tasks")).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("doneTasks:/oapi/flow/tasks/done,无 bizType 参数(中心按签名 appKey 过滤)")
|
||||
void doneTasksAssembly() {
|
||||
client.doneTasks(3, 10006, 2, 10);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, String>> q = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).get(eq(TARGET), eq("/oapi/flow/tasks/done"), q.capture(), eq(Map.class));
|
||||
assertEquals("3", q.getValue().get("schId"));
|
||||
assertEquals("10006", q.getValue().get("assigneeId"));
|
||||
assertEquals("2", q.getValue().get("page"));
|
||||
assertEquals("10", q.getValue().get("size"));
|
||||
assertTrue(!q.getValue().containsKey("bizType"), "P2-02 接缝:不传 bizType");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ccMine:/oapi/flow/cc,userId 维度,无 bizType 参数")
|
||||
void ccMineAssembly() {
|
||||
client.ccMine(3, 10006, 1, 10);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, String>> q = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).get(eq(TARGET), eq("/oapi/flow/cc"), q.capture(), eq(Map.class));
|
||||
assertEquals("10006", q.getValue().get("userId"));
|
||||
assertTrue(!q.getValue().containsKey("bizType"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("approve:taskId 入路径、身份入查询串、opinion/edgeName 入 body 且 trim")
|
||||
void approveAssembly() {
|
||||
client.approve(442L, 3, 10006, "张三", " 同意 ", " 退回 ");
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, String>> q = ArgumentCaptor.forClass(Map.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> body = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).post(eq(TARGET), eq("/oapi/flow/task/442/approve"),
|
||||
q.capture(), body.capture(), eq(Void.class));
|
||||
assertEquals("3", q.getValue().get("schId"));
|
||||
assertEquals("10006", q.getValue().get("operatorId"));
|
||||
assertEquals("张三", q.getValue().get("operatorName"));
|
||||
assertEquals("同意", body.getValue().get("opinion"));
|
||||
assertEquals("退回", body.getValue().get("edgeName"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("approve:空 opinion/edgeName 不入 body")
|
||||
void approveOmitsBlankFields() {
|
||||
client.approve(442L, 3, 10006, "张三", " ", null);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> body = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).post(eq(TARGET), eq("/oapi/flow/task/442/approve"),
|
||||
anyMap(), body.capture(), eq(Void.class));
|
||||
assertTrue(body.getValue().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("reject:opinion 入 body;路径 /task/{id}/reject")
|
||||
void rejectAssembly() {
|
||||
client.reject(443L, 3, 10006, "张三", "不同意");
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> body = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).post(eq(TARGET), eq("/oapi/flow/task/443/reject"),
|
||||
anyMap(), body.capture(), eq(Void.class));
|
||||
assertEquals("不同意", body.getValue().get("opinion"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("embedRecordUrl:mode=record + instanceId 形态(非 bizType+bizId)")
|
||||
void embedRecordUrlUsesInstanceIdForm() {
|
||||
client.embedRecordUrl(3, 139L);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, String>> q = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).get(eq(TARGET), eq("/oapi/flow/embed/url"), q.capture(), eq(Map.class));
|
||||
assertEquals("record", q.getValue().get("mode"));
|
||||
assertEquals("139", q.getValue().get("instanceId"));
|
||||
assertEquals("3", q.getValue().get("schId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("taskPageUrl:mode=task + taskId")
|
||||
void taskPageUrlAssembly() {
|
||||
client.taskPageUrl(3, 442L);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, String>> q = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).get(eq(TARGET), eq("/oapi/flow/embed/url"), q.capture(), eq(Map.class));
|
||||
assertEquals("task", q.getValue().get("mode"));
|
||||
assertEquals("442", q.getValue().get("taskId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("流程中心未接入:一律 FORBIDDEN 且零出站")
|
||||
void disabledRefusesAll() {
|
||||
when(flowClient.enabled()).thenReturn(false);
|
||||
assertThrows(BizException.class, () -> client.todoTasks(3, 10006, 1, 10));
|
||||
assertThrows(BizException.class, () -> client.byBiz(3, 10006, "42"));
|
||||
assertThrows(BizException.class, () -> client.approve(1L, 3, 1, "n", null, null));
|
||||
assertThrows(BizException.class, () -> client.embedRecordUrl(3, 1L));
|
||||
verify(openCall, never()).get(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), anyMap(), any());
|
||||
verify(openCall, never()).post(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), anyMap(), any(), any());
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ class SvcFlowCallbackHandlerTest {
|
||||
private ServiceApplyRepository applyRepository;
|
||||
private SvcFlowCallbackHandler handler;
|
||||
private ServiceApply apply;
|
||||
private com.yunhai.svchall.repository.FlowApplyCommandRepository commands;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
@@ -40,10 +41,21 @@ class SvcFlowCallbackHandlerTest {
|
||||
apply = new ServiceApply();
|
||||
apply.setId(7);
|
||||
apply.setSchId(1L);
|
||||
apply.setFlowGuid("fg-1");
|
||||
apply.setFlowStatus(10);
|
||||
apply.setLastInstanceVersion(1);
|
||||
when(applyRepository.findByIdForUpdate(7)).thenReturn(Optional.of(apply));
|
||||
when(applyRepository.save(any(ServiceApply.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
commands = mock(com.yunhai.svchall.repository.FlowApplyCommandRepository.class);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(handler, "commandRepository", commands);
|
||||
for (int round = 1; round <= 2; round++) {
|
||||
com.yunhai.svchall.entity.FlowApplyCommand c = new com.yunhai.svchall.entity.FlowApplyCommand();
|
||||
c.setApplyId(7); c.setSchId(1L); c.setAttempt(round); c.setRequestToken("token-" + round);
|
||||
c.setFlowGuid("fg-1"); c.setSentAt(java.time.LocalDateTime.now()); c.setPayloadJson("{}");
|
||||
when(commands.findByApplyIdAndAttempt(7, round)).thenReturn(Optional.of(c));
|
||||
}
|
||||
com.yunhai.svchall.entity.FlowApplyCommand first = commands.findByApplyIdAndAttempt(7, 1).get();
|
||||
when(commands.findByApplyIdOrderByIdDesc(7)).thenReturn(java.util.Collections.singletonList(first));
|
||||
}
|
||||
|
||||
private FlowCallbackEvent event(String eventId, String event, Integer version) {
|
||||
@@ -60,6 +72,7 @@ class SvcFlowCallbackHandlerTest {
|
||||
e.setOperatorUserId(201);
|
||||
e.setOperatorName("张三");
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("applyRound", 1); data.put("requestToken", "token-1");
|
||||
e.setData(data);
|
||||
return e;
|
||||
}
|
||||
@@ -162,4 +175,133 @@ class SvcFlowCallbackHandlerTest {
|
||||
assertThrows(org.springframework.dao.DataAccessResourceFailureException.class,
|
||||
() -> handler.onEvent(event("e10", "flow.completed", 3)));
|
||||
}
|
||||
|
||||
// ===== P1-02 行级事件绑定校验(双值白名单下"收"≠"属于该行") =====
|
||||
|
||||
@Test
|
||||
void binding_schoolMismatch_rejectedWithoutAdvance() {
|
||||
FlowCallbackEvent e = event("b1", "flow.completed", 3);
|
||||
e.setSchId(2); // 办件行 schId=1
|
||||
handler.onEvent(e); // 200 消费但不推进(不抛)
|
||||
assertEquals(10, apply.getFlowStatus().intValue(), "学校不匹配拒收,状态不动");
|
||||
assertNull(apply.getFlowResultJson());
|
||||
org.mockito.Mockito.verify(applyRepository, org.mockito.Mockito.never()).save(any(ServiceApply.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void binding_instanceMismatch_rejectedWithoutAdvance() {
|
||||
apply.setFlowInstanceId(100L); // 行已镜像实例 100
|
||||
FlowCallbackEvent e = event("b2", "flow.completed", 3); // 事件实例 9527
|
||||
handler.onEvent(e);
|
||||
assertEquals(10, apply.getFlowStatus().intValue(), "实例不匹配拒收,状态不动");
|
||||
org.mockito.Mockito.verify(applyRepository, org.mockito.Mockito.never()).save(any(ServiceApply.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void binding_rowWithoutInstance_firstCallbackAllowed() {
|
||||
// 首发回调早于 apply 响应回填的合法窗口:行无镜像实例 → 放行推进
|
||||
assertNull(apply.getFlowInstanceId());
|
||||
handler.onEvent(event("b3", "flow.task.created", 2));
|
||||
assertEquals(10, apply.getFlowStatus().intValue());
|
||||
assertEquals(2, apply.getLastInstanceVersion().intValue());
|
||||
}
|
||||
|
||||
// ===== P1-03 R4 时序(H-9 三场景静态化:可控顺序注入,无真实延迟) =====
|
||||
|
||||
/** 场景 1:新轮回调先于 apply 响应(行仍 30 终态,新轮 submitted v4 先到) */
|
||||
@Test
|
||||
void r4_newRoundCallbackBeforeApplyResponse_advances() {
|
||||
apply.setFlowStatus(30);
|
||||
apply.setFlowResultJson("{\"event\":\"flow.rejected\"}");
|
||||
apply.setFinishTime(java.time.LocalDateTime.now());
|
||||
apply.setLastInstanceVersion(3);
|
||||
handler.onEvent(nextRound(event("r1", "flow.submitted", 4)));
|
||||
assertEquals(10, apply.getFlowStatus().intValue(), "新轮(版本更高)回调放行,回开为 10");
|
||||
assertEquals(4, apply.getLastInstanceVersion().intValue());
|
||||
assertNull(apply.getFinishTime(), "回开为在途时清残留 finishTime");
|
||||
assertTrue(apply.getFlowResultJson().contains("flow.submitted"));
|
||||
}
|
||||
|
||||
/** 场景 2:新轮快速终态(行仍 30,新轮 completed v4 在 markResubmitted 前落定) */
|
||||
@Test
|
||||
void r4_newRoundQuickTerminal_overwritesOldResult() {
|
||||
apply.setFlowStatus(30);
|
||||
apply.setFlowResultJson("{\"event\":\"flow.rejected\",\"opinion\":\"旧轮意见\"}");
|
||||
apply.setFinishTime(java.time.LocalDateTime.now());
|
||||
apply.setLastInstanceVersion(3);
|
||||
FlowCallbackEvent e = nextRound(event("r2", "flow.completed", 4));
|
||||
e.getData().put("result", "APPROVED");
|
||||
handler.onEvent(e);
|
||||
assertEquals(20, apply.getFlowStatus().intValue(), "新轮终态覆盖旧轮终态");
|
||||
assertNotNull(apply.getFinishTime());
|
||||
assertTrue(apply.getFlowResultJson().contains("APPROVED"), "结果快照按新轮覆盖");
|
||||
assertEquals(4, apply.getLastInstanceVersion().intValue());
|
||||
}
|
||||
|
||||
/** 场景 3:旧轮迟到/重复(版本更低被乱序门丢弃;同版本另一终态被结果守卫拒绝;同版本在途被终态守卫拒绝) */
|
||||
@Test
|
||||
void r4_oldRoundLateDuplicate_rejected() {
|
||||
apply.setFlowStatus(30);
|
||||
apply.setFlowResultJson("{\"event\":\"flow.rejected\"}");
|
||||
apply.setLastInstanceVersion(3);
|
||||
String frozen = apply.getFlowResultJson();
|
||||
|
||||
handler.onEvent(event("r3a", "flow.completed", 2)); // 旧版本:乱序门丢弃
|
||||
handler.onEvent(event("r3b", "flow.withdrawn", 3)); // 同版本另一终态:结果已落定拒
|
||||
handler.onEvent(event("r3c", "flow.task.handled", 3)); // 同版本在途:终态不回退拒
|
||||
assertEquals(30, apply.getFlowStatus().intValue(), "旧轮事件全部拒收");
|
||||
assertEquals(frozen, apply.getFlowResultJson(), "旧轮结果不被覆盖");
|
||||
}
|
||||
private FlowCallbackEvent nextRound(FlowCallbackEvent e) {
|
||||
e.getData().put("applyRound", 2); e.getData().put("requestToken", "token-2"); return e;
|
||||
}
|
||||
|
||||
@Test
|
||||
void higherVersionInSameRoundDoesNotProveRestart() {
|
||||
apply.setFlowStatus(30); apply.setLastInstanceVersion(3);
|
||||
apply.setFlowResultJson("{\"applyRound\":1,\"result\":\"REJECTED\"}");
|
||||
handler.onEvent(event("old-high-version", "flow.submitted", 99));
|
||||
assertEquals(30, apply.getFlowStatus().intValue());
|
||||
assertEquals(3, apply.getLastInstanceVersion().intValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oldRoundBacklogCannotOverwriteNewRoundTerminalEvenWithHigherVersion() {
|
||||
apply.setFlowStatus(20); apply.setLastInstanceVersion(5);
|
||||
apply.setFlowResultJson("{\"applyRound\":2,\"result\":\"APPROVED\"}");
|
||||
String frozen = apply.getFlowResultJson();
|
||||
handler.onEvent(event("old-round", "flow.submitted", 100));
|
||||
handler.onEvent(event("old-round-terminal", "flow.rejected", 100));
|
||||
assertEquals(20, apply.getFlowStatus().intValue()); assertEquals(frozen, apply.getFlowResultJson());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenMismatchOrUnsentCommandCannotAuthorizeNewRound() {
|
||||
apply.setFlowStatus(40); apply.setLastInstanceVersion(3);
|
||||
FlowCallbackEvent bad = nextRound(event("wrong-token", "flow.submitted", 4));
|
||||
bad.getData().put("requestToken", "foreign"); handler.onEvent(bad);
|
||||
assertEquals(40, apply.getFlowStatus().intValue());
|
||||
commands.findByApplyIdAndAttempt(7, 2).get().setSentAt(null);
|
||||
handler.onEvent(nextRound(event("unsent", "flow.submitted", 5)));
|
||||
assertEquals(40, apply.getFlowStatus().intValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateNewRoundCallbackKeepsTerminalAndResult() {
|
||||
apply.setFlowStatus(30); apply.setLastInstanceVersion(3);
|
||||
FlowCallbackEvent completed = nextRound(event("new-done", "flow.completed", 4));
|
||||
handler.onEvent(completed); String frozen = apply.getFlowResultJson();
|
||||
handler.onEvent(completed);
|
||||
handler.onEvent(nextRound(event("late-task", "flow.task.created", 4)));
|
||||
assertEquals(20, apply.getFlowStatus().intValue()); assertEquals(frozen, apply.getFlowResultJson());
|
||||
}
|
||||
|
||||
@Test
|
||||
void managedCommandCannotAdvanceFromCallbackWithoutRoundIdentity() {
|
||||
apply.setFlowStatus(30); apply.setLastInstanceVersion(3);
|
||||
FlowCallbackEvent e = event("missing-round", "flow.submitted", 99); e.getData().clear();
|
||||
handler.onEvent(e);
|
||||
assertEquals(30, apply.getFlowStatus().intValue()); assertEquals(3, apply.getLastInstanceVersion().intValue());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user