feat: 实现公平捞瓶和领取租约

This commit is contained in:
root
2026-09-15 13:56:56 +08:00
parent 969a10d664
commit 69fc8d51ce
23 changed files with 1221 additions and 20 deletions
+286
View File
@@ -0,0 +1,286 @@
import { randomBytes, randomUUID } from "node:crypto";
import { HttpStatus, Inject, Injectable } from "@nestjs/common";
import { ErrorCode } from "@drift/contracts";
import type { Prisma } from "@prisma/client";
import { DomainException } from "../common/domain.exception.js";
import { PrismaService } from "../database/prisma.service.js";
import { utc8UsageDate } from "../bottle/usage-date.js";
import {
decryptLeaseToken,
encryptLeaseToken,
leaseHmac,
} from "../auth/auth.config.js";
import { CandidateRepository, type Candidate } from "./candidate.repository.js";
type LeaseWithBottle = {
id: string;
expiresAt: Date;
leaseTokenCiphertext: Uint8Array;
bottle: {
id: string;
contentText: string;
author: {
anonymousProfile: {
publicId: string;
nickname: string;
avatarColor: string;
bio: string | null;
} | null;
};
};
};
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;
}
@Injectable()
export class MatchService {
private readonly ttlMs = positiveInt(
process.env.BOTTLE_LEASE_TTL_MS,
300_000,
"BOTTLE_LEASE_TTL_MS",
);
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(CandidateRepository)
private readonly candidates: CandidateRepository,
) {}
async pick(pickerId: string, requestId: string) {
return this.prisma.$transaction(
async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`pick:${pickerId}`}, 0))`;
const previous = await tx.bottlePickRequest.findUnique({
where: { pickerId_requestId: { pickerId, requestId } },
include: {
lease: {
include: {
bottle: {
include: { author: { include: { anonymousProfile: true } } },
},
},
},
},
});
if (previous?.lease) {
let token: string;
try {
token = decryptLeaseToken(
previous.lease.leaseTokenCiphertext,
previous.lease.id,
);
} catch {
throw new DomainException(
ErrorCode.SERVICE_UNAVAILABLE,
"Bottle pick temporarily unavailable",
HttpStatus.SERVICE_UNAVAILABLE,
);
}
return this.response(previous.lease, token);
}
await this.assertEligible(tx, pickerId);
const usageDate = utc8UsageDate(new Date());
const current = await tx.dailyUsage.findUnique({
where: {
accountId_usageDate: {
accountId: pickerId,
usageDate: new Date(`${usageDate}T00:00:00.000Z`),
},
},
});
if ((current?.bottlesPicked ?? 0) >= 20) this.limit();
const batch = await this.candidates.findBatch(tx, pickerId);
while (batch.length) {
const index = Math.floor(Math.random() * batch.length);
const candidate = batch.splice(index, 1)[0]!;
const result = await this.claim(
tx,
pickerId,
requestId,
usageDate,
candidate,
);
if (result) return result;
}
throw new DomainException(
ErrorCode.BOTTLE_POOL_EMPTY,
"Bottle pool empty",
HttpStatus.NOT_FOUND,
);
},
{ isolationLevel: "ReadCommitted" },
);
}
private async claim(
tx: Prisma.TransactionClient,
pickerId: string,
requestId: string,
usageDate: string,
candidate: Candidate,
) {
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + this.ttlMs);
const leaseId = randomUUID();
const claimed = await tx.bottle.updateMany({
where: {
id: candidate.id,
version: candidate.version,
poolStatus: "IN_POOL",
activeLeaseId: null,
},
data: {
poolStatus: "LEASED",
activeLeaseId: leaseId,
version: { increment: 1 },
},
});
if (claimed.count !== 1) return null;
const lease = await tx.bottlePickLease.create({
data: {
id: leaseId,
bottleId: candidate.id,
pickerId,
leaseTokenHash: leaseHmac(token),
leaseTokenCiphertext: encryptLeaseToken(token, leaseId),
expiresAt,
},
});
await tx.bottlePickHistory.create({
data: { bottleId: candidate.id, pickerId },
});
const rows = await tx.$queryRaw<Array<{ bottles_picked: number }>>`
INSERT INTO "daily_usage" ("id", "account_id", "usage_date", "bottles_picked", "updated_at")
VALUES (gen_random_uuid(), ${pickerId}::uuid, ${usageDate}::date, 1, now())
ON CONFLICT ("account_id", "usage_date") DO UPDATE
SET "bottles_picked" = "daily_usage"."bottles_picked" + 1, "updated_at" = now()
WHERE "daily_usage"."bottles_picked" < 20
RETURNING "bottles_picked"`;
if (!rows.length) this.limit();
await tx.bottlePickRequest.create({
data: { pickerId, requestId, leaseId: lease.id },
});
return {
bottle: { id: candidate.id, contentText: candidate.contentText },
author: {
publicId: candidate.publicId,
nickname: candidate.nickname,
avatarColor: candidate.avatarColor,
bio: candidate.bio,
},
lease: { id: lease.id, token, expiresAt },
};
}
private async assertEligible(tx: Prisma.TransactionClient, pickerId: string) {
const now = new Date();
const account = await tx.account.findUnique({
where: { id: pickerId },
select: { status: true },
});
const sanction = await tx.sanction.findFirst({
where: {
accountId: pickerId,
type: { in: ["SUSPENSION", "BAN"] },
revokedAt: null,
startsAt: { lte: now },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
select: { id: true },
});
if (account?.status !== "ACTIVE" || sanction)
throw new DomainException(
ErrorCode.ACCOUNT_SANCTIONED,
"Account sanctioned",
HttpStatus.FORBIDDEN,
);
}
async returnBottle(
pickerId: string,
bottleId: string,
leaseId: string,
token: string,
) {
const tokenHash = leaseHmac(token);
const hidden = () =>
new DomainException(
ErrorCode.NOT_FOUND,
"Not Found",
HttpStatus.NOT_FOUND,
);
return this.prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<
Array<{ id: string; status: string; expiresAt: Date }>
>`
SELECT "id", "status", "expires_at" AS "expiresAt"
FROM "bottle_pick_leases"
WHERE "id" = ${leaseId}::uuid AND "bottle_id" = ${bottleId}::uuid
AND "picker_id" = ${pickerId}::uuid AND "lease_token_hash" = ${tokenHash}
FOR UPDATE`;
const lease = rows[0];
if (!lease) throw hidden();
if (lease.status === "RETURNED")
return { bottleId, leaseId, status: "RETURNED" as const };
if (lease.status !== "ACTIVE") throw hidden();
if (lease.expiresAt <= new Date())
throw new DomainException(
ErrorCode.BOTTLE_LEASE_EXPIRED,
"Bottle lease expired",
HttpStatus.GONE,
);
const released = await tx.bottle.updateMany({
where: { id: bottleId, poolStatus: "LEASED", activeLeaseId: leaseId },
data: {
poolStatus: "IN_POOL",
activeLeaseId: null,
version: { increment: 1 },
},
});
if (released.count !== 1) throw hidden();
await tx.bottlePickLease.update({
where: { id: leaseId },
data: { status: "RETURNED", endedAt: new Date() },
});
return { bottleId, leaseId, status: "RETURNED" as const };
});
}
private response(lease: LeaseWithBottle, token: string) {
const profile = lease.bottle.author.anonymousProfile;
if (!profile)
throw new DomainException(
ErrorCode.BOTTLE_POOL_EMPTY,
"Bottle pool empty",
HttpStatus.NOT_FOUND,
);
return {
bottle: { id: lease.bottle.id, contentText: lease.bottle.contentText },
author: {
publicId: profile.publicId,
nickname: profile.nickname,
avatarColor: profile.avatarColor,
bio: profile.bio,
},
lease: { id: lease.id, token, expiresAt: lease.expiresAt },
};
}
private limit(): never {
throw new DomainException(
ErrorCode.BOTTLE_DAILY_LIMIT,
"Daily pick limit reached",
HttpStatus.TOO_MANY_REQUESTS,
);
}
}