新增小店抢购报告导出并优化停止条件
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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, "点券余额不足"
|
||||
|
||||
@@ -128,6 +128,7 @@ func TestClassifyXiaodianRetryStop(t *testing.T) {
|
||||
product bool
|
||||
reason string
|
||||
}{
|
||||
{"对不起,Livelink登录态校验失败!", true, false, "和平小店绑定已失效,需重新绑定"},
|
||||
{"不好意思,您的余额不足~", true, false, "点券余额不足"},
|
||||
{"点券不足,请充值后重试", true, false, "点券余额不足"},
|
||||
{"未绑定账号", true, false, "账号未绑定角色"},
|
||||
|
||||
@@ -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, "和平小店兑换失败:"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user