feat: 前台匿名浏览、登录引导、客服入口与返水增强

前台:
- 未登录可浏览首页/赛事/赔率,下注等操作弹出登录引导(去登录/继续浏览)
- 顶部新增客服入口与 iframe 弹窗
- 登录页支持暂不登录返回浏览

API:
- 首页/赛事/冠军盘接口改为公开访问,支持 X-Locale 头
- JWT 守卫支持可选认证

返水:
- 注单新增 is_cashbacked 字段,发放时自动标记
- 预览展示玩家余额,明确平台直发不从代理扣款
- 后台注单列表与玩家历史展示回水状态

其他:
- 串关禁止同场重复选号(SAME_MATCH)
- 补充结算资金流分析文档

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 09:36:44 +08:00
parent 785fa4416d
commit 844727c82e
35 changed files with 1007 additions and 49 deletions

View File

@@ -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);
}

View File

@@ -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,
};
}

View File

@@ -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 ?? '',

View File

@@ -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;

View File

@@ -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 },

View File

@@ -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',

View File

@@ -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 (25 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', () => {