fix(治理): 完成任务 8 安全与通知闭环修复

- 串行化拉黑、处罚、投瓶和匹配策略检查\n- 完成异步站内通知、未读统计和偏好并发语义\n- 补齐后台查询审计、处罚恢复和隐私测试\n- 稳定 Redis 恢复、匹配锁序及超时测试
This commit is contained in:
root
2026-09-17 13:08:55 +08:00
parent ce8e1db279
commit fa0fa78312
29 changed files with 1231 additions and 153 deletions
+120 -33
View File
@@ -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,