feat(answer): T3 后端·答卷核心——提交/幂等/限次/索引/软删留痕 + 平台标准版上传
build / build-push (push) Successful in 52s

答卷域是"校验只写一遍、索引只有一份"这条选型(D2 模型 2b)的兑现处,本轮把它一次落齐。

- 提交链路按语义排死顺序:钉版解析→截止→幂等命中→subject 授权→值校验→附件同校归属
  →fill_quota 占位→落 answer→写索引。幂等必须在限次之前(重放那条本就占着名额),
  限次不做先 count 再插而是事务内撞 uk 即拒(并发下 count 靠不住,方案 §5.4)
- 钉版与定义域共用同一条 resolve 遮蔽实现;升版窗口旧版仍可填并钉旧版(P20),
  请求体不含 version/schId/submitterId——身份结构性不可注入(V17)
- FormValueValidator 按契约 §3 逐类校验值形状:date 严格 YYYY-MM-DD 且必须是真实日期、
  number 空值只认 null、选项值取内联 ∪ allowedValues(含软删条目)、
  附件只认 {id,fileName,key} 且拒绝对 URL/跨目录 key
- answer_index 只写显式 searchable 字段、多值一行一个、v_str 截 191;编辑按钉住版本重建,
  软删同步删行且释放 fill_quota 名额;留痕只增不改,改状态无任何转移规则校验(V15)
- 上传模块自写平台标准版:FILE_PATH 三层兜底链、随机存储名、平台 15 项黑名单+svg、
  ≤50MB(并按 P12 显式配 spring.servlet.multipart,默认 1MB 会挡掉合法附件)
- 数据面刻意不建列表/详情/统计/导出读接口:四条读出路径必须共用 T4 的 scope 构造器,
  现在写一个无范围过滤的详情接口就是给 T4 留后门
- T2 已验收文件仅按裁决改动:FormAdminGuard 收紧为 {2,7}(P21)并加 canManage;
  FormDefService 抽出 resolvePublished/新增 pinnedForData(数据面溯源含软删表单);
  FormRepository 加数据面取域查询;application.yml 只加 multipart 两行

