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