feat: 初始化项目

This commit is contained in:
richarjiang
2025-08-13 15:17:33 +08:00
commit 4f9d648a50
72 changed files with 29051 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AccessTokenPayload } from '../../users/services/apple-auth.service';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): AccessTokenPayload => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,68 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EncryptionService } from './encryption.service';
describe('EncryptionService', () => {
let service: EncryptionService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [EncryptionService],
}).compile();
service = module.get<EncryptionService>(EncryptionService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should encrypt and decrypt successfully', () => {
const originalText = 'Hello, World!';
const encrypted = service.encrypt(originalText);
const decrypted = service.decrypt(encrypted);
expect(decrypted).toBe(originalText);
});
it('should encrypt JSON data successfully', () => {
const userData = {
id: '1234567890',
name: '张三',
mail: 'zhangsan@example.com'
};
const jsonString = JSON.stringify(userData);
const encrypted = service.encrypt(jsonString);
const decrypted = service.decrypt(encrypted);
const parsedData = JSON.parse(decrypted);
expect(parsedData).toEqual(userData);
});
it('should generate different ciphertexts for same plaintext', () => {
const plaintext = 'test message';
const encrypted1 = service.encrypt(plaintext);
const encrypted2 = service.encrypt(plaintext);
// 由于IV随机生成每次加密结果应该不同
expect(encrypted1).not.toBe(encrypted2);
// 但解密结果应该相同
expect(service.decrypt(encrypted1)).toBe(plaintext);
expect(service.decrypt(encrypted2)).toBe(plaintext);
});
it('should throw error for invalid encrypted data', () => {
expect(() => {
service.decrypt('invalid-base64-data');
}).toThrow();
});
it('should handle Chinese characters correctly', () => {
const chineseText = '这是一个中文测试消息 🎉';
const encrypted = service.encrypt(chineseText);
const decrypted = service.decrypt(encrypted);
expect(decrypted).toBe(chineseText);
});
});

View File

@@ -0,0 +1,105 @@
import { Injectable, Logger } from '@nestjs/common';
import * as crypto from 'crypto';
@Injectable()
export class EncryptionService {
private readonly logger = new Logger(EncryptionService.name);
private readonly algorithm = 'aes-256-cbc';
private readonly keyLength = 32; // 256 bits for AES
private readonly hmacKeyLength = 32; // 256 bits for HMAC
private readonly ivLength = 16; // 128 bits for CBC
private readonly hmacLength = 32; // 256 bits for SHA256
// 从环境变量获取密钥,或使用默认密钥(生产环境必须使用环境变量)
private readonly encryptionKey: Buffer;
private readonly hmacKey: Buffer;
constructor() {
const encryptionKeyString = process.env.ENCRYPTION_KEY || 'your-default-32-char-aes-secret-key!';
const hmacKeyString = process.env.HMAC_KEY || 'your-default-32-char-hmac-secret-key';
this.logger.log(`EncryptionService constructor encryptionKeyString: ${encryptionKeyString}`);
this.logger.log(`EncryptionService constructor hmacKeyString: ${hmacKeyString}`);
// 两个独立的密钥
this.encryptionKey = Buffer.from(encryptionKeyString.slice(0, 32).padEnd(32, '0'), 'utf8');
this.hmacKey = Buffer.from(hmacKeyString.slice(0, 32).padEnd(32, '0'), 'utf8');
}
/**
* 加密数据
* @param plaintext 要加密的明文
* @returns 加密后的base64字符串格式iv.ciphertext.hmac
*/
encrypt(plaintext: string): string {
try {
const iv = crypto.randomBytes(this.ivLength);
const cipher = crypto.createCipheriv(this.algorithm, this.encryptionKey, iv);
let ciphertext = cipher.update(plaintext, 'utf8');
ciphertext = Buffer.concat([ciphertext, cipher.final()]);
// 计算 HMAC (对 iv + ciphertext 进行HMAC)
const dataToHmac = Buffer.concat([iv, ciphertext]);
const hmac = crypto.createHmac('sha256', this.hmacKey);
hmac.update(dataToHmac);
const hmacDigest = hmac.digest();
// 组合 iv + ciphertext + hmac 并编码为base64
const combined = Buffer.concat([iv, ciphertext, hmacDigest]);
return combined.toString('base64');
} catch (error) {
throw new Error(`加密失败: ${error.message}`);
}
}
/**
* 解密数据
* @param encryptedData 加密的base64字符串
* @returns 解密后的明文
*/
decrypt(encryptedData: string): string {
try {
const combined = Buffer.from(encryptedData, 'base64');
// 检查数据长度是否足够
if (combined.length < this.ivLength + this.hmacLength) {
throw new Error('加密数据格式无效');
}
// 提取 iv, ciphertext, hmac
const iv = combined.slice(0, this.ivLength);
const hmacDigest = combined.slice(-this.hmacLength);
const ciphertext = combined.slice(this.ivLength, -this.hmacLength);
// 验证 HMAC
const dataToHmac = Buffer.concat([iv, ciphertext]);
const hmac = crypto.createHmac('sha256', this.hmacKey);
hmac.update(dataToHmac);
const expectedHmac = hmac.digest();
if (!crypto.timingSafeEqual(hmacDigest, expectedHmac)) {
throw new Error('HMAC验证失败数据可能被篡改');
}
// 解密
const decipher = crypto.createDecipheriv(this.algorithm, this.encryptionKey, iv);
let plaintext = decipher.update(ciphertext, undefined, 'utf8');
plaintext += decipher.final('utf8');
return plaintext;
} catch (error) {
throw new Error(`解密失败: ${error.message}`);
}
}
/**
* 生成新的加密密钥(用于初始化)
* 返回两个独立的密钥
*/
generateKey(): { encryptionKey: string; hmacKey: string } {
return {
encryptionKey: crypto.randomBytes(this.keyLength).toString('base64'),
hmacKey: crypto.randomBytes(this.hmacKeyLength).toString('base64')
};
}
}

