fix: 完善资料审核与 Worker 状态一致性

This commit is contained in:
root
2026-09-15 08:30:39 +08:00
parent f455552e92
commit ec573b5ae2
10 changed files with 639 additions and 101 deletions
+264
View File
@@ -133,6 +133,230 @@ describe("moderation worker with real PostgreSQL", () => {
).toBe("PUBLISHED");
});
it("publishes a replay when the matching bottle task is already completed", async () => {
const { bottle, event, task } = await fixture("ordinary");
await prisma.moderationTask.update({
where: { id: task.id },
data: {
status: "COMPLETED",
decision: "APPROVED",
result: { decision: "APPROVED" },
reviewedAt: new Date(),
},
});
const decide = vi.fn(decideModeration);
expect(await new ModerationWorker(prisma, decide).runOnce()).toBe(true);
expect(decide).not.toHaveBeenCalled();
expect(
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
).toMatchObject({ status: "PUBLISHED" });
expect(
await prisma.bottle.findUniqueOrThrow({ where: { id: bottle.id } }),
).toMatchObject({ reviewStatus: "REVIEWING", poolStatus: "CLOSED" });
});
it("fails the event and leaves the task open when bottle state drift prevents the update", async () => {
const { bottle, event, task } = await fixture("ordinary");
await prisma.bottle.update({
where: { id: bottle.id },
data: { reviewStatus: "APPROVED", poolStatus: "IN_POOL" },
});
expect(await new ModerationWorker(prisma).runOnce()).toBe(true);
expect(
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
).toMatchObject({ status: "FAILED", attempts: 1 });
expect(
await prisma.moderationTask.findUniqueOrThrow({ where: { id: task.id } }),
).toMatchObject({ status: "PENDING", decision: null });
});
it("reaps a stale PROCESSING event already at max attempts without handling it", async () => {
const { event } = await fixture("ordinary");
await prisma.outboxEvent.update({
where: { id: event.id },
data: {
status: "PROCESSING",
attempts: 5,
lockedAt: new Date(Date.now() - 60_000),
},
});
const decide = vi.fn(decideModeration);
expect(await new ModerationWorker(prisma, decide, 1000).runOnce()).toBe(
false,
);
expect(decide).not.toHaveBeenCalled();
expect(
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
).toMatchObject({ status: "FAILED", attempts: 5, lockedAt: null });
});
it.each([
["ordinary", "APPROVED"],
["reject-word", "REJECTED"],
["review-word", "MANUAL_REVIEW"],
] as const)(
"moderates normalized profile content with rule %#",
async (nickname, status) => {
const { profile, event, task } = await profileFixture(
nickname,
"profile bio",
);
expect(await new ModerationWorker(prisma).runOnce()).toBe(true);
expect(
(
await prisma.anonymousProfile.findUniqueOrThrow({
where: { id: profile.id },
})
).reviewStatus,
).toBe(status);
expect(
await prisma.moderationTask.findUniqueOrThrow({
where: { id: task.id },
}),
).toMatchObject({ status: "COMPLETED", decision: status });
expect(
(
await prisma.outboxEvent.findUniqueOrThrow({
where: { id: event.id },
})
).status,
).toBe("PUBLISHED");
},
);
it("publishes a profile event that becomes stale while moderation is running", async () => {
const { createHash } = await import("node:crypto");
const { profile, event, task } = await profileFixture("ordinary", "first");
const currentText = JSON.stringify(["reject-word", "#abcdef", "second"]);
const decide = vi.fn(async (text: string) => {
await prisma.$transaction([
prisma.anonymousProfile.update({
where: { id: profile.id },
data: {
nickname: "reject-word",
bio: "second",
version: 2,
reviewStatus: "REVIEWING",
},
}),
prisma.moderationTask.update({
where: { id: task.id },
data: {
payloadHash: createHash("sha256").update(currentText).digest("hex"),
status: "PENDING",
},
}),
]);
return decideModeration(text);
});
expect(await new ModerationWorker(prisma, decide).runOnce()).toBe(true);
expect(decide).toHaveBeenCalledTimes(1);
expect(
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
).toMatchObject({ status: "PUBLISHED" });
expect(
await prisma.anonymousProfile.findUniqueOrThrow({
where: { id: profile.id },
}),
).toMatchObject({ version: 2, reviewStatus: "REVIEWING" });
expect(
await prisma.moderationTask.findUniqueOrThrow({ where: { id: task.id } }),
).toMatchObject({ status: "PENDING", decision: null });
});
it("publishes a replay when the matching profile task is already completed", async () => {
const { profile, event, task } = await profileFixture("ordinary", "first");
await prisma.moderationTask.update({
where: { id: task.id },
data: {
status: "COMPLETED",
decision: "APPROVED",
result: { decision: "APPROVED" },
reviewedAt: new Date(),
},
});
const decide = vi.fn(decideModeration);
expect(await new ModerationWorker(prisma, decide).runOnce()).toBe(true);
expect(
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
).toMatchObject({ status: "PUBLISHED" });
expect(
await prisma.anonymousProfile.findUniqueOrThrow({
where: { id: profile.id },
}),
).toMatchObject({ reviewStatus: "REVIEWING" });
expect(
await prisma.moderationTask.findUniqueOrThrow({ where: { id: task.id } }),
).toMatchObject({ status: "COMPLETED", decision: "APPROVED" });
});
it("publishes an old profile event without letting it overwrite a newer version", async () => {
const { createHash } = await import("node:crypto");
const { profile, event, task } = await profileFixture("ordinary", "first");
const currentText = JSON.stringify(["reject-word", "#abcdef", "second"]);
await prisma.$transaction([
prisma.anonymousProfile.update({
where: { id: profile.id },
data: {
nickname: "reject-word",
bio: "second",
version: 2,
reviewStatus: "REVIEWING",
},
}),
prisma.moderationTask.update({
where: { id: task.id },
data: {
payloadHash: createHash("sha256").update(currentText).digest("hex"),
},
}),
prisma.outboxEvent.create({
data: {
aggregateType: "PROFILE",
aggregateId: profile.id,
eventType: "PROFILE_MODERATION_REQUESTED",
dedupeKey: `profile-moderation:${profile.id}:2`,
payload: {
profileId: profile.id,
accountId: profile.accountId,
taskId: task.id,
},
},
}),
]);
const worker = new ModerationWorker(prisma);
expect(await worker.runOnce()).toBe(true);
expect(
(await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }))
.status,
).toBe("PUBLISHED");
expect(
(
await prisma.anonymousProfile.findUniqueOrThrow({
where: { id: profile.id },
})
).reviewStatus,
).toBe("REVIEWING");
expect(
(
await prisma.moderationTask.findUniqueOrThrow({
where: { id: task.id },
})
).status,
).toBe("PENDING");
expect(await worker.runOnce()).toBe(true);
expect(
(
await prisma.anonymousProfile.findUniqueOrThrow({
where: { id: profile.id },
})
).reviewStatus,
).toBe("REJECTED");
});
async function fixture(contentText: string) {
const account = await prisma.account.create({
data: { phoneCiphertext: Buffer.from("cipher"), phoneHmac: randomUUID() },
@@ -166,4 +390,44 @@ describe("moderation worker with real PostgreSQL", () => {
});
return { bottle, task, event };
}
async function profileFixture(nickname: string, bio: string) {
const { createHash } = await import("node:crypto");
const account = await prisma.account.create({
data: { phoneCiphertext: Buffer.from("cipher"), phoneHmac: randomUUID() },
});
const profile = await prisma.anonymousProfile.create({
data: {
accountId: account.id,
nickname,
avatarColor: "#abcdef",
bio,
version: 1,
},
});
const text = JSON.stringify([nickname, "#abcdef", bio]);
const task = await prisma.moderationTask.create({
data: {
targetType: "PROFILE",
targetId: profile.id,
provider: "SIMULATED",
payloadHash: createHash("sha256").update(text).digest("hex"),
riskLabels: [],
},
});
const event = await prisma.outboxEvent.create({
data: {
aggregateType: "PROFILE",
aggregateId: profile.id,
eventType: "PROFILE_MODERATION_REQUESTED",
dedupeKey: `profile-moderation:${profile.id}:1`,
payload: {
profileId: profile.id,
accountId: account.id,
taskId: task.id,
},
},
});
return { profile, task, event };
}
});
+200 -73
View File
@@ -1,8 +1,9 @@
import { createHash } from "node:crypto";
import {
Prisma,
PrismaClient,
type ReviewStatus,
type BottlePoolStatus,
type ReviewStatus,
} from "@prisma/client";
type Decision = {
@@ -11,7 +12,25 @@ type Decision = {
labels: string[];
};
type Decide = (text: string) => Decision | Promise<Decision>;
type Claimed = { id: string; aggregateId: string; payload: Prisma.JsonValue };
type Claimed = {
id: string;
aggregateId: string;
eventType: string;
dedupeKey: string;
payload: Prisma.JsonValue;
};
const NEVER = new Date("9999-12-31T23:59:59.999Z");
function positiveInt(
value: string | undefined,
fallback: number,
name: string,
) {
const parsed = value === undefined ? fallback : Number(value);
if (!Number.isInteger(parsed) || parsed <= 0)
throw new Error(`${name} must be a positive integer`);
return parsed;
}
export function decideModeration(text: string): Decision {
const rejectWords = (process.env.MODERATION_REJECT_WORDS ?? "reject-word")
@@ -36,78 +55,38 @@ export function decideModeration(text: string): Decision {
}
export class ModerationWorker {
private readonly maxAttempts: number;
constructor(
private readonly prisma: PrismaClient,
private readonly decide: Decide = decideModeration,
private readonly leaseMs = Number(process.env.OUTBOX_LEASE_MS ?? 30_000),
) {}
private readonly leaseMs = positiveInt(
process.env.OUTBOX_LEASE_MS,
30_000,
"OUTBOX_LEASE_MS",
),
) {
this.maxAttempts = positiveInt(
process.env.OUTBOX_MAX_ATTEMPTS,
5,
"OUTBOX_MAX_ATTEMPTS",
);
}
async runOnce(): Promise<boolean> {
const event = await this.claim();
if (!event) return false;
try {
const payload = event.payload as { bottleId?: unknown; taskId?: unknown };
if (
typeof payload.bottleId !== "string" ||
typeof payload.taskId !== "string" ||
payload.bottleId !== event.aggregateId
)
throw new Error("invalid event payload");
const [bottle, task] = await Promise.all([
this.prisma.bottle.findUniqueOrThrow({
where: { id: payload.bottleId },
}),
this.prisma.moderationTask.findUniqueOrThrow({
where: { id: payload.taskId },
}),
]);
if (task.targetType !== "BOTTLE" || task.targetId !== bottle.id)
throw new Error("mismatched moderation task");
const decision = await this.decide(bottle.contentText);
await this.prisma.$transaction(async (tx) => {
const currentTask = await tx.moderationTask.findUniqueOrThrow({
where: { id: task.id },
});
if (currentTask.status !== "COMPLETED") {
await tx.bottle.updateMany({
where: {
id: bottle.id,
reviewStatus: "REVIEWING",
poolStatus: "CLOSED",
},
data: {
reviewStatus: decision.reviewStatus,
poolStatus: decision.poolStatus,
approvedAt:
decision.reviewStatus === "APPROVED" ? new Date() : null,
},
});
await tx.moderationTask.update({
where: { id: task.id },
data: {
status: "COMPLETED",
decision: decision.reviewStatus,
riskLabels: decision.labels,
result: { decision: decision.reviewStatus },
reviewedAt: new Date(),
},
});
}
await tx.outboxEvent.update({
where: { id: event.id },
data: {
status: "PUBLISHED",
publishedAt: new Date(),
lockedAt: null,
},
});
});
if (event.eventType === "BOTTLE_MODERATION_REQUESTED")
await this.handleBottle(event);
else if (event.eventType === "PROFILE_MODERATION_REQUESTED")
await this.handleProfile(event);
else throw new Error("unsupported moderation event");
} catch {
const current = await this.prisma.outboxEvent.findUniqueOrThrow({
where: { id: event.id },
});
const maxAttempts = Number(process.env.OUTBOX_MAX_ATTEMPTS ?? 5);
const exhausted = current.attempts >= maxAttempts;
const exhausted = current.attempts >= this.maxAttempts;
const delayMs = Math.min(
60_000,
1000 * 2 ** Math.max(0, current.attempts - 1),
@@ -117,29 +96,177 @@ export class ModerationWorker {
data: {
status: "FAILED",
lockedAt: null,
nextRetryAt: exhausted
? new Date("9999-12-31T23:59:59.999Z")
: new Date(Date.now() + delayMs),
nextRetryAt: exhausted ? NEVER : new Date(Date.now() + delayMs),
},
});
}
return true;
}
private async handleBottle(event: Claimed) {
const payload = event.payload as { bottleId?: unknown; taskId?: unknown };
if (
typeof payload.bottleId !== "string" ||
typeof payload.taskId !== "string" ||
payload.bottleId !== event.aggregateId
)
throw new Error("invalid event payload");
const [bottle, task] = await Promise.all([
this.prisma.bottle.findUniqueOrThrow({ where: { id: payload.bottleId } }),
this.prisma.moderationTask.findUniqueOrThrow({
where: { id: payload.taskId },
}),
]);
if (task.targetType !== "BOTTLE" || task.targetId !== bottle.id)
throw new Error("mismatched moderation task");
if (task.status === "COMPLETED") {
await this.prisma.$transaction((tx) => this.publish(tx, event.id));
return;
}
const decision = await this.decide(bottle.contentText);
await this.prisma.$transaction(async (tx) => {
const currentTask = await tx.moderationTask.findUniqueOrThrow({
where: { id: task.id },
});
if (currentTask.status !== "COMPLETED") {
const updated = await tx.bottle.updateMany({
where: {
id: bottle.id,
reviewStatus: "REVIEWING",
poolStatus: "CLOSED",
},
data: {
reviewStatus: decision.reviewStatus,
poolStatus: decision.poolStatus,
approvedAt:
decision.reviewStatus === "APPROVED" ? new Date() : null,
},
});
if (updated.count !== 1)
throw new Error("bottle moderation state drift");
await this.completeTask(tx, task.id, decision);
}
await this.publish(tx, event.id);
});
}
private async handleProfile(event: Claimed) {
const payload = event.payload as {
profileId?: unknown;
accountId?: unknown;
taskId?: unknown;
};
if (
typeof payload.profileId !== "string" ||
typeof payload.accountId !== "string" ||
typeof payload.taskId !== "string" ||
payload.profileId !== event.aggregateId
)
throw new Error("invalid event payload");
const [profile, task] = await Promise.all([
this.prisma.anonymousProfile.findUniqueOrThrow({
where: { id: payload.profileId },
}),
this.prisma.moderationTask.findUniqueOrThrow({
where: { id: payload.taskId },
}),
]);
if (
profile.accountId !== payload.accountId ||
task.targetType !== "PROFILE" ||
task.targetId !== profile.id
)
throw new Error("mismatched moderation task");
const version = Number(
event.dedupeKey.slice(event.dedupeKey.lastIndexOf(":") + 1),
);
const text = JSON.stringify([
profile.nickname,
profile.avatarColor,
profile.bio,
]);
const hash = createHash("sha256").update(text).digest("hex");
if (profile.version !== version || task.payloadHash !== hash) {
await this.prisma.outboxEvent.update({
where: { id: event.id },
data: { status: "PUBLISHED", publishedAt: new Date(), lockedAt: null },
});
return;
}
if (task.status === "COMPLETED") {
await this.prisma.$transaction((tx) => this.publish(tx, event.id));
return;
}
const decision = await this.decide(text);
await this.prisma.$transaction(async (tx) => {
const [currentProfile, currentTask] = await Promise.all([
tx.anonymousProfile.findUniqueOrThrow({ where: { id: profile.id } }),
tx.moderationTask.findUniqueOrThrow({ where: { id: task.id } }),
]);
if (
currentProfile.version !== version ||
currentTask.payloadHash !== hash ||
currentTask.status === "COMPLETED"
) {
await this.publish(tx, event.id);
return;
}
const updated = await tx.anonymousProfile.updateMany({
where: { id: profile.id, version, reviewStatus: "REVIEWING" },
data: { reviewStatus: decision.reviewStatus },
});
if (updated.count !== 1)
throw new Error("profile moderation state drift");
await this.completeTask(tx, task.id, decision);
await this.publish(tx, event.id);
});
}
private completeTask(
tx: Prisma.TransactionClient,
id: string,
decision: Decision,
) {
return tx.moderationTask.update({
where: { id },
data: {
status: "COMPLETED",
decision: decision.reviewStatus,
riskLabels: decision.labels,
result: { decision: decision.reviewStatus },
reviewedAt: new Date(),
},
});
}
private publish(tx: Prisma.TransactionClient, id: string) {
return tx.outboxEvent.update({
where: { id },
data: { status: "PUBLISHED", publishedAt: new Date(), lockedAt: null },
});
}
private async claim(): Promise<Claimed | null> {
const staleBefore = new Date(Date.now() - this.leaseMs);
return this.prisma.$transaction(async (tx) => {
await tx.outboxEvent.updateMany({
where: {
status: "PROCESSING",
lockedAt: { lt: staleBefore },
attempts: { gte: this.maxAttempts },
},
data: { status: "FAILED", lockedAt: null, nextRetryAt: NEVER },
});
const rows = await tx.$queryRaw<Claimed[]>`
SELECT "id", "aggregate_id" AS "aggregateId", "payload"
SELECT "id", "aggregate_id" AS "aggregateId", "event_type" AS "eventType",
"dedupe_key" AS "dedupeKey", "payload"
FROM "outbox_events"
WHERE "event_type" = 'BOTTLE_MODERATION_REQUESTED'
AND (
("status" IN ('PENDING', 'FAILED') AND "next_retry_at" <= now())
OR ("status" = 'PROCESSING' AND "locked_at" < ${staleBefore})
)
WHERE "event_type" IN ('BOTTLE_MODERATION_REQUESTED', 'PROFILE_MODERATION_REQUESTED')
AND "attempts" < ${this.maxAttempts}
AND (("status" IN ('PENDING', 'FAILED') AND "next_retry_at" <= now())
OR ("status" = 'PROCESSING' AND "locked_at" < ${staleBefore}))
ORDER BY "created_at"
FOR UPDATE SKIP LOCKED
LIMIT 1`;
FOR UPDATE SKIP LOCKED LIMIT 1`;
const row = rows[0];
if (!row) return null;
await tx.outboxEvent.update({