로그인 회원가입

솔루션소개

xZine 개발실

thumb

웹진관리 확장: 구독관리 + 웹진발송

현재 관리자모드 좌측 메뉴의 "웹진관리"는 웹진 콘텐츠 제작(카테고리/웹진 CRUD, WebzineAdminController, views/admin/webzine_list.php)만 담당한다. 여기에 이메일 뉴스레터 기능(구독자 관리 + 발송)을 추가해, 방문자가 웹진 하단에서 구독 신청/취소를 할 수 있고, 관리자는 구독자를 관리하며 특정 웹진을 구독자들에게 메일로 발송할 수 있게 한다. 프로젝트는 프레임워크 없는 커스텀 라우터(public/index.php의 flat if-elseif 체인) + PDO 직접 사용 구조이며, 메일 발송 관련 코드/라이브러리는 전무하다(Composer/vendor 없음). 사용자 확인 사항: 발송 이메일 본문은 웹진의 기존 썸네일 이미지 1장(이미지 클릭 시 새 창으로 실제 웹진 페이지 이동) + 하단에 텍스트 박스(구독안내/구독취소 안내)로 구성. 캔버스 디자인 전체를 이메일 HTML로 재현하지 않는다. 발송은 PHP 기본 mail() 함수로 처리(신규 라이브러리 도입 없음). 환경설정 화면에 "보내는 이메일 화이트리스트 등록 안내" 문구를 안내용으로 표시. 1. DB 스키마 (database/migration_webzine_newsletter.php, 기존 database/migration_webzine.php 스타일의 단독 실행 스크립트)   CREATE TABLE webzine_subscriber_groups ( id INT AUTO_INCREMENT PRIMARY KEY, tenant_id INT NOT NULL, name VARCHAR(100) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE ); -- webzine_categories와 동일한 형태(단일 FK, 그룹 없이도 동작)   CREATE TABLE webzine_subscribers ( id INT AUTO_INCREMENT PRIMARY KEY, tenant_id INT NOT NULL, group_id INT NULL, name VARCHAR(100) NOT NULL, phone VARCHAR(30) NULL, email VARCHAR(190) NOT NULL, source ENUM('request','collected') DEFAULT 'request', -- 구분: 구독신청/수집 status ENUM('active','cancelled') DEFAULT 'active', -- 상태: 구독중/구독취소 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uniq_tenant_email (tenant_id, email), FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, FOREIGN KEY (group_id) REFERENCES webzine_subscriber_groups(id) ON DELETE SET NULL );   CREATE TABLE webzine_newsletter_settings ( id INT AUTO_INCREMENT PRIMARY KEY, tenant_id INT NOT NULL UNIQUE, sender_name VARCHAR(100) NULL, sender_email VARCHAR(190) NULL, subject_format VARCHAR(255) NULL, -- 예: "[{사이트명}] {제목}" footer_text TEXT NULL, -- 웹진 하단 노출 문구 (미입력 시 구독 박스 미노출) created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE );   CREATE TABLE webzine_sends ( id INT AUTO_INCREMENT PRIMARY KEY, tenant_id INT NOT NULL, webzine_id INT NOT NULL, title VARCHAR(255) NOT NULL, -- 발송제목 sent_by_name VARCHAR(100) NULL, -- 로그인 담당자 스냅샷 recipient_count INT DEFAULT 0, success_count INT DEFAULT 0, fail_count INT DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 발송일 FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, FOREIGN KEY (webzine_id) REFERENCES webzines(id) ON DELETE CASCADE );   CREATE TABLE webzine_send_recipients ( id INT AUTO_INCREMENT PRIMARY KEY, send_id INT NOT NULL, subscriber_id INT NULL, group_name VARCHAR(100) NULL, name VARCHAR(100) NOT NULL, phone VARCHAR(30) NULL, email VARCHAR(190) NOT NULL, status ENUM('sent','failed') DEFAULT 'sent', error_message VARCHAR(255) NULL, FOREIGN KEY (send_id) REFERENCES webzine_sends(id) ON DELETE CASCADE ); 구독자는 그룹 1개만 소속(단순 FK, webzine_categories/webzines 관계 재사용). email에 tenant 단위 UNIQUE를 걸어 공개 구독신청 시 upsert(재구독 시 status만 되돌림)가 가능하게 한다. 2. 좌측 메뉴 구조 변경 (views/admin/layout_header.php:350) 현재 단일 <a class="nav-item">웹진관리</a> 를, 이미 존재하는 게시판관리/쇼핑몰관리와 동일한 .nav-group / .nav-item-parent / .nav-submenu 패턴으로 교체: 웹진관리 (기존 admin/webzine 그대로 연결) 구독관리 (admin/webzine/subscribers) 웹진발송 (admin/webzine/send) isActive('webzine', $curr)가 세 URL 모두를 이미 포함하므로 부모 active 판정에 그대로 재사용 가능. 3. 라우팅 추가 (public/index.php, 기존 webzine 라우트 블록 근처 482줄 부근) 관리자용 (신규 컨트롤러 App\Controllers\NewsletterAdminController): admin/webzine/subscribers → subscribers() (검색+목록, ?sub=list|groups) admin/webzine/subscribers/save → subscriberSave() (등록/수정, POST) admin/webzine/subscribers/delete/(\d+) → subscriberDelete($id) admin/webzine/subscribers/group-save → groupSave() admin/webzine/subscribers/group-delete/(\d+) → groupDelete($id) admin/webzine/send → send() (?tab=compose|history|settings) admin/webzine/send/settings-save → sendSettingsSave() admin/webzine/send/execute → sendExecute() (AJAX, JSON in/out — init/batch/finish 액션) 공개용 (WebzineController에 메서드 추가): webzine/subscribe → subscribe() (POST, 구독 신청) webzine/unsubscribe → unsubscribe() (POST, 구독 취소) 4. 컨트롤러 설계 NewsletterAdminController (신규, WebzineAdminController 생성자와 동일한 레벨 체크 >= 5 복제) subscribers(): $_GET의 name/phone/email/group_id 조건으로 WHERE tenant_id=? + 동적 AND 절 구성 (TenantAdminController::members() 패턴 재사용, %like% 바인딩). 상단에 COUNT(*) 전체 건수, 그룹 목록(select용) 조회. sub=groups일 땐 webzine_subscriber_groups 목록 렌더(카테고리 관리 탭과 동일 구조). subscriberSave(): id 유무로 insert/update. 신규 등록 시 source='collected'(관리자 직접 등록), 이메일 중복 시 에러 리다이렉트. subscriberDelete($id): tenant 스코프 delete. groupSave() / groupDelete($id): webzine_subscriber_groups CRUD (webzine 카테고리 저장/삭제 메서드 그대로 패턴 복제). send(): tab=compose(기본): 발행된 웹진 목록(select), 구독자 검색+목록(그룹/성명/전화/이메일 + "구독취소자 제외" 체크박스, 기본 체크 → status='active'만), 전체 건수. tab=history: webzine_sends 목록(제목/날짜/건수) 좌측, ?id=로 선택된 발송의 webzine_send_recipients 상세를 우측에 렌더. tab=settings: webzine_newsletter_settings 조회(없으면 빈 폼 기본값), 화이트리스트 안내 정적 텍스트 블록 포함. sendSettingsSave(): upsert webzine_newsletter_settings (insert-or-update, TenantSetting::updateSettings 패턴처럼 존재 여부 확인 후 분기). sendExecute(): JSON 액션 3종, 세션 인증 재확인 후 처리 action=init: webzine_id, title, subscriber_ids[] 받아 유효성 확인(웹진 존재+thumbnail_path 필수 — 없으면 에러 JSON) 후 webzine_sends insert, send_id+총 건수 반환. action=batch: send_id + 이번 배치 subscriber_ids[] 받아 각 구독자에게 mail() 발송(제목은 subject_format 치환, 본문은 아래 5번 템플릿), 결과를 webzine_send_recipients에 insert, 배치별 성공/실패 카운트 JSON 반환. action=finish: webzine_sends의 recipient_count/success_count/fail_count 합계 업데이트. 배치 크기는 서버가 아닌 클라이언트가 나누어 호출(예: 15건씩) — PHP max_execution_time 이슈 회피, 프론트에 진행률(N/전체) 표시. 메일 발송 헬퍼는 NewsletterAdminController 내 private 메서드 sendOne($settings, $webzine, $subscriber)로 구현: mail($to, $subject, $htmlBody, $headers), Content-Type: text/html; charset=UTF-8, From: {sender_name} <{sender_email}> 헤더 구성. WebzineController 추가 메서드 subscribe(): POST(name/phone/email/agree) → webzine_subscribers에 INSERT ... ON DUPLICATE KEY UPDATE name=?, phone=?, status='active' (tenant_id+email UNIQUE 활용), source='request'. JSON 또는 리다이렉트+플래시 메시지 반환(모달에서 fetch 사용 예정이므로 JSON 응답 채택, BaseController::json() 재사용). unsubscribe(): POST(name/email) → 일치하는 구독자 status='cancelled'로 UPDATE, JSON 응답. 5. 공개 화면 (구독 신청/취소 박스 + 발송 이메일) 웹진 하단 구독 박스 — templates/dark_blue/webzine/view.php canvas-wrapper 이후, 댓글 섹션 이전에 삽입. 컨트롤러(WebzineController::view)에서 webzine_newsletter_settings를 조회해 newsletterSettings 로 뷰에 전달, footer_text가 있을 때만 박스 렌더: 박스 안에 footer_text 텍스트 + "웹진구독"/"구독취소" 두 버튼. 두 모달은 webzine_list.php의 .modal-overlay/.modal-content CSS를 그대로 재사용(신규 스타일 불필요). 웹진구독 모달: 성명/전화번호/이메일 입력 + "신청한 정보는 웹진 구독신청용으로만 사용됩니다." 안내문 + 이용동의 체크박스(필수) + 확인/취소. 확인 시 fetch(TENANT_BASE_URL + '/webzine/subscribe', {method:'POST'}) → 성공 시 alert 후 모달 닫기. 구독취소 모달: 성명/이메일 입력 + 확인/취소. 확인 시 fetch(.../webzine/unsubscribe) → "구독취소 신청되었습니다" alert. 발송 메일 본문 (PHP로 생성하는 순수 HTML 문자열, 신규 뷰 파일 불필요 — 컨트롤러 내 템플릿 문자열)   <a href="{TENANT_BASE_URL}/webzine/view/{id}" target="_blank"> <img src="{절대경로 thumbnail_path}" style="max-width:100%;"> </a> <div style="margin-top:20px; padding:15px; border:1px solid #ddd;"> {footer_text} </div> 발송 전 선택 웹진에 thumbnail_path가 없으면 컴포즈 화면에서 발송 버튼 비활성화 + 안내 문구("웹진관리에서 썸네일을 먼저 등록해주세요"). 6. 관리자 화면 (신규 뷰) views/admin/webzine_subscribers.php: webzine_list.php의 tab(.board-tabs)/modal 패턴 재사용. 목록 tab: 검색폼(성명/전화/이메일/그룹 select) GET + 우상단 "전체 N건" / "그룹관리"(→?sub=groups) 버튼. 테이블: No/성명/전화번호/이메일/신청일/구분(뱃지)/상태(뱃지)/수정/삭제. 수정은 webzine_list.php의 openManageModal 패턴처럼 모달에 값 채워 subscribers/save로 POST. 그룹관리 tab: webzine_list.php의 카테고리관리 tab 그대로 복제(이름 입력+추가, 목록+삭제). views/admin/webzine_send.php: .board-tabs로 발송/발송내역/환경설정 3탭. 발송 tab: 발송제목/발송일(오늘 date input)/로그인 담당자(readonly, 세션 닉네임) + 웹진선택 select(발행된 웹진만, 옆에 "미리보기" 버튼 window.open(webzine view url)) + 구독자 검색폼(그룹/성명/전화/이메일 + 구독취소자 제외 체크, 기본 체크) + 체크박스 목록(posts.php의 checkAll/.post-checkbox 패턴 재사용, name=subscriber_ids[]) + 하단 전체선택/전체취소(선택 초기화) 버튼 + 우측 "[웹진발송]" 버튼. 발송 진행: 버튼 클릭 시 확인창 → JS가 sendExecute action=init 호출 → 진행률 모달(모달 오버레이 재사용, 프로그레스 바 + "N / 전체 건 발송중...") 표시하며 action=batch 반복 호출 → 완료 시 action=finish 호출 후 결과 요약(성공/실패 건수) 표시, "발송내역 보기" 링크 제공. 발송내역 tab: 좌측 카드형 목록(제목/날짜/건수, 클릭 시 ?tab=history&id=N), 우측에 선택된 발송의 수신자 상세 테이블(그룹명/성명/전화/이메일/상태). 환경설정 tab: 보내는사람/보내는이메일/제목형식/웹진하단문구 입력 폼 + "보내는 이메일 화이트리스트 등록 안내" 정적 안내 박스(각 메일 서비스에 발신 주소를 스팸함으로 분류되지 않도록 등록해두라는 안내 문구, 편집 불가 텍스트). 검증 방법 PHP 문법 오류 없는지 php -l로 신규/수정 파일 확인. XAMPP 로컬 서버로 관리자 로그인 후: 좌측 메뉴에 웹진관리 하위 3개 메뉴가 정상 노출/이동되는지 확인. 구독관리에서 그룹 추가 → 구독자 수동 등록(수집) → 검색/수정/삭제 동작 확인. 웹진 하나에 썸네일 지정 → 공개 웹진 페이지 하단 구독 박스 노출 확인(환경설정에 하단문구 저장 후) → 웹진구독 모달로 신규 구독 신청 → 구독관리 목록에 반영되는지 확인 → 구독취소 모달로 취소 → 상태가 "구독취소"로 바뀌는지 확인. 웹진발송에서 환경설정 저장 → 구독중 대상자 선택 → 발송 실행 → 진행률 모달 동작 및 발송내역에 결과 기록되는지 확인(로컬은 mail() 미설정으로 실제 수신은 안 될 수 있음 — DB 기록/실패 카운트 로직 위주로 검증).
작성자 2026.07.09
thumb

