小店商品列表shujuok

This commit is contained in:
yml2213
2026-08-08 17:40:16 +08:00
parent 22dc50196f
commit 379a9d58d3
12 changed files with 2733 additions and 27 deletions
File diff suppressed because it is too large Load Diff
@@ -14,7 +14,8 @@ const formData = ref({
concurrency: 150,
attempts: 2,
retryStages: [] as RetryStage[],
seckillMode: 1
seckillMode: 1,
xiaodianPageSize: 200
})
const open = async () => {
@@ -76,6 +77,18 @@ defineExpose({ open, close })
<div class="form-hint">用于获取斗鱼服务端时间,一般不需要修改</div>
</div>
<!-- 和平小店参数 -->
<div class="form-section">
<div class="form-section-title">和平小店商品列表</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">每页商品数</label>
<input v-model.number="formData.xiaodianPageSize" class="form-input" type="number" min="1" max="200" step="1">
<div class="form-hint">角色信息从账号 Cookie 自动获取,接口上限 200</div>
</div>
</div>
</div>
<!-- 日志设置 -->
<div class="form-section">
<div class="form-section-title">日志设置</div>
+5 -2
View File
@@ -18,6 +18,7 @@ export interface Settings {
attempts: number
retryStages: RetryStage[]
seckillMode: number // 0=标准, 1=激进
xiaodianPageSize: number
}
const defaultRetryStages: RetryStage[] = [
@@ -36,7 +37,8 @@ const settings = ref<Settings>({
concurrency: 150,
attempts: 2,
retryStages: [...defaultRetryStages],
seckillMode: 1
seckillMode: 1,
xiaodianPageSize: 200
})
export function useSettings() {
@@ -56,7 +58,8 @@ export function useSettings() {
retryStages: cfg.retryStages && cfg.retryStages.length > 0
? cfg.retryStages
: [...defaultRetryStages],
seckillMode: cfg.seckillMode ?? 1
seckillMode: cfg.seckillMode ?? 1,
xiaodianPageSize: cfg.xiaodianPageSize || 200
}
} catch (e: any) {
showToast('读取设置失败', e.message, 'error')
+21 -1
View File
@@ -23,6 +23,10 @@ type Activity struct {
StoreHomeURL string
CSRFURL string
IsXiaodian bool // 和平小店商品列表使用 QQ 道具接口和响应格式。
ActID string // 和平小店道具活动 ID。
RoleParamURL string // 和平小店 H5 临时授权接口。
RoleURL string // 和平小店角色解析接口。
BatchLimitURL string
PreExchangeURL string
@@ -89,9 +93,25 @@ var peacematch = &Activity{
CTNURL: douyuBase + "/wgapi/livenc/liveweb/csrfApi/getCsrfCookie",
}
// xiaodian 和平小店。商品查询前自动从斗鱼 Cookie 获取角色上下文。
var xiaodian = &Activity{
ID: "xiaodian",
DisplayName: "和平小店",
RoomID: "9263298",
ActAlias: "20260623KDQFH",
ActID: "46195",
BaseURL: "https://app.daoju.qq.com",
ShopReferer: "https://app.daoju.qq.com/",
StoreHomeURL: "https://apps.game.qq.com/daoju/v3/recommend_xn_live_cjm/common",
RoleParamURL: "https://www.douyu.com/japi/carnival/nc/txEmbed/getIframeUrl",
RoleURL: "https://apps.game.qq.com/daoju/igw/live",
IsXiaodian: true,
}
var registry = map[string]*Activity{
peace.ID: peace,
peacematch.ID: peacematch,
xiaodian.ID: xiaodian,
}
// DefaultActivityID 默认活动,新分支默认启用「电竞手册」。
@@ -99,7 +119,7 @@ const DefaultActivityID = "peacematch"
// List 返回所有注册活动,按注册顺序(精英手册在前)。
func List() []*Activity {
return []*Activity{peace, peacematch}
return []*Activity{peace, peacematch, xiaodian}
}
// Get 按 ID 获取活动;不存在时返回 nil。
+3
View File
@@ -79,6 +79,9 @@ func (a *App) SaveSettings(cfg types.Config) types.SimpleResult {
if cfg.TraceMaxBody < 0 {
cfg.TraceMaxBody = 0
}
if cfg.XiaodianPageSize <= 0 || cfg.XiaodianPageSize > 200 {
cfg.XiaodianPageSize = 200
}
// 校验
if cfg.Concurrency <= 0 {
+17 -1
View File
@@ -1,6 +1,8 @@
package app
import (
"fmt"
"strings"
"time"
"douyu_go/internal/constants"
@@ -12,7 +14,21 @@ import (
// LoadProducts 刷新商品列表。refresh=true 时从斗鱼实时拉取。
func (a *App) LoadProducts(refresh bool) types.ProductPayload {
if refresh {
products, err := a.getClient().Products()
cfg := a.getCfg()
options := types.XiaodianProductListOptions{PageSize: cfg.XiaodianPageSize}
var products []types.Product
err := fmt.Errorf("没有可用账号 Cookie")
accounts := a.scheduler.AccountSnapshot()
for _, account := range accounts {
if strings.TrimSpace(account.Cookie) == "" {
continue
}
products, err = a.getClient().Products(account.Cookie, options)
if err == nil {
break
}
logger.LogEvent("商品", "warn", "账号 %s 获取和平小店角色失败,尝试下一个账号: %v", account.Name, err)
}
if err != nil {
logger.LogEvent("商品", "error", "商品刷新失败: %v", err)
return types.ProductPayload{Success: false}
+333 -3
View File
@@ -8,6 +8,7 @@ import (
"io"
"mime/multipart"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
@@ -212,15 +213,18 @@ func (c *DouyuClient) fetchServerTimeOnce() (types.TimeSample, error) {
}, nil
}
// Products 拉取商城商品列表。
func (c *DouyuClient) Products() ([]types.Product, error) {
// Products 拉取当前活动的商品列表。
func (c *DouyuClient) Products(cookie string, xiaodianOptions types.XiaodianProductListOptions) ([]types.Product, error) {
if c.Activity.IsXiaodian {
return c.xiaodianProducts(cookie, xiaodianOptions)
}
var result struct {
types.APIResponse
Data struct {
List []types.Product `json:"list"`
} `json:"data"`
}
if err := c.getJSON(c.Activity.StoreHomeURL, "", "商品列表", &result); err != nil {
if err := c.getJSON(c.Activity.StoreHomeURL, cookie, "商品列表", &result); err != nil {
return nil, err
}
if result.Error != 0 {
@@ -229,6 +233,286 @@ func (c *DouyuClient) Products() ([]types.Product, error) {
return result.Data.List, nil
}
type xiaodianRecommendation struct {
ErrCode string `json:"errcode"`
ErrDesc string `json:"errdesc"`
Data struct {
ClientData struct {
Items []xiaodianItem `json:"itemsdetail"`
} `json:"client_data"`
} `json:"data"`
}
type xiaodianItem struct {
GoodsID string `json:"iGoodsId"`
GoodsName string `json:"sGoodsName"`
Price string `json:"iPrice"`
GoodsLeft string `json:"iGoodsLeft"`
GoodsPic string `json:"sGoodsPic"`
ShowBegin string `json:"dtShowBeginTime"`
ShowEnd string `json:"dtShowEndTime"`
ActionID string `json:"iActionId"`
}
type xiaodianFlexibleString string
// UnmarshalJSON 兼容道具接口把同一字段返回为字符串或数字。
func (s *xiaodianFlexibleString) UnmarshalJSON(data []byte) error {
value := strings.TrimSpace(string(data))
if value == "" || value == "null" {
*s = ""
return nil
}
if strings.HasPrefix(value, `"`) {
var decoded string
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*s = xiaodianFlexibleString(decoded)
return nil
}
*s = xiaodianFlexibleString(value)
return nil
}
type xiaodianRoleContext struct {
OpenID xiaodianFlexibleString `json:"gameOpenId"`
RoleID xiaodianFlexibleString `json:"roleId"`
Type xiaodianFlexibleString `json:"type"`
Area xiaodianFlexibleString `json:"area"`
}
// xiaodianEmbedQuery 获取斗鱼为当前账号签发的短时道具城参数。
func (c *DouyuClient) xiaodianEmbedQuery(cookie string) (url.Values, error) {
uid := cookieValue(cookie, "acf_uid")
stk := cookieValue(cookie, "acf_stk")
ltkid := cookieValue(cookie, "acf_ltkid")
if uid == "" || stk == "" || ltkid == "" {
return nil, errors.New("斗鱼 Cookie 缺少 acf_uid、acf_stk 或 acf_ltkid,无法获取和平小店授权")
}
endpoint, err := url.Parse(c.Activity.RoleParamURL)
if err != nil {
return nil, fmt.Errorf("和平小店授权接口地址无效: %w", err)
}
query := endpoint.Query()
query.Set("actAlias", c.Activity.ActAlias)
query.Set("rid", c.Activity.RoomID)
query.Set("token", uid+"_1_"+stk+"_0_"+ltkid)
endpoint.RawQuery = query.Encode()
req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, err
}
c.setBrowserHeaders(req, cookie)
req.Header.Set("Origin", constants.BaseURL)
req.Header.Set("Referer", "https://www.douyu.com/topic/h5/hpxd01")
var result struct {
types.APIResponse
Data struct {
TXURLH5 string `json:"txurlH5"`
} `json:"data"`
}
if _, err := c.doJSON(req, &result, "获取小店 H5 参数"); err != nil {
return nil, err
}
if result.Error != 0 {
return nil, fmt.Errorf("获取和平小店授权失败: %s", result.Msg)
}
h5, err := url.Parse(result.Data.TXURLH5)
if err != nil || result.Data.TXURLH5 == "" {
return nil, errors.New("和平小店授权响应缺少 txurlH5")
}
return h5.Query(), nil
}
// xiaodianRole 使用短时授权参数解析当前斗鱼账号绑定的游戏角色。
func (c *DouyuClient) xiaodianRole(embedQuery url.Values) (xiaodianRoleContext, error) {
endpoint, err := url.Parse(c.Activity.RoleURL)
if err != nil {
return xiaodianRoleContext{}, fmt.Errorf("和平小店角色接口地址无效: %w", err)
}
query := endpoint.Query()
query.Set("acctype", "livelink")
query.Set("_jsvar", "info")
query.Set("_service", "other.livelink.getrole.out")
query.Set("_biz_code", "cjm")
query.Set("_act_id", c.Activity.ActID)
query.Set("_app_id", "2123")
query.Set("isCode", "1")
for _, key := range []string{"gameId", "actId", "appId", "livePlatId", "code", "timestamp", "v", "sig"} {
query.Set(key, embedQuery.Get(key))
}
query.Set("authType", "delegate")
query.Set("sAnchorId", c.Activity.RoomID)
query.Set("sVideoId", "")
endpoint.RawQuery = query.Encode()
req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
if err != nil {
return xiaodianRoleContext{}, err
}
c.setXiaodianHeaders(req)
body, err := c.doText(req, "查询小店绑定角色", true)
if err != nil {
return xiaodianRoleContext{}, err
}
var result xiaodianRoleContext
if err := parseXiaodianVariable(body, "info", &result); err != nil {
return xiaodianRoleContext{}, fmt.Errorf("和平小店角色响应解析失败: %w", err)
}
switch strings.ToLower(string(result.Type)) {
case "wx":
result.Area = "1"
case "qq":
result.Area = "2"
}
areaID := parseXiaodianInt(string(result.Area))
if areaID <= 0 {
areaID = 1
}
result.Area = xiaodianFlexibleString(strconv.Itoa(areaID))
return result, nil
}
func parseXiaodianVariable(body []byte, name string, out any) error {
text := strings.TrimSpace(string(body))
marker := "var " + name
start := strings.Index(text, marker)
if start < 0 {
return fmt.Errorf("响应中找不到 var %s", name)
}
text = strings.TrimSpace(text[start+len(marker):])
if !strings.HasPrefix(text, "=") {
return fmt.Errorf("var %s 格式无效", name)
}
text = strings.TrimSpace(strings.TrimPrefix(text, "="))
text = strings.TrimSuffix(text, ";")
return json.Unmarshal([]byte(text), out)
}
// xiaodianProducts 查询和平小店全部商品,接口单页最多返回 200 条。
func (c *DouyuClient) xiaodianProducts(cookie string, options types.XiaodianProductListOptions) ([]types.Product, error) {
if strings.TrimSpace(cookie) == "" {
return nil, errors.New("和平小店需要至少一个有效斗鱼 Cookie")
}
if options.PageSize <= 0 || options.PageSize > 200 {
options.PageSize = 200
}
embedQuery, err := c.xiaodianEmbedQuery(cookie)
if err != nil {
return nil, err
}
role, err := c.xiaodianRole(embedQuery)
if err != nil {
return nil, err
}
if role.OpenID == "" || role.RoleID == "" {
return nil, errors.New("未获取到和平小店绑定角色,请确认该斗鱼账号已绑定游戏角色")
}
areaID := parseXiaodianInt(string(role.Area))
if areaID <= 0 {
areaID = 1
}
endpoint, err := url.Parse(c.Activity.StoreHomeURL)
if err != nil {
return nil, fmt.Errorf("和平小店商品接口地址无效: %w", err)
}
query := endpoint.Query()
query.Set("_service", "act.recommend.query")
query.Set("_biz_code", "cjm")
query.Set("_app_id", "2123")
query.Set("app_id", "2123")
query.Set("page_size", strconv.Itoa(options.PageSize))
query.Set("fields", "iRelationGoodsid,dtModifyTime,sBuyLimitInfo,sGoodsWaterMark,dtShowBeginTime,dtShowEndTime,iSort,iGoodsId,sGoodsName,iJbPrice,iJbOrgPrice,iJb2Price,iJb2OrgPrice,iPrice,iOrgPrice,sGoodsPic,sGoodsDesc,sPayType,dtBeginTime,dtEndTime,iActionId,sExtShowInfo,sExtInfo,iCategoryId,dtRushBegin,dtRushEnd")
query.Set("actionFields", "iActionId,sActionName,dtBeginTime,dtEndTime")
query.Set("order_by", "dtShowBeginTime")
query.Set("orderColumnBatch", `[{"key":"iSort","sort":"desc"},{"key":"dtShowBeginTime","sort":"desc"},{"key":"iGoodsId","sort":"asc"}]`)
query.Set("desc", "1")
query.Set("_act_id", c.Activity.ActID)
query.Set("actid", c.Activity.ActID)
query.Set("excludeFields", "iCategoryId_76")
query.Set("userid", string(role.OpenID))
query.Set("roleid", string(role.RoleID))
query.Set("area", "1")
query.Set("areaid", strconv.Itoa(areaID))
query.Set("need_limit", "1")
products := make([]types.Product, 0)
seen := make(map[string]struct{})
for offset, pageNum := 0, 1; ; pageNum++ {
pageQuery := make(url.Values, len(query))
for key, values := range query {
pageQuery[key] = append([]string(nil), values...)
}
pageQuery.Set("page_begin", strconv.Itoa(offset))
pageQuery.Set("page_num", strconv.Itoa(pageNum))
items, err := c.xiaodianProductPage(endpoint, pageQuery)
if err != nil {
return nil, err
}
for _, item := range items {
if item.GoodsID == "" {
continue
}
if _, ok := seen[item.GoodsID]; ok {
continue
}
seen[item.GoodsID] = struct{}{}
products = append(products, types.Product{
CommodityID: item.GoodsID,
CommodityName: item.GoodsName,
Score: parseXiaodianInt(item.Price),
Count: parseXiaodianInt(item.GoodsLeft),
Pic: item.GoodsPic,
})
}
if len(items) < options.PageSize {
break
}
if pageNum >= 100 {
return nil, errors.New("和平小店商品分页超出安全上限")
}
offset += options.PageSize
}
return products, nil
}
func (c *DouyuClient) xiaodianProductPage(endpoint *url.URL, query url.Values) ([]xiaodianItem, error) {
pageURL := *endpoint
pageURL.RawQuery = query.Encode()
req, err := http.NewRequest(http.MethodGet, pageURL.String(), nil)
if err != nil {
return nil, err
}
c.setXiaodianHeaders(req)
body, err := c.doText(req, "和平小店商品列表", false)
if err != nil {
return nil, err
}
var result xiaodianRecommendation
if err := parseXiaodianVariable(body, "recommend", &result); err != nil {
return nil, fmt.Errorf("和平小店商品响应解析失败: %w", err)
}
if result.ErrCode != "" && result.ErrCode != "0" {
return nil, fmt.Errorf("和平小店商品查询失败: %s", result.ErrDesc)
}
items := result.Data.ClientData.Items
if items == nil {
return nil, errors.New("和平小店商品响应格式异常")
}
return items, nil
}
// parseXiaodianInt 将 QQ 道具接口中的字符串数值转为界面商品字段。
func parseXiaodianInt(value string) int {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return 0
}
return parsed
}
// Score 查询账号积分余额。不同活动的积分接口方法/路径不同:
// 和平手册用 POST /japi/oms/web/peace/credit/balance;
// 电竞手册用 GET /wgapi/ordnc/activity/peacematch/score/balance。
@@ -659,6 +943,41 @@ func (c *DouyuClient) doJSON(req *http.Request, out any, label string) (*http.Re
return resp, nil
}
// doText 执行返回 JavaScript 变量的接口请求,并复用统一网络诊断。
func (c *DouyuClient) doText(req *http.Request, label string, redactBody bool) ([]byte, 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, "")
c.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, "")
c.logNetworkError(label, req, time.Since(start), err)
return nil, err
}
if resp.StatusCode != http.StatusOK {
traceBody := body
if redactBody {
traceBody = []byte("<redacted>")
}
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, traceBody, "", 0, "")
c.logNetworkHTTPError(label, req, resp.StatusCode, time.Since(start), body)
return nil, fmt.Errorf("%s HTTP %d: %s", label, resp.StatusCode, utils.TrimForLog(string(body), 160))
}
traceBody := body
if redactBody {
traceBody = []byte("<redacted>")
}
c.Trace.RecordResponse(label, req, resp, resp.StatusCode, time.Since(start), reqBody, traceBody, "", 0, "")
c.logNetworkSuccess(label, req, time.Since(start), 0, "")
return body, nil
}
func shouldLogNetworkSuccess(label string) bool {
return label != "服务时间"
}
@@ -857,3 +1176,14 @@ func (c *DouyuClient) setJSONHeaders(req *http.Request, cookie string) {
c.setBrowserHeaders(req, cookie)
req.Header.Set("Content-Type", "application/json")
}
// setXiaodianHeaders 设置和平小店商品列表请求所需的浏览器头。
func (c *DouyuClient) setXiaodianHeaders(req *http.Request) {
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "zh-CN,zh-Hans;q=0.9")
req.Header.Set("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148, Douyu_IOS")
req.Header.Set("Referer", "https://app.daoju.qq.com/")
req.Header.Set("Sec-Fetch-Dest", "script")
req.Header.Set("Sec-Fetch-Mode", "no-cors")
req.Header.Set("Sec-Fetch-Site", "same-site")
}
+80
View File
@@ -1,8 +1,10 @@
package client
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
@@ -126,6 +128,84 @@ func TestNetworkDiagnosticIncludesActivityAndFieldNamesOnly(t *testing.T) {
}
}
func TestProductsAutomaticallyResolvesXiaodianRoleAndPaginates(t *testing.T) {
var goodsOffsets []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/embed":
if !strings.Contains(r.Header.Get("Cookie"), "acf_uid=uid") {
t.Error("授权请求未携带斗鱼 Cookie")
}
if got := r.URL.Query().Get("token"); got != "uid_1_stk_0_ltkid" {
t.Errorf("token = %q", got)
}
_, _ = w.Write([]byte(`{"error":0,"data":{"txurlH5":"https://app.daoju.qq.com/?gameId=cjm&actId=18882&appId=bp_cf&livePlatId=douyu&code=code&timestamp=1&v=2.0&sig=sig"}}`))
case "/role":
if got := r.URL.Query().Get("code"); got != "code" {
t.Errorf("code = %q", got)
}
if got := r.URL.Query().Get("_act_id"); got != "46195" {
t.Errorf("_act_id = %q", got)
}
_, _ = w.Write([]byte(`var info = {"gameOpenId":"openid","roleId":1192083858,"type":"qq","area":1};`))
case "/goods":
offset := r.URL.Query().Get("page_begin")
goodsOffsets = append(goodsOffsets, offset)
if got := r.URL.Query().Get("areaid"); got != "2" {
t.Errorf("areaid = %q", got)
}
if got := r.URL.Query().Get("page_size"); got != "2" {
t.Errorf("page_size = %q", got)
}
items := []map[string]string{}
switch offset {
case "0":
items = []map[string]string{{"iGoodsId": "g1", "sGoodsName": "商品一", "iPrice": "10", "iGoodsLeft": "-1"}, {"iGoodsId": "g2", "sGoodsName": "商品二", "iPrice": "20", "iGoodsLeft": "3"}}
case "2":
items = []map[string]string{{"iGoodsId": "g2", "sGoodsName": "重复商品", "iPrice": "20", "iGoodsLeft": "3"}, {"iGoodsId": "g3", "sGoodsName": "商品三", "iPrice": "30", "iGoodsLeft": "0"}}
}
body, _ := json.Marshal(map[string]any{"data": map[string]any{"client_data": map[string]any{"itemsdetail": items}}})
_, _ = w.Write(append([]byte("var recommend="), append(body, ';')...))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
act := &activity.Activity{
ID: "xiaodian",
DisplayName: "和平小店",
RoomID: "9263298",
ActAlias: "20260623KDQFH",
ActID: "46195",
BaseURL: server.URL,
ShopReferer: server.URL,
RoleParamURL: server.URL + "/embed",
RoleURL: server.URL + "/role",
StoreHomeURL: server.URL + "/goods",
IsXiaodian: true,
}
cli := New(act, "", &trace.Recorder{})
cli.HTTPClient = server.Client()
products, err := cli.Products("acf_uid=uid; acf_stk=stk; acf_ltkid=ltkid", types.XiaodianProductListOptions{PageSize: 2})
if err != nil {
t.Fatalf("Products() error = %v", err)
}
if len(products) != 3 {
t.Fatalf("商品数量 = %d,期望 3", len(products))
}
if products[0].CommodityID != "g1" || products[2].CommodityID != "g3" {
t.Fatalf("商品顺序 = %#v", products)
}
if products[1].Count != 3 || products[2].Score != 30 {
t.Fatalf("商品字段 = %#v", products)
}
if got := strings.Join(goodsOffsets, ","); got != strings.Join([]string{"0", strconv.Itoa(2), strconv.Itoa(4)}, ",") {
t.Fatalf("分页偏移 = %s", got)
}
}
func testCTNClient(t *testing.T, ctnURL string) *DouyuClient {
t.Helper()
act := &activity.Activity{
+18 -9
View File
@@ -76,15 +76,16 @@ func envInt(key string, fallback int) int {
// defaultConfig 返回内置默认配置。
func defaultConfig() types.Config {
return types.Config{
Activity: activity.DefaultActivityID,
TimeAPIURL: constants.DefaultTimeAPI,
TraceEnabled: false,
TraceMaxBody: 0,
LogLevel: "info",
Concurrency: constants.DefaultConcurrency,
Attempts: constants.DefaultAttempts,
RetryStages: defaultRetryStages(),
SeckillMode: types.SeckillModeAggressive,
Activity: activity.DefaultActivityID,
TimeAPIURL: constants.DefaultTimeAPI,
TraceEnabled: false,
TraceMaxBody: 0,
LogLevel: "info",
Concurrency: constants.DefaultConcurrency,
Attempts: constants.DefaultAttempts,
RetryStages: defaultRetryStages(),
SeckillMode: types.SeckillModeAggressive,
XiaodianPageSize: 200,
}
}
@@ -138,10 +139,18 @@ func LoadConfig(path string) types.Config {
if len(cfg.RetryStages) == 0 {
cfg.RetryStages = defaultRetryStages()
}
normalizeXiaodianConfig(&cfg)
logger.LogEvent("配置", "success", "配置加载完成: %s", path)
return cfg
}
// normalizeXiaodianConfig 约束和平小店分页参数,接口单页最大为 200。
func normalizeXiaodianConfig(cfg *types.Config) {
if cfg.XiaodianPageSize <= 0 || cfg.XiaodianPageSize > 200 {
cfg.XiaodianPageSize = 200
}
}
// SaveConfig 将配置写入文件。
func SaveConfig(path string, cfg types.Config) error {
if path == "" {
+19 -1
View File
@@ -8,6 +8,7 @@ import (
"mime"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
@@ -112,7 +113,7 @@ func (t *Recorder) RecordResponse(label string, req *http.Request, resp *http.Re
Time: time.Now().In(constants.CST).Format("2006-01-02 15:04:05.000"),
Label: label,
Method: req.Method,
URL: req.URL.Redacted(),
URL: redactedURL(req.URL),
Path: req.URL.Path,
Status: status,
Cost: cost,
@@ -128,6 +129,23 @@ func (t *Recorder) RecordResponse(label string, req *http.Request, resp *http.Re
_, _ = t.writer.Write([]byte(formatEntry(e)))
}
// redactedURL 隐藏请求 URL 中的账号、角色与签名参数,避免 trace 落盘敏感信息。
func redactedURL(raw *url.URL) string {
if raw == nil {
return ""
}
copyURL := *raw
query := copyURL.Query()
for key := range query {
switch strings.ToLower(key) {
case "userid", "roleid", "openid", "code", "sig", "token", "sid":
query.Set(key, "<hidden>")
}
}
copyURL.RawQuery = query.Encode()
return copyURL.String()
}
// RequestBody 读取请求体用于追踪。
func (t *Recorder) RequestBody(req *http.Request) []byte {
if t == nil || !t.Enabled || req == nil || req.GetBody == nil {
+15 -9
View File
@@ -7,15 +7,21 @@ import (
// Config 应用配置。
type Config struct {
TimeAPIURL string `json:"timeApiUrl"`
Activity string `json:"activity"` // 当前活动 ID,见 internal/activity
TraceEnabled bool `json:"traceEnabled"`
TraceMaxBody int `json:"traceMaxBody"`
LogLevel string `json:"logLevel"` // debug / info / warn / error
Concurrency int `json:"concurrency"` // 抢购并发数
Attempts int `json:"attempts"` // 单号重试次数
RetryStages []RetryStage `json:"retryStages"` // 重试间隔阶段配置
SeckillMode SeckillMode `json:"seckillMode"` // 抢购模式:0=标准, 1=激进
TimeAPIURL string `json:"timeApiUrl"`
Activity string `json:"activity"` // 当前活动 ID,见 internal/activity
TraceEnabled bool `json:"traceEnabled"`
TraceMaxBody int `json:"traceMaxBody"`
LogLevel string `json:"logLevel"` // debug / info / warn / error
Concurrency int `json:"concurrency"` // 抢购并发数
Attempts int `json:"attempts"` // 单号重试次数
RetryStages []RetryStage `json:"retryStages"` // 重试间隔阶段配置
SeckillMode SeckillMode `json:"seckillMode"` // 抢购模式:0=标准, 1=激进
XiaodianPageSize int `json:"xiaodianPageSize"`
}
// XiaodianProductListOptions 是和平小店商品列表的角色与分页参数。
type XiaodianProductListOptions struct {
PageSize int
}
// ActivityInfo 活动概要,供前端下拉选择使用。
+46
View File
@@ -0,0 +1,46 @@
# Case Scope
## meta
- case_id: 20260808-heping-shop-list
- created: 2026-08-08T00:00:00+08:00
- operator: local
- primary_skill: browser-automation
- lead_role: lead
- specialist_roles: []
## auth
- status: granted
- basis: own_system
- evidence_of_auth: user-provided capture and explicit feature request
## in_scope
- assets: ["/Users/yml/Desktop/抓包/douyu/8-8和平小店/全部商品和购买记录-1.chlsj", "https://www.douyu.com/japi/carnival/nc/txEmbed/getIframeUrl", "https://apps.game.qq.com/daoju/igw/live", "https://apps.game.qq.com/daoju/v3/recommend_xn_live_cjm/common"]
- surfaces: ["captured HTTP API", "local Go application"]
- activities: ["offline capture analysis", "role-context request validation", "product-list request validation", "local feature implementation"]
## out_of_scope
- assets: []
- activities: ["account modification", "purchase submission", "load testing", "unrelated endpoint enumeration"]
## network_profile
- mode: authorized_target_only
- notes: Only the captured product-list endpoint may be requested for validation.
## deliverables
- report: false
- field_journal: false
- diagrams: false
- timeline: false
## constraints
- timebox: feature implementation
- stealth: low
- data_handling: no_user_pii
## signoff
- ready_for_act: true
- checklist:
- [x] auth.status = granted
- [x] in_scope.assets non-empty OR offline sample path set
- [x] network_profile.mode chosen
- [x] out_of_scope reviewed