feat(admin,api,player): 返水流程优化、账单详情与数据库重置
优化返水预览/确认/作废,新增玩家账变详情与后台一键重置为 seed 数据,并修复 dev 启动时 3000 端口占用。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -63,6 +63,14 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'user.page_settings': 'Tetapan global',
|
||||
'user.global_settings': 'Kata laluan & akaun (global)',
|
||||
'user.global_settings_hint': 'Kawal sama ada semua pemain boleh ubah kata laluan/nama akaun dalam app',
|
||||
'user.reset_database': 'Set semula pangkalan data',
|
||||
'user.reset_database_hint': 'Padam semua data perniagaan dan pulihkan data demo awal. Tidak boleh dibatalkan.',
|
||||
'user.reset_database_confirm_label': 'Taip RESET untuk sahkan',
|
||||
'user.reset_database_confirm_ph': 'RESET',
|
||||
'user.reset_database_btn': 'Set semula ke data awal',
|
||||
'user.reset_database_disabled_prod': 'Dilumpuhkan dalam produksi melainkan ALLOW_DB_RESET=true',
|
||||
'user.reset_database_success': 'Pangkalan data diset semula. Sila log masuk semula.',
|
||||
'user.reset_database_accounts': 'Akaun demo',
|
||||
'user.section.password_mgmt': 'Pengurusan kata laluan',
|
||||
'user.field.current_password': 'Kata laluan semasa',
|
||||
'user.msg.created_with_password': 'Pemain dicipta. Kata laluan: {password}',
|
||||
@@ -226,7 +234,24 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'cashback.stat.players': 'Pemain',
|
||||
'cashback.stat.total': 'Jumlah rebat',
|
||||
'cashback.stat.lines': 'Baris butiran',
|
||||
'cashback.stat.effective_stake': 'Jumlah stake berkesan',
|
||||
'cashback.stat.bet_count': 'Bil. pertaruhan',
|
||||
'cashback.stat.avg_rate': 'Kadar purata',
|
||||
'cashback.batch_no': 'No. kelompok',
|
||||
'cashback.history_title': 'Rekod rebat',
|
||||
'cashback.history_empty': 'Tiada kelompok rebat',
|
||||
'cashback.filter_status': 'Status',
|
||||
'cashback.status.PREVIEW': 'Menunggu',
|
||||
'cashback.status.CONFIRMED': 'Dibayar',
|
||||
'cashback.col.period': 'Tempoh',
|
||||
'cashback.col.status': 'Status',
|
||||
'cashback.col.bet_count': 'Pertaruhan',
|
||||
'cashback.col.created_at': 'Dicipta',
|
||||
'cashback.col.confirmed_at': 'Dibayar pada',
|
||||
'cashback.col.operator': 'Operator',
|
||||
'cashback.view_detail': 'Butiran',
|
||||
'cashback.detail_title': 'Butiran kelompok',
|
||||
'cashback.detail_summary': 'Ringkasan kelompok',
|
||||
'cashback.table_title': 'Butiran rebat pemain',
|
||||
'cashback.table_total': 'Jumlah',
|
||||
'cashback.empty_items': 'Tiada rebat layak dalam tempoh ini',
|
||||
@@ -237,12 +262,16 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'cashback.col.rate': 'Kadar',
|
||||
'cashback.col.amount': 'Rebat',
|
||||
'cashback.confirm_issue': 'Sahkan bayaran',
|
||||
'cashback.cancel_issue': 'Batalkan',
|
||||
'cashback.confirm_prompt': 'Bayar rebat kelompok ini ke dompet pemain? Tindakan ini tidak boleh dibatalkan.',
|
||||
'cashback.cancel_prompt': 'Batalkan kelompok menunggu ini? Tiada kredit dompet; boleh pratonton semula.',
|
||||
'cashback.status.CANCELLED': 'Dibatalkan',
|
||||
'cashback.rules_title': 'Peraturan rebat',
|
||||
'cashback.rule_period': 'Pilih julat tarikh. Taruhan dikira mengikut masa penyelesaian dalam tempoh tersebut.',
|
||||
'cashback.rule_eligible': 'Termasuk: taruhan selesai WON/LOST (tunggal ikut stake; parlay sekali ikut stake parlay). Tidak termasuk: belum selesai, dibatalkan, batal, push, dan kadar 0.',
|
||||
'cashback.rule_formula': 'Setiap taruhan: stake × kadar rebat. Jumlah diagregat mengikut pemain.',
|
||||
'cashback.rule_rate': 'Keutamaan kadar: pemain > ejen > global > kadar lalai ejen (cth. 0.01 = 1%).',
|
||||
'cashback.rule_flow': 'Aliran: pratonton → semak jumlah → sahkan bayaran (kredit dompet, entri CASHBACK).',
|
||||
'cashback.rule_flow': 'Aliran: pratonton (satu menunggu setiap tempoh) → semak → sahkan bayaran; batalkan jika tidak perlu. Tempoh dibayar tidak boleh pratonton semula.',
|
||||
'cashback.rule_note_zero': 'Jika 0, semak taruhan WON/LOST dalam tempoh dan kadar rebat > 0.',
|
||||
|
||||
'user.field.player_id': 'ID pemain',
|
||||
@@ -532,6 +561,9 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'msg.outright_teams_added': '{n} pasukan ditambah ({skipped} dilangkau)',
|
||||
'msg.load_matches_failed': 'Gagal memuatkan perlawanan',
|
||||
'msg.cashback_issued': 'Rebat telah dikeluarkan',
|
||||
'msg.cashback_cancelled': 'Kelompok rebat dibatalkan',
|
||||
'msg.cashback_preview_ready': 'Pratonton sedia — semak dan sahkan bayaran',
|
||||
'msg.cashback_preview_replaced': 'Menggantikan {n} pratonton lama untuk tempoh ini',
|
||||
'msg.freeze_confirm_title': '{action} akaun',
|
||||
'msg.freeze_confirm_body': '{action} pemain "{name}"?{extra}',
|
||||
'msg.freeze_extra': ' Mereka tidak akan dapat log masuk.',
|
||||
|
||||
@@ -63,6 +63,14 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'user.page_settings': '全局设置',
|
||||
'user.global_settings': '密码与账号管理(全局)',
|
||||
'user.global_settings_hint': '控制所有玩家是否可在 App 内改密码、改账号名',
|
||||
'user.reset_database': '重置数据库',
|
||||
'user.reset_database_hint': '清空全部业务数据并恢复为初始演示数据(用户、赛事、注单、账变等)。此操作不可撤销。',
|
||||
'user.reset_database_confirm_label': '请输入 RESET 以确认',
|
||||
'user.reset_database_confirm_ph': '输入 RESET',
|
||||
'user.reset_database_btn': '重置为初始数据',
|
||||
'user.reset_database_disabled_prod': '生产环境已禁用;需服务端设置 ALLOW_DB_RESET=true',
|
||||
'user.reset_database_success': '数据库已重置,请使用初始账号重新登录',
|
||||
'user.reset_database_accounts': '演示账号',
|
||||
'user.section.password_mgmt': '密码管理',
|
||||
'user.field.current_password': '当前密码',
|
||||
'user.msg.created_with_password': '玩家已创建,登录密码:{password}',
|
||||
@@ -215,6 +223,7 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'audit.col.time': '时间',
|
||||
'audit.action.CREATE_PLAYER': '新建玩家',
|
||||
'audit.action.UPDATE_PLAYER': '更新玩家',
|
||||
'audit.action.RESET_DATABASE': '重置数据库',
|
||||
'audit.action.CREATE_AGENT': '新建代理',
|
||||
'audit.action.UPDATE_AGENT': '更新代理',
|
||||
'audit.module.USERS': '玩家',
|
||||
@@ -227,7 +236,24 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'cashback.stat.players': '涉及玩家数',
|
||||
'cashback.stat.total': '返水总金额',
|
||||
'cashback.stat.lines': '明细条数',
|
||||
'cashback.stat.effective_stake': '有效投注总额',
|
||||
'cashback.stat.bet_count': '计入注单数',
|
||||
'cashback.stat.avg_rate': '平均返水比例',
|
||||
'cashback.batch_no': '批次号',
|
||||
'cashback.history_title': '返水记录',
|
||||
'cashback.history_empty': '暂无返水批次记录',
|
||||
'cashback.filter_status': '批次状态',
|
||||
'cashback.status.PREVIEW': '待发放',
|
||||
'cashback.status.CONFIRMED': '已发放',
|
||||
'cashback.col.period': '统计周期',
|
||||
'cashback.col.status': '状态',
|
||||
'cashback.col.bet_count': '注单数',
|
||||
'cashback.col.created_at': '生成时间',
|
||||
'cashback.col.confirmed_at': '发放时间',
|
||||
'cashback.col.operator': '操作人',
|
||||
'cashback.view_detail': '查看明细',
|
||||
'cashback.detail_title': '返水批次明细',
|
||||
'cashback.detail_summary': '批次汇总',
|
||||
'cashback.table_title': '玩家返水明细',
|
||||
'cashback.table_total': '合计',
|
||||
'cashback.empty_items': '本周期内无符合条件的返水记录',
|
||||
@@ -238,12 +264,16 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'cashback.col.rate': '返水比例',
|
||||
'cashback.col.amount': '返水金额',
|
||||
'cashback.confirm_issue': '确认发放',
|
||||
'cashback.cancel_issue': '作废',
|
||||
'cashback.confirm_prompt': '确认向玩家钱包发放本批次返水?此操作不可撤销。',
|
||||
'cashback.cancel_prompt': '确认作废该待发放批次?作废后不会入账,可重新生成预览。',
|
||||
'cashback.status.CANCELLED': '已作废',
|
||||
'cashback.rules_title': '返水规则说明',
|
||||
'cashback.rule_period': '选择开始/结束日期,统计该周期内、按注单结算时间落在区间内的有效投注。',
|
||||
'cashback.rule_eligible': '计入:已结算且结果为「赢」或「输」的注单(单关按本金,串关按整单本金计一次)。不计入:未结算、已取消、作废、走水,以及返水比例为 0 的注单。',
|
||||
'cashback.rule_formula': '单笔返水 = 投注本金 × 适用返水比例;同一玩家多笔注单汇总后生成一条返水明细。',
|
||||
'cashback.rule_rate': '返水比例优先级:玩家专属规则 > 代理线规则 > 全局规则 > 所属代理默认返水率(在代理/玩家管理中配置,如 0.01 表示 1%)。',
|
||||
'cashback.rule_flow': '操作流程:生成预览 → 核对涉及玩家数与总金额 → 确认发放(金额入账玩家钱包,并生成 CASHBACK 账变流水)。',
|
||||
'cashback.rule_flow': '操作流程:生成预览(同周期仅保留一条待发放)→ 核对明细 → 确认发放;不需要的可作废。已发放周期不可重复预览。',
|
||||
'cashback.rule_note_zero': '预览为 0 时,请检查:周期内是否有已结算输赢注单、代理/玩家是否配置了大于 0 的返水率。',
|
||||
|
||||
'user.field.player_id': '玩家 ID',
|
||||
@@ -593,6 +623,9 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'outright.hidden_reason.MARKET_CLOSED': '冠军盘口未开放,请联系技术检查盘口状态。',
|
||||
'msg.load_matches_failed': '加载赛事失败',
|
||||
'msg.cashback_issued': '返水已发放',
|
||||
'msg.cashback_cancelled': '返水批次已作废',
|
||||
'msg.cashback_preview_ready': '预览已生成,请核对后确认发放',
|
||||
'msg.cashback_preview_replaced': '已替换同周期 {n} 条旧预览',
|
||||
'msg.freeze_confirm_title': '{action}账号',
|
||||
'msg.freeze_confirm_body': '确定要{action}玩家「{name}」吗?{extra}',
|
||||
'msg.freeze_extra': '冻结后该账号将无法登录。',
|
||||
@@ -664,6 +697,14 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'user.page_settings': 'Global settings',
|
||||
'user.global_settings': 'Password & account (global)',
|
||||
'user.global_settings_hint': 'Controls whether all players can change password or username in the app',
|
||||
'user.reset_database': 'Reset database',
|
||||
'user.reset_database_hint': 'Wipes all business data and restores initial demo seed (users, matches, bets, ledger, etc.). This cannot be undone.',
|
||||
'user.reset_database_confirm_label': 'Type RESET to confirm',
|
||||
'user.reset_database_confirm_ph': 'RESET',
|
||||
'user.reset_database_btn': 'Reset to initial data',
|
||||
'user.reset_database_disabled_prod': 'Disabled in production unless ALLOW_DB_RESET=true is set on the server',
|
||||
'user.reset_database_success': 'Database reset complete. Sign in again with demo accounts.',
|
||||
'user.reset_database_accounts': 'Demo accounts',
|
||||
'user.section.password_mgmt': 'Password management',
|
||||
'user.field.current_password': 'Current password',
|
||||
'user.msg.created_with_password': 'Player created. Login password: {password}',
|
||||
@@ -816,6 +857,7 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'audit.col.time': 'Time',
|
||||
'audit.action.CREATE_PLAYER': 'Create player',
|
||||
'audit.action.UPDATE_PLAYER': 'Update player',
|
||||
'audit.action.RESET_DATABASE': 'Reset database',
|
||||
'audit.action.CREATE_AGENT': 'Create agent',
|
||||
'audit.action.UPDATE_AGENT': 'Update agent',
|
||||
'audit.module.USERS': 'Players',
|
||||
@@ -828,7 +870,24 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'cashback.stat.players': 'Players',
|
||||
'cashback.stat.total': 'Total cashback',
|
||||
'cashback.stat.lines': 'Line items',
|
||||
'cashback.stat.effective_stake': 'Total effective stake',
|
||||
'cashback.stat.bet_count': 'Eligible bets',
|
||||
'cashback.stat.avg_rate': 'Average rate',
|
||||
'cashback.batch_no': 'Batch no.',
|
||||
'cashback.history_title': 'Cashback history',
|
||||
'cashback.history_empty': 'No cashback batches yet',
|
||||
'cashback.filter_status': 'Status',
|
||||
'cashback.status.PREVIEW': 'Pending',
|
||||
'cashback.status.CONFIRMED': 'Paid out',
|
||||
'cashback.col.period': 'Period',
|
||||
'cashback.col.status': 'Status',
|
||||
'cashback.col.bet_count': 'Bets',
|
||||
'cashback.col.created_at': 'Created',
|
||||
'cashback.col.confirmed_at': 'Paid at',
|
||||
'cashback.col.operator': 'Operator',
|
||||
'cashback.view_detail': 'Details',
|
||||
'cashback.detail_title': 'Batch breakdown',
|
||||
'cashback.detail_summary': 'Batch summary',
|
||||
'cashback.table_title': 'Player cashback breakdown',
|
||||
'cashback.table_total': 'Total',
|
||||
'cashback.empty_items': 'No eligible cashback in this period',
|
||||
@@ -839,12 +898,16 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'cashback.col.rate': 'Rate',
|
||||
'cashback.col.amount': 'Cashback',
|
||||
'cashback.confirm_issue': 'Confirm payout',
|
||||
'cashback.cancel_issue': 'Void',
|
||||
'cashback.confirm_prompt': 'Pay out this cashback batch to player wallets? This cannot be undone.',
|
||||
'cashback.cancel_prompt': 'Void this pending batch? No wallet credit will be made; you can preview again.',
|
||||
'cashback.status.CANCELLED': 'Voided',
|
||||
'cashback.rules_title': 'Cashback rules',
|
||||
'cashback.rule_period': 'Pick a date range. Bets are included by settlement time within that period.',
|
||||
'cashback.rule_eligible': 'Included: settled bets with result WON or LOST (singles by stake; parlays counted once by parlay stake). Excluded: pending, cancelled, void, push, and zero-rate bets.',
|
||||
'cashback.rule_formula': 'Per bet: stake × applicable cashback rate. Amounts are summed per player into one line item.',
|
||||
'cashback.rule_rate': 'Rate priority: player rule > agent rule > global rule > agent default rate (set under Agents/Players, e.g. 0.01 = 1%).',
|
||||
'cashback.rule_flow': 'Flow: preview → verify player count and total → confirm payout (credits wallet, creates CASHBACK ledger entries).',
|
||||
'cashback.rule_flow': 'Flow: preview (one pending batch per period) → review → confirm payout; void if not needed. Paid periods cannot be previewed again.',
|
||||
'cashback.rule_note_zero': 'If preview is 0, check for settled WON/LOST bets in the period and a cashback rate above 0.',
|
||||
|
||||
'user.field.player_id': 'Player ID',
|
||||
@@ -1195,6 +1258,9 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'outright.hidden_reason.MARKET_CLOSED': 'Winner market is not open.',
|
||||
'msg.load_matches_failed': 'Failed to load matches',
|
||||
'msg.cashback_issued': 'Cashback issued',
|
||||
'msg.cashback_cancelled': 'Cashback batch voided',
|
||||
'msg.cashback_preview_ready': 'Preview ready — review and confirm payout',
|
||||
'msg.cashback_preview_replaced': 'Replaced {n} older preview(s) for this period',
|
||||
'msg.freeze_confirm_title': '{action} account',
|
||||
'msg.freeze_confirm_body': '{action} player "{name}"?{extra}',
|
||||
'msg.freeze_extra': ' They will not be able to sign in.',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type { TableColumnCtx } from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { formatAmount, formatAmountFull } from '../utils/format-amount';
|
||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
import api from '../api';
|
||||
|
||||
interface CashbackBatch {
|
||||
@@ -13,6 +14,12 @@ interface CashbackBatch {
|
||||
periodEnd: string;
|
||||
playerCount: number;
|
||||
totalAmount: string;
|
||||
totalEffectiveStake?: string;
|
||||
totalBetCount?: number;
|
||||
status: string;
|
||||
operatorUsername?: string | null;
|
||||
confirmedAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface CashbackPreviewItem {
|
||||
@@ -20,6 +27,7 @@ interface CashbackPreviewItem {
|
||||
username: string;
|
||||
agentUsername: string | null;
|
||||
effectiveStake: string;
|
||||
betCount: number;
|
||||
rate: string;
|
||||
amount: string;
|
||||
}
|
||||
@@ -28,19 +36,35 @@ interface CashbackPreview {
|
||||
batch: CashbackBatch;
|
||||
items: CashbackPreviewItem[];
|
||||
totalAmount: string;
|
||||
totalEffectiveStake?: string;
|
||||
totalBetCount?: number;
|
||||
avgRate?: string;
|
||||
replacedPreviewCount?: number;
|
||||
}
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
|
||||
const preview = ref<CashbackPreview | null>(null);
|
||||
const rulesVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const historyLoading = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const detail = ref<CashbackPreview | null>(null);
|
||||
|
||||
const history = ref<CashbackBatch[]>([]);
|
||||
const historyTotal = ref(0);
|
||||
const historyPage = ref(1);
|
||||
const historyPageSize = ref(10);
|
||||
const historyStatus = ref('');
|
||||
|
||||
const period = ref({
|
||||
start: new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10),
|
||||
end: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
|
||||
const previewItems = computed(() => preview.value?.items ?? []);
|
||||
const detailItems = computed(() => detail.value?.items ?? []);
|
||||
|
||||
function formatRate(value: string | number | null | undefined) {
|
||||
const n = Number(value);
|
||||
@@ -53,22 +77,75 @@ function formatPeriodDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
function formatPeriodRange(row: Pick<CashbackBatch, 'periodStart' | 'periodEnd'>) {
|
||||
return `${formatPeriodDate(row.periodStart)} — ${formatPeriodDate(row.periodEnd)}`;
|
||||
}
|
||||
|
||||
function formatTime(value: string | null | undefined) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString(localeTag.value, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const key = `cashback.status.${status}`;
|
||||
const label = t(key);
|
||||
return label === key ? status : label;
|
||||
}
|
||||
|
||||
function statusTagType(status: string) {
|
||||
if (status === 'CONFIRMED') return 'success';
|
||||
if (status === 'CANCELLED') return 'info';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
function apiErrorMessage(err: unknown, fallback: string) {
|
||||
const msg = (err as { response?: { data?: { message?: string | string[] } } })?.response?.data?.message;
|
||||
if (Array.isArray(msg)) return msg.join(';');
|
||||
if (typeof msg === 'string' && msg.trim()) return msg;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function tableSummary(param: {
|
||||
columns: TableColumnCtx<CashbackPreviewItem>[];
|
||||
data: CashbackPreviewItem[];
|
||||
}) {
|
||||
const { columns, data } = param;
|
||||
const stakeSum = data.reduce((s, row) => s + Number(row.effectiveStake), 0);
|
||||
const betSum = data.reduce((s, row) => s + Number(row.betCount ?? 0), 0);
|
||||
const amountSum = data.reduce((s, row) => s + Number(row.amount), 0);
|
||||
|
||||
return columns.map((col, index) => {
|
||||
if (index === 0) return t('cashback.table_total');
|
||||
if (col.property === 'betCount') return String(betSum);
|
||||
if (col.property === 'effectiveStake') return formatAmount(stakeSum);
|
||||
if (col.property === 'amount') return formatAmount(amountSum);
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/admin/cashbacks', {
|
||||
params: {
|
||||
page: historyPage.value,
|
||||
pageSize: historyPageSize.value,
|
||||
status: historyStatus.value || undefined,
|
||||
},
|
||||
});
|
||||
history.value = (data.data.items ?? []) as CashbackBatch[];
|
||||
historyTotal.value = data.data.total ?? 0;
|
||||
} finally {
|
||||
historyLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePreview() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -77,17 +154,98 @@ async function generatePreview() {
|
||||
periodEnd: period.value.end,
|
||||
});
|
||||
preview.value = data.data as CashbackPreview;
|
||||
const replaced = preview.value.replacedPreviewCount ?? 0;
|
||||
if (replaced > 0) {
|
||||
ElMessage.warning(t('msg.cashback_preview_replaced', { n: replaced }));
|
||||
} else {
|
||||
ElMessage.success(t('msg.cashback_preview_ready'));
|
||||
}
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
ElMessage.error(apiErrorMessage(err, t('msg.error')));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBatchId(batchId: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('cashback.confirm_prompt'), t('common.confirm'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('cashback.confirm_issue'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/admin/cashbacks/${batchId}/confirm`);
|
||||
ElMessage.success(t('msg.cashback_issued'));
|
||||
if (preview.value?.batch.id === batchId) preview.value = null;
|
||||
if (detail.value?.batch.id === batchId) detailVisible.value = false;
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
ElMessage.error(apiErrorMessage(err, t('msg.error')));
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelBatchId(batchId: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('cashback.cancel_prompt'), t('common.confirm'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('cashback.cancel_issue'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/admin/cashbacks/${batchId}/cancel`);
|
||||
ElMessage.success(t('msg.cashback_cancelled'));
|
||||
if (preview.value?.batch.id === batchId) preview.value = null;
|
||||
if (detail.value?.batch.id === batchId) detailVisible.value = false;
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
ElMessage.error(apiErrorMessage(err, t('msg.error')));
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!preview.value?.batch) return;
|
||||
await api.post(`/admin/cashbacks/${preview.value.batch.id}/confirm`);
|
||||
ElMessage.success(t('msg.cashback_issued'));
|
||||
preview.value = null;
|
||||
await confirmBatchId(preview.value.batch.id);
|
||||
}
|
||||
|
||||
async function openDetail(batchId: string) {
|
||||
detailLoading.value = true;
|
||||
detailVisible.value = true;
|
||||
detail.value = null;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/cashbacks/${batchId}`);
|
||||
detail.value = data.data as CashbackPreview;
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onHistoryPageChange(p: number) {
|
||||
historyPage.value = p;
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
function onHistorySizeChange(size: number) {
|
||||
historyPageSize.value = size;
|
||||
historyPage.value = 1;
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
function onHistoryStatusChange() {
|
||||
historyPage.value = 1;
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
onMounted(loadHistory);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -143,29 +301,46 @@ async function confirm() {
|
||||
<div class="preview-meta">
|
||||
<span>{{ t('cashback.batch_no') }}:{{ preview.batch.batchNo }}</span>
|
||||
<span class="meta-sep">·</span>
|
||||
<span>
|
||||
{{ formatPeriodDate(preview.batch.periodStart) }}
|
||||
—
|
||||
{{ formatPeriodDate(preview.batch.periodEnd) }}
|
||||
</span>
|
||||
<span>{{ formatPeriodRange(preview.batch) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-tag :type="statusTagType(preview.batch.status)" size="small">
|
||||
{{ statusLabel(preview.batch.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" class="preview-stats">
|
||||
<el-col :span="8">
|
||||
<el-row :gutter="12" class="preview-stats">
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ preview.batch.playerCount ?? 0 }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.players') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value pstat-green">{{ formatAmount(preview.totalAmount) }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.total') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ formatAmount(preview.totalEffectiveStake ?? 0) }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.effective_stake') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ preview.totalBetCount ?? 0 }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.bet_count') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ formatRate(preview.avgRate) }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.avg_rate') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ previewItems.length }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.lines') }}</div>
|
||||
@@ -200,6 +375,12 @@ async function confirm() {
|
||||
>
|
||||
<template #default="{ row }">{{ row.agentUsername || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="betCount"
|
||||
:label="t('cashback.col.bet_count')"
|
||||
width="88"
|
||||
align="right"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="effectiveStake"
|
||||
:label="t('cashback.col.effective_stake')"
|
||||
@@ -225,11 +406,173 @@ async function confirm() {
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-button type="success" class="confirm-btn" :disabled="previewItems.length === 0" @click="confirm">
|
||||
{{ t('cashback.confirm_issue') }}
|
||||
</el-button>
|
||||
<div v-if="preview.batch.status === 'PREVIEW'" class="preview-actions">
|
||||
<el-button type="success" :disabled="previewItems.length === 0" @click="confirm">
|
||||
{{ t('cashback.confirm_issue') }}
|
||||
</el-button>
|
||||
<el-button type="danger" plain @click="cancelBatchId(preview.batch.id)">
|
||||
{{ t('cashback.cancel_issue') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<el-card class="data-card history-card" shadow="never">
|
||||
<div class="history-head">
|
||||
<div class="table-title">{{ t('cashback.history_title') }}</div>
|
||||
<el-select
|
||||
v-model="historyStatus"
|
||||
clearable
|
||||
:placeholder="t('common.all')"
|
||||
style="width: 140px"
|
||||
@change="onHistoryStatusChange"
|
||||
>
|
||||
<el-option :label="t('cashback.status.PREVIEW')" value="PREVIEW" />
|
||||
<el-option :label="t('cashback.status.CONFIRMED')" value="CONFIRMED" />
|
||||
<el-option :label="t('cashback.status.CANCELLED')" value="CANCELLED" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<el-table v-loading="historyLoading" :data="history" stripe>
|
||||
<template #empty>
|
||||
<AdminTableEmpty :text="t('cashback.history_empty')" />
|
||||
</template>
|
||||
<el-table-column prop="batchNo" :label="t('cashback.batch_no')" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column :label="t('cashback.col.period')" min-width="190">
|
||||
<template #default="{ row }">{{ formatPeriodRange(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cashback.col.status')" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="playerCount" :label="t('cashback.stat.players')" width="96" align="right" />
|
||||
<el-table-column prop="totalBetCount" :label="t('cashback.col.bet_count')" width="88" align="right">
|
||||
<template #default="{ row }">{{ row.totalBetCount ?? 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cashback.stat.effective_stake')" min-width="120" align="right">
|
||||
<template #default="{ row }">{{ formatAmount(row.totalEffectiveStake ?? 0) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cashback.stat.total')" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="amount-cell">{{ formatAmount(row.totalAmount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cashback.col.created_at')" min-width="150">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cashback.col.confirmed_at')" min-width="150">
|
||||
<template #default="{ row }">{{ formatTime(row.confirmedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cashback.col.operator')" min-width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.operatorUsername || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row.id)">
|
||||
{{ t('cashback.view_detail') }}
|
||||
</el-button>
|
||||
<template v-if="row.status === 'PREVIEW'">
|
||||
<el-button link type="success" @click="confirmBatchId(row.id)">
|
||||
{{ t('cashback.confirm_issue') }}
|
||||
</el-button>
|
||||
<el-button link type="danger" @click="cancelBatchId(row.id)">
|
||||
{{ t('cashback.cancel_issue') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="historyPage"
|
||||
v-model:page-size="historyPageSize"
|
||||
:total="historyTotal"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@current-change="onHistoryPageChange"
|
||||
@size-change="onHistorySizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
:title="t('cashback.detail_title')"
|
||||
width="920px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="detail">
|
||||
<div class="detail-meta">
|
||||
<span>{{ t('cashback.batch_no') }}:{{ detail.batch.batchNo }}</span>
|
||||
<span class="meta-sep">·</span>
|
||||
<span>{{ formatPeriodRange(detail.batch) }}</span>
|
||||
<el-tag :type="statusTagType(detail.batch.status)" size="small" class="detail-tag">
|
||||
{{ statusLabel(detail.batch.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="detail-summary">{{ t('cashback.detail_summary') }}</div>
|
||||
<el-row :gutter="12" class="preview-stats detail-stats">
|
||||
<el-col :span="8">
|
||||
<div class="pstat pstat-compact">
|
||||
<div class="pstat-value">{{ detail.batch.playerCount ?? 0 }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.players') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="pstat pstat-compact">
|
||||
<div class="pstat-value pstat-green">{{ formatAmount(detail.totalAmount) }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.total') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="pstat pstat-compact">
|
||||
<div class="pstat-value">{{ detail.totalBetCount ?? 0 }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.bet_count') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="table-wrap">
|
||||
<el-table
|
||||
:data="detailItems"
|
||||
stripe
|
||||
max-height="360"
|
||||
show-summary
|
||||
:summary-method="tableSummary"
|
||||
>
|
||||
<el-table-column type="index" :label="t('cashback.col.index')" width="56" align="center" />
|
||||
<el-table-column prop="username" :label="t('cashback.col.player')" min-width="110" />
|
||||
<el-table-column prop="agentUsername" :label="t('cashback.col.agent')" min-width="100">
|
||||
<template #default="{ row }">{{ row.agentUsername || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="betCount" :label="t('cashback.col.bet_count')" width="88" align="right" />
|
||||
<el-table-column prop="effectiveStake" :label="t('cashback.col.effective_stake')" min-width="110" align="right">
|
||||
<template #default="{ row }">{{ formatAmount(row.effectiveStake) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rate" :label="t('cashback.col.rate')" width="96" align="right">
|
||||
<template #default="{ row }">{{ formatRate(row.rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="amount" :label="t('cashback.col.amount')" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="amount-cell">{{ formatAmount(row.amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-if="detail?.batch.status === 'PREVIEW'" class="detail-actions">
|
||||
<el-button type="success" :disabled="detailItems.length === 0" @click="confirmBatchId(detail.batch.id)">
|
||||
{{ t('cashback.confirm_issue') }}
|
||||
</el-button>
|
||||
<el-button type="danger" plain @click="cancelBatchId(detail.batch.id)">
|
||||
{{ t('cashback.cancel_issue') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -278,25 +621,44 @@ async function confirm() {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
.history-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.preview-head,
|
||||
.history-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 15px;
|
||||
.preview-title,
|
||||
.table-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
.preview-meta,
|
||||
.detail-meta {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.detail-tag {
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.detail-summary {
|
||||
margin: 12px 0 8px;
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.meta-sep {
|
||||
margin: 0 6px;
|
||||
}
|
||||
@@ -305,16 +667,25 @@ async function confirm() {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pstat {
|
||||
padding: 16px;
|
||||
padding: 14px 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pstat-compact {
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.pstat-value {
|
||||
font-size: 26px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
@@ -330,13 +701,6 @@ async function confirm() {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -346,7 +710,17 @@ async function confirm() {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
.preview-actions,
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { resolveFormError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
import { clearStaffSession } from '../stores/auth';
|
||||
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
const router = useRouter();
|
||||
import {
|
||||
emptyPlayerCreateForm,
|
||||
emptyPlayerEditForm,
|
||||
@@ -58,15 +61,63 @@ const bettingLimits = ref({
|
||||
});
|
||||
const settingsSaving = ref(false);
|
||||
const limitsSaving = ref(false);
|
||||
const resetAllowed = ref(false);
|
||||
const resetLoading = ref(false);
|
||||
const resetConfirmPhrase = ref('');
|
||||
const settingsCollapseOpen = ref<string[]>([]);
|
||||
|
||||
onMounted(() => {
|
||||
loadAgentOptions();
|
||||
loadPlayerSettings();
|
||||
loadBettingLimits();
|
||||
loadResetDatabaseStatus();
|
||||
load();
|
||||
});
|
||||
|
||||
async function loadResetDatabaseStatus() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/system/reset-database');
|
||||
resetAllowed.value = !!data.data?.allowed;
|
||||
} catch {
|
||||
resetAllowed.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDatabase() {
|
||||
if (resetConfirmPhrase.value !== 'RESET') {
|
||||
ElMessage.warning(t('user.reset_database_confirm_label'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(t('user.reset_database_hint'), t('user.reset_database'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('user.reset_database_btn'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
resetLoading.value = true;
|
||||
try {
|
||||
const { data } = await api.post('/admin/system/reset-database', {
|
||||
confirmPhrase: 'RESET',
|
||||
});
|
||||
const accounts: string[] = data.data?.demoAccounts ?? [];
|
||||
ElMessage.success({
|
||||
message: `${t('user.reset_database_success')}\n${t('user.reset_database_accounts')}: ${accounts.join(' · ')}`,
|
||||
duration: 8000,
|
||||
});
|
||||
clearStaffSession();
|
||||
await router.push('/login');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
resetLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBettingLimits() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/settings/betting-limits');
|
||||
@@ -370,6 +421,40 @@ function statusLabel(s: string) {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="list-settings-block list-settings-block--danger">
|
||||
<p class="list-settings-title">{{ t('user.reset_database') }}</p>
|
||||
<p class="list-settings-hint">{{ t('user.reset_database_hint') }}</p>
|
||||
<el-alert
|
||||
v-if="!resetAllowed"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="reset-db-alert"
|
||||
:title="t('user.reset_database_disabled_prod')"
|
||||
/>
|
||||
<el-form inline size="small" class="settings-form reset-db-form">
|
||||
<el-form-item :label="t('user.reset_database_confirm_label')">
|
||||
<el-input
|
||||
v-model="resetConfirmPhrase"
|
||||
:placeholder="t('user.reset_database_confirm_ph')"
|
||||
style="width: 160px"
|
||||
:disabled="!resetAllowed"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
:loading="resetLoading"
|
||||
:disabled="!resetAllowed || resetConfirmPhrase !== 'RESET'"
|
||||
@click="resetDatabase"
|
||||
>
|
||||
{{ t('user.reset_database_btn') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
@@ -793,6 +878,20 @@ function statusLabel(s: string) {
|
||||
.edit-stats {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.list-settings-block--danger {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed rgba(245, 108, 108, 0.35);
|
||||
}
|
||||
.list-settings-hint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.reset-db-alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"dev": "node ../../scripts/ensure-port-free.mjs 3000 && nest start --watch",
|
||||
"start": "node dist/main",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "cashback_batches" ADD COLUMN "total_effective_stake" DECIMAL(18,4) NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "cashback_batches" ADD COLUMN "total_bet_count" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "cashback_items" ADD COLUMN "bet_count" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -494,16 +494,18 @@ model CashbackRule {
|
||||
}
|
||||
|
||||
model CashbackBatch {
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchNo String @unique @map("batch_no") @db.VarChar(64)
|
||||
periodStart DateTime @map("period_start")
|
||||
periodEnd DateTime @map("period_end")
|
||||
status String @default("PREVIEW") @db.VarChar(20)
|
||||
totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(18, 4)
|
||||
playerCount Int @default(0) @map("player_count")
|
||||
operatorId BigInt? @map("operator_id")
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchNo String @unique @map("batch_no") @db.VarChar(64)
|
||||
periodStart DateTime @map("period_start")
|
||||
periodEnd DateTime @map("period_end")
|
||||
status String @default("PREVIEW") @db.VarChar(20)
|
||||
totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(18, 4)
|
||||
totalEffectiveStake Decimal @default(0) @map("total_effective_stake") @db.Decimal(18, 4)
|
||||
totalBetCount Int @default(0) @map("total_bet_count")
|
||||
playerCount Int @default(0) @map("player_count")
|
||||
operatorId BigInt? @map("operator_id")
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
items CashbackItem[]
|
||||
|
||||
@@ -515,6 +517,7 @@ model CashbackItem {
|
||||
batchId BigInt @map("batch_id")
|
||||
userId BigInt @map("user_id")
|
||||
effectiveStake Decimal @map("effective_stake") @db.Decimal(18, 4)
|
||||
betCount Int @default(0) @map("bet_count")
|
||||
rate Decimal @db.Decimal(8, 4)
|
||||
amount Decimal @db.Decimal(18, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@ -1,731 +1,8 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { syncWc2026OutrightMarket } from '../src/domains/catalog/wc2026-outright.sync';
|
||||
import { runSeed } from '../src/infrastructure/database/run-seed';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/** 为演示赛事补齐详情页玩法(与后台 markets 模板一致) */
|
||||
async function seedDemoMarkets(matchId: bigint) {
|
||||
const configs: Array<{
|
||||
marketType: string;
|
||||
period: string;
|
||||
lineValue?: number;
|
||||
sortOrder: number;
|
||||
selections: Array<{ code: string; name: string; odds: number }>;
|
||||
}> = [
|
||||
{
|
||||
marketType: 'FT_1X2',
|
||||
period: 'FT',
|
||||
sortOrder: 1,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '主胜', odds: 2.5 },
|
||||
{ code: 'DRAW', name: '和', odds: 3.2 },
|
||||
{ code: 'AWAY', name: '客胜', odds: 2.8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_HANDICAP',
|
||||
period: 'FT',
|
||||
lineValue: -0.5,
|
||||
sortOrder: 2,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '主 -0.5', odds: 1.9 },
|
||||
{ code: 'AWAY', name: '客 +0.5', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
period: 'FT',
|
||||
lineValue: 2.5,
|
||||
sortOrder: 3,
|
||||
selections: [
|
||||
{ code: 'OVER', name: '大 2.5', odds: 1.85 },
|
||||
{ code: 'UNDER', name: '小 2.5', odds: 1.95 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_ODD_EVEN',
|
||||
period: 'FT',
|
||||
sortOrder: 4,
|
||||
selections: [
|
||||
{ code: 'ODD', name: '单', odds: 1.9 },
|
||||
{ code: 'EVEN', name: '双', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_1X2',
|
||||
period: 'HT',
|
||||
sortOrder: 5,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '半场主', odds: 3.0 },
|
||||
{ code: 'DRAW', name: '半场和', odds: 2.0 },
|
||||
{ code: 'AWAY', name: '半场客', odds: 3.5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_HANDICAP',
|
||||
period: 'HT',
|
||||
lineValue: -0.5,
|
||||
sortOrder: 6,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '半场主 -0.5', odds: 1.9 },
|
||||
{ code: 'AWAY', name: '半场客 +0.5', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_OVER_UNDER',
|
||||
period: 'HT',
|
||||
lineValue: 1.5,
|
||||
sortOrder: 7,
|
||||
selections: [
|
||||
{ code: 'OVER', name: '半场大 1.5', odds: 2.0 },
|
||||
{ code: 'UNDER', name: '半场小 1.5', odds: 1.75 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_CORRECT_SCORE',
|
||||
period: 'FT',
|
||||
sortOrder: 8,
|
||||
selections: [
|
||||
{ code: 'SCORE_1_0', name: '1-0', odds: 4.86 },
|
||||
{ code: 'SCORE_2_0', name: '2-0', odds: 5.22 },
|
||||
{ code: 'SCORE_2_1', name: '2-1', odds: 7.92 },
|
||||
{ code: 'SCORE_3_0', name: '3-0', odds: 8.28 },
|
||||
{ code: 'SCORE_0_0', name: '0-0', odds: 8.64 },
|
||||
{ code: 'SCORE_1_1', name: '1-1', odds: 7.47 },
|
||||
{ code: 'SCORE_2_2', name: '2-2', odds: 24.3 },
|
||||
{ code: 'SCORE_3_3', name: '3-3', odds: 175.5 },
|
||||
{ code: 'SCORE_0_1', name: '0-1', odds: 14.4 },
|
||||
{ code: 'SCORE_0_2', name: '0-2', odds: 45.9 },
|
||||
{ code: 'SCORE_1_2', name: '1-2', odds: 23.4 },
|
||||
{ code: 'SCORE_0_3', name: '0-3', odds: 207 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_CORRECT_SCORE',
|
||||
period: 'HT',
|
||||
sortOrder: 9,
|
||||
selections: [
|
||||
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
|
||||
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
|
||||
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
|
||||
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
|
||||
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
|
||||
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'SH_CORRECT_SCORE',
|
||||
period: 'SH',
|
||||
sortOrder: 10,
|
||||
selections: [
|
||||
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
|
||||
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
|
||||
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
|
||||
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
|
||||
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
|
||||
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const cfg of configs) {
|
||||
const exists = await prisma.market.findFirst({
|
||||
where: { matchId, marketType: cfg.marketType },
|
||||
include: { _count: { select: { selections: true } } },
|
||||
});
|
||||
if (exists) {
|
||||
const needRefresh =
|
||||
cfg.marketType.includes('CORRECT_SCORE') &&
|
||||
exists._count.selections < cfg.selections.length;
|
||||
if (needRefresh) {
|
||||
await prisma.market.delete({ where: { id: exists.id } });
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await prisma.market.create({
|
||||
data: {
|
||||
matchId,
|
||||
marketType: cfg.marketType,
|
||||
period: cfg.period,
|
||||
lineValue: cfg.lineValue,
|
||||
allowSingle: true,
|
||||
allowParlay: true,
|
||||
sortOrder: cfg.sortOrder,
|
||||
status: 'OPEN',
|
||||
selections: {
|
||||
create: cfg.selections.map((s, i) => ({
|
||||
selectionCode: s.code,
|
||||
selectionName: s.name,
|
||||
odds: s.odds,
|
||||
sortOrder: i,
|
||||
status: 'OPEN',
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertLeagueName(leagueId: bigint, names: Record<string, string>) {
|
||||
for (const [locale, value] of Object.entries(names)) {
|
||||
await prisma.entityTranslation.upsert({
|
||||
where: {
|
||||
entityType_entityId_locale_fieldName: {
|
||||
entityType: 'LEAGUE',
|
||||
entityId: leagueId,
|
||||
locale,
|
||||
fieldName: 'name',
|
||||
},
|
||||
},
|
||||
create: { entityType: 'LEAGUE', entityId: leagueId, locale, fieldName: 'name', value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertTeam(
|
||||
code: string,
|
||||
names: Record<string, string>,
|
||||
) {
|
||||
const team = await prisma.team.upsert({
|
||||
where: { code },
|
||||
create: { code },
|
||||
update: {},
|
||||
});
|
||||
for (const [locale, value] of Object.entries(names)) {
|
||||
await prisma.entityTranslation.upsert({
|
||||
where: {
|
||||
entityType_entityId_locale_fieldName: {
|
||||
entityType: 'TEAM',
|
||||
entityId: team.id,
|
||||
locale,
|
||||
fieldName: 'name',
|
||||
},
|
||||
},
|
||||
create: { entityType: 'TEAM', entityId: team.id, locale, fieldName: 'name', value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
return team;
|
||||
}
|
||||
|
||||
async function ensurePublishedMatch(opts: {
|
||||
leagueId: bigint;
|
||||
homeTeamId: bigint;
|
||||
awayTeamId: bigint;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
displayOrder?: number;
|
||||
}) {
|
||||
let match = await prisma.match.findFirst({
|
||||
where: {
|
||||
leagueId: opts.leagueId,
|
||||
homeTeamId: opts.homeTeamId,
|
||||
awayTeamId: opts.awayTeamId,
|
||||
status: 'PUBLISHED',
|
||||
},
|
||||
});
|
||||
if (!match) {
|
||||
match = await prisma.match.create({
|
||||
data: {
|
||||
leagueId: opts.leagueId,
|
||||
homeTeamId: opts.homeTeamId,
|
||||
awayTeamId: opts.awayTeamId,
|
||||
startTime: opts.startTime,
|
||||
status: 'PUBLISHED',
|
||||
isHot: opts.isHot ?? false,
|
||||
displayOrder: opts.displayOrder ?? 0,
|
||||
publishTime: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
match = await prisma.match.update({
|
||||
where: { id: match.id },
|
||||
data: {
|
||||
startTime: opts.startTime,
|
||||
isHot: opts.isHot ?? match.isHot,
|
||||
displayOrder: opts.displayOrder ?? match.displayOrder,
|
||||
},
|
||||
});
|
||||
}
|
||||
await seedDemoMarkets(match.id);
|
||||
return match;
|
||||
}
|
||||
|
||||
function hoursFromNow(hours: number) {
|
||||
return new Date(Date.now() + hours * 3600 * 1000);
|
||||
}
|
||||
|
||||
async function seedSportsDemo() {
|
||||
const epl = await prisma.league.upsert({
|
||||
where: { code: 'EPL' },
|
||||
create: { code: 'EPL' },
|
||||
update: {},
|
||||
});
|
||||
await upsertLeagueName(epl.id, { 'zh-CN': '英超', 'en-US': 'Premier League' });
|
||||
|
||||
const wc = await prisma.league.upsert({
|
||||
where: { code: 'WC2026' },
|
||||
create: { code: 'WC2026' },
|
||||
update: {},
|
||||
});
|
||||
await upsertLeagueName(wc.id, {
|
||||
'zh-CN': '2026世界杯(在加拿大,墨西哥和美国)',
|
||||
'en-US': '2026 FIFA World Cup',
|
||||
});
|
||||
|
||||
const teams: Array<[string, Record<string, string>]> = [
|
||||
['MUN', { 'zh-CN': '曼联', 'en-US': 'Man United' }],
|
||||
['CHE', { 'zh-CN': '切尔西', 'en-US': 'Chelsea' }],
|
||||
['MEX', { 'zh-CN': '墨西哥', 'en-US': 'Mexico' }],
|
||||
['RSA', { 'zh-CN': '南非', 'en-US': 'South Africa' }],
|
||||
['CZE', { 'zh-CN': '捷克', 'en-US': 'Czech Republic' }],
|
||||
['KOR', { 'zh-CN': '韩国', 'en-US': 'South Korea' }],
|
||||
['CAN', { 'zh-CN': '加拿大', 'en-US': 'Canada' }],
|
||||
['BIH', { 'zh-CN': '波黑', 'en-US': 'Bosnia' }],
|
||||
['USA', { 'zh-CN': '美国', 'en-US': 'USA' }],
|
||||
['PAR', { 'zh-CN': '巴拉圭', 'en-US': 'Paraguay' }],
|
||||
['SUI', { 'zh-CN': '瑞士', 'en-US': 'Switzerland' }],
|
||||
['BRA', { 'zh-CN': '巴西', 'en-US': 'Brazil' }],
|
||||
['SCO', { 'zh-CN': '苏格兰', 'en-US': 'Scotland' }],
|
||||
['TUR', { 'zh-CN': '土耳其', 'en-US': 'Turkey' }],
|
||||
['ARG', { 'zh-CN': '阿根廷', 'en-US': 'Argentina' }],
|
||||
['FRA', { 'zh-CN': '法国', 'en-US': 'France' }],
|
||||
];
|
||||
|
||||
const teamMap = new Map<string, { id: bigint }>();
|
||||
for (const [code, names] of teams) {
|
||||
teamMap.set(code, await upsertTeam(code, names));
|
||||
}
|
||||
|
||||
const get = (code: string) => {
|
||||
const t = teamMap.get(code);
|
||||
if (!t) throw new Error(`Team ${code} missing`);
|
||||
return t;
|
||||
};
|
||||
|
||||
// 英超:明日开赛 → 早盘
|
||||
await ensurePublishedMatch({
|
||||
leagueId: epl.id,
|
||||
homeTeamId: get('MUN').id,
|
||||
awayTeamId: get('CHE').id,
|
||||
startTime: hoursFromNow(26),
|
||||
isHot: true,
|
||||
displayOrder: 1,
|
||||
});
|
||||
|
||||
// 英超:今晚开赛 → 今日
|
||||
await ensurePublishedMatch({
|
||||
leagueId: epl.id,
|
||||
homeTeamId: get('CHE').id,
|
||||
awayTeamId: get('MUN').id,
|
||||
startTime: hoursFromNow(8),
|
||||
isHot: false,
|
||||
displayOrder: 2,
|
||||
});
|
||||
|
||||
const wcFixtures: Array<{
|
||||
home: string;
|
||||
away: string;
|
||||
start: Date;
|
||||
hot?: boolean;
|
||||
order: number;
|
||||
}> = [
|
||||
{ home: 'MEX', away: 'RSA', start: new Date('2026-06-12T03:00:00Z'), hot: true, order: 1 },
|
||||
{ home: 'CZE', away: 'KOR', start: new Date('2026-06-12T07:00:00Z'), order: 2 },
|
||||
{ home: 'CAN', away: 'BIH', start: new Date('2026-06-13T00:00:00Z'), order: 3 },
|
||||
{ home: 'USA', away: 'PAR', start: new Date('2026-06-13T03:00:00Z'), hot: true, order: 4 },
|
||||
{ home: 'SUI', away: 'BRA', start: new Date('2026-06-14T16:00:00Z'), order: 5 },
|
||||
{ home: 'SCO', away: 'TUR', start: new Date('2026-06-14T19:00:00Z'), order: 6 },
|
||||
{ home: 'FRA', away: 'ARG', start: new Date('2026-06-15T20:00:00Z'), hot: true, order: 7 },
|
||||
];
|
||||
|
||||
for (const f of wcFixtures) {
|
||||
await ensurePublishedMatch({
|
||||
leagueId: wc.id,
|
||||
homeTeamId: get(f.home).id,
|
||||
awayTeamId: get(f.away).id,
|
||||
startTime: f.start,
|
||||
isHot: f.hot,
|
||||
displayOrder: f.order,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` Sports demo: ${wcFixtures.length + 2} published matches`);
|
||||
}
|
||||
|
||||
async function seedOutrightDemo() {
|
||||
const wc = await prisma.league.findUnique({ where: { code: 'WC2026' } });
|
||||
if (!wc) return;
|
||||
|
||||
const { matchId, marketId } = await syncWc2026OutrightMarket(prisma, {
|
||||
forceCanonical: true,
|
||||
});
|
||||
const count = await prisma.marketSelection.count({ where: { marketId } });
|
||||
console.log(` WC2026 outright: match ${matchId}, ${count} selections`);
|
||||
}
|
||||
|
||||
async function seedPlayerDemo() {
|
||||
const player = await prisma.user.findUnique({
|
||||
where: { username: 'player1' },
|
||||
include: { wallet: true },
|
||||
});
|
||||
if (!player?.wallet) return;
|
||||
|
||||
await prisma.wallet.update({
|
||||
where: { id: player.wallet.id },
|
||||
data: { availableBalance: 88888.88 },
|
||||
});
|
||||
|
||||
await prisma.walletTransaction.upsert({
|
||||
where: { transactionId: 'DEMO-DEP-001' },
|
||||
create: {
|
||||
transactionId: 'DEMO-DEP-001',
|
||||
userId: player.id,
|
||||
walletId: player.wallet.id,
|
||||
transactionType: 'DEPOSIT',
|
||||
amount: 50000,
|
||||
balanceBefore: 1000,
|
||||
balanceAfter: 51000,
|
||||
frozenBefore: 0,
|
||||
frozenAfter: 0,
|
||||
remark: '演示充值',
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.walletTransaction.upsert({
|
||||
where: { transactionId: 'DEMO-DEP-002' },
|
||||
create: {
|
||||
transactionId: 'DEMO-DEP-002',
|
||||
userId: player.id,
|
||||
walletId: player.wallet.id,
|
||||
transactionType: 'DEPOSIT',
|
||||
amount: 37888.88,
|
||||
balanceBefore: 51000,
|
||||
balanceAfter: 88888.88,
|
||||
frozenBefore: 0,
|
||||
frozenAfter: 0,
|
||||
remark: '演示充值(二笔)',
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const sampleSel = await prisma.marketSelection.findFirst({
|
||||
where: {
|
||||
status: 'OPEN',
|
||||
market: { marketType: 'FT_1X2', match: { status: 'PUBLISHED' } },
|
||||
},
|
||||
include: { market: { include: { match: true } } },
|
||||
});
|
||||
|
||||
if (sampleSel && !(await prisma.bet.findUnique({ where: { betNo: 'DEMO-BET-001' } }))) {
|
||||
const odds = Number(sampleSel.odds);
|
||||
const stake = 200;
|
||||
await prisma.bet.create({
|
||||
data: {
|
||||
betNo: 'DEMO-BET-001',
|
||||
userId: player.id,
|
||||
agentId: player.parentId,
|
||||
betType: 'SINGLE',
|
||||
stake,
|
||||
totalOdds: odds,
|
||||
potentialReturn: stake * odds,
|
||||
status: 'PENDING',
|
||||
requestId: 'seed-demo-bet-001',
|
||||
selections: {
|
||||
create: {
|
||||
matchId: sampleSel.market.matchId,
|
||||
marketId: sampleSel.marketId,
|
||||
selectionId: sampleSel.id,
|
||||
marketType: sampleSel.market.marketType,
|
||||
period: sampleSel.market.period,
|
||||
selectionNameSnapshot: sampleSel.selectionName,
|
||||
odds: sampleSel.odds,
|
||||
oddsVersion: sampleSel.oddsVersion,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const settledSel = await prisma.marketSelection.findFirst({
|
||||
where: {
|
||||
market: { marketType: 'FT_1X2' },
|
||||
selectionCode: 'DRAW',
|
||||
},
|
||||
include: { market: true },
|
||||
});
|
||||
|
||||
if (settledSel && !(await prisma.bet.findUnique({ where: { betNo: 'DEMO-BET-002' } }))) {
|
||||
const odds = Number(settledSel.odds);
|
||||
const stake = 50;
|
||||
await prisma.bet.create({
|
||||
data: {
|
||||
betNo: 'DEMO-BET-002',
|
||||
userId: player.id,
|
||||
agentId: player.parentId,
|
||||
betType: 'SINGLE',
|
||||
stake,
|
||||
totalOdds: odds,
|
||||
potentialReturn: stake * odds,
|
||||
actualReturn: stake * odds,
|
||||
status: 'WON',
|
||||
settlementStatus: 'SETTLED',
|
||||
settledAt: new Date(Date.now() - 86400000),
|
||||
requestId: 'seed-demo-bet-002',
|
||||
selections: {
|
||||
create: {
|
||||
matchId: settledSel.market.matchId,
|
||||
marketId: settledSel.marketId,
|
||||
selectionId: settledSel.id,
|
||||
marketType: settledSel.market.marketType,
|
||||
period: settledSel.market.period,
|
||||
selectionNameSnapshot: settledSel.selectionName,
|
||||
odds: settledSel.odds,
|
||||
oddsVersion: settledSel.oddsVersion,
|
||||
resultStatus: 'WIN',
|
||||
effectiveOdds: settledSel.odds,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(' Player demo: wallet + transactions + sample bets');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Seeding database...');
|
||||
|
||||
const superAdminRole = await prisma.role.upsert({
|
||||
where: { code: 'SUPER_ADMIN' },
|
||||
create: { code: 'SUPER_ADMIN', name: 'Super Admin', description: 'Full access' },
|
||||
update: {},
|
||||
});
|
||||
|
||||
const permCodes = [
|
||||
'users.create', 'users.view', 'agents.create', 'agents.view', 'agents.credit',
|
||||
'wallet.deposit', 'wallet.withdraw', 'matches.manage', 'settlement.confirm',
|
||||
'settlement.resettle', 'cashback.confirm', 'content.manage', 'reports.view',
|
||||
'bets.view', 'settings.manage', 'audit.view',
|
||||
];
|
||||
|
||||
const permIds = new Map<string, bigint>();
|
||||
for (const code of permCodes) {
|
||||
const perm = await prisma.permission.upsert({
|
||||
where: { code },
|
||||
create: { code, name: code, module: code.split('.')[0] },
|
||||
update: {},
|
||||
});
|
||||
permIds.set(code, perm.id);
|
||||
await prisma.rolePermission.upsert({
|
||||
where: { roleId_permissionId: { roleId: superAdminRole.id, permissionId: perm.id } },
|
||||
create: { roleId: superAdminRole.id, permissionId: perm.id },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureRole(code: string, name: string, permissions: string[]) {
|
||||
const role = await prisma.role.upsert({
|
||||
where: { code },
|
||||
create: { code, name, description: name },
|
||||
update: { name },
|
||||
});
|
||||
for (const p of permissions) {
|
||||
const pid = permIds.get(p);
|
||||
if (!pid) continue;
|
||||
await prisma.rolePermission.upsert({
|
||||
where: { roleId_permissionId: { roleId: role.id, permissionId: pid } },
|
||||
create: { roleId: role.id, permissionId: pid },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
await ensureRole('MATCH_ADMIN', 'Match Admin', [
|
||||
'matches.manage', 'settlement.confirm', 'bets.view', 'reports.view', 'audit.view',
|
||||
]);
|
||||
await ensureRole('FINANCE_ADMIN', 'Finance Admin', [
|
||||
'wallet.deposit', 'wallet.withdraw', 'cashback.confirm', 'agents.view',
|
||||
'reports.view', 'bets.view', 'audit.view',
|
||||
]);
|
||||
await ensureRole('SUPPORT', 'Support', ['users.view', 'bets.view', 'reports.view', 'audit.view']);
|
||||
|
||||
const defaultBettingLimits = [
|
||||
['bet.min_stake', '1', '最小单注金额'],
|
||||
['bet.max_stake_single', '50000', '单关最大投注额'],
|
||||
['bet.max_stake_parlay', '20000', '串关最大投注额'],
|
||||
['bet.max_payout_single', '500000', '单关最高派彩'],
|
||||
['bet.max_payout_parlay', '1000000', '串关最高派彩'],
|
||||
['bet.daily_stake_limit', '200000', '玩家每日投注上限'],
|
||||
] as const;
|
||||
for (const [key, value, desc] of defaultBettingLimits) {
|
||||
await prisma.systemConfig.upsert({
|
||||
where: { configKey: key },
|
||||
create: { configKey: key, configValue: value, description: desc },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash('Admin@123', 10);
|
||||
const agentHash = await bcrypt.hash('Agent@123', 10);
|
||||
const playerHash = await bcrypt.hash('Player@123', 10);
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { username: 'admin' },
|
||||
create: {
|
||||
username: 'admin',
|
||||
userType: 'ADMIN',
|
||||
auth: { create: { passwordHash: hash } },
|
||||
adminRole: { create: { roleId: superAdminRole.id } },
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const agent1 = await prisma.user.upsert({
|
||||
where: { username: 'agent1' },
|
||||
create: {
|
||||
username: 'agent1',
|
||||
userType: 'AGENT',
|
||||
agentLevel: 1,
|
||||
auth: { create: { passwordHash: agentHash } },
|
||||
agentProfile: { create: { level: 1, creditLimit: 100000 } },
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.agentClosure.upsert({
|
||||
where: { ancestorId_descendantId: { ancestorId: agent1.id, descendantId: agent1.id } },
|
||||
create: { ancestorId: agent1.id, descendantId: agent1.id, depth: 0 },
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { username: 'agent2' },
|
||||
create: {
|
||||
username: 'agent2',
|
||||
userType: 'AGENT',
|
||||
agentLevel: 2,
|
||||
parentId: agent1.id,
|
||||
auth: { create: { passwordHash: agentHash } },
|
||||
agentProfile: { create: { level: 2, parentAgentId: agent1.id, creditLimit: 30000 } },
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { username: 'player1' },
|
||||
create: {
|
||||
username: 'player1',
|
||||
userType: 'PLAYER',
|
||||
parentId: agent1.id,
|
||||
auth: { create: { passwordHash: playerHash } },
|
||||
wallet: { create: { availableBalance: 1000 } },
|
||||
preferences: { create: { locale: 'zh-CN', managedPassword: 'Player@123' } },
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const messages = [
|
||||
{ key: 'nav.home', zh: '首页', ms: 'Laman Utama', en: 'Home' },
|
||||
{ key: 'nav.football', zh: '足球', ms: 'Bola Sepak', en: 'Football' },
|
||||
{ key: 'bet.place_bet', zh: '确认下注', ms: 'Letak Pertaruhan', en: 'Place Bet' },
|
||||
{ key: 'error.insufficient_balance', zh: '余额不足', ms: 'Baki tidak mencukupi', en: 'Insufficient balance' },
|
||||
];
|
||||
|
||||
for (const m of messages) {
|
||||
for (const [locale, value] of [['zh-CN', m.zh], ['ms-MY', m.ms], ['en-US', m.en]] as const) {
|
||||
await prisma.i18nMessage.upsert({
|
||||
where: { msgKey_locale: { msgKey: m.key, locale } },
|
||||
create: { msgKey: m.key, locale, value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await seedSportsDemo();
|
||||
await seedOutrightDemo();
|
||||
await seedPlayerDemo();
|
||||
|
||||
await prisma.content.create({
|
||||
data: {
|
||||
contentType: 'BANNER',
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 1,
|
||||
linkType: 'ROUTE',
|
||||
linkTarget: '/football',
|
||||
translations: {
|
||||
create: [
|
||||
{ locale: 'zh-CN', title: '欢迎投注', body: '足球赛事火热进行中', imageUrl: '/uploads/banners/welcome.svg' },
|
||||
{ locale: 'en-US', title: 'Welcome', body: 'Football matches available', imageUrl: '/uploads/banners/welcome.svg' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
await prisma.content.create({
|
||||
data: {
|
||||
contentType: 'BANNER',
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 2,
|
||||
translations: {
|
||||
create: [
|
||||
{ locale: 'zh-CN', title: '首存礼遇', body: '新会员专属优惠', imageUrl: '/uploads/banners/promo.svg' },
|
||||
{ locale: 'en-US', title: 'First Deposit', body: 'New member offer', imageUrl: '/uploads/banners/promo.svg' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
await prisma.content.create({
|
||||
data: {
|
||||
contentType: 'BANNER',
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 3,
|
||||
linkType: 'ROUTE',
|
||||
linkTarget: '/football',
|
||||
translations: {
|
||||
create: [
|
||||
{ locale: 'zh-CN', title: '热门赛事', body: '五大联赛天天有球', imageUrl: '/uploads/banners/hot-matches.svg' },
|
||||
{ locale: 'en-US', title: 'Hot Matches', body: 'Top leagues daily', imageUrl: '/uploads/banners/hot-matches.svg' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
await prisma.content.create({
|
||||
data: {
|
||||
contentType: 'TICKER',
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 1,
|
||||
translations: {
|
||||
create: [
|
||||
{ locale: 'zh-CN', body: '欢迎来到 TheBet365 · 热门赛事每日更新 · 请理性投注' },
|
||||
{ locale: 'en-US', body: 'Welcome to TheBet365 · Daily hot matches · Bet responsibly' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
await prisma.content.create({
|
||||
data: {
|
||||
contentType: 'NOTICE',
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 1,
|
||||
translations: {
|
||||
create: [
|
||||
{ locale: 'zh-CN', title: '系统维护通知:每周一 04:00-05:00 例行维护,敬请谅解' },
|
||||
{ locale: 'en-US', title: 'Maintenance: Every Mon 04:00-05:00 UTC' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
console.log('Seed completed! admin/Admin@123 agent1/Agent@123 player1/Player@123');
|
||||
}
|
||||
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
runSeed(prisma)
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
|
||||
@@ -16,4 +16,5 @@ export const P = {
|
||||
cashback: 'cashback.confirm',
|
||||
content: 'content.manage',
|
||||
audit: 'audit.view',
|
||||
resetDatabase: 'settings.reset_database',
|
||||
} as const;
|
||||
|
||||
@@ -32,6 +32,7 @@ import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { P } from './admin-permissions';
|
||||
import { DatabaseResetService } from '../../infrastructure/database/database-reset.service';
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
MinLength,
|
||||
IsIn,
|
||||
Min,
|
||||
Equals,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
|
||||
@@ -152,6 +154,12 @@ class PlayerAccountSettingsDto {
|
||||
allowUsernameChange?: boolean;
|
||||
}
|
||||
|
||||
class ResetDatabaseDto {
|
||||
@IsString()
|
||||
@Equals('RESET')
|
||||
confirmPhrase!: string;
|
||||
}
|
||||
|
||||
class CreateAgentAdminDto {
|
||||
/** 已有玩家用户 ID,升级为一级代理 */
|
||||
@IsString()
|
||||
@@ -675,6 +683,7 @@ export class AdminController {
|
||||
private readonly dashboardService: AdminDashboardService,
|
||||
private systemConfig: SystemConfigService,
|
||||
private bettingLimits: BettingLimitsService,
|
||||
private databaseReset: DatabaseResetService,
|
||||
) {}
|
||||
|
||||
@Get('dashboard')
|
||||
@@ -732,6 +741,32 @@ export class AdminController {
|
||||
return jsonResponse(limits);
|
||||
}
|
||||
|
||||
@Get('system/reset-database')
|
||||
@RequirePermissions(P.resetDatabase)
|
||||
getResetDatabaseStatus() {
|
||||
return jsonResponse({ allowed: this.databaseReset.isAllowed() });
|
||||
}
|
||||
|
||||
@Post('system/reset-database')
|
||||
@RequirePermissions(P.resetDatabase)
|
||||
async resetDatabase(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Body() dto: ResetDatabaseDto,
|
||||
) {
|
||||
if (dto.confirmPhrase !== 'RESET') {
|
||||
throw new BadRequestException('确认短语不正确,请输入 RESET');
|
||||
}
|
||||
const result = await this.databaseReset.resetDatabase();
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: 'RESET_DATABASE',
|
||||
module: 'SYSTEM',
|
||||
afterData: { demoAccounts: result.demoAccounts },
|
||||
});
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
@RequirePermissions(P.usersView)
|
||||
async listUsers(
|
||||
@@ -1545,6 +1580,28 @@ export class AdminController {
|
||||
return jsonResponse(preview);
|
||||
}
|
||||
|
||||
@Get('cashbacks')
|
||||
@RequirePermissions(P.cashback, P.reports)
|
||||
async listCashbacks(
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '10',
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
const result = await this.cashback.listBatches({
|
||||
page: Number(page) || 1,
|
||||
pageSize: Number(pageSize) || 10,
|
||||
status,
|
||||
});
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('cashbacks/:batchId')
|
||||
@RequirePermissions(P.cashback, P.reports)
|
||||
async getCashbackBatch(@Param('batchId') batchId: string) {
|
||||
const detail = await this.cashback.getBatchDetail(BigInt(batchId));
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Post('cashbacks/:batchId/confirm')
|
||||
@RequirePermissions(P.cashback)
|
||||
async cashbackConfirm(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
|
||||
@@ -1559,6 +1616,20 @@ export class AdminController {
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Post('cashbacks/:batchId/cancel')
|
||||
@RequirePermissions(P.cashback)
|
||||
async cashbackCancel(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
|
||||
const result = await this.cashback.cancelBatch(BigInt(batchId));
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: 'CANCEL_CASHBACK',
|
||||
module: 'CASHBACK',
|
||||
targetId: batchId,
|
||||
});
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('contents')
|
||||
@RequirePermissions(P.content, P.reports)
|
||||
async listContents(
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CashbackModule } from '../../domains/operations/cashback/cashback.modul
|
||||
import { ContentModule } from '../../domains/operations/content/content.module';
|
||||
import { I18nModule } from '../../domains/operations/i18n/i18n.module';
|
||||
import { BetsModule } from '../../domains/betting/bets.module';
|
||||
import { DatabaseModule } from '../../infrastructure/database/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -25,6 +26,7 @@ import { BetsModule } from '../../domains/betting/bets.module';
|
||||
ContentModule,
|
||||
I18nModule,
|
||||
BetsModule,
|
||||
DatabaseModule,
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminDashboardService, PermissionsGuard],
|
||||
|
||||
@@ -255,6 +255,15 @@ export class PlayerController {
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('wallet/transactions/:transactionId')
|
||||
async transactionDetail(
|
||||
@CurrentUser('id') userId: bigint,
|
||||
@Param('transactionId') transactionId: string,
|
||||
) {
|
||||
const detail = await this.wallet.getTransactionDetail(userId, transactionId);
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Get('cashbacks')
|
||||
async cashbacks(@CurrentUser('id') userId: bigint) {
|
||||
const items = await this.cashback.getUserCashbacks(userId);
|
||||
|
||||
@@ -259,6 +259,28 @@ export class WalletService {
|
||||
return this.prisma.$transaction(run);
|
||||
}
|
||||
|
||||
async getTransactionDetail(userId: bigint, transactionId: string) {
|
||||
const tx = await this.prisma.walletTransaction.findFirst({
|
||||
where: { userId, transactionId },
|
||||
});
|
||||
if (!tx) return null;
|
||||
|
||||
return {
|
||||
transactionId: tx.transactionId,
|
||||
transactionType: tx.transactionType,
|
||||
amount: tx.amount.toString(),
|
||||
balanceBefore: tx.balanceBefore.toString(),
|
||||
balanceAfter: tx.balanceAfter.toString(),
|
||||
frozenBefore: tx.frozenBefore.toString(),
|
||||
frozenAfter: tx.frozenAfter.toString(),
|
||||
referenceType: tx.referenceType,
|
||||
referenceId: tx.referenceId,
|
||||
remark: tx.remark,
|
||||
createdAt: tx.createdAt.toISOString(),
|
||||
betNo: tx.referenceType === 'BET' ? tx.referenceId : null,
|
||||
};
|
||||
}
|
||||
|
||||
async getTransactions(userId: bigint, page = 1, pageSize = 20) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const [items, total] = await Promise.all([
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, BadRequestException } 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';
|
||||
@@ -8,6 +9,16 @@ import {
|
||||
type CashbackRuleRow,
|
||||
} from './cashback-rate.resolver';
|
||||
|
||||
type AggregatedItem = {
|
||||
userId: bigint;
|
||||
effectiveStake: Decimal;
|
||||
betCount: number;
|
||||
rate: Decimal;
|
||||
amount: Decimal;
|
||||
username: string;
|
||||
agentUsername: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CashbackService {
|
||||
constructor(
|
||||
@@ -15,7 +26,12 @@ export class CashbackService {
|
||||
private wallet: WalletService,
|
||||
) {}
|
||||
|
||||
async previewBatch(periodStart: Date, periodEnd: Date) {
|
||||
private async aggregatePeriod(periodStart: Date, periodEnd: Date): Promise<{
|
||||
items: AggregatedItem[];
|
||||
totalAmount: Decimal;
|
||||
totalEffectiveStake: Decimal;
|
||||
totalBetCount: number;
|
||||
}> {
|
||||
const [settledBets, rules, agentProfiles] = await Promise.all([
|
||||
this.prisma.bet.findMany({
|
||||
where: {
|
||||
@@ -45,7 +61,7 @@ export class CashbackService {
|
||||
|
||||
const playerAgg = new Map<
|
||||
string,
|
||||
{ userId: bigint; stake: Decimal; amount: Decimal }
|
||||
{ userId: bigint; stake: Decimal; amount: Decimal; betCount: number }
|
||||
>();
|
||||
|
||||
for (const bet of settledBets) {
|
||||
@@ -69,22 +85,25 @@ export class CashbackService {
|
||||
userId: bet.userId,
|
||||
stake: new Decimal(0),
|
||||
amount: new Decimal(0),
|
||||
betCount: 0,
|
||||
};
|
||||
existing.stake = existing.stake.add(bet.stake);
|
||||
existing.amount = existing.amount.add(bet.stake.mul(rate));
|
||||
existing.betCount += 1;
|
||||
playerAgg.set(key, existing);
|
||||
}
|
||||
|
||||
const items = Array.from(playerAgg.values())
|
||||
const rawItems = Array.from(playerAgg.values())
|
||||
.map((p) => ({
|
||||
userId: p.userId,
|
||||
effectiveStake: p.stake,
|
||||
betCount: p.betCount,
|
||||
rate: p.stake.gt(0) ? p.amount.div(p.stake) : new Decimal(0),
|
||||
amount: p.amount,
|
||||
}))
|
||||
.sort((a, b) => (b.amount.gt(a.amount) ? 1 : a.amount.gt(b.amount) ? -1 : 0));
|
||||
|
||||
const userIds = items.map((i) => i.userId);
|
||||
const userIds = rawItems.map((i) => i.userId);
|
||||
const users =
|
||||
userIds.length > 0
|
||||
? await this.prisma.user.findMany({
|
||||
@@ -98,7 +117,7 @@ export class CashbackService {
|
||||
: [];
|
||||
const userById = new Map(users.map((u) => [u.id.toString(), u]));
|
||||
|
||||
const enrichedItems = items.map((item) => {
|
||||
const items: AggregatedItem[] = rawItems.map((item) => {
|
||||
const user = userById.get(item.userId.toString());
|
||||
return {
|
||||
...item,
|
||||
@@ -107,32 +126,205 @@ export class CashbackService {
|
||||
};
|
||||
});
|
||||
|
||||
const totalAmount = enrichedItems.reduce((s, i) => s.add(i.amount), new Decimal(0));
|
||||
const totalAmount = items.reduce((s, i) => s.add(i.amount), new Decimal(0));
|
||||
const totalEffectiveStake = items.reduce((s, i) => s.add(i.effectiveStake), new Decimal(0));
|
||||
const totalBetCount = items.reduce((s, i) => s + i.betCount, 0);
|
||||
|
||||
const batch = await this.prisma.cashbackBatch.create({
|
||||
data: {
|
||||
batchNo: generateBatchNo('CB'),
|
||||
periodStart,
|
||||
periodEnd,
|
||||
status: 'PREVIEW',
|
||||
totalAmount,
|
||||
playerCount: items.length,
|
||||
},
|
||||
return { items, totalAmount, totalEffectiveStake, totalBetCount };
|
||||
}
|
||||
|
||||
private normalizePeriodStart(input: Date): Date {
|
||||
const d = new Date(input);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
private normalizePeriodEnd(input: Date): Date {
|
||||
const d = new Date(input);
|
||||
d.setHours(23, 59, 59, 999);
|
||||
return d;
|
||||
}
|
||||
|
||||
private async removePreviewBatchesForPeriod(
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
tx: Prisma.TransactionClient,
|
||||
) {
|
||||
const stale = await tx.cashbackBatch.findMany({
|
||||
where: { status: 'PREVIEW', periodStart, periodEnd },
|
||||
select: { id: true },
|
||||
});
|
||||
for (const b of stale) {
|
||||
await tx.cashbackItem.deleteMany({ where: { batchId: b.id } });
|
||||
await tx.cashbackBatch.delete({ where: { id: b.id } });
|
||||
}
|
||||
return stale.length;
|
||||
}
|
||||
|
||||
for (const item of enrichedItems) {
|
||||
await this.prisma.cashbackItem.create({
|
||||
data: {
|
||||
batchId: batch.id,
|
||||
userId: item.userId,
|
||||
effectiveStake: item.effectiveStake,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
},
|
||||
});
|
||||
async previewBatch(periodStart: Date, periodEnd: Date) {
|
||||
const start = this.normalizePeriodStart(periodStart);
|
||||
const end = this.normalizePeriodEnd(periodEnd);
|
||||
if (start > end) {
|
||||
throw new BadRequestException('开始日期不能晚于结束日期');
|
||||
}
|
||||
|
||||
return { batch, items: enrichedItems, totalAmount };
|
||||
const alreadyPaid = await this.prisma.cashbackBatch.findFirst({
|
||||
where: { status: 'CONFIRMED', periodStart: start, periodEnd: end },
|
||||
});
|
||||
if (alreadyPaid) {
|
||||
throw new BadRequestException('该统计周期已发放返水,不可重复生成预览');
|
||||
}
|
||||
|
||||
const { items, totalAmount, totalEffectiveStake, totalBetCount } =
|
||||
await this.aggregatePeriod(start, end);
|
||||
|
||||
if (items.length === 0 || totalAmount.lte(0)) {
|
||||
throw new BadRequestException('该周期内无符合条件的返水,无法生成预览');
|
||||
}
|
||||
|
||||
let batch!: Awaited<ReturnType<typeof this.prisma.cashbackBatch.create>>;
|
||||
const replacedPreviewCount = await this.prisma.$transaction(async (tx) => {
|
||||
const replaced = await this.removePreviewBatchesForPeriod(start, end, tx);
|
||||
|
||||
batch = await tx.cashbackBatch.create({
|
||||
data: {
|
||||
batchNo: generateBatchNo('CB'),
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
status: 'PREVIEW',
|
||||
totalAmount,
|
||||
totalEffectiveStake,
|
||||
totalBetCount,
|
||||
playerCount: items.length,
|
||||
},
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
await tx.cashbackItem.create({
|
||||
data: {
|
||||
batchId: batch.id,
|
||||
userId: item.userId,
|
||||
effectiveStake: item.effectiveStake,
|
||||
betCount: item.betCount,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return replaced;
|
||||
});
|
||||
|
||||
const avgRate = totalEffectiveStake.gt(0)
|
||||
? totalAmount.div(totalEffectiveStake)
|
||||
: new Decimal(0);
|
||||
|
||||
return {
|
||||
batch,
|
||||
items,
|
||||
totalAmount,
|
||||
totalEffectiveStake,
|
||||
totalBetCount,
|
||||
avgRate,
|
||||
replacedPreviewCount,
|
||||
};
|
||||
}
|
||||
|
||||
async listBatches(params: { page: number; pageSize: number; status?: string }) {
|
||||
const page = Math.max(1, params.page);
|
||||
const pageSize = Math.min(100, Math.max(1, params.pageSize));
|
||||
const where = params.status?.trim() ? { status: params.status.trim() } : {};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.cashbackBatch.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.cashbackBatch.count({ where }),
|
||||
]);
|
||||
|
||||
const operatorIds = rows
|
||||
.map((r) => r.operatorId)
|
||||
.filter((id): id is bigint => id != null);
|
||||
const operators =
|
||||
operatorIds.length > 0
|
||||
? await this.prisma.user.findMany({
|
||||
where: { id: { in: operatorIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
: [];
|
||||
const operatorById = new Map(operators.map((u) => [u.id.toString(), u.username]));
|
||||
|
||||
const items = rows.map((row) => ({
|
||||
...row,
|
||||
operatorUsername: row.operatorId
|
||||
? operatorById.get(row.operatorId.toString()) ?? null
|
||||
: null,
|
||||
}));
|
||||
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getBatchDetail(batchId: bigint) {
|
||||
const batch = await this.prisma.cashbackBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
include: {
|
||||
items: { orderBy: { amount: 'desc' } },
|
||||
},
|
||||
});
|
||||
if (!batch) throw new BadRequestException('Batch not found');
|
||||
|
||||
const userIds = batch.items.map((i) => i.userId);
|
||||
const users =
|
||||
userIds.length > 0
|
||||
? await this.prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
parent: { select: { username: true } },
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const userById = new Map(users.map((u) => [u.id.toString(), u]));
|
||||
|
||||
let operatorUsername: string | null = null;
|
||||
if (batch.operatorId) {
|
||||
const op = await this.prisma.user.findUnique({
|
||||
where: { id: batch.operatorId },
|
||||
select: { username: true },
|
||||
});
|
||||
operatorUsername = op?.username ?? null;
|
||||
}
|
||||
|
||||
const items = batch.items.map((item) => {
|
||||
const user = userById.get(item.userId.toString());
|
||||
return {
|
||||
id: item.id,
|
||||
userId: item.userId,
|
||||
username: user?.username ?? '',
|
||||
agentUsername: user?.parent?.username ?? null,
|
||||
effectiveStake: item.effectiveStake,
|
||||
betCount: item.betCount,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
};
|
||||
});
|
||||
|
||||
const avgRate = batch.totalEffectiveStake.gt(0)
|
||||
? batch.totalAmount.div(batch.totalEffectiveStake)
|
||||
: new Decimal(0);
|
||||
|
||||
return {
|
||||
batch: { ...batch, operatorUsername },
|
||||
items,
|
||||
totalAmount: batch.totalAmount,
|
||||
totalEffectiveStake: batch.totalEffectiveStake,
|
||||
totalBetCount: batch.totalBetCount,
|
||||
avgRate,
|
||||
};
|
||||
}
|
||||
|
||||
async confirmBatch(batchId: bigint, operatorId: bigint) {
|
||||
@@ -141,7 +333,22 @@ export class CashbackService {
|
||||
include: { items: true },
|
||||
});
|
||||
if (!batch) throw new BadRequestException('Batch not found');
|
||||
if (batch.status !== 'PREVIEW') throw new BadRequestException('Already confirmed');
|
||||
if (batch.status !== 'PREVIEW') throw new BadRequestException('该批次不可发放');
|
||||
if (batch.items.length === 0 || batch.totalAmount.lte(0)) {
|
||||
throw new BadRequestException('批次无有效返水金额');
|
||||
}
|
||||
|
||||
const duplicate = await this.prisma.cashbackBatch.findFirst({
|
||||
where: {
|
||||
status: 'CONFIRMED',
|
||||
periodStart: batch.periodStart,
|
||||
periodEnd: batch.periodEnd,
|
||||
id: { not: batchId },
|
||||
},
|
||||
});
|
||||
if (duplicate) {
|
||||
throw new BadRequestException('该统计周期已发放返水');
|
||||
}
|
||||
|
||||
for (const item of batch.items) {
|
||||
if (item.amount.gt(0)) {
|
||||
@@ -164,6 +371,21 @@ export class CashbackService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async cancelBatch(batchId: bigint) {
|
||||
const batch = await this.prisma.cashbackBatch.findUnique({ where: { id: batchId } });
|
||||
if (!batch) throw new BadRequestException('Batch not found');
|
||||
if (batch.status !== 'PREVIEW') {
|
||||
throw new BadRequestException('只能作废待发放批次');
|
||||
}
|
||||
|
||||
await this.prisma.cashbackBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getUserCashbacks(userId: bigint) {
|
||||
return this.prisma.cashbackItem.findMany({
|
||||
where: { userId },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { DEMO_ACCOUNTS, runSeed } from './run-seed';
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseResetService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
isAllowed(): boolean {
|
||||
if (process.env.ALLOW_DB_RESET === 'true') return true;
|
||||
return process.env.NODE_ENV !== 'production';
|
||||
}
|
||||
|
||||
async resetDatabase(): Promise<{ demoAccounts: readonly string[] }> {
|
||||
if (!this.isAllowed()) {
|
||||
throw new ForbiddenException('生产环境禁止重置数据库(需设置 ALLOW_DB_RESET=true)');
|
||||
}
|
||||
|
||||
await this.truncateApplicationTables();
|
||||
await runSeed(this.prisma);
|
||||
|
||||
return { demoAccounts: DEMO_ACCOUNTS };
|
||||
}
|
||||
|
||||
private async truncateApplicationTables() {
|
||||
const rows = await this.prisma.$queryRaw<Array<{ tablename: string }>>`
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename <> '_prisma_migrations'
|
||||
`;
|
||||
|
||||
const tableNames = rows.map((r) => `"${r.tablename}"`);
|
||||
if (tableNames.length === 0) return;
|
||||
|
||||
await this.prisma.$executeRawUnsafe(
|
||||
`TRUNCATE TABLE ${tableNames.join(', ')} RESTART IDENTITY CASCADE`,
|
||||
);
|
||||
}
|
||||
}
|
||||
8
apps/api/src/infrastructure/database/database.module.ts
Normal file
8
apps/api/src/infrastructure/database/database.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseResetService } from './database-reset.service';
|
||||
|
||||
@Module({
|
||||
providers: [DatabaseResetService],
|
||||
exports: [DatabaseResetService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
736
apps/api/src/infrastructure/database/run-seed.ts
Normal file
736
apps/api/src/infrastructure/database/run-seed.ts
Normal file
@@ -0,0 +1,736 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { syncWc2026OutrightMarket } from '../../domains/catalog/wc2026-outright.sync';
|
||||
|
||||
export const DEMO_ACCOUNTS = [
|
||||
'admin / Admin@123',
|
||||
'agent1 / Agent@123',
|
||||
'player1 / Player@123',
|
||||
] as const;
|
||||
|
||||
let prisma: PrismaClient;
|
||||
|
||||
/** 为演示赛事补齐详情页玩法(与后台 markets 模板一致) */
|
||||
async function seedDemoMarkets(matchId: bigint) {
|
||||
const configs: Array<{
|
||||
marketType: string;
|
||||
period: string;
|
||||
lineValue?: number;
|
||||
sortOrder: number;
|
||||
selections: Array<{ code: string; name: string; odds: number }>;
|
||||
}> = [
|
||||
{
|
||||
marketType: 'FT_1X2',
|
||||
period: 'FT',
|
||||
sortOrder: 1,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '主胜', odds: 2.5 },
|
||||
{ code: 'DRAW', name: '和', odds: 3.2 },
|
||||
{ code: 'AWAY', name: '客胜', odds: 2.8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_HANDICAP',
|
||||
period: 'FT',
|
||||
lineValue: -0.5,
|
||||
sortOrder: 2,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '主 -0.5', odds: 1.9 },
|
||||
{ code: 'AWAY', name: '客 +0.5', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
period: 'FT',
|
||||
lineValue: 2.5,
|
||||
sortOrder: 3,
|
||||
selections: [
|
||||
{ code: 'OVER', name: '大 2.5', odds: 1.85 },
|
||||
{ code: 'UNDER', name: '小 2.5', odds: 1.95 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_ODD_EVEN',
|
||||
period: 'FT',
|
||||
sortOrder: 4,
|
||||
selections: [
|
||||
{ code: 'ODD', name: '单', odds: 1.9 },
|
||||
{ code: 'EVEN', name: '双', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_1X2',
|
||||
period: 'HT',
|
||||
sortOrder: 5,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '半场主', odds: 3.0 },
|
||||
{ code: 'DRAW', name: '半场和', odds: 2.0 },
|
||||
{ code: 'AWAY', name: '半场客', odds: 3.5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_HANDICAP',
|
||||
period: 'HT',
|
||||
lineValue: -0.5,
|
||||
sortOrder: 6,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '半场主 -0.5', odds: 1.9 },
|
||||
{ code: 'AWAY', name: '半场客 +0.5', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_OVER_UNDER',
|
||||
period: 'HT',
|
||||
lineValue: 1.5,
|
||||
sortOrder: 7,
|
||||
selections: [
|
||||
{ code: 'OVER', name: '半场大 1.5', odds: 2.0 },
|
||||
{ code: 'UNDER', name: '半场小 1.5', odds: 1.75 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_CORRECT_SCORE',
|
||||
period: 'FT',
|
||||
sortOrder: 8,
|
||||
selections: [
|
||||
{ code: 'SCORE_1_0', name: '1-0', odds: 4.86 },
|
||||
{ code: 'SCORE_2_0', name: '2-0', odds: 5.22 },
|
||||
{ code: 'SCORE_2_1', name: '2-1', odds: 7.92 },
|
||||
{ code: 'SCORE_3_0', name: '3-0', odds: 8.28 },
|
||||
{ code: 'SCORE_0_0', name: '0-0', odds: 8.64 },
|
||||
{ code: 'SCORE_1_1', name: '1-1', odds: 7.47 },
|
||||
{ code: 'SCORE_2_2', name: '2-2', odds: 24.3 },
|
||||
{ code: 'SCORE_3_3', name: '3-3', odds: 175.5 },
|
||||
{ code: 'SCORE_0_1', name: '0-1', odds: 14.4 },
|
||||
{ code: 'SCORE_0_2', name: '0-2', odds: 45.9 },
|
||||
{ code: 'SCORE_1_2', name: '1-2', odds: 23.4 },
|
||||
{ code: 'SCORE_0_3', name: '0-3', odds: 207 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_CORRECT_SCORE',
|
||||
period: 'HT',
|
||||
sortOrder: 9,
|
||||
selections: [
|
||||
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
|
||||
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
|
||||
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
|
||||
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
|
||||
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
|
||||
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'SH_CORRECT_SCORE',
|
||||
period: 'SH',
|
||||
sortOrder: 10,
|
||||
selections: [
|
||||
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
|
||||
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
|
||||
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
|
||||
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
|
||||
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
|
||||
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const cfg of configs) {
|
||||
const exists = await prisma.market.findFirst({
|
||||
where: { matchId, marketType: cfg.marketType },
|
||||
include: { _count: { select: { selections: true } } },
|
||||
});
|
||||
if (exists) {
|
||||
const needRefresh =
|
||||
cfg.marketType.includes('CORRECT_SCORE') &&
|
||||
exists._count.selections < cfg.selections.length;
|
||||
if (needRefresh) {
|
||||
await prisma.market.delete({ where: { id: exists.id } });
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await prisma.market.create({
|
||||
data: {
|
||||
matchId,
|
||||
marketType: cfg.marketType,
|
||||
period: cfg.period,
|
||||
lineValue: cfg.lineValue,
|
||||
allowSingle: true,
|
||||
allowParlay: true,
|
||||
sortOrder: cfg.sortOrder,
|
||||
status: 'OPEN',
|
||||
selections: {
|
||||
create: cfg.selections.map((s, i) => ({
|
||||
selectionCode: s.code,
|
||||
selectionName: s.name,
|
||||
odds: s.odds,
|
||||
sortOrder: i,
|
||||
status: 'OPEN',
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertLeagueName(leagueId: bigint, names: Record<string, string>) {
|
||||
for (const [locale, value] of Object.entries(names)) {
|
||||
await prisma.entityTranslation.upsert({
|
||||
where: {
|
||||
entityType_entityId_locale_fieldName: {
|
||||
entityType: 'LEAGUE',
|
||||
entityId: leagueId,
|
||||
locale,
|
||||
fieldName: 'name',
|
||||
},
|
||||
},
|
||||
create: { entityType: 'LEAGUE', entityId: leagueId, locale, fieldName: 'name', value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertTeam(
|
||||
code: string,
|
||||
names: Record<string, string>,
|
||||
) {
|
||||
const team = await prisma.team.upsert({
|
||||
where: { code },
|
||||
create: { code },
|
||||
update: {},
|
||||
});
|
||||
for (const [locale, value] of Object.entries(names)) {
|
||||
await prisma.entityTranslation.upsert({
|
||||
where: {
|
||||
entityType_entityId_locale_fieldName: {
|
||||
entityType: 'TEAM',
|
||||
entityId: team.id,
|
||||
locale,
|
||||
fieldName: 'name',
|
||||
},
|
||||
},
|
||||
create: { entityType: 'TEAM', entityId: team.id, locale, fieldName: 'name', value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
return team;
|
||||
}
|
||||
|
||||
async function ensurePublishedMatch(opts: {
|
||||
leagueId: bigint;
|
||||
homeTeamId: bigint;
|
||||
awayTeamId: bigint;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
displayOrder?: number;
|
||||
}) {
|
||||
let match = await prisma.match.findFirst({
|
||||
where: {
|
||||
leagueId: opts.leagueId,
|
||||
homeTeamId: opts.homeTeamId,
|
||||
awayTeamId: opts.awayTeamId,
|
||||
status: 'PUBLISHED',
|
||||
},
|
||||
});
|
||||
if (!match) {
|
||||
match = await prisma.match.create({
|
||||
data: {
|
||||
leagueId: opts.leagueId,
|
||||
homeTeamId: opts.homeTeamId,
|
||||
awayTeamId: opts.awayTeamId,
|
||||
startTime: opts.startTime,
|
||||
status: 'PUBLISHED',
|
||||
isHot: opts.isHot ?? false,
|
||||
displayOrder: opts.displayOrder ?? 0,
|
||||
publishTime: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
match = await prisma.match.update({
|
||||
where: { id: match.id },
|
||||
data: {
|
||||
startTime: opts.startTime,
|
||||
isHot: opts.isHot ?? match.isHot,
|
||||
displayOrder: opts.displayOrder ?? match.displayOrder,
|
||||
},
|
||||
});
|
||||
}
|
||||
await seedDemoMarkets(match.id);
|
||||
return match;
|
||||
}
|
||||
|
||||
function hoursFromNow(hours: number) {
|
||||
return new Date(Date.now() + hours * 3600 * 1000);
|
||||
}
|
||||
|
||||
async function seedSportsDemo() {
|
||||
const epl = await prisma.league.upsert({
|
||||
where: { code: 'EPL' },
|
||||
create: { code: 'EPL' },
|
||||
update: {},
|
||||
});
|
||||
await upsertLeagueName(epl.id, { 'zh-CN': '英超', 'en-US': 'Premier League' });
|
||||
|
||||
const wc = await prisma.league.upsert({
|
||||
where: { code: 'WC2026' },
|
||||
create: { code: 'WC2026' },
|
||||
update: {},
|
||||
});
|
||||
await upsertLeagueName(wc.id, {
|
||||
'zh-CN': '2026世界杯(在加拿大,墨西哥和美国)',
|
||||
'en-US': '2026 FIFA World Cup',
|
||||
});
|
||||
|
||||
const teams: Array<[string, Record<string, string>]> = [
|
||||
['MUN', { 'zh-CN': '曼联', 'en-US': 'Man United' }],
|
||||
['CHE', { 'zh-CN': '切尔西', 'en-US': 'Chelsea' }],
|
||||
['MEX', { 'zh-CN': '墨西哥', 'en-US': 'Mexico' }],
|
||||
['RSA', { 'zh-CN': '南非', 'en-US': 'South Africa' }],
|
||||
['CZE', { 'zh-CN': '捷克', 'en-US': 'Czech Republic' }],
|
||||
['KOR', { 'zh-CN': '韩国', 'en-US': 'South Korea' }],
|
||||
['CAN', { 'zh-CN': '加拿大', 'en-US': 'Canada' }],
|
||||
['BIH', { 'zh-CN': '波黑', 'en-US': 'Bosnia' }],
|
||||
['USA', { 'zh-CN': '美国', 'en-US': 'USA' }],
|
||||
['PAR', { 'zh-CN': '巴拉圭', 'en-US': 'Paraguay' }],
|
||||
['SUI', { 'zh-CN': '瑞士', 'en-US': 'Switzerland' }],
|
||||
['BRA', { 'zh-CN': '巴西', 'en-US': 'Brazil' }],
|
||||
['SCO', { 'zh-CN': '苏格兰', 'en-US': 'Scotland' }],
|
||||
['TUR', { 'zh-CN': '土耳其', 'en-US': 'Turkey' }],
|
||||
['ARG', { 'zh-CN': '阿根廷', 'en-US': 'Argentina' }],
|
||||
['FRA', { 'zh-CN': '法国', 'en-US': 'France' }],
|
||||
];
|
||||
|
||||
const teamMap = new Map<string, { id: bigint }>();
|
||||
for (const [code, names] of teams) {
|
||||
teamMap.set(code, await upsertTeam(code, names));
|
||||
}
|
||||
|
||||
const get = (code: string) => {
|
||||
const t = teamMap.get(code);
|
||||
if (!t) throw new Error(`Team ${code} missing`);
|
||||
return t;
|
||||
};
|
||||
|
||||
// 英超:明日开赛 → 早盘
|
||||
await ensurePublishedMatch({
|
||||
leagueId: epl.id,
|
||||
homeTeamId: get('MUN').id,
|
||||
awayTeamId: get('CHE').id,
|
||||
startTime: hoursFromNow(26),
|
||||
isHot: true,
|
||||
displayOrder: 1,
|
||||
});
|
||||
|
||||
// 英超:今晚开赛 → 今日
|
||||
await ensurePublishedMatch({
|
||||
leagueId: epl.id,
|
||||
homeTeamId: get('CHE').id,
|
||||
awayTeamId: get('MUN').id,
|
||||
startTime: hoursFromNow(8),
|
||||
isHot: false,
|
||||
displayOrder: 2,
|
||||
});
|
||||
|
||||
const wcFixtures: Array<{
|
||||
home: string;
|
||||
away: string;
|
||||
start: Date;
|
||||
hot?: boolean;
|
||||
order: number;
|
||||
}> = [
|
||||
{ home: 'MEX', away: 'RSA', start: new Date('2026-06-12T03:00:00Z'), hot: true, order: 1 },
|
||||
{ home: 'CZE', away: 'KOR', start: new Date('2026-06-12T07:00:00Z'), order: 2 },
|
||||
{ home: 'CAN', away: 'BIH', start: new Date('2026-06-13T00:00:00Z'), order: 3 },
|
||||
{ home: 'USA', away: 'PAR', start: new Date('2026-06-13T03:00:00Z'), hot: true, order: 4 },
|
||||
{ home: 'SUI', away: 'BRA', start: new Date('2026-06-14T16:00:00Z'), order: 5 },
|
||||
{ home: 'SCO', away: 'TUR', start: new Date('2026-06-14T19:00:00Z'), order: 6 },
|
||||
{ home: 'FRA', away: 'ARG', start: new Date('2026-06-15T20:00:00Z'), hot: true, order: 7 },
|
||||
];
|
||||
|
||||
for (const f of wcFixtures) {
|
||||
await ensurePublishedMatch({
|
||||
leagueId: wc.id,
|
||||
homeTeamId: get(f.home).id,
|
||||
awayTeamId: get(f.away).id,
|
||||
startTime: f.start,
|
||||
isHot: f.hot,
|
||||
displayOrder: f.order,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` Sports demo: ${wcFixtures.length + 2} published matches`);
|
||||
}
|
||||
|
||||
async function seedOutrightDemo() {
|
||||
const wc = await prisma.league.findUnique({ where: { code: 'WC2026' } });
|
||||
if (!wc) return;
|
||||
|
||||
const { matchId, marketId } = await syncWc2026OutrightMarket(prisma, {
|
||||
forceCanonical: true,
|
||||
});
|
||||
const count = await prisma.marketSelection.count({ where: { marketId } });
|
||||
console.log(` WC2026 outright: match ${matchId}, ${count} selections`);
|
||||
}
|
||||
|
||||
async function seedPlayerDemo() {
|
||||
const player = await prisma.user.findUnique({
|
||||
where: { username: 'player1' },
|
||||
include: { wallet: true },
|
||||
});
|
||||
if (!player?.wallet) return;
|
||||
|
||||
await prisma.wallet.update({
|
||||
where: { id: player.wallet.id },
|
||||
data: { availableBalance: 88888.88 },
|
||||
});
|
||||
|
||||
await prisma.walletTransaction.upsert({
|
||||
where: { transactionId: 'DEMO-DEP-001' },
|
||||
create: {
|
||||
transactionId: 'DEMO-DEP-001',
|
||||
userId: player.id,
|
||||
walletId: player.wallet.id,
|
||||
transactionType: 'DEPOSIT',
|
||||
amount: 50000,
|
||||
balanceBefore: 1000,
|
||||
balanceAfter: 51000,
|
||||
frozenBefore: 0,
|
||||
frozenAfter: 0,
|
||||
remark: '演示充值',
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.walletTransaction.upsert({
|
||||
where: { transactionId: 'DEMO-DEP-002' },
|
||||
create: {
|
||||
transactionId: 'DEMO-DEP-002',
|
||||
userId: player.id,
|
||||
walletId: player.wallet.id,
|
||||
transactionType: 'DEPOSIT',
|
||||
amount: 37888.88,
|
||||
balanceBefore: 51000,
|
||||
balanceAfter: 88888.88,
|
||||
frozenBefore: 0,
|
||||
frozenAfter: 0,
|
||||
remark: '演示充值(二笔)',
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const sampleSel = await prisma.marketSelection.findFirst({
|
||||
where: {
|
||||
status: 'OPEN',
|
||||
market: { marketType: 'FT_1X2', match: { status: 'PUBLISHED' } },
|
||||
},
|
||||
include: { market: { include: { match: true } } },
|
||||
});
|
||||
|
||||
if (sampleSel && !(await prisma.bet.findUnique({ where: { betNo: 'DEMO-BET-001' } }))) {
|
||||
const odds = Number(sampleSel.odds);
|
||||
const stake = 200;
|
||||
await prisma.bet.create({
|
||||
data: {
|
||||
betNo: 'DEMO-BET-001',
|
||||
userId: player.id,
|
||||
agentId: player.parentId,
|
||||
betType: 'SINGLE',
|
||||
stake,
|
||||
totalOdds: odds,
|
||||
potentialReturn: stake * odds,
|
||||
status: 'PENDING',
|
||||
requestId: 'seed-demo-bet-001',
|
||||
selections: {
|
||||
create: {
|
||||
matchId: sampleSel.market.matchId,
|
||||
marketId: sampleSel.marketId,
|
||||
selectionId: sampleSel.id,
|
||||
marketType: sampleSel.market.marketType,
|
||||
period: sampleSel.market.period,
|
||||
selectionNameSnapshot: sampleSel.selectionName,
|
||||
odds: sampleSel.odds,
|
||||
oddsVersion: sampleSel.oddsVersion,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const settledSel = await prisma.marketSelection.findFirst({
|
||||
where: {
|
||||
market: { marketType: 'FT_1X2' },
|
||||
selectionCode: 'DRAW',
|
||||
},
|
||||
include: { market: true },
|
||||
});
|
||||
|
||||
if (settledSel && !(await prisma.bet.findUnique({ where: { betNo: 'DEMO-BET-002' } }))) {
|
||||
const odds = Number(settledSel.odds);
|
||||
const stake = 50;
|
||||
await prisma.bet.create({
|
||||
data: {
|
||||
betNo: 'DEMO-BET-002',
|
||||
userId: player.id,
|
||||
agentId: player.parentId,
|
||||
betType: 'SINGLE',
|
||||
stake,
|
||||
totalOdds: odds,
|
||||
potentialReturn: stake * odds,
|
||||
actualReturn: stake * odds,
|
||||
status: 'WON',
|
||||
settlementStatus: 'SETTLED',
|
||||
settledAt: new Date(Date.now() - 86400000),
|
||||
requestId: 'seed-demo-bet-002',
|
||||
selections: {
|
||||
create: {
|
||||
matchId: settledSel.market.matchId,
|
||||
marketId: settledSel.marketId,
|
||||
selectionId: settledSel.id,
|
||||
marketType: settledSel.market.marketType,
|
||||
period: settledSel.market.period,
|
||||
selectionNameSnapshot: settledSel.selectionName,
|
||||
odds: settledSel.odds,
|
||||
oddsVersion: settledSel.oddsVersion,
|
||||
resultStatus: 'WIN',
|
||||
effectiveOdds: settledSel.odds,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(' Player demo: wallet + transactions + sample bets');
|
||||
}
|
||||
|
||||
export async function runSeed(client: PrismaClient) {
|
||||
prisma = client;
|
||||
console.log('Seeding database...');
|
||||
|
||||
const superAdminRole = await prisma.role.upsert({
|
||||
where: { code: 'SUPER_ADMIN' },
|
||||
create: { code: 'SUPER_ADMIN', name: 'Super Admin', description: 'Full access' },
|
||||
update: {},
|
||||
});
|
||||
|
||||
const permCodes = [
|
||||
'users.create', 'users.view', 'agents.create', 'agents.view', 'agents.credit',
|
||||
'wallet.deposit', 'wallet.withdraw', 'matches.manage', 'settlement.confirm',
|
||||
'settlement.resettle', 'cashback.confirm', 'content.manage', 'reports.view',
|
||||
'bets.view', 'settings.manage', 'settings.reset_database', 'audit.view',
|
||||
];
|
||||
|
||||
const permIds = new Map<string, bigint>();
|
||||
for (const code of permCodes) {
|
||||
const perm = await prisma.permission.upsert({
|
||||
where: { code },
|
||||
create: { code, name: code, module: code.split('.')[0] },
|
||||
update: {},
|
||||
});
|
||||
permIds.set(code, perm.id);
|
||||
await prisma.rolePermission.upsert({
|
||||
where: { roleId_permissionId: { roleId: superAdminRole.id, permissionId: perm.id } },
|
||||
create: { roleId: superAdminRole.id, permissionId: perm.id },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureRole(code: string, name: string, permissions: string[]) {
|
||||
const role = await prisma.role.upsert({
|
||||
where: { code },
|
||||
create: { code, name, description: name },
|
||||
update: { name },
|
||||
});
|
||||
for (const p of permissions) {
|
||||
const pid = permIds.get(p);
|
||||
if (!pid) continue;
|
||||
await prisma.rolePermission.upsert({
|
||||
where: { roleId_permissionId: { roleId: role.id, permissionId: pid } },
|
||||
create: { roleId: role.id, permissionId: pid },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
await ensureRole('MATCH_ADMIN', 'Match Admin', [
|
||||
'matches.manage', 'settlement.confirm', 'bets.view', 'reports.view', 'audit.view',
|
||||
]);
|
||||
await ensureRole('FINANCE_ADMIN', 'Finance Admin', [
|
||||
'wallet.deposit', 'wallet.withdraw', 'cashback.confirm', 'agents.view',
|
||||
'reports.view', 'bets.view', 'audit.view',
|
||||
]);
|
||||
await ensureRole('SUPPORT', 'Support', ['users.view', 'bets.view', 'reports.view', 'audit.view']);
|
||||
|
||||
const defaultBettingLimits = [
|
||||
['bet.min_stake', '1', '最小单注金额'],
|
||||
['bet.max_stake_single', '50000', '单关最大投注额'],
|
||||
['bet.max_stake_parlay', '20000', '串关最大投注额'],
|
||||
['bet.max_payout_single', '500000', '单关最高派彩'],
|
||||
['bet.max_payout_parlay', '1000000', '串关最高派彩'],
|
||||
['bet.daily_stake_limit', '200000', '玩家每日投注上限'],
|
||||
] as const;
|
||||
for (const [key, value, desc] of defaultBettingLimits) {
|
||||
await prisma.systemConfig.upsert({
|
||||
where: { configKey: key },
|
||||
create: { configKey: key, configValue: value, description: desc },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash('Admin@123', 10);
|
||||
const agentHash = await bcrypt.hash('Agent@123', 10);
|
||||
const playerHash = await bcrypt.hash('Player@123', 10);
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { username: 'admin' },
|
||||
create: {
|
||||
username: 'admin',
|
||||
userType: 'ADMIN',
|
||||
auth: { create: { passwordHash: hash } },
|
||||
adminRole: { create: { roleId: superAdminRole.id } },
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const agent1 = await prisma.user.upsert({
|
||||
where: { username: 'agent1' },
|
||||
create: {
|
||||
username: 'agent1',
|
||||
userType: 'AGENT',
|
||||
agentLevel: 1,
|
||||
auth: { create: { passwordHash: agentHash } },
|
||||
agentProfile: { create: { level: 1, creditLimit: 100000 } },
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.agentClosure.upsert({
|
||||
where: { ancestorId_descendantId: { ancestorId: agent1.id, descendantId: agent1.id } },
|
||||
create: { ancestorId: agent1.id, descendantId: agent1.id, depth: 0 },
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { username: 'agent2' },
|
||||
create: {
|
||||
username: 'agent2',
|
||||
userType: 'AGENT',
|
||||
agentLevel: 2,
|
||||
parentId: agent1.id,
|
||||
auth: { create: { passwordHash: agentHash } },
|
||||
agentProfile: { create: { level: 2, parentAgentId: agent1.id, creditLimit: 30000 } },
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { username: 'player1' },
|
||||
create: {
|
||||
username: 'player1',
|
||||
userType: 'PLAYER',
|
||||
parentId: agent1.id,
|
||||
auth: { create: { passwordHash: playerHash } },
|
||||
wallet: { create: { availableBalance: 1000 } },
|
||||
preferences: { create: { locale: 'zh-CN', managedPassword: 'Player@123' } },
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const messages = [
|
||||
{ key: 'nav.home', zh: '首页', ms: 'Laman Utama', en: 'Home' },
|
||||
{ key: 'nav.football', zh: '足球', ms: 'Bola Sepak', en: 'Football' },
|
||||
{ key: 'bet.place_bet', zh: '确认下注', ms: 'Letak Pertaruhan', en: 'Place Bet' },
|
||||
{ key: 'error.insufficient_balance', zh: '余额不足', ms: 'Baki tidak mencukupi', en: 'Insufficient balance' },
|
||||
];
|
||||
|
||||
for (const m of messages) {
|
||||
for (const [locale, value] of [['zh-CN', m.zh], ['ms-MY', m.ms], ['en-US', m.en]] as const) {
|
||||
await prisma.i18nMessage.upsert({
|
||||
where: { msgKey_locale: { msgKey: m.key, locale } },
|
||||
create: { msgKey: m.key, locale, value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await seedSportsDemo();
|
||||
await seedOutrightDemo();
|
||||
await seedPlayerDemo();
|
||||
|
||||
await prisma.content.create({
|
||||
data: {
|
||||
contentType: 'BANNER',
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 1,
|
||||
linkType: 'ROUTE',
|
||||
linkTarget: '/football',
|
||||
translations: {
|
||||
create: [
|
||||
{ locale: 'zh-CN', title: '欢迎投注', body: '足球赛事火热进行中', imageUrl: '/uploads/banners/welcome.svg' },
|
||||
{ locale: 'en-US', title: 'Welcome', body: 'Football matches available', imageUrl: '/uploads/banners/welcome.svg' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
await prisma.content.create({
|
||||
data: {
|
||||
contentType: 'BANNER',
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 2,
|
||||
translations: {
|
||||
create: [
|
||||
{ locale: 'zh-CN', title: '首存礼遇', body: '新会员专属优惠', imageUrl: '/uploads/banners/promo.svg' },
|
||||
{ locale: 'en-US', title: 'First Deposit', body: 'New member offer', imageUrl: '/uploads/banners/promo.svg' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
await prisma.content.create({
|
||||
data: {
|
||||
contentType: 'BANNER',
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 3,
|
||||
linkType: 'ROUTE',
|
||||
linkTarget: '/football',
|
||||
translations: {
|
||||
create: [
|
||||
{ locale: 'zh-CN', title: '热门赛事', body: '五大联赛天天有球', imageUrl: '/uploads/banners/hot-matches.svg' },
|
||||
{ locale: 'en-US', title: 'Hot Matches', body: 'Top leagues daily', imageUrl: '/uploads/banners/hot-matches.svg' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
await prisma.content.create({
|
||||
data: {
|
||||
contentType: 'TICKER',
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 1,
|
||||
translations: {
|
||||
create: [
|
||||
{ locale: 'zh-CN', body: '欢迎来到 TheBet365 · 热门赛事每日更新 · 请理性投注' },
|
||||
{ locale: 'en-US', body: 'Welcome to TheBet365 · Daily hot matches · Bet responsibly' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
await prisma.content.create({
|
||||
data: {
|
||||
contentType: 'NOTICE',
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 1,
|
||||
translations: {
|
||||
create: [
|
||||
{ locale: 'zh-CN', title: '系统维护通知:每周一 04:00-05:00 例行维护,敬请谅解' },
|
||||
{ locale: 'en-US', title: 'Maintenance: Every Mon 04:00-05:00 UTC' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
console.log(`Seed completed! ${DEMO_ACCOUNTS.join(', ')}`);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const uploadDir = process.env.UPLOAD_DIR || join(__dirname, '..', '..', 'uploads');
|
||||
app.useStaticAssets(uploadDir, { prefix: '/uploads/' });
|
||||
|
||||
@@ -101,6 +101,22 @@ const i18n = createI18n({
|
||||
filter_deposit: '存款',
|
||||
filter_withdraw: '提款',
|
||||
filter_bet: '投注',
|
||||
detail_summary: '账务明细',
|
||||
detail_amount: '变动金额',
|
||||
detail_balance_before: '变动前余额',
|
||||
detail_balance_after: '变动后余额',
|
||||
detail_frozen_before: '变动前冻结',
|
||||
detail_frozen_after: '变动后冻结',
|
||||
detail_reference: '关联信息',
|
||||
detail_reference_type: '业务类型',
|
||||
detail_reference_id: '关联编号',
|
||||
detail_remark: '备注',
|
||||
detail_bet_link: '查看注单',
|
||||
detail_tx_id: '流水号',
|
||||
detail_not_found: '账单不存在',
|
||||
ref_bet: '投注',
|
||||
ref_deposit: '存款',
|
||||
ref_withdraw: '提款',
|
||||
},
|
||||
bet: {
|
||||
bet_slip: '投注单',
|
||||
@@ -366,6 +382,22 @@ const i18n = createI18n({
|
||||
filter_deposit: 'Deposit',
|
||||
filter_withdraw: 'Withdraw',
|
||||
filter_bet: 'Bet',
|
||||
detail_summary: 'Details',
|
||||
detail_amount: 'Amount',
|
||||
detail_balance_before: 'Balance Before',
|
||||
detail_balance_after: 'Balance After',
|
||||
detail_frozen_before: 'Frozen Before',
|
||||
detail_frozen_after: 'Frozen After',
|
||||
detail_reference: 'Reference',
|
||||
detail_reference_type: 'Type',
|
||||
detail_reference_id: 'Reference ID',
|
||||
detail_remark: 'Remark',
|
||||
detail_bet_link: 'View Bet',
|
||||
detail_tx_id: 'Transaction ID',
|
||||
detail_not_found: 'Transaction not found',
|
||||
ref_bet: 'Bet',
|
||||
ref_deposit: 'Deposit',
|
||||
ref_withdraw: 'Withdraw',
|
||||
},
|
||||
bet: {
|
||||
bet_slip: 'Bet Slip',
|
||||
@@ -637,6 +669,22 @@ const i18n = createI18n({
|
||||
filter_deposit: 'Deposit',
|
||||
filter_withdraw: 'Pengeluaran',
|
||||
filter_bet: 'Pertaruhan',
|
||||
detail_summary: 'Butiran',
|
||||
detail_amount: 'Jumlah',
|
||||
detail_balance_before: 'Baki Sebelum',
|
||||
detail_balance_after: 'Baki Selepas',
|
||||
detail_frozen_before: 'Beku Sebelum',
|
||||
detail_frozen_after: 'Beku Selepas',
|
||||
detail_reference: 'Rujukan',
|
||||
detail_reference_type: 'Jenis',
|
||||
detail_reference_id: 'ID Rujukan',
|
||||
detail_remark: 'Catatan',
|
||||
detail_bet_link: 'Lihat Pertaruhan',
|
||||
detail_tx_id: 'ID Transaksi',
|
||||
detail_not_found: 'Rekod tidak dijumpai',
|
||||
ref_bet: 'Pertaruhan',
|
||||
ref_deposit: 'Deposit',
|
||||
ref_withdraw: 'Pengeluaran',
|
||||
},
|
||||
bet: {
|
||||
bet_slip: 'Slip Pertaruhan',
|
||||
|
||||
@@ -17,6 +17,7 @@ const router = createRouter({
|
||||
{ path: 'bets', component: () => import('../views/MyBetsView.vue') },
|
||||
{ path: 'bets/:betNo', component: () => import('../views/BetDetailView.vue') },
|
||||
{ path: 'wallet', component: () => import('../views/WalletView.vue') },
|
||||
{ path: 'wallet/transactions/:transactionId', component: () => import('../views/WalletTransactionDetailView.vue') },
|
||||
{ path: 'profile', component: () => import('../views/ProfileView.vue') },
|
||||
{ path: 'profile/edit', component: () => import('../views/ProfileEditView.vue') },
|
||||
],
|
||||
|
||||
36
apps/player/src/utils/walletTx.ts
Normal file
36
apps/player/src/utils/walletTx.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const TX_KEY_MAP: Record<string, string> = {
|
||||
MANUAL_DEPOSIT: 'wallet.tx_deposit',
|
||||
MANUAL_WITHDRAW: 'wallet.tx_withdraw',
|
||||
MANUAL_ADJUST: 'wallet.tx_adjust',
|
||||
BET_FREEZE: 'wallet.tx_bet_freeze',
|
||||
BET_DEDUCT: 'wallet.tx_bet_deduct',
|
||||
BET_SETTLE_WIN: 'wallet.tx_bet_win',
|
||||
BET_SETTLE_LOSE: 'wallet.tx_bet_lose',
|
||||
BET_SETTLE_PUSH: 'wallet.tx_bet_push',
|
||||
BET_WIN: 'wallet.tx_bet_win',
|
||||
BET_REFUND: 'wallet.tx_bet_refund',
|
||||
BET_VOID: 'wallet.tx_bet_void',
|
||||
BET_VOID_REFUND: 'wallet.tx_bet_void',
|
||||
CASHBACK: 'wallet.tx_cashback',
|
||||
CASHBACK_DEPOSIT: 'wallet.tx_cashback',
|
||||
RESETTLE_REVERSE: 'wallet.tx_resettle',
|
||||
DEPOSIT: 'wallet.tx_deposit',
|
||||
WITHDRAW: 'wallet.tx_withdraw',
|
||||
};
|
||||
|
||||
export function txTypeKey(type: string): string {
|
||||
return TX_KEY_MAP[type.toUpperCase()] ?? '';
|
||||
}
|
||||
|
||||
export function isDepositType(type: string): boolean {
|
||||
const t = type.toUpperCase();
|
||||
return t.includes('DEPOSIT') || t === 'CASHBACK_DEPOSIT';
|
||||
}
|
||||
|
||||
export function isWithdrawType(type: string): boolean {
|
||||
return type.toUpperCase().includes('WITHDRAW');
|
||||
}
|
||||
|
||||
export function isBetType(type: string): boolean {
|
||||
return type.toUpperCase().startsWith('BET_') || type.toUpperCase() === 'RESETTLE_REVERSE';
|
||||
}
|
||||
309
apps/player/src/views/WalletTransactionDetailView.vue
Normal file
309
apps/player/src/views/WalletTransactionDetailView.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import { txTypeKey } from '../utils/walletTx';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
|
||||
type TransactionDetail = {
|
||||
transactionId: string;
|
||||
transactionType: string;
|
||||
amount: string;
|
||||
balanceBefore: string;
|
||||
balanceAfter: string;
|
||||
frozenBefore: string;
|
||||
frozenAfter: string;
|
||||
referenceType: string | null;
|
||||
referenceId: string | null;
|
||||
remark: string | null;
|
||||
createdAt: string;
|
||||
betNo: string | null;
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const tx = ref<TransactionDetail | null>(null);
|
||||
const loading = ref(true);
|
||||
const notFound = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get(`/player/wallet/transactions/${route.params.transactionId}`);
|
||||
if (!data.data) {
|
||||
notFound.value = true;
|
||||
return;
|
||||
}
|
||||
tx.value = data.data;
|
||||
} catch {
|
||||
notFound.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function txLabel(type: string): string {
|
||||
const key = txTypeKey(type);
|
||||
if (key) {
|
||||
const translated = t(key);
|
||||
if (translated !== key) return translated;
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
const amountClass = computed(() => {
|
||||
if (!tx.value) return '';
|
||||
return parseFloat(tx.value.amount) >= 0 ? 'pos' : 'neg';
|
||||
});
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
if (!tx.value) return '';
|
||||
return new Date(tx.value.createdAt).toLocaleString(locale.value);
|
||||
});
|
||||
|
||||
const frozenChanged = computed(() => {
|
||||
if (!tx.value) return false;
|
||||
return tx.value.frozenBefore !== tx.value.frozenAfter;
|
||||
});
|
||||
|
||||
const referenceLabel = computed(() => {
|
||||
if (!tx.value?.referenceType) return '';
|
||||
const rt = tx.value.referenceType.toUpperCase();
|
||||
if (rt === 'BET') return t('wallet.ref_bet');
|
||||
if (rt === 'DEPOSIT') return t('wallet.ref_deposit');
|
||||
if (rt === 'WITHDRAW') return t('wallet.ref_withdraw');
|
||||
return tx.value.referenceType;
|
||||
});
|
||||
|
||||
function goBetDetail() {
|
||||
if (!tx.value?.betNo) return;
|
||||
router.push(`/bets/${tx.value.betNo}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="top-bar">
|
||||
<button class="back-btn" type="button" @click="router.back()">‹ {{ t('history.back') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
<div v-else-if="notFound || !tx" class="state">{{ t('wallet.detail_not_found') }}</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="hero" :class="amountClass">
|
||||
<span class="hero-type">{{ txLabel(tx.transactionType) }}</span>
|
||||
<span class="hero-amount">{{ formatMoney(tx.amount, locale) }}</span>
|
||||
<span class="hero-time">{{ formattedTime }}</span>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-title">{{ t('wallet.detail_summary') }}</div>
|
||||
<div class="summary-rows">
|
||||
<div class="sum-row">
|
||||
<span>{{ t('wallet.detail_amount') }}</span>
|
||||
<span :class="amountClass">{{ formatMoney(tx.amount, locale) }}</span>
|
||||
</div>
|
||||
<div class="sum-row">
|
||||
<span>{{ t('wallet.detail_balance_before') }}</span>
|
||||
<span>{{ formatMoney(tx.balanceBefore, locale) }}</span>
|
||||
</div>
|
||||
<div class="sum-row">
|
||||
<span>{{ t('wallet.detail_balance_after') }}</span>
|
||||
<span>{{ formatMoney(tx.balanceAfter, locale) }}</span>
|
||||
</div>
|
||||
<template v-if="frozenChanged">
|
||||
<div class="sum-row">
|
||||
<span>{{ t('wallet.detail_frozen_before') }}</span>
|
||||
<span>{{ formatMoney(tx.frozenBefore, locale) }}</span>
|
||||
</div>
|
||||
<div class="sum-row">
|
||||
<span>{{ t('wallet.detail_frozen_after') }}</span>
|
||||
<span>{{ formatMoney(tx.frozenAfter, locale) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="tx.referenceType || tx.remark" class="section">
|
||||
<div class="section-title">{{ t('wallet.detail_reference') }}</div>
|
||||
<div class="summary-rows">
|
||||
<div v-if="tx.referenceType" class="sum-row">
|
||||
<span>{{ t('wallet.detail_reference_type') }}</span>
|
||||
<span>{{ referenceLabel }}</span>
|
||||
</div>
|
||||
<div v-if="tx.referenceId && !tx.betNo" class="sum-row">
|
||||
<span>{{ t('wallet.detail_reference_id') }}</span>
|
||||
<span class="mono">{{ tx.referenceId }}</span>
|
||||
</div>
|
||||
<div v-if="tx.remark" class="sum-row">
|
||||
<span>{{ t('wallet.detail_remark') }}</span>
|
||||
<span class="remark">{{ tx.remark }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="tx.betNo" type="button" class="bet-link" @click="goBetDetail">
|
||||
{{ t('wallet.detail_bet_link') }} · {{ tx.betNo }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-title">{{ t('wallet.detail_tx_id') }}</div>
|
||||
<div class="tx-id">{{ tx.transactionId }}</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-page {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-light, #d4af37);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
padding: 4px 0 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero {
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
background: #141414;
|
||||
border: 1px solid #222;
|
||||
}
|
||||
|
||||
.hero.pos {
|
||||
border-color: rgba(212, 175, 55, 0.25);
|
||||
background: linear-gradient(135deg, rgba(212, 175, 55, 0.1), rgba(212, 175, 55, 0.03));
|
||||
}
|
||||
|
||||
.hero.neg {
|
||||
border-color: rgba(224, 80, 80, 0.2);
|
||||
background: linear-gradient(135deg, rgba(224, 80, 80, 0.1), rgba(224, 80, 80, 0.03));
|
||||
}
|
||||
|
||||
.hero-type {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.hero-amount {
|
||||
font-size: 34px;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero.pos .hero-amount { color: var(--primary-light); }
|
||||
.hero.neg .hero-amount { color: var(--danger); }
|
||||
|
||||
.hero-time {
|
||||
font-size: 11px;
|
||||
color: #555;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #141414;
|
||||
border: 1px solid #222;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #888;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.summary-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sum-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.sum-row span:last-child {
|
||||
color: #f0f0f0;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pos { color: var(--primary-light) !important; }
|
||||
.neg { color: var(--danger) !important; }
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.remark {
|
||||
max-width: 60%;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bet-link {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-gold-soft, rgba(212, 175, 55, 0.35));
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
color: var(--primary-light);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tx-id {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import { isBetType, isDepositType, isWithdrawType, txTypeKey } from '../utils/walletTx';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import WalletStatsPanel from '../components/WalletStatsPanel.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
type Transaction = {
|
||||
transactionType: string;
|
||||
amount: string;
|
||||
createdAt: string;
|
||||
transactionId?: string;
|
||||
transactionId: string;
|
||||
};
|
||||
|
||||
const items = ref<Transaction[]>([]);
|
||||
@@ -27,26 +30,6 @@ const typeFilter = ref('');
|
||||
const sentinel = ref<HTMLElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const TX_KEY_MAP: Record<string, string> = {
|
||||
MANUAL_DEPOSIT: 'wallet.tx_deposit',
|
||||
MANUAL_WITHDRAW: 'wallet.tx_withdraw',
|
||||
MANUAL_ADJUST: 'wallet.tx_adjust',
|
||||
BET_FREEZE: 'wallet.tx_bet_freeze',
|
||||
BET_DEDUCT: 'wallet.tx_bet_deduct',
|
||||
BET_SETTLE_WIN: 'wallet.tx_bet_win',
|
||||
BET_SETTLE_LOSE: 'wallet.tx_bet_lose',
|
||||
BET_SETTLE_PUSH: 'wallet.tx_bet_push',
|
||||
BET_WIN: 'wallet.tx_bet_win',
|
||||
BET_REFUND: 'wallet.tx_bet_refund',
|
||||
BET_VOID: 'wallet.tx_bet_void',
|
||||
BET_VOID_REFUND: 'wallet.tx_bet_void',
|
||||
CASHBACK: 'wallet.tx_cashback',
|
||||
CASHBACK_DEPOSIT: 'wallet.tx_cashback',
|
||||
RESETTLE_REVERSE: 'wallet.tx_resettle',
|
||||
DEPOSIT: 'wallet.tx_deposit',
|
||||
WITHDRAW: 'wallet.tx_withdraw',
|
||||
};
|
||||
|
||||
const FILTERS = [
|
||||
{ key: '', label: 'wallet.filter_all' },
|
||||
{ key: 'deposit', label: 'wallet.filter_deposit' },
|
||||
@@ -55,7 +38,7 @@ const FILTERS = [
|
||||
];
|
||||
|
||||
function txLabel(type: string): string {
|
||||
const key = TX_KEY_MAP[type.toUpperCase()];
|
||||
const key = txTypeKey(type);
|
||||
if (key) {
|
||||
const translated = t(key);
|
||||
if (translated !== key) return translated;
|
||||
@@ -63,21 +46,6 @@ function txLabel(type: string): string {
|
||||
return type;
|
||||
}
|
||||
|
||||
function isDepositType(type: string): boolean {
|
||||
const t = type.toUpperCase();
|
||||
return t.includes('DEPOSIT') || t === 'CASHBACK_DEPOSIT';
|
||||
}
|
||||
|
||||
function isWithdrawType(type: string): boolean {
|
||||
const t = type.toUpperCase();
|
||||
return t.includes('WITHDRAW');
|
||||
}
|
||||
|
||||
function isBetType(type: string): boolean {
|
||||
const t = type.toUpperCase();
|
||||
return t.startsWith('BET_');
|
||||
}
|
||||
|
||||
function matchesFilter(tx: Transaction): boolean {
|
||||
if (!typeFilter.value) return true;
|
||||
if (typeFilter.value === 'deposit') return isDepositType(tx.transactionType) || tx.transactionType === 'MANUAL_ADJUST';
|
||||
@@ -86,6 +54,11 @@ function matchesFilter(tx: Transaction): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function goDetail(tx: Transaction) {
|
||||
if (!tx.transactionId) return;
|
||||
router.push(`/wallet/transactions/${tx.transactionId}`);
|
||||
}
|
||||
|
||||
async function loadPage(p: number) {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
@@ -181,17 +154,24 @@ const pullIndicatorStyle = () => ({
|
||||
</div>
|
||||
|
||||
<div v-if="items.length" class="tx-list">
|
||||
<div
|
||||
<button
|
||||
v-for="tx in items"
|
||||
:key="tx.transactionId ?? tx.createdAt + Math.random()"
|
||||
:key="tx.transactionId"
|
||||
type="button"
|
||||
class="tx-row"
|
||||
@click="goDetail(tx)"
|
||||
>
|
||||
<span class="tx-type">{{ txLabel(tx.transactionType) }}</span>
|
||||
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">
|
||||
{{ formatMoney(tx.amount, locale) }}
|
||||
</span>
|
||||
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="tx-main">
|
||||
<span class="tx-type">{{ txLabel(tx.transactionType) }}</span>
|
||||
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">
|
||||
{{ formatMoney(tx.amount, locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tx-meta">
|
||||
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
|
||||
<span class="tx-arrow">›</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="sentinel" class="sentinel" />
|
||||
@@ -274,22 +254,44 @@ const pullIndicatorStyle = () => ({
|
||||
}
|
||||
|
||||
.tx-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tx-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tx-row:active {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.tx-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tx-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tx-type { font-weight: 700; color: var(--text); }
|
||||
.pos { color: var(--primary-light); font-weight: 800; font-size: 15px; }
|
||||
.neg { color: var(--danger); font-weight: 700; }
|
||||
.tx-time { width: 100%; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
.tx-time { font-size: 11px; color: var(--text-muted); }
|
||||
.tx-arrow { font-size: 16px; color: #555; font-weight: 700; line-height: 1; }
|
||||
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
|
||||
39
scripts/ensure-port-free.mjs
Normal file
39
scripts/ensure-port-free.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const port = process.argv[2] ?? '3000';
|
||||
|
||||
function killWindows(portNum) {
|
||||
let out = '';
|
||||
try {
|
||||
out = execSync(`netstat -ano | findstr :${portNum}`, { encoding: 'utf8' });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const pids = new Set();
|
||||
for (const line of out.split('\n')) {
|
||||
if (!line.includes('LISTENING')) continue;
|
||||
const pid = line.trim().split(/\s+/).at(-1);
|
||||
if (pid && pid !== '0') pids.add(pid);
|
||||
}
|
||||
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
console.log(`[ensure-port-free] freeing :${portNum} (PID ${pid})`);
|
||||
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function killUnix(portNum) {
|
||||
try {
|
||||
execSync(`lsof -ti tcp:${portNum} | xargs -r kill -9`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
// port already free
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') killWindows(port);
|
||||
else killUnix(port);
|
||||
Reference in New Issue
Block a user