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.
This commit is contained in:
chenxing
2026-10-23 10:51:16 +08:00
parent c6e061815a
commit 6ff39b0cec
9 changed files with 159 additions and 32 deletions
+10 -1
View File
@@ -16,6 +16,10 @@ on:
description: '构建前清空 maven m2 缓存' description: '构建前清空 maven m2 缓存'
required: false required: false
default: 'false' default: 'false'
tests_only:
description: '只验证测试和 JAR,不构建推送镜像或通知建档'
required: false
default: 'true'
jobs: jobs:
build-push: build-push:
@@ -36,6 +40,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src 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
cp -a /tmp/src/. . cp -a /tmp/src/. .
rm -rf /tmp/src rm -rf /tmp/src
# 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符 # 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符
@@ -64,6 +70,7 @@ jobs:
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。 # GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)" BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}" echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
trap 'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true' EXIT
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" \ docker run -d --name "$BUILD_CONTAINER" \
--network infra-infra-net \ --network infra-infra-net \
@@ -71,13 +78,14 @@ jobs:
-w /build maven:3.6.3-jdk-8 sleep 3600 -w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/ 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 "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' docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
mkdir -p "${PWD}/target" mkdir -p "${PWD}/target"
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar" docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f "$BUILD_CONTAINER" docker rm -f "$BUILD_CONTAINER"
- name: 构建并推送镜像 - name: 构建并推送镜像
if: ${{ inputs.tests_only != 'true' }}
run: | run: |
set -e set -e
TAG=$(echo "$GITHUB_SHA" | cut -c1-8) TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
@@ -88,6 +96,7 @@ jobs:
# 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual) # 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual)
- name: 通知 projman 建档(部署由运维手动触发) - name: 通知 projman 建档(部署由运维手动触发)
if: ${{ inputs.tests_only != 'true' }}
run: | run: |
curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \ curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
-H "Content-Type: application/json" \ -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
+24
View File
@@ -316,3 +316,27 @@ svc-hall:普通消费保留 `AnswerProjectionStore.applySnapshot(SnapshotEnvel
`svc-hall/server/sql/fc_projection_and_apply_intent.sql`、 `svc-hall/server/sql/fc_projection_and_apply_intent.sql`、
`flow-center/service/src/main/resources/sql/add_flw_apply_request_identity.sql`) `flow-center/service/src/main/resources/sql/add_flw_apply_request_identity.sql`)
都是**非幂等**脚本,生产需人工执行;`ddl-auto=update` 的开发库会自动追加,两侧不能长期分叉。 都是**非幂等**脚本,生产需人工执行;`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 | 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-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 过期执行者保护共同防止旧执行清除新租约。
@@ -214,6 +214,10 @@ public final class FormSyncModels {
/** 比中心已记录的最新 attempt 更旧:留档但不推进状态 */ /** 比中心已记录的最新 attempt 更旧:留档但不推进状态 */
private Boolean staleAttempt; private Boolean staleAttempt;
private String taskState; private String taskState;
/** CURRENT_ACCEPTED / CURRENT_DUPLICATE / STALE_RETRY / CENTER_TERMINAL */
private String decision;
private Integer reportAttempt;
private String resultState;
private String message; private String message;
} }
@@ -45,9 +45,16 @@ public class AnonReplayGuard {
private static final char[] HEX = "0123456789abcdef".toCharArray(); private static final char[] HEX = "0123456789abcdef".toCharArray();
private final String secret; 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) { 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.secret = secret == null ? "" : secret.trim();
this.clock = java.util.Objects.requireNonNull(clock);
} }
public static String cookieName(String formCode) { public static String cookieName(String formCode) {
@@ -66,7 +73,7 @@ public class AnonReplayGuard {
*/ */
public String issue(String formCode) { public String issue(String formCode) {
requireSecret(); requireSecret();
return issuedAt(formCode, System.currentTimeMillis() / 1000L); return issuedAt(formCode, clock.instant().getEpochSecond());
} }
/** /**
@@ -89,7 +96,7 @@ public class AnonReplayGuard {
if (issuedAt == null) { if (issuedAt == null) {
return 0L; return 0L;
} }
long remaining = issuedAt + window - System.currentTimeMillis() / 1000L; long remaining = issuedAt + window - clock.instant().getEpochSecond();
return remaining > 0 ? remaining : 0L; 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());
} }
/** 签发指定制表时刻的票据值(窗口边界用例要用"过去的时间戳",包内可见即可,不对外暴露) */ /** 签发指定制表时刻的票据值(窗口边界用例要用"过去的时间戳",包内可见即可,不对外暴露) */
@@ -736,38 +736,33 @@ public class AnswerReconcileService {
"回报的 attempt=" + attempt + " 超过中心已派发的 " + dispatchedAttempt); "回报的 attempt=" + attempt + " 超过中心已派发的 " + dispatchedAttempt);
} }
Optional<AnswerReconcileResult> repeat = resultRepository.findByTaskIdAndAttempt(task.getId(), attempt); Optional<AnswerReconcileResult> repeat = resultRepository.findByTaskIdAndAttempt(task.getId(), attempt);
if (AnswerReconcileTask.isTerminal(task.getState()) Integer latest = resultRepository.maxAttempt(task.getId());
|| (repeat.isPresent() && !AnswerReconcileTask.STATE_RUNNING.equals(repeat.get().getState()))) { boolean stale = attempt < dispatchedAttempt || (latest != null && attempt < latest);
ResultAck ack = toResultAck(task); if (AnswerReconcileTask.isTerminal(task.getState())) {
ack.setAccepted(true); return resultAck(task, attempt, "CENTER_TERMINAL", true, stale, null);
ack.setDuplicate(true);
ack.setMessage("任务或本轮结果已终结,仅幂等确认,不恢复执行");
return ack;
} }
String reportState = normalizeReportState(report.getState()); 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) if (AnswerReconcileTask.STATE_COMPLETED.equals(reportState)
&& (!Boolean.TRUE.equals(report.getManifestComplete()) && (!Boolean.TRUE.equals(report.getManifestComplete())
|| !Boolean.TRUE.equals(report.getLocalScanComplete()) || !Boolean.TRUE.equals(report.getLocalScanComplete())
|| !Integer.valueOf(1).equals(task.getManifestComplete()))) { || !Integer.valueOf(1).equals(task.getManifestComplete()))) {
throw new BizException(ErrorCode.PARAM_ERROR, "INCOMPLETE_RESULT:两侧扫描未完整结束,不能回报COMPLETED"); throw new BizException(ErrorCode.PARAM_ERROR, "INCOMPLETE_RESULT:两侧扫描未完整结束,不能回报COMPLETED");
} }
Integer latest = resultRepository.maxAttempt(task.getId()); AnswerReconcileResult row = repeat.isPresent() && AnswerReconcileTask.STATE_RUNNING.equals(reportState)
boolean staleAttempt = attempt < dispatchedAttempt || (latest != null && attempt < latest); ? repeat.get() : persistResult(task, attempt, report);
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;
}
advanceByReport(task, report, row); 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) { private AnswerReconcileResult persistResult(AnswerReconcileTask task, int attempt, ResultReport report) {
@@ -866,11 +861,18 @@ public class AnswerReconcileService {
return out; 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(); ResultAck ack = new ResultAck();
ack.setTaskId(task.getTaskUuid()); ack.setTaskId(task.getTaskUuid());
ack.setAttempt(task.getAttempt()); ack.setAttempt(task.getAttempt());
ack.setReportAttempt(reportAttempt);
ack.setTaskState(task.getState()); ack.setTaskState(task.getState());
ack.setDecision(decision);
ack.setAccepted(true);
ack.setDuplicate(duplicate);
ack.setStaleAttempt(stale);
ack.setResultState(resultState);
return ack; return ack;
} }
@@ -21,7 +21,8 @@ class AnonReplayGuardTest {
private static final String SECRET = "unit-test-secret"; private static final String SECRET = "unit-test-secret";
private static final String CODE = "school_survey"; 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 @Test
@DisplayName("票据名按表单隔离:前缀 fc_anon_ + code(一表单一份,互不干扰)") @DisplayName("票据名按表单隔离:前缀 fc_anon_ + code(一表单一份,互不干扰)")
@@ -48,14 +49,14 @@ class AnonReplayGuardTest {
// 1 分钟窗口下,30 秒前签发的票据还剩约 30 秒,文案向上取整至少 1 分钟 // 1 分钟窗口下,30 秒前签发的票据还剩约 30 秒,文案向上取整至少 1 分钟
long remaining = 30L; long remaining = 30L;
assertThat(AnonReplayGuard.remainingMinutes(remaining)).isEqualTo(1); 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); .isBetween(29L, 31L);
} }
@Test @Test
@DisplayName("窗口已过=放行;未来时间戳按\"刚提交过\"处理(窗口不拉长,也不反过来把旧票据顶成刚刚)") @DisplayName("窗口已过=放行;未来时间戳按\"刚提交过\"处理(窗口不拉长,也不反过来把旧票据顶成刚刚)")
void expiredTicketPassesButFutureOneThrottles() { 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 - 601))).isZero();
assertThat(guard.throttleSeconds(CODE, 10, guard.issuedAt(CODE, now - 300))).isBetween(299L, 301L); 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); assertThat(guard.throttleSeconds(CODE, 10, guard.issuedAt(CODE, now + 120))).isBetween(595L, 600L);
@@ -64,7 +65,7 @@ class AnonReplayGuardTest {
@Test @Test
@DisplayName("伪造无效:换密钥签的值、改过时间戳的值、别的表单的值、形态非法的值,全部按没带 cookie 处理") @DisplayName("伪造无效:换密钥签的值、改过时间戳的值、别的表单的值、形态非法的值,全部按没带 cookie 处理")
void forgedOrMalformedValueIsTreatedAsAbsent() { void forgedOrMalformedValueIsTreatedAsAbsent() {
long now = System.currentTimeMillis() / 1000L; long now = clock.instant().getEpochSecond();
String valid = guard.issue(CODE); String valid = guard.issue(CODE);
// 换密钥(攻击者不知道服务端密钥时能造出的最强形态) // 换密钥(攻击者不知道服务端密钥时能造出的最强形态)
@@ -603,6 +603,9 @@ class FcReconcileTaskTest {
report(1, AnswerReconcileTask.STATE_FAILED)); report(1, AnswerReconcileTask.STATE_FAILED));
assertThat(ack.getStaleAttempt()).isTrue(); assertThat(ack.getStaleAttempt()).isTrue();
assertThat(ack.getDecision()).isEqualTo("STALE_RETRY");
assertThat(ack.getReportAttempt()).isEqualTo(1);
assertThat(ack.getAttempt()).isEqualTo(3);
assertThat(ack.getAccepted()).isTrue(); assertThat(ack.getAccepted()).isTrue();
verify(resultRepository).saveAndFlush(any(AnswerReconcileResult.class)); verify(resultRepository).saveAndFlush(any(AnswerReconcileResult.class));
verify(taskRepository, never()).advanceTerminal(anyLong(), anyString(), any(), any(), any()); verify(taskRepository, never()).advanceTerminal(anyLong(), anyString(), any(), any(), any());
@@ -612,6 +615,58 @@ class FcReconcileTaskTest {
assertThat(task.getState()).isEqualTo(AnswerReconcileTask.STATE_RUNNING); 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 @Test
@DisplayName("假 result:回报 attempt 超过中心已派发数即拒;跨消费方身份同样拒") @DisplayName("假 result:回报 attempt 超过中心已派发数即拒;跨消费方身份同样拒")
void resultFromUnknownAttemptOrCallerIsRejected() { void resultFromUnknownAttemptOrCallerIsRejected() {