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>
267 lines
9.5 KiB
TypeScript
267 lines
9.5 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
||
import { PrismaService } from '../prisma/prisma.service';
|
||
|
||
export const PLAYER_ALLOW_PASSWORD_CHANGE = 'player.allow_password_change';
|
||
export const PLAYER_ALLOW_USERNAME_CHANGE = 'player.allow_username_change';
|
||
export const AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS = 'agent.suspend_freeze_direct_players';
|
||
export const AGENT_SUSPEND_BLOCK_PLAYER_LOGIN = 'agent.suspend_block_player_login';
|
||
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%) */
|
||
platformDirectRate: number;
|
||
/** 管理员邀请注册玩家返水比例;未配置时与 platformDirectRate 相同 */
|
||
adminInviteRate: number;
|
||
};
|
||
|
||
export type AgentHierarchySettings = {
|
||
/** 最大代理层级;0 = 不限制 */
|
||
maxAgentLevel: number;
|
||
/** 创建下级代理时默认授信占上级可用授信的比例(1–100) */
|
||
defaultSubAgentCreditRatio: number;
|
||
};
|
||
|
||
export type PlayerAccountSettings = {
|
||
allowPasswordChange: boolean;
|
||
allowUsernameChange: boolean;
|
||
};
|
||
|
||
export type AgentSuspendSettings = {
|
||
/** 停用代理时是否允许级联冻结其直属玩家(需管理员显式勾选) */
|
||
suspendFreezeDirectPlayers: boolean;
|
||
/** 上级代理停用时是否禁止其直属玩家登录 */
|
||
suspendBlockPlayerLogin: boolean;
|
||
};
|
||
|
||
@Injectable()
|
||
export class SystemConfigService {
|
||
constructor(private prisma: PrismaService) {}
|
||
|
||
async getBoolean(key: string, defaultValue: boolean): Promise<boolean> {
|
||
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
|
||
if (!row) return defaultValue;
|
||
return row.configValue === 'true' || row.configValue === '1';
|
||
}
|
||
|
||
async setBoolean(key: string, value: boolean, description?: string) {
|
||
await this.prisma.systemConfig.upsert({
|
||
where: { configKey: key },
|
||
create: {
|
||
configKey: key,
|
||
configValue: value ? 'true' : 'false',
|
||
description,
|
||
},
|
||
update: { configValue: value ? 'true' : 'false' },
|
||
});
|
||
}
|
||
|
||
async getInt(key: string, defaultValue: number): Promise<number> {
|
||
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
|
||
if (!row) return defaultValue;
|
||
const parsed = parseInt(row.configValue, 10);
|
||
return Number.isFinite(parsed) ? parsed : defaultValue;
|
||
}
|
||
|
||
async setInt(key: string, value: number, description?: string) {
|
||
await this.prisma.systemConfig.upsert({
|
||
where: { configKey: key },
|
||
create: {
|
||
configKey: key,
|
||
configValue: String(value),
|
||
description,
|
||
},
|
||
update: { configValue: String(value) },
|
||
});
|
||
}
|
||
|
||
async getPlayerAccountSettings(): Promise<PlayerAccountSettings> {
|
||
const [allowPasswordChange, allowUsernameChange] = await Promise.all([
|
||
this.getBoolean(PLAYER_ALLOW_PASSWORD_CHANGE, true),
|
||
this.getBoolean(PLAYER_ALLOW_USERNAME_CHANGE, false),
|
||
]);
|
||
return { allowPasswordChange, allowUsernameChange };
|
||
}
|
||
|
||
async updatePlayerAccountSettings(data: Partial<PlayerAccountSettings>) {
|
||
if (data.allowPasswordChange !== undefined) {
|
||
await this.setBoolean(
|
||
PLAYER_ALLOW_PASSWORD_CHANGE,
|
||
data.allowPasswordChange,
|
||
'玩家是否可在客户端修改密码',
|
||
);
|
||
}
|
||
if (data.allowUsernameChange !== undefined) {
|
||
await this.setBoolean(
|
||
PLAYER_ALLOW_USERNAME_CHANGE,
|
||
data.allowUsernameChange,
|
||
'玩家是否可在客户端修改登录账号名',
|
||
);
|
||
}
|
||
return this.getPlayerAccountSettings();
|
||
}
|
||
|
||
async getAgentSuspendSettings(): Promise<AgentSuspendSettings> {
|
||
const [suspendFreezeDirectPlayers, suspendBlockPlayerLogin] = await Promise.all([
|
||
this.getBoolean(AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS, false),
|
||
this.getBoolean(AGENT_SUSPEND_BLOCK_PLAYER_LOGIN, false),
|
||
]);
|
||
return { suspendFreezeDirectPlayers, suspendBlockPlayerLogin };
|
||
}
|
||
|
||
async updateAgentSuspendSettings(data: Partial<AgentSuspendSettings>) {
|
||
if (data.suspendFreezeDirectPlayers !== undefined) {
|
||
await this.setBoolean(
|
||
AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS,
|
||
data.suspendFreezeDirectPlayers,
|
||
'停用代理时是否允许级联冻结直属玩家',
|
||
);
|
||
}
|
||
if (data.suspendBlockPlayerLogin !== undefined) {
|
||
await this.setBoolean(
|
||
AGENT_SUSPEND_BLOCK_PLAYER_LOGIN,
|
||
data.suspendBlockPlayerLogin,
|
||
'上级代理停用时是否禁止直属玩家登录',
|
||
);
|
||
}
|
||
return this.getAgentSuspendSettings();
|
||
}
|
||
|
||
async getAgentHierarchySettings(): Promise<AgentHierarchySettings> {
|
||
const [maxAgentLevel, defaultSubAgentCreditRatio] = await Promise.all([
|
||
this.getInt(AGENT_MAX_LEVEL, 0),
|
||
this.getInt(AGENT_DEFAULT_SUB_CREDIT_RATIO, 50),
|
||
]);
|
||
return { maxAgentLevel, defaultSubAgentCreditRatio };
|
||
}
|
||
|
||
async updateAgentHierarchySettings(data: Partial<AgentHierarchySettings>) {
|
||
if (data.maxAgentLevel !== undefined) {
|
||
if (!Number.isInteger(data.maxAgentLevel) || data.maxAgentLevel < 0) {
|
||
throw new Error('maxAgentLevel must be a non-negative integer');
|
||
}
|
||
await this.setInt(
|
||
AGENT_MAX_LEVEL,
|
||
data.maxAgentLevel,
|
||
'最大代理层级;0 表示不限制',
|
||
);
|
||
}
|
||
if (data.defaultSubAgentCreditRatio !== undefined) {
|
||
if (
|
||
!Number.isInteger(data.defaultSubAgentCreditRatio) ||
|
||
data.defaultSubAgentCreditRatio < 1 ||
|
||
data.defaultSubAgentCreditRatio > 100
|
||
) {
|
||
throw new Error('defaultSubAgentCreditRatio must be an integer between 1 and 100');
|
||
}
|
||
await this.setInt(
|
||
AGENT_DEFAULT_SUB_CREDIT_RATIO,
|
||
data.defaultSubAgentCreditRatio,
|
||
'创建下级代理时默认授信占上级可用授信的比例(%)',
|
||
);
|
||
}
|
||
return this.getAgentHierarchySettings();
|
||
}
|
||
|
||
async getDecimalRate(key: string, defaultValue = 0): Promise<number> {
|
||
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
|
||
if (!row) return defaultValue;
|
||
const parsed = parseFloat(row.configValue);
|
||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : defaultValue;
|
||
}
|
||
|
||
async setDecimalRate(key: string, value: number, description?: string) {
|
||
const safe = Math.max(0, value);
|
||
await this.prisma.systemConfig.upsert({
|
||
where: { configKey: key },
|
||
create: {
|
||
configKey: key,
|
||
configValue: String(safe),
|
||
description,
|
||
},
|
||
update: { configValue: String(safe) },
|
||
});
|
||
}
|
||
|
||
async getPlatformDirectCashbackSettings(): Promise<PlatformDirectCashbackSettings> {
|
||
const platformDirectRate = await this.getDecimalRate(CASHBACK_PLATFORM_DIRECT_RATE, 0);
|
||
const adminInviteConfigured = await this.prisma.systemConfig.findUnique({
|
||
where: { configKey: CASHBACK_ADMIN_INVITE_RATE },
|
||
});
|
||
const adminInviteRate = adminInviteConfigured
|
||
? await this.getDecimalRate(CASHBACK_ADMIN_INVITE_RATE, platformDirectRate)
|
||
: platformDirectRate;
|
||
return { platformDirectRate, adminInviteRate };
|
||
}
|
||
|
||
async updatePlatformDirectCashbackSettings(data: {
|
||
platformDirectRate?: number;
|
||
adminInviteRate?: number;
|
||
}) {
|
||
if (data.platformDirectRate !== undefined) {
|
||
if (!Number.isFinite(data.platformDirectRate) || data.platformDirectRate < 0) {
|
||
throw new Error('platformDirectRate must be a non-negative number');
|
||
}
|
||
await this.setDecimalRate(
|
||
CASHBACK_PLATFORM_DIRECT_RATE,
|
||
data.platformDirectRate,
|
||
'平台直属玩家默认返水比例(小数,如 0.01 = 1%)',
|
||
);
|
||
}
|
||
if (data.adminInviteRate !== undefined) {
|
||
if (!Number.isFinite(data.adminInviteRate) || data.adminInviteRate < 0) {
|
||
throw new Error('adminInviteRate must be a non-negative number');
|
||
}
|
||
await this.setDecimalRate(
|
||
CASHBACK_ADMIN_INVITE_RATE,
|
||
data.adminInviteRate,
|
||
'管理员邀请注册玩家返水比例(小数,如 0.01 = 1%)',
|
||
);
|
||
}
|
||
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();
|
||
}
|
||
}
|