feat(player): 注册账号、登录双模式与移动端性能优化
注册必填 7-32 位账号,手机号区号/本地号分存;登录默认账号模式并支持切换手机号登录;Player i18n 拆包与赛事接口优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -238,8 +238,18 @@ export class BetsService {
|
||||
return bet;
|
||||
}
|
||||
|
||||
async getUserBets(userId: bigint, status?: string, page = 1, pageSize = 20) {
|
||||
const where = { userId, ...(status ? { status } : {}) };
|
||||
async getUserBets(
|
||||
userId: bigint,
|
||||
status?: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
matchId?: bigint,
|
||||
) {
|
||||
const where = {
|
||||
userId,
|
||||
...(status ? { status } : {}),
|
||||
...(matchId ? { selections: { some: { matchId } } } : {}),
|
||||
};
|
||||
const skip = (page - 1) * pageSize;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.bet.findMany({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isPreMatchKickoff, resolveTranslationFallback } from '@thebet365/shared';
|
||||
import { isPreMatchKickoff, MarketType, resolveTranslationFallback } from '@thebet365/shared';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
@@ -26,6 +26,23 @@ import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
|
||||
|
||||
const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
|
||||
|
||||
const PLAYER_PARLAY_MARKET_TYPES = [
|
||||
MarketType.FT_HANDICAP,
|
||||
MarketType.FT_OVER_UNDER,
|
||||
MarketType.FT_1X2,
|
||||
MarketType.FT_ODD_EVEN,
|
||||
MarketType.HT_HANDICAP,
|
||||
MarketType.HT_OVER_UNDER,
|
||||
MarketType.HT_1X2,
|
||||
] as const;
|
||||
|
||||
export type ListPublishedOptions = {
|
||||
/** 响应中是否包含 markets(列表页默认 false,仅摘要) */
|
||||
includeMarkets?: boolean;
|
||||
/** 仅返回串关玩法 markets(需 includeMarkets=true) */
|
||||
parlayMarketsOnly?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MatchesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -1158,7 +1175,11 @@ export class MatchesService {
|
||||
return resolveTranslationFallback(map, locale);
|
||||
}
|
||||
|
||||
async enrichMatch(match: Record<string, unknown>, locale: string) {
|
||||
async enrichMatch(
|
||||
match: Record<string, unknown>,
|
||||
locale: string,
|
||||
options?: { omitMarkets?: boolean },
|
||||
) {
|
||||
const m = match as {
|
||||
id: bigint;
|
||||
leagueId: bigint;
|
||||
@@ -1230,7 +1251,7 @@ export class MatchesService {
|
||||
}>,
|
||||
}),
|
||||
};
|
||||
if (m.markets) {
|
||||
if (m.markets && !options?.omitMarkets) {
|
||||
return {
|
||||
...base,
|
||||
markets: m.markets.map((market) => ({
|
||||
@@ -1302,7 +1323,41 @@ export class MatchesService {
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
};
|
||||
|
||||
async listPublished(locale = 'en-US', leagueId?: bigint) {
|
||||
/** 仅 status 字段,用于列表页计算 bettingOpen,不返回给客户端 */
|
||||
private playerMarketStatusInclude = {
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||||
select: {
|
||||
status: true,
|
||||
selections: { select: { status: true } },
|
||||
},
|
||||
};
|
||||
|
||||
async listPublished(
|
||||
locale = 'en-US',
|
||||
leagueId?: bigint,
|
||||
options?: ListPublishedOptions,
|
||||
) {
|
||||
const includeMarkets = options?.includeMarkets ?? true;
|
||||
const parlayMarketsOnly = options?.parlayMarketsOnly ?? false;
|
||||
|
||||
const marketWhere = {
|
||||
status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] },
|
||||
...(parlayMarketsOnly ? { marketType: { in: [...PLAYER_PARLAY_MARKET_TYPES] } } : {}),
|
||||
};
|
||||
|
||||
const marketsRelation = includeMarkets
|
||||
? {
|
||||
where: marketWhere,
|
||||
include: {
|
||||
selections: {
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
},
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
}
|
||||
: this.playerMarketStatusInclude;
|
||||
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: {
|
||||
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT', 'SETTLED'] },
|
||||
@@ -1317,12 +1372,22 @@ export class MatchesService {
|
||||
homeTeam: true,
|
||||
awayTeam: true,
|
||||
score: true,
|
||||
markets: this.playerMarketInclude,
|
||||
markets: marketsRelation,
|
||||
},
|
||||
orderBy: [{ isHot: 'desc' }, { displayOrder: 'asc' }, { startTime: 'asc' }],
|
||||
});
|
||||
|
||||
return Promise.all(matches.map((m) => this.enrichMatch(m, locale)));
|
||||
const enriched = await Promise.all(
|
||||
matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: !includeMarkets })),
|
||||
);
|
||||
|
||||
if (!includeMarkets || !parlayMarketsOnly) return enriched;
|
||||
|
||||
return enriched.filter(
|
||||
(m) =>
|
||||
Array.isArray((m as { markets?: unknown[] }).markets) &&
|
||||
(m as { markets: unknown[] }).markets.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
async getMatchDetail(matchId: bigint, locale = 'en-US') {
|
||||
|
||||
@@ -33,6 +33,7 @@ export class AuthController {
|
||||
@Post('player/auth/register')
|
||||
async playerRegister(@Body() dto: RegisterDto) {
|
||||
const result = await this.auth.registerPlayer({
|
||||
username: dto.username,
|
||||
phone: dto.phone,
|
||||
countryCode: dto.countryCode,
|
||||
password: dto.password,
|
||||
|
||||
@@ -18,6 +18,11 @@ export class LoginDto {
|
||||
}
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ description: '登录账号,7-32 位字母数字' })
|
||||
@IsString()
|
||||
@MinLength(7)
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({ description: '本地手机号,不含国家区号' })
|
||||
@IsString()
|
||||
phone!: string;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { InvitesService } from './invites.service';
|
||||
import { SmsService } from './sms/sms.service';
|
||||
import { normalizePhone, resolvePlayerLoginUsername } from './sms/phone.util';
|
||||
import { normalizePhone, resolvePlayerLoginCandidates, stripLocalPhoneDigits } from './sms/phone.util';
|
||||
|
||||
const MAX_LOGIN_FAILS = 5;
|
||||
const LOCK_DURATION_MS = 15 * 60 * 1000;
|
||||
@@ -51,15 +51,51 @@ export class AuthService {
|
||||
portal: 'player' | 'admin' | 'agent',
|
||||
countryCode?: string,
|
||||
) {
|
||||
const lookupUsername =
|
||||
portal === 'player'
|
||||
? resolvePlayerLoginUsername(username, countryCode)
|
||||
: username.trim();
|
||||
let user;
|
||||
if (portal === 'player') {
|
||||
const trimmed = username.trim();
|
||||
if (/[a-zA-Z]/.test(trimmed)) {
|
||||
user = await this.prisma.user.findUnique({
|
||||
where: { username: trimmed },
|
||||
include: { auth: true, adminRole: { include: { role: true } } },
|
||||
});
|
||||
} else {
|
||||
const candidates = resolvePlayerLoginCandidates(username, countryCode);
|
||||
let matches = await this.prisma.user.findMany({
|
||||
where: {
|
||||
username: { in: candidates },
|
||||
userType: 'PLAYER',
|
||||
deletedAt: null,
|
||||
},
|
||||
include: { auth: true, adminRole: { include: { role: true } } },
|
||||
});
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { username: lookupUsername },
|
||||
include: { auth: true, adminRole: { include: { role: true } } },
|
||||
});
|
||||
if (matches.length === 0 && countryCode?.trim()) {
|
||||
const dial = countryCode.replace(/\D/g, '');
|
||||
const local = stripLocalPhoneDigits(username);
|
||||
matches = await this.prisma.user.findMany({
|
||||
where: {
|
||||
userType: 'PLAYER',
|
||||
deletedAt: null,
|
||||
preferences: {
|
||||
phoneCountryDial: dial,
|
||||
phoneLocal: local,
|
||||
},
|
||||
},
|
||||
include: { auth: true, adminRole: { include: { role: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
if (matches.length === 1) {
|
||||
user = matches[0];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
user = await this.prisma.user.findUnique({
|
||||
where: { username: username.trim() },
|
||||
include: { auth: true, adminRole: { include: { role: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
if (!user || !user.auth) {
|
||||
throw appUnauthorized('INVALID_CREDENTIALS');
|
||||
@@ -156,6 +192,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async registerPlayer(data: {
|
||||
username: string;
|
||||
phone: string;
|
||||
countryCode: string;
|
||||
password: string;
|
||||
@@ -164,14 +201,20 @@ export class AuthService {
|
||||
inviteCode?: string;
|
||||
locale?: string;
|
||||
}) {
|
||||
const phone = normalizePhone(data.countryCode, data.phone);
|
||||
const username = phone;
|
||||
|
||||
const username = data.username.trim();
|
||||
if (!username) {
|
||||
throw appBadRequest('USERNAME_REQUIRED');
|
||||
}
|
||||
try {
|
||||
assertPlayerUsername(username);
|
||||
} catch {
|
||||
throw appBadRequest('PHONE_INVALID');
|
||||
throw appBadRequest('USERNAME_FORMAT_INVALID');
|
||||
}
|
||||
|
||||
const dial = data.countryCode.replace(/\D/g, '');
|
||||
const phoneLocal = stripLocalPhoneDigits(data.phone);
|
||||
const phone = normalizePhone(dial, data.phone);
|
||||
|
||||
if (!data.password || data.password.length < 8) {
|
||||
throw appBadRequest('PASSWORD_MIN_LENGTH');
|
||||
}
|
||||
@@ -189,11 +232,23 @@ export class AuthService {
|
||||
const { parentId, sponsorId, inviteId } = await this.resolveInviteSponsor(data.inviteCode);
|
||||
const inviteSponsorId = parentId == null && sponsorId != null ? sponsorId : null;
|
||||
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { username },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
if (existingUser) {
|
||||
throw appBadRequest('USERNAME_TAKEN');
|
||||
}
|
||||
|
||||
const existingPhone = await this.prisma.userPreference.findFirst({
|
||||
where: {
|
||||
phoneCountryDial: dial,
|
||||
phoneLocal,
|
||||
user: { deletedAt: null, userType: 'PLAYER' },
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
if (existingPhone) {
|
||||
throw appBadRequest('PHONE_TAKEN');
|
||||
}
|
||||
|
||||
@@ -220,7 +275,13 @@ export class AuthService {
|
||||
});
|
||||
|
||||
await tx.userPreference.create({
|
||||
data: { userId: created.id, locale, phone },
|
||||
data: {
|
||||
userId: created.id,
|
||||
locale,
|
||||
phone,
|
||||
phoneCountryDial: dial,
|
||||
phoneLocal,
|
||||
},
|
||||
});
|
||||
|
||||
if (inviteId) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { isSupportedPhoneDial } from '@thebet365/shared';
|
||||
/** 归一化为创蓝格式:国家区号 + 本地号码(纯数字,无 +) */
|
||||
export function normalizePhone(countryDial: string, localInput: string): string {
|
||||
const dial = countryDial.replace(/\D/g, '');
|
||||
let local = localInput.replace(/\D/g, '');
|
||||
let local = stripLocalPhoneDigits(localInput);
|
||||
|
||||
if (!dial || !local) {
|
||||
throw appBadRequest('PHONE_REQUIRED');
|
||||
@@ -13,11 +13,6 @@ export function normalizePhone(countryDial: string, localInput: string): string
|
||||
throw appBadRequest('PHONE_COUNTRY_UNSUPPORTED');
|
||||
}
|
||||
|
||||
// 马来西亚等本地号常以 0 开头
|
||||
if (local.startsWith('0') && local.length > 1) {
|
||||
local = local.replace(/^0+/, '');
|
||||
}
|
||||
|
||||
const combined = `${dial}${local}`;
|
||||
if (combined.length < 10 || combined.length > 15) {
|
||||
throw appBadRequest('PHONE_INVALID');
|
||||
@@ -25,6 +20,15 @@ export function normalizePhone(countryDial: string, localInput: string): string
|
||||
return combined;
|
||||
}
|
||||
|
||||
/** 提取本地号码(纯数字,去掉前导 0) */
|
||||
export function stripLocalPhoneDigits(localInput: string): string {
|
||||
let local = localInput.replace(/\D/g, '');
|
||||
if (local.startsWith('0') && local.length > 1) {
|
||||
local = local.replace(/^0+/, '');
|
||||
}
|
||||
return local;
|
||||
}
|
||||
|
||||
/** 玩家登录:选国家 + 本地号;字母账号(如 player1)原样返回 */
|
||||
export function resolvePlayerLoginUsername(
|
||||
input: string,
|
||||
@@ -38,6 +42,15 @@ export function resolvePlayerLoginUsername(
|
||||
}
|
||||
|
||||
if (countryDial?.trim()) {
|
||||
const dial = countryDial.replace(/\D/g, '');
|
||||
let digits = trimmed.replace(/\D/g, '');
|
||||
if (digits.startsWith('0') && digits.length > 1) {
|
||||
digits = digits.replace(/^0+/, '');
|
||||
}
|
||||
// 已输入含区号完整号码时,不再重复拼接
|
||||
if (dial && digits.startsWith(dial) && digits.length >= 10) {
|
||||
return digits;
|
||||
}
|
||||
try {
|
||||
return normalizePhone(countryDial, trimmed);
|
||||
} catch {
|
||||
@@ -50,3 +63,27 @@ export function resolvePlayerLoginUsername(
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** 玩家登录:本地号时在开放国家中生成候选完整号码;字母账号原样返回 */
|
||||
export function resolvePlayerLoginCandidates(
|
||||
input: string,
|
||||
countryDial?: string,
|
||||
): string[] {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
if (!/^[\d+\s\-()]+$/.test(trimmed)) {
|
||||
return [trimmed];
|
||||
}
|
||||
|
||||
if (countryDial?.trim()) {
|
||||
return [resolvePlayerLoginUsername(input, countryDial)];
|
||||
}
|
||||
|
||||
const digits = trimmed.replace(/\D/g, '');
|
||||
if (digits.length >= 10) {
|
||||
return [digits];
|
||||
}
|
||||
|
||||
return [digits];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user