修复库存监控提前停止并发布0.2.0

This commit is contained in:
yml2213
2026-08-09 19:37:30 +08:00
parent 02d38d9ede
commit 266f4a3da1
13 changed files with 239 additions and 31 deletions
@@ -5,6 +5,7 @@ import { useAccounts } from '@/composables/useAccounts'
import { useActivity } from '@/composables/useActivity'
import { useProducts } from '@/composables/useProducts'
import { useToast } from '@/composables/useToast'
import { GetResults } from '@wails/go/app/App'
const { scheduleActive, seckillRunning, startNow, stopSeckill, startFixed, startSchedule, cancelSchedule, restoreSchedule, subscribeSeckillState, unsubscribeSchedulerEvents } = useScheduler()
const {
@@ -36,6 +37,8 @@ const stockWatchAttempts = ref(Number(localStorage.getItem('xiaodianStockWatchAt
const stockWatchDelayMs = ref(Number(localStorage.getItem('xiaodianStockWatchDelayMs') || 500))
const stockWatching = ref(false)
const stockWatchAccountProducts = ref<Record<string, string>>({})
const stockWatchRunAccountProducts = ref<Record<string, string>>({})
const stockWatchReportSessionID = ref('')
const stockWatchTargetName = computed(() => {
const assignments = stockWatchAccountProducts.value
const accountCount = Object.keys(assignments).length
@@ -46,6 +49,7 @@ const stockWatchTargetName = computed(() => {
let stockWatchTimer: number | null = null
let stockWatchStartTimer: number | null = null
let stockRefreshing = false
let stockWatchSettling = false
// 从本地缓存恢复定时设置
const { savedTime } = restoreSchedule()
@@ -137,6 +141,65 @@ const stopStockWatch = () => {
}
stockRefreshing = false
stockWatchAccountProducts.value = {}
stockWatchRunAccountProducts.value = {}
stockWatchReportSessionID.value = ''
}
const createStockWatchReportSessionID = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `stock_${crypto.randomUUID()}`
}
return `stock_${Date.now()}_${Math.random().toString(36).slice(2)}`
}
const isStockWatchAccountTerminal = (message: string) => {
return /Livelink.*登录态校验失败|余额不足|点券不足|已拥有该道具|已经拥有该道具|已拥有该商品|未绑定账号/.test(message)
}
const isStockWatchProductTerminal = (message: string) => {
return /商品不存在|商品已下架|不支持该商品/.test(message)
}
const stockWatchResultAccountName = (key: string) => String(key || '').replace(/_第\d+包$/, '')
const settleStockWatchRun = async () => {
if (stockWatchSettling || Object.keys(stockWatchRunAccountProducts.value).length === 0) return
stockWatchSettling = true
const runAssignments = stockWatchRunAccountProducts.value
try {
const data = await GetResults() as Record<string, string>
const messagesByAccount: Record<string, string[]> = {}
Object.entries(data || {}).forEach(([key, value]) => {
const accountName = stockWatchResultAccountName(key)
if (!runAssignments[accountName]) return
if (!messagesByAccount[accountName]) messagesByAccount[accountName] = []
messagesByAccount[accountName].push(String(value || ''))
})
Object.entries(runAssignments).forEach(([accountName, productID]) => {
const messages = messagesByAccount[accountName] || []
const succeeded = messages.some(message => message.startsWith('成功|'))
const terminalAccount = messages.some(isStockWatchAccountTerminal)
const terminalProduct = messages.some(isStockWatchProductTerminal)
if (succeeded || terminalAccount || terminalProduct) {
delete stockWatchAccountProducts.value[accountName]
}
if (terminalProduct) {
Object.entries(stockWatchAccountProducts.value).forEach(([name, assignedProductID]) => {
if (assignedProductID === productID) delete stockWatchAccountProducts.value[name]
})
}
})
} catch {
// 结果读取失败时保留全部账号,下一轮库存刷新后仍可继续抢购。
} finally {
stockWatchRunAccountProducts.value = {}
stockWatchSettling = false
}
if (stockWatching.value && Object.keys(stockWatchAccountProducts.value).length === 0) {
stopStockWatch()
}
}
const beginKeywordWatch = async () => {
@@ -199,7 +262,7 @@ const toggleKeywordWatch = () => {
const tryStartStockBuy = async () => {
const assignments = stockWatchAccountProducts.value
if (Object.keys(assignments).length === 0) return false
if (scheduleActive.value || seckillRunning.value) return false
if (scheduleActive.value || seckillRunning.value || stockWatchSettling || Object.keys(stockWatchRunAccountProducts.value).length > 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]>()
@@ -216,15 +279,14 @@ const tryStartStockBuy = async () => {
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()
// startFixed 返回只代表任务已提交;账号是否应停止监控要等本轮真实结果。
stockWatchRunAccountProducts.value = { ...readyAccountProducts }
const started = await startFixed(stockWatchAttempts.value, stockWatchDelayMs.value, readyAccountProducts, stockWatchReportSessionID.value)
if (!started) {
stockWatchRunAccountProducts.value = {}
return false
}
showToast('检测到库存', `${productText},已启动 ${Object.keys(readyAccountProducts).length} 个账号抢购`, 'info', 3000)
return true
}
@@ -238,7 +300,7 @@ const beginStockWatch = async () => {
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
if (!stockWatching.value || scheduleActive.value || seckillRunning.value || stockRefreshing || stockWatchSettling) return
stockRefreshing = true
try {
await loadProductsForAccounts(accountNames(), false)
@@ -269,6 +331,7 @@ const toggleStockWatch = () => {
saveStockWatchConfig()
stopKeywordWatch()
stockWatchAccountProducts.value = { ...accountProducts }
stockWatchReportSessionID.value = createStockWatchReportSessionID()
stockWatching.value = true
const [hours, minutes, seconds] = stockWatchStartTime.value.split(':').map(Number)
const target = new Date()
@@ -292,6 +355,12 @@ watch(isXiaodian, (enabled) => {
}
})
watch(seckillRunning, (running, wasRunning) => {
if (!running && wasRunning && stockWatching.value) {
void settleStockWatchRun()
}
})
onUnmounted(() => {
stopKeywordWatch()
stopStockWatch()
+2 -2
View File
@@ -161,7 +161,7 @@ export function useScheduler() {
}
}
const startFixed = async (attempts: number, delayMs: number, accountProducts?: Record<string, string>) => {
const startFixed = async (attempts: number, delayMs: number, accountProducts?: Record<string, string>, reportSessionID = '') => {
const ap = accountProducts || buildAccountProducts(selectedProductId.value || '', false)
if (!ap) return false
resetStatus()
@@ -169,7 +169,7 @@ export function useScheduler() {
markAccountNamesPending(Object.keys(ap), '固定抢购中,等待后端结果')
setRunState('固定抢购已提交')
try {
const d = await StartFixedSeckill({ accountProducts: ap, attempts, delayMs })
const d = await StartFixedSeckill({ accountProducts: ap, attempts, delayMs, reportSessionId: reportSessionID })
if (d?.success === false) throw new Error(d.error || '提交失败')
seckillRunning.value = true
pendingXiaodianAssetAccounts.value = Object.keys(ap)
+1 -1
View File
@@ -68,7 +68,7 @@ func (a *App) startXiaodianSeckill(req types.FixedSeckillRequest, message string
if !a.scheduler.TryStartRun() {
return types.MessageResult{Error: "抢购任务正在运行,请勿重复提交"}
}
go a.scheduler.RunXiaodian(utils.CloneStringMap(req.AccountProducts), req.Attempts, req.DelayMs)
go a.scheduler.RunXiaodian(utils.CloneStringMap(req.AccountProducts), req.Attempts, req.DelayMs, req.ReportSessionID)
return types.MessageResult{Success: true, Msg: message}
}
+41 -7
View File
@@ -13,6 +13,7 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/xuri/excelize/v2"
"douyu_go/internal/constants"
"douyu_go/internal/logger"
"douyu_go/internal/types"
)
@@ -274,7 +275,7 @@ func xiaodianDetailsHeaders() []string {
func xiaodianSummaryRow(sequence int, report types.XiaodianRunReport, item xiaodianReportExportData) []interface{} {
account := item.ReportAccount
matched := selectedXiaodianRecord(item)
matched := selectedXiaodianRecord(item, report)
remarks := reportExportRemarks(item)
return []interface{}{
sequence,
@@ -288,7 +289,7 @@ func xiaodianSummaryRow(sequence int, report types.XiaodianRunReport, item xiaod
reportAssetValue(item, false),
exportText(account.Status),
exportText(account.FailureReason),
exportText(selectedRecordVerification(item)),
exportText(selectedRecordVerification(item, report)),
exportText(selectedRecordStatus(item, matched)),
account.Attempts,
exportText(remarks),
@@ -384,25 +385,58 @@ func reportAssetValue(item xiaodianReportExportData, coupons bool) interface{} {
return item.Assets.Fragments
}
func selectedXiaodianRecord(item xiaodianReportExportData) *types.XiaodianExchangeRecord {
func selectedXiaodianRecord(item xiaodianReportExportData, report types.XiaodianRunReport) *types.XiaodianExchangeRecord {
for index := range item.Records {
if item.Records[index].CommodityID == item.ReportAccount.ProductID {
return &item.Records[index]
record := &item.Records[index]
if record.CommodityID == item.ReportAccount.ProductID && recordBelongsToReport(*record, report) {
return record
}
}
return nil
}
func selectedRecordVerification(item xiaodianReportExportData) string {
func selectedRecordVerification(item xiaodianReportExportData, report types.XiaodianRunReport) string {
if item.RecordError != "" {
return "查询失败"
}
if selectedXiaodianRecord(item) != nil {
if selectedXiaodianRecord(item, report) != nil {
return "已兑换"
}
return "未发现订单"
}
func recordBelongsToReport(record types.XiaodianExchangeRecord, report types.XiaodianRunReport) bool {
if report.StartedAt.IsZero() {
return false
}
value := strings.TrimSpace(record.BuyTime)
if value == "" {
value = strings.TrimSpace(record.PayTime)
}
recordedAt, ok := parseXiaodianRecordTime(value)
return ok && !recordedAt.Before(report.StartedAt)
}
func parseXiaodianRecordTime(value string) (time.Time, bool) {
for _, layout := range []string{
"2006-01-02 15:04:05.999999999",
"2006-01-02 15:04:05",
time.RFC3339Nano,
time.RFC3339,
} {
if strings.Contains(layout, "Z07") {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
continue
}
if parsed, err := time.ParseInLocation(layout, value, constants.CST); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func selectedRecordStatus(item xiaodianReportExportData, record *types.XiaodianExchangeRecord) string {
if item.RecordError != "" {
return "查询失败"
@@ -95,3 +95,20 @@ func TestSelectedRecordStatus(t *testing.T) {
t.Fatalf("orderStatusText(empty) = %q", got)
}
}
func TestSelectedXiaodianRecordIgnoresHistoryBeforeReport(t *testing.T) {
report := types.XiaodianRunReport{
StartedAt: time.Date(2026, 8, 9, 15, 0, 0, 0, time.Local),
}
item := xiaodianReportExportData{
ReportAccount: types.XiaodianRunReportAccount{ProductID: "goods-1"},
Records: []types.XiaodianExchangeRecord{
{CommodityID: "goods-1", BuyTime: "2026-08-09 14:59:59", Status: "3"},
{CommodityID: "goods-1", BuyTime: "2026-08-09 15:00:01", Status: "3"},
},
}
matched := selectedXiaodianRecord(item, report)
if matched == nil || matched.BuyTime != "2026-08-09 15:00:01" {
t.Fatalf("历史订单不应被选中: %+v", matched)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package constants
import "time"
// Version 应用版本号,与 wails.json version 同步。
const Version = "0.1.9"
const Version = "0.2.0"
// BuildTime 构建时间,由编译时 ldflags 注入。
var BuildTime = "unknown"
+1 -1
View File
@@ -132,7 +132,7 @@ func (s *Scheduler) scheduleTimers(accountProducts map[string]string, targetTime
return
}
if s.clientSnapshot().Activity.IsXiaodian {
s.RunXiaodian(accountProducts, attempts, delayMs)
s.RunXiaodian(accountProducts, attempts, delayMs, "")
return
}
s.RunNow(accountProducts)
+2 -2
View File
@@ -616,7 +616,7 @@ func (s *Scheduler) RunFixed(accountProducts map[string]string, attempts, delayM
// RunXiaodian 使用和平小店专用策略:固定次数、固定间隔、每轮直接调用 livebuy。
// 小店补货不依赖旧活动的 75 秒阶段窗口,也不执行手册的预检链路。
func (s *Scheduler) RunXiaodian(accountProducts map[string]string, attempts, delayMs int) {
func (s *Scheduler) RunXiaodian(accountProducts map[string]string, attempts, delayMs int, reportSessionID string) {
defer s.finishRun()
s.ClearResults()
@@ -641,7 +641,7 @@ func (s *Scheduler) RunXiaodian(accountProducts map[string]string, attempts, del
logger.LogEvent("抢购", "warn", "和平小店抢购没有可执行账号")
return
}
s.startXiaodianReport(accounts, products)
s.startXiaodianReport(accounts, products, reportSessionID)
stoppedAccounts := make(map[string]string)
cooldownAccounts := make(map[string]time.Time)
+43 -4
View File
@@ -15,9 +15,51 @@ import (
const xiaodianReportFileName = "last_xiaodian_report.json"
func (s *Scheduler) startXiaodianReport(accounts []types.Account, products map[string]types.Product) {
func (s *Scheduler) startXiaodianReport(accounts []types.Account, products map[string]types.Product, sessionID string) {
s.reportMu.Lock()
defer s.reportMu.Unlock()
// 库存监控会把不同商品/账号拆成多次任务;同一会话追加账号,普通抢购则创建新报告。
if sessionID != "" && s.latestXiaodianReport != nil && s.latestXiaodianReport.SessionID == sessionID {
report := s.latestXiaodianReport
report.Stopped = false
report.FinishedAt = time.Time{}
existing := make(map[string]int, len(report.Accounts))
for index := range report.Accounts {
existing[report.Accounts[index].AccountName] = index
}
for _, account := range accounts {
product := products[account.Name]
if index, ok := existing[account.Name]; ok {
// 商品映射在会话内保持账号独立;仅补齐可能缺失的商品元数据。
item := &report.Accounts[index]
if item.ProductID == "" {
item.ProductID = product.CommodityID
}
if item.ProductName == "" {
item.ProductName = product.CommodityName
}
continue
}
report.Accounts = append(report.Accounts, types.XiaodianRunReportAccount{
AccountName: account.Name,
ProductID: product.CommodityID,
ProductName: product.CommodityName,
ProductPrice: product.Score,
PayType: product.PayType,
PayLabel: product.PayLabel,
Status: "待执行",
})
}
sort.Slice(report.Accounts, func(i, j int) bool {
return report.Accounts[i].AccountName < report.Accounts[j].AccountName
})
return
}
report := types.XiaodianRunReport{
ReportID: fmt.Sprintf("xiaodian_%s", time.Now().Format("20060102_150405.000")),
SessionID: sessionID,
StartedAt: time.Now(),
Accounts: make([]types.XiaodianRunReportAccount, 0, len(accounts)),
}
@@ -36,10 +78,7 @@ func (s *Scheduler) startXiaodianReport(accounts []types.Account, products map[s
sort.Slice(report.Accounts, func(i, j int) bool {
return report.Accounts[i].AccountName < report.Accounts[j].AccountName
})
s.reportMu.Lock()
s.latestXiaodianReport = &report
s.reportMu.Unlock()
}
func (s *Scheduler) updateXiaodianReport(results map[string]types.ExchangeResult) {
+47 -1
View File
@@ -1,6 +1,10 @@
package seckill
import "testing"
import (
"testing"
"douyu_go/internal/types"
)
func TestReportFailureReasonUsesXiaodianStopReason(t *testing.T) {
message := "失败|和平小店兑换失败: 对不起,Livelink登录态校验失败!"
@@ -8,3 +12,45 @@ func TestReportFailureReasonUsesXiaodianStopReason(t *testing.T) {
t.Fatalf("reportFailureReason() = %q", got)
}
}
func TestStartXiaodianReportAppendsWithinSession(t *testing.T) {
scheduler := &Scheduler{}
firstAccounts := []types.Account{{Name: "账号A"}}
firstProducts := map[string]types.Product{
"账号A": {CommodityID: "goods-a", CommodityName: "商品A"},
}
scheduler.startXiaodianReport(firstAccounts, firstProducts, "stock-session")
scheduler.updateXiaodianReport(map[string]types.ExchangeResult{
"账号A": {OK: true, Account: "账号A", Message: "成功|角色A"},
})
secondAccounts := []types.Account{{Name: "账号B"}}
secondProducts := map[string]types.Product{
"账号B": {CommodityID: "goods-b", CommodityName: "商品B"},
}
scheduler.startXiaodianReport(secondAccounts, secondProducts, "stock-session")
report, ok := scheduler.LatestXiaodianReport()
if !ok || len(report.Accounts) != 2 {
t.Fatalf("同一监控会话应保留两批账号,报告=%+v", report)
}
if report.Accounts[0].ProductID != "goods-a" || report.Accounts[1].ProductID != "goods-b" {
t.Fatalf("账号商品映射丢失: %+v", report.Accounts)
}
if report.Accounts[0].Status != "成功" || report.Accounts[0].Attempts != 1 {
t.Fatalf("前一批结果被覆盖: %+v", report.Accounts[0])
}
}
func TestStartXiaodianReportDifferentSessionReplacesReport(t *testing.T) {
scheduler := &Scheduler{}
scheduler.startXiaodianReport([]types.Account{{Name: "账号A"}}, map[string]types.Product{
"账号A": {CommodityID: "goods-a"},
}, "session-a")
scheduler.startXiaodianReport([]types.Account{{Name: "账号B"}}, map[string]types.Product{
"账号B": {CommodityID: "goods-b"},
}, "session-b")
report, ok := scheduler.LatestXiaodianReport()
if !ok || report.SessionID != "session-b" || len(report.Accounts) != 1 || report.Accounts[0].AccountName != "账号B" {
t.Fatalf("不同会话不应串报告: %+v", report)
}
}
+2
View File
@@ -149,6 +149,8 @@ type FixedSeckillRequest struct {
AccountProducts map[string]string `json:"accountProducts"`
Attempts int `json:"attempts"`
DelayMs int `json:"delayMs"`
// ReportSessionID 用于把库存监控拆分的多次抢购合并到同一份报告。
ReportSessionID string `json:"reportSessionId,omitempty"`
}
// APIResponse 斗鱼 API 通用响应。
+1
View File
@@ -5,6 +5,7 @@ import "time"
// XiaodianRunReport 保存一次和平小店抢购的可导出快照,不包含 Cookie 等敏感凭据。
type XiaodianRunReport struct {
ReportID string `json:"reportId"`
SessionID string `json:"sessionId,omitempty"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
Stopped bool `json:"stopped"`
+2 -2
View File
@@ -2,7 +2,7 @@
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "douyu_go",
"outputfilename": "douyu_go",
"version": "0.1.9",
"version": "0.2.0",
"frontend:dir": "frontend",
"frontend:install": "npm install",
"frontend:build": "npm run build",
@@ -15,6 +15,6 @@
"email": "yml@example.com"
},
"info": {
"productVersion": "0.1.9"
"productVersion": "0.2.0"
}
}