t18:批量导入——逐行走 submit 管线 + C13 确定性幂等键 + C14 表头先校验与逐行结果

- 新 AnswerImportService:模板(两行表头:当前发布版字段 label + 固定第一列「被记录人ID」,第二行说明)
  与导入两端点都挂在 /api/form/admin/data 下、守卫与导出同一把 requireData(该表单 ALL 档)。
- 每一行拼成一次普通提交请求进 AnswerService.submit(V60 采集管线唯一):值校验、按钉住版本解释、
  限次、截止、幂等、索引、MQ 事件与手工填报逐字同一套;导入侧不写第二份校验,只做
  "读 xlsx → 按 label 映射列 → 最小类型转换(number/多选数组,其余留字符串)→ 逐行收集结果"。
- 坏行不静默:逐行 {rowNo, ok, answerId|error},失败原因是管线原话不改写;rowNo 用文件物理行号,
  "第 7 行"能直接指到那一格。附件类字段逐行点名"批量导入不支持上传",不静默丢列。
- 整批在写库之前拒的三件(C14):重名 label(模板生成侧同样先判,不给带歧义的模板)、
  表头有当前发布版不存在的列、行数超 1000(超限整批拒不是截断)。
- 幂等键 C13 定稿:sha256("import:" + 表单 uuid + "|" + subject + "|" + 规范化值文档) 前 40 位。
  批次串取表单坐标——请求级随机串等于没有幂等,文件字节摘要会让"改一个字重传整份"把未改的行
  全部重插(1000 行修 1 行 = 999 条重复);也不含行号。前缀让导入键与手工填报的客户端 uuid 键不相交。
  登记问题单 P128(已按本单回写契约 §5.1/C13)。
- FormAnswerReadController 加 GET /import-template(xlsx 流,原生 fetch 侧配套)与 POST /import(JSON 信封)。

