feat: 实现投瓶和异步内容审核

This commit is contained in:
root
2026-09-15 01:28:11 +08:00
parent b480d2c91f
commit f455552e92
25 changed files with 1204 additions and 18 deletions
+169
View File
@@ -0,0 +1,169 @@
import { PrismaClient } from "@prisma/client";
import { randomUUID } from "node:crypto";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { assertSafeTestDatabaseUrl } from "../../../prisma/database-safety.js";
import { ModerationWorker, decideModeration } from "./moderation-worker.js";
const prisma = new PrismaClient();
describe("moderation worker with real PostgreSQL", () => {
beforeEach(async () => {
assertSafeTestDatabaseUrl(process.env.DATABASE_URL ?? "");
await prisma.$connect();
await prisma.outboxEvent.deleteMany();
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
});
afterAll(() => prisma.$disconnect());
it.each([
["ordinary sea note", "APPROVED", "IN_POOL"],
["contains reject-word", "REJECTED", "CLOSED"],
["contains review-word", "MANUAL_REVIEW", "CLOSED"],
] as const)(
"maps simulated rule %#",
async (content, reviewStatus, poolStatus) => {
const { bottle } = await fixture(content);
expect(await new ModerationWorker(prisma).runOnce()).toBe(true);
const updated = await prisma.bottle.findUniqueOrThrow({
where: { id: bottle.id },
});
expect(updated).toMatchObject({ reviewStatus, poolStatus });
expect(updated.approvedAt !== null).toBe(reviewStatus === "APPROVED");
expect((await prisma.moderationTask.findFirstOrThrow()).status).toBe(
"COMPLETED",
);
expect((await prisma.outboxEvent.findFirstOrThrow()).status).toBe(
"PUBLISHED",
);
expect(await new ModerationWorker(prisma).runOnce()).toBe(false);
},
);
it("is fail-closed and exponentially reschedules failures", async () => {
const { bottle, event } = await fixture("ordinary");
const worker = new ModerationWorker(prisma, () => {
throw new Error("provider unavailable");
});
await expect(worker.runOnce()).resolves.toBe(true);
expect(
await prisma.bottle.findUniqueOrThrow({ where: { id: bottle.id } }),
).toMatchObject({
reviewStatus: "REVIEWING",
poolStatus: "CLOSED",
});
const failed = await prisma.outboxEvent.findUniqueOrThrow({
where: { id: event.id },
});
expect(failed.status).toBe("FAILED");
expect(failed.attempts).toBe(1);
expect(failed.nextRetryAt.getTime()).toBeGreaterThan(Date.now());
});
it("stops retrying at max attempts while remaining fail-closed", async () => {
const { bottle, event } = await fixture("ordinary");
await prisma.outboxEvent.update({
where: { id: event.id },
data: { attempts: 4 },
});
const worker = new ModerationWorker(prisma, () => {
throw new Error("provider unavailable");
});
await worker.runOnce();
const failed = await prisma.outboxEvent.findUniqueOrThrow({
where: { id: event.id },
});
expect(failed).toMatchObject({ status: "FAILED", attempts: 5 });
expect(failed.nextRetryAt.getUTCFullYear()).toBe(9999);
expect(await worker.runOnce()).toBe(false);
expect(
await prisma.bottle.findUniqueOrThrow({ where: { id: bottle.id } }),
).toMatchObject({ reviewStatus: "REVIEWING", poolStatus: "CLOSED" });
});
it("does not publish when the task payload points at another task", async () => {
const first = await fixture("ordinary");
const second = await fixture("another");
await prisma.outboxEvent.delete({ where: { id: second.event.id } });
await prisma.outboxEvent.update({
where: { id: first.event.id },
data: { payload: { bottleId: first.bottle.id, taskId: second.task.id } },
});
await new ModerationWorker(prisma).runOnce();
expect(
(
await prisma.outboxEvent.findUniqueOrThrow({
where: { id: first.event.id },
})
).status,
).toBe("FAILED");
expect(
await prisma.bottle.findUniqueOrThrow({ where: { id: first.bottle.id } }),
).toMatchObject({ reviewStatus: "REVIEWING", poolStatus: "CLOSED" });
});
it("lets two workers claim one event only once", async () => {
await fixture("ordinary");
const entered = vi.fn();
const decide = async (text: string) => {
entered();
await new Promise((resolve) => setTimeout(resolve, 100));
return decideModeration(text);
};
const outcomes = await Promise.all([
new ModerationWorker(prisma, decide).runOnce(),
new ModerationWorker(prisma, decide).runOnce(),
]);
expect(outcomes.sort()).toEqual([false, true]);
expect(entered).toHaveBeenCalledTimes(1);
expect((await prisma.outboxEvent.findFirstOrThrow()).attempts).toBe(1);
});
it("recovers an expired PROCESSING lease", async () => {
const { event } = await fixture("ordinary");
await prisma.outboxEvent.update({
where: { id: event.id },
data: { status: "PROCESSING", lockedAt: new Date(Date.now() - 60_000) },
});
expect(await new ModerationWorker(prisma, undefined, 1000).runOnce()).toBe(
true,
);
expect(
(await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }))
.status,
).toBe("PUBLISHED");
});
async function fixture(contentText: string) {
const account = await prisma.account.create({
data: { phoneCiphertext: Buffer.from("cipher"), phoneHmac: randomUUID() },
});
const bottle = await prisma.bottle.create({
data: {
authorId: account.id,
clientRequestId: randomUUID(),
contentText,
reviewStatus: "REVIEWING",
poolStatus: "CLOSED",
},
});
const task = await prisma.moderationTask.create({
data: {
targetType: "BOTTLE",
targetId: bottle.id,
provider: "SIMULATED",
payloadHash: "a".repeat(64),
riskLabels: [],
},
});
const event = await prisma.outboxEvent.create({
data: {
aggregateType: "BOTTLE",
aggregateId: bottle.id,
eventType: "BOTTLE_MODERATION_REQUESTED",
dedupeKey: randomUUID(),
payload: { bottleId: bottle.id, taskId: task.id },
},
});
return { bottle, task, event };
}
});