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 };
}
});