支持按账号商品监控库存抢购

This commit is contained in:
yml2213
2026-08-09 18:46:39 +08:00
parent a9ad969b64
commit 02d38d9ede
6 changed files with 154 additions and 42 deletions
@@ -14,11 +14,12 @@ const {
queryAllScore,
queryAllXiaodianAssets,
queryAllXiaodianRoles,
getXiaodianBindQRCode
getXiaodianBindQRCode,
buildAccountProducts
} = useAccounts()
const { currentActivity } = useActivity()
const isXiaodian = computed(() => currentActivity.value?.id === 'xiaodian')
const { products, selectedProductId, selectedProductName, loadProducts, selectProduct } = useProducts()
const { products, selectedProductId, selectedProductName, loadProducts, loadProductsForAccounts, selectProduct } = useProducts()
const { showToast } = useToast()
const fixedKeyword = ref(localStorage.getItem('xiaodianFixedKeyword') || localStorage.getItem('xiaodianWatchKeyword') || '')
const fixedAttempts = ref(Number(localStorage.getItem('xiaodianFixedAttempts') || 3))
@@ -34,8 +35,14 @@ const stockWatchIntervalSec = ref(Number(localStorage.getItem('xiaodianStockWatc
const stockWatchAttempts = ref(Number(localStorage.getItem('xiaodianStockWatchAttempts') || 3))
const stockWatchDelayMs = ref(Number(localStorage.getItem('xiaodianStockWatchDelayMs') || 500))
const stockWatching = ref(false)
const stockWatchTarget = ref<{ id: string; name: string } | null>(null)
const stockWatchTargetName = computed(() => stockWatchTarget.value?.name || selectedProductName.value || '请先在左侧选择商品')
const stockWatchAccountProducts = ref<Record<string, string>>({})
const stockWatchTargetName = computed(() => {
const assignments = stockWatchAccountProducts.value
const accountCount = Object.keys(assignments).length
if (accountCount === 0) return selectedProductName.value || '请先为账号选择商品'
const names = [...new Set(Object.values(assignments).map(id => products.value.find(item => item.commodityId === id)?.commodityName || id))]
return `${names.join('、')} · ${accountCount} 个账号`
})
let stockWatchTimer: number | null = null
let stockWatchStartTimer: number | null = null
let stockRefreshing = false
@@ -129,6 +136,7 @@ const stopStockWatch = () => {
stockWatchStartTimer = null
}
stockRefreshing = false
stockWatchAccountProducts.value = {}
}
const beginKeywordWatch = async () => {
@@ -189,32 +197,51 @@ const toggleKeywordWatch = () => {
}
const tryStartStockBuy = async () => {
const target = stockWatchTarget.value
if (!target) return false
const assignments = stockWatchAccountProducts.value
if (Object.keys(assignments).length === 0) return false
if (scheduleActive.value || seckillRunning.value) return false
const product = products.value.find(item => item.commodityId === target.id)
// count 为 -1 时代表库存未公开,不能把未知状态误判为可抢。
if (!product || product.count <= 0) return false
const productsByID = new Map(products.value.map(product => [product.commodityId, product]))
const readyAccountProducts: Record<string, string> = {}
const readyProducts = new Map<string, typeof products.value[number]>()
Object.entries(assignments).forEach(([accountName, productID]) => {
const product = productsByID.get(productID)
// count 为 -1 时代表库存未公开,不能把未知状态误判为可抢。
if (product && product.count > 0) {
readyAccountProducts[accountName] = productID
readyProducts.set(productID, product)
}
})
if (Object.keys(readyAccountProducts).length === 0) return false
stopStockWatch()
selectProduct(product.commodityId, product.commodityName)
const stockText = product.stockLabel || `库存 ${product.count}`
showToast('检测到库存', `${product.commodityName} ${stockText},开始固定抢购`, 'info', 3000)
await startFixed(stockWatchAttempts.value, stockWatchDelayMs.value)
const productText = [...readyProducts.values()]
.map(product => `${product.commodityName} ${product.stockLabel || `库存 ${product.count}`}`)
.join(';')
const started = await startFixed(stockWatchAttempts.value, stockWatchDelayMs.value, readyAccountProducts)
if (!started) return false
Object.keys(readyAccountProducts).forEach(accountName => {
delete stockWatchAccountProducts.value[accountName]
})
if (Object.keys(stockWatchAccountProducts.value).length === 0) {
stopStockWatch()
}
showToast('检测到库存', `${productText},已启动 ${Object.keys(readyAccountProducts).length} 个账号抢购`, 'info', 3000)
return true
}
const beginStockWatch = async () => {
if (!stockWatching.value || !isXiaodian.value || !stockWatchTarget.value) return
await loadProducts(true)
if (await tryStartStockBuy()) return
if (!stockWatching.value || !isXiaodian.value || Object.keys(stockWatchAccountProducts.value).length === 0) return
const accountNames = () => Object.keys(stockWatchAccountProducts.value)
await loadProductsForAccounts(accountNames(), true)
await tryStartStockBuy()
if (!stockWatching.value) return
const interval = Math.max(1, Math.min(600, Number(stockWatchIntervalSec.value) || 3)) * 1000
stockWatchTimer = window.setInterval(async () => {
if (!stockWatching.value || scheduleActive.value || seckillRunning.value || stockRefreshing) return
stockRefreshing = true
try {
await loadProducts(false)
await loadProductsForAccounts(accountNames(), false)
await tryStartStockBuy()
} finally {
stockRefreshing = false
@@ -227,22 +254,28 @@ const toggleStockWatch = () => {
stopStockWatch()
return
}
const product = products.value.find(item => item.commodityId === selectedProductId.value)
if (!product) {
showToast('请选择商品', '请先在左侧选择要监控库存的商品', 'warn')
const accountProducts = buildAccountProducts(selectedProductId.value || '')
if (!accountProducts) {
return
}
const missingProductIDs = [...new Set(Object.values(accountProducts))]
.filter(productID => !products.value.some(product => product.commodityId === productID))
if (missingProductIDs.length > 0) {
showToast('商品未加载', '请刷新商品后再启动库存监控', 'warn')
return
}
saveStockWatchConfig()
stopKeywordWatch()
stockWatchTarget.value = { id: product.commodityId, name: product.commodityName }
stockWatchAccountProducts.value = { ...accountProducts }
stockWatching.value = true
const [hours, minutes, seconds] = stockWatchStartTime.value.split(':').map(Number)
const target = new Date()
target.setHours(hours || 0, minutes || 0, seconds || 0, 0)
const waitMs = Math.max(0, target.getTime() - Date.now())
if (waitMs > 0) {
showToast('库存监控已设置', `${stockWatchTarget.value.name} 将在 ${stockWatchStartTime.value} 开始监控`, 'info', 3000)
showToast('库存监控已设置', `${stockWatchTargetName.value} 将在 ${stockWatchStartTime.value} 开始监控`, 'info', 3000)
stockWatchStartTimer = window.setTimeout(() => {
stockWatchStartTimer = null
void beginStockWatch()
+9
View File
@@ -303,6 +303,14 @@ export function useAccounts() {
markSelectedStatus({ pending: true, message })
}
const markAccountNamesPending = (accountNames: string[], message = '抢购中,等待后端结果') => {
const next: Record<string, AccountStatus> = {}
accountNames.forEach(name => {
next[name] = { pending: true, message }
})
mergeStatuses(next)
}
const setAccountProduct = (accountName: string, productId: string) => {
if (productId) {
accountProductMap.value[accountName] = productId
@@ -377,6 +385,7 @@ export function useAccounts() {
setStatus,
markSelectedStatus,
markSelectedPending,
markAccountNamesPending,
setAccountProduct,
getAccountProduct,
selectedAccountNames,
+30 -11
View File
@@ -1,7 +1,7 @@
import { ref, computed } from 'vue'
import { useToast } from './useToast'
import { useActivity } from './useActivity'
import { LoadProducts } from '@wails/go/app/App'
import { LoadProducts, LoadProductsForAccounts } from '@wails/go/app/App'
export interface Product {
commodityId: string
@@ -63,21 +63,39 @@ export function useProducts() {
products.value.find(p => p.commodityId === selectedProductId.value)
)
const applyProductPayload = (data: ProductPayload, showToastMsg: boolean) => {
products.value = Array.isArray(data.products) ? data.products : []
productInfo.value = data
if (selectedProductId.value && !products.value.some(p => p.commodityId === selectedProductId.value)) {
clearSelection()
}
if (showToastMsg) {
showToast('商品已刷新', `共 ${products.value.length} 个`, 'success')
}
}
const loadProducts = async (showToastMsg = true) => {
loading.value = true
try {
const data = await LoadProducts(true)
products.value = Array.isArray(data.products) ? data.products : []
productInfo.value = data
applyProductPayload(data, showToastMsg)
} catch (e: any) {
products.value = []
productInfo.value = null
clearSelection()
showToast('商品刷新失败', e.message, 'error')
} finally {
loading.value = false
}
}
// 如果选中的商品不存在了,清空选择
if (selectedProductId.value && !products.value.some(p => p.commodityId === selectedProductId.value)) {
clearSelection()
}
if (showToastMsg) {
showToast('商品已刷新', `共 ${products.value.length} 个`, 'success')
}
const loadProductsForAccounts = async (accountNames: string[], showToastMsg = true) => {
loading.value = true
try {
const data = await LoadProductsForAccounts(accountNames)
applyProductPayload(data, showToastMsg)
} catch (e: any) {
products.value = []
productInfo.value = null
@@ -134,6 +152,7 @@ export function useProducts() {
loading,
xiaodianTab,
loadProducts,
loadProductsForAccounts,
selectProduct,
clearSelection,
setSearch,
+4 -4
View File
@@ -17,7 +17,7 @@ let unsubscribeSeckillState: (() => void) | null = null
export function useScheduler() {
const { showToast } = useToast()
const { resetStatus, buildAccountProducts, markSelectedPending, markSelectedStatus, queryXiaodianAssets } = useAccounts()
const { resetStatus, buildAccountProducts, markSelectedPending, markAccountNamesPending, markSelectedStatus, queryXiaodianAssets } = useAccounts()
const { selectedProductId, selectedProductName } = useProducts()
const { currentActivity } = useActivity()
const { addLocalLog, clearLocalResults } = useLogs()
@@ -161,12 +161,12 @@ export function useScheduler() {
}
}
const startFixed = async (attempts: number, delayMs: number) => {
const ap = buildAccountProducts(selectedProductId.value || '', false)
const startFixed = async (attempts: number, delayMs: number, accountProducts?: Record<string, string>) => {
const ap = accountProducts || buildAccountProducts(selectedProductId.value || '', false)
if (!ap) return false
resetStatus()
clearLocalResults()
markSelectedPending('固定抢购中,等待后端结果')
markAccountNamesPending(Object.keys(ap), '固定抢购中,等待后端结果')
setRunState('固定抢购已提交')
try {
const d = await StartFixedSeckill({ accountProducts: ap, attempts, delayMs })
+36 -4
View File
@@ -2,6 +2,7 @@ package app
import (
"fmt"
"math/rand/v2"
"strings"
"time"
@@ -13,16 +14,23 @@ import (
// LoadProducts 刷新商品列表。refresh=true 时从斗鱼实时拉取。
func (a *App) LoadProducts(refresh bool) types.ProductPayload {
return a.loadProducts(refresh, nil)
}
// LoadProductsForAccounts 使用指定账号随机刷新和平小店商品。
// 库存监控仅使用参与监控的账号,避免无关账号承担角色鉴权请求。
func (a *App) LoadProductsForAccounts(accountNames []string) types.ProductPayload {
return a.loadProducts(true, accountNames)
}
func (a *App) loadProducts(refresh bool, accountNames []string) types.ProductPayload {
if refresh {
cfg := a.getCfg()
options := types.XiaodianProductListOptions{PageSize: cfg.XiaodianPageSize}
var products []types.Product
err := fmt.Errorf("没有可用账号 Cookie")
accounts := a.scheduler.AccountSnapshot()
accounts := productRefreshAccounts(a.scheduler.AccountSnapshot(), accountNames)
for _, account := range accounts {
if strings.TrimSpace(account.Cookie) == "" {
continue
}
products, err = a.getClient().Products(account.Cookie, options)
if err == nil {
break
@@ -41,6 +49,30 @@ func (a *App) LoadProducts(refresh bool) types.ProductPayload {
return a.productPayload("cache")
}
func productRefreshAccounts(accounts []types.Account, preferredNames []string) []types.Account {
preferred := make(map[string]struct{}, len(preferredNames))
for _, name := range preferredNames {
preferred[strings.TrimSpace(name)] = struct{}{}
}
filtered := make([]types.Account, 0, len(accounts))
for _, account := range accounts {
if strings.TrimSpace(account.Cookie) == "" {
continue
}
if len(preferred) > 0 {
if _, ok := preferred[account.Name]; !ok {
continue
}
}
filtered = append(filtered, account)
}
rand.Shuffle(len(filtered), func(i, j int) {
filtered[i], filtered[j] = filtered[j], filtered[i]
})
return filtered
}
// GetAccounts 返回账号列表。
func (a *App) GetAccounts() []types.AccountView {
accounts := a.scheduler.AccountSnapshot()
+19
View File
@@ -0,0 +1,19 @@
package app
import (
"testing"
"douyu_go/internal/types"
)
func TestProductRefreshAccountsUsesOnlyPreferredAccounts(t *testing.T) {
accounts := []types.Account{
{Name: "a", Cookie: "cookie-a"},
{Name: "b", Cookie: ""},
{Name: "c", Cookie: "cookie-c"},
}
result := productRefreshAccounts(accounts, []string{"c", "b"})
if len(result) != 1 || result[0].Name != "c" {
t.Fatalf("productRefreshAccounts() = %#v", result)
}
}