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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user