Files
verdaccio-cache-manager/.trae/documents/notifications-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

27 KiB
Raw Blame History

消息通知功能 — 实施方案

Context(背景与目标)

verdaccio-cache-manager 现有的进度反馈依赖前端轮询(scanProgress / syncProgress),仅在用户停留在触发页面时可见。一旦切走页面或刷新浏览器,批量任务的最终结果就丢失了上下文。拉取包耗时长(单包 10s+,批量可达分钟级),用户无法实时盯着进度条,需要一个异步通知中心留存结果。

本功能新增 6 类非实时通知:

  1. 包版本过多预警(扫描完成后检查)
  2. 批量拉取存量包结果(含详情入口→操作日志,显示该批次每条拉取日志:操作时间、包名、版本号、拉取结果)
  3. 手工拉取指定包结果
  4. 拉取排行榜包结果
  5. 榜单发生变化通知
  6. 定时任务预告(下次扫描/抓榜时间)

关键设计决策

  • 实时性:用户明确说明"用于非实时消息,拉取包很慢没法做到实时"。采用轮询(30s 间隔,复用现有 pollSyncProgress 模式),不引入 SSE。新通知到达时未读数徽章实时更新,warn/error 级别额外弹一次 ElMessage 提示。
  • 入口形态:顶栏铃铛 + 下拉面板(复用 AppTopbar.vue avatar-wrap 下拉 模式),不新增独立通知页。通知项点击按类型跳转到对应详情页。
  • 批次关联:通知与日志通过 batch_id 关联。点击"批量拉取结果"通知跳转 /logs?action=sync_latest&batchId=xxx,LogsView 按批次筛选显示该批次的逐包日志。
  • 依赖关系:本方案假设「操作日志改造方案」(operation-logs-refactor-plan.md)已实施(addLog 扩展签名 + logs 表 v2 字段 + action 筛选)。本方案在此基础上新增 logs.batch_id 列与 notifications 表。

数据库改动 — backend/db.js

新增 notifications 表

参照 db.js L18-36 现有建表语句,在 logs 表 CREATE 之后追加:

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()(北京时间),与 addLog 一致,避免 DEFAULT CURRENT_TIMESTAMP 的 UTC 偏差。

logs 表新增 batch_id 列(迁移幂等)

参照 migrateTimezone 模式,在 logCols 检查后追加:

if (!logCols.includes('batch_id')) {
  db.exec("ALTER TABLE logs ADD COLUMN batch_id TEXT")
}

addLog 签名扩展(追加 batchId,向后兼容)

在「操作日志改造方案」已扩展的签名基础上追加 batchId:

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:

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):检测首参为数字时走旧路径。

新增导出函数

/** 插入通知,返回 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 调用:

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 之后追加:

方法 路径 响应
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} — 全部标记已读
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 finally 块,扫描成功后(操作日志改造方案写 scan 日志之后)。

去重逻辑:与上次预警快照比对,仅当版本数超阈值的包集合变化时通知(避免每日扫描重复刷屏)。快照存 settings.last_warning_snapshot。只发超阈值预警,不发「已恢复正常」通知(用户确认:只报忧不报喜,某包清理后回归正常是静默的)——快照比对只用于"超阈值集合是否变化"来控制是否发新预警,不对"恢复"方向发任何通知。

// 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 汇总 addLog 之后(L156-165 附近)。

依赖注入扩展:runSyncLatest({ addLog, invalidateScan, notify }) — server.js /api/sync/latest 调用时传入 notify。

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(操作日志改造方案新增端点)。

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 实施)。

依赖注入扩展: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)。

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 setTimeout 回调末尾,自调度 scheduleDailyScan() 之前;scheduleWeeklyTopList 同理。

去重策略:仅在定时扫描(_triggerSource === 'daily' 或 'pending')完成后发一次预告,手动扫描不触发。预告内容为"下次自动扫描时间",避免刷屏。

// 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 模式新增:

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(与「操作日志改造方案」合并实施):

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 轮询模式新增:

// 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() 末尾启动通知轮询(首次加载拉取最新 20 条 + 启动 30s 轮询);onBeforeUnmount 清理 timer(App 根组件卸载时)。

AppTopbar.vue — 铃铛 + 下拉面板

参照 AppTopbar.vue avatar-wrap 下拉 模式,在 .topbar-right 内 theme-toggle 与 avatar-wrap 之间插入铃铛:

<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 模式)。

移动端 m-topbar(App.vue)+ 独立通知页(用户选定)

在 App.vue m-topbar 右侧 ☰ 旁加铃铛按钮(带角标),点击跳转独立通知页 /notifications(用户确认:移动端空间小,独立滚动页体验更佳,不做抽屉)。移动端铃铛仅显示角标 + 跳转,不展开下拉。

独立通知页 NotificationsView.vue(新建):移动端专用,全屏列表展示 state.notifications,支持标记已读、全部已读、点击通知跳转对应详情(复用 handleNotifClick 的分发逻辑,抽成共享方法)。路由新增 { path: '/notifications', name: 'notifications', component: NotificationsView }(桌面端铃铛仍用下拉,此页主要覆盖移动端,但桌面端也可通过输入 URL 访问)。

LogsView.vue — 接收 query 参数筛选批次

参照 LogsView.vue 现有结构(「操作日志改造方案」已加 action 筛选 tab),新增读取 route.query 透传:

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 方案 实施时一并加):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. 验证

验证步骤

# 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 通知