t15:匿名通道落地——/anon/form 三端点 + HMAC cookie 防重 + 提交管线分通道

- 新 controller/anon/AnonFormController(RequestMapping /anon/form,不带 /api:CAS starter
  的 6 个 Filter 只挂 /api/* 且无豁免位,A11):resolve/submit/upload 三端点,签名里不出现 BoaUser。
- FormDefService 抽出 publishedOf 共用生命周期判定,新增 resolvePublishedForAnon/resolveForAnon:
  全域取"已发布未删 + audience 含 ANONYMOUS",非匿名/草稿/停用/不存在/跨校歧义同一句"表单不存在"
  (C6/V54 防探测;歧义 fail-closed 见 P119);出参复用登录面同一个组装实现。
- AnswerService 提交身份收成 SubmitIdentity(ofSession/ofAnon 两个工厂,通道由入口显式声明而不是
  从 user==null 反推):匿名面 sch_id/submitter_sch_id=表单归属校、submitter_id=0、subject_id 留 NULL、
  不进人群门也不占 fill_quota,值校验/附件/钉版/报名号/索引一段代码共用(V56/V57/V60)。
- 新 AnonReplayGuard:cookie 值=服务端 HMAC 时间戳(密钥复用 yh.app-auth.app-secret,零新增配置项、
  零服务端会话状态),窗口按 form.anon_cookie_minutes;伪造/篡改/跨表单按"没带 cookie"放行,
  命中=CUSTOM(5) 文案含剩余分钟;只在真提交成功时签发,幂等重放优先于节流且不续窗口(C7)。
- AnonModels 分 DTO:匿名请求体结构性没有 subjectId;Context 只从 HTTP 请求取(IP/UA/节流剩余秒)。
- FormFileService 加 uploadForTenant(schId, file)(匿名上传落表单归属校,否则提交侧附件归属查不到);
  五条硬约束一条不分叉。留痕按列宽截断落 answer.anon_ip/anon_ua(列在 t12 DDL 已预建,本批启用)。

测试:新增 AnonFillIT(V67,8 例:防探测文案等值、落库形态、平台级租户 0、窗口内二发 CUSTOM(5)、
伪造无效、幂等命中优先、窗口按表单配置、上传落档校)+ AnonReplayGuardTest(7 例:窗口边界与密钥缺失
fail-closed)。门禁 mvn -B -o clean test → Tests run: 265, Failures: 0, Errors: 0。
落地差额登记 P119/P121/P122/P123(详见 docs/T12-T18-report.md §1)。
This commit is contained in:
chenxing
2026-10-20 14:04:42 +08:00
parent e4bf960e1b
commit 3dac43dcfc
10 changed files with 1040 additions and 33 deletions
@@ -0,0 +1,149 @@
package com.yunhai.formcenter.controller.anon;
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.service.AnonReplayGuard;
import com.yunhai.formcenter.service.AnswerService;
import com.yunhai.formcenter.service.FormDefService;
import com.yunhai.formcenter.service.FormFileService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 匿名填报面三端点(契约 v2 §3.2/C6/C7/C8,T15,验收 <b>V67</b>)。
*
* <pre>
* GET /anon/form/resolve?code=[&amp;appCode=] → 与登录面同构的钉版 + 内联 options
* POST /anon/form/submit → 同一条提交管线(V60),身份由表单归属推导
* POST /anon/form/upload?code=[&amp;appCode=] → 同登录面五条硬约束,落档租户=表单归属校
* </pre>
*
* <p><b>没有一件事在这个类里放宽</b>:值校验、附件归属、钉版、报名号、索引全部由
* {@link AnswerService#submitAnon} 进同一条管线;防探测文案与"表单不存在"同一条(C6/V54);
* 上传的大小上限、扩展名黑名单、随机 key 一样不少。这个类只负责三件 HTTP 侧的事——
* 取留痕 IP/UA、判防重窗口、下发 cookie。</p>
*/
@RestController
@RequestMapping("/anon/form")
@RequiredArgsConstructor
public class AnonFormController {
/** 本应用 app_code 缺省值(与登录面 resolve 同口径) */
private static final String DEFAULT_APP_CODE = Form.CENTER_APP_CODE;
/** 留痕列宽:{@code answer.anon_ip varchar(64)} / {@code anon_ua varchar(512)}(截断而不是拒提交) */
private static final int IP_MAX = 64;
private static final int UA_MAX = 512;
private final FormDefService defService;
private final AnswerService answerService;
private final FormFileService fileService;
private final AnonReplayGuard replayGuard;
/**
* 匿名解析(C6:{@code audience} 不含 ANONYMOUS、草稿、停用、软删、跨校歧义一律"表单不存在")。
* 出参与登录面 {@code /api/form/resolve} 同一个组装方法(契约 §3.2"同构下发")。
*/
@GetMapping("/resolve")
public FormModels.ResolvedFormView resolve(@RequestParam("code") String code,
@RequestParam(value = "appCode", required = false) String appCode) {
return defService.resolveForAnon(appCode, code);
}
/**
* 匿名提交(C7/C8)。
*
* <p>顺序:先解析表单拿防重窗口 → 判 cookie → 进管线(管线内幂等命中优先于防重命中,
* 撞窗口才 CUSTOM(5))→ 真提交成功才重新签发 cookie(重放不续窗口)。</p>
*/
@PostMapping("/submit")
public AnswerModels.SubmitView submit(@RequestBody AnonModels.SubmitRequest body,
HttpServletRequest request,
HttpServletResponse response) {
String appCode = cleanAppCode(body.getAppCode());
String formCode = body.getFormCode() == null ? "" : body.getFormCode().trim();
// 这一次解析只为取 anon_cookie_minutes 与判 cookie;管线内部照常自己钉版解析(不省略、不分叉)
Form form = defService.resolvePublishedForAnon(appCode, formCode).form();
int windowMinutes = AnonReplayGuard.windowMinutes(form.getAnonCookieMinutes());
long remaining = replayGuard.throttleSeconds(form.getCode(), windowMinutes,
cookieValue(request, AnonReplayGuard.cookieName(form.getCode())));
AnswerModels.SubmitView view = answerService.submitAnon(body,
new AnonModels.Context(clientIp(request), userAgent(request), remaining));
if (!Boolean.TRUE.equals(view.getReplayed())) {
replayGuard.appendCookie(response, form.getCode(), replayGuard.issue(form.getCode()),
windowMinutes, request.getContextPath());
}
return view;
}
/**
* 匿名上传(契约 §3.2:其余与登录面全同,只有落档租户改为表单归属校)。
* {@code code} 在这里是<b>必填</b>的——它是匿名请求唯一能取到 {@code sch_id} 的来源,
* 而附件必须与答卷同域,否则提交时的 {@code assertAttachmentsOwned} 永远查不到这一行。
*/
@PostMapping("/upload")
public AnswerModels.FileView upload(@RequestParam("code") String code,
@RequestParam(value = "appCode", required = false) String appCode,
@RequestParam("file") MultipartFile file) {
Form form = defService.resolvePublishedForAnon(cleanAppCode(appCode), code).form();
return fileService.uploadForTenant(form.getSchId(), file);
}
// ==================== HTTP 侧取数 ====================
/**
* 留痕 IP。{@code X-Forwarded-For} 取第一段(生产经 Caddy 反代,{@code getRemoteAddr()} 只会是代理地址)。
*
* <p><b>这个值是可伪造的</b>:契约把 IP/UA 定成"留痕"(C8/C12)而不是身份,防重也不依赖它
* (依赖的是服务端签发的 cookie)。真要把 IP 当鉴权依据,得先在反代侧配置可信跳数——
* 二期若需要,改的是这一处而不是判据。</p>
*/
private static String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.trim().isEmpty()) {
return truncate(forwarded.split(",")[0].trim(), IP_MAX);
}
return truncate(request.getRemoteAddr(), IP_MAX);
}
private static String userAgent(HttpServletRequest request) {
return truncate(request.getHeader("User-Agent"), UA_MAX);
}
private static String cookieValue(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
for (Cookie cookie : cookies) {
if (name.equals(cookie.getName())) {
return cookie.getValue();
}
}
return null;
}
private static String cleanAppCode(String appCode) {
return appCode == null || appCode.trim().isEmpty() ? DEFAULT_APP_CODE : appCode.trim();
}
private static String truncate(String raw, int max) {
if (raw == null) {
return null;
}
return raw.length() <= max ? raw : raw.substring(0, max);
}
}
@@ -0,0 +1,15 @@
/**
* 匿名面({@code /anon/**},契约 v2 C6/§3.2,T15)。
*
* <p><b>为什么路径族在 {@code /api} 之外</b>:CAS starter 的 {@code yh.sso} 只有 5 个键、
* <b>没有任何 exclude/豁免位</b>,其 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 的残余轻运维项而不是代码缺口。</p>
*
* <p><b>本包的两条纪律</b>:① 方法签名里<b>不出现 {@code BoaUser}</b>(没有会话,任何身份注入都被禁止,
* 契约 §7 身份行的匿名例外就是"请求不携带任何身份字段");② 只读表单,不读任何按租户取的清单——
* 匿名填报人对 {@code /my}、数据面、oapi 一律无能力(C8/§2.1)。</p>
*/
package com.yunhai.formcenter.controller.anon;
@@ -0,0 +1,48 @@
package com.yunhai.formcenter.model;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
/**
* 匿名通道出入参(契约 v2 §3.2/§3.3,T15)。
*
* <p><b>为什么单独一个模型而不是复用 {@link AnswerModels.SubmitRequest}</b>:那个 DTO 里有
* {@code subjectId}。匿名请求的字段闭集(§3.3)<b>不含任何身份项</b>——把 DTO 分开,"服务端不读
* schId/userId/subjectId"就不是一条纪律而是一条编译期事实(V17 的匿名分支,契约 §7 身份行)。</p>
*/
public final class AnonModels {
private AnonModels() {
}
/** 匿名提交请求(字段闭集:寻址两件 + 幂等键 + 值文档) */
@Getter
@Setter
public static class SubmitRequest {
/** 表单 code(匿名面唯一寻址键,契约 §3.2) */
private String formCode;
/** 归属应用,缺省 form-center(契约 §3.3 的匿名示例体没有这一项,落地差额见问题单 P122) */
private String appCode;
/** 客户端生成的幂等键(防双击语义,与防刷无关;命中=200+原 answerId,C7) */
private String idempotencyKey;
/** 值文档 {@code {key:value}},与登录面同一套按钉住版本的校验 */
private JsonNode values;
}
/**
* 匿名提交的服务端上下文:只允许从 HTTP 请求本身取(IP/UA 是留痕,防重秒数是 cookie 校验结果),
* 请求体里没有任何字段能影响它。
*/
@Getter
@RequiredArgsConstructor
public static class Context {
/** 留痕 IP({@code answer.anon_ip},C8/C12) */
private final String anonIp;
/** 留痕 User-Agent({@code answer.anon_ua},C8/C12) */
private final String anonUa;
/** 防重窗口剩余秒数;0=放行(无 cookie / 签名无效 / 窗口已过,C7) */
private final long throttleRemainingSeconds;
}
}
@@ -44,6 +44,17 @@ public interface FormRepository extends JpaRepository<Form, Long> {
@Param("appCode") String appCode, @Param("appCode") String appCode,
@Param("schIds") Collection<Long> schIds); @Param("schIds") Collection<Long> schIds);
/**
* 匿名面取域(契约 v2 §3.2/C6):按 {@code (app_code, code)} 取<b>已发布且未软删</b>的头表,
* <b>不带任何租户条件</b>——匿名请求没有会话校,{@code (会话校 ∪ 平台级)} 那条取域规则无从成立。
*
* <p>{@code audience} 是否含 {@code ANONYMOUS} 留在服务侧判(逗号码串在 SQL 里没有可移植的
* 集合算子,P10 保守口径),跨校同 code 的歧义也在服务侧 fail-closed(问题单 P119)。</p>
*/
@Query("select f from Form f where f.code = :code and f.appCode = :appCode "
+ "and f.isDeleted = 0 and f.status = 1")
List<Form> findAnonCandidates(@Param("code") String code, @Param("appCode") String appCode);
/** /**
* 「我可填」列表取域(P95 W3):会话校 ∪ 平台级里<b>已发布且未软删</b>的表单头表。 * 「我可填」列表取域(P95 W3):会话校 ∪ 平台级里<b>已发布且未软删</b>的表单头表。
* *
@@ -0,0 +1,171 @@
package com.yunhai.formcenter.service;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* 匿名提交的 cookie 防重(契约 v2 C7/§3.2,T15)。
*
* <p><b>cookie 值就是服务端签发的时间戳</b>:{@code {issuedAtEpochSecond}.{hex(HMAC-SHA256(code.issuedAt))}}。
* 校验只看签名与时间戳两件事,因此<b>不需要任何服务端会话状态</b>(一期不引 Redis、不建表):</p>
* <ul>
* <li>签名不对(伪造、篡改时间戳、把别的表单的 cookie 拿来用)→ 视为无 cookie,放行。
* "伪造无效"的口径是<b>伪造值既不能延长也不能绕过窗口</b>,而不是"伪造要被判成攻击":这道机制是
* 节流阀不是身份认证,删掉 cookie 同样能绕,把匿名提交当身份来鉴是错的
* (契约 §3.2 边界明示只承诺"同域部署下的防重");</li>
* <li>签名对且 {@code now < issuedAt + 窗口} → 命中防重,返回剩余秒数
* (调用侧按 CUSTOM(5) 回答,msg 含剩余分钟)。</li>
* </ul>
*
* <p><b>密钥复用 {@code YH_APP_AUTH_APP_SECRET}</b>(C7 明令"不新增配置项")。未配置时匿名提交整体
* 拒绝而不是"关掉防重继续收"——防重是这张表唯一的节流(限次对匿名不适用,作废-11),
* 静默放行等于把表单交给任意脚本刷。</p>
*
* <p><b>cookie 只在提交成功(非幂等重放)时重新签发</b>,重放不续窗口:窗口是"两次真提交之间"的间隔,
* 双击重试落在同一张答卷上,不该把它拉长。</p>
*/
@Component
public class AnonReplayGuard {
/** cookie 名前缀 + 表单 code(一表单一 cookie,多表单互不干扰,C7) */
public static final String COOKIE_PREFIX = "fc_anon_";
/** {@code form.anon_cookie_minutes} 缺省窗口(DDL 默认值同为 10,实体异常时用这一个数兜住) */
public static final int DEFAULT_WINDOW_MINUTES = 10;
private static final String HMAC_ALGORITHM = "HmacSHA256";
private static final char[] HEX = "0123456789abcdef".toCharArray();
private final String secret;
public AnonReplayGuard(@Value("${yh.app-auth.app-secret:}") String secret) {
this.secret = secret == null ? "" : secret.trim();
}
public static String cookieName(String formCode) {
return COOKIE_PREFIX + formCode;
}
/** 防重窗口分钟数(null/非正数一律回落默认 10,不让一个脏配置变成"防重关闭") */
public static int windowMinutes(Integer configured) {
return configured == null || configured < 1 ? DEFAULT_WINDOW_MINUTES : configured;
}
/**
* 提交成功后要下发的 cookie 值。
*
* @throws BizException 密钥未配置({@code YH_APP_AUTH_APP_SECRET} 为空)
*/
public String issue(String formCode) {
requireSecret();
return issuedAt(formCode, System.currentTimeMillis() / 1000L);
}
/**
* 防重窗口判定。
*
* @param cookieValue 请求里带来的 cookie 值(可为 null)
* @return 还需等待的秒数;0=可以提交
*/
public long throttleSeconds(String formCode, Integer configuredWindowMinutes, String cookieValue) {
if (cookieValue == null || cookieValue.trim().isEmpty()) {
return 0L;
}
if (secret.isEmpty()) {
// 有 cookie 却无密钥可验:既不能判"放行"(那等于伪造值也放行),也不能判"命中"。
// 与 issue() 同口径——密钥缺失时匿名通道不可用,配置问题要在提交前暴露而不是静默降级。
requireSecret();
}
int window = windowMinutes(configuredWindowMinutes) * 60;
Long issuedAt = parse(formCode, cookieValue.trim());
if (issuedAt == null) {
return 0L;
}
long remaining = issuedAt + window - System.currentTimeMillis() / 1000L;
return remaining > 0 ? remaining : 0L;
}
/**
* 写 {@code Set-Cookie}。手写头而不是 {@code response.addCookie}:Servlet 的 {@code Cookie} 没有
* {@code SameSite} 位,而嵌入态(同站 iframe)需要显式给出一个值——留空即浏览器默认 Lax,
* 跨域 iframe 会被丢 cookie(契约 §3.2"边界明示"那条,本实现按默认口径原样写入消费方通告)。
*
* @param contextPath 请求上下文路径(生产 {@code /form-center},本地为空)→ cookie Path,C7
*/
public void appendCookie(HttpServletResponse response, String formCode, String value,
Integer configuredWindowMinutes, String contextPath) {
int window = windowMinutes(configuredWindowMinutes) * 60;
String path = contextPath == null || contextPath.isEmpty() ? "/" : contextPath;
StringBuilder header = new StringBuilder(128)
.append(cookieName(formCode)).append('=').append(value)
.append("; Path=").append(path)
.append("; Max-Age=").append(window)
.append("; HttpOnly; SameSite=Lax");
response.addHeader("Set-Cookie", header.toString());
}
/** 命中防重时的剩余分钟文案(向上取整、至少 1 分钟——"请 0 分钟后再试"等于没回答,C7) */
public static int remainingMinutes(long remainingSeconds) {
return (int) Math.max(1L, (remainingSeconds + 59L) / 60L);
}
// ==================== 内部 ====================
/** 时间戳与签名拆分 + 常量时间比对;非法形态或签名不符返回 null(=按无 cookie 处理) */
private Long parse(String formCode, String value) {
int dot = value.lastIndexOf('.');
if (dot <= 0 || dot == value.length() - 1) {
return null;
}
long issuedAt;
try {
issuedAt = Long.parseLong(value.substring(0, dot));
} catch (NumberFormatException e) {
return null;
}
String signature = value.substring(dot + 1);
if (!MessageDigest.isEqual(sign(formCode, issuedAt).getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8))) {
return null;
}
// 未来时间戳按"刚提交过"处理(窗口不越过配置值):签名有效说明它是本机签发的,
// 时钟抖动/机器时间偏快都不该把窗口拉长,更不该反过来把过去的票据顶成"刚刚"
return Math.min(issuedAt, System.currentTimeMillis() / 1000L);
}
/** 签发指定制表时刻的票据值(窗口边界用例要用"过去的时间戳",包内可见即可,不对外暴露) */
String issuedAt(String formCode, long issuedAtSeconds) {
return issuedAtSeconds + "." + sign(formCode, issuedAtSeconds);
}
private String sign(String formCode, long issuedAtSeconds) {
byte[] raw;
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM));
raw = mac.doFinal((formCode + "." + issuedAtSeconds).getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException | java.security.InvalidKeyException e) {
throw new IllegalStateException("匿名防重签名失败:" + e.getMessage(), e);
}
StringBuilder sb = new StringBuilder(raw.length * 2);
for (byte b : raw) {
sb.append(HEX[(b >> 4) & 0xF]).append(HEX[b & 0xF]);
}
return sb.toString();
}
private void requireSecret() {
if (secret.isEmpty()) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR,
"匿名通道不可用:防重密钥未配置(请注入 YH_APP_AUTH_APP_SECRET)");
}
}
}
@@ -10,6 +10,7 @@ import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormFile; import com.yunhai.formcenter.entity.FormFile;
import com.yunhai.formcenter.enums.FormEnums; import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.AnswerModels; import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.AnonModels;
import com.yunhai.formcenter.repository.AnswerRepository; import com.yunhai.formcenter.repository.AnswerRepository;
import com.yunhai.formcenter.repository.FillQuotaRepository; import com.yunhai.formcenter.repository.FillQuotaRepository;
import com.yunhai.formcenter.repository.FormFileRepository; import com.yunhai.formcenter.repository.FormFileRepository;
@@ -60,6 +61,11 @@ import java.util.UUID;
* 同一次解析的结果同时决定"写进去之后谁能看见这行"。<b>代录与导入也走这里</b>(§3.4/V60), * 同一次解析的结果同时决定"写进去之后谁能看见这行"。<b>代录与导入也走这里</b>(§3.4/V60),
* 所以"代录的权限"与"手工填的权限"不可能有两套口径。</p> * 所以"代录的权限"与"手工填的权限"不可能有两套口径。</p>
* *
* <p><b>两条通道、一条管线</b>(§3.2/V60):{@link #submit} 带 CAS 会话,{@link #submitAnon} 带
* {@link AnonModels.Context}(IP/UA/防重窗口剩余秒数)。差别全部收在 {@link SubmitIdentity} 里——
* 租户域、提交人、人群门方向、限次适用性、幂等归属五处各按分支取值,值校验/附件/钉版/报名号/索引
* 一段代码共用。匿名面没有第二份写库实现,这是 V56/V57/V60 三条红线的同一个落点。</p>
*
* <p><b>事务形态</b>:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。 * <p><b>事务形态</b>:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。
* 目的是让"幂等键撞 uk"这种并发结局能在<b>事务外</b>重新读取原答卷并按契约返回 200+原 answer_id * 目的是让"幂等键撞 uk"这种并发结局能在<b>事务外</b>重新读取原答卷并按契约返回 200+原 answer_id
* (撞键后当前事务已被标记回滚,事务内再读是不可靠的)。</p> * (撞键后当前事务已被标记回滚,事务内再读是不可靠的)。</p>
@@ -74,6 +80,8 @@ public class AnswerService {
/** 报名号并发撞号后的重试上限(窗口极窄:同表单同日同刻提交且序号算到同一个数) */ /** 报名号并发撞号后的重试上限(窗口极窄:同表单同日同刻提交且序号算到同一个数) */
private static final int ANSWER_NO_RETRY = 3; private static final int ANSWER_NO_RETRY = 3;
private static final String DEFAULT_APP_CODE = Form.CENTER_APP_CODE; 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 FormDefService defService;
private final FormDictService dictService; private final FormDictService dictService;
@@ -92,28 +100,47 @@ public class AnswerService {
*/ */
public AnswerModels.SubmitView submit(BoaUser user, AnswerModels.SubmitRequest req) { public AnswerModels.SubmitView submit(BoaUser user, AnswerModels.SubmitRequest req) {
String idempotencyKey = requireIdempotencyKey(req.getIdempotencyKey()); 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):<b>与 {@link #submit} 同一条管线</b>(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 之间有并发窗口), * 提交事务的一次尝试。报名号是"当日已有号数+1"(count 与 insert 之间有并发窗口),
* 撞 {@code uk_answer_no} 时重算序号重建事务——重新提交时对手那条已提交,count 已把它算进去。 * 撞 {@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) { String idempotencyKey, int remaining) {
try { try {
return transactionTemplate.execute(tx -> doSubmit(user, req, idempotencyKey)); return transactionTemplate.execute(tx -> doSubmit(identity, req, idempotencyKey));
} catch (DataIntegrityViolationException e) { } catch (DataIntegrityViolationException e) {
if (isAnswerNoCollision(e)) { if (isAnswerNoCollision(e)) {
if (remaining > 1) { if (remaining > 1) {
log.warn("[提交] 报名号并发撞号,重算序号重试(剩 {} 次)", 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, "报名号生成冲突,请稍后重试(同一表单并发提交过多)"); throw new BizException(ErrorCode.CUSTOM, "报名号生成冲突,请稍后重试(同一表单并发提交过多)");
} }
// 幂等键撞 uk(并发重放,按契约返回原答卷)或 uuid 撞库(32 位随机,实际不可能) // 幂等键撞 uk(并发重放,按契约返回原答卷)或 uuid 撞库(32 位随机,实际不可能)
Answer replay = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null); Answer replay = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null);
if (replay != null && belongsToCaller(replay, user)) { if (replay != null && identity.ownsReplay(replay)) {
log.info("[提交] 幂等键并发命中,返回原答卷 uuid={}", replay.getUuid()); log.info("[提交] 幂等键并发命中,返回原答卷 uuid={}", replay.getUuid());
return toView(replay, true); return toView(replay, true);
} }
@@ -138,19 +165,32 @@ public class AnswerService {
return false; return false;
} }
private AnswerModels.SubmitView doSubmit(BoaUser user, AnswerModels.SubmitRequest req, String idempotencyKey) { private AnswerModels.SubmitView doSubmit(SubmitIdentity identity, AnswerModels.SubmitRequest req,
Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属,不能提交答卷"); String idempotencyKey) {
Integer submitterId = user.getId(); BoaUser user = identity.user;
// 租户域(V57):登录面=会话校,无学校归属直接拒;匿名面=表单归属校,无会话可取,
// 因此必须等钉版解析出表单以后才有值(C8 把"恒=会话校"改成了两条分支)
Long sessionSchId = identity.anonymous()
? null : FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属,不能提交答卷");
String appCode = req.getAppCode() == null || req.getAppCode().trim().isEmpty() String appCode = req.getAppCode() == null || req.getAppCode().trim().isEmpty()
? DEFAULT_APP_CODE : req.getAppCode().trim(); ? DEFAULT_APP_CODE : req.getAppCode().trim();
// 1) 钉版:与填报页 resolve 同一条遮蔽规则(P20——升版窗口旧版仍可填,天然按旧版落地) // 1) 钉版:与填报页 resolve 同一条遮蔽规则(P20——升版窗口旧版仍可填,天然按旧版落地)。
FormDefService.Published published = defService.resolvePublished(schId, appCode, req.getFormCode()); // 两条通道各用自己的解析入口,但共用 publishedOf 的生命周期判定与版本行取数(V56)
FormDefService.Published published = identity.anonymous()
? defService.resolvePublishedForAnon(appCode, req.getFormCode())
: defService.resolvePublished(sessionSchId, appCode, req.getFormCode());
Form form = published.form(); Form form = published.form();
String schemaJson = published.versionRow().getSchemaJson(); String schemaJson = published.versionRow().getSchemaJson();
Long schId = identity.anonymous() ? form.getSchId() : sessionSchId;
Integer submitterId = identity.submitterId();
// 2) audience 门(契约 v2 §3.1):钉版之后才有 audience 可判,截止之前先鉴权。 // 2) audience 门(契约 v2 §3.1):钉版之后才有 audience 可判,截止之前先鉴权。
// 只对<b>自填</b>生效(P115 裁决:§3.4 与 §5.1/V73 冲突点按"代录看 ALL 档、不看操作者人群"收敛) // 只对<b>自填</b>生效(P116 裁决:§3.4 与 §5.1/V73 冲突点按"代录看 ALL 档、不看操作者人群"收敛)。
// 匿名通道不进这里:解析阶段已把"audience 含 ANONYMOUS"作为寻址条件(§3.2),
// 而会话身份集合里永远没有 ANONYMOUS(C1)——两条通道对同一个集合用两个方向判,不会互相放行
if (!identity.anonymous()) {
requireAudienceGate(user, form, req.getSubjectId(), submitterId); requireAudienceGate(user, form, req.getSubjectId(), submitterId);
}
// 3) 截止(datetime 粒度,过期接口拒绝,契约 §5) // 3) 截止(datetime 粒度,过期接口拒绝,契约 §5)
if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) { if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) {
@@ -160,28 +200,39 @@ public class AnswerService {
// 4) 幂等命中→200+原 answer_id(在限次之前,见类注释) // 4) 幂等命中→200+原 answer_id(在限次之前,见类注释)
Answer existing = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null); Answer existing = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null);
if (existing != null) { if (existing != null) {
if (!belongsToCaller(existing, user)) { if (!identity.ownsReplay(existing)) {
// 不泄露他人答卷身份:键被他人占用直接判越权,不返回其 answer throw replayConflict(identity);
throw new BizException(ErrorCode.FORBIDDEN, "幂等键已被其它账号占用");
} }
return toView(existing, true); return toView(existing, true);
} }
// 4.5) 匿名防重窗口(C7):命中=CUSTOM(5),文案给剩余分钟。
// 位置在幂等命中<b>之后</b>:双击重放落在同一张答卷上,按契约必须是 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 代填路径收敛到此) // 5) subject 指定权(契约 v2 §3.1:=该表单 ALL 档持有者,原 canManage 代填路径收敛到此)
// + 存在性/同校(P33) // + 存在性/同校(P33)
Integer subjectId = requireSubject(user, form, req.getSubjectId(), submitterId, schId); Integer subjectId = requireSubject(user, form, req.getSubjectId(), submitterId, schId);
// Bug#3:自填(未传 subjectId 或传了本人)落库时把 subject 归一为本人。 // Bug#3:自填(未传 subjectId 或传了本人)落库时把 subject 归一为本人。
// 否则 subject_id=NULL 的自填行永远进不了"关于我的"(/my/about 与 ABOUT_ME 谓词都按 subject 查), // 否则 subject_id=NULL 的自填行永远进不了"关于我的"(/my/about 与 ABOUT_ME 谓词都按 subject 查),
// V18"关于我(含代填)"实际退化成"仅代填"。限次键本就是 coalesce(subject, submitter),归一不改它。 // 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) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属 // 6) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属
// 放行集与附件归属都按 schId 判,匿名面的 schId=表单归属校,与 §3.2 的"同登录面同构校验"一致
String valuesJson = valuesToJson(req.getValues()); String valuesJson = valuesToJson(req.getValues());
FormValueValidator.validate(valuesJson, schemaJson, dictCode -> dictService.allowedValues(schId, dictCode)); FormValueValidator.validate(valuesJson, schemaJson, dictCode -> dictService.allowedValues(schId, dictCode));
assertAttachmentsOwned(schId, valuesJson, schemaJson); assertAttachmentsOwned(schId, valuesJson, schemaJson);
// 7) 限次占位(uk 是并发兜底) // 7) 限次占位(uk 是并发兜底)。匿名不适用(作废-11:无身份无 limit_key,
String quotaKey = occupyQuota(form, storedSubjectId, submitterId); // 配置通道已在 C1 处禁止"含 ANONYMOUS 且 fill_limit=1",这里不再占位)
String quotaKey = identity.anonymous() ? null : occupyQuota(form, storedSubjectId, submitterId);
// 8) 落答卷 // 8) 落答卷
Answer answer = new Answer(); Answer answer = new Answer();
@@ -194,13 +245,20 @@ public class AnswerService {
answer.setVersion(published.versionRow().getVersion()); answer.setVersion(published.versionRow().getVersion());
answer.setSubmitterId(submitterId); answer.setSubmitterId(submitterId);
// T10 方案 §4③(D2=B):SCHOOL 范围的谓词列=操作人会话学校,与 sch_id 同值由校域不变式保证 // T10 方案 §4③(D2=B):SCHOOL 范围的谓词列=操作人会话学校,与 sch_id 同值由校域不变式保证
// (P33 已把代填的 subject 钉在本校内);落值后不随改(updatable=false) // (P33 已把代填的 subject 钉在本校内);落值后不随改(updatable=false)。
// 匿名分支同值=表单归属校,于是"校级匿名表单的匿名答卷落校管理员 ALL 档、
// 平台级匿名表单的匿名答卷只有创建人(超管)看得见"两条推论自动成立(§2.3/C8)
answer.setSubmitterSchId(schId); answer.setSubmitterSchId(schId);
answer.setSubjectId(storedSubjectId); answer.setSubjectId(storedSubjectId);
answer.setStatus(FormStatusDefs.defaultStatusCode(form.getStatusDefs())); answer.setStatus(FormStatusDefs.defaultStatusCode(form.getStatusDefs()));
answer.setValuesJson(valuesJson); answer.setValuesJson(valuesJson);
answer.setAnswerNo(nextAnswerNo(form)); answer.setAnswerNo(nextAnswerNo(form));
answer.setIdempotencyKey(idempotencyKey); 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 判定并发重放); // saveAndFlush:让 uk 撞键在本方法内暴露(外层据 DataIntegrityViolationException 判定并发重放);
// 若这一步失败,同事务内的 fill_quota 占位一起回滚,不残留"名额被占但无答卷"的脏状态 // 若这一步失败,同事务内的 fill_quota 占位一起回滚,不残留"名额被占但无答卷"的脏状态
answer = answerRepository.saveAndFlush(answer); answer = answerRepository.saveAndFlush(answer);
@@ -208,9 +266,9 @@ public class AnswerService {
// 9) 索引:只写 searchable 字段(V3/V9) // 9) 索引:只写 searchable 字段(V3/V9)
indexService.write(answer, schemaJson); indexService.write(answer, schemaJson);
log.info("[提交] answer={} form={}/{} v{} submitter={} subject={} quotaKey={}", log.info("[提交]{} answer={} form={}/{} v{} submitter={} subject={} quotaKey={}",
answer.getUuid(), answer.getAppCode(), answer.getCode(), answer.getVersion(), identity.anonymous() ? "[匿名]" : "", answer.getUuid(), answer.getAppCode(), answer.getCode(),
submitterId, storedSubjectId, quotaKey); answer.getVersion(), submitterId, storedSubjectId, quotaKey);
return toView(answer, false); return toView(answer, false);
} }
@@ -219,7 +277,7 @@ public class AnswerService {
/** /**
* audience 门(契约 v2 §3.1):<b>自填</b>资格=会话身份类型 ∈ 表单面向人群(∩ 状态门在后面几关)。 * audience 门(契约 v2 §3.1):<b>自填</b>资格=会话身份类型 ∈ 表单面向人群(∩ 状态门在后面几关)。
* *
* <p><b>代录不吃这道门</b>(问题单 <b>P115</b>,用户 2026-10-20 裁决):§3.4 写"audience 门按代录 * <p><b>代录不吃这道门</b>(问题单 <b>P116</b>,用户 2026-10-20 裁决):§3.4 写"audience 门按代录
* 操作者会话判定",而 §5.1/V73 又要求管理员视图能"添加(代录)"、批量导入走同一管线—— * 操作者会话判定",而 §5.1/V73 又要求管理员视图能"添加(代录)"、批量导入走同一管线——
* 校管理员(roleId 2/7)本身不在学生或教师人群里,按字面判会让代录对管理员整体失效。 * 校管理员(roleId 2/7)本身不在学生或教师人群里,按字面判会让代录对管理员整体失效。
* 两条承诺只能留一条,留的是<b>功能可用</b>那条:{@code subject} 指向他人时,操作者的身份 * 两条承诺只能留一条,留的是<b>功能可用</b>那条:{@code subject} 指向他人时,操作者的身份
@@ -362,11 +420,13 @@ public class AnswerService {
return clean; return clean;
} }
private boolean belongsToCaller(Answer answer, BoaUser user) { /** 幂等键被别的行占着时的回答(两条通道都不泄露对方那条答卷的任何信息,契约 §6 错误码族) */
// 会话校 + 提交人共同界定"这条幂等记录是不是你的"(uk 是库级的,跨账号命中不能放行) private static BizException replayConflict(SubmitIdentity identity) {
Long sessionSch = FormAccessPolicy.sessionSchId(user); return identity.anonymous()
return answer.getSubmitterId() != null && answer.getSubmitterId().equals(user.getId()) // 匿名请求撞到的若是登录答卷,键空间相同纯属偶然;给冲突而不是"已被他人占用"
&& sessionSch != null && answer.getSchId() != null && answer.getSchId().equals(sessionSch); ? new BizException(ErrorCode.PARAM_ERROR, "提交冲突,请稍后重试(幂等键可能已被占用)")
// 不泄露他人答卷身份:键被他人占用直接判越权,不返回其 answer
: new BizException(ErrorCode.FORBIDDEN, "幂等键已被其它账号占用");
} }
private static String newUuid() { private static String newUuid() {
@@ -380,4 +440,61 @@ public class AnswerService {
view.setReplayed(replayed); view.setReplayed(replayed);
return view; return view;
} }
/**
* 提交身份(契约 v2 §3.1/§3.2 两条通道的唯一差别)。
*
* <p>管线里每一个"谁能填/替谁填/落进哪个租户/这条幂等记录算不算你的"判定都从这里取事实,
* 于是匿名通道不需要第二份提交实现(V60 采集管线唯一):值校验/附件/钉版/报名号/索引一段代码
* 共用。新通道(如二期 oapi 代提交)也只需要再加一个工厂方法。</p>
*
* <p><b>通道是入口显式声明的,不从 {@code user==null} 反推</b>(V58 身份推断单点的同一件事):
* 反推会让"登录面漏传会话"这种调用错误静默变成"走匿名分支写一条匿名答卷"——既绕过人群门,
* 又把数据落到表单归属校。现在 {@code submit(null, …)} 照旧在 {@code requireSessionSchId}
* 处报"未登录"({@code AnswerSubmitIT.sessionRequired} 钉住)。</p>
*/
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);
}
}
} }
@@ -142,15 +142,69 @@ public class FormDefService {
*/ */
public Published resolvePublished(Long schId, String appCode, String code) { public Published resolvePublished(Long schId, String appCode, String code) {
String cleanApp = normalizeAppCode(appCode); String cleanApp = normalizeAppCode(appCode);
String cleanCode = code == null ? null : code.trim(); String cleanCode = requireResolveCode(code);
if (cleanCode == null || cleanCode.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "code 不能为空");
}
Form form = pickByShadowing( Form form = pickByShadowing(
formRepository.findResolveCandidates(cleanCode, cleanApp, resolveDomain(schId)), schId); formRepository.findResolveCandidates(cleanCode, cleanApp, resolveDomain(schId)), schId);
if (form == null) { if (form == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + cleanApp + "/" + cleanCode); throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + cleanApp + "/" + cleanCode);
} }
return publishedOf(form, cleanCode);
}
/**
* 匿名面的同构解析(契约 v2 §3.2/C6,T15):按 {@code (app_code, code)} 在<b>全域</b>取
* 已发布未删、且 {@code audience} 含 {@code ANONYMOUS} 的表单。
*
* <p>三条口径与登录面<b>有意不同</b>,都是为了匿名面不泄露任何存在性信息:</p>
* <ul>
* <li>{@code audience} 不含 ANONYMOUS → 按"表单不存在"回答(C6/V54,与登录面的
* "不在人群内"分别是两条通道的防探测文案基准);</li>
* <li>草稿/停用行在取域阶段就被 SQL 排除,因此也归"表单不存在"——登录面会给
* "表单尚未发布/已停用"这种可诊断文案,那是对持有会话的人说的;匿名面对象是全网,
* 生命周期状态本身就是可枚举信息;</li>
* <li>遮蔽规则不适用(没有"会话校"这个优先级参照系),于是"跨校同 code 且都含 ANONYMOUS"
* 成为无法消歧的输入 → 一律按不存在拒绝 + warn 点名(问题单 <b>P119</b>:宁可关不掉
* 一个配错的入口,也不让匿名请求在两张同 code 的表里猜租户归属——落错校的答卷是数据事故)。</li>
* </ul>
*/
public Published resolvePublishedForAnon(String appCode, String code) {
String cleanApp = normalizeAppCode(appCode);
String cleanCode = requireResolveCode(code);
List<Form> candidates = new ArrayList<>();
for (Form form : formRepository.findAnonCandidates(cleanCode, cleanApp)) {
if (FormEnums.Audience.parse(form.getAudience()).contains(FormEnums.Audience.ANONYMOUS)) {
candidates.add(form);
}
}
String notFound = "表单不存在:" + cleanApp + "/" + cleanCode;
if (candidates.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, notFound);
}
if (candidates.size() > 1) {
log.warn("[匿名] code={}/{} 在 {} 个租户下同时是匿名表单,无法消歧,按不存在拒绝(P119)",
cleanApp, cleanCode, candidates.size());
throw new BizException(ErrorCode.PARAM_ERROR, notFound);
}
return publishedOf(candidates.get(0), cleanCode);
}
/** 匿名面的 resolve 出参(与 {@link #resolve} 同一份组装实现,契约 §3.2"同登录面同构下发") */
public FormModels.ResolvedFormView resolveForAnon(String appCode, String code) {
Published published = resolvePublishedForAnon(appCode, code);
return toResolvedView(published.form(), published.versionRow(),
dictService.expandSchema(published.form().getSchId(), published.versionRow().getSchemaJson()));
}
private static String requireResolveCode(String code) {
String cleanCode = code == null ? null : code.trim();
if (cleanCode == null || cleanCode.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "code 不能为空");
}
return cleanCode;
}
/** 生命周期判定 + 取当前发布版本行(登录面与匿名面共用,V56 钉版同源) */
private Published publishedOf(Form form, String cleanCode) {
if (form.getStatus() == null || form.getStatus() != FormEnums.FormStatus.PUBLISHED.getCode() if (form.getStatus() == null || form.getStatus() != FormEnums.FormStatus.PUBLISHED.getCode()
|| form.getCurrentVersion() == null) { || form.getCurrentVersion() == null) {
// 业务规则类(契约 §6 定稿映射):草稿/停用都不开放填报入口,但数据面读接口不受影响(方案 §5.4) // 业务规则类(契约 §6 定稿映射):草稿/停用都不开放填报入口,但数据面读接口不受影响(方案 §5.4)
@@ -96,6 +96,22 @@ public class FormFileService {
/** 上传:校验 → 落盘(随机名) → 落档建行,返回契约 §3 的三件套 */ /** 上传:校验 → 落盘(随机名) → 落档建行,返回契约 §3 的三件套 */
@Transactional @Transactional
public AnswerModels.FileView upload(BoaUser user, MultipartFile file) { public AnswerModels.FileView upload(BoaUser user, MultipartFile file) {
return uploadForTenant(FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属,不能上传附件"), file);
}
/**
* 租户域显式入参的那一份实现——登录面由会话校推导后调它,匿名面按"表单归属校"调它
* (契约 v2 §3.2/C6:匿名上传的 {@code form_file.sch_id} =表单.sch_id,与答卷同域,
* 否则提交时 {@code assertAttachmentsOwned} 按表单校查不到这一行)。
*
* <p>取<b>方法名</b>而不是 {@code upload(Long, file)} 重载:任何传 null 的调用点
* (IT 里"未登录不能上传"那条断言)在重载下会变成歧义解析。</p>
*
* <p>五条硬约束(大小/黑名单/随机 key/存储根三层占位/返回值三件)一条都不在这里分叉,
* 匿名面没有任何放宽面(V24/V29 不变)。</p>
*/
@Transactional
public AnswerModels.FileView uploadForTenant(Long schId, MultipartFile file) {
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件为空"); throw new BizException(ErrorCode.PARAM_ERROR, "上传文件为空");
} }
@@ -103,7 +119,6 @@ public class FormFileService {
throw new BizException(ErrorCode.PARAM_ERROR, throw new BizException(ErrorCode.PARAM_ERROR,
"单文件不得超过 50MB(当前 " + (file.getSize() / 1024 / 1024) + "MB)"); "单文件不得超过 50MB(当前 " + (file.getSize() / 1024 / 1024) + "MB)");
} }
Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属,不能上传附件");
String original = sanitizeName(file.getOriginalFilename()); String original = sanitizeName(file.getOriginalFilename());
String ext = requireSafeExt(original); String ext = requireSafeExt(original);
String key = "form/" + LocalDate.now().format(YEAR) + "/" + LocalDate.now().format(MONTH) String key = "form/" + LocalDate.now().format(YEAR) + "/" + LocalDate.now().format(MONTH)
@@ -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,验收 <b>V67</b>,T15)。
*
* <p>打真库、用 {@link AnswerTestSupport} 的建表/配置脚手架,但<b>从控制器入口进</b>——匿名面的语义
* 就是"一个没有会话的 HTTP 请求",只测服务分会把三件真正要钉住的事漏掉:cookie 有没有下发、
* Path 是不是上下文路径、留痕是不是从请求头取的。</p>
*
* <p>与登录面<b>共用</b>的环节不重复断言(值校验/限次/钉版各有 IT),这里只钉匿名分支的六个口径:
* 防探测同文案、落库形态(submitter=0 / subject=NULL / sch_id=表单校 / IP·UA 落独立列)、
* 窗口内二发=CUSTOM(5) 含剩余分钟、伪造 cookie 既不延长也不绕过、幂等命中优先于防重(200+原 answerId)、
* 上传落档租户=表单归属校。</p>
*/
@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<Answer> rows = answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", code);
assertThat(rows).hasSize(1);
Answer stored = rows.get(0);
assertThat(stored.getUuid()).isEqualTo(view.getAnswerId());
assertThat(stored.getSubmitterId()).isEqualTo(0);
assertThat(stored.getSubjectId()).isNull();
assertThat(stored.getSchId()).isEqualTo(SCH);
assertThat(stored.getSubmitterSchId()).isEqualTo(SCH);
assertThat(stored.getFormUuid()).isEqualTo(uuid);
// 钉版与登录面同一条解析(V56):匿名行也带 form_uuid + version
assertThat(stored.getVersion()).isNotNull();
// 留痕在独立列,不在 values_json(C8/C12)
assertThat(stored.getAnonIp()).isEqualTo("203.0.113.9");
assertThat(stored.getAnonUa()).startsWith("MockMozilla/5.0");
assertThat(stored.getValuesJson()).doesNotContain("203.0.113.9");
// 名=fc_anon_{code}、Path=上下文路径(C7)、HttpOnly、Max-Age=窗口秒(默认 10 分钟)
String setCookie = response.getHeader("Set-Cookie");
assertThat(setCookie).isNotNull();
assertThat(setCookie).startsWith("fc_anon_" + code + "=");
assertThat(setCookie).contains("Path=/form-center").contains("HttpOnly").contains("Max-Age=600");
}
@Test
@DisplayName("V57 匿名分支:平台级匿名表单的答卷落 sch_id=0(表单归属校,取代\"恒=会话校\")")
void platformLevelAnonymousAnswerKeepsZeroTenant() {
String code = "v67_platform_anon";
String uuid = publishForm(Form.PLATFORM_SCH_ID, code, SCHEMA);
config(Form.PLATFORM_SCH_ID, uuid, audience(Collections.singletonList("ANONYMOUS")));
anonSubmit(code, VALUES, null, null, new MockHttpServletResponse());
List<Answer> rows = answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(
Form.PLATFORM_SCH_ID, "form-center", code);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getSchId()).isEqualTo(Form.PLATFORM_SCH_ID);
assertThat(rows.get(0).getSubmitterSchId()).isEqualTo(Form.PLATFORM_SCH_ID);
assertThat(rows.get(0).getSubmitterId()).isZero();
assertThat(rows.get(0).getFormUuid()).isEqualTo(uuid);
}
@Test
@DisplayName("V60 管线唯一:匿名提交照旧过值校验与截止闸,非匿名表单在匿名面落不进去")
void anonymousSubmitKeepsTheSameGates() {
String code = "v67_anon_gates";
String uuid = publishForm(code, SCHEMA);
config(SCH, uuid, audience(Collections.singletonList("ANONYMOUS")));
assertThatThrownBy(() -> anonSubmit(code, "{\"name\":\"甲\",\"grade\":\"g9\"}", null, null,
new MockHttpServletResponse()))
.isInstanceOf(BizException.class).hasMessageContaining("不在候选列表内");
FormModels.FormConfigRequest closed = audience(Collections.singletonList("ANONYMOUS"));
closed.setDeadline("2020-01-01");
config(SCH, uuid, closed);
assertThatThrownBy(() -> anonSubmit(code, VALUES, null, null, new MockHttpServletResponse()))
.isInstanceOf(BizException.class).hasMessageContaining("已过收集截止时间");
// 人群门在匿名面以"寻址条件"的形式生效:解析阶段就拒,压根进不到写库
String studentCode = "v67_anon_reject_student_form";
publishForm(studentCode, SCHEMA);
assertThatThrownBy(() -> anonSubmit(studentCode, VALUES, null, null, new MockHttpServletResponse()))
.isInstanceOf(BizException.class).hasMessageContaining(NOT_FOUND);
assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", studentCode)).isEmpty();
}
// ==================== 防重窗口(C7) ====================
@Test
@DisplayName("V67/C7:窗口内二发=CUSTOM(5) 且文案给剩余分钟;伪造值既不延长也不绕过,跨表单的合法值也不认")
void secondSubmitWithinWindowIsCustomFiveAndForgedValueIsIgnored() {
String code = "v67_anon_window";
String uuid = publishForm(code, SCHEMA);
config(SCH, uuid, audience(Collections.singletonList("ANONYMOUS")));
MockHttpServletResponse first = new MockHttpServletResponse();
anonSubmit(code, VALUES, null, null, first);
String issued = cookieValueOf(first.getHeader("Set-Cookie"));
BizException throttled = catchThrowableOfType(
() -> anonSubmit(code, VALUES, null, issued, new MockHttpServletResponse()), BizException.class);
assertThat(throttled.getErrorCode()).isEqualTo(ErrorCode.CUSTOM);
assertThat(throttled.getMessage()).contains("分钟").contains(code);
// 篡改时间戳(把签发时刻推到未来)→ 签名不再匹配 → 按无 cookie 处理:伪造换不到任何特权
String tampered = (System.currentTimeMillis() / 1000L + 3600) + issued.substring(issued.indexOf('.'));
anonSubmit(code, VALUES, "k-tampered", tampered, new MockHttpServletResponse());
assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", code)).hasSize(2);
// 别的表单的合法 cookie 挪过来不算数(签名串里含 code,一表单一份)
String other = "v67_anon_window_other";
String otherUuid = publishForm(other, SCHEMA);
config(SCH, otherUuid, audience(Collections.singletonList("ANONYMOUS")));
anonSubmit(other, VALUES, "k-cross", issued, new MockHttpServletResponse());
assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", other)).hasSize(1);
}
@Test
@DisplayName("V67/C7:窗口分钟数按表单配置取(anon_cookie_minutes),Set-Cookie 的 Max-Age 与之一致")
void windowLengthIsPerForm() {
String code = "v67_anon_window_30";
String uuid = publishForm(code, SCHEMA);
FormModels.FormConfigRequest cfg = audience(Collections.singletonList("ANONYMOUS"));
cfg.setAnonCookieMinutes(30);
config(SCH, uuid, cfg);
MockHttpServletResponse response = new MockHttpServletResponse();
anonSubmit(code, VALUES, null, null, response);
assertThat(response.getHeader("Set-Cookie")).contains("Max-Age=1800");
BizException throttled = catchThrowableOfType(() -> anonSubmit(code, VALUES, null,
cookieValueOf(response.getHeader("Set-Cookie")), new MockHttpServletResponse()), BizException.class);
assertThat(throttled.getMessage()).contains("30 分钟内只能提交一次").contains("请约 30 分钟后").contains(code);
}
@Test
@DisplayName("V67/C7:幂等命中优先于防重——双击重放 200+原 answerId、不续窗口,换键才是第二次真提交")
void idempotentReplayBeatsThrottle() {
String code = "v67_anon_replay";
String uuid = publishForm(code, SCHEMA);
config(SCH, uuid, audience(Collections.singletonList("ANONYMOUS")));
MockHttpServletResponse first = new MockHttpServletResponse();
AnswerModels.SubmitView created = anonSubmit(code, VALUES, "same-key", null, first);
assertThat(created.getReplayed()).isFalse();
String issued = cookieValueOf(first.getHeader("Set-Cookie"));
MockHttpServletResponse replay = new MockHttpServletResponse();
AnswerModels.SubmitView again = anonSubmit(code, VALUES, "same-key", issued, replay);
assertThat(again.getAnswerId()).isEqualTo(created.getAnswerId());
assertThat(again.getReplayed()).isTrue();
// 重放不重新下发 cookie:窗口量的是"两次真提交之间"的间隔
assertThat(replay.getHeader("Set-Cookie")).isNull();
assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", code)).hasSize(1);
assertThatThrownBy(() -> anonSubmit(code, VALUES, "another-key", issued, new MockHttpServletResponse()))
.isInstanceOf(BizException.class).hasMessageContaining("分钟");
}
// ==================== upload ====================
@Test
@DisplayName("V67/C6:匿名上传落档租户=表单归属校;非匿名表单\"表单不存在\";黑名单不因匿名放宽")
void uploadLandsInFormTenant() {
String code = "v67_anon_upload";
String uuid = publishForm(code, SCHEMA);
config(SCH, uuid, audience(Collections.singletonList("ANONYMOUS")));
AnswerModels.FileView view = controller.upload(code, null,
new MockMultipartFile("file", "照片.jpg", "image/jpeg", "bytes".getBytes()));
assertThat(view.getId()).isNotNull();
assertThat(view.getKey()).startsWith("form/").endsWith(".jpg");
assertThat(view.getFileName()).isEqualTo("照片.jpg");
// 提交时的附件归属按表单校查这一行,落错校就等于匿名面传不了附件
assertThat(fileRepository.findByIdAndSchId(view.getId(), SCH)).isPresent();
String studentCode = "v67_anon_upload_student";
publishForm(studentCode, SCHEMA);
assertThatThrownBy(() -> controller.upload(studentCode, null,
new MockMultipartFile("file", "照片.jpg", "image/jpeg", "bytes".getBytes())))
.isInstanceOf(BizException.class).hasMessageContaining(NOT_FOUND);
assertThatThrownBy(() -> controller.upload(code, null,
new MockMultipartFile("file", "x.svg", "image/svg+xml", "bytes".getBytes())))
.isInstanceOf(BizException.class).hasMessageContaining("不允许上传的文件类型");
}
// ==================== 脚手架 ====================
private static FormModels.FormConfigRequest audience(List<String> audience) {
FormModels.FormConfigRequest cfg = new FormModels.FormConfigRequest();
cfg.setAudience(audience);
return cfg;
}
private AnswerModels.SubmitView anonSubmit(String code, String valuesJson, String cookie,
MockHttpServletResponse response) {
return anonSubmit(code, valuesJson, null, cookie, response);
}
/** 走控制器发一次匿名提交({@code idemKey}=null 每次新生成=又一次真提交;{@code cookie}=null 即没带防重票据) */
private AnswerModels.SubmitView anonSubmit(String code, String valuesJson, String idemKey, String cookie,
MockHttpServletResponse response) {
AnonModels.SubmitRequest body = new AnonModels.SubmitRequest();
body.setFormCode(code);
body.setIdempotencyKey(idemKey == null ? newIdemKey() : idemKey);
body.setValues(tree(valuesJson));
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/anon/form/submit");
// 生产形态带上下文路径:cookie Path 要吃它(C7)
request.setContextPath("/form-center");
request.setRemoteAddr("203.0.113.9");
request.addHeader("User-Agent", "MockMozilla/5.0 (V67 匿名面)");
if (cookie != null) {
request.setCookies(new MockCookie(AnonReplayGuard.cookieName(code), cookie));
}
return controller.submit(body, request, response);
}
private static String cookieValueOf(String setCookieHeader) {
String pair = setCookieHeader.split(";")[0];
return pair.substring(pair.indexOf('=') + 1);
}
private static JsonNode tree(String json) {
try {
return FormSchemaValidator.mapper().readTree(json);
} catch (Exception e) {
throw new IllegalStateException("测试 JSON 写错了:" + json, e);
}
}
}
@@ -0,0 +1,111 @@
package com.yunhai.formcenter.service;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 匿名防重票据(契约 v2 C7,T15)的单测——窗口判定与签名校验全在这一个类里,
* 用纯对象测比走 HTTP 更清楚(IT 侧只补"cookie 有没有下发、Path 对不对"那一层)。
*
* <p>要钉住的是三条容易写歪的口径:① <b>非法/伪造/跨表单的值一律按"没带 cookie"处理</b>
* (放行,既不能延长窗口也不能绕过窗口的<em>特权</em>),而不是报错;② 窗口按表单配置取,
* 脏配置(null/0/负数)回落默认 10 而不是关掉防重;③ 密钥缺失时<b>整体不可用</b>而不是静默放行。</p>
*/
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");
}
}