优化抢购

This commit is contained in:
yml2213
2026-06-20 05:54:25 +08:00
parent f04ba4ed64
commit 86374fdd4d
5 changed files with 172 additions and 38 deletions
+68 -30
View File
@@ -216,7 +216,7 @@ func (c *DouyuClient) Score(cookie string) (int, error) {
Balance int `json:"balance"`
} `json:"data"`
}
if err := c.doJSON(req, &result, "积分余额"); err != nil {
if _, err := c.doJSON(req, &result, "积分余额"); err != nil {
return 0, err
}
if result.Error != 0 {
@@ -245,44 +245,60 @@ func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) {
body, err := io.ReadAll(resp.Body)
if err != nil {
c.trace.record("生成CSRF", req, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "")
c.trace.recordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "")
logNetworkError("生成CSRF", req, time.Since(start), err)
return "", err
}
if resp.StatusCode != http.StatusOK {
c.trace.record("生成CSRF", req, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
c.trace.recordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
logNetworkHTTPError("生成CSRF", req, resp.StatusCode, time.Since(start), body)
return "", fmt.Errorf("CSRF HTTP %d: %s", resp.StatusCode, trimForLog(string(body), 120))
}
var api APIResponse
if err := json.Unmarshal(body, &api); err != nil {
c.trace.record("生成CSRF", req, resp.StatusCode, time.Since(start), reqBody, body, err.Error(), 0, "")
c.trace.recordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, err.Error(), 0, "")
logNetworkParseError("生成CSRF", req, time.Since(start), body, err)
return "", fmt.Errorf("CSRF 响应解析失败: %w", err)
}
traceErr := ""
if api.Error != 0 {
traceErr = api.Msg
c.trace.record("生成CSRF", req, resp.StatusCode, time.Since(start), reqBody, body, traceErr, api.Error, api.Msg)
c.trace.recordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, traceErr, api.Error, api.Msg)
logNetworkAPIResult("生成CSRF", req, time.Since(start), api.Error, api.Msg)
return "", fmt.Errorf("CSRF 接口失败: %s", api.Msg)
}
for _, ck := range resp.Cookies() {
if ck.Name == "cvl_csrf_token" && ck.Value != "" {
c.trace.record("生成CSRF", req, resp.StatusCode, time.Since(start), reqBody, body, "", api.Error, api.Msg)
c.trace.recordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", api.Error, api.Msg)
logNetworkSuccess("生成CSRF", req, time.Since(start), 0, "")
return ck.Value, nil
}
}
c.trace.record("生成CSRF", req, resp.StatusCode, time.Since(start), reqBody, body, "响应没有 cvl_csrf_token", api.Error, api.Msg)
c.trace.recordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "响应没有 cvl_csrf_token", api.Error, api.Msg)
logEvent("网络", "warn", "接口返回异常: 生成CSRF %s %s 耗时=%s msg=响应没有 cvl_csrf_token",
req.Method, requestPath(req), roundDuration(time.Since(start)))
return "", errors.New("CSRF 响应没有 cvl_csrf_token")
}
func csrfFromResponse(resp *http.Response) string {
if resp == nil {
return ""
}
for _, ck := range resp.Cookies() {
if ck.Name == "cvl_csrf_token" && ck.Value != "" {
return ck.Value
}
}
return ""
}
// BatchExchangeLimit 请求兑换限制接口,返回本次允许的批量上限。
func (c *DouyuClient) BatchExchangeLimit(cookie, commodityID, csrf string) (int, error) {
func (c *DouyuClient) BatchExchangeLimit(cookie, commodityID string, csrf *string) (int, error) {
currentCSRF := ""
if csrf != nil {
currentCSRF = *csrf
}
var result struct {
APIResponse
Data struct {
@@ -293,7 +309,7 @@ func (c *DouyuClient) BatchExchangeLimit(cookie, commodityID, csrf string) (int,
"manualID": manualID,
"commodityID": commodityID,
"rid": roomID,
"csrfToken": csrf,
"csrfToken": currentCSRF,
}, &result); err != nil {
return 0, err
}
@@ -308,14 +324,18 @@ func (c *DouyuClient) BatchExchangeLimit(cookie, commodityID, csrf string) (int,
}
// PreExchange 按抓包流程执行预兑换检查。
func (c *DouyuClient) PreExchange(cookie, commodityID, csrf string) error {
func (c *DouyuClient) PreExchange(cookie, commodityID string, csrf *string) error {
currentCSRF := ""
if csrf != nil {
currentCSRF = *csrf
}
var result APIResponse
if err := c.postMultipart(preExchangeURL, cookie, csrf, "预兑换", map[string]string{
"manualID": manualID,
"rid": roomID,
"commodityID": commodityID,
"num": "1",
"csrfToken": csrf,
"csrfToken": currentCSRF,
}, &result); err != nil {
return err
}
@@ -326,7 +346,11 @@ func (c *DouyuClient) PreExchange(cookie, commodityID, csrf string) error {
}
// Exchange 正式兑换一个商品,num 固定为 1,和浏览器抓包一致。
func (c *DouyuClient) Exchange(cookie, commodityID, csrf string) (string, error) {
func (c *DouyuClient) Exchange(cookie, commodityID string, csrf *string) (string, error) {
currentCSRF := ""
if csrf != nil {
currentCSRF = *csrf
}
var result struct {
APIResponse
Data struct {
@@ -340,7 +364,7 @@ func (c *DouyuClient) Exchange(cookie, commodityID, csrf string) (string, error)
"commodityID": commodityID,
"num": "1",
"rid": roomID,
"csrfToken": csrf,
"csrfToken": currentCSRF,
}, &result); err != nil {
return "", err
}
@@ -379,11 +403,12 @@ func (c *DouyuClient) getJSON(url, cookie, label string, out any) error {
return err
}
c.setBrowserHeaders(req, cookie)
return c.doJSON(req, out, label)
_, err = c.doJSON(req, out, label)
return err
}
// postMultipart 发送 multipart/form-data 请求并解析 JSON。
func (c *DouyuClient) postMultipart(url, cookie, csrf, label string, fields map[string]string, out any) error {
// postMultipart 发送 multipart/form-data 请求并解析 JSON,并同步响应里的 CSRF Cookie。
func (c *DouyuClient) postMultipart(url, cookie string, csrf *string, label string, fields map[string]string, out any) error {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for key, value := range fields {
@@ -399,49 +424,62 @@ func (c *DouyuClient) postMultipart(url, cookie, csrf, label string, fields map[
if err != nil {
return err
}
c.setBrowserHeaders(req, mergeCookie(cookie, "cvl_csrf_token", csrf))
currentCSRF := ""
if csrf != nil {
currentCSRF = *csrf
}
c.setBrowserHeaders(req, mergeCookie(cookie, "cvl_csrf_token", currentCSRF))
req.Header.Set("Content-Type", writer.FormDataContentType())
return c.doJSON(req, out, label)
resp, err := c.doJSON(req, out, label)
if resp != nil {
if nextCSRF := csrfFromResponse(resp); nextCSRF != "" && csrf != nil {
if nextCSRF != *csrf {
logEvent("网络", "info", "响应刷新CSRF已同步: %s %s %s", label, req.Method, requestPath(req))
}
*csrf = nextCSRF
}
}
return err
}
// doJSON 执行请求、检查 HTTP 状态码并解析 JSON。
func (c *DouyuClient) doJSON(req *http.Request, out any, label string) error {
func (c *DouyuClient) doJSON(req *http.Request, out any, label string) (*http.Response, error) {
start := time.Now()
reqBody := traceRequestBody(c.trace, req)
resp, err := c.httpClient.Do(req)
if err != nil {
c.trace.record(label, req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "")
logNetworkError(label, req, time.Since(start), err)
return err
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
c.trace.record(label, req, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "")
c.trace.recordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "")
logNetworkError(label, req, time.Since(start), err)
return err
return resp, err
}
if resp.StatusCode != http.StatusOK {
c.trace.record(label, req, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
c.trace.recordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
logNetworkHTTPError(label, req, resp.StatusCode, time.Since(start), body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, trimForLog(string(body), 160))
return resp, fmt.Errorf("HTTP %d: %s", resp.StatusCode, trimForLog(string(body), 160))
}
if err := json.Unmarshal(body, out); err != nil {
c.trace.record(label, req, resp.StatusCode, time.Since(start), reqBody, body, err.Error(), 0, "")
c.trace.recordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, err.Error(), 0, "")
logNetworkParseError(label, req, time.Since(start), body, err)
return fmt.Errorf("JSON 解析失败: %w, body=%s", err, trimForLog(string(body), 160))
return resp, fmt.Errorf("JSON 解析失败: %w, body=%s", err, trimForLog(string(body), 160))
}
if apiError, apiMsg, ok := extractAPIStatus(out); ok && apiError != 0 {
c.trace.record(label, req, resp.StatusCode, time.Since(start), reqBody, body, apiMsg, apiError, apiMsg)
c.trace.recordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, apiMsg, apiError, apiMsg)
logNetworkAPIResult(label, req, time.Since(start), apiError, apiMsg)
} else if shouldLogNetworkSuccess(label) {
c.trace.record(label, req, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
c.trace.recordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
logNetworkSuccess(label, req, time.Since(start), 0, "")
} else {
c.trace.record(label, req, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
c.trace.recordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
}
return nil
return resp, nil
}
func shouldLogNetworkSuccess(label string) bool {
+4
View File
@@ -91,6 +91,10 @@ func (s *appState) handleSeckill(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, errors.New("请选择账号和商品"))
return
}
if !s.tryStartRun() {
writeError(w, http.StatusConflict, errors.New("抢购任务正在运行,请勿重复提交"))
return
}
go s.runNow(cloneStringMap(req.AccountProducts))
writeJSON(w, map[string]any{"success": true, "msg": "抢购已启动"})
}
+25 -4
View File
@@ -18,6 +18,7 @@ const (
// runNow 立即执行一批抢购任务。
func (s *appState) runNow(accountProducts map[string]string) {
defer s.finishRun()
s.clearResults()
attempts := s.buildAttempts(accountProducts)
if len(attempts) == 0 {
@@ -50,6 +51,22 @@ func (s *appState) runNow(accountProducts map[string]string) {
logEvent("抢购", "info", "抢购任务结束")
}
func (s *appState) tryStartRun() bool {
s.runMu.Lock()
defer s.runMu.Unlock()
if s.running {
return false
}
s.running = true
return true
}
func (s *appState) finishRun() {
s.runMu.Lock()
defer s.runMu.Unlock()
s.running = false
}
// buildAttempts 根据前端传入的账号到商品映射,生成具体兑换尝试。
func (s *appState) buildAttempts(accountProducts map[string]string) []ExchangeAttempt {
accounts := s.accountSnapshot()
@@ -186,7 +203,7 @@ func (s *appState) stopSchedulesLocked() {
}
// runAttempt 按抓包链路执行一次兑换:CSRF -> 限制 -> 预兑换 -> 正式兑换。
// 同一次抢购尝试只生成一个 CSRF,后续接口共用,减少临门请求耗时。
// 同一次抢购尝试只生成一次 CSRF;如果后续响应刷新 token,则沿链路继续复用最新值。
func (s *appState) runAttempt(attempt ExchangeAttempt) ExchangeResult {
result := ExchangeResult{
Account: attempt.Account.Name,
@@ -212,7 +229,7 @@ func (s *appState) runAttempt(attempt ExchangeAttempt) ExchangeResult {
logEvent("抢购", "info", "CSRF已生成,本次兑换链路复用同一个 token: 账号=%s 商品=%s", attempt.Account.Name, attempt.Product.CommodityName)
}
limit, err := s.client.BatchExchangeLimit(attempt.Account.Cookie, attempt.Product.CommodityID, csrf)
limit, err := s.client.BatchExchangeLimit(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf)
if err != nil {
result.Message = "失败|批量限制检查失败: " + err.Error()
return result
@@ -223,12 +240,12 @@ func (s *appState) runAttempt(attempt ExchangeAttempt) ExchangeResult {
}
logEvent("抢购", "info", "批量限制通过: 账号=%s 商品=%s limit=%d", attempt.Account.Name, attempt.Product.CommodityName, limit)
if err := s.client.PreExchange(attempt.Account.Cookie, attempt.Product.CommodityID, csrf); err != nil {
if err := s.client.PreExchange(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf); err != nil {
result.Message = "失败|预兑换失败: " + err.Error()
return result
}
exchangeInfo, err := s.client.Exchange(attempt.Account.Cookie, attempt.Product.CommodityID, csrf)
exchangeInfo, err := s.client.Exchange(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf)
if err != nil {
result.Message = "失败|" + err.Error()
return result
@@ -352,6 +369,10 @@ func (s *appState) scheduleTimers(accountProducts map[string]string, attempts []
estimatedServer.In(cst).Format("15:04:05.000"),
calibration.Offset.Milliseconds(),
calibration.ErrorBound.Milliseconds())
if !s.tryStartRun() {
logEvent("抢购", "warn", "已有抢购任务正在运行,本次定时触发已跳过")
return
}
s.runNow(accountProducts)
})
if resync {
+72 -4
View File
@@ -42,9 +42,9 @@ func initTrace(logDir string, cfg TraceConfig) (*traceRecorder, func()) {
filePath: path,
}
if cfg.MaxBodyBytes > 0 {
logEvent("Trace", "warn", "trace 已启用: %s,单段正文最多保留 %d 字节,不做脱敏", path, cfg.MaxBodyBytes)
logEvent("Trace", "warn", "trace 已启用: %s,单段正文最多保留 %d 字节;请求头不脱敏,响应 Set-Cookie 隐藏值", path, cfg.MaxBodyBytes)
} else {
logEvent("Trace", "warn", "trace 已启用: %s,全量保留请求和响应,不做脱敏", path)
logEvent("Trace", "warn", "trace 已启用: %s,全量保留请求和响应;请求头不脱敏,响应 Set-Cookie 隐藏值", path)
}
return recorder, func() {
_ = recorder.Close()
@@ -60,6 +60,10 @@ func (t *traceRecorder) Close() error {
}
func (t *traceRecorder) record(label string, req *http.Request, status int, cost time.Duration, reqBody, respBody []byte, errText string, apiError int, apiMsg string) {
t.recordResponse(label, req, nil, status, cost, reqBody, respBody, errText, apiError, apiMsg)
}
func (t *traceRecorder) recordResponse(label string, req *http.Request, resp *http.Response, status int, cost time.Duration, reqBody, respBody []byte, errText string, apiError int, apiMsg string) {
if t == nil || !t.enabled || req == nil || req.URL == nil {
return
}
@@ -76,6 +80,7 @@ func (t *traceRecorder) record(label string, req *http.Request, status int, cost
Status: status,
Cost: cost,
ReqHeader: sanitizeHeader(req.Header),
RespHeader: sanitizeResponseHeader(resp),
ReqBody: sanitizeBodyForTrace(reqBody, req.Header.Get("Content-Type"), t.maxBodyBytes),
RespBody: sanitizeResponseBody(respBody, t.maxBodyBytes),
APIError: apiError,
@@ -114,6 +119,66 @@ func sanitizeHeader(header http.Header) map[string][]string {
return out
}
func sanitizeResponseHeader(resp *http.Response) map[string][]string {
if resp == nil {
return nil
}
out := sanitizeHeader(resp.Header)
if len(out) == 0 {
return out
}
if _, ok := out["Set-Cookie"]; ok {
out["Set-Cookie"] = summarizeSetCookies(resp)
}
return out
}
func summarizeSetCookies(resp *http.Response) []string {
if resp == nil {
return nil
}
cookies := resp.Cookies()
if len(cookies) == 0 {
values := resp.Header.Values("Set-Cookie")
summary := make([]string, 0, len(values))
for range values {
summary = append(summary, "<hidden>")
}
return summary
}
summary := make([]string, 0, len(cookies))
for _, ck := range cookies {
if ck == nil || ck.Name == "" {
continue
}
parts := []string{ck.Name + "=<hidden>"}
if ck.Path != "" {
parts = append(parts, "Path="+ck.Path)
}
if ck.Domain != "" {
parts = append(parts, "Domain="+ck.Domain)
}
if ck.MaxAge != 0 {
parts = append(parts, fmt.Sprintf("Max-Age=%d", ck.MaxAge))
}
if !ck.Expires.IsZero() {
parts = append(parts, "Expires="+ck.Expires.In(cst).Format(time.RFC3339))
}
if ck.HttpOnly {
parts = append(parts, "HttpOnly")
}
if ck.Secure {
parts = append(parts, "Secure")
}
summary = append(summary, strings.Join(parts, "; "))
}
if len(summary) == 0 {
return []string{"<hidden>"}
}
sort.Strings(summary)
return summary
}
func sanitizeBodyForTrace(body []byte, contentType string, max int) string {
if len(body) == 0 {
return ""
@@ -192,6 +257,7 @@ type traceEntry struct {
Status int
Cost time.Duration
ReqHeader map[string][]string
RespHeader map[string][]string
ReqBody string
RespBody string
APIError int
@@ -220,6 +286,8 @@ func formatTraceEntry(e traceEntry) string {
if e.ErrorText != "" {
fmt.Fprintf(&b, "错误: %s\n", e.ErrorText)
}
b.WriteString("\n[响应头]\n")
b.WriteString(formatTraceHeaders(e.RespHeader))
b.WriteString("\n[请求头]\n")
b.WriteString(formatTraceHeaders(e.ReqHeader))
b.WriteString("\n[请求体]\n")
@@ -227,9 +295,9 @@ func formatTraceEntry(e traceEntry) string {
b.WriteString("\n[响应体]\n")
b.WriteString(formatTraceBody(e.RespBody))
if e.MaxBodyBytes > 0 {
fmt.Fprintf(&b, "\n提示: 单段正文最多保留 %d 字节;trace 不做脱敏。\n", e.MaxBodyBytes)
fmt.Fprintf(&b, "\n提示: 单段正文最多保留 %d 字节;请求头不脱敏,响应 Set-Cookie 隐藏值。\n", e.MaxBodyBytes)
} else {
b.WriteString("\n提示: trace 全量保留请求和响应,不做脱敏。\n")
b.WriteString("\n提示: trace 全量保留请求和响应;请求头不脱敏,响应 Set-Cookie 隐藏值。\n")
}
b.WriteByte('\n')
return b.String()
+3
View File
@@ -100,6 +100,9 @@ type appState struct {
resultsMu sync.RWMutex
results map[string]string
runMu sync.Mutex
running bool
scheduleMu sync.Mutex
schedule *time.Timer
warmupSchedule *time.Timer