14 KiB
操作日志功能改造 — 实施方案
Context(背景与目标)
现有 logs 表字段(action/pkg_name/deleted_versions/deleted_count/freed_space/created_at)专为「清理低版本」设计,无法承载用户要求的 6 类日志。批量拉取目前只写一条汇总(action='sync'),手工拉取、扫描、登录均未落日志。本次改造扩展表结构 + 改造 sync-latest 为逐包写入 + 新增 3 类日志写入点 + 新增手工拉取入口。
用户确认的 4 个决策点
- 登录:成功 + 失败均记一条(含 IP、用户名、失败原因)
- 扫描:手动扫描 + 每日定时扫描 + 补扫均记一条
- 批量拉取:逐包 N 条(
action='sync_latest')+ 汇总 1 条(保留action='sync') - 手工拉取入口:放在 SearchView.vue 「我的缓存」表格右上角
数据库改动 — backend/db.js
表结构扩展(迁移幂等)
参照 migrateTimezone 模式,新增 migrateLogsV2():
ALTER TABLE logs ADD COLUMN version TEXT; -- 单版本操作目标版本
ALTER TABLE logs ADD COLUMN result TEXT; -- 'success' | 'fail' | 'skipped'
ALTER TABLE logs ADD COLUMN message TEXT; -- 详细信息/失败原因
ALTER TABLE logs ADD COLUMN duration_ms INTEGER; -- 任务耗时(扫描/批量拉取汇总)
ALTER TABLE logs ADD COLUMN meta TEXT; -- JSON 扩展字段
ALTER TABLE logs ADD COLUMN ip TEXT; -- 登录 IP
迁移前用 PRAGMA table_info(logs) 检查列是否已存在,避免重复 ALTER。迁移完成后写入 settings.logs_v2_migrated = '1'。
addLog 签名扩展
export function addLog({
action = 'clean',
pkgName = null,
deletedVersions = null, // clean 用:删除的版本号 CSV
deletedCount = 0, // clean 用
freedSpace = null, // clean 用
version = null, // 单版本操作目标版本
result = null, // 'success' | 'fail' | 'skipped'
message = null,
durationMs = null,
meta = null, // 对象,写入前 JSON.stringify
ip = null,
} = {})
写入逻辑:插入 7 个新列(NULL 兼容旧库);meta 字段 JSON.stringify 后存。返回 lastInsertRowid。
getLogs 扩展
export function getLogs({ action = null, limit = 50 } = {}) {
if (action) {
return db.prepare('SELECT * FROM logs WHERE action = ? ORDER BY id DESC LIMIT ?').all(action, limit)
}
return db.prepare('SELECT * FROM logs ORDER BY id DESC LIMIT ?').all(limit)
}
兼容旧调用 getLogs(50):检测首参为数字时走旧路径。新增 getLogActions() 返回 SELECT DISTINCT action FROM logs 供前端筛选 tab 使用。
后端日志写入点
1. 登录日志 — backend/server.js /api/login
参照 server.js L247-268 现有端点:
- 成功分支(生成 token 后):
addLog({ action: 'login', pkgName: username, result: 'success', ip, meta: { userAgent: req.headers['user-agent'] } }) - 失败分支(密码错误):
addLog({ action: 'login', pkgName: String(username || ''), result: 'fail', message: '密码错误', ip }) - 锁定触发(429 响应前):
addLog({ action: 'login', pkgName: String(username || ''), result: 'fail', message: '触发锁定', ip, meta: { retryAfter: guard.retryAfter } }) - 退出登录(/api/logout):可选不记(避免日志膨胀)
2. 扫描日志 — backend/server.js runScanAsync
参照 server.js L93-138 现有函数,在 finally 块末尾、pendingDailyScan 处理之前写日志:
const duration = Date.now() - new Date(scanState.startedAt).getTime()
addLog({
action: 'scan',
result: scanState.error ? 'fail' : 'success',
message: scanState.error || `扫描完成 ${scanState.packages} 个包`,
durationMs: duration,
meta: {
packages: scanState.packages,
bytes: scanState.bytes,
tgzBytes: scanState.tgzBytes,
diskBytes: scanState.diskBytes,
triggeredBy: scanState._triggerSource || 'manual', // 区分手动/定时
},
})
_triggerSource 字段在 runScanAsync 入口处由调用方设置:手动 /api/scan 设 'manual',scheduleDailyScan 设 'daily',补扫设 'pending'。
3. 批量拉取逐包日志 — backend/sync-latest.js
参照 sync-latest.js L95-119 handleOne 函数,每个包处理后立即写一条:
| 情况 | action | pkgName | version | result | message |
|---|---|---|---|---|---|
| 私服无此包 | sync_latest | pkg.name | — | fail | '私服无此包(404)' |
| 私服未提供 latest | sync_latest | pkg.name | — | fail | '私服未提供 latest 版本' |
| 已是最新 | sync_latest | pkg.name | latest | skipped | '已是最新版本,跳过' |
| 拉取成功 | sync_latest | pkg.name | latest | success | 已拉取 ${latest} |
| 拉取失败 | sync_latest | pkg.name | latest | fail | e.message |
由于 handleOne 当前不接收 addLog,需扩展 runSyncLatest 注入参数签名:runSyncLatest({ addLog, invalidateScan }) 已有 addLog,传递给 handleOne。
4. 批量拉取汇总日志(保留)
参照 sync-latest.js L156-165 现有汇总写入,仅调整字段以兼容新表:
addLog({
action: 'sync',
pkgName: null,
result: syncState.error ? 'fail' : 'success',
message: `批量拉取完成:成功 ${syncState.success} · 跳过 ${syncState.skipped} · 失败 ${syncState.failed}`,
durationMs: Date.now() - new Date(syncState.startedAt).getTime(),
meta: { success: syncState.success, skipped: syncState.skipped, failed: syncState.failedList },
// 兼容字段:保留 deletedVersions 存 JSON(前端旧逻辑兼容)
deletedVersions: JSON.stringify({ success: syncState.success, skipped: syncState.skipped, failed: syncState.failedList }),
})
5. 手工拉取 — backend/server.js 新增 POST /api/pull
app.post('/api/pull', async (req, res) => {
const { pkgName, version } = req.body || {}
if (!pkgName || !version) return res.status(400).json({ ok: false, message: '缺少 pkgName 或 version' })
if (isDemoMode()) return res.json({ ok: false, message: '演示模式不支持拉取' })
try {
const { triggerVerdaccio } = await import('./sync-latest.js')
await triggerVerdaccio(pkgName, version)
addLog({ action: 'pull_manual', pkgName, version, result: 'success', message: `已拉取 ${pkgName}@${version}` })
invalidateScan()
res.json({ ok: true })
} catch (e) {
addLog({ action: 'pull_manual', pkgName, version, result: 'fail', message: e.message })
res.json({ ok: false, message: e.message })
}
})
前置条件:在 sync-latest.js 中 export { triggerVerdaccio }。
6. Top200 拉取日志 — 依赖 npm-toplist.js 模块(独立方案已规划)
runTopListPull 内每个包处理写一条 action='pull_toplist',字段同 pull_manual;汇总写一条 action='pull_toplist_summary'。本方案不重复实现,留待 npm-toplist 模块开发时按本约定写入。
7. 单版本删除日志(已有,仅 action 区分)
server.js L485-502 DELETE /api/packages/:pkgName/versions/:version 当前用默认 action='clean',改为 action='delete_version',并填 version、result: 'success'、message: '已删除 ${version}'。详情弹窗按 action 分支展示。
前端改动
SearchView.vue 新增「拉取指定包」表单
参照 SearchView.vue 表格右上角位置(与搜索框同行或上方),新增:
<el-popover trigger="click" placement="bottom-end" width="320">
<template #reference>
<button class="btn primary">📥 拉取指定包</button>
</template>
<div class="pull-form">
<input v-model="pullName" placeholder="包名,如 lodash 或 @antv/g2" />
<input v-model="pullVersion" placeholder="版本号,如 4.17.21" />
<button :disabled="pulling" @click="submitPull">{{ pulling ? '拉取中…' : '开始拉取' }}</button>
</div>
</el-popover>
提交时调用 api.pullPackage(name, version) → POST /api/pull → 成功后 ElMessage 提示 + refreshLogs()。
LogsView.vue 改造
参照 LogsView.vue 现有结构:
actionMeta 扩展(L14-29)
function actionMeta(action) {
const map = {
clean: { emo: '🗑', text: '清理', cls: 'clean' },
delete_version: { emo: '✂', text: '删版本', cls: 'clean' },
sync: { emo: '📥', text: '批量拉取汇总', cls: 'sync' },
sync_latest: { emo: '⬇', text: '拉取最新', cls: 'sync' },
pull_manual: { emo: '✋', text: '手工拉取', cls: 'sync' },
pull_toplist: { emo: '🏆', text: '榜单拉取', cls: 'sync' },
pull_toplist_summary: { emo: '🏆', text: '榜单拉取汇总', cls: 'sync' },
scan: { emo: '🔍', text: '扫描', cls: 'scan' },
login: { emo: '🔐', text: '登录', cls: 'login' },
ignore: { emo: '💤', text: '忽略', cls: 'ignore' },
unignore: { emo: '↩️', text: '取消忽略', cls: 'unignore' },
}
return map[action] || { emo: '•', text: action, cls: 'clean' }
}
新增 CSS 类 .act-badge.scan(灰色)、.act-badge.login(紫色)。
表格列改造(L40-73)
- 包名列:
scan显示「全目录」、login显示用户名、sync汇总显示「批量拉取」、其他显示 pkgName - 删除数量列 → 改为「版本/详情」列:
clean显示-N 版、pull_*/delete_version/sync_latest显示版本号 - 释放空间列 → 改为「结果」列:所有 action 显示
result徽章(success 绿 / fail 红 / skipped 灰) - 操作列:保留「详情」按钮
筛选 tab(页头新增)
[全部] [清理/删除] [拉取] [扫描] [登录] [忽略]
点击切换 filter 参数,调用 api.getLogs({ action: filter, limit: 100 })。pull_*、sync* 合并为「拉取」标签。
详情弹窗分支(L77-130)
按 action 分支展示:
clean/delete_version:包名 + 版本 + 数量 + 释放空间sync汇总:批量拉取结果(成功/跳过/失败)+ 失败包列表(旧deletedVersionsJSON 兼容)sync_latest/pull_manual/pull_toplist:包名 + 版本 + 结果 + 失败原因scan:耗时 + 包数 + 字节 + 触发源(meta JSON 解析)login:用户名 + IP + 结果 + 失败原因 + UserAgent(meta)ignore/unignore:保留
store/api 扩展
api.getLogs(filter):GET /api/logs?action=${filter}&limit=100,filter 为空时取全部api.pullPackage(pkgName, version):POST /api/pullwith JSON bodystore.refreshLogs(filter):透传 filter
实施顺序
- db.js:migrateLogsV2 加 6 字段 + addLog 扩展签名 + getLogs 支持 action 筛选
- sync-latest.js:
export { triggerVerdaccio };handleOne 逐包写日志(action='sync_latest');汇总日志调整字段 - server.js:/api/login 写日志 + runScanAsync 写日志 + 单版本删除改 action='delete_version' + 新增 POST /api/pull
- api/index.js:getLogs 支持 filter + 新增 pullPackage
- store/index.js:refreshLogs 透传 filter
- LogsView.vue:actionMeta 扩展 + 表格列改造 + 筛选 tab + 详情弹窗分支
- SearchView.vue:表格右上角新增「拉取指定包」表单
- 验证
验证步骤
# 1. 启动后端
cd e:\gitea\verdaccio-cache-manager\backend && node server.js
# 2. 验证迁移
sqlite3 backend/data/manager.db "PRAGMA table_info(logs);"
# 应见 version/result/message/duration_ms/meta/ip 6 个新字段
# 3. 登录页测试:先输错密码,再输正确密码
curl -X POST http://localhost:3000/api/login -H "Content-Type: application/json" -d '{"username":"admin","password":"wrong"}'
curl -X POST http://localhost:3000/api/login -H "Content-Type: application/json" -d '{"username":"admin","password":"666666"}'
# 4. 验证登录日志
sqlite3 backend/data/manager.db "SELECT action, pkg_name, result, ip, message, created_at FROM logs WHERE action='login' ORDER BY id DESC LIMIT 5;"
# 应见 1 条 fail + 1 条 success
# 5. 触发手动扫描
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/scan
sleep 5
sqlite3 backend/data/manager.db "SELECT action, result, duration_ms, meta, created_at FROM logs WHERE action='scan' ORDER BY id DESC LIMIT 1;"
# 应见 success + packages 数 + duration
# 6. 触发批量拉取(确保私服可用),等待完成
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/sync/latest
sleep 30
sqlite3 backend/data/manager.db "SELECT action, pkg_name, version, result, message FROM logs WHERE action='sync_latest' ORDER BY id DESC LIMIT 10;"
sqlite3 backend/data/manager.db "SELECT action, result, message, duration_ms FROM logs WHERE action='sync' ORDER BY id DESC LIMIT 1;"
# 应见 N 条逐包 + 1 条汇总
# 7. 前端手工拉取
# 浏览器打开 http://localhost:5173/search,点击「拉取指定包」按钮
# 输入 lodash + 4.17.21 → 提交 → 应见 ElMessage 成功提示
sqlite3 backend/data/manager.db "SELECT action, pkg_name, version, result, message FROM logs WHERE action='pull_manual' ORDER BY id DESC LIMIT 1;"
# 8. 切换 LogsView 筛选 tab,验证各 action 类型数量匹配
# 浏览器打开 http://localhost:5173/logs
# 点 [登录] → 应见 2 条登录日志
# 点 [扫描] → 应见 1 条扫描日志
# 点 [拉取] → 应见 sync_latest N 条 + sync 汇总 1 条 + pull_manual 1 条
# 9. 验证旧日志兼容
sqlite3 backend/data/manager.db "SELECT id, action, pkg_name, deleted_versions FROM logs WHERE action IN ('clean','ignore','unignore') ORDER BY id DESC LIMIT 5;"
# 前端 LogsView 应能正常显示历史日志(action='clean' / 'ignore' / 'unignore' 都保留映射)