✨ 司机档案 ID 存量回填脚本与文本护栏测试

This commit is contained in:
2026-10-27 20:55:03 +08:00
parent 0869aa7a9d
commit 3ff2c82938
3 changed files with 768 additions and 0 deletions
@@ -0,0 +1,289 @@
package org.springblade.transport.support;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
/**
* 存量司机档案 ID 回填脚本的文本护栏。
* <p>
* 仓库里没有连库的测试基座,回填脚本的正确性只能靠「读脚本、断言不变量」来守。
* 这里守的是「改脚本时最容易写坏、写坏了又不会报错」的几条:
* <ul>
* <li>只填 {@code driver_id IS NULL} 的行 —— 手滑把条件放宽成 {@code <= 0} 就会覆盖
* {@code -1}(无主哨兵)和已经派给别人的单。</li>
* <li>{@code driver_id = -1} 必须有一道显式排除 —— 只靠 {@code IS NULL} 挡住是隐式的,
* 后人看不出「-1 不能动」是需求,容易在重构时丢掉。</li>
* <li>跨表比较必须显式 {@code COLLATE} —— 运单/配载单侧是 {@code utf8mb4_0900_ai_ci},
* 司机档案侧是 {@code utf8mb4_general_ci},漏了就在执行时抛
* {@code ERROR 1267 Illegal mix of collations},而这只有真跑库才会暴露。</li>
* <li>不许 {@code SET driver_phone} / {@code SET update_time} —— 手机号是派单时的号码快照,
* 回填是「补归属」不是「改快照」;{@code update_time} 一改,业务上就分不清这张单是不是
* 真的被人动过。</li>
* <li>唯一命中({@code COUNT(*) = 1})与可重复执行({@code IF NOT EXISTS} / {@code INSERT IGNORE}
* / {@code IS NULL} 兜底)—— 同号多档案时填空会把单挂错人,重复跑必须零变更。</li>
* </ul>
* 这些断言只在脚本文本层面成立,不证明数据结果正确;数据结果由脚本第 2 节校验负责。
*/
class DriverIdBackfillSqlTest {
/**
* 每个用例跑两张表:运单一份、配载单一份,规则完全相同。
*/
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("只更新 driver_id 为 NULL 的行")
void onlyFillsNullDriverId(String fileName, String table) {
List<String> updates = updateStatements(script(fileName));
assertFalse(updates.isEmpty(), fileName + " 里找不到 UPDATE 语句:回填脚本必须真的写 driver_id");
for (String update : updates) {
String where = whereClause(update).toUpperCase();
assertTrue(where.contains("DRIVER_ID IS NULL"),
table + " 的 UPDATE 缺少 driver_id IS NULL:"
+ "条件一放宽就会覆盖 -1(无主哨兵)与已派单的行。原文:" + squeeze(update));
assertTrue(setClause(update).toUpperCase().contains("DRIVER_ID"),
table + " 的 UPDATE 没有写 driver_id:" + squeeze(update));
}
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("driver_id = -1 的哨兵行有一道显式排除")
void keepsSentinelRowsUntouched(String fileName, String table) {
List<String> updates = updateStatements(script(fileName));
assertFalse(updates.isEmpty(), fileName + " 里找不到 UPDATE 语句");
for (String update : updates) {
String where = whereClause(update);
assertTrue(where.contains("-1"),
table + " 的 UPDATE 没有出现 -1 保护:哨兵行会被回填覆盖。原文:" + squeeze(update));
assertTrue(where.toUpperCase().contains("IS NOT TRUE") || where.toUpperCase().contains("NOT ("),
table + " 的 UPDATE 里 -1 只是一笔带过(例如 driver_id <> -1),"
+ "对 NULL 行的比较结果是 NULL、条件不成立,要填的行会被整批漏掉。原文:" + squeeze(update));
}
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("跨表比较手机号与租户时显式 COLLATE")
void comparesAcrossTablesWithExplicitCollate(String fileName, String table) {
for (String line : body(fileName).split("\\R")) {
String trimmed = line.trim();
if (trimmed.isEmpty() || !trimmed.contains("=")) {
continue;
}
boolean tenantComparison = countOf(trimmed, ".tenant_id") >= 2;
boolean mobileComparison = trimmed.contains(".mobile") && trimmed.contains("driver_phone");
if (!tenantComparison && !mobileComparison) {
continue;
}
assertTrue(trimmed.toUpperCase().contains("COLLATE"),
table + " 的跨表比较没有显式 COLLATE(会抛 ERROR 1267 Illegal mix of collations):" + trimmed);
}
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("不改写 driver_phone 快照")
void neverRewritesPhoneSnapshot(String fileName, String table) {
for (String update : updateStatements(script(fileName))) {
assertFalse(setClause(update).toUpperCase().contains("DRIVER_PHONE"),
table + " 的 UPDATE 改写了 driver_phone:手机号是派单时的号码快照,回填只补归属。原文:" + squeeze(update));
}
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("不改写 update_time")
void neverRewritesUpdateTime(String fileName, String table) {
for (String update : updateStatements(script(fileName))) {
assertFalse(setClause(update).toUpperCase().contains("UPDATE_TIME"),
table + " 的 UPDATE 改写了 update_time:回填不是业务动作,改了就看不出单据是否真被人动过。原文:" + squeeze(update));
}
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("同号多档案不填:命中映射要求唯一")
void requiresUniqueMobileMatch(String fileName, String table) {
String body = body(fileName);
assertTrue(Pattern.compile("(?i)HAVING\\s+COUNT\\(\\*\\)\\s*=\\s*1").matcher(body).find(),
table + " 的回填映射没有 HAVING COUNT(*) = 1:同号多档案时会把单挂到其中一条档案上");
assertTrue(Pattern.compile("(?i)COUNT\\(DISTINCT\\s+d\\.id\\)\\s*>\\s*1").matcher(body).find(),
table + " 没有同号多档案的冲突清单:跳过多少、为什么跳过必须可复核");
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("可重复执行:存档表与命中映射都带重复执行保护")
void isRerunnable(String fileName, String table) {
String body = body(fileName);
assertTrue(Pattern.compile("(?i)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS").matcher(body).find(),
table + " 的存档表没有 IF NOT EXISTS:第二次执行会报 1050 Table already exists,跑不完");
assertTrue(Pattern.compile("(?i)INSERT\\s+IGNORE").matcher(body).find(),
table + " 的命中映射没有 INSERT IGNORE:第二次执行会撞主键,跑不完");
String insert = statement(body, "INSERT IGNORE");
assertFalse(insert.isEmpty(), table + " 的 INSERT IGNORE 不是一条完整语句");
assertTrue(insert.toUpperCase().contains("DRIVER_ID IS NULL"),
table + " 的命中映射没有 driver_id IS NULL 兜底:重复执行会给已经填过的行反复建映射");
assertTrue(body.contains("ROW_COUNT()"),
table + " 的分批循环没有 ROW_COUNT() 退出条件:会死循环或只跑第一批");
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("分批执行:循环里带 LIMIT 的批次大小")
void batchesInBoundedChunks(String fileName, String table) {
String body = body(fileName);
assertTrue(body.contains("REPEAT") || body.contains("WHILE"),
table + " 的回填没有分批循环:一次性 UPDATE 全表会长时间锁行");
assertTrue(Pattern.compile("(?i)LIMIT\\s+(\\d+|v_\\w+)").matcher(body).find(),
table + " 的 LIMIT 后面不是批次大小的数字或变量:不是真正的分批");
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("脚本按目录惯例分了 0 存档 / 1 订正 / 2 校验 / 3 回退")
void hasRequiredSections(String fileName, String table) {
String script = script(fileName);
assertTrue(script.contains("-- 0. 存档"), table + " 缺 -- 0. 存档 段");
assertTrue(script.contains("-- 1. 订正"), table + " 缺 -- 1. 订正 段");
assertTrue(script.contains("-- 2. 校验"), table + " 缺 -- 2. 校验 段");
assertTrue(script.contains("-- 3. 回退"), table + " 缺 -- 3. 回退 段");
}
@ParameterizedTest(name = "{0}")
@CsvSource({
"blade_waybill_driver_id_backfill_20261028.sql, blade_waybill",
"blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage"
})
@DisplayName("取数与写入全在 SQL 内:不经过 JSON 中转")
void keepsSnowflakeIdsInsideSql(String fileName, String table) {
String body = body(fileName).toUpperCase();
assertFalse(body.contains("JSON_"),
table + " 的脚本用了 JSON 函数中转:19 位雪花 ID 过 JSON 会丢精度,取数与写入必须在 SQL 内完成");
}
private static String script(String fileName) {
Path path = locate(fileName);
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException exception) {
throw new UncheckedIOException("读不了回填脚本:" + path, exception);
}
}
private static Path locate(String fileName) {
List<Path> candidates = List.of(
Path.of("..", "..", "doc", "sql", "transport", fileName),
Path.of("doc", "sql", "transport", fileName));
List<String> tried = new ArrayList<>();
for (Path candidate : candidates) {
if (Files.isRegularFile(candidate)) {
return candidate;
}
tried.add(candidate.toAbsolutePath().normalize().toString());
}
fail("找不到回填脚本 " + fileName + "。surefire 的工作目录是模块目录(blade-service/blade-transport),"
+ "脚本应在 ../../doc/sql/transport/ 下。已尝试:" + String.join(";", tried));
return null;
}
private static List<String> updateStatements(String script) {
List<String> updates = new ArrayList<>();
Matcher matcher = Pattern.compile("(?is)\\bUPDATE\\b.*?;").matcher(stripComments(script));
while (matcher.find()) {
updates.add(matcher.group());
}
return updates;
}
private static String statement(String body, String prefix) {
Matcher matcher = Pattern.compile("(?is)\\b" + Pattern.quote(prefix) + "\\b.*?;").matcher(body);
return matcher.find() ? matcher.group() : "";
}
private static String setClause(String update) {
String upper = update.toUpperCase();
int set = upper.indexOf(" SET ");
if (set < 0) {
fail("UPDATE 里没有 SET:" + squeeze(update));
}
int where = upper.indexOf(" WHERE ", set);
return where < 0 ? update.substring(set) : update.substring(set, where);
}
private static String whereClause(String update) {
int where = update.toUpperCase().indexOf(" WHERE ");
if (where < 0) {
fail("UPDATE 里没有 WHERE:" + squeeze(update));
}
return update.substring(where);
}
private static String body(String fileName) {
return stripComments(script(fileName));
}
private static String stripComments(String script) {
return script.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("--[^\\r\\n]*", " ");
}
private static int countOf(String text, String needle) {
int count = 0;
int index = text.indexOf(needle);
while (index >= 0) {
count++;
index = text.indexOf(needle, index + needle.length());
}
return count;
}
private static String squeeze(String sql) {
return sql.replaceAll("\\s+", " ").trim();
}
}