Java导入功能怎么改得更好用?让重复数据一次报全

0 阅读1分钟

一份客户表里,第3、6、9行用了同一个邮箱。导入失败,页面却只标出第6、9行。使用者删掉这两行,重新提交,文件通过了。但第3行就一定是应该保留的那条吗?

如果这三行对应不同姓名,程序实际上替人做了一个决定:保留先出现的。我的选择是先把冲突完整展示出来,让使用者确认。对准备改造开源项目的人来说,这也是一项具体的改动:从“发现重复”做到“能把重复处理清楚”。

前面的导入校验文章把字段、文件重复和数据库冲突拆开了,文件内用集合报告后出现的行。这次接着改这一处:一个重复组里,首次出现和后续出现的行都返回,原始值也保留。

先确定:重复时,谁有资格决定保留哪条

用一组教学数据说明。第1行是表头,中间空白行已经由解析器跳过,但后续数据仍带着原 Excel 行号。

原行号姓名原始邮箱本次希望的反馈
3张三ONE@example.com属于重复组
6李四one@example.com属于同一重复组
8王五other@example.com无文件内重复
9赵六one@example.com属于同一重复组

这份示例约定邮箱比较时忽略首尾空白和大小写,因此3、6、9是一组。**这是本例的业务规则,不是所有邮箱系统通用的标准。**实际项目要让预查、保存和数据库唯一性规则采用一致的比较方式。

本例不自动保留第一条,也不做覆盖更新。有冲突先退回,用户确认后再提交。若产品明确规定“第一条有效,后续忽略”,只提示后面的行也可以;问题在于,不能由一个 Set 的写法悄悄决定业务行为。

Set 能发现重复,但不会自动给出完整冲突组

最容易写出来的是:

var seen = new HashSet<String>();
for (var row : rows) {
    if (!seen.add(emailKey(row.rawEmail()))) {
        // 这里只拿到了再次出现的行,第一个成员不会自动进入错误报告。
        duplicateRows.add(row.row());
    }
}

对上面的数据,它返回6、9。改成“邮箱→首次行号”的 Map,可以在第一次冲突时找到第3行,但还得处理第三次、第四次重复,避免把首次行号反复追加进结果。

我更愿意把这份小文件完整分组:用标准化邮箱作为 key,保存它对应的原始行。扫描完成后,只留下成员数大于1的组。

同一邮箱的第3、6、9行进入一个重复组;第8行不进入重复报告

返回组,而不是给每一行复制一份“与谁重复”的长名单,还有一个好处:同一邮箱重复1000次时,只保存1000个成员,不生成接近100万项的两两关系。前端展开组成员就能标记每个单元格。

比较值与原始值分开,行号从解析时就带上

这里需要保留两类信息:emailKey 用于比较,rawEmail 用于解释用户究竟填了什么。第6行两边的空格,不应该在生成报告之前丢掉。

行号也一样。跳过第4行之后,第6行仍然是6,不能用过滤后列表的位置加1来代替。使用 POI 时,Row.getRowNum() 从0开始,展示行号应在读取该行时记成 getRowNum() + 1。

下面是完整的查重组件,保存为 ImportDuplicates.java。它接收已经解析、邮箱字段校验通过的行,不负责读取 Excel,也不代替 @Email 等字段规则。某行只是姓名不合格、邮箱本身有效时,仍可参与邮箱查重,帮助用户一次看到更多问题。

import java.util.*;

public final class ImportDuplicates {
    // row 是原 Excel 行号;表头在第 1 行,不按过滤后的列表重新编号。
    public record InputRow(int row, String rawEmail) {}
    public record DuplicateGroup(String emailKey, List<InputRow> members) {}

    public static String emailKey(String raw) {
        Objects.requireNonNull(raw, "邮箱不能为 null");
        // 本例业务约定:忽略首尾空白与大小写,不删除邮箱中间的字符。
        return raw.strip().toLowerCase(Locale.ROOT);
    }

    public static List<DuplicateGroup> find(List<InputRow> rows) {
        Objects.requireNonNull(rows, "行列表不能为 null");
        if (rows.size() > 1000) {
            throw new IllegalArgumentException("本例最多接收 1000 个数据行");
        }
        var byEmail = new HashMap<String, List<InputRow>>();
        var rowNumbers = new HashSet<Integer>();
        for (var row : rows) {
            Objects.requireNonNull(row, "不能包含 null 行");
            if (row.row() < 2 || !rowNumbers.add(row.row())) {
                throw new IllegalArgumentException("原始行号无效或重复");
            }
            String key = emailKey(row.rawEmail());
            if (key.isEmpty()) {
                throw new IllegalArgumentException("空邮箱应由字段校验处理");
            }
            byEmail.computeIfAbsent(key, unused -> new ArrayList<>()).add(row);
        }
        var groups = new ArrayList<DuplicateGroup>();
        for (var entry : byEmail.entrySet()) {
            if (entry.getValue().size() < 2) continue;
            var members = entry.getValue();
            members.sort(Comparator.comparingInt(InputRow::row));
            // 一组只保存一份成员列表,不给每行复制一份完整重复名单。
            groups.add(new DuplicateGroup(entry.getKey(), List.copyOf(members)));
        }
        groups.sort(Comparator.comparingInt(g -> g.members().get(0).row()));
        return List.copyOf(groups);
    }