View File

@@ -0,0 +1,46 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, Logger } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AppleAuthService } from '../../users/services/apple-auth.service';
@Injectable()
export class JwtAuthGuard implements CanActivate {
private readonly logger = new Logger(JwtAuthGuard.name);
constructor(
private readonly appleAuthService: AppleAuthService,
private readonly reflector: Reflector,
) { }
canActivate(context: ExecutionContext): boolean {
// 检查是否标记为公开接口
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
this.logger.log(`authHeader: ${authHeader}`);
if (!authHeader) {
throw new UnauthorizedException('缺少授权头');
}
try {
const token = this.appleAuthService.extractTokenFromHeader(authHeader);
const payload = this.appleAuthService.verifyAccessToken(token);
this.logger.log(`鉴权成功: ${JSON.stringify(payload)}, token: ${token}`);
// 将用户信息添加到请求对象中
request.user = payload;
return true;
} catch (error) {
throw new UnauthorizedException('无效的访问令牌');
}
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { WinstonModule } from 'nest-winston';
import { winstonConfig } from './winston.config';
@Module({
imports: [WinstonModule.forRoot(winstonConfig)],
exports: [WinstonModule],
})
export class LoggerModule { }

View File

@@ -0,0 +1,136 @@
import * as winston from 'winston';
import * as DailyRotateFile from 'winston-daily-rotate-file';
import { WinstonModule } from 'nest-winston';
import * as path from 'path';
// 日志目录
const LOG_DIR = path.join(process.cwd(), 'logs');
// 日志格式
const logFormat = winston.format.combine(
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss',
}),
winston.format.errors({ stack: true }),
winston.format.printf((info) => {
const { timestamp, level, message, context, stack, ...meta } = info;
const contextStr = context ? `[${context}] ` : '';
const stackStr = stack ? `\n${stack}` : '';
// 如果有额外的元数据将其格式化为JSON字符串
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta, null, 2)}` : '';
return `${timestamp} [${level.toUpperCase()}] ${contextStr}${message}${metaStr}${stackStr}`;
}),
);
// 控制台格式(带颜色)
const consoleFormat = winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss',
}),
winston.format.printf((info) => {
const { timestamp, level, message, context, stack, ...meta } = info;
const contextStr = context ? `[${context}] ` : '';
const stackStr = stack ? `\n${stack}` : '';
// 如果有额外的元数据将其格式化为JSON字符串
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta, null, 2)}` : '';
return `${timestamp} ${level} ${contextStr}${message}${metaStr}${stackStr}`;
}),
);
// 创建日志传输器
const createTransports = () => {
const transports: winston.transport[] = [];
// 控制台输出
transports.push(
new winston.transports.Console({
format: consoleFormat,
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
}),
);
// 错误日志文件按日期滚动保留7天
transports.push(
new DailyRotateFile({
filename: path.join(LOG_DIR, 'error-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
level: 'error',
format: logFormat,
maxFiles: '7d', // 保留7天
maxSize: '20m', // 单个文件最大20MB
auditFile: path.join(LOG_DIR, '.audit-error.json'),
}),
);
// 应用日志文件按日期滚动保留7天
transports.push(
new DailyRotateFile({
filename: path.join(LOG_DIR, 'app-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
level: 'info',
format: logFormat,
maxFiles: '7d', // 保留7天
maxSize: '20m', // 单个文件最大20MB
auditFile: path.join(LOG_DIR, '.audit-app.json'),
}),
);
// 调试日志文件(仅在开发环境)
if (process.env.NODE_ENV !== 'production') {
transports.push(
new DailyRotateFile({
filename: path.join(LOG_DIR, 'debug-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
level: 'debug',
format: logFormat,
maxFiles: '7d', // 保留7天
maxSize: '20m', // 单个文件最大20MB
auditFile: path.join(LOG_DIR, '.audit-debug.json'),
}),
);
}
return transports;
};
// Winston配置
export const winstonConfig = {
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: logFormat,
transports: createTransports(),
// 处理未捕获的异常
exceptionHandlers: [
new DailyRotateFile({
filename: path.join(LOG_DIR, 'exceptions-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
format: logFormat,
maxFiles: '7d',
maxSize: '20m',
auditFile: path.join(LOG_DIR, '.audit-exceptions.json'),
}),
],
// 处理未处理的Promise拒绝
rejectionHandlers: [
new DailyRotateFile({
filename: path.join(LOG_DIR, 'rejections-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
format: logFormat,
maxFiles: '7d',
maxSize: '20m',
auditFile: path.join(LOG_DIR, '.audit-rejections.json'),
}),
],
};
// 创建Winston Logger实例
export const createWinstonLogger = () => {
return WinstonModule.createLogger(winstonConfig);
};
// 导出winston实例供直接使用
export const logger = winston.createLogger(winstonConfig);