test(pickup): 基于生产克隆真实数据全链路验证店铺合并映射功能
- 数据克隆:安全连接生产环境(42.121.217.194),以只读方式完整克隆 4498 条提号单、30 条财务调整及关联支撑数据到本地数据库并校准 - 回归测试:新增 TestClonedProdDataOperations 端到端自动化回归用例,在 4498 条真实数据上完整验证店铺聚合统计、一键转标准店铺、多变体批量合并清洗、提号列表联动筛选及撤销还原逻辑 - 证据沉淀:更新实施方案文档,登记生产克隆与验证证据;验证通过全套代码门禁 (scripts/check.sh)
This commit is contained in:
@@ -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` 自动恢复为原始字样「王大锤商行」,数据完全可逆。
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user