diff --git a/src/main/java/com/yunhai/formcenter/controller/anon/AnonFormController.java b/src/main/java/com/yunhai/formcenter/controller/anon/AnonFormController.java new file mode 100644 index 0000000..f720519 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/anon/AnonFormController.java @@ -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,验收 V67)。 + * + *
+ * GET  /anon/form/resolve?code=[&appCode=]  → 与登录面同构的钉版 + 内联 options
+ * POST /anon/form/submit                      → 同一条提交管线(V60),身份由表单归属推导
+ * POST /anon/form/upload?code=[&appCode=]   → 同登录面五条硬约束,落档租户=表单归属校
+ * 
+ * + *

没有一件事在这个类里放宽:值校验、附件归属、钉版、报名号、索引全部由 + * {@link AnswerService#submitAnon} 进同一条管线;防探测文案与"表单不存在"同一条(C6/V54); + * 上传的大小上限、扩展名黑名单、随机 key 一样不少。这个类只负责三件 HTTP 侧的事—— + * 取留痕 IP/UA、判防重窗口、下发 cookie。

+ */ +@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)。 + * + *

顺序:先解析表单拿防重窗口 → 判 cookie → 进管线(管线内幂等命中优先于防重命中, + * 撞窗口才 CUSTOM(5))→ 真提交成功才重新签发 cookie(重放不续窗口)。

+ */ + @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} 在这里是必填的——它是匿名请求唯一能取到 {@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()} 只会是代理地址)。 + * + *

这个值是可伪造的:契约把 IP/UA 定成"留痕"(C8/C12)而不是身份,防重也不依赖它 + * (依赖的是服务端签发的 cookie)。真要把 IP 当鉴权依据,得先在反代侧配置可信跳数—— + * 二期若需要,改的是这一处而不是判据。

+ */ + 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); + } +} diff --git a/src/main/java/com/yunhai/formcenter/controller/anon/package-info.java b/src/main/java/com/yunhai/formcenter/controller/anon/package-info.java new file mode 100644 index 0000000..a337929 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/anon/package-info.java @@ -0,0 +1,15 @@ +/** + * 匿名面({@code /anon/**},契约 v2 C6/§3.2,T15)。 + * + *

为什么路径族在 {@code /api} 之外:CAS starter 的 {@code yh.sso} 只有 5 个键、 + * 没有任何 exclude/豁免位,其 6 个 Filter 只挂 {@code /api/*}(任务书 A11 取证)。 + * 于是"匿名面挂在 {@code /api/form/anonymous/}"这条直觉写法不可行(契约 C6/P-A)—— + * 放在 {@code /api} 下就必然进墙,而墙内未登录会话的响应是 302 引导不是数据。 + * {@code /anon/**} 因此天然无墙,代价是部署侧要配套加反代/代理条目 + * (生产 Caddy {@code /form-center/anon/*}、dev vite {@code /anon}),这是 T15 的残余轻运维项而不是代码缺口。

+ * + *

本包的两条纪律:① 方法签名里不出现 {@code BoaUser}(没有会话,任何身份注入都被禁止, + * 契约 §7 身份行的匿名例外就是"请求不携带任何身份字段");② 只读表单,不读任何按租户取的清单—— + * 匿名填报人对 {@code /my}、数据面、oapi 一律无能力(C8/§2.1)。

+ */ +package com.yunhai.formcenter.controller.anon; diff --git a/src/main/java/com/yunhai/formcenter/model/AnonModels.java b/src/main/java/com/yunhai/formcenter/model/AnonModels.java new file mode 100644 index 0000000..d89542e --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/model/AnonModels.java @@ -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)。 + * + *

为什么单独一个模型而不是复用 {@link AnswerModels.SubmitRequest}:那个 DTO 里有 + * {@code subjectId}。匿名请求的字段闭集(§3.3)不含任何身份项——把 DTO 分开,"服务端不读 + * schId/userId/subjectId"就不是一条纪律而是一条编译期事实(V17 的匿名分支,契约 §7 身份行)。

