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;
}
}
}