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
@@ -8,5 +8,6 @@ import { NotificationService } from "./notification.service.js";
imports: [DatabaseModule, AuthModule],
controllers: [NotificationController],
providers: [NotificationService, StateChangingOriginGuard],
exports: [NotificationService],
})
export class NotificationModule {}
@@ -2,10 +2,66 @@ import { HttpStatus, Inject, Injectable } from "@nestjs/common";
import { ErrorCode } from "@drift/contracts";
import { PrismaService } from "../database/prisma.service.js";
import { DomainException } from "../common/domain.exception.js";
import type { Prisma, PrismaClient } from "@prisma/client";
type Db = Prisma.TransactionClient | PrismaClient;
@Injectable()
export class NotificationService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async createInApp(
tx: Db,
accountId: string,
dedupeKey: string,
type: string,
payload: Prisma.InputJsonObject,
) {
const preference = await tx.pushPreference.findUnique({
where: { accountId },
select: { inAppEnabled: true },
});
if (preference?.inAppEnabled === false) return null;
return tx.notification.upsert({
where: { accountId_dedupeKey: { accountId, dedupeKey } },
create: {
accountId,
dedupeKey,
type,
payload,
status: "PENDING",
},
update: {},
});
}
async notifyAdmins(tx: Db, reportId: string) {
const admins = await tx.account.findMany({
where: {
role: "ADMIN",
status: "ACTIVE",
OR: [
{ pushPreference: null },
{ pushPreference: { is: { inAppEnabled: true } } },
],
},
select: { id: true },
});
await Promise.all(
admins.map(({ id }) =>
this.createInApp(
tx,
id,
`report:${reportId}:admin-pending`,
"ADMIN_REPORT_PENDING",
{ reportId },
),
),
);
}
async list(accountId: string, cursor: string | undefined, limit: number) {
const preference = await this.prisma.pushPreference.findUnique({
where: { accountId },
select: { inAppEnabled: true },
});
if (preference?.inAppEnabled === false)
return { items: [], nextCursor: null, unreadCount: 0 };
let decoded: { t: string; id: string } | undefined;
try {
if (cursor) {
@@ -38,23 +94,30 @@ export class NotificationService {
);
}
const date = decoded ? new Date(decoded.t) : undefined;
const rows = await this.prisma.notification.findMany({
where: {
accountId,
...(decoded && date
? {
OR: [
{ createdAt: { lt: date } },
{ createdAt: date, id: { lt: decoded.id } },
],
}
: {}),
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: limit + 1,
});
const [rows, unreadCount] = await Promise.all([
this.prisma.notification.findMany({
where: {
accountId,
status: { in: ["SENT", "READ"] },
...(decoded && date
? {
OR: [
{ createdAt: { lt: date } },
{ createdAt: date, id: { lt: decoded.id } },
],
}
: {}),
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: limit + 1,
}),
this.prisma.notification.count({
where: { accountId, status: "SENT", readAt: null },
}),
]);
const page = rows.slice(0, limit);
return {
unreadCount,
items: page.map((x) => ({
id: x.id,
type: x.type,
@@ -76,7 +139,7 @@ export class NotificationService {
}
async read(accountId: string, ids: string[]) {
const owned = await this.prisma.notification.count({
where: { accountId, id: { in: ids } },
where: { accountId, id: { in: ids }, status: { in: ["SENT", "READ"] } },
});
if (owned !== new Set(ids).size)
throw new DomainException(
@@ -85,7 +148,7 @@ export class NotificationService {
HttpStatus.NOT_FOUND,
);
await this.prisma.notification.updateMany({
where: { accountId, id: { in: ids } },
where: { accountId, id: { in: ids }, status: { in: ["SENT", "READ"] } },
data: { status: "READ", readAt: new Date() },
});
return { read: owned };
@@ -97,11 +160,17 @@ export class NotificationService {
return { inAppEnabled: row?.inAppEnabled ?? true };
}
async updatePreference(accountId: string, inAppEnabled: boolean) {
return this.prisma.pushPreference.upsert({
where: { accountId },
create: { accountId, inAppEnabled },
update: { inAppEnabled },
select: { inAppEnabled: true },
return this.prisma.$transaction(async (tx) => {
// The worker locks notification -> account -> preference. A settings
// change has no notification row; it locks account -> preference too.
// Account locking covers the initially absent preference row.
await tx.$queryRaw`SELECT "id" FROM "accounts" WHERE "id"=${accountId}::uuid FOR UPDATE`;
return tx.pushPreference.upsert({
where: { accountId },
create: { accountId, inAppEnabled },
update: { inAppEnabled },
select: { inAppEnabled: true },
});
});
}
}