feat: improve xiaodian seckill flow

This commit is contained in:
yml2213
2026-08-08 23:04:40 +08:00
parent 16972b41cd
commit dde2b43d82
19 changed files with 871 additions and 64 deletions
+1 -2
View File
@@ -15,7 +15,7 @@ frontend/wailsjs/
frontend/app.js
# 本地账号、Cookie 与抓包资料
data/user.txt
data/
ck 对比.md
软软马卡龙.txt
@@ -32,4 +32,3 @@ yml.txt
.DS_Store
.workbuddy
.claude
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useAccounts } from '@/composables/useAccounts'
import { useProducts } from '@/composables/useProducts'
import type { Product } from '@/composables/useProducts'
import { useActivity } from '@/composables/useActivity'
const {
@@ -11,17 +12,51 @@ const {
toggleAccount,
toggleAll,
getScore,
getXiaodianAssets
getXiaodianAssets,
getAccountProduct,
setAccountProduct
} = useAccounts()
const { selectedProductName } = useProducts()
const { products, selectedProductName } = useProducts()
const { currentActivity } = useActivity()
const isXiaodian = computed(() => currentActivity.value?.id === 'xiaodian')
const productFilterMap = ref<Record<string, string>>({})
const allSelected = computed(() =>
accounts.value.length > 0 && selectedIndices.value.size === accounts.value.length
)
const handleProductFilter = (accountName: string, event: Event) => {
productFilterMap.value[accountName] = (event.target as HTMLInputElement).value
}
const handleAccountProductChange = (accountName: string, event: Event) => {
setAccountProduct(accountName, (event.target as HTMLSelectElement).value)
}
const productMatches = (product: Product, keyword: string) => {
if (!keyword) return true
const text = [
product.commodityName,
product.commodityId,
product.score,
product.payLabel || '',
product.payType || ''
].join(' ').toLowerCase()
return text.includes(keyword)
}
const filteredAccountProducts = (accountName: string) => {
const keyword = (productFilterMap.value[accountName] || '').trim().toLowerCase()
const selectedId = getAccountProduct(accountName)
const matched = products.value.filter(product => productMatches(product, keyword)).slice(0, 80)
if (selectedId && !matched.some(product => product.commodityId === selectedId)) {
const selected = products.value.find(product => product.commodityId === selectedId)
if (selected) return [selected, ...matched]
}
return matched
}
// 状态信息计算
const getStatusInfo = (name: string, selected: boolean) => {
const st = statusMap.value[name]
@@ -121,7 +156,37 @@ const getResultMessage = (name: string) => {
<span v-else class="status-badge status-fail">缺失</span>
</td>
<td>
<span v-if="selectedProductName" class="prod-tag">
<div
v-if="isXiaodian"
class="account-product-picker"
>
<input
class="account-product-filter"
type="text"
placeholder="筛选商品"
:value="productFilterMap[account.name] || ''"
@click.stop
@input="handleProductFilter(account.name, $event)"
>
<select
class="account-product-select"
:value="getAccountProduct(account.name)"
@click.stop
@change="handleAccountProductChange(account.name, $event)"
>
<option value="">
跟随全局:{{ selectedProductName || '未选择' }}
</option>
<option
v-for="product in filteredAccountProducts(account.name)"
:key="product.commodityId"
:value="product.commodityId"
>
{{ product.commodityName }} · {{ product.score }}{{ product.payLabel || '点券' }}
</option>
</select>
</div>
<span v-else-if="selectedProductName" class="prod-tag">
{{ selectedProductName }}
</span>
<span v-else>-</span>
@@ -307,6 +372,42 @@ tbody tr:hover {
font-size: 11.5px;
}
.account-product-picker {
width: 240px;
max-width: 100%;
display: grid;
grid-template-columns: minmax(70px, .8fr) minmax(120px, 1.2fr);
gap: 5px;
}
.account-product-filter,
.account-product-select {
width: 220px;
max-width: 100%;
height: 30px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--text);
font-size: 11.5px;
}
.account-product-filter,
.account-product-select {
width: 100%;
}
.account-product-filter::placeholder {
color: var(--muted);
}
.account-product-filter:focus,
.account-product-select:focus {
outline: none;
border-color: var(--accent);
}
@media (max-width: 1180px) {
.table-wrap {
max-height: 520px;
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useScheduler } from '@/composables/useScheduler'
import { useAccounts } from '@/composables/useAccounts'
import { useActivity } from '@/composables/useActivity'
import { useProducts } from '@/composables/useProducts'
import { useToast } from '@/composables/useToast'
const { scheduleActive, startNow, startFixed, startSchedule, cancelSchedule, restoreSchedule } = useScheduler()
const { scheduleActive, seckillRunning, startNow, stopSeckill, startFixed, startSchedule, cancelSchedule, restoreSchedule, subscribeSeckillState, unsubscribeSchedulerEvents } = useScheduler()
const { reloadAccounts, queryAllScore, queryAllXiaodianAssets } = useAccounts()
const { currentActivity } = useActivity()
const isXiaodian = computed(() => currentActivity.value?.id === 'xiaodian')
@@ -26,8 +26,20 @@ let keywordRefreshing = false
const { savedTime } = restoreSchedule()
const targetTime = ref(savedTime || '18:00:00')
onMounted(() => {
subscribeSeckillState()
})
const handleMainAction = async () => {
if (seckillRunning.value) {
await stopSeckill()
return
}
await startNow(fixedAttempts.value, fixedDelayMs.value)
}
const handleSchedule = () => {
startSchedule(targetTime.value)
startSchedule(targetTime.value, fixedAttempts.value, fixedDelayMs.value)
}
const queryAssets = () => {
@@ -50,7 +62,7 @@ const saveFixedConfig = () => {
const startFixedBuy = () => {
saveFixedConfig()
if (keywordWatching.value) stopKeywordWatch()
startFixed(fixedAttempts.value, fixedDelayMs.value)
void startFixed(fixedAttempts.value, fixedDelayMs.value)
}
const stopKeywordWatch = () => {
@@ -133,6 +145,7 @@ watch(isXiaodian, (enabled) => {
onUnmounted(() => {
stopKeywordWatch()
unsubscribeSchedulerEvents()
})
</script>
@@ -141,7 +154,13 @@ onUnmounted(() => {
<div class="control-strip">
<div class="action-row">
<div class="action-group">
<button class="btn btn-danger" @click="startNow">立即抢购</button>
<button
class="btn btn-danger"
:class="{ running: seckillRunning }"
@click="handleMainAction"
>
{{ seckillRunning ? '停止抢购' : '立即抢购' }}
</button>
<button
v-if="!scheduleActive"
class="btn btn-success"
@@ -6,7 +6,10 @@ import { useActivity } from '@/composables/useActivity'
const { selectedProductId, selectedProductName, selectedProduct } = useProducts()
const { currentActivity } = useActivity()
const isXiaodian = computed(() => currentActivity.value?.id === 'xiaodian')
const priceLabel = computed(() => isXiaodian.value ? '点券' : '积分')
const priceLabel = computed(() => {
if (!isXiaodian.value) return '积分'
return selectedProduct.value?.payLabel || '点券'
})
const stockText = computed(() => {
if (!selectedProduct.value) return ''
if (isXiaodian.value && selectedProduct.value.count < 0) return '库存未公开'
@@ -7,7 +7,7 @@ import { useActivity } from '@/composables/useActivity'
const { filteredProducts, selectedProductId, selectProduct, searchQuery, xiaodianTab } = useProducts()
const { currentActivity } = useActivity()
const isXiaodian = computed(() => currentActivity.value?.id === 'xiaodian')
const priceLabel = computed(() => isXiaodian.value ? '点券' : '积分')
const priceLabel = (product: { payLabel?: string }) => isXiaodian.value ? (product.payLabel || '点券') : '积分'
const stockText = (count: number) => isXiaodian.value && count < 0 ? '库存未公开' : `库存 ${count}`
const parentRef = ref<HTMLElement | null>(null)
@@ -76,8 +76,8 @@ watch(xiaodianTab, () => {
<div class="pmeta">
<span
class="chip chip-score"
:title="`${filteredProducts[item.index].score} ${priceLabel}`"
>{{ filteredProducts[item.index].score }} {{ priceLabel }}</span>
:title="`${filteredProducts[item.index].score} ${priceLabel(filteredProducts[item.index])}`"
>{{ filteredProducts[item.index].score }} {{ priceLabel(filteredProducts[item.index]) }}</span>
<span
class="chip chip-stock"
:class="{ low: filteredProducts[item.index].count >= 0 && filteredProducts[item.index].count < 500 }"
+34 -7
View File
@@ -25,6 +25,7 @@ const accounts = ref<Account[]>([])
const selectedIndices = ref<Set<number>>(new Set())
const statusMap = ref<Record<string, AccountStatus | boolean>>({})
const scoreMap = ref<Record<string, string>>({})
const accountProductMap = ref<Record<string, string>>({})
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<string, string> | 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<string, string> | null => {
if (selectedIndices.value.size === 0) {
showToast('提示', '请勾选要抢购的账号', 'warn')
return null
}
if (!productId) {
showToast('提示', '请先选择商品', 'warn')
return null
}
const ap: Record<string, string> = {}
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
}
+2
View File
@@ -8,6 +8,8 @@ export interface Product {
commodityName: string
category: string
score: number
payType?: number
payLabel?: string
count: number
pic: string
status: number
+71 -6
View File
@@ -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<number | null>(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,
+4
View File
@@ -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 {
+26 -2
View File
@@ -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 设置定时抢购。
+194 -5
View File
@@ -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 {
+73 -3
View File
@@ -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&timestamp=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 {
+1 -1
View File
@@ -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"
+40 -2
View File
@@ -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 {
+10 -3
View File
@@ -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 {
+259 -19
View File
@@ -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 {
+15
View File
@@ -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
+4
View File
@@ -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 是和平小店固定次数/间隔抢购请求。
+1 -1
View File
@@ -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",