4 Commits
Author SHA1 Message Date
chenxing 1717f4c47e Run mandatory RC gates with isolated CI failure checks
build / build-push (push) Successful in 3m13s
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:51:19 +08:00
chenxing 6ff39b0cec Define authoritative reconciliation acknowledgements
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:51:16 +08:00
chenxing c6e061815a Enforce exact published form version contracts
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:51:13 +08:00
chenxing e39164a2bd Preserve reviewed existing gate prerequisites
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:51:10 +08:00
20 changed files with 722 additions and 60 deletions
+25 -2
View File
@@ -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:
@@ -36,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 字符
@@ -64,6 +72,19 @@ jobs:
# 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 \
@@ -71,13 +92,14 @@ jobs:
-w /build maven:3.6.3-jdk-8 sleep 3600
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 && mvn -B -DskipTests clean package'
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 "$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)
@@ -88,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" \
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
TESTS=FcAnswerDeliveryTest,FcDiscoveryAccessTest,FormConsumerGrantPushGateTest,FcReconcileTaskTest,OapiFormControllerTest,FcEventPublisherTest,AnonReplayGuardTest,AppCodeReconcileRoutingTest,OapiSchemaPublishedVersionTest
# 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
+43 -1
View File
@@ -7,6 +7,11 @@
依赖:TASK_1《consumer-access-v3.md》的 `FormConsumerAccessService`(owner/read/subscribe 与
`grantVersion`)是唯一授权事实源;本契约不写第二套 ACL,只消费它。
**现行主链(2026-10-22 决策,10-23 复核)**:SH 以签名收据 + HTTP 确认建件,不依赖 MQ 快照
投递行、broker confirm 或 MQ 消费成功;`requireRead + requireSubscribe` 仍是可信上下文的授权前提。
下文 outbox/MQ 章节描述可选推送通道,不能据此把 MQ 重新设为建件必选项。
MQ 隔离、`push_allowed` 全量契约与投递状态覆盖修复另案交付,不能由本轮 FL 门禁变绿推定已完成。
---
## 1. 协议版本差额(先说清"新增了什么、旧的那条怎么算")
@@ -116,7 +121,7 @@ SH POST /api/apply/intents(body 只有 siteCode/itemCode/clientKey)
└ POST /api/form/hosted/submit(body 同 /api/form/submit + contextToken)
FC 在答卷事务内:redeem(存在→未过期→会话用户=断言填报人→会话校=上下文租户域,
错人/错校/过期/不存在一律拒)→ 坐标逐字核对 → 落答卷(answer_revision=1)
→ 原子关联 submission_context.answer_uuid(uk 双向唯一)→ 同事务写 outbox
→ 原子关联 submission_context.answer_uuid(uk 双向唯一)→ 满足推送条件的消费方才同事务写 outbox
→ 同 token 同幂等键=原答卷重放;换幂等键重用=拒绝
└ 成功只 postMessage {proto:"fc",v:1,type:"success",hosted:true} 给**服务端锁定的精确 origin**
(不带 answerId、不带版本号;referrer 回落与同 hostname 降级都不可用)
@@ -205,6 +210,19 @@ v2 快照带完整值文档,"能连上 broker 的任何人可见"是不可接
关键取舍:**不靠异常类型猜"远端有没有受理"**。查到 30/40 只回填镜像状态、**绝不重发 apply**
(重发会命中中心 R4 重启,把已拒绝/已撤回的件重新跑起来);只有查无才用同一 `requestToken` 补发。
### 7.3a flow-center 管理面绑定失败(2026-10-23 R2/R1)
关联表单的流程定义必须绑定 `formUuid + formVersion`;保存显式关联、发布与启用都按
`schema?schId=&uuid=&version=` 核验同一明确发布版本。UUID-only 定义没有长期回退,缺版本必须重新选择。
独立无表单流程保留;孤立版本号拒绝。FC 精确 schema 只允许已发布过的版本(`publish_time != null`),
拒绝草稿;历史发布版不要求是 currentVersion,也不改变历史答卷读取路径。
FL 信封稳定码:`46101 VERSION_REQUIRED`、`46102 INVALID_COORDINATE`、
`46103 SCHEMA_UNAVAILABLE`(可重试)、`46104 SCHEMA_CONTRACT_VIOLATION`、
`46105 RESOURCE_UNAVAILABLE`、`46106 ACCESS_DENIED`。除 46103 外均不能盲目重试。
FC schema 业务码 2 将不存在/不可读/未发布统一拒绝,FL 映射为 46105,不按文案探测资源存在性。
详见 [FL 精确版本契约](../../../flow-center/service/docs/form-binding-contract.md)。
### 7.4 flow-center 发起守卫分支(`FlowApplyRetryModels.ApplyResultV2.branch`,稳定码)
| 码 | 判据 | 是否重启流程 |
@@ -298,3 +316,27 @@ svc-hall:普通消费保留 `AnswerProjectionStore.applySnapshot(SnapshotEnvel
`svc-hall/server/sql/fc_projection_and_apply_intent.sql`、
`flow-center/service/src/main/resources/sql/add_flw_apply_request_identity.sql`)
都是**非幂等**脚本,生产需人工执行;`ddl-auto=update` 的开发库会自动追加,两侧不能长期分叉。
## 11. R3/R4 对账结果回执裁决(2026-10-23 收口)
本节是对账 `submitResult` 协议,不修改 MQ publisher confirm/return 或投递状态机(B-09 另案)。
回执新增 `decision`、`reportAttempt`(本次回报轮次)、`resultState`(本轮已接受的结论);
既有 `attempt` 继续表示中心当前派发轮次,`taskState` 表示中心任务状态,不能单凭它推断本次旧回报被采纳。
旧回报永不推进中心新轮。终态权威必须由 `CENTER_TERMINAL` 显式声明,不以 stale=true 附带的诊断状态冒充。
| 条件 | decision | FC 写入 | SH 处置 |
|---|---|---|---|
| 当前 attempt 首次合法结果 | CURRENT_ACCEPTED | 持久结果;RUNNING 续租,终态按已有规则收敛 | 按 resultState 回填;RUNNING 保留租约,失败不能变成功 |
| 当前 attempt 重复已终结结果 | CURRENT_DUPLICATE | 不覆盖首次结果、不重复计数 | 使用首次 resultState;不采信重报自己宣称的新结果 |
| 旧 attempt,中心尚非终态 | STALE_RETRY | 仅归档;已有终结结果不覆盖;不续新轮租约、不推进任务 | 清本地租约,QUEUED 等重派/重试;不把诊断 taskState 当本轮成功 |
| 中心已终态(包括旧 attempt 回报) | CENTER_TERMINAL | 不改中心终态与新轮结果 | 身份、轮次回声验证通过后镜像中心真实终态;FAILED/PARTIAL/CANCELLED 不伪装 COMPLETED |
| 回报超出已派发 attempt | HTTP 业务拒绝 | 零结果/状态变更 | 保留可重试/人工诊断,不记成功 |
| 发送异常、不可达、拒绝、空或不匹配回执 | 无有效确认 | 不推定已成功 | QUEUED + pendingReportState,仅重报;有界退避,耗尽 FAILED |
| 缺新 decision 的旧中心回执且 stale=true | 保守兼容 | — | 保持 QUEUED,不信任附带终态;下一次完整回执再收敛 |
当前/重复成功回执必须匹配 taskId,reportAttempt 若提供必须等于本地发送 attempt。
新协议 current 决策不得确认其他轮次;CENTER_TERMINAL 的中心 attempt 不得低于本地执行轮。
乱序旧回执在本地持久化时仍受 work_version 乐观锁保护,不覆盖重派或新执行轮。
有效租约 RUNNING 的重派不改变执行 attempt、租约、检查点或 work_version;过期(含相等)后才能重新排队。
接收与领取均用同一任务行的悲观锁;领取时重新核对状态和退避时刻,陈旧扫描候选不能双领。
+12
View File
@@ -362,3 +362,15 @@ Worker 每轮从**同一水位**读两侧一页(中心清单 + `AnswerProjecti
|---|---|---|
| 2026-10-21 | v1 落地:任务坐标到表单为止(全量=按订阅裂变)、清单不可变物化 + `completed` 唯一结束判据、双流锁步水位、缺席证明六前置、`RepairResult` 四分支与整笔回滚后事务外重读、派发 2xx 与终态严格分离、调度与派发双闸默认关 | 任务书 TASK_3 改动点 1–8 与 `TASK_2_PREFIX_CHECK.md` 接入清单 |
| 2026-10-21 | 明确不接触 `application.yml`:全部键以 `@ConfigurationProperties`/`@Value` 默认值交付,env 注入生效 | 约束「配置在本任务独立 `@Configuration` 中,不争用 TASK_2 配置」 |
## 2026-10-23 R3/R4 收口
`ResultAck.decision` 采用 CURRENT_ACCEPTED / CURRENT_DUPLICATE / STALE_RETRY / CENTER_TERMINAL。
`reportAttempt` 回声本次回报,既有 `attempt` 为中心派发轮次;`resultState` 表示被采纳或首次保存的结果。
旧轮不推进新轮;只有显式 CENTER_TERMINAL 可以在 stale=true 时令 SH 镜像中心终态并停止重试。
缺少有效确认、身份/轮次不匹配、网络/不可路由保持重报,不得记成功。
完整状态矩阵以 [answer-delivery-v2 §11](answer-delivery-v2.md#11-r3r4-对账结果回执裁决2026-10-23-收口) 为准。
MQ publisher confirm/return 与 B-09 仍是独立投递状态机,本批没有修改。
有效租约中的 SH RUNNING 行保留执行 attempt 与 work_version;过期或终态再接纳重派。
行锁领取、领取时二次到期判定、work_version 过期执行者保护共同防止旧执行清除新租约。
@@ -53,7 +53,8 @@ public class AnswerReconcileTask extends BaseEntity {
/** 终态:不再被派发扫描领取,也不接受进度回退 */
public static boolean isTerminal(String state) {
return STATE_COMPLETED.equals(state) || STATE_FAILED.equals(state) || STATE_CANCELLED.equals(state);
return STATE_COMPLETED.equals(state) || STATE_PARTIAL.equals(state)
|| STATE_FAILED.equals(state) || STATE_CANCELLED.equals(state);
}
/** 活跃态:同一坐标上只允许一个(单并发),且这些状态要受租约与取消约束 */
@@ -51,10 +51,18 @@ public class FormConsumerGrant extends BaseEntity {
@Column(name = "read_allowed", nullable = false, columnDefinition = "tinyint")
private Integer readAllowed = 0;
/** 订阅授权(答卷变更事件投递):1=允许 */
/** 订阅授权(答卷变更事件的数据订阅权,投影应用/权威对账授权都判它):1=允许 */
@Column(name = "subscribe_allowed", nullable = false, columnDefinition = "tinyint")
private Integer subscribeAllowed = 0;
/**
* MQ broker 推送投递(2026-10-22 决策②:读取/订阅/MQ 推送三权分立)。
* 1=该订阅的事件进 outbox 并经 broker 投递;<b>0=HTTP-only(拉取+收据确认),不建 outbox 行</b>,
* 避免没接 broker 的消费方在 fc_answer_delivery 里长期 PENDING。翻转让 grant_version 进位。
*/
@Column(name = "push_allowed", nullable = false, columnDefinition = "tinyint")
private Integer pushAllowed = 0;
/** 生效开关:0=已撤销(保留行与 grant_version 供审计,撤销后后续读取/发布检查即失败) */
@Column(name = "enabled", nullable = false, columnDefinition = "tinyint")
private Integer enabled = 1;
@@ -47,6 +47,8 @@ public final class FormAccessModels {
private String consumerAppKey;
private boolean readAllowed;
private boolean subscribeAllowed;
/** MQ broker 推送投递开关:false=HTTP-only(拉取+收据确认),入队侧不建 outbox 行 */
private boolean pushAllowed;
private int grantVersion;
}
@@ -101,11 +103,13 @@ public final class FormAccessModels {
private String consumerAppKey;
private boolean readAllowed;
private boolean subscribeAllowed;
/** MQ broker 推送投递开关(运维据此看出哪个消费方在吃推送) */
private boolean pushAllowed;
private boolean enabled;
private int grantVersion;
}
/** grant 授权请求(三元键 + 两把开关;不传开关=只给读,默认拒绝) */
/** grant 授权请求(三元键 + 三把开关;不传开关=只给读,默认拒绝;push 依赖 subscribe) */
@Getter
@Setter
public static class GrantRequest {
@@ -114,6 +118,8 @@ public final class FormAccessModels {
private String consumerAppKey;
private Boolean readAllowed;
private Boolean subscribeAllowed;
/** MQ推送:缺省/null对已有行保持、新行默认false;显式false才关闭,true依赖subscribe。 */
private Boolean pushAllowed;
}
/** grant 撤销请求 */
@@ -214,6 +214,10 @@ public final class FormSyncModels {
/** 比中心已记录的最新 attempt 更旧:留档但不推进状态 */
private Boolean staleAttempt;
private String taskState;
/** CURRENT_ACCEPTED / CURRENT_DUPLICATE / STALE_RETRY / CENTER_TERMINAL */
private String decision;
private Integer reportAttempt;
private String resultState;
private String message;
}
@@ -45,9 +45,16 @@ public class AnonReplayGuard {
private static final char[] HEX = "0123456789abcdef".toCharArray();
private final String secret;
private final java.time.Clock clock;
@org.springframework.beans.factory.annotation.Autowired
public AnonReplayGuard(@Value("${yh.app-auth.app-secret:}") String secret) {
this(secret, java.time.Clock.systemUTC());
}
AnonReplayGuard(String secret, java.time.Clock clock) {
this.secret = secret == null ? "" : secret.trim();
this.clock = java.util.Objects.requireNonNull(clock);
}
public static String cookieName(String formCode) {
@@ -66,7 +73,7 @@ public class AnonReplayGuard {
*/
public String issue(String formCode) {
requireSecret();
return issuedAt(formCode, System.currentTimeMillis() / 1000L);
return issuedAt(formCode, clock.instant().getEpochSecond());
}
/**
@@ -89,7 +96,7 @@ public class AnonReplayGuard {
if (issuedAt == null) {
return 0L;
}
long remaining = issuedAt + window - System.currentTimeMillis() / 1000L;
long remaining = issuedAt + window - clock.instant().getEpochSecond();
return remaining > 0 ? remaining : 0L;
}
@@ -138,7 +145,7 @@ public class AnonReplayGuard {
}
// 未来时间戳按"刚提交过"处理(窗口不越过配置值):签名有效说明它是本机签发的,
// 时钟抖动/机器时间偏快都不该把窗口拉长,更不该反过来把过去的票据顶成"刚刚"
return Math.min(issuedAt, System.currentTimeMillis() / 1000L);
return Math.min(issuedAt, clock.instant().getEpochSecond());
}
/** 签发指定制表时刻的票据值(窗口边界用例要用"过去的时间戳",包内可见即可,不对外暴露) */
@@ -193,6 +193,9 @@ public class AnswerDeliveryService {
* 在<b>调用方事务内</b>为该答卷的当前 revision 建逐订阅投递行。
*
* <p>无订阅的简单表单返回空列表——答卷照常提交,不创建任何消费投递记录(B.2 末句)。
* <b>HTTP-only 订阅(未开 {@code push_allowed})同样不建行</b>(2026-10-22 决策②):
* 拉取/收据/对账那条授权还在,但没有 broker 私有队列的消费方进了 outbox 就只能长期 PENDING——
* 宁不建行,也不制造积压假象;哪天它真接了 broker,把 push 翻开(grant_version 进位)即可。
* 撞 {@code uk_delivery_revision} 说明另一个事务已把同一 revision 投出去了(CAS 没挡住的极端形态),
* 整事务回滚是唯一安全处置,不做"改号重试"。</p>
*/
@@ -207,7 +210,14 @@ public class AnswerDeliveryService {
}
String contentHash = snapshots.contentHash(answer);
LocalDateTime occurredAt = answer.getUpdateTime() == null ? LocalDateTime.now() : answer.getUpdateTime();
int queued = 0;
for (SubscriptionGrant grant : grants) {
if (!grant.isPushAllowed()) {
log.info("[v2投递] HTTP-only 订阅跳过入队(未开 push_allowed,不建 outbox 行):consumer={}@{}",
grant.getConsumerAppKey(), grant.getConsumerSchoolId());
continue;
}
queued++;
AnswerDelivery row = new AnswerDelivery();
row.setEventId(UUID.randomUUID().toString());
row.setProtocolVersion(AnswerDeliveryModels.PROTOCOL_VERSION);
@@ -235,9 +245,9 @@ public class AnswerDeliveryService {
throw new BizException(ErrorCode.CUSTOM, "并发修改冲突(同一快照修订号已有投递行),本次提交回滚,请重试");
}
}
log.info("[v2投递] 入队 answer={} rev={} event={} 目标={}", answer.getUuid(),
answer.getAnswerRevision(), eventType, grants.size());
return grants.size();
log.info("[v2投递] 入队 answer={} rev={} event={} 目标={}/订阅 {}", answer.getUuid(),
answer.getAnswerRevision(), eventType, queued, grants.size());
return queued;
}
private String writeSnapshot(Answer answer, SubscriptionGrant grant) {
@@ -388,11 +398,15 @@ public class AnswerDeliveryService {
}
}
/** 消费方在该答卷上的当前生效订阅(只走 TASK_1 的判据,本类不拼第二套 ACL) */
/**
* 消费方在该答卷上的当前生效订阅(只走 TASK_1 的判据,本类不拼第二套 ACL)。
* 发布侧同样只认开了 {@code push_allowed} 的 grant:推送被降级回 HTTP-only 的存量行
* 在这里复核不到(降级即 grant_version 进位,双保险),按撤权处置而不是照发。
*/
private SubscriptionGrant findLiveGrant(AnswerDelivery row) {
for (SubscriptionGrant grant : accessService.listEnabledSubscriptions(row.getFormUuid(),
row.getConsumerSchoolId())) {
if (row.getConsumerAppKey().equals(grant.getConsumerAppKey())) {
if (row.getConsumerAppKey().equals(grant.getConsumerAppKey()) && grant.isPushAllowed()) {
return grant;
}
}
@@ -736,38 +736,33 @@ public class AnswerReconcileService {
"回报的 attempt=" + attempt + " 超过中心已派发的 " + dispatchedAttempt);
}
Optional<AnswerReconcileResult> repeat = resultRepository.findByTaskIdAndAttempt(task.getId(), attempt);
if (AnswerReconcileTask.isTerminal(task.getState())
|| (repeat.isPresent() && !AnswerReconcileTask.STATE_RUNNING.equals(repeat.get().getState()))) {
ResultAck ack = toResultAck(task);
ack.setAccepted(true);
ack.setDuplicate(true);
ack.setMessage("任务或本轮结果已终结,仅幂等确认,不恢复执行");
return ack;
Integer latest = resultRepository.maxAttempt(task.getId());
boolean stale = attempt < dispatchedAttempt || (latest != null && attempt < latest);
if (AnswerReconcileTask.isTerminal(task.getState())) {
return resultAck(task, attempt, "CENTER_TERMINAL", true, stale, null);
}
String reportState = normalizeReportState(report.getState());
if (stale) {
// 旧轮结论只能归档;重复旧终态也不能被后来回报改写。
boolean duplicate = repeat.isPresent() && !AnswerReconcileTask.STATE_RUNNING.equals(repeat.get().getState());
if (!duplicate) persistResult(task, attempt, report);
return resultAck(task, attempt, "STALE_RETRY", duplicate, true,
duplicate ? repeat.get().getState() : reportState);
}
if (repeat.isPresent() && !AnswerReconcileTask.STATE_RUNNING.equals(repeat.get().getState())) {
return resultAck(task, attempt, "CURRENT_DUPLICATE", true, false, repeat.get().getState());
}
if (AnswerReconcileTask.STATE_COMPLETED.equals(reportState)
&& (!Boolean.TRUE.equals(report.getManifestComplete())
|| !Boolean.TRUE.equals(report.getLocalScanComplete())
|| !Integer.valueOf(1).equals(task.getManifestComplete()))) {
throw new BizException(ErrorCode.PARAM_ERROR, "INCOMPLETE_RESULT:两侧扫描未完整结束,不能回报COMPLETED");
}
Integer latest = resultRepository.maxAttempt(task.getId());
boolean staleAttempt = attempt < dispatchedAttempt || (latest != null && attempt < latest);
if (repeat.isPresent() && AnswerReconcileTask.STATE_RUNNING.equals(reportState) && !staleAttempt) {
advanceByReport(task, report, repeat.get());
return toResultAck(taskRepository.findById(task.getId()).orElse(task));
}
AnswerReconcileResult row = persistResult(task, attempt, report);
ResultAck ack = toResultAck(task);
ack.setAccepted(true);
ack.setDuplicate(false);
ack.setStaleAttempt(staleAttempt);
if (staleAttempt) {
ack.setMessage("旧 attempt=" + attempt + " 的回报只留档,不推进任务状态(最新 attempt=" + latest + ")");
return ack;
}
AnswerReconcileResult row = repeat.isPresent() && AnswerReconcileTask.STATE_RUNNING.equals(reportState)
? repeat.get() : persistResult(task, attempt, report);
advanceByReport(task, report, row);
return toResultAck(taskRepository.findById(task.getId()).orElse(task));
return resultAck(taskRepository.findById(task.getId()).orElse(task), attempt,
"CURRENT_ACCEPTED", false, false, reportState);
}
private AnswerReconcileResult persistResult(AnswerReconcileTask task, int attempt, ResultReport report) {
@@ -866,11 +861,18 @@ public class AnswerReconcileService {
return out;
}
private ResultAck toResultAck(AnswerReconcileTask task) {
private ResultAck resultAck(AnswerReconcileTask task, int reportAttempt, String decision,
boolean duplicate, boolean stale, String resultState) {
ResultAck ack = new ResultAck();
ack.setTaskId(task.getTaskUuid());
ack.setAttempt(task.getAttempt());
ack.setReportAttempt(reportAttempt);
ack.setTaskState(task.getState());
ack.setDecision(decision);
ack.setAccepted(true);
ack.setDuplicate(duplicate);
ack.setStaleAttempt(stale);
ack.setResultState(resultState);
return ack;
}
@@ -283,9 +283,18 @@ public class FormConsumerAccessService {
.orElse(null);
boolean read = Boolean.TRUE.equals(req.getReadAllowed());
boolean subscribe = Boolean.TRUE.equals(req.getSubscribeAllowed());
// 旧客户端没有 pushAllowed;缺省/null 只在新建时默认关闭,已有授权保持原值。
boolean push = req.getPushAllowed() == null
? grant != null && isOne(grant.getPushAllowed())
: Boolean.TRUE.equals(req.getPushAllowed());
if (!read && !subscribe) {
throw new BizException(ErrorCode.PARAM_ERROR, "readAllowed / subscribeAllowed 至少开一把(默认拒绝)");
}
if (push && !subscribe) {
// 推送投递的是订阅数据,订阅权没开就谈不上推送(决策②的三权分立)
throw new BizException(ErrorCode.PARAM_ERROR,
"pushAllowed 依赖订阅授权;关闭订阅时请显式设置 pushAllowed=false,或保留 subscribeAllowed");
}
if (grant == null) {
grant = new FormConsumerGrant();
grant.setFormUuid(form.getUuid());
@@ -294,19 +303,21 @@ public class FormConsumerAccessService {
grant.setGrantVersion(1);
} else if (!isOne(grant.getEnabled())
|| isOne(grant.getReadAllowed()) != read
|| isOne(grant.getSubscribeAllowed()) != subscribe) {
// 撤销后重申、以及"还开着但能力变了"(如只关订阅)都是新纪元:
|| isOne(grant.getSubscribeAllowed()) != subscribe
|| isOne(grant.getPushAllowed()) != push) {
// 撤销后重申、以及"还开着但能力变了"(如只关订阅、关/开 MQ 推送)都是新纪元:
// 订阅投递带的是 grantVersion,能力收窄却不进位会让已发出去的事件继续看起来合法
grant.setGrantVersion(grant.getGrantVersion() + 1);
}
grant.setReadAllowed(read ? 1 : 0);
grant.setSubscribeAllowed(subscribe ? 1 : 0);
grant.setPushAllowed(push ? 1 : 0);
grant.setEnabled(1);
FormConsumerGrant saved = grantRepository.save(grant);
log.info("[consumer ACL] 授权 formUuid={} consumerSchoolId={} consumerAppKey={} read={} subscribe={} "
+ "grantVersion={}", saved.getFormUuid(), saved.getConsumerSchoolId(),
+ "push={} grantVersion={}", saved.getFormUuid(), saved.getConsumerSchoolId(),
saved.getConsumerAppKey(), saved.getReadAllowed(), saved.getSubscribeAllowed(),
saved.getGrantVersion());
saved.getPushAllowed(), saved.getGrantVersion());
return toGrantView(saved);
}
@@ -363,6 +374,7 @@ public class FormConsumerAccessService {
view.setConsumerAppKey(grant.getConsumerAppKey());
view.setReadAllowed(isOne(grant.getReadAllowed()));
view.setSubscribeAllowed(isOne(grant.getSubscribeAllowed()));
view.setPushAllowed(isOne(grant.getPushAllowed()));
view.setGrantVersion(grant.getGrantVersion() == null ? 0 : grant.getGrantVersion());
return view;
}
@@ -374,6 +386,7 @@ public class FormConsumerAccessService {
view.setConsumerAppKey(grant.getConsumerAppKey());
view.setReadAllowed(isOne(grant.getReadAllowed()));
view.setSubscribeAllowed(isOne(grant.getSubscribeAllowed()));
view.setPushAllowed(isOne(grant.getPushAllowed()));
view.setEnabled(isOne(grant.getEnabled()));
view.setGrantVersion(grant.getGrantVersion() == null ? 0 : grant.getGrantVersion());
return view;
@@ -82,6 +82,11 @@ public class OapiFormService {
}
requireSchId(schId);
FormDefService.Published published = defService.resolvePublishedByUuid(schId, uuid, version);
// 精确 schema 用于跨端发布绑定,只允许发布过的不可变版本;不能把草稿签成发布契约。
// 历史答卷读取仍使用既有 resolver,不在此处收紧其生命周期语义。
if (version != null && (version < 1 || published.versionRow().getPublishTime() == null)) {
throw new BizException(ErrorCode.PARAM_ERROR, "指定发布版本不存在或不可读");
}
return toSchemaView(published);
}
@@ -6,10 +6,12 @@ import com.yunhai.formcenter.model.OapiModels;
import com.yunhai.formcenter.service.ConsumerPrincipalResolver;
import com.yunhai.formcenter.service.OapiFormService;
import com.yunhai.platform.appauth.AppAuthFilter;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.assertj.core.api.Assertions.assertThat;
@@ -48,9 +50,13 @@ class OapiFormControllerTest {
@BeforeEach
void setUp() {
formService = mock(OapiFormService.class);
// 密钥注册表用桩替:只认 100018 这一个 appKey,其它一律"未在平台注册"
// 验签密钥与平台注册事实是两道门;standalone 不会执行 Spring 字段注入。
ConsumerPrincipalResolver resolver = new ConsumerPrincipalResolver(
appKey -> "100018".equals(appKey) ? "svc-hall-dev-secret-100018" : null);
OpenApiAppClient registry = mock(OpenApiAppClient.class);
when(registry.getAppByKey("100018"))
.thenReturn(FormSchemaValidator.mapper().createObjectNode().put("appKey", "100018"));
ReflectionTestUtils.setField(resolver, "appRegistryClient", registry);
mvc = MockMvcBuilders.standaloneSetup(new OapiFormController(formService, resolver)).build();
}
@@ -139,6 +145,19 @@ class OapiFormControllerTest {
verifyNoInteractions(formService);
}
@Test
@DisplayName("自报查询参数不代替验签属性;已验签但未注册也不能进入服务")
void selfDeclaredOrUnregisteredCallerNeverReachesService() {
assertThat(catchThrowable(() -> mvc.perform(get("/oapi/form/schema")
.param("schId", "1001").param("uuid", "u").param("appKey", "100018"))))
.hasRootCauseInstanceOf(com.yunhai.platform.apistd.BizException.class);
assertThat(catchThrowable(() -> mvc.perform(get("/oapi/form/schema")
.param("schId", "1001").param("uuid", "u")
.requestAttr(CALLER_ATTR, "100999"))))
.hasRootCauseInstanceOf(com.yunhai.platform.apistd.BizException.class);
verifyNoInteractions(formService);
}
private static OapiModels.SchemaView view(String uuid, int version) {
OapiModels.SchemaView view = new OapiModels.SchemaView();
view.setFormUuid(uuid);
@@ -21,7 +21,8 @@ class AnonReplayGuardTest {
private static final String SECRET = "unit-test-secret";
private static final String CODE = "school_survey";
private final AnonReplayGuard guard = new AnonReplayGuard(SECRET);
private final java.time.Clock clock = java.time.Clock.fixed(java.time.Instant.parse("2026-10-23T00:00:00Z"), java.time.ZoneOffset.UTC);
private final AnonReplayGuard guard = new AnonReplayGuard(SECRET, clock);
@Test
@DisplayName("票据名按表单隔离:前缀 fc_anon_ + code(一表单一份,互不干扰)")
@@ -48,14 +49,14 @@ class AnonReplayGuardTest {
// 1 分钟窗口下,30 秒前签发的票据还剩约 30 秒,文案向上取整至少 1 分钟
long remaining = 30L;
assertThat(AnonReplayGuard.remainingMinutes(remaining)).isEqualTo(1);
assertThat(guard.throttleSeconds(CODE, 1, guard.issuedAt(CODE, System.currentTimeMillis() / 1000L - 30)))
assertThat(guard.throttleSeconds(CODE, 1, guard.issuedAt(CODE, clock.instant().getEpochSecond() - 30)))
.isBetween(29L, 31L);
}
@Test
@DisplayName("窗口已过=放行;未来时间戳按\"刚提交过\"处理(窗口不拉长,也不反过来把旧票据顶成刚刚)")
void expiredTicketPassesButFutureOneThrottles() {
long now = System.currentTimeMillis() / 1000L;
long now = clock.instant().getEpochSecond();
assertThat(guard.throttleSeconds(CODE, 10, guard.issuedAt(CODE, now - 601))).isZero();
assertThat(guard.throttleSeconds(CODE, 10, guard.issuedAt(CODE, now - 300))).isBetween(299L, 301L);
assertThat(guard.throttleSeconds(CODE, 10, guard.issuedAt(CODE, now + 120))).isBetween(595L, 600L);
@@ -64,7 +65,7 @@ class AnonReplayGuardTest {
@Test
@DisplayName("伪造无效:换密钥签的值、改过时间戳的值、别的表单的值、形态非法的值,全部按没带 cookie 处理")
void forgedOrMalformedValueIsTreatedAsAbsent() {
long now = System.currentTimeMillis() / 1000L;
long now = clock.instant().getEpochSecond();
String valid = guard.issue(CODE);
// 换密钥(攻击者不知道服务端密钥时能造出的最强形态)
@@ -193,6 +193,22 @@ class FcAnswerDeliveryTest {
.isEqualTo("fc.snapshot.v2." + CONSUMER_APP_KEY + "." + SCH);
}
@Test
@DisplayName("HTTP-only 订阅(未开 MQ 推送):数据订阅照常生效但不建 outbox 行,不留长期 PENDING")
void httpOnlyGrantCreatesNoOutboxRows() {
AnswerDeliveryRepository rows = mock(AnswerDeliveryRepository.class);
FormConsumerAccessService access = mock(FormConsumerAccessService.class);
when(access.listEnabledSubscriptions(FORM_UUID, SCH)).thenReturn(Collections.singletonList(
grant(CONSUMER_APP_KEY, SCH, 7, false)));
AnswerDeliveryService service = service(rows, mock(AnswerRepository.class), access,
snapshotService(mock(AnswerRepository.class), mock(SubmissionContextRepository.class), access));
assertThat(service.enqueueInTransaction(answer(1L, "{\"k\":\"v\"}", "draft"),
AnswerDeliveryModels.EVENT_CREATED)).isZero();
verify(rows, never()).saveAndFlush(any());
}
@Test
@DisplayName("无订阅的简单表单:零投递行,答卷照常提交(B.2 末句)")
void noSubscriptionMeansNoDeliveryRows() {
@@ -260,6 +276,24 @@ class FcAnswerDeliveryTest {
assertThat(pending.getState()).isEqualTo(AnswerDelivery.STATE_GRANT_REVOKED);
}
@Test
@DisplayName("MQ 推送被降级回 HTTP-only:发送前复核拦下存量行,不向已关闭推送的消费方发消息")
void demotedHttpOnlyGrantStopsDelivery() {
AnswerDeliveryRepository rows = mock(AnswerDeliveryRepository.class);
AnswerDelivery pending = deliveryRow(AnswerDelivery.STATE_PENDING, 1L, 7);
when(rows.findDue(any(LocalDateTime.class), any())).thenReturn(Collections.singletonList(pending));
when(rows.findById(pending.getId())).thenReturn(Optional.of(pending));
FormConsumerAccessService access = mock(FormConsumerAccessService.class);
when(access.listEnabledSubscriptions(FORM_UUID, SCH)).thenReturn(Collections.singletonList(
grant(CONSUMER_APP_KEY, SCH, 7, false)));
AnswerDeliveryService service = service(rows, mock(AnswerRepository.class), access,
snapshotService(mock(AnswerRepository.class), mock(SubmissionContextRepository.class), access));
withBroker(service);
assertThat(service.sendDueBatch()).isZero();
assertThat(pending.getState()).isEqualTo(AnswerDelivery.STATE_GRANT_REVOKED);
}
@Test
@DisplayName("confirm 只写 brokerAccepted;consumerApplied 只能由签名回执写(B.4/B.6)")
void brokerConfirmIsNotBusinessSuccess() {
@@ -613,12 +647,17 @@ class FcAnswerDeliveryTest {
// ==================== 夹具 ====================
private static SubscriptionGrant grant(String appKey, Long schoolId, int grantVersion) {
return grant(appKey, schoolId, grantVersion, true);
}
private static SubscriptionGrant grant(String appKey, Long schoolId, int grantVersion, boolean pushAllowed) {
SubscriptionGrant grant = new SubscriptionGrant();
grant.setFormUuid(FORM_UUID);
grant.setConsumerAppKey(appKey);
grant.setConsumerSchoolId(schoolId);
grant.setReadAllowed(true);
grant.setSubscribeAllowed(true);
grant.setPushAllowed(pushAllowed);
grant.setGrantVersion(grantVersion);
return grant;
}
@@ -16,10 +16,12 @@ import com.yunhai.formcenter.repository.FormOwnershipRepository;
import com.yunhai.formcenter.repository.FormRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.appauth.AppSecretProvider;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -34,6 +36,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
@@ -60,6 +64,7 @@ class FcDiscoveryAccessTest {
private AppSecretProvider secretProvider;
private ConsumerPrincipalResolver resolver;
private OpenApiAppClient registry;
private FormRepository formRepository;
private FormOwnershipRepository ownershipRepository;
private FormConsumerGrantRepository grantRepository;
@@ -76,6 +81,11 @@ class FcDiscoveryAccessTest {
secretProvider = mock(AppSecretProvider.class);
when(secretProvider.getSecret(SIGNED_CALLER)).thenReturn("svc-hall-dev-secret-100018");
resolver = new ConsumerPrincipalResolver(secretProvider);
registry = mock(OpenApiAppClient.class);
when(registry.getAppByKey(SIGNED_CALLER)).thenReturn(
com.yunhai.formcenter.common.FormSchemaValidator.mapper().createObjectNode()
.put("appKey", SIGNED_CALLER));
ReflectionTestUtils.setField(resolver, "appRegistryClient", registry);
formRepository = mock(FormRepository.class);
ownershipRepository = mock(FormOwnershipRepository.class);
@@ -171,6 +181,22 @@ class FcDiscoveryAccessTest {
assertThat(resolver.isRegisteredAppKey(SIGNED_CALLER)).isTrue();
}
@Test
@DisplayName("密钥存在不等于已注册:缺注册、错身份、注册服务失败均拒绝")
void registeredIdentityMustMatchAndRegistryFailuresStayClosed() {
when(registry.getAppByKey(SIGNED_CALLER)).thenReturn(null);
assertThatThrownBy(() -> resolver.resolveSignedCaller(SIGNED_CALLER))
.isInstanceOf(BizException.class).hasMessageContaining("不可用");
when(registry.getAppByKey(SIGNED_CALLER)).thenReturn(
com.yunhai.formcenter.common.FormSchemaValidator.mapper().createObjectNode()
.put("appKey", "100999"));
assertThatThrownBy(() -> resolver.resolveSignedCaller(SIGNED_CALLER))
.isInstanceOf(BizException.class).hasMessageContaining("不可用");
when(registry.getAppByKey(SIGNED_CALLER)).thenThrow(new IllegalStateException("offline registry"));
assertThatThrownBy(() -> resolver.resolveSignedCaller(SIGNED_CALLER))
.isInstanceOf(BizException.class).hasMessageContaining("不可用");
}
@Test
@DisplayName("appCode 同名的应用不自认领:库里 app_code=svc-hall 但调用方无授权 → 拒")
void appCodeIsFilterNotIdentity() {
@@ -394,8 +420,18 @@ class FcDiscoveryAccessTest {
otherSchool.setReadAllowed(true);
assertThatThrownBy(() -> accessService.grant(SCH, otherSchool, false))
.isInstanceOf(BizException.class)
.hasMessageContaining("只能给本校授予消费授权");
// 超管代某校授权是允许的
.hasMessage("表单不存在:u1");
// 超管也不能把学校资源授给其他消费校:资源归属门独立于管理者身份。
assertThatThrownBy(() -> accessService.grant(SCH, otherSchool, true))
.isInstanceOf(BizException.class).hasMessage("表单不存在:u1");
verify(grantRepository, never()).save(any());
// 平台资源可授予具体消费校,但校管理员仍只能为自己的消费域授权。
givenForm(form("u0", Form.PLATFORM_SCH_ID, "form-center", "public_form", "平台表单", 1));
otherSchool.setFormUuid("u0");
assertThatThrownBy(() -> accessService.grant(SCH, otherSchool, false))
.isInstanceOf(BizException.class).hasMessageContaining("只能给本校授予消费授权");
verify(grantRepository, never()).save(any());
assertThat(accessService.grant(SCH, otherSchool, true).getConsumerSchoolId()).isEqualTo(OTHER_SCH);
GrantRequest platformTenant = new GrantRequest();
@@ -581,7 +581,10 @@ class FcReconcileTaskTest {
assertThat(ack.getAccepted()).isTrue();
assertThat(ack.getDuplicate()).isTrue();
verify(resultRepository, never()).save(any(AnswerReconcileResult.class));
verify(resultRepository, never()).saveAndFlush(any(AnswerReconcileResult.class));
verify(taskRepository, never()).advanceTerminal(anyLong(), anyString(), any(), any(), any());
verify(taskRepository, never()).finishAttempt(anyLong(), anyInt(), anyString(), any(), any(), any());
verify(taskRepository, never()).renewRunning(anyLong(), anyInt(), any());
}
@Test
@@ -592,7 +595,7 @@ class FcReconcileTaskTest {
when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task));
when(resultRepository.findByTaskIdAndAttempt(1L, 1)).thenReturn(Optional.empty());
when(resultRepository.maxAttempt(1L)).thenReturn(2);
when(resultRepository.save(any(AnswerReconcileResult.class))).thenAnswer(call -> call.getArgument(0));
when(resultRepository.saveAndFlush(any(AnswerReconcileResult.class))).thenAnswer(call -> call.getArgument(0));
when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER))
.thenReturn(Optional.of(grant(1L, FORM_UUID, 1)));
@@ -600,9 +603,68 @@ class FcReconcileTaskTest {
report(1, AnswerReconcileTask.STATE_FAILED));
assertThat(ack.getStaleAttempt()).isTrue();
assertThat(ack.getDecision()).isEqualTo("STALE_RETRY");
assertThat(ack.getReportAttempt()).isEqualTo(1);
assertThat(ack.getAttempt()).isEqualTo(3);
assertThat(ack.getAccepted()).isTrue();
verify(resultRepository).save(any(AnswerReconcileResult.class));
verify(resultRepository).saveAndFlush(any(AnswerReconcileResult.class));
verify(taskRepository, never()).advanceTerminal(anyLong(), anyString(), any(), any(), any());
verify(taskRepository, never()).finishAttempt(anyLong(), anyInt(), anyString(), any(), any(), any());
verify(taskRepository, never()).renewRunning(anyLong(), anyInt(), any());
assertThat(task.getAttempt()).isEqualTo(3);
assertThat(task.getState()).isEqualTo(AnswerReconcileTask.STATE_RUNNING);
}
@Test
void oldReportCannotOverwriteNewTerminalDecision() {
AnswerReconcileTask task = task(AnswerReconcileTask.STATE_FAILED);
task.setAttempt(3);
when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task));
when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER))
.thenReturn(Optional.of(grant(1L, FORM_UUID, 1)));
ResultAck ack = service.submitResult(caller(CONSUMER), TASK_ID, report(1, AnswerReconcileTask.STATE_COMPLETED));
assertThat(ack.getDecision()).isEqualTo("CENTER_TERMINAL");
assertThat(ack.getTaskState()).isEqualTo(AnswerReconcileTask.STATE_FAILED);
assertThat(ack.getStaleAttempt()).isTrue();
assertThat(ack.getAttempt()).isEqualTo(3);
assertThat(ack.getReportAttempt()).isEqualTo(1);
verify(resultRepository, never()).saveAndFlush(any());
verify(taskRepository, never()).finishAttempt(anyLong(), anyInt(), anyString(), any(), any(), any());
}
@Test
void duplicateFailureIsNotRewrittenByLaterSuccessClaim() {
AnswerReconcileTask task = task(AnswerReconcileTask.STATE_RUNNING);
task.setAttempt(2);
when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task));
when(resultRepository.findByTaskIdAndAttempt(1L, 2))
.thenReturn(Optional.of(result(2, AnswerReconcileTask.STATE_FAILED)));
when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER))
.thenReturn(Optional.of(grant(1L, FORM_UUID, 1)));
ResultAck ack = service.submitResult(caller(CONSUMER), TASK_ID, report(2, AnswerReconcileTask.STATE_COMPLETED));
assertThat(ack.getDecision()).isEqualTo("CURRENT_DUPLICATE");
assertThat(ack.getResultState()).isEqualTo(AnswerReconcileTask.STATE_FAILED);
assertThat(ack.getDuplicate()).isTrue();
assertThat(ack.getReportAttempt()).isEqualTo(2);
verify(resultRepository, never()).saveAndFlush(any());
}
@Test
void duplicateOldTerminalResultStillRequiresNewRoundRetry() {
AnswerReconcileTask task = task(AnswerReconcileTask.STATE_RUNNING);
task.setAttempt(3);
when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task));
when(resultRepository.findByTaskIdAndAttempt(1L, 1))
.thenReturn(Optional.of(result(1, AnswerReconcileTask.STATE_COMPLETED)));
when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER))
.thenReturn(Optional.of(grant(1L, FORM_UUID, 1)));
ResultAck ack = service.submitResult(caller(CONSUMER), TASK_ID, report(1, AnswerReconcileTask.STATE_FAILED));
assertThat(ack.getDecision()).isEqualTo("STALE_RETRY");
assertThat(ack.getStaleAttempt()).isTrue();
assertThat(ack.getResultState()).isEqualTo(AnswerReconcileTask.STATE_COMPLETED);
assertThat(ack.getTaskState()).isEqualTo(AnswerReconcileTask.STATE_RUNNING);
verify(resultRepository, never()).saveAndFlush(any());
verify(taskRepository, never()).renewRunning(anyLong(), anyInt(), any());
}
@Test
@@ -622,6 +684,7 @@ class FcReconcileTaskTest {
report(1, AnswerReconcileTask.STATE_COMPLETED)))
.isInstanceOf(BizException.class).hasMessageContaining("不符");
verify(resultRepository, never()).save(any(AnswerReconcileResult.class));
verify(resultRepository, never()).saveAndFlush(any(AnswerReconcileResult.class));
}
@Test
@@ -632,17 +695,67 @@ class FcReconcileTaskTest {
when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task));
when(resultRepository.findByTaskIdAndAttempt(eq(1L), anyInt())).thenReturn(Optional.empty());
when(resultRepository.maxAttempt(1L)).thenReturn(0);
when(resultRepository.save(any(AnswerReconcileResult.class))).thenAnswer(call -> call.getArgument(0));
when(resultRepository.saveAndFlush(any(AnswerReconcileResult.class))).thenAnswer(call -> call.getArgument(0));
when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER))
.thenReturn(Optional.of(grant(1L, FORM_UUID, 1)));
service.submitResult(caller(CONSUMER), TASK_ID, report(1, AnswerReconcileTask.STATE_PARTIAL));
verify(taskRepository).advanceTerminal(eq(1L), eq(AnswerReconcileTask.STATE_PARTIAL),
any(LocalDateTime.class), isNull(), isNull());
// 先续租、后终态;终态后的 RUNNING 必须幂等吸收,不能恢复执行。
service.submitResult(caller(CONSUMER), TASK_ID, report(1, AnswerReconcileTask.STATE_RUNNING));
verify(taskRepository).markRunning(eq(1L), any(LocalDateTime.class));
verify(taskRepository, times(1)).advanceTerminal(anyLong(), anyString(), any(), any(), any());
verify(taskRepository).renewRunning(eq(1L), eq(1), any(LocalDateTime.class));
verify(taskRepository, never()).finishAttempt(anyLong(), anyInt(), anyString(), any(), any(), any());
service.submitResult(caller(CONSUMER), TASK_ID, report(1, AnswerReconcileTask.STATE_PARTIAL));
verify(taskRepository).finishAttempt(eq(1L), eq(1), eq(AnswerReconcileTask.STATE_PARTIAL),
any(LocalDateTime.class), isNull(), isNull());
assertThat(task.getState()).isEqualTo(AnswerReconcileTask.STATE_PARTIAL);
ResultAck duplicate = service.submitResult(caller(CONSUMER), TASK_ID,
report(1, AnswerReconcileTask.STATE_RUNNING));
assertThat(duplicate.getDuplicate()).isTrue();
assertThat(task.getState()).isEqualTo(AnswerReconcileTask.STATE_PARTIAL);
verify(taskRepository, times(1)).renewRunning(anyLong(), anyInt(), any());
verify(taskRepository, times(1)).finishAttempt(anyLong(), anyInt(), anyString(), any(), any(), any());
verify(resultRepository, times(2)).saveAndFlush(any(AnswerReconcileResult.class));
verify(taskRepository, never()).markRunning(anyLong(), any());
verify(taskRepository, never()).advanceTerminal(anyLong(), anyString(), any(), any(), any());
}
@Test
@DisplayName("完整扫描才允许 COMPLETED;与 PARTIAL 保持不同终态")
void completedRequiresBothCompleteScans() {
AnswerReconcileTask task = task(AnswerReconcileTask.STATE_DISPATCHED);
when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task));
when(resultRepository.saveAndFlush(any(AnswerReconcileResult.class))).thenAnswer(call -> call.getArgument(0));
when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER))
.thenReturn(Optional.of(grant(1L, FORM_UUID, 1)));
ResultReport incomplete = report(1, AnswerReconcileTask.STATE_COMPLETED);
incomplete.setLocalScanComplete(false);
assertThatThrownBy(() -> service.submitResult(caller(CONSUMER), TASK_ID, incomplete))
.isInstanceOf(BizException.class).hasMessageContaining("INCOMPLETE_RESULT");
verify(resultRepository, never()).saveAndFlush(any());
service.submitResult(caller(CONSUMER), TASK_ID, report(1, AnswerReconcileTask.STATE_COMPLETED));
verify(taskRepository).finishAttempt(eq(1L), eq(1), eq(AnswerReconcileTask.STATE_COMPLETED),
any(LocalDateTime.class), isNull(), isNull());
assertThat(task.getState()).isEqualTo(AnswerReconcileTask.STATE_COMPLETED);
}
@Test
@DisplayName("PARTIAL 本轮已结束:不再提供清单、快照或缺席证明,也不接受迟到心跳恢复")
void partialTaskRejectsFurtherReadsAndProgress() {
AnswerReconcileTask task = task(AnswerReconcileTask.STATE_PARTIAL);
when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task));
when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER))
.thenReturn(Optional.of(grant(1L, FORM_UUID, 1)));
assertThatThrownBy(() -> service.manifestPage(caller(CONSUMER), TASK_ID, null, 2))
.isInstanceOf(BizException.class).hasMessageContaining("TASK_TERMINAL");
assertThatThrownBy(() -> service.taskAnswer(caller(CONSUMER), TASK_ID, ANSWER_A, null))
.isInstanceOf(BizException.class).hasMessageContaining("TASK_TERMINAL");
ResultAck ack = service.submitResult(caller(CONSUMER), TASK_ID,
report(1, AnswerReconcileTask.STATE_RUNNING));
assertThat(ack.getDuplicate()).isTrue();
assertThat(task.getState()).isEqualTo(AnswerReconcileTask.STATE_PARTIAL);
verify(resultRepository, never()).saveAndFlush(any());
verify(taskRepository, never()).renewRunning(anyLong(), anyInt(), any());
verify(taskRepository, never()).finishAttempt(anyLong(), anyInt(), anyString(), any(), any(), any());
}
@Test
@@ -662,7 +775,7 @@ class FcReconcileTaskTest {
stubAccessibleTask();
when(resultRepository.findByTaskIdAndAttempt(1L, 1)).thenReturn(Optional.empty());
when(resultRepository.maxAttempt(1L)).thenReturn(0);
when(resultRepository.save(any(AnswerReconcileResult.class))).thenAnswer(call -> call.getArgument(0));
when(resultRepository.saveAndFlush(any(AnswerReconcileResult.class))).thenAnswer(call -> call.getArgument(0));
properties.setMaxErrorItems(3);
List<ReconcileError> errors = new ArrayList<>();
for (int i = 0; i < 10; i++) {
@@ -678,7 +791,7 @@ class FcReconcileTaskTest {
ArgumentCaptor<AnswerReconcileResult> saved = ArgumentCaptor.forClass(AnswerReconcileResult.class);
service.submitResult(caller(CONSUMER), TASK_ID, report);
verify(resultRepository).save(saved.capture());
verify(resultRepository).saveAndFlush(saved.capture());
AnswerReconcileResult row = saved.getValue();
assertThat(row.getErrorsJson()).doesNotContain("冒充身份");
@@ -0,0 +1,220 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormConsumerGrant;
import com.yunhai.formcenter.model.FormAccessModels.GrantRequest;
import com.yunhai.formcenter.model.FormAccessModels.GrantView;
import com.yunhai.formcenter.model.FormAccessModels.SubscriptionGrant;
import com.yunhai.formcenter.repository.FormConsumerGrantRepository;
import com.yunhai.formcenter.repository.FormOwnershipRepository;
import com.yunhai.formcenter.repository.FormRepository;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Collections;
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.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 读取授权与 MQ 订阅的分权(2026-10-22 决策②):
* {@code read_allowed}(拉取)/ {@code subscribe_allowed}(数据订阅:投影与对账授权的判据)/
* {@code push_allowed}(MQ broker 推送投递)三把开关独立——
* <b>HTTP-only 消费方保留 read+subscribe,但 push 不开就绝不建 outbox 行</b>,
* 避免无 broker 消费方在 fc_answer_delivery 里长期 PENDING。
*/
@DisplayName("消费授权三开关 - push_allowed 闸门(读取/订阅/MQ 推送分权)")
class FormConsumerGrantPushGateTest {
private static final long DOMAIN = 5L;
private static final String FORM_UUID = "f0e1d2c3b4a5968778695a4b3c2d1e0f";
private static final String APP_KEY = "100018";
private FormOwnershipRepository ownership;
private FormConsumerGrantRepository grants;
private FormRepository forms;
private FormConsumerAccessService service;
@BeforeEach
void setUp() {
ownership = mock(FormOwnershipRepository.class);
grants = mock(FormConsumerGrantRepository.class);
forms = mock(FormRepository.class);
service = new FormConsumerAccessService(ownership, grants, forms);
Form form = new Form();
form.setUuid(FORM_UUID);
form.setSchId(DOMAIN);
when(forms.findByUuid(FORM_UUID)).thenReturn(Optional.of(form));
when(grants.save(any())).thenAnswer(call -> call.getArgument(0));
}
private GrantRequest request(Boolean read, Boolean subscribe, Boolean push) {
GrantRequest req = new GrantRequest();
req.setFormUuid(FORM_UUID);
req.setConsumerSchoolId(DOMAIN);
req.setConsumerAppKey(APP_KEY);
req.setReadAllowed(read);
req.setSubscribeAllowed(subscribe);
req.setPushAllowed(push);
return req;
}
private FormConsumerGrant existing(int grantVersion, int read, int subscribe, int push) {
FormConsumerGrant grant = new FormConsumerGrant();
grant.setId(1L);
grant.setFormUuid(FORM_UUID);
grant.setConsumerSchoolId(DOMAIN);
grant.setConsumerAppKey(APP_KEY);
grant.setReadAllowed(read);
grant.setSubscribeAllowed(subscribe);
grant.setPushAllowed(push);
grant.setEnabled(1);
grant.setGrantVersion(grantVersion);
when(grants.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, DOMAIN, APP_KEY))
.thenReturn(Optional.of(grant));
return grant;
}
@Test
@DisplayName("新 grant 的 push_allowed 默认 0:存量行不加配置就是 HTTP-only,不推送")
void newGrantDefaultsToHttpOnly() {
assertThat(new FormConsumerGrant().getPushAllowed()).isZero();
}
@Test
@DisplayName("push 依赖订阅数据权:subscribe 没开就给 push 直接拒")
void pushRequiresSubscribe() {
assertThatThrownBy(() -> service.grant(DOMAIN, request(true, null, true), false))
.isInstanceOf(BizException.class).hasMessageContaining("订阅");
}
@Test
@DisplayName("read+subscribe+push 三开:落库 push=1,grantVersion 从 1 起")
void tripleGrantWritesPushFlag() {
GrantView view = service.grant(DOMAIN, request(true, true, true), false);
assertThat(view.isPushAllowed()).isTrue();
assertThat(view.getGrantVersion()).isEqualTo(1);
}
@Test
@DisplayName("push 降级(关推送、保留 read+subscribe)也是能力变化:grantVersion 必须进位")
void demotingPushBumpsEpoch() {
FormConsumerGrant grant = existing(3, 1, 1, 1);
GrantView view = service.grant(DOMAIN, request(true, true, false), false);
assertThat(grant.getPushAllowed()).isZero();
assertThat(grant.getGrantVersion()).isEqualTo(4);
assertThat(view.isPushAllowed()).isFalse();
}
@Test
@DisplayName("push 升级同样新纪元:存量 PENDING 行的旧 grantVersion 从此发不出去")
void enablingPushBumpsEpoch() {
FormConsumerGrant grant = existing(3, 1, 1, 0);
service.grant(DOMAIN, request(true, true, true), false);
assertThat(grant.getPushAllowed()).isEqualTo(1);
assertThat(grant.getGrantVersion()).isEqualTo(4);
}
@Test
@DisplayName("三开关都没变就不进位:重复授权不制造新纪元")
void unchangedFlagsKeepEpoch() {
FormConsumerGrant grant = existing(3, 1, 1, 1);
service.grant(DOMAIN, request(true, true, true), false);
assertThat(grant.getGrantVersion()).isEqualTo(3);
}
@Test
@DisplayName("投递目标清单带 push 标志:入队侧据此跳过 HTTP-only 消费方")
void subscriptionListCarriesPushFlag() {
FormConsumerGrant httpOnly = existing(7, 1, 1, 0);
when(grants.findEnabledSubscriptions(FORM_UUID, DOMAIN))
.thenReturn(Collections.singletonList(httpOnly));
SubscriptionGrant view = service.listEnabledSubscriptions(FORM_UUID, DOMAIN).get(0);
assertThat(view.isSubscribeAllowed()).isTrue();
assertThat(view.isPushAllowed()).isFalse();
assertThat(view.getGrantVersion()).isEqualTo(7);
}
@Test
@DisplayName("管理面 grant 视图回显 push 标志:运维看得到哪个消费方在推送")
void grantViewCarriesPushFlag() {
// existing() 内部也有打桩,先完成它再 stub findLiveByForm(嵌套 when 会 UnfinishedStubbing)
FormConsumerGrant row = existing(2, 1, 1, 1);
when(grants.findLiveByForm(FORM_UUID)).thenReturn(Collections.singletonList(row));
GrantView view = service.listGrants(DOMAIN, FORM_UUID).get(0);
assertThat(view.isPushAllowed()).isTrue();
}
@Test
@DisplayName("旧客户端缺省/null push:保留现值且不无故推进授权纪元")
void omittedAndNullPushPreserveExistingPermission() throws Exception {
FormConsumerGrant grant = existing(3, 1, 1, 1);
for (String suffix : new String[]{"", ",\"pushAllowed\":null"}) {
GrantRequest req = com.yunhai.formcenter.common.FormSchemaValidator.mapper().readValue(
"{\"formUuid\":\"" + FORM_UUID + "\",\"consumerSchoolId\":5,"
+ "\"consumerAppKey\":\"" + APP_KEY + "\",\"readAllowed\":true,"
+ "\"subscribeAllowed\":true" + suffix + "}", GrantRequest.class);
GrantView saved = service.grant(DOMAIN, req, false);
assertThat(saved.isPushAllowed()).isTrue();
assertThat(grant.getPushAllowed()).isEqualTo(1);
assertThat(saved.getGrantVersion()).isEqualTo(3);
}
}
@Test
void omittedPushKeepsHttpOnlyAndDefaultsNewGrantToFalse() {
GrantView created = service.grant(DOMAIN, request(true, true, null), false);
assertThat(created.isPushAllowed()).isFalse();
assertThat(created.getGrantVersion()).isEqualTo(1);
FormConsumerGrant grant = existing(4, 1, 1, 0);
assertThat(service.grant(DOMAIN, request(true, true, null), false).isPushAllowed()).isFalse();
assertThat(grant.getGrantVersion()).isEqualTo(4);
}
@Test
void regrantPreservesPushButStartsNewEpoch() {
FormConsumerGrant grant = existing(4, 1, 1, 1);
grant.setEnabled(0);
GrantView saved = service.grant(DOMAIN, request(true, true, null), false);
assertThat(saved.isPushAllowed()).isTrue();
assertThat(saved.isEnabled()).isTrue();
assertThat(saved.getGrantVersion()).isEqualTo(5);
}
@Test
@DisplayName("合并后push=true而关订阅:拒绝且不改任何旧授权值")
void inheritedPushRequiresExplicitDisableBeforeRemovingSubscription() {
FormConsumerGrant grant = existing(7, 1, 1, 1);
assertThatThrownBy(() -> service.grant(DOMAIN, request(true, false, null), false))
.isInstanceOf(BizException.class).hasMessageContaining("显式设置 pushAllowed=false");
assertThat(grant.getReadAllowed()).isEqualTo(1);
assertThat(grant.getSubscribeAllowed()).isEqualTo(1);
assertThat(grant.getPushAllowed()).isEqualTo(1);
assertThat(grant.getEnabled()).isEqualTo(1);
assertThat(grant.getGrantVersion()).isEqualTo(7);
verify(grants, never()).save(any());
GrantView saved = service.grant(DOMAIN, request(true, false, false), false);
assertThat(saved.isSubscribeAllowed()).isFalse();
assertThat(saved.isPushAllowed()).isFalse();
assertThat(saved.getGrantVersion()).isEqualTo(8);
}
}
@@ -0,0 +1,84 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormVersion;
import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
import com.yunhai.formcenter.repository.AnswerRepository;
import com.yunhai.formcenter.repository.FormRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/** 真实服务层发布版本守卫单测;resolver/仓储是桩,不作为真库验收。 */
class OapiSchemaPublishedVersionTest {
private FormDefService defs;
private FormDictService dicts;
private FormConsumerAccessService access;
private OapiFormService service;
private Form form;
private FormVersion row;
private final ConsumerPrincipal caller = new ConsumerPrincipal("100009");
@BeforeEach
void setUp() {
defs = mock(FormDefService.class);
dicts = mock(FormDictService.class);
access = mock(FormConsumerAccessService.class);
service = new OapiFormService(defs, dicts, mock(AnswerRepository.class), mock(FormRepository.class), access);
form = new Form();
form.setSchId(27L);
form.setUuid("FU1");
form.setAppCode("svc-hall");
form.setCode("leave");
row = new FormVersion();
row.setVersion(3);
row.setSchemaJson("{\"groups\":[]}");
when(defs.resolvePublishedByUuid(27L, "FU1", 3)).thenReturn(new FormDefService.Published(form, row));
when(dicts.expandSchema(anyLong(), anyString())).thenAnswer(inv -> inv.getArgument(1));
}
@Test
void exactDraftCannotBecomeCrossServicePublishedContract() {
BizException ex = assertThrows(BizException.class,
() -> service.schema(caller, 27L, null, null, "FU1", 3));
assertEquals(ErrorCode.PARAM_ERROR.getCode(), ex.getErrorCode().getCode());
verify(access).requireRead(caller, 27L, "FU1");
verifyNoInteractions(dicts);
}
@Test
void historicalPublishedVersionStillReadableWhenHeadIsDisabledOrDeleted() {
row.setPublishTime(LocalDateTime.now().minusDays(1));
form.setStatus(2);
form.setIsDeleted(1);
form.setCurrentVersion(4);
assertEquals(3, service.schema(caller, 27L, null, null, "FU1", 3).getVersion());
verify(defs).resolvePublishedByUuid(27L, "FU1", 3);
}
@Test
void missingVersionRowPreservesResourceRejectionCode() {
when(defs.resolvePublishedByUuid(27L, "FU1", 3))
.thenThrow(new BizException(ErrorCode.PARAM_ERROR, "opaque"));
BizException ex = assertThrows(BizException.class,
() -> service.schema(caller, 27L, null, null, "FU1", 3));
assertEquals(2, ex.getErrorCode().getCode());
verifyNoInteractions(dicts);
}
@Test
void readAuthorizationRunsBeforeVersionResolution() {
doThrow(new BizException(ErrorCode.PARAM_ERROR, "opaque"))
.when(access).requireRead(caller, 27L, "FU1");
assertThrows(BizException.class, () -> service.schema(caller, 27L, null, null, "FU1", 3));
verifyNoInteractions(defs, dicts);
}
}