初始化足球投注平台 MVP Monorepo

包含 NestJS 后端、三端前端、Prisma 数据模型、结算引擎测试与 PRD 文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 14:35:48 +08:00
commit 14e49374ac
118 changed files with 15944 additions and 0 deletions

12
apps/admin/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TheBet365 Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

23
apps/admin/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@thebet365/admin",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5174",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"element-plus": "^2.9.3",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vue-tsc": "^2.2.0"
}
}

4
apps/admin/src/App.vue Normal file
View File

@@ -0,0 +1,4 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>
<template><RouterView /></template>

9
apps/admin/src/api.ts Normal file
View File

@@ -0,0 +1,9 @@
import axios from 'axios';
const api = axios.create({ baseURL: '/api' });
api.interceptors.request.use((c) => {
const t = localStorage.getItem('admin_token');
if (t) c.headers.Authorization = `Bearer ${t}`;
return c;
});
export default api;

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
function logout() {
localStorage.removeItem('admin_token');
router.push('/login');
}
const menus = [
{ path: '/', label: '控制台' },
{ path: '/users', label: '玩家管理' },
{ path: '/agents', label: '代理管理' },
{ path: '/matches', label: '赛事管理' },
{ path: '/bets', label: '注单管理' },
{ path: '/cashback', label: '返水管理' },
{ path: '/audit', label: '操作日志' },
];
</script>
<template>
<el-container style="min-height: 100vh">
<el-aside width="200px" style="background: #1a2332">
<div style="padding: 20px; color: #00a826; font-weight: 800">TheBet365 Admin</div>
<el-menu background-color="#1a2332" text-color="#ccc" active-text-color="#00a826" :default-active="route.path">
<el-menu-item v-for="m in menus" :key="m.path" :index="m.path">
<RouterLink :to="m.path" style="color: inherit; width: 100%">{{ m.label }}</RouterLink>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header style="display: flex; justify-content: flex-end; align-items: center; border-bottom: 1px solid #eee">
<el-button @click="logout">退出</el-button>
</el-header>
<el-main><RouterView /></el-main>
</el-container>
</el-container>
</template>

7
apps/admin/src/main.ts Normal file
View File

@@ -0,0 +1,7 @@
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import App from './App.vue';
import router from './router';
createApp(App).use(router).use(ElementPlus).mount('#app');

View File

@@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router';
export default createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('./views/Login.vue') },
{
path: '/',
component: () => import('./layouts/AdminLayout.vue'),
meta: { auth: true },
children: [
{ path: '', component: () => import('./views/Dashboard.vue') },
{ path: 'users', component: () => import('./views/Users.vue') },
{ path: 'agents', component: () => import('./views/Agents.vue') },
{ path: 'matches', component: () => import('./views/Matches.vue') },
{ path: 'bets', component: () => import('./views/Bets.vue') },
{ path: 'settlement/:id', component: () => import('./views/Settlement.vue') },
{ path: 'cashback', component: () => import('./views/Cashback.vue') },
{ path: 'audit', component: () => import('./views/Audit.vue') },
],
},
],
});

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
import { ElMessage } from 'element-plus';
const agents = ref<unknown[]>([]);
const form = ref({ username: '', password: 'Agent@123', creditLimit: 50000 });
onMounted(load);
async function load() {
const { data } = await api.get('/admin/agents');
agents.value = data.data;
}
async function create() {
await api.post('/admin/agents', form.value);
ElMessage.success('创建成功');
load();
}
async function adjustCredit(agent: { userId: string }, amount: number) {
await api.post(`/admin/agents/${agent.userId}/credit`, {
amount,
requestId: `credit-${Date.now()}`,
});
ElMessage.success('额度已调整');
load();
}
</script>
<template>
<h2>代理管理</h2>
<el-form inline style="margin: 16px 0">
<el-input v-model="form.username" placeholder="用户名" style="width: 120px" />
<el-input-number v-model="form.creditLimit" placeholder="额度" />
<el-button type="primary" @click="create">创建一级代理</el-button>
</el-form>
<el-table :data="agents">
<el-table-column label="用户名">
<template #default="{ row }">{{ (row as { user?: { username: string } }).user?.username }}</template>
</el-table-column>
<el-table-column prop="level" label="层级" />
<el-table-column prop="creditLimit" label="授信额度" />
<el-table-column prop="usedCredit" label="已用额度" />
<el-table-column label="操作">
<template #default="{ row }">
<el-button size="small" @click="adjustCredit(row as { userId: string }, 10000)">+10000</el-button>
</template>
</el-table-column>
</el-table>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
const logs = ref<unknown[]>([]);
onMounted(async () => {
const { data } = await api.get('/admin/audit-logs');
logs.value = data.data.items;
});
</script>
<template>
<h2>操作日志</h2>
<el-table :data="logs">
<el-table-column prop="action" label="操作" />
<el-table-column prop="module" label="模块" />
<el-table-column prop="targetId" label="目标" />
<el-table-column label="时间">
<template #default="{ row }">{{ new Date((row as { createdAt: string }).createdAt).toLocaleString() }}</template>
</el-table-column>
</el-table>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
const bets = ref<unknown[]>([]);
onMounted(async () => {
const { data } = await api.get('/admin/bets');
bets.value = data.data.items;
});
</script>
<template>
<h2>注单管理</h2>
<el-table :data="bets">
<el-table-column prop="betNo" label="注单号" />
<el-table-column prop="betType" label="类型" />
<el-table-column prop="stake" label="投注额" />
<el-table-column prop="status" label="状态" />
<el-table-column label="时间">
<template #default="{ row }">{{ new Date((row as { placedAt: string }).placedAt).toLocaleString() }}</template>
</el-table-column>
</el-table>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { ref } from 'vue';
import api from '../api';
import { ElMessage } from 'element-plus';
const preview = ref<Record<string, unknown> | null>(null);
const period = ref({
start: new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10),
end: new Date().toISOString().slice(0, 10),
});
async function generatePreview() {
const { data } = await api.post('/admin/cashbacks/preview', {
periodStart: period.value.start,
periodEnd: period.value.end,
});
preview.value = data.data;
}
async function confirm() {
if (!preview.value?.batch) return;
await api.post(`/admin/cashbacks/${(preview.value.batch as { id: string }).id}/confirm`);
ElMessage.success('返水已发放');
}
</script>
<template>
<h2>返水管理</h2>
<el-form inline>
<el-date-picker v-model="period.start" type="date" value-format="YYYY-MM-DD" />
<el-date-picker v-model="period.end" type="date" value-format="YYYY-MM-DD" />
<el-button @click="generatePreview">生成预览</el-button>
</el-form>
<el-card v-if="preview" style="margin-top: 16px">
<p>玩家数: {{ (preview.batch as { playerCount: number })?.playerCount }}</p>
<p>总金额: {{ preview.totalAmount }}</p>
<el-button type="success" @click="confirm">确认发放</el-button>
</el-card>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
const stats = ref<Record<string, unknown>>({});
onMounted(async () => {
const { data } = await api.get('/admin/dashboard');
stats.value = data.data;
});
</script>
<template>
<h2>控制台</h2>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="6"><el-statistic title="今日投注笔数" :value="(stats.todayBetCount as number) || 0" /></el-col>
<el-col :span="6"><el-statistic title="今日投注额" :value="Number(stats.todayStake) || 0" :precision="2" /></el-col>
<el-col :span="6"><el-statistic title="今日派彩" :value="Number(stats.todayPayout) || 0" :precision="2" /></el-col>
<el-col :span="6"><el-statistic title="待结算赛事" :value="(stats.pendingSettlement as number) || 0" /></el-col>
</el-row>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { ElMessage } from 'element-plus';
const router = useRouter();
const form = ref({ username: 'admin', password: 'Admin@123' });
const loading = ref(false);
async function login() {
loading.value = true;
try {
const { data } = await api.post('/admin/auth/login', form.value);
localStorage.setItem('admin_token', data.data.token);
router.push('/');
} catch {
ElMessage.error('登录失败');
} finally {
loading.value = false;
}
}
</script>
<template>
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center">
<el-card style="width: 400px">
<h2 style="text-align: center; margin-bottom: 24px">平台后台登录</h2>
<el-form @submit.prevent="login">
<el-form-item label="用户名">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" />
</el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">登录</el-button>
</el-form>
</el-card>
</div>
</template>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { ElMessage } from 'element-plus';
const router = useRouter();
const matches = ref<unknown[]>([]);
const form = ref({
leagueId: '',
homeTeamId: '',
awayTeamId: '',
startTime: '',
});
onMounted(load);
async function load() {
const { data } = await api.get('/admin/matches');
matches.value = data.data;
}
async function create() {
await api.post('/admin/matches', form.value);
ElMessage.success('赛事已创建');
load();
}
async function publish(id: string) {
await api.post(`/admin/matches/${id}/publish`);
await api.post(`/admin/matches/${id}/markets/templates`, {
marketTypes: ['FT_1X2', 'FT_HANDICAP', 'FT_OVER_UNDER', 'FT_ODD_EVEN'],
});
ElMessage.success('已发布并生成盘口');
load();
}
async function close(id: string) {
await api.post(`/admin/matches/${id}/close`);
ElMessage.success('已封盘');
load();
}
function settle(id: string) {
router.push(`/settlement/${id}`);
}
</script>
<template>
<h2>赛事管理</h2>
<el-card style="margin-bottom: 16px">
<el-form inline>
<el-input v-model="form.leagueId" placeholder="联赛ID" style="width: 100px" />
<el-input v-model="form.homeTeamId" placeholder="主队ID" style="width: 100px" />
<el-input v-model="form.awayTeamId" placeholder="客队ID" style="width: 100px" />
<el-input v-model="form.startTime" placeholder="开赛时间 ISO" style="width: 200px" />
<el-button type="primary" @click="create">创建赛事</el-button>
</el-form>
</el-card>
<el-table :data="matches">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="status" label="状态" />
<el-table-column label="开赛时间">
<template #default="{ row }">{{ new Date((row as { startTime: string }).startTime).toLocaleString() }}</template>
</el-table-column>
<el-table-column label="操作" width="300">
<template #default="{ row }">
<el-button v-if="(row as { status: string }).status === 'DRAFT'" size="small" @click="publish((row as { id: string }).id)">发布</el-button>
<el-button v-if="(row as { status: string }).status === 'PUBLISHED'" size="small" @click="close((row as { id: string }).id)">封盘</el-button>
<el-button size="small" type="warning" @click="settle((row as { id: string }).id)">结算</el-button>
</template>
</el-table-column>
</el-table>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import api from '../api';
import { ElMessage } from 'element-plus';
const route = useRoute();
const score = ref({ htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 });
const preview = ref<Record<string, unknown> | null>(null);
async function recordScore() {
await api.post(`/admin/matches/${route.params.id}/settlement/score`, score.value);
ElMessage.success('比分已录入');
}
async function previewSettlement() {
const { data } = await api.post(`/admin/matches/${route.params.id}/settlement/preview`);
preview.value = data.data;
}
async function confirm() {
if (!preview.value?.batch) return;
await api.post(`/admin/settlement/${(preview.value.batch as { id: string }).id}/confirm`);
ElMessage.success('结算已确认');
}
</script>
<template>
<h2>赛事结算 #{{ route.params.id }}</h2>
<el-form inline style="margin: 16px 0">
<el-input-number v-model="score.htHome" :min="0" />
<el-input-number v-model="score.htAway" :min="0" />
<span>半场</span>
<el-input-number v-model="score.ftHome" :min="0" />
<el-input-number v-model="score.ftAway" :min="0" />
<span>全场</span>
<el-button @click="recordScore">录入比分</el-button>
<el-button type="primary" @click="previewSettlement">生成预览</el-button>
</el-form>
<el-card v-if="preview">
<p>单关注单: {{ preview.singleBetCount }}</p>
<p>预计派彩: {{ preview.totalPayout }}</p>
<p>退款: {{ preview.totalRefund }}</p>
<el-button type="success" @click="confirm">确认结算</el-button>
</el-card>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
import { ElMessage } from 'element-plus';
const users = ref<unknown[]>([]);
const form = ref({ username: '', password: 'Player@123', parentId: '' });
onMounted(load);
async function load() {
const { data } = await api.get('/admin/users');
users.value = data.data.items;
}
async function create() {
await api.post('/admin/users', form.value);
ElMessage.success('创建成功');
form.value.username = '';
load();
}
</script>
<template>
<h2>玩家管理</h2>
<el-form inline style="margin: 16px 0">
<el-input v-model="form.username" placeholder="用户名" style="width: 150px" />
<el-input v-model="form.password" placeholder="密码" style="width: 150px" />
<el-button type="primary" @click="create">创建玩家</el-button>
</el-form>
<el-table :data="users">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="status" label="状态" />
<el-table-column label="余额">
<template #default="{ row }">{{ (row as { wallet?: { availableBalance: string } }).wallet?.availableBalance }}</template>
</el-table-column>
</el-table>
</template>

5
apps/admin/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<object, object, unknown>;
export default component;
}

1
apps/admin/tsconfig.json Normal file
View File

@@ -0,0 +1 @@
{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true }, "include": ["src/**/*.ts", "src/**/*.vue"] }

10
apps/admin/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5174,
proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } },
},
});

5
apps/agent/index.html Normal file
View File

@@ -0,0 +1,5 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8" /><title>TheBet365 Agent</title></head>
<body><div id="app"></div><script type="module" src="/src/main.ts"></script></body>
</html>

23
apps/agent/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@thebet365/agent",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5175",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"element-plus": "^2.9.3",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vue-tsc": "^2.2.0"
}
}

1
apps/agent/src/App.vue Normal file
View File

@@ -0,0 +1 @@
<script setup lang="ts"><template><router-view /></template></script>

9
apps/agent/src/api.ts Normal file
View File

@@ -0,0 +1,9 @@
import axios from 'axios';
const api = axios.create({ baseURL: '/api' });
api.interceptors.request.use((c) => {
const t = localStorage.getItem('agent_token');
if (t) c.headers.Authorization = `Bearer ${t}`;
return c;
});
export default api;

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { RouterView, RouterLink, useRouter } from 'vue-router';
const router = useRouter();
function logout() {
localStorage.removeItem('agent_token');
router.push('/login');
}
</script>
<template>
<el-container style="min-height: 100vh">
<el-aside width="180px" style="background: #1a2332">
<div style="padding: 16px; color: #00a826; font-weight: 700">代理后台</div>
<el-menu background-color="#1a2332" text-color="#ccc" active-text-color="#00a826">
<el-menu-item index="/"><RouterLink to="/" style="color: inherit">概览</RouterLink></el-menu-item>
<el-menu-item index="/players"><RouterLink to="/players" style="color: inherit">直属玩家</RouterLink></el-menu-item>
<el-menu-item index="/agents"><RouterLink to="/agents" style="color: inherit">下级代理</RouterLink></el-menu-item>
<el-menu-item index="/bets"><RouterLink to="/bets" style="color: inherit">注单查询</RouterLink></el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header style="text-align: right; line-height: 60px">
<el-button @click="logout">退出</el-button>
</el-header>
<el-main><RouterView /></el-main>
</el-container>
</el-container>
</template>

7
apps/agent/src/main.ts Normal file
View File

@@ -0,0 +1,7 @@
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import App from './App.vue';
import router from './router';
createApp(App).use(router).use(ElementPlus).mount('#app');

View File

@@ -0,0 +1,18 @@
import { createRouter, createWebHistory } from 'vue-router';
export default createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('./views/Login.vue') },
{
path: '/',
component: () => import('./layouts/AgentLayout.vue'),
children: [
{ path: '', component: () => import('./views/Dashboard.vue') },
{ path: 'players', component: () => import('./views/Players.vue') },
{ path: 'agents', component: () => import('./views/SubAgents.vue') },
{ path: 'bets', component: () => import('./views/Bets.vue') },
],
},
],
});

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
const bets = ref<unknown[]>([]);
onMounted(async () => {
const { data } = await api.get('/agent/bets');
bets.value = data.data.items;
});
</script>
<template>
<h2>下级注单</h2>
<el-table :data="bets">
<el-table-column prop="betNo" label="注单号" />
<el-table-column label="玩家">
<template #default="{ row }">{{ (row as { user?: { username: string } }).user?.username }}</template>
</el-table-column>
<el-table-column prop="stake" label="投注额" />
<el-table-column prop="status" label="状态" />
</el-table>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
const summary = ref<Record<string, unknown>>({});
onMounted(async () => {
const { data } = await api.get('/agent/reports/summary');
summary.value = data.data;
});
</script>
<template>
<h2>代理概览</h2>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="6"><el-statistic title="授信额度" :value="Number((summary.profile as { creditLimit?: string })?.creditLimit) || 0" /></el-col>
<el-col :span="6"><el-statistic title="已用额度" :value="Number((summary.profile as { usedCredit?: string })?.usedCredit) || 0" /></el-col>
<el-col :span="6"><el-statistic title="直属玩家" :value="(summary.directPlayerCount as number) || 0" /></el-col>
<el-col :span="6"><el-statistic title="今日投注" :value="Number(summary.todayStake) || 0" :precision="2" /></el-col>
</el-row>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { ElMessage } from 'element-plus';
const router = useRouter();
const form = ref({ username: 'agent1', password: 'Agent@123' });
async function login() {
try {
const { data } = await api.post('/agent/auth/login', form.value);
localStorage.setItem('agent_token', data.data.token);
router.push('/');
} catch {
ElMessage.error('登录失败');
}
}
</script>
<template>
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center">
<el-card style="width: 360px">
<h2 style="text-align: center">代理后台登录</h2>
<el-form @submit.prevent="login" style="margin-top: 20px">
<el-input v-model="form.username" placeholder="用户名" style="margin-bottom: 12px" />
<el-input v-model="form.password" type="password" placeholder="密码" style="margin-bottom: 12px" />
<el-button type="primary" native-type="submit" style="width: 100%">登录</el-button>
</el-form>
</el-card>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
import { ElMessage } from 'element-plus';
const players = ref<unknown[]>([]);
const form = ref({ username: '', password: 'Player@123' });
const depositForm = ref({ playerId: '', amount: 100, requestId: '' });
onMounted(load);
async function load() {
const { data } = await api.get('/agent/players');
players.value = data.data;
}
async function create() {
await api.post('/agent/players', form.value);
ElMessage.success('玩家已创建');
load();
}
async function deposit() {
depositForm.value.requestId = `dep-${Date.now()}`;
await api.post(`/agent/players/${depositForm.value.playerId}/deposit`, {
amount: depositForm.value.amount,
requestId: depositForm.value.requestId,
});
ElMessage.success('上分成功');
load();
}
async function withdraw(playerId: string, amount: number) {
await api.post(`/agent/players/${playerId}/withdraw`, {
amount,
requestId: `wd-${Date.now()}`,
});
ElMessage.success('下分成功');
load();
}
</script>
<template>
<h2>直属玩家</h2>
<el-form inline style="margin-bottom: 16px">
<el-input v-model="form.username" placeholder="用户名" />
<el-button type="primary" @click="create">创建玩家</el-button>
</el-form>
<el-form inline style="margin-bottom: 16px">
<el-input v-model="depositForm.playerId" placeholder="玩家ID" style="width: 100px" />
<el-input-number v-model="depositForm.amount" :min="1" />
<el-button type="success" @click="deposit">上分</el-button>
</el-form>
<el-table :data="players">
<el-table-column prop="id" label="ID" />
<el-table-column prop="username" label="用户名" />
<el-table-column label="余额">
<template #default="{ row }">{{ (row as { wallet?: { availableBalance: string } }).wallet?.availableBalance }}</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button size="small" @click="withdraw((row as { id: string }).id, 50)">下分50</el-button>
</template>
</el-table-column>
</el-table>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
import { ElMessage } from 'element-plus';
const agents = ref<unknown[]>([]);
const form = ref({ username: '', password: 'Agent@123', creditLimit: 10000 });
onMounted(load);
async function load() {
const { data } = await api.get('/agent/agents');
agents.value = data.data;
}
async function create() {
await api.post('/agent/agents', form.value);
ElMessage.success('下级代理已创建');
load();
}
</script>
<template>
<h2>下级代理仅一级代理可见</h2>
<el-form inline style="margin-bottom: 16px">
<el-input v-model="form.username" placeholder="用户名" />
<el-input-number v-model="form.creditLimit" />
<el-button type="primary" @click="create">创建二级代理</el-button>
</el-form>
<el-table :data="agents">
<el-table-column label="用户名">
<template #default="{ row }">{{ (row as { user?: { username: string } }).user?.username }}</template>
</el-table-column>
<el-table-column prop="creditLimit" label="额度" />
<el-table-column prop="usedCredit" label="已用" />
</el-table>
</template>

