diff --git a/apps/admin/src/App.vue b/apps/admin/src/App.vue index b97c95a..137036e 100644 --- a/apps/admin/src/App.vue +++ b/apps/admin/src/App.vue @@ -261,7 +261,6 @@ body::-webkit-scrollbar { } .list-chrome__left .matches-subnav--embedded { align-self: center; - height: var(--list-chrome-control-h); } .list-chrome__actions { display: flex; diff --git a/apps/admin/src/components/AuditLogTable.vue b/apps/admin/src/components/AuditLogTable.vue new file mode 100644 index 0000000..17b9803 --- /dev/null +++ b/apps/admin/src/components/AuditLogTable.vue @@ -0,0 +1,194 @@ + + + + + + + + + + {{ t('common.search') }} + + + + + + + + + + + + {{ formatTime(row.createdAt) }} + + + + + {{ operatorDisplay(row) }} + {{ operatorRoleLabel(row) }} + + + + + {{ auditActionLabel(row.action) }} + + + {{ auditModuleLabel(row.module) }} + + + + {{ row.ipAddress ?? '—' }} + + + + + + + + + + diff --git a/apps/admin/src/components/MatchesSubNav.vue b/apps/admin/src/components/MatchesSubNav.vue index f3b7403..6c127aa 100644 --- a/apps/admin/src/components/MatchesSubNav.vue +++ b/apps/admin/src/components/MatchesSubNav.vue @@ -16,6 +16,7 @@ const tabs = [ { path: '/matches', labelKey: 'nav.matches.fixtures' }, { path: '/matches/outrights', labelKey: 'nav.matches.outrights' }, { path: '/matches/market-templates', labelKey: 'nav.matches.market_templates' }, + { path: '/matches/audit-logs', labelKey: 'nav.matches.audit_logs' }, ]; function isActive(path: string) { @@ -25,11 +26,15 @@ function isActive(path: string) { if (path === '/matches/market-templates') { return route.path === '/matches/market-templates'; } + if (path === '/matches/audit-logs') { + return route.path === '/matches/audit-logs'; + } return ( route.path === '/matches' || (route.path.startsWith('/matches/') && !route.path.startsWith('/matches/outrights') && - !route.path.startsWith('/matches/market-templates')) + !route.path.startsWith('/matches/market-templates') && + !route.path.startsWith('/matches/audit-logs')) ); } @@ -73,9 +78,16 @@ function isActive(path: string) { border-right: 1px solid var(--border); background: transparent; flex-shrink: 0; - height: 44px; + min-height: 0; + height: auto; + align-self: center; box-sizing: border-box; } +.matches-subnav--embedded .matches-subnav__item { + height: 32px; + padding: 0 12px; + font-size: 12px; +} .matches-subnav__item { padding: 0 14px; height: 34px; diff --git a/apps/admin/src/i18n/admin-messages.ts b/apps/admin/src/i18n/admin-messages.ts index aba0a42..6a64988 100644 --- a/apps/admin/src/i18n/admin-messages.ts +++ b/apps/admin/src/i18n/admin-messages.ts @@ -247,6 +247,7 @@ const zh: Record = { 'nav.matches.fixtures': '赛事配置', 'nav.matches.outrights': '优胜赛配置(盘口)', 'nav.matches.market_templates': '盘口模板', + 'nav.matches.audit_logs': '赛事日志', 'page.matches.title': '赛事管理', 'page.matches.desc': '草稿可编辑、删除;已发布可改开赛时间与热门;未结算可下架', 'page.bets.title': '注单管理', @@ -254,7 +255,9 @@ const zh: Record = { 'page.cashback.title': '返水管理', 'page.cashback.desc': '按周期生成返水并发放', 'page.audit.title': '操作日志', - 'page.audit.desc': '记录所有管理员操作行为', + 'page.audit.desc': '账务、用户、结算等非赛事类管理操作', + 'page.matches_audit.title': '赛事日志', + 'page.matches_audit.desc': '联赛、赛事、盘口、赔率、冠军盘等赛事管理操作', 'page.settlement.title': '赛事结算', 'page.agent_dash.title': '代理概览', 'page.agent_dash.desc': '下线经营概况与分布', @@ -543,6 +546,7 @@ const en: Record = { 'nav.matches.fixtures': 'Fixtures', 'nav.matches.outrights': 'Outright odds', 'nav.matches.market_templates': 'Market templates', + 'nav.matches.audit_logs': 'Match logs', 'page.matches.title': 'Matches', 'page.matches.desc': 'Edit/delete drafts; adjust kickoff and featured when published; unpublish while unsettled', 'page.bets.title': 'Bets', @@ -550,7 +554,9 @@ const en: Record = { 'page.cashback.title': 'Cashback', 'page.cashback.desc': 'Generate and issue cashback by period', 'page.audit.title': 'Audit Log', - 'page.audit.desc': 'Administrator action history', + 'page.audit.desc': 'Non-catalog admin actions: users, wallet, settlement, etc.', + 'page.matches_audit.title': 'Match Logs', + 'page.matches_audit.desc': 'Leagues, fixtures, markets, odds, and outright operations', 'page.settlement.title': 'Settlement', 'page.agent_dash.title': 'Agent overview', 'page.agent_dash.desc': 'Downline performance at a glance', @@ -839,6 +845,7 @@ const ms: Record = { 'nav.matches.fixtures': 'Konfigurasi perlawanan', 'nav.matches.outrights': 'Odds juara', 'nav.matches.market_templates': 'Templat pasaran', + 'nav.matches.audit_logs': 'Log perlawanan', 'page.matches.title': 'Perlawanan', 'page.matches.desc': 'Edit/padam draf; laraskan masa mula bila diterbitkan; nyahterbit jika belum selesai', 'page.bets.title': 'Pertaruhan', @@ -846,7 +853,9 @@ const ms: Record = { 'page.cashback.title': 'Rebat', 'page.cashback.desc': 'Jana dan keluarkan rebat mengikut tempoh', 'page.audit.title': 'Log audit', - 'page.audit.desc': 'Sejarah tindakan pentadbir', + 'page.audit.desc': 'Tindakan bukan katalog: pengguna, dompet, penyelesaian, dll.', + 'page.matches_audit.title': 'Log perlawanan', + 'page.matches_audit.desc': 'Liga, perlawanan, pasaran, odds, dan outright', 'page.settlement.title': 'Penyelesaian', 'page.agent_dash.title': 'Gambaran ejen', 'page.agent_dash.desc': 'Prestasi downline sepintas lalu', diff --git a/apps/admin/src/i18n/admin-pages-ms.ts b/apps/admin/src/i18n/admin-pages-ms.ts index 8d44e7f..49621f3 100644 --- a/apps/admin/src/i18n/admin-pages-ms.ts +++ b/apps/admin/src/i18n/admin-pages-ms.ts @@ -332,7 +332,7 @@ export const adminPagesMs: Record = { 'bet.col.line': 'Garisan', 'bet.col.result': 'Keputusan', - 'audit.module_ph': 'cth. USERS, AGENTS', + 'audit.module_ph': 'cth. USERS, SETTLEMENT', 'audit.col.time': 'Masa', 'audit.col.operator': 'Pengendali', 'audit.col.action': 'Tindakan', @@ -360,6 +360,39 @@ export const adminPagesMs: Record = { 'audit.action.UPDATE_STAFF': 'Kemas kini kakitangan', 'audit.action.PURGE_UNUSED_FILES': 'Padam media tidak digunakan', 'audit.action.FORGOT_PASSWORD_RESET': 'Pemain set semula kata laluan', + 'audit.action.CREATE_LEAGUE': 'Cipta liga', + 'audit.action.UPDATE_LEAGUE': 'Kemas kini liga', + 'audit.action.ARCHIVE_LEAGUE': 'Arkib liga', + 'audit.action.CREATE_TEAM': 'Cipta pasukan', + 'audit.action.CREATE_MATCH': 'Cipta perlawanan', + 'audit.action.UPDATE_MATCH': 'Kemas kini perlawanan', + 'audit.action.DELETE_MATCH': 'Padam perlawanan', + 'audit.action.ARCHIVE_MATCH': 'Arkib perlawanan', + 'audit.action.IMPORT_MATCHES': 'Import perlawanan', + 'audit.action.PUBLISH_MATCH': 'Terbit perlawanan', + 'audit.action.UNPUBLISH_MATCH': 'Nyahterbit perlawanan', + 'audit.action.CLOSE_MATCH': 'Tutup perlawanan', + 'audit.action.REOPEN_MATCH': 'Buka semula perlawanan', + 'audit.action.CANCEL_MATCH': 'Batalkan perlawanan', + 'audit.action.CREATE_MARKET_TEMPLATE': 'Cipta templat pasaran', + 'audit.action.UPDATE_MARKET_TEMPLATE': 'Kemas kini templat pasaran', + 'audit.action.DUPLICATE_MARKET_TEMPLATE': 'Salin templat pasaran', + 'audit.action.SET_DEFAULT_MARKET_TEMPLATE': 'Tetapkan templat lalai', + 'audit.action.GENERATE_MATCH_MARKETS': 'Jana pasaran perlawanan', + 'audit.action.APPLY_MARKET_TEMPLATE': 'Guna templat pasaran', + 'audit.action.BULK_SAVE_MATCH_MARKETS': 'Simpan pasaran pukal', + 'audit.action.UPDATE_MATCH_ODDS': 'Kemas kini odds perlawanan', + 'audit.action.UPDATE_MARKET': 'Kemas kini pasaran', + 'audit.action.UPDATE_SELECTION': 'Kemas kini pilihan', + 'audit.action.CREATE_OUTRIGHT': 'Cipta outright', + 'audit.action.UPDATE_OUTRIGHT': 'Kemas kini outright', + 'audit.action.UPDATE_OUTRIGHT_ODDS': 'Kemas kini odds outright', + 'audit.action.ADD_OUTRIGHT_SELECTION': 'Tambah pilihan outright', + 'audit.action.BATCH_ADD_OUTRIGHT_SELECTIONS': 'Tambah pilihan outright pukal', + 'audit.action.UPDATE_OUTRIGHT_SELECTION': 'Kemas kini pilihan outright', + 'audit.action.REMOVE_OUTRIGHT_SELECTION': 'Buang pilihan outright', + 'audit.action.IMPORT_WC2026_OUTRIGHT': 'Import outright WC2026', + 'audit.module.CATALOG': 'Pengurusan perlawanan', 'audit.module.USERS': 'Pemain', 'audit.module.AGENTS': 'Ejen', 'audit.module.SYSTEM': 'Sistem', diff --git a/apps/admin/src/i18n/admin-pages.ts b/apps/admin/src/i18n/admin-pages.ts index 1549a4f..21a776b 100644 --- a/apps/admin/src/i18n/admin-pages.ts +++ b/apps/admin/src/i18n/admin-pages.ts @@ -354,7 +354,7 @@ export const adminPagesZh: Record = { 'bet.col.line': '盘口', 'bet.col.result': '赛果', - 'audit.module_ph': '如 USERS、AGENTS', + 'audit.module_ph': '如 USERS、SETTLEMENT', 'audit.col.time': '时间', 'audit.col.operator': '操作人', 'audit.col.action': '操作', @@ -382,6 +382,39 @@ export const adminPagesZh: Record = { 'audit.action.UPDATE_STAFF': '更新后台员工', 'audit.action.PURGE_UNUSED_FILES': '清理未引用媒体', 'audit.action.FORGOT_PASSWORD_RESET': '玩家找回密码', + 'audit.action.CREATE_LEAGUE': '新建联赛', + 'audit.action.UPDATE_LEAGUE': '更新联赛', + 'audit.action.ARCHIVE_LEAGUE': '归档联赛', + 'audit.action.CREATE_TEAM': '新建球队', + 'audit.action.CREATE_MATCH': '新建赛事', + 'audit.action.UPDATE_MATCH': '更新赛事', + 'audit.action.DELETE_MATCH': '删除赛事', + 'audit.action.ARCHIVE_MATCH': '归档赛事', + 'audit.action.IMPORT_MATCHES': '批量导入赛事', + 'audit.action.PUBLISH_MATCH': '发布赛事', + 'audit.action.UNPUBLISH_MATCH': '下架赛事', + 'audit.action.CLOSE_MATCH': '封盘赛事', + 'audit.action.REOPEN_MATCH': '重新开放赛事', + 'audit.action.CANCEL_MATCH': '取消赛事', + 'audit.action.CREATE_MARKET_TEMPLATE': '新建盘口模板', + 'audit.action.UPDATE_MARKET_TEMPLATE': '更新盘口模板', + 'audit.action.DUPLICATE_MARKET_TEMPLATE': '复制盘口模板', + 'audit.action.SET_DEFAULT_MARKET_TEMPLATE': '设为默认盘口模板', + 'audit.action.GENERATE_MATCH_MARKETS': '生成赛事盘口', + 'audit.action.APPLY_MARKET_TEMPLATE': '应用盘口模板', + 'audit.action.BULK_SAVE_MATCH_MARKETS': '批量保存赛事盘口', + 'audit.action.UPDATE_MATCH_ODDS': '更新赛事赔率', + 'audit.action.UPDATE_MARKET': '更新盘口', + 'audit.action.UPDATE_SELECTION': '更新选项', + 'audit.action.CREATE_OUTRIGHT': '新建冠军盘', + 'audit.action.UPDATE_OUTRIGHT': '更新冠军盘', + 'audit.action.UPDATE_OUTRIGHT_ODDS': '更新冠军盘赔率', + 'audit.action.ADD_OUTRIGHT_SELECTION': '添加冠军盘选项', + 'audit.action.BATCH_ADD_OUTRIGHT_SELECTIONS': '批量添加冠军盘选项', + 'audit.action.UPDATE_OUTRIGHT_SELECTION': '更新冠军盘选项', + 'audit.action.REMOVE_OUTRIGHT_SELECTION': '移除冠军盘选项', + 'audit.action.IMPORT_WC2026_OUTRIGHT': '导入世界杯冠军盘', + 'audit.module.CATALOG': '赛事管理', 'audit.module.USERS': '玩家', 'audit.module.AGENTS': '代理', 'audit.module.SYSTEM': '系统', @@ -1393,7 +1426,7 @@ export const adminPagesEn: Record = { 'bet.col.line': 'Line', 'bet.col.result': 'Result', - 'audit.module_ph': 'e.g. USERS, AGENTS', + 'audit.module_ph': 'e.g. USERS, SETTLEMENT', 'audit.col.time': 'Time', 'audit.col.operator': 'Operator', 'audit.col.action': 'Action', @@ -1421,6 +1454,39 @@ export const adminPagesEn: Record = { 'audit.action.UPDATE_STAFF': 'Update staff account', 'audit.action.PURGE_UNUSED_FILES': 'Purge unused media', 'audit.action.FORGOT_PASSWORD_RESET': 'Player forgot-password reset', + 'audit.action.CREATE_LEAGUE': 'Create league', + 'audit.action.UPDATE_LEAGUE': 'Update league', + 'audit.action.ARCHIVE_LEAGUE': 'Archive league', + 'audit.action.CREATE_TEAM': 'Create team', + 'audit.action.CREATE_MATCH': 'Create match', + 'audit.action.UPDATE_MATCH': 'Update match', + 'audit.action.DELETE_MATCH': 'Delete match', + 'audit.action.ARCHIVE_MATCH': 'Archive match', + 'audit.action.IMPORT_MATCHES': 'Import matches', + 'audit.action.PUBLISH_MATCH': 'Publish match', + 'audit.action.UNPUBLISH_MATCH': 'Unpublish match', + 'audit.action.CLOSE_MATCH': 'Close match', + 'audit.action.REOPEN_MATCH': 'Reopen match', + 'audit.action.CANCEL_MATCH': 'Cancel match', + 'audit.action.CREATE_MARKET_TEMPLATE': 'Create market template', + 'audit.action.UPDATE_MARKET_TEMPLATE': 'Update market template', + 'audit.action.DUPLICATE_MARKET_TEMPLATE': 'Duplicate market template', + 'audit.action.SET_DEFAULT_MARKET_TEMPLATE': 'Set default market template', + 'audit.action.GENERATE_MATCH_MARKETS': 'Generate match markets', + 'audit.action.APPLY_MARKET_TEMPLATE': 'Apply market template', + 'audit.action.BULK_SAVE_MATCH_MARKETS': 'Bulk save match markets', + 'audit.action.UPDATE_MATCH_ODDS': 'Update match odds', + 'audit.action.UPDATE_MARKET': 'Update market', + 'audit.action.UPDATE_SELECTION': 'Update selection', + 'audit.action.CREATE_OUTRIGHT': 'Create outright', + 'audit.action.UPDATE_OUTRIGHT': 'Update outright', + 'audit.action.UPDATE_OUTRIGHT_ODDS': 'Update outright odds', + 'audit.action.ADD_OUTRIGHT_SELECTION': 'Add outright selection', + 'audit.action.BATCH_ADD_OUTRIGHT_SELECTIONS': 'Batch add outright selections', + 'audit.action.UPDATE_OUTRIGHT_SELECTION': 'Update outright selection', + 'audit.action.REMOVE_OUTRIGHT_SELECTION': 'Remove outright selection', + 'audit.action.IMPORT_WC2026_OUTRIGHT': 'Import WC2026 outright', + 'audit.module.CATALOG': 'Catalog', 'audit.module.USERS': 'Players', 'audit.module.AGENTS': 'Agents', 'audit.module.SYSTEM': 'System', diff --git a/apps/admin/src/layouts/ManageLayout.vue b/apps/admin/src/layouts/ManageLayout.vue index c1189a1..387e766 100644 --- a/apps/admin/src/layouts/ManageLayout.vue +++ b/apps/admin/src/layouts/ManageLayout.vue @@ -48,7 +48,7 @@ const adminMenus = computed(() => { { path: '/bets', label: t('nav.bets'), icon: 'bets', permissions: [AdminPerm.bets] }, { path: '/contents', label: t('nav.contents'), icon: 'contents', permissions: [AdminPerm.content] }, { path: '/media', label: t('nav.media'), icon: 'media', permissions: [AdminPerm.content, AdminPerm.matches] }, - { path: '/audit', label: t('nav.audit'), icon: 'audit', permissions: [AdminPerm.audit] }, + { path: '/audit', label: t('nav.audit'), icon: 'audit', permissions: [AdminPerm.audit], excludeRoles: ['MATCH_ADMIN'] }, { path: '/staff', label: t('nav.staff'), icon: 'users', permissions: [AdminPerm.settings] }, { path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests', permissions: [AdminPerm.settings] }, ]; diff --git a/apps/admin/src/router/index.ts b/apps/admin/src/router/index.ts index cd97cab..856d42d 100644 --- a/apps/admin/src/router/index.ts +++ b/apps/admin/src/router/index.ts @@ -86,6 +86,11 @@ const router = createRouter({ component: () => import('../views/MarketTemplates.vue'), meta: { adminOnly: true, permissions: [AdminPerm.matches] }, }, + { + path: 'matches/audit-logs', + component: () => import('../views/MatchesAudit.vue'), + meta: { adminOnly: true, permissions: [AdminPerm.matches] }, + }, { path: 'matches/:matchId/edit', name: 'admin-match-edit', diff --git a/apps/admin/src/utils/admin-breadcrumb.ts b/apps/admin/src/utils/admin-breadcrumb.ts index de11abb..0cbf8f2 100644 --- a/apps/admin/src/utils/admin-breadcrumb.ts +++ b/apps/admin/src/utils/admin-breadcrumb.ts @@ -14,12 +14,6 @@ export function resolveAdminBreadcrumb( { label: t('breadcrumb.settlement') }, ]; } - if (path === '/matches/market-templates') { - return [ - { label: t('nav.matches'), to: '/matches' }, - { label: t('nav.matches.market_templates') }, - ]; - } if (/^\/matches\/[^/]+\/edit/.test(path)) { return [ { label: t('nav.matches'), to: '/matches' }, @@ -32,12 +26,6 @@ export function resolveAdminBreadcrumb( { label: t('breadcrumb.match_markets') }, ]; } - if (path === '/matches/outrights') { - return [ - { label: t('nav.matches'), to: '/matches' }, - { label: t('nav.matches.outrights') }, - ]; - } if (path === '/dashboard/players') { return [ { label: t('nav.dashboard'), to: '/' }, diff --git a/apps/admin/src/views/Audit.vue b/apps/admin/src/views/Audit.vue index 49788ea..26a3df7 100644 --- a/apps/admin/src/views/Audit.vue +++ b/apps/admin/src/views/Audit.vue @@ -1,169 +1,9 @@ - - - - - - - {{ t('common.search') }} - - - - - - - - - - - - {{ formatTime(row.createdAt) }} - - - - - {{ operatorDisplay(row) }} - {{ operatorRoleLabel(row) }} - - - - - {{ auditActionLabel(row.action) }} - - - {{ auditModuleLabel(row.module) }} - - - - {{ row.ipAddress ?? '—' }} - - - - - - - + - - diff --git a/apps/admin/src/views/MarketTemplates.vue b/apps/admin/src/views/MarketTemplates.vue index 0dc3c76..2edf75d 100644 --- a/apps/admin/src/views/MarketTemplates.vue +++ b/apps/admin/src/views/MarketTemplates.vue @@ -477,8 +477,14 @@ load(); - - + + + + + + + + +import MatchesSubNav from '../components/MatchesSubNav.vue'; +import AuditLogTable from '../components/AuditLogTable.vue'; + + + + + + + + + + + + + + diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index bd2350d..3d5ea47 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -35,6 +35,12 @@ import { SettlementService } from '../../domains/settlement/settlement.service'; import { CashbackService } from '../../domains/operations/cashback/cashback.service'; import { I18nService } from '../../domains/operations/i18n/i18n.service'; import { AuditService } from '../../domains/operations/audit/audit.service'; +import { + CatalogAuditAction, + CATALOG_AUDIT_MODULE, + logCatalogAudit, + summarizeOddsUpdates, +} from '../../domains/operations/audit/catalog-audit'; import { BetsService } from '../../domains/betting/bets.service'; import { BettingLimitsService } from '../../domains/betting/betting-limits.service'; import { PrismaService } from '../../shared/prisma/prisma.service'; @@ -1825,11 +1831,13 @@ export class AdminController { @Post('leagues') @RequirePermissions(P.matches) async createLeague( + @CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformLeagueDto | { code: string; translations: Record }, ) { + let league; if ('leagueZh' in dto || 'leagueEn' in dto) { const body = dto as CreatePlatformLeagueDto; - const league = await this.matches.createPlatformLeague({ + league = await this.matches.createPlatformLeague({ leagueEn: body.leagueEn, leagueZh: body.leagueZh, leagueMs: body.leagueMs, @@ -1837,16 +1845,26 @@ export class AdminController { displayOrder: body.displayOrder, isActive: body.isActive, }); - return jsonResponse(league); + } else { + const legacy = dto as { code: string; translations: Record }; + league = await this.matches.createLeague(legacy.code, legacy.translations); } - const legacy = dto as { code: string; translations: Record }; - const league = await this.matches.createLeague(legacy.code, legacy.translations); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.CREATE_LEAGUE, + targetId: league.id, + afterData: + 'leagueZh' in dto || 'leagueEn' in dto + ? { leagueEn: (dto as CreatePlatformLeagueDto).leagueEn, leagueZh: (dto as CreatePlatformLeagueDto).leagueZh } + : { code: (dto as { code: string }).code }, + }); return jsonResponse(league); } @Put('leagues/:leagueId') @RequirePermissions(P.matches) async updateLeague( + @CurrentUser('id') operatorId: bigint, @Param('leagueId') leagueId: string, @Body() dto: CreatePlatformLeagueDto, ) { @@ -1858,6 +1876,12 @@ export class AdminController { displayOrder: dto.displayOrder, isActive: dto.isActive, }); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.UPDATE_LEAGUE, + targetId: leagueId, + afterData: { leagueEn: dto.leagueEn, leagueZh: dto.leagueZh, isActive: dto.isActive }, + }); return jsonResponse(league); } @@ -1870,8 +1894,17 @@ export class AdminController { @Post('leagues/:leagueId/archive') @RequirePermissions(P.matches) - async archiveLeague(@Param('leagueId') leagueId: string) { + async archiveLeague( + @CurrentUser('id') operatorId: bigint, + @Param('leagueId') leagueId: string, + ) { const result = await this.catalogArchive.archiveLeague(BigInt(leagueId)); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.ARCHIVE_LEAGUE, + targetId: leagueId, + afterData: result, + }); return jsonResponse(result); } @@ -1925,8 +1958,17 @@ export class AdminController { @Post('teams') @RequirePermissions(P.matches) - async createTeam(@Body() dto: { code: string; translations: Record }) { + async createTeam( + @CurrentUser('id') operatorId: bigint, + @Body() dto: { code: string; translations: Record }, + ) { const team = await this.matches.createTeam(dto.code, dto.translations); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.CREATE_TEAM, + targetId: team.id, + afterData: { code: dto.code }, + }); return jsonResponse(team); } @@ -1999,13 +2041,29 @@ export class AdminController { updatedBy: operatorId, }); await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.UPDATE_MATCH, + targetId: id, + afterData: { + status: match.status, + startTime: match.startTime, + isHot: match.isHot, + matchName: match.matchName, + }, + }); return jsonResponse(match); } @Delete('matches/:id') @RequirePermissions(P.matches) - async deleteMatch(@Param('id') id: string) { + async deleteMatch(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) { await this.matches.deleteMatch(BigInt(id)); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.DELETE_MATCH, + targetId: id, + }); return jsonResponse({ deleted: true }); } @@ -2018,7 +2076,11 @@ export class AdminController { @Post('matches/:id/archive') @RequirePermissions(P.matches) - async archiveMatch(@Param('id') id: string, @Body() dto: ArchiveMatchDto) { + async archiveMatch( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + @Body() dto: ArchiveMatchDto, + ) { const matchId = BigInt(id); const result = await this.catalogArchive.archiveMatch(matchId, { force: dto.force === true, @@ -2028,6 +2090,12 @@ export class AdminController { const voided = await this.settlement.voidMatchBets(matchId); voidedCount = voided.voidedCount; } + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.ARCHIVE_MATCH, + targetId: id, + afterData: { ...result, force: dto.force === true, voidedCount }, + }); return jsonResponse({ ...result, voidedCount }); } @@ -2059,6 +2127,17 @@ export class AdminController { createdBy: operatorId, }); await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.CREATE_MATCH, + targetId: match.id, + afterData: { + status: match.status, + startTime: match.startTime, + leagueId: match.leagueId?.toString(), + matchName: match.matchName, + }, + }); return jsonResponse(match); } @@ -2069,42 +2148,86 @@ export class AdminController { throw appBadRequest('IMPORT_MATCHES_REQUIRED'); } const result = await this.matches.importZhiboMatchesBundle(dto, operatorId); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.IMPORT_MATCHES, + afterData: { + total: result.total, + imported: result.imported, + skipped: result.skipped, + failed: result.failed, + }, + }); return jsonResponse(result); } @Post('matches/:id/publish') @RequirePermissions(P.matches) - async publishMatch(@Param('id') id: string) { + async publishMatch(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) { const match = await this.matches.publishMatch(BigInt(id)); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.PUBLISH_MATCH, + targetId: id, + afterData: { status: match.status, publishTime: match.publishTime }, + }); return jsonResponse(match); } @Post('matches/:id/unpublish') @RequirePermissions(P.matches) - async unpublishMatch(@Param('id') id: string) { + async unpublishMatch(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) { const match = await this.matches.unpublishMatch(BigInt(id)); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.UNPUBLISH_MATCH, + targetId: id, + afterData: { status: match.status }, + }); return jsonResponse(match); } @Post('matches/:id/close') @RequirePermissions(P.matches) - async closeMatch(@Param('id') id: string) { + async closeMatch(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) { const match = await this.matches.closeMatch(BigInt(id)); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.CLOSE_MATCH, + targetId: id, + afterData: { status: match.status, closeTime: match.closeTime }, + }); return jsonResponse(match); } @Post('matches/:id/reopen') @RequirePermissions(P.matches) - async reopenMatch(@Param('id') id: string, @Body() dto: ReopenMatchDto) { + async reopenMatch( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + @Body() dto: ReopenMatchDto, + ) { const startTime = dto.startTime ? parseMatchStartTime(dto.startTime) : undefined; const match = await this.matches.reopenMatch(BigInt(id), startTime); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.REOPEN_MATCH, + targetId: id, + afterData: { status: match.status, startTime: match.startTime }, + }); return jsonResponse(match); } @Post('matches/:id/cancel') @RequirePermissions(P.matches) - async cancelMatch(@Param('id') id: string) { + async cancelMatch(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) { const voided = await this.settlement.cancelMatchAndVoidBets(BigInt(id)); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.CANCEL_MATCH, + targetId: id, + afterData: voided, + }); return jsonResponse(voided); } @@ -2123,8 +2246,17 @@ export class AdminController { @Post('market-templates') @RequirePermissions(P.matches) - async createMarketTemplate(@Body() dto: MarketTemplateSaveDto) { + async createMarketTemplate( + @CurrentUser('id') operatorId: bigint, + @Body() dto: MarketTemplateSaveDto, + ) { const template = await this.markets.createTemplate(dto); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.CREATE_MARKET_TEMPLATE, + targetId: template.id, + afterData: { name: template.name, sportType: template.sportType }, + }); return jsonResponse(template); } @@ -2137,39 +2269,87 @@ export class AdminController { @Put('market-templates/:id') @RequirePermissions(P.matches) - async updateMarketTemplate(@Param('id') id: string, @Body() dto: MarketTemplateSaveDto) { + async updateMarketTemplate( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + @Body() dto: MarketTemplateSaveDto, + ) { const template = await this.markets.updateTemplate(BigInt(id), dto); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.UPDATE_MARKET_TEMPLATE, + targetId: id, + afterData: { name: template.name, sportType: template.sportType }, + }); return jsonResponse(template); } @Post('market-templates/:id/duplicate') @RequirePermissions(P.matches) - async duplicateMarketTemplate(@Param('id') id: string) { + async duplicateMarketTemplate( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + ) { const template = await this.markets.duplicateTemplate(BigInt(id)); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.DUPLICATE_MARKET_TEMPLATE, + targetId: template.id, + afterData: { sourceTemplateId: id, name: template.name }, + }); return jsonResponse(template); } @Post('market-templates/:id/set-default') @RequirePermissions(P.matches) - async setDefaultMarketTemplate(@Param('id') id: string) { + async setDefaultMarketTemplate( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + ) { const template = await this.markets.setDefaultTemplate(BigInt(id)); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.SET_DEFAULT_MARKET_TEMPLATE, + targetId: id, + afterData: { name: template.name }, + }); return jsonResponse(template); } @Post('matches/:id/markets/templates') @RequirePermissions(P.matches) - async generateTemplates(@Param('id') id: string, @Body() dto: MarketTemplatesDto) { + async generateTemplates( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + @Body() dto: MarketTemplatesDto, + ) { const markets = await this.markets.generateTemplates(BigInt(id), dto.marketTypes); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.GENERATE_MATCH_MARKETS, + targetId: id, + afterData: { marketTypes: dto.marketTypes, ...markets }, + }); return jsonResponse(markets); } @Post('matches/:id/markets/apply-template') @RequirePermissions(P.matches) - async applyMarketTemplate(@Param('id') id: string, @Body() dto: ApplyMarketTemplateDto) { + async applyMarketTemplate( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + @Body() dto: ApplyMarketTemplateDto, + ) { const result = await this.markets.applyTemplateToMatch( BigInt(id), dto.templateId ? BigInt(dto.templateId) : null, ); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.APPLY_MARKET_TEMPLATE, + targetId: id, + afterData: { requestedTemplateId: dto.templateId, ...result }, + }); return jsonResponse(result); } @@ -2181,6 +2361,12 @@ export class AdminController { @Body() dto: BulkMatchMarketsDto, ) { const result = await this.markets.saveMatchMarkets(BigInt(id), dto.markets, operatorId); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.BULK_SAVE_MATCH_MARKETS, + targetId: id, + afterData: { ...result, marketCount: dto.markets.length }, + }); return jsonResponse(result); } @@ -2196,12 +2382,22 @@ export class AdminController { odds: u.odds, })); const results = await this.markets.batchUpdateOdds(updates, operatorId); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.UPDATE_MATCH_ODDS, + targetId: id, + afterData: summarizeOddsUpdates(updates.map((u) => u.selectionId)), + }); return jsonResponse({ matchId: id, updated: results.length }); } @Patch('markets/:id') @RequirePermissions(P.matches) - async updateMarket(@Param('id') id: string, @Body() dto: UpdateMarketDto) { + async updateMarket( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + @Body() dto: UpdateMarketDto, + ) { const market = await this.markets.updateMarket(BigInt(id), { promoLabel: dto.promoLabel, promoLabelI18n: dto.promoLabelI18n, @@ -2210,6 +2406,17 @@ export class AdminController { lineValue: dto.lineValue, showOnPlayer: dto.showOnPlayer, }); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.UPDATE_MARKET, + targetId: id, + afterData: { + matchId: market.matchId.toString(), + status: market.status, + lineValue: market.lineValue, + showOnPlayer: market.showOnPlayer, + }, + }); return jsonResponse(market); } @@ -2230,6 +2437,20 @@ export class AdminController { }, operatorId, ); + const market = await this.prisma.market.findUnique({ + where: { id: selection.marketId }, + select: { matchId: true }, + }); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.UPDATE_SELECTION, + targetId: id, + afterData: { + matchId: market?.matchId?.toString(), + odds: dto.odds, + status: dto.status, + }, + }); return jsonResponse(selection); } @@ -2241,6 +2462,16 @@ export class AdminController { @Body() dto: UpdateOddsDto, ) { const selection = await this.markets.updateOdds(BigInt(id), dto.odds, operatorId); + const market = await this.prisma.market.findUnique({ + where: { id: selection.marketId }, + select: { matchId: true }, + }); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.UPDATE_MATCH_ODDS, + targetId: market?.matchId ?? id, + afterData: { selectionId: id, odds: dto.odds }, + }); return jsonResponse(selection); } @@ -2260,7 +2491,10 @@ export class AdminController { @Post('outrights') @RequirePermissions(P.matches) - async createOutright(@Body() dto: CreateOutrightDto) { + async createOutright( + @CurrentUser('id') operatorId: bigint, + @Body() dto: CreateOutrightDto, + ) { const data = await this.outright.createForAdmin({ leagueId: BigInt(dto.leagueId), titleZh: dto.titleZh, @@ -2268,13 +2502,24 @@ export class AdminController { titleMs: dto.titleMs, status: dto.status, }); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.CREATE_OUTRIGHT, + targetId: data.id, + afterData: { leagueId: dto.leagueId, status: dto.status, titleZh: dto.titleZh }, + }); return jsonResponse(data); } @Post('outrights/import/wc2026') @RequirePermissions(P.matches) - async importWc2026Outright() { + async importWc2026Outright(@CurrentUser('id') operatorId: bigint) { const data = await this.outright.importWc2026Canonical(); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.IMPORT_WC2026_OUTRIGHT, + afterData: data, + }); return jsonResponse(data); } @@ -2298,16 +2543,27 @@ export class AdminController { const list = await this.outright.listForAdmin(); const wc = list.find((e) => e.leagueCode === 'WC2026'); if (!wc) throw appBadRequest('WC_OUTRIGHT_NOT_FOUND'); - return jsonResponse( - await this.outright.batchUpdateOdds(BigInt(wc.id), dto.updates, operatorId), - ); + const data = await this.outright.batchUpdateOdds(BigInt(wc.id), dto.updates, operatorId); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.UPDATE_OUTRIGHT_ODDS, + targetId: wc.id, + afterData: summarizeOddsUpdates(dto.updates.map((u) => u.selectionId)), + }); + return jsonResponse(data); } /** @deprecated */ @Post('outrights/wc2026/apply-canonical') @RequirePermissions(P.matches) - async applyWc2026CanonicalLegacy() { - return jsonResponse(await this.outright.importWc2026Canonical()); + async applyWc2026CanonicalLegacy(@CurrentUser('id') operatorId: bigint) { + const data = await this.outright.importWc2026Canonical(); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.IMPORT_WC2026_OUTRIGHT, + afterData: data, + }); + return jsonResponse(data); } @Get('outrights/:matchId') @@ -2320,10 +2576,17 @@ export class AdminController { @Put('outrights/:matchId') @RequirePermissions(P.matches) async updateOutright( + @CurrentUser('id') operatorId: bigint, @Param('matchId') matchId: string, @Body() dto: UpdateOutrightDto, ) { const data = await this.outright.updateForAdmin(BigInt(matchId), dto); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.UPDATE_OUTRIGHT, + targetId: matchId, + afterData: { status: dto.status, matchName: dto.matchName, isHot: dto.isHot }, + }); return jsonResponse(data); } @@ -2339,22 +2602,36 @@ export class AdminController { dto.updates, operatorId, ); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.UPDATE_OUTRIGHT_ODDS, + targetId: matchId, + afterData: summarizeOddsUpdates(dto.updates.map((u) => u.selectionId)), + }); return jsonResponse(data); } @Post('outrights/:matchId/selections') @RequirePermissions(P.matches) async addOutrightSelection( + @CurrentUser('id') operatorId: bigint, @Param('matchId') matchId: string, @Body() dto: AddOutrightSelectionDto, ) { const data = await this.outright.addSelection(BigInt(matchId), dto); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.ADD_OUTRIGHT_SELECTION, + targetId: matchId, + afterData: dto, + }); return jsonResponse(data); } @Post('outrights/:matchId/selections/batch') @RequirePermissions(P.matches) async addOutrightSelectionsBatch( + @CurrentUser('id') operatorId: bigint, @Param('matchId') matchId: string, @Body() dto: AddOutrightSelectionsBatchDto, ) { @@ -2365,12 +2642,19 @@ export class AdminController { BigInt(matchId), dto.items, ); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.BATCH_ADD_OUTRIGHT_SELECTIONS, + targetId: matchId, + afterData: { count: dto.items.length }, + }); return jsonResponse(data); } @Patch('outrights/:matchId/selections/:selectionId') @RequirePermissions(P.matches) async updateOutrightSelectionTeam( + @CurrentUser('id') operatorId: bigint, @Param('matchId') matchId: string, @Param('selectionId') selectionId: string, @Body() dto: UpdateOutrightSelectionTeamDto, @@ -2380,12 +2664,19 @@ export class AdminController { BigInt(selectionId), dto, ); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.UPDATE_OUTRIGHT_SELECTION, + targetId: selectionId, + afterData: { matchId, ...dto }, + }); return jsonResponse(data); } @Delete('outrights/:matchId/selections/:selectionId') @RequirePermissions(P.matches) async removeOutrightSelection( + @CurrentUser('id') operatorId: bigint, @Param('matchId') matchId: string, @Param('selectionId') selectionId: string, ) { @@ -2393,6 +2684,12 @@ export class AdminController { BigInt(matchId), BigInt(selectionId), ); + await logCatalogAudit(this.audit, { + operatorId, + action: CatalogAuditAction.REMOVE_OUTRIGHT_SELECTION, + targetId: selectionId, + afterData: { matchId }, + }); return jsonResponse(data); } @@ -2927,7 +3224,28 @@ export class AdminController { const result = await this.audit.list( page ? parseInt(page, 10) : 1, pageSize ? parseInt(pageSize, 10) : 10, - module || undefined, + { + module: module || undefined, + excludeModule: CATALOG_AUDIT_MODULE, + }, + { viewerId, viewerRole, viewerUserType }, + ); + return jsonResponse(result); + } + + @Get('catalog-audit-logs') + @RequirePermissions(P.matches) + async catalogAuditLogs( + @CurrentUser('id') viewerId: bigint, + @CurrentUser('role') viewerRole: string | undefined, + @CurrentUser('userType') viewerUserType: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + const result = await this.audit.list( + page ? parseInt(page, 10) : 1, + pageSize ? parseInt(pageSize, 10) : 10, + { module: CATALOG_AUDIT_MODULE }, { viewerId, viewerRole, viewerUserType }, ); return jsonResponse(result); diff --git a/apps/api/src/domains/operations/audit/audit.service.ts b/apps/api/src/domains/operations/audit/audit.service.ts index 23bb586..d26ec92 100644 --- a/apps/api/src/domains/operations/audit/audit.service.ts +++ b/apps/api/src/domains/operations/audit/audit.service.ts @@ -36,13 +36,14 @@ export class AuditService { async list( page = 1, pageSize = 10, - module?: string, + filters?: { module?: string; excludeModule?: string }, viewer?: AuditViewerScope, ) { const skip = (page - 1) * pageSize; const scopeWhere = viewer ? await buildAuditListScopeWhere(this.prisma, viewer) : undefined; const where: Prisma.AuditLogWhereInput = { - ...(module ? { module } : {}), + ...(filters?.module ? { module: filters.module } : {}), + ...(filters?.excludeModule ? { module: { not: filters.excludeModule } } : {}), ...(scopeWhere ?? {}), }; const [rows, total] = await Promise.all([ diff --git a/apps/api/src/domains/operations/audit/catalog-audit.spec.ts b/apps/api/src/domains/operations/audit/catalog-audit.spec.ts new file mode 100644 index 0000000..77c08e3 --- /dev/null +++ b/apps/api/src/domains/operations/audit/catalog-audit.spec.ts @@ -0,0 +1,53 @@ +import { + CatalogAuditAction, + CATALOG_AUDIT_MODULE, + logCatalogAudit, + summarizeOddsUpdates, +} from './catalog-audit'; +import { AuditService } from './audit.service'; + +describe('catalog-audit', () => { + it('uses CATALOG module and stable action codes', () => { + expect(CATALOG_AUDIT_MODULE).toBe('CATALOG'); + expect(CatalogAuditAction.PUBLISH_MATCH).toBe('PUBLISH_MATCH'); + expect(CatalogAuditAction.UPDATE_MATCH_ODDS).toBe('UPDATE_MATCH_ODDS'); + expect(Object.keys(CatalogAuditAction).length).toBeGreaterThanOrEqual(30); + }); + + it('summarizeOddsUpdates truncates large batches', () => { + const ids = Array.from({ length: 25 }, (_, i) => BigInt(i + 1)); + const summary = summarizeOddsUpdates(ids, 20); + expect(summary.updatedCount).toBe(25); + expect(summary.selectionIds).toHaveLength(20); + expect(summary.selectionIdsTruncated).toBe(true); + expect(summary.total).toBe(25); + }); + + it('summarizeOddsUpdates keeps full list when small', () => { + const summary = summarizeOddsUpdates(['1', '2']); + expect(summary.selectionIds).toEqual(['1', '2']); + expect(summary.selectionIdsTruncated).toBeUndefined(); + }); + + it('logCatalogAudit delegates to AuditService.log', async () => { + const audit = { + log: jest.fn().mockResolvedValue({ id: 1n }), + } as unknown as AuditService; + await logCatalogAudit(audit, { + operatorId: 99n, + action: CatalogAuditAction.PUBLISH_MATCH, + targetId: '42', + afterData: { status: 'PUBLISHED' }, + }); + expect(audit.log).toHaveBeenCalledWith({ + operatorId: 99n, + operatorType: 'ADMIN', + action: 'PUBLISH_MATCH', + module: CATALOG_AUDIT_MODULE, + targetId: '42', + beforeData: undefined, + afterData: { status: 'PUBLISHED' }, + ipAddress: undefined, + }); + }); +}); diff --git a/apps/api/src/domains/operations/audit/catalog-audit.ts b/apps/api/src/domains/operations/audit/catalog-audit.ts new file mode 100644 index 0000000..03bade2 --- /dev/null +++ b/apps/api/src/domains/operations/audit/catalog-audit.ts @@ -0,0 +1,87 @@ +import { AuditService } from './audit.service'; + +export const CATALOG_AUDIT_MODULE = 'CATALOG'; + +export const CatalogAuditAction = { + CREATE_LEAGUE: 'CREATE_LEAGUE', + UPDATE_LEAGUE: 'UPDATE_LEAGUE', + ARCHIVE_LEAGUE: 'ARCHIVE_LEAGUE', + CREATE_TEAM: 'CREATE_TEAM', + CREATE_MATCH: 'CREATE_MATCH', + UPDATE_MATCH: 'UPDATE_MATCH', + DELETE_MATCH: 'DELETE_MATCH', + ARCHIVE_MATCH: 'ARCHIVE_MATCH', + IMPORT_MATCHES: 'IMPORT_MATCHES', + PUBLISH_MATCH: 'PUBLISH_MATCH', + UNPUBLISH_MATCH: 'UNPUBLISH_MATCH', + CLOSE_MATCH: 'CLOSE_MATCH', + REOPEN_MATCH: 'REOPEN_MATCH', + CANCEL_MATCH: 'CANCEL_MATCH', + CREATE_MARKET_TEMPLATE: 'CREATE_MARKET_TEMPLATE', + UPDATE_MARKET_TEMPLATE: 'UPDATE_MARKET_TEMPLATE', + DUPLICATE_MARKET_TEMPLATE: 'DUPLICATE_MARKET_TEMPLATE', + SET_DEFAULT_MARKET_TEMPLATE: 'SET_DEFAULT_MARKET_TEMPLATE', + GENERATE_MATCH_MARKETS: 'GENERATE_MATCH_MARKETS', + APPLY_MARKET_TEMPLATE: 'APPLY_MARKET_TEMPLATE', + BULK_SAVE_MATCH_MARKETS: 'BULK_SAVE_MATCH_MARKETS', + UPDATE_MATCH_ODDS: 'UPDATE_MATCH_ODDS', + UPDATE_MARKET: 'UPDATE_MARKET', + UPDATE_SELECTION: 'UPDATE_SELECTION', + CREATE_OUTRIGHT: 'CREATE_OUTRIGHT', + UPDATE_OUTRIGHT: 'UPDATE_OUTRIGHT', + UPDATE_OUTRIGHT_ODDS: 'UPDATE_OUTRIGHT_ODDS', + ADD_OUTRIGHT_SELECTION: 'ADD_OUTRIGHT_SELECTION', + BATCH_ADD_OUTRIGHT_SELECTIONS: 'BATCH_ADD_OUTRIGHT_SELECTIONS', + UPDATE_OUTRIGHT_SELECTION: 'UPDATE_OUTRIGHT_SELECTION', + REMOVE_OUTRIGHT_SELECTION: 'REMOVE_OUTRIGHT_SELECTION', + IMPORT_WC2026_OUTRIGHT: 'IMPORT_WC2026_OUTRIGHT', +} as const; + +export type CatalogAuditActionCode = + (typeof CatalogAuditAction)[keyof typeof CatalogAuditAction]; + +export function catalogTargetId(id: string | bigint | null | undefined): string | undefined { + if (id == null) return undefined; + return String(id); +} + +export function summarizeOddsUpdates( + selectionIds: Array, + maxIds = 20, +): { + updatedCount: number; + selectionIds: string[]; + selectionIdsTruncated?: boolean; + total?: number; +} { + const ids = selectionIds.map((id) => String(id)); + const truncated = ids.length > maxIds; + return { + updatedCount: ids.length, + selectionIds: ids.slice(0, maxIds), + ...(truncated ? { selectionIdsTruncated: true, total: ids.length } : {}), + }; +} + +export async function logCatalogAudit( + audit: AuditService, + data: { + operatorId: bigint; + action: CatalogAuditActionCode; + targetId?: string | bigint | null; + beforeData?: unknown; + afterData?: unknown; + ipAddress?: string; + }, +) { + return audit.log({ + operatorId: data.operatorId, + operatorType: 'ADMIN', + action: data.action, + module: CATALOG_AUDIT_MODULE, + targetId: catalogTargetId(data.targetId), + beforeData: data.beforeData, + afterData: data.afterData, + ipAddress: data.ipAddress, + }); +} diff --git a/docs/UAT_CHECKLIST.md b/docs/UAT_CHECKLIST.md index 25da502..dde9e8c 100644 --- a/docs/UAT_CHECKLIST.md +++ b/docs/UAT_CHECKLIST.md @@ -52,6 +52,8 @@ - [ ] **SEC010** 使用 `support1` 登录:菜单仅显示查询相关项;玩家页可查看/重置密码,无上分/下分/创建按钮;调用 `POST /admin/wallet/deposit` 返回 403 - [ ] **SEC011** 使用 `financeadmin` 登录:无赛事/结算/内容菜单;可对玩家上分、调整代理额度;调用 `POST /admin/matches` 等赛事写接口返回 403 - [ ] **SEC012** 使用 `matchadmin` 登录:有赛事/结算/内容;无上分/额度/返水确认;调用 `POST /admin/wallet/deposit` 返回 403 +- [ ] **SEC013** 使用 `matchadmin` 创建/发布赛事后,在 **赛事管理 → 赛事日志**(`/matches/audit-logs`)可见记录(如 `CREATE_MATCH`、`PUBLISH_MATCH`),操作人为 `matchadmin`;侧栏「操作日志」不显示赛事类记录 +- [ ] **SEC014** 使用 `financeadmin` 打开「操作日志」:**不应**看到 `matchadmin` 的记录(同角色隔离);`financeadmin` 无赛事管理菜单;使用 `admin` 登录可在赛事日志与操作日志分别查看全部 - [ ] 自动化:`pnpm --filter @thebet365/api exec jest admin-rbac.spec.ts --runInBand` 通过 ## 备份与回滚