From 55d75cef6afccadf9e0708cea60e96368e245d04 Mon Sep 17 00:00:00 2001 From: iubns Date: Sat, 17 Jan 2026 18:29:21 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20AI=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/app/admin/ai/chat/Chat.tsx | 21 +++++++++++++ client/src/app/admin/ai/chat/LeftList.tsx | 7 +++++ client/src/app/admin/ai/chat/page.tsx | 16 ++++++++++ .../src/app/admin/components/Header/index.tsx | 7 +++++ server/scripts/backup-db.sh | 8 ++--- server/src/entity/ai/aiChat.ts | 26 ++++++++++++++++ server/src/entity/ai/aiChatRoom.ts | 24 ++++++++++++++ .../migration/1765814621862-WorshipContest.ts | 2 +- .../migration/1768640534507-CreateAiChat.ts | 31 +++++++++++++++++++ 9 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 client/src/app/admin/ai/chat/Chat.tsx create mode 100644 client/src/app/admin/ai/chat/LeftList.tsx create mode 100644 client/src/app/admin/ai/chat/page.tsx create mode 100644 server/src/entity/ai/aiChat.ts create mode 100644 server/src/entity/ai/aiChatRoom.ts create mode 100644 server/src/migration/1768640534507-CreateAiChat.ts diff --git a/client/src/app/admin/ai/chat/Chat.tsx b/client/src/app/admin/ai/chat/Chat.tsx new file mode 100644 index 0000000..de5ff72 --- /dev/null +++ b/client/src/app/admin/ai/chat/Chat.tsx @@ -0,0 +1,21 @@ +"use client" + +import useAuth from "@/hooks/useAuth" +import { Stack } from "@mui/material" + +export default function AdminAIChatComponent() { + const { authUserData } = useAuth() + + return ( + + {authUserData?.name}님 + + + + + ) +} + +function ChatComponent() { + return
Chat Component
+} diff --git a/client/src/app/admin/ai/chat/LeftList.tsx b/client/src/app/admin/ai/chat/LeftList.tsx new file mode 100644 index 0000000..7f2f1d7 --- /dev/null +++ b/client/src/app/admin/ai/chat/LeftList.tsx @@ -0,0 +1,7 @@ +"use client" + +import { Stack } from "@mui/material" + +export default function AdminAIChatLeftComponent() { + return Left List Component +} diff --git a/client/src/app/admin/ai/chat/page.tsx b/client/src/app/admin/ai/chat/page.tsx new file mode 100644 index 0000000..fe11318 --- /dev/null +++ b/client/src/app/admin/ai/chat/page.tsx @@ -0,0 +1,16 @@ +"use client" + +import { Stack } from "@mui/material" +import AdminAIChatComponent from "./Chat" +import AdminAIChatLeftComponent from "./leftList" + +export default function AdminAIChatPage() { + return ( + + + + + + + ) +} diff --git a/client/src/app/admin/components/Header/index.tsx b/client/src/app/admin/components/Header/index.tsx index 42e3a98..b00a8b9 100644 --- a/client/src/app/admin/components/Header/index.tsx +++ b/client/src/app/admin/components/Header/index.tsx @@ -9,6 +9,7 @@ import CommunityIcon from "@mui/icons-material/Groups" import HeaderDrawer, { DrawerItemsType } from "@/components/Header/Drawer" import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline" import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined" +import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome" export default function AdminHeader() { const { push } = useRouter() @@ -62,6 +63,12 @@ export default function AdminHeader() { path: "/admin/soon/attendance", type: "menu", }, + { + title: "AI로 데이터 분석", + icon: , + path: "/admin/ai/chat", + type: "menu", + }, { type: "divider", }, diff --git a/server/scripts/backup-db.sh b/server/scripts/backup-db.sh index df49371..8a1b025 100755 --- a/server/scripts/backup-db.sh +++ b/server/scripts/backup-db.sh @@ -78,11 +78,11 @@ if [ $? -eq 0 ]; then echo "완료 시간: $(date)" # 백업 파일 압축 - #gzip $BACKUP_FILE - #echo "✅ 압축 완료: ${BACKUP_FILE}.gz" + gzip $BACKUP_FILE + echo "✅ 압축 완료: ${BACKUP_FILE}.gz" - # 7일 이상 된 백업 파일 삭제 - find $BACKUP_DIR -name "*.sql.gz" -mtime +7 -delete + # 30일 이상 된 백업 파일 삭제 + find $BACKUP_DIR -name "*.sql.gz" -mtime +30 -delete echo "✅ 오래된 백업 파일 정리 완료" else echo "❌ 백업 실패!" diff --git a/server/src/entity/ai/aiChat.ts b/server/src/entity/ai/aiChat.ts new file mode 100644 index 0000000..ac73a23 --- /dev/null +++ b/server/src/entity/ai/aiChat.ts @@ -0,0 +1,26 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm" +import { AIChatRoom } from "./aiChatRoom" + +export enum ChatType { + USER = "user", + AI = "ai", + SYSTEM = "system", +} + +@Entity() +export class AIChat { + @PrimaryGeneratedColumn("uuid") + id: string + + @ManyToOne(() => AIChatRoom, (room) => room.id) + roomId: string + + @Column({ type: "enum", enum: ChatType }) + type: ChatType + + @Column("text") + message: string + + @Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" }) + createdAt: Date +} diff --git a/server/src/entity/ai/aiChatRoom.ts b/server/src/entity/ai/aiChatRoom.ts new file mode 100644 index 0000000..95f3c1d --- /dev/null +++ b/server/src/entity/ai/aiChatRoom.ts @@ -0,0 +1,24 @@ +import { + Column, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from "typeorm" +import { AIChat } from "./aiChat" +import { User } from "../user" + +@Entity() +export class AIChatRoom { + @PrimaryGeneratedColumn("uuid") + id: string + + @OneToMany(() => AIChat, (chat) => chat.roomId) + chats: AIChat[] + + @ManyToOne(() => User, (user) => user.id) + user: User + + @Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" }) + createdAt: Date +} diff --git a/server/src/migration/1765814621862-WorshipContest.ts b/server/src/migration/1765814621862-WorshipContest.ts index 14271fd..5a48a38 100644 --- a/server/src/migration/1765814621862-WorshipContest.ts +++ b/server/src/migration/1765814621862-WorshipContest.ts @@ -5,7 +5,7 @@ export class WorshipContest1765814621862 implements MigrationInterface { await queryRunner.query(` CREATE TABLE \`worship_contest\` ( \`id\` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - \`voteUserId\` CHAR(36), + \`voteUserId\` VARCHAR(36) NOT NULL, \`firstCommunity\` VARCHAR(255) NOT NULL, \`secondCommunity\` VARCHAR(255) NOT NULL, \`thirdCommunity\` VARCHAR(255) NOT NULL, diff --git a/server/src/migration/1768640534507-CreateAiChat.ts b/server/src/migration/1768640534507-CreateAiChat.ts new file mode 100644 index 0000000..4805cbe --- /dev/null +++ b/server/src/migration/1768640534507-CreateAiChat.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class CreateAiChat1768640534507 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE \`ai_chat_room\` ( + \`id\` varchar(36) NOT NULL, + \`userId\` varchar(36) COLLATE utf8mb4_general_ci NULL, + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (\`id\`), + CONSTRAINT \`FK_ai_chat_room_user\` FOREIGN KEY (\`userId\`) REFERENCES \`user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + await queryRunner.query(` + CREATE TABLE \`ai_chat\` ( + \`id\` varchar(36) NOT NULL, + \`roomId\` varchar(36) COLLATE utf8mb4_general_ci NULL, + \`type\` enum ('user', 'ai', 'system') NOT NULL, + \`message\` text COLLATE utf8mb4_general_ci NOT NULL, + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (\`id\`), + CONSTRAINT \`FK_ai_chat_roomId\` FOREIGN KEY (\`roomId\`) REFERENCES \`ai_chat_room\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE \`ai_chat\``) + await queryRunner.query(`DROP TABLE \`ai_chat_room\``) + } +} From 58f3f088ba0474b476c28020f03ebbf42f639f74 Mon Sep 17 00:00:00 2001 From: iubns Date: Sun, 18 Jan 2026 02:35:55 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20AI=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B8=B0=EC=B4=88=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/app/admin/ai/LeftList.tsx | 34 ++ client/src/app/admin/ai/chat/Chat.tsx | 53 ++- client/src/app/admin/ai/chat/LeftList.tsx | 7 - client/src/app/admin/ai/chat/page.tsx | 2 +- client/src/app/admin/ai/chat/useAiChat.ts | 50 +++ server/src/entity/ai/aiChat.ts | 2 +- server/src/entity/ai/aiChatRoom.ts | 7 +- .../migration/1768640534507-CreateAiChat.ts | 1 + .../1768657178669-AddCommentInAllColumn.ts | 303 ++++++++++++++++++ server/src/model/ai.ts | 204 ++++++++++++ server/src/model/dataSource.ts | 5 + server/src/routes/admin/ai.ts | 111 +++++-- 12 files changed, 739 insertions(+), 40 deletions(-) create mode 100644 client/src/app/admin/ai/LeftList.tsx delete mode 100644 client/src/app/admin/ai/chat/LeftList.tsx create mode 100644 client/src/app/admin/ai/chat/useAiChat.ts create mode 100644 server/src/migration/1768657178669-AddCommentInAllColumn.ts create mode 100644 server/src/model/ai.ts diff --git a/client/src/app/admin/ai/LeftList.tsx b/client/src/app/admin/ai/LeftList.tsx new file mode 100644 index 0000000..f687f02 --- /dev/null +++ b/client/src/app/admin/ai/LeftList.tsx @@ -0,0 +1,34 @@ +"use client" + +import { Stack } from "@mui/material" +import useAiChat from "./chat/useAiChat" +import { useEffect, useState } from "react" +import { AIChatRoom } from "@server/entity/ai/aiChatRoom" + +export default function AdminAIChatLeftComponent() { + const { getChatRooms, selectedChatRoomId, setSelectedChatRoomId } = + useAiChat() + const [chatRooms, setChatRooms] = useState([]) + + useEffect(() => { + getChatRooms().then((res) => { + setChatRooms(res.data) + }) + }, []) + + return ( + + {chatRooms.map((room) => ( + setSelectedChatRoomId(room.id)} + bgcolor={selectedChatRoomId === room.id ? "#eee" : "transparent"} + > + {room.title} + + ))}{" "} + + ) +} diff --git a/client/src/app/admin/ai/chat/Chat.tsx b/client/src/app/admin/ai/chat/Chat.tsx index de5ff72..784f1f4 100644 --- a/client/src/app/admin/ai/chat/Chat.tsx +++ b/client/src/app/admin/ai/chat/Chat.tsx @@ -1,21 +1,62 @@ "use client" import useAuth from "@/hooks/useAuth" -import { Stack } from "@mui/material" +import { Button, Stack, TextField } from "@mui/material" +import useAiChat from "./useAiChat" +import { AIChat } from "@server/entity/ai/aiChat" +import { useState } from "react" + +export enum ChatType { + USER = "user", + AI = "ai", + SYSTEM = "system", +} export default function AdminAIChatComponent() { const { authUserData } = useAuth() + const [message, setMessage] = useState("") + const { selectedChatRoom, sendMessageToAi } = useAiChat() return ( - + {authUserData?.name}님 - - + + {selectedChatRoom && + selectedChatRoom.chats && + selectedChatRoom.chats.map((chat) => ( + + ))} + + + setMessage(e.target.value)} + /> + ) } -function ChatComponent() { - return
Chat Component
+function ChatComponent({ chat }: { chat: AIChat }) { + return ( + + + {chat.message} + + + ) } diff --git a/client/src/app/admin/ai/chat/LeftList.tsx b/client/src/app/admin/ai/chat/LeftList.tsx deleted file mode 100644 index 7f2f1d7..0000000 --- a/client/src/app/admin/ai/chat/LeftList.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client" - -import { Stack } from "@mui/material" - -export default function AdminAIChatLeftComponent() { - return Left List Component -} diff --git a/client/src/app/admin/ai/chat/page.tsx b/client/src/app/admin/ai/chat/page.tsx index fe11318..6796685 100644 --- a/client/src/app/admin/ai/chat/page.tsx +++ b/client/src/app/admin/ai/chat/page.tsx @@ -2,7 +2,7 @@ import { Stack } from "@mui/material" import AdminAIChatComponent from "./Chat" -import AdminAIChatLeftComponent from "./leftList" +import AdminAIChatLeftComponent from "../LeftList" export default function AdminAIChatPage() { return ( diff --git a/client/src/app/admin/ai/chat/useAiChat.ts b/client/src/app/admin/ai/chat/useAiChat.ts new file mode 100644 index 0000000..e70043f --- /dev/null +++ b/client/src/app/admin/ai/chat/useAiChat.ts @@ -0,0 +1,50 @@ +"use client" + +import axios from "@/config/axios" +import { AIChatRoom } from "@server/entity/ai/aiChatRoom" +import { atom, useAtom } from "jotai" +import { useEffect } from "react" + +const SelectedAiCharRoomIdAtom = atom("") +const SelectedAiCharRoomAtom = atom(null) + +export default function useAiChat() { + const [selectedChatRoom, setSelectedChatRoom] = useAtom( + SelectedAiCharRoomAtom + ) + const [selectedChatRoomId, setSelectedChatRoomId] = useAtom( + SelectedAiCharRoomIdAtom + ) + + useEffect(() => { + if (selectedChatRoomId) { + getRoomData(selectedChatRoomId) + } + }, [selectedChatRoomId]) + + async function getChatRooms() { + return await axios.get("/admin/ai/my-rooms") + } + + async function sendMessageToAi(message: string) { + const response = await axios.post("/admin/ai/ask", { + message: message, + roomId: selectedChatRoomId, + }) + setSelectedChatRoom(response.data) + } + + async function getRoomData(roomId: string) { + const response = await axios.get(`/admin/ai/room/${roomId}`) + setSelectedChatRoom(response.data) + } + + return { + getChatRooms, + sendMessageToAi, + selectedChatRoom, + setSelectedChatRoom, + selectedChatRoomId, + setSelectedChatRoomId, + } +} diff --git a/server/src/entity/ai/aiChat.ts b/server/src/entity/ai/aiChat.ts index ac73a23..cd2e6e1 100644 --- a/server/src/entity/ai/aiChat.ts +++ b/server/src/entity/ai/aiChat.ts @@ -13,7 +13,7 @@ export class AIChat { id: string @ManyToOne(() => AIChatRoom, (room) => room.id) - roomId: string + room: AIChatRoom @Column({ type: "enum", enum: ChatType }) type: ChatType diff --git a/server/src/entity/ai/aiChatRoom.ts b/server/src/entity/ai/aiChatRoom.ts index 95f3c1d..e1416ff 100644 --- a/server/src/entity/ai/aiChatRoom.ts +++ b/server/src/entity/ai/aiChatRoom.ts @@ -13,12 +13,17 @@ export class AIChatRoom { @PrimaryGeneratedColumn("uuid") id: string - @OneToMany(() => AIChat, (chat) => chat.roomId) + @OneToMany(() => AIChat, (chat) => chat.room, { + cascade: ["insert", "update"], + }) chats: AIChat[] @ManyToOne(() => User, (user) => user.id) user: User + @Column() + title: string + @Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" }) createdAt: Date } diff --git a/server/src/migration/1768640534507-CreateAiChat.ts b/server/src/migration/1768640534507-CreateAiChat.ts index 4805cbe..24c61d1 100644 --- a/server/src/migration/1768640534507-CreateAiChat.ts +++ b/server/src/migration/1768640534507-CreateAiChat.ts @@ -6,6 +6,7 @@ export class CreateAiChat1768640534507 implements MigrationInterface { CREATE TABLE \`ai_chat_room\` ( \`id\` varchar(36) NOT NULL, \`userId\` varchar(36) COLLATE utf8mb4_general_ci NULL, + \`title\` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (\`id\`), CONSTRAINT \`FK_ai_chat_room_user\` FOREIGN KEY (\`userId\`) REFERENCES \`user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION diff --git a/server/src/migration/1768657178669-AddCommentInAllColumn.ts b/server/src/migration/1768657178669-AddCommentInAllColumn.ts new file mode 100644 index 0000000..978ceff --- /dev/null +++ b/server/src/migration/1768657178669-AddCommentInAllColumn.ts @@ -0,0 +1,303 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class AddCommentInAllColumn1768657178669 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // FK Check 비활성화 + await queryRunner.query("SET FOREIGN_KEY_CHECKS = 0") + + // ========================================== + // 1. User Table (사용자) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '사용자 고유 ID (UUID)'` + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`kakaoId\` varchar(255) NULL COMMENT '카카오 계정 고유 ID (로그인 식별용)'` + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`name\` varchar(255) NULL COMMENT '사용자 실명'` + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`yearOfBirth\` int NULL COMMENT '출생년도 (YYYY 형식)'` + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`gender\` varchar(255) NULL COMMENT '성별 (man: 남성, woman: 여성)'` + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`phone\` varchar(255) NULL COMMENT '전화번호 (하이픈 없이 숫자만 or 하이픈 포함)'` + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`etc\` varchar(255) NULL COMMENT '사용자 관련 비고/특이사항'` + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`token\` varchar(255) NULL COMMENT '자체 인증 토큰'` + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`expire\` datetime NULL COMMENT '인증 토큰 만료 시간'` + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`isSuperUser\` tinyint NOT NULL DEFAULT 0 COMMENT '시스템 최고 관리자 권한 여부 (1: 관리자, 0: 일반)'` + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '계정 생성 일시'` + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`deletedAt\` timestamp(6) NULL COMMENT '계정 삭제 일시 (Soft Delete, NULL이면 활성 계정)'` + ) + await queryRunner.query( + `ALTER TABLE \`user\` MODIFY COLUMN \`profile\` int NOT NULL DEFAULT 0 COMMENT '프로필 이미지 식별자 (ID)'` + ) + // await queryRunner.query(`ALTER TABLE \`user\` MODIFY COLUMN \`communityId\` int NULL COMMENT '현재 소속된 공동체/순 ID (FK)'`); + + // ========================================== + // 2. Community Table (공동체/조직) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`community\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '공동체 고유 ID'` + ) + // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`parentId\` int NULL COMMENT '상위 조직 ID (FK, NULL이면 최상위 조직)'`); + await queryRunner.query( + `ALTER TABLE \`community\` MODIFY COLUMN \`name\` varchar(255) NOT NULL COMMENT '공동체 이름 (예: 무슨무슨 마을, 수련회 조)'` + ) + await queryRunner.query( + `ALTER TABLE \`community\` MODIFY COLUMN \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '생성 일시'` + ) + await queryRunner.query( + `ALTER TABLE \`community\` MODIFY COLUMN \`lastModifiedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '마지막 정보 수정 일시'` + ) + // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`leaderId\` varchar(36) NULL COMMENT '리더(순장/조장) 사용자 ID (FK)'`); + // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`deputyLeaderId\` varchar(36) NULL COMMENT '부리더(부순장/부조장) 사용자 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`community\` MODIFY COLUMN \`x\` int NOT NULL DEFAULT 0 COMMENT '조직도 시각화용 X 좌표'` + ) + await queryRunner.query( + `ALTER TABLE \`community\` MODIFY COLUMN \`y\` int NOT NULL DEFAULT 0 COMMENT '조직도 시각화용 Y 좌표'` + ) + + // ========================================== + // 3. Permission Table (권한 관리) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`permission\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '권한 레코드 ID'` + ) + // await queryRunner.query(`ALTER TABLE \`permission\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '권한이 부여된 사용자 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`permission\` MODIFY COLUMN \`permissionType\` int NOT NULL COMMENT '부여된 권한 종류 (0: superUser, 1: admin, 2: userList 등 Enum 참조)'` + ) + await queryRunner.query( + `ALTER TABLE \`permission\` MODIFY COLUMN \`have\` tinyint NOT NULL COMMENT '권한 보유 여부 (1: 있음, 0: 없음)'` + ) + + // ========================================== + // 4. WorshipSchedule Table (예배 일정) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '예배 일정 ID'` + ) + await queryRunner.query( + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`kind\` int NOT NULL COMMENT '예배 종류 (1: 주일예배, 2: 금요예배, 3: 기타)'` + ) + await queryRunner.query( + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`date\` varchar(255) NOT NULL COMMENT '예배 날짜 (YYYY-MM-DD 문자열 형식)'` + ) + await queryRunner.query( + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`canEdit\` tinyint NOT NULL DEFAULT 1 COMMENT '리더의 출석 입력 가능 여부 (1: 가능, 0: 마감)'` + ) + await queryRunner.query( + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`isVisible\` tinyint NOT NULL DEFAULT 1 COMMENT '앱 내 일정 노출 여부 (1: 보임, 0: 숨김)'` + ) + await queryRunner.query( + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '일정 생성 일시'` + ) + await queryRunner.query( + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`lastModifiedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '마지막 수정 일시'` + ) + + // ========================================== + // 5. AttendData Table (출석 기록) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`attend_data\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '출석 기록 고유 ID'` + ) + // await queryRunner.query(`ALTER TABLE \`attend_data\` MODIFY COLUMN \`worshipScheduleId\` int NULL COMMENT '대상 예배 일정 ID (FK)'`); + // await queryRunner.query(`ALTER TABLE \`attend_data\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '출석 대상 사용자 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`attend_data\` MODIFY COLUMN \`isAttend\` varchar(255) NOT NULL COMMENT '출석 상태 (ATTEND: 출석, ABSENT: 결석, ETC: 늦참/기타)'` + ) + await queryRunner.query( + `ALTER TABLE \`attend_data\` MODIFY COLUMN \`memo\` varchar(255) NOT NULL DEFAULT '' COMMENT '사유 또는 비고 사항'` + ) + + // ========================================== + // 6. RetreatAttend Table (수련회 참가 정보) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '수련회 참가 신청서 ID'` + ) + // await queryRunner.query(`ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '신청자 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`groupNumber\` int NOT NULL DEFAULT 0 COMMENT '배정된 조 번호 (0이면 미배정)'` + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`roomNumber\` int NULL COMMENT '배정된 숙소/방 번호'` + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`memo\` text NULL COMMENT '관리자용 메모'` + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isDeposited\` varchar(255) NOT NULL DEFAULT 'none' COMMENT '회비 입금 상태 (none: 미입금, student: 학생회비, business: 직장인회비, half: 부분참석회비)'` + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`howToGo\` int NULL COMMENT '가는 편 이동 수단 (1: 같이가기, 2: 자차(단독), 3: 자차(동승), 4: 얻어타기, 5: 따로가기 등)'` + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`howToBack\` int NULL COMMENT '오는 편 이동 수단 (옵션 위와 동일)'` + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isCanceled\` tinyint NOT NULL DEFAULT 0 COMMENT '참가 취소 여부 (1: 취소됨)'` + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`etc\` varchar(255) NULL COMMENT '신청서 기타란 내용'` + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`currentStatus\` int NOT NULL DEFAULT 0 COMMENT '참가 현황 상태 (0: null, 1: 교회도착, 2: 수련회장도착 등)'` + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`attendanceNumber\` int NOT NULL DEFAULT 0 COMMENT '접수 번호 (순서대로 발급)'` + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`postcardContent\` text NULL COMMENT '수련회 엽서 내용'` + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isWorker\` tinyint NOT NULL DEFAULT 1 COMMENT '직장인 여부 (1: 직장인, 0: 학생) - 회비 구분에 사용'` + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isHalf\` tinyint NOT NULL DEFAULT 0 COMMENT '부분 참석 여부 (1: 토요일 저녁 이후 참석/부분참석, 0: 전체참석)'` + ) + await queryRunner.query( + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '신청서 작성 일시'` + ) + + // ========================================== + // 7. InOutInfo Table (입퇴실 상세 이동 정보) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '이동 정보 ID'` + ) + // await queryRunner.query(`ALTER TABLE \`in_out_info\` MODIFY COLUMN \`retreatAttendId\` varchar(36) NULL COMMENT '수련회 참가 신청 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`day\` int NOT NULL COMMENT '수련회 1, 2, 3일차 구분 (1=금, 2=토, 3=일)'` + ) + await queryRunner.query( + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`time\` varchar(255) NOT NULL COMMENT '예상 이동 시각 (HH:MM 문자열)'` + ) + await queryRunner.query( + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`inOutType\` varchar(255) NOT NULL COMMENT '이동 방향 (IN: 수련회장으로 감, OUT: 수련회장에서 나옴, none: 없음)'` + ) + await queryRunner.query( + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`position\` varchar(255) NOT NULL COMMENT '출발지 또는 목적지 상세 (예: 교회, 집 등)'` + ) + await queryRunner.query( + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`howToMove\` int NOT NULL COMMENT '상세 이동 수단 (Enum 참조)'` + ) + await queryRunner.query( + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`autoCreated\` tinyint NOT NULL DEFAULT 0 COMMENT '시스템 자동 생성 여부'` + ) + // await queryRunner.query(`ALTER TABLE \`in_out_info\` MODIFY COLUMN \`rideCarInfoId\` int NULL COMMENT '카풀 시 탑승한 차량(운전자)의 이동 정보 ID (FK)'`); + + // ========================================== + // 8. SharingText Table (나눔 - 텍스트) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '은혜 나눔(글) ID'` + ) + // await queryRunner.query(`ALTER TABLE \`sharing_text\` MODIFY COLUMN \`writerId\` varchar(36) NULL COMMENT '작성자 User ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`content\` text NOT NULL COMMENT '나눔 본문 내용'` + ) + await queryRunner.query( + `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`visible\` tinyint NOT NULL DEFAULT 1 COMMENT '공개 여부 (1: 공개, 0: 비공개)'` + ) + await queryRunner.query( + `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '작성 일시'` + ) + + // ========================================== + // 9. SharingImage Table (나눔 - 이미지) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '은혜 나눔(사진) ID'` + ) + // await queryRunner.query(`ALTER TABLE \`sharing_image\` MODIFY COLUMN \`writerId\` varchar(36) NULL COMMENT '업로더 User ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`url\` text NOT NULL COMMENT 'S3 등 스토리지 이미지 경로'` + ) + await queryRunner.query( + `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`visible\` tinyint NOT NULL DEFAULT 1 COMMENT '공개 여부'` + ) + await queryRunner.query( + `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`tags\` text NULL COMMENT '이미지 태그 (콤마로 구분되거나 JSON)'` + ) + await queryRunner.query( + `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '업로드 일시'` + ) + + // ========================================== + // 10. WorshipContest Table (워십 콘테스트 투표) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '투표 기록 ID'` + ) + // await queryRunner.query(`ALTER TABLE \`worship_contest\` MODIFY COLUMN \`voteUserId\` varchar(36) NULL COMMENT '투표한 사용자 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`firstCommunity\` varchar(255) NOT NULL COMMENT '1순위 투표 팀 이름'` + ) + await queryRunner.query( + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`secondCommunity\` varchar(255) NOT NULL COMMENT '2순위 투표 팀 이름'` + ) + await queryRunner.query( + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`thirdCommunity\` varchar(255) NOT NULL COMMENT '3순위 투표 팀 이름'` + ) + await queryRunner.query( + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`term\` int NOT NULL COMMENT '투표 부문/회차 (예: 1부, 2부)'` + ) + await queryRunner.query( + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '투표 일시'` + ) + + // ========================================== + // 11. AIChatRoom Table (AI 챗봇 채팅방) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`ai_chat_room\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '채팅방 고유 ID (UUID)'` + ) + // await queryRunner.query(`ALTER TABLE \`ai_chat_room\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '채팅방 소유 사용자 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`ai_chat_room\` MODIFY COLUMN \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '채팅방 생성 일시'` + ) + + // ========================================== + // 12. AIChat Table (AI 챗봇 메시지) + // ========================================== + await queryRunner.query( + `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '메시지 고유 ID (UUID)'` + ) + // await queryRunner.query(`ALTER TABLE \`ai_chat\` MODIFY COLUMN \`roomId\` varchar(36) NULL COMMENT '소속 채팅방 ID (FK)'`); + await queryRunner.query( + `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`type\` enum('user', 'ai', 'system') NOT NULL COMMENT '발화자 구분 (user: 사용자, ai: 챗봇, system: 시스템 프롬프트)'` + ) + await queryRunner.query( + `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`message\` text NOT NULL COMMENT '대화 내용'` + ) + await queryRunner.query( + `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '메시지 생성 일시'` + ) + + // FK Check 활성화 + await queryRunner.query("SET FOREIGN_KEY_CHECKS = 1") + } + + public async down(queryRunner: QueryRunner): Promise { + // 생략 (롤백 시 코멘트 제거 로직이 필요하지만, 여기서는 생략함) + } +} diff --git a/server/src/model/ai.ts b/server/src/model/ai.ts new file mode 100644 index 0000000..3479a2b --- /dev/null +++ b/server/src/model/ai.ts @@ -0,0 +1,204 @@ +import { AIChat, ChatType } from "../entity/ai/aiChat" +import { AIChatRoom } from "../entity/ai/aiChatRoom" +import { User } from "../entity/user" +import dataSource, { aiChatRoomDatabase } from "./dataSource" + +/* + * AI와 주고받는 메시지 타입 정의 + */ +interface MessageType { + role: "user" | "assistant" | "system" + content: string +} + +interface AiChatResponse { + id: string + content: Array<{ + citations: any + text: string + type: string + }> + model: string + role: string + stop_reason: string | null + stop_sequence: string | null + type: string + usage: any +} + +const AiModel = { + async createNewRoom(user: User, title: string): Promise { + if (title.length > 100) { + title = title.slice(0, 100) + } + const room = await aiChatRoomDatabase.create({ + user: user, + title: title, + }) + await aiChatRoomDatabase.save(room) + return room + }, + + async getUserRooms(user: User) { + const rooms = await aiChatRoomDatabase.find({ + where: { + user: { + id: user.id, + }, + }, + }) + return rooms + }, + + async callSql(query: string) { + const sqlMatch = query.match(/```(?:sql)?\s*([\s\S]*?)\s*```/i) + const sql = sqlMatch ? sqlMatch[1] : query + + const result = await dataSource.query(sql) + return result + }, + + async requestChatAI(messages: AIChat[]) { + // Separate system prompt (assumed to be the first message if it's SYSTEM type) + let systemPrompt = await getSystemPrompt() + let chatMessages = [...messages] + + const body = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 1024, + system: systemPrompt, + messages: chatMessages.map((chat) => { + let role: "user" | "assistant" = "user" + switch (chat.type) { + case ChatType.USER: + role = "user" + break + case ChatType.AI: + role = "assistant" + break + case ChatType.SYSTEM: + // Query Result is treated as User input (context for the AI) + // SQL Query (generated by AI previously) is treated as Assistant output + if (chat.message.startsWith("Query Result:")) { + role = "user" + } else { + role = "assistant" + } + break + } + return { + role: role, + content: chat.message, + } + }), + } + + console.log("AI 요청 본문:", JSON.stringify(body, null, 2)) + const response = await fetch( + "https://factchat-cloud.mindlogic.ai/v1/api/anthropic/messages", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.AI_API_KEY}`, + }, + body: JSON.stringify(body), + } + ) + const data = (await response.json()) as AiChatResponse + console.log("AI 응답 전체:", data) + + if (!data.content || data.content.length === 0) { + console.error("AI returned empty content", data) + throw new Error("AI returned empty content") + } + + return { + type: ChatType.AI, + message: data.content[0].text, + } as AIChat + }, + + async getChatRoom(roomId: string, isSystemChatIncluded = false) { + const chatRoom = await aiChatRoomDatabase.findOne({ + select: { + id: true, + title: true, + createdAt: true, + user: { + id: true, + }, + chats: { + id: true, + type: true, + message: true, + createdAt: true, + }, + }, + where: { + id: roomId, + }, + relations: { + chats: true, + user: true, + }, + order: { + chats: { createdAt: "ASC" }, + }, + }) + if (!isSystemChatIncluded) { + chatRoom.chats = chatRoom.chats.filter( + (chat) => chat.type !== ChatType.SYSTEM + ) + } + return chatRoom + }, +} + +async function getSystemPrompt(): Promise { + const schema = await dataSource.query(` + SELECT + TABLE_NAME, + COLUMN_NAME, + DATA_TYPE, + COLUMN_COMMENT +FROM + INFORMATION_SCHEMA.COLUMNS +WHERE + TABLE_SCHEMA = '${process.env.DB_NAME}' +ORDER BY + TABLE_NAME, + ORDINAL_POSITION; + `) + + return ` + 당신은 교회 수련회 및 공동체 관리 시스템의 데이터베이스 전문가 AI 비서입니다. + 사용자의 질문을 분석하여 통계를 내거나 정보를 찾기 위해 SQL 쿼리를 작성하고, 이후 제공되는 쿼리 결과를 분석하여 사용자에게 답변을 제공합니다. + + [데이터베이스 스키마 정보] + ${JSON.stringify(schema, null, 2)} + + [작업 절차] + 1. 사용자의 질문이 데이터 조회가 필요한 경우, 표준 SQL(Mysql 호환) 쿼리를 작성해 주세요. + - 쿼리는 반드시 \`\`\`sql ... \`\`\` 코드 블록 안에 작성해야 합니다. + - 다른 설명 없이 오직 SQL 쿼리만 반환하는 것을 권장합니다. + 2. 만약 입력으로 "Query Result:" 와 함께 데이터가 주어진다면, 그 데이터를 분석하여 사용자의 원래 질문에 대해 친절하게 답변해 주세요. + 3. 조회 이외의 모든 쿼리 예(데이터 삽입, 수정, 삭제 등)은 절대 절대 금지 함으로 작성하지 마세요. + 4. 쿼리 결과가 너무 많이 나오지 않도록 항상 적절한 제한을 걸어 주세요. + 5. 오늘 날짜는 ${new Date().getFullYear()}년 ${ + new Date().getMonth() + 1 + }월 ${new Date().getDate()}일 입니다. + + [예시] + User: "저번주 주일예배 출석률 알려줘" + Assistant: + \`\`\`sql + SELECT count(*) as count, isAttend FROM AttendData + LEFT JOIN WorshipSchedule ON AttendData.worshipScheduleId = WorshipSchedule.id + WHERE WorshipSchedule.kind = 1 AND WorshipSchedule.date = '...' + GROUP BY isAttend + \`\`\` + ` +} + +export default AiModel diff --git a/server/src/model/dataSource.ts b/server/src/model/dataSource.ts index 1999d5c..59c67b3 100644 --- a/server/src/model/dataSource.ts +++ b/server/src/model/dataSource.ts @@ -12,6 +12,8 @@ import { import { WorshipSchedule } from "../entity/worshipSchedule" import { AttendData } from "../entity/attendData" import { WorshipContest } from "../entity/event/worshipContest" +import { AIChat } from "../entity/ai/aiChat" +import { AIChatRoom } from "../entity/ai/aiChatRoom" const dataSource = new DataSource(require("../../ormconfig.js")) @@ -28,6 +30,9 @@ export const sharingTextDatabase = dataSource.getRepository(SharingText) export const sharingImageDatabase = dataSource.getRepository(SharingImage) export const sharingVideoDatabase = dataSource.getRepository(SharingVideo) +export const aiChatDatabase = dataSource.getRepository(AIChat) +export const aiChatRoomDatabase = dataSource.getRepository(AIChatRoom) + export const worshipContestDatabase = dataSource.getRepository(WorshipContest) export default dataSource diff --git a/server/src/routes/admin/ai.ts b/server/src/routes/admin/ai.ts index 2b60d13..23c5e73 100644 --- a/server/src/routes/admin/ai.ts +++ b/server/src/routes/admin/ai.ts @@ -1,36 +1,99 @@ import { Router } from "express" +import { getUserFromToken, hasPermissionFromReq } from "../../util/util" +import AiModel from "../../model/ai" +import { AIChat, ChatType } from "../../entity/ai/aiChat" +import { aiChatRoomDatabase } from "../../model/dataSource" +import { PermissionType } from "../../entity/types" const router = Router() router.post("/ask", async (req, res) => { - // AI 질문 처리 로직 구현 - - const body = { - model: "claude-sonnet-4-5-20250929", - max_tokens: 1024, - messages: [ - { - role: "user", - content: "Say this is a test!", - }, - ], + const user = await getUserFromToken(req) + if (!user) { + res.status(401).json({ message: "Unauthorized" }) + return } - const response = await fetch( - "https://factchat-cloud.mindlogic.ai/v1/api/anthropic/messages", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${process.env.AI_API_KEY}`, - }, - body: JSON.stringify(body), + const isAdmin = await hasPermissionFromReq(req, PermissionType.admin) + if (!isAdmin) { + res.status(403).json({ message: "Forbidden" }) + return + } + + const userRequestMessage = req.body.message + let roomId: string = req.body.roomId + if (!roomId) { + const newRoom = await AiModel.createNewRoom(user, userRequestMessage) + roomId = newRoom.id + } + + const chatRoom = await AiModel.getChatRoom(roomId, true) + + const requestChat = new AIChat() + requestChat.room = chatRoom + requestChat.type = ChatType.USER + requestChat.message = userRequestMessage + requestChat.createdAt = new Date() + chatRoom.chats.push(requestChat) + + let responseChat: AIChat + do { + responseChat = await AiModel.requestChatAI(chatRoom.chats) + responseChat.room = chatRoom + responseChat.createdAt = new Date() + if (responseChat.message.includes("```sql")) { + console.log("query:", responseChat.message) + responseChat.type = ChatType.SYSTEM // 쿼리는 시스템으로 저장 + chatRoom.chats.push(responseChat) + const sqlResult = await AiModel.callSql(responseChat.message) + const queryResult = JSON.stringify(sqlResult, null, 2).slice(0, 3000) + const queryChat = new AIChat() + queryChat.room = chatRoom + queryChat.type = ChatType.SYSTEM + queryChat.message = `Query Result:\n${queryResult}` + queryChat.createdAt = new Date() + chatRoom.chats.push(queryChat) + continue } - ) - const data = (await response.json()) as any + responseChat.type = ChatType.AI + chatRoom.chats.push(responseChat) + } while (responseChat.message.includes("```sql")) + + await aiChatRoomDatabase.save(chatRoom) + + const savedChatRoom = await AiModel.getChatRoom(roomId, false) + savedChatRoom.chats.forEach((chat) => { + delete chat.room + }) + res.json(savedChatRoom) +}) + +router.get("/my-rooms", async (req, res) => { + const user = await getUserFromToken(req) + if (!user) { + res.status(401).json({ message: "Unauthorized" }) + return + } + + const rooms = await AiModel.getUserRooms(user) + res.json(rooms) +}) + +router.get("/room/:roomId", async (req, res) => { + const user = await getUserFromToken(req) + if (!user) { + res.status(401).json({ message: "Unauthorized" }) + return + } + + const roomId: string = req.params.roomId as string + const chatRoom = await AiModel.getChatRoom(roomId) + if (chatRoom.user.id !== user.id) { + res.status(403).json({ message: "Forbidden" }) + return + } - console.log("AI response data:", data) - res.json(data.content) + res.json(chatRoom) }) export default router From 32d1adea24babc5913454648a09c470ca63d8bb0 Mon Sep 17 00:00:00 2001 From: iubns Date: Sun, 18 Jan 2026 02:50:47 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20AI=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EA=B0=9C=EC=84=A0,=20=ED=94=84?= =?UTF-8?q?=EB=A1=AC=ED=94=84=ED=8A=B8=20=EA=B0=9C=EC=88=98=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/package.json | 1 + client/pnpm-lock.yaml | 20 +++++++++++++++----- client/src/app/admin/ai/chat/Chat.tsx | 18 ++++++++++++------ server/src/model/ai.ts | 6 ++++-- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/client/package.json b/client/package.json index c4690f7..e007a2b 100644 --- a/client/package.json +++ b/client/package.json @@ -23,6 +23,7 @@ "jsbarcode": "^3.11.6", "jwt-decode": "^4.0.0", "lodash": "^4.17.21", + "marked": "^17.0.1", "mui": "^1.0.0", "next": "15.3.3", "quagga": "0.12.1", diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 40ee95a..daa1510 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + marked: + specifier: ^17.0.1 + version: 17.0.1 mui: specifier: ^1.0.0 version: 1.0.0 @@ -2756,6 +2759,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -5945,7 +5953,7 @@ snapshots: '@typescript-eslint/parser': 5.62.0(eslint@9.28.0)(typescript@5.8.3) eslint: 9.28.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0))(eslint@9.28.0) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.28.0) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.28.0) eslint-plugin-react: 7.37.5(eslint@9.28.0) @@ -5965,7 +5973,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0))(eslint@9.28.0): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.28.0): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0 @@ -5980,14 +5988,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0))(eslint@9.28.0))(eslint@9.28.0): + eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 5.62.0(eslint@9.28.0)(typescript@5.8.3) eslint: 9.28.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0))(eslint@9.28.0) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.28.0) transitivePeerDependencies: - supports-color @@ -6002,7 +6010,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.28.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0))(eslint@9.28.0))(eslint@9.28.0) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.28.0) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -6712,6 +6720,8 @@ snapshots: dependencies: yallist: 3.1.1 + marked@17.0.1: {} + math-intrinsics@1.1.0: {} mdn-data@2.0.28: {} diff --git a/client/src/app/admin/ai/chat/Chat.tsx b/client/src/app/admin/ai/chat/Chat.tsx index 784f1f4..8988d96 100644 --- a/client/src/app/admin/ai/chat/Chat.tsx +++ b/client/src/app/admin/ai/chat/Chat.tsx @@ -1,10 +1,11 @@ "use client" -import useAuth from "@/hooks/useAuth" -import { Button, Stack, TextField } from "@mui/material" +import { useState } from "react" import useAiChat from "./useAiChat" +import { marked } from "marked" +import useAuth from "@/hooks/useAuth" import { AIChat } from "@server/entity/ai/aiChat" -import { useState } from "react" +import { Button, Stack, TextField } from "@mui/material" export enum ChatType { USER = "user", @@ -18,9 +19,14 @@ export default function AdminAIChatComponent() { const { selectedChatRoom, sendMessageToAi } = useAiChat() return ( - + {authUserData?.name}님 - + {selectedChatRoom && selectedChatRoom.chats && selectedChatRoom.chats.map((chat) => ( @@ -55,7 +61,7 @@ function ChatComponent({ chat }: { chat: AIChat }) { bgcolor={chat.type === ChatType.USER ? "#daf1da" : "#f1f1f1"} textAlign={chat.type === ChatType.USER ? "right" : "left"} > - {chat.message} +
) diff --git a/server/src/model/ai.ts b/server/src/model/ai.ts index 3479a2b..2d76840 100644 --- a/server/src/model/ai.ts +++ b/server/src/model/ai.ts @@ -61,6 +61,10 @@ const AiModel = { async requestChatAI(messages: AIChat[]) { // Separate system prompt (assumed to be the first message if it's SYSTEM type) let systemPrompt = await getSystemPrompt() + const SIZE_LIMIT = 8 + if (messages.length > SIZE_LIMIT) { + messages = messages.slice(messages.length - SIZE_LIMIT, messages.length) + } let chatMessages = [...messages] const body = { @@ -93,7 +97,6 @@ const AiModel = { }), } - console.log("AI 요청 본문:", JSON.stringify(body, null, 2)) const response = await fetch( "https://factchat-cloud.mindlogic.ai/v1/api/anthropic/messages", { @@ -106,7 +109,6 @@ const AiModel = { } ) const data = (await response.json()) as AiChatResponse - console.log("AI 응답 전체:", data) if (!data.content || data.content.length === 0) { console.error("AI returned empty content", data) From 847c7cd7f6a30df87387c2568463a1f0817e19b4 Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 19 Jan 2026 02:55:47 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20AI=20Chat=201=EC=B0=A8=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/app/admin/ai/LeftList.tsx | 94 +++++++++++++++++++++---- client/src/app/admin/ai/chat/Chat.tsx | 98 ++++++++++++++++----------- client/src/app/admin/ai/chat/page.tsx | 66 ++++++++++++++++-- client/src/app/admin/layout.tsx | 9 ++- client/src/app/admin/soon/page.tsx | 2 +- 5 files changed, 209 insertions(+), 60 deletions(-) diff --git a/client/src/app/admin/ai/LeftList.tsx b/client/src/app/admin/ai/LeftList.tsx index f687f02..b43bade 100644 --- a/client/src/app/admin/ai/LeftList.tsx +++ b/client/src/app/admin/ai/LeftList.tsx @@ -1,11 +1,14 @@ "use client" -import { Stack } from "@mui/material" +import useAuth from "@/hooks/useAuth" +import { Stack, Typography, Box } from "@mui/material" import useAiChat from "./chat/useAiChat" import { useEffect, useState } from "react" import { AIChatRoom } from "@server/entity/ai/aiChatRoom" +import dayjs from "dayjs" export default function AdminAIChatLeftComponent() { + const { authUserData } = useAuth() const { getChatRooms, selectedChatRoomId, setSelectedChatRoomId } = useAiChat() const [chatRooms, setChatRooms] = useState([]) @@ -17,18 +20,85 @@ export default function AdminAIChatLeftComponent() { }, []) return ( - - {chatRooms.map((room) => ( - setSelectedChatRoomId(room.id)} - bgcolor={selectedChatRoomId === room.id ? "#eee" : "transparent"} + + + + 채팅 목록 + + + {authUserData?.name}님 + + + + + {/* 새 채팅 만들기 버튼 예시 (기능은 연결 안 함) */} + {/* */} + + {chatRooms.map((room) => ( + setSelectedChatRoomId(room.id)} + sx={{ + p: 2, + cursor: "pointer", + borderRadius: 2, + bgcolor: selectedChatRoomId === room.id ? "white" : "transparent", + boxShadow: + selectedChatRoomId === room.id + ? "0 2px 4px rgba(0,0,0,0.05)" + : "none", + transition: "all 0.2s", + "&:hover": { + bgcolor: + selectedChatRoomId === room.id ? "white" : "rgba(0,0,0,0.04)", + }, + border: + selectedChatRoomId === room.id + ? "1px solid #e0e0e0" + : "1px solid transparent", + }} + > + + {room.title || "새로운 채팅"} + + + {dayjs(room.createdAt).format("YY-MM-DD HH:mm")} + + + ))} + ) } diff --git a/client/src/app/admin/ai/chat/Chat.tsx b/client/src/app/admin/ai/chat/Chat.tsx index 8988d96..4eaebcb 100644 --- a/client/src/app/admin/ai/chat/Chat.tsx +++ b/client/src/app/admin/ai/chat/Chat.tsx @@ -1,11 +1,11 @@ "use client" -import { useState } from "react" import useAiChat from "./useAiChat" import { marked } from "marked" -import useAuth from "@/hooks/useAuth" import { AIChat } from "@server/entity/ai/aiChat" -import { Button, Stack, TextField } from "@mui/material" +import { Box, Stack, Typography, Avatar } from "@mui/material" +import SmartToyIcon from "@mui/icons-material/SmartToy" +import PersonIcon from "@mui/icons-material/Person" export enum ChatType { USER = "user", @@ -14,55 +14,73 @@ export enum ChatType { } export default function AdminAIChatComponent() { - const { authUserData } = useAuth() - const [message, setMessage] = useState("") - const { selectedChatRoom, sendMessageToAi } = useAiChat() + const { selectedChatRoom } = useAiChat() return ( - - {authUserData?.name}님 - - {selectedChatRoom && - selectedChatRoom.chats && - selectedChatRoom.chats.map((chat) => ( - - ))} - - - setMessage(e.target.value)} - /> - - + + {selectedChatRoom && + selectedChatRoom.chats && + selectedChatRoom.chats.map((chat) => ( + + ))} ) } function ChatComponent({ chat }: { chat: AIChat }) { + const isUser = chat.type === ChatType.USER + return ( - + {isUser ? ( + + ) : ( + + )} + + + -
- +
+ ) } diff --git a/client/src/app/admin/ai/chat/page.tsx b/client/src/app/admin/ai/chat/page.tsx index 6796685..f2dd2ec 100644 --- a/client/src/app/admin/ai/chat/page.tsx +++ b/client/src/app/admin/ai/chat/page.tsx @@ -1,15 +1,73 @@ "use client" -import { Stack } from "@mui/material" +import { Button, Stack, TextField, IconButton, Paper } from "@mui/material" import AdminAIChatComponent from "./Chat" import AdminAIChatLeftComponent from "../LeftList" +import useAuth from "@/hooks/useAuth" +import { useState } from "react" +import useAiChat from "./useAiChat" +import SendIcon from "@mui/icons-material/Send" export default function AdminAIChatPage() { + const [message, setMessage] = useState("") + const { sendMessageToAi } = useAiChat() + return ( - - + + - + + + + + + setMessage(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + sendMessageToAi(message) + } + }} + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: 3, + bgcolor: "#f8f9fa", + }, + }} + /> + + + ) diff --git a/client/src/app/admin/layout.tsx b/client/src/app/admin/layout.tsx index bca6615..849ff94 100644 --- a/client/src/app/admin/layout.tsx +++ b/client/src/app/admin/layout.tsx @@ -1,6 +1,7 @@ "use client" import Header from "@/app/admin/components/Header" +import { Stack } from "@mui/material" export default function AdminLayout({ children, @@ -8,9 +9,11 @@ export default function AdminLayout({ children: React.ReactNode }) { return ( - <> +
- {children} - + + {children} + + ) } diff --git a/client/src/app/admin/soon/page.tsx b/client/src/app/admin/soon/page.tsx index d247696..6a126eb 100644 --- a/client/src/app/admin/soon/page.tsx +++ b/client/src/app/admin/soon/page.tsx @@ -150,7 +150,7 @@ export default function Soon() { const filteredUsers = orderingUserList() return ( - + Date: Mon, 19 Jan 2026 03:25:41 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20AI=20Chat=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/app/admin/ai/LeftList.tsx | 29 +++--- client/src/app/admin/ai/chat/Chat.tsx | 64 +++++++++++-- client/src/app/admin/ai/chat/page.tsx | 108 ++++++++++++++-------- client/src/app/admin/ai/chat/useAiChat.ts | 60 ++++++++++-- server/src/model/ai.ts | 16 ++-- 5 files changed, 208 insertions(+), 69 deletions(-) diff --git a/client/src/app/admin/ai/LeftList.tsx b/client/src/app/admin/ai/LeftList.tsx index b43bade..fb5c87e 100644 --- a/client/src/app/admin/ai/LeftList.tsx +++ b/client/src/app/admin/ai/LeftList.tsx @@ -1,22 +1,25 @@ "use client" import useAuth from "@/hooks/useAuth" -import { Stack, Typography, Box } from "@mui/material" +import { Stack, Typography, Box, Button } from "@mui/material" import useAiChat from "./chat/useAiChat" import { useEffect, useState } from "react" import { AIChatRoom } from "@server/entity/ai/aiChatRoom" +import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline" import dayjs from "dayjs" export default function AdminAIChatLeftComponent() { const { authUserData } = useAuth() - const { getChatRooms, selectedChatRoomId, setSelectedChatRoomId } = - useAiChat() - const [chatRooms, setChatRooms] = useState([]) + const { + getChatRooms, + selectedChatRoomId, + setSelectedChatRoomId, + setSelectedChatRoom, + chatRooms, + } = useAiChat() useEffect(() => { - getChatRooms().then((res) => { - setChatRooms(res.data) - }) + getChatRooms() }, []) return ( @@ -44,15 +47,19 @@ export default function AdminAIChatLeftComponent() { - {/* 새 채팅 만들기 버튼 예시 (기능은 연결 안 함) */} - {/* */} + {chatRooms.map((room) => ( (null) + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollIntoView({ behavior: "smooth" }) + } + }, [selectedChatRoom?.chats, isAiReplying]) return ( - + {selectedChatRoom && selectedChatRoom.chats && selectedChatRoom.chats.map((chat) => ( ))} + {isAiReplying && } +
+ + ) +} + +function AiLoadingComponent() { + return ( + + + + + + + ) } @@ -32,9 +85,9 @@ function ChatComponent({ chat }: { chat: AIChat }) { return ( { + if (!message.trim()) return + const msg = message + setMessage("") // 즉시 초기화 + await sendMessageToAi(msg) + } return ( - + @@ -25,47 +40,64 @@ export default function AdminAIChatPage() { sx={{ p: 2, display: "flex", - flexDirection: "row", + flexDirection: "column", gap: 1, bgcolor: "white", - alignItems: "center", borderTop: "1px solid #e0e0e0", }} > - setMessage(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - sendMessageToAi(message) - } - }} - sx={{ - "& .MuiOutlinedInput-root": { - borderRadius: 3, - bgcolor: "#f8f9fa", - }, - }} - /> - + + + 사용가능한 토큰이 무제한이 아닙니다. + + + + setMessage(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + }} + disabled={isAiReplying} + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: 3, + bgcolor: "#f8f9fa", + }, + }} + /> + + diff --git a/client/src/app/admin/ai/chat/useAiChat.ts b/client/src/app/admin/ai/chat/useAiChat.ts index e70043f..bd15f4e 100644 --- a/client/src/app/admin/ai/chat/useAiChat.ts +++ b/client/src/app/admin/ai/chat/useAiChat.ts @@ -7,14 +7,18 @@ import { useEffect } from "react" const SelectedAiCharRoomIdAtom = atom("") const SelectedAiCharRoomAtom = atom(null) +const AiChatRoomsAtom = atom([]) +const IsAiReplyingAtom = atom(false) export default function useAiChat() { const [selectedChatRoom, setSelectedChatRoom] = useAtom( - SelectedAiCharRoomAtom + SelectedAiCharRoomAtom, ) const [selectedChatRoomId, setSelectedChatRoomId] = useAtom( - SelectedAiCharRoomIdAtom + SelectedAiCharRoomIdAtom, ) + const [chatRooms, setChatRooms] = useAtom(AiChatRoomsAtom) + const [isAiReplying, setIsAiReplying] = useAtom(IsAiReplyingAtom) useEffect(() => { if (selectedChatRoomId) { @@ -23,15 +27,55 @@ export default function useAiChat() { }, [selectedChatRoomId]) async function getChatRooms() { - return await axios.get("/admin/ai/my-rooms") + const response = await axios.get("/admin/ai/my-rooms") + setChatRooms(response.data) + return response } async function sendMessageToAi(message: string) { - const response = await axios.post("/admin/ai/ask", { - message: message, - roomId: selectedChatRoomId, + // Optimistic Update: 사용자 메시지를 미리 보여줌 + const tempUserChat: any = { + id: `temp-${Date.now()}`, + message, + type: "user", + createdAt: new Date(), + } + + setSelectedChatRoom((prev) => { + if (!prev) { + // 새 채팅방인 경우 임시 방 생성 + return { + id: "temp-room", + title: "New Chat", + chats: [tempUserChat], + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as AIChatRoom + } + return { + ...prev, + chats: [...(prev.chats || []), tempUserChat], + } }) - setSelectedChatRoom(response.data) + + setIsAiReplying(true) + try { + const response = await axios.post("/admin/ai/ask", { + message: message, + roomId: selectedChatRoomId, + }) + setSelectedChatRoom(response.data) + + if (!selectedChatRoomId || selectedChatRoomId !== response.data.id) { + setSelectedChatRoomId(response.data.id) + getChatRooms() + } else { + // 이미 룸이 있어도 채팅 업데이트를 위해 리스트 갱신 (마지막 메세지, 시간 등) + getChatRooms() + } + } finally { + setIsAiReplying(false) + } } async function getRoomData(roomId: string) { @@ -46,5 +90,7 @@ export default function useAiChat() { setSelectedChatRoom, selectedChatRoomId, setSelectedChatRoomId, + chatRooms, + isAiReplying, } } diff --git a/server/src/model/ai.ts b/server/src/model/ai.ts index 2d76840..5fcc9b6 100644 --- a/server/src/model/ai.ts +++ b/server/src/model/ai.ts @@ -106,9 +106,10 @@ const AiModel = { Authorization: `Bearer ${process.env.AI_API_KEY}`, }, body: JSON.stringify(body), - } + }, ) const data = (await response.json()) as AiChatResponse + console.log("AI Response:", data) if (!data.content || data.content.length === 0) { console.error("AI returned empty content", data) @@ -150,7 +151,7 @@ const AiModel = { }) if (!isSystemChatIncluded) { chatRoom.chats = chatRoom.chats.filter( - (chat) => chat.type !== ChatType.SYSTEM + (chat) => chat.type !== ChatType.SYSTEM, ) } return chatRoom @@ -174,7 +175,7 @@ ORDER BY `) return ` - 당신은 교회 수련회 및 공동체 관리 시스템의 데이터베이스 전문가 AI 비서입니다. + 당신은 수원제일교회 청년부 관리 시스템의 데이터베이스 전문가 AI 비서입니다. 사용자의 질문을 분석하여 통계를 내거나 정보를 찾기 위해 SQL 쿼리를 작성하고, 이후 제공되는 쿼리 결과를 분석하여 사용자에게 답변을 제공합니다. [데이터베이스 스키마 정보] @@ -183,13 +184,14 @@ ORDER BY [작업 절차] 1. 사용자의 질문이 데이터 조회가 필요한 경우, 표준 SQL(Mysql 호환) 쿼리를 작성해 주세요. - 쿼리는 반드시 \`\`\`sql ... \`\`\` 코드 블록 안에 작성해야 합니다. - - 다른 설명 없이 오직 SQL 쿼리만 반환하는 것을 권장합니다. + - 다른 설명 없이 오직 SQL 쿼리만 반환하는 것을 강제합니다. 2. 만약 입력으로 "Query Result:" 와 함께 데이터가 주어진다면, 그 데이터를 분석하여 사용자의 원래 질문에 대해 친절하게 답변해 주세요. 3. 조회 이외의 모든 쿼리 예(데이터 삽입, 수정, 삭제 등)은 절대 절대 금지 함으로 작성하지 마세요. 4. 쿼리 결과가 너무 많이 나오지 않도록 항상 적절한 제한을 걸어 주세요. - 5. 오늘 날짜는 ${new Date().getFullYear()}년 ${ - new Date().getMonth() + 1 - }월 ${new Date().getDate()}일 입니다. + 5. 시스템에 악영향을 미칠 수 있거나 부적절한 요청에 대해서는 정중하게 거절하는 답변을 해 주세요. + 6. 오늘 날짜는 ${new Date().getFullYear()}년 ${ + new Date().getMonth() + 1 + }월 ${new Date().getDate()}일 입니다. [예시] User: "저번주 주일예배 출석률 알려줘" From f066c3454c46e56765bbcef828b2b0f855fffaa1 Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 19 Jan 2026 03:28:36 +0900 Subject: [PATCH 06/10] cleanup: remove console.log --- server/src/model/ai.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/model/ai.ts b/server/src/model/ai.ts index 5fcc9b6..0fab4ed 100644 --- a/server/src/model/ai.ts +++ b/server/src/model/ai.ts @@ -109,7 +109,6 @@ const AiModel = { }, ) const data = (await response.json()) as AiChatResponse - console.log("AI Response:", data) if (!data.content || data.content.length === 0) { console.error("AI returned empty content", data) From 32ed8ecc753943eb6bee6184404336614cf33940 Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 19 Jan 2026 03:34:49 +0900 Subject: [PATCH 07/10] =?UTF-8?q?update:=20Ai=20Chat=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=20=EB=AC=B8=EA=B5=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/app/admin/ai/chat/page.tsx | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/client/src/app/admin/ai/chat/page.tsx b/client/src/app/admin/ai/chat/page.tsx index 43eff7b..f9360db 100644 --- a/client/src/app/admin/ai/chat/page.tsx +++ b/client/src/app/admin/ai/chat/page.tsx @@ -7,6 +7,7 @@ import { IconButton, Paper, Typography, + Alert, } from "@mui/material" import AdminAIChatComponent from "./Chat" import AdminAIChatLeftComponent from "../LeftList" @@ -46,19 +47,17 @@ export default function AdminAIChatPage() { borderTop: "1px solid #e0e0e0", }} > - - - - 사용가능한 토큰이 무제한이 아닙니다. + + + • 현재 시범 운영 중인 기능입니다. - + + • 제공되는 토큰이 한정되어 있어 필요한 만큼만 질문해 주세요. + + + • AI의 답변은 참고용으로만 활용 부탁드립니다. + + Date: Mon, 19 Jan 2026 03:35:42 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20=EC=95=88=EB=82=B4=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=ED=81=AC=EA=B8=B0=20=EB=A7=88=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/app/admin/ai/chat/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/app/admin/ai/chat/page.tsx b/client/src/app/admin/ai/chat/page.tsx index f9360db..6f4aa10 100644 --- a/client/src/app/admin/ai/chat/page.tsx +++ b/client/src/app/admin/ai/chat/page.tsx @@ -47,7 +47,7 @@ export default function AdminAIChatPage() { borderTop: "1px solid #e0e0e0", }} > - + • 현재 시범 운영 중인 기능입니다. From a142ce1df593f15894fa41533528056fe00c210e Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 19 Jan 2026 03:40:38 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EC=88=98=EC=A0=95=20(=EB=8B=A4=EB=9D=BD?= =?UTF-8?q?=EB=B0=A9=20=EC=BB=AC=EB=9F=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../1768657178669-AddCommentInAllColumn.ts | 150 +++++++++--------- 1 file changed, 75 insertions(+), 75 deletions(-) diff --git a/server/src/migration/1768657178669-AddCommentInAllColumn.ts b/server/src/migration/1768657178669-AddCommentInAllColumn.ts index 978ceff..54b5578 100644 --- a/server/src/migration/1768657178669-AddCommentInAllColumn.ts +++ b/server/src/migration/1768657178669-AddCommentInAllColumn.ts @@ -9,43 +9,43 @@ export class AddCommentInAllColumn1768657178669 implements MigrationInterface { // 1. User Table (사용자) // ========================================== await queryRunner.query( - `ALTER TABLE \`user\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '사용자 고유 ID (UUID)'` + `ALTER TABLE \`user\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '사용자 고유 ID (UUID)'`, ) await queryRunner.query( - `ALTER TABLE \`user\` MODIFY COLUMN \`kakaoId\` varchar(255) NULL COMMENT '카카오 계정 고유 ID (로그인 식별용)'` + `ALTER TABLE \`user\` MODIFY COLUMN \`kakaoId\` varchar(255) NULL COMMENT '카카오 계정 고유 ID (로그인 식별용)'`, ) await queryRunner.query( - `ALTER TABLE \`user\` MODIFY COLUMN \`name\` varchar(255) NULL COMMENT '사용자 실명'` + `ALTER TABLE \`user\` MODIFY COLUMN \`name\` varchar(255) NULL COMMENT '사용자 실명'`, ) await queryRunner.query( - `ALTER TABLE \`user\` MODIFY COLUMN \`yearOfBirth\` int NULL COMMENT '출생년도 (YYYY 형식)'` + `ALTER TABLE \`user\` MODIFY COLUMN \`yearOfBirth\` int NULL COMMENT '출생년도 (YYYY 형식)'`, ) await queryRunner.query( - `ALTER TABLE \`user\` MODIFY COLUMN \`gender\` varchar(255) NULL COMMENT '성별 (man: 남성, woman: 여성)'` + `ALTER TABLE \`user\` MODIFY COLUMN \`gender\` varchar(255) NULL COMMENT '성별 (man: 남성, woman: 여성)'`, ) await queryRunner.query( - `ALTER TABLE \`user\` MODIFY COLUMN \`phone\` varchar(255) NULL COMMENT '전화번호 (하이픈 없이 숫자만 or 하이픈 포함)'` + `ALTER TABLE \`user\` MODIFY COLUMN \`phone\` varchar(255) NULL COMMENT '전화번호 (하이픈 없이 숫자만 or 하이픈 포함)'`, ) await queryRunner.query( - `ALTER TABLE \`user\` MODIFY COLUMN \`etc\` varchar(255) NULL COMMENT '사용자 관련 비고/특이사항'` + `ALTER TABLE \`user\` MODIFY COLUMN \`etc\` varchar(255) NULL COMMENT '사용자 관련 비고/특이사항'`, ) await queryRunner.query( - `ALTER TABLE \`user\` MODIFY COLUMN \`token\` varchar(255) NULL COMMENT '자체 인증 토큰'` + `ALTER TABLE \`user\` MODIFY COLUMN \`token\` varchar(255) NULL COMMENT '자체 인증 토큰'`, ) await queryRunner.query( - `ALTER TABLE \`user\` MODIFY COLUMN \`expire\` datetime NULL COMMENT '인증 토큰 만료 시간'` + `ALTER TABLE \`user\` MODIFY COLUMN \`expire\` datetime NULL COMMENT '인증 토큰 만료 시간'`, ) await queryRunner.query( - `ALTER TABLE \`user\` MODIFY COLUMN \`isSuperUser\` tinyint NOT NULL DEFAULT 0 COMMENT '시스템 최고 관리자 권한 여부 (1: 관리자, 0: 일반)'` + `ALTER TABLE \`user\` MODIFY COLUMN \`isSuperUser\` tinyint NOT NULL DEFAULT 0 COMMENT '시스템 최고 관리자 권한 여부 (1: 관리자, 0: 일반)'`, ) await queryRunner.query( - `ALTER TABLE \`user\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '계정 생성 일시'` + `ALTER TABLE \`user\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '계정 생성 일시'`, ) await queryRunner.query( - `ALTER TABLE \`user\` MODIFY COLUMN \`deletedAt\` timestamp(6) NULL COMMENT '계정 삭제 일시 (Soft Delete, NULL이면 활성 계정)'` + `ALTER TABLE \`user\` MODIFY COLUMN \`deletedAt\` timestamp(6) NULL COMMENT '계정 삭제 일시 (Soft Delete, NULL이면 활성 계정)'`, ) await queryRunner.query( - `ALTER TABLE \`user\` MODIFY COLUMN \`profile\` int NOT NULL DEFAULT 0 COMMENT '프로필 이미지 식별자 (ID)'` + `ALTER TABLE \`user\` MODIFY COLUMN \`profile\` int NOT NULL DEFAULT 0 COMMENT '프로필 이미지 식별자 (ID)'`, ) // await queryRunner.query(`ALTER TABLE \`user\` MODIFY COLUMN \`communityId\` int NULL COMMENT '현재 소속된 공동체/순 ID (FK)'`); @@ -53,155 +53,155 @@ export class AddCommentInAllColumn1768657178669 implements MigrationInterface { // 2. Community Table (공동체/조직) // ========================================== await queryRunner.query( - `ALTER TABLE \`community\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '공동체 고유 ID'` + `ALTER TABLE \`community\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '공동체 고유 ID'`, ) // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`parentId\` int NULL COMMENT '상위 조직 ID (FK, NULL이면 최상위 조직)'`); await queryRunner.query( - `ALTER TABLE \`community\` MODIFY COLUMN \`name\` varchar(255) NOT NULL COMMENT '공동체 이름 (예: 무슨무슨 마을, 수련회 조)'` + `ALTER TABLE \`community\` MODIFY COLUMN \`name\` varchar(255) NOT NULL COMMENT '공동체 이름 (예: 무슨무슨 마을, 무슨무슨 다락방, 다락방이 최하위 조직으로 상위는 마을임)'`, ) await queryRunner.query( - `ALTER TABLE \`community\` MODIFY COLUMN \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '생성 일시'` + `ALTER TABLE \`community\` MODIFY COLUMN \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '생성 일시'`, ) await queryRunner.query( - `ALTER TABLE \`community\` MODIFY COLUMN \`lastModifiedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '마지막 정보 수정 일시'` + `ALTER TABLE \`community\` MODIFY COLUMN \`lastModifiedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '마지막 정보 수정 일시'`, ) // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`leaderId\` varchar(36) NULL COMMENT '리더(순장/조장) 사용자 ID (FK)'`); // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`deputyLeaderId\` varchar(36) NULL COMMENT '부리더(부순장/부조장) 사용자 ID (FK)'`); await queryRunner.query( - `ALTER TABLE \`community\` MODIFY COLUMN \`x\` int NOT NULL DEFAULT 0 COMMENT '조직도 시각화용 X 좌표'` + `ALTER TABLE \`community\` MODIFY COLUMN \`x\` int NOT NULL DEFAULT 0 COMMENT '조직도 시각화용 X 좌표'`, ) await queryRunner.query( - `ALTER TABLE \`community\` MODIFY COLUMN \`y\` int NOT NULL DEFAULT 0 COMMENT '조직도 시각화용 Y 좌표'` + `ALTER TABLE \`community\` MODIFY COLUMN \`y\` int NOT NULL DEFAULT 0 COMMENT '조직도 시각화용 Y 좌표'`, ) // ========================================== // 3. Permission Table (권한 관리) // ========================================== await queryRunner.query( - `ALTER TABLE \`permission\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '권한 레코드 ID'` + `ALTER TABLE \`permission\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '권한 레코드 ID'`, ) // await queryRunner.query(`ALTER TABLE \`permission\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '권한이 부여된 사용자 ID (FK)'`); await queryRunner.query( - `ALTER TABLE \`permission\` MODIFY COLUMN \`permissionType\` int NOT NULL COMMENT '부여된 권한 종류 (0: superUser, 1: admin, 2: userList 등 Enum 참조)'` + `ALTER TABLE \`permission\` MODIFY COLUMN \`permissionType\` int NOT NULL COMMENT '부여된 권한 종류 (0: superUser, 1: admin, 2: userList 등 Enum 참조)'`, ) await queryRunner.query( - `ALTER TABLE \`permission\` MODIFY COLUMN \`have\` tinyint NOT NULL COMMENT '권한 보유 여부 (1: 있음, 0: 없음)'` + `ALTER TABLE \`permission\` MODIFY COLUMN \`have\` tinyint NOT NULL COMMENT '권한 보유 여부 (1: 있음, 0: 없음)'`, ) // ========================================== // 4. WorshipSchedule Table (예배 일정) // ========================================== await queryRunner.query( - `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '예배 일정 ID'` + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '예배 일정 ID'`, ) await queryRunner.query( - `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`kind\` int NOT NULL COMMENT '예배 종류 (1: 주일예배, 2: 금요예배, 3: 기타)'` + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`kind\` int NOT NULL COMMENT '예배 종류 (1: 주일예배, 2: 금요예배, 3: 기타)'`, ) await queryRunner.query( - `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`date\` varchar(255) NOT NULL COMMENT '예배 날짜 (YYYY-MM-DD 문자열 형식)'` + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`date\` varchar(255) NOT NULL COMMENT '예배 날짜 (YYYY-MM-DD 문자열 형식)'`, ) await queryRunner.query( - `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`canEdit\` tinyint NOT NULL DEFAULT 1 COMMENT '리더의 출석 입력 가능 여부 (1: 가능, 0: 마감)'` + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`canEdit\` tinyint NOT NULL DEFAULT 1 COMMENT '리더의 출석 입력 가능 여부 (1: 가능, 0: 마감)'`, ) await queryRunner.query( - `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`isVisible\` tinyint NOT NULL DEFAULT 1 COMMENT '앱 내 일정 노출 여부 (1: 보임, 0: 숨김)'` + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`isVisible\` tinyint NOT NULL DEFAULT 1 COMMENT '앱 내 일정 노출 여부 (1: 보임, 0: 숨김)'`, ) await queryRunner.query( - `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '일정 생성 일시'` + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '일정 생성 일시'`, ) await queryRunner.query( - `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`lastModifiedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '마지막 수정 일시'` + `ALTER TABLE \`worship_schedule\` MODIFY COLUMN \`lastModifiedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '마지막 수정 일시'`, ) // ========================================== // 5. AttendData Table (출석 기록) // ========================================== await queryRunner.query( - `ALTER TABLE \`attend_data\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '출석 기록 고유 ID'` + `ALTER TABLE \`attend_data\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '출석 기록 고유 ID'`, ) // await queryRunner.query(`ALTER TABLE \`attend_data\` MODIFY COLUMN \`worshipScheduleId\` int NULL COMMENT '대상 예배 일정 ID (FK)'`); // await queryRunner.query(`ALTER TABLE \`attend_data\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '출석 대상 사용자 ID (FK)'`); await queryRunner.query( - `ALTER TABLE \`attend_data\` MODIFY COLUMN \`isAttend\` varchar(255) NOT NULL COMMENT '출석 상태 (ATTEND: 출석, ABSENT: 결석, ETC: 늦참/기타)'` + `ALTER TABLE \`attend_data\` MODIFY COLUMN \`isAttend\` varchar(255) NOT NULL COMMENT '출석 상태 (ATTEND: 출석, ABSENT: 결석, ETC: 늦참/기타)'`, ) await queryRunner.query( - `ALTER TABLE \`attend_data\` MODIFY COLUMN \`memo\` varchar(255) NOT NULL DEFAULT '' COMMENT '사유 또는 비고 사항'` + `ALTER TABLE \`attend_data\` MODIFY COLUMN \`memo\` varchar(255) NOT NULL DEFAULT '' COMMENT '사유 또는 비고 사항'`, ) // ========================================== // 6. RetreatAttend Table (수련회 참가 정보) // ========================================== await queryRunner.query( - `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '수련회 참가 신청서 ID'` + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '수련회 참가 신청서 ID'`, ) // await queryRunner.query(`ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '신청자 ID (FK)'`); await queryRunner.query( - `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`groupNumber\` int NOT NULL DEFAULT 0 COMMENT '배정된 조 번호 (0이면 미배정)'` + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`groupNumber\` int NOT NULL DEFAULT 0 COMMENT '배정된 조 번호 (0이면 미배정)'`, ) await queryRunner.query( - `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`roomNumber\` int NULL COMMENT '배정된 숙소/방 번호'` + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`roomNumber\` int NULL COMMENT '배정된 숙소/방 번호'`, ) await queryRunner.query( - `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`memo\` text NULL COMMENT '관리자용 메모'` + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`memo\` text NULL COMMENT '관리자용 메모'`, ) await queryRunner.query( - `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isDeposited\` varchar(255) NOT NULL DEFAULT 'none' COMMENT '회비 입금 상태 (none: 미입금, student: 학생회비, business: 직장인회비, half: 부분참석회비)'` + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isDeposited\` varchar(255) NOT NULL DEFAULT 'none' COMMENT '회비 입금 상태 (none: 미입금, student: 학생회비, business: 직장인회비, half: 부분참석회비)'`, ) await queryRunner.query( - `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`howToGo\` int NULL COMMENT '가는 편 이동 수단 (1: 같이가기, 2: 자차(단독), 3: 자차(동승), 4: 얻어타기, 5: 따로가기 등)'` + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`howToGo\` int NULL COMMENT '가는 편 이동 수단 (1: 같이가기, 2: 자차(단독), 3: 자차(동승), 4: 얻어타기, 5: 따로가기 등)'`, ) await queryRunner.query( - `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`howToBack\` int NULL COMMENT '오는 편 이동 수단 (옵션 위와 동일)'` + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`howToBack\` int NULL COMMENT '오는 편 이동 수단 (옵션 위와 동일)'`, ) await queryRunner.query( - `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isCanceled\` tinyint NOT NULL DEFAULT 0 COMMENT '참가 취소 여부 (1: 취소됨)'` + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isCanceled\` tinyint NOT NULL DEFAULT 0 COMMENT '참가 취소 여부 (1: 취소됨)'`, ) await queryRunner.query( - `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`etc\` varchar(255) NULL COMMENT '신청서 기타란 내용'` + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`etc\` varchar(255) NULL COMMENT '신청서 기타란 내용'`, ) await queryRunner.query( - `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`currentStatus\` int NOT NULL DEFAULT 0 COMMENT '참가 현황 상태 (0: null, 1: 교회도착, 2: 수련회장도착 등)'` + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`currentStatus\` int NOT NULL DEFAULT 0 COMMENT '참가 현황 상태 (0: null, 1: 교회도착, 2: 수련회장도착 등)'`, ) await queryRunner.query( - `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`attendanceNumber\` int NOT NULL DEFAULT 0 COMMENT '접수 번호 (순서대로 발급)'` + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`attendanceNumber\` int NOT NULL DEFAULT 0 COMMENT '접수 번호 (순서대로 발급)'`, ) await queryRunner.query( - `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`postcardContent\` text NULL COMMENT '수련회 엽서 내용'` + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`postcardContent\` text NULL COMMENT '수련회 엽서 내용'`, ) await queryRunner.query( - `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isWorker\` tinyint NOT NULL DEFAULT 1 COMMENT '직장인 여부 (1: 직장인, 0: 학생) - 회비 구분에 사용'` + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isWorker\` tinyint NOT NULL DEFAULT 1 COMMENT '직장인 여부 (1: 직장인, 0: 학생) - 회비 구분에 사용'`, ) await queryRunner.query( - `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isHalf\` tinyint NOT NULL DEFAULT 0 COMMENT '부분 참석 여부 (1: 토요일 저녁 이후 참석/부분참석, 0: 전체참석)'` + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`isHalf\` tinyint NOT NULL DEFAULT 0 COMMENT '부분 참석 여부 (1: 토요일 저녁 이후 참석/부분참석, 0: 전체참석)'`, ) await queryRunner.query( - `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '신청서 작성 일시'` + `ALTER TABLE \`retreat_attend\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '신청서 작성 일시'`, ) // ========================================== // 7. InOutInfo Table (입퇴실 상세 이동 정보) // ========================================== await queryRunner.query( - `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '이동 정보 ID'` + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '이동 정보 ID'`, ) // await queryRunner.query(`ALTER TABLE \`in_out_info\` MODIFY COLUMN \`retreatAttendId\` varchar(36) NULL COMMENT '수련회 참가 신청 ID (FK)'`); await queryRunner.query( - `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`day\` int NOT NULL COMMENT '수련회 1, 2, 3일차 구분 (1=금, 2=토, 3=일)'` + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`day\` int NOT NULL COMMENT '수련회 1, 2, 3일차 구분 (1=금, 2=토, 3=일)'`, ) await queryRunner.query( - `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`time\` varchar(255) NOT NULL COMMENT '예상 이동 시각 (HH:MM 문자열)'` + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`time\` varchar(255) NOT NULL COMMENT '예상 이동 시각 (HH:MM 문자열)'`, ) await queryRunner.query( - `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`inOutType\` varchar(255) NOT NULL COMMENT '이동 방향 (IN: 수련회장으로 감, OUT: 수련회장에서 나옴, none: 없음)'` + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`inOutType\` varchar(255) NOT NULL COMMENT '이동 방향 (IN: 수련회장으로 감, OUT: 수련회장에서 나옴, none: 없음)'`, ) await queryRunner.query( - `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`position\` varchar(255) NOT NULL COMMENT '출발지 또는 목적지 상세 (예: 교회, 집 등)'` + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`position\` varchar(255) NOT NULL COMMENT '출발지 또는 목적지 상세 (예: 교회, 집 등)'`, ) await queryRunner.query( - `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`howToMove\` int NOT NULL COMMENT '상세 이동 수단 (Enum 참조)'` + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`howToMove\` int NOT NULL COMMENT '상세 이동 수단 (Enum 참조)'`, ) await queryRunner.query( - `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`autoCreated\` tinyint NOT NULL DEFAULT 0 COMMENT '시스템 자동 생성 여부'` + `ALTER TABLE \`in_out_info\` MODIFY COLUMN \`autoCreated\` tinyint NOT NULL DEFAULT 0 COMMENT '시스템 자동 생성 여부'`, ) // await queryRunner.query(`ALTER TABLE \`in_out_info\` MODIFY COLUMN \`rideCarInfoId\` int NULL COMMENT '카풀 시 탑승한 차량(운전자)의 이동 정보 ID (FK)'`); @@ -209,88 +209,88 @@ export class AddCommentInAllColumn1768657178669 implements MigrationInterface { // 8. SharingText Table (나눔 - 텍스트) // ========================================== await queryRunner.query( - `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '은혜 나눔(글) ID'` + `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '은혜 나눔(글) ID'`, ) // await queryRunner.query(`ALTER TABLE \`sharing_text\` MODIFY COLUMN \`writerId\` varchar(36) NULL COMMENT '작성자 User ID (FK)'`); await queryRunner.query( - `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`content\` text NOT NULL COMMENT '나눔 본문 내용'` + `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`content\` text NOT NULL COMMENT '나눔 본문 내용'`, ) await queryRunner.query( - `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`visible\` tinyint NOT NULL DEFAULT 1 COMMENT '공개 여부 (1: 공개, 0: 비공개)'` + `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`visible\` tinyint NOT NULL DEFAULT 1 COMMENT '공개 여부 (1: 공개, 0: 비공개)'`, ) await queryRunner.query( - `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '작성 일시'` + `ALTER TABLE \`sharing_text\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '작성 일시'`, ) // ========================================== // 9. SharingImage Table (나눔 - 이미지) // ========================================== await queryRunner.query( - `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '은혜 나눔(사진) ID'` + `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '은혜 나눔(사진) ID'`, ) // await queryRunner.query(`ALTER TABLE \`sharing_image\` MODIFY COLUMN \`writerId\` varchar(36) NULL COMMENT '업로더 User ID (FK)'`); await queryRunner.query( - `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`url\` text NOT NULL COMMENT 'S3 등 스토리지 이미지 경로'` + `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`url\` text NOT NULL COMMENT 'S3 등 스토리지 이미지 경로'`, ) await queryRunner.query( - `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`visible\` tinyint NOT NULL DEFAULT 1 COMMENT '공개 여부'` + `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`visible\` tinyint NOT NULL DEFAULT 1 COMMENT '공개 여부'`, ) await queryRunner.query( - `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`tags\` text NULL COMMENT '이미지 태그 (콤마로 구분되거나 JSON)'` + `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`tags\` text NULL COMMENT '이미지 태그 (콤마로 구분되거나 JSON)'`, ) await queryRunner.query( - `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '업로드 일시'` + `ALTER TABLE \`sharing_image\` MODIFY COLUMN \`createAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '업로드 일시'`, ) // ========================================== // 10. WorshipContest Table (워십 콘테스트 투표) // ========================================== await queryRunner.query( - `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '투표 기록 ID'` + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`id\` int NOT NULL AUTO_INCREMENT COMMENT '투표 기록 ID'`, ) // await queryRunner.query(`ALTER TABLE \`worship_contest\` MODIFY COLUMN \`voteUserId\` varchar(36) NULL COMMENT '투표한 사용자 ID (FK)'`); await queryRunner.query( - `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`firstCommunity\` varchar(255) NOT NULL COMMENT '1순위 투표 팀 이름'` + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`firstCommunity\` varchar(255) NOT NULL COMMENT '1순위 투표 팀 이름'`, ) await queryRunner.query( - `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`secondCommunity\` varchar(255) NOT NULL COMMENT '2순위 투표 팀 이름'` + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`secondCommunity\` varchar(255) NOT NULL COMMENT '2순위 투표 팀 이름'`, ) await queryRunner.query( - `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`thirdCommunity\` varchar(255) NOT NULL COMMENT '3순위 투표 팀 이름'` + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`thirdCommunity\` varchar(255) NOT NULL COMMENT '3순위 투표 팀 이름'`, ) await queryRunner.query( - `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`term\` int NOT NULL COMMENT '투표 부문/회차 (예: 1부, 2부)'` + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`term\` int NOT NULL COMMENT '투표 부문/회차 (예: 1부, 2부)'`, ) await queryRunner.query( - `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '투표 일시'` + `ALTER TABLE \`worship_contest\` MODIFY COLUMN \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '투표 일시'`, ) // ========================================== // 11. AIChatRoom Table (AI 챗봇 채팅방) // ========================================== await queryRunner.query( - `ALTER TABLE \`ai_chat_room\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '채팅방 고유 ID (UUID)'` + `ALTER TABLE \`ai_chat_room\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '채팅방 고유 ID (UUID)'`, ) // await queryRunner.query(`ALTER TABLE \`ai_chat_room\` MODIFY COLUMN \`userId\` varchar(36) NULL COMMENT '채팅방 소유 사용자 ID (FK)'`); await queryRunner.query( - `ALTER TABLE \`ai_chat_room\` MODIFY COLUMN \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '채팅방 생성 일시'` + `ALTER TABLE \`ai_chat_room\` MODIFY COLUMN \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '채팅방 생성 일시'`, ) // ========================================== // 12. AIChat Table (AI 챗봇 메시지) // ========================================== await queryRunner.query( - `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '메시지 고유 ID (UUID)'` + `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`id\` varchar(36) NOT NULL COMMENT '메시지 고유 ID (UUID)'`, ) // await queryRunner.query(`ALTER TABLE \`ai_chat\` MODIFY COLUMN \`roomId\` varchar(36) NULL COMMENT '소속 채팅방 ID (FK)'`); await queryRunner.query( - `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`type\` enum('user', 'ai', 'system') NOT NULL COMMENT '발화자 구분 (user: 사용자, ai: 챗봇, system: 시스템 프롬프트)'` + `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`type\` enum('user', 'ai', 'system') NOT NULL COMMENT '발화자 구분 (user: 사용자, ai: 챗봇, system: 시스템 프롬프트)'`, ) await queryRunner.query( - `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`message\` text NOT NULL COMMENT '대화 내용'` + `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`message\` text NOT NULL COMMENT '대화 내용'`, ) await queryRunner.query( - `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '메시지 생성 일시'` + `ALTER TABLE \`ai_chat\` MODIFY COLUMN \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '메시지 생성 일시'`, ) // FK Check 활성화 From 71b6adc81a7115b9b4bee7f80c6b73d1a745a15c Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 19 Jan 2026 03:58:02 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20Ai=20Chat=20=EC=83=88=EB=B2=BD?= =?UTF-8?q?=EC=9D=B4=EC=8A=AC=20=EC=95=88=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/migration/1768657178669-AddCommentInAllColumn.ts | 4 ++-- server/src/model/ai.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/server/src/migration/1768657178669-AddCommentInAllColumn.ts b/server/src/migration/1768657178669-AddCommentInAllColumn.ts index 54b5578..39d80d0 100644 --- a/server/src/migration/1768657178669-AddCommentInAllColumn.ts +++ b/server/src/migration/1768657178669-AddCommentInAllColumn.ts @@ -65,8 +65,8 @@ export class AddCommentInAllColumn1768657178669 implements MigrationInterface { await queryRunner.query( `ALTER TABLE \`community\` MODIFY COLUMN \`lastModifiedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '마지막 정보 수정 일시'`, ) - // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`leaderId\` varchar(36) NULL COMMENT '리더(순장/조장) 사용자 ID (FK)'`); - // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`deputyLeaderId\` varchar(36) NULL COMMENT '부리더(부순장/부조장) 사용자 ID (FK)'`); + // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`leaderId\` varchar(36) NULL COMMENT '리더(순장/마을장) 사용자 ID (FK)'`); + // await queryRunner.query(`ALTER TABLE \`community\` MODIFY COLUMN \`deputyLeaderId\` varchar(36) NULL COMMENT '부리더(부순장) 사용자 ID (FK)'`); await queryRunner.query( `ALTER TABLE \`community\` MODIFY COLUMN \`x\` int NOT NULL DEFAULT 0 COMMENT '조직도 시각화용 X 좌표'`, ) diff --git a/server/src/model/ai.ts b/server/src/model/ai.ts index 0fab4ed..da87696 100644 --- a/server/src/model/ai.ts +++ b/server/src/model/ai.ts @@ -177,6 +177,11 @@ ORDER BY 당신은 수원제일교회 청년부 관리 시스템의 데이터베이스 전문가 AI 비서입니다. 사용자의 질문을 분석하여 통계를 내거나 정보를 찾기 위해 SQL 쿼리를 작성하고, 이후 제공되는 쿼리 결과를 분석하여 사용자에게 답변을 제공합니다. + [수원제일교회 청년부 정보] + 수원제일교회 청년부의 이름은 새벽이슬이며, 사역자가 각 마을들을 관리하며 마을들 안에는 다락방이 존재합니다. + 다락방은 하위 조직이 없는 단위이며 다락방의 리더를 순장, 부순장으로 지칭 합니다. + 마을의 리더는 마을장으로 지칭 합니다. + [데이터베이스 스키마 정보] ${JSON.stringify(schema, null, 2)}