5
apps/agent/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<object, object, unknown>;
export default component;
}

1
apps/agent/tsconfig.json Normal file
View File

@@ -0,0 +1 @@
{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true }, "include": ["src/**/*.ts", "src/**/*.vue"] }

10
apps/agent/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5175,
proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } },
},
});

14
apps/api/jest.config.js Normal file
View File

@@ -0,0 +1,14 @@
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
};

8
apps/api/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

59
apps/api/package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "@thebet365/api",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:migrate:deploy": "prisma migrate deploy",
"db:seed": "ts-node prisma/seed.ts",
"db:studio": "prisma studio"
},
"dependencies": {
"@nestjs/common": "^11.0.6",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.6",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.6",
"@nestjs/schedule": "^5.0.1",
"@nestjs/swagger": "^11.0.3",
"@prisma/client": "^6.3.1",
"@thebet365/shared": "workspace:*",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"decimal.js": "^10.4.3",
"ioredis": "^5.4.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"uuid": "^11.0.5"
},
"devDependencies": {
"@nestjs/cli": "^11.0.2",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.6",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/uuid": "^10.0.0",
"jest": "^29.7.0",
"prisma": "^6.3.1",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.7.3"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}

View File

@@ -0,0 +1,5 @@
-- Initial migration for TheBet365 MVP
-- Run: pnpm --filter @thebet365/api db:migrate
-- Generated from prisma/schema.prisma
-- Use `pnpm db:migrate` when PostgreSQL is available

View File

@@ -0,0 +1,585 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============ Users & Auth ============
model User {
id BigInt @id @default(autoincrement())
username String @unique @db.VarChar(64)
userType String @map("user_type") @db.VarChar(20)
status String @default("ACTIVE") @db.VarChar(20)
parentId BigInt? @map("parent_id")
agentLevel Int? @map("agent_level")
locale String @default("en-US") @db.VarChar(10)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
auth UserAuth?
wallet Wallet?
agentProfile AgentProfile?
adminRole AdminUserRole?
bets Bet[]
preferences UserPreference?
parent User? @relation("UserHierarchy", fields: [parentId], references: [id])
children User[] @relation("UserHierarchy")
@@index([userType])
@@index([parentId])
@@map("users")
}
model UserAuth {
id BigInt @id @default(autoincrement())
userId BigInt @unique @map("user_id")
passwordHash String @map("password_hash") @db.VarChar(255)
loginFailCount Int @default(0) @map("login_fail_count")
lockedUntil DateTime? @map("locked_until")
lastLoginAt DateTime? @map("last_login_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
@@map("user_auth")
}
model UserPreference {
id BigInt @id @default(autoincrement())
userId BigInt @unique @map("user_id")
locale String @default("en-US") @db.VarChar(10)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
@@map("user_preferences")
}
model Role {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(64)
name String @db.VarChar(128)
description String? @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at")
permissions RolePermission[]
adminUsers AdminUserRole[]
@@map("roles")
}
model Permission {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(128)
name String @db.VarChar(128)
module String @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at")
roles RolePermission[]
@@map("permissions")
}
model RolePermission {
roleId BigInt @map("role_id")
permissionId BigInt @map("permission_id")
role Role @relation(fields: [roleId], references: [id])
permission Permission @relation(fields: [permissionId], references: [id])
@@id([roleId, permissionId])
@@map("role_permissions")
}
model AdminUserRole {
userId BigInt @unique @map("user_id")
roleId BigInt @map("role_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
role Role @relation(fields: [roleId], references: [id])
@@map("admin_user_roles")
}
// ============ Agent ============
model AgentProfile {
id BigInt @id @default(autoincrement())
userId BigInt @unique @map("user_id")
level Int
parentAgentId BigInt? @map("parent_agent_id")
creditLimit Decimal @default(0) @map("credit_limit") @db.Decimal(18, 4)
usedCredit Decimal @default(0) @map("used_credit") @db.Decimal(18, 4)
directPlayerLiability Decimal @default(0) @map("direct_player_liability") @db.Decimal(18, 4)
childAgentExposure Decimal @default(0) @map("child_agent_exposure") @db.Decimal(18, 4)
status String @default("ACTIVE") @db.VarChar(20)
maxSingleDeposit Decimal? @map("max_single_deposit") @db.Decimal(18, 4)
maxDailyDeposit Decimal? @map("max_daily_deposit") @db.Decimal(18, 4)
cashbackRate Decimal @default(0) @map("cashback_rate") @db.Decimal(8, 4)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
@@index([parentAgentId])
@@map("agent_profiles")
}
model AgentClosure {
ancestorId BigInt @map("ancestor_id")
descendantId BigInt @map("descendant_id")
depth Int
@@id([ancestorId, descendantId])
@@index([descendantId])
@@map("agent_closure")
}
model AgentCreditTransaction {
id BigInt @id @default(autoincrement())
agentId BigInt @map("agent_id")
transactionType String @map("transaction_type") @db.VarChar(32)
amount Decimal @db.Decimal(18, 4)
creditBefore Decimal @map("credit_before") @db.Decimal(18, 4)
creditAfter Decimal @map("credit_after") @db.Decimal(18, 4)
referenceType String? @map("reference_type") @db.VarChar(32)
referenceId String? @map("reference_id") @db.VarChar(64)
operatorId BigInt? @map("operator_id")
requestId String? @map("request_id") @db.VarChar(128)
remark String? @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
@@unique([operatorId, requestId])
@@index([agentId])
@@map("agent_credit_transactions")
}
// ============ Wallet ============
model Wallet {
id BigInt @id @default(autoincrement())
userId BigInt @unique @map("user_id")
availableBalance Decimal @default(0) @map("available_balance") @db.Decimal(18, 4)
frozenBalance Decimal @default(0) @map("frozen_balance") @db.Decimal(18, 4)
currency String @default("USD") @db.VarChar(16)
status String @default("ACTIVE") @db.VarChar(20)
version Int @default(0)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
transactions WalletTransaction[]
@@map("wallets")
}
model WalletTransaction {
id BigInt @id @default(autoincrement())
transactionId String @unique @map("transaction_id") @db.VarChar(64)
userId BigInt @map("user_id")
walletId BigInt @map("wallet_id")
transactionType String @map("transaction_type") @db.VarChar(32)
amount Decimal @db.Decimal(18, 4)
balanceBefore Decimal @map("balance_before") @db.Decimal(18, 4)
balanceAfter Decimal @map("balance_after") @db.Decimal(18, 4)
frozenBefore Decimal @map("frozen_before") @db.Decimal(18, 4)
frozenAfter Decimal @map("frozen_after") @db.Decimal(18, 4)
referenceType String? @map("reference_type") @db.VarChar(32)
referenceId String? @map("reference_id") @db.VarChar(64)
operatorId BigInt? @map("operator_id")
remark String? @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
wallet Wallet @relation(fields: [walletId], references: [id])
@@index([userId])
@@index([walletId])
@@index([createdAt])
@@map("wallet_transactions")
}
// ============ Sports Data ============
model League {
id BigInt @id @default(autoincrement())
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
code String @unique @db.VarChar(64)
displayOrder Int @default(0) @map("display_order")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
matches Match[]
@@map("leagues")
}
model Team {
id BigInt @id @default(autoincrement())
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
code String @unique @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
homeMatches Match[] @relation("HomeTeam")
awayMatches Match[] @relation("AwayTeam")
@@map("teams")
}
model EntityTranslation {
id BigInt @id @default(autoincrement())
entityType String @map("entity_type") @db.VarChar(32)
entityId BigInt @map("entity_id")
locale String @db.VarChar(10)
fieldName String @map("field_name") @db.VarChar(32)
value String @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([entityType, entityId, locale, fieldName])
@@index([entityType, entityId])
@@map("entity_translations")
}
model Match {
id BigInt @id @default(autoincrement())
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
leagueId BigInt @map("league_id")
homeTeamId BigInt @map("home_team_id")
awayTeamId BigInt @map("away_team_id")
startTime DateTime @map("start_time")
status String @default("DRAFT") @db.VarChar(32)
isHot Boolean @default(false) @map("is_hot")
displayOrder Int @default(0) @map("display_order")
publishTime DateTime? @map("publish_time")
closeTime DateTime? @map("close_time")
isOutright Boolean @default(false) @map("is_outright")
createdBy BigInt? @map("created_by")
updatedBy BigInt? @map("updated_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
league League @relation(fields: [leagueId], references: [id])
homeTeam Team @relation("HomeTeam", fields: [homeTeamId], references: [id])
awayTeam Team @relation("AwayTeam", fields: [awayTeamId], references: [id])
score MatchScore?
markets Market[]
settlements SettlementBatch[]
@@index([status])
@@index([startTime])
@@index([leagueId])
@@map("matches")
}
model MatchScore {
id BigInt @id @default(autoincrement())
matchId BigInt @unique @map("match_id")
htHomeScore Int? @map("ht_home_score")
htAwayScore Int? @map("ht_away_score")
ftHomeScore Int? @map("ft_home_score")
ftAwayScore Int? @map("ft_away_score")
winnerTeamId BigInt? @map("winner_team_id")
recordedBy BigInt? @map("recorded_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
match Match @relation(fields: [matchId], references: [id])
@@map("match_scores")
}
model Market {
id BigInt @id @default(autoincrement())
matchId BigInt @map("match_id")
marketType String @map("market_type") @db.VarChar(64)
period String @db.VarChar(16)
lineValue Decimal? @map("line_value") @db.Decimal(8, 2)
status String @default("OPEN") @db.VarChar(20)
allowSingle Boolean @default(true) @map("allow_single")
allowParlay Boolean @default(true) @map("allow_parlay")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
match Match @relation(fields: [matchId], references: [id])
selections MarketSelection[]
@@index([matchId])
@@index([marketType])
@@map("markets")
}
model MarketSelection {
id BigInt @id @default(autoincrement())
marketId BigInt @map("market_id")
selectionCode String @map("selection_code") @db.VarChar(64)
selectionName String @map("selection_name") @db.VarChar(255)
odds Decimal @db.Decimal(18, 6)
oddsVersion BigInt @default(1) @map("odds_version")
status String @default("OPEN") @db.VarChar(20)
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
market Market @relation(fields: [marketId], references: [id])
oddsLogs OddsChangeLog[]
@@index([marketId])
@@map("market_selections")
}
model OddsChangeLog {
id BigInt @id @default(autoincrement())
selectionId BigInt @map("selection_id")
oldOdds Decimal @map("old_odds") @db.Decimal(18, 6)
newOdds Decimal @map("new_odds") @db.Decimal(18, 6)
oddsVersion BigInt @map("odds_version")
changedBy BigInt? @map("changed_by")
createdAt DateTime @default(now()) @map("created_at")
selection MarketSelection @relation(fields: [selectionId], references: [id])
@@index([selectionId])
@@map("odds_change_logs")
}
// ============ Bets ============
model Bet {
id BigInt @id @default(autoincrement())
betNo String @unique @map("bet_no") @db.VarChar(64)
userId BigInt @map("user_id")
agentId BigInt? @map("agent_id")
betType String @map("bet_type") @db.VarChar(20)
stake Decimal @db.Decimal(18, 4)
totalOdds Decimal? @map("total_odds") @db.Decimal(18, 6)
potentialReturn Decimal? @map("potential_return") @db.Decimal(18, 4)
actualReturn Decimal @default(0) @map("actual_return") @db.Decimal(18, 4)
status String @default("PENDING") @db.VarChar(32)
settlementStatus String? @map("settlement_status") @db.VarChar(32)
currency String @default("USD") @db.VarChar(16)
requestId String @map("request_id") @db.VarChar(128)
placedAt DateTime @default(now()) @map("placed_at")
settledAt DateTime? @map("settled_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
selections BetSelection[]
@@unique([userId, requestId])
@@index([userId])
@@index([agentId])
@@index([status])
@@index([placedAt])
@@map("bets")
}
model BetSelection {
id BigInt @id @default(autoincrement())
betId BigInt @map("bet_id")
matchId BigInt? @map("match_id")
marketId BigInt @map("market_id")
selectionId BigInt @map("selection_id")
marketType String @map("market_type") @db.VarChar(64)
period String? @db.VarChar(16)
selectionNameSnapshot String @map("selection_name_snapshot") @db.VarChar(255)
handicapLine Decimal? @map("handicap_line") @db.Decimal(8, 2)
totalLine Decimal? @map("total_line") @db.Decimal(8, 2)
odds Decimal @db.Decimal(18, 6)
oddsVersion BigInt @map("odds_version")
resultStatus String? @map("result_status") @db.VarChar(32)
effectiveOdds Decimal? @map("effective_odds") @db.Decimal(18, 6)
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
bet Bet @relation(fields: [betId], references: [id])
@@index([betId])
@@index([matchId])
@@map("bet_selections")
}
// ============ Settlement ============
model SettlementBatch {
id BigInt @id @default(autoincrement())
matchId BigInt @map("match_id")
batchNo String @unique @map("batch_no") @db.VarChar(64)
htHomeScore Int? @map("ht_home_score")
htAwayScore Int? @map("ht_away_score")
ftHomeScore Int? @map("ft_home_score")
ftAwayScore Int? @map("ft_away_score")
status String @default("PREVIEW") @db.VarChar(20)
totalBets Int @default(0) @map("total_bets")
totalPayout Decimal @default(0) @map("total_payout") @db.Decimal(18, 4)
totalRefund Decimal @default(0) @map("total_refund") @db.Decimal(18, 4)
operatorId BigInt? @map("operator_id")
confirmedAt DateTime? @map("confirmed_at")
isResettle Boolean @default(false) @map("is_resettle")
reason String? @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
match Match @relation(fields: [matchId], references: [id])
items SettlementItem[]
@@index([matchId])
@@map("settlement_batches")
}
model SettlementItem {
id BigInt @id @default(autoincrement())
batchId BigInt @map("batch_id")
betId BigInt @map("bet_id")
userId BigInt @map("user_id")
result String @db.VarChar(32)
payout Decimal @db.Decimal(18, 4)
createdAt DateTime @default(now()) @map("created_at")
batch SettlementBatch @relation(fields: [batchId], references: [id])
@@index([batchId])
@@index([betId])
@@map("settlement_items")
}
// ============ Cashback ============
model CashbackRule {
id BigInt @id @default(autoincrement())
name String @db.VarChar(128)
targetType String @map("target_type") @db.VarChar(32)
targetId BigInt? @map("target_id")
rate Decimal @db.Decimal(8, 4)
marketType String? @map("market_type") @db.VarChar(64)
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("cashback_rules")
}
model CashbackBatch {
id BigInt @id @default(autoincrement())
batchNo String @unique @map("batch_no") @db.VarChar(64)
periodStart DateTime @map("period_start")
periodEnd DateTime @map("period_end")
status String @default("PREVIEW") @db.VarChar(20)
totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(18, 4)
playerCount Int @default(0) @map("player_count")
operatorId BigInt? @map("operator_id")
confirmedAt DateTime? @map("confirmed_at")
createdAt DateTime @default(now()) @map("created_at")
items CashbackItem[]
@@map("cashback_batches")
}
model CashbackItem {
id BigInt @id @default(autoincrement())
batchId BigInt @map("batch_id")
userId BigInt @map("user_id")
effectiveStake Decimal @map("effective_stake") @db.Decimal(18, 4)
rate Decimal @db.Decimal(8, 4)
amount Decimal @db.Decimal(18, 4)
createdAt DateTime @default(now()) @map("created_at")
batch CashbackBatch @relation(fields: [batchId], references: [id])
@@index([batchId])
@@index([userId])
@@map("cashback_items")
}
// ============ Content & i18n ============
model Content {
id BigInt @id @default(autoincrement())
contentType String @map("content_type") @db.VarChar(32)
sortOrder Int @default(0) @map("sort_order")
status String @default("DRAFT") @db.VarChar(20)
linkType String? @map("link_type") @db.VarChar(32)
linkTarget String? @map("link_target") @db.VarChar(500)
startTime DateTime? @map("start_time")
endTime DateTime? @map("end_time")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
translations ContentTranslation[]
@@index([contentType, status])
@@map("contents")
}
model ContentTranslation {
id BigInt @id @default(autoincrement())
contentId BigInt @map("content_id")
locale String @db.VarChar(10)
title String? @db.VarChar(255)
body String? @db.Text
imageUrl String? @map("image_url") @db.VarChar(500)
content Content @relation(fields: [contentId], references: [id])
@@unique([contentId, locale])
@@map("content_translations")
}
model I18nMessage {
id BigInt @id @default(autoincrement())
msgKey String @map("msg_key") @db.VarChar(128)
locale String @db.VarChar(10)
value String @db.Text
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([msgKey, locale])
@@map("i18n_messages")
}
// ============ System Config & Audit ============
model SystemConfig {
id BigInt @id @default(autoincrement())
configKey String @unique @map("config_key") @db.VarChar(128)
configValue String @map("config_value") @db.Text
description String? @db.VarChar(255)
updatedAt DateTime @updatedAt @map("updated_at")
@@map("system_configs")
}
model AuditLog {
id BigInt @id @default(autoincrement())
operatorId BigInt? @map("operator_id")
operatorType String @map("operator_type") @db.VarChar(20)
action String @db.VarChar(128)
module String @db.VarChar(64)
targetType String? @map("target_type") @db.VarChar(32)
targetId String? @map("target_id") @db.VarChar(64)
beforeData String? @map("before_data") @db.Text
afterData String? @map("after_data") @db.Text
ipAddress String? @map("ip_address") @db.VarChar(45)
userAgent String? @map("user_agent") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
@@index([operatorId])
@@index([module])
@@index([createdAt])
@@map("audit_logs")
}

182
apps/api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,182 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding database...');
const superAdminRole = await prisma.role.upsert({
where: { code: 'SUPER_ADMIN' },
create: { code: 'SUPER_ADMIN', name: 'Super Admin', description: 'Full access' },
update: {},
});
const permCodes = [
'users.create', 'users.view', 'agents.create', 'agents.view',
'wallet.deposit', 'wallet.withdraw', 'matches.manage', 'settlement.confirm',
'cashback.confirm', 'content.manage', 'reports.view',
];
for (const code of permCodes) {
const perm = await prisma.permission.upsert({
where: { code },
create: { code, name: code, module: code.split('.')[0] },
update: {},
});
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: superAdminRole.id, permissionId: perm.id } },
create: { roleId: superAdminRole.id, permissionId: perm.id },
update: {},
});
}
const hash = await bcrypt.hash('Admin@123', 10);
const agentHash = await bcrypt.hash('Agent@123', 10);
const playerHash = await bcrypt.hash('Player@123', 10);
await prisma.user.upsert({
where: { username: 'admin' },
create: {
username: 'admin',
userType: 'ADMIN',
auth: { create: { passwordHash: hash } },
adminRole: { create: { roleId: superAdminRole.id } },
},
update: {},
});
const agent1 = await prisma.user.upsert({
where: { username: 'agent1' },
create: {
username: 'agent1',
userType: 'AGENT',
agentLevel: 1,
auth: { create: { passwordHash: agentHash } },
agentProfile: { create: { level: 1, creditLimit: 100000 } },
},
update: {},
});
await prisma.agentClosure.upsert({
where: { ancestorId_descendantId: { ancestorId: agent1.id, descendantId: agent1.id } },
create: { ancestorId: agent1.id, descendantId: agent1.id, depth: 0 },
update: {},
});
await prisma.user.upsert({
where: { username: 'agent2' },
create: {
username: 'agent2',
userType: 'AGENT',
agentLevel: 2,
parentId: agent1.id,
auth: { create: { passwordHash: agentHash } },
agentProfile: { create: { level: 2, parentAgentId: agent1.id, creditLimit: 30000 } },
},
update: {},
});
await prisma.user.upsert({
where: { username: 'player1' },
create: {
username: 'player1',
userType: 'PLAYER',
parentId: agent1.id,
auth: { create: { passwordHash: playerHash } },
wallet: { create: { availableBalance: 1000 } },
preferences: { create: { locale: 'zh-CN' } },
},
update: {},
});
const messages = [
{ key: 'nav.home', zh: '首页', ms: 'Laman Utama', en: 'Home' },
{ key: 'nav.football', zh: '足球', ms: 'Bola Sepak', en: 'Football' },
{ key: 'bet.place_bet', zh: '确认下注', ms: 'Letak Pertaruhan', en: 'Place Bet' },
{ key: 'error.insufficient_balance', zh: '余额不足', ms: 'Baki tidak mencukupi', en: 'Insufficient balance' },
];
for (const m of messages) {
for (const [locale, value] of [['zh-CN', m.zh], ['ms-MY', m.ms], ['en-US', m.en]] as const) {
await prisma.i18nMessage.upsert({
where: { msgKey_locale: { msgKey: m.key, locale } },
create: { msgKey: m.key, locale, value },
update: { value },
});
}
}
const league = await prisma.league.upsert({
where: { code: 'EPL' },
create: { code: 'EPL' },
update: {},
});
await prisma.entityTranslation.upsert({
where: { entityType_entityId_locale_fieldName: { entityType: 'LEAGUE', entityId: league.id, locale: 'zh-CN', fieldName: 'name' } },
create: { entityType: 'LEAGUE', entityId: league.id, locale: 'zh-CN', fieldName: 'name', value: '英超' },
update: {},
});
for (const [code, name] of [['MUN', '曼联'], ['CHE', '切尔西']] as const) {
const team = await prisma.team.upsert({ where: { code }, create: { code }, update: {} });
await prisma.entityTranslation.upsert({
where: { entityType_entityId_locale_fieldName: { entityType: 'TEAM', entityId: team.id, locale: 'zh-CN', fieldName: 'name' } },
create: { entityType: 'TEAM', entityId: team.id, locale: 'zh-CN', fieldName: 'name', value: name },
update: { value: name },
});
}
const mun = await prisma.team.findUnique({ where: { code: 'MUN' } });
const che = await prisma.team.findUnique({ where: { code: 'CHE' } });
if (mun && che) {
const existing = await prisma.match.findFirst({ where: { homeTeamId: mun.id, awayTeamId: che.id } });
if (!existing) {
const match = await prisma.match.create({
data: {
leagueId: league.id,
homeTeamId: mun.id,
awayTeamId: che.id,
startTime: new Date(Date.now() + 86400000),
status: 'PUBLISHED',
isHot: true,
publishTime: new Date(),
},
});
await prisma.market.create({
data: {
matchId: match.id,
marketType: 'FT_1X2',
period: 'FT',
selections: {
create: [
{ selectionCode: 'HOME', selectionName: 'Home', odds: 2.5 },
{ selectionCode: 'DRAW', selectionName: 'Draw', odds: 3.2 },
{ selectionCode: 'AWAY', selectionName: 'Away', odds: 2.8 },
],
},
},
});
}
}
await prisma.content.create({
data: {
contentType: 'BANNER',
status: 'ACTIVE',
sortOrder: 1,
translations: {
create: [
{ locale: 'zh-CN', title: '欢迎投注', body: '足球赛事火热进行中' },
{ locale: 'en-US', title: 'Welcome', body: 'Football matches available' },
],
},
},
}).catch(() => {});
console.log('Seed completed! admin/Admin@123 agent1/Agent@123 player1/Player@123');
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,400 @@
import {
Controller,
Get,
Post,
Put,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard, AdminGuard } from '../auth/guards';
import { ContentService } from '../content/content.service';
import { CurrentUser } from '../common/decorators';
import { jsonResponse } from '../common/filters';
import { UsersService } from '../users/users.service';
import { AgentsService } from '../agents/agents.service';
import { WalletService } from '../wallet/wallet.service';
import { MatchesService } from '../matches/matches.service';
import { MarketsService } from '../markets/markets.service';
import { SettlementService } from '../settlement/settlement.service';
import { CashbackService } from '../cashback/cashback.service';
import { I18nService } from '../i18n/i18n.service';
import { AuditService } from '../audit/audit.service';
import { BetsService } from '../bets/bets.service';
import { PrismaService } from '../prisma/prisma.service';
import { IsString, IsNumber, IsOptional, IsArray, IsBoolean, MinLength } from 'class-validator';
class CreateUserDto {
@IsString()
username!: string;
@IsString()
@MinLength(8)
password!: string;
@IsOptional()
@IsString()
parentId?: string;
@IsOptional()
@IsNumber()
creditLimit?: number;
}
class DepositDto {
@IsNumber()
amount!: number;
@IsString()
requestId!: string;
@IsOptional()
@IsString()
remark?: string;
}
class CreateMatchDto {
@IsString()
leagueId!: string;
@IsString()
homeTeamId!: string;
@IsString()
awayTeamId!: string;
@IsString()
startTime!: string;
@IsOptional()
@IsBoolean()
isHot?: boolean;
}
class ScoreDto {
@IsNumber()
htHome!: number;
@IsNumber()
htAway!: number;
@IsNumber()
ftHome!: number;
@IsNumber()
ftAway!: number;
}
class MarketTemplatesDto {
@IsArray()
marketTypes!: string[];
}
class UpdateOddsDto {
@IsNumber()
odds!: number;
}
class CashbackPreviewDto {
@IsString()
periodStart!: string;
@IsString()
periodEnd!: string;
}
@ApiTags('Admin')
@Controller('admin')
@UseGuards(JwtAuthGuard, AdminGuard)
@ApiBearerAuth()
export class AdminController {
constructor(
private users: UsersService,
private agents: AgentsService,
private wallet: WalletService,
private matches: MatchesService,
private markets: MarketsService,
private settlement: SettlementService,
private cashback: CashbackService,
private content: ContentService,
private i18n: I18nService,
private audit: AuditService,
private bets: BetsService,
private prisma: PrismaService,
) {}
@Get('dashboard')
async dashboard() {
const today = new Date();
today.setHours(0, 0, 0, 0);
const [todayBets, pendingMatches, totalPlayers] = await Promise.all([
this.prisma.bet.aggregate({
where: { placedAt: { gte: today } },
_sum: { stake: true, actualReturn: true },
_count: true,
}),
this.prisma.match.count({ where: { status: 'PENDING_SETTLEMENT' } }),
this.prisma.user.count({ where: { userType: 'PLAYER' } }),
]);
return jsonResponse({
todayBetCount: todayBets._count,
todayStake: todayBets._sum.stake,
todayPayout: todayBets._sum.actualReturn,
pendingSettlement: pendingMatches,
totalPlayers,
});
}
@Get('users')
async listUsers(@Query('page') page?: string) {
const result = await this.users.listPlayers(page ? parseInt(page) : 1);
return jsonResponse(result);
}
@Post('users')
async createPlayer(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateUserDto) {
const user = await this.agents.createPlayer(operatorId, {
username: dto.username,
password: dto.password,
parentId: dto.parentId ? BigInt(dto.parentId) : operatorId,
});
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'CREATE_PLAYER',
module: 'USERS',
targetId: user.id.toString(),
});
return jsonResponse(user);
}
@Get('agents')
async listAgents() {
const agents = await this.prisma.agentProfile.findMany({
include: { user: true },
});
return jsonResponse(agents);
}
@Post('agents')
async createAgent(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateUserDto) {
const user = await this.agents.createAgent(operatorId, {
username: dto.username,
password: dto.password,
level: 1,
creditLimit: dto.creditLimit,
});
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'CREATE_AGENT',
module: 'AGENTS',
targetId: user.id.toString(),
});
return jsonResponse(user);
}
@Post('agents/:id/credit')
async adjustCredit(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: DepositDto,
) {
const result = await this.agents.adjustCredit(
BigInt(id),
dto.amount,
operatorId,
dto.requestId,
dto.remark,
);
return jsonResponse(result);
}
@Post('wallet/deposit')
async deposit(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
const result = await this.wallet.deposit(
BigInt(dto.userId),
dto.amount,
operatorId,
dto.remark,
dto.requestId,
);
return jsonResponse(result);
}
@Post('wallet/withdraw')
async withdraw(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
const result = await this.wallet.withdraw(
BigInt(dto.userId),
dto.amount,
operatorId,
dto.remark,
dto.requestId,
);
return jsonResponse(result);
}
@Get('wallet/transactions')
async walletTransactions(@Query('userId') userId: string, @Query('page') page?: string) {
const result = await this.wallet.getTransactions(BigInt(userId), page ? parseInt(page) : 1);
return jsonResponse(result);
}
@Post('leagues')
async createLeague(@Body() dto: { code: string; translations: Record<string, string> }) {
const league = await this.matches.createLeague(dto.code, dto.translations);
return jsonResponse(league);
}
@Post('teams')
async createTeam(@Body() dto: { code: string; translations: Record<string, string> }) {
const team = await this.matches.createTeam(dto.code, dto.translations);
return jsonResponse(team);
}
@Get('matches')
async listMatches() {
const matches = await this.prisma.match.findMany({
include: { markets: { include: { selections: true } } },
orderBy: { startTime: 'desc' },
});
return jsonResponse(matches);
}
@Post('matches')
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateMatchDto) {
const match = await this.matches.createMatch({
leagueId: BigInt(dto.leagueId),
homeTeamId: BigInt(dto.homeTeamId),
awayTeamId: BigInt(dto.awayTeamId),
startTime: new Date(dto.startTime),
isHot: dto.isHot,
createdBy: operatorId,
});
return jsonResponse(match);
}
@Post('matches/:id/publish')
async publishMatch(@Param('id') id: string) {
const match = await this.matches.publishMatch(BigInt(id));
return jsonResponse(match);
}
@Post('matches/:id/close')
async closeMatch(@Param('id') id: string) {
const match = await this.matches.closeMatch(BigInt(id));
return jsonResponse(match);
}
@Post('matches/:id/cancel')
async cancelMatch(@Param('id') id: string) {
await this.matches.cancelMatch(BigInt(id));
const voided = await this.settlement.voidMatchBets(BigInt(id));
return jsonResponse(voided);
}
@Post('matches/:id/markets/templates')
async generateTemplates(@Param('id') id: string, @Body() dto: MarketTemplatesDto) {
const markets = await this.markets.generateTemplates(BigInt(id), dto.marketTypes);
return jsonResponse(markets);
}
@Put('selections/:id/odds')
async updateOdds(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: UpdateOddsDto,
) {
const selection = await this.markets.updateOdds(BigInt(id), dto.odds, operatorId);
return jsonResponse(selection);
}
@Post('matches/:id/settlement/score')
async recordScore(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: ScoreDto,
) {
const result = await this.settlement.recordScore(
BigInt(id),
dto.htHome,
dto.htAway,
dto.ftHome,
dto.ftAway,
operatorId,
);
return jsonResponse(result);
}
@Post('matches/:id/settlement/preview')
async settlementPreview(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) {
const preview = await this.settlement.previewSettlement(BigInt(id), operatorId);
return jsonResponse(preview);
}
@Post('settlement/:batchId/confirm')
async confirmSettlement(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
const result = await this.settlement.confirmSettlement(BigInt(batchId), operatorId);
return jsonResponse(result);
}
@Get('bets')
async listBets(@Query('status') status?: string, @Query('page') page?: string) {
const skip = ((page ? parseInt(page) : 1) - 1) * 20;
const where = status ? { status } : {};
const [items, total] = await Promise.all([
this.prisma.bet.findMany({
where,
include: { selections: true, user: true },
orderBy: { placedAt: 'desc' },
skip,
take: 20,
}),
this.prisma.bet.count({ where }),
]);
return jsonResponse({ items, total });
}
@Post('cashbacks/preview')
async cashbackPreview(@Body() dto: CashbackPreviewDto) {
const preview = await this.cashback.previewBatch(
new Date(dto.periodStart),
new Date(dto.periodEnd),
);
return jsonResponse(preview);
}
@Post('cashbacks/:batchId/confirm')
async cashbackConfirm(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
const result = await this.cashback.confirmBatch(BigInt(batchId), operatorId);
return jsonResponse(result);
}
@Get('contents')
async listContents(@Query('type') type?: string) {
const items = await this.content.listAll(type);
return jsonResponse(items);
}
@Post('contents')
async createContent(@Body() dto: Parameters<ContentService['create']>[0]) {
const item = await this.content.create(dto);
return jsonResponse(item);
}
@Get('i18n/messages')
async getMessages(@Query('locale') locale = 'en-US') {
const messages = await this.i18n.getMessages(locale);
return jsonResponse(messages);
}
@Get('audit-logs')
async auditLogs(@Query('page') page?: string, @Query('module') module?: string) {
const result = await this.audit.list(page ? parseInt(page) : 1, 50, module);
return jsonResponse(result);
}
}

