抖音签名

This commit is contained in:
win 2026-05-09 01:26:15 +08:00
parent e2364f3831
commit 3390d0e24a
20 changed files with 1296 additions and 127 deletions

BIN
abogus_poc Executable file

Binary file not shown.

145
cmd/abogus_poc/main.go Normal file
View File

@ -0,0 +1,145 @@
// abogus_poc 验证 goja 嵌入 a_bogus 算法能否绕过抖店风控
//
// 流程:
// 1. 构造 抖店 searchlist 的 URL params不含 a_bogus
// 2. 用 goja 跑 a_bogus.js 算签名
// 3. 把 a_bogus 拼上去发请求,看是否返回 st=0 的真实订单数据
//
// 用法:
//
// go run cmd/abogus_poc/main.go -buyer 47074703875 -cookie '<完整 cookie>'
package main
import (
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"bindbox-game/internal/service/douyin/abogus"
)
const (
defaultUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"
defaultReferer = "https://fxg.jinritemai.com/ffa/morder/order/list"
)
func main() {
buyer := flag.String("buyer", "", "抖音 Buyer ID必填")
cookie := flag.String("cookie", "", "抖店登录 cookie 字符串,必填")
pageSize := flag.Int("page-size", 10, "pageSize")
flag.Parse()
if *buyer == "" || *cookie == "" {
fmt.Println("用法: -buyer <id> -cookie <cookie>")
os.Exit(1)
}
gen, err := abogus.NewGenerator()
if err != nil {
exit("初始化 a_bogus 生成器失败: %v", err)
}
csrf := parseCookie(*cookie, "csrf_session_id")
msToken := parseCookie(*cookie, "msToken")
if csrf == "" {
fmt.Println("[WARN] cookie 中找不到 csrf_session_id__token 用兜底值")
}
if msToken == "" {
msToken = "qo0QYnkK7z_SrM7MPt2AA5xdWwKSGInO7AEeALRJ_BshJqip3nSLTnGa-gFL-aSNP6m1qNnf71-kf6hUf8xbwwLhbsaa_q3BamgxUXPxm4oXIyWPwBOXeXldqOkRV3naDtcad6PJb7rbxhbOaESKQ1YHY1y__z9Wt8GduCOxF-3ks9xHqstnKccV"
fmt.Println("[WARN] cookie 中找不到 msToken使用历史值很可能已过期建议从浏览器刷新一份")
}
verifyFp := parseCookie(*cookie, "s_v_web_id")
if verifyFp == "" {
verifyFp = "verify_mmwdotm1_QYpHiLoc_99vO_49un_9xFU_0ZKfqsmF8gzh"
}
params := url.Values{}
params.Set("page", "0")
params.Set("pageSize", fmt.Sprintf("%d", *pageSize))
params.Set("compact_time[select]", "create_time_start,create_time_end")
params.Set("buyer", *buyer)
params.Set("order_by", "create_time")
params.Set("order", "desc")
params.Set("tab", "all")
params.Set("appid", "1")
if csrf != "" {
params.Set("__token", csrf)
}
params.Set("_bid", "ffa_order")
params.Set("aid", "4272")
params.Set("verifyFp", verifyFp)
params.Set("fp", verifyFp)
params.Set("msToken", msToken)
queryStr := params.Encode()
aBogus, err := gen.Sign(queryStr, defaultUA)
if err != nil {
exit("计算 a_bogus 失败: %v", err)
}
fmt.Printf("生成 a_bogus = %s (长度 %d)\n", aBogus, len(aBogus))
finalURL := "https://fxg.jinritemai.com/api/order/searchlist?" + queryStr + "&a_bogus=" + url.QueryEscape(aBogus)
body, status, err := doGet(finalURL, *cookie)
if err != nil {
exit("请求失败: %v", err)
}
fmt.Printf("HTTP %d\n响应前 1500 字节:\n%s\n", status, truncate(body, 1500))
}
func parseCookie(cookie, key string) string {
for _, part := range strings.Split(cookie, ";") {
kv := strings.TrimSpace(part)
if strings.HasPrefix(kv, key+"=") {
return strings.TrimPrefix(kv, key+"=")
}
}
return ""
}
func doGet(u, cookie string) (string, int, error) {
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return "", 0, err
}
req.Header.Set("User-Agent", defaultUA)
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
req.Header.Set("Cookie", cookie)
req.Header.Set("Referer", defaultReferer)
req.Header.Set("priority", "u=1, i")
req.Header.Set("sec-ch-ua", `"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform", `"macOS"`)
req.Header.Set("sec-fetch-dest", "empty")
req.Header.Set("sec-fetch-mode", "cors")
req.Header.Set("sec-fetch-site", "same-origin")
req.Close = true
cli := &http.Client{Timeout: 60 * time.Second}
resp, err := cli.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return string(b), resp.StatusCode, err
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func exit(format string, a ...any) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(1)
}

View File

@ -2,9 +2,12 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"time"
@ -19,6 +22,7 @@ import (
// staticSyscfg implements sysconfig.Service with fixed cookie
type staticSyscfg struct {
cookie string
proxy string
}
func (s *staticSyscfg) GetByKey(ctx context.Context, key string) (*model.SystemConfigs, error) {
@ -30,6 +34,11 @@ func (s *staticSyscfg) GetByKey(ctx context.Context, key string) (*model.SystemC
return &model.SystemConfigs{ConfigKey: key, ConfigValue: s.cookie}, nil
case douyin.ConfigKeyDouyinInterval:
return &model.SystemConfigs{ConfigKey: key, ConfigValue: "5"}, nil
case douyin.ConfigKeyDouyinProxy:
if s.proxy == "" {
return nil, errors.New("douyin proxy 未设置")
}
return &model.SystemConfigs{ConfigKey: key, ConfigValue: s.proxy}, nil
default:
return nil, errors.New("暂不支持的配置 key: " + key)
}
@ -52,19 +61,23 @@ func main() {
minutes := flag.Int("minutes", 10, "同步最近多少分钟的订单")
useProxy := flag.Bool("proxy", false, "是否使用服务内置代理")
printLimit := flag.Int("print", 10, "同步后打印多少条订单 (0 表示不打印)")
mode := flag.String("mode", "sync-all", "同步模式: sync-all(默认增量)/fetch(按绑定用户)")
mode := flag.String("mode", "sync-all", "同步模式: sync-all(默认增量)/fetch(按绑定用户)/user(指定抖音 buyer)")
grantMinesweeper := flag.Bool("grant-minesweeper", false, "同步后执行 GrantMinesweeperQualifications")
fetchOnlyUnmatched := flag.Bool("fetch-only-unmatched", true, "按用户同步时是否仅同步未匹配订单的用户")
fetchMaxUsers := flag.Int("fetch-max-users", 200, "按用户同步时最多处理的用户数量 (50-1000)")
fetchBatchSize := flag.Int("fetch-batch-size", 20, "按用户同步时的单批次用户数量 (5-50)")
fetchConcurrency := flag.Int("fetch-concurrency", 5, "按用户同步时的并发抓取数 (<=批次大小)")
fetchDelay := flag.Int("fetch-delay-ms", 200, "批次之间的停顿时间 (毫秒)")
buyer := flag.String("buyer", "", "指定抖音 ID (douyin_user_id / Buyer ID),仅同步此用户。等价于 -mode user")
proxyURL := flag.String("proxy-url", "", "覆盖代理地址 (例: http://user:pass@host:port)")
replayURL := flag.String("replay-url", "", "[救急] 把浏览器抓到的完整 searchlist URL 贴进来,绕过风控直接同步")
replayCookie := flag.String("replay-cookie", "", "[救急] 与 -replay-url 配套的 cookie 串")
flag.Parse()
env.Active() // 初始化 env flag依赖已有的全局 -env/ACTIVE_ENV 配置)
configs.Init()
cookie := "passport_csrf_token=59cdf9f8b9154bb170fbe3718b5c2c41; passport_csrf_token_default=59cdf9f8b9154bb170fbe3718b5c2c41; s_v_web_id=verify_mnkmeu91_r7NhDaDR_4MVT_4Icm_85n7_EDp2hQZTZj6o; is_staff_user=false; has_biz_token=false; SHOP_ID=156231010; PIGEON_CID=4339134776748827; ecom_gray_shop_id=156231010; gfkadpd=4272,23756; Hm_lvt_b6520b076191ab4b36812da4c90f7a5e=1774632716,1774968034,1775994293,1776710317; HMACCOUNT=0D91B8CECCE6C828; csrf_session_id=38f99d2b9a62d9770438596859e8afaa; Hm_lpvt_b6520b076191ab4b36812da4c90f7a5e=1776710323; ttwid=1%7Cs-eQn8Q_A0kZTCaP0uZ6tFZ-5nSc-YV48RZrmP6MSxo%7C1776710327%7Cd8f5c7d7c70eb1f6fee2619fded05f2a35312fa0a533927e4a5216c461411d6e; odin_tt=b70087c0142baa34c0f3dd2b30100dcc07a14b5438b47c01bbc37141a76feb092ba8cefe346da028ab9a0cdb517f47882eb347a04398d521e43ac7acefe87ead; passport_auth_status=0e16808156b0d324952de02ab8d0e366%2Cdfeee43cd8f8414913f2a1194f2ed9fc; passport_auth_status_ss=0e16808156b0d324952de02ab8d0e366%2Cdfeee43cd8f8414913f2a1194f2ed9fc; uid_tt=9a2ebe82f2439116d8488a6451fa6ada; uid_tt_ss=9a2ebe82f2439116d8488a6451fa6ada; sid_tt=8fe15f1994f4714600ac16c9c5873e06; sessionid=8fe15f1994f4714600ac16c9c5873e06; sessionid_ss=8fe15f1994f4714600ac16c9c5873e06; PHPSESSID=923a5ec84fb4e5286d758f8565ef89b9; PHPSESSID_SS=923a5ec84fb4e5286d758f8565ef89b9; ecom_us_lt=84055fac4b5c0ec4ab4e7dfb4f8363dc8cf0d3723132461ef45b84d3d10e3ed7; ecom_us_lt_ss=84055fac4b5c0ec4ab4e7dfb4f8363dc8cf0d3723132461ef45b84d3d10e3ed7; ucas_c0=CkEKBTEuMC4wEKKIjYz405zzaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0DA5ZnPBkjAmdbRBlC_vL6Ekt3t1GdYbhIULJNshLh6B-RTna84mldnRD04dCI; ucas_c0_ss=CkEKBTEuMC4wEKKIjYz405zzaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0DA5ZnPBkjAmdbRBlC_vL6Ekt3t1GdYbhIULJNshLh6B-RTna84mldnRD04dCI; zsgw_business_data=%7B%22uuid%22%3A%2295540517-0144-4b48-8d52-a060aa220f27%22%2C%22platform%22%3A%22pc%22%2C%22source%22%3A%22seo.fxg.jinritemai.com%22%7D; source=seo.fxg.jinritemai.com; sid_guard=8fe15f1994f4714600ac16c9c5873e06%7C1776710345%7C5184000%7CFri%2C+19-Jun-2026+18%3A39%3A05+GMT; session_tlb_tag=sttt%7C3%7Cj-FfGZT0cUYArBbJxYc-Bv_________F-R3m7za-NQWGijt8uvp4cecmrChrDjkt3_5ZQbKHXlM%3D; sid_ucp_v1=1.0.0-KGIzMTgwMzk5OWU2YWYxYmZjN2FmZWY1ZThkODNkNDhjZTBhMzZhNjgKGQib1oDYuM3aBxDJ5ZnPBhiwISAMOAZA9AcaAmxmIiA4ZmUxNWYxOTk0ZjQ3MTQ2MDBhYzE2YzljNTg3M2UwNg; ssid_ucp_v1=1.0.0-KGIzMTgwMzk5OWU2YWYxYmZjN2FmZWY1ZThkODNkNDhjZTBhMzZhNjgKGQib1oDYuM3aBxDJ5ZnPBhiwISAMOAZA9AcaAmxmIiA4ZmUxNWYxOTk0ZjQ3MTQ2MDBhYzE2YzljNTg3M2UwNg; BUYIN_SASID=SID2_7630910859501781288"
cookie := "passport_csrf_token=133a0751277aa016a5851e4cfc27c30c; passport_csrf_token_default=133a0751277aa016a5851e4cfc27c30c; s_v_web_id=verify_mmwdotm1_QYpHiLoc_99vO_49un_9xFU_0ZKfqsmF8gzh; is_staff_user=false; ttwid=1%7Caa-Nm2neyE97yjVd8lXbX7cMYg2IRxLWDrrcDT-XwQI%7C1778257690%7C8366dc43f20a0c89c5c9b77aaa7a5f554d1e2eed250b654e65ae9828aba5b8be; odin_tt=c4ebef3065193a34066032a3d3e72ecd8584a523548d361b090a303d3af9d410dde8802c1319457684ebca43f05632bf3a190329be76dc16918a45fc0453e4d0; uid_tt=8d9cfddb6881e9a2e5f2985ba1509aef; sid_tt=f5f318456b8263a30e1d4a6a5926ef41; sessionid=f5f318456b8263a30e1d4a6a5926ef41; sessionid_ss=f5f318456b8263a30e1d4a6a5926ef41; PHPSESSID=cf2e0d098639e2c3eed2d3d9eb1e0293; csrf_session_id=436ad6d84082eaf485438e339abd88df; ecom_gray_shop_id=156231010; sid_guard=f5f318456b8263a30e1d4a6a5926ef41%7C1778257842%7C5184000%7CTue%2C+07-Jul-2026+16%3A30%3A42+GMT"
if cookie == "" {
fmt.Println("请通过环境变量 DOUYIN_COOKIE 提供抖店 Cookie")
os.Exit(1)
@ -82,12 +95,63 @@ func main() {
defer repo.DbRClose()
defer repo.DbWClose()
svc := douyin.New(log, repo, &staticSyscfg{cookie: cookie}, nil, nil, nil)
if *proxyURL != "" {
fmt.Printf("使用代理: %s\n", *proxyURL)
}
svc := douyin.New(log, repo, &staticSyscfg{cookie: cookie, proxy: *proxyURL}, nil, nil, nil)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
switch *mode {
effectiveMode := *mode
if *replayURL != "" {
effectiveMode = "replay"
} else if *buyer != "" {
effectiveMode = "user"
}
switch effectiveMode {
case "replay":
if *replayURL == "" || *replayCookie == "" {
fmt.Println("replay 模式需要同时提供 -replay-url 和 -replay-cookie")
os.Exit(1)
}
if *buyer == "" {
fmt.Println("replay 模式需要 -buyer 用于关联本地用户 ID")
os.Exit(1)
}
var u model.Users
if err := repo.GetDbR().Where("douyin_user_id = ?", *buyer).First(&u).Error; err != nil {
fmt.Printf("未找到绑定该抖音 ID 的用户 (douyin_user_id=%s): %v\n", *buyer, err)
os.Exit(1)
}
fmt.Printf("Replaylocal_user_id=%d, nickname=%s, douyin_user_id=%s\n", u.ID, u.Nickname, u.DouyinUserID)
newOrders, matched, total, err := replayFetchAndSync(ctx, svc, *replayURL, *replayCookie, u.ID)
if err != nil {
fmt.Printf("replay 失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("完成:抓取 %d新订单 %d匹配 %d。\n", total, newOrders, matched)
case "user":
if *buyer == "" {
fmt.Println("使用 -mode user 时必须通过 -buyer 指定抖音 ID")
os.Exit(1)
}
var u model.Users
if err := repo.GetDbR().Where("douyin_user_id = ?", *buyer).First(&u).Error; err != nil {
fmt.Printf("未找到绑定该抖音 ID 的用户 (douyin_user_id=%s): %v\n", *buyer, err)
os.Exit(1)
}
fmt.Printf("开始 SyncUserOrderslocal_user_id=%d, nickname=%s, douyin_user_id=%s\n",
u.ID, u.Nickname, u.DouyinUserID)
result, err := svc.SyncUserOrders(ctx, u.ID)
if err != nil {
fmt.Printf("SyncUserOrders 失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("完成:抓取 %d新订单 %d匹配 %d用时 %.2fs。\nDebug: %s\n",
result.TotalFetched, result.NewOrders, result.MatchedUsers,
float64(result.ElapsedMS)/1000.0, result.DebugInfo)
case "fetch":
fmt.Println("开始 FetchAndSyncOrders按绑定用户同步...")
result, err := svc.FetchAndSyncOrders(ctx, &douyin.FetchOptions{
@ -139,3 +203,68 @@ func main() {
}
// go run cmd/douyin_sync_debug/main.go -env dev -mode fetch -fetch-only-unmatched=false -fetch-max-users=200 -fetch-batch-size=1 -fetch-concurrency=1 -fetch-delay-ms=0
// replayFetchAndSync 把浏览器抓到的完整 URL+cookie 直接打过去,绕开风控,然后把订单写入 DB
func replayFetchAndSync(ctx context.Context, svc douyin.Service, fullURL, cookie string, localUserID int64) (newOrders, matched, total int, err error) {
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
if err != nil {
return 0, 0, 0, fmt.Errorf("构造请求失败: %w", err)
}
req.Header.Set("Cookie", cookie)
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
req.Header.Set("Referer", "https://fxg.jinritemai.com/ffa/morder/order/list")
req.Header.Set("priority", "u=1, i")
req.Header.Set("sec-ch-ua", `"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform", `"macOS"`)
req.Header.Set("sec-fetch-dest", "empty")
req.Header.Set("sec-fetch-mode", "cors")
req.Header.Set("sec-fetch-site", "same-origin")
req.Close = true
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0, 0, 0, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, 0, 0, fmt.Errorf("读取响应失败: %w", err)
}
var wrap struct {
St int `json:"st"`
Code int `json:"code"`
Msg string `json:"msg"`
Data []douyin.DouyinOrderItem `json:"data"`
}
if err := json.Unmarshal(body, &wrap); err != nil {
fmt.Printf("响应原文(前2000字节): %s\n", string(body[:min2k(len(body))]))
return 0, 0, 0, fmt.Errorf("解析响应失败: %w", err)
}
if wrap.St != 0 && wrap.Code != 0 {
return 0, 0, 0, fmt.Errorf("API 错误: %s (st=%d code=%d) body=%s", wrap.Msg, wrap.St, wrap.Code, string(body[:min2k(len(body))]))
}
total = len(wrap.Data)
for i := range wrap.Data {
isNew, isMatched := svc.SyncOrder(ctx, &wrap.Data[i], localUserID, "")
if isNew {
newOrders++
}
if isMatched {
matched++
}
}
return newOrders, matched, total, nil
}
func min2k(n int) int {
if n > 2000 {
return 2000
}
return n
}

