Files
yml2213 ac6ba0d98c 抢购控制台支持查看精英手册兑换记录,发布0.3.7
此前只有和平小店能在控制台查兑换记录,精英/电竞手册只能去别的页面或客户端看。
现在账号作战表直接带上"兑换记录"列:查询一次就能看到每个账号兑换了什么、什么时候、
花多少积分,点"记录"看该账号全部明细。

- 新增 QueryExchangeRecords 批量查询(手册类活动):复用既有的并发 lookupExchangeRecords,
  记录不做任何过滤("看我的兑换记录"不该藏东西),记录自带商品名与积分
- 商品筛选为可选项:不选=看全部记录;选了则额外标记该账号是否兑换过它(已兑换/未兑换)
- 手册的商品筛选用本地状态,不写回全局商品选择——免得只是想看记录却把抢购目标改掉;
  和平小店沿用原来的全局商品选择
- 账号表"兑换记录"列对所有活动可见;手册侧展示条数与最近一条(商品名 + 时间),
  小店的"已兑换/未兑换 + 订单状态"保持原样
- 每行"记录"弹窗:若已批量查询过就直接复用结果不再打接口;否则手册走 VerifyExchange,
  小店走原有订单查询。弹窗表格按活动切换列(手册:商品/数量/兑换时间/积分/昵称)
- 聚合逻辑抽成纯函数 buildExchangeRecordsResult 并补测试
  (记录不过滤、命中按商品统计、空 Cookie 与查询失败分开计数、未选商品不做命中判断)
2026-10-03 20:13:07 +08:00

508 lines
16 KiB
Go

