feat(admin+api+player): 站内信手动发送、媒体库选择与发送记录详情

新增管理员手动发送站内信(三语富文本),支持发送记录查看与删除;修复全站「从媒体库选择」因分类过滤导致列表为空的问题;玩家端支持渲染管理员自定义消息;并优化后台列表页双层卡片布局。
This commit is contained in:
2026-06-22 14:01:31 +08:00
parent 648c314e23
commit 1210142a33
21 changed files with 1355 additions and 40 deletions

View File

@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "player_message_broadcasts" (
"id" BIGSERIAL NOT NULL,
"title" VARCHAR(256) NOT NULL,
"body" TEXT NOT NULL,
"target_type" VARCHAR(16) NOT NULL,
"target_user_id" BIGINT,
"target_username" VARCHAR(64),
"recipient_count" INTEGER NOT NULL DEFAULT 0,
"created_by_id" BIGINT,
"created_by_username" VARCHAR(64),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "player_message_broadcasts_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "player_messages" ADD COLUMN "broadcast_id" BIGINT;
-- CreateIndex
CREATE INDEX "player_message_broadcasts_created_at_idx" ON "player_message_broadcasts"("created_at" DESC);
-- CreateIndex
CREATE INDEX "player_messages_broadcast_id_idx" ON "player_messages"("broadcast_id");
-- AddForeignKey
ALTER TABLE "player_messages" ADD CONSTRAINT "player_messages_broadcast_id_fkey" FOREIGN KEY ("broadcast_id") REFERENCES "player_message_broadcasts"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "player_message_broadcasts" ADD COLUMN "translations" JSONB;
-- Backfill existing rows as English content
UPDATE "player_message_broadcasts"
SET "translations" = jsonb_build_object(
'en-US', jsonb_build_object('title', "title", 'body', "body")
)
WHERE "translations" IS NULL;

View File

@@ -816,22 +816,44 @@ model DepositOrderAuditLog {
}
model PlayerMessage {
id BigInt @id @default(autoincrement())
userId BigInt @map("user_id")
type String @db.VarChar(32)
title String @db.VarChar(256)
body String @db.Text
payload Json?
readAt DateTime? @map("read_at")
createdAt DateTime @default(now()) @map("created_at")
id BigInt @id @default(autoincrement())
userId BigInt @map("user_id")
type String @db.VarChar(32)
title String @db.VarChar(256)
body String @db.Text
payload Json?
broadcastId BigInt? @map("broadcast_id")
readAt DateTime? @map("read_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
broadcast PlayerMessageBroadcast? @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
@@index([userId, createdAt(sort: Desc)])
@@index([userId, readAt])
@@index([broadcastId])
@@map("player_messages")
}
model PlayerMessageBroadcast {
id BigInt @id @default(autoincrement())
title String @db.VarChar(256)
body String @db.Text
translations Json? @map("translations")
targetType String @map("target_type") @db.VarChar(16)
targetUserId BigInt? @map("target_user_id")
targetUsername String? @map("target_username") @db.VarChar(64)
recipientCount Int @default(0) @map("recipient_count")
createdById BigInt? @map("created_by_id")
createdByUsername String? @map("created_by_username") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at")
messages PlayerMessage[]
@@index([createdAt(sort: Desc)])
@@map("player_message_broadcasts")
}
// ============ System Config & Audit ============
model SystemConfig {

View File

@@ -64,6 +64,7 @@ import {
IsIn,
Min,
Max,
MaxLength,
Equals,
ValidateIf,
} from 'class-validator';
@@ -1111,6 +1112,33 @@ class InboxNotifySettingsDto {
deposit?: boolean;
}
class BroadcastTranslationDto {
@IsString()
locale!: string;
@IsOptional()
@IsString()
@MaxLength(256)
title?: string;
@IsOptional()
@IsString()
body?: string;
}
class CreatePlayerMessageBroadcastDto {
@IsArray()
translations!: BroadcastTranslationDto[];
@IsIn(['ALL', 'USER'])
targetType!: 'ALL' | 'USER';
@ValidateIf((dto: CreatePlayerMessageBroadcastDto) => dto.targetType === 'USER')
@IsString()
@MinLength(1)
targetUsername?: string;
}
class UpdateDepositCleanupConfigDto {
@IsOptional()
@IsBoolean()
@@ -3102,8 +3130,24 @@ export class AdminController {
@Query('category') category?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('imagesOnly') imagesOnly?: string,
) {
const where = category && UPLOAD_CATEGORIES.includes(category as any) ? { category } : {};
const where: {
category?: string | { in: string[] };
mimeType?: { startsWith: string };
} = {};
if (category && UPLOAD_CATEGORIES.includes(category as UploadCategory)) {
where.category = category;
} else if (imagesOnly === '1' || imagesOnly === 'true') {
// 媒体库选择器:默认可选 banners/teams/contents/payments不含 deposits
where.category = { in: ['banners', 'teams', 'contents', 'payments'] };
}
if (imagesOnly === '1' || imagesOnly === 'true') {
where.mimeType = { startsWith: 'image/' };
}
const take = Math.min(parseInt(pageSize ?? '50', 10) || 50, 200);
const skip = (Math.max(parseInt(page ?? '1', 10) || 1, 1) - 1) * take;
@@ -3310,6 +3354,68 @@ export class AdminController {
return jsonResponse(settings);
}
@Get('player-message-broadcasts')
@RequirePermissions(P.content, P.reports)
async listPlayerMessageBroadcasts(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const p = Math.max(1, page ? parseInt(page, 10) || 1 : 1);
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 20), 50);
const result = await this.playerMessages.listBroadcasts(p, size);
return jsonResponse(result);
}
@Post('player-message-broadcasts')
@RequirePermissions(P.content)
async createPlayerMessageBroadcast(
@CurrentUser('id') operatorId: bigint,
@Body() dto: CreatePlayerMessageBroadcastDto,
) {
const operator = await this.prisma.user.findUnique({
where: { id: operatorId },
select: { username: true },
});
const item = await this.playerMessages.createCustomBroadcast({
translations: dto.translations,
targetType: dto.targetType,
targetUsername: dto.targetUsername,
createdById: operatorId,
createdByUsername: operator?.username ?? null,
});
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'SEND_PLAYER_MESSAGE_BROADCAST',
module: 'CONTENT',
afterData: JSON.stringify({
id: item.id,
title: item.title,
targetType: item.targetType,
recipientCount: item.recipientCount,
}),
});
return jsonResponse(item);
}
@Delete('player-message-broadcasts/:id')
@RequirePermissions(P.content)
async deletePlayerMessageBroadcast(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
) {
const result = await this.playerMessages.deleteBroadcast(BigInt(id));
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'DELETE_PLAYER_MESSAGE_BROADCAST',
module: 'CONTENT',
targetId: id,
afterData: JSON.stringify(result),
});
return jsonResponse(result);
}
@Get('contents')
@RequirePermissions(P.content, P.reports)
async listContents(

View File

@@ -1,7 +1,8 @@
import { PlayerMessagesService } from './player-messages.service';
describe('PlayerMessagesService', () => {
const prisma = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const prisma: any = {
playerMessage: {
create: jest.fn(),
findMany: jest.fn(),
@@ -13,10 +14,19 @@ describe('PlayerMessagesService', () => {
deleteMany: jest.fn(),
createMany: jest.fn(),
},
playerMessageBroadcast: {
findMany: jest.fn(),
count: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
user: {
findUnique: jest.fn(),
findMany: jest.fn(),
findFirst: jest.fn(),
},
$transaction: jest.fn(async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma)),
};
let service: PlayerMessagesService;
@@ -162,4 +172,58 @@ describe('PlayerMessagesService', () => {
],
});
});
it('creates custom broadcast to all active players', async () => {
prisma.user.findMany.mockResolvedValue([
{ id: 1n, username: 'p1', locale: 'zh-CN', preferences: { locale: 'zh-CN' } },
{ id: 2n, username: 'p2', locale: 'en-US', preferences: null },
]);
prisma.playerMessageBroadcast.create.mockResolvedValue({
id: 9n,
title: 'Hello',
body: '<p>World</p>',
translations: {
'en-US': { title: 'Hello', body: '<p>World</p>' },
'zh-CN': { title: '你好', body: '<p>内容</p>' },
},
targetType: 'ALL',
targetUserId: null,
targetUsername: null,
recipientCount: 2,
createdById: 99n,
createdByUsername: 'admin',
createdAt: new Date('2026-06-22T10:00:00.000Z'),
});
const result = await service.createCustomBroadcast({
translations: [
{ locale: 'en-US', title: 'Hello', body: '<p>World</p>' },
{ locale: 'zh-CN', title: '你好', body: '<p>内容</p>' },
],
targetType: 'ALL',
createdById: 99n,
createdByUsername: 'admin',
});
expect(result.recipientCount).toBe(2);
expect(prisma.playerMessage.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({ userId: 1n, type: 'ADMIN_CUSTOM', title: '你好', broadcastId: 9n }),
expect.objectContaining({ userId: 2n, type: 'ADMIN_CUSTOM', title: 'Hello', broadcastId: 9n }),
],
});
});
it('deletes broadcast and cascades player messages', async () => {
prisma.playerMessageBroadcast.findUnique.mockResolvedValue({
id: 5n,
recipientCount: 3,
});
prisma.playerMessageBroadcast.delete.mockResolvedValue({ id: 5n });
const result = await service.deleteBroadcast(5n);
expect(result).toEqual({ deleted: true, recipientCount: 3 });
expect(prisma.playerMessageBroadcast.delete).toHaveBeenCalledWith({ where: { id: 5n } });
});
});

