Skip to content

Commit

Permalink
Merge branch 'main' into feat/trend-sse
Browse files Browse the repository at this point in the history
  • Loading branch information
Jo-Minseok committed Nov 20, 2024
2 parents bb1e443 + 51e0072 commit ebfed1c
Show file tree
Hide file tree
Showing 13 changed files with 327 additions and 63 deletions.
64 changes: 63 additions & 1 deletion server/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion server/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export class AdminService {

const sessionId = uuid.v4();

await this.redisService.set(
await this.redisService.redisClient.set(
sessionId,
admin.loginId,
`EX`,
Expand Down
6 changes: 5 additions & 1 deletion server/src/blog/blog.entity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Feed } from '../feed/feed.entity';
import { RssInformation } from '../rss/rss.entity';
import { Entity, OneToMany } from 'typeorm';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { Rss } from '../rss/rss.entity';

@Entity({
Expand All @@ -10,6 +10,10 @@ export class Blog extends RssInformation {
@OneToMany((type) => Feed, (feed) => feed.blog)
feeds: Feed[];

@Index({ fulltext: true, parser: 'ngram' })
@Column({ name: 'user_name', nullable: false })
userName: string;

static fromRss(rss: Rss) {
const blog = new Blog();
blog.name = rss.name;
Expand Down
2 changes: 1 addition & 1 deletion server/src/common/guard/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export class CookieAuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const sid = request.cookies['sessionId'];
const loginId = await this.redisService.get(sid);
const loginId = await this.redisService.redisClient.get(sid);
if (!loginId) {
throw new UnauthorizedException('인증되지 않은 요청입니다.');
}
Expand Down
17 changes: 7 additions & 10 deletions server/src/common/redis/redis.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,15 @@ import Redis_Mock from 'ioredis-mock';
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
const envType = process.env.NODE_ENV;
let redis: Redis;
if (envType === 'test') {
redis = new Redis_Mock();
} else {
redis = new Redis({
host: configService.get<string>('REDIS_HOST'),
port: configService.get<number>('REDIS_PORT'),
username: configService.get<string>('REDIS_USERNAME'),
password: configService.get<string>('REDIS_PASSWORD'),
});
return new Redis_Mock();
}
return redis;
return new Redis({
host: configService.get<string>('REDIS_HOST'),
port: configService.get<number>('REDIS_PORT'),
username: configService.get<string>('REDIS_USERNAME'),
password: configService.get<string>('REDIS_PASSWORD'),
});
},
},
RedisService,
Expand Down
6 changes: 2 additions & 4 deletions server/src/common/redis/redis.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';

@Injectable()
export class RedisService extends Redis {
constructor(@Inject('REDIS_CLIENT') private readonly redisClient: Redis) {
super(redisClient.options);
}
export class RedisService {
constructor(@Inject('REDIS_CLIENT') public readonly redisClient: Redis) {}
}
65 changes: 65 additions & 0 deletions server/src/feed/dto/search-feed.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Feed } from '../feed.entity';
import { IsDefined, IsEnum, IsInt, IsString } from 'class-validator';
import { Type } from 'class-transformer';

export enum SearchType {
TITLE = 'title',
USERNAME = 'userName',
ALL = 'all',
}

export class SearchFeedReq {
@IsDefined({
message: '검색어를 입력해주세요.',
})
@IsString()
find: string;
@IsDefined({
message: '검색 타입을 입력해주세요.',
})
@IsEnum(SearchType, {
message: '검색 타입은 title, userName, all 중 하나여야 합니다.',
})
type: SearchType;
@IsInt({
message: '페이지 번호는 정수입니다.',
})
@Type(() => Number)
page?: number = 1;
@IsInt({
message: '한 페이지에 보여줄 개수는 정수입니다.',
})
@Type(() => Number)
limit?: number = 4;
}

export class SearchFeedResult {
constructor(
private id: number,
private userName: string,
private title: string,
private path: string,
private createdAt: Date,
) {}

static feedsToResults(feeds: Feed[]): SearchFeedResult[] {
return feeds.map((item) => {
return new SearchFeedResult(
item.id,
item.blog.userName,
item.title,
item.path,
item.createdAt,
);
});
}
}

export class SearchFeedRes {
constructor(
private totalCount: number,
private result: SearchFeedResult[],
private totalPages: number,
private limit: number,
) {}
}
74 changes: 74 additions & 0 deletions server/src/feed/feed.api-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ApiQuery,
} from '@nestjs/swagger';
import { applyDecorators } from '@nestjs/common';
import { SearchType } from './dto/search-feed.dto';

export function ApiGetFeedList() {
return applyDecorators(
Expand Down Expand Up @@ -78,6 +79,79 @@ export function ApiGetFeedList() {
);
}

export function ApiSearchFeed() {
return applyDecorators(
ApiOperation({
summary: `검색 API`,
}),
ApiQuery({
name: 'find',
required: true,
type: String,
description: '검색어',
example: '데나무',
}),
ApiQuery({
name: 'type',
required: true,
enum: SearchType,
description: '검색 타입',
example: SearchType.ALL,
}),
ApiQuery({
name: 'page',
required: true,
type: Number,
description: '페이지 번호',
example: 1,
}),
ApiQuery({
name: 'limit',
required: true,
type: Number,
description: '한 페이지에 보여줄 개수',
example: 4,
}),
ApiOkResponse({
description: 'Ok',
schema: {
example: {
message: '검색 결과 조회 완료',
data: {
totalCount: 2,
result: [
{
id: 2,
userName: '향로',
title: '암묵지에서 형식지로',
path: 'https://jojoldu.tistory.com/809',
createdAt: '2024-10-27T02:08:55.000Z',
},
{
id: 3,
userName: '향로',
title: '주인이 아닌데 어떻게 주인의식을 가지죠',
path: 'https://jojoldu.tistory.com/808',
createdAt: '2024-10-12T18:15:06.000Z',
},
],
totalPages: 3,
limit: 2,
},
},
},
}),
ApiBadRequestResponse({
description: 'Bad Request',
schema: {
example: {
message: '오류 메세지 출력',
},
},
}),
);
}

export function ApiGetTrendList() {
return applyDecorators(
ApiOperation({
Expand Down
16 changes: 16 additions & 0 deletions server/src/feed/feed.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import {
} from '@nestjs/common';
import { FeedService } from './feed.service';
import { QueryFeedDto } from './dto/query-feed.dto';
import { SearchFeedReq } from './dto/search-feed.dto';
import {
ApiGetFeedList,
ApiSearchFeed,
ApiGetTrendList,
ApiGetTrendSse,
} from './feed.api-docs';
Expand Down Expand Up @@ -64,4 +66,18 @@ export class FeedController {
});
});
}

@ApiSearchFeed()
@Get('search')
@HttpCode(HttpStatus.OK)
@UsePipes(
new ValidationPipe({
transform: true,
}),
new ValidationPipe(),
)
async searchFeed(@Query() searchFeedReq: SearchFeedReq) {
const data = await this.feedService.search(searchFeedReq);
return ApiResponse.responseWithData('검색 결과 조회 완료', data);
}
}
2 changes: 2 additions & 0 deletions server/src/feed/feed.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
BaseEntity,
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Expand All @@ -20,6 +21,7 @@ export class Feed extends BaseEntity {
})
createdAt: Date;

@Index({ fulltext: true, parser: 'ngram' })
@Column({ name: 'title', nullable: false })
title: string;

Expand Down
64 changes: 57 additions & 7 deletions server/src/feed/feed.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { FeedRepository } from './feed.repository';
import { QueryFeedDto } from './dto/query-feed.dto';
import { Feed } from './feed.entity';
import { FeedResponseDto } from './dto/feed-response.dto';
import { RedisService } from '../common/redis/redis.service';
import { Cron, CronExpression } from '@nestjs/schedule';
import { EventEmitter2 } from '@nestjs/event-emitter';
import * as _ from 'lodash';
import { Feed } from './feed.entity';
import {
SearchFeedReq,
SearchFeedRes,
SearchFeedResult,
} from './dto/search-feed.dto';
import { SelectQueryBuilder } from 'typeorm';

@Injectable()
export class FeedService {
Expand Down Expand Up @@ -36,7 +42,7 @@ export class FeedService {
}

async getTrendList() {
const trendFeedIdList = await this.redisService.zrevrange(
const trendFeedIdList = await this.redisService.redisClient.zrevrange(
'feed:trend',
0,
3,
Expand All @@ -57,22 +63,66 @@ export class FeedService {

@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async resetTrendTable() {
await this.redisService.del('feed:trend');
await this.redisService.redisClient.del('feed:trend');
}

@Cron(CronExpression.EVERY_MINUTE)
async analyzeTrend() {
const [originTrend, nowTrend] = await Promise.all([
this.redisService.lrange('feed:origin_trend', 0, 3),
this.redisService.zrevrange('feed:trend', 0, 3),
this.redisService.redisClient.lrange('feed:origin_trend', 0, 3),
this.redisService.redisClient.zrevrange('feed:trend', 0, 3),
]);
if (!_.isEqual(originTrend, nowTrend)) {
const redisPipeline = this.redisService.pipeline();
const redisPipeline = this.redisService.redisClient.pipeline();
redisPipeline.del('feed:origin_trend');
redisPipeline.rpush('feed:origin_trend', ...nowTrend);
await redisPipeline.exec();
const trendFeeds = await this.getTrendList();
this.eventService.emit('ranking-update', trendFeeds);
}
}

async search(searchFeedReq: SearchFeedReq) {
console.log(typeof searchFeedReq.page);

const { find, page, limit, type } = searchFeedReq;
const offset = (page - 1) * limit;

const qb = this.feedRepository
.createQueryBuilder('feed')
.leftJoinAndSelect('feed.blog', 'blog')
.orderBy('feed.createdAt', 'DESC')
.skip(offset)
.take(limit);
this.applySearchConditions(qb, type, find);

const [result, totalCount] = await qb.getManyAndCount();
const results = SearchFeedResult.feedsToResults(result);
const totalPages = Math.ceil(totalCount / limit);

return new SearchFeedRes(totalCount, results, totalPages, limit);
}

private applySearchConditions(
qb: SelectQueryBuilder<Feed>,
type: string,
find: string,
) {
switch (type) {
case 'title':
qb.where('MATCH (feed.title) AGAINST (:find)', { find });
break;
case 'userName':
qb.where('MATCH (blog.userName) AGAINST (:find)', { find });
break;
case 'all':
qb.where('MATCH (feed.title) AGAINST (:find)', { find }).orWhere(
'MATCH (blog.userName) AGAINST (:find)',
{ find },
);
break;
default:
throw new BadRequestException('검색 타입이 잘못되었습니다.');
}
}
}
Loading

0 comments on commit ebfed1c

Please sign in to comment.