优化一些功能

This commit is contained in:
yml2213
2026-07-29 14:02:56 +08:00
parent 2c88edb741
commit f64838f8db
19 changed files with 313 additions and 85 deletions
+3 -4
View File
@@ -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 {}
// 清空后端旧结果
+11 -8
View File
@@ -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<any>(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')
@@ -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
})
+13 -8
View File
@@ -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<Account[]>([])
const selectedIndices = ref<Set<number>>(new Set())
const statusMap = ref<Record<string, AccountStatus | boolean>>({})
@@ -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 {
+5 -5
View File
@@ -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<ActivityItem[]>([])
const currentActivity = ref<ActivityItem | null>(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<string, any> = await App.SwitchActivity(id)
const d: Record<string, any> = await SwitchActivity(id)
if (d && d.success) {
currentActivity.value = { id: d.activity, displayName: d.displayName }
showToast('活动已切换', (d.displayName || id) + ',正在刷新商品...', 'success')
+4 -5
View File
@@ -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<LogMessage[]>([])
const results = ref<Record<string, string>>({})
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<string, string>
const json = JSON.stringify(d)
@@ -156,7 +155,7 @@ export function useLogs() {
}
const clearResults = async () => {
await App.ClearResults()
await ClearResults()
clearLocalResults()
}
+2 -3
View File
@@ -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<Product[]>([])
const productInfo = ref<ProductPayload | null>(null)
const selectedProductId = ref<string | null>(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
+5 -6
View File
@@ -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
+10 -7
View File
@@ -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<Settings>({
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) {
-16
View File
@@ -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
}
}
}
}
+2 -1
View File
@@ -16,7 +16,8 @@
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@wails/*": ["./wailsjs/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "wailsjs/**/*.ts"]
+2 -1
View File
@@ -6,7 +6,8 @@ export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
'@': resolve(__dirname, 'src'),
'@wails': resolve(__dirname, 'wailsjs')
}
},
build: {
+40 -13
View File
@@ -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()
+37 -1
View File
@@ -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)
+96 -3
View File
@@ -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
}
+53
View File
@@ -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())
}
}
}
+3
View File
@@ -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,
+5
View File
@@ -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()
+20 -3
View File
@@ -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 立即执行抢购任务,持续循环直到超时或全部成功。