Files
affiliate_dash/backend/internal/service/provider_rock_test.go
T
yml2213 83870843ed fix(rock): 修复兑换发货401未鉴权错误与买家角色名提取解析
1. 根因修复(兑换发货鉴权):对比原生平台发货抓包证实 POST /api/redeem/:id 必须携带 Authorization: Bearer <token>,Origin 及 Referer 头,补齐相关请求头彻底杜绝 401 报错;
2. 根因修复(角色昵称解析):逆向解构 0666.pro 响应报文,将角色名提取从错误的 binding.role_name 切换为 token.bound_account,消除提货页角色名显示为 '-' 的问题;
3. 协议对齐(响应结构重构):将 RockRedeemResponse 与真实上游返回对齐(包含 token 与 status 结构),剔除臆想的 delivery 字段;
4. 单元测试与方案文档:更新单测覆盖鉴权与字段映射断言,在方案文档 7.4 节录入实测抓包报文镜像比对表。
2026-10-05 16:30:18 +08:00

407 lines
12 KiB
Go
Raw 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"
"testing"
"time"
"affiliate_dash/internal/model"
"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_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)
}
// 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/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))
}
}