package com.ruoyi.project.common; import java.util.HashSet; import java.util.Set; import java.util.regex.Pattern; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; /** * 报销单附件文件名规范校验(2026-10-15)。 * *

业务口径(业务方确认): *

    *
  1. 必须包含中文(至少 1 个汉字)——"为方便查验附件,请用中文规范命名附件!"
  2. *
  3. 去掉扩展名后的主干,按字符数计算不能超过 20 个字——"为方便查验附件,附件名字长度不能超过20个字!"
  4. *
  5. 不能包含空格及 {@code / \ : * ? " < > | # %} 等特殊字符
  6. *
  7. 同一明细行内不能重名(比较时忽略扩展名大小写与首尾空格)
  8. *
* *

与前端 {@code opo-web/src/utils/fileNameCheck.js} 同口径: * 前端负责"上传即提醒 + 保存前拦截",本类负责后端保存时的二次校验(防止绕过前端)。 * *

用法(在保存入口里): *

 *   String err = AttachmentNameValidator.checkJsonList(detail.getInvoicefiles(), detail.getAnnexfiles());
 *   if (err != null) { return AjaxResult.error("附件文件名不符合要求,无法保存:" + err); }
 * 
* * @author ruoyi */ public final class AttachmentNameValidator { /** 主干(去扩展名)最大字符数 */ public static final int MAX_MAIN_LENGTH = 20; public static final String MSG_NO_CHINESE = "为方便查验附件,请用中文规范命名附件!"; public static final String MSG_TOO_LONG = "为方便查验附件,附件名字长度不能超过20个字!"; public static final String MSG_ILLEGAL = "为方便查验附件,附件名不能包含空格及 / \\ : * ? \" < > | # % 等特殊字符!"; public static final String MSG_DUPLICATE = "为方便查验附件,同一行内不能有重名的附件!"; /** 附件项里取不到任何文件名(既没有 name 也没有 fileName)时的提示 */ public static final String MSG_NO_NAME = "为方便查验附件,附件必须带文件名,请重新上传!"; /** 汉字 */ private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fa5]"); /** 非法字符:空格(含各类空白)+ / \ : * ? " < > | # % */ private static final Pattern ILLEGAL = Pattern.compile("[\\s/\\\\:*?\"<>|#%]"); private static final ObjectMapper MAPPER = new ObjectMapper(); private AttachmentNameValidator() { } /** * 取主干(去掉最后一个扩展名)。 */ public static String mainName(String fileName) { String s = fileName == null ? "" : fileName.trim(); int index = s.lastIndexOf('.'); return index > 0 ? s.substring(0, index) : s; } /** * 校验单个文件名。 * * @param fileName 原始文件名 * @return {@code null}=通过;否则返回不合格原因 */ public static String checkName(String fileName) { String name = fileName == null ? "" : fileName.trim(); if (name.isEmpty()) { return MSG_NO_CHINESE; } if (ILLEGAL.matcher(name).find()) { return MSG_ILLEGAL; } String main = mainName(name); if (!CHINESE.matcher(main).find()) { return MSG_NO_CHINESE; } // 按字符数计(码点计数:中文/字母/数字/全角各计 1) if (main.codePointCount(0, main.length()) > MAX_MAIN_LENGTH) { return MSG_TOO_LONG; } return null; } /** * 校验一个附件列表 JSON(形如 {@code [{"name":"张三发票.pdf","url":"/profile/upload/..."}]})。 * * @param json 附件字段内容(annexfiles / invoicefiles),可为空 * @return {@code null}=通过;否则返回不合格原因(含文件名) */ public static String checkJson(String json) { return checkJson(json, null, true); } /** * 修改保存时使用:忽略"保存前已存在"的历史附件名。 * *