自验:mvn -B -o clean package 绿(121 测试,本轮新增 54 条含 39 条打真实 MySQL,
V32 并发面用双线程非回滚 IT 取证),逐 V 证据与问题单 P23~P32 见 docs/T3-report.md
This commit is contained in:
chenxing
2026-10-14 16:50:07 +08:00
parent 90f6b0a767
commit bff0fb15fa
33 changed files with 3956 additions and 22 deletions
@@ -0,0 +1,346 @@
package com.yunhai.formcenter.common;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 提交值校验(契约 §3 值形状 + §5 校验条款;方案 §4.3②)。
*
* <p>校验基准是<b>答卷钉住的那个版本</b>的 schema({@code answer.version}),
* 而不是表单的最新版本——升版窗口旧版仍可填(P20),新 schema 的字段不会凭空出现在旧数据上,
* 旧 schema 已删的字段也不会被新校验误拒(编辑回显同口径,方案 §5.4"编辑按钉住 version 校验")。</p>
*
* <p>与 {@link FormSchemaValidator} 的分工:那个管<b>定义</b>(发布期,白名单/类型/options 形状),
* 这个管<b>数据</b>(提交期,值形状与取值域)。两者都只用声明式规则,
* 严禁存库 JS / eval(旧 CustomForm 反面先例)。</p>
*
* <p>取值域(P18/P19 定稿):内联 options 的 value 集合 ∪ 字典条目的 value 集合,
* 其中<b>字典侧必须走 {@code FormDictService#allowedValues}(含软删条目)</b>——
* 旧答卷原样重存的放行口径(契约 §2.1)。</p>
*/
public final class FormValueValidator {
/** 全局兜底:单文件 ≤50MB(方案 §7.7) */
public static final long MAX_FILE_SIZE_BYTES = 50L * 1024 * 1024;
/** 全局兜底:每字段 ≤9 个附件(字段级 maxFiles 只能更严,不能放宽) */
public static final int MAX_FILES_PER_FIELD = 9;
private static final Pattern DATE_STRICT = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$");
/** uuuu + STRICT:拒绝 2023-02-30 这类"格式对但日期不存在"的值(yyyy 在 STRICT 下会被宽松处理) */
private static final DateTimeFormatter DATE_PARSER =
DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT);
private static final Set<String> ATTACHMENT_TYPES = new HashSet<>(Arrays.asList("upload", "image"));
private static final Set<String> TEXT_TYPES = new HashSet<>(Arrays.asList("text", "textarea", "phone"));
private static final Set<String> SINGLE_OPTION_TYPES = new HashSet<>(Arrays.asList("select", "radio"));
private static final Set<String> MULTI_OPTION_TYPES = new HashSet<>(Arrays.asList("checkbox", "multiselect"));
private static final Set<String> ATTACHMENT_PROPS = new HashSet<>(Arrays.asList("id", "fileName", "key"));
private FormValueValidator() {
}
/** 字典候选解析(由服务层注入,保持本类可脱库单测) */
public interface DictCandidates {
Set<String> valuesOf(String dictCode);
}
/**
* 校验提交值。
*
* @param valuesJson 提交的 {@code {key:value}} 文档
* @param schemaJson 钉住版本的 schema 原文(未经字典展开)
* @return 原样返回 {@code valuesJson}(校验不改写用户数据;存储即所提交)
* @throws BizException PARAM_ERROR——msg 指明字段与原因(契约 §6 定稿映射)
*/
public static String validate(String valuesJson, String schemaJson, DictCandidates dictCandidates) {
Map<String, JsonNode> fields = FormSchemaValidator.fieldsByKeys(schemaJson);
JsonNode data = FormSchemaValidator.parse(
valuesJson == null || valuesJson.trim().isEmpty() ? "{}" : valuesJson,
"表单数据不是合法 JSON 对象");
if (!data.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "表单数据必须是 JSON 对象");
}
rejectUnknownKeys(data, fields);
for (Map.Entry<String, JsonNode> entry : fields.entrySet()) {
JsonNode field = entry.getValue();
String key = entry.getKey();
JsonNode value = data.get(key);
if (isEmptyValue(value)) {
if (isRequired(field)) {
throw fail(field, key, "为必填项,未填写");
}
continue;
}
validateValue(field, key, value, dictCandidates);
}
return valuesJson;
}
/** 提交值里引用的附件 id(服务层据此做同校归属校验,防把别人的 key 塞进自己的答卷) */
public static List<Long> attachmentFileIds(String valuesJson, String schemaJson) {
List<Long> ids = new ArrayList<>();
Map<String, JsonNode> fields = FormSchemaValidator.fieldsByKeys(schemaJson);
JsonNode data = FormSchemaValidator.parse(
valuesJson == null || valuesJson.trim().isEmpty() ? "{}" : valuesJson, "表单数据不是合法 JSON 对象");
if (!data.isObject()) {
return ids;
}
for (Map.Entry<String, JsonNode> entry : fields.entrySet()) {
if (!ATTACHMENT_TYPES.contains(entry.getValue().path("type").asText())) {
continue;
}
JsonNode value = data.get(entry.getKey());
if (value == null || !value.isArray()) {
continue;
}
for (JsonNode item : value) {
JsonNode id = item.get("id");
if (id != null && id.isIntegralNumber()) {
ids.add(id.longValue());
}
}
}
return ids;
}
// ==================== 分派 ====================
private static void validateValue(JsonNode field, String key, JsonNode value, DictCandidates dicts) {
String type = field.path("type").asText();
if (TEXT_TYPES.contains(type)) {
validateText(field, key, value);
} else if ("number".equals(type)) {
validateNumber(field, key, value);
} else if ("date".equals(type)) {
validateDate(field, key, value);
} else if (SINGLE_OPTION_TYPES.contains(type)) {
validateSingleOption(field, key, value, dicts);
} else if (MULTI_OPTION_TYPES.contains(type)) {
validateMultiOption(field, key, value, dicts);
} else if (ATTACHMENT_TYPES.contains(type)) {
validateAttachments(field, key, value);
} else {
// 二期类型(address/user/users)不可能到这里:发布期已被 PHASE1_TYPES 拦下
throw fail(field, key, "该类型一期未放行:" + type);
}
}
private static void validateText(JsonNode field, String key, JsonNode value) {
if (!value.isTextual()) {
throw fail(field, key, "必须是字符串");
}
String text = value.asText();
Integer maxLength = intVal(field, "maxLength");
if (maxLength != null && text.length() > maxLength) {
throw fail(field, key, "长度超过上限 " + maxLength + "(当前 " + text.length() + ")");
}
String pattern = text(field, "pattern");
if (pattern != null && !Pattern.compile(pattern).matcher(text).matches()) {
throw fail(field, key, "格式不正确");
}
}
/** number 值形状={@code number|null}(契约 §3:"空为 null,不是 \"\"") */
private static void validateNumber(JsonNode field, String key, JsonNode value) {
if (!value.isNumber()) {
throw fail(field, key, "必须是数字(留空请传 null,不要传空字符串)");
}
BigDecimal decimal = value.decimalValue();
Integer precision = intVal(field, "precision");
if (precision != null && decimal.scale() > precision) {
throw fail(field, key, "小数位数超过 precision=" + precision + "(当前 " + decimal.scale() + ")");
}
Double min = doubleVal(field, "min");
Double max = doubleVal(field, "max");
if (min != null && decimal.doubleValue() < min) {
throw fail(field, key, "不得小于 " + min);
}
if (max != null && decimal.doubleValue() > max) {
throw fail(field, key, "不得大于 " + max);
}
}
/** date 存储格式在契约层定死 {@code "YYYY-MM-DD"},且必须是真实存在的日期(V4 的根因) */
private static void validateDate(JsonNode field, String key, JsonNode value) {
if (!value.isTextual()) {
throw fail(field, key, "必须是 \"YYYY-MM-DD\" 字符串");
}
String text = value.asText();
if (!DATE_STRICT.matcher(text).matches()) {
throw fail(field, key, "日期格式必须为 YYYY-MM-DD(当前 " + text + ")");
}
try {
LocalDate.parse(text, DATE_PARSER);
} catch (DateTimeParseException e) {
throw fail(field, key, "日期不存在:" + text);
}
}
private static void validateSingleOption(JsonNode field, String key, JsonNode value, DictCandidates dicts) {
if (!value.isTextual()) {
throw fail(field, key, "必须是选项 value 字符串(存 value 不存 label)");
}
Set<String> candidates = candidates(field, dicts);
if (!candidates.contains(value.asText())) {
throw fail(field, key, "选项值不在候选列表内:" + value.asText());
}
}
private static void validateMultiOption(JsonNode field, String key, JsonNode value, DictCandidates dicts) {
if (!value.isArray()) {
throw fail(field, key, "必须是选项 value 数组(存 value 数组,展示顺序按 options 定义序)");
}
Set<String> candidates = candidates(field, dicts);
for (JsonNode item : value) {
if (!item.isTextual()) {
throw fail(field, key, "数组元素必须是选项 value 字符串");
}
if (!candidates.contains(item.asText())) {
throw fail(field, key, "选项值不在候选列表内:" + item.asText());
}
}
}
/**
* 附件值形状 {@code [{id, fileName, key}]}(方案 §4.3② v1.1 修订):
* 只存 id/文件名/相对 key,<b>不存 fileUrl</b>——绝对 URL 进答卷=环境耦合,换域名历史附件全断。
*/
private static void validateAttachments(JsonNode field, String key, JsonNode value) {
if (!value.isArray()) {
throw fail(field, key, "必须是附件数组 [{id,fileName,key}]");
}
Integer maxFiles = intVal(field, "maxFiles");
int limit = maxFiles != null && maxFiles > 0 ? Math.min(maxFiles, MAX_FILES_PER_FIELD) : MAX_FILES_PER_FIELD;
if (value.size() > limit) {
throw fail(field, key, "附件数超过上限 " + limit);
}
for (JsonNode item : value) {
if (!item.isObject()) {
throw fail(field, key, "附件项必须是 {id,fileName,key} 对象");
}
Iterator<String> names = item.fieldNames();
while (names.hasNext()) {
String prop = names.next();
if (!ATTACHMENT_PROPS.contains(prop)) {
throw fail(field, key, "附件项含契约外字段(只允许 id/fileName/key):" + prop);
}
}
JsonNode id = item.get("id");
if (id == null || !id.isIntegralNumber() || id.longValue() <= 0) {
throw fail(field, key, "附件项 id 必须是正整数(中心上传接口返回值)");
}
String fileName = text(item, "fileName");
if (fileName == null || fileName.trim().isEmpty()) {
throw fail(field, key, "附件项缺少 fileName");
}
String storeKey = text(item, "key");
if (storeKey == null || storeKey.trim().isEmpty()) {
throw fail(field, key, "附件项缺少 key");
}
assertRelativeKey(field, key, storeKey);
}
}
/** key 必须是相对存储路径:拒绝绝对 URL / 协议头 / 绝对路径 / 上跳(V24"答卷值中不含绝对 URL") */
private static void assertRelativeKey(JsonNode field, String key, String storeKey) {
String lower = storeKey.toLowerCase();
if (lower.contains("://") || lower.startsWith("//") || lower.startsWith("/") || storeKey.contains("..")
|| lower.startsWith("file:")) {
throw fail(field, key, "附件 key 必须是相对存储路径,不接受绝对 URL 或跨目录路径:" + storeKey);
}
}
// ==================== 取值域与工具 ====================
/** 候选值集合:内联 options 的 value,或字典引用解析结果(含软删条目,P18/P19) */
private static Set<String> candidates(JsonNode field, DictCandidates dicts) {
String dictCode = FormSchemaValidator.dictRefCode(field);
if (dictCode != null) {
Set<String> resolved = dicts == null ? null : dicts.valuesOf(dictCode);
if (resolved == null || resolved.isEmpty()) {
// 放行集为空=字典已删或从未建:宁可拒提交,也不放行任意值(契约 §2.1 的伪造 value 拦截)
throw fail(field, field.path("key").asText(), "引用的字典不可用:" + dictCode);
}
return resolved;
}
Set<String> values = new LinkedHashSet<>();
JsonNode options = field.get("options");
if (options != null && options.isArray()) {
for (JsonNode option : options) {
String value = text(option, "value");
if (value != null) {
values.add(value);
}
}
}
return values;
}
private static void rejectUnknownKeys(JsonNode data, Map<String, JsonNode> fields) {
List<String> unknown = new ArrayList<>();
Iterator<String> names = data.fieldNames();
while (names.hasNext()) {
String name = names.next();
if (!fields.containsKey(name)) {
unknown.add(name);
}
}
if (!unknown.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "表单数据含未知字段:" + String.join(",", unknown));
}
}
/** 空值口径(与 svc-hall 同源):null / 空串(含纯空白)/ 空数组 */
private static boolean isEmptyValue(JsonNode value) {
if (value == null || value.isNull()) {
return true;
}
if (value.isTextual()) {
return value.asText().trim().isEmpty();
}
return value.isArray() && value.size() == 0;
}
private static boolean isRequired(JsonNode field) {
JsonNode required = field.get("required");
return required != null && required.isBoolean() && required.booleanValue();
}
private static BizException fail(JsonNode field, String key, String reason) {
String label = text(field, "label");
return new BizException(ErrorCode.PARAM_ERROR,
"字段「" + (label == null || label.trim().isEmpty() ? key : label) + "」" + reason
+ "(" + key + ")");
}
private static String text(JsonNode node, String prop) {
JsonNode value = node.get(prop);
return value == null || value.isNull() ? null : value.asText();
}
private static Integer intVal(JsonNode node, String prop) {
JsonNode value = node.get(prop);
return value != null && value.isIntegralNumber() ? value.intValue() : null;
}
private static Double doubleVal(JsonNode node, String prop) {
JsonNode value = node.get(prop);
return value != null && value.isNumber() ? value.asDouble() : null;
}
}
@@ -0,0 +1,80 @@
package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.service.AnswerAdminService;
import com.yunhai.formcenter.service.FormAdminGuard;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
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 java.util.List;
/**
* 答卷管理端({@code /api/form/admin/data/**},方案 §5.4 生命周期表的管理动作)。
*
* <p>这里<b>没有</b>编辑/删除的"填表人版本"接口——填表人可用的动作只有提交(V29"无编辑/撤回接口可用"
* 的实现形态就是<b>不存在这些端点</b>,加上管理权守卫给出的 403)。改状态同理:填表人在接口层即被拒(V14)。</p>
*
* <p>也不提供列表/详情读接口:那四条读出路径(列表/详情/统计/导出)必须共用同一个 scope 构造器
* (方案 §6.4 红线,V27/V30),归 T4 一次建成,避免本轮先写一个没有范围过滤的读接口、T4 再补一遍。</p>
*
* <p>{@code schId} 参数只是超管跨校的目标域(校级传别的值即拒,同 {@link FormAdminController} 口径);
* 答卷定位按 {@code (域, uuid)},跨校答卷查不到。</p>
*/
@RestController
@RequestMapping("/api/form/admin/data")
@RequiredArgsConstructor
public class FormAnswerAdminController {
private final FormAdminGuard adminGuard;
private final AnswerAdminService adminService;
/** 编辑值文档:按该答卷钉住的 version 校验(V8 同族),写 answer_op_log,索引同步重建 */
@PostMapping("/edit")
public void edit(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody AnswerModels.EditRequest body) {
Long domain = adminGuard.requireAdmin(user, schId);
adminService.edit(domain, user.getId(), body);
}
/** 软删(唯一删除形态,无物理删除接口——V30);同步删索引行并释放限次名额 */
@PostMapping("/softDelete")
public void softDelete(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("answerId") String answerId) {
Long domain = adminGuard.requireAdmin(user, schId);
adminService.softDelete(domain, user.getId(), answerId);
}
/** 改状态:填表人不可用(V14);写 status_log,系统内无任何转移规则校验(V15) */
@PostMapping("/changeStatus")
public void changeStatus(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody AnswerModels.ChangeStatusRequest body) {
Long domain = adminGuard.requireAdmin(user, schId);
adminService.changeStatus(domain, user.getId(), body);
}
/** 批量软删:逐条单条校验与留痕,每条独立事务,结果逐条返回 */
@PostMapping("/batch/softDelete")
public List<AnswerModels.BatchItemView> batchSoftDelete(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody AnswerModels.BatchRequest body) {
Long domain = adminGuard.requireAdmin(user, schId);
return adminService.batchSoftDelete(domain, user.getId(), body);
}
/** 批量改状态:同上 */
@PostMapping("/batch/changeStatus")
public List<AnswerModels.BatchItemView> batchChangeStatus(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody AnswerModels.BatchRequest body) {
Long domain = adminGuard.requireAdmin(user, schId);
return adminService.batchChangeStatus(domain, user.getId(), body);
}
}
@@ -0,0 +1,50 @@
package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.service.AnswerService;
import com.yunhai.formcenter.service.FormFileService;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
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;
/**
* 填报面(用户面,CAS 会话):提交与附件上传。
*
* <p>契约纪律:</p>
* <ul>
* <li><b>不存在 oapi 代提交</b>(方案 §2.3 边界):提交只能由浏览器直连 CAS 会话发起,
* 身份({@code sch_id}/{@code submitter_id})一律会话推导,请求体里没有这两个字段(V17);</li>
* <li><b>提交返回 200 + {@code {answerId, answerNo}}</b>:幂等键命中同样是 200(不是 409,契约 §5),
* {@code answerId} 是答卷 uuid——前端"提交后跳结果页/{@code {answerId}} 占位回传"用的就是它(契约 §7);</li>
* <li><b>上传只返回 {@code {id, fileName, key}}</b>:无 fileUrl(V24,绝对 URL 由渲染期拼
* {@code FILE_WEB_PATH})。</li>
* </ul>
*/
@RestController
@RequestMapping("/api/form")
@RequiredArgsConstructor
public class FormAnswerController {
private final AnswerService answerService;
private final FormFileService fileService;
/** 提交答卷(契约 §5 端点) */
@PostMapping("/submit")
public AnswerModels.SubmitView submit(BoaUser user, @RequestBody AnswerModels.SubmitRequest body) {
return answerService.submit(user, body);
}
/**
* 附件上传(单文件)。每字段 ≤9 个的上限在提交值校验里判(契约 §3 值是数组,一次提交才看得全);
* 单文件 ≤50MB 与扩展名黑名单在这里判(容器侧上限见 application.yml 的 spring.servlet.multipart,P12)。
*/
@PostMapping("/upload")
public AnswerModels.FileView upload(BoaUser user, @RequestParam("file") MultipartFile file) {
return fileService.upload(user, file);
}
}
@@ -0,0 +1,90 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 答卷(契约 §5/§10,方案 §5.1/§5.2):一答卷一行(旧系统 EAV 每字段一行的反面)。
*
* <p>三条钉死的事实:</p>
* <ul>
* <li><b>版本钉住</b>:{@code (sch_id, app_code, code, version)} 是提交时解析到的版本冗余下来
* (方案 §5.2,svc-hall {@code ServiceApply} 先例);升版窗口旧版仍可填(P20),
* 在途提交天然按旧版落地,编辑/详情/导出也都按这一行列语义走。</li>
* <li><b>身份三键</b>(方案 §6.3):{@code submitter_id}=谁提交、{@code subject_id}=关于谁(可空)、
* {@code sch_id}=会话校,<b>全部会话推导不可外部传入</b>(契约 §5、V17)。</li>
* <li><b>幂等键是库级唯一</b>:防双击/重放只有 DB 唯一索引靠得住(方案 §5.1),
* 与"限一次"({@code fill_quota})是两个机制——幂等防重复提交,限次防多次提交。</li>
* </ul>
*
* <p>{@code values_json} 用 MEDIUMTEXT:TEXT 的 64KB 会被长文本答卷顶爆(方案 §5.1)。
* 值形状见契约 §3,其中附件只存 {@code {id,fileName,key}}(key=相对路径,绝不含绝对 URL)。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "answer",
uniqueConstraints = {
@UniqueConstraint(name = "uk_answer_uuid", columnNames = "uuid"),
@UniqueConstraint(name = "uk_answer_idem", columnNames = "idempotency_key")
},
indexes = {
@Index(name = "idx_answer_form", columnList = "sch_id, code, status, id"),
@Index(name = "idx_answer_submitter", columnList = "sch_id, submitter_id"),
@Index(name = "idx_answer_subject", columnList = "subject_id")
})
public class Answer extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 数据面运行时身份(详情 URL / 嵌入地址 / L2 引用都用它,契约 §1) */
@Column(name = "uuid", nullable = false, length = 32, updatable = false)
private String uuid;
@Column(name = "sch_id", nullable = false, updatable = false)
private Long schId;
@Column(name = "app_code", nullable = false, length = 64, updatable = false)
private String appCode;
@Column(name = "code", nullable = false, length = 64, updatable = false)
private String code;
/** 钉住的 schema 版本(提交时解析到的发布版本,之后不随升版改变) */
@Column(name = "version", nullable = false, updatable = false)
private Integer version;
/** 谁提交的(CAS 会话推导) */
@Column(name = "submitter_id", nullable = false, updatable = false)
private Integer submitterId;
/** 关于谁(管理员/教师代填时=被代填人;普通用户只能=自己,方案 §6.3) */
@Column(name = "subject_id")
private Integer subjectId;
/** 轻状态位:字符串 code,取值域=表单状态定义(一等列,方案 §5.3) */
@Column(name = "status", nullable = false, length = 64)
private String status;
/** 值文档 {@code {key:value}}(契约 §3 值形状) */
@Column(name = "values_json", nullable = false, columnDefinition = "mediumtext")
private String valuesJson;
/** 报名号 {@code {code}-{YYYYMMDD}-{当日序号}};表单级开关默认关(契约 §5) */
@Column(name = "answer_no", length = 64)
private String answerNo;
/** 客户端幂等键(库级唯一) */
@Column(name = "idempotency_key", nullable = false, length = 64, updatable = false)
private String idempotencyKey;
/** 软删=唯一删除形态(无物理删除接口,P22/V30);同步删 answer_index 行、释放 fill_quota 名额 */
@Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint")
private Integer isDeleted = 0;
}
@@ -0,0 +1,77 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 索引值表(契约 §10 / 方案 §5.2):一答卷的<b>一个 searchable 字段值</b>一行,
* 同时支撑筛选与分组统计;零 DDL 加字段、类型正确({@code v_date} 区间、{@code v_num} 数值)。
*
* <p>四条纪律:</p>
* <ol>
* <li><b>只写显式 {@code searchable} 的字段</b>(V3/V9)——未声明的字段不进索引,也就不进筛选候选;</li>
* <li><b>多值字段(checkbox/multiselect)一值一行</b>,等值筛选才命中;</li>
* <li>{@code v_str} 定 varchar(191):utf8mb4 下 767 字节索引上限,超长值<b>截断入索引</b>
* (筛选语义=前缀匹配,契约 §10 已认可);</li>
* <li><b>生命周期</b>:新版本删字段<b>不清</b>历史索引行(否则 V8"历史仍可筛"失效);
* 只有答卷软删时同步删行({@code idx_ai_answer} 就是为这条删路径建的)。</li>
* </ol>
*
* <p>列选择按该答卷<b>钉住版本</b>的 schema 判定:只有该版本声明 searchable 的字段才有行。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "answer_index",
indexes = {
@Index(name = "idx_ai_str", columnList = "sch_id, code, field_key, v_str, answer_id"),
@Index(name = "idx_ai_num", columnList = "sch_id, code, field_key, v_num, answer_id"),
@Index(name = "idx_ai_date", columnList = "sch_id, code, field_key, v_date, answer_id"),
@Index(name = "idx_ai_answer", columnList = "answer_id")
})
public class AnswerIndex extends BaseEntity {
private static final long serialVersionUID = 1L;
/** utf8mb4 下 InnoDB 索引列上限 767 字节 = 191 字符(契约 §10 定死) */
public static final int MAX_V_STR_LEN = 191;
@Column(name = "answer_id", nullable = false, updatable = false)
private Long answerId;
@Column(name = "sch_id", nullable = false, updatable = false)
private Long schId;
@Column(name = "code", nullable = false, length = 64, updatable = false)
private String code;
@Column(name = "field_key", nullable = false, length = 64, updatable = false)
private String fieldKey;
/** 文本/选项类:等值与前缀模糊;超长截断 */
@Column(name = "v_str", length = MAX_V_STR_LEN)
private String vStr;
/** 数值类:区间与分组统计;DECIMAL(20,6) 与 precision ≤6 的白名单约束同口径 */
@Column(name = "v_num", precision = 20, scale = 6)
private BigDecimal vNum;
/** 日期类:{@code YYYY-MM-DD} 落 DATE 列(禁字典序比日期,方案 §4.3④/V4) */
@Column(name = "v_date")
private LocalDate vDate;
/** 超长截断只在入索引这一处发生,值文档本身不动(三态空值与降级显示读的是 values_json) */
public static String truncateForIndex(String value) {
if (value == null) {
return null;
}
return value.length() <= MAX_V_STR_LEN ? value : value.substring(0, MAX_V_STR_LEN);
}
}
@@ -0,0 +1,59 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
/**
* 操作留痕(契约 §10 / 方案 §5.4):管理员编辑、软删、批量操作逐条一行。
*
* <p>批量与单条<b>同形</b>(方案 §5.4"批量逐条走单条校验与留痕"):一条答卷一行日志、同一操作人、
* 同一时间口径;op 用 {@code batch_*} 前缀标出来自批量通道(契约 §10 注释的
* {@code edit/soft_delete/batch_…} 形态),批次上下文写在 summary 里。</p>
*
* <p>填表人没有编辑/撤回接口可用(V29)——所以本表里不会出现"填表人自己 edit"的行。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "answer_op_log", indexes = @Index(name = "idx_ol_answer", columnList = "answer_id"))
public class AnswerOpLog extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 管理员原地修正值文档(按钉住版本校验,方案 §5.4) */
public static final String OP_EDIT = "edit";
/** 软删(唯一删除形态) */
public static final String OP_SOFT_DELETE = "soft_delete";
/** 改状态(非填表人;细节同时进 status_log) */
public static final String OP_CHANGE_STATUS = "change_status";
/** 批量软删中的某一条 */
public static final String OP_BATCH_SOFT_DELETE = "batch_soft_delete";
/** 批量改状态中的某一条 */
public static final String OP_BATCH_CHANGE_STATUS = "batch_change_status";
@Column(name = "answer_id", nullable = false, updatable = false)
private Long answerId;
@Column(name = "op", nullable = false, length = 32, updatable = false)
private String op;
@Column(name = "operator_id", nullable = false, updatable = false)
private Integer operatorId;
/** 摘要(改了哪些字段/批次上下文等),≤500 */
@Column(name = "summary", length = 500)
private String summary;
public static String summarize(String summary) {
if (summary == null || summary.trim().isEmpty()) {
return null;
}
String clean = summary.trim();
return clean.length() > 500 ? clean.substring(0, 500) : clean;
}
}
@@ -0,0 +1,36 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 限填登记(契约 §10 / 方案 §5.4 v1.4 并发兜底):{@code (form_id, limit_key)} 唯一键,
* <b>提交事务内插入、撞键即拒</b>——两个并发提交不可能都通过 count 检查。
*
* <ul>
* <li>{@code limit_key} = {@code coalesce(subject_id, submitter_id)} 字符串化({@code FormRuntimeRules#quotaKey});</li>
* <li><b>软删答卷同步删登记行 = 释放名额</b>(管理员删错单后学生可重填,方案 §5.4 点名"第一天就会发生的场景");</li>
* <li>不限次表单不写本表(无名额可占)。</li>
* </ul>
*/
@Getter
@Setter
@Entity
@Table(name = "fill_quota",
uniqueConstraints = @UniqueConstraint(name = "uk_quota", columnNames = {"form_id", "limit_key"}))
public class FillQuota extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 表单头表 id(限次是表单级配置,跨版本共享名额——升版不清零,语义与"报名"一致) */
@Column(name = "form_id", nullable = false, updatable = false)
private Long formId;
@Column(name = "limit_key", nullable = false, length = 128, updatable = false)
private String limitKey;
}
@@ -0,0 +1,57 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 中心上传的附件记录(平台标准版上传模块的落档行)。
*
* <p><b>为什么需要这张表</b>:契约 §3 把 image/upload 的值形状定死为
* {@code [{id, fileName, key}]}——那个 {@code id} 必须是中心侧可定位的附件行主键,
* 否则"值里存 id"无从解析(管理员编辑重存、附件审计、后续 GC 都按它)。
* 契约 §10 九表草案未列本表 → 见问题单(本轮按值契约补齐最小落档结构,不扩语义)。</p>
*
* <p><b>环境解耦纪律</b>(方案 §4.3② v1.1 修订):只存 {@code store_key}=相对存储路径,
* <b>绝不存绝对 URL</b>——{@code FILE_WEB_PATH} 是环境注入项,绝对地址进答卷=换域名历史附件全断。
* 访问地址由渲染期拼接;一期显式假设"key=不可猜测随机串即访问控制"(方案 §7.7,鉴权接口二期)。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "form_file",
uniqueConstraints = @UniqueConstraint(name = "uk_form_file_key", columnNames = "store_key"),
indexes = @Index(name = "idx_form_file_sch", columnList = "sch_id, id"))
public class FormFile extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 上传时的会话校(归属留档;跨校不可读由读路径负责) */
@Column(name = "sch_id", nullable = false, updatable = false)
private Long schId;
/** 用户上传时的原始文件名(展示用;存储名另生成,防路径穿越与同名覆盖) */
@Column(name = "file_name", nullable = false, length = 255)
private String fileName;
/**
* 相对存储 key({@code FILE_PATH} 下),形如 {@code form/2026/10/{32位随机}.{ext}}。
* 列宽取 191:本列上有唯一键,utf8mb4 下 191×4=764 字节刚好落在 InnoDB 单列索引 767 上限内
* (方案 §7.7 要求 MySQL 5.7 兼容;实际 key 只有 ~50 字符,191 已留足余量)。
*/
@Column(name = "store_key", nullable = false, length = 191, updatable = false)
private String storeKey;
/** 字节数(上限校验在上传时执行;留档供后续配额/GC 判断) */
@Column(name = "file_size", nullable = false)
private Long fileSize;
/** 归一化小写扩展名(不含点),黑名单判定结果留档 */
@Column(name = "ext", nullable = false, length = 16)
private String ext;
}
@@ -0,0 +1,35 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
/**
* 状态留痕(契约 §10 / 方案 §5.3④):改状态写一行,一期不做通知。
*
* <p>红线复述:状态是"<b>标记</b>"不是"流转"——本表只记"谁在什么时候把这条答卷标成了什么",
* <b>系统里不存在任何转移规则校验</b>(V15)。需要流转走流程中心。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "status_log", indexes = @Index(name = "idx_sl_answer", columnList = "answer_id"))
public class StatusLog extends BaseEntity {
private static final long serialVersionUID = 1L;
@Column(name = "answer_id", nullable = false, updatable = false)
private Long answerId;
/** 状态 code(取值域=表单 status_defs,含已废弃 code 的历史痕迹) */
@Column(name = "status_code", nullable = false, length = 64, updatable = false)
private String statusCode;
/** 操作人(CAS 会话用户 id;填表人改状态在接口层即被拒,方案 §5.3③/V14) */
@Column(name = "operator_id", nullable = false, updatable = false)
private Integer operatorId;
}
@@ -0,0 +1,113 @@
package com.yunhai.formcenter.model;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 答卷域出入参模型(契约 §3/§5/§7 的响应侧形状)。
*
* <p>三条对外纪律:</p>
* <ul>
* <li><b>身份字段不进请求</b>:{@code schId/submitterId} 一律会话推导,DTO 里没有这两个字段,
* 客户端即使传了也被 Spring 忽略(Boot 默认 {@code FAIL_ON_UNKNOWN_PROPERTIES=false})——
* 这是 V17"接口不接受外部传入"的实现形态;</li>
* <li><b>数据面身份只用 uuid</b>:请求与响应都出现 {@code answerId}=答卷 uuid,
* 库内 {@code id} 不出现在任何外层(契约 §1/V5);</li>
* <li><b>附件返回值只含 {@code {id, fileName, key}}</b>:没有 fileUrl 字段——
* 绝对 URL 由渲染期拼 {@code FILE_WEB_PATH}(方案 §4.3②,V24)。</li>
* </ul>
*/
public final class AnswerModels {
private AnswerModels() {
}
/** 提交请求(契约 §5 请求体的字段闭集) */
@Getter
@Setter
public static class SubmitRequest {
/** 表单 code(入口寻址用的那一个,契约 §1) */
private String formCode;
/** 归属应用,缺省 form-center;快应用填报页传快应用 app_code */
private String appCode;
/** 客户端生成的幂等键(uuid 形态;uk_answer_idem) */
private String idempotencyKey;
/** 可选:关于谁。普通用户只能=自己;有管理权者可指定他人(方案 §6.3/V17) */
private Integer subjectId;
/** 值文档 {@code {key:value}},形状见契约 §3 */
private JsonNode values;
}
/** 提交响应(契约 §5:{@code {answerId, answerNo}};answerNo 在未开启报名号时为 null) */
@Getter
@Setter
public static class SubmitView {
private String answerId;
private String answerNo;
/** 幂等键命中原答卷时为 true(仍返回 200,契约 §5"不是 409") */
private Boolean replayed;
}
/** 上传返回值:字段闭集就是契约 §3 的 {@code {id, fileName, key}} 三件 */
@Getter
@Setter
public static class FileView {
private Long id;
private String fileName;
private String key;
}
/** 管理员原地修正值文档(按答卷钉住的 version 校验;不改状态、不改身份) */
@Getter
@Setter
public static class EditRequest {
private String answerId;
private JsonNode values;
/** 改动说明,进 answer_op_log.summary */
private String summary;
}
/** 改状态(非填表人;写 status_log,无任何转移规则校验——V15) */
@Getter
@Setter
public static class ChangeStatusRequest {
private String answerId;
private String status;
}
/** 批量:软删或改状态。逐条走单条校验与留痕(方案 §5.4) */
@Getter
@Setter
public static class BatchRequest {
private List<String> answerIds;
/** 批量改状态时必填;批量软删时忽略 */
private String status;
}
/** 批量逐条结果(一条一行,失败不影响其它条——每条独立事务) */
@Getter
@Setter
public static class BatchItemView {
private String answerId;
private Boolean success;
private String msg;
public static BatchItemView ok(String answerId) {
BatchItemView item = new BatchItemView();
item.setAnswerId(answerId);
item.setSuccess(true);
return item;
}
public static BatchItemView fail(String answerId, String msg) {
BatchItemView item = new BatchItemView();
item.setAnswerId(answerId);
item.setSuccess(false);
item.setMsg(msg);
return item;
}
}
}
@@ -0,0 +1,24 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.AnswerIndex;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* 索引值表仓库。删除只有两个场景(方案 §5.2 索引行生命周期):
* ①管理员编辑后重建;②答卷软删时同步删。
* <b>不存在"按新版本 schema 清理旧字段行"的方法</b>——那会打掉 V8"删字段后历史仍可筛"。
*/
public interface AnswerIndexRepository extends JpaRepository<AnswerIndex, Long> {
List<AnswerIndex> findByAnswerIdOrderByIdAsc(Long answerId);
/** 重建/软删清理:bulk delete(方案 §7.7 全量覆盖式保存用 @Modifying) */
@Modifying
@Query("delete from AnswerIndex x where x.answerId = :answerId")
int deleteByAnswerId(@Param("answerId") Long answerId);
}
@@ -0,0 +1,15 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.AnswerOpLog;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 操作留痕仓库:只增不改不删。批量与单条同形——<b>一条答卷一行日志</b>(方案 §5.4),
* 批量通道只是 op 值不同({@code batch_*})并在 summary 里带批次上下文。
*/
public interface AnswerOpLogRepository extends JpaRepository<AnswerOpLog, Long> {
List<AnswerOpLog> findByAnswerIdOrderByIdAsc(Long answerId);
}
@@ -0,0 +1,34 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.Answer;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
/**
* 答卷仓库。数据面一律按 {@code uuid + sch_id} 定位(契约 §1:id 绝不外露、uuid 不可枚举)。
* 检索/统计/导出的读路径在 T4(四读出路径共用同一 scope 构造器,方案 §6.4 红线),
* 本轮只放提交与管理操作必需的查询——<b>不提供任何物理删除方法</b>(P22/V30)。
*/
public interface AnswerRepository extends JpaRepository<Answer, Long> {
/** 幂等键定位(uk_answer_idem,库级唯一) */
Optional<Answer> findByIdempotencyKey(String idempotencyKey);
Optional<Answer> findByUuidAndSchId(String uuid, Long schId);
/**
* 报名号当日序号:统计该 (校, app_code, code) 下以 {@code {code}-{YYYYMMDD}-} 开头的行数(<b>含软删行</b>——
* 软删不回收号,否则同日重填会出现两个相同报名号)。前缀由服务端拼接,参数绑定进 JPQL。
*/
@Query("select count(a) from Answer a where a.schId = :schId and a.appCode = :appCode and a.code = :code "
+ "and a.answerNo like :prefix")
long countTodayAnswerNo(@Param("schId") Long schId, @Param("appCode") String appCode,
@Param("code") String code, @Param("prefix") String prefix);
/** 测试与审计取证:某表单下的全部答卷(按 id 升序) */
List<Answer> findBySchIdAndAppCodeAndCodeOrderByIdAsc(Long schId, String appCode, String code);
}
@@ -0,0 +1,27 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.FillQuota;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* 限填登记仓库。<b>{@code uk_quota(form_id, limit_key)} 就是并发兜底本身</b>(方案 §5.4 v1.4):
* 提交事务内插入、撞键即拒——所以这里不做"先 count 再插"的伪检查,count 检查在并发下靠不住。
*/
public interface FillQuotaRepository extends JpaRepository<FillQuota, Long> {
/** 名额是否已被占(用于给出"您已提交过"的可读提示;真正的拒绝仍由插入撞键完成) */
boolean existsByFormIdAndLimitKey(Long formId, String limitKey);
/** 某表单的名额占用清单(管理端"已填人数"与运维排查用) */
List<FillQuota> findByFormId(Long formId);
/** 软删答卷=释放名额(P22/方案 §5.4:管理员删错单后学生可重填) */
@Modifying
@Query("delete from FillQuota q where q.formId = :formId and q.limitKey = :limitKey")
int releaseQuota(@Param("formId") Long formId, @Param("limitKey") String limitKey);
}
@@ -0,0 +1,16 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.FormFile;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* 附件落档仓库。一期只提供两件事:建行(上传成功即落档)与<b>同校归属校验</b>
* (提交值里引用的附件 id 必须是本校上传过的行,防止把别人的 key 塞进自己的答卷)。
* 没有删除接口——附件物理清理由二期(方案 U11 族)处理。
*/
public interface FormFileRepository extends JpaRepository<FormFile, Long> {
Optional<FormFile> findByIdAndSchId(Long id, Long schId);
}
@@ -29,10 +29,20 @@ public interface FormRepository extends JpaRepository<Form, Long> {
@Query("select f from Form f where f.schId = :schId and f.isDeleted = 0 order by f.updateTime desc, f.id desc")
List<Form> findLiveBySch(@Param("schId") Long schId);
/** resolve 取域:会话校 ∪ 平台级(契约 §1 / 方案 §4.1 校域不变式) */
/** resolve 取域:会话校 ∪ 平台级(契约 §1 / 方案 §4.1 校域不变式)——填报入口只认未删表单 */
@Query("select f from Form f where f.code = :code and f.appCode = :appCode "
+ "and f.schId in (:schIds) and f.isDeleted = 0")
List<Form> findResolveCandidates(@Param("code") String code,
@Param("appCode") String appCode,
@Param("schIds") Collection<Long> schIds);
/**
* 数据面取域(T3 答卷溯源):同一条遮蔽规则,但<b>不排除软删表单</b>——
* 表单只有软删、没有物理删除(方案 §5.4),其历史答卷仍要可按钉住版本校验与溯源,
* 否则"删掉表单"会连带把存量数据变成无法管理的孤儿。
*/
@Query("select f from Form f where f.code = :code and f.appCode = :appCode and f.schId in (:schIds)")
List<Form> findDataDomainCandidates(@Param("code") String code,
@Param("appCode") String appCode,
@Param("schIds") Collection<Long> schIds);
}
@@ -0,0 +1,15 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.StatusLog;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 状态留痕仓库:只增、不改、不删(痕迹是审计资产,与 {@code answer_index} 的"软删同步删行"不同)。
* 改状态一律写这里,系统内不存在任何转移规则校验(V15)。
*/
public interface StatusLogRepository extends JpaRepository<StatusLog, Long> {
List<StatusLog> findByAnswerIdOrderByIdAsc(Long answerId);
}
@@ -0,0 +1,231 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.common.FormRuntimeRules;
import com.yunhai.formcenter.common.FormStatusDefs;
import com.yunhai.formcenter.common.FormValueValidator;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.AnswerOpLog;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.StatusLog;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.repository.AnswerOpLogRepository;
import com.yunhai.formcenter.repository.AnswerRepository;
import com.yunhai.formcenter.repository.FillQuotaRepository;
import com.yunhai.formcenter.repository.FormFileRepository;
import com.yunhai.formcenter.repository.StatusLogRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.ArrayList;
import java.util.List;
/**
* 答卷管理操作(契约 §5/§10,方案 §5.4 生命周期表"谁碰、怎么碰"那一列的落地)。
*
* <ul>
* <li><b>编辑</b>:仅管理员,原地修正值文档;<b>按该答卷提交时钉住的 version 校验与渲染</b>
* (V8 同族——新 schema 的字段不会凭空出现在旧数据上,旧 schema 已删的字段也不会被新校验误拒);
* 写 {@code answer_op_log},索引同步重建。不改状态、不改身份、不看截止时间。</li>
* <li><b>软删</b>:唯一删除形态(<b>无物理删除接口</b>,V30/P22);同步删 {@code answer_index} 行
* (方案 §5.2)+ <b>释放 fill_quota 名额</b>(管理员删错单后学生可重填,方案 §5.4 点名场景);
* 留痕表不动——痕迹是审计资产。</li>
* <li><b>改状态</b>:只写 {@code status_log},<b>系统内不存在任何转移规则校验</b>(V15);
* 填表人在接口层即被拒(V14)。目标状态必须是该表单状态定义里的<b>未废弃</b> code。</li>
* <li><b>批量</b>:逐条走单条校验与留痕(方案 §5.4)——每条一个独立事务,
* 一条失败不回滚其它条,结果逐条返回。这是"批量与单条完全一致"的实现形态。</li>
* </ul>
*
* <p>权限:调用方(控制器)已用 {@code FormAdminGuard} 拿到管理权与租户域,本服务只按
* {@code (schId, answerUuid)} 定位——<b>跨校答卷根本查不到</b>,越权面在定位这一步就关死。
* 数据范围(契约 §8 矩阵/scope 构造器)属 T4,本轮不做行级过滤。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AnswerAdminService {
/** 批量单次上限(安全阈值,非契约条款:批量是一条条独立事务做的,不给一次请求挂几千条的口子) */
static final int MAX_BATCH_SIZE = 200;
private final AnswerRepository answerRepository;
private final AnswerOpLogRepository opLogRepository;
private final StatusLogRepository statusLogRepository;
private final FillQuotaRepository quotaRepository;
private final FormFileRepository fileRepository;
private final FormDefService defService;
private final FormDictService dictService;
private final AnswerIndexService indexService;
private final TransactionTemplate transactionTemplate;
// ==================== 单条 ====================
/** 管理员编辑:按钉住版本校验值文档,原地修正 + 重建索引 + 留痕(V29) */
public void edit(Long schId, Integer operatorId, AnswerModels.EditRequest req) {
transactionTemplate.executeWithoutResult(tx -> doEdit(schId, operatorId, req));
}
/** 软删:唯一删除形态;同步删索引行、释放限次名额、留痕(V30/V32 后半) */
public void softDelete(Long schId, Integer operatorId, String answerUuid) {
transactionTemplate.executeWithoutResult(tx -> doSoftDelete(schId, operatorId, answerUuid, false));
}
/** 改状态:非填表人(接口层由守卫把关,V14);写 status_log,无转移校验(V15) */
public void changeStatus(Long schId, Integer operatorId, AnswerModels.ChangeStatusRequest req) {
transactionTemplate.executeWithoutResult(tx -> doChangeStatus(schId, operatorId, req, false));
}
// ==================== 批量(逐条独立事务) ====================
public List<AnswerModels.BatchItemView> batchSoftDelete(Long schId, Integer operatorId,
AnswerModels.BatchRequest req) {
List<String> ids = requireBatchIds(req.getAnswerIds());
List<AnswerModels.BatchItemView> results = new ArrayList<>();
for (String answerUuid : ids) {
results.add(runOne(answerUuid, () -> transactionTemplate.executeWithoutResult(
tx -> doSoftDelete(schId, operatorId, answerUuid, true))));
}
return results;
}
public List<AnswerModels.BatchItemView> batchChangeStatus(Long schId, Integer operatorId,
AnswerModels.BatchRequest req) {
List<String> ids = requireBatchIds(req.getAnswerIds());
List<AnswerModels.BatchItemView> results = new ArrayList<>();
for (String answerUuid : ids) {
AnswerModels.ChangeStatusRequest one = new AnswerModels.ChangeStatusRequest();
one.setAnswerId(answerUuid);
one.setStatus(req.getStatus());
results.add(runOne(answerUuid, () -> transactionTemplate.executeWithoutResult(
tx -> doChangeStatus(schId, operatorId, one, true))));
}
return results;
}
// ==================== 实现(每条一个独立事务的执行体) ====================
private void doEdit(Long schId, Integer operatorId, AnswerModels.EditRequest req) {
Answer answer = requireLiveAnswer(schId, req.getAnswerId());
FormDefService.Published pinned = defService.pinnedForData(
answer.getSchId(), answer.getAppCode(), answer.getCode(), answer.getVersion());
String schemaJson = pinned.versionRow().getSchemaJson();
if (req.getValues() != null && !req.getValues().isNull() && !req.getValues().isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "values 必须是 JSON 对象 {key:value}");
}
String valuesJson = req.getValues() == null || req.getValues().isNull() ? "{}" : req.getValues().toString();
FormValueValidator.validate(valuesJson, schemaJson,
dictCode -> dictService.allowedValues(answer.getSchId(), dictCode));
assertAttachmentsOwned(answer.getSchId(), valuesJson, schemaJson);
answer.setValuesJson(valuesJson);
answerRepository.save(answer);
// 索引同步重建(方案 §5.4):仍按钉住版本的 searchable 声明写,不随新版本 schema 改变
indexService.rebuild(answer, schemaJson);
opLog(answer.getId(), AnswerOpLog.OP_EDIT, operatorId,
req.getSummary() == null ? "管理员编辑(钉住版本 v" + answer.getVersion() + ")"
: "编辑:钉住版本 v" + answer.getVersion() + ";" + req.getSummary());
log.info("[编辑] answer={} 按 v{} 校验通过并重建索引", answer.getUuid(), answer.getVersion());
}
private void doSoftDelete(Long schId, Integer operatorId, String answerUuid, boolean batched) {
Answer answer = requireLiveAnswer(schId, answerUuid);
answer.setIsDeleted(1);
answerRepository.save(answer);
indexService.clear(answer.getId());
// 释放名额:limit_key 与提交时同一条推导(coalesce(subject, submitter))。
// 不按"当前是否仍限次"判断——配置改回不限次后残留名额仍会挡住重填。
Long formId = defService.pinnedForData(answer.getSchId(), answer.getAppCode(), answer.getCode(),
answer.getVersion()).form().getId();
quotaRepository.releaseQuota(formId,
FormRuntimeRules.quotaKey(answer.getSubjectId(), answer.getSubmitterId()));
opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_SOFT_DELETE : AnswerOpLog.OP_SOFT_DELETE,
operatorId, batched ? "批量软删(单条同校验、单条留痕)" : "软删(无物理删除接口)");
}
private void doChangeStatus(Long schId, Integer operatorId, AnswerModels.ChangeStatusRequest req,
boolean batched) {
Answer answer = requireLiveAnswer(schId, req.getAnswerId());
String status = req.getStatus() == null ? null : req.getStatus().trim();
if (status == null || status.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "status 不能为空");
}
FormDefService.Published pinned = defService.pinnedForData(
answer.getSchId(), answer.getAppCode(), answer.getCode(), answer.getVersion());
Form form = pinned.form();
// 目标状态必须是该表单定义里的未废弃 code;已废弃 code 只用于历史回显(V13 由读路径降级显示)
boolean live = FormStatusDefs.liveDefs(form.getStatusDefs()).stream()
.anyMatch(def -> status.equals(def.getCode()));
if (!live) {
throw new BizException(ErrorCode.PARAM_ERROR,
"状态不在该表单的可用定义内(已废弃或未定义):" + status);
}
String oldStatus = answer.getStatus();
answer.setStatus(status);
answerRepository.save(answer);
StatusLog entry = new StatusLog();
entry.setAnswerId(answer.getId());
entry.setStatusCode(status);
entry.setOperatorId(operatorId);
statusLogRepository.save(entry);
// 留痕与单条完全一致(方案 §5.4):一条答卷一行;批量通道只在 op 与 summary 上标出来自批量
opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_CHANGE_STATUS : AnswerOpLog.OP_CHANGE_STATUS,
operatorId, (batched ? "批量改状态:" : "改状态:") + oldStatus + " → " + status
+ "(无转移规则校验)");
}
// ==================== 辅助 ====================
private Answer requireLiveAnswer(Long schId, String answerUuid) {
if (answerUuid == null || answerUuid.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "answerId 不能为空");
}
Answer answer = answerRepository.findByUuidAndSchId(answerUuid.trim(), schId).orElse(null);
if (answer == null || Integer.valueOf(1).equals(answer.getIsDeleted())) {
// 不存在与别校的答卷同一口径回答,不给出探测面(uuid 不可枚举,契约 §1)
throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在或已删除:" + answerUuid);
}
return answer;
}
private void assertAttachmentsOwned(Long schId, String valuesJson, String schemaJson) {
for (Long id : FormValueValidator.attachmentFileIds(valuesJson, schemaJson)) {
if (!fileRepository.findByIdAndSchId(id, schId).isPresent()) {
throw new BizException(ErrorCode.PARAM_ERROR, "附件不存在或不属于本校:id=" + id);
}
}
}
private void opLog(Long answerId, String op, Integer operatorId, String summary) {
AnswerOpLog entry = new AnswerOpLog();
entry.setAnswerId(answerId);
entry.setOp(op);
entry.setOperatorId(operatorId);
entry.setSummary(AnswerOpLog.summarize(summary));
opLogRepository.save(entry);
}
private List<String> requireBatchIds(List<String> ids) {
if (ids == null || ids.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "批量操作至少要选一条答卷");
}
if (ids.size() > MAX_BATCH_SIZE) {
throw new BizException(ErrorCode.PARAM_ERROR, "批量操作单次上限 " + MAX_BATCH_SIZE + " 条");
}
return ids;
}
/** 逐条隔离:一条失败只记录这条的失败原因,不影响其它条(方案 §5.4"批量逐条走单条校验") */
private AnswerModels.BatchItemView runOne(String answerUuid, Runnable action) {
try {
action.run();
return AnswerModels.BatchItemView.ok(answerUuid);
} catch (BizException e) {
return AnswerModels.BatchItemView.fail(answerUuid, e.getMessage());
}
}
}
@@ -0,0 +1,104 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.AnswerIndex;
import com.yunhai.formcenter.repository.AnswerIndexRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* {@code answer_index} 写入器(方案 §5.2 / 契约 §2 searchable)。
*
* <p>唯一一条判断口径:<b>只有该答卷钉住版本里显式声明 {@code searchable} 的字段才写行</b>,
* 且列按 type 归位——等值(select/radio/checkbox/multiselect→v_str)、
* 区间(date→v_date、number→v_num)、前缀模糊(text/textarea/phone→v_str,超 191 截断)。
* {@code upload/image} 没有定义检索算子(契约 §2),即便被勾了 searchable 也不写行(只 warn)。</p>
*
* <p>三个调用点:提交写入、编辑重建({@link #rebuild})、软删同步删行({@link #clear})。
* <b>不存在</b>"按新版本 schema 清理旧字段行"的入口——那会打掉 V8 的"删字段后历史仍可筛"。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AnswerIndexService {
private final AnswerIndexRepository indexRepository;
/** 提交:按钉住版本写行 */
public void write(Answer answer, String schemaJson) {
JsonNode values = FormSchemaValidator.parse(answer.getValuesJson(), "答卷值文档损坏");
Map<String, JsonNode> fields = FormSchemaValidator.fieldsByKeys(schemaJson);
for (String fieldKey : FormSchemaValidator.searchableKeys(schemaJson)) {
JsonNode field = fields.get(fieldKey);
JsonNode value = values.get(fieldKey);
if (field == null || value == null || value.isNull()) {
continue;
}
String type = field.path("type").asText();
if (isMultiValueType(type)) {
if (!value.isArray()) {
continue;
}
for (JsonNode item : value) {
if (item.isTextual()) {
indexRepository.save(row(answer, fieldKey, item.asText(), null, null));
}
}
} else if ("number".equals(type)) {
if (value.isNumber()) {
indexRepository.save(row(answer, fieldKey, null, new BigDecimal(value.asText()), null));
}
} else if ("date".equals(type)) {
if (value.isTextual()) {
indexRepository.save(row(answer, fieldKey, null, null, LocalDate.parse(value.asText())));
}
} else if ("upload".equals(type) || "image".equals(type)) {
log.warn("[索引] 字段 {} 是 {} 类型,契约未定义检索算子,不写 answer_index 行", fieldKey, type);
} else {
if (value.isTextual()) {
indexRepository.save(row(answer, fieldKey, value.asText(), null, null));
}
}
}
}
/** 编辑:全量覆盖式重建(先 bulk 删旧行,再按同一份钉住 schema 重写,方案 §5.4"索引同步重建") */
public void rebuild(Answer answer, String schemaJson) {
clear(answer.getId());
write(answer, schemaJson);
}
/** 软删答卷同步删行(方案 §5.2;idx_ai_answer 就是为这条路径建的) */
public void clear(Long answerId) {
int removed = indexRepository.deleteByAnswerId(answerId);
log.debug("[索引] answer_id={} 清理索引行 {} 条", answerId, removed);
}
public List<AnswerIndex> rowsOf(Long answerId) {
return indexRepository.findByAnswerIdOrderByIdAsc(answerId);
}
private AnswerIndex row(Answer answer, String fieldKey, String vStr, BigDecimal vNum, LocalDate vDate) {
AnswerIndex row = new AnswerIndex();
row.setAnswerId(answer.getId());
row.setSchId(answer.getSchId());
row.setCode(answer.getCode());
row.setFieldKey(fieldKey);
row.setVStr(AnswerIndex.truncateForIndex(vStr));
row.setVNum(vNum);
row.setVDate(vDate);
return row;
}
private static boolean isMultiValueType(String type) {
return "checkbox".equals(type) || "multiselect".equals(type);
}
}
@@ -0,0 +1,258 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.formcenter.common.FormRuntimeRules;
import com.yunhai.formcenter.common.FormStatusDefs;
import com.yunhai.formcenter.common.FormValueValidator;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.FillQuota;
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.repository.AnswerRepository;
import com.yunhai.formcenter.repository.FillQuotaRepository;
import com.yunhai.formcenter.repository.FormFileRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
/**
* 提交链路(契约 §5 / 方案 §5.4)。一次提交在这条流水线上依次过关:
*
* <pre>
* 钉版解析 → 截止 → 幂等命中 → subject 授权 → 值校验(按钉住版本) → 附件归属
* → 限次占位(fill_quota 撞键即拒) → 落 answer → 写 answer_index(只写 searchable)
* </pre>
*
* <p><b>为什么顺序是这样</b>:</p>
* <ul>
* <li>幂等检查必须在限次<b>之前</b>——重放的那一条本来就占着名额,先查限次会把双击重试误判成"超过次数";</li>
* <li>限次用"事务内向 {@code fill_quota} 插入、撞唯一键即拒"而不是先 count 再插
* (方案 §5.4 v1.4:两个并发提交不可能都通过 count 检查);</li>
* <li>值校验用<b>未展开</b>的 schema 原文 + 字典放行集(含软删条目),
* 而不是 resolve 的内联结果——展开后就看不出这个字段引用了哪个字典,
* "软删条目仍放行"的口径也就无法表达(契约 §2.1)。</li>
* </ul>
*
* <p><b>事务形态</b>:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。
* 目的是让"幂等键撞 uk"这种并发结局能在<b>事务外</b>重新读取原答卷并按契约返回 200+原 answer_id
* (撞键后当前事务已被标记回滚,事务内再读是不可靠的)。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AnswerService {
/** 答案号日期段格式:{@code {code}-{yyyyMMdd}-{当日序号}}(契约 §5) */
private static final DateTimeFormatter ANSWER_NO_DAY = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final String DEFAULT_APP_CODE = Form.CENTER_APP_CODE;
private final FormDefService defService;
private final FormDictService dictService;
private final FormAdminGuard adminGuard;
private final AnswerRepository answerRepository;
private final FillQuotaRepository quotaRepository;
private final FormFileRepository fileRepository;
private final AnswerIndexService indexService;
private final TransactionTemplate transactionTemplate;
/**
* 提交答卷。
*
* @param user CAS 会话用户(sch_id / submitter_id 由它推导,请求体里没有这两个字段——V17)
*/
public AnswerModels.SubmitView submit(BoaUser user, AnswerModels.SubmitRequest req) {
String idempotencyKey = requireIdempotencyKey(req.getIdempotencyKey());
try {
return transactionTemplate.execute(tx -> doSubmit(user, req, idempotencyKey));
} catch (DataIntegrityViolationException e) {
// 只剩两种可能:幂等键撞 uk(并发重放,按契约返回原答卷)或 uuid 撞库(32 位随机,实际不可能)
Answer replay = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null);
if (replay != null && belongsToCaller(replay, user)) {
log.info("[提交] 幂等键并发命中,返回原答卷 uuid={}", replay.getUuid());
return toView(replay, true);
}
throw new BizException(ErrorCode.PARAM_ERROR, "提交冲突,请稍后重试(幂等键可能已被占用)");
}
}
private AnswerModels.SubmitView doSubmit(BoaUser user, AnswerModels.SubmitRequest req, String idempotencyKey) {
Long schId = requireSessionSchId(user);
Integer submitterId = user.getId();
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());
Form form = published.form();
String schemaJson = published.versionRow().getSchemaJson();
// 2) 截止(datetime 粒度,过期接口拒绝,契约 §5)
if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) {
throw new BizException(ErrorCode.CUSTOM, "已过收集截止时间,不再接收提交:" + form.getCode());
}
// 3) 幂等命中→200+原 answer_id(在限次之前,见类注释)
Answer existing = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null);
if (existing != null) {
if (!belongsToCaller(existing, user)) {
// 不泄露他人答卷身份:键被他人占用直接判越权,不返回其 answer
throw new BizException(ErrorCode.FORBIDDEN, "幂等键已被其它账号占用");
}
return toView(existing, true);
}
// 4) subject 授权(方案 §6.3:普通用户只能=自己,有管理权者可代填他人)
Integer subjectId = requireSubject(user, req.getSubjectId(), submitterId);
// 5) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属
String valuesJson = valuesToJson(req.getValues());
FormValueValidator.validate(valuesJson, schemaJson, dictCode -> dictService.allowedValues(schId, dictCode));
assertAttachmentsOwned(schId, valuesJson, schemaJson);
// 6) 限次占位(uk 是并发兜底)
String quotaKey = occupyQuota(form, subjectId, submitterId);
// 7) 落答卷
Answer answer = new Answer();
answer.setUuid(newUuid());
answer.setSchId(schId);
answer.setAppCode(form.getAppCode());
answer.setCode(form.getCode());
answer.setVersion(published.versionRow().getVersion());
answer.setSubmitterId(submitterId);
answer.setSubjectId(subjectId);
answer.setStatus(FormStatusDefs.defaultStatusCode(form.getStatusDefs()));
answer.setValuesJson(valuesJson);
answer.setAnswerNo(nextAnswerNo(form));
answer.setIdempotencyKey(idempotencyKey);
// saveAndFlush:让 uk 撞键在本方法内暴露(外层据 DataIntegrityViolationException 判定并发重放);
// 若这一步失败,同事务内的 fill_quota 占位一起回滚,不残留"名额被占但无答卷"的脏状态
answer = answerRepository.saveAndFlush(answer);
// 8) 索引:只写 searchable 字段(V3/V9)
indexService.write(answer, schemaJson);
log.info("[提交] answer={} form={}/{} v{} submitter={} subject={} quotaKey={}",
answer.getUuid(), answer.getAppCode(), answer.getCode(), answer.getVersion(),
submitterId, subjectId, quotaKey);
return toView(answer, false);
}
// ==================== 各环节 ====================
private Integer requireSubject(BoaUser user, Integer subjectId, Integer submitterId) {
if (subjectId == null || subjectId.equals(submitterId)) {
return subjectId;
}
if (!adminGuard.canManage(user)) {
throw new BizException(ErrorCode.FORBIDDEN,
"无权为他人填报:subjectId 只能等于本人(当前 " + submitterId + ",请求 " + subjectId + ")");
}
return subjectId;
}
/** 返回占住的 limit_key(null=该表单不限次,未占位) */
private String occupyQuota(Form form, Integer subjectId, Integer submitterId) {
if (!FormEnums.FillLimit.once(form.getFillLimit())) {
return null;
}
String limitKey = FormRuntimeRules.quotaKey(subjectId, submitterId);
if (quotaRepository.existsByFormIdAndLimitKey(form.getId(), limitKey)) {
throw new BizException(ErrorCode.CUSTOM, "该表单限填一次,已提交过(如需重填请联系管理员删除原提交)");
}
FillQuota quota = new FillQuota();
quota.setFormId(form.getId());
quota.setLimitKey(limitKey);
try {
quotaRepository.saveAndFlush(quota);
} catch (DataIntegrityViolationException e) {
// 并发兜底的正主:两个并发提交在这里只有一个能插进去
throw new BizException(ErrorCode.CUSTOM, "该表单限填一次,已提交过(并发提交被唯一键拦下)");
}
return limitKey;
}
/** 报名号:开关关→null;开→{code}-{yyyyMMdd}-{当日序号}(序号=当日已有号数+1,含软删行不回收号) */
private String nextAnswerNo(Form form) {
if (form.getAnswerNoEnabled() == null || form.getAnswerNoEnabled() != 1) {
return null;
}
String prefix = form.getCode() + "-" + LocalDate.now().format(ANSWER_NO_DAY) + "-";
long seq = answerRepository.countTodayAnswerNo(form.getSchId(), form.getAppCode(), form.getCode(),
prefix + "%") + 1;
return prefix + String.format("%04d", seq);
}
private void assertAttachmentsOwned(Long schId, String valuesJson, String schemaJson) {
List<Long> ids = FormValueValidator.attachmentFileIds(valuesJson, schemaJson);
for (Long id : ids) {
FormFile file = fileRepository.findByIdAndSchId(id, schId).orElse(null);
if (file == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"附件不存在或不属于本校(请先走中心上传接口,值里只存返回的 {id,fileName,key}):id=" + id);
}
}
}
private String valuesToJson(JsonNode values) {
if (values == null || values.isNull()) {
return "{}";
}
if (!values.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "values 必须是 JSON 对象 {key:value}");
}
return values.toString();
}
private String requireIdempotencyKey(String raw) {
String clean = raw == null ? null : raw.trim();
if (clean == null || clean.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "idempotencyKey 必填(客户端生成的 uuid,契约 §5)");
}
if (clean.length() > 64) {
throw new BizException(ErrorCode.PARAM_ERROR, "idempotencyKey 过长(≤64)");
}
return clean;
}
private Long requireSessionSchId(BoaUser user) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属,不能提交答卷");
}
return Long.valueOf(user.getCurrentRole().getSchId());
}
private boolean belongsToCaller(Answer answer, BoaUser user) {
// 会话校 + 提交人共同界定"这条幂等记录是不是你的"(uk 是库级的,跨账号命中不能放行)
return answer.getSubmitterId() != null && answer.getSubmitterId().equals(user.getId())
&& user.getCurrentRole() != null && user.getCurrentRole().getSchId() != null
&& answer.getSchId() != null && answer.getSchId().longValue() == user.getCurrentRole().getSchId();
}
private static String newUuid() {
return UUID.randomUUID().toString().replace("-", "").toLowerCase();
}
private AnswerModels.SubmitView toView(Answer answer, boolean replayed) {
AnswerModels.SubmitView view = new AnswerModels.SubmitView();
view.setAnswerId(answer.getUuid());
view.setAnswerNo(answer.getAnswerNo());
view.setReplayed(replayed);
return view;
}
}
@@ -10,25 +10,30 @@ import java.util.Arrays;
import java.util.List;
/**
* 定义域管理面守卫(方案 §6.5:中心管理端同样走三层权限,"中心管理员"=被授权这些模块行的人)。
* 管理面守卫(方案 §6.5:中心管理端同样走三层权限,"中心管理员"=被授权这些模块行的人)。
*
* <p>本任务(T2)按平台标准角色闸门落地,与 fork 模板 flow-center 的
* {@code FlowAdminGuard} 完全同口径:超管({@code type==1})可跨校、且是唯一能操作
* 平台级行({@code sch_id=0}:平台级表单/平台级字典,方案 §6.5、契约 §2.1)的身份;
* 校级管理员(roleId∈{2 校领导,5 局领导,7 管理员})限本校。</p>
* <p><b>过渡态</b>:方案 §6.1 第②层的权威载体是"授权模块集"(表单设计/数据管理/字典管理模块行),
* 那要等 T4 消费 GetUserMod 授权树后才能实现;在那之前按平台标准角色闸门把关,
* 口径=执行手册 §5 <b>P21 裁决</b>(T2 验收时对 flow-center 模板做的唯一一处收紧):</p>
* <ul>
* <li>超管({@code type==1},UserBase {@code UserTypeEnum.SUPER_ADMIN}):可跨校,
* 且是<b>唯一</b>能操作平台级行({@code sch_id=0})的身份;</li>
* <li>校级:{@code roleId ∈ {2 校领导, 7 管理员}} 限本校——
* <b>局领导(5) 不给管理权</b>(契约 §8 矩阵里 5=回落"仅本人提交",属查看角色非维护角色)。</li>
* </ul>
*
* <p><b>缺口记账</b>:方案 §6.1 的第②层"模块授权(表单设计/字典管理模块行)"要把把关
* 从角色换成授权模块集,依赖 GetUserMod 的模块树消费(T4 的 customId 扫描与 T8 的
* 前端把关同一条链路),本轮不在此实现 → 问题单登记。</p>
* <p>{@link #canManage} 是同一个判定的"不抛异常"形态,供契约 §5 的 subject 授权分支使用
* (普通用户 {@code subject_id} 只能=自己,有管理权者可代填他人,V17)。</p>
*/
@Service
public class FormAdminGuard {
private static final int SUPER_ADMIN_TYPE = 1;
private static final List<Integer> ADMIN_ROLE_IDS = Arrays.asList(2, 5, 7);
/** P21 收紧:校级只认 2(校领导)/7(管理员),局领导 5 已移出 */
private static final List<Integer> ADMIN_ROLE_IDS = Arrays.asList(2, 7);
/**
* 校验定义管理权并返回本次操作的租户域。
* 校验管理权并返回本次操作的租户域(定义面与数据面共用)。
*
* @param user 会话用户(CAS 自取,不接受任何外部传入身份——契约 §7 身份行)
* @param targetSchId 目标校;超管可显式指定(含 0=平台级),校级传入不等本校即拒
@@ -37,7 +42,7 @@ public class FormAdminGuard {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
if (user.getType() != null && user.getType() == SUPER_ADMIN_TYPE) {
if (isSuperAdmin(user)) {
if (targetSchId != null) {
return targetSchId;
}
@@ -47,22 +52,40 @@ public class FormAdminGuard {
}
throw new BizException(ErrorCode.PARAM_ERROR, "超管请显式指定 schId(当前身份无学校归属)");
}
boolean roleMatch = user.getRoleIds() != null
&& user.getRoleIds().stream().anyMatch(ADMIN_ROLE_IDS::contains);
Long current = currentSchId(user);
if (!roleMatch || current == null) {
throw new BizException(ErrorCode.FORBIDDEN, "无表单定义管理权限");
if (!hasSchoolAdminRole(user) || current == null) {
throw new BizException(ErrorCode.FORBIDDEN, "无表单管理权限");
}
if (targetSchId != null && targetSchId == Form.PLATFORM_SCH_ID) {
// 平台级行=跨校资产:校级管理员把目标域传成 0,先按"平台级不归学校管"讲清楚,
// 而不是笼统说一句"只能管本校"(同一条拒绝,原因要说准)
throw new BizException(ErrorCode.FORBIDDEN, "平台级表单/字典仅超管可维护");
}
if (targetSchId != null && !targetSchId.equals(current)) {
throw new BizException(ErrorCode.FORBIDDEN, "仅可管理本校表单与字典");
}
if (targetSchId != null && targetSchId == Form.PLATFORM_SCH_ID) {
// 平台级行=跨校资产,校级管理员即便把本校 id 传成 0 也不给(方案 §6.5)
throw new BizException(ErrorCode.FORBIDDEN, "平台级表单/字典仅超管可维护");
}
return current;
}
/**
* 是否具备管理权(超管或校级管理员)。<b>不含租户域核对</b>——
* 需要限定"只能管本校"的调用方请用 {@link #requireAdmin}。
*/
public boolean canManage(BoaUser user) {
if (user == null || user.getId() == null) {
return false;
}
return isSuperAdmin(user) || (hasSchoolAdminRole(user) && currentSchId(user) != null);
}
private boolean isSuperAdmin(BoaUser user) {
return user.getType() != null && user.getType() == SUPER_ADMIN_TYPE;
}
private boolean hasSchoolAdminRole(BoaUser user) {
return user.getRoleIds() != null && user.getRoleIds().stream().anyMatch(ADMIN_ROLE_IDS::contains);
}
private Long currentSchId(BoaUser user) {
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
return null;
@@ -55,6 +55,12 @@ public class FormDefService {
*/
private static final Pattern CODE_PATTERN = Pattern.compile("^[A-Za-z0-9_\\-]{1,64}$");
/**
* 开报名号时的 code 长度上限:{@code -yyyyMMdd-} 占 9 位,序号留到 5 位(单日上万)仍不越界
* {@code answer_no varchar(64)}(契约 §10)→ 64-9-6=49,留 1 位余量。
*/
static final int MAX_CODE_LEN_FOR_ANSWER_NO = 49;
/** 新建表单的占位 schema:分组与字段都在草稿里长出来,发布时必过校验器 */
private static final String EMPTY_SCHEMA = "{\"groups\":[]}";
@@ -117,6 +123,20 @@ public class FormDefService {
* options 已内联展开(字典不进对外契约,契约 §2.1)。
*/
public FormModels.ResolvedFormView resolve(Long schId, String appCode, String code) {
Published published = resolvePublished(schId, appCode, code);
return toResolvedView(published.form(), published.versionRow(),
dictService.expandSchema(published.form().getSchId(), published.versionRow().getSchemaJson()));
}
/**
* 同一条 resolve 规则的<b>服务内部形态</b>:返回头表 + 钉住的发布版本行(含 schema 原文)。
*
* <p>提交路径(T3)必须与填报页加载走<b>同一份遮蔽实现</b>,否则"按加载时解析到的版本校验并钉版"
* 会出现两套口径(契约 §5);值校验要的是<b>未展开</b>的 schema 原文(字典引用按放行集校验,
* 展开后的 options 反而看不出引用了哪个字典)。返回实体只在本服务与答卷服务之间流转,
* 不出响应体(契约 §1:id 绝不外露)。</p>
*/
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()) {
@@ -129,7 +149,7 @@ public class FormDefService {
}
if (form.getStatus() == null || form.getStatus() != FormEnums.FormStatus.PUBLISHED.getCode()
|| form.getCurrentVersion() == null) {
// 业务规则类(契约 §6 末段):草稿/停用都不开放填报入口,但数据面读接口不受影响(方案 §5.4)
// 业务规则类(契约 §6 定稿映射):草稿/停用都不开放填报入口,但数据面读接口不受影响(方案 §5.4)
throw new BizException(ErrorCode.CUSTOM, form.getStatus() != null
&& form.getStatus() == FormEnums.FormStatus.DISABLED.getCode()
? "表单已停用,不再接收填报:" + cleanCode : "表单尚未发布:" + cleanCode);
@@ -137,7 +157,65 @@ public class FormDefService {
FormVersion row = versionRepository.findByFormIdAndVersion(form.getId(), form.getCurrentVersion())
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR,
"当前发布版本行缺失(version=" + form.getCurrentVersion() + "),请联系管理员"));
return toResolvedView(form, row, dictService.expandSchema(form.getSchId(), row.getSchemaJson()));
return new Published(form, row);
}
/** 解析结果对(头表 + 钉住的发布版本行),仅服务层内部使用 */
public static final class Published {
private final Form form;
private final FormVersion versionRow;
Published(Form form, FormVersion versionRow) {
this.form = form;
this.versionRow = versionRow;
}
public Form form() {
return form;
}
public FormVersion versionRow() {
return versionRow;
}
}
/**
* 数据面解析<b>答卷钉住的版本行</b>(T3 编辑/软删/改状态用):按同一条校域遮蔽规则取头表,
* 再取该版本行;<b>不校验生命周期</b>——方案 §5.4"停用只断填报入口、数据读改接口不变",
* 升版期间的旧版答卷同样必须可管理。
*
* <p>一处歧义窗口按"退回平台级行"处理:答卷的钉版坐标是 {@code (会话校, app_code, code, version)}
* (契约 §1 校域不变式:{@code answer.sch_id} 恒=会话校),若它当初投的是<b>平台级表单</b>、
* 而本校后来建了同 {@code (app_code, code)} 的表单,遮蔽规则会把解析指向本校表单且版本号可能对不上——
* 此时退回平台级同 code 表单的版本行,历史答卷仍按原 schema 校验。
* 根因与替代方案见 T3 报告问题单。</p>
*/
public Published pinnedForData(Long schId, String appCode, String code, Integer version) {
String cleanApp = normalizeAppCode(appCode);
// 数据面取域含软删表单:答卷的定义溯源不能因为表单被软删而断掉(无物理删除,方案 §5.4)
Form form = pickByShadowing(
formRepository.findDataDomainCandidates(code, cleanApp, resolveDomain(schId)), schId);
if (form == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + cleanApp + "/" + code);
}
FormVersion row = versionRepository.findByFormIdAndVersion(form.getId(), version).orElse(null);
if (row == null && form.getSchId() != null && form.getSchId() != Form.PLATFORM_SCH_ID) {
Form platform = formRepository.findBySchIdAndAppCodeAndCode(Form.PLATFORM_SCH_ID, cleanApp, code)
.orElse(null);
if (platform != null) {
FormVersion platformRow = versionRepository.findByFormIdAndVersion(platform.getId(), version)
.orElse(null);
if (platformRow != null) {
form = platform;
row = platformRow;
}
}
}
if (row == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"答卷钉住的版本行不存在:" + cleanApp + "/" + code + " v" + version);
}
return new Published(form, row);
}
// ==================== 写:草稿/发布/生命周期 ====================
@@ -301,6 +379,15 @@ public class FormDefService {
if (req.getAnswerNoEnabled() != 0 && req.getAnswerNoEnabled() != 1) {
throw new BizException(ErrorCode.PARAM_ERROR, "answerNoEnabled 只能是 0 或 1");
}
if (req.getAnswerNoEnabled() == 1) {
// 报名号形态 {code}-{yyyyMMdd}-{4 位序号} 必须装得进 answer_no varchar(64)(契约 §10):
// 9 个分隔/日期字符 + 4 位序号占掉 13,code 上限因此是 51。开关处拦住,好过提交当天报库错。
if (form.getCode().length() > MAX_CODE_LEN_FOR_ANSWER_NO) {
throw new BizException(ErrorCode.PARAM_ERROR,
"开启报名号要求 code ≤" + MAX_CODE_LEN_FOR_ANSWER_NO + " 位(当前 "
+ form.getCode().length() + "):" + form.getCode());
}
}
form.setAnswerNoEnabled(req.getAnswerNoEnabled());
}
if (req.getGroupTag() != null) {
@@ -0,0 +1,210 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.common.FormValueValidator;
import com.yunhai.formcenter.entity.FormFile;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.repository.FormFileRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.PostConstruct;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.SecureRandom;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
/**
* 上传模块(<b>平台标准版,自写</b>——手册 §1 明令"不抄 notice 的 FileController",
* 其反面教材点是本地键与绝对 URL 进值;本实现按契约 §3/方案 §4.3② 只交相对 key)。
*
* <p>五条硬约束:</p>
* <ol>
* <li><b>存储根路径</b>读形 {@code ${FILE_PATH:${FILE_UPLOAD_PATH:${file.upload-path:/data/upload}}}}——
* {@code FILE_PATH} 由 projman 注入且持久卷固定挂 {@code /data/upload},jar 内只在不注入 env 的本地生效,
* <b>不可删</b>(notice 的容器锚定丢附件教训,见 application.yml 原样保留的注释);</li>
* <li><b>返回只有 {@code {id, fileName, key}}</b>:没有 fileUrl——{@code FILE_WEB_PATH} 是环境注入项,
* 绝对地址进答卷=环境耦合,换域名历史附件全断(方案 §4.3②);</li>
* <li><b>key=不可猜测随机串即访问控制</b>(方案 §7.7 一期显式假设):{@code form/{yyyy}/{MM}/{32hex}.{ext}},
* 存储名与用户文件名无关,同时消灭同名覆盖与路径穿越两类事故;</li>
* <li><b>扩展名黑名单</b>:平台现行 15 项(svc-hall/UserBase/Notice 同源)+ {@code svg}
* (同源静态直访下 svg 可携带脚本,是"key 即访问控制"这套假设的唯一例外);</li>
* <li>单文件 ≤50MB(方案 §7.7 全局兜底;容器侧上限由 application.yml 的
* {@code spring.servlet.multipart} 显式配,P12——Spring 默认 1MB 会直接挡掉合法附件)。
* 每字段 ≤9 个是<b>提交值</b>侧的约束,在 {@link FormValueValidator} 判定。</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FormFileService {
/** 平台同源危险后缀黑名单(小写、不含点) */
private static final Set<String> DANGEROUS_EXT = new HashSet<>(Arrays.asList(
"jsp", "jspx", "html", "htm", "js", "exe", "sh", "bat", "php", "asp", "aspx",
"jar", "war", "class", "htaccess",
// 本轮追加:同源静态直访 + 前端 img/链接渲染,svg 可内嵌脚本(方案 §7.7 的访问控制假设兜不住它)
"svg"));
/** 归一化扩展名形态:小写字母数字 1-15 位(挡掉 ".jpg "、".jsp%00"、双扩展名伪装) */
private static final java.util.regex.Pattern EXT_PATTERN =
java.util.regex.Pattern.compile("^[a-z0-9]{1,15}$");
private static final DateTimeFormatter YEAR = DateTimeFormatter.ofPattern("yyyy");
private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("MM");
private static final char[] HEX = "0123456789abcdef".toCharArray();
private static final SecureRandom RANDOM = new SecureRandom();
/**
* 存储根:FILE_PATH(平台注入)&gt; FILE_UPLOAD_PATH(兼容别名)&gt; jar 内本地兜底 &gt; /data/upload。
* 三层占位是 application.yml 注释里定死的读取形态,勿改成单键。
*/
@Value("${FILE_PATH:${FILE_UPLOAD_PATH:${file.upload-path:/data/upload}}}")
private String storeRoot;
/** 本地兜底键值(仅测试用;生产不注入 env 时也是它) */
@Value("${file.upload-path:/data/upload}")
private String localFallbackRoot;
private final FormFileRepository fileRepository;
@PostConstruct
public void init() {
// 启动即暴露配置问题(路径不可写要在第一次上传前就知道,而不是等用户点了上传才 500)
try {
Files.createDirectories(Paths.get(storeRoot));
} catch (IOException e) {
throw new IllegalStateException("上传存储根不可用:" + storeRoot, e);
}
log.info("[上传] store_root={}(FILE_PATH 未注入时走本地兜底键 file.upload-path)", storeRoot);
}
/** 上传:校验 → 落盘(随机名) → 落档建行,返回契约 §3 的三件套 */
@Transactional
public AnswerModels.FileView upload(BoaUser user, MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件为空");
}
if (file.getSize() > FormValueValidator.MAX_FILE_SIZE_BYTES) {
throw new BizException(ErrorCode.PARAM_ERROR,
"单文件不得超过 50MB(当前 " + (file.getSize() / 1024 / 1024) + "MB)");
}
Long schId = requireSessionSchId(user);
String original = sanitizeName(file.getOriginalFilename());
String ext = requireSafeExt(original);
String key = "form/" + LocalDate.now().format(YEAR) + "/" + LocalDate.now().format(MONTH)
+ "/" + randomHex() + "." + ext;
Path base = Paths.get(storeRoot).toAbsolutePath().normalize();
Path target = base.resolve(key).normalize();
// 双保险:随机 key 本身不含用户输入,这里再保证落点一定在存储根之下
if (!target.startsWith(base)) {
throw new BizException(ErrorCode.PARAM_ERROR, "存储路径非法,已拒绝");
}
try {
Files.createDirectories(target.getParent());
try (InputStream in = file.getInputStream()) {
Files.copy(in, target);
}
} catch (IOException e) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "文件写入失败:" + e.getMessage());
}
FormFile row = new FormFile();
row.setSchId(schId);
row.setFileName(original);
row.setStoreKey(key);
row.setFileSize(file.getSize());
row.setExt(ext);
row = fileRepository.save(row);
AnswerModels.FileView view = new AnswerModels.FileView();
view.setId(row.getId());
view.setFileName(row.getFileName());
view.setKey(row.getStoreKey());
return view;
}
/** 当前生效的存储根(仅测试断言用——日志行含中文,Windows 控制台重定向是 GBK,grep 不可靠) */
public String getEffectiveStoreRoot() {
return storeRoot;
}
/** 未注入平台 env 时应当落到的本地兜底根(仅测试断言用) */
public String getLocalFallbackRoot() {
return localFallbackRoot;
}
/** 把相对 key 解析成磁盘路径;越出存储根一律返回 null(测试断言与二期清理共用) */
public Path resolveStored(String key) {
Path base = Paths.get(storeRoot).toAbsolutePath().normalize();
Path target = base.resolve(key).normalize();
return target.startsWith(base) ? target : null;
}
// ==================== 内部 ====================
/** 只取用户文件名的最后一段并限长:丢掉任何路径分隔符(zip-slip/穿越面),保留展示名 */
private String sanitizeName(String originalFilename) {
if (originalFilename == null || originalFilename.trim().isEmpty()) {
return "file";
}
String name = originalFilename.replace('\\', '/');
int slash = name.lastIndexOf('/');
name = slash >= 0 ? name.substring(slash + 1) : name;
name = name.replaceAll("[\\p{Cntrl}]", "").trim();
if (name.isEmpty()) {
return "file";
}
return name.length() > 255 ? name.substring(name.length() - 255) : name;
}
private String requireSafeExt(String fileName) {
int dot = fileName.lastIndexOf('.');
if (dot < 0 || dot == fileName.length() - 1) {
throw new BizException(ErrorCode.PARAM_ERROR, "文件缺少扩展名:" + fileName);
}
String ext = fileName.substring(dot + 1).toLowerCase(Locale.ROOT);
if (!EXT_PATTERN.matcher(ext).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR, "扩展名不合法:" + ext);
}
if (DANGEROUS_EXT.contains(ext)) {
throw new BizException(ErrorCode.PARAM_ERROR, "不允许上传的文件类型:" + ext);
}
return ext;
}
private static String randomHex() {
byte[] bytes = new byte[16];
RANDOM.nextBytes(bytes);
StringBuilder sb = new StringBuilder(32);
for (byte b : bytes) {
sb.append(HEX[(b >> 4) & 0xF]).append(HEX[b & 0xF]);
}
return sb.toString();
}
private Long requireSessionSchId(BoaUser user) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属,不能上传附件");
}
return Long.valueOf(user.getCurrentRole().getSchId());
}
}
+9
View File
@@ -30,6 +30,15 @@ spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
# ===== 上传大小上限(T3 上传模块,问题单 P12 记档项)=====
# 必须显式配:Spring Boot 默认 max-file-size=1MB,会把契约 §3 的 ≤50MB 附件在校验器之前就挡掉
# (报 MultipartException,用户只看到"上传失败")。两处上限与方案 §7.7 全局兜底同口径:
# 单文件 ≤50MB;请求侧同样 50MB——前端逐文件上传(view-ui-plus 默认一次请求一个文件),
# 不给"一个请求塞多个大文件"的放大面。生产若需调整由平台 env 覆盖,不写进 jar。
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
# ===== 文件上传存储路径 =====
# 教训注释自 notice/service application.yml 原样抄入(2026-10 事故):
@@ -0,0 +1,226 @@
package com.yunhai.formcenter.common;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 提交值校验单测(契约 §3 值形状 + §5 校验条款;脱库脱网可跑)。
*
* <p>字典侧取值域由注入的 {@link FormValueValidator.DictCandidates} 提供,
* 服务层接的是 {@code FormDictService#allowedValues}(<b>含软删条目</b>,P18/P19 定稿)——
* 本测试用假实现把"软删条目仍放行""伪造 value 被拒"两条口径钉住。</p>
*/
@DisplayName("FormValueValidator - 契约 §3 值形状")
class FormValueValidatorTest {
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"maxLength\":20},"
+ "{\"key\":\"reason\",\"label\":\"申报理由\",\"type\":\"textarea\",\"maxLength\":10},"
+ "{\"key\":\"mobile\",\"label\":\"手机号\",\"type\":\"phone\",\"pattern\":\"^1[3-9]\\\\d{9}$\"},"
+ "{\"key\":\"score\",\"label\":\"分数\",\"type\":\"number\",\"precision\":1,\"min\":0,\"max\":100},"
+ "{\"key\":\"birthday\",\"label\":\"出生日期\",\"type\":\"date\",\"searchable\":true},"
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true,"
+ "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高三\",\"value\":\"g3\"}]},"
+ "{\"key\":\"level\",\"label\":\"荣誉级别\",\"type\":\"radio\","
+ "\"options\":{\"source\":\"dict:level\"}},"
+ "{\"key\":\"subjects\",\"label\":\"获奖学科\",\"type\":\"checkbox\","
+ "\"options\":[{\"label\":\"数学\",\"value\":\"math\"},{\"label\":\"物理\",\"value\":\"phy\"}]},"
+ "{\"key\":\"cert\",\"label\":\"证书照片\",\"type\":\"image\",\"maxFiles\":2},"
+ "{\"key\":\"files\",\"label\":\"证明材料\",\"type\":\"upload\"}"
+ "]}]}";
/** level 字典的放行集:含一条"软删条目"legacy(服务层给的就是 allowedValues,含软删) */
private static final Set<String> LEVEL_VALUES =
new LinkedHashSet<>(Arrays.asList("school", "city", "legacy"));
private static final FormValueValidator.DictCandidates DICTS = code -> "level".equals(code)
? new HashSet<>(LEVEL_VALUES) : Collections.<String>emptySet();
private static String v(String body) {
return "{" + body + "}";
}
/** 契约 §3 示例值的"全类型合法提交"——同时是 V1 的后端接受面 */
private static String fullValid() {
return v("\"name\":\"张三\",\"reason\":\"多次获得市级三好学生\",\"mobile\":\"13800138000\","
+ "\"score\":95.5,\"birthday\":\"2010-03-09\",\"grade\":\"g2\",\"level\":\"school\","
+ "\"subjects\":[\"phy\",\"math\"],"
+ "\"cert\":[{\"id\":1001,\"fileName\":\"cert1.jpg\",\"key\":\"form/2026/10/1001.jpg\"}]");
}
@Test
@DisplayName("契约 §3 每类控件的示例值都被接受(V1 的后端接受面)")
void allReleasedTypesAcceptContractExamples() {
assertThatCode(() -> FormValueValidator.validate(fullValid(), SCHEMA, DICTS))
.doesNotThrowAnyException();
// 返回值原样回传(不改写用户数据)
assertThat(FormValueValidator.validate(fullValid(), SCHEMA, DICTS)).isEqualTo(fullValid());
}
@Test
@DisplayName("必填缺失/纯空白/空数组都算空;非必填留空放行")
void requiredChecks() {
assertThatThrownBy(() -> FormValueValidator.validate(v("\"grade\":\"g2\""), SCHEMA, DICTS))
.isInstanceOf(BizException.class).hasMessageContaining("姓名").hasMessageContaining("必填");
assertThatThrownBy(() -> FormValueValidator.validate(v("\"name\":\" \",\"grade\":\"g2\""), SCHEMA, DICTS))
.hasMessageContaining("必填");
assertThatThrownBy(() -> FormValueValidator.validate(v("\"name\":\"张三\""), SCHEMA, DICTS))
.hasMessageContaining("年级").hasMessageContaining("必填");
// score/birthday/subjects 等非必填全空 → 过
assertThatCode(() -> FormValueValidator.validate(v("\"name\":\"张三\",\"grade\":\"g2\""), SCHEMA, DICTS))
.doesNotThrowAnyException();
}
@Test
@DisplayName("number:值形状=number|null;空串被拒、小数位超 precision、越 min/max 都拒")
void numberRules() {
assertOn("score", "\"name\":\"张三\",\"grade\":\"g2\",\"score\":\"95.5\"", "必须是数字");
assertOn("score", "\"name\":\"张三\",\"grade\":\"g2\",\"score\":95.55", "precision");
assertOn("score", "\"name\":\"张三\",\"grade\":\"g2\",\"score\":101", "不得大于");
assertOn("score", "\"name\":\"张三\",\"grade\":\"g2\",\"score\":-1", "不得小于");
assertThatCode(() -> FormValueValidator.validate(
v("\"name\":\"张三\",\"grade\":\"g2\",\"score\":null"), SCHEMA, DICTS))
.doesNotThrowAnyException();
}
@Test
@DisplayName("date:格式定死 YYYY-MM-DD 且必须是真实存在的日期(V4 的根因,区间比较靠 v_date 类型列)")
void dateRules() {
assertOn("birthday", "\"name\":\"张三\",\"grade\":\"g2\",\"birthday\":\"2023-1-9\"", "YYYY-MM-DD");
assertOn("birthday", "\"name\":\"张三\",\"grade\":\"g2\",\"birthday\":\"2023-02-30\"", "日期不存在");
assertOn("birthday", "\"name\":\"张三\",\"grade\":\"g2\",\"birthday\":20230109", "YYYY-MM-DD");
assertThatCode(() -> FormValueValidator.validate(
v("\"name\":\"张三\",\"grade\":\"g2\",\"birthday\":\"2024-02-29\""), SCHEMA, DICTS))
.doesNotThrowAnyException();
}
@Test
@DisplayName("text/textarea/phone:maxLength 与 pattern 生效(phone 只按声明的 pattern,不内置号码规则)")
void textRules() {
assertOn("name", "\"name\":\"超出二十个字符的姓名测试超出二十个字符的姓名测试A\",\"grade\":\"g2\"", "长度超过上限");
assertOn("reason", "\"name\":\"张三\",\"grade\":\"g2\",\"reason\":\"超过十个字符的申报理由文本\"", "长度超过上限");
assertOn("mobile", "\"name\":\"张三\",\"grade\":\"g2\",\"mobile\":\"23800138000\"", "格式不正确");
assertThatCode(() -> FormValueValidator.validate(
v("\"name\":\"张三\",\"grade\":\"g2\",\"mobile\":\"13900139000\""), SCHEMA, DICTS))
.doesNotThrowAnyException();
}
@Test
@DisplayName("select/radio:存 value;候选外的值(含伪造)被拒")
void singleOptionRules() {
assertOn("grade", "\"name\":\"张三\",\"grade\":\"g9\"", "不在候选列表内");
assertOn("grade", "\"name\":\"张三\",\"grade\":2", "选项 value 字符串");
assertOn("level", "\"name\":\"张三\",\"grade\":\"g2\",\"level\":\"province\"", "不在候选列表内");
assertThatCode(() -> FormValueValidator.validate(
v("\"name\":\"张三\",\"grade\":\"g3\",\"level\":\"city\""), SCHEMA, DICTS))
.doesNotThrowAnyException();
}
@Test
@DisplayName("字典引用字段:软删条目的 value 仍放行(旧答卷可原样重存),字典不可用时宁可拒也不放行任意值")
void dictSoftDeletedValuesStillPass() {
// legacy 是软删条目:设计器不可新选,但提交校验放行(契约 §2.1 / V38)
assertThatCode(() -> FormValueValidator.validate(
v("\"name\":\"张三\",\"grade\":\"g2\",\"level\":\"legacy\""), SCHEMA, DICTS))
.doesNotThrowAnyException();
FormValueValidator.DictCandidates gone = code -> Collections.emptySet();
assertThatThrownBy(() -> FormValueValidator.validate(
v("\"name\":\"张三\",\"grade\":\"g2\",\"level\":\"school\""), SCHEMA, gone))
.isInstanceOf(BizException.class).hasMessageContaining("字典不可用");
}
@Test
@DisplayName("checkbox/multiselect:必须数组、元素必须是候选 value")
void multiOptionRules() {
assertOn("subjects", "\"name\":\"张三\",\"grade\":\"g2\",\"subjects\":\"math\"", "必须是选项 value 数组");
assertOn("subjects", "\"name\":\"张三\",\"grade\":\"g2\",\"subjects\":[\"math\",\"art\"]",
"不在候选列表内");
assertOn("subjects", "\"name\":\"张三\",\"grade\":\"g2\",\"subjects\":[1]", "数组元素必须是选项 value 字符串");
assertThatCode(() -> FormValueValidator.validate(
v("\"name\":\"张三\",\"grade\":\"g2\",\"subjects\":[\"phy\",\"math\"]"), SCHEMA, DICTS))
.doesNotThrowAnyException();
}
@Test
@DisplayName("V24:附件值只认 {id,fileName,key}——多带 fileUrl 被拒,绝对 URL/绝对路径/上跳 key 都被拒")
void attachmentShapeRules() {
assertThatCode(() -> FormValueValidator.validate(
v("\"name\":\"张三\",\"grade\":\"g2\",\"cert\":"
+ "[{\"id\":1,\"fileName\":\"a.jpg\",\"key\":\"form/2026/1/a.jpg\"}]"), SCHEMA, DICTS))
.doesNotThrowAnyException();
assertOn("cert", "\"name\":\"张三\",\"grade\":\"g2\",\"cert\":"
+ "[{\"id\":1,\"fileName\":\"a.jpg\",\"key\":\"form/2026/1/a.jpg\",\"fileUrl\":\"http://x/a.jpg\"}]",
"契约外字段");
assertOn("cert", "\"name\":\"张三\",\"grade\":\"g2\",\"cert\":"
+ "[{\"id\":1,\"fileName\":\"a.jpg\",\"key\":\"https://cdn.example/a.jpg\"}]", "相对存储路径");
assertOn("cert", "\"name\":\"张三\",\"grade\":\"g2\",\"cert\":"
+ "[{\"id\":1,\"fileName\":\"a.jpg\",\"key\":\"/data/upload/a.jpg\"}]", "相对存储路径");
assertOn("cert", "\"name\":\"张三\",\"grade\":\"g2\",\"cert\":"
+ "[{\"id\":1,\"fileName\":\"a.jpg\",\"key\":\"../../etc/passwd\"}]", "相对存储路径");
assertOn("cert", "\"name\":\"张三\",\"grade\":\"g2\",\"cert\":[{\"fileName\":\"a.jpg\",\"key\":\"k\"}]",
"id 必须是正整数");
assertOn("cert", "\"name\":\"张三\",\"grade\":\"g2\",\"cert\":\"http://x/a.jpg\"", "必须是附件数组");
}
@Test
@DisplayName("附件数量上限:字段级 maxFiles 生效;未声明时走全局 ≤9(方案 §7.7),声明超 9 也不放宽")
void attachmentCountLimits() {
String three = "[{\"id\":1,\"fileName\":\"a\",\"key\":\"k/a\"},"
+ "{\"id\":2,\"fileName\":\"b\",\"key\":\"k/b\"},"
+ "{\"id\":3,\"fileName\":\"c\",\"key\":\"k/c\"}]";
assertOn("cert", "\"name\":\"张三\",\"grade\":\"g2\",\"cert\":" + three, "附件数超过上限 2");
StringBuilder many = new StringBuilder("[");
for (int i = 1; i <= 10; i++) {
many.append(i > 1 ? "," : "").append("{\"id\":").append(i)
.append(",\"fileName\":\"f").append(i).append("\",\"key\":\"k/f").append(i).append("\"}");
}
many.append("]");
assertOn("files", "\"name\":\"张三\",\"grade\":\"g2\",\"files\":" + many, "附件数超过上限 9");
}
@Test
@DisplayName("提交数据不得夹带 schema 外字段(严格契约);非对象提交被拒;空 values 视为全空")
void documentShapeRules() {
assertOn(null, "\"name\":\"张三\",\"grade\":\"g2\",\"hack\":1", "未知字段");
assertThatThrownBy(() -> FormValueValidator.validate("[]", SCHEMA, DICTS))
.isInstanceOf(BizException.class).hasMessageContaining("JSON 对象");
assertThatThrownBy(() -> FormValueValidator.validate("not json", SCHEMA, DICTS))
.hasMessageContaining("合法 JSON 对象");
assertThatThrownBy(() -> FormValueValidator.validate(null, SCHEMA, DICTS))
.hasMessageContaining("必填");
}
@Test
@DisplayName("attachmentFileIds:只为附件类字段抽取引用 id(服务层据此做同校归属校验)")
void extractsAttachmentIdsOnlyFromAttachmentFields() {
List<Long> ids = FormValueValidator.attachmentFileIds(
v("\"name\":\"张三\",\"grade\":\"g2\",\"cert\":"
+ "[{\"id\":1001,\"fileName\":\"a.jpg\",\"key\":\"k/a.jpg\"}],"
+ "\"files\":[{\"id\":1002,\"fileName\":\"b.pdf\",\"key\":\"k/b.pdf\"},"
+ "{\"id\":1003,\"fileName\":\"c.pdf\",\"key\":\"k/c.pdf\"}]"), SCHEMA);
assertThat(ids).containsExactly(1001L, 1002L, 1003L);
assertThat(FormValueValidator.attachmentFileIds(
v("\"name\":\"张三\",\"grade\":\"g2\""), SCHEMA)).isEmpty();
}
/** 传字段体的"半份文档"(不含外层大括号),断言被拒且 msg 指明原因 */
private static void assertOn(String fieldKey, String valuesBody, String expectedFragment) {
assertThatThrownBy(() -> FormValueValidator.validate(v(valuesBody), SCHEMA, DICTS))
.as(fieldKey == null ? valuesBody : "字段 " + fieldKey)
.isInstanceOf(BizException.class)
.hasMessageContaining(expectedFragment);
}
}
@@ -0,0 +1,346 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.AnswerOpLog;
import com.yunhai.formcenter.entity.AnswerIndex;
import com.yunhai.formcenter.entity.StatusLog;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.repository.AnswerOpLogRepository;
import com.yunhai.formcenter.repository.StatusLogRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.model.BoaUser;
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.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 答卷管理操作集成测试(真实 MySQL;自验项 <b>V29/V30</b> + 编排补充的 <b>V14/V15</b> 顺带断言)。
*
* <p>权限层(V14"填表人改状态被拒(接口层)"、V29"填表人无编辑/撤回接口可用")在这里断言的是
* <b>接口实际走的那条判定</b>:控制器调 {@code FormAdminGuard.requireAdmin} → 无管理权的填表人被拒。
* "不存在填表人可用的编辑/撤回端点"这一半,由 {@code FormAnswerController} 只有 submit/upload
* 两个端点保证(本轮代码结构即证据,见 T3 报告 §1)。</p>
*/
@SpringBootTest
@Transactional
@DisplayName("AnswerAdminService - 编辑/软删/改状态/批量(V29/V30/V14/V15)")
class AnswerAdminIT extends AnswerTestSupport {
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true},"
+ "{\"key\":\"score\",\"label\":\"分数\",\"type\":\"number\",\"precision\":1,\"searchable\":true},"
+ "{\"key\":\"remark\",\"label\":\"备注\",\"type\":\"textarea\"}"
+ "]}]}";
private static final String VALUES = "{\"name\":\"张三\",\"score\":95.5,\"remark\":\"原文\"}";
@Autowired
private AnswerOpLogRepository opLogRepository;
@Autowired
private StatusLogRepository statusLogRepository;
@Autowired
private FormAdminGuard adminGuard;
// ==================== V29 编辑 ====================
@Test
@DisplayName("V29:管理员编辑成功、写 answer_op_log、索引按钉住版本重建;身份与状态列不动")
void adminEditWritesOpLogAndRebuildsIndex() {
String code = "v29_edit";
publishForm(code, SCHEMA);
String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
Integer operator = 10001;
AnswerModels.EditRequest edit = new AnswerModels.EditRequest();
edit.setAnswerId(answerId);
edit.setValues(json("{\"name\":\"张三改\",\"score\":88,\"remark\":\"管理员修正\"}"));
adminService.edit(SCH, operator, edit);
Answer stored = answerRepository.findByUuidAndSchId(answerId, SCH).get();
assertThat(stored.getValuesJson()).contains("张三改").contains("88");
assertThat(stored.getSubmitterId()).isNotEqualTo(operator); // 编辑不改提交人
assertThat(stored.getStatus()).isEqualTo("pending"); // 编辑不改状态
List<AnswerOpLog> logs = opLogRepository.findByAnswerIdOrderByIdAsc(stored.getId());
assertThat(logs).hasSize(1);
assertThat(logs.get(0).getOp()).isEqualTo(AnswerOpLog.OP_EDIT);
assertThat(logs.get(0).getOperatorId()).isEqualTo(operator);
assertThat(logs.get(0).getSummary()).contains("钉住版本 v1");
List<String> keys = indexService.rowsOf(stored.getId()).stream()
.map(AnswerIndex::getFieldKey).collect(Collectors.toList());
assertThat(keys).contains("name", "score").doesNotContain("remark");
assertThat(indexService.rowsOf(stored.getId())).filteredOn(r -> "score".equals(r.getFieldKey()))
.extracting(AnswerIndex::getVNum).containsExactly(new java.math.BigDecimal("88"));
}
@Test
@DisplayName("V29/V8 同族:编辑按<b>钉住的版本</b>校验——升版后新增字段出现在旧答卷上即被拒")
void editValidatesAgainstPinnedVersionNotLatest() {
String code = "v29_pinned";
String uuid = publishForm(code, SCHEMA);
String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(uuid);
save.setSchemaJson(SCHEMA.replace("{\"key\":\"remark\"",
"{\"key\":\"teacher\",\"label\":\"指导教师\",\"type\":\"text\"},{\"key\":\"remark\""));
defService.save(SCH, save);
defService.publish(SCH, uuid, "加指导教师");
AnswerModels.EditRequest withNewField = new AnswerModels.EditRequest();
withNewField.setAnswerId(answerId);
withNewField.setValues(json("{\"name\":\"张三\",\"score\":95.5,\"teacher\":\"李老师\"}"));
assertThatThrownBy(() -> adminService.edit(SCH, 10001, withNewField))
.isInstanceOf(BizException.class)
.hasMessageContaining("未知字段"); // v2 的字段不凭空落在 v1 的答卷上
// 答卷仍钉在 v1,其原值未被破坏
Answer stored = answerRepository.findByUuidAndSchId(answerId, SCH).get();
assertThat(stored.getVersion()).isEqualTo(1);
assertThat(stored.getValuesJson()).contains("原文");
}
@Test
@DisplayName("编辑仍受值契约约束(必填清空、类型错、夹带未知字段都被拒),且被拒时不落 op_log")
void editStillEnforcesValueContract() {
String code = "v29_invalid";
publishForm(code, SCHEMA);
String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
Answer rowId = answerRepository.findByUuidAndSchId(answerId, SCH).get();
AnswerModels.EditRequest clearRequired = new AnswerModels.EditRequest();
clearRequired.setAnswerId(answerId);
clearRequired.setValues(json("{\"score\":95.5}"));
assertThatThrownBy(() -> adminService.edit(SCH, 10001, clearRequired)).hasMessageContaining("必填");
AnswerModels.EditRequest badType = new AnswerModels.EditRequest();
badType.setAnswerId(answerId);
badType.setValues(json("{\"name\":\"张三\",\"score\":\"高分\"}"));
assertThatThrownBy(() -> adminService.edit(SCH, 10001, badType)).hasMessageContaining("必须是数字");
assertThat(opLogRepository.findByAnswerIdOrderByIdAsc(rowId.getId())).isEmpty();
assertThat(answerRepository.findByUuidAndSchId(answerId, SCH).get().getValuesJson()).contains("原文");
}
// ==================== V30 软删 ====================
@Test
@DisplayName("V30:软删置 is_deleted、索引行同步清空、写 op_log;<b>无物理删除</b>——行仍在库里;重复删被拒")
void softDeleteIsTheOnlyDeletion() {
String code = "v30_delete";
publishForm(code, SCHEMA);
String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
Answer stored = answerRepository.findByUuidAndSchId(answerId, SCH).get();
assertThat(indexService.rowsOf(stored.getId())).isNotEmpty();
adminService.softDelete(SCH, 10001, answerId);
Answer after = answerRepository.findByUuidAndSchId(answerId, SCH).get();
assertThat(after.getIsDeleted()).isEqualTo(1);
assertThat(indexService.rowsOf(after.getId())).isEmpty(); // 方案 §5.2 索引行生命周期
assertThat(opLogRepository.findByAnswerIdOrderByIdAsc(after.getId()))
.extracting(AnswerOpLog::getOp).contains(AnswerOpLog.OP_SOFT_DELETE);
// 留痕与答卷行都还在:只有软删标记,没有任何物理删除路径
assertThat(answerRepository.findById(after.getId())).isPresent();
assertThatThrownBy(() -> adminService.softDelete(SCH, 10001, answerId))
.isInstanceOf(BizException.class).hasMessageContaining("已删除");
}
@Test
@DisplayName("跨校答卷定位不到(按 (域, uuid) 查);表单软删不影响其存量答卷的可管理性")
void domainScopingAndSoftDeletedFormStillOwnsAnswers() {
String code = "v30_scope";
String uuid = publishForm(code, SCHEMA);
String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
assertThatThrownBy(() -> adminService.softDelete(OTHER_SCH, 10001, answerId))
.isInstanceOf(BizException.class).hasMessageContaining("答卷不存在");
defService.softDelete(SCH, uuid);
AnswerModels.EditRequest edit = new AnswerModels.EditRequest();
edit.setAnswerId(answerId);
edit.setValues(json("{\"name\":\"删表后仍可修正\",\"score\":60}"));
adminService.edit(SCH, 10001, edit);
assertThat(answerRepository.findByUuidAndSchId(answerId, SCH).get().getValuesJson()).contains("删表后仍可修正");
}
// ==================== V14 / V15 改状态 ====================
@Test
@DisplayName("V15:改状态写 status_log;任意未废弃 code 之间都可改(系统内不存在转移规则校验)")
void statusChangeHasNoTransitionRules() {
String code = "v15_status";
String uuid = publishForm(code, SCHEMA);
FormModels.StatusDefsRequest defs = new FormModels.StatusDefsRequest();
defs.setUuid(uuid);
defs.setStatusDefs(Arrays.asList(def("pending", "待处理"), def("done", "已处理"), def("back", "退回")));
defService.updateStatusDefs(SCH, defs);
String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
Answer row = answerRepository.findByUuidAndSchId(answerId, SCH).get();
changeStatus(answerId, "back");
changeStatus(answerId, "pending");
changeStatus(answerId, "done"); // 倒退也合法:状态是标记不是流转(方案 §5.3 红线)
List<StatusLog> logs = statusLogRepository.findByAnswerIdOrderByIdAsc(row.getId());
assertThat(logs).extracting(StatusLog::getStatusCode).containsExactly("back", "pending", "done");
assertThat(logs).extracting(StatusLog::getOperatorId).containsOnly(10001);
assertThat(answerRepository.findByUuidAndSchId(answerId, SCH).get().getStatus()).isEqualTo("done");
}
@Test
@DisplayName("改状态的目标值必须在未废弃定义内(已废弃 code 只用于历史回显,V13 由读路径降级)")
void statusMustBeLiveDefinition() {
String code = "v15_live";
String uuid = publishForm(code, SCHEMA);
FormModels.StatusDefsRequest defs = new FormModels.StatusDefsRequest();
defs.setUuid(uuid);
FormModels.StatusDefView retired = def("retired", "已废弃状态");
retired.setDeleted(true);
defs.setStatusDefs(Arrays.asList(def("pending", "待处理"), retired));
defService.updateStatusDefs(SCH, defs);
String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
assertThatThrownBy(() -> changeStatus(answerId, "retired"))
.isInstanceOf(BizException.class).hasMessageContaining("不在该表单的可用定义内");
assertThatThrownBy(() -> changeStatus(answerId, "never_defined"))
.hasMessageContaining("不在该表单的可用定义内");
assertThatThrownBy(() -> changeStatus(answerId, " "))
.hasMessageContaining("status 不能为空");
}
@Test
@DisplayName("V14:填表人(学生/普通教师)在接口层被守卫拒于改状态之外;管理员放行")
void submitterCannotChangeStatus() {
String code = "v14_gate";
publishForm(code, SCHEMA);
String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
AnswerModels.ChangeStatusRequest req = new AnswerModels.ChangeStatusRequest();
req.setAnswerId(answerId);
req.setStatus("done");
// 控制器走的就是这一行:无管理权的填表人到不了 service
for (BoaUser submitter : Arrays.asList(plainUser(3), plainUser(1))) {
assertThatThrownBy(() -> adminGuard.requireAdmin(submitter, null))
.isInstanceOf(BizException.class).hasMessageContaining("无表单管理权限");
}
assertThat(adminGuard.requireAdmin(adminUser(), null)).isEqualTo(SCH);
}
// ==================== 批量 ====================
@Test
@DisplayName("批量逐条:一条一行留痕、逐条校验隔离失败(混入不存在的 id 只让那一条失败)")
void batchIsPerItemValidationAndLogging() {
String code = "v39_batch";
String uuid = publishForm(code, SCHEMA);
// 状态定义里有 done,批量改状态的目标值才合法
FormModels.StatusDefsRequest defs = new FormModels.StatusDefsRequest();
defs.setUuid(uuid);
defs.setStatusDefs(Arrays.asList(def("pending", "待处理"), def("done", "已处理")));
defService.updateStatusDefs(SCH, defs);
String first = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
String second = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
AnswerModels.BatchRequest batch = new AnswerModels.BatchRequest();
batch.setAnswerIds(Arrays.asList(first, "00000000000000000000000000000000", second));
List<AnswerModels.BatchItemView> results = adminService.batchChangeStatus(SCH, 10001, withStatus(batch));
assertThat(results).hasSize(3);
assertThat(results.stream().filter(AnswerModels.BatchItemView::getSuccess).count()).isEqualTo(2);
AnswerModels.BatchItemView failed = results.get(1);
assertThat(failed.getSuccess()).isFalse();
assertThat(failed.getAnswerId()).isEqualTo("00000000000000000000000000000000");
assertThat(failed.getMsg()).contains("答卷不存在");
Answer a = answerRepository.findByUuidAndSchId(first, SCH).get();
Answer b = answerRepository.findByUuidAndSchId(second, SCH).get();
assertThat(a.getStatus()).isEqualTo("done");
assertThat(b.getStatus()).isEqualTo("done");
assertThat(statusLogRepository.findByAnswerIdOrderByIdAsc(a.getId())).hasSize(1);
assertThat(statusLogRepository.findByAnswerIdOrderByIdAsc(b.getId())).hasSize(1);
// 批量通道的 op 值带 batch_ 前缀(契约 §10 的 op 'edit/soft_delete/batch_…' 形态),一条答卷一行
assertThat(opLogRepository.findByAnswerIdOrderByIdAsc(a.getId()))
.extracting(AnswerOpLog::getOp).containsExactly(AnswerOpLog.OP_BATCH_CHANGE_STATUS);
}
@Test
@DisplayName("批量入参约束:空集合与超上限(200)直接拒,不进入逐条阶段")
void batchInputGuards() {
AnswerModels.BatchRequest empty = new AnswerModels.BatchRequest();
empty.setAnswerIds(java.util.Collections.emptyList());
assertThatThrownBy(() -> adminService.batchSoftDelete(SCH, 10001, empty))
.isInstanceOf(BizException.class).hasMessageContaining("至少要选一条");
AnswerModels.BatchRequest tooMany = new AnswerModels.BatchRequest();
tooMany.setAnswerIds(java.util.Collections.nCopies(AnswerAdminService.MAX_BATCH_SIZE + 1, "x"));
assertThatThrownBy(() -> adminService.batchSoftDelete(SCH, 10001, tooMany))
.hasMessageContaining("单次上限");
}
@Test
@DisplayName("批量软删同样逐条留痕并写 batch_soft_delete;被软删的答卷不再出现在可读集合(is_deleted=1)")
void batchSoftDeletePerItemLogging() {
String code = "batch_sd";
publishForm(code, SCHEMA);
String first = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
String second = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
AnswerModels.BatchRequest batch = new AnswerModels.BatchRequest();
batch.setAnswerIds(Arrays.asList(first, second));
assertThat(adminService.batchSoftDelete(SCH, 10001, batch))
.extracting(AnswerModels.BatchItemView::getSuccess).containsOnly(true);
for (String id : Arrays.asList(first, second)) {
Answer row = answerRepository.findByUuidAndSchId(id, SCH).get();
assertThat(row.getIsDeleted()).isEqualTo(1);
assertThat(opLogRepository.findByAnswerIdOrderByIdAsc(row.getId()))
.extracting(AnswerOpLog::getOp).containsExactly(AnswerOpLog.OP_BATCH_SOFT_DELETE);
assertThat(indexService.rowsOf(row.getId())).isEmpty();
}
}
// ==================== 小工具 ====================
private void changeStatus(String answerId, String status) {
AnswerModels.ChangeStatusRequest req = new AnswerModels.ChangeStatusRequest();
req.setAnswerId(answerId);
req.setStatus(status);
adminService.changeStatus(SCH, 10001, req);
}
private AnswerModels.BatchRequest withStatus(AnswerModels.BatchRequest batch) {
batch.setStatus("done");
return batch;
}
private FormModels.StatusDefView def(String code, String label) {
FormModels.StatusDefView view = new FormModels.StatusDefView();
view.setCode(code);
view.setLabel(label);
return view;
}
private com.fasterxml.jackson.databind.JsonNode json(String body) {
try {
return com.yunhai.formcenter.common.FormSchemaValidator.mapper().readTree(body);
} catch (Exception e) {
throw new IllegalStateException("测试 JSON 写错了:" + body, e);
}
}
}
@@ -0,0 +1,203 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.FillQuota;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormVersion;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.repository.AnswerIndexRepository;
import com.yunhai.formcenter.repository.AnswerRepository;
import com.yunhai.formcenter.repository.FillQuotaRepository;
import com.yunhai.formcenter.repository.FormRepository;
import com.yunhai.formcenter.repository.FormVersionRepository;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.AfterEach;
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.transaction.support.TransactionTemplate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 提交并发兜底取证(真实 MySQL 双线程,自验项 <b>V32</b> 的并发那半)。
*
* <p>本类<b>故意不加 {@code @Transactional}</b>:回滚型测试事务里两个"并发"提交其实串行在同一事务上,
* 唯一键竞态造不出来。这里每条提交各走各的事务并真实落库,
* 因此 {@code @AfterEach} 必须定点清理本类自建的数据(租户域 990401,不与其它 IT 交叉)。</p>
*/
@SpringBootTest
@DisplayName("AnswerService - 并发兜底(V32:唯一键才是并发下的裁判)")
class AnswerConcurrencyIT extends AnswerTestSupport {
private static final long SCH_C = 990401L;
private static final String APP = "form-center";
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true},"
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true,"
+ "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"}]}]}]}";
private static final String VALUES = "{\"name\":\"并发提交\",\"grade\":\"g2\"}";
private final List<String> createdCodes = new ArrayList<>();
@Autowired
private AnswerRepository answers;
@Autowired
private AnswerIndexRepository indexRepository;
@Autowired
private FillQuotaRepository quotaRepository;
@Autowired
private FormRepository formRepository;
@Autowired
private FormVersionRepository versionRepository;
@Autowired
private TransactionTemplate transactionTemplate;
@AfterEach
void purgeOwnData() {
transactionTemplate.executeWithoutResult(tx -> {
for (String code : createdCodes) {
for (Answer answer : answers.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH_C, APP, code)) {
indexRepository.deleteByAnswerId(answer.getId());
answers.delete(answer);
}
formRepository.findBySchIdAndAppCodeAndCode(SCH_C, APP, code).ifPresent(form -> {
quotaRepository.findByFormId(form.getId()).forEach(quotaRepository::delete);
for (FormVersion row : versionRepository.findByFormIdOrderByVersionDesc(form.getId())) {
versionRepository.delete(row);
}
formRepository.delete(form);
});
}
createdCodes.clear();
});
}
@Test
@DisplayName("V32:限一次的表单,同一人并发双提交——恰一条成功、恰一行落库、名额恰一行(uk 是裁判)")
void concurrentSubmitsAgainstOnceLimitProduceExactlyOneAnswer() throws Exception {
String code = "conc_quota";
createdCodes.add(code);
String uuid = publishForm(SCH_C, code, SCHEMA);
FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
config.setUuid(uuid);
config.setFillLimit(com.yunhai.formcenter.enums.FormEnums.FillLimit.ONCE.getCode());
defService.updateConfig(SCH_C, config);
Outcome[] outcomes = runTwo(() -> {
try {
String answerId = answerService.submit(user(70001, SCH_C, 1), submit(code, VALUES))
.getAnswerId();
return new Outcome(answerId, null);
} catch (BizException e) {
return new Outcome(null, e.getMessage());
}
});
List<Answer> rows = answers.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH_C, APP, code);
assertThat(rows).as("并发双提交只应留下一条答卷").hasSize(1);
assertThat(outcomes[0].answerId != null ^ outcomes[1].answerId != null)
.as("恰一条成功(另一条被限次拦下)").isTrue();
assertThat(outcomes[0].message + outcomes[1].message).contains("限填一次");
assertThat(indexRepository.findByAnswerIdOrderByIdAsc(rows.get(0).getId()))
.as("成功那条的索引行照常写入").isNotEmpty();
Form form = formRepository.findBySchIdAndAppCodeAndCode(SCH_C, APP, code).get();
assertThat(quotaRepository.findByFormId(form.getId()))
.extracting(FillQuota::getLimitKey).containsExactly("70001");
}
@Test
@DisplayName("V32 相邻面:同一幂等键并发双提交——恰一行答卷,且不产生两个不同的 answerId")
void concurrentSubmitsWithSameIdempotencyKeyProduceOneAnswer() throws Exception {
String code = "conc_idem";
createdCodes.add(code);
publishForm(SCH_C, code, SCHEMA);
String key = newIdemKey();
Outcome[] outcomes = runTwo(() -> {
try {
return new Outcome(answerService.submit(user(70002, SCH_C, 1),
submitWithKey(code, key, VALUES)).getAnswerId(), null);
} catch (BizException e) {
return new Outcome(null, e.getMessage());
}
});
List<Answer> rows = answers.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH_C, APP, code);
assertThat(rows).hasSize(1);
List<String> ids = new ArrayList<>();
for (Outcome outcome : outcomes) {
if (outcome.answerId != null) {
ids.add(outcome.answerId);
}
}
// 并发下的正确结局可以是"一个真插入 + 另一个撞 uk 后按契约回原答卷"(都 200、同一个 answerId),
// 也可以是一个 200 + 一个冲突提示;但绝不能出现两条答卷或两个不同的 answerId
assertThat(ids).isNotEmpty();
assertThat(ids).allMatch(id -> id.equals(rows.get(0).getUuid()));
assertThat(rows.get(0).getIdempotencyKey()).isEqualTo(key);
}
// ==================== 双线程脚手架 ====================
/** 两个线程在同一时刻放开跑同一段逻辑 */
private interface SubmitOne {
Outcome call();
}
private static final class Outcome {
private final String answerId;
private final String message;
private Outcome(String answerId, String message) {
this.answerId = answerId;
this.message = message;
}
}
private Outcome[] runTwo(SubmitOne action) throws InterruptedException {
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
AtomicReference<Outcome> firstRef = new AtomicReference<>();
AtomicReference<Outcome> secondRef = new AtomicReference<>();
Thread one = thread(ready, start, firstRef, action);
Thread two = thread(ready, start, secondRef, action);
one.start();
two.start();
ready.await();
start.countDown();
one.join(20000);
two.join(20000);
assertThat(one.isAlive() || two.isAlive()).as("线程未在 20s 内收尾,可能有锁等待").isFalse();
return new Outcome[]{firstRef.get(), secondRef.get()};
}
private Thread thread(CountDownLatch ready, CountDownLatch start,
AtomicReference<Outcome> slot, SubmitOne action) {
Thread t = new Thread(() -> {
ready.countDown();
try {
start.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
slot.set(action.call());
});
t.setDaemon(true);
return t;
}
}
@@ -0,0 +1,399 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.AnswerIndex;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 提交链路集成测试(真实 MySQL;自验项 <b>V17 / V31 / V32 / V33</b> 的功能面 + 契约 §3 索引落值)。
*
* <p>并发兜底(V32 的"并发同键双提交恰一条成功")不在本类——回滚型事务里造不出真实竞态,
* 由 {@code AnswerConcurrencyIT} 用双线程打真库取证。</p>
*/
@SpringBootTest
@Transactional
@DisplayName("AnswerService - 提交链路(V17/V31/V32/V33)")
class AnswerSubmitIT extends AnswerTestSupport {
/** name/score/birthday/grade/subjects 都声明 searchable;remark 故意不声明(V3 的另一半) */
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"maxLength\":20,"
+ "\"searchable\":true},"
+ "{\"key\":\"score\",\"label\":\"分数\",\"type\":\"number\",\"precision\":1,\"searchable\":true},"
+ "{\"key\":\"birthday\",\"label\":\"出生日期\",\"type\":\"date\",\"searchable\":true},"
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true,\"searchable\":true,"
+ "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高三\",\"value\":\"g3\"}]},"
+ "{\"key\":\"subjects\",\"label\":\"学科\",\"type\":\"checkbox\",\"searchable\":true,"
+ "\"options\":[{\"label\":\"数学\",\"value\":\"math\"},{\"label\":\"物理\",\"value\":\"phy\"}]},"
+ "{\"key\":\"remark\",\"label\":\"备注\",\"type\":\"textarea\"}"
+ "]}]}";
private static final String VALUES = "{\"name\":\"张三\",\"score\":95.5,\"birthday\":\"2010-03-09\","
+ "\"grade\":\"g2\",\"subjects\":[\"phy\",\"math\"],\"remark\":\"只进值文档,不进筛选\"}";
/** 升版后的 schema:多一个 v2 才有的 teacher 字段 */
private static final String SCHEMA_V2 = SCHEMA.replace(
"{\"key\":\"subjects\"", "{\"key\":\"teacher\",\"label\":\"指导教师\",\"type\":\"text\"},"
+ "{\"key\":\"subjects\"");
// ==================== V17:身份与 subject 授权 ====================
@Test
@DisplayName("V17:sch_id/submitter_id 由会话推导;普通用户把 subject 构造成他人被拒、=自己放行")
void subjectAuthorization() {
String code = "v17_form";
publishForm(code, SCHEMA);
Integer studentId = 20260001;
AnswerModels.SubmitRequest otherAsSubject = submit(code, VALUES);
otherAsSubject.setSubjectId(20260002);
assertThatThrownBy(() -> answerService.submit(user(studentId, SCH, 3), otherAsSubject))
.isInstanceOf(BizException.class)
.hasMessageContaining("无权为他人填报");
AnswerModels.SubmitRequest self = submit(code, VALUES);
self.setSubjectId(studentId);
AnswerModels.SubmitView view = answerService.submit(user(studentId, SCH, 3), self);
Answer stored = answerRepository.findByUuidAndSchId(view.getAnswerId(), SCH).orElse(null);
assertThat(stored).isNotNull();
assertThat(stored.getSubmitterId()).isEqualTo(studentId);
assertThat(stored.getSubjectId()).isEqualTo(studentId);
assertThat(stored.getSchId()).isEqualTo(SCH);
assertThat(stored.getVersion()).isEqualTo(1);
assertThat(stored.getStatus()).isEqualTo("pending");
}
@Test
@DisplayName("V17 正向:有管理权者可代填他人——submitter=操作人、subject=被代填人(两键各司其职,方案 §6.3)")
void adminCanFillOnBehalf() {
String code = "v17_proxy";
publishForm(code, SCHEMA);
Integer teacherId = 10001;
Integer studentId = 20269001;
AnswerModels.SubmitRequest req = submit(code, VALUES);
req.setSubjectId(studentId);
AnswerModels.SubmitView view = answerService.submit(user(teacherId, SCH, 7), req);
Answer stored = answerRepository.findByUuidAndSchId(view.getAnswerId(), SCH).get();
assertThat(stored.getSubmitterId()).isEqualTo(teacherId);
assertThat(stored.getSubjectId()).isEqualTo(studentId);
}
@Test
@DisplayName("未登录=未登录段、无学校归属身份不能提交(契约 §6 定稿映射)")
void sessionRequired() {
String code = "v17_session";
publishForm(code, SCHEMA);
assertThatThrownBy(() -> answerService.submit(null, submit(code, VALUES)))
.isInstanceOf(BizException.class).hasMessageContaining("未登录");
com.yunhai.platform.utils.model.BoaUser noSchool = new com.yunhai.platform.utils.model.BoaUser();
noSchool.setId(10001);
assertThatThrownBy(() -> answerService.submit(noSchool, submit(code, VALUES)))
.hasMessageContaining("无学校归属");
}
// ==================== V31:幂等 ====================
@Test
@DisplayName("V31:同幂等键重复提交只产生一条答卷,第二次返回 200+原 answerId(不是 409)、不覆盖原值")
void idempotencyReturnsOriginalAnswer() {
String code = "v31_form";
publishForm(code, SCHEMA);
String key = newIdemKey();
com.yunhai.platform.utils.model.BoaUser student = plainUser(3);
AnswerModels.SubmitView first = answerService.submit(student, submitWithKey(code, key, VALUES));
AnswerModels.SubmitView second = answerService.submit(student,
submitWithKey(code, key, "{\"name\":\"李四\",\"grade\":\"g3\"}"));
assertThat(second.getAnswerId()).isEqualTo(first.getAnswerId());
assertThat(first.getReplayed()).isFalse();
assertThat(second.getReplayed()).isTrue();
List<Answer> rows = answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", code);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getValuesJson()).contains("张三");
}
@Test
@DisplayName("幂等键是库级唯一键:别的账号撞上同键不泄露他人答卷,直接判越权(uk 语义的取舍见 T3 报告)")
void idempotencyKeyOwnedByAnotherAccountIsRejected() {
String code = "v31_cross_account";
publishForm(code, SCHEMA);
String key = newIdemKey();
answerService.submit(plainUser(3), submitWithKey(code, key, VALUES));
assertThatThrownBy(() -> answerService.submit(plainUser(3), submitWithKey(code, key, VALUES)))
.isInstanceOf(BizException.class).hasMessageContaining("其它账号占用");
}
@Test
@DisplayName("幂等键必填且 ≤64;不同键各自成一条")
void idempotencyKeyRequired() {
String code = "v31_key";
publishForm(code, SCHEMA);
AnswerModels.SubmitRequest blank = submit(code, VALUES);
blank.setIdempotencyKey(" ");
assertThatThrownBy(() -> answerService.submit(plainUser(3), blank))
.isInstanceOf(BizException.class).hasMessageContaining("idempotencyKey 必填");
StringBuilder longKey = new StringBuilder();
for (int i = 0; i < 65; i++) {
longKey.append('k');
}
AnswerModels.SubmitRequest tooLong = submit(code, VALUES);
tooLong.setIdempotencyKey(longKey.toString());
assertThatThrownBy(() -> answerService.submit(plainUser(3), tooLong)).hasMessageContaining("过长");
answerService.submit(plainUser(3), submit(code, VALUES));
answerService.submit(plainUser(3), submit(code, VALUES));
assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", code))
.hasSize(2);
}
// ==================== V32:限次(功能面;并发面见 AnswerConcurrencyIT) ====================
@Test
@DisplayName("V32:限一次——subject 非空按 subject 限(两教师代填同一学生,第二条被拒;换学生仍可填)")
void onceLimitIsPerSubjectWhenSubjectPresent() {
String code = "v32_subject";
String uuid = publishForm(code, SCHEMA);
config(SCH, uuid, limitOnce());
Integer student = 20261001;
AnswerModels.SubmitRequest first = submit(code, VALUES);
first.setSubjectId(student);
answerService.submit(user(10001, SCH, 7), first);
AnswerModels.SubmitRequest second = submit(code, "{\"name\":\"王五\",\"grade\":\"g2\"}");
second.setSubjectId(student);
assertThatThrownBy(() -> answerService.submit(user(10002, SCH, 7), second))
.isInstanceOf(BizException.class).hasMessageContaining("限填一次");
AnswerModels.SubmitRequest otherStudent = submit(code, "{\"name\":\"赵六\",\"grade\":\"g3\"}");
otherStudent.setSubjectId(20261002);
assertThat(answerService.submit(user(10002, SCH, 7), otherStudent).getAnswerId()).isNotBlank();
}
@Test
@DisplayName("V32:限一次——subject 为空按 submitter 限(同人第二次被拒);不限次表单不受影响")
void onceLimitFallsBackToSubmitter() {
String limited = "v32_submitter";
String uuid = publishForm(limited, SCHEMA);
config(SCH, uuid, limitOnce());
Integer sameUser = 10001;
answerService.submit(user(sameUser, SCH, 1), submit(limited, VALUES));
assertThatThrownBy(() -> answerService.submit(user(sameUser, SCH, 1), submit(limited, VALUES)))
.hasMessageContaining("限填一次");
String unlimited = "v32_unlimited";
publishForm(unlimited, SCHEMA);
answerService.submit(user(sameUser, SCH, 1), submit(unlimited, VALUES));
answerService.submit(user(sameUser, SCH, 1), submit(unlimited, VALUES));
assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", unlimited))
.hasSize(2);
}
@Test
@DisplayName("V32 后半:软删后释放名额,可重填(管理员删错单的第一天眼场景,方案 §5.4)")
void softDeleteReleasesQuota() {
String code = "v32_release";
String uuid = publishForm(code, SCHEMA);
config(SCH, uuid, limitOnce());
Integer student = 20262001;
AnswerModels.SubmitRequest first = submit(code, VALUES);
first.setSubjectId(student);
AnswerModels.SubmitView view = answerService.submit(user(10001, SCH, 7), first);
adminService.softDelete(SCH, 10001, view.getAnswerId());
AnswerModels.SubmitRequest refill = submit(code, "{\"name\":\"重新填的\",\"grade\":\"g3\"}");
refill.setSubjectId(student);
AnswerModels.SubmitView refilled = answerService.submit(user(10001, SCH, 7), refill);
assertThat(refilled.getAnswerId()).isNotEqualTo(view.getAnswerId());
assertThat(answerRepository.findByUuidAndSchId(refilled.getAnswerId(), SCH).get().getIsDeleted()).isZero();
}
// ==================== V33:截止与停用 ====================
@Test
@DisplayName("V33:截止后提交被拒(业务规则段);未过期放行(只选日期补 23:59:59)")
void deadlineBlocksSubmit() {
String code = "v33_deadline";
String uuid = publishForm(code, SCHEMA);
FormModels.FormConfigRequest past = new FormModels.FormConfigRequest();
past.setDeadline("2020-01-01");
config(SCH, uuid, past);
assertThatThrownBy(() -> answerService.submit(plainUser(3), submit(code, VALUES)))
.isInstanceOf(BizException.class).hasMessageContaining("已过收集截止时间");
FormModels.FormConfigRequest future = new FormModels.FormConfigRequest();
future.setDeadline("2099-01-01");
config(SCH, uuid, future);
assertThat(answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId()).isNotBlank();
}
@Test
@DisplayName("V33 后半:停用只断填报入口(提交与 resolve 同口径),重新发布可恢复")
void disabledFormClosesFillEntryOnly() {
String code = "v33_disabled";
String uuid = publishForm(code, SCHEMA);
defService.disable(SCH, uuid);
assertThatThrownBy(() -> answerService.submit(plainUser(3), submit(code, VALUES)))
.isInstanceOf(BizException.class).hasMessageContaining("已停用");
defService.enable(SCH, uuid);
assertThat(answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId()).isNotBlank();
}
// ==================== 钉版(P20)与索引(V3/V9) ====================
@Test
@DisplayName("P20 落地:升版窗口按旧版校验并钉旧版;发布后新字段才可用")
void submitPinsCurrentlyPublishedVersion() {
String code = "pin_version";
String uuid = publishForm(code, SCHEMA);
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(uuid);
save.setSchemaJson(SCHEMA_V2);
defService.save(SCH, save);
AnswerModels.SubmitRequest tooNew = submit(code,
"{\"name\":\"张三\",\"grade\":\"g2\",\"teacher\":\"李老师\"}");
assertThatThrownBy(() -> answerService.submit(plainUser(3), tooNew))
.isInstanceOf(BizException.class).hasMessageContaining("未知字段");
AnswerModels.SubmitView old = answerService.submit(plainUser(3), submit(code, VALUES));
assertThat(answerRepository.findByUuidAndSchId(old.getAnswerId(), SCH).get().getVersion()).isEqualTo(1);
defService.publish(SCH, uuid, "加指导教师字段");
AnswerModels.SubmitView neu = answerService.submit(plainUser(3),
submit(code, "{\"name\":\"张三\",\"grade\":\"g2\",\"teacher\":\"李老师\"}"));
assertThat(answerRepository.findByUuidAndSchId(neu.getAnswerId(), SCH).get().getVersion()).isEqualTo(2);
}
@Test
@DisplayName("索引只写 searchable 字段(V3):多值一值一行、date 落 v_date、number 落 v_num、文本超 191 截断")
void indexWritesSearchableOnly() {
String code = "index_v3";
// 这条用例要往 searchable 的文本字段塞 250 字符验证索引截断,因此把 maxLength 放宽(校验器本身另有用例)
publishForm(code, SCHEMA.replace("\"maxLength\":20", "\"maxLength\":1000"));
StringBuilder longName = new StringBuilder();
for (int i = 0; i < 250; i++) {
longName.append('n');
}
AnswerModels.SubmitView view = answerService.submit(plainUser(3), submit(code,
"{\"name\":\"" + longName + "\",\"score\":95.5,\"birthday\":\"2010-03-09\","
+ "\"grade\":\"g2\",\"subjects\":[\"phy\",\"math\"],\"remark\":\"不声明就不进索引\"}"));
Answer answer = answerRepository.findByUuidAndSchId(view.getAnswerId(), SCH).get();
List<AnswerIndex> rows = indexService.rowsOf(answer.getId());
List<String> keys = rows.stream().map(AnswerIndex::getFieldKey).collect(Collectors.toList());
assertThat(keys).contains("name", "score", "birthday", "grade", "subjects");
assertThat(keys).doesNotContain("remark");
assertThat(rows.stream().filter(r -> "subjects".equals(r.getFieldKey())).count()).isEqualTo(2);
assertThat(rows.stream().filter(r -> "birthday".equals(r.getFieldKey()))
.map(AnswerIndex::getVDate).collect(Collectors.toList()))
.containsExactly(LocalDate.of(2010, 3, 9));
assertThat(rows.stream().filter(r -> "score".equals(r.getFieldKey()))
.map(AnswerIndex::getVNum).collect(Collectors.toList()))
.containsExactly(new BigDecimal("95.5"));
String indexedName = rows.stream().filter(r -> "name".equals(r.getFieldKey()))
.map(AnswerIndex::getVStr).findFirst().get();
assertThat(indexedName).hasSize(AnswerIndex.MAX_V_STR_LEN);
assertThat(answer.getValuesJson()).contains(longName.toString());
}
@Test
@DisplayName("未开报名号时 answer_no 为空;开启后为 {code}-{yyyyMMdd}-{当日序号} 且逐条递增")
void answerNoGeneration() {
String code = "answer_no";
String uuid = publishForm(code, SCHEMA);
assertThat(answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerNo()).isNull();
FormModels.FormConfigRequest enable = new FormModels.FormConfigRequest();
enable.setAnswerNoEnabled(1);
config(SCH, uuid, enable);
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
assertThat(answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerNo())
.isEqualTo(code + "-" + today + "-0001");
assertThat(answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerNo())
.isEqualTo(code + "-" + today + "-0002");
}
@Test
@DisplayName("初始状态取该表单状态定义里第一个未废弃 code(默认待处理=pending)")
void initialStatusComesFromFormStatusDefs() {
String code = "status_default";
String uuid = publishForm(code, SCHEMA);
FormModels.StatusDefView draft = new FormModels.StatusDefView();
draft.setCode("first_review");
draft.setLabel("初筛");
FormModels.StatusDefsRequest defs = new FormModels.StatusDefsRequest();
defs.setUuid(uuid);
defs.setStatusDefs(Arrays.asList(draft));
defService.updateStatusDefs(SCH, defs);
String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
assertThat(answerRepository.findByUuidAndSchId(answerId, SCH).get().getStatus()).isEqualTo("first_review");
}
@Test
@DisplayName("提交期值校验按契约拒绝:伪造选项值、缺必填、number 给字符串")
void submitRejectsInvalidValues() {
String code = "bad_values";
publishForm(code, SCHEMA);
assertThatThrownBy(() -> answerService.submit(plainUser(3),
submit(code, "{\"name\":\"张三\",\"grade\":\"g9\"}")))
.isInstanceOf(BizException.class).hasMessageContaining("不在候选列表内");
assertThatThrownBy(() -> answerService.submit(plainUser(3), submit(code, "{\"grade\":\"g2\"}")))
.hasMessageContaining("必填");
assertThatThrownBy(() -> answerService.submit(plainUser(3),
submit(code, "{\"name\":\"张三\",\"grade\":\"g2\",\"score\":\"高分\"}")))
.hasMessageContaining("必须是数字");
}
@Test
@DisplayName("字典引用字段按放行集校验(P18/P19 贯通到提交侧);伪造 value 被拒")
void dictValuesGateAppliesAtSubmit() {
dictService.saveDict(SCH, "level", "荣誉级别");
dictService.saveItem(SCH, "level", "school", "校级", 1);
String code = "dict_submit";
publishForm(code, "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"level\",\"label\":\"级别\",\"type\":\"select\",\"required\":true,"
+ "\"options\":{\"source\":\"dict:level\"}}]}]}");
String accepted = answerService.submit(plainUser(3), submit(code, "{\"level\":\"school\"}"))
.getAnswerId();
assertThat(accepted).isNotBlank();
assertThatThrownBy(() -> answerService.submit(plainUser(3), submit(code, "{\"level\":\"province\"}")))
.isInstanceOf(BizException.class).hasMessageContaining("不在候选列表内");
}
// ==================== 小工具 ====================
private FormModels.FormConfigRequest limitOnce() {
FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
config.setFillLimit(FormEnums.FillLimit.ONCE.getCode());
return config;
}
}
@@ -0,0 +1,139 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.repository.AnswerRepository;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 答卷域测试脚手架(T3 各 IT 共用):会话用户构造 + 建表单并发布 + 提交入参拼装。
*
* <p>身份载体是平台 POJO {@link BoaUser}(真实链路由 CAS 过滤器填充),
* 测试直接造它——<b>这正是 V17 的断言方式</b>:服务签名里只有 {@code BoaUser} 与请求体,
* 请求体没有 {@code schId/submitterId} 字段,身份只能来自会话对象。</p>
*/
abstract class AnswerTestSupport {
/** 测试专用租户域(避开真实开发库数据;IT 结束一律回滚或显式清理) */
protected static final long SCH = 990301L;
protected static final long OTHER_SCH = 990302L;
private static final AtomicInteger USER_SEQ = new AtomicInteger(20000);
@Autowired
protected FormDefService defService;
@Autowired
protected FormDictService dictService;
@Autowired
protected AnswerService answerService;
@Autowired
protected AnswerAdminService adminService;
@Autowired
protected AnswerRepository answerRepository;
@Autowired
protected AnswerIndexService indexService;
// ==================== 会话用户 ====================
/** 普通用户(学生 roleId=3 / 教师 roleId=1):无管理权,subject 只能=自己 */
protected BoaUser plainUser(Integer roleId) {
return user(USER_SEQ.incrementAndGet(), SCH, roleId, 0);
}
/** 普通登录用户(type=0 非管理员):指定 id 版 */
protected BoaUser user(Integer id, Long schId, Integer roleId) {
return user(id, schId, roleId, roleId.equals(7) ? 2 : 0);
}
/** 中心管理员(roleId=7 管理员):有管理权,可代填他人 */
protected BoaUser adminUser() {
return user(USER_SEQ.incrementAndGet(), SCH, 7, 2);
}
/** 另一所学校的用户(跨校探测用) */
protected BoaUser userAtOtherSchool() {
return user(USER_SEQ.incrementAndGet(), OTHER_SCH, 7, 2);
}
/** 指定 id 的会话用户(并发/限次用例需要"同一个人"稳定复现) */
protected BoaUser user(Integer id, Long schId, Integer roleId, Integer type) {
BoaUser user = new BoaUser();
user.setId(id);
user.setType(type);
user.setRoleIds(java.util.Collections.singletonList(roleId));
BoaRoleInfo current = new BoaRoleInfo();
current.setRoleId(roleId);
current.setSchId(schId.intValue());
user.setCurrentRole(current);
return user;
}
// ==================== 建定义 ====================
/** 建表单 → 保存 schema → 发布,返回 uuid */
protected String publishForm(String code, String schemaJson) {
return publishForm(SCH, code, schemaJson);
}
protected String publishForm(Long schId, String code, String schemaJson) {
FormModels.FormCreateRequest create = new FormModels.FormCreateRequest();
create.setCode(code);
create.setName("测试 " + code);
String uuid = defService.create(schId, create).getFormUuid();
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(uuid);
save.setSchemaJson(schemaJson);
defService.save(schId, save);
defService.publish(schId, uuid, null);
return uuid;
}
protected void config(Long schId, String formUuid, FormModels.FormConfigRequest config) {
config.setUuid(formUuid);
defService.updateConfig(schId, config);
}
// ==================== 提交入参 ====================
protected AnswerModels.SubmitRequest submit(String formCode, String valuesJson) {
AnswerModels.SubmitRequest req = new AnswerModels.SubmitRequest();
req.setFormCode(formCode);
req.setIdempotencyKey(newIdemKey());
req.setValues(readTree(valuesJson));
return req;
}
protected AnswerModels.SubmitRequest submitWithKey(String formCode, String idemKey, String valuesJson) {
AnswerModels.SubmitRequest req = submit(formCode, valuesJson);
req.setIdempotencyKey(idemKey);
return req;
}
protected String newIdemKey() {
return java.util.UUID.randomUUID().toString().replace("-", "");
}
protected Answer lastAnswer(String code) {
List<Answer> rows = answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", code);
return rows.isEmpty() ? null : rows.get(rows.size() - 1);
}
private static com.fasterxml.jackson.databind.JsonNode readTree(String json) {
try {
return com.yunhai.formcenter.common.FormSchemaValidator.mapper().readTree(json);
} catch (Exception e) {
throw new IllegalStateException("测试 JSON 写错了:" + json, e);
}
}
}
@@ -0,0 +1,115 @@
package com.yunhai.formcenter.service;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 管理权闸门单测(执行手册 §5 <b>P21 裁决</b>的落地断言,也是 V17/V14/V29 的权限层依据)。
*
* <p>口径:超管 {@code type==1}(UserBase {@code UserTypeEnum.SUPER_ADMIN})可跨校且唯一可管
* {@code sch_id=0} 平台级行;校级限 {@code roleId ∈ {2 校领导, 7 管理员}}——
* <b>局领导(5) 被明确移出</b>(契约 §8 矩阵里 5=回落"仅本人提交",属查看角色非维护角色)。
* 这是 T4 换成"授权模块集"口径之前的过渡态。</p>
*/
@DisplayName("FormAdminGuard - P21 过渡闸门")
class FormAdminGuardTest {
private static final long SCH = 880001L;
private final FormAdminGuard guard = new FormAdminGuard();
@Test
@DisplayName("P21 收紧:校领导(2)/管理员(7) 可管且只能管本校;局领导(5) 与教师(1)/学生(3) 都不可管")
void onlyRoleIdsTwoAndSevenManage() {
assertThat(guard.requireAdmin(schoolUser(2), null)).isEqualTo(SCH);
assertThat(guard.requireAdmin(schoolUser(7), null)).isEqualTo(SCH);
for (int roleId : Arrays.asList(5, 1, 3, 4, 6)) {
assertThatThrownBy(() -> guard.requireAdmin(schoolUser(roleId), null))
.as("roleId=" + roleId)
.isInstanceOf(BizException.class)
.hasMessageContaining("无表单管理权限");
assertThat(guard.canManage(schoolUser(roleId))).as("canManage roleId=" + roleId).isFalse();
}
}
@Test
@DisplayName("校级管理员指定他校 schId 被拒(含把 schId 传成 0 想碰平台级行)")
void schoolAdminCannotCrossDomain() {
BoaUser admin = schoolUser(7);
assertThatThrownBy(() -> guard.requireAdmin(admin, 999999L))
.isInstanceOf(BizException.class).hasMessageContaining("仅可管理本校");
assertThatThrownBy(() -> guard.requireAdmin(admin, 0L))
.hasMessageContaining("仅超管可维护");
}
@Test
@DisplayName("超管可显式指定任意域(含 0=平台级),未指定时回落当前身份学校")
void superAdminCanTargetAnyDomain() {
BoaUser root = new BoaUser();
root.setId(1);
root.setType(1);
assertThat(guard.requireAdmin(root, 0L)).isEqualTo(0L);
assertThat(guard.requireAdmin(root, SCH)).isEqualTo(SCH);
root.setCurrentRole(role(3, (int) SCH));
assertThat(guard.requireAdmin(root, null)).isEqualTo(SCH);
assertThat(guard.canManage(root)).isTrue();
BoaUser rootWithoutSchool = new BoaUser();
rootWithoutSchool.setId(1);
rootWithoutSchool.setType(1);
assertThatThrownBy(() -> guard.requireAdmin(rootWithoutSchool, null))
.hasMessageContaining("超管请显式指定 schId");
}
@Test
@DisplayName("无会话=UNAUTHORIZED(不是 FORBIDDEN)——契约 §6 定稿映射的未登录段")
void missingSessionIsUnauthorized() {
assertThatThrownBy(() -> guard.requireAdmin(null, null))
.isInstanceOf(BizException.class).hasMessageContaining("未登录");
BoaUser anonymous = new BoaUser();
assertThatThrownBy(() -> guard.requireAdmin(anonymous, null)).hasMessageContaining("未登录");
assertThat(guard.canManage(null)).isFalse();
assertThat(guard.canManage(anonymous)).isFalse();
}
@Test
@DisplayName("有当前身份但无学校归属(区域组/根直属)不算管理权——不给出「本校」域")
void noSchoolBindingIsNotAdmin() {
BoaUser user = new BoaUser();
user.setId(10133);
user.setRoleIds(Collections.singletonList(7));
user.setCurrentRole(role(7, null));
assertThatThrownBy(() -> guard.requireAdmin(user, null)).hasMessageContaining("无表单管理权限");
assertThat(guard.canManage(user)).isFalse();
BoaUser full = schoolUser(7);
assertThatCode(() -> guard.requireAdmin(full, SCH)).doesNotThrowAnyException();
}
private BoaUser schoolUser(int roleId) {
BoaUser user = new BoaUser();
user.setId(10133);
user.setType(2);
user.setRoleIds(Arrays.asList(roleId));
user.setCurrentRole(role(roleId, (int) SCH));
return user;
}
private BoaRoleInfo role(Integer roleId, Integer schId) {
BoaRoleInfo info = new BoaRoleInfo();
info.setRoleId(roleId);
info.setSchId(schId);
return info;
}
}
@@ -0,0 +1,241 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.repository.FormFileRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.model.BoaUser;
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.mock.web.MockMultipartFile;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 上传模块集成测试(真实 MySQL + 本地临时存储根;编排补充项 <b>V24 的本地可断言部分</b>:
* 非白名单扩展名被拒、返回值只含 {@code {id,fileName,key}}、答卷值不含绝对 URL。
* "经 FILE_WEB_PATH 可访问"要等 T9 部署环境,本轮不断言)。
*
* <p>存储根经 {@code file.upload-path} 指向 {@code target/tmp-upload}:
* {@code ${FILE_PATH:${FILE_UPLOAD_PATH:${file.upload-path:/data/upload}}}} 的第三级兜底,
* 即"未注入平台 env 时读 jar 内本地键"这条真实分支——测试顺手把这条读取形态钉住了。</p>
*/
@SpringBootTest
@Transactional
@TestPropertySource(properties = "file.upload-path=target/tmp-upload")
@DisplayName("FormFileService - 上传模块(V24 本地部分)")
class FormFileServiceIT extends AnswerTestSupport {
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"cert\",\"label\":\"证书照片\",\"type\":\"image\",\"maxFiles\":2},"
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true}"
+ "]}]}";
@Autowired
private FormFileService fileService;
@Autowired
private FormFileRepository fileRepository;
@Test
@DisplayName("V24:上传成功只回 {id,fileName,key};key 是 form/yyyy/MM/随机串.ext 的相对路径,落盘可见")
void uploadReturnsContractTripleAndStoresUnderRoot() throws Exception {
AnswerModels.FileView view = fileService.upload(user(10001, SCH, 3),
file("证书 照片.JPG", "img/jpeg", "hello".getBytes()));
JsonNode json = com.yunhai.formcenter.common.FormSchemaValidator.mapper().valueToTree(view);
List<String> fields = new java.util.ArrayList<>();
json.fieldNames().forEachRemaining(fields::add);
assertThat(fields).containsExactlyInAnyOrder("id", "fileName", "key");
assertThat(view.getId()).isPositive();
assertThat(view.getFileName()).isEqualTo("证书 照片.JPG");
assertThat(view.getKey())
.matches("^form/\\d{4}/\\d{2}/[0-9a-f]{32}\\.jpg$")
.doesNotContain("http");
File stored = fileService.resolveStored(view.getKey()).toFile();
assertThat(stored).as("落点在存储根之下且文件真实存在").exists();
assertThat(stored.length()).isEqualTo(5);
com.yunhai.formcenter.entity.FormFile row = fileRepository.findById(view.getId()).get();
assertThat(row.getSchId()).isEqualTo(SCH);
assertThat(row.getExt()).isEqualTo("jpg");
assertThat(row.getStoreKey()).isEqualTo(view.getKey());
// 库里存的也是相对 key(不是拼好的 URL)——二期换域名不会打断历史附件
assertThat(row.getStoreKey()).doesNotContain("http");
}
@Test
@DisplayName("存储名与用户文件名无关:同名重复上传各得一个随机 key(不覆盖),路径穿越输入被吸收成展示名")
void storedNameIsRandomNotUserControlled() {
AnswerModels.FileView a = fileService.upload(user(10001, SCH, 3), file("a.jpg", "image/jpeg", new byte[]{1}));
AnswerModels.FileView b = fileService.upload(user(10001, SCH, 3), file("a.jpg", "image/jpeg", new byte[]{2}));
assertThat(a.getKey()).isNotEqualTo(b.getKey());
AnswerModels.FileView traversal = fileService.upload(user(10001, SCH, 3),
file("..\\..\\windows\\note.txt", "text/plain", new byte[]{3}));
assertThat(traversal.getFileName()).isEqualTo("note.txt"); // 只取最后一段,分隔符与上跳被吸收
assertThat(traversal.getKey()).startsWith("form/").doesNotContain("..");
assertThat(fileService.resolveStored(traversal.getKey())).startsWith(
java.nio.file.Paths.get("target/tmp-upload").toAbsolutePath().normalize());
}
@Test
@DisplayName("V24:危险扩展名一律拒(平台同源 15 项 + svg);缺扩展名/非法扩展名字符也拒")
void dangerousExtensionsRejected() {
for (String name : Arrays.asList("shell.jsp", "page.html", "tool.exe", "x.htaccess",
"a.war", "b.class", "c.php", "d.aspx", "e.js", "f.sh", "g.bat", "h.jspx", "i.htm", "j.asp", "k.jar")) {
final String fileName = name;
assertThatThrownBy(() -> fileService.upload(user(10001, SCH, 3),
file(fileName, "application/octet-stream", new byte[]{1})))
.as(fileName)
.isInstanceOf(BizException.class)
.hasMessageContaining("不允许上传的文件类型");
}
// 本轮追加项:同源静态直访下 svg 可携带脚本,"key 即访问控制"这套假设兜不住它
assertThatThrownBy(() -> fileService.upload(user(10001, SCH, 3),
file("icon.svg", "image/svg+xml", new byte[]{1})))
.hasMessageContaining("不允许上传的文件类型:svg");
assertThatThrownBy(() -> fileService.upload(user(10001, SCH, 3), file("noext", "application/octet-stream",
new byte[]{1}))).hasMessageContaining("缺少扩展名");
// 扩展名形态非法(含百分号/空格)直接拒;伪装成 .jpg 的 .jsp 因"取最后一段扩展名+随机存储名"而失效
assertThatThrownBy(() -> fileService.upload(user(10001, SCH, 3), file("x.jp%67", "image/jpeg",
new byte[]{1}))).hasMessageContaining("扩展名不合法");
// 伪装双扩展名按<b>最后一段</b>判定,直接拒;即便放行,存储名也是随机串(不带 .jsp),不构成执行面
assertThatThrownBy(() -> fileService.upload(user(10001, SCH, 3), file("report.pdf.jsp", "application/pdf",
new byte[]{1})))
.isInstanceOf(BizException.class)
.hasMessageContaining("不允许上传的文件类型:jsp");
assertThatCode(() -> fileService.upload(user(10001, SCH, 3), file("ok.docx", "application/msword",
new byte[]{1}))).doesNotThrowAnyException();
}
@Test
@DisplayName("单文件 ≤50MB(方案 §7.7 全局兜底):超限拒且不留文件行;空文件拒")
void fileSizeCap() {
MultipartFile tooBig = new OversizedFile("big.pdf", 50L * 1024 * 1024 + 1);
assertThatThrownBy(() -> fileService.upload(user(10001, SCH, 3), tooBig))
.isInstanceOf(BizException.class).hasMessageContaining("不得超过 50MB");
assertThatThrownBy(() -> fileService.upload(user(10001, SCH, 3),
new MockMultipartFile("file", "empty.jpg", "image/jpeg", new byte[0])))
.hasMessageContaining("上传文件为空");
}
@Test
@DisplayName("未登录与无学校归属身份不能上传(契约 §7 身份行)")
void uploadRequiresSession() {
assertThatThrownBy(() -> fileService.upload(null, file("a.jpg", "image/jpeg", new byte[]{1})))
.isInstanceOf(BizException.class).hasMessageContaining("未登录");
BoaUser noSchool = new BoaUser();
noSchool.setId(1);
assertThatThrownBy(() -> fileService.upload(noSchool, file("a.jpg", "image/jpeg", new byte[]{1})))
.hasMessageContaining("无学校归属");
}
@Test
@DisplayName("存储根读取形态生效:未注入 FILE_PATH/FILE_UPLOAD_PATH 时落到 jar 内本地兜底键(P12/yml 注释那条链)")
void storeRootFollowsDocumentedPlaceholderChain() {
// 本类经 @TestPropertySource 把本地兜底键指到 target/tmp-upload;
// 断言"生效根=兜底根"即证明占位链的最后两级(file.upload-path → 默认值)在真实上下文里被正确解析
assertThat(fileService.getLocalFallbackRoot()).isEqualTo("target/tmp-upload");
assertThat(fileService.getEffectiveStoreRoot()).isEqualTo(fileService.getLocalFallbackRoot());
}
@Test
@DisplayName("提交值里的附件 id 必须是本校上传过的行(伪造 id 被拒);真实上传的 id 正常入库")
void submittedAttachmentIdsMustBeOwned() {
String code = "attach_submit";
publishForm(code, SCHEMA);
AnswerModels.FileView uploaded = fileService.upload(user(10001, SCH, 3),
file("cert.jpg", "image/jpeg", "img".getBytes()));
String realId = String.valueOf(uploaded.getId());
assertThatCode(() -> answerService.submit(user(10001, SCH, 3), submit(code,
"{\"name\":\"张三\",\"cert\":[{\"id\":" + realId + ",\"fileName\":\"cert.jpg\","
+ "\"key\":\"" + uploaded.getKey() + "\"}]}")))
.doesNotThrowAnyException();
assertThatThrownBy(() -> answerService.submit(user(10001, SCH, 3), submit(code,
"{\"name\":\"张三\",\"cert\":[{\"id\":99999999,\"fileName\":\"x.jpg\",\"key\":\"form/2026/1/x.jpg\"}]}")))
.isInstanceOf(BizException.class).hasMessageContaining("附件不存在或不属于本校");
// 绝对 URL 进值也被拒(V24 的"答卷值中不含绝对 URL")
assertThatThrownBy(() -> answerService.submit(user(10001, SCH, 3), submit(code,
"{\"name\":\"张三\",\"cert\":[{\"id\":" + realId + ",\"fileName\":\"cert.jpg\","
+ "\"key\":\"http://cdn.example/cert.jpg\"}]}")))
.hasMessageContaining("相对存储路径");
}
// ==================== 小工具 ====================
private MockMultipartFile file(String name, String contentType, byte[] content) {
return new MockMultipartFile("file", name, contentType, content);
}
/** 只报体积、不真的占 50MB 内存的超大文件(测大小上限用) */
private static final class OversizedFile implements MultipartFile {
private final String name;
private final long size;
private OversizedFile(String name, long size) {
this.name = name;
this.size = size;
}
@Override
public String getName() {
return "file";
}
@Override
public String getOriginalFilename() {
return name;
}
@Override
public String getContentType() {
return "application/octet-stream";
}
@Override
public boolean isEmpty() {
return false;
}
@Override
public long getSize() {
return size;
}
@Override
public byte[] getBytes() {
return new byte[0];
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(new byte[0]);
}
@Override
public void transferTo(File dest) {
throw new UnsupportedOperationException("不该被调用:体积校验在前");
}
}
}