Skip to content

Commit 749c945

Browse files
refactor(be): move chatting filter batch jobs from API server to Socket server (#114)
* refactor: remove batch logic in api server * refactor: remove socket server chat controller * feat: add chatting batch in socket server * test: update chatting unit test * refactor: remove socket http end point * feat: add event handler in socket broadcast * chore: auto-fix linting and formatting issues --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent a856a99 commit 749c945

20 files changed

+290
-498
lines changed

apps/server/src/app.module.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
22
import { ConfigModule } from '@nestjs/config';
33
import { APP_FILTER } from '@nestjs/core';
4-
import { ScheduleModule } from '@nestjs/schedule';
54

65
import { AiModule } from '@ai/ai.module';
76
import { AuthModule } from '@auth/auth.module';
@@ -16,7 +15,6 @@ import { QuestionsModule } from '@questions/questions.module';
1615
import { RepliesModule } from '@replies/replies.module';
1716
import { SessionsModule } from '@sessions/sessions.module';
1817
import { SessionsAuthModule } from '@sessions-auth/sessions-auth.module';
19-
import { SocketModule } from '@socket/socket.module';
2018
import { UploadModule } from '@upload/upload.module';
2119
import { UsersModule } from '@users/users.module';
2220

@@ -27,7 +25,6 @@ import { UsersModule } from '@users/users.module';
2725
isGlobal: true,
2826
envFilePath: '.env',
2927
}),
30-
ScheduleModule.forRoot(),
3128
UsersModule,
3229
PrismaModule,
3330
SessionsModule,
@@ -36,7 +33,6 @@ import { UsersModule } from '@users/users.module';
3633
RepliesModule,
3734
AuthModule,
3835
UploadModule,
39-
SocketModule,
4036
ChatsModule,
4137
LoggerModule,
4238
AiModule,

apps/server/src/chats/chats.module.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@ import { ChatsController } from './chats.controller';
44
import { ChatsRepository } from './chats.repository';
55
import { ChatsService } from './chats.service';
66

7-
import { LoggerModule } from '@logger/logger.module';
87
import { PrismaModule } from '@prisma-alias/prisma.module';
98

