feat(rock): 同一提货号过期后重取二维码
轮询绑定时向 0666 换一条新的活动链接,提货号不变。下游只展示返回的链接。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -215,7 +215,7 @@ func (p *RockProvider) CheckBind(ctx context.Context, order *model.FulfillmentOr
|
||||
patch["actual_role_id"] = res.RoleID
|
||||
}
|
||||
|
||||
return &ProviderBindResult{
|
||||
result := &ProviderBindResult{
|
||||
BindUUID: bindUUID,
|
||||
Bound: res.Bound,
|
||||
RoleName: res.RoleName,
|
||||
@@ -226,7 +226,14 @@ func (p *RockProvider) CheckBind(ctx context.Context, order *model.FulfillmentOr
|
||||
Message: res.Message,
|
||||
Provider: ProviderRock,
|
||||
PatchResultData: patch,
|
||||
}, nil
|
||||
}
|
||||
// 还在等扫码时,把同一提取码的最新活动链接交回去。下游只展示,不负责重取。
|
||||
if !res.Bound && !res.Mismatch {
|
||||
bindURL, qrURL := p.rockService.freshBindLink(ctx, uint(tokenIDUint), order.OrderNo)
|
||||
result.BindURL = bindURL
|
||||
result.QRURL = qrURL
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *RockProvider) Submit(ctx context.Context, order *model.FulfillmentOrder, gameAccount, bindUUID string) (*ProviderSubmitResult, error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -862,3 +863,115 @@ func TestRockService_CorrectMismatchUIDKeepsAssignment(t *testing.T) {
|
||||
t.Fatalf("订单期望 UID 应同步为扫码 UID,实际: %s", savedOrder.ResultData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRockProvider_CheckBindRefreshesSameTokenLink(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
|
||||
var mu sync.Mutex
|
||||
redeemHits := 0
|
||||
currentURL := "https://gp.qq.com/cp/a20241128mobile/index.html?timestamp=1&nonce=old&sign=old"
|
||||
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: "https://gp.qq.com/cp/a20241128mobile/index.html?timestamp=1&nonce=old&sign=old",
|
||||
qrURL: "https://example.test/old.png",
|
||||
cachedAt: time.Now().Add(-rockBindLinkMaxAge - time.Second),
|
||||
})
|
||||
mu.Lock()
|
||||
currentURL = "https://gp.qq.com/cp/a20241128mobile/index.html?timestamp=2&nonce=new&sign=new"
|
||||
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 != "https://gp.qq.com/cp/a20241128mobile/index.html?timestamp=2&nonce=new&sign=new" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,22 @@ type rockRefreshCacheItem struct {
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
// 腾讯活动链接按生成时间签名。同一提货号过期后重取链接,不另发新号。
|
||||
const rockBindLinkMaxAge = 8 * time.Minute
|
||||
|
||||
type rockBindLinkCacheItem struct {
|
||||
bindURL string
|
||||
qrURL string
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
// RockService 负责管理 Rock 平台的配置、库存池、采购、UID强一致风控与状态机
|
||||
type RockService struct {
|
||||
db *gorm.DB
|
||||
client *RockClient
|
||||
mu sync.Mutex
|
||||
refreshCache sync.Map // key: upstreamTokenID string, value: rockRefreshCacheItem
|
||||
db *gorm.DB
|
||||
client *RockClient
|
||||
mu sync.Mutex
|
||||
refreshCache sync.Map // key: upstreamTokenID string, value: rockRefreshCacheItem
|
||||
bindLinkCache sync.Map // key: token ID uint, value: rockBindLinkCacheItem
|
||||
}
|
||||
|
||||
func NewRockService(db *gorm.DB, client *RockClient) *RockService {
|
||||
@@ -35,6 +45,60 @@ func NewRockService(db *gorm.DB, client *RockClient) *RockService {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RockService) rememberBindLink(tokenID uint, bindURL, qrURL string) {
|
||||
bindURL = strings.TrimSpace(bindURL)
|
||||
if s == nil || tokenID == 0 || bindURL == "" {
|
||||
return
|
||||
}
|
||||
qrURL = strings.TrimSpace(qrURL)
|
||||
if qrURL == "" {
|
||||
qrURL = qrCodeURL(bindURL)
|
||||
}
|
||||
s.bindLinkCache.Store(tokenID, rockBindLinkCacheItem{
|
||||
bindURL: bindURL,
|
||||
qrURL: qrURL,
|
||||
cachedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// freshBindLink 只为仍在扫码的同一提货号重取活动链接。过期前复用缓存,避免每轮轮询都打 0666。
|
||||
func (s *RockService) freshBindLink(ctx context.Context, tokenID uint, orderNo string) (string, string) {
|
||||
if s == nil || s.client == nil || tokenID == 0 {
|
||||
return "", ""
|
||||
}
|
||||
var token model.RockToken
|
||||
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
|
||||
return "", ""
|
||||
}
|
||||
if token.Status != model.RockStatusUsing || strings.TrimSpace(token.RedeemCode) == "" {
|
||||
return "", ""
|
||||
}
|
||||
if orderNo = strings.TrimSpace(orderNo); orderNo != "" && token.AssignedOrderNo != "" && token.AssignedOrderNo != orderNo {
|
||||
return "", ""
|
||||
}
|
||||
if item, ok := s.bindLinkCache.Load(token.ID); ok {
|
||||
cached := item.(rockBindLinkCacheItem)
|
||||
if cached.bindURL != "" && time.Since(cached.cachedAt) < rockBindLinkMaxAge {
|
||||
return cached.bindURL, cached.qrURL
|
||||
}
|
||||
}
|
||||
detail, err := s.client.GetRedeemDetail(ctx, token.RedeemCode)
|
||||
if err != nil || detail == nil || strings.TrimSpace(detail.BindURL) == "" {
|
||||
if item, ok := s.bindLinkCache.Load(token.ID); ok {
|
||||
cached := item.(rockBindLinkCacheItem)
|
||||
return cached.bindURL, cached.qrURL
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
bindURL := strings.TrimSpace(detail.BindURL)
|
||||
qrURL := strings.TrimSpace(detail.QRURL)
|
||||
s.rememberBindLink(token.ID, bindURL, qrURL)
|
||||
if qrURL == "" {
|
||||
qrURL = qrCodeURL(bindURL)
|
||||
}
|
||||
return bindURL, qrURL
|
||||
}
|
||||
|
||||
// RockStatsResponse 看板与库存统计返回
|
||||
type RockStatsResponse struct {
|
||||
AvailableStock int64 `json:"available_stock"`
|
||||
@@ -435,6 +499,7 @@ func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expected
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("获取上游活动绑定链接失败: %w", err)
|
||||
}
|
||||
s.rememberBindLink(token.ID, detail.BindURL, detail.QRURL)
|
||||
|
||||
// 4. 首次分配记录上游初始绑定快照(防范 Ghost-Binding 历史残留数据)
|
||||
if token.InitialBoundSnapshot == "" && cfg.BearerToken != "" {
|
||||
|
||||
@@ -219,6 +219,12 @@ export default function Delivery() {
|
||||
if (!silent) setCheckingBind(true)
|
||||
try {
|
||||
const res = await deliveryApi.getBindResult(decodedOrderNo, bindResult.bind_uuid, deliveryAuth)
|
||||
if (res.bind_url) {
|
||||
setBindResult((prev) => {
|
||||
if (!prev || prev.bind_url === res.bind_url) return prev
|
||||
return { ...prev, bind_url: res.bind_url, qr_url: res.qr_url || prev.qr_url }
|
||||
})
|
||||
}
|
||||
if (res.bound) {
|
||||
setBoundInfo({
|
||||
bound: true,
|
||||
|
||||
Reference in New Issue
Block a user