View File

@ -3,13 +3,13 @@ local = 'zh-cn'
[mysql.read]
addr = '150.158.78.154:3306'
name = 'dev_game'
name = 'bindbox_game'
pass = 'bindbox2025kdy'
user = 'root'
[mysql.write]
addr = '150.158.78.154:3306'
name = 'dev_game'
name = 'bindbox_game'
pass = 'bindbox2025kdy'
user = 'root'

BIN
douyin_sync_debug Executable file

Binary file not shown.

6
go.mod
View File

@ -7,7 +7,6 @@ toolchain go1.24.2
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/DanPlayer/randomname v1.0.1
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13
github.com/alibabacloud-go/dysmsapi-20170525/v4 v4.1.3
github.com/alibabacloud-go/tea v1.3.14
@ -15,6 +14,7 @@ require (
github.com/bwmarrin/snowflake v0.3.0
github.com/bytedance/sonic v1.13.2
github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203
github.com/fatih/color v1.14.1
github.com/gin-contrib/pprof v1.4.0
github.com/gin-gonic/gin v1.9.1
@ -76,6 +76,8 @@ require (
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
@ -85,9 +87,11 @@ require (
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-querystring v1.0.0 // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect

8
go.sum
View File

@ -145,6 +145,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg=
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@ -202,6 +206,8 @@ github.com/go-playground/validator/v10 v10.15.0 h1:nDU5XeOKtB3GEa+uB7GNYwhVKsgjA
github.com/go-playground/validator/v10 v10.15.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-resty/resty/v2 v2.10.0 h1:Qla4W/+TMmv0fOeeRqzEpXPLfTUnR5HZ1+lGs+CkiCo=
github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
@ -275,6 +281,8 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

View File

@ -4,12 +4,12 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"bindbox-game/internal/pkg/httpclient"
pkgutils "bindbox-game/internal/pkg/utils"
"bindbox-game/internal/pkg/wechat"
"go.uber.org/zap"
)
@ -46,44 +46,6 @@ type LotteryResultNotificationResponse struct {
Errmsg string `json:"errmsg"`
}
// AccessTokenResponse access_token 响应
type AccessTokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
}
// getAccessToken 获取微信 access_token
func getAccessToken(ctx context.Context, appID, appSecret string) (string, error) {
url := "https://api.weixin.qq.com/cgi-bin/token"
client := httpclient.GetHttpClient()
resp, err := client.R().
SetQueryParams(map[string]string{
"grant_type": "client_credential",
"appid": appID,
"secret": appSecret,
}).
Get(url)
if err != nil {
return "", fmt.Errorf("获取access_token失败: %v", err)
}
if resp.StatusCode() != http.StatusOK {
return "", fmt.Errorf("HTTP请求失败状态码: %d", resp.StatusCode())
}
var tokenResp AccessTokenResponse
if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil {
return "", fmt.Errorf("解析access_token响应失败: %v", err)
}
if tokenResp.ErrCode != 0 {
return "", fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("获取到的access_token为空")
}
return tokenResp.AccessToken, nil
}
// SendLotteryResultNotification 发送开奖结果订阅消息
// ctx: context
// cfg: 微信通知配置
@ -102,8 +64,11 @@ func SendLotteryResultNotification(ctx context.Context, cfg *WechatNotifyConfig,
return nil
}
// 获取 access_token
accessToken, err := getAccessToken(ctx, cfg.AppID, cfg.AppSecret)
// 获取 access_token。必须复用统一缓存避免高峰期每条订阅通知都请求微信 token 触发 45009 限流。
accessToken, err := wechat.GetAccessTokenWithContext(ctx, &wechat.WechatConfig{
AppID: cfg.AppID,
AppSecret: cfg.AppSecret,
})
if err != nil {
zap.L().Error("[开奖通知] 获取access_token失败", zap.Error(err), zap.String("openid", openid))
return err

View File

@ -46,7 +46,7 @@ func Code2Session(ctx context.Context, config *WechatConfig, code string) (*Code
return nil, err
}
if r.ErrCode != 0 {
return nil, fmt.Errorf(r.ErrMsg)
return nil, fmt.Errorf("%s", r.ErrMsg)
}
if r.OpenID == "" || r.SessionKey == "" {
return nil, fmt.Errorf("响应缺少必要字段")

View File

@ -1,50 +1,50 @@
package wechat
import (
"encoding/json"
"fmt"
"net/http"
"encoding/json"
"fmt"
"net/http"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/httpclient"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/httpclient"
)
type PhoneNumberResponse struct {
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
PhoneInfo struct {
PhoneNumber string `json:"phoneNumber"`
PurePhoneNumber string `json:"purePhoneNumber"`
CountryCode string `json:"countryCode"`
} `json:"phone_info"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
PhoneInfo struct {
PhoneNumber string `json:"phoneNumber"`
PurePhoneNumber string `json:"purePhoneNumber"`
CountryCode string `json:"countryCode"`
} `json:"phone_info"`
}
// GetPhoneNumber 使用微信开放接口换取用户手机号
// DOC: https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-info/phone-number/getPhoneNumber.html
func GetPhoneNumber(ctx core.Context, accessToken, code string) (*PhoneNumberResponse, error) {
if accessToken == "" || code == "" {
return nil, fmt.Errorf("参数缺失")
}
client := httpclient.GetHttpClientWithContext(ctx.RequestContext())
resp, err := client.R().
SetQueryParam("access_token", accessToken).
SetBody(map[string]string{"code": code}).
Post("https://api.weixin.qq.com/wxa/business/getuserphonenumber")
if err != nil {
return nil, err
}
if resp.StatusCode() != http.StatusOK {
return nil, fmt.Errorf("HTTP错误: %d", resp.StatusCode())
}
var r PhoneNumberResponse
if err := json.Unmarshal(resp.Body(), &r); err != nil {
return nil, err
}
if r.ErrCode != 0 {
return nil, fmt.Errorf(r.ErrMsg)
}
if r.PhoneInfo.PurePhoneNumber == "" && r.PhoneInfo.PhoneNumber == "" {
return nil, fmt.Errorf("未获取到手机号")
}
return &r, nil
}
if accessToken == "" || code == "" {
return nil, fmt.Errorf("参数缺失")
}
client := httpclient.GetHttpClientWithContext(ctx.RequestContext())
resp, err := client.R().
SetQueryParam("access_token", accessToken).
SetBody(map[string]string{"code": code}).
Post("https://api.weixin.qq.com/wxa/business/getuserphonenumber")
if err != nil {
return nil, err
}
if resp.StatusCode() != http.StatusOK {
return nil, fmt.Errorf("HTTP错误: %d", resp.StatusCode())
}
var r PhoneNumberResponse
if err := json.Unmarshal(resp.Body(), &r); err != nil {
return nil, err
}
if r.ErrCode != 0 {
return nil, fmt.Errorf("%s", r.ErrMsg)
}
if r.PhoneInfo.PurePhoneNumber == "" && r.PhoneInfo.PhoneNumber == "" {
return nil, fmt.Errorf("未获取到手机号")
}
return &r, nil
}

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
@ -39,6 +40,19 @@ type TokenCache struct {
// 全局 token 缓存
var globalTokenCache = &TokenCache{}
type accessTokenFailure struct {
Err error
RetryAt time.Time
RecordedAt time.Time
}
type accessTokenFailureCache struct {
mutex sync.Mutex
failures map[string]accessTokenFailure
}
var globalAccessTokenFailureCache = &accessTokenFailureCache{failures: make(map[string]accessTokenFailure)}
// WechatConfig 微信配置
type WechatConfig struct {
AppID string
@ -110,6 +124,10 @@ func GetAccessToken(ctx core.Context, config *WechatConfig) (string, error) {
return globalTokenCache.Token, nil
}
if err := accessTokenBackoffError(config.AppID); err != nil {
return "", err
}
// 3. 调用微信 API 获取新 token (使用 stable_token 接口)
url := "https://api.weixin.qq.com/cgi-bin/stable_token"
requestBody := map[string]any{
@ -124,30 +142,41 @@ func GetAccessToken(ctx core.Context, config *WechatConfig) (string, error) {
SetBody(requestBody).
Post(url)
if err != nil {
return "", fmt.Errorf("获取stable_access_token失败: %v", err)
wrapped := fmt.Errorf("获取stable_access_token失败: %v", err)
rememberAccessTokenFailure(config.AppID, wrapped)
return "", wrapped
}
if resp.StatusCode() != http.StatusOK {
return "", fmt.Errorf("HTTP请求失败状态码: %d", resp.StatusCode())
err := fmt.Errorf("HTTP请求失败状态码: %d", resp.StatusCode())
rememberAccessTokenFailure(config.AppID, err)
return "", err
}
var tokenResp AccessTokenResponse
if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil {
return "", fmt.Errorf("解析access_token响应失败: %v", err)
wrapped := fmt.Errorf("解析access_token响应失败: %v", err)
rememberAccessTokenFailure(config.AppID, wrapped)
return "", wrapped
}
if tokenResp.ErrCode != 0 {
return "", fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
err := fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
rememberAccessTokenFailure(config.AppID, err)
return "", err
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("获取到的access_token为空")
err := fmt.Errorf("获取到的access_token为空")
rememberAccessTokenFailure(config.AppID, err)
return "", err
}
// 4. 更新缓存提前5分钟过期以留出刷新余地
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second)
globalTokenCache.Token = tokenResp.AccessToken
globalTokenCache.ExpiresAt = expiresAt
clearAccessTokenFailure(config.AppID)
return tokenResp.AccessToken, nil
}
@ -172,6 +201,10 @@ func GetAccessTokenWithContext(ctx context.Context, config *WechatConfig) (strin
return token, nil
}
if err := accessTokenBackoffError(config.AppID); err != nil {
return "", err
}
// 2. Redis 中没有,使用分布式锁获取新 token
lockKey := fmt.Sprintf("lock:wechat:access_token:%s", config.AppID)
locked, err := acquireDistributedLock(ctx, lockKey, 10*time.Second)
@ -205,20 +238,30 @@ func GetAccessTokenWithContext(ctx context.Context, config *WechatConfig) (strin
SetBody(requestBody).
Post(url)
if err != nil {
return "", fmt.Errorf("获取stable_access_token失败: %v", err)
wrapped := fmt.Errorf("获取stable_access_token失败: %v", err)
rememberAccessTokenFailure(config.AppID, wrapped)
return "", wrapped
}
if resp.StatusCode() != http.StatusOK {
return "", fmt.Errorf("HTTP请求失败,状态码: %d", resp.StatusCode())
err := fmt.Errorf("HTTP请求失败,状态码: %d", resp.StatusCode())
rememberAccessTokenFailure(config.AppID, err)
return "", err
}
var tokenResp AccessTokenResponse
if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil {
return "", fmt.Errorf("解析access_token响应失败: %v", err)
wrapped := fmt.Errorf("解析access_token响应失败: %v", err)
rememberAccessTokenFailure(config.AppID, wrapped)
return "", wrapped
}
if tokenResp.ErrCode != 0 {
return "", fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
err := fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
rememberAccessTokenFailure(config.AppID, err)
return "", err
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("获取到的access_token为空")
err := fmt.Errorf("获取到的access_token为空")
rememberAccessTokenFailure(config.AppID, err)
return "", err
}
// 5. 存储到 Redis (提前5分钟过期以留出刷新余地)
@ -233,6 +276,7 @@ func GetAccessTokenWithContext(ctx context.Context, config *WechatConfig) (strin
globalTokenCache.Token = tokenResp.AccessToken
globalTokenCache.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
globalTokenCache.mutex.Unlock()
clearAccessTokenFailure(config.AppID)
return tokenResp.AccessToken, nil
}
@ -393,6 +437,53 @@ func releaseDistributedLock(ctx context.Context, lockKey string) {
_ = client.Del(ctx, lockKey).Err()
}
func accessTokenBackoffError(appID string) error {
globalAccessTokenFailureCache.mutex.Lock()
defer globalAccessTokenFailureCache.mutex.Unlock()
failure, ok := globalAccessTokenFailureCache.failures[appID]
if !ok {
return nil
}
if time.Now().After(failure.RetryAt) {
delete(globalAccessTokenFailureCache.failures, appID)
return nil
}
return fmt.Errorf("access_token 获取暂时退避,%s 后重试: %w", time.Until(failure.RetryAt).Round(time.Second), failure.Err)
}
func rememberAccessTokenFailure(appID string, err error) {
if err == nil {
return
}
delay := 30 * time.Second
if isAccessTokenQuotaError(err) {
delay = 10 * time.Minute
}
globalAccessTokenFailureCache.mutex.Lock()
defer globalAccessTokenFailureCache.mutex.Unlock()
globalAccessTokenFailureCache.failures[appID] = accessTokenFailure{
Err: err,
RetryAt: time.Now().Add(delay),
RecordedAt: time.Now(),
}
}
func clearAccessTokenFailure(appID string) {
globalAccessTokenFailureCache.mutex.Lock()
defer globalAccessTokenFailureCache.mutex.Unlock()
delete(globalAccessTokenFailureCache.failures, appID)
}
func isAccessTokenQuotaError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "45009") || strings.Contains(msg, "reach max api daily quota limit")
}
// getTokenFromMemoryOrAPI 降级方案:从内存缓存获取或调用API
func getTokenFromMemoryOrAPI(ctx context.Context, config *WechatConfig) (string, error) {
// 1. 先检查内存缓存
@ -413,6 +504,10 @@ func getTokenFromMemoryOrAPI(ctx context.Context, config *WechatConfig) (string,
return globalTokenCache.Token, nil
}
if err := accessTokenBackoffError(config.AppID); err != nil {
return "", err
}
// 3. 调用微信 API (使用 stable_token 接口)
url := "https://api.weixin.qq.com/cgi-bin/stable_token"
requestBody := map[string]any{
@ -427,20 +522,30 @@ func getTokenFromMemoryOrAPI(ctx context.Context, config *WechatConfig) (string,
SetBody(requestBody).
Post(url)
if err != nil {
return "", fmt.Errorf("获取stable_access_token失败: %v", err)
wrapped := fmt.Errorf("获取stable_access_token失败: %v", err)
rememberAccessTokenFailure(config.AppID, wrapped)
return "", wrapped
}
if resp.StatusCode() != http.StatusOK {
return "", fmt.Errorf("HTTP请求失败,状态码: %d", resp.StatusCode())
err := fmt.Errorf("HTTP请求失败,状态码: %d", resp.StatusCode())
rememberAccessTokenFailure(config.AppID, err)
return "", err
}
var tokenResp AccessTokenResponse
if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil {
return "", fmt.Errorf("解析access_token响应失败: %v", err)
wrapped := fmt.Errorf("解析access_token响应失败: %v", err)
rememberAccessTokenFailure(config.AppID, wrapped)
return "", wrapped
}
if tokenResp.ErrCode != 0 {
return "", fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
err := fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
rememberAccessTokenFailure(config.AppID, err)
return "", err
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("获取到的access_token为空")
err := fmt.Errorf("获取到的access_token为空")
rememberAccessTokenFailure(config.AppID, err)
return "", err
}
// 4. 更新内存缓存
@ -450,6 +555,7 @@ func getTokenFromMemoryOrAPI(ctx context.Context, config *WechatConfig) (string,
}
globalTokenCache.Token = tokenResp.AccessToken
globalTokenCache.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
clearAccessTokenFailure(config.AppID)
return tokenResp.AccessToken, nil
}

View File

@ -0,0 +1,28 @@
package wechat
import (
"fmt"
"strings"
"testing"
)
func TestAccessTokenBackoffForQuotaError(t *testing.T) {
appID := "test-quota-app"
clearAccessTokenFailure(appID)
t.Cleanup(func() { clearAccessTokenFailure(appID) })
rememberAccessTokenFailure(appID, fmt.Errorf("获取access_token失败: errcode=45009, errmsg=reach max api daily quota limit"))
err := accessTokenBackoffError(appID)
if err == nil {
t.Fatalf("expected backoff error")
}
if !strings.Contains(err.Error(), "45009") {
t.Fatalf("expected original quota error to be preserved, got %v", err)
}
clearAccessTokenFailure(appID)
if err := accessTokenBackoffError(appID); err != nil {
t.Fatalf("expected no backoff after clear, got %v", err)
}
}

View File

@ -18,6 +18,8 @@ import (
"go.uber.org/zap"
)
const virtualShippingRetryLockTTL = 5 * time.Minute
// ProcessOrderLottery 处理订单开奖(统原子化高性能幂等逻辑)
func (s *service) ProcessOrderLottery(ctx context.Context, orderID int64) error {
s.logger.Debug("开始原子化处理订单开奖", zap.Int64("order_id", orderID))
@ -256,8 +258,17 @@ func (s *service) ProcessOrderLottery(ctx context.Context, orderID int64) error
// TriggerVirtualShipping 触发虚拟发货同步到微信
func (s *service) TriggerVirtualShipping(ctx context.Context, orderID int64, orderNo string, userID int64, aid int64, actName string, playType string) {
drawLogs, _ := s.readDB.ActivityDrawLogs.WithContext(ctx).Where(s.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).Find()
if !s.acquireVirtualShippingRetrySlot(ctx, orderID, orderNo) {
return
}
drawLogs, err := s.readDB.ActivityDrawLogs.WithContext(ctx).Where(s.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).Find()
if err != nil {
s.logger.Error("[虚拟发货] 查询开奖记录失败", zap.Int64("order_id", orderID), zap.String("order_no", orderNo), zap.Error(err))
return
}
if len(drawLogs) == 0 {
s.logger.Warn("[虚拟发货] 跳过: 未找到开奖记录", zap.Int64("order_id", orderID), zap.String("order_no", orderNo))
return
}
// 批量获取 reward 信息
@ -300,8 +311,9 @@ func (s *service) TriggerVirtualShipping(ctx context.Context, orderID int64, ord
}
itemsDesc := actName + " " + orderNo + " 盲盒赏品: " + strings.Join(rewardNames, ", ")
itemsDesc = utf8SafeTruncate(itemsDesc, 110) // 微信限制 128 字节,我们保守一点截断到 110
tx, _ := s.readDB.PaymentTransactions.WithContext(ctx).Where(s.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First()
tx, err := s.readDB.PaymentTransactions.WithContext(ctx).Where(s.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First()
if tx == nil || tx.TransactionID == "" {
s.logger.Warn("[虚拟发货] 跳过: 未找到支付交易", zap.Int64("order_id", orderID), zap.String("order_no", orderNo), zap.Error(err))
return
}
// 优先使用支付时的 openid (避免多小程序/多渠道导致的 openid 不一致)
@ -312,6 +324,9 @@ func (s *service) TriggerVirtualShipping(ctx context.Context, orderID int64, ord
payerOpenid = u.Openid
}
}
if payerOpenid == "" {
s.logger.Warn("[虚拟发货] 支付 openid 为空,继续尝试发货", zap.Int64("order_id", orderID), zap.String("order_no", orderNo), zap.String("transaction_id", tx.TransactionID))
}
var cfg *wechat.WechatConfig
if dc := sysconfig.GetDynamicConfig(); dc != nil {
wc := dc.GetWechat(ctx)
@ -320,10 +335,32 @@ func (s *service) TriggerVirtualShipping(ctx context.Context, orderID int64, ord
c := configs.Get()
cfg = &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}
}
errUpload := wechat.UploadVirtualShippingForBackground(ctx, cfg, tx.TransactionID, orderNo, payerOpenid, itemsDesc)
var errUpload error
retryDelays := []time.Duration{0, 5 * time.Second, 30 * time.Second}
for attempt, delay := range retryDelays {
if delay > 0 {
time.Sleep(delay)
}
errUpload = wechat.UploadVirtualShippingForBackground(ctx, cfg, tx.TransactionID, orderNo, payerOpenid, itemsDesc)
if errUpload == nil || isVirtualShippingAlreadyDone(errUpload) || !isRetriableVirtualShippingError(errUpload) {
break
}
if isWechatAccessTokenQuotaError(errUpload) {
break
}
if attempt == len(retryDelays)-1 {
break
}
s.logger.Warn("[虚拟发货] 上传失败,将重试",
zap.Int64("order_id", orderID),
zap.String("order_no", orderNo),
zap.Int("attempt", attempt+1),
zap.Duration("next_delay", retryDelays[attempt+1]),
zap.Error(errUpload))
}
// 如果发货成功,或者微信提示已经发过货了(10060003),则标记本地订单为已履约
if errUpload == nil || strings.Contains(errUpload.Error(), "10060003") {
if errUpload == nil || isVirtualShippingAlreadyDone(errUpload) {
_, _ = s.writeDB.Orders.WithContext(ctx).Where(s.readDB.Orders.ID.Eq(orderID)).Update(s.readDB.Orders.IsConsumed, 1)
if errUpload != nil {
s.logger.Info("[虚拟发货] 微信反馈已处理,更新本地标记", zap.String("order_no", orderNo))
@ -354,6 +391,63 @@ func (s *service) TriggerVirtualShipping(ctx context.Context, orderID int64, ord
}
}
func (s *service) acquireVirtualShippingRetrySlot(ctx context.Context, orderID int64, orderNo string) bool {
if s.redis == nil {
return true
}
lockKey := fmt.Sprintf("lock:virtual_shipping:order:%d", orderID)
locked, err := s.redis.SetNX(ctx, lockKey, "1", virtualShippingRetryLockTTL).Result()
if err != nil {
s.logger.Warn("[虚拟发货] 防抖锁异常,继续尝试", zap.Int64("order_id", orderID), zap.String("order_no", orderNo), zap.Error(err))
return true
}
if !locked {
s.logger.Debug("[虚拟发货] 跳过: 近期已尝试", zap.Int64("order_id", orderID), zap.String("order_no", orderNo))
return false
}
return true
}
func isVirtualShippingAlreadyDone(err error) bool {
return err != nil && strings.Contains(err.Error(), "10060003")
}
func isWechatAccessTokenQuotaError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "45009") || strings.Contains(msg, "reach max api daily quota limit")
}
func isRetriableVirtualShippingError(err error) bool {
if err == nil {
return false
}
if isWechatAccessTokenQuotaError(err) {
return false
}
msg := strings.ToLower(err.Error())
retriableMarkers := []string{
"10060001",
"支付单不存在",
"timeout",
"deadline exceeded",
"connection reset",
"connection refused",
"eof",
"temporary",
"获取stable_access_token失败",
"http请求失败",
}
for _, marker := range retriableMarkers {
if strings.Contains(msg, strings.ToLower(marker)) {
return true
}
}
return false
}
func (s *service) applyItemCardEffect(ctx context.Context, icID int64, aid int64, iss int64, log *model.ActivityDrawLogs) {
uic, _ := s.readDB.UserItemCards.WithContext(ctx).Where(s.readDB.UserItemCards.ID.Eq(icID), s.readDB.UserItemCards.UserID.Eq(log.UserID), s.readDB.UserItemCards.Status.Eq(1)).First()
if uic == nil {

View File

@ -38,6 +38,7 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo, rdb *redis
go func() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
lastVirtualShippingRetry := time.Time{}
for range t.C {
ctx := context.Background()
now := time.Now()
@ -298,10 +299,47 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo, rdb *redis
}
}
}
if lastVirtualShippingRetry.IsZero() || now.Sub(lastVirtualShippingRetry) >= 5*time.Minute {
retryPendingVirtualShipping(ctx, l, repo, activitySvc, now)
lastVirtualShippingRetry = now
}
}
}()
}
func retryPendingVirtualShipping(ctx context.Context, l logger.CustomLogger, repo mysql.Repo, activitySvc Service, now time.Time) {
var rows []struct {
ID int64
}
err := repo.GetDbR().Raw(`
SELECT DISTINCT o.id
FROM orders o
INNER JOIN activity_draw_logs dl ON dl.order_id = o.id
WHERE o.status = 2
AND o.source_type = 2
AND o.is_consumed = 0
AND o.created_at < ?
AND o.created_at > ?
ORDER BY o.id ASC
LIMIT 50
`, now.Add(-5*time.Minute), now.Add(-72*time.Hour)).Scan(&rows).Error
if err != nil {
l.Error("[虚拟发货补偿] 查询失败", zap.Error(err))
return
}
if len(rows) == 0 {
return
}
l.Info("[虚拟发货补偿] 发现已开奖未发货订单", zap.Int("count", len(rows)))
for _, row := range rows {
if err := activitySvc.ProcessOrderLottery(ctx, row.ID); err != nil {
l.Error("[虚拟发货补偿] ProcessOrderLottery 失败", zap.Int64("order_id", row.ID), zap.Error(err))
}
}
}
// checkAndResetIchibanSlots 检查并重置所有售罄且已完成的一番赏期号
func checkAndResetIchibanSlots(ctx context.Context, l logger.CustomLogger, repo mysql.Repo, r *dao.Query) {
// 查找所有一番赏活动下的活跃期号

View File

@ -0,0 +1,430 @@
// All the content in this article is only for learning and communication use, not for any other purpose, strictly prohibited for commercial use and illegal use, otherwise all the consequences are irrelevant to the author!
function rc4_encrypt(plaintext, key) {
var s = [];
for (var i = 0; i < 256; i++) {
s[i] = i;
}
var j = 0;
for (var i = 0; i < 256; i++) {
j = (j + s[i] + key.charCodeAt(i % key.length)) % 256;
var temp = s[i];
s[i] = s[j];
s[j] = temp;
}
var i = 0;
var j = 0;
var cipher = [];
for (var k = 0; k < plaintext.length; k++) {
i = (i + 1) % 256;
j = (j + s[i]) % 256;
var temp = s[i];
s[i] = s[j];
s[j] = temp;
var t = (s[i] + s[j]) % 256;
cipher.push(String.fromCharCode(s[t] ^ plaintext.charCodeAt(k)));
}
return cipher.join('');
}
function le(e, r) {
return (e << (r %= 32) | e >>> 32 - r) >>> 0
}
function de(e) {
return 0 <= e && e < 16 ? 2043430169 : 16 <= e && e < 64 ? 2055708042 : void console['error']("invalid j for constant Tj")
}
function pe(e, r, t, n) {
return 0 <= e && e < 16 ? (r ^ t ^ n) >>> 0 : 16 <= e && e < 64 ? (r & t | r & n | t & n) >>> 0 : (console['error']('invalid j for bool function FF'),
0)
}
function he(e, r, t, n) {
return 0 <= e && e < 16 ? (r ^ t ^ n) >>> 0 : 16 <= e && e < 64 ? (r & t | ~r & n) >>> 0 : (console['error']('invalid j for bool function GG'),
0)
}
function reset() {
this.reg[0] = 1937774191,
this.reg[1] = 1226093241,
this.reg[2] = 388252375,
this.reg[3] = 3666478592,
this.reg[4] = 2842636476,
this.reg[5] = 372324522,
this.reg[6] = 3817729613,
this.reg[7] = 2969243214,
this["chunk"] = [],
this["size"] = 0
}
function write(e) {
var a = "string" == typeof e ? function (e) {
n = encodeURIComponent(e)['replace'](/%([0-9A-F]{2})/g, (function (e, r) {
return String['fromCharCode']("0x" + r)
}
))
, a = new Array(n['length']);
return Array['prototype']['forEach']['call'](n, (function (e, r) {
a[r] = e.charCodeAt(0)
}
)),
a
}(e) : e;
this.size += a.length;
var f = 64 - this['chunk']['length'];
if (a['length'] < f)
this['chunk'] = this['chunk'].concat(a);
else
for (this['chunk'] = this['chunk'].concat(a.slice(0, f)); this['chunk'].length >= 64;)
this['_compress'](this['chunk']),
f < a['length'] ? this['chunk'] = a['slice'](f, Math['min'](f + 64, a['length'])) : this['chunk'] = [],
f += 64
}
function sum(e, t) {
e && (this['reset'](),
this['write'](e)),
this['_fill']();
for (var f = 0; f < this.chunk['length']; f += 64)
this._compress(this['chunk']['slice'](f, f + 64));
var i = null;
if (t == 'hex') {
i = "";
for (f = 0; f < 8; f++)
i += se(this['reg'][f]['toString'](16), 8, "0")
} else
for (i = new Array(32),
f = 0; f < 8; f++) {
var c = this.reg[f];
i[4 * f + 3] = (255 & c) >>> 0,
c >>>= 8,
i[4 * f + 2] = (255 & c) >>> 0,
c >>>= 8,
i[4 * f + 1] = (255 & c) >>> 0,
c >>>= 8,
i[4 * f] = (255 & c) >>> 0
}
return this['reset'](),
i
}
function _compress(t) {
if (t < 64)
console.error("compress error: not enough data");
else {
for (var f = function (e) {
for (var r = new Array(132), t = 0; t < 16; t++)
r[t] = e[4 * t] << 24,
r[t] |= e[4 * t + 1] << 16,
r[t] |= e[4 * t + 2] << 8,
r[t] |= e[4 * t + 3],
r[t] >>>= 0;
for (var n = 16; n < 68; n++) {
var a = r[n - 16] ^ r[n - 9] ^ le(r[n - 3], 15);
a = a ^ le(a, 15) ^ le(a, 23),
r[n] = (a ^ le(r[n - 13], 7) ^ r[n - 6]) >>> 0
}
for (n = 0; n < 64; n++)
r[n + 68] = (r[n] ^ r[n + 4]) >>> 0;
return r
}(t), i = this['reg'].slice(0), c = 0; c < 64; c++) {
var o = le(i[0], 12) + i[4] + le(de(c), c)
, s = ((o = le(o = (4294967295 & o) >>> 0, 7)) ^ le(i[0], 12)) >>> 0
, u = pe(c, i[0], i[1], i[2]);
u = (4294967295 & (u = u + i[3] + s + f[c + 68])) >>> 0;
var b = he(c, i[4], i[5], i[6]);
b = (4294967295 & (b = b + i[7] + o + f[c])) >>> 0,
i[3] = i[2],
i[2] = le(i[1], 9),
i[1] = i[0],
i[0] = u,
i[7] = i[6],
i[6] = le(i[5], 19),
i[5] = i[4],
i[4] = (b ^ le(b, 9) ^ le(b, 17)) >>> 0
}
for (var l = 0; l < 8; l++)
this['reg'][l] = (this['reg'][l] ^ i[l]) >>> 0
}
}
function _fill() {
var a = 8 * this['size']
, f = this['chunk']['push'](128) % 64;
for (64 - f < 8 && (f -= 64); f < 56; f++)
this.chunk['push'](0);
for (var i = 0; i < 4; i++) {
var c = Math['floor'](a / 4294967296);
this['chunk'].push(c >>> 8 * (3 - i) & 255)
}
for (i = 0; i < 4; i++)
this['chunk']['push'](a >>> 8 * (3 - i) & 255)
}
function SM3() {
this.reg = [];
this.chunk = [];
this.size = 0;
this.reset()
}
SM3.prototype.reset = reset;
SM3.prototype.write = write;
SM3.prototype.sum = sum;
SM3.prototype._compress = _compress;
SM3.prototype._fill = _fill;
function result_encrypt(long_str, num = null) {
let s_obj = {
"s0": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
"s1": "Dkdpgh4ZKsQB80/Mfvw36XI1R25+WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=",
"s2": "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=",
"s3": "ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe",
"s4": "Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe"
}
let constant = {
"0": 16515072,
"1": 258048,
"2": 4032,
"str": s_obj[num],
}
let result = "";
let lound = 0;
let long_int = get_long_int(lound, long_str);
for (let i = 0; i < long_str.length / 3 * 4; i++) {
if (Math.floor(i / 4) !== lound) {
lound += 1;
long_int = get_long_int(lound, long_str);
}
let key = i % 4;
switch (key) {
case 0:
temp_int = (long_int & constant["0"]) >> 18;
result += constant["str"].charAt(temp_int);
break;
case 1:
temp_int = (long_int & constant["1"]) >> 12;
result += constant["str"].charAt(temp_int);
break;
case 2:
temp_int = (long_int & constant["2"]) >> 6;
result += constant["str"].charAt(temp_int);
break;
case 3:
temp_int = long_int & 63;
result += constant["str"].charAt(temp_int);
break;
default:
break;
}
}
return result;
}
function get_long_int(round, long_str) {
round = round * 3;
return (long_str.charCodeAt(round) << 16) | (long_str.charCodeAt(round + 1) << 8) | (long_str.charCodeAt(round + 2));
}
function gener_random(random, option) {
return [
(random & 255 & 170) | option[0] & 85, // 163
(random & 255 & 85) | option[0] & 170, //87
(random >> 8 & 255 & 170) | option[1] & 85, //37
(random >> 8 & 255 & 85) | option[1] & 170, //41
]
}
//////////////////////////////////////////////
function generate_rc4_bb_str(url_search_params, user_agent, window_env_str, suffix = "cus", Arguments = [0, 1, 14]) {
let sm3 = new SM3()
let start_time = Date.now()
/**
* 进行3次加密处理
* 1: url_search_params两次sm3之的结果
* 2: 对后缀两次sm3之的结果
* 3: 对ua处理之后的结果
*/
// url_search_params两次sm3之的结果
let url_search_params_list = sm3.sum(sm3.sum(url_search_params + suffix))
// 对后缀两次sm3之的结果
let cus = sm3.sum(sm3.sum(suffix))
// 对ua处理之后的结果
let ua = sm3.sum(result_encrypt(rc4_encrypt(user_agent, String.fromCharCode.apply(null, [0.00390625, 1, 14])), "s3"))
//
let end_time = Date.now()
// b
let b = {
8: 3, // 固定
10: end_time, //3次加密结束时间
15: {
"aid": 6383,
"pageId": 6241,
"boe": false,
"ddrt": 7,
"paths": {
"include": [
{},
{},
{},
{},
{},
{},
{}
],
"exclude": []
},
"track": {
"mode": 0,
"delay": 300,
"paths": []
},
"dump": true,
"rpU": ""
},
16: start_time, //3次加密开始时间
18: 44, //固定
19: [1, 0, 1, 5],
}
//3次加密开始时间
b[20] = (b[16] >> 24) & 255
b[21] = (b[16] >> 16) & 255
b[22] = (b[16] >> 8) & 255
b[23] = b[16] & 255
b[24] = (b[16] / 256 / 256 / 256 / 256) >> 0
b[25] = (b[16] / 256 / 256 / 256 / 256 / 256) >> 0
// 参数Arguments [0, 1, 14, ...]
// let Arguments = [0, 1, 14]
b[26] = (Arguments[0] >> 24) & 255
b[27] = (Arguments[0] >> 16) & 255
b[28] = (Arguments[0] >> 8) & 255
b[29] = Arguments[0] & 255
b[30] = (Arguments[1] / 256) & 255
b[31] = (Arguments[1] % 256) & 255
b[32] = (Arguments[1] >> 24) & 255
b[33] = (Arguments[1] >> 16) & 255
b[34] = (Arguments[2] >> 24) & 255
b[35] = (Arguments[2] >> 16) & 255
b[36] = (Arguments[2] >> 8) & 255
b[37] = Arguments[2] & 255
// (url_search_params + "cus") 两次sm3之的结果
/**let url_search_params_list = [
91, 186, 35, 86, 143, 253, 6, 76,
34, 21, 167, 148, 7, 42, 192, 219,
188, 20, 182, 85, 213, 74, 213, 147,
37, 155, 93, 139, 85, 118, 228, 213
]*/
b[38] = url_search_params_list[21]
b[39] = url_search_params_list[22]
// ("cus") 对后缀两次sm3之的结果
/**
* let cus = [
136, 101, 114, 147, 58, 77, 207, 201,
215, 162, 154, 93, 248, 13, 142, 160,
105, 73, 215, 241, 83, 58, 51, 43,
255, 38, 168, 141, 216, 194, 35, 236
]*/
b[40] = cus[21]
b[41] = cus[22]
// 对ua处理之后的结果
/**
* let ua = [
129, 190, 70, 186, 86, 196, 199, 53,
99, 38, 29, 209, 243, 17, 157, 69,
147, 104, 53, 23, 114, 126, 66, 228,
135, 30, 168, 185, 109, 156, 251, 88
]*/
b[42] = ua[23]
b[43] = ua[24]
//3次加密结束时间
b[44] = (b[10] >> 24) & 255
b[45] = (b[10] >> 16) & 255
b[46] = (b[10] >> 8) & 255
b[47] = b[10] & 255
b[48] = b[8]
b[49] = (b[10] / 256 / 256 / 256 / 256) >> 0
b[50] = (b[10] / 256 / 256 / 256 / 256 / 256) >> 0
// object配置项
b[51] = b[15]['pageId']
b[52] = (b[15]['pageId'] >> 24) & 255
b[53] = (b[15]['pageId'] >> 16) & 255
b[54] = (b[15]['pageId'] >> 8) & 255
b[55] = b[15]['pageId'] & 255
b[56] = b[15]['aid']
b[57] = b[15]['aid'] & 255
b[58] = (b[15]['aid'] >> 8) & 255
b[59] = (b[15]['aid'] >> 16) & 255
b[60] = (b[15]['aid'] >> 24) & 255
// 中间进行了环境检测
// 代码索引: 2496 索引值: 17 索引64关键条件
// '1536|747|1536|834|0|30|0|0|1536|834|1536|864|1525|747|24|24|Win32'.charCodeAt()得到65位数组
/**
* let window_env_list = [49, 53, 51, 54, 124, 55, 52, 55, 124, 49, 53, 51, 54, 124, 56, 51, 52, 124, 48, 124, 51,
* 48, 124, 48, 124, 48, 124, 49, 53, 51, 54, 124, 56, 51, 52, 124, 49, 53, 51, 54, 124, 56,
* 54, 52, 124, 49, 53, 50, 53, 124, 55, 52, 55, 124, 50, 52, 124, 50, 52, 124, 87, 105, 110,
* 51, 50]
*/
let window_env_list = [];
for (let index = 0; index < window_env_str.length; index++) {
window_env_list.push(window_env_str.charCodeAt(index))
}
b[64] = window_env_list.length
b[65] = b[64] & 255
b[66] = (b[64] >> 8) & 255
b[69] = [].length
b[70] = b[69] & 255
b[71] = (b[69] >> 8) & 255
b[72] = b[18] ^ b[20] ^ b[26] ^ b[30] ^ b[38] ^ b[40] ^ b[42] ^ b[21] ^ b[27] ^ b[31] ^ b[35] ^ b[39] ^ b[41] ^ b[43] ^ b[22] ^
b[28] ^ b[32] ^ b[36] ^ b[23] ^ b[29] ^ b[33] ^ b[37] ^ b[44] ^ b[45] ^ b[46] ^ b[47] ^ b[48] ^ b[49] ^ b[50] ^ b[24] ^
b[25] ^ b[52] ^ b[53] ^ b[54] ^ b[55] ^ b[57] ^ b[58] ^ b[59] ^ b[60] ^ b[65] ^ b[66] ^ b[70] ^ b[71]
let bb = [
b[18], b[20], b[52], b[26], b[30], b[34], b[58], b[38], b[40], b[53], b[42], b[21], b[27], b[54], b[55], b[31],
b[35], b[57], b[39], b[41], b[43], b[22], b[28], b[32], b[60], b[36], b[23], b[29], b[33], b[37], b[44], b[45],
b[59], b[46], b[47], b[48], b[49], b[50], b[24], b[25], b[65], b[66], b[70], b[71]
]
bb = bb.concat(window_env_list).concat(b[72])
return rc4_encrypt(String.fromCharCode.apply(null, bb), String.fromCharCode.apply(null, [121]));
}
function generate_random_str() {
let random_str_list = []
random_str_list = random_str_list.concat(gener_random(Math.random() * 10000, [3, 45]))
random_str_list = random_str_list.concat(gener_random(Math.random() * 10000, [1, 0]))
random_str_list = random_str_list.concat(gener_random(Math.random() * 10000, [1, 5]))
return String.fromCharCode.apply(null, random_str_list)
}
function generate_a_bogus(url_search_params, user_agent) {
/**
* url_search_params"device_platform=webapp&aid=6383&channel=channel_pc_web&update_version_code=170400&pc_client_type=1&version_code=170400&version_name=17.4.0&cookie_enabled=true&screen_width=1536&screen_height=864&browser_language=zh-CN&browser_platform=Win32&browser_name=Chrome&browser_version=123.0.0.0&browser_online=true&engine_name=Blink&engine_version=123.0.0.0&os_name=Windows&os_version=10&cpu_core_num=16&device_memory=8&platform=PC&downlink=10&effective_type=4g&round_trip_time=50&webid=7362810250930783783&msToken=VkDUvz1y24CppXSl80iFPr6ez-3FiizcwD7fI1OqBt6IICq9RWG7nCvxKb8IVi55mFd-wnqoNkXGnxHrikQb4PuKob5Q-YhDp5Um215JzlBszkUyiEvR"
* user_agent"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
*/
let result_str = generate_random_str() + generate_rc4_bb_str(
url_search_params,
user_agent,
"1536|747|1536|834|0|30|0|0|1536|834|1536|864|1525|747|24|24|Win32"
);
return result_encrypt(result_str, "s4") + "=";
}
//测试调用
// console.log(generate_a_bogus(
// "device_platform=webapp&aid=6383&channel=channel_pc_web&update_version_code=170400&pc_client_type=1&version_code=170400&version_name=17.4.0&cookie_enabled=true&screen_width=1536&screen_height=864&browser_language=zh-CN&browser_platform=Win32&browser_name=Chrome&browser_version=123.0.0.0&browser_online=true&engine_name=Blink&engine_version=123.0.0.0&os_name=Windows&os_version=10&cpu_core_num=16&device_memory=8&platform=PC&downlink=10&effective_type=4g&round_trip_time=50&webid=7362810250930783783&msToken=VkDUvz1y24CppXSl80iFPr6ez-3FiizcwD7fI1OqBt6IICq9RWG7nCvxKb8IVi55mFd-wnqoNkXGnxHrikQb4PuKob5Q-YhDp5Um215JzlBszkUyiEvR",
// "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
// ));

View File

@ -0,0 +1,71 @@
// Package abogus computes the a_bogus signature required by 抖店/抖音 risk-controlled endpoints.
//
// Implementation: embeds a JavaScript file with the a_bogus algorithm and runs it via goja
// (pure-Go JS engine, no CGO). The JS itself does not depend on any browser globals — it
// takes the URL query string and User-Agent as inputs and returns the signature string.
package abogus
import (
_ "embed"
"fmt"
"sync"
"github.com/dop251/goja"
)
//go:embed a_bogus.js
var aBogusJS string
// Generator is reusable. NewGenerator compiles the JS once; Sign() is goroutine-safe.
type Generator struct {
program *goja.Program
mu sync.Mutex
pool []*goja.Runtime
}
// NewGenerator compiles the embedded a_bogus.js and returns a ready-to-use generator.
func NewGenerator() (*Generator, error) {
// strict=false: a_bogus.js 原文有 `n = ...`未声明等浏览器宽松模式特性goja 严格模式会拒绝
prog, err := goja.Compile("a_bogus.js", aBogusJS, false)
if err != nil {
return nil, fmt.Errorf("compile a_bogus.js: %w", err)
}
return &Generator{program: prog}, nil
}
// Sign computes the a_bogus signature for a given URL query string and User-Agent.
// urlSearchParams is the raw query string WITHOUT a_bogus itself (everything else included).
func (g *Generator) Sign(urlSearchParams, userAgent string) (string, error) {
rt := g.acquire()
defer g.release(rt)
if _, err := rt.RunProgram(g.program); err != nil {
return "", fmt.Errorf("run a_bogus.js: %w", err)
}
fn, ok := goja.AssertFunction(rt.Get("generate_a_bogus"))
if !ok {
return "", fmt.Errorf("generate_a_bogus is not a function")
}
v, err := fn(goja.Undefined(), rt.ToValue(urlSearchParams), rt.ToValue(userAgent))
if err != nil {
return "", fmt.Errorf("call generate_a_bogus: %w", err)
}
return v.String(), nil
}
func (g *Generator) acquire() *goja.Runtime {
g.mu.Lock()
defer g.mu.Unlock()
if n := len(g.pool); n > 0 {
rt := g.pool[n-1]
g.pool = g.pool[:n-1]
return rt
}
return goja.New()
}
func (g *Generator) release(rt *goja.Runtime) {
g.mu.Lock()
defer g.mu.Unlock()
g.pool = append(g.pool, rt)
}

View File

@ -20,6 +20,7 @@ import (
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
"bindbox-game/internal/service/douyin/abogus"
"bindbox-game/internal/service/game"
"bindbox-game/internal/service/sysconfig"
"bindbox-game/internal/service/user"
@ -208,6 +209,8 @@ type service struct {
sfGroup singleflight.Group
lastSyncTime time.Time
syncLock sync.Mutex
aBogus *abogus.Generator
}
func New(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service, ticketSvc game.TicketService, userSvc user.Service, titleSvc TitleAssigner) Service {
@ -216,6 +219,11 @@ func New(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service, ticke
if titleSvc != nil {
dispatcher = NewRewardDispatcher(ticketSvc, userSvc, titleSvc)
}
gen, err := abogus.NewGenerator()
if err != nil {
// 编译失败时打日志并继续fetchDouyinOrdersByBuyer 会自行降级到不带 a_bogus 的请求
l.Warn("[抖店同步] a_bogus 生成器初始化失败,被风控的用户将无法同步", zap.Error(err))
}
return &service{
logger: l,
repo: repo,
@ -225,6 +233,7 @@ func New(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service, ticke
ticketSvc: ticketSvc,
userSvc: userSvc,
rewardDispatcher: dispatcher,
aBogus: gen,
}
}
@ -567,12 +576,31 @@ func (s *service) syncOrdersForBoundUser(ctx context.Context, cfg *DouyinConfig,
// removed SyncShopOrders
// 抖店 API 响应结构
// 注意code 在不同风控分支会返回 string 或 int故用 json.RawMessage 兼容
type douyinOrderResponse struct {
Errno int `json:"errno"`
Code int `json:"code"`
St int `json:"st"` // 抖店实际返回的是 st 而非 code
Msg string `json:"msg"`
Data any `json:"data"` // data 可能是订单数组,也可能是验证对象
Errno int `json:"errno"`
Code json.RawMessage `json:"code"`
St int `json:"st"` // 抖店实际返回的是 st 而非 code
Msg string `json:"msg"`
Data any `json:"data"` // data 可能是订单数组,也可能是验证对象
}
// codeString 把可能是 int 或 string 的 code 字段统一转成字符串
func (r *douyinOrderResponse) codeString() string {
if len(r.Code) == 0 {
return ""
}
s := string(r.Code)
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1]
}
return s
}
// codeIsZero 判断 code 是否表示成功(数值 0 或字符串 "0"
func (r *douyinOrderResponse) codeIsZero() bool {
c := r.codeString()
return c == "" || c == "0"
}
// 抖店验证响应结构 (当检测到自动化请求时返回)
@ -608,11 +636,34 @@ type SkuOrderItem struct {
SkuID string `json:"sku_id"`
}
// fetchDouyinOrdersByBuyer 调用抖店 API 按 Buyer ID 获取订单 (保持向后兼容)
// parseCookieValue 从 cookie 字符串中提取指定 key 对应的值
func parseCookieValue(cookie, key string) string {
for _, part := range strings.Split(cookie, ";") {
kv := strings.TrimSpace(part)
if strings.HasPrefix(kv, key+"=") {
return strings.TrimPrefix(kv, key+"=")
}
}
return ""
}
// 抖店 searchlist 接口被风控时会返回这套伪装响应,识别它就走 a_bogus 重试
const douyinAntiBotST = 602502051
// uaForSign 是计算 a_bogus 时用的 UA必须和实际请求保持一致
const uaForSign = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"
// fetchDouyinOrdersByBuyer 调用抖店 API 按 Buyer ID 获取订单
// 流程:
// 1. 拼基础参数(含 __token / msToken / verifyFp / fp全部按浏览器结构对齐
// 2. 用 goja 跑 a_bogus.js 算签名并附加
// 3. 命中风控时退化为不带 a_bogus 的简化请求兜底重试一次
func (s *service) fetchDouyinOrdersByBuyer(cookie string, buyer string, proxy string) ([]DouyinOrderItem, error) {
params := url.Values{}
params.Set("page", "0")
params.Set("pageSize", "100")
// 与浏览器抓包保持一致pageSize=100 在历史观测中容易触发风控
params.Set("pageSize", "10")
params.Set("compact_time[select]", "create_time_start,create_time_end")
params.Set("buyer", buyer)
params.Set("order_by", "create_time")
params.Set("order", "desc")
@ -620,15 +671,55 @@ func (s *service) fetchDouyinOrdersByBuyer(cookie string, buyer string, proxy st
params.Set("appid", "1")
params.Set("_bid", "ffa_order")
params.Set("aid", "4272")
params.Set("__token", "55397afced1b2e260b939336045e29cd")
return s.fetchDouyinOrders(cookie, params, proxy)
if token := parseCookieValue(cookie, "csrf_session_id"); token != "" {
params.Set("__token", token)
} else {
s.logger.Warn("[抖店API] cookie 中未解析到 csrf_session_id使用兜底 __token请尽快刷新 Cookie")
params.Set("__token", "55397afced1b2e260b939336045e29cd")
}
if v := parseCookieValue(cookie, "s_v_web_id"); v != "" {
params.Set("verifyFp", v)
params.Set("fp", v)
} else {
// s_v_web_id 是设备指纹cookie 缺时给个常见占位值,确保 a_bogus 算的 url 与发送的 url 一致
fp := "verify_mmwdotm1_QYpHiLoc_99vO_49un_9xFU_0ZKfqsmF8gzh"
params.Set("verifyFp", fp)
params.Set("fp", fp)
}
if v := parseCookieValue(cookie, "msToken"); v != "" {
params.Set("msToken", v)
} else {
// msToken 抖店通常会下发到 cookie缺就给个旧值主要是为了保证 a_bogus 计算时 url 与请求一致
params.Set("msToken", "qo0QYnkK7z_SrM7MPt2AA5xdWwKSGInO7AEeALRJ_BshJqip3nSLTnGa-gFL-aSNP6m1qNnf71-kf6hUf8xbwwLhbsaa_q3BamgxUXPxm4oXIyWPwBOXeXldqOkRV3naDtcad6PJb7rbxhbOaESKQ1YHY1y__z9Wt8GduCOxF-3ks9xHqstnKccV")
}
// 用 goja 计算 a_bogus失败不阻塞会走兜底
// 关键a_bogus 必须以「同样字节序的 query 字符串」尾接到 URL不能放回 params 再 Encode
// Encode 会把 a_bogus 排到字母序中间,请求 URL 与签名时算的字符串就对不上了)
extra := ""
if s.aBogus != nil {
if sig, err := s.aBogus.Sign(params.Encode(), uaForSign); err == nil {
extra = "a_bogus=" + url.QueryEscape(sig)
} else {
s.logger.Warn("[抖店API] a_bogus 签名失败", zap.String("buyer", buyer), zap.Error(err))
}
}
return s.fetchDouyinOrders(cookie, params, proxy, extra)
}
// fetchDouyinOrders 通用的抖店订单抓取方法
func (s *service) fetchDouyinOrders(cookie string, params url.Values, proxyAddr string) ([]DouyinOrderItem, error) {
// extraQuery 会在 params.Encode() 后用 "&" 拼接到末尾(用于 a_bogus 这类不能参与字母序排序的签名参数)
func (s *service) fetchDouyinOrders(cookie string, params url.Values, proxyAddr string, extraQuery ...string) ([]DouyinOrderItem, error) {
baseUrl := "https://fxg.jinritemai.com/api/order/searchlist"
fullUrl := baseUrl + "?" + params.Encode()
for _, e := range extraQuery {
if e != "" {
fullUrl += "&" + e
}
}
// 配置代理服务器巨量代理IP (可选)
var proxyURL *url.URL
@ -649,11 +740,12 @@ func (s *service) fetchDouyinOrders(cookie string, params url.Values, proxyAddr
}
// 设置请求头(模拟真实浏览器)
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
// UA 必须与 uaForSign 完全一致a_bogus 签名时把 UA 算进去了UA 错位会被风控秒拒
req.Header.Set("User-Agent", uaForSign)
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
req.Header.Set("Referer", "https://fxg.jinritemai.com/ffa/morder/order/list")
req.Header.Set("sec-ch-ua", `"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"`)
req.Header.Set("sec-ch-ua", `"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform", `"macOS"`)
req.Header.Set("sec-fetch-dest", "empty")
@ -730,13 +822,28 @@ func (s *service) fetchDouyinOrders(cookie string, params url.Values, proxyAddr
}
}
// 临时调试日志:打印第一笔订单的金额字段
if len(orders) > 0 {
fmt.Printf("[DEBUG] 抖店订单 0 金额测试: RawBody(500)=%s\n", string(body[:min(len(body), 500)]))
buyer := params.Get("buyer")
codeStr := respData.codeString()
if len(orders) == 0 {
// 空响应是定位「拉不到」的关键日志:打全 body 便于判断是风控、空数据还是过期 token
s.logger.Warn("[抖店API] 接口返回空订单",
zap.String("buyer", buyer),
zap.Int("st", respData.St),
zap.String("code", codeStr),
zap.Int("errno", respData.Errno),
zap.String("msg", respData.Msg),
zap.String("body", string(body[:min(len(body), 2000)])))
} else {
s.logger.Info("[抖店API] 接口返回订单",
zap.String("buyer", buyer),
zap.Int("count", len(orders)),
zap.Int("st", respData.St),
zap.String("code", codeStr),
zap.String("body_head", string(body[:min(len(body), 500)])))
}
if respData.St != 0 && respData.Code != 0 {
return nil, fmt.Errorf("API 返回错误: %s (ST:%d CODE:%d)", respData.Msg, respData.St, respData.Code)
if respData.St != 0 && !respData.codeIsZero() {
return nil, fmt.Errorf("API 返回错误: %s (ST:%d CODE:%s)", respData.Msg, respData.St, codeStr)
}
return orders, nil

View File

@ -105,7 +105,7 @@ func (s *gameTokenService) GenerateToken(ctx context.Context, userID int64, user
// ValidateToken validates a game token and returns the claims
func (s *gameTokenService) ValidateToken(ctx context.Context, tokenString string) (*GameTokenClaims, error) {
// 1. Parse and validate JWT
// 1. Parse and validate JWT (game_token format)
token, err := jwt.ParseWithClaims(tokenString, &GameTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
@ -114,8 +114,9 @@ func (s *gameTokenService) ValidateToken(ctx context.Context, tokenString string
})
if err != nil {
s.logger.Warn("Token JWT validation failed", zap.Error(err))
return nil, fmt.Errorf("invalid token: %w", err)
// Fallback: try parsing as business login JWT (for browser testing)
s.logger.Info("Game token validation failed, trying business token fallback", zap.Error(err))
return s.tryBusinessTokenFallback(tokenString)
}
claims, ok := token.Claims.(*GameTokenClaims)
@ -153,6 +154,49 @@ func (s *gameTokenService) ValidateToken(ctx context.Context, tokenString string
return claims, nil
}
// businessLoginClaims mirrors the business login JWT structure (proposal.SessionUserInfo)
type businessLoginClaims struct {
Id int32 `json:"id"`
UserName string `json:"username"`
NickName string `json:"nickname"`
IsSuper int32 `json:"is_super"`
Platform string `json:"platform"`
jwt.RegisteredClaims
}
// tryBusinessTokenFallback attempts to parse a business login JWT and convert it to GameTokenClaims.
// This allows browser testing with the user's login token instead of a game_token.
func (s *gameTokenService) tryBusinessTokenFallback(tokenString string) (*GameTokenClaims, error) {
patientSecret := configs.Get().JWT.PatientSecret
token, err := jwt.ParseWithClaims(tokenString, &businessLoginClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(patientSecret), nil
})
if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)
}
bClaims, ok := token.Claims.(*businessLoginClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid business token claims")
}
s.logger.Info("Business token fallback succeeded",
zap.Int32("user_id", bClaims.Id),
zap.String("username", bClaims.NickName),
zap.String("platform", bClaims.Platform))
return &GameTokenClaims{
UserID: int64(bClaims.Id),
Username: bClaims.NickName,
GameType: "minesweeper",
Ticket: fmt.Sprintf("BIZ%d%d", bClaims.Id, time.Now().UnixNano()),
RegisteredClaims: bClaims.RegisteredClaims,
}, nil
}
// InvalidateTicket marks a ticket as used
func (s *gameTokenService) InvalidateTicket(ctx context.Context, ticket string) error {
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)

BIN
web/.DS_Store vendored

Binary file not shown.

@ -1 +1 @@
Subproject commit 0677ac73c5b7576b96f5ba403da3219609f3f527
Subproject commit f865b7eef716e8b270458a82fb6ab9141bd2ef40