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)。
|
*
|
* <p>业务口径(业务方确认):
|
* <ol>
|
* <li>必须包含中文(至少 1 个汉字)——"为方便查验附件,请用中文规范命名附件!"</li>
|
* <li>去掉扩展名后的主干,按字符数计算不能超过 20 个字——"为方便查验附件,附件名字长度不能超过20个字!"</li>
|
* <li>不能包含空格及 {@code / \ : * ? " < > | # %} 等特殊字符</li>
|
* <li>同一明细行内不能重名(比较时忽略扩展名大小写与首尾空格)</li>
|
* </ol>
|
*
|
* <p>与前端 {@code opo-web/src/utils/fileNameCheck.js} <b>同口径</b>:
|
* 前端负责"上传即提醒 + 保存前拦截",本类负责后端保存时的<b>二次校验</b>(防止绕过前端)。
|
*
|
* <p>用法(在保存入口里):
|
* <pre>
|
* String err = AttachmentNameValidator.checkJsonList(detail.getInvoicefiles(), detail.getAnnexfiles());
|
* if (err != null) { return AjaxResult.error("附件文件名不符合要求,无法保存:" + err); }
|
* </pre>
|
*
|
* @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);
|
}
|
|
/**
|
* 修改保存时使用:忽略"保存前已存在"的历史附件名。
|
*
|
* <p>业务口径(2026-10-15 确认):**只校验本次变更的附件**——历史单据里已存的不合规附件名,
|
* 在编辑该单据时不应把保存卡死,否则老单据改一个金额都存不进去。
|
*
|
* @param existingNames 保存前该明细已存在的附件名(用 {@link #collectNames(String)} 收集,
|
* 需为小写去空白形式;为空/为 null 表示按全新单据处理)
|
*/
|
public static String checkJsonForUpdate(String json, Set<String> existingNames) {
|
return checkJson(json, existingNames, false);
|
}
|
|
/**
|
* 收集附件 JSON 里已有的文件名(小写、去空白),用于识别"历史已存在"的附件。
|
*/
|
public static Set<String> collectNames(String json) {
|
Set<String> 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<String> 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<String> 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<String> existingNames, String... jsons) {
|
if (jsons == null) {
|
return null;
|
}
|
for (String json : jsons) {
|
String reason = checkJsonForUpdate(json, existingNames);
|
if (reason != null) {
|
return reason;
|
}
|
}
|
return null;
|
}
|
}
|