升级版本并改用内部账号存储

This commit is contained in:
yml2213
2026-08-09 13:04:54 +08:00
parent 1cd94c0179
commit 8cf29598a5
13 changed files with 271 additions and 54 deletions
+4 -4
View File
@@ -21,7 +21,7 @@ const loading = ref(false)
const verifyResult = ref<any>(null)
const organizeResult = ref<any>(null)
// 账号文件管理状态
// 内部账号库管理状态
const accountContent = ref('')
const accountLoading = ref(false)
const selectedAccountNames = ref<string[]>([])
@@ -46,7 +46,7 @@ const importAccountText = async (content: string) => {
try {
const result = await ImportAccounts(content)
if (!result.success) {
showToast('导入失败', result.error || '账号文件未更新', 'error')
showToast('导入失败', result.error || '内部账号库未更新', 'error')
return
}
accountContent.value = ''
@@ -115,7 +115,7 @@ const saveEdit = async () => {
try {
const result = await UpdateAccount(editingAccount.value.name, editName.value, editCookie.value)
if (!result.success) {
showToast('保存失败', result.error || '账号文件未更新', 'error')
showToast('保存失败', result.error || '内部账号库未更新', 'error')
return
}
await refreshManagedAccounts()
@@ -139,7 +139,7 @@ const deleteManagedAccounts = async (names: string[]) => {
try {
const result = await DeleteAccounts(targetNames)
if (!result.success) {
showToast('删除失败', result.error || '账号文件未更新', 'error')
showToast('删除失败', result.error || '内部账号库未更新', 'error')
return
}
await refreshManagedAccounts()
+7 -1
View File
@@ -42,7 +42,7 @@ type App struct {
products []types.Product
productsAt time.Time
// accountsMu 串行化账号文件修改,避免并发导入、编辑或删除互相覆盖。
// accountsMu 串行化内部账号库修改,避免并发导入、编辑或删除互相覆盖。
accountsMu sync.Mutex
closeLog func()
@@ -123,8 +123,14 @@ func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
a.paths = config.DefaultAppPaths()
configMigrated, configMigrationErr := config.MigrateLegacyFile(a.paths.ConfigFile, a.paths.LegacyConfigFile)
a.logStore = logger.NewMemoryLogStore(800)
a.closeLog = logger.InitLogging(a.logStore, a.paths.LogDir)
if configMigrationErr != nil {
logger.LogEvent("配置", "warn", "旧配置迁移失败: %s: %v", a.paths.LegacyConfigFile, configMigrationErr)
} else if configMigrated {
logger.LogEvent("配置", "success", "已迁移旧配置到应用内部目录: %s", a.paths.ConfigFile)
}
// 加载配置并应用运行时设置
a.cfg = config.LoadConfig(a.paths.ConfigFile)
+7 -7
View File
@@ -8,7 +8,7 @@ import (
"douyu_go/internal/types"
)
// ImportAccounts 将剪贴板中的账号行追加到账号文件。每行格式必须为
// ImportAccounts 将剪贴板中的账号行追加到内部账号库。每行格式必须为
// 账号名----密码----Cookie,账号名不能与已有账号重复。
func (a *App) ImportAccounts(content string) types.AccountMutationResult {
entries, err := parseAccountImport(content)
@@ -22,7 +22,7 @@ func (a *App) ImportAccounts(content string) types.AccountMutationResult {
accountFile := a.scheduler.AccountFilePath()
lines, err := parseAccountFile(accountFile)
if err != nil {
return types.AccountMutationResult{Error: "读取账号文件失败: " + err.Error()}
return types.AccountMutationResult{Error: "读取内部账号库失败: " + err.Error()}
}
existingNames := make(map[string]struct{}, len(lines))
@@ -46,7 +46,7 @@ func (a *App) ImportAccounts(content string) types.AccountMutationResult {
return types.AccountMutationResult{Success: true, Count: len(entries)}
}
// UpdateAccount 更新账号名或 Cookie。newCookie 留空时保留文件中的原 Cookie。
// UpdateAccount 更新账号名或 Cookie。newCookie 留空时保留内部账号库中的原 Cookie。
func (a *App) UpdateAccount(originalName, newName, newCookie string) types.AccountMutationResult {
originalName = strings.TrimSpace(originalName)
newName = strings.TrimSpace(newName)
@@ -64,7 +64,7 @@ func (a *App) UpdateAccount(originalName, newName, newCookie string) types.Accou
accountFile := a.scheduler.AccountFilePath()
lines, err := parseAccountFile(accountFile)
if err != nil {
return types.AccountMutationResult{Error: "读取账号文件失败: " + err.Error()}
return types.AccountMutationResult{Error: "读取内部账号库失败: " + err.Error()}
}
targetIndex := -1
@@ -108,7 +108,7 @@ func (a *App) UpdateAccount(originalName, newName, newCookie string) types.Accou
return types.AccountMutationResult{Success: true, Count: 1}
}
// DeleteAccounts 从账号文件中删除指定账号,可一次删除多个账号。
// DeleteAccounts 从内部账号库中删除指定账号,可一次删除多个账号。
func (a *App) DeleteAccounts(accountNames []string) types.AccountMutationResult {
nameSet := make(map[string]struct{}, len(accountNames))
for _, name := range accountNames {
@@ -126,7 +126,7 @@ func (a *App) DeleteAccounts(accountNames []string) types.AccountMutationResult
accountFile := a.scheduler.AccountFilePath()
lines, err := parseAccountFile(accountFile)
if err != nil {
return types.AccountMutationResult{Error: "读取账号文件失败: " + err.Error()}
return types.AccountMutationResult{Error: "读取内部账号库失败: " + err.Error()}
}
kept := make([]accountLine, 0, len(lines))
deleted := 0
@@ -194,7 +194,7 @@ func (a *App) saveAccountLines(accountFile string, lines []accountLine) error {
content += "\r\n"
}
if _, err := writeFileWithBackup(accountFile, []byte(content)); err != nil {
return fmt.Errorf("更新账号文件失败: %w", err)
return fmt.Errorf("更新内部账号库失败: %w", err)
}
a.scheduler.LoadAccounts()
return nil
+8 -5
View File
@@ -53,7 +53,7 @@ func (a *App) ExportAccounts(accountNames []string, filename string) types.Expor
accountFile := a.scheduler.AccountFilePath()
lines, err := parseAccountFile(accountFile)
if err != nil {
return types.ExportAccountsResult{Error: "读取账号文件失败: " + err.Error()}
return types.ExportAccountsResult{Error: "读取内部账号库失败: " + err.Error()}
}
nameSet := make(map[string]bool)
@@ -79,7 +79,10 @@ func (a *App) ExportAccounts(accountNames []string, filename string) types.Expor
exportPath := filepath.Join(filepath.Dir(accountFile), filename)
content := strings.Join(matchedLines, "\n") + "\n"
if err := os.WriteFile(exportPath, []byte(content), 0644); err != nil {
if err := os.MkdirAll(filepath.Dir(exportPath), 0700); err != nil {
return types.ExportAccountsResult{Error: "创建导出目录失败: " + err.Error()}
}
if err := os.WriteFile(exportPath, []byte(content), 0600); err != nil {
return types.ExportAccountsResult{Error: "写入文件失败: " + err.Error()}
}
@@ -137,7 +140,7 @@ func (a *App) OrganizeAccounts(commodityID, commodityName string, startTime, end
accountFile := a.scheduler.AccountFilePath()
lines, err := parseAccountFile(accountFile)
if err != nil {
return types.OrganizeAccountsResult{Error: "读取账号文件失败: " + err.Error()}
return types.OrganizeAccountsResult{Error: "读取内部账号库失败: " + err.Error()}
}
missSet := make(map[string]bool)
@@ -160,9 +163,9 @@ func (a *App) OrganizeAccounts(commodityID, commodityName string, startTime, end
newContent := strings.Join(keepLines, "\r\n") + "\r\n"
backupPath, err := writeFileWithBackup(accountFile, []byte(newContent))
if err != nil {
return types.OrganizeAccountsResult{Error: "更新账号文件失败: " + err.Error()}
return types.OrganizeAccountsResult{Error: "更新内部账号库失败: " + err.Error()}
}
logger.LogEvent("整理", "success", "账号文件已安全更新,备份=%s", backupPath)
logger.LogEvent("整理", "success", "内部账号库已安全更新,备份=%s", backupPath)
// 重新加载账号
a.scheduler.LoadAccounts()
+1 -1
View File
@@ -55,7 +55,7 @@ func (a *App) GetAccounts() []types.AccountView {
return list
}
// ReloadAccounts 重新加载账号文件。
// ReloadAccounts 重新加载内部账号库。
func (a *App) ReloadAccounts() types.ReloadAccountsResult {
a.scheduler.LoadAccounts()
return types.ReloadAccountsResult{Success: true, Count: a.scheduler.AccountCount()}
+71 -8
View File
@@ -15,15 +15,43 @@ import (
)
// DefaultAppPaths 收集运行时路径,支持环境变量覆盖。
// 所有相对路径会基于当前工作目录解析为绝对路径,避免打包后工作目录不同导致路径错位。
// 默认数据存放在当前用户的应用配置目录,而非工作目录,避免 macOS 应用包目录无写权限。
func DefaultAppPaths() types.AppPaths {
return types.AppPaths{
ConfigFile: resolvePath(envOrDefault("DOUYU_CONFIG", constants.DefaultConfigFile)),
AccountFile: resolvePath(envOrDefault("DOUYU_USERS", constants.DefaultAccountFile)),
LogDir: resolvePath(envOrDefault("DOUYU_LOG_DIR", constants.DefaultLogDir)),
SuccessDir: resolvePath(envOrDefault("DOUYU_SUCCESS_DIR", constants.DefaultSuccessDir)),
FontFiles: imgpkg.CollectFontCandidates(),
dataDir := resolvePath(envOrDefault("DOUYU_DATA_DIR", defaultAppDataDir()))
configFile := filepath.Join(dataDir, constants.DefaultConfigFile)
accountFile := filepath.Join(dataDir, constants.DefaultAccountFile)
legacyConfigFile := resolvePath(constants.LegacyDefaultConfigFile)
legacyAccountFile := resolvePath(constants.LegacyDefaultAccountFile)
if value := os.Getenv("DOUYU_CONFIG"); value != "" {
configFile = resolvePath(value)
legacyConfigFile = ""
}
if value := os.Getenv("DOUYU_USERS"); value != "" {
accountFile = resolvePath(value)
legacyAccountFile = ""
}
return types.AppPaths{
DataDir: dataDir,
ConfigFile: configFile,
AccountFile: accountFile,
LegacyConfigFile: legacyConfigFile,
LegacyAccountFile: legacyAccountFile,
LogDir: resolvePath(envOrDefault("DOUYU_LOG_DIR", filepath.Join(dataDir, constants.DefaultLogDir))),
SuccessDir: resolvePath(envOrDefault("DOUYU_SUCCESS_DIR", filepath.Join(dataDir, constants.DefaultSuccessDir))),
FontFiles: imgpkg.CollectFontCandidates(),
}
}
func defaultAppDataDir() string {
if dir, err := os.UserConfigDir(); err == nil && dir != "" {
return filepath.Join(dir, constants.DefaultAppDataDirName)
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
return filepath.Join(home, "."+constants.DefaultAppDataDirName)
}
return "." + constants.DefaultAppDataDirName
}
// resolvePath 将相对路径解析为绝对路径。
@@ -44,6 +72,38 @@ func envOrDefault(key, fallback string) string {
return fallback
}
// MigrateLegacyFile 在目标文件不存在时复制旧版本数据,不删除旧文件。
// 返回值表示是否完成了迁移。
func MigrateLegacyFile(target, legacy string) (bool, error) {
if target == "" || legacy == "" || samePath(target, legacy) {
return false, nil
}
if _, err := os.Stat(target); err == nil {
return false, nil
} else if !errors.Is(err, os.ErrNotExist) {
return false, err
}
content, err := os.ReadFile(legacy)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, err
}
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
return false, err
}
if err := os.WriteFile(target, content, 0600); err != nil {
return false, err
}
return true, nil
}
func samePath(left, right string) bool {
return filepath.Clean(left) == filepath.Clean(right)
}
// LoadTraceConfig 读取 trace 开关;默认关闭,避免保存敏感调试数据。
func LoadTraceConfig() types.TraceConfig {
return types.TraceConfig{
@@ -160,5 +220,8 @@ func SaveConfig(path string, cfg types.Config) error {
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
+80
View File
@@ -0,0 +1,80 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestMigrateLegacyFile(t *testing.T) {
dir := t.TempDir()
legacy := filepath.Join(dir, "legacy", "config.json")
target := filepath.Join(dir, "internal", "config.json")
content := []byte(`{"activity":"xiaodian"}`)
if err := os.MkdirAll(filepath.Dir(legacy), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacy, content, 0600); err != nil {
t.Fatal(err)
}
migrated, err := MigrateLegacyFile(target, legacy)
if err != nil {
t.Fatalf("迁移失败: %v", err)
}
if !migrated {
t.Fatal("应迁移旧文件")
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(got) != string(content) {
t.Fatalf("迁移内容 = %q, want %q", got, content)
}
if _, err := os.Stat(legacy); err != nil {
t.Fatalf("旧文件不应被删除: %v", err)
}
if err := os.WriteFile(legacy, []byte(`{"activity":"other"}`), 0600); err != nil {
t.Fatal(err)
}
migrated, err = MigrateLegacyFile(target, legacy)
if err != nil {
t.Fatalf("重复迁移失败: %v", err)
}
if migrated {
t.Fatal("目标已存在时不应覆盖迁移")
}
got, err = os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(got) != string(content) {
t.Fatalf("目标文件被意外覆盖: %q", got)
}
}
func TestDefaultAppPathsUseInternalDataDir(t *testing.T) {
dir := t.TempDir()
t.Setenv("DOUYU_DATA_DIR", dir)
t.Setenv("DOUYU_CONFIG", "")
t.Setenv("DOUYU_USERS", "")
t.Setenv("DOUYU_LOG_DIR", "")
t.Setenv("DOUYU_SUCCESS_DIR", "")
paths := DefaultAppPaths()
if paths.DataDir != dir {
t.Fatalf("DataDir = %q, want %q", paths.DataDir, dir)
}
if paths.ConfigFile != filepath.Join(dir, "config.json") {
t.Fatalf("ConfigFile = %q", paths.ConfigFile)
}
if paths.AccountFile != filepath.Join(dir, "accounts.txt") {
t.Fatalf("AccountFile = %q", paths.AccountFile)
}
if paths.LogDir != filepath.Join(dir, "logs") || paths.SuccessDir != filepath.Join(dir, "success_images") {
t.Fatalf("应用数据子目录异常: logs=%q success=%q", paths.LogDir, paths.SuccessDir)
}
}
+10 -5
View File
@@ -3,7 +3,7 @@ package constants
import "time"
// Version 应用版本号,与 wails.json version 同步。
const Version = "0.1.5"
const Version = "0.1.6"
// BuildTime 构建时间,由编译时 ldflags 注入。
var BuildTime = "unknown"
@@ -15,10 +15,15 @@ const (
DefaultConcurrency = 150
ProductCacheTTL = 30 * time.Second
DefaultConfigFile = "config.json"
DefaultAccountFile = "data/user.txt"
DefaultLogDir = "logs"
DefaultSuccessDir = "success_images"
DefaultAppDataDirName = "douyu-go"
DefaultConfigFile = "config.json"
DefaultAccountFile = "accounts.txt"
DefaultLogDir = "logs"
DefaultSuccessDir = "success_images"
// LegacyDefault* 是旧版本相对工作目录存储的位置,仅用于首次迁移。
LegacyDefaultConfigFile = "config.json"
LegacyDefaultAccountFile = "data/user.txt"
// DefaultTimeAPI 服务端时间校准接口。
// 斗鱼服务器时间与活动无关,统一用精英手册的 storedetail(能稳定返回
+33 -14
View File
@@ -8,26 +8,32 @@ import (
"strings"
"sync"
"douyu_go/internal/config"
"douyu_go/internal/constants"
"douyu_go/internal/logger"
"douyu_go/internal/types"
)
// AccountStore 账号存储,负责从账号文件加载、查询账号信息。
// AccountStore 账号存储,负责从应用内部账号库加载、查询账号信息。
// 账号文件格式:每行 `账号名----密码----Cookie`(密码段保留以兼容历史文件,
// 但程序不读取也不使用密码,鉴权完全基于 Cookie)。
type AccountStore struct {
mu sync.RWMutex
accounts []types.Account
filePath string
mu sync.RWMutex
accounts []types.Account
filePath string
legacyFilePath string
}
// NewAccountStore 创建账号存储,filePath 为账号文件路径。
func NewAccountStore(filePath string) *AccountStore {
// NewAccountStore 创建账号存储。legacyFilePaths 仅在内部账号库首次创建时用于迁移旧文件。
func NewAccountStore(filePath string, legacyFilePaths ...string) *AccountStore {
if filePath == "" {
filePath = constants.DefaultAccountFile
}
return &AccountStore{filePath: filePath}
legacyFilePath := ""
if len(legacyFilePaths) > 0 {
legacyFilePath = legacyFilePaths[0]
}
return &AccountStore{filePath: filePath, legacyFilePath: legacyFilePath}
}
// FilePath 返回账号文件路径。
@@ -35,23 +41,33 @@ func (a *AccountStore) FilePath() string {
return a.filePath
}
// Load 从账号文件加载账号。
// Load 从应用内部账号库加载账号。
func (a *AccountStore) Load() {
path := a.filePath
// 文件不存在时自动创建目录和空文件,方便用户直接粘贴 Cookie
// 首次启动时迁移旧版本工作目录中的账号文件,保留原文件作为备份。
if _, err := os.Stat(path); os.IsNotExist(err) {
migrated, migrateErr := config.MigrateLegacyFile(path, a.legacyFilePath)
if migrateErr != nil {
logger.LogEvent("账号", "warn", "旧账号文件迁移失败: %s: %v", a.legacyFilePath, migrateErr)
} else if migrated {
logger.LogEvent("账号", "success", "已迁移旧账号文件到应用内部目录: %s", path)
}
}
// 应用内部账号库不存在时自动创建目录和空文件,方便用户直接在界面导入。
if _, err := os.Stat(path); os.IsNotExist(err) {
if dir := filepath.Dir(path); dir != "" && dir != "." {
if mkErr := os.MkdirAll(dir, 0755); mkErr != nil {
if mkErr := os.MkdirAll(dir, 0700); 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 {
if wrErr := os.WriteFile(path, []byte(hint), 0600); wrErr != nil {
logger.LogEvent("账号", "warn", "账号文件创建失败: %s: %v", path, wrErr)
} else {
logger.LogEvent("账号", "info", "已创建空账号文件: %s,请粘贴 Cookie 后重新加载", path)
logger.LogEvent("账号", "info", "已创建空账号库: %s,请在 CK 管理中导入账号", path)
}
}
@@ -69,13 +85,16 @@ func (a *AccountStore) Load() {
normalized, converted := normalizeLineEndings(content)
if converted {
// 保存转换后的内容
if err := os.WriteFile(path, normalized, 0644); err != nil {
if err := os.WriteFile(path, normalized, 0600); err != nil {
logger.LogEvent("账号", "warn", "账号文件换行格式转换失败: %s: %v", path, err)
} else {
logger.LogEvent("账号", "info", "账号文件已自动转换为 Windows 换行格式: %s", path)
}
content = normalized
}
if err := os.Chmod(path, 0600); err != nil {
logger.LogEvent("账号", "warn", "账号库权限收紧失败: %s: %v", path, err)
}
var list []types.Account
scanner := bufio.NewScanner(bytes.NewReader(content))
@@ -102,7 +121,7 @@ func (a *AccountStore) Load() {
a.mu.Lock()
a.accounts = list
a.mu.Unlock()
logger.LogEvent("账号", "success", "账号加载完成: %d 个,来源=%s", len(list), path)
logger.LogEvent("账号", "success", "账号加载完成: %d 个,内部存储=%s", len(list), path)
}
// Count 返回当前账号数。
+38
View File
@@ -0,0 +1,38 @@
package seckill
import (
"os"
"path/filepath"
"testing"
)
func TestAccountStoreMigratesLegacyFile(t *testing.T) {
dir := t.TempDir()
legacy := filepath.Join(dir, "data", "user.txt")
target := filepath.Join(dir, "app-data", "accounts.txt")
content := []byte("# 旧账号\r\nfoo----password----cookie-a\r\n")
if err := os.MkdirAll(filepath.Dir(legacy), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacy, content, 0600); err != nil {
t.Fatal(err)
}
store := NewAccountStore(target, legacy)
store.Load()
accounts := store.Snapshot()
if len(accounts) != 1 || accounts[0].Name != "foo" || accounts[0].Cookie != "cookie-a" {
t.Fatalf("迁移后的账号 = %#v", accounts)
}
if _, err := os.Stat(legacy); err != nil {
t.Fatalf("旧账号文件不应被删除: %v", err)
}
info, err := os.Stat(target)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("账号库权限 = %o, want 0600", info.Mode().Perm())
}
}
+3 -3
View File
@@ -129,7 +129,7 @@ func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font, con
client: cli,
paths: paths,
font: font,
accounts: NewAccountStore(paths.AccountFile),
accounts: NewAccountStore(paths.AccountFile, paths.LegacyAccountFile),
successImages: image.NewSuccessImageSaver(
font,
paths.SuccessDir,
@@ -260,7 +260,7 @@ func normalizeSeckillMode(mode types.SeckillMode) types.SeckillMode {
// ==================== 账号转发(AccountStore) ====================
// LoadAccounts 从账号文件加载账号。
// LoadAccounts 从应用内部账号库加载账号。
func (s *Scheduler) LoadAccounts() {
s.accounts.Load()
}
@@ -270,7 +270,7 @@ func (s *Scheduler) AccountCount() int {
return s.accounts.Count()
}
// AccountFilePath 返回账号文件路径。
// AccountFilePath 返回内部账号库路径。
func (s *Scheduler) AccountFilePath() string {
return s.accounts.FilePath()
}
+8 -5
View File
@@ -72,11 +72,14 @@ const (
// AppPaths 运行时路径。
type AppPaths struct {
ConfigFile string
AccountFile string
LogDir string
SuccessDir string
FontFiles []string
DataDir string
ConfigFile string
AccountFile string
LegacyConfigFile string
LegacyAccountFile string
LogDir string
SuccessDir string
FontFiles []string
}
// TraceConfig 追踪配置。
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "douyu_go",
"outputfilename": "douyu_go",
"version": "0.1.5",
"version": "0.1.6",
"frontend:dir": "frontend",
"frontend:install": "npm install",
"frontend:build": "npm run build",