109
@Module({
11-
imports: [PrismaModule, LoggerModule],
10+
imports: [PrismaModule],
1211
providers: [ChatsRepository, ChatsService],
1312
exports: [ChatsService, ChatsRepository],
1413
controllers: [ChatsController],
Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,10 @@
11
import { Injectable } from '@nestjs/common';
2-
import { AbuseState } from '@prisma/client';
3-
4-
import { ChatSaveDto } from './chats.service';
52

63
import { PrismaService } from '@prisma-alias/prisma.service';
74

85
@Injectable()
96
export class ChatsRepository {
107
constructor(private readonly prisma: PrismaService) {}
11-
async save({ sessionId, token, body }: ChatSaveDto) {
12-
return await this.prisma.chatting.create({
13-
data: { sessionId, createUserToken: token, body },
14-
include: {
15-
createUserTokenEntity: {
16-
select: {
17-
user: true,
18-
},
19-
},
20-
},
21-
});
22-
}
23-
24-
async update(chattingId: number, abuse: AbuseState) {
25-
await this.prisma.chatting.update({
26-
where: { chattingId },
27-
data: { abuse },
28-
});
29-
}
308

319
async getChatsForInfiniteScroll(sessionId: string, count: number, chatId?: number) {
3210
return await this.prisma.chatting.findMany({
@@ -47,14 +25,4 @@ export class ChatsRepository {
4725
take: count,
4826
});
4927
}
50-
51-
async getChatsForFilter(count: number, chatId: number) {
52-
return await this.prisma.chatting.findMany({
53-
where: {
54-
chattingId: { gt: chatId },
55-
abuse: AbuseState.PENDING,
56-
},
57-
take: count,
58-
});
59-
}
6028
}
Lines changed: 2 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,12 @@
11
import { Test, TestingModule } from '@nestjs/testing';
2-
import { AbuseState } from '@prisma/client';
32

43
import { ChatsRepository } from './chats.repository';
5-
import { ChatSaveDto, ChatsService } from './chats.service';
6-
import {
7-
MOCK_CHAT_DATA,
8-
MOCK_CHAT_DATA_NO_NICKNAME,
9-
MOCK_SAVED_CHAT,
10-
MOCK_SAVED_CHAT_NO_NICKNAME,
11-
} from './test-chats.mock';
12-
13-
import { LoggerService } from '@logger/logger.service';
4+
import { ChatsService } from './chats.service';
5+
import { MOCK_CHAT_DATA, MOCK_CHAT_DATA_NO_NICKNAME } from './test-chats.mock';
146

157
describe('ChatsService', () => {
168
let service: ChatsService;
179
let chatsRepository: jest.Mocked<ChatsRepository>;
18-
let fetchMock: jest.SpyInstance;
1910

2011
beforeEach(async () => {
2112
const module: TestingModule = await Test.createTestingModule({
@@ -26,78 +17,19 @@ describe('ChatsService', () => {
2617
useValue: {
2718
save: jest.fn(),
2819
getChatsForInfiniteScroll: jest.fn(),
29-
getChatsForFilter: jest.fn(),
30-
update: jest.fn(),
31-
},
32-
},
33-
{
34-
provide: LoggerService,
35-
useValue: {
36-
log: jest.fn(),
37-
error: jest.fn(),
38-
warn: jest.fn(),
39-
debug: jest.fn(),
4020
},
4121
},
4222
],
4323
}).compile();
4424

4525
service = module.get<ChatsService>(ChatsService);
4626
chatsRepository = module.get(ChatsRepository);
47-
48-
fetchMock = jest.spyOn(global, 'fetch').mockImplementation(() =>
49-
Promise.resolve({
50-
ok: true,
51-
text: () => Promise.resolve(JSON.stringify({ predicted: '일반어', probability: 0.9 })),
52-
} as Response),
53-
);
5427
});
5528

5629
it('서비스가 정의되어 있어야 한다', () => {
5730
expect(service).toBeDefined();
5831
});
5932

60-
describe('saveChat', () => {
61-
it('채팅을 저장하고 저장된 데이터를 반환해야 한다', async () => {
62-
const data: ChatSaveDto = {
63-
sessionId: '123',
64-
token: 'mockToken',
65-
body: 'Test message',
66-
};
67-
68-
chatsRepository.save.mockResolvedValue(MOCK_SAVED_CHAT);
69-
70-
const result = await service.saveChat(data);
71-
expect(chatsRepository.save).toHaveBeenCalledWith(data);
72-
expect(result).toEqual({
73-
chattingId: 1,
74-
nickname: 'TestUser',
75-
content: 'Test chat message',
76-
abuse: false,
77-
});
78-
});
79-
80-
it('사용자의 닉네임이 없는 경우 "익명"을 반환해야 한다', async () => {
81-
const data: ChatSaveDto = {
82-
sessionId: '123',
83-
token: 'mockToken',
84-
body: 'Test message',
85-
};
86-
87-
chatsRepository.save.mockResolvedValue(MOCK_SAVED_CHAT_NO_NICKNAME);
88-
89-
const result = await service.saveChat(data);
90-
91-
expect(chatsRepository.save).toHaveBeenCalledWith(data);
92-
expect(result).toEqual({
93-
chattingId: 1,
94-
nickname: '익명',
95-
content: 'Test message',
96-
abuse: false,
97-
});
98-
});
99-
});
100-
10133
describe('getChatsForInfiniteScroll', () => {
10234
it('무한 스크롤을 위한 채팅 목록을 조회해야 한다', async () => {
10335
const sessionId = '123';
@@ -126,73 +58,4 @@ describe('ChatsService', () => {
12658
expect(result).toEqual([{ chattingId: 10, nickname: '익명', content: 'Message 1', abuse: false }]);
12759
});
12860
});
129-
130-
describe('detectAbuseBatch', () => {
131-
it('새로운 채팅들을 필터링하고 상태를 업데이트해야 한다', async () => {
132-
const SAFE_WORD = 'Hello';
133-
const BAD_WORD = 'Bad word';
134-
135-
const mockChats = [
136-
{ ...MOCK_SAVED_CHAT, chattingId: 1, body: SAFE_WORD },
137-
{ ...MOCK_SAVED_CHAT, chattingId: 2, body: BAD_WORD },
138-
];
139-
140-
chatsRepository.getChatsForFilter.mockResolvedValue(mockChats);
141-
142-
fetchMock
143-
.mockResolvedValueOnce({
144-
ok: true,
145-
text: () => Promise.resolve(JSON.stringify({ predicted: '일반어', probability: 0.9 })),
146-
} as Response)
147-
.mockResolvedValueOnce({
148-
ok: true,
149-
text: () => Promise.resolve(JSON.stringify({ predicted: '욕설', probability: 0.9 })),
150-
} as Response);
151-
152-
await service.detectAbuseBatch();
153-
154-
expect(chatsRepository.update).toHaveBeenCalledWith(1, AbuseState.SAFE);
155-
expect(chatsRepository.update).toHaveBeenCalledWith(2, AbuseState.BLOCKED);
156-
});
157-
});
158-
159-
describe('checkAbuse', () => {
160-
it('욕설이 감지되면 true를 반환해야 한다', async () => {
161-
fetchMock.mockResolvedValueOnce({
162-
ok: true,
163-
text: () => Promise.resolve(JSON.stringify({ predicted: '욕설', probability: 0.9 })),
164-
} as Response);
165-
166-
const result = await service.checkAbuse('욕설 내용');
167-
expect(result).toBe(true);
168-
});
169-
170-
it('일반어가 감지되면 false를 반환해야 한다', async () => {
171-
fetchMock.mockResolvedValueOnce({
172-
ok: true,
173-
text: () => Promise.resolve(JSON.stringify({ predicted: '일반어', probability: 0.9 })),
174-
} as Response);
175-
176-
const result = await service.checkAbuse('일반 내용');
177-
expect(result).toBe(false);
178-
});
179-
180-
it('API 요청이 실패하면 false를 반환해야 한다', async () => {
181-
fetchMock.mockResolvedValueOnce({
182-
ok: false,
183-
status: 500,
184-
statusText: 'Internal Server Error',
185-
} as Response);
186-
187-
const result = await service.checkAbuse('테스트 내용');
188-
expect(result).toBe(false);
189-
});
190-
191-
it('네트워크 에러가 발생하면 false를 반환해야 한다', async () => {
192-
fetchMock.mockRejectedValueOnce(new Error('Network error'));
193-
194-
const result = await service.checkAbuse('테스트 내용');
195-
expect(result).toBe(false);
196-
});
197-
});
19861
});
Lines changed: 1 addition & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,10 @@
11
import { Injectable } from '@nestjs/common';
2-
import { Cron, CronExpression } from '@nestjs/schedule';
3-
import { AbuseState } from '@prisma/client';
42

53
import { ChatsRepository } from './chats.repository';
64

7-
import { LoggerService } from '@logger/logger.service';
8-
9-
export interface ChatSaveDto {
10-
sessionId: string;
11-
token: string;
12-
body: string;
13-
}
14-
15-
interface SlangPredictResult {
16-
predicted: '욕설' | '일반어';
17-
probability: number;
18-
}
19-
205
@Injectable()
216
export class ChatsService {
22-
private lastProcessedChattingId = 0;
23-
private readonly BATCH_SIZE = 10;
24-
25-
constructor(
26-
private readonly chatsRepository: ChatsRepository,
27-
private readonly logger: LoggerService,
28-
) {}
29-
30-
async saveChat(data: ChatSaveDto) {
31-
const chat = await this.chatsRepository.save(data);
32-
const { chattingId, createUserTokenEntity, body: content } = chat;
33-
return {
34-
chattingId,
35-
nickname: createUserTokenEntity?.user?.nickname || '익명',
36-
content,
37-
abuse: chat.abuse === 'BLOCKED' ? true : false,
38-
};
39-
}
7+
constructor(private readonly chatsRepository: ChatsRepository) {}
408

419
async getChatsForInfiniteScroll(sessionId: string, count: number, chatId?: number) {
4210
const chats = await this.chatsRepository.getChatsForInfiniteScroll(sessionId, count, chatId);
@@ -53,69 +21,4 @@ export class ChatsService {
5321
};
5422
});
5523
}
56-
57-
@Cron(CronExpression.EVERY_MINUTE, { name: 'chatting-abuse-detection' })
58-
async detectAbuseBatch() {
59-
const startTime = new Date();
60-
61-
const newChats = await this.chatsRepository.getChatsForFilter(this.BATCH_SIZE, this.lastProcessedChattingId);
62-
this.lastProcessedChattingId = newChats.reduce(
63-
(max, { chattingId }) => Math.max(max, chattingId),
64-
this.lastProcessedChattingId,
65-
);
66-
const abuseResult = await Promise.all(
67-
newChats.map(async ({ chattingId, body, sessionId }) => {
68-
const abuse = (await this.checkAbuse(body)) ? AbuseState.BLOCKED : AbuseState.SAFE;
69-
this.chatsRepository.update(chattingId, abuse);
70-
return { chattingId, sessionId, abuse };
71-
}),
72-
);
73-
74-
this.broadcast(abuseResult.filter(({ abuse }) => abuse === AbuseState.BLOCKED));
75-
76-
const endTime = new Date();
77-
const executionTime = endTime.getTime() - startTime.getTime();
78-
this.logger.log(
79-
`[BATCH] last chatting id: ${this.lastProcessedChattingId} chatting count: ${newChats.length} execution time: ${executionTime}ms `,
80-
);
81-
}
82-
83-
async broadcast(abuseChattings: { chattingId: number; sessionId: string }[]) {
84-
const SOCKET_SERVER_HOST = process.env.SOCKET_SERVER_HOST;
85-
const SOCKET_SERVER_PORT = process.env.SOCKET_SERVER_PORT;
86-
87-
try {
88-
fetch(`${SOCKET_SERVER_HOST}:${SOCKET_SERVER_PORT}/api/socket/abuse-chattings`, {
89-
method: 'POST',
90-
headers: { 'Content-Type': 'application/json' },
91-
body: JSON.stringify({ abuseChattings }),
92-
});
93-
} catch (error) {
94-
this.logger.error('Failed to fetch /api/socket/abuse-chattings', error.stack);
95-
}
96-
}
97-
98-
async checkAbuse(content: string) {
99-
const CLASSIFIER_SERVER_HOST = process.env.CLASSIFIER_SERVER_HOST;
100-
const CLASSIFIER_SERVER_PORT = process.env.CLASSIFIER_SERVER_PORT;
101-
102-
try {
103-
const response = await fetch(`${CLASSIFIER_SERVER_HOST}:${CLASSIFIER_SERVER_PORT}/slang-predict`, {
104-
method: 'POST',
105-
headers: { 'Content-Type': 'application/json' },
106-
body: JSON.stringify({ input: content }),
107-
});
108-
109-
if (!response.ok) {
110-
this.logger.error(`Classifier server response: ${response.status}`, response.statusText);
111-
return false;
112-
}
113-
114-
const data: SlangPredictResult = JSON.parse(await response.text());
115-
return data.predicted === '욕설';
116-
} catch (error) {
117-
this.logger.error(`Unexpected error while requesting classifier server`, error.stack);
118-
return false;
119-
}
120-
}
12124
}

apps/server/src/socket/socket.constant.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@ export const SOCKET_EVENTS = {
1919

2020
HOST_CHANGED: 'hostChanged',
2121
SESSION_ENDED: 'sessionEnded',
22+
CHATTING_FILTERED: 'chattingFiltered',
2223
} as const;

0 commit comments

Comments
 (0)