feat: add smoke tests, agent credit ledger, and player cashback page

Introduce admin smoke-test suite with API probes, agent credit transaction history, and player cashback records; fix SmokeTestModule DI and polish admin/player UI assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 16:05:48 +08:00
parent 9c6c5e51f3
commit d5e7c8edb3
52 changed files with 3357 additions and 67 deletions

View File

@@ -37,9 +37,11 @@ const zh: Record<string, string> = {
'nav.matches': '赛事管理', 'nav.matches': '赛事管理',
'nav.outrights': '优胜冠军', 'nav.outrights': '优胜冠军',
'nav.bets': '注单管理', 'nav.bets': '注单管理',
'nav.credit_transactions': '额度流水',
'nav.cashback': '返水管理', 'nav.cashback': '返水管理',
'nav.contents': '公共管理', 'nav.contents': '公共管理',
'nav.audit': '操作日志', 'nav.audit': '操作日志',
'nav.smoke_tests': '自动化测试',
'nav.players': '直属玩家', 'nav.players': '直属玩家',
'nav.subAgents': '下级代理', 'nav.subAgents': '下级代理',
'nav.myBets': '注单查询', 'nav.myBets': '注单查询',
@@ -215,9 +217,11 @@ const en: Record<string, string> = {
'nav.matches': 'Matches', 'nav.matches': 'Matches',
'nav.outrights': 'Outrights', 'nav.outrights': 'Outrights',
'nav.bets': 'Bets', 'nav.bets': 'Bets',
'nav.credit_transactions': 'Credit ledger',
'nav.cashback': 'Cashback', 'nav.cashback': 'Cashback',
'nav.contents': 'Public Content', 'nav.contents': 'Public Content',
'nav.audit': 'Audit Log', 'nav.audit': 'Audit Log',
'nav.smoke_tests': 'Smoke tests',
'nav.players': 'My Players', 'nav.players': 'My Players',
'nav.subAgents': 'Sub-Agents', 'nav.subAgents': 'Sub-Agents',
'nav.myBets': 'Bet Search', 'nav.myBets': 'Bet Search',
@@ -393,9 +397,11 @@ const ms: Record<string, string> = {
'nav.matches': 'Perlawanan', 'nav.matches': 'Perlawanan',
'nav.outrights': 'Juara', 'nav.outrights': 'Juara',
'nav.bets': 'Pertaruhan', 'nav.bets': 'Pertaruhan',
'nav.credit_transactions': 'Lejar kredit',
'nav.cashback': 'Rebat', 'nav.cashback': 'Rebat',
'nav.contents': 'Kandungan awam', 'nav.contents': 'Kandungan awam',
'nav.audit': 'Log audit', 'nav.audit': 'Log audit',
'nav.smoke_tests': 'Ujian asap',
'nav.players': 'Pemain saya', 'nav.players': 'Pemain saya',
'nav.subAgents': 'Sub-ejen', 'nav.subAgents': 'Sub-ejen',
'nav.myBets': 'Carian pertaruhan', 'nav.myBets': 'Carian pertaruhan',

View File

@@ -129,7 +129,13 @@ export const adminPagesMs: Record<string, string> = {
'agent.credit.decrease': 'Kurang', 'agent.credit.decrease': 'Kurang',
'agent.col.credit_type': 'Jenis', 'agent.col.credit_type': 'Jenis',
'agent.col.credit_change': 'Perubahan', 'agent.col.credit_change': 'Perubahan',
'agent.col.credit_before': 'Sebelum',
'agent.col.credit_after': 'Selepas', '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.col.no_records': 'Tiada rekod',
'agent.btn.confirm_adjust': 'Sahkan', 'agent.btn.confirm_adjust': 'Sahkan',
'agent.field.select_user': 'Pilih pengguna', 'agent.field.select_user': 'Pilih pengguna',
@@ -597,4 +603,39 @@ export const adminPagesMs: Record<string, string> = {
'msg.freeze_extra': ' Mereka tidak akan dapat log masuk.', 'msg.freeze_extra': ' Mereka tidak akan dapat log masuk.',
'msg.freeze_done': '{action} selesai', 'msg.freeze_done': '{action} selesai',
'msg.freeze_failed': '{action} gagal', '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',
}; };

View File

@@ -132,7 +132,13 @@ export const adminPagesZh: Record<string, string> = {
'agent.credit.decrease': '减少', 'agent.credit.decrease': '减少',
'agent.col.credit_type': '类型', 'agent.col.credit_type': '类型',
'agent.col.credit_change': '变动', 'agent.col.credit_change': '变动',
'agent.col.credit_before': '变动前',
'agent.col.credit_after': '变动后', '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.col.no_records': '暂无记录',
'agent.btn.confirm_adjust': '确认调整', 'agent.btn.confirm_adjust': '确认调整',
'agent.field.select_user': '选择用户', 'agent.field.select_user': '选择用户',
@@ -692,6 +698,41 @@ export const adminPagesZh: Record<string, string> = {
'msg.freeze_extra': '冻结后该账号将无法登录。', 'msg.freeze_extra': '冻结后该账号将无法登录。',
'msg.freeze_done': '已{action}', 'msg.freeze_done': '已{action}',
'msg.freeze_failed': '{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<string, string> = { export const adminPagesEn: Record<string, string> = {
@@ -827,7 +868,13 @@ export const adminPagesEn: Record<string, string> = {
'agent.credit.decrease': 'Decrease', 'agent.credit.decrease': 'Decrease',
'agent.col.credit_type': 'Type', 'agent.col.credit_type': 'Type',
'agent.col.credit_change': 'Change', 'agent.col.credit_change': 'Change',
'agent.col.credit_before': 'Before',
'agent.col.credit_after': 'After', '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.col.no_records': 'No records',
'agent.btn.confirm_adjust': 'Confirm', 'agent.btn.confirm_adjust': 'Confirm',
'agent.field.select_user': 'Select user', 'agent.field.select_user': 'Select user',
@@ -1388,4 +1435,39 @@ export const adminPagesEn: Record<string, string> = {
'msg.freeze_extra': ' They will not be able to sign in.', 'msg.freeze_extra': ' They will not be able to sign in.',
'msg.freeze_done': '{action} completed', 'msg.freeze_done': '{action} completed',
'msg.freeze_failed': '{action} failed', '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',
}; };

View File

@@ -18,10 +18,12 @@ const adminMenus = computed(() => [
{ path: '/', label: t('nav.dashboard') }, { path: '/', label: t('nav.dashboard') },
{ path: '/matches', label: t('nav.matches'), matchPrefix: true }, { path: '/matches', label: t('nav.matches'), matchPrefix: true },
{ path: '/users', label: t('nav.agents_players') }, { path: '/users', label: t('nav.agents_players') },
{ path: '/agent-credit-transactions', label: t('nav.credit_transactions') },
{ path: '/cashback', label: t('nav.cashback') }, { path: '/cashback', label: t('nav.cashback') },
{ path: '/bets', label: t('nav.bets') }, { path: '/bets', label: t('nav.bets') },
{ path: '/contents', label: t('nav.contents') }, { path: '/contents', label: t('nav.contents') },
{ path: '/audit', label: t('nav.audit') }, { path: '/audit', label: t('nav.audit') },
{ path: '/smoke-tests', label: t('nav.smoke_tests') },
]); ]);
const agentMenus = computed(() => [ const agentMenus = computed(() => [
@@ -60,8 +62,11 @@ const roleLabel = computed(() => {
return t('role.agent'); return t('role.agent');
}); });
const currentUser = computed(() => auth.user.value);
const isAdminPortal = computed(() => auth.isAdmin.value);
const userInitial = computed(() => const userInitial = computed(() =>
(auth.user?.username ?? '').charAt(0).toUpperCase() (currentUser.value?.username ?? '').charAt(0).toUpperCase()
); );
function syncMobileNav() { function syncMobileNav() {
@@ -175,12 +180,12 @@ watch(() => route.path, () => {
<div class="user-chip"> <div class="user-chip">
<div class="avatar">{{ userInitial }}</div> <div class="avatar">{{ userInitial }}</div>
<div class="user-info"> <div class="user-info">
<span class="user-name">{{ auth.user?.username }}</span> <span class="user-name">{{ currentUser?.username }}</span>
<span class="user-role">{{ roleLabel }}</span> <span class="user-role">{{ roleLabel }}</span>
</div> </div>
</div> </div>
<AdminLocaleSwitcher /> <AdminLocaleSwitcher />
<div class="portal-tag">{{ auth.isAdmin ? t('portal.admin') : t('portal.agent') }}</div> <div class="portal-tag">{{ isAdminPortal ? t('portal.admin') : t('portal.agent') }}</div>
<button class="btn-logout" @click="logout">{{ t('logout') }}</button> <button class="btn-logout" @click="logout">{{ t('logout') }}</button>
</div> </div>
</header> </header>

View File

@@ -17,6 +17,11 @@ const router = createRouter({
component: () => import('../views/AgentManager.vue'), component: () => import('../views/AgentManager.vue'),
meta: { adminOnly: true }, meta: { adminOnly: true },
}, },
{
path: 'agent-credit-transactions',
component: () => import('../views/AgentCreditTransactions.vue'),
meta: { adminOnly: true },
},
{ {
path: 'agents', path: 'agents',
redirect: '/users', redirect: '/users',
@@ -76,6 +81,16 @@ const router = createRouter({
component: () => import('../views/Audit.vue'), component: () => import('../views/Audit.vue'),
meta: { adminOnly: true }, 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', path: 'my-players',
component: () => import('../views/agent/Players.vue'), component: () => import('../views/agent/Players.vue'),

View File

@@ -25,8 +25,9 @@ function dec(value: string | number | null | undefined): number {
export function snapshotFromAgentRow( export function snapshotFromAgentRow(
row: Pick< row: Pick<
AgentRow | AgentDetail | AgentSubAgentRow, AgentRow | AgentDetail | AgentSubAgentRow,
'username' | 'creditLimit' | 'usedCredit' | 'availableCredit' | 'level' 'username' | 'creditLimit' | 'usedCredit' | 'availableCredit'
> & { > & {
level?: number;
directPlayerLiability?: string; directPlayerLiability?: string;
childAgentExposure?: string; childAgentExposure?: string;
}, },

View File

@@ -0,0 +1,223 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
const { t, locale, localeTag } = useAdminLocale();
const route = useRoute();
const router = useRouter();
interface CreditTxRow {
id: string;
agentId: string;
agentUsername: string | null;
transactionType: string;
amount: string;
creditBefore: string;
creditAfter: string;
operatorUsername: string | null;
remark: string | null;
createdAt: string;
}
const items = ref<CreditTxRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const keyword = ref('');
const agentId = ref('');
const transactionType = ref('');
function creditTypeLabel(type: string) {
if (type === 'CREDIT_INCREASE') return t('agent.credit.increase');
if (type === 'CREDIT_DECREASE') return t('agent.credit.decrease');
return type;
}
function formatTime(v: string) {
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
async function load() {
const { data } = await api.get('/admin/agents/credit-transactions', {
params: {
page: page.value,
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
agentId: agentId.value.trim() || undefined,
transactionType: transactionType.value || undefined,
},
});
items.value = (data.data?.items ?? []) as CreditTxRow[];
total.value = data.data?.total ?? 0;
}
function onSearch() {
page.value = 1;
void load();
}
function onPageChange(p: number) {
page.value = p;
void load();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
void load();
}
function openAgentFilter(id: string) {
agentId.value = id;
keyword.value = '';
page.value = 1;
void router.replace({ query: { agentId: id } });
void load();
}
onMounted(() => {
const q = route.query.agentId;
if (typeof q === 'string' && q.trim()) {
agentId.value = q.trim();
}
void load();
});
watch(
() => route.query.agentId,
(q) => {
const next = typeof q === 'string' ? q.trim() : '';
if (next !== agentId.value) {
agentId.value = next;
page.value = 1;
void load();
}
},
);
</script>
<template>
<div class="admin-list-page">
<el-card class="filter-card" shadow="never">
<el-form inline>
<el-form-item :label="t('common.keyword')">
<el-input
v-model="keyword"
:placeholder="t('agent.credit_tx.filter_agent_ph')"
clearable
style="width: 180px"
@keyup.enter="onSearch"
/>
</el-form-item>
<el-form-item :label="t('agent.credit_tx.filter_agent_id')">
<el-input
v-model="agentId"
:placeholder="t('agent.credit_tx.filter_agent_id_ph')"
clearable
style="width: 140px"
@keyup.enter="onSearch"
/>
</el-form-item>
<el-form-item :label="t('agent.col.credit_type')">
<el-select v-model="transactionType" clearable :placeholder="t('common.all')" style="width: 120px">
<el-option :label="t('agent.credit.increase')" value="CREDIT_INCREASE" />
<el-option :label="t('agent.credit.decrease')" value="CREDIT_DECREASE" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :key="locale" :data="items" stripe>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column :label="t('audit.col.time')" min-width="158">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('user.col.username')" min-width="120">
<template #default="{ row }">
<el-button
v-if="row.agentUsername"
link
type="primary"
@click="openAgentFilter(row.agentId)"
>
{{ row.agentUsername }}
</el-button>
<span v-else>{{ row.agentId }}</span>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_type')" width="88">
<template #default="{ row }">{{ creditTypeLabel(row.transactionType) }}</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_change')" width="108" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
<span :class="parseFloat(row.amount) >= 0 ? 'amt-pos' : 'amt-neg'">
{{ formatAmount(row.amount) }}
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_before')" width="108" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.creditBefore)" placement="top">
<span>{{ formatAmount(row.creditBefore) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_after')" width="108" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.creditAfter)" placement="top">
<span>{{ formatAmount(row.creditAfter) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('agent.credit_tx.col.operator')" min-width="100">
<template #default="{ row }">{{ row.operatorUsername ?? '—' }}</template>
</el-table-column>
<el-table-column prop="remark" :label="t('user.field.remark')" min-width="140" show-overflow-tooltip />
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</el-card>
</div>
</template>
<style scoped>
.amt-pos {
color: var(--el-color-success);
font-weight: 600;
}
.amt-neg {
color: var(--el-color-danger);
font-weight: 600;
}
</style>

View File

@@ -19,14 +19,14 @@ import {
type PlayerDetail, type PlayerDetail,
type PlayerCreateForm, type PlayerCreateForm,
type PlayerEditForm, type PlayerEditForm,
} from './user-form.ts'; } from './user-form';
import { import {
emptyAgentEditForm, emptyAgentEditForm,
editFormFromAgentDetail, editFormFromAgentDetail,
type AgentRow, type AgentRow,
type AgentDetail, type AgentDetail,
type AgentEditForm, type AgentEditForm,
} from './agent-form.ts'; } from './agent-form';
import { subAgentAccountStatus } from './agent/agent-sub-agent-form'; import { subAgentAccountStatus } from './agent/agent-sub-agent-form';
import { import {
formatAmount, formatAmount,
@@ -994,8 +994,8 @@ function creditTypeLabel(type: string) {
:expand-row-keys="subAgentExpandedKeys" :expand-row-keys="subAgentExpandedKeys"
:row-class-name="expandableTableRowClassName" :row-class-name="expandableTableRowClassName"
class="inner-table expandable-table" class="inner-table expandable-table"
@expand-change="(sub, rows) => onSubAgentExpand(row.userId, sub, rows)" @expand-change="(sub: AgentRow, rows: AgentRow[]) => onSubAgentExpand(row.userId, sub, rows)"
@row-click="(sub, col, e) => onSubAgentRowClick(row.userId, sub, col, e)" @row-click="(sub: AgentRow, col: unknown, e: MouseEvent) => onSubAgentRowClick(row.userId, sub, col, e)"
> >
<template #empty><AdminTableEmpty /></template> <template #empty><AdminTableEmpty /></template>
<el-table-column type="expand"> <el-table-column type="expand">
@@ -1469,7 +1469,17 @@ function creditTypeLabel(type: string) {
<el-descriptions-item :label="t('agent.col.created')" :span="2">{{ formatTime(agentDetail.createdAt) }}</el-descriptions-item> <el-descriptions-item :label="t('agent.col.created')" :span="2">{{ formatTime(agentDetail.createdAt) }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<div class="section-title">{{ t('agent.section.credit_log') }}</div> <div class="section-title section-title--row">
<span>{{ t('agent.section.credit_log') }}</span>
<el-button
v-if="agentDetail"
link
type="primary"
@click="router.push({ path: '/agent-credit-transactions', query: { agentId: agentDetail.userId } })"
>
{{ t('agent.credit_tx.view_all') }}
</el-button>
</div>
<el-table :data="agentDetail.recentCreditTransactions" size="small" stripe :empty-text="t('agent.col.no_records')"> <el-table :data="agentDetail.recentCreditTransactions" size="small" stripe :empty-text="t('agent.col.no_records')">
<el-table-column :label="t('agent.col.credit_type')" width="80"> <el-table-column :label="t('agent.col.credit_type')" width="80">
<template #default="{ row }">{{ creditTypeLabel(row.transactionType) }}</template> <template #default="{ row }">{{ creditTypeLabel(row.transactionType) }}</template>
@@ -1627,6 +1637,13 @@ function creditTypeLabel(type: string) {
color: #666; color: #666;
margin-bottom: 8px; margin-bottom: 8px;
} }
.section-title--row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
</style> </style>
<style> <style>

View File

@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale'; import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveFormError, resolveApiError } from '../i18n/form-validation'; import { resolveFormError, resolveApiError } from '../i18n/form-validation';
import api from '../api'; import api from '../api';
const { t } = useAdminLocale(); const { t } = useAdminLocale();
const router = useRouter();
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { import {
emptyAgentCreateForm, emptyAgentCreateForm,
@@ -17,7 +19,7 @@ import {
type AgentCreateForm, type AgentCreateForm,
type AgentEditForm, type AgentEditForm,
type PromotableUserOption, type PromotableUserOption,
} from './agent-form.ts'; } from './agent-form';
import { import {
formatAmount, formatAmount,
formatAmountFull, formatAmountFull,
@@ -484,7 +486,17 @@ function creditTypeLabel(type: string) {
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
<div class="section-title">{{ t('agent.section.credit_log') }}</div> <div class="section-title section-title--row">
<span>{{ t('agent.section.credit_log') }}</span>
<el-button
v-if="detail"
link
type="primary"
@click="router.push({ path: '/agent-credit-transactions', query: { agentId: detail.userId } })"
>
{{ t('agent.credit_tx.view_all') }}
</el-button>
</div>
<el-table <el-table
:data="detail.recentCreditTransactions" :data="detail.recentCreditTransactions"
size="small" size="small"
@@ -527,6 +539,13 @@ function creditTypeLabel(type: string) {
color: #666; color: #666;
margin-bottom: 8px; margin-bottom: 8px;
} }
.section-title--row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.amount-compact { .amount-compact {
white-space: nowrap; white-space: nowrap;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;

View File

@@ -15,7 +15,7 @@ import AdminTableEmpty from '../components/AdminTableEmpty.vue';
const { t, localeTag } = useAdminLocale(); const { t, localeTag } = useAdminLocale();
const { statusOptions, typeOptions } = useBetFilterOptions(); const { statusOptions, typeOptions } = useBetFilterOptions();
import type { BetListRow, BetDetail } from './bet-form.ts'; import type { BetListRow, BetDetail } from './bet-form';
const bets = ref<BetListRow[]>([]); const bets = ref<BetListRow[]>([]);
const total = ref(0); const total = ref(0);

View File

@@ -12,7 +12,7 @@ import { getBuiltinCountry } from '../data/builtinCountries';
import { import {
readMatchesListUiState, readMatchesListUiState,
writeMatchesListUiState, writeMatchesListUiState,
} from '../utils/matchesListState.ts'; } from '../utils/matchesListState';
import { formatAmount } from '../utils/format-amount'; import { formatAmount } from '../utils/format-amount';
import { import {
emptyMatchForm, emptyMatchForm,
@@ -20,7 +20,7 @@ import {
fillBuiltinTeam, fillBuiltinTeam,
clearBuiltinTeam, clearBuiltinTeam,
type MatchCreateForm, type MatchCreateForm,
} from './match-form.ts'; } from './match-form';
const { t } = useAdminLocale(); const { t } = useAdminLocale();
const route = useRoute(); const route = useRoute();

View File

@@ -8,7 +8,7 @@ import LeagueOutrightOddsPanel from './matches/LeagueOutrightOddsPanel.vue';
import { import {
readMatchesListUiState, readMatchesListUiState,
writeMatchesListUiState, writeMatchesListUiState,
} from '../utils/matchesListState.ts'; } from '../utils/matchesListState';
const { t } = useAdminLocale(); const { t } = useAdminLocale();
const route = useRoute(); const route = useRoute();

View File

@@ -15,9 +15,10 @@ import {
betStatusLabel, betStatusLabel,
betStatusTagType, betStatusTagType,
betTypeLabel, betTypeLabel,
betResultLabel,
} from '../utils/bet-labels'; } from '../utils/bet-labels';
import { adminSelectionLabel } from '../utils/adminSelectionLabel.ts'; import { adminSelectionLabel } from '../utils/adminSelectionLabel';
import type { AdminMatchDetail } from './match-form.ts'; import type { AdminMatchDetail } from './match-form';
import AdminSubNav from '../components/AdminSubNav.vue'; import AdminSubNav from '../components/AdminSubNav.vue';
interface SettlementBetStats { interface SettlementBetStats {

View File

@@ -0,0 +1,403 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
const { t, locale, localeTag } = useAdminLocale();
type SuiteInfo = { id: string; name: string; description: string; caseCount: number };
type CaseMeta = { id: string; suite: string; name: string; uatRef?: string };
type CaseResult = CaseMeta & {
status: 'PASS' | 'FAIL' | 'SKIP';
durationMs: number;
stepCount?: number;
message?: string;
error?: string;
details?: string[];
};
type RunSummary = {
runId: string;
startedAt: string;
finishedAt: string;
durationMs: number;
total: number;
passed: number;
failed: number;
skipped: number;
suites: string[];
results: CaseResult[];
};
const suites = ref<SuiteInfo[]>([]);
const selectedSuites = ref<string[]>([]);
const running = ref(false);
const lastRun = ref<RunSummary | null>(null);
const filterStatus = ref('');
const suiteName = (id: string) => suites.value.find((s) => s.id === id)?.name ?? id;
const filteredResults = computed(() => {
const rows = lastRun.value?.results ?? [];
if (!filterStatus.value) return rows;
return rows.filter((r) => r.status === filterStatus.value);
});
const passRate = computed(() => {
if (!lastRun.value?.total) return 0;
return Math.round((lastRun.value.passed / lastRun.value.total) * 100);
});
const runLogText = computed(() => (lastRun.value ? formatRunLog(lastRun.value) : ''));
function formatCaseHeader(row: CaseResult) {
const parts = [
`[${row.status}]`,
row.id,
suiteName(row.suite),
row.name,
row.uatRef ? `UAT:${row.uatRef}` : null,
row.stepCount ? `${row.stepCount} steps` : null,
`${row.durationMs}ms`,
row.error || row.message || '',
].filter(Boolean);
return parts.join(' | ');
}
function formatCaseBlock(row: CaseResult) {
const lines = [formatCaseHeader(row)];
if (row.details?.length) {
lines.push(...row.details.map((d) => ` ${d.replace(/\n/g, '\n ')}`));
}
return lines.join('\n');
}
function formatCaseLine(row: CaseResult) {
return formatCaseBlock(row);
}
function formatRunLog(run: RunSummary) {
const lines = [
'=== Smoke Test Report ===',
`Run ID: ${run.runId}`,
`Started: ${run.startedAt}`,
`Finished: ${run.finishedAt}`,
`Duration: ${run.durationMs}ms`,
`Summary: pass=${run.passed}, fail=${run.failed}, skip=${run.skipped}, total=${run.total}`,
`Suites: ${run.suites.join(', ')}`,
'',
'--- Cases ---',
...run.results.map((row) => formatCaseBlock(row)),
];
return lines.join('\n\n');
}
async function copyText(text: string) {
if (!text) return;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
ElMessage.success(t('smoke.msg.copy_ok'));
} catch {
ElMessage.error(t('smoke.msg.copy_failed'));
}
}
async function loadMeta() {
const { data } = await api.get('/admin/smoke-tests/suites');
suites.value = data.data?.suites ?? [];
if (data.data?.lastRun) lastRun.value = data.data.lastRun;
if (!selectedSuites.value.length) {
selectedSuites.value = suites.value.map((s) => s.id);
}
}
async function runTests() {
running.value = true;
try {
const { data } = await api.post('/admin/smoke-tests/run', {
suites: selectedSuites.value.length ? selectedSuites.value : undefined,
});
lastRun.value = data.data as RunSummary;
if (lastRun.value.failed > 0) {
ElMessage.warning(t('smoke.msg.has_failures', { n: lastRun.value.failed }));
} else {
ElMessage.success(t('smoke.msg.all_passed', { n: lastRun.value.passed }));
}
} catch (e: unknown) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
ElMessage.error(msg || t('smoke.msg.run_failed'));
} finally {
running.value = false;
}
}
function formatTime(v: string) {
return new Date(v).toLocaleString(localeTag.value);
}
function statusTagType(status: string) {
if (status === 'PASS') return 'success';
if (status === 'FAIL') return 'danger';
return 'info';
}
onMounted(loadMeta);
</script>
<template>
<div class="admin-list-page smoke-page">
<el-card class="intro-card" shadow="never">
<p class="intro-text">{{ t('smoke.intro') }}</p>
<ul class="intro-list">
<li>{{ t('smoke.intro_rule') }}</li>
<li>{{ t('smoke.intro_db') }}</li>
<li>{{ t('smoke.intro_bet_flow') }}</li>
<li>{{ t('smoke.intro_note') }}</li>
</ul>
</el-card>
<el-card class="filter-card" shadow="never">
<el-form inline>
<el-form-item :label="t('smoke.field.suites')">
<el-select
v-model="selectedSuites"
multiple
collapse-tags
collapse-tags-tooltip
:placeholder="t('smoke.ph.suites')"
style="min-width: 320px"
>
<el-option
v-for="s in suites"
:key="s.id"
:label="`${s.name} (${s.caseCount})`"
:value="s.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="running" @click="runTests">
{{ t('smoke.btn.run') }}
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="lastRun" class="data-card summary-card" shadow="never">
<div class="summary-head">
<div>
<div class="summary-title">{{ t('smoke.last_run') }}</div>
<div class="summary-meta">
{{ formatTime(lastRun.startedAt) }} · {{ lastRun.durationMs }}ms · {{ lastRun.runId }}
</div>
</div>
<div class="summary-actions">
<div class="summary-stats">
<el-tag type="success">{{ t('smoke.stat.pass') }} {{ lastRun.passed }}</el-tag>
<el-tag type="danger">{{ t('smoke.stat.fail') }} {{ lastRun.failed }}</el-tag>
<el-tag>{{ t('smoke.stat.total') }} {{ lastRun.total }}</el-tag>
<el-tag type="warning">{{ passRate }}%</el-tag>
</div>
<el-button size="small" @click="copyText(runLogText)">{{ t('smoke.btn.copy_all') }}</el-button>
</div>
</div>
</el-card>
<el-card v-if="lastRun" class="data-card" shadow="never">
<div class="table-head">
<span class="table-title">{{ t('smoke.results_title') }}</span>
<el-select v-model="filterStatus" clearable :placeholder="t('common.all')" style="width: 120px">
<el-option :label="t('smoke.status.PASS')" value="PASS" />
<el-option :label="t('smoke.status.FAIL')" value="FAIL" />
</el-select>
</div>
<div class="table-wrap">
<el-table :key="locale" :data="filteredResults" stripe row-key="id">
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column type="expand" width="44">
<template #default="{ row }">
<div v-if="row.details?.length" class="case-details">
<pre>{{ row.details.join('\n\n') }}</pre>
</div>
<span v-else class="case-details-empty">{{ t('smoke.no_steps') }}</span>
</template>
</el-table-column>
<el-table-column prop="id" :label="t('smoke.col.id')" width="88" />
<el-table-column :label="t('smoke.col.suite')" width="120">
<template #default="{ row }">{{ suiteName(row.suite) }}</template>
</el-table-column>
<el-table-column prop="name" :label="t('smoke.col.name')" min-width="180" />
<el-table-column prop="uatRef" :label="t('smoke.col.uat')" width="88">
<template #default="{ row }">{{ row.uatRef ?? '—' }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="96">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ t(`smoke.status.${row.status}`) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('smoke.col.steps')" width="72" align="center">
<template #default="{ row }">{{ row.stepCount ?? '—' }}</template>
</el-table-column>
<el-table-column :label="t('smoke.col.duration')" width="96" align="right">
<template #default="{ row }">{{ row.durationMs }}ms</template>
</el-table-column>
<el-table-column :label="t('smoke.col.message')" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span :class="{ 'fail-msg': row.status === 'FAIL' }">{{ row.error || row.message || '—' }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="88" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="copyText(formatCaseBlock(row))">
{{ t('smoke.btn.copy_one') }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="log-panel">
<div class="log-head">
<span class="table-title">{{ t('smoke.log_title') }}</span>
<el-button size="small" @click="copyText(runLogText)">{{ t('smoke.btn.copy_all') }}</el-button>
</div>
<el-input
type="textarea"
class="log-textarea"
:model-value="runLogText"
readonly
:rows="18"
resize="vertical"
/>
</div>
</el-card>
<el-card v-else class="data-card empty-card" shadow="never">
<p>{{ t('smoke.empty') }}</p>
</el-card>
</div>
</template>
<style scoped>
.intro-text {
margin: 0 0 8px;
color: #666;
line-height: 1.5;
}
.intro-list {
margin: 0;
padding-left: 18px;
color: #888;
font-size: 13px;
line-height: 1.6;
}
.summary-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.summary-title {
font-size: 15px;
font-weight: 700;
}
.summary-meta {
font-size: 12px;
color: #888;
margin-top: 4px;
}
.summary-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.summary-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.log-panel {
margin-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 12px;
}
.log-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.log-textarea :deep(textarea) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
}
.table-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.table-title {
font-weight: 700;
}
.fail-msg {
color: var(--el-color-danger);
}
.case-details {
padding: 8px 12px 12px 48px;
}
.case-details pre {
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
color: #444;
}
.case-details-empty {
padding: 8px 12px 12px 48px;
color: #999;
font-size: 13px;
}
.empty-card {
text-align: center;
color: #888;
padding: 24px 0;
}
</style>

View File

@@ -18,7 +18,7 @@ import {
type PlayerDetail, type PlayerDetail,
type PlayerCreateForm, type PlayerCreateForm,
type PlayerEditForm, type PlayerEditForm,
} from './user-form.ts'; } from './user-form';
import { import {
formatAmount, formatAmount,
formatAmountFull, formatAmountFull,

View File

@@ -472,7 +472,7 @@ function openCreditSub(row: AgentSubAgentRow) {
creditContext.value = { creditContext.value = {
target: snapshotFromAgentRow(row), target: snapshotFromAgentRow(row),
parent: snapshotFromAgentRow({ 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), creditLimit: String(profile.value.creditLimit ?? 0),
usedCredit: String(profile.value.usedCredit ?? 0), usedCredit: String(profile.value.usedCredit ?? 0),
availableCredit: String(profile.value.availableCredit ?? 0), availableCredit: String(profile.value.availableCredit ?? 0),

View File

@@ -114,7 +114,7 @@ function openCredit(row: AgentSubAgentRow) {
creditContext.value = { creditContext.value = {
target: snapshotFromAgentRow(row), target: snapshotFromAgentRow(row),
parent: snapshotFromAgentRow({ 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), creditLimit: String(profile.value.creditLimit ?? 0),
usedCredit: String(profile.value.usedCredit ?? 0), usedCredit: String(profile.value.usedCredit ?? 0),
availableCredit: String(profile.value.availableCredit ?? 0), availableCredit: String(profile.value.availableCredit ?? 0),

View File

@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale'; import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api'; import api from '../../api';
import { ensureLeagueExpanded } from '../../utils/matchesListState.ts'; import { ensureLeagueExpanded } from '../../utils/matchesListState';
import { formatAmount } from '../../utils/format-amount'; import { formatAmount } from '../../utils/format-amount';
const props = defineProps<{ const props = defineProps<{
leagueId: string; leagueId: string;

View File

@@ -13,7 +13,7 @@ import {
formFromDetail, formFromDetail,
type AdminMatchDetail, type AdminMatchDetail,
type MatchCreateForm, type MatchCreateForm,
} from '../match-form.ts'; } from '../match-form';
import AdminSubNav from '../../components/AdminSubNav.vue'; import AdminSubNav from '../../components/AdminSubNav.vue';
const route = useRoute(); const route = useRoute();

View File

@@ -5,7 +5,7 @@ import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale'; import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api'; import api from '../../api';
import MatchMarketsPanel from './MatchMarketsPanel.vue'; import MatchMarketsPanel from './MatchMarketsPanel.vue';
import type { AdminMatchDetail } from '../match-form.ts'; import type { AdminMatchDetail } from '../match-form';
import AdminSubNav from '../../components/AdminSubNav.vue'; import AdminSubNav from '../../components/AdminSubNav.vue';
const route = useRoute(); const route = useRoute();

View File

@@ -3,9 +3,9 @@ import { ref, watch } from 'vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale'; import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api'; import api from '../../api';
import type { AdminMatchDetail } from '../match-form.ts'; import type { AdminMatchDetail } from '../match-form';
import { defaultSelectionName } from '../../utils/selectionDefaults.ts'; import { defaultSelectionName } from '../../utils/selectionDefaults';
import { adminSelectionLabel } from '../../utils/adminSelectionLabel.ts'; import { adminSelectionLabel } from '../../utils/adminSelectionLabel';
const props = defineProps<{ const props = defineProps<{
matchId: string; matchId: string;

View File

@@ -1,3 +1,5 @@
/// <reference types="vite/client" />
declare module '*.vue' { declare module '*.vue' {
import type { DefineComponent } from 'vue'; import type { DefineComponent } from 'vue';
const component: DefineComponent<object, object, unknown>; const component: DefineComponent<object, object, unknown>;

View File

@@ -2,6 +2,7 @@ import {
BadRequestException, BadRequestException,
Controller, Controller,
Delete, Delete,
ForbiddenException,
Get, Get,
Post, Post,
Put, Put,
@@ -9,13 +10,20 @@ import {
Body, Body,
Param, Param,
Query, Query,
UploadedFile,
UseGuards, UseGuards,
UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; 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 { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
import { ContentService } from '../../domains/operations/content/content.service'; import { ContentService } from '../../domains/operations/content/content.service';
import { CurrentUser, RequirePermissions } from '../../shared/common/decorators'; import { CurrentUser, RequirePermissions } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters'; import { jsonResponse } from '../../shared/common/filters';
import { getUploadRoot } from '../../shared/uploads/upload-paths';
import { UsersService } from '../../domains/identity/users.service'; import { UsersService } from '../../domains/identity/users.service';
import { AgentsService } from '../../domains/agent/agents.service'; import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.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 { SystemConfigService } from '../../shared/config/system-config.service';
import { P } from './admin-permissions'; import { P } from './admin-permissions';
import { DatabaseResetService } from '../../infrastructure/database/database-reset.service'; import { DatabaseResetService } from '../../infrastructure/database/database-reset.service';
import { SmokeTestService } from '../../domains/operations/smoke-tests/smoke-test.service';
import { import {
IsString, IsString,
IsNumber, IsNumber,
@@ -47,6 +56,77 @@ import {
} from 'class-validator'; } from 'class-validator';
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types'; 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<string, string> = {
'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('<script') || sample.includes('javascript:') || /\son[a-z]+\s*=/.test(sample)) {
throw new BadRequestException('Unsafe SVG content is not allowed');
}
}
}
function uploadFilename(file: UploadedImage) {
const fromMime = IMAGE_MIME_EXT[file.mimetype];
const fromName = extname(file.originalname || '').toLowerCase();
const ext = fromMime || fromName || '.img';
const base = (file.originalname || 'asset')
.replace(/\.[^.]+$/, '')
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 42) || 'asset';
return `${Date.now()}-${base}-${randomUUID().slice(0, 8)}${ext}`;
}
class CreateUserDto { class CreateUserDto {
@IsString() @IsString()
username!: string; username!: string;
@@ -746,6 +826,7 @@ export class AdminController {
private systemConfig: SystemConfigService, private systemConfig: SystemConfigService,
private bettingLimits: BettingLimitsService, private bettingLimits: BettingLimitsService,
private databaseReset: DatabaseResetService, private databaseReset: DatabaseResetService,
private smokeTests: SmokeTestService,
) {} ) {}
@Get('dashboard') @Get('dashboard')
@@ -985,6 +1066,25 @@ export class AdminController {
return jsonResponse(result); return jsonResponse(result);
} }
@Get('agents/credit-transactions')
@RequirePermissions(P.agentsView, P.reports)
async listAgentCreditTransactions(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('agentId') agentId?: string,
@Query('keyword') keyword?: string,
@Query('transactionType') transactionType?: string,
) {
const result = await this.agents.listCreditTransactions({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 20,
agentId: agentId ? BigInt(agentId) : undefined,
keyword,
transactionType,
});
return jsonResponse(result);
}
@Get('agents/:id') @Get('agents/:id')
@RequirePermissions(P.agentsView) @RequirePermissions(P.agentsView)
async getAgentDetail(@Param('id') id: string) { async getAgentDetail(@Param('id') id: string) {
@@ -1731,6 +1831,33 @@ export class AdminController {
return jsonResponse(result); return jsonResponse(result);
} }
@Post('uploads')
@RequirePermissions(P.content, P.matches)
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
async uploadAsset(
@CurrentUser() user: AdminUploadUser,
@UploadedFile() file: UploadedImage | undefined,
@Query('category') rawCategory?: string,
) {
const category = uploadCategory(rawCategory);
assertUploadPermission(user, category);
assertImageFile(file);
const filename = uploadFilename(file);
const root = getUploadRoot();
const targetDir = join(root, category);
await mkdir(targetDir, { recursive: true });
await writeFile(join(targetDir, filename), file.buffer);
return jsonResponse({
category,
filename,
size: file.size,
mimeType: file.mimetype,
url: `/uploads/${category}/${filename}`,
});
}
@Get('contents') @Get('contents')
@RequirePermissions(P.content, P.reports) @RequirePermissions(P.content, P.reports)
async listContents( async listContents(
@@ -1786,6 +1913,45 @@ export class AdminController {
return jsonResponse(messages); return jsonResponse(messages);
} }
@Get('smoke-tests/suites')
@RequirePermissions(P.settings)
async smokeTestSuites() {
return jsonResponse({
suites: this.smokeTests.listSuites(),
cases: this.smokeTests.listCases(),
lastRun: this.smokeTests.getLastRun(),
});
}
@Get('smoke-tests/last-run')
@RequirePermissions(P.settings)
async smokeTestLastRun() {
return jsonResponse(this.smokeTests.getLastRun());
}
@Post('smoke-tests/run')
@RequirePermissions(P.settings)
async runSmokeTests(
@CurrentUser('id') operatorId: bigint,
@Body() body: { suites?: string[] },
) {
const summary = await this.smokeTests.run(body?.suites, operatorId);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'RUN_SMOKE_TESTS',
module: 'SYSTEM',
targetId: summary.runId,
afterData: {
passed: summary.passed,
failed: summary.failed,
total: summary.total,
suites: summary.suites,
},
});
return jsonResponse(summary);
}
@Get('audit-logs') @Get('audit-logs')
@RequirePermissions(P.audit) @RequirePermissions(P.audit)
async auditLogs( async auditLogs(

View File

@@ -13,6 +13,7 @@ import { ContentModule } from '../../domains/operations/content/content.module';
import { I18nModule } from '../../domains/operations/i18n/i18n.module'; import { I18nModule } from '../../domains/operations/i18n/i18n.module';
import { BetsModule } from '../../domains/betting/bets.module'; import { BetsModule } from '../../domains/betting/bets.module';
import { DatabaseModule } from '../../infrastructure/database/database.module'; import { DatabaseModule } from '../../infrastructure/database/database.module';
import { SmokeTestModule } from '../../domains/operations/smoke-tests/smoke-test.module';
@Module({ @Module({
imports: [ imports: [
@@ -27,6 +28,7 @@ import { DatabaseModule } from '../../infrastructure/database/database.module';
I18nModule, I18nModule,
BetsModule, BetsModule,
DatabaseModule, DatabaseModule,
SmokeTestModule,
], ],
controllers: [AdminController], controllers: [AdminController],
providers: [AdminDashboardService, PermissionsGuard], providers: [AdminDashboardService, PermissionsGuard],

View File

@@ -693,6 +693,108 @@ export class AgentsService {
}; };
} }
async listCreditTransactions(params: {
page?: number;
pageSize?: number;
agentId?: bigint;
keyword?: string;
transactionType?: string;
}) {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20));
const skip = (page - 1) * pageSize;
const where: Prisma.AgentCreditTransactionWhereInput = {};
if (params.transactionType?.trim()) {
where.transactionType = params.transactionType.trim();
}
const keyword = params.keyword?.trim();
if (keyword) {
const matched = await this.prisma.user.findMany({
where: {
userType: 'AGENT',
deletedAt: null,
username: { contains: keyword, mode: 'insensitive' },
},
select: { id: true },
take: 50,
});
const agentUserIds = matched.map((u) => 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( async updateAgentAdmin(
agentId: bigint, agentId: bigint,
data: { data: {

View File

@@ -474,10 +474,33 @@ export class CashbackService {
} }
async getUserCashbacks(userId: bigint) { async getUserCashbacks(userId: bigint) {
return this.prisma.cashbackItem.findMany({ const items = await this.prisma.cashbackItem.findMany({
where: { userId }, where: { userId, batch: { status: 'CONFIRMED' } },
include: { batch: true }, include: {
batch: {
select: {
batchNo: true,
periodStart: true,
periodEnd: true,
confirmedAt: true,
status: true,
},
},
},
orderBy: { createdAt: 'desc' }, 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,
}));
} }
} }

View File

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

View File

@@ -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<BetFlowFixtureIds> {
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<void> {
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 } });
}

View File

@@ -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<void>;
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.53 球大球赢',
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<string, { name: string; description: string }> = {
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下注→冻结→录分→结算→钱包/代理额度(临时数据自动清理)',
},
};

View File

@@ -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<Array<{ ok: number }>>`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',
});
},
},
];
}

View File

@@ -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<T>(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<void>,
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');
});
}

View File

@@ -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 {}

View File

@@ -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<string, number>();
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<SmokeTestRunSummary> {
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<SmokeTestCaseResult> {
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),
};
}
}
}

View File

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

View File

@@ -2,17 +2,16 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express'; import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './shared/common/filters'; import { GlobalExceptionFilter } from './shared/common/filters';
import { getUploadRoot } from './shared/uploads/upload-paths';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableShutdownHooks(); app.enableShutdownHooks();
app.useGlobalFilters(new GlobalExceptionFilter()); app.useGlobalFilters(new GlobalExceptionFilter());
const uploadDir = process.env.UPLOAD_DIR || join(__dirname, '..', '..', 'uploads'); app.useStaticAssets(getUploadRoot(), { prefix: '/uploads/' });
app.useStaticAssets(uploadDir, { prefix: '/uploads/' });
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
app.enableCors({ origin: true, credentials: true }); app.enableCors({ origin: true, credentials: true });

View File

@@ -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];
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 913 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 771 KiB

View File

@@ -28,7 +28,8 @@ const isDetailPage = computed(() => {
p.startsWith('/bet/') || p.startsWith('/bet/') ||
p.startsWith('/bets/') || p.startsWith('/bets/') ||
p.startsWith('/wallet/') || p.startsWith('/wallet/') ||
p === '/profile/edit' p === '/profile/edit' ||
p === '/profile/cashbacks'
); );
}); });

View File

@@ -92,7 +92,7 @@ const i18n = createI18n({
tx_bet_push: '投注退水', tx_bet_push: '投注退水',
tx_bet_refund: '投注退款', tx_bet_refund: '投注退款',
tx_bet_void: '投注撤销', tx_bet_void: '投注撤销',
tx_cashback: '返水发放', tx_cashback: '返水入账',
tx_resettle: '重新结算', tx_resettle: '重新结算',
stats_income: '收入', stats_income: '收入',
stats_expense: '支出', stats_expense: '支出',
@@ -120,6 +120,23 @@ const i18n = createI18n({
ref_bet: '投注', ref_bet: '投注',
ref_deposit: '存款', ref_deposit: '存款',
ref_withdraw: '提款', 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: {
bet_slip: '投注单', bet_slip: '投注单',
@@ -376,7 +393,7 @@ const i18n = createI18n({
tx_bet_push: 'Bet Push', tx_bet_push: 'Bet Push',
tx_bet_refund: 'Bet Refund', tx_bet_refund: 'Bet Refund',
tx_bet_void: 'Bet Voided', tx_bet_void: 'Bet Voided',
tx_cashback: 'Cashback Distribution', tx_cashback: 'Cashback credit',
tx_resettle: 'Resettlement', tx_resettle: 'Resettlement',
stats_income: 'Income', stats_income: 'Income',
stats_expense: 'Expense', stats_expense: 'Expense',
@@ -404,6 +421,23 @@ const i18n = createI18n({
ref_bet: 'Bet', ref_bet: 'Bet',
ref_deposit: 'Deposit', ref_deposit: 'Deposit',
ref_withdraw: 'Withdraw', 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: {
bet_slip: 'Bet Slip', bet_slip: 'Bet Slip',
@@ -666,7 +700,7 @@ const i18n = createI18n({
tx_bet_push: 'Pertaruhan Seri', tx_bet_push: 'Pertaruhan Seri',
tx_bet_refund: 'Bayaran Balik', tx_bet_refund: 'Bayaran Balik',
tx_bet_void: 'Pertaruhan Dibatalkan', tx_bet_void: 'Pertaruhan Dibatalkan',
tx_cashback: 'Pembayaran Cashback', tx_cashback: 'Kredit rebat',
tx_resettle: 'Penyelesaian Semula', tx_resettle: 'Penyelesaian Semula',
stats_income: 'Pendapatan', stats_income: 'Pendapatan',
stats_expense: 'Perbelanjaan', stats_expense: 'Perbelanjaan',
@@ -694,6 +728,23 @@ const i18n = createI18n({
ref_bet: 'Pertaruhan', ref_bet: 'Pertaruhan',
ref_deposit: 'Deposit', ref_deposit: 'Deposit',
ref_withdraw: 'Pengeluaran', 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: {
bet_slip: 'Slip Pertaruhan', bet_slip: 'Slip Pertaruhan',

View File

@@ -18,8 +18,10 @@ const router = createRouter({
{ path: 'bets/:betNo', component: () => import('../views/BetDetailView.vue') }, { path: 'bets/:betNo', component: () => import('../views/BetDetailView.vue') },
{ path: 'wallet', component: () => import('../views/WalletView.vue') }, { path: 'wallet', component: () => import('../views/WalletView.vue') },
{ path: 'wallet/detail', component: () => import('../views/WalletDetailView.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: 'wallet/transactions/:transactionId', component: () => import('../views/WalletTransactionDetailView.vue') },
{ path: 'profile', component: () => import('../views/ProfileView.vue') }, { path: 'profile', component: () => import('../views/ProfileView.vue') },
{ path: 'profile/cashbacks', component: () => import('../views/CashbackRecordsView.vue') },
{ path: 'profile/edit', component: () => import('../views/ProfileEditView.vue') }, { path: 'profile/edit', component: () => import('../views/ProfileEditView.vue') },
], ],
}, },

View File

@@ -0,0 +1,354 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney, formatMoneyCompact } from '../utils/localeDisplay';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const highlightBatchNo = computed(() => {
const q = route.query.batchNo;
return typeof q === 'string' ? q.trim() : '';
});
type CashbackRecord = {
id: string;
batchNo: string;
periodStart: string;
periodEnd: string;
confirmedAt: string | null;
effectiveStake: string;
betCount: number;
rate: string;
amount: string;
createdAt: string;
};
const items = ref<CashbackRecord[]>([]);
const loading = ref(true);
const totalAmount = computed(() =>
items.value.reduce((sum, row) => sum + Math.abs(parseFloat(row.amount) || 0), 0),
);
function formatPeriod(start: string, end: string) {
const opts: Intl.DateTimeFormatOptions = { month: '2-digit', day: '2-digit' };
const s = new Date(start).toLocaleDateString(locale.value, opts);
const e = new Date(end).toLocaleDateString(locale.value, opts);
return `${s} ${e}`;
}
function formatRate(rate: string) {
const n = parseFloat(rate);
if (!Number.isFinite(n)) return rate;
return `${(n * 100).toFixed(2)}%`;
}
function formatTime(v: string | null) {
if (!v) return '—';
return new Date(v).toLocaleString(locale.value, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
async function fetchRecords() {
loading.value = true;
try {
const { data } = await api.get('/player/cashbacks');
items.value = data.data ?? [];
} catch {
items.value = [];
} finally {
loading.value = false;
}
}
useOnLocaleChange(fetchRecords);
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: fetchRecords,
});
onMounted(fetchRecords);
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
opacity: Math.min(pullDistance.value / 48, 1),
});
</script>
<template>
<div class="cashback-page">
<div class="top-bar">
<button type="button" class="back-btn" @click="router.back()">
&#x2039; {{ t('history.back') }}
</button>
</div>
<div class="pull-indicator" :style="pullIndicatorStyle()">
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
<span class="loading-text">{{ t('bet.loading') }}</span>
</div>
<template v-else>
<div class="hero">
<span class="hero-amount">{{ formatMoney(totalAmount, locale) }}</span>
<span class="hero-sub">
{{ t('cashback.total_received') }}
<template v-if="items.length">
· {{ t('cashback.record_count', { n: items.length }) }}
</template>
</span>
</div>
<p class="ledger-hint">{{ t('cashback.ledger_hint') }}</p>
<p v-if="items.length" class="section-title">{{ t('cashback.list_title') }}</p>
<div v-if="items.length" class="list-card">
<article
v-for="row in items"
:key="row.id"
class="record-row"
:class="{ 'record-row--highlight': highlightBatchNo && row.batchNo === highlightBatchNo }"
>
<div class="row-main">
<div class="row-left">
<span class="row-amount">{{ formatMoney(row.amount, locale) }}</span>
<span class="row-period">{{ formatPeriod(row.periodStart, row.periodEnd) }}</span>
</div>
<span class="row-rate">{{ formatRate(row.rate) }}</span>
</div>
<div class="row-detail">
<span>{{ t('cashback.effective_stake') }} {{ formatMoneyCompact(row.effectiveStake, locale) }}</span>
<span>{{ t('cashback.bet_count', { n: row.betCount }) }}</span>
</div>
<div class="row-foot">
<span class="row-batch">{{ row.batchNo }}</span>
<span class="row-time">{{ formatTime(row.confirmedAt ?? row.createdAt) }}</span>
</div>
</article>
</div>
<div v-else class="empty">
<p class="empty-title">{{ t('cashback.empty') }}</p>
<p class="empty-hint">{{ t('cashback.empty_hint') }}</p>
</div>
</template>
</div>
</template>
<style scoped>
.cashback-page {
padding-bottom: 24px;
}
.top-bar {
margin-bottom: 4px;
}
.back-btn {
background: none;
border: none;
color: var(--primary-light, #d4af37);
font-size: 15px;
font-weight: 700;
padding: 4px 0 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 2px;
}
.pull-indicator {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: height 0.15s ease;
}
.state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
color: var(--text-muted);
padding: 56px 20px;
font-weight: 600;
font-size: 14px;
}
.loading-text {
font-size: 13px;
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 18px 16px 16px;
margin-bottom: 14px;
border-radius: var(--radius, 12px);
background: linear-gradient(135deg, rgba(212, 175, 55, 0.14), rgba(26, 26, 26, 0.92));
border: 1px solid rgba(212, 175, 55, 0.28);
text-align: center;
}
.hero-amount {
font-size: 28px;
font-weight: 800;
color: #f0b90b;
line-height: 1.2;
text-shadow: 0 0 20px rgba(240, 185, 11, 0.2);
}
.hero-sub {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
}
.section-title {
font-size: 10.5px;
color: #555;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin: 0 0 10px;
}
.list-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
backdrop-filter: blur(10px);
}
.record-row {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.record-row:last-child {
border-bottom: none;
}
.record-row--highlight {
background: rgba(212, 175, 55, 0.08);
box-shadow: inset 3px 0 0 #d4af37;
}
.ledger-hint {
margin: 0 0 12px;
font-size: 12px;
line-height: 1.5;
color: #666;
}
.row-main {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.row-left {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.row-amount {
font-size: 17px;
font-weight: 800;
color: var(--primary-light);
}
.row-period {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
}
.row-rate {
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: #f0b90b;
background: rgba(240, 185, 11, 0.1);
border: 1px solid rgba(240, 185, 11, 0.22);
border-radius: 999px;
padding: 3px 10px;
}
.row-detail {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
}
.row-foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed rgba(255, 255, 255, 0.06);
font-size: 11px;
color: #666;
}
.row-batch {
font-family: ui-monospace, monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 58%;
}
.row-time {
flex-shrink: 0;
}
.empty {
text-align: center;
padding: 48px 20px 32px;
}
.empty-title {
margin: 0 0 8px;
font-size: 15px;
font-weight: 700;
color: var(--text-muted);
}
.empty-hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: #666;
}
</style>

View File

@@ -115,10 +115,19 @@ function logout() {
</div> </div>
</div> </div>
</div> </div>
<span class="wallet-chevron" aria-hidden="true">&#x203A;</span>
</div> </div>
<section class="settings-group"> <section class="settings-group">
<RouterLink to="/wallet/detail" class="settings-cell settings-cell--gold-entry">
<span class="cell-label">{{ t('wallet.view_all') }}</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<RouterLink to="/profile/cashbacks" class="settings-cell settings-cell--gold-entry">
<span class="cell-label">{{ t('wallet.view_cashbacks') }}</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<RouterLink to="/profile/edit" class="settings-cell"> <RouterLink to="/profile/edit" class="settings-cell">
<span class="cell-label">{{ t('profile.edit') }}</span> <span class="cell-label">{{ t('profile.edit') }}</span>
<span class="cell-chevron" aria-hidden="true"></span> <span class="cell-chevron" aria-hidden="true"></span>
@@ -197,26 +206,6 @@ function logout() {
transition: filter 0.1s; 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 { .wallet-banner-img {
width: 100%; width: 100%;
height: auto; height: auto;
@@ -378,6 +367,32 @@ function logout() {
background: rgba(255, 255, 255, 0.03); 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 { .settings-cell--stack {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;

View File

@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import api from '../api'; import api from '../api';
import { formatMoney } from '../utils/localeDisplay'; import { formatMoney } from '../utils/localeDisplay';
import { txTypeKey } from '../utils/walletTx'; import { txTypeKey, isCashbackType } from '../utils/walletTx';
import GoldSpinner from '../components/GoldSpinner.vue'; import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh'; import { usePullToRefresh } from '../composables/usePullToRefresh';
@@ -82,7 +82,17 @@ const frozenChanged = computed(() => {
return tx.value.frozenBefore !== tx.value.frozenAfter; 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(() => { const referenceLabel = computed(() => {
if (isCashbackTx.value) return t('wallet.ref_cashback');
if (!tx.value?.referenceType) return ''; if (!tx.value?.referenceType) return '';
const rt = tx.value.referenceType.toUpperCase(); const rt = tx.value.referenceType.toUpperCase();
if (rt === 'BET') return t('wallet.ref_bet'); if (rt === 'BET') return t('wallet.ref_bet');
@@ -95,6 +105,11 @@ function goBetDetail() {
if (!tx.value?.betNo) return; if (!tx.value?.betNo) return;
router.push(`/bets/${tx.value.betNo}`); router.push(`/bets/${tx.value.betNo}`);
} }
function goCashbackDetail() {
if (!cashbackBatchNo.value) return;
router.push({ path: '/wallet/cashbacks', query: { batchNo: cashbackBatchNo.value } });
}
</script> </script>
<template> <template>
@@ -169,6 +184,9 @@ function goBetDetail() {
<button v-if="tx.betNo" type="button" class="bet-link" @click="goBetDetail"> <button v-if="tx.betNo" type="button" class="bet-link" @click="goBetDetail">
{{ t('wallet.detail_bet_link') }} · {{ tx.betNo }} {{ t('wallet.detail_bet_link') }} · {{ tx.betNo }}
</button> </button>
<button v-if="cashbackBatchNo" type="button" class="bet-link" @click="goCashbackDetail">
{{ t('wallet.detail_cashback_link') }} · {{ cashbackBatchNo }}
</button>
</section> </section>
<section class="section"> <section class="section">

View File

@@ -40,6 +40,10 @@ function goWalletDetail() {
router.push('/wallet/detail'); router.push('/wallet/detail');
} }
function goCashbacks() {
router.push('/wallet/cashbacks');
}
async function fetchTransactions() { async function fetchTransactions() {
loading.value = true; loading.value = true;
try { try {
@@ -80,8 +84,12 @@ const pullIndicatorStyle = () => ({
</div> </div>
<template v-else> <template v-else>
<div v-if="items.length" class="top-bar"> <div class="top-bar">
<button type="button" class="more-link secondary" @click="goCashbacks">
{{ t('wallet.view_cashbacks_detail') }} &#x203A;
</button>
<button <button
v-if="items.length"
type="button" type="button"
class="more-link" class="more-link"
@click="goWalletDetail" @click="goWalletDetail"
@@ -191,12 +199,16 @@ const pullIndicatorStyle = () => ({
.top-bar { .top-bar {
display: flex; display: flex;
justify-content: flex-end; justify-content: space-between;
align-items: center; align-items: center;
padding: 0 4px 2px; padding: 0 4px 2px;
margin-top: -8px; margin-top: -8px;
} }
.top-bar .more-link.secondary {
color: #f0b90b;
}
.more-link { .more-link {
background: none; background: none;
border: none; border: none;

View File

@@ -20,27 +20,31 @@
- [ ] 前台可展示赛事和盘口 - [ ] 前台可展示赛事和盘口
- [ ] 玩家可单关下注 - [ ] 玩家可单关下注
- [ ] 玩家可 2-5 串 1 - [ ] 玩家可 2-5 串 1
- [ ] 同场串关被禁止 - [ ] ~~同场串关被禁止~~v1 产品决定:允许同场串关,跳过)
- [ ] 四分之一盘口不能进入串关 - [ ] 四分之一盘口不能进入串关
- [ ] 开赛自动封盘 - [ ] 开赛自动封盘
- [ ] 后台可录入比分并生成预览 - [ ] 后台可录入比分并生成预览
- [ ] 确认结算后钱包正确变化 - [ ] 确认结算后钱包正确变化
- [ ] 玩家可查看注单和账变 - [ ] 玩家可查看注单和账变
- [ ] 玩家可查看返水明细,且与账单「反水」入账金额一致
- [ ] 后台可生成并发放返水 - [ ] 后台可生成并发放返水
- [ ] Banner/公告/走马灯可配置 - [ ] Banner/公告/走马灯可配置
- [ ] 所有关键操作有日志 - [ ] 所有关键操作有日志
- [ ] 平台后台「自动化测试」运行所选套件,全部通过(约 50 条用例,含逐步 input/expected/actual 明细)
## 回归测试流程 ## 回归测试流程
1. 运行单元测试:`pnpm --filter @thebet365/api test` 1. 运行单元测试:`pnpm --filter @thebet365/api test`(规则类用例,不含 DB 全链路)
2. 代理上分 → 玩家下注 → 封盘 → 录入比分 → 预览 → 确认结算 2. 或平台后台 → **自动化测试** → 选择套件(含「下注结算链路」)→ **运行测试**
3. 验证钱包余额与代理额度变化 3. 代理上分 → 玩家下注 → 封盘 → 录入比分 → 预览 → 确认结算
4. 测试串关限制(同场、四分之一球、冠军竞猜) 4. 验证钱包余额与代理额度变化
5. 测试返水批次生成与确认 5. 测试串关限制(四分之一球、冠军竞猜;同场串关按 v1 允许)
6. 测试返水批次生成与确认
7. 返水对账:后台发放后,玩家「账单-反水」入账金额 =「返水明细」对应批次金额;账变详情可跳转返水明细
## 备份与回滚 ## 备份与回滚
- PostgreSQL每日 `pg_dump` 备份 - PostgreSQL每日 `pg_dump` 备份(脚本:`scripts/backup-db.ps1`
- 回滚:恢复备份 + 回退部署版本 - 回滚:恢复备份 + 回退部署版本
- 结算错误:使用重结算冲正流程,禁止直接改余额 - 结算错误:使用重结算冲正流程,禁止直接改余额

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 117 KiB

23
scripts/backup-db.ps1 Normal file
View File

@@ -0,0 +1,23 @@
# PostgreSQL backup for TheBet365 (dev/docker-compose defaults)
param(
[string]$OutDir = ".\backups",
[string]$DbName = "thebet365",
[string]$DbUser = "thebet365",
[string]$DbHost = "127.0.0.1",
[int]$DbPort = 5432
)
$ErrorActionPreference = "Stop"
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$outFile = Join-Path $OutDir "thebet365-$stamp.sql"
Write-Host "Backing up $DbName to $outFile ..."
$env:PGPASSWORD = $env:THEBET365_DB_PASSWORD
if (-not $env:PGPASSWORD) {
Write-Warning "Set THEBET365_DB_PASSWORD if your Postgres requires a password."
}
pg_dump -h $DbHost -p $DbPort -U $DbUser -d $DbName -F p -f $outFile
Write-Host "Done: $outFile"