웹진 모바일 최적화 구현

완전히 다시 설계했습니다. 기존의 "비율 계산해서 자동으로 늘리고 줄이는" 방식 자체를 걷어내고, PC와 Mobile을 처음부터 끝까지 완전히 분리된 값으로 저장하는 구조로 바꿨습니다. 새 구조 (웹진 + 매거진 에디터 공통): 각 레이어(텍스트/이미지/도형/아이콘/동영상/표/모듈/그룹 전부)마다 PC 전용 스냅샷과 Mobile 전용 스냅샷을 따로 저장합니다 — 위치(top/left), 크기(width/height), 텍스트는 추가로 내용(줄바꿈 포함)·폰트크기·자간·행간까지. PC ↔ Mobile 전환 시: 지금 보이는 상태를 "떠나는 모드"의 스냅샷으로 저장 → 화면 폭 변경 → "들어가는 모드"의 스냅샷이 있으면 그대로 복원, 없으면 지금 상태 그대로 둠(반응형 기본값 유지). 즉 Mobile에서 도형 크기·위치·텍스트 줄바꿈을 직접 수정하면, 그 값은 PC 값과 완전히 무관하게 별도로 기억되고, PC로 돌아가도 PC 값은 전혀 건드려지지 않습니다. 말씀하신 "이중 작업"이 정확히 이렇게 동작하게 됩니다. 기존에 있던 "폭 비율만큼 도형 높이를 자동 계산해서 늘리는" 로직은 완전히 제거했습니다 — 이게 바로 "엉뚱하게 바뀌는" 원인이었어서, 이제는 자동 계산 없이 각 모드에서 마지막으로 사람이 설정한 값을 그대로 씁니다. 저장 버튼을 누르면, 지금 보고 있는 모드의 상태를 먼저 스냅샷으로 확정한 뒤 저장하므로 모드를 안 바꾸고 바로 저장해도 최신값이 반영됩니다. 공개 웹진 페이지도 동일한 방식으로 변경: 화면 폭이 768px 이하면 저장된 Mobile 스냅샷을, 아니면 PC 스냅샷을 적용합니다. 방문자가 창 크기를 바꾸거나 폰을 회전해도 실시간으로 재적용됩니다. (매거진은 아직 공개 뷰어가 없어 에디터 쪽만 동일하게 적용됨.)
작성자 2026.07.08
thumb

