feat: improve xiaodian seckill flow
This commit is contained in:
+1
-2
@@ -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 }"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface Product {
|
||||
commodityName: string
|
||||
category: string
|
||||
score: number
|
||||
payType?: number
|
||||
payLabel?: string
|
||||
count: number
|
||||
pic: string
|
||||
status: number
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user