feat(player/admin/api): 站内邮箱、在线状态、员工菜单权限与内容管理增强
API: - 新增 player-messages 域:充值审核通过/拒绝、Banner/公告推广消息,支持多语言模板 - 新增 presence 域:Redis 心跳在线状态,管理端可查询在线玩家数 - User 表增加 visible_menus 字段;新增 player_messages 表及迁移 - 充值审核通过/拒绝时按系统配置自动写入玩家站内消息 - 管理端新增 GET /deposit-orders/pending-count、GET /presence/online-count - 玩家端新增消息 CRUD、presence/ping、home 返回 inbox 开关配置 - 员工管理支持 visibleMenus 配置与删除保护(不能删自己/最后超管) - SystemConfig 增加 inbox 功能开关及各类通知开关 Admin: - 员工管理:按角色默认菜单 + 可勾选可见菜单项 - ManageLayout:按 visibleMenus 过滤侧栏;充值待审数量角标轮询 - Contents:富文本编辑器、图片字段组件重构 - DashboardPlayers:展示在线玩家数;AdminPlayerStatusCell 在线状态列 - 多页面 i18n 与权限细节调整 Player: - 站内邮箱中心(InboxHub):消息列表/详情、未读角标、一键已读/删除 - 公告列表与详情页;走马灯可跳转详情 - 客服 Modal 改为 Panel,与邮箱 Hub 整合 - 充值状态轮询通知;presence 心跳;BetSlip 清空二次确认 - HomeView 今日赛事板块;FootballView 等体验优化 Shared: 新增 CANNOT_DELETE_SELF、STAFF_NOT_FOUND、MESSAGE_NOT_FOUND 等错误码 Docs: 玩家端缺失功能分析文档 Chore: 移除 .agents/skills 设计类 skill 文件 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "visible_menus" VARCHAR(1000);
|
||||
@@ -0,0 +1,22 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "player_messages" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"user_id" BIGINT NOT NULL,
|
||||
"type" VARCHAR(32) NOT NULL,
|
||||
"title" VARCHAR(256) NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"payload" JSONB,
|
||||
"read_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "player_messages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "player_messages_user_id_created_at_idx" ON "player_messages"("user_id", "created_at" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "player_messages_user_id_read_at_idx" ON "player_messages"("user_id", "read_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "player_messages" ADD CONSTRAINT "player_messages_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -23,6 +23,7 @@ model User {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
visibleMenus String? @map("visible_menus") @db.VarChar(1000)
|
||||
|
||||
auth UserAuth?
|
||||
wallet Wallet?
|
||||
@@ -31,6 +32,7 @@ model User {
|
||||
bets Bet[]
|
||||
preferences UserPreference?
|
||||
depositOrders DepositOrder[] @relation("PlayerDepositOrders")
|
||||
playerMessages PlayerMessage[]
|
||||
|
||||
parent User? @relation("UserHierarchy", fields: [parentId], references: [id])
|
||||
children User[] @relation("UserHierarchy")
|
||||
@@ -813,6 +815,23 @@ model DepositOrderAuditLog {
|
||||
@@map("deposit_order_audit_logs")
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt(sort: Desc)])
|
||||
@@index([userId, readAt])
|
||||
@@map("player_messages")
|
||||
}
|
||||
|
||||
// ============ System Config & Audit ============
|
||||
|
||||
model SystemConfig {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { PresenceService } from '../../domains/presence/presence.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
|
||||
function dec(v: Decimal | null | undefined) {
|
||||
@@ -12,7 +13,10 @@ function sub(a: Decimal | null | undefined, b: Decimal | null | undefined) {
|
||||
|
||||
@Injectable()
|
||||
export class AdminDashboardService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private presence: PresenceService,
|
||||
) {}
|
||||
|
||||
async getOverview() {
|
||||
const today = new Date();
|
||||
@@ -60,6 +64,7 @@ export class AdminDashboardService {
|
||||
walletAgg,
|
||||
recentBets,
|
||||
recentPlayers,
|
||||
playersOnlineNow,
|
||||
] = await Promise.all([
|
||||
this.prisma.bet.aggregate({
|
||||
where: { placedAt: { gte: today } },
|
||||
@@ -125,6 +130,7 @@ export class AdminDashboardService {
|
||||
parent: { select: { username: true } },
|
||||
},
|
||||
}),
|
||||
this.presence.getOnlineCount(),
|
||||
]);
|
||||
|
||||
const matchByStatus: Record<string, number> = {};
|
||||
@@ -164,6 +170,7 @@ export class AdminDashboardService {
|
||||
playersActive: playerActive,
|
||||
playersSuspended: playerSuspended,
|
||||
playersDirect: playerDirect,
|
||||
playersOnlineNow,
|
||||
agentsTotal: agentProfiles._count._all,
|
||||
agentsActive,
|
||||
},
|
||||
|
||||
@@ -50,6 +50,8 @@ import { P } from './admin-permissions';
|
||||
import { DatabaseResetService } from '../../infrastructure/database/database-reset.service';
|
||||
import { SmokeTestService } from '../../domains/operations/smoke-tests/smoke-test.service';
|
||||
import { DepositService } from '../../domains/deposit/deposit.service';
|
||||
import { PlayerMessagesService } from '../../domains/player-messages/player-messages.service';
|
||||
import { PresenceService } from '../../domains/presence/presence.service';
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
@@ -272,6 +274,10 @@ class CreateStaffDto {
|
||||
|
||||
@IsString()
|
||||
roleCode!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
visibleMenus?: string;
|
||||
}
|
||||
|
||||
class UpdateStaffDto {
|
||||
@@ -287,6 +293,10 @@ class UpdateStaffDto {
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
visibleMenus?: string;
|
||||
}
|
||||
|
||||
class ResetPlayerPasswordDto {
|
||||
@@ -1046,6 +1056,10 @@ class CreateContentDto {
|
||||
@IsString()
|
||||
endTime?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
notifyInbox?: boolean;
|
||||
|
||||
@IsArray()
|
||||
translations!: ContentTranslationDto[];
|
||||
}
|
||||
@@ -1085,6 +1099,16 @@ class ContentStatusDto {
|
||||
status!: string;
|
||||
}
|
||||
|
||||
class InboxNotifySettingsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
inboxEnabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
deposit?: boolean;
|
||||
}
|
||||
|
||||
class CashbackPreviewDto {
|
||||
@IsString()
|
||||
periodStart!: string;
|
||||
@@ -1209,9 +1233,18 @@ export class AdminController {
|
||||
private databaseReset: DatabaseResetService,
|
||||
private smokeTests: SmokeTestService,
|
||||
private depositService: DepositService,
|
||||
private playerMessages: PlayerMessagesService,
|
||||
private staff: AdminStaffService,
|
||||
private presence: PresenceService,
|
||||
) {}
|
||||
|
||||
@Get('presence/online-count')
|
||||
@RequirePermissions(P.usersView)
|
||||
async getOnlinePlayerCount() {
|
||||
const count = await this.presence.getOnlineCount();
|
||||
return jsonResponse({ count, asOf: new Date().toISOString() });
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
@RequirePermissions(P.reports)
|
||||
async getDashboard() {
|
||||
@@ -1500,6 +1533,23 @@ export class AdminController {
|
||||
return jsonResponse(updated);
|
||||
}
|
||||
|
||||
@Delete('staff/:id')
|
||||
@RequirePermissions(P.settings)
|
||||
async deleteStaff(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
await this.staff.deleteStaff(BigInt(id), operatorId);
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: 'DELETE_STAFF',
|
||||
module: 'STAFF',
|
||||
targetId: id,
|
||||
});
|
||||
return jsonResponse({ deleted: true });
|
||||
}
|
||||
|
||||
@Delete('users/:id')
|
||||
@RequirePermissions(P.usersCreate)
|
||||
async deletePlayer(
|
||||
@@ -3104,6 +3154,20 @@ export class AdminController {
|
||||
return urls;
|
||||
}
|
||||
|
||||
@Get('contents/inbox-notify-settings')
|
||||
@RequirePermissions(P.content, P.reports)
|
||||
async getInboxNotifySettings() {
|
||||
const settings = await this.systemConfig.getInboxNotifySettings();
|
||||
return jsonResponse(settings);
|
||||
}
|
||||
|
||||
@Put('contents/inbox-notify-settings')
|
||||
@RequirePermissions(P.content)
|
||||
async updateInboxNotifySettings(@Body() dto: InboxNotifySettingsDto) {
|
||||
const settings = await this.systemConfig.updateInboxNotifySettings(dto);
|
||||
return jsonResponse(settings);
|
||||
}
|
||||
|
||||
@Get('contents')
|
||||
@RequirePermissions(P.content, P.reports)
|
||||
async listContents(
|
||||
@@ -3128,8 +3192,34 @@ export class AdminController {
|
||||
@Post('contents')
|
||||
@RequirePermissions(P.content)
|
||||
async createContent(@Body() dto: CreateContentDto) {
|
||||
const item = await this.content.create(dto);
|
||||
return jsonResponse(item);
|
||||
const { notifyInbox, ...createDto } = dto;
|
||||
const item = await this.content.create(createDto);
|
||||
let notifiedCount: number | undefined;
|
||||
if (notifyInbox && (createDto.status ?? 'DRAFT') === 'ACTIVE') {
|
||||
const inboxEnabled = await this.systemConfig.getInboxFeatureEnabled();
|
||||
if (inboxEnabled) {
|
||||
const translations = createDto.translations.map((tr) => ({
|
||||
locale: tr.locale,
|
||||
title: tr.title,
|
||||
body: tr.body,
|
||||
}));
|
||||
if (createDto.contentType === 'BANNER') {
|
||||
notifiedCount = await this.playerMessages.broadcastBannerPromotion({
|
||||
contentId: BigInt(item.id),
|
||||
translations,
|
||||
});
|
||||
} else if (
|
||||
createDto.contentType === 'NOTICE' ||
|
||||
createDto.contentType === 'TICKER'
|
||||
) {
|
||||
notifiedCount = await this.playerMessages.broadcastAnnouncementPromotion({
|
||||
contentId: BigInt(item.id),
|
||||
translations,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return jsonResponse({ ...item, notifiedCount });
|
||||
}
|
||||
|
||||
@Put('contents/:id')
|
||||
@@ -3349,6 +3439,13 @@ export class AdminController {
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('deposit-orders/pending-count')
|
||||
@RequirePermissions(P.depositReview)
|
||||
async depositPendingCount() {
|
||||
const count = await this.depositService.countPendingDepositOrders();
|
||||
return jsonResponse({ count });
|
||||
}
|
||||
|
||||
@Get('deposit-orders/:id/audit-logs')
|
||||
@RequirePermissions(P.depositReview)
|
||||
async depositOrderAuditLogs(@Param('id') id: string) {
|
||||
|
||||
@@ -15,6 +15,8 @@ import { BetsModule } from '../../domains/betting/bets.module';
|
||||
import { DatabaseModule } from '../../infrastructure/database/database.module';
|
||||
import { SmokeTestModule } from '../../domains/operations/smoke-tests/smoke-test.module';
|
||||
import { DepositModule } from '../../domains/deposit/deposit.module';
|
||||
import { PlayerMessagesModule } from '../../domains/player-messages/player-messages.module';
|
||||
import { PresenceModule } from '../../domains/presence/presence.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -31,6 +33,8 @@ import { DepositModule } from '../../domains/deposit/deposit.module';
|
||||
DatabaseModule,
|
||||
SmokeTestModule,
|
||||
DepositModule,
|
||||
PlayerMessagesModule,
|
||||
PresenceModule,
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminDashboardService, PermissionsGuard],
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
@@ -30,6 +31,8 @@ import { BetsService } from '../../domains/betting/bets.service';
|
||||
import { ContentService } from '../../domains/operations/content/content.service';
|
||||
import { CashbackService } from '../../domains/operations/cashback/cashback.service';
|
||||
import { DepositService } from '../../domains/deposit/deposit.service';
|
||||
import { PlayerMessagesService } from '../../domains/player-messages/player-messages.service';
|
||||
import { PresenceService } from '../../domains/presence/presence.service';
|
||||
import { isInLocalTodayMatchWindow } from '@thebet365/shared';
|
||||
import { IsString, IsNumber, IsArray, ValidateNested, Min, IsOptional } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
@@ -120,8 +123,16 @@ export class PlayerController {
|
||||
private cashback: CashbackService,
|
||||
private systemConfig: SystemConfigService,
|
||||
private deposit: DepositService,
|
||||
private playerMessages: PlayerMessagesService,
|
||||
private presence: PresenceService,
|
||||
) {}
|
||||
|
||||
@Post('presence/ping')
|
||||
async presencePing(@CurrentUser('id') userId: bigint) {
|
||||
await this.presence.touch(userId);
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
|
||||
private async formatPlayerProfile(user: NonNullable<Awaited<ReturnType<UsersService['findById']>>>) {
|
||||
const accountSettings = await this.systemConfig.getPlayerAccountSettings();
|
||||
const prefs = user.preferences;
|
||||
@@ -175,10 +186,12 @@ export class PlayerController {
|
||||
@Headers('x-time-zone') headerTimeZone?: string,
|
||||
) {
|
||||
const locale = userLocale || headerLocale || 'zh-CN';
|
||||
const [banners, announcements, allMatches] = await Promise.all([
|
||||
const [banners, announcements, allMatches, upcomingMatches, inboxEnabled] = await Promise.all([
|
||||
this.content.listActive('BANNER', locale),
|
||||
this.content.listActiveAnnouncements(locale),
|
||||
this.matches.listPublished(locale, undefined, { includeMarkets: false }),
|
||||
this.matches.listUpcomingPublished(locale),
|
||||
this.systemConfig.getInboxFeatureEnabled(),
|
||||
]);
|
||||
const timeZone = safeTimeZone(headerTimeZone);
|
||||
const now = new Date();
|
||||
@@ -198,6 +211,8 @@ export class PlayerController {
|
||||
notices: announcements,
|
||||
hotMatches,
|
||||
todayMatches,
|
||||
upcomingMatches,
|
||||
inboxEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -245,6 +260,20 @@ export class PlayerController {
|
||||
return jsonResponse(match);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('selections/odds')
|
||||
async selectionOdds(@Query('ids') ids?: string) {
|
||||
if (!ids?.trim()) throw appBadRequest('SELECTION_NOT_FOUND');
|
||||
const parsed = ids
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => BigInt(s));
|
||||
if (!parsed.length || parsed.length > 20) throw appBadRequest('PARLAY_LEG_COUNT_INVALID');
|
||||
const items = await this.matches.getSelectionsOdds(parsed);
|
||||
return jsonResponse({ items });
|
||||
}
|
||||
|
||||
@Post('bets/single')
|
||||
async singleBet(@CurrentUser('id') userId: bigint, @CurrentUser('parentId') parentId: bigint, @Body() dto: SingleBetDto) {
|
||||
const bet = await this.bets.placeSingleBet(
|
||||
@@ -449,4 +478,56 @@ export class PlayerController {
|
||||
createdAt: order!.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Messages / Inbox ============
|
||||
|
||||
@Get('messages')
|
||||
async listMessages(
|
||||
@CurrentUser('id') userId: bigint,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const result = await this.playerMessages.listForPlayer(
|
||||
userId,
|
||||
page ? parseInt(page, 10) : 1,
|
||||
pageSize ? parseInt(pageSize, 10) : 20,
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('messages/unread-count')
|
||||
async messageUnreadCount(@CurrentUser('id') userId: bigint) {
|
||||
const result = await this.playerMessages.getUnreadCount(userId);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('messages/:id')
|
||||
async messageDetail(@CurrentUser('id') userId: bigint, @Param('id') id: string) {
|
||||
const message = await this.playerMessages.getForPlayer(userId, BigInt(id));
|
||||
return jsonResponse(message);
|
||||
}
|
||||
|
||||
@Patch('messages/read-all')
|
||||
async markAllMessagesRead(@CurrentUser('id') userId: bigint) {
|
||||
const result = await this.playerMessages.markAllRead(userId);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Patch('messages/:id/read')
|
||||
async markMessageRead(@CurrentUser('id') userId: bigint, @Param('id') id: string) {
|
||||
const message = await this.playerMessages.markRead(userId, BigInt(id));
|
||||
return jsonResponse(message);
|
||||
}
|
||||
|
||||
@Delete('messages')
|
||||
async deleteAllMessages(@CurrentUser('id') userId: bigint) {
|
||||
const result = await this.playerMessages.deleteAllForPlayer(userId);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Delete('messages/:id')
|
||||
async deleteMessage(@CurrentUser('id') userId: bigint, @Param('id') id: string) {
|
||||
const result = await this.playerMessages.deleteForPlayer(userId, BigInt(id));
|
||||
return jsonResponse(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ import { BetsModule } from '../../domains/betting/bets.module';
|
||||
import { ContentModule } from '../../domains/operations/content/content.module';
|
||||
import { CashbackModule } from '../../domains/operations/cashback/cashback.module';
|
||||
import { DepositModule } from '../../domains/deposit/deposit.module';
|
||||
import { PlayerMessagesModule } from '../../domains/player-messages/player-messages.module';
|
||||
import { PresenceModule } from '../../domains/presence/presence.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, WalletModule, MatchesModule, BetsModule, ContentModule, CashbackModule, DepositModule],
|
||||
imports: [UsersModule, WalletModule, MatchesModule, BetsModule, ContentModule, CashbackModule, DepositModule, PlayerMessagesModule, PresenceModule],
|
||||
controllers: [PlayerController],
|
||||
})
|
||||
export class PlayerModule {}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
jest.mock('@thebet365/shared', () => ({
|
||||
isPreMatchKickoff: jest.fn(() => true),
|
||||
PARLAY_MARKET_TYPES: [],
|
||||
resolveTranslationFallback: jest.fn(
|
||||
(translations: Map<string, string>, locale: string) =>
|
||||
translations.get(locale) ?? translations.get('zh-CN') ?? translations.get('en-US') ?? null,
|
||||
),
|
||||
resolveTranslationFallback: jest.fn((translations: Map<string, string> | Record<string, string>, locale: string) => {
|
||||
const get = (key: string) =>
|
||||
translations instanceof Map ? translations.get(key) : translations[key];
|
||||
return get(locale) ?? get('zh-CN') ?? get('en-US') ?? null;
|
||||
}),
|
||||
}));
|
||||
|
||||
import { MatchesService } from './matches.service';
|
||||
@@ -18,6 +19,7 @@ describe('MatchesService publish/unpublish', () => {
|
||||
match: { findFirst: jest.Mock; update: jest.Mock };
|
||||
entityTranslation: { findFirst: jest.Mock; upsert: jest.Mock };
|
||||
settlementBatch: { deleteMany: jest.Mock };
|
||||
marketSelection: { findMany: jest.Mock };
|
||||
};
|
||||
let outright: { syncWithLeaguePublished: jest.Mock };
|
||||
let matchBetStats: { betStatsForMatches: jest.Mock };
|
||||
@@ -36,6 +38,7 @@ describe('MatchesService publish/unpublish', () => {
|
||||
},
|
||||
entityTranslation: { findFirst: jest.fn().mockResolvedValue(null), upsert: jest.fn().mockResolvedValue({}) },
|
||||
settlementBatch: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
|
||||
marketSelection: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
outright = { syncWithLeaguePublished: jest.fn().mockResolvedValue(undefined) };
|
||||
matchBetStats = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
|
||||
@@ -124,3 +127,177 @@ describe('MatchesService publish/unpublish', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatchesService getSelectionsOdds', () => {
|
||||
const selectionId = BigInt(100);
|
||||
const matchId = BigInt(10);
|
||||
|
||||
let prisma: { marketSelection: { findMany: jest.Mock } };
|
||||
let service: MatchesService;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
marketSelection: { findMany: jest.fn() },
|
||||
};
|
||||
service = new MatchesService(
|
||||
prisma as never,
|
||||
{ syncWithLeaguePublished: jest.fn() } as never,
|
||||
{ betStatsForMatches: jest.fn() } as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns odds snapshot for requested selections', async () => {
|
||||
prisma.marketSelection.findMany.mockResolvedValue([
|
||||
{
|
||||
id: selectionId,
|
||||
odds: { toString: () => '1.95' },
|
||||
oddsVersion: BigInt(3),
|
||||
status: 'OPEN',
|
||||
market: {
|
||||
status: 'OPEN',
|
||||
showOnPlayer: true,
|
||||
match: { id: matchId, status: 'PUBLISHED' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getSelectionsOdds([selectionId]);
|
||||
|
||||
expect(prisma.marketSelection.findMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: [selectionId] } },
|
||||
include: { market: { include: { match: true } } },
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: '100',
|
||||
odds: '1.95',
|
||||
oddsVersion: '3',
|
||||
status: 'OPEN',
|
||||
marketStatus: 'OPEN',
|
||||
marketShowOnPlayer: true,
|
||||
matchStatus: 'PUBLISHED',
|
||||
matchId: '10',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when ids not found', async () => {
|
||||
prisma.marketSelection.findMany.mockResolvedValue([]);
|
||||
const result = await service.getSelectionsOdds([BigInt(999)]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for empty id list', async () => {
|
||||
prisma.marketSelection.findMany.mockResolvedValue([]);
|
||||
const result = await service.getSelectionsOdds([]);
|
||||
expect(prisma.marketSelection.findMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: [] } },
|
||||
include: { market: { include: { match: true } } },
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatchesService listUpcomingPublished', () => {
|
||||
const leagueId = BigInt(1);
|
||||
const homeTeamId = BigInt(2);
|
||||
const awayTeamId = BigInt(3);
|
||||
const matchId = BigInt(10);
|
||||
|
||||
let prisma: {
|
||||
match: { findMany: jest.Mock };
|
||||
entityTranslation: { findMany: jest.Mock };
|
||||
};
|
||||
let service: MatchesService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-06-17T12:00:00.000Z'));
|
||||
|
||||
prisma = {
|
||||
match: { findMany: jest.fn() },
|
||||
entityTranslation: {
|
||||
findMany: jest.fn().mockResolvedValue([{ locale: 'zh-CN', fieldName: 'name', value: '测试' }]),
|
||||
},
|
||||
};
|
||||
service = new MatchesService(
|
||||
prisma as never,
|
||||
{ syncWithLeaguePublished: jest.fn() } as never,
|
||||
{ betStatsForMatches: jest.fn().mockResolvedValue(new Map()) } as never,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('queries published matches within the next 3 days sorted by startTime', async () => {
|
||||
const now = new Date('2026-06-17T12:00:00.000Z');
|
||||
const end = new Date(now);
|
||||
end.setDate(end.getDate() + 3);
|
||||
|
||||
prisma.match.findMany.mockResolvedValue([
|
||||
{
|
||||
id: matchId,
|
||||
leagueId,
|
||||
homeTeamId,
|
||||
awayTeamId,
|
||||
startTime: new Date('2026-06-18T15:00:00.000Z'),
|
||||
status: 'PUBLISHED',
|
||||
isHot: false,
|
||||
displayOrder: 0,
|
||||
matchName: null,
|
||||
stage: null,
|
||||
groupName: null,
|
||||
league: { logoUrl: null },
|
||||
homeTeam: { code: 'HME', logoUrl: null },
|
||||
awayTeam: { code: 'AWY', logoUrl: null },
|
||||
score: null,
|
||||
markets: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.listUpcomingPublished('zh-CN');
|
||||
|
||||
expect(prisma.match.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'] },
|
||||
isOutright: false,
|
||||
sportType: 'FOOTBALL',
|
||||
deletedAt: null,
|
||||
startTime: { gte: now, lte: end },
|
||||
}),
|
||||
orderBy: [{ startTime: 'asc' }, { displayOrder: 'asc' }],
|
||||
take: 50,
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: '10',
|
||||
startTime: '2026-06-18T15:00:00.000Z',
|
||||
isHot: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('respects custom limit and days options', async () => {
|
||||
prisma.match.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.listUpcomingPublished('en-US', { limit: 20, days: 5 });
|
||||
|
||||
const now = new Date('2026-06-17T12:00:00.000Z');
|
||||
const end = new Date(now);
|
||||
end.setDate(end.getDate() + 5);
|
||||
|
||||
expect(prisma.match.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
startTime: { gte: now, lte: end },
|
||||
}),
|
||||
take: 20,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1461,6 +1461,42 @@ export class MatchesService {
|
||||
);
|
||||
}
|
||||
|
||||
/** 未来 N 天内开赛的已发布赛事(按开赛时间升序,不限 isHot) */
|
||||
async listUpcomingPublished(
|
||||
locale = 'en-US',
|
||||
options?: { limit?: number; days?: number },
|
||||
) {
|
||||
const limit = options?.limit ?? 50;
|
||||
const days = options?.days ?? 3;
|
||||
const now = new Date();
|
||||
const end = new Date(now);
|
||||
end.setDate(end.getDate() + days);
|
||||
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: {
|
||||
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'] },
|
||||
isOutright: false,
|
||||
sportType: 'FOOTBALL',
|
||||
deletedAt: null,
|
||||
startTime: { gte: now, lte: end },
|
||||
league: { isActive: true, deletedAt: null },
|
||||
},
|
||||
include: {
|
||||
league: true,
|
||||
homeTeam: true,
|
||||
awayTeam: true,
|
||||
score: true,
|
||||
markets: this.playerMarketStatusInclude,
|
||||
},
|
||||
orderBy: [{ startTime: 'asc' }, { displayOrder: 'asc' }],
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: true })),
|
||||
);
|
||||
}
|
||||
|
||||
async getMatchDetail(matchId: bigint, locale = 'en-US') {
|
||||
const match = await this.prisma.match.findFirst({
|
||||
where: {
|
||||
@@ -1483,6 +1519,24 @@ export class MatchesService {
|
||||
return this.enrichMatch(match, locale);
|
||||
}
|
||||
|
||||
async getSelectionsOdds(ids: bigint[]) {
|
||||
const selections = await this.prisma.marketSelection.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: { market: { include: { match: true } } },
|
||||
});
|
||||
|
||||
return selections.map((sel) => ({
|
||||
id: sel.id.toString(),
|
||||
odds: sel.odds.toString(),
|
||||
oddsVersion: sel.oddsVersion.toString(),
|
||||
status: sel.status,
|
||||
marketStatus: sel.market.status,
|
||||
marketShowOnPlayer: sel.market.showOnPlayer,
|
||||
matchStatus: sel.market.match.status,
|
||||
matchId: sel.market.match.id.toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async listOutrights(locale = 'en-US') {
|
||||
try {
|
||||
await syncWc2026OutrightMarket(this.prisma, { forceCanonical: false });
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
|
||||
import { DepositService } from './deposit.service';
|
||||
import { WalletModule } from '../ledger/wallet.module';
|
||||
import { AgentsModule } from '../agent/agents.module';
|
||||
import { PlayerMessagesModule } from '../player-messages/player-messages.module';
|
||||
import { SystemConfigModule } from '../../shared/config/system-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [WalletModule, AgentsModule],
|
||||
imports: [WalletModule, AgentsModule, PlayerMessagesModule, SystemConfigModule],
|
||||
providers: [DepositService],
|
||||
exports: [DepositService],
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ describe('DepositService', () => {
|
||||
},
|
||||
user: {
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
agentProfile: {
|
||||
findUnique: jest.fn(),
|
||||
@@ -38,12 +39,28 @@ describe('DepositService', () => {
|
||||
const credit = {
|
||||
recalculateUsedCredit: jest.fn(),
|
||||
};
|
||||
const playerMessages = {
|
||||
createDepositApprovedMessage: jest.fn(),
|
||||
createDepositRejectedMessage: jest.fn(),
|
||||
};
|
||||
const systemConfig = {
|
||||
getInboxNotifySettings: jest.fn().mockResolvedValue({
|
||||
inboxEnabled: true,
|
||||
deposit: true,
|
||||
}),
|
||||
};
|
||||
|
||||
let service: DepositService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new DepositService(prisma as never, funds as never, credit as never);
|
||||
service = new DepositService(
|
||||
prisma as never,
|
||||
funds as never,
|
||||
credit as never,
|
||||
playerMessages as never,
|
||||
systemConfig as never,
|
||||
);
|
||||
tx.$queryRaw.mockResolvedValue([{ id: 1n }]);
|
||||
tx.depositOrder.findUnique.mockResolvedValue({
|
||||
id: 1n,
|
||||
@@ -57,8 +74,11 @@ describe('DepositService', () => {
|
||||
});
|
||||
tx.bet.findMany.mockResolvedValue([]);
|
||||
tx.user.findFirst.mockResolvedValue(null);
|
||||
tx.user.findUnique.mockResolvedValue({ locale: 'en-US' });
|
||||
tx.agentProfile.findUnique.mockResolvedValue(null);
|
||||
credit.recalculateUsedCredit.mockResolvedValue(undefined);
|
||||
playerMessages.createDepositApprovedMessage.mockResolvedValue({});
|
||||
playerMessages.createDepositRejectedMessage.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('posts approved deposits as player wallet transactions and refreshes parent credit', async () => {
|
||||
@@ -131,7 +151,7 @@ describe('DepositService', () => {
|
||||
});
|
||||
|
||||
it('uses approval cycle key when revoking a funded deposit for re-review', async () => {
|
||||
const reviewedAt = new Date('2026-06-15T14:08:48.000Z');
|
||||
const reviewedAt = new Date();
|
||||
tx.depositOrder.findUnique.mockResolvedValue({
|
||||
id: 1n,
|
||||
orderNo: 'DEP-1',
|
||||
|
||||
@@ -7,6 +7,8 @@ import { FundsPostingService } from '../ledger/funds-posting.service';
|
||||
import { AgentCreditService } from '../agent/agent-credit.service';
|
||||
import { appBadRequest } from '../../shared/common/app-error';
|
||||
import { deleteUploadFileByUrl } from '../../shared/uploads/delete-upload-file';
|
||||
import { PlayerMessagesService } from '../player-messages/player-messages.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
|
||||
function generateOrderNo(): string {
|
||||
const ts = Date.now().toString(36).toUpperCase();
|
||||
@@ -49,6 +51,8 @@ export class DepositService {
|
||||
private prisma: PrismaService,
|
||||
private funds: FundsPostingService,
|
||||
private credit: AgentCreditService,
|
||||
private playerMessages: PlayerMessagesService,
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
// ============ Payment Methods (Admin CRUD) ============
|
||||
@@ -254,6 +258,14 @@ export class DepositService {
|
||||
|
||||
// ============ Deposit Orders ============
|
||||
|
||||
private async getPlayerLocale(playerId: bigint, tx: Prisma.TransactionClient | PrismaService = this.prisma) {
|
||||
const user = await tx.user.findUnique({
|
||||
where: { id: playerId },
|
||||
select: { locale: true },
|
||||
});
|
||||
return user?.locale ?? 'en-US';
|
||||
}
|
||||
|
||||
private async recordDepositAudit(
|
||||
client: AuditLogWriter,
|
||||
data: {
|
||||
@@ -670,6 +682,10 @@ export class DepositService {
|
||||
};
|
||||
}
|
||||
|
||||
async countPendingDepositOrders(): Promise<number> {
|
||||
return this.prisma.depositOrder.count({ where: { status: 'PENDING' } });
|
||||
}
|
||||
|
||||
async approveDepositOrder(
|
||||
orderId: bigint,
|
||||
operatorId: bigint,
|
||||
@@ -722,6 +738,22 @@ export class DepositService {
|
||||
await this.credit.recalculateUsedCredit(parentAgentId, tx);
|
||||
}
|
||||
|
||||
const playerLocale = await this.getPlayerLocale(order.playerId, tx);
|
||||
const inboxNotify = await this.systemConfig.getInboxNotifySettings();
|
||||
if (inboxNotify.inboxEnabled && inboxNotify.deposit) {
|
||||
await this.playerMessages.createDepositApprovedMessage(
|
||||
order.playerId,
|
||||
{
|
||||
depositOrderId: orderId,
|
||||
orderNo: order.orderNo,
|
||||
amount: order.amount.toString(),
|
||||
approvedAmount: creditAmount.toString(),
|
||||
locale: playerLocale,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
@@ -753,6 +785,18 @@ export class DepositService {
|
||||
remark: reason,
|
||||
});
|
||||
|
||||
const playerLocale = await this.getPlayerLocale(order.playerId);
|
||||
const inboxNotify = await this.systemConfig.getInboxNotifySettings();
|
||||
if (inboxNotify.inboxEnabled && inboxNotify.deposit) {
|
||||
await this.playerMessages.createDepositRejectedMessage(order.playerId, {
|
||||
depositOrderId: orderId,
|
||||
orderNo: order.orderNo,
|
||||
amount: order.amount.toString(),
|
||||
rejectReason: reason,
|
||||
locale: playerLocale,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -61,11 +61,12 @@ export class AdminStaffService {
|
||||
roleName: u.adminRole?.role?.name ?? null,
|
||||
lastLoginAt: u.auth?.lastLoginAt ?? null,
|
||||
createdAt: u.createdAt,
|
||||
visibleMenus: u.visibleMenus,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async createStaff(data: { username: string; password: string; roleCode: string }) {
|
||||
async createStaff(data: { username: string; password: string; roleCode: string; visibleMenus?: string }) {
|
||||
const username = data.username.trim();
|
||||
if (!username) throw appBadRequest('USERNAME_REQUIRED');
|
||||
if (data.password.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
|
||||
@@ -86,6 +87,7 @@ export class AdminStaffService {
|
||||
userType: 'ADMIN',
|
||||
auth: { create: { passwordHash: hash } },
|
||||
adminRole: { create: { roleId: role.id } },
|
||||
visibleMenus: data.visibleMenus,
|
||||
},
|
||||
include: {
|
||||
adminRole: { include: { role: { select: { code: true, name: true } } } },
|
||||
@@ -99,12 +101,13 @@ export class AdminStaffService {
|
||||
status: user.status,
|
||||
role: user.adminRole?.role?.code ?? null,
|
||||
roleName: user.adminRole?.role?.name ?? null,
|
||||
visibleMenus: user.visibleMenus,
|
||||
};
|
||||
}
|
||||
|
||||
async updateStaff(
|
||||
staffId: bigint,
|
||||
data: { status?: string; roleCode?: string; password?: string },
|
||||
data: { status?: string; roleCode?: string; password?: string; visibleMenus?: string },
|
||||
) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: staffId, userType: 'ADMIN', deletedAt: null },
|
||||
@@ -140,6 +143,13 @@ export class AdminStaffService {
|
||||
}
|
||||
}
|
||||
|
||||
if (data.visibleMenus !== undefined) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: staffId },
|
||||
data: { visibleMenus: data.visibleMenus },
|
||||
});
|
||||
}
|
||||
|
||||
let plainPassword: string | undefined;
|
||||
if (data.password !== undefined) {
|
||||
if (data.password.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
|
||||
@@ -163,10 +173,40 @@ export class AdminStaffService {
|
||||
status: refreshed!.status,
|
||||
role: refreshed!.adminRole?.role?.code ?? null,
|
||||
roleName: refreshed!.adminRole?.role?.name ?? null,
|
||||
visibleMenus: refreshed!.visibleMenus,
|
||||
...(plainPassword ? { password: plainPassword } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteStaff(staffId: bigint, operatorId?: bigint) {
|
||||
if (operatorId && staffId === operatorId) {
|
||||
throw appBadRequest('CANNOT_DELETE_SELF');
|
||||
}
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: staffId, userType: 'ADMIN', deletedAt: null },
|
||||
include: { adminRole: { include: { role: true } } },
|
||||
});
|
||||
if (!user) throw appNotFound('STAFF_NOT_FOUND');
|
||||
|
||||
if (user.adminRole?.role?.code === 'SUPER_ADMIN') {
|
||||
const superAdminCount = await this.prisma.user.count({
|
||||
where: {
|
||||
userType: 'ADMIN',
|
||||
deletedAt: null,
|
||||
adminRole: { role: { code: 'SUPER_ADMIN' } },
|
||||
},
|
||||
});
|
||||
if (superAdminCount <= 1) {
|
||||
throw appBadRequest('CANNOT_DELETE_LAST_SUPER_ADMIN');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id: staffId },
|
||||
data: { deletedAt: new Date(), status: 'DISABLED' },
|
||||
});
|
||||
}
|
||||
|
||||
async resetPlayerPassword(playerId: bigint, password?: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
|
||||
@@ -10,6 +10,8 @@ import { JwtAuthGuard } from './guards';
|
||||
import { jsonResponse } from '../../shared/common/filters';
|
||||
import { getClientIp } from '../../shared/common/client-ip.util';
|
||||
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller()
|
||||
export class AuthController {
|
||||
@@ -17,6 +19,7 @@ export class AuthController {
|
||||
private auth: AuthService,
|
||||
private invites: InvitesService,
|
||||
private systemConfig: SystemConfigService,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@@ -189,6 +192,15 @@ export class AuthController {
|
||||
inviteCode = (await this.auth.getInviteInfo(userId)).inviteCode;
|
||||
}
|
||||
|
||||
let visibleMenus: string | null = null;
|
||||
if (userType === 'ADMIN') {
|
||||
const userDb = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { visibleMenus: true },
|
||||
});
|
||||
visibleMenus = userDb?.visibleMenus ?? null;
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
id: userId.toString(),
|
||||
username,
|
||||
@@ -200,6 +212,7 @@ export class AuthController {
|
||||
maxAgentLevel,
|
||||
canManageSubAgents,
|
||||
inviteCode,
|
||||
visibleMenus,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ export class AuthService {
|
||||
locale: user.locale,
|
||||
role: user.adminRole?.role?.code,
|
||||
agentLevel: user.userType === 'AGENT' ? user.agentLevel : null,
|
||||
visibleMenus: user.visibleMenus,
|
||||
...(adminPermissions ? { permissions: adminPermissions } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,9 +3,10 @@ import { UsersService } from './users.service';
|
||||
import { AdminStaffService } from './admin-staff.service';
|
||||
import { AgentsModule } from '../agent/agents.module';
|
||||
import { CashbackModule } from '../operations/cashback/cashback.module';
|
||||
import { PresenceModule } from '../presence/presence.module';
|
||||
|
||||
@Module({
|
||||
imports: [AgentsModule, CashbackModule],
|
||||
imports: [AgentsModule, CashbackModule, PresenceModule],
|
||||
providers: [UsersService, AdminStaffService],
|
||||
exports: [UsersService, AdminStaffService],
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { AgentsService } from '../agent/agents.service';
|
||||
import { CashbackService } from '../operations/cashback/cashback.service';
|
||||
import { PresenceService } from '../presence/presence.service';
|
||||
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
|
||||
@@ -22,6 +23,7 @@ export class UsersService {
|
||||
private agents: AgentsService,
|
||||
private systemConfig: SystemConfigService,
|
||||
private cashback: CashbackService,
|
||||
private presence: PresenceService,
|
||||
) {}
|
||||
|
||||
private buildAffiliationAgents(
|
||||
@@ -297,14 +299,19 @@ export class UsersService {
|
||||
|
||||
const betMap = await this.loadBetStatsMap(rows.map((r) => r.id));
|
||||
const affiliationMap = await this.buildAffiliationChainMap(rows.map((r) => r.parentId));
|
||||
const onlineSet = await this.presence.filterOnlineIds(rows.map((r) => r.id));
|
||||
return {
|
||||
items: rows.map((u) =>
|
||||
this.formatPlayerRow(
|
||||
items: rows.map((u) => {
|
||||
const row = this.formatPlayerRow(
|
||||
u,
|
||||
betMap.get(u.id.toString()),
|
||||
u.parentId ? affiliationMap.get(u.parentId.toString()) : undefined,
|
||||
),
|
||||
),
|
||||
);
|
||||
return {
|
||||
...row,
|
||||
isOnline: onlineSet.has(row.id),
|
||||
};
|
||||
}),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
|
||||
@@ -222,6 +222,9 @@ export class ContentService {
|
||||
id: item.id.toString(),
|
||||
contentType: item.contentType,
|
||||
sortOrder: item.sortOrder,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
linkType: item.linkType,
|
||||
linkTarget: item.linkTarget,
|
||||
translation: tr,
|
||||
};
|
||||
});
|
||||
@@ -252,6 +255,7 @@ export class ContentService {
|
||||
id: item.id.toString(),
|
||||
contentType: item.contentType,
|
||||
sortOrder: item.sortOrder,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
linkType: item.linkType,
|
||||
linkTarget: item.linkTarget,
|
||||
translation: t,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlayerMessagesService } from './player-messages.service';
|
||||
|
||||
@Module({
|
||||
providers: [PlayerMessagesService],
|
||||
exports: [PlayerMessagesService],
|
||||
})
|
||||
export class PlayerMessagesModule {}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { PlayerMessagesService } from './player-messages.service';
|
||||
|
||||
describe('PlayerMessagesService', () => {
|
||||
const prisma = {
|
||||
playerMessage: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
let service: PlayerMessagesService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new PlayerMessagesService(prisma as never);
|
||||
});
|
||||
|
||||
it('creates localized deposit approved messages', async () => {
|
||||
prisma.playerMessage.create.mockResolvedValue({
|
||||
id: 1n,
|
||||
type: 'DEPOSIT_APPROVED',
|
||||
title: '充值已到账',
|
||||
body: 'body',
|
||||
payload: { orderNo: 'DEP-1' },
|
||||
readAt: null,
|
||||
createdAt: new Date('2026-06-17T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
const result = await service.createDepositApprovedMessage(7n, {
|
||||
depositOrderId: 10n,
|
||||
orderNo: 'DEP-1',
|
||||
amount: '100',
|
||||
approvedAmount: '100',
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
|
||||
expect(prisma.playerMessage.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: 7n,
|
||||
type: 'DEPOSIT_APPROVED',
|
||||
title: '充值已到账',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.type).toBe('DEPOSIT_APPROVED');
|
||||
expect(result.isRead).toBe(false);
|
||||
});
|
||||
|
||||
it('returns paginated inbox with unread count', async () => {
|
||||
prisma.playerMessage.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 2n,
|
||||
type: 'DEPOSIT_REJECTED',
|
||||
title: 'Deposit rejected',
|
||||
body: 'Rejected',
|
||||
payload: null,
|
||||
readAt: null,
|
||||
createdAt: new Date('2026-06-17T11:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
prisma.playerMessage.count.mockResolvedValueOnce(1).mockResolvedValueOnce(1);
|
||||
|
||||
const result = await service.listForPlayer(7n, 1, 20);
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.unreadCount).toBe(1);
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
|
||||
it('deletes a single message for the player', async () => {
|
||||
prisma.playerMessage.findFirst.mockResolvedValue({
|
||||
id: 3n,
|
||||
type: 'DEPOSIT_APPROVED',
|
||||
title: 'Deposit approved',
|
||||
body: 'body',
|
||||
payload: null,
|
||||
readAt: null,
|
||||
createdAt: new Date('2026-06-17T12:00:00.000Z'),
|
||||
});
|
||||
prisma.playerMessage.delete.mockResolvedValue({ id: 3n });
|
||||
|
||||
const result = await service.deleteForPlayer(7n, 3n);
|
||||
|
||||
expect(prisma.playerMessage.delete).toHaveBeenCalledWith({ where: { id: 3n } });
|
||||
expect(result).toEqual({ deleted: true, wasUnread: true });
|
||||
});
|
||||
|
||||
it('deletes all messages for the player', async () => {
|
||||
prisma.playerMessage.deleteMany.mockResolvedValue({ count: 4 });
|
||||
|
||||
const result = await service.deleteAllForPlayer(7n);
|
||||
|
||||
expect(prisma.playerMessage.deleteMany).toHaveBeenCalledWith({ where: { userId: 7n } });
|
||||
expect(result).toEqual({ deleted: 4 });
|
||||
});
|
||||
|
||||
it('broadcasts banner promotion inbox messages to active players', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([
|
||||
{ id: 10n, locale: 'zh-CN', preferences: { locale: 'zh-CN' } },
|
||||
{ id: 11n, locale: 'en-US', preferences: null },
|
||||
]);
|
||||
prisma.playerMessage.createMany.mockResolvedValue({ count: 2 });
|
||||
|
||||
const count = await service.broadcastBannerPromotion({
|
||||
contentId: 99n,
|
||||
translations: [
|
||||
{ locale: 'zh-CN', title: '夏季活动', body: '<p>限时优惠</p>' },
|
||||
{ locale: 'en-US', title: 'Summer promo', body: '' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(count).toBe(2);
|
||||
expect(prisma.playerMessage.createMany).toHaveBeenCalledWith({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
userId: 10n,
|
||||
type: 'BANNER_PROMO',
|
||||
title: '夏季活动',
|
||||
body: '限时优惠',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
userId: 11n,
|
||||
type: 'BANNER_PROMO',
|
||||
title: 'Summer promo',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts announcement promotion inbox messages to active players', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([
|
||||
{ id: 10n, locale: 'zh-CN', preferences: { locale: 'zh-CN' } },
|
||||
]);
|
||||
prisma.playerMessage.createMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
const count = await service.broadcastAnnouncementPromotion({
|
||||
contentId: 100n,
|
||||
translations: [{ locale: 'zh-CN', title: '维护通知', body: '系统维护中' }],
|
||||
});
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(prisma.playerMessage.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
expect.objectContaining({
|
||||
userId: 10n,
|
||||
type: 'ANNOUNCEMENT_PROMO',
|
||||
title: '维护通知',
|
||||
body: '系统维护中',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
437
apps/api/src/domains/player-messages/player-messages.service.ts
Normal file
437
apps/api/src/domains/player-messages/player-messages.service.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { appNotFound } from '../../shared/common/app-error';
|
||||
|
||||
export type PlayerMessageType =
|
||||
| 'DEPOSIT_APPROVED'
|
||||
| 'DEPOSIT_REJECTED'
|
||||
| 'BANNER_PROMO'
|
||||
| 'ANNOUNCEMENT_PROMO';
|
||||
|
||||
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'>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PlayerMessagesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private buildDepositMessage(
|
||||
type: Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO'>,
|
||||
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 };
|
||||
}
|
||||
}
|
||||
10
apps/api/src/domains/presence/presence.module.ts
Normal file
10
apps/api/src/domains/presence/presence.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RedisModule } from '../../shared/redis/redis.module';
|
||||
import { PresenceService } from './presence.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisModule],
|
||||
providers: [PresenceService],
|
||||
exports: [PresenceService],
|
||||
})
|
||||
export class PresenceModule {}
|
||||
70
apps/api/src/domains/presence/presence.service.spec.ts
Normal file
70
apps/api/src/domains/presence/presence.service.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { PresenceService } from './presence.service';
|
||||
|
||||
describe('PresenceService', () => {
|
||||
const pipeline = {
|
||||
exists: jest.fn().mockReturnThis(),
|
||||
exec: jest.fn(),
|
||||
};
|
||||
|
||||
const redis = {
|
||||
set: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
raw: {
|
||||
scan: jest.fn(),
|
||||
pipeline: jest.fn(() => pipeline),
|
||||
},
|
||||
};
|
||||
|
||||
let service: PresenceService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new PresenceService(redis as never);
|
||||
});
|
||||
|
||||
it('touches player key with 120s TTL', async () => {
|
||||
await service.touch(42n);
|
||||
expect(redis.set).toHaveBeenCalledWith('presence:player:42', '1', 120);
|
||||
});
|
||||
|
||||
it('checks single player online status', async () => {
|
||||
redis.exists.mockResolvedValue(true);
|
||||
await expect(service.isOnline(7n)).resolves.toBe(true);
|
||||
expect(redis.exists).toHaveBeenCalledWith('presence:player:7');
|
||||
});
|
||||
|
||||
it('counts online keys via SCAN', async () => {
|
||||
redis.raw.scan
|
||||
.mockResolvedValueOnce(['1', ['presence:player:1', 'presence:player:2']])
|
||||
.mockResolvedValueOnce(['0', ['presence:player:3']]);
|
||||
|
||||
await expect(service.getOnlineCount()).resolves.toBe(3);
|
||||
expect(redis.raw.scan).toHaveBeenCalledWith(
|
||||
'0',
|
||||
'MATCH',
|
||||
'presence:player:*',
|
||||
'COUNT',
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it('filters online ids with pipeline exists', async () => {
|
||||
pipeline.exec.mockResolvedValue([
|
||||
[null, 1],
|
||||
[null, 0],
|
||||
[null, 1],
|
||||
]);
|
||||
|
||||
const result = await service.filterOnlineIds([10n, 20n, 30n]);
|
||||
expect(result).toEqual(new Set(['10', '30']));
|
||||
expect(pipeline.exists).toHaveBeenCalledTimes(3);
|
||||
expect(pipeline.exists).toHaveBeenNthCalledWith(1, 'presence:player:10');
|
||||
expect(pipeline.exists).toHaveBeenNthCalledWith(2, 'presence:player:20');
|
||||
expect(pipeline.exists).toHaveBeenNthCalledWith(3, 'presence:player:30');
|
||||
});
|
||||
|
||||
it('returns empty set when no ids provided', async () => {
|
||||
await expect(service.filterOnlineIds([])).resolves.toEqual(new Set());
|
||||
expect(redis.raw.pipeline).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
57
apps/api/src/domains/presence/presence.service.ts
Normal file
57
apps/api/src/domains/presence/presence.service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '../../shared/redis/redis.service';
|
||||
|
||||
const TTL_SECONDS = 120;
|
||||
const KEY_PREFIX = 'presence:player:';
|
||||
|
||||
function keyFor(userId: bigint): string {
|
||||
return `${KEY_PREFIX}${userId.toString()}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PresenceService {
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
async touch(userId: bigint): Promise<void> {
|
||||
await this.redis.set(keyFor(userId), '1', TTL_SECONDS);
|
||||
}
|
||||
|
||||
async isOnline(userId: bigint): Promise<boolean> {
|
||||
return this.redis.exists(keyFor(userId));
|
||||
}
|
||||
|
||||
async getOnlineCount(): Promise<number> {
|
||||
let cursor = '0';
|
||||
let count = 0;
|
||||
do {
|
||||
const [next, keys] = await this.redis.raw.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
`${KEY_PREFIX}*`,
|
||||
'COUNT',
|
||||
200,
|
||||
);
|
||||
cursor = next;
|
||||
count += keys.length;
|
||||
} while (cursor !== '0');
|
||||
return count;
|
||||
}
|
||||
|
||||
async filterOnlineIds(ids: bigint[]): Promise<Set<string>> {
|
||||
const online = new Set<string>();
|
||||
if (ids.length === 0) return online;
|
||||
|
||||
const pipeline = this.redis.raw.pipeline();
|
||||
for (const id of ids) {
|
||||
pipeline.exists(keyFor(id));
|
||||
}
|
||||
const results = await pipeline.exec();
|
||||
ids.forEach((id, index) => {
|
||||
const entry = results?.[index];
|
||||
if (!entry) return;
|
||||
const [err, value] = entry;
|
||||
if (!err && value === 1) online.add(id.toString());
|
||||
});
|
||||
return online;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,17 @@ export const AGENT_MAX_LEVEL = 'agent.max_level';
|
||||
export const AGENT_DEFAULT_SUB_CREDIT_RATIO = 'agent.default_sub_credit_ratio';
|
||||
export const CASHBACK_PLATFORM_DIRECT_RATE = 'cashback.platform_direct_rate';
|
||||
export const CASHBACK_ADMIN_INVITE_RATE = 'cashback.admin_invite_rate';
|
||||
export const INBOX_NOTIFY_DEPOSIT = 'inbox.notify.deposit';
|
||||
export const INBOX_FEATURE_ENABLED = 'inbox.feature_enabled';
|
||||
export const INBOX_NOTIFY_BANNER = 'inbox.notify.banner';
|
||||
export const INBOX_NOTIFY_ANNOUNCEMENT = 'inbox.notify.announcement';
|
||||
|
||||
export type InboxNotifySettings = {
|
||||
/** 玩家端是否展示站内邮箱(关闭后入口直达客服) */
|
||||
inboxEnabled: boolean;
|
||||
/** 充值审核通过/拒绝时发送站内信 */
|
||||
deposit: boolean;
|
||||
};
|
||||
|
||||
export type PlatformDirectCashbackSettings = {
|
||||
/** 平台直属玩家默认返水比例(小数,0.01 = 1%) */
|
||||
@@ -222,4 +233,34 @@ export class SystemConfigService {
|
||||
}
|
||||
return this.getPlatformDirectCashbackSettings();
|
||||
}
|
||||
|
||||
async getInboxFeatureEnabled(): Promise<boolean> {
|
||||
return this.getBoolean(INBOX_FEATURE_ENABLED, true);
|
||||
}
|
||||
|
||||
async getInboxNotifySettings(): Promise<InboxNotifySettings> {
|
||||
const [inboxEnabled, deposit] = await Promise.all([
|
||||
this.getBoolean(INBOX_FEATURE_ENABLED, true),
|
||||
this.getBoolean(INBOX_NOTIFY_DEPOSIT, true),
|
||||
]);
|
||||
return { inboxEnabled, deposit };
|
||||
}
|
||||
|
||||
async updateInboxNotifySettings(data: Partial<InboxNotifySettings>) {
|
||||
if (data.inboxEnabled !== undefined) {
|
||||
await this.setBoolean(
|
||||
INBOX_FEATURE_ENABLED,
|
||||
data.inboxEnabled,
|
||||
'玩家端是否开启站内邮箱功能',
|
||||
);
|
||||
}
|
||||
if (data.deposit !== undefined) {
|
||||
await this.setBoolean(
|
||||
INBOX_NOTIFY_DEPOSIT,
|
||||
data.deposit,
|
||||
'充值审核结果是否通过站内邮箱通知玩家',
|
||||
);
|
||||
}
|
||||
return this.getInboxNotifySettings();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user