fix(治理): 完成任务 8 安全与通知闭环修复
- 串行化拉黑、处罚、投瓶和匹配策略检查\n- 完成异步站内通知、未读统计和偏好并发语义\n- 补齐后台查询审计、处罚恢复和隐私测试\n- 稳定 Redis 恢复、匹配锁序及超时测试
This commit is contained in:
@@ -17,6 +17,14 @@ describe("worker lifecycle", () => {
|
||||
expect(reaper.runOnce).toHaveBeenCalledTimes(1);
|
||||
expect(moderation.runOnce).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("invokes reaping, moderation and notification each iteration", async () => {
|
||||
const reaper = { runOnce: vi.fn().mockResolvedValue(true) };
|
||||
const moderation = { runOnce: vi.fn().mockResolvedValue(false) };
|
||||
const notification = { runOnce: vi.fn().mockResolvedValue(true) };
|
||||
const worker = createCombinedWorker(reaper, moderation, notification);
|
||||
await expect(worker.runOnce()).resolves.toBe(true);
|
||||
expect(notification.runOnce).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("waits for the current run before disconnecting on SIGTERM", async () => {
|
||||
let finish!: () => void;
|
||||
const runOnce = vi.fn(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { pathToFileURL } from "node:url";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { ModerationWorker } from "./moderation-worker.js";
|
||||
import { LeaseReaper } from "./lease-reaper.processor.js";
|
||||
import { NotificationProcessor } from "./notification.processor.js";
|
||||
|
||||
type SignalSource = {
|
||||
once(signal: "SIGTERM" | "SIGINT", listener: () => void): unknown;
|
||||
@@ -22,12 +23,14 @@ type Worker = { runOnce(): Promise<boolean> };
|
||||
export function createCombinedWorker(
|
||||
reaper: Worker,
|
||||
moderation: Worker,
|
||||
notification?: Worker,
|
||||
): Worker {
|
||||
return {
|
||||
async runOnce() {
|
||||
const reaped = await reaper.runOnce();
|
||||
const moderated = await moderation.runOnce();
|
||||
return reaped || moderated;
|
||||
const notified = notification ? await notification.runOnce() : false;
|
||||
return reaped || moderated || notified;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -65,7 +68,8 @@ export async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
const moderation = new ModerationWorker(prisma);
|
||||
const reaper = new LeaseReaper(prisma);
|
||||
const worker = createCombinedWorker(reaper, moderation);
|
||||
const notification = new NotificationProcessor(prisma);
|
||||
const worker = createCombinedWorker(reaper, moderation, notification);
|
||||
await runWorker({
|
||||
worker,
|
||||
connect: () => prisma.$connect(),
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { assertSafeTestDatabaseUrl } from "../../../prisma/database-safety.js";
|
||||
import { NotificationProcessor } from "./notification.processor.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
describe("NotificationProcessor", () => {
|
||||
beforeAll(async () => {
|
||||
assertSafeTestDatabaseUrl(process.env.DATABASE_URL ?? "");
|
||||
await prisma.$connect();
|
||||
});
|
||||
afterAll(async () => prisma.$disconnect());
|
||||
|
||||
it("lets a preference disable committed before worker delivery win the race", async () => {
|
||||
const account = await prisma.account.create({
|
||||
data: { phoneCiphertext: Buffer.from("race"), phoneHmac: randomUUID() },
|
||||
});
|
||||
const preference = await prisma.pushPreference.create({
|
||||
data: { accountId: account.id, inAppEnabled: true },
|
||||
});
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
dedupeKey: randomUUID(),
|
||||
type: "TEST",
|
||||
payload: {},
|
||||
},
|
||||
});
|
||||
await prisma.notification.updateMany({
|
||||
where: { status: "PENDING", id: { not: notification.id } },
|
||||
data: { status: "FAILED" },
|
||||
});
|
||||
let release!: () => void;
|
||||
let locked!: () => void;
|
||||
const lockHeld = new Promise<void>((resolve) => {
|
||||
locked = resolve;
|
||||
});
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const disabling = prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT "account_id" FROM "push_preferences" WHERE "account_id"=${preference.accountId}::uuid FOR UPDATE`;
|
||||
locked();
|
||||
await gate;
|
||||
await tx.pushPreference.update({
|
||||
where: { accountId: account.id },
|
||||
data: { inAppEnabled: false },
|
||||
});
|
||||
});
|
||||
try {
|
||||
await lockHeld;
|
||||
const delivery = new NotificationProcessor(prisma).runOnce();
|
||||
// Wait until the worker holds the notification lock and has reached the
|
||||
// preference boundary; an unlocked preference would expose stale true.
|
||||
let claimed = false;
|
||||
for (let attempt = 0; attempt < 100 && !claimed; attempt += 1) {
|
||||
try {
|
||||
await prisma.$transaction(async (probe) => {
|
||||
await probe.$queryRaw`SELECT "id" FROM "notifications" WHERE "id"=${notification.id}::uuid FOR UPDATE NOWAIT`;
|
||||
});
|
||||
} catch {
|
||||
claimed = true;
|
||||
}
|
||||
if (!claimed) await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
expect(claimed).toBe(true);
|
||||
release();
|
||||
await disabling;
|
||||
expect(await delivery).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await prisma.notification.findUniqueOrThrow({
|
||||
where: { id: notification.id },
|
||||
})
|
||||
).status,
|
||||
).toBe("FAILED");
|
||||
} finally {
|
||||
release();
|
||||
await disabling;
|
||||
await prisma.account.delete({ where: { id: account.id } });
|
||||
}
|
||||
});
|
||||
|
||||
it("delivers a pending in-app notification once and skips disabled preferences", async () => {
|
||||
const account = await prisma.account.create({
|
||||
data: { phoneCiphertext: Buffer.from("test"), phoneHmac: randomUUID() },
|
||||
});
|
||||
try {
|
||||
const enabled = await prisma.notification.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
type: "TEST",
|
||||
dedupeKey: randomUUID(),
|
||||
payload: { safe: true },
|
||||
},
|
||||
});
|
||||
await prisma.notification.updateMany({
|
||||
where: { status: "PENDING", id: { not: enabled.id } },
|
||||
data: { status: "FAILED" },
|
||||
});
|
||||
const worker = new NotificationProcessor(prisma);
|
||||
expect(await worker.runOnce()).toBe(true);
|
||||
const delivered = await prisma.notification.findUniqueOrThrow({
|
||||
where: { id: enabled.id },
|
||||
});
|
||||
expect(delivered).toMatchObject({ status: "SENT", readAt: null });
|
||||
expect(delivered.sentAt).toBeInstanceOf(Date);
|
||||
expect(await worker.runOnce()).toBe(false);
|
||||
await prisma.pushPreference.create({
|
||||
data: { accountId: account.id, inAppEnabled: false },
|
||||
});
|
||||
const disabled = await prisma.notification.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
type: "TEST",
|
||||
dedupeKey: randomUUID(),
|
||||
payload: { safe: true },
|
||||
},
|
||||
});
|
||||
expect(await worker.runOnce()).toBe(true);
|
||||
expect(
|
||||
await prisma.notification.findUniqueOrThrow({
|
||||
where: { id: disabled.id },
|
||||
}),
|
||||
).toMatchObject({ status: "FAILED", sentAt: null });
|
||||
} finally {
|
||||
await prisma.account.delete({ where: { id: account.id } });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
/** Deliver committed PENDING in-app rows after serializing against opt-out. */
|
||||
export class NotificationProcessor {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async runOnce(): Promise<boolean> {
|
||||
// Claim and update share a transaction; the row lock lives until commit.
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.$queryRaw<
|
||||
Array<{ id: string; accountId: string }>
|
||||
>`
|
||||
SELECT n."id", n."account_id" AS "accountId"
|
||||
FROM "notifications" n
|
||||
WHERE n."status" = 'PENDING'
|
||||
ORDER BY n."created_at", n."id"
|
||||
FOR UPDATE OF n SKIP LOCKED LIMIT 1`;
|
||||
const notification = claimed[0];
|
||||
if (!notification) return false;
|
||||
// n -> account -> preference is the fixed lock order. Locking the
|
||||
// account also serializes preference-row insertion when none exists.
|
||||
await tx.$queryRaw`
|
||||
SELECT "id" FROM "accounts"
|
||||
WHERE "id"=${notification.accountId}::uuid FOR UPDATE`;
|
||||
const preference = await tx.$queryRaw<Array<{ enabled: boolean }>>`
|
||||
SELECT "in_app_enabled" AS "enabled" FROM "push_preferences"
|
||||
WHERE "account_id"=${notification.accountId}::uuid FOR UPDATE`;
|
||||
await tx.notification.update({
|
||||
where: { id: notification.id },
|
||||
data:
|
||||
preference[0]?.enabled !== false
|
||||
? { status: "SENT", sentAt: new Date() }
|
||||
: { status: "FAILED" },
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user