Files
thebet365/apps/api/src/domains/player-messages/player-messages.service.ts
Mars d8e08aaed0 sync(theme-3): 同步 main 最新 API/Admin 与玩家端逻辑
从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-3 样式。
2026-06-22 14:11:47 +08:00

680 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
export type PlayerMessageType =
| 'DEPOSIT_APPROVED'
| 'DEPOSIT_REJECTED'
| 'BANNER_PROMO'
| 'ANNOUNCEMENT_PROMO'
| 'ADMIN_CUSTOM';
export type DepositMessagePayload = {
depositOrderId: string;
orderNo: string;
amount: string;
approvedAmount?: string | null;
rejectReason?: string | null;
};
export type BannerPromoPayload = {
contentId: string;
};
type ContentTranslationLike = {
locale: string;
title?: string | null;
body?: string | null;
};
const SUPPORTED_LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
const BANNER_PROMO_DEFAULT_TITLE: Record<string, string> = {
'zh-CN': '新推广活动',
'en-US': 'New promotion',
'ms-MY': 'Promosi baharu',
};
const ANNOUNCEMENT_PROMO_DEFAULT_TITLE: Record<string, string> = {
'zh-CN': '新公告',
'en-US': 'New announcement',
'ms-MY': 'Pengumuman baharu',
};
type MessageTemplate = {
title: string;
body: (payload: DepositMessagePayload) => string;
};
const MESSAGE_TEMPLATES: Record<
Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO' | 'ADMIN_CUSTOM'>,
Record<string, MessageTemplate>
> = {
DEPOSIT_APPROVED: {
'zh-CN': {
title: '充值已到账',
body: (p) =>
`您的充值订单 ${p.orderNo} 已审核通过,申请金额 ${p.amount},到账金额 ${p.approvedAmount ?? p.amount}`,
},
'en-US': {
title: 'Deposit approved',
body: (p) =>
`Your deposit order ${p.orderNo} has been approved. Requested ${p.amount}, credited ${p.approvedAmount ?? p.amount}.`,
},
'ms-MY': {
title: 'Deposit diluluskan',
body: (p) =>
`Pesanan deposit ${p.orderNo} telah diluluskan. Diminta ${p.amount}, dikreditkan ${p.approvedAmount ?? p.amount}.`,
},
},
DEPOSIT_REJECTED: {
'zh-CN': {
title: '充值未通过',
body: (p) => {
const reason = p.rejectReason?.trim();
return reason
? `您的充值订单 ${p.orderNo}${p.amount})未通过审核。原因:${reason}`
: `您的充值订单 ${p.orderNo}${p.amount})未通过审核。`;
},
},
'en-US': {
title: 'Deposit rejected',
body: (p) => {
const reason = p.rejectReason?.trim();
return reason
? `Your deposit order ${p.orderNo} (${p.amount}) was rejected. Reason: ${reason}`
: `Your deposit order ${p.orderNo} (${p.amount}) was rejected.`;
},
},
'ms-MY': {
title: 'Deposit ditolak',
body: (p) => {
const reason = p.rejectReason?.trim();
return reason
? `Pesanan deposit ${p.orderNo} (${p.amount}) ditolak. Sebab: ${reason}`
: `Pesanan deposit ${p.orderNo} (${p.amount}) ditolak.`;
},
},
},
};
function resolveLocale(locale?: string | null): string {
const value = locale?.trim();
if (value && SUPPORTED_LOCALES.includes(value as (typeof SUPPORTED_LOCALES)[number])) {
return value;
}
return 'en-US';
}
function pickContentTranslation<T extends { locale: string }>(
translations: T[],
locale: string,
): T | undefined {
const chain = [locale, 'en-US', 'zh-CN', 'ms-MY'];
for (const loc of chain) {
const hit = translations.find((tr) => tr.locale === loc);
if (hit) return hit;
}
return translations[0];
}
function stripHtml(value: string): string {
return value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
function buildBannerPromoFallbackBody(locale: string, title: string): string {
if (locale === 'zh-CN') return `${title}」已上线,请到首页查看详情。`;
if (locale === 'ms-MY') return `"${title}" kini tersedia. Lihat butiran di halaman utama.`;
return `"${title}" is now live. View details on the home page.`;
}
function buildAnnouncementPromoFallbackBody(locale: string, title: string): string {
if (locale === 'zh-CN') return `公告「${title}」已发布,请及时查看。`;
if (locale === 'ms-MY') return `Pengumuman "${title}" telah diterbitkan. Sila semak.`;
return `Announcement "${title}" is published. Please check it out.`;
}
function buildBannerPromoMessage(
locale: string | null | undefined,
contentId: bigint,
translations: ContentTranslationLike[],
) {
const resolvedLocale = resolveLocale(locale);
const tr = pickContentTranslation(translations, resolvedLocale);
const title =
tr?.title?.trim() ||
BANNER_PROMO_DEFAULT_TITLE[resolvedLocale] ||
BANNER_PROMO_DEFAULT_TITLE['en-US'];
const rawBody = tr?.body?.trim() ? stripHtml(tr.body) : '';
const body = rawBody || buildBannerPromoFallbackBody(resolvedLocale, title);
const payload: BannerPromoPayload = { contentId: contentId.toString() };
return {
type: 'BANNER_PROMO' as const,
title,
body,
payload,
};
}
function buildAnnouncementPromoMessage(
locale: string | null | undefined,
contentId: bigint,
translations: ContentTranslationLike[],
) {
const resolvedLocale = resolveLocale(locale);
const tr = pickContentTranslation(translations, resolvedLocale);
const title =
tr?.title?.trim() ||
(tr?.body?.trim() ? tr.body.trim().slice(0, 40) : '') ||
ANNOUNCEMENT_PROMO_DEFAULT_TITLE[resolvedLocale] ||
ANNOUNCEMENT_PROMO_DEFAULT_TITLE['en-US'];
const rawBody = tr?.body?.trim() ?? '';
const body = rawBody || buildAnnouncementPromoFallbackBody(resolvedLocale, title);
const payload: BannerPromoPayload = { contentId: contentId.toString() };
return {
type: 'ANNOUNCEMENT_PROMO' as const,
title,
body,
payload,
};
}
function mapMessageRow(row: {
id: bigint;
type: string;
title: string;
body: string;
payload: Prisma.JsonValue;
readAt: Date | null;
createdAt: Date;
}) {
return {
id: row.id.toString(),
type: row.type,
title: row.title,
body: row.body,
payload: row.payload ?? null,
readAt: row.readAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
isRead: row.readAt != null,
};
}
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' | 'ADMIN_CUSTOM'>,
locale: string | null | undefined,
payload: DepositMessagePayload,
) {
const resolvedLocale = resolveLocale(locale);
const templates = MESSAGE_TEMPLATES[type];
const template = templates[resolvedLocale] ?? templates['en-US'];
return {
type,
title: template.title,
body: template.body(payload),
payload,
};
}
async createDepositApprovedMessage(
userId: bigint,
data: {
depositOrderId: bigint;
orderNo: string;
amount: string;
approvedAmount: string;
locale?: string | null;
},
client: Prisma.TransactionClient | PrismaService = this.prisma,
) {
const payload: DepositMessagePayload = {
depositOrderId: data.depositOrderId.toString(),
orderNo: data.orderNo,
amount: data.amount,
approvedAmount: data.approvedAmount,
};
const content = this.buildDepositMessage('DEPOSIT_APPROVED', data.locale, payload);
const row = await client.playerMessage.create({
data: {
userId,
type: content.type,
title: content.title,
body: content.body,
payload: content.payload as Prisma.InputJsonValue,
},
});
return mapMessageRow(row);
}
async createDepositRejectedMessage(
userId: bigint,
data: {
depositOrderId: bigint;
orderNo: string;
amount: string;
rejectReason?: string | null;
locale?: string | null;
},
client: Prisma.TransactionClient | PrismaService = this.prisma,
) {
const payload: DepositMessagePayload = {
depositOrderId: data.depositOrderId.toString(),
orderNo: data.orderNo,
amount: data.amount,
rejectReason: data.rejectReason ?? null,
};
const content = this.buildDepositMessage('DEPOSIT_REJECTED', data.locale, payload);
const row = await client.playerMessage.create({
data: {
userId,
type: content.type,
title: content.title,
body: content.body,
payload: content.payload as Prisma.InputJsonValue,
},
});
return mapMessageRow(row);
}
async broadcastBannerPromotion(data: {
contentId: bigint;
translations: ContentTranslationLike[];
}) {
const players = await this.prisma.user.findMany({
where: { userType: 'PLAYER', deletedAt: null, status: 'ACTIVE' },
select: {
id: true,
locale: true,
preferences: { select: { locale: true } },
},
});
if (!players.length) return 0;
const rows = players.map((player) => {
const playerLocale = player.preferences?.locale ?? player.locale;
const content = buildBannerPromoMessage(
playerLocale,
data.contentId,
data.translations,
);
return {
userId: player.id,
type: content.type,
title: content.title,
body: content.body,
payload: content.payload as Prisma.InputJsonValue,
};
});
const batchSize = 200;
for (let i = 0; i < rows.length; i += batchSize) {
await this.prisma.playerMessage.createMany({ data: rows.slice(i, i + batchSize) });
}
return rows.length;
}
async broadcastAnnouncementPromotion(data: {
contentId: bigint;
translations: ContentTranslationLike[];
}) {
const players = await this.prisma.user.findMany({
where: { userType: 'PLAYER', deletedAt: null, status: 'ACTIVE' },
select: {
id: true,
locale: true,
preferences: { select: { locale: true } },
},
});
if (!players.length) return 0;
const rows = players.map((player) => {
const playerLocale = player.preferences?.locale ?? player.locale;
const content = buildAnnouncementPromoMessage(
playerLocale,
data.contentId,
data.translations,
);
return {
userId: player.id,
type: content.type,
title: content.title,
body: content.body,
payload: content.payload as Prisma.InputJsonValue,
};
});
const batchSize = 200;
for (let i = 0; i < rows.length; i += batchSize) {
await this.prisma.playerMessage.createMany({ data: rows.slice(i, i + batchSize) });
}
return rows.length;
}
async listForPlayer(userId: bigint, 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 where = { userId };
const [rows, total, unreadCount] = await Promise.all([
this.prisma.playerMessage.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: safePageSize,
}),
this.prisma.playerMessage.count({ where }),
this.prisma.playerMessage.count({ where: { ...where, readAt: null } }),
]);
return {
items: rows.map(mapMessageRow),
total,
unreadCount,
page: safePage,
pageSize: safePageSize,
};
}
async getForPlayer(userId: bigint, messageId: bigint) {
const row = await this.prisma.playerMessage.findFirst({
where: { id: messageId, userId },
});
if (!row) throw appNotFound('MESSAGE_NOT_FOUND');
return mapMessageRow(row);
}
async markRead(userId: bigint, messageId: bigint) {
const row = await this.prisma.playerMessage.findFirst({
where: { id: messageId, userId },
});
if (!row) throw appNotFound('MESSAGE_NOT_FOUND');
if (row.readAt) return mapMessageRow(row);
const updated = await this.prisma.playerMessage.update({
where: { id: messageId },
data: { readAt: new Date() },
});
return mapMessageRow(updated);
}
async markAllRead(userId: bigint) {
const result = await this.prisma.playerMessage.updateMany({
where: { userId, readAt: null },
data: { readAt: new Date() },
});
return { updated: result.count };
}
async getUnreadCount(userId: bigint) {
const unreadCount = await this.prisma.playerMessage.count({
where: { userId, readAt: null },
});
return { unreadCount };
}
async deleteForPlayer(userId: bigint, messageId: bigint) {
const row = await this.prisma.playerMessage.findFirst({
where: { id: messageId, userId },
});
if (!row) throw appNotFound('MESSAGE_NOT_FOUND');
await this.prisma.playerMessage.delete({ where: { id: messageId } });
return { deleted: true, wasUnread: row.readAt == null };
}
async deleteAllForPlayer(userId: bigint) {
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 };
}
}