From 844727c82e79aefeb4e019933ab12d0fc02875bb Mon Sep 17 00:00:00 2001 From: Mars <3361409208a@gmail.com> Date: Thu, 11 Jun 2026 09:36:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E5=8F=B0=E5=8C=BF=E5=90=8D?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E3=80=81=E7=99=BB=E5=BD=95=E5=BC=95=E5=AF=BC?= =?UTF-8?q?=E3=80=81=E5=AE=A2=E6=9C=8D=E5=85=A5=E5=8F=A3=E4=B8=8E=E8=BF=94?= =?UTF-8?q?=E6=B0=B4=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前台: - 未登录可浏览首页/赛事/赔率,下注等操作弹出登录引导(去登录/继续浏览) - 顶部新增客服入口与 iframe 弹窗 - 登录页支持暂不登录返回浏览 API: - 首页/赛事/冠军盘接口改为公开访问,支持 X-Locale 头 - JWT 守卫支持可选认证 返水: - 注单新增 is_cashbacked 字段,发放时自动标记 - 预览展示玩家余额,明确平台直发不从代理扣款 - 后台注单列表与玩家历史展示回水状态 其他: - 串关禁止同场重复选号(SAME_MATCH) - 补充结算资金流分析文档 Co-authored-by: Cursor --- apps/admin/src/i18n/admin-pages-ms.ts | 7 +- apps/admin/src/i18n/admin-pages.ts | 14 +- apps/admin/src/views/Bets.vue | 9 + apps/admin/src/views/Cashback.vue | 17 ++ apps/admin/src/views/bet-form.ts | 1 + .../migration.sql | 9 + apps/api/prisma/schema.prisma | 1 + .../applications/player/player.controller.ts | 30 +- apps/api/src/domains/betting/bets.service.ts | 8 + .../src/domains/catalog/matches.service.ts | 2 + apps/api/src/domains/identity/guards.ts | 7 + .../operations/cashback/cashback.service.ts | 34 +++ .../smoke-tests/smoke-test.cases.ts | 13 + apps/api/src/integration.spec.ts | 11 +- apps/player/src/App.vue | 2 + apps/player/src/api/index.ts | 2 +- apps/player/src/components/BetHistoryCard.vue | 15 +- apps/player/src/components/BetSlipDrawer.vue | 6 + .../src/components/CustomerServiceModal.vue | 145 ++++++++++ .../src/components/LoginPromptModal.vue | 154 ++++++++++ apps/player/src/components/UserAvatarMenu.vue | 2 +- .../src/components/outright/OutrightPanel.vue | 10 + .../src/components/parlay/ParlayPanel.vue | 11 + apps/player/src/config/customerService.ts | 3 + apps/player/src/layouts/MainLayout.vue | 85 +++++- apps/player/src/main.ts | 60 +++- apps/player/src/router/index.ts | 27 +- apps/player/src/stores/auth.ts | 22 +- apps/player/src/stores/betSlip.ts | 10 +- apps/player/src/views/LoginView.vue | 29 +- apps/player/src/views/MatchDetailView.vue | 16 +- apps/player/src/views/ProfileView.vue | 2 +- docs/settlement-and-fund-flow-analysis.md | 272 ++++++++++++++++++ packages/shared/src/api-errors.ts | 5 + packages/shared/src/betting-rules.ts | 15 +- 35 files changed, 1007 insertions(+), 49 deletions(-) create mode 100644 apps/api/prisma/migrations/20260611100000_bet_is_cashbacked/migration.sql create mode 100644 apps/player/src/components/CustomerServiceModal.vue create mode 100644 apps/player/src/components/LoginPromptModal.vue create mode 100644 apps/player/src/config/customerService.ts create mode 100644 docs/settlement-and-fund-flow-analysis.md diff --git a/apps/admin/src/i18n/admin-pages-ms.ts b/apps/admin/src/i18n/admin-pages-ms.ts index e587632..fed45d0 100644 --- a/apps/admin/src/i18n/admin-pages-ms.ts +++ b/apps/admin/src/i18n/admin-pages-ms.ts @@ -288,6 +288,7 @@ export const adminPagesMs: Record = { 'bet.col.odds': 'Odds', 'bet.col.payout': 'Bayaran', 'bet.col.placed_at': 'Masa pertaruhan', + 'bet.col.cashbacked': 'Rebat dibayar', 'bet.dialog.detail': 'Butiran pertaruhan', 'bet.field.total_odds': 'Jumlah odds', 'bet.field.currency': 'Mata wang', @@ -358,20 +359,22 @@ export const adminPagesMs: Record = { 'cashback.col.index': '#', 'cashback.col.player': 'Pemain', 'cashback.col.agent': 'Ejen', + 'cashback.col.balance': 'Baki semasa', 'cashback.col.effective_stake': 'Stake berkesan', 'cashback.col.rate': 'Kadar', 'cashback.col.amount': 'Rebat', 'cashback.confirm_issue': 'Sahkan bayaran', 'cashback.cancel_issue': 'Batalkan', - 'cashback.confirm_prompt': 'Bayar rebat kelompok ini ke dompet pemain? Tindakan ini tidak boleh dibatalkan.', + 'cashback.confirm_prompt': 'Bayar rebat kelompok ini ke dompet pemain? Rebat dikreditkan terus oleh platform dan tidak ditolak daripada ejen. Tindakan ini tidak boleh dibatalkan.', 'cashback.cancel_prompt': 'Batalkan kelompok menunggu ini? Tiada kredit dompet; boleh pratonton semula.', 'cashback.status.CANCELLED': 'Dibatalkan', 'cashback.rules_title': 'Peraturan rebat', 'cashback.rule_period': 'Pilih julat tarikh. Taruhan dikira mengikut masa penyelesaian dalam tempoh tersebut.', - 'cashback.rule_eligible': 'Termasuk: taruhan selesai WON/LOST (tunggal ikut stake; parlay sekali ikut stake parlay). Tidak termasuk: belum selesai, dibatalkan, batal, push, dan kadar 0.', + 'cashback.rule_eligible': 'Termasuk: taruhan selesai WON/LOST (tunggal ikut stake; parlay sekali ikut stake parlay). Tidak termasuk: belum selesai, dibatalkan, batal, push, kadar 0, dan taruhan yang sudah dibayar rebat.', '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 (satu menunggu setiap tempoh) → semak → sahkan bayaran; batalkan jika tidak perlu. Tempoh dibayar tidak boleh pratonton semula.', + 'cashback.rule_platform': 'Bayaran: rebat dikreditkan ke baki tunai pemain oleh platform; tidak ditolak daripada kredit atau baki ejen.', 'cashback.rule_note_zero': 'Jika 0, semak taruhan WON/LOST dalam tempoh dan kadar rebat > 0.', 'user.field.player_id': 'ID pemain', diff --git a/apps/admin/src/i18n/admin-pages.ts b/apps/admin/src/i18n/admin-pages.ts index fb081d4..152de9d 100644 --- a/apps/admin/src/i18n/admin-pages.ts +++ b/apps/admin/src/i18n/admin-pages.ts @@ -303,6 +303,7 @@ export const adminPagesZh: Record = { 'bet.col.odds': '赔率', 'bet.col.payout': '派彩', 'bet.col.placed_at': '投注时间', + 'bet.col.cashbacked': '已回水', 'bet.dialog.detail': '注单详情', 'bet.field.total_odds': '总赔率', 'bet.field.currency': '币种', @@ -373,20 +374,22 @@ export const adminPagesZh: Record = { 'cashback.col.index': '#', 'cashback.col.player': '玩家', 'cashback.col.agent': '所属代理', + 'cashback.col.balance': '当前余额', 'cashback.col.effective_stake': '有效投注', 'cashback.col.rate': '返水比例', 'cashback.col.amount': '返水金额', 'cashback.confirm_issue': '确认发放', 'cashback.cancel_issue': '作废', - 'cashback.confirm_prompt': '确认向玩家钱包发放本批次返水?此操作不可撤销。', + 'cashback.confirm_prompt': '确认向玩家钱包发放本批次返水?返水由平台直接入账,不从代理扣款。此操作不可撤销。', 'cashback.cancel_prompt': '确认作废该待发放批次?作废后不会入账,可重新生成预览。', 'cashback.status.CANCELLED': '已作废', 'cashback.rules_title': '返水规则说明', 'cashback.rule_period': '选择开始/结束日期,统计该周期内、按注单结算时间落在区间内的有效投注。', - 'cashback.rule_eligible': '计入:已结算且结果为「赢」或「输」的注单(单关按本金,串关按整单本金计一次)。不计入:未结算、已取消、作废、走水,以及返水比例为 0 的注单。', + 'cashback.rule_eligible': '计入:已结算且结果为「赢」或「输」的注单(单关按本金,串关按整单本金计一次)。不计入:未结算、已取消、作废、走水,以及返水比例为 0 的注单;已返水过的注单不会重复计入。', 'cashback.rule_formula': '单笔返水 = 投注本金 × 适用返水比例;同一玩家多笔注单汇总后生成一条返水明细。', 'cashback.rule_rate': '返水比例优先级:玩家专属规则 > 代理线规则 > 全局规则 > 所属代理默认返水率(在代理/玩家管理中配置,如 0.01 表示 1%)。', 'cashback.rule_flow': '操作流程:生成预览(同周期仅保留一条待发放)→ 核对明细 → 确认发放;不需要的可作废。已发放周期不可重复预览。', + 'cashback.rule_platform': '发放方式:返水由平台直接打入玩家现金余额,不从代理信用或余额中扣除。', 'cashback.rule_note_zero': '预览为 0 时,请检查:周期内是否有已结算输赢注单、代理/玩家是否配置了大于 0 的返水率。', 'user.field.player_id': '玩家 ID', @@ -1174,6 +1177,7 @@ export const adminPagesEn: Record = { 'bet.col.odds': 'Odds', 'bet.col.payout': 'Payout', 'bet.col.placed_at': 'Placed at', + 'bet.col.cashbacked': 'Cashbacked', 'bet.dialog.detail': 'Bet details', 'bet.field.total_odds': 'Total odds', 'bet.field.currency': 'Currency', @@ -1244,20 +1248,22 @@ export const adminPagesEn: Record = { 'cashback.col.index': '#', 'cashback.col.player': 'Player', 'cashback.col.agent': 'Agent', + 'cashback.col.balance': 'Balance', 'cashback.col.effective_stake': 'Effective stake', 'cashback.col.rate': 'Rate', 'cashback.col.amount': 'Cashback', 'cashback.confirm_issue': 'Confirm payout', 'cashback.cancel_issue': 'Void', - 'cashback.confirm_prompt': 'Pay out this cashback batch to player wallets? This cannot be undone.', + 'cashback.confirm_prompt': 'Pay out this cashback batch to player wallets? Cashback is credited by the platform directly and is not deducted from agents. This cannot be undone.', 'cashback.cancel_prompt': 'Void this pending batch? No wallet credit will be made; you can preview again.', 'cashback.status.CANCELLED': 'Voided', 'cashback.rules_title': 'Cashback rules', 'cashback.rule_period': 'Pick a date range. Bets are included by settlement time within that period.', - 'cashback.rule_eligible': 'Included: settled bets with result WON or LOST (singles by stake; parlays counted once by parlay stake). Excluded: pending, cancelled, void, push, and zero-rate bets.', + 'cashback.rule_eligible': 'Included: settled bets with result WON or LOST (singles by stake; parlays counted once by parlay stake). Excluded: pending, cancelled, void, push, zero-rate bets, and bets already paid cashback.', '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 (one pending batch per period) → review → confirm payout; void if not needed. Paid periods cannot be previewed again.', + 'cashback.rule_platform': 'Payout: cashback is credited to player cash balance by the platform; it is not deducted from agent credit or balance.', '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', diff --git a/apps/admin/src/views/Bets.vue b/apps/admin/src/views/Bets.vue index a8177c5..4a37c9c 100644 --- a/apps/admin/src/views/Bets.vue +++ b/apps/admin/src/views/Bets.vue @@ -229,6 +229,12 @@ async function openDetail(row: BetListRow) { + + + @@ -284,6 +290,9 @@ async function openDetail(row: BetListRow) { {{ formatTime(detail.placedAt) }} {{ formatTime(detail.settledAt) }} + + {{ detail.isCashbacked ? t('common.yes') : t('common.no') }} + {{ detail.requestId }} diff --git a/apps/admin/src/views/Cashback.vue b/apps/admin/src/views/Cashback.vue index e2ed1e7..f3a35c6 100644 --- a/apps/admin/src/views/Cashback.vue +++ b/apps/admin/src/views/Cashback.vue @@ -27,6 +27,7 @@ interface CashbackPreviewItem { userId: string; username: string; agentUsername: string | null; + availableBalance: string; effectiveStake: string; betCount: number; rate: string; @@ -288,6 +289,7 @@ onMounted(loadHistory);
  • {{ t('cashback.rule_formula') }}
  • {{ t('cashback.rule_rate') }}
  • {{ t('cashback.rule_flow') }}
  • +
  • {{ t('cashback.rule_platform') }}
  • {{ t('cashback.rule_note_zero') }}
  • @@ -374,6 +376,18 @@ onMounted(loadHistory); > + + + + + + diff --git a/apps/admin/src/views/bet-form.ts b/apps/admin/src/views/bet-form.ts index 9ad1863..c4efc4d 100644 --- a/apps/admin/src/views/bet-form.ts +++ b/apps/admin/src/views/bet-form.ts @@ -25,6 +25,7 @@ export interface BetListRow { currency: string; placedAt: string; settledAt: string | null; + isCashbacked: boolean; selectionCount: number; selectionSummary: string; selectionPreviews: BetSelectionPreview[]; diff --git a/apps/api/prisma/migrations/20260611100000_bet_is_cashbacked/migration.sql b/apps/api/prisma/migrations/20260611100000_bet_is_cashbacked/migration.sql new file mode 100644 index 0000000..1e9d641 --- /dev/null +++ b/apps/api/prisma/migrations/20260611100000_bet_is_cashbacked/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "bets" ADD COLUMN "is_cashbacked" BOOLEAN NOT NULL DEFAULT false; + +-- Backfill: mark bets already paid out via confirmed cashback batches +UPDATE "bets" b +SET "is_cashbacked" = true +FROM "cashback_bets" cb +INNER JOIN "cashback_batches" batch ON batch.id = cb.batch_id +WHERE cb.bet_id = b.id AND batch.status = 'CONFIRMED'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e9ac202..8fc559a 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -396,6 +396,7 @@ model Bet { requestId String @map("request_id") @db.VarChar(128) placedAt DateTime @default(now()) @map("placed_at") settledAt DateTime? @map("settled_at") + isCashbacked Boolean @default(false) @map("is_cashbacked") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/apps/api/src/applications/player/player.controller.ts b/apps/api/src/applications/player/player.controller.ts index 3408f5d..1364d6d 100644 --- a/apps/api/src/applications/player/player.controller.ts +++ b/apps/api/src/applications/player/player.controller.ts @@ -6,11 +6,12 @@ import { Body, Param, Query, + Headers, UseGuards, } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard, PlayerGuard } from '../../domains/identity/guards'; -import { CurrentUser } from '../../shared/common/decorators'; +import { CurrentUser, Public } from '../../shared/common/decorators'; import { jsonResponse } from '../../shared/common/filters'; import { UsersService } from '../../domains/identity/users.service'; import { SystemConfigService } from '../../shared/config/system-config.service'; @@ -144,8 +145,13 @@ export class PlayerController { return jsonResponse(await this.formatPlayerProfile(user)); } + @Public() @Get('home') - async home(@CurrentUser('locale') locale: string) { + async home( + @CurrentUser('locale') userLocale: string | undefined, + @Headers('x-locale') headerLocale?: string, + ) { + const locale = userLocale || headerLocale || 'zh-CN'; const [banners, announcements, hotMatches, todayMatches] = await Promise.all([ this.content.listActive('BANNER', locale), this.content.listActiveAnnouncements(locale), @@ -164,23 +170,37 @@ export class PlayerController { }); } + @Public() @Get('matches') async listMatches( - @CurrentUser('locale') locale: string, + @CurrentUser('locale') userLocale: string | undefined, + @Headers('x-locale') headerLocale: string | undefined, @Query('leagueId') leagueId?: string, ) { + const locale = userLocale || headerLocale || 'zh-CN'; const items = await this.matches.listPublished(locale, leagueId ? BigInt(leagueId) : undefined); return jsonResponse(items); } + @Public() @Get('outrights') - async listOutrights(@CurrentUser('locale') locale: string) { + async listOutrights( + @CurrentUser('locale') userLocale: string | undefined, + @Headers('x-locale') headerLocale: string | undefined, + ) { + const locale = userLocale || headerLocale || 'zh-CN'; const items = await this.outright.listForPlayer(locale); return jsonResponse(items); } + @Public() @Get('matches/:id') - async matchDetail(@Param('id') id: string, @CurrentUser('locale') locale: string) { + async matchDetail( + @Param('id') id: string, + @CurrentUser('locale') userLocale: string | undefined, + @Headers('x-locale') headerLocale: string | undefined, + ) { + const locale = userLocale || headerLocale || 'zh-CN'; const match = await this.matches.getMatchDetail(BigInt(id), locale); return jsonResponse(match); } diff --git a/apps/api/src/domains/betting/bets.service.ts b/apps/api/src/domains/betting/bets.service.ts index 5d8f383..87e17b3 100644 --- a/apps/api/src/domains/betting/bets.service.ts +++ b/apps/api/src/domains/betting/bets.service.ts @@ -10,6 +10,7 @@ import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS, canSelectForParlay, + hasDuplicateParlayMatch, isPreMatchKickoff, isSupportedSport, resolveTranslationFallback, @@ -180,6 +181,11 @@ export class BetsService { selections.push(sel); } + const matchIds = selections.map((s) => s.market.matchId); + if (hasDuplicateParlayMatch(matchIds)) { + throw appBadRequest('PARLAY_SAME_MATCH_FORBIDDEN'); + } + let totalOdds = new Decimal(1); for (const sel of selections) { totalOdds = totalOdds.mul(sel.odds.toString()); @@ -403,6 +409,7 @@ export class BetsService { currency: string; placedAt: Date; settledAt: Date | null; + isCashbacked?: boolean; user: { id: bigint; username: string; parent: { username: string } | null }; _count: { selections: number }; }, @@ -424,6 +431,7 @@ export class BetsService { currency: b.currency, placedAt: b.placedAt, settledAt: b.settledAt, + isCashbacked: b.isCashbacked ?? false, selectionCount: b._count.selections, }; } diff --git a/apps/api/src/domains/catalog/matches.service.ts b/apps/api/src/domains/catalog/matches.service.ts index 33f8b1b..6e63328 100644 --- a/apps/api/src/domains/catalog/matches.service.ts +++ b/apps/api/src/domains/catalog/matches.service.ts @@ -1413,6 +1413,7 @@ export class MatchesService { actualReturn: unknown; status: string; placedAt: Date; + isCashbacked?: boolean; selections: Array<{ matchId: bigint | null; marketType: string; @@ -1505,6 +1506,7 @@ export class MatchesService { actualReturn: bet.actualReturn, status: bet.status, placedAt: bet.placedAt, + isCashbacked: bet.isCashbacked ?? false, leagueName: isParlay ? 'Parlay' : meta?.leagueName ?? legs[0]?.leagueName ?? '', diff --git a/apps/api/src/domains/identity/guards.ts b/apps/api/src/domains/identity/guards.ts index 4cf1ac1..f31135f 100644 --- a/apps/api/src/domains/identity/guards.ts +++ b/apps/api/src/domains/identity/guards.ts @@ -68,7 +68,14 @@ export function UserTypeGuard(...types: string[]) { @Injectable() export class PlayerGuard implements CanActivate { + constructor(private reflector: Reflector) {} + canActivate(context: ExecutionContext): boolean { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; const { user } = context.switchToHttp().getRequest(); if (user?.userType !== 'PLAYER') throw appForbidden('PLAYER_ACCESS_ONLY'); return true; diff --git a/apps/api/src/domains/operations/cashback/cashback.service.ts b/apps/api/src/domains/operations/cashback/cashback.service.ts index 7986ad1..f9e83d2 100644 --- a/apps/api/src/domains/operations/cashback/cashback.service.ts +++ b/apps/api/src/domains/operations/cashback/cashback.service.ts @@ -18,6 +18,7 @@ type AggregatedItem = { amount: Decimal; username: string; agentUsername: string | null; + availableBalance: Decimal; }; type BetCashbackLine = { @@ -158,12 +159,25 @@ export class CashbackService { : []; const userById = new Map(users.map((u) => [u.id.toString(), u])); + const wallets = + userIds.length > 0 + ? await this.prisma.wallet.findMany({ + where: { userId: { in: userIds } }, + select: { userId: true, availableBalance: true }, + }) + : []; + const balanceByUserId = new Map( + wallets.map((w) => [w.userId.toString(), w.availableBalance]), + ); + const items: AggregatedItem[] = rawItems.map((item) => { const user = userById.get(item.userId.toString()); return { ...item, username: user?.username ?? '', agentUsername: user?.parent?.username ?? null, + availableBalance: + balanceByUserId.get(item.userId.toString()) ?? new Decimal(0), }; }); @@ -362,6 +376,17 @@ export class CashbackService { : []; const userById = new Map(users.map((u) => [u.id.toString(), u])); + const wallets = + userIds.length > 0 + ? await this.prisma.wallet.findMany({ + where: { userId: { in: userIds } }, + select: { userId: true, availableBalance: true }, + }) + : []; + const balanceByUserId = new Map( + wallets.map((w) => [w.userId.toString(), w.availableBalance]), + ); + let operatorUsername: string | null = null; if (batch.operatorId) { const op = await this.prisma.user.findUnique({ @@ -378,6 +403,8 @@ export class CashbackService { userId: item.userId, username: user?.username ?? '', agentUsername: user?.parent?.username ?? null, + availableBalance: + balanceByUserId.get(item.userId.toString()) ?? new Decimal(0), effectiveStake: item.effectiveStake, betCount: item.betCount, rate: item.rate, @@ -448,6 +475,13 @@ export class CashbackService { } } + if (betIds.length > 0) { + await this.prisma.bet.updateMany({ + where: { id: { in: betIds } }, + data: { isCashbacked: true }, + }); + } + await this.prisma.cashbackBatch.update({ where: { id: batchId }, data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId }, diff --git a/apps/api/src/domains/operations/smoke-tests/smoke-test.cases.ts b/apps/api/src/domains/operations/smoke-tests/smoke-test.cases.ts index 6cb7d6a..3a2f03b 100644 --- a/apps/api/src/domains/operations/smoke-tests/smoke-test.cases.ts +++ b/apps/api/src/domains/operations/smoke-tests/smoke-test.cases.ts @@ -1,6 +1,7 @@ import { Decimal } from '@prisma/client/runtime/library'; import { canSelectForParlay, + hasDuplicateParlayMatch, isQuarterHandicapOrTotal, PARLAY_MAX_LEGS, PARLAY_MIN_LEGS, @@ -456,6 +457,18 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [ expectTrue('1 leg blocked', 1 < PARLAY_MIN_LEGS); }, }, + { + id: 'B007', + suite: 'betting', + name: '同场串关应拒绝', + uatRef: 'B007', + run: () => { + expectTrue('same match blocked', hasDuplicateParlayMatch(['1', '1']), { matchIds: ['1', '1'] }); + expectFalse('different matches ok', hasDuplicateParlayMatch(['1', '2', '3']), { + matchIds: ['1', '2', '3'], + }); + }, + }, { id: 'B008', suite: 'betting', diff --git a/apps/api/src/integration.spec.ts b/apps/api/src/integration.spec.ts index 4289f38..754a768 100644 --- a/apps/api/src/integration.spec.ts +++ b/apps/api/src/integration.spec.ts @@ -3,6 +3,7 @@ import { calculatePayout, isQuarterHandicapOrTotal, } from './domains/settlement/domain/settlement-calculator'; +import { hasDuplicateParlayMatch } from '@thebet365/shared'; /** * Agent credit & wallet integration scenarios (A001-A007) @@ -82,13 +83,9 @@ describe('Bet Validation Rules (B001-B010)', () => { expect(submitted === current).toBe(false); }); - 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('B007: same match legs rejected in parlay', () => { + expect(hasDuplicateParlayMatch(['1', '1'])).toBe(true); + expect(hasDuplicateParlayMatch(['1', '2', '3'])).toBe(false); }); it('B008: quarter line in parlay rejected', () => { diff --git a/apps/player/src/App.vue b/apps/player/src/App.vue index 590d4ea..f4ac569 100644 --- a/apps/player/src/App.vue +++ b/apps/player/src/App.vue @@ -1,6 +1,8 @@ diff --git a/apps/player/src/api/index.ts b/apps/player/src/api/index.ts index 3f35d75..0bdddd8 100644 --- a/apps/player/src/api/index.ts +++ b/apps/player/src/api/index.ts @@ -20,7 +20,7 @@ api.interceptors.response.use( // Don't redirect on login/auth failures — let the caller handle the error if (!url.includes('/auth/login')) { localStorage.removeItem('token'); - window.location.href = '/login'; + // 不再强制跳转登录页,让调用方处理 401 } } return Promise.reject(err); diff --git a/apps/player/src/components/BetHistoryCard.vue b/apps/player/src/components/BetHistoryCard.vue index 7ebaf16..10ffbea 100644 --- a/apps/player/src/components/BetHistoryCard.vue +++ b/apps/player/src/components/BetHistoryCard.vue @@ -20,6 +20,7 @@ export interface BetHistoryItem { actualReturn: unknown; status: string; placedAt: string; + isCashbacked?: boolean; leagueName?: string; matchTitle: string; pickLabel: string; @@ -122,6 +123,7 @@ function goDetail() {
    {{ betTypeLabel }} + {{ t('history.cashbacked') }} {{ placedDate }}
    {{ title }} @@ -169,8 +171,18 @@ function goDetail() { .card-header { display: flex; align-items: center; - justify-content: space-between; gap: 8px; + flex-wrap: wrap; +} + +.cashback-tag { + font-size: 10px; + font-weight: 700; + color: #f0b90b; + background: rgba(240, 185, 11, 0.1); + border: 1px solid rgba(240, 185, 11, 0.25); + border-radius: 4px; + padding: 1px 6px; } .bet-type-tag { @@ -189,6 +201,7 @@ function goDetail() { color: #555; font-weight: 600; flex-shrink: 0; + margin-left: auto; } .title { diff --git a/apps/player/src/components/BetSlipDrawer.vue b/apps/player/src/components/BetSlipDrawer.vue index 44bc8eb..4654d75 100644 --- a/apps/player/src/components/BetSlipDrawer.vue +++ b/apps/player/src/components/BetSlipDrawer.vue @@ -3,6 +3,7 @@ import { ref, computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared'; import { useBetSlipStore } from '../stores/betSlip'; +import { useAuthStore } from '../stores/auth'; import BetSuccessOverlay from './BetSuccessOverlay.vue'; import api from '../api'; @@ -11,6 +12,7 @@ const emit = defineEmits<{ 'update:modelValue': [boolean] }>(); const { t } = useI18n(); const slip = useBetSlipStore(); +const auth = useAuthStore(); const show = computed({ get: () => props.modelValue, set: (v) => emit('update:modelValue', v), @@ -33,6 +35,10 @@ function onSuccessDone() { async function placeBet() { if (!slip.items.length) return; + if (!auth.token) { + auth.showLoginPrompt(); + return; + } loading.value = true; error.value = ''; success.value = ''; diff --git a/apps/player/src/components/CustomerServiceModal.vue b/apps/player/src/components/CustomerServiceModal.vue new file mode 100644 index 0000000..09f14f5 --- /dev/null +++ b/apps/player/src/components/CustomerServiceModal.vue @@ -0,0 +1,145 @@ + + +