Files

1071 lines
36 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}