2 Commits
Author SHA1 Message Date
chenxing 13abc2dbc7 t11-schema: /oapi/form/schema 支持按 form_uuid 查询(uuid 优先 + version 精确取行)
build / build-push (push) Failing after 2m2s
flow-center 在流程定义发布期拉契约快照的数据源(v2 方案 §4.4),code 查询路径逐字节不变:

- Controller:新增可选 uuid(32 位串)/version(Integer),code 由必填放宽为可选;uuid 与
  code 同传时 uuid 优先(类头 javadoc 写明形态与用途);响应形状零改动,仍是同一个 SchemaView。
- FormDefService.resolvePublishedByUuid(纯新增):域校验复用 readDomainForm(本校 ∪ 平台级,
  他校与不存在的 uuid 同一回答,不给探测面);不给 version=入口口径——要求 PUBLISHED 且
  currentVersion 非空,文案与错误码族对齐 resolvePublished,并额外挡软删行(code 形态取域
  SQL 带 is_deleted=0,不挡就是同一张表两份可读集);给 version=溯源口径——pinnedForData
  同款,不吃生命周期、不过滤软删,缺行给 PARAM「版本行不存在:<appCode>/<code> v<n>」。
- OapiFormService:五参重载只做入口分流,原三参方法体一字未动(numstat 删除行=0);只给
  version 不给 uuid 显式拒(静默忽略会让调用方误以为拿到的是那个版本)。SchemaView 组装段
  按任务书 W2-1「原方法体不重构不挪动」保留两份,靠 F1 的 groups 逐字比对作漂移闸。

问题单 P106~P111 已回填执行手册 §5;《契约》§9 增补两形态并新开 §9.1(参数表/示例/错误码/
「uuid 形态不经遮蔽」与「两条分支口径不同源」两段说明)。

验证:mvn -B -o test 全量 272 例绿(0 failures / 0 errors,BUILD SUCCESS)。
新增 11 例=OapiFormServiceIT F1~F7(uuid 已发布版/精确历史版/平台级/防枚举同文案/
未发布·停用·软删/版本行不存在/uuid 优先)+ OapiFormControllerTest 4 例(不传 code 不 400、
version 绑成 Integer 且缺省 null、五参原样透传)。基线 261 → 只增不减;
F8 回归=既有四个 code 形态用例一字未改(该测试文件 diff 为 +144/-0)。
2026-10-19 19:56:46 +08:00
chenxing 7af6a98fc0 fix(post-p95): 跨版本编辑必挂/编辑不吃截止闸/自填 subject 归一/报名号并发重号
P95 落地后的独立修复批,四条存量逻辑硬伤(填报入口层不动):

- 跨版本编辑必挂:客户端按当前发布版渲染、服务端按钉住旧版校验,升版后编辑永远
  400(含未知字段/缺必填)。改为编辑态吃 /my/detail 的钉住版 groups。
- 编辑不吃截止/停用闸:先占位、截止后随便改可绕过 deadline。editBySubmitter 补
  截止/停用/已删三闸,与 submit 同口径。
- 自填 subject 落 NULL:subject_id 为空的自填行进不了"关于我的"(/my/about 与
  ABOUT_ME 谓词都按 subject 查),V18"含代填"实际退化成"仅代填"。落库归一为
  coalesce(subjectId, submitterId);限次键本就是 coalesce,不受影响。
- answer_no 并发重号:count+1 的 count 与 insert 之间有并发窗口,报名号是对外凭证。
  加 uk_answer_no 兜底 + 撞号重算序号重试(3 次封顶),按异常消息列名区分撞的是
  报名号还是幂等键(MySQL/H2 通用)。

语义变更备案:自填行 subject 由 NULL 变本人,读矩阵"学生x面向学生=关于我"这一格
此后含自填行,可见集变大(DataScopeService/矩阵规则一字未动,变的是数据形状)。
三处断言随行为更新:AdminScopeIT、AnswerReadIT、MyAnswerIT(+ReadFixture 注释)。

