fix: 完善资料审核与 Worker 状态一致性
This commit is contained in:
@@ -129,6 +129,31 @@ describe("bottles with real PostgreSQL", () => {
|
||||
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesCreated).toBe(1);
|
||||
});
|
||||
|
||||
it("returns idempotency conflict for a reused key with different normalized content", async () => {
|
||||
const key = randomUUID();
|
||||
const first = await create(key, " same content ").expect(201);
|
||||
const retry = await create(key, "same content").expect(201);
|
||||
expect(retry.body.data.id).toBe(first.body.data.id);
|
||||
const conflict = await create(key, "different content").expect(409);
|
||||
expect(conflict.body.code).toBe("IDEMPOTENCY_CONFLICT");
|
||||
expect(await prisma.bottle.count()).toBe(1);
|
||||
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesCreated).toBe(1);
|
||||
});
|
||||
|
||||
it("allows one concurrent writer and conflicts the other for different content on one key", async () => {
|
||||
const key = randomUUID();
|
||||
const results = await Promise.all([
|
||||
create(key, "first contender"),
|
||||
create(key, "second contender"),
|
||||
]);
|
||||
expect(results.map((result) => result.status).sort()).toEqual([201, 409]);
|
||||
expect(results.find((result) => result.status === 409)?.body.code).toBe(
|
||||
"IDEMPOTENCY_CONFLICT",
|
||||
);
|
||||
expect(await prisma.bottle.count()).toBe(1);
|
||||
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesCreated).toBe(1);
|
||||
});
|
||||
|
||||
it("atomically permits exactly ten of twelve concurrent requests", async () => {
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 12 }, (_, index) =>
|
||||
|
||||
@@ -16,22 +16,24 @@ export class BottleService {
|
||||
authorId_clientRequestId: { authorId, clientRequestId: requestId },
|
||||
},
|
||||
});
|
||||
if (existing) return existing;
|
||||
if (existing) return this.resolveIdempotent(existing, contentText);
|
||||
try {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`${authorId}:${requestId}`}, 0))`;
|
||||
const duplicate = await tx.bottle.findUnique({
|
||||
where: {
|
||||
authorId_clientRequestId: { authorId, clientRequestId: requestId },
|
||||
},
|
||||
});
|
||||
if (duplicate) return duplicate;
|
||||
if (duplicate) return this.resolveIdempotent(duplicate, contentText);
|
||||
const now = new Date();
|
||||
const sanctioned = await tx.sanction.findFirst({
|
||||
where: {
|
||||
accountId: authorId,
|
||||
type: { in: ["SUSPENSION", "BAN"] },
|
||||
revokedAt: null,
|
||||
startsAt: { lte: new Date() },
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
startsAt: { lte: now },
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
@@ -41,7 +43,7 @@ export class BottleService {
|
||||
"Account sanctioned",
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
const usageDate = utc8UsageDate(new Date());
|
||||
const usageDate = utc8UsageDate(now);
|
||||
const rows = await tx.$queryRaw<Array<{ bottles_created: number }>>`
|
||||
INSERT INTO "daily_usage" ("id", "account_id", "usage_date", "bottles_created", "updated_at")
|
||||
VALUES (gen_random_uuid(), ${authorId}::uuid, ${usageDate}::date, 1, now())
|
||||
@@ -89,16 +91,30 @@ export class BottleService {
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === "P2002"
|
||||
) {
|
||||
return this.prisma.bottle.findUniqueOrThrow({
|
||||
const duplicate = await this.prisma.bottle.findUniqueOrThrow({
|
||||
where: {
|
||||
authorId_clientRequestId: { authorId, clientRequestId: requestId },
|
||||
},
|
||||
});
|
||||
return this.resolveIdempotent(duplicate, contentText);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveIdempotent<T extends { contentText: string }>(
|
||||
bottle: T,
|
||||
contentText: string,
|
||||
): T {
|
||||
if (bottle.contentText !== contentText)
|
||||
throw new DomainException(
|
||||
ErrorCode.IDEMPOTENCY_CONFLICT,
|
||||
"Idempotency key reused with different content",
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
return bottle;
|
||||
}
|
||||
|
||||
async get(authorId: string, id: string) {
|
||||
const bottle = await this.prisma.bottle.findFirst({
|
||||
where: { id, authorId },
|
||||
|
||||
@@ -103,6 +103,67 @@ describe("anonymous profile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("atomically queues content-free moderation and versions rapid profile updates", async () => {
|
||||
const first = await request(app.getHttpServer())
|
||||
.patch("/api/v1/me/anonymous-profile")
|
||||
.set("Authorization", authorization)
|
||||
.send({
|
||||
nickname: " first ",
|
||||
avatarColor: "#ABCDEF",
|
||||
bio: " bio one ",
|
||||
})
|
||||
.expect(200);
|
||||
const profileId = first.body.data.id as string;
|
||||
const task = await prisma.moderationTask.findFirstOrThrow({
|
||||
where: { targetType: "PROFILE", targetId: profileId },
|
||||
});
|
||||
const firstEvent = await prisma.outboxEvent.findFirstOrThrow({
|
||||
where: {
|
||||
eventType: "PROFILE_MODERATION_REQUESTED",
|
||||
aggregateId: profileId,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
expect(first.body.data).toMatchObject({
|
||||
version: 1,
|
||||
reviewStatus: "REVIEWING",
|
||||
});
|
||||
expect(firstEvent.payload).toEqual({
|
||||
profileId,
|
||||
accountId,
|
||||
taskId: task.id,
|
||||
});
|
||||
expect(JSON.stringify(firstEvent.payload)).not.toContain("first");
|
||||
expect(task.payloadHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
|
||||
const second = await request(app.getHttpServer())
|
||||
.patch("/api/v1/me/anonymous-profile")
|
||||
.set("Authorization", authorization)
|
||||
.send({ nickname: "second", avatarColor: "#abcdef", bio: "bio two" })
|
||||
.expect(200);
|
||||
expect(second.body.data).toMatchObject({ id: profileId, version: 2 });
|
||||
const events = await prisma.outboxEvent.findMany({
|
||||
where: {
|
||||
eventType: "PROFILE_MODERATION_REQUESTED",
|
||||
aggregateId: profileId,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]?.dedupeKey).toBe(`profile-moderation:${profileId}:1`);
|
||||
expect(events[1]?.dedupeKey).toBe(`profile-moderation:${profileId}:2`);
|
||||
expect(
|
||||
await prisma.moderationTask.count({ where: { targetId: profileId } }),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await prisma.moderationTask.findUniqueOrThrow({ where: { id: task.id } }),
|
||||
).toMatchObject({
|
||||
status: "PENDING",
|
||||
decision: null,
|
||||
reviewedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ nickname: " ", avatarColor: "#abcdef" },
|
||||
{ nickname: "x".repeat(65), avatarColor: "#abcdef" },
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../database/prisma.service.js";
|
||||
import type { UpdateAnonymousProfileDto } from "./dto.js";
|
||||
|
||||
@@ -7,28 +9,59 @@ export class ProfileService {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
update(accountId: string, input: UpdateAnonymousProfileDto) {
|
||||
return this.prisma.anonymousProfile.upsert({
|
||||
where: { accountId },
|
||||
create: {
|
||||
accountId,
|
||||
nickname: input.nickname,
|
||||
avatarColor: input.avatarColor,
|
||||
bio: input.bio ?? null,
|
||||
reviewStatus: "REVIEWING",
|
||||
},
|
||||
update: {
|
||||
nickname: input.nickname,
|
||||
avatarColor: input.avatarColor,
|
||||
bio: input.bio ?? null,
|
||||
reviewStatus: "REVIEWING",
|
||||
},
|
||||
select: {
|
||||
publicId: true,
|
||||
nickname: true,
|
||||
avatarColor: true,
|
||||
bio: true,
|
||||
reviewStatus: true,
|
||||
},
|
||||
const bio = input.bio ?? null;
|
||||
const payloadHash = createHash("sha256")
|
||||
.update(JSON.stringify([input.nickname, input.avatarColor, bio]))
|
||||
.digest("hex");
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const profile = await tx.anonymousProfile.upsert({
|
||||
where: { accountId },
|
||||
create: {
|
||||
accountId,
|
||||
nickname: input.nickname,
|
||||
avatarColor: input.avatarColor,
|
||||
bio,
|
||||
reviewStatus: "REVIEWING",
|
||||
},
|
||||
update: {
|
||||
nickname: input.nickname,
|
||||
avatarColor: input.avatarColor,
|
||||
bio,
|
||||
reviewStatus: "REVIEWING",
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
const task = await tx.moderationTask.upsert({
|
||||
where: {
|
||||
targetType_targetId: { targetType: "PROFILE", targetId: profile.id },
|
||||
},
|
||||
create: {
|
||||
targetType: "PROFILE",
|
||||
targetId: profile.id,
|
||||
provider: "SIMULATED",
|
||||
payloadHash,
|
||||
riskLabels: [],
|
||||
},
|
||||
update: {
|
||||
provider: "SIMULATED",
|
||||
payloadHash,
|
||||
riskLabels: [],
|
||||
result: Prisma.JsonNull,
|
||||
status: "PENDING",
|
||||
decision: null,
|
||||
reviewedAt: null,
|
||||
},
|
||||
});
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
aggregateType: "PROFILE",
|
||||
aggregateId: profile.id,
|
||||
eventType: "PROFILE_MODERATION_REQUESTED",
|
||||
dedupeKey: `profile-moderation:${profile.id}:${profile.version}`,
|
||||
payload: { profileId: profile.id, accountId, taskId: task.id },
|
||||
},
|
||||
});
|
||||
return profile;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user