Files
dygou_go/internal/app/app_xiaodian_report_export.go
T

514 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}