View File

@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { UsersModule } from '../users/users.module';
import { AgentsModule } from '../agents/agents.module';
import { WalletModule } from '../wallet/wallet.module';
import { MatchesModule } from '../matches/matches.module';
import { MarketsModule } from '../markets/markets.module';
import { SettlementModule } from '../settlement/settlement.module';
import { CashbackModule } from '../cashback/cashback.module';
import { ContentModule } from '../content/content.module';
import { I18nModule } from '../i18n/i18n.module';
import { BetsModule } from '../bets/bets.module';
@Module({
imports: [
UsersModule,
AgentsModule,
WalletModule,
MatchesModule,
MarketsModule,
SettlementModule,
CashbackModule,
ContentModule,
I18nModule,
BetsModule,
],
controllers: [AdminController],
})
export class AdminModule {}

View File

@@ -0,0 +1,189 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard, AgentGuard } from '../auth/guards';
import { CurrentUser } from '../common/decorators';
import { jsonResponse } from '../common/filters';
import { AgentsService } from '../agents/agents.service';
import { WalletService } from '../wallet/wallet.service';
import { BetsService } from '../bets/bets.service';
import { PrismaService } from '../prisma/prisma.service';
import { IsString, IsNumber, MinLength, IsOptional } from 'class-validator';
class CreatePlayerDto {
@IsString()
username!: string;
@IsString()
@MinLength(8)
password!: string;
}
class CreateSubAgentDto extends CreatePlayerDto {
@IsOptional()
@IsNumber()
creditLimit?: number;
}
class TransferDto {
@IsNumber()
amount!: number;
@IsString()
requestId!: string;
}
class CreditDto extends TransferDto {
@IsOptional()
@IsString()
remark?: string;
}
@ApiTags('Agent Portal')
@Controller('agent')
@UseGuards(JwtAuthGuard, AgentGuard)
@ApiBearerAuth()
export class AgentPortalController {
constructor(
private agents: AgentsService,
private wallet: WalletService,
private bets: BetsService,
private prisma: PrismaService,
) {}
@Get('profile')
async profile(@CurrentUser('id') agentId: bigint) {
const profile = await this.agents.getProfile(agentId);
return jsonResponse(profile);
}
@Get('players')
async listPlayers(@CurrentUser('id') agentId: bigint) {
const players = await this.agents.getDirectPlayers(agentId);
return jsonResponse(players);
}
@Post('players')
async createPlayer(@CurrentUser('id') agentId: bigint, @Body() dto: CreatePlayerDto) {
const user = await this.agents.createPlayer(agentId, {
username: dto.username,
password: dto.password,
parentId: agentId,
});
return jsonResponse(user);
}
@Get('agents')
async listSubAgents(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number) {
if (level !== 1) {
return jsonResponse([]);
}
const agents = await this.agents.getChildAgents(agentId);
return jsonResponse(agents);
}
@Post('agents')
async createSubAgent(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number, @Body() dto: CreateSubAgentDto) {
if (level !== 1) {
return jsonResponse(null, 'Only level 1 agents can create sub-agents');
}
const user = await this.agents.createAgent(agentId, {
username: dto.username,
password: dto.password,
level: 2,
parentAgentId: agentId,
creditLimit: dto.creditLimit,
});
return jsonResponse(user);
}
@Post('players/:id/deposit')
async depositToPlayer(
@CurrentUser('id') agentId: bigint,
@Param('id') playerId: string,
@Body() dto: TransferDto,
) {
const result = await this.agents.depositToPlayer(agentId, BigInt(playerId), dto.amount, dto.requestId);
return jsonResponse(result);
}
@Post('players/:id/withdraw')
async withdrawFromPlayer(
@CurrentUser('id') agentId: bigint,
@Param('id') playerId: string,
@Body() dto: TransferDto,
) {
const result = await this.agents.withdrawFromPlayer(agentId, BigInt(playerId), dto.amount, dto.requestId);
return jsonResponse(result);
}
@Post('agents/:id/credit')
async allocateCredit(
@CurrentUser('id') agentId: bigint,
@Param('id') subAgentId: string,
@Body() dto: CreditDto,
) {
const subAgent = await this.prisma.agentProfile.findUnique({
where: { userId: BigInt(subAgentId) },
});
if (!subAgent || subAgent.parentAgentId !== agentId) {
return jsonResponse(null, 'Not your sub-agent');
}
const result = await this.agents.adjustCredit(
BigInt(subAgentId),
dto.amount,
agentId,
dto.requestId,
dto.remark,
);
return jsonResponse(result);
}
@Get('bets')
async listBets(@CurrentUser('id') agentId: bigint, @Query('page') page?: string) {
const skip = ((page ? parseInt(page) : 1) - 1) * 20;
const descendants = await this.prisma.agentClosure.findMany({
where: { ancestorId: agentId },
});
const agentIds = descendants.map((d) => d.descendantId);
const [items, total] = await Promise.all([
this.prisma.bet.findMany({
where: { agentId: { in: agentIds } },
include: { selections: true, user: true },
orderBy: { placedAt: 'desc' },
skip,
take: 20,
}),
this.prisma.bet.count({ where: { agentId: { in: agentIds } } }),
]);
return jsonResponse({ items, total });
}
@Get('reports/summary')
async reportSummary(@CurrentUser('id') agentId: bigint) {
const summary = await this.agents.getReportSummary(agentId);
return jsonResponse(summary);
}
@Get('wallet-transactions')
async walletTransactions(@CurrentUser('id') agentId: bigint, @Query('playerId') playerId?: string) {
const players = playerId
? [BigInt(playerId)]
: (await this.agents.getDirectPlayers(agentId)).map((p) => p.id);
const transactions = await this.prisma.walletTransaction.findMany({
where: { userId: { in: players } },
orderBy: { createdAt: 'desc' },
take: 50,
});
return jsonResponse(transactions);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AgentPortalController } from './agent-portal.controller';
import { AgentsModule } from '../agents/agents.module';
import { WalletModule } from '../wallet/wallet.module';
import { BetsModule } from '../bets/bets.module';
@Module({
imports: [AgentsModule, WalletModule, BetsModule],
controllers: [AgentPortalController],
})
export class AgentPortalModule {}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AgentsService } from './agents.service';
import { WalletModule } from '../wallet/wallet.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [WalletModule, AuthModule],
providers: [AgentsService],
exports: [AgentsService],
})
export class AgentsModule {}

View File

