Files
thebet365/apps/api/src/shared/config/system-config.service.ts
Mars f9343b00af 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>
2026-06-17 17:51:10 +08:00

267 lines
9.5 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 { 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;
/** 创建下级代理时默认授信占上级可用授信的比例1100 */
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();
}
}