diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index a759dd1..d8f06cf 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -16,6 +16,10 @@ on: description: '构建前清空 maven m2 缓存' required: false default: 'false' + tests_only: + description: '只验证测试和 JAR,不构建推送镜像或通知建档' + required: false + default: 'true' jobs: build-push: @@ -36,6 +40,8 @@ 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 cp -a /tmp/src/. . rm -rf /tmp/src # 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符 @@ -64,6 +70,7 @@ jobs: # GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。 BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)" 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 run -d --name "$BUILD_CONTAINER" \ --network infra-infra-net \ @@ -71,13 +78,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' 'nexus-reponexus-internalhttp://nexus:8081/repository/maven-public/truetruealwaysnexus-repo' > /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: ${{ inputs.tests_only != 'true' }} run: | set -e TAG=$(echo "$GITHUB_SHA" | cut -c1-8) @@ -88,6 +96,7 @@ jobs: # 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual) - name: 通知 projman 建档(部署由运维手动触发) + if: ${{ inputs.tests_only != 'true' }} run: | curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \ -H "Content-Type: application/json" \ diff --git a/ci/test-gate.sh b/ci/test-gate.sh new file mode 100644 index 0000000..5851cdc --- /dev/null +++ b/ci/test-gate.sh @@ -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 diff --git a/docs/answer-delivery-v2.md b/docs/answer-delivery-v2.md index 30e6a61..5d5a1e3 100644 --- a/docs/answer-delivery-v2.md +++ b/docs/answer-delivery-v2.md @@ -316,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;过期(含相等)后才能重新排队。 +接收与领取均用同一任务行的悲观锁;领取时重新核对状态和退避时刻,陈旧扫描候选不能双领。 diff --git a/docs/answer-reconcile-v1.md b/docs/answer-reconcile-v1.md index 5337bf3..9ad7fc2 100644 --- a/docs/answer-reconcile-v1.md +++ b/docs/answer-reconcile-v1.md @@ -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 过期执行者保护共同防止旧执行清除新租约。 diff --git a/src/main/java/com/yunhai/formcenter/model/FormSyncModels.java b/src/main/java/com/yunhai/formcenter/model/FormSyncModels.java index 8d281db..a482ccb 100644 --- a/src/main/java/com/yunhai/formcenter/model/FormSyncModels.java +++ b/src/main/java/com/yunhai/formcenter/model/FormSyncModels.java @@ -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; } diff --git a/src/main/java/com/yunhai/formcenter/service/AnonReplayGuard.java b/src/main/java/com/yunhai/formcenter/service/AnonReplayGuard.java index ac1c850..861813d 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnonReplayGuard.java +++ b/src/main/java/com/yunhai/formcenter/service/AnonReplayGuard.java @@ -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()); } /** 签发指定制表时刻的票据值(窗口边界用例要用"过去的时间戳",包内可见即可,不对外暴露) */ diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReconcileService.java b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileService.java index 0ec37e8..5a7aba6 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerReconcileService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileService.java @@ -736,38 +736,33 @@ public class AnswerReconcileService { "回报的 attempt=" + attempt + " 超过中心已派发的 " + dispatchedAttempt); } Optional 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; } diff --git a/src/test/java/com/yunhai/formcenter/service/AnonReplayGuardTest.java b/src/test/java/com/yunhai/formcenter/service/AnonReplayGuardTest.java index b09fdaf..0a7b4d4 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnonReplayGuardTest.java +++ b/src/test/java/com/yunhai/formcenter/service/AnonReplayGuardTest.java @@ -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); // 换密钥(攻击者不知道服务端密钥时能造出的最强形态) diff --git a/src/test/java/com/yunhai/formcenter/service/FcReconcileTaskTest.java b/src/test/java/com/yunhai/formcenter/service/FcReconcileTaskTest.java index 2f8e4d2..79c600d 100644 --- a/src/test/java/com/yunhai/formcenter/service/FcReconcileTaskTest.java +++ b/src/test/java/com/yunhai/formcenter/service/FcReconcileTaskTest.java @@ -603,6 +603,9 @@ 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).saveAndFlush(any(AnswerReconcileResult.class)); verify(taskRepository, never()).advanceTerminal(anyLong(), anyString(), any(), any(), any()); @@ -612,6 +615,58 @@ class FcReconcileTaskTest { 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 @DisplayName("假 result:回报 attempt 超过中心已派发数即拒;跨消费方身份同样拒") void resultFromUnknownAttemptOrCallerIsRejected() {