    public static void main(String[] args) {
        var rows = List.of(
            new InputRow(3, "ONE@example.com"),
            new InputRow(6, " one@example.com "),
            new InputRow(8, "other@example.com"),
            new InputRow(9, "one@example.com"));
        for (var group : find(rows)) {
            System.out.println(group.emailKey() + " -> "
                + group.members().stream().map(InputRow::row).toList());
        }
    }
}

Locale.ROOT 是有意写出来的。这里在生成标识值,不应该因为机器默认语言不同而得到不同结果;Java String 文档也说明了默认语言对大小写转换的影响。

另外,代码只去除首尾空白,不会擅自删除邮箱中间的空格、圆点或 + 后面的部分。进一步合并两个地址是否合理,应该由业务规则决定。

本例限制最多1000个数据行;解析入口仍要限制文件大小、读取行数,不能等整份大文件进入内存后才检查这里的上限。分组后的成员和组都按原行号排序,输入遍历顺序改变时,报告仍保持一致。

不只断言“发现重复”,还要检查首次行有没有漏

用 JDK 21 执行:

javac -encoding UTF-8 ImportDuplicates.java
java ImportDuplicates

得到:

one@example.com -> [3, 6, 9]

这里最重要的断言是完整的 [3, 6, 9]。只检查“错误数大于0”,旧版漏掉第3行也会通过。

我还补了原始值保留、输入顺序变化、多个重复组排序、默认语言变化和非法行号等检查。下面的测试不依赖 JUnit,判断失败会直接抛异常;保存为 ImportDuplicatesTest.java,与前一个文件放在同一目录。

import java.util.*;

public class ImportDuplicatesTest {
    static int checks;
    static ImportDuplicates.InputRow row(int number, String email) {
        return new ImportDuplicates.InputRow(number, email);
    }
    static void equal(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) {
            throw new AssertionError("expected=" + expected + ", actual=" + actual);
        }
        checks++;
    }
    static void rejects(Runnable action) {
        try { action.run(); }
        catch (IllegalArgumentException expected) { checks++; return; }
        throw new AssertionError("应拒绝无效输入");
    }
    public static void main(String[] args) {
        var input = new ArrayList<>(List.of(row(3, "ONE@example.com"),
            row(6, " one@example.com "), row(8, "other@example.com"),
            row(9, "one@example.com")));
        var before = List.copyOf(input);
        var groups = ImportDuplicates.find(input);
        equal(1, groups.size());
        equal(List.of(3, 6, 9), groups.get(0).members().stream()
            .map(ImportDuplicates.InputRow::row).toList());
        equal(" one@example.com ", groups.get(0).members().get(1).rawEmail());
        equal(before, input);
        Collections.reverse(input);
        equal(groups, ImportDuplicates.find(input));
        equal(List.of(), ImportDuplicates.find(List.of()));
        equal(List.of(), ImportDuplicates.find(List.of(row(2,"a@example.com"))));
        equal(List.of(), ImportDuplicates.find(List.of(
            row(2,"a@example.com"), row(3,"b@example.com"))));
        var multiple = ImportDuplicates.find(List.of(row(9,"b@example.com"),
            row(6,"a@example.com"), row(3,"a@example.com"), row(2,"b@example.com")));
        equal(List.of("b@example.com", "a@example.com"), multiple.stream()
            .map(ImportDuplicates.DuplicateGroup::emailKey).toList());
        // 默认语言切换后,标识值仍应得到同一个比较结果。
        var previous = Locale.getDefault();
        try {
            Locale.setDefault(Locale.forLanguageTag("tr-TR"));
            equal("i@example.com", ImportDuplicates.emailKey("I@example.com"));
        } finally { Locale.setDefault(previous); }
        rejects(() -> ImportDuplicates.find(List.of(row(2,"a@example.com"), row(2,"b@example.com"))));
        rejects(() -> ImportDuplicates.find(List.of(row(1,"a@example.com"))));
        rejects(() -> ImportDuplicates.find(List.of(row(2,"   "))));
        var tooMany = new ArrayList<ImportDuplicates.InputRow>();
        for (int i = 0; i < 1001; i++) tooMany.add(row(i + 2,"a" + i + "@example.com"));
        rejects(() -> ImportDuplicates.find(tooMany));
        System.out.println("PASS: " + checks + " checks");
    }
}

执行:

javac -encoding UTF-8 ImportDuplicates.java ImportDuplicatesTest.java
java ImportDuplicatesTest

本次在 JDK 21.0.11 下的实际输出是 PASS: 14 checks。这14项检查针对查重组件,没有把文件上传、数据库或前端展示算进来。

接回项目时,改动要落在完整处理流程里

文件解析后,先保留原行号和原始值,再做字段校验与文件内查重。把格式合格的邮箱按相同规则整理后批量预查数据库。字段错误、文件重复、库内已存在分开返回,界面才能告诉使用者具体改什么。

本篇的重复组对象不承载全部错误。接入时可以与既有 fieldErrors 并列放在返回结果里,沿用项目现有错误协议,不必为了这次改造重写整个接口。

即使预查都通过,真正写入时仍可能遇到另一个请求刚插入的相同邮箱。数据库唯一约束和整批事务不能删;我在导入返回409却残留第一行的文章里单独验证过这个问题,这里不再重复展开。

这份代码还没有接入 RuoYi。要把它变成自己的项目改造,下一步很具体:接原文件行号、把冲突组显示出来、验证修改文件后能否重新提交。再保存一次改造前后的对照,说明原功能怎么反馈、自己改了哪里、哪些用例能证明区别。

你们遇到同一个邮箱对应不同姓名时,会让使用者确认,还是按一条明确规则自动保留?如果会自动处理,最关键的是哪条规则?