Files
verdaccio-cache-manager/backend/server.js
T
DuMate 552eb0df3f
CI / Backend tests (push) Canceled after 0s
CI / Frontend build (push) Canceled after 0s
fix(scan): 登录后首次拉包自动触发扫描
ensureScan 原本只在每日自动扫描被调用,/api/packages 未接线,
导致部署重启后 rawCache 为空、登录拉包返回空列表且无扫描动作,
必须手动点「重新扫描」。现在 /api/packages 首次访问懒触发扫描,
返回 scanning 状态供前端轮询,登录后即可自动扫描。
2026-09-22 00:50:41 +08:00

700 lines
25 KiB
JavaScript
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.
/**
* Verdaccio 缓存可视化管理面板 — 后端服务
* Express + better-sqlite3,§4.2 REST API
*
* 环境变量:
* VERDACCIO_STORAGE Verdaccio 缓存目录(优先于 UI 设置)
* PORT 监听端口(默认 3000)
* DATA_DIR SQLite 数据目录(默认 backend/data)
* VCM_USERNAME 面板登录账号(默认 admin)
* VCM_PASSWORD 面板登录密码(默认 666666,Docker 部署务必通过 compose 覆盖)
* UV_THREADPOOL_SIZE libuv IO 线程池大小(默认 4 → 8:扫描/stat/readFile 并发更高,NAS 四核安全)
*/
// 必须在 libuv 线程池首次使用前设置:扫描大量目录/文件时默认 4 线程是主要瓶颈
process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8'
import express from 'express'
import path from 'node:path'
import fs from 'node:fs'
import { randomUUID } from 'node:crypto'
import { fileURLToPath } from 'node:url'
import {
getSetting, setSetting, getIgnored, getIgnoredRows, addIgnored, removeIgnored,
addLog, getLogs,
} from './db.js'
import {
resolveStorageDir, isValidStorage, validateStorage, scanStorage, scanStorageAsync,
demoPackages, demoSummary, clearPkgCache,
} from './scanner.js'
import { cleanPackage, deleteSingleVersion } from './clean.js'
import semverCompare from './semver.js'
import { syncState, runSyncLatest } from './sync-latest.js'
import {
seedIfNeeded, countPending, getPkgList, addPkg, editPkg, removePkg,
getConfig, saveConfig, startPkgListDaemon, updateFromAllSources, dedupPkgList,
} from './pkglist.js'
import { pkgPullState, runPkgListPull } from './pkglist-pull.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express()
app.use(express.json({ limit: '1mb' }))
const PORT = Number(process.env.PORT || 3000)
const DEFAULT_VERSION_THRESHOLD = 15
const DEFAULT_SIZE_THRESHOLD = 500
// —— 当前生效的 storage 目录(每次请求动态解析,UI 保存后立即生效) ——
function currentStorage() {
return resolveStorageDir()
}
function isDemoMode() {
return process.env.VCM_DEMO === '1' || !isValidStorage(currentStorage())
}
// —— 扫描结果缓存:真实目录全量扫描 IO 重,缓存避免每次请求/刷新页面重扫 ——
let rawCache = null
let rawCacheAt = null
// —— 异步扫描状态(后台重扫时供前端轮询进度) ——
const scanState = {
scanning: false, done: false, packages: 0, bytes: 0, tgzBytes: 0, diskBytes: null,
startedAt: null, finishedAt: null, error: null,
}
/** 磁盘占用(跨平台):递归统计目录内所有文件字节和
* 原实现 execSync('du -sk') 依赖 Linux du,Windows 下不可用;
* 改为 Node 原生递归统计,语义与「文件字节和」一致,避免外部命令差异 */
async function diskBytesOf(dir) {
let total = 0
async function walk(d) {
let entries
try {
entries = await fs.promises.readdir(d, { withFileTypes: true })
} catch {
return
}
await Promise.all(entries.map(async e => {
if (e.name === 'node_modules' || e.name.startsWith('.') || e.name === '.DS_Store') return
const full = path.join(d, e.name)
if (e.isDirectory()) {
await walk(full)
} else {
try {
const st = await fs.promises.stat(full)
total += st.size
} catch {}
}
}))
}
try {
await walk(dir)
return total
} catch {
return null
}
}
async function runScanAsync() {
scanState.scanning = true
scanState.done = false
scanState.error = null
scanState.packages = 0
scanState.bytes = 0
scanState.tgzBytes = 0
scanState.diskBytes = null
scanState.startedAt = new Date().toISOString()
const storage = currentStorage()
console.log(`[scan] 开始扫描存储目录: ${storage}`)
try {
// 分片异步扫描:期间 /api/scan/progress 等请求仍可被响应
rawCache = await scanStorageAsync(storage, (p) => {
scanState.packages = p.packages
scanState.bytes = p.bytes // 目录内所有文件字节和
scanState.tgzBytes = p.tgzBytes || 0 // 仅 .tgz
})
rawCacheAt = new Date().toISOString()
scanState.done = true
scanState.finishedAt = rawCacheAt
scanState.diskBytes = await diskBytesOf(storage) // 磁盘占用(跨平台字节和)
console.log('[scan] 完成 ' + JSON.stringify({
storage,
packages: rawCache.length,
tgzBytes: scanState.tgzBytes,
bytes: scanState.bytes,
diskBytes: scanState.diskBytes,
}))
} catch (e) {
scanState.error = e.message
console.error('[scan] 扫描失败:', e.message)
} finally {
scanState.scanning = false
}
}
// 确保扫描已启动(缓存缺失时懒触发,不阻塞当前请求)
function ensureScan() {
if (isDemoMode()) return
if (rawCache || scanState.scanning) return
runScanAsync()
}
function getRawPackages() {
return rawCache || []
}
function invalidateScan() {
rawCache = null
rawCacheAt = null
clearPkgCache() // storage 目录切换 / 清理动作后废弃增量缓存,避免旧目录条目残留
}
// 构建包列表(统一状态计算 + 排序 + 忽略标记)
function buildPackages(keyword = '') {
const threshold = Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD))
const ignoredRows = getIgnoredRows()
const ignoredSet = new Set(ignoredRows.map(r => r.pkg_name))
const ignoredAtMap = Object.fromEntries(ignoredRows.map(r => [r.pkg_name, (r.ignored_at || '').slice(0, 10)]))
const raw = getRawPackages() || []
let list = raw.map(p => {
let status
if (p.status === 'broken') status = 'broken'
else if (ignoredSet.has(p.name)) status = 'ignored'
else status = p.versions > threshold ? 'warn' : 'ok'
return {
name: p.name,
versions: p.versions,
size: p.size,
lastUpdated: p.lastUpdated,
visits: p.visits || 0,
distTags: p.distTags || {},
status,
ignoredAt: ignoredAtMap[p.name] || null,
description: p.description || null,
homepage: p.homepage || null,
// 列表不携带 versionsDetail(包多时响应巨大拖慢加载),详情走 GET /api/packages/:name
}
})
// 检索:不区分大小写子串,匹配包名(版本号匹配由前端本地完成,避免列表携带明细)
const kw = keyword.trim().toLowerCase()
if (kw) {
list = list.filter(p => p.name.toLowerCase().includes(kw) || String(p.size).includes(kw))
}
// 排序:预警置顶 → 正常 → 忽略 → 异常;同状态按版本数降序
const rank = { warn: 0, ok: 1, ignored: 2, broken: 3 }
list.sort((a, b) => {
if (rank[a.status] !== rank[b.status]) return rank[a.status] - rank[b.status]
return b.versions - a.versions
})
return list
}
// —— 登录鉴权:内置账号(Docker 部署通过 VCM_USERNAME/VCM_PASSWORD 环境变量覆盖) ——
const AUTH_USERNAME = process.env.VCM_USERNAME || 'admin'
const AUTH_PASSWORD = process.env.VCM_PASSWORD || '666666'
const SESSION_TTL_MS = 12 * 3600_000 // 会话有效期 12 小时,防长期 token 泄漏
const LOGIN_LOCK = { failures: 3, windowMs: 5 * 60_000, lockMs: 15 * 60_000 } // 3 次失败锁 15 分钟
const sessions = new Map() // token -> { name, at, exp };内存态,进程重启后需重新登录
const loginFail = new Map() // ip -> { count, firstAt, lockedUntil }
// 登录限速:滑动窗口内失败达阈值即锁定(按客户端 IP)
function loginGuard(ip) {
const now = Date.now()
const rec = loginFail.get(ip)
if (!rec) return { locked: false }
if (rec.lockedUntil && now < rec.lockedUntil) {
return { locked: true, retryAfter: Math.ceil((rec.lockedUntil - now) / 1000) }
}
if (rec.firstAt && now - rec.firstAt > LOGIN_LOCK.windowMs) {
loginFail.delete(ip) // 窗口过期重置
return { locked: false }
}
return { locked: false }
}
function recordLoginFail(ip) {
const now = Date.now()
let rec = loginFail.get(ip)
if (!rec || now - rec.firstAt > LOGIN_LOCK.windowMs) rec = { count: 0, firstAt: now, lockedUntil: null }
rec.count += 1
if (rec.count >= LOGIN_LOCK.failures) {
rec.lockedUntil = now + LOGIN_LOCK.lockMs
console.warn(`[auth] IP ${ip} 登录失败 ${rec.count} 次,锁定 ${LOGIN_LOCK.lockMs / 60000} 分钟`)
}
loginFail.set(ip, rec)
}
function recordLoginOk(ip) {
loginFail.delete(ip)
}
app.post('/api/login', (req, res) => {
const { username, password } = req.body || {}
const ip = req.ip || req.socket.remoteAddress || 'unknown'
const guard = loginGuard(ip)
if (guard.locked) {
return res.status(429).json({ ok: false, message: `尝试过于频繁,请 ${guard.retryAfter} 秒后再试` })
}
if (String(username || '') !== AUTH_USERNAME || String(password || '') !== AUTH_PASSWORD) {
recordLoginFail(ip)
const rec = loginFail.get(ip)
const left = rec && rec.lockedUntil ? Math.ceil((rec.lockedUntil - Date.now()) / 1000) : 0
return res.status(401).json({
ok: false,
message: left > 0 ? `用户名或密码错误,已触发 ${left} 秒锁定` : '用户名或密码错误',
})
}
recordLoginOk(ip)
const token = randomUUID()
const now = Date.now()
sessions.set(token, { name: AUTH_USERNAME, at: now, exp: now + SESSION_TTL_MS })
res.json({ ok: true, token, user: { name: AUTH_USERNAME } })
})
app.post('/api/logout', (req, res) => {
const auth = req.headers.authorization || ''
if (auth.startsWith('Bearer ')) sessions.delete(auth.slice(7))
res.json({ ok: true })
})
// 保护全部 /api/*(/login、/logout 除外):未登录或会话过期返回 401,前端据此跳转登录页
app.use('/api', (req, res, next) => {
if (req.path === '/login' || req.path === '/logout') return next()
const auth = req.headers.authorization || ''
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
const sess = token ? sessions.get(token) : null
if (!sess) {
return res.status(401).json({ ok: false, message: '未登录或会话已失效' })
}
if (Date.now() > sess.exp) {
sessions.delete(token) // 过期 token 立即作废
return res.status(401).json({ ok: false, message: '登录已过期,请重新登录' })
}
next()
})
// —— API:阈值 ——
// spec §4.2:GET/POST /api/threshold(版本阈值)
app.get('/api/threshold', (req, res) => {
res.json({ versionThreshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)) })
})
app.post('/api/threshold', (req, res) => {
// 兼容 versionThreshold / value 两种字段名
const v = clampInt(req.body.versionThreshold ?? req.body.value, 1, 100)
setSetting('version_threshold', v)
res.json({ ok: true, versionThreshold: v })
})
// 扩展:GET/POST /api/thresholds(版本 + 大小 + 单包例外)
app.get('/api/thresholds', (req, res) => {
let exceptions = []
try { exceptions = JSON.parse(getSetting('exceptions', '[]')) } catch { exceptions = [] }
res.json({
versionThreshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)),
sizeThreshold: Number(getSetting('size_threshold', DEFAULT_SIZE_THRESHOLD)),
exceptions,
})
})
app.post('/api/thresholds', (req, res) => {
const { versionThreshold, sizeThreshold, exceptions } = req.body || {}
setSetting('version_threshold', clampInt(versionThreshold, 1, 100))
setSetting('size_threshold', clampInt(sizeThreshold, 1, 10000))
const ex = Array.isArray(exceptions)
? exceptions.map(e => ({
pkgName: String(e.pkgName || '').trim(),
versionThreshold: clampInt(e.versionThreshold, 1, 100),
sizeThreshold: clampInt(e.sizeThreshold, 1, 10000),
})).filter(e => e.pkgName)
: []
setSetting('exceptions', JSON.stringify(ex))
res.json({ ok: true })
})
// —— API:忽略 ——
app.get('/api/ignored', (req, res) => {
res.json(getIgnored())
})
app.post('/api/ignore', (req, res) => {
const { pkgName } = req.body || {}
if (!pkgName) return res.status(400).json({ ok: false, message: '缺少 pkgName' })
addIgnored(String(pkgName))
addLog({ action: 'ignore', pkgName: String(pkgName) })
res.json({ ok: true })
})
app.delete('/api/ignore/:pkgName', (req, res) => {
const pkgName = decodeURIComponent(req.params.pkgName)
removeIgnored(pkgName)
addLog({ action: 'unignore', pkgName })
res.json({ ok: true })
})
// —— API:包列表(扫描) ——
app.get('/api/packages', (req, res) => {
const demo = isDemoMode()
const storage = currentStorage()
const validation = demo ? { ok: true, message: 'demo' } : validateStorage(storage)
ensureScan() // 懒触发:登录后首次拉包自动启动扫描(rawCache 缺失时),返回 scanning 状态供前端轮询
const packages = buildPackages(req.query.keyword || '') // 先构建(填充缓存)
res.json({
demo,
storageDir: storage || null,
storageValid: validation.ok,
storageMessage: validation.message,
threshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)),
scannedAt: demo ? new Date().toISOString() : (rawCacheAt || null),
scanning: scanState.scanning,
scanPackages: scanState.packages,
scanBytes: scanState.bytes,
totalBytes: demo ? null : (scanState.diskBytes ?? scanState.bytes), // 磁盘占用(跨平台字节和)
packages,
...(demo ? demoSummary() : {}),
})
})
// —— API:单包详情(含版本明细,抽屉打开时按需加载)
// 只读现有扫描缓存,绝不触发同步扫描(缓存被失效时直接 404,
// 前端用列表数据兜底 + 提示,避免打开抽屉卡几十秒) ——
app.get('/api/packages/:pkgName', (req, res) => {
const name = decodeURIComponent(req.params.pkgName)
const raw = isDemoMode() ? demoPackages() : (rawCache || null)
const p = raw ? raw.find(x => x.name === name) : null
if (!p) return res.status(404).json({ ok: false, message: '包不存在或正在扫描中' })
const threshold = Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD))
const ignoredSet = new Set(getIgnoredRows().map(r => r.pkg_name))
let status
if (p.status === 'broken') status = 'broken'
else if (ignoredSet.has(p.name)) status = 'ignored'
else status = p.versions > threshold ? 'warn' : 'ok'
res.json({ ok: true, pkg: { ...p, status } })
})
// —— API:手动重新扫描缓存目录(异步,扫描期间前端轮询进度) ——
app.post('/api/scan', (req, res) => {
invalidateScan()
const demo = isDemoMode()
if (demo) {
const scannedAt = new Date().toISOString()
return res.json({ ok: true, demo, scanning: false, scannedAt })
}
if (!scanState.scanning) runScanAsync() // 后台启动,不阻塞响应
res.json({
ok: true,
demo: false,
scanning: scanState.scanning,
startedAt: scanState.startedAt,
packages: scanState.packages,
bytes: scanState.bytes,
})
})
// —— API:扫描进度(重扫/首扫期间由前端轮询) ——
app.get('/api/scan/progress', (req, res) => {
res.json({
scanning: scanState.scanning,
done: scanState.done,
packages: scanState.packages,
bytes: scanState.bytes,
tgzBytes: scanState.tgzBytes,
diskBytes: scanState.diskBytes,
error: scanState.error,
startedAt: scanState.startedAt,
finishedAt: scanState.finishedAt,
})
})
// —— API:一键拉取存量包最新版本(异步,期间前端轮询进度) ——
app.post('/api/sync/latest', (req, res) => {
if (isDemoMode()) {
return res.json({ ok: true, demo: true, running: false, message: '演示模式:未配置 Verdaccio 缓存目录,不支持拉取' })
}
if (!syncState.running) {
runSyncLatest({
addLog,
invalidateScan,
})
}
res.json({
ok: true,
demo: false,
running: syncState.running,
total: syncState.total,
startedAt: syncState.startedAt,
})
})
// —— API:拉取进度(拉取期间由前端轮询) ——
app.get('/api/sync/progress', (req, res) => {
res.json({
running: syncState.running,
done: syncState.done,
total: syncState.total,
processed: syncState.processed,
success: syncState.success,
skipped: syncState.skipped,
failed: syncState.failed,
failedList: syncState.failedList,
current: syncState.current,
error: syncState.error,
startedAt: syncState.startedAt,
finishedAt: syncState.finishedAt,
})
})
// —— API:npm 包清单(热门包缓存扩充) -----
// 清单维护:查看 / 新增 / 编辑 / 删除(含 valid 性校验与操作日志)
app.get('/api/pkglist', (req, res) => {
res.json({ ok: true, ...getPkgList() })
})
app.post('/api/pkglist', (req, res) => {
const r = addPkg(req.body || {})
if (!r.ok) return res.status(400).json(r)
res.json({ ok: true, ...getPkgList() })
})
app.put('/api/pkglist/:id', (req, res) => {
const r = editPkg(req.params.id, req.body || {})
if (!r.ok) return res.status(400).json(r)
res.json({ ok: true, ...getPkgList() })
})
app.delete('/api/pkglist/:id', (req, res) => {
const r = removePkg(req.params.id)
if (!r.ok) return res.status(400).json(r)
res.json({ ok: true, ...getPkgList() })
})
// —— API:清单手动去重(将 storage 已缓存但清单仍为 pending 的条目置 done,避免重复拉取) ——
app.post('/api/pkglist/dedup', (req, res) => {
const r = dedupPkgList()
res.json({ ok: true, ...r, ...getPkgList() })
})
// —— API:按清单手动拉取(同步启动后台任务,不阻塞;失败自动重试 3 次;清单失效时提示) ——
app.post('/api/pkglist/pull', (req, res) => {
if (isDemoMode()) {
return res.json({ ok: true, demo: true, running: false, message: '演示模式:未配置 Verdaccio 缓存目录,不支持拉取' })
}
// 清单失效(全部条目已成功拉取)→ 当前无可用 npm 包
if (countPending() === 0) {
return res.json({ ok: true, active: false, running: false, message: '当前无可用npm包' })
}
const r = runPkgListPull({ addLog, invalidateScan, source: 'manual' })
res.json({
ok: true, demo: false,
running: r.started ? true : pkgPullState.running,
active: r.active,
pendingCount: r.pendingCount,
startedAt: pkgPullState.startedAt,
})
})
// —— API:按清单拉取进度(拉取期间由前端轮询;含重试轮次与失败明细) ——
app.get('/api/pkglist/progress', (req, res) => {
res.json({
running: pkgPullState.running,
done: pkgPullState.done,
total: pkgPullState.total,
processed: pkgPullState.processed,
success: pkgPullState.success,
failed: pkgPullState.failed,
failedList: pkgPullState.failedList,
round: pkgPullState.round,
current: pkgPullState.current,
source: pkgPullState.source,
error: pkgPullState.error,
startedAt: pkgPullState.startedAt,
finishedAt: pkgPullState.finishedAt,
})
})
// —— API:清单定时更新 / 定时拉取配置(右上角用户名下拉入口读写) ——
app.get('/api/pkglist/config', (req, res) => {
res.json({ ok: true, config: getConfig() })
})
app.post('/api/pkglist/config', (req, res) => {
const r = saveConfig(req.body || {})
if (!r.ok) return res.status(400).json(r)
res.json({ ok: true, config: r.config })
})
// —— API:清理 ——
app.post('/api/clean', (req, res) => {
const { pkgName, keep } = req.body || {}
if (!pkgName) return res.status(400).json({ ok: false, message: '缺少 pkgName' })
if (isDemoMode()) {
return demoClean(res, pkgName, clampInt(keep, 1, 999))
}
const storage = currentStorage()
const result = cleanPackage(storage, pkgName, clampInt(keep, 1, 999))
if (result.ok) {
addLog({
pkgName,
deletedVersions: result.deletedVersions,
deletedCount: result.deletedCount,
freedSpace: result.freedSpace,
})
invalidateScan()
}
res.json(result)
})
// —— API:单版本删除 ——
app.delete('/api/packages/:pkgName/versions/:version', (req, res) => {
const { pkgName, version } = req.params
if (isDemoMode()) {
return demoDeleteVersion(res, pkgName, version)
}
const storage = currentStorage()
const result = deleteSingleVersion(storage, pkgName, version)
if (result.ok) {
addLog({
pkgName,
deletedVersions: version,
deletedCount: 1,
freedSpace: result.freedSpace,
})
invalidateScan()
}
res.json(result)
})
// —— API:日志 ——
app.get('/api/logs', (req, res) => {
res.json(getLogs(50))
})
// —— API:storage 配置(UI 设置页) ——
app.get('/api/storage', (req, res) => {
const storage = currentStorage()
const demo = isDemoMode()
res.json({
storageDir: storage,
envDir: process.env.VERDACCIO_STORAGE || null,
demo,
validation: demo ? { ok: true, message: 'demo' } : validateStorage(storage),
})
})
app.post('/api/storage', (req, res) => {
const { storageDir } = req.body || {}
if (!storageDir) return res.status(400).json({ ok: false, message: '缺少 storageDir' })
const v = validateStorage(String(storageDir))
if (!v.ok) return res.status(400).json(v)
setSetting('storage_dir', String(storageDir))
res.json({ ok: true })
})
// —— 生产:托管前端 dist ——
const distDir = path.join(__dirname, '..', 'frontend', 'dist')
if (fs.existsSync(distDir)) {
app.use(express.static(distDir))
app.get('*', (req, res) => {
if (req.path.startsWith('/api/')) return res.status(404).json({ ok: false, message: 'not found' })
res.sendFile(path.join(distDir, 'index.html'))
})
}
app.listen(PORT, () => {
const storage = resolveStorageDir()
const mode = isDemoMode() ? 'demo' : 'storage'
if (!process.env.VCM_PASSWORD) {
console.warn(
'[auth] ⚠ 未设置 VCM_PASSWORD 环境变量,正在使用默认口令 666666。' +
'生产部署请务必通过 docker-compose 的环境变量覆盖,否则面板可被任意登录!',
)
}
console.log(`[verdaccio-manager] listening on :${PORT} (mode=${mode})`)
if (mode === 'storage') console.log(`[verdaccio-manager] storage: ${storage}`)
else console.log('[verdaccio-manager] 未配置 VERDACCIO_STORAGE,运行于演示模式(设置 VCM_DEMO=0 + 真实目录可关闭)')
scheduleDailyScan()
// —— npm 包清单:初始化内置热门包 + 启动定时守护(定时更新收录 / 定时按清单拉取) ——
try {
seedIfNeeded()
startPkgListDaemon({
// 定时更新清单:内置热门池 ∪ npm-search 榜单,按包名去重后补入(pending)
runAutoUpdate() {
updateFromAllSources().then(({ added, rankSource }) => {
addLog({
action: 'list-auto',
pkgName: null,
deletedCount: added,
deletedVersions: JSON.stringify({
note: rankSource ? `定时收录热门 npm 包(榜单源 ${rankSource})` : '定时收录热门 npm 包(npm-search 榜单不可用,仅内置池)',
}),
})
})
},
// 定时拉取:清单已失效(无 pending)时自动停止,直到清单写入新包
runAutoPull() {
if (isDemoMode()) return
runPkgListPull({ addLog, invalidateScan, source: 'auto' })
},
})
} catch (e) {
console.error('[pkglist] 初始化失败:', e.message)
}
})
// 清理登录限速表:超过窗口 + 锁定期的记录不再保留(防 Map 无限增长)
setInterval(() => {
const now = Date.now()
for (const [ip, rec] of loginFail) {
const stale = rec.lockedUntil
? now > rec.lockedUntil
: now - rec.firstAt > LOGIN_LOCK.windowMs
if (stale) loginFail.delete(ip)
}
}, 10 * 60_000).unref()
// —— 每天 00:00(服务器本地时区)自动重扫缓存,逻辑同 POST /api/scan ——
function scheduleDailyScan() {
const now = new Date()
const next = new Date(now)
next.setHours(24, 0, 0, 0) // 下个本地零点
const wait = next.getTime() - now.getTime()
setTimeout(() => {
try {
invalidateScan()
ensureScan() // 后台异步重扫,不阻塞事件循环
console.log(`[auto-scan] ${new Date().toISOString()} 每日自动扫描已启动`)
} catch (e) {
console.error('[auto-scan] 每日自动扫描失败:', e.message)
}
scheduleDailyScan() // 安排次日零点
}, wait)
}
function clampInt(v, min, max) {
const n = Number(v)
if (Number.isNaN(n)) return min
return Math.min(max, Math.max(min, Math.round(n)))
}
// —— demo 模式下的清理/删除(内存态) ——
const demoMemory = { logs: [] }
function demoClean(res, pkgName, keep) {
const p = demoPackages().find(x => x.name === pkgName)
if (!p) return res.json({ ok: false, message: '包不存在' })
const sorted = [...p.versionsDetail].sort((a, b) => semverCompare(a.version, b.version))
const toDel = sorted.slice(0, Math.max(0, sorted.length - keep))
if (!toDel.length) return res.json({ ok: false, message: '无需清理' })
const freed = toDel.reduce((s, v) => s + v.size, 0)
const result = {
ok: true,
pkgName,
deletedVersions: toDel.map(v => v.version).join(', '),
deletedCount: toDel.length,
freedSpace: `${freed.toFixed(1)} MB`,
}
// demo 日志并入 logs 表,保持一致
addLog({ pkgName, deletedVersions: result.deletedVersions, deletedCount: result.deletedCount, freedSpace: result.freedSpace })
res.json(result)
}
function demoDeleteVersion(res, pkgName, version) {
const p = demoPackages().find(x => x.name === pkgName)
const v = p?.versionsDetail?.find(x => x.version === version)
if (!v) return res.json({ ok: false, message: '版本不存在' })
addLog({ pkgName, deletedVersions: version, deletedCount: 1, freedSpace: `${v.size.toFixed(1)} MB` })
res.json({ ok: true, freedSpace: `${v.size.toFixed(1)} MB` })
}