diff --git a/docs/answer-delivery-v2.md b/docs/answer-delivery-v2.md index 05dd74c..30e6a61 100644 --- a/docs/answer-delivery-v2.md +++ b/docs/answer-delivery-v2.md @@ -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`,稳定码) | 码 | 判据 | 是否重启流程 | diff --git a/src/main/java/com/yunhai/formcenter/service/OapiFormService.java b/src/main/java/com/yunhai/formcenter/service/OapiFormService.java index fddf611..3afd40d 100644 --- a/src/main/java/com/yunhai/formcenter/service/OapiFormService.java +++ b/src/main/java/com/yunhai/formcenter/service/OapiFormService.java @@ -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); } diff --git a/src/test/java/com/yunhai/formcenter/service/OapiSchemaPublishedVersionTest.java b/src/test/java/com/yunhai/formcenter/service/OapiSchemaPublishedVersionTest.java new file mode 100644 index 0000000..0b82d2c --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/OapiSchemaPublishedVersionTest.java @@ -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); + } +}