@@ -0,0 +1,296 @@
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { WalletService } from '../wallet/wallet.service';
import { AuthService } from '../auth/auth.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBatchNo } from '../common/decorators';
@Injectable()
export class AgentsService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private auth: AuthService,
) {}
async getProfile(agentId: bigint) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
});
if (!profile) throw new BadRequestException('Agent profile not found');
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
return { ...profile, availableCredit: available };
}
async recalculateUsedCredit(agentId: bigint) {
const directPlayers = await this.prisma.user.findMany({
where: { parentId: agentId, userType: 'PLAYER' },
include: { wallet: true },
});
let directLiability = new Decimal(0);
for (const p of directPlayers) {
if (p.wallet) {
directLiability = directLiability
.add(p.wallet.availableBalance)
.add(p.wallet.frozenBalance);
}
}
const childAgents = await this.prisma.agentProfile.findMany({
where: { parentAgentId: agentId },
});
let childExposure = new Decimal(0);
for (const child of childAgents) {
const exposure = Decimal.max(child.creditLimit, child.usedCredit);
childExposure = childExposure.add(exposure);
}
const usedCredit = directLiability.add(childExposure);
await this.prisma.agentProfile.update({
where: { userId: agentId },
data: {
usedCredit,
directPlayerLiability: directLiability,
childAgentExposure: childExposure,
},
});
return usedCredit;
}
async adjustCredit(
agentId: bigint,
amount: Decimal | number,
operatorId: bigint,
requestId: string,
remark?: string,
) {
const amt = new Decimal(amount);
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
});
if (!profile) throw new BadRequestException('Agent not found');
const creditBefore = profile.creditLimit;
const creditAfter = creditBefore.add(amt);
if (creditAfter.lt(0)) throw new BadRequestException('Credit limit cannot be negative');
await this.prisma.$transaction(async (tx) => {
await tx.agentProfile.update({
where: { userId: agentId },
data: { creditLimit: creditAfter },
});
await tx.agentCreditTransaction.create({
data: {
agentId,
transactionType: amt.gte(0) ? 'CREDIT_INCREASE' : 'CREDIT_DECREASE',
amount: amt,
creditBefore,
creditAfter,
operatorId,
requestId,
remark,
},
});
});
if (profile.parentAgentId) {
await this.recalculateUsedCredit(profile.parentAgentId);
}
return { creditAfter };
}
async depositToPlayer(
agentId: bigint,
playerId: bigint,
amount: number,
requestId: string,
) {
const player = await this.prisma.user.findUnique({ where: { id: playerId } });
if (!player || player.parentId !== agentId) {
throw new ForbiddenException('Can only deposit to direct players');
}
const profile = await this.getProfile(agentId);
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
const amt = new Decimal(amount);
if (available.lt(amt)) {
throw new BadRequestException('Insufficient agent credit');
}
await this.wallet.deposit(playerId, amt, agentId, 'Agent deposit', requestId);
await this.recalculateUsedCredit(agentId);
return { success: true };
}
async withdrawFromPlayer(
agentId: bigint,
playerId: bigint,
amount: number,
requestId: string,
) {
const player = await this.prisma.user.findUnique({ where: { id: playerId } });
if (!player || player.parentId !== agentId) {
throw new ForbiddenException('Can only withdraw from direct players');
}
await this.wallet.withdraw(playerId, amount, agentId, 'Agent withdraw', requestId);
await this.recalculateUsedCredit(agentId);
return { success: true };
}
async createAgent(
operatorId: bigint,
data: {
username: string;
password: string;
level: number;
parentAgentId?: bigint;
creditLimit?: number;
},
) {
if (data.level === 2 && !data.parentAgentId) {
throw new BadRequestException('Level 2 agent requires parent');
}
const hash = await this.auth.hashPassword(data.password);
return this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
username: data.username,
userType: 'AGENT',
parentId: data.parentAgentId,
agentLevel: data.level,
},
});
await tx.userAuth.create({
data: { userId: user.id, passwordHash: hash },
});
await tx.agentProfile.create({
data: {
userId: user.id,
level: data.level,
parentAgentId: data.parentAgentId,
creditLimit: data.creditLimit ?? 0,
},
});
// Build closure table
await tx.agentClosure.create({
data: { ancestorId: user.id, descendantId: user.id, depth: 0 },
});
if (data.parentAgentId) {
const ancestors = await tx.agentClosure.findMany({
where: { descendantId: data.parentAgentId },
});
for (const a of ancestors) {
await tx.agentClosure.create({
data: {
ancestorId: a.ancestorId,
descendantId: user.id,
depth: a.depth + 1,
},
});
}
if (data.parentAgentId) {
await this.recalculateUsedCredit(data.parentAgentId);
}
}
return user;
});
}
async createPlayer(
operatorId: bigint,
data: { username: string; password: string; parentId: bigint },
) {
const hash = await this.auth.hashPassword(data.password);
return this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
username: data.username,
userType: 'PLAYER',
parentId: data.parentId,
},
});
await tx.userAuth.create({
data: { userId: user.id, passwordHash: hash },
});
await tx.wallet.create({
data: { userId: user.id },
});
await tx.userPreference.create({
data: { userId: user.id },
});
const parent = await tx.user.findUnique({ where: { id: data.parentId } });
if (parent?.userType === 'AGENT') {
await this.recalculateUsedCredit(data.parentId);
}
return user;
});
}
async getDirectPlayers(agentId: bigint) {
return this.prisma.user.findMany({
where: { parentId: agentId, userType: 'PLAYER' },
include: { wallet: true },
});
}
async getChildAgents(agentId: bigint) {
return this.prisma.agentProfile.findMany({
where: { parentAgentId: agentId },
include: { user: true },
});
}
async getReportSummary(agentId: bigint) {
const profile = await this.getProfile(agentId);
const players = await this.getDirectPlayers(agentId);
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayBets = await this.prisma.bet.aggregate({
where: {
agentId,
placedAt: { gte: today },
},
_sum: { stake: true, actualReturn: true },
_count: true,
});
return {
profile,
directPlayerCount: players.length,
directPlayerTotalBalance: players.reduce(
(sum, p) =>
sum +
Number(p.wallet?.availableBalance ?? 0) +
Number(p.wallet?.frozenBalance ?? 0),
0,
),
todayBetCount: todayBets._count,
todayStake: todayBets._sum.stake,
todayReturn: todayBets._sum.actualReturn,
};
}
}

View File

@@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './auth/guards';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { AgentsModule } from './agents/agents.module';
import { WalletModule } from './wallet/wallet.module';
import { MatchesModule } from './matches/matches.module';
import { MarketsModule } from './markets/markets.module';
import { BetsModule } from './bets/bets.module';
import { SettlementModule } from './settlement/settlement.module';
import { CashbackModule } from './cashback/cashback.module';
import { ContentModule } from './content/content.module';
import { I18nModule } from './i18n/i18n.module';
import { AuditModule } from './audit/audit.module';
import { AdminModule } from './admin/admin.module';
import { PlayerModule } from './player/player.module';
import { AgentPortalModule } from './agent-portal/agent-portal.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
PrismaModule,
AuthModule,
UsersModule,
AgentsModule,
WalletModule,
MatchesModule,
MarketsModule,
BetsModule,
SettlementModule,
CashbackModule,
ContentModule,
I18nModule,
AuditModule,
AdminModule,
PlayerModule,
AgentPortalModule,
],
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }],
})
export class AppModule {}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { AuditService } from './audit.service';
@Global()
@Module({
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AuditService {
constructor(private prisma: PrismaService) {}
async log(data: {
operatorId?: bigint;
operatorType: string;
action: string;
module: string;
targetType?: string;
targetId?: string;
beforeData?: unknown;
afterData?: unknown;
ipAddress?: string;
}) {
return this.prisma.auditLog.create({
data: {
operatorId: data.operatorId,
operatorType: data.operatorType,
action: data.action,
module: data.module,
targetType: data.targetType,
targetId: data.targetId,
beforeData: data.beforeData ? JSON.stringify(data.beforeData) : null,
afterData: data.afterData ? JSON.stringify(data.afterData) : null,
ipAddress: data.ipAddress,
},
});
}
async list(page = 1, pageSize = 50, module?: string) {
const skip = (page - 1) * pageSize;
const where = module ? { module } : {};
const [items, total] = await Promise.all([
this.prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.auditLog.count({ where }),
]);
return { items, total, page, pageSize };
}
}

View File

@@ -0,0 +1,45 @@
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto, ChangePasswordDto } from './auth.dto';
import { Public, CurrentUser } from '../common/decorators';
import { JwtAuthGuard } from './guards';
import { jsonResponse } from '../common/filters';
@ApiTags('Auth')
@Controller()
export class AuthController {
constructor(private auth: AuthService) {}
@Public()
@Post('player/auth/login')
async playerLogin(@Body() dto: LoginDto) {
const result = await this.auth.login(dto.username, dto.password, 'player');
return jsonResponse(result);
}
@Public()
@Post('admin/auth/login')
async adminLogin(@Body() dto: LoginDto) {
const result = await this.auth.login(dto.username, dto.password, 'admin');
return jsonResponse(result);
}
@Public()
@Post('agent/auth/login')
async agentLogin(@Body() dto: LoginDto) {
const result = await this.auth.login(dto.username, dto.password, 'agent');
return jsonResponse(result);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Post('player/auth/change-password')
async playerChangePassword(
@CurrentUser('id') userId: bigint,
@Body() dto: ChangePasswordDto,
) {
await this.auth.changePassword(userId, dto.oldPassword, dto.newPassword);
return jsonResponse(null, 'Password changed');
}
}

View File

@@ -0,0 +1,24 @@
import { IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty()
@IsString()
username!: string;
@ApiProperty()
@IsString()
@MinLength(1)
password!: string;
}
export class ChangePasswordDto {
@ApiProperty()
@IsString()
oldPassword!: string;
@ApiProperty()
@IsString()
@MinLength(8)
newPassword!: string;
}

View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { AuthController } from './auth.controller';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET', 'dev-secret'),
signOptions: { expiresIn: config.get('JWT_PLAYER_EXPIRES', '24h') },
}),
inject: [ConfigService],
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,111 @@
import { Injectable, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../prisma/prisma.service';
const MAX_LOGIN_FAILS = 5;
const LOCK_DURATION_MS = 15 * 60 * 1000;
export interface JwtPayload {
sub: string;
username: string;
userType: string;
role?: string;
}
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwt: JwtService,
private config: ConfigService,
) {}
async login(username: string, password: string, portal: 'player' | 'admin' | 'agent') {
const user = await this.prisma.user.findUnique({
where: { username },
include: { auth: true, adminRole: { include: { role: true } } },
});
if (!user || !user.auth) {
throw new UnauthorizedException('Invalid credentials');
}
const expectedType = portal === 'admin' ? 'ADMIN' : portal === 'agent' ? 'AGENT' : 'PLAYER';
if (user.userType !== expectedType) {
throw new UnauthorizedException('Invalid credentials');
}
if (user.status === 'DISABLED') {
throw new ForbiddenException('Account disabled');
}
if (user.auth.lockedUntil && user.auth.lockedUntil > new Date()) {
throw new ForbiddenException('Account locked, try again later');
}
const valid = await bcrypt.compare(password, user.auth.passwordHash);
if (!valid) {
const failCount = user.auth.loginFailCount + 1;
const lockedUntil =
failCount >= MAX_LOGIN_FAILS ? new Date(Date.now() + LOCK_DURATION_MS) : null;
await this.prisma.userAuth.update({
where: { userId: user.id },
data: { loginFailCount: failCount, lockedUntil },
});
throw new UnauthorizedException('Invalid credentials');
}
await this.prisma.userAuth.update({
where: { userId: user.id },
data: { loginFailCount: 0, lockedUntil: null, lastLoginAt: new Date() },
});
const expiresIn =
portal === 'admin'
? this.config.get('JWT_ADMIN_EXPIRES', '2h')
: portal === 'agent'
? this.config.get('JWT_AGENT_EXPIRES', '8h')
: this.config.get('JWT_PLAYER_EXPIRES', '24h');
const payload: JwtPayload = {
sub: user.id.toString(),
username: user.username,
userType: user.userType,
role: user.adminRole?.role?.code,
};
const token = this.jwt.sign(payload, { expiresIn });
return {
token,
user: {
id: user.id.toString(),
username: user.username,
userType: user.userType,
locale: user.locale,
role: user.adminRole?.role?.code,
},
};
}
async changePassword(userId: bigint, oldPassword: string, newPassword: string) {
const auth = await this.prisma.userAuth.findUnique({ where: { userId } });
if (!auth) throw new UnauthorizedException('User not found');
const valid = await bcrypt.compare(oldPassword, auth.passwordHash);
if (!valid) throw new UnauthorizedException('Invalid old password');
const hash = await bcrypt.hash(newPassword, 10);
await this.prisma.userAuth.update({
where: { userId },
data: { passwordHash: hash },
});
return { success: true };
}
async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
}

View File

@@ -0,0 +1,87 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY, PERMISSIONS_KEY } from '../common/decorators';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
handleRequest<TUser = unknown>(err: Error | null, user: TUser): TUser {
if (err || !user) throw err || new UnauthorizedException();
return user;
}
}
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required?.length) return true;
const { user } = context.switchToHttp().getRequest();
const userPerms: string[] = user?.permissions ?? [];
if (user?.role === 'SUPER_ADMIN') return true;
const hasAll = required.every((p) => userPerms.includes(p));
if (!hasAll) throw new ForbiddenException('Insufficient permissions');
return true;
}
}
export function UserTypeGuard(...types: string[]) {
@Injectable()
class MixedUserTypeGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
if (!types.includes(user?.userType)) {
throw new ForbiddenException('Access denied for this portal');
}
return true;
}
}
return MixedUserTypeGuard;
}
@Injectable()
export class PlayerGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
if (user?.userType !== 'PLAYER') throw new ForbiddenException('Player access only');
return true;
}
}
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
if (user?.userType !== 'ADMIN') throw new ForbiddenException('Admin access only');
return true;
}
}
@Injectable()
export class AgentGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
if (user?.userType !== 'AGENT') throw new ForbiddenException('Agent access only');
return true;
}
}

View File

@@ -0,0 +1,54 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { JwtPayload } from './auth.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
config: ConfigService,
private prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get('JWT_SECRET', 'dev-secret'),
});
}
async validate(payload: JwtPayload) {
const user = await this.prisma.user.findUnique({
where: { id: BigInt(payload.sub) },
include: {
adminRole: {
include: {
role: {
include: {
permissions: {
include: { permission: true },
},
},
},
},
},
},
});
if (!user || user.status !== 'ACTIVE') {
throw new UnauthorizedException();
}
const permissions =
user.adminRole?.role?.permissions?.map((rp) => rp.permission.code) ?? [];
return {
id: user.id,
username: user.username,
userType: user.userType,
parentId: user.parentId,
agentLevel: user.agentLevel,
locale: user.locale,
role: payload.role,
permissions,
};
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BetsService } from './bets.service';
import { WalletModule } from '../wallet/wallet.module';
@Module({
imports: [WalletModule],
providers: [BetsService],
exports: [BetsService],
})
export class BetsModule {}

View File

@@ -0,0 +1,211 @@
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { WalletService } from '../wallet/wallet.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBetNo } from '../common/decorators';
import { isQuarterHandicapOrTotal } from '../settlement/settlement-calculator';
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
interface BetSelectionInput {
selectionId: bigint;
oddsVersion: bigint;
stake?: number;
}
@Injectable()
export class BetsService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
) {}
private async validateSelection(selectionId: bigint, oddsVersion: bigint) {
const selection = await this.prisma.marketSelection.findUnique({
where: { id: selectionId },
include: { market: { include: { match: true } } },
});
if (!selection) throw new BadRequestException('Selection not found');
if (selection.status !== 'OPEN') throw new BadRequestException('Selection closed');
if (selection.market.status !== 'OPEN') throw new BadRequestException('Market closed');
if (selection.market.match.status !== 'PUBLISHED') {
throw new BadRequestException('Match not available for betting');
}
if (selection.oddsVersion !== oddsVersion) {
throw new BadRequestException('Odds changed, please confirm again');
}
return selection;
}
async placeSingleBet(
userId: bigint,
agentId: bigint | null,
selectionId: bigint,
oddsVersion: bigint,
stake: number,
requestId: string,
) {
if (stake <= 0) throw new BadRequestException('Invalid stake');
const existing = await this.prisma.bet.findUnique({
where: { userId_requestId: { userId, requestId } },
});
if (existing) return existing;
const selection = await this.validateSelection(selectionId, oddsVersion);
const odds = new Decimal(selection.odds.toString());
const stakeDec = new Decimal(stake);
const potentialReturn = stakeDec.mul(odds);
const betNo = generateBetNo();
const bet = await this.prisma.$transaction(async (tx) => {
const created = await tx.bet.create({
data: {
betNo,
userId,
agentId,
betType: 'SINGLE',
stake: stakeDec,
totalOdds: odds,
potentialReturn,
requestId,
selections: {
create: {
matchId: selection.market.matchId,
marketId: selection.marketId,
selectionId: selection.id,
marketType: selection.market.marketType,
period: selection.market.period,
selectionNameSnapshot: selection.selectionName,
handicapLine: selection.market.lineValue,
totalLine: selection.market.lineValue,
odds,
oddsVersion,
},
},
},
include: { selections: true },
});
await this.wallet.freezeForBet(userId, stakeDec, betNo);
return created;
});
return bet;
}
async placeParlayBet(
userId: bigint,
agentId: bigint | null,
legs: BetSelectionInput[],
stake: number,
requestId: string,
) {
if (stake <= 0) throw new BadRequestException('Invalid stake');
if (legs.length < PARLAY_MIN_LEGS || legs.length > PARLAY_MAX_LEGS) {
throw new BadRequestException(`Parlay must have ${PARLAY_MIN_LEGS}-${PARLAY_MAX_LEGS} legs`);
}
const existing = await this.prisma.bet.findUnique({
where: { userId_requestId: { userId, requestId } },
});
if (existing) return existing;
const selections: Awaited<ReturnType<typeof this.validateSelection>>[] = [];
const matchIds = new Set<string>();
for (const leg of legs) {
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion);
if (sel.market.marketType === 'OUTRIGHT_WINNER') {
throw new BadRequestException('Outright cannot be in parlay');
}
const line = sel.market.lineValue ? Number(sel.market.lineValue) : null;
if (
['FT_HANDICAP', 'HT_HANDICAP', 'FT_OVER_UNDER', 'HT_OVER_UNDER'].includes(
sel.market.marketType,
) &&
isQuarterHandicapOrTotal(line)
) {
throw new BadRequestException('Quarter line markets cannot be in parlay');
}
const matchKey = sel.market.matchId.toString();
if (matchIds.has(matchKey)) {
throw new BadRequestException('Same match cannot be in parlay');
}
matchIds.add(matchKey);
selections.push(sel);
}
let totalOdds = new Decimal(1);
for (const sel of selections) {
totalOdds = totalOdds.mul(sel.odds.toString());
}
const stakeDec = new Decimal(stake);
const potentialReturn = stakeDec.mul(totalOdds);
const betNo = generateBetNo();
const bet = await this.prisma.$transaction(async (tx) => {
const created = await tx.bet.create({
data: {
betNo,
userId,
agentId,
betType: 'PARLAY',
stake: stakeDec,
totalOdds,
potentialReturn,
requestId,
selections: {
create: selections.map((sel, i) => ({
matchId: sel.market.matchId,
marketId: sel.marketId,
selectionId: sel.id,
marketType: sel.market.marketType,
period: sel.market.period,
selectionNameSnapshot: sel.selectionName,
handicapLine: sel.market.lineValue,
totalLine: sel.market.lineValue,
odds: sel.odds,
oddsVersion: legs[i].oddsVersion,
sortOrder: i,
})),
},
},
include: { selections: true },
});
await this.wallet.freezeForBet(userId, stakeDec, betNo);
return created;
});
return bet;
}
async getUserBets(userId: bigint, status?: string, page = 1, pageSize = 20) {
const where = { userId, ...(status ? { status } : {}) };
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.bet.findMany({
where,
include: { selections: true },
orderBy: { placedAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.bet.count({ where }),
]);
return { items, total, page, pageSize };
}
async getBetByNo(betNo: string, userId?: bigint) {
return this.prisma.bet.findFirst({
where: { betNo, ...(userId ? { userId } : {}) },
include: { selections: true },
});
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CashbackService } from './cashback.service';
import { WalletModule } from '../wallet/wallet.module';
@Module({
imports: [WalletModule],
providers: [CashbackService],
exports: [CashbackService],
})
export class CashbackModule {}

View File

@@ -0,0 +1,108 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { WalletService } from '../wallet/wallet.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBatchNo } from '../common/decorators';
@Injectable()
export class CashbackService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
) {}
async previewBatch(periodStart: Date, periodEnd: Date) {
const settledBets = await this.prisma.bet.findMany({
where: {
status: { in: ['WON', 'LOST', 'SETTLED'] },
settledAt: { gte: periodStart, lte: periodEnd },
},
include: { user: { include: { agentProfile: true } } },
});
const playerStakes = new Map<string, { userId: bigint; stake: Decimal; rate: Decimal }>();
for (const bet of settledBets) {
if (bet.status === 'PUSH' || bet.status === 'VOID') continue;
const key = bet.userId.toString();
const existing = playerStakes.get(key) ?? {
userId: bet.userId,
stake: new Decimal(0),
rate: new Decimal(0.01),
};
existing.stake = existing.stake.add(bet.stake);
playerStakes.set(key, existing);
}
const items = Array.from(playerStakes.values()).map((p) => ({
userId: p.userId,
effectiveStake: p.stake,
rate: p.rate,
amount: p.stake.mul(p.rate),
}));
const totalAmount = items.reduce((s, i) => s.add(i.amount), new Decimal(0));
const batch = await this.prisma.cashbackBatch.create({
data: {
batchNo: generateBatchNo('CB'),
periodStart,
periodEnd,
status: 'PREVIEW',
totalAmount,
playerCount: items.length,
},
});
for (const item of items) {
await this.prisma.cashbackItem.create({
data: {
batchId: batch.id,
userId: item.userId,
effectiveStake: item.effectiveStake,
rate: item.rate,
amount: item.amount,
},
});
}
return { batch, items, totalAmount };
}
async confirmBatch(batchId: bigint, operatorId: bigint) {
const batch = await this.prisma.cashbackBatch.findUnique({
where: { id: batchId },
include: { items: true },
});
if (!batch) throw new BadRequestException('Batch not found');
if (batch.status !== 'PREVIEW') throw new BadRequestException('Already confirmed');
for (const item of batch.items) {
if (item.amount.gt(0)) {
await this.wallet.deposit(
item.userId,
item.amount,
operatorId,
`Cashback batch ${batch.batchNo}`,
batch.batchNo,
);
}
}
await this.prisma.cashbackBatch.update({
where: { id: batchId },
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
});
return { success: true };
}
async getUserCashbacks(userId: bigint) {
return this.prisma.cashbackItem.findMany({
where: { userId },
include: { batch: true },
orderBy: { createdAt: 'desc' },
});
}
}