+ */ +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; + } +} diff --git a/src/main/java/com/yunhai/formcenter/repository/FormRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java index 7fb6e57..a87080a 100644 --- a/src/main/java/com/yunhai/formcenter/repository/FormRepository.java +++ b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java @@ -44,6 +44,17 @@ public interface FormRepository extends JpaRepository { @Param("appCode") String appCode, @Param("schIds") Collection schIds); + /** + * 匿名面取域(契约 v2 §3.2/C6):按 {@code (app_code, code)} 取已发布且未软删的头表, + * 不带任何租户条件——匿名请求没有会话校,{@code (会话校 ∪ 平台级)} 那条取域规则无从成立。 + * + *

{@code audience} 是否含 {@code ANONYMOUS} 留在服务侧判(逗号码串在 SQL 里没有可移植的 + * 集合算子,P10 保守口径),跨校同 code 的歧义也在服务侧 fail-closed(问题单 P119)。

+ */ + @Query("select f from Form f where f.code = :code and f.appCode = :appCode " + + "and f.isDeleted = 0 and f.status = 1") + List
findAnonCandidates(@Param("code") String code, @Param("appCode") String appCode); + /** * 「我可填」列表取域(P95 W3):会话校 ∪ 平台级里已发布且未软删的表单头表。 * diff --git a/src/main/java/com/yunhai/formcenter/service/AnonReplayGuard.java b/src/main/java/com/yunhai/formcenter/service/AnonReplayGuard.java new file mode 100644 index 0000000..ac1c850 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AnonReplayGuard.java @@ -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)。 + * + *

cookie 值就是服务端签发的时间戳:{@code {issuedAtEpochSecond}.{hex(HMAC-SHA256(code.issuedAt))}}。 + * 校验只看签名与时间戳两件事,因此不需要任何服务端会话状态(一期不引 Redis、不建表):

+ *
    + *
  • 签名不对(伪造、篡改时间戳、把别的表单的 cookie 拿来用)→ 视为无 cookie,放行。 + * "伪造无效"的口径是伪造值既不能延长也不能绕过窗口,而不是"伪造要被判成攻击":这道机制是 + * 节流阀不是身份认证,删掉 cookie 同样能绕,把匿名提交当身份来鉴是错的 + * (契约 §3.2 边界明示只承诺"同域部署下的防重");
  • + *
  • 签名对且 {@code now < issuedAt + 窗口} → 命中防重,返回剩余秒数 + * (调用侧按 CUSTOM(5) 回答,msg 含剩余分钟)。
  • + *
+ * + *

密钥复用 {@code YH_APP_AUTH_APP_SECRET}(C7 明令"不新增配置项")。未配置时匿名提交整体 + * 拒绝而不是"关掉防重继续收"——防重是这张表唯一的节流(限次对匿名不适用,作废-11), + * 静默放行等于把表单交给任意脚本刷。

+ * + *

cookie 只在提交成功(非幂等重放)时重新签发,重放不续窗口:窗口是"两次真提交之间"的间隔, + * 双击重试落在同一张答卷上,不该把它拉长。

+ */ +@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)"); + } + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerService.java b/src/main/java/com/yunhai/formcenter/service/AnswerService.java index 7147334..a54857b 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerService.java @@ -10,6 +10,7 @@ import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.entity.FormFile; import com.yunhai.formcenter.enums.FormEnums; import com.yunhai.formcenter.model.AnswerModels; +import com.yunhai.formcenter.model.AnonModels; import com.yunhai.formcenter.repository.AnswerRepository; import com.yunhai.formcenter.repository.FillQuotaRepository; import com.yunhai.formcenter.repository.FormFileRepository; @@ -60,6 +61,11 @@ import java.util.UUID; * 同一次解析的结果同时决定"写进去之后谁能看见这行"。代录与导入也走这里(§3.4/V60), * 所以"代录的权限"与"手工填的权限"不可能有两套口径。

* + *

