[ ['title' => '서비스 점검 안내', 'content' => '

안녕하세요. 서비스 품질 향상을 위한 정기 점검이 예정되어 있습니다.

점검 일시: 매주 화요일 02:00 ~ 06:00

점검 시간 동안 서비스 이용이 제한될 수 있습니다.

양해 부탁드립니다.

'], ['title' => '이용약관 개정 공지', 'content' => '

이용약관이 아래와 같이 개정됨을 알려드립니다.

변경된 약관은 공지일로부터 7일 후 적용됩니다.

'], ['title' => '신규 기능 출시 안내', 'content' => '

새로운 기능이 추가되었습니다.

주요 기능

많은 이용 부탁드립니다.

'], ['title' => '회원 등급 제도 안내', 'content' => '

회원 등급 제도가 도입되었습니다.

활동에 따라 등급이 부여되며, 등급별 혜택이 제공됩니다.

등급조건혜택
골드게시글 100개 이상포인트 2배 적립
'], ['title' => '커뮤니티 가이드라인', 'content' => '

건전한 커뮤니티 운영을 위해 다음 사항을 준수해주세요.

'], ['title' => '보안 업데이트 완료', 'content' => '

서비스 보안이 강화되었습니다.

더욱 안전한 서비스 이용을 위해 비밀번호 변경을 권장드립니다.

'], ['title' => '연말 이벤트 안내', 'content' => '

연말을 맞아 특별 이벤트를 진행합니다.

참여 방법과 경품 안내는 이벤트 페이지를 확인해주세요.

'], ['title' => '앱 업데이트 안내', 'content' => '

앱 최신 버전이 출시되었습니다. 업데이트 후 새로운 기능을 이용해보세요.

'], ['title' => '서버 이전 공지', 'content' => '

서비스 안정성 향상을 위해 서버 이전 작업이 진행됩니다.

작업 중 일시적으로 접속이 불안정할 수 있습니다.

'], ['title' => '개인정보 처리방침 변경 안내', 'content' => '

개인정보 처리방침이 일부 변경되었습니다. 주요 변경 내용을 확인해주세요.

'], ], // 입력 제한 커스텀 적용 (min_title:5, max_title:100, min_content:0, max_content:2000) // 짧은 제목(5자 이상)과 내용 없는 게시글(링크/이미지만) 케이스 포함 'free' => [ ['title' => '오늘 날씨 좋다', 'content' => ''], // min_content_length:0 검증 — 내용 없는 게시글 ['title' => '링크 공유', 'content' => 'https://example.com'], // 짧은 내용 ['title' => '한 달 사용 후기 공유합니다', 'content' => '

서비스를 한 달간 사용해본 솔직한 후기입니다.

장점: 직관적인 UI, 빠른 응답 속도

아쉬운 점: 모바일 앱 기능 부족

전반적으로 만족스럽습니다.

'], ['title' => '맛집 추천해요', 'content' => '

최근에 다녀온 맛집을 공유합니다.

위치: 강남역 근처

메뉴: 파스타가 맛있어요

'], ['title' => '주말 계획 있으세요?', 'content' => '

이번 주말에 뭐 하실 계획인가요?

저는 영화 보러 갈 예정이에요.

'], ['title' => '운동 시작했어요', 'content' => '

오늘부터 헬스장 다니기 시작했습니다.

작심삼일 되지 않도록 열심히 해볼게요!

'], ['title' => '요즘 읽고 있는 책', 'content' => '

요즘 자기계발서를 읽고 있는데 추천드립니다.

'], ['title' => '새로 산 물건 자랑', 'content' => '

고민하다가 드디어 샀어요!

'], ['title' => '오랜만에 친구 만났어요', 'content' => '

오랜만에 친구들이랑 만나서 수다 떨었습니다.

'], ['title' => '요즘 빠져있는 취미', 'content' => '

요즘 사진 찍는 취미에 빠졌어요.

주말마다 출사 다니고 있습니다.

'], ['title' => '직장인의 소소한 일상', 'content' => '

출근길 커피 한 잔의 여유가 좋습니다.

'], ['title' => '반려동물 자랑합니다', 'content' => '

우리 집 강아지 사진이에요. 너무 귀엽지 않나요?

'], ], 'gallery' => [ ['title' => '주말 나들이 사진', 'content' => '

주말에 다녀온 나들이 사진입니다.

날씨가 좋아서 사진이 잘 나왔어요.

'], ['title' => '작업물 공유합니다', 'content' => '

최근에 작업한 결과물입니다.

피드백 환영합니다!

'], ['title' => '일상 스냅 모음', 'content' => '

오늘의 일상 사진들입니다.

'], ['title' => '여행 사진 공유', 'content' => '

지난 여행에서 찍은 사진들이에요.

정말 아름다웠습니다.

'], ['title' => '풍경 사진 모음', 'content' => '

출퇴근길에 찍은 풍경 사진들입니다.

'], ['title' => '음식 사진', 'content' => '

오늘 먹은 맛있는 음식 사진이에요.

'], ['title' => '야경 촬영', 'content' => '

밤에 찍은 야경 사진입니다.

삼각대 없이 찍어서 좀 흔들렸네요.

'], ['title' => '반려동물 사진', 'content' => '

우리 집 고양이 사진입니다.

자는 모습이 너무 귀여워요.

'], ['title' => '꽃 사진 공유', 'content' => '

봄에 찍어둔 꽃 사진들이에요.

'], ['title' => '카페 인테리어', 'content' => '

분위기 좋은 카페 사진입니다.

'], ], // card 타입 이벤트 게시판 — 카드 형식에 맞는 짧고 임팩트 있는 내용 'event' => [ ['title' => '신규 가입 환영 이벤트', 'content' => '

새로 가입하신 모든 분께 웰컴 쿠폰을 드립니다.

혜택: 10% 할인 쿠폰 즉시 지급

기간: 상시

'], ['title' => '친구 초대 이벤트', 'content' => '

친구를 초대하고 함께 혜택을 누리세요.

혜택: 초대자·피초대자 모두 포인트 적립

기간: 이번 달 말까지

'], ['title' => '리뷰 작성 이벤트', 'content' => '

서비스 이용 후기를 남겨주세요.

혜택: 리뷰 1건당 100포인트 적립

기간: 매월 진행

'], ['title' => '출석 체크 이벤트', 'content' => '

매일 출석 체크하고 포인트를 받아가세요.

혜택: 연속 7일 출석 시 보너스 포인트

기간: 상시

'], ['title' => '사진 공모전', 'content' => '

일상의 아름다운 순간을 담아 제출해주세요.

상금: 1등 50만원 / 2등 30만원 / 3등 10만원

마감: 이번 달 말

'], ['title' => '설문조사 참여 이벤트', 'content' => '

서비스 개선을 위한 설문에 참여해주세요.

혜택: 참여자 전원 포인트 지급

소요 시간: 약 3분

'], ['title' => '시즌 특가 이벤트', 'content' => '

시즌 한정 특별 할인을 놓치지 마세요.

할인율: 최대 50% 할인

기간: 한정 수량 소진 시 종료

'], ['title' => '커뮤니티 활동왕 이벤트', 'content' => '

이번 달 가장 활발하게 활동한 회원을 선정합니다.

선정 기준: 게시글 + 댓글 수

혜택: 1위 상품권 10만원

'], ], 'qna' => [ ['title' => '결제 오류가 발생합니다', 'content' => '

결제 시 오류가 발생하는데요.

오류 메시지: "결제 처리 중 오류가 발생했습니다"

해결 방법을 알려주세요.

'], ['title' => '기능 사용법 질문', 'content' => '

이 기능은 어떻게 사용하나요?

메뉴에서 찾을 수가 없어요.

'], ['title' => 'API 연동 문의', 'content' => '

API 연동 방법을 알려주세요.

문서를 봤는데 잘 이해가 안 됩니다.

'], ['title' => '회원가입이 안 돼요', 'content' => '

회원가입 시 이메일 인증이 안 됩니다.

인증 메일이 오지 않아요.

'], ['title' => '비밀번호 변경 방법', 'content' => '

비밀번호를 변경하고 싶은데 어디서 하나요?

'], ['title' => '알림 설정 질문', 'content' => '

알림을 끄고 싶은데 설정을 못 찾겠어요.

'], ['title' => '파일 업로드 오류', 'content' => '

파일 업로드가 안 됩니다.

용량 제한이 있나요?

'], ['title' => '모바일에서 접속 문제', 'content' => '

모바일에서 접속이 잘 안 됩니다.

PC에서는 정상입니다.

'], ['title' => '포인트 사용 방법', 'content' => '

적립된 포인트는 어떻게 사용하나요?

'], ['title' => '탈퇴 후 재가입 가능한가요?', 'content' => '

탈퇴 후 동일한 이메일로 재가입이 가능한지 궁금합니다.

'], ['title' => '데이터 백업 방법', 'content' => '

내 데이터를 백업하는 방법이 있나요?

'], ['title' => '언어 설정 변경', 'content' => '

언어 설정은 어디서 변경하나요?

'], ], // secret_mode:always + use_comment:true 조합 — 비밀게시판 내 댓글 동작 확인 'members' => [ ['title' => '회원 전용 이벤트 안내', 'content' => '

회원 여러분을 위한 특별 이벤트입니다.

참여 방법은 아래를 확인해주세요.

'], ['title' => '내부 자료 공유', 'content' => '

회원 전용 자료입니다.

외부 유출을 삼가해주세요.

'], ['title' => '회원 모임 공지', 'content' => '

다음 달 정기 모임 일정입니다.

많은 참여 부탁드립니다.

'], ['title' => '회원 혜택 안내', 'content' => '

이번 달 회원 혜택을 안내드립니다.

'], ['title' => '회원 전용 할인 쿠폰', 'content' => '

회원님께 드리는 특별 할인 쿠폰입니다.

'], ['title' => '커뮤니티 규칙 공유', 'content' => '

회원 커뮤니티 이용 규칙입니다.

'], ['title' => '신규 회원 환영합니다', 'content' => '

새로 가입하신 회원분들 환영합니다!

'], ['title' => '회원 설문조사', 'content' => '

서비스 개선을 위한 설문조사입니다.

참여 부탁드립니다.

'], ['title' => '회원 등급 업그레이드 안내', 'content' => '

회원 등급 업그레이드 조건이 변경되었습니다.

'], ['title' => '비공개 정보 공유', 'content' => '

회원에게만 공개되는 정보입니다.

'], ], 'inquiry' => [ ['title' => '배송 관련 문의드립니다', 'content' => '

주문한 상품 배송이 지연되고 있습니다.

현재 배송 상태 확인 부탁드립니다.

'], ['title' => '결제 오류 문의', 'content' => '

결제가 이중으로 된 것 같습니다.

확인 후 조치 부탁드립니다.

'], ['title' => '환불 요청합니다', 'content' => '

구매한 상품 환불을 요청합니다.

자세한 사유는 아래와 같습니다.

'], ['title' => '계정 로그인 문제', 'content' => '

계정에 문제가 있어서 문의합니다.

로그인이 안 되는 상황입니다.

'], ['title' => '서비스 이용 문의', 'content' => '

서비스 이용 중 궁금한 점이 있어 문의드립니다.

'], ['title' => '회원정보 변경 요청', 'content' => '

회원정보 변경을 요청드립니다.

연락처와 주소를 변경하고 싶습니다.

'], ['title' => '제휴 및 협력 문의', 'content' => '

제휴 및 협력 관련하여 문의드립니다.

담당자 연락처 부탁드립니다.

'], ['title' => '기타 문의사항', 'content' => '

기타 문의사항이 있어 연락드립니다.

'], ['title' => '불편사항 접수', 'content' => '

서비스 이용 중 불편한 점이 있어 접수합니다.

'], ['title' => '개인정보 관련 문의', 'content' => '

개인정보 처리 관련하여 문의드립니다.

'], ], // is_active:false 비활성 게시판 — 과거 아카이브 성격의 내용 // order_direction:ASC이므로 오래된 글이 상단에 노출되는 구조 'archive' => [ ['title' => '2022년 서비스 오픈 안내', 'content' => '

서비스가 정식 오픈되었습니다.

많은 관심과 이용 부탁드립니다.

'], ['title' => '2022년 주요 업데이트 내역', 'content' => '

2022년 한 해 동안 진행된 주요 업데이트를 정리했습니다.

'], ['title' => '2023년 서비스 운영 회고', 'content' => '

2023년 서비스 운영 결과를 공유합니다.

총 방문자 수, 게시글 수, 주요 이슈 등을 정리했습니다.

'], ['title' => '구버전 기능 안내', 'content' => '

현재는 사용되지 않는 구버전 기능 안내입니다.

새로운 기능으로 마이그레이션 방법을 안내합니다.

'], ['title' => '이전 이벤트 당첨자 발표', 'content' => '

지난 이벤트 당첨자를 발표합니다.

당첨되신 분들은 이메일을 확인해주세요.

'], ['title' => '과거 공지사항 모음', 'content' => '

2022~2023년 주요 공지사항을 아카이브합니다.

'], ['title' => '서비스 초기 이용 가이드', 'content' => '

서비스 초기 버전 이용 가이드입니다.

현재 UI와 다를 수 있습니다.

'], ['title' => '구버전 FAQ', 'content' => '

자주 묻는 질문 구버전입니다.

최신 FAQ는 고객센터를 이용해주세요.

'], ], ]; /** * 일반 콘텐츠 템플릿 (기본 폴백용) */ private const DEFAULT_CONTENT_TEMPLATES = [ ['title' => '테스트 게시글입니다', 'content' => '

테스트용 게시글 내용입니다.

'], ['title' => '샘플 데이터', 'content' => '

샘플 데이터입니다.

'], ['title' => '안녕하세요', 'content' => '

안녕하세요. 반갑습니다.

'], ]; /** * 댓글 샘플 템플릿 */ private const COMMENT_TEMPLATES = [ '좋은 글 감사합니다!', '유익한 정보네요.', '저도 같은 생각입니다.', '질문이 있는데요, 자세히 설명해주실 수 있나요?', '공유해주셔서 감사합니다.', '도움이 많이 되었습니다.', '저도 비슷한 경험이 있어요.', '추가 정보 있으면 공유 부탁드립니다.', '궁금한 점이 해결됐습니다. 감사합니다!', '이 부분은 조금 다르게 생각하는데요.', '참고할만한 자료가 있을까요?', '정말 유용한 팁이네요!', '저는 다른 방법을 사용했는데 이 방법도 좋네요.', '이해가 잘 안되는 부분이 있어요.', '계속해서 좋은 글 부탁드립니다.', ]; /** * 대댓글 샘플 템플릿 (depth별) */ private const NESTED_REPLY_TEMPLATES = [ 1 => [ '감사합니다!', '네, 맞습니다.', '추가 설명드리자면...', '궁금하신 점 해결되셨으면 좋겠습니다.', '또 궁금하신 점 있으면 언제든지 물어보세요.', ], 2 => [ '아, 그렇군요! 이해했습니다.', '추가 질문이 있는데요.', '덕분에 잘 해결했습니다.', '조금 더 자세히 설명해주실 수 있나요?', ], 3 => [ '완전히 이해됐습니다. 감사해요!', '다른 분들도 참고하시면 좋겠네요.', '마지막으로 한 가지만 더 여쭤볼게요.', ], ]; /** * 답변글 템플릿 */ private const ANSWER_TEMPLATES = [ '문의하신 내용에 대해 답변드립니다.', '안녕하세요. 답변드립니다.', '해당 문의에 대한 답변입니다.', '확인 후 답변드립니다.', ]; /** * 다단계 답변글 템플릿 (depth 2+) */ private const NESTED_ANSWER_TEMPLATES = [ 2 => [ '추가 답변드립니다.', '보충 설명드리겠습니다.', '말씀하신 부분에 대해 추가로 안내드립니다.', ], 3 => [ '최종 답변드립니다.', '추가 확인 후 답변드립니다.', '관련 부서 확인 결과 안내드립니다.', ], ]; /** * 샘플 이미지 파일 개수 */ private const SAMPLE_IMAGE_COUNT = 5; /** * 게시판별 게시글 수 * * - notice : 15건 — 관리자 전용, 공지 특성상 적은 수 * - free : 30건 — 입력 제한 커스텀 검증, 비회원 포함 다양한 작성자 * - gallery : 20건 — 이미지 첨부 위주, 조회수 정렬 분포 확인 * - event : 10건 — card 타입, 관리자만 등록 * - qna : 30건 — 카테고리 분포 + 답글 다단계 확인 * - members : 25건 — secret_mode:always 비밀글 전량 생성 * - inquiry : 20건 — 비밀글 필수, 답글 depth:1 응답률 확인 * - archive : 10건 — 비활성 게시판, ASC 정렬 확인용 */ private const POSTS_COUNT = [ 'notice' => 15, 'free' => 30, 'gallery' => 20, 'event' => 10, 'qna' => 30, 'members' => 25, 'inquiry' => 20, 'archive' => 10, ]; /** * 생성된 샘플 이미지 파일 정보 * * @var array */ private array $sampleImages = []; /** * 비회원 비밀번호 캐시 (bcrypt 반복 호출 방지) */ private ?string $guestPasswordHash = null; /** * 관리자 사용자 */ private ?User $adminUser = null; /** * 스텝 사용자 */ private ?User $stepUser = null; /** * 시더 실행 */ public function run(): void { $this->command->info('게시글/댓글 샘플 데이터 생성 중...'); // 쿼리 로그 비활성화 (메모리 절약 — 수백~수천 건 INSERT 시 필수) DB::disableQueryLog(); DB::connection()->unsetEventDispatcher(); // 테스트 사용자 조회 (더 많은 사용자 확보) $users = User::limit(20)->get(); if ($users->isEmpty()) { $this->command->error('사용자가 없습니다. DummyUserSeeder를 먼저 실행하세요.'); return; } // 관리자/스텝 사용자 조회 $this->resolveRoleUsers($users); $this->command->info(" - {$users->count()}명의 사용자로 게시글/댓글 생성"); if ($this->adminUser) { $this->command->info(" - 관리자: {$this->adminUser->name} (ID: {$this->adminUser->id})"); } if ($this->stepUser) { $this->command->info(" - 스텝: {$this->stepUser->name} (ID: {$this->stepUser->id})"); } // 스케일링 비율 계산 $baseTotal = (int) array_sum(self::POSTS_COUNT); $requestedTotal = $this->getSeederCount('posts', $baseTotal); $ratio = $requestedTotal / $baseTotal; if ($ratio !== 1.0) { $this->command->info(" - 게시글 수 스케일링: 기본 {$baseTotal}개 → 요청 {$requestedTotal}개 (비율: {$ratio})"); } // 샘플 이미지 파일 미리 생성 (5개) $this->createSampleImageFiles(); // 모든 게시판에 게시글 생성 $boards = Board::all(); $totalStats = ['posts' => 0, 'replies' => 0, 'comments' => 0]; foreach ($boards as $board) { try { $this->command->warn(" >>> {$board->slug} 게시판 처리 시작"); $stats = $this->createPostsForBoard($board, $users, $ratio); $totalStats['posts'] += $stats['posts']; $totalStats['replies'] += $stats['replies']; $totalStats['comments'] += $stats['comments']; $this->command->warn(" <<< {$board->slug} 게시판 처리 완료 (게시글 {$stats['posts']}, 답변글 {$stats['replies']}, 댓글 {$stats['comments']})"); } catch (\Exception $e) { $this->command->error(" !!! {$board->slug} 게시판 처리 중 오류: {$e->getMessage()}"); $this->command->error(" {$e->getFile()}:{$e->getLine()}"); } // 게시판별 메모리 해제 gc_collect_cycles(); } $this->command->info(''); $this->command->info("게시글/댓글 샘플 데이터 생성 완료 — 총 게시글 {$totalStats['posts']}개, 답변글 {$totalStats['replies']}개, 댓글 {$totalStats['comments']}개"); // 시드는 모델 이벤트(BoardCommentsCountSyncListener / PostCountSyncListener)를 // 우회하는 raw INSERT 라 캐시 컬럼이 갱신되지 않는다. 시드 직후 게시글 상세 화면에서 // 댓글/답글 영역이 "comments_count > 0" 조건으로 가려져 표시 안 되는 결함이 발생하므로 // 일괄 재집계로 actual count 와 일치시킨다 (soft delete 제외). $this->syncDenormalizedCounts(); } /** * 시드 종료 시 비정규화된 캐시 컬럼을 실데이터와 일괄 동기화한다. * * 대상: * - posts.comments_count = 그 게시글의 미삭제 댓글 수 * - posts.replies_count = 그 게시글의 미삭제 답변글 수 * - posts.attachments_count = 그 게시글의 미삭제 첨부 수 * - comments.replies_count = 그 댓글의 미삭제 자식 댓글 수 * - boards.posts_count = 그 게시판의 미삭제 게시글 수 (답글 포함) * - boards.comments_count = 그 게시판의 미삭제 댓글 수 * * 동일 테이블 UPDATE + 서브쿼리는 MySQL 이 거부하므로 (Error 1093) 집계 결과를 * 파생 테이블(`JOIN (SELECT ... GROUP BY ...) t`) 로 끌어와 갱신한다. */ private function syncDenormalizedCounts(): void { $this->command->info('비정규화 카운트 컬럼 동기화 중...'); // posts.comments_count (다른 테이블 참조 — 직접 서브쿼리 가능) DB::statement(' UPDATE g7_board_posts p SET comments_count = ( SELECT COUNT(*) FROM g7_board_comments c WHERE c.post_id = p.id AND c.deleted_at IS NULL ) '); // posts.replies_count (자기 참조 — 파생 테이블 사용) DB::statement(' UPDATE g7_board_posts p LEFT JOIN ( SELECT parent_id, COUNT(*) AS cnt FROM g7_board_posts WHERE parent_id IS NOT NULL AND deleted_at IS NULL GROUP BY parent_id ) t ON t.parent_id = p.id SET p.replies_count = COALESCE(t.cnt, 0) '); // posts.attachments_count (다른 테이블 참조 — 직접 서브쿼리 가능) DB::statement(' UPDATE g7_board_posts p SET attachments_count = ( SELECT COUNT(*) FROM g7_board_attachments a WHERE a.post_id = p.id AND a.deleted_at IS NULL ) '); // comments.replies_count (자기 참조 — 파생 테이블 사용) DB::statement(' UPDATE g7_board_comments c LEFT JOIN ( SELECT parent_id, COUNT(*) AS cnt FROM g7_board_comments WHERE parent_id IS NOT NULL AND deleted_at IS NULL GROUP BY parent_id ) t ON t.parent_id = c.id SET c.replies_count = COALESCE(t.cnt, 0) '); // boards.posts_count (게시판별 미삭제 게시글 수 — 답글 포함) DB::statement(' UPDATE g7_boards b LEFT JOIN ( SELECT board_id, COUNT(*) AS cnt FROM g7_board_posts WHERE deleted_at IS NULL GROUP BY board_id ) t ON t.board_id = b.id SET b.posts_count = COALESCE(t.cnt, 0) '); // boards.comments_count (게시판별 미삭제 댓글 수) DB::statement(' UPDATE g7_boards b LEFT JOIN ( SELECT board_id, COUNT(*) AS cnt FROM g7_board_comments WHERE deleted_at IS NULL GROUP BY board_id ) t ON t.board_id = b.id SET b.comments_count = COALESCE(t.cnt, 0) '); $this->command->info(' ✅ posts.comments_count / posts.replies_count / posts.attachments_count / comments.replies_count / boards.posts_count / boards.comments_count 일괄 갱신 완료'); } /** * 관리자/스텝 역할 사용자를 조회합니다. * * @param Collection $users 사용자 목록 */ private function resolveRoleUsers($users): void { // admin 역할 사용자 $adminRole = Role::where('identifier', 'admin')->first(); if ($adminRole) { $this->adminUser = $adminRole->users()->first(); } // step 역할 사용자 (없으면 두 번째 사용자를 사용) $stepRole = Role::where('identifier', 'step')->first(); if ($stepRole) { $this->stepUser = $stepRole->users()->first(); } // 폴백: 사용자 목록에서 할당 if (! $this->adminUser && $users->count() > 0) { $this->adminUser = $users->first(); } if (! $this->stepUser && $users->count() > 1) { $this->stepUser = $users[1]; } } /** * 샘플 이미지 파일을 미리 생성합니다. * * 5개의 샘플 이미지를 storage/app/modules/sirsoft-board/attachments/samples/ 에 저장합니다. */ private function createSampleImageFiles(): void { $this->command->info(' - 샘플 이미지 파일 생성 중...'); $datePath = date('Y/m/d'); for ($i = 0; $i < self::SAMPLE_IMAGE_COUNT; $i++) { $filename = Str::uuid().'.jpg'; $path = "samples/{$datePath}/{$filename}"; // 이미지 크기 랜덤 $width = rand(800, 1920); $height = rand(600, 1080); // 실제 샘플 이미지 생성 $imageContent = $this->createSampleImage($width, $height); $fileSize = strlen($imageContent); // 스토리지에 파일 저장 $storagePath = "sirsoft-board/attachments/{$path}"; Storage::disk('modules')->put($storagePath, $imageContent); // 파일 정보 저장 $this->sampleImages[] = [ 'filename' => $filename, 'path' => $path, 'size' => $fileSize, 'width' => $width, 'height' => $height, ]; $this->command->info(" - 샘플 이미지 #{$i}: {$storagePath}"); } } /** * 특정 게시판에 게시글과 댓글을 생성합니다. * * @param Board $board 게시판 * @param Collection $users 사용자 목록 * @param float $ratio 게시글 수 스케일링 비율 * @return array{posts: int, replies: int, comments: int} 생성 통계 */ private function createPostsForBoard(Board $board, $users, float $ratio = 1.0): array { // 이미 데이터가 있으면 스킵 $existingCount = DB::table('board_posts')->where('board_id', $board->id)->count(); if ($existingCount > 0) { $this->command->warn(" - {$board->slug} 게시판에 이미 게시글 {$existingCount}개가 있습니다. 스킵합니다."); return ['posts' => 0, 'replies' => 0, 'comments' => 0]; } // card/gallery 타입 게시판은 배지 케이스 보장 게시글 먼저 생성 $badgeStats = ['posts' => 0, 'replies' => 0, 'comments' => 0]; if (in_array($board->type, ['card', 'gallery'])) { $badgeStats = $this->createBadgeCasePosts($board, $users); } // 권한 확인 $allowGuestPost = $this->boardAllowsGuestWrite($board); $allowGuestComment = $this->boardAllowsGuestComment($board); $postCount = $badgeStats['posts']; $replyPostCount = $badgeStats['replies']; $commentCount = $badgeStats['comments']; // 생성할 게시글 개수 (비율 스케일링 적용) $defaultCount = self::POSTS_COUNT[$board->slug] ?? 20; $totalPosts = max(1, (int) round($defaultCount * $ratio)); // 게시판별 콘텐츠 템플릿 가져오기 $contentTemplates = self::BOARD_CONTENT_TEMPLATES[$board->slug] ?? self::DEFAULT_CONTENT_TEMPLATES; $templateCount = count($contentTemplates); // 날짜 분포 계산: 오늘(10%), 이번주(30%), 이번달(40%), 이전(20%) $todayCount = (int) ($totalPosts * 0.10); $weekCount = (int) ($totalPosts * 0.30); $monthCount = (int) ($totalPosts * 0.40); for ($i = 0; $i < $totalPosts; $i++) { $templateIndex = $i % $templateCount; $template = $contentTemplates[$templateIndex]; // 비회원 게시글 - 권한 확인 후 30% 확률로 생성 $isGuest = $allowGuestPost && rand(1, 10) <= 3; // 작성자 결정: notice는 관리자만, 나머지는 역할 혼합 $user = $this->pickAuthor($board, $users, $i); // 카테고리 설정 (있는 경우) $category = null; if (! empty($board->categories) && is_array($board->categories)) { $category = $board->categories[array_rand($board->categories)]; } // 비밀글 처리 - secret_mode 기반 $isSecret = $this->determineIsSecret($board); // 상태 랜덤 설정 (published: 70%, blinded: 20%, deleted: 10%) $statusRand = rand(1, 100); $status = 'published'; $deletedAt = null; $actionLogs = null; if ($statusRand <= 10) { // 10% 삭제 $status = 'deleted'; $deletedAt = now()->subDays(rand(1, 5)); $actionLogs = [[ 'action' => 'delete', 'reason' => '부적절한 내용으로 인한 삭제', 'admin_id' => $this->adminUser?->id ?? $user->id, 'admin_name' => $this->adminUser?->name ?? $user->name, 'ip_address' => '127.0.0.1', 'created_at' => $deletedAt, ]]; } elseif ($statusRand <= 30) { // 20% 블라인드 $status = 'blinded'; $actionLogs = [[ 'action' => 'blind', 'reason' => '커뮤니티 가이드 위반', 'admin_id' => $this->adminUser?->id ?? $user->id, 'admin_name' => $this->adminUser?->name ?? $user->name, 'ip_address' => '127.0.0.1', 'created_at' => now()->subDays(rand(1, 5)), ]]; } // 공지글 처리 - notice 게시판 제외, 다른 게시판은 5% 확률 $isNotice = ($board->slug !== 'notice') && (rand(1, 20) === 1); // 날짜 분포에 따른 created_at 설정 $createdAt = $this->generateCreatedAt($i, $todayCount, $weekCount, $monthCount); // 조회수: 최근 게시글일수록 적게 (현실적인 분포) $viewCount = match (true) { $i < $todayCount => rand(0, 50), $i < $todayCount + $weekCount => rand(20, 200), $i < $todayCount + $weekCount + $monthCount => rand(50, 400), default => rand(100, 500), }; // 게시글 생성 $postData = [ 'category' => $category, 'title' => ($isNotice && $board->slug !== 'notice' ? '[공지] ' : '').$template['title'], 'content' => $template['content'], 'content_mode' => 'html', 'user_id' => $isGuest ? null : $user->id, 'author_name' => $isGuest ? '익명'.rand(1, 100) : null, 'password' => $isGuest ? $this->getGuestPassword() : null, 'ip_address' => '127.0.0.'.rand(1, 255), 'is_notice' => $isNotice, 'is_secret' => $isSecret, 'status' => $status, 'trigger_type' => 'admin', 'action_logs' => $actionLogs ? json_encode($actionLogs) : null, 'view_count' => $viewCount, 'parent_id' => null, 'depth' => 0, 'created_at' => $createdAt, 'updated_at' => $createdAt, 'deleted_at' => $deletedAt, ]; $postData['board_id'] = $board->id; $postId = DB::table('board_posts')->insertGetId($postData); $postCount++; // 첨부파일 생성 - use_file_upload 설정 확인 if ($board->use_file_upload) { // gallery는 85% (일부는 이미지 없음), 나머지는 50% 확률 $attachmentChance = ($board->type === 'gallery') ? 85 : 50; if (rand(1, 100) <= $attachmentChance) { $this->createAttachmentsForPost($board->id, $postId, $user->id ?? null); } } // 답변글 생성 - use_reply 설정 확인, 다단계 depth 지원 if ($board->use_reply && ! $isNotice) { $replyPostCount += $this->createRepliesForPost( $board, $postId, $category, $template, $isSecret, $createdAt, $users ); } // 댓글 생성 - use_comment 설정 확인, 다단계 depth 지원 if ($board->use_comment) { $commentCount += $this->createCommentsForPost( $board, $postId, $createdAt, $users, $i, $todayCount, $weekCount, $monthCount, $allowGuestComment ); } } $this->command->info(" - {$board->slug}: 게시글 {$postCount}개, 답변글 {$replyPostCount}개, 댓글 {$commentCount}개 생성"); return ['posts' => $postCount, 'replies' => $replyPostCount, 'comments' => $commentCount]; } /** * card/gallery 타입 게시판에 배지 케이스별 보장 게시글을 생성합니다. * * 다음 케이스를 각각 1개 이상 보장합니다: * - 공지글 (is_notice) * - 신규글 (created_at = 지금, is_new = true) * - 블라인드 + 이미지 있음 * - 삭제됨 + 이미지 있음 * - 비밀글 (is_secret) * - 답글 RE (parent_id 있음) * - 이미지 없는 일반글 * - 카테고리 있는 글 * - 일반글 + 이미지 있음 * * @param Board $board 게시판 * @param Collection $users 사용자 목록 * @return array{posts: int, replies: int, comments: int} 생성 통계 */ private function createBadgeCasePosts(Board $board, $users): array { $this->command->info(" - {$board->slug}: 배지 케이스 보장 게시글 생성 중..."); $postCount = 0; $replyCount = 0; $user = $users->first(); $adminUser = $this->adminUser ?? $user; // 카테고리 (있으면 첫 번째, 없으면 null) $firstCategory = (! empty($board->categories) && is_array($board->categories)) ? $board->categories[0] : null; // 공통 게시글 INSERT 헬퍼 $insertPost = function (array $data) use ($board): int { $defaults = [ 'board_id' => $board->id, 'category' => null, 'content_mode' => 'html', 'ip_address' => '127.0.0.1', 'is_notice' => false, 'is_secret' => false, 'status' => 'published', 'trigger_type' => 'admin', 'action_logs' => null, 'view_count' => rand(10, 200), 'parent_id' => null, 'depth' => 0, 'deleted_at' => null, ]; $now = now()->subMinutes(rand(1, 30)); $defaults['created_at'] = $now; $defaults['updated_at'] = $now; return DB::table('board_posts')->insertGetId(array_merge($defaults, $data)); }; // ① 공지글 + 이미지 있음 $postId = $insertPost([ 'title' => '[공지] 게시판 이용 안내', 'content' => '

게시판 이용 규칙을 안내드립니다. 건전한 커뮤니티 문화를 함께 만들어주세요.

', 'user_id' => $adminUser->id, 'is_notice' => true, ]); if ($board->use_file_upload) { $this->createAttachmentsForPost($board->id, $postId, $adminUser->id); } $postCount++; // ② 신규글 (created_at = 바로 지금 → is_new = true) + 이미지 있음 $nowTime = now()->subMinutes(1); $postId = DB::table('board_posts')->insertGetId([ 'board_id' => $board->id, 'category' => $firstCategory, 'title' => '방금 올라온 신규 게시글', 'content' => '

방금 등록된 최신 게시글입니다. N 배지가 표시됩니다.

', 'content_mode' => 'html', 'user_id' => $user->id, 'ip_address' => '127.0.0.1', 'is_notice' => false, 'is_secret' => false, 'status' => 'published', 'trigger_type' => 'admin', 'view_count' => 1, 'parent_id' => null, 'depth' => 0, 'deleted_at' => null, 'created_at' => $nowTime, 'updated_at' => $nowTime, ]); if ($board->use_file_upload) { $this->createAttachmentsForPost($board->id, $postId, $user->id); } $postCount++; // ③ 블라인드 + 이미지 있음 $blindedAt = now()->subDays(1); $postId = $insertPost([ 'title' => '블라인드 처리된 게시글', 'content' => '

이 게시글은 블라인드 처리되었습니다.

', 'user_id' => $user->id, 'status' => 'blinded', 'action_logs' => json_encode([[ 'action' => 'blind', 'reason' => '커뮤니티 가이드 위반', 'admin_id' => $adminUser->id, 'admin_name' => $adminUser->name, 'ip_address' => '127.0.0.1', 'created_at' => $blindedAt, ]]), ]); if ($board->use_file_upload) { $this->createAttachmentsForPost($board->id, $postId, $user->id); } $postCount++; // ④ 삭제됨 + 이미지 있음 $deletedAt = now()->subDays(2); $postId = $insertPost([ 'title' => '삭제된 게시글', 'content' => '

이 게시글은 삭제되었습니다.

', 'user_id' => $user->id, 'status' => 'deleted', 'deleted_at' => $deletedAt, 'action_logs' => json_encode([[ 'action' => 'delete', 'reason' => '부적절한 내용', 'admin_id' => $adminUser->id, 'admin_name' => $adminUser->name, 'ip_address' => '127.0.0.1', 'created_at' => $deletedAt, ]]), ]); if ($board->use_file_upload) { $this->createAttachmentsForPost($board->id, $postId, $user->id); } $postCount++; // ⑤ 비밀글 (secret_mode가 disabled가 아닌 경우만) if ($board->secret_mode->value !== 'disabled') { $postId = $insertPost([ 'title' => '비밀글입니다', 'content' => '

비밀글 내용입니다. 작성자 본인과 관리자만 볼 수 있습니다.

', 'user_id' => $user->id, 'is_secret' => true, 'category' => $firstCategory, ]); if ($board->use_file_upload) { $this->createAttachmentsForPost($board->id, $postId, $user->id); } $postCount++; } // ⑥ 원본 게시글 (답글의 부모) $parentPostId = $insertPost([ 'title' => '답글이 달린 게시글', 'content' => '

이 게시글에는 답글이 있습니다.

', 'user_id' => $user->id, 'category' => $firstCategory, ]); if ($board->use_file_upload && rand(1, 2) === 1) { $this->createAttachmentsForPost($board->id, $parentPostId, $user->id); } $postCount++; // ⑥-1 답글 RE (use_reply가 true인 경우) if ($board->use_reply) { $replyCreatedAt = now()->subHours(1); $replyId = DB::table('board_posts')->insertGetId([ 'board_id' => $board->id, 'category' => $firstCategory, 'title' => 'Re: 답글이 달린 게시글', 'content' => '

답글입니다. RE 배지가 표시됩니다.

', 'content_mode' => 'html', 'user_id' => $adminUser->id, 'ip_address' => '127.0.0.1', 'is_notice' => false, 'is_secret' => false, 'status' => 'published', 'trigger_type' => 'admin', 'view_count' => rand(5, 50), 'parent_id' => $parentPostId, 'depth' => 1, 'deleted_at' => null, 'created_at' => $replyCreatedAt, 'updated_at' => $replyCreatedAt, ]); $replyCount++; } // ⑦ 이미지 없는 일반글 $postId = $insertPost([ 'title' => '이미지 없는 게시글', 'content' => '

첨부 이미지가 없는 게시글입니다. 플레이스홀더가 표시됩니다.

', 'user_id' => $user->id, 'category' => $firstCategory, ]); // 첨부파일 미생성 (의도적) $postCount++; // ⑧ 카테고리 있는 글 (카테고리가 있는 게시판만) if ($firstCategory) { $postId = $insertPost([ 'title' => "[$firstCategory] 카테고리가 있는 게시글", 'content' => "

{$firstCategory} 카테고리에 속한 게시글입니다.

", 'user_id' => $user->id, 'category' => $firstCategory, ]); if ($board->use_file_upload) { $this->createAttachmentsForPost($board->id, $postId, $user->id); } $postCount++; } // ⑨ 일반글 + 이미지 있음 (댓글도 포함) $postId = $insertPost([ 'title' => '일반 게시글 (이미지 있음)', 'content' => '

이미지가 첨부된 일반 게시글입니다. 정상적으로 표시됩니다.

', 'user_id' => $user->id, 'view_count' => rand(50, 300), ]); if ($board->use_file_upload) { $this->createAttachmentsForPost($board->id, $postId, $user->id); } if ($board->use_comment) { $commentCreatedAt = now()->subHours(2); DB::table('board_comments')->insert([ 'board_id' => $board->id, 'post_id' => $postId, 'user_id' => $adminUser->id, 'parent_id' => null, 'author_name' => null, 'password' => null, 'content' => '좋은 게시글 감사합니다!', 'is_secret' => false, 'status' => 'published', 'trigger_type' => 'admin', 'depth' => 0, 'ip_address' => '127.0.0.1', 'created_at' => $commentCreatedAt, 'updated_at' => $commentCreatedAt, ]); $commentCreatedAt2 = now()->subHours(1); DB::table('board_comments')->insert([ 'board_id' => $board->id, 'post_id' => $postId, 'user_id' => $user->id, 'parent_id' => null, 'author_name' => null, 'password' => null, 'content' => '저도 좋아요!', 'is_secret' => false, 'status' => 'published', 'trigger_type' => 'admin', 'depth' => 0, 'ip_address' => '127.0.0.1', 'created_at' => $commentCreatedAt2, 'updated_at' => $commentCreatedAt2, ]); } $postCount++; $this->command->info(" - {$board->slug}: 배지 케이스 보장 게시글 {$postCount}개, 답글 {$replyCount}개 생성 완료"); return ['posts' => $postCount, 'replies' => $replyCount, 'comments' => ($board->use_comment ? 2 : 0)]; } /** * 게시글 작성자를 결정합니다. * * notice는 관리자만, 나머지는 일반 사용자 + 관리자/스텝 혼합 * * @param Board $board 게시판 * @param Collection $users 사용자 목록 * @param int $index 현재 인덱스 * @return User 작성자 */ private function pickAuthor(Board $board, $users, int $index): User { // notice 게시판은 관리자만 작성 if ($board->slug === 'notice') { return $this->adminUser ?? $users[0]; } // 10% 확률로 관리자, 5% 확률로 스텝 $roleRand = rand(1, 100); if ($roleRand <= 10 && $this->adminUser) { return $this->adminUser; } if ($roleRand <= 15 && $this->stepUser) { return $this->stepUser; } return $users[$index % count($users)]; } /** * 게시글에 대한 답변글을 다단계로 생성합니다. * * @param Board $board 게시판 * @param int $postId 원본 게시글 ID * @param string|null $category 카테고리 * @param array $template 콘텐츠 템플릿 * @param bool $isSecret 비밀글 여부 * @param Carbon $createdAt 원본 게시글 생성일 * @param Collection $users 사용자 목록 * @return int 생성된 답변글 수 */ private function createRepliesForPost( Board $board, int $postId, ?string $category, array $template, bool $isSecret, Carbon $createdAt, $users ): int { // inquiry 게시판은 95% 확률, 나머지는 30% 확률 $replyChance = ($board->slug === 'inquiry') ? 95 : 30; if (rand(1, 100) > $replyChance) { return 0; } $maxDepth = $board->max_reply_depth ?? 1; $replyCount = 0; $currentParentId = $postId; // depth 1 답변글 (관리자 또는 스텝이 답변) $replyUser = ($board->slug === 'inquiry') ? ($this->stepUser ?? $this->adminUser ?? $users[count($users) - 1]) : ($this->adminUser ?? $users[count($users) - 1]); // 부모 글 시각 + (1~72시간) 으로 자식 시각을 미루되, 현재 시각을 절대 넘기지 않는다. // 부모 글이 "오늘 분포" 로 만들어졌을 때 자식이 미래로 밀려 대시보드/목록의 created_at 정렬이 뒤집히는 회귀 방지. $replyCreatedAt = $this->clampToNow($createdAt->copy()->addHours(rand(1, 72))); $answerTemplate = self::ANSWER_TEMPLATES[array_rand(self::ANSWER_TEMPLATES)]; $replyId = DB::table('board_posts')->insertGetId([ 'board_id' => $board->id, 'category' => $category, 'title' => 'Re: '.$template['title'], 'content' => '

'.$answerTemplate.'

추가 문의사항이 있으시면 말씀해주세요.

', 'content_mode' => 'html', 'user_id' => $replyUser->id, 'author_name' => null, 'password' => null, 'ip_address' => '127.0.0.1', 'is_notice' => false, 'is_secret' => $isSecret, 'status' => 'published', 'trigger_type' => 'admin', 'view_count' => rand(0, 50), 'parent_id' => $currentParentId, 'depth' => 1, 'created_at' => $replyCreatedAt, 'updated_at' => $replyCreatedAt, ]); $replyCount++; $currentParentId = $replyId; // depth 2+ 답변글 체인 생성 (40% 확률로 이어짐, max_reply_depth까지) for ($depth = 2; $depth <= $maxDepth; $depth++) { if (rand(1, 100) > 40) { break; } // 짝수 depth는 원글 작성자(재문의), 홀수 depth는 관리자/스텝(재답변) $isUserReply = ($depth % 2 === 0); $depthUser = $isUserReply ? $users[rand(0, count($users) - 1)] : ($this->stepUser ?? $this->adminUser ?? $users[count($users) - 1]); $depthCreatedAt = $this->clampToNow($replyCreatedAt->copy()->addHours(rand(1, 48))); $nestedTemplates = self::NESTED_ANSWER_TEMPLATES[$depth] ?? self::NESTED_ANSWER_TEMPLATES[3] ?? self::ANSWER_TEMPLATES; $nestedContent = $nestedTemplates[array_rand($nestedTemplates)]; $prefix = $isUserReply ? 'Re: ' : 'Re: [답변] '; $newReplyId = DB::table('board_posts')->insertGetId([ 'board_id' => $board->id, 'category' => $category, 'title' => $prefix.$template['title'], 'content' => '

'.$nestedContent.'

', 'content_mode' => 'html', 'user_id' => $depthUser->id, 'author_name' => null, 'password' => null, 'ip_address' => '127.0.0.1', 'is_notice' => false, 'is_secret' => $isSecret, 'status' => 'published', 'trigger_type' => 'admin', 'view_count' => rand(0, 30), 'parent_id' => $currentParentId, 'depth' => $depth, 'created_at' => $depthCreatedAt, 'updated_at' => $depthCreatedAt, ]); $replyCount++; $currentParentId = $newReplyId; $replyCreatedAt = $depthCreatedAt; } return $replyCount; } /** * 게시글에 대한 댓글을 다단계로 생성합니다. * * @param Board $board 게시판 * @param int $postId 게시글 ID * @param Carbon $createdAt 게시글 생성일 * @param Collection $users 사용자 목록 * @param int $postIndex 게시글 인덱스 (날짜 분포 결정용) * @param int $todayCount 오늘 게시글 수 * @param int $weekCount 이번주 게시글 수 * @param int $monthCount 이번달 게시글 수 * @param bool $allowGuestComment 비회원 댓글 허용 여부 * @return int 생성된 댓글 수 */ private function createCommentsForPost( Board $board, int $postId, Carbon $createdAt, $users, int $postIndex, int $todayCount, int $weekCount, int $monthCount, bool $allowGuestComment ): int { $commentCount = 0; $maxCommentDepth = $board->max_comment_depth ?? 1; // 댓글 수: 축소된 범위 $commentCountForPost = match (true) { $postIndex < $todayCount => rand(0, 3), $postIndex < $todayCount + $weekCount => rand(1, 5), $postIndex < $todayCount + $weekCount + $monthCount => rand(2, 8), default => rand(3, 10), }; for ($j = 0; $j < $commentCountForPost; $j++) { // 비회원 댓글 - 권한 확인 후 30% 확률 $isGuestComment = $allowGuestComment && rand(1, 10) <= 3; // 작성자: 일부는 관리자/스텝 $commentUser = $this->pickCommentAuthor($users); // 댓글은 게시글보다 0~7일 후 작성하되, 현재 시각을 넘지 않도록 clamp. $commentCreatedAt = $this->clampToNow($createdAt->copy()->addHours(rand(1, 168))); $commentId = DB::table('board_comments')->insertGetId([ 'board_id' => $board->id, 'post_id' => $postId, 'user_id' => $isGuestComment ? null : $commentUser->id, 'parent_id' => null, 'author_name' => $isGuestComment ? '익명'.rand(1, 100) : null, 'password' => $isGuestComment ? $this->getGuestPassword() : null, 'content' => self::COMMENT_TEMPLATES[array_rand(self::COMMENT_TEMPLATES)], 'is_secret' => false, 'status' => 'published', 'trigger_type' => 'admin', 'depth' => 0, 'ip_address' => '127.0.0.1', 'created_at' => $commentCreatedAt, 'updated_at' => $commentCreatedAt, ]); $commentCount++; // 다단계 대댓글 체인 생성 (50% 확률로 시작, depth별 감소) if (rand(0, 1) === 1 && $maxCommentDepth > 0) { $commentCount += $this->createNestedComments( $board, $postId, $commentId, $commentCreatedAt, $users, $maxCommentDepth ); } } return $commentCount; } /** * 다단계 대댓글 체인을 생성합니다. * * @param Board $board 게시판 * @param int $postId 게시글 ID * @param int $parentCommentId 부모 댓글 ID * @param Carbon $parentCreatedAt 부모 댓글 생성일 * @param Collection $users 사용자 목록 * @param int $maxDepth 최대 depth * @return int 생성된 대댓글 수 */ private function createNestedComments( Board $board, int $postId, int $parentCommentId, Carbon $parentCreatedAt, $users, int $maxDepth ): int { $count = 0; $currentParentId = $parentCommentId; $currentCreatedAt = $parentCreatedAt; // depth 1부터 maxDepth까지 체인 생성 (depth가 깊어질수록 확률 감소) for ($depth = 1; $depth <= $maxDepth; $depth++) { // depth별 생성 확률: depth 1=60%, depth 2=40%, depth 3+=25% $chance = match (true) { $depth === 1 => 60, $depth === 2 => 40, default => 25, }; if (rand(1, 100) > $chance) { break; } $replyUser = $this->pickCommentAuthor($users); $replyCreatedAt = $this->clampToNow($currentCreatedAt->copy()->addHours(rand(1, 48))); // depth별 템플릿 선택 $templates = self::NESTED_REPLY_TEMPLATES[$depth] ?? self::NESTED_REPLY_TEMPLATES[3] ?? self::NESTED_REPLY_TEMPLATES[1]; $content = $templates[array_rand($templates)]; $newCommentId = DB::table('board_comments')->insertGetId([ 'board_id' => $board->id, 'post_id' => $postId, 'user_id' => $replyUser->id, 'parent_id' => $currentParentId, 'author_name' => null, 'password' => null, 'content' => $content, 'is_secret' => false, 'status' => 'published', 'trigger_type' => 'admin', 'depth' => $depth, 'ip_address' => '127.0.0.1', 'created_at' => $replyCreatedAt, 'updated_at' => $replyCreatedAt, ]); $count++; $currentParentId = $newCommentId; $currentCreatedAt = $replyCreatedAt; } return $count; } /** * 댓글 작성자를 결정합니다. * * 8% 관리자, 5% 스텝, 나머지 일반 사용자 * * @param Collection $users 사용자 목록 * @return User 작성자 */ private function pickCommentAuthor($users): User { $roleRand = rand(1, 100); if ($roleRand <= 8 && $this->adminUser) { return $this->adminUser; } if ($roleRand <= 13 && $this->stepUser) { return $this->stepUser; } return $users[rand(0, count($users) - 1)]; } /** * 게시판의 secret_mode에 따라 비밀글 여부를 결정합니다. * * @param Board $board 게시판 * @return bool 비밀글 여부 */ private function determineIsSecret(Board $board): bool { return match ($board->secret_mode->value) { 'always' => true, // 항상 비밀글 (inquiry) 'disabled' => false, // 절대 비밀글 아님 (notice, gallery, members) 'enabled' => rand(1, 5) === 1, // 20% 확률 (free, qna) default => false, }; } /** * 날짜 분포에 따른 created_at을 생성합니다. * * 날짜 포맷 표시 구간을 모두 커버하도록 분포: * - 방금 전 (30초 이내) * - 1~9분 전 (정확한 분 표시) * - 10~50분 전 (10분 단위 내림 표시) * - 1~23시간 전 * - 1~29일 전 (표준형: MM-DD, 유동형: N일 전) * - 1~11개월 전 (유동형: N개월 전) * - 1~3년 전 (표준형: YY-MM-DD, 유동형: N년 전) * * @param int $index 현재 인덱스 * @param int $todayCount 오늘 게시글 수 (방금~24시간 전) * @param int $weekCount 이번주 게시글 수 (1~7일 전) * @param int $monthCount 이번달 게시글 수 (8~30일 전) * @return Carbon 생성일 */ private function generateCreatedAt(int $index, int $todayCount, int $weekCount, int $monthCount): Carbon { if ($index < $todayCount) { // 오늘: 방금 전 ~ 23시간 전 (모든 분/시간 구간 커버) $segment = $index % 6; return match ($segment) { 0 => now()->subSeconds(rand(0, 30)), // 방금 전 (30초 이내) 1 => now()->subMinutes(rand(1, 9)), // 1~9분 전 2 => now()->subMinutes(rand(10, 19)), // 10~19분 → 10분 전 표시 3 => now()->subMinutes(rand(20, 59)), // 20~59분 → 20~50분 전 표시 4 => now()->subHours(rand(1, 11)), // 1~11시간 전 default => now()->subHours(rand(12, 23)), // 12~23시간 전 }; } elseif ($index < $todayCount + $weekCount) { // 이번주: 1~7일 전 return now()->subDays(rand(1, 7))->subHours(rand(0, 23)); } elseif ($index < $todayCount + $weekCount + $monthCount) { // 이번달: 8~30일 전 (유동형 N일 전, 표준형 MM-DD) return now()->subDays(rand(8, 30)); } else { // 오래된 글: 1개월~3년 전 (유동형 N개월/N년, 표준형 YY-MM-DD) $segment = $index % 3; return match ($segment) { 0 => now()->subMonths(rand(1, 5)), // 1~5개월 전 1 => now()->subMonths(rand(6, 11)), // 6~11개월 전 default => now()->subYears(rand(1, 3)), // 1~3년 전 }; } } /** * Carbon 시각이 현재 시각을 넘으면 현재 시각으로 clamp 합니다. * * 답글/댓글 시드가 부모의 created_at + addHours() 로 자식 시각을 미루는 구조라 * 부모가 "오늘" 분포로 만들어진 경우 자식이 미래로 밀려 들어가 대시보드/목록 정렬을 뒤집는 회귀가 생긴다. * 이 헬퍼로 모든 자식 시각의 상한을 now() 로 잡아 미래 created_at 발생 자체를 차단한다. * * @param Carbon $time 계산된 자식 시각 * @return Carbon 현재 시각 이하의 시각 */ private function clampToNow(Carbon $time): Carbon { $now = now(); return $time->greaterThan($now) ? $now : $time; } /** * 게시판이 비회원 글쓰기를 허용하는지 확인합니다. * * @param Board $board 게시판 * @return bool 비회원 글쓰기 허용 여부 */ private function boardAllowsGuestWrite(Board $board): bool { $permission = Permission::where( 'identifier', "sirsoft-board.{$board->slug}.posts.write" )->first(); if (! $permission) { return false; } // 역할이 없으면 전체 허용 if ($permission->roles()->count() === 0) { return true; } return $permission->roles()->where('identifier', 'guest')->exists(); } /** * 게시판이 비회원 댓글을 허용하는지 확인합니다. * * @param Board $board 게시판 * @return bool 비회원 댓글 허용 여부 */ private function boardAllowsGuestComment(Board $board): bool { if (! $board->use_comment) { return false; } $permission = Permission::where( 'identifier', "sirsoft-board.{$board->slug}.comments.write" )->first(); if (! $permission) { return false; } // 역할이 없으면 전체 허용 if ($permission->roles()->count() === 0) { return true; } return $permission->roles()->where('identifier', 'guest')->exists(); } /** * 비회원 비밀번호 해시를 반환합니다 (캐싱). * * bcrypt 반복 호출로 인한 메모리 소모를 방지합니다. * * @return string bcrypt 해시 */ private function getGuestPassword(): string { if ($this->guestPasswordHash === null) { $this->guestPasswordHash = bcrypt('1234'); } return $this->guestPasswordHash; } /** * 첨부파일 인덱스 (샘플 이미지를 순환하며 사용) */ private int $attachmentIndex = 0; /** * 게시글에 첨부파일을 생성합니다. * * 미리 생성한 5개의 샘플 이미지 파일을 돌려가면서 사용합니다. * * @param int $boardId 게시판 ID * @param int $postId 게시글 ID * @param int|null $userId 사용자 ID */ private function createAttachmentsForPost(int $boardId, int $postId, ?int $userId): void { // 첨부파일 개수 랜덤 (1~3개) $attachmentCount = rand(1, 3); for ($i = 0; $i < $attachmentCount; $i++) { try { // 미리 생성된 샘플 이미지 중 하나 선택 (순환) $sampleImage = $this->sampleImages[$this->attachmentIndex % self::SAMPLE_IMAGE_COUNT]; $this->attachmentIndex++; $meta = json_encode([ 'width' => $sampleImage['width'], 'height' => $sampleImage['height'], ]); // DB에 첨부파일 레코드 생성 (샘플 파일 경로 사용) DB::table('board_attachments')->insert([ 'board_id' => $boardId, 'post_id' => $postId, 'hash' => Str::random(12), 'original_filename' => 'sample-image-'.rand(1, 1000).'.jpg', 'stored_filename' => $sampleImage['filename'], 'disk' => 'modules', 'path' => $sampleImage['path'], 'mime_type' => 'image/jpeg', 'size' => $sampleImage['size'], 'collection' => 'attachments', 'order' => $i + 1, 'meta' => $meta, 'created_by' => $userId, 'created_at' => now(), 'updated_at' => now(), ]); } catch (\Exception $e) { // 오류 발생 시 스킵하고 계속 진행 $this->command->warn(" - 첨부파일 레코드 생성 실패 (Post {$postId}): {$e->getMessage()}"); continue; } } } /** * 샘플 이미지 생성 (GD 라이브러리 사용) * * @param int $width 이미지 너비 * @param int $height 이미지 높이 * @return string JPEG 이미지 바이너리 */ private function createSampleImage(int $width, int $height): string { // GD 라이브러리로 간단한 그라데이션 이미지 생성 $image = imagecreatetruecolor($width, $height); // 랜덤 색상으로 그라데이션 배경 $startColor = [rand(50, 200), rand(50, 200), rand(50, 200)]; $endColor = [rand(50, 200), rand(50, 200), rand(50, 200)]; for ($y = 0; $y < $height; $y++) { $ratio = $y / $height; $r = (int) ($startColor[0] + ($endColor[0] - $startColor[0]) * $ratio); $g = (int) ($startColor[1] + ($endColor[1] - $startColor[1]) * $ratio); $b = (int) ($startColor[2] + ($endColor[2] - $startColor[2]) * $ratio); $color = imagecolorallocate($image, $r, $g, $b); imageline($image, 0, $y, $width, $y, $color); } // 텍스트 추가 $textColor = imagecolorallocate($image, 255, 255, 255); $text = 'Sample Image'; $fontSize = 5; $textWidth = imagefontwidth($fontSize) * strlen($text); $textHeight = imagefontheight($fontSize); $x = ($width - $textWidth) / 2; $y = ($height - $textHeight) / 2; imagestring($image, $fontSize, (int) $x, (int) $y, $text, $textColor); // JPEG로 출력 ob_start(); imagejpeg($image, null, 85); $imageContent = ob_get_clean(); imagedestroy($image); return $imageContent; } }