feat(player): 注册账号、登录双模式与移动端性能优化
注册必填 7-32 位账号,手机号区号/本地号分存;登录默认账号模式并支持切换手机号登录;Player i18n 拆包与赛事接口优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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