1. 新增 FlexibleFloat64 类型,解决上游 /api/me 响应中 points 为字符串(如 '8271.2')导致反序列化失败的问题; 2. 服务层与数据库快照存储统一调用 Float64() 保持类型严谨; 3. 单元测试补充真实上游字符串响应场景并全绿通过。
374 lines
11 KiB
Go
374 lines
11 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.Binding.Bound = true
|
||
resp.Binding.RoleName = currentBoundAccount
|
||
resp.Binding.RoleID = currentBoundRoleID
|
||
resp.Binding.Channel = "安卓-微信"
|
||
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)
|
||
}
|
||
}
|
||
|
||
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.HasPrefix(r.URL.Path, "/api/redeem/") {
|
||
var req map[string]string
|
||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||
receivedSkin = req["skin"]
|
||
receivedCurrency = req["currency"]
|
||
receivedAccount = req["account"]
|
||
|
||
resp := RockRedeemResponse{
|
||
Success: true,
|
||
Message: "发放成功",
|
||
}
|
||
resp.Delivery.Skin = receivedSkin
|
||
resp.Delivery.Currency = receivedCurrency
|
||
resp.Delivery.Quantity = 10
|
||
resp.Delivery.FulfillID = 888999
|
||
_ = json.NewEncoder(w).Encode(resp)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusOK)
|
||
}))
|
||
defer server.Close()
|
||
|
||
client := NewRockClient(server.URL)
|
||
rockSvc := NewRockService(db, client)
|
||
provider := NewRockProvider(rockSvc)
|
||
|
||
// 场景 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))
|
||
}
|
||
}
|