fix(rock): 按链接签名时间提前刷新二维码,扫码查询改为 5 秒

腾讯活动链接大约 10 分钟后签名过期。从链接 timestamp 起满 8 分钟就换新码,避免买家扫到失效二维码。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-10-07 09:49:26 +08:00
co-authored by Cursor
parent 8cc5d42d53
commit fcbd7715c7
3 changed files with 78 additions and 15 deletions
+24 -6
View File
@@ -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()
+53 -8
View File
@@ -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
}
}
+1 -1
View File
@@ -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])