fix(治理): 完成任务 8 安全与通知闭环修复
- 串行化拉黑、处罚、投瓶和匹配策略检查\n- 完成异步站内通知、未读统计和偏好并发语义\n- 补齐后台查询审计、处罚恢复和隐私测试\n- 稳定 Redis 恢复、匹配锁序及超时测试
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user