diff --git a/package.json b/package.json index 06bc7a3..be1b481 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/moderation.ts b/src/api/moderation.ts new file mode 100644 index 0000000..139833b --- /dev/null +++ b/src/api/moderation.ts @@ -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 { + if (!MODERATION_API_URL) { + throw new Error( + 'VITE_MODERATION_API_URL 환경 변수가 설정되지 않았습니다. .env 파일에 VITE_MODERATION_API_URL을 추가해주세요..', + ); + } + + const { data: result } = await axios.post( + 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, +) { + return useMutation({ + mutationFn: (text: string) => requestModeration(text), + ...options, + }); +} diff --git a/src/components/poll/edit/PollEditButton/hooks.ts b/src/components/poll/edit/PollEditButton/hooks.ts index a15c760..d112342 100644 --- a/src/components/poll/edit/PollEditButton/hooks.ts +++ b/src/components/poll/edit/PollEditButton/hooks.ts @@ -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: () => { @@ -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 { diff --git a/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx b/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx index 401cbc9..74546a2 100644 --- a/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx +++ b/src/components/poll/regist/PollRegistButton/PollRegistButton.tsx @@ -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(); @@ -32,15 +33,21 @@ export default function PollRegistButton() { openBottomSheet(); }, }); + 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, + })), + }); + }, }); } }; diff --git a/src/hooks/useModerationCheck.tsx b/src/hooks/useModerationCheck.tsx new file mode 100644 index 0000000..21fbea0 --- /dev/null +++ b/src/hooks/useModerationCheck.tsx @@ -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> + > + ).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( + { + closeDialog(); + variables.onConfirm(); + }, + }} + showLaterButton={false} + inlineMessage={inlineMessage} + />, + ); + } else { + variables.onConfirm(); + } + }, + }); +} diff --git a/yarn.lock b/yarn.lock index 7b578da..20a2a72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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"