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 新用例:并发报名号不重复、编辑闸三态)。
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user