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 节录入实测抓包报文镜像比对表。
407 lines
12 KiB
Go
407 lines
12 KiB
Go
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))
|
||
}
|
||
}
|