diff --git a/apps/admin/src/i18n/admin-messages.ts b/apps/admin/src/i18n/admin-messages.ts index 5474c7c..c485e3d 100644 --- a/apps/admin/src/i18n/admin-messages.ts +++ b/apps/admin/src/i18n/admin-messages.ts @@ -37,9 +37,11 @@ const zh: Record = { 'nav.matches': '赛事管理', 'nav.outrights': '优胜冠军', 'nav.bets': '注单管理', + 'nav.credit_transactions': '额度流水', 'nav.cashback': '返水管理', 'nav.contents': '公共管理', 'nav.audit': '操作日志', + 'nav.smoke_tests': '自动化测试', 'nav.players': '直属玩家', 'nav.subAgents': '下级代理', 'nav.myBets': '注单查询', @@ -215,9 +217,11 @@ const en: Record = { 'nav.matches': 'Matches', 'nav.outrights': 'Outrights', 'nav.bets': 'Bets', + 'nav.credit_transactions': 'Credit ledger', 'nav.cashback': 'Cashback', 'nav.contents': 'Public Content', 'nav.audit': 'Audit Log', + 'nav.smoke_tests': 'Smoke tests', 'nav.players': 'My Players', 'nav.subAgents': 'Sub-Agents', 'nav.myBets': 'Bet Search', @@ -393,9 +397,11 @@ const ms: Record = { 'nav.matches': 'Perlawanan', 'nav.outrights': 'Juara', 'nav.bets': 'Pertaruhan', + 'nav.credit_transactions': 'Lejar kredit', 'nav.cashback': 'Rebat', 'nav.contents': 'Kandungan awam', 'nav.audit': 'Log audit', + 'nav.smoke_tests': 'Ujian asap', 'nav.players': 'Pemain saya', 'nav.subAgents': 'Sub-ejen', 'nav.myBets': 'Carian pertaruhan', diff --git a/apps/admin/src/i18n/admin-pages-ms.ts b/apps/admin/src/i18n/admin-pages-ms.ts index 5c75f65..57c45e0 100644 --- a/apps/admin/src/i18n/admin-pages-ms.ts +++ b/apps/admin/src/i18n/admin-pages-ms.ts @@ -129,7 +129,13 @@ export const adminPagesMs: Record = { 'agent.credit.decrease': 'Kurang', 'agent.col.credit_type': 'Jenis', 'agent.col.credit_change': 'Perubahan', + 'agent.col.credit_before': 'Sebelum', 'agent.col.credit_after': 'Selepas', + 'agent.credit_tx.filter_agent_ph': 'Nama pengguna ejen', + 'agent.credit_tx.filter_agent_id': 'ID ejen', + 'agent.credit_tx.filter_agent_id_ph': 'ID pengguna', + 'agent.credit_tx.col.operator': 'Operator', + 'agent.credit_tx.view_all': 'Lihat semua lejar kredit', 'agent.col.no_records': 'Tiada rekod', 'agent.btn.confirm_adjust': 'Sahkan', 'agent.field.select_user': 'Pilih pengguna', @@ -597,4 +603,39 @@ export const adminPagesMs: Record = { 'msg.freeze_extra': ' Mereka tidak akan dapat log masuk.', 'msg.freeze_done': '{action} selesai', 'msg.freeze_failed': '{action} gagal', + + 'smoke.intro': 'Jalankan ujian asap automatik: penyelesaian, peraturan pertaruhan, kredit ejen, rebat, sondakan DB, dan integrasi pertaruhan→penyelesaian→dompet.', + 'smoke.intro_rule': 'Kes peraturan menggunakan logik sama seperti ujian unit Jest; tiada data perniagaan ditulis.', + 'smoke.intro_db': 'Kit Database hanya semak sambungan dan konfigurasi (baca sahaja).', + 'smoke.intro_bet_flow': 'Kit aliran pertaruhan mencipta perlawanan/pemain sementara, mengesahkan bekuan/payout/kredit ejen, kemudian membersihkan.', + 'smoke.intro_note': 'Merangkumi kebanyakan regresi UAT; semak rebat secara manual jika perlu.', + 'smoke.field.suites': 'Kit ujian', + 'smoke.ph.suites': 'Pilih kit untuk dijalankan', + 'smoke.btn.run': 'Jalankan ujian', + 'smoke.last_run': 'Jalanan terakhir', + 'smoke.results_title': 'Keputusan kes', + 'smoke.empty': 'Belum dijalankan. Klik Jalankan ujian.', + 'smoke.stat.pass': 'Lulus', + 'smoke.stat.fail': 'Gagal', + 'smoke.stat.total': 'Jumlah', + 'smoke.col.id': 'ID', + 'smoke.col.suite': 'Kit', + 'smoke.col.name': 'Kes', + 'smoke.col.uat': 'UAT', + 'smoke.col.duration': 'Masa', + 'smoke.col.steps': 'Langkah', + 'smoke.col.message': 'Mesej', + 'smoke.no_steps': 'Tiada butiran langkah', + 'smoke.status.PASS': 'Lulus', + 'smoke.status.FAIL': 'Gagal', + 'smoke.status.SKIP': 'Langkau', + 'smoke.msg.all_passed': 'Semua lulus ({n})', + 'smoke.msg.has_failures': '{n} kes gagal', + 'smoke.msg.run_failed': 'Gagal menjalankan ujian', + 'smoke.log_title': 'Log terperinci', + 'smoke.btn.copy_all': 'Salin semua log', + 'smoke.btn.copy_one': 'Salin', + 'smoke.msg.copy_ok': 'Disalin ke papan keratan', + 'smoke.msg.copy_failed': 'Gagal menyalin — pilih log secara manual', + 'audit.action.RUN_SMOKE_TESTS': 'Jalankan ujian asap', }; diff --git a/apps/admin/src/i18n/admin-pages.ts b/apps/admin/src/i18n/admin-pages.ts index badcb33..f2d4344 100644 --- a/apps/admin/src/i18n/admin-pages.ts +++ b/apps/admin/src/i18n/admin-pages.ts @@ -132,7 +132,13 @@ export const adminPagesZh: Record = { 'agent.credit.decrease': '减少', 'agent.col.credit_type': '类型', 'agent.col.credit_change': '变动', + 'agent.col.credit_before': '变动前', 'agent.col.credit_after': '变动后', + 'agent.credit_tx.filter_agent_ph': '代理用户名', + 'agent.credit_tx.filter_agent_id': '代理 ID', + 'agent.credit_tx.filter_agent_id_ph': '用户 ID', + 'agent.credit_tx.col.operator': '操作人', + 'agent.credit_tx.view_all': '查看全部额度流水', 'agent.col.no_records': '暂无记录', 'agent.btn.confirm_adjust': '确认调整', 'agent.field.select_user': '选择用户', @@ -692,6 +698,41 @@ export const adminPagesZh: Record = { 'msg.freeze_extra': '冻结后该账号将无法登录。', 'msg.freeze_done': '已{action}', 'msg.freeze_failed': '{action}失败', + + 'smoke.intro': '在后台一键运行自动化冒烟测试,覆盖结算引擎、下注规则、代理额度逻辑、返水规则、数据库探针,以及下注→结算→钱包全链路集成用例。', + 'smoke.intro_rule': '规则类用例与 Jest 单元测试同源逻辑,不写入业务数据。', + 'smoke.intro_db': '「数据库」套件仅做连接与配置探针(只读)。', + 'smoke.intro_bet_flow': '「下注结算链路」套件会创建临时赛事/玩家并自动清理,验证冻结、派彩与代理额度。', + 'smoke.intro_note': '可替代大部分 UAT 手工回归;发返水等仍建议抽样人工确认。', + 'smoke.field.suites': '测试套件', + 'smoke.ph.suites': '选择要运行的套件', + 'smoke.btn.run': '运行测试', + 'smoke.last_run': '最近一次运行', + 'smoke.results_title': '用例结果', + 'smoke.empty': '尚未运行测试,请点击「运行测试」。', + 'smoke.stat.pass': '通过', + 'smoke.stat.fail': '失败', + 'smoke.stat.total': '合计', + 'smoke.col.id': '编号', + 'smoke.col.suite': '套件', + 'smoke.col.name': '用例', + 'smoke.col.uat': 'UAT', + 'smoke.col.duration': '耗时', + 'smoke.col.steps': '步骤', + 'smoke.col.message': '说明', + 'smoke.no_steps': '无步骤明细', + 'smoke.status.PASS': '通过', + 'smoke.status.FAIL': '失败', + 'smoke.status.SKIP': '跳过', + 'smoke.msg.all_passed': '全部通过({n} 项)', + 'smoke.msg.has_failures': '有 {n} 项失败,请查看明细', + 'smoke.msg.run_failed': '测试运行失败', + 'smoke.log_title': '详细日志', + 'smoke.btn.copy_all': '复制全部日志', + 'smoke.btn.copy_one': '复制', + 'smoke.msg.copy_ok': '已复制到剪贴板', + 'smoke.msg.copy_failed': '复制失败,请手动选中日志复制', + 'audit.action.RUN_SMOKE_TESTS': '运行自动化测试', }; export const adminPagesEn: Record = { @@ -827,7 +868,13 @@ export const adminPagesEn: Record = { 'agent.credit.decrease': 'Decrease', 'agent.col.credit_type': 'Type', 'agent.col.credit_change': 'Change', + 'agent.col.credit_before': 'Before', 'agent.col.credit_after': 'After', + 'agent.credit_tx.filter_agent_ph': 'Agent username', + 'agent.credit_tx.filter_agent_id': 'Agent ID', + 'agent.credit_tx.filter_agent_id_ph': 'User ID', + 'agent.credit_tx.col.operator': 'Operator', + 'agent.credit_tx.view_all': 'View all credit ledger', 'agent.col.no_records': 'No records', 'agent.btn.confirm_adjust': 'Confirm', 'agent.field.select_user': 'Select user', @@ -1388,4 +1435,39 @@ export const adminPagesEn: Record = { 'msg.freeze_extra': ' They will not be able to sign in.', 'msg.freeze_done': '{action} completed', 'msg.freeze_failed': '{action} failed', + + 'smoke.intro': 'Run automated smoke tests from the admin console: settlement, betting rules, agent credit logic, cashback rules, read-only DB probes, and bet→settle→wallet integration.', + 'smoke.intro_rule': 'Rule cases reuse the same logic as Jest unit tests; no business data is written.', + 'smoke.intro_db': 'The Database suite only checks connectivity and config (read-only).', + 'smoke.intro_bet_flow': 'The Bet-flow suite creates temporary matches/players, verifies freeze/payout/agent credit, then cleans up.', + 'smoke.intro_note': 'Covers most UAT regression; spot-check cashback payout manually if needed.', + 'smoke.field.suites': 'Suites', + 'smoke.ph.suites': 'Select suites to run', + 'smoke.btn.run': 'Run tests', + 'smoke.last_run': 'Last run', + 'smoke.results_title': 'Case results', + 'smoke.empty': 'No run yet. Click Run tests.', + 'smoke.stat.pass': 'Pass', + 'smoke.stat.fail': 'Fail', + 'smoke.stat.total': 'Total', + 'smoke.col.id': 'ID', + 'smoke.col.suite': 'Suite', + 'smoke.col.name': 'Case', + 'smoke.col.uat': 'UAT', + 'smoke.col.duration': 'Duration', + 'smoke.col.steps': 'Steps', + 'smoke.col.message': 'Message', + 'smoke.no_steps': 'No step details', + 'smoke.status.PASS': 'Pass', + 'smoke.status.FAIL': 'Fail', + 'smoke.status.SKIP': 'Skip', + 'smoke.msg.all_passed': 'All passed ({n})', + 'smoke.msg.has_failures': '{n} case(s) failed — see details', + 'smoke.msg.run_failed': 'Failed to run tests', + 'smoke.log_title': 'Detailed log', + 'smoke.btn.copy_all': 'Copy full log', + 'smoke.btn.copy_one': 'Copy', + 'smoke.msg.copy_ok': 'Copied to clipboard', + 'smoke.msg.copy_failed': 'Copy failed — select the log manually', + 'audit.action.RUN_SMOKE_TESTS': 'Run smoke tests', }; diff --git a/apps/admin/src/layouts/ManageLayout.vue b/apps/admin/src/layouts/ManageLayout.vue index fa10123..9203f01 100644 --- a/apps/admin/src/layouts/ManageLayout.vue +++ b/apps/admin/src/layouts/ManageLayout.vue @@ -18,10 +18,12 @@ const adminMenus = computed(() => [ { path: '/', label: t('nav.dashboard') }, { path: '/matches', label: t('nav.matches'), matchPrefix: true }, { path: '/users', label: t('nav.agents_players') }, + { path: '/agent-credit-transactions', label: t('nav.credit_transactions') }, { path: '/cashback', label: t('nav.cashback') }, { path: '/bets', label: t('nav.bets') }, { path: '/contents', label: t('nav.contents') }, { path: '/audit', label: t('nav.audit') }, + { path: '/smoke-tests', label: t('nav.smoke_tests') }, ]); const agentMenus = computed(() => [ @@ -60,8 +62,11 @@ const roleLabel = computed(() => { return t('role.agent'); }); +const currentUser = computed(() => auth.user.value); +const isAdminPortal = computed(() => auth.isAdmin.value); + const userInitial = computed(() => - (auth.user?.username ?? '').charAt(0).toUpperCase() + (currentUser.value?.username ?? '').charAt(0).toUpperCase() ); function syncMobileNav() { @@ -175,12 +180,12 @@ watch(() => route.path, () => {
{{ userInitial }}
-
{{ auth.isAdmin ? t('portal.admin') : t('portal.agent') }}
+
{{ isAdminPortal ? t('portal.admin') : t('portal.agent') }}
diff --git a/apps/admin/src/router/index.ts b/apps/admin/src/router/index.ts index 1a5647c..1e9ccd3 100644 --- a/apps/admin/src/router/index.ts +++ b/apps/admin/src/router/index.ts @@ -17,6 +17,11 @@ const router = createRouter({ component: () => import('../views/AgentManager.vue'), meta: { adminOnly: true }, }, + { + path: 'agent-credit-transactions', + component: () => import('../views/AgentCreditTransactions.vue'), + meta: { adminOnly: true }, + }, { path: 'agents', redirect: '/users', @@ -76,6 +81,16 @@ const router = createRouter({ component: () => import('../views/Audit.vue'), meta: { adminOnly: true }, }, + { + path: 'smoke-tests', + component: () => import('../views/SmokeTests.vue'), + meta: { adminOnly: true }, + }, + { + path: 'smoke-tests', + component: () => import('../views/SmokeTests.vue'), + meta: { adminOnly: true }, + }, { path: 'my-players', component: () => import('../views/agent/Players.vue'), diff --git a/apps/admin/src/utils/agent-credit-context.ts b/apps/admin/src/utils/agent-credit-context.ts index e4ad848..3449144 100644 --- a/apps/admin/src/utils/agent-credit-context.ts +++ b/apps/admin/src/utils/agent-credit-context.ts @@ -25,8 +25,9 @@ function dec(value: string | number | null | undefined): number { export function snapshotFromAgentRow( row: Pick< AgentRow | AgentDetail | AgentSubAgentRow, - 'username' | 'creditLimit' | 'usedCredit' | 'availableCredit' | 'level' + 'username' | 'creditLimit' | 'usedCredit' | 'availableCredit' > & { + level?: number; directPlayerLiability?: string; childAgentExposure?: string; }, diff --git a/apps/admin/src/views/AgentCreditTransactions.vue b/apps/admin/src/views/AgentCreditTransactions.vue new file mode 100644 index 0000000..7bbfe4f --- /dev/null +++ b/apps/admin/src/views/AgentCreditTransactions.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/apps/admin/src/views/AgentManager.vue b/apps/admin/src/views/AgentManager.vue index bae5764..4686e0e 100644 --- a/apps/admin/src/views/AgentManager.vue +++ b/apps/admin/src/views/AgentManager.vue @@ -19,14 +19,14 @@ import { type PlayerDetail, type PlayerCreateForm, type PlayerEditForm, -} from './user-form.ts'; +} from './user-form'; import { emptyAgentEditForm, editFormFromAgentDetail, type AgentRow, type AgentDetail, type AgentEditForm, -} from './agent-form.ts'; +} from './agent-form'; import { subAgentAccountStatus } from './agent/agent-sub-agent-form'; import { formatAmount, @@ -994,8 +994,8 @@ function creditTypeLabel(type: string) { :expand-row-keys="subAgentExpandedKeys" :row-class-name="expandableTableRowClassName" class="inner-table expandable-table" - @expand-change="(sub, rows) => onSubAgentExpand(row.userId, sub, rows)" - @row-click="(sub, col, e) => onSubAgentRowClick(row.userId, sub, col, e)" + @expand-change="(sub: AgentRow, rows: AgentRow[]) => onSubAgentExpand(row.userId, sub, rows)" + @row-click="(sub: AgentRow, col: unknown, e: MouseEvent) => onSubAgentRowClick(row.userId, sub, col, e)" > @@ -1469,7 +1469,17 @@ function creditTypeLabel(type: string) { {{ formatTime(agentDetail.createdAt) }} -
{{ t('agent.section.credit_log') }}
+
+ {{ t('agent.section.credit_log') }} + + {{ t('agent.credit_tx.view_all') }} + +
@@ -1627,6 +1637,13 @@ function creditTypeLabel(type: string) { color: #666; margin-bottom: 8px; } + +.section-title--row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} diff --git a/apps/admin/src/views/Users.vue b/apps/admin/src/views/Users.vue index 07f9706..c7f96a6 100644 --- a/apps/admin/src/views/Users.vue +++ b/apps/admin/src/views/Users.vue @@ -18,7 +18,7 @@ import { type PlayerDetail, type PlayerCreateForm, type PlayerEditForm, -} from './user-form.ts'; +} from './user-form'; import { formatAmount, formatAmountFull, diff --git a/apps/admin/src/views/agent/Players.vue b/apps/admin/src/views/agent/Players.vue index 12eb9d9..64dd471 100644 --- a/apps/admin/src/views/agent/Players.vue +++ b/apps/admin/src/views/agent/Players.vue @@ -472,7 +472,7 @@ function openCreditSub(row: AgentSubAgentRow) { creditContext.value = { target: snapshotFromAgentRow(row), parent: snapshotFromAgentRow({ - username: auth.user?.username ?? t('credit.context.acting_agent'), + username: auth.user.value?.username ?? t('credit.context.acting_agent'), creditLimit: String(profile.value.creditLimit ?? 0), usedCredit: String(profile.value.usedCredit ?? 0), availableCredit: String(profile.value.availableCredit ?? 0), diff --git a/apps/admin/src/views/agent/SubAgents.vue b/apps/admin/src/views/agent/SubAgents.vue index 41da61e..f5ac142 100644 --- a/apps/admin/src/views/agent/SubAgents.vue +++ b/apps/admin/src/views/agent/SubAgents.vue @@ -114,7 +114,7 @@ function openCredit(row: AgentSubAgentRow) { creditContext.value = { target: snapshotFromAgentRow(row), parent: snapshotFromAgentRow({ - username: auth.user?.username ?? t('credit.context.acting_agent'), + username: auth.user.value?.username ?? t('credit.context.acting_agent'), creditLimit: String(profile.value.creditLimit ?? 0), usedCredit: String(profile.value.usedCredit ?? 0), availableCredit: String(profile.value.availableCredit ?? 0), diff --git a/apps/admin/src/views/matches/LeagueMatchesPanel.vue b/apps/admin/src/views/matches/LeagueMatchesPanel.vue index 20e6db1..a996f5c 100644 --- a/apps/admin/src/views/matches/LeagueMatchesPanel.vue +++ b/apps/admin/src/views/matches/LeagueMatchesPanel.vue @@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'; import { ElMessage, ElMessageBox } from 'element-plus'; import { useAdminLocale } from '../../composables/useAdminLocale'; import api from '../../api'; -import { ensureLeagueExpanded } from '../../utils/matchesListState.ts'; +import { ensureLeagueExpanded } from '../../utils/matchesListState'; import { formatAmount } from '../../utils/format-amount'; const props = defineProps<{ leagueId: string; diff --git a/apps/admin/src/views/matches/MatchEventEditor.vue b/apps/admin/src/views/matches/MatchEventEditor.vue index 66be8c4..70a84f4 100644 --- a/apps/admin/src/views/matches/MatchEventEditor.vue +++ b/apps/admin/src/views/matches/MatchEventEditor.vue @@ -13,7 +13,7 @@ import { formFromDetail, type AdminMatchDetail, type MatchCreateForm, -} from '../match-form.ts'; +} from '../match-form'; import AdminSubNav from '../../components/AdminSubNav.vue'; const route = useRoute(); diff --git a/apps/admin/src/views/matches/MatchMarketsPage.vue b/apps/admin/src/views/matches/MatchMarketsPage.vue index 3fd2671..fe0d360 100644 --- a/apps/admin/src/views/matches/MatchMarketsPage.vue +++ b/apps/admin/src/views/matches/MatchMarketsPage.vue @@ -5,7 +5,7 @@ import { ElMessage } from 'element-plus'; import { useAdminLocale } from '../../composables/useAdminLocale'; import api from '../../api'; import MatchMarketsPanel from './MatchMarketsPanel.vue'; -import type { AdminMatchDetail } from '../match-form.ts'; +import type { AdminMatchDetail } from '../match-form'; import AdminSubNav from '../../components/AdminSubNav.vue'; const route = useRoute(); diff --git a/apps/admin/src/views/matches/MatchMarketsPanel.vue b/apps/admin/src/views/matches/MatchMarketsPanel.vue index d3f0a81..1558c89 100644 --- a/apps/admin/src/views/matches/MatchMarketsPanel.vue +++ b/apps/admin/src/views/matches/MatchMarketsPanel.vue @@ -3,9 +3,9 @@ import { ref, watch } from 'vue'; import { ElMessage } from 'element-plus'; import { useAdminLocale } from '../../composables/useAdminLocale'; import api from '../../api'; -import type { AdminMatchDetail } from '../match-form.ts'; -import { defaultSelectionName } from '../../utils/selectionDefaults.ts'; -import { adminSelectionLabel } from '../../utils/adminSelectionLabel.ts'; +import type { AdminMatchDetail } from '../match-form'; +import { defaultSelectionName } from '../../utils/selectionDefaults'; +import { adminSelectionLabel } from '../../utils/adminSelectionLabel'; const props = defineProps<{ matchId: string; diff --git a/apps/admin/src/vite-env.d.ts b/apps/admin/src/vite-env.d.ts index aad3656..149f067 100644 --- a/apps/admin/src/vite-env.d.ts +++ b/apps/admin/src/vite-env.d.ts @@ -1,3 +1,5 @@ +/// + declare module '*.vue' { import type { DefineComponent } from 'vue'; const component: DefineComponent; diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index f6a0a77..9db86b4 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -2,6 +2,7 @@ import { BadRequestException, Controller, Delete, + ForbiddenException, Get, Post, Put, @@ -9,13 +10,20 @@ import { Body, Param, Query, + UploadedFile, UseGuards, + UseInterceptors, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { randomUUID } from 'crypto'; +import { mkdir, writeFile } from 'fs/promises'; +import { extname, join } from 'path'; import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards'; import { ContentService } from '../../domains/operations/content/content.service'; import { CurrentUser, RequirePermissions } from '../../shared/common/decorators'; import { jsonResponse } from '../../shared/common/filters'; +import { getUploadRoot } from '../../shared/uploads/upload-paths'; import { UsersService } from '../../domains/identity/users.service'; import { AgentsService } from '../../domains/agent/agents.service'; import { WalletService } from '../../domains/ledger/wallet.service'; @@ -33,6 +41,7 @@ import { AdminDashboardService } from './admin-dashboard.service'; import { SystemConfigService } from '../../shared/config/system-config.service'; import { P } from './admin-permissions'; import { DatabaseResetService } from '../../infrastructure/database/database-reset.service'; +import { SmokeTestService } from '../../domains/operations/smoke-tests/smoke-test.service'; import { IsString, IsNumber, @@ -47,6 +56,77 @@ import { } from 'class-validator'; import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types'; +const UPLOAD_CATEGORIES = ['banners', 'teams', 'contents'] as const; +type UploadCategory = (typeof UPLOAD_CATEGORIES)[number]; + +const IMAGE_MIME_EXT: Record = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/webp': '.webp', + 'image/gif': '.gif', + 'image/svg+xml': '.svg', +}; + +type UploadedImage = { + originalname: string; + mimetype: string; + buffer: Buffer; + size: number; +}; + +type AdminUploadUser = { + role?: string; + permissions?: string[]; +}; + +function uploadCategory(value?: string): UploadCategory { + const category = (value || 'contents').trim(); + if (UPLOAD_CATEGORIES.includes(category as UploadCategory)) { + return category as UploadCategory; + } + throw new BadRequestException('Unsupported upload category'); +} + +function requiredUploadPermission(category: UploadCategory) { + return category === 'teams' ? P.matches : P.content; +} + +function assertUploadPermission(user: AdminUploadUser | undefined, category: UploadCategory) { + if (user?.role === 'SUPER_ADMIN') return; + const required = requiredUploadPermission(category); + if (!user?.permissions?.includes(required)) { + throw new ForbiddenException('Insufficient permissions'); + } +} + +function assertImageFile(file: UploadedImage | undefined): asserts file is UploadedImage { + if (!file?.buffer?.length) { + throw new BadRequestException('Image file is required'); + } + if (!IMAGE_MIME_EXT[file.mimetype]) { + throw new BadRequestException('Only PNG, JPG, WEBP, GIF or SVG images are allowed'); + } + if (file.mimetype === 'image/svg+xml') { + const sample = file.buffer.toString('utf8', 0, Math.min(file.buffer.length, 8192)).toLowerCase(); + if (sample.includes(' u.id); + if (!agentUserIds.length) { + return { items: [], total: 0, page, pageSize }; + } + if (params.agentId) { + if (!agentUserIds.some((id) => id === params.agentId)) { + return { items: [], total: 0, page, pageSize }; + } + where.agentId = params.agentId; + } else { + where.agentId = { in: agentUserIds }; + } + } else if (params.agentId) { + where.agentId = params.agentId; + } + + const [rows, total] = await Promise.all([ + this.prisma.agentCreditTransaction.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + }), + this.prisma.agentCreditTransaction.count({ where }), + ]); + + const agentIds = [...new Set(rows.map((r) => r.agentId))]; + const operatorIds = [ + ...new Set(rows.map((r) => r.operatorId).filter((id): id is bigint => id != null)), + ]; + + const [agentUsers, operators] = await Promise.all([ + agentIds.length + ? this.prisma.user.findMany({ + where: { id: { in: agentIds } }, + select: { id: true, username: true }, + }) + : [], + operatorIds.length + ? this.prisma.user.findMany({ + where: { id: { in: operatorIds } }, + select: { id: true, username: true }, + }) + : [], + ]); + + const agentNameById = new Map(agentUsers.map((u) => [u.id.toString(), u.username])); + const operatorNameById = new Map(operators.map((u) => [u.id.toString(), u.username])); + + return { + items: rows.map((row) => ({ + id: row.id.toString(), + agentId: row.agentId.toString(), + agentUsername: agentNameById.get(row.agentId.toString()) ?? null, + transactionType: row.transactionType, + amount: row.amount.toString(), + creditBefore: row.creditBefore.toString(), + creditAfter: row.creditAfter.toString(), + referenceType: row.referenceType, + referenceId: row.referenceId, + operatorId: row.operatorId?.toString() ?? null, + operatorUsername: row.operatorId + ? (operatorNameById.get(row.operatorId.toString()) ?? null) + : null, + requestId: row.requestId, + remark: row.remark, + createdAt: row.createdAt, + })), + total, + page, + pageSize, + }; + } + async updateAgentAdmin( agentId: bigint, data: { diff --git a/apps/api/src/domains/operations/cashback/cashback.service.ts b/apps/api/src/domains/operations/cashback/cashback.service.ts index 1cda4b5..81605cb 100644 --- a/apps/api/src/domains/operations/cashback/cashback.service.ts +++ b/apps/api/src/domains/operations/cashback/cashback.service.ts @@ -474,10 +474,33 @@ export class CashbackService { } async getUserCashbacks(userId: bigint) { - return this.prisma.cashbackItem.findMany({ - where: { userId }, - include: { batch: true }, + const items = await this.prisma.cashbackItem.findMany({ + where: { userId, batch: { status: 'CONFIRMED' } }, + include: { + batch: { + select: { + batchNo: true, + periodStart: true, + periodEnd: true, + confirmedAt: true, + status: true, + }, + }, + }, orderBy: { createdAt: 'desc' }, }); + + return items.map((item) => ({ + id: item.id.toString(), + batchNo: item.batch.batchNo, + periodStart: item.batch.periodStart, + periodEnd: item.batch.periodEnd, + confirmedAt: item.batch.confirmedAt, + effectiveStake: item.effectiveStake.toString(), + betCount: item.betCount, + rate: item.rate.toString(), + amount: item.amount.toString(), + createdAt: item.createdAt, + })); } } diff --git a/apps/api/src/domains/operations/smoke-tests/smoke-test.bet-flow-probes.ts b/apps/api/src/domains/operations/smoke-tests/smoke-test.bet-flow-probes.ts new file mode 100644 index 0000000..4074719 --- /dev/null +++ b/apps/api/src/domains/operations/smoke-tests/smoke-test.bet-flow-probes.ts @@ -0,0 +1,254 @@ +import { Decimal } from '@prisma/client/runtime/library'; +import type { AgentsService } from '../../agent/agents.service'; +import type { BetsService } from '../../betting/bets.service'; +import type { SettlementService } from '../../settlement/settlement.service'; +import type { WalletService } from '../../ledger/wallet.service'; +import type { PrismaService } from '../../../shared/prisma/prisma.service'; +import { expectEqual, expectThrows, expectTrue } from './smoke-test.helpers'; +import type { SmokeTestCaseDef } from './smoke-test.cases'; +import { + BetFlowFixtureIds, + createBetFlowFixture, + teardownBetFlowFixture, +} from './smoke-test.bet-flow.fixture'; + +export const BET_FLOW_PROBE_COUNT = 5; + +export type BetFlowProbeDeps = { + prisma: PrismaService; + wallet: WalletService; + bets: BetsService; + settlement: SettlementService; + agents: AgentsService; +}; + +async function confirmMatchSettlement( + deps: BetFlowProbeDeps, + fx: BetFlowFixtureIds, + score: { htHome: number; htAway: number; ftHome: number; ftAway: number }, +) { + await deps.settlement.recordScore( + fx.matchId, + score.htHome, + score.htAway, + score.ftHome, + score.ftAway, + fx.operatorId, + ); + const preview = await deps.settlement.previewSettlement(fx.matchId, fx.operatorId); + await deps.settlement.confirmSettlement(preview.batch.id, fx.operatorId); +} + +export function createBetFlowProbes(deps: BetFlowProbeDeps): SmokeTestCaseDef[] { + return [ + { + id: 'BF001', + suite: 'bet-flow', + name: '单关赢:冻结后结算派彩', + uatRef: '8/14', + description: '下注 100@2.0 主胜,比分 2-1,余额 1000→1100', + run: async () => { + const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 }); + try { + const bet = await deps.bets.placeSingleBet( + fx.playerId, + null, + fx.homeSelectionId, + fx.homeOddsVersion, + 100, + `smoke-win-${fx.runId}`, + ); + + expectEqual('bet.status', bet.status, 'PENDING'); + expectEqual('bet.stake', bet.stake.toString(), '100'); + + let w = await deps.wallet.getWallet(fx.playerId); + expectEqual('available after freeze', w.availableBalance.toString(), '900'); + expectEqual('frozen after freeze', w.frozenBalance.toString(), '100'); + + await confirmMatchSettlement(deps, fx, { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 }); + + const settled = await deps.prisma.bet.findUnique({ where: { id: bet.id } }); + expectEqual('settled status', settled?.status, 'WON'); + expectEqual('actualReturn', settled?.actualReturn.toString(), '200'); + + w = await deps.wallet.getWallet(fx.playerId); + expectEqual('available after settle', w.availableBalance.toString(), '1100'); + expectEqual('frozen after settle', w.frozenBalance.toString(), '0'); + + const txs = await deps.prisma.walletTransaction.findMany({ + where: { userId: fx.playerId }, + orderBy: { createdAt: 'asc' }, + }); + expectEqual( + 'wallet tx types', + txs.map((t) => t.transactionType).join(','), + 'MANUAL_DEPOSIT,BET_FREEZE,BET_SETTLE_WIN', + ); + } finally { + await teardownBetFlowFixture(deps.prisma, fx); + } + }, + }, + { + id: 'BF002', + suite: 'bet-flow', + name: '单关输:冻结本金被消耗', + uatRef: '14', + description: '和局选项 + 2-1 比分,余额 1000→900', + run: async () => { + const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 }); + try { + const bet = await deps.bets.placeSingleBet( + fx.playerId, + null, + fx.drawSelectionId, + fx.drawOddsVersion, + 100, + `smoke-lose-${fx.runId}`, + ); + + let w = await deps.wallet.getWallet(fx.playerId); + expectEqual('available after freeze', w.availableBalance.toString(), '900'); + expectEqual('frozen after freeze', w.frozenBalance.toString(), '100'); + + await confirmMatchSettlement(deps, fx, { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 }); + + const settled = await deps.prisma.bet.findUnique({ where: { id: bet.id } }); + expectEqual('settled status', settled?.status, 'LOST'); + expectEqual('actualReturn', settled?.actualReturn.toString(), '0'); + + w = await deps.wallet.getWallet(fx.playerId); + expectEqual('available after settle', w.availableBalance.toString(), '900'); + expectEqual('frozen after settle', w.frozenBalance.toString(), '0'); + } finally { + await teardownBetFlowFixture(deps.prisma, fx); + } + }, + }, + { + id: 'BF003', + suite: 'bet-flow', + name: '下注幂等:相同 requestId 不重复扣款', + uatRef: '8', + description: 'userId + requestId 唯一,重复提交返回同一注单', + run: async () => { + const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 500 }); + try { + const requestId = `smoke-idem-${fx.runId}`; + const first = await deps.bets.placeSingleBet( + fx.playerId, + null, + fx.homeSelectionId, + fx.homeOddsVersion, + 50, + requestId, + ); + const second = await deps.bets.placeSingleBet( + fx.playerId, + null, + fx.homeSelectionId, + fx.homeOddsVersion, + 50, + requestId, + ); + + expectEqual('same bet id', second.id.toString(), first.id.toString()); + const count = await deps.prisma.bet.count({ where: { userId: fx.playerId } }); + expectEqual('bet count', count, 1); + + const w = await deps.wallet.getWallet(fx.playerId); + expectEqual('available', w.availableBalance.toString(), '450'); + expectEqual('frozen', w.frozenBalance.toString(), '50'); + } finally { + await teardownBetFlowFixture(deps.prisma, fx); + } + }, + }, + { + id: 'BF004', + suite: 'bet-flow', + name: '余额不足:拒绝下注', + uatRef: '8', + description: '可用 50 时下 100 注单应失败且无冻结', + run: async () => { + const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 50 }); + try { + await expectThrows( + 'placeSingleBet', + async () => { + await deps.bets.placeSingleBet( + fx.playerId, + null, + fx.homeSelectionId, + fx.homeOddsVersion, + 100, + `smoke-insuf-${fx.runId}`, + ); + }, + 'Insufficient balance', + ); + + const count = await deps.prisma.bet.count({ where: { userId: fx.playerId } }); + expectEqual('bet count', count, 0); + + const w = await deps.wallet.getWallet(fx.playerId); + expectEqual('available', w.availableBalance.toString(), '50'); + expectEqual('frozen', w.frozenBalance.toString(), '0'); + } finally { + await teardownBetFlowFixture(deps.prisma, fx); + } + }, + }, + { + id: 'BF005', + suite: 'bet-flow', + name: '代理额度:结算后 usedCredit 同步', + uatRef: '4/14', + description: '玩家输 100 后代理 usedCredit 1000→900', + run: async () => { + const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { + initialBalance: 1000, + withAgent: true, + }); + try { + expectTrue('agent exists', !!fx.agentId); + + await deps.agents.recalculateUsedCredit(fx.agentId!); + let profile = await deps.prisma.agentProfile.findUnique({ + where: { userId: fx.agentId! }, + }); + expectEqual('usedCredit before bet', profile?.usedCredit.toString(), '1000'); + + await deps.bets.placeSingleBet( + fx.playerId, + fx.agentId!, + fx.drawSelectionId, + fx.drawOddsVersion, + 100, + `smoke-agent-${fx.runId}`, + ); + + await confirmMatchSettlement(deps, fx, { htHome: 0, htAway: 0, ftHome: 2, ftAway: 1 }); + + await deps.agents.recalculateUsedCredit(fx.agentId!); + profile = await deps.prisma.agentProfile.findUnique({ where: { userId: fx.agentId! } }); + expectEqual('usedCredit after settle', profile?.usedCredit.toString(), '900'); + + const w = await deps.wallet.getWallet(fx.playerId); + expectEqual('player available', w.availableBalance.toString(), '900'); + expectTrue( + 'directPlayerLiability matches wallet', + new Decimal(profile!.directPlayerLiability).eq(w.availableBalance.add(w.frozenBalance)), + { + liability: profile?.directPlayerLiability.toString(), + wallet: w.availableBalance.add(w.frozenBalance).toString(), + }, + ); + } finally { + await teardownBetFlowFixture(deps.prisma, fx); + } + }, + }, + ]; +} diff --git a/apps/api/src/domains/operations/smoke-tests/smoke-test.bet-flow.fixture.ts b/apps/api/src/domains/operations/smoke-tests/smoke-test.bet-flow.fixture.ts new file mode 100644 index 0000000..946ab0c --- /dev/null +++ b/apps/api/src/domains/operations/smoke-tests/smoke-test.bet-flow.fixture.ts @@ -0,0 +1,215 @@ +import { Decimal } from '@prisma/client/runtime/library'; +import type { PrismaService } from '../../../shared/prisma/prisma.service'; +import type { WalletService } from '../../ledger/wallet.service'; + +export interface BetFlowFixtureIds { + runId: string; + operatorId: bigint; + playerId: bigint; + agentId?: bigint; + matchId: bigint; + marketId: bigint; + homeSelectionId: bigint; + homeOddsVersion: bigint; + drawSelectionId: bigint; + drawOddsVersion: bigint; + awaySelectionId: bigint; + awayOddsVersion: bigint; + leagueId: bigint; + homeTeamId: bigint; + awayTeamId: bigint; +} + +export async function createBetFlowFixture( + prisma: PrismaService, + wallet: WalletService, + opts?: { initialBalance?: number; withAgent?: boolean }, +): Promise { + const runId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + + const operator = await prisma.user.create({ + data: { + username: `smoke_op_${runId}`, + userType: 'ADMIN', + auth: { create: { passwordHash: 'smoke-test' } }, + }, + }); + + const league = await prisma.league.create({ + data: { + code: `SMK_L_${runId}`, + sportType: 'FOOTBALL', + }, + }); + + const homeTeam = await prisma.team.create({ + data: { code: `SMK_H_${runId}`, sportType: 'FOOTBALL' }, + }); + const awayTeam = await prisma.team.create({ + data: { code: `SMK_A_${runId}`, sportType: 'FOOTBALL' }, + }); + + const startTime = new Date(Date.now() + 48 * 60 * 60 * 1000); + const match = await prisma.match.create({ + data: { + sportType: 'FOOTBALL', + leagueId: league.id, + homeTeamId: homeTeam.id, + awayTeamId: awayTeam.id, + startTime, + status: 'PUBLISHED', + publishTime: new Date(), + }, + }); + + const market = await prisma.market.create({ + data: { + matchId: match.id, + marketType: 'FT_1X2', + period: 'FT', + status: 'OPEN', + selections: { + create: [ + { + selectionCode: 'HOME', + selectionName: 'Home', + odds: new Decimal('2.00'), + oddsVersion: BigInt(1), + status: 'OPEN', + sortOrder: 0, + }, + { + selectionCode: 'DRAW', + selectionName: 'Draw', + odds: new Decimal('3.00'), + oddsVersion: BigInt(1), + status: 'OPEN', + sortOrder: 1, + }, + { + selectionCode: 'AWAY', + selectionName: 'Away', + odds: new Decimal('4.00'), + oddsVersion: BigInt(1), + status: 'OPEN', + sortOrder: 2, + }, + ], + }, + }, + include: { selections: true }, + }); + + let agentId: bigint | undefined; + if (opts?.withAgent) { + const agent = await prisma.user.create({ + data: { + username: `smoke_ag_${runId}`, + userType: 'AGENT', + auth: { create: { passwordHash: 'smoke-test' } }, + agentProfile: { + create: { level: 1, creditLimit: new Decimal(100000) }, + }, + }, + }); + await prisma.agentClosure.create({ + data: { ancestorId: agent.id, descendantId: agent.id, depth: 0 }, + }); + agentId = agent.id; + } + + const player = await prisma.user.create({ + data: { + username: `smoke_pl_${runId}`, + userType: 'PLAYER', + parentId: agentId, + auth: { create: { passwordHash: 'smoke-test' } }, + wallet: { create: { currency: 'USD' } }, + }, + }); + + const balance = opts?.initialBalance ?? 1000; + if (balance > 0) { + await wallet.deposit(player.id, balance, operator.id, 'smoke test seed'); + } + + const home = market.selections.find((s) => s.selectionCode === 'HOME')!; + const draw = market.selections.find((s) => s.selectionCode === 'DRAW')!; + const away = market.selections.find((s) => s.selectionCode === 'AWAY')!; + + return { + runId, + operatorId: operator.id, + playerId: player.id, + agentId, + matchId: match.id, + marketId: market.id, + homeSelectionId: home.id, + homeOddsVersion: home.oddsVersion, + drawSelectionId: draw.id, + drawOddsVersion: draw.oddsVersion, + awaySelectionId: away.id, + awayOddsVersion: away.oddsVersion, + leagueId: league.id, + homeTeamId: homeTeam.id, + awayTeamId: awayTeam.id, + }; +} + +export async function teardownBetFlowFixture( + prisma: PrismaService, + fx: BetFlowFixtureIds, +): Promise { + const bets = await prisma.bet.findMany({ + where: { userId: fx.playerId }, + select: { id: true }, + }); + const betIds = bets.map((b) => b.id); + + const batches = await prisma.settlementBatch.findMany({ + where: { matchId: fx.matchId }, + select: { id: true }, + }); + const batchIds = batches.map((b) => b.id); + + if (batchIds.length) { + await prisma.settlementItem.deleteMany({ where: { batchId: { in: batchIds } } }); + await prisma.settlementBatch.deleteMany({ where: { id: { in: batchIds } } }); + } + if (betIds.length) { + await prisma.betSelection.deleteMany({ where: { betId: { in: betIds } } }); + await prisma.bet.deleteMany({ where: { id: { in: betIds } } }); + } + + await prisma.walletTransaction.deleteMany({ where: { userId: fx.playerId } }); + await prisma.wallet.deleteMany({ where: { userId: fx.playerId } }); + await prisma.matchScore.deleteMany({ where: { matchId: fx.matchId } }); + + const markets = await prisma.market.findMany({ + where: { matchId: fx.matchId }, + select: { id: true }, + }); + const marketIds = markets.map((m) => m.id); + if (marketIds.length) { + await prisma.marketSelection.deleteMany({ where: { marketId: { in: marketIds } } }); + await prisma.market.deleteMany({ where: { id: { in: marketIds } } }); + } + + await prisma.match.deleteMany({ where: { id: fx.matchId } }); + + if (fx.agentId) { + await prisma.agentClosure.deleteMany({ + where: { OR: [{ ancestorId: fx.agentId }, { descendantId: fx.agentId }] }, + }); + await prisma.agentProfile.deleteMany({ where: { userId: fx.agentId } }); + await prisma.userAuth.deleteMany({ where: { userId: fx.agentId } }); + await prisma.user.deleteMany({ where: { id: fx.agentId } }); + } + + await prisma.userAuth.deleteMany({ + where: { userId: { in: [fx.playerId, fx.operatorId] } }, + }); + await prisma.user.deleteMany({ where: { id: { in: [fx.playerId, fx.operatorId] } } }); + await prisma.team.deleteMany({ where: { id: { in: [fx.homeTeamId, fx.awayTeamId] } } }); + await prisma.league.deleteMany({ where: { id: fx.leagueId } }); +} diff --git a/apps/api/src/domains/operations/smoke-tests/smoke-test.cases.ts b/apps/api/src/domains/operations/smoke-tests/smoke-test.cases.ts new file mode 100644 index 0000000..6cb7d6a --- /dev/null +++ b/apps/api/src/domains/operations/smoke-tests/smoke-test.cases.ts @@ -0,0 +1,762 @@ +import { Decimal } from '@prisma/client/runtime/library'; +import { + canSelectForParlay, + isQuarterHandicapOrTotal, + PARLAY_MAX_LEGS, + PARLAY_MIN_LEGS, +} from '@thebet365/shared'; +import { BettingLimitsService } from '../../betting/betting-limits.service'; +import { resolveSelectionCode } from '../../settlement/domain/settlement-helpers'; +import { + calculatePayout, + calculateParlayPayout, + settleSelection, + type ScoreInput, +} from '../../settlement/domain/settlement-calculator'; +import { resolveCashbackRateForBet } from '../cashback/cashback-rate.resolver'; +import { expectEqual, expectFalse, expectThrows, expectTrue } from './smoke-test.helpers'; +import type { SmokeTestCaseMeta } from './smoke-test.types'; + +export type SmokeTestRunner = () => void | Promise; + +export type SmokeTestCaseDef = SmokeTestCaseMeta & { + run: SmokeTestRunner; +}; + +const SCORE_2_1: ScoreInput = { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 }; +const SCORE_0_0: ScoreInput = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 }; +const SCORE_1_1: ScoreInput = { htHome: 0, htAway: 0, ftHome: 1, ftAway: 1 }; + +function createBettingLimitsService() { + const prisma = { + systemConfig: { findUnique: async () => null }, + bet: { + aggregate: async () => ({ _sum: { stake: new Decimal(0) } }), + }, + }; + return new BettingLimitsService(prisma as never); +} + +export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [ + // —— Settlement —— + { + id: 'S001', + suite: 'settlement', + name: '全场独赢:主胜/和局', + uatRef: 'S001', + description: '比分 2-1,主胜赢、和局输', + run: () => { + expectEqual( + 'HOME result', + settleSelection({ marketType: 'FT_1X2', selectionCode: 'HOME', score: SCORE_2_1 }), + 'WIN', + { market: 'FT_1X2', selection: 'HOME', score: SCORE_2_1 }, + ); + expectEqual( + 'DRAW result', + settleSelection({ marketType: 'FT_1X2', selectionCode: 'DRAW', score: SCORE_2_1 }), + 'LOSE', + { market: 'FT_1X2', selection: 'DRAW', score: SCORE_2_1 }, + ); + }, + }, + { + id: 'S002', + suite: 'settlement', + name: '全场独赢:和局命中', + uatRef: 'S002', + run: () => { + expectEqual( + 'DRAW result', + settleSelection({ marketType: 'FT_1X2', selectionCode: 'DRAW', score: SCORE_1_1 }), + 'WIN', + { market: 'FT_1X2', selection: 'DRAW', score: SCORE_1_1 }, + ); + }, + }, + { + id: 'S003', + suite: 'settlement', + name: '半场独赢:半场主胜', + uatRef: 'S003', + run: () => { + expectEqual( + 'HT HOME', + settleSelection({ marketType: 'HT_1X2', selectionCode: 'HOME', score: SCORE_2_1 }), + 'WIN', + { market: 'HT_1X2', ht: '1-0', ft: '2-1' }, + ); + }, + }, + { + id: 'S004', + suite: 'settlement', + name: '全场单双:0-0 为双', + uatRef: 'S004', + run: () => { + expectEqual( + 'EVEN', + settleSelection({ marketType: 'FT_ODD_EVEN', selectionCode: 'EVEN', score: SCORE_0_0 }), + 'WIN', + { score: SCORE_0_0, totalGoals: 0 }, + ); + expectEqual( + 'ODD', + settleSelection({ marketType: 'FT_ODD_EVEN', selectionCode: 'ODD', score: SCORE_0_0 }), + 'LOSE', + { score: SCORE_0_0, totalGoals: 0 }, + ); + }, + }, + { + id: 'S005', + suite: 'settlement', + name: '波胆:2-1 精确命中', + uatRef: 'S005', + run: () => { + expectEqual( + 'SCORE_2_1', + settleSelection({ + marketType: 'FT_CORRECT_SCORE', + selectionCode: 'SCORE_2_1', + score: SCORE_2_1, + }), + 'WIN', + { score: SCORE_2_1 }, + ); + }, + }, + { + id: 'S006', + suite: 'settlement', + name: '波胆:主胜其他', + uatRef: 'S006', + run: () => { + const score = { htHome: 2, htAway: 0, ftHome: 5, ftAway: 0 }; + expectEqual( + 'OTHER_HOME', + settleSelection({ + marketType: 'FT_CORRECT_SCORE', + selectionCode: 'OTHER_HOME', + score, + templateScores: ['SCORE_1_0', 'SCORE_2_0'], + }), + 'WIN', + { score, templateScores: ['SCORE_1_0', 'SCORE_2_0'] }, + ); + }, + }, + { + id: 'S008', + suite: 'settlement', + name: '下半场波胆:1-1', + uatRef: 'S008', + run: () => { + expectEqual( + 'SH SCORE_1_1', + settleSelection({ + marketType: 'SH_CORRECT_SCORE', + selectionCode: 'SCORE_1_1', + score: SCORE_2_1, + }), + 'WIN', + { shGoals: '1-1', ft: '2-1' }, + ); + }, + }, + { + id: 'S009', + suite: 'settlement', + name: '让球 -1 全赢 + 派彩', + uatRef: 'S009', + run: () => { + const score = { htHome: 0, htAway: 0, ftHome: 2, ftAway: 0 }; + const result = settleSelection({ + marketType: 'FT_HANDICAP', + selectionCode: 'HOME', + handicapLine: -1, + score, + }); + expectEqual('settlement', result, 'WIN', { line: -1, score }); + expectEqual('payout', calculatePayout(100, 1.85, result).toNumber(), 185, { + stake: 100, + odds: 1.85, + }); + }, + }, + { + id: 'S010', + suite: 'settlement', + name: '让球 -1 走水 + 退本', + uatRef: 'S010', + run: () => { + const score = { htHome: 0, htAway: 0, ftHome: 1, ftAway: 0 }; + const result = settleSelection({ + marketType: 'FT_HANDICAP', + selectionCode: 'HOME', + handicapLine: -1, + score, + }); + expectEqual('settlement', result, 'PUSH', { line: -1, score }); + expectEqual('payout', calculatePayout(100, 1.85, result).toNumber(), 100, { + stake: 100, + odds: 1.85, + }); + }, + }, + { + id: 'S011', + suite: 'settlement', + name: '让球 -0.25 半输', + uatRef: 'S011', + run: () => { + const result = settleSelection({ + marketType: 'FT_HANDICAP', + selectionCode: 'HOME', + handicapLine: -0.25, + score: SCORE_0_0, + }); + expectEqual('settlement', result, 'HALF_LOSE', { line: -0.25, score: SCORE_0_0 }); + expectEqual('payout', calculatePayout(100, 1.85, result).toNumber(), 50, { + stake: 100, + odds: 1.85, + }); + }, + }, + { + id: 'S011B', + suite: 'settlement', + name: '半赢派彩系数', + description: 'HALF_WIN 派彩 = stake + stake*(odds-1)/2', + run: () => { + expectEqual('HALF_WIN payout', calculatePayout(100, 1.85, 'HALF_WIN').toNumber(), 142.5, { + stake: 100, + odds: 1.85, + }); + }, + }, + { + id: 'S012', + suite: 'settlement', + name: '让球 -0.5 全输(0-0)', + uatRef: 'S012', + run: () => { + const result = settleSelection({ + marketType: 'FT_HANDICAP', + selectionCode: 'HOME', + handicapLine: -0.5, + score: SCORE_0_0, + }); + expectEqual('settlement', result, 'LOSE', { line: -0.5, score: SCORE_0_0 }); + expectEqual('payout', calculatePayout(100, 1.85, result).toNumber(), 0, { + stake: 100, + odds: 1.85, + }); + }, + }, + { + id: 'S013', + suite: 'settlement', + name: '大小 2.5:3 球大球赢', + uatRef: 'S013', + run: () => { + expectEqual( + 'OVER 2.5', + settleSelection({ + marketType: 'FT_OVER_UNDER', + selectionCode: 'OVER', + totalLine: 2.5, + score: SCORE_2_1, + }), + 'WIN', + { totalGoals: 3, line: 2.5 }, + ); + }, + }, + { + id: 'S014', + suite: 'settlement', + name: '大小整数盘 2 球走水', + uatRef: 'S014', + run: () => { + const score = { htHome: 1, htAway: 0, ftHome: 1, ftAway: 1 }; + expectEqual( + 'OVER 2 push', + settleSelection({ + marketType: 'FT_OVER_UNDER', + selectionCode: 'OVER', + totalLine: 2, + score, + }), + 'PUSH', + { totalGoals: 2, line: 2 }, + ); + }, + }, + { + id: 'S015', + suite: 'settlement', + name: '0-0 大小 2.5:小球赢', + description: '0-0 下小球赢、大球输', + run: () => { + const under = settleSelection({ + marketType: 'FT_OVER_UNDER', + selectionCode: 'UNDER', + totalLine: 2.5, + score: SCORE_0_0, + }); + const over = settleSelection({ + marketType: 'FT_OVER_UNDER', + selectionCode: 'OVER', + totalLine: 2.5, + score: SCORE_0_0, + }); + expectEqual('UNDER', under, 'WIN', { line: 2.5, score: SCORE_0_0 }); + expectEqual('OVER', over, 'LOSE', { line: 2.5, score: SCORE_0_0 }); + expectEqual('UNDER payout', calculatePayout(100, 1.95, under).toNumber(), 195, { + stake: 100, + odds: 1.95, + }); + }, + }, + { + id: 'S016', + suite: 'settlement', + name: '串关全中派彩', + uatRef: 'S016', + run: () => { + const legs = [ + { odds: 1.8, result: 'WIN' as const }, + { odds: 2.0, result: 'WIN' as const }, + ]; + const { betResult, payout } = calculateParlayPayout(100, legs); + expectEqual('betResult', betResult, 'WON', { stake: 100, legs }); + expectEqual('payout', payout.toNumber(), 360, { stake: 100, legs }); + }, + }, + { + id: 'S017', + suite: 'settlement', + name: '串关一关输:全输', + uatRef: 'S017', + run: () => { + const legs = [ + { odds: 1.8, result: 'WIN' as const }, + { odds: 2.0, result: 'LOSE' as const }, + ]; + const { betResult, payout } = calculateParlayPayout(100, legs); + expectEqual('betResult', betResult, 'LOST', { stake: 100, legs }); + expectEqual('payout', payout.toNumber(), 0, { stake: 100, legs }); + }, + }, + { + id: 'S018', + suite: 'settlement', + name: '串关一关走水:赔率按 1.00', + uatRef: 'S018', + run: () => { + const legs = [ + { odds: 1.8, result: 'WIN' as const }, + { odds: 2.0, result: 'PUSH' as const }, + { odds: 1.9, result: 'WIN' as const }, + ]; + const { betResult, payout } = calculateParlayPayout(100, legs); + expectEqual('betResult', betResult, 'WON', { stake: 100, legs }); + expectEqual('payout', payout.toNumber(), 342, { stake: 100, legs }); + }, + }, + { + id: 'S019', + suite: 'settlement', + name: '串关全走水/作废:退本', + uatRef: 'S019', + run: () => { + const legs = [ + { odds: 1.8, result: 'PUSH' as const }, + { odds: 2.0, result: 'VOID' as const }, + ]; + const { betResult, payout } = calculateParlayPayout(100, legs); + expectEqual('betResult', betResult, 'PUSH', { stake: 100, legs }); + expectEqual('payout', payout.toNumber(), 100, { stake: 100, legs }); + }, + }, + { + id: 'OUT001', + suite: 'settlement', + name: '冠军盘:猜中/猜错', + description: 'OUTRIGHT_WINNER 按 winnerTeamCode 结算', + run: () => { + const input = { winnerTeamCode: 'BRA', selection: 'BRA' }; + expectEqual( + 'BRA wins', + settleSelection({ + marketType: 'OUTRIGHT_WINNER', + selectionCode: 'BRA', + score: SCORE_0_0, + winnerTeamCode: 'BRA', + }), + 'WIN', + input, + ); + expectEqual( + 'ARG loses', + settleSelection({ + marketType: 'OUTRIGHT_WINNER', + selectionCode: 'ARG', + score: SCORE_0_0, + winnerTeamCode: 'BRA', + }), + 'LOSE', + { winnerTeamCode: 'BRA', selection: 'ARG' }, + ); + }, + }, + + // —— Settlement helpers —— + { + id: 'H001', + suite: 'settlement_helpers', + name: '选项代码解析', + description: '数据库 code 优先,中文快照名兜底', + run: () => { + expectEqual('db code', resolveSelectionCode('UNDER', '小 2.5'), 'UNDER', { + code: 'UNDER', + name: '小 2.5', + }); + expectEqual('主胜', resolveSelectionCode(null, '主胜'), 'HOME', { name: '主胜' }); + expectEqual('小 2.5', resolveSelectionCode('', '小 2.5'), 'UNDER', { name: '小 2.5' }); + expectEqual('大 2.5', resolveSelectionCode(undefined, '大 2.5'), 'OVER', { name: '大 2.5' }); + expectEqual('主 -0.5', resolveSelectionCode(null, '主 -0.5'), 'HOME', { name: '主 -0.5' }); + }, + }, + + // —— Betting rules —— + { + id: 'B003', + suite: 'betting', + name: '赔率版本不一致应拒绝', + uatRef: 'B003', + run: () => { + const submitted = BigInt(1); + const current = BigInt(2); + expectTrue('version mismatch', submitted !== current, { submitted: '1', current: '2' }); + }, + }, + { + id: 'B006', + suite: 'betting', + name: '串关 2-5 场范围常量', + uatRef: 'B006', + run: () => { + expectEqual('PARLAY_MIN_LEGS', PARLAY_MIN_LEGS, 2); + expectEqual('PARLAY_MAX_LEGS', PARLAY_MAX_LEGS, 5); + expectTrue('2 legs ok', 2 >= PARLAY_MIN_LEGS && 2 <= PARLAY_MAX_LEGS); + expectTrue('5 legs ok', 5 >= PARLAY_MIN_LEGS && 5 <= PARLAY_MAX_LEGS); + expectTrue('6 legs blocked', 6 > PARLAY_MAX_LEGS); + expectTrue('1 leg blocked', 1 < PARLAY_MIN_LEGS); + }, + }, + { + id: 'B008', + suite: 'betting', + name: '四分之一盘不可串关', + uatRef: 'B008', + run: () => { + expectTrue('-0.25 is quarter', isQuarterHandicapOrTotal(-0.25), { line: -0.25 }); + expectFalse('2.5 not quarter', isQuarterHandicapOrTotal(2.5), { line: 2.5 }); + expectFalse('-1 not quarter', isQuarterHandicapOrTotal(-1), { line: -1 }); + const check = canSelectForParlay({ + marketType: 'FT_HANDICAP', + lineValue: -0.25, + }); + expectFalse('blocked', check.ok, { line: -0.25 }, check.ok ? '' : (check as { reason: string }).reason); + if (!check.ok) { + expectEqual('reason', check.reason, 'QUARTER_LINE', { line: -0.25 }); + } + }, + }, + { + id: 'B009', + suite: 'betting', + name: '串关超过 5 场应拒绝', + uatRef: 'B009', + run: () => { + expectTrue('6 > max', 6 > PARLAY_MAX_LEGS, { legs: 6, max: PARLAY_MAX_LEGS }); + }, + }, + { + id: 'B010', + suite: 'betting', + name: '冠军盘不可串关', + uatRef: 'B010', + run: () => { + const check = canSelectForParlay({ + marketType: 'OUTRIGHT_WINNER', + isOutright: true, + }); + expectFalse('blocked', check.ok, { marketType: 'OUTRIGHT_WINNER' }); + if (!check.ok) { + expectEqual('reason', check.reason, 'OUTRIGHT', { marketType: 'OUTRIGHT_WINNER' }); + } + }, + }, + { + id: 'B011', + suite: 'betting', + name: '常规让球盘可串关', + run: () => { + const check = canSelectForParlay({ + marketType: 'FT_HANDICAP', + lineValue: -0.5, + }); + expectTrue('allowed', check.ok, { marketType: 'FT_HANDICAP', line: -0.5 }); + }, + }, + { + id: 'B012', + suite: 'betting', + name: '显式禁止串关的盘口', + run: () => { + const check = canSelectForParlay({ + marketType: 'FT_1X2', + allowParlay: false, + }); + expectFalse('blocked', check.ok, { marketType: 'FT_1X2', allowParlay: false }); + if (!check.ok) { + expectEqual('reason', check.reason, 'NOT_ALLOWED', { allowParlay: false }); + } + }, + }, + + // —— Betting limits —— + { + id: 'BL001', + suite: 'betting_limits', + name: '低于最小投注额拒绝', + run: async () => { + const service = createBettingLimitsService(); + await expectThrows( + 'min stake', + () => + service.validateBet({ + userId: BigInt(1), + betType: 'SINGLE', + stake: 0.5, + potentialReturn: new Decimal(1), + }), + 'Minimum stake is 1', + { stake: 0.5, minStake: 1 }, + ); + }, + }, + { + id: 'BL002', + suite: 'betting_limits', + name: '超过单关最大投注额拒绝', + run: async () => { + const service = createBettingLimitsService(); + await expectThrows( + 'max stake', + () => + service.validateBet({ + userId: BigInt(1), + betType: 'SINGLE', + stake: 60000, + potentialReturn: new Decimal(70000), + }), + 'Maximum stake is 50000', + { stake: 60000, maxStakeSingle: 50000 }, + ); + }, + }, + { + id: 'BL003', + suite: 'betting_limits', + name: '超过最高派彩拒绝', + run: async () => { + const service = createBettingLimitsService(); + await expectThrows( + 'max payout', + () => + service.validateBet({ + userId: BigInt(1), + betType: 'SINGLE', + stake: 100, + potentialReturn: new Decimal(600000), + }), + 'Potential return exceeds limit', + { potentialReturn: 600000, maxPayoutSingle: 500000 }, + ); + }, + }, + + // —— Agent credit —— + { + id: 'A001', + suite: 'agent_credit', + name: '代理上分占用额度', + uatRef: 'A001', + run: () => { + const creditLimit = 10000; + const usedCredit = 1000 + 500; + const available = creditLimit - usedCredit; + expectEqual('available credit', available, 8500, { + creditLimit, + usedCredit, + formula: 'creditLimit - usedCredit', + }); + }, + }, + { + id: 'A002', + suite: 'agent_credit', + name: '下注冻结:总额不变、可用减少', + uatRef: 'A002', + run: () => { + const balance = 1000; + const frozenBefore = 0; + const stake = 100; + const totalBefore = balance + frozenBefore; + const balanceAfter = balance - stake; + const frozenAfter = frozenBefore + stake; + const totalAfter = balanceAfter + frozenAfter; + expectEqual('total unchanged', totalAfter, totalBefore, { + before: { balance, frozen: frozenBefore }, + after: { balance: balanceAfter, frozen: frozenAfter }, + }); + expectEqual('available', balanceAfter, 900, { stake }); + }, + }, + { + id: 'A003', + suite: 'agent_credit', + name: '结算派彩:可用余额增加', + description: '赢单释放本金并增加盈利', + run: () => { + const balance = 900; + const frozen = 100; + const payout = 185; + const balanceAfter = balance + payout; + const frozenAfter = frozen - 100; + expectEqual('balance after win', balanceAfter, 1085, { payout, before: 900 }); + expectEqual('frozen released', frozenAfter, 0, { frozenBefore: 100, stake: 100 }); + }, + }, + { + id: 'A005', + suite: 'agent_credit', + name: '额度为负禁止继续放款', + uatRef: 'A005', + run: () => { + const creditLimit = 1000; + const usedCredit = 1200; + const available = creditLimit - usedCredit; + expectTrue('negative available', available < 0, { creditLimit, usedCredit, available }); + expectFalse('deposit allowed', available > 0, { available }); + }, + }, + + // —— Cashback —— + { + id: 'CB001', + suite: 'cashback', + name: '返水比例:玩家规则优先', + run: () => { + const rate = resolveCashbackRateForBet({ + userId: BigInt(100), + agentId: BigInt(200), + marketTypes: ['FT_1X2'], + agentDefaultRate: new Decimal('0.01'), + rules: [ + { + targetType: 'USER', + targetId: BigInt(100), + rate: new Decimal('0.03'), + marketType: null, + }, + ], + }); + expectEqual('rate', rate.toString(), '0.03', { + userRule: '0.03', + agentDefault: '0.01', + }); + }, + }, + { + id: 'CB002', + suite: 'cashback', + name: '返水比例:玩法专属规则', + run: () => { + const rate = resolveCashbackRateForBet({ + userId: BigInt(100), + agentId: BigInt(200), + marketTypes: ['FT_HANDICAP'], + agentDefaultRate: new Decimal('0.01'), + rules: [ + { + targetType: 'GLOBAL', + targetId: null, + rate: new Decimal('0.005'), + marketType: 'FT_HANDICAP', + }, + ], + }); + expectEqual('rate', rate.toString(), '0.005', { marketType: 'FT_HANDICAP' }); + }, + }, + { + id: 'CB003', + suite: 'cashback', + name: '返水比例:无规则用代理默认', + run: () => { + const rate = resolveCashbackRateForBet({ + userId: BigInt(100), + agentId: BigInt(200), + marketTypes: ['FT_1X2'], + agentDefaultRate: new Decimal('0.02'), + rules: [], + }); + expectEqual('rate', rate.toString(), '0.02', { agentDefault: '0.02', rules: [] }); + }, + }, + { + id: 'CB004', + suite: 'cashback', + name: '返水比例:玩法不匹配回退默认', + run: () => { + const rate = resolveCashbackRateForBet({ + userId: BigInt(100), + agentId: BigInt(200), + marketTypes: ['FT_1X2'], + agentDefaultRate: new Decimal('0.01'), + rules: [ + { + targetType: 'GLOBAL', + targetId: null, + rate: new Decimal('0.005'), + marketType: 'FT_HANDICAP', + }, + ], + }); + expectEqual('rate', rate.toString(), '0.01', { + betMarket: 'FT_1X2', + ruleMarket: 'FT_HANDICAP', + agentDefault: '0.01', + }); + }, + }, +]; + +export const SMOKE_SUITE_META: Record = { + settlement: { name: '结算引擎', description: '独赢、波胆、让球、大小、串关、冠军盘' }, + settlement_helpers: { name: '结算辅助', description: '选项代码与中文快照名映射' }, + betting: { name: '下注规则', description: '串关限制、赔率版本、四分之一盘' }, + betting_limits: { name: '投注限额', description: '最小/最大投注与派彩上限校验' }, + agent_credit: { name: '代理额度', description: '额度占用、冻结与放款规则(纯逻辑)' }, + cashback: { name: '返水规则', description: '返水比例优先级与回退' }, + database: { name: '数据库', description: '连接、种子账号、赛事盘口与配置探针(只读)' }, + 'bet-flow': { + name: '下注结算链路', + description: '真实 DB:下注→冻结→录分→结算→钱包/代理额度(临时数据自动清理)', + }, +}; diff --git a/apps/api/src/domains/operations/smoke-tests/smoke-test.db-probes.ts b/apps/api/src/domains/operations/smoke-tests/smoke-test.db-probes.ts new file mode 100644 index 0000000..0ebb148 --- /dev/null +++ b/apps/api/src/domains/operations/smoke-tests/smoke-test.db-probes.ts @@ -0,0 +1,164 @@ +import { Decimal } from '@prisma/client/runtime/library'; +import { BET_LIMIT_KEYS } from '../../betting/betting-limits.service'; +import type { PrismaService } from '../../../shared/prisma/prisma.service'; +import { expectEqual, expectTrue } from './smoke-test.helpers'; +import type { SmokeTestCaseDef } from './smoke-test.cases'; + +export const DATABASE_PROBE_COUNT = 8; + +export function createDatabaseProbes(prisma: PrismaService): SmokeTestCaseDef[] { + return [ + { + id: 'DB001', + suite: 'database', + name: '数据库连接', + description: 'SELECT 1 探活', + run: async () => { + const rows = await prisma.$queryRaw>`SELECT 1 AS ok`; + expectEqual('ping', rows[0]?.ok, 1, { query: 'SELECT 1' }); + }, + }, + { + id: 'DB002', + suite: 'database', + name: '演示管理员账号', + description: 'seed admin / Admin@123', + run: async () => { + const user = await prisma.user.findUnique({ + where: { username: 'admin' }, + select: { id: true, userType: true, deletedAt: true }, + }); + expectTrue('admin exists', !!user, { username: 'admin' }); + expectEqual('userType', user?.userType, 'ADMIN', { username: 'admin' }); + expectEqual('not deleted', user?.deletedAt ?? null, null, { username: 'admin' }); + }, + }, + { + id: 'DB003', + suite: 'database', + name: '演示代理账号与额度', + description: 'seed agent1 授信额度', + run: async () => { + const agent = await prisma.user.findUnique({ + where: { username: 'agent1' }, + select: { + id: true, + userType: true, + agentProfile: { select: { creditLimit: true, level: true } }, + }, + }); + expectTrue('agent1 exists', !!agent, { username: 'agent1' }); + expectEqual('userType', agent?.userType, 'AGENT', { username: 'agent1' }); + const credit = agent?.agentProfile?.creditLimit; + expectTrue('creditLimit > 0', !!credit && credit.gt(0), { + username: 'agent1', + creditLimit: credit?.toString(), + }); + }, + }, + { + id: 'DB004', + suite: 'database', + name: '演示玩家与钱包', + description: 'seed player1 钱包余额', + run: async () => { + const player = await prisma.user.findUnique({ + where: { username: 'player1' }, + select: { + id: true, + userType: true, + wallet: { select: { availableBalance: true, frozenBalance: true } }, + }, + }); + expectTrue('player1 exists', !!player, { username: 'player1' }); + expectEqual('userType', player?.userType, 'PLAYER', { username: 'player1' }); + expectTrue('wallet exists', !!player?.wallet, { username: 'player1' }); + expectTrue('availableBalance >= 0', !!player?.wallet && player.wallet.availableBalance.gte(0), { + availableBalance: player?.wallet?.availableBalance.toString(), + frozenBalance: player?.wallet?.frozenBalance.toString(), + }); + }, + }, + { + id: 'DB005', + suite: 'database', + name: '演示赛事与盘口', + description: '至少一场 OPEN 赛事且含 FT_1X2 盘口', + run: async () => { + const matchCount = await prisma.match.count({ + where: { deletedAt: null, status: { in: ['OPEN', 'PUBLISHED', 'SUSPENDED', 'CLOSED'] } }, + }); + expectTrue('match count > 0', matchCount > 0, { matchCount }); + + const market = await prisma.market.findFirst({ + where: { + marketType: 'FT_1X2', + match: { deletedAt: null, status: { in: ['OPEN', 'PUBLISHED'] } }, + }, + select: { + id: true, + marketType: true, + selections: { select: { id: true, selectionCode: true, odds: true }, take: 3 }, + }, + }); + expectTrue('FT_1X2 market exists', !!market, { marketType: 'FT_1X2' }); + expectTrue('has 3 selections', (market?.selections.length ?? 0) >= 3, { + selectionCount: market?.selections.length, + }); + }, + }, + { + id: 'DB006', + suite: 'database', + name: '投注限额配置完整', + description: '6 项 bet.* 限额键可读', + run: async () => { + const keys = Object.values(BET_LIMIT_KEYS); + for (const key of keys) { + const row = await prisma.systemConfig.findUnique({ where: { configKey: key } }); + const value = row?.configValue ?? '(default)'; + const n = row ? Number(row.configValue) : NaN; + const valid = !row || (Number.isFinite(n) && n >= 0); + expectTrue(`${key} valid`, valid, { key, value }); + } + }, + }, + { + id: 'DB007', + suite: 'database', + name: '权限与角色种子', + description: '超级管理员角色含 settings.manage', + run: async () => { + const role = await prisma.role.findFirst({ + where: { code: 'SUPER_ADMIN' }, + include: { permissions: { include: { permission: true } } }, + }); + expectTrue('SUPER_ADMIN role exists', !!role, { code: 'SUPER_ADMIN' }); + const codes = role?.permissions.map((rp) => rp.permission.code) ?? []; + expectTrue('has settings.manage', codes.includes('settings.manage'), { permissions: codes }); + }, + }, + { + id: 'DB008', + suite: 'database', + name: '今日注单聚合可查', + description: 'bet 表聚合查询(只读)', + run: async () => { + const start = new Date(); + start.setHours(0, 0, 0, 0); + const agg = await prisma.bet.aggregate({ + where: { placedAt: { gte: start } }, + _sum: { stake: true }, + _count: true, + }); + expectTrue('aggregate ok', agg._count >= 0, { + todayBetCount: agg._count, + todayStakeSum: agg._sum.stake?.toString() ?? '0', + }); + expectEqual('stake sum type', agg._sum.stake instanceof Decimal || agg._sum.stake == null, true, { + stake: agg._sum.stake?.toString() ?? '0', + }); + }, + }, + ]; +} diff --git a/apps/api/src/domains/operations/smoke-tests/smoke-test.helpers.ts b/apps/api/src/domains/operations/smoke-tests/smoke-test.helpers.ts new file mode 100644 index 0000000..d1eea79 --- /dev/null +++ b/apps/api/src/domains/operations/smoke-tests/smoke-test.helpers.ts @@ -0,0 +1,88 @@ +export type SmokeTestStep = { + label: string; + input?: string; + expected: string; + actual: string; +}; + +let activeSteps: SmokeTestStep[] = []; + +export function beginSmokeSteps() { + activeSteps = []; +} + +export function drainSmokeSteps(): SmokeTestStep[] { + const steps = [...activeSteps]; + activeSteps = []; + return steps; +} + +function formatValue(value: unknown): string { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} + +export function recordStep( + label: string, + input: unknown, + expected: unknown, + actual: unknown, + ok: boolean, +) { + activeSteps.push({ + label, + input: input === undefined ? undefined : formatValue(input), + expected: formatValue(expected), + actual: formatValue(actual), + }); + if (!ok) { + throw new Error(`${label}: expected ${formatValue(expected)}, got ${formatValue(actual)}`); + } +} + +export function expectEqual(label: string, actual: T, expected: T, input?: unknown) { + const ok = actual === expected; + recordStep(label, input, expected, actual, ok); +} + +export function expectTrue(label: string, condition: boolean, input?: unknown, failHint?: string) { + recordStep(label, input, 'true', condition ? 'true' : failHint ?? 'false', condition); +} + +export function expectFalse(label: string, condition: boolean, input?: unknown, failHint?: string) { + recordStep(label, input, 'false', condition ? failHint ?? 'true' : 'false', !condition); +} + +export async function expectThrows( + label: string, + fn: () => void | Promise, + messageIncludes: string, + input?: unknown, +) { + try { + await fn(); + recordStep(label, input, `error contains "${messageIncludes}"`, 'no error thrown', false); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + recordStep(label, input, `error contains "${messageIncludes}"`, msg, msg.includes(messageIncludes)); + } +} + +export function formatStepsForResult(steps: SmokeTestStep[]): string[] { + return steps.map((step, index) => { + const lines = [`${index + 1}. ${step.label}`]; + if (step.input) lines.push(` input: ${step.input}`); + lines.push(` expected: ${step.expected}`); + lines.push(` actual: ${step.actual}`); + return lines.join('\n'); + }); +} diff --git a/apps/api/src/domains/operations/smoke-tests/smoke-test.module.ts b/apps/api/src/domains/operations/smoke-tests/smoke-test.module.ts new file mode 100644 index 0000000..6bbbabe --- /dev/null +++ b/apps/api/src/domains/operations/smoke-tests/smoke-test.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AgentsModule } from '../../agent/agents.module'; +import { BetsModule } from '../../betting/bets.module'; +import { WalletModule } from '../../ledger/wallet.module'; +import { SettlementModule } from '../../settlement/settlement.module'; +import { PrismaModule } from '../../../shared/prisma/prisma.module'; +import { SmokeTestService } from './smoke-test.service'; + +@Module({ + imports: [PrismaModule, WalletModule, BetsModule, SettlementModule, AgentsModule], + providers: [SmokeTestService], + exports: [SmokeTestService], +}) +export class SmokeTestModule {} diff --git a/apps/api/src/domains/operations/smoke-tests/smoke-test.service.ts b/apps/api/src/domains/operations/smoke-tests/smoke-test.service.ts new file mode 100644 index 0000000..4d03369 --- /dev/null +++ b/apps/api/src/domains/operations/smoke-tests/smoke-test.service.ts @@ -0,0 +1,152 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../../shared/prisma/prisma.service'; +import { AgentsService } from '../../agent/agents.service'; +import { BetsService } from '../../betting/bets.service'; +import { SettlementService } from '../../settlement/settlement.service'; +import { WalletService } from '../../ledger/wallet.service'; +import { SMOKE_SUITE_META, SMOKE_TEST_CASES, type SmokeTestCaseDef } from './smoke-test.cases'; +import { BET_FLOW_PROBE_COUNT, createBetFlowProbes } from './smoke-test.bet-flow-probes'; +import { createDatabaseProbes, DATABASE_PROBE_COUNT } from './smoke-test.db-probes'; +import { + beginSmokeSteps, + drainSmokeSteps, + formatStepsForResult, +} from './smoke-test.helpers'; +import type { + SmokeTestCaseResult, + SmokeTestRunSummary, + SmokeTestSuiteInfo, +} from './smoke-test.types'; + +@Injectable() +export class SmokeTestService { + private lastRun: SmokeTestRunSummary | null = null; + + constructor( + private prisma: PrismaService, + private wallet: WalletService, + private bets: BetsService, + private settlement: SettlementService, + private agents: AgentsService, + ) {} + + listSuites(): SmokeTestSuiteInfo[] { + const counts = new Map(); + for (const c of SMOKE_TEST_CASES) { + counts.set(c.suite, (counts.get(c.suite) ?? 0) + 1); + } + counts.set('database', DATABASE_PROBE_COUNT); + counts.set('bet-flow', BET_FLOW_PROBE_COUNT); + + return [...counts.entries()].map(([id, caseCount]) => ({ + id, + name: SMOKE_SUITE_META[id]?.name ?? id, + description: SMOKE_SUITE_META[id]?.description ?? '', + caseCount, + })); + } + + listCases(suites?: string[]) { + const allow = suites?.length ? new Set(suites) : null; + return SMOKE_TEST_CASES.filter((c) => !allow || allow.has(c.suite)).map( + ({ id, suite, name, description, uatRef }) => ({ id, suite, name, description, uatRef }), + ); + } + + getLastRun() { + return this.lastRun; + } + + async run(suites?: string[], operatorId?: bigint): Promise { + const started = Date.now(); + const runId = `SMOKE-${started}-${operatorId?.toString() ?? '0'}`; + const allow = suites?.length ? new Set(suites) : null; + + const staticCases = SMOKE_TEST_CASES.filter((c) => !allow || allow.has(c.suite)); + const runDb = !allow || allow.has('database'); + const runBetFlow = !allow || allow.has('bet-flow'); + + const results: SmokeTestCaseResult[] = []; + + for (const testCase of staticCases) { + results.push(await this.executeCase(testCase)); + } + + if (runDb) { + for (const probe of createDatabaseProbes(this.prisma)) { + results.push(await this.executeCase(probe)); + } + } + + if (runBetFlow) { + for (const probe of createBetFlowProbes({ + prisma: this.prisma, + wallet: this.wallet, + bets: this.bets, + settlement: this.settlement, + agents: this.agents, + })) { + results.push(await this.executeCase(probe)); + } + } + + const finished = Date.now(); + const passed = results.filter((r) => r.status === 'PASS').length; + const failed = results.filter((r) => r.status === 'FAIL').length; + const skipped = results.filter((r) => r.status === 'SKIP').length; + + const summary: SmokeTestRunSummary = { + runId, + startedAt: new Date(started).toISOString(), + finishedAt: new Date(finished).toISOString(), + durationMs: finished - started, + total: results.length, + passed, + failed, + skipped, + suites: [...new Set(results.map((r) => r.suite))], + results, + }; + + this.lastRun = summary; + return summary; + } + + private async executeCase(testCase: SmokeTestCaseDef): Promise { + const t0 = performance.now(); + const base = { + id: testCase.id, + suite: testCase.suite, + name: testCase.name, + description: testCase.description, + uatRef: testCase.uatRef, + }; + + beginSmokeSteps(); + try { + await testCase.run(); + const steps = drainSmokeSteps(); + const durationMs = Math.max(0.01, Math.round((performance.now() - t0) * 100) / 100); + return { + ...base, + status: 'PASS', + durationMs, + stepCount: steps.length, + message: steps.length ? `${steps.length} steps passed` : 'OK', + details: formatStepsForResult(steps), + }; + } catch (err) { + const steps = drainSmokeSteps(); + const message = err instanceof Error ? err.message : String(err); + const durationMs = Math.max(0.01, Math.round((performance.now() - t0) * 100) / 100); + return { + ...base, + status: 'FAIL', + durationMs, + stepCount: steps.length, + error: message, + details: formatStepsForResult(steps), + }; + } + } +} diff --git a/apps/api/src/domains/operations/smoke-tests/smoke-test.types.ts b/apps/api/src/domains/operations/smoke-tests/smoke-test.types.ts new file mode 100644 index 0000000..75a8862 --- /dev/null +++ b/apps/api/src/domains/operations/smoke-tests/smoke-test.types.ts @@ -0,0 +1,38 @@ +export type SmokeTestStatus = 'PASS' | 'FAIL' | 'SKIP'; + +export type SmokeTestCaseMeta = { + id: string; + suite: string; + name: string; + description?: string; + uatRef?: string; +}; + +export type SmokeTestCaseResult = SmokeTestCaseMeta & { + status: SmokeTestStatus; + durationMs: number; + stepCount?: number; + message?: string; + error?: string; + details?: string[]; +}; + +export type SmokeTestRunSummary = { + runId: string; + startedAt: string; + finishedAt: string; + durationMs: number; + total: number; + passed: number; + failed: number; + skipped: number; + suites: string[]; + results: SmokeTestCaseResult[]; +}; + +export type SmokeTestSuiteInfo = { + id: string; + name: string; + description: string; + caseCount: number; +}; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 4ba52e1..da06f16 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -2,17 +2,16 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { NestExpressApplication } from '@nestjs/platform-express'; -import { join } from 'path'; import { AppModule } from './app.module'; import { GlobalExceptionFilter } from './shared/common/filters'; +import { getUploadRoot } from './shared/uploads/upload-paths'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableShutdownHooks(); app.useGlobalFilters(new GlobalExceptionFilter()); - const uploadDir = process.env.UPLOAD_DIR || join(__dirname, '..', '..', 'uploads'); - app.useStaticAssets(uploadDir, { prefix: '/uploads/' }); + app.useStaticAssets(getUploadRoot(), { prefix: '/uploads/' }); app.setGlobalPrefix('api'); app.enableCors({ origin: true, credentials: true }); diff --git a/apps/api/src/shared/uploads/upload-paths.ts b/apps/api/src/shared/uploads/upload-paths.ts new file mode 100644 index 0000000..8a66f96 --- /dev/null +++ b/apps/api/src/shared/uploads/upload-paths.ts @@ -0,0 +1,16 @@ +import { existsSync } from 'fs'; +import { resolve } from 'path'; + +export function getUploadRoot() { + if (process.env.UPLOAD_DIR?.trim()) { + return resolve(process.env.UPLOAD_DIR.trim()); + } + + const candidates = [ + resolve(process.cwd(), '..', '..', 'uploads'), + resolve(process.cwd(), 'uploads'), + resolve(__dirname, '..', '..', 'uploads'), + ]; + + return candidates.find((p) => existsSync(p)) ?? candidates[0]; +} diff --git a/apps/player/src/assets/images/banner.png b/apps/player/src/assets/images/banner.png index f997986..6307bcf 100644 Binary files a/apps/player/src/assets/images/banner.png and b/apps/player/src/assets/images/banner.png differ diff --git a/apps/player/src/assets/images/bg.png b/apps/player/src/assets/images/bg.png index f9ae155..a7334b3 100644 Binary files a/apps/player/src/assets/images/bg.png and b/apps/player/src/assets/images/bg.png differ diff --git a/apps/player/src/assets/images/card-bg.png b/apps/player/src/assets/images/card-bg.png index 65fac3f..6fcdb4d 100644 Binary files a/apps/player/src/assets/images/card-bg.png and b/apps/player/src/assets/images/card-bg.png differ diff --git a/apps/player/src/assets/images/h5bg.png b/apps/player/src/assets/images/h5bg.png index 1b0b2bc..56f38df 100644 Binary files a/apps/player/src/assets/images/h5bg.png and b/apps/player/src/assets/images/h5bg.png differ diff --git a/apps/player/src/assets/images/vs.png b/apps/player/src/assets/images/vs.png index 681c78f..ecf5137 100644 Binary files a/apps/player/src/assets/images/vs.png and b/apps/player/src/assets/images/vs.png differ diff --git a/apps/player/src/assets/images/wallet-bg.png b/apps/player/src/assets/images/wallet-bg.png index 86de48b..5d199de 100644 Binary files a/apps/player/src/assets/images/wallet-bg.png and b/apps/player/src/assets/images/wallet-bg.png differ diff --git a/apps/player/src/layouts/MainLayout.vue b/apps/player/src/layouts/MainLayout.vue index 3e89f85..4360e71 100644 --- a/apps/player/src/layouts/MainLayout.vue +++ b/apps/player/src/layouts/MainLayout.vue @@ -28,7 +28,8 @@ const isDetailPage = computed(() => { p.startsWith('/bet/') || p.startsWith('/bets/') || p.startsWith('/wallet/') || - p === '/profile/edit' + p === '/profile/edit' || + p === '/profile/cashbacks' ); }); diff --git a/apps/player/src/main.ts b/apps/player/src/main.ts index 346e6e1..6371329 100644 --- a/apps/player/src/main.ts +++ b/apps/player/src/main.ts @@ -92,7 +92,7 @@ const i18n = createI18n({ tx_bet_push: '投注退水', tx_bet_refund: '投注退款', tx_bet_void: '投注撤销', - tx_cashback: '返水发放', + tx_cashback: '返水入账', tx_resettle: '重新结算', stats_income: '收入', stats_expense: '支出', @@ -120,6 +120,23 @@ const i18n = createI18n({ ref_bet: '投注', ref_deposit: '存款', ref_withdraw: '提款', + view_cashbacks: '返水明细', + view_cashbacks_detail: '查看返水周期明细', + cashback_filter_hint: '此处为入账流水;周期、比例等详见返水明细。', + detail_cashback_link: '查看返水明细', + ref_cashback: '返水批次', + }, + cashback: { + title: '返水明细', + list_title: '发放明细', + total_received: '累计返水', + record_count: '共 {n} 笔', + period: '统计周期', + effective_stake: '有效投注', + bet_count: '{n} 笔注单', + empty: '暂无返水记录', + empty_hint: '返水由后台按周期统计并发放,到账后可在此查看。', + ledger_hint: '每笔返水确认后,账单中会有对应的「返水入账」流水,金额一致。', }, bet: { bet_slip: '投注单', @@ -376,7 +393,7 @@ const i18n = createI18n({ tx_bet_push: 'Bet Push', tx_bet_refund: 'Bet Refund', tx_bet_void: 'Bet Voided', - tx_cashback: 'Cashback Distribution', + tx_cashback: 'Cashback credit', tx_resettle: 'Resettlement', stats_income: 'Income', stats_expense: 'Expense', @@ -404,6 +421,23 @@ const i18n = createI18n({ ref_bet: 'Bet', ref_deposit: 'Deposit', ref_withdraw: 'Withdraw', + view_cashbacks: 'Cashback details', + view_cashbacks_detail: 'View cashback details (period/rate)', + cashback_filter_hint: 'This list shows wallet credits; see cashback details for period and rate.', + ref_cashback: 'Cashback batch', + detail_cashback_link: 'View cashback details', + }, + cashback: { + title: 'Cashback Details', + list_title: 'Payout details', + total_received: 'Total cashback', + record_count: '{n} record(s)', + period: 'Period', + effective_stake: 'Effective stake', + bet_count: '{n} bet(s)', + empty: 'No cashback records yet', + empty_hint: 'Cashback is issued by the platform after each settlement period.', + ledger_hint: 'Matches wallet entries under the Cashback filter; amounts are the same.', }, bet: { bet_slip: 'Bet Slip', @@ -666,7 +700,7 @@ const i18n = createI18n({ tx_bet_push: 'Pertaruhan Seri', tx_bet_refund: 'Bayaran Balik', tx_bet_void: 'Pertaruhan Dibatalkan', - tx_cashback: 'Pembayaran Cashback', + tx_cashback: 'Kredit rebat', tx_resettle: 'Penyelesaian Semula', stats_income: 'Pendapatan', stats_expense: 'Perbelanjaan', @@ -694,6 +728,23 @@ const i18n = createI18n({ ref_bet: 'Pertaruhan', ref_deposit: 'Deposit', ref_withdraw: 'Pengeluaran', + view_cashbacks: 'Butiran rebat', + view_cashbacks_detail: 'Lihat butiran rebat (tempoh/kadar)', + cashback_filter_hint: 'Senarai ini ialah kredit dompet; tempoh dan kadar ada di butiran rebat.', + ref_cashback: 'Batch rebat', + detail_cashback_link: 'Lihat butiran rebat', + }, + cashback: { + title: 'Butiran Rebat', + list_title: 'Butiran pembayaran', + total_received: 'Jumlah rebat', + record_count: '{n} rekod', + period: 'Tempoh', + effective_stake: 'Pertaruhan sah', + bet_count: '{n} pertaruhan', + empty: 'Tiada rekod rebat', + empty_hint: 'Rebat dikeluarkan oleh platform mengikut kitaran penyelesaian.', + ledger_hint: 'Sepadan dengan entri dompet di penapis Rebat; jumlah adalah sama.', }, bet: { bet_slip: 'Slip Pertaruhan', diff --git a/apps/player/src/router/index.ts b/apps/player/src/router/index.ts index 3cab3aa..7be220c 100644 --- a/apps/player/src/router/index.ts +++ b/apps/player/src/router/index.ts @@ -18,8 +18,10 @@ const router = createRouter({ { path: 'bets/:betNo', component: () => import('../views/BetDetailView.vue') }, { path: 'wallet', component: () => import('../views/WalletView.vue') }, { path: 'wallet/detail', component: () => import('../views/WalletDetailView.vue') }, + { path: 'wallet/cashbacks', component: () => import('../views/CashbackRecordsView.vue') }, { path: 'wallet/transactions/:transactionId', component: () => import('../views/WalletTransactionDetailView.vue') }, { path: 'profile', component: () => import('../views/ProfileView.vue') }, + { path: 'profile/cashbacks', component: () => import('../views/CashbackRecordsView.vue') }, { path: 'profile/edit', component: () => import('../views/ProfileEditView.vue') }, ], }, diff --git a/apps/player/src/views/CashbackRecordsView.vue b/apps/player/src/views/CashbackRecordsView.vue new file mode 100644 index 0000000..e27eb69 --- /dev/null +++ b/apps/player/src/views/CashbackRecordsView.vue @@ -0,0 +1,354 @@ + + + + + diff --git a/apps/player/src/views/ProfileView.vue b/apps/player/src/views/ProfileView.vue index 0e10621..f820437 100644 --- a/apps/player/src/views/ProfileView.vue +++ b/apps/player/src/views/ProfileView.vue @@ -115,10 +115,19 @@ function logout() { -
+ + {{ t('wallet.view_all') }} + + + + + {{ t('wallet.view_cashbacks') }} + + + {{ t('profile.edit') }} @@ -197,26 +206,6 @@ function logout() { transition: filter 0.1s; } -.wallet-chevron { - position: absolute; - right: 4%; - bottom: 12%; - z-index: 3; - font-size: 28px; - font-weight: 400; - color: rgba(255, 255, 255, 0.85); - background: rgba(0, 0, 0, 0.35); - width: 32px; - height: 32px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - pointer-events: none; - line-height: 1; - backdrop-filter: blur(4px); -} - .wallet-banner-img { width: 100%; height: auto; @@ -378,6 +367,32 @@ function logout() { background: rgba(255, 255, 255, 0.03); } +.settings-cell--gold-entry { + position: relative; + min-height: 50px; + border-color: rgba(212, 175, 55, 0.18); + border-bottom-color: rgba(212, 175, 55, 0.18); + background: + linear-gradient(90deg, rgba(212, 175, 55, 0.08), rgba(20, 20, 20, 0.65) 46%, rgba(212, 175, 55, 0.04)); + box-shadow: + inset 1px 0 0 rgba(212, 175, 55, 0.22), + inset 0 1px 0 rgba(255, 244, 200, 0.05); +} + +.settings-cell--gold-entry:active { + background: + linear-gradient(90deg, rgba(212, 175, 55, 0.12), rgba(20, 20, 20, 0.74) 46%, rgba(212, 175, 55, 0.06)); +} + +.settings-cell--gold-entry .cell-label { + color: #f4dc8b; + font-weight: 800; +} + +.settings-cell--gold-entry .cell-chevron { + color: rgba(240, 216, 117, 0.78); +} + .settings-cell--stack { flex-direction: column; align-items: stretch; diff --git a/apps/player/src/views/WalletTransactionDetailView.vue b/apps/player/src/views/WalletTransactionDetailView.vue index 42bb957..4d3b140 100644 --- a/apps/player/src/views/WalletTransactionDetailView.vue +++ b/apps/player/src/views/WalletTransactionDetailView.vue @@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'; import { useI18n } from 'vue-i18n'; import api from '../api'; import { formatMoney } from '../utils/localeDisplay'; -import { txTypeKey } from '../utils/walletTx'; +import { txTypeKey, isCashbackType } from '../utils/walletTx'; import GoldSpinner from '../components/GoldSpinner.vue'; import { usePullToRefresh } from '../composables/usePullToRefresh'; @@ -82,7 +82,17 @@ const frozenChanged = computed(() => { return tx.value.frozenBefore !== tx.value.frozenAfter; }); +const isCashbackTx = computed( + () => !!tx.value && isCashbackType(tx.value.transactionType), +); + +const cashbackBatchNo = computed(() => { + if (!isCashbackTx.value || !tx.value?.referenceId) return null; + return tx.value.referenceId; +}); + const referenceLabel = computed(() => { + if (isCashbackTx.value) return t('wallet.ref_cashback'); if (!tx.value?.referenceType) return ''; const rt = tx.value.referenceType.toUpperCase(); if (rt === 'BET') return t('wallet.ref_bet'); @@ -95,6 +105,11 @@ function goBetDetail() { if (!tx.value?.betNo) return; router.push(`/bets/${tx.value.betNo}`); } + +function goCashbackDetail() { + if (!cashbackBatchNo.value) return; + router.push({ path: '/wallet/cashbacks', query: { batchNo: cashbackBatchNo.value } }); +}