fix(治理): 完成任务 8 安全与通知闭环修复

- 串行化拉黑、处罚、投瓶和匹配策略检查\n- 完成异步站内通知、未读统计和偏好并发语义\n- 补齐后台查询审计、处罚恢复和隐私测试\n- 稳定 Redis 恢复、匹配锁序及超时测试
This commit is contained in:
root
2026-09-17 13:08:55 +08:00
parent ce8e1db279
commit fa0fa78312
29 changed files with 1231 additions and 153 deletions
+8
View File
@@ -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(
+6 -2
View File
@@ -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 } });
}
});
});
+38
View File
@@ -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;
});
}
}