fix(admin): 生产环境隐藏并禁用自动化测试

侧栏与路由按服务端 NODE_ENV 控制;可通过 ALLOW_SMOKE_TESTS=true 临时开启。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 17:52:07 +08:00
parent e469611138
commit 283252c841
7 changed files with 94 additions and 15 deletions

View File

@@ -0,0 +1,24 @@
import { ref } from 'vue';
import api from '../api';
const allowed = ref<boolean | null>(null);
let loadPromise: Promise<void> | null = null;
export function useSmokeTestsAllowed() {
async function ensureLoaded() {
if (allowed.value !== null) return;
if (!loadPromise) {
loadPromise = (async () => {
try {
const { data } = await api.get('/admin/system/smoke-tests');
allowed.value = !!data.data?.allowed;
} catch {
allowed.value = false;
}
})();
}
await loadPromise;
}
return { allowed, ensureLoaded };
}

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router'; import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { useAdminLocale } from '../composables/useAdminLocale'; import { useAdminLocale } from '../composables/useAdminLocale';
import { useSmokeTestsAllowed } from '../composables/useSmokeTestsAllowed';
import AdminLocaleSwitcher from '../components/AdminLocaleSwitcher.vue'; import AdminLocaleSwitcher from '../components/AdminLocaleSwitcher.vue';
import AdminNavIcon from '../components/AdminNavIcon.vue'; import AdminNavIcon from '../components/AdminNavIcon.vue';
import { resolveAdminBreadcrumb } from '../utils/admin-breadcrumb'; import { resolveAdminBreadcrumb } from '../utils/admin-breadcrumb';
@@ -11,23 +12,30 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const { t } = useAdminLocale(); const { t } = useAdminLocale();
const { allowed: smokeTestsAllowed, ensureLoaded: ensureSmokeTestsAllowed } = useSmokeTestsAllowed();
const sidebarOpen = ref(false); const sidebarOpen = ref(false);
const isMobileNav = ref(false); const isMobileNav = ref(false);
const adminMenus = computed(() => [ const adminMenus = computed(() => {
{ path: '/', label: t('nav.dashboard'), icon: 'dashboard', matchPrefix: true }, const items = [
{ path: '/matches', label: t('nav.matches'), icon: 'matches', matchPrefix: true }, { path: '/', label: t('nav.dashboard'), icon: 'dashboard', matchPrefix: true },
{ path: '/users', label: t('nav.agents_players'), icon: 'users' }, { path: '/matches', label: t('nav.matches'), icon: 'matches', matchPrefix: true },
{ path: '/finance-logs', label: t('nav.finance_logs'), icon: 'finance' }, { path: '/users', label: t('nav.agents_players'), icon: 'users' },
{ path: '/deposit', label: t('nav.deposit_manage'), icon: 'deposit', matchPrefix: true }, { path: '/finance-logs', label: t('nav.finance_logs'), icon: 'finance' },
{ path: '/cashback', label: t('nav.cashback'), icon: 'cashback' }, { path: '/deposit', label: t('nav.deposit_manage'), icon: 'deposit', matchPrefix: true },
{ path: '/bets', label: t('nav.bets'), icon: 'bets' }, { path: '/cashback', label: t('nav.cashback'), icon: 'cashback' },
{ path: '/contents', label: t('nav.contents'), icon: 'contents' }, { path: '/bets', label: t('nav.bets'), icon: 'bets' },
{ path: '/media', label: t('nav.media'), icon: 'media' }, { path: '/contents', label: t('nav.contents'), icon: 'contents' },
{ path: '/audit', label: t('nav.audit'), icon: 'audit' }, { path: '/media', label: t('nav.media'), icon: 'media' },
{ path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests' }, { path: '/audit', label: t('nav.audit'), icon: 'audit' },
]); { path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests' },
];
if (smokeTestsAllowed.value === false) {
return items.filter((item) => item.path !== '/smoke-tests');
}
return items;
});
const agentMenus = computed(() => [ const agentMenus = computed(() => [
{ path: '/', label: t('nav.dashboard'), icon: 'dashboard' }, { path: '/', label: t('nav.dashboard'), icon: 'dashboard' },
@@ -114,6 +122,9 @@ function logout() {
onMounted(() => { onMounted(() => {
syncMobileNav(); syncMobileNav();
window.addEventListener('resize', syncMobileNav); window.addEventListener('resize', syncMobileNav);
if (auth.isAdmin.value) {
void ensureSmokeTestsAllowed();
}
}); });
onUnmounted(() => { onUnmounted(() => {

View File

@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { useSmokeTestsAllowed } from '../composables/useSmokeTestsAllowed';
import { ensureStaffSession } from '../utils/session-hydrate'; import { ensureStaffSession } from '../utils/session-hydrate';
const router = createRouter({ const router = createRouter({
@@ -107,7 +108,7 @@ const router = createRouter({
{ {
path: 'smoke-tests', path: 'smoke-tests',
component: () => import('../views/SmokeTests.vue'), component: () => import('../views/SmokeTests.vue'),
meta: { adminOnly: true }, meta: { adminOnly: true, smokeTestsOnly: true },
}, },
{ {
path: 'media', path: 'media',
@@ -185,6 +186,12 @@ router.beforeEach(async (to) => {
return '/'; return '/';
} }
if (to.meta.smokeTestsOnly) {
const { ensureLoaded, allowed } = useSmokeTestsAllowed();
await ensureLoaded();
if (!allowed.value) return '/';
}
return true; return true;
}); });

View File

@@ -1,11 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale'; import { useAdminLocale } from '../composables/useAdminLocale';
import { useSmokeTestsAllowed } from '../composables/useSmokeTestsAllowed';
import api from '../api'; import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue'; import AdminTableEmpty from '../components/AdminTableEmpty.vue';
const { t, locale, localeTag } = useAdminLocale(); const { t, locale, localeTag } = useAdminLocale();
const router = useRouter();
const { allowed: smokeTestsAllowed, ensureLoaded: ensureSmokeTestsAllowed } = useSmokeTestsAllowed();
type SuiteInfo = { id: string; name: string; description: string; caseCount: number }; type SuiteInfo = { id: string; name: string; description: string; caseCount: number };
type CaseMeta = { id: string; suite: string; name: string; uatRef?: string }; type CaseMeta = { id: string; suite: string; name: string; uatRef?: string };
@@ -153,7 +157,14 @@ function statusTagType(status: string) {
return 'info'; return 'info';
} }
onMounted(loadMeta); onMounted(async () => {
await ensureSmokeTestsAllowed();
if (!smokeTestsAllowed.value) {
router.replace('/');
return;
}
await loadMeta();
});
</script> </script>
<template> <template>

View File

@@ -2299,9 +2299,16 @@ export class AdminController {
return jsonResponse(messages); return jsonResponse(messages);
} }
@Get('system/smoke-tests')
@RequirePermissions(P.settings)
getSmokeTestsStatus() {
return jsonResponse({ allowed: this.smokeTests.isAllowed() });
}
@Get('smoke-tests/suites') @Get('smoke-tests/suites')
@RequirePermissions(P.settings) @RequirePermissions(P.settings)
async smokeTestSuites() { async smokeTestSuites() {
this.smokeTests.assertAllowed();
return jsonResponse({ return jsonResponse({
suites: this.smokeTests.listSuites(), suites: this.smokeTests.listSuites(),
cases: this.smokeTests.listCases(), cases: this.smokeTests.listCases(),
@@ -2312,6 +2319,7 @@ export class AdminController {
@Get('smoke-tests/last-run') @Get('smoke-tests/last-run')
@RequirePermissions(P.settings) @RequirePermissions(P.settings)
async smokeTestLastRun() { async smokeTestLastRun() {
this.smokeTests.assertAllowed();
return jsonResponse(this.smokeTests.getLastRun()); return jsonResponse(this.smokeTests.getLastRun());
} }
@@ -2321,6 +2329,7 @@ export class AdminController {
@CurrentUser('id') operatorId: bigint, @CurrentUser('id') operatorId: bigint,
@Body() body: { suites?: string[] }, @Body() body: { suites?: string[] },
) { ) {
this.smokeTests.assertAllowed();
const summary = await this.smokeTests.run(body?.suites, operatorId); const summary = await this.smokeTests.run(body?.suites, operatorId);
await this.audit.log({ await this.audit.log({
operatorId, operatorId,

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { appForbidden } from '../../../shared/common/app-error';
import { PrismaService } from '../../../shared/prisma/prisma.service'; import { PrismaService } from '../../../shared/prisma/prisma.service';
import { AgentsService } from '../../agent/agents.service'; import { AgentsService } from '../../agent/agents.service';
import { BetsService } from '../../betting/bets.service'; import { BetsService } from '../../betting/bets.service';
@@ -30,6 +31,17 @@ export class SmokeTestService {
private agents: AgentsService, private agents: AgentsService,
) {} ) {}
isAllowed(): boolean {
if (process.env.ALLOW_SMOKE_TESTS === 'true') return true;
return process.env.NODE_ENV !== 'production';
}
assertAllowed() {
if (!this.isAllowed()) {
throw appForbidden('SMOKE_TESTS_FORBIDDEN');
}
}
listSuites(): SmokeTestSuiteInfo[] { listSuites(): SmokeTestSuiteInfo[] {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const c of SMOKE_TEST_CASES) { for (const c of SMOKE_TEST_CASES) {

View File

@@ -519,6 +519,11 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Database reset forbidden in production (set ALLOW_DB_RESET=true)', 'en-US': 'Database reset forbidden in production (set ALLOW_DB_RESET=true)',
'ms-MY': 'Reset DB dilarang di produksi (set ALLOW_DB_RESET=true)', 'ms-MY': 'Reset DB dilarang di produksi (set ALLOW_DB_RESET=true)',
}, },
SMOKE_TESTS_FORBIDDEN: {
'zh-CN': '生产环境已禁用自动化测试',
'en-US': 'Smoke tests are disabled in production',
'ms-MY': 'Ujian asap dilumpuhkan di produksi',
},
CASHBACK_DATE_RANGE_INVALID: { CASHBACK_DATE_RANGE_INVALID: {
'zh-CN': '开始日期不能晚于结束日期', 'zh-CN': '开始日期不能晚于结束日期',
'en-US': 'Start date cannot be after end date', 'en-US': 'Start date cannot be after end date',