一份客户表里,第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的组。
返回组,而不是给每一行复制一份“与谁重复”的长名单,还有一个好处:同一邮箱重复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。要把它变成自己的项目改造,下一步很具体:接原文件行号、把冲突组显示出来、验证修改文件后能否重新提交。再保存一次改造前后的对照,说明原功能怎么反馈、自己改了哪里、哪些用例能证明区别。
你们遇到同一个邮箱对应不同姓名时,会让使用者确认,还是按一条明确规则自动保留?如果会自动处理,最关键的是哪条规则?