Files
verdaccio-cache-manager/.trae/documents/operation-logs-refactor-plan.md
T
yinjun.chen1 6371c83a2a
CI / Backend tests (push) Canceled after 0s
CI / Frontend build (push) Canceled after 0s
fix(schedule): 每日自动扫描改为 03:00,避开周一 0 点 Top200 抓榜
存档操作日志改造、Top200 榜单、消息通知三个功能实施规划方案
2026-09-22 10:35:46 +08:00

14 KiB
Raw Blame History

操作日志功能改造 — 实施方案

Context(背景与目标)

现有 logs 表字段(action/pkg_name/deleted_versions/deleted_count/freed_space/created_at)专为「清理低版本」设计,无法承载用户要求的 6 类日志。批量拉取目前只写一条汇总(action='sync'),手工拉取、扫描、登录均未落日志。本次改造扩展表结构 + 改造 sync-latest 为逐包写入 + 新增 3 类日志写入点 + 新增手工拉取入口。

用户确认的 4 个决策点

  1. 登录:成功 + 失败均记一条(含 IP、用户名、失败原因)
  2. 扫描:手动扫描 + 每日定时扫描 + 补扫均记一条
  3. 批量拉取:逐包 N 条(action='sync_latest')+ 汇总 1 条(保留 action='sync')
  4. 手工拉取入口:放在 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 汇总:批量拉取结果(成功/跳过/失败)+ 失败包列表(旧 deletedVersions JSON 兼容)
  • 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/pull with JSON body
  • store.refreshLogs(filter):透传 filter

实施顺序

  1. db.js:migrateLogsV2 加 6 字段 + addLog 扩展签名 + getLogs 支持 action 筛选
  2. sync-latest.js:export { triggerVerdaccio };handleOne 逐包写日志(action='sync_latest');汇总日志调整字段
  3. server.js:/api/login 写日志 + runScanAsync 写日志 + 单版本删除改 action='delete_version' + 新增 POST /api/pull
  4. api/index.js:getLogs 支持 filter + 新增 pullPackage
  5. store/index.js:refreshLogs 透传 filter
  6. LogsView.vue:actionMeta 扩展 + 表格列改造 + 筛选 tab + 详情弹窗分支
  7. SearchView.vue:表格右上角新增「拉取指定包」表单
  8. 验证

验证步骤

# 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' 都保留映射)