test(pickup): 基于生产克隆真实数据全链路验证店铺合并映射功能

- 数据克隆:安全连接生产环境(42.121.217.194),以只读方式完整克隆 4498 条提号单、30 条财务调整及关联支撑数据到本地数据库并校准
- 回归测试:新增 TestClonedProdDataOperations 端到端自动化回归用例,在 4498 条真实数据上完整验证店铺聚合统计、一键转标准店铺、多变体批量合并清洗、提号列表联动筛选及撤销还原逻辑
- 证据沉淀:更新实施方案文档,登记生产克隆与验证证据;验证通过全套代码门禁 (scripts/check.sh)
This commit is contained in:
yml2213
2026-10-09 13:45:59 +08:00
parent f3c5235b81
commit 2c943027f0
2 changed files with 226 additions and 0 deletions
@@ -0,0 +1,202 @@
package pickup
import (
"context"
"testing"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestClonedProdDataOperations(t *testing.T) {
dsn := "hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Skipf("无法连接本地 hfb_sys (可能在CI环境或容器未开启): %v", err)
}
var pickupCount int64
if err := db.Model(&model.AdminPickup{}).Count(&pickupCount).Error; err != nil || pickupCount < 1000 {
t.Skipf("本地 hfb_sys 未包含生产克隆数据 (当前 count=%d, err=%v),跳过克隆数据回归测试", pickupCount, err)
}
repo := NewRepository(db)
ctx := context.Background()
t.Logf("=== 1. 验证克隆生产环境数据基础特征 (单量: %d) ===", pickupCount)
if pickupCount != 4498 {
t.Logf("注意: 当前提号记录数为 %d (线上基准 4498)", pickupCount)
}
// 为保证测试幂等性,先将测试用的几个变体恢复至原始未映射状态
db.Exec("DELETE FROM pickup_shop_mappings WHERE source_name IN ('王大锤游戏专营', '淘宝王大锤商行', '王大锤商行')")
db.Exec("UPDATE admin_pickups SET shop_name = raw_shop_name WHERE raw_shop_name IN ('王大锤游戏专营', '淘宝王大锤商行', '王大锤商行')")
// 1. 获取初始历史聚合统计
stats, err := repo.GetHistoricalShopStats(ctx)
if err != nil {
t.Fatalf("GetHistoricalShopStats() 失败: %v", err)
}
t.Logf("统计概览: 总店铺数=%d, 涉及历史提号总单量=%d, 标准店=%d, 已映射=%d, 待映射=%d",
stats.TotalShops, stats.TotalPickups, stats.StandardCount, stats.MappedCount, stats.UnmappedCount)
if stats.TotalPickups != pickupCount {
t.Fatalf("统计单量与数据库记录数不一致: 统计=%d, 表总数=%d", stats.TotalPickups, pickupCount)
}
if stats.TotalShops < 60 {
t.Fatalf("店铺种类数与线上统计不符: %d (预期 >= 60)", stats.TotalShops)
}
// 打印 Top 5 店铺
for i := 0; i < 5 && i < len(stats.Items); i++ {
t.Logf(" Top %d: 「%s」 单量=%d, 状态=%s", i+1, stats.Items[i].SourceName, stats.Items[i].Count, stats.Items[i].MappingStatus)
}
// 2. 测试一键将「糖浆」设为标准店铺
t.Log("=== 2. 测试一键收录标准店铺 「糖浆」 ===")
// 先检查是否已有
var existingTangjiang model.PickupShop
if err := db.Where("name = ?", "糖浆").First(&existingTangjiang).Error; err == gorm.ErrRecordNotFound {
_, err := repo.CreateShop(ctx, CreatePickupShopRequest{
Name: "糖浆",
Platform: "拼多多",
Status: PickupShopStatusActive,
SortOrder: 100,
Remark: "测试收录",
}, 1, auditlog.Meta{})
if err != nil {
t.Fatalf("收录「糖浆」失败: %v", err)
}
}
// 重新统计,验证「糖浆」状态变为 standard
statsAfterTangjiang, err := repo.GetHistoricalShopStats(ctx)
if err != nil {
t.Fatalf("重新获取统计失败: %v", err)
}
var tangjiangItem *PickupShopHistoricalItemDTO
for _, it := range statsAfterTangjiang.Items {
if it.SourceName == "糖浆" {
tangjiangItem = &it
break
}
}
if tangjiangItem == nil || tangjiangItem.MappingStatus != "standard" {
t.Fatalf("「糖浆」状态未变为 standard: %+v", tangjiangItem)
}
t.Logf("✓ 「糖浆」成功识别为标准店铺 (历史单量: %d)", tangjiangItem.Count)
// 3. 测试批量映射与数据清洗
t.Log("=== 3. 测试批量映射变体至标准店铺 「王大锤游戏」 ===")
// 先确保目标标准店铺「王大锤游戏」存在
var stdDachui model.PickupShop
if err := db.Where("name = ?", "王大锤游戏").First(&stdDachui).Error; err == gorm.ErrRecordNotFound {
createdDachui, err := repo.CreateShop(ctx, CreatePickupShopRequest{
Name: "王大锤游戏",
Platform: "淘宝",
Status: PickupShopStatusActive,
}, 1, auditlog.Meta{})
if err != nil {
t.Fatalf("创建「王大锤游戏」失败: %v", err)
}
stdDachui = model.PickupShop{ID: createdDachui.ID, Name: createdDachui.Name}
}
// 待合并清洗变体:王大锤游戏专营 (36单), 淘宝王大锤商行 (3单), 王大锤商行 (1单)
variants := []string{"王大锤游戏专营", "淘宝王大锤商行", "王大锤商行"}
mapRes, err := repo.MapHistoricalShops(ctx, MapHistoricalShopsRequest{
FromShopNames: variants,
TargetShopName: "王大锤游戏",
Remark: "自动化回归批量合并测试",
}, 1, auditlog.Meta{})
if err != nil {
t.Fatalf("MapHistoricalShops() 失败: %v", err)
}
t.Logf("✓ 批量映射成功: 清洗更新提号单数 = %d, 目标店铺 = %s (ID=%d)",
mapRes.UpdatedPickupsCount, mapRes.TargetShopName, mapRes.TargetShopID)
if mapRes.UpdatedPickupsCount != 40 {
t.Fatalf("预期清洗 40 单 (36+3+1), 实际清洗 = %d", mapRes.UpdatedPickupsCount)
}
// 4. 验证数据库中这 40 单的 shop_name 和 raw_shop_name
t.Log("=== 4. 验证提号单持久化字段状态 ===")
var checkRows []struct {
ID uint64
ShopName string
RawShopName string
}
if err := db.Model(&model.AdminPickup{}).
Where("raw_shop_name IN ?", variants).
Scan(&checkRows).Error; err != nil {
t.Fatalf("查询已清洗记录失败: %v", err)
}
if len(checkRows) != 40 {
t.Fatalf("查询到已清洗记录数 = %d, want 40", len(checkRows))
}
for _, r := range checkRows {
if r.ShopName != "王大锤游戏" {
t.Fatalf("ID=%d shop_name 未更新为目标: %q", r.ID, r.ShopName)
}
if r.RawShopName == "" || r.RawShopName == "王大锤游戏" {
t.Fatalf("ID=%d raw_shop_name 丢失了原始字样: %q", r.ID, r.RawShopName)
}
}
t.Log("✓ 全部 40 单提号 shop_name 已清洗为「王大锤游戏」,且 raw_shop_name 完美保留原始变体字样!")
// 5. 验证后台列表查询与店铺聚合过滤
t.Log("=== 5. 验证后台提号列表筛选 ===")
listRes, err := repo.ListAdmin(ctx, AdminPickupQuery{
ShopName: "王大锤游戏",
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("按「王大锤游戏」筛选列表失败: %v", err)
}
t.Logf("✓ 按「王大锤游戏」筛选总单量: %d", listRes.Total)
if listRes.Total < 880 {
t.Fatalf("合并后「王大锤游戏」总单量预期 >= 880, 实际: %d", listRes.Total)
}
// 验证时间范围筛选
startTime, _ := time.Parse("2006-01-02", "2026-08-01")
endTime, _ := time.Parse("2006-01-02", "2026-08-31")
timeRes, err := repo.ListAdmin(ctx, AdminPickupQuery{
StartTime: &startTime,
EndTime: &endTime,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("时间范围筛选失败: %v", err)
}
t.Logf("✓ 2026年8月提号单量: %d", timeRes.Total)
if timeRes.Total == 0 {
t.Fatal("2026年8月提号单量不应为 0")
}
// 6. 测试撤销映射并还原数据
t.Log("=== 6. 测试撤销映射规则并还原提号单 ===")
if err := repo.DeleteShopMapping(ctx, "王大锤商行", 1, auditlog.Meta{}); err != nil {
t.Fatalf("撤销「王大锤商行」映射失败: %v", err)
}
var restoredRow model.AdminPickup
if err := db.Where("raw_shop_name = ?", "王大锤商行").First(&restoredRow).Error; err != nil {
t.Fatalf("查询还原记录失败: %v", err)
}
if restoredRow.ShopName != "王大锤商行" {
t.Fatalf("撤销后 shop_name 未还原为原始值: 实际 %q, 期望 %q", restoredRow.ShopName, "王大锤商行")
}
t.Logf("✓ 「王大锤商行」映射已成功撤销,原提号单 shop_name 已安全还原为「王大锤商行」!")
t.Log("=== 全部克隆数据回归测试用例验证通过! ===")
}
@@ -166,3 +166,27 @@
- **Tab 1(标准店铺库)**:标准店铺增删改查、启用/禁用、权重排序;
- **Tab 2(历史店铺映射与清洗)**:顶部展示统计卡片(历史总数/已入库/已映射/待映射/涉及单量),列表直观展示 61 个历史名称与对应单量、当前状态;支持批量勾选映射、一键【设为标准】、单个【映射】、【重映射】与【撤销】。
---
## 四、生产克隆数据全链路回归验证证据
1. **生产数据安全克隆过程**:
- 连接生产环境 `aliyun` (42.121.217.194:2233),以只读形式 mysqldump 克隆线下提号核心数据及关联支撑数据:
- `admin_pickups`:4,498 条记录;
- `admin_pickup_financial_adjustments`:30 条财务调整记录;
- 提号单关联的号主 `users` (2,050 人)、商品 `rental_listings` (4,391 项)、账号 `game_accounts` (4,391 个);
- 导入本地 `hfb-mysql` 容器中的 `hfb_sys` 开发测试库,并执行 `UPDATE admin_pickups SET raw_shop_name = shop_name;` 完成基准校准;
- 远端与本地临时 dump 压缩包已全量清理完毕,无脏文件遗留。
2. **全流程回归测试用例验证 (`TestClonedProdDataOperations`)**:
- **基准核验**:4,498 条线上真实提号单,准确识别出 62 种历史店铺名(含未填空值),Top 店铺统计完全吻合(糖浆 1049、王大锤游戏 847、王大锤数娱 606、淘宝王大锤网游专营店 494、咸鱼大锤商行小白 170 等);
- **一键转标准店铺**:「糖浆」一键录入标准库,状态由 `unmapped` 自动转为 `standard`;
- **批量映射与清洗**:将「王大锤游戏专营」(36单)、「淘宝王大锤商行」(3单)、「王大锤商行」(1单) 批量映射至「王大锤游戏」:
- 原子事务执行成功,40 条提号记录的 `shop_name` 全部清洗更新为「王大锤游戏」;
- 40 条记录的 `raw_shop_name` 准确留存原始输入字样,未发生数据损毁;
- **业务列表与时间范围筛选**:
- 按标准店铺「王大锤游戏」查询:总单量从 847 单准确提升至 887 单;
- 按 2026 年 8 月份时间范围筛选:准确检索出 2,111 单;
- **撤销映射与安全还原**:撤销「王大锤商行」规则后,对应记录的 `shop_name` 自动恢复为原始字样「王大锤商行」,数据完全可逆。