From 266f4a3da187d54224660fb3ddc60c0545454243 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 9 Aug 2026 19:22:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BA=93=E5=AD=98=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E6=8F=90=E5=89=8D=E5=81=9C=E6=AD=A2=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=B8=830.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/console/ControlStrip.vue | 89 ++++++++++++++++--- frontend/src/composables/useScheduler.ts | 4 +- internal/app/app_seckill.go | 2 +- internal/app/app_xiaodian_report_export.go | 48 ++++++++-- .../app/app_xiaodian_report_export_test.go | 17 ++++ internal/constants/constants.go | 2 +- internal/seckill/schedule_timer.go | 2 +- internal/seckill/scheduler.go | 4 +- internal/seckill/xiaodian_report.go | 47 +++++++++- internal/seckill/xiaodian_report_test.go | 48 +++++++++- internal/types/types.go | 2 + internal/types/xiaodian_report.go | 1 + wails.json | 4 +- 13 files changed, 239 insertions(+), 31 deletions(-) diff --git a/frontend/src/components/console/ControlStrip.vue b/frontend/src/components/console/ControlStrip.vue index 7570a04..2b8ef5c 100644 --- a/frontend/src/components/console/ControlStrip.vue +++ b/frontend/src/components/console/ControlStrip.vue @@ -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>({}) +const stockWatchRunAccountProducts = ref>({}) +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 + const messagesByAccount: Record = {} + 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 = {} const readyProducts = new Map() @@ -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() diff --git a/frontend/src/composables/useScheduler.ts b/frontend/src/composables/useScheduler.ts index c77065a..31c3954 100644 --- a/frontend/src/composables/useScheduler.ts +++ b/frontend/src/composables/useScheduler.ts @@ -161,7 +161,7 @@ export function useScheduler() { } } - const startFixed = async (attempts: number, delayMs: number, accountProducts?: Record) => { + const startFixed = async (attempts: number, delayMs: number, accountProducts?: Record, 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) diff --git a/internal/app/app_seckill.go b/internal/app/app_seckill.go index 8509692..9855ff5 100644 --- a/internal/app/app_seckill.go +++ b/internal/app/app_seckill.go @@ -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} } diff --git a/internal/app/app_xiaodian_report_export.go b/internal/app/app_xiaodian_report_export.go index baebb24..8a5c2c0 100644 --- a/internal/app/app_xiaodian_report_export.go +++ b/internal/app/app_xiaodian_report_export.go @@ -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 "查询失败" diff --git a/internal/app/app_xiaodian_report_export_test.go b/internal/app/app_xiaodian_report_export_test.go index d940533..0fe87b0 100644 --- a/internal/app/app_xiaodian_report_export_test.go +++ b/internal/app/app_xiaodian_report_export_test.go @@ -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) + } +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 01dfd41..055f7eb 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -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" diff --git a/internal/seckill/schedule_timer.go b/internal/seckill/schedule_timer.go index 159490f..be551f2 100644 --- a/internal/seckill/schedule_timer.go +++ b/internal/seckill/schedule_timer.go @@ -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) diff --git a/internal/seckill/scheduler.go b/internal/seckill/scheduler.go index 2d648b5..1f1bcbc 100644 --- a/internal/seckill/scheduler.go +++ b/internal/seckill/scheduler.go @@ -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) diff --git a/internal/seckill/xiaodian_report.go b/internal/seckill/xiaodian_report.go index 880a3f7..7b8e3da 100644 --- a/internal/seckill/xiaodian_report.go +++ b/internal/seckill/xiaodian_report.go @@ -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) { diff --git a/internal/seckill/xiaodian_report_test.go b/internal/seckill/xiaodian_report_test.go index 74da47b..fc3599e 100644 --- a/internal/seckill/xiaodian_report_test.go +++ b/internal/seckill/xiaodian_report_test.go @@ -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) + } +} diff --git a/internal/types/types.go b/internal/types/types.go index 8abbb9a..3f1804a 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -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 通用响应。 diff --git a/internal/types/xiaodian_report.go b/internal/types/xiaodian_report.go index d9a1412..1de1698 100644 --- a/internal/types/xiaodian_report.go +++ b/internal/types/xiaodian_report.go @@ -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"` diff --git a/wails.json b/wails.json index 4a4bf42..ae2621c 100644 --- a/wails.json +++ b/wails.json @@ -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" } }