feat: 前台匿名浏览、登录引导、客服入口与返水增强
前台: - 未登录可浏览首页/赛事/赔率,下注等操作弹出登录引导(去登录/继续浏览) - 顶部新增客服入口与 iframe 弹窗 - 登录页支持暂不登录返回浏览 API: - 首页/赛事/冠军盘接口改为公开访问,支持 X-Locale 头 - JWT 守卫支持可选认证 返水: - 注单新增 is_cashbacked 字段,发放时自动标记 - 预览展示玩家余额,明确平台直发不从代理扣款 - 后台注单列表与玩家历史展示回水状态 其他: - 串关禁止同场重复选号(SAME_MATCH) - 补充结算资金流分析文档 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user