fa0fa78312
- 串行化拉黑、处罚、投瓶和匹配策略检查\n- 完成异步站内通知、未读统计和偏好并发语义\n- 补齐后台查询审计、处罚恢复和隐私测试\n- 稳定 Redis 恢复、匹配锁序及超时测试
480 lines
15 KiB
TypeScript
480 lines
15 KiB
TypeScript
import { randomBytes, randomInt, 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";
|
|
import {
|
|
CandidateBatchContended,
|
|
classifyPickError,
|
|
readMatchRetryConfig,
|
|
transactionLimits,
|
|
} from "./match-retry-policy.js";
|
|
import { SafetyLockService } from "../safety/safety-lock.service.js";
|
|
|
|
type LeaseWithBottle = {
|
|
id: string;
|
|
expiresAt: Date;
|
|
leaseTokenCiphertext: Uint8Array;
|
|
bottle: {
|
|
id: string;
|
|
authorId: 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",
|
|
);
|
|
private readonly retry = readMatchRetryConfig(process.env);
|
|
|
|
constructor(
|
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
|
@Inject(CandidateRepository)
|
|
private readonly candidates: CandidateRepository,
|
|
@Inject(SafetyLockService) private readonly locks: SafetyLockService,
|
|
) {}
|
|
|
|
async pick(pickerId: string, requestId: string) {
|
|
const deadline = performance.now() + this.retry.budgetMs;
|
|
for (let attempt = 0; attempt < this.retry.maxAttempts; attempt += 1) {
|
|
const limits = transactionLimits(this.retry, performance.now(), deadline);
|
|
if (!limits) break;
|
|
try {
|
|
const result = await this.prisma.$transaction(
|
|
async (tx) => {
|
|
const previous = await this.findPrevious(tx, pickerId, requestId);
|
|
if (previous?.lease) {
|
|
await this.locks.lockAccounts(tx, [
|
|
pickerId,
|
|
previous.lease.bottle.authorId,
|
|
]);
|
|
await this.assertPairAllowed(
|
|
tx,
|
|
pickerId,
|
|
previous.lease.bottle.authorId,
|
|
);
|
|
return this.replayResponse(previous.lease);
|
|
}
|
|
await this.assertEligible(tx, pickerId);
|
|
const usageDate = utc8UsageDate(new Date());
|
|
const batch = await this.candidates.findBatch(tx, pickerId);
|
|
if (!batch.length) {
|
|
// An in-flight same-key winner may have claimed the sole bottle
|
|
// without committing its request row yet. Let it commit before
|
|
// deciding this request actually saw an empty pool.
|
|
await this.locks.lockAccounts(tx, [pickerId]);
|
|
if (await this.findPrevious(tx, pickerId, requestId))
|
|
throw new CandidateBatchContended();
|
|
throw new DomainException(
|
|
ErrorCode.BOTTLE_POOL_EMPTY,
|
|
"Bottle pool empty",
|
|
HttpStatus.NOT_FOUND,
|
|
);
|
|
}
|
|
// Lock the entire subject set once, in global order. Locking pairs
|
|
// across candidates could otherwise form A→B / B→A cycles. This
|
|
// trades wider short-lived locks for correctness; the concurrent
|
|
// sole-bottle and same-key retry E2E cases exercise contention.
|
|
await this.locks.lockAccounts(tx, [
|
|
pickerId,
|
|
...batch.map((candidate) => candidate.authorId),
|
|
]);
|
|
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 CandidateBatchContended();
|
|
},
|
|
{
|
|
isolationLevel: "ReadCommitted",
|
|
...limits,
|
|
},
|
|
);
|
|
return result;
|
|
} catch (error) {
|
|
if (error instanceof DomainException) {
|
|
if (error.code === ErrorCode.BOTTLE_POOL_EMPTY) {
|
|
try {
|
|
const winner = await this.readWinnerUntil(
|
|
pickerId,
|
|
requestId,
|
|
Math.min(deadline, performance.now() + 250),
|
|
);
|
|
if (winner) return winner;
|
|
} catch (readError) {
|
|
if (classifyPickError(readError) === "SERVICE_UNAVAILABLE")
|
|
throw error;
|
|
throw readError;
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
const action = classifyPickError(error);
|
|
if (action === "THROW") throw error;
|
|
if (action === "SERVICE_UNAVAILABLE") break;
|
|
if (action === "READ_WINNER") {
|
|
try {
|
|
const winner = await this.readWinner(pickerId, requestId, deadline);
|
|
if (winner) return winner;
|
|
} catch (readError) {
|
|
if (classifyPickError(readError) === "SERVICE_UNAVAILABLE") break;
|
|
throw readError;
|
|
}
|
|
}
|
|
}
|
|
const remaining = deadline - performance.now();
|
|
if (remaining <= 1) break;
|
|
await new Promise((resolve) =>
|
|
setTimeout(
|
|
resolve,
|
|
Math.min(
|
|
randomInt(5, 21) * 2 ** Math.min(attempt, 3),
|
|
100,
|
|
remaining,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
throw new DomainException(
|
|
ErrorCode.SERVICE_UNAVAILABLE,
|
|
"Bottle pick temporarily unavailable",
|
|
HttpStatus.SERVICE_UNAVAILABLE,
|
|
);
|
|
}
|
|
|
|
private findPrevious(
|
|
client: Prisma.TransactionClient | PrismaService,
|
|
pickerId: string,
|
|
requestId: string,
|
|
) {
|
|
return client.bottlePickRequest.findUnique({
|
|
where: { pickerId_requestId: { pickerId, requestId } },
|
|
include: {
|
|
lease: {
|
|
include: {
|
|
bottle: {
|
|
include: { author: { include: { anonymousProfile: true } } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
private async readWinner(
|
|
pickerId: string,
|
|
requestId: string,
|
|
deadline: number,
|
|
) {
|
|
const limits = transactionLimits(this.retry, performance.now(), deadline);
|
|
if (!limits) return null;
|
|
const result = await this.prisma.$transaction(
|
|
async (tx) => {
|
|
const previous = await this.findPrevious(tx, pickerId, requestId);
|
|
if (!previous?.lease) return null;
|
|
await this.locks.lockAccounts(tx, [
|
|
pickerId,
|
|
previous.lease.bottle.authorId,
|
|
]);
|
|
await this.assertPairAllowed(
|
|
tx,
|
|
pickerId,
|
|
previous.lease.bottle.authorId,
|
|
);
|
|
return this.replayResponse(previous.lease);
|
|
},
|
|
{ isolationLevel: "ReadCommitted", ...limits },
|
|
);
|
|
return result;
|
|
}
|
|
|
|
private async readWinnerUntil(
|
|
pickerId: string,
|
|
requestId: string,
|
|
deadline: number,
|
|
) {
|
|
while (performance.now() < deadline) {
|
|
const winner = await this.readWinner(pickerId, requestId, deadline);
|
|
if (winner) return winner;
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private async claim(
|
|
tx: Prisma.TransactionClient,
|
|
pickerId: string,
|
|
requestId: string,
|
|
usageDate: string,
|
|
candidate: Candidate,
|
|
) {
|
|
await this.assertPairAllowed(tx, pickerId, candidate.authorId);
|
|
const eligible = await tx.bottle.findFirst({
|
|
where: {
|
|
id: candidate.id,
|
|
authorId: candidate.authorId,
|
|
version: candidate.version,
|
|
reviewStatus: "APPROVED",
|
|
poolStatus: "IN_POOL",
|
|
activeLeaseId: null,
|
|
author: { anonymousProfile: { reviewStatus: "APPROVED" } },
|
|
pickHistory: { none: { pickerId } },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (!eligible) return null;
|
|
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,
|
|
);
|
|
}
|
|
|
|
private async assertPairAllowed(
|
|
tx: Prisma.TransactionClient,
|
|
pickerId: string,
|
|
authorId: string,
|
|
) {
|
|
const now = new Date();
|
|
const [accounts, block, sanction] = await Promise.all([
|
|
tx.account.count({
|
|
where: { id: { in: [pickerId, authorId] }, status: "ACTIVE" },
|
|
}),
|
|
tx.block.findFirst({
|
|
where: {
|
|
OR: [
|
|
{ blockerId: pickerId, blockedId: authorId },
|
|
{ blockerId: authorId, blockedId: pickerId },
|
|
],
|
|
},
|
|
select: { id: true },
|
|
}),
|
|
tx.sanction.findFirst({
|
|
where: {
|
|
accountId: { in: [pickerId, authorId] },
|
|
type: { in: ["SUSPENSION", "BAN"] },
|
|
revokedAt: null,
|
|
startsAt: { lte: now },
|
|
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
|
},
|
|
select: { id: true },
|
|
}),
|
|
]);
|
|
if (block)
|
|
throw new DomainException(
|
|
ErrorCode.USER_BLOCKED,
|
|
"User blocked",
|
|
HttpStatus.FORBIDDEN,
|
|
);
|
|
if (accounts !== 2 || sanction)
|
|
throw new DomainException(
|
|
ErrorCode.ACCOUNT_SANCTIONED,
|
|
"Account sanctioned",
|
|
HttpStatus.FORBIDDEN,
|
|
);
|
|
}
|
|
|
|
private replayResponse(lease: LeaseWithBottle) {
|
|
try {
|
|
return this.response(
|
|
lease,
|
|
decryptLeaseToken(lease.leaseTokenCiphertext, lease.id),
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof DomainException) throw error;
|
|
throw new DomainException(
|
|
ErrorCode.SERVICE_UNAVAILABLE,
|
|
"Bottle pick temporarily unavailable",
|
|
HttpStatus.SERVICE_UNAVAILABLE,
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
}
|