feat: 赛事管理操作日志与审计页体验优化

- API 为赛事/联赛/盘口/赔率/冠军盘写操作写入 CATALOG 审计日志,并新增 catalog-audit-logs 接口

- 管理端将赛事日志独立至赛事管理子页,通用操作日志排除 CATALOG 模块

- 统一赛事子页 list-chrome 布局与面包屑;修复操作日志页表格滚动与分页不可见问题

- 补充中英文/马来语文案及 UAT 验收项 SEC013/SEC014

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 10:00:24 +08:00
parent 567ec9ec8a
commit 2a8b8415e7
17 changed files with 850 additions and 216 deletions

View File

@@ -261,7 +261,6 @@ body::-webkit-scrollbar {
} }
.list-chrome__left .matches-subnav--embedded { .list-chrome__left .matches-subnav--embedded {
align-self: center; align-self: center;
height: var(--list-chrome-control-h);
} }
.list-chrome__actions { .list-chrome__actions {
display: flex; display: flex;

View File

@@ -0,0 +1,194 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { useAdminLocale } from '../composables/useAdminLocale';
import { useAuditLabels } from '../utils/audit-labels';
import api from '../api';
import AdminTableEmpty from './AdminTableEmpty.vue';
const props = withDefaults(
defineProps<{
endpoint?: string;
showModuleColumn?: boolean;
showModuleFilter?: boolean;
}>(),
{
endpoint: '/admin/audit-logs',
showModuleColumn: true,
showModuleFilter: true,
},
);
const { t, locale, localeTag } = useAdminLocale();
const { auditActionLabel, auditModuleLabel } = useAuditLabels();
interface AuditRow {
action: string;
module: string;
targetId: string | null;
operatorId: string | null;
operatorUsername: string | null;
operatorRole: string | null;
operatorUserType: string | null;
operatorType: string;
ipAddress: string | null;
createdAt: string;
}
const logs = ref<AuditRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const filterModule = ref('');
onMounted(load);
watch(
() => props.endpoint,
() => {
page.value = 1;
load();
},
);
async function load() {
const { data } = await api.get(props.endpoint, {
params: {
page: page.value,
pageSize: pageSize.value,
module: props.showModuleFilter && filterModule.value.trim()
? filterModule.value.trim()
: undefined,
},
});
logs.value = (data.data.items ?? []) as AuditRow[];
total.value = data.data.total ?? 0;
}
function onPageChange(p: number) {
page.value = p;
load();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
}
function formatTime(v: string) {
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function operatorRoleLabel(row: AuditRow): string | null {
const code = row.operatorRole;
if (code === 'SUPER_ADMIN') return t('role.super_admin');
if (code === 'MATCH_ADMIN') return t('role.match_admin');
if (code === 'FINANCE_ADMIN') return t('role.finance_admin');
if (code === 'SUPPORT') return t('role.support');
if (row.operatorUserType === 'ADMIN' || row.operatorType === 'ADMIN') return t('role.admin');
if (row.operatorUserType === 'AGENT' || row.operatorType === 'AGENT') return t('role.agent');
if (row.operatorUserType === 'PLAYER' || row.operatorType === 'PLAYER') return t('audit.operator_player');
return null;
}
function operatorDisplay(row: AuditRow): string {
if (row.operatorUsername) return row.operatorUsername;
if (row.operatorType === 'SYSTEM') return t('audit.operator_system');
return '—';
}
</script>
<template>
<el-card v-if="showModuleFilter" class="filter-card" shadow="never">
<el-form inline>
<el-form-item :label="t('common.module')">
<el-input
v-model="filterModule"
:placeholder="t('audit.module_ph')"
clearable
style="width: 160px"
@keyup.enter="load"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :key="locale" :data="logs" stripe>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column :label="t('audit.col.time')" min-width="168" fixed="left">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('audit.col.operator')" min-width="160">
<template #default="{ row }">
<div class="operator-cell">
<span class="operator-name">{{ operatorDisplay(row) }}</span>
<span v-if="operatorRoleLabel(row)" class="operator-role">{{ operatorRoleLabel(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="t('audit.col.action')" min-width="140">
<template #default="{ row }">{{ auditActionLabel(row.action) }}</template>
</el-table-column>
<el-table-column
v-if="showModuleColumn"
:label="t('audit.col.module')"
width="120"
>
<template #default="{ row }">{{ auditModuleLabel(row.module) }}</template>
</el-table-column>
<el-table-column prop="targetId" :label="t('audit.col.target_id')" min-width="100" show-overflow-tooltip />
<el-table-column :label="t('audit.col.ip')" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.ipAddress ?? '—' }}</template>
</el-table-column>
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</el-card>
</template>
<style scoped>
.filter-card { border-radius: 12px; margin-bottom: 16px; }
.data-card { border-radius: 12px; }
.operator-cell {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.35;
}
.operator-name {
font-weight: 500;
color: var(--el-text-color-primary);
}
.operator-role {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -16,6 +16,7 @@ const tabs = [
{ path: '/matches', labelKey: 'nav.matches.fixtures' }, { path: '/matches', labelKey: 'nav.matches.fixtures' },
{ path: '/matches/outrights', labelKey: 'nav.matches.outrights' }, { path: '/matches/outrights', labelKey: 'nav.matches.outrights' },
{ path: '/matches/market-templates', labelKey: 'nav.matches.market_templates' }, { path: '/matches/market-templates', labelKey: 'nav.matches.market_templates' },
{ path: '/matches/audit-logs', labelKey: 'nav.matches.audit_logs' },
]; ];
function isActive(path: string) { function isActive(path: string) {
@@ -25,11 +26,15 @@ function isActive(path: string) {
if (path === '/matches/market-templates') { if (path === '/matches/market-templates') {
return route.path === '/matches/market-templates'; return route.path === '/matches/market-templates';
} }
if (path === '/matches/audit-logs') {
return route.path === '/matches/audit-logs';
}
return ( return (
route.path === '/matches' || route.path === '/matches' ||
(route.path.startsWith('/matches/') && (route.path.startsWith('/matches/') &&
!route.path.startsWith('/matches/outrights') && !route.path.startsWith('/matches/outrights') &&
!route.path.startsWith('/matches/market-templates')) !route.path.startsWith('/matches/market-templates') &&
!route.path.startsWith('/matches/audit-logs'))
); );
} }
</script> </script>
@@ -73,9 +78,16 @@ function isActive(path: string) {
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
background: transparent; background: transparent;
flex-shrink: 0; flex-shrink: 0;
height: 44px; min-height: 0;
height: auto;
align-self: center;
box-sizing: border-box; box-sizing: border-box;
} }
.matches-subnav--embedded .matches-subnav__item {
height: 32px;
padding: 0 12px;
font-size: 12px;
}
.matches-subnav__item { .matches-subnav__item {
padding: 0 14px; padding: 0 14px;
height: 34px; height: 34px;

View File

@@ -247,6 +247,7 @@ const zh: Record<string, string> = {
'nav.matches.fixtures': '赛事配置', 'nav.matches.fixtures': '赛事配置',
'nav.matches.outrights': '优胜赛配置(盘口)', 'nav.matches.outrights': '优胜赛配置(盘口)',
'nav.matches.market_templates': '盘口模板', 'nav.matches.market_templates': '盘口模板',
'nav.matches.audit_logs': '赛事日志',
'page.matches.title': '赛事管理', 'page.matches.title': '赛事管理',
'page.matches.desc': '草稿可编辑、删除;已发布可改开赛时间与热门;未结算可下架', 'page.matches.desc': '草稿可编辑、删除;已发布可改开赛时间与热门;未结算可下架',
'page.bets.title': '注单管理', 'page.bets.title': '注单管理',
@@ -254,7 +255,9 @@ const zh: Record<string, string> = {
'page.cashback.title': '返水管理', 'page.cashback.title': '返水管理',
'page.cashback.desc': '按周期生成返水并发放', 'page.cashback.desc': '按周期生成返水并发放',
'page.audit.title': '操作日志', 'page.audit.title': '操作日志',
'page.audit.desc': '记录所有管理操作行为', 'page.audit.desc': '账务、用户、结算等非赛事类管理操作',
'page.matches_audit.title': '赛事日志',
'page.matches_audit.desc': '联赛、赛事、盘口、赔率、冠军盘等赛事管理操作',
'page.settlement.title': '赛事结算', 'page.settlement.title': '赛事结算',
'page.agent_dash.title': '代理概览', 'page.agent_dash.title': '代理概览',
'page.agent_dash.desc': '下线经营概况与分布', 'page.agent_dash.desc': '下线经营概况与分布',
@@ -543,6 +546,7 @@ const en: Record<string, string> = {
'nav.matches.fixtures': 'Fixtures', 'nav.matches.fixtures': 'Fixtures',
'nav.matches.outrights': 'Outright odds', 'nav.matches.outrights': 'Outright odds',
'nav.matches.market_templates': 'Market templates', 'nav.matches.market_templates': 'Market templates',
'nav.matches.audit_logs': 'Match logs',
'page.matches.title': 'Matches', 'page.matches.title': 'Matches',
'page.matches.desc': 'Edit/delete drafts; adjust kickoff and featured when published; unpublish while unsettled', 'page.matches.desc': 'Edit/delete drafts; adjust kickoff and featured when published; unpublish while unsettled',
'page.bets.title': 'Bets', 'page.bets.title': 'Bets',
@@ -550,7 +554,9 @@ const en: Record<string, string> = {
'page.cashback.title': 'Cashback', 'page.cashback.title': 'Cashback',
'page.cashback.desc': 'Generate and issue cashback by period', 'page.cashback.desc': 'Generate and issue cashback by period',
'page.audit.title': 'Audit Log', '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.settlement.title': 'Settlement',
'page.agent_dash.title': 'Agent overview', 'page.agent_dash.title': 'Agent overview',
'page.agent_dash.desc': 'Downline performance at a glance', 'page.agent_dash.desc': 'Downline performance at a glance',
@@ -839,6 +845,7 @@ const ms: Record<string, string> = {
'nav.matches.fixtures': 'Konfigurasi perlawanan', 'nav.matches.fixtures': 'Konfigurasi perlawanan',
'nav.matches.outrights': 'Odds juara', 'nav.matches.outrights': 'Odds juara',
'nav.matches.market_templates': 'Templat pasaran', 'nav.matches.market_templates': 'Templat pasaran',
'nav.matches.audit_logs': 'Log perlawanan',
'page.matches.title': 'Perlawanan', 'page.matches.title': 'Perlawanan',
'page.matches.desc': 'Edit/padam draf; laraskan masa mula bila diterbitkan; nyahterbit jika belum selesai', 'page.matches.desc': 'Edit/padam draf; laraskan masa mula bila diterbitkan; nyahterbit jika belum selesai',
'page.bets.title': 'Pertaruhan', 'page.bets.title': 'Pertaruhan',
@@ -846,7 +853,9 @@ const ms: Record<string, string> = {
'page.cashback.title': 'Rebat', 'page.cashback.title': 'Rebat',
'page.cashback.desc': 'Jana dan keluarkan rebat mengikut tempoh', 'page.cashback.desc': 'Jana dan keluarkan rebat mengikut tempoh',
'page.audit.title': 'Log audit', '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.settlement.title': 'Penyelesaian',
'page.agent_dash.title': 'Gambaran ejen', 'page.agent_dash.title': 'Gambaran ejen',
'page.agent_dash.desc': 'Prestasi downline sepintas lalu', 'page.agent_dash.desc': 'Prestasi downline sepintas lalu',

View File

@@ -332,7 +332,7 @@ export const adminPagesMs: Record<string, string> = {
'bet.col.line': 'Garisan', 'bet.col.line': 'Garisan',
'bet.col.result': 'Keputusan', 'bet.col.result': 'Keputusan',
'audit.module_ph': 'cth. USERS, AGENTS', 'audit.module_ph': 'cth. USERS, SETTLEMENT',
'audit.col.time': 'Masa', 'audit.col.time': 'Masa',
'audit.col.operator': 'Pengendali', 'audit.col.operator': 'Pengendali',
'audit.col.action': 'Tindakan', 'audit.col.action': 'Tindakan',
@@ -360,6 +360,39 @@ export const adminPagesMs: Record<string, string> = {
'audit.action.UPDATE_STAFF': 'Kemas kini kakitangan', 'audit.action.UPDATE_STAFF': 'Kemas kini kakitangan',
'audit.action.PURGE_UNUSED_FILES': 'Padam media tidak digunakan', 'audit.action.PURGE_UNUSED_FILES': 'Padam media tidak digunakan',
'audit.action.FORGOT_PASSWORD_RESET': 'Pemain set semula kata laluan', '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.USERS': 'Pemain',
'audit.module.AGENTS': 'Ejen', 'audit.module.AGENTS': 'Ejen',
'audit.module.SYSTEM': 'Sistem', 'audit.module.SYSTEM': 'Sistem',

View File

@@ -354,7 +354,7 @@ export const adminPagesZh: Record<string, string> = {
'bet.col.line': '盘口', 'bet.col.line': '盘口',
'bet.col.result': '赛果', 'bet.col.result': '赛果',
'audit.module_ph': '如 USERS、AGENTS', 'audit.module_ph': '如 USERS、SETTLEMENT',
'audit.col.time': '时间', 'audit.col.time': '时间',
'audit.col.operator': '操作人', 'audit.col.operator': '操作人',
'audit.col.action': '操作', 'audit.col.action': '操作',
@@ -382,6 +382,39 @@ export const adminPagesZh: Record<string, string> = {
'audit.action.UPDATE_STAFF': '更新后台员工', 'audit.action.UPDATE_STAFF': '更新后台员工',
'audit.action.PURGE_UNUSED_FILES': '清理未引用媒体', 'audit.action.PURGE_UNUSED_FILES': '清理未引用媒体',
'audit.action.FORGOT_PASSWORD_RESET': '玩家找回密码', '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.USERS': '玩家',
'audit.module.AGENTS': '代理', 'audit.module.AGENTS': '代理',
'audit.module.SYSTEM': '系统', 'audit.module.SYSTEM': '系统',
@@ -1393,7 +1426,7 @@ export const adminPagesEn: Record<string, string> = {
'bet.col.line': 'Line', 'bet.col.line': 'Line',
'bet.col.result': 'Result', 'bet.col.result': 'Result',
'audit.module_ph': 'e.g. USERS, AGENTS', 'audit.module_ph': 'e.g. USERS, SETTLEMENT',
'audit.col.time': 'Time', 'audit.col.time': 'Time',
'audit.col.operator': 'Operator', 'audit.col.operator': 'Operator',
'audit.col.action': 'Action', 'audit.col.action': 'Action',
@@ -1421,6 +1454,39 @@ export const adminPagesEn: Record<string, string> = {
'audit.action.UPDATE_STAFF': 'Update staff account', 'audit.action.UPDATE_STAFF': 'Update staff account',
'audit.action.PURGE_UNUSED_FILES': 'Purge unused media', 'audit.action.PURGE_UNUSED_FILES': 'Purge unused media',
'audit.action.FORGOT_PASSWORD_RESET': 'Player forgot-password reset', '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.USERS': 'Players',
'audit.module.AGENTS': 'Agents', 'audit.module.AGENTS': 'Agents',
'audit.module.SYSTEM': 'System', 'audit.module.SYSTEM': 'System',

View File

@@ -48,7 +48,7 @@ const adminMenus = computed(() => {
{ path: '/bets', label: t('nav.bets'), icon: 'bets', permissions: [AdminPerm.bets] }, { path: '/bets', label: t('nav.bets'), icon: 'bets', permissions: [AdminPerm.bets] },
{ path: '/contents', label: t('nav.contents'), icon: 'contents', permissions: [AdminPerm.content] }, { 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: '/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: '/staff', label: t('nav.staff'), icon: 'users', permissions: [AdminPerm.settings] },
{ path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests', permissions: [AdminPerm.settings] }, { path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests', permissions: [AdminPerm.settings] },
]; ];

View File

@@ -86,6 +86,11 @@ const router = createRouter({
component: () => import('../views/MarketTemplates.vue'), component: () => import('../views/MarketTemplates.vue'),
meta: { adminOnly: true, permissions: [AdminPerm.matches] }, 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', path: 'matches/:matchId/edit',
name: 'admin-match-edit', name: 'admin-match-edit',

View File

@@ -14,12 +14,6 @@ export function resolveAdminBreadcrumb(
{ label: t('breadcrumb.settlement') }, { 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)) { if (/^\/matches\/[^/]+\/edit/.test(path)) {
return [ return [
{ label: t('nav.matches'), to: '/matches' }, { label: t('nav.matches'), to: '/matches' },
@@ -32,12 +26,6 @@ export function resolveAdminBreadcrumb(
{ label: t('breadcrumb.match_markets') }, { 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') { if (path === '/dashboard/players') {
return [ return [
{ label: t('nav.dashboard'), to: '/' }, { label: t('nav.dashboard'), to: '/' },

View File

@@ -1,169 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import AuditLogTable from '../components/AuditLogTable.vue';
import { useAdminLocale } from '../composables/useAdminLocale';
import { useAuditLabels } from '../utils/audit-labels';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
const { t, locale, localeTag } = useAdminLocale();
const { auditActionLabel, auditModuleLabel } = useAuditLabels();
interface AuditRow {
action: string;
module: string;
targetId: string | null;
operatorId: string | null;
operatorUsername: string | null;
operatorRole: string | null;
operatorUserType: string | null;
operatorType: string;
ipAddress: string | null;
createdAt: string;
}
const logs = ref<AuditRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const filterModule = ref('');
onMounted(load);
async function load() {
const { data } = await api.get('/admin/audit-logs', {
params: {
page: page.value,
pageSize: pageSize.value,
module: filterModule.value.trim() || undefined,
},
});
logs.value = (data.data.items ?? []) as AuditRow[];
total.value = data.data.total ?? 0;
}
function onPageChange(p: number) {
page.value = p;
load();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
}
function formatTime(v: string) {
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function operatorRoleLabel(row: AuditRow): string | null {
const code = row.operatorRole;
if (code === 'SUPER_ADMIN') return t('role.super_admin');
if (code === 'MATCH_ADMIN') return t('role.match_admin');
if (code === 'FINANCE_ADMIN') return t('role.finance_admin');
if (code === 'SUPPORT') return t('role.support');
if (row.operatorUserType === 'ADMIN' || row.operatorType === 'ADMIN') return t('role.admin');
if (row.operatorUserType === 'AGENT' || row.operatorType === 'AGENT') return t('role.agent');
if (row.operatorUserType === 'PLAYER' || row.operatorType === 'PLAYER') return t('audit.operator_player');
return null;
}
function operatorDisplay(row: AuditRow): string {
if (row.operatorUsername) return row.operatorUsername;
if (row.operatorType === 'SYSTEM') return t('audit.operator_system');
return '—';
}
</script> </script>
<template> <template>
<div class="admin-list-page"> <div class="admin-list-page">
<el-card class="filter-card" shadow="never"> <AuditLogTable />
<el-form inline>
<el-form-item :label="t('common.module')">
<el-input
v-model="filterModule"
:placeholder="t('audit.module_ph')"
clearable
style="width: 160px"
@keyup.enter="load"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :key="locale" :data="logs" stripe>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column :label="t('audit.col.time')" min-width="168" fixed="left">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('audit.col.operator')" min-width="160">
<template #default="{ row }">
<div class="operator-cell">
<span class="operator-name">{{ operatorDisplay(row) }}</span>
<span v-if="operatorRoleLabel(row)" class="operator-role">{{ operatorRoleLabel(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="t('audit.col.action')" min-width="140">
<template #default="{ row }">{{ auditActionLabel(row.action) }}</template>
</el-table-column>
<el-table-column :label="t('audit.col.module')" width="120">
<template #default="{ row }">{{ auditModuleLabel(row.module) }}</template>
</el-table-column>
<el-table-column prop="targetId" :label="t('audit.col.target_id')" min-width="100" show-overflow-tooltip />
<el-table-column :label="t('audit.col.ip')" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.ipAddress ?? '—' }}</template>
</el-table-column>
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</el-card>
</div> </div>
</template> </template>
<style scoped>
.filter-card { border-radius: 12px; }
.data-card { border-radius: 12px; }
.operator-cell {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.35;
}
.operator-name {
font-weight: 500;
color: var(--el-text-color-primary);
}
.operator-role {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -477,8 +477,14 @@ load();
</script> </script>
<template> <template>
<div v-loading="loading" class="template-page"> <div v-loading="loading" class="admin-list-page template-page">
<MatchesSubNav /> <div class="list-chrome">
<div class="list-chrome__row">
<div class="list-chrome__left">
<MatchesSubNav embedded />
</div>
</div>
</div>
<div class="template-toolbar"> <div class="template-toolbar">
<el-select v-model="selectedId" class="template-select" :placeholder="ui('selectTemplate')"> <el-select v-model="selectedId" class="template-select" :placeholder="ui('selectTemplate')">
<el-option <el-option

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import MatchesSubNav from '../components/MatchesSubNav.vue';
import AuditLogTable from '../components/AuditLogTable.vue';
</script>
<template>
<div class="admin-list-page matches-page">
<div class="list-chrome">
<div class="list-chrome__row">
<div class="list-chrome__left">
<MatchesSubNav embedded />
</div>
</div>
</div>
<AuditLogTable
endpoint="/admin/catalog-audit-logs"
:show-module-column="false"
:show-module-filter="false"
/>
</div>
</template>

View File

@@ -35,6 +35,12 @@ import { SettlementService } from '../../domains/settlement/settlement.service';
import { CashbackService } from '../../domains/operations/cashback/cashback.service'; import { CashbackService } from '../../domains/operations/cashback/cashback.service';
import { I18nService } from '../../domains/operations/i18n/i18n.service'; import { I18nService } from '../../domains/operations/i18n/i18n.service';
import { AuditService } from '../../domains/operations/audit/audit.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 { BetsService } from '../../domains/betting/bets.service';
import { BettingLimitsService } from '../../domains/betting/betting-limits.service'; import { BettingLimitsService } from '../../domains/betting/betting-limits.service';
import { PrismaService } from '../../shared/prisma/prisma.service'; import { PrismaService } from '../../shared/prisma/prisma.service';
@@ -1825,11 +1831,13 @@ export class AdminController {
@Post('leagues') @Post('leagues')
@RequirePermissions(P.matches) @RequirePermissions(P.matches)
async createLeague( async createLeague(
@CurrentUser('id') operatorId: bigint,
@Body() dto: CreatePlatformLeagueDto | { code: string; translations: Record<string, string> }, @Body() dto: CreatePlatformLeagueDto | { code: string; translations: Record<string, string> },
) { ) {
let league;
if ('leagueZh' in dto || 'leagueEn' in dto) { if ('leagueZh' in dto || 'leagueEn' in dto) {
const body = dto as CreatePlatformLeagueDto; const body = dto as CreatePlatformLeagueDto;
const league = await this.matches.createPlatformLeague({ league = await this.matches.createPlatformLeague({
leagueEn: body.leagueEn, leagueEn: body.leagueEn,
leagueZh: body.leagueZh, leagueZh: body.leagueZh,
leagueMs: body.leagueMs, leagueMs: body.leagueMs,
@@ -1837,16 +1845,26 @@ export class AdminController {
displayOrder: body.displayOrder, displayOrder: body.displayOrder,
isActive: body.isActive, isActive: body.isActive,
}); });
return jsonResponse(league); } else {
}
const legacy = dto as { code: string; translations: Record<string, string> }; const legacy = dto as { code: string; translations: Record<string, string> };
const league = await this.matches.createLeague(legacy.code, legacy.translations); 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); return jsonResponse(league);
} }
@Put('leagues/:leagueId') @Put('leagues/:leagueId')
@RequirePermissions(P.matches) @RequirePermissions(P.matches)
async updateLeague( async updateLeague(
@CurrentUser('id') operatorId: bigint,
@Param('leagueId') leagueId: string, @Param('leagueId') leagueId: string,
@Body() dto: CreatePlatformLeagueDto, @Body() dto: CreatePlatformLeagueDto,
) { ) {
@@ -1858,6 +1876,12 @@ export class AdminController {
displayOrder: dto.displayOrder, displayOrder: dto.displayOrder,
isActive: dto.isActive, 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); return jsonResponse(league);
} }
@@ -1870,8 +1894,17 @@ export class AdminController {
@Post('leagues/:leagueId/archive') @Post('leagues/:leagueId/archive')
@RequirePermissions(P.matches) @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)); const result = await this.catalogArchive.archiveLeague(BigInt(leagueId));
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.ARCHIVE_LEAGUE,
targetId: leagueId,
afterData: result,
});
return jsonResponse(result); return jsonResponse(result);
} }
@@ -1925,8 +1958,17 @@ export class AdminController {
@Post('teams') @Post('teams')
@RequirePermissions(P.matches) @RequirePermissions(P.matches)
async createTeam(@Body() dto: { code: string; translations: Record<string, string> }) { async createTeam(
@CurrentUser('id') operatorId: bigint,
@Body() dto: { code: string; translations: Record<string, string> },
) {
const team = await this.matches.createTeam(dto.code, dto.translations); 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); return jsonResponse(team);
} }
@@ -1999,13 +2041,29 @@ export class AdminController {
updatedBy: operatorId, updatedBy: operatorId,
}); });
await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId); 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); return jsonResponse(match);
} }
@Delete('matches/:id') @Delete('matches/:id')
@RequirePermissions(P.matches) @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 this.matches.deleteMatch(BigInt(id));
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.DELETE_MATCH,
targetId: id,
});
return jsonResponse({ deleted: true }); return jsonResponse({ deleted: true });
} }
@@ -2018,7 +2076,11 @@ export class AdminController {
@Post('matches/:id/archive') @Post('matches/:id/archive')
@RequirePermissions(P.matches) @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 matchId = BigInt(id);
const result = await this.catalogArchive.archiveMatch(matchId, { const result = await this.catalogArchive.archiveMatch(matchId, {
force: dto.force === true, force: dto.force === true,
@@ -2028,6 +2090,12 @@ export class AdminController {
const voided = await this.settlement.voidMatchBets(matchId); const voided = await this.settlement.voidMatchBets(matchId);
voidedCount = voided.voidedCount; 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 }); return jsonResponse({ ...result, voidedCount });
} }
@@ -2059,6 +2127,17 @@ export class AdminController {
createdBy: operatorId, createdBy: operatorId,
}); });
await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId); 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); return jsonResponse(match);
} }
@@ -2069,42 +2148,86 @@ export class AdminController {
throw appBadRequest('IMPORT_MATCHES_REQUIRED'); throw appBadRequest('IMPORT_MATCHES_REQUIRED');
} }
const result = await this.matches.importZhiboMatchesBundle(dto, operatorId); 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); return jsonResponse(result);
} }
@Post('matches/:id/publish') @Post('matches/:id/publish')
@RequirePermissions(P.matches) @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)); 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); return jsonResponse(match);
} }
@Post('matches/:id/unpublish') @Post('matches/:id/unpublish')
@RequirePermissions(P.matches) @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)); 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); return jsonResponse(match);
} }
@Post('matches/:id/close') @Post('matches/:id/close')
@RequirePermissions(P.matches) @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)); 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); return jsonResponse(match);
} }
@Post('matches/:id/reopen') @Post('matches/:id/reopen')
@RequirePermissions(P.matches) @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 startTime = dto.startTime ? parseMatchStartTime(dto.startTime) : undefined;
const match = await this.matches.reopenMatch(BigInt(id), startTime); 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); return jsonResponse(match);
} }
@Post('matches/:id/cancel') @Post('matches/:id/cancel')
@RequirePermissions(P.matches) @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)); const voided = await this.settlement.cancelMatchAndVoidBets(BigInt(id));
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.CANCEL_MATCH,
targetId: id,
afterData: voided,
});
return jsonResponse(voided); return jsonResponse(voided);
} }
@@ -2123,8 +2246,17 @@ export class AdminController {
@Post('market-templates') @Post('market-templates')
@RequirePermissions(P.matches) @RequirePermissions(P.matches)
async createMarketTemplate(@Body() dto: MarketTemplateSaveDto) { async createMarketTemplate(
@CurrentUser('id') operatorId: bigint,
@Body() dto: MarketTemplateSaveDto,
) {
const template = await this.markets.createTemplate(dto); 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); return jsonResponse(template);
} }
@@ -2137,39 +2269,87 @@ export class AdminController {
@Put('market-templates/:id') @Put('market-templates/:id')
@RequirePermissions(P.matches) @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); 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); return jsonResponse(template);
} }
@Post('market-templates/:id/duplicate') @Post('market-templates/:id/duplicate')
@RequirePermissions(P.matches) @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)); 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); return jsonResponse(template);
} }
@Post('market-templates/:id/set-default') @Post('market-templates/:id/set-default')
@RequirePermissions(P.matches) @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)); 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); return jsonResponse(template);
} }
@Post('matches/:id/markets/templates') @Post('matches/:id/markets/templates')
@RequirePermissions(P.matches) @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); 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); return jsonResponse(markets);
} }
@Post('matches/:id/markets/apply-template') @Post('matches/:id/markets/apply-template')
@RequirePermissions(P.matches) @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( const result = await this.markets.applyTemplateToMatch(
BigInt(id), BigInt(id),
dto.templateId ? BigInt(dto.templateId) : null, 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); return jsonResponse(result);
} }
@@ -2181,6 +2361,12 @@ export class AdminController {
@Body() dto: BulkMatchMarketsDto, @Body() dto: BulkMatchMarketsDto,
) { ) {
const result = await this.markets.saveMatchMarkets(BigInt(id), dto.markets, operatorId); 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); return jsonResponse(result);
} }
@@ -2196,12 +2382,22 @@ export class AdminController {
odds: u.odds, odds: u.odds,
})); }));
const results = await this.markets.batchUpdateOdds(updates, operatorId); 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 }); return jsonResponse({ matchId: id, updated: results.length });
} }
@Patch('markets/:id') @Patch('markets/:id')
@RequirePermissions(P.matches) @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), { const market = await this.markets.updateMarket(BigInt(id), {
promoLabel: dto.promoLabel, promoLabel: dto.promoLabel,
promoLabelI18n: dto.promoLabelI18n, promoLabelI18n: dto.promoLabelI18n,
@@ -2210,6 +2406,17 @@ export class AdminController {
lineValue: dto.lineValue, lineValue: dto.lineValue,
showOnPlayer: dto.showOnPlayer, 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); return jsonResponse(market);
} }
@@ -2230,6 +2437,20 @@ export class AdminController {
}, },
operatorId, 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); return jsonResponse(selection);
} }
@@ -2241,6 +2462,16 @@ export class AdminController {
@Body() dto: UpdateOddsDto, @Body() dto: UpdateOddsDto,
) { ) {
const selection = await this.markets.updateOdds(BigInt(id), dto.odds, operatorId); 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); return jsonResponse(selection);
} }
@@ -2260,7 +2491,10 @@ export class AdminController {
@Post('outrights') @Post('outrights')
@RequirePermissions(P.matches) @RequirePermissions(P.matches)
async createOutright(@Body() dto: CreateOutrightDto) { async createOutright(
@CurrentUser('id') operatorId: bigint,
@Body() dto: CreateOutrightDto,
) {
const data = await this.outright.createForAdmin({ const data = await this.outright.createForAdmin({
leagueId: BigInt(dto.leagueId), leagueId: BigInt(dto.leagueId),
titleZh: dto.titleZh, titleZh: dto.titleZh,
@@ -2268,13 +2502,24 @@ export class AdminController {
titleMs: dto.titleMs, titleMs: dto.titleMs,
status: dto.status, 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); return jsonResponse(data);
} }
@Post('outrights/import/wc2026') @Post('outrights/import/wc2026')
@RequirePermissions(P.matches) @RequirePermissions(P.matches)
async importWc2026Outright() { async importWc2026Outright(@CurrentUser('id') operatorId: bigint) {
const data = await this.outright.importWc2026Canonical(); const data = await this.outright.importWc2026Canonical();
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.IMPORT_WC2026_OUTRIGHT,
afterData: data,
});
return jsonResponse(data); return jsonResponse(data);
} }
@@ -2298,16 +2543,27 @@ export class AdminController {
const list = await this.outright.listForAdmin(); const list = await this.outright.listForAdmin();
const wc = list.find((e) => e.leagueCode === 'WC2026'); const wc = list.find((e) => e.leagueCode === 'WC2026');
if (!wc) throw appBadRequest('WC_OUTRIGHT_NOT_FOUND'); if (!wc) throw appBadRequest('WC_OUTRIGHT_NOT_FOUND');
return jsonResponse( const data = await this.outright.batchUpdateOdds(BigInt(wc.id), dto.updates, operatorId);
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 */ /** @deprecated */
@Post('outrights/wc2026/apply-canonical') @Post('outrights/wc2026/apply-canonical')
@RequirePermissions(P.matches) @RequirePermissions(P.matches)
async applyWc2026CanonicalLegacy() { async applyWc2026CanonicalLegacy(@CurrentUser('id') operatorId: bigint) {
return jsonResponse(await this.outright.importWc2026Canonical()); const data = await this.outright.importWc2026Canonical();
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.IMPORT_WC2026_OUTRIGHT,
afterData: data,
});
return jsonResponse(data);
} }
@Get('outrights/:matchId') @Get('outrights/:matchId')
@@ -2320,10 +2576,17 @@ export class AdminController {
@Put('outrights/:matchId') @Put('outrights/:matchId')
@RequirePermissions(P.matches) @RequirePermissions(P.matches)
async updateOutright( async updateOutright(
@CurrentUser('id') operatorId: bigint,
@Param('matchId') matchId: string, @Param('matchId') matchId: string,
@Body() dto: UpdateOutrightDto, @Body() dto: UpdateOutrightDto,
) { ) {
const data = await this.outright.updateForAdmin(BigInt(matchId), dto); 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); return jsonResponse(data);
} }
@@ -2339,22 +2602,36 @@ export class AdminController {
dto.updates, dto.updates,
operatorId, operatorId,
); );
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.UPDATE_OUTRIGHT_ODDS,
targetId: matchId,
afterData: summarizeOddsUpdates(dto.updates.map((u) => u.selectionId)),
});
return jsonResponse(data); return jsonResponse(data);
} }
@Post('outrights/:matchId/selections') @Post('outrights/:matchId/selections')
@RequirePermissions(P.matches) @RequirePermissions(P.matches)
async addOutrightSelection( async addOutrightSelection(
@CurrentUser('id') operatorId: bigint,
@Param('matchId') matchId: string, @Param('matchId') matchId: string,
@Body() dto: AddOutrightSelectionDto, @Body() dto: AddOutrightSelectionDto,
) { ) {
const data = await this.outright.addSelection(BigInt(matchId), dto); 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); return jsonResponse(data);
} }
@Post('outrights/:matchId/selections/batch') @Post('outrights/:matchId/selections/batch')
@RequirePermissions(P.matches) @RequirePermissions(P.matches)
async addOutrightSelectionsBatch( async addOutrightSelectionsBatch(
@CurrentUser('id') operatorId: bigint,
@Param('matchId') matchId: string, @Param('matchId') matchId: string,
@Body() dto: AddOutrightSelectionsBatchDto, @Body() dto: AddOutrightSelectionsBatchDto,
) { ) {
@@ -2365,12 +2642,19 @@ export class AdminController {
BigInt(matchId), BigInt(matchId),
dto.items, dto.items,
); );
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.BATCH_ADD_OUTRIGHT_SELECTIONS,
targetId: matchId,
afterData: { count: dto.items.length },
});
return jsonResponse(data); return jsonResponse(data);
} }
@Patch('outrights/:matchId/selections/:selectionId') @Patch('outrights/:matchId/selections/:selectionId')
@RequirePermissions(P.matches) @RequirePermissions(P.matches)
async updateOutrightSelectionTeam( async updateOutrightSelectionTeam(
@CurrentUser('id') operatorId: bigint,
@Param('matchId') matchId: string, @Param('matchId') matchId: string,
@Param('selectionId') selectionId: string, @Param('selectionId') selectionId: string,
@Body() dto: UpdateOutrightSelectionTeamDto, @Body() dto: UpdateOutrightSelectionTeamDto,
@@ -2380,12 +2664,19 @@ export class AdminController {
BigInt(selectionId), BigInt(selectionId),
dto, dto,
); );
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.UPDATE_OUTRIGHT_SELECTION,
targetId: selectionId,
afterData: { matchId, ...dto },
});
return jsonResponse(data); return jsonResponse(data);
} }
@Delete('outrights/:matchId/selections/:selectionId') @Delete('outrights/:matchId/selections/:selectionId')
@RequirePermissions(P.matches) @RequirePermissions(P.matches)
async removeOutrightSelection( async removeOutrightSelection(
@CurrentUser('id') operatorId: bigint,
@Param('matchId') matchId: string, @Param('matchId') matchId: string,
@Param('selectionId') selectionId: string, @Param('selectionId') selectionId: string,
) { ) {
@@ -2393,6 +2684,12 @@ export class AdminController {
BigInt(matchId), BigInt(matchId),
BigInt(selectionId), BigInt(selectionId),
); );
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.REMOVE_OUTRIGHT_SELECTION,
targetId: selectionId,
afterData: { matchId },
});
return jsonResponse(data); return jsonResponse(data);
} }
@@ -2927,7 +3224,28 @@ export class AdminController {
const result = await this.audit.list( const result = await this.audit.list(
page ? parseInt(page, 10) : 1, page ? parseInt(page, 10) : 1,
pageSize ? parseInt(pageSize, 10) : 10, 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 }, { viewerId, viewerRole, viewerUserType },
); );
return jsonResponse(result); return jsonResponse(result);

