From db28390be92fed839e1ad39a1480d05ee3fa3f8e Mon Sep 17 00:00:00 2001 From: Mars <3361409208a@gmail.com> Date: Fri, 12 Jun 2026 10:25:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(player):=20=E6=8E=A5=E5=85=A5=E5=88=9B?= =?UTF-8?q?=E8=93=9D=E7=9F=AD=E4=BF=A1=E6=89=8B=E6=9C=BA=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E4=B8=8E=E7=99=BB=E5=BD=95=E9=A1=B5=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 SMS 验证码注册、8 国手机号选择与 Redis 频控;优化登录/注册 UI 及图形验证码样式。 Co-authored-by: Cursor --- .env.docker.example | 9 + .env.example | 9 + apps/api/src/app.module.ts | 2 + .../src/domains/identity/auth.controller.ts | 12 +- apps/api/src/domains/identity/auth.dto.ts | 21 +- apps/api/src/domains/identity/auth.module.ts | 2 + apps/api/src/domains/identity/auth.service.ts | 45 +++- .../domains/identity/sms/chuanglan/client.ts | 61 +++++ .../domains/identity/sms/chuanglan/config.ts | 36 +++ .../domains/identity/sms/chuanglan/sign.ts | 18 ++ apps/api/src/domains/identity/sms/code.ts | 3 + .../src/domains/identity/sms/phone.util.ts | 52 ++++ .../domains/identity/sms/sms.controller.ts | 26 ++ apps/api/src/domains/identity/sms/sms.dto.ts | 35 +++ .../src/domains/identity/sms/sms.module.ts | 11 + .../src/domains/identity/sms/sms.service.ts | 95 +++++++ .../api/src/domains/identity/sms/sms.types.ts | 14 + .../api/src/domains/identity/sms/templates.ts | 16 ++ apps/api/src/shared/common/app-error.ts | 6 + apps/api/src/shared/common/client-ip.util.ts | 13 + apps/api/src/shared/redis/redis.module.ts | 9 + apps/api/src/shared/redis/redis.service.ts | 37 +++ .../src/components/PhoneCountrySelect.vue | 251 ++++++++++++++++++ apps/player/src/components/RobotVerify.vue | 124 +++++---- apps/player/src/composables/useSmsCode.ts | 63 +++++ apps/player/src/main.ts | 57 ++++ apps/player/src/stores/auth.ts | 24 +- apps/player/src/styles.css | 5 + apps/player/src/views/LoginView.vue | 58 +++- apps/player/src/views/RegisterView.vue | 241 ++++++++++++++--- docker-compose.prod.yml | 7 + packages/shared/package.json | 4 +- .../scripts/generate-phone-countries.mjs | 37 +++ packages/shared/src/api-errors.ts | 45 ++++ packages/shared/src/index.ts | 1 + packages/shared/src/phone-countries.ts | 104 ++++++++ packages/shared/src/phone-dial-codes.json | 66 +++++ packages/shared/tsconfig.json | 1 + pnpm-lock.yaml | 8 + 39 files changed, 1521 insertions(+), 107 deletions(-) create mode 100644 apps/api/src/domains/identity/sms/chuanglan/client.ts create mode 100644 apps/api/src/domains/identity/sms/chuanglan/config.ts create mode 100644 apps/api/src/domains/identity/sms/chuanglan/sign.ts create mode 100644 apps/api/src/domains/identity/sms/code.ts create mode 100644 apps/api/src/domains/identity/sms/phone.util.ts create mode 100644 apps/api/src/domains/identity/sms/sms.controller.ts create mode 100644 apps/api/src/domains/identity/sms/sms.dto.ts create mode 100644 apps/api/src/domains/identity/sms/sms.module.ts create mode 100644 apps/api/src/domains/identity/sms/sms.service.ts create mode 100644 apps/api/src/domains/identity/sms/sms.types.ts create mode 100644 apps/api/src/domains/identity/sms/templates.ts create mode 100644 apps/api/src/shared/common/client-ip.util.ts create mode 100644 apps/api/src/shared/redis/redis.module.ts create mode 100644 apps/api/src/shared/redis/redis.service.ts create mode 100644 apps/player/src/components/PhoneCountrySelect.vue create mode 100644 apps/player/src/composables/useSmsCode.ts create mode 100644 packages/shared/scripts/generate-phone-countries.mjs create mode 100644 packages/shared/src/phone-countries.ts create mode 100644 packages/shared/src/phone-dial-codes.json diff --git a/.env.docker.example b/.env.docker.example index 81eae25..a0c3418 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -17,3 +17,12 @@ SEED_DATABASE=true API_PORT=3000 PLAYER_PORT=8082 ADMIN_PORT=8081 + +# 创蓝短信(Chuanglan,仅 API 使用) +CHUANGLAN_ACCOUNT=your_account +CHUANGLAN_PASSWORD=your_password +CHUANGLAN_ENDPOINT=https://sgap.253.com/send/sms +CHUANGLAN_CONNECT_TIMEOUT_MS=10000 +CHUANGLAN_READ_TIMEOUT_MS=10000 +SMS_CODE_TTL_SECONDS=300 +SMS_RATE_LIMIT_SECONDS=60 diff --git a/.env.example b/.env.example index 312cdfd..9942dce 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,12 @@ JWT_AGENT_EXPIRES=8h PORT=3000 NODE_ENV=development UPLOAD_DIR= + +# 创蓝短信(Chuanglan) +CHUANGLAN_ACCOUNT=your_account +CHUANGLAN_PASSWORD=your_password +CHUANGLAN_ENDPOINT=https://sgap.253.com/send/sms +CHUANGLAN_CONNECT_TIMEOUT_MS=10000 +CHUANGLAN_READ_TIMEOUT_MS=10000 +SMS_CODE_TTL_SECONDS=300 +SMS_RATE_LIMIT_SECONDS=60 diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b4fb56d..495cf4a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,6 +4,7 @@ import { ScheduleModule } from '@nestjs/schedule'; import { APP_GUARD } from '@nestjs/core'; import { JwtAuthGuard } from './domains/identity/guards'; import { PrismaModule } from './shared/prisma/prisma.module'; +import { RedisModule } from './shared/redis/redis.module'; import { SystemConfigModule } from './shared/config/system-config.module'; import { IdentityModule } from './domains/identity/identity.module'; import { AgentsModule } from './domains/agent/agents.module'; @@ -22,6 +23,7 @@ import { AgentPortalModule } from './applications/agent/agent-portal.module'; ConfigModule.forRoot({ isGlobal: true }), ScheduleModule.forRoot(), PrismaModule, + RedisModule, SystemConfigModule, IdentityModule, AgentsModule, diff --git a/apps/api/src/domains/identity/auth.controller.ts b/apps/api/src/domains/identity/auth.controller.ts index 907fbc0..794bc9e 100644 --- a/apps/api/src/domains/identity/auth.controller.ts +++ b/apps/api/src/domains/identity/auth.controller.ts @@ -20,7 +20,12 @@ export class AuthController { @Public() @Post('player/auth/login') async playerLogin(@Body() dto: LoginDto) { - const result = await this.auth.login(dto.username, dto.password, 'player'); + const result = await this.auth.login( + dto.username, + dto.password, + 'player', + dto.countryCode, + ); return jsonResponse(result); } @@ -28,8 +33,11 @@ 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, + smsCode: dto.smsCode, + sessionId: dto.sessionId, inviteCode: dto.inviteCode, locale: dto.locale, }); diff --git a/apps/api/src/domains/identity/auth.dto.ts b/apps/api/src/domains/identity/auth.dto.ts index c035c37..f3bbee1 100644 --- a/apps/api/src/domains/identity/auth.dto.ts +++ b/apps/api/src/domains/identity/auth.dto.ts @@ -10,18 +10,35 @@ export class LoginDto { @IsString() @MinLength(1) password!: string; + + @ApiProperty({ required: false, description: '国家区号,不含 +,如 86' }) + @IsOptional() + @IsString() + countryCode?: string; } export class RegisterDto { - @ApiProperty() + @ApiProperty({ description: '本地手机号,不含国家区号' }) @IsString() - username!: string; + phone!: string; + + @ApiProperty({ description: '国家区号,不含 +,如 86' }) + @IsString() + countryCode!: string; @ApiProperty() @IsString() @MinLength(8) password!: string; + @ApiProperty({ description: '短信验证码' }) + @IsString() + smsCode!: string; + + @ApiProperty({ description: '发送验证码返回的 sessionId' }) + @IsString() + sessionId!: string; + @ApiProperty({ required: false }) @IsOptional() @IsString() diff --git a/apps/api/src/domains/identity/auth.module.ts b/apps/api/src/domains/identity/auth.module.ts index ae6a5c4..53af9fa 100644 --- a/apps/api/src/domains/identity/auth.module.ts +++ b/apps/api/src/domains/identity/auth.module.ts @@ -7,9 +7,11 @@ import { InvitesService } from './invites.service'; import { JwtStrategy } from './jwt.strategy'; import { AuthController } from './auth.controller'; import { SystemConfigModule } from '../../shared/config/system-config.module'; +import { SmsModule } from './sms/sms.module'; @Module({ imports: [ + SmsModule, SystemConfigModule, PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.registerAsync({ diff --git a/apps/api/src/domains/identity/auth.service.ts b/apps/api/src/domains/identity/auth.service.ts index 823be8b..1d458e5 100644 --- a/apps/api/src/domains/identity/auth.service.ts +++ b/apps/api/src/domains/identity/auth.service.ts @@ -8,6 +8,8 @@ import { assertPlayerUsername } from '@thebet365/shared'; 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'; const MAX_LOGIN_FAILS = 5; const LOCK_DURATION_MS = 15 * 60 * 1000; @@ -27,6 +29,7 @@ export class AuthService { private config: ConfigService, private systemConfig: SystemConfigService, private invites: InvitesService, + private sms: SmsService, ) {} /** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT) */ @@ -42,9 +45,19 @@ export class AuthService { return this.login(username, password, portal); } - async login(username: string, password: string, portal: 'player' | 'admin' | 'agent') { + async login( + username: string, + password: string, + portal: 'player' | 'admin' | 'agent', + countryCode?: string, + ) { + const lookupUsername = + portal === 'player' + ? resolvePlayerLoginUsername(username, countryCode) + : username.trim(); + const user = await this.prisma.user.findUnique({ - where: { username }, + where: { username: lookupUsername }, include: { auth: true, adminRole: { include: { role: true } } }, }); @@ -143,23 +156,35 @@ export class AuthService { } async registerPlayer(data: { - username: string; + phone: string; + countryCode: string; password: string; + smsCode: string; + sessionId: string; inviteCode?: string; locale?: string; }) { - const username = data.username.trim(); - if (!username) { - throw appBadRequest('USERNAME_REQUIRED'); - } + const phone = normalizePhone(data.countryCode, data.phone); + const username = phone; + try { assertPlayerUsername(username); } catch { - throw appBadRequest('USERNAME_FORMAT_INVALID'); + throw appBadRequest('PHONE_INVALID'); } if (!data.password || data.password.length < 8) { throw appBadRequest('PASSWORD_MIN_LENGTH'); } + if (!data.smsCode?.trim() || !data.sessionId?.trim()) { + throw appBadRequest('SMS_CODE_REQUIRED'); + } + + await this.sms.verifyCode({ + phone: data.phone, + countryCode: data.countryCode, + code: data.smsCode.trim(), + sessionId: data.sessionId.trim(), + }); const { parentId, sponsorId, inviteId } = await this.resolveInviteSponsor(data.inviteCode); const inviteSponsorId = parentId == null && sponsorId != null ? sponsorId : null; @@ -169,7 +194,7 @@ export class AuthService { select: { id: true }, }); if (existing) { - throw appBadRequest('USERNAME_TAKEN'); + throw appBadRequest('PHONE_TAKEN'); } const hash = await this.hashPassword(data.password); @@ -195,7 +220,7 @@ export class AuthService { }); await tx.userPreference.create({ - data: { userId: created.id, locale }, + data: { userId: created.id, locale, phone }, }); if (inviteId) { diff --git a/apps/api/src/domains/identity/sms/chuanglan/client.ts b/apps/api/src/domains/identity/sms/chuanglan/client.ts new file mode 100644 index 0000000..9e474b6 --- /dev/null +++ b/apps/api/src/domains/identity/sms/chuanglan/client.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { generateChuanglanSign } from './sign'; +import { loadChuanglanConfig } from './config'; +import type { ChuanglanSendResponse, SmsSendResult } from '../sms.types'; + +@Injectable() +export class ChuanglanClient { + private readonly cfg; + + constructor(config: ConfigService) { + this.cfg = loadChuanglanConfig(config); + } + + async sendSms(mobile: string, msg: string, uid?: string): Promise { + const nonce = String(Date.now()); + + const body: Record = { + account: this.cfg.account, + mobile, + msg, + }; + if (uid) body.uid = uid; + + const sign = generateChuanglanSign(this.cfg.password, { ...body, nonce }); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.cfg.readTimeoutMs); + + try { + const res = await fetch(this.cfg.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + nonce, + sign, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + const data = (await res.json()) as ChuanglanSendResponse; + + if (data.code === '0') { + return { + success: true, + code: data.code, + message: 'OK', + messageId: data.data?.messageId, + }; + } + + return { success: false, code: data.code, message: data.message }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return { success: false, code: 'HTTP_ERROR', message }; + } finally { + clearTimeout(timer); + } + } +} diff --git a/apps/api/src/domains/identity/sms/chuanglan/config.ts b/apps/api/src/domains/identity/sms/chuanglan/config.ts new file mode 100644 index 0000000..2f5dbb1 --- /dev/null +++ b/apps/api/src/domains/identity/sms/chuanglan/config.ts @@ -0,0 +1,36 @@ +import { ConfigService } from '@nestjs/config'; + +export interface ChuanglanConfig { + account: string; + password: string; + endpoint: string; + connectTimeoutMs: number; + readTimeoutMs: number; +} + +export interface SmsBusinessConfig { + codeTtlSeconds: number; + rateLimitSeconds: number; +} + +export function loadChuanglanConfig(config: ConfigService): ChuanglanConfig { + const account = config.get('CHUANGLAN_ACCOUNT'); + const password = config.get('CHUANGLAN_PASSWORD'); + if (!account || !password) { + throw new Error('Missing CHUANGLAN_ACCOUNT or CHUANGLAN_PASSWORD'); + } + return { + account, + password, + endpoint: config.get('CHUANGLAN_ENDPOINT', 'https://sgap.253.com/send/sms'), + connectTimeoutMs: Number(config.get('CHUANGLAN_CONNECT_TIMEOUT_MS', 10_000)), + readTimeoutMs: Number(config.get('CHUANGLAN_READ_TIMEOUT_MS', 10_000)), + }; +} + +export function loadSmsBusinessConfig(config: ConfigService): SmsBusinessConfig { + return { + codeTtlSeconds: Number(config.get('SMS_CODE_TTL_SECONDS', 300)), + rateLimitSeconds: Number(config.get('SMS_RATE_LIMIT_SECONDS', 60)), + }; +} diff --git a/apps/api/src/domains/identity/sms/chuanglan/sign.ts b/apps/api/src/domains/identity/sms/chuanglan/sign.ts new file mode 100644 index 0000000..a01bf05 --- /dev/null +++ b/apps/api/src/domains/identity/sms/chuanglan/sign.ts @@ -0,0 +1,18 @@ +import { createHash } from 'node:crypto'; + +export function generateChuanglanSign( + password: string, + params: Record, +): string { + const raw = Object.keys(params) + .sort() + .reduce((acc, key) => { + const value = params[key]; + if (value != null && value.trim() !== '') { + return acc + key + value; + } + return acc; + }, ''); + + return createHash('md5').update(raw + password, 'utf8').digest('hex').toLowerCase(); +} diff --git a/apps/api/src/domains/identity/sms/code.ts b/apps/api/src/domains/identity/sms/code.ts new file mode 100644 index 0000000..3ce54ea --- /dev/null +++ b/apps/api/src/domains/identity/sms/code.ts @@ -0,0 +1,3 @@ +export function generateSixDigitCode(): string { + return String(Math.floor(Math.random() * 1_000_000)).padStart(6, '0'); +} diff --git a/apps/api/src/domains/identity/sms/phone.util.ts b/apps/api/src/domains/identity/sms/phone.util.ts new file mode 100644 index 0000000..3e18732 --- /dev/null +++ b/apps/api/src/domains/identity/sms/phone.util.ts @@ -0,0 +1,52 @@ +import { appBadRequest } from '../../../shared/common/app-error'; +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, ''); + + if (!dial || !local) { + throw appBadRequest('PHONE_REQUIRED'); + } + if (!isSupportedPhoneDial(dial)) { + 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'); + } + return combined; +} + +/** 玩家登录:选国家 + 本地号;字母账号(如 player1)原样返回 */ +export function resolvePlayerLoginUsername( + input: string, + countryDial?: string, +): string { + const trimmed = input.trim(); + if (!trimmed) return trimmed; + + if (!/^[\d+\s\-()]+$/.test(trimmed)) { + return trimmed; + } + + if (countryDial?.trim()) { + try { + return normalizePhone(countryDial, trimmed); + } catch { + return trimmed; + } + } + + const digits = trimmed.replace(/\D/g, ''); + if (digits.length >= 10) return digits; + + return trimmed; +} diff --git a/apps/api/src/domains/identity/sms/sms.controller.ts b/apps/api/src/domains/identity/sms/sms.controller.ts new file mode 100644 index 0000000..4fae36e --- /dev/null +++ b/apps/api/src/domains/identity/sms/sms.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Post, Body, Req } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import type { Request } from 'express'; +import { SmsService } from './sms.service'; +import { SendSmsCodeDto } from './sms.dto'; +import { Public } from '../../../shared/common/decorators'; +import { jsonResponse } from '../../../shared/common/filters'; +import { getClientIp } from '../../../shared/common/client-ip.util'; + +@ApiTags('SMS') +@Controller() +export class SmsController { + constructor(private sms: SmsService) {} + + @Public() + @Post('player/sms/send') + async send(@Body() dto: SendSmsCodeDto, @Req() req: Request) { + const result = await this.sms.sendVerifyCode({ + phone: dto.phone.trim(), + countryCode: dto.countryCode.trim(), + locale: dto.locale, + clientIp: getClientIp(req), + }); + return jsonResponse(result); + } +} diff --git a/apps/api/src/domains/identity/sms/sms.dto.ts b/apps/api/src/domains/identity/sms/sms.dto.ts new file mode 100644 index 0000000..a56976e --- /dev/null +++ b/apps/api/src/domains/identity/sms/sms.dto.ts @@ -0,0 +1,35 @@ +import { IsString, IsOptional } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class SendSmsCodeDto { + @ApiProperty({ example: '13800138000', description: '本地手机号,不含国家区号' }) + @IsString() + phone!: string; + + @ApiProperty({ example: '86', description: '国家区号,不含 +' }) + @IsString() + countryCode!: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + locale?: string; +} + +export class VerifySmsCodeDto { + @ApiProperty() + @IsString() + phone!: string; + + @ApiProperty() + @IsString() + countryCode!: string; + + @ApiProperty() + @IsString() + code!: string; + + @ApiProperty() + @IsString() + sessionId!: string; +} diff --git a/apps/api/src/domains/identity/sms/sms.module.ts b/apps/api/src/domains/identity/sms/sms.module.ts new file mode 100644 index 0000000..d13ce4d --- /dev/null +++ b/apps/api/src/domains/identity/sms/sms.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SmsService } from './sms.service'; +import { SmsController } from './sms.controller'; +import { ChuanglanClient } from './chuanglan/client'; + +@Module({ + providers: [SmsService, ChuanglanClient], + controllers: [SmsController], + exports: [SmsService], +}) +export class SmsModule {} diff --git a/apps/api/src/domains/identity/sms/sms.service.ts b/apps/api/src/domains/identity/sms/sms.service.ts new file mode 100644 index 0000000..ec0ac78 --- /dev/null +++ b/apps/api/src/domains/identity/sms/sms.service.ts @@ -0,0 +1,95 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { randomUUID } from 'node:crypto'; +import { normalizeLocale } from '@thebet365/shared'; +import { RedisService } from '../../../shared/redis/redis.service'; +import { appBadRequest, appTooManyRequests } from '../../../shared/common/app-error'; +import { ChuanglanClient } from './chuanglan/client'; +import { loadSmsBusinessConfig } from './chuanglan/config'; +import { generateSixDigitCode } from './code'; +import { normalizePhone } from './phone.util'; +import { renderVerifySms } from './templates'; +import type { SmsLang } from './sms.types'; + +const codeKey = (sessionId: string) => `sms:code:${sessionId}`; +const phoneRateKey = (phone: string) => `sms:rate:phone:${phone}`; +const ipRateKey = (ip: string) => `sms:rate:ip:${ip}`; + +function mapLocaleToSmsLang(locale?: string): SmsLang { + const normalized = normalizeLocale(locale); + if (normalized === 'zh-CN') return 'zh'; + if (normalized === 'ms-MY') return 'ms'; + return 'en'; +} + +@Injectable() +export class SmsService { + private readonly smsCfg; + + constructor( + private redis: RedisService, + private chuanglan: ChuanglanClient, + config: ConfigService, + ) { + this.smsCfg = loadSmsBusinessConfig(config); + } + + async sendVerifyCode(params: { + phone: string; + countryCode: string; + locale?: string; + clientIp: string; + }): Promise<{ sessionId: string }> { + const phone = normalizePhone(params.countryCode, params.phone); + + const [phoneLimited, ipLimited] = await Promise.all([ + this.redis.exists(phoneRateKey(phone)), + this.redis.exists(ipRateKey(params.clientIp)), + ]); + if (phoneLimited || ipLimited) { + throw appTooManyRequests('SMS_RATE_LIMIT'); + } + + const code = generateSixDigitCode(); + const sessionId = randomUUID(); + const lang = mapLocaleToSmsLang(params.locale); + const msg = renderVerifySms(lang, code); + + const result = await this.chuanglan.sendSms(phone, msg, sessionId); + if (!result.success) { + throw appBadRequest('SMS_SEND_FAILED'); + } + + await Promise.all([ + this.redis.set( + codeKey(sessionId), + JSON.stringify({ phone, code }), + this.smsCfg.codeTtlSeconds, + ), + this.redis.set(phoneRateKey(phone), '1', this.smsCfg.rateLimitSeconds), + this.redis.set(ipRateKey(params.clientIp), '1', this.smsCfg.rateLimitSeconds), + ]); + + return { sessionId }; + } + + async verifyCode(params: { + phone: string; + countryCode: string; + code: string; + sessionId: string; + }): Promise { + const phone = normalizePhone(params.countryCode, params.phone); + const raw = await this.redis.get(codeKey(params.sessionId)); + if (!raw) { + throw appBadRequest('SMS_CODE_EXPIRED'); + } + + const cached = JSON.parse(raw) as { phone: string; code: string }; + if (cached.phone !== phone || cached.code !== params.code.trim()) { + throw appBadRequest('SMS_CODE_INVALID'); + } + + await this.redis.del(codeKey(params.sessionId)); + } +} diff --git a/apps/api/src/domains/identity/sms/sms.types.ts b/apps/api/src/domains/identity/sms/sms.types.ts new file mode 100644 index 0000000..7c83697 --- /dev/null +++ b/apps/api/src/domains/identity/sms/sms.types.ts @@ -0,0 +1,14 @@ +export type SmsLang = 'zh' | 'en' | 'vi' | 'ms' | 'kh'; + +export interface ChuanglanSendResponse { + code: string; + message: string; + data?: { messageId: string }; +} + +export interface SmsSendResult { + success: boolean; + code: string; + message: string; + messageId?: string; +} diff --git a/apps/api/src/domains/identity/sms/templates.ts b/apps/api/src/domains/identity/sms/templates.ts new file mode 100644 index 0000000..7e2488f --- /dev/null +++ b/apps/api/src/domains/identity/sms/templates.ts @@ -0,0 +1,16 @@ +import type { SmsLang } from './sms.types'; + +const TEMPLATES: Record = { + default: '您的验证码是:{code}。5分钟内有效。', + zh: '您的验证码是:{code}。5分钟内有效。', + en: 'Your verification code is {code}. Valid for 5 minutes.', + vi: 'Mã xác minh của bạn là {code}. Có hiệu lực trong 5 phút.', + ms: 'Kod pengesahan anda ialah {code}. Sah selama 5 minit.', + kh: 'កូដផ្ទៀងផ្ទាត់របស់អ្នកគឺ {code} ។ មានសុពលភាពរយៈពេល ៥ នាទី។', +}; + +export function renderVerifySms(lang: SmsLang | undefined, code: string): string { + const key = lang?.trim() || 'zh'; + const tpl = TEMPLATES[key] ?? TEMPLATES.default ?? TEMPLATES.zh; + return tpl.replace('{code}', code); +} diff --git a/apps/api/src/shared/common/app-error.ts b/apps/api/src/shared/common/app-error.ts index 6b67d30..40f9f31 100644 --- a/apps/api/src/shared/common/app-error.ts +++ b/apps/api/src/shared/common/app-error.ts @@ -1,6 +1,8 @@ import { BadRequestException, ForbiddenException, + HttpException, + HttpStatus, NotFoundException, UnauthorizedException, } from '@nestjs/common'; @@ -33,6 +35,10 @@ export function appUnauthorized(code: ApiErrorCode, params?: ApiErrorParams) { return new UnauthorizedException(body(code, params)); } +export function appTooManyRequests(code: ApiErrorCode, params?: ApiErrorParams) { + return new HttpException(body(code, params), HttpStatus.TOO_MANY_REQUESTS); +} + export function isCodedExceptionResponse( res: unknown, ): res is { code: ApiErrorCode; params?: ApiErrorParams } { diff --git a/apps/api/src/shared/common/client-ip.util.ts b/apps/api/src/shared/common/client-ip.util.ts new file mode 100644 index 0000000..65e8d04 --- /dev/null +++ b/apps/api/src/shared/common/client-ip.util.ts @@ -0,0 +1,13 @@ +import type { Request } from 'express'; + +export function getClientIp(req: Request): string { + const forwarded = req.headers['x-forwarded-for']; + if (typeof forwarded === 'string' && forwarded.trim()) { + return forwarded.split(',')[0]?.trim() || '0.0.0.0'; + } + const realIp = req.headers['x-real-ip']; + if (typeof realIp === 'string' && realIp.trim()) { + return realIp.trim(); + } + return req.ip || req.socket?.remoteAddress || '0.0.0.0'; +} diff --git a/apps/api/src/shared/redis/redis.module.ts b/apps/api/src/shared/redis/redis.module.ts new file mode 100644 index 0000000..b9cfabf --- /dev/null +++ b/apps/api/src/shared/redis/redis.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { RedisService } from './redis.service'; + +@Global() +@Module({ + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/apps/api/src/shared/redis/redis.service.ts b/apps/api/src/shared/redis/redis.service.ts new file mode 100644 index 0000000..9c14b1c --- /dev/null +++ b/apps/api/src/shared/redis/redis.service.ts @@ -0,0 +1,37 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +@Injectable() +export class RedisService implements OnModuleDestroy { + private readonly client: Redis; + + constructor(config: ConfigService) { + const url = config.get('REDIS_URL', 'redis://127.0.0.1:6379'); + this.client = new Redis(url, { maxRetriesPerRequest: 3, lazyConnect: true }); + } + + async onModuleDestroy() { + await this.client.quit(); + } + + get raw(): Redis { + return this.client; + } + + async exists(key: string): Promise { + return (await this.client.exists(key)) > 0; + } + + async get(key: string): Promise { + return this.client.get(key); + } + + async set(key: string, value: string, ttlSeconds: number): Promise { + await this.client.set(key, value, 'EX', ttlSeconds); + } + + async del(key: string): Promise { + await this.client.del(key); + } +} diff --git a/apps/player/src/components/PhoneCountrySelect.vue b/apps/player/src/components/PhoneCountrySelect.vue new file mode 100644 index 0000000..4a7412e --- /dev/null +++ b/apps/player/src/components/PhoneCountrySelect.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/apps/player/src/components/RobotVerify.vue b/apps/player/src/components/RobotVerify.vue index cbd60d3..a1df64a 100644 --- a/apps/player/src/components/RobotVerify.vue +++ b/apps/player/src/components/RobotVerify.vue @@ -12,6 +12,8 @@ const validated = ref(false); const errorMsg = ref(''); const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const CANVAS_W = 96; +const CANVAS_H = 36; function generateCode() { let result = ''; @@ -24,40 +26,39 @@ function generateCode() { function drawCaptcha() { const canvas = canvasRef.value; if (!canvas) return; - const w = 108, h = 44; - canvas.width = w; - canvas.height = h; + canvas.width = CANVAS_W; + canvas.height = CANVAS_H; const ctx = canvas.getContext('2d'); if (!ctx) return; - ctx.fillStyle = '#2a2210'; - ctx.fillRect(0, 0, w, h); + ctx.fillStyle = '#0d0d0d'; + ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); - for (let i = 0; i < 28; i++) { - ctx.fillStyle = `rgba(212, 175, 55, ${0.1 + Math.random() * 0.2})`; + for (let i = 0; i < 20; i++) { + ctx.fillStyle = `rgba(255, 255, 255, ${0.03 + Math.random() * 0.06})`; ctx.beginPath(); - ctx.arc(Math.random() * w, Math.random() * h, Math.random() * 2.2, 0, Math.PI * 2); + ctx.arc(Math.random() * CANVAS_W, Math.random() * CANVAS_H, Math.random() * 1.8, 0, Math.PI * 2); ctx.fill(); } - for (let i = 0; i < 5; i++) { - ctx.strokeStyle = `rgba(212, 175, 55, ${0.15 + Math.random() * 0.25})`; + for (let i = 0; i < 3; i++) { + ctx.strokeStyle = `rgba(255, 255, 255, ${0.06 + Math.random() * 0.08})`; ctx.lineWidth = 1; ctx.beginPath(); - ctx.moveTo(Math.random() * w, Math.random() * h); - ctx.lineTo(Math.random() * w, Math.random() * h); + ctx.moveTo(Math.random() * CANVAS_W, Math.random() * CANVAS_H); + ctx.lineTo(Math.random() * CANVAS_W, Math.random() * CANVAS_H); ctx.stroke(); } - const charWidth = w / (code.value.length + 1); + const charWidth = CANVAS_W / (code.value.length + 1); for (let i = 0; i < code.value.length; i++) { ctx.save(); - const x = charWidth * (i + 0.5) + (Math.random() - 0.5) * 4; - const y = h / 2 + (Math.random() - 0.5) * 6; + const x = charWidth * (i + 0.5) + (Math.random() - 0.5) * 3; + const y = CANVAS_H / 2 + (Math.random() - 0.5) * 4; ctx.translate(x, y); - ctx.rotate((Math.random() - 0.5) * 0.4); - ctx.font = `bold ${18 + Math.random() * 6}px 'Courier New', monospace`; - ctx.fillStyle = `hsl(${40 + Math.random() * 20}, ${80 + Math.random() * 20}%, ${65 + Math.random() * 20}%)`; + ctx.rotate((Math.random() - 0.5) * 0.35); + ctx.font = `bold ${15 + Math.random() * 4}px 'Courier New', monospace`; + ctx.fillStyle = `rgba(240, 216, 117, ${0.85 + Math.random() * 0.15})`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(code.value[i], 0, 0); @@ -94,24 +95,58 @@ defineExpose({ validate, refresh }); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 54e5b3d..de8d402 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -51,6 +51,13 @@ services: NODE_ENV: production UPLOAD_DIR: /app/uploads SEED_DATABASE: ${SEED_DATABASE:-false} + CHUANGLAN_ACCOUNT: ${CHUANGLAN_ACCOUNT} + CHUANGLAN_PASSWORD: ${CHUANGLAN_PASSWORD} + CHUANGLAN_ENDPOINT: ${CHUANGLAN_ENDPOINT:-https://sgap.253.com/send/sms} + CHUANGLAN_CONNECT_TIMEOUT_MS: ${CHUANGLAN_CONNECT_TIMEOUT_MS:-10000} + CHUANGLAN_READ_TIMEOUT_MS: ${CHUANGLAN_READ_TIMEOUT_MS:-10000} + SMS_CODE_TTL_SECONDS: ${SMS_CODE_TTL_SECONDS:-300} + SMS_RATE_LIMIT_SECONDS: ${SMS_RATE_LIMIT_SECONDS:-60} volumes: - uploads_data:/app/uploads depends_on: diff --git a/packages/shared/package.json b/packages/shared/package.json index 86b0301..4bf4b5a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -5,10 +5,12 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { - "build": "tsc", + "generate:phone-countries": "node scripts/generate-phone-countries.mjs", + "build": "pnpm run generate:phone-countries && tsc", "dev": "tsc --watch" }, "devDependencies": { + "country-codes-list": "^2.0.0", "typescript": "^5.7.3" } } diff --git a/packages/shared/scripts/generate-phone-countries.mjs b/packages/shared/scripts/generate-phone-countries.mjs new file mode 100644 index 0000000..1024d1b --- /dev/null +++ b/packages/shared/scripts/generate-phone-countries.mjs @@ -0,0 +1,37 @@ +import { writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { all } from 'country-codes-list'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const outPath = join(__dirname, '../src/phone-dial-codes.json'); + +/** 平台开放注册/短信的国家(ISO 3166-1 alpha-2) */ +const ALLOWED_PHONE_ISO = ['MY', 'SG', 'IN', 'AU', 'TH', 'VN', 'BD', 'TW']; + +/** 平台常用市场置顶(须为 ALLOWED_PHONE_ISO 子集) */ +const PINNED_ISO = ['MY', 'SG', 'IN', 'AU', 'TH', 'VN', 'BD', 'TW']; + +const entries = all() + .filter((c) => c.countryCallingCode && c.countryCode && ALLOWED_PHONE_ISO.includes(c.countryCode)) + .map((c) => ({ + iso: c.countryCode, + dial: c.countryCallingCode, + nameEn: c.countryNameEn, + nameLocal: c.countryNameLocal || c.countryNameEn, + flag: c.flag || '', + region: c.region || '', + })) + .sort((a, b) => { + const pa = PINNED_ISO.indexOf(a.iso); + const pb = PINNED_ISO.indexOf(b.iso); + if (pa !== -1 || pb !== -1) { + if (pa === -1) return 1; + if (pb === -1) return -1; + return pa - pb; + } + return a.nameEn.localeCompare(b.nameEn, 'en'); + }); + +writeFileSync(outPath, `${JSON.stringify(entries, null, 2)}\n`, 'utf8'); +console.log(`Wrote ${entries.length} countries to ${outPath}`); diff --git a/packages/shared/src/api-errors.ts b/packages/shared/src/api-errors.ts index 8eba9e6..0673c64 100644 --- a/packages/shared/src/api-errors.ts +++ b/packages/shared/src/api-errors.ts @@ -819,6 +819,51 @@ export const API_ERROR_MESSAGES = { 'en-US': 'Payment method is required', 'ms-MY': 'Kaedah pembayaran diperlukan', }, + PHONE_REQUIRED: { + 'zh-CN': '请填写手机号', + 'en-US': 'Phone number is required', + 'ms-MY': 'Nombor telefon diperlukan', + }, + PHONE_INVALID: { + 'zh-CN': '手机号格式无效', + 'en-US': 'Invalid phone number', + 'ms-MY': 'Nombor telefon tidak sah', + }, + PHONE_TAKEN: { + 'zh-CN': '该手机号已注册', + 'en-US': 'This phone number is already registered', + 'ms-MY': 'Nombor telefon ini sudah didaftarkan', + }, + SMS_CODE_REQUIRED: { + 'zh-CN': '请填写短信验证码', + 'en-US': 'SMS verification code is required', + 'ms-MY': 'Kod pengesahan SMS diperlukan', + }, + SMS_CODE_INVALID: { + 'zh-CN': '验证码错误', + 'en-US': 'Incorrect verification code', + 'ms-MY': 'Kod pengesahan salah', + }, + SMS_CODE_EXPIRED: { + 'zh-CN': '验证码已过期,请重新获取', + 'en-US': 'Verification code expired, please request a new one', + 'ms-MY': 'Kod pengesahan tamat tempoh, sila minta yang baharu', + }, + SMS_RATE_LIMIT: { + 'zh-CN': '发送太频繁,请60秒后再试', + 'en-US': 'Too many requests, please try again in 60 seconds', + 'ms-MY': 'Terlalu kerap, sila cuba lagi dalam 60 saat', + }, + SMS_SEND_FAILED: { + 'zh-CN': '短信发送失败,请稍后重试', + 'en-US': 'Failed to send SMS, please try again later', + 'ms-MY': 'Gagal menghantar SMS, sila cuba lagi', + }, + PHONE_COUNTRY_UNSUPPORTED: { + 'zh-CN': '暂不支持该国家/地区', + 'en-US': 'This country or region is not supported', + 'ms-MY': 'Negara atau wilayah ini tidak disokong', + }, } as const satisfies Record>; export type ApiErrorCode = keyof typeof API_ERROR_MESSAGES; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b2b0196..16a5d1d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -127,6 +127,7 @@ export * from './builtinPlayers'; export * from './playerLocale'; export * from './playerUsername'; export * from './initial-depositRemark'; +export * from './phone-countries'; export interface ApiResponse { success: boolean; diff --git a/packages/shared/src/phone-countries.ts b/packages/shared/src/phone-countries.ts new file mode 100644 index 0000000..c65b32c --- /dev/null +++ b/packages/shared/src/phone-countries.ts @@ -0,0 +1,104 @@ +import { normalizeLocale } from './api-errors'; +import dialCodesJson from './phone-dial-codes.json'; + +type PhoneLocale = 'zh-CN' | 'ms-MY' | 'en-US'; + +export interface PhoneCountry { + iso: string; + dial: string; + nameEn: string; + nameLocal: string; + flag: string; + region: string; +} + +/** 平台开放注册/短信的国家(ISO 3166-1 alpha-2),顺序即下拉展示顺序 */ +export const ALLOWED_PHONE_ISO = ['MY', 'SG', 'IN', 'AU', 'TH', 'VN', 'BD', 'TW'] as const; + +export type AllowedPhoneIso = (typeof ALLOWED_PHONE_ISO)[number]; + +const ALLOWED_SET = new Set(ALLOWED_PHONE_ISO); + +const ZH_LABELS: Record = { + MY: '马来西亚', + SG: '新加坡', + IN: '印度', + AU: '澳洲', + TH: '泰国', + VN: '越南', + BD: '孟加拉国', + TW: '台湾', +}; + +const allCountries = dialCodesJson as PhoneCountry[]; + +/** 开放国家列表(从 ITU 全量数据中筛选) */ +export const PHONE_COUNTRIES: PhoneCountry[] = ALLOWED_PHONE_ISO.map((iso) => { + const found = allCountries.find((c) => c.iso === iso); + if (!found) { + throw new Error(`Missing phone country data for ISO: ${iso}`); + } + return found; +}); + +const DIAL_SET = new Set(PHONE_COUNTRIES.map((c) => c.dial)); + +export function isSupportedPhoneDial(dial: string): boolean { + return DIAL_SET.has(dial.replace(/\D/g, '')); +} + +export function isAllowedPhoneIso(iso: string): boolean { + return ALLOWED_SET.has(iso.toUpperCase()); +} + +export function defaultPhoneDialForLocale(localeInput?: string | null): string { + return findPhoneCountryByIso(defaultPhoneIsoForLocale(localeInput))?.dial ?? '60'; +} + +export function defaultPhoneIsoForLocale(localeInput?: string | null): string { + const locale = normalizeLocale(localeInput); + if (locale === 'zh-CN') return 'TW'; + if (locale === 'ms-MY') return 'MY'; + return 'SG'; +} + +export function getPhoneDialFromIso(iso: string): string { + return findPhoneCountryByIso(iso)?.dial ?? ''; +} + +export function getPhoneCountryLabel(country: PhoneCountry, localeInput?: string | null): string { + const locale = normalizeLocale(localeInput); + if (locale === 'zh-CN' && isAllowedPhoneIso(country.iso)) { + return ZH_LABELS[country.iso as AllowedPhoneIso]; + } + if (locale === 'zh-CN') { + return country.nameLocal || country.nameEn; + } + return country.nameEn; +} + +export function findPhoneCountryByDial(dial: string): PhoneCountry | undefined { + const normalized = dial.replace(/\D/g, ''); + return PHONE_COUNTRIES.find((c) => c.dial === normalized); +} + +export function findPhoneCountryByIso(iso: string): PhoneCountry | undefined { + const upper = iso.toUpperCase(); + if (!isAllowedPhoneIso(upper)) return undefined; + return PHONE_COUNTRIES.find((c) => c.iso === upper); +} + +export function searchPhoneCountries(query: string, localeInput?: string | null): PhoneCountry[] { + const q = query.trim().toLowerCase(); + if (!q) return PHONE_COUNTRIES; + return PHONE_COUNTRIES.filter((country) => { + const label = getPhoneCountryLabel(country, localeInput).toLowerCase(); + return ( + country.iso.toLowerCase().includes(q) + || country.dial.includes(q.replace(/^\+/, '')) + || country.nameEn.toLowerCase().includes(q) + || label.includes(q) + || `+${country.dial}`.includes(q) + ); + }); +} diff --git a/packages/shared/src/phone-dial-codes.json b/packages/shared/src/phone-dial-codes.json new file mode 100644 index 0000000..77a524f --- /dev/null +++ b/packages/shared/src/phone-dial-codes.json @@ -0,0 +1,66 @@ +[ + { + "iso": "MY", + "dial": "60", + "nameEn": "Malaysia", + "nameLocal": "Malaysia", + "flag": "🇲🇾", + "region": "Asia & Pacific" + }, + { + "iso": "SG", + "dial": "65", + "nameEn": "Singapore", + "nameLocal": "Singapore", + "flag": "🇸🇬", + "region": "Asia & Pacific" + }, + { + "iso": "IN", + "dial": "91", + "nameEn": "India", + "nameLocal": "भारत, India", + "flag": "🇮🇳", + "region": "Asia & Pacific" + }, + { + "iso": "AU", + "dial": "61", + "nameEn": "Australia", + "nameLocal": "Australia", + "flag": "🇦🇺", + "region": "Asia & Pacific" + }, + { + "iso": "TH", + "dial": "66", + "nameEn": "Thailand", + "nameLocal": "ประเทศไทย", + "flag": "🇹🇭", + "region": "Asia & Pacific" + }, + { + "iso": "VN", + "dial": "84", + "nameEn": "Vietnam", + "nameLocal": "Việt Nam", + "flag": "🇻🇳", + "region": "Asia & Pacific" + }, + { + "iso": "BD", + "dial": "880", + "nameEn": "Bangladesh", + "nameLocal": "গণপ্রজাতন্ত্রী বাংলাদেশ", + "flag": "🇧🇩", + "region": "Asia & Pacific" + }, + { + "iso": "TW", + "dial": "886", + "nameEn": "Taiwan, Province of China", + "nameLocal": "Taiwan", + "flag": "🇹🇼", + "region": "Asia & Pacific" + } +] diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 15999f9..336039a 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -7,6 +7,7 @@ "rootDir": "./src", "strict": true, "esModuleInterop": true, + "resolveJsonModule": true, "skipLibCheck": true }, "include": ["src/**/*"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 636a425..76c7142 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: packages/shared: devDependencies: + country-codes-list: + specifier: ^2.0.0 + version: 2.0.0 typescript: specifier: ^5.7.3 version: 5.7.3 @@ -1938,6 +1941,9 @@ packages: resolution: {integrity: sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==} engines: {node: '>=14'} + country-codes-list@2.0.0: + resolution: {integrity: sha512-KZqq/LBdCD76hQCa6nOx0bA/nIjYly1OtV8+Bbt/4SW+mJEqGk7oZHjUj7PRrV0gXJJKs6Tv2cIntFdofBByvA==} + create-jest@29.7.0: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5615,6 +5621,8 @@ snapshots: parse-json: 5.2.0 path-type: 4.0.0 + country-codes-list@2.0.0: {} + create-jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.7.3)): dependencies: '@jest/types': 29.6.3