两条通道、一条管线(§3.2/V60):{@link #submit} 带 CAS 会话,{@link #submitAnon} 带 + * {@link AnonModels.Context}(IP/UA/防重窗口剩余秒数)。差别全部收在 {@link SubmitIdentity} 里—— + * 租户域、提交人、人群门方向、限次适用性、幂等归属五处各按分支取值,值校验/附件/钉版/报名号/索引 + * 一段代码共用。匿名面没有第二份写库实现,这是 V56/V57/V60 三条红线的同一个落点。

+ * *

事务形态:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。 * 目的是让"幂等键撞 uk"这种并发结局能在事务外重新读取原答卷并按契约返回 200+原 answer_id * (撞键后当前事务已被标记回滚,事务内再读是不可靠的)。

@@ -74,6 +80,8 @@ public class AnswerService { /** 报名号并发撞号后的重试上限(窗口极窄:同表单同日同刻提交且序号算到同一个数) */ private static final int ANSWER_NO_RETRY = 3; private static final String DEFAULT_APP_CODE = Form.CENTER_APP_CODE; + /** 匿名提交人哨兵值(契约 v2 C8:库内不自增、不指向任何 UserBase 用户) */ + private static final Integer ANONYMOUS_SUBMITTER_ID = 0; private final FormDefService defService; private final FormDictService dictService; @@ -92,28 +100,47 @@ public class AnswerService { */ public AnswerModels.SubmitView submit(BoaUser user, AnswerModels.SubmitRequest req) { String idempotencyKey = requireIdempotencyKey(req.getIdempotencyKey()); - return executeSubmit(user, req, idempotencyKey, ANSWER_NO_RETRY); + return executeSubmit(SubmitIdentity.ofSession(user), req, idempotencyKey, ANSWER_NO_RETRY); + } + + /** + * 匿名提交(契约 v2 §3.2,T15):与 {@link #submit} 同一条管线(V60),差别只在身份—— + * 租户域取表单归属校而不是会话校(C8/V57)、{@code submitter_id=0}、{@code subject_id=NULL}、 + * 人群门在解析阶段就以"audience 必须含 ANONYMOUS"的形式判过、限次不适用(作废-11), + * 另加 IP/UA 两列留痕。请求体里没有 subjectId/appCode 之外的任何身份字段。 + * + * @param ctx 服务端从 HTTP 请求取的留痕与防重判定结果(请求体无法影响它) + */ + public AnswerModels.SubmitView submitAnon(AnonModels.SubmitRequest req, AnonModels.Context ctx) { + String idempotencyKey = requireIdempotencyKey(req.getIdempotencyKey()); + AnswerModels.SubmitRequest internal = new AnswerModels.SubmitRequest(); + internal.setFormCode(req.getFormCode()); + internal.setAppCode(req.getAppCode()); + internal.setIdempotencyKey(idempotencyKey); + internal.setValues(req.getValues()); + // subjectId 不填:匿名请求的字段闭集里没有它,两个 DTO 分开写就是为了让它无从出现(§3.3) + return executeSubmit(SubmitIdentity.ofAnon(ctx), internal, idempotencyKey, ANSWER_NO_RETRY); } /** * 提交事务的一次尝试。报名号是"当日已有号数+1"(count 与 insert 之间有并发窗口), * 撞 {@code uk_answer_no} 时重算序号重建事务——重新提交时对手那条已提交,count 已把它算进去。 */ - private AnswerModels.SubmitView executeSubmit(BoaUser user, AnswerModels.SubmitRequest req, + private AnswerModels.SubmitView executeSubmit(SubmitIdentity identity, AnswerModels.SubmitRequest req, String idempotencyKey, int remaining) { try { - return transactionTemplate.execute(tx -> doSubmit(user, req, idempotencyKey)); + return transactionTemplate.execute(tx -> doSubmit(identity, req, idempotencyKey)); } catch (DataIntegrityViolationException e) { if (isAnswerNoCollision(e)) { if (remaining > 1) { log.warn("[提交] 报名号并发撞号,重算序号重试(剩 {} 次)", remaining - 1); - return executeSubmit(user, req, idempotencyKey, remaining - 1); + return executeSubmit(identity, req, idempotencyKey, remaining - 1); } throw new BizException(ErrorCode.CUSTOM, "报名号生成冲突,请稍后重试(同一表单并发提交过多)"); } // 幂等键撞 uk(并发重放,按契约返回原答卷)或 uuid 撞库(32 位随机,实际不可能) Answer replay = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null); - if (replay != null && belongsToCaller(replay, user)) { + if (replay != null && identity.ownsReplay(replay)) { log.info("[提交] 幂等键并发命中,返回原答卷 uuid={}", replay.getUuid()); return toView(replay, true); } @@ -138,19 +165,32 @@ public class AnswerService { return false; } - private AnswerModels.SubmitView doSubmit(BoaUser user, AnswerModels.SubmitRequest req, String idempotencyKey) { - Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属,不能提交答卷"); - Integer submitterId = user.getId(); + private AnswerModels.SubmitView doSubmit(SubmitIdentity identity, AnswerModels.SubmitRequest req, + String idempotencyKey) { + BoaUser user = identity.user; + // 租户域(V57):登录面=会话校,无学校归属直接拒;匿名面=表单归属校,无会话可取, + // 因此必须等钉版解析出表单以后才有值(C8 把"恒=会话校"改成了两条分支) + Long sessionSchId = identity.anonymous() + ? null : FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属,不能提交答卷"); String appCode = req.getAppCode() == null || req.getAppCode().trim().isEmpty() ? DEFAULT_APP_CODE : req.getAppCode().trim(); - // 1) 钉版:与填报页 resolve 同一条遮蔽规则(P20——升版窗口旧版仍可填,天然按旧版落地) - FormDefService.Published published = defService.resolvePublished(schId, appCode, req.getFormCode()); + // 1) 钉版:与填报页 resolve 同一条遮蔽规则(P20——升版窗口旧版仍可填,天然按旧版落地)。 + // 两条通道各用自己的解析入口,但共用 publishedOf 的生命周期判定与版本行取数(V56) + FormDefService.Published published = identity.anonymous() + ? defService.resolvePublishedForAnon(appCode, req.getFormCode()) + : defService.resolvePublished(sessionSchId, appCode, req.getFormCode()); Form form = published.form(); String schemaJson = published.versionRow().getSchemaJson(); + Long schId = identity.anonymous() ? form.getSchId() : sessionSchId; + Integer submitterId = identity.submitterId(); // 2) audience 门(契约 v2 §3.1):钉版之后才有 audience 可判,截止之前先鉴权。 - // 只对自填生效(P115 裁决:§3.4 与 §5.1/V73 冲突点按"代录看 ALL 档、不看操作者人群"收敛) - requireAudienceGate(user, form, req.getSubjectId(), submitterId); + // 只对自填生效(P116 裁决:§3.4 与 §5.1/V73 冲突点按"代录看 ALL 档、不看操作者人群"收敛)。 + // 匿名通道不进这里:解析阶段已把"audience 含 ANONYMOUS"作为寻址条件(§3.2), + // 而会话身份集合里永远没有 ANONYMOUS(C1)——两条通道对同一个集合用两个方向判,不会互相放行 + if (!identity.anonymous()) { + requireAudienceGate(user, form, req.getSubjectId(), submitterId); + } // 3) 截止(datetime 粒度,过期接口拒绝,契约 §5) if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) { @@ -160,28 +200,39 @@ public class AnswerService { // 4) 幂等命中→200+原 answer_id(在限次之前,见类注释) Answer existing = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null); if (existing != null) { - if (!belongsToCaller(existing, user)) { - // 不泄露他人答卷身份:键被他人占用直接判越权,不返回其 answer - throw new BizException(ErrorCode.FORBIDDEN, "幂等键已被其它账号占用"); + if (!identity.ownsReplay(existing)) { + throw replayConflict(identity); } return toView(existing, true); } + // 4.5) 匿名防重窗口(C7):命中=CUSTOM(5),文案给剩余分钟。 + // 位置在幂等命中之后:双击重放落在同一张答卷上,按契约必须是 200+原 answerId, + // 不能被节流阀报成"提交太频繁";又必须在任何写库动作之前,否则窗口内会留下名额/号段消耗 + if (identity.anonymous() && identity.anon().getThrottleRemainingSeconds() > 0) { + throw new BizException(ErrorCode.CUSTOM, "该表单 " + AnonReplayGuard.windowMinutes(form.getAnonCookieMinutes()) + + " 分钟内只能提交一次,请约 " + AnonReplayGuard.remainingMinutes( + identity.anon().getThrottleRemainingSeconds()) + " 分钟后再提交:" + form.getCode()); + } + // 5) subject 指定权(契约 v2 §3.1:=该表单 ALL 档持有者,原 canManage 代填路径收敛到此) // + 存在性/同校(P33) Integer subjectId = requireSubject(user, form, req.getSubjectId(), submitterId, schId); // Bug#3:自填(未传 subjectId 或传了本人)落库时把 subject 归一为本人。 // 否则 subject_id=NULL 的自填行永远进不了"关于我的"(/my/about 与 ABOUT_ME 谓词都按 subject 查), // V18"关于我(含代填)"实际退化成"仅代填"。限次键本就是 coalesce(subject, submitter),归一不改它。 - Integer storedSubjectId = subjectId != null ? subjectId : submitterId; + // 匿名分支反过来必须留 NULL(C8):submitter_id=0 不是任何人,归一过去会造出一个指向 0 号用户的"关于我的"行 + Integer storedSubjectId = identity.anonymous() ? null : (subjectId != null ? subjectId : submitterId); // 6) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属 + // 放行集与附件归属都按 schId 判,匿名面的 schId=表单归属校,与 §3.2 的"同登录面同构校验"一致 String valuesJson = valuesToJson(req.getValues()); FormValueValidator.validate(valuesJson, schemaJson, dictCode -> dictService.allowedValues(schId, dictCode)); assertAttachmentsOwned(schId, valuesJson, schemaJson); - // 7) 限次占位(uk 是并发兜底) - String quotaKey = occupyQuota(form, storedSubjectId, submitterId); + // 7) 限次占位(uk 是并发兜底)。匿名不适用(作废-11:无身份无 limit_key, + // 配置通道已在 C1 处禁止"含 ANONYMOUS 且 fill_limit=1",这里不再占位) + String quotaKey = identity.anonymous() ? null : occupyQuota(form, storedSubjectId, submitterId); // 8) 落答卷 Answer answer = new Answer(); @@ -194,13 +245,20 @@ public class AnswerService { answer.setVersion(published.versionRow().getVersion()); answer.setSubmitterId(submitterId); // T10 方案 §4③(D2=B):SCHOOL 范围的谓词列=操作人会话学校,与 sch_id 同值由校域不变式保证 - // (P33 已把代填的 subject 钉在本校内);落值后不随改(updatable=false) + // (P33 已把代填的 subject 钉在本校内);落值后不随改(updatable=false)。 + // 匿名分支同值=表单归属校,于是"校级匿名表单的匿名答卷落校管理员 ALL 档、 + // 平台级匿名表单的匿名答卷只有创建人(超管)看得见"两条推论自动成立(§2.3/C8) answer.setSubmitterSchId(schId); answer.setSubjectId(storedSubjectId); answer.setStatus(FormStatusDefs.defaultStatusCode(form.getStatusDefs())); answer.setValuesJson(valuesJson); answer.setAnswerNo(nextAnswerNo(form)); answer.setIdempotencyKey(idempotencyKey); + if (identity.anonymous()) { + // 留痕进独立列,不进 values_json(C8/C12:值文档是业务数据,IP/UA 是审计数据) + answer.setAnonIp(identity.anon().getAnonIp()); + answer.setAnonUa(identity.anon().getAnonUa()); + } // saveAndFlush:让 uk 撞键在本方法内暴露(外层据 DataIntegrityViolationException 判定并发重放); // 若这一步失败,同事务内的 fill_quota 占位一起回滚,不残留"名额被占但无答卷"的脏状态 answer = answerRepository.saveAndFlush(answer); @@ -208,9 +266,9 @@ public class AnswerService { // 9) 索引:只写 searchable 字段(V3/V9) indexService.write(answer, schemaJson); - log.info("[提交] answer={} form={}/{} v{} submitter={} subject={} quotaKey={}", - answer.getUuid(), answer.getAppCode(), answer.getCode(), answer.getVersion(), - submitterId, storedSubjectId, quotaKey); + log.info("[提交]{} answer={} form={}/{} v{} submitter={} subject={} quotaKey={}", + identity.anonymous() ? "[匿名]" : "", answer.getUuid(), answer.getAppCode(), answer.getCode(), + answer.getVersion(), submitterId, storedSubjectId, quotaKey); return toView(answer, false); } @@ -219,7 +277,7 @@ public class AnswerService { /** * audience 门(契约 v2 §3.1):自填资格=会话身份类型 ∈ 表单面向人群(∩ 状态门在后面几关)。 * - *

代录不吃这道门(问题单 P115,用户 2026-10-20 裁决):§3.4 写"audience 门按代录 + *

代录不吃这道门(问题单 P116,用户 2026-10-20 裁决):§3.4 写"audience 门按代录 * 操作者会话判定",而 §5.1/V73 又要求管理员视图能"添加(代录)"、批量导入走同一管线—— * 校管理员(roleId 2/7)本身不在学生或教师人群里,按字面判会让代录对管理员整体失效。 * 两条承诺只能留一条,留的是功能可用那条:{@code subject} 指向他人时,操作者的身份 @@ -362,11 +420,13 @@ public class AnswerService { return clean; } - private boolean belongsToCaller(Answer answer, BoaUser user) { - // 会话校 + 提交人共同界定"这条幂等记录是不是你的"(uk 是库级的,跨账号命中不能放行) - Long sessionSch = FormAccessPolicy.sessionSchId(user); - return answer.getSubmitterId() != null && answer.getSubmitterId().equals(user.getId()) - && sessionSch != null && answer.getSchId() != null && answer.getSchId().equals(sessionSch); + /** 幂等键被别的行占着时的回答(两条通道都不泄露对方那条答卷的任何信息,契约 §6 错误码族) */ + private static BizException replayConflict(SubmitIdentity identity) { + return identity.anonymous() + // 匿名请求撞到的若是登录答卷,键空间相同纯属偶然;给冲突而不是"已被他人占用" + ? new BizException(ErrorCode.PARAM_ERROR, "提交冲突,请稍后重试(幂等键可能已被占用)") + // 不泄露他人答卷身份:键被他人占用直接判越权,不返回其 answer + : new BizException(ErrorCode.FORBIDDEN, "幂等键已被其它账号占用"); } private static String newUuid() { @@ -380,4 +440,61 @@ public class AnswerService { view.setReplayed(replayed); return view; } + + /** + * 提交身份(契约 v2 §3.1/§3.2 两条通道的唯一差别)。 + * + *

管线里每一个"谁能填/替谁填/落进哪个租户/这条幂等记录算不算你的"判定都从这里取事实, + * 于是匿名通道不需要第二份提交实现(V60 采集管线唯一):值校验/附件/钉版/报名号/索引一段代码 + * 共用。新通道(如二期 oapi 代提交)也只需要再加一个工厂方法。

+ * + *

通道是入口显式声明的,不从 {@code user==null} 反推(V58 身份推断单点的同一件事): + * 反推会让"登录面漏传会话"这种调用错误静默变成"走匿名分支写一条匿名答卷"——既绕过人群门, + * 又把数据落到表单归属校。现在 {@code submit(null, …)} 照旧在 {@code requireSessionSchId} + * 处报"未登录"({@code AnswerSubmitIT.sessionRequired} 钉住)。

+ */ + private static final class SubmitIdentity { + + private final BoaUser user; + private final AnonModels.Context anon; + private final boolean anonymous; + + private SubmitIdentity(BoaUser user, AnonModels.Context anon, boolean anonymous) { + this.user = user; + this.anon = anon; + this.anonymous = anonymous; + } + + static SubmitIdentity ofSession(BoaUser user) { + return new SubmitIdentity(user, null, false); + } + + static SubmitIdentity ofAnon(AnonModels.Context anon) { + return new SubmitIdentity(null, anon, true); + } + + boolean anonymous() { + return anonymous; + } + + AnonModels.Context anon() { + return anon; + } + + /** 提交人:会话用户 id,或匿名哨兵 0(C8,库内不自增、不指向任何 UserBase 用户) */ + Integer submitterId() { + return anonymous() ? ANONYMOUS_SUBMITTER_ID : user.getId(); + } + + /** 幂等命中的归属判定(C7:匿名"无身份可比对",只额外要求命中行本身是匿名行) */ + boolean ownsReplay(Answer answer) { + if (anonymous()) { + return ANONYMOUS_SUBMITTER_ID.equals(answer.getSubmitterId()); + } + // 会话校 + 提交人共同界定"这条幂等记录是不是你的"(uk 是库级的,跨账号命中不能放行) + Long sessionSch = FormAccessPolicy.sessionSchId(user); + return answer.getSubmitterId() != null && answer.getSubmitterId().equals(user.getId()) + && sessionSch != null && answer.getSchId() != null && answer.getSchId().equals(sessionSch); + } + } } diff --git a/src/main/java/com/yunhai/formcenter/service/FormDefService.java b/src/main/java/com/yunhai/formcenter/service/FormDefService.java index 140df4a..ebaf3a4 100644 --- a/src/main/java/com/yunhai/formcenter/service/FormDefService.java +++ b/src/main/java/com/yunhai/formcenter/service/FormDefService.java @@ -142,15 +142,69 @@ public class FormDefService { */ public Published resolvePublished(Long schId, String appCode, String code) { String cleanApp = normalizeAppCode(appCode); - String cleanCode = code == null ? null : code.trim(); - if (cleanCode == null || cleanCode.isEmpty()) { - throw new BizException(ErrorCode.PARAM_ERROR, "code 不能为空"); - } + String cleanCode = requireResolveCode(code); Form form = pickByShadowing( formRepository.findResolveCandidates(cleanCode, cleanApp, resolveDomain(schId)), schId); if (form == null) { throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + cleanApp + "/" + cleanCode); } + return publishedOf(form, cleanCode); + } + + /** + * 匿名面的同构解析(契约 v2 §3.2/C6,T15):按 {@code (app_code, code)} 在全域取 + * 已发布未删、且 {@code audience} 含 {@code ANONYMOUS} 的表单。 + * + *

三条口径与登录面有意不同,都是为了匿名面不泄露任何存在性信息:

+ *
    + *
  • {@code audience} 不含 ANONYMOUS → 按"表单不存在"回答(C6/V54,与登录面的 + * "不在人群内"分别是两条通道的防探测文案基准);
  • + *
  • 草稿/停用行在取域阶段就被 SQL 排除,因此也归"表单不存在"——登录面会给 + * "表单尚未发布/已停用"这种可诊断文案,那是对持有会话的人说的;匿名面对象是全网, + * 生命周期状态本身就是可枚举信息;
  • + *
  • 遮蔽规则不适用(没有"会话校"这个优先级参照系),于是"跨校同 code 且都含 ANONYMOUS" + * 成为无法消歧的输入 → 一律按不存在拒绝 + warn 点名(问题单 P119:宁可关不掉 + * 一个配错的入口,也不让匿名请求在两张同 code 的表里猜租户归属——落错校的答卷是数据事故)。
  • + *
+ */ + public Published resolvePublishedForAnon(String appCode, String code) { + String cleanApp = normalizeAppCode(appCode); + String cleanCode = requireResolveCode(code); + List 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() || form.getCurrentVersion() == null) { // 业务规则类(契约 §6 定稿映射):草稿/停用都不开放填报入口,但数据面读接口不受影响(方案 §5.4) diff --git a/src/main/java/com/yunhai/formcenter/service/FormFileService.java b/src/main/java/com/yunhai/formcenter/service/FormFileService.java index cbe5e07..0c76932 100644 --- a/src/main/java/com/yunhai/formcenter/service/FormFileService.java +++ b/src/main/java/com/yunhai/formcenter/service/FormFileService.java @@ -96,6 +96,22 @@ public class FormFileService { /** 上传:校验 → 落盘(随机名) → 落档建行,返回契约 §3 的三件套 */ @Transactional 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} 按表单校查不到这一行)。 + * + *

取方法名而不是 {@code upload(Long, file)} 重载:任何传 null 的调用点 + * (IT 里"未登录不能上传"那条断言)在重载下会变成歧义解析。

+ * + *

五条硬约束(大小/黑名单/随机 key/存储根三层占位/返回值三件)一条都不在这里分叉, + * 匿名面没有任何放宽面(V24/V29 不变)。

+ */ + @Transactional + public AnswerModels.FileView uploadForTenant(Long schId, MultipartFile file) { if (file == null || file.isEmpty()) { throw new BizException(ErrorCode.PARAM_ERROR, "上传文件为空"); } @@ -103,7 +119,6 @@ public class FormFileService { throw new BizException(ErrorCode.PARAM_ERROR, "单文件不得超过 50MB(当前 " + (file.getSize() / 1024 / 1024) + "MB)"); } - Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属,不能上传附件"); String original = sanitizeName(file.getOriginalFilename()); String ext = requireSafeExt(original); String key = "form/" + LocalDate.now().format(YEAR) + "/" + LocalDate.now().format(MONTH) diff --git a/src/test/java/com/yunhai/formcenter/service/AnonFillIT.java b/src/test/java/com/yunhai/formcenter/service/AnonFillIT.java new file mode 100644 index 0000000..9807780 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/AnonFillIT.java @@ -0,0 +1,316 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.common.FormSchemaValidator; +import com.yunhai.formcenter.controller.anon.AnonFormController; +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.model.AnonModels; +import com.yunhai.formcenter.model.AnswerModels; +import com.yunhai.formcenter.model.FormModels; +import com.yunhai.formcenter.repository.FormFileRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockCookie; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +/** + * 匿名通道(契约 v2 §3.2/C6/C7/C8,验收 V67,T15)。 + * + *

打真库、用 {@link AnswerTestSupport} 的建表/配置脚手架,但从控制器入口进——匿名面的语义 + * 就是"一个没有会话的 HTTP 请求",只测服务分会把三件真正要钉住的事漏掉:cookie 有没有下发、 + * Path 是不是上下文路径、留痕是不是从请求头取的。

+ * + *

与登录面共用的环节不重复断言(值校验/限次/钉版各有 IT),这里只钉匿名分支的六个口径: + * 防探测同文案、落库形态(submitter=0 / subject=NULL / sch_id=表单校 / IP·UA 落独立列)、 + * 窗口内二发=CUSTOM(5) 含剩余分钟、伪造 cookie 既不延长也不绕过、幂等命中优先于防重(200+原 answerId)、 + * 上传落档租户=表单归属校。

+ */ +@SpringBootTest +@Import(AnswerTestSupport.TestGatewayConfiguration.class) +@Transactional +@DisplayName("匿名通道 - /anon/form 三端点与 cookie 防重(V67)") +class AnonFillIT extends AnswerTestSupport { + + private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":[" + + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true}," + + "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true," + + "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高三\",\"value\":\"g3\"}]}]}]}"; + + private static final String VALUES = "{\"name\":\"匿名者\",\"grade\":\"g2\"}"; + + private static final String NOT_FOUND = "表单不存在"; + + @Autowired + private AnonFormController controller; + + @Autowired + private FormFileRepository fileRepository; + + // ==================== resolve:防探测 ==================== + + @Test + @DisplayName("V66/C6:匿名 resolve 只认 audience 含 ANONYMOUS 的表单;非匿名与不存在同一条\"表单不存在\"文案") + void resolveOnlyAdmitsAnonymousForms() { + String anonCode = "v67_anon_resolve"; + String anonUuid = publishForm(anonCode, SCHEMA); + config(SCH, anonUuid, audience(Collections.singletonList("ANONYMOUS"))); + + FormModels.ResolvedFormView view = controller.resolve(anonCode, null); + assertThat(view.getCode()).isEqualTo(anonCode); + assertThat(view.getFormUuid()).isEqualTo(anonUuid); + assertThat(view.getVersion()).isNotNull(); + assertThat(view.getAudience()).containsExactly("ANONYMOUS"); + // 出参与登录面同一个组装实现:options 已内联(字典不进对外契约,契约 §2.1) + assertThat(view.getGroups().toString()).contains("高二"); + + String studentCode = "v67_student_resolve"; + publishForm(studentCode, SCHEMA); + BizException notAnonymous = catchThrowableOfType(() -> controller.resolve(studentCode, null), BizException.class); + assertThat(notAnonymous.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR); + assertThat(notAnonymous.getMessage()).contains(NOT_FOUND).contains(studentCode); + + // 与"不存在"同文案:没配过匿名人群的表单与压根不存在的 code 得到同一句话(V54 的匿名半句) + BizException missing = catchThrowableOfType(() -> controller.resolve("v67_never_exists", null), BizException.class); + assertThat(missing.getMessage()).isEqualTo(notAnonymous.getMessage().replace(studentCode, "v67_never_exists")); + } + + // ==================== submit:落库形态(C8/V57) ==================== + + @Test + @DisplayName("V67/C8:匿名提交落库 submitter_id=0、subject_id=NULL、sch_id=表单校、IP/UA 落独立列,并下发防重 cookie") + void submitLandsAnonymousShape() { + String code = "v67_anon_shape"; + String uuid = publishForm(code, SCHEMA); + config(SCH, uuid, audience(Collections.singletonList("ANONYMOUS"))); + + MockHttpServletResponse response = new MockHttpServletResponse(); + AnswerModels.SubmitView view = anonSubmit(code, VALUES, null, null, response); + + List 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 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 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); + } + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/AnonReplayGuardTest.java b/src/test/java/com/yunhai/formcenter/service/AnonReplayGuardTest.java new file mode 100644 index 0000000..b09fdaf --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/AnonReplayGuardTest.java @@ -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 对不对"那一层)。 + * + *

要钉住的是三条容易写歪的口径:① 非法/伪造/跨表单的值一律按"没带 cookie"处理 + * (放行,既不能延长窗口也不能绕过窗口的特权),而不是报错;② 窗口按表单配置取, + * 脏配置(null/0/负数)回落默认 10 而不是关掉防重;③ 密钥缺失时整体不可用而不是静默放行。

+ */ +class AnonReplayGuardTest { + + private static final String SECRET = "unit-test-secret"; + private static final String CODE = "school_survey"; + + private final AnonReplayGuard guard = new AnonReplayGuard(SECRET); + + @Test + @DisplayName("票据名按表单隔离:前缀 fc_anon_ + code(一表单一份,互不干扰)") + void cookieNameIsPerForm() { + assertThat(AnonReplayGuard.cookieName(CODE)).isEqualTo("fc_anon_school_survey"); + assertThat(AnonReplayGuard.COOKIE_PREFIX).isEqualTo("fc_anon_"); + } + + @Test + @DisplayName("刚签发的票据在窗口内=命中,剩余秒数接近整窗;缺省窗口 10 分钟") + void freshTicketThrottlesForTheWholeWindow() { + long remaining = guard.throttleSeconds(CODE, null, guard.issue(CODE)); + assertThat(remaining).isBetween(595L, 600L); + assertThat(AnonReplayGuard.windowMinutes(null)).isEqualTo(AnonReplayGuard.DEFAULT_WINDOW_MINUTES); + assertThat(AnonReplayGuard.remainingMinutes(remaining)).isEqualTo(10); + } + + @Test + @DisplayName("窗口分钟数:null/0/负数一律回落 10,配 30 就用 30(脏配置不等于关掉防重)") + void windowMinutesNeverDisablesTheThrottle() { + assertThat(AnonReplayGuard.windowMinutes(0)).isEqualTo(10); + assertThat(AnonReplayGuard.windowMinutes(-5)).isEqualTo(10); + assertThat(AnonReplayGuard.windowMinutes(30)).isEqualTo(30); + // 1 分钟窗口下,30 秒前签发的票据还剩约 30 秒,文案向上取整至少 1 分钟 + long remaining = 30L; + assertThat(AnonReplayGuard.remainingMinutes(remaining)).isEqualTo(1); + assertThat(guard.throttleSeconds(CODE, 1, guard.issuedAt(CODE, System.currentTimeMillis() / 1000L - 30))) + .isBetween(29L, 31L); + } + + @Test + @DisplayName("窗口已过=放行;未来时间戳按\"刚提交过\"处理(窗口不拉长,也不反过来把旧票据顶成刚刚)") + void expiredTicketPassesButFutureOneThrottles() { + long now = System.currentTimeMillis() / 1000L; + assertThat(guard.throttleSeconds(CODE, 10, guard.issuedAt(CODE, now - 601))).isZero(); + assertThat(guard.throttleSeconds(CODE, 10, guard.issuedAt(CODE, now - 300))).isBetween(299L, 301L); + assertThat(guard.throttleSeconds(CODE, 10, guard.issuedAt(CODE, now + 120))).isBetween(595L, 600L); + } + + @Test + @DisplayName("伪造无效:换密钥签的值、改过时间戳的值、别的表单的值、形态非法的值,全部按没带 cookie 处理") + void forgedOrMalformedValueIsTreatedAsAbsent() { + long now = System.currentTimeMillis() / 1000L; + String valid = guard.issue(CODE); + + // 换密钥(攻击者不知道服务端密钥时能造出的最强形态) + assertThat(guard.throttleSeconds(CODE, 10, new AnonReplayGuard("other-secret").issue(CODE))).isZero(); + // 篡改时间戳(保留原签名往后推=想延长窗口) + String tampered = (now + 3600) + valid.substring(valid.indexOf('.')); + assertThat(guard.throttleSeconds(CODE, 10, tampered)).isZero(); + // 别的表单的合法票据挪过来 + assertThat(guard.throttleSeconds("other_form", 10, valid)).isZero(); + // 形态非法:不给异常,只当没带 + assertThat(guard.throttleSeconds(CODE, 10, null)).isZero(); + assertThat(guard.throttleSeconds(CODE, 10, " ")).isZero(); + assertThat(guard.throttleSeconds(CODE, 10, "not-a-ticket")).isZero(); + assertThat(guard.throttleSeconds(CODE, 10, "1.2.3")).isZero(); + } + + @Test + @DisplayName("密钥缺失=匿名通道不可用:签发与判定都拒绝,而不是静默放行(防重是唯一的节流)") + void missingSecretFailsClosed() { + AnonReplayGuard unconfigured = new AnonReplayGuard(" "); + assertThatThrownBy(() -> unconfigured.issue(CODE)) + .isInstanceOf(BizException.class).hasMessageContaining("防重密钥未配置"); + assertThatThrownBy(() -> unconfigured.throttleSeconds(CODE, 10, "1760000000.deadbeef")) + .isInstanceOf(BizException.class).hasMessageContaining("防重密钥未配置"); + // 没带 cookie 时不需要密钥就能判"放行",这一条不受配置缺失影响 + assertThat(unconfigured.throttleSeconds(CODE, 10, null)).isZero(); + } + + @Test + @DisplayName("下发形态:Path=上下文路径(生产 /form-center)、Max-Age=窗口秒、HttpOnly、SameSite=Lax") + void cookieHeaderCarriesContextPathAndWindow() { + // 逐属性断言而不是整串等值:MockHttpServletResponse 会解析 Set-Cookie 并补一个 Expires, + // 真容器的头里没有它 + MockHttpServletResponse response = new MockHttpServletResponse(); + guard.appendCookie(response, CODE, "1760000000.abc", 30, "/form-center"); + assertThat(response.getHeader("Set-Cookie")) + .startsWith("fc_anon_school_survey=1760000000.abc;") + .contains("Path=/form-center").contains("Max-Age=1800").contains("HttpOnly").contains("SameSite=Lax"); + + MockHttpServletResponse local = new MockHttpServletResponse(); + guard.appendCookie(local, CODE, "1760000000.abc", null, ""); + assertThat(local.getHeader("Set-Cookie")).contains("Path=/;").contains("Max-Age=600"); + } +}