Files
plp/apps/api/src/match/match.service.ts
T
root fa0fa78312 fix(治理): 完成任务 8 安全与通知闭环修复
- 串行化拉黑、处罚、投瓶和匹配策略检查\n- 完成异步站内通知、未读统计和偏好并发语义\n- 补齐后台查询审计、处罚恢复和隐私测试\n- 稳定 Redis 恢复、匹配锁序及超时测试
2026-09-17 13:08:55 +08:00

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,
);
}
}