View File

@@ -0,0 +1,48 @@
import { createParamDecorator, ExecutionContext, SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);
export function generateBetNo(): string {
const ts = Date.now().toString(36).toUpperCase();
const rand = Math.random().toString(36).substring(2, 8).toUpperCase();
return `BET${ts}${rand}`;
}
export function generateTransactionId(): string {
const ts = Date.now().toString(36).toUpperCase();
const rand = Math.random().toString(36).substring(2, 8).toUpperCase();
return `TXN${ts}${rand}`;
}
export function generateBatchNo(prefix: string): string {
const ts = Date.now().toString(36).toUpperCase();
return `${prefix}${ts}`;
}
export function serializeBigInt(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj;
if (typeof obj === 'bigint') return obj.toString();
if (obj instanceof Date) return obj.toISOString();
if (Array.isArray(obj)) return obj.map(serializeBigInt);
if (typeof obj === 'object') {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
result[key] = serializeBigInt(value);
}
return result;
}
return obj;
}

View File

@@ -0,0 +1,42 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { serializeBigInt } from './decorators';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const res = exception.getResponse();
message = typeof res === 'string' ? res : (res as { message?: string }).message || message;
} else if (exception instanceof Error) {
message = exception.message;
}
response.status(status).json({
success: false,
error: message,
data: null,
});
}
}
export function jsonResponse<T>(data: T, message?: string) {
return {
success: true,
data: serializeBigInt(data),
message,
};
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ContentService } from './content.service';
@Module({
providers: [ContentService],
exports: [ContentService],
})
export class ContentModule {}

View File

@@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class ContentService {
constructor(private prisma: PrismaService) {}
async listActive(contentType: string, locale: string) {
const now = new Date();
const items = await this.prisma.content.findMany({
where: {
contentType,
status: 'ACTIVE',
OR: [{ startTime: null }, { startTime: { lte: now } }],
AND: [{ OR: [{ endTime: null }, { endTime: { gte: now } }] }],
},
include: { translations: true },
orderBy: { sortOrder: 'asc' },
});
return items.map((item) => {
const t =
item.translations.find((tr) => tr.locale === locale) ||
item.translations.find((tr) => tr.locale === 'en-US') ||
item.translations[0];
return { ...item, translation: t };
});
}
async create(data: {
contentType: string;
sortOrder?: number;
linkType?: string;
linkTarget?: string;
translations: Array<{ locale: string; title?: string; body?: string; imageUrl?: string }>;
}) {
return this.prisma.content.create({
data: {
contentType: data.contentType,
sortOrder: data.sortOrder ?? 0,
linkType: data.linkType,
linkTarget: data.linkTarget,
status: 'ACTIVE',
translations: {
create: data.translations,
},
},
include: { translations: true },
});
}
async listAll(contentType?: string) {
return this.prisma.content.findMany({
where: contentType ? { contentType } : {},
include: { translations: true },
orderBy: { sortOrder: 'asc' },
});
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { I18nService } from './i18n.service';
@Module({
providers: [I18nService],
exports: [I18nService],
})
export class I18nModule {}

View File

@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DEFAULT_LOCALE } from '@thebet365/shared';
const FALLBACK_ORDER = ['en-US', 'zh-CN', 'ms-MY'];
@Injectable()
export class I18nService {
constructor(private prisma: PrismaService) {}
async getMessages(locale: string) {
const messages = await this.prisma.i18nMessage.findMany({
where: { locale: { in: [locale, ...FALLBACK_ORDER] } },
});
const byKey: Record<string, Record<string, string>> = {};
for (const m of messages) {
if (!byKey[m.msgKey]) byKey[m.msgKey] = {};
byKey[m.msgKey][m.locale] = m.value;
}
const result: Record<string, string> = {};
for (const [key, locales] of Object.entries(byKey)) {
result[key] =
locales[locale] ||
FALLBACK_ORDER.map((l) => locales[l]).find(Boolean) ||
key;
}
return result;
}
async upsertMessage(msgKey: string, locale: string, value: string) {
return this.prisma.i18nMessage.upsert({
where: { msgKey_locale: { msgKey, locale } },
create: { msgKey, locale, value },
update: { value },
});
}
async listMissing() {
const keys = await this.prisma.i18nMessage.groupBy({ by: ['msgKey'] });
const locales = ['zh-CN', 'ms-MY', 'en-US'];
const missing = [];
for (const { msgKey } of keys) {
for (const locale of locales) {
const exists = await this.prisma.i18nMessage.findUnique({
where: { msgKey_locale: { msgKey, locale } },
});
if (!exists) missing.push({ msgKey, locale });
}
}
return missing;
}
}

View File

@@ -0,0 +1,111 @@
import {
settleSelection,
calculatePayout,
isQuarterHandicapOrTotal,
} from './settlement/settlement-calculator';
/**
* Agent credit & wallet integration scenarios (A001-A007)
* These tests validate business rules without DB dependency.
*/
describe('Agent Credit Rules', () => {
it('A001: deposit increases player balance and reduces agent available credit', () => {
const creditLimit = 10000;
const usedCredit = 1000;
const depositAmount = 500;
const newUsed = usedCredit + depositAmount;
expect(creditLimit - newUsed).toBe(8500);
});
it('A002: bet freeze does not change total balance or agent credit', () => {
const available = 1000;
const frozen = 0;
const stake = 100;
const totalBefore = available + frozen;
const totalAfter = (available - stake) + (frozen + stake);
expect(totalAfter).toBe(totalBefore);
});
it('A003: player lose releases agent credit', () => {
const usedBefore = 1000;
const stake = 100;
const usedAfter = usedBefore - stake;
expect(usedAfter).toBe(900);
});
it('A004: player win increases agent credit usage', () => {
const usedBefore = 1000;
const payout = 185;
const stake = 100;
const netGain = payout - stake;
const usedAfter = usedBefore + netGain;
expect(usedAfter).toBe(1085);
});
it('A005: negative credit blocks further deposit', () => {
const creditLimit = 1000;
const usedCredit = 1200;
const available = creditLimit - usedCredit;
expect(available).toBeLessThan(0);
const canDeposit = available > 0;
expect(canDeposit).toBe(false);
});
it('A006: level 1 allocating credit to level 2 reduces available', () => {
const available = 10000;
const allocate = 3000;
expect(available - allocate).toBe(7000);
});
it('A007: non-direct player deposit should be rejected', () => {
const agentId = BigInt(1);
const playerParentId = BigInt(2);
const canDeposit = agentId === playerParentId;
expect(canDeposit).toBe(false);
});
});
describe('Bet Validation Rules (B001-B010)', () => {
it('B003: odds version mismatch should reject', () => {
const submitted = BigInt(1);
const current = BigInt(2);
expect(submitted === current).toBe(false);
});
it('B007: same match in parlay rejected', () => {
const matchIds = ['1', '1', '2'];
const unique = new Set(matchIds);
expect(unique.size !== matchIds.length).toBe(true);
});
it('B008: quarter line in parlay rejected', () => {
expect(isQuarterHandicapOrTotal(-0.25)).toBe(true);
expect(isQuarterHandicapOrTotal(-0.5)).toBe(false);
});
it('B009: more than 5 legs rejected', () => {
const legs = 6;
expect(legs > 5).toBe(true);
});
});
describe('Settlement payout accuracy', () => {
it('half win payout formula', () => {
const payout = calculatePayout(100, 1.85, 'HALF_WIN');
expect(payout.toNumber()).toBe(142.5);
});
it('half lose payout formula', () => {
const payout = calculatePayout(100, 1.85, 'HALF_LOSE');
expect(payout.toNumber()).toBe(50);
});
it('S004: 0-0 odd/even is even', () => {
const result = settleSelection({
marketType: 'FT_ODD_EVEN',
selectionCode: 'EVEN',
score: { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 },
});
expect(result).toBe('WIN');
});
});

33
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,33 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.enableCors({ origin: true, credentials: true });
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
const config = new DocumentBuilder()
.setTitle('TheBet365 API')
.setDescription('足球投注平台 MVP API')
.setVersion('1.0')
.addBearerAuth()
.build();
SwaggerModule.setup('api/docs', app, SwaggerModule.createDocument(app, config));
const port = process.env.PORT || 3000;
await app.listen(port);
console.log(`API running on http://localhost:${port}`);
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
}
bootstrap();

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MarketsService } from './markets.service';
@Module({
providers: [MarketsService],
exports: [MarketsService],
})
export class MarketsModule {}

View File

@@ -0,0 +1,203 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Decimal } from '@prisma/client/runtime/library';
import {
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
} from '../settlement/settlement-calculator';
@Injectable()
export class MarketsService {
constructor(private prisma: PrismaService) {}
async generateTemplates(matchId: bigint, marketTypes: string[]) {
const match = await this.prisma.match.findUnique({ where: { id: matchId } });
if (!match) throw new NotFoundException('Match not found');
const created = [];
for (const marketType of marketTypes) {
const existing = await this.prisma.market.findFirst({
where: { matchId, marketType },
});
if (existing) continue;
const config = this.getMarketConfig(marketType);
const market = await this.prisma.market.create({
data: {
matchId,
marketType,
period: config.period,
lineValue: config.lineValue,
allowSingle: true,
allowParlay: config.allowParlay,
sortOrder: config.sortOrder,
selections: {
create: config.selections.map((s, i) => ({
selectionCode: s.code,
selectionName: s.name,
odds: s.odds ?? 1.01,
sortOrder: i,
})),
},
},
include: { selections: true },
});
created.push(market);
}
return created;
}
private getMarketConfig(marketType: string) {
const configs: Record<string, {
period: string;
lineValue?: number;
allowParlay: boolean;
sortOrder: number;
selections: Array<{ code: string; name: string; odds?: number }>;
}> = {
FT_1X2: {
period: 'FT',
allowParlay: true,
sortOrder: 1,
selections: [
{ code: 'HOME', name: 'Home', odds: 2.5 },
{ code: 'DRAW', name: 'Draw', odds: 3.2 },
{ code: 'AWAY', name: 'Away', odds: 2.8 },
],
},
HT_1X2: {
period: 'HT',
allowParlay: true,
sortOrder: 5,
selections: [
{ code: 'HOME', name: 'HT Home', odds: 3.0 },
{ code: 'DRAW', name: 'HT Draw', odds: 2.0 },
{ code: 'AWAY', name: 'HT Away', odds: 3.5 },
],
},
FT_HANDICAP: {
period: 'FT',
lineValue: -0.5,
allowParlay: true,
sortOrder: 2,
selections: [
{ code: 'HOME', name: 'Home -0.5', odds: 1.9 },
{ code: 'AWAY', name: 'Away +0.5', odds: 1.9 },
],
},
HT_HANDICAP: {
period: 'HT',
lineValue: -0.5,
allowParlay: true,
sortOrder: 6,
selections: [
{ code: 'HOME', name: 'HT Home -0.5', odds: 1.9 },
{ code: 'AWAY', name: 'HT Away +0.5', odds: 1.9 },
],
},
FT_OVER_UNDER: {
period: 'FT',
lineValue: 2.5,
allowParlay: true,
sortOrder: 3,
selections: [
{ code: 'OVER', name: 'Over 2.5', odds: 1.85 },
{ code: 'UNDER', name: 'Under 2.5', odds: 1.95 },
],
},
HT_OVER_UNDER: {
period: 'HT',
lineValue: 1.5,
allowParlay: true,
sortOrder: 7,
selections: [
{ code: 'OVER', name: 'HT Over 1.5', odds: 2.0 },
{ code: 'UNDER', name: 'HT Under 1.5', odds: 1.75 },
],
},
FT_ODD_EVEN: {
period: 'FT',
allowParlay: true,
sortOrder: 4,
selections: [
{ code: 'ODD', name: 'Odd', odds: 1.9 },
{ code: 'EVEN', name: 'Even', odds: 1.9 },
],
},
FT_CORRECT_SCORE: {
period: 'FT',
allowParlay: true,
sortOrder: 8,
selections: FT_CORRECT_SCORE_TEMPLATE.map((code) => ({
code,
name: code.replace('SCORE_', '').replace('_', '-') || code,
odds: 8.0,
})),
},
HT_CORRECT_SCORE: {
period: 'HT',
allowParlay: true,
sortOrder: 9,
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
code,
name: code.replace('SCORE_', '').replace('_', '-') || code,
odds: 6.0,
})),
},
SH_CORRECT_SCORE: {
period: 'SH',
allowParlay: true,
sortOrder: 10,
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
code,
name: code.replace('SCORE_', '').replace('_', '-') || code,
odds: 6.0,
})),
},
};
const config = configs[marketType];
if (!config) throw new BadRequestException(`Unknown market type: ${marketType}`);
return config;
}
async updateOdds(selectionId: bigint, newOdds: number, operatorId: bigint) {
const selection = await this.prisma.marketSelection.findUnique({
where: { id: selectionId },
});
if (!selection) throw new NotFoundException('Selection not found');
if (newOdds <= 1) throw new BadRequestException('Odds must be > 1.00');
const newVersion = selection.oddsVersion + BigInt(1);
return this.prisma.$transaction(async (tx) => {
await tx.oddsChangeLog.create({
data: {
selectionId,
oldOdds: selection.odds,
newOdds,
oddsVersion: newVersion,
changedBy: operatorId,
},
});
return tx.marketSelection.update({
where: { id: selectionId },
data: { odds: newOdds, oddsVersion: newVersion },
});
});
}
async batchUpdateOdds(
updates: Array<{ selectionId: bigint; odds: number }>,
operatorId: bigint,
) {
const results = [];
for (const u of updates) {
results.push(await this.updateOdds(u.selectionId, u.odds, operatorId));
}
return results;
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MatchesService } from './matches.service';
@Module({
providers: [MatchesService],
exports: [MatchesService],
})
export class MatchesModule {}

View File

@@ -0,0 +1,161 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class MatchesService {
constructor(private prisma: PrismaService) {}
async createLeague(code: string, translations: Record<string, string>) {
const league = await this.prisma.league.create({ data: { code } });
for (const [locale, value] of Object.entries(translations)) {
await this.prisma.entityTranslation.create({
data: {
entityType: 'LEAGUE',
entityId: league.id,
locale,
fieldName: 'name',
value,
},
});
}
return league;
}
async createTeam(code: string, translations: Record<string, string>) {
const team = await this.prisma.team.create({ data: { code } });
for (const [locale, value] of Object.entries(translations)) {
await this.prisma.entityTranslation.create({
data: {
entityType: 'TEAM',
entityId: team.id,
locale,
fieldName: 'name',
value,
},
});
}
return team;
}
async createMatch(data: {
leagueId: bigint;
homeTeamId: bigint;
awayTeamId: bigint;
startTime: Date;
isHot?: boolean;
createdBy?: bigint;
}) {
return this.prisma.match.create({
data: {
leagueId: data.leagueId,
homeTeamId: data.homeTeamId,
awayTeamId: data.awayTeamId,
startTime: data.startTime,
isHot: data.isHot ?? false,
createdBy: data.createdBy,
status: 'DRAFT',
},
});
}
async publishMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'PUBLISHED', publishTime: new Date() },
});
}
async closeMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'CLOSED', closeTime: new Date() },
});
}
async cancelMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'CANCELLED' },
});
}
async getTranslation(entityType: string, entityId: bigint, locale: string) {
const translations = await this.prisma.entityTranslation.findMany({
where: { entityType, entityId },
});
const map = Object.fromEntries(
translations.filter((t) => t.fieldName === 'name').map((t) => [t.locale, t.value]),
);
return map[locale] || map['en-US'] || map['zh-CN'] || Object.values(map)[0] || '';
}
async enrichMatch(match: Record<string, unknown>, locale: string) {
const m = match as {
id: bigint;
leagueId: bigint;
homeTeamId: bigint;
awayTeamId: bigint;
league?: unknown;
homeTeam?: unknown;
awayTeam?: unknown;
markets?: unknown[];
};
const [leagueName, homeName, awayName] = await Promise.all([
this.getTranslation('LEAGUE', m.leagueId, locale),
this.getTranslation('TEAM', m.homeTeamId, locale),
this.getTranslation('TEAM', m.awayTeamId, locale),
]);
return {
...match,
leagueName,
homeTeamName: homeName,
awayTeamName: awayName,
};
}
async listPublished(locale = 'en-US', leagueId?: bigint) {
const matches = await this.prisma.match.findMany({
where: {
status: 'PUBLISHED',
...(leagueId ? { leagueId } : {}),
},
include: {
markets: {
where: { status: 'OPEN' },
include: { selections: { where: { status: 'OPEN' } } },
},
},
orderBy: [{ isHot: 'desc' }, { startTime: 'asc' }],
});
return Promise.all(matches.map((m) => this.enrichMatch(m, locale)));
}
async getMatchDetail(matchId: bigint, locale = 'en-US') {
const match = await this.prisma.match.findUnique({
where: { id: matchId },
include: {
markets: {
include: { selections: true },
orderBy: { sortOrder: 'asc' },
},
score: true,
},
});
if (!match) throw new NotFoundException('Match not found');
return this.enrichMatch(match, locale);
}
@Cron(CronExpression.EVERY_MINUTE)
async autoCloseMatches() {
const now = new Date();
await this.prisma.match.updateMany({
where: {
status: 'PUBLISHED',
startTime: { lte: now },
},
data: { status: 'CLOSED', closeTime: now },
});
}
}

