From f64838f8db130d5fea881787970700412a91b427 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 29 Jul 2026 14:02:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=B8=80=E4=BA=9B=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.vue | 7 +- frontend/src/components/ck/CKManager.vue | 19 ++-- .../src/components/modal/SettingsModal.vue | 3 +- frontend/src/composables/useAccounts.ts | 21 ++-- frontend/src/composables/useActivity.ts | 10 +- frontend/src/composables/useLogs.ts | 9 +- frontend/src/composables/useProducts.ts | 5 +- frontend/src/composables/useScheduler.ts | 11 +-- frontend/src/composables/useSettings.ts | 17 ++-- frontend/src/env.d.ts | 16 --- frontend/tsconfig.json | 3 +- frontend/vite.config.ts | 3 +- internal/app/app.go | 53 +++++++--- internal/app/app_activity.go | 38 ++++++- internal/app/app_export.go | 99 ++++++++++++++++++- internal/app/app_export_test.go | 53 ++++++++++ internal/client/client.go | 3 + internal/logger/logger.go | 5 + internal/seckill/scheduler.go | 23 ++++- 19 files changed, 313 insertions(+), 85 deletions(-) create mode 100644 internal/app/app_export_test.go diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2a2dec8..3e7478c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,6 +8,7 @@ import { useLogs } from '@/composables/useLogs' import { useActivity } from '@/composables/useActivity' import AppShell from '@/components/layout/AppShell.vue' import ToastContainer from '@/components/ToastContainer.vue' +import { GetTime, GetVersion } from '@wails/go/app/App' const { initTheme } = useTheme() const { loadProducts, restoreSelection: restoreProductSelection } = useProducts() @@ -25,11 +26,9 @@ let timeTimer: number | null = null let logTimer: number | null = null let resultTimer: number | null = null -const App = window.go.app.App - const updateTime = async () => { try { - const d = await App.GetTime() + const d = await GetTime() serverTime.value = d.serverTime || '--' } catch {} } @@ -68,7 +67,7 @@ onMounted(async () => { // 获取版本号 try { - version.value = await App.GetVersion() + version.value = await GetVersion() } catch {} // 清空后端旧结果 diff --git a/frontend/src/components/ck/CKManager.vue b/frontend/src/components/ck/CKManager.vue index 5806fda..bdd7779 100644 --- a/frontend/src/components/ck/CKManager.vue +++ b/frontend/src/components/ck/CKManager.vue @@ -2,12 +2,15 @@ import { ref, computed } from 'vue' import { useProducts } from '@/composables/useProducts' import { useToast } from '@/composables/useToast' +import { + ExportAccountsWithProduct, + OrganizeAccounts, + VerifyAndExport +} from '@wails/go/app/App' const { selectedProductId, selectedProductName, selectedProduct } = useProducts() const { showToast } = useToast() -const App = window.go.app.App - // 状态 const loading = ref(false) const verifyResult = ref(null) @@ -42,14 +45,14 @@ const handleVerify = async () => { loading.value = true try { - const result = await App.VerifyAndExport(selectedProductId.value) + const result = await VerifyAndExport(selectedProductId.value) verifyResult.value = result organizeResult.value = null if (result.success) { showToast('复验完成', `已抢到: ${result.hitCount}, 未抢到: ${result.missCount}`, 'success') } else { - showToast('复验失败', result.error, 'error') + showToast('复验失败', '后端未返回成功状态', 'error') } } catch (e: any) { showToast('复验失败', e.message, 'error') @@ -66,7 +69,7 @@ const exportHitAccounts = async () => { } try { - const result = await App.ExportAccountsWithProduct( + const result = await ExportAccountsWithProduct( verifyResult.value.hitAccounts, selectedProductId.value, selectedProductName.value || '' @@ -74,7 +77,7 @@ const exportHitAccounts = async () => { if (result.success) { showToast('导出成功', `已导出 ${result.count} 个账号到 ${result.filePath}`, 'success') } else { - showToast('导出失败', result.error, 'error') + showToast('导出失败', result.error || '', 'error') } } catch (e: any) { showToast('导出失败', e.message, 'error') @@ -91,14 +94,14 @@ const handleOrganize = async () => { loading.value = true try { const { start, end } = timeRangeMs.value - const result = await App.OrganizeAccounts(selectedProductId.value, selectedProductName.value, start, end) + const result = await OrganizeAccounts(selectedProductId.value, selectedProductName.value, start, end) organizeResult.value = result verifyResult.value = null if (result.success) { showToast('整理完成', `已抢到: ${result.hitCount}, 未抢到: ${result.missCount}`, 'success') } else { - showToast('整理失败', result.error, 'error') + showToast('整理失败', result.error || '', 'error') } } catch (e: any) { showToast('整理失败', e.message, 'error') diff --git a/frontend/src/components/modal/SettingsModal.vue b/frontend/src/components/modal/SettingsModal.vue index 3d8ef02..ced2095 100644 --- a/frontend/src/components/modal/SettingsModal.vue +++ b/frontend/src/components/modal/SettingsModal.vue @@ -7,11 +7,12 @@ const { settings, loadSettings, saveSettings } = useSettings() const visible = ref(false) const formData = ref({ timeApiUrl: '', + activity: '', traceEnabled: false, traceMaxBody: 0, logLevel: 'info', concurrency: 150, - attempts: 1, + attempts: 2, retryStages: [] as RetryStage[], seckillMode: 1 }) diff --git a/frontend/src/composables/useAccounts.ts b/frontend/src/composables/useAccounts.ts index fc2a0a6..b24c881 100644 --- a/frontend/src/composables/useAccounts.ts +++ b/frontend/src/composables/useAccounts.ts @@ -1,5 +1,12 @@ import { ref } from 'vue' import { useToast } from './useToast' +import { + GetAccounts, + QueryScore, + ReloadAccounts, + VerifyAllExchange, + VerifyExchange +} from '@wails/go/app/App' export interface Account { index: number @@ -13,8 +20,6 @@ export interface AccountStatus { message?: string } -const App = window.go.app.App - const accounts = ref([]) const selectedIndices = ref>(new Set()) const statusMap = ref>({}) @@ -24,7 +29,7 @@ export function useAccounts() { const { showToast } = useToast() const loadAccounts = async () => { - const data = await App.GetAccounts() + const data = await GetAccounts() accounts.value = data || [] // 清理无效选择 @@ -35,7 +40,7 @@ export function useAccounts() { } const reloadAccounts = async () => { - const data = await App.ReloadAccounts() + const data = await ReloadAccounts() await loadAccounts() showToast('账号已刷新', `共 ${data.count} 个账号`, 'success') } @@ -81,7 +86,7 @@ export function useAccounts() { showToast('查询积分', '正在并发查询...', 'info') const tasks = accounts.value.map(async (a) => { try { - const d = await App.QueryScore(a.name) + const d = await QueryScore(a.name) const score = d.score ?? 'N/A' scoreMap.value[a.name] = String(score) localStorage.setItem('accScore_' + a.name, String(score)) @@ -193,7 +198,7 @@ export function useVerifyExchange() { const verifyAccount = async (accountName: string) => { try { - const result = await App.VerifyExchange(accountName) + const result = await VerifyExchange(accountName) verifyResults.value[accountName] = result return result } catch (e: any) { @@ -206,8 +211,8 @@ export function useVerifyExchange() { verifying.value = true try { // 使用后端批量查询接口,自带间隔 - const results = await App.VerifyAllExchange() - verifyResults.value = results || {} + const results = await VerifyAllExchange() + verifyResults.value = results?.results || {} } catch (e: any) { console.error('批量复验失败:', e) } finally { diff --git a/frontend/src/composables/useActivity.ts b/frontend/src/composables/useActivity.ts index ff82c0e..e8f4337 100644 --- a/frontend/src/composables/useActivity.ts +++ b/frontend/src/composables/useActivity.ts @@ -1,5 +1,6 @@ import { ref } from 'vue' import { useToast } from './useToast' +import { GetActivities, GetCurrentActivity, SwitchActivity } from '@wails/go/app/App' // 活动项(与后端 types.ActivityInfo 对齐) export interface ActivityItem { @@ -12,8 +13,7 @@ const activities = ref([]) const currentActivity = ref(null) const switching = ref(false) -// 与项目其他 composable 一致,直接走 Wails 运行时注入的 window.go.app.App -const App = window.go.app.App +// 统一走 Wails 自动生成绑定,后端签名变化时可由 TypeScript 暴露问题。 export function useActivity() { const { showToast } = useToast() @@ -21,10 +21,10 @@ export function useActivity() { // 拉取全部活动与当前生效活动 const loadActivities = async () => { try { - const list = await App.GetActivities() + const list = await GetActivities() activities.value = list || [] try { - currentActivity.value = await App.GetCurrentActivity() + currentActivity.value = await GetCurrentActivity() } catch { // 当前活动读取失败不阻塞列表展示 } @@ -38,7 +38,7 @@ export function useActivity() { if (id === currentActivity.value?.id) return true switching.value = true try { - const d: Record = await App.SwitchActivity(id) + const d: Record = await SwitchActivity(id) if (d && d.success) { currentActivity.value = { id: d.activity, displayName: d.displayName } showToast('活动已切换', (d.displayName || id) + ',正在刷新商品...', 'success') diff --git a/frontend/src/composables/useLogs.ts b/frontend/src/composables/useLogs.ts index 755334d..e444274 100644 --- a/frontend/src/composables/useLogs.ts +++ b/frontend/src/composables/useLogs.ts @@ -1,5 +1,6 @@ import { ref } from 'vue' import { useAccounts, type AccountStatus } from './useAccounts' +import { ClearResults, GetLogs, GetResults } from '@wails/go/app/App' export interface LogEntry { id: number @@ -20,8 +21,6 @@ export interface LogMessage { source: 'local' | 'server' } -const App = window.go.app.App - const logs = ref([]) const results = ref>({}) const lastLogId = ref(0) @@ -91,7 +90,7 @@ export function useLogs() { const fetchServerLogs = async () => { try { - const d = await App.GetLogs(lastLogId.value) + const d = await GetLogs(lastLogId.value) if (!d.success || !Array.isArray(d.logs) || d.logs.length === 0) return d.logs.forEach((item: LogEntry) => { @@ -127,7 +126,7 @@ export function useLogs() { const checkResults = async () => { try { - const d = await App.GetResults() + const d = await GetResults() const data = (d || {}) as Record const json = JSON.stringify(d) @@ -156,7 +155,7 @@ export function useLogs() { } const clearResults = async () => { - await App.ClearResults() + await ClearResults() clearLocalResults() } diff --git a/frontend/src/composables/useProducts.ts b/frontend/src/composables/useProducts.ts index e89f42f..0465ff1 100644 --- a/frontend/src/composables/useProducts.ts +++ b/frontend/src/composables/useProducts.ts @@ -1,5 +1,6 @@ import { ref, computed } from 'vue' import { useToast } from './useToast' +import { LoadProducts } from '@wails/go/app/App' export interface Product { commodityId: string @@ -25,8 +26,6 @@ export interface ProductPayload { stale: boolean } -const App = window.go.app.App - const products = ref([]) const productInfo = ref(null) const selectedProductId = ref(null) @@ -54,7 +53,7 @@ export function useProducts() { const loadProducts = async (showToastMsg = true) => { loading.value = true try { - const data = await App.LoadProducts(true) + const data = await LoadProducts(true) products.value = Array.isArray(data.products) ? data.products : [] productInfo.value = data diff --git a/frontend/src/composables/useScheduler.ts b/frontend/src/composables/useScheduler.ts index 428c744..c6ea929 100644 --- a/frontend/src/composables/useScheduler.ts +++ b/frontend/src/composables/useScheduler.ts @@ -3,8 +3,7 @@ import { useToast } from './useToast' import { useAccounts } from './useAccounts' import { useProducts } from './useProducts' import { useLogs } from './useLogs' - -const App = window.go.app.App +import { CancelSchedule, SetSchedule, StartSeckill } from '@wails/go/app/App' const scheduleActive = ref(false) const countdownSeconds = ref(0) @@ -33,7 +32,7 @@ export function useScheduler() { showToast('抢购中', `${Object.keys(ap).length} 个账号正在抢购`, 'info', 3000) try { - const d = await App.StartSeckill(ap) + const d = await StartSeckill(ap) if (d?.success === false) { throw new Error(d.error || '提交失败') } @@ -75,12 +74,12 @@ export function useScheduler() { addLocalLog(`设置定时: ${fullTime}`, 'i') try { - const d = await App.SetSchedule({ + const d = await SetSchedule({ accountProducts: ap, targetTime: fullTime }) if (d?.success === false) { - throw new Error(d.error || '定时设置失败') + throw new Error('定时设置失败') } scheduleActive.value = true @@ -106,7 +105,7 @@ export function useScheduler() { const cancelSchedule = async () => { try { - await App.CancelSchedule() + await CancelSchedule() } catch {} scheduleActive.value = false diff --git a/frontend/src/composables/useSettings.ts b/frontend/src/composables/useSettings.ts index 1473646..7c06672 100644 --- a/frontend/src/composables/useSettings.ts +++ b/frontend/src/composables/useSettings.ts @@ -1,5 +1,7 @@ import { ref } from 'vue' import { useToast } from './useToast' +import { GetSettings, SaveSettings } from '@wails/go/app/App' +import { types } from '@wails/go/models' export interface RetryStage { maxElapsed: number @@ -8,6 +10,7 @@ export interface RetryStage { export interface Settings { timeApiUrl: string + activity: string traceEnabled: boolean traceMaxBody: number logLevel: string @@ -17,8 +20,6 @@ export interface Settings { seckillMode: number // 0=标准, 1=激进 } -const App = window.go.app.App - const defaultRetryStages: RetryStage[] = [ { maxElapsed: 4, interval: 120 }, { maxElapsed: 12, interval: 200 }, @@ -28,11 +29,12 @@ const defaultRetryStages: RetryStage[] = [ const settings = ref({ timeApiUrl: '', + activity: '', traceEnabled: false, traceMaxBody: 0, logLevel: 'info', concurrency: 150, - attempts: 1, + attempts: 2, retryStages: [...defaultRetryStages], seckillMode: 1 }) @@ -42,14 +44,15 @@ export function useSettings() { const loadSettings = async () => { try { - const cfg = await App.GetSettings() + const cfg = await GetSettings() settings.value = { timeApiUrl: cfg.timeApiUrl || '', + activity: cfg.activity || '', traceEnabled: !!cfg.traceEnabled, traceMaxBody: cfg.traceMaxBody || 0, logLevel: cfg.logLevel || 'info', concurrency: cfg.concurrency || 150, - attempts: cfg.attempts || 1, + attempts: cfg.attempts || 2, retryStages: cfg.retryStages && cfg.retryStages.length > 0 ? cfg.retryStages : [...defaultRetryStages], @@ -62,13 +65,13 @@ export function useSettings() { const saveSettings = async (newSettings: Settings) => { try { - const d = await App.SaveSettings(newSettings) + const d = await SaveSettings(types.Config.createFrom(newSettings)) if (d.success) { settings.value = { ...newSettings } showToast('设置已保存', '配置已生效', 'success') return true } else { - showToast('保存失败', d.error || '', 'error') + showToast('保存失败', '后端未返回成功状态', 'error') return false } } catch (e: any) { diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts index 26285b7..323c78a 100644 --- a/frontend/src/env.d.ts +++ b/frontend/src/env.d.ts @@ -5,19 +5,3 @@ declare module '*.vue' { const component: DefineComponent<{}, {}, any> export default component } - -// 统一从 Wails 自动生成的强类型绑定派生 window.go.app.App 的类型声明, -// 避免手写声明与后端契约漂移。后端方法签名变更后重新运行 `wails generate` -// 会更新 wailsjs/go/app/App.d.ts,此处类型随之更新;若前端引用了已删除或 -// 已重命名的方法,TypeScript 编译(vue-tsc)会直接报错。 -import type * as GoApp from './wailsjs/go/app/App' - -declare global { - interface Window { - go: { - app: { - App: typeof GoApp - } - } - } -} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 9d38e6f..4165502 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -16,7 +16,8 @@ "noUnusedParameters": false, "noFallthroughCasesInSwitch": true, "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@wails/*": ["./wailsjs/*"] } }, "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "wailsjs/**/*.ts"] diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 04b0ae8..370d675 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,7 +6,8 @@ export default defineConfig({ plugins: [vue()], resolve: { alias: { - '@': resolve(__dirname, 'src') + '@': resolve(__dirname, 'src'), + '@wails': resolve(__dirname, 'wailsjs') } }, build: { diff --git a/internal/app/app.go b/internal/app/app.go index 6c0ff37..d9319e6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -28,9 +28,11 @@ type App struct { client *client.DouyuClient scheduler *seckill.Scheduler logStore *logger.MemoryLogStore - trace *trace.Recorder - paths types.AppPaths - font *truetype.Font + + traceMu sync.RWMutex + trace *trace.Recorder + paths types.AppPaths + font *truetype.Font cfgMu sync.RWMutex cfg types.Config @@ -50,6 +52,13 @@ func (a *App) getClient() *client.DouyuClient { return a.client } +// getTrace 返回当前 trace 记录器快照(读锁保护)。 +func (a *App) getTrace() *trace.Recorder { + a.traceMu.RLock() + defer a.traceMu.RUnlock() + return a.trace +} + // getCfg 返回当前配置的深拷贝(读锁保护)。 func (a *App) getCfg() types.Config { a.cfgMu.RLock() @@ -71,6 +80,25 @@ func (a *App) setCfg(c types.Config) { a.cfgMu.Unlock() } +// replaceTrace 根据配置替换 trace 记录器,旧记录器在锁外关闭,避免阻塞读路径。 +func (a *App) replaceTrace(cfg types.Config) { + traceCfg := types.TraceConfig{ + Enabled: cfg.TraceEnabled, + MaxBodyBytes: cfg.TraceMaxBody, + } + tr, closeTrace := trace.InitTrace(a.paths.LogDir, traceCfg) + + a.traceMu.Lock() + oldClose := a.closeTrace + a.trace = tr + a.closeTrace = closeTrace + a.traceMu.Unlock() + + if oldClose != nil { + oldClose() + } +} + // cloneConfig 深拷贝配置,避免 RetryStages 切片共享引用。 func cloneConfig(c types.Config) types.Config { cp := c @@ -98,13 +126,7 @@ func (a *App) Startup(ctx context.Context) { a.cfg = config.LoadConfig(a.paths.ConfigFile) logger.SetMinLevel(a.cfg.LogLevel) - traceCfg := types.TraceConfig{ - Enabled: a.cfg.TraceEnabled, - MaxBodyBytes: a.cfg.TraceMaxBody, - } - tr, closeTrace := trace.InitTrace(a.paths.LogDir, traceCfg) - a.trace = tr - a.closeTrace = closeTrace + a.replaceTrace(a.cfg) // 解析当前活动:配置中的 activity ID 决定使用哪套接口参数 act := activity.Get(a.cfg.Activity) @@ -112,7 +134,7 @@ func (a *App) Startup(ctx context.Context) { act = activity.Default() logger.LogEvent("活动", "warn", "配置中的活动 %q 未识别,已回退到默认活动 %s", a.cfg.Activity, act.DisplayName) } - a.client = client.New(act, a.cfg.TimeAPIURL, a.trace) + a.client = client.New(act, a.cfg.TimeAPIURL, a.getTrace()) a.font = imgpkg.LoadFont(a.paths.FontFiles) a.scheduler = seckill.New(a.client, a.paths, a.font, a.cfg.Concurrency, a.cfg.Attempts, a.cfg.RetryStages, a.cfg.SeckillMode) a.scheduler.LoadAccounts() @@ -136,8 +158,13 @@ func (a *App) Shutdown(ctx context.Context) { if a.scheduler != nil { a.scheduler.Close() } - if a.closeTrace != nil { - a.closeTrace() + a.traceMu.Lock() + closeTrace := a.closeTrace + a.closeTrace = nil + a.trace = nil + a.traceMu.Unlock() + if closeTrace != nil { + closeTrace() } if a.closeLog != nil { a.closeLog() diff --git a/internal/app/app_activity.go b/internal/app/app_activity.go index 54b9179..d456aa5 100644 --- a/internal/app/app_activity.go +++ b/internal/app/app_activity.go @@ -43,7 +43,7 @@ func (a *App) SwitchActivity(id string) types.SwitchActivityResult { return types.SwitchActivityResult{Error: "配置保存失败: " + err.Error()} } a.setCfg(cfg) - newClient := client.New(act, cfg.TimeAPIURL, a.trace) + newClient := client.New(act, cfg.TimeAPIURL, a.getTrace()) a.setClient(newClient) if !a.scheduler.UpdateClient(newClient) { logger.LogEvent("活动", "warn", "抢购任务正在运行,调度器客户端将在下次抢购时生效") @@ -62,6 +62,24 @@ func (a *App) GetSettings() types.Config { // SaveSettings 保存配置并运行时生效。 func (a *App) SaveSettings(cfg types.Config) types.SimpleResult { + oldCfg := a.getCfg() + if cfg.Activity == "" { + cfg.Activity = oldCfg.Activity + } + act := activity.Get(cfg.Activity) + if act == nil { + unknownActivity := cfg.Activity + act = activity.Default() + cfg.Activity = act.ID + logger.LogEvent("配置", "warn", "保存设置时活动 %q 未识别,已回退到默认活动 %s", unknownActivity, act.DisplayName) + } + if cfg.TimeAPIURL == "" { + cfg.TimeAPIURL = constants.DefaultTimeAPI + } + if cfg.TraceMaxBody < 0 { + cfg.TraceMaxBody = 0 + } + // 校验 if cfg.Concurrency <= 0 { cfg.Concurrency = constants.DefaultConcurrency @@ -79,10 +97,28 @@ func (a *App) SaveSettings(cfg types.Config) types.SimpleResult { return types.SimpleResult{} } + traceChanged := cfg.TraceEnabled != oldCfg.TraceEnabled || cfg.TraceMaxBody != oldCfg.TraceMaxBody + clientChanged := traceChanged || cfg.TimeAPIURL != oldCfg.TimeAPIURL || cfg.Activity != oldCfg.Activity + if traceChanged { + a.replaceTrace(cfg) + } + // 运行时生效 a.setCfg(cfg) logger.SetMinLevel(cfg.LogLevel) a.scheduler.UpdateSettings(cfg.Concurrency, cfg.Attempts, cfg.RetryStages, cfg.SeckillMode) + if clientChanged { + newClient := client.New(act, cfg.TimeAPIURL, a.getTrace()) + a.setClient(newClient) + if !a.scheduler.UpdateClient(newClient) { + logger.LogEvent("配置", "warn", "抢购任务正在运行,客户端配置将在下次抢购时完整生效") + } + if sample, err := newClient.ServerTimeSample(); err == nil { + logger.SetDouyuOffset(sample.Offset) + } else { + logger.LogEvent("时间", "warn", "保存配置后服务端时间校准失败: %v", err) + } + } logger.LogEvent("配置", "success", "配置已保存并生效: 日志等级=%s 并发=%d 重试=%d 重试阶段=%d 模式=%d trace=%v", cfg.LogLevel, cfg.Concurrency, cfg.Attempts, len(cfg.RetryStages), cfg.SeckillMode, cfg.TraceEnabled) diff --git a/internal/app/app_export.go b/internal/app/app_export.go index 0f00a52..8432dbf 100644 --- a/internal/app/app_export.go +++ b/internal/app/app_export.go @@ -128,6 +128,9 @@ func (a *App) OrganizeAccounts(commodityID, commodityName string, startTime, end var exportResult types.ExportAccountsResult if len(hitAccounts) > 0 { exportResult = a.ExportAccountsWithProduct(hitAccounts, commodityID, commodityName) + if !exportResult.Success { + return types.OrganizeAccountsResult{Error: "导出已抢到账号失败,已取消整理: " + exportResult.Error, ExportResult: exportResult} + } } // 更新原文件,只保留未抢到的 @@ -153,11 +156,13 @@ 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 { + // 写回原文件前先备份,再用同目录临时文件替换,避免半写入损坏账号文件。 + newContent := strings.Join(keepLines, "\r\n") + "\r\n" + backupPath, err := writeFileWithBackup(accountFile, []byte(newContent)) + if err != nil { return types.OrganizeAccountsResult{Error: "更新账号文件失败: " + err.Error()} } + logger.LogEvent("整理", "success", "账号文件已安全更新,备份=%s", backupPath) // 重新加载账号 a.scheduler.LoadAccounts() @@ -173,3 +178,91 @@ func (a *App) OrganizeAccounts(commodityID, commodityName string, startTime, end ExportResult: exportResult, } } + +func writeFileWithBackup(path string, content []byte) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", err + } + mode := info.Mode().Perm() + if mode == 0 { + mode = 0644 + } + + original, err := os.ReadFile(path) + if err != nil { + return "", err + } + backupPath, err := createFileBackup(path, original, mode) + if err != nil { + return "", err + } + if err := replaceFileWithTemp(path, content, mode); err != nil { + return backupPath, err + } + return backupPath, nil +} + +func createFileBackup(path string, content []byte, mode os.FileMode) (string, error) { + timestamp := time.Now().Format("20060102_150405.000") + basePath := filepath.Join(filepath.Dir(path), filepath.Base(path)+"."+timestamp+".bak") + for i := 0; i < 100; i++ { + backupPath := basePath + if i > 0 { + backupPath = fmt.Sprintf("%s.%d", basePath, i) + } + file, err := os.OpenFile(backupPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if os.IsExist(err) { + continue + } + if err != nil { + return "", err + } + if _, err := file.Write(content); err != nil { + _ = file.Close() + _ = os.Remove(backupPath) + return "", err + } + if err := file.Close(); err != nil { + _ = os.Remove(backupPath) + return "", err + } + return backupPath, nil + } + return "", fmt.Errorf("备份文件名连续冲突: %s", basePath) +} + +func replaceFileWithTemp(path string, content []byte, mode os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + if tmpPath != "" { + _ = os.Remove(tmpPath) + } + }() + + if _, err := tmp.Write(content); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(mode); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return err + } + tmpPath = "" + return nil +} diff --git a/internal/app/app_export_test.go b/internal/app/app_export_test.go new file mode 100644 index 0000000..1814eaf --- /dev/null +++ b/internal/app/app_export_test.go @@ -0,0 +1,53 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteFileWithBackup(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "user.txt") + original := []byte("# 账号\r\nfoo----bar----cookie\r\n") + updated := []byte("# 账号\r\n") + + if err := os.WriteFile(path, original, 0640); err != nil { + t.Fatalf("写入原始账号文件失败: %v", err) + } + + backupPath, err := writeFileWithBackup(path, updated) + if err != nil { + t.Fatalf("writeFileWithBackup 返回错误: %v", err) + } + if backupPath == "" { + t.Fatal("backupPath 为空") + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读取更新后账号文件失败: %v", err) + } + if string(got) != string(updated) { + t.Fatalf("更新后账号文件内容 = %q, want %q", got, updated) + } + + backup, err := os.ReadFile(backupPath) + if err != nil { + t.Fatalf("读取备份文件失败: %v", err) + } + if string(backup) != string(original) { + t.Fatalf("备份文件内容 = %q, want %q", backup, original) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("读取临时目录失败: %v", err) + } + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".tmp") { + t.Fatalf("发现残留临时文件: %s", entry.Name()) + } + } +} diff --git a/internal/client/client.go b/internal/client/client.go index a6e5163..e1b1e3c 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -36,6 +36,9 @@ func New(act *activity.Activity, timeAPIURL string, tr *trace.Recorder) *DouyuCl if timeAPIURL == "" { timeAPIURL = constants.DefaultTimeAPI } + if tr == nil { + tr = &trace.Recorder{} + } return &DouyuClient{ Activity: act, TimeAPIURL: timeAPIURL, diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 4880c23..c81ef93 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -53,6 +53,7 @@ func levelPriority(level string) int { // AsyncLogWriter 异步日志写入器,队列满时丢弃日志以保护主链路。 type AsyncLogWriter struct { + mu sync.RWMutex dst io.Writer ch chan []byte wg sync.WaitGroup @@ -79,6 +80,8 @@ func (w *AsyncLogWriter) Write(p []byte) (int, error) { if w == nil { return len(p), nil } + w.mu.RLock() + defer w.mu.RUnlock() if atomic.LoadUint32(&w.closed) == 1 { return len(p), nil } @@ -106,6 +109,8 @@ func (w *AsyncLogWriter) Close() error { if w == nil { return nil } + w.mu.Lock() + defer w.mu.Unlock() if atomic.CompareAndSwapUint32(&w.closed, 0, 1) { close(w.ch) w.wg.Wait() diff --git a/internal/seckill/scheduler.go b/internal/seckill/scheduler.go index 10ffe33..68d8456 100644 --- a/internal/seckill/scheduler.go +++ b/internal/seckill/scheduler.go @@ -71,6 +71,8 @@ type Scheduler struct { runMu sync.Mutex running bool + // pendingClient 保存运行中收到的客户端更新,当前任务结束后自动应用。 + pendingClient *client.DouyuClient scheduleMu sync.Mutex schedule *time.Timer @@ -133,15 +135,24 @@ func (s *Scheduler) clientSnapshot() *client.DouyuClient { } // UpdateClient 热替换底层 HTTP 客户端(用于活动切换)。 -// 若有抢购任务正在运行则拒绝替换,返回 false。 +// 若有抢购任务正在运行则暂存新客户端,当前任务结束后自动应用,并返回 false。 func (s *Scheduler) UpdateClient(newClient *client.DouyuClient) bool { s.runMu.Lock() if s.running { + if s.pendingClient != nil && s.pendingClient.HTTPClient != nil { + s.pendingClient.HTTPClient.CloseIdleConnections() + } + s.pendingClient = newClient s.runMu.Unlock() return false } s.runMu.Unlock() + s.replaceClient(newClient) + return true +} + +func (s *Scheduler) replaceClient(newClient *client.DouyuClient) { s.clientMu.Lock() defer s.clientMu.Unlock() old := s.client @@ -149,7 +160,6 @@ func (s *Scheduler) UpdateClient(newClient *client.DouyuClient) bool { if old != nil && old.HTTPClient != nil { old.HTTPClient.CloseIdleConnections() } - return true } // UpdateProducts 更新商品缓存,抢购成功图片会优先使用中文商品名。 @@ -289,8 +299,15 @@ func (s *Scheduler) TryStartRun() bool { func (s *Scheduler) finishRun() { s.runMu.Lock() - defer s.runMu.Unlock() s.running = false + pendingClient := s.pendingClient + s.pendingClient = nil + s.runMu.Unlock() + + if pendingClient != nil { + s.replaceClient(pendingClient) + logger.LogEvent("配置", "success", "抢购任务结束,已应用等待中的客户端配置") + } } // RunNow 立即执行抢购任务,持续循环直到超时或全部成功。