package service import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/timeutil" "affiliate_dash/internal/testdb" "gorm.io/gorm" ) func newRockTestDB(t *testing.T) *gorm.DB { t.Helper() return testdb.New(t, &model.Merchant{}, &model.Product{}, &model.MerchantProduct{}, &model.FulfillmentOrder{}, &model.RockConfig{}, &model.RockToken{}, ) } func TestRockProvider_KillSwitch(t *testing.T) { db := newRockTestDB(t) client := NewRockClient("https://example.com") rockSvc := NewRockService(db, client) provider := NewRockProvider(rockSvc) // 1. 初始化配置并关闭总开关 cfg, err := rockSvc.GetConfig(context.Background()) if err != nil { t.Fatalf("get config: %v", err) } f := false _, err = rockSvc.UpdateConfig(context.Background(), UpdateRockConfigReq{ AutoDeliveryEnabled: &f, }) if err != nil { t.Fatalf("update config: %v", err) } order := &model.FulfillmentOrder{ OrderNo: "ROCK-ORDER-001", ProductSKU: model.RockSKUBlackTshirt, } // 2. PrepareDelivery 检查是否触发熔断 state, err := provider.PrepareDelivery(context.Background(), order, nil) if err != nil { t.Fatalf("prepare delivery: %v", err) } if state.CanShip { t.Fatalf("总开关关闭时应该不可发货") } if !strings.Contains(state.CannotShipReason, "维护中") { t.Fatalf("熔断提示信息不符: %s", state.CannotShipReason) } // 3. 恢复开启总开关 tr := true _, _ = rockSvc.UpdateConfig(context.Background(), UpdateRockConfigReq{ AutoDeliveryEnabled: &tr, }) _ = cfg } func TestRockProvider_PrepareDelivery_ZeroStockWithAssignedToken(t *testing.T) { db := newRockTestDB(t) client := NewRockClient("https://example.com") rockSvc := NewRockService(db, client) provider := NewRockProvider(rockSvc) // 开启总开关 tr := true _, _ = rockSvc.UpdateConfig(context.Background(), UpdateRockConfigReq{ AutoDeliveryEnabled: &tr, }) // 当前数据库没有可用 Token (available = 0) // 场景 1:未分配 Token 的新订单 newOrder := &model.FulfillmentOrder{ OrderNo: "ROCK-ORDER-NEW", ProductSKU: model.RockSKUBlackTshirt, } stateNew, err := provider.PrepareDelivery(context.Background(), newOrder, nil) if err != nil { t.Fatalf("prepare new order: %v", err) } if stateNew.CanShip { t.Fatalf("库存为 0 且未分配 Token 时新订单应该不可发货") } if !strings.Contains(stateNew.CannotShipReason, "库存补充中") { t.Fatalf("期望库存补充中提示,实际为: %s", stateNew.CannotShipReason) } // 场景 2:已分配并锁定 Token 的订单 (using 或 bound 状态) assignedOrder := &model.FulfillmentOrder{ OrderNo: "ROCK-ORDER-ASSIGNED", ProductSKU: model.RockSKUBlackTshirt, } now := time.Now() token := model.RockToken{ UpstreamTokenID: "999888", Token: "token_assigned", RedeemCode: "code_assigned", Color: model.RockColorBlack, Status: model.RockStatusBound, AssignedOrderNo: "ROCK-ORDER-ASSIGNED", ExpectedUID: "123456", ClaimedAt: &now, } db.Create(&token) stateAssigned, err := provider.PrepareDelivery(context.Background(), assignedOrder, nil) if err != nil { t.Fatalf("prepare assigned order: %v", err) } if !stateAssigned.CanShip { t.Fatalf("已分配并锁定 Token 的订单即使池子空闲库存为 0 也必须允许继续提货发货: %s", stateAssigned.CannotShipReason) } } func TestRockProvider_AntiGhostAndUIDMismatch(t *testing.T) { db := newRockTestDB(t) // Mock 上游 0666.pro 响应 var currentBoundAccount string var currentBoundRoleID string var currentBindChangedAt string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if strings.HasPrefix(r.URL.Path, "/api/redeem/") { _ = json.NewEncoder(w).Encode(RockRedeemDetailResponse{ ID: 151153, RedeemCode: "mock_redeem_code", Color: "black", Status: "available", BindURL: "https://gp.qq.com/cp/mock_bind", }) return } if strings.HasSuffix(r.URL.Path, "/refresh") { var resp RockRefreshResponse if currentBoundRoleID != "" { resp.Token.Status = "bound" resp.Token.BoundAccount = currentBoundAccount resp.Token.BoundAccountID = currentBoundRoleID resp.Token.Region = "安卓-微信" resp.Token.BindChangedAt = currentBindChangedAt } _ = json.NewEncoder(w).Encode(resp) return } w.WriteHeader(http.StatusNotFound) })) defer server.Close() client := NewRockClient(server.URL) rockSvc := NewRockService(db, client) provider := NewRockProvider(rockSvc) // 预先插入 1 个可用 Token,模拟历史残留数据 now := time.Now() token := model.RockToken{ UpstreamTokenID: "151153", Token: "mock_jwt_token", RedeemCode: "mock_redeem_code", Color: model.RockColorBlack, Status: model.RockStatusAvailable, ClaimedAt: &now, } db.Create(&token) // 模拟场景 A:上游初始已有历史残留账号“总荒凉”(3751279035) currentBoundAccount = "总荒凉" currentBoundRoleID = "3751279035" currentBindChangedAt = "2026-10-01 10:00:00" // 买家期望 UID 为 3688825401 expectedUID := "3688825401" order := &model.FulfillmentOrder{ OrderNo: "ROCK-ORDER-002", ProductSKU: model.RockSKUBlackTshirt, ResultData: fmt.Sprintf(`{"expected_uid":"%s"}`, expectedUID), } // 1. 买家点击生成绑定二维码 bindRes, err := provider.Bind(context.Background(), order, expectedUID) if err != nil { t.Fatalf("bind error: %v", err) } if bindRes.BindURL == "" { t.Fatalf("bind URL 不应为空") } // 2. 买家尚未扫码,轮询 CheckBind:应当识别出历史残留并忽略,不报错也不发货 check1, err := provider.CheckBind(context.Background(), order, bindRes.BindUUID) if err != nil { t.Fatalf("check bind: %v", err) } if check1.Bound { t.Fatalf("买家尚未扫码,不应判定为绑定成功(被历史残留误导)") } // 3. 买家扫错了号!拿了微信小号扫码,实际角色变成了“错误小号”(9999999999) currentBoundAccount = "错误小号" currentBoundRoleID = "9999999999" currentBindChangedAt = "2026-10-05 14:00:00" check2, err := provider.CheckBind(context.Background(), order, bindRes.BindUUID) if err != nil { t.Fatalf("check bind mismatch: %v", err) } if !check2.Mismatch { t.Fatalf("UID 不匹配时必须标记 mismatch = true!") } if check2.Bound { t.Fatalf("UID 不匹配时严禁 bound = true!") } // 检查数据库中 Token 状态是否已跃迁为 mismatch var dbToken model.RockToken db.First(&dbToken, token.ID) if dbToken.Status != model.RockStatusMismatch { t.Fatalf("Token 状态应为 mismatch,当前为 %s", dbToken.Status) } if !strings.Contains(dbToken.MismatchReason, "不一致") { t.Fatalf("mismatch reason 异常: %s", dbToken.MismatchReason) } // 3b. 绑定失败后,即使池里还有库存、买家改了 UID,也不能再领新号。 spare := model.RockToken{ UpstreamTokenID: "151154", Token: "spare_jwt_token", RedeemCode: "spare_redeem_code", Color: model.RockColorBlack, Status: model.RockStatusAvailable, ClaimedAt: &now, } db.Create(&spare) _, err = provider.Bind(context.Background(), order, "1111111111") if err == nil || !strings.Contains(err.Error(), "联系客服") { t.Fatalf("mismatch 后必须拒绝更换提货号,实际: %v", err) } var spareAfter model.RockToken db.First(&spareAfter, spare.ID) if spareAfter.Status != model.RockStatusAvailable || spareAfter.AssignedOrderNo != "" { t.Fatalf("拒绝换号后备用库存应保持可用,实际 status=%s order=%s", spareAfter.Status, spareAfter.AssignedOrderNo) } var assignedCount int64 db.Model(&model.RockToken{}).Where("assigned_order_no = ?", order.OrderNo).Count(&assignedCount) if assignedCount != 1 { t.Fatalf("一个订单只能占用一个提货号,实际 %d", assignedCount) } // 4. 客服确认买家输错,执行人工解冻重置 err = rockSvc.ResetMismatchToken(context.Background(), token.ID) if err != nil { t.Fatalf("reset mismatch token: %v", err) } db.First(&dbToken, token.ID) if dbToken.Status != model.RockStatusAvailable { t.Fatalf("重置后状态应为 available,当前为 %s", dbToken.Status) } // 5. 重新绑定正确账号“感性的洁少”(3688825401) currentBoundAccount = "感性的洁少" currentBoundRoleID = "3688825401" currentBindChangedAt = "2026-10-05 14:05:00" bindRes2, err := provider.Bind(context.Background(), order, expectedUID) if err != nil { t.Fatalf("rebind error: %v", err) } check3, err := provider.CheckBind(context.Background(), order, bindRes2.BindUUID) if err != nil { t.Fatalf("check bind match: %v", err) } if !check3.Bound { t.Fatalf("正确账号扫码后应判定为 bound = true") } if check3.RoleName != "感性的洁少" { t.Fatalf("正确账号扫码后应正确读取角色名 感性的洁少,实际为: %s", check3.RoleName) } } func TestRockProvider_SubmitBlueAndBlack(t *testing.T) { db := newRockTestDB(t) var receivedSkin string var receivedCurrency string var receivedAccount string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if strings.HasSuffix(r.URL.Path, "/refresh") { var resp RockRefreshResponse resp.DeliveryList = []RockDeliveryItem{ {FulfillID: 888999}, } _ = json.NewEncoder(w).Encode(resp) return } if strings.HasPrefix(r.URL.Path, "/api/redeem/") { if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]string{"error": "请先登录"}) return } var req map[string]string _ = json.NewDecoder(r.Body).Decode(&req) receivedSkin = req["skin"] receivedCurrency = req["currency"] receivedAccount = req["account"] resp := RockRedeemResponse{ Status: "success", Message: "领取成功", } resp.Token.Status = "redeemed" resp.Token.RedeemSkin = receivedSkin resp.Token.RedeemCurrency = receivedCurrency _ = json.NewEncoder(w).Encode(resp) return } w.WriteHeader(http.StatusOK) })) defer server.Close() client := NewRockClient(server.URL) rockSvc := NewRockService(db, client) provider := NewRockProvider(rockSvc) db.Model(&model.RockConfig{}).Where("id = 1").Update("bearer_token", "mock_bearer_token") // 场景 A:兑换蓝色运动服上衣 now := time.Now() tokenBlue := model.RockToken{ UpstreamTokenID: "151154", Token: "token_blue", RedeemCode: "code_blue", Color: model.RockColorBlack, // 通用黑色 Token Status: model.RockStatusBound, AssignedOrderNo: "ORDER-BLUE-001", ExpectedUID: "4331027062", ActualRoleName: "破浪小王子", ActualRoleID: "4331027062", BoundAt: &now, } db.Create(&tokenBlue) orderBlue := &model.FulfillmentOrder{ OrderNo: "ORDER-BLUE-001", ProductSKU: model.RockSKUBlueSportswear, // 蓝色运动服 ResultData: `{"expected_uid":"4331027062"}`, } submitRes, err := provider.Submit(context.Background(), orderBlue, "4331027062", fmt.Sprintf("%d", tokenBlue.ID)) if err != nil { t.Fatalf("submit blue error: %v", err) } if receivedSkin != model.RockSkinBlueSportswear { t.Fatalf("蓝色运动服发货应传入 %s,实际为 %s", model.RockSkinBlueSportswear, receivedSkin) } if receivedAccount != "破浪小王子" { t.Fatalf("发货账号应为 破浪小王子,实际为 %s", receivedAccount) } if submitRes.ProviderOrderNo != "888999" { t.Fatalf("期望官方 fulfillId 888999,实际为 %s", submitRes.ProviderOrderNo) } // 检查数据库记录 var dbToken model.RockToken db.First(&dbToken, tokenBlue.ID) if dbToken.Status != model.RockStatusRedeemed { t.Fatalf("发货后 Token 状态应为 redeemed,当前为 %s", dbToken.Status) } if dbToken.UpstreamFulfillID != 888999 { t.Fatalf("fulfillID 记录错误: %d", dbToken.UpstreamFulfillID) } } func TestRockService_StatsAndPurchase(t *testing.T) { db := newRockTestDB(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/api/user/dashboard" { writeRockDashboardJSON(w, 999, "20.0") return } if r.URL.Path == "/api/me" { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "user": map[string]interface{}{ "id": 10028, "username": "王大锤", "role": "merchant", "points": "8271.2", // 真实上游 0666.pro 返回字符串格式 "status": "active", }, }) return } if r.URL.Path == "/api/user/tokens/claim" { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "tokens": []map[string]interface{}{ { "id": 20001, "token": "token_mock_1", "color": "black", "region": "Q/V可用", "status": "available", "redeem_code": "code_mock_1", "claimed_at": "2026-10-05 14:00:00", }, { "id": 20002, "token": "token_mock_2", "color": "black", "region": "Q/V可用", "status": "available", "redeem_code": "code_mock_2", "claimed_at": "2026-10-05 14:00:00", }, }, }) return } w.WriteHeader(http.StatusOK) })) defer server.Close() client := NewRockClient(server.URL) rockSvc := NewRockService(db, client) // 1. 测试 Token 连通性 me, err := rockSvc.TestConnection(context.Background(), "mock_bearer_token") if err != nil { t.Fatalf("test connection: %v", err) } if me.User.Username != "王大锤" || me.User.Points != 8271.2 { t.Fatalf("用户信息解析不符: %+v", me.User) } // 2. 更新系统配置设置 BearerToken tokenStr := "mock_bearer_token" _, err = rockSvc.UpdateConfig(context.Background(), UpdateRockConfigReq{ BearerToken: &tokenStr, }) if err != nil { t.Fatalf("update config: %v", err) } // 3. 采购 2 个黑色通用 Token tokens, err := rockSvc.PurchaseTokens(context.Background(), 2, false) if err != nil { t.Fatalf("purchase tokens: %v", err) } if len(tokens) != 2 { t.Fatalf("期望采购 2 个,实际返回 %d", len(tokens)) } // 4. 检查库存统计 stats, err := rockSvc.GetStats(context.Background()) if err != nil { t.Fatalf("get stats: %v", err) } if stats.AvailableStock != 2 { t.Fatalf("期望可用库存为 2,实际为 %d", stats.AvailableStock) } if stats.Username != "王大锤" { t.Fatalf("期望采购商户名为王大锤,实际为 %s", stats.Username) } // 5. 分页查询列表 list, total, err := rockSvc.ListTokens(context.Background(), 1, 10, "", "") if err != nil { t.Fatalf("list tokens: %v", err) } if total != 2 || len(list) != 2 { t.Fatalf("期望查询到 2 条,实际为 total=%d len=%d", total, len(list)) } // 6. 测试关联履约订单与创建订单商户单号反查 orderNo := "FO_TEST_ROCK_999" clientOrderNo := "SHOP_ORDER_PDD_888" _ = db.Create(&model.FulfillmentOrder{ OrderNo: orderNo, ClientOrderNo: clientOrderNo, ProductSKU: "rock_black_tshirt", ProductName: "黑色ROCK长袖T恤", }).Error _ = db.Model(&model.RockToken{}).Where("id = ?", list[0].ID).Update("assigned_order_no", orderNo).Error listWithClient, _, err := rockSvc.ListTokens(context.Background(), 1, 10, "", "") if err != nil { t.Fatalf("list tokens with client: %v", err) } found := false for _, tk := range listWithClient { if tk.ID == list[0].ID { found = true if tk.ClientOrderNo != clientOrderNo { t.Fatalf("期望商户单号为 %s,实际为 %s", clientOrderNo, tk.ClientOrderNo) } } } if !found { t.Fatalf("未找到对应的 Token 记录") } // 7. 测试按商户单号关键词搜索 searchList, searchTotal, err := rockSvc.ListTokens(context.Background(), 1, 10, "", "PDD_888") if err != nil { t.Fatalf("search tokens by client_order_no: %v", err) } if searchTotal != 1 || len(searchList) != 1 { t.Fatalf("期望按商户单号搜索出 1 条,实际为 total=%d len=%d", searchTotal, len(searchList)) } } func TestReleaseUnboundUsingToken(t *testing.T) { db := newRockTestDB(t) var boundRoleID string var bindChangedAt string var deliveryTime string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if strings.HasSuffix(r.URL.Path, "/refresh") { var resp RockRefreshResponse if boundRoleID != "" { resp.Token.Status = "bound" resp.Token.BoundAccount = "已扫码角色" resp.Token.BoundAccountID = boundRoleID resp.Token.BindChangedAt = bindChangedAt } if deliveryTime != "" { resp.DeliveryList = []RockDeliveryItem{{ Time: deliveryTime, Role: "iOS-微信-已扫码角色", Message: "成功发放", }} } _ = json.NewEncoder(w).Encode(resp) return } w.WriteHeader(http.StatusNotFound) })) defer server.Close() client := NewRockClient(server.URL) rockSvc := NewRockService(db, client) provider := NewRockProvider(rockSvc) bearer := "mock_bearer_token" if _, err := rockSvc.UpdateConfig(context.Background(), UpdateRockConfigReq{BearerToken: &bearer}); err != nil { t.Fatalf("update config: %v", err) } snap := BindingSnapshot{ Bound: true, RoleName: "历史残留", RoleID: "111", BindChangedAt: "2026-10-01 10:00:00", } snapBytes, _ := json.Marshal(snap) token := model.RockToken{ UpstreamTokenID: "88001", Token: "jwt", RedeemCode: "redeem-unused", Color: model.RockColorBlack, Status: model.RockStatusUsing, AssignedOrderNo: "FO-RELEASE-1", ExpectedUID: "3688825401", InitialBoundSnapshot: string(snapBytes), } if err := db.Create(&token).Error; err != nil { t.Fatalf("create token: %v", err) } if err := db.Create(&model.FulfillmentOrder{ OrderNo: "FO-RELEASE-1", ProductSKU: model.RockSKUBlackTshirt, ProductName: "黑色ROCK长袖T恤", ResultData: fmt.Sprintf(`{"provider":"rock","rock_token_id":%d,"rock_redeem_code":"redeem-unused","expected_uid":"3688825401"}`, token.ID), }).Error; err != nil { t.Fatalf("create order: %v", err) } // 历史残留与快照一致,视为买家还没扫,允许释放。 boundRoleID = "111" bindChangedAt = "2026-10-01 10:00:00" if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), token.ID); err != nil { t.Fatalf("release ghost snapshot: %v", err) } var released model.RockToken if err := db.First(&released, token.ID).Error; err != nil { t.Fatalf("reload token: %v", err) } if released.Status != model.RockStatusAvailable || released.AssignedOrderNo != "" || released.ExpectedUID != "" { t.Fatalf("释放后应回到可用库存,实际 status=%s order=%s uid=%s", released.Status, released.AssignedOrderNo, released.ExpectedUID) } var order model.FulfillmentOrder if err := db.Where("order_no = ?", "FO-RELEASE-1").First(&order).Error; err != nil { t.Fatalf("reload order: %v", err) } if resultDataNumber(order.ResultData, "rock_token_id") != 0 || !strings.Contains(order.ResultData, "rock_token_released") { t.Fatalf("原订单仍指向已释放链接: %s", order.ResultData) } // 旧发货页继续轮询时,不能把已释放的链接再改成绑定。 boundRoleID = "3688825401" bindChangedAt = "2026-10-05 21:00:00" check, err := provider.CheckBind(context.Background(), &order, fmt.Sprintf("%d", token.ID)) if err != nil { t.Fatalf("check bind after release: %v", err) } if check.Bound { t.Fatalf("已释放链接不应再判定为绑定成功") } if err := db.First(&released, token.ID).Error; err != nil { t.Fatalf("reload after check: %v", err) } if released.Status != model.RockStatusAvailable { t.Fatalf("旧页面轮询后状态被改写为 %s", released.Status) } // 上游已经出现新扫码时拒绝释放。 using := model.RockToken{ UpstreamTokenID: "88002", Token: "jwt2", RedeemCode: "redeem-bound", Color: model.RockColorBlack, Status: model.RockStatusUsing, AssignedOrderNo: "FO-RELEASE-2", ExpectedUID: "3688825401", } if err := db.Create(&using).Error; err != nil { t.Fatalf("create using token: %v", err) } boundRoleID = "3688825401" bindChangedAt = "2026-10-05 21:10:00" if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), using.ID); err == nil || !strings.Contains(err.Error(), "已扫码绑定") { t.Fatalf("已绑定链接必须拒绝释放,实际: %v", err) } var stillUsing model.RockToken if err := db.First(&stillUsing, using.ID).Error; err != nil { t.Fatalf("reload using: %v", err) } if stillUsing.Status != model.RockStatusUsing || stillUsing.AssignedOrderNo != "FO-RELEASE-2" { t.Fatalf("拒绝释放后应保持使用中,实际 status=%s order=%s", stillUsing.Status, stillUsing.AssignedOrderNo) } // 上游 status=bound,但发货记录早于本次领取、绑定时间仍为空:历史残留,可以再次使用。 // 上游时间串不带偏移,统一按业务时区解析;这里的时间基线也必须用同一时区, // 否则测试结果会随宿主机 TZ 漂移。 claimedAt := time.Date(2026, 10, 5, 20, 32, 17, 0, timeutil.Location()) stale := model.RockToken{ UpstreamTokenID: "152415", Token: "jwt3", RedeemCode: "dbece05764c701a3be3bb5c689926d07", Color: model.RockColorBlack, Status: model.RockStatusUsing, AssignedOrderNo: "FO-RELEASE-3", ExpectedUID: "3364503067", ClaimedAt: &claimedAt, InitialBoundSnapshot: `{"bound":true,"role_name":"予心纯白","role_id":"1900505843","channel":"iOS-微信","bind_changed_at":""}`, } if err := db.Create(&stale).Error; err != nil { t.Fatalf("create stale token: %v", err) } boundRoleID = "1900505843" bindChangedAt = "" deliveryTime = "2026-09-27T14:38:51.23" if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), stale.ID); err != nil { t.Fatalf("历史残留应允许释放: %v", err) } // 领取之后出现新的发货记录,不能释放。 fresh := model.RockToken{ UpstreamTokenID: "152420", Token: "jwt4", RedeemCode: "redeem-fresh-delivery", Color: model.RockColorBlack, Status: model.RockStatusUsing, AssignedOrderNo: "FO-RELEASE-4", ExpectedUID: "4744920014", ClaimedAt: &claimedAt, } if err := db.Create(&fresh).Error; err != nil { t.Fatalf("create fresh token: %v", err) } boundRoleID = "4744920014" bindChangedAt = "2026-10-05 21:52:30" deliveryTime = "2026-10-05T21:52:40.61" if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), fresh.ID); err == nil || !strings.Contains(err.Error(), "已扫码绑定") { t.Fatalf("领取后的新发货必须拒绝释放,实际: %v", err) } } func TestRockProvider_SchemeB_UIDSelfHealing(t *testing.T) { db := newRockTestDB(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if strings.Contains(r.URL.Path, "/api/redeem/") { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "id": 1001, "bind_url": "https://igame.qq.com/activity/test?code=redeem_scheme_b", "redeem_code": "redeem_scheme_b", }) return } if strings.Contains(r.URL.Path, "refresh") { _ = json.NewEncoder(w).Encode(map[string]interface{}{ "token": map[string]interface{}{ "id": 152001, "status": "bound", "bind_changed_at": "2026-10-06 10:00:00", "bound_account": "少年有点忧郁", "bound_account_id": "3454648872", "region": "安卓-微信", }, "deliveryList": []interface{}{}, }) return } w.WriteHeader(http.StatusNotFound) })) defer server.Close() client := NewRockClient(server.URL) rockSvc := NewRockService(db, client) provider := NewRockProvider(rockSvc) ctx := context.Background() now := time.Now() token := model.RockToken{ UpstreamTokenID: "152001", Token: "test_jwt_scheme_b", RedeemCode: "redeem_scheme_b", Color: model.RockColorBlack, Status: model.RockStatusAvailable, ClaimedAt: &now, } db.Create(&token) order := &model.FulfillmentOrder{ OrderNo: "ROCK-ORDER-HEAL", ProductSKU: model.RockSKUBlackTshirt, } db.Create(order) // 1. 买家一开始输错 UID 为 3658745636,触发分配 bindRes1, err := provider.Bind(ctx, order, "3658745636") if err != nil { t.Fatalf("首次分配失败: %v", err) } if bindRes1.Bound { t.Fatalf("尚未扫码,bound 应为 false") } // 2. 扫码反查,真实角色是 3454648872,发生 mismatch check1, err := provider.CheckBind(ctx, order, bindRes1.BindUUID) if err != nil { t.Fatalf("check bind failed: %v", err) } if !check1.Mismatch { t.Fatalf("UID 不一致必须标记为 mismatch") } if !strings.Contains(check1.Message, "3454648872") { t.Fatalf("提示信息应包含引导买家确认修改为 3454648872,实际: %s", check1.Message) } // 3. 买家尝试换成第三个账号 9999999999,应坚决被拦截 _, err = provider.Bind(ctx, order, "9999999999") if err == nil || !strings.Contains(err.Error(), "3454648872") { t.Fatalf("更换为非扫码角色应被拦截,实际 err: %v", err) } // 4. 方案 B:买家确认更换为扫码角色 UID 3454648872 bindRes2, err := provider.Bind(ctx, order, "3454648872") if err != nil { t.Fatalf("确认更换为扫码角色 UID 应自愈成功,实际失败: %v", err) } if !bindRes2.Bound { t.Fatalf("自愈后 bound 应为 true") } if bindRes2.RoleName != "少年有点忧郁" || bindRes2.GameAccount != "3454648872" { t.Fatalf("自愈后返回的角色信息不符: %v", bindRes2) } // 5. 校验数据库状态与订单占号唯一性 var dbToken model.RockToken db.First(&dbToken, token.ID) if dbToken.Status != model.RockStatusBound { t.Fatalf("自愈后 token 状态应为 bound,当前: %s", dbToken.Status) } if dbToken.ExpectedUID != "3454648872" { t.Fatalf("自愈后 expectedUID 应为 3454648872,当前: %s", dbToken.ExpectedUID) } var count int64 db.Model(&model.RockToken{}).Where("assigned_order_no = ?", order.OrderNo).Count(&count) if count != 1 { t.Fatalf("整个流程该订单必须严格只占用 1 个 Token,实际占用: %d", count) } } func TestRockService_CorrectMismatchUIDKeepsAssignment(t *testing.T) { db := newRockTestDB(t) now := time.Now() token := model.RockToken{ UpstreamTokenID: "179", Token: "token-179", RedeemCode: "redeem-179", Color: model.RockColorBlack, Status: model.RockStatusMismatch, AssignedOrderNo: "FO-KEEP-179", ExpectedUID: "3780457044", ActualRoleName: "德州火箭头", ActualRoleID: "3780451044", ActualChannel: "安卓-微信", MismatchAt: &now, MismatchReason: "预期UID(3780457044) 与 扫码UID(德州火箭头/3780451044) 不一致", } if err := db.Create(&token).Error; err != nil { t.Fatalf("create token: %v", err) } order := model.FulfillmentOrder{ OrderNo: "FO-KEEP-179", MerchantID: 2, ProductSKU: model.RockSKUBlackTshirt, OrderStatus: "paid", ResultData: `{"expected_uid":"3780457044","provider":"rock"}`, } if err := db.Create(&order).Error; err != nil { t.Fatalf("create order: %v", err) } rockSvc := NewRockService(db, NewRockClient("https://example.com")) kept, err := rockSvc.CorrectMismatchExpectedUID(context.Background(), token.ID, "1111111111") if err != nil { t.Fatalf("update unmatched uid: %v", err) } if kept.Status != model.RockStatusMismatch || kept.AssignedOrderNo != "FO-KEEP-179" || kept.ExpectedUID != "1111111111" { t.Fatalf("未对上扫码 UID 时应保持占用和阻断,实际 status=%s order=%s uid=%s", kept.Status, kept.AssignedOrderNo, kept.ExpectedUID) } bound, err := rockSvc.CorrectMismatchExpectedUID(context.Background(), token.ID, "3780451044") if err != nil { t.Fatalf("correct to scanned uid: %v", err) } if bound.Status != model.RockStatusBound || bound.AssignedOrderNo != "FO-KEEP-179" || bound.ExpectedUID != "3780451044" { t.Fatalf("改成扫码 UID 后应解除阻断并留在原订单,实际 status=%s order=%s uid=%s", bound.Status, bound.AssignedOrderNo, bound.ExpectedUID) } var savedOrder model.FulfillmentOrder if err := db.Where("order_no = ?", "FO-KEEP-179").First(&savedOrder).Error; err != nil { t.Fatalf("reload order: %v", err) } if !strings.Contains(savedOrder.ResultData, "3780451044") { t.Fatalf("订单期望 UID 应同步为扫码 UID,实际: %s", savedOrder.ResultData) } } func TestRockBindLinkStillFreshUsesURLTimestamp(t *testing.T) { now := time.Now() stale := fmt.Sprintf("https://gp.qq.com/cp/a20241128mobile/index.html?timestamp=%d", now.Add(-rockBindLinkMaxAge-time.Second).Unix()) fresh := fmt.Sprintf("https://gp.qq.com/cp/a20241128mobile/index.html?timestamp=%d", now.Add(-time.Minute).Unix()) if rockBindLinkStillFresh(rockBindLinkCacheItem{bindURL: stale, cachedAt: now}, now) { t.Fatal("签名已超过刷新点时,不能因为刚写入缓存就继续展示") } if !rockBindLinkStillFresh(rockBindLinkCacheItem{bindURL: fresh, cachedAt: now.Add(-time.Hour)}, now) { t.Fatal("签名仍在刷新点内就应该继续展示") } } func TestRockProvider_CheckBindRefreshesSameTokenLink(t *testing.T) { db := newRockTestDB(t) var mu sync.Mutex redeemHits := 0 staleTS := time.Now().Add(-rockBindLinkMaxAge - time.Second).Unix() freshTS := time.Now().Unix() oldURL := fmt.Sprintf("https://gp.qq.com/cp/a20241128mobile/index.html?timestamp=%d&nonce=old&sign=old", staleTS) newURL := fmt.Sprintf("https://gp.qq.com/cp/a20241128mobile/index.html?timestamp=%d&nonce=new&sign=new", freshTS) currentURL := oldURL server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if strings.HasPrefix(r.URL.Path, "/api/redeem/") { mu.Lock() redeemHits++ bindURL := currentURL mu.Unlock() _ = json.NewEncoder(w).Encode(RockRedeemDetailResponse{BindURL: bindURL}) return } if strings.HasSuffix(r.URL.Path, "/refresh") { _ = json.NewEncoder(w).Encode(RockRefreshResponse{}) return } w.WriteHeader(http.StatusNotFound) })) defer server.Close() client := NewRockClient(server.URL) rockSvc := NewRockService(db, client) provider := NewRockProvider(rockSvc) now := time.Now() token := model.RockToken{ UpstreamTokenID: "177", Token: "token-177", RedeemCode: "7b7445735ab7432fdc9db997c7637913", Color: model.RockColorBlack, Status: model.RockStatusUsing, AssignedOrderNo: "FO20261006170653afd37ce7e19", ExpectedUID: "3763064651", ClaimedAt: &now, } if err := db.Create(&token).Error; err != nil { t.Fatalf("create token: %v", err) } order := &model.FulfillmentOrder{ OrderNo: token.AssignedOrderNo, ProductSKU: model.RockSKUBlackTshirt, ResultData: `{"expected_uid":"3763064651","provider":"rock"}`, } // 缓存刚写入,但链接上的签名时间已经超过刷新点,必须换新码。 rockSvc.bindLinkCache.Store(token.ID, rockBindLinkCacheItem{ bindURL: oldURL, qrURL: "https://example.test/old.png", cachedAt: time.Now(), fetchedAt: time.Now().Add(-rockBindLinkRetryGap - time.Second), }) mu.Lock() currentURL = newURL mu.Unlock() check, err := provider.CheckBind(context.Background(), order, fmt.Sprintf("%d", token.ID)) if err != nil { t.Fatalf("check bind: %v", err) } if check.BindURL != currentURL { t.Fatalf("过期后应返回同一提取码的新链接,实际: %s", check.BindURL) } if check.BindUUID != fmt.Sprintf("%d", token.ID) || check.Bound || check.Mismatch { t.Fatalf("刷新二维码不能换号或改绑定状态: %+v", check) } var saved model.RockToken if err := db.First(&saved, token.ID).Error; err != nil { t.Fatalf("reload token: %v", err) } if saved.Status != model.RockStatusUsing || saved.AssignedOrderNo != token.AssignedOrderNo { t.Fatalf("必须留在原提货号,实际 status=%s order=%s", saved.Status, saved.AssignedOrderNo) } var tokenCount int64 db.Model(&model.RockToken{}).Count(&tokenCount) if tokenCount != 1 { t.Fatalf("不能另发提货号,实际 %d 条", tokenCount) } mu.Lock() hitsAfterRefresh := redeemHits currentURL = "https://gp.qq.com/cp/should-not-fetch" mu.Unlock() checkCached, err := provider.CheckBind(context.Background(), order, fmt.Sprintf("%d", token.ID)) if err != nil { t.Fatalf("cached check: %v", err) } if checkCached.BindURL != newURL { t.Fatalf("有效期内应复用刚取到的链接,实际: %s", checkCached.BindURL) } mu.Lock() if redeemHits != hitsAfterRefresh { t.Fatalf("有效期内不应再次请求 0666,实际多了 %d 次", redeemHits-hitsAfterRefresh) } mu.Unlock() other := &model.FulfillmentOrder{ OrderNo: "FO-OTHER", ProductSKU: model.RockSKUBlackTshirt, ResultData: `{"expected_uid":"3763064651"}`, } checkOther, err := provider.CheckBind(context.Background(), other, fmt.Sprintf("%d", token.ID)) if err != nil { t.Fatalf("other order check: %v", err) } if checkOther.BindURL != "" { t.Fatalf("其他订单不能拿到这个提货号的二维码: %s", checkOther.BindURL) } } func TestRockService_TokenRemarkAndAbnormal(t *testing.T) { db := testdb.New(t) client := NewRockClient("https://0666.pro") rockSvc := NewRockService(db, client) now := time.Now() token := model.RockToken{ UpstreamTokenID: "TEST_REMARK_1", Token: "jwt-remark-1", RedeemCode: "CODE_REMARK_1", Status: model.RockStatusMismatch, AssignedOrderNo: "ORDER_REMARK_1", ExpectedUID: "123456", ActualRoleID: "654321", ActualRoleName: "路人甲", MismatchAt: &now, MismatchReason: "预期UID不符", } if err := db.Create(&token).Error; err != nil { t.Fatalf("create test token failed: %v", err) } // 1. 测试单纯更新备注 updated, err := rockSvc.UpdateTokenRemark(context.Background(), token.ID, "买家联系客服核对中", "") if err != nil { t.Fatalf("update remark failed: %v", err) } if updated.Remark != "买家联系客服核对中" || updated.Status != model.RockStatusMismatch { t.Fatalf("更新备注不符: remark=%s status=%s", updated.Remark, updated.Status) } // 2. 测试快捷标记为已上报失效 (MarkTokenAbnormal) abnormalToken, err := rockSvc.MarkTokenAbnormal(context.Background(), token.ID, "已上报0868客服 (卡密绑定错误废弃)") if err != nil { t.Fatalf("mark abnormal failed: %v", err) } if abnormalToken.Status != model.RockStatusAbnormal { t.Fatalf("标记异常后状态应为 abnormal,实际: %s", abnormalToken.Status) } if abnormalToken.Remark != "已上报0868客服 (卡密绑定错误废弃)" { t.Fatalf("标记异常后备注不符: %s", abnormalToken.Remark) } // 3. 搜索备注测试 list, total, err := rockSvc.ListTokens(context.Background(), 1, 10, "", "卡密绑定错误废弃") if err != nil { t.Fatalf("list by remark search error: %v", err) } if total != 1 || len(list) != 1 || list[0].ID != token.ID { t.Fatalf("通过备注搜索未检索到目标 token, total=%d", total) } // 4. 从 abnormal 状态解冻重置回 available if err := rockSvc.ResetMismatchToken(context.Background(), token.ID); err != nil { t.Fatalf("reset abnormal token error: %v", err) } var resetToken model.RockToken if err := db.First(&resetToken, token.ID).Error; err != nil { t.Fatalf("find reset token error: %v", err) } if resetToken.Status != model.RockStatusAvailable { t.Fatalf("重置后应为 available,实际: %s", resetToken.Status) } if resetToken.AssignedOrderNo != "" || resetToken.ExpectedUID != "" { t.Fatalf("重置后应清空订单关联, assigned_order_no=%s", resetToken.AssignedOrderNo) } }