Files
affiliate_dash/backend/internal/service/provider_rock_test.go
T
yml2213 ef7f67dd0c feat(fulfillment): 接入Rock发货平台(0666.pro)代理履约、通用库存池与UID强一致风控
1. 数据库新增 012_rock_fulfillment 迁移,创建 rock_configs 配置表与 rock_tokens 通用库存表,补齐黑色ROCK长袖T恤与蓝色运动服上衣商品目录;
2. 封装 RockClient 与 RockService,实现 0666.pro 平台 Bearer Token 极简连通校验、批量采购50个入库、水位监控巡检与自动补货;
3. 新增 RockProvider 实现标准履约路由,引入 Anti-Ghost 旧绑定基线比对与 UID 强一致门禁,扫错号硬阻断并记录为 mismatch 状态;
4. 针对黑色ROCK与蓝色运动服实现动态发货(绑定后发货前动态选择 skin),并提取腾讯官方发货流水号 fulfillId 对账;
5. 新增全局自动发货总开关(Kill Switch),支持一键软熔断降级转人工接单处理;
6. 前端新增 PlatformRock 管理大屏,提供总开关切换、Token配置与连通测试、库存监控、一键补货及错单解冻重置。
2026-10-05 14:56:11 +08:00

374 lines
11 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.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,
"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))
}
}