package app
import (
"fmt"
"strconv"
"strings"
"sync"
"time"
"douyu_go/internal/logger"
"douyu_go/internal/types"
)
const exchangeLookupConcurrency = 8
const xiaodianExchangeLookupConcurrency = 4
type exchangeLookupResult struct {
Account types.Account
Records []types.ExchangeRecord
Err error
EmptyCookie bool
}
// GetScoreBaselines 返回各账号最近一次已记录的积分余额。
//
// 抢购结束后的自动核对会把新余额写进基线,界面据此刷新积分列,
// 不必再为每个账号单独发一次查询请求。
func (a *App) GetScoreBaselines() types.ScoreBaselineResult {
return types.ScoreBaselineResult{Success: true, Scores: a.scheduler.ScoreBaselines()}
}
// QueryScore 查询单个账号的积分。
func (a *App) QueryScore(accountName string) types.ScoreResult {
if accountName == "" {
return types.ScoreResult{Score: "缺少账号名"}
}
cookie, ok := a.scheduler.AccountCookieByName(accountName)
if !ok || cookie == "" {
return types.ScoreResult{Score: fmt.Sprintf("账号不存在或 Cookie 为空: %s", accountName)}
}
score, err := a.getClient().Score(cookie)
if err != nil {
return types.ScoreResult{Score: "查询失败: " + err.Error()}
}
// 手动查到的余额同样作为最新基线:抢购结束后用它比对是否扣分。
a.scheduler.RecordScoreBaseline(accountName, score)
return types.ScoreResult{Score: strconv.Itoa(score)}
}
// QueryXiaodianAssets 查询勾选账号的和平小店点券和扭蛋碎片。
func (a *App) QueryXiaodianAssets(accountNames []string) types.XiaodianAssetsResult {
results := make(map[string]types.XiaodianAssets)
if !a.getClient().Activity.IsXiaodian {
return types.XiaodianAssetsResult{Results: results}
}
selected := make(map[string]struct{}, len(accountNames))
for _, name := range accountNames {
if name = strings.TrimSpace(name); name != "" {
selected[name] = struct{}{}
}
}
for _, account := range a.scheduler.AccountSnapshot() {
if _, ok := selected[account.Name]; !ok {
continue
}
if strings.TrimSpace(account.Cookie) == "" {
results[account.Name] = types.XiaodianAssets{Error: "Cookie 为空"}
continue
}
assets, err := a.getClient().XiaodianAssets(account.Cookie)
if err != nil {
results[account.Name] = types.XiaodianAssets{Error: "查询失败: " + err.Error()}
continue
}
results[account.Name] = assets
}
return types.XiaodianAssetsResult{Results: results}
}
// QueryXiaodianRoles 查询勾选账号当前绑定的和平小店角色。
func (a *App) QueryXiaodianRoles(accountNames []string) types.XiaodianRoleResult {
results := make(map[string]types.XiaodianRole)
if !a.getClient().Activity.IsXiaodian {
return types.XiaodianRoleResult{Results: results}
}
selected := make(map[string]struct{}, len(accountNames))
for _, name := range accountNames {
if name = strings.TrimSpace(name); name != "" {
selected[name] = struct{}{}
}
}
for _, account := range a.scheduler.AccountSnapshot() {
if _, ok := selected[account.Name]; !ok {
continue
}
if strings.TrimSpace(account.Cookie) == "" {
results[account.Name] = types.XiaodianRole{Error: "Cookie 为空"}
continue
}
role, err := a.getClient().XiaodianRole(account.Cookie)
if err != nil {
results[account.Name] = types.XiaodianRole{Error: "查询失败: " + err.Error()}
continue
}
results[account.Name] = role
}
return types.XiaodianRoleResult{Results: results}
}
// GetXiaodianBindQRCode 为指定账号生成和平小店角色绑定二维码。
func (a *App) GetXiaodianBindQRCode(accountName string) types.XiaodianBindQRCode {
accountName = strings.TrimSpace(accountName)
if !a.getClient().Activity.IsXiaodian {
return types.XiaodianBindQRCode{Error: "当前活动不是和平小店"}
}
cookie, ok := a.scheduler.AccountCookieByName(accountName)
if !ok || strings.TrimSpace(cookie) == "" {
return types.XiaodianBindQRCode{Error: "账号不存在或 Cookie 为空"}
}
urlValue, bindURL, err := a.getClient().XiaodianBindQRCode(cookie)
if err != nil {
return types.XiaodianBindQRCode{Error: "获取绑定二维码失败: " + err.Error()}
}
return types.XiaodianBindQRCode{
Success: true,
AccountName: accountName,
URL: urlValue,
GameRoleBindURL: bindURL,
}
}
// BindXiaodianRole 确认扫码后的绑定,并返回最新角色信息。
func (a *App) BindXiaodianRole(accountName string) types.XiaodianRole {
accountName = strings.TrimSpace(accountName)
if !a.getClient().Activity.IsXiaodian {
return types.XiaodianRole{Error: "当前活动不是和平小店"}
}
cookie, ok := a.scheduler.AccountCookieByName(accountName)
if !ok || strings.TrimSpace(cookie) == "" {
return types.XiaodianRole{Error: "账号不存在或 Cookie 为空"}
}
role, err := a.getClient().XiaodianBindRole(cookie)
if err != nil {
return types.XiaodianRole{Error: "绑定失败: " + err.Error()}
}
return role
}
// QueryExchangeRecords 批量查询手册类活动(精英手册/电竞手册)的兑换记录。
//
// 与和平小店的批量查询并列,供抢购控制台直接查看"每个账号兑换过什么",
// 省得再去别的页面或客户端查。手册记录自带商品名与积分,商品可以不选;
// 选了商品就额外标记该账号是否兑换过它。
func (a *App) QueryExchangeRecords(commodityID, commodityName string) types.ExchangeRecordsBatchResult {
result := types.ExchangeRecordsBatchResult{
ProductID: strings.TrimSpace(commodityID),
ProductName: strings.TrimSpace(commodityName),
Results: make(map[string]types.ExchangeAccountRecords),
}
if a.getClient().Activity.IsXiaodian {
result.Error = "和平小店请使用订单查询"
return result
}
accounts := a.scheduler.AccountSnapshot()
result.Success = true
if len(accounts) == 0 {
return result
}
return buildExchangeRecordsResult(a.lookupExchangeRecords("兑换记录", accounts), result.ProductID, result.ProductName)
}
// buildExchangeRecordsResult 把批量查询结果整理成界面用的结构(纯函数,便于测试)。
//
// 刻意不对记录做过滤:这是"看我的兑换记录",任何一条都不该被藏起来。
func buildExchangeRecordsResult(lookups []exchangeLookupResult, productID, productName string) types.ExchangeRecordsBatchResult {
result := types.ExchangeRecordsBatchResult{
Success: true,
ProductID: productID,
ProductName: productName,
Results: make(map[string]types.ExchangeAccountRecords, len(lookups)),
}
for _, lookup := range lookups {
item := types.ExchangeAccountRecords{}
switch {
case lookup.EmptyCookie:
item.Error = "Cookie 为空"
case lookup.Err != nil:
item.Error = lookup.Err.Error()
default:
item.Success = true
item.Records = lookup.Records
item.Matched = productID != "" && hasCommodityRecord(item.Records, productID, 0, 0)
}
if item.Error != "" {
result.Failed++
} else {
result.Total += len(item.Records)
if item.Matched {
result.Matched++
}
}
result.Results[lookup.Account.Name] = item
}
return result
}
// VerifyExchange 查询兑换记录,用于复验抢购结果。
func (a *App) VerifyExchange(accountName string) types.ExchangeRecordsResult {
if accountName == "" {
return types.ExchangeRecordsResult{Error: "缺少账号名"}
}
cookie, ok := a.scheduler.AccountCookieByName(accountName)
if !ok || cookie == "" {
return types.ExchangeRecordsResult{Error: fmt.Sprintf("账号不存在或 Cookie 为空: %s", accountName)}
}
records, err := a.getClient().ExchangeList(cookie)
if err != nil {
return types.ExchangeRecordsResult{Error: "查询失败: " + err.Error()}
}
matched := filterExchangeRecords(records)
return types.ExchangeRecordsResult{
Success: true,
Records: matched,
Count: len(matched),
}
}
// VerifyAllExchange 批量查询兑换记录,使用有限并发减少等待时间。
func (a *App) VerifyAllExchange() types.VerifyAllExchangeResult {
accounts := a.scheduler.AccountSnapshot()
results := make(map[string]types.AccountExchangeResult)
lookups := a.lookupExchangeRecords("复验", accounts)
for _, lookup := range lookups {
if lookup.EmptyCookie {
results[lookup.Account.Name] = types.AccountExchangeResult{Error: "Cookie 为空"}
continue
}
if lookup.Err != nil {
results[lookup.Account.Name] = types.AccountExchangeResult{Error: "查询失败: " + lookup.Err.Error()}
continue
}
matched := filterExchangeRecords(lookup.Records)
results[lookup.Account.Name] = types.AccountExchangeResult{
Success: true,
Records: matched,
Count: len(matched),
}
}
return types.VerifyAllExchangeResult{Results: results}
}
// QueryXiaodianExchangeRecords 查询所有账号的和平小店真实订单,并标记当前商品是否已兑换。
func (a *App) QueryXiaodianExchangeRecords(commodityID, commodityName string) types.XiaodianExchangeRecordsResult {
result := types.XiaodianExchangeRecordsResult{
ProductID: strings.TrimSpace(commodityID),
ProductName: strings.TrimSpace(commodityName),
Results: make(map[string]types.XiaodianExchangeAccountResult),
}
if !a.getClient().Activity.IsXiaodian {
result.Error = "当前活动不是和平小店"
return result
}
if result.ProductID == "" {
result.Error = "请先选择商品"
return result
}
accounts := a.scheduler.AccountSnapshot()
if len(accounts) == 0 {
result.Success = true
return result
}
start := time.Now()
logger.LogEvent("兑换记录", "info", "和平小店兑换记录批量查询开始: 账号=%d 并发=%d", len(accounts), xiaodianExchangeLookupConcurrency)
type lookup struct {
account types.Account
records []types.XiaodianExchangeRecord
err error
}
lookups := make([]lookup, len(accounts))
jobs := make(chan int)
workerCount := xiaodianExchangeLookupConcurrency
if len(accounts) < workerCount {
workerCount = len(accounts)
}
var wg sync.WaitGroup
for worker := 0; worker < workerCount; worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for index := range jobs {
account := accounts[index]
item := lookup{account: account}
if strings.TrimSpace(account.Cookie) == "" {
item.err = fmt.Errorf("Cookie 为空")
} else {
item.records, item.err = a.getClient().XiaodianExchangeList(account.Cookie)
}
lookups[index] = item
}
}()
}
for index := range accounts {
jobs <- index
}
close(jobs)
wg.Wait()
failed := 0
matched := 0
for _, lookup := range lookups {
if lookup.err != nil {
failed++
result.Results[lookup.account.Name] = types.XiaodianExchangeAccountResult{Error: lookup.err.Error()}
continue
}
isMatched := false
for _, record := range lookup.records {
if record.CommodityID == result.ProductID {
isMatched = true
break
}
}
if isMatched {
matched++
}
result.Results[lookup.account.Name] = types.XiaodianExchangeAccountResult{
Success: true,
Records: lookup.records,
Matched: isMatched,
}
}
result.Success = true
level := "success"
if failed > 0 {
level = "warn"
}
logger.LogEvent("兑换记录", level, "和平小店兑换记录批量查询完成: 账号=%d 命中=%d 失败=%d 耗时=%s",
len(accounts), matched, failed, time.Since(start).Round(time.Millisecond))
return result
}
// QueryXiaodianAccountExchangeRecords 查询一个账号的和平小店真实订单,供账号表按需展开使用。
func (a *App) QueryXiaodianAccountExchangeRecords(accountName string) types.XiaodianExchangeAccountResult {
accountName = strings.TrimSpace(accountName)
if !a.getClient().Activity.IsXiaodian {
return types.XiaodianExchangeAccountResult{Error: "当前活动不是和平小店"}
}
if accountName == "" {
return types.XiaodianExchangeAccountResult{Error: "缺少账号名"}
}
cookie, ok := a.scheduler.AccountCookieByName(accountName)
if !ok || strings.TrimSpace(cookie) == "" {
return types.XiaodianExchangeAccountResult{Error: "账号不存在或 Cookie 为空"}
}
records, err := a.getClient().XiaodianExchangeList(cookie)
if err != nil {
return types.XiaodianExchangeAccountResult{Error: "查询失败: " + err.Error()}
}
return types.XiaodianExchangeAccountResult{Success: true, Records: records}
}
// VerifyAndExport 复验并导出结果。
func (a *App) VerifyAndExport(commodityID string) types.VerifyAndExportResult {
accounts := a.scheduler.AccountSnapshot()
results := make(map[string]types.AccountHitResult)
hitAccounts := make([]string, 0)
missAccounts := make([]string, 0)
lookups := a.lookupExchangeRecords("复验", accounts)
for _, lookup := range lookups {
if lookup.EmptyCookie {
missAccounts = append(missAccounts, lookup.Account.Name)
results[lookup.Account.Name] = types.AccountHitResult{Error: "Cookie 为空"}
continue
}
if lookup.Err != nil {
missAccounts = append(missAccounts, lookup.Account.Name)
results[lookup.Account.Name] = types.AccountHitResult{Error: "查询失败: " + lookup.Err.Error()}
continue
}
// 检查是否抢到了指定商品
hit := hasCommodityRecord(lookup.Records, commodityID, 0, 0)
if hit {
hitAccounts = append(hitAccounts, lookup.Account.Name)
results[lookup.Account.Name] = types.AccountHitResult{Success: true, Hit: true}
} else {
missAccounts = append(missAccounts, lookup.Account.Name)
results[lookup.Account.Name] = types.AccountHitResult{Success: true}
}
}
return types.VerifyAndExportResult{
Success: true,
HitCount: len(hitAccounts),
MissCount: len(missAccounts),
HitAccounts: hitAccounts,
MissAccounts: missAccounts,
Details: results,
}
}
func (a *App) lookupExchangeRecords(category string, accounts []types.Account) []exchangeLookupResult {
results := make([]exchangeLookupResult, len(accounts))
if len(accounts) == 0 {
return results
}
workerCount := exchangeLookupConcurrency
if len(accounts) < workerCount {
workerCount = len(accounts)
}
start := time.Now()
logger.LogEvent(category, "info", "兑换记录批量查询开始: 账号=%d 并发=%d", len(accounts), workerCount)
jobs := make(chan int)
var wg sync.WaitGroup
for worker := 0; worker < workerCount; worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for idx := range jobs {
account := accounts[idx]
lookup := exchangeLookupResult{Account: account}
if strings.TrimSpace(account.Cookie) == "" {
lookup.EmptyCookie = true
results[idx] = lookup
continue
}
records, err := a.getClient().ExchangeList(account.Cookie)
lookup.Records = records
lookup.Err = err
results[idx] = lookup
}
}()
}
for idx := range accounts {
jobs <- idx
}
close(jobs)
wg.Wait()
errors := 0
empty := 0
for _, result := range results {
if result.EmptyCookie {
empty++
}
if result.Err != nil {
errors++
}
}
level := "success"
if errors > 0 {
level = "warn"
}
logger.LogEvent(category, level, "兑换记录批量查询完成: 账号=%d 空CK=%d 失败=%d 耗时=%s",
len(accounts), empty, errors, time.Since(start).Round(time.Millisecond))
return results
}
func filterExchangeRecords(records []types.ExchangeRecord) []types.ExchangeRecord {
matched := make([]types.ExchangeRecord, 0, len(records))
for _, r := range records {
if r.CommodityID != "" {
matched = append(matched, r)
}
}
return matched
}
func hasCommodityRecord(records []types.ExchangeRecord, commodityID string, startTime, endTime int64) bool {
if commodityID == "" {
return false
}
for _, r := range records {
if r.CommodityID != commodityID {
continue
}
if startTime == 0 && endTime == 0 {
return true
}
recordTime, err := strconv.ParseInt(r.Time, 10, 64)
if err != nil {
continue
}
if startTime > 0 && recordTime < startTime {
continue
}
if endTime > 0 && recordTime > endTime {
continue
}
return true
}
return false
}