支持按账号商品监控库存抢购
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user