From 7af6a98fc0b489a61953d43759f998e49dff34cb Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 16 Oct 2026 15:04:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(post-p95):=20=E8=B7=A8=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=BF=85=E6=8C=82/=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E4=B8=8D=E5=90=83=E6=88=AA=E6=AD=A2=E9=97=B8/=E8=87=AA?= =?UTF-8?q?=E5=A1=AB=20subject=20=E5=BD=92=E4=B8=80/=E6=8A=A5=E5=90=8D?= =?UTF-8?q?=E5=8F=B7=E5=B9=B6=E5=8F=91=E9=87=8D=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 新用例:并发报名号不重复、编辑闸三态)。 --- sql/t3_answer_domain.sql | 4 ++ .../com/yunhai/formcenter/entity/Answer.java | 7 ++- .../formcenter/service/AnswerReadService.java | 11 +++++ .../formcenter/service/AnswerService.java | 47 +++++++++++++++++-- .../formcenter/service/AdminScopeIT.java | 3 +- .../service/AnswerConcurrencyIT.java | 30 ++++++++++++ .../formcenter/service/AnswerReadIT.java | 4 +- .../yunhai/formcenter/service/MyAnswerIT.java | 36 +++++++++++++- .../formcenter/service/ReadFixture.java | 5 +- 9 files changed, 135 insertions(+), 12 deletions(-) diff --git a/sql/t3_answer_domain.sql b/sql/t3_answer_domain.sql index 2ef7435..9b383b2 100644 --- a/sql/t3_answer_domain.sql +++ b/sql/t3_answer_domain.sql @@ -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`), diff --git a/src/main/java/com/yunhai/formcenter/entity/Answer.java b/src/main/java/com/yunhai/formcenter/entity/Answer.java index 462b163..594492a 100644 --- a/src/main/java/com/yunhai/formcenter/entity/Answer.java +++ b/src/main/java/com/yunhai/formcenter/entity/Answer.java @@ -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"), diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java index 60accd4..73a1d4e 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java @@ -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(), diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerService.java b/src/main/java/com/yunhai/formcenter/service/AnswerService.java index 0b3d0e0..1c7578a 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerService.java @@ -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); } diff --git a/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java b/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java index 4914499..5edacd8 100644 --- a/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java @@ -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 diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerConcurrencyIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerConcurrencyIT.java index 9b38b61..608c217 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnswerConcurrencyIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AnswerConcurrencyIT.java @@ -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 rows = answers.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH_C, APP, code); + assertThat(rows).hasSize(2); + assertThat(rows).extracting(Answer::getAnswerNo).doesNotContainNull().doesNotHaveDuplicates(); + } + // ==================== 双线程脚手架 ==================== /** 两个线程在同一时刻放开跑同一段逻辑 */ diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java index f52fc80..1aa3bfc 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java @@ -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(); } diff --git a/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java b/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java index a341562..16f050d 100644 --- a/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java +++ b/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java @@ -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 共存) */ diff --git a/src/test/java/com/yunhai/formcenter/service/ReadFixture.java b/src/test/java/com/yunhai/formcenter/service/ReadFixture.java index 93b57d7..64defea 100644 --- a/src/test/java/com/yunhai/formcenter/service/ReadFixture.java +++ b/src/test/java/com/yunhai/formcenter/service/ReadFixture.java @@ -14,8 +14,9 @@ import com.yunhai.platform.utils.model.BoaUser; *
  • {@code advisor} 字段在 v1 schema 里存在但不声明 searchable,v2 才声明—— * V9(升版声明 searchable 后旧答卷可按新字段筛,靠发布时补建索引行)用的就是它;
  • *
  • {@code cert} 是 upload 且被勾了 searchable——P28(无检索算子:不写索引行、不进筛选候选);
  • - *
  • 学生自填的行 {@code subject_id} 为空、管理员代填的行 {@code subject_id}=学生—— - * 契约 §8"学生=关于我"与"仅本人提交"两格的差异只有这种数据才测得出来;
  • + *
  • 学生自填的行 {@code subject_id}=本人(Bug#3 起落库即归一,不再为 {@code NULL})、 + * 管理员代填的行 {@code subject_id}=学生——契约 §8"学生=关于我"这一格含自填与代填两类 + * ("仅本人提交"那一格看的是 {@code submitter_id});
  • *
  • 一条被软删的行——四条读出路径都必须看不见(V30 的读侧半边)。
  • * */