fix(schedule): 每日自动扫描改为 03:00,避开周一 0 点 Top200 抓榜
CI / Backend tests (push) Canceled after 0s
CI / Frontend build (push) Canceled after 0s

存档操作日志改造、Top200 榜单、消息通知三个功能实施规划方案
This commit is contained in:
yinjun.chen1
2026-09-22 10:35:46 +08:00
parent e82c7b70cd
commit 6371c83a2a
4 changed files with 1159 additions and 3 deletions
+601
View File
@@ -0,0 +1,601 @@
# 消息通知功能 — 实施方案
## Context(背景与目标)
verdaccio-cache-manager 现有的进度反馈依赖前端轮询(`scanProgress` / `syncProgress`),仅在用户停留在触发页面时可见。一旦切走页面或刷新浏览器,批量任务的最终结果就丢失了上下文。拉取包耗时长(单包 10s+,批量可达分钟级),用户无法实时盯着进度条,需要一个**异步通知中心**留存结果。
本功能新增 6 类非实时通知:
1. 包版本过多预警(扫描完成后检查)
2. 批量拉取存量包结果(含详情入口→操作日志,显示该批次每条拉取日志:操作时间、包名、版本号、拉取结果)
3. 手工拉取指定包结果
4. 拉取排行榜包结果
5. 榜单发生变化通知
6. 定时任务预告(下次扫描/抓榜时间)
### 关键设计决策
- **实时性**:用户明确说明"用于非实时消息,拉取包很慢没法做到实时"。采用**轮询**(30s 间隔,复用现有 [pollSyncProgress](file:///e:/gitea/verdaccio-cache-manager/frontend/src/store/index.js) 模式),不引入 SSE。新通知到达时未读数徽章实时更新,`warn`/`error` 级别额外弹一次 `ElMessage` 提示。
- **入口形态**:顶栏铃铛 + 下拉面板(复用 [AppTopbar.vue avatar-wrap 下拉](file:///e:/gitea/verdaccio-cache-manager/frontend/src/components/AppTopbar.vue) 模式),不新增独立通知页。通知项点击按类型跳转到对应详情页。
- **批次关联**:通知与日志通过 `batch_id` 关联。点击"批量拉取结果"通知跳转 `/logs?action=sync_latest&batchId=xxx`,LogsView 按批次筛选显示该批次的逐包日志。
- **依赖关系**:本方案假设「操作日志改造方案」([operation-logs-refactor-plan.md](file:///e:/gitea/verdaccio-cache-manager/.trae/documents/operation-logs-refactor-plan.md))已实施(`addLog` 扩展签名 + logs 表 v2 字段 + action 筛选)。本方案在此基础上新增 `logs.batch_id` 列与 `notifications` 表。
## 数据库改动 — `backend/db.js`
### 新增 `notifications` 表
参照 [db.js L18-36](file:///e:/gitea/verdaccio-cache-manager/backend/db.js) 现有建表语句,在 `logs` 表 CREATE 之后追加:
```sql
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL, -- 6 类之一,见下方
title TEXT NOT NULL,
body TEXT, -- 简短描述(一行)
level TEXT DEFAULT 'info', -- 'info' | 'warn' | 'error'
related_action_id INTEGER, -- 关联的 summary log id(点击详情跳转用)
batch_id TEXT, -- 关联批次 ID(与 logs.batch_id 对应)
meta TEXT, -- JSON 扩展字段
read_at DATETIME, -- NULL = 未读
created_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read_at);
CREATE INDEX IF NOT EXISTS idx_notifications_id ON notifications(id DESC);
```
`created_at` 写入时显式用 [nowIso()](file:///e:/gitea/verdaccio-cache-manager/backend/db.js)(北京时间),与 `addLog` 一致,避免 `DEFAULT CURRENT_TIMESTAMP` 的 UTC 偏差。
### logs 表新增 `batch_id` 列(迁移幂等)
参照 [migrateTimezone](file:///e:/gitea/verdaccio-cache-manager/backend/db.js) 模式,在 `logCols` 检查后追加:
```js
if (!logCols.includes('batch_id')) {
db.exec("ALTER TABLE logs ADD COLUMN batch_id TEXT")
}
```
### addLog 签名扩展(追加 batchId,向后兼容)
在「操作日志改造方案」已扩展的签名基础上追加 `batchId`:
```js
export function addLog({
action = 'clean', pkgName, deletedVersions = null, deletedCount = 0, freedSpace = null,
// —— 操作日志改造方案字段(已存档)——
version = null, result = null, message = null, durationMs = null, meta = null, ip = null,
// —— 消息通知方案新增 ——
batchId = null,
} = {}) {
// INSERT 时多写 batch_id 列;其余逻辑不变
return info.lastInsertRowid
}
```
### getLogs 扩展(追加 batchId 过滤)
在「操作日志改造方案」已支持的 `action` 过滤基础上追加 `batchId`:
```js
export function getLogs({ action = null, batchId = null, limit = 50 } = {}) {
let sql = 'SELECT * FROM logs'
const where = [], params = []
if (action) { where.push('action = ?'); params.push(action) }
if (batchId) { where.push('batch_id = ?'); params.push(batchId) }
if (where.length) sql += ' WHERE ' + where.join(' AND ')
sql += ' ORDER BY id DESC LIMIT ?'
params.push(limit)
return db.prepare(sql).all(...params)
}
```
兼容无参 `getLogs(50)`:检测首参为数字时走旧路径。
### 新增导出函数
```js
/** 插入通知,返回 id */
export function addNotification({ type, title, body = '', level = 'info',
relatedActionId = null, batchId = null, meta = null }) {
const info = db.prepare(
`INSERT INTO notifications (type, title, body, level, related_action_id, batch_id, meta, read_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)`,
).run(type, title, body, level, relatedActionId, batchId,
meta ? JSON.stringify(meta) : null, nowIso())
return info.lastInsertRowid
}
/** 拉取 sinceId 之后的通知(sinceId=0 取最新 N 条) */
export function getNotifications({ sinceId = 0, limit = 20 } = {}) {
const rows = db.prepare(
`SELECT * FROM notifications WHERE id > ? ORDER BY id DESC LIMIT ?`,
).all(sinceId, limit)
// meta 反序列化
return rows.map(r => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null }))
}
export function getUnreadNotificationCount() {
return db.prepare('SELECT COUNT(*) AS c FROM notifications WHERE read_at IS NULL').get().c
}
export function markNotificationRead(id) {
db.prepare('UPDATE notifications SET read_at = ? WHERE id = ? AND read_at IS NULL')
.run(nowIso(), id)
}
export function markAllNotificationsRead() {
const r = db.prepare('UPDATE notifications SET read_at = ? WHERE read_at IS NULL').run(nowIso())
return r.changes
}
/** 清理旧通知:只保留最近 N 条(用户决定:100) */
export function pruneNotifications(keep = 100) {
db.prepare('DELETE FROM notifications WHERE id NOT IN (SELECT id FROM notifications ORDER BY id DESC LIMIT ?)').run(keep)
}
```
**存留策略(用户确认)**:`notifications` 表只保留最近 100 条,超出的旧通知自动清理。在每次 `addNotification` 后调用 `pruneNotifications(100)`(写放大可控,单条 INSERT 后紧跟一次清理)。
## 核心模块 — `backend/notifier.js`(新建)
轻量封装,提供 `notify()` 单一入口供 `server.js` / `sync-latest.js` / `npm-toplist.js` 调用:
```js
import { addNotification } from './db.js'
/**
* 发送通知(写 DB;前端轮询拉取)
* @param {Object} opts
* @param {string} opts.type - 6 类之一
* @param {string} opts.title
* @param {string} [opts.body]
* @param {'info'|'warn'|'error'} [opts.level]
* @param {number} [opts.relatedActionId] - 关联 summary log id
* @param {string} [opts.batchId]
* @param {Object} [opts.meta]
* @returns {number} notification id
*/
export function notify(opts) {
const id = addNotification({
type: opts.type,
title: opts.title,
body: opts.body || '',
level: opts.level || 'info',
relatedActionId: opts.relatedActionId || null,
batchId: opts.batchId || null,
meta: opts.meta || null,
})
console.log(`[notify] #${id} [${opts.type}] ${opts.title}`)
return id
}
```
## API 端点 — `backend/server.js`
在 [GET /api/logs](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) 之后追加:
| 方法 | 路径 | 响应 |
|---|---|---|
| GET | `/api/notifications?sinceId=0&limit=20` | `{list: [...], unreadCount, lastId}` — sinceId=0 时取最新 N 条;meta 已反序列化 |
| POST | `/api/notifications/:id/read` | `{ok: true}` — 标记单条已读 |
| POST | `/api/notifications/read-all` | `{ok: true, updated: N}` — 全部标记已读 |
```js
import { notify } from './notifier.js'
app.get('/api/notifications', (req, res) => {
const sinceId = Number(req.query.sinceId || 0)
const limit = Math.min(Number(req.query.limit || 20), 100)
const list = getNotifications({ sinceId, limit })
res.json({
list,
unreadCount: getUnreadNotificationCount(),
lastId: list.length ? list[0].id : sinceId,
})
})
app.post('/api/notifications/:id/read', (req, res) => {
markNotificationRead(Number(req.params.id))
res.json({ ok: true })
})
app.post('/api/notifications/read-all', (req, res) => {
const updated = markAllNotificationsRead()
res.json({ ok: true, updated })
})
```
## 6 类通知的触发点
### 1. 包版本过多预警(type: `version_warning`)
**触发位置**:[server.js runScanAsync](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) `finally` 块,扫描成功后(操作日志改造方案写 `scan` 日志之后)。
**去重逻辑**:与上次预警快照比对,仅当版本数超阈值的包集合变化时通知(避免每日扫描重复刷屏)。快照存 `settings.last_warning_snapshot`。**只发超阈值预警,不发「已恢复正常」通知**(用户确认:只报忧不报喜,某包清理后回归正常是静默的)——快照比对只用于"超阈值集合是否变化"来控制是否发新预警,不对"恢复"方向发任何通知。
```js
// runScanAsync finally 块末尾(pendingDailyScan 处理之前)
if (!scanState.error && rawCache) {
const threshold = Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD))
const warnPkgs = rawCache
.filter(p => p.versions > threshold && p.status !== 'broken')
.sort((a, b) => b.versions - a.versions)
if (warnPkgs.length > 0) {
const snapshot = warnPkgs.map(p => `${p.name}:${p.versions}`).sort().join('|')
const last = getSetting('last_warning_snapshot', '')
if (snapshot !== last) {
setSetting('last_warning_snapshot', snapshot)
notify({
type: 'version_warning',
title: `${warnPkgs.length} 个包版本数超阈值(${threshold})`,
body: warnPkgs.slice(0, 3).map(p => `${p.name}(${p.versions})`).join('、') + (warnPkgs.length > 3 ? '…' : ''),
level: 'warn',
meta: { packageCount: warnPkgs.length, threshold,
topPackages: warnPkgs.slice(0, 10).map(p => ({ name: p.name, versions: p.versions })) },
})
}
}
}
```
**跳转**:通知详情 → `/search?status=warn`。
### 2. 批量拉取存量包结果(type: `sync_batch`)
**触发位置**:[sync-latest.js runSyncLatest](file:///e:/gitea/verdaccio-cache-manager/backend/sync-latest.js) 汇总 `addLog` 之后(L156-165 附近)。
**依赖注入扩展**:`runSyncLatest({ addLog, invalidateScan, notify })` — [server.js /api/sync/latest](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) 调用时传入 `notify`。
```js
export async function runSyncLatest({ addLog, invalidateScan, notify }) {
if (syncState.running) return
const batchId = randomUUID() // 本批次 ID,传给所有逐包 addLog + 汇总 addLog + notify
// ... handleOne 调用时 addLog({ action: 'sync_latest', batchId, ... })
const summaryLogId = addLog({ action: 'sync', batchId, meta: { success, skipped, failed }, ... })
if (notify) notify({
type: 'sync_batch',
title: syncState.error
? '批量拉取失败'
: `批量拉取完成:成功 ${syncState.success} · 失败 ${syncState.failed}`,
body: syncState.error || `共 ${syncState.total} 个包,跳过 ${syncState.skipped} 个已最新`,
level: syncState.failed > 0 ? 'warn' : 'info',
relatedActionId: summaryLogId,
batchId,
meta: { success: syncState.success, skipped: syncState.skipped,
failed: syncState.failed, total: syncState.total },
})
}
```
**跳转**:通知详情 → `/logs?action=sync_latest&batchId=<batchId>` — LogsView 按批次筛选显示该批次的逐包日志(操作时间、包名、版本号、拉取结果)。
### 3. 手工拉取指定包结果(type: `pull_manual`)
**触发位置**:[server.js POST /api/pull](file:///e:/gitea/verdaccio-cache-manager/backend/server.js)(操作日志改造方案新增端点)。
```js
app.post('/api/pull', async (req, res) => {
const { pkgName, version } = req.body || {}
// ... 校验 ...
const batchId = randomUUID()
try {
const { triggerVerdaccio } = await import('./sync-latest.js')
await triggerVerdaccio(pkgName, version)
const logId = addLog({ action: 'pull_manual', pkgName, version, result: 'success', batchId, ... })
notify({ type: 'pull_manual', title: `已拉取 ${pkgName}@${version}`, level: 'info',
relatedActionId: logId, batchId, meta: { pkgName, version, result: 'success' } })
invalidateScan()
res.json({ ok: true })
} catch (e) {
const logId = addLog({ action: 'pull_manual', pkgName, version, result: 'fail', batchId, message: e.message })
notify({ type: 'pull_manual', title: `拉取失败:${pkgName}@${version}`, body: e.message, level: 'error',
relatedActionId: logId, batchId, meta: { pkgName, version, result: 'fail', error: e.message } })
res.json({ ok: false, message: e.message })
}
})
```
**跳转**:通知详情 → `/logs?action=pull_manual&batchId=<batchId>`。
### 4. 拉取排行榜包结果(type: `toplist_pull`)
**触发位置**:npm-toplist.js `runTopListPull` 汇总 `addLog` 之后(依赖 [npm-top200-cache-plan.md](file:///e:/gitea/verdaccio-cache-manager/.trae/documents/npm-top200-cache-plan.md) 实施)。
**依赖注入扩展**:`runTopListPull({ addLog, invalidateScan, notify })`。
逻辑同 `sync_batch`:title=`榜单拉取完成:成功 X · 失败 Y`,`relatedActionId` 指向 `pull_toplist_summary` 日志,`batchId` 关联逐包 `pull_toplist` 日志。
**跳转**:`/logs?action=pull_toplist&batchId=<batchId>`。
### 5. 榜单发生变化通知(type: `toplist_change`)
**触发位置**:npm-toplist.js `runTopListFetch` 比对阶段(`replaceTopList` 之前,旧榜单仍在 DB)。
```js
const prevRows = getTopListRows() // 旧榜单
const newSet = new Map(newList.map(e => [e.name, e]))
const oldSet = new Map(prevRows.map(e => [e.name, e]))
const newEntries = newList.filter(e => !oldSet.has(e.name))
const dropped = prevRows.filter(e => !newSet.has(e.name))
const versionUpdates = newList.filter(e => {
const old = oldSet.get(e.name)
return old && old.latest_version !== e.latestVersion
})
if (newEntries.length || dropped.length || versionUpdates.length) {
notify({
type: 'toplist_change',
title: `榜单更新:新进 ${newEntries.length} · 掉出 ${dropped.length} · 版本更新 ${versionUpdates.length}`,
body: newEntries.slice(0, 3).map(e => `▲${e.name}`).join('、') || '无新进包',
level: 'info',
meta: { newEntries, dropped, versionUpdates },
})
}
```
首次抓榜(旧榜单为空)不发 `toplist_change` 通知(无变化可比)。
**跳转**:通知详情 → `/toplist`(Top 榜单页)。
### 6. 定时任务预告(type: `daily_preview`)
**触发位置**:[server.js scheduleDailyScan](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) setTimeout 回调末尾,自调度 `scheduleDailyScan()` 之前;`scheduleWeeklyTopList` 同理。
**去重策略**:仅在定时扫描(`_triggerSource === 'daily'` 或 `'pending'`)完成后发一次预告,手动扫描不触发。预告内容为"下次自动扫描时间",避免刷屏。
```js
// scheduleDailyScan setTimeout 回调内
if (scanState._triggerSource === 'daily' || scanState._triggerSource === 'pending') {
const next = new Date()
next.setHours(3, 0, 0, 0) // 次日 03:00(避开周一 0 点 Top200 抓榜)
notify({
type: 'daily_preview',
title: '下次自动扫描预告',
body: `明日 ${String(next.getMonth()+1).padStart(2,'0')}-${String(next.getDate()).padStart(2,'0')} 03:00 将自动扫描缓存目录`,
level: 'info',
meta: { taskType: 'scan', nextRunAt: next.toISOString() },
})
}
```
`scheduleWeeklyTopList` 同理:抓榜完成后预告"下周一 00:00 将抓取 npm Top200 榜单"(`meta.taskType: 'toplist_fetch'`)。两者错峰 3 小时,不再撞车。
**跳转**:通知详情 → `/`(缓存概览页,无专门调度页)。
## 前端改动
### API — `frontend/src/api/index.js`
参照 [api/index.js authFetch](file:///e:/gitea/verdaccio-cache-manager/frontend/src/api/index.js) 模式新增:
```js
async getNotifications(sinceId = 0, limit = 20) {
const r = await authFetch(`/api/notifications?sinceId=${sinceId}&limit=${limit}`)
return r.json()
},
async markNotificationRead(id) {
const r = await authFetch(`/api/notifications/${id}/read`, { method: 'POST' })
return r.json()
},
async markAllNotificationsRead() {
const r = await authFetch('/api/notifications/read-all', { method: 'POST' })
return r.json()
},
```
`getLogs` 扩展支持 `action` + `batchId`(与「操作日志改造方案」合并实施):
```js
async getLogs({ action = null, batchId = null, limit = 100 } = {}) {
const params = new URLSearchParams()
if (action) params.set('action', action)
if (batchId) params.set('batchId', batchId)
params.set('limit', limit)
const r = await authFetch(`/api/logs?${params}`)
return r.json()
},
```
### Store — `frontend/src/store/index.js`
参照 [pollSyncProgress](file:///e:/gitea/verdaccio-cache-manager/frontend/src/store/index.js) 轮询模式新增:
```js
// state 新增字段
notifications: [], // 最近 50 条(下拉展示用)
unreadCount: 0,
lastNotifId: 0, // 轮询游标(仅取 id > lastNotifId 的新通知)
let notifPollTimer = null
function pollNotifications() {
clearTimeout(notifPollTimer)
const tick = async () => {
try {
const data = await api.getNotifications(state.lastNotifId, 20)
if (data.list.length) {
// 合并到头部,截断保留 50 条
state.notifications = [...data.list, ...state.notifications].slice(0, 50)
state.lastNotifId = data.lastId
state.unreadCount = data.unreadCount
// warn/error 级别弹一次 toast
data.list.filter(n => n.level === 'warn' || n.level === 'error').forEach(n => {
ElMessage({ message: `🔔 ${n.title}`, type: n.level === 'error' ? 'error' : 'warning', duration: 5000 })
})
} else {
state.unreadCount = data.unreadCount
}
} catch { /* 静默失败,下个 tick 继续 */ }
notifPollTimer = setTimeout(tick, 30_000) // 30s 轮询
}
tick()
}
async function refreshNotifications() {
// 下拉打开时手动刷新:重置游标拉最新 20 条
const data = await api.getNotifications(0, 20)
state.notifications = data.list
state.lastNotifId = data.lastId
state.unreadCount = data.unreadCount
}
async function markNotifRead(id) {
// 乐观更新
const n = state.notifications.find(x => x.id === id)
if (n && !n.read_at) {
n.read_at = nowIso() // 本地立刻标记
state.unreadCount = Math.max(0, state.unreadCount - 1)
}
await api.markNotificationRead(id)
}
async function markAllNotifsRead() {
state.notifications.forEach(n => { if (!n.read_at) n.read_at = 'now' })
state.unreadCount = 0
await api.markAllNotificationsRead()
}
```
在 [doRefresh()](file:///e:/gitea/verdaccio-cache-manager/frontend/src/store/index.js) 末尾启动通知轮询(首次加载拉取最新 20 条 + 启动 30s 轮询);`onBeforeUnmount` 清理 timer(App 根组件卸载时)。
### AppTopbar.vue — 铃铛 + 下拉面板
参照 [AppTopbar.vue avatar-wrap 下拉](file:///e:/gitea/verdaccio-cache-manager/frontend/src/components/AppTopbar.vue) 模式,在 `.topbar-right` 内 `theme-toggle` 与 `avatar-wrap` 之间插入铃铛:
```vue
<div class="notif-wrap" ref="notifWrap">
<button class="notif-bell" @click="toggleNotif" :title="'消息通知'">
🔔
<span v-if="state.unreadCount" class="notif-badge">{{ state.unreadCount > 99 ? '99+' : state.unreadCount }}</span>
</button>
<transition name="pop">
<div v-if="notifOpen" class="notif-panel">
<div class="notif-header">
<span>🔔 消息通知</span>
<button v-if="state.unreadCount" @click="markAllRead">全部已读</button>
</div>
<div class="notif-list">
<div v-for="n in state.notifications" :key="n.id"
class="notif-item" :class="{ unread: !n.read_at }"
@click="handleNotifClick(n)">
<span class="notif-emo">{{ typeMeta(n.type).emo }}</span>
<div class="notif-body">
<div class="notif-title">{{ n.title }}</div>
<div class="notif-sub">{{ n.body }} · {{ fmtTime(n.created_at) }}</div>
</div>
<span v-if="hasDeepLink(n)" class="notif-go">→</span>
</div>
<div v-if="!state.notifications.length" class="notif-empty">暂无通知</div>
</div>
</div>
</transition>
</div>
```
`typeMeta(type)` 映射 6 类的 emoji:`version_warning` 🚨 / `sync_batch` 📥 / `pull_manual` ✋ / `toplist_pull` 🏆 / `toplist_change` 📊 / `daily_preview` ⏰。
`handleNotifClick(n)` 按类型路由跳转:
| type | 跳转 |
|---|---|
| `sync_batch` | `/logs?action=sync_latest&batchId=<n.batch_id>` |
| `pull_manual` | `/logs?action=pull_manual&batchId=<n.batch_id>` |
| `toplist_pull` | `/logs?action=pull_toplist&batchId=<n.batch_id>` |
| `toplist_change` | `/toplist` |
| `version_warning` | `/search?status=warn` |
| `daily_preview` | `/` |
点击后调用 `markNotifRead(n.id)` 关闭面板。
下拉打开时调用 `refreshNotifications()` 拉最新 20 条。点击外部关闭(复用 [onDocClick](file:///e:/gitea/verdaccio-cache-manager/frontend/src/components/AppTopbar.vue) 模式)。
### 移动端 m-topbar(App.vue)+ 独立通知页(用户选定)
在 [App.vue m-topbar](file:///e:/gitea/verdaccio-cache-manager/frontend/src/App.vue) 右侧 `☰` 旁加铃铛按钮(带角标),点击**跳转独立通知页 `/notifications`**(用户确认:移动端空间小,独立滚动页体验更佳,不做抽屉)。移动端铃铛仅显示角标 + 跳转,不展开下拉。
**独立通知页 `NotificationsView.vue`**(新建):移动端专用,全屏列表展示 `state.notifications`,支持标记已读、全部已读、点击通知跳转对应详情(复用 `handleNotifClick` 的分发逻辑,抽成共享方法)。路由新增 `{ path: '/notifications', name: 'notifications', component: NotificationsView }`(桌面端铃铛仍用下拉,此页主要覆盖移动端,但桌面端也可通过输入 URL 访问)。
### LogsView.vue — 接收 query 参数筛选批次
参照 [LogsView.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/views/LogsView.vue) 现有结构(「操作日志改造方案」已加 action 筛选 tab),新增读取 route.query 透传:
```js
import { useRoute } from 'vue-router'
const route = useRoute()
onMounted(() => {
const { action, batchId } = route.query
// 透传到 refreshLogs,后端按 action + batchId 过滤
refreshLogs({ action: action || null, batchId: batchId || null })
})
// 切换 action tab 时同步更新 URL query(方便分享/书签)
watch(() => route.query, (q) => {
refreshLogs({ action: q.action || null, batchId: q.batchId || null })
})
```
`refreshLogs({action, batchId})` 透传到 `api.getLogs({action, batchId, limit: 100})`。
## 实施顺序
1. **db.js**:新增 `notifications` 表 + `logs.batch_id` 列迁移 + `addLog` 接受 `batchId` + `addNotification`/`getNotifications`/`markNotificationRead`/`markAllNotificationsRead` 导出 + `getLogs` 支持 batchId 过滤
2. **notifier.js**:新建 `notify()` 入口
3. **server.js**:导入 notify;runScanAsync finally 加预警检查 + daily_preview;POST /api/pull 加通知;新增 `/api/notifications*` 端点;`/api/sync/latest` 调用 runSyncLatest 时传 notify
4. **sync-latest.js**:runSyncLatest 入口生成 batchId;handleOne 逐包 addLog 传 batchId;汇总 addLog 传 batchId + notify
5. **npm-toplist.js**(待 [Top200 方案](file:///e:/gitea/verdaccio-cache-manager/.trae/documents/npm-top200-cache-plan.md) 实施时一并加):runTopListFetch 加 toplist_change 通知;runTopListPull 加 toplist_pull 通知 + batchId;scheduleWeeklyTopList 加 daily_preview(taskType=toplist_fetch)
6. **api/index.js**:新增 getNotifications / markNotificationRead / markAllNotificationsRead;getLogs 支持 action+batchId
7. **store/index.js**:新增 notifications/unreadCount/lastNotifId state + pollNotifications(30s)+ refreshNotifications/markNotifRead/markAllNotifsRead;doRefresh 末尾启动轮询
8. **AppTopbar.vue**:铃铛 + 角标 + notif-panel 下拉 + handleNotifClick 路由 + 点击外部关闭
9. **App.vue m-topbar**:移动端铃铛入口(角标 + 跳转列表页或展开下拉)
10. **LogsView.vue**:读取 route.query.action / batchId 透传 refreshLogs + watch 同步
11. **验证**
## 验证步骤
```bash
# 1. 启动后端 + 前端
cd e:\gitea\verdaccio-cache-manager\backend && node server.js
cd e:\gitea\verdaccio-cache-manager\frontend && npm run dev
# 2. 验证迁移
sqlite3 backend/data/manager.db "PRAGMA table_info(logs);" # 应见 batch_id 列
sqlite3 backend/data/manager.db "PRAGMA table_info(notifications);" # 应见 9 字段
# 3. 触发扫描(先调低 version_threshold 让某些包超阈值,验证预警通知)
curl -X POST -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"versionThreshold":3}' http://localhost:3000/api/thresholds
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/scan
sleep 5
sqlite3 backend/data/manager.db "SELECT type, title, level FROM notifications WHERE type='version_warning' ORDER BY id DESC LIMIT 1;"
# 应见 1 条预警,level=warn
# 4. 触发批量拉取
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/sync/latest
sleep 30
sqlite3 backend/data/manager.db "SELECT type, title, batch_id FROM notifications WHERE type='sync_batch' ORDER BY id DESC LIMIT 1;"
sqlite3 backend/data/manager.db "SELECT batch_id, COUNT(*) FROM logs WHERE action='sync_latest' GROUP BY batch_id ORDER BY 1 DESC LIMIT 1;"
# 通知的 batch_id 应与日志的 batch_id 一致
# 5. 手工拉取
curl -X POST -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"pkgName":"lodash","version":"4.17.21"}' http://localhost:3000/api/pull
sqlite3 backend/data/manager.db "SELECT type, title, level, batch_id FROM notifications WHERE type='pull_manual' ORDER BY id DESC LIMIT 1;"
# 6. 验证未读数 + 列表接口
curl -H "Authorization: Bearer <token>" "http://localhost:3000/api/notifications?sinceId=0&limit=20"
# 应返回 list + unreadCount + lastId
# 7. 标记已读
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/notifications/1/read
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/notifications/read-all
# 8. 浏览器端到端验证
# - 登录后顶栏右侧出现铃铛 + 未读角标
# - 点击铃铛展开下拉,列出步骤 3-5 的通知
# - 点击 sync_batch 通知 → 跳转 /logs?action=sync_latest&batchId=xxx
# → 仅显示该批次的逐包日志(时间/包名/版本号/拉取结果)
# - 30s 后角标自动更新(轮询拉新通知)
# - warn/error 级别通知到达时应有 ElMessage toast 弹出
# - 点击"全部已读"按钮 → 角标消失
# - 移动端 m-topbar 铃铛可见 + 角标 + 可点开
# - 验证去重:再次扫描(不改阈值)→ 不应新增 version_warning 通知
```
+261
View File
@@ -0,0 +1,261 @@
# npm Top200 周榜缓存功能 — 实施方案
## Context(背景与目标)
verdaccio-cache-manager 当前只能扫描私服 storage 已缓存的包,缺乏「应该缓存哪些热门包」的外部榜单来源。新功能引入 npm 周下载量 Top200 榜单作为缓存预演依据:
1. 按既定文档方案抓取 npm 周下载量 Top200(22 组关键词 × Search API 粗排 350 → Downloads API 复核截 200)
2. 榜单与本地 storage 扫描结果(`rawCache`)比对,每条标记三态:待拉取 / 需更新 / 已就绪
3. 前端展示榜单 + 状态徽章 + 一键拉取待缓存包(经私服代理落盘,与 sync-latest 同机制)
4. 每周一 00:00(本地时区)自动触发抓榜任务;提供手动触发按钮以便调试和按需刷新
不引入新依赖:网络请求复用 Node 内置 `fetch`;落盘机制复用 sync-latest 的 `triggerVerdaccio`;状态机对称 `syncState`;前端复用 `authFetch` + 现有 reactive store。
## 数据库改动 — `backend/db.js`
新增 `top_list` 表(在现有 `settings`/`ignored_packages`/`logs` 之后追加 `db.exec`):
```sql
CREATE TABLE IF NOT EXISTS top_list (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rank INTEGER NOT NULL, -- 1~200
pkg_name TEXT NOT NULL UNIQUE, -- 含 @scope
latest_version TEXT NOT NULL, -- Search API 返回的最新版本
weekly_downloads INTEGER NOT NULL DEFAULT 0, -- Downloads API 复核值
search_weekly INTEGER DEFAULT 0, -- Search 快照值(参考)
keyword TEXT, -- 命中的关键词
fetched_at DATETIME -- 入榜时间(北京时间,复用 nowIso())
);
CREATE INDEX IF NOT EXISTS idx_top_list_rank ON top_list(rank);
```
`settings` 表新增两个键:`toplist_last_run`(最近一次抓榜完成时间)、`toplist_next_run`(下次定时执行时间,前端展示用)。
新增导出函数:
- `replaceTopList(rows)` — 事务内 `DELETE FROM top_list` + 批量 `INSERT`(每轮抓榜完整替换)
- `getTopListRows()` — `SELECT * FROM top_list ORDER BY rank`
- `getTopListCount()` — 计数
不动 `addLog`,复用现有签名,新增 action 值:`toplist_fetch` / `toplist_pull`。
## 核心模块 — `backend/npm-toplist.js`(新建)
仿照 [sync-latest.js](file:///e:/gitea/verdaccio-cache-manager/backend/sync-latest.js) 结构:
```js
export const toplistState = {
running: false, done: false,
phase: 'idle', // 'idle' | 'search' | 'recheck' | 'compare' | 'done'
total: 0, processed: 0,
success: 0, skipped: 0, failed: 0,
failedList: [], // [{name, reason}]
current: null, // 当前处理的关键词或包名
error: null,
startedAt: null, finishedAt: null,
// 拉取子状态(与 sync-latest 对称)
pullRunning: false, pullTotal: 0, pullProcessed: 0,
pullSuccess: 0, pullSkipped: 0, pullFailed: 0, pullFailedList: [],
pullCurrent: null, pullStartedAt: null, pullFinishedAt: null,
}
```
### 常量
```js
const KEYWORDS = ['javascript','node','react','vue','css','util','web','frontend',
'angular','typescript','api','test','tool','cli','server','build','http',
'data','component','style','message','theme']
const SEARCH_API = 'https://registry.npmjs.org/-/v1/search'
const DOWNLOADS_API = 'https://api.npmjs.org/downloads/point/last-week'
const SEARCH_SIZE = 250
const COARSE_TOP = 350
const FINAL_TOP = 200
const REQ_INTERVAL_MS = 1500 // 请求间隔:调宽至 1.5s,避开 npm 匿名隐性限流(用户允许慢)
const RECHECK_CONCURRENCY = 4 // @ 包逐个复核并发:降为 4,避免并发瞬时挤爆
const RECHECK_BATCH = 70 // 非 @ 包批量复核每批大小(保持不变,一次请求复核 70 个,天然省请求)
const PULL_CONCURRENT = 3 // 拉取并发(与 sync-latest 一致)
const RATE_LIMIT_BACKOFF_MAX = 8 // 429 退避最大重试指数(2^n 秒,最高 256s)
```
> **限流说明**:npm Search/Downloads 是公共匿名接口,无 token 标识,有隐性速率限制(超了返回 429 或临时封 IP)。用户明确"更新榜单不急、接口慢慢调",故:
> - Search 阶段 22 次 × 1.5s ≈ 33s(可接受)
> - Recheck 阶段靠 70 个/批批量复核省请求 + 4 并发控制峰值
> - 任何一步被 429 时按 2^n 秒退避重试(最多 8 次),如仍失败则计入 failedList 不中断
> 该容忍上限为"单个请求隔 1 分钟",实际按上面更快的间隔跑,若实测仍触发限流再继续放宽。
### 关键函数
| 函数 | 签名 | 职责 |
|---|---|---|
| `runTopListFetch({addLog, getRawPackages, invalidateScan, setSetting})` | async | 主流程:search → recheck → merge → compare → persist |
| `runTopListPull({addLog, invalidateScan})` | async | 拉取所有 cached=false 或 latestCached=false 的包 |
| `getTopList()` | 同步 | 返回 DB 当前快照 + 实时比对结果(不写 DB) |
**Search 阶段**:
- 22 个关键词顺序调用 `${SEARCH_API}?text=${kw}&size=${SEARCH_SIZE}`
- 每个响应解析 `objects[].package` 取 `{name, version}`,`objects[].downloads.weekly` 取快照值
- 间隔 ≥400ms,单关键词失败计入 `failedList` 不中断
**Recheck 阶段**:
- 粗排 350 候选 → 拆 @ 包和非 @ 包
- 非 @ 包 70 个/批:`GET ${DOWNLOADS_API}/${names.join(',')}` 返回 `{[name]: {downloads: N}}`
- @ 包逐个调用,16 并发 worker pool
- 用 recheck 值覆盖 search 快照值,按降序截取前 200
**Compare 阶段**(参考 [scanner.js](file:///e:/gitea/verdaccio-cache-manager/backend/scanner.js) 的 `versionsDetail` 字段):
```js
function compareWithCache(topList, rawPackages) {
const pkgMap = new Map(rawPackages.map(p => [p.name, p]))
return topList.map(entry => {
const pkg = pkgMap.get(entry.name)
const vs = pkg && Array.isArray(pkg.versionsDetail) ? pkg.versionsDetail : []
const versionSet = new Set(vs.map(v => v.version))
return {
...entry,
cached: versionSet.size > 0,
latestCached: versionSet.has(entry.latestVersion),
cachedVersions: [...versionSet],
}
})
}
```
三态:`cached=false`(待拉取) / `cached=true && latestCached=false`(需更新) / `cached=true && latestCached=true`(已就绪)
**Pull 阶段**(复用 [sync-latest.js#triggerVerdaccio](file:///e:/gitea/verdaccio-cache-manager/backend/sync-latest.js) 的实现思路):
- 由于 `triggerVerdaccio` 当前未导出,**直接 import 复用**:在 sync-latest.js 中添加 `export { triggerVerdaccio }`,npm-toplist.js 中 `import { triggerVerdaccio } from './sync-latest.js'`
- 拉取范围:`cached=false` 的包(待拉取)+ `cached=true && latestCached=false` 的包(需更新)
- 并发 3,失败计入 `pullFailedList` 不中断
- 完成后调用 `invalidateScan()` 让下一次请求触发重扫,新版本可见
## API 端点 — `backend/server.js`
在 `/api/sync/progress` 之后追加:
| 方法 | 路径 | 响应 |
|---|---|---|
| GET | `/api/toplist` | `{list: [...], summary: {total, cached, latestCached, missing, outdated}, state: toplistState, lastRun, nextRun}` |
| GET | `/api/toplist?filter=missing` | 仅返回 cached=false 的子集 |
| POST | `/api/toplist/fetch` | `{ok, running, startedAt}` — 触发抓榜 |
| GET | `/api/toplist/progress` | `toplistState` 全字段(轮询用) |
| POST | `/api/toplist/pull` | `{ok, running, total, startedAt}` — 触发拉取 |
| GET | `/api/toplist/pull/progress` | `{running, done, total, processed, success, skipped, failed, failedList, current, error, startedAt, finishedAt}` |
GET `/api/toplist` 内部实时调用 `compareWithCache(topList, rawCache || [])`,前端拿到的 `cached/latestCached` 永远反映私服实况,不依赖落库字段。
## 定时调度 — `backend/server.js`
新增 `scheduleWeeklyTopList()`,与现有 [scheduleDailyScan](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) 模式对称:
```js
function scheduleWeeklyTopList() {
const now = new Date()
const next = new Date(now)
next.setHours(0, 0, 0, 0)
const dow = next.getDay() // 0=Sun, 1=Mon
let daysUntilMonday = (1 - dow + 7) % 7
if (daysUntilMonday === 0 && now.getTime() > next.getTime()) daysUntilMonday = 7
next.setDate(next.getDate() + daysUntilMonday)
setSetting('toplist_next_run', nowIso(next))
setTimeout(async () => {
try { await runTopListFetch({ addLog, getRawPackages: getRawPackages, invalidateScan, setSetting }) }
catch (e) { console.error('[toplist] 定时抓榜失败:', e.message) }
scheduleWeeklyTopList()
}, next.getTime() - now.getTime())
}
```
在 `app.listen` 回调里 `scheduleDailyScan()` 之后并列调用一次。
## 前端改动
### 路由 — `frontend/src/router/index.js`
```js
import TopListView from '../views/TopListView.vue'
// routes 数组新增:
{ path: '/toplist', name: 'toplist', component: TopListView },
```
### 顶栏与底栏菜单
[AppTopbar.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/components/AppTopbar.vue) 的 `navs` 数组追加 `{ path: '/toplist', label: 'Top榜单', emo: '🏆' }`,[MobileTabbar.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/components/MobileTabbar.vue) 的 `tabs` 同步追加。
### Store — `frontend/src/store/index.js`
state 新增字段:
```js
toplist: [],
toplistProgress: { running: false, phase: 'idle', total: 0, processed: 0, ... },
toplistPullProgress: { running: false, total: 0, processed: 0, ... },
toplistNextRun: null,
toplistLastRun: null,
```
新增 actions:`refreshTopList(filter)`、`fetchTopList()`、`pullTopList()`、`pollTopListProgress()`、`pollTopListPullProgress()`,仿照 [syncLatest](file:///e:/gitea/verdaccio-cache-manager/frontend/src/store/index.js) + `pollSyncProgress` 模式实现。
### API — `frontend/src/api/index.js`
新增方法:`getTopList(filter)`、`fetchTopList()`、`getTopListProgress()`、`pullTopList()`、`getTopListPullProgress()`,均通过 `authFetch` 调用对应端点。
### 主页面 — `frontend/src/views/TopListView.vue`(新建)
参考 [IgnoredView.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/views/IgnoredView.vue) 的表格 + [SearchView.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/views/SearchView.vue) 的筛选条:
- **顶部摘要条**:`共 200 · 已缓存 X · 待拉取 Y · 需更新 Z · 下次抓榜 2026-09-28 00:00`
- **操作区**:立即抓榜按钮 / 一键拉取按钮 / 筛选 tabs(全部 · 待拉取 · 需更新 · 已就绪)
- **进度条**:抓榜期间显示 phase(搜索中 → 重校验 → 对比中)+ processed/total;拉取期间显示 success/skipped/failed
- **表格列**:排名 / 包名 / 最新版本 / 周下载(千分位) / 状态徽章 / 已缓存版本 / 操作
- **状态徽章**:复用 [StatusTag.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/components/StatusTag.vue) 风格,三色(红/黄/绿)
- **空态**:DB 无数据时提示「尚无 Top200 数据,点击立即抓榜获取」
- **轮询**:触发后 800ms 拉一次 progress,任务结束后刷新 list
## 验证步骤
```bash
# 1. 启动后端
cd e:\gitea\verdaccio-cache-manager\backend && node server.js
# 2. 启动前端
cd e:\gitea\verdaccio-cache-manager\frontend && npm run dev
# 3. 触发抓榜(登录后)
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/toplist/fetch
# 4. 轮询进度
curl -H "Authorization: Bearer <token>" http://localhost:3000/api/toplist/progress
# 5. 验证 DB
sqlite3 backend/data/manager.db "SELECT COUNT(*) FROM top_list;" # 应 = 200
sqlite3 backend/data/manager.db "SELECT rank, pkg_name, latest_version, weekly_downloads FROM top_list ORDER BY rank LIMIT 10;"
# 6. 浏览器打开 http://localhost:5173/toplist
# - 表格 200 行,状态徽章符合实际 storage 状态
# - 切换筛选 tabs 数量准确
# 7. 触发拉取
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/toplist/pull
# 8. 拉取完成后查看 storage
ls <VERDACCIO_STORAGE>/<pkg-name>/ # 应有 latest 版本 .tgz
# 9. 刷新页面,对应行变为已就绪
curl -H "Authorization: Bearer <token>" "http://localhost:3000/api/toplist?filter=missing"
# 返回 list 长度应减小
# 10. 验证日志
sqlite3 backend/data/manager.db "SELECT action, deleted_versions, created_at FROM logs WHERE action LIKE 'toplist_%' ORDER BY id DESC LIMIT 5;"
```
可选验证:把 `scheduleWeeklyTopList` 内 `next` 改为 5 秒后,验证自调度 + 下一次正确计算到下周一 00:00。
## 实施顺序
1. **db.js**:新增 `top_list` 表 + `replaceTopList` / `getTopListRows` 等导出函数
2. **sync-latest.js**:导出 `triggerVerdaccio` 供复用
3. **npm-toplist.js**:新建核心模块(fetch + compare + pull + state)
4. **server.js**:新增 `/api/toplist*` 端点 + `scheduleWeeklyTopList()` + 启动注册
5. **router + 顶/底栏菜单**:注入路由和菜单项
6. **store + api**:新增 state 字段和 actions
7. **TopListView.vue**:新建主页面
8. **端到端验证**:按上述 10 步执行
@@ -0,0 +1,292 @@
# 操作日志功能改造 — 实施方案
## 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](file:///e:/gitea/verdaccio-cache-manager/frontend/src/views/SearchView.vue) 「我的缓存」表格右上角
## 数据库改动 — `backend/db.js`
### 表结构扩展(迁移幂等)
参照 [migrateTimezone](file:///e:/gitea/verdaccio-cache-manager/backend/db.js) 模式,新增 `migrateLogsV2()`:
```sql
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 签名扩展
```js
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 扩展
```js
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](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) 现有端点:
- 成功分支(生成 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](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) 现有函数,在 `finally` 块末尾、`pendingDailyScan` 处理之前写日志:
```js
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](file:///e:/gitea/verdaccio-cache-manager/backend/sync-latest.js) 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](file:///e:/gitea/verdaccio-cache-manager/backend/sync-latest.js) 现有汇总写入,仅调整字段以兼容新表:
```js
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
```js
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](file:///e:/gitea/verdaccio-cache-manager/backend/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](file:///e:/gitea/verdaccio-cache-manager/backend/server.js) `DELETE /api/packages/:pkgName/versions/:version` 当前用默认 `action='clean'`,改为 `action='delete_version'`,并填 `version`、`result: 'success'`、`message: '已删除 ${version}'`。详情弹窗按 action 分支展示。
## 前端改动
### SearchView.vue 新增「拉取指定包」表单
参照 [SearchView.vue](file:///e:/gitea/verdaccio-cache-manager/frontend/src/views/SearchView.vue) 表格右上角位置(与搜索框同行或上方),新增:
```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](file:///e:/gitea/verdaccio-cache-manager/frontend/src/views/LogsView.vue) 现有结构:
#### actionMeta 扩展(L14-29)
```js
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. **验证**
## 验证步骤
```bash
# 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' 都保留映射)
```
+5 -3
View File
@@ -562,11 +562,13 @@ setInterval(() => {
} }
}, 10 * 60_000).unref() }, 10 * 60_000).unref()
// —— 每天 00:00(服务器本地时区)自动重扫缓存,逻辑同 POST /api/scan —— // —— 每天 03:00(服务器本地时区)自动重扫缓存,逻辑同 POST /api/scan ——
// 选 3 点而非 0 点:避开周一 0 点 Top200 抓榜,减少并发撞车;凌晨 3 点用户访问最稀疏
function scheduleDailyScan() { function scheduleDailyScan() {
const now = new Date() const now = new Date()
const next = new Date(now) const next = new Date(now)
next.setHours(24, 0, 0, 0) // 下个本地零点 next.setHours(3, 0, 0, 0)
if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1) // 今日 3 点已过 → 明日
const wait = next.getTime() - now.getTime() const wait = next.getTime() - now.getTime()
setTimeout(() => { setTimeout(() => {
try { try {
@@ -582,7 +584,7 @@ function scheduleDailyScan() {
} catch (e) { } catch (e) {
console.error('[auto-scan] 每日自动扫描失败:', e.message) console.error('[auto-scan] 每日自动扫描失败:', e.message)
} }
scheduleDailyScan() // 安排次日零点 scheduleDailyScan() // 安排次日 03:00
}, wait) }, wait)
} }