业务口径(2026-10-15 确认):**只校验本次变更的附件**——历史单据里已存的不合规附件名, * 在编辑该单据时不应把保存卡死,否则老单据改一个金额都存不进去。 * * @param existingNames 保存前该明细已存在的附件名(用 {@link #collectNames(String)} 收集, * 需为小写去空白形式;为空/为 null 表示按全新单据处理) */ public static String checkJsonForUpdate(String json, Set existingNames) { return checkJson(json, existingNames, false); } /** * 收集附件 JSON 里已有的文件名(小写、去空白),用于识别"历史已存在"的附件。 */ public static Set collectNames(String json) { Set names = new HashSet<>(); if (json == null || json.trim().isEmpty()) { return names; } try { JsonNode root = MAPPER.readTree(json); if (root != null && root.isArray()) { for (JsonNode node : root) { String raw = fieldText(node, "name"); if (raw == null) { raw = fieldText(node, "fileName"); } if (raw != null && !raw.trim().isEmpty()) { names.add(raw.trim().toLowerCase()); } } } } catch (Exception ignored) { // 历史字段解析失败就当作"没有历史附件",不因解析问题影响保存 } return names; } /** * @param existingNames 历史附件名(小写),命中则跳过校验;{@code null} 表示不忽略任何名字 * @param strictNoName {@code true}=取不到文件名时拦截(全新保存);{@code false}=放行(修改历史单据) */ private static String checkJson(String json, Set existingNames, boolean strictNoName) { if (json == null || json.trim().isEmpty()) { return null; } JsonNode root; try { root = MAPPER.readTree(json); } catch (Exception e) { // 解析不了就不在这里拦(保存流程自身会处理格式问题),避免误拦 return null; } if (root == null || !root.isArray()) { return null; } Set seen = new HashSet<>(); for (JsonNode node : root) { String raw = fieldText(node, "name"); if (raw == null) { raw = fieldText(node, "fileName"); } if (raw == null || raw.trim().isEmpty()) { // 既没有 name 也没有 fileName:无法核对附件名。 // 全新保存(add)按"不能保存"处理;修改历史单据时放行,避免卡住老数据 if (strictNoName) { return MSG_NO_NAME; } continue; } String name = raw.trim(); if (isUploadPath(name)) { // 该附件项存的是"上传后的存储路径"(形如 /profile/upload/2026/10/15/xxx.pdf), // 不是操作人命名的文件名,无法据此判断命名是否规范 → 跳过校验,避免误拦合法单据。 // 待前端改为上传时记录原始文件名(file.raw.name)后,这些项会自动纳入校验。 continue; } if (existingNames != null && existingNames.contains(name.toLowerCase())) { // 历史附件(保存前已存在)不参与校验:业务确认"只校验本次变更的附件",避免卡住老单据 continue; } String reason = checkName(name); if (reason != null) { return "【" + name + "】" + reason; } String key = name.toLowerCase(); if (!seen.add(key)) { return "【" + name + "】" + MSG_DUPLICATE; } } return null; } /** 读取附件项里的字符串字段,取不到(缺失/为空/null)返回 {@code null} */ private static String fieldText(JsonNode node, String field) { if (node == null) { return null; } JsonNode value = node.get(field); if (value == null || value.isNull()) { return null; } String text = value.asText(); return text == null || text.trim().isEmpty() ? null : text; } /** 判断附件项里的 name 是否为"上传后的存储路径 / URL"(而不是操作人命名的文件名) */ private static boolean isUploadPath(String name) { String lower = name.toLowerCase(); return name.startsWith("/") || name.startsWith("http") || name.contains("/") || name.contains("\\") || lower.contains("profile/upload"); } /** * 校验同一明细行下的多个附件列表(如发票 + 其它附件),逐个列表独立校验: * 每个列表内部查重名(与前端 checkDetails 的口径一致)。 * * @param jsons 附件字段内容,可为 null/空 * @return {@code null}=通过;否则返回不合格原因 */ public static String checkJsonList(String... jsons) { if (jsons == null) { return null; } for (String json : jsons) { String reason = checkJson(json); if (reason != null) { return reason; } } return null; } /** 修改保存时使用:忽略历史附件名,逐个列表校验(供"多个字段一起校验"的调用点) */ public static String checkJsonListForUpdate(Set existingNames, String... jsons) { if (jsons == null) { return null; } for (String json : jsons) { String reason = checkJsonForUpdate(json, existingNames); if (reason != null) { return reason; } } return null; } }