Skip to content

Commit 9e195c2

Browse files
authored
feat(nest.js): add lock for auth service (#159)
* feat(nest.js): add lock for auth service * fix: resolve suggestion
1 parent 23751f9 commit 9e195c2

21 files changed

Lines changed: 341 additions & 12 deletions

template/nestJs/.env.example

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,44 @@
1+
# 数据库IP
12
DATABASE_HOST = 'localhost'
3+
# 数据库端口
24
DATABASE_PORT = 3306
5+
# 数据库用户
36
DATABASE_USERNAME = 'root'
7+
# 数据库密码
48
DATABASE_PASSWORD = 'root'
9+
# 数据库名称 (确保数据库存在)
510
DATABASE_NAME = 'demo_tiny_pro'
11+
# 是否强制同步数据库 (线上建议关闭)
612
DATABASE_SYNCHRONIZE = true
13+
# 是否自动加载Entry (建议设置为true)
714
DATABASE_AUTOLOADENTITIES = true
15+
# jwt盐
816
AUTH_SECRET = 'secret'
17+
# AccessToken默认过期时间
918
REDIS_SECONDS = 7200
19+
# Redis IP
1020
REDIS_HOST = 'localhost'
21+
# Redis 端口
1122
REDIS_PORT = 6379
23+
# JwT过期时间 (已废弃)
1224
EXPIRES_IN = '2h'
25+
# 分页默认起始页码数
1326
PAGINATION_PAGE = 1
27+
# 分页默认起始大小
1428
PAGINATION_LIMIT = 10
29+
# api接口全局前缀
1530
GLOBAL_PREFIX = '/'
31+
# mock接口glob表达式
1632
MOCK_REGEX = '/mock'
33+
# refreshToken过期时间
1734
REFRESH_TOKEN_TTL = 604800000
18-
# 至多有多少个设备可以同时在线
35+
# 最大会话数, -1表示不限制
1936
DEVICE_LIMIT=1
20-
# 是否启用演示模式
37+
# 是否启用演示模式, 如果设置为true, 则会拒绝所有的增加、修改、删除操作
2138
PREVIEW_MODE=true
39+
# Swagger文档标题
2240
SWAGGER_TITLE="Tiny Pro"
41+
# Swagger文档简介
2342
SWAGGER_DESC="开箱即用的中后台模板"
43+
# Swagger文档版本
2444
SWAGGER_VERSION="1.0.0"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './locker.module';
2+
export * from './locker.service';
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Module } from '@nestjs/common';
2+
import { LockerService } from './locker.service';
3+
import { ConfigurableModuleClass } from './locker.options';
4+
import { RedisService } from '../../redis/redis.service';
5+
6+
@Module({
7+
providers: [LockerService, RedisService],
8+
exports: [LockerService],
9+
})
10+
export class LockerModule extends ConfigurableModuleClass {}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ConfigurableModuleBuilder } from "@nestjs/common";
2+
3+
export const {
4+
MODULE_OPTIONS_TOKEN,
5+
ConfigurableModuleClass
6+
} = new ConfigurableModuleBuilder()
7+
.setExtras({global: false}, (def, extra) => {
8+
return {
9+
...def,
10+
...extra
11+
}
12+
})
13+
.build()
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { LockerService } from './locker.service';
3+
import { RedisService } from '../../redis/redis.service';
4+
5+
describe('LockerService', () => {
6+
let service: LockerService;
7+
8+
beforeEach(async () => {
9+
const module: TestingModule = await Test.createTestingModule({
10+
providers: [
11+
LockerService,
12+
{
13+
provide: RedisService,
14+
useValue: {
15+
getRedis: jest.fn().mockReturnValue({
16+
set: jest.fn(),
17+
eval: jest.fn(),
18+
exists: jest.fn(),
19+
}),
20+
},
21+
},
22+
],
23+
}).compile();
24+
25+
service = module.get<LockerService>(LockerService);
26+
});
27+
28+
it('should be defined', () => {
29+
expect(service).toBeDefined();
30+
});
31+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Injectable, Logger, Scope } from '@nestjs/common';
2+
import { RedisService } from '../../redis/redis.service';
3+
import { randomBytes } from 'crypto';
4+
import { hostname } from 'os';
5+
6+
@Injectable({ scope: Scope.DEFAULT })
7+
export class LockerService {
8+
private readonly logger = new Logger(LockerService.name);
9+
private readonly LOCK_PREFIX = 'lock:';
10+
private readonly DEFAULT_TTL = 30000; // 30秒默认超时
11+
private readonly RETRY_DELAY = 1000; // 重试间隔
12+
private readonly MAX_RETRY = 20; // 最大重试次数
13+
private readonly id = `${process.pid}-${hostname()}-${randomBytes(16).toString('hex')}`;
14+
15+
constructor(private readonly redisService: RedisService) {}
16+
17+
async acquire(key: string, ttl: number = this.DEFAULT_TTL): Promise<boolean> {
18+
const lockKey = `${this.LOCK_PREFIX}${key}`;
19+
20+
try {
21+
const result = await this.redisService.getRedis().set(
22+
lockKey,
23+
this.id,
24+
'PX',
25+
ttl,
26+
'NX' // Only set if key doesn't exist
27+
);
28+
29+
return result === 'OK';
30+
} catch (error) {
31+
this.logger.error(`Failed to acquire lock for key ${key}: ${error.message}`);
32+
return false;
33+
}
34+
}
35+
36+
async release(key: string): Promise<boolean> {
37+
const lockKey = `${this.LOCK_PREFIX}${key}`;
38+
const luaScript = `
39+
if redis.call("get", KEYS[1]) == ARGV[1] then
40+
return redis.call("del", KEYS[1])
41+
else
42+
return 0
43+
end
44+
`;
45+
46+
try {
47+
const result = await this.redisService.getRedis().eval(
48+
luaScript,
49+
1,
50+
lockKey,
51+
this.id
52+
);
53+
54+
return result === 1;
55+
} catch (error) {
56+
this.logger.error(`Failed to release lock for key ${key}: ${error.message}`);
57+
return false;
58+
}
59+
}
60+
61+
async extend(key: string, ttl: number): Promise<boolean> {
62+
const lockKey = `${this.LOCK_PREFIX}${key}`;
63+
64+
const luaScript = `
65+
if redis.call("get", KEYS[1]) == ARGV[1] then
66+
return redis.call("pexpire", KEYS[1], ARGV[2])
67+
else
68+
return 0
69+
end
70+
`;
71+
72+
try {
73+
const result = await this.redisService.getRedis().eval(
74+
luaScript,
75+
1,
76+
lockKey,
77+
this.id, // 使用固定的实例ID进行验证
78+
ttl.toString()
79+
);
80+
81+
return result === 1;
82+
} catch (error) {
83+
this.logger.error(`Failed to extend lock for key ${key}: ${error.message}`);
84+
return false;
85+
}
86+
}
87+
88+
async isLocked(key: string): Promise<boolean> {
89+
const lockKey = `${this.LOCK_PREFIX}${key}`;
90+
const result = await this.redisService.getRedis().exists(lockKey);
91+
return result === 1;
92+
}
93+
94+
async acquireWithRetry(key: string, ttl?: number, maxRetries: number = this.MAX_RETRY): Promise<boolean> {
95+
for (let i = 0; i < maxRetries; i++) {
96+
const acquired = await this.acquire(key, ttl);
97+
if (acquired) {
98+
return true
99+
};
100+
101+
// Wait before retrying
102+
await new Promise(resolve => setTimeout(resolve, this.RETRY_DELAY));
103+
}
104+
return false;
105+
}
106+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { LockerService } from './locker.service';
2+
import { I18nTranslations } from '../../../src/.generate/i18n.generated';
3+
import { I18nContext, I18nService } from 'nestjs-i18n';
4+
import { HttpException, HttpStatus } from '@nestjs/common';
5+
6+
export type WithLockOptions = {
7+
key: string | ((args: any[]) => string);
8+
ttl?: number;
9+
} & ({
10+
service: string;
11+
method: string;
12+
} | {
13+
paramIndex?: number;
14+
});
15+
16+
export function WithLock(options: WithLockOptions) {
17+
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
18+
const originalMethod = descriptor.value;
19+
20+
descriptor.value = async function (...args: any[]) {
21+
const locker: LockerService = 'service' in options ? this[options.service] || this.locker : this.locker;
22+
const i18: I18nService<I18nTranslations> = this.i18nService || this.i18n;
23+
if (!locker) {
24+
throw new HttpException(i18.t('exception.common.internalError', {
25+
lang: I18nContext.current().lang,
26+
}), HttpStatus.INTERNAL_SERVER_ERROR)
27+
}
28+
29+
let lockKey: string;
30+
if (typeof options.key === 'function') {
31+
lockKey = options.key(args);
32+
} else if ('paramIndex' in options && options.paramIndex !== undefined) {
33+
lockKey = `${options.key}:${args[options.paramIndex]}`;
34+
} else {
35+
lockKey = options.key;
36+
}
37+
38+
const ttl = options.ttl || 30000;
39+
40+
const acquired = await locker.acquireWithRetry(lockKey, ttl);
41+
if (!acquired) {
42+
throw new HttpException(i18.t('exception.common.timeout', {
43+
lang: I18nContext.current().lang,
44+
}), HttpStatus.REQUEST_TIMEOUT)
45+
}
46+
47+
try {
48+
return await originalMethod.apply(this, args);
49+
} finally {
50+
await locker.release(lockKey);
51+
}
52+
};
53+
54+
return descriptor;
55+
};
56+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"declaration": true,
5+
"outDir": "../../dist/libs/locker"
6+
},
7+
"include": ["src/**/*"],
8+
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
9+
}

