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

@@ -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 };
}
}