yxh
3 天以前 3bde121db1c2a5fd0bc656c71e8fe6bdfde31074
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
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;
    }
}