diff --git a/backend/internal/service/provider_rock_test.go b/backend/internal/service/provider_rock_test.go index 13b9b1e..e7175b4 100644 --- a/backend/internal/service/provider_rock_test.go +++ b/backend/internal/service/provider_rock_test.go @@ -867,12 +867,28 @@ func TestRockService_CorrectMismatchUIDKeepsAssignment(t *testing.T) { } } +func TestRockBindLinkStillFreshUsesURLTimestamp(t *testing.T) { + now := time.Now() + stale := fmt.Sprintf("https://gp.qq.com/cp/a20241128mobile/index.html?timestamp=%d", now.Add(-rockBindLinkMaxAge-time.Second).Unix()) + fresh := fmt.Sprintf("https://gp.qq.com/cp/a20241128mobile/index.html?timestamp=%d", now.Add(-time.Minute).Unix()) + if rockBindLinkStillFresh(rockBindLinkCacheItem{bindURL: stale, cachedAt: now}, now) { + t.Fatal("签名已超过刷新点时,不能因为刚写入缓存就继续展示") + } + if !rockBindLinkStillFresh(rockBindLinkCacheItem{bindURL: fresh, cachedAt: now.Add(-time.Hour)}, now) { + t.Fatal("签名仍在刷新点内就应该继续展示") + } +} + 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" + staleTS := time.Now().Add(-rockBindLinkMaxAge - time.Second).Unix() + freshTS := time.Now().Unix() + oldURL := fmt.Sprintf("https://gp.qq.com/cp/a20241128mobile/index.html?timestamp=%d&nonce=old&sign=old", staleTS) + newURL := fmt.Sprintf("https://gp.qq.com/cp/a20241128mobile/index.html?timestamp=%d&nonce=new&sign=new", freshTS) + currentURL := oldURL 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/") { @@ -915,13 +931,15 @@ func TestRockProvider_CheckBindRefreshesSameTokenLink(t *testing.T) { 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), + bindURL: oldURL, + qrURL: "https://example.test/old.png", + cachedAt: time.Now(), + fetchedAt: time.Now().Add(-rockBindLinkRetryGap - time.Second), }) mu.Lock() - currentURL = "https://gp.qq.com/cp/a20241128mobile/index.html?timestamp=2&nonce=new&sign=new" + currentURL = newURL mu.Unlock() check, err := provider.CheckBind(context.Background(), order, fmt.Sprintf("%d", token.ID)) @@ -956,7 +974,7 @@ func TestRockProvider_CheckBindRefreshesSameTokenLink(t *testing.T) { 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" { + if checkCached.BindURL != newURL { t.Fatalf("有效期内应复用刚取到的链接,实际: %s", checkCached.BindURL) } mu.Lock() diff --git a/backend/internal/service/rock_bindlink.go b/backend/internal/service/rock_bindlink.go index fe38478..ba09e67 100644 --- a/backend/internal/service/rock_bindlink.go +++ b/backend/internal/service/rock_bindlink.go @@ -2,6 +2,8 @@ package service import ( "context" + "net/url" + "strconv" "strings" "time" @@ -13,13 +15,51 @@ type rockRefreshCacheItem struct { cachedAt time.Time } -// 腾讯活动链接按生成时间签名。同一提货号过期后重取链接,不另发新号。 +// 腾讯「查询绑定」按链接里的 timestamp 验签,大约 10 分钟后返回「签名已过期」。 +// 从签名时间起满 8 分钟就换一条,留出余量,买家不会扫到已经失效的码。 const rockBindLinkMaxAge = 8 * time.Minute +// 上游若仍返回同一条旧签名,不要每个轮询都再打一次。 +const rockBindLinkRetryGap = 15 * time.Second + type rockBindLinkCacheItem struct { - bindURL string - qrURL string - cachedAt time.Time + bindURL string + qrURL string + cachedAt time.Time // 链接上没有 timestamp 时,用写入时间代替签名时间 + fetchedAt time.Time +} + +func rockBindLinkIssuedAt(bindURL string) (time.Time, bool) { + parsed, err := url.Parse(strings.TrimSpace(bindURL)) + if err != nil { + return time.Time{}, false + } + raw := strings.TrimSpace(parsed.Query().Get("timestamp")) + if raw == "" { + return time.Time{}, false + } + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil || value <= 0 { + return time.Time{}, false + } + if value >= 1_000_000_000_000 { + return time.UnixMilli(value), true + } + return time.Unix(value, 0), true +} + +func rockBindLinkStillFresh(item rockBindLinkCacheItem, now time.Time) bool { + if strings.TrimSpace(item.bindURL) == "" { + return false + } + issued, ok := rockBindLinkIssuedAt(item.bindURL) + if !ok { + issued = item.cachedAt + } + if issued.IsZero() { + return false + } + return now.Sub(issued) < rockBindLinkMaxAge } func (s *RockService) rememberBindLink(tokenID uint, bindURL, qrURL string) { @@ -31,10 +71,12 @@ func (s *RockService) rememberBindLink(tokenID uint, bindURL, qrURL string) { if qrURL == "" { qrURL = qrCodeURL(bindURL) } + now := time.Now() s.bindLinkCache.Store(tokenID, rockBindLinkCacheItem{ - bindURL: bindURL, - qrURL: qrURL, - cachedAt: time.Now(), + bindURL: bindURL, + qrURL: qrURL, + cachedAt: now, + fetchedAt: now, }) } @@ -55,7 +97,10 @@ func (s *RockService) freshBindLink(ctx context.Context, tokenID uint, orderNo s } if item, ok := s.bindLinkCache.Load(token.ID); ok { cached := item.(rockBindLinkCacheItem) - if cached.bindURL != "" && time.Since(cached.cachedAt) < rockBindLinkMaxAge { + if rockBindLinkStillFresh(cached, time.Now()) { + return cached.bindURL, cached.qrURL + } + if !cached.fetchedAt.IsZero() && time.Since(cached.fetchedAt) < rockBindLinkRetryGap { return cached.bindURL, cached.qrURL } } diff --git a/frontend/src/pages/Delivery.tsx b/frontend/src/pages/Delivery.tsx index 6fb2925..035027c 100644 --- a/frontend/src/pages/Delivery.tsx +++ b/frontend/src/pages/Delivery.tsx @@ -266,7 +266,7 @@ export default function Delivery() { } const timer = setInterval(() => { checkBindStatus(true) - }, 2500) + }, 5000) return () => clearInterval(timer) }, [bindResult?.bind_uuid, boundInfo?.bound, submitting, submitResult, checkBindStatus])