diff --git a/apps/api/prisma/migrations/20260612120000_user_preference_phone_split/migration.sql b/apps/api/prisma/migrations/20260612120000_user_preference_phone_split/migration.sql new file mode 100644 index 0000000..f5a60a8 --- /dev/null +++ b/apps/api/prisma/migrations/20260612120000_user_preference_phone_split/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 3123621..dc14269 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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") } diff --git a/apps/api/src/applications/player/player.controller.ts b/apps/api/src/applications/player/player.controller.ts index 7c69583..9e08687 100644 --- a/apps/api/src/applications/player/player.controller.ts +++ b/apps/api/src/applications/player/player.controller.ts @@ -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 }); } diff --git a/apps/api/src/domains/betting/bets.service.ts b/apps/api/src/domains/betting/bets.service.ts index 87e17b3..b12202d 100644 --- a/apps/api/src/domains/betting/bets.service.ts +++ b/apps/api/src/domains/betting/bets.service.ts @@ -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({ diff --git a/apps/api/src/domains/catalog/matches.service.ts b/apps/api/src/domains/catalog/matches.service.ts index 2617ab9..ae3b69e 100644 --- a/apps/api/src/domains/catalog/matches.service.ts +++ b/apps/api/src/domains/catalog/matches.service.ts @@ -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, locale: string) { + async enrichMatch( + match: Record, + 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') { diff --git a/apps/api/src/domains/identity/auth.controller.ts b/apps/api/src/domains/identity/auth.controller.ts index 794bc9e..a2b1750 100644 --- a/apps/api/src/domains/identity/auth.controller.ts +++ b/apps/api/src/domains/identity/auth.controller.ts @@ -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, diff --git a/apps/api/src/domains/identity/auth.dto.ts b/apps/api/src/domains/identity/auth.dto.ts index f3bbee1..69483ad 100644 --- a/apps/api/src/domains/identity/auth.dto.ts +++ b/apps/api/src/domains/identity/auth.dto.ts @@ -18,6 +18,11 @@ export class LoginDto { } export class RegisterDto { + @ApiProperty({ description: '登录账号,7-32 位字母数字' }) + @IsString() + @MinLength(7) + username!: string; + @ApiProperty({ description: '本地手机号,不含国家区号' }) @IsString() phone!: string; diff --git a/apps/api/src/domains/identity/auth.service.ts b/apps/api/src/domains/identity/auth.service.ts index 1d458e5..3be3b24 100644 --- a/apps/api/src/domains/identity/auth.service.ts +++ b/apps/api/src/domains/identity/auth.service.ts @@ -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) { diff --git a/apps/api/src/domains/identity/sms/phone.util.ts b/apps/api/src/domains/identity/sms/phone.util.ts index 3e18732..51fcf7f 100644 --- a/apps/api/src/domains/identity/sms/phone.util.ts +++ b/apps/api/src/domains/identity/sms/phone.util.ts @@ -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]; +} diff --git a/apps/player/package.json b/apps/player/package.json index bac11a1..8b3bbf4 100644 --- a/apps/player/package.json +++ b/apps/player/package.json @@ -15,8 +15,7 @@ "pinia": "^2.3.1", "vue": "^3.5.13", "vue-i18n": "^11.1.1", - "vue-router": "^4.5.0", - "vue3-slide-verify": "^1.1.8" + "vue-router": "^4.5.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.2.1", diff --git a/apps/player/src/components/BannerCarousel.vue b/apps/player/src/components/BannerCarousel.vue index 2ddee87..f24b93a 100644 --- a/apps/player/src/components/BannerCarousel.vue +++ b/apps/player/src/components/BannerCarousel.vue @@ -128,6 +128,7 @@ onUnmounted(stopAutoPlay); :src="imageUrl(banner)" :alt="title(banner)" class="slide-img" + :loading="i === 0 ? 'eager' : 'lazy'" @error="onImgError" />
{{ title(banner) }}
diff --git a/apps/player/src/components/LeagueAccordionItem.vue b/apps/player/src/components/LeagueAccordionItem.vue index aeea514..4996890 100644 --- a/apps/player/src/components/LeagueAccordionItem.vue +++ b/apps/player/src/components/LeagueAccordionItem.vue @@ -102,7 +102,7 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope content: ''; position: absolute; inset: 0; - background: v-bind(matchCardBg) center top / 100% auto no-repeat; + background: v-bind(matchCardBg) center / 100% 100% no-repeat; opacity: 0.25; z-index: -1; pointer-events: none; diff --git a/apps/player/src/components/outright/OutrightEventSection.vue b/apps/player/src/components/outright/OutrightEventSection.vue index d8399e1..83ae3d0 100644 --- a/apps/player/src/components/outright/OutrightEventSection.vue +++ b/apps/player/src/components/outright/OutrightEventSection.vue @@ -111,7 +111,7 @@ const headMeta = computed(() => { content: ''; position: absolute; inset: 0; - background: v-bind(matchCardBg) center top / 100% auto no-repeat; + background: v-bind(matchCardBg) center / 100% 100% no-repeat; opacity: 0.25; z-index: -1; pointer-events: none; diff --git a/apps/player/src/components/parlay/ParlayPanel.vue b/apps/player/src/components/parlay/ParlayPanel.vue index 3cb55b7..0dd52d8 100644 --- a/apps/player/src/components/parlay/ParlayPanel.vue +++ b/apps/player/src/components/parlay/ParlayPanel.vue @@ -1,8 +1,8 @@