feat(rock): 同一提货号过期后重取二维码

轮询绑定时向 0666 换一条新的活动链接,提货号不变。下游只展示返回的链接。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-10-06 18:08:27 +08:00
co-authored by Cursor
parent 4b8fc76f36
commit c85b9f4173
4 changed files with 197 additions and 6 deletions
+9 -2
View File
@@ -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)
}
}
+69 -4
View File
@@ -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 != "" {
+6
View File
@@ -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,