优化一些功能
This commit is contained in:
@@ -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 {}
|
||||
|
||||
// 清空后端旧结果
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Vendored
-16
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@wails/*": ["./wailsjs/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "wailsjs/**/*.ts"]
|
||||
|
||||
@@ -6,7 +6,8 @@ export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
'@': resolve(__dirname, 'src'),
|
||||
'@wails': resolve(__dirname, 'wailsjs')
|
||||
}
|
||||
},
|
||||
build: {
|
||||
|
||||
+40
-13
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 立即执行抢购任务,持续循环直到超时或全部成功。
|
||||
|
||||
Reference in New Issue
Block a user