diff --git a/.gitignore b/.gitignore index d4c47dd..5fdabcd 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ yml.txt *.app .DS_Store .workbuddy +.claude + diff --git a/app.go b/app.go index ef23fec..2b6a430 100644 --- a/app.go +++ b/app.go @@ -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} } // ==================== 内部辅助方法 ==================== diff --git a/internal/client/client.go b/internal/client/client.go index 77108c3..a6e5163 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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") diff --git a/internal/seckill/account_store.go b/internal/seckill/account_store.go new file mode 100644 index 0000000..86b6dae --- /dev/null +++ b/internal/seckill/account_store.go @@ -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 +} diff --git a/internal/seckill/scheduler.go b/internal/seckill/scheduler.go index c63cbbc..96ab4ff 100644 --- a/internal/seckill/scheduler.go +++ b/internal/seckill/scheduler.go @@ -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 } diff --git a/internal/types/responses.go b/internal/types/responses.go new file mode 100644 index 0000000..7395870 --- /dev/null +++ b/internal/types/responses.go @@ -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"` +} diff --git a/internal/types/types.go b/internal/types/types.go index 9c31d4b..ef708d9 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -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"` +}