From c85b9f417341183ed329264e6626f0396b32aa96 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 6 Oct 2026 18:08:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(rock):=20=E5=90=8C=E4=B8=80=E6=8F=90?= =?UTF-8?q?=E8=B4=A7=E5=8F=B7=E8=BF=87=E6=9C=9F=E5=90=8E=E9=87=8D=E5=8F=96?= =?UTF-8?q?=E4=BA=8C=E7=BB=B4=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 轮询绑定时向 0666 换一条新的活动链接,提货号不变。下游只展示返回的链接。 Co-authored-by: Cursor --- backend/internal/service/provider_rock.go | 11 +- .../internal/service/provider_rock_test.go | 113 ++++++++++++++++++ backend/internal/service/rock_service.go | 73 ++++++++++- frontend/src/pages/Delivery.tsx | 6 + 4 files changed, 197 insertions(+), 6 deletions(-) diff --git a/backend/internal/service/provider_rock.go b/backend/internal/service/provider_rock.go index 97cd3a2..62ec289 100644 --- a/backend/internal/service/provider_rock.go +++ b/backend/internal/service/provider_rock.go @@ -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) { diff --git a/backend/internal/service/provider_rock_test.go b/backend/internal/service/provider_rock_test.go index 7e035dc..de2b6c6 100644 --- a/backend/internal/service/provider_rock_test.go +++ b/backend/internal/service/provider_rock_test.go @@ -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) + } +} diff --git a/backend/internal/service/rock_service.go b/backend/internal/service/rock_service.go index eb07c41..ba3cfba 100644 --- a/backend/internal/service/rock_service.go +++ b/backend/internal/service/rock_service.go @@ -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 != "" { diff --git a/frontend/src/pages/Delivery.tsx b/frontend/src/pages/Delivery.tsx index 5b74f80..6fb2925 100644 --- a/frontend/src/pages/Delivery.tsx +++ b/frontend/src/pages/Delivery.tsx @@ -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,