View File

@@ -36,13 +36,14 @@ export class AuditService {
async list( async list(
page = 1, page = 1,
pageSize = 10, pageSize = 10,
module?: string, filters?: { module?: string; excludeModule?: string },
viewer?: AuditViewerScope, viewer?: AuditViewerScope,
) { ) {
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
const scopeWhere = viewer ? await buildAuditListScopeWhere(this.prisma, viewer) : undefined; const scopeWhere = viewer ? await buildAuditListScopeWhere(this.prisma, viewer) : undefined;
const where: Prisma.AuditLogWhereInput = { const where: Prisma.AuditLogWhereInput = {
...(module ? { module } : {}), ...(filters?.module ? { module: filters.module } : {}),
...(filters?.excludeModule ? { module: { not: filters.excludeModule } } : {}),
...(scopeWhere ?? {}), ...(scopeWhere ?? {}),
}; };
const [rows, total] = await Promise.all([ const [rows, total] = await Promise.all([

View File

@@ -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,
});
});
});

View File

@@ -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<string | bigint>,
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,
});
}

View File

@@ -52,6 +52,8 @@
- [ ] **SEC010** 使用 `support1` 登录:菜单仅显示查询相关项;玩家页可查看/重置密码,无上分/下分/创建按钮;调用 `POST /admin/wallet/deposit` 返回 403 - [ ] **SEC010** 使用 `support1` 登录:菜单仅显示查询相关项;玩家页可查看/重置密码,无上分/下分/创建按钮;调用 `POST /admin/wallet/deposit` 返回 403
- [ ] **SEC011** 使用 `financeadmin` 登录:无赛事/结算/内容菜单;可对玩家上分、调整代理额度;调用 `POST /admin/matches` 等赛事写接口返回 403 - [ ] **SEC011** 使用 `financeadmin` 登录:无赛事/结算/内容菜单;可对玩家上分、调整代理额度;调用 `POST /admin/matches` 等赛事写接口返回 403
- [ ] **SEC012** 使用 `matchadmin` 登录:有赛事/结算/内容;无上分/额度/返水确认;调用 `POST /admin/wallet/deposit` 返回 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` 通过 - [ ] 自动化:`pnpm --filter @thebet365/api exec jest admin-rbac.spec.ts --runInBand` 通过
## 备份与回滚 ## 备份与回滚