重构: 账号管理抽离、强类型返回、消除反射、删除死字段

- 抽离账号管理为独立 AccountStore,Scheduler 只持有引用,
  职责单一化(scheduler.go 减少 ~110 行)
- Wails 绑定方法返回结构体替代 map[string]any(新增 responses.go),
  前端获得强类型,字段名保持兼容无需改前端
- ExchangeRecord 移至 types 包,消除 types↔client 循环依赖
- extractAPIStatus 用 apiResponder 接口替代 reflect 逐字段查找,
  types.APIResponse 与 ExchangeListResponse 实现 APIStatus 方法
- 删除 types.Account.Password 死字段(鉴权仅用 Cookie)
- 重新生成 Wails 绑定,前端 vue-tsc 类型检查通过
This commit is contained in:
yml2213
2026-07-29 12:47:01 +08:00
parent 3ae9cb4a42
commit 7c2c5a61a6
7 changed files with 450 additions and 287 deletions
+2
View File
@@ -31,3 +31,5 @@ yml.txt
*.app
.DS_Store
.workbuddy
.claude
+89 -107
View File
@@ -90,7 +90,7 @@ func cloneConfig(c types.Config) types.Config {
type exchangeLookupResult struct {
Account types.Account
Records []client.ExchangeRecord
Records []types.ExchangeRecord
Err error
EmptyCookie bool
}
@@ -191,32 +191,32 @@ func (a *App) GetAccounts() []types.AccountView {
}
// ReloadAccounts 重新加载账号文件。
func (a *App) ReloadAccounts() map[string]any {
func (a *App) ReloadAccounts() types.ReloadAccountsResult {
a.scheduler.LoadAccounts()
return map[string]any{"success": true, "count": a.scheduler.AccountCount()}
return types.ReloadAccountsResult{Success: true, Count: a.scheduler.AccountCount()}
}
// StartSeckill 立即启动抢购。
func (a *App) StartSeckill(accountProducts map[string]string) map[string]any {
func (a *App) StartSeckill(accountProducts map[string]string) types.MessageResult {
if len(accountProducts) == 0 {
return map[string]any{"success": false, "error": "请选择账号和商品"}
return types.MessageResult{Error: "请选择账号和商品"}
}
if !a.scheduler.TryStartRun() {
return map[string]any{"success": false, "error": "抢购任务正在运行,请勿重复提交"}
return types.MessageResult{Error: "抢购任务正在运行,请勿重复提交"}
}
go a.scheduler.RunNow(utils.CloneStringMap(accountProducts))
return map[string]any{"success": true, "msg": "抢购已启动"}
return types.MessageResult{Success: true, Msg: "抢购已启动"}
}
// SetSchedule 设置定时抢购。
func (a *App) SetSchedule(req types.SeckillRequest) (map[string]any, error) {
func (a *App) SetSchedule(req types.SeckillRequest) (types.ScheduleResult, error) {
return a.scheduler.ScheduleRun(req)
}
// CancelSchedule 取消未触发的定时抢购。
func (a *App) CancelSchedule() map[string]any {
func (a *App) CancelSchedule() types.CancelScheduleResult {
stopped := a.scheduler.CancelSchedule()
return map[string]any{"success": true, "stopped": stopped}
return types.CancelScheduleResult{Success: true, Stopped: stopped}
}
// GetResults 返回当前抢购结果。
@@ -225,118 +225,112 @@ func (a *App) GetResults() map[string]string {
}
// GetLogs 返回后端内存日志,支持增量获取。
func (a *App) GetLogs(since int64) map[string]any {
func (a *App) GetLogs(since int64) types.LogsResult {
entries := a.logStore.Since(since)
nextSince := since
if len(entries) > 0 {
nextSince = entries[len(entries)-1].ID
}
return map[string]any{
"success": true,
"logs": entries,
"since": nextSince,
"file": a.logStore.FilePath(),
return types.LogsResult{
Success: true,
Logs: entries,
Since: nextSince,
File: a.logStore.FilePath(),
}
}
// GetTime 返回服务端时间和本地时间。
func (a *App) GetTime() map[string]any {
func (a *App) GetTime() types.TimeResult {
serverTime, err := a.getClient().ServerTime()
source := "currentTime"
if err != nil {
source = "localFallback"
logger.LogEvent("时间", "warn", "时间接口降级: %v", err)
}
return map[string]any{
"serverTime": serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
"localTime": time.Now().In(constants.CST).Format("2006-01-02 15:04:05.000"),
"source": source,
return types.TimeResult{
ServerTime: serverTime.In(constants.CST).Format("2006-01-02 15:04:05.000"),
LocalTime: time.Now().In(constants.CST).Format("2006-01-02 15:04:05.000"),
Source: source,
}
}
// ClearResults 清空抢购结果。
func (a *App) ClearResults() map[string]any {
func (a *App) ClearResults() types.SimpleResult {
a.scheduler.ClearResults()
return map[string]any{"success": true}
return types.SimpleResult{Success: true}
}
// QueryScore 查询单个账号的积分。
func (a *App) QueryScore(accountName string) map[string]any {
func (a *App) QueryScore(accountName string) types.ScoreResult {
if accountName == "" {
return map[string]any{"score": "缺少账号名"}
return types.ScoreResult{Score: "缺少账号名"}
}
cookie, ok := a.scheduler.AccountCookieByName(accountName)
if !ok || cookie == "" {
return map[string]any{"score": fmt.Sprintf("账号不存在或 Cookie 为空: %s", accountName)}
return types.ScoreResult{Score: fmt.Sprintf("账号不存在或 Cookie 为空: %s", accountName)}
}
score, err := a.getClient().Score(cookie)
if err != nil {
return map[string]any{"score": "查询失败: " + err.Error()}
return types.ScoreResult{Score: "查询失败: " + err.Error()}
}
return map[string]any{"score": score}
return types.ScoreResult{Score: strconv.Itoa(score)}
}
// VerifyExchange 查询兑换记录,用于复验抢购结果。
func (a *App) VerifyExchange(accountName string) map[string]any {
func (a *App) VerifyExchange(accountName string) types.ExchangeRecordsResult {
if accountName == "" {
return map[string]any{"success": false, "error": "缺少账号名"}
return types.ExchangeRecordsResult{Error: "缺少账号名"}
}
cookie, ok := a.scheduler.AccountCookieByName(accountName)
if !ok || cookie == "" {
return map[string]any{"success": false, "error": fmt.Sprintf("账号不存在或 Cookie 为空: %s", accountName)}
return types.ExchangeRecordsResult{Error: fmt.Sprintf("账号不存在或 Cookie 为空: %s", accountName)}
}
records, err := a.getClient().ExchangeList(cookie)
if err != nil {
return map[string]any{"success": false, "error": "查询失败: " + err.Error()}
return types.ExchangeRecordsResult{Error: "查询失败: " + err.Error()}
}
matched := filterExchangeRecords(records)
return map[string]any{
"success": true,
"records": matched,
"count": len(matched),
return types.ExchangeRecordsResult{
Success: true,
Records: matched,
Count: len(matched),
}
}
// VerifyAllExchange 批量查询兑换记录,使用有限并发减少等待时间。
func (a *App) VerifyAllExchange() map[string]any {
func (a *App) VerifyAllExchange() types.VerifyAllExchangeResult {
accounts := a.scheduler.AccountSnapshot()
results := make(map[string]any)
results := make(map[string]types.AccountExchangeResult)
lookups := a.lookupExchangeRecords("复验", accounts)
for _, lookup := range lookups {
if lookup.EmptyCookie {
results[lookup.Account.Name] = map[string]any{
"success": false,
"error": "Cookie 为空",
}
results[lookup.Account.Name] = types.AccountExchangeResult{Error: "Cookie 为空"}
continue
}
if lookup.Err != nil {
results[lookup.Account.Name] = map[string]any{
"success": false,
"error": "查询失败: " + lookup.Err.Error(),
}
results[lookup.Account.Name] = types.AccountExchangeResult{Error: "查询失败: " + lookup.Err.Error()}
continue
}
matched := filterExchangeRecords(lookup.Records)
results[lookup.Account.Name] = map[string]any{
"success": true,
"records": matched,
"count": len(matched),
results[lookup.Account.Name] = types.AccountExchangeResult{
Success: true,
Records: matched,
Count: len(matched),
}
}
return results
return types.VerifyAllExchangeResult{Results: results}
}
// VerifyAndExport 复验并导出结果。
func (a *App) VerifyAndExport(commodityID string) map[string]any {
func (a *App) VerifyAndExport(commodityID string) types.VerifyAndExportResult {
accounts := a.scheduler.AccountSnapshot()
results := make(map[string]any)
results := make(map[string]types.AccountHitResult)
hitAccounts := make([]string, 0)
missAccounts := make([]string, 0)
lookups := a.lookupExchangeRecords("复验", accounts)
@@ -344,19 +338,13 @@ func (a *App) VerifyAndExport(commodityID string) map[string]any {
for _, lookup := range lookups {
if lookup.EmptyCookie {
missAccounts = append(missAccounts, lookup.Account.Name)
results[lookup.Account.Name] = map[string]any{
"success": false,
"error": "Cookie 为空",
}
results[lookup.Account.Name] = types.AccountHitResult{Error: "Cookie 为空"}
continue
}
if lookup.Err != nil {
missAccounts = append(missAccounts, lookup.Account.Name)
results[lookup.Account.Name] = map[string]any{
"success": false,
"error": "查询失败: " + lookup.Err.Error(),
}
results[lookup.Account.Name] = types.AccountHitResult{Error: "查询失败: " + lookup.Err.Error()}
continue
}
@@ -364,26 +352,20 @@ func (a *App) VerifyAndExport(commodityID string) map[string]any {
hit := hasCommodityRecord(lookup.Records, commodityID, 0, 0)
if hit {
hitAccounts = append(hitAccounts, lookup.Account.Name)
results[lookup.Account.Name] = map[string]any{
"success": true,
"hit": true,
}
results[lookup.Account.Name] = types.AccountHitResult{Success: true, Hit: true}
} else {
missAccounts = append(missAccounts, lookup.Account.Name)
results[lookup.Account.Name] = map[string]any{
"success": true,
"hit": false,
}
results[lookup.Account.Name] = types.AccountHitResult{Success: true}
}
}
return map[string]any{
"success": true,
"hitCount": len(hitAccounts),
"missCount": len(missAccounts),
"hitAccounts": hitAccounts,
"missAccounts": missAccounts,
"details": results,
return types.VerifyAndExportResult{
Success: true,
HitCount: len(hitAccounts),
MissCount: len(missAccounts),
HitAccounts: hitAccounts,
MissAccounts: missAccounts,
Details: results,
}
}
@@ -446,8 +428,8 @@ func (a *App) lookupExchangeRecords(category string, accounts []types.Account) [
return results
}
func filterExchangeRecords(records []client.ExchangeRecord) []client.ExchangeRecord {
matched := make([]client.ExchangeRecord, 0, len(records))
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)
@@ -456,7 +438,7 @@ func filterExchangeRecords(records []client.ExchangeRecord) []client.ExchangeRec
return matched
}
func hasCommodityRecord(records []client.ExchangeRecord, commodityID string, startTime, endTime int64) bool {
func hasCommodityRecord(records []types.ExchangeRecord, commodityID string, startTime, endTime int64) bool {
if commodityID == "" {
return false
}
@@ -514,15 +496,15 @@ func parseAccountFile(path string) ([]accountLine, error) {
}
// ExportAccounts 导出指定账号到文件。
func (a *App) ExportAccounts(accountNames []string, filename string) map[string]any {
func (a *App) ExportAccounts(accountNames []string, filename string) types.ExportAccountsResult {
if len(accountNames) == 0 {
return map[string]any{"success": false, "error": "没有要导出的账号"}
return types.ExportAccountsResult{Error: "没有要导出的账号"}
}
accountFile := a.scheduler.AccountFilePath()
lines, err := parseAccountFile(accountFile)
if err != nil {
return map[string]any{"success": false, "error": "读取账号文件失败: " + err.Error()}
return types.ExportAccountsResult{Error: "读取账号文件失败: " + err.Error()}
}
nameSet := make(map[string]bool)
@@ -541,7 +523,7 @@ func (a *App) ExportAccounts(accountNames []string, filename string) map[string]
}
if len(matchedLines) == 0 {
return map[string]any{"success": false, "error": "没有找到匹配的账号行"}
return types.ExportAccountsResult{Error: "没有找到匹配的账号行"}
}
// 生成导出文件路径
@@ -549,20 +531,20 @@ func (a *App) ExportAccounts(accountNames []string, filename string) map[string]
content := strings.Join(matchedLines, "\n") + "\n"
if err := os.WriteFile(exportPath, []byte(content), 0644); err != nil {
return map[string]any{"success": false, "error": "写入文件失败: " + err.Error()}
return types.ExportAccountsResult{Error: "写入文件失败: " + err.Error()}
}
logger.LogEvent("导出", "success", "已导出 %d 个账号到: %s", len(matchedLines), exportPath)
return map[string]any{
"success": true,
"count": len(matchedLines),
"filePath": exportPath,
return types.ExportAccountsResult{
Success: true,
Count: len(matchedLines),
FilePath: exportPath,
}
}
// ExportAccountsWithProduct 导出指定账号到文件,自动使用中文商品名和时间戳命名。
func (a *App) ExportAccountsWithProduct(accountNames []string, commodityID, commodityName string) map[string]any {
func (a *App) ExportAccountsWithProduct(accountNames []string, commodityID, commodityName string) types.ExportAccountsResult {
// 生成文件名: user_已抢到_商品中文名_20260626_150405.txt
now := time.Now()
timestamp := now.Format("20060102_150405")
@@ -574,7 +556,7 @@ func (a *App) ExportAccountsWithProduct(accountNames []string, commodityID, comm
}
// OrganizeAccounts 整理账号,把已抢到指定商品的账号分离出来。
func (a *App) OrganizeAccounts(commodityID, commodityName string, startTime, endTime int64) map[string]any {
func (a *App) OrganizeAccounts(commodityID, commodityName string, startTime, endTime int64) types.OrganizeAccountsResult {
accounts := a.scheduler.AccountSnapshot()
hitAccounts := make([]string, 0)
missAccounts := make([]string, 0)
@@ -594,7 +576,7 @@ func (a *App) OrganizeAccounts(commodityID, commodityName string, startTime, end
}
// 导出已抢到的账号
var exportResult map[string]any
var exportResult types.ExportAccountsResult
if len(hitAccounts) > 0 {
exportResult = a.ExportAccountsWithProduct(hitAccounts, commodityID, commodityName)
}
@@ -603,7 +585,7 @@ func (a *App) OrganizeAccounts(commodityID, commodityName string, startTime, end
accountFile := a.scheduler.AccountFilePath()
lines, err := parseAccountFile(accountFile)
if err != nil {
return map[string]any{"success": false, "error": "读取账号文件失败: " + err.Error()}
return types.OrganizeAccountsResult{Error: "读取账号文件失败: " + err.Error()}
}
missSet := make(map[string]bool)
@@ -625,7 +607,7 @@ func (a *App) OrganizeAccounts(commodityID, commodityName string, startTime, end
// 写回原文件
newContent := strings.Join(keepLines, "\n") + "\n"
if err := os.WriteFile(accountFile, []byte(newContent), 0644); err != nil {
return map[string]any{"success": false, "error": "更新账号文件失败: " + err.Error()}
return types.OrganizeAccountsResult{Error: "更新账号文件失败: " + err.Error()}
}
// 重新加载账号
@@ -633,13 +615,13 @@ func (a *App) OrganizeAccounts(commodityID, commodityName string, startTime, end
logger.LogEvent("整理", "success", "整理完成: 已抢到=%d, 未抢到=%d", len(hitAccounts), len(missAccounts))
return map[string]any{
"success": true,
"hitCount": len(hitAccounts),
"missCount": len(missAccounts),
"hitAccounts": hitAccounts,
"missAccounts": missAccounts,
"exportResult": exportResult,
return types.OrganizeAccountsResult{
Success: true,
HitCount: len(hitAccounts),
MissCount: len(missAccounts),
HitAccounts: hitAccounts,
MissAccounts: missAccounts,
ExportResult: exportResult,
}
}
@@ -669,10 +651,10 @@ func (a *App) GetCurrentActivity() types.ActivityInfo {
}
// SwitchActivity 切换当前活动并持久化到配置;切换后重建客户端与时间基准。
func (a *App) SwitchActivity(id string) map[string]any {
func (a *App) SwitchActivity(id string) types.SwitchActivityResult {
act := activity.Get(id)
if act == nil {
return map[string]any{"success": false, "error": "未知活动: " + id}
return types.SwitchActivityResult{Error: "未知活动: " + id}
}
cfg := a.getCfg()
cfg.Activity = act.ID
@@ -682,7 +664,7 @@ func (a *App) SwitchActivity(id string) map[string]any {
}
if err := config.SaveConfig(a.paths.ConfigFile, cfg); err != nil {
logger.LogEvent("活动", "error", "切换活动配置保存失败: %v", err)
return map[string]any{"success": false, "error": "配置保存失败: " + err.Error()}
return types.SwitchActivityResult{Error: "配置保存失败: " + err.Error()}
}
a.setCfg(cfg)
newClient := client.New(act, cfg.TimeAPIURL, a.trace)
@@ -694,7 +676,7 @@ func (a *App) SwitchActivity(id string) map[string]any {
logger.SetDouyuOffset(sample.Offset)
}
logger.LogEvent("活动", "success", "已切换到活动: %s", act.DisplayName)
return map[string]any{"success": true, "activity": act.ID, "displayName": act.DisplayName}
return types.SwitchActivityResult{Success: true, Activity: act.ID, DisplayName: act.DisplayName}
}
// GetSettings 返回当前配置。
@@ -703,7 +685,7 @@ func (a *App) GetSettings() types.Config {
}
// SaveSettings 保存配置并运行时生效。
func (a *App) SaveSettings(cfg types.Config) map[string]any {
func (a *App) SaveSettings(cfg types.Config) types.SimpleResult {
// 校验
if cfg.Concurrency <= 0 {
cfg.Concurrency = constants.DefaultConcurrency
@@ -718,7 +700,7 @@ func (a *App) SaveSettings(cfg types.Config) map[string]any {
// 写入文件
if err := config.SaveConfig(a.paths.ConfigFile, cfg); err != nil {
logger.LogEvent("配置", "error", "配置保存失败: %v", err)
return map[string]any{"success": false, "error": err.Error()}
return types.SimpleResult{}
}
// 运行时生效
@@ -728,7 +710,7 @@ func (a *App) SaveSettings(cfg types.Config) map[string]any {
logger.LogEvent("配置", "success", "配置已保存并生效: 日志等级=%s 并发=%d 重试=%d 重试阶段=%d 模式=%d trace=%v",
cfg.LogLevel, cfg.Concurrency, cfg.Attempts, len(cfg.RetryStages), cfg.SeckillMode, cfg.TraceEnabled)
return map[string]any{"success": true}
return types.SimpleResult{Success: true}
}
// ==================== 内部辅助方法 ====================
+18 -41
View File
@@ -7,7 +7,6 @@ import (
"io"
"mime/multipart"
"net/http"
"reflect"
"strconv"
"strings"
"time"
@@ -498,29 +497,23 @@ func (c *DouyuClient) RoleName(cookie string) (string, error) {
return result.Data.GameRole.RoleName, nil
}
// ExchangeRecord 兑换记录。
type ExchangeRecord struct {
CommodityID string `json:"commodityId"`
CommodityName string `json:"commodityName"`
NickName string `json:"nickName"`
ShowName string `json:"showName"`
ExchangeNum int `json:"exchangeNum"`
Time string `json:"time"`
Score string `json:"score"`
}
// ExchangeListResponse 兑换列表响应。
type ExchangeListResponse struct {
Error int `json:"error"`
Msg string `json:"msg"`
Data struct {
List []ExchangeRecord `json:"list"`
Count int `json:"count"`
List []types.ExchangeRecord `json:"list"`
Count int `json:"count"`
} `json:"data"`
}
// APIStatus 返回斗鱼 API 的 error/msg 状态,实现 apiResponder 接口。
func (r ExchangeListResponse) APIStatus() (int, string, bool) {
return r.Error, r.Msg, true
}
// ExchangeList 查询兑换记录列表。
func (c *DouyuClient) ExchangeList(cookie string) ([]ExchangeRecord, error) {
func (c *DouyuClient) ExchangeList(cookie string) ([]types.ExchangeRecord, error) {
var result ExchangeListResponse
if err := c.getJSON(c.Activity.ExchangeListURL, cookie, "兑换记录", &result); err != nil {
return nil, err
@@ -675,36 +668,20 @@ func extractAPIStatus(out any) (int, string, bool) {
if out == nil {
return 0, "", false
}
v := reflect.ValueOf(out)
if v.Kind() == reflect.Pointer {
if v.IsNil() {
return 0, "", false
}
v = v.Elem()
}
if !v.IsValid() {
return 0, "", false
}
if v.Type() == reflect.TypeOf(types.APIResponse{}) {
api := v.Interface().(types.APIResponse)
return api.Error, api.Msg, true
}
if v.Kind() != reflect.Struct {
return 0, "", false
}
if field := v.FieldByName("APIResponse"); field.IsValid() && field.CanInterface() {
if api, ok := field.Interface().(types.APIResponse); ok {
return api.Error, api.Msg, true
}
}
errorField := v.FieldByName("Error")
msgField := v.FieldByName("Msg")
if errorField.IsValid() && msgField.IsValid() && errorField.CanInt() && msgField.Kind() == reflect.String {
return int(errorField.Int()), msgField.String(), true
if r, ok := out.(apiResponder); ok {
return r.APIStatus()
}
return 0, "", false
}
// apiResponder 返回斗鱼 API 的 error/msg 状态。
// types.APIResponse 通过其方法满足该接口;
// 未嵌入 APIResponse 但自带 Error/Msg 字段的结构体(如 ExchangeListResponse)
// 通过实现 APIStatus 方法满足该接口,避免使用反射逐字段查找。
type apiResponder interface {
APIStatus() (int, string, bool)
}
func (c *DouyuClient) setBrowserHeaders(req *http.Request, cookie string) {
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5")
+161
View File
@@ -0,0 +1,161 @@
package seckill
import (
"bufio"
"bytes"
"os"
"path/filepath"
"strings"
"sync"
"douyu_go/internal/constants"
"douyu_go/internal/logger"
"douyu_go/internal/types"
)
// AccountStore 账号存储,负责从账号文件加载、查询账号信息。
// 账号文件格式:每行 `账号名----密码----Cookie`(密码段保留以兼容历史文件,
// 但程序不读取也不使用密码,鉴权完全基于 Cookie)。
type AccountStore struct {
mu sync.RWMutex
accounts []types.Account
filePath string
}
// NewAccountStore 创建账号存储,filePath 为账号文件路径。
func NewAccountStore(filePath string) *AccountStore {
if filePath == "" {
filePath = constants.DefaultAccountFile
}
return &AccountStore{filePath: filePath}
}
// FilePath 返回账号文件路径。
func (a *AccountStore) FilePath() string {
return a.filePath
}
// Load 从账号文件加载账号。
func (a *AccountStore) Load() {
path := a.filePath
// 文件不存在时自动创建目录和空文件,方便用户直接粘贴 Cookie
if _, err := os.Stat(path); os.IsNotExist(err) {
if dir := filepath.Dir(path); dir != "" && dir != "." {
if mkErr := os.MkdirAll(dir, 0755); mkErr != nil {
logger.LogEvent("账号", "warn", "账号目录创建失败: %s: %v", dir, mkErr)
}
}
// 使用 Windows 换行格式 \r\n
hint := "# 格式: 账号名----密码----Cookie\r\n# 每行一个账号,粘贴后保存即可\r\n"
if wrErr := os.WriteFile(path, []byte(hint), 0644); wrErr != nil {
logger.LogEvent("账号", "warn", "账号文件创建失败: %s: %v", path, wrErr)
} else {
logger.LogEvent("账号", "info", "已创建空账号文件: %s,请粘贴 Cookie 后重新加载", path)
}
}
// 读取文件内容,自动转换换行格式为 Windows 格式
content, err := os.ReadFile(path)
if err != nil {
logger.LogEvent("账号", "error", "读取账号文件失败: %s: %v", path, err)
a.mu.Lock()
a.accounts = nil
a.mu.Unlock()
return
}
// 检测并转换换行格式
normalized, converted := normalizeLineEndings(content)
if converted {
// 保存转换后的内容
if err := os.WriteFile(path, normalized, 0644); err != nil {
logger.LogEvent("账号", "warn", "账号文件换行格式转换失败: %s: %v", path, err)
} else {
logger.LogEvent("账号", "info", "账号文件已自动转换为 Windows 换行格式: %s", path)
}
content = normalized
}
var list []types.Account
scanner := bufio.NewScanner(bytes.NewReader(content))
scanner.Buffer(make([]byte, 1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "----", 3)
if len(parts) != 3 {
logger.LogEvent("账号", "warn", "账号行格式错误,已跳过: %s", line)
continue
}
list = append(list, types.Account{
Name: strings.TrimSpace(parts[0]),
Cookie: strings.TrimSpace(parts[2]),
})
}
if err := scanner.Err(); err != nil {
logger.LogEvent("账号", "error", "读取账号文件中断: %v", err)
}
a.mu.Lock()
a.accounts = list
a.mu.Unlock()
logger.LogEvent("账号", "success", "账号加载完成: %d 个,来源=%s", len(list), path)
}
// Count 返回当前账号数。
func (a *AccountStore) Count() int {
a.mu.RLock()
defer a.mu.RUnlock()
return len(a.accounts)
}
// Snapshot 复制一份账号列表。
func (a *AccountStore) Snapshot() []types.Account {
a.mu.RLock()
defer a.mu.RUnlock()
list := make([]types.Account, len(a.accounts))
copy(list, a.accounts)
return list
}
// CookieByName 按账号名查找 Cookie。
func (a *AccountStore) CookieByName(name string) (string, bool) {
a.mu.RLock()
defer a.mu.RUnlock()
for _, account := range a.accounts {
if account.Name == name {
return account.Cookie, true
}
}
return "", false
}
// normalizeLineEndings 检测并转换换行格式为 Windows 格式(\r\n)。
// 返回转换后的内容和是否进行了转换。
func normalizeLineEndings(content []byte) ([]byte, bool) {
// 检查是否已经是 Windows 格式(包含 \r\n)
if bytes.Contains(content, []byte("\r\n")) {
// 已经是 Windows 格式,检查是否还有单独的 \n
// 先将所有 \r\n 替换为占位符,检查是否还有单独的 \n
temp := bytes.ReplaceAll(content, []byte("\r\n"), []byte("__RN_PLACEHOLDER__"))
if !bytes.Contains(temp, []byte("\n")) && !bytes.Contains(temp, []byte("\r")) {
// 完全是 Windows 格式,不需要转换
return content, false
}
// 混合格式,需要统一转换
}
// 转换逻辑:先统一为 \n,再转换为 \r\n
normalized := content
// 1. 将 \r\n 替换为 \n
normalized = bytes.ReplaceAll(normalized, []byte("\r\n"), []byte("\n"))
// 2. 将单独的 \r 替换为 \n
normalized = bytes.ReplaceAll(normalized, []byte("\r"), []byte("\n"))
// 3. 将 \n 替换为 \r\n
normalized = bytes.ReplaceAll(normalized, []byte("\n"), []byte("\r\n"))
return normalized, true
}
+23 -136
View File
@@ -1,13 +1,9 @@
package seckill
import (
"bufio"
"bytes"
"errors"
"fmt"
"math/rand"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
@@ -68,8 +64,7 @@ type Scheduler struct {
retryStages []types.RetryStage // 重试间隔阶段配置
seckillMode types.SeckillMode // 抢购模式
accountsMu sync.RWMutex
accounts []types.Account
accounts *AccountStore
resultsMu sync.RWMutex
results map[string]string
@@ -103,9 +98,10 @@ func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font, con
attempts = constants.DefaultAttempts
}
return &Scheduler{
client: cli,
paths: paths,
font: font,
client: cli,
paths: paths,
font: font,
accounts: NewAccountStore(paths.AccountFile),
successImages: image.NewSuccessImageSaver(
font,
paths.SuccessDir,
@@ -227,136 +223,27 @@ func normalizeSeckillMode(mode types.SeckillMode) types.SeckillMode {
// LoadAccounts 从账号文件加载账号。
func (s *Scheduler) LoadAccounts() {
path := s.paths.AccountFile
if path == "" {
path = constants.DefaultAccountFile
}
// 文件不存在时自动创建目录和空文件,方便用户直接粘贴 Cookie
if _, err := os.Stat(path); os.IsNotExist(err) {
if dir := filepath.Dir(path); dir != "" && dir != "." {
if mkErr := os.MkdirAll(dir, 0755); mkErr != nil {
logger.LogEvent("账号", "warn", "账号目录创建失败: %s: %v", dir, mkErr)
}
}
// 使用 Windows 换行格式 \r\n
hint := "# 格式: 账号名----密码----Cookie\r\n# 每行一个账号,粘贴后保存即可\r\n"
if wrErr := os.WriteFile(path, []byte(hint), 0644); wrErr != nil {
logger.LogEvent("账号", "warn", "账号文件创建失败: %s: %v", path, wrErr)
} else {
logger.LogEvent("账号", "info", "已创建空账号文件: %s,请粘贴 Cookie 后重新加载", path)
}
}
// 读取文件内容,自动转换换行格式为 Windows 格式
content, err := os.ReadFile(path)
if err != nil {
logger.LogEvent("账号", "error", "读取账号文件失败: %s: %v", path, err)
s.accountsMu.Lock()
s.accounts = nil
s.accountsMu.Unlock()
return
}
// 检测并转换换行格式
normalized, converted := s.normalizeLineEndings(path, content)
if converted {
// 保存转换后的内容
if err := os.WriteFile(path, normalized, 0644); err != nil {
logger.LogEvent("账号", "warn", "账号文件换行格式转换失败: %s: %v", path, err)
} else {
logger.LogEvent("账号", "info", "账号文件已自动转换为 Windows 换行格式: %s", path)
}
content = normalized
}
var list []types.Account
scanner := bufio.NewScanner(bytes.NewReader(content))
scanner.Buffer(make([]byte, 1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "----", 3)
if len(parts) != 3 {
logger.LogEvent("账号", "warn", "账号行格式错误,已跳过: %s", line)
continue
}
list = append(list, types.Account{
Name: strings.TrimSpace(parts[0]),
Password: strings.TrimSpace(parts[1]),
Cookie: strings.TrimSpace(parts[2]),
})
}
if err := scanner.Err(); err != nil {
logger.LogEvent("账号", "error", "读取账号文件中断: %v", err)
}
s.accountsMu.Lock()
s.accounts = list
s.accountsMu.Unlock()
logger.LogEvent("账号", "success", "账号加载完成: %d 个,来源=%s", len(list), path)
s.accounts.Load()
}
// AccountCount 返回当前账号数。
func (s *Scheduler) AccountCount() int {
s.accountsMu.RLock()
defer s.accountsMu.RUnlock()
return len(s.accounts)
}
// normalizeLineEndings 检测并转换换行格式为 Windows 格式(\r\n)。
// 返回转换后的内容和是否进行了转换。
func (s *Scheduler) normalizeLineEndings(path string, content []byte) ([]byte, bool) {
// 检查是否已经是 Windows 格式(包含 \r\n)
if bytes.Contains(content, []byte("\r\n")) {
// 已经是 Windows 格式,检查是否还有单独的 \n
// 先将所有 \r\n 替换为占位符,检查是否还有单独的 \n
temp := bytes.ReplaceAll(content, []byte("\r\n"), []byte("__RN_PLACEHOLDER__"))
if !bytes.Contains(temp, []byte("\n")) && !bytes.Contains(temp, []byte("\r")) {
// 完全是 Windows 格式,不需要转换
return content, false
}
// 混合格式,需要统一转换
}
// 转换逻辑:先统一为 \n,再转换为 \r\n
normalized := content
// 1. 将 \r\n 替换为 \n
normalized = bytes.ReplaceAll(normalized, []byte("\r\n"), []byte("\n"))
// 2. 将单独的 \r 替换为 \n
normalized = bytes.ReplaceAll(normalized, []byte("\r"), []byte("\n"))
// 3. 将 \n 替换为 \r\n
normalized = bytes.ReplaceAll(normalized, []byte("\n"), []byte("\r\n"))
return normalized, true
return s.accounts.Count()
}
// AccountFilePath 返回账号文件路径。
func (s *Scheduler) AccountFilePath() string {
return s.paths.AccountFile
return s.accounts.FilePath()
}
// AccountSnapshot 复制一份账号列表。
func (s *Scheduler) AccountSnapshot() []types.Account {
s.accountsMu.RLock()
defer s.accountsMu.RUnlock()
list := make([]types.Account, len(s.accounts))
copy(list, s.accounts)
return list
return s.accounts.Snapshot()
}
// AccountCookieByName 按账号名查找 Cookie。
func (s *Scheduler) AccountCookieByName(name string) (string, bool) {
s.accountsMu.RLock()
defer s.accountsMu.RUnlock()
for _, account := range s.accounts {
if account.Name == name {
return account.Cookie, true
}
}
return "", false
return s.accounts.CookieByName(name)
}
// Results 返回当前抢购结果快照。
@@ -1115,17 +1002,17 @@ func shouldFallbackStandard(errMsg string) bool {
}
// ScheduleRun 注册一次定时抢购。
func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (map[string]any, error) {
func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (types.ScheduleResult, error) {
if len(req.AccountProducts) == 0 {
return nil, errors.New("请选择账号和商品")
return types.ScheduleResult{}, errors.New("请选择账号和商品")
}
if strings.TrimSpace(req.TargetTime) == "" {
return nil, errors.New("请设置目标时间")
return types.ScheduleResult{}, errors.New("请设置目标时间")
}
targetLocal, err := time.ParseInLocation("2006-01-02 15:04:05", req.TargetTime, constants.CST)
if err != nil {
return nil, fmt.Errorf("时间格式错误: %w", err)
return types.ScheduleResult{}, fmt.Errorf("时间格式错误: %w", err)
}
targetTime := targetLocal.UTC()
@@ -1135,7 +1022,7 @@ func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (map[string]any, error
triggerTime := targetTime.Add(-internalAdvance)
remain := triggerTime.Sub(serverTime)
if remain <= 0 {
return nil, errors.New("目标时间已过")
return types.ScheduleResult{}, errors.New("目标时间已过")
}
ap := utils.CloneStringMap(req.AccountProducts)
@@ -1180,14 +1067,14 @@ func (s *Scheduler) ScheduleRun(req types.SeckillRequest) (map[string]any, error
remainSecond = 0
}
return map[string]any{
"success": true,
"targetTime": targetTime.In(constants.CST).Format("2006-01-02 15:04:05"),
"triggerTime": triggerTime.In(constants.CST).Format("2006-01-02 15:04:05"),
"remainSecond": remainSecond,
"offsetMs": calibration.Offset.Milliseconds(),
"errorBoundMs": calibration.ErrorBound.Milliseconds(),
"rttMs": calibration.RTT.Milliseconds(),
return types.ScheduleResult{
Success: true,
TargetTime: targetTime.In(constants.CST).Format("2006-01-02 15:04:05"),
TriggerTime: triggerTime.In(constants.CST).Format("2006-01-02 15:04:05"),
RemainSecond: remainSecond,
OffsetMs: calibration.Offset.Milliseconds(),
ErrorBoundMs: calibration.ErrorBound.Milliseconds(),
RttMs: calibration.RTT.Milliseconds(),
}, nil
}
+138
View File
@@ -0,0 +1,138 @@
package types
// 本文件定义 Wails 绑定方法的结构化返回类型,替代原先的 map[string]any。
// 字段名与历史 map key 保持一致,前端无需改动。
// 使用 omitempty 让 error 在成功时不出现在 JSON 中。
// SimpleResult 仅含 success 标志的结果。
type SimpleResult struct {
Success bool `json:"success"`
}
// ErrorResult 携带错误信息的结果。
type ErrorResult struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// MessageResult 携带成功消息的结果。
type MessageResult struct {
Success bool `json:"success"`
Msg string `json:"msg,omitempty"`
Error string `json:"error,omitempty"`
}
// CountResult 携带计数的通用结果。
type CountResult struct {
Success bool `json:"success"`
Count int `json:"count"`
Error string `json:"error,omitempty"`
}
// ReloadAccountsResult 重新加载账号结果。
type ReloadAccountsResult struct {
Success bool `json:"success"`
Count int `json:"count"`
}
// CancelScheduleResult 取消定时抢购结果。
type CancelScheduleResult struct {
Success bool `json:"success"`
Stopped bool `json:"stopped"`
}
// LogsResult 增量日志查询结果。
type LogsResult struct {
Success bool `json:"success"`
Logs []LogEntry `json:"logs"`
Since int64 `json:"since"`
File string `json:"file"`
}
// TimeResult 服务端/本地时间查询结果。
type TimeResult struct {
ServerTime string `json:"serverTime"`
LocalTime string `json:"localTime"`
Source string `json:"source"`
}
// ScoreResult 积分查询结果。score 为字符串:成功时为数字字符串,失败时为错误描述。
type ScoreResult struct {
Score string `json:"score"`
}
// ExchangeRecordsResult 兑换记录查询结果。
type ExchangeRecordsResult struct {
Success bool `json:"success"`
Records []ExchangeRecord `json:"records,omitempty"`
Count int `json:"count"`
Error string `json:"error,omitempty"`
}
// AccountExchangeResult 单账号兑换记录查询结果(VerifyAllExchange 的 value)。
type AccountExchangeResult struct {
Success bool `json:"success"`
Records []ExchangeRecord `json:"records,omitempty"`
Count int `json:"count"`
Error string `json:"error,omitempty"`
}
// AccountHitResult 单账号命中结果(VerifyAndExport 的 value)。
type AccountHitResult struct {
Success bool `json:"success"`
Hit bool `json:"hit,omitempty"`
Error string `json:"error,omitempty"`
}
// VerifyAndExportResult 复验并导出结果。
type VerifyAndExportResult struct {
Success bool `json:"success"`
HitCount int `json:"hitCount"`
MissCount int `json:"missCount"`
HitAccounts []string `json:"hitAccounts"`
MissAccounts []string `json:"missAccounts"`
Details map[string]AccountHitResult `json:"details"`
}
// VerifyAllExchangeResult 批量复验结果。
type VerifyAllExchangeResult struct {
Results map[string]AccountExchangeResult `json:"results"`
}
// ExportAccountsResult 导出账号结果。
type ExportAccountsResult struct {
Success bool `json:"success"`
Count int `json:"count"`
FilePath string `json:"filePath,omitempty"`
Error string `json:"error,omitempty"`
}
// OrganizeAccountsResult 整理账号结果。
type OrganizeAccountsResult struct {
Success bool `json:"success"`
HitCount int `json:"hitCount"`
MissCount int `json:"missCount"`
HitAccounts []string `json:"hitAccounts"`
MissAccounts []string `json:"missAccounts"`
ExportResult ExportAccountsResult `json:"exportResult,omitempty"`
Error string `json:"error,omitempty"`
}
// SwitchActivityResult 切换活动结果。
type SwitchActivityResult struct {
Success bool `json:"success"`
Activity string `json:"activity,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Error string `json:"error,omitempty"`
}
// ScheduleResult 定时抢购设置结果。
type ScheduleResult struct {
Success bool `json:"success"`
TargetTime string `json:"targetTime"`
TriggerTime string `json:"triggerTime"`
RemainSecond int `json:"remainSecond"`
OffsetMs int64 `json:"offsetMs"`
ErrorBoundMs int64 `json:"errorBoundMs"`
RttMs int64 `json:"rttMs"`
}
+19 -3
View File
@@ -54,10 +54,10 @@ type TraceConfig struct {
}
// Account 账号信息。
// 密码段保留在账号文件中以兼容历史格式,但程序不读取也不使用密码,鉴权完全基于 Cookie。
type Account struct {
Name string `json:"name"`
Password string `json:"password"`
Cookie string `json:"cookie"`
Name string `json:"name"`
Cookie string `json:"cookie"`
}
// Product 商品信息。
@@ -106,6 +106,11 @@ type APIResponse struct {
Data json.RawMessage `json:"data"`
}
// APIStatus 返回斗鱼 API 的 error/msg 状态,实现 client.apiResponder 接口。
func (a APIResponse) APIStatus() (int, string, bool) {
return a.Error, a.Msg, true
}
// LogEntry 日志条目。
type LogEntry struct {
ID int64 `json:"id"`
@@ -193,3 +198,14 @@ type AttemptCache struct {
Qualified bool
LastCheck time.Time
}
// ExchangeRecord 兑换记录。
type ExchangeRecord struct {
CommodityID string `json:"commodityId"`
CommodityName string `json:"commodityName"`
NickName string `json:"nickName"`
ShowName string `json:"showName"`
ExchangeNum int `json:"exchangeNum"`
Time string `json:"time"`
Score string `json:"score"`
}