diff --git a/BE/package-lock.json b/BE/package-lock.json index f9b6300f..b443ce76 100644 --- a/BE/package-lock.json +++ b/BE/package-lock.json @@ -32,6 +32,7 @@ "dotenv": "^16.4.5", "express": "^4.21.1", "fastify-swagger": "^5.1.1", + "ioredis": "^5.4.1", "mysql2": "^3.11.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -822,6 +823,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "license": "ISC", @@ -3630,6 +3637,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "devOptional": true, @@ -6407,6 +6423,30 @@ "node": ">= 0.4" } }, + "node_modules/ioredis": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", + "integrity": "sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "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/ipaddr.js": { "version": "1.9.1", "license": "MIT", @@ -7813,11 +7853,23 @@ "version": "4.17.21", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -9084,6 +9136,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/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", "license": "Apache-2.0" @@ -9911,6 +9984,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "license": "MIT", diff --git a/BE/package.json b/BE/package.json index 86c3538b..9bff210b 100644 --- a/BE/package.json +++ b/BE/package.json @@ -43,6 +43,7 @@ "dotenv": "^16.4.5", "express": "^4.21.1", "fastify-swagger": "^5.1.1", + "ioredis": "^5.4.1", "mysql2": "^3.11.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", diff --git a/BE/src/app.module.ts b/BE/src/app.module.ts index ca5abc62..27220f4b 100644 --- a/BE/src/app.module.ts +++ b/BE/src/app.module.ts @@ -14,6 +14,7 @@ import { StockDetailModule } from './stock/detail/stock-detail.module'; import { typeOrmConfig } from './configs/typeorm.config'; import { StockListModule } from './stock/list/stock-list.module'; import { StockTradeHistoryModule } from './stock/trade/history/stock-trade-history.module'; +import { RedisModule } from './common/redis/redis.module'; @Module({ imports: [ @@ -29,6 +30,7 @@ import { StockTradeHistoryModule } from './stock/trade/history/stock-trade-histo StockOrderModule, StockListModule, StockTradeHistoryModule, + RedisModule, ], controllers: [AppController], providers: [AppService], diff --git a/BE/src/auth/strategy/jwt.strategy.ts b/BE/src/auth/strategy/jwt.strategy.ts index e6e85d05..7f8f5d2a 100644 --- a/BE/src/auth/strategy/jwt.strategy.ts +++ b/BE/src/auth/strategy/jwt.strategy.ts @@ -23,6 +23,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) { const user: User = await this.userRepository.findOne({ where: { email } }); if (!user) throw new UnauthorizedException(); - return user; + return { + userId: user.id, + email: user.email, + tutorial: user.tutorial, + kakaoId: user.kakaoId, + }; } } diff --git a/BE/src/common/redis/redis.domain-service.ts b/BE/src/common/redis/redis.domain-service.ts new file mode 100644 index 00000000..ce636d6e --- /dev/null +++ b/BE/src/common/redis/redis.domain-service.ts @@ -0,0 +1,57 @@ +import { Injectable, Inject } from '@nestjs/common'; +import Redis from 'ioredis'; + +@Injectable() +export class RedisDomainService { + constructor( + @Inject('REDIS_CLIENT') + private readonly redis: Redis, + ) {} + + async get(key: string): Promise { + return this.redis.get(key); + } + + async set(key: string, value: string, expires?: number): Promise<'OK'> { + if (expires) { + return this.redis.set(key, value, 'EX', expires); + } + return this.redis.set(key, value); + } + + async del(key: string): Promise { + return this.redis.del(key); + } + + async zadd(key: string, score: number, member: string): Promise { + return this.redis.zadd(key, score, member); + } + + async zcard(key: string): Promise { + return this.redis.zcard(key); + } + + async zrange(key: string, start: number, stop: number): Promise { + return this.redis.zrange(key, start, stop); + } + + async zremrangebyrank( + key: string, + start: number, + stop: number, + ): Promise { + return this.redis.zremrangebyrank(key, start, stop); + } + + async zrevrange(key: string, start: number, stop: number): Promise { + return this.redis.zrevrange(key, start, stop); + } + + async zrem(key: string, member: string): Promise { + return this.redis.zrem(key, member); + } + + async expire(key: string, seconds: number): Promise { + return this.redis.expire(key, seconds); + } +} diff --git a/BE/src/common/redis/redis.module.ts b/BE/src/common/redis/redis.module.ts new file mode 100644 index 00000000..a0cf55bc --- /dev/null +++ b/BE/src/common/redis/redis.module.ts @@ -0,0 +1,22 @@ +// src/common/redis/redis.module.ts +import { Global, Module } from '@nestjs/common'; +import Redis from 'ioredis'; +import { RedisDomainService } from './redis.domain-service'; + +@Global() +@Module({ + providers: [ + { + provide: 'REDIS_CLIENT', + useFactory: () => { + return new Redis({ + host: process.env.REDIS_HOST, + port: Number(process.env.REDIS_PORT), + }); + }, + }, + RedisDomainService, + ], + exports: [RedisDomainService, 'REDIS_CLIENT'], +}) +export class RedisModule {} diff --git a/BE/src/common/redis/redis.provider.ts b/BE/src/common/redis/redis.provider.ts new file mode 100644 index 00000000..28e1471d --- /dev/null +++ b/BE/src/common/redis/redis.provider.ts @@ -0,0 +1,14 @@ +import { Provider } from '@nestjs/common'; +import Redis from 'ioredis'; +import dotenv from 'dotenv'; + +dotenv.config(); +export const RedisProvider: Provider = { + provide: 'REDIS_CLIENT', + useFactory: () => { + return new Redis({ + host: process.env.REDIS_HOST, + port: Number(process.env.REDIS_PORT), + }); + }, +}; diff --git a/BE/src/stock/list/interface/search-params.interface.ts b/BE/src/stock/list/interface/search-params.interface.ts index 88080d0a..879f55b9 100644 --- a/BE/src/stock/list/interface/search-params.interface.ts +++ b/BE/src/stock/list/interface/search-params.interface.ts @@ -2,4 +2,5 @@ export interface SearchParams { name?: string; market?: string; code?: string; + userId: number; } diff --git a/BE/src/stock/list/stock-list.controller.ts b/BE/src/stock/list/stock-list.controller.ts index 427f9ff8..d7272784 100644 --- a/BE/src/stock/list/stock-list.controller.ts +++ b/BE/src/stock/list/stock-list.controller.ts @@ -1,5 +1,7 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Request } from 'express'; +import { JwtAuthGuard } from 'src/auth/jwt-auth-guard'; import { StockListService } from './stock-list.service'; import { StockListResponseDto } from './dto/stock-list-response.dto'; @@ -32,12 +34,15 @@ export class StockListController { @ApiQuery({ name: 'market', required: false }) @ApiQuery({ name: 'code', required: false }) @Get('/search') + @UseGuards(JwtAuthGuard) async searchWithQuery( + @Req() req: Request, @Query('name') name?: string, @Query('market') market?: string, @Query('code') code?: string, ): Promise { - return this.stockListService.search({ name, market, code }); + const userId = parseInt(req.user.userId, 10); + return this.stockListService.search({ name, market, code, userId }); } @ApiOperation({ diff --git a/BE/src/stock/list/stock-list.module.ts b/BE/src/stock/list/stock-list.module.ts index 9087c692..73243bb9 100644 --- a/BE/src/stock/list/stock-list.module.ts +++ b/BE/src/stock/list/stock-list.module.ts @@ -1,14 +1,16 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { RedisDomainService } from 'src/common/redis/redis.domain-service'; +import { RedisModule } from 'src/common/redis/redis.module'; import { StockListRepository } from './stock-list.repostiory'; import { StockListService } from './stock-list.service'; import { StockListController } from './stock-list.controller'; import { Stocks } from './stock-list.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Stocks])], + imports: [TypeOrmModule.forFeature([Stocks]), RedisModule], controllers: [StockListController], - providers: [StockListRepository, StockListService], + providers: [StockListRepository, StockListService, RedisDomainService], exports: [], }) export class StockListModule {} diff --git a/BE/src/stock/list/stock-list.service.ts b/BE/src/stock/list/stock-list.service.ts index 1c46c5bb..9d61f15a 100644 --- a/BE/src/stock/list/stock-list.service.ts +++ b/BE/src/stock/list/stock-list.service.ts @@ -1,4 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { RedisDomainService } from 'src/common/redis/redis.domain-service'; import { StockListRepository } from './stock-list.repostiory'; import { Stocks } from './stock-list.entity'; import { StockListResponseDto } from './dto/stock-list-response.dto'; @@ -6,7 +7,12 @@ import { SearchParams } from './interface/search-params.interface'; @Injectable() export class StockListService { - constructor(private readonly stockListRepository: StockListRepository) {} + private readonly SearchHistoryLimit = 10; + + constructor( + private readonly stockListRepository: StockListRepository, + private readonly redisDomainService: RedisDomainService, + ) {} private toResponseDto(stock: Stocks): StockListResponseDto { return new StockListResponseDto(stock.code, stock.name, stock.market); @@ -27,7 +33,24 @@ export class StockListService { } async search(params: SearchParams): Promise { + await this.addSearchTermToRedis(params); const stocks = await this.stockListRepository.search(params); return stocks.map((stock) => this.toResponseDto(stock)); } + + async addSearchTermToRedis(params: SearchParams) { + const key = `search:${params.userId}`; + const timeStamp = Date.now(); + + const { name, market, code } = params; + + const searchTerm = name || market || code; + + await this.redisDomainService.zadd(key, timeStamp, searchTerm); + + const searchHistoryCount = await this.redisDomainService.zcard(key); + if (searchHistoryCount > this.SearchHistoryLimit) { + await this.redisDomainService.zremrangebyrank(key, 0, 0); + } + } }