fix(rock): 按链接签名时间提前刷新二维码,扫码查询改为 5 秒
腾讯活动链接大约 10 分钟后签名过期。从链接 timestamp 起满 8 分钟就换新码,避免买家扫到失效二维码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user