diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index d1b54f1..a876ba5 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -1,6 +1,6 @@ import { IsBoolean, IsEmail, IsIP, IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { IsNs } from 'src/common/validate'; +import { IsNs, IsPhone } from 'src/common/validate'; export class LoginDto { /** @@ -23,7 +23,7 @@ export class LoginByPhoneDto { * 手机号 */ @IsNotEmpty() - @IsString() + @IsPhone() phone: string; /** diff --git a/src/auth/dto/register.dto.ts b/src/auth/dto/register.dto.ts index cdf9b9e..eedf4fd 100644 --- a/src/auth/dto/register.dto.ts +++ b/src/auth/dto/register.dto.ts @@ -1,6 +1,6 @@ import { IsEmail, IsIP, IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { IsNs, IsPassword, IsUsername } from 'src/common/validate'; +import { IsNs, IsPassword, IsPhone, IsUsername } from 'src/common/validate'; export class RegisterDto { /** @@ -65,7 +65,7 @@ export class RegisterbyPhoneDto { * 手机号 */ @IsNotEmpty() - @IsString() + @IsPhone() phone: string; /** diff --git a/src/auth/dto/reset-password.dto.ts b/src/auth/dto/reset-password.dto.ts index 4bcc22f..08167d3 100644 --- a/src/auth/dto/reset-password.dto.ts +++ b/src/auth/dto/reset-password.dto.ts @@ -1,13 +1,13 @@ import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; -import { IsPassword } from 'src/common/validate'; +import { IsPassword, IsPhone } from 'src/common/validate'; export class ResetPasswordByPhoneDto { /** * 手机号 */ @IsNotEmpty() - @IsString() + @IsPhone() phone: string; /** diff --git a/src/common/validate.test.spec.ts b/src/common/validate.test.spec.ts index d090c89..eaf620f 100644 --- a/src/common/validate.test.spec.ts +++ b/src/common/validate.test.spec.ts @@ -1,4 +1,4 @@ -import { isNs } from './validate'; +import { detectLoginField, isNs, isPhone } from './validate'; describe('common validate', () => { it('should validate ns', () => { @@ -18,4 +18,31 @@ describe('common validate', () => { ) ).toBe(false); }); + + describe('isPhone', () => { + it('should accept numbers with or without + prefix and separators', () => { + expect(isPhone('18612345678')).toBe(true); + expect(isPhone('186-1234-5678')).toBe(true); + expect(isPhone('186 1234 5678')).toBe(true); + expect(isPhone('+1-415-555-2671')).toBe(true); + expect(isPhone('+44 7911 123456')).toBe(true); + expect(isPhone('447911123456')).toBe(true); + }); + + it('should reject invalid phone numbers', () => { + expect(isPhone('12345')).toBe(false); + expect(isPhone('alice@test.com')).toBe(false); + expect(isPhone('alice-test')).toBe(false); + }); + }); + + describe('detectLoginField', () => { + it('should detect email, phone and username', () => { + expect(detectLoginField('admin@36node.com')).toBe('email'); + expect(detectLoginField('18612345678')).toBe('phone'); + expect(detectLoginField('186-1234-5678')).toBe('phone'); + expect(detectLoginField('+1-415-555-2671')).toBe('phone'); + expect(detectLoginField('alice123')).toBe('username'); + }); + }); }); diff --git a/src/common/validate.ts b/src/common/validate.ts index 768802d..254014f 100644 --- a/src/common/validate.ts +++ b/src/common/validate.ts @@ -50,6 +50,62 @@ export function IsNs(validationOptions?: ValidationOptions) { }; } +/** 手机号:可选 + 前缀,仅含数字及连字符/空格 */ +const PHONE = /^\+?[\d\s-]+$/; + +const MIN_PHONE_DIGITS = 6; +const MAX_PHONE_DIGITS = 15; + +export function isPhone(value: unknown): boolean { + if (typeof value !== 'string') { + return false; + } + const s = value.trim(); + if (!s || !PHONE.test(s)) { + return false; + } + const digits = s.replace(/\D/g, ''); + return digits.length >= MIN_PHONE_DIGITS && digits.length <= MAX_PHONE_DIGITS; +} + +export type LoginField = 'email' | 'phone' | 'username'; + +/** + * 根据 login 字符串判定登录字段类型(email / phone / username) + */ +export function detectLoginField(login: string): LoginField { + if (login.includes('@')) { + return 'email'; + } + if (isPhone(login)) { + return 'phone'; + } + return 'username'; +} + +/** + * 验证 phone 格式 + */ +export function IsPhone(validationOptions?: ValidationOptions) { + return function (object: any, propertyName: string) { + registerDecorator({ + name: 'isPhone', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: { + validate(value: any) { + return value ? isPhone(value) : true; + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be a valid phone number`; + }, + }, + }); + }; +} + export function isUserName(value: any): boolean { const regex = /^[a-zA-Z][a-zA-Z0-9_.-]{1,63}$/; return typeof value === 'string' && regex.test(value); diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index 00449ac..c9e7d0e 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -4,7 +4,7 @@ import { Type } from 'class-transformer'; import { IsBoolean, IsDate, IsEmail, IsInt, IsIP, IsOptional, IsString } from 'class-validator'; import { Document } from 'mongoose'; -import { IsPassword, IsUsername } from 'src/common/validate'; +import { IsPassword, IsPhone, IsUsername } from 'src/common/validate'; import { SortFields } from 'src/lib/sort'; import { helper, MongoEntity } from 'src/mongo'; @@ -172,7 +172,7 @@ export class UserDoc { * 手机号 */ @IsOptional() - @IsString() + @IsPhone() @Prop() phone?: string | null; diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts index 20efdbe..fe16831 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -100,7 +100,7 @@ describe('UserService', () => { }); describe('findByLogin', () => { - it('should find a user by login', async () => { + it('should find a user by username', async () => { const userDoc = mockUser(); const user = await userService.create(userDoc); const found = await userService.findByLogin(user.username); @@ -110,6 +110,27 @@ describe('UserService', () => { expect(found).toMatchObject(rest); expect(userService.checkPassword(found.password, password)).toBeTruthy(); }); + + it('should find a user by phone', async () => { + const phone = '15158033280'; + const userDoc = { ...mockUser(), phone }; + const user = await userService.create(userDoc); + const found = await userService.findByLogin(phone); + expect(found?.id).toBe(user.id); + }); + + it('should find a user by email', async () => { + const userDoc = mockUser(); + const user = await userService.create(userDoc); + const found = await userService.findByLogin(user.email); + expect(found?.id).toBe(user.id); + }); + + it('should not fallback to other fields when phone is not found', async () => { + await userService.create(mockUser()); + const found = await userService.findByLogin('15158033280'); + expect(found).toBeNull(); + }); }); describe('countUser', () => { diff --git a/src/user/user.service.ts b/src/user/user.service.ts index dfad579..7ec1c49 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -5,6 +5,7 @@ import { DeleteResult } from 'mongodb'; import { FilterQuery, Model } from 'mongoose'; import { nanoid } from 'nanoid'; +import { detectLoginField } from 'src/common/validate'; import { ErrorCodes } from 'src/constants'; import { createHash, validateHash } from 'src/lib/crypt'; import { countTailZero, inferNumber } from 'src/lib/lang/number'; @@ -257,10 +258,15 @@ export class UserService { * @param login phone/username/email * @returns */ - findByLogin(login: string): Promise { - return this.userModel - .findOne({ $or: [{ username: login }, { email: login }, { phone: login }] }) - .exec(); + async findByLogin(login: string): Promise { + switch (detectLoginField(login)) { + case 'email': + return this.findByEmail(login); + case 'phone': + return this.findByPhone(login); + case 'username': + return this.findByUsername(login); + } } /** diff --git a/test/captcha.e2e-spec.ts b/test/captcha.e2e-spec.ts index d708a90..91a00b5 100644 --- a/test/captcha.e2e-spec.ts +++ b/test/captcha.e2e-spec.ts @@ -114,7 +114,7 @@ describe('Captcha workflow (e2e)', () => { // 错误的手机号 await request(app.getHttpServer()) .post('/auth/@loginByPhone') - .send({ phone: '11111111111', autoRegister: false, ...captchaDoc }) + .send({ phone: '13900139001', autoRegister: false, ...captchaDoc }) .set('Content-Type', 'application/json') .set('x-api-key', auth.apiKey) .set('Accept', 'application/json')