514 lines
15 KiB
Go
514 lines
15 KiB
Go
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/constants"
|
||
"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, report)
|
||
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, report)),
|
||
exportText(selectedRecordStatus(item, 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, report types.XiaodianRunReport) *types.XiaodianExchangeRecord {
|
||
for index := range item.Records {
|
||
record := &item.Records[index]
|
||
if record.CommodityID == item.ReportAccount.ProductID && recordBelongsToReport(*record, report) {
|
||
return record
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func selectedRecordVerification(item xiaodianReportExportData, report types.XiaodianRunReport) string {
|
||
if item.RecordError != "" {
|
||
return "查询失败"
|
||
}
|
||
if selectedXiaodianRecord(item, report) != nil {
|
||
return "已兑换"
|
||
}
|
||
return "未发现订单"
|
||
}
|
||
|
||
func recordBelongsToReport(record types.XiaodianExchangeRecord, report types.XiaodianRunReport) bool {
|
||
if report.StartedAt.IsZero() {
|
||
return false
|
||
}
|
||
value := strings.TrimSpace(record.BuyTime)
|
||
if value == "" {
|
||
value = strings.TrimSpace(record.PayTime)
|
||
}
|
||
recordedAt, ok := parseXiaodianRecordTime(value)
|
||
return ok && !recordedAt.Before(report.StartedAt)
|
||
}
|
||
|
||
func parseXiaodianRecordTime(value string) (time.Time, bool) {
|
||
for _, layout := range []string{
|
||
"2006-01-02 15:04:05.999999999",
|
||
"2006-01-02 15:04:05",
|
||
time.RFC3339Nano,
|
||
time.RFC3339,
|
||
} {
|
||
if strings.Contains(layout, "Z07") {
|
||
if parsed, err := time.Parse(layout, value); err == nil {
|
||
return parsed, true
|
||
}
|
||
continue
|
||
}
|
||
if parsed, err := time.ParseInLocation(layout, value, constants.CST); err == nil {
|
||
return parsed, true
|
||
}
|
||
}
|
||
return time.Time{}, false
|
||
}
|
||
|
||
func selectedRecordStatus(item xiaodianReportExportData, record *types.XiaodianExchangeRecord) string {
|
||
if item.RecordError != "" {
|
||
return "查询失败"
|
||
}
|
||
if record == nil {
|
||
return "无订单"
|
||
}
|
||
return orderStatusText(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")
|
||
}
|