验证:mvn 261 绿(+2 新用例:并发报名号不重复、编辑闸三态)。
2026-10-16 15:04:20 +08:00
14 changed files with 498 additions and 18 deletions
+4
View File
@@ -40,6 +40,10 @@ CREATE TABLE `answer` (
PRIMARY KEY (`id`),
UNIQUE KEY `uk_answer_uuid` (`uuid`),
UNIQUE KEY `uk_answer_idem` (`idempotency_key`),
-- Bug#4 变更(本轮):报名号并发重号兜底。answer_no 是 count+1(当日已有号数)生成,
-- 同表单同日并发提交会算出同一个号;号是对外凭证,库级唯一才拦得住。
-- null 不参与唯一判定(未开报名号的表单该列恒 null,多行共存不受影响)。
UNIQUE KEY `uk_answer_no` (`sch_id`, `app_code`, `code`, `answer_no`),
KEY `idx_answer_form` (`sch_id`, `code`, `status`, `id`),
KEY `idx_answer_submitter` (`sch_id`, `submitter_id`),
KEY `idx_answer_subject` (`subject_id`),
@@ -13,10 +13,17 @@ import org.springframework.web.bind.annotation.RestController;
* {@code yh.app-auth.server.enforce-patterns=[/oapi/*]} 已覆盖本前缀,无签名三件套直接 401)。
*
* <pre>
* 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=&amp;code=[&amp;appCode=] → 现形态:data=内联 options + valueSchema 的机读 schema(契约 §4)
* GET /oapi/form/schema?schId=&amp;uuid= → T11 新形态:该表单<b>当前已发布版</b>
* GET /oapi/form/schema?schId=&amp;uuid=&amp;version= → T11 新形态:精确版本行(快照核对/重放)
* GET /oapi/form/answer?schId=&amp;answerId= → data=钉住版本的 schema 引用 + 值文档(契约 §9)
* </pre>
*
* <p>uuid 两形态的用途=给 flow-center 在<b>流程定义发布期</b>拉契约快照嵌进 def 版本行
* (《ysch-flow单一接入件与中心托管体验-实施方案v2-20261019.md》§4.4),运行期中心零依赖。
* <b>uuid 与 code 同传时 uuid 优先</b>(code/appCode 被忽略):uuid 是权威坐标、code 是解析入口,
* 两者同传说明调用方手里是存下来的 uuid,按它取才是它要的那张表。响应形状与 code 形态完全一致。</p>
*
* <p>信封 {@code code=0} 由平台统一响应链负责;消费范式={@code YschOpenCall.raw()} 直调
* (Tier0 逃生舱,不建 SDK form 域门面——≥2 消费方实际调用时才触发晋升评估,方案 §7.5)。</p>
*
@@ -31,12 +38,17 @@ public class OapiFormController {
private final OapiFormService formService;
/** 机读 schema(内联 options + valueSchema) */
/**
* 机读 schema(内联 options + valueSchema);寻址支持 code 与 uuid 两种形态,
* <b>同传时 uuid 优先</b>(见类头),{@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 引用 + 值文档) */
@@ -33,7 +33,12 @@ import javax.persistence.UniqueConstraint;
@Table(name = "answer",
uniqueConstraints = {
@UniqueConstraint(name = "uk_answer_uuid", columnNames = "uuid"),
@UniqueConstraint(name = "uk_answer_idem", columnNames = "idempotency_key")
@UniqueConstraint(name = "uk_answer_idem", columnNames = "idempotency_key"),
// Bug#4:报名号是 count+1 生成的(当日已有号数),count 与 insert 之间有并发窗口,
// 同表单同日并发提交会算出同一个号。号是对外凭证,库级唯一是唯一靠得住的兜底
// (null 不参与唯一判定,未开报名号的表单不受影响);撞键由 AnswerService 重算序号重试。
@UniqueConstraint(name = "uk_answer_no",
columnNames = {"sch_id", "app_code", "code", "answer_no"})
},
indexes = {
@Index(name = "idx_answer_form", columnList = "sch_id, code, status, id"),
@@ -354,6 +354,17 @@ public class AnswerReadService {
}
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
Form form = pinned.form();
// Bug#2:编辑与提交同口径——过截止/表单停用(或已软删)就不再接收修改。
// 否则"先交个占位、截止后随便改"能绕过 deadline,截止语义形同虚设(停用同理:填报入口已失效)。
if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) {
throw new BizException(ErrorCode.CUSTOM, "已过收集截止时间,不再接收修改:" + form.getCode());
}
if (Integer.valueOf(1).equals(form.getIsDeleted())) {
throw new BizException(ErrorCode.CUSTOM, "表单已删除,不能修改提交:" + form.getCode());
}
if (!FormRuntimeRules.isFillOpen(form)) {
throw new BizException(ErrorCode.CUSTOM, "表单已停用,不能修改提交:" + form.getCode());
}
String valuesJson = values == null || values.isNull() ? "{}" : values.toString();
// 与管理员编辑同一条值契约(按钉住版本、字典放行集);P34:附件三件套必须对得上本校上传记录
FormValueValidator.validate(valuesJson, pinned.versionRow().getSchemaJson(),
@@ -69,6 +69,8 @@ public class AnswerService {
/** 答案号日期段格式:{@code {code}-{yyyyMMdd}-{当日序号}}(契约 §5) */
private static final DateTimeFormatter ANSWER_NO_DAY = DateTimeFormatter.ofPattern("yyyyMMdd");
/** 报名号并发撞号后的重试上限(窗口极窄:同表单同日同刻提交且序号算到同一个数) */
private static final int ANSWER_NO_RETRY = 3;
private static final String DEFAULT_APP_CODE = Form.CENTER_APP_CODE;
private final FormDefService defService;
@@ -89,10 +91,26 @@ public class AnswerService {
*/
public AnswerModels.SubmitView submit(BoaUser user, AnswerModels.SubmitRequest req) {
String idempotencyKey = requireIdempotencyKey(req.getIdempotencyKey());
return executeSubmit(user, req, idempotencyKey, ANSWER_NO_RETRY);
}
/**
* 提交事务的一次尝试。报名号是"当日已有号数+1"(count 与 insert 之间有并发窗口),
* 撞 {@code uk_answer_no} 时重算序号重建事务——重新提交时对手那条已提交,count 已把它算进去。
*/
private AnswerModels.SubmitView executeSubmit(BoaUser user, AnswerModels.SubmitRequest req,
String idempotencyKey, int remaining) {
try {
return transactionTemplate.execute(tx -> doSubmit(user, req, idempotencyKey));
} catch (DataIntegrityViolationException e) {
// 只剩两种可能:幂等键撞 uk(并发重放,按契约返回原答卷)或 uuid 撞库(32 位随机,实际不可能)
if (isAnswerNoCollision(e)) {
if (remaining > 1) {
log.warn("[提交] 报名号并发撞号,重算序号重试(剩 {} 次)", remaining - 1);
return executeSubmit(user, req, idempotencyKey, remaining - 1);
}
throw new BizException(ErrorCode.CUSTOM, "报名号生成冲突,请稍后重试(同一表单并发提交过多)");
}
// 幂等键撞 uk(并发重放,按契约返回原答卷)或 uuid 撞库(32 位随机,实际不可能)
Answer replay = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null);
if (replay != null && belongsToCaller(replay, user)) {
log.info("[提交] 幂等键并发命中,返回原答卷 uuid={}", replay.getUuid());
@@ -102,6 +120,23 @@ public class AnswerService {
}
}
/**
* 撞的是不是报名号唯一键:MySQL 报 {@code Duplicate entry ... for key 'uk_answer_no'},
* H2 报 {@code Unique index ... ON ... (ANSWER_NO ...)}——两处消息都含列名 {@code answer_no},
* 而 answer 上另两个唯一键(uuid / idempotency_key)都不含它,按小写子串判定即可跨库。
*/
private static boolean isAnswerNoCollision(DataIntegrityViolationException e) {
Throwable cause = e;
while (cause != null) {
String message = cause.getMessage();
if (message != null && message.toLowerCase().contains("answer_no")) {
return true;
}
cause = cause.getCause();
}
return false;
}
private AnswerModels.SubmitView doSubmit(BoaUser user, AnswerModels.SubmitRequest req, String idempotencyKey) {
Long schId = requireSessionSchId(user);
Integer submitterId = user.getId();
@@ -133,6 +168,10 @@ public class AnswerService {
// 5) subject 授权(P95 行为表:谁能代填由第 0 关的层决定)+ 存在性/同校(P33)
Integer subjectId = requireSubject(user, tiers, req.getSubjectId(), submitterId, schId);
// Bug#3:自填(未传 subjectId 或传了本人)落库时把 subject 归一为本人。
// 否则 subject_id=NULL 的自填行永远进不了"关于我的"(/my/about 与 ABOUT_ME 谓词都按 subject 查),
// V18"关于我(含代填)"实际退化成"仅代填"。限次键本就是 coalesce(subject, submitter),归一不改它。
Integer storedSubjectId = subjectId != null ? subjectId : submitterId;
// 6) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属
String valuesJson = valuesToJson(req.getValues());
@@ -140,7 +179,7 @@ public class AnswerService {
assertAttachmentsOwned(schId, valuesJson, schemaJson);
// 7) 限次占位(uk 是并发兜底)
String quotaKey = occupyQuota(form, subjectId, submitterId);
String quotaKey = occupyQuota(form, storedSubjectId, submitterId);
// 8) 落答卷
Answer answer = new Answer();
@@ -155,7 +194,7 @@ public class AnswerService {
// T10 方案 §4③(D2=B):SCHOOL 范围的谓词列=操作人会话学校,与 sch_id 同值由校域不变式保证
// (P33 已把代填的 subject 钉在本校内);落值后不随改(updatable=false)
answer.setSubmitterSchId(schId);
answer.setSubjectId(subjectId);
answer.setSubjectId(storedSubjectId);
answer.setStatus(FormStatusDefs.defaultStatusCode(form.getStatusDefs()));
answer.setValuesJson(valuesJson);
answer.setAnswerNo(nextAnswerNo(form));
@@ -169,7 +208,7 @@ public class AnswerService {
log.info("[提交] answer={} form={}/{} v{} submitter={} subject={} quotaKey={}",
answer.getUuid(), answer.getAppCode(), answer.getCode(), answer.getVersion(),
submitterId, subjectId, quotaKey);
submitterId, storedSubjectId, quotaKey);
return toView(answer, false);
}
@@ -229,6 +229,47 @@ public class FormDefService {
return form;
}
/**
* 对外读接口的 <b>uuid 形态</b>解析(T11,v2 方案 §4.4:flow-center 在流程定义发布期按 uuid 拉契约快照)。
*
* <p>两条分支刻意<b>不同源</b>,因为它们回答的是两个问题:</p>
* <ul>
* <li><b>不给 version=入口口径</b>:与 {@link #resolvePublished} 同一套生命周期判定与文案
* (未发布/停用都拒、错误码 family 同契约 §6),并额外挡掉软删行——{@code resolvePublished}
* 的取域 SQL 带 {@code is_deleted=0},uuid 形态必须给出同一份可读集,否则"按 code 查不到、
* 按 uuid 查得到"就成了第二套口径(问题单 P107);</li>
* <li><b>给 version=溯源口径</b>:与 {@link #pinnedForData} 同——精确取行、<b>不校验生命周期</b>、
* 不过滤软删。停用/软删表单的历史版本核对是合法需求(方案 §5.4"停用只断填报入口")。</li>
* </ul>
*
* <p>域校验复用 {@link #readDomainForm}(本校 ∪ 平台级):他校 uuid 与不存在的 uuid 同一回答,
* 差别信息就是探测面(契约 §1)。</p>
*/
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);
}
/**
* 数据面读接口<b>入口把关</b>用的表单定位(T10 方案 §5.2):只按 uuid 取头表、不做域判定,
* 取不到返回 null。域与模块行的判断全在 {@code FormAdminGuard#requireDataView}——
@@ -39,6 +39,31 @@ public class OapiFormService {
private final FormDictService dictService;
private final AnswerRepository answerRepository;
/**
* {@code /oapi/form/schema} 的<b>入口分流</b>(T11):{@code uuid} 非空走按 uuid 取版本行的新路径,
* 否则原样交给 code 形态的 {@link #schema(Long, String, String)}——原实现一行没挪,
* 所以 code 查询路径的行为与响应逐字节不变(任务书 §0-2 红线)。
*
* <p><b>uuid 优先</b>:{@code uuid} 与 {@code code} 同传时只看 uuid,忽略 code 与 appCode
* (uuid 是权威坐标、code 是解析入口,两者同传说明调用方拿的是 flow-center 存的 uuid)。
* {@code version} 只在 uuid 形态有意义,单独传 version 不给 uuid 直接拒——静默忽略会让调用方
* 以为拿到的是那个版本(快照核对场景下这是最坏的错误)。</p>
*
* @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 形状)。
*
* <p><b>与 {@link #schema(Long, String, String)} 内的组装段逐行同形</b>:任务书 §0-2/W2-1 要求
* code 形态的原方法体不重构不挪动,故 uuid 形态自带一份而不是抽公共方法(问题单 P108)。
* 要改这里就同时改那边——{@code OapiFormServiceIT} 的 F1 断言两种形态的 groups 等价,是这份重复的漂移闸。</p>
*/
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),
@@ -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} 的<b>参数绑定</b>单测(T11 W1)。
*
* <p>服务层的分流与口径由 {@code OapiFormServiceIT} 的 F1~F7 断言;这一层只有 MockMvc 能证三件事:</p>
* <ul>
* <li><b>不传 code 不再 400</b>——{@code code} 由必填放宽为可选是 uuid 形态的前提(问题单 P106);</li>
* <li>{@code version} 绑成 {@code Integer}、缺省给 null 而不是 0(0 会被当成"要 v0 行");</li>
* <li>controller 只做透传、自己不做判断(uuid 优先与 version 单独传的拒绝规则都在服务层,F7 钉住)。</li>
* </ul>
*
* <p>用 {@code standaloneSetup} 而不是 {@code @SpringBootTest}:{@code /oapi/*} 的验签墙与统一响应链
* 是平台链({@code local-debug/t4-read-smoke.sh} 取证),本类只管参数形状,因此也不断言信封包装。</p>
*/
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;
}
}
@@ -105,7 +105,8 @@ class AdminScopeIT extends AnswerTestSupport {
Seed seed = seed();
BoaUser student = user(ReadFixture.STUDENT_1, SCH, 3);
assertThat(scopesOf(seed.ownUuid, student, SCH)).containsExactly(DataScope.ABOUT_ME);
assertThat(visible(seed.ownUuid, student, SCH)).isEmpty();
// Bug#3 起自填行落库即 subject=本人,该生自己那条现在就在"关于我"里(此前 subject 为空、不在)
assertThat(visible(seed.ownUuid, student, SCH)).containsExactly(seed.ownByStudent);
}
@Test
@@ -152,6 +152,36 @@ class AnswerConcurrencyIT extends AnswerTestSupport {
assertThat(rows.get(0).getIdempotencyKey()).isEqualTo(key);
}
// ==================== Bug#4:报名号并发重号 ====================
@Test
@DisplayName("Bug#4:开了报名号的表单并发双提交——两条都成功且拿到的报名号互不相同(uk_answer_no + 重算序号)")
void concurrentSubmitsNeverShareAnswerNo() throws Exception {
String code = "conc_answer_no";
createdCodes.add(code);
String uuid = publishForm(SCH_C, code, SCHEMA);
FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
config.setUuid(uuid);
config.setAnswerNoEnabled(1);
defService.updateConfig(SCH_C, config);
Outcome[] outcomes = runTwo(() -> {
try {
return new Outcome(answerService.submit(user(70003, SCH_C, 3), submit(code, VALUES))
.getAnswerId(), null);
} catch (BizException e) {
return new Outcome(null, e.getMessage());
}
});
// 修复前:两条都算到同一个序号,后落库的那条撞 uk 被外层当成"幂等键冲突"报错(提交失败)
assertThat(outcomes[0].answerId).as("并发提交不应因报名号撞号而失败,报错=" + outcomes[0].message).isNotNull();
assertThat(outcomes[1].answerId).as("并发提交不应因报名号撞号而失败,报错=" + outcomes[1].message).isNotNull();
List<Answer> rows = answers.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH_C, APP, code);
assertThat(rows).hasSize(2);
assertThat(rows).extracting(Answer::getAnswerNo).doesNotContainNull().doesNotHaveDuplicates();
}
// ==================== 双线程脚手架 ====================
/** 两个线程在同一时刻放开跑同一段逻辑 */
@@ -132,9 +132,9 @@ class AnswerReadIT extends AnswerTestSupport {
// 教师×面向学生:一期实际生效=仅本人提交(方案 §6.4 表格明示,防被当 bug 报)
assertThat(visibleIds(fixture, fixture.teacher1(), rows.formAUuid))
.containsExactly(rows.teacher1Row);
// 学生×面向学生=关于我:自填那条 subject 为空看不见,老师代填的看得见(契约 §6.3 两键分立的直接体现)
// 学生×面向学生=关于我:自填那条(Bug#3 起 subject 归一为本人)与老师代填那条都在"关于我"里
assertThat(visibleIds(fixture, fixture.student1(), rows.formAUuid))
.containsExactly(rows.proxyForStudent1);
.containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1);
// 局领导(5) 回落仅本人提交:他没填过 → 空集(不是全部)
assertThat(visibleIds(fixture, fixture.bureauLeader(), rows.formAUuid)).isEmpty();
}
@@ -245,8 +245,8 @@ class MyAnswerIT extends AnswerTestSupport {
assertThat(refillId).isNotEqualTo(answerId);
assertThat(idsOf(controller.list(student, code, null))).containsExactly(refillId);
assertThat(idsOf(controller.submitList(student))).containsExactly(refillId);
// 自填未指定被代填人(subject 为空)→"关于我的"按 subject 谓词查,这条不在那里(两谓词各归各,V18)
assertThat(idsOf(controller.aboutList(student))).isEmpty();
// Bug#3 起自填落库即 subject=本人 → "关于我的"含这条(V18"含代填"的另一半;两谓词仍各归各)
assertThat(idsOf(controller.aboutList(student))).containsExactly(refillId);
assertNotFoundShape(() -> controller.detail(student, answerId), answerId);
}
@@ -424,6 +424,38 @@ class MyAnswerIT extends AnswerTestSupport {
.isInstanceOf(BizException.class).hasMessageContaining("answerId 不能为空");
}
// ==================== Bug#2:编辑吃截止/停用闸 ====================
@Test
@DisplayName("Bug#2:过截止/停用后不能再改自己的答卷(与提交同口径,堵住\"先占位、截止后随便改\")")
void editObeysDeadlineAndDisabledGate() {
BoaUser student = plainUser(3);
// ① 截止:先正常提交,再把截止时间设成过去 → 编辑被拒(修复前可无限期改)
String code = "my_edit_deadline";
String uuid = publishForm(code, SCHEMA);
String answerId = answerService.submit(student, submit(code, VALUES)).getAnswerId();
FormModels.FormConfigRequest past = new FormModels.FormConfigRequest();
past.setDeadline("2020-01-01");
config(SCH, uuid, past);
assertThatThrownBy(() -> controller.edit(student, editBody(answerId, VALUES)))
.isInstanceOf(BizException.class).hasMessageContaining("已过收集截止时间");
// ② 停用:提交后停用 → 编辑被拒
String disabledCode = "my_edit_disabled";
String disabledUuid = publishForm(disabledCode, SCHEMA);
String disabledAnswerId = answerService.submit(student, submit(disabledCode, VALUES)).getAnswerId();
defService.disable(SCH, disabledUuid);
assertThatThrownBy(() -> controller.edit(student, editBody(disabledAnswerId, VALUES)))
.isInstanceOf(BizException.class).hasMessageContaining("表单已停用");
// ③ 对照:正常发布的表单编辑照常成功(闸只在异常态生效,不是把整条链收紧)
String okCode = "my_edit_ok";
publishForm(okCode, SCHEMA);
String okAnswerId = answerService.submit(student, submit(okCode, VALUES)).getAnswerId();
assertThat(controller.edit(student, editBody(okAnswerId, VALUES)).getAnswerId()).isEqualTo(okAnswerId);
}
// ==================== 小工具 ====================
/** 在指定归属应用下建并发布一张同 code 表单(快应用表单与中心表单同 code 共存) */
@@ -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;
*
* <p>信封 {@code code=0} 与验签墙是平台链({@code /oapi/*} 401 由 {@code local-debug/t4-read-smoke.sh}
* 启动冒烟取证),本类断言的是 data 载荷内容。</p>
*
* <p>T11(契约 §9 v20261019 增补)加了 F1~F7 一组 {@code uuid}/{@code version} 形态用例:
* <b>F8 就是上面原有那四个 code 形态用例——它们一字未改,全绿即"现行为逐字节不变"的证据</b>。</p>
*/
@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, "<uuid>")).isEqualTo(onMissing.replace(missingUuid, "<uuid>"));
// version 形态同样受域名约束:他校 uuid 带 version 也不给
assertThat(catchThrowable(() -> oapiFormService.schema(SCH, null, null, otherUuid, 1)).getMessage())
.startsWith("表单不存在:").endsWith(otherUuid);
}
@Test
@DisplayName("F5 uuid 不给 version 是入口口径:草稿/停用/软删一律拒,文案对齐 code 形态")
void uuidEntryPathRejectsNonPublishedForms() {
String draftUuid = createDraft("t11_draft");
assertThatThrownBy(() -> oapiFormService.schema(SCH, null, null, draftUuid, null))
.isInstanceOf(BizException.class).hasMessageContaining("表单尚未发布");
String disabledUuid = publishForm("t11_disabled", SCHEMA);
defService.disable(SCH, disabledUuid);
assertThatThrownBy(() -> oapiFormService.schema(SCH, null, null, disabledUuid, null))
.isInstanceOf(BizException.class).hasMessageContaining("表单已停用,不再接收填报");
// 软删:code 形态的取域 SQL 带 is_deleted=0,uuid 入口口径必须给同一份可读集(问题单 P107)
String deletedUuid = publishForm("t11_deleted", SCHEMA);
defService.softDelete(SCH, deletedUuid);
assertThatThrownBy(() -> oapiFormService.schema(SCH, null, null, deletedUuid, null))
.isInstanceOf(BizException.class).hasMessageContaining("表单不存在:" + deletedUuid);
}
@Test
@DisplayName("F6 uuid+version 指到不存在的行:PARAM 点名版本行,不误报成表单不存在")
void uuidWithMissingVersionRow() {
String formUuid = publishForm(CODE, SCHEMA);
assertThatThrownBy(() -> oapiFormService.schema(SCH, null, null, formUuid, 7))
.isInstanceOf(BizException.class)
.hasMessageContaining("版本行不存在:form-center/" + CODE + " v7");
}
@Test
@DisplayName("F7 uuid 优先:与 code 同传只看 uuid;只传 version 不传 uuid 直接拒")
void uuidWinsOverCode() {
String first = publishForm(CODE, SCHEMA);
String second = publishForm("t11_second", SCHEMA);
assertThat(oapiFormService.schema(SCH, null, "t11_second", first, null).getFormUuid()).isEqualTo(first);
// code 压根不存在也不影响:uuid 形态不消费 code/appCode
assertThat(oapiFormService.schema(SCH, "other-app", "no_such_code", second, null).getFormUuid())
.isEqualTo(second);
assertThatThrownBy(() -> oapiFormService.schema(SCH, null, CODE, null, 1))
.isInstanceOf(BizException.class).hasMessageContaining("version 需与 uuid 同传");
assertThatThrownBy(() -> oapiFormService.schema(null, null, null, first, null))
.isInstanceOf(BizException.class).hasMessageContaining("schId");
// 两把寻址钥匙都不给=回到 code 形态的空参守卫(现实现,不新增校验)
assertThatThrownBy(() -> oapiFormService.schema(SCH, null, null, null, null))
.isInstanceOf(BizException.class).hasMessageContaining("code 不能为空");
}
// ==================== 小工具 ====================
/** 只建不发布(status=DRAFT、current_version=null):入口口径的"尚未发布"分支要用 */
private String createDraft(String code) {
FormModels.FormCreateRequest create = new FormModels.FormCreateRequest();
create.setCode(code);
create.setName("测试 " + code);
String uuid = defService.create(SCH, create).getFormUuid();
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(uuid);
save.setSchemaJson(SCHEMA);
defService.save(SCH, save);
return uuid;
}
/** 建字典(grades:高二年级=g2 / 高三年级=g3)+ 表单 + 发布,返回表单 uuid */
@Override
protected String publishForm(String code, String schemaJson) {
@@ -14,8 +14,9 @@ import com.yunhai.platform.utils.model.BoaUser;
* <li>{@code advisor} 字段在 v1 schema 里存在但<b>不声明 searchable</b>,v2 才声明——
* V9(升版声明 searchable 后旧答卷可按新字段筛,靠发布时补建索引行)用的就是它;</li>
* <li>{@code cert} 是 upload 且被勾了 searchable——P28(无检索算子:不写索引行、不进筛选候选);</li>
* <li>学生自填的行 {@code subject_id} 为空、管理员代填的行 {@code subject_id}=学生——
* 契约 §8"学生=关于我"与"仅本人提交"两格的差异只有这种数据才测得出来;</li>
* <li>学生自填的行 {@code subject_id}=本人(Bug#3 起落库即归一,不再为 {@code NULL})、
* 管理员代填的行 {@code subject_id}=学生——契约 §8"学生=关于我"这一格含自填与代填两类
* ("仅本人提交"那一格看的是 {@code submitter_id});</li>
* <li>一条被软删的行——四条读出路径都必须看不见(V30 的读侧半边)。</li>
* </ul>
*/