feat: 完成举报拉黑和审核处置闭环
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
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";
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(SafetyLockService) private readonly locks: SafetyLockService,
|
||||
) {}
|
||||
async reports(
|
||||
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,
|
||||
});
|
||||
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
|
||||
? (report.targetSnapshot as { reportedAccountId?: string })
|
||||
.reportedAccountId
|
||||
: 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 tx.notification.create({
|
||||
data: {
|
||||
accountId: report.reporterId,
|
||||
dedupeKey: `report:${id}:reporter`,
|
||||
type: "REPORT_RESOLVED",
|
||||
payload: { reportId: id, decision: dto.decision },
|
||||
status: "SENT",
|
||||
sentAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (accountId)
|
||||
await tx.notification.create({
|
||||
data: {
|
||||
accountId,
|
||||
dedupeKey: `report:${id}:target`,
|
||||
type: "MODERATION_DECISION",
|
||||
payload: {
|
||||
reportId: id,
|
||||
decision: dto.decision,
|
||||
sanctionType: dto.sanction?.type ?? null,
|
||||
},
|
||||
status: "SENT",
|
||||
sentAt: new Date(),
|
||||
},
|
||||
});
|
||||
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 tx.auditLog.create({
|
||||
data: {
|
||||
actorId,
|
||||
action: "SANCTION_ISSUED",
|
||||
entityType: "ACCOUNT",
|
||||
entityId: profile.accountId,
|
||||
metadata: { type: dto.type },
|
||||
},
|
||||
});
|
||||
return sanction;
|
||||
});
|
||||
}
|
||||
async moderation(limit: number) {
|
||||
return {
|
||||
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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
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: { status: "SUSPENDED", tokenVersion: { increment: 1 } },
|
||||
});
|
||||
return sanction;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user