Files
hfb_sys/backend/internal/modules/pickup/clone_prod_verification_test.go
T
yml2213 2c943027f0 test(pickup): 基于生产克隆真实数据全链路验证店铺合并映射功能
- 数据克隆:安全连接生产环境(42.121.217.194),以只读方式完整克隆 4498 条提号单、30 条财务调整及关联支撑数据到本地数据库并校准
- 回归测试:新增 TestClonedProdDataOperations 端到端自动化回归用例,在 4498 条真实数据上完整验证店铺聚合统计、一键转标准店铺、多变体批量合并清洗、提号列表联动筛选及撤销还原逻辑
- 证据沉淀:更新实施方案文档,登记生产克隆与验证证据;验证通过全套代码门禁 (scripts/check.sh)
2026-10-09 13:45:59 +08:00

203 lines
7.5 KiB
Go

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("=== 全部克隆数据回归测试用例验证通过! ===")
}