答卷域是"校验只写一遍、索引只有一份"这条选型(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:
@@ -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(平台注入)> FILE_UPLOAD_PATH(兼容别名)> jar 内本地兜底 > /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());
|
||||
}
|
||||
}
|
||||
@@ -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 事故):
|
||||
|
||||
Reference in New Issue
Block a user