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:
@@ -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=[&appCode=] → 与登录面同构的钉版 + 内联 options
|
||||||
|
* POST /anon/form/submit → 同一条提交管线(V60),身份由表单归属推导
|
||||||
|
* POST /anon/form/upload?code=[&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 档、不看操作者人群"收敛)。
|
||||||
requireAudienceGate(user, form, req.getSubjectId(), submitterId);
|
// 匿名通道不进这里:解析阶段已把"audience 含 ANONYMOUS"作为寻址条件(§3.2),
|
||||||
|
// 而会话身份集合里永远没有 ANONYMOUS(C1)——两条通道对同一个集合用两个方向判,不会互相放行
|
||||||
|
if (!identity.anonymous()) {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user