v0.4.1:新增 CK 有效性检查并修复抢购调度

This commit is contained in:
yml2213
2026-10-17 14:24:32 +08:00
parent 8b3b14654f
commit 2451466bed
13 changed files with 1210 additions and 132 deletions
+318 -33
View File
@@ -29,6 +29,7 @@ import {
Plus,
RefreshCw,
Save,
ShieldCheck,
ShoppingCart,
Square,
Settings,
@@ -36,6 +37,7 @@ import {
Zap,
} from 'lucide-react'
import {
CheckCookies,
DefaultConfig,
Exchange,
GetInstanceInfo,
@@ -58,6 +60,9 @@ import { ClipboardGetText, ClipboardSetText, EventsOn } from '../wailsjs/runtime
const configStorageBase = 'huya-go.gui.config.v2'
const cookieStorageBase = 'huya-go.gui.cookies.v1'
const prizeCacheStorageBase = 'huya-go.gui.prizes.v1'
const leadDefaultMigrationBase = 'huya-go.gui.lead-default.v1'
// 旧版本默认把提前量写成 2500ms,与现在的动态提前设计冲突;只迁移一次。
const legacyDefaultLeadMs = 2500
// 界面本地存储保留的日志上限,与后端的增量拉取配合使用。
const maxClientLogs = 1000
@@ -123,11 +128,26 @@ type UiConfig = {
disableAdaptiveThrottle: boolean
}
type CookieCheckStatus = 'valid' | 'expired' | 'unknown' | 'invalid'
type CookieCheckResult = {
line: number
uid: number
status: CookieCheckStatus
message?: string
statusCode?: number
checkedAt: number
}
type AccountPreview = {
line: number
uid: number
valid: boolean
error?: string
// checkStatus 是服务端登录态检查结果;valid 仅表示本地能解析出 uid。
checkStatus?: CookieCheckStatus
checkMessage?: string
checkedAt?: number
}
type CookieEntry = {
@@ -136,6 +156,9 @@ type CookieEntry = {
uid: number
valid: boolean
error?: string
checkStatus?: CookieCheckStatus
checkMessage?: string
checkedAt?: number
updatedAt: number
}
@@ -483,9 +506,45 @@ function categoryTag(category?: string): ReactNode {
return <Tag color={item.color}>{item.label}</Tag>
}
function normalizeCookieCheckStatus(value: unknown): CookieCheckStatus | undefined {
const status = String(value || '').trim()
if (status === 'valid' || status === 'expired' || status === 'unknown' || status === 'invalid') {
return status
}
return undefined
}
function isCookieCheckUnusable(status?: CookieCheckStatus): boolean {
return status === 'expired' || status === 'unknown' || status === 'invalid'
}
function cookieCheckTag(status?: CookieCheckStatus, message?: string): ReactNode {
const config: Record<CookieCheckStatus | 'unchecked', { color: string; label: string }> = {
valid: { color: 'success', label: '可用' },
expired: { color: 'error', label: '失效' },
unknown: { color: 'error', label: '失效' },
invalid: { color: 'error', label: '无效' },
unchecked: { color: 'default', label: '未检查' },
}
const item = config[status || 'unchecked']
const tag = <Tag color={item.color}>{item.label}</Tag>
return message ? <Tooltip title={message}>{tag}</Tooltip> : tag
}
function readSavedConfig(): Partial<SavedConfig> {
try {
return JSON.parse(localStorage.getItem(scopedKey(configStorageBase)) || '{}') as Partial<SavedConfig>
const configKey = scopedKey(configStorageBase)
const saved = JSON.parse(localStorage.getItem(configKey) || '{}') as Partial<SavedConfig>
const migrationKey = scopedKey(leadDefaultMigrationBase)
if (!localStorage.getItem(migrationKey)) {
if (Number(saved.leadMs || 0) === legacyDefaultLeadMs) {
saved.leadMs = 0
// 迁移必须立即落盘,否则下一次 readSavedConfig 又会读回旧值。
localStorage.setItem(configKey, JSON.stringify(saved))
}
localStorage.setItem(migrationKey, '1')
}
return saved
} catch {
return {}
}
@@ -505,6 +564,9 @@ function readSavedCookies(): CookieEntry[] {
uid: Number(item.uid || 0),
valid: Boolean(item.valid),
error: item.error,
checkStatus: normalizeCookieCheckStatus(item.checkStatus),
checkMessage: String(item.checkMessage || '').trim() || undefined,
checkedAt: Number(item.checkedAt || 0) || undefined,
updatedAt: Number(item.updatedAt || Date.now()),
}))
.filter((item) => item.text)
@@ -706,11 +768,23 @@ export default function Dashboard() {
}
})
setCookieEntries(enriched)
setAccounts(previews || [])
setSelectedAccountLines((previews || []).filter((item) => item.valid).map((item) => item.line))
const enrichedPreviews = (previews || []).map((preview) => {
const entry = enriched[preview.line - 1]
return {
...preview,
checkStatus: entry?.checkStatus,
checkMessage: entry?.checkMessage,
checkedAt: entry?.checkedAt,
}
})
setAccounts(enrichedPreviews)
const selectableLines = enrichedPreviews
.filter((item) => item.valid && !isCookieCheckUnusable(item.checkStatus))
.map((item) => item.line)
setSelectedAccountLines(selectableLines)
if (persist) writeSavedCookies(enriched)
if (refreshLatest && enriched.some((item) => item.valid)) {
window.setTimeout(() => queryPrizes(true), 0)
if (refreshLatest && selectableLines.length > 0) {
window.setTimeout(() => queryPrizes(true, selectableLines), 0)
}
return enriched
}
@@ -798,7 +872,22 @@ export default function Dashboard() {
writeSavedConfigPatch({ startAt: String(form.getFieldValue('startAt') || '') })
}, [configLoaded, form, watchedStartAt])
const validAccounts = useMemo(() => accounts.filter((item) => item.valid), [accounts])
const validAccounts = useMemo(
() => accounts.filter((item) => item.valid && !isCookieCheckUnusable(item.checkStatus)),
[accounts],
)
const cookieCheckStats = useMemo(() => {
const stats = { valid: 0, expired: 0, unknown: 0, invalid: 0, unchecked: 0 }
cookieEntries.forEach((entry) => {
if (entry.checkStatus === 'valid') stats.valid += 1
else if (entry.checkStatus === 'expired') stats.expired += 1
else if (entry.checkStatus === 'unknown') stats.unknown += 1
else if (entry.checkStatus === 'invalid') stats.invalid += 1
else stats.unchecked += 1
})
return stats
}, [cookieEntries])
const failedCookieCount = cookieCheckStats.expired + cookieCheckStats.unknown + cookieCheckStats.invalid
const importedCookieCount = useMemo(
() => normalizeCookieText(watchedCookieText || '').split('\n').filter(Boolean).length,
[watchedCookieText],
@@ -918,10 +1007,10 @@ export default function Dashboard() {
[logs, logFilter],
)
const config = () => ({
const config = (selectedLines: Key[] = selectedAccountLines) => ({
...pickConfig(form.getFieldsValue(true)),
category: activeCategory,
selectedAccountLines: selectedAccountLines.map(Number).filter((line) => line > 0),
selectedAccountLines: selectedLines.map(Number).filter((line) => line > 0),
})
const runAction = async <T,>(
@@ -1021,7 +1110,16 @@ export default function Dashboard() {
if (editingCookie) {
const text = normalizedText.split('\n')[0]
const nextEntries = cookieEntries.map((entry) =>
entry.id === editingCookie.id ? { ...entry, text, updatedAt: Date.now() } : entry,
entry.id === editingCookie.id
? {
...entry,
text,
checkStatus: undefined,
checkMessage: undefined,
checkedAt: undefined,
updatedAt: Date.now(),
}
: entry,
)
await applyCookieEntries(nextEntries, true, true)
message.success('CK 已更新')
@@ -1064,13 +1162,110 @@ export default function Dashboard() {
message.success('已重新载入本地 CK')
}
function queryPrizes(quiet = false) {
// 检查走服务端 getUserScore;失效结果连续确认 3 次,避免偶发「账号未绑定」误判。
const checkCookies = async () => {
if (running) {
message.warning('抢购运行中,请先停止再检查 CK')
return
}
if (cookieEntries.length === 0) {
message.warning('没有可检查的 CK')
return
}
setLoading('cookie-check')
try {
const snapshot = cookieEntries
const results = (await CheckCookies(config())) as CookieCheckResult[]
const byLine = new Map<number, CookieCheckResult>()
const byUID = new Map<number, CookieCheckResult>()
results.forEach((item) => {
byLine.set(Number(item.line || 0), item)
if (Number(item.uid || 0) > 0) byUID.set(Number(item.uid), item)
})
const nextEntries = snapshot.map((entry, index) => {
const result = byLine.get(index + 1) || (entry.uid > 0 ? byUID.get(entry.uid) : undefined)
if (!result) return entry
return {
...entry,
uid: Number(result.uid || entry.uid || 0),
checkStatus: normalizeCookieCheckStatus(result.status),
checkMessage: result.status === 'valid' ? '' : String(result.message || '').trim(),
checkedAt: Number(result.checkedAt || Date.now()),
}
})
const stats = { valid: 0, expired: 0, unknown: 0, invalid: 0 }
nextEntries.forEach((entry) => {
if (entry.checkStatus === 'valid') stats.valid += 1
else if (entry.checkStatus === 'expired') stats.expired += 1
else if (entry.checkStatus === 'unknown') stats.unknown += 1
else if (entry.checkStatus === 'invalid') stats.invalid += 1
})
const failed = stats.expired + stats.unknown + stats.invalid
setCookieEntries(nextEntries)
writeSavedCookies(nextEntries)
setAccounts((prev) =>
prev.map((account) => {
const result = byLine.get(account.line) || (account.uid > 0 ? byUID.get(account.uid) : undefined)
if (!result) return account
return {
...account,
checkStatus: normalizeCookieCheckStatus(result.status),
checkMessage: result.status === 'valid' ? '' : String(result.message || '').trim(),
checkedAt: Number(result.checkedAt || Date.now()),
}
}),
)
const failedLines = new Set(
nextEntries
.map((entry, index) => (isCookieCheckUnusable(entry.checkStatus) ? index + 1 : 0))
.filter((line) => line > 0),
)
setSelectedAccountLines((prev) => prev.filter((line) => !failedLines.has(Number(line))))
const summary = `检查完成:可用 ${stats.valid},失效 ${failed}`
if (failed > 0) {
message.warning(summary)
} else {
message.success(summary)
}
} catch (err: unknown) {
message.error(`检查 CK 失败: ${String(err)}`)
} finally {
setLoading('')
}
}
const clearExpiredCookies = () => {
if (failedCookieCount === 0) return
modal.confirm({
title: '清理失效 CK',
content: `确认删除 ${failedCookieCount} 条失效 CK?未检查的 CK 会保留。`,
okText: '清理',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
const remaining = cookieEntries.filter((entry) => !isCookieCheckUnusable(entry.checkStatus))
await applyCookieEntries(remaining, true, false)
message.success(`已清理 ${failedCookieCount} 条失效 CK`)
},
})
}
function queryPrizes(quiet = false, selectedLines: Key[] = selectedAccountLines) {
if (!String(form.getFieldValue('cookieText') || '').trim()) {
if (!quiet) message.warning('Cookie 不能为空')
return
}
if (selectedLines.length === 0) {
if (!quiet) message.warning('请先选择账号')
return
}
setLoading('list')
ListPrizes(config())
ListPrizes(config(selectedLines))
.then((data) => {
const result = data as {
prizes?: PrizeInfo[]
@@ -1187,6 +1382,10 @@ export default function Dashboard() {
const startMonitor = (scheduled: boolean) => {
const values = config()
if (values.selectedAccountLines.length === 0) {
message.warning('请先选择账号')
return undefined
}
const countdown = scheduled ? buildScheduleCountdown(values.startAt, values.leadMs, now) : null
if (scheduled && !countdown) {
message.warning('请先填写定时目标时间(HH:mm:ss)')
@@ -1205,6 +1404,10 @@ export default function Dashboard() {
const directExchange = () => {
const values = config()
if (values.selectedAccountLines.length === 0) {
message.warning('请先选择账号')
return undefined
}
if (Number(values.pid || 0) <= 0) {
message.warning('请先在左侧商品列表中选择要兑换的商品')
return undefined
@@ -1262,14 +1465,29 @@ export default function Dashboard() {
render: (_, row) => (
<div>
<strong>{row.uid || `第${row.line}行`}</strong>
<div className="muted">{row.valid ? '已加入任务' : row.error || '未选择'}</div>
<div className="muted">
{isCookieCheckUnusable(row.checkStatus)
? row.checkMessage || 'CK 已失效'
: row.valid
? row.checkStatus === 'valid'
? 'CK 可用'
: '已加入任务'
: row.error || '未选择'}
</div>
</div>
),
},
{
title: '凭据',
width: 90,
render: (_, row) => (row.valid ? <Tag color="green">已加载</Tag> : <Tag>无效</Tag>),
width: 110,
render: (_, row) =>
row.checkStatus ? (
cookieCheckTag(row.checkStatus, row.checkMessage)
) : row.valid ? (
<Tag color="green">已加载</Tag>
) : (
<Tag>无效</Tag>
),
},
{
title: '商品',
@@ -1368,18 +1586,37 @@ export default function Dashboard() {
() => [
{
title: '账号',
width: 150,
width: 140,
render: (_, row) => (
<div>
<strong>{row.uid || '-'}</strong>
<div className="muted">{row.valid ? '有效 CK' : row.error || '待校验'}</div>
<div className="muted">
{isCookieCheckUnusable(row.checkStatus) ? 'CK 已失效' : row.valid ? '可解析 UID' : row.error || '待校验'}
</div>
</div>
),
},
{
title: '状态',
width: 86,
render: (_, row) => (row.valid ? <Tag color="green">有效</Tag> : <Tag color="error">无效</Tag>),
title: '格式',
width: 78,
render: (_, row) => (row.valid ? <Tag color="green">有效</Tag> : <Tag color="default">无效</Tag>),
},
{
title: 'CK 状态',
width: 190,
render: (_, row) => (
<div>
{cookieCheckTag(row.checkStatus, row.checkMessage)}
{row.checkStatus && row.checkStatus !== 'valid' && row.checkMessage ? (
<div className="muted ck-check-message" title={row.checkMessage}>
{row.checkMessage}
</div>
) : null}
{row.checkedAt ? (
<div className="muted">检查于 {new Date(row.checkedAt).toLocaleString('zh-CN', { hour12: false })}</div>
) : null}
</div>
),
},
{
title: 'CK',
@@ -1595,7 +1832,7 @@ export default function Dashboard() {
block
icon={<RefreshCw size={16} />}
loading={loading === 'list'}
disabled={validAccounts.length === 0}
disabled={selectedAccountCount === 0}
onClick={() => queryPrizes()}
>
用 CK 刷新商品
@@ -1719,7 +1956,7 @@ export default function Dashboard() {
type="primary"
icon={<ShoppingCart size={16} />}
loading={loading === 'monitor'}
disabled={running}
disabled={running || selectedAccountCount === 0}
onClick={() => startMonitor(false)}
>
立即抢购
@@ -1729,7 +1966,7 @@ export default function Dashboard() {
type="primary"
icon={<Play size={16} />}
loading={loading === 'monitor'}
disabled={running}
disabled={running || selectedAccountCount === 0}
onClick={() => startMonitor(true)}
>
开启定时
@@ -1737,7 +1974,7 @@ export default function Dashboard() {
<Button
icon={<Zap size={16} />}
loading={loading === 'exchange'}
disabled={running || validAccounts.length === 0}
disabled={running || selectedAccountCount === 0}
onClick={directExchange}
>
直接兑换
@@ -1752,22 +1989,42 @@ export default function Dashboard() {
<Button
icon={<RefreshCw size={16} />}
loading={loading === 'score'}
onClick={() =>
runAction('score', () => QueryScores(config()), (data) =>
disabled={selectedAccountCount === 0}
onClick={() => {
const values = config()
if (values.selectedAccountLines.length === 0) {
message.warning('请先选择账号')
return
}
runAction('score', () => QueryScores(values), (data) =>
setScores((data || []) as ScoreResult[]),
)
}
}}
>
查询积分
</Button>
<Button
icon={<ShieldCheck size={16} />}
loading={loading === 'cookie-check'}
disabled={running || cookieEntries.length === 0}
onClick={checkCookies}
>
CK 有效性
</Button>
<Button
icon={<ClipboardList size={16} />}
loading={loading === 'records'}
onClick={() =>
runAction('records', () => GetRecords(config()), (data) =>
disabled={selectedAccountCount === 0}
onClick={() => {
const values = config()
if (values.selectedAccountLines.length === 0) {
message.warning('请先选择账号')
return
}
runAction('records', () => GetRecords(values), (data) =>
setRecords((data || []) as RecordResult[]),
)
}
}}
>
兑换记录
</Button>
@@ -1815,7 +2072,9 @@ export default function Dashboard() {
rowSelection={{
selectedRowKeys: selectedAccountLines,
onChange: setSelectedAccountLines,
getCheckboxProps: (row) => ({ disabled: !row.valid }),
getCheckboxProps: (row) => ({
disabled: !row.valid || isCookieCheckUnusable(row.checkStatus),
}),
}}
columns={accountColumns}
dataSource={accounts}
@@ -1848,7 +2107,15 @@ export default function Dashboard() {
<Modal
title="CK 管理"
open={cookieManagerOpen}
width={980}
width="min(1080px, calc(100vw - 48px))"
style={{ maxWidth: 'calc(100vw - 24px)' }}
styles={{
body: {
maxHeight: 'calc(100vh - 180px)',
overflowY: 'auto',
overflowX: 'hidden',
},
}}
onCancel={() => setCookieManagerOpen(false)}
footer={null}
>
@@ -1868,10 +2135,28 @@ export default function Dashboard() {
<Button icon={<RefreshCw size={16} />} onClick={reloadSavedCookies}>
重新载入
</Button>
<Button
icon={<ShieldCheck size={16} />}
loading={loading === 'cookie-check'}
disabled={running || cookieEntries.length === 0}
onClick={checkCookies}
>
检查 CK 有效性
</Button>
</Space>
<Space wrap>
<Tag>{cookieEntries.length} 条 CK</Tag>
<Tag color="green">{validAccounts.length} 个有效</Tag>
<Tag color="green">{cookieCheckStats.valid} 可用</Tag>
{failedCookieCount > 0 ? <Tag color="error">{failedCookieCount} 失效</Tag> : null}
{cookieCheckStats.unchecked > 0 ? <Tag>{cookieCheckStats.unchecked} 未检查</Tag> : null}
<Button
danger
icon={<Trash2 size={16} />}
disabled={failedCookieCount === 0}
onClick={clearExpiredCookies}
>
清理失效
</Button>
<Button
danger
icon={<Trash2 size={16} />}
@@ -1888,7 +2173,7 @@ export default function Dashboard() {
columns={cookieColumns}
dataSource={cookieEntries}
pagination={{ pageSize: 8, showSizeChanger: false }}
scroll={{ x: 900, y: 430 }}
scroll={{ x: 1000, y: 430 }}
/>
</div>
</Modal>
+26 -1
View File
@@ -502,24 +502,49 @@ body {
.ck-manager {
display: grid;
gap: 12px;
min-width: 0;
}
.ck-manager-tools {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.ck-manager-tools > .ant-space {
max-width: 100%;
}
.ck-manager-tools > .ant-space:last-child {
margin-left: auto;
justify-content: flex-end;
}
.ck-manager .ant-table-wrapper,
.ck-manager .ant-spin-nested-loading,
.ck-manager .ant-spin-container {
min-width: 0;
}
.ck-text-preview {
display: block;
max-width: 100%;
overflow: hidden;
color: #344054;
text-overflow: ellipsis;
white-space: nowrap;
}
.ck-check-message {
display: -webkit-box;
overflow: hidden;
word-break: break-all;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));