From 3dac43dcfc60c2504d28adcb9deb8a2c7d72295f Mon Sep 17 00:00:00 2001
From: chenxing 没有一件事在这个类里放宽:值校验、附件归属、钉版、报名号、索引全部由
+ * {@link AnswerService#submitAnon} 进同一条管线;防探测文案与"表单不存在"同一条(C6/V54);
+ * 上传的大小上限、扩展名黑名单、随机 key 一样不少。这个类只负责三件 HTTP 侧的事——
+ * 取留痕 IP/UA、判防重窗口、下发 cookie。 顺序:先解析表单拿防重窗口 → 判 cookie → 进管线(管线内幂等命中优先于防重命中,
+ * 撞窗口才 CUSTOM(5))→ 真提交成功才重新签发 cookie(重放不续窗口)。 这个值是可伪造的:契约把 IP/UA 定成"留痕"(C8/C12)而不是身份,防重也不依赖它
+ * (依赖的是服务端签发的 cookie)。真要把 IP 当鉴权依据,得先在反代侧配置可信跳数——
+ * 二期若需要,改的是这一处而不是判据。 为什么路径族在 {@code /api} 之外:CAS starter 的 {@code yh.sso} 只有 5 个键、
+ * 没有任何 exclude/豁免位,其 6 个 Filter 只挂 {@code /api/*}(任务书 A11 取证)。
+ * 于是"匿名面挂在 {@code /api/form/anonymous/}"这条直觉写法不可行(契约 C6/P-A)——
+ * 放在 {@code /api} 下就必然进墙,而墙内未登录会话的响应是 302 引导不是数据。
+ * {@code /anon/**} 因此天然无墙,代价是部署侧要配套加反代/代理条目
+ * (生产 Caddy {@code /form-center/anon/*}、dev vite {@code /anon}),这是 T15 的残余轻运维项而不是代码缺口。 本包的两条纪律:① 方法签名里不出现 {@code BoaUser}(没有会话,任何身份注入都被禁止,
+ * 契约 §7 身份行的匿名例外就是"请求不携带任何身份字段");② 只读表单,不读任何按租户取的清单——
+ * 匿名填报人对 {@code /my}、数据面、oapi 一律无能力(C8/§2.1)。 为什么单独一个模型而不是复用 {@link AnswerModels.SubmitRequest}:那个 DTO 里有
+ * {@code subjectId}。匿名请求的字段闭集(§3.3)不含任何身份项——把 DTO 分开,"服务端不读
+ * schId/userId/subjectId"就不是一条纪律而是一条编译期事实(V17 的匿名分支,契约 §7 身份行)。
+ * GET /anon/form/resolve?code=[&appCode=] → 与登录面同构的钉版 + 内联 options
+ * POST /anon/form/submit → 同一条提交管线(V60),身份由表单归属推导
+ * POST /anon/form/upload?code=[&appCode=] → 同登录面五条硬约束,落档租户=表单归属校
+ *
+ *
+ *
两条通道、一条管线(§3.2/V60):{@link #submit} 带 CAS 会话,{@link #submitAnon} 带 + * {@link AnonModels.Context}(IP/UA/防重窗口剩余秒数)。差别全部收在 {@link SubmitIdentity} 里—— + * 租户域、提交人、人群门方向、限次适用性、幂等归属五处各按分支取值,值校验/附件/钉版/报名号/索引 + * 一段代码共用。匿名面没有第二份写库实现,这是 V56/V57/V60 三条红线的同一个落点。
+ * *事务形态:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。 * 目的是让"幂等键撞 uk"这种并发结局能在事务外重新读取原答卷并按契约返回 200+原 answer_id * (撞键后当前事务已被标记回滚,事务内再读是不可靠的)。
@@ -74,6 +80,8 @@ public class AnswerService { /** 报名号并发撞号后的重试上限(窗口极窄:同表单同日同刻提交且序号算到同一个数) */ private static final int ANSWER_NO_RETRY = 3; private static final String DEFAULT_APP_CODE = Form.CENTER_APP_CODE; + /** 匿名提交人哨兵值(契约 v2 C8:库内不自增、不指向任何 UserBase 用户) */ + private static final Integer ANONYMOUS_SUBMITTER_ID = 0; private final FormDefService defService; private final FormDictService dictService; @@ -92,28 +100,47 @@ 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); + return executeSubmit(SubmitIdentity.ofSession(user), req, idempotencyKey, ANSWER_NO_RETRY); + } + + /** + * 匿名提交(契约 v2 §3.2,T15):与 {@link #submit} 同一条管线(V60),差别只在身份—— + * 租户域取表单归属校而不是会话校(C8/V57)、{@code submitter_id=0}、{@code subject_id=NULL}、 + * 人群门在解析阶段就以"audience 必须含 ANONYMOUS"的形式判过、限次不适用(作废-11), + * 另加 IP/UA 两列留痕。请求体里没有 subjectId/appCode 之外的任何身份字段。 + * + * @param ctx 服务端从 HTTP 请求取的留痕与防重判定结果(请求体无法影响它) + */ + public AnswerModels.SubmitView submitAnon(AnonModels.SubmitRequest req, AnonModels.Context ctx) { + String idempotencyKey = requireIdempotencyKey(req.getIdempotencyKey()); + AnswerModels.SubmitRequest internal = new AnswerModels.SubmitRequest(); + internal.setFormCode(req.getFormCode()); + internal.setAppCode(req.getAppCode()); + internal.setIdempotencyKey(idempotencyKey); + internal.setValues(req.getValues()); + // subjectId 不填:匿名请求的字段闭集里没有它,两个 DTO 分开写就是为了让它无从出现(§3.3) + return executeSubmit(SubmitIdentity.ofAnon(ctx), internal, idempotencyKey, ANSWER_NO_RETRY); } /** * 提交事务的一次尝试。报名号是"当日已有号数+1"(count 与 insert 之间有并发窗口), * 撞 {@code uk_answer_no} 时重算序号重建事务——重新提交时对手那条已提交,count 已把它算进去。 */ - private AnswerModels.SubmitView executeSubmit(BoaUser user, AnswerModels.SubmitRequest req, + private AnswerModels.SubmitView executeSubmit(SubmitIdentity identity, AnswerModels.SubmitRequest req, String idempotencyKey, int remaining) { try { - return transactionTemplate.execute(tx -> doSubmit(user, req, idempotencyKey)); + return transactionTemplate.execute(tx -> doSubmit(identity, req, idempotencyKey)); } catch (DataIntegrityViolationException e) { if (isAnswerNoCollision(e)) { if (remaining > 1) { log.warn("[提交] 报名号并发撞号,重算序号重试(剩 {} 次)", remaining - 1); - return executeSubmit(user, req, idempotencyKey, remaining - 1); + return executeSubmit(identity, 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)) { + if (replay != null && identity.ownsReplay(replay)) { log.info("[提交] 幂等键并发命中,返回原答卷 uuid={}", replay.getUuid()); return toView(replay, true); } @@ -138,19 +165,32 @@ public class AnswerService { return false; } - private AnswerModels.SubmitView doSubmit(BoaUser user, AnswerModels.SubmitRequest req, String idempotencyKey) { - Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属,不能提交答卷"); - Integer submitterId = user.getId(); + private AnswerModels.SubmitView doSubmit(SubmitIdentity identity, AnswerModels.SubmitRequest req, + String idempotencyKey) { + BoaUser user = identity.user; + // 租户域(V57):登录面=会话校,无学校归属直接拒;匿名面=表单归属校,无会话可取, + // 因此必须等钉版解析出表单以后才有值(C8 把"恒=会话校"改成了两条分支) + Long sessionSchId = identity.anonymous() + ? null : FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属,不能提交答卷"); String appCode = req.getAppCode() == null || req.getAppCode().trim().isEmpty() ? DEFAULT_APP_CODE : req.getAppCode().trim(); - // 1) 钉版:与填报页 resolve 同一条遮蔽规则(P20——升版窗口旧版仍可填,天然按旧版落地) - FormDefService.Published published = defService.resolvePublished(schId, appCode, req.getFormCode()); + // 1) 钉版:与填报页 resolve 同一条遮蔽规则(P20——升版窗口旧版仍可填,天然按旧版落地)。 + // 两条通道各用自己的解析入口,但共用 publishedOf 的生命周期判定与版本行取数(V56) + FormDefService.Published published = identity.anonymous() + ? defService.resolvePublishedForAnon(appCode, req.getFormCode()) + : defService.resolvePublished(sessionSchId, appCode, req.getFormCode()); Form form = published.form(); String schemaJson = published.versionRow().getSchemaJson(); + Long schId = identity.anonymous() ? form.getSchId() : sessionSchId; + Integer submitterId = identity.submitterId(); // 2) audience 门(契约 v2 §3.1):钉版之后才有 audience 可判,截止之前先鉴权。 - // 只对自填生效(P115 裁决:§3.4 与 §5.1/V73 冲突点按"代录看 ALL 档、不看操作者人群"收敛) - requireAudienceGate(user, form, req.getSubjectId(), submitterId); + // 只对自填生效(P116 裁决:§3.4 与 §5.1/V73 冲突点按"代录看 ALL 档、不看操作者人群"收敛)。 + // 匿名通道不进这里:解析阶段已把"audience 含 ANONYMOUS"作为寻址条件(§3.2), + // 而会话身份集合里永远没有 ANONYMOUS(C1)——两条通道对同一个集合用两个方向判,不会互相放行 + if (!identity.anonymous()) { + requireAudienceGate(user, form, req.getSubjectId(), submitterId); + } // 3) 截止(datetime 粒度,过期接口拒绝,契约 §5) if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) { @@ -160,28 +200,39 @@ public class AnswerService { // 4) 幂等命中→200+原 answer_id(在限次之前,见类注释) Answer existing = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null); if (existing != null) { - if (!belongsToCaller(existing, user)) { - // 不泄露他人答卷身份:键被他人占用直接判越权,不返回其 answer - throw new BizException(ErrorCode.FORBIDDEN, "幂等键已被其它账号占用"); + if (!identity.ownsReplay(existing)) { + throw replayConflict(identity); } return toView(existing, true); } + // 4.5) 匿名防重窗口(C7):命中=CUSTOM(5),文案给剩余分钟。 + // 位置在幂等命中之后:双击重放落在同一张答卷上,按契约必须是 200+原 answerId, + // 不能被节流阀报成"提交太频繁";又必须在任何写库动作之前,否则窗口内会留下名额/号段消耗 + if (identity.anonymous() && identity.anon().getThrottleRemainingSeconds() > 0) { + throw new BizException(ErrorCode.CUSTOM, "该表单 " + AnonReplayGuard.windowMinutes(form.getAnonCookieMinutes()) + + " 分钟内只能提交一次,请约 " + AnonReplayGuard.remainingMinutes( + identity.anon().getThrottleRemainingSeconds()) + " 分钟后再提交:" + form.getCode()); + } + // 5) subject 指定权(契约 v2 §3.1:=该表单 ALL 档持有者,原 canManage 代填路径收敛到此) // + 存在性/同校(P33) Integer subjectId = requireSubject(user, form, 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; + // 匿名分支反过来必须留 NULL(C8):submitter_id=0 不是任何人,归一过去会造出一个指向 0 号用户的"关于我的"行 + Integer storedSubjectId = identity.anonymous() ? null : (subjectId != null ? subjectId : submitterId); // 6) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属 + // 放行集与附件归属都按 schId 判,匿名面的 schId=表单归属校,与 §3.2 的"同登录面同构校验"一致 String valuesJson = valuesToJson(req.getValues()); FormValueValidator.validate(valuesJson, schemaJson, dictCode -> dictService.allowedValues(schId, dictCode)); assertAttachmentsOwned(schId, valuesJson, schemaJson); - // 7) 限次占位(uk 是并发兜底) - String quotaKey = occupyQuota(form, storedSubjectId, submitterId); + // 7) 限次占位(uk 是并发兜底)。匿名不适用(作废-11:无身份无 limit_key, + // 配置通道已在 C1 处禁止"含 ANONYMOUS 且 fill_limit=1",这里不再占位) + String quotaKey = identity.anonymous() ? null : occupyQuota(form, storedSubjectId, submitterId); // 8) 落答卷 Answer answer = new Answer(); @@ -194,13 +245,20 @@ public class AnswerService { answer.setVersion(published.versionRow().getVersion()); answer.setSubmitterId(submitterId); // T10 方案 §4③(D2=B):SCHOOL 范围的谓词列=操作人会话学校,与 sch_id 同值由校域不变式保证 - // (P33 已把代填的 subject 钉在本校内);落值后不随改(updatable=false) + // (P33 已把代填的 subject 钉在本校内);落值后不随改(updatable=false)。 + // 匿名分支同值=表单归属校,于是"校级匿名表单的匿名答卷落校管理员 ALL 档、 + // 平台级匿名表单的匿名答卷只有创建人(超管)看得见"两条推论自动成立(§2.3/C8) answer.setSubmitterSchId(schId); answer.setSubjectId(storedSubjectId); answer.setStatus(FormStatusDefs.defaultStatusCode(form.getStatusDefs())); answer.setValuesJson(valuesJson); answer.setAnswerNo(nextAnswerNo(form)); answer.setIdempotencyKey(idempotencyKey); + if (identity.anonymous()) { + // 留痕进独立列,不进 values_json(C8/C12:值文档是业务数据,IP/UA 是审计数据) + answer.setAnonIp(identity.anon().getAnonIp()); + answer.setAnonUa(identity.anon().getAnonUa()); + } // saveAndFlush:让 uk 撞键在本方法内暴露(外层据 DataIntegrityViolationException 判定并发重放); // 若这一步失败,同事务内的 fill_quota 占位一起回滚,不残留"名额被占但无答卷"的脏状态 answer = answerRepository.saveAndFlush(answer); @@ -208,9 +266,9 @@ public class AnswerService { // 9) 索引:只写 searchable 字段(V3/V9) indexService.write(answer, schemaJson); - log.info("[提交] answer={} form={}/{} v{} submitter={} subject={} quotaKey={}", - answer.getUuid(), answer.getAppCode(), answer.getCode(), answer.getVersion(), - submitterId, storedSubjectId, quotaKey); + log.info("[提交]{} answer={} form={}/{} v{} submitter={} subject={} quotaKey={}", + identity.anonymous() ? "[匿名]" : "", answer.getUuid(), answer.getAppCode(), answer.getCode(), + answer.getVersion(), submitterId, storedSubjectId, quotaKey); return toView(answer, false); } @@ -219,7 +277,7 @@ public class AnswerService { /** * audience 门(契约 v2 §3.1):自填资格=会话身份类型 ∈ 表单面向人群(∩ 状态门在后面几关)。 * - *代录不吃这道门(问题单 P115,用户 2026-10-20 裁决):§3.4 写"audience 门按代录 + *
代录不吃这道门(问题单 P116,用户 2026-10-20 裁决):§3.4 写"audience 门按代录 * 操作者会话判定",而 §5.1/V73 又要求管理员视图能"添加(代录)"、批量导入走同一管线—— * 校管理员(roleId 2/7)本身不在学生或教师人群里,按字面判会让代录对管理员整体失效。 * 两条承诺只能留一条,留的是功能可用那条:{@code subject} 指向他人时,操作者的身份 @@ -362,11 +420,13 @@ public class AnswerService { return clean; } - private boolean belongsToCaller(Answer answer, BoaUser user) { - // 会话校 + 提交人共同界定"这条幂等记录是不是你的"(uk 是库级的,跨账号命中不能放行) - Long sessionSch = FormAccessPolicy.sessionSchId(user); - return answer.getSubmitterId() != null && answer.getSubmitterId().equals(user.getId()) - && sessionSch != null && answer.getSchId() != null && answer.getSchId().equals(sessionSch); + /** 幂等键被别的行占着时的回答(两条通道都不泄露对方那条答卷的任何信息,契约 §6 错误码族) */ + private static BizException replayConflict(SubmitIdentity identity) { + return identity.anonymous() + // 匿名请求撞到的若是登录答卷,键空间相同纯属偶然;给冲突而不是"已被他人占用" + ? new BizException(ErrorCode.PARAM_ERROR, "提交冲突,请稍后重试(幂等键可能已被占用)") + // 不泄露他人答卷身份:键被他人占用直接判越权,不返回其 answer + : new BizException(ErrorCode.FORBIDDEN, "幂等键已被其它账号占用"); } private static String newUuid() { @@ -380,4 +440,61 @@ public class AnswerService { view.setReplayed(replayed); return view; } + + /** + * 提交身份(契约 v2 §3.1/§3.2 两条通道的唯一差别)。 + * + *
管线里每一个"谁能填/替谁填/落进哪个租户/这条幂等记录算不算你的"判定都从这里取事实, + * 于是匿名通道不需要第二份提交实现(V60 采集管线唯一):值校验/附件/钉版/报名号/索引一段代码 + * 共用。新通道(如二期 oapi 代提交)也只需要再加一个工厂方法。
+ * + *通道是入口显式声明的,不从 {@code user==null} 反推(V58 身份推断单点的同一件事): + * 反推会让"登录面漏传会话"这种调用错误静默变成"走匿名分支写一条匿名答卷"——既绕过人群门, + * 又把数据落到表单归属校。现在 {@code submit(null, …)} 照旧在 {@code requireSessionSchId} + * 处报"未登录"({@code AnswerSubmitIT.sessionRequired} 钉住)。
+ */ + private static final class SubmitIdentity { + + private final BoaUser user; + private final AnonModels.Context anon; + private final boolean anonymous; + + private SubmitIdentity(BoaUser user, AnonModels.Context anon, boolean anonymous) { + this.user = user; + this.anon = anon; + this.anonymous = anonymous; + } + + static SubmitIdentity ofSession(BoaUser user) { + return new SubmitIdentity(user, null, false); + } + + static SubmitIdentity ofAnon(AnonModels.Context anon) { + return new SubmitIdentity(null, anon, true); + } + + boolean anonymous() { + return anonymous; + } + + AnonModels.Context anon() { + return anon; + } + + /** 提交人:会话用户 id,或匿名哨兵 0(C8,库内不自增、不指向任何 UserBase 用户) */ + Integer submitterId() { + return anonymous() ? ANONYMOUS_SUBMITTER_ID : user.getId(); + } + + /** 幂等命中的归属判定(C7:匿名"无身份可比对",只额外要求命中行本身是匿名行) */ + boolean ownsReplay(Answer answer) { + if (anonymous()) { + return ANONYMOUS_SUBMITTER_ID.equals(answer.getSubmitterId()); + } + // 会话校 + 提交人共同界定"这条幂等记录是不是你的"(uk 是库级的,跨账号命中不能放行) + Long sessionSch = FormAccessPolicy.sessionSchId(user); + return answer.getSubmitterId() != null && answer.getSubmitterId().equals(user.getId()) + && sessionSch != null && answer.getSchId() != null && answer.getSchId().equals(sessionSch); + } + } } diff --git a/src/main/java/com/yunhai/formcenter/service/FormDefService.java b/src/main/java/com/yunhai/formcenter/service/FormDefService.java index 140df4a..ebaf3a4 100644 --- a/src/main/java/com/yunhai/formcenter/service/FormDefService.java +++ b/src/main/java/com/yunhai/formcenter/service/FormDefService.java @@ -142,15 +142,69 @@ public class FormDefService { */ public Published resolvePublished(Long schId, String appCode, String code) { String cleanApp = normalizeAppCode(appCode); - String cleanCode = code == null ? null : code.trim(); - if (cleanCode == null || cleanCode.isEmpty()) { - throw new BizException(ErrorCode.PARAM_ERROR, "code 不能为空"); - } + String cleanCode = requireResolveCode(code); Form form = pickByShadowing( formRepository.findResolveCandidates(cleanCode, cleanApp, resolveDomain(schId)), schId); if (form == null) { throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + cleanApp + "/" + cleanCode); } + return publishedOf(form, cleanCode); + } + + /** + * 匿名面的同构解析(契约 v2 §3.2/C6,T15):按 {@code (app_code, code)} 在全域取 + * 已发布未删、且 {@code audience} 含 {@code ANONYMOUS} 的表单。 + * + *三条口径与登录面有意不同,都是为了匿名面不泄露任何存在性信息:
+ *取方法名而不是 {@code upload(Long, file)} 重载:任何传 null 的调用点 + * (IT 里"未登录不能上传"那条断言)在重载下会变成歧义解析。
+ * + *五条硬约束(大小/黑名单/随机 key/存储根三层占位/返回值三件)一条都不在这里分叉, + * 匿名面没有任何放宽面(V24/V29 不变)。
+ */ + @Transactional + public AnswerModels.FileView uploadForTenant(Long schId, MultipartFile file) { if (file == null || file.isEmpty()) { throw new BizException(ErrorCode.PARAM_ERROR, "上传文件为空"); } @@ -103,7 +119,6 @@ public class FormFileService { throw new BizException(ErrorCode.PARAM_ERROR, "单文件不得超过 50MB(当前 " + (file.getSize() / 1024 / 1024) + "MB)"); } - Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属,不能上传附件"); String original = sanitizeName(file.getOriginalFilename()); String ext = requireSafeExt(original); String key = "form/" + LocalDate.now().format(YEAR) + "/" + LocalDate.now().format(MONTH) diff --git a/src/test/java/com/yunhai/formcenter/service/AnonFillIT.java b/src/test/java/com/yunhai/formcenter/service/AnonFillIT.java new file mode 100644 index 0000000..9807780 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/AnonFillIT.java @@ -0,0 +1,316 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.common.FormSchemaValidator; +import com.yunhai.formcenter.controller.anon.AnonFormController; +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.model.AnonModels; +import com.yunhai.formcenter.model.AnswerModels; +import com.yunhai.formcenter.model.FormModels; +import com.yunhai.formcenter.repository.FormFileRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockCookie; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +/** + * 匿名通道(契约 v2 §3.2/C6/C7/C8,验收 V67,T15)。 + * + *打真库、用 {@link AnswerTestSupport} 的建表/配置脚手架,但从控制器入口进——匿名面的语义 + * 就是"一个没有会话的 HTTP 请求",只测服务分会把三件真正要钉住的事漏掉:cookie 有没有下发、 + * Path 是不是上下文路径、留痕是不是从请求头取的。
+ * + *与登录面共用的环节不重复断言(值校验/限次/钉版各有 IT),这里只钉匿名分支的六个口径: + * 防探测同文案、落库形态(submitter=0 / subject=NULL / sch_id=表单校 / IP·UA 落独立列)、 + * 窗口内二发=CUSTOM(5) 含剩余分钟、伪造 cookie 既不延长也不绕过、幂等命中优先于防重(200+原 answerId)、 + * 上传落档租户=表单归属校。
+ */ +@SpringBootTest +@Import(AnswerTestSupport.TestGatewayConfiguration.class) +@Transactional +@DisplayName("匿名通道 - /anon/form 三端点与 cookie 防重(V67)") +class AnonFillIT extends AnswerTestSupport { + + private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":[" + + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true}," + + "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true," + + "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高三\",\"value\":\"g3\"}]}]}]}"; + + private static final String VALUES = "{\"name\":\"匿名者\",\"grade\":\"g2\"}"; + + private static final String NOT_FOUND = "表单不存在"; + + @Autowired + private AnonFormController controller; + + @Autowired + private FormFileRepository fileRepository; + + // ==================== resolve:防探测 ==================== + + @Test + @DisplayName("V66/C6:匿名 resolve 只认 audience 含 ANONYMOUS 的表单;非匿名与不存在同一条\"表单不存在\"文案") + void resolveOnlyAdmitsAnonymousForms() { + String anonCode = "v67_anon_resolve"; + String anonUuid = publishForm(anonCode, SCHEMA); + config(SCH, anonUuid, audience(Collections.singletonList("ANONYMOUS"))); + + FormModels.ResolvedFormView view = controller.resolve(anonCode, null); + assertThat(view.getCode()).isEqualTo(anonCode); + assertThat(view.getFormUuid()).isEqualTo(anonUuid); + assertThat(view.getVersion()).isNotNull(); + assertThat(view.getAudience()).containsExactly("ANONYMOUS"); + // 出参与登录面同一个组装实现:options 已内联(字典不进对外契约,契约 §2.1) + assertThat(view.getGroups().toString()).contains("高二"); + + String studentCode = "v67_student_resolve"; + publishForm(studentCode, SCHEMA); + BizException notAnonymous = catchThrowableOfType(() -> controller.resolve(studentCode, null), BizException.class); + assertThat(notAnonymous.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR); + assertThat(notAnonymous.getMessage()).contains(NOT_FOUND).contains(studentCode); + + // 与"不存在"同文案:没配过匿名人群的表单与压根不存在的 code 得到同一句话(V54 的匿名半句) + BizException missing = catchThrowableOfType(() -> controller.resolve("v67_never_exists", null), BizException.class); + assertThat(missing.getMessage()).isEqualTo(notAnonymous.getMessage().replace(studentCode, "v67_never_exists")); + } + + // ==================== submit:落库形态(C8/V57) ==================== + + @Test + @DisplayName("V67/C8:匿名提交落库 submitter_id=0、subject_id=NULL、sch_id=表单校、IP/UA 落独立列,并下发防重 cookie") + void submitLandsAnonymousShape() { + String code = "v67_anon_shape"; + String uuid = publishForm(code, SCHEMA); + config(SCH, uuid, audience(Collections.singletonList("ANONYMOUS"))); + + MockHttpServletResponse response = new MockHttpServletResponse(); + AnswerModels.SubmitView view = anonSubmit(code, VALUES, null, null, response); + + List要钉住的是三条容易写歪的口径:① 非法/伪造/跨表单的值一律按"没带 cookie"处理 + * (放行,既不能延长窗口也不能绕过窗口的特权),而不是报错;② 窗口按表单配置取, + * 脏配置(null/0/负数)回落默认 10 而不是关掉防重;③ 密钥缺失时整体不可用而不是静默放行。
+ */ +class AnonReplayGuardTest { + + private static final String SECRET = "unit-test-secret"; + private static final String CODE = "school_survey"; + + private final AnonReplayGuard guard = new AnonReplayGuard(SECRET); + + @Test + @DisplayName("票据名按表单隔离:前缀 fc_anon_ + code(一表单一份,互不干扰)") + void cookieNameIsPerForm() { + assertThat(AnonReplayGuard.cookieName(CODE)).isEqualTo("fc_anon_school_survey"); + assertThat(AnonReplayGuard.COOKIE_PREFIX).isEqualTo("fc_anon_"); + } + + @Test + @DisplayName("刚签发的票据在窗口内=命中,剩余秒数接近整窗;缺省窗口 10 分钟") + void freshTicketThrottlesForTheWholeWindow() { + long remaining = guard.throttleSeconds(CODE, null, guard.issue(CODE)); + assertThat(remaining).isBetween(595L, 600L); + assertThat(AnonReplayGuard.windowMinutes(null)).isEqualTo(AnonReplayGuard.DEFAULT_WINDOW_MINUTES); + assertThat(AnonReplayGuard.remainingMinutes(remaining)).isEqualTo(10); + } + + @Test + @DisplayName("窗口分钟数:null/0/负数一律回落 10,配 30 就用 30(脏配置不等于关掉防重)") + void windowMinutesNeverDisablesTheThrottle() { + assertThat(AnonReplayGuard.windowMinutes(0)).isEqualTo(10); + assertThat(AnonReplayGuard.windowMinutes(-5)).isEqualTo(10); + assertThat(AnonReplayGuard.windowMinutes(30)).isEqualTo(30); + // 1 分钟窗口下,30 秒前签发的票据还剩约 30 秒,文案向上取整至少 1 分钟 + long remaining = 30L; + assertThat(AnonReplayGuard.remainingMinutes(remaining)).isEqualTo(1); + assertThat(guard.throttleSeconds(CODE, 1, guard.issuedAt(CODE, System.currentTimeMillis() / 1000L - 30))) + .isBetween(29L, 31L); + } + + @Test + @DisplayName("窗口已过=放行;未来时间戳按\"刚提交过\"处理(窗口不拉长,也不反过来把旧票据顶成刚刚)") + void expiredTicketPassesButFutureOneThrottles() { + long now = System.currentTimeMillis() / 1000L; + assertThat(guard.throttleSeconds(CODE, 10, guard.issuedAt(CODE, now - 601))).isZero(); + assertThat(guard.throttleSeconds(CODE, 10, guard.issuedAt(CODE, now - 300))).isBetween(299L, 301L); + assertThat(guard.throttleSeconds(CODE, 10, guard.issuedAt(CODE, now + 120))).isBetween(595L, 600L); + } + + @Test + @DisplayName("伪造无效:换密钥签的值、改过时间戳的值、别的表单的值、形态非法的值,全部按没带 cookie 处理") + void forgedOrMalformedValueIsTreatedAsAbsent() { + long now = System.currentTimeMillis() / 1000L; + String valid = guard.issue(CODE); + + // 换密钥(攻击者不知道服务端密钥时能造出的最强形态) + assertThat(guard.throttleSeconds(CODE, 10, new AnonReplayGuard("other-secret").issue(CODE))).isZero(); + // 篡改时间戳(保留原签名往后推=想延长窗口) + String tampered = (now + 3600) + valid.substring(valid.indexOf('.')); + assertThat(guard.throttleSeconds(CODE, 10, tampered)).isZero(); + // 别的表单的合法票据挪过来 + assertThat(guard.throttleSeconds("other_form", 10, valid)).isZero(); + // 形态非法:不给异常,只当没带 + assertThat(guard.throttleSeconds(CODE, 10, null)).isZero(); + assertThat(guard.throttleSeconds(CODE, 10, " ")).isZero(); + assertThat(guard.throttleSeconds(CODE, 10, "not-a-ticket")).isZero(); + assertThat(guard.throttleSeconds(CODE, 10, "1.2.3")).isZero(); + } + + @Test + @DisplayName("密钥缺失=匿名通道不可用:签发与判定都拒绝,而不是静默放行(防重是唯一的节流)") + void missingSecretFailsClosed() { + AnonReplayGuard unconfigured = new AnonReplayGuard(" "); + assertThatThrownBy(() -> unconfigured.issue(CODE)) + .isInstanceOf(BizException.class).hasMessageContaining("防重密钥未配置"); + assertThatThrownBy(() -> unconfigured.throttleSeconds(CODE, 10, "1760000000.deadbeef")) + .isInstanceOf(BizException.class).hasMessageContaining("防重密钥未配置"); + // 没带 cookie 时不需要密钥就能判"放行",这一条不受配置缺失影响 + assertThat(unconfigured.throttleSeconds(CODE, 10, null)).isZero(); + } + + @Test + @DisplayName("下发形态:Path=上下文路径(生产 /form-center)、Max-Age=窗口秒、HttpOnly、SameSite=Lax") + void cookieHeaderCarriesContextPathAndWindow() { + // 逐属性断言而不是整串等值:MockHttpServletResponse 会解析 Set-Cookie 并补一个 Expires, + // 真容器的头里没有它 + MockHttpServletResponse response = new MockHttpServletResponse(); + guard.appendCookie(response, CODE, "1760000000.abc", 30, "/form-center"); + assertThat(response.getHeader("Set-Cookie")) + .startsWith("fc_anon_school_survey=1760000000.abc;") + .contains("Path=/form-center").contains("Max-Age=1800").contains("HttpOnly").contains("SameSite=Lax"); + + MockHttpServletResponse local = new MockHttpServletResponse(); + guard.appendCookie(local, CODE, "1760000000.abc", null, ""); + assertThat(local.getHeader("Set-Cookie")).contains("Path=/;").contains("Max-Age=600"); + } +}