(null)
@@ -76,8 +76,8 @@ watch(xiaodianTab, () => {
{{ filteredProducts[item.index].score }} {{ priceLabel }}
+ :title="`${filteredProducts[item.index].score} ${priceLabel(filteredProducts[item.index])}`"
+ >{{ filteredProducts[item.index].score }} {{ priceLabel(filteredProducts[item.index]) }}
([])
const selectedIndices = ref>(new Set())
const statusMap = ref>({})
const scoreMap = ref>({})
+const accountProductMap = ref>({})
export interface XiaodianAssets {
success: boolean
coupons: number
@@ -93,6 +94,12 @@ export function useAccounts() {
statusMap.value = JSON.parse(savedStatus)
} catch {}
}
+ const savedProducts = localStorage.getItem('accountProducts')
+ if (savedProducts) {
+ try {
+ accountProductMap.value = JSON.parse(savedProducts)
+ } catch {}
+ }
}
const queryAllScore = async () => {
@@ -170,26 +177,43 @@ export function useAccounts() {
markSelectedStatus({ pending: true, message })
}
- const buildAccountProducts = (productId: string): Record | null => {
+ const setAccountProduct = (accountName: string, productId: string) => {
+ if (productId) {
+ accountProductMap.value[accountName] = productId
+ } else {
+ delete accountProductMap.value[accountName]
+ }
+ localStorage.setItem('accountProducts', JSON.stringify(accountProductMap.value))
+ }
+
+ const getAccountProduct = (accountName: string): string => {
+ return accountProductMap.value[accountName] || ''
+ }
+
+ const buildAccountProducts = (productId: string, respectOverrides = true): Record | null => {
if (selectedIndices.value.size === 0) {
showToast('提示', '请勾选要抢购的账号', 'warn')
return null
}
- if (!productId) {
- showToast('提示', '请先选择商品', 'warn')
- return null
- }
const ap: Record = {}
selectedIndices.value.forEach(i => {
const account = accounts.value[i]
if (account) {
- ap[account.name] = productId
+ const selectedProduct = respectOverrides ? getAccountProduct(account.name) : ''
+ const assignedProduct = selectedProduct || productId
+ if (assignedProduct) {
+ ap[account.name] = assignedProduct
+ }
}
})
if (Object.keys(ap).length === 0) {
- showToast('提示', '没有有效账号可提交', 'warn')
+ showToast('提示', '请先选择商品,或为每个账号分配商品', 'warn')
+ return null
+ }
+ if (Object.keys(ap).length !== selectedIndices.value.size) {
+ showToast('提示', '还有已选账号没有商品', 'warn')
return null
}
return ap
@@ -201,6 +225,7 @@ export function useAccounts() {
statusMap,
scoreMap,
xiaodianAssetsMap,
+ accountProductMap,
loadAccounts,
reloadAccounts,
toggleAccount,
@@ -217,6 +242,8 @@ export function useAccounts() {
setStatus,
markSelectedStatus,
markSelectedPending,
+ setAccountProduct,
+ getAccountProduct,
selectedAccountNames,
buildAccountProducts
}
diff --git a/frontend/src/composables/useProducts.ts b/frontend/src/composables/useProducts.ts
index cfbae4a..ef5c3d2 100644
--- a/frontend/src/composables/useProducts.ts
+++ b/frontend/src/composables/useProducts.ts
@@ -8,6 +8,8 @@ export interface Product {
commodityName: string
category: string
score: number
+ payType?: number
+ payLabel?: string
count: number
pic: string
status: number
diff --git a/frontend/src/composables/useScheduler.ts b/frontend/src/composables/useScheduler.ts
index f2c08c9..8b9416c 100644
--- a/frontend/src/composables/useScheduler.ts
+++ b/frontend/src/composables/useScheduler.ts
@@ -3,24 +3,63 @@ import { useToast } from './useToast'
import { useAccounts } from './useAccounts'
import { useProducts } from './useProducts'
import { useLogs } from './useLogs'
-import { CancelSchedule, SetSchedule, StartFixedSeckill, StartSeckill } from '@wails/go/app/App'
+import { useActivity } from './useActivity'
+import { CancelSchedule, GetSeckillStatus, SetSchedule, StartFixedSeckill, StartSeckill, StartXiaodianSeckill, StopSeckill } from '@wails/go/app/App'
+import { EventsOn } from '@wails/runtime/runtime'
const scheduleActive = ref(false)
+const seckillRunning = ref(false)
const countdownSeconds = ref(0)
const countdownTimer = ref(null)
const runState = ref('等待操作')
+let unsubscribeSeckillState: (() => void) | null = null
export function useScheduler() {
const { showToast } = useToast()
const { resetStatus, buildAccountProducts, markSelectedPending, markSelectedStatus } = useAccounts()
const { selectedProductId, selectedProductName } = useProducts()
+ const { currentActivity } = useActivity()
const { addLocalLog, clearLocalResults } = useLogs()
const setRunState = (text: string) => {
runState.value = text || '等待操作'
}
- const startNow = async () => {
+ const refreshRunningState = async () => {
+ try {
+ const d = await GetSeckillStatus()
+ seckillRunning.value = !!d?.success
+ } catch {
+ seckillRunning.value = false
+ }
+ }
+
+ const applySeckillState = (state: string) => {
+ seckillRunning.value = state === 'running' || state === 'stopping'
+ if (state === 'stopping') {
+ setRunState('正在停止抢购')
+ }
+ }
+
+ const subscribeSeckillState = () => {
+ if (unsubscribeSeckillState) {
+ return unsubscribeSeckillState
+ }
+ unsubscribeSeckillState = EventsOn('seckill:state', (state: string) => {
+ applySeckillState(state)
+ })
+ void refreshRunningState()
+ return unsubscribeSeckillState
+ }
+
+ const unsubscribeSchedulerEvents = () => {
+ if (unsubscribeSeckillState) {
+ unsubscribeSeckillState()
+ unsubscribeSeckillState = null
+ }
+ }
+
+ const startNow = async (attempts = 0, delayMs = 0) => {
const ap = buildAccountProducts(selectedProductId.value || '')
if (!ap) return
@@ -32,10 +71,13 @@ export function useScheduler() {
showToast('抢购中', `${Object.keys(ap).length} 个账号正在抢购`, 'info', 3000)
try {
- const d = await StartSeckill(ap)
+ const d = currentActivity.value?.id === 'xiaodian'
+ ? await StartXiaodianSeckill({ accountProducts: ap, attempts, delayMs })
+ : await StartSeckill(ap)
if (d?.success === false) {
throw new Error(d.error || '提交失败')
}
+ seckillRunning.value = true
setRunState(d.msg || '已提交')
addLocalLog(d.msg || '抢购已提交', 'i')
} catch (e: any) {
@@ -46,7 +88,7 @@ export function useScheduler() {
}
}
- const startSchedule = async (targetTime: string) => {
+ const startSchedule = async (targetTime: string, attempts = 0, delayMs = 0) => {
const ap = buildAccountProducts(selectedProductId.value || '')
if (!ap) return
@@ -76,7 +118,9 @@ export function useScheduler() {
try {
const d = await SetSchedule({
accountProducts: ap,
- targetTime: fullTime
+ targetTime: fullTime,
+ attempts,
+ delayMs
})
if (d?.success === false) {
throw new Error('定时设置失败')
@@ -104,7 +148,7 @@ export function useScheduler() {
}
const startFixed = async (attempts: number, delayMs: number) => {
- const ap = buildAccountProducts(selectedProductId.value || '')
+ const ap = buildAccountProducts(selectedProductId.value || '', false)
if (!ap) return false
resetStatus()
clearLocalResults()
@@ -113,6 +157,7 @@ export function useScheduler() {
try {
const d = await StartFixedSeckill({ accountProducts: ap, attempts, delayMs })
if (d?.success === false) throw new Error(d.error || '提交失败')
+ seckillRunning.value = true
setRunState(d.msg || '固定抢购已启动')
addLocalLog(`固定抢购: ${Object.keys(ap).length} 个账号,${attempts} 次,间隔 ${delayMs}ms`, 'i')
showToast('固定抢购已启动', `${Object.keys(ap).length} 个账号`, 'info', 3000)
@@ -138,6 +183,22 @@ export function useScheduler() {
showToast('定时已停止', '抢购任务已取消', 'warn')
}
+ const stopSeckill = async () => {
+ try {
+ const d = await StopSeckill()
+ if (d?.success === false) {
+ throw new Error(d.error || '停止失败')
+ }
+ setRunState(d.msg || '已停止抢购')
+ showToast('已停止抢购', d.msg || '', 'warn')
+ } catch (e: any) {
+ setRunState('停止失败')
+ showToast('停止失败', e?.message || '', 'error')
+ } finally {
+ seckillRunning.value = false
+ }
+ }
+
const startCountdown = (sec: number) => {
stopCountdown()
const endTime = Date.now() + sec * 1000
@@ -177,10 +238,14 @@ export function useScheduler() {
return {
scheduleActive,
+ seckillRunning,
countdownSeconds,
runState,
setRunState,
+ subscribeSeckillState,
+ unsubscribeSchedulerEvents,
startNow,
+ stopSeckill,
startFixed,
startSchedule,
cancelSchedule,
diff --git a/internal/app/app.go b/internal/app/app.go
index a8b0890..cefd478 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/golang/freetype/truetype"
+ "github.com/wailsapp/wails/v2/pkg/runtime"
"douyu_go/internal/activity"
"douyu_go/internal/client"
@@ -140,6 +141,9 @@ func (a *App) Startup(ctx context.Context) {
a.client = client.New(act, a.cfg.TimeAPIURL, a.getTrace())
a.font = imgpkg.LoadFont(a.paths.FontFiles)
a.scheduler = seckill.New(a.client, a.paths, a.font, a.cfg.Concurrency, a.cfg.Attempts, a.cfg.RetryStages, a.cfg.SeckillMode)
+ a.scheduler.SetStateChangeCallback(func(state string) {
+ runtime.EventsEmit(a.ctx, seckillStateEvent, state)
+ })
a.scheduler.LoadAccounts()
if err := os.MkdirAll(a.paths.SuccessDir, 0755); err != nil {
diff --git a/internal/app/app_seckill.go b/internal/app/app_seckill.go
index 9d9bb9a..53cfa55 100644
--- a/internal/app/app_seckill.go
+++ b/internal/app/app_seckill.go
@@ -9,6 +9,8 @@ import (
"douyu_go/internal/utils"
)
+const seckillStateEvent = "seckill:state"
+
// StartSeckill 立即启动抢购。
func (a *App) StartSeckill(accountProducts map[string]string) types.MessageResult {
if len(accountProducts) == 0 {
@@ -21,8 +23,30 @@ func (a *App) StartSeckill(accountProducts map[string]string) types.MessageResul
return types.MessageResult{Success: true, Msg: "抢购已启动"}
}
+// StartXiaodianSeckill 启动和平小店专用次数/间隔抢购。
+func (a *App) StartXiaodianSeckill(req types.FixedSeckillRequest) types.MessageResult {
+ return a.startXiaodianSeckill(req, "和平小店抢购已启动")
+}
+
+// StopSeckill 停止当前正在运行的抢购任务。
+func (a *App) StopSeckill() types.MessageResult {
+ if !a.scheduler.StopRun() {
+ return types.MessageResult{Success: true, Msg: "当前没有正在运行的抢购任务"}
+ }
+ return types.MessageResult{Success: true, Msg: "已发送停止指令"}
+}
+
+// GetSeckillStatus 返回当前抢购运行状态。
+func (a *App) GetSeckillStatus() types.SimpleResult {
+ return types.SimpleResult{Success: a.scheduler.IsRunning()}
+}
+
// StartFixedSeckill 启动和平小店固定次数/间隔抢购。
func (a *App) StartFixedSeckill(req types.FixedSeckillRequest) types.MessageResult {
+ return a.startXiaodianSeckill(req, "固定次数抢购已启动")
+}
+
+func (a *App) startXiaodianSeckill(req types.FixedSeckillRequest, message string) types.MessageResult {
if !a.getClient().Activity.IsXiaodian {
return types.MessageResult{Error: "固定次数抢购仅支持和平小店"}
}
@@ -38,8 +62,8 @@ func (a *App) StartFixedSeckill(req types.FixedSeckillRequest) types.MessageResu
if !a.scheduler.TryStartRun() {
return types.MessageResult{Error: "抢购任务正在运行,请勿重复提交"}
}
- go a.scheduler.RunFixed(utils.CloneStringMap(req.AccountProducts), req.Attempts, req.DelayMs)
- return types.MessageResult{Success: true, Msg: "固定次数抢购已启动"}
+ go a.scheduler.RunXiaodian(utils.CloneStringMap(req.AccountProducts), req.Attempts, req.DelayMs)
+ return types.MessageResult{Success: true, Msg: message}
}
// SetSchedule 设置定时抢购。
diff --git a/internal/client/client.go b/internal/client/client.go
index b530b75..b8ba9a0 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -12,6 +12,7 @@ import (
"sort"
"strconv"
"strings"
+ "sync"
"time"
"douyu_go/internal/activity"
@@ -28,6 +29,14 @@ type DouyuClient struct {
Activity *activity.Activity
TimeAPIURL string
Trace *trace.Recorder
+
+ xiaodianEmbedMu sync.Mutex
+ xiaodianEmbedCache map[string]xiaodianEmbedCacheItem
+}
+
+type xiaodianEmbedCacheItem struct {
+ query url.Values
+ expiresAt time.Time
}
// New 创建斗鱼客户端。act 决定使用哪个活动的接口参数;
@@ -43,9 +52,10 @@ func New(act *activity.Activity, timeAPIURL string, tr *trace.Recorder) *DouyuCl
tr = &trace.Recorder{}
}
return &DouyuClient{
- Activity: act,
- TimeAPIURL: timeAPIURL,
- Trace: tr,
+ Activity: act,
+ TimeAPIURL: timeAPIURL,
+ Trace: tr,
+ xiaodianEmbedCache: make(map[string]xiaodianEmbedCacheItem),
HTTPClient: &http.Client{
Timeout: 8 * time.Second,
Transport: &http.Transport{
@@ -248,6 +258,8 @@ type xiaodianItem struct {
GoodsName string `json:"sGoodsName"`
Category xiaodianFlexibleString `json:"iCategoryId"`
Price string `json:"iPrice"`
+ JB2Price string `json:"iJb2Price"`
+ PayType string `json:"sPayType"`
GoodsLeft string `json:"iGoodsLeft"`
GoodsPic string `json:"sGoodsPic"`
ShowBegin string `json:"dtShowBeginTime"`
@@ -304,6 +316,13 @@ type xiaodianOrderGoods struct {
Quantity xiaodianFlexibleString `json:"iQuantity"`
}
+type xiaodianBuyInfo struct {
+ Ret xiaodianFlexibleString `json:"ret"`
+ Msg string `json:"msg"`
+ Serial string `json:"serial"`
+ SerialNum string `json:"sSerialNum"`
+}
+
// xiaodianEmbedQuery 获取斗鱼为当前账号签发的短时道具城参数。
func (c *DouyuClient) xiaodianEmbedQuery(cookie string) (url.Values, error) {
uid := cookieValue(cookie, "acf_uid")
@@ -312,6 +331,10 @@ func (c *DouyuClient) xiaodianEmbedQuery(cookie string) (url.Values, error) {
if uid == "" || stk == "" || ltkid == "" {
return nil, errors.New("斗鱼 Cookie 缺少 acf_uid、acf_stk 或 acf_ltkid,无法获取和平小店授权")
}
+ cacheKey := uid + ":" + c.Activity.ActAlias + ":" + c.Activity.RoomID
+ if cached, ok := c.getXiaodianEmbedCache(cacheKey); ok {
+ return cached, nil
+ }
endpoint, err := url.Parse(c.Activity.RoleParamURL)
if err != nil {
return nil, fmt.Errorf("和平小店授权接口地址无效: %w", err)
@@ -345,7 +368,37 @@ func (c *DouyuClient) xiaodianEmbedQuery(cookie string) (url.Values, error) {
if err != nil || result.Data.TXURLH5 == "" {
return nil, errors.New("和平小店授权响应缺少 txurlH5")
}
- return h5.Query(), nil
+ queryValues := h5.Query()
+ c.setXiaodianEmbedCache(cacheKey, queryValues)
+ return cloneValues(queryValues), nil
+}
+
+func (c *DouyuClient) getXiaodianEmbedCache(key string) (url.Values, bool) {
+ c.xiaodianEmbedMu.Lock()
+ defer c.xiaodianEmbedMu.Unlock()
+ item, ok := c.xiaodianEmbedCache[key]
+ if !ok || time.Now().After(item.expiresAt) {
+ delete(c.xiaodianEmbedCache, key)
+ return nil, false
+ }
+ return cloneValues(item.query), true
+}
+
+func (c *DouyuClient) setXiaodianEmbedCache(key string, values url.Values) {
+ c.xiaodianEmbedMu.Lock()
+ defer c.xiaodianEmbedMu.Unlock()
+ c.xiaodianEmbedCache[key] = xiaodianEmbedCacheItem{
+ query: cloneValues(values),
+ expiresAt: time.Now().Add(45 * time.Second),
+ }
+}
+
+func cloneValues(values url.Values) url.Values {
+ out := make(url.Values, len(values))
+ for key, items := range values {
+ out[key] = append([]string(nil), items...)
+ }
+ return out
}
// xiaodianRole 使用短时授权参数解析当前斗鱼账号绑定的游戏角色。
@@ -479,11 +532,14 @@ func (c *DouyuClient) xiaodianProducts(cookie string, options types.XiaodianProd
continue
}
seen[item.GoodsID] = struct{}{}
+ payType, payLabel, price := xiaodianPayment(item)
products = append(products, types.Product{
CommodityID: item.GoodsID,
CommodityName: item.GoodsName,
Category: string(item.Category),
- Score: parseXiaodianInt(item.Price),
+ Score: price,
+ PayType: payType,
+ PayLabel: payLabel,
Count: parseXiaodianInt(item.GoodsLeft),
Pic: item.GoodsPic,
})
@@ -491,6 +547,18 @@ func (c *DouyuClient) xiaodianProducts(cookie string, options types.XiaodianProd
return products, nil
}
+func xiaodianPayment(item xiaodianItem) (int, string, int) {
+ payType := 1
+ label := "点券"
+ price := parseXiaodianInt(item.Price)
+ if strings.Contains(strings.ToLower(item.PayType), "jb2") || parseXiaodianInt(item.JB2Price) > 0 {
+ payType = 5
+ label = "扭蛋碎片"
+ price = parseXiaodianInt(item.JB2Price)
+ }
+ return payType, label, price
+}
+
// xiaodianProductPages 按分类拉取完整分页;分类为空时查询全部商品。
func (c *DouyuClient) xiaodianProductPages(endpoint *url.URL, query url.Values, pageSize int, category, excludeCategory, label string) ([]xiaodianItem, error) {
itemsAll := make([]xiaodianItem, 0)
@@ -749,6 +817,9 @@ func (c *DouyuClient) Score(cookie string) (int, error) {
// GenerateCSRF 生成当前活动需要的鉴权 token。
func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) {
+ if c.Activity.IsXiaodian {
+ return "", nil
+ }
if c.Activity.UseCTN {
ctn, err := c.FetchCTN(cookie)
if err != nil {
@@ -875,6 +946,9 @@ func csrfFromResponse(resp *http.Response) string {
// BatchExchangeLimit 请求兑换限制接口。电竞手册不需要此步骤,直接跳过返回默认限制。
func (c *DouyuClient) BatchExchangeLimit(cookie, commodityID string, csrf *string) (int, error) {
+ if c.Activity.IsXiaodian {
+ return 1, nil
+ }
if c.Activity.UseCTN {
return 1, nil
}
@@ -908,6 +982,9 @@ func (c *DouyuClient) BatchExchangeLimit(cookie, commodityID string, csrf *strin
// PreExchange 按抓包流程执行预兑换检查。电竞手册不需要此步骤,直接跳过。
func (c *DouyuClient) PreExchange(cookie, commodityID string, csrf *string) error {
+ if c.Activity.IsXiaodian {
+ return nil
+ }
if c.Activity.UseCTN {
return nil
}
@@ -933,6 +1010,9 @@ func (c *DouyuClient) PreExchange(cookie, commodityID string, csrf *string) erro
// Exchange 正式兑换一个商品。
func (c *DouyuClient) Exchange(cookie, commodityID string, csrf *string) (string, error) {
+ if c.Activity.IsXiaodian {
+ return c.xiaodianExchange(cookie, types.Product{CommodityID: commodityID})
+ }
var result struct {
types.APIResponse
Data struct {
@@ -995,6 +1075,115 @@ func (c *DouyuClient) Exchange(cookie, commodityID string, csrf *string) (string
return fmt.Sprintf("exchangeId=%d,num=%s", result.Data.ExchangeID, result.Data.ExchangeNum), nil
}
+// ExchangeProduct 正式兑换一个商品。和平小店需要商品支付类型/价格,因此应优先走这个入口。
+func (c *DouyuClient) ExchangeProduct(cookie string, product types.Product, csrf *string) (string, error) {
+ if c.Activity.IsXiaodian {
+ return c.xiaodianExchange(cookie, product)
+ }
+ return c.Exchange(cookie, product.CommodityID, csrf)
+}
+
+func (c *DouyuClient) xiaodianExchange(cookie string, product types.Product) (string, error) {
+ if strings.TrimSpace(cookie) == "" {
+ return "", errors.New("Cookie 为空")
+ }
+ if strings.TrimSpace(product.CommodityID) == "" {
+ return "", errors.New("商品 ID 为空")
+ }
+ embedQuery, err := c.xiaodianEmbedQuery(cookie)
+ if err != nil {
+ return "", err
+ }
+ payType := product.PayType
+ if payType <= 0 {
+ payType = 1
+ }
+ payPrice := product.Score
+ if payPrice < 0 {
+ payPrice = 0
+ }
+
+ endpoint, err := url.Parse(c.xiaodianLiveBuyURL())
+ if err != nil {
+ return "", err
+ }
+ query := endpoint.Query()
+ query.Set("_service", "buy.plug.svr.web")
+ query.Set("_plug_id", "7200")
+ query.Set("_jsvar", "buyInfo")
+ query.Set("acctype", "livelink")
+ query.Set("_app_id", "2123")
+ query.Set("iActionId", c.Activity.ActID)
+ query.Set("propid", product.CommodityID)
+ query.Set("paytype", strconv.Itoa(payType))
+ query.Set("source", c.Activity.RoomID)
+ query.Set("sAnchorId", c.Activity.RoomID)
+ query.Set("attach", `{"actId":"`+embedQuery.Get("actId")+`"}`)
+ query.Set("apptype", "3")
+ query.Set("_biz_code", "cjm")
+ query.Set("_act_id", c.Activity.ActID)
+ query.Set("authType", "delegate")
+ query.Set("sLiveUserInfo", `{"sAnchorId":"`+c.Activity.RoomID+`","sVideoId":0}`)
+ for _, key := range []string{"actId", "appId", "gameId", "livePlatId", "code", "timestamp", "v", "sig"} {
+ query.Set(key, embedQuery.Get(key))
+ }
+ appext, err := json.Marshal(map[string]any{
+ "user_price": payPrice,
+ "sGoodsPic": product.Pic,
+ "paytype": payType,
+ })
+ if err != nil {
+ return "", err
+ }
+ query.Set("appext", string(appext))
+ query.Set("_sid", "8")
+ query.Set("set", "8")
+ endpoint.RawQuery = query.Encode()
+
+ req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
+ if err != nil {
+ return "", err
+ }
+ c.setXiaodianHeaders(req)
+ body, err := c.doText(req, "和平小店正式兑换", true)
+ if err != nil {
+ return "", err
+ }
+ var result xiaodianBuyInfo
+ if err := parseXiaodianVariable(body, "buyInfo", &result); err != nil {
+ return "", fmt.Errorf("和平小店兑换响应解析失败: %w", err)
+ }
+ if result.Ret != "0" {
+ if strings.TrimSpace(result.Msg) == "" {
+ return "", fmt.Errorf("和平小店兑换失败: ret=%s", result.Ret)
+ }
+ return "", fmt.Errorf("和平小店兑换失败: %s", result.Msg)
+ }
+ serial := result.Serial
+ if serial == "" {
+ serial = result.SerialNum
+ }
+ if serial == "" {
+ return "paytype=" + strconv.Itoa(payType), nil
+ }
+ return fmt.Sprintf("serial=%s,paytype=%d", serial, payType), nil
+}
+
+func (c *DouyuClient) xiaodianLiveBuyURL() string {
+ roleURL := strings.TrimSpace(c.Activity.RoleURL)
+ if roleURL == "" {
+ return "https://apps.game.qq.com/daoju/igw/livebuy/"
+ }
+ endpoint, err := url.Parse(roleURL)
+ if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
+ return "https://apps.game.qq.com/daoju/igw/livebuy/"
+ }
+ endpoint.Path = "/daoju/igw/livebuy/"
+ endpoint.RawQuery = ""
+ endpoint.Fragment = ""
+ return endpoint.String()
+}
+
// RoleName 查询绑定角色名。
func (c *DouyuClient) RoleName(cookie string) (string, error) {
var result struct {
diff --git a/internal/client/client_test.go b/internal/client/client_test.go
index c315319..806b766 100644
--- a/internal/client/client_test.go
+++ b/internal/client/client_test.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "net/url"
"strings"
"sync/atomic"
"testing"
@@ -171,14 +172,14 @@ func TestProductsAutomaticallyResolvesXiaodianRoleAndPaginates(t *testing.T) {
if category == "76" {
switch offset {
case "0":
- items = []map[string]string{{"iGoodsId": "g3", "sGoodsName": "热门商品", "iCategoryId": "76", "iPrice": "30", "iGoodsLeft": "0"}}
+ items = []map[string]string{{"iGoodsId": "g3", "sGoodsName": "热门商品", "iCategoryId": "76", "iPrice": "30", "sPayType": "qpqb", "iGoodsLeft": "0"}}
}
} else {
switch offset {
case "0":
- items = []map[string]string{{"iGoodsId": "g1", "sGoodsName": "商品一", "iCategoryId": "1", "iPrice": "10", "iGoodsLeft": "-1"}, {"iGoodsId": "g2", "sGoodsName": "特惠商品", "iCategoryId": "45", "iPrice": "20", "iGoodsLeft": "3"}}
+ items = []map[string]string{{"iGoodsId": "g1", "sGoodsName": "商品一", "iCategoryId": "1", "iPrice": "10", "sPayType": "qpqb", "iGoodsLeft": "-1"}, {"iGoodsId": "g2", "sGoodsName": "特惠商品", "iCategoryId": "45", "iPrice": "0", "iJb2Price": "20", "sPayType": "jb2", "iGoodsLeft": "3"}}
case "2":
- items = []map[string]string{{"iGoodsId": "g2", "sGoodsName": "重复商品", "iCategoryId": "45", "iPrice": "20", "iGoodsLeft": "3"}, {"iGoodsId": "g4", "sGoodsName": "商品四", "iCategoryId": "1", "iPrice": "40", "iGoodsLeft": "0"}}
+ items = []map[string]string{{"iGoodsId": "g2", "sGoodsName": "重复商品", "iCategoryId": "45", "iPrice": "0", "iJb2Price": "20", "sPayType": "jb2", "iGoodsLeft": "3"}, {"iGoodsId": "g4", "sGoodsName": "商品四", "iCategoryId": "1", "iPrice": "40", "sPayType": "qpqb", "iGoodsLeft": "0"}}
}
}
body, _ := json.Marshal(map[string]any{"data": map[string]any{"client_data": map[string]any{"itemsdetail": items}}})
@@ -218,6 +219,9 @@ func TestProductsAutomaticallyResolvesXiaodianRoleAndPaginates(t *testing.T) {
if products[1].Category != "45" || products[3].Score != 30 {
t.Fatalf("商品字段 = %#v", products)
}
+ if products[0].PayType != 1 || products[0].PayLabel != "点券" || products[1].PayType != 5 || products[1].PayLabel != "扭蛋碎片" || products[1].Score != 20 {
+ t.Fatalf("支付字段 = %#v", products)
+ }
if got := strings.Join(goodsRequests, ","); got != ":iCategoryId_76:0,:iCategoryId_76:2,:iCategoryId_76:4,76::0" {
t.Fatalf("分类分页请求 = %s", got)
}
@@ -230,6 +234,72 @@ func TestProductsAutomaticallyResolvesXiaodianRoleAndPaginates(t *testing.T) {
}
}
+func TestXiaodianExchangeProductUsesLiveBuyPayTypes(t *testing.T) {
+ var requests []string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/embed":
+ _, _ = w.Write([]byte(`{"error":0,"data":{"txurlH5":"https://app.daoju.qq.com/act/a20260602cjmshop/index.html?gameId=cjm&actId=18882&appId=bp_cf&livePlatId=douyu&code=code×tamp=1&v=2.0&sig=sig"}}`))
+ case "/daoju/igw/livebuy/":
+ requests = append(requests, r.URL.RawQuery)
+ if got := r.URL.Query().Get("_service"); got != "buy.plug.svr.web" {
+ t.Errorf("_service = %q", got)
+ }
+ if got := r.URL.Query().Get("iActionId"); got != "46195" {
+ t.Errorf("iActionId = %q", got)
+ }
+ _, _ = w.Write([]byte(`var buyInfo={"ret":"0","msg":"ok","serial":"CJM-1","sSerialNum":"CJM-1"};`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ act := &activity.Activity{
+ ID: "xiaodian", DisplayName: "和平小店", RoomID: "9263298", ActAlias: "20260623KDQFH",
+ ActID: "46195", RoleParamURL: server.URL + "/embed", RoleURL: server.URL + "/daoju/igw/live", IsXiaodian: true,
+ }
+ cli := New(act, "", &trace.Recorder{})
+ cli.HTTPClient = server.Client()
+
+ got, err := cli.ExchangeProduct("acf_uid=uid; acf_stk=stk; acf_ltkid=ltkid", types.Product{
+ CommodityID: "g-dq", Score: 8800, PayType: 1, Pic: "pic-dq",
+ }, nil)
+ if err != nil {
+ t.Fatalf("点券 ExchangeProduct() error = %v", err)
+ }
+ if !strings.Contains(got, "serial=CJM-1") {
+ t.Fatalf("点券返回 = %q", got)
+ }
+ got, err = cli.ExchangeProduct("acf_uid=uid; acf_stk=stk; acf_ltkid=ltkid", types.Product{
+ CommodityID: "g-jb2", Score: 108, PayType: 5, Pic: "pic-jb2",
+ }, nil)
+ if err != nil {
+ t.Fatalf("碎片 ExchangeProduct() error = %v", err)
+ }
+ if !strings.Contains(got, "paytype=5") {
+ t.Fatalf("碎片返回 = %q", got)
+ }
+ if len(requests) != 2 {
+ t.Fatalf("请求数量 = %d", len(requests))
+ }
+ first, _ := url.ParseQuery(requests[0])
+ second, _ := url.ParseQuery(requests[1])
+ if first.Get("paytype") != "1" || first.Get("propid") != "g-dq" {
+ t.Fatalf("点券请求 = %s", requests[0])
+ }
+ if second.Get("paytype") != "5" || second.Get("propid") != "g-jb2" {
+ t.Fatalf("碎片请求 = %s", requests[1])
+ }
+ var appext map[string]any
+ if err := json.Unmarshal([]byte(second.Get("appext")), &appext); err != nil {
+ t.Fatalf("appext 解析失败: %v", err)
+ }
+ if appext["user_price"].(float64) != 108 || appext["paytype"].(float64) != 5 {
+ t.Fatalf("碎片 appext = %#v", appext)
+ }
+}
+
func TestXiaodianExchangeListUsesOrderListAndParsesGoods(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
diff --git a/internal/constants/constants.go b/internal/constants/constants.go
index 9ece992..622414f 100644
--- a/internal/constants/constants.go
+++ b/internal/constants/constants.go
@@ -3,7 +3,7 @@ package constants
import "time"
// Version 应用版本号,与 wails.json version 同步。
-const Version = "0.1.4"
+const Version = "0.1.5"
// BuildTime 构建时间,由编译时 ldflags 注入。
var BuildTime = "unknown"
diff --git a/internal/seckill/attempt.go b/internal/seckill/attempt.go
index a270f86..2a7245d 100644
--- a/internal/seckill/attempt.go
+++ b/internal/seckill/attempt.go
@@ -59,7 +59,7 @@ func (s *Scheduler) runAttempt(attempt types.ExchangeAttempt) types.ExchangeResu
})
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "资格预检通过: 账号=%s 商品=%s,后续可直兑", attempt.Account.Name, attempt.Product.CommodityName)
- exchangeInfo, err := cli.Exchange(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf)
+ exchangeInfo, err := cli.ExchangeProduct(attempt.Account.Cookie, attempt.Product, &csrf)
if err != nil {
s.setAttemptCache(attempt.Account.Name, &types.AttemptCache{
CSRF: csrf,
@@ -121,7 +121,7 @@ func (s *Scheduler) runAttemptFast(attempt types.ExchangeAttempt) types.Exchange
logger.LogAccountEvent("抢购", "info", attempt.Account.Name, "激进模式直兑: 账号=%s 商品=%s 第%d包", attempt.Account.Name, attempt.Product.CommodityName, attempt.Index)
// 直接调用 Exchange
- exchangeInfo, err := cli.Exchange(attempt.Account.Cookie, attempt.Product.CommodityID, &csrf)
+ exchangeInfo, err := cli.ExchangeProduct(attempt.Account.Cookie, attempt.Product, &csrf)
if err != nil {
s.setAttemptCache(attempt.Account.Name, &types.AttemptCache{
CSRF: csrf,
@@ -169,6 +169,44 @@ func (s *Scheduler) runAttemptFast(attempt types.ExchangeAttempt) types.Exchange
return result
}
+// runXiaodianAttempt 和平小店专用兑换:跳过手册鉴权、批量限制和预兑换,直接调用 livebuy。
+func (s *Scheduler) runXiaodianAttempt(attempt types.ExchangeAttempt) types.ExchangeResult {
+ cli := s.clientSnapshot()
+ result := types.ExchangeResult{
+ Account: attempt.Account.Name,
+ Product: attempt.Product.CommodityName,
+ Attempt: attempt.Index,
+ }
+
+ if s.runStopped() {
+ result.Message = "失败|已收到停止指令"
+ return result
+ }
+
+ logger.LogAccountEvent("抢购", "info", attempt.Account.Name,
+ "和平小店直接兑换: 账号=%s 商品=%s 第%d次",
+ attempt.Account.Name, attempt.Product.CommodityName, attempt.Index)
+ exchangeInfo, err := cli.ExchangeProduct(attempt.Account.Cookie, attempt.Product, nil)
+ if err != nil {
+ result.Message = "失败|" + err.Error()
+ return result
+ }
+
+ roleName, err := cli.RoleName(attempt.Account.Cookie)
+ if err != nil {
+ logger.LogAccountEvent("抢购", "warn", attempt.Account.Name,
+ "角色名查询失败: 账号=%s err=%v", attempt.Account.Name, err)
+ }
+ result.OK = true
+ result.RoleName = roleName
+ if roleName != "" {
+ result.Message = "成功|" + roleName + "|" + exchangeInfo
+ } else {
+ result.Message = "成功|" + exchangeInfo
+ }
+ return result
+}
+
func shouldFallbackStandard(errMsg string) bool {
lower := strings.ToLower(errMsg)
switch {
diff --git a/internal/seckill/schedule_timer.go b/internal/seckill/schedule_timer.go
index 94b1403..8fe5e47 100644
--- a/internal/seckill/schedule_timer.go
+++ b/internal/seckill/schedule_timer.go
@@ -42,7 +42,7 @@ func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (types.ScheduleResult,
s.clearWarmups()
s.scheduleMu.Unlock()
- s.scheduleTimers(ap, targetTime, triggerTime, calibration, false)
+ s.scheduleTimers(ap, targetTime, triggerTime, calibration, req.Attempts, req.DelayMs, false)
resyncRemain := triggerTime.Sub(serverTime) - resyncBeforeTrigger
if resyncRemain < 0 {
@@ -52,7 +52,7 @@ func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (types.ScheduleResult,
s.scheduleMu.Lock()
s.replaceTimerLocked(&s.resyncSchedule, resyncRemain, func() {
newCalibration := s.calibrateDouyuTime("临近")
- s.scheduleTimers(ap, targetTime, triggerTime, newCalibration, true)
+ s.scheduleTimers(ap, targetTime, triggerTime, newCalibration, req.Attempts, req.DelayMs, true)
})
s.scheduleMu.Unlock()
@@ -89,7 +89,7 @@ func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (types.ScheduleResult,
}, nil
}
-func (s *Scheduler) scheduleTimers(accountProducts map[string]string, targetTime, triggerTime time.Time, calibration types.TimeCalibration, resync bool) {
+func (s *Scheduler) scheduleTimers(accountProducts map[string]string, targetTime, triggerTime time.Time, calibration types.TimeCalibration, attempts, delayMs int, resync bool) {
serverTime := calibration.EstimatedServerNow()
remain := triggerTime.Sub(serverTime)
if remain < 0 {
@@ -107,6 +107,9 @@ func (s *Scheduler) scheduleTimers(accountProducts map[string]string, targetTime
if atomic.LoadInt64(&s.warmupGen) != gen {
return
}
+ if s.clientSnapshot().Activity.IsXiaodian {
+ return
+ }
logger.LogEvent("预热", "success", "预热触发: 本地=%s", time.Now().In(constants.CST).Format("15:04:05.000"))
settings := s.settingsSnapshot()
attempts := s.buildAttempts(accountProducts, settings.attempts)
@@ -124,6 +127,10 @@ func (s *Scheduler) scheduleTimers(accountProducts map[string]string, targetTime
logger.LogEvent("抢购", "warn", "已有抢购任务正在运行,本次定时触发已跳过")
return
}
+ if s.clientSnapshot().Activity.IsXiaodian {
+ s.RunXiaodian(accountProducts, attempts, delayMs)
+ return
+ }
s.RunNow(accountProducts)
})
if resync {
diff --git a/internal/seckill/scheduler.go b/internal/seckill/scheduler.go
index df86822..9a7dc3f 100644
--- a/internal/seckill/scheduler.go
+++ b/internal/seckill/scheduler.go
@@ -18,17 +18,18 @@ import (
)
const (
- warmupBeforeTrigger = 2 * time.Second
- warmupMaxAge = 30 * time.Second
- qualifiedMaxAge = 30 * time.Second
- timeCalibrateRounds = 3
- timeCalibrateWait = 1200 * time.Millisecond
- resyncBeforeTrigger = 10 * time.Second
- seckillMaxDuration = 75 * time.Second
- behaviorCooldown = 5 * time.Second
- clickFastCooldown = 3 * time.Second
- internalAdvance = 50 * time.Millisecond
- maxCooldownCount = 5
+ warmupBeforeTrigger = 2 * time.Second
+ warmupMaxAge = 30 * time.Second
+ qualifiedMaxAge = 30 * time.Second
+ timeCalibrateRounds = 3
+ timeCalibrateWait = 1200 * time.Millisecond
+ resyncBeforeTrigger = 10 * time.Second
+ seckillMaxDuration = 75 * time.Second
+ behaviorCooldown = 5 * time.Second
+ clickFastCooldown = 3 * time.Second
+ internalAdvance = 50 * time.Millisecond
+ maxCooldownCount = 5
+ xiaodianDefaultDelay = 500 * time.Millisecond
)
type roundOutcome struct {
@@ -69,8 +70,12 @@ type Scheduler struct {
resultsMu sync.RWMutex
results map[string]string
- runMu sync.Mutex
- running bool
+ runMu sync.Mutex
+ running bool
+ stopRun chan struct{}
+ stopRequested bool
+ stateMu sync.RWMutex
+ onStateChange func(string)
// pendingClient 保存运行中收到的客户端更新,当前任务结束后自动应用。
pendingClient *client.DouyuClient
@@ -91,6 +96,22 @@ type Scheduler struct {
products map[string]types.Product
}
+// SetStateChangeCallback 设置运行状态变化回调。
+func (s *Scheduler) SetStateChangeCallback(cb func(string)) {
+ s.stateMu.Lock()
+ s.onStateChange = cb
+ s.stateMu.Unlock()
+}
+
+func (s *Scheduler) emitState(state string) {
+ s.stateMu.RLock()
+ cb := s.onStateChange
+ s.stateMu.RUnlock()
+ if cb != nil {
+ cb(state)
+ }
+}
+
// New 创建抢购调度器。
func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font, concurrency, attempts int, retryStages []types.RetryStage, seckillMode types.SeckillMode) *Scheduler {
if concurrency <= 0 {
@@ -289,17 +310,23 @@ func (s *Scheduler) setResult(key, value string) {
// TryStartRun 尝试开始抢购,返回 false 表示已有任务在运行。
func (s *Scheduler) TryStartRun() bool {
s.runMu.Lock()
- defer s.runMu.Unlock()
if s.running {
+ s.runMu.Unlock()
return false
}
s.running = true
+ s.stopRequested = false
+ s.stopRun = make(chan struct{})
+ s.runMu.Unlock()
+ s.emitState("running")
return true
}
func (s *Scheduler) finishRun() {
s.runMu.Lock()
s.running = false
+ s.stopRequested = false
+ s.stopRun = nil
pendingClient := s.pendingClient
s.pendingClient = nil
s.runMu.Unlock()
@@ -308,6 +335,59 @@ func (s *Scheduler) finishRun() {
s.replaceClient(pendingClient)
logger.LogEvent("配置", "success", "抢购任务结束,已应用等待中的客户端配置")
}
+ s.emitState("idle")
+}
+
+// StopRun 请求停止当前正在执行的抢购任务。
+func (s *Scheduler) StopRun() bool {
+ s.runMu.Lock()
+ if !s.running || s.stopRequested || s.stopRun == nil {
+ s.runMu.Unlock()
+ return false
+ }
+ s.stopRequested = true
+ close(s.stopRun)
+ s.runMu.Unlock()
+ s.emitState("stopping")
+ return true
+}
+
+// IsRunning 返回当前是否有抢购任务正在执行。
+func (s *Scheduler) IsRunning() bool {
+ s.runMu.Lock()
+ defer s.runMu.Unlock()
+ return s.running
+}
+
+func (s *Scheduler) stopRunChan() <-chan struct{} {
+ s.runMu.Lock()
+ defer s.runMu.Unlock()
+ return s.stopRun
+}
+
+func (s *Scheduler) runStopped() bool {
+ s.runMu.Lock()
+ defer s.runMu.Unlock()
+ return s.stopRequested
+}
+
+func (s *Scheduler) interruptibleSleep(d time.Duration) bool {
+ if d <= 0 {
+ return s.runStopped()
+ }
+ stop := s.stopRunChan()
+ if stop == nil {
+ time.Sleep(d)
+ return false
+ }
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+ select {
+ case <-timer.C:
+ return false
+ case <-stop:
+ return true
+ }
}
// RunNow 立即执行抢购任务,持续循环直到超时或全部成功。
@@ -340,6 +420,10 @@ func (s *Scheduler) RunNow(accountProducts map[string]string) {
logger.LogEvent("抢购", "info", "开始抢购: %d 个账号,窗口=%v,预检通过后直兑,前段高频后段降速", totalAccounts, seckillMaxDuration)
for {
+ if s.runStopped() {
+ logger.LogEvent("抢购", "warn", "收到停止指令,抢购任务正在结束")
+ break
+ }
elapsed := time.Since(startTime)
if elapsed >= seckillMaxDuration {
logger.LogEvent("抢购", "info", "已达到抢购窗口 %v,停止抢购", seckillMaxDuration)
@@ -373,7 +457,10 @@ func (s *Scheduler) RunNow(accountProducts map[string]string) {
}
if sleepFor > 0 {
logger.LogEvent("抢购", "info", "第%d轮账号冷却中,等待=%v", round, utils.RoundDuration(sleepFor))
- time.Sleep(sleepFor)
+ if s.interruptibleSleep(sleepFor) {
+ logger.LogEvent("抢购", "warn", "收到停止指令,抢购任务正在结束")
+ break
+ }
}
continue
}
@@ -414,6 +501,10 @@ func (s *Scheduler) RunNow(accountProducts map[string]string) {
}
}
+ if s.runStopped() {
+ logger.LogEvent("抢购", "warn", "收到停止指令,抢购任务正在结束")
+ break
+ }
if len(outcome.successAccounts) > 0 {
logger.LogEvent("抢购", "success", "第%d轮成功: %d 个账号", round, len(outcome.successAccounts))
}
@@ -424,7 +515,10 @@ func (s *Scheduler) RunNow(accountProducts map[string]string) {
retryInterval := retryIntervalFor(settings.retryStages, time.Since(startTime))
if retryInterval > 0 && time.Since(startTime)+retryInterval < seckillMaxDuration {
- time.Sleep(jitterDuration(retryInterval))
+ if s.interruptibleSleep(jitterDuration(retryInterval)) {
+ logger.LogEvent("抢购", "warn", "收到停止指令,抢购任务正在结束")
+ break
+ }
}
}
@@ -471,6 +565,10 @@ func (s *Scheduler) RunFixed(accountProducts map[string]string, attempts, delayM
start := time.Now()
logger.LogEvent("抢购", "info", "固定次数抢购开始: %d 个账号,次数=%d,间隔=%dms", len(accounts), attempts, delayMs)
for round := 1; round <= attempts; round++ {
+ if s.runStopped() {
+ logger.LogEvent("抢购", "warn", "收到停止指令,固定次数抢购正在结束")
+ break
+ }
roundAttempts := make([]types.ExchangeAttempt, 0, len(accounts))
for _, account := range accounts {
if stopped[account.Name] {
@@ -487,7 +585,10 @@ func (s *Scheduler) RunFixed(accountProducts map[string]string, attempts, delayM
break
}
if round > 1 && delayMs > 0 {
- time.Sleep(time.Duration(delayMs) * time.Millisecond)
+ if s.interruptibleSleep(time.Duration(delayMs) * time.Millisecond) {
+ logger.LogEvent("抢购", "warn", "收到停止指令,固定次数抢购正在结束")
+ break
+ }
}
outcome := s.executeRound(roundAttempts, settings)
for name := range outcome.successAccounts {
@@ -501,6 +602,103 @@ func (s *Scheduler) RunFixed(accountProducts map[string]string, attempts, delayM
logger.LogEvent("抢购", "info", "固定次数抢购结束: 成功=%d/%d,耗时=%v", len(stopped), len(accounts), utils.RoundDuration(time.Since(start)))
}
+// RunXiaodian 使用和平小店专用策略:固定次数、固定间隔、每轮直接调用 livebuy。
+// 小店补货不依赖旧活动的 75 秒阶段窗口,也不执行手册的预检链路。
+func (s *Scheduler) RunXiaodian(accountProducts map[string]string, attempts, delayMs int) {
+ defer s.finishRun()
+ s.ClearResults()
+
+ settings := s.settingsSnapshot()
+ attempts, delay := normalizeXiaodianRun(attempts, delayMs, settings.attempts)
+ accounts := make([]types.Account, 0, len(accountProducts))
+ for name := range accountProducts {
+ cookie, ok := s.AccountCookieByName(name)
+ if !ok {
+ continue
+ }
+ accounts = append(accounts, types.Account{Name: name, Cookie: cookie})
+ }
+ if len(accounts) == 0 {
+ logger.LogEvent("抢购", "warn", "和平小店抢购没有可执行账号")
+ return
+ }
+
+ stopped := make(map[string]bool)
+ start := time.Now()
+ logger.LogEvent("抢购", "info", "和平小店抢购开始: %d 个账号,次数=%d,间隔=%dms",
+ len(accounts), attempts, delay.Milliseconds())
+
+ for round := 1; round <= attempts; round++ {
+ if s.runStopped() {
+ logger.LogEvent("抢购", "warn", "收到停止指令,和平小店抢购正在结束")
+ break
+ }
+ if round > 1 && s.interruptibleSleep(delay) {
+ logger.LogEvent("抢购", "warn", "收到停止指令,和平小店抢购正在结束")
+ break
+ }
+
+ roundAttempts := make([]types.ExchangeAttempt, 0, len(accounts))
+ for _, account := range accounts {
+ if stopped[account.Name] {
+ continue
+ }
+ productID := accountProducts[account.Name]
+ product := s.productForCommodityID(productID)
+ if product.CommodityID == "" {
+ product.CommodityID = productID
+ }
+ roundAttempts = append(roundAttempts, types.ExchangeAttempt{
+ Account: account,
+ Product: product,
+ Index: round,
+ })
+ }
+ if len(roundAttempts) == 0 {
+ break
+ }
+
+ outcome := s.executeXiaodianRound(roundAttempts, settings)
+ for name := range outcome.successAccounts {
+ stopped[name] = true
+ }
+ for name := range outcome.stoppedAccounts {
+ stopped[name] = true
+ }
+ logger.LogEvent("抢购", "info", "和平小店第%d/%d轮完成: 成功=%d,停止=%d,剩余账号=%d",
+ round, attempts, len(outcome.successAccounts), len(outcome.stoppedAccounts), len(accounts)-len(stopped))
+
+ if len(stopped) >= len(accounts) {
+ break
+ }
+ }
+
+ logger.LogEvent("抢购", "info", "和平小店抢购结束: 成功或停止=%d/%d,耗时=%v",
+ len(stopped), len(accounts), utils.RoundDuration(time.Since(start)))
+}
+
+func normalizeXiaodianRun(attempts, delayMs, fallbackAttempts int) (int, time.Duration) {
+ if attempts <= 0 {
+ attempts = fallbackAttempts
+ }
+ if attempts <= 0 {
+ attempts = 3
+ }
+ if attempts > 100000 {
+ attempts = 100000
+ }
+ if delayMs < 0 {
+ delayMs = 0
+ }
+ if delayMs == 0 {
+ delayMs = int(xiaodianDefaultDelay / time.Millisecond)
+ }
+ if delayMs > 60000 {
+ delayMs = 60000
+ }
+ return attempts, time.Duration(delayMs) * time.Millisecond
+}
+
// filterPendingAttempts 过滤掉已终止账号和已确认无库存的商品。
func (s *Scheduler) filterPendingAttempts(allAttempts []types.ExchangeAttempt, stoppedAccounts, stoppedProducts map[string]string) []types.ExchangeAttempt {
var pending []types.ExchangeAttempt
@@ -569,6 +767,18 @@ func pickRoundAttempts(attempts []types.ExchangeAttempt, round, attemptsPerAccou
// executeRound 执行一轮抢购,并返回成功和终止重试的账号。
func (s *Scheduler) executeRound(attempts []types.ExchangeAttempt, settings runSettings) roundOutcome {
+ return s.executeRoundWithRunner(attempts, settings, nil)
+}
+
+func (s *Scheduler) executeXiaodianRound(attempts []types.ExchangeAttempt, settings runSettings) roundOutcome {
+ return s.executeRoundWithRunner(attempts, settings, s.runXiaodianAttempt)
+}
+
+func (s *Scheduler) executeRoundWithRunner(
+ attempts []types.ExchangeAttempt,
+ settings runSettings,
+ runner func(types.ExchangeAttempt) types.ExchangeResult,
+) roundOutcome {
outcome := roundOutcome{
successAccounts: make(map[string]bool),
stoppedAccounts: make(map[string]string),
@@ -581,15 +791,25 @@ func (s *Scheduler) executeRound(attempts []types.ExchangeAttempt, settings runS
var wg sync.WaitGroup
for _, attempt := range attempts {
+ if s.runStopped() {
+ break
+ }
wg.Add(1)
go func(attempt types.ExchangeAttempt) {
defer wg.Done()
+ if s.runStopped() {
+ return
+ }
limit <- struct{}{}
defer func() { <-limit }()
+ if s.runStopped() {
+ return
+ }
- // 根据模式选择执行方法
var result types.ExchangeResult
- if settings.seckillMode == types.SeckillModeAggressive {
+ if runner != nil {
+ result = runner(attempt)
+ } else if settings.seckillMode == types.SeckillModeAggressive {
result = s.runAttemptFast(attempt)
} else {
result = s.runAttempt(attempt)
@@ -614,6 +834,9 @@ func (s *Scheduler) executeRound(attempts []types.ExchangeAttempt, settings runS
outcome.cooldownAccounts[attempt.Account.Name] = time.Now().Add(jitterDuration(cooldown))
}
terminalAccount, terminalProduct, reason := classifyRetryStop(result.Message)
+ if runner != nil {
+ terminalAccount, terminalProduct, reason = classifyXiaodianRetryStop(result.Message)
+ }
if terminalAccount {
outcome.stoppedAccounts[attempt.Account.Name] = reason
}
@@ -695,6 +918,23 @@ func classifyRetryStop(message string) (account bool, product bool, reason strin
}
}
+func classifyXiaodianRetryStop(message string) (account bool, product bool, reason string) {
+ switch {
+ case strings.Contains(message, "已达限量"),
+ strings.Contains(message, "已达本次活动兑换上限"),
+ strings.Contains(message, "兑换上限"):
+ return true, false, "已达兑换限量"
+ case strings.Contains(message, "未绑定账号"):
+ return true, false, "账号未绑定角色"
+ case strings.Contains(message, "商品不存在"),
+ strings.Contains(message, "商品已下架"),
+ strings.Contains(message, "不支持该商品"):
+ return false, true, "商品不可兑换"
+ default:
+ return false, false, ""
+ }
+}
+
func uniqueAttemptAccounts(attempts []types.ExchangeAttempt) int {
seen := make(map[string]struct{}, len(attempts))
for _, attempt := range attempts {
diff --git a/internal/seckill/scheduler_test.go b/internal/seckill/scheduler_test.go
index adacf83..a6e483d 100644
--- a/internal/seckill/scheduler_test.go
+++ b/internal/seckill/scheduler_test.go
@@ -52,6 +52,21 @@ func TestRetryIntervalForEmpty(t *testing.T) {
}
}
+func TestNormalizeXiaodianRun(t *testing.T) {
+ attempts, delay := normalizeXiaodianRun(0, 0, 4)
+ if attempts != 4 || delay != xiaodianDefaultDelay {
+ t.Fatalf("normalizeXiaodianRun fallback = (%d,%v), want (4,%v)", attempts, delay, xiaodianDefaultDelay)
+ }
+ attempts, delay = normalizeXiaodianRun(100001, 70000, 0)
+ if attempts != 100000 || delay != 60*time.Second {
+ t.Fatalf("normalizeXiaodianRun clamp = (%d,%v), want (100000,60s)", attempts, delay)
+ }
+ attempts, delay = normalizeXiaodianRun(2, 150, 0)
+ if attempts != 2 || delay != 150*time.Millisecond {
+ t.Fatalf("normalizeXiaodianRun explicit = (%d,%v), want (2,150ms)", attempts, delay)
+ }
+}
+
func TestRetryCooldownDuration(t *testing.T) {
cases := []struct {
msg string
diff --git a/internal/types/types.go b/internal/types/types.go
index 79a6e32..5e7557b 100644
--- a/internal/types/types.go
+++ b/internal/types/types.go
@@ -72,6 +72,8 @@ type Product struct {
CommodityName string `json:"commodityName"`
Category string `json:"category"`
Score int `json:"score"`
+ PayType int `json:"payType,omitempty"`
+ PayLabel string `json:"payLabel,omitempty"`
Count int `json:"count"`
Pic string `json:"pic"`
Status int `json:"status"`
@@ -104,6 +106,8 @@ type AccountView struct {
type SeckillRequest struct {
AccountProducts map[string]string `json:"accountProducts"`
TargetTime string `json:"targetTime"`
+ Attempts int `json:"attempts,omitempty"`
+ DelayMs int `json:"delayMs,omitempty"`
}
// FixedSeckillRequest 是和平小店固定次数/间隔抢购请求。
diff --git a/wails.json b/wails.json
index 3f12951..1b96eb4 100644
--- a/wails.json
+++ b/wails.json
@@ -2,7 +2,7 @@
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "douyu_go",
"outputfilename": "douyu_go",
- "version": "0.1.4",
+ "version": "0.1.5",
"frontend:dir": "frontend",
"frontend:install": "npm install",
"frontend:build": "npm run build",