测试 AnswerImportIT(V60/V74)12 例:模板形状与说明行、未发布两侧拒、导入行带 form_uuid+version 且
searchable 字段照写 answer_index、四类坏行逐行原话 + 物理行号、被记录人非整数逐行拒、
代录行 subject/submitter 归属正确且非 ALL 档进不到服务、同一文件重传逐行原 answerId 且改一格只新增那一行、
限次照挡、三类整批拒且零写入。门禁定向 12 例全绿(s7/import-it-4.log,MVN_EXIT=0)。
This commit is contained in:
chenxing
2026-10-20 17:40:16 +08:00
parent b3478afddd
commit be2752ed4d
4 changed files with 952 additions and 0 deletions
@@ -1,7 +1,9 @@
package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.model.ImportModels;
import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.service.AnswerExportService;
import com.yunhai.formcenter.service.AnswerImportService;
import com.yunhai.formcenter.service.AnswerReadService;
import com.yunhai.formcenter.service.AnswerStatsService;
import com.yunhai.formcenter.service.DataScopeService;
@@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@@ -55,6 +58,7 @@ public class FormAnswerReadController {
private final AnswerReadService readService;
private final AnswerStatsService statsService;
private final AnswerExportService exportService;
private final AnswerImportService importService;
/** 两段式列表(keyset 游标对外是 uuid,排序恒为提交时间序) */
@PostMapping("/list")
@@ -122,6 +126,46 @@ public class FormAnswerReadController {
}
}
/**
* 导入模板下载(契约 v2 C14,T18)。表头=当前发布版本的字段 label,第一列固定"被记录人ID",
* 第二行是给人看的说明;与 {@link #importAnswers} 的解析共用同一份映射(都在 {@code AnswerImportService})。
*
* <p>守卫与导出同一把(该表单 <b>ALL 档</b>)——模板本身就是数据面产物(它给出这张表的字段清单),
* 且拿到模板的人下一步就是要写数据。未发布的表单没有可导入的字段定义,服务侧直接拒。</p>
*/
@GetMapping("/import-template")
public void importTemplate(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("formUuid") String formUuid,
HttpServletResponse response) throws IOException {
requireFormUuid(formUuid);
Long domain = adminGuard.requireData(user, schId, defService.findFormForGuard(formUuid));
String fileName = "form-import-template.xlsx";
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("UTF-8");
response.setHeader("Content-Disposition",
"attachment; filename=" + fileName + "; filename*=UTF-8''" + encode(fileName));
try (OutputStream out = response.getOutputStream()) {
importService.template(domain, formUuid, out);
}
}
/**
* 批量导入(C14/C13,T18):multipart xlsx,<b>逐行走提交管线</b>(V60),响应逐行结果不静默坏行。
*
* <p>这条端点返回 JSON 信封(与导出不同:它不是文件流)。幂等键由服务端按 C13 确定性生成,
* 所以"重复导入同一个文件"是逐行 200+原 answerId,而不是重复落库。</p>
*/
@PostMapping("/import")
public ImportModels.Result importAnswers(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("formUuid") String formUuid,
@RequestParam("file") MultipartFile file) {
requireFormUuid(formUuid);
Long domain = adminGuard.requireData(user, schId, defService.findFormForGuard(formUuid));
return importService.importAnswers(user, domain, formUuid, file);
}
private static void requireFormUuid(String formUuid) {
if (formUuid == null || formUuid.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "formUuid 不能为空(数据面按表单 uuid 寻址,契约 §1)");
@@ -0,0 +1,44 @@
package com.yunhai.formcenter.model;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 批量导入的响应模型(契约 v2 C14,T18)。
*
* <p><b>逐行结果,坏行不静默</b>:导入是"把 Excel 里的一堆行交给提交管线",管线对每一行都可能给出
* 不同的答案(值校验/必填/截止/限次/被记录人不存在…),所以响应形状是每行一条 {@link RowResult},
* 而不是"整体成功/失败"。整批 500 行里 499 行成功必须能从响应里看出那 1 行为什么失败。</p>
*/
public final class ImportModels {
private ImportModels() {
}
/** {@code POST /api/form/admin/data/import} 的 data */
@Getter
@Setter
public static class Result {
/** 逐行结果,按文件里的行号顺序({@code rowNo} 是 xlsx 的物理行号,1 起) */
private List<RowResult> rows;
private int okCount;
private int failCount;
}
/** 单行结果(C14 的字段闭集就这三件可选两件:{@code {rowNo, ok, answerId?, error?}},不加扩展位) */
@Getter
@Setter
public static class RowResult {
private Integer rowNo;
private Boolean ok;
/**
* 成功时的答卷 uuid。命中幂等键(重复导入同一个文件,C13)时是<b>原</b>答卷 uuid——
* 契约明令"逐行返回 200+原 answerId",所以这种情况同样算 ok,不额外给"重复"标记位。
*/
private String answerId;
/** 失败原因=管线给的那句话原样(不在导入侧改写,否则同一个校验在两个地方有两种说法) */
private String error;
}
}
@@ -0,0 +1,473 @@
package com.yunhai.formcenter.service;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.ImportModels;
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.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 批量导入(契约 v2 §5.1/C13/C14,T18,验收 <b>V60/V74</b>)。
*
* <p><b>导入不是第二条写库路径</b>:每一行都拼成一次普通提交请求进 {@link AnswerService#submit}
* (V60 采集管线唯一),所以值校验、按钉住版本解释、附件归属、限次、截止、幂等、索引、事件
* 与手工填报<b>逐字同一套</b>——"导入数据与手工填的数据完全可比"(§3.4)靠的就是这个,
* 而不是导入侧再写一份校验。本类只做四件事:读 xlsx、按 label 映射列、算<b>确定性</b>幂等键、
* 逐行收集结果。</p>
*
* <p>四条纪律:</p>
* <ol>
* <li><b>坏行不静默</b>:一行的失败不影响其它行(逐行独立调用管线并独立捕获),响应里逐行给
* {@code {rowNo, ok, answerId|error}};失败原因是管线原话,导入侧不改写;</li>
* <li><b>幂等键确定性</b>(C13):{@code sha256("import:" + 表单 uuid + "|" + subject + "|" + 规范化值文档)}。
* "批次串"取<b>表单坐标</b>而不是请求级随机 uuid(那等于没有幂等),也不是文件字节摘要——
* 字节摘要会让"改一个字重传整份"把未改的行全部重插一遍。详见 {@link #idempotencyKey};
* 落地差额登记在手册 §5 <b>P128</b>;</li>
* <li><b>表头先校验再读数据</b>(C14):重名 label、当前版本没有的列、一列都不认识,都在碰到任何一行之前拒。
* "认不出的列就当没有"会让那一列的数据静默消失,比报错糟得多;</li>
* <li><b>行上限 1000,超限整批拒</b>:截断会让"导入完成"与"文件里有多少行"两个数不一致且无从发现。</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AnswerImportService {
/** C14:单次导入行上限(超限 PARAM_ERROR) */
static final int MAX_ROWS = 1000;
/** 模板第一列的表头(可空;填 UserBase 用户主键=被记录人,即代录语义) */
static final String SUBJECT_HEADER = "被记录人ID";
/** 模板第二行的说明行前缀(读侧据此跳过;用户手动删掉也不影响解析) */
private static final String NOTE_PREFIX = "说明:";
/** 导入文件大小上限:xlsx 是纯数据,与附件上传的 50MB 不同轴,不共用那个常量 */
private static final long MAX_FILE_BYTES = 10L * 1024 * 1024;
/** 幂等键长度:列宽 64,取 40 位十六进制留足余量 */
private static final int IDEMPOTENCY_KEY_LENGTH = 40;
private static final List<String> MULTI_OPTION_TYPES = Arrays.asList("checkbox", "multiselect");
private static final Set<String> UNSUPPORTED_TYPES = new HashSet<>(Arrays.asList("upload", "image"));
private final FormDefService defService;
private final FormDictService dictService;
private final AnswerService answerService;
// ==================== 模板(C14 的下载件) ====================
/**
* 生成导入模板:表头=当前发布版本的字段 label(前面固定一列"被记录人ID"),第二行是给填表人看的说明。
*
* <p>列序取<b>当前发布版本</b>的字段序(与导出同源那条规则)——导入的行会钉在这个版本上,
* 模板给旧版本的列会让每一行都撞上"未知字段"。</p>
*/
public void template(Long domainSchId, String formUuid, OutputStream out) {
Form form = defService.readDomainForm(domainSchId, formUuid);
Map<String, JsonNode> fields = publishedFields(form);
requireUniqueLabels(fields);
EasyExcel.write(out).head(headOf(fields)).sheet("导入").doWrite(new ArrayList<>());
}
// ==================== 导入 ====================
/**
* 逐行导入。
*
* @param domainSchId 已由控制器过 {@code requireData}(该表单 ALL 档)的租户域
*/
public ImportModels.Result importAnswers(BoaUser user, Long domainSchId, String formUuid, MultipartFile file) {
byte[] bytes = requireFile(file);
Form form = defService.readDomainForm(domainSchId, formUuid);
Map<String, JsonNode> fields = publishedFields(form);
List<SheetRow> rows = readRows(bytes);
if (rows.size() - 1 > MAX_ROWS) {
throw new BizException(ErrorCode.PARAM_ERROR,
"单次导入行数上限 " + MAX_ROWS + " 行(当前 " + (rows.size() - 1) + " 行),请拆分文件");
}
Map<Integer, String> columnByKey = mapColumns(rows.get(0).cells, fields);
ImportModels.Result result = new ImportModels.Result();
List<ImportModels.RowResult> items = new ArrayList<>();
int ok = 0;
for (SheetRow row : rows.subList(1, rows.size())) {
ImportModels.RowResult item = new ImportModels.RowResult();
item.setRowNo(row.rowNo);
try {
AnswerModels.SubmitView view = answerService.submit(user,
requestOf(form, row, fields, columnByKey));
item.setOk(true);
item.setAnswerId(view.getAnswerId());
ok++;
} catch (BizException e) {
item.setOk(false);
item.setError(e.getMessage());
} catch (RuntimeException e) {
// 单行的意外异常不能带倒整批(与批量软删/批量改状态同一条"逐条独立"的既有口径)
item.setOk(false);
item.setError("导入该行时服务内部异常:" + e.getClass().getSimpleName());
log.warn("[导入] 第 {} 行异常", row.rowNo, e);
}
items.add(item);
}
result.setRows(items);
result.setOkCount(ok);
result.setFailCount(items.size() - ok);
log.info("[导入] form={}/{} 数据行={} 成功={} 失败={}",
form.getAppCode(), form.getCode(), items.size(), ok, result.getFailCount());
return result;
}
// ==================== 行 → 提交请求 ====================
private AnswerModels.SubmitRequest requestOf(Form form, SheetRow row, Map<String, JsonNode> fields,
Map<Integer, String> columnByKey) {
AnswerModels.SubmitRequest req = new AnswerModels.SubmitRequest();
req.setFormCode(form.getCode());
req.setAppCode(form.getAppCode());
req.setSubjectId(subjectOf(row));
req.setValues(valuesOf(row, fields, columnByKey));
req.setIdempotencyKey(idempotencyKey(form.getUuid(), req.getSubjectId(), req.getValues()));
return req;
}
private static Integer subjectOf(SheetRow row) {
String raw = trim(row.cells.get(0));
if (raw == null || raw.isEmpty()) {
return null;
}
try {
// Excel 的数字单元格可能带小数尾巴("10133.0"),按整数取
return Integer.valueOf(new BigDecimal(raw).toBigInteger().toString());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "被记录人ID 必须是整数:\"" + raw + "\"");
}
}
private ObjectNode valuesOf(SheetRow row, Map<String, JsonNode> fields, Map<Integer, String> columnByKey) {
ObjectNode values = FormSchemaValidator.mapper().createObjectNode();
for (Map.Entry<Integer, String> column : columnByKey.entrySet()) {
String key = column.getValue();
JsonNode field = fields.get(key);
String raw = trim(row.cells.get(column.getKey()));
if (raw == null || raw.isEmpty()) {
continue;
}
String type = field == null ? "" : field.path("type").asText("");
if (UNSUPPORTED_TYPES.contains(type)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"字段「" + labelOf(field, key) + "」是附件类(" + type + "),批量导入不支持上传,"
+ "请逐条填写(" + key + ")");
}
values.set(key, valueNode(raw, type));
}
return values;
}
/**
* 单元格文本 → 值节点。<b>只做最小的类型转换</b>:能转成数字就转、多选取成数组,其余一律留字符串——
* 格式对不对由提交管线里那一份 {@code FormValueValidator} 判。数字格转不动时也原样给字符串,
* 让校验器说出与手工填报同一句"必须是数字",而不是导入侧编一条。
*/
private static JsonNode valueNode(String raw, String type) {
if ("number".equals(type)) {
try {
return FormSchemaValidator.mapper().getNodeFactory().numberNode(new BigDecimal(raw));
} catch (NumberFormatException e) {
return FormSchemaValidator.mapper().getNodeFactory().textNode(raw);
}
}
if (MULTI_OPTION_TYPES.contains(type)) {
ArrayNode array = FormSchemaValidator.mapper().createArrayNode();
for (String token : raw.split("[、,,]")) {
String value = token.trim();
if (!value.isEmpty()) {
array.add(value);
}
}
return array;
}
return FormSchemaValidator.mapper().getNodeFactory().textNode(raw);
}
// ==================== 表头 ====================
/** 表头两行:第一行=label(解析按它映射字段),第二行=说明(读侧按前缀跳过) */
private static List<List<String>> headOf(Map<String, JsonNode> fields) {
List<List<String>> head = new ArrayList<>();
head.add(Arrays.asList(SUBJECT_HEADER,
NOTE_PREFIX + "填 UserBase 用户ID;留空=以你本人为被记录人(代录需该表单 ALL 档)"));
for (Map.Entry<String, JsonNode> entry : fields.entrySet()) {
head.add(Arrays.asList(labelOf(entry.getValue(), entry.getKey()), hintOf(entry.getValue(), entry.getKey())));
}
return head;
}
private static String hintOf(JsonNode field, String key) {
StringBuilder hint = new StringBuilder(NOTE_PREFIX);
hint.append(field.path("required").asBoolean(false) ? "必填;" : "选填;");
String type = field.path("type").asText("");
if ("number".equals(type)) {
hint.append("数字");
} else if (MULTI_OPTION_TYPES.contains(type)) {
hint.append("多选,多个值用、分隔");
} else if ("select".equals(type) || "radio".equals(type)) {
hint.append("候选值之一:").append(optionsOf(field));
} else if ("date".equals(type)) {
hint.append("日期 yyyy-MM-dd");
} else if ("datetime".equals(type)) {
hint.append("时间 yyyy-MM-dd HH:mm:ss");
} else if (UNSUPPORTED_TYPES.contains(type)) {
hint.append("附件类,导入不支持,请留空");
} else {
hint.append("文本");
}
return hint.toString();
}
private static String optionsOf(JsonNode field) {
JsonNode options = field.path("options");
if (!options.isArray()) {
return "(见表单定义)";
}
List<String> values = new ArrayList<>();
for (JsonNode option : options) {
values.add(option.path("value").asText(option.path("label").asText("?")));
}
return String.join("、", values);
}
private static Map<String, Integer> countLabels(Map<String, JsonNode> fields) {
Map<String, Integer> counts = new LinkedHashMap<>();
for (Map.Entry<String, JsonNode> entry : fields.entrySet()) {
counts.merge(labelOf(entry.getValue(), entry.getKey()), 1, Integer::sum);
}
return counts;
}
/**
* label 唯一性(C14"重名 label 拒绝并提示先改 label"):模板与解析都按 label 映射列,
* 重名就等于"这一列到底是哪个字段"没有答案。<b>模板生成时同样先判</b>——
* 生成一份带歧义的模板再在导入时报错,不如在下载这一步就把设计器的问题点名。
*
* <p>另一件保留名:字段不能就叫 {@code 被记录人ID},那一列是模板的固定第一列(代录入口)。</p>
*/
private static void requireUniqueLabels(Map<String, JsonNode> fields) {
for (Map.Entry<String, JsonNode> entry : fields.entrySet()) {
String label = labelOf(entry.getValue(), entry.getKey());
if (SUBJECT_HEADER.equals(label)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"字段 label 不能叫「" + SUBJECT_HEADER + "」(它是模板的固定第一列)");
}
if (countLabels(fields).get(label) > 1) {
throw new BizException(ErrorCode.PARAM_ERROR,
"字段 label「" + label + "」重复,模板与解析都按 label 映射,请先在设计器改 label 再导入");
}
}
}
private static String labelOf(JsonNode field, String key) {
String label = field == null ? null : trim(field.path("label").asText(null));
return label == null || label.isEmpty() ? key : label;
}
/**
* 表头 → {@code 列号 → 字段 key}(C14"表头按 label 映射字段")。
*
* <p>三道检查都在<b>读数据之前</b>:① 字段 label 有重复(模板与解析都按 label 映射,重复即二义,
* 提示先改 label);② 有列名在当前发布版本里不存在(模板过期或手改表头);③ 一列都不认识。
* ②若按"忽略那一列"处理,那一列的数据会静默消失。</p>
*/
private static Map<Integer, String> mapColumns(Map<Integer, String> head, Map<String, JsonNode> fields) {
requireUniqueLabels(fields);
Map<String, String> keyByLabel = new HashMap<>();
for (Map.Entry<String, JsonNode> entry : fields.entrySet()) {
keyByLabel.put(labelOf(entry.getValue(), entry.getKey()), entry.getKey());
}
Map<Integer, String> columnByKey = new LinkedHashMap<>();
List<String> unknown = new ArrayList<>();
for (Map.Entry<Integer, String> cell : head.entrySet()) {
if (cell.getKey() == 0) {
continue;
}
String label = trim(cell.getValue());
if (label == null || label.isEmpty()) {
continue;
}
String key = keyByLabel.get(label);
if (key == null) {
unknown.add(label);
} else {
columnByKey.put(cell.getKey(), key);
}
}
if (!unknown.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"表头有当前发布版本里不存在的列:" + String.join("、", unknown)
+ "(请用本页下载的模板,或先发布含这些字段的版本)");
}
if (columnByKey.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "表头没有任何可识别的字段列(不是这张表单的模板?)");
}
return columnByKey;
}
// ==================== 读文件 ====================
private static byte[] requireFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "导入文件为空");
}
if (file.getSize() > MAX_FILE_BYTES) {
throw new BizException(ErrorCode.PARAM_ERROR, "导入文件过大(≤10MB)");
}
try {
return file.getBytes();
} catch (IOException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "导入文件读取失败:" + e.getMessage());
}
}
/**
* 读工作表:返回的列表第 0 项是表头行,其后是数据行(模板的说明行被识别后丢弃)。
*
* <p>{@code headRowNumber(1)}:EasyExcel 用第一行做表头({@link AnalysisEventListener#invokeHeadMap}),
* 其余按数据回调。行号取 {@code rowIndex + 1}——与用户在 Excel 里看到的行号一致,
* 报错时"第 7 行"要能直接指到那一格。</p>
*/
private static List<SheetRow> readRows(byte[] bytes) {
List<SheetRow> dataRows = new ArrayList<>();
Map<Integer, String> head = new LinkedHashMap<>();
try (InputStream in = new ByteArrayInputStream(bytes)) {
EasyExcel.read(in, new AnalysisEventListener<Map<Integer, String>>() {
@Override
public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) {
head.putAll(headMap);
}
@Override
public void invoke(Map<Integer, String> row, AnalysisContext context) {
dataRows.add(new SheetRow(context.readRowHolder().getRowIndex() + 1,
new LinkedHashMap<>(row)));
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 逐行已在 invoke 里收集,无需收尾
}
}).sheet().headRowNumber(1).doRead();
} catch (IOException | RuntimeException e) {
throw new BizException(ErrorCode.PARAM_ERROR,
"导入文件解析失败(需为本页下载的 xlsx 模板):" + e.getMessage());
}
if (head.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "导入文件没有表头行");
}
if (!dataRows.isEmpty() && startsWithNote(dataRows.get(0).cells.get(0))) {
dataRows.remove(0);
}
if (dataRows.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "表头之下没有数据行");
}
List<SheetRow> rows = new ArrayList<>();
rows.add(new SheetRow(1, head));
rows.addAll(dataRows);
return rows;
}
private static boolean startsWithNote(String cell) {
String value = trim(cell);
return value != null && value.startsWith(NOTE_PREFIX);
}
/** 当前发布版本的字段表;未发布直接拒(没有可校验的 schema,也没有可钉的版本) */
private Map<String, JsonNode> publishedFields(Form form) {
if (form.getCurrentVersion() == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"表单尚未发布,没有可导入的字段定义:" + form.getAppCode() + "/" + form.getCode());
}
String schemaJson = defService.pinnedForData(form.getUuid(), form.getCurrentVersion())
.versionRow().getSchemaJson();
return FormSchemaValidator.fieldsByKeys(dictService.expandSchema(form.getSchId(), schemaJson));
}
// ==================== 幂等键 ====================
/**
* C13 的确定性幂等键:{@code sha256("import:" + 表单 uuid + "|" + subject + "|" + 规范化值文档)} 前 40 位。
*
* <p>批次串取<b>表单坐标</b>而不是文件字节摘要:字节摘要会让"改一个字重传整份文件"把未改动的那些行
* 全部当新数据插一遍(1000 行里修 1 行 = 造出 999 条重复),那是不可接受的副作用。按表单坐标后
* 键<b>只随行内容变</b>:同一张表里同样的行只落一次,改过的行才算新数据。也不含行号(中间插一行
* 不该让后续所有行的键都变掉),更不含请求级随机串(那等于没有幂等)。见手册 §5 <b>P128</b>。</p>
*
* <p>{@code "import:"} 前缀让导入产生的键与手工填报的客户端 uuid 键天然不相交({@code uk} 是库级的)。</p>
*/
private static String idempotencyKey(String formUuid, Integer subjectId, JsonNode values) {
String canonical = "import:" + formUuid + "|" + (subjectId == null ? "" : subjectId) + "|" + values;
return digest(canonical.getBytes(StandardCharsets.UTF_8)).substring(0, IDEMPOTENCY_KEY_LENGTH);
}
private static String digest(byte[] bytes) {
try {
return hex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 不可用", e);
}
}
private static String hex(byte[] raw) {
StringBuilder sb = new StringBuilder(raw.length * 2);
for (byte b : raw) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
}
private static String trim(String raw) {
return raw == null ? null : raw.trim();
}
/** 文件侧的一行:物理行号(1 起,与用户在 Excel 里看到的行号一致)+ 列号到单元格文本 */
private static final class SheetRow {
private final int rowNo;
private final Map<Integer, String> cells;
private SheetRow(int rowNo, Map<Integer, String> cells) {
this.rowNo = rowNo;
this.cells = cells;
}
}
}
@@ -0,0 +1,391 @@
package com.yunhai.formcenter.service;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import com.yunhai.formcenter.controller.api.FormAnswerReadController;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.model.ImportModels;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.repository.AnswerIndexRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
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.context.annotation.Import;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 批量导入(契约 v2 §3.4/§5.1/C13/C14,T18,验收 <b>V60/V74</b>)。
*
* <p>主线只有一句话:<b>导入的行与手工填报的行必须完全可比</b>。所以断言都冲着"是不是同一条管线"去——
* 值校验的报错文案与手工填报逐字相同、钉版与索引照写、限次照挡、被记录人走 {@code subjectId}
* 那一条 ALL 档判据(P116 裁决的代录口径),而不是导入侧另写的一套。</p>
*
* <p>另三条是新机制自己的断言:C13 的确定性幂等键(同一文件重传逐行返回原 answerId、不落新行,P128)、
* C14 的"表头先校验"(重名 label / 不存在的列 / 行数超上限都在写库之前整批拒)、坏行不静默
* (逐行 ok/error,一行失败不影响其它行)。</p>
*/
@SpringBootTest
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
@Transactional
@DisplayName("批量导入 - 同一条提交管线与逐行结果(V60/V74,C13/C14)")
class AnswerImportIT extends AnswerTestSupport {
/** text + searchable 的 select + number:够覆盖"必填 / 选项域 / 数字域"三类坏行 */
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true},"
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true,\"searchable\":true,"
+ "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高三\",\"value\":\"g3\"}]},"
+ "{\"key\":\"score\",\"label\":\"分数\",\"type\":\"number\"}]}]}";
private static final String SUBJECT_HEADER = "被记录人ID";
private static final String NOTE = "说明:模板说明行,导入时按前缀跳过";
private static final int TEACHER_ADMIN_ID = 20268001;
@Autowired
private AnswerImportService importService;
@Autowired
private FormAnswerReadController readController;
@Autowired
private AnswerIndexRepository indexRows;
// ==================== 模板 ====================
@Test
@DisplayName("模板两行表头:第一行=label + 固定第一列「被记录人ID」,第二行是给人看的说明(导入时按前缀跳过)")
void templateHeadIsLabelRowThenNoteRow() {
String uuid = publishForm("t18_template", SCHEMA);
ByteArrayOutputStream out = new ByteArrayOutputStream();
importService.template(SCH, uuid, out);
List<Map<Integer, String>> read = readAllRows(out.toByteArray());
assertThat(read).hasSize(2);
assertThat(read.get(0).values()).contains(SUBJECT_HEADER, "姓名", "年级", "分数");
assertThat(read.get(1).get(0)).startsWith("说明:");
// 说明行带必填与候选值提示,填表人不用回设计器对照
assertThat(read.get(1).get(2)).contains("必填").contains("g2");
}
@Test
@DisplayName("未发布的表单没有可导入的字段定义:模板与导入都在入口拒(不给只有一列的空模板)")
void unpublishedFormHasNoTemplate() {
FormModels.FormCreateRequest create = new FormModels.FormCreateRequest();
create.setCode("t18_draft_only");
create.setName("草稿表");
String uuid = defService.create(SCH, create, null).getFormUuid();
assertThatThrownBy(() -> importService.template(SCH, uuid, new ByteArrayOutputStream()))
.isInstanceOf(BizException.class).hasMessageContaining("尚未发布");
assertThatThrownBy(() -> importService.importAnswers(adminTeacher(), SCH, uuid,
fileOf(xlsx(twoRowHead("姓名"), rows(row("甲"))))))
.isInstanceOf(BizException.class).hasMessageContaining("尚未发布");
}
// ==================== V60 同一条管线 ====================
@Test
@DisplayName("V60/V74:导入的两行落库带 form_uuid+钉住 version,searchable 字段照写索引(与手工填报同一管线)")
void importedRowsGoThroughTheSamePipeline() {
String code = "t18_import_ok";
String uuid = managedTeacherForm(code);
ImportModels.Result result = importService.importAnswers(adminTeacher(), SCH, uuid,
fileOf(xlsx(twoRowHead("姓名", "年级", "分数"),
rows(row("", "张三", "g2", "95"), row("", "李四", "g3", "88")))));
assertThat(result.getFailCount()).isZero();
assertThat(result.getOkCount()).isEqualTo(2);
assertThat(result.getRows()).extracting(ImportModels.RowResult::getAnswerId).doesNotContainNull();
List<Answer> stored = answers(code);
assertThat(stored).hasSize(2);
assertThat(stored.get(0).getFormUuid()).isEqualTo(uuid);
assertThat(stored.get(0).getVersion()).isEqualTo(1);
assertThat(stored).allSatisfy(row -> {
assertThat(row.getSubmitterId()).isEqualTo(TEACHER_ADMIN_ID);
// 自填归一(Bug#3 口径):被记录人留空 = 本人,不是 NULL
assertThat(row.getSubjectId()).isEqualTo(TEACHER_ADMIN_ID);
assertThat(row.getAnonIp()).isNull();
assertThat(row.getValuesJson()).contains("grade");
// 索引同管线:searchable 的 grade 必须进 answer_index,否则数据面筛不到导入的行
assertThat(indexRows.findFieldKeysByAnswerId(row.getId())).contains("grade");
});
}
@Test
@DisplayName("V74 坏行不静默:缺必填 / 选项域外 / 数字域外逐行给管线原话,好行照常写入;行号是文件物理行号")
void badRowsAreReportedPerRowWithoutAbortingTheBatch() {
String code = "t18_import_bad_rows";
String uuid = managedTeacherForm(code);
ImportModels.Result result = importService.importAnswers(adminTeacher(), SCH, uuid,
fileOf(xlsx(twoRowHead("姓名", "年级", "分数"), rows(
row("", "好行", "g2", "95"),
row("", "", "g2", ""),
row("", "乙", "g9", ""),
row("", "丙", "g2", "高分")))));
assertThat(result.getOkCount()).isEqualTo(1);
assertThat(result.getFailCount()).isEqualTo(3);
assertThat(result.getRows().get(0).getOk()).isTrue();
assertThat(result.getRows().get(1).getAnswerId()).isNull();
assertThat(result.getRows().get(1).getError()).contains("姓名").contains("必填");
assertThat(result.getRows().get(2).getError()).contains("不在候选列表内");
assertThat(result.getRows().get(3).getError()).contains("必须是数字");
// 行号是文件里的物理行号(表头 1 行 + 说明 1 行 → 数据从第 3 行起)
assertThat(result.getRows()).extracting(ImportModels.RowResult::getRowNo).containsExactly(3, 4, 5, 6);
assertThat(answers(code)).hasSize(1);
}
@Test
@DisplayName("被记录人列非整数:逐行拒(这一列不是自由文本),同批好行照常写入")
void nonNumericSubjectCellIsRejectedPerRow() {
String code = "t18_import_bad_subject";
String uuid = managedTeacherForm(code);
ImportModels.Result result = importService.importAnswers(adminTeacher(), SCH, uuid,
fileOf(xlsx(twoRowHead("姓名", "年级", "分数"),
rows(row("", "张三", "g2", "95"), row("abc", "甲", "g2", "")))));
assertThat(result.getOkCount()).isEqualTo(1);
assertThat(result.getRows().get(1).getError()).contains("被记录人ID").contains("整数");
assertThat(answers(code)).hasSize(1);
}
@Test
@DisplayName("C14+P116:填「被记录人」=代录,落 subject_id=该人、submitter 仍是操作者;非 ALL 档连端点都进不来")
void subjectColumnIsTheProxyPathGuardedByAllTier() {
String code = "t18_import_proxy";
String uuid = managedTeacherForm(code);
int proxy = 20268101;
subjects.markSchool(proxy, SCH);
ImportModels.Result result = importService.importAnswers(adminTeacher(), SCH, uuid,
fileOf(xlsx(twoRowHead("姓名", "年级", "分数"),
rows(row(String.valueOf(proxy), "甲", "g2", "")))));
assertThat(result.getOkCount()).isEqualTo(1);
Answer stored = answers(code).get(0);
assertThat(stored.getSubjectId()).isEqualTo(proxy);
assertThat(stored.getSubmitterId()).isEqualTo(TEACHER_ADMIN_ID);
// 不是这张表 ALL 档的会话:控制器的 requireData 就拒,进不到服务(与导出/编辑同一把守卫)
BoaUser outsider = plainUser(1);
assertThatThrownBy(() -> readController.importAnswers(outsider, null, uuid,
fileOf(xlsx(twoRowHead("姓名", "年级"), rows(row("x", "g2"))))))
.isInstanceOf(BizException.class)
.satisfies(e -> assertThat(((BizException) e).getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN));
}
// ==================== C13 确定性幂等键 ====================
@Test
@DisplayName("C13/P128:同一文件重传逐行返回原 answerId 且不落新行;改一个字只有那一行算新数据")
void sameFileReimportIsIdempotentRowByRow() {
String code = "t18_import_replay";
String uuid = managedTeacherForm(code);
List<List<String>> head = twoRowHead("姓名", "年级", "分数");
byte[] same = xlsx(head, rows(row("", "甲", "g2", "95"), row("", "乙", "g3", "88")));
ImportModels.Result first = importService.importAnswers(adminTeacher(), SCH, uuid, fileOf(same));
ImportModels.Result second = importService.importAnswers(adminTeacher(), SCH, uuid, fileOf(same));
assertThat(first.getOkCount()).isEqualTo(2);
assertThat(ids(second.getRows())).isEqualTo(ids(first.getRows()));
assertThat(answers(code)).hasSize(2);
byte[] edited = xlsx(head, rows(row("", "甲", "g2", "95"), row("", "乙改", "g3", "88")));
ImportModels.Result third = importService.importAnswers(adminTeacher(), SCH, uuid, fileOf(edited));
assertThat(third.getOkCount()).isEqualTo(2);
assertThat(third.getRows().get(0).getAnswerId()).isEqualTo(first.getRows().get(0).getAnswerId());
assertThat(third.getRows().get(1).getAnswerId()).isNotEqualTo(first.getRows().get(1).getAnswerId());
assertThat(answers(code)).hasSize(3);
}
@Test
@DisplayName("限次对导入同样生效(同一把管线闸):同 subject 的第二行被限次拒")
void fillLimitAppliesToImportedRowsToo() {
String code = "t18_import_quota";
String uuid = managedTeacherForm(code);
config(SCH, uuid, fillOnce());
ImportModels.Result result = importService.importAnswers(adminTeacher(), SCH, uuid,
fileOf(xlsx(twoRowHead("姓名", "年级", "分数"),
rows(row("", "甲", "g2", ""), row("", "乙", "g2", "")))));
assertThat(result.getOkCount()).isEqualTo(1);
assertThat(result.getRows().get(1).getError()).contains("限填一次");
assertThat(answers(code)).hasSize(1);
}
// ==================== 整批拒的三件 ====================
@Test
@DisplayName("C14:表头有当前发布版里不存在的列 → 整批拒且零写入(不做忽略那一列的静默降级)")
void unknownHeaderColumnRejectsWithoutWritingAnything() {
String code = "t18_import_header_unknown";
String uuid = managedTeacherForm(code);
assertThatThrownBy(() -> importService.importAnswers(adminTeacher(), SCH, uuid,
fileOf(xlsx(twoRowHead("姓名", "年级", "手机"), rows(row("甲", "g2"))))))
.isInstanceOf(BizException.class).hasMessageContaining("不存在的列").hasMessageContaining("手机");
assertThat(answers(code)).isEmpty();
}
@Test
@DisplayName("C14:重名 label 在模板与导入两侧都拒,并提示先在设计器改 label")
void duplicateLabelIsRejectedOnBothSides() {
String code = "t18_import_dup_label";
String uuid = publishForm(code, SCHEMA.replace("\"label\":\"年级\"", "\"label\":\"姓名\""));
config(SCH, uuid, audience(Arrays.asList("STUDENT", "TEACHER")));
grantFormAdmin(uuid, TEACHER_ADMIN_ID, SCH);
assertThatThrownBy(() -> importService.template(SCH, uuid, new ByteArrayOutputStream()))
.isInstanceOf(BizException.class).hasMessageContaining("重复").hasMessageContaining("先在设计器改 label");
assertThatThrownBy(() -> importService.importAnswers(adminTeacher(), SCH, uuid,
fileOf(xlsx(twoRowHead("姓名", "姓名"), rows(row("甲", "g2"))))))
.isInstanceOf(BizException.class).hasMessageContaining("重复");
assertThat(answers(code)).isEmpty();
}
@Test
@DisplayName("C14:行上限 1000 是整批拒不是截断,文案给实际行数;一件写库都不发生")
void rowCapRejectsTheWholeBatchInsteadOfTruncating() {
String code = "t18_import_cap";
String uuid = managedTeacherForm(code);
List<List<Object>> big = new ArrayList<>();
for (int i = 0; i < 1001; i++) {
big.add(row("", "行" + i, "g2", ""));
}
assertThatThrownBy(() -> importService.importAnswers(adminTeacher(), SCH, uuid,
fileOf(xlsx(twoRowHead("姓名", "年级", "分数"), big))))
.isInstanceOf(BizException.class).hasMessageContaining("上限 1000").hasMessageContaining("1001");
assertThat(answers(code)).isEmpty();
}
@Test
@DisplayName("附件类字段不接受导入值:逐行点名批量导入不支持上传,不静默丢那一列")
void attachmentCellsAreRejectedPerRow() {
String code = "t18_import_attach";
String uuid = managedTeacherForm(code, "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"cert\",\"label\":\"证书\",\"type\":\"image\"}]}]}");
ImportModels.Result result = importService.importAnswers(adminTeacher(), SCH, uuid,
fileOf(xlsx(twoRowHead("证书"), rows(row("", "a.jpg")))));
assertThat(result.getFailCount()).isEqualTo(1);
assertThat(result.getRows().get(0).getError()).contains("附件类").contains("批量导入不支持");
assertThat(answers(code)).isEmpty();
}
// ==================== 脚手架 ====================
/** 一张"面向学生+教师、操作者是 ALL 档(form_admin)"的已发布表;人群含 TEACHER 才让自填过得了 audience 门 */
private String managedTeacherForm(String code) {
return managedTeacherForm(code, SCHEMA);
}
private String managedTeacherForm(String code, String schema) {
String uuid = publishForm(code, schema);
config(SCH, uuid, audience(Arrays.asList("STUDENT", "TEACHER")));
grantFormAdmin(uuid, TEACHER_ADMIN_ID, SCH);
return uuid;
}
/** 导入操作人:教师身份(在人群内)+ 该表 ALL 档(form_admin)——自填与代录两条路都走得通 */
private BoaUser adminTeacher() {
return user(TEACHER_ADMIN_ID, SCH, 1);
}
private List<Answer> answers(String code) {
return answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", code);
}
private static FormModels.FormConfigRequest audience(List<String> audience) {
FormModels.FormConfigRequest cfg = new FormModels.FormConfigRequest();
cfg.setAudience(audience);
return cfg;
}
private static FormModels.FormConfigRequest fillOnce() {
FormModels.FormConfigRequest cfg = new FormModels.FormConfigRequest();
cfg.setFillLimit(1);
return cfg;
}
/** 两行表头(第一行 label、第二行说明),与 {@code AnswerImportService#headOf} 同形 */
private static List<List<String>> twoRowHead(String... labels) {
List<List<String>> head = new ArrayList<>();
head.add(Arrays.asList(SUBJECT_HEADER, NOTE));
for (String label : labels) {
head.add(Arrays.asList(label, NOTE + "|" + label));
}
return head;
}
private static List<Object> row(Object... cells) {
return new ArrayList<>(Arrays.asList(cells));
}
@SafeVarargs
private static List<List<Object>> rows(List<Object>... dataRows) {
return new ArrayList<>(Arrays.asList(dataRows));
}
/** 写一份 xlsx:head 为两行表头(EasyExcel 自己写两行),dataRows 只含真数据行 */
private static byte[] xlsx(List<List<String>> head, List<List<Object>> dataRows) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
EasyExcel.write(out).head(head).sheet("导入").doWrite(dataRows);
return out.toByteArray();
}
private static MockMultipartFile fileOf(byte[] bytes) {
return new MockMultipartFile("file", "import.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", bytes);
}
private static List<String> ids(List<ImportModels.RowResult> rows) {
List<String> ids = new ArrayList<>();
for (ImportModels.RowResult row : rows) {
ids.add(row.getAnswerId());
}
return ids;
}
/** 读回 xlsx(headRowNumber(0)=表头也按行返回),断言模板形状用 */
private static List<Map<Integer, String>> readAllRows(byte[] bytes) {
List<Map<Integer, String>> all = new ArrayList<>();
EasyExcel.read(new ByteArrayInputStream(bytes), new AnalysisEventListener<Map<Integer, String>>() {
@Override
public void invoke(Map<Integer, String> row, AnalysisContext context) {
all.add(new LinkedHashMap<>(row));
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 收满即止
}
}).sheet().headRowNumber(0).doRead();
return all;
}
}