feat(player): 注册账号、登录双模式与移动端性能优化

注册必填 7-32 位账号,手机号区号/本地号分存;登录默认账号模式并支持切换手机号登录;Player i18n 拆包与赛事接口优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 10:56:51 +08:00
parent 83f0f380c5
commit 312c3c5816
35 changed files with 1944 additions and 1394 deletions

View File

@@ -0,0 +1,26 @@
ALTER TABLE "user_preferences"
ADD COLUMN IF NOT EXISTS "phone_country_dial" VARCHAR(8),
ADD COLUMN IF NOT EXISTS "phone_local" VARCHAR(24);
CREATE INDEX IF NOT EXISTS "user_preferences_phone_country_dial_phone_local_idx"
ON "user_preferences" ("phone_country_dial", "phone_local");
-- 回填已有手机号的区号/本地号username 为纯数字国际号码时)
UPDATE "user_preferences" up
SET
"phone_country_dial" = matched.dial,
"phone_local" = SUBSTRING(u.username FROM (LENGTH(matched.dial) + 1))
FROM "users" u
JOIN LATERAL (
SELECT dial FROM (
VALUES ('880'), ('886'), ('60'), ('65'), ('66'), ('84'), ('91'), ('61')
) AS d(dial)
WHERE u.username LIKE d.dial || '%'
ORDER BY LENGTH(d.dial) DESC
LIMIT 1
) matched ON TRUE
WHERE up.user_id = u.id
AND up.phone IS NOT NULL
AND up.phone_country_dial IS NULL
AND u.username ~ '^[0-9]+$'
AND LENGTH(u.username) >= 10;

View File

@@ -85,6 +85,8 @@ model UserPreference {
userId BigInt @unique @map("user_id")
locale String @default("en-US") @db.VarChar(10)
phone String? @db.VarChar(32)
phoneCountryDial String? @map("phone_country_dial") @db.VarChar(8)
phoneLocal String? @map("phone_local") @db.VarChar(24)
email String? @db.VarChar(128)
avatarKey String? @map("avatar_key") @db.VarChar(128)
allowPasswordChange Boolean @default(true) @map("allow_password_change")
@@ -95,6 +97,7 @@ model UserPreference {
user User @relation(fields: [userId], references: [id])
@@index([phoneCountryDial, phoneLocal])
@@map("user_preferences")
}

View File

@@ -162,12 +162,24 @@ export class PlayerController {
@Headers('x-locale') headerLocale?: string,
) {
const locale = userLocale || headerLocale || 'zh-CN';
const [banners, announcements, hotMatches, todayMatches] = await Promise.all([
const [banners, announcements, allMatches] = await Promise.all([
this.content.listActive('BANNER', locale),
this.content.listActiveAnnouncements(locale),
this.matches.listPublished(locale),
this.matches.listPublished(locale),
this.matches.listPublished(locale, undefined, { includeMarkets: false }),
]);
const now = new Date();
const isKickoffToday = (startTime: string) => {
const d = new Date(startTime);
return (
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate()
);
};
const hotMatches = (allMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot);
const todayMatches = (allMatches as Array<{ startTime: string }>).filter((m) =>
isKickoffToday(m.startTime),
);
return jsonResponse({
banners,
announcements,
@@ -175,7 +187,7 @@ export class PlayerController {
ticker: announcements,
/** @deprecated 使用 announcements */
notices: announcements,
hotMatches: (hotMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot),
hotMatches,
todayMatches,
});
}
@@ -186,9 +198,18 @@ export class PlayerController {
@CurrentUser('locale') userLocale: string | undefined,
@Headers('x-locale') headerLocale: string | undefined,
@Query('leagueId') leagueId?: string,
@Query('scope') scope?: string,
) {
const locale = userLocale || headerLocale || 'zh-CN';
const items = await this.matches.listPublished(locale, leagueId ? BigInt(leagueId) : undefined);
const lid = leagueId ? BigInt(leagueId) : undefined;
if (scope === 'parlay') {
const items = await this.matches.listPublished(locale, lid, {
includeMarkets: true,
parlayMarketsOnly: true,
});
return jsonResponse(items);
}
const items = await this.matches.listPublished(locale, lid, { includeMarkets: false });
return jsonResponse(items);
}
@@ -246,8 +267,15 @@ export class PlayerController {
@CurrentUser('locale') locale: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('matchId') matchId?: string,
) {
const result = await this.bets.getUserBets(userId, status, page ? parseInt(page) : 1);
const result = await this.bets.getUserBets(
userId,
status,
page ? parseInt(page, 10) : 1,
20,
matchId ? BigInt(matchId) : undefined,
);
const items = await this.matches.enrichBetsForHistory(result.items, locale);
return jsonResponse({ ...result, items });
}

View File

@@ -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({

View File

@@ -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') {

View File

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

View File

@@ -18,6 +18,11 @@ export class LoginDto {
}
export class RegisterDto {
@ApiProperty({ description: '登录账号7-32 位字母数字' })
@IsString()
@MinLength(7)
username!: string;
@ApiProperty({ description: '本地手机号,不含国家区号' })
@IsString()
phone!: string;

View File

@@ -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) {

View File

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