diff --git a/.trae/documents/notifications-plan.md b/.trae/documents/notifications-plan.md new file mode 100644 index 0000000..f2131a2 --- /dev/null +++ b/.trae/documents/notifications-plan.md @@ -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=` — 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=`。 + +### 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=`。 + +### 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 +
+ + +
+
+ 🔔 消息通知 + +
+
+
+ {{ typeMeta(n.type).emo }} +
+
{{ n.title }}
+
{{ n.body }} · {{ fmtTime(n.created_at) }}
+
+ → +
+
暂无通知
+
+
+
+
+``` + +`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=` | +| `pull_manual` | `/logs?action=pull_manual&batchId=` | +| `toplist_pull` | `/logs?action=pull_toplist&batchId=` | +| `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 " -H "Content-Type: application/json" \ + -d '{"versionThreshold":3}' http://localhost:3000/api/thresholds +curl -X POST -H "Authorization: Bearer " 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 " 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 " -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 " "http://localhost:3000/api/notifications?sinceId=0&limit=20" +# 应返回 list + unreadCount + lastId + +# 7. 标记已读 +curl -X POST -H "Authorization: Bearer " http://localhost:3000/api/notifications/1/read +curl -X POST -H "Authorization: Bearer " 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 通知 +``` diff --git a/.trae/documents/npm-top200-cache-plan.md b/.trae/documents/npm-top200-cache-plan.md new file mode 100644 index 0000000..51363db --- /dev/null +++ b/.trae/documents/npm-top200-cache-plan.md @@ -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 " http://localhost:3000/api/toplist/fetch + +# 4. 轮询进度 +curl -H "Authorization: Bearer " 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 " http://localhost:3000/api/toplist/pull + +# 8. 拉取完成后查看 storage +ls // # 应有 latest 版本 .tgz + +# 9. 刷新页面,对应行变为已就绪 +curl -H "Authorization: Bearer " "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 步执行 diff --git a/.trae/documents/operation-logs-refactor-plan.md b/.trae/documents/operation-logs-refactor-plan.md new file mode 100644 index 0000000..5cca082 --- /dev/null +++ b/.trae/documents/operation-logs-refactor-plan.md @@ -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 + + +
+ + + +
+
+``` + +提交时调用 `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 " 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 " 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' 都保留映射) +``` diff --git a/backend/server.js b/backend/server.js index 879dce6..a7a1579 100644 --- a/backend/server.js +++ b/backend/server.js @@ -562,11 +562,13 @@ setInterval(() => { } }, 10 * 60_000).unref() -// —— 每天 00:00(服务器本地时区)自动重扫缓存,逻辑同 POST /api/scan —— +// —— 每天 03:00(服务器本地时区)自动重扫缓存,逻辑同 POST /api/scan —— +// 选 3 点而非 0 点:避开周一 0 点 Top200 抓榜,减少并发撞车;凌晨 3 点用户访问最稀疏 function scheduleDailyScan() { const now = new Date() 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() setTimeout(() => { try { @@ -582,7 +584,7 @@ function scheduleDailyScan() { } catch (e) { console.error('[auto-scan] 每日自动扫描失败:', e.message) } - scheduleDailyScan() // 安排次日零点 + scheduleDailyScan() // 安排次日 03:00 }, wait) }