diff --git a/apps/admin/package.json b/apps/admin/package.json index 686b7ba..c3c31ea 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -11,8 +11,10 @@ }, "dependencies": { "axios": "^1.7.9", + "echarts": "^6.1.0", "element-plus": "^2.9.3", "vue": "^3.5.13", + "vue-echarts": "^8.0.1", "vue-router": "^4.5.0" }, "devDependencies": { diff --git a/apps/admin/src/App.vue b/apps/admin/src/App.vue index e571fd1..a49b14e 100644 --- a/apps/admin/src/App.vue +++ b/apps/admin/src/App.vue @@ -2,3 +2,275 @@ import { RouterView } from 'vue-router'; + + diff --git a/apps/admin/src/components/RobotVerify.vue b/apps/admin/src/components/RobotVerify.vue new file mode 100644 index 0000000..bed4aff --- /dev/null +++ b/apps/admin/src/components/RobotVerify.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/apps/admin/src/components/dashboard/EChartPanel.vue b/apps/admin/src/components/dashboard/EChartPanel.vue new file mode 100644 index 0000000..e441a47 --- /dev/null +++ b/apps/admin/src/components/dashboard/EChartPanel.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/apps/admin/src/components/dashboard/echarts-setup.ts b/apps/admin/src/components/dashboard/echarts-setup.ts new file mode 100644 index 0000000..9d5f806 --- /dev/null +++ b/apps/admin/src/components/dashboard/echarts-setup.ts @@ -0,0 +1,22 @@ +import { use } from 'echarts/core'; +import { BarChart, LineChart, PieChart } from 'echarts/charts'; +import { + GridComponent, + TooltipComponent, + LegendComponent, + TitleComponent, + GraphicComponent, +} from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; + +use([ + CanvasRenderer, + BarChart, + LineChart, + PieChart, + GridComponent, + TooltipComponent, + LegendComponent, + TitleComponent, + GraphicComponent, +]); diff --git a/apps/admin/src/layouts/ManageLayout.vue b/apps/admin/src/layouts/ManageLayout.vue index 4f88fb2..33e4ea0 100644 --- a/apps/admin/src/layouts/ManageLayout.vue +++ b/apps/admin/src/layouts/ManageLayout.vue @@ -8,24 +8,32 @@ const router = useRouter(); const auth = useAuthStore(); const adminMenus = [ - { path: '/', label: '控制台' }, - { path: '/users', label: '玩家管理' }, - { path: '/agents', label: '代理管理' }, - { path: '/matches', label: '赛事管理' }, - { path: '/bets', label: '注单管理' }, - { path: '/cashback', label: '返水管理' }, - { path: '/audit', label: '操作日志' }, + { path: '/', label: '控制台' }, + { path: '/users', label: '玩家管理' }, + { path: '/agents', label: '代理管理' }, + { path: '/matches', label: '赛事管理' }, + { path: '/bets', label: '注单管理' }, + { path: '/cashback', label: '返水管理' }, + { path: '/audit', label: '操作日志' }, ]; const agentMenus = [ - { path: '/', label: '概览' }, - { path: '/my-players', label: '直属玩家' }, - { path: '/sub-agents', label: '下级代理' }, - { path: '/my-bets', label: '注单查询' }, + { path: '/', label: '概览' }, + { path: '/my-players', label: '直属玩家' }, + { path: '/sub-agents', label: '下级代理' }, + { path: '/my-bets', label: '注单查询' }, ]; const menus = computed(() => (auth.isAdmin.value ? adminMenus : agentMenus)); +const currentLabel = computed(() => + menus.value.find(m => m.path === route.path)?.label ?? '' +); + +const userInitial = computed(() => + (auth.user?.username ?? '').charAt(0).toUpperCase() +); + function logout() { auth.logout(); router.push('/login'); @@ -33,33 +41,224 @@ function logout() { + + diff --git a/apps/admin/src/utils/bet-labels.ts b/apps/admin/src/utils/bet-labels.ts new file mode 100644 index 0000000..7ea2f23 --- /dev/null +++ b/apps/admin/src/utils/bet-labels.ts @@ -0,0 +1,60 @@ +export type BetTagType = '' | 'info' | 'success' | 'warning' | 'danger'; + +const STATUS_LABELS: Record = { + PENDING: '待结算', + WON: '已赢', + LOST: '已输', + VOID: '作废', + REFUNDED: '已退款', +}; + +const STATUS_TAG: Record = { + PENDING: 'warning', + WON: 'success', + LOST: 'danger', + VOID: 'info', + REFUNDED: 'info', +}; + +const TYPE_LABELS: Record = { + SINGLE: '单关', + PARLAY: '串关', +}; + +export function betStatusLabel(status: string) { + return STATUS_LABELS[status] ?? status; +} + +export function betStatusTagType(status: string): BetTagType { + return STATUS_TAG[status] ?? 'info'; +} + +export function betTypeLabel(betType: string) { + return TYPE_LABELS[betType] ?? betType; +} + +const SETTLEMENT_LABELS: Record = { + PENDING: '待结算', + SETTLED: '已结算', + VOID: '已作废', +}; + +export function betSettlementLabel(v: string | null | undefined) { + if (!v) return '—'; + return SETTLEMENT_LABELS[v] ?? v; +} + +export const BET_STATUS_OPTIONS = [ + { value: '', label: '全部' }, + { value: 'PENDING', label: '待结算' }, + { value: 'WON', label: '已赢' }, + { value: 'LOST', label: '已输' }, + { value: 'VOID', label: '作废' }, + { value: 'REFUNDED', label: '已退款' }, +]; + +export const BET_TYPE_OPTIONS = [ + { value: '', label: '全部' }, + { value: 'SINGLE', label: '单关' }, + { value: 'PARLAY', label: '串关' }, +]; diff --git a/apps/admin/src/utils/dashboard-charts.ts b/apps/admin/src/utils/dashboard-charts.ts new file mode 100644 index 0000000..a00a29e --- /dev/null +++ b/apps/admin/src/utils/dashboard-charts.ts @@ -0,0 +1,290 @@ +import type { EChartsOption } from 'echarts'; +import { formatAmount, formatAmountFull } from './format-amount'; + +const tooltipBase = { + backgroundColor: '#141414', + borderColor: '#2a2a2a', + textStyle: { color: '#e0e0e0', fontSize: 12 }, +}; + +const axisLabel = { color: '#888', fontSize: 11 }; +const splitLine = { lineStyle: { color: '#252525' } }; + +export type ChartSeries = { name: string; color: string; values: number[] }; +export type PieSegment = { label: string; value: number; color: string }; + +export function buildBarChartOption( + labels: string[], + series: ChartSeries[], + opts?: { amountAxis?: boolean }, +): EChartsOption { + const amountAxis = opts?.amountAxis !== false; + return { + backgroundColor: 'transparent', + color: series.map((s) => s.color), + tooltip: { + ...tooltipBase, + trigger: 'axis', + axisPointer: { type: 'shadow' }, + valueFormatter: (v) => + amountAxis ? formatAmountFull(Number(v)) : fmtCount(Number(v)), + }, + legend: { + bottom: 0, + itemWidth: 10, + itemHeight: 10, + textStyle: { color: '#999', fontSize: 11 }, + }, + grid: { left: 52, right: 12, top: 20, bottom: 48 }, + xAxis: { + type: 'category', + data: labels, + axisLabel, + axisLine: { lineStyle: { color: '#333' } }, + axisTick: { show: false }, + }, + yAxis: { + type: 'value', + axisLabel: { + ...axisLabel, + formatter: (v: number) => (amountAxis ? formatAmount(v) : fmtCount(v)), + }, + splitLine, + }, + series: series.map((s) => ({ + name: s.name, + type: 'bar', + data: s.values, + itemStyle: { color: s.color, borderRadius: [4, 4, 0, 0] }, + barMaxWidth: 22, + emphasis: { focus: 'series' }, + })), + }; +} + +export function buildMultiLineChartOption( + labels: string[], + series: ChartSeries[], +): EChartsOption { + return { + backgroundColor: 'transparent', + color: series.map((s) => s.color), + tooltip: { + ...tooltipBase, + trigger: 'axis', + valueFormatter: (v) => formatAmountFull(Number(v)), + }, + legend: { + bottom: 0, + itemWidth: 10, + itemHeight: 10, + textStyle: { color: '#999', fontSize: 11 }, + }, + grid: { left: 52, right: 12, top: 20, bottom: 48 }, + xAxis: { + type: 'category', + data: labels, + boundaryGap: false, + axisLabel, + axisLine: { lineStyle: { color: '#333' } }, + }, + yAxis: { + type: 'value', + axisLabel: { ...axisLabel, formatter: (v: number) => formatAmount(v) }, + splitLine, + }, + series: series.map((s) => ({ + name: s.name, + type: 'line', + smooth: true, + symbol: 'circle', + symbolSize: 6, + data: s.values, + itemStyle: { color: s.color }, + lineStyle: { color: s.color, width: 2 }, + areaStyle: { color: s.color, opacity: 0.12 }, + })), + }; +} + +export function buildPieChartOption( + title: string, + segments: PieSegment[], +): EChartsOption { + const data = segments.map((s) => ({ + name: s.label, + value: s.value, + itemStyle: { color: s.color }, + })); + + return { + backgroundColor: 'transparent', + tooltip: { + ...tooltipBase, + trigger: 'item', + formatter: '{b}:{c}({d}%)', + }, + legend: { + orient: 'vertical', + right: 4, + top: 'middle', + itemWidth: 8, + itemHeight: 8, + textStyle: { color: '#999', fontSize: 11 }, + }, + series: [ + { + name: title, + type: 'pie', + radius: ['42%', '68%'], + center: ['36%', '50%'], + avoidLabelOverlap: true, + itemStyle: { borderRadius: 4, borderColor: '#111', borderWidth: 2 }, + label: { + show: segments.length > 0, + color: '#bbb', + fontSize: 11, + formatter: '{b}\n{d}%', + }, + labelLine: { lineStyle: { color: '#444' } }, + emphasis: { + label: { fontSize: 12, fontWeight: 'bold' }, + scaleSize: 6, + }, + data: data.length ? data : [{ name: '暂无数据', value: 1, itemStyle: { color: '#333' } }], + }, + ], + }; +} + +function fmtCount(v: number) { + return v.toLocaleString('zh-CN', { maximumFractionDigits: 0 }); +} + +/** 7 日金额折线 + 注单柱(双 Y 轴),一图看清趋势 */ +export function buildCombinedTrendOption( + labels: string[], + amountSeries: ChartSeries[], + betCounts: number[], +): EChartsOption { + return { + backgroundColor: 'transparent', + color: [...amountSeries.map((s) => s.color), '#fb923c'], + tooltip: { + ...tooltipBase, + trigger: 'axis', + formatter(params) { + const items = Array.isArray(params) ? params : [params]; + return items + .map((p) => { + const v = Number(p.value ?? 0); + const isCount = p.seriesName === '注单笔数'; + const val = isCount ? `${fmtCount(v)} 笔` : formatAmountFull(v); + return `${p.marker ?? ''}${p.seriesName}:${val}`; + }) + .join('
'); + }, + }, + legend: { + top: 0, + itemWidth: 10, + itemHeight: 10, + textStyle: { color: '#999', fontSize: 11 }, + }, + grid: { left: 56, right: 48, top: 36, bottom: 28 }, + xAxis: { + type: 'category', + data: labels, + boundaryGap: true, + axisLabel, + axisLine: { lineStyle: { color: '#333' } }, + }, + yAxis: [ + { + type: 'value', + name: '金额', + nameTextStyle: { color: '#666', fontSize: 10 }, + axisLabel: { ...axisLabel, formatter: (v: number) => formatAmount(v) }, + splitLine, + }, + { + type: 'value', + name: '笔数', + nameTextStyle: { color: '#666', fontSize: 10 }, + axisLabel: { ...axisLabel, formatter: (v: number) => fmtCount(v) }, + splitLine: { show: false }, + }, + ], + series: [ + ...amountSeries.map((s) => ({ + name: s.name, + type: 'line' as const, + yAxisIndex: 0, + smooth: true, + symbol: 'circle', + symbolSize: 5, + data: s.values, + itemStyle: { color: s.color }, + lineStyle: { color: s.color, width: 2 }, + })), + { + name: '注单笔数', + type: 'bar', + yAxisIndex: 1, + data: betCounts, + barMaxWidth: 14, + itemStyle: { color: 'rgba(251, 146, 60, 0.45)', borderRadius: [3, 3, 0, 0] }, + }, + ], + }; +} + +/** 三个饼图并排,占一张图 */ +export function buildTriplePieOption( + blocks: { title: string; segments: PieSegment[] }[], +): EChartsOption { + const slots = [ + { center: ['18%', '58%'] as [string, string], titleLeft: '14%' }, + { center: ['50%', '58%'] as [string, string], titleLeft: '46%' }, + { center: ['82%', '58%'] as [string, string], titleLeft: '78%' }, + ]; + + return { + backgroundColor: 'transparent', + tooltip: { + ...tooltipBase, + trigger: 'item', + formatter: '{b}:{c}({d}%)', + }, + graphic: blocks.map((b, i) => ({ + type: 'text' as const, + left: slots[i]?.titleLeft ?? '50%', + top: '6%', + style: { + text: b.title, + fill: '#aaa', + fontSize: 12, + fontWeight: 600, + textAlign: 'center', + }, + })), + series: blocks.map((b, i) => { + const data = b.segments.map((s) => ({ + name: s.label, + value: s.value, + itemStyle: { color: s.color }, + })); + return { + name: b.title, + type: 'pie' as const, + radius: ['32%', '48%'], + center: slots[i]?.center ?? ['50%', '55%'], + label: { show: false }, + labelLine: { show: false }, + data: data.length + ? data + : [{ name: '暂无', value: 1, itemStyle: { color: '#333' } }], + }; + }), + }; +} diff --git a/apps/admin/src/utils/format-amount.ts b/apps/admin/src/utils/format-amount.ts new file mode 100644 index 0000000..3dfe808 --- /dev/null +++ b/apps/admin/src/utils/format-amount.ts @@ -0,0 +1,43 @@ +/** 完整数字(悬停提示、详情对照) */ +export function formatAmountFull(value: string | number | null | undefined): string { + const n = Number(value); + if (!Number.isFinite(n)) return '—'; + return n.toLocaleString('zh-CN', { maximumFractionDigits: 4 }); +} + +function unitPart(abs: number, divisor: number, maxDecimals: number): string { + return (abs / divisor).toLocaleString('zh-CN', { + minimumFractionDigits: 0, + maximumFractionDigits: maxDecimals, + }); +} + +/** + * 金额展示:≥1万用「万」,≥1亿用「亿」,避免表格撑破布局 + */ +export function formatAmount( + value: string | number | null | undefined, + maxDecimals = 2, +): string { + const n = Number(value); + if (!Number.isFinite(n)) return '—'; + + const sign = n < 0 ? '-' : ''; + const abs = Math.abs(n); + + if (abs >= 1e8) { + return `${sign}${unitPart(abs, 1e8, maxDecimals)}亿`; + } + if (abs >= 1e4) { + return `${sign}${unitPart(abs, 1e4, maxDecimals)}万`; + } + + return n.toLocaleString('zh-CN', { + minimumFractionDigits: 0, + maximumFractionDigits: maxDecimals, + }); +} + +export function shouldCompactAmount(value: string | number | null | undefined): boolean { + return Math.abs(Number(value) || 0) >= 1e4; +} diff --git a/apps/admin/src/views/Agents.vue b/apps/admin/src/views/Agents.vue index 9131df1..aa488b0 100644 --- a/apps/admin/src/views/Agents.vue +++ b/apps/admin/src/views/Agents.vue @@ -2,51 +2,468 @@ import { ref, onMounted } from 'vue'; import api from '../api'; import { ElMessage } from 'element-plus'; +import { + emptyAgentCreateForm, + emptyAgentEditForm, + editFormFromAgentDetail, + buildCreateAgentPayload, + type AgentRow, + type AgentDetail, + type AgentCreateForm, + type AgentEditForm, +} from './agent-form'; +import { + formatAmount, + formatAmountFull, + shouldCompactAmount as shouldCompact, +} from '../utils/format-amount'; -const agents = ref([]); -const form = ref({ username: '', password: 'Agent@123', creditLimit: 50000 }); +const agents = ref([]); +const total = ref(0); +const page = ref(1); +const pageSize = ref(10); +const keyword = ref(''); + +function creditLine(row: AgentRow) { + return `${formatAmount(row.creditLimit)} / ${formatAmount(row.usedCredit)} / ${formatAmount(row.availableCredit)}`; +} + +function creditLineFull(row: AgentRow) { + return `${formatAmountFull(row.creditLimit)} / ${formatAmountFull(row.usedCredit)} / ${formatAmountFull(row.availableCredit)}`; +} + +const createVisible = ref(false); +const editVisible = ref(false); +const detailVisible = ref(false); +const creditVisible = ref(false); +const createLoading = ref(false); +const editLoading = ref(false); +const creditLoading = ref(false); + +const createForm = ref(emptyAgentCreateForm()); +const editForm = ref(emptyAgentEditForm()); +const detail = ref(null); +const editingId = ref(''); + +const creditForm = ref({ amount: 10000, remark: '' }); 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()}`, + const { data } = await api.get('/admin/agents', { + params: { + page: page.value, + pageSize: pageSize.value, + keyword: keyword.value.trim() || undefined, + }, }); - ElMessage.success('额度已调整'); + agents.value = data.data.items as AgentRow[]; + total.value = data.data.total; +} + +function onPageChange(p: number) { + page.value = p; load(); } + +function onSizeChange(size: number) { + pageSize.value = size; + page.value = 1; + load(); +} + +function openCreate() { + createForm.value = emptyAgentCreateForm(); + createVisible.value = true; +} + +async function openDetail(userId: string) { + const { data } = await api.get(`/admin/agents/${userId}`); + detail.value = data.data as AgentDetail; + detailVisible.value = true; +} + +async function openEdit(userId: string) { + const { data } = await api.get(`/admin/agents/${userId}`); + const d = data.data as AgentDetail; + editingId.value = userId; + editForm.value = editFormFromAgentDetail(d); + editVisible.value = true; +} + +function openCredit(row: AgentRow) { + editingId.value = row.userId; + creditForm.value = { amount: 10000, remark: '' }; + creditVisible.value = true; +} + +async function submitCreate() { + let payload: ReturnType; + try { + payload = buildCreateAgentPayload(createForm.value); + } catch (e) { + ElMessage.warning(e instanceof Error ? e.message : '请检查表单'); + return; + } + createLoading.value = true; + try { + await api.post('/admin/agents', payload); + ElMessage.success('一级代理已创建'); + createVisible.value = false; + load(); + } catch (e: unknown) { + const err = e as { response?: { data?: { error?: string } } }; + ElMessage.error(err.response?.data?.error ?? '创建失败'); + } finally { + createLoading.value = false; + } +} + +async function submitEdit() { + editLoading.value = true; + try { + await api.put(`/admin/agents/${editingId.value}`, { + status: editForm.value.status, + phone: editForm.value.phone.trim() || undefined, + email: editForm.value.email.trim() || undefined, + cashbackRate: editForm.value.cashbackRate, + }); + ElMessage.success('已保存'); + editVisible.value = false; + load(); + } catch (e: unknown) { + const err = e as { response?: { data?: { error?: string } } }; + ElMessage.error(err.response?.data?.error ?? '保存失败'); + } finally { + editLoading.value = false; + } +} + +async function submitCredit() { + if (creditForm.value.amount === 0) { + ElMessage.warning('调整金额不能为 0'); + return; + } + creditLoading.value = true; + try { + await api.post(`/admin/agents/${editingId.value}/credit`, { + amount: creditForm.value.amount, + requestId: `credit-${editingId.value}-${Date.now()}`, + remark: creditForm.value.remark || undefined, + }); + ElMessage.success('授信已调整'); + creditVisible.value = false; + load(); + } catch (e: unknown) { + const err = e as { response?: { data?: { error?: string } } }; + ElMessage.error(err.response?.data?.error ?? '调整失败'); + } finally { + creditLoading.value = false; + } +} + +function formatTime(v: string) { + if (!v) return '—'; + return new Date(v).toLocaleString('zh-CN'); +} + +function statusTagType(s: string) { + return s === 'ACTIVE' ? 'success' : 'warning'; +} + +function statusLabel(s: string) { + return s === 'ACTIVE' ? '正常' : s === 'SUSPENDED' ? '停用' : s; +} + +function creditTypeLabel(t: string) { + if (t === 'CREDIT_INCREASE') return '增加'; + if (t === 'CREDIT_DECREASE') return '减少'; + return t; +} + + diff --git a/apps/admin/src/views/Audit.vue b/apps/admin/src/views/Audit.vue index f738b7c..7995773 100644 --- a/apps/admin/src/views/Audit.vue +++ b/apps/admin/src/views/Audit.vue @@ -3,21 +3,94 @@ import { ref, onMounted } from 'vue'; import api from '../api'; const logs = ref([]); +const total = ref(0); +const page = ref(1); +const pageSize = ref(10); +const filterModule = ref(''); -onMounted(async () => { - const { data } = await api.get('/admin/audit-logs'); +onMounted(load); + +async function load() { + const { data } = await api.get('/admin/audit-logs', { + params: { + page: page.value, + pageSize: pageSize.value, + module: filterModule.value || undefined, + }, + }); logs.value = data.data.items; -}); + total.value = data.data.total; +} + +function onPageChange(p: number) { + page.value = p; + load(); +} + +function onSizeChange(size: number) { + pageSize.value = size; + page.value = 1; + load(); +} + + diff --git a/apps/admin/src/views/Bets.vue b/apps/admin/src/views/Bets.vue index 6997ac8..8fbcaa6 100644 --- a/apps/admin/src/views/Bets.vue +++ b/apps/admin/src/views/Bets.vue @@ -1,24 +1,319 @@ + + diff --git a/apps/admin/src/views/Cashback.vue b/apps/admin/src/views/Cashback.vue index 1a8f5e3..07a5ce2 100644 --- a/apps/admin/src/views/Cashback.vue +++ b/apps/admin/src/views/Cashback.vue @@ -21,19 +21,63 @@ async function confirm() { if (!preview.value?.batch) return; await api.post(`/admin/cashbacks/${(preview.value.batch as { id: string }).id}/confirm`); ElMessage.success('返水已发放'); + preview.value = null; } + + diff --git a/apps/admin/src/views/Dashboard.vue b/apps/admin/src/views/Dashboard.vue index 481ff89..ad82e27 100644 --- a/apps/admin/src/views/Dashboard.vue +++ b/apps/admin/src/views/Dashboard.vue @@ -1,21 +1,329 @@ + + diff --git a/apps/admin/src/views/Login.vue b/apps/admin/src/views/Login.vue index 0ca10b4..70a761e 100644 --- a/apps/admin/src/views/Login.vue +++ b/apps/admin/src/views/Login.vue @@ -4,6 +4,8 @@ import { useRoute, useRouter } from 'vue-router'; import api from '../api'; import { ElMessage } from 'element-plus'; import { useAuthStore, type StaffUser } from '../stores/auth'; +import RobotVerify from '../components/RobotVerify.vue'; +import bgImage from '../assets/images/bg.png'; const router = useRouter(); const route = useRoute(); @@ -11,17 +13,37 @@ const auth = useAuthStore(); const form = ref({ username: '', password: '' }); const loading = ref(false); +const captchaRef = ref | null>(null); + +async function quickLogin(username: string, password: string) { + loading.value = true; + try { + const { data } = await api.post('/manage/auth/login', { username, password }); + const payload = data.data as { token: string; user: StaffUser }; + auth.setSession(payload.token, payload.user); + router.push((route.query.redirect as string) || '/'); + } catch { + ElMessage.error('快速登录失败'); + } finally { + loading.value = false; + } +} async function login() { + if (!captchaRef.value?.validate()) { + ElMessage.error('验证码错误,请重试'); + captchaRef.value?.refresh(); + return; + } loading.value = true; try { const { data } = await api.post('/manage/auth/login', form.value); const payload = data.data as { token: string; user: StaffUser }; auth.setSession(payload.token, payload.user); - const redirect = (route.query.redirect as string) || '/'; - router.push(redirect); + router.push((route.query.redirect as string) || '/'); } catch { ElMessage.error('登录失败,请检查账号与密码'); + captchaRef.value?.refresh(); } finally { loading.value = false; } @@ -29,28 +51,188 @@ async function login() { + + diff --git a/apps/admin/src/views/Matches.vue b/apps/admin/src/views/Matches.vue index fd74902..0fa408d 100644 --- a/apps/admin/src/views/Matches.vue +++ b/apps/admin/src/views/Matches.vue @@ -1,31 +1,185 @@ + + diff --git a/apps/admin/src/views/Settlement.vue b/apps/admin/src/views/Settlement.vue index 43092c3..f9fdb77 100644 --- a/apps/admin/src/views/Settlement.vue +++ b/apps/admin/src/views/Settlement.vue @@ -1,5 +1,5 @@ + + diff --git a/apps/admin/src/views/Users.vue b/apps/admin/src/views/Users.vue index fde65bb..1fc3707 100644 --- a/apps/admin/src/views/Users.vue +++ b/apps/admin/src/views/Users.vue @@ -1,38 +1,585 @@ + + + + diff --git a/apps/admin/src/views/agent-form.ts b/apps/admin/src/views/agent-form.ts new file mode 100644 index 0000000..0c82b7a --- /dev/null +++ b/apps/admin/src/views/agent-form.ts @@ -0,0 +1,96 @@ +export interface AgentCreateForm { + username: string; + password: string; + confirmPassword: string; + creditLimit: number; + cashbackRate: number; + phone: string; + email: string; +} + +export interface AgentEditForm { + status: string; + phone: string; + email: string; + cashbackRate: number; +} + +export interface AgentRow { + userId: string; + username: string; + userStatus: string; + level: number; + status: string; + creditLimit: string; + usedCredit: string; + availableCredit: string; + directPlayerCount: number; + cashbackRate: string; + phone: string | null; + email: string | null; + locale: string; + createdAt: string; +} + +export interface AgentDetail extends AgentRow { + parentAgentId: string | null; + parentUsername: string | null; + directPlayerLiability: string; + childAgentExposure: string; + lastLoginAt: string | null; + updatedAt: string; + recentCreditTransactions: { + id: string; + transactionType: string; + amount: string; + creditBefore: string; + creditAfter: string; + remark: string | null; + createdAt: string; + }[]; +} + +export function emptyAgentCreateForm(): AgentCreateForm { + return { + username: '', + password: 'Agent@123', + confirmPassword: 'Agent@123', + creditLimit: 50000, + cashbackRate: 0, + phone: '', + email: '', + }; +} + +export function emptyAgentEditForm(): AgentEditForm { + return { + status: 'ACTIVE', + phone: '', + email: '', + cashbackRate: 0, + }; +} + +export function editFormFromAgentDetail(d: AgentDetail): AgentEditForm { + return { + status: d.status, + phone: d.phone ?? '', + email: d.email ?? '', + cashbackRate: Number(d.cashbackRate), + }; +} + +export function buildCreateAgentPayload(form: AgentCreateForm) { + if (!form.username.trim()) throw new Error('请填写用户名'); + if (form.password.length < 8) throw new Error('密码至少 8 位'); + if (form.password !== form.confirmPassword) throw new Error('两次密码不一致'); + if (form.creditLimit < 0) throw new Error('授信额度不能为负'); + return { + username: form.username.trim(), + password: form.password, + creditLimit: form.creditLimit, + cashbackRate: form.cashbackRate, + phone: form.phone.trim() || undefined, + email: form.email.trim() || undefined, + }; +} diff --git a/apps/admin/src/views/agent/Bets.vue b/apps/admin/src/views/agent/Bets.vue index a39e110..8c5e580 100644 --- a/apps/admin/src/views/agent/Bets.vue +++ b/apps/admin/src/views/agent/Bets.vue @@ -1,25 +1,105 @@ + + diff --git a/apps/admin/src/views/agent/Dashboard.vue b/apps/admin/src/views/agent/Dashboard.vue index 0e5415a..ddc10ef 100644 --- a/apps/admin/src/views/agent/Dashboard.vue +++ b/apps/admin/src/views/agent/Dashboard.vue @@ -1,6 +1,7 @@ + + diff --git a/apps/admin/src/views/agent/Players.vue b/apps/admin/src/views/agent/Players.vue index 351d1b7..b873d80 100644 --- a/apps/admin/src/views/agent/Players.vue +++ b/apps/admin/src/views/agent/Players.vue @@ -2,6 +2,7 @@ import { ref, onMounted } from 'vue'; import api from '../../api'; import { ElMessage } from 'element-plus'; +import { formatAmount, formatAmountFull } from '../../utils/format-amount'; const players = ref([]); const form = ref({ username: '', password: 'Player@123' }); @@ -41,28 +42,97 @@ async function withdraw(playerId: string, amount: number) { + + diff --git a/apps/admin/src/views/agent/SubAgents.vue b/apps/admin/src/views/agent/SubAgents.vue index f326f79..14f408c 100644 --- a/apps/admin/src/views/agent/SubAgents.vue +++ b/apps/admin/src/views/agent/SubAgents.vue @@ -2,6 +2,7 @@ import { ref, onMounted } from 'vue'; import api from '../../api'; import { ElMessage } from 'element-plus'; +import { formatAmount, formatAmountFull } from '../../utils/format-amount'; const agents = ref([]); const form = ref({ username: '', password: 'Agent@123', creditLimit: 10000 }); @@ -21,19 +22,58 @@ async function create() { + + diff --git a/apps/admin/src/views/bet-form.ts b/apps/admin/src/views/bet-form.ts new file mode 100644 index 0000000..5f4faa7 --- /dev/null +++ b/apps/admin/src/views/bet-form.ts @@ -0,0 +1,40 @@ +export interface BetListRow { + id: string; + betNo: string; + userId: string; + username: string; + parentUsername: string | null; + agentId: string | null; + betType: string; + stake: string; + totalOdds: string | null; + potentialReturn: string | null; + actualReturn: string; + status: string; + settlementStatus: string | null; + currency: string; + placedAt: string; + settledAt: string | null; + selectionCount: number; +} + +export interface BetSelectionDetail { + id: string; + matchId: string | null; + marketType: string; + period: string | null; + selectionName: string; + handicapLine: string | null; + totalLine: string | null; + odds: string; + resultStatus: string | null; + effectiveOdds: string | null; + sortOrder: number; +} + +export interface BetDetail extends BetListRow { + requestId: string; + createdAt: string; + updatedAt: string; + selections: BetSelectionDetail[]; +} diff --git a/apps/admin/src/views/dashboard-types.ts b/apps/admin/src/views/dashboard-types.ts new file mode 100644 index 0000000..89dd6a7 --- /dev/null +++ b/apps/admin/src/views/dashboard-types.ts @@ -0,0 +1,71 @@ +export interface DashboardTrendDay { + date: string; + label: string; + betCount: number; + stake: string; + payout: string; + ggr: string; +} + +export interface AdminDashboard { + generatedAt: string; + trend7d: DashboardTrendDay[]; + today: { + betCount: number; + stake: string; + payout: string; + ggr: string; + newPlayers: number; + }; + yesterday: { + betCount: number; + stake: string; + payout: string; + ggr: string; + }; + users: { + playersTotal: number; + playersActive: number; + playersSuspended: number; + playersDirect: number; + agentsTotal: number; + agentsActive: number; + }; + wallets: { + totalAvailable: string; + totalFrozen: string; + playerWalletCount: number; + }; + agents: { + totalCreditLimit: string; + totalUsedCredit: string; + totalAvailableCredit: string; + }; + matches: { + total: number; + draft: number; + published: number; + closed: number; + cancelled: number; + pendingSettlement: number; + settled: number; + }; + bets: { + pendingTotal: number; + todayByStatus: Record; + }; + recentBets: { + betNo: string; + username: string; + stake: string; + status: string; + placedAt: string; + }[]; + recentPlayers: { + id: string; + username: string; + status: string; + parentUsername: string | null; + createdAt: string; + }[]; +} diff --git a/apps/admin/src/views/match-form.ts b/apps/admin/src/views/match-form.ts new file mode 100644 index 0000000..705e01b --- /dev/null +++ b/apps/admin/src/views/match-form.ts @@ -0,0 +1,78 @@ +/** 后台手动新增赛事(投注平台最小字段) */ + +export interface MatchCreateForm { + leagueEn: string; + leagueZh: string; + startTime: string; + homeTeamZh: string; + homeTeamEn: string; + awayTeamZh: string; + awayTeamEn: string; + isHot: boolean; +} + +export function emptyMatchForm(): MatchCreateForm { + return { + leagueEn: 'FIFA World Cup 2026', + leagueZh: '2026 世界杯', + startTime: '', + homeTeamZh: '', + homeTeamEn: '', + awayTeamZh: '', + awayTeamEn: '', + isHot: false, + }; +} + +export type AdminMatchDetail = { + id: string; + status: string; + isOutright: boolean; + isHot: boolean; + startTime: string; + leagueEn: string; + leagueZh: string; + homeTeamEn: string; + homeTeamZh: string; + awayTeamEn: string; + awayTeamZh: string; + matchName: string; +}; + +export function formFromDetail(d: AdminMatchDetail): MatchCreateForm { + return { + leagueEn: d.leagueEn, + leagueZh: d.leagueZh, + startTime: d.startTime, + homeTeamZh: d.homeTeamZh, + homeTeamEn: d.homeTeamEn, + awayTeamZh: d.awayTeamZh, + awayTeamEn: d.awayTeamEn, + isHot: d.isHot, + }; +} + +export function buildPlatformPayload(form: MatchCreateForm) { + if (!form.startTime.trim()) { + throw new Error('请填写开赛时间'); + } + const homeOk = form.homeTeamZh.trim() || form.homeTeamEn.trim(); + const awayOk = form.awayTeamZh.trim() || form.awayTeamEn.trim(); + if (!homeOk || !awayOk) { + throw new Error('请填写主客队名称(中文或英文至少一项)'); + } + if (!form.leagueZh.trim() && !form.leagueEn.trim()) { + throw new Error('请填写联赛名称'); + } + + return { + leagueEn: form.leagueEn.trim(), + leagueZh: form.leagueZh.trim(), + homeTeamEn: form.homeTeamEn.trim(), + homeTeamZh: form.homeTeamZh.trim(), + awayTeamEn: form.awayTeamEn.trim(), + awayTeamZh: form.awayTeamZh.trim(), + startTime: form.startTime.trim(), + isHot: form.isHot, + }; +} diff --git a/apps/admin/src/views/user-form.ts b/apps/admin/src/views/user-form.ts new file mode 100644 index 0000000..f7aca63 --- /dev/null +++ b/apps/admin/src/views/user-form.ts @@ -0,0 +1,120 @@ +export interface PlayerCreateForm { + username: string; + password: string; + confirmPassword: string; + parentId: string; + phone: string; + email: string; + initialDeposit: number; + remark: string; +} + +export interface PlayerEditForm { + id: string; + username: string; + status: string; + parentId: string; + parentUsername: string | null; + availableBalance: string; + frozenBalance: string; + betCount: number; + totalStake: string; + totalReturn: string; + createdAt: string; + lastLoginAt: string | null; + loginFailCount: number; + phone: string; + email: string; +} + +export interface PlayerRow { + id: string; + username: string; + status: string; + locale: string; + parentId: string | null; + parentUsername: string | null; + phone: string | null; + email: string | null; + availableBalance: string; + frozenBalance: string; + lastLoginAt: string | null; + betCount: number; + totalStake: string; + totalReturn: string; + createdAt: string; +} + +export interface PlayerDetail extends PlayerRow { + loginFailCount: number; + lockedUntil: string | null; + updatedAt: string; +} + +export function emptyPlayerCreateForm(): PlayerCreateForm { + return { + username: '', + password: 'Player@123', + confirmPassword: 'Player@123', + parentId: '', + phone: '', + email: '', + initialDeposit: 0, + remark: '', + }; +} + +export function emptyPlayerEditForm(): PlayerEditForm { + return { + id: '', + username: '', + status: 'ACTIVE', + parentId: '', + parentUsername: null, + availableBalance: '0', + frozenBalance: '0', + betCount: 0, + totalStake: '0', + totalReturn: '0', + createdAt: '', + lastLoginAt: null, + loginFailCount: 0, + phone: '', + email: '', + }; +} + +export function editFormFromDetail(d: PlayerDetail): PlayerEditForm { + return { + id: d.id, + username: d.username, + status: d.status, + parentId: d.parentId ?? '', + parentUsername: d.parentUsername, + availableBalance: d.availableBalance, + frozenBalance: d.frozenBalance, + betCount: d.betCount, + totalStake: d.totalStake, + totalReturn: d.totalReturn, + createdAt: d.createdAt, + lastLoginAt: d.lastLoginAt, + loginFailCount: d.loginFailCount, + phone: d.phone ?? '', + email: d.email ?? '', + }; +} + +export function buildCreatePlayerPayload(form: PlayerCreateForm) { + if (!form.username.trim()) throw new Error('请填写用户名'); + if (form.password.length < 8) throw new Error('密码至少 8 位'); + if (form.password !== form.confirmPassword) throw new Error('两次密码不一致'); + return { + username: form.username.trim(), + password: form.password, + parentId: form.parentId || undefined, + phone: form.phone.trim() || undefined, + email: form.email.trim() || undefined, + initialDeposit: form.initialDeposit > 0 ? form.initialDeposit : undefined, + remark: form.remark.trim() || undefined, + }; +} diff --git a/apps/api/prisma/migrations/20260603102323_zhibo_match_fields/migration.sql b/apps/api/prisma/migrations/20260603102323_zhibo_match_fields/migration.sql new file mode 100644 index 0000000..5008730 --- /dev/null +++ b/apps/api/prisma/migrations/20260603102323_zhibo_match_fields/migration.sql @@ -0,0 +1,21 @@ +-- AlterTable +ALTER TABLE "teams" ADD COLUMN IF NOT EXISTS "external_id" INTEGER, +ADD COLUMN IF NOT EXISTS "logo_url" VARCHAR(500); + +-- AlterTable +ALTER TABLE "matches" ADD COLUMN IF NOT EXISTS "official_match_no" INTEGER, +ADD COLUMN IF NOT EXISTS "stage" VARCHAR(32), +ADD COLUMN IF NOT EXISTS "group_name" VARCHAR(8), +ADD COLUMN IF NOT EXISTS "live_match_id" BIGINT, +ADD COLUMN IF NOT EXISTS "addition_match_id" BIGINT, +ADD COLUMN IF NOT EXISTS "channel_id" VARCHAR(64), +ADD COLUMN IF NOT EXISTS "match_name" VARCHAR(200), +ADD COLUMN IF NOT EXISTS "venue_json" JSONB, +ADD COLUMN IF NOT EXISTS "kickoff_json" JSONB, +ADD COLUMN IF NOT EXISTS "external_status" VARCHAR(32); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "teams_external_id_key" ON "teams"("external_id"); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "matches_live_match_id_key" ON "matches"("live_match_id"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 427333f..bab2d2d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -227,12 +227,14 @@ model League { } 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") + id BigInt @id @default(autoincrement()) + sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20) + code String @unique @db.VarChar(64) + externalId Int? @unique @map("external_id") + logoUrl String? @map("logo_url") @db.VarChar(500) + 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") @@ -256,23 +258,33 @@ model EntityTranslation { } 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") + 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") + officialMatchNo Int? @map("official_match_no") + stage String? @db.VarChar(32) + groupName String? @map("group_name") @db.VarChar(8) + liveMatchId BigInt? @unique @map("live_match_id") + additionMatchId BigInt? @map("addition_match_id") + channelId String? @map("channel_id") @db.VarChar(64) + matchName String? @map("match_name") @db.VarChar(200) + venueJson Json? @map("venue_json") + kickoffJson Json? @map("kickoff_json") + externalStatus String? @map("external_status") @db.VarChar(32) + 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]) diff --git a/apps/api/src/applications/admin/admin-dashboard.service.ts b/apps/api/src/applications/admin/admin-dashboard.service.ts new file mode 100644 index 0000000..2da678d --- /dev/null +++ b/apps/api/src/applications/admin/admin-dashboard.service.ts @@ -0,0 +1,209 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { Decimal } from '@prisma/client/runtime/library'; + +function dec(v: Decimal | null | undefined) { + return v?.toString() ?? '0'; +} + +function sub(a: Decimal | null | undefined, b: Decimal | null | undefined) { + return new Decimal(a ?? 0).sub(b ?? 0).toString(); +} + +@Injectable() +export class AdminDashboardService { + constructor(private prisma: PrismaService) {} + + async getOverview() { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const trend7d = await Promise.all( + Array.from({ length: 7 }, (_, i) => { + const dayStart = new Date(today); + dayStart.setDate(dayStart.getDate() - (6 - i)); + const dayEnd = new Date(dayStart); + dayEnd.setDate(dayEnd.getDate() + 1); + return this.prisma.bet + .aggregate({ + where: { placedAt: { gte: dayStart, lt: dayEnd } }, + _sum: { stake: true, actualReturn: true }, + _count: true, + }) + .then((agg) => ({ + date: dayStart.toISOString().slice(0, 10), + label: `${dayStart.getMonth() + 1}/${dayStart.getDate()}`, + betCount: agg._count, + stake: dec(agg._sum.stake), + payout: dec(agg._sum.actualReturn), + ggr: sub(agg._sum.stake, agg._sum.actualReturn), + })); + }), + ); + + const playerWhere = { userType: 'PLAYER', deletedAt: null }; + + const [ + todayBets, + yesterdayBets, + pendingBets, + betStatusToday, + matchGroups, + matchTotal, + playerTotal, + playerActive, + playerSuspended, + playerDirect, + newPlayersToday, + agentProfiles, + agentsActive, + walletAgg, + recentBets, + recentPlayers, + ] = await Promise.all([ + this.prisma.bet.aggregate({ + where: { placedAt: { gte: today } }, + _sum: { stake: true, actualReturn: true }, + _count: true, + }), + this.prisma.bet.aggregate({ + where: { + placedAt: { + gte: new Date(today.getTime() - 86400000), + lt: today, + }, + }, + _sum: { stake: true, actualReturn: true }, + _count: true, + }), + this.prisma.bet.count({ where: { status: 'PENDING' } }), + this.prisma.bet.groupBy({ + by: ['status'], + where: { placedAt: { gte: today } }, + _count: { _all: true }, + _sum: { stake: true }, + }), + this.prisma.match.groupBy({ + by: ['status'], + where: { deletedAt: null }, + _count: { _all: true }, + }), + this.prisma.match.count({ where: { deletedAt: null } }), + this.prisma.user.count({ where: playerWhere }), + this.prisma.user.count({ where: { ...playerWhere, status: 'ACTIVE' } }), + this.prisma.user.count({ where: { ...playerWhere, status: 'SUSPENDED' } }), + this.prisma.user.count({ + where: { ...playerWhere, parentId: null }, + }), + this.prisma.user.count({ + where: { ...playerWhere, createdAt: { gte: today } }, + }), + this.prisma.agentProfile.aggregate({ + _sum: { creditLimit: true, usedCredit: true }, + _count: { _all: true }, + }), + this.prisma.agentProfile.count({ where: { status: 'ACTIVE' } }), + this.prisma.wallet.aggregate({ + where: { user: playerWhere }, + _sum: { availableBalance: true, frozenBalance: true }, + _count: { _all: true }, + }), + this.prisma.bet.findMany({ + take: 8, + orderBy: { placedAt: 'desc' }, + include: { user: { select: { username: true } } }, + }), + this.prisma.user.findMany({ + where: playerWhere, + take: 6, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + username: true, + status: true, + createdAt: true, + parent: { select: { username: true } }, + }, + }), + ]); + + const matchByStatus: Record = {}; + for (const g of matchGroups) { + matchByStatus[g.status] = g._count._all; + } + + const todayBetByStatus: Record = {}; + for (const g of betStatusToday) { + todayBetByStatus[g.status] = { + count: g._count._all, + stake: dec(g._sum.stake), + }; + } + + const creditLimit = agentProfiles._sum.creditLimit ?? new Decimal(0); + const usedCredit = agentProfiles._sum.usedCredit ?? new Decimal(0); + + return { + generatedAt: new Date().toISOString(), + trend7d, + today: { + betCount: todayBets._count, + stake: dec(todayBets._sum.stake), + payout: dec(todayBets._sum.actualReturn), + ggr: sub(todayBets._sum.stake, todayBets._sum.actualReturn), + newPlayers: newPlayersToday, + }, + yesterday: { + betCount: yesterdayBets._count, + stake: dec(yesterdayBets._sum.stake), + payout: dec(yesterdayBets._sum.actualReturn), + ggr: sub(yesterdayBets._sum.stake, yesterdayBets._sum.actualReturn), + }, + users: { + playersTotal: playerTotal, + playersActive: playerActive, + playersSuspended: playerSuspended, + playersDirect: playerDirect, + agentsTotal: agentProfiles._count._all, + agentsActive, + }, + wallets: { + totalAvailable: dec(walletAgg._sum.availableBalance), + totalFrozen: dec(walletAgg._sum.frozenBalance), + playerWalletCount: walletAgg._count._all, + }, + agents: { + totalCreditLimit: dec(creditLimit), + totalUsedCredit: dec(usedCredit), + totalAvailableCredit: creditLimit.sub(usedCredit).toString(), + }, + matches: { + total: matchTotal, + draft: matchByStatus.DRAFT ?? 0, + published: matchByStatus.PUBLISHED ?? 0, + closed: matchByStatus.CLOSED ?? 0, + cancelled: matchByStatus.CANCELLED ?? 0, + pendingSettlement: matchByStatus.PENDING_SETTLEMENT ?? 0, + settled: matchByStatus.SETTLED ?? 0, + }, + bets: { + pendingTotal: pendingBets, + todayByStatus: todayBetByStatus, + }, + recentBets: recentBets.map((b) => ({ + betNo: b.betNo, + username: b.user.username, + stake: dec(b.stake), + status: b.status, + placedAt: b.placedAt, + })), + recentPlayers: recentPlayers.map((p) => ({ + id: p.id.toString(), + username: p.username, + status: p.status, + parentUsername: p.parent?.username ?? null, + createdAt: p.createdAt, + })), + }; + } +} diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index 5422c15..d21275a 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -1,5 +1,7 @@ import { + BadRequestException, Controller, + Delete, Get, Post, Put, @@ -24,7 +26,18 @@ import { I18nService } from '../../domains/operations/i18n/i18n.service'; import { AuditService } from '../../domains/operations/audit/audit.service'; import { BetsService } from '../../domains/betting/bets.service'; import { PrismaService } from '../../shared/prisma/prisma.service'; -import { IsString, IsNumber, IsOptional, IsArray, IsBoolean, MinLength } from 'class-validator'; +import { AdminDashboardService } from './admin-dashboard.service'; +import { + IsString, + IsNumber, + IsOptional, + IsArray, + IsBoolean, + MinLength, + IsIn, + Min, +} from 'class-validator'; +import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types'; class CreateUserDto { @IsString() @@ -43,6 +56,116 @@ class CreateUserDto { creditLimit?: number; } +class CreatePlayerAdminDto { + @IsString() + username!: string; + + @IsString() + @MinLength(8) + password!: string; + + @IsOptional() + @IsString() + parentId?: string; + + @IsOptional() + @IsString() + locale?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + email?: string; + + @IsOptional() + @IsNumber() + @Min(0) + initialDeposit?: number; + + @IsOptional() + @IsString() + remark?: string; +} + +class UpdatePlayerAdminDto { + @IsOptional() + @IsIn(['ACTIVE', 'SUSPENDED']) + status?: string; + + @IsOptional() + @IsString() + locale?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + email?: string; + + /** 传空字符串表示改为平台直属(无代理) */ + @IsOptional() + @IsString() + parentId?: string; +} + +class CreateAgentAdminDto { + @IsString() + username!: string; + + @IsString() + @MinLength(8) + password!: string; + + @IsNumber() + @Min(0) + creditLimit!: number; + + @IsOptional() + @IsString() + locale?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + email?: string; + + @IsOptional() + @IsNumber() + @Min(0) + cashbackRate?: number; +} + +class UpdateAgentAdminDto { + @IsOptional() + @IsIn(['ACTIVE', 'SUSPENDED']) + status?: string; + + @IsOptional() + @IsString() + locale?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + email?: string; + + @IsOptional() + @IsNumber() + @Min(0) + cashbackRate?: number; +} + class DepositDto { @IsNumber() amount!: number; @@ -55,15 +178,24 @@ class DepositDto { remark?: string; } -class CreateMatchDto { +class CreatePlatformMatchDto { @IsString() - leagueId!: string; + leagueEn!: string; @IsString() - homeTeamId!: string; + leagueZh!: string; @IsString() - awayTeamId!: string; + homeTeamEn!: string; + + @IsString() + homeTeamZh!: string; + + @IsString() + awayTeamEn!: string; + + @IsString() + awayTeamZh!: string; @IsString() startTime!: string; @@ -71,6 +203,15 @@ class CreateMatchDto { @IsOptional() @IsBoolean() isHot?: boolean; + + @IsOptional() + @IsNumber() + displayOrder?: number; +} + +function isZhiboBundlePayload(body: unknown): body is ZhiboMatchesBundleExport { + if (!body || typeof body !== 'object') return false; + return Array.isArray((body as ZhiboMatchesBundleExport).matches); } class ScoreDto { @@ -123,44 +264,73 @@ export class AdminController { private audit: AuditService, private bets: BetsService, private prisma: PrismaService, + private readonly dashboardService: AdminDashboardService, ) {} @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, - }); + async getDashboard() { + const overview = await this.dashboardService.getOverview(); + return jsonResponse(overview); } @Get('users') - async listUsers(@Query('page') page?: string) { - const result = await this.users.listPlayers(page ? parseInt(page) : 1); + async listUsers( + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Query('keyword') keyword?: string, + @Query('parentId') parentId?: string, + @Query('status') status?: string, + ) { + const result = await this.users.listPlayers( + page ? parseInt(page, 10) : 1, + pageSize ? parseInt(pageSize, 10) : 10, + { + keyword, + parentId: parentId ? BigInt(parentId) : undefined, + status, + }, + ); return jsonResponse(result); } + @Get('users/:id') + async getUserDetail(@Param('id') id: string) { + const detail = await this.users.getPlayerAdminDetail(BigInt(id)); + return jsonResponse(detail); + } + + @Put('users/:id') + async updateUser( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + @Body() dto: UpdatePlayerAdminDto, + ) { + const detail = await this.users.updatePlayerAdmin(BigInt(id), dto); + await this.audit.log({ + operatorId, + operatorType: 'ADMIN', + action: 'UPDATE_PLAYER', + module: 'USERS', + targetId: id, + }); + return jsonResponse(detail); + } + @Post('users') - async createPlayer(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateUserDto) { + async createPlayer( + @CurrentUser('id') operatorId: bigint, + @Body() dto: CreatePlayerAdminDto, + ) { const user = await this.agents.createPlayer(operatorId, { username: dto.username, password: dto.password, - parentId: dto.parentId ? BigInt(dto.parentId) : operatorId, + parentId: dto.parentId ? BigInt(dto.parentId) : undefined, + locale: dto.locale, + phone: dto.phone, + email: dto.email, + initialDeposit: dto.initialDeposit, + depositRemark: dto.remark, + depositRequestId: `create-player-${dto.username}-${Date.now()}`, }); await this.audit.log({ operatorId, @@ -169,24 +339,73 @@ export class AdminController { module: 'USERS', targetId: user.id.toString(), }); - return jsonResponse(user); + const detail = await this.users.getPlayerAdminDetail(user.id); + return jsonResponse(detail); + } + + @Get('agents/options') + async listAgentOptions() { + const agents = await this.prisma.user.findMany({ + where: { userType: 'AGENT', deletedAt: null, agentLevel: 1 }, + select: { id: true, username: true }, + orderBy: { username: 'asc' }, + }); + return jsonResponse( + agents.map((a) => ({ id: a.id.toString(), username: a.username })), + ); } @Get('agents') - async listAgents() { - const agents = await this.prisma.agentProfile.findMany({ - include: { user: true }, + async listAgents( + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Query('keyword') keyword?: string, + ) { + const result = await this.agents.listAgentsAdmin({ + page: page ? parseInt(page, 10) : 1, + pageSize: pageSize ? parseInt(pageSize, 10) : 10, + keyword, }); - return jsonResponse(agents); + return jsonResponse(result); + } + + @Get('agents/:id') + async getAgentDetail(@Param('id') id: string) { + const detail = await this.agents.getAgentAdminDetail(BigInt(id)); + return jsonResponse(detail); + } + + @Put('agents/:id') + async updateAgent( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + @Body() dto: UpdateAgentAdminDto, + ) { + const detail = await this.agents.updateAgentAdmin(BigInt(id), dto); + await this.audit.log({ + operatorId, + operatorType: 'ADMIN', + action: 'UPDATE_AGENT', + module: 'AGENTS', + targetId: id, + }); + return jsonResponse(detail); } @Post('agents') - async createAgent(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateUserDto) { + async createAgent( + @CurrentUser('id') operatorId: bigint, + @Body() dto: CreateAgentAdminDto, + ) { const user = await this.agents.createAgent(operatorId, { username: dto.username, password: dto.password, level: 1, creditLimit: dto.creditLimit, + locale: dto.locale, + phone: dto.phone, + email: dto.email, + cashbackRate: dto.cashbackRate, }); await this.audit.log({ operatorId, @@ -195,7 +414,8 @@ export class AdminController { module: 'AGENTS', targetId: user.id.toString(), }); - return jsonResponse(user); + const detail = await this.agents.getAgentAdminDetail(user.id); + return jsonResponse(detail); } @Post('agents/:id/credit') @@ -257,27 +477,100 @@ export class AdminController { } @Get('matches') - async listMatches() { - const matches = await this.prisma.match.findMany({ - include: { markets: { include: { selections: true } } }, - orderBy: { startTime: 'desc' }, + async listMatches( + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Query('status') status?: string, + @Query('keyword') keyword?: string, + ) { + const p = Math.max(1, page ? parseInt(page, 10) : 1); + const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100); + const skip = (p - 1) * size; + const where: { deletedAt: null; status?: string; OR?: object[] } = { deletedAt: null }; + if (status) where.status = status; + const kw = keyword?.trim(); + if (kw) { + where.OR = [ + { matchName: { contains: kw, mode: 'insensitive' } }, + { homeTeam: { code: { contains: kw, mode: 'insensitive' } } }, + { awayTeam: { code: { contains: kw, mode: 'insensitive' } } }, + ]; + } + const [items, total] = await Promise.all([ + this.prisma.match.findMany({ + where, + include: { + homeTeam: true, + awayTeam: true, + }, + orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }], + skip, + take: size, + }), + this.prisma.match.count({ where }), + ]); + return jsonResponse({ items, total, page: p, pageSize: size }); + } + + @Get('matches/:id') + async getMatch(@Param('id') id: string) { + const match = await this.matches.getAdminMatchDetail(BigInt(id)); + return jsonResponse(match); + } + + @Put('matches/:id') + async updateMatch( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + @Body() dto: CreatePlatformMatchDto, + ) { + const match = await this.matches.updatePlatformMatch(BigInt(id), { + leagueEn: dto.leagueEn, + leagueZh: dto.leagueZh, + homeTeamEn: dto.homeTeamEn, + homeTeamZh: dto.homeTeamZh, + awayTeamEn: dto.awayTeamEn, + awayTeamZh: dto.awayTeamZh, + startTime: new Date(dto.startTime), + isHot: dto.isHot, + displayOrder: dto.displayOrder, + updatedBy: operatorId, }); - return jsonResponse(matches); + return jsonResponse(match); + } + + @Delete('matches/:id') + async deleteMatch(@Param('id') id: string) { + await this.matches.deleteMatch(BigInt(id)); + return jsonResponse({ deleted: true }); } @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), + async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) { + const match = await this.matches.createPlatformMatch({ + leagueEn: dto.leagueEn, + leagueZh: dto.leagueZh, + homeTeamEn: dto.homeTeamEn, + homeTeamZh: dto.homeTeamZh, + awayTeamEn: dto.awayTeamEn, + awayTeamZh: dto.awayTeamZh, startTime: new Date(dto.startTime), isHot: dto.isHot, + displayOrder: dto.displayOrder, createdBy: operatorId, }); return jsonResponse(match); } + @Post('matches/import') + async importMatches(@CurrentUser('id') operatorId: bigint, @Body() dto: ZhiboMatchesBundleExport) { + if (!isZhiboBundlePayload(dto)) { + throw new BadRequestException('Invalid import payload: matches[] required'); + } + const result = await this.matches.importZhiboMatchesBundle(dto, operatorId); + return jsonResponse(result); + } + @Post('matches/:id/publish') async publishMatch(@Param('id') id: string) { const match = await this.matches.publishMatch(BigInt(id)); @@ -343,20 +636,31 @@ export class AdminController { } @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 }); + async listBets( + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Query('keyword') keyword?: string, + @Query('status') status?: string, + @Query('betType') betType?: string, + @Query('placedFrom') placedFrom?: string, + @Query('placedTo') placedTo?: string, + ) { + const result = await this.bets.listBetsAdmin({ + page: page ? parseInt(page, 10) : 1, + pageSize: pageSize ? parseInt(pageSize, 10) : 10, + keyword, + status: status || undefined, + betType: betType || undefined, + placedFrom, + placedTo, + }); + return jsonResponse(result); + } + + @Get('bets/:id') + async getBet(@Param('id') id: string) { + const detail = await this.bets.getBetAdminDetail(BigInt(id)); + return jsonResponse(detail); } @Post('cashbacks/preview') @@ -393,8 +697,16 @@ export class AdminController { } @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); + async auditLogs( + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Query('module') module?: string, + ) { + const result = await this.audit.list( + page ? parseInt(page, 10) : 1, + pageSize ? parseInt(pageSize, 10) : 10, + module || undefined, + ); return jsonResponse(result); } } diff --git a/apps/api/src/applications/admin/admin.module.ts b/apps/api/src/applications/admin/admin.module.ts index 844eac5..7277e1c 100644 --- a/apps/api/src/applications/admin/admin.module.ts +++ b/apps/api/src/applications/admin/admin.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { AdminController } from './admin.controller'; +import { AdminDashboardService } from './admin-dashboard.service'; import { UsersModule } from '../../domains/identity/users.module'; import { AgentsModule } from '../../domains/agent/agents.module'; import { WalletModule } from '../../domains/ledger/wallet.module'; @@ -25,5 +26,6 @@ import { BetsModule } from '../../domains/betting/bets.module'; BetsModule, ], controllers: [AdminController], + providers: [AdminDashboardService], }) export class AdminModule {} diff --git a/apps/api/src/applications/agent/agent-portal.controller.ts b/apps/api/src/applications/agent/agent-portal.controller.ts index fcb4622..83da514 100644 --- a/apps/api/src/applications/agent/agent-portal.controller.ts +++ b/apps/api/src/applications/agent/agent-portal.controller.ts @@ -147,8 +147,14 @@ export class AgentPortalController { } @Get('bets') - async listBets(@CurrentUser('id') agentId: bigint, @Query('page') page?: string) { - const skip = ((page ? parseInt(page) : 1) - 1) * 20; + async listBets( + @CurrentUser('id') agentId: bigint, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + const p = Math.max(1, page ? parseInt(page, 10) : 1); + const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100); + const skip = (p - 1) * size; const descendants = await this.prisma.agentClosure.findMany({ where: { ancestorId: agentId }, }); @@ -160,11 +166,11 @@ export class AgentPortalController { include: { selections: true, user: true }, orderBy: { placedAt: 'desc' }, skip, - take: 20, + take: size, }), this.prisma.bet.count({ where: { agentId: { in: agentIds } } }), ]); - return jsonResponse({ items, total }); + return jsonResponse({ items, total, page: p, pageSize: size }); } @Get('reports/summary') diff --git a/apps/api/src/domains/agent/agents.service.ts b/apps/api/src/domains/agent/agents.service.ts index 52f9dc6..f5c3505 100644 --- a/apps/api/src/domains/agent/agents.service.ts +++ b/apps/api/src/domains/agent/agents.service.ts @@ -1,4 +1,10 @@ -import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common'; +import { + Injectable, + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { WalletService } from '../ledger/wallet.service'; import { AuthService } from '../identity/auth.service'; @@ -147,6 +153,211 @@ export class AgentsService { return { success: true }; } + async listAgentsAdmin(params?: { + page?: number; + pageSize?: number; + keyword?: string; + }) { + const page = Math.max(1, params?.page ?? 1); + const pageSize = Math.min(Math.max(1, params?.pageSize ?? 10), 100); + const skip = (page - 1) * pageSize; + + const where: Prisma.AgentProfileWhereInput = {}; + const kw = params?.keyword?.trim(); + if (kw) { + where.user = { username: { contains: kw, mode: 'insensitive' } }; + } + + const [profiles, total] = await Promise.all([ + this.prisma.agentProfile.findMany({ + where, + include: { + user: { include: { preferences: true } }, + }, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + }), + this.prisma.agentProfile.count({ where }), + ]); + + const agentIds = profiles.map((p) => p.userId); + const playerCounts = + agentIds.length > 0 + ? await this.prisma.user.groupBy({ + by: ['parentId'], + where: { + userType: 'PLAYER', + parentId: { in: agentIds }, + deletedAt: null, + }, + _count: { _all: true }, + }) + : []; + + const countMap = new Map( + playerCounts.map((g) => [g.parentId?.toString(), g._count._all]), + ); + + const items = profiles.map((p) => { + const available = new Decimal(p.creditLimit).sub(p.usedCredit); + return { + id: p.id.toString(), + userId: p.userId.toString(), + username: p.user.username, + userStatus: p.user.status, + level: p.level, + status: p.status, + parentAgentId: p.parentAgentId?.toString() ?? null, + creditLimit: p.creditLimit.toString(), + usedCredit: p.usedCredit.toString(), + availableCredit: available.toString(), + directPlayerLiability: p.directPlayerLiability.toString(), + childAgentExposure: p.childAgentExposure.toString(), + cashbackRate: p.cashbackRate.toString(), + directPlayerCount: countMap.get(p.userId.toString()) ?? 0, + phone: p.user.preferences?.phone ?? null, + email: p.user.preferences?.email ?? null, + locale: p.user.locale, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + }; + }); + + return { items, total, page, pageSize }; + } + + async getAgentAdminDetail(agentId: bigint) { + const profile = await this.prisma.agentProfile.findUnique({ + where: { userId: agentId }, + include: { user: { include: { preferences: true, auth: true } } }, + }); + if (!profile) throw new NotFoundException('代理不存在'); + + const [directPlayerCount, recentCredits] = await Promise.all([ + this.prisma.user.count({ + where: { parentId: agentId, userType: 'PLAYER', deletedAt: null }, + }), + this.prisma.agentCreditTransaction.findMany({ + where: { agentId }, + orderBy: { createdAt: 'desc' }, + take: 10, + }), + ]); + + const available = new Decimal(profile.creditLimit).sub(profile.usedCredit); + let parentUsername: string | null = null; + if (profile.parentAgentId) { + const parent = await this.prisma.user.findUnique({ + where: { id: profile.parentAgentId }, + select: { username: true }, + }); + parentUsername = parent?.username ?? null; + } + + return { + id: profile.id.toString(), + userId: profile.userId.toString(), + username: profile.user.username, + userStatus: profile.user.status, + level: profile.level, + status: profile.status, + parentAgentId: profile.parentAgentId?.toString() ?? null, + parentUsername, + creditLimit: profile.creditLimit.toString(), + usedCredit: profile.usedCredit.toString(), + availableCredit: available.toString(), + directPlayerLiability: profile.directPlayerLiability.toString(), + childAgentExposure: profile.childAgentExposure.toString(), + cashbackRate: profile.cashbackRate.toString(), + directPlayerCount, + phone: profile.user.preferences?.phone ?? null, + email: profile.user.preferences?.email ?? null, + locale: profile.user.locale, + lastLoginAt: profile.user.auth?.lastLoginAt ?? null, + createdAt: profile.createdAt, + updatedAt: profile.updatedAt, + recentCreditTransactions: recentCredits.map((t) => ({ + id: t.id.toString(), + transactionType: t.transactionType, + amount: t.amount.toString(), + creditBefore: t.creditBefore.toString(), + creditAfter: t.creditAfter.toString(), + remark: t.remark, + createdAt: t.createdAt, + })), + }; + } + + async updateAgentAdmin( + agentId: bigint, + data: { + status?: string; + locale?: string; + phone?: string; + email?: string; + cashbackRate?: number; + }, + ) { + const profile = await this.prisma.agentProfile.findUnique({ + where: { userId: agentId }, + include: { user: true }, + }); + if (!profile) throw new NotFoundException('代理不存在'); + + if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) { + throw new BadRequestException('无效状态'); + } + + if (data.status) { + await this.prisma.$transaction([ + this.prisma.user.update({ + where: { id: agentId }, + data: { status: data.status }, + }), + this.prisma.agentProfile.update({ + where: { userId: agentId }, + data: { status: data.status }, + }), + ]); + } + + if (data.locale) { + await this.prisma.user.update({ + where: { id: agentId }, + data: { locale: data.locale }, + }); + } + + if (data.cashbackRate !== undefined) { + await this.prisma.agentProfile.update({ + where: { userId: agentId }, + data: { cashbackRate: data.cashbackRate }, + }); + } + + if (data.phone !== undefined || data.email !== undefined || data.locale) { + const phone = data.phone !== undefined ? data.phone?.trim() || null : undefined; + const email = data.email !== undefined ? data.email?.trim() || null : undefined; + await this.prisma.userPreference.upsert({ + where: { userId: agentId }, + create: { + userId: agentId, + locale: data.locale ?? profile.user.locale, + phone: phone ?? null, + email: email ?? null, + }, + update: { + ...(data.locale ? { locale: data.locale } : {}), + ...(phone !== undefined ? { phone } : {}), + ...(email !== undefined ? { email } : {}), + }, + }); + } + + return this.getAgentAdminDetail(agentId); + } + async createAgent( operatorId: bigint, data: { @@ -155,6 +366,10 @@ export class AgentsService { level: number; parentAgentId?: bigint; creditLimit?: number; + locale?: string; + phone?: string; + email?: string; + cashbackRate?: number; }, ) { if (data.level === 2 && !data.parentAgentId) { @@ -164,12 +379,14 @@ export class AgentsService { const hash = await this.auth.hashPassword(data.password); return this.prisma.$transaction(async (tx) => { + const locale = data.locale ?? 'zh-CN'; const user = await tx.user.create({ data: { username: data.username, userType: 'AGENT', parentId: data.parentAgentId, agentLevel: data.level, + locale, }, }); @@ -177,12 +394,22 @@ export class AgentsService { data: { userId: user.id, passwordHash: hash }, }); + await tx.userPreference.create({ + data: { + userId: user.id, + locale, + phone: data.phone?.trim() || null, + email: data.email?.trim() || null, + }, + }); + await tx.agentProfile.create({ data: { userId: user.id, level: data.level, parentAgentId: data.parentAgentId, creditLimit: data.creditLimit ?? 0, + cashbackRate: data.cashbackRate ?? 0, }, }); @@ -215,38 +442,81 @@ export class AgentsService { async createPlayer( operatorId: bigint, - data: { username: string; password: string; parentId: bigint }, + data: { + username: string; + password: string; + parentId?: bigint; + locale?: string; + phone?: string; + email?: string; + initialDeposit?: number; + depositRemark?: string; + depositRequestId?: string; + }, ) { - const hash = await this.auth.hashPassword(data.password); + let parentId: bigint | null = null; + if (data.parentId != null) { + const parent = await this.prisma.user.findUnique({ where: { id: data.parentId } }); + if (!parent || parent.userType !== 'AGENT') { + throw new BadRequestException('上级必须为代理账号'); + } + parentId = data.parentId; + } - return this.prisma.$transaction(async (tx) => { - const user = await tx.user.create({ + const hash = await this.auth.hashPassword(data.password); + const locale = data.locale ?? 'zh-CN'; + + const user = await this.prisma.$transaction(async (tx) => { + const created = await tx.user.create({ data: { username: data.username, userType: 'PLAYER', - parentId: data.parentId, + parentId, + locale, }, }); await tx.userAuth.create({ - data: { userId: user.id, passwordHash: hash }, + data: { userId: created.id, passwordHash: hash }, }); await tx.wallet.create({ - data: { userId: user.id }, + data: { userId: created.id }, }); await tx.userPreference.create({ - data: { userId: user.id }, + data: { + userId: created.id, + locale, + phone: data.phone?.trim() || null, + email: data.email?.trim() || null, + }, }); - const parent = await tx.user.findUnique({ where: { id: data.parentId } }); - if (parent?.userType === 'AGENT') { - await this.recalculateUsedCredit(data.parentId); - } - - return user; + return created; }); + + if (parentId) { + await this.recalculateUsedCredit(parentId); + } + + const initial = data.initialDeposit ?? 0; + if (initial > 0) { + const requestId = + data.depositRequestId ?? `admin-create-${user.id}-${Date.now()}`; + await this.wallet.deposit( + user.id, + initial, + operatorId, + data.depositRemark ?? '开户初始余额', + requestId, + ); + if (parentId) { + await this.recalculateUsedCredit(parentId); + } + } + + return user; } async getDirectPlayers(agentId: bigint) { diff --git a/apps/api/src/domains/betting/bets.service.ts b/apps/api/src/domains/betting/bets.service.ts index d8df885..5b572f7 100644 --- a/apps/api/src/domains/betting/bets.service.ts +++ b/apps/api/src/domains/betting/bets.service.ts @@ -1,4 +1,5 @@ -import { Injectable, BadRequestException, ConflictException } from '@nestjs/common'; +import { Injectable, BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { WalletService } from '../ledger/wallet.service'; import { Decimal } from '@prisma/client/runtime/library'; @@ -208,4 +209,157 @@ export class BetsService { include: { selections: true }, }); } + + private dec(v: Decimal | null | undefined) { + return v?.toString() ?? '0'; + } + + private formatBetListRow( + b: { + id: bigint; + betNo: string; + userId: bigint; + agentId: bigint | null; + betType: string; + stake: Decimal; + totalOdds: Decimal | null; + potentialReturn: Decimal | null; + actualReturn: Decimal; + status: string; + settlementStatus: string | null; + currency: string; + placedAt: Date; + settledAt: Date | null; + user: { id: bigint; username: string; parent: { username: string } | null }; + _count: { selections: number }; + }, + ) { + return { + id: b.id.toString(), + betNo: b.betNo, + userId: b.userId.toString(), + username: b.user.username, + parentUsername: b.user.parent?.username ?? null, + agentId: b.agentId?.toString() ?? null, + betType: b.betType, + stake: this.dec(b.stake), + totalOdds: b.totalOdds ? this.dec(b.totalOdds) : null, + potentialReturn: b.potentialReturn ? this.dec(b.potentialReturn) : null, + actualReturn: this.dec(b.actualReturn), + status: b.status, + settlementStatus: b.settlementStatus, + currency: b.currency, + placedAt: b.placedAt, + settledAt: b.settledAt, + selectionCount: b._count.selections, + }; + } + + async listBetsAdmin(params: { + page?: number; + pageSize?: number; + keyword?: string; + status?: string; + betType?: string; + placedFrom?: string; + placedTo?: string; + }) { + const page = Math.max(1, params.page ?? 1); + const pageSize = Math.min(Math.max(1, params.pageSize ?? 10), 100); + const skip = (page - 1) * pageSize; + + const where: Prisma.BetWhereInput = {}; + + if (params.status) where.status = params.status; + if (params.betType) where.betType = params.betType; + + if (params.placedFrom || params.placedTo) { + where.placedAt = {}; + if (params.placedFrom) { + const from = new Date(params.placedFrom); + from.setHours(0, 0, 0, 0); + where.placedAt.gte = from; + } + if (params.placedTo) { + const to = new Date(params.placedTo); + to.setHours(23, 59, 59, 999); + where.placedAt.lte = to; + } + } + + const kw = params.keyword?.trim(); + if (kw) { + where.OR = [ + { betNo: { contains: kw, mode: 'insensitive' } }, + { user: { username: { contains: kw, mode: 'insensitive' } } }, + ]; + } + + const [items, total] = await Promise.all([ + this.prisma.bet.findMany({ + where, + include: { + user: { + select: { + id: true, + username: true, + parent: { select: { username: true } }, + }, + }, + _count: { select: { selections: true } }, + }, + orderBy: { placedAt: 'desc' }, + skip, + take: pageSize, + }), + this.prisma.bet.count({ where }), + ]); + + return { + items: items.map((b) => this.formatBetListRow(b)), + total, + page, + pageSize, + }; + } + + async getBetAdminDetail(betId: bigint) { + const bet = await this.prisma.bet.findUnique({ + where: { id: betId }, + include: { + user: { + select: { + id: true, + username: true, + parent: { select: { username: true } }, + }, + }, + selections: { orderBy: { sortOrder: 'asc' } }, + }, + }); + if (!bet) throw new NotFoundException('注单不存在'); + + return { + ...this.formatBetListRow({ + ...bet, + _count: { selections: bet.selections.length }, + }), + requestId: bet.requestId, + createdAt: bet.createdAt, + updatedAt: bet.updatedAt, + selections: bet.selections.map((s) => ({ + id: s.id.toString(), + matchId: s.matchId?.toString() ?? null, + marketType: s.marketType, + period: s.period, + selectionName: s.selectionNameSnapshot, + handicapLine: s.handicapLine ? this.dec(s.handicapLine) : null, + totalLine: s.totalLine ? this.dec(s.totalLine) : null, + odds: this.dec(s.odds), + resultStatus: s.resultStatus, + effectiveOdds: s.effectiveOdds ? this.dec(s.effectiveOdds) : null, + sortOrder: s.sortOrder, + })), + }; + } } diff --git a/apps/api/src/domains/catalog/matches.service.ts b/apps/api/src/domains/catalog/matches.service.ts index 7ca1c00..b3cfa40 100644 --- a/apps/api/src/domains/catalog/matches.service.ts +++ b/apps/api/src/domains/catalog/matches.service.ts @@ -1,6 +1,18 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../../shared/prisma/prisma.service'; +import type { ZhiboLeagueExport, ZhiboMatchExport, ZhiboMatchesBundleExport, ZhiboTeamExport } from './zhibo-match.types'; +import { + leagueCodeFromExport, + resolveInternalStatus, + resolveIsHot, + resolveStartTime, + teamCodeFromExport, + toKickoffJson, + toVenueJson, + translationsFromZhiboNames, +} from './zhibo-match.mapper'; @Injectable() export class MatchesService { @@ -44,8 +56,24 @@ export class MatchesService { awayTeamId: bigint; startTime: Date; isHot?: boolean; + displayOrder?: number; createdBy?: bigint; + status?: string; + publishTime?: Date; + zhibo?: Partial<{ + officialMatchNo: number; + stage: string; + groupName: string; + liveMatchId?: bigint; + additionMatchId: bigint | null; + channelId: string | null; + matchName: string; + venueJson: Prisma.InputJsonValue; + kickoffJson: Prisma.InputJsonValue; + externalStatus: string; + }>; }) { + const status = data.status ?? 'DRAFT'; return this.prisma.match.create({ data: { leagueId: data.leagueId, @@ -53,12 +81,384 @@ export class MatchesService { awayTeamId: data.awayTeamId, startTime: data.startTime, isHot: data.isHot ?? false, + displayOrder: data.displayOrder ?? 0, createdBy: data.createdBy, - status: 'DRAFT', + status, + publishTime: data.publishTime ?? (status === 'PUBLISHED' ? new Date() : undefined), + officialMatchNo: data.zhibo?.officialMatchNo, + stage: data.zhibo?.stage, + groupName: data.zhibo?.groupName, + liveMatchId: data.zhibo?.liveMatchId, + additionMatchId: data.zhibo?.additionMatchId ?? undefined, + channelId: data.zhibo?.channelId ?? undefined, + matchName: data.zhibo?.matchName, + venueJson: data.zhibo?.venueJson, + kickoffJson: data.zhibo?.kickoffJson, + externalStatus: data.zhibo?.externalStatus, }, }); } + private async upsertEntityTranslations( + entityType: 'LEAGUE' | 'TEAM', + entityId: bigint, + translations: Record, + ) { + for (const [locale, value] of Object.entries(translations)) { + await this.prisma.entityTranslation.upsert({ + where: { + entityType_entityId_locale_fieldName: { + entityType, + entityId, + locale, + fieldName: 'name', + }, + }, + create: { entityType, entityId, locale, fieldName: 'name', value }, + update: { value }, + }); + } + } + + async upsertLeagueFromZhiboExport(league: ZhiboLeagueExport) { + const code = leagueCodeFromExport(league); + const record = await this.prisma.league.upsert({ + where: { code }, + create: { code, sportType: league.type || 'FOOTBALL' }, + update: { sportType: league.type || 'FOOTBALL' }, + }); + await this.upsertEntityTranslations('LEAGUE', record.id, { + 'zh-CN': league.zh, + 'en-US': league.en, + }); + return record; + } + + async upsertTeamFromZhiboExport(team: ZhiboTeamExport) { + const code = teamCodeFromExport(team); + const translations = translationsFromZhiboNames(team.names, team.name); + + let record = + team.id != null + ? await this.prisma.team.findFirst({ where: { externalId: team.id } }) + : await this.prisma.team.findUnique({ where: { code } }); + + if (!record) { + record = await this.prisma.team.create({ + data: { + code, + externalId: team.id ?? undefined, + logoUrl: team.image || undefined, + }, + }); + } else { + record = await this.prisma.team.update({ + where: { id: record.id }, + data: { + logoUrl: team.image || record.logoUrl, + externalId: team.id ?? record.externalId, + }, + }); + } + + await this.upsertEntityTranslations('TEAM', record.id, translations); + return record; + } + + private async findExistingZhiboMatch( + leagueId: bigint, + homeTeamId: bigint, + awayTeamId: bigint, + item: ZhiboMatchExport, + ) { + if (item.liveMatchId != null) { + return this.prisma.match.findUnique({ + where: { liveMatchId: BigInt(item.liveMatchId) }, + }); + } + if (item.officialMatchNo != null) { + return this.prisma.match.findFirst({ + where: { + leagueId, + homeTeamId, + awayTeamId, + officialMatchNo: item.officialMatchNo, + }, + }); + } + return null; + } + + async createPlatformMatch(data: { + leagueEn: string; + leagueZh: string; + homeTeamZh: string; + homeTeamEn: string; + awayTeamZh: string; + awayTeamEn: string; + startTime: Date; + isHot?: boolean; + displayOrder?: number; + createdBy?: bigint; + }) { + const homeEn = data.homeTeamEn.trim(); + const homeZh = data.homeTeamZh.trim(); + const awayEn = data.awayTeamEn.trim(); + const awayZh = data.awayTeamZh.trim(); + if ((!homeEn && !homeZh) || (!awayEn && !awayZh)) { + throw new BadRequestException('请填写主客队中英文名至少各一项'); + } + + const league = await this.upsertLeagueFromZhiboExport({ + type: 'FOOTBALL', + en: data.leagueEn.trim(), + zh: data.leagueZh.trim(), + }); + const [homeTeam, awayTeam] = await Promise.all([ + this.upsertTeamFromZhiboExport({ + id: null, + name: homeEn || homeZh, + names: { zh: homeZh || null, en: homeEn || null, zhTw: '', vi: null, km: null, ms: null }, + image: '', + }), + this.upsertTeamFromZhiboExport({ + id: null, + name: awayEn || awayZh, + names: { zh: awayZh || null, en: awayEn || null, zhTw: '', vi: null, km: null, ms: null }, + image: '', + }), + ]); + + return this.createMatch({ + leagueId: league.id, + homeTeamId: homeTeam.id, + awayTeamId: awayTeam.id, + startTime: data.startTime, + isHot: data.isHot ?? false, + displayOrder: data.displayOrder ?? 0, + createdBy: data.createdBy, + status: 'DRAFT', + zhibo: { + matchName: `${homeEn || homeZh} - ${awayEn || awayZh}`, + }, + }); + } + + private async requireAdminMatch(matchId: bigint) { + const match = await this.prisma.match.findFirst({ + where: { id: matchId, deletedAt: null }, + include: { homeTeam: true, awayTeam: true }, + }); + if (!match) throw new NotFoundException('赛事不存在'); + return match; + } + + async getAdminMatchDetail(matchId: bigint) { + const match = await this.requireAdminMatch(matchId); + const [leagueEn, leagueZh, homeEn, homeZh, awayEn, awayZh] = await Promise.all([ + this.getTranslation('LEAGUE', match.leagueId, 'en-US'), + this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'), + this.getTranslation('TEAM', match.homeTeamId, 'en-US'), + this.getTranslation('TEAM', match.homeTeamId, 'zh-CN'), + this.getTranslation('TEAM', match.awayTeamId, 'en-US'), + this.getTranslation('TEAM', match.awayTeamId, 'zh-CN'), + ]); + return { + id: match.id.toString(), + status: match.status, + isOutright: match.isOutright, + isHot: match.isHot, + startTime: match.startTime.toISOString(), + leagueEn, + leagueZh, + homeTeamEn: homeEn, + homeTeamZh: homeZh, + awayTeamEn: awayEn, + awayTeamZh: awayZh, + matchName: match.matchName ?? '', + }; + } + + async updatePlatformMatch( + matchId: bigint, + data: { + leagueEn: string; + leagueZh: string; + homeTeamZh: string; + homeTeamEn: string; + awayTeamZh: string; + awayTeamEn: string; + startTime: Date; + isHot?: boolean; + displayOrder?: number; + updatedBy?: bigint; + }, + ) { + const match = await this.requireAdminMatch(matchId); + if (match.isOutright) { + throw new BadRequestException('冠军盘请通过盘口管理维护'); + } + if (!['DRAFT', 'PUBLISHED'].includes(match.status)) { + throw new BadRequestException('当前状态不可编辑'); + } + + const matchName = `${data.homeTeamEn.trim() || data.homeTeamZh.trim()} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim()}`; + + await Promise.all([ + this.upsertEntityTranslations('LEAGUE', match.leagueId, { + 'zh-CN': data.leagueZh.trim(), + 'en-US': data.leagueEn.trim(), + }), + this.upsertEntityTranslations('TEAM', match.homeTeamId, { + 'zh-CN': data.homeTeamZh.trim(), + 'en-US': data.homeTeamEn.trim(), + }), + this.upsertEntityTranslations('TEAM', match.awayTeamId, { + 'zh-CN': data.awayTeamZh.trim(), + 'en-US': data.awayTeamEn.trim(), + }), + ]); + + return this.prisma.match.update({ + where: { id: matchId }, + data: { + startTime: data.startTime, + isHot: data.isHot ?? match.isHot, + displayOrder: data.displayOrder ?? match.displayOrder, + matchName, + updatedBy: data.updatedBy, + }, + }); + } + + async deleteMatch(matchId: bigint) { + const match = await this.requireAdminMatch(matchId); + if (match.isOutright) { + throw new BadRequestException('冠军盘不可删除'); + } + if (match.status !== 'DRAFT') { + throw new BadRequestException('仅草稿状态可删除'); + } + const betCount = await this.prisma.betSelection.count({ where: { matchId } }); + if (betCount > 0) { + throw new BadRequestException('该赛事已有注单关联,无法删除'); + } + return this.prisma.match.update({ + where: { id: matchId }, + data: { deletedAt: new Date() }, + }); + } + + async createMatchFromZhiboExport( + item: ZhiboMatchExport, + createdBy?: bigint, + opts?: { asDraft?: boolean }, + ) { + const league = await this.upsertLeagueFromZhiboExport(item.league); + const [homeTeam, awayTeam] = await Promise.all([ + this.upsertTeamFromZhiboExport(item.homeTeam), + this.upsertTeamFromZhiboExport(item.awayTeam), + ]); + + const status = opts?.asDraft ? 'DRAFT' : resolveInternalStatus(item); + const startTime = resolveStartTime(item.kickoff); + const liveMatchId = + item.liveMatchId != null ? BigInt(item.liveMatchId) : undefined; + const payload = { + leagueId: league.id, + homeTeamId: homeTeam.id, + awayTeamId: awayTeam.id, + startTime, + isHot: resolveIsHot(item), + displayOrder: item.sortOrder, + createdBy, + status, + publishTime: status === 'PUBLISHED' ? new Date() : undefined, + zhibo: { + officialMatchNo: item.officialMatchNo, + stage: item.stage, + groupName: item.groupName, + liveMatchId, + additionMatchId: item.additionMatchId != null ? BigInt(item.additionMatchId) : null, + channelId: item.channelId, + matchName: item.matchName, + venueJson: toVenueJson(item.venue), + kickoffJson: toKickoffJson(item.kickoff), + externalStatus: item.status.state, + }, + }; + + const existing = await this.findExistingZhiboMatch( + league.id, + homeTeam.id, + awayTeam.id, + item, + ); + if (existing) { + return this.prisma.match.update({ + where: { id: existing.id }, + data: { + leagueId: payload.leagueId, + homeTeamId: payload.homeTeamId, + awayTeamId: payload.awayTeamId, + startTime: payload.startTime, + isHot: payload.isHot, + displayOrder: payload.displayOrder, + status: payload.status, + publishTime: existing.publishTime ?? payload.publishTime, + officialMatchNo: payload.zhibo.officialMatchNo, + stage: payload.zhibo.stage, + groupName: payload.zhibo.groupName, + liveMatchId: payload.zhibo.liveMatchId ?? undefined, + additionMatchId: payload.zhibo.additionMatchId ?? undefined, + channelId: payload.zhibo.channelId ?? undefined, + matchName: payload.zhibo.matchName, + venueJson: payload.zhibo.venueJson, + kickoffJson: payload.zhibo.kickoffJson, + externalStatus: payload.zhibo.externalStatus, + updatedBy: createdBy, + }, + }); + } + + return this.createMatch(payload); + } + + async importZhiboMatchesBundle(bundle: ZhiboMatchesBundleExport, createdBy?: bigint) { + if (!bundle.matches?.length) { + throw new BadRequestException('matches array is required'); + } + + const results: Array<{ liveMatchId: string; id: string; status: string; skipped?: boolean; reason?: string }> = []; + + for (const item of bundle.matches) { + try { + const match = await this.createMatchFromZhiboExport(item, createdBy, { asDraft: true }); + results.push({ + liveMatchId: item.liveMatchId != null ? String(item.liveMatchId) : '', + id: match.id.toString(), + status: match.status, + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'import failed'; + results.push({ + liveMatchId: item.liveMatchId != null ? String(item.liveMatchId) : '', + id: '', + status: 'error', + reason: message, + }); + } + } + + return { + total: bundle.matches.length, + imported: results.filter((r) => !r.skipped && r.status !== 'error').length, + skipped: results.filter((r) => r.skipped).length, + failed: results.filter((r) => r.status === 'error').length, + results, + }; + } + async publishMatch(matchId: bigint) { return this.prisma.match.update({ where: { id: matchId }, diff --git a/apps/api/src/domains/catalog/zhibo-match.mapper.ts b/apps/api/src/domains/catalog/zhibo-match.mapper.ts new file mode 100644 index 0000000..85858b0 --- /dev/null +++ b/apps/api/src/domains/catalog/zhibo-match.mapper.ts @@ -0,0 +1,64 @@ +import { Prisma } from '@prisma/client'; +import type { ZhiboLeagueExport, ZhiboMatchExport, ZhiboTeamExport } from './zhibo-match.types'; + +const LOCALE_MAP: Record = { + zh: 'zh-CN', + en: 'en-US', + ms: 'ms-MY', +}; + +export function slugTeamCode(name: string): string { + return name + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9]+/g, '_') + .replace(/^_|_$/g, '') + .toUpperCase() + .slice(0, 48) || 'TEAM'; +} + +export function teamCodeFromExport(team: ZhiboTeamExport): string { + if (team.id != null) return `ZIBO_${team.id}`; + return `NAME_${slugTeamCode(team.name)}`; +} + +export function leagueCodeFromExport(league: ZhiboLeagueExport): string { + if (league.en.includes('World Cup 2026')) return 'WC2026'; + return slugTeamCode(league.en).slice(0, 32); +} + +export function translationsFromZhiboNames( + names: ZhiboTeamExport['names'], + fallbackEn: string, +): Record { + const out: Record = {}; + for (const [key, locale] of Object.entries(LOCALE_MAP)) { + const v = names[key as keyof typeof names]; + if (typeof v === 'string' && v.trim()) out[locale] = v.trim(); + } + if (!out['en-US'] && fallbackEn) out['en-US'] = fallbackEn; + if (!out['zh-CN'] && names.zh) out['zh-CN'] = names.zh; + return out; +} + +export function resolveStartTime(kickoff: ZhiboMatchExport['kickoff']): Date { + if (kickoff.utcIso) return new Date(kickoff.utcIso); + return new Date(kickoff.utcTimeStart * 1000); +} + +export function resolveInternalStatus(item: ZhiboMatchExport): string { + if (!item.isPublished || item.status.state === 'off') return 'DRAFT'; + return 'PUBLISHED'; +} + +export function resolveIsHot(item: ZhiboMatchExport): boolean { + return (item.status.isHot ?? 0) > 0; +} + +export function toKickoffJson(kickoff: ZhiboMatchExport['kickoff']): Prisma.InputJsonValue { + return kickoff as unknown as Prisma.InputJsonValue; +} + +export function toVenueJson(venue: ZhiboMatchExport['venue']): Prisma.InputJsonValue { + return venue as unknown as Prisma.InputJsonValue; +} diff --git a/apps/api/src/domains/catalog/zhibo-match.types.ts b/apps/api/src/domains/catalog/zhibo-match.types.ts new file mode 100644 index 0000000..54efd70 --- /dev/null +++ b/apps/api/src/domains/catalog/zhibo-match.types.ts @@ -0,0 +1,67 @@ +/** zhibo 导出(world_cup_match_ext + live_matches)对齐结构 */ + +export interface ZhiboLocalizedNames { + zh?: string | null; + en?: string | null; + zhTw?: string | null; + vi?: string | null; + km?: string | null; + ms?: string | null; +} + +export interface ZhiboLeagueExport { + type: string; + en: string; + zh: string; +} + +export interface ZhiboKickoffExport { + utcTimeStart: number; + utcTimeStop: number; + utcIso: string; + chinaTime: string; + venueTime: string; + venueTimezone: string; +} + +export interface ZhiboTeamExport { + id: number | null; + name: string; + names: ZhiboLocalizedNames; + image: string; +} + +export interface ZhiboMatchExport { + officialMatchNo: number; + stage: string; + groupName: string; + liveMatchId: number | null; + additionMatchId: number | null; + channelId: string | null; + matchName: string; + league: ZhiboLeagueExport; + kickoff: ZhiboKickoffExport; + homeTeam: ZhiboTeamExport; + awayTeam: ZhiboTeamExport; + score: { home: number | string | null; away: number | string | null }; + status: { + state: string; + nowPlaying: number; + isLive: number; + isHot: number; + }; + venue: { + names: ZhiboLocalizedNames; + city: ZhiboLocalizedNames; + }; + sortOrder: number; + isPublished: boolean; +} + +export interface ZhiboMatchesBundleExport { + exportedAt?: string; + source?: Record; + count?: number; + groups?: string[]; + matches: ZhiboMatchExport[]; +} diff --git a/apps/api/src/domains/identity/users.module.ts b/apps/api/src/domains/identity/users.module.ts index 8fa904f..6004921 100644 --- a/apps/api/src/domains/identity/users.module.ts +++ b/apps/api/src/domains/identity/users.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { UsersService } from './users.service'; +import { AgentsModule } from '../agent/agents.module'; @Module({ + imports: [AgentsModule], providers: [UsersService], exports: [UsersService], }) diff --git a/apps/api/src/domains/identity/users.service.ts b/apps/api/src/domains/identity/users.service.ts index e340c11..21c71f2 100644 --- a/apps/api/src/domains/identity/users.service.ts +++ b/apps/api/src/domains/identity/users.service.ts @@ -1,9 +1,77 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../shared/prisma/prisma.service'; +import { AgentsService } from '../agent/agents.service'; + +export type PlayerListFilters = { + keyword?: string; + parentId?: bigint; + status?: string; +}; @Injectable() export class UsersService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private agents: AgentsService, + ) {} + + private formatPlayerRow( + u: { + id: bigint; + username: string; + status: string; + locale: string; + parentId: bigint | null; + createdAt: Date; + updatedAt: Date; + wallet?: { availableBalance: { toString(): string }; frozenBalance: { toString(): string } } | null; + preferences?: { phone: string | null; email: string | null } | null; + parent?: { username: string } | null; + auth?: { lastLoginAt: Date | null } | null; + }, + bet?: { count: number; totalStake: string; totalReturn: string }, + ) { + return { + id: u.id.toString(), + username: u.username, + status: u.status, + locale: u.locale, + parentId: u.parentId?.toString() ?? null, + parentUsername: u.parent?.username ?? null, + phone: u.preferences?.phone ?? null, + email: u.preferences?.email ?? null, + availableBalance: u.wallet?.availableBalance?.toString() ?? '0', + frozenBalance: u.wallet?.frozenBalance?.toString() ?? '0', + lastLoginAt: u.auth?.lastLoginAt ?? null, + betCount: bet?.count ?? 0, + totalStake: bet?.totalStake ?? '0', + totalReturn: bet?.totalReturn ?? '0', + createdAt: u.createdAt, + updatedAt: u.updatedAt, + }; + } + + private async loadBetStatsMap(userIds: bigint[]) { + if (userIds.length === 0) return new Map(); + + const groups = await this.prisma.bet.groupBy({ + by: ['userId'], + where: { userId: { in: userIds } }, + _count: { _all: true }, + _sum: { stake: true, actualReturn: true }, + }); + + return new Map( + groups.map((g) => [ + g.userId.toString(), + { + count: g._count._all, + totalStake: g._sum.stake?.toString() ?? '0', + totalReturn: g._sum.actualReturn?.toString() ?? '0', + }, + ]), + ); + } async findById(id: bigint) { return this.prisma.user.findUnique({ @@ -36,19 +104,170 @@ export class UsersService { return { locale }; } - async listPlayers(page = 1, pageSize = 20, parentId?: bigint) { - const where = { userType: 'PLAYER', ...(parentId ? { parentId } : {}) }; + async listPlayers( + page = 1, + pageSize = 10, + filters: PlayerListFilters = {}, + ) { + const where: { + userType: string; + deletedAt: null; + parentId?: bigint; + status?: string; + OR?: { username?: { contains: string; mode: 'insensitive' } }[]; + } = { + userType: 'PLAYER', + deletedAt: null, + }; + if (filters.parentId) where.parentId = filters.parentId; + if (filters.status) where.status = filters.status; + if (filters.keyword?.trim()) { + const kw = filters.keyword.trim(); + where.OR = [{ username: { contains: kw, mode: 'insensitive' } }]; + } + const skip = (page - 1) * pageSize; - const [items, total] = await Promise.all([ + const [rows, total] = await Promise.all([ this.prisma.user.findMany({ where, - include: { wallet: true }, + include: { + wallet: true, + preferences: true, + parent: { select: { id: true, username: true } }, + auth: { select: { lastLoginAt: true } }, + }, skip, take: pageSize, orderBy: { createdAt: 'desc' }, }), this.prisma.user.count({ where }), ]); - return { items, total, page, pageSize }; + + const betMap = await this.loadBetStatsMap(rows.map((r) => r.id)); + return { + items: rows.map((u) => this.formatPlayerRow(u, betMap.get(u.id.toString()))), + total, + page, + pageSize, + }; + } + + async getPlayerAdminDetail(playerId: bigint) { + const user = await this.prisma.user.findFirst({ + where: { id: playerId, userType: 'PLAYER', deletedAt: null }, + include: { + wallet: true, + preferences: true, + parent: { select: { id: true, username: true, agentLevel: true } }, + auth: { select: { lastLoginAt: true, loginFailCount: true, lockedUntil: true } }, + }, + }); + if (!user) throw new NotFoundException('玩家不存在'); + + const [betCount, betStake] = await Promise.all([ + this.prisma.bet.count({ where: { userId: playerId } }), + this.prisma.bet.aggregate({ + where: { userId: playerId }, + _sum: { stake: true, actualReturn: true }, + }), + ]); + + return { + ...this.formatPlayerRow(user), + lastLoginAt: user.auth?.lastLoginAt ?? null, + loginFailCount: user.auth?.loginFailCount ?? 0, + lockedUntil: user.auth?.lockedUntil ?? null, + betCount, + totalStake: betStake._sum.stake?.toString() ?? '0', + totalReturn: betStake._sum.actualReturn?.toString() ?? '0', + }; + } + + async updatePlayerAdmin( + playerId: bigint, + data: { + status?: string; + locale?: string; + phone?: string; + email?: string; + parentId?: string | null; + }, + ) { + const user = await this.prisma.user.findFirst({ + where: { id: playerId, userType: 'PLAYER', deletedAt: null }, + }); + if (!user) throw new NotFoundException('玩家不存在'); + + if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) { + throw new BadRequestException('无效状态'); + } + + if (data.status) { + await this.prisma.user.update({ + where: { id: playerId }, + data: { status: data.status }, + }); + } + + if (data.parentId !== undefined) { + const newParentId = + data.parentId === null || data.parentId === '' + ? null + : BigInt(data.parentId); + + if (newParentId !== null) { + const parent = await this.prisma.user.findUnique({ + where: { id: newParentId }, + }); + if (!parent || parent.userType !== 'AGENT') { + throw new BadRequestException('上级必须为代理账号'); + } + } + + const oldParentId = user.parentId; + const changed = + (oldParentId?.toString() ?? null) !== (newParentId?.toString() ?? null); + + if (changed) { + await this.prisma.user.update({ + where: { id: playerId }, + data: { parentId: newParentId }, + }); + if (oldParentId) { + await this.agents.recalculateUsedCredit(oldParentId); + } + if (newParentId) { + await this.agents.recalculateUsedCredit(newParentId); + } + } + } + + if (data.locale) { + await this.prisma.user.update({ + where: { id: playerId }, + data: { locale: data.locale }, + }); + } + + if (data.phone !== undefined || data.email !== undefined || data.locale) { + const phone = data.phone !== undefined ? data.phone?.trim() || null : undefined; + const email = data.email !== undefined ? data.email?.trim() || null : undefined; + await this.prisma.userPreference.upsert({ + where: { userId: playerId }, + create: { + userId: playerId, + locale: data.locale ?? user.locale, + phone: phone ?? null, + email: email ?? null, + }, + update: { + ...(data.locale ? { locale: data.locale } : {}), + ...(phone !== undefined ? { phone } : {}), + ...(email !== undefined ? { email } : {}), + }, + }); + } + + return this.getPlayerAdminDetail(playerId); } } diff --git a/apps/api/src/domains/operations/audit/audit.service.ts b/apps/api/src/domains/operations/audit/audit.service.ts index f6081c7..4f0eda1 100644 --- a/apps/api/src/domains/operations/audit/audit.service.ts +++ b/apps/api/src/domains/operations/audit/audit.service.ts @@ -31,7 +31,7 @@ export class AuditService { }); } - async list(page = 1, pageSize = 50, module?: string) { + async list(page = 1, pageSize = 10, module?: string) { const skip = (page - 1) * pageSize; const where = module ? { module } : {}; const [items, total] = await Promise.all([ diff --git a/apps/player/src/assets/images/足球赛事下注背景框.png b/apps/player/src/assets/images/足球赛事下注背景框.png new file mode 100644 index 0000000..a7de43b Binary files /dev/null and b/apps/player/src/assets/images/足球赛事下注背景框.png differ diff --git a/apps/player/src/components/outright/OutrightBetModal.vue b/apps/player/src/components/outright/OutrightBetModal.vue index 871ce77..9d23ead 100644 --- a/apps/player/src/components/outright/OutrightBetModal.vue +++ b/apps/player/src/components/outright/OutrightBetModal.vue @@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import api from '../../api'; import { formatMoney, parseAmount } from '../../utils/localeDisplay'; +import { teamFlagUrl } from '../../utils/teamFlag'; export interface OutrightPick { selectionId: string; @@ -30,8 +31,26 @@ const balance = ref(0); const successBalance = ref(0); const successStake = ref(0); +const flagUrl = computed(() => + props.pick ? teamFlagUrl(props.pick.teamCode, props.pick.teamName) : null, +); + const balanceText = computed(() => formatMoney(balance.value, locale.value)); +const oddsNum = computed(() => { + if (!props.pick) return 0; + const n = parseFloat(props.pick.odds); + return Number.isFinite(n) ? n : 0; +}); + +const estReturn = computed(() => { + const s = Number(stake.value); + if (!s || s <= 0 || !oddsNum.value) return 0; + return s * oddsNum.value; +}); + +const estReturnText = computed(() => formatMoney(estReturn.value, locale.value)); + watch( () => props.open, async (v) => { @@ -56,8 +75,20 @@ function genRequestId() { return `${Date.now()}-${Math.random().toString(36).slice(2)}`; } +function setStake(amount: number) { + stake.value = Math.max(0.01, Math.round(amount * 100) / 100); +} + +function setMaxStake() { + if (balance.value > 0) setStake(balance.value); +} + async function submit() { if (!props.pick || stake.value <= 0) return; + if (stake.value > balance.value) { + error.value = t('bet.outright_insufficient'); + return; + } loading.value = true; error.value = ''; try { @@ -88,13 +119,26 @@ function formatOdds(odds: string) {