const { PrismaClient } = require("@prisma/client"); const { createHash } = require("crypto"); const prisma = new PrismaClient(); function hashPassword(plain) { return createHash("sha256").update(plain, "utf8").digest("hex"); } function randomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function randomDate(startYear, endYear) { const start = new Date(`${startYear}-01-01`).getTime(); const end = new Date(`${endYear}-12-31`).getTime(); return new Date(randomInt(start, end)); } function generateRandomKoreanName() { const lastNames = ["김", "이", "박", "최", "정", "강", "조", "윤", "장", "임"]; const firstParts = ["민", "서", "도", "현", "지", "아", "윤", "준", "하", "유", "채", "은", "수", "태", "나"]; const secondParts = ["우", "영", "민", "서", "진", "현", "빈", "율", "솔", "연", "환", "호", "린", "훈", "경"]; const last = lastNames[randomInt(0, lastNames.length - 1)]; const first = firstParts[randomInt(0, firstParts.length - 1)] + secondParts[randomInt(0, secondParts.length - 1)]; return last + first; } function generateUniquePhone(i) { const mid = String(2000 + (i % 8000)).padStart(4, "0"); const last = String(3000 + i).padStart(4, "0"); return `010-${mid}-${last}`; } function generateNickname(i) { const suffix = Math.random().toString(36).slice(2, 4); return `user${String(i + 1).padStart(3, "0")}${suffix}`; } // 랜덤 제목/문장/이미지 도우미 const TITLE_FRAGMENTS = [ // 아주 짧은 키워드 "공지", "업뎃", "버그", "요청", "후기", "정보", "TIP", "사진", "잡담", "나눔", "질문", "헬프", "리뷰", "이슈", "주의", "긴급", "정리", "모음", "요약", "스샷", // 짧은 구문 "오늘의 이슈", "핫 토픽", "소소한 일상", "정보 공유", "꿀팁 모음", "개발 노트", "버그 리포트", "아이디어 제안", "함께 보아요", ]; const SENTENCES = [ "안녕하세요, 간단히 공유 드립니다.", "도움이 되셨다면 댓글로 알려주세요.", "의견이나 질문은 언제든 환영입니다.", "테스트로 작성된 시드 데이터입니다.", "참고용 스크린샷을 함께 첨부합니다.", "관련 경험 있으시면 팁 부탁드려요.", "문서화가 필요해 간단히 정리했습니다.", "링크와 자료를 함께 첨부합니다.", "개선 제안은 자유롭게 남겨주세요.", "읽어주셔서 감사합니다.", ]; const TITLE_SUBS = [ "지금", "방금", "오늘", "금일", "v2", "2025", "베타", "테스트", "임시", "간단히", "빠르게", "짧게", "새로", "업데이트", "정리", "공유", ]; const TITLE_EMOJIS = ["🔥", "📌", "✅", "❗", "💡", "🆕", "🔧", "📝", "📷"]; function clampTitle(s, max = 60) { return s.length <= max ? s : s.slice(0, max).trim(); } function pick(arr) { return arr[randomInt(0, arr.length - 1)]; } function coin(p = 0.5) { return Math.random() < p; } function randomTitle(boardName, index) { // 다양한 템플릿으로 제목 생성 (짧은 것도, 긴 것도 포함) const a = pick(TITLE_FRAGMENTS); const b = pick(TITLE_FRAGMENTS); const sub = pick(TITLE_SUBS); const emoji = pick(TITLE_EMOJIS); const num = (index % 99) + 1; const templates = [ () => `${a}`, () => `${a} ${emoji}`, () => `${a} #${num}`, () => `${a} ${sub}`, () => `${a} · ${b}`, () => `[${a}] ${b}`, () => `${a}: ${b}`, () => `${a} ${b} ${emoji}`, // 가끔만 보드명 포함 () => `${boardName} ${a}`, () => `${boardName} ${a} · ${b}`, ]; // 짧은 제목 확률을 높이기 위해 템플릿 선택 가중치 없이 랜덤 const title = pick(templates)(); return clampTitle(title, 60); } function randomSentence() { return SENTENCES[randomInt(0, SENTENCES.length - 1)]; } function randomImageUrl(seedKey, w = 800, h = 450) { // 외부 랜덤 이미지. 네트워크가 제한되면 /sample.jpg로 대체 가능 const seed = encodeURIComponent(String(seedKey)); return `https://picsum.photos/seed/${seed}/${w}/${h}`; } async function createRandomUsers(count = 100) { const roleUser = await prisma.role.findUnique({ where: { name: "user" } }); // 사용되지 않은 전화번호를 찾는 보조 함수 async function findAvailablePhone(startIndex) { let offset = 0; while (true) { const candidate = generateUniquePhone(startIndex + offset); const exists = await prisma.user.findUnique({ where: { phone: candidate } }); if (!exists) return candidate; offset += 1; } } for (let i = 0; i < count; i++) { // 닉네임을 결정론적으로 생성해 재실행 시 중복 생성 방지 const nickname = `user${String(i + 1).padStart(3, "0")}`; const existing = await prisma.user.findUnique({ where: { nickname } }); let user = existing; if (!existing) { const name = generateRandomKoreanName(); const birth = randomDate(1975, 2005); const phone = await findAvailablePhone(i + 2); // admin이 0001 사용하므로 겹치지 않도록 오프셋 user = await prisma.user.create({ data: { nickname, name, birth, phone, passwordHash: hashPassword("1234"), agreementTermsAt: new Date(), authLevel: "USER", isAdultVerified: Math.random() < 0.6, lastLoginAt: Math.random() < 0.8 ? new Date() : null, }, }); } else { // 기존 사용자도 패스워드를 1234로 업데이트 await prisma.user.update({ where: { userId: user.userId }, data: { passwordHash: hashPassword("1234") }, }); } if (roleUser && user) { await prisma.userRole.upsert({ where: { userId_roleId: { userId: user.userId, roleId: roleUser.roleId } }, update: {}, create: { userId: user.userId, roleId: roleUser.roleId }, }); } } } async function upsertCategories() { // 카테고리 트리 (projectmemo 기준 상위 그룹) const categories = [ { name: "메인", slug: "main", sortOrder: 1, status: "active" }, { name: "명예의 전당", slug: "hall-of-fame", sortOrder: 2, status: "active" }, { name: "소통방", slug: "community", sortOrder: 3, status: "active" }, { name: "TEST", slug: "test", sortOrder: 4, status: "active" }, ]; const map = {}; for (const c of categories) { const created = await prisma.boardCategory.upsert({ where: { slug: c.slug }, update: { name: c.name, sortOrder: c.sortOrder, status: c.status }, create: c, }); map[c.slug] = created; } return map; // { slug: category } } async function upsertRoles() { const roles = [ { name: "admin", description: "관리자" }, { name: "editor", description: "운영진" }, { name: "user", description: "일반 사용자" } ]; for (const r of roles) { await prisma.role.upsert({ where: { name: r.name }, update: { description: r.description }, create: r, }); } // 기본 권한 매핑 const roleMap = { admin: [ ["ADMIN", "ADMINISTER"], ["BOARD", "MODERATE"], ["POST", "CREATE"], ["POST", "UPDATE"], ["POST", "DELETE"], ["COMMENT", "DELETE"], ["USER", "UPDATE"], ], editor: [ ["BOARD", "MODERATE"], ["POST", "UPDATE"], ["POST", "DELETE"], ["COMMENT", "DELETE"], ], user: [ ["POST", "CREATE"], ["COMMENT", "CREATE"], ["POST", "READ"], ["COMMENT", "READ"], ], }; for (const [roleName, perms] of Object.entries(roleMap)) { const role = await prisma.role.findUnique({ where: { name: roleName } }); if (!role) continue; for (const [resource, action] of perms) { await prisma.rolePermission.upsert({ where: { roleId_resource_action: { roleId: role.roleId, resource, action, }, }, update: { allowed: true }, create: { roleId: role.roleId, resource, action, allowed: true }, }); } } } async function upsertAdmin() { const admin = await prisma.user.upsert({ where: { nickname: "admin" }, update: { passwordHash: hashPassword("1234"), grade: 7, points: 1650000, level: 200, }, create: { nickname: "admin", name: "Administrator", birth: new Date("1990-01-01"), phone: "010-0000-0001", passwordHash: hashPassword("1234"), agreementTermsAt: new Date(), authLevel: "ADMIN", grade: 7, points: 1650000, level: 200, }, }); const adminRole = await prisma.role.findUnique({ where: { name: "admin" } }); if (adminRole) { await prisma.userRole.upsert({ where: { userId_roleId: { userId: admin.userId, roleId: adminRole.roleId }, }, update: {}, create: { userId: admin.userId, roleId: adminRole.roleId }, }); } return admin; } async function upsertBoards(admin, categoryMap) { const boards = [ // 일반 { name: "공지사항", slug: "notice", description: "공지", sortOrder: 1, writeLevel: "moderator" }, { name: "가입인사", slug: "greetings", description: "가입인사", sortOrder: 2 }, { name: "버그건의", slug: "bug-report", description: "버그/건의", sortOrder: 3 }, { name: "이벤트", slug: "event", description: "이벤트", sortOrder: 4, requiredTags: { required: ["이벤트"] } }, { name: "자유게시판", slug: "free", description: "자유", sortOrder: 5 }, { name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", sortOrder: 6 }, { name: "익명게시판", slug: "anonymous", description: "익명", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true }, { name: "청와대", slug: "blue-house", description: "레벨 제한", sortOrder: 10, readLevel: "member" }, // 특수 { name: "출석부", slug: "attendance", description: "데일리 체크인", sortOrder: 12 }, { name: "회원랭킹", slug: "ranking", description: "랭킹", sortOrder: 14 }, { name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", sortOrder: 15 }, { name: "월간집계", slug: "monthly-stats", description: "월간 통계", sortOrder: 16 }, // TEST { name: "TEST", slug: "test", description: "테스트 게시판", sortOrder: 20 }, // 광고/제휴 계열 게시판은 제거(메인/명예의전당/소통방 외) ]; const created = []; // 특수 랭킹/텍스트/미리보기 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨) const mainSpecial = await prisma.boardViewType.findUnique({ where: { key: "main_special_rank" } }); const listSpecial = await prisma.boardViewType.findUnique({ where: { key: "list_special_rank" } }); const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } }); const mainPreview = await prisma.boardViewType.findUnique({ where: { key: "main_preview" } }); for (const b of boards) { // 카테고리 매핑 규칙 (트리 기준 상위 카테고리) const mapBySlug = { // 암실소문 (메인) notice: "main", greetings: "main", "bug-report": "main", event: "main", attendance: "main", // 명예의 전당 ranking: "hall-of-fame", "free-coupons": "hall-of-fame", "monthly-stats": "hall-of-fame", // 소통방(기본값 community로 처리) free: "community", qna: "community", tips: "community", anonymous: "community", // TEST test: "test", // 광고/제휴 }; const categorySlug = mapBySlug[b.slug] || "community"; const category = categoryMap[categorySlug]; const board = await prisma.board.upsert({ where: { slug: b.slug }, update: { description: b.description, sortOrder: b.sortOrder, allowAnonymousPost: !!b.allowAnonymousPost, readLevel: b.readLevel || undefined, categoryId: category ? category.id : undefined, // 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지, TEST는 미리보기 ...(b.slug === "ranking" ? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}) : b.slug === "test" ? (mainPreview ? { mainPageViewTypeId: mainPreview.id } : {}) : (mainText ? { mainPageViewTypeId: mainText.id } : {})), ...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}), }, create: { name: b.name, slug: b.slug, description: b.description, sortOrder: b.sortOrder, allowAnonymousPost: !!b.allowAnonymousPost, readLevel: b.readLevel || undefined, categoryId: category ? category.id : undefined, // 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지, TEST는 미리보기 ...(b.slug === "ranking" ? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}) : b.slug === "test" ? (mainPreview ? { mainPageViewTypeId: mainPreview.id } : {}) : (mainText ? { mainPageViewTypeId: mainText.id } : {})), ...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}), }, }); created.push(board); // 공지/운영 보드는 관리자 모더레이터 지정 if (["notice", "bug-report"].includes(board.slug)) { await prisma.boardModerator.upsert({ where: { boardId_userId: { boardId: board.id, userId: admin.userId }, }, update: {}, create: { boardId: board.id, userId: admin.userId, role: "MANAGER" }, }); } } return created; } async function upsertViewTypes() { const viewTypes = [ // main scope { key: "main_default", name: "기본", scope: "main" }, { key: "main_text", name: "텍스트", scope: "main" }, { key: "main_preview", name: "미리보기", scope: "main" }, { key: "main_special_rank", name: "특수랭킹", scope: "main" }, // list scope { key: "list_default", name: "기본", scope: "list" }, { key: "list_text", name: "텍스트", scope: "list" }, { key: "list_preview", name: "미리보기", scope: "list" }, { key: "list_special_rank", name: "특수랭킹", scope: "list" }, ]; for (const vt of viewTypes) { await prisma.boardViewType.upsert({ where: { key: vt.key }, update: { name: vt.name, scope: vt.scope }, create: vt, }); } } async function removeNonPrimaryBoards() { // 메인/명예의전당/소통방에 해당하지 않는 게시판(광고/제휴 계열) 정리 await prisma.board.deleteMany({ where: { slug: { in: ["partners-photos", "partner-contact", "partner-req"] } }, }); } async function createPostsForAllBoards(boards, countPerBoard = 100, admin) { const users = await prisma.user.findMany({ select: { userId: true } }); const userIds = users.map((u) => u.userId); for (const board of boards) { const data = []; for (let i = 0; i < countPerBoard; i++) { const authorId = ["notice", "bug-report"].includes(board.slug) ? admin.userId : userIds[randomInt(0, userIds.length - 1)]; const title = randomTitle(board.name, i); const img = randomImageUrl(`${board.slug}-${i}`); const p1 = randomSentence(); const p2 = randomSentence(); const p3 = randomSentence(); data.push({ boardId: board.id, authorId, title, content: `
${p1}
\n${p2}
\n${p3}
`, status: "published", isAnonymous: board.allowAnonymousPost ? Math.random() < 0.3 : false, }); } await prisma.post.createMany({ data }); } } async function seedPolicies() { // 금칙어 예시 const banned = [ { pattern: "광고", appliesTo: "POST", severity: 1 }, { pattern: "욕설", appliesTo: "COMMENT", severity: 2 }, { pattern: "스팸", appliesTo: "POST", severity: 2 }, ]; for (const k of banned) { await prisma.bannedKeyword.upsert({ where: { pattern: k.pattern }, update: { appliesTo: k.appliesTo, severity: k.severity, active: true }, create: k, }); } // 레벨 임계 예시 const levels = [ { level: 1, minPoints: 0 }, { level: 2, minPoints: 100 }, { level: 3, minPoints: 300 }, ]; for (const l of levels) { await prisma.levelThreshold.upsert({ where: { level: l.level }, update: { minPoints: l.minPoints }, create: l, }); } } async function seedPartnerShops() { const items = [ { region: "경기도", name: "test1", address: "수원시 팔달구 매산로 45", imageUrl: "/sample.jpg", sortOrder: 1 }, { region: "강원도", name: "test2", address: "춘천시 중앙로 112", imageUrl: "/sample.jpg", sortOrder: 2 }, ]; for (const it of items) { await prisma.partnerShop.upsert({ where: { name_region: { name: it.name, region: it.region } }, update: { address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true }, create: it, }); } // 표시 토글 기본값 보장 const SETTINGS_KEY = "mainpage_settings"; const setting = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } }); const current = setting ? JSON.parse(setting.value) : {}; const next = { showPartnerShops: true, ...current }; await prisma.setting.upsert({ where: { key: SETTINGS_KEY }, update: { value: JSON.stringify(next) }, create: { key: SETTINGS_KEY, value: JSON.stringify(next) }, }); } async function seedBanners() { // 기존 배너 정리 후 5개만 채움 await prisma.banner.deleteMany({}); const items = Array.from({ length: 5 }).map((_, i) => ({ title: `메인 배너 ${i + 1}`, imageUrl: "/sample.jpg", linkUrl: "/", sortOrder: i + 1, active: true, })); await prisma.banner.createMany({ data: items }); } async function seedMainpageVisibleBoards(boards) { const SETTINGS_KEY = "mainpage_settings"; const setting = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } }); const current = setting ? JSON.parse(setting.value) : {}; const wantSlugs = new Set(["notice", "free", "ranking", "test"]); const visibleBoardIds = boards.filter((b) => wantSlugs.has(b.slug)).map((b) => b.id); const next = { ...current, visibleBoardIds }; await prisma.setting.upsert({ where: { key: SETTINGS_KEY }, update: { value: JSON.stringify(next) }, create: { key: SETTINGS_KEY, value: JSON.stringify(next) }, }); } async function main() { await upsertRoles(); const admin = await upsertAdmin(); const categoryMap = await upsertCategories(); await upsertViewTypes(); await createRandomUsers(100); await removeNonPrimaryBoards(); const boards = await upsertBoards(admin, categoryMap); await seedMainpageVisibleBoards(boards); await createPostsForAllBoards(boards, 100, admin); await seedPartnerShops(); await seedBanners(); await seedPolicies(); // 제휴업체 예시 데이터 const partners = [ { name: "test", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" }, { name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, address: "서울 중구" }, ]; for (const p of partners) { await prisma.partner.upsert({ where: { name: p.name }, update: p, create: p }); } } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); });