diff --git a/frontend/src/components/modal/SettingsModal.vue b/frontend/src/components/modal/SettingsModal.vue index 033751f..4910770 100644 --- a/frontend/src/components/modal/SettingsModal.vue +++ b/frontend/src/components/modal/SettingsModal.vue @@ -169,15 +169,15 @@ defineExpose({ open, close })
diff --git a/go.mod b/go.mod index 50d1858..d2550dc 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 9ecd788..9a31507 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/activity/activity.go b/internal/activity/activity.go index bb7a300..cdfe624 100644 --- a/internal/activity/activity.go +++ b/internal/activity/activity.go @@ -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", diff --git a/internal/client/client.go b/internal/client/client.go index b4f5a15..2ac0fbc 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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 { diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 272e0cc..d713065 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -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{ diff --git a/internal/constants/constants.go b/internal/constants/constants.go index acc9b51..545eba7 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -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" diff --git a/internal/seckill/attempt.go b/internal/seckill/attempt.go index b5cadb1..b35467a 100644 --- a/internal/seckill/attempt.go +++ b/internal/seckill/attempt.go @@ -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 { diff --git a/internal/seckill/schedule_timer.go b/internal/seckill/schedule_timer.go index be551f2..1d3fb13 100644 --- a/internal/seckill/schedule_timer.go +++ b/internal/seckill/schedule_timer.go @@ -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) }) diff --git a/internal/seckill/scheduler.go b/internal/seckill/scheduler.go index 1f1bcbc..79b0808 100644 --- a/internal/seckill/scheduler.go +++ b/internal/seckill/scheduler.go @@ -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) { diff --git a/internal/seckill/scheduler_test.go b/internal/seckill/scheduler_test.go index 9c645f3..7cc3069 100644 --- a/internal/seckill/scheduler_test.go +++ b/internal/seckill/scheduler_test.go @@ -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}, } diff --git a/wails.json b/wails.json index 03d47e6..9b394a8 100644 --- a/wails.json +++ b/wails.json @@ -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" } }