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.
This commit is contained in:
@@ -7,6 +7,11 @@
|
|||||||
依赖:TASK_1《consumer-access-v3.md》的 `FormConsumerAccessService`(owner/read/subscribe 与
|
依赖:TASK_1《consumer-access-v3.md》的 `FormConsumerAccessService`(owner/read/subscribe 与
|
||||||
`grantVersion`)是唯一授权事实源;本契约不写第二套 ACL,只消费它。
|
`grantVersion`)是唯一授权事实源;本契约不写第二套 ACL,只消费它。
|
||||||
|
|
||||||
|
**现行主链(2026-10-22 决策,10-23 复核)**:SH 以签名收据 + HTTP 确认建件,不依赖 MQ 快照
|
||||||
|
投递行、broker confirm 或 MQ 消费成功;`requireRead + requireSubscribe` 仍是可信上下文的授权前提。
|
||||||
|
下文 outbox/MQ 章节描述可选推送通道,不能据此把 MQ 重新设为建件必选项。
|
||||||
|
MQ 隔离、`push_allowed` 全量契约与投递状态覆盖修复另案交付,不能由本轮 FL 门禁变绿推定已完成。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 协议版本差额(先说清"新增了什么、旧的那条怎么算")
|
## 1. 协议版本差额(先说清"新增了什么、旧的那条怎么算")
|
||||||
@@ -116,7 +121,7 @@ SH POST /api/apply/intents(body 只有 siteCode/itemCode/clientKey)
|
|||||||
└ POST /api/form/hosted/submit(body 同 /api/form/submit + contextToken)
|
└ POST /api/form/hosted/submit(body 同 /api/form/submit + contextToken)
|
||||||
FC 在答卷事务内:redeem(存在→未过期→会话用户=断言填报人→会话校=上下文租户域,
|
FC 在答卷事务内:redeem(存在→未过期→会话用户=断言填报人→会话校=上下文租户域,
|
||||||
错人/错校/过期/不存在一律拒)→ 坐标逐字核对 → 落答卷(answer_revision=1)
|
错人/错校/过期/不存在一律拒)→ 坐标逐字核对 → 落答卷(answer_revision=1)
|
||||||
→ 原子关联 submission_context.answer_uuid(uk 双向唯一)→ 同事务写 outbox
|
→ 原子关联 submission_context.answer_uuid(uk 双向唯一)→ 满足推送条件的消费方才同事务写 outbox
|
||||||
→ 同 token 同幂等键=原答卷重放;换幂等键重用=拒绝
|
→ 同 token 同幂等键=原答卷重放;换幂等键重用=拒绝
|
||||||
└ 成功只 postMessage {proto:"fc",v:1,type:"success",hosted:true} 给**服务端锁定的精确 origin**
|
└ 成功只 postMessage {proto:"fc",v:1,type:"success",hosted:true} 给**服务端锁定的精确 origin**
|
||||||
(不带 answerId、不带版本号;referrer 回落与同 hostname 降级都不可用)
|
(不带 answerId、不带版本号;referrer 回落与同 hostname 降级都不可用)
|
||||||
@@ -205,6 +210,19 @@ v2 快照带完整值文档,"能连上 broker 的任何人可见"是不可接
|
|||||||
关键取舍:**不靠异常类型猜"远端有没有受理"**。查到 30/40 只回填镜像状态、**绝不重发 apply**
|
关键取舍:**不靠异常类型猜"远端有没有受理"**。查到 30/40 只回填镜像状态、**绝不重发 apply**
|
||||||
(重发会命中中心 R4 重启,把已拒绝/已撤回的件重新跑起来);只有查无才用同一 `requestToken` 补发。
|
(重发会命中中心 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`,稳定码)
|
### 7.4 flow-center 发起守卫分支(`FlowApplyRetryModels.ApplyResultV2.branch`,稳定码)
|
||||||
|
|
||||||
| 码 | 判据 | 是否重启流程 |
|
| 码 | 判据 | 是否重启流程 |
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ public class OapiFormService {
|
|||||||
}
|
}
|
||||||
requireSchId(schId);
|
requireSchId(schId);
|
||||||
FormDefService.Published published = defService.resolvePublishedByUuid(schId, uuid, version);
|
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);
|
return toSchemaView(published);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user