From 2c943027f00e5368f9622f1130116da747568a13 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 9 Oct 2026 13:45:59 +0800 Subject: [PATCH] =?UTF-8?q?test(pickup):=20=E5=9F=BA=E4=BA=8E=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E5=85=8B=E9=9A=86=E7=9C=9F=E5=AE=9E=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=85=A8=E9=93=BE=E8=B7=AF=E9=AA=8C=E8=AF=81=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E6=98=A0=E5=B0=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据克隆:安全连接生产环境(42.121.217.194),以只读方式完整克隆 4498 条提号单、30 条财务调整及关联支撑数据到本地数据库并校准 - 回归测试:新增 TestClonedProdDataOperations 端到端自动化回归用例,在 4498 条真实数据上完整验证店铺聚合统计、一键转标准店铺、多变体批量合并清洗、提号列表联动筛选及撤销还原逻辑 - 证据沉淀:更新实施方案文档,登记生产克隆与验证证据;验证通过全套代码门禁 (scripts/check.sh) --- .../pickup/clone_prod_verification_test.go | 202 ++++++++++++++++++ docs/线下提号字段扩展与店铺管理实施方案.md | 24 +++ 2 files changed, 226 insertions(+) create mode 100644 backend/internal/modules/pickup/clone_prod_verification_test.go diff --git a/backend/internal/modules/pickup/clone_prod_verification_test.go b/backend/internal/modules/pickup/clone_prod_verification_test.go new file mode 100644 index 0000000..8d17927 --- /dev/null +++ b/backend/internal/modules/pickup/clone_prod_verification_test.go @@ -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("=== 全部克隆数据回归测试用例验证通过! ===") +} diff --git a/docs/线下提号字段扩展与店铺管理实施方案.md b/docs/线下提号字段扩展与店铺管理实施方案.md index fc3466d..48fd708 100644 --- a/docs/线下提号字段扩展与店铺管理实施方案.md +++ b/docs/线下提号字段扩展与店铺管理实施方案.md @@ -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` 自动恢复为原始字样「王大锤商行」,数据完全可逆。 + +