diff --git a/frontend/src/components/account/AccountTable.vue b/frontend/src/components/account/AccountTable.vue index 9a082ca..557c1dc 100644 --- a/frontend/src/components/account/AccountTable.vue +++ b/frontend/src/components/account/AccountTable.vue @@ -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 } | null>(null) const deleteTargetAccount = ref(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 ? '查询中...' : '查询兑换记录' }} + + @@ -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; diff --git a/go.mod b/go.mod index 6e13607..3df081d 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index d06a1e6..9ecd788 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/app/app_xiaodian_report_export.go b/internal/app/app_xiaodian_report_export.go new file mode 100644 index 0000000..048cb79 --- /dev/null +++ b/internal/app/app_xiaodian_report_export.go @@ -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") +} diff --git a/internal/app/app_xiaodian_report_export_test.go b/internal/app/app_xiaodian_report_export_test.go new file mode 100644 index 0000000..6044bec --- /dev/null +++ b/internal/app/app_xiaodian_report_export_test.go @@ -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) + } +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go index ec1e5d0..68c1375 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.6" +const Version = "0.1.7" // BuildTime 构建时间,由编译时 ldflags 注入。 var BuildTime = "unknown" diff --git a/internal/seckill/scheduler.go b/internal/seckill/scheduler.go index 55f9ed3..2d648b5 100644 --- a/internal/seckill/scheduler.go +++ b/internal/seckill/scheduler.go @@ -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, "点券余额不足" diff --git a/internal/seckill/scheduler_test.go b/internal/seckill/scheduler_test.go index 6796ba7..9c645f3 100644 --- a/internal/seckill/scheduler_test.go +++ b/internal/seckill/scheduler_test.go @@ -128,6 +128,7 @@ func TestClassifyXiaodianRetryStop(t *testing.T) { product bool reason string }{ + {"对不起,Livelink登录态校验失败!", true, false, "和平小店绑定已失效,需重新绑定"}, {"不好意思,您的余额不足~", true, false, "点券余额不足"}, {"点券不足,请充值后重试", true, false, "点券余额不足"}, {"未绑定账号", true, false, "账号未绑定角色"}, diff --git a/internal/seckill/xiaodian_report.go b/internal/seckill/xiaodian_report.go new file mode 100644 index 0000000..880a3f7 --- /dev/null +++ b/internal/seckill/xiaodian_report.go @@ -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, "和平小店兑换失败:")) +} diff --git a/internal/seckill/xiaodian_report_test.go b/internal/seckill/xiaodian_report_test.go new file mode 100644 index 0000000..74da47b --- /dev/null +++ b/internal/seckill/xiaodian_report_test.go @@ -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) + } +} diff --git a/internal/types/xiaodian_report.go b/internal/types/xiaodian_report.go new file mode 100644 index 0000000..d9a1412 --- /dev/null +++ b/internal/types/xiaodian_report.go @@ -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"` +} diff --git a/wails.json b/wails.json index e5ca03d..1d4d162 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.6", + "version": "0.1.7", "frontend:dir": "frontend", "frontend:install": "npm install", "frontend:build": "npm run build",