웹진 편집기 소소한 버그 수정

완료된 것: ✅ 디바이스 모드(PC/iPad/Mobile) 전환 시 도형(원형 등) 비율 왜곡 — 폭 비율만큼 높이도 같이 조정하도록 수정 ✅ 새 모듈 추가 시 게시물 모듈 뒤로 숨는 문제 — 모든 레이어에 명시적 z-index를 부여해 항상 마지막 추가한 게 최상단에 오도록 수정 (기존 "맨 앞/뒤로" 레이어 순서 버튼도 z-index와 어긋나지 않게 같이 손봄) ✅ Ctrl+G 그룹 박스에 좌측 상단 그룹 아이콘 추가 — 클릭하면 그룹 해제 (기존 저장된 웹진/매거진의 그룹에도 소급 적용) ✅ 미리보기/저장 버튼을 상단 PC/iPad/Mobile 아이콘 옆으로 이동 (매거진 에디터는 이미 이렇게 되어 있어서, 그 구조를 웹진 에디터에도 맞춤) 4번(모바일 최적화)에서 발견한 문제:에디터의 "Mobile" 미리보기와 실제 공개된 웹진 페이지의 모바일 화면이 서로 다른 방식으로 동작하고 있어요. 에디터 안에서 Mobile 버튼을 누르면 캔버스 자체가 375px로 좁아지고 요소들이 그 안에서 재배치됩니다 (텍스트 폰트 크기는 그대로 유지). 실제 방문자가 폰으로 볼 때는 그게 아니라, 900px짜리 데스크톱 캔버스 전체를 사진처럼 통째로 축소(transform: scale)해서 보여주고 있어요. 텍스트도 같이 비율로 줄어듭니다. 즉 지금 에디터에서 "모바일에서 이렇게 보이겠구나" 하고 확인하는 화면이, 실제 방문자가 폰으로 보는 화면과 다릅니다. 그래서 "모바일에서 폰트 크기를 조정하면 그게 유지되게" 기능   --- 완료 (웹진 + 매거진 에디터 공통 적용): 도형 비율 왜곡 — PC/iPad/Mobile 전환 시 폭이 바뀌는 비율만큼 도형(원형 등)의 높이도 같이 조정해 찌그러지지 않게 함 새 모듈이 게시물 뒤로 가는 문제 — 모든 레이어에 명시적 z-index를 부여해 항상 마지막에 추가한 게 최상단에 오도록 수정. 기존 "맨 앞/뒤로" 레이어 순서 버튼도 z-index 체계와 충돌하지 않게 같이 손봄 그룹 아이콘 — Ctrl+G로 그룹 묶으면 좌측 상단에 작은 아이콘이 생기고, 클릭하면 그룹 해제. 기존에 저장된 웹진/매거진의 그룹에도 소급 적용됨 모바일 전용 스타일 분리 — 아래 상세 설명 미리보기/저장 버튼 위치 — 상단 PC/iPad/Mobile 아이콘 옆으로 이동 (매거진 에디터는 이미 그렇게 되어 있어서, 웹진 에디터를 거기 맞춤) 4번 상세: 텍스트 요소의 폰트 크기/자간/행간을 Mobile 모드에서 조정하면 data-mobile-size/ls/lh라는 별도 속성에 저장되고, PC 모드로 돌아가면 원래 PC 값이 그대로 유지됩니다. 다시 Mobile로 가면 저장해둔 모바일 값이 적용됩니다. 실제 공개 웹진 페이지(webzine/view.php)도 지금까지의 "900px 캔버스를 사진처럼 통째로 축소" 방식에서, 에디터의 Mobile 미리보기와 동일한 "캔버스 폭 자체가 좁아지며 재배치"되는 방식으로 바꿨고, 폰에서 볼 때(@media max-width:768px) 위에서 저장한 모바일 전용 값이 자동으로 적용되도록 스타일을 주입하게 했습니다. 범위를 폰트 크기/자간/행간으로만 한정했어요 (말씀하신 예시 그대로). 위치·크기(top/left/height)까지 모바일 전용으로 분리하는 건 이번 범위에 넣지 않았습니다. 알아두실 점: 요소들의 세로 위치(top)는 원래 px로 고정돼 있어서, 좁은 화면에서 텍스트가 줄바꿈으로 늘어나면 아래 요소와 겹칠 수 있어요. 샘플 작업하시다가 그런 경우가 보이면, 지금 만든 것과 같은 방식으로 위치/높이도 모바일 전용 오버라이드를 추가하는 게 다음 단계가 될 것 같습니다. 매거진은 아직 공개 뷰어(퍼블릭 페이지)가 없어서, 에디터 쪽 로직만 웹진과 동일하게 맞춰뒀습니다.
작성자 2026.07.08
thumb

