fix(治理): 完成任务 8 安全与通知闭环修复
- 串行化拉黑、处罚、投瓶和匹配策略检查\n- 完成异步站内通知、未读统计和偏好并发语义\n- 补齐后台查询审计、处罚恢复和隐私测试\n- 稳定 Redis 恢复、匹配锁序及超时测试
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
readMatchRetryConfig,
|
||||
transactionLimits,
|
||||
} from "./match-retry-policy.js";
|
||||
import { SafetyLockService } from "../safety/safety-lock.service.js";
|
||||
|
||||
type LeaseWithBottle = {
|
||||
id: string;
|
||||
@@ -24,6 +25,7 @@ type LeaseWithBottle = {
|
||||
leaseTokenCiphertext: Uint8Array;
|
||||
bottle: {
|
||||
id: string;
|
||||
authorId: string;
|
||||
contentText: string;
|
||||
author: {
|
||||
anonymousProfile: {
|
||||
@@ -60,6 +62,7 @@ export class MatchService {
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(CandidateRepository)
|
||||
private readonly candidates: CandidateRepository,
|
||||
@Inject(SafetyLockService) private readonly locks: SafetyLockService,
|
||||
) {}
|
||||
|
||||
async pick(pickerId: string, requestId: string) {
|
||||
@@ -72,30 +75,41 @@ export class MatchService {
|
||||
async (tx) => {
|
||||
const previous = await this.findPrevious(tx, pickerId, requestId);
|
||||
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.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)
|
||||
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]!;
|
||||
@@ -193,26 +207,24 @@ export class MatchService {
|
||||
) {
|
||||
const limits = transactionLimits(this.retry, performance.now(), deadline);
|
||||
if (!limits) return null;
|
||||
const previous = await this.prisma.$transaction(
|
||||
(tx) => this.findPrevious(tx, pickerId, requestId),
|
||||
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 },
|
||||
);
|
||||
if (previous?.lease) {
|
||||
try {
|
||||
const token = decryptLeaseToken(
|
||||
previous.lease.leaseTokenCiphertext,
|
||||
previous.lease.id,
|
||||
);
|
||||
return this.response(previous.lease, token);
|
||||
} catch {
|
||||
throw new DomainException(
|
||||
ErrorCode.SERVICE_UNAVAILABLE,
|
||||
"Bottle pick temporarily unavailable",
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async readWinnerUntil(
|
||||
@@ -235,6 +247,21 @@ export class MatchService {
|
||||
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();
|
||||
@@ -312,6 +339,66 @@ export class MatchService {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user