From a11cbfa3248208f7fef3895417d1a7589c2de8ee Mon Sep 17 00:00:00 2001 From: worable Date: Sun, 25 Oct 2026 22:25:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(storage):=20=E5=AD=98=E5=82=A8=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E5=91=8A=E8=AD=A6=EF=BC=88=E8=BF=9E=E7=BB=AD=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=89=8D=E9=80=9A=E7=9F=A5=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=EF=BC=8C=E6=81=A2=E5=A4=8D=E6=9C=89=E4=B8=8B=E6=96=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对标 #12.5 里「4 项缺失」的最后一项:对方 `ossExceptionNotice()` 每 5 分钟探一次 对象存储,失败就通知管理员;我方 `/ready` 有存储探测,但探测结果只对探针可见, 存储挂了没人收到通知。 ## 不照抄对方的两处 1. **抖一下就通知** → 协议是「连续 3 次失败才告警」(5 分钟一次 ≈ 15 分钟延迟)。 一次超时可能只是网络抖动,而管理员会被无意义告警训练成「不看了」。 2. **只报故障不报恢复** → 恢复也要「连续 2 次成功」才发,避免故障/恢复交替刷屏, 同时让管理员知道要不要继续排查。 ## 实现 - 新内核任务 `storage.watch`(5 分钟,与对方同频),`createStorageWatch()` 持有 `failures/successes/alerted` 三个计数;探测抛错按不可用处理,**不向任务抛出** (否则任务进死信被反复重试)。 - 收件人用 `listAdminUserIds()`:管理员在数据模型里不是 `User` 字段,而是权限组 授予的 `platform.admin`;限时成员按读路径规则过滤过期行 —— 与 `hasPermission` 同一口径,否则过期的管理员权益会让告警一直发给无权的人。 - 新增通知类型 `admin.storage.unreachable` / `admin.storage.recovered` 并登记在内建 目录(「发出即声明」守门覆盖)。类型字面量写在 `type:` 位置而不是用模板串拼, 否则会绕过 `notification-type-coverage` 扫描。 ## 验证 - `tests/unit/storage-watch.test.ts` 5 例:阈值前不告警、阈值后只发一次(持续故障 不刷屏)、探测抛错按不可用且错误进正文、恢复需连续成功且中途抖动会归零、 通知/管理员列表失败都不抛错。 - `tests/integration/storage-watch-admins.test.ts` 1 例(真实 DB):有效管理员在列, **过期成员与非管理员都不在**。反向验证:去掉 `expiresAt` 过滤后该用例转红并指出 过期管理员被算进收件人。 - `jobs-contract.test.ts` 清单加 `storage.watch` 5min(改名/改频率即红), 并断言 deps 缺失时**完全不注册**(零出网)。 - `apps/api` 单元测试全跑:98 文件 / 913 例通过;`tsc` 干净。 --- apps/api/src/jobs/index.ts | 15 +++ apps/api/src/jobs/kernel-jobs.ts | 2 + apps/api/src/lib/admin-recipients.ts | 28 ++++ apps/api/src/notifications/policy-service.ts | 16 +++ apps/api/src/notifications/storage-watch.ts | 111 ++++++++++++++++ apps/api/src/plugin-host.ts | 19 ++- .../integration/storage-watch-admins.test.ts | 74 +++++++++++ apps/api/tests/unit/jobs-contract.test.ts | 10 +- .../unit/notification-type-coverage.test.ts | 1 + apps/api/tests/unit/storage-watch.test.ts | 123 ++++++++++++++++++ 10 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/lib/admin-recipients.ts create mode 100644 apps/api/src/notifications/storage-watch.ts create mode 100644 apps/api/tests/integration/storage-watch-admins.test.ts create mode 100644 apps/api/tests/unit/storage-watch.test.ts diff --git a/apps/api/src/jobs/index.ts b/apps/api/src/jobs/index.ts index 7ec9af0c..06ebf899 100644 --- a/apps/api/src/jobs/index.ts +++ b/apps/api/src/jobs/index.ts @@ -90,6 +90,8 @@ export interface KernelSweepDeps { autoUpdate?: () => Promise; /** 清理已过期的限时权限组成员关系(会员权益)。缺省不注册。 */ membershipSweep?: () => Promise; + /** 存储可用性巡检(连续失败才告警)。缺省不注册:无存储探测实现时零出网。 */ + storageWatch?: () => Promise; logger: { warn: (m: string) => void }; } @@ -106,6 +108,11 @@ const OUTBOX_CLEANUP_EVERY = Math.max(1, Math.round((60 * 60_000) / OUTBOX_RELAY const CATALOG_REFRESH_MS = 6 * 60 * 60_000; /** 会员权益到期清理:每小时。过期判定在读路径,频率只影响清理及时性。 */ const MEMBERSHIP_SWEEP_MS = 60 * 60_000; +/** + * 存储巡检:5 分钟(与对方 `ossExceptionNotice` 同频)。告警阈值是「连续 3 次失败」, + * 所以真正的告警延迟约 15 分钟 —— 用抖动换取不误报。 + */ +const STORAGE_WATCH_MS = 5 * 60_000; /** * Register the kernel's recurring jobs. Missing handlers are simply absent on a @@ -168,6 +175,14 @@ export async function registerKernelSweeps( }); await jobs.schedule(KERNEL_JOBS.autoUpdate, { everyMs: CATALOG_REFRESH_MS }, undefined); } + // 存储可用性巡检:探测失败**不**抛错(否则任务进死信并反复重试), + // 由 `createStorageWatch` 内部按「连续失败」决定是否告警管理员。 + if (deps.storageWatch) { + jobs.handle(KERNEL_JOBS.storageWatch, async () => { + await deps.storageWatch?.(); + }); + await jobs.schedule(KERNEL_JOBS.storageWatch, { everyMs: STORAGE_WATCH_MS }, undefined); + } // 会员权益到期清理:删除已过期的限时成员关系。授权正确性由读路径保证 // (过期成员不授予任何权限),这里只做数据清理,晚跑一小时也不会放行过期会员。 if (deps.membershipSweep) { diff --git a/apps/api/src/jobs/kernel-jobs.ts b/apps/api/src/jobs/kernel-jobs.ts index 141f7ef1..bdefea0f 100644 --- a/apps/api/src/jobs/kernel-jobs.ts +++ b/apps/api/src/jobs/kernel-jobs.ts @@ -177,4 +177,6 @@ export const KERNEL_JOBS = { autoUpdate: 'distribution.auto-update', /** 会员权益到期清理(限时权限组成员关系)。 */ membershipSweep: 'membership.sweep-expired', + /** 存储可用性巡检 → 连续失败后告警管理员(对标 #12.5 `ossExceptionNotice`)。 */ + storageWatch: 'storage.watch', } as const; diff --git a/apps/api/src/lib/admin-recipients.ts b/apps/api/src/lib/admin-recipients.ts new file mode 100644 index 00000000..7dbd9304 --- /dev/null +++ b/apps/api/src/lib/admin-recipients.ts @@ -0,0 +1,28 @@ +import type { PrismaClient } from '@stackpanel/db'; + +/** + * 「管理员」在数据模型里不是一个字段,而是权限组授予的 `platform.admin`(RBAC)。 + * + * 告警类通知需要一个收件人列表时,必须按**同一口径**取人:写死 `role: 'ADMIN'` + * 之类的判断会挑出「看起来是管理员但没有权限」的收件人,或者漏掉真正有权的人。 + * 时限成员(会员商品授予)按读路径规则过滤过期行 —— 与 `hasPermission` 一致。 + */ +export const ADMIN_PERMISSION = 'platform.admin'; + +export async function listAdminUserIds( + db: PrismaClient, + now: Date = new Date(), +): Promise { + const admins = await db.user.findMany({ + where: { + groups: { + some: { + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + group: { permissions: { some: { permission: { key: ADMIN_PERMISSION } } } }, + }, + }, + }, + select: { id: true }, + }); + return admins.map((admin) => admin.id); +} diff --git a/apps/api/src/notifications/policy-service.ts b/apps/api/src/notifications/policy-service.ts index 94f5328a..84724ec9 100644 --- a/apps/api/src/notifications/policy-service.ts +++ b/apps/api/src/notifications/policy-service.ts @@ -88,6 +88,22 @@ export const BUILTIN_NOTIFICATION_TYPES: readonly NotificationTypeDefinition[] = audience: 'admin', order: 30, }, + // 存储巡检告警(`storage-watch.ts`):只发给管理员,且成功/失败都只发一次 + // (连续失败才报故障、连续成功才报恢复),避免抖动刷屏。 + { + type: 'admin.storage.unreachable', + category: 'system', + label: '存储不可用', + audience: 'admin', + order: 40, + }, + { + type: 'admin.storage.recovered', + category: 'system', + label: '存储已恢复', + audience: 'admin', + order: 41, + }, { type: 'security.password.reset', category: 'security', diff --git a/apps/api/src/notifications/storage-watch.ts b/apps/api/src/notifications/storage-watch.ts new file mode 100644 index 00000000..02f28825 --- /dev/null +++ b/apps/api/src/notifications/storage-watch.ts @@ -0,0 +1,111 @@ +import type { NotificationsService } from '@stackpanel/sdk'; +import { notifyUserBestEffort } from './notify.ts'; + +/** + * 存储(对象存储 / 本地磁盘)异常告警(对标 #12.5 的 `ossExceptionNotice`)。 + * + * 对方每 5 分钟探一次 OSS,失败就通知管理员。直接照抄有两个问题: + * + * 1. **抖一下就通知**:一次超时可能是网络瞬时抖动,管理员会被无意义的告警训练成 + * 「不看了」。所以连续失败 `STORAGE_ALERT_AFTER_FAILURES` 次才发。 + * 2. **恢复后没有下文**:只报故障不报恢复,管理员不知道要不要继续排查。恢复同样 + * 需要连续 `STORAGE_RECOVERY_AFTER_SUCCESSES` 次成功才发,避免「故障/恢复」 + * 交替刷屏。 + * + * 状态放在进程内存里:任务执行本身已被数据库租约 / BullMQ 收敛为「集群只跑一份」 + * (见 `REF-定时任务与幂等契约.md` §5.1),所以不需要跨副本共享。进程重启会丢失 + * 「已告警」标记 —— 若此时存储仍然不可用,会重新告警一次。这是**刻意**的:持久故障 + * 值得再提醒一次,而代价只是一条通知。 + * + * 通知是告警的附属品:探测或写入失败都不向任务抛出,否则任务会进死信并被反复重试。 + */ + +/** 连续失败多少次才告警(5 分钟一次 → 约 15 分钟)。 */ +export const STORAGE_ALERT_AFTER_FAILURES = 3; +/** 连续成功多少次才发「已恢复」(避免故障/恢复交替刷屏)。 */ +export const STORAGE_RECOVERY_AFTER_SUCCESSES = 2; + +export interface StorageWatchDeps { + /** 探测存储可用性;实现内部应自己兜住异常,但抛错也按「不可用」处理。 */ + ping: () => Promise; + notifications: NotificationsService; + /** 列出管理员用户 id(告警收件人)。 */ + listAdminIds: () => Promise; + logger: { warn: (message: string) => void; info?: (message: string) => void }; +} + +function messageOf(error: unknown): string { + return error instanceof Error && error.message ? error.message : String(error); +} + +export function createStorageWatch(deps: StorageWatchDeps): () => Promise { + let failures = 0; + let successes = 0; + let alerted = false; + let lastError = ''; + + const announce = async (notice: { + type: 'admin.storage.unreachable' | 'admin.storage.recovered'; + title: string; + body: string; + }): Promise => { + const { type, title, body } = notice; + let adminIds: string[]; + try { + adminIds = await deps.listAdminIds(); + } catch (error) { + deps.logger.warn(`[storage-watch] 读取管理员列表失败:${messageOf(error)}`); + return; + } + if (adminIds.length === 0) { + deps.logger.warn('[storage-watch] 没有可通知的管理员,告警被丢弃'); + return; + } + for (const userId of adminIds) { + await notifyUserBestEffort( + { notifications: deps.notifications, logger: deps.logger }, + { userId, type, title, body, link: '/admin/settings' }, + ); + } + deps.logger.info?.(`[storage-watch] 已向 ${adminIds.length} 名管理员发送「${title}」`); + }; + + return async () => { + let ok: boolean; + try { + ok = await deps.ping(); + } catch (error) { + ok = false; + lastError = messageOf(error); + } + if (!ok && !lastError) lastError = '探测返回不可用'; + + if (ok) { + failures = 0; + lastError = ''; + if (!alerted) return; + successes += 1; + if (successes < STORAGE_RECOVERY_AFTER_SUCCESSES) return; + successes = 0; + alerted = false; + await announce({ + // 字面量写在 `type:` 位置:`notification-type-coverage` 靠扫这个模式守门, + // 用模板串拼类型就会绕过守门(正是这个测试存在的理由)。 + type: 'admin.storage.recovered', + title: '存储已恢复', + body: '此前不可用的对象存储已连续探测成功,媒体上传与附件下载应已恢复。', + }); + return; + } + + successes = 0; + failures += 1; + if (alerted || failures < STORAGE_ALERT_AFTER_FAILURES) return; + alerted = true; + await announce({ + type: 'admin.storage.unreachable', + title: '存储不可用', + body: `对象存储连续 ${failures} 次探测失败(最近一次:${lastError}),媒体上传与附件下载可能失败,请检查存储配置与网络。`, + }); + }; +} diff --git a/apps/api/src/plugin-host.ts b/apps/api/src/plugin-host.ts index 67fb067d..7831aa89 100644 --- a/apps/api/src/plugin-host.ts +++ b/apps/api/src/plugin-host.ts @@ -43,6 +43,8 @@ import { KernelNotificationsService } from './notifications/notifications-servic import { KernelNotificationPolicyService } from './notifications/policy-service.ts'; import { KernelIdentityChallengeService } from './auth/identity-challenge-service.ts'; import { KernelCaptchaService } from './auth/captcha-service.ts'; +import { createStorageWatch } from './notifications/storage-watch.ts'; +import { listAdminUserIds } from './lib/admin-recipients.ts'; import { getStateService } from './state/index.ts'; import { getCacheService, getDataCache } from './cache/index.ts'; import { getJobRuntime, registerKernelSweeps, createKernelJobContext } from './jobs/index.ts'; @@ -50,7 +52,7 @@ import { registerBackupJobs } from './backup/jobs.ts'; import { registerConversationRetentionJobs } from './agent/retention.ts'; import { registerMediaJobs } from './media/jobs.ts'; import { AttachmentReferenceService } from './media/references.ts'; -import { setStorageRuntime, refreshStorageDriver } from './infra.ts'; +import { getStorage, setStorageRuntime, refreshStorageDriver } from './infra.ts'; import { NotificationDispatcher } from './notifications/dispatcher.ts'; export interface PluginHostLogger { @@ -312,6 +314,21 @@ export function createPluginHost(options: PluginHostOptions): PluginHost { .cleanup() .then(() => undefined), notificationDeliverySweep: () => notificationDispatcher.sweep(), + // 存储巡检(对标 #12.5 `ossExceptionNotice`):连续失败才告警管理员, + // 连续成功才报恢复。探测本身失败只累加计数,不抛给任务。 + storageWatch: createStorageWatch({ + ping: async () => { + const driver = getStorage(); + if (!('ping' in driver) || typeof (driver as { ping?: unknown }).ping !== 'function') { + // 本地磁盘驱动没有探测能力:磁盘挂了 API 自己也起不来,无需巡检。 + return true; + } + return (driver as unknown as { ping(): Promise }).ping(); + }, + notifications: notificationsService, + listAdminIds: () => listAdminUserIds(getPrisma()), + logger, + }), // 会员权益到期清理:删除已过期的限时成员关系(会员商品授予)。 // 授权正确性由读路径保证,这里只是数据清理。 membershipSweep: async () => { diff --git a/apps/api/tests/integration/storage-watch-admins.test.ts b/apps/api/tests/integration/storage-watch-admins.test.ts new file mode 100644 index 00000000..fb886d13 --- /dev/null +++ b/apps/api/tests/integration/storage-watch-admins.test.ts @@ -0,0 +1,74 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { getPrisma } from '../../src/plugins/prisma.ts'; +import { checkDbAvailable } from '../helpers.ts'; +import { ADMIN_PERMISSION, listAdminUserIds } from '../../src/lib/admin-recipients.ts'; + +/** + * 告警收件人解析(`listAdminUserIds`,供存储巡检告警使用)。 + * + * 这条查询很容易写「看起来对」但其实错:管理员在数据模型里**不是** `User` 上的字段, + * 而是权限组授予的 `platform.admin`;而且限时成员(会员商品授予)必须按读路径 + * 规则过滤掉过期行,否则一条早就过期的管理员权益会让告警一直发给无权的人。 + * 用真实 DB 验,避免只靠假对象自证。 + */ +const dbAvailable = await checkDbAvailable(); +const prisma = getPrisma(); + +describe.skipIf(!dbAvailable)('listAdminUserIds(真实 DB)', () => { + const stamp = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const groupId = `test-admins-${stamp}`; + const otherGroupId = `test-plain-${stamp}`; + const created: string[] = []; + + afterAll(async () => { + if (created.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: created } } }).catch(() => undefined); + } + await prisma.permissionGroup.deleteMany({ where: { id: { in: [groupId, otherGroupId] } } }) + .catch(() => undefined); + }); + + it('只返回「有效管理员」,过期成员与非管理员都不算', async () => { + const permission = await prisma.permission.upsert({ + where: { key: ADMIN_PERMISSION }, + update: {}, + create: { key: ADMIN_PERMISSION, name: '平台管理员' }, + }); + await prisma.permissionGroup.create({ + data: { + id: groupId, + name: `测试管理员组 ${stamp}`, + permissions: { create: { permissionId: permission.id } }, + }, + }); + await prisma.permissionGroup.create({ + data: { id: otherGroupId, name: `测试普通组 ${stamp}` }, + }); + + const admin = await prisma.user.create({ + data: { email: `admin-${stamp}@example.test`, groups: { create: { groupId } } }, + }); + const expiredAdmin = await prisma.user.create({ + data: { + email: `expired-admin-${stamp}@example.test`, + groups: { + create: { + groupId, + // 会员权益到期:读路径会把它当作「无此权限」,收件人列表必须一致。 + expiresAt: new Date(Date.now() - 60_000), + }, + }, + }, + }); + const plain = await prisma.user.create({ + data: { email: `plain-${stamp}@example.test`, groups: { create: { groupId: otherGroupId } } }, + }); + created.push(admin.id, expiredAdmin.id, plain.id); + + const ids = await listAdminUserIds(prisma); + + expect(ids).toContain(admin.id); + expect(ids).not.toContain(expiredAdmin.id); + expect(ids).not.toContain(plain.id); + }, 30_000); +}); diff --git a/apps/api/tests/unit/jobs-contract.test.ts b/apps/api/tests/unit/jobs-contract.test.ts index 587c5e59..dd970597 100644 --- a/apps/api/tests/unit/jobs-contract.test.ts +++ b/apps/api/tests/unit/jobs-contract.test.ts @@ -44,6 +44,7 @@ function deps(overrides: Partial[1]> = { catalogRefresh: vi.fn(async () => undefined), autoUpdate: vi.fn(async () => undefined), membershipSweep: vi.fn(async () => undefined), + storageWatch: vi.fn(async () => undefined), logger: { warn: vi.fn() }, ...overrides, }; @@ -59,6 +60,8 @@ const DOCUMENTED: Array<[string, number]> = [ [KERNEL_JOBS.catalogRefresh, 6 * 60 * 60_000], [KERNEL_JOBS.autoUpdate, 6 * 60 * 60_000], [KERNEL_JOBS.membershipSweep, 60 * 60_000], + // 与对方 `ossExceptionNotice` 同频;告警阈值是「连续 3 次失败」,不靠频率防抖。 + [KERNEL_JOBS.storageWatch, 5 * 60_000], ]; describe('#12.5 kernel job inventory matches the documented contract', () => { @@ -99,7 +102,12 @@ describe('#12.5 kernel job inventory matches the documented contract', () => { ].sort(), ); // 目录源被关闭的实例不注册处理器:不是「注册了但空跑」,是没有这一项。 - for (const name of [KERNEL_JOBS.catalogRefresh, KERNEL_JOBS.autoUpdate, KERNEL_JOBS.membershipSweep]) { + for (const name of [ + KERNEL_JOBS.catalogRefresh, + KERNEL_JOBS.autoUpdate, + KERNEL_JOBS.membershipSweep, + KERNEL_JOBS.storageWatch, + ]) { expect(handlers.has(name), `handler for ${name}`).toBe(false); } }); diff --git a/apps/api/tests/unit/notification-type-coverage.test.ts b/apps/api/tests/unit/notification-type-coverage.test.ts index 059beec9..47a2cd6c 100644 --- a/apps/api/tests/unit/notification-type-coverage.test.ts +++ b/apps/api/tests/unit/notification-type-coverage.test.ts @@ -34,6 +34,7 @@ const KERNEL_EMITTERS = [ 'apps/api/src/routes/admin/updates.ts', 'apps/api/src/notifications/lifecycle-notifications.ts', 'apps/api/src/notifications/agent-notifications.ts', + 'apps/api/src/notifications/storage-watch.ts', 'apps/api/src/routes/agent.ts', 'apps/api/src/agent/background.ts', ]; diff --git a/apps/api/tests/unit/storage-watch.test.ts b/apps/api/tests/unit/storage-watch.test.ts new file mode 100644 index 00000000..aee2c86a --- /dev/null +++ b/apps/api/tests/unit/storage-watch.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createStorageWatch, + STORAGE_ALERT_AFTER_FAILURES, + STORAGE_RECOVERY_AFTER_SUCCESSES, +} from '../../src/notifications/storage-watch.ts'; + +/** + * 存储巡检告警(对标 #12.5 `ossExceptionNotice`)。 + * + * 这里锁的是**告警时机**,不是「能不能发通知」:直接照抄对方「失败一次就通知」 + * 会让管理员被抖动训练成不看告警,所以断言的正是「连续失败才报、连续成功才说恢复」。 + */ +function harness(overrides: { + admins?: string[]; + listAdminsFails?: boolean; + notifyFails?: boolean; +} = {}) { + const created: Array> = []; + const notifications = { + create: vi.fn(async (input: Record) => { + if (overrides.notifyFails) throw new Error('notifications down'); + created.push(input); + return { id: `n-${created.length}`, ...input }; + }), + }; + const listAdminIds = vi.fn(async () => { + if (overrides.listAdminsFails) throw new Error('db down'); + return overrides.admins ?? ['admin-1', 'admin-2']; + }); + const logger = { warn: vi.fn(), info: vi.fn() }; + const ping = vi.fn(async () => true); + const watch = createStorageWatch({ + ping, + notifications: notifications as never, + listAdminIds, + logger, + }); + return { watch, ping, created, listAdminIds, logger }; +} + +const types = (created: Array>) => created.map((row) => row.type); + +describe('存储巡检告警', () => { + it('连续失败到阈值才告警,且只发一次', async () => { + const h = harness(); + h.ping.mockResolvedValue(false); + + for (let i = 0; i < STORAGE_ALERT_AFTER_FAILURES - 1; i += 1) await h.watch(); + // 抖动不告警:这是与对方「失败即通知」的关键差异。 + expect(h.created).toHaveLength(0); + + await h.watch(); + expect(types(h.created)).toEqual([ + 'admin.storage.unreachable', + 'admin.storage.unreachable', + ]); + + // 已经报过就不再刷屏,故障持续多久都只有那一条。 + await h.watch(); + await h.watch(); + expect(h.created).toHaveLength(2); + }); + + it('探测抛错按不可用处理,不会被当成可用', async () => { + const h = harness(); + h.ping.mockRejectedValue(new Error('connect ETIMEDOUT')); + + for (let i = 0; i < STORAGE_ALERT_AFTER_FAILURES; i += 1) await h.watch(); + + expect(types(h.created)).toContain('admin.storage.unreachable'); + expect(String(h.created[0]?.body)).toContain('ETIMEDOUT'); + }); + + it('恢复要连续成功到阈值,且失败会重置计数', async () => { + const h = harness(); + h.ping.mockResolvedValue(false); + for (let i = 0; i < STORAGE_ALERT_AFTER_FAILURES; i += 1) await h.watch(); + h.created.length = 0; + + h.ping.mockResolvedValue(true); + await h.watch(); + expect(h.created).toHaveLength(0); + + // 中间又抖了一次:连续成功计数必须归零,否则「恢复」会在故障中途发出。 + h.ping.mockResolvedValue(false); + await h.watch(); + h.ping.mockResolvedValue(true); + for (let i = 0; i < STORAGE_RECOVERY_AFTER_SUCCESSES - 1; i += 1) await h.watch(); + expect(h.created).toHaveLength(0); + + await h.watch(); + expect(types(h.created)).toEqual([ + 'admin.storage.recovered', + 'admin.storage.recovered', + ]); + }); + + it('通知失败不让任务抛错(否则任务会进死信并被反复重试)', async () => { + const h = harness({ notifyFails: true }); + h.ping.mockResolvedValue(false); + + for (let i = 0; i < STORAGE_ALERT_AFTER_FAILURES; i += 1) { + await expect(h.watch()).resolves.toBeUndefined(); + } + expect(h.logger.warn).toHaveBeenCalled(); + }); + + it('没有管理员或读不到管理员列表时只记日志,不抛错', async () => { + const none = harness({ admins: [] }); + none.ping.mockResolvedValue(false); + for (let i = 0; i < STORAGE_ALERT_AFTER_FAILURES; i += 1) await none.watch(); + expect(none.created).toHaveLength(0); + expect(none.logger.warn).toHaveBeenCalled(); + + const broken = harness({ listAdminsFails: true }); + broken.ping.mockResolvedValue(false); + for (let i = 0; i < STORAGE_ALERT_AFTER_FAILURES; i += 1) { + await expect(broken.watch()).resolves.toBeUndefined(); + } + expect(broken.logger.warn).toHaveBeenCalled(); + }); +});