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:
2026-06-17 17:51:10 +08:00
parent e9a23de935
commit f9343b00af
105 changed files with 6960 additions and 7523 deletions

View 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();
});
});