View File

@@ -1,13 +1,14 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appNotFound } from '../../shared/common/app-error';
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
export type PlayerMessageType =
| 'DEPOSIT_APPROVED'
| 'DEPOSIT_REJECTED'
| 'BANNER_PROMO'
| 'ANNOUNCEMENT_PROMO';
| 'ANNOUNCEMENT_PROMO'
| 'ADMIN_CUSTOM';
export type DepositMessagePayload = {
depositOrderId: string;
@@ -47,7 +48,7 @@ type MessageTemplate = {
};
const MESSAGE_TEMPLATES: Record<
Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO'>,
Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO' | 'ADMIN_CUSTOM'>,
Record<string, MessageTemplate>
> = {
DEPOSIT_APPROVED: {
@@ -200,12 +201,122 @@ function mapMessageRow(row: {
};
}
export type BroadcastTranslation = {
title: string;
body: string;
};
export type BroadcastTranslations = Record<string, BroadcastTranslation>;
export type BroadcastTranslationInput = {
locale: string;
title?: string | null;
body?: string | null;
};
function normalizeBroadcastTranslations(
inputs: BroadcastTranslationInput[],
): BroadcastTranslations {
const map: BroadcastTranslations = {};
for (const tr of inputs) {
const locale = resolveLocale(tr.locale);
map[locale] = {
title: tr.title?.trim() ?? '',
body: tr.body?.trim() ?? '',
};
}
return map;
}
function resolveBroadcastContent(
translations: BroadcastTranslations,
locale: string | null | undefined,
): BroadcastTranslation | null {
const chain = [resolveLocale(locale), 'en-US', 'zh-CN', 'ms-MY'];
const seen = new Set<string>();
for (const loc of chain) {
if (seen.has(loc)) continue;
seen.add(loc);
const tr = translations[loc];
if (!tr) continue;
const title = tr.title?.trim();
const body = tr.body?.trim();
if (title || body) {
return {
title: title || stripHtml(body).slice(0, 256) || 'Notification',
body: body || '',
};
}
}
for (const tr of Object.values(translations)) {
const title = tr.title?.trim();
const body = tr.body?.trim();
if (title || body) {
return {
title: title || stripHtml(body).slice(0, 256) || 'Notification',
body: body || '',
};
}
}
return null;
}
function parseBroadcastTranslations(value: Prisma.JsonValue | null | undefined): BroadcastTranslations {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const map: BroadcastTranslations = {};
for (const [locale, raw] of Object.entries(value as Record<string, unknown>)) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
const row = raw as Record<string, unknown>;
map[locale] = {
title: typeof row.title === 'string' ? row.title : '',
body: typeof row.body === 'string' ? row.body : '',
};
}
return map;
}
function mapBroadcastRow(row: {
id: bigint;
title: string;
body: string;
translations: Prisma.JsonValue | null;
targetType: string;
targetUserId: bigint | null;
targetUsername: string | null;
recipientCount: number;
createdById: bigint | null;
createdByUsername: string | null;
createdAt: Date;
}) {
const translations = parseBroadcastTranslations(row.translations);
const hasTranslations = Object.keys(translations).length > 0;
const preview =
resolveBroadcastContent(hasTranslations ? translations : { 'en-US': { title: row.title, body: row.body } }, 'en-US') ??
{ title: row.title, body: row.body };
return {
id: row.id.toString(),
title: preview.title,
body: preview.body,
translations: hasTranslations
? translations
: { 'en-US': { title: row.title, body: row.body } },
targetType: row.targetType,
targetUserId: row.targetUserId?.toString() ?? null,
targetUsername: row.targetUsername,
recipientCount: row.recipientCount,
createdById: row.createdById?.toString() ?? null,
createdByUsername: row.createdByUsername,
createdAt: row.createdAt.toISOString(),
};
}
@Injectable()
export class PlayerMessagesService {
constructor(private prisma: PrismaService) {}
private buildDepositMessage(
type: Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO'>,
type: Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO' | 'ADMIN_CUSTOM'>,
locale: string | null | undefined,
payload: DepositMessagePayload,
) {
@@ -434,4 +545,135 @@ export class PlayerMessagesService {
const result = await this.prisma.playerMessage.deleteMany({ where: { userId } });
return { deleted: result.count };
}
async listBroadcasts(page = 1, pageSize = 20) {
const safePage = Math.max(1, page);
const safePageSize = Math.min(50, Math.max(1, pageSize));
const skip = (safePage - 1) * safePageSize;
const [rows, total] = await Promise.all([
this.prisma.playerMessageBroadcast.findMany({
orderBy: { createdAt: 'desc' },
skip,
take: safePageSize,
}),
this.prisma.playerMessageBroadcast.count(),
]);
return {
items: rows.map(mapBroadcastRow),
total,
page: safePage,
pageSize: safePageSize,
};
}
async createCustomBroadcast(data: {
translations: BroadcastTranslationInput[];
targetType: 'ALL' | 'USER';
targetUsername?: string | null;
createdById?: bigint | null;
createdByUsername?: string | null;
}) {
const translations = normalizeBroadcastTranslations(data.translations);
const preview = resolveBroadcastContent(translations, 'en-US');
if (!preview?.title && !preview?.body) {
throw appBadRequest('BROADCAST_CONTENT_REQUIRED');
}
const title = preview.title.slice(0, 256);
const body = preview.body;
if (!title && !body) throw appBadRequest('BROADCAST_CONTENT_REQUIRED');
let targetUsers: Array<{
id: bigint;
username: string;
locale: string | null;
preferences: { locale: string | null } | null;
}> = [];
let targetUserId: bigint | null = null;
let targetUsername: string | null = null;
if (data.targetType === 'ALL') {
targetUsers = await this.prisma.user.findMany({
where: { userType: 'PLAYER', deletedAt: null, status: 'ACTIVE' },
select: {
id: true,
username: true,
locale: true,
preferences: { select: { locale: true } },
},
});
} else {
const username = data.targetUsername?.trim();
if (!username) throw appBadRequest('BROADCAST_TARGET_USER_REQUIRED');
const player = await this.prisma.user.findFirst({
where: {
username,
userType: 'PLAYER',
deletedAt: null,
status: 'ACTIVE',
},
select: {
id: true,
username: true,
locale: true,
preferences: { select: { locale: true } },
},
});
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
targetUsers = [player];
targetUserId = player.id;
targetUsername = player.username;
}
if (!targetUsers.length) throw appBadRequest('BROADCAST_NO_RECIPIENTS');
const broadcast = await this.prisma.$transaction(async (tx) => {
const created = await tx.playerMessageBroadcast.create({
data: {
title,
body,
translations: translations as Prisma.InputJsonValue,
targetType: data.targetType,
targetUserId,
targetUsername,
recipientCount: targetUsers.length,
createdById: data.createdById ?? null,
createdByUsername: data.createdByUsername ?? null,
},
});
const rows = targetUsers.map((player) => {
const playerLocale = player.preferences?.locale ?? player.locale;
const content =
resolveBroadcastContent(translations, playerLocale) ?? preview;
return {
userId: player.id,
type: 'ADMIN_CUSTOM',
title: content.title.slice(0, 256),
body: content.body,
broadcastId: created.id,
payload: { broadcastId: created.id.toString() } as Prisma.InputJsonValue,
};
});
const batchSize = 200;
for (let i = 0; i < rows.length; i += batchSize) {
await tx.playerMessage.createMany({ data: rows.slice(i, i + batchSize) });
}
return created;
});
return mapBroadcastRow(broadcast);
}
async deleteBroadcast(broadcastId: bigint) {
const row = await this.prisma.playerMessageBroadcast.findUnique({
where: { id: broadcastId },
});
if (!row) throw appNotFound('BROADCAST_NOT_FOUND');
await this.prisma.playerMessageBroadcast.delete({ where: { id: broadcastId } });
return { deleted: true, recipientCount: row.recipientCount };
}
}