View File

@@ -0,0 +1,179 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard, PlayerGuard } from '../auth/guards';
import { CurrentUser } from '../common/decorators';
import { jsonResponse } from '../common/filters';
import { UsersService } from '../users/users.service';
import { WalletService } from '../wallet/wallet.service';
import { MatchesService } from '../matches/matches.service';
import { BetsService } from '../bets/bets.service';
import { ContentService } from '../content/content.service';
import { CashbackService } from '../cashback/cashback.service';
import { IsString, IsNumber, IsArray, ValidateNested, Min, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
class SingleBetDto {
@IsString()
selectionId!: string;
@IsString()
oddsVersion!: string;
@IsNumber()
@Min(0.01)
stake!: number;
@IsString()
requestId!: string;
}
class ParlayLegDto {
@IsString()
selectionId!: string;
@IsString()
oddsVersion!: string;
}
class ParlayBetDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ParlayLegDto)
legs!: ParlayLegDto[];
@IsNumber()
@Min(0.01)
stake!: number;
@IsString()
requestId!: string;
}
class LocaleDto {
@IsString()
locale!: string;
}
@ApiTags('Player')
@Controller('player')
@UseGuards(JwtAuthGuard, PlayerGuard)
@ApiBearerAuth()
export class PlayerController {
constructor(
private users: UsersService,
private wallet: WalletService,
private matches: MatchesService,
private bets: BetsService,
private content: ContentService,
private cashback: CashbackService,
) {}
@Get('profile')
async profile(@CurrentUser('id') userId: bigint) {
const user = await this.users.findById(userId);
return jsonResponse(user);
}
@Post('language')
async setLanguage(@CurrentUser('id') userId: bigint, @Body() dto: LocaleDto) {
const result = await this.users.updateLocale(userId, dto.locale);
return jsonResponse(result);
}
@Get('home')
async home(@CurrentUser('locale') locale: string) {
const [banners, notices, ticker, hotMatches, todayMatches] = await Promise.all([
this.content.listActive('BANNER', locale),
this.content.listActive('NOTICE', locale),
this.content.listActive('TICKER', locale),
this.matches.listPublished(locale),
this.matches.listPublished(locale),
]);
return jsonResponse({
banners,
notices,
ticker,
hotMatches: (hotMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot),
todayMatches,
});
}
@Get('matches')
async listMatches(
@CurrentUser('locale') locale: string,
@Query('leagueId') leagueId?: string,
) {
const items = await this.matches.listPublished(locale, leagueId ? BigInt(leagueId) : undefined);
return jsonResponse(items);
}
@Get('matches/:id')
async matchDetail(@Param('id') id: string, @CurrentUser('locale') locale: string) {
const match = await this.matches.getMatchDetail(BigInt(id), locale);
return jsonResponse(match);
}
@Post('bets/single')
async singleBet(@CurrentUser('id') userId: bigint, @CurrentUser('parentId') parentId: bigint, @Body() dto: SingleBetDto) {
const bet = await this.bets.placeSingleBet(
userId,
parentId,
BigInt(dto.selectionId),
BigInt(dto.oddsVersion),
dto.stake,
dto.requestId,
);
return jsonResponse(bet);
}
@Post('bets/parlay')
async parlayBet(@CurrentUser('id') userId: bigint, @CurrentUser('parentId') parentId: bigint, @Body() dto: ParlayBetDto) {
const bet = await this.bets.placeParlayBet(
userId,
parentId,
dto.legs.map((l) => ({ selectionId: BigInt(l.selectionId), oddsVersion: BigInt(l.oddsVersion) })),
dto.stake,
dto.requestId,
);
return jsonResponse(bet);
}
@Get('bets')
async myBets(
@CurrentUser('id') userId: bigint,
@Query('status') status?: string,
@Query('page') page?: string,
) {
const result = await this.bets.getUserBets(userId, status, page ? parseInt(page) : 1);
return jsonResponse(result);
}
@Get('bets/:betNo')
async betDetail(@CurrentUser('id') userId: bigint, @Param('betNo') betNo: string) {
const bet = await this.bets.getBetByNo(betNo, userId);
return jsonResponse(bet);
}
@Get('wallet/transactions')
async transactions(
@CurrentUser('id') userId: bigint,
@Query('page') page?: string,
) {
const result = await this.wallet.getTransactions(userId, page ? parseInt(page) : 1);
return jsonResponse(result);
}
@Get('cashbacks')
async cashbacks(@CurrentUser('id') userId: bigint) {
const items = await this.cashback.getUserCashbacks(userId);
return jsonResponse(items);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PlayerController } from './player.controller';
import { UsersModule } from '../users/users.module';
import { WalletModule } from '../wallet/wallet.module';
import { MatchesModule } from '../matches/matches.module';
import { BetsModule } from '../bets/bets.module';
import { ContentModule } from '../content/content.module';
import { CashbackModule } from '../cashback/cashback.module';
@Module({
imports: [UsersModule, WalletModule, MatchesModule, BetsModule, ContentModule, CashbackModule],
controllers: [PlayerController],
})
export class PlayerModule {}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,13 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@@ -0,0 +1,187 @@
import {
settleSelection,
calculatePayout,
calculateParlayPayout,
isQuarterHandicapOrTotal,
ScoreInput,
} from './settlement-calculator';
describe('SettlementCalculator', () => {
const score: ScoreInput = { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 };
describe('FT_1X2', () => {
it('S001: home win', () => {
expect(
settleSelection({ marketType: 'FT_1X2', selectionCode: 'HOME', score }),
).toBe('WIN');
expect(
settleSelection({ marketType: 'FT_1X2', selectionCode: 'DRAW', score }),
).toBe('LOSE');
});
it('S002: draw', () => {
const draw = { htHome: 0, htAway: 0, ftHome: 1, ftAway: 1 };
expect(
settleSelection({ marketType: 'FT_1X2', selectionCode: 'DRAW', score: draw }),
).toBe('WIN');
});
});
describe('HT_1X2', () => {
it('S003: half time result', () => {
expect(
settleSelection({ marketType: 'HT_1X2', selectionCode: 'HOME', score }),
).toBe('WIN');
});
});
describe('FT_ODD_EVEN', () => {
it('S004: 0-0 is even', () => {
const s = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 };
expect(
settleSelection({ marketType: 'FT_ODD_EVEN', selectionCode: 'EVEN', score: s }),
).toBe('WIN');
});
});
describe('Correct Score', () => {
it('S005: exact score 2-1', () => {
expect(
settleSelection({
marketType: 'FT_CORRECT_SCORE',
selectionCode: 'SCORE_2_1',
score,
}),
).toBe('WIN');
});
it('S006: other home win', () => {
const s = { htHome: 2, htAway: 0, ftHome: 5, ftAway: 0 };
expect(
settleSelection({
marketType: 'FT_CORRECT_SCORE',
selectionCode: 'OTHER_HOME',
score: s,
templateScores: ['SCORE_1_0', 'SCORE_2_0'],
}),
).toBe('WIN');
});
it('S008: second half correct score', () => {
expect(
settleSelection({
marketType: 'SH_CORRECT_SCORE',
selectionCode: 'SCORE_1_1',
score,
}),
).toBe('WIN');
});
});
describe('Handicap', () => {
it('S009: full win', () => {
const r = settleSelection({
marketType: 'FT_HANDICAP',
selectionCode: 'HOME',
handicapLine: -1,
score: { htHome: 0, htAway: 0, ftHome: 2, ftAway: 0 },
});
expect(r).toBe('WIN');
expect(calculatePayout(100, 1.85, r).toNumber()).toBe(185);
});
it('S010: push', () => {
const r = settleSelection({
marketType: 'FT_HANDICAP',
selectionCode: 'HOME',
handicapLine: -1,
score: { htHome: 0, htAway: 0, ftHome: 1, ftAway: 0 },
});
expect(r).toBe('PUSH');
expect(calculatePayout(100, 1.85, r).toNumber()).toBe(100);
});
it('S011: half win -0.25', () => {
const r = settleSelection({
marketType: 'FT_HANDICAP',
selectionCode: 'HOME',
handicapLine: -0.25,
score: { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 },
});
expect(r).toBe('HALF_LOSE');
});
});
describe('Over/Under', () => {
it('S013: over 2.5 wins with 3 goals', () => {
const s = { htHome: 1, htAway: 1, ftHome: 2, ftAway: 1 };
expect(
settleSelection({
marketType: 'FT_OVER_UNDER',
selectionCode: 'OVER',
totalLine: 2.5,
score: s,
}),
).toBe('WIN');
});
it('S014: push on integer line', () => {
const s = { htHome: 1, htAway: 0, ftHome: 1, ftAway: 1 };
expect(
settleSelection({
marketType: 'FT_OVER_UNDER',
selectionCode: 'OVER',
totalLine: 2,
score: s,
}),
).toBe('PUSH');
});
});
describe('Parlay', () => {
it('S016: all win', () => {
const result = calculateParlayPayout(100, [
{ odds: 1.8, result: 'WIN' },
{ odds: 2.0, result: 'WIN' },
]);
expect(result.betResult).toBe('WON');
expect(result.payout.toNumber()).toBe(360);
});
it('S017: one lose', () => {
const result = calculateParlayPayout(100, [
{ odds: 1.8, result: 'WIN' },
{ odds: 2.0, result: 'LOSE' },
]);
expect(result.betResult).toBe('LOST');
expect(result.payout.toNumber()).toBe(0);
});
it('S018: one push', () => {
const result = calculateParlayPayout(100, [
{ odds: 1.8, result: 'WIN' },
{ odds: 2.0, result: 'PUSH' },
{ odds: 1.9, result: 'WIN' },
]);
expect(result.betResult).toBe('WON');
expect(result.payout.toNumber()).toBe(342);
});
it('S019: all push', () => {
const result = calculateParlayPayout(100, [
{ odds: 1.8, result: 'PUSH' },
{ odds: 2.0, result: 'VOID' },
]);
expect(result.betResult).toBe('PUSH');
expect(result.payout.toNumber()).toBe(100);
});
});
describe('Quarter line detection', () => {
it('detects quarter lines', () => {
expect(isQuarterHandicapOrTotal(-0.25)).toBe(true);
expect(isQuarterHandicapOrTotal(2.5)).toBe(false);
expect(isQuarterHandicapOrTotal(-1)).toBe(false);
});
});
});

View File

@@ -0,0 +1,279 @@
import Decimal from 'decimal.js';
export type SelectionResult = 'WIN' | 'HALF_WIN' | 'PUSH' | 'HALF_LOSE' | 'LOSE' | 'VOID';
export interface ScoreInput {
htHome: number;
htAway: number;
ftHome: number;
ftAway: number;
}
export interface SettlementInput {
marketType: string;
selectionCode: string;
handicapLine?: number | null;
totalLine?: number | null;
score: ScoreInput;
templateScores?: string[];
}
export function getShScore(score: ScoreInput): { home: number; away: number } {
return {
home: score.ftHome - score.htHome,
away: score.ftAway - score.htAway,
};
}
function isQuarterLine(line: number): boolean {
const frac = Math.abs(line % 1);
return Math.abs(frac - 0.25) < 0.001 || Math.abs(frac - 0.75) < 0.001;
}
function splitQuarterLine(line: number): [number, number] {
const sign = line >= 0 ? 1 : -1;
const abs = Math.abs(line);
const lower = Math.floor(abs * 2) / 2 * sign;
const upper = (Math.floor(abs * 2) + 1) / 2 * sign;
if (Math.abs(abs % 1 - 0.25) < 0.001) {
return [lower, upper];
}
// 0.75 case: e.g. -0.75 => -0.5 and -1
const l = Math.floor(abs) * sign;
const u = (Math.floor(abs) + 0.5) * sign;
return abs % 1 > 0.5 ? [l, u] : [lower, upper];
}
function settleHandicap(
teamGoals: number,
oppGoals: number,
handicap: number,
isHome: boolean,
): SelectionResult {
const adj = teamGoals + handicap - oppGoals;
if (!isQuarterLine(handicap)) {
if (adj > 0) return 'WIN';
if (adj === 0) return 'PUSH';
return 'LOSE';
}
const [line1, line2] = splitQuarterLine(handicap);
const r1 = teamGoals + line1 - oppGoals;
const r2 = teamGoals + line2 - oppGoals;
const results: SelectionResult[] = [];
for (const r of [r1, r2]) {
if (r > 0) results.push('WIN');
else if (r === 0) results.push('PUSH');
else results.push('LOSE');
}
const winCount = results.filter((r) => r === 'WIN').length;
const loseCount = results.filter((r) => r === 'LOSE').length;
if (winCount === 2) return 'WIN';
if (loseCount === 2) return 'LOSE';
if (winCount === 1 && loseCount === 0) return 'HALF_WIN';
if (loseCount === 1 && winCount === 0) return 'HALF_LOSE';
return 'PUSH';
}
function settleOverUnder(
totalGoals: number,
line: number,
isOver: boolean,
): SelectionResult {
if (!isQuarterLine(line)) {
if (isOver) {
if (totalGoals > line) return 'WIN';
if (totalGoals === line) return 'PUSH';
return 'LOSE';
}
if (totalGoals < line) return 'WIN';
if (totalGoals === line) return 'PUSH';
return 'LOSE';
}
const [line1, line2] = splitQuarterLine(line);
const r1 = settleOverUnder(totalGoals, line1, isOver);
const r2 = settleOverUnder(totalGoals, line2, isOver);
const winCount = [r1, r2].filter((r) => r === 'WIN').length;
const loseCount = [r1, r2].filter((r) => r === 'LOSE').length;
if (winCount === 2) return 'WIN';
if (loseCount === 2) return 'LOSE';
if (winCount === 1) return 'HALF_WIN';
if (loseCount === 1) return 'HALF_LOSE';
return 'PUSH';
}
function parseScoreCode(code: string): { home: number; away: number } | null {
const match = code.match(/SCORE_(\d+)_(\d+)/);
if (match) return { home: parseInt(match[1]), away: parseInt(match[2]) };
return null;
}
function settleCorrectScore(
home: number,
away: number,
selectionCode: string,
templateScores: string[],
): SelectionResult {
const parsed = parseScoreCode(selectionCode);
if (parsed) {
return parsed.home === home && parsed.away === away ? 'WIN' : 'LOSE';
}
const actualKey = `SCORE_${home}_${away}`;
if (templateScores.includes(actualKey)) {
return 'LOSE';
}
const isDraw = home === away;
const isHomeWin = home > away;
if (selectionCode === 'OTHER_DRAW' && isDraw) return 'WIN';
if (selectionCode === 'OTHER_HOME' && isHomeWin) return 'WIN';
if (selectionCode === 'OTHER_AWAY' && !isHomeWin && !isDraw) return 'WIN';
return 'LOSE';
}
export function settleSelection(input: SettlementInput): SelectionResult {
const { marketType, selectionCode, handicapLine, totalLine, score } = input;
const templates = input.templateScores ?? [];
switch (marketType) {
case 'FT_1X2': {
if (selectionCode === 'HOME') return score.ftHome > score.ftAway ? 'WIN' : 'LOSE';
if (selectionCode === 'DRAW') return score.ftHome === score.ftAway ? 'WIN' : 'LOSE';
if (selectionCode === 'AWAY') return score.ftHome < score.ftAway ? 'WIN' : 'LOSE';
break;
}
case 'HT_1X2': {
if (selectionCode === 'HOME') return score.htHome > score.htAway ? 'WIN' : 'LOSE';
if (selectionCode === 'DRAW') return score.htHome === score.htAway ? 'WIN' : 'LOSE';
if (selectionCode === 'AWAY') return score.htHome < score.htAway ? 'WIN' : 'LOSE';
break;
}
case 'FT_ODD_EVEN': {
const total = score.ftHome + score.ftAway;
const isOdd = total % 2 === 1;
if (selectionCode === 'ODD') return isOdd ? 'WIN' : 'LOSE';
if (selectionCode === 'EVEN') return !isOdd ? 'WIN' : 'LOSE';
break;
}
case 'FT_HANDICAP': {
const line = handicapLine ?? 0;
const isHome = selectionCode === 'HOME';
const goals = isHome ? score.ftHome : score.ftAway;
const opp = isHome ? score.ftAway : score.ftHome;
return settleHandicap(goals, opp, isHome ? line : -line, isHome);
}
case 'HT_HANDICAP': {
const line = handicapLine ?? 0;
const isHome = selectionCode === 'HOME';
const goals = isHome ? score.htHome : score.htAway;
const opp = isHome ? score.htAway : score.htHome;
return settleHandicap(goals, opp, isHome ? line : -line, isHome);
}
case 'FT_OVER_UNDER': {
const total = score.ftHome + score.ftAway;
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
}
case 'HT_OVER_UNDER': {
const total = score.htHome + score.htAway;
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
}
case 'FT_CORRECT_SCORE':
return settleCorrectScore(score.ftHome, score.ftAway, selectionCode, templates);
case 'HT_CORRECT_SCORE':
return settleCorrectScore(score.htHome, score.htAway, selectionCode, templates);
case 'SH_CORRECT_SCORE': {
const sh = getShScore(score);
return settleCorrectScore(sh.home, sh.away, selectionCode, templates);
}
case 'OUTRIGHT_WINNER':
return selectionCode === `TEAM_${input.score.ftHome}` ? 'WIN' : 'LOSE';
}
return 'LOSE';
}
export function calculatePayout(
stake: Decimal | number,
odds: Decimal | number,
result: SelectionResult,
): Decimal {
const s = new Decimal(stake);
const o = new Decimal(odds);
switch (result) {
case 'WIN':
return s.mul(o);
case 'HALF_WIN':
return s.div(2).mul(o).add(s.div(2));
case 'PUSH':
case 'VOID':
return s;
case 'HALF_LOSE':
return s.div(2);
case 'LOSE':
return new Decimal(0);
default:
return new Decimal(0);
}
}
export function calculateParlayPayout(
stake: Decimal | number,
selections: Array<{ odds: Decimal | number; result: SelectionResult }>,
): { betResult: 'WON' | 'LOST' | 'PUSH'; payout: Decimal; effectiveOdds: Decimal } {
const s = new Decimal(stake);
if (selections.some((sel) => sel.result === 'LOSE')) {
return { betResult: 'LOST', payout: new Decimal(0), effectiveOdds: new Decimal(0) };
}
let combinedOdds = new Decimal(1);
for (const sel of selections) {
if (sel.result === 'WIN') {
combinedOdds = combinedOdds.mul(sel.odds);
} else if (sel.result === 'HALF_WIN') {
combinedOdds = combinedOdds.mul(new Decimal(sel.odds).add(1).div(2));
} else if (sel.result === 'HALF_LOSE') {
combinedOdds = combinedOdds.mul(0.5);
}
// PUSH/VOID => odds 1.00
}
const allPush = selections.every(
(sel) => sel.result === 'PUSH' || sel.result === 'VOID',
);
if (allPush) {
return { betResult: 'PUSH', payout: s, effectiveOdds: new Decimal(1) };
}
return { betResult: 'WON', payout: s.mul(combinedOdds), effectiveOdds: combinedOdds };
}
export function isQuarterHandicapOrTotal(line: number | null | undefined): boolean {
if (line == null) return false;
return isQuarterLine(line);
}
export const FT_CORRECT_SCORE_TEMPLATE = [
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'SCORE_3_3', 'SCORE_4_4', 'OTHER_DRAW',
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'SCORE_3_1', 'SCORE_3_2',
'SCORE_4_0', 'SCORE_4_1', 'SCORE_4_2', 'SCORE_4_3', 'OTHER_HOME',
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'SCORE_1_3', 'SCORE_2_3',
'SCORE_0_4', 'SCORE_1_4', 'SCORE_2_4', 'SCORE_3_4', 'OTHER_AWAY',
];
export const HT_CORRECT_SCORE_TEMPLATE = [
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'OTHER_DRAW',
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'OTHER_HOME',
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'OTHER_AWAY',
];

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SettlementService } from './settlement.service';
import { WalletModule } from '../wallet/wallet.module';
import { AgentsModule } from '../agents/agents.module';
@Module({
imports: [WalletModule, AgentsModule],
providers: [SettlementService],
exports: [SettlementService],
})
export class SettlementModule {}

