Files
crawler-plugin/frontend-vue/src/shared/composables/useVersionUpdate.ts
T
huangzd1997 6d46506726 fix: 全维度审查修复(安全/正确性/性能/稳定性/客户端/前端)
安全
- /api/ziniao/** 五个匿名接口加管理员鉴权(此前可匿名换取任意员工店铺登录令牌)
- 删除 Flask 遗留后门:默认密码建超管 + 每次启动写生产 users 表(服务端与客户端各一份)
- 进度/详情接口归属过滤:新增 TaskProgressOwnershipSupport,11 模块 progress/light 与
  /tasks/batch 接入,DTO 补 userId,前端 13 个查询封装补传(未传时后端不过滤,兼容旧端)
- 代理提取链接(含账密)不再明文入日志(新增 common/util/SecretMasking)
- 全局异常兜底不再回传原始异常信息;内部令牌比较改常量时间
- 登录加失败计数与锁定(10 次锁 15 分钟);品牌源文件下载加 SSRF 防护
- AdminApiGuardFilter 覆盖前缀从 2 扩到 15(开关默认 false,行为不变,为收紧做准备)
- 生产关闭 springdoc/knife4j(/doc.html 匿名可读全部接口定义)

正确性
- 40901/40902 拆分:锁竞争不再被伪装成 success=true(此前客户端停止重试、分片静默丢失)
- 假成功收敛:集采明细批量写失败改为抛出、去重 worker 异常标失败、4 个 worker 改判
  success 字段、publish 空 ASIN 行参与批次 flush、巡店删除全失败带 error 上报
- 客户端心跳 discard 移入 finally(7 模块,失败路径不再留僵尸 RUNNING 任务)
- 状态机条件更新:跟价停止循环、集采 activate/fail、imagevideo 归档回填、店铺匹配提交

性能
- 前端入口包 JS 1.05MB→204KB、CSS 355KB→10.7KB(Element Plus 改按需 + el-config-provider)
- 载荷引用计数按指针里的 taskId 收敛(原 JSON 列 IN 全表扫且逐行调用)
- 店铺明细多值批量 INSERT;快照 upsert 预载缓存;结果文件列改单条 UPDATE
- 新增迁移 V120(补 3 个缺失索引)/V121(删 4 个被覆盖的冗余索引)/V122(URL 前缀索引)

稳定性
- 新增 common/util/ThreadPools 有界线程池替换 5 处无界队列(防堆积 OOM)
- Redis 锁释放改 Lua 原子校验(原裸 delete 会误删他人已过期的锁)
- imagevideo 加死节点接管;锁续期失败重试;调度池 4→16;openStream 全部加超时
- 事务内远程对象删除移到提交后;启动恢复锁按实例命名

客户端
- 不再 taskkill /f /im chrome.exe(改为按调试端口精准回收,不杀用户自己的浏览器)
- 密码检测不再无条件杀紫鸟进程;品牌检测加全局互斥(代理池不再互相覆盖)
- base_dir 统一到 exe 目录(原被 os.getcwd() 覆盖,日志/缓存会分裂两个目录)
- 缓存加定时清理;图片下载加超时;mkstemp 句柄托管

测试
- 同步更新受影响的契约测试(构造器签名/条件更新/方法改名/新增接口方法等)
- 修复 FaultInjectionTest 等 3 处 mock 未 stub 流式 read 导致的读循环 OOM
- mvn test 2795 个测试全绿
2026-09-14 04:15:36 +08:00

232 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 版本检测 / 更新共享逻辑(登录页、首页共用)
*
* 服务端化后桌面页面直载线上 Web:检测"是否有新版"需把线上 /api/version/latest
* 与"本机 exe 版本"比较。本机版本经 pywebview 桥 get_app_version() 获取(新版客户端
* 提供);旧客户端无此桥时降级:本机版本为空 → 只要线上有包就允许手动更新。
*/
import { computed, ref } from 'vue'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { selectChangelogEntries, type ClientChangelogEntry } from '@/shared/client-changelog'
import { compareVersions, normVersion } from '@/shared/utils/version-compare'
import {
normalizeUpdateProgress,
type UpdateProgress,
} from '@/shared/utils/update-progress'
export type { UpdateProgress, UpdateProgressStatus } from '@/shared/utils/update-progress'
const updateProgress = ref<UpdateProgress | null>(null)
let progressListenerBound = false
/**
* 绑定一次全局监听:下载进度由 Python 侧主动推事件(同 pywebview-download-progress 约定),
* JS 侧不轮询——桥调用虽然并发安全,但轮询会给主进程平白加请求。
*/
function bindProgressListener() {
if (progressListenerBound || typeof window === 'undefined') return
progressListenerBound = true
window.addEventListener('pywebview-update-progress', (event) => {
const progress = normalizeUpdateProgress((event as CustomEvent<unknown>).detail)
if (!progress) return
updateProgress.value = progress
})
}
async function fetchJson(url: string): Promise<Record<string, unknown>> {
const resp = await window.fetch(url, { credentials: 'same-origin' })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const text = await resp.text()
if (!text) return {}
try {
return JSON.parse(text) as Record<string, unknown>
} catch {
return {}
}
}
/**
* 读取本机客户端版本号:优先桌面桥;无桥(旧版 exe / 浏览器环境)返回空串。
*/
export async function resolveLocalVersion(): Promise<string> {
const bridge = getPywebviewApi()
if (bridge?.get_app_version) {
try {
const res = await bridge.get_app_version()
if (res?.success && res.version) {
return normVersion(res.version)
}
} catch {
/* 忽略,走空版本降级 */
}
}
return ''
}
/** 拉取线上最新客户端版本(公开接口,无需登录;web_config 无记录时 version 为空)。 */
export async function fetchLatestVersion(): Promise<{ version: string; fileUrl: string }> {
const data = await fetchJson('/api/version/latest')
return { version: normVersion(data.version), fileUrl: String(data.file_url ?? '') }
}
export function useVersionUpdate() {
const checking = ref(false)
const updating = ref(false)
const currentVersion = ref('')
const latestVersion = ref('')
const fileUrl = ref('')
const hasUpdate = ref(false)
/** 本地版本未知但线上有包时也允许"立即更新",此标志标记可下载 */
const canDownload = ref(false)
const hint = ref('')
const checked = ref(false)
async function loadCurrentVersion() {
if (currentVersion.value) return
currentVersion.value = await resolveLocalVersion()
}
async function runCheck() {
if (checking.value) return
checking.value = true
hint.value = '正在检测更新...'
try {
const local = await resolveLocalVersion()
currentVersion.value = local
const latest = await fetchLatestVersion()
latestVersion.value = latest.version
fileUrl.value = latest.fileUrl
checked.value = true
if (!latest.version) {
hasUpdate.value = false
canDownload.value = false
hint.value = '暂无版本更新信息'
return
}
if (!local) {
// 旧客户端无本机版本桥:无法精确比较,线上有包即可手动更新
hasUpdate.value = false
canDownload.value = Boolean(latest.fileUrl)
hint.value = latest.fileUrl
? `线上最新版本 v${latest.version},可点击下方按钮手动更新`
: `线上最新版本 v${latest.version},暂无下载地址`
return
}
// 只有线上版本严格高于本机版本才算"有更新":本机领先(灰度/回滚/漏发版)
// 时不能反过来提示更新,否则会把用户往低版本上引导。
const diff = compareVersions(latest.version, local)
hasUpdate.value = diff > 0
canDownload.value = hasUpdate.value && Boolean(latest.fileUrl)
hint.value = hasUpdate.value
? `发现新版本 v${latest.version}${latest.fileUrl ? '' : ',暂无下载地址'}`
: diff < 0
? `当前版本 v${local} 高于线上发布版本 v${latest.version},无需更新`
: `已是最新版本 v${local}`
} catch {
hasUpdate.value = false
canDownload.value = false
hint.value = '检测更新失败,请稍后重试'
} finally {
checking.value = false
}
}
async function doUpdate() {
if (updating.value || !fileUrl.value) return
const bridge = getPywebviewApi()
const doUpdateApp = bridge?.do_update_app
const desktopUpdate = Boolean(doUpdateApp)
const tip = desktopUpdate
? `有更新,是否现在更新?${changelogConfirmSuffix()}\n更新将下载安装包并重启程序。`
: `发现新版本,是否下载最新安装包?${changelogConfirmSuffix()}`
if (!window.confirm(tip)) return
updating.value = true
if (!desktopUpdate) {
// 网页/无桌面桥形态:OSS 公开直链直接下载安装包(区别于桌面端自动下载安装)
hint.value = '开始下载安装包,若浏览器未响应请再次点击...'
window.location.href = fileUrl.value
updating.value = false
return
}
// 先挂监听再发桥调用:进度事件在下载开始后立刻开推,晚绑会丢最前面的进度
bindProgressListener()
updateProgress.value = {
status: 'downloading',
message: '正在准备下载更新包...',
downloaded: 0,
total: 0,
percent: -1,
}
hint.value = '正在下载并准备更新,程序将自动退出...'
try {
const result = await doUpdateApp!(fileUrl.value)
if (result?.success) {
const last = updateProgress.value
updateProgress.value = {
status: 'ready',
message: '安装包已就绪,正在启动更新程序,程序即将退出...',
downloaded: last?.downloaded || 0,
total: last?.total || 0,
percent: 100,
}
hint.value = '更新已启动,程序即将退出...'
} else {
// 失败:进度条停在断点处,重试将从断点继续(Python 侧保留断点文件)
const message = result?.error || '更新启动失败,请重试'
updateProgress.value = {
status: 'failed',
message,
downloaded: updateProgress.value?.downloaded || 0,
total: updateProgress.value?.total || 0,
percent: updateProgress.value?.percent ?? -1,
}
hint.value = message
}
} catch {
updateProgress.value = {
status: 'failed',
message: '请求更新失败,请重试',
downloaded: updateProgress.value?.downloaded || 0,
total: updateProgress.value?.total || 0,
percent: updateProgress.value?.percent ?? -1,
}
hint.value = '请求更新失败,请重试'
} finally {
updating.value = false
}
}
// 进度条的百分比/文案由 UpdateProgressBar 组件从 progress 自行派生(见 shared/utils/update-progress
const progress = computed(() => updateProgress.value)
// 更新日志:本机版本之后到最新版本之间的条目(文案见 shared/client-changelog
const changelog = computed<ClientChangelogEntry[]>(() =>
selectChangelogEntries({ currentVersion: currentVersion.value, latestVersion: latestVersion.value }),
)
/** 更新确认框里的"本次更新内容":只取最新一条、最多 5 行,避免弹窗过长 */
function changelogConfirmSuffix(): string {
const items = (changelog.value[0]?.items ?? []).slice(0, 5)
return items.length ? `\n本次更新内容:\n${items.map((item) => ${item}`).join('\n')}` : ''
}
return {
checking,
updating,
currentVersion,
latestVersion,
fileUrl,
hasUpdate,
canDownload,
hint,
checked,
progress,
changelog,
loadCurrentVersion,
runCheck,
doUpdate,
}
}