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("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>的表单头表。
|
||||
*
|
||||
|
||||
@@ -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.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;
|
||||
* 同一次解析的结果同时决定"写进去之后谁能看见这行"。<b>代录与导入也走这里</b>(§3.4/V60),
|
||||
* 所以"代录的权限"与"手工填的权限"不可能有两套口径。</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} 包。
|
||||
* 目的是让"幂等键撞 uk"这种并发结局能在<b>事务外</b>重新读取原答卷并按契约返回 200+原 answer_id
|
||||
* (撞键后当前事务已被标记回滚,事务内再读是不可靠的)。</p>
|
||||
@@ -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):<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 之间有并发窗口),
|
||||
* 撞 {@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 可判,截止之前先鉴权。
|
||||
// 只对<b>自填</b>生效(P115 裁决:§3.4 与 §5.1/V73 冲突点按"代录看 ALL 档、不看操作者人群"收敛)
|
||||
requireAudienceGate(user, form, req.getSubjectId(), submitterId);
|
||||
// 只对<b>自填</b>生效(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),文案给剩余分钟。
|
||||
// 位置在幂等命中<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 代填路径收敛到此)
|
||||
// + 存在性/同校(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):<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 又要求管理员视图能"添加(代录)"、批量导入走同一管线——
|
||||
* 校管理员(roleId 2/7)本身不在学生或教师人群里,按字面判会让代录对管理员整体失效。
|
||||
* 两条承诺只能留一条,留的是<b>功能可用</b>那条:{@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 两条通道的唯一差别)。
|
||||
*
|
||||
* <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) {
|
||||
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)} 在<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()
|
||||
|| form.getCurrentVersion() == null) {
|
||||
// 业务规则类(契约 §6 定稿映射):草稿/停用都不开放填报入口,但数据面读接口不受影响(方案 §5.4)
|
||||
|
||||
@@ -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} 按表单校查不到这一行)。
|
||||
*
|
||||
* <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()) {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user