Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@tailwindcss/vite": "^4.0.0",
"@tanstack/react-query": "^5.64.2",
"axios": "^1.7.9",
"chooz-ai-moderation": "^0.1.0",
"class-variance-authority": "^0.7.1",
"motion": "^12.16.0",
"react": "^18.3.1",
Expand Down
64 changes: 64 additions & 0 deletions src/api/moderation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import axios from 'axios';

export type ModerationResult = {
isAllowed: boolean;
category:
| 'ok'
| 'profanity'
| 'hate'
| 'sexual'
| 'violence'
| 'self_harm'
| 'etc';
detectedWords?: string[];
};

const MODERATION_API_URL = import.meta.env.VITE_MODERATION_API_URL;

export async function requestModeration(
content: string,
): Promise<ModerationResult> {
if (!MODERATION_API_URL) {
throw new Error(
'VITE_MODERATION_API_URL 환경 변수가 설정되지 않았습니다. .env 파일에 VITE_MODERATION_API_URL을 추가해주세요..',
);
}

const { data: result } = await axios.post<ModerationResult>(
MODERATION_API_URL,
{ content },
{
headers: {
'Content-Type': 'application/json',
},
withCredentials: false,
},
);

// detectedWords가 배열이 아니면 배열로 변환
let detectedWords = result.detectedWords;
if (!Array.isArray(detectedWords)) {
if (detectedWords && typeof detectedWords === 'string') {
detectedWords = [detectedWords];
} else {
detectedWords = [];
}
}

// detectedWords가 없으면 빈 배열로 설정
return {
...result,
detectedWords: detectedWords || [],
};
}

// React Query 훅
export function useModerateText(
options?: UseMutationOptions<ModerationResult, Error, string>,
) {
return useMutation<ModerationResult, Error, string>({
mutationFn: (text: string) => requestModeration(text),
...options,
});
}
13 changes: 11 additions & 2 deletions src/components/poll/edit/PollEditButton/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { useNavigate, useParams } from 'react-router-dom';
import useUpdatePoll from '@/api/useUpdatePoll';
import usePollForm from '@/components/poll/Provider/hooks';
import { useModerationCheck } from '@/hooks/useModerationCheck';