template/nestJs/nest-cli.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@
3939
"compilerOptions": {
4040
"tsConfigPath": "libs/jwt/tsconfig.lib.json"
4141
}
42+
},
43+
"locker": {
44+
"type": "library",
45+
"root": "libs/locker",
46+
"entryFile": "index",
47+
"sourceRoot": "libs/locker/src",
48+
"compilerOptions": {
49+
"tsConfigPath": "libs/locker/tsconfig.lib.json"
50+
}
4251
}
4352
}
4453
}

template/nestJs/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
"json",
9090
"ts"
9191
],
92-
"rootDir": "src",
92+
"rootDir": "./src",
9393
"testRegex": ".spec.ts$",
9494
"transform": {
9595
"^.+\\.(t|j)s$": "ts-jest"
@@ -114,7 +114,8 @@
114114
"testEnvironment": "node",
115115
"moduleNameMapper": {
116116
"@app/models": "<rootDir>/../libs/models/src/index.ts",
117-
"^@app/jwt(|/.*)$": "<rootDir>/../libs/jwt/src/$1"
117+
"^@app/jwt(|/.*)$": "<rootDir>/../libs/jwt/src/$1",
118+
"^@app/locker(|/.*)$": "<rootDir>/../libs/locker/src/$1"
118119
}
119120
}
120121
}

0 commit comments

Comments
 (0)