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-11-03 调整)。
*
*
业务口径:
*
* - 允许文件名不含中文;含中文/纯英文/数字均可
* - 去掉扩展名后的主干,按字符数计算不能超过 20 个字
* - 不能包含空格及 {@code / \ : * ? " < > | # %} 等特殊字符
* - 同一明细行内不能重名(比较时忽略扩展名大小写与首尾空格)
* - 只校验新上传/新保存的附件;历史附件由 existingNames 放行
*
*
* 与前端 {@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_EMPTY_NAME = "为方便查验附件,附件名不能为空!";
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 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_EMPTY_NAME;
}
if (ILLEGAL.matcher(name).find()) {
return MSG_ILLEGAL;
}
String main = mainName(name);
// 不再强制要求中文;含中文、纯英文、数字均可
// 按字符数计(码点计数:中文/字母/数字/全角各计 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;
}
}