export default function usePollEditButton() {
const navigate = useNavigate();
const { isValid, data } = usePollForm();
const { pollId } = useParams<{ pollId: string }>();
const { mutate: checkModeration, isPending: isModerationPending } =
useModerationCheck();

const { mutate: updatePoll, isPending } = useUpdatePoll({
const { mutate: updatePoll, isPending: isUpdatePending } = useUpdatePoll({
id: Number(pollId),
options: {
onSuccess: () => {
Expand All @@ -16,8 +19,14 @@ export default function usePollEditButton() {
},
});

const isPending = isModerationPending || isUpdatePending;

const handleClickPollEditButton = () => {
updatePoll(data);
if (!isValid) return;
checkModeration({
pollData: data,
onConfirm: () => updatePoll(data),
});
};

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import ReactGA from 'react-ga4';
import { useNavigate } from 'react-router-dom';
import usePollForm from '../../Provider/hooks';
import useGetMyInfo from '@/api/useGetMyInfo';
import usePostRegistVote from '@/api/usePostRegistVote';
import useUpdateOnboarding from '@/api/useUpdateOnboarding';
import { useBottomSheet } from '@/components/common/BottomSheet/hooks';
import { Button } from '@/components/common/Button/Button';
import Loading from '@/components/common/Loading';
import PollCreatedShareBottomSheet from '@/components/common/PollCreatedShareBottomSheet';
import useGetMyInfo from '@/api/useGetMyInfo';
import usePostRegistVote from '@/api/usePostRegistVote';
import useUpdateOnboarding from '@/api/useUpdateOnboarding';
import { useModerationCheck } from '@/hooks/useModerationCheck';

export default function PollRegistButton() {
const navigate = useNavigate();
Expand All @@ -32,15 +33,21 @@ export default function PollRegistButton() {
openBottomSheet(<PollCreatedShareBottomSheet shareUrl={shareUrl} />);
},
});
const { mutate: checkModeration } = useModerationCheck();

const handleClickSubmitButton = () => {
if (isValid) {
registVote({
...pollData,
pollChoices: pollData.pollChoices.map((choice) => ({
title: choice.title,
imageUrl: choice.imageUrl,
})),
checkModeration({
pollData,
onConfirm: () => {
registVote({
...pollData,
pollChoices: pollData.pollChoices.map((choice) => ({
title: choice.title,
imageUrl: choice.imageUrl,
})),
});
},
});
}
};
Expand Down
88 changes: 88 additions & 0 deletions src/hooks/useModerationCheck.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useMutation } from '@tanstack/react-query';
import { requestModeration } from '@/api/moderation';
import Dialog from '@/components/common/Dialog/Dialog';
import { useDialog } from '@/components/common/Dialog/hooks';
import { PollFormData } from '@/components/poll/Provider/types';

interface ModerationCheckParams {
pollData: PollFormData;
onConfirm: () => void;
}

export function useModerationCheck() {
const { openDialog, closeDialog } = useDialog();

return useMutation({
mutationFn: async ({ pollData }: ModerationCheckParams) => {
// 제목, 내용, 이미지 이름을 모두 검사
const textsToCheck = [
pollData.title,
pollData.description,
...pollData.pollChoices.map((choice) => choice.title),
].filter(Boolean);

// 모든 텍스트를 검사하고 결과 수집 (에러가 발생해도 계속 진행)
const moderationSettledResults = await Promise.allSettled(
textsToCheck.map((text) => requestModeration(text)),
);

// 성공한 결과만 필터링
const moderationResults = moderationSettledResults
.filter((result) => result.status === 'fulfilled')
.map(
(result) =>
(
result as PromiseFulfilledResult<
Awaited<ReturnType<typeof requestModeration>>
>
).value,
);

// 욕설이 감지된 경우 찾기
const detectedResults = moderationResults.filter(
(result) => !result.isAllowed,
);

// 감지된 결과에서만 detectedWords 수집
const allDetectedWords = detectedResults
.flatMap((result) => {
return result.detectedWords || [];
})
.filter((word) => word && word.trim().length > 0) // 빈 문자열 제거
.filter((word, index, self) => self.indexOf(word) === index); // 중복 제거

return {
hasDetectedWords: detectedResults.length > 0,
detectedWords: allDetectedWords,
};
},
onSuccess: (result, variables) => {
if (result.hasDetectedWords) {
const inlineMessage =
result.detectedWords.length > 0
? `비속어: ${result.detectedWords.map((word) => `"${word}"`).join(', ')}`
: '';

openDialog(
<Dialog
title="잠깐!"
description="작성한 내용에 부적절한 표현이 포함되어 있어요."
hasCloseButton={true}
cancelButtonProps={{ text: '수정하기' }}
confirmButtonProps={{
text: '그대로 올리기',
onClick: () => {
closeDialog();
variables.onConfirm();
},
}}
showLaterButton={false}
inlineMessage={inlineMessage}
/>,
);
} else {
variables.onConfirm();
}
},
});
}
12 changes: 12 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1849,6 +1849,13 @@ chooz-ai-code-review-cli@^0.1.0:
"@octokit/rest" "^20.0.0"
openai "^4.0.0"

chooz-ai-moderation@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/chooz-ai-moderation/-/chooz-ai-moderation-0.1.0.tgz#9461ce239cc983cabbcce4c7df4b3675c61fbc6d"
integrity sha512-B/DpJcJf+UNHeMTZqIvXUmuZK+d1od0L33F2DMe7dG6RQVt84lz7GYk3JrbQEqVJoF/9HtmtxFJRhUv/C6JCtQ==
dependencies:
openai "^6.9.1"

class-variance-authority@^0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz#4008a798a0e4553a781a57ac5177c9fb5d043787"
Expand Down Expand Up @@ -3682,6 +3689,11 @@ openai@^4.0.0:
formdata-node "^4.3.2"
node-fetch "^2.6.7"

openai@^6.9.1:
version "6.10.0"
resolved "https://registry.yarnpkg.com/openai/-/openai-6.10.0.tgz#3f52d2ad7b6b2288124d064b0eb737c914d1f3ea"
integrity sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==

optionator@^0.9.3:
version "0.9.4"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
Expand Down