feat: 集成Redis模块并重构限流存储机制
This commit is contained in:
86
package-lock.json
generated
86
package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@openrouter/sdk": "^0.1.27",
|
||||
"@parse/node-apn": "^5.0.0",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"apns2": "^12.2.0",
|
||||
@@ -31,6 +32,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"fs": "^0.0.1-security",
|
||||
"ioredis": "^5.8.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"mysql2": "^3.14.0",
|
||||
@@ -1362,6 +1364,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@ioredis/commands/-/commands-1.4.0.tgz",
|
||||
"integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -3427,6 +3435,15 @@
|
||||
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ioredis": {
|
||||
"version": "4.28.10",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@types/ioredis/-/ioredis-4.28.10.tgz",
|
||||
"integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
@@ -5422,6 +5439,14 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://mirrors.tencent.com/npm/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/co": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||
@@ -8072,6 +8097,30 @@
|
||||
"kind-of": "^6.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://mirrors.tencent.com/npm/ioredis/-/ioredis-5.8.2.tgz",
|
||||
"integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "1.4.0",
|
||||
"cluster-key-slot": "^1.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"denque": "^2.1.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.isarguments": "^3.1.0",
|
||||
"redis-errors": "^1.2.0",
|
||||
"redis-parser": "^3.0.0",
|
||||
"standard-as-callback": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ioredis"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://mirrors.tencent.com/npm/ip-address/-/ip-address-9.0.5.tgz",
|
||||
@@ -9357,12 +9406,23 @@
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://mirrors.tencent.com/npm/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
@@ -11266,6 +11326,26 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-parser": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"redis-errors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
@@ -12203,6 +12283,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@openrouter/sdk": "^0.1.27",
|
||||
"@parse/node-apn": "^5.0.0",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"apns2": "^12.2.0",
|
||||
@@ -49,6 +50,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"fs": "^0.0.1-security",
|
||||
"ioredis": "^5.8.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"mysql2": "^3.14.0",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { LoggerModule } from './common/logger/logger.module';
|
||||
import { RedisModule, ThrottlerStorageRedisService } from './redis';
|
||||
import { CheckinsModule } from './checkins/checkins.module';
|
||||
import { AiCoachModule } from './ai-coach/ai-coach.module';
|
||||
import { TrainingPlansModule } from './training-plans/training-plans.module';
|
||||
@@ -33,10 +34,18 @@ import { HealthProfilesModule } from './health-profiles/health-profiles.module';
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
ThrottlerModule.forRoot([{
|
||||
ttl: 60000, // 时间窗口:60秒
|
||||
limit: 100, // 每个时间窗口最多100个请求
|
||||
}]),
|
||||
// 限流模块必须在 RedisModule 之后导入,以确保 Redis 连接可用
|
||||
RedisModule,
|
||||
ThrottlerModule.forRootAsync({
|
||||
useFactory: (throttlerStorage: ThrottlerStorageRedisService) => ({
|
||||
throttlers: [{
|
||||
ttl: 60000, // 时间窗口:60秒
|
||||
limit: 100, // 每个时间窗口最多100个请求
|
||||
}],
|
||||
storage: throttlerStorage,
|
||||
}),
|
||||
inject: [ThrottlerStorageRedisService],
|
||||
}),
|
||||
LoggerModule,
|
||||
DatabaseModule,
|
||||
UsersModule,
|
||||
|
||||
@@ -17,7 +17,7 @@ import * as dayjs from 'dayjs';
|
||||
@Injectable()
|
||||
export class MedicationReminderService {
|
||||
private readonly logger = new Logger(MedicationReminderService.name);
|
||||
private readonly REMINDER_MINUTES_BEFORE = 2; // 提前5分钟提醒
|
||||
private readonly REMINDER_MINUTES_BEFORE = 5; // 提前5分钟提醒
|
||||
private readonly OVERDUE_HOURS_THRESHOLD = 1; // 超过1小时后发送超时提醒
|
||||
private readonly EXPIRY_ONE_MONTH_DAYS = 30; // 提前一个月预警
|
||||
private readonly EXPIRY_ONE_WEEK_DAYS = 7; // 提前一周预警
|
||||
|
||||
3
src/redis/index.ts
Normal file
3
src/redis/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './redis.module';
|
||||
export * from './redis.service';
|
||||
export * from './throttler-storage-redis.service';
|
||||
46
src/redis/redis.module.ts
Normal file
46
src/redis/redis.module.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { RedisService } from './redis.service';
|
||||
import { ThrottlerStorageRedisService } from './throttler-storage-redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [
|
||||
{
|
||||
provide: 'REDIS_CLIENT',
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
const Redis = (await import('ioredis')).default;
|
||||
const client = new Redis({
|
||||
host: configService.get<string>('REDIS_HOST', '127.0.0.1'),
|
||||
port: configService.get<number>('REDIS_PORT', 6379),
|
||||
password: configService.get<string>('REDIS_PASSWORD', ''),
|
||||
db: configService.get<number>('REDIS_DB', 0),
|
||||
keyPrefix: configService.get<string>('REDIS_PREFIX', 'pilates:'),
|
||||
retryStrategy: (times: number) => {
|
||||
if (times > 3) {
|
||||
return null; // 停止重试
|
||||
}
|
||||
return Math.min(times * 200, 2000);
|
||||
},
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
client.on('connect', () => {
|
||||
console.log('Redis client connected');
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
console.error('Redis client error:', err);
|
||||
});
|
||||
|
||||
return client;
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
RedisService,
|
||||
ThrottlerStorageRedisService,
|
||||
],
|
||||
exports: ['REDIS_CLIENT', RedisService, ThrottlerStorageRedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
315
src/redis/redis.service.ts
Normal file
315
src/redis/redis.service.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { Injectable, Inject, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(RedisService.name);
|
||||
|
||||
constructor(
|
||||
@Inject('REDIS_CLIENT')
|
||||
private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.redis.quit();
|
||||
this.logger.log('Redis connection closed');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始 Redis 客户端(用于高级操作)
|
||||
*/
|
||||
getClient(): Redis {
|
||||
return this.redis;
|
||||
}
|
||||
|
||||
// ==================== 基础操作 ====================
|
||||
|
||||
/**
|
||||
* 设置键值
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
* @param ttlSeconds 过期时间(秒),可选
|
||||
*/
|
||||
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
||||
if (ttlSeconds) {
|
||||
await this.redis.setex(key, ttlSeconds, value);
|
||||
} else {
|
||||
await this.redis.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取键值
|
||||
*/
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.redis.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除键
|
||||
*/
|
||||
async del(...keys: string[]): Promise<number> {
|
||||
return this.redis.del(...keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查键是否存在
|
||||
*/
|
||||
async exists(key: string): Promise<boolean> {
|
||||
const result = await this.redis.exists(key);
|
||||
return result === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置过期时间
|
||||
*/
|
||||
async expire(key: string, seconds: number): Promise<boolean> {
|
||||
const result = await this.redis.expire(key, seconds);
|
||||
return result === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剩余过期时间
|
||||
*/
|
||||
async ttl(key: string): Promise<number> {
|
||||
return this.redis.ttl(key);
|
||||
}
|
||||
|
||||
// ==================== JSON 操作 ====================
|
||||
|
||||
/**
|
||||
* 设置 JSON 对象
|
||||
*/
|
||||
async setJson<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
const jsonString = JSON.stringify(value);
|
||||
await this.set(key, jsonString, ttlSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 JSON 对象
|
||||
*/
|
||||
async getJson<T>(key: string): Promise<T | null> {
|
||||
const value = await this.get(key);
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Hash 操作 ====================
|
||||
|
||||
/**
|
||||
* 设置 Hash 字段
|
||||
*/
|
||||
async hset(key: string, field: string, value: string): Promise<number> {
|
||||
return this.redis.hset(key, field, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Hash 字段
|
||||
*/
|
||||
async hget(key: string, field: string): Promise<string | null> {
|
||||
return this.redis.hget(key, field);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 Hash 字段
|
||||
*/
|
||||
async hgetall(key: string): Promise<Record<string, string>> {
|
||||
return this.redis.hgetall(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Hash 字段
|
||||
*/
|
||||
async hdel(key: string, ...fields: string[]): Promise<number> {
|
||||
return this.redis.hdel(key, ...fields);
|
||||
}
|
||||
|
||||
// ==================== List 操作 ====================
|
||||
|
||||
/**
|
||||
* 从左侧推入列表
|
||||
*/
|
||||
async lpush(key: string, ...values: string[]): Promise<number> {
|
||||
return this.redis.lpush(key, ...values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从右侧推入列表
|
||||
*/
|
||||
async rpush(key: string, ...values: string[]): Promise<number> {
|
||||
return this.redis.rpush(key, ...values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表范围
|
||||
*/
|
||||
async lrange(key: string, start: number, stop: number): Promise<string[]> {
|
||||
return this.redis.lrange(key, start, stop);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表长度
|
||||
*/
|
||||
async llen(key: string): Promise<number> {
|
||||
return this.redis.llen(key);
|
||||
}
|
||||
|
||||
// ==================== Set 操作 ====================
|
||||
|
||||
/**
|
||||
* 添加 Set 成员
|
||||
*/
|
||||
async sadd(key: string, ...members: string[]): Promise<number> {
|
||||
return this.redis.sadd(key, ...members);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 Set 成员
|
||||
*/
|
||||
async smembers(key: string): Promise<string[]> {
|
||||
return this.redis.smembers(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是 Set 成员
|
||||
*/
|
||||
async sismember(key: string, member: string): Promise<boolean> {
|
||||
const result = await this.redis.sismember(key, member);
|
||||
return result === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 Set 成员
|
||||
*/
|
||||
async srem(key: string, ...members: string[]): Promise<number> {
|
||||
return this.redis.srem(key, ...members);
|
||||
}
|
||||
|
||||
// ==================== 计数器操作 ====================
|
||||
|
||||
/**
|
||||
* 自增
|
||||
*/
|
||||
async incr(key: string): Promise<number> {
|
||||
return this.redis.incr(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自增指定值
|
||||
*/
|
||||
async incrby(key: string, increment: number): Promise<number> {
|
||||
return this.redis.incrby(key, increment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自减
|
||||
*/
|
||||
async decr(key: string): Promise<number> {
|
||||
return this.redis.decr(key);
|
||||
}
|
||||
|
||||
// ==================== 分布式锁 ====================
|
||||
|
||||
/**
|
||||
* 获取分布式锁
|
||||
* @param lockKey 锁的键名
|
||||
* @param ttlSeconds 锁的过期时间(秒)
|
||||
* @param retryTimes 重试次数
|
||||
* @param retryDelay 重试间隔(毫秒)
|
||||
* @returns 锁的唯一标识,获取失败返回 null
|
||||
*/
|
||||
async acquireLock(
|
||||
lockKey: string,
|
||||
ttlSeconds: number = 10,
|
||||
retryTimes: number = 3,
|
||||
retryDelay: number = 100,
|
||||
): Promise<string | null> {
|
||||
const lockValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
for (let i = 0; i < retryTimes; i++) {
|
||||
const result = await this.redis.set(
|
||||
`lock:${lockKey}`,
|
||||
lockValue,
|
||||
'EX',
|
||||
ttlSeconds,
|
||||
'NX',
|
||||
);
|
||||
|
||||
if (result === 'OK') {
|
||||
return lockValue;
|
||||
}
|
||||
|
||||
if (i < retryTimes - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放分布式锁
|
||||
* @param lockKey 锁的键名
|
||||
* @param lockValue 锁的唯一标识
|
||||
*/
|
||||
async releaseLock(lockKey: string, lockValue: string): Promise<boolean> {
|
||||
const script = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("del", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`;
|
||||
const result = await this.redis.eval(script, 1, `lock:${lockKey}`, lockValue);
|
||||
return result === 1;
|
||||
}
|
||||
|
||||
// ==================== 缓存辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 带缓存的数据获取
|
||||
* 如果缓存存在则返回缓存,否则执行 factory 函数获取数据并缓存
|
||||
*/
|
||||
async getOrSet<T>(
|
||||
key: string,
|
||||
factory: () => Promise<T>,
|
||||
ttlSeconds: number = 300,
|
||||
): Promise<T> {
|
||||
const cached = await this.getJson<T>(key);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const value = await factory();
|
||||
await this.setJson(key, value, ttlSeconds);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除匹配模式的键
|
||||
* @param pattern 匹配模式,如 "user:*"
|
||||
*/
|
||||
async delByPattern(pattern: string): Promise<number> {
|
||||
const keys = await this.redis.keys(pattern);
|
||||
if (keys.length === 0) return 0;
|
||||
return this.redis.del(...keys);
|
||||
}
|
||||
|
||||
// ==================== 健康检查 ====================
|
||||
|
||||
/**
|
||||
* 检查 Redis 连接状态
|
||||
*/
|
||||
async ping(): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.redis.ping();
|
||||
return result === 'PONG';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/redis/throttler-storage-redis.service.ts
Normal file
93
src/redis/throttler-storage-redis.service.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Injectable, Inject, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ThrottlerStorage } from '@nestjs/throttler';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
export interface ThrottlerStorageRecord {
|
||||
totalHits: number;
|
||||
timeToExpire: number;
|
||||
isBlocked: boolean;
|
||||
timeToBlockExpire: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ThrottlerStorageRedisService
|
||||
implements ThrottlerStorage, OnModuleDestroy
|
||||
{
|
||||
private readonly prefix = 'throttler:';
|
||||
|
||||
constructor(
|
||||
@Inject('REDIS_CLIENT')
|
||||
private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
async onModuleDestroy() {
|
||||
// Redis 连接由 RedisModule 管理,这里不需要关闭
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加指定 key 的请求计数
|
||||
* @param key 限流 key(通常是 IP 或用户标识)
|
||||
* @param ttl 过期时间(毫秒)
|
||||
* @param limit 限制次数
|
||||
* @param blockDuration 封禁时长(毫秒)
|
||||
* @param throttlerName 限流器名称
|
||||
*/
|
||||
async increment(
|
||||
key: string,
|
||||
ttl: number,
|
||||
limit: number,
|
||||
blockDuration: number,
|
||||
throttlerName: string,
|
||||
): Promise<ThrottlerStorageRecord> {
|
||||
const redisKey = `${this.prefix}${throttlerName}:${key}`;
|
||||
const blockKey = `${this.prefix}${throttlerName}:block:${key}`;
|
||||
|
||||
// 检查是否被封禁
|
||||
const blockTtl = await this.redis.pttl(blockKey);
|
||||
if (blockTtl > 0) {
|
||||
return {
|
||||
totalHits: limit + 1,
|
||||
timeToExpire: ttl,
|
||||
isBlocked: true,
|
||||
timeToBlockExpire: blockTtl,
|
||||
};
|
||||
}
|
||||
|
||||
// 使用 Lua 脚本保证原子性操作
|
||||
const luaScript = `
|
||||
local current = redis.call('INCR', KEYS[1])
|
||||
if current == 1 then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
local pttl = redis.call('PTTL', KEYS[1])
|
||||
return {current, pttl}
|
||||
`;
|
||||
|
||||
const result = (await this.redis.eval(
|
||||
luaScript,
|
||||
1,
|
||||
redisKey,
|
||||
ttl.toString(),
|
||||
)) as [number, number];
|
||||
|
||||
const totalHits = result[0];
|
||||
const timeToExpire = result[1] > 0 ? result[1] : ttl;
|
||||
|
||||
// 如果超过限制且设置了封禁时长,则设置封禁
|
||||
let isBlocked = false;
|
||||
let timeToBlockExpire = 0;
|
||||
|
||||
if (totalHits > limit && blockDuration > 0) {
|
||||
await this.redis.set(blockKey, '1', 'PX', blockDuration);
|
||||
isBlocked = true;
|
||||
timeToBlockExpire = blockDuration;
|
||||
}
|
||||
|
||||
return {
|
||||
totalHits,
|
||||
timeToExpire,
|
||||
isBlocked,
|
||||
timeToBlockExpire,
|
||||
};
|
||||
}
|
||||
}
|
||||
59
yarn.lock
59
yarn.lock
@@ -599,6 +599,11 @@
|
||||
resolved "https://registry.npmjs.org/@inquirer/type/-/type-3.0.5.tgz"
|
||||
integrity sha512-ZJpeIYYueOz/i/ONzrfof8g89kNdO2hjGuvULROo3O8rlB2CRtSseE5KeirnyE4t/thAn/EwvS/vuQeJCn+NZg==
|
||||
|
||||
"@ioredis/commands@1.4.0":
|
||||
version "1.4.0"
|
||||
resolved "https://mirrors.tencent.com/npm/@ioredis/commands/-/commands-1.4.0.tgz"
|
||||
integrity sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==
|
||||
|
||||
"@isaacs/cliui@^8.0.2":
|
||||
version "8.0.2"
|
||||
resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz"
|
||||
@@ -1526,6 +1531,13 @@
|
||||
resolved "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz"
|
||||
integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==
|
||||
|
||||
"@types/ioredis@^4.28.10":
|
||||
version "4.28.10"
|
||||
resolved "https://mirrors.tencent.com/npm/@types/ioredis/-/ioredis-4.28.10.tgz"
|
||||
integrity sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1":
|
||||
version "2.0.6"
|
||||
resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz"
|
||||
@@ -2740,6 +2752,11 @@ clone@^1.0.2:
|
||||
resolved "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz"
|
||||
integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==
|
||||
|
||||
cluster-key-slot@^1.1.0:
|
||||
version "1.1.2"
|
||||
resolved "https://mirrors.tencent.com/npm/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz"
|
||||
integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==
|
||||
|
||||
co@^4.6.0:
|
||||
version "4.6.0"
|
||||
resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz"
|
||||
@@ -4316,6 +4333,21 @@ inspect-with-kind@^1.0.5:
|
||||
dependencies:
|
||||
kind-of "^6.0.2"
|
||||
|
||||
ioredis@^5.8.2:
|
||||
version "5.8.2"
|
||||
resolved "https://mirrors.tencent.com/npm/ioredis/-/ioredis-5.8.2.tgz"
|
||||
integrity sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==
|
||||
dependencies:
|
||||
"@ioredis/commands" "1.4.0"
|
||||
cluster-key-slot "^1.1.0"
|
||||
debug "^4.3.4"
|
||||
denque "^2.1.0"
|
||||
lodash.defaults "^4.2.0"
|
||||
lodash.isarguments "^3.1.0"
|
||||
redis-errors "^1.2.0"
|
||||
redis-parser "^3.0.0"
|
||||
standard-as-callback "^2.1.0"
|
||||
|
||||
ip-address@^9.0.5:
|
||||
version "9.0.5"
|
||||
resolved "https://mirrors.tencent.com/npm/ip-address/-/ip-address-9.0.5.tgz"
|
||||
@@ -5138,11 +5170,21 @@ lodash.clonedeep@^4.5.0:
|
||||
resolved "https://mirrors.tencent.com/npm/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz"
|
||||
integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==
|
||||
|
||||
lodash.defaults@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://mirrors.tencent.com/npm/lodash.defaults/-/lodash.defaults-4.2.0.tgz"
|
||||
integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==
|
||||
|
||||
lodash.includes@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://mirrors.tencent.com/npm/lodash.includes/-/lodash.includes-4.3.0.tgz"
|
||||
integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==
|
||||
|
||||
lodash.isarguments@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://mirrors.tencent.com/npm/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz"
|
||||
integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==
|
||||
|
||||
lodash.isboolean@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://mirrors.tencent.com/npm/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz"
|
||||
@@ -6213,6 +6255,18 @@ readdirp@~3.6.0:
|
||||
dependencies:
|
||||
picomatch "^2.2.1"
|
||||
|
||||
redis-errors@^1.0.0, redis-errors@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://mirrors.tencent.com/npm/redis-errors/-/redis-errors-1.2.0.tgz"
|
||||
integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==
|
||||
|
||||
redis-parser@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://mirrors.tencent.com/npm/redis-parser/-/redis-parser-3.0.0.tgz"
|
||||
integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==
|
||||
dependencies:
|
||||
redis-errors "^1.0.0"
|
||||
|
||||
reflect-metadata@^0.2.2:
|
||||
version "0.2.2"
|
||||
resolved "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz"
|
||||
@@ -6716,6 +6770,11 @@ stack-utils@^2.0.3:
|
||||
dependencies:
|
||||
escape-string-regexp "^2.0.0"
|
||||
|
||||
standard-as-callback@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://mirrors.tencent.com/npm/standard-as-callback/-/standard-as-callback-2.1.0.tgz"
|
||||
integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==
|
||||
|
||||
statuses@2.0.1, statuses@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz"
|
||||
|
||||
Reference in New Issue
Block a user