精英手册改为锁单支付并发布0.2.2
- 精英手册兑换改为 createExchangeOrder 锁单后 payExchangeOrder 支付,不再查询商品状态与预兑 - 电竞手册保留直接兑换,标准模式重新取鉴权,激进模式复用鉴权 - csrfToken 优先复用 Cookie,校验失败自动刷新后重试一次 - 锁单支付按频控/通用退避表重试,临近过期不再重试 - 定时复校发现抢购已开始则不再改写触发时刻 - 升级 wails 至 v2.15.0
This commit is contained in:
@@ -169,15 +169,15 @@ defineExpose({ open, close })
|
||||
<div class="form-group">
|
||||
<label class="form-label">抢购模式</label>
|
||||
<select v-model.number="formData.seckillMode" class="form-select">
|
||||
<option :value="0">标准模式(每次完整校验)</option>
|
||||
<option :value="1">激进模式(预检后直兑)</option>
|
||||
<option :value="0">标准模式(电竞手册每次重新取鉴权)</option>
|
||||
<option :value="1">激进模式(电竞手册复用鉴权)</option>
|
||||
</select>
|
||||
<div class="form-hint">
|
||||
<template v-if="formData.seckillMode === 0">
|
||||
标准模式:每次兑换都执行完整校验流程(CSRF→限制检查→预兑换→兑换)
|
||||
精英手册固定为锁单后再支付,不查商品状态。电竞手册每次重新取鉴权后直接兑换。
|
||||
</template>
|
||||
<template v-else>
|
||||
激进模式:默认策略,优先复用预热结果直兑,必要时自动回退完整校验
|
||||
精英手册与标准模式相同。电竞手册复用已有鉴权直接兑换,失效后再取一次。
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,10 @@ go 1.26.3
|
||||
require github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
|
||||
|
||||
require (
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
github.com/wailsapp/wails/v2 v2.15.0
|
||||
github.com/xuri/excelize/v2 v2.9.1
|
||||
golang.org/x/image v0.42.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/sys v0.46.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -41,7 +41,7 @@ require (
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.1 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
)
|
||||
|
||||
@@ -54,8 +54,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo=
|
||||
github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
@@ -68,33 +68,33 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
github.com/wailsapp/wails/v2 v2.15.0 h1:u7cHK+UesZOlYxyJxfYLteaCPhws6UsZoDdqUejuX6Q=
|
||||
github.com/wailsapp/wails/v2 v2.15.0/go.mod h1:scxrgwfsv6yR6fE6cCF+Flfl+JeU+SR87T9x4kILJ6M=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
|
||||
github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s=
|
||||
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
|
||||
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
|
||||
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -32,6 +32,9 @@ type Activity struct {
|
||||
PreExchangeURL string
|
||||
ExchangeURL string
|
||||
ExchangeListURL string
|
||||
// 精英手册正式兑换是锁单再支付。电竞手册仍走 ExchangeURL。
|
||||
CreateOrderURL string
|
||||
PayOrderURL string
|
||||
|
||||
BindInfoURL string
|
||||
ScoreURL string
|
||||
@@ -61,6 +64,8 @@ var peace = &Activity{
|
||||
BatchLimitURL: douyuBase + "/wgapi/ordnc/activity/peace/batchExchangeLimit",
|
||||
PreExchangeURL: douyuBase + "/wgapi/ordnc/activity/peace/preExchange",
|
||||
ExchangeURL: douyuBase + "/wgapi/ordnc/activity/peace/exchange",
|
||||
CreateOrderURL: douyuBase + "/wgapi/ordnc/activity/peace/createExchangeOrder",
|
||||
PayOrderURL: douyuBase + "/wgapi/ordnc/activity/peace/payExchangeOrder",
|
||||
ExchangeListURL: douyuBase + "/wgapi/ordnc/activity/peace/exchangeList?manualID=G4KA4Qnz4LDp7&limit=100&offset=0",
|
||||
BindInfoURL: douyuBase + "/japi/carnivalApi/v2/tencent/bindInfo?actAlias=20260120QYOOB",
|
||||
ScoreURL: douyuBase + "/japi/oms/web/peace/credit/balance",
|
||||
|
||||
+178
-1
@@ -1018,8 +1018,13 @@ func (c *DouyuClient) Score(cookie string) (int, error) {
|
||||
return result.Data.Balance, nil
|
||||
}
|
||||
|
||||
// GenerateCSRF 生成当前活动需要的鉴权 token。
|
||||
// GenerateCSRF 获取当前活动需要的鉴权 token。
|
||||
// 精英手册优先复用 Cookie 里的 cvl_csrf_token,缺失时才请求生成接口。
|
||||
func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) {
|
||||
return c.generateCSRF(cookie, false)
|
||||
}
|
||||
|
||||
func (c *DouyuClient) generateCSRF(cookie string, forceRefresh bool) (string, error) {
|
||||
if c.Activity.IsXiaodian {
|
||||
return "", nil
|
||||
}
|
||||
@@ -1030,6 +1035,11 @@ func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) {
|
||||
}
|
||||
return combineAuthToken("", ctn), nil
|
||||
}
|
||||
if !forceRefresh {
|
||||
if token := cookieValue(cookie, "cvl_csrf_token"); token != "" {
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", c.Activity.CSRFURL, nil)
|
||||
if err != nil {
|
||||
@@ -1212,6 +1222,173 @@ func (c *DouyuClient) PreExchange(cookie, commodityID string, csrf *string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// LockedExchangeOrder 是精英手册锁单结果。
|
||||
type LockedExchangeOrder struct {
|
||||
OrderID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// CreateExchangeOrder 锁定精英手册商品,不查询商品详情或预兑状态。
|
||||
func (c *DouyuClient) CreateExchangeOrder(cookie, commodityID string, num int, csrf *string) (LockedExchangeOrder, error) {
|
||||
if strings.TrimSpace(c.Activity.CreateOrderURL) == "" {
|
||||
return LockedExchangeOrder{}, errors.New("当前活动没有锁单接口")
|
||||
}
|
||||
if num < 1 {
|
||||
num = 1
|
||||
}
|
||||
result, err := c.postCSRFForm(c.Activity.CreateOrderURL, cookie, "锁定兑换商品", map[string]string{
|
||||
"manualID": c.Activity.ManualID,
|
||||
"rid": c.Activity.RoomID,
|
||||
"commodityID": commodityID,
|
||||
"num": strconv.Itoa(num),
|
||||
}, csrf)
|
||||
if err != nil {
|
||||
return LockedExchangeOrder{}, err
|
||||
}
|
||||
if result.Error != 0 {
|
||||
if strings.TrimSpace(result.Msg) == "" {
|
||||
return LockedExchangeOrder{}, errors.New("锁定兑换商品失败")
|
||||
}
|
||||
return LockedExchangeOrder{}, fmt.Errorf("锁定兑换商品失败: %s", result.Msg)
|
||||
}
|
||||
var data struct {
|
||||
OrderID string `json:"orderId"`
|
||||
ExpireSeconds json.RawMessage `json:"expireSeconds"`
|
||||
}
|
||||
if err := json.Unmarshal(result.Data, &data); err != nil {
|
||||
return LockedExchangeOrder{}, fmt.Errorf("锁单数据解析失败: %w", err)
|
||||
}
|
||||
orderID := strings.TrimSpace(data.OrderID)
|
||||
if orderID == "" {
|
||||
return LockedExchangeOrder{}, errors.New("锁定兑换商品成功,但响应中没有 orderId")
|
||||
}
|
||||
lockedAt := time.Now()
|
||||
expiresAt := lockedAt.Add(300 * time.Second)
|
||||
if seconds := rawInt(data.ExpireSeconds); seconds > 0 {
|
||||
expiresAt = lockedAt.Add(time.Duration(seconds) * time.Second)
|
||||
}
|
||||
return LockedExchangeOrder{OrderID: orderID, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
// PayExchangeOrder 支付一笔已锁定的精英手册订单。
|
||||
func (c *DouyuClient) PayExchangeOrder(cookie, orderID string, csrf *string) (string, error) {
|
||||
if strings.TrimSpace(c.Activity.PayOrderURL) == "" {
|
||||
return "", errors.New("当前活动没有锁单支付接口")
|
||||
}
|
||||
orderID = strings.TrimSpace(orderID)
|
||||
if orderID == "" {
|
||||
return "", errors.New("锁单订单号不能为空")
|
||||
}
|
||||
result, err := c.postCSRFForm(c.Activity.PayOrderURL, cookie, "支付锁单", map[string]string{
|
||||
"orderID": orderID,
|
||||
"manualID": c.Activity.ManualID,
|
||||
}, csrf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Error != 0 {
|
||||
if alreadyPaid(result.Msg) {
|
||||
return "orderId=" + orderID + ",alreadyPaid", nil
|
||||
}
|
||||
if strings.TrimSpace(result.Msg) == "" {
|
||||
return "", errors.New("支付锁单失败")
|
||||
}
|
||||
return "", fmt.Errorf("支付锁单失败: %s", result.Msg)
|
||||
}
|
||||
var data struct {
|
||||
ExchangeID json.RawMessage `json:"exchangeId"`
|
||||
ExchangeNum json.RawMessage `json:"exchangeNum"`
|
||||
}
|
||||
if err := json.Unmarshal(result.Data, &data); err != nil {
|
||||
return "", fmt.Errorf("支付数据解析失败: %w", err)
|
||||
}
|
||||
exchangeID := rawString(data.ExchangeID)
|
||||
exchangeNum := rawString(data.ExchangeNum)
|
||||
if exchangeID == "" && exchangeNum == "" {
|
||||
return "", errors.New("支付数据解析失败: 响应中没有兑换结果")
|
||||
}
|
||||
return fmt.Sprintf("exchangeId=%s,num=%s", exchangeID, exchangeNum), nil
|
||||
}
|
||||
|
||||
func alreadyPaid(message string) bool {
|
||||
return strings.Contains(message, "订单已支付") || strings.Contains(message, "请勿重复支付")
|
||||
}
|
||||
|
||||
func (c *DouyuClient) postCSRFForm(endpoint, cookie, label string, fields map[string]string, csrf *string) (types.APIResponse, error) {
|
||||
var result types.APIResponse
|
||||
token := ""
|
||||
if csrf != nil {
|
||||
token = strings.SplitN(*csrf, "\x00", 2)[0]
|
||||
}
|
||||
if token == "" {
|
||||
var err error
|
||||
token, err = c.GenerateCSRF(cookie)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if csrf != nil {
|
||||
*csrf = token
|
||||
}
|
||||
}
|
||||
fields["csrfToken"] = token
|
||||
if err := c.postMultipart(endpoint, cookie, csrf, label, fields, &result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if result.Error != 0 && csrfError(result.Msg) {
|
||||
logger.LogEvent("网络", "info", "%s: csrfToken 校验失败,刷新后重试一次", label)
|
||||
token, err := c.generateCSRF(cookie, true)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if csrf != nil {
|
||||
*csrf = token
|
||||
}
|
||||
fields["csrfToken"] = token
|
||||
result = types.APIResponse{}
|
||||
if err := c.postMultipart(endpoint, cookie, csrf, label, fields, &result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func csrfError(message string) bool {
|
||||
return strings.Contains(strings.ToLower(message), "csrf")
|
||||
}
|
||||
|
||||
func rawString(raw json.RawMessage) string {
|
||||
raw = bytesTrimJSON(raw)
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func rawInt(raw json.RawMessage) int {
|
||||
raw = bytesTrimJSON(raw)
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return 0
|
||||
}
|
||||
var n int
|
||||
if err := json.Unmarshal(raw, &n); err == nil {
|
||||
return n
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
n, _ = strconv.Atoi(strings.TrimSpace(s))
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func bytesTrimJSON(raw json.RawMessage) json.RawMessage {
|
||||
return json.RawMessage(strings.TrimSpace(string(raw)))
|
||||
}
|
||||
|
||||
// Exchange 正式兑换一个商品。
|
||||
func (c *DouyuClient) Exchange(cookie, commodityID string, csrf *string) (string, error) {
|
||||
if c.Activity.IsXiaodian {
|
||||
|
||||
@@ -361,6 +361,127 @@ func TestXiaodianExchangeListUsesOrderListAndParsesGoods(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCSRFReusesPeaceCookie(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("不应请求鉴权接口: %s", r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
act := *activity.Get("peace")
|
||||
act.CSRFURL = server.URL + "/csrf"
|
||||
cli := New(&act, "", &trace.Recorder{})
|
||||
cli.HTTPClient = server.Client()
|
||||
|
||||
token, err := cli.GenerateCSRF("foo=1; cvl_csrf_token=from-cookie")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCSRF() error = %v", err)
|
||||
}
|
||||
if token != "from-cookie" {
|
||||
t.Fatalf("token = %q, want from-cookie", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExchangeOrderRefreshesCSRFOnce(t *testing.T) {
|
||||
var creates int
|
||||
var csrfCalls int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/csrf":
|
||||
csrfCalls++
|
||||
http.SetCookie(w, &http.Cookie{Name: "cvl_csrf_token", Value: "fresh"})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"error":0,"msg":"ok"}`))
|
||||
case "/create":
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
t.Errorf("ParseMultipartForm() error = %v", err)
|
||||
http.Error(w, "bad multipart", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
creates++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if creates == 1 {
|
||||
if got := r.MultipartForm.Value["csrfToken"][0]; got != "stale" {
|
||||
t.Errorf("first csrfToken = %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"error":1,"msg":"csrfToken invalid"}`))
|
||||
return
|
||||
}
|
||||
if got := r.MultipartForm.Value["csrfToken"][0]; got != "fresh" {
|
||||
t.Errorf("retry csrfToken = %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-1","expireSeconds":120}}`))
|
||||
default:
|
||||
t.Errorf("意外请求 %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
act := *activity.Get("peace")
|
||||
act.CSRFURL = server.URL + "/csrf"
|
||||
act.CreateOrderURL = server.URL + "/create"
|
||||
cli := New(&act, "", &trace.Recorder{})
|
||||
cli.HTTPClient = server.Client()
|
||||
|
||||
token := "stale"
|
||||
order, err := cli.CreateExchangeOrder("cvl_csrf_token=stale", "goods-1", 1, &token)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateExchangeOrder() error = %v", err)
|
||||
}
|
||||
if order.OrderID != "order-1" {
|
||||
t.Fatalf("order = %#v", order)
|
||||
}
|
||||
if creates != 2 || csrfCalls != 1 {
|
||||
t.Fatalf("creates=%d csrfCalls=%d, want 2 and 1", creates, csrfCalls)
|
||||
}
|
||||
if token != "fresh" {
|
||||
t.Fatalf("token = %q, want fresh", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayExchangeOrderAcceptsStringExchangeID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"error":0,"data":{"exchangeId":"E100","exchangeNum":"1"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
act := *activity.Get("peace")
|
||||
act.PayOrderURL = server.URL
|
||||
cli := New(&act, "", &trace.Recorder{})
|
||||
cli.HTTPClient = server.Client()
|
||||
|
||||
token := "tok"
|
||||
got, err := cli.PayExchangeOrder("cvl_csrf_token=tok", "74650", &token)
|
||||
if err != nil {
|
||||
t.Fatalf("PayExchangeOrder() error = %v", err)
|
||||
}
|
||||
if got != "exchangeId=E100,num=1" {
|
||||
t.Fatalf("PayExchangeOrder() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayExchangeOrderTreatsDuplicatePaymentAsSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"error":1644430006,"msg":"订单已支付,请勿重复支付"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
act := *activity.Get("peace")
|
||||
act.PayOrderURL = server.URL
|
||||
cli := New(&act, "", &trace.Recorder{})
|
||||
cli.HTTPClient = server.Client()
|
||||
|
||||
token := "tok"
|
||||
got, err := cli.PayExchangeOrder("cvl_csrf_token=tok", "74650", &token)
|
||||
if err != nil {
|
||||
t.Fatalf("PayExchangeOrder() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "alreadyPaid") || !strings.Contains(got, "74650") {
|
||||
t.Fatalf("PayExchangeOrder() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testCTNClient(t *testing.T, ctnURL string) *DouyuClient {
|
||||
t.Helper()
|
||||
act := &activity.Activity{
|
||||
|
||||
@@ -3,7 +3,7 @@ package constants
|
||||
import "time"
|
||||
|
||||
// Version 应用版本号,与 wails.json version 同步。
|
||||
const Version = "0.2.1"
|
||||
const Version = "0.2.2"
|
||||
|
||||
// BuildTime 构建时间,由编译时 ldflags 注入。
|
||||
var BuildTime = "unknown"
|
||||
|
||||
+239
-124
@@ -1,34 +1,170 @@
|
||||
package seckill
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"douyu_go/internal/client"
|
||||
"douyu_go/internal/logger"
|
||||
"douyu_go/internal/types"
|
||||
"douyu_go/internal/utils"
|
||||
)
|
||||
|
||||
// runAttempt 标准模式兑换:完整执行鉴权 token→BatchLimit→PreExchange→Exchange 链路。
|
||||
var errPurchaseStopped = errors.New("已收到停止指令,锁单可能仍有效")
|
||||
|
||||
const (
|
||||
peaceLockTTL = 300 * time.Second
|
||||
peaceLockExpiryMargin = 15 * time.Second
|
||||
peaceLockRetryFallback = 3 * time.Second
|
||||
exchangeRateCooldown = 30 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
peacePreLockMin = 300 * time.Millisecond
|
||||
peacePreLockMax = 1200 * time.Millisecond
|
||||
peaceLockPayMin = 2 * time.Second
|
||||
peaceLockPayMax = 4 * time.Second
|
||||
)
|
||||
|
||||
var peaceRateLimitBackoffs = []time.Duration{30 * time.Second, 60 * time.Second, 90 * time.Second}
|
||||
var peaceGenericBackoffs = []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 20 * time.Second}
|
||||
|
||||
// runAttempt 按活动兑换。精英手册锁单后支付;电竞手册直接兑换。两者都不做商品预检。
|
||||
func (s *Scheduler) runAttempt(attempt types.ExchangeAttempt) types.ExchangeResult {
|
||||
cli := s.clientSnapshot()
|
||||
// 最终防护:任何旧入口在和平小店活动下都必须使用 livebuy。
|
||||
if cli.Activity.IsXiaodian {
|
||||
return s.runXiaodianAttempt(attempt)
|
||||
}
|
||||
if !cli.Activity.UseCTN {
|
||||
return s.runPeaceLockPay(attempt)
|
||||
}
|
||||
return s.runDirectExchange(attempt, false)
|
||||
}
|
||||
|
||||
// runAttemptFast 激进模式。精英手册与标准模式相同;电竞手册复用鉴权后直接兑换。
|
||||
func (s *Scheduler) runAttemptFast(attempt types.ExchangeAttempt) types.ExchangeResult {
|
||||
cli := s.clientSnapshot()
|
||||
if cli.Activity.IsXiaodian {
|
||||
return s.runXiaodianAttempt(attempt)
|
||||
}
|
||||
if !cli.Activity.UseCTN {
|
||||
return s.runPeaceLockPay(attempt)
|
||||
}
|
||||
return s.runDirectExchange(attempt, true)
|
||||
}
|
||||
|
||||
func (s *Scheduler) runPeaceLockPay(attempt types.ExchangeAttempt) types.ExchangeResult {
|
||||
cli := s.clientSnapshot()
|
||||
result := types.ExchangeResult{
|
||||
Account: attempt.Account.Name,
|
||||
Product: attempt.Product.CommodityName,
|
||||
Attempt: attempt.Index,
|
||||
}
|
||||
roleName, csrf := s.cachedAuth(attempt)
|
||||
if csrf == "" {
|
||||
var err error
|
||||
csrf, err = cli.GenerateCSRF(attempt.Account.Cookie)
|
||||
if err != nil {
|
||||
result.Message = "失败|生成鉴权token失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "执行资格预检链路: 账号=%s 商品=%s 第%d包", attempt.Account.Name, attempt.Product.CommodityName, attempt.Index)
|
||||
var roleName string
|
||||
csrf := ""
|
||||
if warmup, ok := s.warmupFor(attempt); ok {
|
||||
csrf = warmup.CSRF
|
||||
roleName = warmup.RoleName
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "使用预热鉴权token: 账号=%s 商品=%s 预热距今=%s", attempt.Account.Name, attempt.Product.CommodityName, utils.RoundDuration(time.Since(warmup.CreatedAt)))
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "精英手册锁单支付: 账号=%s 商品=%s 第%d包", attempt.Account.Name, attempt.Product.CommodityName, attempt.Index)
|
||||
if s.interruptibleSleep(randomSpan(peacePreLockMin, peacePreLockMax)) {
|
||||
result.Message = "失败|已收到停止指令"
|
||||
return result
|
||||
}
|
||||
|
||||
locked, err := cli.CreateExchangeOrder(attempt.Account.Cookie, attempt.Product.CommodityID, 1, &csrf)
|
||||
if err != nil {
|
||||
delay := peaceLockRetryDelay(err.Error())
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "锁定兑换商品失败(%s),%s 后重试一次", err.Error(), delay)
|
||||
if s.interruptibleSleep(delay) {
|
||||
result.Message = "失败|已收到停止指令"
|
||||
return result
|
||||
}
|
||||
locked, err = cli.CreateExchangeOrder(attempt.Account.Cookie, attempt.Product.CommodityID, 1, &csrf)
|
||||
if err != nil {
|
||||
result.RoleName = roleName
|
||||
result.Message = "失败|锁定兑换商品失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
}
|
||||
s.rememberAuth(attempt.Account.Name, csrf, roleName)
|
||||
|
||||
delay := randomSpan(peaceLockPayMin, peaceLockPayMax)
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "锁单成功(订单 %s),%s 后支付", locked.OrderID, delay.Round(100*time.Millisecond))
|
||||
if s.interruptibleSleep(delay) {
|
||||
result.RoleName = roleName
|
||||
result.Message = "失败|已收到停止指令,锁单可能仍有效"
|
||||
return result
|
||||
}
|
||||
|
||||
exchangeInfo, err := s.payLockedOrder(cli, attempt, locked, &csrf)
|
||||
if err != nil {
|
||||
result.RoleName = roleName
|
||||
result.Message = "失败|" + err.Error()
|
||||
return result
|
||||
}
|
||||
return s.finishExchange(cli, attempt, result, csrf, roleName, exchangeInfo)
|
||||
}
|
||||
|
||||
func (s *Scheduler) payLockedOrder(cli *client.DouyuClient, attempt types.ExchangeAttempt, locked client.LockedExchangeOrder, csrf *string) (string, error) {
|
||||
for try := 0; try < 6; try++ {
|
||||
if s.runStopped() {
|
||||
return "", errPurchaseStopped
|
||||
}
|
||||
info, err := cli.PayExchangeOrder(attempt.Account.Cookie, locked.OrderID, csrf)
|
||||
if err == nil {
|
||||
return info, nil
|
||||
}
|
||||
backoff, ok := peacePayBackoff(try, exchangeRateLimited(err.Error()))
|
||||
if !ok {
|
||||
if exchangeRateLimited(err.Error()) {
|
||||
return "", errPayRateLimited(locked.OrderID, try, err)
|
||||
}
|
||||
return "", errPayFailed(locked.OrderID, try, err)
|
||||
}
|
||||
remaining := time.Until(locked.ExpiresAt)
|
||||
if remaining <= 0 {
|
||||
remaining = peaceLockTTL
|
||||
}
|
||||
if remaining-backoff < peaceLockExpiryMargin {
|
||||
return "", errPayNearExpiry(locked.OrderID, remaining, err)
|
||||
}
|
||||
kind := "错误"
|
||||
if exchangeRateLimited(err.Error()) {
|
||||
kind = "频控"
|
||||
}
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "锁单 %s 支付%s退避 %s 后重试: %s", locked.OrderID, kind, backoff, err.Error())
|
||||
if s.interruptibleSleep(backoff) {
|
||||
return "", errPurchaseStopped
|
||||
}
|
||||
}
|
||||
return "", errPayFailed(locked.OrderID, len(peaceGenericBackoffs), nil)
|
||||
}
|
||||
|
||||
func (s *Scheduler) runDirectExchange(attempt types.ExchangeAttempt, reuseCache bool) types.ExchangeResult {
|
||||
cli := s.clientSnapshot()
|
||||
result := types.ExchangeResult{
|
||||
Account: attempt.Account.Name,
|
||||
Product: attempt.Product.CommodityName,
|
||||
Attempt: attempt.Index,
|
||||
}
|
||||
roleName, csrf := s.cachedAuth(attempt)
|
||||
if reuseCache {
|
||||
cache := s.getAttemptCache(attempt.Account.Name)
|
||||
if cache == nil || cache.CSRF == "" || time.Since(cache.LastCheck) >= qualifiedMaxAge {
|
||||
return s.runDirectExchange(attempt, false)
|
||||
}
|
||||
csrf = cache.CSRF
|
||||
if cache.RoleName != "" {
|
||||
roleName = cache.RoleName
|
||||
}
|
||||
}
|
||||
if csrf == "" {
|
||||
var err error
|
||||
@@ -37,135 +173,34 @@ func (s *Scheduler) runAttempt(attempt types.ExchangeAttempt) types.ExchangeResu
|
||||
result.Message = "失败|生成鉴权token失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "鉴权token已生成,本次兑换链路复用同一个 token: 账号=%s 商品=%s", attempt.Account.Name, attempt.Product.CommodityName)
|
||||
}
|
||||
|
||||
limit, err := cli.BatchExchangeLimit(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf)
|
||||
if err != nil {
|
||||
result.Message = "失败|批量限制检查失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
if limit <= 0 {
|
||||
result.Message = "失败|当前商品限制兑换"
|
||||
return result
|
||||
}
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "批量限制通过: 账号=%s 商品=%s limit=%d", attempt.Account.Name, attempt.Product.CommodityName, limit)
|
||||
|
||||
if err := cli.PreExchange(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf); err != nil {
|
||||
result.Message = "失败|预兑换失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
s.setAttemptCache(attempt.Account.Name, &types.AttemptCache{
|
||||
CSRF: csrf,
|
||||
RoleName: roleName,
|
||||
Qualified: true,
|
||||
LastCheck: time.Now(),
|
||||
})
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "资格预检通过: 账号=%s 商品=%s,后续可直兑", attempt.Account.Name, attempt.Product.CommodityName)
|
||||
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "直接兑换: 账号=%s 商品=%s 第%d包", attempt.Account.Name, attempt.Product.CommodityName, attempt.Index)
|
||||
exchangeInfo, err := cli.ExchangeProduct(attempt.Account.Cookie, attempt.Product, &csrf)
|
||||
if err != nil {
|
||||
s.setAttemptCache(attempt.Account.Name, &types.AttemptCache{
|
||||
CSRF: csrf,
|
||||
RoleName: roleName,
|
||||
Qualified: true,
|
||||
LastCheck: time.Now(),
|
||||
})
|
||||
result.RoleName = roleName
|
||||
result.Message = "失败|" + err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
if roleName == "" {
|
||||
roleName, err = cli.RoleName(attempt.Account.Cookie)
|
||||
if err != nil {
|
||||
logger.LogAccountEvent("抢购", "warn", attempt.Account.Name, "角色名查询失败: 账号=%s err=%v", attempt.Account.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
result.OK = true
|
||||
result.RoleName = roleName
|
||||
if roleName != "" {
|
||||
result.Message = "成功|" + roleName + "|" + exchangeInfo
|
||||
} else {
|
||||
result.Message = "成功|" + exchangeInfo
|
||||
}
|
||||
|
||||
// 缓存鉴权 token 和角色名(激进模式使用)
|
||||
s.setAttemptCache(attempt.Account.Name, &types.AttemptCache{
|
||||
CSRF: csrf,
|
||||
RoleName: roleName,
|
||||
Qualified: true,
|
||||
LastCheck: time.Now(),
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// runAttemptFast 激进模式的兑换逻辑,复用缓存的鉴权 token,跳过 BatchExchangeLimit 和 PreExchange。
|
||||
func (s *Scheduler) runAttemptFast(attempt types.ExchangeAttempt) types.ExchangeResult {
|
||||
cli := s.clientSnapshot()
|
||||
if cli.Activity.IsXiaodian {
|
||||
return s.runXiaodianAttempt(attempt)
|
||||
}
|
||||
result := types.ExchangeResult{
|
||||
Account: attempt.Account.Name,
|
||||
Product: attempt.Product.CommodityName,
|
||||
Attempt: attempt.Index,
|
||||
}
|
||||
|
||||
cache := s.getAttemptCache(attempt.Account.Name)
|
||||
if cache == nil || cache.CSRF == "" || time.Since(cache.LastCheck) >= qualifiedMaxAge {
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "激进模式执行资格预检: 账号=%s 原因=无缓存鉴权token", attempt.Account.Name)
|
||||
return s.runAttempt(attempt)
|
||||
}
|
||||
if !cache.Qualified {
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "激进模式执行资格预检: 账号=%s 原因=未完成资格预检", attempt.Account.Name)
|
||||
return s.runAttempt(attempt)
|
||||
}
|
||||
|
||||
csrf := cache.CSRF
|
||||
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "激进模式直兑: 账号=%s 商品=%s 第%d包", attempt.Account.Name, attempt.Product.CommodityName, attempt.Index)
|
||||
|
||||
// 直接调用 Exchange
|
||||
exchangeInfo, err := cli.ExchangeProduct(attempt.Account.Cookie, attempt.Product, &csrf)
|
||||
if err != nil {
|
||||
s.setAttemptCache(attempt.Account.Name, &types.AttemptCache{
|
||||
CSRF: csrf,
|
||||
RoleName: cache.RoleName,
|
||||
Qualified: true,
|
||||
LastCheck: time.Now(),
|
||||
})
|
||||
result.RoleName = cache.RoleName
|
||||
s.rememberAuth(attempt.Account.Name, csrf, roleName)
|
||||
errMsg := err.Error()
|
||||
if shouldFallbackStandard(errMsg) {
|
||||
logger.LogAccountEvent("抢购", "warn", attempt.Account.Name, "激进模式回退标准: 账号=%s 原因=%s", attempt.Account.Name, utils.TrimForLog(errMsg, 80))
|
||||
s.setAttemptCache(attempt.Account.Name, nil) // 清空缓存
|
||||
return s.runAttempt(attempt)
|
||||
if reuseCache && shouldFallbackStandard(errMsg) {
|
||||
logger.LogAccountEvent("抢购", "warn", attempt.Account.Name, "激进模式重新取鉴权后重试: 账号=%s 原因=%s", attempt.Account.Name, utils.TrimForLog(errMsg, 80))
|
||||
s.setAttemptCache(attempt.Account.Name, nil)
|
||||
return s.runDirectExchange(attempt, false)
|
||||
}
|
||||
result.RoleName = roleName
|
||||
result.Message = "失败|" + errMsg
|
||||
return result
|
||||
}
|
||||
return s.finishExchange(cli, attempt, result, csrf, roleName, exchangeInfo)
|
||||
}
|
||||
|
||||
// 获取角色名
|
||||
roleName := ""
|
||||
if cache != nil {
|
||||
roleName = cache.RoleName
|
||||
}
|
||||
func (s *Scheduler) finishExchange(cli *client.DouyuClient, attempt types.ExchangeAttempt, result types.ExchangeResult, csrf, roleName, exchangeInfo string) types.ExchangeResult {
|
||||
if roleName == "" {
|
||||
var err error
|
||||
roleName, err = cli.RoleName(attempt.Account.Cookie)
|
||||
name, err := cli.RoleName(attempt.Account.Cookie)
|
||||
if err != nil {
|
||||
logger.LogAccountEvent("抢购", "warn", attempt.Account.Name, "角色名查询失败: 账号=%s err=%v", attempt.Account.Name, err)
|
||||
} else {
|
||||
roleName = name
|
||||
}
|
||||
}
|
||||
s.setAttemptCache(attempt.Account.Name, &types.AttemptCache{
|
||||
CSRF: csrf,
|
||||
RoleName: roleName,
|
||||
Qualified: true,
|
||||
LastCheck: time.Now(),
|
||||
})
|
||||
|
||||
s.rememberAuth(attempt.Account.Name, csrf, roleName)
|
||||
result.OK = true
|
||||
result.RoleName = roleName
|
||||
if roleName != "" {
|
||||
@@ -176,6 +211,31 @@ func (s *Scheduler) runAttemptFast(attempt types.ExchangeAttempt) types.Exchange
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Scheduler) cachedAuth(attempt types.ExchangeAttempt) (roleName, csrf string) {
|
||||
if warmup, ok := s.warmupFor(attempt); ok {
|
||||
roleName = warmup.RoleName
|
||||
csrf = warmup.CSRF
|
||||
}
|
||||
if cache := s.getAttemptCache(attempt.Account.Name); cache != nil && cache.CSRF != "" && time.Since(cache.LastCheck) < qualifiedMaxAge {
|
||||
if csrf == "" {
|
||||
csrf = cache.CSRF
|
||||
}
|
||||
if roleName == "" {
|
||||
roleName = cache.RoleName
|
||||
}
|
||||
}
|
||||
return roleName, csrf
|
||||
}
|
||||
|
||||
func (s *Scheduler) rememberAuth(accountName, csrf, roleName string) {
|
||||
s.setAttemptCache(accountName, &types.AttemptCache{
|
||||
CSRF: csrf,
|
||||
RoleName: roleName,
|
||||
Qualified: true,
|
||||
LastCheck: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// runXiaodianAttempt 和平小店专用兑换:跳过手册鉴权、批量限制和预兑换,直接调用 livebuy。
|
||||
func (s *Scheduler) runXiaodianAttempt(attempt types.ExchangeAttempt) types.ExchangeResult {
|
||||
cli := s.clientSnapshot()
|
||||
@@ -215,6 +275,61 @@ func (s *Scheduler) runXiaodianAttempt(attempt types.ExchangeAttempt) types.Exch
|
||||
return result
|
||||
}
|
||||
|
||||
func peacePayBackoff(attempt int, rateLimited bool) (time.Duration, bool) {
|
||||
table := peaceGenericBackoffs
|
||||
if rateLimited {
|
||||
table = peaceRateLimitBackoffs
|
||||
}
|
||||
if attempt < 0 || attempt >= len(table) {
|
||||
return 0, false
|
||||
}
|
||||
return table[attempt], true
|
||||
}
|
||||
|
||||
func peaceLockRetryDelay(message string) time.Duration {
|
||||
if exchangeRateLimited(message) && len(peaceRateLimitBackoffs) > 0 {
|
||||
return peaceRateLimitBackoffs[0]
|
||||
}
|
||||
return peaceLockRetryFallback
|
||||
}
|
||||
|
||||
func exchangeRateLimited(message string) bool {
|
||||
for _, hint := range []string{
|
||||
"火爆", "频繁", "稍后", "太热", "限流", "繁忙", "人多", "排队", "手慢",
|
||||
"用户行为异常", "点击太快", "请求过多", "请求太频繁", "频控",
|
||||
} {
|
||||
if strings.Contains(message, hint) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func randomSpan(min, max time.Duration) time.Duration {
|
||||
if max <= min {
|
||||
if min < 0 {
|
||||
return 0
|
||||
}
|
||||
return min
|
||||
}
|
||||
return min + time.Duration(rand.Int63n(int64(max-min)+1))
|
||||
}
|
||||
|
||||
func errPayRateLimited(orderID string, tries int, err error) error {
|
||||
return fmt.Errorf("锁单 %s 支付被频控拦截(已退避重试%d次): %w", orderID, tries, err)
|
||||
}
|
||||
|
||||
func errPayFailed(orderID string, tries int, err error) error {
|
||||
if err == nil {
|
||||
return fmt.Errorf("锁单 %s 支付失败(已重试%d次)", orderID, tries)
|
||||
}
|
||||
return fmt.Errorf("锁单 %s 支付失败(已重试%d次): %w", orderID, tries, err)
|
||||
}
|
||||
|
||||
func errPayNearExpiry(orderID string, remaining time.Duration, err error) error {
|
||||
return fmt.Errorf("锁单 %s 支付失败且临近过期(%ds): %w", orderID, int(remaining.Seconds()), err)
|
||||
}
|
||||
|
||||
func shouldFallbackStandard(errMsg string) bool {
|
||||
lower := strings.ToLower(errMsg)
|
||||
switch {
|
||||
|
||||
@@ -53,7 +53,15 @@ func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (types.ScheduleResult,
|
||||
|
||||
s.scheduleMu.Lock()
|
||||
s.replaceTimerLocked(&s.resyncSchedule, resyncRemain, func() {
|
||||
if s.IsRunning() {
|
||||
logger.LogEvent("定时", "info", "临近复校时抢购已开始,不再改写触发时刻")
|
||||
return
|
||||
}
|
||||
newCalibration := s.calibrateDouyuTime("临近")
|
||||
if s.IsRunning() {
|
||||
logger.LogEvent("定时", "info", "临近复校完成时抢购已开始,保持当前任务")
|
||||
return
|
||||
}
|
||||
newTriggerTime := targetTime.Add(-dynamicAdvance(newCalibration))
|
||||
s.scheduleTimers(ap, targetTime, newTriggerTime, newCalibration, req.Attempts, req.DelayMs, true)
|
||||
})
|
||||
|
||||
@@ -27,8 +27,6 @@ const (
|
||||
resyncBeforeTrigger = 3 * time.Second
|
||||
calibrationHistorySize = 3
|
||||
seckillMaxDuration = 75 * time.Second
|
||||
behaviorCooldown = 5 * time.Second
|
||||
clickFastCooldown = 3 * time.Second
|
||||
minInternalAdvance = 50 * time.Millisecond
|
||||
maxCooldownCount = 5
|
||||
xiaodianDefaultDelay = 500 * time.Millisecond
|
||||
@@ -429,7 +427,7 @@ func (s *Scheduler) RunNow(accountProducts map[string]string) {
|
||||
s.initCacheFromWarmup(allAttempts)
|
||||
}
|
||||
|
||||
logger.LogEvent("抢购", "info", "开始抢购: %d 个账号,窗口=%v,预检通过后直兑,前段高频后段降速", totalAccounts, seckillMaxDuration)
|
||||
logger.LogEvent("抢购", "info", "开始抢购: %d 个账号,窗口=%v", totalAccounts, seckillMaxDuration)
|
||||
|
||||
for {
|
||||
if s.runStopped() {
|
||||
@@ -941,20 +939,13 @@ func jitterDuration(base time.Duration) time.Duration {
|
||||
}
|
||||
|
||||
func retryCooldownDuration(message string) time.Duration {
|
||||
switch {
|
||||
case strings.Contains(message, "用户行为异常"):
|
||||
return behaviorCooldown
|
||||
case strings.Contains(message, "点击太快"):
|
||||
return clickFastCooldown
|
||||
case strings.Contains(message, "操作频繁"):
|
||||
return behaviorCooldown
|
||||
case strings.Contains(message, "请求过多"):
|
||||
return behaviorCooldown
|
||||
case strings.Contains(message, "请求太频繁"):
|
||||
return behaviorCooldown
|
||||
default:
|
||||
return 0
|
||||
if exchangeRateLimited(message) {
|
||||
return exchangeRateCooldown
|
||||
}
|
||||
if strings.Contains(message, "锁定兑换商品失败") || strings.Contains(message, "支付失败") {
|
||||
return peaceLockRetryFallback
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func classifyRetryStop(message string) (account bool, product bool, reason string) {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package seckill
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"douyu_go/internal/activity"
|
||||
"douyu_go/internal/client"
|
||||
"douyu_go/internal/trace"
|
||||
"douyu_go/internal/types"
|
||||
)
|
||||
|
||||
@@ -18,6 +23,89 @@ func makeAttempts(names ...string) []types.ExchangeAttempt {
|
||||
return out
|
||||
}
|
||||
|
||||
func TestPeacePayBackoff(t *testing.T) {
|
||||
if got, ok := peacePayBackoff(0, true); !ok || got != 30*time.Second {
|
||||
t.Fatalf("first rate backoff = %v ok=%v", got, ok)
|
||||
}
|
||||
if _, ok := peacePayBackoff(len(peaceRateLimitBackoffs), true); ok {
|
||||
t.Fatal("rate backoff should stop after the configured attempts")
|
||||
}
|
||||
if got, ok := peacePayBackoff(1, false); !ok || got != 5*time.Second {
|
||||
t.Fatalf("second generic backoff = %v ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeacePurchaseLocksThenPaysWithoutChecks(t *testing.T) {
|
||||
oldMin, oldMax := peacePreLockMin, peacePreLockMax
|
||||
oldPayMin, oldPayMax := peaceLockPayMin, peaceLockPayMax
|
||||
peacePreLockMin, peacePreLockMax = 0, 0
|
||||
peaceLockPayMin, peaceLockPayMax = 0, 0
|
||||
t.Cleanup(func() {
|
||||
peacePreLockMin, peacePreLockMax = oldMin, oldMax
|
||||
peaceLockPayMin, peaceLockPayMax = oldPayMin, oldPayMax
|
||||
})
|
||||
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/create":
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
t.Errorf("ParseMultipartForm() error = %v", err)
|
||||
return
|
||||
}
|
||||
if _, ok := r.MultipartForm.Value["orderID"]; ok {
|
||||
t.Error("锁单请求不应携带支付订单号")
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-9","expireSeconds":300}}`))
|
||||
case "/pay":
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
t.Errorf("ParseMultipartForm() error = %v", err)
|
||||
return
|
||||
}
|
||||
if got := r.MultipartForm.Value["orderID"][0]; got != "order-9" {
|
||||
t.Errorf("orderID = %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"error":0,"data":{"exchangeId":9,"exchangeNum":"1"}}`))
|
||||
case "/bind":
|
||||
_, _ = w.Write([]byte(`{"error":0,"data":{"gameRole":{"roleName":"测试角色"}}}`))
|
||||
default:
|
||||
t.Errorf("不应请求预检或单步兑换接口: %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
act := *activity.Get("peace")
|
||||
act.CreateOrderURL = server.URL + "/create"
|
||||
act.PayOrderURL = server.URL + "/pay"
|
||||
act.BindInfoURL = server.URL + "/bind"
|
||||
act.CSRFURL = server.URL + "/csrf"
|
||||
cli := client.New(&act, "", &trace.Recorder{})
|
||||
cli.HTTPClient = server.Client()
|
||||
scheduler := New(cli, types.AppPaths{}, nil, 1, 1, nil, types.SeckillModeAggressive)
|
||||
|
||||
result := scheduler.runPeaceLockPay(types.ExchangeAttempt{
|
||||
Account: types.Account{Name: "a", Cookie: "cvl_csrf_token=tok"},
|
||||
Product: types.Product{CommodityID: "goods-1", CommodityName: "测试商品"},
|
||||
Index: 1,
|
||||
})
|
||||
if !result.OK || result.RoleName != "测试角色" {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
if len(calls) < 2 || calls[0] != "/create" || calls[1] != "/pay" {
|
||||
t.Fatalf("calls = %v, want create then pay", calls)
|
||||
}
|
||||
for _, path := range calls {
|
||||
switch path {
|
||||
case "/create", "/pay", "/bind":
|
||||
default:
|
||||
t.Fatalf("unexpected call %s in %v", path, calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryIntervalFor(t *testing.T) {
|
||||
stages := []types.RetryStage{
|
||||
{MaxElapsed: 4, Interval: 120},
|
||||
@@ -72,11 +160,12 @@ func TestRetryCooldownDuration(t *testing.T) {
|
||||
msg string
|
||||
want time.Duration
|
||||
}{
|
||||
{"用户行为异常,请稍后再试", behaviorCooldown},
|
||||
{"点击太快了", clickFastCooldown},
|
||||
{"操作频繁请稍后", behaviorCooldown},
|
||||
{"请求过多", behaviorCooldown},
|
||||
{"请求太频繁", behaviorCooldown},
|
||||
{"用户行为异常,请稍后再试", exchangeRateCooldown},
|
||||
{"点击太快了", exchangeRateCooldown},
|
||||
{"操作频繁请稍后", exchangeRateCooldown},
|
||||
{"请求过多", exchangeRateCooldown},
|
||||
{"请求太频繁", exchangeRateCooldown},
|
||||
{"当前活动太火爆", exchangeRateCooldown},
|
||||
{"兑换成功", 0},
|
||||
{"库存不足", 0},
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "douyu_go",
|
||||
"outputfilename": "douyu_go",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"frontend:dir": "frontend",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
@@ -15,6 +15,6 @@
|
||||
"email": "yml@example.com"
|
||||
},
|
||||
"info": {
|
||||
"productVersion": "0.2.1"
|
||||
"productVersion": "0.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user