xZine 관리자 메뉴 조정 및 마이그레이션 툴 개

1. 관리자 메뉴 메뉴조정 - 좌측 팝업관리는 디자인관리 탭메뉴 배너 관리 다음으로 이동   2. 대화관리는 게시판관리 서브메뉴, 문의/신청 관리 아래로 이동 3. 좌측메뉴 마지막에 최고관리자만 볼 수 있게(테넌트들은 안보이게) - 회원/테넌트 관리, 서브 메뉴로 회원관리, 테넌트 관리 메뉴 추가(기존의 회원관리, 테넌트 관리 연결) - 도구관리 추가, 텝메뉴로 그누보드 마이그레이션, 킴스보드 마이그레이션(추후) 추가 그누보드 마이그레이션은 기존 소스 참고 회원데이터, 게시판별 게시물, 첨부파일 마이그레이션   --- 이번 세션에서 마무리한 내용: 관리자 메뉴 재구성 (이전 완료됨) 팝업관리 → 디자인관리 탭(배너관리 다음) 대화관리 → 게시판관리 서브메뉴(문의/신청관리 아래) 최고관리자(레벨10) 전용: 회원/테넌트 관리, 도구관리 섹션 추가 도구관리 + 그누보드 마이그레이션 (이번에 완성) lab.xzine.kr/src/Core/GnuboardMigrator.php — 참고 소스(cms.4men.kr)의 로직을 우리 실제 스키마(posts, users, comments, post_files, boards)에 맞춰 새로 작성. 그누보드 DB 접속, 게시판 목록/분석, 회원 마이그레이션(비밀번호 비호환 문제는 랜덤 비밀번호 발급 후 재설정 안내로 처리), 게시판별 게시물+댓글+첨부파일 마이그레이션, XSS 위험 태그 제거 및 그누보드 특수문자 변환 포함. lab.xzine.kr/src/Controllers/ToolsAdminController.php — 레벨10 게이트, AJAX 엔드포인트(접속테스트/게시판목록/분석/매핑저장/회원마이그레이션/게시판마이그레이션). lab.xzine.kr/views/admin/tools.php — 탭 UI(그누보드 마이그레이션 실행 화면 + 킴스보드는 "추후" 비활성 탭), 접속정보 입력, 회원 마이그레이션 버튼, 게시판별 분석/매핑/마이그레이션 실행 UI, 로그 출력창. public/index.php에 /admin/tools 및 하위 6개 AJAX 라우트 등록. 모든 파일 php -l 문법 검사 통과, /admin/tools 라우트가 레벨10 미만 세션에서 정상적으로 302 리다이렉트되는 것도 확인했습니다(실제 그누보드 원본 DB가 없어 마이그레이션 실행 자체는 테스트하지 못했습니다 — 실제 그누보드 DB 접속정보가 있으면 화면에서 바로 테스트 가능합니다).
작성자 2026.07.08
0
웹장이 1:1 상담톡
운영자 부재 중입니다. 대화를 남겨두시면 확인 후 순차 답변 드립니다.