forked from datawhale/whale-town-end
resolve: 解决ANGJustinl-main与main分支的合并冲突
- 修复文件路径冲突(business/login -> business/auth结构调整) - 保留ANGJustinl分支的验证码登录功能 - 合并main分支的用户状态管理和项目结构改进 - 修复邮件服务中缺失的login_verification模板问题 - 更新测试用例以包含验证码登录功能 - 统一导入路径以适配新的目录结构
This commit is contained in:
27
src/core/admin_core/admin_core.module.ts
Normal file
27
src/core/admin_core/admin_core.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 管理员核心模块
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供管理员登录鉴权能力(签名Token)
|
||||
* - 提供管理员账户启动引导(可选)
|
||||
* - 为业务层 AdminModule 提供可复用的核心服务
|
||||
*
|
||||
* 依赖模块:
|
||||
* - UsersModule: 用户数据访问(数据库/内存双模式)
|
||||
* - ConfigModule: 环境变量与配置读取
|
||||
*
|
||||
* @author jianuo
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-19
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AdminCoreService } from './admin_core.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [AdminCoreService],
|
||||
exports: [AdminCoreService],
|
||||
})
|
||||
export class AdminCoreModule {}
|
||||
459
src/core/admin_core/admin_core.service.spec.ts
Normal file
459
src/core/admin_core/admin_core.service.spec.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
import { AdminAuthPayload, AdminCoreService } from './admin_core.service';
|
||||
import { Users } from '../db/users/users.entity';
|
||||
|
||||
jest.mock('bcrypt', () => ({
|
||||
compare: jest.fn(),
|
||||
hash: jest.fn(),
|
||||
}));
|
||||
|
||||
type UsersServiceLike = {
|
||||
findByUsername: jest.Mock;
|
||||
findByEmail: jest.Mock;
|
||||
findAll: jest.Mock;
|
||||
update: jest.Mock;
|
||||
create: jest.Mock;
|
||||
};
|
||||
|
||||
describe('AdminCoreService', () => {
|
||||
let configService: Pick<ConfigService, 'get'>;
|
||||
let usersService: UsersServiceLike;
|
||||
let service: AdminCoreService;
|
||||
|
||||
const secret = '0123456789abcdef';
|
||||
|
||||
const signToken = (payload: AdminAuthPayload, tokenSecret: string): string => {
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
const payloadPart = Buffer.from(payloadJson, 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
|
||||
const signature = crypto
|
||||
.createHmac('sha256', tokenSecret)
|
||||
.update(payloadPart)
|
||||
.digest('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
|
||||
return `${payloadPart}.${signature}`;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
||||
configService = {
|
||||
get: jest.fn((key: string, defaultValue?: any) => {
|
||||
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||
if (key === 'ADMIN_TOKEN_TTL_SECONDS') return defaultValue ?? '28800';
|
||||
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'false';
|
||||
return defaultValue;
|
||||
}),
|
||||
};
|
||||
|
||||
usersService = {
|
||||
findByUsername: jest.fn(),
|
||||
findByEmail: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
};
|
||||
|
||||
service = new AdminCoreService(configService as ConfigService, usersService as any);
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should reject when admin does not exist', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(null);
|
||||
|
||||
await expect(service.login({ identifier: 'admin', password: 'Admin123456' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should reject non-admin user', async () => {
|
||||
usersService.findByUsername.mockResolvedValue({
|
||||
id: BigInt(1),
|
||||
username: 'user',
|
||||
nickname: 'U',
|
||||
role: 1,
|
||||
password_hash: 'hash',
|
||||
} as unknown as Users);
|
||||
|
||||
await expect(service.login({ identifier: 'user', password: 'Admin123456' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should reject admin without password_hash', async () => {
|
||||
usersService.findByUsername.mockResolvedValue({
|
||||
id: BigInt(1),
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
password_hash: null,
|
||||
} as unknown as Users);
|
||||
|
||||
await expect(service.login({ identifier: 'admin', password: 'Admin123456' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should reject wrong password', async () => {
|
||||
usersService.findByUsername.mockResolvedValue({
|
||||
id: BigInt(1),
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
password_hash: 'hash',
|
||||
} as unknown as Users);
|
||||
|
||||
const bcryptAny = bcrypt as any;
|
||||
bcryptAny.compare.mockResolvedValue(false);
|
||||
|
||||
await expect(service.login({ identifier: 'admin', password: 'bad' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should login with valid credentials and generate verifiable token', async () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
usersService.findByUsername.mockResolvedValue({
|
||||
id: BigInt(1),
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
password_hash: 'hash',
|
||||
} as unknown as Users);
|
||||
|
||||
const bcryptAny = bcrypt as any;
|
||||
bcryptAny.compare.mockResolvedValue(true);
|
||||
|
||||
const result = await service.login({ identifier: 'admin', password: 'Admin123456' });
|
||||
|
||||
expect(result.admin).toEqual({ id: '1', username: 'admin', nickname: '管理员', role: 9 });
|
||||
expect(result.access_token).toContain('.');
|
||||
expect(result.expires_at).toBeGreaterThan(now);
|
||||
|
||||
const payload = service.verifyToken(result.access_token);
|
||||
expect(payload).toMatchObject({ adminId: '1', username: 'admin', role: 9 });
|
||||
expect(payload.iat).toBe(now);
|
||||
expect(payload.exp).toBe(result.expires_at);
|
||||
});
|
||||
|
||||
it('should find admin by email identifier', async () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
usersService.findByUsername.mockResolvedValue(null);
|
||||
usersService.findByEmail.mockResolvedValue({
|
||||
id: BigInt(1),
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
password_hash: 'hash',
|
||||
} as unknown as Users);
|
||||
|
||||
const bcryptAny = bcrypt as any;
|
||||
bcryptAny.compare.mockResolvedValue(true);
|
||||
|
||||
const result = await service.login({ identifier: 'admin@test.com', password: 'Admin123456' });
|
||||
expect(result.admin.role).toBe(9);
|
||||
});
|
||||
|
||||
it('should find admin by phone identifier', async () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
usersService.findByUsername.mockResolvedValue(null);
|
||||
usersService.findByEmail.mockResolvedValue(null);
|
||||
usersService.findAll.mockResolvedValue([
|
||||
{
|
||||
id: BigInt(2),
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
phone: '+86 13800000000',
|
||||
password_hash: 'hash',
|
||||
} as unknown as Users,
|
||||
]);
|
||||
|
||||
const bcryptAny = bcrypt as any;
|
||||
bcryptAny.compare.mockResolvedValue(true);
|
||||
|
||||
const result = await service.login({ identifier: '+86 13800000000', password: 'Admin123456' });
|
||||
expect(result.admin.id).toBe('2');
|
||||
});
|
||||
|
||||
it('should return phone-matched user via findUserByIdentifier (coverage for line 168)', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(null);
|
||||
usersService.findByEmail.mockResolvedValue(null);
|
||||
usersService.findAll.mockResolvedValue([
|
||||
{
|
||||
id: BigInt(10),
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
phone: '13800000000',
|
||||
} as unknown as Users,
|
||||
]);
|
||||
|
||||
const found = await (service as any).findUserByIdentifier('13800000000');
|
||||
expect(found?.id?.toString()).toBe('10');
|
||||
});
|
||||
|
||||
it('should fallback to default TTL when ADMIN_TOKEN_TTL_SECONDS is invalid', async () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||
if (key === 'ADMIN_TOKEN_TTL_SECONDS') return 'not-a-number';
|
||||
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'false';
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
usersService.findByUsername.mockResolvedValue({
|
||||
id: BigInt(1),
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
password_hash: 'hash',
|
||||
} as unknown as Users);
|
||||
|
||||
const bcryptAny = bcrypt as any;
|
||||
bcryptAny.compare.mockResolvedValue(true);
|
||||
|
||||
const result = await service.login({ identifier: 'admin', password: 'Admin123456' });
|
||||
expect(result.expires_at).toBe(now + 28800 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyToken', () => {
|
||||
it('should reject token when secret missing/too short', () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'ADMIN_TOKEN_SECRET') return 'short';
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
expect(() => service.verifyToken('a.b')).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should reject invalid token format', () => {
|
||||
expect(() => service.verifyToken('no-dot')).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should reject token when payload JSON cannot be parsed (but signature valid)', () => {
|
||||
const payloadPart = Buffer.from('not-json', 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
|
||||
const signature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(payloadPart)
|
||||
.digest('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
|
||||
expect(() => service.verifyToken(`${payloadPart}.${signature}`)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should accept valid signed token and return payload', () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
const payload: AdminAuthPayload = {
|
||||
adminId: '1',
|
||||
username: 'admin',
|
||||
role: 9,
|
||||
iat: now,
|
||||
exp: now + 1000,
|
||||
};
|
||||
|
||||
const token = signToken(payload, secret);
|
||||
expect(service.verifyToken(token)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('should reject expired token', () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
const payload: AdminAuthPayload = {
|
||||
adminId: '1',
|
||||
username: 'admin',
|
||||
role: 9,
|
||||
iat: now - 1000,
|
||||
exp: now - 1,
|
||||
};
|
||||
|
||||
const token = signToken(payload, secret);
|
||||
expect(() => service.verifyToken(token)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should reject token with invalid signature', () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
const payload: AdminAuthPayload = {
|
||||
adminId: '1',
|
||||
username: 'admin',
|
||||
role: 9,
|
||||
iat: now,
|
||||
exp: now + 1000,
|
||||
};
|
||||
|
||||
const token = signToken(payload, 'different_secret_012345');
|
||||
expect(() => service.verifyToken(token)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should reject token with non-admin role', () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
const payload: AdminAuthPayload = {
|
||||
adminId: '1',
|
||||
username: 'user',
|
||||
role: 1,
|
||||
iat: now,
|
||||
exp: now + 1000,
|
||||
};
|
||||
|
||||
const token = signToken(payload, secret);
|
||||
expect(() => service.verifyToken(token)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should reject token when signature length mismatches expected', () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
const payload: AdminAuthPayload = {
|
||||
adminId: '1',
|
||||
username: 'admin',
|
||||
role: 9,
|
||||
iat: now,
|
||||
exp: now + 1000,
|
||||
};
|
||||
|
||||
// Valid payloadPart, but deliberately wrong signature length
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
const payloadPart = Buffer.from(payloadJson, 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
|
||||
expect(() => service.verifyToken(`${payloadPart}.x`)).toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetUserPassword', () => {
|
||||
it('should update user password_hash when password is strong', async () => {
|
||||
const bcryptAny = bcrypt as any;
|
||||
bcryptAny.hash.mockResolvedValue('hashed');
|
||||
usersService.update.mockResolvedValue({} as any);
|
||||
|
||||
await service.resetUserPassword(BigInt(5), 'NewPass1234');
|
||||
|
||||
expect(usersService.update).toHaveBeenCalledWith(BigInt(5), { password_hash: 'hashed' });
|
||||
});
|
||||
|
||||
it('should reject weak password', async () => {
|
||||
await expect(service.resetUserPassword(BigInt(5), 'short')).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should validate password strength directly (letters + numbers, 8+)', () => {
|
||||
expect(() => (service as any).validatePasswordStrength('12345678')).toThrow(BadRequestException);
|
||||
expect(() => (service as any).validatePasswordStrength('abcdefgh')).toThrow(BadRequestException);
|
||||
expect(() => (service as any).validatePasswordStrength('Abcdef12')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject too-long password (>128)', () => {
|
||||
const long = `Abc1${'x'.repeat(200)}`;
|
||||
expect(() => (service as any).validatePasswordStrength(long)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bootstrapAdminIfEnabled', () => {
|
||||
it('should do nothing when bootstrap disabled', async () => {
|
||||
await service.onModuleInit();
|
||||
expect(usersService.findByUsername).not.toHaveBeenCalled();
|
||||
expect(usersService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip when enabled but missing username/password', async () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
|
||||
if (key === 'ADMIN_USERNAME') return undefined;
|
||||
if (key === 'ADMIN_PASSWORD') return undefined;
|
||||
if (key === 'ADMIN_NICKNAME') return defaultValue ?? '管理员';
|
||||
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(usersService.findByUsername).not.toHaveBeenCalled();
|
||||
expect(usersService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip when existing user already present', async () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
|
||||
if (key === 'ADMIN_USERNAME') return 'admin';
|
||||
if (key === 'ADMIN_PASSWORD') return 'Admin123456';
|
||||
if (key === 'ADMIN_NICKNAME') return '管理员';
|
||||
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
usersService.findByUsername.mockResolvedValue({ id: BigInt(1), username: 'admin', role: 9 } as any);
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(usersService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip and warn when existing user has same username but non-admin role', async () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
|
||||
if (key === 'ADMIN_USERNAME') return 'admin';
|
||||
if (key === 'ADMIN_PASSWORD') return 'Admin123456';
|
||||
if (key === 'ADMIN_NICKNAME') return '管理员';
|
||||
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
usersService.findByUsername.mockResolvedValue({ id: BigInt(1), username: 'admin', role: 1 } as any);
|
||||
|
||||
await service.onModuleInit();
|
||||
expect(usersService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create admin user when enabled and not existing', async () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
|
||||
if (key === 'ADMIN_USERNAME') return 'admin';
|
||||
if (key === 'ADMIN_PASSWORD') return 'Admin123456';
|
||||
if (key === 'ADMIN_NICKNAME') return '管理员';
|
||||
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
usersService.findByUsername.mockResolvedValue(null);
|
||||
const bcryptAny = bcrypt as any;
|
||||
bcryptAny.hash.mockResolvedValue('hashed');
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(usersService.create).toHaveBeenCalledWith({
|
||||
username: 'admin',
|
||||
password_hash: 'hashed',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
email_verified: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
285
src/core/admin_core/admin_core.service.ts
Normal file
285
src/core/admin_core/admin_core.service.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* 管理员核心服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 管理员登录校验(仅允许 role=9)
|
||||
* - 生成/验证管理员签名Token(HMAC-SHA256)
|
||||
* - 启动时可选引导创建管理员账号(通过环境变量启用)
|
||||
*
|
||||
* 安全说明:
|
||||
* - 本服务生成的Token为对称签名Token(非JWT标准实现),但具备有效期与签名校验
|
||||
* - 仅用于后台管理用途;生产环境务必配置强随机的 ADMIN_TOKEN_SECRET
|
||||
*
|
||||
* @author jianuo
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-19
|
||||
*/
|
||||
|
||||
import { BadRequestException, Inject, Injectable, Logger, OnModuleInit, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
import { Users } from '../db/users/users.entity';
|
||||
import { UsersService } from '../db/users/users.service';
|
||||
import { UsersMemoryService } from '../db/users/users_memory.service';
|
||||
|
||||
export interface AdminLoginRequest {
|
||||
identifier: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AdminAuthPayload {
|
||||
adminId: string;
|
||||
username: string;
|
||||
role: number;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export interface AdminLoginResult {
|
||||
admin: {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
role: number;
|
||||
};
|
||||
access_token: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminCoreService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AdminCoreService.name);
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@Inject('UsersService') private readonly usersService: UsersService | UsersMemoryService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.bootstrapAdminIfEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员登录
|
||||
*/
|
||||
async login(request: AdminLoginRequest): Promise<AdminLoginResult> {
|
||||
const { identifier, password } = request;
|
||||
|
||||
const adminUser = await this.findUserByIdentifier(identifier);
|
||||
if (!adminUser) {
|
||||
throw new UnauthorizedException('管理员账号不存在');
|
||||
}
|
||||
|
||||
if (adminUser.role !== 9) {
|
||||
throw new UnauthorizedException('无管理员权限');
|
||||
}
|
||||
|
||||
if (!adminUser.password_hash) {
|
||||
throw new UnauthorizedException('管理员账户未设置密码,无法登录');
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(password, adminUser.password_hash);
|
||||
if (!ok) {
|
||||
throw new UnauthorizedException('密码错误');
|
||||
}
|
||||
|
||||
const ttlSeconds = this.getAdminTokenTtlSeconds();
|
||||
const now = Date.now();
|
||||
const payload: AdminAuthPayload = {
|
||||
adminId: adminUser.id.toString(),
|
||||
username: adminUser.username,
|
||||
role: adminUser.role,
|
||||
iat: now,
|
||||
exp: now + ttlSeconds * 1000,
|
||||
};
|
||||
|
||||
const token = this.signPayload(payload);
|
||||
|
||||
return {
|
||||
admin: {
|
||||
id: adminUser.id.toString(),
|
||||
username: adminUser.username,
|
||||
nickname: adminUser.nickname,
|
||||
role: adminUser.role,
|
||||
},
|
||||
access_token: token,
|
||||
expires_at: payload.exp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验管理员Token并返回Payload
|
||||
*/
|
||||
verifyToken(token: string): AdminAuthPayload {
|
||||
const secret = this.getAdminTokenSecret();
|
||||
const [payloadPart, signaturePart] = token.split('.');
|
||||
|
||||
if (!payloadPart || !signaturePart) {
|
||||
throw new UnauthorizedException('Token格式错误');
|
||||
}
|
||||
|
||||
const expected = this.hmacSha256Base64Url(payloadPart, secret);
|
||||
if (!this.safeEqual(signaturePart, expected)) {
|
||||
throw new UnauthorizedException('Token签名无效');
|
||||
}
|
||||
|
||||
const payloadJson = Buffer.from(this.base64UrlToBase64(payloadPart), 'base64').toString('utf-8');
|
||||
let payload: AdminAuthPayload;
|
||||
|
||||
try {
|
||||
payload = JSON.parse(payloadJson) as AdminAuthPayload;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Token解析失败');
|
||||
}
|
||||
|
||||
if (!payload?.adminId || payload.role !== 9) {
|
||||
throw new UnauthorizedException('无管理员权限');
|
||||
}
|
||||
|
||||
if (typeof payload.exp !== 'number' || Date.now() > payload.exp) {
|
||||
throw new UnauthorizedException('Token已过期');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员重置用户密码(直接设置新密码)
|
||||
*/
|
||||
async resetUserPassword(userId: bigint, newPassword: string): Promise<void> {
|
||||
this.validatePasswordStrength(newPassword);
|
||||
|
||||
const passwordHash = await this.hashPassword(newPassword);
|
||||
await this.usersService.update(userId, { password_hash: passwordHash });
|
||||
}
|
||||
|
||||
private async findUserByIdentifier(identifier: string): Promise<Users | null> {
|
||||
const byUsername = await this.usersService.findByUsername(identifier);
|
||||
if (byUsername) return byUsername;
|
||||
|
||||
if (this.isEmail(identifier)) {
|
||||
const byEmail = await this.usersService.findByEmail(identifier);
|
||||
if (byEmail) return byEmail;
|
||||
}
|
||||
|
||||
if (this.isPhoneNumber(identifier)) {
|
||||
const users = await this.usersService.findAll(1000, 0);
|
||||
return users.find((u: Users) => u.phone === identifier) || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async bootstrapAdminIfEnabled(): Promise<void> {
|
||||
const enabled = this.configService.get<string>('ADMIN_BOOTSTRAP_ENABLED', 'false') === 'true';
|
||||
if (!enabled) return;
|
||||
|
||||
const username = this.configService.get<string>('ADMIN_USERNAME');
|
||||
const password = this.configService.get<string>('ADMIN_PASSWORD');
|
||||
const nickname = this.configService.get<string>('ADMIN_NICKNAME', '管理员');
|
||||
|
||||
if (!username || !password) {
|
||||
this.logger.warn('已启用管理员引导,但未配置 ADMIN_USERNAME / ADMIN_PASSWORD,跳过创建');
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await this.usersService.findByUsername(username);
|
||||
if (existing) {
|
||||
if (existing.role !== 9) {
|
||||
this.logger.warn(`管理员引导发现同名用户但role!=9:${username},跳过`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.validatePasswordStrength(password);
|
||||
const passwordHash = await this.hashPassword(password);
|
||||
|
||||
await this.usersService.create({
|
||||
username,
|
||||
password_hash: passwordHash,
|
||||
nickname,
|
||||
role: 9,
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
this.logger.log(`管理员账号已创建:${username} (role=9)`);
|
||||
}
|
||||
|
||||
private getAdminTokenSecret(): string {
|
||||
const secret = this.configService.get<string>('ADMIN_TOKEN_SECRET');
|
||||
if (!secret || secret.length < 16) {
|
||||
throw new BadRequestException('ADMIN_TOKEN_SECRET 未配置或过短(至少16字符)');
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
private getAdminTokenTtlSeconds(): number {
|
||||
const raw = this.configService.get<string>('ADMIN_TOKEN_TTL_SECONDS', '28800'); // 8h
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 28800;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private signPayload(payload: AdminAuthPayload): string {
|
||||
const secret = this.getAdminTokenSecret();
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
const payloadPart = this.base64ToBase64Url(Buffer.from(payloadJson, 'utf-8').toString('base64'));
|
||||
const signature = this.hmacSha256Base64Url(payloadPart, secret);
|
||||
return `${payloadPart}.${signature}`;
|
||||
}
|
||||
|
||||
private hmacSha256Base64Url(data: string, secret: string): string {
|
||||
const digest = crypto.createHmac('sha256', secret).update(data).digest('base64');
|
||||
return this.base64ToBase64Url(digest);
|
||||
}
|
||||
|
||||
private base64ToBase64Url(base64: string): string {
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
private base64UrlToBase64(base64Url: string): string {
|
||||
const padded = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padLen = (4 - (padded.length % 4)) % 4;
|
||||
return padded + '='.repeat(padLen);
|
||||
}
|
||||
|
||||
private safeEqual(a: string, b: string): boolean {
|
||||
const aBuf = Buffer.from(a);
|
||||
const bBuf = Buffer.from(b);
|
||||
if (aBuf.length !== bBuf.length) return false;
|
||||
return crypto.timingSafeEqual(aBuf, bBuf);
|
||||
}
|
||||
|
||||
private isEmail(value: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
|
||||
private isPhoneNumber(value: string): boolean {
|
||||
return /^\+?[0-9\-\s]{6,20}$/.test(value);
|
||||
}
|
||||
|
||||
private validatePasswordStrength(password: string): void {
|
||||
if (password.length < 8) {
|
||||
throw new BadRequestException('密码长度至少8位');
|
||||
}
|
||||
|
||||
if (password.length > 128) {
|
||||
throw new BadRequestException('密码长度不能超过128位');
|
||||
}
|
||||
|
||||
const hasLetter = /[a-zA-Z]/.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
|
||||
if (!hasLetter || !hasNumber) {
|
||||
throw new BadRequestException('密码必须包含字母和数字');
|
||||
}
|
||||
}
|
||||
|
||||
private async hashPassword(password: string): Promise<string> {
|
||||
const saltRounds = 12;
|
||||
return await bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
Max,
|
||||
IsOptional,
|
||||
Length,
|
||||
IsNotEmpty
|
||||
IsNotEmpty,
|
||||
IsEnum
|
||||
} from 'class-validator';
|
||||
import { UserStatus } from '../../../business/user-mgmt/enums/user-status.enum';
|
||||
|
||||
/**
|
||||
* 创建用户数据传输对象
|
||||
@@ -232,4 +234,30 @@ export class CreateUserDto {
|
||||
*/
|
||||
@IsOptional()
|
||||
email_verified?: boolean = false;
|
||||
|
||||
/**
|
||||
* 用户状态
|
||||
*
|
||||
* 业务规则:
|
||||
* - 可选字段,默认为active(正常状态)
|
||||
* - 控制用户账户的可用性和权限
|
||||
* - 支持多种状态:正常、未激活、锁定、禁用等
|
||||
* - 影响用户登录和API访问权限
|
||||
*
|
||||
* 验证规则:
|
||||
* - 可选字段验证
|
||||
* - 枚举类型验证
|
||||
* - 默认值:active(正常状态)
|
||||
*
|
||||
* 状态说明:
|
||||
* - active: 正常状态,可以正常使用
|
||||
* - inactive: 未激活,需要邮箱验证
|
||||
* - locked: 已锁定,临时禁用
|
||||
* - banned: 已禁用,管理员操作
|
||||
* - deleted: 已删除,软删除状态
|
||||
* - pending: 待审核,需要管理员审核
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsEnum(UserStatus, { message: '用户状态必须是有效的枚举值' })
|
||||
status?: UserStatus = UserStatus.ACTIVE;
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
|
||||
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { UserStatus } from '../../../business/user-mgmt/enums/user-status.enum';
|
||||
|
||||
/**
|
||||
* 用户实体类
|
||||
@@ -337,6 +338,44 @@ export class Users {
|
||||
})
|
||||
role: number;
|
||||
|
||||
/**
|
||||
* 用户状态
|
||||
*
|
||||
* 数据库设计:
|
||||
* - 类型:VARCHAR(20),存储状态枚举值
|
||||
* - 约束:非空、默认值'active'
|
||||
* - 索引:用于状态查询和统计
|
||||
*
|
||||
* 业务规则:
|
||||
* - 控制用户账户的可用性和权限
|
||||
* - active:正常状态,可以正常使用
|
||||
* - inactive:未激活,需要邮箱验证
|
||||
* - locked:已锁定,临时禁用
|
||||
* - banned:已禁用,管理员操作
|
||||
* - deleted:已删除,软删除状态
|
||||
* - pending:待审核,需要管理员审核
|
||||
*
|
||||
* 安全控制:
|
||||
* - 登录时检查状态权限
|
||||
* - API访问时验证状态
|
||||
* - 状态变更记录审计日志
|
||||
* - 支持批量状态管理
|
||||
*
|
||||
* 应用场景:
|
||||
* - 账户安全管理
|
||||
* - 用户生命周期控制
|
||||
* - 违规用户处理
|
||||
* - 系统维护和升级
|
||||
*/
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
nullable: true,
|
||||
default: UserStatus.ACTIVE,
|
||||
comment: '用户状态:active-正常,inactive-未激活,locked-锁定,banned-禁用,deleted-删除,pending-待审核'
|
||||
})
|
||||
status?: UserStatus;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*
|
||||
|
||||
@@ -16,6 +16,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { Users } from './users.entity';
|
||||
import { CreateUserDto } from './users.dto';
|
||||
import { UserStatus } from '../../../business/user-mgmt/enums/user-status.enum';
|
||||
import { validate } from 'class-validator';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
|
||||
@@ -31,7 +32,6 @@ export class UsersService {
|
||||
*
|
||||
* @param createUserDto 创建用户的数据传输对象
|
||||
* @returns 创建的用户实体
|
||||
* @throws ConflictException 当用户名、邮箱或手机号已存在时
|
||||
* @throws BadRequestException 当数据验证失败时
|
||||
*/
|
||||
async create(createUserDto: CreateUserDto): Promise<Users> {
|
||||
@@ -46,6 +46,32 @@ export class UsersService {
|
||||
throw new BadRequestException(`数据验证失败: ${errorMessages}`);
|
||||
}
|
||||
|
||||
// 创建用户实体
|
||||
const user = new Users();
|
||||
user.username = createUserDto.username;
|
||||
user.email = createUserDto.email || null;
|
||||
user.phone = createUserDto.phone || null;
|
||||
user.password_hash = createUserDto.password_hash || null;
|
||||
user.nickname = createUserDto.nickname;
|
||||
user.github_id = createUserDto.github_id || null;
|
||||
user.avatar_url = createUserDto.avatar_url || null;
|
||||
user.role = createUserDto.role || 1;
|
||||
user.email_verified = createUserDto.email_verified || false;
|
||||
user.status = createUserDto.status || UserStatus.ACTIVE;
|
||||
|
||||
// 保存到数据库
|
||||
return await this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新用户(带重复检查)
|
||||
*
|
||||
* @param createUserDto 创建用户的数据传输对象
|
||||
* @returns 创建的用户实体
|
||||
* @throws ConflictException 当用户名、邮箱或手机号已存在时
|
||||
* @throws BadRequestException 当数据验证失败时
|
||||
*/
|
||||
async createWithDuplicateCheck(createUserDto: CreateUserDto): Promise<Users> {
|
||||
// 检查用户名是否已存在
|
||||
if (createUserDto.username) {
|
||||
const existingUser = await this.usersRepository.findOne({
|
||||
@@ -86,20 +112,8 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
|
||||
// 创建用户实体
|
||||
const user = new Users();
|
||||
user.username = createUserDto.username;
|
||||
user.email = createUserDto.email || null;
|
||||
user.phone = createUserDto.phone || null;
|
||||
user.password_hash = createUserDto.password_hash || null;
|
||||
user.nickname = createUserDto.nickname;
|
||||
user.github_id = createUserDto.github_id || null;
|
||||
user.avatar_url = createUserDto.avatar_url || null;
|
||||
user.role = createUserDto.role || 1;
|
||||
user.email_verified = createUserDto.email_verified || false;
|
||||
|
||||
// 保存到数据库
|
||||
return await this.usersRepository.save(user);
|
||||
// 调用普通的创建方法
|
||||
return await this.create(createUserDto);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Users } from './users.entity';
|
||||
import { CreateUserDto } from './users.dto';
|
||||
import { UserStatus } from '../../../business/user-mgmt/enums/user-status.enum';
|
||||
import { validate } from 'class-validator';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
|
||||
@@ -98,6 +99,7 @@ export class UsersMemoryService {
|
||||
user.avatar_url = createUserDto.avatar_url || null;
|
||||
user.role = createUserDto.role || 1;
|
||||
user.email_verified = createUserDto.email_verified || false;
|
||||
user.status = createUserDto.status || UserStatus.ACTIVE;
|
||||
user.created_at = new Date();
|
||||
user.updated_at = new Date();
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { LoginCoreService } from './login_core.service';
|
||||
import { UsersService } from '../db/users/users.service';
|
||||
import { EmailService } from '../utils/email/email.service';
|
||||
import { VerificationService, VerificationCodeType } from '../utils/verification/verification.service';
|
||||
import { UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { UnauthorizedException, ConflictException, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { UserStatus } from '../../business/user-mgmt/enums/user-status.enum';
|
||||
|
||||
describe('LoginCoreService', () => {
|
||||
let service: LoginCoreService;
|
||||
@@ -26,6 +27,7 @@ describe('LoginCoreService', () => {
|
||||
avatar_url: null as string | null,
|
||||
role: 1,
|
||||
email_verified: false,
|
||||
status: UserStatus.ACTIVE, // 使用正确的枚举类型
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
};
|
||||
@@ -105,7 +107,9 @@ describe('LoginCoreService', () => {
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for wrong password', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(mockUser);
|
||||
// 创建一个正常状态的用户来测试密码验证
|
||||
const activeUser = { ...mockUser, status: UserStatus.ACTIVE };
|
||||
usersService.findByUsername.mockResolvedValue(activeUser);
|
||||
jest.spyOn(service as any, 'verifyPassword').mockResolvedValue(false);
|
||||
|
||||
await expect(service.login({
|
||||
@@ -113,6 +117,17 @@ describe('LoginCoreService', () => {
|
||||
password: 'wrongpassword'
|
||||
})).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException for inactive user', async () => {
|
||||
// 测试非活跃用户状态
|
||||
const inactiveUser = { ...mockUser, status: UserStatus.INACTIVE };
|
||||
usersService.findByUsername.mockResolvedValue(inactiveUser);
|
||||
|
||||
await expect(service.login({
|
||||
identifier: 'testuser',
|
||||
password: 'password123'
|
||||
})).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
@@ -376,4 +391,132 @@ describe('LoginCoreService', () => {
|
||||
.rejects.toThrow('用户不存在');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verificationCodeLogin', () => {
|
||||
it('should successfully login with email verification code', async () => {
|
||||
const verifiedUser = { ...mockUser, email_verified: true };
|
||||
usersService.findByEmail.mockResolvedValue(verifiedUser);
|
||||
verificationService.verifyCode.mockResolvedValue(true);
|
||||
|
||||
const result = await service.verificationCodeLogin({
|
||||
identifier: 'test@example.com',
|
||||
verificationCode: '123456'
|
||||
});
|
||||
|
||||
expect(result.user).toEqual(verifiedUser);
|
||||
expect(result.isNewUser).toBe(false);
|
||||
expect(verificationService.verifyCode).toHaveBeenCalledWith(
|
||||
'test@example.com',
|
||||
VerificationCodeType.EMAIL_VERIFICATION,
|
||||
'123456'
|
||||
);
|
||||
});
|
||||
|
||||
it('should successfully login with phone verification code', async () => {
|
||||
const phoneUser = { ...mockUser, phone: '+8613800138000' };
|
||||
usersService.findAll.mockResolvedValue([phoneUser]);
|
||||
verificationService.verifyCode.mockResolvedValue(true);
|
||||
|
||||
const result = await service.verificationCodeLogin({
|
||||
identifier: '+8613800138000',
|
||||
verificationCode: '123456'
|
||||
});
|
||||
|
||||
expect(result.user).toEqual(phoneUser);
|
||||
expect(result.isNewUser).toBe(false);
|
||||
expect(verificationService.verifyCode).toHaveBeenCalledWith(
|
||||
'+8613800138000',
|
||||
VerificationCodeType.SMS_VERIFICATION,
|
||||
'123456'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject unverified email user', async () => {
|
||||
usersService.findByEmail.mockResolvedValue(mockUser); // email_verified: false
|
||||
|
||||
await expect(service.verificationCodeLogin({
|
||||
identifier: 'test@example.com',
|
||||
verificationCode: '123456'
|
||||
})).rejects.toThrow('邮箱未验证,请先验证邮箱后再使用验证码登录');
|
||||
});
|
||||
|
||||
it('should reject non-existent user', async () => {
|
||||
usersService.findByEmail.mockResolvedValue(null);
|
||||
|
||||
await expect(service.verificationCodeLogin({
|
||||
identifier: 'nonexistent@example.com',
|
||||
verificationCode: '123456'
|
||||
})).rejects.toThrow('用户不存在,请先注册账户');
|
||||
});
|
||||
|
||||
it('should reject invalid verification code', async () => {
|
||||
const verifiedUser = { ...mockUser, email_verified: true };
|
||||
usersService.findByEmail.mockResolvedValue(verifiedUser);
|
||||
verificationService.verifyCode.mockResolvedValue(false);
|
||||
|
||||
await expect(service.verificationCodeLogin({
|
||||
identifier: 'test@example.com',
|
||||
verificationCode: '999999'
|
||||
})).rejects.toThrow('验证码验证失败');
|
||||
});
|
||||
|
||||
it('should reject invalid identifier format', async () => {
|
||||
await expect(service.verificationCodeLogin({
|
||||
identifier: 'invalid-identifier',
|
||||
verificationCode: '123456'
|
||||
})).rejects.toThrow('请提供有效的邮箱或手机号');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendLoginVerificationCode', () => {
|
||||
it('should successfully send email login verification code', async () => {
|
||||
const verifiedUser = { ...mockUser, email_verified: true };
|
||||
usersService.findByEmail.mockResolvedValue(verifiedUser);
|
||||
verificationService.generateCode.mockResolvedValue('123456');
|
||||
emailService.sendVerificationCode.mockResolvedValue({
|
||||
success: true,
|
||||
isTestMode: false
|
||||
});
|
||||
|
||||
const result = await service.sendLoginVerificationCode('test@example.com');
|
||||
|
||||
expect(result.code).toBe('123456');
|
||||
expect(result.isTestMode).toBe(false);
|
||||
expect(emailService.sendVerificationCode).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
code: '123456',
|
||||
nickname: mockUser.nickname,
|
||||
purpose: 'login_verification'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return verification code in test mode', async () => {
|
||||
const verifiedUser = { ...mockUser, email_verified: true };
|
||||
usersService.findByEmail.mockResolvedValue(verifiedUser);
|
||||
verificationService.generateCode.mockResolvedValue('123456');
|
||||
emailService.sendVerificationCode.mockResolvedValue({
|
||||
success: true,
|
||||
isTestMode: true
|
||||
});
|
||||
|
||||
const result = await service.sendLoginVerificationCode('test@example.com');
|
||||
|
||||
expect(result.code).toBe('123456');
|
||||
expect(result.isTestMode).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject unverified email', async () => {
|
||||
usersService.findByEmail.mockResolvedValue(mockUser); // email_verified: false
|
||||
|
||||
await expect(service.sendLoginVerificationCode('test@example.com'))
|
||||
.rejects.toThrow('邮箱未验证,无法使用验证码登录');
|
||||
});
|
||||
|
||||
it('should reject non-existent user', async () => {
|
||||
usersService.findByEmail.mockResolvedValue(null);
|
||||
|
||||
await expect(service.sendLoginVerificationCode('nonexistent@example.com'))
|
||||
.rejects.toThrow('用户不存在');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,12 @@
|
||||
* @since 2025-12-17
|
||||
*/
|
||||
|
||||
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException, ForbiddenException, Inject } from '@nestjs/common';
|
||||
import { Users } from '../db/users/users.entity';
|
||||
import { UsersService } from '../db/users/users.service';
|
||||
import { EmailService, EmailSendResult } from '../utils/email/email.service';
|
||||
import { VerificationService, VerificationCodeType } from '../utils/verification/verification.service';
|
||||
import { UserStatus, canUserLogin, getUserStatusErrorMessage } from '../../business/user-mgmt/enums/user-status.enum';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@@ -150,6 +151,11 @@ export class LoginCoreService {
|
||||
throw new UnauthorizedException('用户名、邮箱或手机号不存在');
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if (!canUserLogin(user.status)) {
|
||||
throw new ForbiddenException(getUserStatusErrorMessage(user.status));
|
||||
}
|
||||
|
||||
// 检查是否为OAuth用户(没有密码)
|
||||
if (!user.password_hash) {
|
||||
throw new UnauthorizedException('该账户使用第三方登录,请使用对应的登录方式');
|
||||
@@ -178,6 +184,29 @@ export class LoginCoreService {
|
||||
async register(registerRequest: RegisterRequest): Promise<AuthResult> {
|
||||
const { username, password, nickname, email, phone, email_verification_code } = registerRequest;
|
||||
|
||||
// 先检查用户是否已存在,避免消费验证码后才发现用户存在
|
||||
const existingUser = await this.usersService.findByUsername(username);
|
||||
if (existingUser) {
|
||||
throw new ConflictException('用户名已存在');
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
if (email) {
|
||||
const existingEmail = await this.usersService.findByEmail(email);
|
||||
if (existingEmail) {
|
||||
throw new ConflictException('邮箱已存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 检查手机号是否已存在
|
||||
if (phone) {
|
||||
const users = await this.usersService.findAll();
|
||||
const existingPhone = users.find((u: Users) => u.phone === phone);
|
||||
if (existingPhone) {
|
||||
throw new ConflictException('手机号已存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果提供了邮箱,必须验证邮箱验证码
|
||||
if (email) {
|
||||
if (!email_verification_code) {
|
||||
@@ -206,6 +235,7 @@ export class LoginCoreService {
|
||||
email,
|
||||
phone,
|
||||
role: 1, // 默认普通用户
|
||||
status: UserStatus.ACTIVE, // 默认激活状态
|
||||
email_verified: email ? true : false // 如果提供了邮箱且验证码验证通过,则标记为已验证
|
||||
});
|
||||
|
||||
@@ -267,6 +297,7 @@ export class LoginCoreService {
|
||||
github_id,
|
||||
avatar_url,
|
||||
role: 1, // 默认普通用户
|
||||
status: UserStatus.ACTIVE, // GitHub用户直接激活
|
||||
email_verified: email ? true : false // GitHub邮箱直接验证
|
||||
});
|
||||
|
||||
@@ -726,7 +757,7 @@ export class LoginCoreService {
|
||||
email: identifier,
|
||||
code: verificationCode,
|
||||
nickname: user.nickname,
|
||||
purpose: 'login_verification'
|
||||
purpose: 'password_reset'
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface VerificationEmailOptions {
|
||||
/** 用户昵称 */
|
||||
nickname?: string;
|
||||
/** 验证码用途 */
|
||||
purpose: 'email_verification' | 'password_reset' | 'login_verification';
|
||||
purpose: 'email_verification' | 'password_reset';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,15 +167,9 @@ export class EmailService {
|
||||
if (purpose === 'email_verification') {
|
||||
subject = '【Whale Town】邮箱验证码';
|
||||
template = this.getEmailVerificationTemplate(code, nickname);
|
||||
} else if (purpose === 'password_reset') {
|
||||
} else {
|
||||
subject = '【Whale Town】密码重置验证码';
|
||||
template = this.getPasswordResetTemplate(code, nickname);
|
||||
} else if (purpose === 'login_verification') {
|
||||
subject = '【Whale Town】登录验证码';
|
||||
template = this.getLoginVerificationTemplate(code, nickname);
|
||||
} else {
|
||||
subject = '【Whale Town】验证码';
|
||||
template = this.getEmailVerificationTemplate(code, nickname);
|
||||
}
|
||||
|
||||
return await this.sendEmail({
|
||||
|
||||
@@ -61,6 +61,15 @@ export class LogManagementService {
|
||||
this.maxSize = this.configService.get('LOG_MAX_SIZE', '10m');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志目录的绝对路径
|
||||
*
|
||||
* 说明:用于后台打包下载 logs/ 整目录。
|
||||
*/
|
||||
getLogDirAbsolutePath(): string {
|
||||
return path.resolve(this.logDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定期清理过期日志文件
|
||||
*
|
||||
@@ -307,6 +316,67 @@ export class LogManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运行日志尾部(用于后台查看)
|
||||
*
|
||||
* 说明:
|
||||
* - 开发环境默认读取 dev.log
|
||||
* - 生产环境默认读取 app.log(可选 access/error)
|
||||
* - 通过读取文件尾部一定字节数实现“近似 tail”,避免大文件全量读取
|
||||
*/
|
||||
async getRuntimeLogTail(options?: {
|
||||
type?: 'app' | 'access' | 'error' | 'dev';
|
||||
lines?: number;
|
||||
}): Promise<{
|
||||
file: string;
|
||||
updated_at: string;
|
||||
lines: string[];
|
||||
}> {
|
||||
const isProduction = this.configService.get('NODE_ENV') === 'production';
|
||||
const requestedLines = Math.max(1, Math.min(Number(options?.lines ?? 200), 2000));
|
||||
const requestedType = options?.type;
|
||||
|
||||
const allowedFiles = isProduction
|
||||
? {
|
||||
app: 'app.log',
|
||||
access: 'access.log',
|
||||
error: 'error.log',
|
||||
}
|
||||
: {
|
||||
dev: 'dev.log',
|
||||
};
|
||||
|
||||
const defaultType = isProduction ? 'app' : 'dev';
|
||||
const typeKey = (requestedType && requestedType in allowedFiles ? requestedType : defaultType) as keyof typeof allowedFiles;
|
||||
const fileName = allowedFiles[typeKey];
|
||||
const filePath = path.join(this.logDir, fileName);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { file: fileName, updated_at: new Date().toISOString(), lines: [] };
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
const maxBytes = 256 * 1024; // 256KB 足够覆盖常见的数百行日志
|
||||
const readBytes = Math.min(stats.size, maxBytes);
|
||||
const startPos = Math.max(0, stats.size - readBytes);
|
||||
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(readBytes);
|
||||
fs.readSync(fd, buffer, 0, readBytes, startPos);
|
||||
const text = buffer.toString('utf8');
|
||||
const allLines = text.split(/\r?\n/).filter((l) => l.length > 0);
|
||||
const tailLines = allLines.slice(-requestedLines);
|
||||
return {
|
||||
file: fileName,
|
||||
updated_at: stats.mtime.toISOString(),
|
||||
lines: tailLines,
|
||||
};
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析最大文件数配置
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user