初步优化成全部go+wails
This commit is contained in:
@@ -0,0 +1,585 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"douyu_go/internal/constants"
|
||||
"douyu_go/internal/logger"
|
||||
"douyu_go/internal/trace"
|
||||
"douyu_go/internal/types"
|
||||
"douyu_go/internal/utils"
|
||||
)
|
||||
|
||||
// DouyuClient 斗鱼接口客户端。
|
||||
type DouyuClient struct {
|
||||
HTTPClient *http.Client
|
||||
TimeAPIURL string
|
||||
Trace *trace.Recorder
|
||||
}
|
||||
|
||||
// New 创建斗鱼客户端。
|
||||
func New(timeAPIURL string, tr *trace.Recorder) *DouyuClient {
|
||||
return &DouyuClient{
|
||||
TimeAPIURL: timeAPIURL,
|
||||
Trace: tr,
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: 8 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxConnsPerHost: 120,
|
||||
MaxIdleConns: 120,
|
||||
MaxIdleConnsPerHost: 80,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ServerTime 从商品详情接口读取 data.preExchange.currentTime。
|
||||
func (c *DouyuClient) ServerTime() (time.Time, error) {
|
||||
sample, err := c.ServerTimeSample()
|
||||
return sample.EstimatedServerNow(), err
|
||||
}
|
||||
|
||||
// ServerTimeSample 从斗鱼秒级 currentTime 估算毫秒级服务端时间。
|
||||
func (c *DouyuClient) ServerTimeSample() (types.TimeSample, error) {
|
||||
var lastErr error
|
||||
for i := 0; i < 3; i++ {
|
||||
sample, err := c.fetchServerTimeOnce()
|
||||
if err == nil {
|
||||
sample.Samples = 1
|
||||
sample.Source = "single"
|
||||
return sample, nil
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
now := time.Now()
|
||||
return types.TimeSample{
|
||||
ServerTime: now.UTC(),
|
||||
LocalTime: now,
|
||||
Offset: 0,
|
||||
Source: "localFallback",
|
||||
Fallback: true,
|
||||
}, fmt.Errorf("服务端时间获取失败,已降级本地时间: %w", lastErr)
|
||||
}
|
||||
|
||||
// CalibratedServerTimeSample 通过等待 currentTime 跳秒估算本地与斗鱼秒边界的偏移。
|
||||
func (c *DouyuClient) CalibratedServerTimeSample(maxWait time.Duration) (types.TimeSample, error) {
|
||||
first, err := c.ServerTimeSample()
|
||||
if err != nil || maxWait <= 0 {
|
||||
return first, err
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(maxWait)
|
||||
prev := first
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
current, err := c.fetchServerTimeOnce()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if current.RawCurrentTime <= prev.RawCurrentTime {
|
||||
prev = current
|
||||
continue
|
||||
}
|
||||
|
||||
sampleGap := current.LocalTime.Sub(prev.LocalTime)
|
||||
localBoundary := prev.LocalTime.Add(sampleGap / 2)
|
||||
serverBoundary := time.Unix(current.RawCurrentTime, 0).UTC()
|
||||
offset := serverBoundary.Sub(localBoundary.UTC())
|
||||
current.Offset = offset
|
||||
current.ServerTime = current.EstimatedServerNow()
|
||||
current.LocalTime = time.Now()
|
||||
current.RTT += first.RTT
|
||||
current.ErrorBound = sampleGap / 2
|
||||
current.Samples = 2
|
||||
current.Source = "boundary"
|
||||
return current, nil
|
||||
}
|
||||
|
||||
first.ServerTime = first.EstimatedServerNow()
|
||||
return first, nil
|
||||
}
|
||||
|
||||
// BestServerTimeCalibration 多次校准,选择误差范围最小、RTT更低的一次。
|
||||
func (c *DouyuClient) BestServerTimeCalibration(rounds int, maxWait time.Duration) (types.TimeCalibration, error) {
|
||||
if rounds <= 0 {
|
||||
rounds = 1
|
||||
}
|
||||
var best types.TimeSample
|
||||
var lastErr error
|
||||
for i := 0; i < rounds; i++ {
|
||||
sample, err := c.CalibratedServerTimeSample(maxWait)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
if sample.LocalTime.IsZero() {
|
||||
continue
|
||||
}
|
||||
sample.Samples = max(sample.Samples, 1)
|
||||
if best.LocalTime.IsZero() || betterTimeSample(sample, best) {
|
||||
best = sample
|
||||
}
|
||||
}
|
||||
if best.LocalTime.IsZero() {
|
||||
now := time.Now()
|
||||
return types.TimeCalibration{
|
||||
CreatedAt: now,
|
||||
Source: "localFallback",
|
||||
Fallback: true,
|
||||
}, fmt.Errorf("斗鱼时间校准失败: %w", lastErr)
|
||||
}
|
||||
return types.TimeCalibration{
|
||||
Offset: best.Offset,
|
||||
ErrorBound: best.ErrorBound,
|
||||
RTT: best.RTT,
|
||||
Samples: rounds,
|
||||
CreatedAt: time.Now(),
|
||||
Source: best.Source,
|
||||
Fallback: best.Fallback,
|
||||
}, lastErr
|
||||
}
|
||||
|
||||
func betterTimeSample(candidate, current types.TimeSample) bool {
|
||||
if candidate.ErrorBound != 0 && current.ErrorBound == 0 {
|
||||
return true
|
||||
}
|
||||
if candidate.ErrorBound == 0 && current.ErrorBound != 0 {
|
||||
return false
|
||||
}
|
||||
if candidate.ErrorBound != current.ErrorBound {
|
||||
return candidate.ErrorBound < current.ErrorBound
|
||||
}
|
||||
return candidate.RTT < current.RTT
|
||||
}
|
||||
|
||||
func (c *DouyuClient) fetchServerTimeOnce() (types.TimeSample, error) {
|
||||
url := utils.AppendCacheBuster(c.TimeAPIURL)
|
||||
var result struct {
|
||||
types.APIResponse
|
||||
Data struct {
|
||||
PreExchange struct {
|
||||
CurrentTime string `json:"currentTime"`
|
||||
} `json:"preExchange"`
|
||||
} `json:"data"`
|
||||
}
|
||||
start := time.Now()
|
||||
if err := c.getJSON(url, "", "服务时间", &result); err != nil {
|
||||
return types.TimeSample{}, err
|
||||
}
|
||||
end := time.Now()
|
||||
ts, err := strconv.ParseInt(result.Data.PreExchange.CurrentTime, 10, 64)
|
||||
if err != nil || ts <= 0 {
|
||||
return types.TimeSample{}, fmt.Errorf("currentTime 无效: %q", result.Data.PreExchange.CurrentTime)
|
||||
}
|
||||
rtt := end.Sub(start)
|
||||
|
||||
estimatedLocal := start.Add(rtt / 2)
|
||||
serverTime := time.Unix(ts, 0).Add(time.Duration(estimatedLocal.Nanosecond())).UTC()
|
||||
return types.TimeSample{
|
||||
ServerTime: serverTime,
|
||||
LocalTime: estimatedLocal,
|
||||
RTT: rtt,
|
||||
Offset: serverTime.Sub(estimatedLocal.UTC()),
|
||||
RawCurrentTime: ts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Products 拉取商城商品列表。
|
||||
func (c *DouyuClient) Products() ([]types.Product, error) {
|
||||
var result struct {
|
||||
types.APIResponse
|
||||
Data struct {
|
||||
List []types.Product `json:"list"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := c.getJSON(constants.StoreHomeURL, "", "商品列表", &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Error != 0 {
|
||||
return nil, fmt.Errorf("商品接口失败: %s", result.Msg)
|
||||
}
|
||||
return result.Data.List, nil
|
||||
}
|
||||
|
||||
// Score 查询账号积分余额。
|
||||
func (c *DouyuClient) Score(cookie string) (int, error) {
|
||||
req, err := http.NewRequest("POST", constants.ScoreURL, strings.NewReader(`{}`))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
c.setJSONHeaders(req, cookie)
|
||||
req.Header.Set("Referer", constants.BaseURL+"/")
|
||||
|
||||
var result struct {
|
||||
types.APIResponse
|
||||
Data struct {
|
||||
Balance int `json:"balance"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if _, err := c.doJSON(req, &result, "积分余额"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if result.Error != 0 {
|
||||
return 0, fmt.Errorf("积分接口失败: %s", result.Msg)
|
||||
}
|
||||
return result.Data.Balance, nil
|
||||
}
|
||||
|
||||
// GenerateCSRF 调用 generateCsrf,并从 Set-Cookie 中取 cvl_csrf_token。
|
||||
func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) {
|
||||
req, err := http.NewRequest("POST", constants.CSRFURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c.setBrowserHeaders(req, cookie)
|
||||
|
||||
start := time.Now()
|
||||
reqBody := c.Trace.RequestBody(req)
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
c.Trace.Record("生成CSRF", req, 0, time.Since(start), reqBody, nil, err.Error(), 0, "")
|
||||
logNetworkError("生成CSRF", req, time.Since(start), err)
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
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.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, utils.TrimForLog(string(body), 120))
|
||||
}
|
||||
|
||||
var api types.APIResponse
|
||||
if err := json.Unmarshal(body, &api); err != nil {
|
||||
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.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.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.RecordResponse("生成CSRF", req, resp, resp.StatusCode, time.Since(start), reqBody, body, "响应没有 cvl_csrf_token", api.Error, api.Msg)
|
||||
logger.LogEvent("网络", "warn", "接口返回异常: 生成CSRF %s %s 耗时=%s msg=响应没有 cvl_csrf_token",
|
||||
req.Method, requestPath(req), utils.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 string, csrf *string) (int, error) {
|
||||
currentCSRF := ""
|
||||
if csrf != nil {
|
||||
currentCSRF = *csrf
|
||||
}
|
||||
var result struct {
|
||||
types.APIResponse
|
||||
Data struct {
|
||||
Limit string `json:"limit"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := c.postMultipart(constants.BatchLimitURL, cookie, csrf, "批量限制", map[string]string{
|
||||
"manualID": constants.ManualID,
|
||||
"commodityID": commodityID,
|
||||
"rid": constants.RoomID,
|
||||
"csrfToken": currentCSRF,
|
||||
}, &result); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if result.Error != 0 {
|
||||
return 0, fmt.Errorf("批量限制接口失败: %s", result.Msg)
|
||||
}
|
||||
limit, err := strconv.Atoi(result.Data.Limit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("批量限制值无效: %q", result.Data.Limit)
|
||||
}
|
||||
return limit, nil
|
||||
}
|
||||
|
||||
// PreExchange 按抓包流程执行预兑换检查。
|
||||
func (c *DouyuClient) PreExchange(cookie, commodityID string, csrf *string) error {
|
||||
currentCSRF := ""
|
||||
if csrf != nil {
|
||||
currentCSRF = *csrf
|
||||
}
|
||||
var result types.APIResponse
|
||||
if err := c.postMultipart(constants.PreExchangeURL, cookie, csrf, "预兑换", map[string]string{
|
||||
"manualID": constants.ManualID,
|
||||
"rid": constants.RoomID,
|
||||
"commodityID": commodityID,
|
||||
"num": "1",
|
||||
"csrfToken": currentCSRF,
|
||||
}, &result); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Error != 0 {
|
||||
return fmt.Errorf("预兑换失败: %s", result.Msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exchange 正式兑换一个商品。
|
||||
func (c *DouyuClient) Exchange(cookie, commodityID string, csrf *string) (string, error) {
|
||||
currentCSRF := ""
|
||||
if csrf != nil {
|
||||
currentCSRF = *csrf
|
||||
}
|
||||
var result struct {
|
||||
types.APIResponse
|
||||
Data struct {
|
||||
ExchangeID int `json:"exchangeId"`
|
||||
ExchangeNum string `json:"exchangeNum"`
|
||||
CommodityTyp int `json:"commodityType"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := c.postMultipart(constants.ExchangeURL, cookie, csrf, "正式兑换", map[string]string{
|
||||
"manualID": constants.ManualID,
|
||||
"commodityID": commodityID,
|
||||
"num": "1",
|
||||
"rid": constants.RoomID,
|
||||
"csrfToken": currentCSRF,
|
||||
}, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Error != 0 {
|
||||
if strings.TrimSpace(result.Msg) == "" {
|
||||
return "", errors.New("兑换失败,接口未返回原因")
|
||||
}
|
||||
return "", fmt.Errorf("兑换失败: %s", result.Msg)
|
||||
}
|
||||
return fmt.Sprintf("exchangeId=%d,num=%s", result.Data.ExchangeID, result.Data.ExchangeNum), nil
|
||||
}
|
||||
|
||||
// RoleName 查询绑定角色名。
|
||||
func (c *DouyuClient) RoleName(cookie string) (string, error) {
|
||||
var result struct {
|
||||
types.APIResponse
|
||||
Data struct {
|
||||
GameRole struct {
|
||||
RoleName string `json:"roleName"`
|
||||
} `json:"gameRole"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := c.getJSON(constants.BindInfoURL, cookie, "角色绑定", &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Error != 0 {
|
||||
return "", fmt.Errorf("角色接口失败: %s", result.Msg)
|
||||
}
|
||||
return result.Data.GameRole.RoleName, nil
|
||||
}
|
||||
|
||||
func (c *DouyuClient) getJSON(url, cookie, label string, out any) error {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.setBrowserHeaders(req, cookie)
|
||||
_, err = c.doJSON(req, out, label)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *DouyuClient) postMultipart(url, cookie string, csrf *string, label string, fields map[string]string, out any) error {
|
||||
var body strings.Builder
|
||||
writer := multipart.NewWriter(&body)
|
||||
for key, value := range fields {
|
||||
if err := writer.WriteField(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(body.String()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentCSRF := ""
|
||||
if csrf != nil {
|
||||
currentCSRF = *csrf
|
||||
}
|
||||
c.setBrowserHeaders(req, utils.MergeCookie(cookie, "cvl_csrf_token", currentCSRF))
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
resp, err := c.doJSON(req, out, label)
|
||||
if resp != nil {
|
||||
if nextCSRF := csrfFromResponse(resp); nextCSRF != "" && csrf != nil {
|
||||
if nextCSRF != *csrf {
|
||||
logger.LogEvent("网络", "info", "响应刷新CSRF已同步: %s %s %s", label, req.Method, requestPath(req))
|
||||
}
|
||||
*csrf = nextCSRF
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *DouyuClient) doJSON(req *http.Request, out any, label string) (*http.Response, error) {
|
||||
start := time.Now()
|
||||
reqBody := c.Trace.RequestBody(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 nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, nil, err.Error(), 0, "")
|
||||
logNetworkError(label, req, time.Since(start), err)
|
||||
return resp, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
|
||||
logNetworkHTTPError(label, req, resp.StatusCode, time.Since(start), body)
|
||||
return resp, fmt.Errorf("HTTP %d: %s", resp.StatusCode, utils.TrimForLog(string(body), 160))
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
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 resp, fmt.Errorf("JSON 解析失败: %w, body=%s", err, utils.TrimForLog(string(body), 160))
|
||||
}
|
||||
if apiError, apiMsg, ok := extractAPIStatus(out); ok && apiError != 0 {
|
||||
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.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
|
||||
logNetworkSuccess(label, req, time.Since(start), 0, "")
|
||||
} else {
|
||||
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, body, "", 0, "")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func shouldLogNetworkSuccess(label string) bool {
|
||||
return label != "服务时间"
|
||||
}
|
||||
|
||||
func logNetworkSuccess(label string, req *http.Request, cost time.Duration, apiError int, apiMsg string) {
|
||||
if !shouldLogNetworkSuccess(label) {
|
||||
return
|
||||
}
|
||||
extra := ""
|
||||
if apiMsg != "" || apiError != 0 {
|
||||
extra = fmt.Sprintf(" apiError=%d msg=%s", apiError, utils.TrimForLog(apiMsg, 80))
|
||||
}
|
||||
logger.LogEvent("网络", "info", "请求完成: %s %s %s 耗时=%s%s", label, req.Method, requestPath(req), utils.RoundDuration(cost), extra)
|
||||
}
|
||||
|
||||
func logNetworkAPIResult(label string, req *http.Request, cost time.Duration, apiError int, apiMsg string) {
|
||||
logger.LogEvent("网络", "warn", "接口返回异常: %s %s %s 耗时=%s apiError=%d msg=%s",
|
||||
label, req.Method, requestPath(req), utils.RoundDuration(cost), apiError, utils.TrimForLog(apiMsg, 120))
|
||||
}
|
||||
|
||||
func logNetworkHTTPError(label string, req *http.Request, status int, cost time.Duration, body []byte) {
|
||||
logger.LogEvent("网络", "error", "HTTP异常: %s %s %s 状态=%d 耗时=%s 响应=%s",
|
||||
label, req.Method, requestPath(req), status, utils.RoundDuration(cost), utils.TrimForLog(string(body), 160))
|
||||
}
|
||||
|
||||
func logNetworkParseError(label string, req *http.Request, cost time.Duration, body []byte, err error) {
|
||||
logger.LogEvent("网络", "error", "响应解析失败: %s %s %s 耗时=%s 错误=%v 响应=%s",
|
||||
label, req.Method, requestPath(req), utils.RoundDuration(cost), err, utils.TrimForLog(string(body), 160))
|
||||
}
|
||||
|
||||
func logNetworkError(label string, req *http.Request, cost time.Duration, err error) {
|
||||
logger.LogEvent("网络", "error", "请求失败: %s %s %s 耗时=%s 错误=%v",
|
||||
label, req.Method, requestPath(req), utils.RoundDuration(cost), err)
|
||||
}
|
||||
|
||||
func requestPath(req *http.Request) string {
|
||||
if req == nil || req.URL == nil {
|
||||
return "-"
|
||||
}
|
||||
return req.URL.Path
|
||||
}
|
||||
|
||||
func extractAPIStatus(out any) (int, string, bool) {
|
||||
if out == nil {
|
||||
return 0, "", false
|
||||
}
|
||||
v := reflect.ValueOf(out)
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return 0, "", false
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
if !v.IsValid() {
|
||||
return 0, "", false
|
||||
}
|
||||
if v.Type() == reflect.TypeOf(types.APIResponse{}) {
|
||||
api := v.Interface().(types.APIResponse)
|
||||
return api.Error, api.Msg, true
|
||||
}
|
||||
if v.Kind() != reflect.Struct {
|
||||
return 0, "", false
|
||||
}
|
||||
if field := v.FieldByName("APIResponse"); field.IsValid() && field.CanInterface() {
|
||||
if api, ok := field.Interface().(types.APIResponse); ok {
|
||||
return api.Error, api.Msg, true
|
||||
}
|
||||
}
|
||||
errorField := v.FieldByName("Error")
|
||||
msgField := v.FieldByName("Msg")
|
||||
if errorField.IsValid() && msgField.IsValid() && errorField.CanInt() && msgField.Kind() == reflect.String {
|
||||
return int(errorField.Int()), msgField.String(), true
|
||||
}
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
func (c *DouyuClient) setBrowserHeaders(req *http.Request, cookie string) {
|
||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5")
|
||||
req.Header.Set("User-Agent", constants.UserAgent)
|
||||
req.Header.Set("Origin", constants.BaseURL)
|
||||
req.Header.Set("Referer", constants.ShopReferer)
|
||||
req.Header.Set("Sec-Fetch-Dest", "empty")
|
||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
if strings.TrimSpace(cookie) != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DouyuClient) setJSONHeaders(req *http.Request, cookie string) {
|
||||
c.setBrowserHeaders(req, cookie)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
||||
"douyu_go/internal/constants"
|
||||
"douyu_go/internal/logger"
|
||||
"douyu_go/internal/types"
|
||||
)
|
||||
|
||||
// DefaultAppPaths 收集运行时路径,支持环境变量覆盖。
|
||||
func DefaultAppPaths() types.AppPaths {
|
||||
return types.AppPaths{
|
||||
ConfigFile: envOrDefault("DOUYU_CONFIG", constants.DefaultConfigFile),
|
||||
AccountFile: envOrDefault("DOUYU_USERS", constants.DefaultAccountFile),
|
||||
LogDir: envOrDefault("DOUYU_LOG_DIR", constants.DefaultLogDir),
|
||||
SuccessDir: envOrDefault("DOUYU_SUCCESS_DIR", constants.DefaultSuccessDir),
|
||||
FontFiles: CollectFontCandidates(),
|
||||
}
|
||||
}
|
||||
|
||||
func envOrDefault(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return filepath.Clean(value)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// LoadTraceConfig 读取 trace 开关;默认关闭,避免保存敏感调试数据。
|
||||
func LoadTraceConfig() types.TraceConfig {
|
||||
return types.TraceConfig{
|
||||
Enabled: isTruthyEnv("DOUYU_TRACE"),
|
||||
MaxBodyBytes: envInt("DOUYU_TRACE_MAX_BODY", 0),
|
||||
}
|
||||
}
|
||||
|
||||
func isTruthyEnv(key string) bool {
|
||||
switch os.Getenv(key) {
|
||||
case "1", "true", "TRUE", "yes", "YES", "on", "ON":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
raw := os.Getenv(key)
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// LoadConfig 读取配置文件;缺失时安静使用内置默认配置。
|
||||
func LoadConfig(path string) types.Config {
|
||||
cfg := types.Config{TimeAPIURL: constants.DefaultTimeAPI}
|
||||
if path == "" {
|
||||
path = constants.DefaultConfigFile
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
logger.LogEvent("配置", "info", "配置加载完成: 内置默认配置")
|
||||
} else {
|
||||
logger.LogEvent("配置", "warn", "读取 %s 出错,使用内置默认配置: %v", path, err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err := json.NewDecoder(f).Decode(&cfg); err != nil {
|
||||
logger.LogEvent("配置", "warn", "解析 %s 失败,使用内置默认配置: %v", path, err)
|
||||
return types.Config{TimeAPIURL: constants.DefaultTimeAPI}
|
||||
}
|
||||
if cfg.TimeAPIURL == "" {
|
||||
cfg.TimeAPIURL = constants.DefaultTimeAPI
|
||||
}
|
||||
logger.LogEvent("配置", "success", "配置加载完成: %s", path)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// CollectFontCandidates 收集常见中文字体路径,允许用 DOUYU_FONT 指定自定义字体。
|
||||
func CollectFontCandidates() []string {
|
||||
candidates := []string{}
|
||||
if custom := os.Getenv("DOUYU_FONT"); custom != "" {
|
||||
candidates = append(candidates, custom)
|
||||
}
|
||||
candidates = append(candidates,
|
||||
"Microsoft Yahei.ttf",
|
||||
"Microsoft YaHei.ttf",
|
||||
"NotoSansCJKsc-Regular.otf",
|
||||
"NotoSansCJK-Regular.ttc",
|
||||
)
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
candidates = append(candidates,
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Songti.ttc",
|
||||
)
|
||||
case "windows":
|
||||
candidates = append(candidates,
|
||||
`C:\Windows\Fonts\msyh.ttc`,
|
||||
`C:\Windows\Fonts\simhei.ttf`,
|
||||
`C:\Windows\Fonts\simsun.ttc`,
|
||||
)
|
||||
default:
|
||||
candidates = append(candidates,
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.otf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package constants
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
ManualID = "G4KA4Qnz4LDp7"
|
||||
RoomID = "9263298"
|
||||
ActAlias = "20260120QYOOB"
|
||||
|
||||
BaseURL = "https://www.douyu.com"
|
||||
ShopReferer = BaseURL + "/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=" + RoomID
|
||||
DefaultTimeAPI = BaseURL + "/wgapi/ordnc/activity/peace/storedetail?manualID=" + ManualID + "&commodityID=20260120QYOOB_bag274&rid=" + RoomID
|
||||
StoreHomeURL = BaseURL + "/wgapi/ordnc/activity/peace/storehome?manualID=" + ManualID + "&rid=" + RoomID
|
||||
CSRFURL = BaseURL + "/japi/carnival/nc/common/generateCsrf"
|
||||
BatchLimitURL = BaseURL + "/wgapi/ordnc/activity/peace/batchExchangeLimit"
|
||||
PreExchangeURL = BaseURL + "/wgapi/ordnc/activity/peace/preExchange"
|
||||
ExchangeURL = BaseURL + "/wgapi/ordnc/activity/peace/exchange"
|
||||
BindInfoURL = BaseURL + "/japi/carnivalApi/v2/tencent/bindInfo?actAlias=" + ActAlias
|
||||
ScoreURL = BaseURL + "/japi/oms/web/peace/credit/balance"
|
||||
UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
|
||||
DefaultAttempts = 1
|
||||
DefaultConcurrency = 80
|
||||
ProductCacheTTL = 30 * time.Second
|
||||
|
||||
DefaultConfigFile = "config.json"
|
||||
DefaultAccountFile = "data/user.txt"
|
||||
DefaultLogDir = "logs"
|
||||
DefaultSuccessDir = "success_images"
|
||||
)
|
||||
|
||||
// CST 中国标准时间 UTC+8。
|
||||
var CST = time.FixedZone("CST", 8*3600)
|
||||
@@ -0,0 +1,53 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/golang/freetype"
|
||||
"github.com/golang/freetype/truetype"
|
||||
"golang.org/x/image/font/gofont/goregular"
|
||||
|
||||
"douyu_go/internal/logger"
|
||||
)
|
||||
|
||||
// LoadFont 预加载字体,优先使用本地或系统中文字体,缺失时使用内置字体兜底。
|
||||
func LoadFont(fontFiles []string) *truetype.Font {
|
||||
source, font, err := parseFirstFont(fontFiles)
|
||||
if err != nil {
|
||||
source = "内置 Go Regular 字体"
|
||||
font, err = freetype.ParseFont(goregular.TTF)
|
||||
if err != nil {
|
||||
logger.LogEvent("字体", "error", "内置字体解析失败,成功图片功能将跳过: %v", err)
|
||||
return nil
|
||||
}
|
||||
logger.LogEvent("字体", "warn", "未找到可用系统字体,使用内置字体兜底,中文可能显示不完整")
|
||||
}
|
||||
logger.LogEvent("字体", "success", "字体加载完成: %s", source)
|
||||
return font
|
||||
}
|
||||
|
||||
func parseFirstFont(candidates []string) (string, *truetype.Font, error) {
|
||||
for _, path := range candidates {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
fontBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
logger.LogEvent("字体", "warn", "字体读取失败,已跳过: %s: %v", path, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(fontBytes) == 0 {
|
||||
continue
|
||||
}
|
||||
font, err := freetype.ParseFont(fontBytes)
|
||||
if err != nil {
|
||||
logger.LogEvent("字体", "warn", "字体解析失败,已跳过: %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
return path, font, nil
|
||||
}
|
||||
return "", nil, errors.New("没有可用字体")
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/golang/freetype"
|
||||
"github.com/golang/freetype/truetype"
|
||||
|
||||
"douyu_go/internal/constants"
|
||||
"douyu_go/internal/logger"
|
||||
"douyu_go/internal/types"
|
||||
"douyu_go/internal/utils"
|
||||
)
|
||||
|
||||
// SaveSuccessImage 保存成功兑换结果图片。
|
||||
func SaveSuccessImage(account types.Account, product types.Product, roleName string, font *truetype.Font, successDir string) {
|
||||
if font == nil {
|
||||
return
|
||||
}
|
||||
|
||||
productName := product.CommodityName
|
||||
if productName == "" {
|
||||
productName = product.CommodityID
|
||||
}
|
||||
|
||||
width, height := 600, 280
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
bgColor := color.RGBA{213, 239, 253, 255}
|
||||
topColor := color.RGBA{76, 175, 80, 255}
|
||||
white := color.RGBA{255, 255, 255, 255}
|
||||
black := color.RGBA{0, 0, 0, 255}
|
||||
|
||||
for x := 0; x < width; x++ {
|
||||
for y := 0; y < height; y++ {
|
||||
if y < 60 {
|
||||
img.Set(x, y, topColor)
|
||||
} else {
|
||||
img.Set(x, y, bgColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx := freetype.NewContext()
|
||||
ctx.SetDPI(72)
|
||||
ctx.SetFont(font)
|
||||
ctx.SetClip(img.Bounds())
|
||||
ctx.SetDst(img)
|
||||
|
||||
lines := []struct {
|
||||
Text string
|
||||
Size float64
|
||||
X, Y int
|
||||
Color color.Color
|
||||
}{
|
||||
{productName, 20, 25, 42, white},
|
||||
{"兑换时间:" + time.Now().In(constants.CST).Format("2006年01月02日 15:04:05"), 16, 25, 110, black},
|
||||
{"游戏昵称:" + utils.EmptyDefault(roleName, "未知"), 16, 25, 150, black},
|
||||
{"兑换账号:" + account.Name, 16, 25, 190, black},
|
||||
}
|
||||
for _, line := range lines {
|
||||
ctx.SetFontSize(line.Size)
|
||||
ctx.SetSrc(image.NewUniform(line.Color))
|
||||
_, _ = ctx.DrawString(line.Text, freetype.Pt(line.X, line.Y))
|
||||
}
|
||||
|
||||
name := utils.SafeFilename(fmt.Sprintf("%s_%s_%d.png", account.Name, productName, time.Now().Unix()))
|
||||
path := filepath.Join(successDir, name)
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
logger.LogEvent("图片", "error", "成功图片创建失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if err := png.Encode(file, img); err != nil {
|
||||
logger.LogEvent("图片", "error", "成功图片保存失败: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogEvent("图片", "success", "成功图片已保存: %s", path)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"douyu_go/internal/constants"
|
||||
"douyu_go/internal/types"
|
||||
)
|
||||
|
||||
// MemoryLogStore 内存日志缓冲,实现 io.Writer 接口。
|
||||
type MemoryLogStore struct {
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
max int
|
||||
filePath string
|
||||
entries []types.LogEntry
|
||||
}
|
||||
|
||||
// NewMemoryLogStore 创建内存日志缓冲,最多保留 max 条最新日志。
|
||||
func NewMemoryLogStore(max int) *MemoryLogStore {
|
||||
if max <= 0 {
|
||||
max = 500
|
||||
}
|
||||
return &MemoryLogStore{
|
||||
max: max,
|
||||
entries: make([]types.LogEntry, 0, max),
|
||||
}
|
||||
}
|
||||
|
||||
// Write 实现 io.Writer,把标准 log 输出转存为内存日志。
|
||||
func (s *MemoryLogStore) Write(p []byte) (int, error) {
|
||||
text := strings.TrimRight(string(p), "\r\n")
|
||||
if text == "" {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
category, level := ParseLogMeta(line)
|
||||
if level == "" {
|
||||
level = DetectLogLevel(line)
|
||||
}
|
||||
s.nextID++
|
||||
s.entries = append(s.entries, types.LogEntry{
|
||||
ID: s.nextID,
|
||||
Time: time.Now().In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||||
Level: level,
|
||||
Category: category,
|
||||
Message: line,
|
||||
})
|
||||
if len(s.entries) > s.max {
|
||||
copy(s.entries, s.entries[len(s.entries)-s.max:])
|
||||
s.entries = s.entries[:s.max]
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// SetFilePath 保存当前日志文件路径。
|
||||
func (s *MemoryLogStore) SetFilePath(path string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.filePath = path
|
||||
}
|
||||
|
||||
// Since 返回 ID 大于 since 的日志。
|
||||
func (s *MemoryLogStore) Since(since int64) []types.LogEntry {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
out := make([]types.LogEntry, 0, len(s.entries))
|
||||
for _, entry := range s.entries {
|
||||
if entry.ID > since {
|
||||
out = append(out, entry)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FilePath 返回当前日志文件路径。
|
||||
func (s *MemoryLogStore) FilePath() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.filePath
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"douyu_go/internal/constants"
|
||||
)
|
||||
|
||||
// AsyncLogWriter 异步日志写入器,队列满时丢弃日志以保护主链路。
|
||||
type AsyncLogWriter struct {
|
||||
dst io.Writer
|
||||
ch chan []byte
|
||||
wg sync.WaitGroup
|
||||
closed uint32
|
||||
dropped uint64
|
||||
}
|
||||
|
||||
// NewAsyncLogWriter 创建异步日志写入器。
|
||||
func NewAsyncLogWriter(dst io.Writer, buffer int) *AsyncLogWriter {
|
||||
if buffer <= 0 {
|
||||
buffer = 1024
|
||||
}
|
||||
w := &AsyncLogWriter{
|
||||
dst: dst,
|
||||
ch: make(chan []byte, buffer),
|
||||
}
|
||||
w.wg.Add(1)
|
||||
go w.run()
|
||||
return w
|
||||
}
|
||||
|
||||
// Write 异步写日志;队列满时丢弃,避免抢购请求被磁盘 I/O 阻塞。
|
||||
func (w *AsyncLogWriter) Write(p []byte) (int, error) {
|
||||
if w == nil {
|
||||
return len(p), nil
|
||||
}
|
||||
if atomic.LoadUint32(&w.closed) == 1 {
|
||||
return len(p), nil
|
||||
}
|
||||
buf := append([]byte(nil), p...)
|
||||
select {
|
||||
case w.ch <- buf:
|
||||
default:
|
||||
atomic.AddUint64(&w.dropped, 1)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (w *AsyncLogWriter) run() {
|
||||
defer w.wg.Done()
|
||||
for p := range w.ch {
|
||||
_, _ = w.dst.Write(p)
|
||||
}
|
||||
if dropped := atomic.LoadUint64(&w.dropped); dropped > 0 {
|
||||
_, _ = fmt.Fprintf(w.dst, "%s [日志][warn] 日志队列已丢弃 %d 条,已优先保护抢购链路\n",
|
||||
time.Now().In(constants.CST).Format("2006/01/02 15:04:05.000000"), dropped)
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭写入器,等待队列清空。
|
||||
func (w *AsyncLogWriter) Close() error {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
if atomic.CompareAndSwapUint32(&w.closed, 0, 1) {
|
||||
close(w.ch)
|
||||
w.wg.Wait()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitLogging 初始化日志输出:终端、日志文件、内存缓冲三处同时写入。
|
||||
func InitLogging(store *MemoryLogStore, logDir string) func() {
|
||||
if logDir == "" {
|
||||
logDir = constants.DefaultLogDir
|
||||
}
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
async := NewAsyncLogWriter(io.MultiWriter(os.Stdout, store), 2048)
|
||||
log.SetOutput(async)
|
||||
log.Printf("日志目录创建失败,仅写入终端和内存: %v", err)
|
||||
return func() { _ = async.Close() }
|
||||
}
|
||||
|
||||
filePath := filepath.Join(logDir, "douyu_"+time.Now().In(constants.CST).Format("20060102_150405")+".log")
|
||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
async := NewAsyncLogWriter(io.MultiWriter(os.Stdout, store), 2048)
|
||||
log.SetOutput(async)
|
||||
log.Printf("日志文件创建失败,仅写入终端和内存: %v", err)
|
||||
return func() { _ = async.Close() }
|
||||
}
|
||||
|
||||
store.SetFilePath(filePath)
|
||||
async := NewAsyncLogWriter(io.MultiWriter(os.Stdout, file, store), 2048)
|
||||
log.SetOutput(async)
|
||||
log.Printf("日志文件已启用: %s", filePath)
|
||||
return func() {
|
||||
_ = async.Close()
|
||||
_ = file.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// LogEvent 输出带分类和级别的结构化文本日志。
|
||||
func LogEvent(category, level, format string, args ...any) {
|
||||
if category == "" {
|
||||
category = "系统"
|
||||
}
|
||||
if level == "" {
|
||||
level = "info"
|
||||
}
|
||||
log.Printf("[%s][%s] %s", category, level, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// ParseLogMeta 从标准日志行中提取类似 [网络][warn] 的分类和级别。
|
||||
func ParseLogMeta(line string) (string, string) {
|
||||
idx := strings.Index(line, "[")
|
||||
if idx < 0 {
|
||||
return "", ""
|
||||
}
|
||||
category, rest, ok := cutBracket(line[idx:])
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
level, _, ok := cutBracket(rest)
|
||||
if !ok || !IsLogLevel(level) {
|
||||
return category, ""
|
||||
}
|
||||
return category, level
|
||||
}
|
||||
|
||||
func cutBracket(s string) (string, string, bool) {
|
||||
if !strings.HasPrefix(s, "[") {
|
||||
return "", s, false
|
||||
}
|
||||
end := strings.Index(s, "]")
|
||||
if end <= 1 {
|
||||
return "", s, false
|
||||
}
|
||||
return s[1:end], s[end+1:], true
|
||||
}
|
||||
|
||||
// IsLogLevel 判断是否为有效日志级别。
|
||||
func IsLogLevel(level string) bool {
|
||||
switch strings.ToLower(level) {
|
||||
case "success", "error", "warn", "info":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// DetectLogLevel 根据日志文本粗略分类,方便前端染色。
|
||||
func DetectLogLevel(line string) string {
|
||||
lower := strings.ToLower(line)
|
||||
switch {
|
||||
case strings.Contains(lower, "[success]"):
|
||||
return "success"
|
||||
case strings.Contains(lower, "[error]"):
|
||||
return "error"
|
||||
case strings.Contains(lower, "[warn]"):
|
||||
return "warn"
|
||||
case strings.Contains(lower, "[info]"):
|
||||
return "info"
|
||||
case strings.Contains(line, "成功"):
|
||||
return "success"
|
||||
case strings.Contains(line, "失败") || strings.Contains(line, "错误") || strings.Contains(lower, "error") || strings.Contains(lower, "panic"):
|
||||
return "error"
|
||||
case strings.Contains(line, "降级") || strings.Contains(line, "跳过") || strings.Contains(line, "不存在") || strings.Contains(lower, "warn"):
|
||||
return "warn"
|
||||
default:
|
||||
return "info"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
package seckill
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang/freetype/truetype"
|
||||
|
||||
"douyu_go/internal/client"
|
||||
"douyu_go/internal/constants"
|
||||
"douyu_go/internal/image"
|
||||
"douyu_go/internal/logger"
|
||||
"douyu_go/internal/types"
|
||||
"douyu_go/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
warmupBeforeTrigger = 2 * time.Second
|
||||
warmupMaxAge = 30 * time.Second
|
||||
timeCalibrateRounds = 3
|
||||
timeCalibrateWait = 1200 * time.Millisecond
|
||||
resyncBeforeTrigger = 10 * time.Second
|
||||
)
|
||||
|
||||
// Scheduler 抢购调度器。
|
||||
type Scheduler struct {
|
||||
client *client.DouyuClient
|
||||
paths types.AppPaths
|
||||
font *truetype.Font
|
||||
|
||||
accountsMu sync.RWMutex
|
||||
accounts []types.Account
|
||||
|
||||
resultsMu sync.RWMutex
|
||||
results map[string]string
|
||||
|
||||
runMu sync.Mutex
|
||||
running bool
|
||||
|
||||
scheduleMu sync.Mutex
|
||||
schedule *time.Timer
|
||||
warmupSchedule *time.Timer
|
||||
resyncSchedule *time.Timer
|
||||
|
||||
warmupsMu sync.RWMutex
|
||||
warmups map[string]types.WarmupData
|
||||
}
|
||||
|
||||
// New 创建抢购调度器。
|
||||
func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font) *Scheduler {
|
||||
return &Scheduler{
|
||||
client: cli,
|
||||
paths: paths,
|
||||
font: font,
|
||||
results: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAccounts 从账号文件加载账号。
|
||||
func (s *Scheduler) LoadAccounts() {
|
||||
path := s.paths.AccountFile
|
||||
if path == "" {
|
||||
path = constants.DefaultAccountFile
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
logger.LogEvent("账号", "error", "读取账号文件失败: %s: %v", path, err)
|
||||
s.accountsMu.Lock()
|
||||
s.accounts = nil
|
||||
s.accountsMu.Unlock()
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var list []types.Account
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "----", 3)
|
||||
if len(parts) != 3 {
|
||||
logger.LogEvent("账号", "warn", "账号行格式错误,已跳过: %s", line)
|
||||
continue
|
||||
}
|
||||
list = append(list, types.Account{
|
||||
Name: strings.TrimSpace(parts[0]),
|
||||
Password: strings.TrimSpace(parts[1]),
|
||||
Cookie: strings.TrimSpace(parts[2]),
|
||||
})
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
logger.LogEvent("账号", "error", "读取账号文件中断: %v", err)
|
||||
}
|
||||
|
||||
s.accountsMu.Lock()
|
||||
s.accounts = list
|
||||
s.accountsMu.Unlock()
|
||||
logger.LogEvent("账号", "success", "账号加载完成: %d 个,来源=%s", len(list), path)
|
||||
}
|
||||
|
||||
// AccountCount 返回当前账号数。
|
||||
func (s *Scheduler) AccountCount() int {
|
||||
s.accountsMu.RLock()
|
||||
defer s.accountsMu.RUnlock()
|
||||
return len(s.accounts)
|
||||
}
|
||||
|
||||
// AccountSnapshot 复制一份账号列表。
|
||||
func (s *Scheduler) AccountSnapshot() []types.Account {
|
||||
s.accountsMu.RLock()
|
||||
defer s.accountsMu.RUnlock()
|
||||
list := make([]types.Account, len(s.accounts))
|
||||
copy(list, s.accounts)
|
||||
return list
|
||||
}
|
||||
|
||||
// AccountCookieByName 按账号名查找 Cookie。
|
||||
func (s *Scheduler) AccountCookieByName(name string) (string, bool) {
|
||||
s.accountsMu.RLock()
|
||||
defer s.accountsMu.RUnlock()
|
||||
for _, account := range s.accounts {
|
||||
if account.Name == name {
|
||||
return account.Cookie, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Results 返回当前抢购结果快照。
|
||||
func (s *Scheduler) Results() map[string]string {
|
||||
s.resultsMu.RLock()
|
||||
defer s.resultsMu.RUnlock()
|
||||
out := make(map[string]string, len(s.results))
|
||||
for k, v := range s.results {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ClearResults 清空抢购结果。
|
||||
func (s *Scheduler) ClearResults() {
|
||||
s.resultsMu.Lock()
|
||||
defer s.resultsMu.Unlock()
|
||||
s.results = make(map[string]string)
|
||||
}
|
||||
|
||||
func (s *Scheduler) setResult(key, value string) {
|
||||
s.resultsMu.Lock()
|
||||
defer s.resultsMu.Unlock()
|
||||
s.results[key] = value
|
||||
}
|
||||
|
||||
// TryStartRun 尝试开始抢购,返回 false 表示已有任务在运行。
|
||||
func (s *Scheduler) TryStartRun() bool {
|
||||
s.runMu.Lock()
|
||||
defer s.runMu.Unlock()
|
||||
if s.running {
|
||||
return false
|
||||
}
|
||||
s.running = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Scheduler) finishRun() {
|
||||
s.runMu.Lock()
|
||||
defer s.runMu.Unlock()
|
||||
s.running = false
|
||||
}
|
||||
|
||||
// RunNow 立即执行一批抢购任务。
|
||||
func (s *Scheduler) RunNow(accountProducts map[string]string) {
|
||||
defer s.finishRun()
|
||||
s.ClearResults()
|
||||
attempts := s.buildAttempts(accountProducts)
|
||||
if len(attempts) == 0 {
|
||||
logger.LogEvent("抢购", "warn", "没有可执行的抢购任务")
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogEvent("抢购", "info", "开始抢购: %d 个请求", len(attempts))
|
||||
limit := make(chan struct{}, constants.DefaultConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, attempt := range attempts {
|
||||
wg.Add(1)
|
||||
go func(attempt types.ExchangeAttempt) {
|
||||
defer wg.Done()
|
||||
limit <- struct{}{}
|
||||
defer func() { <-limit }()
|
||||
|
||||
result := s.runAttempt(attempt)
|
||||
key := fmt.Sprintf("%s_第%d包", result.Account, result.Attempt)
|
||||
s.setResult(key, result.Message)
|
||||
if result.OK {
|
||||
logger.LogEvent("抢购", "success", "成功: %s %s 第%d包 %s", result.Account, result.Product, result.Attempt, result.Message)
|
||||
go image.SaveSuccessImage(attempt.Account, attempt.Product, result.RoleName, s.font, s.paths.SuccessDir)
|
||||
} else {
|
||||
logger.LogEvent("抢购", "error", "失败: %s %s 第%d包 %s", result.Account, result.Product, result.Attempt, result.Message)
|
||||
}
|
||||
}(attempt)
|
||||
}
|
||||
wg.Wait()
|
||||
logger.LogEvent("抢购", "info", "抢购任务结束")
|
||||
}
|
||||
|
||||
func (s *Scheduler) buildAttempts(accountProducts map[string]string) []types.ExchangeAttempt {
|
||||
accounts := s.AccountSnapshot()
|
||||
var attempts []types.ExchangeAttempt
|
||||
for _, account := range accounts {
|
||||
commodityID := accountProducts[account.Name]
|
||||
if commodityID == "" {
|
||||
continue
|
||||
}
|
||||
product := types.Product{CommodityID: commodityID, CommodityName: commodityID}
|
||||
for i := 1; i <= constants.DefaultAttempts; i++ {
|
||||
attempts = append(attempts, types.ExchangeAttempt{
|
||||
Account: account,
|
||||
Product: product,
|
||||
Index: i,
|
||||
})
|
||||
}
|
||||
}
|
||||
return attempts
|
||||
}
|
||||
|
||||
func warmupKey(attempt types.ExchangeAttempt) string {
|
||||
return fmt.Sprintf("%s|%s|%d", attempt.Account.Name, attempt.Product.CommodityID, attempt.Index)
|
||||
}
|
||||
|
||||
func (s *Scheduler) setWarmup(key string, data types.WarmupData) {
|
||||
s.warmupsMu.Lock()
|
||||
defer s.warmupsMu.Unlock()
|
||||
if s.warmups == nil {
|
||||
s.warmups = make(map[string]types.WarmupData)
|
||||
}
|
||||
s.warmups[key] = data
|
||||
}
|
||||
|
||||
func (s *Scheduler) warmupFor(attempt types.ExchangeAttempt) (types.WarmupData, bool) {
|
||||
s.warmupsMu.RLock()
|
||||
data, ok := s.warmups[warmupKey(attempt)]
|
||||
s.warmupsMu.RUnlock()
|
||||
if !ok || data.CreatedAt.IsZero() || time.Since(data.CreatedAt) > warmupMaxAge {
|
||||
return types.WarmupData{}, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
func (s *Scheduler) clearWarmups() {
|
||||
s.warmupsMu.Lock()
|
||||
defer s.warmupsMu.Unlock()
|
||||
s.warmups = make(map[string]types.WarmupData)
|
||||
}
|
||||
|
||||
func (s *Scheduler) warmupAttempts(attempts []types.ExchangeAttempt) {
|
||||
if len(attempts) == 0 {
|
||||
return
|
||||
}
|
||||
s.clearWarmups()
|
||||
logger.LogEvent("预热", "info", "开始预热: %d 个请求", len(attempts))
|
||||
|
||||
limit := make(chan struct{}, constants.DefaultConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, attempt := range attempts {
|
||||
wg.Add(1)
|
||||
go func(attempt types.ExchangeAttempt) {
|
||||
defer wg.Done()
|
||||
limit <- struct{}{}
|
||||
defer func() { <-limit }()
|
||||
|
||||
start := time.Now()
|
||||
csrf, err := s.client.GenerateCSRF(attempt.Account.Cookie)
|
||||
if err != nil {
|
||||
logger.LogEvent("预热", "warn", "CSRF预热失败: 账号=%s 商品=%s err=%v", attempt.Account.Name, attempt.Product.CommodityName, err)
|
||||
return
|
||||
}
|
||||
roleName, err := s.client.RoleName(attempt.Account.Cookie)
|
||||
if err != nil {
|
||||
logger.LogEvent("预热", "warn", "角色预热失败: 账号=%s err=%v", attempt.Account.Name, err)
|
||||
}
|
||||
s.setWarmup(warmupKey(attempt), types.WarmupData{
|
||||
CSRF: csrf,
|
||||
RoleName: roleName,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
logger.LogEvent("预热", "success", "预热完成: 账号=%s 商品=%s 耗时=%s", attempt.Account.Name, attempt.Product.CommodityName, utils.RoundDuration(time.Since(start)))
|
||||
}(attempt)
|
||||
}
|
||||
wg.Wait()
|
||||
logger.LogEvent("预热", "info", "预热任务结束")
|
||||
}
|
||||
|
||||
func (s *Scheduler) calibrateDouyuTime(label string) types.TimeCalibration {
|
||||
calibration, err := s.client.BestServerTimeCalibration(timeCalibrateRounds, timeCalibrateWait)
|
||||
if err != nil {
|
||||
logger.LogEvent("时间", "warn", "%s校准降级: %v", label, err)
|
||||
}
|
||||
logger.LogEvent("时间", "success", "%s校准完成: 斗鱼估算=%s 偏移=%dms 误差<=%dms RTT=%dms 采样=%d 来源=%s",
|
||||
label,
|
||||
calibration.EstimatedServerNow().In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||||
calibration.Offset.Milliseconds(),
|
||||
calibration.ErrorBound.Milliseconds(),
|
||||
calibration.RTT.Milliseconds(),
|
||||
calibration.Samples,
|
||||
calibration.Source)
|
||||
return calibration
|
||||
}
|
||||
|
||||
func (s *Scheduler) replaceTimerLocked(timer **time.Timer, d time.Duration, fn func()) {
|
||||
if *timer != nil {
|
||||
(*timer).Stop()
|
||||
}
|
||||
*timer = time.AfterFunc(d, fn)
|
||||
}
|
||||
|
||||
func (s *Scheduler) stopSchedulesLocked() {
|
||||
if s.schedule != nil {
|
||||
s.schedule.Stop()
|
||||
s.schedule = nil
|
||||
}
|
||||
if s.warmupSchedule != nil {
|
||||
s.warmupSchedule.Stop()
|
||||
s.warmupSchedule = nil
|
||||
}
|
||||
if s.resyncSchedule != nil {
|
||||
s.resyncSchedule.Stop()
|
||||
s.resyncSchedule = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) runAttempt(attempt types.ExchangeAttempt) types.ExchangeResult {
|
||||
result := types.ExchangeResult{
|
||||
Account: attempt.Account.Name,
|
||||
Product: attempt.Product.CommodityName,
|
||||
Attempt: attempt.Index,
|
||||
}
|
||||
|
||||
logger.LogEvent("抢购", "info", "执行兑换链路: 账号=%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.LogEvent("抢购", "info", "使用预热CSRF: 账号=%s 商品=%s 预热距今=%s", attempt.Account.Name, attempt.Product.CommodityName, utils.RoundDuration(time.Since(warmup.CreatedAt)))
|
||||
}
|
||||
if csrf == "" {
|
||||
var err error
|
||||
csrf, err = s.client.GenerateCSRF(attempt.Account.Cookie)
|
||||
if err != nil {
|
||||
result.Message = "失败|生成CSRF失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
logger.LogEvent("抢购", "info", "CSRF已生成,本次兑换链路复用同一个 token: 账号=%s 商品=%s", attempt.Account.Name, attempt.Product.CommodityName)
|
||||
}
|
||||
|
||||
limit, err := s.client.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.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 {
|
||||
result.Message = "失败|预兑换失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
exchangeInfo, err := s.client.Exchange(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf)
|
||||
if err != nil {
|
||||
result.Message = "失败|" + err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
if roleName == "" {
|
||||
roleName, err = s.client.RoleName(attempt.Account.Cookie)
|
||||
if err != nil {
|
||||
logger.LogEvent("抢购", "warn", "%s 角色名查询失败: %v", attempt.Account.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
result.OK = true
|
||||
result.RoleName = roleName
|
||||
if roleName != "" {
|
||||
result.Message = "成功|" + roleName + "|" + exchangeInfo
|
||||
} else {
|
||||
result.Message = "成功|" + exchangeInfo
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ScheduleRun 注册一次定时抢购。
|
||||
func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (map[string]any, error) {
|
||||
if len(req.AccountProducts) == 0 {
|
||||
return nil, errors.New("请选择账号和商品")
|
||||
}
|
||||
if strings.TrimSpace(req.TargetTime) == "" {
|
||||
return nil, errors.New("请设置目标时间")
|
||||
}
|
||||
|
||||
targetLocal, err := time.ParseInLocation("2006-01-02 15:04:05", req.TargetTime, constants.CST)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("时间格式错误: %w", err)
|
||||
}
|
||||
targetTime := targetLocal.UTC()
|
||||
|
||||
calibration := s.calibrateDouyuTime("定时")
|
||||
serverTime := calibration.EstimatedServerNow()
|
||||
|
||||
advance := time.Duration(req.Advance) * time.Millisecond
|
||||
triggerTime := targetTime.Add(-advance)
|
||||
remain := triggerTime.Sub(serverTime)
|
||||
if remain <= 0 {
|
||||
return nil, errors.New("目标时间已过")
|
||||
}
|
||||
|
||||
ap := utils.CloneStringMap(req.AccountProducts)
|
||||
attempts := s.buildAttempts(ap)
|
||||
s.clearWarmups()
|
||||
s.scheduleMu.Lock()
|
||||
s.stopSchedulesLocked()
|
||||
s.scheduleMu.Unlock()
|
||||
|
||||
s.scheduleTimers(ap, attempts, targetTime, triggerTime, calibration, false)
|
||||
|
||||
resyncRemain := triggerTime.Sub(serverTime) - resyncBeforeTrigger
|
||||
if resyncRemain < 0 {
|
||||
resyncRemain = 0
|
||||
}
|
||||
|
||||
s.scheduleMu.Lock()
|
||||
s.replaceTimerLocked(&s.resyncSchedule, resyncRemain, func() {
|
||||
newCalibration := s.calibrateDouyuTime("临近")
|
||||
s.scheduleTimers(ap, attempts, targetTime, triggerTime, newCalibration, true)
|
||||
})
|
||||
s.scheduleMu.Unlock()
|
||||
|
||||
warmupRemain := remain - warmupBeforeTrigger
|
||||
if warmupRemain < 0 {
|
||||
warmupRemain = 0
|
||||
}
|
||||
|
||||
logger.LogEvent("定时", "success", "定时已设置: 目标=%s 触发=%s 提前=%dms 斗鱼估算=%s 本地偏移=%dms 误差<=%dms RTT=%dms 预热=%s 复校=%s",
|
||||
targetTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||||
triggerTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||||
req.Advance,
|
||||
serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||||
calibration.Offset.Milliseconds(),
|
||||
calibration.ErrorBound.Milliseconds(),
|
||||
calibration.RTT.Milliseconds(),
|
||||
time.Now().Add(warmupRemain).In(constants.CST).Format("15:04:05.000"),
|
||||
time.Now().Add(resyncRemain).In(constants.CST).Format("15:04:05.000"),
|
||||
)
|
||||
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"targetTime": targetTime.In(constants.CST).Format("2006-01-02 15:04:05"),
|
||||
"triggerTime": triggerTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||||
"advance": req.Advance,
|
||||
"remainSecond": int(remain.Seconds()),
|
||||
"serverTime": serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||||
"offsetMs": calibration.Offset.Milliseconds(),
|
||||
"errorBoundMs": calibration.ErrorBound.Milliseconds(),
|
||||
"rttMs": calibration.RTT.Milliseconds(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) scheduleTimers(accountProducts map[string]string, attempts []types.ExchangeAttempt, targetTime, triggerTime time.Time, calibration types.TimeCalibration, resync bool) {
|
||||
serverTime := calibration.EstimatedServerNow()
|
||||
remain := triggerTime.Sub(serverTime)
|
||||
if remain < 0 {
|
||||
remain = 0
|
||||
}
|
||||
warmupRemain := remain - warmupBeforeTrigger
|
||||
if warmupRemain < 0 {
|
||||
warmupRemain = 0
|
||||
}
|
||||
|
||||
s.scheduleMu.Lock()
|
||||
defer s.scheduleMu.Unlock()
|
||||
s.replaceTimerLocked(&s.warmupSchedule, warmupRemain, func() {
|
||||
logger.LogEvent("预热", "success", "预热触发: 本地=%s", time.Now().In(constants.CST).Format("15:04:05.000"))
|
||||
s.warmupAttempts(attempts)
|
||||
})
|
||||
s.replaceTimerLocked(&s.schedule, remain, func() {
|
||||
now := time.Now()
|
||||
estimatedServer := now.Add(calibration.Offset)
|
||||
logger.LogEvent("定时", "success", "定时触发: 本地=%s 斗鱼估算=%s 偏移=%dms 误差<=%dms",
|
||||
now.In(constants.CST).Format("15:04:05.000"),
|
||||
estimatedServer.In(constants.CST).Format("15:04:05.000"),
|
||||
calibration.Offset.Milliseconds(),
|
||||
calibration.ErrorBound.Milliseconds())
|
||||
if !s.TryStartRun() {
|
||||
logger.LogEvent("抢购", "warn", "已有抢购任务正在运行,本次定时触发已跳过")
|
||||
return
|
||||
}
|
||||
s.RunNow(accountProducts)
|
||||
})
|
||||
if resync {
|
||||
logger.LogEvent("定时", "success", "临近复校已应用: 目标=%s 触发=%s 斗鱼估算=%s 本地偏移=%dms 误差<=%dms RTT=%dms 预热=%s",
|
||||
targetTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||||
triggerTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||||
serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||||
calibration.Offset.Milliseconds(),
|
||||
calibration.ErrorBound.Milliseconds(),
|
||||
calibration.RTT.Milliseconds(),
|
||||
time.Now().Add(warmupRemain).In(constants.CST).Format("15:04:05.000"))
|
||||
}
|
||||
}
|
||||
|
||||
// CancelSchedule 停止尚未触发的定时任务。
|
||||
func (s *Scheduler) CancelSchedule() bool {
|
||||
s.scheduleMu.Lock()
|
||||
defer s.scheduleMu.Unlock()
|
||||
stopped := false
|
||||
if s.schedule != nil {
|
||||
stopped = s.schedule.Stop()
|
||||
}
|
||||
s.stopSchedulesLocked()
|
||||
s.clearWarmups()
|
||||
return stopped
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package trace
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"douyu_go/internal/constants"
|
||||
"douyu_go/internal/logger"
|
||||
"douyu_go/internal/types"
|
||||
"douyu_go/internal/utils"
|
||||
)
|
||||
|
||||
// Recorder HTTP 请求追踪记录器。
|
||||
type Recorder struct {
|
||||
Enabled bool
|
||||
MaxBodyBytes int
|
||||
writer *logger.AsyncLogWriter
|
||||
filePath string
|
||||
seq uint64
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
Seq uint64
|
||||
Time string
|
||||
Label string
|
||||
Method string
|
||||
URL string
|
||||
Path string
|
||||
Status int
|
||||
Cost time.Duration
|
||||
ReqHeader map[string][]string
|
||||
RespHeader map[string][]string
|
||||
ReqBody string
|
||||
RespBody string
|
||||
APIError int
|
||||
APIMessage string
|
||||
ErrorText string
|
||||
MaxBodyBytes int
|
||||
}
|
||||
|
||||
// InitTrace 初始化追踪记录器。
|
||||
func InitTrace(logDir string, cfg types.TraceConfig) (*Recorder, func()) {
|
||||
if !cfg.Enabled {
|
||||
return &Recorder{Enabled: false}, func() {}
|
||||
}
|
||||
if logDir == "" {
|
||||
logDir = constants.DefaultLogDir
|
||||
}
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
logger.LogEvent("Trace", "error", "trace 目录创建失败,已关闭 trace: %v", err)
|
||||
return &Recorder{Enabled: false}, func() {}
|
||||
}
|
||||
|
||||
path := filepath.Join(logDir, "douyu_trace_"+time.Now().In(constants.CST).Format("20060102_150405")+".log")
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
logger.LogEvent("Trace", "error", "trace 文件创建失败,已关闭 trace: %v", err)
|
||||
return &Recorder{Enabled: false}, func() {}
|
||||
}
|
||||
|
||||
recorder := &Recorder{
|
||||
Enabled: true,
|
||||
MaxBodyBytes: cfg.MaxBodyBytes,
|
||||
writer: logger.NewAsyncLogWriter(file, 2048),
|
||||
filePath: path,
|
||||
}
|
||||
if cfg.MaxBodyBytes > 0 {
|
||||
logger.LogEvent("Trace", "warn", "trace 已启用: %s,单段正文最多保留 %d 字节;请求头不脱敏,响应 Set-Cookie 隐藏值", path, cfg.MaxBodyBytes)
|
||||
} else {
|
||||
logger.LogEvent("Trace", "warn", "trace 已启用: %s,全量保留请求和响应;请求头不脱敏,响应 Set-Cookie 隐藏值", path)
|
||||
}
|
||||
return recorder, func() {
|
||||
_ = recorder.Close()
|
||||
_ = file.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭记录器。
|
||||
func (t *Recorder) Close() error {
|
||||
if t == nil || t.writer == nil {
|
||||
return nil
|
||||
}
|
||||
return t.writer.Close()
|
||||
}
|
||||
|
||||
// Record 记录一次请求。
|
||||
func (t *Recorder) 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)
|
||||
}
|
||||
|
||||
// RecordResponse 记录一次请求和响应。
|
||||
func (t *Recorder) 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
|
||||
}
|
||||
if label == "服务时间" && status == http.StatusOK && errText == "" && apiError == 0 {
|
||||
return
|
||||
}
|
||||
e := entry{
|
||||
Seq: atomic.AddUint64(&t.seq, 1),
|
||||
Time: time.Now().In(constants.CST).Format("2006-01-02 15:04:05.000"),
|
||||
Label: label,
|
||||
Method: req.Method,
|
||||
URL: req.URL.Redacted(),
|
||||
Path: req.URL.Path,
|
||||
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,
|
||||
APIMessage: apiMsg,
|
||||
ErrorText: errText,
|
||||
MaxBodyBytes: t.MaxBodyBytes,
|
||||
}
|
||||
_, _ = t.writer.Write([]byte(formatEntry(e)))
|
||||
}
|
||||
|
||||
// RequestBody 读取请求体用于追踪。
|
||||
func (t *Recorder) RequestBody(req *http.Request) []byte {
|
||||
if t == nil || !t.Enabled || req == nil || req.GetBody == nil {
|
||||
return nil
|
||||
}
|
||||
body, err := req.GetBody()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer body.Close()
|
||||
if t.MaxBodyBytes > 0 {
|
||||
data, _ := io.ReadAll(io.LimitReader(body, int64(t.MaxBodyBytes)+1))
|
||||
return data
|
||||
}
|
||||
data, _ := io.ReadAll(body)
|
||||
return data
|
||||
}
|
||||
|
||||
func sanitizeHeader(header http.Header) map[string][]string {
|
||||
out := make(map[string][]string, len(header))
|
||||
for key, values := range header {
|
||||
canonical := http.CanonicalHeaderKey(key)
|
||||
copied := make([]string, len(values))
|
||||
copy(copied, values)
|
||||
out[canonical] = copied
|
||||
}
|
||||
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(constants.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 ""
|
||||
}
|
||||
mediaType, params, _ := mime.ParseMediaType(contentType)
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
boundary := params["boundary"]
|
||||
if boundary != "" {
|
||||
return sanitizeMultipartBody(body, boundary, max)
|
||||
}
|
||||
}
|
||||
return sanitizeTextBody(body, max)
|
||||
}
|
||||
|
||||
func sanitizeResponseBody(body []byte, max int) string {
|
||||
return sanitizeTextBody(body, max)
|
||||
}
|
||||
|
||||
func sanitizeTextBody(body []byte, max int) string {
|
||||
raw := string(body)
|
||||
if pretty, ok := prettyJSON(raw); ok {
|
||||
return truncateText(pretty, max)
|
||||
}
|
||||
return truncateText(raw, max)
|
||||
}
|
||||
|
||||
func sanitizeMultipartBody(body []byte, boundary string, max int) string {
|
||||
reader := multipart.NewReader(bytes.NewReader(body), boundary)
|
||||
fields := make(map[string][]string)
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return truncateText(string(body), max)
|
||||
}
|
||||
name := part.FormName()
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
var valueBytes []byte
|
||||
if max > 0 {
|
||||
valueBytes, _ = io.ReadAll(io.LimitReader(part, int64(max)+1))
|
||||
} else {
|
||||
valueBytes, _ = io.ReadAll(part)
|
||||
}
|
||||
value := truncateText(string(valueBytes), max)
|
||||
fields[name] = append(fields[name], value)
|
||||
}
|
||||
encoded, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func truncateText(s string, max int) string {
|
||||
if max <= 0 || len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + fmt.Sprintf("...<truncated %d bytes>", len(s)-max)
|
||||
}
|
||||
|
||||
func formatEntry(e entry) string {
|
||||
var b strings.Builder
|
||||
statusText := "成功"
|
||||
if e.Status < 200 || e.Status >= 300 || e.APIError != 0 || e.ErrorText != "" {
|
||||
statusText = "异常"
|
||||
}
|
||||
|
||||
b.WriteString(strings.Repeat("=", 96))
|
||||
b.WriteByte('\n')
|
||||
fmt.Fprintf(&b, "#%06d %s [%s] %s %s\n", e.Seq, e.Time, statusText, e.Method, e.URL)
|
||||
fmt.Fprintf(&b, "接口: %s\n", utils.EmptyDefault(e.Label, "-"))
|
||||
fmt.Fprintf(&b, "路径: %s\n", utils.EmptyDefault(e.Path, "-"))
|
||||
fmt.Fprintf(&b, "结果: HTTP %d | 耗时 %s | apiError=%d", e.Status, utils.RoundDuration(e.Cost), e.APIError)
|
||||
if e.APIMessage != "" {
|
||||
fmt.Fprintf(&b, " | msg=%s", e.APIMessage)
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
if e.ErrorText != "" {
|
||||
fmt.Fprintf(&b, "错误: %s\n", e.ErrorText)
|
||||
}
|
||||
b.WriteString("\n[响应头]\n")
|
||||
b.WriteString(formatHeaders(e.RespHeader))
|
||||
b.WriteString("\n[请求头]\n")
|
||||
b.WriteString(formatHeaders(e.ReqHeader))
|
||||
b.WriteString("\n[请求体]\n")
|
||||
b.WriteString(formatBody(e.ReqBody))
|
||||
b.WriteString("\n[响应体]\n")
|
||||
b.WriteString(formatBody(e.RespBody))
|
||||
if e.MaxBodyBytes > 0 {
|
||||
fmt.Fprintf(&b, "\n提示: 单段正文最多保留 %d 字节;请求头不脱敏,响应 Set-Cookie 隐藏值。\n", e.MaxBodyBytes)
|
||||
} else {
|
||||
b.WriteString("\n提示: trace 全量保留请求和响应;请求头不脱敏,响应 Set-Cookie 隐藏值。\n")
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatHeaders(headers map[string][]string) string {
|
||||
if len(headers) == 0 {
|
||||
return "<empty>\n"
|
||||
}
|
||||
keys := make([]string, 0, len(headers))
|
||||
for key := range headers {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var b strings.Builder
|
||||
for _, key := range keys {
|
||||
fmt.Fprintf(&b, "%s: %s\n", key, strings.Join(headers[key], ", "))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatBody(body string) string {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return "<empty>\n"
|
||||
}
|
||||
if pretty, ok := prettyJSON(body); ok {
|
||||
return pretty + "\n"
|
||||
}
|
||||
return body + "\n"
|
||||
}
|
||||
|
||||
func prettyJSON(s string) (string, bool) {
|
||||
var value any
|
||||
if err := json.Unmarshal([]byte(s), &value); err != nil {
|
||||
return "", false
|
||||
}
|
||||
out, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(out), true
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config 应用配置。
|
||||
type Config struct {
|
||||
TimeAPIURL string `json:"timeApiUrl"`
|
||||
}
|
||||
|
||||
// AppPaths 运行时路径。
|
||||
type AppPaths struct {
|
||||
ConfigFile string
|
||||
AccountFile string
|
||||
LogDir string
|
||||
SuccessDir string
|
||||
FontFiles []string
|
||||
}
|
||||
|
||||
// TraceConfig 追踪配置。
|
||||
type TraceConfig struct {
|
||||
Enabled bool
|
||||
MaxBodyBytes int
|
||||
}
|
||||
|
||||
// Account 账号信息。
|
||||
type Account struct {
|
||||
Name string `json:"name"`
|
||||
Password string `json:"password"`
|
||||
Cookie string `json:"cookie"`
|
||||
}
|
||||
|
||||
// Product 商品信息。
|
||||
type Product struct {
|
||||
CommodityID string `json:"commodityId"`
|
||||
CommodityName string `json:"commodityName"`
|
||||
Score int `json:"score"`
|
||||
Count int `json:"count"`
|
||||
Pic string `json:"pic"`
|
||||
Status int `json:"status"`
|
||||
OpenStatus int `json:"openStatus"`
|
||||
StoreStatus int `json:"storeStatus"`
|
||||
TimeLimited int `json:"timeLimited"`
|
||||
CountLimited int `json:"countLimited"`
|
||||
EliteLimited int `json:"eliteLimited"`
|
||||
}
|
||||
|
||||
// ProductPayload 前端商品响应。
|
||||
type ProductPayload struct {
|
||||
Success bool `json:"success"`
|
||||
Source string `json:"source"`
|
||||
Products []Product `json:"products"`
|
||||
Count int `json:"count"`
|
||||
FetchedAt string `json:"fetchedAt"`
|
||||
AgeSecond int `json:"ageSecond"`
|
||||
Stale bool `json:"stale"`
|
||||
}
|
||||
|
||||
// AccountView 前端账号视图。
|
||||
type AccountView struct {
|
||||
Index int `json:"index"`
|
||||
Name string `json:"name"`
|
||||
Cookie string `json:"cookie"`
|
||||
}
|
||||
|
||||
// SeckillRequest 抢购请求。
|
||||
type SeckillRequest struct {
|
||||
AccountProducts map[string]string `json:"accountProducts"`
|
||||
TargetTime string `json:"targetTime"`
|
||||
Advance int `json:"advance"`
|
||||
}
|
||||
|
||||
// APIResponse 斗鱼 API 通用响应。
|
||||
type APIResponse struct {
|
||||
Error int `json:"error"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// LogEntry 日志条目。
|
||||
type LogEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Time string `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Category string `json:"category"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ExchangeAttempt 兑换尝试。
|
||||
type ExchangeAttempt struct {
|
||||
Account Account
|
||||
Product Product
|
||||
Index int
|
||||
}
|
||||
|
||||
// ExchangeResult 兑换结果。
|
||||
type ExchangeResult struct {
|
||||
OK bool
|
||||
Account string
|
||||
Product string
|
||||
Attempt int
|
||||
RoleName string
|
||||
Message string
|
||||
}
|
||||
|
||||
// TimeSample 时间采样。
|
||||
type TimeSample struct {
|
||||
ServerTime time.Time
|
||||
LocalTime time.Time
|
||||
RTT time.Duration
|
||||
Offset time.Duration
|
||||
ErrorBound time.Duration
|
||||
RawCurrentTime int64
|
||||
Samples int
|
||||
Source string
|
||||
Fallback bool
|
||||
}
|
||||
|
||||
// EstimatedServerNow 根据采样偏移估算当前斗鱼时间。
|
||||
func (t TimeSample) EstimatedServerNow() time.Time {
|
||||
if t.LocalTime.IsZero() {
|
||||
return t.ServerTime
|
||||
}
|
||||
return time.Now().Add(t.Offset).UTC()
|
||||
}
|
||||
|
||||
// TimeCalibration 时间校准结果。
|
||||
type TimeCalibration struct {
|
||||
Offset time.Duration
|
||||
ErrorBound time.Duration
|
||||
RTT time.Duration
|
||||
Samples int
|
||||
CreatedAt time.Time
|
||||
Source string
|
||||
Fallback bool
|
||||
}
|
||||
|
||||
// EstimatedServerNow 根据校准偏移估算当前斗鱼时间。
|
||||
func (c TimeCalibration) EstimatedServerNow() time.Time {
|
||||
if c.CreatedAt.IsZero() {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return time.Now().Add(c.Offset).UTC()
|
||||
}
|
||||
|
||||
// IsZero 判断校准结果是否为零值。
|
||||
func (c TimeCalibration) IsZero() bool {
|
||||
return c.CreatedAt.IsZero()
|
||||
}
|
||||
|
||||
// WarmupData 预热数据。
|
||||
type WarmupData struct {
|
||||
CSRF string
|
||||
RoleName string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppendCacheBuster 给 URL 追加时间戳,避免时间接口被缓存。
|
||||
func AppendCacheBuster(rawURL string) string {
|
||||
sep := "?"
|
||||
if strings.Contains(rawURL, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
return rawURL + sep + "_=" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
}
|
||||
|
||||
// CloneStringMap 复制 map,避免异步任务读到调用方后续修改。
|
||||
func CloneStringMap(in map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MergeCookie 把指定 Cookie 写入原 Cookie 字符串,已有同名 Cookie 会被覆盖。
|
||||
func MergeCookie(cookie, name, value string) string {
|
||||
parts := strings.Split(cookie, ";")
|
||||
pairs := make([]string, 0, len(parts)+1)
|
||||
replaced := false
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
key, _, ok := strings.Cut(part, "=")
|
||||
if ok && strings.TrimSpace(key) == name {
|
||||
pairs = append(pairs, name+"="+value)
|
||||
replaced = true
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, part)
|
||||
}
|
||||
if !replaced && value != "" {
|
||||
pairs = append(pairs, name+"="+value)
|
||||
}
|
||||
return strings.Join(pairs, "; ")
|
||||
}
|
||||
|
||||
// TrimForLog 截断接口响应,避免日志刷屏。
|
||||
func TrimForLog(s string, max int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "..."
|
||||
}
|
||||
|
||||
// EmptyDefault 在字符串为空时返回默认值。
|
||||
func EmptyDefault(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// SafeFilename 替换文件名中的危险字符。
|
||||
func SafeFilename(name string) string {
|
||||
replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
|
||||
return replacer.Replace(name)
|
||||
}
|
||||
|
||||
// MaskCookie 返回 Cookie 摘要,避免把完整凭据暴露给前端。
|
||||
func MaskCookie(cookie string) string {
|
||||
pairs := strings.Split(cookie, ";")
|
||||
masked := make([]string, 0, 3)
|
||||
total := 0
|
||||
for _, pair := range pairs {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(pair), "=")
|
||||
if !ok || key == "" {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if len(masked) >= 3 {
|
||||
continue
|
||||
}
|
||||
masked = append(masked, key+"="+MaskValue(value))
|
||||
}
|
||||
if total == 0 {
|
||||
return ""
|
||||
}
|
||||
if total > len(masked) {
|
||||
masked = append(masked, fmt.Sprintf("...共%d项", total))
|
||||
}
|
||||
return strings.Join(masked, "; ")
|
||||
}
|
||||
|
||||
// MaskValue 保留少量首尾字符用于识别,其余替换为星号。
|
||||
func MaskValue(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "****"
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
return string(runes[:4]) + "****" + string(runes[len(runes)-4:])
|
||||
}
|
||||
|
||||
// RoundDuration 将时间四舍五入到毫秒。
|
||||
func RoundDuration(d time.Duration) time.Duration {
|
||||
return d.Round(time.Millisecond)
|
||||
}
|
||||
|
||||
// ReadJSON 读取请求 JSON。
|
||||
func ReadJSON(r *http.Request, out any) error {
|
||||
defer r.Body.Close()
|
||||
return json.NewDecoder(r.Body).Decode(out)
|
||||
}
|
||||
|
||||
// WriteJSON 写出 JSON 响应。
|
||||
func WriteJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
// WriteError 写出统一错误 JSON,同时设置 HTTP 状态码。
|
||||
func WriteError(w http.ResponseWriter, status int, err error) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user