View File

@@ -0,0 +1,312 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { WalletService } from '../wallet/wallet.service';
import { AgentsService } from '../agents/agents.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBatchNo } from '../common/decorators';
import {
settleSelection,
calculatePayout,
calculateParlayPayout,
ScoreInput,
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
} from './settlement-calculator';
@Injectable()
export class SettlementService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private agents: AgentsService,
) {}
async recordScore(
matchId: bigint,
htHome: number,
htAway: number,
ftHome: number,
ftAway: number,
operatorId: bigint,
) {
await this.prisma.matchScore.upsert({
where: { matchId },
create: { matchId, htHomeScore: htHome, htAwayScore: htAway, ftHomeScore: ftHome, ftAwayScore: ftAway, recordedBy: operatorId },
update: { htHomeScore: htHome, htAwayScore: htAway, ftHomeScore: ftHome, ftAwayScore: ftAway, recordedBy: operatorId },
});
await this.prisma.match.update({
where: { id: matchId },
data: { status: 'PENDING_SETTLEMENT' },
});
return { matchId, htHome, htAway, ftHome, ftAway };
}
async previewSettlement(matchId: bigint, operatorId: bigint) {
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (!score) throw new BadRequestException('Score not recorded');
const scoreInput: ScoreInput = {
htHome: score.htHomeScore ?? 0,
htAway: score.htAwayScore ?? 0,
ftHome: score.ftHomeScore ?? 0,
ftAway: score.ftAwayScore ?? 0,
};
const pendingBets = await this.prisma.bet.findMany({
where: {
status: 'PENDING',
selections: { some: { matchId } },
},
include: { selections: true },
});
const parlayBets = await this.prisma.bet.findMany({
where: {
status: 'PENDING',
betType: 'PARLAY',
selections: { some: { matchId } },
},
include: { selections: true },
});
let totalPayout = new Decimal(0);
let totalRefund = new Decimal(0);
const items: Array<{ betId: bigint; betNo: string; result: string; payout: Decimal }> = [];
for (const bet of pendingBets) {
if (bet.betType === 'SINGLE') {
const sel = bet.selections[0];
const template =
sel.marketType === 'FT_CORRECT_SCORE'
? FT_CORRECT_SCORE_TEMPLATE
: sel.marketType.includes('CORRECT_SCORE')
? HT_CORRECT_SCORE_TEMPLATE
: [];
const result = settleSelection({
marketType: sel.marketType,
selectionCode: sel.selectionNameSnapshot.includes('-')
? `SCORE_${sel.selectionNameSnapshot.replace('-', '_')}`
: sel.selectionNameSnapshot,
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
score: scoreInput,
templateScores: template,
});
const payout = calculatePayout(bet.stake, sel.odds, result);
items.push({ betId: bet.id, betNo: bet.betNo, result, payout });
if (result === 'LOSE') {
// no payout
} else if (result === 'PUSH' || result === 'VOID') {
totalRefund = totalRefund.add(bet.stake);
} else {
totalPayout = totalPayout.add(payout);
}
}
}
const batch = await this.prisma.settlementBatch.create({
data: {
matchId,
batchNo: generateBatchNo('STL'),
htHomeScore: score.htHomeScore,
htAwayScore: score.htAwayScore,
ftHomeScore: score.ftHomeScore,
ftAwayScore: score.ftAwayScore,
status: 'PREVIEW',
totalBets: pendingBets.length,
totalPayout,
totalRefund,
operatorId,
},
});
return {
batch,
score: scoreInput,
singleBetCount: pendingBets.filter((b) => b.betType === 'SINGLE').length,
parlayBetCount: parlayBets.length,
items,
totalPayout,
totalRefund,
};
}
async confirmSettlement(batchId: bigint, operatorId: bigint) {
const batch = await this.prisma.settlementBatch.findUnique({
where: { id: batchId },
include: { match: true },
});
if (!batch) throw new NotFoundException('Batch not found');
if (batch.status !== 'PREVIEW') throw new BadRequestException('Batch already confirmed');
const score = await this.prisma.matchScore.findUnique({
where: { matchId: batch.matchId },
});
if (!score) throw new BadRequestException('Score not found');
const scoreInput: ScoreInput = {
htHome: score.htHomeScore ?? 0,
htAway: score.htAwayScore ?? 0,
ftHome: score.ftHomeScore ?? 0,
ftAway: score.ftAwayScore ?? 0,
};
const pendingBets = await this.prisma.bet.findMany({
where: {
status: 'PENDING',
selections: { some: { matchId: batch.matchId } },
},
include: { selections: true, user: true },
});
const agentIds = new Set<bigint>();
await this.prisma.$transaction(async (tx) => {
for (const bet of pendingBets) {
if (bet.betType === 'SINGLE') {
const sel = bet.selections[0];
const selection = await tx.marketSelection.findUnique({
where: { id: sel.selectionId },
});
const result = settleSelection({
marketType: sel.marketType,
selectionCode: selection?.selectionCode ?? sel.selectionNameSnapshot,
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
score: scoreInput,
templateScores:
sel.marketType === 'FT_CORRECT_SCORE'
? FT_CORRECT_SCORE_TEMPLATE
: HT_CORRECT_SCORE_TEMPLATE,
});
const payout = calculatePayout(bet.stake, sel.odds, result);
const betStatus =
result === 'LOSE' ? 'LOST' : result === 'PUSH' || result === 'VOID' ? 'PUSH' : 'WON';
await tx.bet.update({
where: { id: bet.id },
data: {
status: betStatus,
actualReturn: payout,
settledAt: new Date(),
},
});
await tx.betSelection.update({
where: { id: sel.id },
data: { resultStatus: result, effectiveOdds: sel.odds },
});
await this.wallet.settleBet(
bet.userId,
bet.stake,
payout,
bet.betNo,
result === 'HALF_WIN' ? 'HALF_WIN' : result === 'HALF_LOSE' ? 'HALF_LOSE' : result as 'WIN' | 'LOSE' | 'PUSH' | 'VOID',
);
if (bet.agentId) agentIds.add(bet.agentId);
await tx.settlementItem.create({
data: {
batchId,
betId: bet.id,
userId: bet.userId,
result: betStatus,
payout,
},
});
} else {
// Parlay: update this leg's result, check if all legs settled
for (const sel of bet.selections) {
if (sel.matchId?.toString() === batch.matchId.toString()) {
const selection = await tx.marketSelection.findUnique({
where: { id: sel.selectionId },
});
const result = settleSelection({
marketType: sel.marketType,
selectionCode: selection?.selectionCode ?? '',
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
score: scoreInput,
});
await tx.betSelection.update({
where: { id: sel.id },
data: { resultStatus: result },
});
}
}
const updated = await tx.betSelection.findMany({ where: { betId: bet.id } });
const allHaveResult = updated.every((s) => s.resultStatus != null);
if (allHaveResult) {
const legResults = updated.map((s) => ({
odds: s.odds,
result: s.resultStatus as 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
}));
const parlayResult = calculateParlayPayout(bet.stake, legResults);
await tx.bet.update({
where: { id: bet.id },
data: {
status: parlayResult.betResult === 'LOST' ? 'LOST' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WON',
actualReturn: parlayResult.payout,
settledAt: new Date(),
},
});
await this.wallet.settleBet(
bet.userId,
bet.stake,
parlayResult.payout,
bet.betNo,
parlayResult.betResult === 'LOST' ? 'LOSE' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WIN',
);
if (bet.agentId) agentIds.add(bet.agentId);
}
}
}
await tx.settlementBatch.update({
where: { id: batchId },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
});
await tx.match.update({
where: { id: batch.matchId },
data: { status: 'SETTLED' },
});
});
for (const agentId of agentIds) {
await this.agents.recalculateUsedCredit(agentId);
}
return { success: true, batchId: batchId.toString() };
}
async voidMatchBets(matchId: bigint) {
const bets = await this.prisma.bet.findMany({
where: { status: 'PENDING', selections: { some: { matchId } } },
});
for (const bet of bets) {
await this.wallet.settleBet(bet.userId, bet.stake, bet.stake, bet.betNo, 'VOID');
await this.prisma.bet.update({
where: { id: bet.id },
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
});
}
return { voidedCount: bets.length };
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async findById(id: bigint) {
return this.prisma.user.findUnique({
where: { id },
include: { wallet: true, agentProfile: true, preferences: true },
});
}
async updateLocale(userId: bigint, locale: string) {
await this.prisma.user.update({
where: { id: userId },
data: { locale },
});
await this.prisma.userPreference.upsert({
where: { userId },
create: { userId, locale },
update: { locale },
});
return { locale };
}
async listPlayers(page = 1, pageSize = 20, parentId?: bigint) {
const where = { userType: 'PLAYER', ...(parentId ? { parentId } : {}) };
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.user.findMany({
where,
include: { wallet: true },
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
this.prisma.user.count({ where }),
]);
return { items, total, page, pageSize };
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { WalletService } from './wallet.service';
@Module({
providers: [WalletService],
exports: [WalletService],
})
export class WalletModule {}

View File

@@ -0,0 +1,222 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateTransactionId } from '../common/decorators';
@Injectable()
export class WalletService {
constructor(private prisma: PrismaService) {}
async getWallet(userId: bigint) {
const wallet = await this.prisma.wallet.findUnique({ where: { userId } });
if (!wallet) throw new BadRequestException('Wallet not found');
return wallet;
}
async createWallet(userId: bigint, currency = 'USD') {
return this.prisma.wallet.create({
data: { userId, currency },
});
}
private async lockWallet(tx: Parameters<Parameters<PrismaService['$transaction']>[0]>[0], userId: bigint) {
const wallets = await tx.$queryRaw<Array<{ id: bigint; available_balance: Decimal; frozen_balance: Decimal; version: number }>>`
SELECT id, available_balance, frozen_balance, version FROM wallets WHERE user_id = ${userId} FOR UPDATE
`;
if (!wallets.length) throw new BadRequestException('Wallet not found');
return wallets[0];
}
async deposit(
userId: bigint,
amount: Decimal | number,
operatorId: bigint,
remark?: string,
referenceId?: string,
) {
const amt = new Decimal(amount);
if (amt.lte(0)) throw new BadRequestException('Amount must be positive');
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const balanceBefore = new Decimal(w.available_balance);
const balanceAfter = balanceBefore.add(amt);
await tx.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
version: { increment: 1 },
},
});
await tx.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: 'MANUAL_DEPOSIT',
amount: amt,
balanceBefore,
balanceAfter,
frozenBefore: w.frozen_balance,
frozenAfter: w.frozen_balance,
referenceType: 'DEPOSIT',
referenceId,
operatorId,
remark,
},
});
return { balanceAfter };
});
}
async withdraw(
userId: bigint,
amount: Decimal | number,
operatorId: bigint,
remark?: string,
referenceId?: string,
) {
const amt = new Decimal(amount);
if (amt.lte(0)) throw new BadRequestException('Amount must be positive');
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const balanceBefore = new Decimal(w.available_balance);
if (balanceBefore.lt(amt)) throw new BadRequestException('Insufficient balance');
const balanceAfter = balanceBefore.sub(amt);
await tx.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
version: { increment: 1 },
},
});
await tx.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: 'MANUAL_WITHDRAW',
amount: amt.neg(),
balanceBefore,
balanceAfter,
frozenBefore: w.frozen_balance,
frozenAfter: w.frozen_balance,
referenceType: 'WITHDRAW',
referenceId,
operatorId,
remark,
},
});
return { balanceAfter };
});
}
async freezeForBet(userId: bigint, stake: Decimal | number, betId: string) {
const amt = new Decimal(stake);
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const avail = new Decimal(w.available_balance);
if (avail.lt(amt)) throw new BadRequestException('Insufficient balance');
const balanceAfter = avail.sub(amt);
const frozenAfter = new Decimal(w.frozen_balance).add(amt);
await tx.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
frozenBalance: frozenAfter,
version: { increment: 1 },
},
});
await tx.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: 'BET_FREEZE',
amount: amt.neg(),
balanceBefore: avail,
balanceAfter,
frozenBefore: w.frozen_balance,
frozenAfter,
referenceType: 'BET',
referenceId: betId,
},
});
});
}
async settleBet(
userId: bigint,
stake: Decimal,
payout: Decimal,
betId: string,
result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
) {
const txTypeMap: Record<string, string> = {
WIN: 'BET_SETTLE_WIN',
LOSE: 'BET_SETTLE_LOSE',
PUSH: 'BET_SETTLE_PUSH',
VOID: 'BET_VOID_REFUND',
HALF_WIN: 'BET_SETTLE_WIN',
HALF_LOSE: 'BET_SETTLE_LOSE',
};
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const avail = new Decimal(w.available_balance);
const frozen = new Decimal(w.frozen_balance);
const frozenAfter = frozen.sub(stake);
const balanceAfter = avail.add(payout);
await tx.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
frozenBalance: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
version: { increment: 1 },
},
});
await tx.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: txTypeMap[result] || 'BET_SETTLE_WIN',
amount: payout,
balanceBefore: avail,
balanceAfter,
frozenBefore: frozen,
frozenAfter: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
referenceType: 'BET',
referenceId: betId,
},
});
});
}
async getTransactions(userId: bigint, page = 1, pageSize = 20) {
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.walletTransaction.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.walletTransaction.count({ where: { userId } }),
]);
return { items, total, page, pageSize };
}
}

25
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*", "prisma/seed.ts"]
}

12
apps/player/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>TheBet365</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

25
apps/player/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@thebet365/player",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5173",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@thebet365/shared": "workspace:*",
"axios": "^1.7.9",
"pinia": "^2.3.1",
"vue": "^3.5.13",
"vue-i18n": "^11.1.1",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vue-tsc": "^2.2.0"
}
}

6
apps/player/src/App.vue Normal file
View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>
<template>
<RouterView />
</template>

View File

@@ -0,0 +1,22 @@
import axios from 'axios';
const api = axios.create({ baseURL: '/api' });
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(err);
},
);
export default api;

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useBetSlipStore } from '../stores/betSlip';
import api from '../api';
const props = defineProps<{ modelValue: boolean }>();
const emit = defineEmits<{ 'update:modelValue': [boolean] }>();
const { t } = useI18n();
const slip = useBetSlipStore();
const show = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
});
const loading = ref(false);
const error = ref('');
const success = ref('');
function genId() {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
async function placeBet() {
if (!slip.items.length) return;
loading.value = true;
error.value = '';
success.value = '';
try {
const requestId = genId();
if (slip.mode === 'parlay' && slip.items.length >= 2) {
if (slip.hasSameMatch) {
error.value = '同一场比赛不能串关';
return;
}
await api.post('/player/bets/parlay', {
legs: slip.items.map((i) => ({
selectionId: i.selectionId,
oddsVersion: i.oddsVersion,
})),
stake: slip.stake,
requestId,
});
} else if (slip.items.length === 1) {
const item = slip.items[0];
await api.post('/player/bets/single', {
selectionId: item.selectionId,
oddsVersion: item.oddsVersion,
stake: slip.stake,
requestId,
});
} else {
error.value = '请选择投注项';
return;
}
success.value = '下注成功!';
slip.clear();
setTimeout(() => { show.value = false; success.value = ''; }, 1500);
} catch (e: unknown) {
error.value = (e as { response?: { data?: { error?: string } } })?.response?.data?.error || '下注失败';
} finally {
loading.value = false;
}
}
</script>
<template>
<div v-if="show" class="overlay" @click.self="show = false">
<div class="drawer">
<div class="drawer-header">
<h3>{{ t('bet.bet_slip') }} ({{ slip.count }})</h3>
<button @click="show = false"></button>
</div>
<div v-if="!slip.items.length" class="empty">点击赔率添加投注</div>
<div v-for="item in slip.items" :key="item.selectionId" class="slip-item">
<div class="item-name">{{ item.matchName }}</div>
<div class="item-sel">{{ item.selectionName }} @ {{ item.odds }}</div>
<button class="remove" @click="slip.removeItem(item.selectionId)">移除</button>
</div>
<div v-if="slip.hasSameMatch" class="warn">同场比赛不能串关可作为单关分别投注</div>
<div v-if="slip.items.length" class="stake-area">
<label>{{ t('bet.stake') }}</label>
<input v-model.number="slip.stake" type="number" min="1" />
<div v-if="slip.isParlay" class="mode-tag">{{ t('bet.parlay') }} · 赔率 {{ slip.totalOdds.toFixed(2) }}</div>
<div class="return">预计返还: {{ slip.potentialReturn.toFixed(2) }}</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="success" class="success">{{ success }}</p>
<button class="btn-primary" :disabled="loading || !slip.items.length" @click="placeBet">
{{ t('bet.place_bet') }}
</button>
</div>
</div>
</template>
<style scoped>
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 200; display: flex; align-items: flex-end; }
.drawer { background: #1a2332; width: 100%; max-height: 80vh; border-radius: 16px 16px 0 0; padding: 16px; overflow-y: auto; }
.drawer-header { display: flex; justify-content: space-between; margin-bottom: 16px; }
.drawer-header button { background: none; color: var(--text-muted); font-size: 20px; }
.slip-item { padding: 12px; background: var(--bg-hover); border-radius: 6px; margin-bottom: 8px; }
.item-name { font-size: 13px; font-weight: 600; }
.item-sel { font-size: 12px; color: var(--text-muted); }
.remove { background: none; color: var(--danger); font-size: 12px; margin-top: 4px; }
.stake-area { margin: 16px 0; }
.stake-area label { font-size: 13px; color: var(--text-muted); display: block; margin-bottom: 4px; }
.return { font-size: 14px; color: #ffd700; margin-top: 8px; }
.mode-tag { font-size: 12px; color: var(--primary); margin-top: 4px; }
.warn { color: #ff9800; font-size: 12px; margin-bottom: 8px; }
.error { color: var(--danger); font-size: 13px; }
.success { color: var(--primary); font-size: 13px; }
.empty { text-align: center; color: var(--text-muted); padding: 24px; }
</style>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { RouterView, RouterLink, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
import { useBetSlipStore } from '../stores/betSlip';
import BetSlipDrawer from '../components/BetSlipDrawer.vue';
import { ref } from 'vue';
const { t, locale } = useI18n();
const auth = useAuthStore();
const slip = useBetSlipStore();
const route = useRoute();
const showSlip = ref(false);
const locales = [
{ code: 'zh-CN', label: '中文' },
{ code: 'en-US', label: 'EN' },
{ code: 'ms-MY', label: 'BM' },
];
function setLocale(code: string) {
locale.value = code;
localStorage.setItem('locale', code);
}
</script>
<template>
<div class="layout">
<header class="header">
<span class="logo">TheBet365</span>
<div class="header-actions">
<select :value="locale" @change="setLocale(($event.target as HTMLSelectElement).value)" class="lang-select">
<option v-for="l in locales" :key="l.code" :value="l.code">{{ l.label }}</option>
</select>
<span class="balance">{{ auth.user?.username }}</span>
</div>
</header>
<main class="main">
<RouterView />
</main>
<nav class="bottom-nav">
<RouterLink to="/" :class="{ active: route.path === '/' }">{{ t('nav.home') }}</RouterLink>
<RouterLink to="/football" :class="{ active: route.path.startsWith('/football') || route.path.startsWith('/match') }">{{ t('nav.football') }}</RouterLink>
<button class="slip-btn" @click="showSlip = true">
{{ t('bet.bet_slip') }}
<span v-if="slip.count" class="badge">{{ slip.count }}</span>
</button>
<RouterLink to="/bets" :class="{ active: route.path === '/bets' }">{{ t('nav.my_bets') }}</RouterLink>
<RouterLink to="/profile" :class="{ active: route.path === '/profile' }">{{ t('nav.profile') }}</RouterLink>
</nav>
<BetSlipDrawer v-model="showSlip" />
</div>
</template>
<style scoped>
.layout { display: flex; flex-direction: column; min-height: 100vh; padding-bottom: 60px; }
.header {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px; background: #1a2332; border-bottom: 1px solid var(--border);
}
.logo { font-weight: 800; color: var(--primary); font-size: 18px; }
.header-actions { display: flex; gap: 12px; align-items: center; }
.lang-select { background: var(--bg-card); color: #fff; border: 1px solid var(--border); padding: 4px 8px; border-radius: 4px; width: auto; }
.balance { font-size: 13px; color: var(--text-muted); }
.main { flex: 1; padding: 12px; }
.bottom-nav {
position: fixed; bottom: 0; left: 0; right: 0;
display: flex; background: #1a2332; border-top: 1px solid var(--border);
z-index: 100;
}
.bottom-nav a, .slip-btn {
flex: 1; text-align: center; padding: 10px 4px; font-size: 11px;
color: var(--text-muted); background: none; position: relative;
}
.bottom-nav a.active, .slip-btn.active { color: var(--primary); }
.badge {
position: absolute; top: 2px; right: 20%;
background: var(--danger); color: #fff; font-size: 10px;
padding: 1px 5px; border-radius: 10px;
}
</style>

34
apps/player/src/main.ts Normal file
View File

@@ -0,0 +1,34 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { createI18n } from 'vue-i18n';
import App from './App.vue';
import router from './router';
import './styles.css';
const i18n = createI18n({
legacy: false,
locale: localStorage.getItem('locale') || 'zh-CN',
fallbackLocale: 'en-US',
messages: {
'zh-CN': {
nav: { home: '首页', football: '足球', my_bets: '我的投注', profile: '我的' },
auth: { login: '登录', username: '账号', password: '密码' },
wallet: { balance: '余额' },
bet: { bet_slip: '投注单', stake: '投注金额', place_bet: '确认下注', parlay: '串关' },
},
'en-US': {
nav: { home: 'Home', football: 'Football', my_bets: 'My Bets', profile: 'Profile' },
auth: { login: 'Login', username: 'Username', password: 'Password' },
wallet: { balance: 'Balance' },
bet: { bet_slip: 'Bet Slip', stake: 'Stake', place_bet: 'Place Bet', parlay: 'Parlay' },
},
'ms-MY': {
nav: { home: 'Laman Utama', football: 'Bola Sepak', my_bets: 'Pertaruhan Saya', profile: 'Profil' },
auth: { login: 'Log Masuk', username: 'Nama Pengguna', password: 'Kata Laluan' },
wallet: { balance: 'Baki' },
bet: { bet_slip: 'Slip Pertaruhan', stake: 'Jumlah', place_bet: 'Letak Pertaruhan', parlay: 'Berganda' },
},
},
});
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app');

View File

@@ -0,0 +1,29 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from './stores/auth';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('./views/LoginView.vue') },
{
path: '/',
component: () => import('./layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', component: () => import('./views/HomeView.vue') },
{ path: 'football', component: () => import('./views/FootballView.vue') },
{ path: 'match/:id', component: () => import('./views/MatchDetailView.vue') },
{ path: 'bets', component: () => import('./views/MyBetsView.vue') },
{ path: 'profile', component: () => import('./views/ProfileView.vue') },
],
},
],
});
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.meta.requiresAuth && !auth.token) return '/login';
if (to.path === '/login' && auth.token) return '/';
});
export default router;

