feat: 实现公平捞瓶和领取租约
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user