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
+71
View File
@@ -0,0 +1,71 @@
import {
Body,
Controller,
Get,
Headers,
HttpStatus,
Inject,
Param,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { ErrorCode } from "@drift/contracts";
import { AuthGuard } from "../auth/auth.guard.js";
import { CurrentUser } from "../auth/current-user.decorator.js";
import type { AccessClaims } from "../auth/token.service.js";
import { DomainException } from "../common/domain.exception.js";
import { BottleService } from "./bottle.service.js";
import { CreateBottleDto, ListBottlesDto } from "./dto.js";
@Controller()
@UseGuards(AuthGuard)
export class BottleController {
constructor(@Inject(BottleService) private readonly bottles: BottleService) {}
@Post("bottles")
create(
@CurrentUser() user: AccessClaims,
@Headers("idempotency-key") key: string | undefined,
@Body() dto: CreateBottleDto,
) {
if (!key || !key.trim() || key.length > 128)
throw new DomainException(
ErrorCode.VALIDATION_ERROR,
"Validation failed",
HttpStatus.BAD_REQUEST,
);
return this.bottles.create(user.sub, key, dto.contentText);
}
@Get("bottles/:id")
get(
@CurrentUser() user: AccessClaims,
@Param("id", new ParseUUIDPipe()) id: string,
) {
return this.bottles.get(user.sub, id);
}
@Get("me/bottles")
list(@CurrentUser() user: AccessClaims, @Query() query: ListBottlesDto) {
return this.bottles.list(user.sub, query.cursor, query.limit);
}
}
Reflect.defineMetadata(
"design:paramtypes",
[Object, String, CreateBottleDto],
BottleController.prototype,
"create",
);
Reflect.defineMetadata(
"design:paramtypes",
[Object, String],
BottleController.prototype,
"get",
);
Reflect.defineMetadata(
"design:paramtypes",
[Object, ListBottlesDto],
BottleController.prototype,
"list",
);
+285
View File
@@ -0,0 +1,285 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument */
import "reflect-metadata";
import { type INestApplication } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { PrismaClient } from "@prisma/client";
import { randomUUID } from "node:crypto";
import request from "supertest";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { assertSafeTestDatabaseUrl } from "../../../../prisma/database-safety.js";
import { AppModule } from "../app.module.js";
import { TokenService } from "../auth/token.service.js";
import { configureApp } from "../main.js";
import { utc8UsageDate } from "./usage-date.js";
process.env.NODE_ENV = "test";
process.env.WEB_ORIGIN = "http://localhost:3000";
process.env.PHONE_ENCRYPTION_KEY =
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
process.env.VERIFICATION_CODE_HMAC_KEY =
"test-code-hmac-key-with-at-least-32-bytes";
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
process.env.REFRESH_TOKEN_HMAC_KEY =
"test-refresh-hmac-key-with-at-least-32-bytes";
const prisma = new PrismaClient();
describe("bottles with real PostgreSQL", () => {
let app: INestApplication;
let accountId: string;
let authorization: string;
beforeAll(async () => {
assertSafeTestDatabaseUrl(process.env.DATABASE_URL ?? "");
await prisma.$connect();
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
});
beforeEach(async () => {
await prisma.outboxEvent.deleteMany();
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
({ accountId, authorization } = await actor("owner-device"));
});
afterAll(async () => {
await app?.close();
await prisma.$disconnect();
});
async function actor(deviceId: string) {
const account = await prisma.account.create({
data: { phoneCiphertext: Buffer.from("cipher"), phoneHmac: randomUUID() },
});
const session = await prisma.session.create({
data: {
accountId: account.id,
refreshTokenHash: randomUUID(),
deviceId,
expiresAt: new Date(Date.now() + 60_000),
tokenFamily: randomUUID(),
},
});
const token = app.get(TokenService).issueAccess({
sub: account.id,
session_id: session.id,
device_id: deviceId,
scopes: ["user"],
token_version: 0,
});
return { accountId: account.id, authorization: `Bearer ${token}` };
}
const create = (key: string, contentText = "hello sea") =>
request(app.getHttpServer())
.post("/api/v1/bottles")
.set("Authorization", authorization)
.set("Idempotency-Key", key)
.send({ contentText });
it("creates reviewing/closed bottle, task, and content-free outbox atomically", async () => {
const response = await create(randomUUID(), " hello sea ").expect(201);
expect(response.body.data).toMatchObject({
contentText: "hello sea",
reviewStatus: "REVIEWING",
poolStatus: "CLOSED",
});
const bottle = await prisma.bottle.findUniqueOrThrow({
where: { id: response.body.data.id },
});
const [task, event, usage] = await Promise.all([
prisma.moderationTask.findFirstOrThrow({
where: { targetId: bottle.id },
}),
prisma.outboxEvent.findFirstOrThrow({
where: { aggregateId: bottle.id },
}),
prisma.dailyUsage.findFirstOrThrow({ where: { accountId } }),
]);
expect(task.payloadHash).toMatch(/^[a-f0-9]{64}$/);
expect(event.payload).toEqual({ bottleId: bottle.id, taskId: task.id });
expect(JSON.stringify(event.payload)).not.toContain("hello sea");
expect(usage.bottlesCreated).toBe(1);
});
it("deduplicates retry without incrementing quota or creating events", async () => {
const key = randomUUID();
const first = await create(key).expect(201);
const retry = await create(key).expect(201);
expect(retry.body.data.id).toBe(first.body.data.id);
expect(await prisma.bottle.count()).toBe(1);
expect(await prisma.moderationTask.count()).toBe(1);
expect(await prisma.outboxEvent.count()).toBe(1);
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesCreated).toBe(1);
});
it("deduplicates concurrent retries without incrementing quota", async () => {
const key = randomUUID();
const results = await Promise.all(
Array.from({ length: 8 }, () => create(key, "same content")),
);
expect(results.every((result) => result.status === 201)).toBe(true);
expect(
new Set(results.map((result) => (result.body.data as { id: string }).id)),
).toHaveLength(1);
expect(await prisma.bottle.count()).toBe(1);
expect(await prisma.moderationTask.count()).toBe(1);
expect(await prisma.outboxEvent.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) =>
create(randomUUID(), `b${index}`),
),
);
expect(results.filter((x) => x.status === 201)).toHaveLength(10);
const denied = results.filter((x) => x.status === 429);
expect(denied).toHaveLength(2);
expect(denied.every((x) => x.body.code === "BOTTLE_DAILY_LIMIT")).toBe(
true,
);
expect(await prisma.bottle.count()).toBe(10);
expect(await prisma.outboxEvent.count()).toBe(10);
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesCreated).toBe(
10,
);
});
it("validates trimmed content and idempotency key", async () => {
await create(randomUUID(), " ").expect(400);
await create(randomUUID(), "x".repeat(1001)).expect(400);
await request(app.getHttpServer())
.post("/api/v1/bottles")
.set("Authorization", authorization)
.send({ contentText: "hello" })
.expect(400);
});
it("allows only owner reads and cursor-paginates own bottles", async () => {
const ids: string[] = [];
for (let n = 0; n < 3; n += 1)
ids.push(
(await create(randomUUID(), `message${n}`).expect(201)).body.data.id,
);
await request(app.getHttpServer())
.get(`/api/v1/bottles/${ids[0]}`)
.set("Authorization", authorization)
.expect(200);
const stranger = await actor("stranger-device");
await request(app.getHttpServer())
.get(`/api/v1/bottles/${ids[0]}`)
.set("Authorization", stranger.authorization)
.expect(404);
const page1 = await request(app.getHttpServer())
.get("/api/v1/me/bottles?limit=2")
.set("Authorization", authorization)
.expect(200);
expect(page1.body.data.items).toHaveLength(2);
expect(page1.body.data.nextCursor).toEqual(expect.any(String));
const page2 = await request(app.getHttpServer())
.get(`/api/v1/me/bottles?limit=2&cursor=${page1.body.data.nextCursor}`)
.set("Authorization", authorization)
.expect(200);
expect(page2.body.data.items).toHaveLength(1);
expect(page2.body.data.nextCursor).toBeNull();
});
it("returns 400 for malformed, missing, too-long, and foreign cursors", async () => {
await request(app.getHttpServer())
.get("/api/v1/me/bottles?cursor=not-a-uuid")
.set("Authorization", authorization)
.expect(400);
await request(app.getHttpServer())
.get(`/api/v1/me/bottles?cursor=${randomUUID()}`)
.set("Authorization", authorization)
.expect(400);
const stranger = await actor("cursor-stranger");
const foreignId = (
await request(app.getHttpServer())
.post("/api/v1/bottles")
.set("Authorization", stranger.authorization)
.set("Idempotency-Key", randomUUID())
.send({ contentText: "foreign" })
.expect(201)
).body.data.id;
await request(app.getHttpServer())
.get(`/api/v1/me/bottles?cursor=${foreignId}`)
.set("Authorization", authorization)
.expect(400);
});
it("rejects blank and overlong idempotency keys", async () => {
await create(" ").expect(400);
await create("x".repeat(129)).expect(400);
});
it.each(["SUSPENSION", "BAN"] as const)(
"blocks active %s sanction",
async (type) => {
await prisma.sanction.create({
data: {
accountId,
type,
reason: "policy",
expiresAt: new Date(Date.now() + 60_000),
},
});
const denied = await create(randomUUID()).expect(403);
expect(denied.body.code).toBe("ACCOUNT_SANCTIONED");
expect(await prisma.bottle.count()).toBe(0);
},
);
it("blocks non-active accounts", async () => {
await prisma.account.update({
where: { id: accountId },
data: { status: "SUSPENDED" },
});
await create(randomUUID()).expect(401);
expect(await prisma.bottle.count()).toBe(0);
});
it("blocks sanctions that have started but ignores future sanctions", async () => {
await prisma.sanction.create({
data: {
accountId,
type: "BAN",
reason: "future",
startsAt: new Date(Date.now() + 60_000),
},
});
await create(randomUUID()).expect(201);
});
it("ignores expired and revoked sanctions", async () => {
await prisma.sanction.createMany({
data: [
{
accountId,
type: "BAN",
reason: "expired",
expiresAt: new Date(Date.now() - 1),
},
{
accountId,
type: "SUSPENSION",
reason: "revoked",
revokedAt: new Date(),
},
],
});
await create(randomUUID()).expect(201);
});
it("computes usage day across UTC+8 midnight", () => {
expect(utc8UsageDate(new Date("2026-09-15T15:59:59.999Z"))).toBe(
"2026-09-15",
);
expect(utc8UsageDate(new Date("2026-09-15T16:00:00.000Z"))).toBe(
"2026-09-16",
);
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { DatabaseModule } from "../database/database.module.js";
import { BottleController } from "./bottle.controller.js";
import { BottleService } from "./bottle.service.js";
@Module({
imports: [AuthModule, DatabaseModule],
controllers: [BottleController],
providers: [BottleService],
})
export class BottleModule {}
+138
View File
@@ -0,0 +1,138 @@
import { createHash } from "node:crypto";
import { HttpStatus, Inject, Injectable } from "@nestjs/common";
import { ErrorCode } from "@drift/contracts";
import { Prisma } from "@prisma/client";
import { DomainException } from "../common/domain.exception.js";
import { PrismaService } from "../database/prisma.service.js";
import { utc8UsageDate } from "./usage-date.js";
@Injectable()
export class BottleService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async create(authorId: string, requestId: string, contentText: string) {
const existing = await this.prisma.bottle.findUnique({
where: {
authorId_clientRequestId: { authorId, clientRequestId: requestId },
},
});
if (existing) return existing;
try {
return await this.prisma.$transaction(async (tx) => {
const duplicate = await tx.bottle.findUnique({
where: {
authorId_clientRequestId: { authorId, clientRequestId: requestId },
},
});
if (duplicate) return duplicate;
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() } }],
},
select: { id: true },
});
if (sanctioned)
throw new DomainException(
ErrorCode.ACCOUNT_SANCTIONED,
"Account sanctioned",
HttpStatus.FORBIDDEN,
);
const usageDate = utc8UsageDate(new Date());
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())
ON CONFLICT ("account_id", "usage_date") DO UPDATE
SET "bottles_created" = "daily_usage"."bottles_created" + 1, "updated_at" = now()
WHERE "daily_usage"."bottles_created" < 10
RETURNING "bottles_created"`;
if (rows.length === 0)
throw new DomainException(
ErrorCode.BOTTLE_DAILY_LIMIT,
"Daily bottle limit reached",
HttpStatus.TOO_MANY_REQUESTS,
);
const bottle = await tx.bottle.create({
data: {
authorId,
clientRequestId: requestId,
contentText,
reviewStatus: "REVIEWING",
poolStatus: "CLOSED",
},
});
const task = await tx.moderationTask.create({
data: {
targetType: "BOTTLE",
targetId: bottle.id,
provider: "SIMULATED",
payloadHash: createHash("sha256").update(contentText).digest("hex"),
riskLabels: [],
},
});
await tx.outboxEvent.create({
data: {
aggregateType: "BOTTLE",
aggregateId: bottle.id,
eventType: "BOTTLE_MODERATION_REQUESTED",
dedupeKey: `bottle-moderation:${bottle.id}`,
payload: { bottleId: bottle.id, taskId: task.id },
},
});
return bottle;
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
return this.prisma.bottle.findUniqueOrThrow({
where: {
authorId_clientRequestId: { authorId, clientRequestId: requestId },
},
});
}
throw error;
}
}
async get(authorId: string, id: string) {
const bottle = await this.prisma.bottle.findFirst({
where: { id, authorId },
});
if (!bottle)
throw new DomainException(
ErrorCode.NOT_FOUND,
"Not Found",
HttpStatus.NOT_FOUND,
);
return bottle;
}
async list(authorId: string, cursor: string | undefined, limit: number) {
if (cursor) {
const ownedCursor = await this.prisma.bottle.findFirst({
where: { id: cursor, authorId },
select: { id: true },
});
if (!ownedCursor)
throw new DomainException(
ErrorCode.VALIDATION_ERROR,
"Invalid cursor",
HttpStatus.BAD_REQUEST,
);
}
const items = await this.prisma.bottle.findMany({
where: { authorId },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
const hasMore = items.length > limit;
if (hasMore) items.pop();
return { items, nextCursor: hasMore ? (items.at(-1)?.id ?? null) : null };
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Transform, Type } from "class-transformer";
import {
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
MinLength,
} from "class-validator";
export class CreateBottleDto {
@Transform(({ value }: { value: unknown }) =>
typeof value === "string" ? value.trim() : value,
)
@IsString()
@MinLength(1)
@MaxLength(1000)
contentText!: string;
}
export class ListBottlesDto {
@IsOptional() @IsUUID() cursor?: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(50) limit = 20;
}
+5
View File
@@ -0,0 +1,5 @@
export function utc8UsageDate(now: Date): string {
return new Date(now.getTime() + 8 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
}