From 5b7590a2963f56b2ad98a5e2bfe0b7c57ab60aaa Mon Sep 17 00:00:00 2001 From: zzswang Date: Mon, 15 Jun 2026 23:15:46 +0800 Subject: [PATCH 1/3] fix(user): optimize findByLogin to use indexed single-field queries Replace $or collection scan with prioritized lookups by login format so phone, email, and username each hit their partial unique indexes. Co-authored-by: Cursor --- src/user/user.service.spec.ts | 17 ++++++++++++++++- src/user/user.service.ts | 35 +++++++++++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts index 20efdbe..1ae714c 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,21 @@ 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); + }); }); describe('countUser', () => { diff --git a/src/user/user.service.ts b/src/user/user.service.ts index dfad579..13c9206 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -257,10 +257,37 @@ 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 { + const lookups: Array<() => Promise> = []; + + if (login.includes('@')) { + lookups.push( + () => this.findByEmail(login), + () => this.findByUsername(login), + () => this.findByPhone(login) + ); + } else if (/^\d+$/.test(login)) { + lookups.push( + () => this.findByPhone(login), + () => this.findByUsername(login), + () => this.findByEmail(login) + ); + } else { + lookups.push( + () => this.findByUsername(login), + () => this.findByEmail(login), + () => this.findByPhone(login) + ); + } + + for (const lookup of lookups) { + const user = await lookup(); + if (user) { + return user; + } + } + + return null; } /** From 3182b7e364c311b6d5486e92b56182fdc89ba893 Mon Sep 17 00:00:00 2001 From: zzswang Date: Tue, 16 Jun 2026 08:31:54 +0800 Subject: [PATCH 2/3] fix(user): add IsPhone validator and single-field login lookup Use regex-based phone validation for user writes and auth phone DTOs. Replace findByLogin $or/fallback with detectLoginField + one indexed query. Co-authored-by: Cursor --- src/auth/dto/login.dto.ts | 4 +-- src/auth/dto/register.dto.ts | 4 +-- src/auth/dto/reset-password.dto.ts | 4 +-- src/common/validate.test.spec.ts | 32 ++++++++++++++++- src/common/validate.ts | 55 ++++++++++++++++++++++++++++++ src/user/entities/user.entity.ts | 4 +-- src/user/user.service.spec.ts | 6 ++++ src/user/user.service.ts | 39 +++++---------------- test/captcha.e2e-spec.ts | 2 +- 9 files changed, 110 insertions(+), 40 deletions(-) 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..64303ca 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,34 @@ describe('common validate', () => { ) ).toBe(false); }); + + describe('isPhone', () => { + it('should accept CN mobile with or without separators', () => { + expect(isPhone('18612345678')).toBe(true); + expect(isPhone('186-1234-5678')).toBe(true); + expect(isPhone('186 1234 5678')).toBe(true); + }); + + it('should accept international numbers with + prefix', () => { + expect(isPhone('+1-415-555-2671')).toBe(true); + expect(isPhone('+44 7911 123456')).toBe(true); + }); + + it('should reject invalid phone numbers', () => { + expect(isPhone('11111111111')).toBe(false); + expect(isPhone('12345')).toBe(false); + expect(isPhone('alice@test.com')).toBe(false); + expect(isPhone('447911123456')).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..d196c8c 100644 --- a/src/common/validate.ts +++ b/src/common/validate.ts @@ -50,6 +50,61 @@ export function IsNs(validationOptions?: ValidationOptions) { }; } +/** 中国手机号:1 开头,第二位 3-9,允许连字符/空格 */ +const CN_PHONE = /^1[3-9][\d\s-]{9,11}$/; + +/** 国际号码:+ 开头,后跟数字及常见分隔符 */ +const INTL_PHONE = /^\+\d[\d\s-]{5,20}$/; + +export function isPhone(value: unknown): boolean { + if (typeof value !== 'string') { + return false; + } + const s = value.trim(); + if (CN_PHONE.test(s)) { + return s.replace(/[\s-]/g, '').length === 11; + } + return INTL_PHONE.test(s); +} + +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 (CN mobile or international + prefix)`; + }, + }, + }); + }; +} + 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 1ae714c..fe16831 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -125,6 +125,12 @@ describe('UserService', () => { 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 13c9206..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,37 +258,15 @@ export class UserService { * @param login phone/username/email * @returns */ - async findByLogin(login: string): Promise { - const lookups: Array<() => Promise> = []; - - if (login.includes('@')) { - lookups.push( - () => this.findByEmail(login), - () => this.findByUsername(login), - () => this.findByPhone(login) - ); - } else if (/^\d+$/.test(login)) { - lookups.push( - () => this.findByPhone(login), - () => this.findByUsername(login), - () => this.findByEmail(login) - ); - } else { - lookups.push( - () => this.findByUsername(login), - () => this.findByEmail(login), - () => this.findByPhone(login) - ); - } - - for (const lookup of lookups) { - const user = await lookup(); - if (user) { - return user; - } + 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); } - - return null; } /** 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') From 8e55d5417720d24eb2c60f419d5e5f24ce55e1d8 Mon Sep 17 00:00:00 2001 From: zzswang Date: Tue, 16 Jun 2026 08:46:00 +0800 Subject: [PATCH 3/3] refactor(validate): unify isPhone without CN-specific rules Treat all phone numbers with one pattern: optional +, digits, separators, and 6-15 digit length. Co-authored-by: Cursor --- src/common/validate.test.spec.ts | 9 +++------ src/common/validate.ts | 17 +++++++++-------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/common/validate.test.spec.ts b/src/common/validate.test.spec.ts index 64303ca..eaf620f 100644 --- a/src/common/validate.test.spec.ts +++ b/src/common/validate.test.spec.ts @@ -20,22 +20,19 @@ describe('common validate', () => { }); describe('isPhone', () => { - it('should accept CN mobile with or without separators', () => { + 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); - }); - - it('should accept international numbers with + prefix', () => { 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('11111111111')).toBe(false); expect(isPhone('12345')).toBe(false); expect(isPhone('alice@test.com')).toBe(false); - expect(isPhone('447911123456')).toBe(false); + expect(isPhone('alice-test')).toBe(false); }); }); diff --git a/src/common/validate.ts b/src/common/validate.ts index d196c8c..254014f 100644 --- a/src/common/validate.ts +++ b/src/common/validate.ts @@ -50,21 +50,22 @@ export function IsNs(validationOptions?: ValidationOptions) { }; } -/** 中国手机号:1 开头,第二位 3-9,允许连字符/空格 */ -const CN_PHONE = /^1[3-9][\d\s-]{9,11}$/; +/** 手机号:可选 + 前缀,仅含数字及连字符/空格 */ +const PHONE = /^\+?[\d\s-]+$/; -/** 国际号码:+ 开头,后跟数字及常见分隔符 */ -const INTL_PHONE = /^\+\d[\d\s-]{5,20}$/; +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 (CN_PHONE.test(s)) { - return s.replace(/[\s-]/g, '').length === 11; + if (!s || !PHONE.test(s)) { + return false; } - return INTL_PHONE.test(s); + const digits = s.replace(/\D/g, ''); + return digits.length >= MIN_PHONE_DIGITS && digits.length <= MAX_PHONE_DIGITS; } export type LoginField = 'email' | 'phone' | 'username'; @@ -98,7 +99,7 @@ export function IsPhone(validationOptions?: ValidationOptions) { return value ? isPhone(value) : true; }, defaultMessage(args: ValidationArguments) { - return `${args.property} must be a valid phone number (CN mobile or international + prefix)`; + return `${args.property} must be a valid phone number`; }, }, });