feat: 前台匿名浏览、登录引导、客服入口与返水增强
前台: - 未登录可浏览首页/赛事/赔率,下注等操作弹出登录引导(去登录/继续浏览) - 顶部新增客服入口与 iframe 弹窗 - 登录页支持暂不登录返回浏览 API: - 首页/赛事/冠军盘接口改为公开访问,支持 X-Locale 头 - JWT 守卫支持可选认证 返水: - 注单新增 is_cashbacked 字段,发放时自动标记 - 预览展示玩家余额,明确平台直发不从代理扣款 - 后台注单列表与玩家历史展示回水状态 其他: - 串关禁止同场重复选号(SAME_MATCH) - 补充结算资金流分析文档 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -288,6 +288,7 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -303,6 +303,7 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -229,6 +229,12 @@ async function openDetail(row: BetListRow) {
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('bet.col.cashbacked')" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.isCashbacked" type="success" size="small" effect="plain">✓</el-tag>
|
||||
<span v-else class="bet-content-empty">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('bet.col.placed_at')" min-width="168">
|
||||
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
|
||||
</el-table-column>
|
||||
@@ -284,6 +290,9 @@ async function openDetail(row: BetListRow) {
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.col.placed_at')">{{ formatTime(detail.placedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.field.settled_at')">{{ formatTime(detail.settledAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.col.cashbacked')">
|
||||
{{ detail.isCashbacked ? t('common.yes') : t('common.no') }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.field.request_id')" :span="2">{{ detail.requestId }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
|
||||
@@ -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);
|
||||
<li>{{ t('cashback.rule_formula') }}</li>
|
||||
<li>{{ t('cashback.rule_rate') }}</li>
|
||||
<li>{{ t('cashback.rule_flow') }}</li>
|
||||
<li>{{ t('cashback.rule_platform') }}</li>
|
||||
<li class="rules-note">{{ t('cashback.rule_note_zero') }}</li>
|
||||
</ul>
|
||||
</el-dialog>
|
||||
@@ -374,6 +376,18 @@ onMounted(loadHistory);
|
||||
>
|
||||
<template #default="{ row }">{{ row.agentUsername || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="availableBalance"
|
||||
:label="t('cashback.col.balance')"
|
||||
min-width="110"
|
||||
align="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull(row.availableBalance)" placement="top">
|
||||
<span>{{ formatAmount(row.availableBalance) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="betCount"
|
||||
:label="t('cashback.col.bet_count')"
|
||||
@@ -547,6 +561,9 @@ onMounted(loadHistory);
|
||||
<el-table-column prop="agentUsername" :label="t('cashback.col.agent')" min-width="100">
|
||||
<template #default="{ row }">{{ row.agentUsername || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="availableBalance" :label="t('cashback.col.balance')" min-width="100" align="right">
|
||||
<template #default="{ row }">{{ formatAmount(row.availableBalance) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="betCount" :label="t('cashback.col.bet_count')" width="88" align="right" />
|
||||
<el-table-column prop="effectiveStake" :label="t('cashback.col.effective_stake')" min-width="110" align="right">
|
||||
<template #default="{ row }">{{ formatAmount(row.effectiveStake) }}</template>
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface BetListRow {
|
||||
currency: string;
|
||||
placedAt: string;
|
||||
settledAt: string | null;
|
||||
isCashbacked: boolean;
|
||||
selectionCount: number;
|
||||
selectionSummary: string;
|
||||
selectionPreviews: BetSelectionPreview[];
|
||||
|
||||
@@ -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';
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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<boolean>(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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import LoginPromptModal from './components/LoginPromptModal.vue';
|
||||
</script>
|
||||
<template>
|
||||
<RouterView />
|
||||
<LoginPromptModal />
|
||||
</template>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
<div class="card-body">
|
||||
<div class="card-header">
|
||||
<span v-if="betTypeLabel" class="bet-type-tag">{{ betTypeLabel }}</span>
|
||||
<span v-if="bet.isCashbacked" class="cashback-tag">{{ t('history.cashbacked') }}</span>
|
||||
<span class="card-date">{{ placedDate }}</span>
|
||||
</div>
|
||||
<span class="title">{{ title }}</span>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
145
apps/player/src/components/CustomerServiceModal.vue
Normal file
145
apps/player/src/components/CustomerServiceModal.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CUSTOMER_SERVICE_URL } from '../config/customerService';
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>();
|
||||
const emit = defineEmits<{ 'update:modelValue': [boolean] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
});
|
||||
|
||||
const hasUrl = computed(() => Boolean(CUSTOMER_SERVICE_URL));
|
||||
|
||||
function close() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="visible" class="cs-overlay" @click.self="close">
|
||||
<div class="cs-modal" role="dialog" :aria-label="t('support.title')">
|
||||
<header class="cs-header">
|
||||
<h2 class="cs-title">{{ t('support.title') }}</h2>
|
||||
<button type="button" class="close-btn" :aria-label="t('support.close')" @click="close">
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="cs-body">
|
||||
<iframe
|
||||
v-if="hasUrl"
|
||||
class="cs-frame"
|
||||
:src="CUSTOMER_SERVICE_URL"
|
||||
:title="t('support.title')"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
<p v-else class="cs-empty">{{ t('support.url_pending') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cs-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.cs-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(100%, 420px);
|
||||
height: min(82vh, 680px);
|
||||
background: #141414;
|
||||
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.cs-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2a);
|
||||
background: rgba(26, 26, 26, 0.98);
|
||||
}
|
||||
|
||||
.cs-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light, #c8a84e);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.cs-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #0d0d0d;
|
||||
}
|
||||
|
||||
.cs-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cs-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
154
apps/player/src/components/LoginPromptModal.vue
Normal file
154
apps/player/src/components/LoginPromptModal.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const { t } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
function goLogin() {
|
||||
const redirect = auth.loginReturnTo || undefined;
|
||||
auth.hideLoginPrompt();
|
||||
router.push({
|
||||
path: '/login',
|
||||
query: redirect ? { redirect } : {},
|
||||
});
|
||||
}
|
||||
|
||||
function continueBrowsing() {
|
||||
auth.loginReturnTo = '';
|
||||
auth.hideLoginPrompt();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="auth.loginPromptVisible" class="login-overlay" @click.self="continueBrowsing">
|
||||
<div class="login-modal">
|
||||
<button type="button" class="close-btn" :aria-label="t('auth.continue_browsing')" @click="continueBrowsing">
|
||||
✕
|
||||
</button>
|
||||
<h2 class="login-title">{{ t('auth.login_required') }}</h2>
|
||||
<p class="login-hint">{{ t('auth.login_hint') }}</p>
|
||||
|
||||
<div class="login-actions">
|
||||
<button type="button" class="login-submit" @click="goLogin">
|
||||
{{ t('auth.go_login') }}
|
||||
</button>
|
||||
<button type="button" class="login-secondary" @click="continueBrowsing">
|
||||
{{ t('auth.continue_browsing') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.login-modal {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-width: 360px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
|
||||
border-radius: 12px;
|
||||
padding: 28px 24px 24px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light, #c8a84e);
|
||||
margin: 0 0 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-hint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #d4a017, #e8c84a);
|
||||
color: #1a1a1a;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.login-submit:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.login-secondary {
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #333;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
min-height: 40px;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.login-secondary:active {
|
||||
color: #aaa;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -48,7 +48,7 @@ function goEdit() {
|
||||
function logout() {
|
||||
close();
|
||||
auth.logout();
|
||||
router.push('/login');
|
||||
router.push('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,11 +7,17 @@ import OutrightEventSection, {
|
||||
type OutrightSelection,
|
||||
} from './OutrightEventSection.vue';
|
||||
import OutrightBetModal, { type OutrightPick } from './OutrightBetModal.vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import emptyMatchesImg from '../../assets/images/empty-matches.svg';
|
||||
import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
|
||||
|
||||
const { t } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
|
||||
function goLogin() {
|
||||
auth.showLoginPrompt('/bet');
|
||||
}
|
||||
|
||||
const loading = ref(true);
|
||||
const loadError = ref('');
|
||||
@@ -96,6 +102,10 @@ function toggle(id: string) {
|
||||
}
|
||||
|
||||
function openBet(event: OutrightEvent, sel: OutrightSelection) {
|
||||
if (!auth.token) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
activePick.value = {
|
||||
selectionId: sel.id,
|
||||
oddsVersion: sel.oddsVersion,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../../api';
|
||||
import { useBetSlipStore } from '../../stores/betSlip';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { PARLAY_MAX_LEGS, canSelectForParlay } from '@thebet365/shared';
|
||||
import { PARLAY_MARKET_TYPES, PARLAY_SELECTION_KEYS, PARLAY_MARKET_GROUPS } from '../../utils/parlayColumns';
|
||||
import BetGuideHelp from '../BetGuideHelp.vue';
|
||||
@@ -56,6 +57,11 @@ interface ParlayMatch {
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const slip = useBetSlipStore();
|
||||
const auth = useAuthStore();
|
||||
|
||||
function goLogin() {
|
||||
auth.showLoginPrompt('/bet');
|
||||
}
|
||||
|
||||
const loading = ref(true);
|
||||
const matches = ref<ParlayMatch[]>([]);
|
||||
@@ -248,10 +254,15 @@ function pickSelection(match: ParlayMatch, market: Market, sel: Selection) {
|
||||
else if (err === 'QUARTER_LINE') parlayHint.value = t('bet.parlay_block_quarter');
|
||||
else if (err === 'OUTRIGHT') parlayHint.value = t('bet.parlay_block_outright');
|
||||
else if (err === 'NOT_ALLOWED') parlayHint.value = t('bet.parlay_block_not_allowed');
|
||||
else if (err === 'SAME_MATCH') parlayHint.value = t('bet.parlay_same_match');
|
||||
else parlayHint.value = '';
|
||||
}
|
||||
|
||||
function openSlip() {
|
||||
if (!auth.token) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
slip.openDrawer();
|
||||
}
|
||||
|
||||
|
||||
3
apps/player/src/config/customerService.ts
Normal file
3
apps/player/src/config/customerService.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
/** 客服 iframe 地址,可通过环境变量 VITE_CUSTOMER_SERVICE_URL 覆盖 */
|
||||
export const CUSTOMER_SERVICE_URL =
|
||||
(import.meta.env.VITE_CUSTOMER_SERVICE_URL as string | undefined)?.trim() || '';
|
||||
@@ -10,6 +10,7 @@ import LocaleSwitcher from '../components/LocaleSwitcher.vue';
|
||||
import { useAppLocale } from '../composables/useAppLocale';
|
||||
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
|
||||
import BottomNavIcon from '../components/BottomNavIcon.vue';
|
||||
import CustomerServiceModal from '../components/CustomerServiceModal.vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||
@@ -44,6 +45,7 @@ const { announcements, load: loadPlayerHome } = usePlayerHome();
|
||||
const { loadProfile } = usePlayerProfile();
|
||||
const mainRef = ref<HTMLElement | null>(null);
|
||||
const tabScrollTops = new Map<string, number>();
|
||||
const customerServiceOpen = ref(false);
|
||||
|
||||
watch(locale, (next, prev) => {
|
||||
if (prev && next !== prev) void loadPlayerHome(true);
|
||||
@@ -66,8 +68,10 @@ onMounted(() => {
|
||||
watch(
|
||||
() => auth.token,
|
||||
(token) => {
|
||||
// 首页数据(公告、热门赛事)对所有人公开,始终加载
|
||||
void loadPlayerHome();
|
||||
// 个人资料仅登录用户需要
|
||||
if (token) {
|
||||
void loadPlayerHome();
|
||||
void loadProfile();
|
||||
}
|
||||
},
|
||||
@@ -80,9 +84,34 @@ watch(
|
||||
<header v-if="showHeader" class="header">
|
||||
<img src="/logo.png" alt="TheBet365" class="logo" />
|
||||
<div class="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="support-btn"
|
||||
:aria-label="t('support.open')"
|
||||
@click="customerServiceOpen = true"
|
||||
>
|
||||
<svg class="support-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M12 3C7.03 3 3 6.58 3 11c0 2.02.9 3.86 2.38 5.24L4 21l4.2-1.02A10.8 10.8 0 0 0 12 19c4.97 0 9-3.58 9-8s-4.03-8-9-8Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="9" cy="11" r="1" fill="currentColor" />
|
||||
<circle cx="12" cy="11" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="11" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
<span class="support-label">{{ t('support.short') }}</span>
|
||||
</button>
|
||||
<LocaleSwitcher />
|
||||
<CashBalanceChip v-if="auth.user" />
|
||||
<UserAvatarMenu v-if="auth.user" />
|
||||
<template v-if="auth.user">
|
||||
<CashBalanceChip />
|
||||
<UserAvatarMenu />
|
||||
</template>
|
||||
<button v-else type="button" class="login-btn" @click="auth.showLoginPrompt(route.fullPath)">
|
||||
{{ t('auth.login') }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -127,6 +156,7 @@ watch(
|
||||
</nav>
|
||||
|
||||
<BetSlipDrawer v-model="slip.drawerOpen" />
|
||||
<CustomerServiceModal v-model="customerServiceOpen" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -168,6 +198,55 @@ watch(
|
||||
.header-actions :deep(.avatar-btn) {
|
||||
width: var(--header-chip-h);
|
||||
}
|
||||
|
||||
.support-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: var(--header-chip-h, 36px);
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
|
||||
background: rgba(200, 168, 78, 0.08);
|
||||
color: var(--primary-light, #c8a84e);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.support-btn:active {
|
||||
background: rgba(200, 168, 78, 0.16);
|
||||
}
|
||||
|
||||
.support-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-label {
|
||||
max-width: 48px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--primary, #c8a84e);
|
||||
background: transparent;
|
||||
color: var(--primary-light, #c8a84e);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
background: rgba(200, 168, 78, 0.15);
|
||||
}
|
||||
.announce-strip {
|
||||
flex-shrink: 0;
|
||||
z-index: 105;
|
||||
|
||||
@@ -66,6 +66,7 @@ const i18n = createI18n({
|
||||
stats_push: '走盘',
|
||||
stats_stake: '总投注额',
|
||||
stats_return: '总回报',
|
||||
cashbacked: '已回水',
|
||||
},
|
||||
auth: {
|
||||
login: '登录',
|
||||
@@ -75,6 +76,21 @@ const i18n = createI18n({
|
||||
captcha_placeholder: 'Captcha',
|
||||
captcha_refresh: '点击换一张',
|
||||
captcha_wrong: '验证码错误',
|
||||
login_required: '请先登录',
|
||||
login_hint: '登录后可下注及访问更多功能',
|
||||
go_login: '去登录',
|
||||
continue_browsing: '暂不登录,继续浏览',
|
||||
username_placeholder: '请输入账号',
|
||||
password_placeholder: '请输入密码',
|
||||
login_btn: '登录',
|
||||
login_failed: '登录失败,请重试',
|
||||
},
|
||||
support: {
|
||||
short: '客服',
|
||||
title: '在线客服',
|
||||
open: '打开在线客服',
|
||||
close: '关闭',
|
||||
url_pending: '客服链接暂未配置,请联系管理员。',
|
||||
},
|
||||
wallet: {
|
||||
balance: '余额',
|
||||
@@ -181,7 +197,7 @@ const i18n = createI18n({
|
||||
parlay_guide_help: '查看串关说明',
|
||||
parlay_desc: '选择 2–5 场赛前赛事组合串关(2 串 1 至 5 串 1)。赔率相乘,不含滚球、冠军盘与四分盘让球/大小。',
|
||||
parlay_guide_1: '在列表中点击各场赔率,选中项显示金边;再点同一项可取消',
|
||||
parlay_guide_2: '须选 2–5 项(可同场多项);冠军盘与四分盘让球/大小不可选',
|
||||
parlay_guide_2: '须选 2–5 项,且须为不同赛事;冠军盘与四分盘让球/大小不可选',
|
||||
parlay_guide_3: '选好后点底部「确认下单」打开投注单,填写金额并提交',
|
||||
parlay_max_legs: '串关最多 5 项',
|
||||
parlay_block_outright: '冠军盘不可串关',
|
||||
@@ -316,7 +332,7 @@ const i18n = createI18n({
|
||||
password_disabled: '当前账号不允许自行修改密码,请联系客服',
|
||||
rules_title: '投注规则',
|
||||
rules_p1: '本平台第一版仅支持足球赛前盘,不含滚球、Cash Out、改单及系统串关。',
|
||||
rules_p2: '串关为 2 串 1 至 5 串 1,同场可多选;冠军盘、四分盘让球/大小不可进入串关。',
|
||||
rules_p2: '串关为 2 串 1 至 5 串 1,每场最多选 1 项;冠军盘、四分盘让球/大小不可进入串关。',
|
||||
rules_p3: '赛果由平台根据官方录入的半场/全场比分结算,结算预览经确认后入账。',
|
||||
rules_p4: '若本说明与后台公告冲突,以最新公告及实际盘口规则为准。',
|
||||
rules_p5: '操作步骤:进入任意赛事详情,点右上角「?」查看玩法说明。',
|
||||
@@ -378,6 +394,7 @@ const i18n = createI18n({
|
||||
stats_push: 'Push',
|
||||
stats_stake: 'Total Stake',
|
||||
stats_return: 'Total Return',
|
||||
cashbacked: 'Cashbacked',
|
||||
},
|
||||
auth:
|
||||
{ login: 'Login',
|
||||
@@ -387,6 +404,21 @@ const i18n = createI18n({
|
||||
captcha_placeholder: 'Captcha',
|
||||
captcha_refresh: 'Click to refresh',
|
||||
captcha_wrong: 'Invalid captcha',
|
||||
login_required: 'Login Required',
|
||||
login_hint: 'Log in to place bets and access more features',
|
||||
go_login: 'Go to login',
|
||||
continue_browsing: 'Continue browsing',
|
||||
username_placeholder: 'Enter username',
|
||||
password_placeholder: 'Enter password',
|
||||
login_btn: 'Log In',
|
||||
login_failed: 'Login failed, please try again',
|
||||
},
|
||||
support: {
|
||||
short: 'Support',
|
||||
title: 'Customer Support',
|
||||
open: 'Open customer support',
|
||||
close: 'Close',
|
||||
url_pending: 'Support URL is not configured yet.',
|
||||
},
|
||||
wallet: {
|
||||
balance: 'Balance',
|
||||
@@ -493,7 +525,7 @@ const i18n = createI18n({
|
||||
parlay_guide_help: 'Parlay help',
|
||||
parlay_desc: 'Combine 2–5 pre-match legs (2-fold to 5-fold). No live, outright, or quarter-ball HDP/O-U in parlay.',
|
||||
parlay_guide_1: 'Tap odds in the list; selected cells show a gold border. Tap again to remove',
|
||||
parlay_guide_2: 'Pick 2–5 legs (same match allowed). No outright or quarter-ball HDP/O-U',
|
||||
parlay_guide_2: 'Pick 2–5 legs from different matches. No outright or quarter-ball HDP/O-U',
|
||||
parlay_guide_3: 'Tap Confirm order at the bottom, enter stake in the bet slip, and submit',
|
||||
parlay_max_legs: 'Parlay allows up to 5 legs',
|
||||
parlay_block_outright: 'Outright cannot be parlayed',
|
||||
@@ -628,7 +660,7 @@ const i18n = createI18n({
|
||||
password_disabled: 'Password change is disabled for this account; contact support',
|
||||
rules_title: 'Betting Rules',
|
||||
rules_p1: 'Football pre-match only in v1. No live betting, Cash Out, bet edits, or system parlays.',
|
||||
rules_p2: 'Parlays: 2–5 legs, same-match multi-select allowed. Outright and quarter-ball HDP/O-U are excluded.',
|
||||
rules_p2: 'Parlays: 2–5 legs from different matches (one per match). Outright and quarter-ball HDP/O-U are excluded.',
|
||||
rules_p3: 'Results use admin-entered half-time and full-time scores; payouts apply after settlement preview is confirmed.',
|
||||
rules_p4: 'If this text conflicts with site notices, the latest notice and market rules prevail.',
|
||||
rules_p5: 'How to bet: open any match, tap the ? icon on the top right.',
|
||||
@@ -696,6 +728,7 @@ const i18n = createI18n({
|
||||
stats_push: 'Seri',
|
||||
stats_stake: 'Jumlah Taruhan',
|
||||
stats_return: 'Jumlah Pulangan',
|
||||
cashbacked: 'Rebat dibayar',
|
||||
},
|
||||
auth: {
|
||||
login: 'Log Masuk',
|
||||
@@ -705,6 +738,21 @@ const i18n = createI18n({
|
||||
captcha_placeholder: 'Captcha',
|
||||
captcha_refresh: 'Klik untuk muat semula',
|
||||
captcha_wrong: 'Kod pengesahan salah',
|
||||
login_required: 'Sila Log Masuk',
|
||||
login_hint: 'Log masuk untuk bertaruh dan akses lebih banyak ciri',
|
||||
go_login: 'Pergi log masuk',
|
||||
continue_browsing: 'Teruskan melayari',
|
||||
username_placeholder: 'Masukkan nama pengguna',
|
||||
password_placeholder: 'Masukkan kata laluan',
|
||||
login_btn: 'Log Masuk',
|
||||
login_failed: 'Log masuk gagal, sila cuba lagi',
|
||||
},
|
||||
support: {
|
||||
short: 'Sokongan',
|
||||
title: 'Khidmat Pelanggan',
|
||||
open: 'Buka khidmat pelanggan',
|
||||
close: 'Tutup',
|
||||
url_pending: 'Pautan khidmat pelanggan belum dikonfigurasi.',
|
||||
},
|
||||
wallet: {
|
||||
balance: 'Baki',
|
||||
@@ -811,7 +859,7 @@ const i18n = createI18n({
|
||||
parlay_guide_help: 'Bantuan parlay',
|
||||
parlay_desc: 'Gabung 2–5 perlawanan pra-perlawanan (2 hingga 5 liputan). Tiada live, outright atau suku bola HDP/O-U.',
|
||||
parlay_guide_1: 'Ketik odds dalam senarai; pilihan dipilih ada sempadan emas. Ketik lagi untuk batal',
|
||||
parlay_guide_2: 'Pilih 2–5 pilihan (boleh perlawanan sama). Tiada outright atau suku bola HDP/O-U',
|
||||
parlay_guide_2: 'Pilih 2–5 pilihan dari perlawanan berbeza. Tiada outright atau suku bola HDP/O-U',
|
||||
parlay_guide_3: 'Ketik Sahkan pesanan di bawah, isi pegangan dalam slip, dan hantar',
|
||||
parlay_max_legs: 'Maksimum 5 pilihan parlay',
|
||||
parlay_block_outright: 'Outright tidak boleh parlay',
|
||||
@@ -946,7 +994,7 @@ const i18n = createI18n({
|
||||
password_disabled: 'Akaun ini tidak dibenarkan tukar kata laluan; hubungi sokongan',
|
||||
rules_title: 'Peraturan Pertaruhan',
|
||||
rules_p1: 'Versi pertama: hanya bola sepak pra-perlawanan. Tiada live, Cash Out, edit pertaruhan atau parlay sistem.',
|
||||
rules_p2: 'Parlay 2–5 pilihan, boleh pilih berbilang dari perlawanan sama. Outright dan suku bola HDP/O-U tidak boleh parlay.',
|
||||
rules_p2: 'Parlay 2–5 pilihan, satu pilihan setiap perlawanan. Outright dan suku bola HDP/O-U tidak boleh parlay.',
|
||||
rules_p3: 'Keputusan berdasarkan skor separuh masa/penuh yang dimasukkan admin; bayaran selepas pratonton disahkan.',
|
||||
rules_p4: 'Jika bercanggah dengan notis laman, ikut notis terkini dan peraturan pasaran.',
|
||||
rules_p5: 'Langkah operasi: buka butiran perlawanan, ketik ikon ? di atas kanan.',
|
||||
|
||||
@@ -8,21 +8,22 @@ const router = createRouter({
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../layouts/MainLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
// 公开页面 — 无需登录即可浏览
|
||||
{ path: '', component: () => import('../views/HomeView.vue'), meta: { keepAlive: true } },
|
||||
{ path: 'bet', component: () => import('../views/FootballView.vue'), meta: { keepAlive: true } },
|
||||
{ path: 'football', redirect: '/bet' },
|
||||
{ path: 'match/:id', component: () => import('../views/MatchDetailView.vue') },
|
||||
{ path: 'bets', component: () => import('../views/MyBetsView.vue'), meta: { keepAlive: true } },
|
||||
{ path: 'bets/:betNo', component: () => import('../views/BetDetailView.vue') },
|
||||
{ path: 'wallet', component: () => import('../views/WalletView.vue'), meta: { keepAlive: true } },
|
||||
{ path: 'wallet/detail', component: () => import('../views/WalletDetailView.vue') },
|
||||
{ path: 'wallet/cashbacks', component: () => import('../views/CashbackRecordsView.vue') },
|
||||
{ path: 'wallet/transactions/:transactionId', component: () => import('../views/WalletTransactionDetailView.vue') },
|
||||
{ path: 'profile', component: () => import('../views/ProfileView.vue'), meta: { keepAlive: true } },
|
||||
{ path: 'profile/cashbacks', component: () => import('../views/CashbackRecordsView.vue') },
|
||||
{ path: 'profile/edit', component: () => import('../views/ProfileEditView.vue') },
|
||||
// 需要登录的页面
|
||||
{ path: 'bets', component: () => import('../views/MyBetsView.vue'), meta: { keepAlive: true, requiresAuth: true } },
|
||||
{ path: 'bets/:betNo', component: () => import('../views/BetDetailView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'wallet', component: () => import('../views/WalletView.vue'), meta: { keepAlive: true, requiresAuth: true } },
|
||||
{ path: 'wallet/detail', component: () => import('../views/WalletDetailView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'wallet/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'wallet/transactions/:transactionId', component: () => import('../views/WalletTransactionDetailView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'profile', component: () => import('../views/ProfileView.vue'), meta: { keepAlive: true, requiresAuth: true } },
|
||||
{ path: 'profile/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'profile/edit', component: () => import('../views/ProfileEditView.vue'), meta: { requiresAuth: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -30,8 +31,12 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore();
|
||||
if (to.meta.requiresAuth && !auth.token) return '/login';
|
||||
if (to.path === '/login' && auth.token) return '/';
|
||||
// 需要登录的页面 — 未登录时弹出登录提示,留在当前页
|
||||
if (to.meta.requiresAuth && !auth.token) {
|
||||
auth.showLoginPrompt(to.fullPath);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -8,12 +8,28 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
JSON.parse(localStorage.getItem('user') || 'null'),
|
||||
);
|
||||
|
||||
const loginPromptVisible = ref(false);
|
||||
const loginReturnTo = ref('');
|
||||
|
||||
function showLoginPrompt(returnTo?: string) {
|
||||
loginReturnTo.value = returnTo || '';
|
||||
loginPromptVisible.value = true;
|
||||
}
|
||||
|
||||
function hideLoginPrompt() {
|
||||
loginPromptVisible.value = false;
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
const { data } = await api.post('/player/auth/login', { username, password });
|
||||
token.value = data.data.token;
|
||||
user.value = data.data.user;
|
||||
localStorage.setItem('token', token.value);
|
||||
localStorage.setItem('user', JSON.stringify(user.value));
|
||||
const returnTo = loginReturnTo.value;
|
||||
loginReturnTo.value = '';
|
||||
loginPromptVisible.value = false;
|
||||
return returnTo;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
@@ -23,5 +39,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
return { token, user, login, logout };
|
||||
return {
|
||||
token, user, login, logout,
|
||||
loginPromptVisible, loginReturnTo,
|
||||
showLoginPrompt, hideLoginPrompt,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
items.value.push(item);
|
||||
}
|
||||
|
||||
/** 串关页:同场/跨场均可多选,合成一张串关单 */
|
||||
/** 串关页:须为不同赛事,每场最多 1 项 */
|
||||
function addParlayLeg(item: SlipItem): ParlayRejectReason | 'MAX_LEGS' | null {
|
||||
if (mode.value === 'single') items.value = [];
|
||||
mode.value = 'parlay';
|
||||
@@ -62,6 +62,11 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (items.value.some((i) => i.matchId === item.matchId)) {
|
||||
lastParlayError.value = 'SAME_MATCH';
|
||||
return 'SAME_MATCH';
|
||||
}
|
||||
|
||||
const check = canSelectForParlay({
|
||||
marketType: item.marketType,
|
||||
lineValue: parseLineValue(item.lineValue),
|
||||
@@ -115,7 +120,8 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
() =>
|
||||
mode.value === 'parlay' &&
|
||||
items.value.length >= PARLAY_MIN_LEGS &&
|
||||
items.value.length <= PARLAY_MAX_LEGS,
|
||||
items.value.length <= PARLAY_MAX_LEGS &&
|
||||
!hasSameMatch.value,
|
||||
);
|
||||
|
||||
/** 详情页等同场多笔单关(串关模式不走此路径) */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useAppLocale } from '../composables/useAppLocale';
|
||||
@@ -12,6 +12,7 @@ const { t } = useI18n();
|
||||
const { initFromUser } = useAppLocale();
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const captchaRef = ref<InstanceType<typeof RobotVerify> | null>(null);
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
@@ -29,13 +30,19 @@ async function submit() {
|
||||
try {
|
||||
await auth.login(username.value, password.value);
|
||||
initFromUser(auth.user?.locale);
|
||||
router.push('/');
|
||||
const redirectTo = (route.query.redirect as string) || '/';
|
||||
router.push(redirectTo);
|
||||
} catch (e: unknown) {
|
||||
error.value = (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Login failed';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function continueBrowsing() {
|
||||
const redirect = (route.query.redirect as string) || '/';
|
||||
router.push(redirect);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -53,6 +60,9 @@ async function submit() {
|
||||
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
|
||||
{{ t('auth.login') }}
|
||||
</button>
|
||||
<button type="button" class="btn-skip" @click="continueBrowsing">
|
||||
{{ t('auth.continue_browsing') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -111,6 +121,21 @@ label {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-skip {
|
||||
margin-top: 2px;
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-skip:active {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import { useBetSlipStore } from '../stores/betSlip';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import TeamEmblem from '../components/TeamEmblem.vue';
|
||||
import { DETAIL_MARKET_TYPES, MARKET_I18N_KEY } from '../utils/marketCatalog';
|
||||
import MatchBetGuide from '../components/match-detail/MatchBetGuide.vue';
|
||||
@@ -26,6 +27,11 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const slip = useBetSlipStore();
|
||||
const auth = useAuthStore();
|
||||
|
||||
function goLogin() {
|
||||
auth.showLoginPrompt(route.fullPath);
|
||||
}
|
||||
|
||||
interface Market {
|
||||
id: string;
|
||||
@@ -96,7 +102,7 @@ const myBets = ref<MyBet[]>([]);
|
||||
const loadingMyBets = ref(false);
|
||||
|
||||
async function loadMyBets() {
|
||||
if (!match.value) return;
|
||||
if (!match.value || !auth.token) return;
|
||||
loadingMyBets.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/bets?page=1');
|
||||
@@ -241,6 +247,10 @@ async function confirmCorrectScoreBets() {
|
||||
|
||||
async function placeCorrectScoreBets(marketType: string) {
|
||||
if (!bettingOpen.value) return;
|
||||
if (!auth.token) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
const market = marketsByType.value.get(marketType);
|
||||
if (!market || !match.value) return;
|
||||
const entries = market.selections.filter((s) => (correctScoreStakes.value[s.id] ?? 0) > 0);
|
||||
@@ -325,6 +335,10 @@ function onPickSelection(selId: string, marketType: string) {
|
||||
}
|
||||
|
||||
function openBetSlipDrawer() {
|
||||
if (!auth.token) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
slip.openDrawer();
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ async function changeLocale(code: string) {
|
||||
|
||||
function logout() {
|
||||
auth.logout();
|
||||
router.push('/login');
|
||||
router.push('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
272
docs/settlement-and-fund-flow-analysis.md
Normal file
272
docs/settlement-and-fund-flow-analysis.md
Normal file
@@ -0,0 +1,272 @@
|
||||
## 赛事结算全流程与资金链路分析报告
|
||||
|
||||
---
|
||||
|
||||
### 一、赛事生命周期
|
||||
|
||||
比赛经历以下状态流转:
|
||||
|
||||
```
|
||||
DRAFT → PUBLISHED → CLOSED → PENDING_SETTLEMENT → SETTLED
|
||||
↓
|
||||
CANCELLED(取消,全部退款)
|
||||
```
|
||||
|
||||
| 状态转换 | 触发方式 |
|
||||
|---------|---------|
|
||||
| DRAFT → PUBLISHED | 管理员发布比赛 |
|
||||
| PUBLISHED → CLOSED | 每分钟自动 cron 检测开赛时间到达,或管理员手动关闭 |
|
||||
| CLOSED → PENDING_SETTLEMENT | 管理员录入比分 |
|
||||
| PENDING_SETTLEMENT → SETTLED | 管理员确认结算 |
|
||||
| 任意 → CANCELLED | 管理员取消比赛,所有未结算注单全额退款 |
|
||||
|
||||
玩家端看到三种简化状态:`open`(可投注)、`closed_pending`(封盘待结算)、`settled`(已结算)。
|
||||
|
||||
---
|
||||
|
||||
### 二、结算操作流程(三步走)
|
||||
|
||||
结算完全由管理员手动触发,分三个阶段:
|
||||
|
||||
**第一步:录入比分**
|
||||
- 接口:`POST /admin/matches/:id/settlement/score`
|
||||
- 管理员输入全场比分(主队/客队半场和全场进球),冠军盘只需选择获胜队伍
|
||||
- 系统创建 `MatchScore` 记录,比赛状态变为 `PENDING_SETTLEMENT`
|
||||
|
||||
**第二步:预览结算**
|
||||
- 接口:`POST /admin/matches/:id/settlement/preview`
|
||||
- 系统自动计算所有该场次的 PENDING 注单结果(赢/输/走水/半赢/半输)
|
||||
- 创建 `SettlementBatch`(状态 PREVIEW),汇总总注单数、总派彩、总退款
|
||||
- 管理员可分页查看每笔注单的结算预览
|
||||
|
||||
**第三步:确认结算**
|
||||
- 接口:`POST /admin/settlement/:batchId/confirm`
|
||||
- 在单个数据库事务中:逐笔结算、更新钱包、标记比赛为 SETTLED
|
||||
- 结算后触发所有相关代理的信用额度重算
|
||||
|
||||
---
|
||||
|
||||
### 三、单关 vs 串关结算差异
|
||||
|
||||
#### 单关投注(Single Bet)
|
||||
|
||||
每笔注单只有一个选项,直接根据比分计算结果:
|
||||
|
||||
| 结果 | 派彩公式 |
|
||||
|------|---------|
|
||||
| WIN(全赢) | stake × odds |
|
||||
| HALF_WIN(半赢) | stake/2 × odds + stake/2 |
|
||||
| PUSH(走水) | stake(全额退还) |
|
||||
| HALF_LOSE(半输) | stake / 2 |
|
||||
| LOSE(全输) | 0 |
|
||||
|
||||
#### 串关投注(Parlay Bet)
|
||||
|
||||
串关跨多场比赛,结算逻辑更复杂:
|
||||
|
||||
1. **结算某场比赛时**:只计算该场比赛中涉及的"腿"(leg),其他场次的腿等待后续结算
|
||||
2. **所有腿都有结果后**:按串关规则合并计算总赔率
|
||||
3. **任一腿为 LOSE**:整单作废,派彩 = 0
|
||||
4. **全部腿为 PUSH/VOID**:整单走水,退还本金
|
||||
5. **混合结果**:
|
||||
- WIN:乘以该腿赔率
|
||||
- HALF_WIN:乘以 `(odds + 1) / 2`
|
||||
- HALF_LOSE:乘以 `0.5`
|
||||
- PUSH/VOID:乘以 `1.0`(跳过)
|
||||
- 最终派彩 = stake × 所有腿赔率的连乘
|
||||
|
||||
#### 亚盘让球/大小球(Quarter Line)
|
||||
|
||||
对于 `.25` 或 `.75` 盘口,系统拆分为两条半盘独立结算,再合并:
|
||||
- 两半都赢 → WIN
|
||||
- 两半都输 → LOSE
|
||||
- 一赢一平 → HALF_WIN
|
||||
- 一输一平 → HALF_LOSE
|
||||
- 一赢一输 → PUSH
|
||||
|
||||
---
|
||||
|
||||
### 四、支持的盘口类型
|
||||
|
||||
| 盘口类型 | 结算依据 |
|
||||
|---------|---------|
|
||||
| FT_1X2(全场胜平负) | 全场比分 |
|
||||
| HT_1X2(半场胜平负) | 半场比分 |
|
||||
| FT_ODD_EVEN(全场单双) | 全场总进球奇偶(0-0 = 双) |
|
||||
| FT_HANDICAP(全场让球) | 全场比分 + 让球盘 |
|
||||
| HT_HANDICAP(半场让球) | 半场比分 + 让球盘 |
|
||||
| FT_OVER_UNDER(全场大小) | 全场总进球 + 大小盘 |
|
||||
| HT_OVER_UNDER(半场大小) | 半场总进球 + 大小盘 |
|
||||
| FT_CORRECT_SCORE(波胆) | 精确比分匹配 |
|
||||
| HT_CORRECT_SCORE(半场波胆) | 半场精确比分 |
|
||||
| OUTRIGHT_WINNER(冠军) | 获胜队伍编号 |
|
||||
|
||||
---
|
||||
|
||||
### 五、资金链路全景图
|
||||
|
||||
```
|
||||
充值(Admin/Agent → 玩家)
|
||||
┌──────────────────────────────┐
|
||||
│ availableBalance += 金额 │
|
||||
│ 流水: MANUAL_DEPOSIT (+) │
|
||||
│ Agent.usedCredit 重算 │
|
||||
└──────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
下注(玩家 → 系统)
|
||||
┌──────────────────────────────┐
|
||||
│ availableBalance -= stake │
|
||||
│ frozenBalance += stake │
|
||||
│ 流水: BET_FREEZE (-stake) │
|
||||
│ 注单状态: PENDING │
|
||||
└──────────────────────────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
赢了 (WIN) 输了 (LOSE) 走水 (PUSH)
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
|
||||
│ frozen -= stk│ │ frozen -= stk│ │ frozen -= stk │
|
||||
│ avail += stk×odds │ │ avail += 0 │ │ avail += stk │
|
||||
│ 流水: BET_SETTLE_WIN │ │ 流水: BET_SETTLE_LOSE │ │ 流水: BET_SETTLE_PUSH │
|
||||
│ 注单: WON │ │ 注单: LOST │ │ 注单: PUSH │
|
||||
└──────────────┘ └──────────────┘ └──────────────────┘
|
||||
|
||||
提现(玩家 → Admin/Agent)
|
||||
┌──────────────────────────────┐
|
||||
│ availableBalance -= 金额 │
|
||||
│ 流水: MANUAL_WITHDRAW (-) │
|
||||
│ Agent.usedCredit 重算 │
|
||||
└──────────────────────────────┘
|
||||
|
||||
返水/返佣(系统 → 玩家)
|
||||
┌──────────────────────────────┐
|
||||
│ availableBalance += 金额 │
|
||||
│ 流水: CASHBACK_DEPOSIT (+) │
|
||||
│ 金额 = 有效投注额 × 返水率 │
|
||||
└──────────────────────────────┘
|
||||
|
||||
重新结算(比分纠正)
|
||||
┌──────────────────────────────┐
|
||||
│ availableBalance += delta │
|
||||
│ delta = 新派彩 - 旧派彩 │
|
||||
│ 流水: BET_SETTLE_WIN 或 │
|
||||
│ RESETTLE_REVERSE │
|
||||
│ (delta 可为负数,余额可变负) │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 六、钱包模型
|
||||
|
||||
每个玩家有一个钱包,包含两个余额字段:
|
||||
|
||||
- **availableBalance**(可用余额):可自由使用的金额
|
||||
- **frozenBalance**(冻结余额):已下注未结算的金额
|
||||
|
||||
每次余额变动都会创建一条 `WalletTransaction` 记录,包含变动前后的快照(balanceBefore/After, frozenBefore/After),形成完整的审计链。
|
||||
|
||||
#### 所有交易类型
|
||||
|
||||
| 类别 | 类型 | 方向 |
|
||||
|------|------|------|
|
||||
| 充值 | MANUAL_DEPOSIT | 入账 (+) |
|
||||
| 提现 | MANUAL_WITHDRAW | 出账 (-) |
|
||||
| 下注冻结 | BET_FREEZE | available → frozen |
|
||||
| 结算赢 | BET_SETTLE_WIN | frozen 释放 + 派彩入账 |
|
||||
| 结算输 | BET_SETTLE_LOSE | frozen 释放,无入账 |
|
||||
| 结算走水 | BET_SETTLE_PUSH | frozen 释放 + 退还本金 |
|
||||
| 比赛取消退款 | BET_VOID_REFUND | frozen 释放 + 退还本金 |
|
||||
| 重新结算调整 | RESETTLE_REVERSE | delta 调整(可正可负) |
|
||||
| 返水 | CASHBACK_DEPOSIT | 入账 (+) |
|
||||
|
||||
---
|
||||
|
||||
### 七、代理信用体系
|
||||
|
||||
代理(Agent)没有真实钱包,采用**信用额度**模型:
|
||||
|
||||
```
|
||||
可用信用 = creditLimit - usedCredit
|
||||
```
|
||||
|
||||
其中 `usedCredit = directPlayerLiability + childAgentExposure`:
|
||||
- `directPlayerLiability`:所有直属玩家的(可用余额 + 冻结余额)之和
|
||||
- `childAgentExposure`:所有子代理的 max(creditLimit, usedCredit) 之和
|
||||
|
||||
#### 约束规则
|
||||
- 子代理的 creditLimit 不能超过父代理
|
||||
- 子代理的 cashbackRate 不能超过父代理
|
||||
- 给玩家充值前,检查代理可用信用 ≥ 充值金额
|
||||
- 每次充值/提现/结算后都会重算代理信用
|
||||
|
||||
---
|
||||
|
||||
### 八、返水(Cashback)系统
|
||||
|
||||
#### 返水率解析优先级(从高到低)
|
||||
1. 玩家个人规则(targetType = USER)
|
||||
2. 代理规则(targetType = AGENT)
|
||||
3. 全局规则(targetType = GLOBAL)
|
||||
4. 代理默认 cashbackRate
|
||||
|
||||
#### 返水流程
|
||||
1. **预览**:加载周期内所有 WON/LOST 的注单,计算有效投注额 × 返水率
|
||||
2. **确认**:逐笔调用 `wallet.deposit()` 发放返水,交易类型 `CASHBACK_DEPOSIT`
|
||||
3. **取消**:仅 PREVIEW 状态可取消
|
||||
|
||||
#### 返水公式
|
||||
```
|
||||
返水金额 = 有效投注额 × 返水率
|
||||
```
|
||||
有效投注额 = 周期内所有已结算(WON/LOST)注单的投注金额之和
|
||||
|
||||
---
|
||||
|
||||
### 九、安全与并发控制
|
||||
|
||||
| 机制 | 说明 |
|
||||
|------|------|
|
||||
| 行级锁 | 每次钱包操作使用 `SELECT ... FOR UPDATE`,PostgreSQL 悲观锁串行化 |
|
||||
| 幂等性 | 注单表有 `UNIQUE(userId, requestId)` 约束,重复提交直接返回已有注单 |
|
||||
| 乐观版本 | `Wallet.version` 每次变动递增,提供审计轨迹 |
|
||||
| 事务完整性 | 下注+冻结在同一事务;一场比赛所有注单结算在同一事务 |
|
||||
| 余额校验 | 下注前检查 availableBalance ≥ stake,提现前检查 availableBalance ≥ amount |
|
||||
| 重新结算 | delta 可为负数,允许追扣(余额可能变负) |
|
||||
|
||||
---
|
||||
|
||||
### 十、重新结算(纠错机制)
|
||||
|
||||
当管理员发现比分录入错误时:
|
||||
|
||||
1. **预览重新结算**:用新比分重新计算所有注单结果,对比新旧派彩差额
|
||||
2. **确认重新结算**:
|
||||
- delta > 0:补发差额(BET_SETTLE_WIN)
|
||||
- delta < 0:追扣差额(RESETTLE_REVERSE),余额可能变负
|
||||
- 注单标记 `settlementStatus = RESETTLED`
|
||||
- 代理信用重算
|
||||
|
||||
---
|
||||
|
||||
### 十一、典型流程示例
|
||||
|
||||
**示例:玩家投注主胜 100 元,赔率 2.0**
|
||||
|
||||
| 步骤 | availableBalance | frozenBalance | 说明 |
|
||||
|------|-----------------|---------------|------|
|
||||
| 初始 | 1,000 | 0 | - |
|
||||
| 下注 | 900 | 100 | BET_FREEZE |
|
||||
| 结算(赢) | 1,100 | 0 | BET_SETTLE_WIN: +200 |
|
||||
| 净收益 | +100 | - | 派彩 200 - 本金 100 |
|
||||
|
||||
**示例:串关 3 串 1,本金 100 元,赔率 2.0 × 3.0 × 1.5 = 9.0**
|
||||
|
||||
| 步骤 | 说明 |
|
||||
|------|------|
|
||||
| 下注 | 冻结 100 元 |
|
||||
| 第一场结算(赢) | 该腿 WIN,注单仍 PENDING |
|
||||
| 第二场结算(输) | 该腿 LOSE,整单 LOST,派彩 = 0 |
|
||||
| 第三场 | 无需结算,注单已终结 |
|
||||
| 结果 | 冻结释放,派彩 0,玩家亏损 100 元 |
|
||||
@@ -204,6 +204,11 @@ export const API_ERROR_MESSAGES = {
|
||||
'en-US': 'Market not allowed in parlay',
|
||||
'ms-MY': 'Pasaran tidak dibenarkan dalam parlay',
|
||||
},
|
||||
PARLAY_SAME_MATCH_FORBIDDEN: {
|
||||
'zh-CN': '同一场比赛不能串关',
|
||||
'en-US': 'Cannot parlay selections from the same match',
|
||||
'ms-MY': 'Perlawanan sama tidak boleh berganda',
|
||||
},
|
||||
BET_NOT_FOUND: {
|
||||
'zh-CN': '注单不存在',
|
||||
'en-US': 'Bet not found',
|
||||
|
||||
@@ -22,7 +22,20 @@ export const HANDICAP_TOTAL_MARKET_TYPES = [
|
||||
'HT_OVER_UNDER',
|
||||
] as const;
|
||||
|
||||
export type ParlayRejectReason = 'OUTRIGHT' | 'NOT_ALLOWED' | 'QUARTER_LINE';
|
||||
export type ParlayRejectReason = 'OUTRIGHT' | 'NOT_ALLOWED' | 'QUARTER_LINE' | 'SAME_MATCH';
|
||||
|
||||
export function hasDuplicateParlayMatch(
|
||||
matchIds: Array<string | bigint | null | undefined>,
|
||||
): boolean {
|
||||
const seen = new Set<string>();
|
||||
for (const id of matchIds) {
|
||||
if (id == null) continue;
|
||||
const key = String(id);
|
||||
if (seen.has(key)) return true;
|
||||
seen.add(key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isQuarterLine(line: number | null | undefined): boolean {
|
||||
if (line == null || Number.isNaN(line)) return false;
|
||||
|
||||
Reference in New Issue
Block a user