fa0fa78312
- 串行化拉黑、处罚、投瓶和匹配策略检查\n- 完成异步站内通知、未读统计和偏好并发语义\n- 补齐后台查询审计、处罚恢复和隐私测试\n- 稳定 Redis 恢复、匹配锁序及超时测试
413 lines
13 KiB
TypeScript
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;
|
|
}
|
|
}
|