diff --git a/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java b/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java index deff4d6..069e86f 100644 --- a/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java +++ b/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java @@ -13,10 +13,17 @@ import org.springframework.web.bind.annotation.RestController; * {@code yh.app-auth.server.enforce-patterns=[/oapi/*]} 已覆盖本前缀,无签名三件套直接 401)。 * *
- * GET /oapi/form/schema?schId=&code=[&appCode=] → data=内联 options + valueSchema 的机读 schema(契约 §4) - * GET /oapi/form/answer?schId=&answerId= → data=钉住版本的 schema 引用 + 值文档(契约 §9) + * GET /oapi/form/schema?schId=&code=[&appCode=] → 现形态:data=内联 options + valueSchema 的机读 schema(契约 §4) + * GET /oapi/form/schema?schId=&uuid= → T11 新形态:该表单当前已发布版 + * GET /oapi/form/schema?schId=&uuid=&version= → T11 新形态:精确版本行(快照核对/重放) + * GET /oapi/form/answer?schId=&answerId= → data=钉住版本的 schema 引用 + 值文档(契约 §9) ** + *
uuid 两形态的用途=给 flow-center 在流程定义发布期拉契约快照嵌进 def 版本行 + * (《ysch-flow单一接入件与中心托管体验-实施方案v2-20261019.md》§4.4),运行期中心零依赖。 + * uuid 与 code 同传时 uuid 优先(code/appCode 被忽略):uuid 是权威坐标、code 是解析入口, + * 两者同传说明调用方手里是存下来的 uuid,按它取才是它要的那张表。响应形状与 code 形态完全一致。
+ * *信封 {@code code=0} 由平台统一响应链负责;消费范式={@code YschOpenCall.raw()} 直调 * (Tier0 逃生舱,不建 SDK form 域门面——≥2 消费方实际调用时才触发晋升评估,方案 §7.5)。
* @@ -31,12 +38,17 @@ public class OapiFormController { private final OapiFormService formService; - /** 机读 schema(内联 options + valueSchema) */ + /** + * 机读 schema(内联 options + valueSchema);寻址支持 code 与 uuid 两种形态, + * 同传时 uuid 优先(见类头),{@code version} 只在 uuid 形态有意义=精确取版本行。 + */ @GetMapping("/schema") public OapiModels.SchemaView schema(@RequestParam("schId") Long schId, - @RequestParam("code") String code, - @RequestParam(value = "appCode", required = false) String appCode) { - return formService.schema(schId, appCode, code); + @RequestParam(value = "code", required = false) String code, + @RequestParam(value = "appCode", required = false) String appCode, + @RequestParam(value = "uuid", required = false) String uuid, + @RequestParam(value = "version", required = false) Integer version) { + return formService.schema(schId, appCode, code, uuid, version); } /** 单条答卷(钉住版本 schema 引用 + 值文档) */ diff --git a/src/main/java/com/yunhai/formcenter/service/FormDefService.java b/src/main/java/com/yunhai/formcenter/service/FormDefService.java index 8fe92a9..663b409 100644 --- a/src/main/java/com/yunhai/formcenter/service/FormDefService.java +++ b/src/main/java/com/yunhai/formcenter/service/FormDefService.java @@ -229,6 +229,47 @@ public class FormDefService { return form; } + /** + * 对外读接口的 uuid 形态解析(T11,v2 方案 §4.4:flow-center 在流程定义发布期按 uuid 拉契约快照)。 + * + *两条分支刻意不同源,因为它们回答的是两个问题:
+ *域校验复用 {@link #readDomainForm}(本校 ∪ 平台级):他校 uuid 与不存在的 uuid 同一回答, + * 差别信息就是探测面(契约 §1)。
+ */ + public Published resolvePublishedByUuid(Long schId, String formUuid, Integer version) { + Form form = readDomainForm(schId, formUuid); + if (version != null) { + FormVersion row = versionRepository.findByFormIdAndVersion(form.getId(), version).orElse(null); + if (row == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + "版本行不存在:" + form.getAppCode() + "/" + form.getCode() + " v" + version); + } + return new Published(form, row); + } + if (Integer.valueOf(1).equals(form.getIsDeleted())) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + formUuid); + } + if (form.getStatus() == null || form.getStatus() != FormEnums.FormStatus.PUBLISHED.getCode() + || form.getCurrentVersion() == null) { + throw new BizException(ErrorCode.CUSTOM, form.getStatus() != null + && form.getStatus() == FormEnums.FormStatus.DISABLED.getCode() + ? "表单已停用,不再接收填报:" + form.getCode() : "表单尚未发布:" + form.getCode()); + } + FormVersion row = versionRepository.findByFormIdAndVersion(form.getId(), form.getCurrentVersion()) + .orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR, + "当前发布版本行缺失(version=" + form.getCurrentVersion() + "),请联系管理员")); + return new Published(form, row); + } + /** * 数据面读接口入口把关用的表单定位(T10 方案 §5.2):只按 uuid 取头表、不做域判定, * 取不到返回 null。域与模块行的判断全在 {@code FormAdminGuard#requireDataView}—— diff --git a/src/main/java/com/yunhai/formcenter/service/OapiFormService.java b/src/main/java/com/yunhai/formcenter/service/OapiFormService.java index 97e1d39..8ae5977 100644 --- a/src/main/java/com/yunhai/formcenter/service/OapiFormService.java +++ b/src/main/java/com/yunhai/formcenter/service/OapiFormService.java @@ -39,6 +39,31 @@ public class OapiFormService { private final FormDictService dictService; private final AnswerRepository answerRepository; + /** + * {@code /oapi/form/schema} 的入口分流(T11):{@code uuid} 非空走按 uuid 取版本行的新路径, + * 否则原样交给 code 形态的 {@link #schema(Long, String, String)}——原实现一行没挪, + * 所以 code 查询路径的行为与响应逐字节不变(任务书 §0-2 红线)。 + * + *uuid 优先:{@code uuid} 与 {@code code} 同传时只看 uuid,忽略 code 与 appCode + * (uuid 是权威坐标、code 是解析入口,两者同传说明调用方拿的是 flow-center 存的 uuid)。 + * {@code version} 只在 uuid 形态有意义,单独传 version 不给 uuid 直接拒——静默忽略会让调用方 + * 以为拿到的是那个版本(快照核对场景下这是最坏的错误)。
+ * + * @param uuid 表单 uuid(可空;契约 §1:32 位、跨版本不变、不可枚举) + * @param version 版本行号(可空=当前已发布版;给值=精确取该行) + */ + public OapiModels.SchemaView schema(Long schId, String appCode, String code, String uuid, Integer version) { + if (version != null && (uuid == null || uuid.trim().isEmpty())) { + throw new BizException(ErrorCode.PARAM_ERROR, "version 需与 uuid 同传(uuid 形态的版本行核对参数)"); + } + if (uuid == null || uuid.trim().isEmpty()) { + return schema(schId, appCode, code); + } + requireSchId(schId); + FormDefService.Published published = defService.resolvePublishedByUuid(schId, uuid, version); + return toSchemaView(published); + } + /** * 机读 schema(契约 §4 形状):字典已内联展开,每个字段附 valueSchema。 * @@ -103,6 +128,27 @@ public class OapiFormService { return view; } + /** + * 已发布解析结果 → 机读 schema 视图(契约 §4 形状)。 + * + *与 {@link #schema(Long, String, String)} 内的组装段逐行同形:任务书 §0-2/W2-1 要求 + * code 形态的原方法体不重构不挪动,故 uuid 形态自带一份而不是抽公共方法(问题单 P108)。 + * 要改这里就同时改那边——{@code OapiFormServiceIT} 的 F1 断言两种形态的 groups 等价,是这份重复的漂移闸。
+ */ + private OapiModels.SchemaView toSchemaView(FormDefService.Published published) { + Form form = published.form(); + JsonNode root = FormValueSchemaBuilder.withValueSchemas(expandedRoot(form, published.versionRow().getSchemaJson())); + + OapiModels.SchemaView view = new OapiModels.SchemaView(); + view.setFormUuid(form.getUuid()); + view.setVersion(published.versionRow().getVersion()); + view.setCode(form.getCode()); + view.setAppCode(form.getAppCode()); + view.setAudience(FormEnums.Audience.nameOf(form.getAudience())); + view.setGroups(root.path("groups")); + return view; + } + /** 字典内联展开 → JSON 树(对外自包含,契约 §2.1) */ private JsonNode expandedRoot(Form form, String schemaJson) { return FormSchemaValidator.parse(dictService.expandSchema(form.getSchId(), schemaJson), diff --git a/src/test/java/com/yunhai/formcenter/controller/oapi/OapiFormControllerTest.java b/src/test/java/com/yunhai/formcenter/controller/oapi/OapiFormControllerTest.java new file mode 100644 index 0000000..2151246 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/controller/oapi/OapiFormControllerTest.java @@ -0,0 +1,114 @@ +package com.yunhai.formcenter.controller.oapi; + +import com.yunhai.formcenter.common.FormSchemaValidator; +import com.yunhai.formcenter.model.OapiModels; +import com.yunhai.formcenter.service.OapiFormService; +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.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * {@code /oapi/form/schema} 的参数绑定单测(T11 W1)。 + * + *服务层的分流与口径由 {@code OapiFormServiceIT} 的 F1~F7 断言;这一层只有 MockMvc 能证三件事:
+ *用 {@code standaloneSetup} 而不是 {@code @SpringBootTest}:{@code /oapi/*} 的验签墙与统一响应链 + * 是平台链({@code local-debug/t4-read-smoke.sh} 取证),本类只管参数形状,因此也不断言信封包装。
+ */ +class OapiFormControllerTest { + + private OapiFormService formService; + private MockMvc mvc; + + @BeforeEach + void setUp() { + formService = mock(OapiFormService.class); + mvc = MockMvcBuilders.standaloneSetup(new OapiFormController(formService)).build(); + } + + @Test + @DisplayName("uuid 形态不带 code:绑定通过(不 400),code/appCode 以 null 传给服务") + void uuidFormNeedsNoCode() throws Exception { + when(formService.schema(1001L, null, null, "t11uuid000000000000000000000000ab", null)) + .thenReturn(view("t11uuid000000000000000000000000ab", 2)); + + mvc.perform(get("/oapi/form/schema").param("schId", "1001") + .param("uuid", "t11uuid000000000000000000000000ab")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.formUuid").value("t11uuid000000000000000000000000ab")) + .andExpect(jsonPath("$.version").value(2)); + + verify(formService).schema(1001L, null, null, "t11uuid000000000000000000000000ab", null); + } + + @Test + @DisplayName("uuid+version 形态:version 绑成 Integer=3;不给时是 null 而不是 0") + void versionBindsAsIntegerAndDefaultsToNull() throws Exception { + when(formService.schema(1001L, null, null, "u", 3)).thenReturn(view("u", 3)); + mvc.perform(get("/oapi/form/schema").param("schId", "1001").param("uuid", "u").param("version", "3")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.version").value(3)); + + when(formService.schema(1001L, null, null, "u", null)).thenReturn(view("u", 7)); + mvc.perform(get("/oapi/form/schema").param("schId", "1001").param("uuid", "u")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.version").value(7)); + } + + @Test + @DisplayName("code 形态五参全给:controller 原样透传,不做任何取舍(取舍规则在服务层)") + void codeFormPassesAllFiveArgumentsThrough() throws Exception { + when(formService.schema(1001L, "ysch-flow", "honor_student", null, null)).thenReturn(view("u9", 1)); + + mvc.perform(get("/oapi/form/schema").param("schId", "1001") + .param("code", "honor_student").param("appCode", "ysch-flow")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.groups[0].key").value("base")) + .andExpect(jsonPath("$.audience").value("STUDENT")); + + verify(formService).schema(1001L, "ysch-flow", "honor_student", null, null); + } + + @Test + @DisplayName("uuid 与 code 同传:两个值都原样交给服务(uuid 优先由服务层判,见 IT F7)") + void bothKeysAreForwarded() throws Exception { + when(formService.schema(1001L, null, "honor_student", "u-first", null)).thenReturn(view("u-first", 1)); + + mvc.perform(get("/oapi/form/schema").param("schId", "1001") + .param("code", "honor_student").param("uuid", "u-first")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.formUuid").value("u-first")); + + verify(formService).schema(1001L, null, "honor_student", "u-first", null); + } + + private static OapiModels.SchemaView view(String uuid, int version) { + OapiModels.SchemaView view = new OapiModels.SchemaView(); + view.setFormUuid(uuid); + view.setVersion(version); + view.setCode("honor_student"); + view.setAppCode("form-center"); + view.setAudience("STUDENT"); + try { + view.setGroups(FormSchemaValidator.mapper() + .readTree("[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":[]}]")); + } catch (Exception e) { + throw new IllegalStateException("测试 JSON 写错了", e); + } + return view; + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/OapiFormServiceIT.java b/src/test/java/com/yunhai/formcenter/service/OapiFormServiceIT.java index 63ca943..e76333f 100644 --- a/src/test/java/com/yunhai/formcenter/service/OapiFormServiceIT.java +++ b/src/test/java/com/yunhai/formcenter/service/OapiFormServiceIT.java @@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; /** * 对外读接口集成测试(契约 §9 两端点 + §4 下发形状;真实 MySQL)。 @@ -30,6 +31,9 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; * *信封 {@code code=0} 与验签墙是平台链({@code /oapi/*} 401 由 {@code local-debug/t4-read-smoke.sh} * 启动冒烟取证),本类断言的是 data 载荷内容。
+ * + *T11(契约 §9 v20261019 增补)加了 F1~F7 一组 {@code uuid}/{@code version} 形态用例: + * F8 就是上面原有那四个 code 形态用例——它们一字未改,全绿即"现行为逐字节不变"的证据。
*/ @SpringBootTest @Import(AnswerTestSupport.TestGatewayConfiguration.class) @@ -148,8 +152,148 @@ class OapiFormServiceIT extends AnswerTestSupport { .isInstanceOf(BizException.class).hasMessageContaining("answerId 不能为空"); } + // ==================== T11:schema 的 uuid 形态(契约 §9 v20261019 增补) ==================== + + @Test + @DisplayName("F1 uuid 取当前已发布版:响应与 code 形态等价(groups 逐字节相同)") + void uuidResolvesCurrentPublishedVersion() { + String formUuid = publishForm(CODE, SCHEMA); + + OapiModels.SchemaView byUuid = oapiFormService.schema(SCH, null, null, formUuid, null); + assertThat(byUuid.getFormUuid()).isEqualTo(formUuid); + assertThat(byUuid.getVersion()).isEqualTo(1); + assertThat(byUuid.getCode()).isEqualTo(CODE); + assertThat(byUuid.getAppCode()).isEqualTo("form-center"); + assertThat(byUuid.getAudience()).isEqualTo("STUDENT"); + + // 两份组装的漂移闸(问题单 P108):同库同表同版本,两种形态的 groups 必须逐字相同 + OapiModels.SchemaView byCode = oapiFormService.schema(SCH, null, CODE); + assertThat(byUuid.getGroups().toString()).isEqualTo(byCode.getGroups().toString()); + // 内联展开 + valueSchema 的口径也一致(uuid 形态不是"半成品"schema) + assertThat(field(byUuid.getGroups(), "grade").path("options").get(0).path("label").asText()) + .isEqualTo("高二年级"); + assertThat(byUuid.getGroups().toString()).doesNotContain("dict:grades"); + } + + @Test + @DisplayName("F2 uuid+version 精确取版本行:非 currentVersion 同样放行,停用/软删不影响历史核对") + void uuidWithVersionReadsTheExactRow() { + String formUuid = publishForm(CODE, SCHEMA); + FormModels.FormSaveRequest save = new FormModels.FormSaveRequest(); + save.setUuid(formUuid); + save.setSchemaJson(SCHEMA_V2); + defService.save(SCH, save); + defService.publish(SCH, formUuid, "加新字段"); // currentVersion=2 + + OapiModels.SchemaView v1 = oapiFormService.schema(SCH, null, null, formUuid, 1); + assertThat(v1.getVersion()).isEqualTo(1); + assertThat(v1.getGroups().toString()).doesNotContain("新字段"); + assertThat(oapiFormService.schema(SCH, null, null, formUuid, null).getVersion()) + .isEqualTo(2); // 不给 version=当前版 + + // 溯源口径:不校验生命周期(方案 §5.4"停用只断填报入口"),历史行仍可核对 + defService.disable(SCH, formUuid); + assertThat(oapiFormService.schema(SCH, null, null, formUuid, 2).getVersion()).isEqualTo(2); + defService.softDelete(SCH, formUuid); + assertThat(oapiFormService.schema(SCH, null, null, formUuid, 1).getVersion()).isEqualTo(1); + } + + @Test + @DisplayName("F3 uuid 的域=本校 ∪ 平台级;且 uuid 形态不经遮蔽(uuid 是权威坐标)") + void uuidAcceptsPlatformLevelFormWithoutShadowing() { + String platformUuid = publishForm(0L, CODE, SCHEMA); + assertThat(oapiFormService.schema(SCH, null, null, platformUuid, null).getFormUuid()).isEqualTo(platformUuid); + assertThat(oapiFormService.schema(OTHER_SCH, null, null, platformUuid, null).getFormUuid()).isEqualTo(platformUuid); + + // 本校建同 (app_code, code) 表单:code 形态被遮蔽,uuid 形态照旧指到平台级那一行 + String schoolUuid = publishForm(SCH, CODE, SCHEMA); + assertThat(oapiFormService.schema(SCH, null, CODE).getFormUuid()).isEqualTo(schoolUuid); + assertThat(oapiFormService.schema(SCH, null, null, platformUuid, null).getFormUuid()).isEqualTo(platformUuid); + } + + @Test + @DisplayName("F4 防枚举:他校表单与不存在的 uuid 同一回答(文案只差 uuid 回显本身)") + void otherSchoolUuidAndMissingUuidAreIndistinguishable() { + String otherUuid = publishForm(OTHER_SCH, "t11_other_school", SCHEMA); + String missingUuid = "t11nosuchformuuid00000000000000"; + + String onOtherSchool = catchThrowable(() -> oapiFormService.schema(SCH, null, null, otherUuid, null)) + .getMessage(); + String onMissing = catchThrowable(() -> oapiFormService.schema(SCH, null, null, missingUuid, null)) + .getMessage(); + + assertThat(onOtherSchool).startsWith("表单不存在:").endsWith(otherUuid); + assertThat(onMissing).startsWith("表单不存在:").endsWith(missingUuid); + // 抹掉 uuid 回显后两条文案逐字相同=没有差别信息(差别信息就是探测面,契约 §1) + assertThat(onOtherSchool.replace(otherUuid, "