feat: 完成举报拉黑和审核处置闭环

This commit is contained in:
root
2026-09-16 20:41:01 +08:00
parent 02189ebb2e
commit ce8e1db279
19 changed files with 1778 additions and 5 deletions
@@ -0,0 +1,74 @@
import {
Body,
Controller,
Get,
Inject,
Post,
Put,
Query,
UseGuards,
} from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard.js";
import { CurrentUser } from "../auth/current-user.decorator.js";
import type { AccessClaims } from "../auth/token.service.js";
import { StateChangingOriginGuard } from "../conversation/state-changing-origin.guard.js";
import {
NotificationQueryDto,
PushPreferenceDto,
ReadNotificationsDto,
} from "../safety/dto.js";
import { NotificationService } from "./notification.service.js";
@Controller()
@UseGuards(AuthGuard)
export class NotificationController {
constructor(
@Inject(NotificationService)
private readonly notifications: NotificationService,
) {}
@Get("notifications") list(
@CurrentUser() u: AccessClaims,
@Query() q: NotificationQueryDto,
) {
return this.notifications.list(u.sub, q.cursor, q.limit);
}
@UseGuards(StateChangingOriginGuard) @Post("notifications/read") read(
@CurrentUser() u: AccessClaims,
@Body() d: ReadNotificationsDto,
) {
return this.notifications.read(u.sub, d.ids);
}
@Get("settings/push") pref(@CurrentUser() u: AccessClaims) {
return this.notifications.preference(u.sub);
}
@UseGuards(StateChangingOriginGuard) @Put("settings/push") update(
@CurrentUser() u: AccessClaims,
@Body() d: PushPreferenceDto,
) {
return this.notifications.updatePreference(u.sub, d.inAppEnabled);
}
}
Reflect.defineMetadata(
"design:paramtypes",
[Object, NotificationQueryDto],
NotificationController.prototype,
"list",
);
Reflect.defineMetadata(
"design:paramtypes",
[Object, ReadNotificationsDto],
NotificationController.prototype,
"read",
);
Reflect.defineMetadata(
"design:paramtypes",
[Object],
NotificationController.prototype,
"pref",
);
Reflect.defineMetadata(
"design:paramtypes",
[Object, PushPreferenceDto],
NotificationController.prototype,
"update",
);
@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { DatabaseModule } from "../database/database.module.js";
import { StateChangingOriginGuard } from "../conversation/state-changing-origin.guard.js";
import { NotificationController } from "./notification.controller.js";
import { NotificationService } from "./notification.service.js";
@Module({
imports: [DatabaseModule, AuthModule],
controllers: [NotificationController],
providers: [NotificationService, StateChangingOriginGuard],
})
export class NotificationModule {}
@@ -0,0 +1,107 @@
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";
@Injectable()
export class NotificationService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async list(accountId: string, cursor: string | undefined, limit: number) {
let decoded: { t: string; id: string } | undefined;
try {
if (cursor) {
const value: unknown = JSON.parse(
Buffer.from(cursor, "base64url").toString("utf8"),
);
if (
typeof value !== "object" ||
value === null ||
Array.isArray(value) ||
Object.keys(value).sort().join(",") !== "id,t" ||
!("t" in value) ||
typeof value.t !== "string" ||
!("id" in value) ||
typeof value.id !== "string" ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
value.id,
) ||
!Number.isFinite(Date.parse(value.t)) ||
new Date(value.t).toISOString() !== value.t
)
throw new Error("Invalid cursor");
decoded = { t: value.t, id: value.id };
}
} catch {
throw new DomainException(
ErrorCode.VALIDATION_ERROR,
"Invalid cursor",
HttpStatus.BAD_REQUEST,
);
}
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 page = rows.slice(0, limit);
return {
items: page.map((x) => ({
id: x.id,
type: x.type,
payload: x.payload,
status: x.status,
readAt: x.readAt,
createdAt: x.createdAt,
})),
nextCursor:
rows.length > limit && page.length
? Buffer.from(
JSON.stringify({
t: page.at(-1)!.createdAt.toISOString(),
id: page.at(-1)!.id,
}),
).toString("base64url")
: null,
};
}
async read(accountId: string, ids: string[]) {
const owned = await this.prisma.notification.count({
where: { accountId, id: { in: ids } },
});
if (owned !== new Set(ids).size)
throw new DomainException(
ErrorCode.NOT_FOUND,
"Not found",
HttpStatus.NOT_FOUND,
);
await this.prisma.notification.updateMany({
where: { accountId, id: { in: ids } },
data: { status: "READ", readAt: new Date() },
});
return { read: owned };
}
async preference(accountId: string) {
const row = await this.prisma.pushPreference.findUnique({
where: { accountId },
});
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 },
});
}
}