Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/api/src/jobs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export interface KernelSweepDeps {
autoUpdate?: () => Promise<void>;
/** 清理已过期的限时权限组成员关系(会员权益)。缺省不注册。 */
membershipSweep?: () => Promise<void>;
/** 存储可用性巡检(连续失败才告警)。缺省不注册:无存储探测实现时零出网。 */
storageWatch?: () => Promise<void>;
logger: { warn: (m: string) => void };
}

Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/jobs/kernel-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,6 @@ export const KERNEL_JOBS = {
autoUpdate: 'distribution.auto-update',
/** 会员权益到期清理(限时权限组成员关系)。 */
membershipSweep: 'membership.sweep-expired',
/** 存储可用性巡检 → 连续失败后告警管理员(对标 #12.5 `ossExceptionNotice`)。 */
storageWatch: 'storage.watch',
} as const;
28 changes: 28 additions & 0 deletions apps/api/src/lib/admin-recipients.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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);
}
16 changes: 16 additions & 0 deletions apps/api/src/notifications/policy-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
111 changes: 111 additions & 0 deletions apps/api/src/notifications/storage-watch.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
notifications: NotificationsService;
/** 列出管理员用户 id(告警收件人)。 */
listAdminIds: () => Promise<string[]>;
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<void> {
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<void> => {
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}),媒体上传与附件下载可能失败,请检查存储配置与网络。`,
});
};
}
19 changes: 18 additions & 1 deletion apps/api/src/plugin-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,16 @@ 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';
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 {
Expand Down Expand Up @@ -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<boolean> }).ping();
},
notifications: notificationsService,
listAdminIds: () => listAdminUserIds(getPrisma()),
logger,
}),
// 会员权益到期清理:删除已过期的限时成员关系(会员商品授予)。
// 授权正确性由读路径保证,这里只是数据清理。
membershipSweep: async () => {
Expand Down
74 changes: 74 additions & 0 deletions apps/api/tests/integration/storage-watch-admins.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
10 changes: 9 additions & 1 deletion apps/api/tests/unit/jobs-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function deps(overrides: Partial<Parameters<typeof registerKernelSweeps>[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,
};
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);
}
});
Expand Down
1 change: 1 addition & 0 deletions apps/api/tests/unit/notification-type-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
Expand Down
Loading
Loading