View File

@@ -0,0 +1,25 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import api from '../api';
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '');
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'));
async function login(username: string, password: string) {
const { data } = await api.post('/player/auth/login', { username, password });
token.value = data.data.token;
user.value = data.data.user;
localStorage.setItem('token', token.value);
localStorage.setItem('user', JSON.stringify(user.value));
}
function logout() {
token.value = '';
user.value = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
}
return { token, user, login, logout };
});

View File

@@ -0,0 +1,74 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export interface SlipItem {
selectionId: string;
oddsVersion: string;
matchId: string;
matchName: string;
selectionName: string;
odds: number;
marketType: string;
}
export const useBetSlipStore = defineStore('betSlip', () => {
const items = ref<SlipItem[]>([]);
const stake = ref<number>(10);
const mode = ref<'single' | 'parlay'>('single');
const count = computed(() => items.value.length);
const isParlay = computed(() => items.value.length >= 2);
function addItem(item: SlipItem) {
const existing = items.value.findIndex(
(i) => i.selectionId === item.selectionId,
);
if (existing >= 0) {
items.value.splice(existing, 1);
return;
}
items.value.push(item);
if (items.value.length >= 2) mode.value = 'parlay';
}
function removeItem(selectionId: string) {
items.value = items.value.filter((i) => i.selectionId !== selectionId);
if (items.value.length < 2) mode.value = 'single';
}
function clear() {
items.value = [];
mode.value = 'single';
}
const totalOdds = computed(() =>
items.value.reduce((acc, i) => acc * i.odds, 1),
);
const potentialReturn = computed(() =>
mode.value === 'parlay'
? stake.value * totalOdds.value
: items.value.length === 1
? stake.value * items.value[0].odds
: 0,
);
const hasSameMatch = computed(() => {
const matchIds = items.value.map((i) => i.matchId);
return new Set(matchIds).size !== matchIds.length;
});
return {
items,
stake,
mode,
count,
isParlay,
totalOdds,
potentialReturn,
hasSameMatch,
addItem,
removeItem,
clear,
};
});

View File

@@ -0,0 +1,58 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f1419;
color: #e8eaed;
min-height: 100vh;
}
:root {
--primary: #00a826;
--primary-dark: #008a1f;
--bg-card: #1a2332;
--bg-hover: #243044;
--text-muted: #8b95a5;
--border: #2d3a4d;
--danger: #ff4444;
}
a { color: inherit; text-decoration: none; }
button {
cursor: pointer;
border: none;
font-family: inherit;
}
input {
font-family: inherit;
background: var(--bg-card);
border: 1px solid var(--border);
color: #fff;
padding: 10px 12px;
border-radius: 6px;
width: 100%;
}
.btn-primary {
background: var(--primary);
color: #fff;
padding: 12px 24px;
border-radius: 6px;
font-weight: 600;
width: 100%;
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.odds-btn {
background: var(--bg-card);
border: 1px solid var(--border);
color: #fff;
padding: 8px 12px;
border-radius: 6px;
text-align: center;
min-width: 70px;
}
.odds-btn.selected { background: var(--primary); border-color: var(--primary); }
.odds-btn .label { font-size: 11px; color: var(--text-muted); }
.odds-btn .value { font-size: 15px; font-weight: 700; color: #ffd700; }
.card {
background: var(--bg-card);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
}

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
const router = useRouter();
const matches = ref<Match[]>([]);
interface Match {
id: string;
homeTeamName: string;
awayTeamName: string;
startTime: string;
leagueName: string;
markets?: Array<{ marketType: string; selections: Selection[] }>;
}
interface Selection {
id: string;
selectionCode: string;
selectionName: string;
odds: string;
oddsVersion: string;
}
onMounted(async () => {
const { data } = await api.get('/player/matches');
matches.value = data.data;
});
function goMatch(id: string) {
router.push(`/match/${id}`);
}
</script>
<template>
<div>
<h2 class="title">足球赛事</h2>
<div v-for="match in matches" :key="match.id" class="card">
<div class="league">{{ match.leagueName }}</div>
<div class="teams" @click="goMatch(match.id)">
{{ match.homeTeamName }} vs {{ match.awayTeamName }}
</div>
<div class="time">{{ new Date(match.startTime).toLocaleString() }}</div>
<div v-if="match.markets?.length" class="odds-row">
<template v-for="market in match.markets.filter(m => m.marketType === 'FT_1X2')" :key="market.marketType">
<div v-for="sel in market.selections" :key="sel.id" class="odds-btn" @click="goMatch(match.id)">
<div class="label">{{ sel.selectionName }}</div>
<div class="value">{{ sel.odds }}</div>
</div>
</template>
</div>
</div>
<div v-if="!matches.length" class="empty">暂无赛事</div>
</div>
</template>
<style scoped>
.title { font-size: 18px; margin-bottom: 16px; }
.league { font-size: 11px; color: var(--primary); margin-bottom: 4px; }
.teams { font-weight: 600; cursor: pointer; margin-bottom: 4px; }
.time { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; }
.odds-row { display: flex; gap: 8px; }
.empty { text-align: center; color: var(--text-muted); padding: 40px; }
</style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
const router = useRouter();
const home = ref<{ banners: unknown[]; hotMatches: Match[]; ticker: unknown[] } | null>(null);
interface Match {
id: string;
homeTeamName: string;
awayTeamName: string;
startTime: string;
isHot: boolean;
markets?: Array<{ marketType: string; selections: Array<{ id: string; selectionName: string; odds: string; oddsVersion: string }> }>;
}
onMounted(async () => {
const { data } = await api.get('/player/home');
home.value = data.data;
});
function goMatch(id: string) {
router.push(`/match/${id}`);
}
</script>
<template>
<div>
<div v-if="home?.banners?.length" class="banner card">
{{ (home.banners[0] as { translation?: { title?: string } })?.translation?.title || 'Welcome' }}
</div>
<div v-if="home?.ticker?.length" class="ticker">
{{ (home.ticker[0] as { translation?: { body?: string } })?.translation?.body }}
</div>
<h2 class="section-title">热门赛事</h2>
<div v-for="match in home?.hotMatches || []" :key="match.id" class="card match-card" @click="goMatch(match.id)">
<div class="match-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</div>
<div class="match-time">{{ new Date(match.startTime).toLocaleString() }}</div>
</div>
<div v-if="!home?.hotMatches?.length" class="empty">暂无赛事</div>
</div>
</template>
<style scoped>
.banner { background: linear-gradient(135deg, #1a472a, #0f1419); padding: 24px; font-size: 18px; font-weight: 600; }
.ticker { background: #243044; padding: 8px 12px; font-size: 12px; margin-bottom: 12px; border-radius: 4px; overflow: hidden; white-space: nowrap; }
.section-title { font-size: 16px; margin-bottom: 12px; }
.match-card { cursor: pointer; }
.match-card:hover { background: var(--bg-hover); }
.match-teams { font-weight: 600; margin-bottom: 4px; }
.match-time { font-size: 12px; color: var(--text-muted); }
.empty { text-align: center; color: var(--text-muted); padding: 40px; }
</style>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
const { t } = useI18n();
const auth = useAuthStore();
const router = useRouter();
const username = ref('player1');
const password = ref('Player@123');
const error = ref('');
const loading = ref(false);
async function submit() {
loading.value = true;
error.value = '';
try {
await auth.login(username.value, password.value);
router.push('/');
} catch (e: unknown) {
error.value = (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Login failed';
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="login-page">
<h1 class="logo">TheBet365</h1>
<form @submit.prevent="submit" class="login-form">
<label>{{ t('auth.username') }}</label>
<input v-model="username" required />
<label>{{ t('auth.password') }}</label>
<input v-model="password" type="password" required />
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" class="btn-primary" :disabled="loading">
{{ t('auth.login') }}
</button>
</form>
</div>
</template>
<style scoped>
.login-page {
min-height: 100vh; display: flex; flex-direction: column;
align-items: center; justify-content: center; padding: 24px;
}
.logo { color: var(--primary); font-size: 32px; margin-bottom: 32px; }
.login-form { width: 100%; max-width: 320px; display: flex; flex-direction: column; gap: 12px; }
label { font-size: 13px; color: var(--text-muted); }
.error { color: var(--danger); font-size: 13px; }
</style>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import api from '../api';
import { useBetSlipStore } from '../stores/betSlip';
const route = useRoute();
const slip = useBetSlipStore();
const match = ref<MatchDetail | null>(null);
interface MatchDetail {
id: string;
homeTeamName: string;
awayTeamName: string;
startTime: string;
markets: Market[];
}
interface Market {
id: string;
marketType: string;
period: string;
lineValue?: string;
selections: Selection[];
}
interface Selection {
id: string;
selectionCode: string;
selectionName: string;
odds: string;
oddsVersion: string;
}
const marketLabels: Record<string, string> = {
FT_1X2: '全场独赢',
FT_HANDICAP: '全场让球',
FT_OVER_UNDER: '全场大小',
FT_ODD_EVEN: '全场单双',
HT_1X2: '半场独赢',
FT_CORRECT_SCORE: '波胆',
};
onMounted(async () => {
const { data } = await api.get(`/player/matches/${route.params.id}`);
match.value = data.data;
});
function isSelected(id: string) {
return slip.items.some((i) => i.selectionId === id);
}
function toggleSelection(sel: Selection, market: Market) {
if (!match.value) return;
slip.addItem({
selectionId: sel.id,
oddsVersion: sel.oddsVersion,
matchId: match.value.id,
matchName: `${match.value.homeTeamName} vs ${match.value.awayTeamName}`,
selectionName: sel.selectionName,
odds: parseFloat(sel.odds),
marketType: market.marketType,
});
}
const groupedMarkets = computed(() => {
if (!match.value) return [];
return match.value.markets;
});
</script>
<template>
<div v-if="match">
<div class="match-header card">
<h2>{{ match.homeTeamName }} vs {{ match.awayTeamName }}</h2>
<p class="time">{{ new Date(match.startTime).toLocaleString() }}</p>
</div>
<div v-for="market in groupedMarkets" :key="market.id" class="card market-group">
<h3>{{ marketLabels[market.marketType] || market.marketType }}</h3>
<div class="selections">
<button
v-for="sel in market.selections"
:key="sel.id"
class="odds-btn"
:class="{ selected: isSelected(sel.id) }"
@click="toggleSelection(sel, market)"
>
<div class="label">{{ sel.selectionName }}</div>
<div class="value">{{ sel.odds }}</div>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.match-header h2 { font-size: 18px; margin-bottom: 4px; }
.time { color: var(--text-muted); font-size: 13px; }
.market-group h3 { font-size: 14px; margin-bottom: 12px; color: var(--text-muted); }
.selections { display: flex; flex-wrap: wrap; gap: 8px; }
</style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
const bets = ref<{ items: Bet[]; total: number }>({ items: [], total: 0 });
interface Bet {
betNo: string;
betType: string;
stake: string;
totalOdds: string;
potentialReturn: string;
actualReturn: string;
status: string;
placedAt: string;
selections: Array<{ selectionNameSnapshot: string; odds: string; resultStatus?: string }>;
}
onMounted(load);
async function load() {
const { data } = await api.get('/player/bets');
bets.value = data.data;
}
</script>
<template>
<div>
<h2>我的投注</h2>
<div v-for="bet in bets.items" :key="bet.betNo" class="card bet-card">
<div class="bet-header">
<span class="bet-no">{{ bet.betNo }}</span>
<span :class="['status', bet.status.toLowerCase()]">{{ bet.status }}</span>
</div>
<div v-for="(sel, i) in bet.selections" :key="i" class="sel">
{{ sel.selectionNameSnapshot }} @ {{ sel.odds }}
<span v-if="sel.resultStatus"> {{ sel.resultStatus }}</span>
</div>
<div class="bet-footer">
<span>{{ bet.betType }} · 投注 {{ bet.stake }}</span>
<span v-if="bet.status === 'WON'">返还 {{ bet.actualReturn }}</span>
<span v-else-if="bet.status === 'PENDING'">预计 {{ bet.potentialReturn }}</span>
</div>
<div class="time">{{ new Date(bet.placedAt).toLocaleString() }}</div>
</div>
<div v-if="!bets.items.length" class="empty">暂无投注</div>
</div>
</template>
<style scoped>
h2 { margin-bottom: 16px; }
.bet-header { display: flex; justify-content: space-between; margin-bottom: 8px; }
.bet-no { font-size: 12px; color: var(--text-muted); }
.status { font-size: 12px; font-weight: 600; }
.status.pending { color: #ff9800; }
.status.won { color: var(--primary); }
.status.lost { color: var(--danger); }
.sel { font-size: 13px; margin-bottom: 4px; }
.bet-footer { font-size: 13px; margin-top: 8px; display: flex; justify-content: space-between; }
.time { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.empty { text-align: center; color: var(--text-muted); padding: 40px; }
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
import api from '../api';
const { t, locale } = useI18n();
const auth = useAuthStore();
const router = useRouter();
const profile = ref<{ wallet?: { availableBalance: string; frozenBalance: string } } | null>(null);
const transactions = ref<unknown[]>([]);
onMounted(async () => {
const [prof, txns] = await Promise.all([
api.get('/player/profile'),
api.get('/player/wallet/transactions'),
]);
profile.value = prof.data.data;
transactions.value = txns.data.data.items;
});
async function changeLocale(code: string) {
locale.value = code;
localStorage.setItem('locale', code);
await api.post('/player/language', { locale: code });
}
function logout() {
auth.logout();
router.push('/login');
}
</script>
<template>
<div>
<div class="card profile-card">
<div class="username">{{ auth.user?.username }}</div>
<div class="balance-row">
<span>{{ t('wallet.balance') }}</span>
<span class="amount">{{ profile?.wallet?.availableBalance ?? '0' }}</span>
</div>
<div class="frozen">冻结: {{ profile?.wallet?.frozenBalance ?? '0' }}</div>
</div>
<div class="card">
<h3>语言</h3>
<div class="lang-btns">
<button @click="changeLocale('zh-CN')" :class="{ active: locale === 'zh-CN' }">中文</button>
<button @click="changeLocale('en-US')" :class="{ active: locale === 'en-US' }">English</button>
<button @click="changeLocale('ms-MY')" :class="{ active: locale === 'ms-MY' }">BM</button>
</div>
</div>
<div class="card">
<h3>账变记录</h3>
<div v-for="tx in transactions as Array<{ transactionType: string; amount: string; createdAt: string }>" :key="(tx as { transactionId?: string }).transactionId" class="tx-row">
<span>{{ tx.transactionType }}</span>
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">{{ tx.amount }}</span>
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
</div>
</div>
<button class="btn-logout" @click="logout">退出登录</button>
</div>
</template>
<style scoped>
.profile-card { margin-bottom: 12px; }
.username { font-size: 18px; font-weight: 600; margin-bottom: 12px; }
.balance-row { display: flex; justify-content: space-between; font-size: 16px; }
.amount { color: #ffd700; font-weight: 700; }
.frozen { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
h3 { font-size: 14px; margin-bottom: 12px; }
.lang-btns { display: flex; gap: 8px; }
.lang-btns button {
flex: 1; padding: 8px; background: var(--bg-hover); color: #fff;
border-radius: 6px; border: 1px solid var(--border);
}
.lang-btns button.active { border-color: var(--primary); color: var(--primary); }
.tx-row { display: flex; justify-content: space-between; font-size: 13px; padding: 8px 0; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.pos { color: var(--primary); }
.neg { color: var(--danger); }
.tx-time { width: 100%; font-size: 11px; color: var(--text-muted); }
.btn-logout { width: 100%; margin-top: 16px; padding: 12px; background: var(--bg-card); color: var(--danger); border-radius: 6px; border: 1px solid var(--border); }
</style>

5
apps/player/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<object, object, unknown>;
export default component;
}

11
apps/player/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:3000', changeOrigin: true },
},
},
});