新增小店抢购报告导出并优化停止条件

This commit is contained in:
yml2213
2026-08-09 16:06:46 +08:00
parent 8b867709b2
commit 0c55da1e97
12 changed files with 884 additions and 19 deletions
@@ -7,8 +7,9 @@ import type { Product } from '@/composables/useProducts'
import { useActivity } from '@/composables/useActivity'
import { useToast } from '@/composables/useToast'
import {
QueryXiaodianAccountExchangeRecords,
QueryXiaodianExchangeRecords
ExportLatestXiaodianReport,
QueryXiaodianAccountExchangeRecords,
QueryXiaodianExchangeRecords
} from '@wails/go/app/App'
const {
@@ -38,6 +39,8 @@ const recordsModal = ref<{ accountName: string; loading: boolean; error: string;
const exchangeLookup = ref<{ productId: string; productName: string; loading: boolean; results: Record<string, any> } | null>(null)
const deleteTargetAccount = ref<string | null>(null)
const deletingAccount = ref(false)
const reportFormat = ref<'xlsx' | 'csv'>('xlsx')
const exportingReport = ref(false)
watch(xiaodianQRCode, async (value) => {
qrImage.value = ''
@@ -58,6 +61,24 @@ const handleBind = async () => {
await bindXiaodianRole()
}
const exportXiaodianReport = async () => {
if (exportingReport.value) return
exportingReport.value = true
try {
const result = await ExportLatestXiaodianReport(reportFormat.value)
if (result.cancelled) return
if (!result.success) {
showToast('导出失败', result.error || '无法生成抢购报告', 'error')
return
}
showToast('导出成功', `账号 ${result.accountCount} 个,订单 ${result.recordCount} 条`, 'success')
} catch (e: any) {
showToast('导出失败', e?.message || '无法生成抢购报告', 'error')
} finally {
exportingReport.value = false
}
}
const requestDeleteAccount = (accountName: string) => {
deleteTargetAccount.value = accountName
}
@@ -260,6 +281,13 @@ const closeExchangeRecords = () => {
>
{{ exchangeLookup?.loading ? '查询中...' : '查询兑换记录' }}
</button>
<select v-model="reportFormat" class="report-format" :disabled="exportingReport" aria-label="选择报告导出格式">
<option value="xlsx">Excel</option>
<option value="csv">CSV</option>
</select>
<button class="btn btn-compact btn-export-report" :disabled="exportingReport" @click="exportXiaodianReport">
{{ exportingReport ? '导出中...' : '导出报告' }}
</button>
</div>
</div>
@@ -541,6 +569,23 @@ const closeExchangeRecords = () => {
.exchange-lookup-product:disabled { opacity: .6; cursor: not-allowed; }
.report-format {
width: 72px;
height: 28px;
padding: 0 6px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--field);
color: var(--text);
font-size: 12px;
}
.btn-export-report {
color: var(--success);
border-color: var(--success);
background: var(--success-soft);
}
.panel-title {
font-size: 13px;
font-weight: 600;
+9 -3
View File
@@ -6,6 +6,7 @@ require github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
require (
github.com/wailsapp/wails/v2 v2.12.0
github.com/xuri/excelize/v2 v2.9.1
golang.org/x/image v0.42.0
)
@@ -27,15 +28,20 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tiendc/go-deepcopy v1.6.0 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.1 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.38.0 // indirect
)
+19 -6
View File
@@ -44,6 +44,11 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -51,6 +56,8 @@ github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo=
github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -63,21 +70,27 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s=
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+476
View File
@@ -0,0 +1,476 @@
package app
import (
"archive/zip"
"encoding/csv"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/xuri/excelize/v2"
"douyu_go/internal/logger"
"douyu_go/internal/types"
)
const xiaodianReportExportConcurrency = 4
type xiaodianReportExportData struct {
ReportAccount types.XiaodianRunReportAccount
Assets types.XiaodianAssets
AssetError string
Records []types.XiaodianExchangeRecord
RecordError string
}
// ExportLatestXiaodianReport 导出最近一次完成的和平小店抢购报告。
// format 支持 xlsx(Excel)和 csv(含两份 CSV 的 ZIP 包)。
func (a *App) ExportLatestXiaodianReport(format string) types.XiaodianReportExportResult {
format = strings.ToLower(strings.TrimSpace(format))
if format != "xlsx" && format != "csv" {
return types.XiaodianReportExportResult{Error: "导出格式仅支持 Excel 或 CSV"}
}
if a.scheduler.IsRunning() {
return types.XiaodianReportExportResult{Error: "抢购任务仍在运行,请结束后再导出"}
}
report, ok := a.scheduler.LatestXiaodianReport()
if !ok || report.FinishedAt.IsZero() {
return types.XiaodianReportExportResult{Error: "暂无已完成的和平小店抢购报告"}
}
data := a.collectXiaodianReportData(report)
recordCount := 0
for _, item := range data {
recordCount += len(item.Records)
}
extension := ".xlsx"
filter := runtime.FileFilter{DisplayName: "Excel 工作簿 (*.xlsx)", Pattern: "*.xlsx"}
if format == "csv" {
extension = ".zip"
filter = runtime.FileFilter{DisplayName: "CSV 报告压缩包 (*.zip)", Pattern: "*.zip"}
}
exportDir := filepath.Join(a.paths.DataDir, "exports")
if err := os.MkdirAll(exportDir, 0700); err != nil {
return types.XiaodianReportExportResult{Error: "创建导出目录失败: " + err.Error()}
}
defaultName := fmt.Sprintf("和平小店抢购报告_%s%s", report.FinishedAt.Format("20060102_150405"), extension)
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
DefaultDirectory: exportDir,
DefaultFilename: defaultName,
Title: "导出和平小店抢购报告",
Filters: []runtime.FileFilter{filter},
})
if err != nil {
return types.XiaodianReportExportResult{Error: "选择导出位置失败: " + err.Error()}
}
if strings.TrimSpace(path) == "" {
return types.XiaodianReportExportResult{Success: true, Cancelled: true}
}
path = reportExportPath(path, extension)
if format == "xlsx" {
err = writeXiaodianReportXLSX(path, report, data)
} else {
err = writeXiaodianReportCSVZip(path, report, data)
}
if err != nil {
return types.XiaodianReportExportResult{Error: "写入导出文件失败: " + err.Error()}
}
logger.LogEvent("导出", "success", "和平小店抢购报告已导出: 账号=%d 记录=%d 路径=%s", len(data), recordCount, path)
return types.XiaodianReportExportResult{
Success: true,
FilePath: path,
AccountCount: len(data),
RecordCount: recordCount,
}
}
func (a *App) collectXiaodianReportData(report types.XiaodianRunReport) []xiaodianReportExportData {
data := make([]xiaodianReportExportData, len(report.Accounts))
accounts := make(map[string]types.Account, len(report.Accounts))
for _, account := range a.scheduler.AccountSnapshot() {
accounts[account.Name] = account
}
workers := xiaodianReportExportConcurrency
if len(data) < workers {
workers = len(data)
}
if workers == 0 {
return data
}
jobs := make(chan int)
var wg sync.WaitGroup
for worker := 0; worker < workers; worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for index := range jobs {
item := xiaodianReportExportData{ReportAccount: report.Accounts[index]}
account, ok := accounts[item.ReportAccount.AccountName]
if !ok || strings.TrimSpace(account.Cookie) == "" {
item.AssetError = "账号已删除或 Cookie 为空"
item.RecordError = item.AssetError
data[index] = item
continue
}
assets, err := a.getClient().XiaodianAssets(account.Cookie)
if err != nil {
item.AssetError = err.Error()
} else {
item.Assets = assets
}
records, err := a.getClient().XiaodianExchangeList(account.Cookie)
if err != nil {
item.RecordError = err.Error()
} else {
item.Records = records
}
data[index] = item
}
}()
}
for index := range data {
jobs <- index
}
close(jobs)
wg.Wait()
return data
}
func writeXiaodianReportXLSX(path string, report types.XiaodianRunReport, data []xiaodianReportExportData) error {
file := excelize.NewFile()
defer file.Close()
const summarySheet = "账号汇总"
file.SetSheetName("Sheet1", summarySheet)
if err := writeXiaodianSummarySheet(file, summarySheet, report, data); err != nil {
return fmt.Errorf("写入账号汇总失败: %w", err)
}
const detailsSheet = "兑换明细"
if _, err := file.NewSheet(detailsSheet); err != nil {
return err
}
if err := writeXiaodianDetailsSheet(file, detailsSheet, data); err != nil {
return fmt.Errorf("写入兑换明细失败: %w", err)
}
return file.SaveAs(path)
}
func writeXiaodianSummarySheet(file *excelize.File, sheet string, report types.XiaodianRunReport, data []xiaodianReportExportData) error {
headers := xiaodianSummaryHeaders()
headerRow := stringRow(headers)
if err := file.SetSheetRow(sheet, "A1", &headerRow); err != nil {
return fmt.Errorf("写入汇总表头失败: %w", err)
}
for index, item := range data {
row := index + 2
values := xiaodianSummaryRow(index+1, report, item)
if err := file.SetSheetRow(sheet, fmt.Sprintf("A%d", row), &values); err != nil {
return fmt.Errorf("写入汇总第%d行失败: %w", row, err)
}
}
return styleXiaodianReportSheet(file, sheet, len(headers), len(data)+1, []float64{8, 20, 16, 32, 18, 12, 12, 14, 14, 12, 28, 16, 14, 12, 32, 20, 20})
}
func writeXiaodianDetailsSheet(file *excelize.File, sheet string, data []xiaodianReportExportData) error {
headers := xiaodianDetailsHeaders()
headerRow := stringRow(headers)
if err := file.SetSheetRow(sheet, "A1", &headerRow); err != nil {
return fmt.Errorf("写入明细表头失败: %w", err)
}
row := 2
sequence := 1
for _, item := range data {
for _, record := range item.Records {
values := xiaodianDetailsRow(sequence, item, record)
if err := file.SetSheetRow(sheet, fmt.Sprintf("A%d", row), &values); err != nil {
return fmt.Errorf("写入明细第%d行失败: %w", row, err)
}
row++
sequence++
}
}
return styleXiaodianReportSheet(file, sheet, len(headers), row-1, []float64{8, 20, 32, 32, 18, 10, 12, 20, 20, 14, 16})
}
func styleXiaodianReportSheet(file *excelize.File, sheet string, columns, rows int, widths []float64) error {
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Color: []string{"1677FF"}, Pattern: 1},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
if err != nil {
return fmt.Errorf("创建表头样式失败: %w", err)
}
lastColumn, err := excelize.ColumnNumberToName(columns)
if err != nil {
return fmt.Errorf("计算列名失败: %w", err)
}
if err := file.SetCellStyle(sheet, "A1", fmt.Sprintf("%s1", lastColumn), headerStyle); err != nil {
return fmt.Errorf("设置表头样式失败: %w", err)
}
for index, width := range widths {
column, _ := excelize.ColumnNumberToName(index + 1)
if err := file.SetColWidth(sheet, column, column, width); err != nil {
return fmt.Errorf("设置列宽失败: %w", err)
}
}
if rows > 1 {
if err := file.AutoFilter(sheet, fmt.Sprintf("A1:%s%d", lastColumn, rows), nil); err != nil {
return fmt.Errorf("设置筛选失败: %w", err)
}
}
if err := file.SetPanes(sheet, &excelize.Panes{Freeze: true, Split: false, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft"}); err != nil {
return fmt.Errorf("冻结表头失败: %w", err)
}
return nil
}
func writeXiaodianReportCSVZip(path string, report types.XiaodianRunReport, data []xiaodianReportExportData) error {
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
archive := zip.NewWriter(file)
defer archive.Close()
if err := writeXiaodianCSVEntry(archive, "账号汇总.csv", xiaodianSummaryHeaders(), summaryCSVRows(report, data)); err != nil {
return err
}
return writeXiaodianCSVEntry(archive, "兑换明细.csv", xiaodianDetailsHeaders(), detailsCSVRows(data))
}
func writeXiaodianCSVEntry(archive *zip.Writer, name string, headers []string, rows [][]string) error {
entry, err := archive.Create(name)
if err != nil {
return err
}
if _, err := entry.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
return err
}
writer := csv.NewWriter(entry)
if err := writer.Write(headers); err != nil {
return err
}
for _, row := range rows {
if err := writer.Write(row); err != nil {
return err
}
}
writer.Flush()
return writer.Error()
}
func xiaodianSummaryHeaders() []string {
return []string{"序号", "斗鱼账号", "角色名", "当时选择商品", "商品ID", "商品价格", "支付方式", "点券(查询时)", "扭蛋碎片(查询时)", "本次结果", "失败/停止原因", "选中商品订单核验", "订单状态", "尝试次数", "备注", "任务开始时间", "任务结束时间"}
}
func xiaodianDetailsHeaders() []string {
return []string{"序号", "斗鱼账号", "当时选择商品", "订单商品", "商品ID", "数量", "支付金额", "兑换时间", "支付时间", "订单状态", "角色名"}
}
func xiaodianSummaryRow(sequence int, report types.XiaodianRunReport, item xiaodianReportExportData) []interface{} {
account := item.ReportAccount
matched := selectedXiaodianRecord(item)
remarks := reportExportRemarks(item)
return []interface{}{
sequence,
exportText(account.AccountName),
exportText(firstNonEmpty(account.RoleName, roleNameFromRecords(item.Records))),
exportText(account.ProductName),
exportText(account.ProductID),
formatReportPrice(account),
exportText(reportPayLabel(account)),
reportAssetValue(item, true),
reportAssetValue(item, false),
exportText(account.Status),
exportText(account.FailureReason),
exportText(selectedRecordVerification(item)),
exportText(orderStatusText(recordStatus(matched))),
account.Attempts,
exportText(remarks),
exportText(formatReportTime(report.StartedAt)),
exportText(formatReportTime(report.FinishedAt)),
}
}
func xiaodianDetailsRow(sequence int, item xiaodianReportExportData, record types.XiaodianExchangeRecord) []interface{} {
return []interface{}{
sequence,
exportText(item.ReportAccount.AccountName),
exportText(item.ReportAccount.ProductName),
exportText(record.CommodityName),
exportText(record.CommodityID),
record.Quantity,
record.Price,
exportText(record.BuyTime),
exportText(record.PayTime),
exportText(orderStatusText(record.Status)),
exportText(record.RoleName),
}
}
func summaryCSVRows(report types.XiaodianRunReport, data []xiaodianReportExportData) [][]string {
rows := make([][]string, 0, len(data))
for index, item := range data {
values := xiaodianSummaryRow(index+1, report, item)
rows = append(rows, interfaceRowToStrings(values))
}
return rows
}
func detailsCSVRows(data []xiaodianReportExportData) [][]string {
rows := make([][]string, 0)
sequence := 1
for _, item := range data {
for _, record := range item.Records {
rows = append(rows, interfaceRowToStrings(xiaodianDetailsRow(sequence, item, record)))
sequence++
}
}
return rows
}
func stringRow(values []string) []interface{} {
row := make([]interface{}, len(values))
for index, value := range values {
row[index] = value
}
return row
}
func interfaceRowToStrings(values []interface{}) []string {
row := make([]string, len(values))
for index, value := range values {
row[index] = fmt.Sprint(value)
}
return row
}
func reportExportPath(path, extension string) string {
if strings.EqualFold(filepath.Ext(path), extension) {
return path
}
return strings.TrimSuffix(path, filepath.Ext(path)) + extension
}
func reportPayLabel(account types.XiaodianRunReportAccount) string {
if account.PayLabel != "" {
return account.PayLabel
}
if account.PayType == 5 {
return "扭蛋碎片"
}
return "点券"
}
func formatReportPrice(account types.XiaodianRunReportAccount) string {
if account.PayType == 1 {
return fmt.Sprintf("%.1f", float64(account.ProductPrice)/10)
}
return fmt.Sprint(account.ProductPrice)
}
func reportAssetValue(item xiaodianReportExportData, coupons bool) interface{} {
if item.AssetError != "" || !item.Assets.Success {
return "查询失败"
}
if coupons {
return item.Assets.Coupons
}
return item.Assets.Fragments
}
func selectedXiaodianRecord(item xiaodianReportExportData) *types.XiaodianExchangeRecord {
for index := range item.Records {
if item.Records[index].CommodityID == item.ReportAccount.ProductID {
return &item.Records[index]
}
}
return nil
}
func selectedRecordVerification(item xiaodianReportExportData) string {
if item.RecordError != "" {
return "查询失败"
}
if selectedXiaodianRecord(item) != nil {
return "已兑换"
}
return "未发现订单"
}
func recordStatus(record *types.XiaodianExchangeRecord) string {
if record == nil {
return ""
}
return record.Status
}
func orderStatusText(status string) string {
switch status {
case "0":
return "待支付"
case "1":
return "处理中"
case "2":
return "已支付"
case "3":
return "已完成"
case "4":
return "已取消"
case "5":
return "已退款"
case "":
return "-"
default:
return "状态 " + status
}
}
func reportExportRemarks(item xiaodianReportExportData) string {
remarks := make([]string, 0, 2)
if item.AssetError != "" {
remarks = append(remarks, "资产查询失败:"+item.AssetError)
}
if item.RecordError != "" {
remarks = append(remarks, "订单查询失败:"+item.RecordError)
}
return strings.Join(remarks, ";")
}
func roleNameFromRecords(records []types.XiaodianExchangeRecord) string {
for _, record := range records {
if record.RoleName != "" {
return record.RoleName
}
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func exportText(value string) string {
value = strings.TrimSpace(value)
if value != "" && strings.ContainsAny(value[:1], "=+-@") {
return "'" + value
}
return value
}
func formatReportTime(value time.Time) string {
if value.IsZero() {
return "-"
}
return value.Format("2006-01-02 15:04:05")
}
@@ -0,0 +1,85 @@
package app
import (
"archive/zip"
"io"
"path/filepath"
"strings"
"testing"
"time"
"github.com/xuri/excelize/v2"
"douyu_go/internal/types"
)
func testXiaodianReportData() (types.XiaodianRunReport, []xiaodianReportExportData) {
report := types.XiaodianRunReport{
ReportID: "xiaodian_test",
StartedAt: time.Date(2026, 8, 9, 15, 0, 0, 0, time.Local),
FinishedAt: time.Date(2026, 8, 9, 15, 1, 0, 0, time.Local),
Accounts: []types.XiaodianRunReportAccount{{
AccountName: "用户10001", ProductID: "goods-1", ProductName: "测试套装",
ProductPrice: 7100, PayType: 1, PayLabel: "点券", Attempts: 2, Status: "成功", RoleName: "测试角色",
}},
}
data := []xiaodianReportExportData{{
ReportAccount: report.Accounts[0],
Assets: types.XiaodianAssets{Success: true, Coupons: 1200, Fragments: 5},
Records: []types.XiaodianExchangeRecord{{
CommodityID: "goods-1", CommodityName: "测试套装", Quantity: 1, Price: 7100,
BuyTime: "2026-08-09 15:00:10", PayTime: "2026-08-09 15:00:12", Status: "3", RoleName: "测试角色",
}},
}}
return report, data
}
func TestWriteXiaodianReportXLSX(t *testing.T) {
report, data := testXiaodianReportData()
path := filepath.Join(t.TempDir(), "report.xlsx")
if err := writeXiaodianReportXLSX(path, report, data); err != nil {
t.Fatalf("writeXiaodianReportXLSX() error = %v", err)
}
file, err := excelize.OpenFile(path)
if err != nil {
t.Fatalf("OpenFile() error = %v", err)
}
defer file.Close()
sheets := strings.Join(file.GetSheetList(), ",")
if sheets != "账号汇总,兑换明细" {
t.Fatalf("sheet list = %q", sheets)
}
value, err := file.GetCellValue("账号汇总", "B2")
if err != nil || value != "用户10001" {
t.Fatalf("summary account = %q, err=%v", value, err)
}
value, err = file.GetCellValue("兑换明细", "D2")
if err != nil || value != "测试套装" {
t.Fatalf("detail product = %q, err=%v", value, err)
}
}
func TestWriteXiaodianReportCSVZip(t *testing.T) {
report, data := testXiaodianReportData()
path := filepath.Join(t.TempDir(), "report.zip")
if err := writeXiaodianReportCSVZip(path, report, data); err != nil {
t.Fatalf("writeXiaodianReportCSVZip() error = %v", err)
}
archive, err := zip.OpenReader(path)
if err != nil {
t.Fatalf("OpenReader() error = %v", err)
}
defer archive.Close()
if len(archive.File) != 2 || archive.File[0].Name != "账号汇总.csv" || archive.File[1].Name != "兑换明细.csv" {
t.Fatalf("zip entries = %#v", archive.File)
}
entry, err := archive.File[0].Open()
if err != nil {
t.Fatalf("open CSV entry error = %v", err)
}
defer entry.Close()
content, err := io.ReadAll(entry)
if err != nil || !strings.HasPrefix(string(content), "\uFEFF序号,斗鱼账号") {
t.Fatalf("summary CSV content invalid: %q, err=%v", content, err)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package constants
import "time"
// Version 应用版本号,与 wails.json version 同步。
const Version = "0.1.6"
const Version = "0.1.7"
// BuildTime 构建时间,由编译时 ldflags 注入。
var BuildTime = "unknown"
+24 -6
View File
@@ -39,6 +39,7 @@ type roundOutcome struct {
stoppedAccounts map[string]string
stoppedProducts map[string]string
cooldownAccounts map[string]time.Time
results map[string]types.ExchangeResult
attempts int
}
@@ -72,6 +73,9 @@ type Scheduler struct {
resultsMu sync.RWMutex
results map[string]string
reportMu sync.RWMutex
latestXiaodianReport *types.XiaodianRunReport
runMu sync.Mutex
running bool
stopRun chan struct{}
@@ -125,7 +129,7 @@ func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font, con
if attempts <= 0 {
attempts = constants.DefaultAttempts
}
return &Scheduler{
scheduler := &Scheduler{
client: cli,
paths: paths,
font: font,
@@ -145,6 +149,8 @@ func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font, con
products: make(map[string]types.Product),
calibrationOffsets: make([]time.Duration, 0, calibrationHistorySize),
}
scheduler.loadLatestXiaodianReport()
return scheduler
}
// Close 关闭调度器的后台资源。
@@ -617,17 +623,25 @@ func (s *Scheduler) RunXiaodian(accountProducts map[string]string, attempts, del
settings := s.settingsSnapshot()
attempts, delay := normalizeXiaodianRun(attempts, delayMs, settings.attempts)
accounts := make([]types.Account, 0, len(accountProducts))
products := make(map[string]types.Product, len(accountProducts))
for name := range accountProducts {
cookie, ok := s.AccountCookieByName(name)
if !ok {
continue
}
productID := accountProducts[name]
product := s.productForCommodityID(productID)
if product.CommodityID == "" {
product.CommodityID = productID
}
accounts = append(accounts, types.Account{Name: name, Cookie: cookie})
products[name] = product
}
if len(accounts) == 0 {
logger.LogEvent("抢购", "warn", "和平小店抢购没有可执行账号")
return
}
s.startXiaodianReport(accounts, products)
stoppedAccounts := make(map[string]string)
cooldownAccounts := make(map[string]time.Time)
@@ -652,11 +666,7 @@ func (s *Scheduler) RunXiaodian(accountProducts map[string]string, attempts, del
if stoppedAccounts[account.Name] != "" {
continue
}
productID := accountProducts[account.Name]
product := s.productForCommodityID(productID)
if product.CommodityID == "" {
product.CommodityID = productID
}
product := products[account.Name]
roundAttempts = append(roundAttempts, types.ExchangeAttempt{
Account: account,
Product: product,
@@ -681,6 +691,7 @@ func (s *Scheduler) RunXiaodian(accountProducts map[string]string, attempts, del
}
outcome := s.executeXiaodianRound(roundAttempts, settings)
s.updateXiaodianReport(outcome.results)
for name := range outcome.successAccounts {
stoppedAccounts[name] = "已抢到"
}
@@ -690,6 +701,7 @@ func (s *Scheduler) RunXiaodian(accountProducts map[string]string, attempts, del
logger.LogAccountEvent("抢购", "warn", name, "账号停止重试: 账号=%s 原因=%s", name, reason)
}
}
s.stopXiaodianReportAccounts(outcome.stoppedAccounts)
for name, until := range outcome.cooldownAccounts {
if stoppedAccounts[name] != "" || !until.After(cooldownAccounts[name]) {
continue
@@ -715,6 +727,7 @@ func (s *Scheduler) RunXiaodian(accountProducts map[string]string, attempts, del
logger.LogEvent("抢购", "info", "和平小店抢购结束: 成功或停止=%d/%d,耗时=%v",
len(stoppedAccounts), len(accounts), utils.RoundDuration(time.Since(start)))
s.finishXiaodianReport(s.runStopped())
}
func normalizeXiaodianRun(attempts, delayMs, fallbackAttempts int) (int, time.Duration) {
@@ -824,6 +837,7 @@ func (s *Scheduler) executeRoundWithRunner(
stoppedAccounts: make(map[string]string),
stoppedProducts: make(map[string]string),
cooldownAccounts: make(map[string]time.Time),
results: make(map[string]types.ExchangeResult),
}
var mu sync.Mutex
@@ -867,6 +881,7 @@ func (s *Scheduler) executeRoundWithRunner(
mu.Lock()
outcome.attempts++
outcome.results[attempt.Account.Name] = result
if result.OK {
outcome.successAccounts[attempt.Account.Name] = true
} else {
@@ -960,6 +975,9 @@ 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, "Livelink登录态校验失败"),
strings.Contains(message, "Livelink") && strings.Contains(message, "登录态校验失败"):
return true, false, "和平小店绑定已失效,需重新绑定"
case strings.Contains(message, "余额不足"),
strings.Contains(message, "点券不足"):
return true, false, "点券余额不足"
+1
View File
@@ -128,6 +128,7 @@ func TestClassifyXiaodianRetryStop(t *testing.T) {
product bool
reason string
}{
{"对不起,Livelink登录态校验失败!", true, false, "和平小店绑定已失效,需重新绑定"},
{"不好意思,您的余额不足~", true, false, "点券余额不足"},
{"点券不足,请充值后重试", true, false, "点券余额不足"},
{"未绑定账号", true, false, "账号未绑定角色"},
+174
View File
@@ -0,0 +1,174 @@
package seckill
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"douyu_go/internal/logger"
"douyu_go/internal/types"
)
const xiaodianReportFileName = "last_xiaodian_report.json"
func (s *Scheduler) startXiaodianReport(accounts []types.Account, products map[string]types.Product) {
report := types.XiaodianRunReport{
ReportID: fmt.Sprintf("xiaodian_%s", time.Now().Format("20060102_150405.000")),
StartedAt: time.Now(),
Accounts: make([]types.XiaodianRunReportAccount, 0, len(accounts)),
}
for _, account := range accounts {
product := products[account.Name]
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
})
s.reportMu.Lock()
s.latestXiaodianReport = &report
s.reportMu.Unlock()
}
func (s *Scheduler) updateXiaodianReport(results map[string]types.ExchangeResult) {
if len(results) == 0 {
return
}
s.reportMu.Lock()
defer s.reportMu.Unlock()
if s.latestXiaodianReport == nil {
return
}
for accountName, result := range results {
for i := range s.latestXiaodianReport.Accounts {
account := &s.latestXiaodianReport.Accounts[i]
if account.AccountName != accountName {
continue
}
account.Attempts++
account.LastMessage = result.Message
account.RoleName = result.RoleName
if result.OK {
account.Status = "成功"
account.FailureReason = ""
} else {
account.Status = "失败"
account.FailureReason = reportFailureReason(result.Message)
}
break
}
}
}
func (s *Scheduler) stopXiaodianReportAccounts(stopped map[string]string) {
if len(stopped) == 0 {
return
}
s.reportMu.Lock()
defer s.reportMu.Unlock()
if s.latestXiaodianReport == nil {
return
}
for i := range s.latestXiaodianReport.Accounts {
account := &s.latestXiaodianReport.Accounts[i]
if reason := stopped[account.AccountName]; reason != "" && account.Status != "成功" {
account.Status = "失败"
account.FailureReason = reason
}
}
}
func (s *Scheduler) finishXiaodianReport(stopped bool) {
s.reportMu.Lock()
if s.latestXiaodianReport == nil {
s.reportMu.Unlock()
return
}
report := s.latestXiaodianReport
report.FinishedAt = time.Now()
report.Stopped = stopped
if stopped {
for i := range report.Accounts {
account := &report.Accounts[i]
if account.Status == "待执行" {
account.Status = "已停止"
account.FailureReason = "任务已停止"
}
}
}
snapshot := cloneXiaodianReport(*report)
s.reportMu.Unlock()
if err := s.saveXiaodianReport(snapshot); err != nil {
logger.LogEvent("导出", "warn", "保存和平小店任务报告失败: %v", err)
}
}
// LatestXiaodianReport 返回最近一次和平小店任务报告的副本。
func (s *Scheduler) LatestXiaodianReport() (types.XiaodianRunReport, bool) {
s.reportMu.RLock()
defer s.reportMu.RUnlock()
if s.latestXiaodianReport == nil {
return types.XiaodianRunReport{}, false
}
return cloneXiaodianReport(*s.latestXiaodianReport), true
}
func (s *Scheduler) loadLatestXiaodianReport() {
data, err := os.ReadFile(s.xiaodianReportPath())
if err != nil {
return
}
var report types.XiaodianRunReport
if err := json.Unmarshal(data, &report); err != nil || report.ReportID == "" || len(report.Accounts) == 0 {
return
}
s.reportMu.Lock()
s.latestXiaodianReport = &report
s.reportMu.Unlock()
}
func (s *Scheduler) saveXiaodianReport(report types.XiaodianRunReport) error {
path := s.xiaodianReportPath()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
data, err := json.MarshalIndent(report, "", " ")
if err != nil {
return err
}
tempPath := path + ".tmp"
if err := os.WriteFile(tempPath, data, 0600); err != nil {
return err
}
return os.Rename(tempPath, path)
}
func (s *Scheduler) xiaodianReportPath() string {
return filepath.Join(s.paths.DataDir, "reports", xiaodianReportFileName)
}
func cloneXiaodianReport(report types.XiaodianRunReport) types.XiaodianRunReport {
report.Accounts = append([]types.XiaodianRunReportAccount(nil), report.Accounts...)
return report
}
func reportFailureReason(message string) string {
if accountStopped, _, reason := classifyXiaodianRetryStop(message); accountStopped {
return reason
}
reason := strings.TrimSpace(strings.TrimPrefix(message, "失败|"))
return strings.TrimSpace(strings.TrimPrefix(reason, "和平小店兑换失败:"))
}
+10
View File
@@ -0,0 +1,10 @@
package seckill
import "testing"
func TestReportFailureReasonUsesXiaodianStopReason(t *testing.T) {
message := "失败|和平小店兑换失败: 对不起,Livelink登录态校验失败!"
if got := reportFailureReason(message); got != "和平小店绑定已失效,需重新绑定" {
t.Fatalf("reportFailureReason() = %q", got)
}
}
+37
View File
@@ -0,0 +1,37 @@
package types
import "time"
// XiaodianRunReport 保存一次和平小店抢购的可导出快照,不包含 Cookie 等敏感凭据。
type XiaodianRunReport struct {
ReportID string `json:"reportId"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
Stopped bool `json:"stopped"`
Accounts []XiaodianRunReportAccount `json:"accounts"`
}
// XiaodianRunReportAccount 是单个账号在一次小店任务中的最终状态。
type XiaodianRunReportAccount struct {
AccountName string `json:"accountName"`
ProductID string `json:"productId"`
ProductName string `json:"productName"`
ProductPrice int `json:"productPrice"`
PayType int `json:"payType"`
PayLabel string `json:"payLabel"`
Attempts int `json:"attempts"`
Status string `json:"status"`
FailureReason string `json:"failureReason,omitempty"`
LastMessage string `json:"lastMessage,omitempty"`
RoleName string `json:"roleName,omitempty"`
}
// XiaodianReportExportResult 是最近一次小店任务报告的导出结果。
type XiaodianReportExportResult struct {
Success bool `json:"success"`
Cancelled bool `json:"cancelled"`
FilePath string `json:"filePath,omitempty"`
AccountCount int `json:"accountCount"`
RecordCount int `json:"recordCount"`
Error string `json:"error,omitempty"`
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "douyu_go",
"outputfilename": "douyu_go",
"version": "0.1.6",
"version": "0.1.7",
"frontend:dir": "frontend",
"frontend:install": "npm install",
"frontend:build": "npm run build",