Files
plp/apps/api/src/admin/admin.service.ts
T
root fa0fa78312 fix(治理): 完成任务 8 安全与通知闭环修复
- 串行化拉黑、处罚、投瓶和匹配策略检查\n- 完成异步站内通知、未读统计和偏好并发语义\n- 补齐后台查询审计、处罚恢复和隐私测试\n- 稳定 Redis 恢复、匹配锁序及超时测试
2026-09-17 13:08:55 +08:00

413 lines
13 KiB
TypeScript

import { createHash } from "node:crypto";
import { HttpStatus, Inject, Injectable } from "@nestjs/common";
import { ErrorCode } from "@drift/contracts";
import { Prisma } from "@prisma/client";
import { DomainException } from "../common/domain.exception.js";
import { PrismaService } from "../database/prisma.service.js";
import { SafetyLockService } from "../safety/safety-lock.service.js";
import type { ResolveReportDto, SanctionDto } from "./dto.js";
import { NotificationService } from "../notification/notification.service.js";
@Injectable()
export class AdminService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SafetyLockService) private readonly locks: SafetyLockService,
@Inject(NotificationService)
private readonly notifications: NotificationService,
) {}
async reports(
actorId: string,
status: "PENDING" | "REVIEWING" | "RESOLVED" | "DISMISSED" | undefined,
limit: number,
) {
const rows = await this.prisma.report.findMany({
where: status ? { status } : {},
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
take: limit,
});
await this.prisma.auditLog.create({
data: {
actorId,
action: "REPORTS_QUERIED",
entityType: "REPORT",
metadata: { status: status ?? null, limit, resultCount: rows.length },
},
});
return {
items: rows.map((x) => ({
id: x.id,
status: x.status,
reason: x.reason,
details: x.details,
targetSnapshot: x.targetSnapshot,
resolution: x.resolution,
createdAt: x.createdAt,
})),
};
}
async resolve(actorId: string, id: string, dto: ResolveReportDto) {
if (dto.decision === "DISMISSED" && dto.sanction)
throw new DomainException(
ErrorCode.VALIDATION_ERROR,
"Invalid sanction",
HttpStatus.BAD_REQUEST,
);
return this.prisma.$transaction(
async (tx) => {
const report = await tx.report.findUnique({ where: { id } });
if (!report)
throw new DomainException(
ErrorCode.NOT_FOUND,
"Not found",
HttpStatus.NOT_FOUND,
);
const accountId =
report.reportedAccountId ??
(report.messageId
? (
await tx.message.findUnique({
where: { id: report.messageId },
select: { senderId: true },
})
)?.senderId
: report.bottleId
? (
await tx.bottle.findUnique({
where: { id: report.bottleId },
select: { authorId: true },
})
)?.authorId
: report.conversationId
? (
await tx.anonymousProfile.findUnique({
where: {
publicId:
(
report.targetSnapshot as {
subjectPublicId?: string;
}
).subjectPublicId ??
"00000000-0000-0000-0000-000000000000",
},
select: { accountId: true },
})
)?.accountId
: undefined);
await this.locks.lockAccounts(tx, [
actorId,
...(accountId ? [accountId] : []),
]);
const changed = await tx.report.updateMany({
where: { id, status: { in: ["PENDING", "REVIEWING"] } },
data: {
status: dto.decision === "UPHELD" ? "RESOLVED" : "DISMISSED",
resolution: dto.resolution,
},
});
if (changed.count !== 1)
throw new DomainException(
ErrorCode.IDEMPOTENCY_CONFLICT,
"Report already resolved",
HttpStatus.CONFLICT,
);
const task = await tx.moderationTask.update({
where: {
targetType_targetId: { targetType: "REPORT", targetId: id },
},
data: {
status: "COMPLETED",
decision: dto.decision,
result: { decision: dto.decision },
assignedToId: actorId,
reviewedAt: new Date(),
},
});
if (dto.decision === "UPHELD" && dto.sanction && accountId)
await this.issue(tx, actorId, accountId, dto.sanction, task.id);
await tx.auditLog.create({
data: {
actorId,
action: "REPORT_RESOLVED",
entityType: "REPORT",
entityId: id,
metadata: {
decision: dto.decision,
sanctionType: dto.sanction?.type,
},
},
});
await this.notifications.createInApp(
tx,
report.reporterId,
`report:${id}:reporter`,
"REPORT_RESOLVED",
{ reportId: id, decision: dto.decision },
);
if (dto.decision === "UPHELD" && dto.sanction && accountId)
await this.notifications.createInApp(
tx,
accountId,
`report:${id}:target`,
"ACCOUNT_ACTION",
{ type: "ACCOUNT_ACTION" },
);
return tx.report.findUniqueOrThrow({
where: { id },
select: { id: true, status: true, resolution: true },
});
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
}
async sanctions(actorId: string, publicId: string, dto: SanctionDto) {
const profile = await this.prisma.anonymousProfile.findUnique({
where: { publicId },
});
if (!profile)
throw new DomainException(
ErrorCode.NOT_FOUND,
"Not found",
HttpStatus.NOT_FOUND,
);
return this.prisma.$transaction(async (tx) => {
await this.locks.lockAccounts(tx, [actorId, profile.accountId]);
const sanction = await this.issue(tx, actorId, profile.accountId, dto);
await this.notifications.createInApp(
tx,
profile.accountId,
`sanction:${sanction.id}:account-action`,
"ACCOUNT_ACTION",
{ type: "ACCOUNT_ACTION" },
);
await tx.auditLog.create({
data: {
actorId,
action: "SANCTION_ISSUED",
entityType: "ACCOUNT",
entityId: profile.accountId,
metadata: { type: dto.type },
},
});
return sanction;
});
}
async moderation(actorId: string, limit: number) {
const items = await this.prisma.moderationTask.findMany({
where: {
status: "COMPLETED",
decision: "MANUAL_REVIEW",
targetType: { in: ["PROFILE", "BOTTLE"] },
},
select: {
id: true,
targetType: true,
targetId: true,
riskLabels: true,
createdAt: true,
},
orderBy: { createdAt: "asc" },
take: limit,
});
await this.prisma.auditLog.create({
data: {
actorId,
action: "MODERATION_QUERIED",
entityType: "MODERATION_TASK",
metadata: { limit, resultCount: items.length },
},
});
return { items };
}
async moderate(
actorId: string,
id: string,
decision: "APPROVED" | "REJECTED",
reason: string,
) {
return this.prisma.$transaction(async (tx) => {
// Only the worker's terminal MANUAL_REVIEW result may be overridden. The
// outbox must already be published, so no leased worker can later write
// a stale moderation decision over the administrator's decision.
const task = await tx.moderationTask.findUnique({ where: { id } });
if (
!task ||
task.status !== "COMPLETED" ||
task.decision !== "MANUAL_REVIEW" ||
(task.targetType !== "BOTTLE" && task.targetType !== "PROFILE")
)
throw new DomainException(
ErrorCode.IDEMPOTENCY_CONFLICT,
"Task not awaiting manual review",
HttpStatus.CONFLICT,
);
const event = await tx.outboxEvent.findFirst({
where: {
eventType:
task.targetType === "BOTTLE"
? "BOTTLE_MODERATION_REQUESTED"
: "PROFILE_MODERATION_REQUESTED",
aggregateId: task.targetId,
status: "PUBLISHED",
payload: { path: ["taskId"], equals: task.id },
},
});
if (!event)
throw new DomainException(
ErrorCode.IDEMPOTENCY_CONFLICT,
"Moderation event not published",
HttpStatus.CONFLICT,
);
if (task.targetType === "BOTTLE") {
const bottle = await tx.bottle.findUnique({
where: { id: task.targetId },
});
if (
!bottle ||
bottle.reviewStatus !== "MANUAL_REVIEW" ||
bottle.poolStatus !== "CLOSED" ||
task.payloadHash !==
createHash("sha256").update(bottle.contentText).digest("hex")
)
throw new DomainException(
ErrorCode.IDEMPOTENCY_CONFLICT,
"Bottle state changed",
HttpStatus.CONFLICT,
);
const updated = await tx.bottle.updateMany({
where: {
id: bottle.id,
reviewStatus: "MANUAL_REVIEW",
poolStatus: "CLOSED",
contentText: bottle.contentText,
},
data: {
reviewStatus: decision,
poolStatus: decision === "APPROVED" ? "IN_POOL" : "CLOSED",
approvedAt: decision === "APPROVED" ? new Date() : null,
},
});
if (updated.count !== 1)
throw new DomainException(
ErrorCode.IDEMPOTENCY_CONFLICT,
"Bottle state changed",
HttpStatus.CONFLICT,
);
} else {
const profile = await tx.anonymousProfile.findUnique({
where: { id: task.targetId },
});
const hash =
profile &&
createHash("sha256")
.update(
JSON.stringify([
profile.nickname,
profile.avatarColor,
profile.bio,
]),
)
.digest("hex");
const version = Number(
event.dedupeKey.slice(event.dedupeKey.lastIndexOf(":") + 1),
);
if (
!profile ||
profile.reviewStatus !== "MANUAL_REVIEW" ||
profile.version !== version ||
hash !== task.payloadHash
)
throw new DomainException(
ErrorCode.IDEMPOTENCY_CONFLICT,
"Profile state changed",
HttpStatus.CONFLICT,
);
const updated = await tx.anonymousProfile.updateMany({
where: { id: profile.id, reviewStatus: "MANUAL_REVIEW", version },
data: { reviewStatus: decision },
});
if (updated.count !== 1)
throw new DomainException(
ErrorCode.IDEMPOTENCY_CONFLICT,
"Profile state changed",
HttpStatus.CONFLICT,
);
}
const changed = await tx.moderationTask.updateMany({
where: {
id,
status: "COMPLETED",
decision: "MANUAL_REVIEW",
payloadHash: task.payloadHash,
},
data: {
decision,
result: { decision },
assignedToId: actorId,
reviewedAt: new Date(),
},
});
if (changed.count !== 1)
throw new DomainException(
ErrorCode.IDEMPOTENCY_CONFLICT,
"Task already resolved",
HttpStatus.CONFLICT,
);
await tx.auditLog.create({
data: {
actorId,
action: "MODERATION_RESOLVED",
entityType: task.targetType,
entityId: task.targetId,
metadata: {
decision,
reason,
previousDecision: "MANUAL_REVIEW",
eventId: event.id,
},
},
});
return { id, status: "COMPLETED", decision };
});
}
private async issue(
tx: Prisma.TransactionClient,
actorId: string,
accountId: string,
dto: SanctionDto,
moderationTaskId?: string,
) {
if (
dto.expiresAt &&
(new Date(dto.expiresAt).getTime() <= Date.now() ||
new Date(dto.expiresAt).getTime() >
Date.now() + 365 * 24 * 60 * 60 * 1000)
)
throw new DomainException(
ErrorCode.VALIDATION_ERROR,
"Invalid sanction expiry",
HttpStatus.BAD_REQUEST,
);
const sanction = await tx.sanction.create({
data: {
accountId,
issuedById: actorId,
moderationTaskId: moderationTaskId ?? null,
type: dto.type,
reason: dto.reason,
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
},
});
if (dto.type === "SUSPENSION" || dto.type === "BAN") {
await tx.account.update({
where: { id: accountId },
data: { tokenVersion: { increment: 1 } },
});
await tx.session.updateMany({
where: { accountId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
return sanction;
}
}