diff --git a/apps/admin/src/i18n/admin-pages-ms.ts b/apps/admin/src/i18n/admin-pages-ms.ts index c5e769a..25ec70d 100644 --- a/apps/admin/src/i18n/admin-pages-ms.ts +++ b/apps/admin/src/i18n/admin-pages-ms.ts @@ -172,6 +172,9 @@ export const adminPagesMs: Record = { 'bet.col.player': 'Pemain', 'bet.col.agent': 'Ejen', 'bet.col.selection': 'Pilihan', + 'bet.col.content': 'Kandungan taruhan', + 'bet.col.match': 'Perlawanan', + 'bet.legs_more': '+{n} lagi…', 'bet.col.selection_count': 'Bil. pilihan', 'bet.col.stake': 'Stake', 'bet.col.odds': 'Odds', @@ -210,7 +213,25 @@ export const adminPagesMs: Record = { 'cashback.preview_title': 'Pratonton rebat', 'cashback.stat.players': 'Pemain', 'cashback.stat.total': 'Jumlah rebat', + 'cashback.stat.lines': 'Baris butiran', + 'cashback.batch_no': 'No. kelompok', + 'cashback.table_title': 'Butiran rebat pemain', + 'cashback.table_total': 'Jumlah', + 'cashback.empty_items': 'Tiada rebat layak dalam tempoh ini', + 'cashback.col.index': '#', + 'cashback.col.player': 'Pemain', + 'cashback.col.agent': 'Ejen', + 'cashback.col.effective_stake': 'Stake berkesan', + 'cashback.col.rate': 'Kadar', + 'cashback.col.amount': 'Rebat', 'cashback.confirm_issue': 'Sahkan bayaran', + '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_note_zero': 'Jika 0, semak taruhan WON/LOST dalam tempoh dan kadar rebat > 0.', 'user.field.player_id': 'ID pemain', 'user.field.bet_count': 'Bilangan pertaruhan', @@ -313,8 +334,12 @@ export const adminPagesMs: Record = { 'settlement.stats_parlay': 'Parlay', 'settlement.stats_total_stake': 'Jumlah stake', 'settlement.stats_potential': 'Menang maksimum', + 'settlement.chart.bet_type': 'Tunggal vs parlay', + 'settlement.chart.status': 'Taburan status pertaruhan', + 'settlement.chart.stake_by_selection': 'TOP6 pilihan mengikut taruhan tunggal', 'settlement.stats_by_market': 'Ikut pasaran / pilihan', - 'settlement.bet_list': 'Semua pertaruhan', + 'settlement.bet_list': 'Pertaruhan berkaitan', + 'settlement.bet_list_hint': 'Dikumpulkan mengikut pertaruhan; parlay sama perlawanan tunjuk ×kaki', 'settlement.no_bets': 'Tiada pertaruhan untuk perlawanan ini', 'settlement.col.market': 'Pasaran', 'settlement.col.selection': 'Pilihan', @@ -324,7 +349,10 @@ export const adminPagesMs: Record = { 'settlement.ht_score': 'Skor separuh masa', 'settlement.ft_score': 'Skor penuh masa', 'settlement.record_score': 'Simpan skor', + 'settlement.preview_hint': 'Skor di atas disimpan secara automatik sebelum pratonton', 'settlement.preview_btn': 'Pratonton penyelesaian', + 'settlement.preview_failed': 'Gagal menjana pratonton penyelesaian', + 'settlement.err_score_not_recorded': 'Sila masukkan skor separuh masa dan penuh masa sebelum penyelesaian', 'settlement.preview_title': 'Pratonton penyelesaian', 'settlement.single_count': 'Pertaruhan tunggal', 'settlement.est_payout': 'Anggaran bayaran', diff --git a/apps/admin/src/i18n/admin-pages.ts b/apps/admin/src/i18n/admin-pages.ts index 8933202..1bf6d83 100644 --- a/apps/admin/src/i18n/admin-pages.ts +++ b/apps/admin/src/i18n/admin-pages.ts @@ -172,6 +172,9 @@ export const adminPagesZh: Record = { 'bet.col.player': '玩家', 'bet.col.agent': '所属代理', 'bet.col.selection': '选项', + 'bet.col.content': '投注内容', + 'bet.col.match': '赛事', + 'bet.legs_more': '还有 {n} 项…', 'bet.col.selection_count': '投注项数', 'bet.col.stake': '投注额', 'bet.col.odds': '赔率', @@ -210,7 +213,25 @@ export const adminPagesZh: Record = { 'cashback.preview_title': '返水预览', 'cashback.stat.players': '涉及玩家数', 'cashback.stat.total': '返水总金额', + 'cashback.stat.lines': '明细条数', + 'cashback.batch_no': '批次号', + 'cashback.table_title': '玩家返水明细', + 'cashback.table_total': '合计', + 'cashback.empty_items': '本周期内无符合条件的返水记录', + 'cashback.col.index': '#', + 'cashback.col.player': '玩家', + 'cashback.col.agent': '所属代理', + 'cashback.col.effective_stake': '有效投注', + 'cashback.col.rate': '返水比例', + 'cashback.col.amount': '返水金额', 'cashback.confirm_issue': '确认发放', + '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_note_zero': '预览为 0 时,请检查:周期内是否有已结算输赢注单、代理/玩家是否配置了大于 0 的返水率。', 'user.field.player_id': '玩家 ID', 'user.field.bet_count': '注单数', @@ -313,23 +334,61 @@ export const adminPagesZh: Record = { 'settlement.stats_parlay': '串关', 'settlement.stats_total_stake': '总投注额', 'settlement.stats_potential': '最大可赢', + 'settlement.chart.bet_type': '单关 / 串关占比', + 'settlement.chart.status': '注单状态分布', + 'settlement.chart.stake_by_selection': '选项单关投注额 TOP6', 'settlement.stats_by_market': '按玩法 / 选项汇总', - 'settlement.bet_list': '全部注单', + 'settlement.bet_list': '相关注单', + 'settlement.bet_list_hint': '按注单聚合;同场串关含多腿时显示 ×腿数', 'settlement.no_bets': '本场暂无注单', 'settlement.col.market': '玩法', 'settlement.col.selection': '选项', - 'settlement.col.legs': '笔数', + 'settlement.col.legs': '腿数', 'settlement.col.single_stake': '单关投注额', 'settlement.col.parlay_legs': '串关腿数', 'settlement.ht_score': '半场比分', 'settlement.ft_score': '全场比分', 'settlement.record_score': '录入比分', + 'settlement.preview_hint': '生成预览前会自动保存上方比分', 'settlement.preview_btn': '生成结算预览', + 'settlement.preview_failed': '生成结算预览失败', + 'settlement.err_score_not_recorded': '请先录入半场与全场比分后再结算', 'settlement.preview_title': '结算预览', 'settlement.single_count': '单关注单数', + 'settlement.preview_pending_bets': '待结算注单', + 'settlement.preview_bet_mix': '单关 / 串关', + 'settlement.preview_items_title': '逐笔预览({n} 笔)', + 'settlement.preview_items_scroll_hint': '笔数较多时可滚动查看', + 'settlement.preview_col.result': '本场结果', + 'settlement.preview_zero_parlay_hint': + '预计派彩为 0:本场虽有 {legs} 条赢腿,但均在跨场串关内({pending} 笔待其他场次),须等其他比赛结算后才派彩。', + 'settlement.preview_zero_lost_hint': + '预计派彩为 0:{count} 笔串关在本场已有输腿,整单作废;其余单关/串关本场亦未中奖。', + 'settlement.preview_zero_default_hint': '预计派彩为 0:本场相关注单均未中奖或暂不满足派彩条件。', + 'settlement.preview.result.WIN': '赢', + 'settlement.preview.result.LOSE': '输', + 'settlement.preview.result.LOST': '整单输', + 'settlement.preview.result.WON': '整单赢', + 'settlement.preview.result.PUSH': '走盘', + 'settlement.preview.result.PENDING_OTHER_MATCHES': '待其他场次', 'settlement.est_payout': '预计派彩', 'settlement.refund_amount': '退款金额', 'settlement.confirm_btn': '确认结算', + 'settlement.resettle_reason': '重结算原因', + 'settlement.resettle_preview': '重结算预览', + 'settlement.resettle_preview_title': '重结算预览', + 'settlement.resettle_affected': '影响注单数', + 'settlement.resettle_topup': '需补发金额', + 'settlement.resettle_clawback': '需扣回金额', + 'settlement.resettle_confirm': '确认重结算', + 'user.betting_limits': '投注限额', + 'user.betting_limits_hint': '全局下注校验:最小/最大投注、最高派彩、每日投注上限', + 'user.limit.min_stake': '最小投注', + 'user.limit.max_stake_single': '单关最大投注', + 'user.limit.max_stake_parlay': '串关最大投注', + 'user.limit.max_payout_single': '单关最高派彩', + 'user.limit.max_payout_parlay': '串关最高派彩', + 'user.limit.daily_stake': '每日投注上限', 'settlement.smart.btn': '智能比分', 'settlement.smart.title': '智能推荐比分', 'settlement.smart.hint': '根据本场待结算单关注单,在合理比分范围内穷举并计算派彩;串关仅统计不参与推荐。点击方案可填入录分框。', @@ -350,6 +409,7 @@ export const adminPagesZh: Record = { 'settlement.smart.strategy.TARGET_HOLD': '目标留存率', 'msg.score_recorded': '比分已录入', 'msg.settlement_confirmed': '结算已确认', + 'msg.resettle_confirmed': '重结算已确认', 'agent_portal.create_player_section': '创建玩家', 'agent_portal.deposit_section': '上分操作', @@ -669,6 +729,9 @@ export const adminPagesEn: Record = { 'bet.col.player': 'Player', 'bet.col.agent': 'Agent', 'bet.col.selection': 'Pick', + 'bet.col.content': 'Selections', + 'bet.col.match': 'Match', + 'bet.legs_more': '+{n} more…', 'bet.col.selection_count': 'Legs', 'bet.col.stake': 'Stake', 'bet.col.odds': 'Odds', @@ -707,7 +770,25 @@ export const adminPagesEn: Record = { 'cashback.preview_title': 'Cashback preview', 'cashback.stat.players': 'Players', 'cashback.stat.total': 'Total cashback', + 'cashback.stat.lines': 'Line items', + 'cashback.batch_no': 'Batch no.', + 'cashback.table_title': 'Player cashback breakdown', + 'cashback.table_total': 'Total', + 'cashback.empty_items': 'No eligible cashback in this period', + 'cashback.col.index': '#', + 'cashback.col.player': 'Player', + 'cashback.col.agent': 'Agent', + 'cashback.col.effective_stake': 'Effective stake', + 'cashback.col.rate': 'Rate', + 'cashback.col.amount': 'Cashback', 'cashback.confirm_issue': 'Confirm payout', + '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_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', 'user.field.bet_count': 'Bet count', @@ -810,8 +891,12 @@ export const adminPagesEn: Record = { 'settlement.stats_parlay': 'Parlays', 'settlement.stats_total_stake': 'Total stake', 'settlement.stats_potential': 'Max potential win', + 'settlement.chart.bet_type': 'Single vs parlay', + 'settlement.chart.status': 'Bet status mix', + 'settlement.chart.stake_by_selection': 'Top selections by single stake', 'settlement.stats_by_market': 'By market / selection', - 'settlement.bet_list': 'All bets', + 'settlement.bet_list': 'Related bets', + 'settlement.bet_list_hint': 'Grouped by bet; same-match parlays show ×legs', 'settlement.no_bets': 'No bets on this match', 'settlement.col.market': 'Market', 'settlement.col.selection': 'Selection', @@ -821,12 +906,47 @@ export const adminPagesEn: Record = { 'settlement.ht_score': 'Half-time score', 'settlement.ft_score': 'Full-time score', 'settlement.record_score': 'Save score', + 'settlement.preview_hint': 'Current scores are saved automatically before preview', 'settlement.preview_btn': 'Preview settlement', + 'settlement.preview_failed': 'Failed to generate settlement preview', + 'settlement.err_score_not_recorded': 'Enter half-time and full-time scores before settling', 'settlement.preview_title': 'Settlement preview', 'settlement.single_count': 'Single bets', + 'settlement.preview_pending_bets': 'Pending bets', + 'settlement.preview_bet_mix': 'Single / parlay', + 'settlement.preview_items_title': 'Per-bet preview ({n})', + 'settlement.preview_items_scroll_hint': 'Scroll when the list is long', + 'settlement.preview_col.result': 'Match result', + 'settlement.preview_zero_parlay_hint': + 'Est. payout is 0: {legs} winning leg(s) on this match are in cross-match parlays ({pending} bet(s) awaiting other fixtures).', + 'settlement.preview_zero_lost_hint': + 'Est. payout is 0: {count} parlay(s) already lost on this match; other bets did not win here either.', + 'settlement.preview_zero_default_hint': + 'Est. payout is 0: no winning or refundable outcome on this match yet.', + 'settlement.preview.result.WIN': 'Win', + 'settlement.preview.result.LOSE': 'Lose', + 'settlement.preview.result.LOST': 'Parlay lost', + 'settlement.preview.result.WON': 'Parlay won', + 'settlement.preview.result.PUSH': 'Push', + 'settlement.preview.result.PENDING_OTHER_MATCHES': 'Awaiting other matches', 'settlement.est_payout': 'Est. payout', 'settlement.refund_amount': 'Refund amount', 'settlement.confirm_btn': 'Confirm settlement', + 'settlement.resettle_reason': 'Resettle reason', + 'settlement.resettle_preview': 'Resettle preview', + 'settlement.resettle_preview_title': 'Resettle preview', + 'settlement.resettle_affected': 'Affected bets', + 'settlement.resettle_topup': 'Top-up required', + 'settlement.resettle_clawback': 'Clawback required', + 'settlement.resettle_confirm': 'Confirm resettle', + 'user.betting_limits': 'Betting limits', + 'user.betting_limits_hint': 'Global stake/payout/daily limits for player bets', + 'user.limit.min_stake': 'Min stake', + 'user.limit.max_stake_single': 'Max single stake', + 'user.limit.max_stake_parlay': 'Max parlay stake', + 'user.limit.max_payout_single': 'Max single payout', + 'user.limit.max_payout_parlay': 'Max parlay payout', + 'user.limit.daily_stake': 'Daily stake limit', 'settlement.smart.btn': 'Smart score', 'settlement.smart.title': 'Smart score suggestions', 'settlement.smart.hint': 'Enumerates valid scores from pending single bets and estimates payout. Parlays are excluded. Click a card to apply.', @@ -847,6 +967,7 @@ export const adminPagesEn: Record = { 'settlement.smart.strategy.TARGET_HOLD': 'Target hold rate', 'msg.score_recorded': 'Score saved', 'msg.settlement_confirmed': 'Settlement confirmed', + 'msg.resettle_confirmed': 'Resettle confirmed', 'agent_portal.create_player_section': 'Create player', 'agent_portal.deposit_section': 'Top up', diff --git a/apps/admin/src/utils/settlement-charts.ts b/apps/admin/src/utils/settlement-charts.ts new file mode 100644 index 0000000..54f8061 --- /dev/null +++ b/apps/admin/src/utils/settlement-charts.ts @@ -0,0 +1,123 @@ +import type { EChartsOption } from 'echarts'; +import { buildBarChartOption, buildPieChartOption, type PieSegment } from './dashboard-charts'; + +const STATUS_COLORS: Record = { + PENDING: '#e8a040', + WON: '#2fb56a', + LOST: '#ff453a', + PUSH: '#8e8e93', + VOID: '#555', +}; + +function withCompactHeader(option: EChartsOption, text: string, subtext?: string): EChartsOption { + const grid = (option.grid ?? {}) as { top?: number; bottom?: number; left?: number; right?: number }; + const top = subtext ? 36 : 24; + return { + ...option, + title: { + text, + subtext, + left: 'center', + top: 0, + textStyle: { color: '#ccc', fontSize: 11, fontWeight: 600 }, + subtextStyle: { color: '#777', fontSize: 9 }, + itemGap: 2, + }, + grid: { ...grid, top: Math.max(top, grid.top ?? 0) }, + }; +} + +function compactPie(option: EChartsOption, centerLines?: string[]): EChartsOption { + const series = option.series as Array> | undefined; + if (series?.[0]) { + series[0].center = ['50%', '55%']; + series[0].radius = ['34%', '50%']; + series[0].label = { show: false }; + series[0].labelLine = { show: false }; + } + + const lines = centerLines?.filter(Boolean) ?? []; + const graphic = lines.map((line, i) => ({ + type: 'text' as const, + left: 'center', + top: lines.length === 1 ? '52%' : `${48 + i * 12}%`, + style: { + text: line, + textAlign: 'center' as const, + fill: i === 0 ? '#eee' : '#888', + fontSize: i === 0 ? 15 : 9, + fontWeight: i === 0 ? 700 : 400, + }, + })); + + return { + ...option, + legend: { + bottom: 0, + left: 'center', + itemWidth: 8, + itemHeight: 8, + textStyle: { color: '#999', fontSize: 9 }, + }, + graphic, + }; +} + +export function buildBetTypePieOption(input: { + title: string; + singleBets: number; + parlayBets: number; + totalBets: number; + legCount: number; + labels: { single: string; parlay: string }; + subtext: string; + centerLabel: string; +}): EChartsOption { + const segments: PieSegment[] = [ + { label: input.labels.single, value: input.singleBets, color: '#2fb56a' }, + { label: input.labels.parlay, value: input.parlayBets, color: '#fb923c' }, + ].filter((s) => s.value > 0); + + const base = buildPieChartOption(input.title, segments, { pieTooltip: '{b}:{c}({d}%)' }); + const withHeader = withCompactHeader(base, input.title, input.subtext); + return compactPie(withHeader, [String(input.totalBets), input.centerLabel]); +} + +export function buildStatusPieOption(input: { + title: string; + statusCounts: Record; + labelFor: (status: string) => string; + subtext: string; +}): EChartsOption { + const segments: PieSegment[] = Object.entries(input.statusCounts) + .filter(([, count]) => count > 0) + .map(([status, value]) => ({ + label: input.labelFor(status), + value, + color: STATUS_COLORS[status] ?? '#666', + })); + + const total = segments.reduce((sum, s) => sum + s.value, 0); + const base = buildPieChartOption(input.title, segments, { pieTooltip: '{b}:{c}({d}%)' }); + const withHeader = withCompactHeader(base, input.title, input.subtext); + return compactPie(withHeader, [String(total)]); +} + +export function buildSelectionStakeBarOption(input: { + title: string; + subtext: string; + labels: string[]; + stakes: number[]; + seriesName: string; +}): EChartsOption { + const base = buildBarChartOption( + input.labels, + [{ name: input.seriesName, color: '#2fb56a', values: input.stakes }], + { amountAxis: true }, + ); + return withCompactHeader( + { ...base, grid: { left: 40, right: 6, top: 36, bottom: 22 }, legend: { show: false } }, + input.title, + input.subtext, + ); +} diff --git a/apps/admin/src/views/Bets.vue b/apps/admin/src/views/Bets.vue index b1de327..ee2c029 100644 --- a/apps/admin/src/views/Bets.vue +++ b/apps/admin/src/views/Bets.vue @@ -179,6 +179,32 @@ async function openDetail(row: BetListRow) { {{ betTypeLabel(row.betType) }} + + + @@ -267,6 +293,12 @@ async function openDetail(row: BetListRow) {
{{ t('bet.selections_title', { n: detail.selections.length }) }}
+ + + @@ -310,7 +342,55 @@ async function openDetail(row: BetListRow) { } .bets-table { - min-width: 1180px; + min-width: 1380px; +} + +.bet-content-cell { + display: flex; + flex-direction: column; + gap: 4px; + padding: 2px 0; +} + +.bet-leg-line { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 6px; + font-size: 12px; + line-height: 1.35; +} + +.bet-match { + color: #ddd; +} + +.bet-pick { + color: var(--green-glow); +} + +.bet-leg-odds { + color: #888; + font-size: 11px; +} + +.bet-leg-more { + font-size: 11px; + color: #666; +} + +.bet-content-empty { + color: #666; +} + +.detail-match-cell { + line-height: 1.35; +} + +.detail-league { + margin-top: 2px; + font-size: 11px; + color: #888; } .bet-no { font-size: 12px; diff --git a/apps/admin/src/views/Cashback.vue b/apps/admin/src/views/Cashback.vue index 65f20ef..3e0fd89 100644 --- a/apps/admin/src/views/Cashback.vue +++ b/apps/admin/src/views/Cashback.vue @@ -1,28 +1,90 @@ @@ -413,11 +701,11 @@ onMounted(() => { .settlement-page { display: flex; flex-direction: column; - gap: 16px; - flex: none !important; - min-height: auto !important; - align-self: flex-start; + gap: 12px; + height: 100%; + min-height: 0; width: 100%; + overflow: hidden; } .page-header { @@ -426,6 +714,7 @@ onMounted(() => { justify-content: space-between; gap: 12px; flex-wrap: wrap; + flex-shrink: 0; } .header-left { @@ -449,11 +738,41 @@ onMounted(() => { } .settle-top-card, -.stats-card, .preview-card { + flex-shrink: 0; border-radius: 12px; } +.settle-top-card :deep(.el-card__body) { + padding: 14px 16px; +} + +.stats-card { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + border-radius: 12px; + overflow: hidden; +} + +.stats-card :deep(.el-card__body) { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 10px 12px 12px; +} + +.stats-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; +} + .settle-top-row { display: flex; align-items: center; @@ -528,54 +847,88 @@ onMounted(() => { } .section-title { - font-size: 15px; + font-size: 14px; font-weight: 600; color: #e0e0e0; - margin-bottom: 14px; + margin-bottom: 10px; + flex-shrink: 0; } .subsection-title { - font-size: 13px; + font-size: 12px; font-weight: 600; color: #aaa; - margin: 18px 0 10px; + margin: 0 0 8px; + flex-shrink: 0; } -.summary-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); - gap: 12px; +.subsection-hint { + margin-left: 8px; + font-size: 10px; + font-weight: 400; + color: #666; } -.sstat { - padding: 12px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid #2a2a2a; - border-radius: 8px; - text-align: center; -} - -.sstat-value { - font-size: 22px; - font-weight: 700; - color: #eee; -} - -.sstat-muted { +.leg-badge { + margin-left: 4px; + font-size: 10px; color: var(--green-text, #2fb56a); } -.sstat-label { - font-size: 11px; - color: #888; - margin-top: 4px; +.bet-content-cell { + font-size: 12px; + color: #bbb; + line-height: 1.4; } -.status-chips { - display: flex; - flex-wrap: wrap; +.stats-charts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; - margin-top: 12px; + flex-shrink: 0; +} + +.mini-chart { + border: 1px solid #2a2a2a; + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); + padding: 4px 6px 0; + min-height: 0; + overflow: hidden; +} + +.mini-chart-canvas { + height: 148px; + width: 100%; +} + +.stats-tables { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 1fr 1.4fr; + gap: 12px; + overflow: hidden; +} + +.stats-table-block { + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.stats-table-block--bets .table-wrap { + flex: 1; + min-height: 0; + overflow: auto; +} + +.bet-pager { + flex-shrink: 0; + margin-top: 8px; + display: flex; + justify-content: flex-end; } .stats-table { @@ -635,15 +988,109 @@ onMounted(() => { align-items: center; } +.preview-hint { + font-size: 12px; + color: var(--el-text-color-secondary); +} + .preview-title { - font-size: 15px; + font-size: 14px; font-weight: 600; color: #e0e0e0; - margin-bottom: 16px; + margin-bottom: 12px; +} + +.preview-card--compact :deep(.el-card__body) { + padding: 12px 16px; +} + +.preview-bar { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.preview-bar-title { + font-size: 14px; + font-weight: 600; + color: #e0e0e0; + white-space: nowrap; +} + +.preview-metrics { + display: flex; + align-items: center; + gap: 20px; + flex: 1; + flex-wrap: wrap; +} + +.preview-metric { + display: flex; + align-items: baseline; + gap: 8px; +} + +.preview-metric-value { + font-size: 20px; + font-weight: 700; + color: #e0e0e0; +} + +.preview-metric-green { + color: var(--green-glow); +} + +.preview-metric-orange { + color: #e8a040; +} + +.preview-metric-label { + font-size: 12px; + color: #888; +} + +.preview-zero-hint { + margin: 10px 0 0; + padding: 8px 10px; + border-radius: 6px; + background: rgba(232, 160, 64, 0.12); + border: 1px solid rgba(232, 160, 64, 0.25); + color: #e8c080; + font-size: 12px; + line-height: 1.5; +} + +.preview-items-wrap { + margin-top: 10px; +} + +.preview-items-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.preview-items-title { + font-size: 12px; + font-weight: 600; + color: #aaa; +} + +.preview-items-table { + margin-top: 0; +} + +.preview-pager { + margin-top: 8px; + justify-content: flex-end; } .pstat { - padding: 16px; + padding: 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid #2a2a2a; border-radius: 10px; @@ -651,7 +1098,7 @@ onMounted(() => { } .pstat-value { - font-size: 26px; + font-size: 22px; font-weight: 700; color: #e0e0e0; } @@ -672,7 +1119,23 @@ onMounted(() => { } .confirm-btn { - margin-top: 24px; + margin-top: 16px; +} + +@media (max-width: 1100px) { + .stats-charts { + grid-template-columns: 1fr; + } + + .stats-tables { + grid-template-columns: 1fr; + overflow-y: auto; + } + + .stats-table-block { + min-height: 200px; + } + } /* 智能比分弹窗样式(功能已关闭,保留便于恢复) diff --git a/apps/admin/src/views/Users.vue b/apps/admin/src/views/Users.vue index 0ae57ec..c6479ba 100644 --- a/apps/admin/src/views/Users.vue +++ b/apps/admin/src/views/Users.vue @@ -47,14 +47,48 @@ const editingId = ref(''); const depositForm = ref({ userId: '', amount: 100, remark: '' }); const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false }); +const bettingLimits = ref({ + minStake: 1, + maxStakeSingle: 50000, + maxStakeParlay: 20000, + maxPayoutSingle: 500000, + maxPayoutParlay: 1000000, + dailyStakeLimit: 200000, +}); const settingsSaving = ref(false); +const limitsSaving = ref(false); onMounted(() => { loadAgentOptions(); loadPlayerSettings(); + loadBettingLimits(); load(); }); +async function loadBettingLimits() { + try { + const { data } = await api.get('/admin/settings/betting-limits'); + bettingLimits.value = data.data; + } catch { + /* 使用默认值 */ + } +} + +async function saveBettingLimits() { + limitsSaving.value = true; + try { + const { data } = await api.put('/admin/settings/betting-limits', bettingLimits.value); + bettingLimits.value = data.data; + ElMessage.success(t('msg.saved')); + } catch (e: unknown) { + const err = e as { response?: { data?: { error?: string } } }; + ElMessage.error(err.response?.data?.error ?? t('msg.save_failed')); + loadBettingLimits(); + } finally { + limitsSaving.value = false; + } +} + async function loadPlayerSettings() { try { const { data } = await api.get('/admin/users/settings/account'); @@ -312,6 +346,38 @@ function statusLabel(s: string) { + +
+ {{ t('user.betting_limits') }} + {{ t('user.betting_limits_hint') }} + + + + + + + + + + + + + + + + + + + + + + {{ t('common.save') }} + + + +
+
+ diff --git a/apps/admin/src/views/bet-form.ts b/apps/admin/src/views/bet-form.ts index 5f4faa7..9ad1863 100644 --- a/apps/admin/src/views/bet-form.ts +++ b/apps/admin/src/views/bet-form.ts @@ -1,3 +1,13 @@ +export interface BetSelectionPreview { + matchId: string | null; + matchLabel: string; + leagueName: string; + marketType: string; + period: string | null; + selectionName: string; + odds: string; +} + export interface BetListRow { id: string; betNo: string; @@ -16,11 +26,15 @@ export interface BetListRow { placedAt: string; settledAt: string | null; selectionCount: number; + selectionSummary: string; + selectionPreviews: BetSelectionPreview[]; } export interface BetSelectionDetail { id: string; matchId: string | null; + matchLabel: string; + leagueName: string; marketType: string; period: string | null; selectionName: string; diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json index f9aa683..113593f 100644 --- a/apps/api/nest-cli.json +++ b/apps/api/nest-cli.json @@ -3,6 +3,7 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": false, + "tsConfigPath": "tsconfig.build.json" } } diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 64029fd..5060092 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -507,17 +507,20 @@ async function main() { }); const permCodes = [ - 'users.create', 'users.view', 'agents.create', 'agents.view', + 'users.create', 'users.view', 'agents.create', 'agents.view', 'agents.credit', 'wallet.deposit', 'wallet.withdraw', 'matches.manage', 'settlement.confirm', - 'cashback.confirm', 'content.manage', 'reports.view', + 'settlement.resettle', 'cashback.confirm', 'content.manage', 'reports.view', + 'bets.view', 'settings.manage', 'audit.view', ]; + const permIds = new Map(); 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 }, @@ -525,6 +528,49 @@ async function main() { }); } + 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); diff --git a/apps/api/src/applications/admin/admin-permissions.ts b/apps/api/src/applications/admin/admin-permissions.ts new file mode 100644 index 0000000..5166543 --- /dev/null +++ b/apps/api/src/applications/admin/admin-permissions.ts @@ -0,0 +1,19 @@ +/** 后台权限码 — 与 seed 中 permissions 表一致 */ +export const P = { + reports: 'reports.view', + usersView: 'users.view', + usersCreate: 'users.create', + settings: 'settings.manage', + agentsView: 'agents.view', + agentsCreate: 'agents.create', + agentsCredit: 'agents.credit', + walletDeposit: 'wallet.deposit', + walletWithdraw: 'wallet.withdraw', + matches: 'matches.manage', + settlement: 'settlement.confirm', + resettle: 'settlement.resettle', + bets: 'bets.view', + cashback: 'cashback.confirm', + content: 'content.manage', + audit: 'audit.view', +} as const; diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index 7733f39..b793726 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -12,9 +12,9 @@ import { UseGuards, } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; -import { JwtAuthGuard, AdminGuard } from '../../domains/identity/guards'; +import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards'; import { ContentService } from '../../domains/operations/content/content.service'; -import { CurrentUser } from '../../shared/common/decorators'; +import { CurrentUser, RequirePermissions } from '../../shared/common/decorators'; import { jsonResponse } from '../../shared/common/filters'; import { UsersService } from '../../domains/identity/users.service'; import { AgentsService } from '../../domains/agent/agents.service'; @@ -27,9 +27,11 @@ import { CashbackService } from '../../domains/operations/cashback/cashback.serv import { I18nService } from '../../domains/operations/i18n/i18n.service'; import { AuditService } from '../../domains/operations/audit/audit.service'; import { BetsService } from '../../domains/betting/bets.service'; +import { BettingLimitsService } from '../../domains/betting/betting-limits.service'; 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 { IsString, IsNumber, @@ -341,17 +343,26 @@ function isZhiboBundlePayload(body: unknown): body is ZhiboMatchesBundleExport { } class ScoreDto { + @IsOptional() @IsNumber() - htHome!: number; + htHome?: number; + @IsOptional() @IsNumber() - htAway!: number; + htAway?: number; + @IsOptional() @IsNumber() - ftHome!: number; + ftHome?: number; + @IsOptional() @IsNumber() - ftAway!: number; + ftAway?: number; + + /** 冠军盘结算:获胜球队 ID */ + @IsOptional() + @IsNumber() + winnerTeamId?: number; } /* 智能比分推荐已关闭 @@ -571,9 +582,67 @@ class CashbackPreviewDto { periodEnd!: string; } +class ResettlePreviewDto { + @IsOptional() + @IsNumber() + htHome?: number; + + @IsOptional() + @IsNumber() + htAway?: number; + + @IsOptional() + @IsNumber() + ftHome?: number; + + @IsOptional() + @IsNumber() + ftAway?: number; + + @IsOptional() + @IsString() + reason?: string; + + @IsOptional() + @IsNumber() + winnerTeamId?: number; +} + +class BettingLimitsDto { + @IsOptional() + @IsNumber() + @Min(0) + minStake?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxStakeSingle?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxStakeParlay?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxPayoutSingle?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxPayoutParlay?: number; + + @IsOptional() + @IsNumber() + @Min(0) + dailyStakeLimit?: number; +} + @ApiTags('Admin') @Controller('admin') -@UseGuards(JwtAuthGuard, AdminGuard) +@UseGuards(JwtAuthGuard, AdminGuard, PermissionsGuard) @ApiBearerAuth() export class AdminController { constructor( @@ -592,21 +661,25 @@ export class AdminController { private prisma: PrismaService, private readonly dashboardService: AdminDashboardService, private systemConfig: SystemConfigService, + private bettingLimits: BettingLimitsService, ) {} @Get('dashboard') + @RequirePermissions(P.reports) async getDashboard() { const overview = await this.dashboardService.getOverview(); return jsonResponse(overview); } @Get('users/settings/account') + @RequirePermissions(P.settings) async getPlayerAccountSettings() { const settings = await this.systemConfig.getPlayerAccountSettings(); return jsonResponse(settings); } @Put('users/settings/account') + @RequirePermissions(P.settings) async updatePlayerAccountSettings( @CurrentUser('id') operatorId: bigint, @Body() dto: PlayerAccountSettingsDto, @@ -622,7 +695,32 @@ export class AdminController { return jsonResponse(settings); } + @Get('settings/betting-limits') + @RequirePermissions(P.settings) + async getBettingLimits() { + const limits = await this.bettingLimits.getLimits(); + return jsonResponse(limits); + } + + @Put('settings/betting-limits') + @RequirePermissions(P.settings) + async updateBettingLimits( + @CurrentUser('id') operatorId: bigint, + @Body() dto: BettingLimitsDto, + ) { + const limits = await this.bettingLimits.updateLimits(dto); + await this.audit.log({ + operatorId, + operatorType: 'ADMIN', + action: 'UPDATE_BETTING_LIMITS', + module: 'SETTINGS', + afterData: limits, + }); + return jsonResponse(limits); + } + @Get('users') + @RequirePermissions(P.usersView) async listUsers( @Query('page') page?: string, @Query('pageSize') pageSize?: string, @@ -643,12 +741,14 @@ export class AdminController { } @Get('users/:id') + @RequirePermissions(P.usersView) async getUserDetail(@Param('id') id: string) { const detail = await this.users.getPlayerAdminDetail(BigInt(id)); return jsonResponse(detail); } @Put('users/:id') + @RequirePermissions(P.usersCreate) async updateUser( @CurrentUser('id') operatorId: bigint, @Param('id') id: string, @@ -666,6 +766,7 @@ export class AdminController { } @Post('users') + @RequirePermissions(P.usersCreate) async createPlayer( @CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlayerAdminDto, @@ -700,6 +801,7 @@ export class AdminController { } @Get('users/promotable-for-agent') + @RequirePermissions(P.usersView) async listPromotableForAgent(@Query('keyword') keyword?: string) { const rows = await this.agents.listPromotablePlayers(keyword); return jsonResponse( @@ -716,6 +818,7 @@ export class AdminController { } @Get('agents/options') + @RequirePermissions(P.agentsView) async listAgentOptions() { const agents = await this.prisma.user.findMany({ where: { userType: 'AGENT', deletedAt: null, agentLevel: 1 }, @@ -728,6 +831,7 @@ export class AdminController { } @Get('agents') + @RequirePermissions(P.agentsView) async listAgents( @Query('page') page?: string, @Query('pageSize') pageSize?: string, @@ -742,12 +846,14 @@ export class AdminController { } @Get('agents/:id') + @RequirePermissions(P.agentsView) async getAgentDetail(@Param('id') id: string) { const detail = await this.agents.getAgentAdminDetail(BigInt(id)); return jsonResponse(detail); } @Put('agents/:id') + @RequirePermissions(P.agentsCreate) async updateAgent( @CurrentUser('id') operatorId: bigint, @Param('id') id: string, @@ -765,6 +871,7 @@ export class AdminController { } @Post('agents') + @RequirePermissions(P.agentsCreate) async createAgent( @CurrentUser('id') operatorId: bigint, @Body() dto: CreateAgentAdminDto, @@ -787,6 +894,7 @@ export class AdminController { } @Post('agents/:id/credit') + @RequirePermissions(P.agentsCredit) async adjustCredit( @CurrentUser('id') operatorId: bigint, @Param('id') id: string, @@ -803,6 +911,7 @@ export class AdminController { } @Post('wallet/deposit') + @RequirePermissions(P.walletDeposit) async deposit(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) { const result = await this.wallet.deposit( BigInt(dto.userId), @@ -815,6 +924,7 @@ export class AdminController { } @Post('wallet/withdraw') + @RequirePermissions(P.walletWithdraw) async withdraw(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) { const result = await this.wallet.withdraw( BigInt(dto.userId), @@ -827,12 +937,14 @@ export class AdminController { } @Get('wallet/transactions') + @RequirePermissions(P.walletDeposit, P.walletWithdraw) async walletTransactions(@Query('userId') userId: string, @Query('page') page?: string) { const result = await this.wallet.getTransactions(BigInt(userId), page ? parseInt(page) : 1); return jsonResponse(result); } @Post('leagues') + @RequirePermissions(P.matches) async createLeague( @Body() dto: CreatePlatformLeagueDto | { code: string; translations: Record }, ) { @@ -853,6 +965,7 @@ export class AdminController { } @Get('leagues') + @RequirePermissions(P.matches, P.reports) async listLeagues( @Query('page') page?: string, @Query('pageSize') pageSize?: string, @@ -871,6 +984,7 @@ export class AdminController { } @Get('leagues/:leagueId/matches') + @RequirePermissions(P.matches, P.reports) async listLeagueMatches( @Param('leagueId') leagueId: string, @Query('status') status?: string, @@ -886,12 +1000,14 @@ export class AdminController { } @Post('teams') + @RequirePermissions(P.matches) async createTeam(@Body() dto: { code: string; translations: Record }) { const team = await this.matches.createTeam(dto.code, dto.translations); return jsonResponse(team); } @Get('matches') + @RequirePermissions(P.matches, P.reports) async listMatches( @Query('page') page?: string, @Query('pageSize') pageSize?: string, @@ -928,12 +1044,14 @@ export class AdminController { } @Get('matches/:id') + @RequirePermissions(P.matches, P.reports) async getMatch(@Param('id') id: string) { const match = await this.matches.getAdminMatchDetail(BigInt(id)); return jsonResponse(match); } @Put('matches/:id') + @RequirePermissions(P.matches) async updateMatch( @CurrentUser('id') operatorId: bigint, @Param('id') id: string, @@ -964,12 +1082,14 @@ export class AdminController { } @Delete('matches/:id') + @RequirePermissions(P.matches) async deleteMatch(@Param('id') id: string) { await this.matches.deleteMatch(BigInt(id)); return jsonResponse({ deleted: true }); } @Post('matches') + @RequirePermissions(P.matches) async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) { const match = await this.matches.createPlatformMatch({ leagueId: dto.leagueId ? BigInt(dto.leagueId) : undefined, @@ -997,6 +1117,7 @@ export class AdminController { } @Post('matches/import') + @RequirePermissions(P.matches) async importMatches(@CurrentUser('id') operatorId: bigint, @Body() dto: ZhiboMatchesBundleExport) { if (!isZhiboBundlePayload(dto)) { throw new BadRequestException('Invalid import payload: matches[] required'); @@ -1006,18 +1127,21 @@ export class AdminController { } @Post('matches/:id/publish') + @RequirePermissions(P.matches) async publishMatch(@Param('id') id: string) { const match = await this.matches.publishMatch(BigInt(id)); return jsonResponse(match); } @Post('matches/:id/close') + @RequirePermissions(P.matches) async closeMatch(@Param('id') id: string) { const match = await this.matches.closeMatch(BigInt(id)); return jsonResponse(match); } @Post('matches/:id/cancel') + @RequirePermissions(P.matches) async cancelMatch(@Param('id') id: string) { await this.matches.cancelMatch(BigInt(id)); const voided = await this.settlement.voidMatchBets(BigInt(id)); @@ -1025,12 +1149,14 @@ export class AdminController { } @Post('matches/:id/markets/templates') + @RequirePermissions(P.matches) async generateTemplates(@Param('id') id: string, @Body() dto: MarketTemplatesDto) { const markets = await this.markets.generateTemplates(BigInt(id), dto.marketTypes); return jsonResponse(markets); } @Put('matches/:id/odds') + @RequirePermissions(P.matches) async batchUpdateMatchOdds( @CurrentUser('id') operatorId: bigint, @Param('id') id: string, @@ -1045,6 +1171,7 @@ export class AdminController { } @Patch('markets/:id') + @RequirePermissions(P.matches) async updateMarket(@Param('id') id: string, @Body() dto: UpdateMarketDto) { const market = await this.markets.updateMarket(BigInt(id), { promoLabel: dto.promoLabel, @@ -1055,6 +1182,7 @@ export class AdminController { } @Patch('selections/:id') + @RequirePermissions(P.matches) async updateSelection( @CurrentUser('id') operatorId: bigint, @Param('id') id: string, @@ -1073,6 +1201,7 @@ export class AdminController { } @Put('selections/:id/odds') + @RequirePermissions(P.matches) async updateOdds( @CurrentUser('id') operatorId: bigint, @Param('id') id: string, @@ -1083,18 +1212,21 @@ export class AdminController { } @Get('outrights') + @RequirePermissions(P.matches, P.reports) async listOutrights() { const data = await this.outright.listForAdmin(); return jsonResponse(data); } @Get('outrights/leagues') + @RequirePermissions(P.matches, P.reports) async listOutrightLeagues() { const data = await this.outright.listLeagueOptions(); return jsonResponse(data); } @Post('outrights') + @RequirePermissions(P.matches) async createOutright(@Body() dto: CreateOutrightDto) { const data = await this.outright.createForAdmin({ leagueId: BigInt(dto.leagueId), @@ -1107,6 +1239,7 @@ export class AdminController { } @Post('outrights/import/wc2026') + @RequirePermissions(P.matches) async importWc2026Outright() { const data = await this.outright.importWc2026Canonical(); return jsonResponse(data); @@ -1114,6 +1247,7 @@ export class AdminController { /** @deprecated */ @Get('outrights/wc2026') + @RequirePermissions(P.matches, P.reports) async getWc2026OutrightLegacy() { const list = await this.outright.listForAdmin(); const wc = list.find((e) => e.leagueCode === 'WC2026'); @@ -1123,6 +1257,7 @@ export class AdminController { /** @deprecated */ @Put('outrights/wc2026/odds') + @RequirePermissions(P.matches) async updateWc2026OutrightOddsLegacy( @CurrentUser('id') operatorId: bigint, @Body() dto: BatchOutrightOddsDto, @@ -1137,17 +1272,20 @@ export class AdminController { /** @deprecated */ @Post('outrights/wc2026/apply-canonical') + @RequirePermissions(P.matches) async applyWc2026CanonicalLegacy() { return jsonResponse(await this.outright.importWc2026Canonical()); } @Get('outrights/:matchId') + @RequirePermissions(P.matches, P.reports) async getOutright(@Param('matchId') matchId: string) { const data = await this.outright.getForAdmin(BigInt(matchId)); return jsonResponse(data); } @Put('outrights/:matchId') + @RequirePermissions(P.matches) async updateOutright( @Param('matchId') matchId: string, @Body() dto: UpdateOutrightDto, @@ -1157,6 +1295,7 @@ export class AdminController { } @Put('outrights/:matchId/odds') + @RequirePermissions(P.matches) async updateOutrightOdds( @CurrentUser('id') operatorId: bigint, @Param('matchId') matchId: string, @@ -1171,6 +1310,7 @@ export class AdminController { } @Post('outrights/:matchId/selections') + @RequirePermissions(P.matches) async addOutrightSelection( @Param('matchId') matchId: string, @Body() dto: AddOutrightSelectionDto, @@ -1180,6 +1320,7 @@ export class AdminController { } @Patch('outrights/:matchId/selections/:selectionId') + @RequirePermissions(P.matches) async updateOutrightSelectionTeam( @Param('matchId') matchId: string, @Param('selectionId') selectionId: string, @@ -1194,6 +1335,7 @@ export class AdminController { } @Delete('outrights/:matchId/selections/:selectionId') + @RequirePermissions(P.matches) async removeOutrightSelection( @Param('matchId') matchId: string, @Param('selectionId') selectionId: string, @@ -1206,8 +1348,16 @@ export class AdminController { } @Get('matches/:id/settlement/stats') - async getMatchSettlementStats(@Param('id') id: string) { - const data = await this.settlement.getMatchBetStats(BigInt(id)); + @RequirePermissions(P.settlement, P.reports) + async getMatchSettlementStats( + @Param('id') id: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + const data = await this.settlement.getMatchBetStats(BigInt(id), { + page: page ? Math.max(1, parseInt(page, 10) || 1) : 1, + pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10, + }); return jsonResponse(data); } @@ -1216,6 +1366,7 @@ export class AdminController { // async suggestSmartScore(...) { ... } @Post('matches/:id/settlement/score') + @RequirePermissions(P.settlement) async recordScore( @CurrentUser('id') operatorId: bigint, @Param('id') id: string, @@ -1223,28 +1374,99 @@ export class AdminController { ) { const result = await this.settlement.recordScore( BigInt(id), - dto.htHome, - dto.htAway, - dto.ftHome, - dto.ftAway, + dto.htHome ?? 0, + dto.htAway ?? 0, + dto.ftHome ?? 0, + dto.ftAway ?? 0, operatorId, + dto.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined, ); return jsonResponse(result); } @Post('matches/:id/settlement/preview') - async settlementPreview(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) { - const preview = await this.settlement.previewSettlement(BigInt(id), operatorId); + @RequirePermissions(P.settlement) + async settlementPreview( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + @Body() dto?: { page?: number; pageSize?: number }, + ) { + const preview = await this.settlement.previewSettlement(BigInt(id), operatorId, { + page: dto?.page ? Math.max(1, dto.page) : 1, + pageSize: dto?.pageSize ? Math.min(100, Math.max(1, dto.pageSize)) : 10, + }); return jsonResponse(preview); } + @Get('settlement/:batchId/preview-items') + @RequirePermissions(P.settlement) + async getSettlementPreviewItems( + @Param('batchId') batchId: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + const data = await this.settlement.getPreviewSettlementItems(BigInt(batchId), { + page: page ? Math.max(1, parseInt(page, 10) || 1) : 1, + pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10, + }); + return jsonResponse(data); + } + @Post('settlement/:batchId/confirm') + @RequirePermissions(P.settlement) async confirmSettlement(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) { const result = await this.settlement.confirmSettlement(BigInt(batchId), operatorId); + await this.audit.log({ + operatorId, + operatorType: 'ADMIN', + action: 'CONFIRM_SETTLEMENT', + module: 'SETTLEMENT', + targetId: batchId, + }); + return jsonResponse(result); + } + + @Post('matches/:id/resettle/preview') + @RequirePermissions(P.resettle) + async resettlePreview( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + @Body() dto: ResettlePreviewDto, + ) { + const preview = await this.settlement.previewResettlement( + BigInt(id), + { + htHome: dto.htHome ?? 0, + htAway: dto.htAway ?? 0, + ftHome: dto.ftHome ?? 0, + ftAway: dto.ftAway ?? 0, + }, + operatorId, + dto.reason, + dto.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined, + ); + return jsonResponse(preview); + } + + @Post('resettle/:batchId/confirm') + @RequirePermissions(P.resettle) + async confirmResettlement( + @CurrentUser('id') operatorId: bigint, + @Param('batchId') batchId: string, + ) { + const result = await this.settlement.confirmResettlement(BigInt(batchId), operatorId); + await this.audit.log({ + operatorId, + operatorType: 'ADMIN', + action: 'CONFIRM_RESETTLE', + module: 'SETTLEMENT', + targetId: batchId, + }); return jsonResponse(result); } @Get('bets') + @RequirePermissions(P.bets) async listBets( @Query('page') page?: string, @Query('pageSize') pageSize?: string, @@ -1267,12 +1489,14 @@ export class AdminController { } @Get('bets/:id') + @RequirePermissions(P.bets) async getBet(@Param('id') id: string) { const detail = await this.bets.getBetAdminDetail(BigInt(id)); return jsonResponse(detail); } @Post('cashbacks/preview') + @RequirePermissions(P.cashback, P.reports) async cashbackPreview(@Body() dto: CashbackPreviewDto) { const preview = await this.cashback.previewBatch( new Date(dto.periodStart), @@ -1282,12 +1506,21 @@ export class AdminController { } @Post('cashbacks/:batchId/confirm') + @RequirePermissions(P.cashback) async cashbackConfirm(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) { const result = await this.cashback.confirmBatch(BigInt(batchId), operatorId); + await this.audit.log({ + operatorId, + operatorType: 'ADMIN', + action: 'CONFIRM_CASHBACK', + module: 'CASHBACK', + targetId: batchId, + }); return jsonResponse(result); } @Get('contents') + @RequirePermissions(P.content, P.reports) async listContents( @Query('type') type?: string, @Query('status') status?: string, @@ -1297,24 +1530,28 @@ export class AdminController { } @Get('contents/:id') + @RequirePermissions(P.content, P.reports) async getContent(@Param('id') id: string) { const item = await this.content.getForAdmin(BigInt(id)); return jsonResponse(item); } @Post('contents') + @RequirePermissions(P.content) async createContent(@Body() dto: CreateContentDto) { const item = await this.content.create(dto); return jsonResponse(item); } @Put('contents/:id') + @RequirePermissions(P.content) async updateContent(@Param('id') id: string, @Body() dto: UpdateContentDto) { const item = await this.content.update(BigInt(id), dto); return jsonResponse(item); } @Patch('contents/:id/status') + @RequirePermissions(P.content) async updateContentStatus( @Param('id') id: string, @Body() dto: ContentStatusDto, @@ -1324,18 +1561,21 @@ export class AdminController { } @Delete('contents/:id') + @RequirePermissions(P.content) async deleteContent(@Param('id') id: string) { const result = await this.content.remove(BigInt(id)); return jsonResponse(result); } @Get('i18n/messages') + @RequirePermissions(P.settings, P.reports) async getMessages(@Query('locale') locale = 'en-US') { const messages = await this.i18n.getMessages(locale); return jsonResponse(messages); } @Get('audit-logs') + @RequirePermissions(P.audit) async auditLogs( @Query('page') page?: string, @Query('pageSize') pageSize?: string, diff --git a/apps/api/src/applications/admin/admin.module.ts b/apps/api/src/applications/admin/admin.module.ts index 7277e1c..a2b4268 100644 --- a/apps/api/src/applications/admin/admin.module.ts +++ b/apps/api/src/applications/admin/admin.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { AdminController } from './admin.controller'; import { AdminDashboardService } from './admin-dashboard.service'; +import { PermissionsGuard } from '../../domains/identity/guards'; import { UsersModule } from '../../domains/identity/users.module'; import { AgentsModule } from '../../domains/agent/agents.module'; import { WalletModule } from '../../domains/ledger/wallet.module'; @@ -26,6 +27,6 @@ import { BetsModule } from '../../domains/betting/bets.module'; BetsModule, ], controllers: [AdminController], - providers: [AdminDashboardService], + providers: [AdminDashboardService, PermissionsGuard], }) export class AdminModule {} diff --git a/apps/api/src/domains/betting/bets.module.ts b/apps/api/src/domains/betting/bets.module.ts index 25bed0c..9d04950 100644 --- a/apps/api/src/domains/betting/bets.module.ts +++ b/apps/api/src/domains/betting/bets.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { BetsService } from './bets.service'; +import { BettingLimitsService } from './betting-limits.service'; import { WalletModule } from '../ledger/wallet.module'; @Module({ imports: [WalletModule], - providers: [BetsService], - exports: [BetsService], + providers: [BetsService, BettingLimitsService], + exports: [BetsService, BettingLimitsService], }) export class BetsModule {} diff --git a/apps/api/src/domains/betting/bets.service.ts b/apps/api/src/domains/betting/bets.service.ts index 3c289e8..16979df 100644 --- a/apps/api/src/domains/betting/bets.service.ts +++ b/apps/api/src/domains/betting/bets.service.ts @@ -2,6 +2,7 @@ import { Injectable, BadRequestException, ConflictException, NotFoundException } import { Prisma } from '@prisma/client'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { WalletService } from '../ledger/wallet.service'; +import { BettingLimitsService } from './betting-limits.service'; import { Decimal } from '@prisma/client/runtime/library'; import { generateBetNo } from '../../shared/common/decorators'; import { @@ -10,8 +11,25 @@ import { canSelectForParlay, isPreMatchKickoff, isSupportedSport, + resolveTranslationFallback, } from '@thebet365/shared'; +type MatchContext = { + matchLabel: string; + leagueName: string; +}; + +type BetSelectionRow = { + matchId: bigint | null; + marketType: string; + period: string | null; + selectionNameSnapshot: string; + handicapLine: Decimal | null; + totalLine: Decimal | null; + odds: Decimal; + sortOrder?: number; +}; + interface BetSelectionInput { selectionId: bigint; oddsVersion: bigint; @@ -23,6 +41,7 @@ export class BetsService { constructor( private prisma: PrismaService, private wallet: WalletService, + private bettingLimits: BettingLimitsService, ) {} private async validateSelection( @@ -92,6 +111,12 @@ export class BetsService { const odds = new Decimal(selection.odds.toString()); const stakeDec = new Decimal(stake); const potentialReturn = stakeDec.mul(odds); + await this.bettingLimits.validateBet({ + userId, + betType: 'SINGLE', + stake, + potentialReturn, + }); const betNo = generateBetNo(); const bet = await this.prisma.$transaction(async (tx) => { @@ -148,16 +173,9 @@ export class BetsService { if (existing) return existing; const selections: Awaited>[] = []; - const matchIds = new Set(); for (const leg of legs) { const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion, { forParlay: true }); - - const matchKey = sel.market.matchId.toString(); - if (matchIds.has(matchKey)) { - throw new BadRequestException('Same match cannot be in parlay'); - } - matchIds.add(matchKey); selections.push(sel); } @@ -168,6 +186,12 @@ export class BetsService { const stakeDec = new Decimal(stake); const potentialReturn = stakeDec.mul(totalOdds); + await this.bettingLimits.validateBet({ + userId, + betType: 'PARLAY', + stake, + potentialReturn, + }); const betNo = generateBetNo(); const bet = await this.prisma.$transaction(async (tx) => { @@ -234,6 +258,105 @@ export class BetsService { return v?.toString() ?? '0'; } + private resolveEntityName( + translations: Array<{ entityType: string; entityId: bigint; locale: string; value: string }>, + entityType: string, + entityId: bigint, + locale: string, + ) { + const map = Object.fromEntries( + translations + .filter( + (t) => + t.entityType === entityType && t.entityId.toString() === entityId.toString(), + ) + .map((t) => [t.locale, t.value]), + ); + return resolveTranslationFallback(map, locale); + } + + private async loadMatchContext( + matchIds: bigint[], + locale = 'zh-CN', + ): Promise> { + const result = new Map(); + if (matchIds.length === 0) return result; + + const matches = await this.prisma.match.findMany({ + where: { id: { in: matchIds } }, + select: { + id: true, + matchName: true, + leagueId: true, + homeTeamId: true, + awayTeamId: true, + homeTeam: { select: { code: true } }, + awayTeam: { select: { code: true } }, + }, + }); + + const entityIds = new Set(); + for (const m of matches) { + entityIds.add(m.leagueId); + entityIds.add(m.homeTeamId); + entityIds.add(m.awayTeamId); + } + + const translations = + entityIds.size > 0 + ? await this.prisma.entityTranslation.findMany({ + where: { + entityId: { in: Array.from(entityIds) }, + entityType: { in: ['TEAM', 'LEAGUE'] }, + fieldName: 'name', + }, + }) + : []; + + for (const m of matches) { + const leagueName = + this.resolveEntityName(translations, 'LEAGUE', m.leagueId, locale) || m.leagueId.toString(); + const homeName = + this.resolveEntityName(translations, 'TEAM', m.homeTeamId, locale) || m.homeTeam.code; + const awayName = + this.resolveEntityName(translations, 'TEAM', m.awayTeamId, locale) || m.awayTeam.code; + const matchLabel = m.matchName?.trim() || `${homeName} vs ${awayName}`; + result.set(m.id.toString(), { matchLabel, leagueName }); + } + + return result; + } + + private formatSelectionPreviews( + selections: BetSelectionRow[], + matchContext: Map, + ) { + return selections.map((s) => { + const ctx = s.matchId ? matchContext.get(s.matchId.toString()) : undefined; + return { + matchId: s.matchId?.toString() ?? null, + matchLabel: ctx?.matchLabel ?? '—', + leagueName: ctx?.leagueName ?? '', + marketType: s.marketType, + period: s.period, + selectionName: s.selectionNameSnapshot, + odds: this.dec(s.odds), + }; + }); + } + + private attachSelectionPreviews>( + row: T, + selections: BetSelectionRow[], + matchContext: Map, + ) { + const selectionPreviews = this.formatSelectionPreviews(selections, matchContext); + const selectionSummary = selectionPreviews + .map((p) => `${p.matchLabel} · ${p.selectionName}`) + .join(';'); + return { ...row, selectionPreviews, selectionSummary }; + } + private formatBetListRow( b: { id: bigint; @@ -326,7 +449,7 @@ export class BetsService { parent: { select: { username: true } }, }, }, - _count: { select: { selections: true } }, + selections: { orderBy: { sortOrder: 'asc' } }, }, orderBy: { placedAt: 'desc' }, skip, @@ -335,8 +458,26 @@ export class BetsService { this.prisma.bet.count({ where }), ]); + const matchIds = [ + ...new Set( + items.flatMap((b) => + b.selections.map((s) => s.matchId).filter((id): id is bigint => id != null), + ), + ), + ]; + const matchContext = await this.loadMatchContext(matchIds); + return { - items: items.map((b) => this.formatBetListRow(b)), + items: items.map((b) => + this.attachSelectionPreviews( + this.formatBetListRow({ + ...b, + _count: { selections: b.selections.length }, + }), + b.selections, + matchContext, + ), + ), total, page, pageSize, @@ -359,27 +500,44 @@ export class BetsService { }); if (!bet) throw new NotFoundException('注单不存在'); - return { - ...this.formatBetListRow({ + const matchIds = bet.selections + .map((s) => s.matchId) + .filter((id): id is bigint => id != null); + const matchContext = await this.loadMatchContext(matchIds); + const selectionPreviews = this.formatSelectionPreviews(bet.selections, matchContext); + + const base = this.attachSelectionPreviews( + this.formatBetListRow({ ...bet, _count: { selections: bet.selections.length }, }), + bet.selections, + matchContext, + ); + + return { + ...base, requestId: bet.requestId, createdAt: bet.createdAt, updatedAt: bet.updatedAt, - selections: bet.selections.map((s) => ({ - id: s.id.toString(), - matchId: s.matchId?.toString() ?? null, - marketType: s.marketType, - period: s.period, - selectionName: s.selectionNameSnapshot, - handicapLine: s.handicapLine ? this.dec(s.handicapLine) : null, - totalLine: s.totalLine ? this.dec(s.totalLine) : null, - odds: this.dec(s.odds), - resultStatus: s.resultStatus, - effectiveOdds: s.effectiveOdds ? this.dec(s.effectiveOdds) : null, - sortOrder: s.sortOrder, - })), + selections: bet.selections.map((s, index) => { + const preview = selectionPreviews[index]; + return { + id: s.id.toString(), + matchId: s.matchId?.toString() ?? null, + matchLabel: preview?.matchLabel ?? '—', + leagueName: preview?.leagueName ?? '', + marketType: s.marketType, + period: s.period, + selectionName: s.selectionNameSnapshot, + handicapLine: s.handicapLine ? this.dec(s.handicapLine) : null, + totalLine: s.totalLine ? this.dec(s.totalLine) : null, + odds: this.dec(s.odds), + resultStatus: s.resultStatus, + effectiveOdds: s.effectiveOdds ? this.dec(s.effectiveOdds) : null, + sortOrder: s.sortOrder, + }; + }), }; } } diff --git a/apps/api/src/domains/betting/betting-limits.service.spec.ts b/apps/api/src/domains/betting/betting-limits.service.spec.ts new file mode 100644 index 0000000..1fd5018 --- /dev/null +++ b/apps/api/src/domains/betting/betting-limits.service.spec.ts @@ -0,0 +1,54 @@ +import { BettingLimitsService } from './betting-limits.service'; +import { Decimal } from '@prisma/client/runtime/library'; + +describe('BettingLimitsService', () => { + const prisma = { + systemConfig: { + findUnique: jest.fn(), + }, + bet: { + aggregate: jest.fn(), + }, + }; + + const service = new BettingLimitsService(prisma as never); + + beforeEach(() => { + jest.clearAllMocks(); + prisma.systemConfig.findUnique.mockResolvedValue(null); + prisma.bet.aggregate.mockResolvedValue({ _sum: { stake: new Decimal(0) } }); + }); + + it('rejects stake below minimum', async () => { + await expect( + service.validateBet({ + userId: BigInt(1), + betType: 'SINGLE', + stake: 0.5, + potentialReturn: new Decimal(1), + }), + ).rejects.toThrow('Minimum stake is 1'); + }); + + it('rejects stake above single max', async () => { + await expect( + service.validateBet({ + userId: BigInt(1), + betType: 'SINGLE', + stake: 60000, + potentialReturn: new Decimal(70000), + }), + ).rejects.toThrow('Maximum stake is 50000'); + }); + + it('rejects potential return above payout cap', async () => { + await expect( + service.validateBet({ + userId: BigInt(1), + betType: 'SINGLE', + stake: 100, + potentialReturn: new Decimal(600000), + }), + ).rejects.toThrow('Potential return exceeds limit'); + }); +}); diff --git a/apps/api/src/domains/betting/betting-limits.service.ts b/apps/api/src/domains/betting/betting-limits.service.ts new file mode 100644 index 0000000..71f50ed --- /dev/null +++ b/apps/api/src/domains/betting/betting-limits.service.ts @@ -0,0 +1,126 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { Decimal } from '@prisma/client/runtime/library'; +import { PrismaService } from '../../shared/prisma/prisma.service'; + +export type BettingLimits = { + minStake: number; + maxStakeSingle: number; + maxStakeParlay: number; + maxPayoutSingle: number; + maxPayoutParlay: number; + dailyStakeLimit: number; +}; + +export const BET_LIMIT_KEYS = { + minStake: 'bet.min_stake', + maxStakeSingle: 'bet.max_stake_single', + maxStakeParlay: 'bet.max_stake_parlay', + maxPayoutSingle: 'bet.max_payout_single', + maxPayoutParlay: 'bet.max_payout_parlay', + dailyStakeLimit: 'bet.daily_stake_limit', +} as const; + +const DEFAULTS: BettingLimits = { + minStake: 1, + maxStakeSingle: 50000, + maxStakeParlay: 20000, + maxPayoutSingle: 500000, + maxPayoutParlay: 1000000, + dailyStakeLimit: 200000, +}; + +@Injectable() +export class BettingLimitsService { + constructor(private prisma: PrismaService) {} + + private async getNumber(key: string, fallback: number): Promise { + const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } }); + if (!row) return fallback; + const n = Number(row.configValue); + return Number.isFinite(n) && n >= 0 ? n : fallback; + } + + private async setNumber(key: string, value: number, description: string) { + await this.prisma.systemConfig.upsert({ + where: { configKey: key }, + create: { configKey: key, configValue: String(value), description }, + update: { configValue: String(value) }, + }); + } + + async getLimits(): Promise { + return { + minStake: await this.getNumber(BET_LIMIT_KEYS.minStake, DEFAULTS.minStake), + maxStakeSingle: await this.getNumber(BET_LIMIT_KEYS.maxStakeSingle, DEFAULTS.maxStakeSingle), + maxStakeParlay: await this.getNumber(BET_LIMIT_KEYS.maxStakeParlay, DEFAULTS.maxStakeParlay), + maxPayoutSingle: await this.getNumber(BET_LIMIT_KEYS.maxPayoutSingle, DEFAULTS.maxPayoutSingle), + maxPayoutParlay: await this.getNumber(BET_LIMIT_KEYS.maxPayoutParlay, DEFAULTS.maxPayoutParlay), + dailyStakeLimit: await this.getNumber(BET_LIMIT_KEYS.dailyStakeLimit, DEFAULTS.dailyStakeLimit), + }; + } + + async updateLimits(data: Partial): Promise { + const desc: Record = { + minStake: '最小单注金额', + maxStakeSingle: '单关最大投注额', + maxStakeParlay: '串关最大投注额', + maxPayoutSingle: '单关最高派彩', + maxPayoutParlay: '串关最高派彩', + dailyStakeLimit: '玩家每日投注上限', + }; + for (const [field, key] of Object.entries(BET_LIMIT_KEYS) as Array< + [keyof BettingLimits, string] + >) { + const val = data[field]; + if (val !== undefined) { + await this.setNumber(key, val, desc[field]); + } + } + return this.getLimits(); + } + + async validateBet(params: { + userId: bigint; + betType: 'SINGLE' | 'PARLAY'; + stake: number; + potentialReturn: Decimal; + }) { + const limits = await this.getLimits(); + const stake = params.stake; + + if (stake < limits.minStake) { + throw new BadRequestException(`Minimum stake is ${limits.minStake}`); + } + + const maxStake = params.betType === 'PARLAY' ? limits.maxStakeParlay : limits.maxStakeSingle; + if (stake > maxStake) { + throw new BadRequestException(`Maximum stake is ${maxStake}`); + } + + const maxPayout = + params.betType === 'PARLAY' ? limits.maxPayoutParlay : limits.maxPayoutSingle; + if (params.potentialReturn.gt(maxPayout)) { + throw new BadRequestException(`Potential return exceeds limit of ${maxPayout}`); + } + + if (limits.dailyStakeLimit > 0) { + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(); + endOfDay.setHours(23, 59, 59, 999); + + const agg = await this.prisma.bet.aggregate({ + where: { + userId: params.userId, + placedAt: { gte: startOfDay, lte: endOfDay }, + status: { notIn: ['VOID', 'CANCELLED'] }, + }, + _sum: { stake: true }, + }); + const todayStake = new Decimal(agg._sum.stake ?? 0); + if (todayStake.add(stake).gt(limits.dailyStakeLimit)) { + throw new BadRequestException(`Daily stake limit of ${limits.dailyStakeLimit} exceeded`); + } + } + } +} diff --git a/apps/api/src/domains/identity/guards.ts b/apps/api/src/domains/identity/guards.ts index 52287e6..13a10ed 100644 --- a/apps/api/src/domains/identity/guards.ts +++ b/apps/api/src/domains/identity/guards.ts @@ -33,14 +33,20 @@ export class PermissionsGuard implements CanActivate { context.getHandler(), context.getClass(), ]); - if (!required?.length) return true; const { user } = context.switchToHttp().getRequest(); - const userPerms: string[] = user?.permissions ?? []; - if (user?.role === 'SUPER_ADMIN') return true; + if (!user || user.userType !== 'ADMIN') { + throw new ForbiddenException('Admin access required'); + } + if (user.role === 'SUPER_ADMIN') return true; - const hasAll = required.every((p) => userPerms.includes(p)); - if (!hasAll) throw new ForbiddenException('Insufficient permissions'); + if (!required?.length) { + throw new ForbiddenException('Insufficient permissions'); + } + + const userPerms: string[] = user.permissions ?? []; + const hasAccess = required.some((p) => userPerms.includes(p)); + if (!hasAccess) throw new ForbiddenException('Insufficient permissions'); return true; } } diff --git a/apps/api/src/domains/identity/jwt.strategy.ts b/apps/api/src/domains/identity/jwt.strategy.ts index deef8ca..9c63a28 100644 --- a/apps/api/src/domains/identity/jwt.strategy.ts +++ b/apps/api/src/domains/identity/jwt.strategy.ts @@ -40,6 +40,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } const permissions = user.adminRole?.role?.permissions?.map((rp) => rp.permission.code) ?? []; + const roleCode = user.adminRole?.role?.code ?? payload.role; return { id: user.id, username: user.username, @@ -47,7 +48,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { parentId: user.parentId, agentLevel: user.agentLevel, locale: user.locale, - role: payload.role, + role: roleCode, permissions, }; } diff --git a/apps/api/src/domains/ledger/wallet.service.ts b/apps/api/src/domains/ledger/wallet.service.ts index eddee7e..07def41 100644 --- a/apps/api/src/domains/ledger/wallet.service.ts +++ b/apps/api/src/domains/ledger/wallet.service.ts @@ -1,8 +1,11 @@ import { Injectable, BadRequestException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { Decimal } from '@prisma/client/runtime/library'; import { generateTransactionId } from '../../shared/common/decorators'; +type TxClient = Prisma.TransactionClient; + @Injectable() export class WalletService { constructor(private prisma: PrismaService) {} @@ -19,7 +22,7 @@ export class WalletService { }); } - private async lockWallet(tx: Parameters[0]>[0], userId: bigint) { + private async lockWallet(tx: TxClient, userId: bigint) { const wallets = await tx.$queryRaw>` SELECT id, available_balance, frozen_balance, version FROM wallets WHERE user_id = ${userId} FOR UPDATE `; @@ -163,24 +166,25 @@ export class WalletService { payout: Decimal, betId: string, result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE', + tx?: TxClient, ) { - const txTypeMap: Record = { - WIN: 'BET_SETTLE_WIN', - LOSE: 'BET_SETTLE_LOSE', - PUSH: 'BET_SETTLE_PUSH', - VOID: 'BET_VOID_REFUND', - HALF_WIN: 'BET_SETTLE_WIN', - HALF_LOSE: 'BET_SETTLE_LOSE', - }; + const run = async (client: TxClient) => { + const txTypeMap: Record = { + WIN: 'BET_SETTLE_WIN', + LOSE: 'BET_SETTLE_LOSE', + PUSH: 'BET_SETTLE_PUSH', + VOID: 'BET_VOID_REFUND', + HALF_WIN: 'BET_SETTLE_WIN', + HALF_LOSE: 'BET_SETTLE_LOSE', + }; - return this.prisma.$transaction(async (tx) => { - const w = await this.lockWallet(tx, userId); + const w = await this.lockWallet(client, userId); const avail = new Decimal(w.available_balance); const frozen = new Decimal(w.frozen_balance); const frozenAfter = frozen.sub(stake); const balanceAfter = avail.add(payout); - await tx.wallet.update({ + await client.wallet.update({ where: { id: w.id }, data: { availableBalance: balanceAfter, @@ -189,7 +193,7 @@ export class WalletService { }, }); - await tx.walletTransaction.create({ + await client.walletTransaction.create({ data: { transactionId: generateTransactionId(), userId, @@ -204,7 +208,55 @@ export class WalletService { referenceId: betId, }, }); - }); + }; + + if (tx) return run(tx); + return this.prisma.$transaction(run); + } + + /** 重结算差额调整:delta = 新派彩 - 原派彩,允许扣回导致负余额 */ + async applyResettleDelta( + userId: bigint, + delta: Decimal, + betNo: string, + tx?: TxClient, + ) { + if (delta.eq(0)) return; + + const run = async (client: TxClient) => { + const w = await this.lockWallet(client, userId); + const avail = new Decimal(w.available_balance); + const balanceAfter = avail.add(delta); + const txType = delta.gt(0) ? 'BET_SETTLE_WIN' : 'RESETTLE_REVERSE'; + + await client.wallet.update({ + where: { id: w.id }, + data: { + availableBalance: balanceAfter, + version: { increment: 1 }, + }, + }); + + await client.walletTransaction.create({ + data: { + transactionId: generateTransactionId(), + userId, + walletId: w.id, + transactionType: txType, + amount: delta, + balanceBefore: avail, + balanceAfter, + frozenBefore: w.frozen_balance, + frozenAfter: w.frozen_balance, + referenceType: 'BET', + referenceId: betNo, + remark: 'Resettlement adjustment', + }, + }); + }; + + if (tx) return run(tx); + return this.prisma.$transaction(run); } async getTransactions(userId: bigint, page = 1, pageSize = 20) { diff --git a/apps/api/src/domains/operations/cashback/cashback-rate.resolver.spec.ts b/apps/api/src/domains/operations/cashback/cashback-rate.resolver.spec.ts new file mode 100644 index 0000000..a8514e4 --- /dev/null +++ b/apps/api/src/domains/operations/cashback/cashback-rate.resolver.spec.ts @@ -0,0 +1,72 @@ +import { resolveCashbackRateForBet } from './cashback-rate.resolver'; +import { Decimal } from '@prisma/client/runtime/library'; + +describe('resolveCashbackRateForBet', () => { + const userId = BigInt(100); + const agentId = BigInt(200); + + it('uses agent default when no rules', () => { + const rate = resolveCashbackRateForBet({ + userId, + agentId, + marketTypes: ['FT_1X2'], + agentDefaultRate: new Decimal('0.02'), + rules: [], + }); + expect(rate.toString()).toBe('0.02'); + }); + + it('prefers USER rule over agent default', () => { + const rate = resolveCashbackRateForBet({ + userId, + agentId, + marketTypes: ['FT_1X2'], + agentDefaultRate: new Decimal('0.01'), + rules: [ + { + targetType: 'USER', + targetId: userId, + rate: new Decimal('0.03'), + marketType: null, + }, + ], + }); + expect(rate.toString()).toBe('0.03'); + }); + + it('applies market-specific rule when market matches', () => { + const rate = resolveCashbackRateForBet({ + userId, + agentId, + marketTypes: ['FT_HANDICAP'], + agentDefaultRate: new Decimal('0.01'), + rules: [ + { + targetType: 'GLOBAL', + targetId: null, + rate: new Decimal('0.005'), + marketType: 'FT_HANDICAP', + }, + ], + }); + expect(rate.toString()).toBe('0.005'); + }); + + it('skips market-specific rule when market does not match', () => { + const rate = resolveCashbackRateForBet({ + userId, + agentId, + marketTypes: ['FT_1X2'], + agentDefaultRate: new Decimal('0.01'), + rules: [ + { + targetType: 'GLOBAL', + targetId: null, + rate: new Decimal('0.005'), + marketType: 'FT_HANDICAP', + }, + ], + }); + expect(rate.toString()).toBe('0.01'); + }); +}); diff --git a/apps/api/src/domains/operations/cashback/cashback-rate.resolver.ts b/apps/api/src/domains/operations/cashback/cashback-rate.resolver.ts new file mode 100644 index 0000000..8c146b2 --- /dev/null +++ b/apps/api/src/domains/operations/cashback/cashback-rate.resolver.ts @@ -0,0 +1,50 @@ +import { Decimal } from '@prisma/client/runtime/library'; + +export type CashbackRuleRow = { + targetType: string; + targetId: bigint | null; + rate: Decimal; + marketType: string | null; +}; + +const TARGET_PRIORITY: Record = { + USER: 3, + AGENT: 2, + GLOBAL: 1, +}; + +export function resolveCashbackRateForBet(params: { + userId: bigint; + agentId: bigint | null; + marketTypes: string[]; + agentDefaultRate: Decimal; + rules: CashbackRuleRow[]; +}): Decimal { + const { userId, agentId, marketTypes, agentDefaultRate, rules } = params; + let best: { priority: number; rate: Decimal } | null = null; + + for (const rule of rules) { + if (rule.marketType && !marketTypes.includes(rule.marketType)) continue; + + let priority = 0; + if (rule.targetType === 'USER' && rule.targetId?.toString() === userId.toString()) { + priority = TARGET_PRIORITY.USER; + } else if ( + rule.targetType === 'AGENT' && + agentId != null && + rule.targetId?.toString() === agentId.toString() + ) { + priority = TARGET_PRIORITY.AGENT; + } else if (rule.targetType === 'GLOBAL') { + priority = TARGET_PRIORITY.GLOBAL; + } else { + continue; + } + + if (!best || priority > best.priority) { + best = { priority, rate: rule.rate }; + } + } + + return best?.rate ?? agentDefaultRate; +} diff --git a/apps/api/src/domains/operations/cashback/cashback.service.ts b/apps/api/src/domains/operations/cashback/cashback.service.ts index b38c027..9a074d5 100644 --- a/apps/api/src/domains/operations/cashback/cashback.service.ts +++ b/apps/api/src/domains/operations/cashback/cashback.service.ts @@ -3,6 +3,10 @@ import { PrismaService } from '../../../shared/prisma/prisma.service'; import { WalletService } from '../../ledger/wallet.service'; import { Decimal } from '@prisma/client/runtime/library'; import { generateBatchNo } from '../../../shared/common/decorators'; +import { + resolveCashbackRateForBet, + type CashbackRuleRow, +} from './cashback-rate.resolver'; @Injectable() export class CashbackService { @@ -12,37 +16,98 @@ export class CashbackService { ) {} async previewBatch(periodStart: Date, periodEnd: Date) { - const settledBets = await this.prisma.bet.findMany({ - where: { - status: { in: ['WON', 'LOST', 'SETTLED'] }, - settledAt: { gte: periodStart, lte: periodEnd }, - }, - include: { user: { include: { agentProfile: true } } }, - }); + const [settledBets, rules, agentProfiles] = await Promise.all([ + this.prisma.bet.findMany({ + where: { + status: { in: ['WON', 'LOST'] }, + settledAt: { gte: periodStart, lte: periodEnd }, + }, + include: { + user: { select: { id: true, parentId: true } }, + selections: { select: { marketType: true } }, + }, + }), + this.prisma.cashbackRule.findMany({ where: { isActive: true } }), + this.prisma.agentProfile.findMany({ + select: { userId: true, cashbackRate: true }, + }), + ]); - const playerStakes = new Map(); - - for (const bet of settledBets) { - if (bet.status === 'PUSH' || bet.status === 'VOID') continue; - - const key = bet.userId.toString(); - const existing = playerStakes.get(key) ?? { - userId: bet.userId, - stake: new Decimal(0), - rate: new Decimal(0.01), - }; - existing.stake = existing.stake.add(bet.stake); - playerStakes.set(key, existing); - } - - const items = Array.from(playerStakes.values()).map((p) => ({ - userId: p.userId, - effectiveStake: p.stake, - rate: p.rate, - amount: p.stake.mul(p.rate), + const agentRateById = new Map( + agentProfiles.map((p) => [p.userId.toString(), new Decimal(p.cashbackRate)]), + ); + const ruleRows: CashbackRuleRow[] = rules.map((r) => ({ + targetType: r.targetType, + targetId: r.targetId, + rate: new Decimal(r.rate), + marketType: r.marketType, })); - const totalAmount = items.reduce((s, i) => s.add(i.amount), new Decimal(0)); + const playerAgg = new Map< + string, + { userId: bigint; stake: Decimal; amount: Decimal } + >(); + + for (const bet of settledBets) { + const agentId = bet.user.parentId; + const agentDefaultRate = agentId + ? agentRateById.get(agentId.toString()) ?? new Decimal(0) + : new Decimal(0); + const marketTypes = bet.selections.map((s) => s.marketType); + const rate = resolveCashbackRateForBet({ + userId: bet.userId, + agentId, + marketTypes, + agentDefaultRate, + rules: ruleRows, + }); + + if (rate.lte(0)) continue; + + const key = bet.userId.toString(); + const existing = playerAgg.get(key) ?? { + userId: bet.userId, + stake: new Decimal(0), + amount: new Decimal(0), + }; + existing.stake = existing.stake.add(bet.stake); + existing.amount = existing.amount.add(bet.stake.mul(rate)); + playerAgg.set(key, existing); + } + + const items = Array.from(playerAgg.values()) + .map((p) => ({ + userId: p.userId, + effectiveStake: p.stake, + 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 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])); + + const enrichedItems = items.map((item) => { + const user = userById.get(item.userId.toString()); + return { + ...item, + username: user?.username ?? '', + agentUsername: user?.parent?.username ?? null, + }; + }); + + const totalAmount = enrichedItems.reduce((s, i) => s.add(i.amount), new Decimal(0)); const batch = await this.prisma.cashbackBatch.create({ data: { @@ -55,7 +120,7 @@ export class CashbackService { }, }); - for (const item of items) { + for (const item of enrichedItems) { await this.prisma.cashbackItem.create({ data: { batchId: batch.id, @@ -67,7 +132,7 @@ export class CashbackService { }); } - return { batch, items, totalAmount }; + return { batch, items: enrichedItems, totalAmount }; } async confirmBatch(batchId: bigint, operatorId: bigint) { diff --git a/apps/api/src/domains/settlement/domain/settlement-calculator.spec.ts b/apps/api/src/domains/settlement/domain/settlement-calculator.spec.ts index 6a9fe41..4e5f125 100644 --- a/apps/api/src/domains/settlement/domain/settlement-calculator.spec.ts +++ b/apps/api/src/domains/settlement/domain/settlement-calculator.spec.ts @@ -110,9 +110,40 @@ describe('SettlementCalculator', () => { }); expect(r).toBe('HALF_LOSE'); }); + + it('0-0: home -0.5 loses', () => { + const s = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 }; + expect( + settleSelection({ + marketType: 'FT_HANDICAP', + selectionCode: 'HOME', + handicapLine: -0.5, + score: s, + }), + ).toBe('LOSE'); + }); }); describe('Over/Under', () => { + it('0-0: under 2.5 wins, over 2.5 loses', () => { + const s = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 }; + const under = settleSelection({ + marketType: 'FT_OVER_UNDER', + selectionCode: 'UNDER', + totalLine: 2.5, + score: s, + }); + const over = settleSelection({ + marketType: 'FT_OVER_UNDER', + selectionCode: 'OVER', + totalLine: 2.5, + score: s, + }); + expect(under).toBe('WIN'); + expect(over).toBe('LOSE'); + expect(calculatePayout(100, 1.95, under).toNumber()).toBe(195); + }); + it('S013: over 2.5 wins with 3 goals', () => { const s = { htHome: 1, htAway: 1, ftHome: 2, ftAway: 1 }; expect( @@ -177,6 +208,27 @@ describe('SettlementCalculator', () => { }); }); + describe('OUTRIGHT_WINNER', () => { + it('wins when selection matches winner team code', () => { + expect( + settleSelection({ + marketType: 'OUTRIGHT_WINNER', + selectionCode: 'BRA', + score: { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 }, + winnerTeamCode: 'BRA', + }), + ).toBe('WIN'); + expect( + settleSelection({ + marketType: 'OUTRIGHT_WINNER', + selectionCode: 'ARG', + score: { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 }, + winnerTeamCode: 'BRA', + }), + ).toBe('LOSE'); + }); + }); + describe('Quarter line detection', () => { it('detects quarter lines', () => { expect(isQuarterHandicapOrTotal(-0.25)).toBe(true); diff --git a/apps/api/src/domains/settlement/domain/settlement-calculator.ts b/apps/api/src/domains/settlement/domain/settlement-calculator.ts index e394bfc..4d62353 100644 --- a/apps/api/src/domains/settlement/domain/settlement-calculator.ts +++ b/apps/api/src/domains/settlement/domain/settlement-calculator.ts @@ -16,6 +16,8 @@ export interface SettlementInput { totalLine?: number | null; score: ScoreInput; templateScores?: string[]; + /** 冠军盘:获胜球队 code,如 FRA、BRA */ + winnerTeamCode?: string | null; } export function getShScore(score: ScoreInput): { home: number; away: number } { @@ -196,7 +198,8 @@ export function settleSelection(input: SettlementInput): SelectionResult { return settleCorrectScore(sh.home, sh.away, selectionCode, templates); } case 'OUTRIGHT_WINNER': - return selectionCode === `TEAM_${input.score.ftHome}` ? 'WIN' : 'LOSE'; + if (!input.winnerTeamCode) return 'LOSE'; + return selectionCode === input.winnerTeamCode ? 'WIN' : 'LOSE'; } return 'LOSE'; diff --git a/apps/api/src/domains/settlement/domain/settlement-helpers.spec.ts b/apps/api/src/domains/settlement/domain/settlement-helpers.spec.ts new file mode 100644 index 0000000..7e6bcb8 --- /dev/null +++ b/apps/api/src/domains/settlement/domain/settlement-helpers.spec.ts @@ -0,0 +1,14 @@ +import { resolveSelectionCode } from './settlement-helpers'; + +describe('resolveSelectionCode', () => { + it('prefers database selection code', () => { + expect(resolveSelectionCode('UNDER', '小 2.5')).toBe('UNDER'); + }); + + it('maps Chinese snapshot names when code is missing', () => { + expect(resolveSelectionCode(null, '主胜')).toBe('HOME'); + expect(resolveSelectionCode('', '小 2.5')).toBe('UNDER'); + expect(resolveSelectionCode(undefined, '大 2.5')).toBe('OVER'); + expect(resolveSelectionCode(null, '主 -0.5')).toBe('HOME'); + }); +}); diff --git a/apps/api/src/domains/settlement/domain/settlement-helpers.ts b/apps/api/src/domains/settlement/domain/settlement-helpers.ts new file mode 100644 index 0000000..90d516a --- /dev/null +++ b/apps/api/src/domains/settlement/domain/settlement-helpers.ts @@ -0,0 +1,37 @@ +import { + FT_CORRECT_SCORE_TEMPLATE, + HT_CORRECT_SCORE_TEMPLATE, +} from './settlement-calculator'; + +const SNAPSHOT_1X2: Record = { + 主胜: 'HOME', + 客胜: 'AWAY', + 和局: 'DRAW', + 平局: 'DRAW', +}; + +/** 盘口 code 缺失时,从下单快照名推断标准 selectionCode */ +export function resolveSelectionCode( + selectionCode: string | null | undefined, + nameSnapshot: string, +): string { + if (selectionCode?.trim()) return selectionCode.trim(); + + const name = nameSnapshot.trim(); + if (SNAPSHOT_1X2[name]) return SNAPSHOT_1X2[name]; + if (name.startsWith('大')) return 'OVER'; + if (name.startsWith('小')) return 'UNDER'; + if (name.startsWith('主')) return 'HOME'; + if (name.startsWith('客')) return 'AWAY'; + + if (name.includes('-')) { + return `SCORE_${name.replace('-', '_')}`; + } + return name; +} + +export function templateScoresForMarket(marketType: string): string[] { + if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_TEMPLATE; + if (marketType.includes('CORRECT_SCORE')) return HT_CORRECT_SCORE_TEMPLATE; + return []; +} diff --git a/apps/api/src/domains/settlement/settlement.service.ts b/apps/api/src/domains/settlement/settlement.service.ts index 08cbdce..82a13d2 100644 --- a/apps/api/src/domains/settlement/settlement.service.ts +++ b/apps/api/src/domains/settlement/settlement.service.ts @@ -9,22 +9,25 @@ import { calculatePayout, calculateParlayPayout, ScoreInput, - FT_CORRECT_SCORE_TEMPLATE, - HT_CORRECT_SCORE_TEMPLATE, + SelectionResult, } from './domain/settlement-calculator'; -// 智能比分推荐已关闭 -// import { suggestScoresForBets, type SmartScoreSimBet, type SmartScoreStrategy } from './smart-score.solver'; +import { + resolveSelectionCode, + templateScoresForMarket, +} from './domain/settlement-helpers'; -function resolveSelectionCodeFromLeg( - selectionCode: string | null | undefined, - nameSnapshot: string, -): string { - if (selectionCode?.trim()) return selectionCode.trim(); - if (nameSnapshot.includes('-')) { - return `SCORE_${nameSnapshot.replace('-', '_')}`; - } - return nameSnapshot; -} +type BetSelectionLeg = { + id: bigint; + matchId: bigint | null; + marketType: string; + selectionId: bigint; + selectionNameSnapshot: string; + handicapLine: Decimal | null; + totalLine: Decimal | null; + odds: Decimal; + resultStatus: string | null; + sortOrder: number; +}; @Injectable() export class SettlementService { @@ -34,6 +37,52 @@ export class SettlementService { private agents: AgentsService, ) {} + private async resolveWinnerTeamCode(winnerTeamId: bigint | null | undefined): Promise { + if (!winnerTeamId) return null; + const team = await this.prisma.team.findUnique({ where: { id: winnerTeamId } }); + return team?.code ?? null; + } + + private buildSettleInput( + sel: BetSelectionLeg, + selectionCode: string, + scoreInput: ScoreInput, + winnerTeamCode: string | null, + ) { + return { + marketType: sel.marketType, + selectionCode, + handicapLine: sel.handicapLine != null ? Number(sel.handicapLine) : null, + totalLine: sel.totalLine != null ? Number(sel.totalLine) : null, + score: scoreInput, + templateScores: templateScoresForMarket(sel.marketType), + winnerTeamCode, + }; + } + + private settleLegResult( + sel: BetSelectionLeg, + selectionCode: string, + scoreInput: ScoreInput, + winnerTeamCode: string | null, + ): SelectionResult { + return settleSelection(this.buildSettleInput(sel, selectionCode, scoreInput, winnerTeamCode)); + } + + private walletResultFromSelection( + result: SelectionResult, + ): 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE' { + if (result === 'HALF_WIN') return 'HALF_WIN'; + if (result === 'HALF_LOSE') return 'HALF_LOSE'; + return result as 'WIN' | 'LOSE' | 'PUSH' | 'VOID'; + } + + private betStatusFromSelection(result: SelectionResult): 'LOST' | 'PUSH' | 'WON' { + if (result === 'LOSE') return 'LOST'; + if (result === 'PUSH' || result === 'VOID') return 'PUSH'; + return 'WON'; + } + async recordScore( matchId: bigint, htHome: number, @@ -41,11 +90,49 @@ export class SettlementService { ftHome: number, ftAway: number, operatorId: bigint, + winnerTeamId?: bigint, ) { + const match = await this.prisma.match.findFirst({ + where: { id: matchId, deletedAt: null }, + }); + if (!match) throw new NotFoundException('Match not found'); + + if (match.isOutright) { + if (!winnerTeamId) { + throw new BadRequestException('冠军盘结算需指定获胜球队 winnerTeamId'); + } + const team = await this.prisma.team.findUnique({ where: { id: winnerTeamId } }); + if (!team) throw new BadRequestException('获胜球队不存在'); + const outrightSel = await this.prisma.marketSelection.findFirst({ + where: { + market: { matchId, marketType: 'OUTRIGHT_WINNER' }, + selectionCode: team.code, + }, + }); + if (!outrightSel) { + throw new BadRequestException('该球队不在本冠军盘选项中'); + } + } + await this.prisma.matchScore.upsert({ where: { matchId }, - create: { matchId, htHomeScore: htHome, htAwayScore: htAway, ftHomeScore: ftHome, ftAwayScore: ftAway, recordedBy: operatorId }, - update: { htHomeScore: htHome, htAwayScore: htAway, ftHomeScore: ftHome, ftAwayScore: ftAway, recordedBy: operatorId }, + create: { + matchId, + htHomeScore: match.isOutright ? 0 : htHome, + htAwayScore: match.isOutright ? 0 : htAway, + ftHomeScore: match.isOutright ? 0 : ftHome, + ftAwayScore: match.isOutright ? 0 : ftAway, + winnerTeamId: match.isOutright ? winnerTeamId : null, + recordedBy: operatorId, + }, + update: { + htHomeScore: match.isOutright ? 0 : htHome, + htAwayScore: match.isOutright ? 0 : htAway, + ftHomeScore: match.isOutright ? 0 : ftHome, + ftAwayScore: match.isOutright ? 0 : ftAway, + winnerTeamId: match.isOutright ? winnerTeamId : null, + recordedBy: operatorId, + }, }); await this.prisma.match.update({ @@ -53,75 +140,18 @@ export class SettlementService { data: { status: 'PENDING_SETTLEMENT' }, }); - return { matchId, htHome, htAway, ftHome, ftAway }; + return { matchId, htHome, htAway, ftHome, ftAway, winnerTeamId: winnerTeamId?.toString() ?? null }; } - async previewSettlement(matchId: bigint, operatorId: bigint) { + async previewSettlement( + matchId: bigint, + operatorId: bigint, + opts?: { page?: number; pageSize?: number }, + ) { const score = await this.prisma.matchScore.findUnique({ where: { matchId } }); if (!score) throw new BadRequestException('Score not recorded'); - const scoreInput: ScoreInput = { - htHome: score.htHomeScore ?? 0, - htAway: score.htAwayScore ?? 0, - ftHome: score.ftHomeScore ?? 0, - ftAway: score.ftAwayScore ?? 0, - }; - - const pendingBets = await this.prisma.bet.findMany({ - where: { - status: 'PENDING', - selections: { some: { matchId } }, - }, - include: { selections: true }, - }); - - const parlayBets = await this.prisma.bet.findMany({ - where: { - status: 'PENDING', - betType: 'PARLAY', - selections: { some: { matchId } }, - }, - include: { selections: true }, - }); - - let totalPayout = new Decimal(0); - let totalRefund = new Decimal(0); - const items: Array<{ betId: bigint; betNo: string; result: string; payout: Decimal }> = []; - - for (const bet of pendingBets) { - if (bet.betType === 'SINGLE') { - const sel = bet.selections[0]; - const template = - sel.marketType === 'FT_CORRECT_SCORE' - ? FT_CORRECT_SCORE_TEMPLATE - : sel.marketType.includes('CORRECT_SCORE') - ? HT_CORRECT_SCORE_TEMPLATE - : []; - - const result = settleSelection({ - marketType: sel.marketType, - selectionCode: sel.selectionNameSnapshot.includes('-') - ? `SCORE_${sel.selectionNameSnapshot.replace('-', '_')}` - : sel.selectionNameSnapshot, - handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null, - totalLine: sel.totalLine ? Number(sel.totalLine) : null, - score: scoreInput, - templateScores: template, - }); - - const payout = calculatePayout(bet.stake, sel.odds, result); - items.push({ betId: bet.id, betNo: bet.betNo, result, payout }); - - if (result === 'LOSE') { - // no payout - } else if (result === 'PUSH' || result === 'VOID') { - totalRefund = totalRefund.add(bet.stake); - } else { - totalPayout = totalPayout.add(payout); - } - } - } - + const computation = await this.computePreviewComputation(matchId); const batch = await this.prisma.settlementBatch.create({ data: { matchId, @@ -131,24 +161,324 @@ export class SettlementService { ftHomeScore: score.ftHomeScore, ftAwayScore: score.ftAwayScore, status: 'PREVIEW', - totalBets: pendingBets.length, - totalPayout, - totalRefund, + totalBets: computation.pendingBets.length, + totalPayout: computation.totalPayout, + totalRefund: computation.totalRefund, operatorId, }, }); + return this.buildPreviewResponse(computation, batch, opts); + } + + async getPreviewSettlementItems( + batchId: bigint, + opts?: { page?: number; pageSize?: number }, + ) { + const batch = await this.prisma.settlementBatch.findUnique({ where: { id: batchId } }); + if (!batch) throw new NotFoundException('Batch not found'); + if (batch.status !== 'PREVIEW') { + throw new BadRequestException('Batch is not in preview'); + } + + const computation = await this.computePreviewComputation(batch.matchId); + const itemsPage = this.paginatePreviewItems(computation.items, opts); + return { + items: itemsPage.items.map((item) => this.serializePreviewItem(item)), + total: itemsPage.total, + page: itemsPage.page, + pageSize: itemsPage.pageSize, + }; + } + + private buildPreviewResponse( + computation: { + scoreInput: ScoreInput; + winnerTeamCode: string | null; + pendingBets: Array<{ betType: string }>; + items: Array<{ + betId: bigint; + betNo: string; + betType: string; + result: string; + payout: Decimal; + note?: string; + }>; + totalPayout: Decimal; + totalRefund: Decimal; + lostOnThisMatch: number; + pendingOtherMatches: number; + wonLegsOnMatch: number; + }, + batch: { id: bigint }, + opts?: { page?: number; pageSize?: number }, + ) { + const { pendingBets } = computation; + const itemsPage = this.paginatePreviewItems(computation.items, opts); + return { batch, - score: scoreInput, + score: computation.scoreInput, + winnerTeamCode: computation.winnerTeamCode, + pendingBetCount: pendingBets.length, singleBetCount: pendingBets.filter((b) => b.betType === 'SINGLE').length, - parlayBetCount: parlayBets.length, + parlayBetCount: pendingBets.filter((b) => b.betType === 'PARLAY').length, + lostOnThisMatch: computation.lostOnThisMatch, + pendingOtherMatches: computation.pendingOtherMatches, + wonLegsOnMatch: computation.wonLegsOnMatch, + items: { + items: itemsPage.items.map((item) => this.serializePreviewItem(item)), + total: itemsPage.total, + page: itemsPage.page, + pageSize: itemsPage.pageSize, + }, + totalPayout: computation.totalPayout.toString(), + totalRefund: computation.totalRefund.toString(), + }; + } + + private serializePreviewItem(item: { + betId: bigint; + betNo: string; + betType: string; + result: string; + payout: Decimal; + note?: string; + }) { + return { + betId: item.betId.toString(), + betNo: item.betNo, + betType: item.betType, + result: item.result, + payout: item.payout.toString(), + note: item.note, + }; + } + + private paginatePreviewItems(all: T[], opts?: { page?: number; pageSize?: number }) { + const page = Math.max(1, opts?.page ?? 1); + const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 10)); + const total = all.length; + const start = (page - 1) * pageSize; + return { + items: all.slice(start, start + pageSize), + total, + page, + pageSize, + }; + } + + private async computePreviewComputation(matchId: bigint) { + const score = await this.prisma.matchScore.findUnique({ where: { matchId } }); + if (!score) throw new BadRequestException('Score not recorded'); + + const scoreInput: ScoreInput = { + htHome: score.htHomeScore ?? 0, + htAway: score.htAwayScore ?? 0, + ftHome: score.ftHomeScore ?? 0, + ftAway: score.ftAwayScore ?? 0, + }; + const winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId); + + const pendingBets = await this.prisma.bet.findMany({ + where: { + status: 'PENDING', + selections: { some: { matchId } }, + }, + include: { selections: { orderBy: { sortOrder: 'asc' } } }, + }); + + const selectionCodes = await this.loadSelectionCodes(pendingBets); + + let totalPayout = new Decimal(0); + let totalRefund = new Decimal(0); + let lostOnThisMatch = 0; + let pendingOtherMatches = 0; + let wonLegsOnMatch = 0; + const items: Array<{ + betId: bigint; + betNo: string; + betType: string; + result: string; + payout: Decimal; + note?: string; + }> = []; + + for (const bet of pendingBets) { + if (bet.betType === 'SINGLE' && bet.selections.length === 1) { + const sel = bet.selections[0]; + const code = resolveSelectionCode( + selectionCodes.get(sel.selectionId.toString()), + sel.selectionNameSnapshot, + ); + const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode); + const payout = calculatePayout(bet.stake, sel.odds, result); + if (result === 'WIN' || result === 'HALF_WIN') wonLegsOnMatch += 1; + items.push({ betId: bet.id, betNo: bet.betNo, betType: 'SINGLE', result, payout }); + if (result === 'PUSH' || result === 'VOID') { + totalRefund = totalRefund.add(bet.stake); + } else if (result !== 'LOSE') { + totalPayout = totalPayout.add(payout); + } + } else if (bet.betType === 'SINGLE' && bet.selections.length > 1) { + const legResults = bet.selections.map((sel) => { + const code = resolveSelectionCode( + selectionCodes.get(sel.selectionId.toString()), + sel.selectionNameSnapshot, + ); + return { + odds: sel.odds, + result: this.settleLegResult(sel, code, scoreInput, winnerTeamCode), + }; + }); + const parlay = calculateParlayPayout(bet.stake, legResults); + items.push({ + betId: bet.id, + betNo: bet.betNo, + betType: 'SINGLE', + result: parlay.betResult === 'LOST' ? 'LOSE' : parlay.betResult, + payout: parlay.payout, + note: '单关含多腿,按串关规则合并结算', + }); + if (parlay.betResult === 'PUSH') { + totalRefund = totalRefund.add(bet.stake); + } else if (parlay.betResult === 'WON') { + totalPayout = totalPayout.add(parlay.payout); + } + } else { + for (const sel of bet.selections) { + if (sel.matchId?.toString() !== matchId.toString()) continue; + const code = resolveSelectionCode( + selectionCodes.get(sel.selectionId.toString()), + sel.selectionNameSnapshot, + ); + const legResult = this.settleLegResult(sel, code, scoreInput, winnerTeamCode); + if (legResult === 'WIN' || legResult === 'HALF_WIN') wonLegsOnMatch += 1; + } + + const preview = this.previewParlayForMatch( + bet, + matchId, + scoreInput, + winnerTeamCode, + selectionCodes, + ); + if (preview.kind === 'SETTLED') { + items.push({ + betId: bet.id, + betNo: bet.betNo, + betType: 'PARLAY', + result: preview.betResult, + payout: preview.payout, + }); + if (preview.betResult === 'PUSH') { + totalRefund = totalRefund.add(bet.stake); + } else if (preview.betResult === 'WON') { + totalPayout = totalPayout.add(preview.payout); + } + } else if (preview.kind === 'LOST_ON_THIS_MATCH') { + lostOnThisMatch += 1; + items.push({ + betId: bet.id, + betNo: bet.betNo, + betType: 'PARLAY', + result: 'LOST', + payout: preview.payout, + note: '本场已有输腿,串关整单作废', + }); + } else { + pendingOtherMatches += 1; + items.push({ + betId: bet.id, + betNo: bet.betNo, + betType: 'PARLAY', + result: 'PENDING_OTHER_MATCHES', + payout: new Decimal(0), + note: '本场腿已出结果,待其他场次结算后派彩', + }); + } + } + } + + return { + scoreInput, + winnerTeamCode, + pendingBets, items, totalPayout, totalRefund, + lostOnThisMatch, + pendingOtherMatches, + wonLegsOnMatch, }; } + private previewParlayForMatch( + bet: { stake: Decimal; selections: BetSelectionLeg[] }, + matchId: bigint, + scoreInput: ScoreInput, + winnerTeamCode: string | null, + selectionCodes: Map, + ): + | { kind: 'SETTLED'; betResult: 'WON' | 'LOST' | 'PUSH'; payout: Decimal } + | { kind: 'LOST_ON_THIS_MATCH'; payout: Decimal } + | { kind: 'PENDING_OTHER_MATCHES' } { + for (const sel of bet.selections) { + if (sel.matchId?.toString() !== matchId.toString()) continue; + const code = resolveSelectionCode( + selectionCodes.get(sel.selectionId.toString()), + sel.selectionNameSnapshot, + ); + const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode); + if (result === 'LOSE' || result === 'HALF_LOSE') { + return { kind: 'LOST_ON_THIS_MATCH', payout: new Decimal(0) }; + } + } + + const legResults: Array<{ odds: Decimal; result: SelectionResult }> = []; + for (const sel of bet.selections) { + if (sel.matchId?.toString() === matchId.toString()) { + const code = resolveSelectionCode( + selectionCodes.get(sel.selectionId.toString()), + sel.selectionNameSnapshot, + ); + legResults.push({ + odds: sel.odds, + result: this.settleLegResult(sel, code, scoreInput, winnerTeamCode), + }); + } else if (sel.resultStatus) { + legResults.push({ + odds: sel.odds, + result: sel.resultStatus as SelectionResult, + }); + } else { + return { kind: 'PENDING_OTHER_MATCHES' }; + } + } + + if (legResults.length !== bet.selections.length) { + return { kind: 'PENDING_OTHER_MATCHES' }; + } + const settled = calculateParlayPayout(bet.stake, legResults); + return { kind: 'SETTLED', betResult: settled.betResult, payout: settled.payout }; + } + + private async loadSelectionCodes( + bets: Array<{ selections: Array<{ selectionId: bigint }> }>, + ): Promise> { + const ids = new Set(); + for (const bet of bets) { + for (const sel of bet.selections) ids.add(sel.selectionId); + } + if (!ids.size) return new Map(); + + const rows = await this.prisma.marketSelection.findMany({ + where: { id: { in: [...ids] } }, + select: { id: true, selectionCode: true }, + }); + return new Map(rows.map((r) => [r.id.toString(), r.selectionCode])); + } + async confirmSettlement(batchId: bigint, operatorId: bigint) { const batch = await this.prisma.settlementBatch.findUnique({ where: { id: batchId }, @@ -168,40 +498,30 @@ export class SettlementService { ftHome: score.ftHomeScore ?? 0, ftAway: score.ftAwayScore ?? 0, }; + const winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId); const pendingBets = await this.prisma.bet.findMany({ where: { status: 'PENDING', selections: { some: { matchId: batch.matchId } }, }, - include: { selections: true, user: true }, + include: { selections: { orderBy: { sortOrder: 'asc' } }, user: true }, }); + const selectionCodes = await this.loadSelectionCodes(pendingBets); const agentIds = new Set(); await this.prisma.$transaction(async (tx) => { for (const bet of pendingBets) { - if (bet.betType === 'SINGLE') { + if (bet.betType === 'SINGLE' && bet.selections.length === 1) { const sel = bet.selections[0]; - const selection = await tx.marketSelection.findUnique({ - where: { id: sel.selectionId }, - }); - - const result = settleSelection({ - marketType: sel.marketType, - selectionCode: selection?.selectionCode ?? sel.selectionNameSnapshot, - handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null, - totalLine: sel.totalLine ? Number(sel.totalLine) : null, - score: scoreInput, - templateScores: - sel.marketType === 'FT_CORRECT_SCORE' - ? FT_CORRECT_SCORE_TEMPLATE - : HT_CORRECT_SCORE_TEMPLATE, - }); - + const code = resolveSelectionCode( + selectionCodes.get(sel.selectionId.toString()), + sel.selectionNameSnapshot, + ); + const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode); const payout = calculatePayout(bet.stake, sel.odds, result); - const betStatus = - result === 'LOSE' ? 'LOST' : result === 'PUSH' || result === 'VOID' ? 'PUSH' : 'WON'; + const betStatus = this.betStatusFromSelection(result); await tx.bet.update({ where: { id: bet.id }, @@ -222,7 +542,8 @@ export class SettlementService { bet.stake, payout, bet.betNo, - result === 'HALF_WIN' ? 'HALF_WIN' : result === 'HALF_LOSE' ? 'HALF_LOSE' : result as 'WIN' | 'LOSE' | 'PUSH' | 'VOID', + this.walletResultFromSelection(result), + tx, ); if (bet.agentId) agentIds.add(bet.agentId); @@ -236,20 +557,69 @@ export class SettlementService { payout, }, }); + } else if (bet.betType === 'SINGLE' && bet.selections.length > 1) { + const legResults: Array<{ odds: Decimal; result: SelectionResult }> = []; + for (const sel of bet.selections) { + const code = resolveSelectionCode( + selectionCodes.get(sel.selectionId.toString()), + sel.selectionNameSnapshot, + ); + const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode); + legResults.push({ odds: sel.odds, result }); + await tx.betSelection.update({ + where: { id: sel.id }, + data: { resultStatus: result, effectiveOdds: sel.odds }, + }); + } + const parlayResult = calculateParlayPayout(bet.stake, legResults); + const betStatus = + parlayResult.betResult === 'LOST' + ? 'LOST' + : parlayResult.betResult === 'PUSH' + ? 'PUSH' + : 'WON'; + + await tx.bet.update({ + where: { id: bet.id }, + data: { + status: betStatus, + actualReturn: parlayResult.payout, + settledAt: new Date(), + }, + }); + + await this.wallet.settleBet( + bet.userId, + bet.stake, + parlayResult.payout, + bet.betNo, + parlayResult.betResult === 'LOST' + ? 'LOSE' + : parlayResult.betResult === 'PUSH' + ? 'PUSH' + : 'WIN', + tx, + ); + + if (bet.agentId) agentIds.add(bet.agentId); + + await tx.settlementItem.create({ + data: { + batchId, + betId: bet.id, + userId: bet.userId, + result: betStatus, + payout: parlayResult.payout, + }, + }); } else { - // Parlay: update this leg's result, check if all legs settled for (const sel of bet.selections) { if (sel.matchId?.toString() === batch.matchId.toString()) { - const selection = await tx.marketSelection.findUnique({ - where: { id: sel.selectionId }, - }); - const result = settleSelection({ - marketType: sel.marketType, - selectionCode: selection?.selectionCode ?? '', - handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null, - totalLine: sel.totalLine ? Number(sel.totalLine) : null, - score: scoreInput, - }); + const code = resolveSelectionCode( + selectionCodes.get(sel.selectionId.toString()), + sel.selectionNameSnapshot, + ); + const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode); await tx.betSelection.update({ where: { id: sel.id }, data: { resultStatus: result }, @@ -257,20 +627,29 @@ export class SettlementService { } } - const updated = await tx.betSelection.findMany({ where: { betId: bet.id } }); + const updated = await tx.betSelection.findMany({ + where: { betId: bet.id }, + orderBy: { sortOrder: 'asc' }, + }); const allHaveResult = updated.every((s) => s.resultStatus != null); if (allHaveResult) { const legResults = updated.map((s) => ({ odds: s.odds, - result: s.resultStatus as 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE', + result: s.resultStatus as SelectionResult, })); const parlayResult = calculateParlayPayout(bet.stake, legResults); + const betStatus = + parlayResult.betResult === 'LOST' + ? 'LOST' + : parlayResult.betResult === 'PUSH' + ? 'PUSH' + : 'WON'; await tx.bet.update({ where: { id: bet.id }, data: { - status: parlayResult.betResult === 'LOST' ? 'LOST' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WON', + status: betStatus, actualReturn: parlayResult.payout, settledAt: new Date(), }, @@ -281,10 +660,25 @@ export class SettlementService { bet.stake, parlayResult.payout, bet.betNo, - parlayResult.betResult === 'LOST' ? 'LOSE' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WIN', + parlayResult.betResult === 'LOST' + ? 'LOSE' + : parlayResult.betResult === 'PUSH' + ? 'PUSH' + : 'WIN', + tx, ); if (bet.agentId) agentIds.add(bet.agentId); + + await tx.settlementItem.create({ + data: { + batchId, + betId: bet.id, + userId: bet.userId, + result: betStatus, + payout: parlayResult.payout, + }, + }); } } } @@ -307,7 +701,10 @@ export class SettlementService { return { success: true, batchId: batchId.toString() }; } - async getMatchBetStats(matchId: bigint) { + async getMatchBetStats( + matchId: bigint, + opts?: { page?: number; pageSize?: number }, + ) { const match = await this.prisma.match.findFirst({ where: { id: matchId, deletedAt: null }, }); @@ -405,28 +802,50 @@ export class SettlementService { return a.selectionName.localeCompare(b.selectionName); }); - const bets = Array.from(legs) - .map((leg) => ({ - id: leg.bet.id.toString(), - betNo: leg.bet.betNo, - username: leg.bet.user.username, - betType: leg.bet.betType, - status: leg.bet.status, - settlementStatus: leg.bet.settlementStatus, - stake: leg.bet.stake.toString(), - potentialReturn: leg.bet.potentialReturn?.toString() ?? null, - actualReturn: leg.bet.actualReturn.toString(), - placedAt: leg.bet.placedAt.toISOString(), - marketType: leg.marketType, - period: leg.period, - selectionName: leg.selectionNameSnapshot, - odds: leg.odds.toString(), + const betsById = new Map< + string, + { + bet: (typeof legs)[0]['bet']; + matchLegs: (typeof legs); + } + >(); + for (const leg of legs) { + const key = leg.betId.toString(); + const row = betsById.get(key) ?? { bet: leg.bet, matchLegs: [] }; + row.matchLegs.push(leg); + betsById.set(key, row); + } + + const allBets = Array.from(betsById.values()) + .map(({ bet, matchLegs }) => ({ + id: bet.id.toString(), + betNo: bet.betNo, + username: matchLegs[0].bet.user.username, + betType: bet.betType, + status: bet.status, + settlementStatus: bet.settlementStatus, + stake: bet.stake.toString(), + potentialReturn: bet.potentialReturn?.toString() ?? null, + actualReturn: bet.actualReturn.toString(), + placedAt: bet.placedAt.toISOString(), + legCountOnMatch: matchLegs.length, + selections: matchLegs.map((leg) => ({ + marketType: leg.marketType, + period: leg.period, + selectionName: leg.selectionNameSnapshot, + odds: leg.odds.toString(), + })), })) .sort( (a, b) => new Date(b.placedAt).getTime() - new Date(a.placedAt).getTime(), ); + const page = Math.max(1, opts?.page ?? 1); + const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 10)); + const total = allBets.length; + const start = (page - 1) * pageSize; + return { summary: { totalBets: betById.size, @@ -438,13 +857,283 @@ export class SettlementService { legCount: legs.length, }, bySelection, - bets, + bets: { + items: allBets.slice(start, start + pageSize), + total, + page, + pageSize, + }, }; } - /* 智能比分推荐已关闭 — 恢复时取消注释并恢复 smart-score.solver import - async suggestSmartScores(...) { ... } - */ + private async computeBetOutcome( + bet: { + id: bigint; + betType: string; + stake: Decimal; + actualReturn: Decimal; + selections: BetSelectionLeg[]; + }, + matchId: bigint, + scoreInput: ScoreInput, + winnerTeamCode: string | null, + selectionCodes: Map, + ) { + if (bet.betType === 'SINGLE') { + const sel = bet.selections[0]; + const code = resolveSelectionCode( + selectionCodes.get(sel.selectionId.toString()), + sel.selectionNameSnapshot, + ); + const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode); + const payout = calculatePayout(bet.stake, sel.odds, result); + return { + payout, + betStatus: this.betStatusFromSelection(result), + legUpdates: new Map([[sel.id.toString(), result]]), + }; + } + + const legResults: Array<{ odds: Decimal; result: SelectionResult }> = []; + const legUpdates = new Map(); + + for (const sel of bet.selections) { + let result: SelectionResult; + if (sel.matchId?.toString() === matchId.toString()) { + const code = resolveSelectionCode( + selectionCodes.get(sel.selectionId.toString()), + sel.selectionNameSnapshot, + ); + result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode); + legUpdates.set(sel.id.toString(), result); + } else { + if (!sel.resultStatus) { + throw new BadRequestException(`Parlay bet ${bet.id} has unsettled legs`); + } + result = sel.resultStatus as SelectionResult; + } + legResults.push({ odds: sel.odds, result }); + } + + const parlayResult = calculateParlayPayout(bet.stake, legResults); + const betStatus = + parlayResult.betResult === 'LOST' + ? 'LOST' + : parlayResult.betResult === 'PUSH' + ? 'PUSH' + : 'WON'; + + return { payout: parlayResult.payout, betStatus, legUpdates }; + } + + async previewResettlement( + matchId: bigint, + scoreInput: ScoreInput, + operatorId: bigint, + reason?: string, + winnerTeamId?: bigint, + ) { + const match = await this.prisma.match.findFirst({ + where: { id: matchId, deletedAt: null }, + }); + if (!match) throw new NotFoundException('Match not found'); + if (match.status !== 'SETTLED') { + throw new BadRequestException('Only settled matches can be resettled'); + } + + const winnerTeamCode = winnerTeamId + ? await this.resolveWinnerTeamCode(winnerTeamId) + : null; + + const settledBets = await this.prisma.bet.findMany({ + where: { + status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] }, + selections: { some: { matchId } }, + }, + include: { selections: { orderBy: { sortOrder: 'asc' } } }, + }); + + const selectionCodes = await this.loadSelectionCodes(settledBets); + const items: Array<{ + betId: bigint; + betNo: string; + oldPayout: Decimal; + newPayout: Decimal; + delta: Decimal; + oldStatus: string; + newStatus: string; + }> = []; + + let totalClawback = new Decimal(0); + let totalTopup = new Decimal(0); + + for (const bet of settledBets) { + const oldPayout = new Decimal(bet.actualReturn); + const outcome = await this.computeBetOutcome( + bet, + matchId, + scoreInput, + winnerTeamCode, + selectionCodes, + ); + const delta = outcome.payout.sub(oldPayout); + if (delta.eq(0) && outcome.betStatus === bet.status) continue; + + items.push({ + betId: bet.id, + betNo: bet.betNo, + oldPayout, + newPayout: outcome.payout, + delta, + oldStatus: bet.status, + newStatus: outcome.betStatus, + }); + + if (delta.gt(0)) totalTopup = totalTopup.add(delta); + else if (delta.lt(0)) totalClawback = totalClawback.add(delta.abs()); + } + + const batch = await this.prisma.settlementBatch.create({ + data: { + matchId, + batchNo: generateBatchNo('RST'), + htHomeScore: scoreInput.htHome, + htAwayScore: scoreInput.htAway, + ftHomeScore: scoreInput.ftHome, + ftAwayScore: scoreInput.ftAway, + status: 'PREVIEW', + totalBets: items.length, + totalPayout: totalTopup, + totalRefund: totalClawback, + operatorId, + isResettle: true, + reason: reason?.trim() || null, + }, + }); + + return { + batch, + score: scoreInput, + winnerTeamCode, + items, + totalClawback, + totalTopup, + affectedCount: items.length, + }; + } + + async confirmResettlement(batchId: bigint, operatorId: bigint) { + const batch = await this.prisma.settlementBatch.findUnique({ + where: { id: batchId }, + include: { match: true }, + }); + if (!batch) throw new NotFoundException('Batch not found'); + if (!batch.isResettle) throw new BadRequestException('Not a resettle batch'); + if (batch.status !== 'PREVIEW') throw new BadRequestException('Batch already confirmed'); + + const scoreInput: ScoreInput = { + htHome: batch.htHomeScore ?? 0, + htAway: batch.htAwayScore ?? 0, + ftHome: batch.ftHomeScore ?? 0, + ftAway: batch.ftAwayScore ?? 0, + }; + const winnerTeamCode = await this.resolveWinnerTeamCode( + ( + await this.prisma.matchScore.findUnique({ where: { matchId: batch.matchId } }) + )?.winnerTeamId, + ); + + const settledBets = await this.prisma.bet.findMany({ + where: { + status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] }, + selections: { some: { matchId: batch.matchId } }, + }, + include: { selections: { orderBy: { sortOrder: 'asc' } } }, + }); + + const selectionCodes = await this.loadSelectionCodes(settledBets); + const agentIds = new Set(); + + await this.prisma.$transaction(async (tx) => { + await tx.matchScore.upsert({ + where: { matchId: batch.matchId }, + create: { + matchId: batch.matchId, + htHomeScore: scoreInput.htHome, + htAwayScore: scoreInput.htAway, + ftHomeScore: scoreInput.ftHome, + ftAwayScore: scoreInput.ftAway, + recordedBy: operatorId, + }, + update: { + htHomeScore: scoreInput.htHome, + htAwayScore: scoreInput.htAway, + ftHomeScore: scoreInput.ftHome, + ftAwayScore: scoreInput.ftAway, + recordedBy: operatorId, + }, + }); + + for (const bet of settledBets) { + const oldPayout = new Decimal(bet.actualReturn); + const outcome = await this.computeBetOutcome( + bet, + batch.matchId, + scoreInput, + winnerTeamCode, + selectionCodes, + ); + const delta = outcome.payout.sub(oldPayout); + + if (delta.eq(0) && outcome.betStatus === bet.status) continue; + + for (const [legId, result] of outcome.legUpdates) { + const leg = bet.selections.find((s) => s.id.toString() === legId); + await tx.betSelection.update({ + where: { id: BigInt(legId) }, + data: { + resultStatus: result, + effectiveOdds: leg?.odds ?? undefined, + }, + }); + } + + await tx.bet.update({ + where: { id: bet.id }, + data: { + status: outcome.betStatus, + actualReturn: outcome.payout, + settlementStatus: 'RESETTLED', + }, + }); + + await this.wallet.applyResettleDelta(bet.userId, delta, bet.betNo, tx); + + if (bet.agentId) agentIds.add(bet.agentId); + + await tx.settlementItem.create({ + data: { + batchId, + betId: bet.id, + userId: bet.userId, + result: outcome.betStatus, + payout: outcome.payout, + }, + }); + } + + await tx.settlementBatch.update({ + where: { id: batchId }, + data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId }, + }); + }); + + for (const agentId of agentIds) { + await this.agents.recalculateUsedCredit(agentId); + } + + return { success: true, batchId: batchId.toString() }; + } async voidMatchBets(matchId: bigint) { const bets = await this.prisma.bet.findMany({ diff --git a/apps/api/src/integration.spec.ts b/apps/api/src/integration.spec.ts index 11f693b..c423061 100644 --- a/apps/api/src/integration.spec.ts +++ b/apps/api/src/integration.spec.ts @@ -72,10 +72,13 @@ describe('Bet Validation Rules (B001-B010)', () => { expect(submitted === current).toBe(false); }); - it('B007: same match in parlay rejected', () => { - const matchIds = ['1', '1', '2']; - const unique = new Set(matchIds); - expect(unique.size !== matchIds.length).toBe(true); + it('B007: same match legs allowed in parlay (2–5 legs)', () => { + const legs = [ + { matchId: '1', selectionId: 'a' }, + { matchId: '1', selectionId: 'b' }, + ]; + expect(legs.length).toBeGreaterThanOrEqual(2); + expect(legs.length).toBeLessThanOrEqual(5); }); it('B008: quarter line in parlay rejected', () => { diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..c568e04 --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 6efaa46..a58eb52 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -9,6 +9,7 @@ "target": "ES2022", "sourceMap": true, "outDir": "./dist", + "rootDir": "src", "baseUrl": "./", "incremental": true, "skipLibCheck": true, @@ -21,5 +22,5 @@ "@/*": ["src/*"] } }, - "include": ["src/**/*", "prisma/seed.ts"] + "include": ["src/**/*"] } diff --git a/apps/player/src/components/BetSlipDrawer.vue b/apps/player/src/components/BetSlipDrawer.vue index e48fde6..bd5f8d5 100644 --- a/apps/player/src/components/BetSlipDrawer.vue +++ b/apps/player/src/components/BetSlipDrawer.vue @@ -30,19 +30,11 @@ async function placeBet() { success.value = ''; try { - if (slip.mode === 'parlay' && slip.items.length >= PARLAY_MIN_LEGS) { - if (slip.hasSameMatch) { - error.value = t('bet.parlay_same_match'); - return; - } + if (slip.canPlaceParlay) { if (slip.items.length > PARLAY_MAX_LEGS) { error.value = t('bet.parlay_max_legs'); return; } - if (!slip.canPlaceParlay) { - error.value = t('bet.parlay_need_more'); - return; - } await api.post('/player/bets/parlay', { legs: slip.items.map((i) => ({ selectionId: i.selectionId, @@ -51,15 +43,17 @@ async function placeBet() { stake: slip.stake, requestId: genId(), }); - } else if (slip.items.length === 1) { - const item = slip.items[0]; - await api.post('/player/bets/single', { - selectionId: item.selectionId, - oddsVersion: item.oddsVersion, - stake: slip.stake, - requestId: genId(), - }); + } else if (slip.canPlaceBatchSingles) { + for (const item of slip.items) { + await api.post('/player/bets/single', { + selectionId: item.selectionId, + oddsVersion: item.oddsVersion, + stake: slip.stake, + requestId: genId(), + }); + } } else { + error.value = t('bet.parlay_need_more'); return; } success.value = t('bet.place_success'); @@ -100,12 +94,19 @@ async function placeBet() { -

+

{{ t('bet.parlay') }} · {{ t('bet.slip_parlay_odds', { odds: slip.totalOdds.toFixed(2) }) }}

+

+ {{ t('bet.slip_singles_hint', { n: slip.count }) }} +

- +
{{ t('bet.slip_est_return') }}: @@ -119,7 +120,7 @@ async function placeBet() {