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>
@@ -37,9 +37,11 @@ const zh: Record<string, string> = {
|
||||
'nav.matches': '赛事管理',
|
||||
'nav.outrights': '优胜冠军',
|
||||
'nav.bets': '注单管理',
|
||||
'nav.credit_transactions': '额度流水',
|
||||
'nav.cashback': '返水管理',
|
||||
'nav.contents': '公共管理',
|
||||
'nav.audit': '操作日志',
|
||||
'nav.smoke_tests': '自动化测试',
|
||||
'nav.players': '直属玩家',
|
||||
'nav.subAgents': '下级代理',
|
||||
'nav.myBets': '注单查询',
|
||||
@@ -215,9 +217,11 @@ const en: Record<string, string> = {
|
||||
'nav.matches': 'Matches',
|
||||
'nav.outrights': 'Outrights',
|
||||
'nav.bets': 'Bets',
|
||||
'nav.credit_transactions': 'Credit ledger',
|
||||
'nav.cashback': 'Cashback',
|
||||
'nav.contents': 'Public Content',
|
||||
'nav.audit': 'Audit Log',
|
||||
'nav.smoke_tests': 'Smoke tests',
|
||||
'nav.players': 'My Players',
|
||||
'nav.subAgents': 'Sub-Agents',
|
||||
'nav.myBets': 'Bet Search',
|
||||
@@ -393,9 +397,11 @@ const ms: Record<string, string> = {
|
||||
'nav.matches': 'Perlawanan',
|
||||
'nav.outrights': 'Juara',
|
||||
'nav.bets': 'Pertaruhan',
|
||||
'nav.credit_transactions': 'Lejar kredit',
|
||||
'nav.cashback': 'Rebat',
|
||||
'nav.contents': 'Kandungan awam',
|
||||
'nav.audit': 'Log audit',
|
||||
'nav.smoke_tests': 'Ujian asap',
|
||||
'nav.players': 'Pemain saya',
|
||||
'nav.subAgents': 'Sub-ejen',
|
||||
'nav.myBets': 'Carian pertaruhan',
|
||||
|
||||
@@ -129,7 +129,13 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'agent.credit.decrease': 'Kurang',
|
||||
'agent.col.credit_type': 'Jenis',
|
||||
'agent.col.credit_change': 'Perubahan',
|
||||
'agent.col.credit_before': 'Sebelum',
|
||||
'agent.col.credit_after': 'Selepas',
|
||||
'agent.credit_tx.filter_agent_ph': 'Nama pengguna ejen',
|
||||
'agent.credit_tx.filter_agent_id': 'ID ejen',
|
||||
'agent.credit_tx.filter_agent_id_ph': 'ID pengguna',
|
||||
'agent.credit_tx.col.operator': 'Operator',
|
||||
'agent.credit_tx.view_all': 'Lihat semua lejar kredit',
|
||||
'agent.col.no_records': 'Tiada rekod',
|
||||
'agent.btn.confirm_adjust': 'Sahkan',
|
||||
'agent.field.select_user': 'Pilih pengguna',
|
||||
@@ -597,4 +603,39 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'msg.freeze_extra': ' Mereka tidak akan dapat log masuk.',
|
||||
'msg.freeze_done': '{action} selesai',
|
||||
'msg.freeze_failed': '{action} gagal',
|
||||
|
||||
'smoke.intro': 'Jalankan ujian asap automatik: penyelesaian, peraturan pertaruhan, kredit ejen, rebat, sondakan DB, dan integrasi pertaruhan→penyelesaian→dompet.',
|
||||
'smoke.intro_rule': 'Kes peraturan menggunakan logik sama seperti ujian unit Jest; tiada data perniagaan ditulis.',
|
||||
'smoke.intro_db': 'Kit Database hanya semak sambungan dan konfigurasi (baca sahaja).',
|
||||
'smoke.intro_bet_flow': 'Kit aliran pertaruhan mencipta perlawanan/pemain sementara, mengesahkan bekuan/payout/kredit ejen, kemudian membersihkan.',
|
||||
'smoke.intro_note': 'Merangkumi kebanyakan regresi UAT; semak rebat secara manual jika perlu.',
|
||||
'smoke.field.suites': 'Kit ujian',
|
||||
'smoke.ph.suites': 'Pilih kit untuk dijalankan',
|
||||
'smoke.btn.run': 'Jalankan ujian',
|
||||
'smoke.last_run': 'Jalanan terakhir',
|
||||
'smoke.results_title': 'Keputusan kes',
|
||||
'smoke.empty': 'Belum dijalankan. Klik Jalankan ujian.',
|
||||
'smoke.stat.pass': 'Lulus',
|
||||
'smoke.stat.fail': 'Gagal',
|
||||
'smoke.stat.total': 'Jumlah',
|
||||
'smoke.col.id': 'ID',
|
||||
'smoke.col.suite': 'Kit',
|
||||
'smoke.col.name': 'Kes',
|
||||
'smoke.col.uat': 'UAT',
|
||||
'smoke.col.duration': 'Masa',
|
||||
'smoke.col.steps': 'Langkah',
|
||||
'smoke.col.message': 'Mesej',
|
||||
'smoke.no_steps': 'Tiada butiran langkah',
|
||||
'smoke.status.PASS': 'Lulus',
|
||||
'smoke.status.FAIL': 'Gagal',
|
||||
'smoke.status.SKIP': 'Langkau',
|
||||
'smoke.msg.all_passed': 'Semua lulus ({n})',
|
||||
'smoke.msg.has_failures': '{n} kes gagal',
|
||||
'smoke.msg.run_failed': 'Gagal menjalankan ujian',
|
||||
'smoke.log_title': 'Log terperinci',
|
||||
'smoke.btn.copy_all': 'Salin semua log',
|
||||
'smoke.btn.copy_one': 'Salin',
|
||||
'smoke.msg.copy_ok': 'Disalin ke papan keratan',
|
||||
'smoke.msg.copy_failed': 'Gagal menyalin — pilih log secara manual',
|
||||
'audit.action.RUN_SMOKE_TESTS': 'Jalankan ujian asap',
|
||||
};
|
||||
|
||||
@@ -132,7 +132,13 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'agent.credit.decrease': '减少',
|
||||
'agent.col.credit_type': '类型',
|
||||
'agent.col.credit_change': '变动',
|
||||
'agent.col.credit_before': '变动前',
|
||||
'agent.col.credit_after': '变动后',
|
||||
'agent.credit_tx.filter_agent_ph': '代理用户名',
|
||||
'agent.credit_tx.filter_agent_id': '代理 ID',
|
||||
'agent.credit_tx.filter_agent_id_ph': '用户 ID',
|
||||
'agent.credit_tx.col.operator': '操作人',
|
||||
'agent.credit_tx.view_all': '查看全部额度流水',
|
||||
'agent.col.no_records': '暂无记录',
|
||||
'agent.btn.confirm_adjust': '确认调整',
|
||||
'agent.field.select_user': '选择用户',
|
||||
@@ -692,6 +698,41 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'msg.freeze_extra': '冻结后该账号将无法登录。',
|
||||
'msg.freeze_done': '已{action}',
|
||||
'msg.freeze_failed': '{action}失败',
|
||||
|
||||
'smoke.intro': '在后台一键运行自动化冒烟测试,覆盖结算引擎、下注规则、代理额度逻辑、返水规则、数据库探针,以及下注→结算→钱包全链路集成用例。',
|
||||
'smoke.intro_rule': '规则类用例与 Jest 单元测试同源逻辑,不写入业务数据。',
|
||||
'smoke.intro_db': '「数据库」套件仅做连接与配置探针(只读)。',
|
||||
'smoke.intro_bet_flow': '「下注结算链路」套件会创建临时赛事/玩家并自动清理,验证冻结、派彩与代理额度。',
|
||||
'smoke.intro_note': '可替代大部分 UAT 手工回归;发返水等仍建议抽样人工确认。',
|
||||
'smoke.field.suites': '测试套件',
|
||||
'smoke.ph.suites': '选择要运行的套件',
|
||||
'smoke.btn.run': '运行测试',
|
||||
'smoke.last_run': '最近一次运行',
|
||||
'smoke.results_title': '用例结果',
|
||||
'smoke.empty': '尚未运行测试,请点击「运行测试」。',
|
||||
'smoke.stat.pass': '通过',
|
||||
'smoke.stat.fail': '失败',
|
||||
'smoke.stat.total': '合计',
|
||||
'smoke.col.id': '编号',
|
||||
'smoke.col.suite': '套件',
|
||||
'smoke.col.name': '用例',
|
||||
'smoke.col.uat': 'UAT',
|
||||
'smoke.col.duration': '耗时',
|
||||
'smoke.col.steps': '步骤',
|
||||
'smoke.col.message': '说明',
|
||||
'smoke.no_steps': '无步骤明细',
|
||||
'smoke.status.PASS': '通过',
|
||||
'smoke.status.FAIL': '失败',
|
||||
'smoke.status.SKIP': '跳过',
|
||||
'smoke.msg.all_passed': '全部通过({n} 项)',
|
||||
'smoke.msg.has_failures': '有 {n} 项失败,请查看明细',
|
||||
'smoke.msg.run_failed': '测试运行失败',
|
||||
'smoke.log_title': '详细日志',
|
||||
'smoke.btn.copy_all': '复制全部日志',
|
||||
'smoke.btn.copy_one': '复制',
|
||||
'smoke.msg.copy_ok': '已复制到剪贴板',
|
||||
'smoke.msg.copy_failed': '复制失败,请手动选中日志复制',
|
||||
'audit.action.RUN_SMOKE_TESTS': '运行自动化测试',
|
||||
};
|
||||
|
||||
export const adminPagesEn: Record<string, string> = {
|
||||
@@ -827,7 +868,13 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'agent.credit.decrease': 'Decrease',
|
||||
'agent.col.credit_type': 'Type',
|
||||
'agent.col.credit_change': 'Change',
|
||||
'agent.col.credit_before': 'Before',
|
||||
'agent.col.credit_after': 'After',
|
||||
'agent.credit_tx.filter_agent_ph': 'Agent username',
|
||||
'agent.credit_tx.filter_agent_id': 'Agent ID',
|
||||
'agent.credit_tx.filter_agent_id_ph': 'User ID',
|
||||
'agent.credit_tx.col.operator': 'Operator',
|
||||
'agent.credit_tx.view_all': 'View all credit ledger',
|
||||
'agent.col.no_records': 'No records',
|
||||
'agent.btn.confirm_adjust': 'Confirm',
|
||||
'agent.field.select_user': 'Select user',
|
||||
@@ -1388,4 +1435,39 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'msg.freeze_extra': ' They will not be able to sign in.',
|
||||
'msg.freeze_done': '{action} completed',
|
||||
'msg.freeze_failed': '{action} failed',
|
||||
|
||||
'smoke.intro': 'Run automated smoke tests from the admin console: settlement, betting rules, agent credit logic, cashback rules, read-only DB probes, and bet→settle→wallet integration.',
|
||||
'smoke.intro_rule': 'Rule cases reuse the same logic as Jest unit tests; no business data is written.',
|
||||
'smoke.intro_db': 'The Database suite only checks connectivity and config (read-only).',
|
||||
'smoke.intro_bet_flow': 'The Bet-flow suite creates temporary matches/players, verifies freeze/payout/agent credit, then cleans up.',
|
||||
'smoke.intro_note': 'Covers most UAT regression; spot-check cashback payout manually if needed.',
|
||||
'smoke.field.suites': 'Suites',
|
||||
'smoke.ph.suites': 'Select suites to run',
|
||||
'smoke.btn.run': 'Run tests',
|
||||
'smoke.last_run': 'Last run',
|
||||
'smoke.results_title': 'Case results',
|
||||
'smoke.empty': 'No run yet. Click Run tests.',
|
||||
'smoke.stat.pass': 'Pass',
|
||||
'smoke.stat.fail': 'Fail',
|
||||
'smoke.stat.total': 'Total',
|
||||
'smoke.col.id': 'ID',
|
||||
'smoke.col.suite': 'Suite',
|
||||
'smoke.col.name': 'Case',
|
||||
'smoke.col.uat': 'UAT',
|
||||
'smoke.col.duration': 'Duration',
|
||||
'smoke.col.steps': 'Steps',
|
||||
'smoke.col.message': 'Message',
|
||||
'smoke.no_steps': 'No step details',
|
||||
'smoke.status.PASS': 'Pass',
|
||||
'smoke.status.FAIL': 'Fail',
|
||||
'smoke.status.SKIP': 'Skip',
|
||||
'smoke.msg.all_passed': 'All passed ({n})',
|
||||
'smoke.msg.has_failures': '{n} case(s) failed — see details',
|
||||
'smoke.msg.run_failed': 'Failed to run tests',
|
||||
'smoke.log_title': 'Detailed log',
|
||||
'smoke.btn.copy_all': 'Copy full log',
|
||||
'smoke.btn.copy_one': 'Copy',
|
||||
'smoke.msg.copy_ok': 'Copied to clipboard',
|
||||
'smoke.msg.copy_failed': 'Copy failed — select the log manually',
|
||||
'audit.action.RUN_SMOKE_TESTS': 'Run smoke tests',
|
||||
};
|
||||
|
||||
@@ -18,10 +18,12 @@ const adminMenus = computed(() => [
|
||||
{ path: '/', label: t('nav.dashboard') },
|
||||
{ path: '/matches', label: t('nav.matches'), matchPrefix: true },
|
||||
{ path: '/users', label: t('nav.agents_players') },
|
||||
{ path: '/agent-credit-transactions', label: t('nav.credit_transactions') },
|
||||
{ path: '/cashback', label: t('nav.cashback') },
|
||||
{ path: '/bets', label: t('nav.bets') },
|
||||
{ path: '/contents', label: t('nav.contents') },
|
||||
{ path: '/audit', label: t('nav.audit') },
|
||||
{ path: '/smoke-tests', label: t('nav.smoke_tests') },
|
||||
]);
|
||||
|
||||
const agentMenus = computed(() => [
|
||||
@@ -60,8 +62,11 @@ const roleLabel = computed(() => {
|
||||
return t('role.agent');
|
||||
});
|
||||
|
||||
const currentUser = computed(() => auth.user.value);
|
||||
const isAdminPortal = computed(() => auth.isAdmin.value);
|
||||
|
||||
const userInitial = computed(() =>
|
||||
(auth.user?.username ?? '').charAt(0).toUpperCase()
|
||||
(currentUser.value?.username ?? '').charAt(0).toUpperCase()
|
||||
);
|
||||
|
||||
function syncMobileNav() {
|
||||
@@ -175,12 +180,12 @@ watch(() => route.path, () => {
|
||||
<div class="user-chip">
|
||||
<div class="avatar">{{ userInitial }}</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -17,6 +17,11 @@ const router = createRouter({
|
||||
component: () => import('../views/AgentManager.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'agent-credit-transactions',
|
||||
component: () => import('../views/AgentCreditTransactions.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'agents',
|
||||
redirect: '/users',
|
||||
@@ -76,6 +81,16 @@ const router = createRouter({
|
||||
component: () => import('../views/Audit.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'smoke-tests',
|
||||
component: () => import('../views/SmokeTests.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'smoke-tests',
|
||||
component: () => import('../views/SmokeTests.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'my-players',
|
||||
component: () => import('../views/agent/Players.vue'),
|
||||
|
||||
@@ -25,8 +25,9 @@ function dec(value: string | number | null | undefined): number {
|
||||
export function snapshotFromAgentRow(
|
||||
row: Pick<
|
||||
AgentRow | AgentDetail | AgentSubAgentRow,
|
||||
'username' | 'creditLimit' | 'usedCredit' | 'availableCredit' | 'level'
|
||||
'username' | 'creditLimit' | 'usedCredit' | 'availableCredit'
|
||||
> & {
|
||||
level?: number;
|
||||
directPlayerLiability?: string;
|
||||
childAgentExposure?: string;
|
||||
},
|
||||
|
||||
223
apps/admin/src/views/AgentCreditTransactions.vue
Normal 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>
|
||||
@@ -19,14 +19,14 @@ import {
|
||||
type PlayerDetail,
|
||||
type PlayerCreateForm,
|
||||
type PlayerEditForm,
|
||||
} from './user-form.ts';
|
||||
} from './user-form';
|
||||
import {
|
||||
emptyAgentEditForm,
|
||||
editFormFromAgentDetail,
|
||||
type AgentRow,
|
||||
type AgentDetail,
|
||||
type AgentEditForm,
|
||||
} from './agent-form.ts';
|
||||
} from './agent-form';
|
||||
import { subAgentAccountStatus } from './agent/agent-sub-agent-form';
|
||||
import {
|
||||
formatAmount,
|
||||
@@ -994,8 +994,8 @@ function creditTypeLabel(type: string) {
|
||||
:expand-row-keys="subAgentExpandedKeys"
|
||||
:row-class-name="expandableTableRowClassName"
|
||||
class="inner-table expandable-table"
|
||||
@expand-change="(sub, rows) => onSubAgentExpand(row.userId, sub, rows)"
|
||||
@row-click="(sub, col, e) => onSubAgentRowClick(row.userId, sub, col, e)"
|
||||
@expand-change="(sub: AgentRow, rows: AgentRow[]) => onSubAgentExpand(row.userId, sub, rows)"
|
||||
@row-click="(sub: AgentRow, col: unknown, e: MouseEvent) => onSubAgentRowClick(row.userId, sub, col, e)"
|
||||
>
|
||||
<template #empty><AdminTableEmpty /></template>
|
||||
<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>
|
||||
|
||||
<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-column :label="t('agent.col.credit_type')" width="80">
|
||||
<template #default="{ row }">{{ creditTypeLabel(row.transactionType) }}</template>
|
||||
@@ -1627,6 +1637,13 @@ function creditTypeLabel(type: string) {
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-title--row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { resolveFormError, resolveApiError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const router = useRouter();
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
emptyAgentCreateForm,
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
type AgentCreateForm,
|
||||
type AgentEditForm,
|
||||
type PromotableUserOption,
|
||||
} from './agent-form.ts';
|
||||
} from './agent-form';
|
||||
import {
|
||||
formatAmount,
|
||||
formatAmountFull,
|
||||
@@ -484,7 +486,17 @@ function creditTypeLabel(type: string) {
|
||||
</el-descriptions-item>
|
||||
</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
|
||||
:data="detail.recentCreditTransactions"
|
||||
size="small"
|
||||
@@ -527,6 +539,13 @@ function creditTypeLabel(type: string) {
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-title--row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.amount-compact {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
@@ -15,7 +15,7 @@ import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
const { statusOptions, typeOptions } = useBetFilterOptions();
|
||||
import type { BetListRow, BetDetail } from './bet-form.ts';
|
||||
import type { BetListRow, BetDetail } from './bet-form';
|
||||
|
||||
const bets = ref<BetListRow[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getBuiltinCountry } from '../data/builtinCountries';
|
||||
import {
|
||||
readMatchesListUiState,
|
||||
writeMatchesListUiState,
|
||||
} from '../utils/matchesListState.ts';
|
||||
} from '../utils/matchesListState';
|
||||
import { formatAmount } from '../utils/format-amount';
|
||||
import {
|
||||
emptyMatchForm,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
fillBuiltinTeam,
|
||||
clearBuiltinTeam,
|
||||
type MatchCreateForm,
|
||||
} from './match-form.ts';
|
||||
} from './match-form';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const route = useRoute();
|
||||
|
||||
@@ -8,7 +8,7 @@ import LeagueOutrightOddsPanel from './matches/LeagueOutrightOddsPanel.vue';
|
||||
import {
|
||||
readMatchesListUiState,
|
||||
writeMatchesListUiState,
|
||||
} from '../utils/matchesListState.ts';
|
||||
} from '../utils/matchesListState';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const route = useRoute();
|
||||
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
betStatusLabel,
|
||||
betStatusTagType,
|
||||
betTypeLabel,
|
||||
betResultLabel,
|
||||
} from '../utils/bet-labels';
|
||||
import { adminSelectionLabel } from '../utils/adminSelectionLabel.ts';
|
||||
import type { AdminMatchDetail } from './match-form.ts';
|
||||
import { adminSelectionLabel } from '../utils/adminSelectionLabel';
|
||||
import type { AdminMatchDetail } from './match-form';
|
||||
import AdminSubNav from '../components/AdminSubNav.vue';
|
||||
|
||||
interface SettlementBetStats {
|
||||
|
||||
403
apps/admin/src/views/SmokeTests.vue
Normal 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>
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type PlayerDetail,
|
||||
type PlayerCreateForm,
|
||||
type PlayerEditForm,
|
||||
} from './user-form.ts';
|
||||
} from './user-form';
|
||||
import {
|
||||
formatAmount,
|
||||
formatAmountFull,
|
||||
|
||||
@@ -472,7 +472,7 @@ function openCreditSub(row: AgentSubAgentRow) {
|
||||
creditContext.value = {
|
||||
target: snapshotFromAgentRow(row),
|
||||
parent: snapshotFromAgentRow({
|
||||
username: auth.user?.username ?? t('credit.context.acting_agent'),
|
||||
username: auth.user.value?.username ?? t('credit.context.acting_agent'),
|
||||
creditLimit: String(profile.value.creditLimit ?? 0),
|
||||
usedCredit: String(profile.value.usedCredit ?? 0),
|
||||
availableCredit: String(profile.value.availableCredit ?? 0),
|
||||
|
||||
@@ -114,7 +114,7 @@ function openCredit(row: AgentSubAgentRow) {
|
||||
creditContext.value = {
|
||||
target: snapshotFromAgentRow(row),
|
||||
parent: snapshotFromAgentRow({
|
||||
username: auth.user?.username ?? t('credit.context.acting_agent'),
|
||||
username: auth.user.value?.username ?? t('credit.context.acting_agent'),
|
||||
creditLimit: String(profile.value.creditLimit ?? 0),
|
||||
usedCredit: String(profile.value.usedCredit ?? 0),
|
||||
availableCredit: String(profile.value.availableCredit ?? 0),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import { ensureLeagueExpanded } from '../../utils/matchesListState.ts';
|
||||
import { ensureLeagueExpanded } from '../../utils/matchesListState';
|
||||
import { formatAmount } from '../../utils/format-amount';
|
||||
const props = defineProps<{
|
||||
leagueId: string;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
formFromDetail,
|
||||
type AdminMatchDetail,
|
||||
type MatchCreateForm,
|
||||
} from '../match-form.ts';
|
||||
} from '../match-form';
|
||||
import AdminSubNav from '../../components/AdminSubNav.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import MatchMarketsPanel from './MatchMarketsPanel.vue';
|
||||
import type { AdminMatchDetail } from '../match-form.ts';
|
||||
import type { AdminMatchDetail } from '../match-form';
|
||||
import AdminSubNav from '../../components/AdminSubNav.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -3,9 +3,9 @@ import { ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import type { AdminMatchDetail } from '../match-form.ts';
|
||||
import { defaultSelectionName } from '../../utils/selectionDefaults.ts';
|
||||
import { adminSelectionLabel } from '../../utils/adminSelectionLabel.ts';
|
||||
import type { AdminMatchDetail } from '../match-form';
|
||||
import { defaultSelectionName } from '../../utils/selectionDefaults';
|
||||
import { adminSelectionLabel } from '../../utils/adminSelectionLabel';
|
||||
|
||||
const props = defineProps<{
|
||||
matchId: string;
|
||||
|
||||
2
apps/admin/src/vite-env.d.ts
vendored
@@ -1,3 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<object, object, unknown>;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
@@ -9,13 +10,20 @@ import {
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { extname, join } from 'path';
|
||||
import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
|
||||
import { ContentService } from '../../domains/operations/content/content.service';
|
||||
import { CurrentUser, RequirePermissions } from '../../shared/common/decorators';
|
||||
import { jsonResponse } from '../../shared/common/filters';
|
||||
import { getUploadRoot } from '../../shared/uploads/upload-paths';
|
||||
import { UsersService } from '../../domains/identity/users.service';
|
||||
import { AgentsService } from '../../domains/agent/agents.service';
|
||||
import { WalletService } from '../../domains/ledger/wallet.service';
|
||||
@@ -33,6 +41,7 @@ import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { P } from './admin-permissions';
|
||||
import { DatabaseResetService } from '../../infrastructure/database/database-reset.service';
|
||||
import { SmokeTestService } from '../../domains/operations/smoke-tests/smoke-test.service';
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
@@ -47,6 +56,77 @@ import {
|
||||
} from 'class-validator';
|
||||
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
|
||||
|
||||
const UPLOAD_CATEGORIES = ['banners', 'teams', 'contents'] as const;
|
||||
type UploadCategory = (typeof UPLOAD_CATEGORIES)[number];
|
||||
|
||||
const IMAGE_MIME_EXT: Record<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 {
|
||||
@IsString()
|
||||
username!: string;
|
||||
@@ -746,6 +826,7 @@ export class AdminController {
|
||||
private systemConfig: SystemConfigService,
|
||||
private bettingLimits: BettingLimitsService,
|
||||
private databaseReset: DatabaseResetService,
|
||||
private smokeTests: SmokeTestService,
|
||||
) {}
|
||||
|
||||
@Get('dashboard')
|
||||
@@ -985,6 +1066,25 @@ export class AdminController {
|
||||
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')
|
||||
@RequirePermissions(P.agentsView)
|
||||
async getAgentDetail(@Param('id') id: string) {
|
||||
@@ -1731,6 +1831,33 @@ export class AdminController {
|
||||
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')
|
||||
@RequirePermissions(P.content, P.reports)
|
||||
async listContents(
|
||||
@@ -1786,6 +1913,45 @@ export class AdminController {
|
||||
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')
|
||||
@RequirePermissions(P.audit)
|
||||
async auditLogs(
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ContentModule } from '../../domains/operations/content/content.module';
|
||||
import { I18nModule } from '../../domains/operations/i18n/i18n.module';
|
||||
import { BetsModule } from '../../domains/betting/bets.module';
|
||||
import { DatabaseModule } from '../../infrastructure/database/database.module';
|
||||
import { SmokeTestModule } from '../../domains/operations/smoke-tests/smoke-test.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -27,6 +28,7 @@ import { DatabaseModule } from '../../infrastructure/database/database.module';
|
||||
I18nModule,
|
||||
BetsModule,
|
||||
DatabaseModule,
|
||||
SmokeTestModule,
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminDashboardService, PermissionsGuard],
|
||||
|
||||
@@ -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(
|
||||
agentId: bigint,
|
||||
data: {
|
||||
|
||||
@@ -474,10 +474,33 @@ export class CashbackService {
|
||||
}
|
||||
|
||||
async getUserCashbacks(userId: bigint) {
|
||||
return this.prisma.cashbackItem.findMany({
|
||||
where: { userId },
|
||||
include: { batch: true },
|
||||
const items = await this.prisma.cashbackItem.findMany({
|
||||
where: { userId, batch: { status: 'CONFIRMED' } },
|
||||
include: {
|
||||
batch: {
|
||||
select: {
|
||||
batchNo: true,
|
||||
periodStart: true,
|
||||
periodEnd: true,
|
||||
confirmedAt: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return items.map((item) => ({
|
||||
id: item.id.toString(),
|
||||
batchNo: item.batch.batchNo,
|
||||
periodStart: item.batch.periodStart,
|
||||
periodEnd: item.batch.periodEnd,
|
||||
confirmedAt: item.batch.confirmedAt,
|
||||
effectiveStake: item.effectiveStake.toString(),
|
||||
betCount: item.betCount,
|
||||
rate: item.rate.toString(),
|
||||
amount: item.amount.toString(),
|
||||
createdAt: item.createdAt,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
762
apps/api/src/domains/operations/smoke-tests/smoke-test.cases.ts
Normal 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.5:3 球大球赢',
|
||||
uatRef: 'S013',
|
||||
run: () => {
|
||||
expectEqual(
|
||||
'OVER 2.5',
|
||||
settleSelection({
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
selectionCode: 'OVER',
|
||||
totalLine: 2.5,
|
||||
score: SCORE_2_1,
|
||||
}),
|
||||
'WIN',
|
||||
{ totalGoals: 3, line: 2.5 },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S014',
|
||||
suite: 'settlement',
|
||||
name: '大小整数盘 2 球走水',
|
||||
uatRef: 'S014',
|
||||
run: () => {
|
||||
const score = { htHome: 1, htAway: 0, ftHome: 1, ftAway: 1 };
|
||||
expectEqual(
|
||||
'OVER 2 push',
|
||||
settleSelection({
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
selectionCode: 'OVER',
|
||||
totalLine: 2,
|
||||
score,
|
||||
}),
|
||||
'PUSH',
|
||||
{ totalGoals: 2, line: 2 },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S015',
|
||||
suite: 'settlement',
|
||||
name: '0-0 大小 2.5:小球赢',
|
||||
description: '0-0 下小球赢、大球输',
|
||||
run: () => {
|
||||
const under = settleSelection({
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
selectionCode: 'UNDER',
|
||||
totalLine: 2.5,
|
||||
score: SCORE_0_0,
|
||||
});
|
||||
const over = settleSelection({
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
selectionCode: 'OVER',
|
||||
totalLine: 2.5,
|
||||
score: SCORE_0_0,
|
||||
});
|
||||
expectEqual('UNDER', under, 'WIN', { line: 2.5, score: SCORE_0_0 });
|
||||
expectEqual('OVER', over, 'LOSE', { line: 2.5, score: SCORE_0_0 });
|
||||
expectEqual('UNDER payout', calculatePayout(100, 1.95, under).toNumber(), 195, {
|
||||
stake: 100,
|
||||
odds: 1.95,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S016',
|
||||
suite: 'settlement',
|
||||
name: '串关全中派彩',
|
||||
uatRef: 'S016',
|
||||
run: () => {
|
||||
const legs = [
|
||||
{ odds: 1.8, result: 'WIN' as const },
|
||||
{ odds: 2.0, result: 'WIN' as const },
|
||||
];
|
||||
const { betResult, payout } = calculateParlayPayout(100, legs);
|
||||
expectEqual('betResult', betResult, 'WON', { stake: 100, legs });
|
||||
expectEqual('payout', payout.toNumber(), 360, { stake: 100, legs });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S017',
|
||||
suite: 'settlement',
|
||||
name: '串关一关输:全输',
|
||||
uatRef: 'S017',
|
||||
run: () => {
|
||||
const legs = [
|
||||
{ odds: 1.8, result: 'WIN' as const },
|
||||
{ odds: 2.0, result: 'LOSE' as const },
|
||||
];
|
||||
const { betResult, payout } = calculateParlayPayout(100, legs);
|
||||
expectEqual('betResult', betResult, 'LOST', { stake: 100, legs });
|
||||
expectEqual('payout', payout.toNumber(), 0, { stake: 100, legs });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S018',
|
||||
suite: 'settlement',
|
||||
name: '串关一关走水:赔率按 1.00',
|
||||
uatRef: 'S018',
|
||||
run: () => {
|
||||
const legs = [
|
||||
{ odds: 1.8, result: 'WIN' as const },
|
||||
{ odds: 2.0, result: 'PUSH' as const },
|
||||
{ odds: 1.9, result: 'WIN' as const },
|
||||
];
|
||||
const { betResult, payout } = calculateParlayPayout(100, legs);
|
||||
expectEqual('betResult', betResult, 'WON', { stake: 100, legs });
|
||||
expectEqual('payout', payout.toNumber(), 342, { stake: 100, legs });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S019',
|
||||
suite: 'settlement',
|
||||
name: '串关全走水/作废:退本',
|
||||
uatRef: 'S019',
|
||||
run: () => {
|
||||
const legs = [
|
||||
{ odds: 1.8, result: 'PUSH' as const },
|
||||
{ odds: 2.0, result: 'VOID' as const },
|
||||
];
|
||||
const { betResult, payout } = calculateParlayPayout(100, legs);
|
||||
expectEqual('betResult', betResult, 'PUSH', { stake: 100, legs });
|
||||
expectEqual('payout', payout.toNumber(), 100, { stake: 100, legs });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'OUT001',
|
||||
suite: 'settlement',
|
||||
name: '冠军盘:猜中/猜错',
|
||||
description: 'OUTRIGHT_WINNER 按 winnerTeamCode 结算',
|
||||
run: () => {
|
||||
const input = { winnerTeamCode: 'BRA', selection: 'BRA' };
|
||||
expectEqual(
|
||||
'BRA wins',
|
||||
settleSelection({
|
||||
marketType: 'OUTRIGHT_WINNER',
|
||||
selectionCode: 'BRA',
|
||||
score: SCORE_0_0,
|
||||
winnerTeamCode: 'BRA',
|
||||
}),
|
||||
'WIN',
|
||||
input,
|
||||
);
|
||||
expectEqual(
|
||||
'ARG loses',
|
||||
settleSelection({
|
||||
marketType: 'OUTRIGHT_WINNER',
|
||||
selectionCode: 'ARG',
|
||||
score: SCORE_0_0,
|
||||
winnerTeamCode: 'BRA',
|
||||
}),
|
||||
'LOSE',
|
||||
{ winnerTeamCode: 'BRA', selection: 'ARG' },
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// —— Settlement helpers ——
|
||||
{
|
||||
id: 'H001',
|
||||
suite: 'settlement_helpers',
|
||||
name: '选项代码解析',
|
||||
description: '数据库 code 优先,中文快照名兜底',
|
||||
run: () => {
|
||||
expectEqual('db code', resolveSelectionCode('UNDER', '小 2.5'), 'UNDER', {
|
||||
code: 'UNDER',
|
||||
name: '小 2.5',
|
||||
});
|
||||
expectEqual('主胜', resolveSelectionCode(null, '主胜'), 'HOME', { name: '主胜' });
|
||||
expectEqual('小 2.5', resolveSelectionCode('', '小 2.5'), 'UNDER', { name: '小 2.5' });
|
||||
expectEqual('大 2.5', resolveSelectionCode(undefined, '大 2.5'), 'OVER', { name: '大 2.5' });
|
||||
expectEqual('主 -0.5', resolveSelectionCode(null, '主 -0.5'), 'HOME', { name: '主 -0.5' });
|
||||
},
|
||||
},
|
||||
|
||||
// —— Betting rules ——
|
||||
{
|
||||
id: 'B003',
|
||||
suite: 'betting',
|
||||
name: '赔率版本不一致应拒绝',
|
||||
uatRef: 'B003',
|
||||
run: () => {
|
||||
const submitted = BigInt(1);
|
||||
const current = BigInt(2);
|
||||
expectTrue('version mismatch', submitted !== current, { submitted: '1', current: '2' });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'B006',
|
||||
suite: 'betting',
|
||||
name: '串关 2-5 场范围常量',
|
||||
uatRef: 'B006',
|
||||
run: () => {
|
||||
expectEqual('PARLAY_MIN_LEGS', PARLAY_MIN_LEGS, 2);
|
||||
expectEqual('PARLAY_MAX_LEGS', PARLAY_MAX_LEGS, 5);
|
||||
expectTrue('2 legs ok', 2 >= PARLAY_MIN_LEGS && 2 <= PARLAY_MAX_LEGS);
|
||||
expectTrue('5 legs ok', 5 >= PARLAY_MIN_LEGS && 5 <= PARLAY_MAX_LEGS);
|
||||
expectTrue('6 legs blocked', 6 > PARLAY_MAX_LEGS);
|
||||
expectTrue('1 leg blocked', 1 < PARLAY_MIN_LEGS);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'B008',
|
||||
suite: 'betting',
|
||||
name: '四分之一盘不可串关',
|
||||
uatRef: 'B008',
|
||||
run: () => {
|
||||
expectTrue('-0.25 is quarter', isQuarterHandicapOrTotal(-0.25), { line: -0.25 });
|
||||
expectFalse('2.5 not quarter', isQuarterHandicapOrTotal(2.5), { line: 2.5 });
|
||||
expectFalse('-1 not quarter', isQuarterHandicapOrTotal(-1), { line: -1 });
|
||||
const check = canSelectForParlay({
|
||||
marketType: 'FT_HANDICAP',
|
||||
lineValue: -0.25,
|
||||
});
|
||||
expectFalse('blocked', check.ok, { line: -0.25 }, check.ok ? '' : (check as { reason: string }).reason);
|
||||
if (!check.ok) {
|
||||
expectEqual('reason', check.reason, 'QUARTER_LINE', { line: -0.25 });
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'B009',
|
||||
suite: 'betting',
|
||||
name: '串关超过 5 场应拒绝',
|
||||
uatRef: 'B009',
|
||||
run: () => {
|
||||
expectTrue('6 > max', 6 > PARLAY_MAX_LEGS, { legs: 6, max: PARLAY_MAX_LEGS });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'B010',
|
||||
suite: 'betting',
|
||||
name: '冠军盘不可串关',
|
||||
uatRef: 'B010',
|
||||
run: () => {
|
||||
const check = canSelectForParlay({
|
||||
marketType: 'OUTRIGHT_WINNER',
|
||||
isOutright: true,
|
||||
});
|
||||
expectFalse('blocked', check.ok, { marketType: 'OUTRIGHT_WINNER' });
|
||||
if (!check.ok) {
|
||||
expectEqual('reason', check.reason, 'OUTRIGHT', { marketType: 'OUTRIGHT_WINNER' });
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'B011',
|
||||
suite: 'betting',
|
||||
name: '常规让球盘可串关',
|
||||
run: () => {
|
||||
const check = canSelectForParlay({
|
||||
marketType: 'FT_HANDICAP',
|
||||
lineValue: -0.5,
|
||||
});
|
||||
expectTrue('allowed', check.ok, { marketType: 'FT_HANDICAP', line: -0.5 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'B012',
|
||||
suite: 'betting',
|
||||
name: '显式禁止串关的盘口',
|
||||
run: () => {
|
||||
const check = canSelectForParlay({
|
||||
marketType: 'FT_1X2',
|
||||
allowParlay: false,
|
||||
});
|
||||
expectFalse('blocked', check.ok, { marketType: 'FT_1X2', allowParlay: false });
|
||||
if (!check.ok) {
|
||||
expectEqual('reason', check.reason, 'NOT_ALLOWED', { allowParlay: false });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// —— Betting limits ——
|
||||
{
|
||||
id: 'BL001',
|
||||
suite: 'betting_limits',
|
||||
name: '低于最小投注额拒绝',
|
||||
run: async () => {
|
||||
const service = createBettingLimitsService();
|
||||
await expectThrows(
|
||||
'min stake',
|
||||
() =>
|
||||
service.validateBet({
|
||||
userId: BigInt(1),
|
||||
betType: 'SINGLE',
|
||||
stake: 0.5,
|
||||
potentialReturn: new Decimal(1),
|
||||
}),
|
||||
'Minimum stake is 1',
|
||||
{ stake: 0.5, minStake: 1 },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BL002',
|
||||
suite: 'betting_limits',
|
||||
name: '超过单关最大投注额拒绝',
|
||||
run: async () => {
|
||||
const service = createBettingLimitsService();
|
||||
await expectThrows(
|
||||
'max stake',
|
||||
() =>
|
||||
service.validateBet({
|
||||
userId: BigInt(1),
|
||||
betType: 'SINGLE',
|
||||
stake: 60000,
|
||||
potentialReturn: new Decimal(70000),
|
||||
}),
|
||||
'Maximum stake is 50000',
|
||||
{ stake: 60000, maxStakeSingle: 50000 },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BL003',
|
||||
suite: 'betting_limits',
|
||||
name: '超过最高派彩拒绝',
|
||||
run: async () => {
|
||||
const service = createBettingLimitsService();
|
||||
await expectThrows(
|
||||
'max payout',
|
||||
() =>
|
||||
service.validateBet({
|
||||
userId: BigInt(1),
|
||||
betType: 'SINGLE',
|
||||
stake: 100,
|
||||
potentialReturn: new Decimal(600000),
|
||||
}),
|
||||
'Potential return exceeds limit',
|
||||
{ potentialReturn: 600000, maxPayoutSingle: 500000 },
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// —— Agent credit ——
|
||||
{
|
||||
id: 'A001',
|
||||
suite: 'agent_credit',
|
||||
name: '代理上分占用额度',
|
||||
uatRef: 'A001',
|
||||
run: () => {
|
||||
const creditLimit = 10000;
|
||||
const usedCredit = 1000 + 500;
|
||||
const available = creditLimit - usedCredit;
|
||||
expectEqual('available credit', available, 8500, {
|
||||
creditLimit,
|
||||
usedCredit,
|
||||
formula: 'creditLimit - usedCredit',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'A002',
|
||||
suite: 'agent_credit',
|
||||
name: '下注冻结:总额不变、可用减少',
|
||||
uatRef: 'A002',
|
||||
run: () => {
|
||||
const balance = 1000;
|
||||
const frozenBefore = 0;
|
||||
const stake = 100;
|
||||
const totalBefore = balance + frozenBefore;
|
||||
const balanceAfter = balance - stake;
|
||||
const frozenAfter = frozenBefore + stake;
|
||||
const totalAfter = balanceAfter + frozenAfter;
|
||||
expectEqual('total unchanged', totalAfter, totalBefore, {
|
||||
before: { balance, frozen: frozenBefore },
|
||||
after: { balance: balanceAfter, frozen: frozenAfter },
|
||||
});
|
||||
expectEqual('available', balanceAfter, 900, { stake });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'A003',
|
||||
suite: 'agent_credit',
|
||||
name: '结算派彩:可用余额增加',
|
||||
description: '赢单释放本金并增加盈利',
|
||||
run: () => {
|
||||
const balance = 900;
|
||||
const frozen = 100;
|
||||
const payout = 185;
|
||||
const balanceAfter = balance + payout;
|
||||
const frozenAfter = frozen - 100;
|
||||
expectEqual('balance after win', balanceAfter, 1085, { payout, before: 900 });
|
||||
expectEqual('frozen released', frozenAfter, 0, { frozenBefore: 100, stake: 100 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'A005',
|
||||
suite: 'agent_credit',
|
||||
name: '额度为负禁止继续放款',
|
||||
uatRef: 'A005',
|
||||
run: () => {
|
||||
const creditLimit = 1000;
|
||||
const usedCredit = 1200;
|
||||
const available = creditLimit - usedCredit;
|
||||
expectTrue('negative available', available < 0, { creditLimit, usedCredit, available });
|
||||
expectFalse('deposit allowed', available > 0, { available });
|
||||
},
|
||||
},
|
||||
|
||||
// —— Cashback ——
|
||||
{
|
||||
id: 'CB001',
|
||||
suite: 'cashback',
|
||||
name: '返水比例:玩家规则优先',
|
||||
run: () => {
|
||||
const rate = resolveCashbackRateForBet({
|
||||
userId: BigInt(100),
|
||||
agentId: BigInt(200),
|
||||
marketTypes: ['FT_1X2'],
|
||||
agentDefaultRate: new Decimal('0.01'),
|
||||
rules: [
|
||||
{
|
||||
targetType: 'USER',
|
||||
targetId: BigInt(100),
|
||||
rate: new Decimal('0.03'),
|
||||
marketType: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expectEqual('rate', rate.toString(), '0.03', {
|
||||
userRule: '0.03',
|
||||
agentDefault: '0.01',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'CB002',
|
||||
suite: 'cashback',
|
||||
name: '返水比例:玩法专属规则',
|
||||
run: () => {
|
||||
const rate = resolveCashbackRateForBet({
|
||||
userId: BigInt(100),
|
||||
agentId: BigInt(200),
|
||||
marketTypes: ['FT_HANDICAP'],
|
||||
agentDefaultRate: new Decimal('0.01'),
|
||||
rules: [
|
||||
{
|
||||
targetType: 'GLOBAL',
|
||||
targetId: null,
|
||||
rate: new Decimal('0.005'),
|
||||
marketType: 'FT_HANDICAP',
|
||||
},
|
||||
],
|
||||
});
|
||||
expectEqual('rate', rate.toString(), '0.005', { marketType: 'FT_HANDICAP' });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'CB003',
|
||||
suite: 'cashback',
|
||||
name: '返水比例:无规则用代理默认',
|
||||
run: () => {
|
||||
const rate = resolveCashbackRateForBet({
|
||||
userId: BigInt(100),
|
||||
agentId: BigInt(200),
|
||||
marketTypes: ['FT_1X2'],
|
||||
agentDefaultRate: new Decimal('0.02'),
|
||||
rules: [],
|
||||
});
|
||||
expectEqual('rate', rate.toString(), '0.02', { agentDefault: '0.02', rules: [] });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'CB004',
|
||||
suite: 'cashback',
|
||||
name: '返水比例:玩法不匹配回退默认',
|
||||
run: () => {
|
||||
const rate = resolveCashbackRateForBet({
|
||||
userId: BigInt(100),
|
||||
agentId: BigInt(200),
|
||||
marketTypes: ['FT_1X2'],
|
||||
agentDefaultRate: new Decimal('0.01'),
|
||||
rules: [
|
||||
{
|
||||
targetType: 'GLOBAL',
|
||||
targetId: null,
|
||||
rate: new Decimal('0.005'),
|
||||
marketType: 'FT_HANDICAP',
|
||||
},
|
||||
],
|
||||
});
|
||||
expectEqual('rate', rate.toString(), '0.01', {
|
||||
betMarket: 'FT_1X2',
|
||||
ruleMarket: 'FT_HANDICAP',
|
||||
agentDefault: '0.01',
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const SMOKE_SUITE_META: Record<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:下注→冻结→录分→结算→钱包/代理额度(临时数据自动清理)',
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -2,17 +2,16 @@ import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
import { GlobalExceptionFilter } from './shared/common/filters';
|
||||
import { getUploadRoot } from './shared/uploads/upload-paths';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.enableShutdownHooks();
|
||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||
|
||||
const uploadDir = process.env.UPLOAD_DIR || join(__dirname, '..', '..', 'uploads');
|
||||
app.useStaticAssets(uploadDir, { prefix: '/uploads/' });
|
||||
app.useStaticAssets(getUploadRoot(), { prefix: '/uploads/' });
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
|
||||
16
apps/api/src/shared/uploads/upload-paths.ts
Normal 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];
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.9 MiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 913 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 771 KiB |
@@ -28,7 +28,8 @@ const isDetailPage = computed(() => {
|
||||
p.startsWith('/bet/') ||
|
||||
p.startsWith('/bets/') ||
|
||||
p.startsWith('/wallet/') ||
|
||||
p === '/profile/edit'
|
||||
p === '/profile/edit' ||
|
||||
p === '/profile/cashbacks'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ const i18n = createI18n({
|
||||
tx_bet_push: '投注退水',
|
||||
tx_bet_refund: '投注退款',
|
||||
tx_bet_void: '投注撤销',
|
||||
tx_cashback: '返水发放',
|
||||
tx_cashback: '返水入账',
|
||||
tx_resettle: '重新结算',
|
||||
stats_income: '收入',
|
||||
stats_expense: '支出',
|
||||
@@ -120,6 +120,23 @@ const i18n = createI18n({
|
||||
ref_bet: '投注',
|
||||
ref_deposit: '存款',
|
||||
ref_withdraw: '提款',
|
||||
view_cashbacks: '返水明细',
|
||||
view_cashbacks_detail: '查看返水周期明细',
|
||||
cashback_filter_hint: '此处为入账流水;周期、比例等详见返水明细。',
|
||||
detail_cashback_link: '查看返水明细',
|
||||
ref_cashback: '返水批次',
|
||||
},
|
||||
cashback: {
|
||||
title: '返水明细',
|
||||
list_title: '发放明细',
|
||||
total_received: '累计返水',
|
||||
record_count: '共 {n} 笔',
|
||||
period: '统计周期',
|
||||
effective_stake: '有效投注',
|
||||
bet_count: '{n} 笔注单',
|
||||
empty: '暂无返水记录',
|
||||
empty_hint: '返水由后台按周期统计并发放,到账后可在此查看。',
|
||||
ledger_hint: '每笔返水确认后,账单中会有对应的「返水入账」流水,金额一致。',
|
||||
},
|
||||
bet: {
|
||||
bet_slip: '投注单',
|
||||
@@ -376,7 +393,7 @@ const i18n = createI18n({
|
||||
tx_bet_push: 'Bet Push',
|
||||
tx_bet_refund: 'Bet Refund',
|
||||
tx_bet_void: 'Bet Voided',
|
||||
tx_cashback: 'Cashback Distribution',
|
||||
tx_cashback: 'Cashback credit',
|
||||
tx_resettle: 'Resettlement',
|
||||
stats_income: 'Income',
|
||||
stats_expense: 'Expense',
|
||||
@@ -404,6 +421,23 @@ const i18n = createI18n({
|
||||
ref_bet: 'Bet',
|
||||
ref_deposit: 'Deposit',
|
||||
ref_withdraw: 'Withdraw',
|
||||
view_cashbacks: 'Cashback details',
|
||||
view_cashbacks_detail: 'View cashback details (period/rate)',
|
||||
cashback_filter_hint: 'This list shows wallet credits; see cashback details for period and rate.',
|
||||
ref_cashback: 'Cashback batch',
|
||||
detail_cashback_link: 'View cashback details',
|
||||
},
|
||||
cashback: {
|
||||
title: 'Cashback Details',
|
||||
list_title: 'Payout details',
|
||||
total_received: 'Total cashback',
|
||||
record_count: '{n} record(s)',
|
||||
period: 'Period',
|
||||
effective_stake: 'Effective stake',
|
||||
bet_count: '{n} bet(s)',
|
||||
empty: 'No cashback records yet',
|
||||
empty_hint: 'Cashback is issued by the platform after each settlement period.',
|
||||
ledger_hint: 'Matches wallet entries under the Cashback filter; amounts are the same.',
|
||||
},
|
||||
bet: {
|
||||
bet_slip: 'Bet Slip',
|
||||
@@ -666,7 +700,7 @@ const i18n = createI18n({
|
||||
tx_bet_push: 'Pertaruhan Seri',
|
||||
tx_bet_refund: 'Bayaran Balik',
|
||||
tx_bet_void: 'Pertaruhan Dibatalkan',
|
||||
tx_cashback: 'Pembayaran Cashback',
|
||||
tx_cashback: 'Kredit rebat',
|
||||
tx_resettle: 'Penyelesaian Semula',
|
||||
stats_income: 'Pendapatan',
|
||||
stats_expense: 'Perbelanjaan',
|
||||
@@ -694,6 +728,23 @@ const i18n = createI18n({
|
||||
ref_bet: 'Pertaruhan',
|
||||
ref_deposit: 'Deposit',
|
||||
ref_withdraw: 'Pengeluaran',
|
||||
view_cashbacks: 'Butiran rebat',
|
||||
view_cashbacks_detail: 'Lihat butiran rebat (tempoh/kadar)',
|
||||
cashback_filter_hint: 'Senarai ini ialah kredit dompet; tempoh dan kadar ada di butiran rebat.',
|
||||
ref_cashback: 'Batch rebat',
|
||||
detail_cashback_link: 'Lihat butiran rebat',
|
||||
},
|
||||
cashback: {
|
||||
title: 'Butiran Rebat',
|
||||
list_title: 'Butiran pembayaran',
|
||||
total_received: 'Jumlah rebat',
|
||||
record_count: '{n} rekod',
|
||||
period: 'Tempoh',
|
||||
effective_stake: 'Pertaruhan sah',
|
||||
bet_count: '{n} pertaruhan',
|
||||
empty: 'Tiada rekod rebat',
|
||||
empty_hint: 'Rebat dikeluarkan oleh platform mengikut kitaran penyelesaian.',
|
||||
ledger_hint: 'Sepadan dengan entri dompet di penapis Rebat; jumlah adalah sama.',
|
||||
},
|
||||
bet: {
|
||||
bet_slip: 'Slip Pertaruhan',
|
||||
|
||||
@@ -18,8 +18,10 @@ const router = createRouter({
|
||||
{ path: 'bets/:betNo', component: () => import('../views/BetDetailView.vue') },
|
||||
{ path: 'wallet', component: () => import('../views/WalletView.vue') },
|
||||
{ path: 'wallet/detail', component: () => import('../views/WalletDetailView.vue') },
|
||||
{ path: 'wallet/cashbacks', component: () => import('../views/CashbackRecordsView.vue') },
|
||||
{ path: 'wallet/transactions/:transactionId', component: () => import('../views/WalletTransactionDetailView.vue') },
|
||||
{ path: 'profile', component: () => import('../views/ProfileView.vue') },
|
||||
{ path: 'profile/cashbacks', component: () => import('../views/CashbackRecordsView.vue') },
|
||||
{ path: 'profile/edit', component: () => import('../views/ProfileEditView.vue') },
|
||||
],
|
||||
},
|
||||
|
||||
354
apps/player/src/views/CashbackRecordsView.vue
Normal 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()">
|
||||
‹ {{ 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>
|
||||
@@ -115,10 +115,19 @@ function logout() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="wallet-chevron" aria-hidden="true">›</span>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<span class="cell-label">{{ t('profile.edit') }}</span>
|
||||
<span class="cell-chevron" aria-hidden="true">›</span>
|
||||
@@ -197,26 +206,6 @@ function logout() {
|
||||
transition: filter 0.1s;
|
||||
}
|
||||
|
||||
.wallet-chevron {
|
||||
position: absolute;
|
||||
right: 4%;
|
||||
bottom: 12%;
|
||||
z-index: 3;
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
line-height: 1;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.wallet-banner-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
@@ -378,6 +367,32 @@ function logout() {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.settings-cell--gold-entry {
|
||||
position: relative;
|
||||
min-height: 50px;
|
||||
border-color: rgba(212, 175, 55, 0.18);
|
||||
border-bottom-color: rgba(212, 175, 55, 0.18);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(212, 175, 55, 0.08), rgba(20, 20, 20, 0.65) 46%, rgba(212, 175, 55, 0.04));
|
||||
box-shadow:
|
||||
inset 1px 0 0 rgba(212, 175, 55, 0.22),
|
||||
inset 0 1px 0 rgba(255, 244, 200, 0.05);
|
||||
}
|
||||
|
||||
.settings-cell--gold-entry:active {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(212, 175, 55, 0.12), rgba(20, 20, 20, 0.74) 46%, rgba(212, 175, 55, 0.06));
|
||||
}
|
||||
|
||||
.settings-cell--gold-entry .cell-label {
|
||||
color: #f4dc8b;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.settings-cell--gold-entry .cell-chevron {
|
||||
color: rgba(240, 216, 117, 0.78);
|
||||
}
|
||||
|
||||
.settings-cell--stack {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import { txTypeKey } from '../utils/walletTx';
|
||||
import { txTypeKey, isCashbackType } from '../utils/walletTx';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
|
||||
@@ -82,7 +82,17 @@ const frozenChanged = computed(() => {
|
||||
return tx.value.frozenBefore !== tx.value.frozenAfter;
|
||||
});
|
||||
|
||||
const isCashbackTx = computed(
|
||||
() => !!tx.value && isCashbackType(tx.value.transactionType),
|
||||
);
|
||||
|
||||
const cashbackBatchNo = computed(() => {
|
||||
if (!isCashbackTx.value || !tx.value?.referenceId) return null;
|
||||
return tx.value.referenceId;
|
||||
});
|
||||
|
||||
const referenceLabel = computed(() => {
|
||||
if (isCashbackTx.value) return t('wallet.ref_cashback');
|
||||
if (!tx.value?.referenceType) return '';
|
||||
const rt = tx.value.referenceType.toUpperCase();
|
||||
if (rt === 'BET') return t('wallet.ref_bet');
|
||||
@@ -95,6 +105,11 @@ function goBetDetail() {
|
||||
if (!tx.value?.betNo) return;
|
||||
router.push(`/bets/${tx.value.betNo}`);
|
||||
}
|
||||
|
||||
function goCashbackDetail() {
|
||||
if (!cashbackBatchNo.value) return;
|
||||
router.push({ path: '/wallet/cashbacks', query: { batchNo: cashbackBatchNo.value } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -169,6 +184,9 @@ function goBetDetail() {
|
||||
<button v-if="tx.betNo" type="button" class="bet-link" @click="goBetDetail">
|
||||
{{ t('wallet.detail_bet_link') }} · {{ tx.betNo }}
|
||||
</button>
|
||||
<button v-if="cashbackBatchNo" type="button" class="bet-link" @click="goCashbackDetail">
|
||||
{{ t('wallet.detail_cashback_link') }} · {{ cashbackBatchNo }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
|
||||
@@ -40,6 +40,10 @@ function goWalletDetail() {
|
||||
router.push('/wallet/detail');
|
||||
}
|
||||
|
||||
function goCashbacks() {
|
||||
router.push('/wallet/cashbacks');
|
||||
}
|
||||
|
||||
async function fetchTransactions() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -80,8 +84,12 @@ const pullIndicatorStyle = () => ({
|
||||
</div>
|
||||
|
||||
<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') }} ›
|
||||
</button>
|
||||
<button
|
||||
v-if="items.length"
|
||||
type="button"
|
||||
class="more-link"
|
||||
@click="goWalletDetail"
|
||||
@@ -191,12 +199,16 @@ const pullIndicatorStyle = () => ({
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 4px 2px;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.top-bar .more-link.secondary {
|
||||
color: #f0b90b;
|
||||
}
|
||||
|
||||
.more-link {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -20,27 +20,31 @@
|
||||
- [ ] 前台可展示赛事和盘口
|
||||
- [ ] 玩家可单关下注
|
||||
- [ ] 玩家可 2-5 串 1
|
||||
- [ ] 同场串关被禁止
|
||||
- [ ] ~~同场串关被禁止~~(v1 产品决定:允许同场串关,跳过)
|
||||
- [ ] 四分之一盘口不能进入串关
|
||||
- [ ] 开赛自动封盘
|
||||
- [ ] 后台可录入比分并生成预览
|
||||
- [ ] 确认结算后钱包正确变化
|
||||
- [ ] 玩家可查看注单和账变
|
||||
- [ ] 玩家可查看返水明细,且与账单「反水」入账金额一致
|
||||
- [ ] 后台可生成并发放返水
|
||||
- [ ] Banner/公告/走马灯可配置
|
||||
- [ ] 所有关键操作有日志
|
||||
- [ ] 平台后台「自动化测试」运行所选套件,全部通过(约 50 条用例,含逐步 input/expected/actual 明细)
|
||||
|
||||
## 回归测试流程
|
||||
|
||||
1. 运行单元测试:`pnpm --filter @thebet365/api test`
|
||||
2. 代理上分 → 玩家下注 → 封盘 → 录入比分 → 预览 → 确认结算
|
||||
3. 验证钱包余额与代理额度变化
|
||||
4. 测试串关限制(同场、四分之一球、冠军竞猜)
|
||||
5. 测试返水批次生成与确认
|
||||
1. 运行单元测试:`pnpm --filter @thebet365/api test`(规则类用例,不含 DB 全链路)
|
||||
2. 或平台后台 → **自动化测试** → 选择套件(含「下注结算链路」)→ **运行测试**
|
||||
3. 代理上分 → 玩家下注 → 封盘 → 录入比分 → 预览 → 确认结算
|
||||
4. 验证钱包余额与代理额度变化
|
||||
5. 测试串关限制(四分之一球、冠军竞猜;同场串关按 v1 允许)
|
||||
6. 测试返水批次生成与确认
|
||||
7. 返水对账:后台发放后,玩家「账单-反水」入账金额 =「返水明细」对应批次金额;账变详情可跳转返水明细
|
||||
|
||||
## 备份与回滚
|
||||
|
||||
- PostgreSQL:每日 `pg_dump` 备份
|
||||
- PostgreSQL:每日 `pg_dump` 备份(脚本:`scripts/backup-db.ps1`)
|
||||
- 回滚:恢复备份 + 回退部署版本
|
||||
- 结算错误:使用重结算冲正流程,禁止直接改余额
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 117 KiB |
23
scripts/backup-db.ps1
Normal 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"
|
||||