36 Commits
Author SHA1 Message Date
HeuJung cb2b481caf Merge branch 'develop' 2026-07-08 13:55:16 +09:00
HeuJung 487283b33a fix(core): core:update 증분 모드가 _bundled 확장 파일을 반영하도록 protected 오버라이드
targets 에 명시된 {modules,plugins,templates,lang-packs}/_bundled 가
상위 protected(modules 등)에 걸려 3-way apply 목록에서 제외되던 결함 수정.
targets 가 protected 보다 더 구체적이면 오버라이드해 통과시켜, 코어 업데이트로
번들 확장 갱신 파일(composer.json·vendor-bundle.json 등)이 정상 반영되도록 한다.
자동 발견 폴백의 방어 및 prune 롤백 경로는 불변.

아울러 이슈 템플릿의 사용법 문의 링크를 Discussions 에서 sir.kr Q&A 로 전환.
2026-07-08 13:54:18 +09:00
HeuJung 212ecb3577 Merge branch 'develop' 2026-07-08 12:45:19 +09:00
HeuJung 6acc914100 fix(ecommerce): 버전 bump 시 누락된 vendor-bundle 재생성 (무결성 검증 복구)
에서 이커머스 모듈 버전을 1.0.0 → 1.0.1 로 올리며 composer.json version
필드를 변경했으나 vendor-bundle 을 재생성하지 않아, 번들 기록 해시와 실제
composer.json SHA 가 불일치. VendorIntegrityChecker 가 module:update 를 차단.
module:vendor-bundle 로 재생성해 해시를 재동기화하고 무결성 검증을 복구.
2026-07-08 12:43:22 +09:00
HeuJung e4f5319dad Merge branch 'develop' 2026-07-08 12:26:44 +09:00
HeuJung 683ebdc396 chore(release): 7.0.2 공개 release 누출 검사 대상 주석·시나리오 내부 단서 제거
코어/번들 확장의 주석·테스트·시나리오 yaml 에 남은 내부 역할 호칭("")과
 파일명 참조를 제거해 공개 release 누출 검사(error 0/warn 0)를 통과.
기술적 의미는 그대로 유지하고 비공개 단서만 정리.
2026-07-08 12:26:00 +09:00
HeuJung 3ab879ea9a Merge branch 'develop' 2026-07-08 12:20:22 +09:00
HeuJung e7bb07f80e docs(changelog): 7.0.2 릴리스 대상 코어·확장·언어팩 CHANGELOG 날짜를 출시일(2026-07-08)로 통일
7.0.2 번들과 함께 버전이 상향되는 코어(7.0.2), 모듈(board/ecommerce/page 1.0.1),
템플릿(admin_basic/basic 1.0.1) 및 대응 번들 언어팩의 릴리스 날짜를 오늘로 정렬.
이니시스 결제(pay_kginicis 1.0.0)는 기존 출시일(07-01) 유지.
2026-07-08 11:53:40 +09:00
정정홍andGitHub 93f6bd5b71 Merge pull request from gnuboard:HeuJung/issue452
feat(core): core:update 기본을 코어 변경분만 적용(3-way skip)으로 전환
2026-07-08 10:21:41 +09:00
HeuJung d0943d6cb6 feat(core): core:update 기본을 코어 변경분만 적용(3-way skip)으로 전환
core:update Step 7 이 targets 전체를 무조건 재복사하고 orphan 을 삭제하여
사용자 .htaccess 커스텀 블록·_bundled 커스텀 확장이 소실되던 사고(공개 )를
차단한다. 백업(base)↔_pending(theirs) 3-way md5 비교로 코어가 실제 변경/추가한
파일만 산출해 적용하고, 코어 미변경 파일은 복사·권한·mtime 을 전부 스킵해 현재
디스크 상태(사용자 수정 포함)를 보존한다. 전체 덮어쓰기 + orphan 정리가 필요하면
--prune 으로 opt-in. 백업 부재(--no-backup) 시 base 가 없어 전체 덮어쓰기로 안전
회귀 + 안내를 출력한다.

기존 stale 정리 테스트는 새 기본(증분)에서 orphan 을 삭제하지 않으므로 prune
컨텍스트로 전환했고, 증분 3-way 판정·목록 적용·fallback 단위 테스트와 --prune
커맨드 표면 테스트를 추가했다.

번들 ja 언어팩은 코어 apply_mode 안내 키(g7-core-ja)와 함께, 같은 빌드에서
동기화된 관리자↔유저 크로스링크 라벨(board/ecommerce/admin_basic/basic)의 일본어
번역을 반영하고 각 패키지 버전·CHANGELOG 를 갱신했다.
2026-07-07 22:45:16 +09:00
정정홍andGitHub efc8a7ae6b Merge pull request from gnuboard:HeuJung/issue450
feat(board,ecommerce,template): 관리자↔유저 크로스 이동 링크 + 상품코드·게시판 슬러그 라우팅 전환
2026-07-07 10:42:23 +09:00
HeuJung 6505d85959 feat(board,ecommerce,template): 관리자↔유저 크로스 이동 링크 + 상품코드·게시판 슬러그 라우팅 전환
관리자·유저 화면 왕복 이동 수단을 추가하고, 상품/게시판 URL 식별자를
사람이 읽을 수 있는 값(상품코드/슬러그)으로 전환한다.

크로스 이동 링크 (전부 새 탭):
- 관리자 사이드바 "사이트 보기" + 모바일 드로어 상단 잘림 수정
- 관리자 게시물 조회 ↔ 유저 게시판, 유저 상품 상세 ↔ 관리자 상품 수정
- 유저 화면의 관리자 진입은 is_admin 이 아니라 세부 권한 플래그로 게이트
 (게시판 can_access_admin, 상품 can_update)

상품 라우팅 id → product_code:
- Product::resolveRouteBinding(code/id) + 공개 라우트 정규식 확장
- 모든 상품 링크(목록·최근본·검색·장바구니·주문내역·문의내역·관리자) 전환
- 상품 검색을 상품코드·번호 양쪽 조회 가능하게 확장(관리자+통합검색)

게시판 라우팅 board id → slug:
- Board::resolveRouteBinding(slug/id) + 관리자 라우트 slug 전환
- 생성 후 리다이렉트·토글·삭제·수정·add-to-menu·편집기 스코프 전수 정정
- BoardController::addToAdminMenu 를 Board 바인딩으로 정합

검증: 프론트 레이아웃 Vitest 36건 green, 백엔드 회귀 테스트 추가,
g7.dev 라이브 실측(생성/삭제/메뉴추가 slug, 문의내역·검색 상품코드 URL).
2026-07-07 10:40:22 +09:00
정정홍andGitHub 32b1b11930 Merge pull request from gnuboard:HeuJung/issue446
fix(installer): Read DB 미사용 시 Write DB만 참조하도록 수정 + GitHub 이슈 템플릿 신설
2026-07-06 17:18:04 +09:00
HeuJung cbc91117d7 fix(installer): Read DB 미사용 시 Write DB만 참조하도록 수정 + GitHub 이슈 템플릿 신설
설치 시 use_read_db=false 인데도 db_read_host 잔존값으로 read 커넥션을
조립해 5단계 마이그레이션이 실패하던 문제를 수정한다. "read 사용 여부"
판정이 세 지점에서 달랐던 것이 근본 원인으로, use_read_db 플래그를 단일
SSoT 로 통일한다.

- installer-runtime.php: read 블록을 use_read_db 게이트로. 미사용 시 runtime
 에 read 키 미생성 → 하류가 write 로 자동 fallback. mergeRuntimeIntoEnv 도
 read 부재 시 DB_READ_* 를 빈 값으로 기록(write 복사 제거).
- config/database.php: read env 를 Elvis(?:) fallback 으로 복원. 중첩
 env(A, env(B)) 는 DB_READ_* 가 빈 문자열일 때 fallback 이 무력화되므로,
 빈 값까지 흡수하는 ?: 를 사용(tinker 실측 확인). 설치 이후 운영 중
 DB_READ_* 공란 시에도 SELECT 가 write DB 로 정확히 향하게 한다.
- functions.php / request-handler.php: read 미사용 시 .env DB_READ_* 빈 값
 기록 + state.json 잔존 read 필드 제거(2단계 방어).

기존 설치 환경은 .env 에 DB_READ_* 가 이미 채워져 Elvis fallback 미발동 →
동작 불변이라 업그레이드 스텝 불필요(실측 확인).

무관 사전 실패 테스트(beta.7 stale-ENV 복구)는 코어 version 이 beta.7 이
아니면 skip 하도록 가드 헬퍼 추가(프로덕션 한시 가드 전제를 테스트에 반영).

자유형식 제보에서 진단 축(버전/활성 확장/커스텀 자산/환경)이 누락되기 쉬워
G7 전용 이슈 템플릿을 추가한다. bug_report / feature_request / upgrade_issue
+ config.yml(blank 비활성, Discussions·보안신고 링크).
2026-07-06 17:15:29 +09:00
정정홍andGitHub 94d2cd2c11 Merge pull request from gnuboard:HeuJung/issue445
HeuJung/issue445
2026-07-06 17:10:22 +09:00
HeuJung 157485003d fix(installer): 요구사항 검증의 디렉토리 권한 재귀 스캔 깊이 제한으로 2단계 지연 해소
인스톨러 2단계(설치 환경 확인)의 checkDirectoryPermissions 가 storage 를
재귀 스캔하는데, checkSubdirectoriesRecursive 가 깊이 제한 없이 무한 재귀하여
storage/framework/testing 하위에 누적된 수만 개 디렉토리를 전부 순회 → 응답이
8초 이상 지연되던 문제.

쓰기 권한 문제는 트리 상위 얕은 깊이에서 결정되므로(상위 쓰기 가능 시 하위 상속),
재귀에 maxDepth(기본 1) 제한을 추가. 기본값이 있어 호출부는 변경 불필요.
실측: checkDirectoryPermissions 8.4초 → 0.086초.
2026-07-06 17:09:42 +09:00
HeuJung 342486a20b fix(installer): Composer 설치 로그 인코딩으로 인한 진행 상태 응답 빈 본문 보완
Composer 실행 모드로 설치할 때 일부 Windows 환경에서 composer 출력의
invalid UTF-8 바이트가 정규화 없이 로그에 저장되어, 진행 상태 응답의
json_encode 가 false 를 반환 → 빈 본문(HTTP 200) → 프론트 파싱 실패로
진행 상황이 표시되지 않던 문제.

- 로그 저장(addLog)에서 mb_scrub 로 항상 유효 UTF-8 보장 (근본)
- composer 스트림 라인 정규화 (SSE/폴링 공통 경계)
- 폴링(state-management)·SSE(progress-emitter) 응답 json_encode 에
 JSON_INVALID_UTF8_SUBSTITUTE + false 가드 (양쪽 안전망)

무관 결함 동시 정리: 신규 격리 테스트가 트리거한 BASE_PATH 누수로
깨지던 DeleteDirectoryTest·InstallerWindowsCommandsTest 를 프로젝트 루트
절대경로 앵커로 격리.

공개 이슈 gnuboard/g7 (@kitrio 제보).
2026-07-06 17:09:41 +09:00
eunhyeandGitHub 5ee1d1f2b5 Merge pull request from gnuboard/chym1217/issue424
fix(page): 페이지 검수 이슈 일괄 수정
2026-07-06 09:54:05 +09:00
chym1217 9d7bccdfb9 chore(page): 코드·테스트·라우트 주석 정리 및 CHANGELOG 날짜 정정
코드/테스트 주석에 남아 있던 내부 이슈번호(-14, -15 등)를
규정에 따라 제거하고, 첨부 라우트 주석의 오해 소지 표현을 정정한다.
1.0.1 릴리스 날짜를 2026-07-06 으로 맞춘다.

- Listener/커맨드 주석, Feature/Unit/레이아웃/E2E 테스트 주석
- 시나리오 매니페스트 설명 (audit:allow 지시어는 보존)
- 첨부 라우트: admin 그룹의 오해성 "단일화" 주석 제거,
 공개 pages 그룹 각 라우트에 게시판과 동일한 방식·사유를 간결히 명시
 (미리보기=공개 이미지, 다운로드=발행 상태 조건부 접근 제어)
- CHANGELOG [1.0.1] 날짜를 2026-07-06 으로 정정
 (page 모듈 / basic·admin_basic 템플릿 / 대응 ja 언어팩 3종)
- 로직·동작 변경 없음
2026-07-06 09:37:07 +09:00
chym1217 155b95989a fix(page): 페이지 관리 화면 UI 정돈 및 모바일 검색·취소 이동 결함 수정
목록·상세·등록/수정 화면의 버튼·선택 상자·카드 여백을 시맨틱 클래스로 통일하고,
검수 중 발견한 동작 결함을 함께 수정한다.

- 모바일 검색이 저장소 불일치(_global 저장 ↔ _local 읽기)로 미작동하던 문제 수정
- 폼 취소 이동을 수정=상세 / 등록=목록으로 명시 (기존 navigateBack 안티패턴 제거)
- 검증 에러 박스 제목 색/굵기·하단 여백, 저장 버튼 시맨틱화(spinner 포함)
- 상세: 첨부 기본 열림 + 항목별 구분선, 다크모드 카드 대비 개선,
 메타 정보 2열 배치, 카드 헤더 통일(이중 여백·구분선 제거), 실패 토스트 실제 메시지 노출
- 언어 탭이 모바일에서 줄바꿈되도록 반응형 처리

레이아웃 렌더 회귀 테스트 113건 통과 + 취소 이동 E2E spec 추가.
2026-07-06 09:37:07 +09:00
chym1217 b98a536162 fix(page): 페이지 첨부 썸네일·다운로드 미노출 수정
썸네일(<img>)·다운로드(<a>)는 브라우저 직접 GET 이라 토큰을 실을 수 없는데
커밋 d48c684 가 첨부 URL 을 auth:sanctum admin 라우트로 바꿔 401 회귀가 났다.
게시판·이커머스 표준으로 정렬한다.

- 썸네일(preview): 공개 hash 서빙으로 단일화 (발행/미발행 무관 200)
- 다운로드: 공개 hash 라우트 + 권한 게이트(발행=누구나 / 미발행=pages.read 관리자만)
- 프론트 다운로드를 downloadAttachment 핸들러(토큰 fetch→blob)로 전환
- 다운로드 아이콘 arrow-down-tray(FA 미존재) → download
- 사용자 페이지에도 이미지 첨부 썸네일 추가 (관리자 상세와 일관)
- admin 첨부 preview/download 데드 라우트·메서드 제거
- iteration 안 정적 id 를 att_idx 동적 id 로 교정 (위생 정리)
2026-07-06 09:37:06 +09:00
chym1217 98989e3c2c fix(page): 첨부 업로드 검증 실패 시 사용자 안내 토스트 노출
- 페이지 폼 FileUploader 에 onUploadError→toast 연결 (용량/확장자 초과 안내)
- 사용자 템플릿(sirsoft-basic) 개수 초과 시 onUploadError 발화 추가 (silent 무시 방지)
- 개수 초과 문구를 이미지 전용에서 일반 파일 범용 공통 메시지로 통일 (ko/en/ja)
2026-07-06 09:37:06 +09:00
chym1217 2473ca7242 fix(page): 버전 복원 후 검색엔진 캐시가 즉시 갱신되도록 after_restore 훅 구독
버전 복원 시 발화되는 after_restore 훅을 SeoPageCacheListener가
구독하지 않아, 복원 후에도 검색봇에 복원 전 버전이 계속 노출되던
문제를 수정. 복원은 콘텐츠 변경의 일종이므로 수정/삭제와 동일한
onPageChange 무효화 정책을 재사용.
2026-07-06 09:37:05 +09:00
chym1217 ba2adcf748 fix(page): 미발행 페이지를 권한 관리자가 사용자 화면에서 미리보도록 수정
미발행 페이지는 관리자여도 사용자 화면(/page/{slug})에 접근할 수 없어
발행 전 미리보기가 불가능했다. 페이지 조회 권한(sirsoft-page.pages.read)
보유 관리자는 미발행 페이지를 사용자 화면에서 미리볼 수 있게 하고, 미리보기
화면에 안내 배너를 노출한다. 비로그인·일반 회원은 기존대로 404를 유지한다.

- getPublishedPageBySlug 에 미리보기 허용 플래그 추가 (권한 판정은 컨트롤러)
- 미발행 미리보기 시 첨부 URL 을 admin 컨텍스트로 전환해 첨부까지 미리보기
- page/show.json 에 미발행 미리보기 안내 배너 + ko/en/ja 다국어
- E2E 시드 커맨드 구현 + 커맨드 등록 + 사용자 화면 미리보기 spec
2026-07-06 09:37:05 +09:00
chym1217 8cf55ad88f fix(page): 버전 이력 변경내역을 다국어 라벨로 표시 + E2E 인프라 신설
- 페이지 상세 버전 이력의 "변경내역"이 title·content 등 영문 필드명으로
 노출되던 문제를 field_labels 다국어 매핑으로 정정 (ko/en/ja)
- sirsoft-page 모듈에 Playwright E2E 인프라를 이커머스와 동일 패턴으로 신설
 (config·fixture·seed stub + spec 3건, data-testid 보강 전까지 skip placeholder)
- 출시된 1.0.0 이후 변경이므로 module 1.0.1 bump (3곳+lock 동기화),
 ja 언어팩 1.0.1 bump
2026-07-06 09:37:04 +09:00
chym1217 4c4f90a091 fix(page): 페이지 목록 검색 범위·페이지당 기본값·긴 검색어 오류 정정
- 검색 조건 '전체'가 슬러그를 검색하도록 통합하고, 검색 대상을 제목·슬러그로
 한정(본문 제외 — UI '제목 또는 슬러그로 검색'과 정합). '제목' 선택 시 제목만 검색.
- 페이지당 표시 개수 기본값을 백엔드도 20으로 통일(화면 표시와 실제 조회 일치).
- 긴 검색어(공백 없는 한글 다수)가 FULLTEXT 토큰 한도를 넘겨 500을 유발하던 문제를
 검색어 100자 제한으로 422 처리. 검증 메시지 필드명을 '검색어'로 노출.
2026-07-06 09:37:03 +09:00
chym1217 bb654fb56c fix(page): 일괄 발행/미발행 시 활동 이력이 기록되도록 after_publish 훅 발화
페이지 목록 일괄 발행/미발행이 after_publish 훅을 발화하지 않아
활동로그에 발행/미발행이 기록되지 않던 문제를 수정한다.

- bulkChangePublishStatus 를 DB::transaction 내부에서 페이지별로
 before_publish -> update -> after_publish 발화하도록 재작성 (per-item 로그)
- 존재하지 않는 id 가 섞이면 전체 롤백 (all-or-nothing)
- 단일 mass UPDATE 전용 bulkUpdatePublished 제거 (dead code, 의존 확장 영향 없음)
- 일괄 발행 성공 응답 메시지의 :count 미치환 정정
 (success 의 4번째 인자 messageParams 로 치환 파라미터 전달)
- 회귀 테스트 추가 (훅 발화 횟수/published 인자/롤백/메시지 치환)
2026-07-06 09:37:02 +09:00
chym1217 0de58baf46 fix(page): 첨부 URL 컨텍스트·업로드 순서·등록폼 잔존·슬러그 미리보기·SEO 캐시 정정
- 관리자 첨부 다운로드/미리보기를 admin 라우트 URL로 분기 (미발행 페이지 첨부를 관리자가 열 수 있도록)
- 다중 업로드 시 올린 순서대로 저장 (이커머스 패턴: temp_key 연결 + form.attachments 동기화, 코어/공용 컴포넌트 무수정)
- 수정→등록 전환 시 이전 첨부·제목·슬러그·검증안내 잔존 차단
 (initialFiles를 _local.form.attachments에 바인딩 + init_actions에서 form null→기본값 초기화)
- 등록 폼 슬러그 미리보기 {{slug}} 미치환 수정 (빈 슬러그를 못 거르던 ?? → || fallback)
- 페이지 변경 시 상세 SEO 캐시 무효화 URL 패턴을 단수형(/page/{slug})으로 정정
2026-07-06 09:37:02 +09:00
chym1217 b03042b6d7 fix(page): 페이지·첨부 삭제를 물리 삭제로 전환해 슬러그 재사용 가능하도록 수정
페이지 삭제 후 동일 슬러그로 재생성 시 "이미 사용 중" 오류가 나던 문제 수정.
슬러그 중복 체크(SoftDeletes 스코프로 삭제 행 제외)와 생성 검증(Rule::unique로
삭제 행 포함)이 soft delete를 다르게 취급해 발생. Page/PageAttachment의
SoftDeletes를 제거하고 deleted_at 컬럼을 마이그레이션으로 정리해 두 경로를 정합화.
2026-07-06 09:37:01 +09:00
정정홍andGitHub d6baa1c299 Merge pull request from gnuboard:HeuJung/issue436
fix(ecommerce): 관리자·유저 구매확정 적립 정합성 및 마일리지 게이트 통일
2026-07-06 09:32:57 +09:00
HeuJung 4a90046d54 fix(ecommerce): 관리자·유저 구매확정 적립 정합성 및 마일리지 게이트 통일
관리자 주문옵션 구매확정 경로가 유저 셀프 확정과 부수효과가 어긋나던
문제를 바로잡고, 검수에서 드러난 적립 금액·기능 토글 결함을 함께 수정한다.

- confirmed_at 대칭화: 관리자 CONFIRMED 전이 시 옵션·주문 헤더 confirmed_at
 기록 + order-option.after_confirm 훅 발화(즉시적립·활동로그·리뷰기한 정상화)
- 나눠 확정 델타 적립: 멱등 기준을 "적립 건수"에서 "금액 델타"로 전환.
 분할·병합으로 목표 적립액이 늘어도 차액만큼 정확히 지급(기존 lot 증액,
 적립 내역 한 줄 유지). 병합 시 적립 거래를 생존 옵션으로 이전
- 마일리지 미사용 시 즉시적립도 미지급하도록 통일(지연적립 게이트와 일관)

관리자·회원·비회원 셀프확정 전 표면 및 지연적립 스케줄러 연계를
Chrome MCP로 실측 검증(전 시나리오 통과). 회귀 테스트 12건 추가.
2026-07-06 09:32:16 +09:00
정정홍andGitHub d6555c0e7f Merge pull request from gnuboard:HeuJung/issue440
HeuJung/issue440
2026-07-06 09:15:17 +09:00
HeuJung d466aae6eb Merge origin/HeuJung/issue440 into HeuJung/issue440
원격 커밋(f267f21, 버전 bump 이전 7.0.1)과 로컬 재작성본
(7.0.2 bump 반영)이 갈라져 발생한 충돌 해결.

충돌 해결 방침 — 로컬(7.0.2) 기준 채택:
- docs/extension/module-assets.md: 번들 gzip 압축 섹션 유지
- template-engine/CHANGELOG.md: 루트 [7.0.2] 참조 유지
- history/20260704_2200: 7.0.2 bump 경위 기록 유지
- CHANGELOG.md: 자동 병합이 번들 병합 항목을 [7.0.1]에 중복
 삽입한 것을 제거 (해당 항목은 [7.0.2]에만 존재)
2026-07-05 22:36:58 +09:00
HeuJung c819820573 perf(core): JSON 서빙 네트워크 오버헤드 최적화
실측(g7.dev) 결과 줄바꿈 제거·gzip 은 이미 적용된 상태였고,
남은 실질 개선 3종을 구현:

- 모든 API 응답에 JSON_UNESCAPED_UNICODE 적용 — 한글이 \uXXXX(6B)로
 팽창하던 것을 raw UTF-8(3B)로 직렬화 (lang pack gzip -9.1%)
- 공개 레이아웃 서빙에서 개발자용 comment/_comment 재귀 제거,
 편집 모드(with_source_meta)는 보존 (layout gzip -34.9%)
- 번들 JS/CSS(BinaryFileResponse) gzip 압축 사각지대 해소 —
 기존 미들웨어가 getContent==false 로 압축을 건너뛰던 문제 (-73~77%)
2026-07-05 22:31:04 +09:00
HeuJung a958443d69 feat(core): 모듈/플러그인 프론트엔드 에셋 서버측 번들 병합
활성 모듈/플러그인마다 프론트엔드 IIFE JS·CSS 가 개별 HTTP 요청으로 로드되어
확장 수에 비례해 요청이 늘어나는 오버헤드를, 종류별 1개 번들로 서버 병합해 줄인다.
각 확장 IIFE 자가등록 계약(핸들러/리스너/preblocker)은 priority 순 물리 병합으로
실행 순서가 보존되어 그대로 동작한다.

## 백엔드
- 신규 ExtensionBundleService — 정렬(uasort priority)·필터(global)·concat(\n;\n
 구분자로 ASI 경계 보호)·소스맵(prod strip / dev rewrite)·version-in-path 디스크
 캐시·확장별 fine-grained try/catch(한 확장 실패가 번들 전체를 붕괴시키지 않음)
- AbstractModule/AbstractPlugin::getBuiltAssetAbsolutePaths — _bundled 경로
 정확 해석용 절대경로 게터(getAssetFilePath 하드코딩 복제 회피)
- PublicModule/PluginController::serveBundle{Js,Css} + routes/api.php 4개
 (/api/{modules,plugins}/bundle.{js,css}?v={version}, fileResponse ETag/304 재사용)
- config/filesystems.php ext-bundles 디스크 + storage/.gitignore + 루트 .gitignore
- 신규 ext-bundles:cleanup 커맨드 + {module,plugin,template}:cache-clear 번들
 정리 통합 + dev-dashboard 버튼

## 프론트엔드 (engine-v1.52.0)
- TemplateApp.loadExtensionAssets — 개별 로딩 → bundleUrls 기반 모듈→플러그인
 순 번들 로딩. bundleUrls 부재 시 개별 로딩 폴백(회귀 안전)
- ModuleAssetLoader.loadBundle + parseBundleUrlsFromConfig + ExtensionBundleUrls
- TemplateComposer/UserTemplateComposer bundleUrls 뷰변수 + app/admin.blade.php
- core:build 재빌드 산출물(public/build/core/*)

## 버전
- 코어 7.0.1 → 7.0.2 bump. 7.0.1 이 이미 2026-07-03 공개 릴리스이므로 그 위 신규
 기능은 +1 bump(new-bump). config/app.php(SSoT) + .env{.example,.testing.example,
 .testing} + README 뱃지 + INSTALL 예시 + 루트 CHANGELOG 신규 [7.0.2] 섹션 동기화.
- 확장 g7_version 상향 안 함(>=7.0.0 유지) — 6개 프론트 에셋 확장이 신규 코어
 표면을 자기 코드에서 호출하지 않고 개별 서빙 라우트 존치+프론트 폴백으로 코어
 7.0.0 에서도 동작 → 실제 의존 없음( 결정).
- 부수 결함 수정: config/app.php 편집으로 활성화된 core-update-targets-coverage
 룰이 v1.51.0 에서 추가된 vite.config.{editor,devtools}.js 가 app.update.targets
 에 미등재됨을 검출 → 추가. 코어 업데이트 시 편집기/devtools 빌드 설정 갱신 누락
 방지(CHANGELOG [7.0.2] Fixed).

## 규정/audit/문서/테스트
- ("확장 번들 병합" 절+파일유형표+E2E경로) / AGENTS.md /
 docs/extension/module-assets.md / docs/cheatsheet.md
- coverage.json 4항목 + codified 룰 4종(url-same-origin, concat-separator,
 asset-path-getter, clears-template-caches-no-file-delete)
- 루트 CHANGELOG [7.0.2] Added/Fixed / 엔진 CHANGELOG engine-v1.52.0 Added
- Vitest 11(loadBundle + concat 자가등록) + PHPUnit 17(Service 12 + Feature 5)
 + Playwright spec + 시나리오 매니페스트
- 브라우저 실측(g7.dev): 홈/관리자 번들 각 1건·개별 iife 0건, gdpr 인터셉터
 정상, 활성화 토글 반영, 장바구니→체크아웃·게시판 글쓰기 정상. g7_2.dev 미적용
 대비 확장 요청 11~12→4건, DOMContentLoaded ~843~957→605~741ms(페이로드 무손실)

## 도구 보완
- pre-commit-review 스킬 에 엔진 경로 감지 게이트(5-pre + 보고 5e행) 신설 —
 매핑 표에 엔진 파일이 있으면 엔진 CHANGELOG 판정을 강제(이번 초기 누락 재발 방지)
2026-07-05 21:57:39 +09:00
HeuJung f267f21ea6 feat(core): 모듈/플러그인 프론트엔드 에셋 서버측 번들 병합
활성 모듈/플러그인마다 프론트엔드 IIFE JS·CSS 가 개별 HTTP 요청으로 로드되어
확장 수에 비례해 요청이 늘어나는 오버헤드를, 종류별 1개 번들로 서버 병합해 줄인다.
각 확장 IIFE 자가등록 계약(핸들러/리스너/preblocker)은 priority 순 물리 병합으로
실행 순서가 보존되어 그대로 동작한다.

## 백엔드
- 신규 ExtensionBundleService — 정렬(uasort priority)·필터(global)·concat(\n;\n
 구분자로 ASI 경계 보호)·소스맵(prod strip / dev rewrite)·version-in-path 디스크
 캐시·확장별 fine-grained try/catch(한 확장 실패가 번들 전체를 붕괴시키지 않음)
- AbstractModule/AbstractPlugin::getBuiltAssetAbsolutePaths — _bundled 경로
 정확 해석용 절대경로 게터(getAssetFilePath 하드코딩 복제 회피)
- PublicModule/PluginController::serveBundle{Js,Css} + routes/api.php 4개
 (/api/{modules,plugins}/bundle.{js,css}?v={version}, fileResponse ETag/304 재사용)
- config/filesystems.php ext-bundles 디스크 + storage/.gitignore + 루트 .gitignore
- 신규 ext-bundles:cleanup 커맨드 + {module,plugin,template}:cache-clear 번들
 정리 통합 + dev-dashboard 버튼

## 프론트엔드 (engine-v1.52.0)
- TemplateApp.loadExtensionAssets — 개별 로딩 → bundleUrls 기반 모듈→플러그인
 순 번들 로딩. bundleUrls 부재 시 개별 로딩 폴백(회귀 안전)
- ModuleAssetLoader.loadBundle + parseBundleUrlsFromConfig + ExtensionBundleUrls
- TemplateComposer/UserTemplateComposer bundleUrls 뷰변수 + app/admin.blade.php
- core:build 재빌드 산출물(public/build/core/*)

## 버전 동기화 판정 ( 결정)
코어 add-only(개별 서빙 라우트 존치 + 프론트 폴백). 6개 프론트 에셋 확장이 신규
코어 표면을 자기 코드에서 호출하지 않아 코어 7.0.0 에서도 동작 → 실제 의존 없음.
확장 g7_version 상향 안 함(>=7.0.0 유지). 코어 버전 무변경(7.0.1 accumulate).

## 규정/audit/문서/테스트
- ("확장 번들 병합" 절+파일유형표+E2E경로) / AGENTS.md /
 docs/extension/module-assets.md / docs/cheatsheet.md
- coverage.json 4항목 + codified 룰 4종(url-same-origin, concat-separator,
 asset-path-getter, clears-template-caches-no-file-delete)
- 루트 CHANGELOG [7.0.1] Added / 엔진 CHANGELOG engine-v1.52.0 Added
- Vitest 11(loadBundle + concat 자가등록) + PHPUnit 17(Service 12 + Feature 5)
 + Playwright spec + 시나리오 매니페스트
- 브라우저 실측(g7.dev): 홈/관리자 번들 각 1건·개별 iife 0건, gdpr 인터셉터
 정상, 활성화 토글 반영, 장바구니→체크아웃·게시판 글쓰기 정상. g7_2.dev 미적용
 대비 확장 요청 11~12→4건, DOMContentLoaded ~843~957→605~741ms(페이로드 무손실)

## 도구 보완
- pre-commit-review 스킬 에 엔진 경로 감지 게이트(5-pre + 보고 5e행) 신설 —
 매핑 표에 엔진 파일이 있으면 엔진 CHANGELOG 판정을 강제(이번 초기 누락 재발 방지)
2026-07-04 22:19:55 +09:00
264 changed files with 16630 additions and 7194 deletions
+4 -2
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.1
APP_VERSION=7.0.2
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
@@ -36,7 +36,9 @@ DB_WRITE_PASSWORD=
# Database Table Prefix (optional)
DB_PREFIX=g7_
# Read Database Connection (Optional - falls back to WRITE if not set)
# Read Database Connection (Optional)
# 비워두면 각 항목이 대응하는 DB_WRITE_* 값으로 대체됩니다 (config/database.php).
# Read 전용 DB(리플리카)를 쓸 때만 값을 채우세요.
DB_READ_HOST=
DB_READ_PORT=
DB_READ_DATABASE=
+2 -2
View File
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.1
APP_VERSION=7.0.2
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
@@ -34,7 +34,7 @@ DB_WRITE_PASSWORD=
DB_PREFIX=g7_
# Read Database Connection (테스트에서는 WRITE와 동일하게 설정)
# database.php의 read 기본값이 WRITE와 다르므로, 명시 설정 필수
# 비워두면 config/database.php 가 WRITE 값으로 대체하나, 테스트 DB 명시를 위해 값을 채워 둔다.
DB_READ_HOST=127.0.0.1
DB_READ_PORT=3306
DB_READ_DATABASE=g7_testing
+71
View File
@@ -0,0 +1,71 @@
---
name: 🐛 버그 제보
about: 그누보드7(G7) 사용 중 발견한 버그를 제보합니다.
title: "[Bug] "
labels: bug
assignees: ''
---
## 증상 요약
<!-- 어떤 문제가 발생했는지 한두 문장으로 적어 주세요. -->
## 재현 절차
1.
2.
3.
## 기대한 동작
<!-- 원래 어떻게 동작해야 하는지 적어 주세요. -->
## 실제 동작
<!-- 실제로 어떻게 동작했는지 적어 주세요. 오류 메시지가 있다면 그대로 붙여 주세요. -->
## 환경 정보 (필수)
> 아래 정보가 없으면 원인 파악이 어렵습니다. 가능한 한 모두 채워 주세요.
- **코어 버전**: <!-- 예: 7.0.2 / config/app.php 의 APP_VERSION 또는 관리자 > 정보 화면 -->
- **업그레이드 이슈인 경우 — 업그레이드 전 버전 → 후 버전**: <!-- 예: 7.0.0-beta.7 → 7.0.2 -->
- **PHP 버전**: <!-- 예: 8.2.x / php -v -->
- **웹서버·실행 방식**: <!-- 예: Apache + mod_php / Nginx + PHP-FPM / 공유호스팅 -->
- **DB**: <!-- 예: MySQL 8.0 / MariaDB 10.x -->
- **OS**: <!-- 예: Ubuntu 24.04 / Windows 11 -->
## 활성 확장 목록 (해당 시)
<!--
확장(모듈/플러그인/템플릿) 관련 문제이거나 업그레이드 문제라면 아래 명령 결과를 붙여 주세요.
php artisan module:list
php artisan plugin:list
php artisan template:list
-->
```text
(여기에 붙여넣기)
```
## 커스텀 자산 여부 (해당 시)
> 업그레이드·파일 삭제·권한 문제라면 반드시 확인해 주세요.
- [ ] `modules/_bundled/`·`plugins/_bundled/`·`templates/_bundled/` 아래에 직접 추가한 커스텀 확장이 있습니다.
- [ ] `public/.htaccess` 에 직접 추가한 규칙이 있습니다.
- [ ] 그 외 코어 배포본에 없는 커스텀 파일을 직접 추가/수정했습니다.
<!-- 위 항목에 해당하면 어떤 경로에 무엇을 추가했는지 적어 주세요. -->
## 스크린샷·로그
<!--
- 브라우저 콘솔 에러 (F12 > Console)
- storage/logs/ 관련 로그
- 화면 스크린샷
-->
## 추가 정보
<!-- 원인 추정, 임시 우회 방법 등 참고될 내용이 있으면 적어 주세요. -->
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 질문·사용법 문의 (sir.kr Q&A)
url: https://sir.kr/questions/create?tag=%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7
about: 버그가 아닌 사용법 질문·사용법 문의는 sir.kr Q&A 를 이용해 주세요.
- name: 보안 취약점 신고
url: https://github.com/gnuboard/g7/security/advisories/new
about: 보안 취약점은 공개 이슈가 아닌 비공개 Security Advisory 로 신고해 주세요.
+26
View File
@@ -0,0 +1,26 @@
---
name: ✨ 기능 제안
about: 새로운 기능이나 개선 아이디어를 제안합니다.
title: "[Feature] "
labels: enhancement
assignees: ''
---
## 해결하려는 문제
<!-- 어떤 불편이나 한계 때문에 이 기능이 필요한지 적어 주세요. -->
## 제안하는 기능
<!-- 원하는 동작을 구체적으로 적어 주세요. -->
## 적용 범위 (해당 시)
- [ ] 코어
- [ ] 모듈 <!-- 어떤 모듈인지: -->
- [ ] 플러그인 <!-- 어떤 플러그인인지: -->
- [ ] 템플릿 <!-- 어떤 템플릿인지: -->
## 대안·참고 사항
<!-- 고려한 다른 방법, 타 CMS 사례, 참고 링크 등이 있으면 적어 주세요. -->
+51
View File
@@ -0,0 +1,51 @@
---
name: ⬆️ 업그레이드 문제
about: core:update / 확장 업데이트 과정에서 발생한 문제를 제보합니다.
title: "[Upgrade] "
labels: "bug, upgrade"
assignees: ''
---
## 업그레이드 경로 (필수)
- **업그레이드 전 버전**: <!-- 예: 7.0.0-beta.7 -->
- **업그레이드 후(목표) 버전**: <!-- 예: 7.0.2 -->
- **실행한 명령**: <!-- 예: php artisan core:update --source=/tmp/g7-7.0.2 -->
- **실행 계정/권한**: <!-- 예: www-data / sudo(root) / 일반 사용자 -->
> 참고: `core:update` 는 **현재 설치된(업그레이드 전) 버전의 코드**로 실행됩니다.
> 따라서 최신 버전에서 수정된 버그라도, 그 수정 이전 버전에서 올라오는 경우에는 재발할 수 있습니다.
> 업그레이드 전 반드시 전체 백업을 권장합니다.
## 증상
<!-- 업데이트 중/후 어떤 문제가 발생했는지 적어 주세요. 오류 메시지는 그대로 붙여 주세요. -->
## 확장·커스텀 자산 상태 (필수)
- [ ] `_bundled/` 아래에 직접 추가한 커스텀 확장이 있습니다. <!-- 경로: -->
- [ ] `public/.htaccess`·`public/storage` 등 커스텀 파일/링크가 있습니다.
- [ ] 업데이트 후 위 자산이 삭제/변경되었습니다.
<!-- 업데이트 전후 확장 목록을 붙여 주시면 도움이 됩니다:
php artisan module:list / plugin:list / template:list
-->
```text
(확장 목록 붙여넣기)
```
## 환경 정보
- **PHP 버전**:
- **웹서버·실행 방식**: <!-- Apache+mod_php / Nginx+PHP-FPM / 공유호스팅 등 -->
- **DB**:
- **OS**:
## 로그·스크린샷
<!--
- 콘솔 전체 출력 (업데이트 실행 로그)
- storage/logs/ 의 관련 로그
- 브라우저 콘솔 에러
-->
+3
View File
@@ -49,6 +49,9 @@ node_modules/
# Settings JSON files (환경별 설정)
/storage/app/settings/
# 확장 프론트엔드 병합 번들 캐시 (version-in-path, 런타임 생성)
/storage/app/ext-bundles/
# Bootstrap cache (auto-generated)
/bootstrap/cache/*
!/bootstrap/cache/.gitkeep
+12
View File
@@ -788,6 +788,18 @@ php artisan plugin:update sirsoft-payment --force
lazy 번들(편집기/devtools)이 코어 런타임(DynamicRenderer·엔진 싱글톤·React Context·DevTools 코어)을 재사용할 때는 재번들하지 않고 `window.G7Core.__runtime` 을 빌려 쓴다 — React/컨텍스트/싱글톤 인스턴스 동일성이 강제되기 때문(사본이 둘이면 "Invalid hook call"·컨텍스트 미매칭). 메인 번들이 `G7CoreGlobals` 에서 공유 대상을 `G7Core.__runtime` 에 노출하고, lazy 번들 vite config 는 React 4종(`react`/`react-dom`/`react-dom/client`/`react/jsx-runtime`)을 external→window 로, 코어 런타임 모듈을 `resolveId` 플러그인으로 `__runtime-shims/` 로 치환한다.
### 확장 번들 병합 (서버측 concat)
활성 모듈/플러그인의 프론트엔드 IIFE JS·CSS 는 타입별로 서버에서 하나의 번들로 병합해 서빙한다(`/api/{modules,plugins}/bundle.{js,css}?v={version}`). `ExtensionBundleService` 가 정렬·필터·concat·캐시를 전담하고, 프론트는 `window.G7Config.bundleUrls` 를 읽어 모듈 번들 → 플러그인 번들 순으로 로드한다. 병합 규율:
- priority 순서는 선언형 — 실행 순서는 오직 manifest `loading.priority` 오름차순(`uasort`). 특정 확장 이름을 지목하는 분기를 두지 않는다.
- IIFE 사이는 `\n;\n`(JS)/`\n`(CSS) 로 잇는다. 미사용 시 ASI 경계가 깨져 번들 전체 파싱 에러가 난다.
- 소스맵은 prod strip, dev 는 개별 에셋 서빙 절대 URL 로 rewrite. 개별 에셋 서빙 라우트(`*.map` 포함)는 존치한다.
- 번들 URL 은 반드시 same-origin(`/api/...`). 외부 origin/CDN·protocol-relative 는 gdpr preblocker 에 자기 차단된다.
- 확장 에셋 절대경로는 `getBuiltAssetAbsolutePaths()`(=`getModulePath()`/`getPluginPath()`) 만 쓴다. `base_path("modules"|"plugins")` 직접 조립은 `_bundled` 경로 오해석 → 빈 번들.
- concat 루프는 확장별 try/catch — 실패 확장만 skip 하고 나머지 병합을 지속한다.
- 번들 파일명에 확장 캐시 버전을 포함(`{type}.{version}.{js,css}`). 조합 변경 시 version bump → 새 파일명 → 자동 재생성. 구파일 GC 는 `ext-bundles:cleanup` + `{module,plugin,template}:cache-clear` 가 담당한다. prod 은 version-in-path 디스크 캐시, 비프로덕션은 매 요청 concat.
### 빌드 명령어 (Artisan)
```bash
+21
View File
@@ -4,6 +4,27 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.2] - 2026-07-08
### Added
- 설치된 모듈·플러그인이 늘어날수록 화면마다 개별로 내려받던 확장 스크립트·스타일 요청을 종류별로 하나로 묶어 내려받도록 개선해, 확장이 많은 사이트의 초기 로딩 요청 수를 크게 줄였습니다. 확장을 설치·활성화·비활성화하면 묶음이 자동으로 다시 만들어져 항상 현재 활성 상태와 일치합니다.
### Changed
- 코어 업데이트가 파일을 반영하는 기본 방식을 "새 버전이 실제로 추가·변경한 파일만 반영"으로 바꿔, 업데이트로 인한 사고 가능성을 크게 줄였습니다. 이제 코어가 건드리지 않은 파일은 그대로 두므로, `public/.htaccess` 에 직접 넣은 설정이나 사용자가 추가해 둔 파일이 업데이트 과정에서 사라지지 않습니다. 새 버전에 함께 담긴 기본 제공 확장(모듈·플러그인·템플릿·언어팩)의 갱신 파일은 이 과정에서도 정상적으로 최신 내용으로 반영되며, 사용자가 확장 폴더에 직접 추가해 둔 항목은 그대로 보존됩니다. 기존처럼 새 버전에서 제거된 파일 정리까지 포함한 전체 덮어쓰기를 원하면 `--prune` 옵션으로 업데이트하세요. (#64 @bigmsg 님께서 제보해주셨습니다.)
- 화면·데이터를 내려받을 때의 전송 용량을 줄여 초기 로딩과 페이지 이동 속도를 개선했습니다.
- 모듈·플러그인의 묶음 스크립트·스타일이 지금까지 압축 없이 전송되던 것을 압축 전송하도록 바꿔, 확장이 많은 사이트에서 첫 화면에 받는 용량을 크게 줄였습니다.
- 한글 등 다국어 텍스트가 응답에 불필요하게 부풀려 담기던 것을 원래 형태 그대로 담도록 바꿔, 특히 다국어 번역 데이터의 전송 용량을 줄였습니다.
- 화면을 그리는 데 쓰이지 않는 개발용 설명 주석을 화면 정보 응답에서 빼도록 해 응답 용량을 추가로 줄였습니다.
### Fixed
- 코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다.
- 설치 마법사에서 Vendor 설치 방식을 "Composer 실행"으로 선택해 설치할 때, 일부 Windows 환경에서 Composer 설치 로그의 문자 인코딩이 어긋나면 진행 상태 응답이 비어 진행 상황이 표시되지 않고 오류가 반복될 수 있던 문제를 보완했습니다. 어떤 문자가 섞이더라도 진행 로그가 항상 정상적으로 전달되도록 처리했습니다. (#62 @kitrio 님께서 제보해주셨습니다.)
- 설치 마법사의 "설치 환경 확인" 단계에서, storage 폴더 하위에 파일이 많이 쌓인 환경일수록 요구사항 검사가 오래 걸리던 문제를 개선했습니다. 폴더 쓰기 권한 검사가 불필요하게 깊은 하위까지 훑던 것을 필요한 범위로 제한해 이 단계가 즉시 완료되도록 했습니다.
- 설치 시 "읽기 전용 데이터베이스(Read DB)를 사용하지 않음"으로 선택했는데도, 읽기 전용 DB 접속 정보를 확인하려다 설치 마지막 단계에서 실패하던 문제를 수정했습니다. 이제 읽기 전용 DB를 사용하지 않으면 쓰기 DB 정보만으로 설치가 정상 완료됩니다. 아울러 설치 이후에도 읽기 전용 DB 접속 정보를 비워 두면 조회 연결이 자동으로 쓰기 DB로 대체되도록 하여, 별도의 읽기 전용 DB 없이도 안정적으로 동작합니다. (#63 @kitrio 님께서 제보해주셨습니다.)
## [7.0.1] - 2026-07-03
### Changed
+1 -1
View File
@@ -246,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.1 g7
# (필요 시) mv g7-7.0.2 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
+1 -1
View File
@@ -8,7 +8,7 @@
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.1-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.2-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
/**
* 오래된 확장 프론트엔드 병합 번들 파일을 정리하는 커맨드
*
* 번들 캐시 파일(`storage/app/ext-bundles/{type}.{version}.{js|css}`)은 Laravel
* 캐시 스토어 밖 파일시스템이라 version bump 나 cache:clear 가 구버전 파일을
* 지우지 않는다(TTL/GC 없음). 이 커맨드가 현재 version 외의 구파일을 삭제한다.
* (`CleanupLayoutPreviewsCommand` 파일 산출물 GC 패턴 미러)
*/
class CleanupExtensionBundlesCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'ext-bundles:cleanup';
/**
* The console command description.
*/
protected $description = '오래된 확장 프론트엔드 병합 번들 파일(구 version)을 삭제합니다';
/**
* Execute the console command.
*
* @param ExtensionBundleService $service 번들 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(ExtensionBundleService $service): int
{
$currentVersion = $service->getCurrentVersion();
$deleted = $service->cleanupStaleBundles($currentVersion);
$this->info("오래된 확장 번들 {$deleted}건이 삭제되었습니다. (현재 version: {$currentVersion})");
return self::SUCCESS;
}
}
@@ -28,6 +28,7 @@ class CoreUpdateCommand extends Command
protected $signature = 'core:update
{--force : 버전 비교 없이 강제 업데이트}
{--no-backup : 백업 생성 건너뛰기}
{--prune : 코어가 제거한 파일 정리 + targets 전체 덮어쓰기(기존 방식). 미지정 시 코어가 실제 변경/추가한 파일만 적용하고 나머지는 보존}
{--no-maintenance : 유지보수 모드 활성화 건너뛰기}
{--local : 로컬 코드베이스를 업데이트 소스로 사용 (GitHub 스킵)}
{--source= : 수동 업데이트용 소스 디렉토리 경로 (GitHub 다운로드 대신 지정 디렉토리 사용)}
@@ -346,6 +347,12 @@ class CoreUpdateCommand extends Command
//
// `--no-backup` 모드면 backupPath 가 null 이므로 manifest 생성을 스킵 — 롤백
// 자체가 불가능한 모드이므로 기존 동작 유지.
// 증분 적용(3-way) 대상 목록. 백업 있음 + --prune 미지정 시에만 산출.
// null 로 남으면(백업 부재 또는 --prune) applyUpdate 가 전체 덮어쓰기로 회귀.
$applyList = null;
$applyStats = null;
$prune = (bool) $this->option('prune');
if ($backupPath !== null) {
$bar->setMessage('신규 파일 manifest 생성 중...');
$log('신규 파일 manifest 생성 시작');
@@ -371,15 +378,51 @@ class CoreUpdateCommand extends Command
'error' => $manifestError->getMessage(),
]);
}
// ── 증분 적용 대상 산출 (3-way) ──
// --prune 이면 전체 덮어쓰기이므로 산출 자체를 스킵.
if (! $prune) {
try {
$applyStats = CoreBackupHelper::computeApplyList(
$backupPath,
$pendingPath,
(array) config('app.update.targets', []),
(array) config('app.update.protected_paths', []),
(array) config('app.update.excludes', []),
);
$applyList = $applyStats['apply'];
$log(sprintf(
'증분 적용 대상 산출 완료 (added=%d, changed=%d, total=%d)',
$applyStats['added_count'],
$applyStats['changed_count'],
count($applyList),
));
} catch (\Throwable $applyError) {
// 산출 실패 시 안전하게 전체 덮어쓰기로 회귀 (applyList=null 유지)
$applyList = null;
$applyStats = null;
$log("증분 적용 대상 산출 실패 (전체 덮어쓰기로 회귀): {$applyError->getMessage()}");
Log::warning('코어 업데이트: 증분 적용 대상 산출 실패', [
'error' => $applyError->getMessage(),
]);
}
}
}
// ── Step 7: 파일 적용 ──
$bar->setMessage(__('settings.core_update.step_apply'));
$bar->advance();
$log('코어 파일 덮어쓰기 시작');
$service->applyUpdate($pendingPath, $onProgress);
$log('코어 파일 덮어쓰기 완료');
if ($prune) {
$log('코어 파일 덮어쓰기 시작 (--prune: 전체 덮어쓰기 + orphan 정리)');
} elseif ($applyList !== null) {
$log(sprintf('코어 파일 적용 시작 (증분: 코어 변경분 %d건만 적용, 나머지 보존)', count($applyList)));
} else {
$log('코어 파일 덮어쓰기 시작 (백업 부재 — 전체 덮어쓰기로 회귀)');
}
$service->applyUpdate($pendingPath, $onProgress, prune: $prune, applyList: $applyList);
$log('코어 파일 적용 완료');
// ── Step 8: vendor 디렉토리 복사 (_pending → 운영) ──
if ($composerSkipped) {
@@ -503,6 +546,9 @@ class CoreUpdateCommand extends Command
$this->info("그누보드7 코어가 {$toVersion} 버전으로 업데이트되었습니다!");
$this->newLine();
// ── 파일 적용 방식 요약 (부수의무: 설명의무) ──
$this->summarizeApplyMode($prune, $applyList, $applyStats);
// _bundled 확장 일괄 업데이트 프롬프트 (번들에 신버전이 있을 때만 표시).
//
// 주의: Step 11 restoreOwnership 이후에 실행되므로, 프롬프트 내 실제
@@ -1068,6 +1114,41 @@ class CoreUpdateCommand extends Command
return true;
}
/**
* 파일 적용 방식(증분/prune/fallback)과 통계를 사용자에게 요약 출력합니다.
*
* @param bool $prune --prune 지정 여부
* @param array<int, string>|null $applyList 증분 적용 대상 목록 (null 이면 전체 덮어쓰기)
* @param array{apply:array<int,string>, added_count:int, changed_count:int, has_symlink:bool}|null $applyStats computeApplyList 산출 통계
*/
private function summarizeApplyMode(bool $prune, ?array $applyList, ?array $applyStats): void
{
if ($prune) {
$this->line(__('settings.core_update.apply_mode_prune'));
$this->newLine();
return;
}
if ($applyList === null) {
// 백업 부재 또는 산출 실패 → 전체 덮어쓰기로 회귀
$this->warn(__('settings.core_update.apply_mode_fallback'));
$this->newLine();
return;
}
// 증분 모드
$added = $applyStats['added_count'] ?? 0;
$changed = $applyStats['changed_count'] ?? 0;
$this->line(__('settings.core_update.apply_mode_incremental', [
'added' => $added,
'changed' => $changed,
]));
$this->line(__('settings.core_update.apply_mode_incremental_prune_hint'));
$this->newLine();
}
/**
* 업데이트 로그를 파일로 저장합니다.
*
@@ -5,6 +5,7 @@ namespace App\Console\Commands\Module;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Extension\ModuleManager;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -27,7 +28,8 @@ class ClearModuleCacheCommand extends Command
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository,
private CacheInterface $cache
private CacheInterface $cache,
private ExtensionBundleService $bundleService
) {
parent::__construct();
}
@@ -86,6 +88,9 @@ class ClearModuleCacheCommand extends Command
$clearedCount += $this->clearModuleCache($module->getIdentifier());
}
// 모듈 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
$clearedCount += $this->bundleService->clearBundles('module');
$this->info('✅ '.__('modules.commands.cache_clear.success_all', ['count' => $clearedCount]));
}
@@ -5,6 +5,7 @@ namespace App\Console\Commands\Plugin;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Extension\PluginManager;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -27,7 +28,8 @@ class ClearPluginCacheCommand extends Command
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository,
private CacheInterface $cache
private CacheInterface $cache,
private ExtensionBundleService $bundleService
) {
parent::__construct();
}
@@ -86,6 +88,9 @@ class ClearPluginCacheCommand extends Command
$clearedCount += $this->clearPluginCache($plugin->getIdentifier());
}
// 플러그인 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
$clearedCount += $this->bundleService->clearBundles('plugin');
$this->info('✅ '.__('plugins.commands.cache_clear.success_all', ['count' => $clearedCount]));
}
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
use App\Extension\TemplateManager;
use App\Models\Template;
use App\Models\TemplateLayout;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -27,7 +28,8 @@ class ClearTemplateCacheCommand extends Command
*/
public function __construct(
private TemplateManager $templateManager,
private CacheInterface $cache
private CacheInterface $cache,
private ExtensionBundleService $bundleService
) {
parent::__construct();
}
@@ -153,6 +155,9 @@ class ClearTemplateCacheCommand extends Command
$this->cache->forget('templates.active.user');
$clearedCount += 2;
// 5. 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신)
$clearedCount += $this->bundleService->clearBundles();
$this->info('✅ '.__('templates.commands.cache_clear.success_all', [
'count' => $clearedCount,
]));
+23
View File
@@ -1190,6 +1190,29 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
return $result;
}
/**
* 빌드된 에셋의 절대 파일 경로를 반환합니다.
*
* `getBuiltAssetPaths()` 는 module.json 의 상대 output 경로를 돌려주므로
* 파일을 실제로 읽으려면 모듈 루트(`getModulePath()`: 활성 dir 또는 `_bundled`
* 실제 위치)를 앞에 붙여야 한다. 서버측 번들 병합(ExtensionBundleService)이
* `getAssetFilePath()` 의 `base_path("modules/{id}/...")` 하드코딩을 복제하지
* 않고 `_bundled` 확장에서도 정확한 경로를 얻도록 이 게터를 SSoT 로 쓴다.
*
* @return array 빌드된 에셋 절대 경로 배열 ['js' => '...', 'css' => '...']
*/
public function getBuiltAssetAbsolutePaths(): array
{
$relative = $this->getBuiltAssetPaths();
$result = [];
foreach ($relative as $kind => $output) {
$result[$kind] = $this->getModulePath().'/'.$output;
}
return $result;
}
/**
* 모듈 스토리지 드라이버 인스턴스 반환
*
+24
View File
@@ -1011,6 +1011,30 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
return $result;
}
/**
* 빌드된 에셋의 절대 파일 경로를 반환합니다.
*
* `getBuiltAssetPaths()` 는 plugin.json 의 상대 output 경로를 돌려주므로
* 파일을 실제로 읽으려면 플러그인 루트(`getPluginPath()`: 활성 dir 또는
* `_bundled` 실제 위치)를 앞에 붙여야 한다. 서버측 번들 병합
* (ExtensionBundleService)이 `getAssetFilePath()` 의
* `base_path("plugins/{id}/...")` 하드코딩을 복제하지 않고 `_bundled`
* 확장에서도 정확한 경로를 얻도록 이 게터를 SSoT 로 쓴다.
*
* @return array 빌드된 에셋 절대 경로 배열 ['js' => '...', 'css' => '...']
*/
public function getBuiltAssetAbsolutePaths(): array
{
$relative = $this->getBuiltAssetPaths();
$result = [];
foreach ($relative as $kind => $output) {
$result[$kind] = $this->getPluginPath().'/'.$output;
}
return $result;
}
/**
* 플러그인이 설정 페이지를 가지고 있는지 확인합니다.
*
+221 -10
View File
@@ -238,6 +238,7 @@ class CoreBackupHelper
$newDirs = [];
$protectedSet = self::normalizeProtectedSet($protectedPaths);
$allowedTargetSet = self::normalizeProtectedSet($targets);
$excludeSet = array_values(array_filter(array_map('trim', $excludes)));
foreach ($targets as $target) {
@@ -246,8 +247,9 @@ class CoreBackupHelper
continue;
}
// target 자체가 보호 경로면 스킵
if (self::isWithinProtectedPath($target, $protectedSet)) {
// target 자체가 보호 경로면 스킵 (단, targets 에 더 구체적으로 명시된
// 경로는 상위 protected 를 오버라이드 — _bundled 갱신 허용)
if (self::isWithinProtectedPath($target, $protectedSet, $allowedTargetSet)) {
continue;
}
@@ -284,7 +286,7 @@ class CoreBackupHelper
if (self::matchesExcludes($relative, $excludeSet)) {
continue;
}
if (self::isWithinProtectedPath($relative, $protectedSet)) {
if (self::isWithinProtectedPath($relative, $protectedSet, $allowedTargetSet)) {
continue;
}
@@ -336,6 +338,177 @@ class CoreBackupHelper
];
}
/**
* 코어(신 버전)가 실제로 추가·변경한 파일 목록(적용 대상)을 3-way 비교로 산출합니다.
*
* `core:update` 기본(증분) 모드에서 `applyUpdate()` 가 참조하는 "적용 대상 집합"을
* 만든다. 코어가 건드리지 않은 파일(base == theirs)은 목록에서 제외되어, 활성
* 디렉토리에 남은 현재 상태(사용자 수정 포함 가능)를 그대로 보존한다.
*
* 3-way 판정:
* - base = 구버전 원본 = 백업 스냅샷 (`$backupPath/$target`)
* - theirs = 신 버전 = _pending 소스 (`$sourcePath/$target`)
* - 규칙:
* · base 없음 (theirs 에만 존재) → added (신규 파일 — 적용)
* · base 있고 size 또는 md5 다름 → changed (코어가 변경 — 적용)
* · base 있고 size·md5 동일 → 제외 (코어 미변경 — 스킵/보존)
*
* 성능: size 가 다르면 md5 를 계산하지 않는다(즉시 changed). size 가 같을 때만
* `md5_file`. mtime 은 비교하지 않는다 — `_pending` 은 방금 추출되어 mtime 이
* 전부 추출 시각이므로 항상 "다름"으로 오판한다.
*
* symlink 는 목록에서 제외한다 — 링크 자체는 `copyDirectory` 의 별도 링크 처리
* 경로가 담당하며, 증분 모드에서도 항상 재생성되어야 한다(target 추적 없이 동일
* 링크). 따라서 반환값 `is_partial` 이 true 여도 링크는 정상 반영된다.
*
* `writeNewFilesManifest()` 와 동일한 순회 구조·가드(`excludes`/`protectedPaths`)를
* 재사용한다. 두 산출을 각각 별도 순회하지만, 반환값은 in-memory 로 즉시 소비되어
* 파일화 비용이 없다(롤백용 `_new_files_manifest.json` 은 별도).
*
* @param string $backupPath 백업 디렉토리 경로 (= base, 활성 사전 스냅샷)
* @param string $sourcePath 소스 디렉토리 경로 (= theirs, _pending 신 버전)
* @param array $targets 처리할 target 경로 목록 (`app.update.targets`)
* @param array $protectedPaths 보호 경로 목록 (목록에서 제외)
* @param array $excludes 제외 패턴 목록 (예: ['node_modules', '.git'])
* @return array{apply:array<int,string>, added_count:int, changed_count:int, has_symlink:bool}
* apply = 적용 대상 상대경로 목록(정렬됨, 슬래시 정규화)
*/
public static function computeApplyList(
string $backupPath,
string $sourcePath,
array $targets,
array $protectedPaths,
array $excludes,
): array {
$added = [];
$changed = [];
$hasSymlink = false;
$protectedSet = self::normalizeProtectedSet($protectedPaths);
$allowedTargetSet = self::normalizeProtectedSet($targets);
$excludeSet = array_values(array_filter(array_map('trim', $excludes)));
foreach ($targets as $target) {
$target = trim($target);
if ($target === '') {
continue;
}
if (self::isWithinProtectedPath($target, $protectedSet, $allowedTargetSet)) {
continue;
}
$sourceTargetPath = $sourcePath.DIRECTORY_SEPARATOR.$target;
if (! file_exists($sourceTargetPath)) {
continue;
}
// 단일 파일 target
if (is_file($sourceTargetPath) && ! is_link($sourceTargetPath)) {
$rel = self::normalizeRelative($target);
$backupFilePath = $backupPath.DIRECTORY_SEPARATOR.$target;
self::classifyForApply($rel, $sourceTargetPath, $backupFilePath, $added, $changed);
continue;
}
if (is_link($sourceTargetPath)) {
$hasSymlink = true;
continue;
}
if (! is_dir($sourceTargetPath)) {
continue;
}
// 디렉토리 target — 재귀 비교
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($sourceTargetPath, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST,
);
foreach ($iterator as $item) {
/** @var SplFileInfo $item */
$absolute = $item->getPathname();
$relative = self::normalizeRelative($target.'/'.ltrim(substr($absolute, strlen($sourceTargetPath)), DIRECTORY_SEPARATOR.'/'));
if (self::matchesExcludes($relative, $excludeSet)) {
continue;
}
if (self::isWithinProtectedPath($relative, $protectedSet, $allowedTargetSet)) {
continue;
}
// symlink 는 목록 제외 — 링크 처리 경로가 별도 담당
if ($item->isLink()) {
$hasSymlink = true;
continue;
}
// 디렉토리 자체는 목록에 담지 않는다 — 파일 단위 적용이 디렉토리를
// 필요 시 생성한다(copyDirectory 가 대상 디렉토리를 ensure).
if (! $item->isFile()) {
continue;
}
$backupItemPath = $backupPath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
self::classifyForApply($relative, $absolute, $backupItemPath, $added, $changed);
}
}
$apply = array_values(array_unique(array_merge($added, $changed)));
sort($apply, SORT_STRING);
return [
'apply' => $apply,
'added_count' => count($added),
'changed_count' => count($changed),
'has_symlink' => $hasSymlink,
];
}
/**
* 3-way 판정으로 파일 하나를 added/changed 로 분류하거나 제외합니다.
*
* base(백업) 부재 → added, size 다름 또는 md5 다름 → changed, 동일 → 제외.
*
* @param string $relative 슬래시 정규화된 상대 경로 (목록 등재값)
* @param string $theirsAbsolute 신 버전(_pending) 파일 절대 경로
* @param string $baseAbsolute 백업(base) 파일 절대 경로
* @param array<int,string> $added added 누적 배열 (참조)
* @param array<int,string> $changed changed 누적 배열 (참조)
*/
private static function classifyForApply(
string $relative,
string $theirsAbsolute,
string $baseAbsolute,
array &$added,
array &$changed,
): void {
if (! file_exists($baseAbsolute) || is_dir($baseAbsolute)) {
$added[] = $relative;
return;
}
// size 선필터 — 다르면 md5 생략하고 즉시 changed
$theirsSize = @filesize($theirsAbsolute);
$baseSize = @filesize($baseAbsolute);
if ($theirsSize !== $baseSize) {
$changed[] = $relative;
return;
}
// size 동일 → 내용 비교
if (@md5_file($theirsAbsolute) !== @md5_file($baseAbsolute)) {
$changed[] = $relative;
}
// 동일하면 제외 (코어 미변경 — 스킵)
}
/**
* 백업 디렉토리의 manifest 를 로드해 활성 디렉토리에서 신 버전 신규 파일을 정리합니다.
*
@@ -491,23 +664,61 @@ class CoreBackupHelper
/**
* 상대 경로가 protected_paths 목록의 어떤 항목 하위에 위치하는지 검사합니다.
*
* `$allowedTargetSet` 이 주어지면 "targets 에 명시된 더 구체적인 경로" 는 상위
* protected 를 오버라이드한다. 예: `protected_paths` 에 `modules` 가 있고
* `targets` 에 `modules/_bundled` 가 명시되어 있으면, `modules/_bundled/...` 파일은
* protected 로 차단되지 않고 apply 대상에 포함된다.
*
* 배경: `protected_paths` 의 확장 부모(`modules`/`plugins`/`templates`/`lang-packs`)는
* "자동 발견 폴백" 이 활성 서브디렉토리(`modules/sirsoft-*`)를 통째로 삭제하는
* 회귀(#347) 를 막기 위한 방어인데, 정상 증분 흐름에서는 `_bundled` 갱신까지
* 함께 막아 코어 업데이트로 번들 확장 파일이 반영되지 않는 결함(#452 / 공개 #64)을
* 유발했다. targets 에 명시된 경로가 protected 보다 더 구체적(하위)이면 예외 처리한다.
*
* @param string $relative 검사할 상대 경로
* @param array<int, string> $protectedSet 정규화된 보호 경로 집합
* @param array<int, string> $allowedTargetSet 정규화된 targets 집합 (protected 오버라이드 허용)
*/
private static function isWithinProtectedPath(string $relative, array $protectedSet): bool
private static function isWithinProtectedPath(string $relative, array $protectedSet, array $allowedTargetSet = []): bool
{
$relative = self::normalizeRelative($relative);
// 가장 구체적으로 매칭되는 protected 경로 길이(세그먼트 수)를 찾는다.
$matchedProtectedLen = -1;
foreach ($protectedSet as $p) {
if ($p === '') {
continue;
}
if ($relative === $p) {
return true;
}
if (str_starts_with($relative, $p.'/')) {
return true;
if ($relative === $p || str_starts_with($relative, $p.'/')) {
$len = substr_count($p, '/') + 1;
if ($len > $matchedProtectedLen) {
$matchedProtectedLen = $len;
}
}
}
return false;
if ($matchedProtectedLen === -1) {
return false;
}
// protected 하위이지만, targets 에 더 구체적으로(= 더 깊게) 명시된 경로의
// 하위이면 오버라이드하여 통과(= 보호 해제). target 이 protected 와 같거나
// 더 얕으면 오버라이드하지 않는다(예: target `storage` == protected `storage`).
foreach ($allowedTargetSet as $t) {
if ($t === '') {
continue;
}
$targetLen = substr_count($t, '/') + 1;
if ($targetLen <= $matchedProtectedLen) {
continue;
}
if ($relative === $t || str_starts_with($relative, $t.'/')) {
return false;
}
}
return true;
}
/**
+12 -2
View File
@@ -32,8 +32,13 @@ class FilePermissionHelper
* @param string $relativePath 현재 상대 경로 (내부 재귀용)
* @param bool $removeOrphans 소스에 없는 대상 파일/디렉토리 삭제 여부
* @param bool $preserveTopLevelOrphans 최상위 한 레벨의 orphan 보존 여부
* @param array<string, bool>|null $applyList 적용 대상 파일 상대경로 화이트리스트
* (이 디렉토리 루트 기준, `'sub/file.txt' => true` 형태의 lookup 맵). null 이면
* 전체 파일 복사(기존 동작). 지정 시 이 맵에 없는 파일은 복사를 스킵한다 — 코어
* 업데이트 증분 모드에서 "코어가 변경하지 않은 파일"을 건드리지 않기 위함. symlink
* 는 이 필터의 영향을 받지 않고 항상 재생성된다(링크는 목록 산출 대상이 아님).
*/
public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false, bool $preserveTopLevelOrphans = false): void
public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false, bool $preserveTopLevelOrphans = false, ?array $applyList = null): void
{
if (! File::isDirectory($destination)) {
// 신규 디렉토리: 부모 디렉토리의 퍼미션/소유권 상속
@@ -76,8 +81,13 @@ class FilePermissionHelper
if ($item->isDir()) {
// preserveTopLevelOrphans 는 최상위 한 레벨 한정 — 자식 재귀에는 항상 false 전달.
static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans, preserveTopLevelOrphans: false);
static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans, preserveTopLevelOrphans: false, applyList: $applyList);
} else {
// 증분 모드(applyList 지정): 화이트리스트에 없는 파일은 스킵 —
// 코어가 변경하지 않은 파일의 현재 디스크 상태(사용자 수정 포함)를 보존.
if ($applyList !== null && ! isset($applyList[$itemRelativePath])) {
continue;
}
static::copyFile($item->getPathname(), $destPath);
}
}
+104 -95
View File
@@ -9,14 +9,23 @@ use Illuminate\Support\Facades\App;
class ResponseHelper
{
/**
* JSON 응답 인코딩 옵션.
*
* JSON_UNESCAPED_UNICODE: 한글 등 멀티바이트 문자를 `\uXXXX` (6바이트) 로
* 이스케이프하지 않고 raw UTF-8 (한글 3바이트) 로 직렬화하여 응답 크기를 줄인다.
* 특히 다국어 팩(lang) 응답에서 전송량 절감 효과가 크다.
*/
public const JSON_ENCODE_OPTIONS = JSON_UNESCAPED_UNICODE;
/**
* 성공 응답을 생성합니다.
*
* @param string $messageKey 메시지 키 (기본값: 'messages.success')
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 메시지 키 (기본값: 'messages.success')
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse JSON 응답
*/
public static function success(
@@ -29,18 +38,18 @@ class ResponseHelper
return response()->json([
'success' => true,
'message' => self::trans($messageKey, $messageParams, $domain),
'data' => $data
], $statusCode);
'data' => $data,
], $statusCode, [], self::JSON_ENCODE_OPTIONS);
}
/**
* 실패 응답을 생성합니다.
*
* @param string $messageKey 메시지 키 (기본값: 'messages.failed')
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
* @param mixed $errors 오류 정보
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 메시지 키 (기본값: 'messages.failed')
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
* @param mixed $errors 오류 정보
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse JSON 응답
*/
public static function error(
@@ -59,28 +68,28 @@ class ResponseHelper
if (config('app.debug')) {
// messageParams에 이미 에러 메시지가 포함된 경우 중복 방지
if (empty($messageParams)) {
$response['message'] .= ': ' . $errors->getMessage();
$response['message'] .= ': '.$errors->getMessage();
}
$response['debug'] = self::formatException($errors);
}
} elseif ($errors !== null) {
if ($statusCode >= 500 && is_string($errors) && !config('app.debug')) {
if ($statusCode >= 500 && is_string($errors) && ! config('app.debug')) {
// 프로덕션 500+ 에러의 string errors 차단 (내부 예외 메시지 노출 방지)
} else {
$response['errors'] = $errors;
}
}
return response()->json($response, $statusCode);
return response()->json($response, $statusCode, [], self::JSON_ENCODE_OPTIONS);
}
/**
* 입력 검증 실패 응답을 생성합니다.
*
* @param mixed $errors 검증 오류 정보
* @param string $messageKey 메시지 키 (기본값: 'messages.validation_failed')
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param mixed $errors 검증 오류 정보
* @param string $messageKey 메시지 키 (기본값: 'messages.validation_failed')
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 422 상태 코드를 가진 JSON 응답
*/
public static function validationError(
@@ -92,16 +101,16 @@ class ResponseHelper
return response()->json([
'success' => false,
'message' => self::trans($messageKey, $messageParams, $domain),
'errors' => $errors
], 422);
'errors' => $errors,
], 422, [], self::JSON_ENCODE_OPTIONS);
}
/**
* 인증 실패 응답을 생성합니다.
*
* @param string $messageKey 메시지 키 (기본값: 'messages.unauthorized')
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 메시지 키 (기본값: 'messages.unauthorized')
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 401 상태 코드를 가진 JSON 응답
*/
public static function unauthorized(
@@ -111,16 +120,16 @@ class ResponseHelper
): JsonResponse {
return response()->json([
'success' => false,
'message' => self::trans($messageKey, $messageParams, $domain)
], 401);
'message' => self::trans($messageKey, $messageParams, $domain),
], 401, [], self::JSON_ENCODE_OPTIONS);
}
/**
* 권한 부족 응답을 생성합니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.forbidden')
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 멤시지 키 (기본값: 'messages.forbidden')
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 403 상태 코드를 가진 JSON 응답
*/
public static function forbidden(
@@ -130,16 +139,16 @@ class ResponseHelper
): JsonResponse {
return response()->json([
'success' => false,
'message' => self::trans($messageKey, $messageParams, $domain)
], 403);
'message' => self::trans($messageKey, $messageParams, $domain),
], 403, [], self::JSON_ENCODE_OPTIONS);
}
/**
* 리소스를 찾을 수 없음 응답을 생성합니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.not_found')
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 멤시지 키 (기본값: 'messages.not_found')
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 404 상태 코드를 가진 JSON 응답
*/
public static function notFound(
@@ -149,17 +158,17 @@ class ResponseHelper
): JsonResponse {
return response()->json([
'success' => false,
'message' => self::trans($messageKey, $messageParams, $domain)
], 404);
'message' => self::trans($messageKey, $messageParams, $domain),
], 404, [], self::JSON_ENCODE_OPTIONS);
}
/**
* 서버 내부 오류 응답을 생성합니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.error_occurred')
* @param mixed $error 오류 정보 (디버그 모드에서만 표시)
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 멤시지 키 (기본값: 'messages.error_occurred')
* @param mixed $error 오류 정보 (디버그 모드에서만 표시)
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 500 상태 코드를 가진 JSON 응답
*/
public static function serverError(
@@ -175,22 +184,22 @@ class ResponseHelper
if ($error instanceof \Throwable) {
if (config('app.debug')) {
$response['message'] .= ': ' . $error->getMessage();
$response['message'] .= ': '.$error->getMessage();
$response['debug'] = self::formatException($error);
}
} elseif ($error !== null && config('app.debug')) {
$response['error'] = $error;
}
return response()->json($response, 500);
return response()->json($response, 500, [], self::JSON_ENCODE_OPTIONS);
}
/**
* 다국어 메시지를 변환합니다.
*
* @param string $key 번역 키
* @param array $params 번역 매개변수
* @param string $domain 번역 도메인
* @param string $key 번역 키
* @param array $params 번역 매개변수
* @param string $domain 번역 도메인
* @return string 번역된 메시지
*/
private static function trans(
@@ -199,10 +208,10 @@ class ResponseHelper
string $domain = 'core'
): string {
$locale = self::getUserLocale();
// 도메인별로 다른 경로에서 번역 파일 로드
$translationKey = $domain === 'core' ? $key : "{$domain}::{$key}";
return __($translationKey, $params, $locale);
}
@@ -225,7 +234,7 @@ class ResponseHelper
/**
* 예외 정보를 디버그용 배열로 변환합니다.
*
* @param \Throwable $e 예외 인스턴스
* @param \Throwable $e 예외 인스턴스
* @return array 디버그 정보 배열
*/
private static function formatException(\Throwable $e): array
@@ -238,7 +247,7 @@ class ResponseHelper
'trace' => collect($e->getTrace())->take(10)->map(fn ($frame) => [
'file' => $frame['file'] ?? null,
'line' => $frame['line'] ?? null,
'function' => ($frame['class'] ?? '') . ($frame['type'] ?? '') . ($frame['function'] ?? ''),
'function' => ($frame['class'] ?? '').($frame['type'] ?? '').($frame['function'] ?? ''),
])->toArray(),
];
}
@@ -246,11 +255,11 @@ class ResponseHelper
/**
* 모듈별 성공 응답을 생성합니다.
*
* @param string $module 모듈명
* @param string $messageKey 멤시지 키
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @param string $module 모듈명
* @param string $messageKey 멤시지 키
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @return JsonResponse JSON 응답
*/
public static function moduleSuccess(
@@ -266,11 +275,11 @@ class ResponseHelper
/**
* 모듈별 실패 응답을 생성합니다.
*
* @param string $module 모듈명
* @param string $messageKey 멤시지 키
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
* @param mixed $errors 오류 정보
* @param array $messageParams 멤시지 매개변수
* @param string $module 모듈명
* @param string $messageKey 멤시지 키
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
* @param mixed $errors 오류 정보
* @param array $messageParams 멤시지 매개변수
* @return JsonResponse JSON 응답
*/
public static function moduleError(
@@ -286,11 +295,11 @@ class ResponseHelper
/**
* 플러그인별 성공 응답을 생성합니다.
*
* @param string $plugin 플러그인명
* @param string $messageKey 멤시지 키
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @param string $plugin 플러그인명
* @param string $messageKey 멤시지 키
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @return JsonResponse JSON 응답
*/
public static function pluginSuccess(
@@ -306,11 +315,11 @@ class ResponseHelper
/**
* 플러그인별 실패 응답을 생성합니다.
*
* @param string $plugin 플러그인명
* @param string $messageKey 멤시지 키
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
* @param mixed $errors 오류 정보
* @param array $messageParams 멤시지 매개변수
* @param string $plugin 플러그인명
* @param string $messageKey 멤시지 키
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
* @param mixed $errors 오류 정보
* @param array $messageParams 멤시지 매개변수
* @return JsonResponse JSON 응답
*/
public static function pluginError(
@@ -326,11 +335,11 @@ class ResponseHelper
/**
* JSON Resource를 사용한 성공 응답을 생성합니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.success')
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 멤시지 키 (기본값: 'messages.success')
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse JSON 응답
*/
public static function successWithResource(
@@ -341,22 +350,22 @@ class ResponseHelper
string $domain = 'core'
): JsonResponse {
$data = $resource ? $resource->resolve() : null;
return response()->json([
'success' => true,
'message' => self::trans($messageKey, $messageParams, $domain),
'data' => $data
], $statusCode);
'data' => $data,
], $statusCode, [], self::JSON_ENCODE_OPTIONS);
}
/**
* JSON Resource를 사용한 모듈 성공 응답을 생성합니다.
*
* @param string $module 모듈명
* @param string $messageKey 멤시지 키
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @param string $module 모듈명
* @param string $messageKey 멤시지 키
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @return JsonResponse JSON 응답
*/
public static function moduleSuccessWithResource(
@@ -372,11 +381,11 @@ class ResponseHelper
/**
* JSON Resource를 사용한 플러그인 성공 응답을 생성합니다.
*
* @param string $plugin 플러그인명
* @param string $messageKey 멤시지 키
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @param string $plugin 플러그인명
* @param string $messageKey 멤시지 키
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @return JsonResponse JSON 응답
*/
public static function pluginSuccessWithResource(
@@ -392,21 +401,21 @@ class ResponseHelper
/**
* 페이지네이션된 리소스 응답을 생성합니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.success')
* @param ResourceCollection|null $collection 페이지네이션된 리소스 컬렉션
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 멤시지 키 (기본값: 'messages.success')
* @param ResourceCollection|null $collection 페이지네이션된 리소스 컬렉션
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 페이지네이션 메타 데이터를 포함한 JSON 응답
*/
public static function successWithPagination(
string $messageKey = 'messages.success',
ResourceCollection|null $collection = null,
?ResourceCollection $collection = null,
array $messageParams = [],
string $domain = 'core'
): JsonResponse {
$response = [
'success' => true,
'message' => self::trans($messageKey, $messageParams, $domain)
'message' => self::trans($messageKey, $messageParams, $domain),
];
if ($collection) {
@@ -424,7 +433,7 @@ class ResponseHelper
$response['links'] = $paginationData['links'] ?? null;
}
return response()->json($response);
return response()->json($response, 200, [], self::JSON_ENCODE_OPTIONS);
}
/**
@@ -444,6 +453,6 @@ class ResponseHelper
'error_code' => 'identity_verification_required',
'message' => self::trans('identity.errors.verification_required', [], 'core'),
'verification' => $verification,
], 428);
], 428, [], self::JSON_ENCODE_OPTIONS);
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Base;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
@@ -20,11 +21,11 @@ abstract class BaseApiController extends Controller
/**
* 성공 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
* @param string $messageKey 메시지 키
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드
* @param array $messageParams 메시지 매개변수
* @return JsonResponse
*/
protected function success(
string $messageKey = 'common.success',
@@ -38,11 +39,11 @@ abstract class BaseApiController extends Controller
/**
* 실패 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param int $statusCode HTTP 상태 코드
* @param mixed $errors 오류 정보
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
* @param string $messageKey 메시지 키
* @param int $statusCode HTTP 상태 코드
* @param mixed $errors 오류 정보
* @param array $messageParams 메시지 매개변수
* @return JsonResponse
*/
protected function error(
string $messageKey = 'common.failed',
@@ -56,11 +57,11 @@ abstract class BaseApiController extends Controller
/**
* 리소스와 함께 성공 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param mixed $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
* @param string $messageKey 메시지 키
* @param mixed $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드
* @param array $messageParams 메시지 매개변수
* @return JsonResponse
*/
protected function successWithResource(
string $messageKey = 'common.success',
@@ -74,7 +75,7 @@ abstract class BaseApiController extends Controller
/**
* 현재 인증된 사용자를 반환합니다.
*
* @return \App\Models\User|null
* @return User|null
*/
protected function getCurrentUser()
{
@@ -84,9 +85,9 @@ abstract class BaseApiController extends Controller
/**
* Not Found 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return JsonResponse
*/
protected function notFound(
string $messageKey = 'common.not_found',
@@ -98,9 +99,9 @@ abstract class BaseApiController extends Controller
/**
* Unauthorized 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return JsonResponse
*/
protected function unauthorized(
string $messageKey = 'common.unauthorized',
@@ -112,9 +113,9 @@ abstract class BaseApiController extends Controller
/**
* Forbidden 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return JsonResponse
*/
protected function forbidden(
string $messageKey = 'common.forbidden',
@@ -126,10 +127,10 @@ abstract class BaseApiController extends Controller
/**
* Validation Error 응답을 생성합니다.
*
* @param mixed $errors 검증 오류
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
* @param mixed $errors 검증 오류
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return JsonResponse
*/
protected function validationError(
mixed $errors,
@@ -142,9 +143,9 @@ abstract class BaseApiController extends Controller
/**
* 파일 응답을 반환합니다 (ETag 및 캐싱 헤더 포함).
*
* @param string $filePath 파일 경로
* @param string $mimeType MIME 타입
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1년)
* @param string $filePath 파일 경로
* @param string $mimeType MIME 타입
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1년)
*/
protected function fileResponse(string $filePath, string $mimeType, int $maxAge = 31536000): BinaryFileResponse|Response
{
@@ -176,35 +177,35 @@ abstract class BaseApiController extends Controller
/**
* JSON 응답을 반환합니다 (캐싱 헤더 포함).
*
* @param mixed $data JSON으로 변환할 데이터
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간)
* @param int $status HTTP 상태 코드
* @param mixed $data JSON으로 변환할 데이터
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간)
* @param int $status HTTP 상태 코드
* @return JsonResponse JSON 응답
*/
protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse
{
return response()->json($data, $status, [
'Cache-Control' => "public, max-age={$maxAge}",
]);
], ResponseHelper::JSON_ENCODE_OPTIONS);
}
/**
* ETag를 생성합니다.
*
* @param mixed $data 해시할 데이터
* @param mixed $data 해시할 데이터
* @return string ETag 값 (따옴표 포함)
*/
protected function generateETag(mixed $data): string
{
$content = is_string($data) ? $data : json_encode($data);
return '"' . md5($content) . '"';
return '"'.md5($content).'"';
}
/**
* 클라이언트 캐시가 유효한지 확인합니다.
*
* @param string $etag 현재 ETag 값
* @param string $etag 현재 ETag 값
* @return bool 캐시가 유효하면 true
*/
protected function isNotModified(string $etag): bool
@@ -217,8 +218,8 @@ abstract class BaseApiController extends Controller
/**
* 304 Not Modified 응답을 반환합니다.
*
* @param string $etag ETag 값
* @param int $maxAge 캐시 TTL (초)
* @param string $etag ETag 값
* @param int $maxAge 캐시 TTL (초)
* @return Response 304 응답
*/
protected function notModifiedResponse(string $etag, int $maxAge = 3600): Response
@@ -233,10 +234,10 @@ abstract class BaseApiController extends Controller
*
* 클라이언트의 ETag가 일치하면 304 Not Modified를 반환합니다.
*
* @param string $messageKey 메시지 키
* @param mixed $data 응답 데이터
* @param int $maxAge 캐시 TTL (초, 기본: 1시간)
* @param array $messageParams 메시지 매개변수
* @param string $messageKey 메시지 키
* @param mixed $data 응답 데이터
* @param int $maxAge 캐시 TTL (초, 기본: 1시간)
* @param array $messageParams 메시지 매개변수
* @return JsonResponse|Response JSON 응답 또는 304 응답
*/
protected function successWithCache(
@@ -256,7 +257,7 @@ abstract class BaseApiController extends Controller
'success' => true,
'message' => __($messageKey, $messageParams),
'data' => $data,
])
], 200, [], ResponseHelper::JSON_ENCODE_OPTIONS)
->header('ETag', $etag)
->header('Cache-Control', "public, max-age={$maxAge}")
->header('Vary', 'Accept-Encoding, Accept-Language');
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\Module\ServeModuleAssetRequest;
use App\Services\ExtensionBundleService;
use App\Services\ModuleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
@@ -17,11 +18,54 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PublicModuleController extends PublicBaseController
{
public function __construct(
private readonly ModuleService $moduleService
private readonly ModuleService $moduleService,
private readonly ExtensionBundleService $bundleService
) {
parent::__construct();
}
/**
* 활성 모듈 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
*
* 활성 global 모듈 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다
* (프론트는 빈 스크립트 로드로 무해). 그 외에는 병합 파일을 fileResponse 로
* 서빙(ETag/304/환경별 Cache-Control 재사용).
*
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
*/
public function serveBundleJs(): BinaryFileResponse|Response
{
$this->logApiUsage('modules.bundle', ['kind' => 'js']);
$version = $this->bundleService->getCurrentVersion();
$path = $this->bundleService->getBundleFilePath('module', 'js', $version);
if ($path === '') {
return response('', 200)->header('Content-Type', 'text/javascript');
}
return $this->fileResponse($path, 'text/javascript', 31536000);
}
/**
* 활성 모듈 프론트엔드 병합 번들(CSS)을 서빙합니다.
*
* @return BinaryFileResponse|Response 병합 CSS 파일 응답 또는 빈 응답
*/
public function serveBundleCss(): BinaryFileResponse|Response
{
$this->logApiUsage('modules.bundle', ['kind' => 'css']);
$version = $this->bundleService->getCurrentVersion();
$path = $this->bundleService->getBundleFilePath('module', 'css', $version);
if ($path === '') {
return response('', 200)->header('Content-Type', 'text/css');
}
return $this->fileResponse($path, 'text/css', 31536000);
}
/**
* 모듈 에셋 서빙
*
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\Plugin\ServePluginAssetRequest;
use App\Services\ExtensionBundleService;
use App\Services\PluginService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
@@ -17,11 +18,53 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PublicPluginController extends PublicBaseController
{
public function __construct(
private readonly PluginService $pluginService
private readonly PluginService $pluginService,
private readonly ExtensionBundleService $bundleService
) {
parent::__construct();
}
/**
* 활성 플러그인 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
*
* 활성 global 플러그인 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다.
* 그 외에는 병합 파일을 fileResponse 로 서빙(ETag/304/환경별 Cache-Control 재사용).
*
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
*/
public function serveBundleJs(): BinaryFileResponse|Response
{
$this->logApiUsage('plugins.bundle', ['kind' => 'js']);
$version = $this->bundleService->getCurrentVersion();
$path = $this->bundleService->getBundleFilePath('plugin', 'js', $version);
if ($path === '') {
return response('', 200)->header('Content-Type', 'text/javascript');
}
return $this->fileResponse($path, 'text/javascript', 31536000);
}
/**
* 활성 플러그인 프론트엔드 병합 번들(CSS)을 서빙합니다.
*
* @return BinaryFileResponse|Response 병합 CSS 파일 응답 또는 빈 응답
*/
public function serveBundleCss(): BinaryFileResponse|Response
{
$this->logApiUsage('plugins.bundle', ['kind' => 'css']);
$version = $this->bundleService->getCurrentVersion();
$path = $this->bundleService->getBundleFilePath('plugin', 'css', $version);
if ($path === '') {
return response('', 200)->header('Content-Type', 'text/css');
}
return $this->fileResponse($path, 'text/css', 31536000);
}
/**
* 플러그인 에셋 서빙
*
+75 -3
View File
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -98,6 +99,15 @@ class GzipEncodeResponse
}
// 최소 크기 이상인지 확인
// BinaryFileResponse 는 getContent() 가 false 를 반환하므로 파일 크기로 판정한다.
// (번들 JS/CSS 서빙은 response()->file() 로 BinaryFileResponse 를 반환하며,
// 이 분기가 없으면 미들웨어가 압축 없이 통과시켜 비압축 전송된다.)
if ($response instanceof BinaryFileResponse) {
$file = $response->getFile();
return $file !== null && $file->getSize() >= self::MIN_COMPRESS_SIZE;
}
$content = $response->getContent();
if ($content === false || strlen($content) < self::MIN_COMPRESS_SIZE) {
return false;
@@ -158,6 +168,12 @@ class GzipEncodeResponse
*/
private function compressResponse(Response $response): Response
{
// BinaryFileResponse 는 본문을 setContent() 로 교체할 수 없으므로
// 파일 내용을 읽어 일반 Response 로 치환한 뒤 압축한다 (헤더는 승계).
if ($response instanceof BinaryFileResponse) {
return $this->compressBinaryFileResponse($response);
}
$content = $response->getContent();
// gzip 압축 적용
@@ -173,14 +189,70 @@ class GzipEncodeResponse
$response->headers->set('Content-Encoding', 'gzip');
$response->headers->set('Content-Length', (string) strlen($compressed));
// Vary 헤더가 없으면 추가 (캐시 프록시 지원)
$this->ensureVaryAcceptEncoding($response);
return $response;
}
/**
* BinaryFileResponse 를 gzip 압축된 일반 Response 로 치환합니다.
*
* BinaryFileResponse 는 파일 스트림 기반이라 setContent() 로 본문을 교체할 수 없으므로,
* 파일 내용을 읽어 gzip 압축한 새 Response 를 생성하고 기존 헤더(Content-Type,
* ETag, Cache-Control, Expires 등)를 승계한다. Content-Encoding/Content-Length 는
* 압축 결과로 재설정한다.
*
* @param BinaryFileResponse $response 파일 응답
* @return Response gzip 압축된 응답 (실패 시 원본 반환)
*/
private function compressBinaryFileResponse(BinaryFileResponse $response): Response
{
$file = $response->getFile();
if ($file === null) {
return $response;
}
$content = @file_get_contents($file->getPathname());
if ($content === false) {
// 파일 읽기 실패 시 원본(비압축) 반환
return $response;
}
$compressed = gzencode($content, self::COMPRESSION_LEVEL);
if ($compressed === false) {
return $response;
}
// 기존 헤더를 승계한 새 Response 생성 (본문만 압축된 내용으로 교체)
$compressedResponse = new Response(
$compressed,
$response->getStatusCode(),
$response->headers->all()
);
$compressedResponse->headers->set('Content-Encoding', 'gzip');
$compressedResponse->headers->set('Content-Length', (string) strlen($compressed));
$this->ensureVaryAcceptEncoding($compressedResponse);
return $compressedResponse;
}
/**
* 응답에 `Vary: Accept-Encoding` 헤더를 보장합니다 (캐시 프록시 지원).
*
* @param Response $response HTTP 응답
*/
private function ensureVaryAcceptEncoding(Response $response): void
{
if (! $response->headers->has('Vary')) {
$response->headers->set('Vary', 'Accept-Encoding');
} elseif (! str_contains($response->headers->get('Vary', ''), 'Accept-Encoding')) {
$currentVary = $response->headers->get('Vary');
$response->headers->set('Vary', $currentVary.', Accept-Encoding');
}
return $response;
}
}
@@ -99,6 +99,9 @@ class TemplateComposer
// 확장 기능 캐시 버전 (브라우저 캐시 무효화용)
$extensionCacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
// 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null)
$bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion);
$view->with('activeAdminTemplate', $activeTemplate);
$view->with('extensionCacheVersion', $extensionCacheVersion);
$view->with('frontendSettings', $frontendSettings);
@@ -106,12 +109,39 @@ class TemplateComposer
$view->with('moduleSettings', $moduleSettings);
$view->with('moduleAssets', $moduleAssets);
$view->with('pluginAssets', $pluginAssets);
$view->with('bundleUrls', $bundleUrls);
$view->with('activeModulesMeta', $activeModulesMeta);
$view->with('activePluginsMeta', $activePluginsMeta);
$view->with('appConfig', $appConfig);
$view->with('templateExternals', $templateExternals);
}
/**
* 확장 프론트엔드 병합 번들 URL을 계산합니다.
*
* 활성 global 에셋(JS/CSS)이 하나라도 있는 타입만 해당 번들 URL을 채우고,
* 없으면 null을 두어 프론트가 로드를 스킵하게 한다. URL은 same-origin
* (`/api/...`)이어야 gdpr preblocker 의 same-origin 통과 규칙에 자기 차단되지
* 않는다(CDN 호스팅 금지).
*
* @param array $moduleAssets 수집된 모듈 개별 에셋 맵
* @param array $pluginAssets 수집된 플러그인 개별 에셋 맵
* @param int $version 확장 캐시 버전
* @return array{moduleJs: ?string, moduleCss: ?string, pluginJs: ?string, pluginCss: ?string}
*/
private function buildExtensionBundleUrls(array $moduleAssets, array $pluginAssets, int $version): array
{
$hasJs = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['js'])));
$hasCss = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['css'])));
return [
'moduleJs' => $hasJs($moduleAssets) ? "/api/modules/bundle.js?v={$version}" : null,
'moduleCss' => $hasCss($moduleAssets) ? "/api/modules/bundle.css?v={$version}" : null,
'pluginJs' => $hasJs($pluginAssets) ? "/api/plugins/bundle.js?v={$version}" : null,
'pluginCss' => $hasCss($pluginAssets) ? "/api/plugins/bundle.css?v={$version}" : null,
];
}
/**
* 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다.
*
@@ -105,6 +105,9 @@ class UserTemplateComposer
// 확장 기능 캐시 버전 (브라우저 캐시 무효화용)
$extensionCacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
// 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null)
$bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion);
$view->with('activeUserTemplate', $activeTemplate);
$view->with('extensionCacheVersion', $extensionCacheVersion);
$view->with('frontendSettings', $frontendSettings);
@@ -112,12 +115,39 @@ class UserTemplateComposer
$view->with('moduleSettings', $moduleSettings);
$view->with('moduleAssets', $moduleAssets);
$view->with('pluginAssets', $pluginAssets);
$view->with('bundleUrls', $bundleUrls);
$view->with('activeModulesMeta', $activeModulesMeta);
$view->with('activePluginsMeta', $activePluginsMeta);
$view->with('appConfig', $appConfig);
$view->with('templateExternals', $templateExternals);
}
/**
* 확장 프론트엔드 병합 번들 URL을 계산합니다.
*
* 활성 global 에셋(JS/CSS)이 하나라도 있는 타입만 해당 번들 URL을 채우고,
* 없으면 null을 두어 프론트가 로드를 스킵하게 한다. URL은 same-origin
* (`/api/...`)이어야 gdpr preblocker 의 same-origin 통과 규칙에 자기 차단되지
* 않는다(CDN 호스팅 금지).
*
* @param array $moduleAssets 수집된 모듈 개별 에셋 맵
* @param array $pluginAssets 수집된 플러그인 개별 에셋 맵
* @param int $version 확장 캐시 버전
* @return array{moduleJs: ?string, moduleCss: ?string, pluginJs: ?string, pluginCss: ?string}
*/
private function buildExtensionBundleUrls(array $moduleAssets, array $pluginAssets, int $version): array
{
$hasJs = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['js'])));
$hasCss = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['css'])));
return [
'moduleJs' => $hasJs($moduleAssets) ? "/api/modules/bundle.js?v={$version}" : null,
'moduleCss' => $hasCss($moduleAssets) ? "/api/modules/bundle.css?v={$version}" : null,
'pluginJs' => $hasJs($pluginAssets) ? "/api/plugins/bundle.js?v={$version}" : null,
'pluginCss' => $hasCss($pluginAssets) ? "/api/plugins/bundle.css?v={$version}" : null,
];
}
/**
* 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다.
*
+81 -6
View File
@@ -885,7 +885,16 @@ class CoreUpdateService
}
/**
* 코어 업데이트 대상 파일만 선택적으로 덮어씁니다.
* 코어 업데이트 대상 파일을 덮어씁니다.
*
* 기본(증분) 모드 — `$applyList` 가 주어지고 `$prune === false`:
* - 코어(신 버전)가 실제로 추가·변경한 파일만 적용한다(3-way 판정 산출물).
* - 코어가 건드리지 않은 파일은 복사·chmod·chown·mtime 갱신을 전부 스킵하여 현재
* 디스크 상태(사용자 수정 포함 가능)를 그대로 보존한다.
* - orphan(소스에 없는 대상 파일) 삭제를 하지 않는다 — 사용자가 추가한 신규 파일 보존.
*
* prune 모드 — `$prune === true` 또는 `$applyList === null`(백업 부재 fallback):
* - targets 전체를 무조건 덮어쓰고, 소스에 없는 orphan 을 삭제한다(기존 동작).
*
* 주의: ExtensionPendingHelper::copyToActive()는 PHP copy()를 사용하여
* 파일 퍼미션/소유자를 보존하지 않으므로, 코어 업데이트에서는 사용하지 않습니다.
@@ -893,12 +902,29 @@ class CoreUpdateService
*
* @param string $sourcePath 소스 경로 (_pending 내)
* @param \Closure|null $onProgress 진행 콜백
* @param bool $prune true 면 전체 덮어쓰기 + orphan 삭제(기존 동작), false 면 증분 적용
* @param array<int, string>|null $applyList 적용 대상 상대경로 목록(target 접두사 포함).
* `CoreBackupHelper::computeApplyList()` 산출물. null 이면 증분 불가로 판단해 전체 덮어쓰기.
*/
public function applyUpdate(string $sourcePath, ?\Closure $onProgress = null): void
public function applyUpdate(string $sourcePath, ?\Closure $onProgress = null, bool $prune = false, ?array $applyList = null): void
{
$targets = config('app.update.targets', []);
$excludes = config('app.update.excludes', []);
// 증분 모드 여부: prune 이 아니고 적용 목록이 주어진 경우에만 증분 적용.
// applyList 가 null 이면(백업 부재 등) 안전을 위해 전체 덮어쓰기로 회귀한다.
$incremental = ! $prune && $applyList !== null;
// 증분 모드에서 각 target 하위 파일을 O(1) 로 조회하기 위한 lookup 맵.
// computeApplyList 는 target 접두사를 포함한 상대경로(예: 'public/.htaccess')를
// 반환하므로, 그대로 정규화 키로 담는다.
$applySet = [];
if ($incremental) {
foreach ($applyList as $rel) {
$applySet[$this->normalizeRelativePath((string) $rel)] = true;
}
}
$applied = [];
foreach ($targets as $target) {
@@ -909,21 +935,45 @@ class CoreUpdateService
continue;
}
$normalizedTarget = $this->normalizeRelativePath($target);
$onProgress?->__invoke('apply', $target);
if (File::isDirectory($src)) {
// 증분 모드: 이 target 하위의 적용 대상만 추린 뒤, target 접두사를 벗겨
// copyDirectory 의 내부 상대경로($itemRelativePath)와 직접 매칭되는
// 화이트리스트 맵을 만든다.
$targetApplyList = null;
if ($incremental) {
$targetApplyList = $this->buildTargetApplyList($applySet, $normalizedTarget);
}
// `{domain}/_bundled` 타깃은 최상위 한 레벨의 orphan 을 보존한다 — 사용자가
// `_bundled/` 바로 아래에 직접 만든 커스텀 확장 디렉토리/파일이 코어 업데이트
// 소스(번들 확장만 포함)에 없다는 이유로 삭제되던 결함 차단.
// 번들 확장 디렉토리 *내부* stale 정리는 그대로 유지된다.
$preserveTopLevelOrphans = str_ends_with($this->normalizeRelativePath($target), '_bundled');
FilePermissionHelper::copyDirectory($src, $dest, $onProgress, $excludes, removeOrphans: true, preserveTopLevelOrphans: $preserveTopLevelOrphans);
// 번들 확장 디렉토리 *내부* stale 정리는 prune 모드에서만 수행된다.
$preserveTopLevelOrphans = str_ends_with($normalizedTarget, '_bundled');
FilePermissionHelper::copyDirectory(
$src,
$dest,
$onProgress,
$excludes,
removeOrphans: $prune,
preserveTopLevelOrphans: $preserveTopLevelOrphans,
applyList: $targetApplyList,
);
} else {
// 단일 파일 target — 증분 모드에서는 적용 목록에 있을 때만 복사.
if ($incremental && ! isset($applySet[$normalizedTarget])) {
$applied[$normalizedTarget] = true;
continue;
}
File::ensureDirectoryExists(dirname($dest));
FilePermissionHelper::copyFile($src, $dest);
}
$applied[$this->normalizeRelativePath($target)] = true;
$applied[$normalizedTarget] = true;
}
// 자동 발견 폴백 — 부모 프로세스(구버전) 의 stale `app.update.targets` 가
@@ -1005,6 +1055,31 @@ class CoreUpdateService
return trim(str_replace(['\\', '/'], '/', $path), '/');
}
/**
* 전체 적용 집합에서 특정 target 하위 항목만 추려, target 접두사를 제거한
* `copyDirectory` 내부 상대경로 화이트리스트 맵을 만듭니다.
*
* `computeApplyList` 는 target 접두사를 포함한 경로(예: `public/.htaccess`)를 담지만,
* `copyDirectory` 는 target 루트 기준 상대경로(`.htaccess`)로 항목을 순회하므로 접두사를
* 벗겨야 매칭된다.
*
* @param array<string, bool> $applySet 전체 적용 대상 정규화 경로 lookup 맵
* @param string $normalizedTarget 정규화된 target 경로 (예: `public`)
* @return array<string, bool> target 내부 상대경로 화이트리스트 (빈 맵일 수 있음 → 전부 스킵)
*/
private function buildTargetApplyList(array $applySet, string $normalizedTarget): array
{
$prefix = $normalizedTarget.'/';
$out = [];
foreach ($applySet as $rel => $_) {
if (str_starts_with((string) $rel, $prefix)) {
$out[substr((string) $rel, strlen($prefix))] = true;
}
}
return $out;
}
/**
* 정규화된 path 가 이미 처리된 상위/하위 path 와 관계가 있는지 검사합니다.
*
+475
View File
@@ -0,0 +1,475 @@
<?php
namespace App\Services;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\Storage\CoreStorageDriver;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\View\Composers\TemplateComposer;
use Illuminate\Support\Facades\Log;
/**
* 확장(모듈/플러그인) 프론트엔드 IIFE/CSS 번들 병합 서비스
*
* 활성 모듈/플러그인의 개별 IIFE JS·CSS 에셋을 타입별로 하나의 번들 파일로
* 이어붙여(concat) 서빙 오버헤드(HTTP 요청 수)를 줄인다. 각 확장 IIFE 는
* 자체 클로저에서 자가등록(`window.G7ModuleRegistry`/`G7PluginRegistry` +
* 핸들러/리스너)을 수행하므로, N개 IIFE 를 priority 순으로 이어붙여 1개
* `<script>` 로 실행해도 등록 로직은 동일하게 동작한다.
*
* 정렬/필터(`hasAssets()` && strategy==='global' + `uasort(priority)`) 는
* TemplateComposer 와 공유하는 SSoT 로 이 서비스에 둔다(drift 방지).
*
* 경로는 절대경로 게터(`getBuiltAssetAbsolutePaths()`)를 재사용한다 —
* `ModuleService::getAssetFilePath()` 의 `base_path("modules/{id}/...")`
* 하드코딩을 복제하지 않아야 `_bundled` 확장에서도 정확히 읽는다(제약 4).
*
* @see TemplateComposer
*/
class ExtensionBundleService
{
// ext.cache_version 게터 재사용 — 트레이트를 use 해 self:: 로 호출(트레이트명
// 직접 정적 호출은 PHP 8.3+ deprecated). 인스턴스 캐시 무효화 메서드는 미사용.
use ClearsTemplateCaches;
/**
* 번들 캐시 파일이 저장되는 스토리지 디스크(= storage/app/ext-bundles).
*/
private const BUNDLE_DISK = 'ext-bundles';
/**
* 서비스 주입
*
* @param ModuleManager $moduleManager 모듈 매니저
* @param PluginManager $pluginManager 플러그인 매니저
*/
public function __construct(
private readonly ModuleManager $moduleManager,
private readonly PluginManager $pluginManager
) {}
/**
* 확장 타입별 global 전략 에셋을 priority 오름차순으로 정렬해 반환합니다.
*
* TemplateComposer 의 개별 에셋 URL 생성과 번들러의 concat 이 동일한
* 필터/정렬을 쓰도록 하는 SSoT. 순서 제어는 오직 manifest
* `loading.priority` 숫자 오름차순뿐이며 특정 확장 이름 하드코딩은 없다(제약 1).
*
* @param string $type 'module' | 'plugin'
* @return array<string, array{jsAbsPath: ?string, cssAbsPath: ?string, priority: int}>
* identifier => 절대경로/우선순위 (priority 오름차순 정렬)
*/
public function getOrderedGlobalAssetPaths(string $type): array
{
$extensions = $type === 'plugin'
? $this->pluginManager->getActivePlugins()
: $this->moduleManager->getActiveModules();
$ordered = [];
foreach ($extensions as $extension) {
if (! $extension->hasAssets()) {
continue;
}
$loadingConfig = $extension->getAssetLoadingConfig();
// global 전략만 번들 대상 (layout, lazy 는 레이아웃에서 개별 처리)
if (($loadingConfig['strategy'] ?? 'global') !== 'global') {
continue;
}
$absolutePaths = $extension->getBuiltAssetAbsolutePaths();
$jsAbsPath = $absolutePaths['js'] ?? null;
$cssAbsPath = $absolutePaths['css'] ?? null;
// JS/CSS 둘 다 없으면 번들에 기여할 것이 없으므로 제외
if ($jsAbsPath === null && $cssAbsPath === null) {
continue;
}
$ordered[$extension->getIdentifier()] = [
'jsAbsPath' => $jsAbsPath,
'cssAbsPath' => $cssAbsPath,
'priority' => (int) ($loadingConfig['priority'] ?? 100),
];
}
// priority 오름차순 (낮을수록 먼저) — 개별 로딩과 동일 규칙
uasort($ordered, fn ($a, $b) => $a['priority'] <=> $b['priority']);
return $ordered;
}
/**
* 확장 타입의 JS 번들 문자열을 생성합니다.
*
* priority 순으로 각 IIFE 파일을 읽어 `\n;\n` 구분자로 이어붙인다(제약 2 —
* ASI 경계 보호). 각 파일 끝의 `//# sourceMappingURL` 주석은 prod 에서는
* strip(맵 생략), dev 에서는 개별 에셋 서빙 절대 URL 로 rewrite 한다(제약 3).
*
* 확장별 fine-grained try/catch — 파일 읽기 실패 시 해당 확장만 skip +
* Log::warning, 나머지 병합 지속(한 확장 실패가 번들 전체를 붕괴시키지 않음).
*
* @param string $type 'module' | 'plugin'
* @return string 병합된 JS (활성 global 에셋이 없으면 빈 문자열)
*/
public function buildJsBundle(string $type): string
{
$ordered = $this->getOrderedGlobalAssetPaths($type);
$isProduction = app()->environment('production');
$segments = [];
foreach ($ordered as $identifier => $paths) {
if (empty($paths['jsAbsPath'])) {
continue;
}
try {
$content = @file_get_contents($paths['jsAbsPath']);
if ($content === false) {
Log::warning('확장 JS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [
'type' => $type,
'identifier' => $identifier,
'path' => $paths['jsAbsPath'],
]);
continue;
}
$segments[] = $this->processJsSourceMap($content, $type, $identifier, $isProduction);
} catch (\Throwable $e) {
Log::warning('확장 JS 번들 병합 중 오류, 해당 확장 skip', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
}
}
return implode("\n;\n", $segments);
}
/**
* 확장 타입의 CSS 번들 문자열을 생성합니다.
*
* priority 순으로 각 CSS 파일을 읽어 `\n` 구분자로 이어붙인다. 상대경로
* `url(...)` 참조가 있는 CSS 는 병합 시 경로가 깨지므로 번들에서 제외하고
* 경고 로그를 남긴다(안전장치 — 현재 번들 CSS 는 url() 0건).
*
* @param string $type 'module' | 'plugin'
* @return string 병합된 CSS (활성 global 에셋이 없으면 빈 문자열)
*/
public function buildCssBundle(string $type): string
{
$ordered = $this->getOrderedGlobalAssetPaths($type);
$segments = [];
foreach ($ordered as $identifier => $paths) {
if (empty($paths['cssAbsPath'])) {
continue;
}
try {
$content = @file_get_contents($paths['cssAbsPath']);
if ($content === false) {
Log::warning('확장 CSS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [
'type' => $type,
'identifier' => $identifier,
'path' => $paths['cssAbsPath'],
]);
continue;
}
// 상대경로 url() 참조가 있으면 병합 시 폰트/이미지 경로가 깨진다.
// 절대/data URI 는 안전하므로 상대경로만 검출해 해당 CSS 제외.
if ($this->hasRelativeUrl($content)) {
Log::warning('확장 CSS 에 상대경로 url() 존재 — 번들에서 제외(개별 폴백 유지)', [
'type' => $type,
'identifier' => $identifier,
]);
continue;
}
$segments[] = $content;
} catch (\Throwable $e) {
Log::warning('확장 CSS 번들 병합 중 오류, 해당 확장 skip', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
}
}
return implode("\n", $segments);
}
/**
* 캐시된 번들 파일의 절대 경로를 반환합니다(없으면 build → 원자적 write).
*
* 파일명에 version 을 포함(`{type}.{version}.{js|css}`)하므로 활성 조합이
* 바뀌어 version 이 bump 되면 새 파일명으로 자연 무효화된다. 프로덕션에서만
* 디스크 캐시하며, 비프로덕션(dev/watch)에서는 캐시하지 않고 매 요청 build 해
* rebuild 를 즉시 반영한다.
*
* @param string $type 'module' | 'plugin'
* @param string $kind 'js' | 'css'
* @param int $version 확장 캐시 버전(ClearsTemplateCaches::getExtensionCacheVersion)
* @return string 캐시(또는 방금 build 한) 파일의 절대 경로. 병합 결과가 빈 문자열이면 빈 문자열.
*/
public function getBundleFilePath(string $type, string $kind, int $version): string
{
$content = $kind === 'css'
? $this->buildCssBundle($type)
: $this->buildJsBundle($type);
// 병합할 에셋이 하나도 없으면 파일을 만들지 않는다(호출측이 빈 문자열로 판단).
if ($content === '') {
return '';
}
$storage = $this->bundleStorage();
$relativeName = $this->bundleFileName($type, $kind, $version);
// 비프로덕션은 캐시하지 않고 임시 파일로 매번 build → rebuild 즉시 반영
if (! app()->environment('production')) {
return $this->writeAtomically($storage, $relativeName, $content, cache: false);
}
// 프로덕션: 동일 version 캐시가 있으면 그대로 사용
if ($storage->exists('', $relativeName)) {
return $storage->getBasePath('').'/'.$relativeName;
}
return $this->writeAtomically($storage, $relativeName, $content, cache: true);
}
/**
* 현재 version 외의 오래된 번들 파일을 삭제하고 삭제 건수를 반환합니다.
*
* @param int $currentVersion 보존할 현재 캐시 버전
* @return int 삭제된 파일 수
*/
public function cleanupStaleBundles(int $currentVersion): int
{
$storage = $this->bundleStorage();
$deleted = 0;
foreach ($storage->files('', '') as $file) {
$name = basename($file);
// 현재 version 파일과 .gitignore 는 보존
if ($name === '.gitignore' || $this->matchesVersion($name, $currentVersion)) {
continue;
}
if ($this->isBundleFile($name) && $storage->delete('', $name)) {
$deleted++;
}
}
return $deleted;
}
/**
* 번들 캐시 파일을 삭제합니다(cache-clear 커맨드용).
*
* @param string|null $type 'module' | 'plugin' 지정 시 해당 타입만, null 이면 전체
* @return int 삭제된 파일 수
*/
public function clearBundles(?string $type = null): int
{
$storage = $this->bundleStorage();
$deleted = 0;
foreach ($storage->files('', '') as $file) {
$name = basename($file);
if ($name === '.gitignore' || ! $this->isBundleFile($name)) {
continue;
}
// 타입 필터 (파일명 접두사 `{type}.`)
if ($type !== null && ! str_starts_with($name, $type.'.')) {
continue;
}
if ($storage->delete('', $name)) {
$deleted++;
}
}
return $deleted;
}
/**
* 번들 파일명을 생성합니다(`{type}.{version}.{kind}`).
*
* @param string $type 'module' | 'plugin'
* @param string $kind 'js' | 'css'
* @param int $version 캐시 버전
* @return string 파일명 (디렉토리 제외)
*/
private function bundleFileName(string $type, string $kind, int $version): string
{
return "{$type}.{$version}.{$kind}";
}
/**
* 파일명이 번들 파일 패턴(`{type}.{version}.{kind}`)인지 확인합니다.
*
* @param string $name 파일명
* @return bool 번들 파일이면 true
*/
private function isBundleFile(string $name): bool
{
return (bool) preg_match('/^(module|plugin)\.\d+\.(js|css)$/', $name);
}
/**
* 파일명이 지정한 version 의 번들인지 확인합니다.
*
* @param string $name 파일명
* @param int $version 비교할 버전
* @return bool 해당 version 파일이면 true
*/
private function matchesVersion(string $name, int $version): bool
{
return (bool) preg_match('/^(module|plugin)\.'.preg_quote((string) $version, '/').'\.(js|css)$/', $name);
}
/**
* 병합 결과를 원자적으로(임시 파일 → rename) 기록하고 절대 경로를 반환합니다.
*
* @param CoreStorageDriver $storage 번들 디스크 스토리지
* @param string $relativeName 대상 파일명
* @param string $content 기록할 내용
* @param bool $cache true 면 version 파일명 유지, false 면 임시 파일 사용
* @return string 기록된 파일의 절대 경로
*/
private function writeAtomically(CoreStorageDriver $storage, string $relativeName, string $content, bool $cache): string
{
$basePath = $storage->getBasePath('');
if (! is_dir($basePath)) {
@mkdir($basePath, 0o755, true);
}
$finalPath = $basePath.'/'.$relativeName;
if (! $cache) {
// 비프로덕션: 매 요청 덮어써도 무방(원자성 불요), 그대로 write
$storage->put('', $relativeName, $content);
return $finalPath;
}
// 프로덕션: 임시 파일에 쓴 뒤 rename 으로 원자적 게시(부분 파일 서빙 방지)
$tmpName = $relativeName.'.tmp.'.getmypid();
$storage->put('', $tmpName, $content);
$tmpPath = $basePath.'/'.$tmpName;
if (! @rename($tmpPath, $finalPath)) {
// rename 실패 시(경합으로 이미 존재 등) 임시 파일 정리 후 최종 경로 사용
$storage->delete('', $tmpName);
}
return $finalPath;
}
/**
* IIFE 소스맵 주석을 환경에 맞게 처리합니다.
*
* prod: `//# sourceMappingURL` 라인 strip(맵 생략).
* dev: 개별 에셋 서빙 절대 URL(`/api/{type}s/assets/{id}/dist/js/*.map`)로
* rewrite → 브라우저가 확장별 원본 맵을 추적(완벽한 통합 맵은 아님).
*
* @param string $content 원본 IIFE 내용
* @param string $type 'module' | 'plugin'
* @param string $identifier 확장 식별자
* @param bool $isProduction 프로덕션 여부
* @return string 처리된 내용
*/
private function processJsSourceMap(string $content, string $type, string $identifier, bool $isProduction): string
{
// 구분자로 `~` 사용 — 패턴 자체에 `#`(`//#`)가 포함되어 `#` 구분자는 못 씀
$pattern = '~//# sourceMappingURL=(\S+)~';
if ($isProduction) {
// prod: 맵 참조 제거
return preg_replace($pattern, '', $content) ?? $content;
}
// dev: 상대 맵 파일명을 개별 에셋 서빙 절대 URL 로 rewrite
$typeSegment = $type === 'plugin' ? 'plugins' : 'modules';
return preg_replace_callback($pattern, function (array $m) use ($typeSegment, $identifier) {
$mapFile = ltrim($m[1], './');
return '//# sourceMappingURL=/api/'.$typeSegment.'/assets/'.$identifier.'/dist/js/'.basename($mapFile);
}, $content) ?? $content;
}
/**
* CSS 내용에 상대경로 url() 참조가 있는지 확인합니다.
*
* 절대 URL(http/https), 루트 절대경로(/), data URI 는 병합에 안전하므로
* 그 외의 url() 참조만 상대경로로 간주한다.
*
* @param string $css CSS 내용
* @return bool 상대경로 url() 이 하나라도 있으면 true
*/
private function hasRelativeUrl(string $css): bool
{
if (! preg_match_all('/url\(\s*[\'"]?([^\'")]+)[\'"]?\s*\)/i', $css, $matches)) {
return false;
}
foreach ($matches[1] as $url) {
$url = trim($url);
if ($url === '') {
continue;
}
$isAbsolute = str_starts_with($url, 'http://')
|| str_starts_with($url, 'https://')
|| str_starts_with($url, '//')
|| str_starts_with($url, '/')
|| str_starts_with($url, 'data:');
if (! $isAbsolute) {
return true;
}
}
return false;
}
/**
* 번들 디스크용 스토리지 드라이버를 반환합니다(StorageInterface 경유).
*
* @return CoreStorageDriver ext-bundles 디스크 스토리지
*/
private function bundleStorage(): CoreStorageDriver
{
return (new CoreStorageDriver)->withDisk(self::BUNDLE_DISK);
}
/**
* 현재 확장 캐시 버전을 반환합니다.
*
* @return int 캐시 버전
*/
public function getCurrentVersion(): int
{
return self::getExtensionCacheVersion();
}
}
+36
View File
@@ -807,6 +807,12 @@ class LayoutService
// 캐시 비활성 시 매번 병합 실행
if (! $cacheEnabled) {
$merged = $this->loadAndMergeLayoutInternal($templateId, $layoutName, $withSourceMeta);
// 공개 서빙 응답에서 개발자용 comment 필드 제거 (편집 모드는 보존)
if (! $withSourceMeta) {
$merged = $this->stripDeveloperComments($merged);
}
HookManager::doAction('core.layout.after_load', $merged, $templateId, $layoutName, false);
return $merged;
@@ -844,6 +850,12 @@ class LayoutService
// 병합된 레이아웃 생성
$mergedLayout = $this->loadAndMergeLayoutInternal($templateId, $layoutName, $withSourceMeta);
// 공개 서빙 응답에서 개발자용 comment 필드 제거 (편집 모드는 보존).
// withSourceMeta 가 캐시 키에 반영되므로 공개/편집 캐시가 분리되어 안전.
if (! $withSourceMeta) {
$mergedLayout = $this->stripDeveloperComments($mergedLayout);
}
// 캐시에 저장
$cacheTtl = $this->getCacheTtl();
$this->cache->put($cacheKey, $mergedLayout, $cacheTtl);
@@ -1601,6 +1613,30 @@ class LayoutService
}, $components);
}
/**
* 병합된 레이아웃에서 개발자용 comment 필드를 재귀적으로 제거합니다.
*
* 레이아웃 JSON 의 `comment` / `_comment` 키는 편집기(layout-editor)와 개발자를 위한
* 설명 주석으로, 프론트엔드 런타임 렌더러(DynamicRenderer)는 이를 참조하지 않는다.
* 공개 서빙 응답에서만 제거하여 전송 크기를 줄이며, 편집 모드(withSourceMeta)에서는
* 호출하지 않아 보존한다. 배열/객체 노드를 재귀 순회하며 두 키만 unset 한다.
*
* @param array $node 정리 대상 레이아웃(또는 하위) 배열
* @return array comment 필드가 제거된 배열
*/
private function stripDeveloperComments(array $node): array
{
unset($node['comment'], $node['_comment']);
foreach ($node as $key => $value) {
if (is_array($value)) {
$node[$key] = $this->stripDeveloperComments($value);
}
}
return $node;
}
/**
* 컴포넌트의 props를 sanitize합니다.
*
+2 -2
View File
@@ -231,7 +231,7 @@ return [
|
*/
'version' => env('APP_VERSION', '7.0.1'),
'version' => env('APP_VERSION', '7.0.2'),
/*
|--------------------------------------------------------------------------
@@ -270,7 +270,7 @@ return [
'github_url' => env('G7_UPDATE_GITHUB_URL', 'https://github.com/gnuboard/g7'),
'github_token' => env('G7_UPDATE_GITHUB_TOKEN', ''),
'pending_path' => env('G7_UPDATE_PENDING_PATH') ?: storage_path('app/core_pending'),
'targets' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_TARGETS', 'app,bootstrap,config,database,docs,lang,lang-packs/_bundled,resources,routes,public,tests,upgrades,artisan,composer.json,composer.json.default,composer.lock,package.json,package-lock.json,vite.config.js,vite.config.core.js,vitest.config.ts,playwright.config.ts,tsconfig.json,phpunit.xml,.editorconfig,.gitattributes,.gitignore,README.md,CHANGELOG.md,modules/_bundled,plugins/_bundled,templates/_bundled')))),
'targets' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_TARGETS', 'app,bootstrap,config,database,docs,lang,lang-packs/_bundled,resources,routes,public,tests,upgrades,artisan,composer.json,composer.json.default,composer.lock,package.json,package-lock.json,vite.config.js,vite.config.core.js,vite.config.editor.js,vite.config.devtools.js,vitest.config.ts,playwright.config.ts,tsconfig.json,phpunit.xml,.editorconfig,.gitattributes,.gitignore,README.md,CHANGELOG.md,modules/_bundled,plugins/_bundled,templates/_bundled')))),
'excludes' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_EXCLUDES', 'node_modules,.git,bootstrap/cache')))),
// applyUpdate 의 "신규 최상위 항목 자동 발견" 폴백이 절대 덮어쓰면 안 되는 경로 목록.
// 런타임 데이터(`storage`), 로컬 환경(`.env*`), 별도 파이프라인 산출물(`vendor`),
+11 -5
View File
@@ -45,14 +45,20 @@ return [
'mysql' => [
'driver' => 'mysql',
// Read 커넥션 — DB_READ_* 가 비어 있으면(미설정/빈 값/주석) 대응하는
// DB_WRITE_* 로 fallback 한다. Read DB 미사용 환경에서도 SELECT 가 write DB 로
// 정확히 향하도록 보장한다. (이슈 #63)
// 중첩 env(A, env(B)) 대신 Elvis(?:) 를 쓰는 이유: env('DB_READ_HOST') 가
// 빈 문자열('')을 반환하면 두 번째 인자(default)가 적용되지 않아 fallback 이
// 무력화된다. ?: 는 빈 문자열까지 falsy 로 흡수하여 write 로 넘긴다.
'read' => [
'host' => [
env('DB_READ_HOST', '127.0.0.1'),
env('DB_READ_HOST') ?: env('DB_WRITE_HOST', '127.0.0.1'),
],
'port' => env('DB_READ_PORT', '3306'),
'database' => env('DB_READ_DATABASE', 'laravel'),
'username' => env('DB_READ_USERNAME', 'root'),
'password' => env('DB_READ_PASSWORD', ''),
'port' => env('DB_READ_PORT') ?: env('DB_WRITE_PORT', '3306'),
'database' => env('DB_READ_DATABASE') ?: env('DB_WRITE_DATABASE', 'laravel'),
'username' => env('DB_READ_USERNAME') ?: env('DB_WRITE_USERNAME', 'root'),
'password' => env('DB_READ_PASSWORD') ?: env('DB_WRITE_PASSWORD', ''),
],
'write' => [
'host' => [
+10
View File
@@ -74,6 +74,16 @@ return [
'report' => false,
],
// 확장(모듈/플러그인) 프론트엔드 IIFE/CSS 번들 병합 결과 캐시.
// ExtensionBundleService 가 version-in-path 파일명으로 저장/서빙한다.
'ext-bundles' => [
'driver' => 'local',
'root' => storage_path('app/ext-bundles'),
'serve' => false,
'throw' => true,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
+41 -5
View File
@@ -111,7 +111,8 @@
├─ Step 4: 다운로드 (GitHub zipball 또는 --source/--local에서 복사)
├─ Step 5: 백업 생성 (--no-backup 시 스킵)
├─ Step 6: Composer install (_pending에서 실행, 변경 없으면 스킵)
├─ Step 7: 파일 적용 (_pending → base_path 선택적 덮어쓰기)
├─ Step 6.5: 신규 파일 manifest 생성 + 증분 적용 대상(3-way) 산출 (백업 있을 때)
├─ Step 7: 파일 적용 (_pending → base_path, 기본=코어 변경분만 / --prune=전체 덮어쓰기)
├─ Step 8: vendor 복사 (_pending/vendor → base_path/vendor, Step 6 스킵 시 함께 스킵)
├─ Step 9: 마이그레이션 + 동기화 (migrate, roles, permissions, menus, mail templates)
├─ Step 10: 업그레이드 스텝 실행 (upgrades/Upgrade_X_Y_Z.php)
@@ -178,16 +179,49 @@ v접두사 자동 감지 (resolveGithubArchiveUrl):
> Vendor 번들 시스템 상세: [docs/extension/vendor-bundle.md](../extension/vendor-bundle.md)
### Step 6.5: 신규 파일 manifest + 증분 적용 대상 산출
```text
백업이 있을 때(--no-backup 아님)만 수행:
1. writeNewFilesManifest() — 자동 롤백용 `_new_files_manifest.json` 기록
(base=백업 vs theirs=_pending: 신 버전이 추가한 파일/디렉토리 목록)
2. computeApplyList() — 기본(증분) 모드의 3-way 적용 대상 산출 (--prune 시 스킵)
· base = 구버전 원본 = 백업 스냅샷
· theirs = 신 버전 = _pending
· base 없음 → added / size·md5 다름 → changed / 동일 → 제외(스킵)
· size 선필터 후 size 동일할 때만 md5 (mtime 비교 안 함 — _pending 은 추출 시각)
· symlink / excludes / protected_paths 하위 → 목록 제외
· 단, targets 에 더 구체적으로 명시된 경로는 상위 protected 를 오버라이드(아래 주석)
```
> **protected_paths 오버라이드 (공개 #64 / 내부 #452)**: `protected_paths` 에는 확장 부모(`modules`·`plugins`·`templates`·`lang-packs`)가 포함되지만, `targets` 에는 `{domain}/_bundled` 가 명시된다. 3-way 산출(`computeApplyList`)과 신규 파일 manifest(`writeNewFilesManifest`)는 "targets 에 더 구체적(하위)으로 명시된 경로가 상위 protected 를 오버라이드"하도록 판정한다. 이로써 코어 배포본에 포함된 번들 확장의 갱신 파일(`_bundled/{id}/composer.json`·`vendor-bundle.json` 등)이 코어 업데이트로 정상 반영된다. 오버라이드는 target 이 protected 보다 **더 깊을 때만** 적용되므로, `storage`(target) == `storage`(protected) 같은 동일 경로는 여전히 제외된다. 확장 부모를 protected 에 둔 원래 의도(자동 발견 폴백이 활성 서브디렉토리 `modules/sirsoft-*` 를 삭제하는 #347 방어)는 그대로 유지된다 — 자동 발견 폴백은 targets 순회가 아니므로 오버라이드 영향을 받지 않는다.
### Step 7: 파일 적용
```text
- _pending 소스에서 config('app.update.targets') 에 해당하는 파일/디렉토리 선택적 덮어쓰기
기본(증분) 모드 — --prune 미지정 + 백업 있음:
- Step 6.5 의 applyList(코어가 실제 변경/추가한 파일)에 있는 파일만 적용
- 코어가 건드리지 않은 파일은 복사·chmod·chown·mtime 갱신을 전부 스킵 → 현재 디스크
상태(사용자 수정 포함 가능)를 그대로 보존
- orphan(소스에 없는 대상 파일) 삭제 안 함 → 사용자가 추가한 신규 파일 보존
--prune 모드 (또는 백업 부재 fallback):
- targets 전체 무조건 덮어쓰기 + orphan 삭제 (기존 동작)
- 백업 부재 시 base 가 없어 3-way 불가 → 안전하게 전체 덮어쓰기로 회귀 + 안내 출력
공통:
- 자동 발견 폴백: targets 에 미등재된 source 최상위 항목도 스캔하여 적용
(config('app.update.protected_paths') 와 config('app.update.excludes') 매치 시 스킵)
- FilePermissionHelper::copyDirectory() 사용 → 원본 파일 권한 보존
- ExtensionPendingHelper::copyToActive() 미사용 (권한 유실 방지)
```
> **기본 동작 변경 배경 (공개 #64)**: 이전에는 Step 7 이 targets 전체를 무조건 재복사하고 orphan 을 삭제하여, 사용자가 수정한 `public/.htaccess` 커스텀 블록이나 `_bundled/` 아래 커스텀 확장이 소실되는 사고가 반복 제보되었다. 기본 동작을 "코어가 실제로 변경/추가한 파일만 적용(3-way)"으로 전환해 발생 표면을 제거했다. 전체 덮어쓰기 + 정리를 원하면 `--prune` 을 지정한다. "코어도 바꾸고 사용자도 바꾼" 파일은 코어 버전으로 갱신되지만 백업에 원본이 보존되어 복구 가능하다.
> **증분 모드 잔존 stale 파일 정리**: 기본(증분) 모드는 orphan 을 삭제하지 않으므로, 신 버전에서 제거된 파일이 활성 디렉토리에 잔존할 수 있다. 완료 요약이 잔존을 안내하며, 정리하려면 다음 업데이트를 `--prune` 으로 실행하거나 단발성 정리 도구 `php artisan hotfix:rollback-stale-files --prune` 을 사용한다 (진단 모드 기본, `--prune` 시 확인 프롬프트 후 정리, symlink/protected_paths 가드 적용). 상세 사용법: [docs/cheatsheet.md](../cheatsheet.md) "단발성 결함 보정 (hotfix)".
> **자동 발견 폴백의 배경 (engine-v / beta.4 이후)**: Step 7 은 부모 프로세스의 `config('app.update.targets')` 를 사용한다. 부모는 업그레이드 *직전* 의 코드/메모리 상태이므로 신버전이 도입한 신규 최상위 디렉토리(예: beta.4 의 `lang-packs/`) 가 부모의 stale targets 에서 누락된다. 폴백은 이 결함을 안전망으로 차단하며, `config/app.php` 의 `update.protected_paths` 가 런타임 데이터(`storage`)·로컬 환경(`.env*`)·별도 파이프라인 산출물(`vendor`)·개발 메타(`.git`/`.claude`/`.serena` 등) 의 의도치 않은 덮어쓰기를 방지한다.
### Step 9: 마이그레이션 + 동기화
@@ -631,12 +665,13 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값')
|--------|---------|------|
| `createBackup()` | `(?Closure $onProgress): string` | CoreBackupHelper로 백업 생성 |
| `restoreFromBackup()` | `(string $backupPath, ?Closure $onProgress): void` | 백업에서 파일 복원 |
| `CoreBackupHelper::computeApplyList()` | `(string $backupPath, string $sourcePath, array $targets, array $protectedPaths, array $excludes): array` | 3-way 판정으로 증분 적용 대상(added/changed) 산출 (`apply`, `added_count`, `changed_count`, `has_symlink`). targets 에 명시된 `{domain}/_bundled` 는 상위 protected(`modules` 등)를 오버라이드해 목록에 포함 (공개 #64 / 내부 #452) |
### 적용 및 설치
| 메서드 | 시그니처 | 설명 |
|--------|---------|------|
| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress): void` | _pending → base_path 선택적 덮어쓰기 |
| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress, bool $prune = false, ?array $applyList = null): void` | _pending → base_path 적용. `$applyList` 지정 + `!$prune` 이면 증분(코어 변경분만), 그 외 전체 덮어쓰기 + orphan 삭제 |
| `runComposerInstallInPending()` | `(string $pendingPath, ?Closure $onProgress): void` | _pending에서 composer install (--no-scripts) |
| `isComposerUnchangedForCore()` | `(string $pendingPath): bool` | composer.json/lock MD5 비교 |
| `runComposerInstall()` | `(?Closure $onProgress): void` | base_path에서 composer install |
@@ -678,13 +713,14 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값')
### core:update
```bash
php artisan core:update [--force] [--no-backup] [--no-maintenance] [--local] [--source={path}]
php artisan core:update [--force] [--no-backup] [--prune] [--no-maintenance] [--local] [--source={path}]
```
| 옵션 | 설명 |
|------|------|
| `--force` | 버전 비교 스킵, 동일 버전이어도 강제 업데이트 |
| `--no-backup` | 백업 생성 스킵 (Step 5) |
| `--no-backup` | 백업 생성 스킵 (Step 5). 증분 적용 불가 → 전체 덮어쓰기로 회귀 |
| `--prune` | 코어가 제거한 파일 정리 + targets 전체 덮어쓰기(기존 방식). 미지정 시 코어가 실제 변경/추가한 파일만 적용(3-way)하고 나머지는 보존 |
| `--no-maintenance` | 유지보수 모드 스킵 (Step 3) |
| `--local` | 현재 코드베이스를 소스로 사용 (GitHub 스킵) |
| `--source={path}` | 지정 디렉토리를 소스로 사용 (GitHub 스킵) |
+21
View File
@@ -587,6 +587,27 @@ class PublicLayoutController extends PublicBaseController
---
## JSON 인코딩 옵션 (JSON_UNESCAPED_UNICODE)
모든 `response()->json()` 호출은 `ResponseHelper::JSON_ENCODE_OPTIONS`(= `JSON_UNESCAPED_UNICODE`)를 인코딩 옵션으로 전달한다. 한글 등 멀티바이트 문자를 `\uXXXX`(6바이트)로 이스케이프하지 않고 raw UTF-8(한글 3바이트)로 직렬화하여 응답 전송 크기를 줄인다. 다국어 팩(lang) 응답처럼 한글 비중이 큰 페이로드에서 절감 효과가 크다.
```php
// ✅ 올바른 사용 — 네 번째 인자로 옵션 전달
return response()->json($payload, $status, [], ResponseHelper::JSON_ENCODE_OPTIONS);
// 헤더 배열이 필요한 경우 (세 번째 인자)
return response()->json($payload, $status, ['Cache-Control' => '...'], ResponseHelper::JSON_ENCODE_OPTIONS);
// ❌ 옵션 누락 — 한글이 \uXXXX 로 팽창
return response()->json($payload, $status);
```
`ResponseHelper` 의 모든 메서드(`success`/`error`/`validationError`/… )와 `BaseApiController::successWithCache`/`cachedJsonResponse` 는 이미 이 옵션을 적용한다. 컨트롤러/서비스는 `ResponseHelper` 를 사용하면 자동으로 적용되므로 별도 조치가 불필요하다. `response()->json()` 을 직접 호출하는 신규 코드에서만 옵션 전달을 잊지 않는다.
> 슬래시 언이스케이프(`JSON_UNESCAPED_SLASHES`)는 적용하지 않는다 — endpoint 경로의 `\/` 는 크기 영향이 미미하고, 기존 응답 본문 정확 매칭 테스트의 안전성을 우선한다.
---
## 관련 문서
- [index.md](index.md) - 백엔드 가이드 인덱스
+2
View File
@@ -27,6 +27,8 @@ _bundled에서 TSX/TS 파일 수정한 경우 → 빌드 후 확장 업데이
| `modules/**/resources/js/**/*.ts` (핸들러) | `module:build` + `module:update {id} --force` |
| `templates/**/src/**/*.tsx` (컴포넌트) | `template:build` + `template:update {id} --force` |
> **확장 프론트엔드 번들**: 활성 모듈/플러그인 IIFE 는 서버측에서 종류별 1개 번들로 병합 서빙된다(`/api/{modules,plugins}/bundle.{js,css}`). `{type}:update` 후 확장 캐시 버전이 bump 되면 번들이 자동 재생성된다(prod 캐시, dev 매 요청 concat). 구버전 번들 파일은 `ext-bundles:cleanup` 또는 `{type}:cache-clear` 로 정리. 상세: [module-assets.md](extension/module-assets.md#서버측-번들-병합).
---
## 확장 업데이트 (_bundled → 활성 반영)
+66
View File
@@ -381,6 +381,72 @@ GET /api/modules/assets/{identifier}/{path}
---
## 서버측 번들 병합 (Server-side Bundle)
활성 모듈/플러그인이 늘어날수록 개별 IIFE JS/CSS 요청이 선형 증가한다. 이를 줄이기 위해 코어는 타입별(모듈/플러그인)로 활성 `global` 에셋을 서버에서 하나의 번들로 병합해 서빙한다. 각 확장 IIFE 는 자체 클로저에서 자가등록(레지스트리 + 핸들러/리스너)을 수행하므로, priority 순으로 이어붙여 단일 `<script>` 로 실행해도 등록 동작은 동일하다.
### 서빙 엔드포인트
```
GET /api/modules/bundle.js?v={version}
GET /api/modules/bundle.css?v={version}
GET /api/plugins/bundle.js?v={version}
GET /api/plugins/bundle.css?v={version}
```
`{version}` = 확장 캐시 버전(`ClearsTemplateCaches::getExtensionCacheVersion()`). 활성 조합이 바뀌면 install/activate/deactivate/update 라이프사이클에서 version 이 bump 되어 새 URL → 새 캐시 파일명으로 자동 무효화된다.
### 동작 흐름
```
1. blade → window.G7Config.bundleUrls 주입 (활성 에셋 없는 타입은 null)
2. TemplateApp.loadExtensionAssets()
└─ ModuleAssetLoader.loadBundle('module', ...) → loadBundle('plugin', ...)
├─ 단일 <script async=false> + 단일 <link> append
└─ 번들 내부 물리 순서(=priority 정렬)로 IIFE 자가등록 실행
```
`bundleUrls` 가 없으면(구버전 blade) `ModuleAssetLoader.loadActiveExtensionAssets` 개별 로딩으로 폴백한다.
### 병합 규율
| 규율 | 내용 | audit 룰 |
|------|------|---------|
| priority 순서 | manifest `loading.priority` 오름차순만. 확장 이름 하드코딩 금지 | (선언형) |
| `\n;\n` 구분자 | IIFE 사이는 `\n;\n`(JS)/`\n`(CSS). 미사용 시 ASI 붕괴 → 전체 파싱 에러 | `extension-bundle-concat-separator` |
| 소스맵 | prod strip, dev 는 개별 에셋 서빙 절대 URL 로 rewrite | - |
| same-origin | 번들 URL 은 `/api/...` 만 (CDN 금지 — gdpr preblocker 자기차단 방지) | `extension-bundle-url-same-origin` |
| 절대경로 게터 | `getBuiltAssetAbsolutePaths()` 사용. `base_path("modules"\|"plugins")` 직접 조립 금지 | `extension-bundle-asset-path-getter` |
| 확장별 try/catch | 파일 읽기 실패 시 해당 확장만 skip, 나머지 병합 지속 | (메모리+회귀테스트) |
| CSS url() | 상대경로 url() 을 가진 CSS 는 번들 제외(개별 폴백) | - |
### 캐시 stale 관리
번들 파일(`storage/app/ext-bundles/{type}.{version}.{js,css}`)은 Laravel 캐시 스토어 밖 파일시스템이라 version bump/`cache:clear` 가 구파일을 지우지 않는다. 정리 경로:
```bash
php artisan ext-bundles:cleanup # 현재 version 외 구파일 삭제
php artisan module:cache-clear # 모듈 번들 파일 정리 포함
php artisan plugin:cache-clear # 플러그인 번들 파일 정리 포함
php artisan template:cache-clear # 전체 번들 파일 정리 포함
```
프로덕션은 version-in-path 디스크 캐시, 비프로덕션(dev/watch)은 캐시 없이 매 요청 concat(rebuild 즉시 반영). `_bundled` 수정 후에는 `{type}:update {id} --force` 로 활성 반영 후 version bump 로 번들이 재생성된다.
> 개별 에셋 서빙 라우트(`/api/{type}/assets/...`, `*.map` 포함)는 소스맵·static 참조를 위해 존치한다.
### 전송 압축 (gzip)
번들 JS/CSS 는 `fileResponse()`(= `response()->file()` → `BinaryFileResponse`)로 서빙되며, `GzipEncodeResponse` 미들웨어가 gzip 압축을 적용한다. `BinaryFileResponse` 는 `getContent()` 가 `false` 를 반환하므로, 미들웨어는 파일 경로(`getFile()->getPathname()`)에서 본문을 읽어 압축한 뒤 헤더(Content-Type/ETag/Cache-Control)를 승계한 일반 `Response` 로 치환한다.
- 1KB 미만 번들(예: 빈 CSS)은 `MIN_COMPRESS_SIZE` 가드로 압축 생략.
- `Accept-Encoding: gzip` 미포함 요청, 이미 `Content-Encoding` 이 있는 응답, 304 응답은 압축 대상에서 제외.
- 회귀 테스트: `tests/Feature/Middleware/GzipEncodeResponseTest.php` (BinaryFileResponse 압축/헤더 승계/소용량 skip).
> `BinaryFileResponse` 를 압축 대상에 포함하지 않으면 번들이 비압축 전송되는 사각지대가 생긴다(모듈/플러그인 번들은 크기가 커 압축 이득이 특히 크다).
---
## 에셋 로딩 전략
### global (기본값)
+8 -1
View File
@@ -554,7 +554,7 @@ Laravel의 `FormRequest::validated()` 메서드는 `rules()`에 정의된 필드
| `id` | string | ✅ | 컴포넌트 고유 ID |
| `type` | string | ✅ | 컴포넌트 타입 (basic/composite/layout) |
| `name` | string | ✅ | 컴포넌트 이름 (components.json에 등록된 이름) |
| `comment` | string | ✅ | 시안 추적 코드 및 역할 설명 (예: "F-002: 상품명 검색 필드") |
| `comment` | string | ✅ | 시안 추적 코드 및 역할 설명 (예: "F-002: 상품명 검색 필드"). 공개 서빙 응답에서는 제거됨 (아래 참조) |
| `props` | object | ❌ | 컴포넌트에 전달할 props |
| `text` | string | ❌ | 텍스트 콘텐츠 (최우선 렌더링) |
| `children` | array | ❌ | 자식 컴포넌트 배열 (레이아웃/집합 컴포넌트에서 사용) |
@@ -575,6 +575,13 @@ Laravel의 `FormRequest::validated()` 메서드는 `rules()`에 정의된 필드
> - `actions`: [layout-json-features.md](layout-json-features.md#액션-시스템-actions)
> - `isolatedState`: [아래 섹션](#격리된-상태-isolatedstate)
### `comment` / `_comment` — 공개 서빙 응답에서 제거
`comment` / `_comment` 는 편집기·개발자용 설명 주석이며 런타임 렌더러는 참조하지 않는다. 공개 레이아웃 서빙(`GET /api/layouts/{template}/{layout}.json`) 응답에서는 전송 크기 절감을 위해 이 두 키가 노드 트리 전체에서 재귀적으로 제거된다(`LayoutService::stripDeveloperComments`).
- 편집 모드 서빙(`?with_source_meta=1`, 편집 권한 필요)에서는 편집기가 `comment` 를 편집 가능한 속성으로 노출하므로 **보존**된다. 공개/편집 응답은 별도 캐시 키로 분리되어 있어 조건부 제거가 안전하다.
- 따라서 `comment` 는 편집기·저장본에서는 유지되고, 일반 사용자에게 서빙되는 응답에서만 사라진다. 레이아웃 로직이 `comment` 값에 의존해서는 안 된다.
---
## 격리된 상태 (isolatedState)
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.2] - 2026-07-08
### Added
- 코어 업데이트 파일 적용 방식 안내 메시지 일본어 번역 추가 (`settings.core_update.apply_mode_incremental`·`apply_mode_incremental_prune_hint`·`apply_mode_prune`·`apply_mode_fallback`) — 코어가 실제 변경한 파일만 적용하는 기본 방식·`--prune` 전체 덮어쓰기·백업 부재 회귀 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.1] - 2026-07-03
### Added
@@ -144,6 +144,10 @@ return [
'zip_file_not_found' => '指定されたZIPファイルが見つかりません::path',
'source_vendor_missing' => 'ソースディレクトリにvendorがありません。composer installが実行されていない可能性があります。',
'composer_failed_with_output' => 'composer installの実行に失敗しました。:output',
'apply_mode_incremental' => 'ファイル適用: コアが実際に変更したファイルのみ適用しました (新規 :added個、変更 :changed個)。その他のファイルは現在の状態のまま保存されました。',
'apply_mode_incremental_prune_hint' => '新しいバージョンで削除されたファイルを一緒に整理する場合は、`--prune` オプションで再実行するか、`php artisan hotfix:rollback-stale-files --prune` を使用してください。',
'apply_mode_prune' => 'ファイル適用: 完全な上書き + 削除されたファイルの整理を実行しました (--prune)。',
'apply_mode_fallback' => 'ファイル適用: バックアップがないため増分適用をスキップし、完全な上書きを実行しました。ユーザーカスタムファイルの保存を希望する場合は、次回からはバックアップを維持したまま更新してください。',
],
'backup_path_required' => 'バックアップパスを入力してください。',
'restore_success' => '設定が正常に復元されました。',
@@ -12,7 +12,7 @@
"en": "G7 core Japanese language pack (bundled)",
"ja": "G7 コア 日本語 言語パック(バンドル)"
},
"version": "1.0.1",
"version": "1.0.2",
"license": "MIT",
"scope": "core",
"target_identifier": null,
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-08
### Added
- 관리자 게시글 목록의 "사용자 화면 보기" 링크 라벨(`view_user_page`) 일본어 번역 추가 — 관리자에서 해당 게시글의 사용자 화면으로 이동하는 링크가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.0] - 2026-07-01
### Added
@@ -236,7 +236,8 @@
"blind": "ブラインド",
"restore": "復元",
"delete": "削除",
"create": "投稿を追加"
"create": "投稿を追加",
"view_user_page": "ユーザー画面を表示"
},
"author_actions": {
"view_user": "ユーザー情報を表示"
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-board) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-board) 日本語 言語パック(バンドル)"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-board",
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-08
### Added
- 관리자 상품 목록의 "사용자 화면 보기" 링크 라벨(`view_user_page`) 일본어 번역 추가 — 관리자에서 해당 상품의 사용자 화면으로 이동하는 링크가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.0] - 2026-07-01
### Added
@@ -87,7 +87,8 @@
"edit_search": "検索編集",
"search": "検索",
"reset": "初期化",
"save_preset": "プリセット保存"
"save_preset": "プリセット保存",
"view_user_page": "ユーザー画面を表示"
},
"bulk_apply": "一括適用",
"bulk_success": "選択された商品が一括変更されました。",
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-ecommerce",
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-08
### Added
- 페이지 버전 이력의 변경 필드명 라벨 일본어 번역 추가 (`versions.field_labels.*`) — 버전 이력의 "변경내역" 항목이 일본어 로케일에서 필드 키 대신 자연스러운 라벨로 표시됩니다.
## [1.0.0] - 2026-07-01
### Added
@@ -62,4 +62,7 @@ return [
'array' => '順序情報の形式が正しくありません。',
],
],
'attributes' => [
'search' => '検索ワード',
],
];
@@ -160,7 +160,13 @@
"changed_fields": "変更: {{fields}}",
"restored_from": "v{{version}}から復元",
"restore_guide": "表示ボタンをクリックすると、内容を確認して復元できます。添付ファイルはバージョン管理に含まれていません。",
"preview_modal_editor_label": "バージョンプレビュー"
"preview_modal_editor_label": "バージョンプレビュー",
"field_labels": {
"title": "タイトル",
"content": "内容",
"seo_meta": "SEO情報",
"content_mode": "編集モード"
}
}
}
}
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-page) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-page) 日本語 言語パック(バンドル)"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-page",
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-08
### Added
- 관리자 헤더의 "사이트 보기" 링크와 접근 가능한 메뉴 부재 안내(`view_site`·`no_accessible_menus`) 일본어 번역 추가 — 관리자에서 사용자 사이트로 이동하는 링크와 메뉴 접근 권한이 없을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
### Changed
- 첨부 개수 초과 안내 문구(`attachment.upload_limit_exceeded`)를 이미지 전용 표현에서 일반 파일에도 맞는 범용 표현으로 갱신했습니다 — 한국어/영어 원문 변경에 맞춰 일본어 번역을 동기화했습니다.
## [1.0.0] - 2026-07-01
### Added
@@ -1,6 +1,7 @@
{
"sidebar": {
"no_accessible_menus": "アクセス可能なメニューがありません"
"no_accessible_menus": "アクセス可能なメニューがありません",
"view_site": "サイトを表示"
},
"profile_settings": "プロフィール設定",
"logout": "ログアウト",
@@ -7,11 +7,11 @@
"deleting": "削除中...",
"drop_or_click": "ファイルをドラッグするか、クリックしてアップロード",
"upload_limit": "最大{{maxFiles}}個、1個あたり{{maxSize}}MB",
"upload_limit_exceeded": "最大{{max}}個まで添付できます。",
"attached_count": "{{count}}/{{max}}個添付済み",
"max_size": "最大{{size}}MB",
"drag_to_reorder": "ドラッグして順序を変更",
"reorder_failed": "順序変更に失敗しました。",
"primary_image": "メイン画像",
"set_as_primary": "メイン画像に設定",
"upload_limit_exceeded": "画像は最大{{max}}枚までアップロードできます。"
"set_as_primary": "メイン画像に設定"
}
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-admin_basic",
@@ -4,6 +4,14 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-08
### Added
- 미발행 페이지 관리자 미리보기 안내 배너(`user.page.preview_banner`) 일본어 번역 추가 — 관리자가 발행 전 페이지를 사용자 화면에서 미리 볼 때의 안내 문구가 일본어 로케일에서 자연스럽게 표시됩니다.
- 첨부 개수 초과 안내 문구(`attachment.upload_limit_exceeded`) 일본어 번역 추가 — 게시글 첨부를 허용 개수보다 많이 선택했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 게시판·상품 사용자 화면의 관리자 이동 링크 라벨(`admin_links.posts`·`admin_links.manage`·상품 `admin_edit`) 일본어 번역 추가 — 관리자가 사용자 화면에서 관리자 게시글 목록·게시판 관리·상품 편집으로 이동하는 링크가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.0] - 2026-07-01
### Added
@@ -9,5 +9,6 @@
"reorder_failed": "ファイルの順序変更に失敗しました",
"drag_to_reorder": "ドラッグして順序を変更",
"primary_image": "代表画像",
"set_as_primary": "代表画像に設定"
"set_as_primary": "代表画像に設定",
"upload_limit_exceeded": "最大{{max}}個まで添付できます。"
}
@@ -227,5 +227,9 @@
"confirm_remove_attachment_title": "添付ファイル削除",
"confirm_remove_attachment_message": "ファイルが即座に削除されます。投稿保存と無関係に取り戻せません。"
}
},
"admin_links": {
"posts": "管理者投稿閲覧",
"manage": "掲示板管理"
}
}
@@ -610,5 +610,6 @@
"blocks_order": "配送不可商品が含まれているため注文できません。該当商品を削除するか、配送国家を変更してください。",
"shippable_to": "{country} 配送可能",
"shipping_fee_summary": "{country} 配送料:{fee}"
}
},
"admin_edit": "管理者で編集"
}
@@ -51,7 +51,8 @@
"published_at": "公開日",
"create_page": "ページ作成",
"attachments": "添付ファイル",
"download": "ダウンロード"
"download": "ダウンロード",
"preview_banner": "未公開ページをプレビュー中です"
},
"mypage": {
"page_title": "マイページ",
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-basic",
+6
View File
@@ -144,6 +144,12 @@ return [
'step_composer_prod' => 'Running composer install in production directory...',
'step_cleanup' => 'Cleaning up...',
// File apply mode summary (Step 7)
'apply_mode_incremental' => 'File apply: only files actually changed by the core were applied (:added new, :changed changed). All other files were left untouched as-is.',
'apply_mode_incremental_prune_hint' => 'To also remove files deleted in the new version, re-run with the `--prune` option or use `php artisan hotfix:rollback-stale-files --prune`.',
'apply_mode_prune' => 'File apply: full overwrite plus cleanup of removed files was performed (--prune).',
'apply_mode_fallback' => 'File apply: no backup available, so incremental apply was skipped and a full overwrite was performed. To preserve custom files, keep the backup enabled on future updates.',
// GitHub API error messages
'github_url_not_configured' => 'GitHub repository URL is not configured.',
'github_api_failed' => 'Unable to connect to GitHub API.',
+6
View File
@@ -144,6 +144,12 @@ return [
'step_composer_prod' => '운영 디렉토리 composer install 중...',
'step_cleanup' => '정리 중...',
// 파일 적용 방식 요약 (Step 7)
'apply_mode_incremental' => '파일 적용: 코어가 실제 변경한 파일만 적용했습니다 (신규 :added개, 변경 :changed개). 그 외 파일은 현재 상태 그대로 보존되었습니다.',
'apply_mode_incremental_prune_hint' => '신 버전에서 제거된 파일을 함께 정리하려면 `--prune` 옵션으로 다시 실행하거나 `php artisan hotfix:rollback-stale-files --prune` 을 사용하세요.',
'apply_mode_prune' => '파일 적용: 전체 덮어쓰기 + 제거된 파일 정리를 수행했습니다 (--prune).',
'apply_mode_fallback' => '파일 적용: 백업이 없어 증분 적용을 건너뛰고 전체 덮어쓰기를 수행했습니다. 사용자 커스텀 파일 보존을 원하면 다음부터는 백업을 유지한 채 업데이트하세요.',
// GitHub API 에러 메시지
'github_url_not_configured' => 'GitHub 저장소 URL이 설정되지 않았습니다.',
'github_api_failed' => 'GitHub API에 연결할 수 없습니다.',
@@ -4,6 +4,17 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-08
### Added
- 관리자 게시물 조회 화면(목록·상세)에 "유저 화면 보기" 버튼을 추가했습니다. 클릭하면 해당 게시판/게시글의 실제 사용자 화면이 새 탭으로 열립니다.
- 사용자 게시판 화면(목록·상세)에 관리자 진입 버튼을 추가했습니다. 게시판 관리 권한이 있는 사용자에게만 "관리자 게시물 조회"·"게시판 관리" 버튼이 보이며, 클릭하면 관리자 화면이 새 탭으로 열립니다.
### Changed
- 관리자 게시판 설정 화면의 주소가 게시판 번호 대신 게시판 아이디(슬러그) 기준으로 바뀌었습니다 (예: /admin/boards/free/edit). 새 게시판을 만든 직후 이동하는 편집 화면, 목록의 활성화 토글·삭제, 목록에서 여는 수정 화면, 상품 문의 설정의 "관리자 메뉴에 추가"까지 모두 게시판 아이디 기준으로 통일되었습니다. 기존 번호 기반 주소도 계속 동작합니다.
## [1.0.0] - 2026-07-01
### Added
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-board",
"description": "Board module for Gnuboard7",
"type": "library",
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"autoload": {
"psr-4": {
@@ -2170,10 +2170,10 @@
]
},
{
"comment": "admin 게시판 폼 — route.id 유무로 수정/신규(본문 line 9), _local.activeTab 으로 탭 분기(본문 line 161). 검증 오류는 _local.errors.",
"comment": "admin 게시판 폼 — route.slug 유무로 수정/신규(본문 line 9), _local.activeTab 으로 탭 분기(본문 line 161). 검증 오류는 _local.errors.",
"scope": {
"kind": "route",
"match": "*/admin/boards/:id/edit"
"match": "*/admin/boards/:slug/edit"
},
"items": [
{
+1 -1
View File
@@ -5,7 +5,7 @@
"ko": "게시판",
"en": "Board"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-board",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-board",
"version": "1.0.0",
"version": "1.0.1",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-board",
"version": "1.0.0",
"version": "1.0.1",
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -174,7 +174,7 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards/{{(board_list_for_inquiry?.data?.data ?? []).find(b => b.slug === (settings?.data?.inquiry?.board_slug ?? ''))?.id}}/edit"
"path": "/admin/boards/{{settings?.data?.inquiry?.board_slug ?? ''}}/edit"
}
}
]
@@ -278,7 +278,7 @@
{
"type": "click",
"handler": "apiCall",
"target": "/api/modules/sirsoft-board/admin/boards/{{(board_list_for_inquiry?.data?.data ?? []).find(b => b.slug === (settings?.data?.inquiry?.board_slug ?? ''))?.id}}/add-to-menu",
"target": "/api/modules/sirsoft-board/admin/boards/{{settings?.data?.inquiry?.board_slug ?? ''}}/add-to-menu",
"params": {
"method": "POST"
},
@@ -0,0 +1,80 @@
/**
* @file admin-board-cross-navigation.test.tsx
* @description 관리자 게시판 ↔ 유저 게시판 상호 이동 크로스링크 검증 (이슈 #450)
*
* 검증 방식: 레이아웃 JSON 트리 직접 분석 (DOM 비의존).
* 검증 목적:
* 1. 관리자 게시물 리스트/상세에 "유저 화면 보기" 링크 존재 (새 탭, 조건 없음)
* 2. 링크가 올바른 유저 라우트 href 와 target=_blank 를 사용
*/
import { describe, it, expect } from 'vitest';
import postsIndex from '../../../../resources/layouts/admin/admin_board_posts_index.json';
import postDetail from '../../../../resources/layouts/admin/admin_board_post_detail.json';
interface LayoutNode {
id?: string;
name?: string;
props?: Record<string, unknown>;
children?: LayoutNode[];
[key: string]: unknown;
}
/** 레이아웃 트리에서 id 로 노드를 재귀 탐색 */
function findById(node: LayoutNode | LayoutNode[] | undefined, id: string): LayoutNode | null {
if (!node) return null;
const nodes = Array.isArray(node) ? node : [node];
for (const n of nodes) {
if (n && typeof n === 'object') {
if (n.id === id) return n;
for (const key of Object.keys(n)) {
const child = (n as Record<string, unknown>)[key];
if (Array.isArray(child) || (child && typeof child === 'object')) {
const found = findById(child as LayoutNode | LayoutNode[], id);
if (found) return found;
}
}
}
}
return null;
}
describe('이슈 #450 — 관리자 게시판 → 유저 게시판 크로스링크', () => {
describe('admin_board_posts_index.json (게시물 리스트)', () => {
const link = findById(postsIndex as LayoutNode, 'view_user_page_link');
it('유저 화면 보기 링크 노드가 존재한다', () => {
expect(link).not.toBeNull();
});
it('A 태그로 유저 게시판 리스트 경로를 새 탭으로 연다', () => {
expect(link?.name).toBe('A');
expect(link?.props?.href).toBe('/board/{{route.slug}}');
expect(link?.props?.target).toBe('_blank');
expect(link?.props?.rel).toBe('noopener noreferrer');
});
it('노출 조건(if)이 없어 관리자 화면에서 항상 표시된다', () => {
expect(link?.if).toBeUndefined();
});
});
describe('admin_board_post_detail.json (게시물 상세)', () => {
const link = findById(postDetail as LayoutNode, 'view_user_page_link');
it('유저 화면 보기 링크 노드가 존재한다', () => {
expect(link).not.toBeNull();
});
it('A 태그로 유저 게시판 상세 경로를 새 탭으로 연다', () => {
expect(link?.name).toBe('A');
expect(link?.props?.href).toBe('/board/{{route.slug}}/{{route?.id}}');
expect(link?.props?.target).toBe('_blank');
});
it('노출 조건(if)이 없어 관리자 화면에서 항상 표시된다', () => {
expect(link?.if).toBeUndefined();
});
});
});
@@ -233,7 +233,8 @@
"blind": "Blind",
"restore": "Restore",
"delete": "Delete",
"create": "Create Post"
"create": "Create Post",
"view_user_page": "View on site"
},
"author_actions": {
"view_user": "View User Info"
@@ -236,7 +236,8 @@
"blind": "블라인드",
"restore": "복원",
"delete": "삭제",
"create": "게시글 추가"
"create": "게시글 추가",
"view_user_page": "유저 화면 보기"
},
"author_actions": {
"view_user": "사용자 정보 보기"
@@ -6,11 +6,11 @@
],
"extends": "_admin_base",
"meta": {
"title": "{{route?.id ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}",
"title": "{{route?.slug ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}",
"description": "$t:sirsoft-board.admin.form.description"
},
"computed": {
"isReadOnly": "{{!!route?.id && !!boards?.data && boards?.data?.abilities?.can_update !== true}}"
"isReadOnly": "{{!!route?.slug && !!boards?.data && boards?.data?.abilities?.can_update !== true}}"
},
"state": {
"activeTab": "section_basic_card",
@@ -40,7 +40,7 @@
"cache": false,
"loading_strategy": "blocking",
"params": {
"board_id": "{{route?.id}}",
"board_slug": "{{route?.slug}}",
"copy_id": "{{query?.copy_id}}"
},
"initLocal": "form",
@@ -141,7 +141,7 @@
"props": {
"className": "page-title"
},
"text": "{{route?.id ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}"
"text": "{{route?.slug ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}"
},
{
"type": "basic",
@@ -368,7 +368,7 @@
"name": "Button",
"props": {
"type": "button",
"disabled": "{{_computed.isReadOnly || _local.isSaving || (!!route?.id && !_local.hasChanges)}}",
"disabled": "{{_computed.isReadOnly || _local.isSaving || (!!route?.slug && !_local.hasChanges)}}",
"className": "btn btn-primary flex-center gap-1.5"
},
"children": [
@@ -403,9 +403,9 @@
{
"handler": "apiCall",
"auth_required": true,
"target": "{{route?.id ? '/api/modules/sirsoft-board/admin/boards/' + route.id : '/api/modules/sirsoft-board/admin/boards'}}",
"target": "{{route?.slug ? '/api/modules/sirsoft-board/admin/boards/' + route.slug : '/api/modules/sirsoft-board/admin/boards'}}",
"params": {
"method": "{{route?.id ? 'PUT' : 'POST'}}",
"method": "{{route?.slug ? 'PUT' : 'POST'}}",
"body": "{{_local.form}}"
},
"onSuccess": [
@@ -422,14 +422,14 @@
"handler": "toast",
"params": {
"type": "success",
"message": "{{route?.id ? '$t:sirsoft-board.admin.board.messages.update_success' : '$t:sirsoft-board.admin.board.messages.create_success'}}"
"message": "{{route?.slug ? '$t:sirsoft-board.admin.board.messages.update_success' : '$t:sirsoft-board.admin.board.messages.create_success'}}"
}
},
{
"handler": "openWindow",
"condition": "{{!route?.id}}",
"condition": "{{!route?.slug}}",
"params": {
"path": "{{'/admin/boards/' + result.data.id + '/edit'}}",
"path": "{{'/admin/boards/' + result.data.slug + '/edit'}}",
"target": "_self"
}
}
@@ -259,7 +259,7 @@
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/boards/{{row.id}}",
"target": "/api/modules/sirsoft-board/admin/boards/{{row.slug}}",
"params": {
"method": "PUT",
"body": {
@@ -340,7 +340,7 @@
"edit": {
"handler": "navigate",
"params": {
"path": "/admin/boards/{{row.id}}/edit",
"path": "/admin/boards/{{row.slug}}/edit",
"mergeQuery": false,
"query": []
}
@@ -758,7 +758,7 @@
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/boards/{{_global.modal_data?.id}}",
"target": "/api/modules/sirsoft-board/admin/boards/{{_global.modal_data?.slug}}",
"params": {
"method": "DELETE",
"body": []
@@ -80,6 +80,33 @@
"description": "{{post?.data?.board?.name}}$t:sirsoft-board.admin.posts.page_description_with_board"
},
"children": [
{
"id": "view_user_page_link",
"type": "basic",
"name": "A",
"props": {
"href": "/board/{{route.slug}}/{{route?.id}}",
"target": "_blank",
"rel": "noopener noreferrer",
"title": "$t:sirsoft-board.admin.posts.actions.view_user_page",
"className": "flex-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors no-underline"
},
"children": [
{
"type": "composite",
"name": "Icon",
"props": {
"name": "external-link",
"size": "sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.posts.actions.view_user_page"
}
]
},
{
"id": "refresh_button",
"type": "basic",
@@ -112,6 +112,33 @@
"className": "flex-center gap-2"
},
"children": [
{
"id": "view_user_page_link",
"type": "basic",
"name": "A",
"props": {
"href": "/board/{{route.slug}}",
"target": "_blank",
"rel": "noopener noreferrer",
"title": "$t:sirsoft-board.admin.posts.actions.view_user_page",
"className": "flex-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors no-underline"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "external-link",
"size": "sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.posts.actions.view_user_page"
}
]
},
{
"id": "refresh_button",
"type": "basic",
@@ -27,7 +27,7 @@
}
},
{
"path": "*/admin/boards/:id/edit",
"path": "*/admin/boards/:slug/edit",
"layout": "admin_board_form",
"auth_required": true,
"meta": {
@@ -15,6 +15,7 @@ use Modules\Sirsoft\Board\Http\Resources\BoardCollection;
use Modules\Sirsoft\Board\Http\Resources\BoardResource;
use Modules\Sirsoft\Board\Http\Resources\BoardTypeResource;
use Modules\Sirsoft\Board\Http\Resources\PostResource;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Services\BoardTypeService;
use Modules\Sirsoft\Board\Services\PostService;
@@ -69,13 +70,13 @@ class BoardController extends AdminBaseController
/**
* 게시판 상세 정보를 조회합니다.
*
* @param int $id 게시판 ID
* @param Board $board 라우트 바인딩된 게시판 (slug 또는 ID)
* @return JsonResponse 게시판 상세 정보 응답
*/
public function show(int $id): JsonResponse
public function show(Board $board): JsonResponse
{
try {
$board = $this->boardService->getBoard($id);
$board = $this->boardService->getBoard($board->id);
return $this->successWithResource(
'sirsoft-board::messages.boards.fetch_success',
@@ -168,13 +169,13 @@ class BoardController extends AdminBaseController
* 게시판 정보를 수정합니다.
*
* @param UpdateBoardRequest $request 게시판 수정 요청
* @param int $id 게시판 ID
* @param Board $board 라우트 바인딩된 게시판 (slug 또는 ID)
* @return JsonResponse 수정된 게시판 정보 응답
*/
public function update(UpdateBoardRequest $request, int $id): JsonResponse
public function update(UpdateBoardRequest $request, Board $board): JsonResponse
{
try {
$board = $this->boardService->updateBoard($id, $request->validated());
$board = $this->boardService->updateBoard($board->id, $request->validated());
return $this->successWithResource(
'sirsoft-board::messages.boards.update_success',
@@ -194,16 +195,16 @@ class BoardController extends AdminBaseController
/**
* 게시판을 영구 삭제합니다.
*
* @param int $id 게시판 ID
* @param Board $board 라우트 바인딩된 게시판 (slug 또는 ID)
* @param Request $request HTTP 요청
* @return JsonResponse 삭제 성공 응답
*/
// audit:allow controller-base-request-injection reason: DELETE 단건 삭제. force_delete 불리언 플래그만 input()으로 읽음 (검증 불필요)
public function destroy(int $id, Request $request): JsonResponse
public function destroy(Board $board, Request $request): JsonResponse
{
try {
$forceDelete = (bool) $request->input('force_delete', false);
$this->boardService->deleteBoard($id, $forceDelete);
$this->boardService->deleteBoard($board->id, $forceDelete);
return $this->success('sirsoft-board::messages.boards.delete_success');
} catch (ModelNotFoundException $e) {
@@ -227,11 +228,17 @@ class BoardController extends AdminBaseController
try {
$basicDefaults = g7_module_settings('sirsoft-board', 'basic_defaults', []);
// 수정 모드
if ($request->filled('board_id')) {
// 수정 모드 — board_slug(신규, slug 라우팅) 또는 board_id(하위호환) 로 식별
if ($request->filled('board_slug') || $request->filled('board_id')) {
$request->merge(['include_permissions' => true]);
$board = $this->boardService->getBoard($request->board_id);
$board = $request->filled('board_slug')
? $this->boardService->getBoardBySlug($request->board_slug)
: $this->boardService->getBoard((int) $request->board_id);
if (! $board) {
return $this->notFound('sirsoft-board::messages.boards.error_404');
}
// 폼 토글 초기값: 관리자 메뉴 등록 여부를 모델 속성으로 전달
$board->is_in_admin_menu = $this->boardService->isInAdminMenu($board);
$data = (new BoardResource($board))->toArray($request);
@@ -317,14 +324,14 @@ class BoardController extends AdminBaseController
/**
* 게시판을 관리자 메뉴에 추가합니다.
*
* @param int $id 게시판 ID
* @param Board $board 게시판 모델 (slug/id 라우트 바인딩)
* @return JsonResponse 메뉴 추가 결과 응답
*/
public function addToAdminMenu(int $id): JsonResponse
public function addToAdminMenu(Board $board): JsonResponse
{
try {
// Service 계층에 위임
$menu = $this->boardService->addToAdminMenu($id);
$menu = $this->boardService->addToAdminMenu($board->id);
return $this->success('sirsoft-board::messages.boards.menu_added_success', ['menu' => $menu]);
} catch (MenuAlreadyExistsException $e) {
@@ -152,6 +152,7 @@ class BoardResource extends BaseApiResource
public function toBoardInfoForUser(): array
{
return [
'id' => $this->id,
'slug' => $this->slug,
'name' => $this->getLocalizedName(),
'description' => $this->getLocalizedDescription(),
@@ -394,9 +395,11 @@ class BoardResource extends BaseApiResource
'can_upload' => "sirsoft-board.{$slug}.attachments.upload",
'can_download' => "sirsoft-board.{$slug}.attachments.download",
'can_manage' => "sirsoft-board.{$slug}.manager",
// 유저 화면에서 관리자 게시판 화면 진입 게이트 (Admin 타입 admin.manage 보유자만 true)
'can_access_admin' => "sirsoft-board.{$slug}.admin.manage",
];
// 8개 권한을 일괄 조회 (개별 Permission::where() 8회 → whereIn 1회)
// 권한 일괄 조회 (개별 Permission::where() → whereIn 1회)
$identifiers = array_values($permissionMap);
$permissions = Permission::whereIn('identifier', $identifiers)->get()->keyBy('identifier');
@@ -300,6 +300,9 @@ class PostCollection extends BaseApiCollection
'can_download' => $this->checkBoardPermission($slug, 'attachments.download', PermissionType::User),
'can_manage' => $canManage,
'can_view_deleted' => $canManage,
// 유저 화면에서 관리자 게시판 화면으로 진입 가능한지 여부 (Admin 타입 admin.manage 권한 보유자만 true).
// 유저용 can_manage(게시판 매니저)와 다른, 관리자 화면 접근 게이트 전용 플래그.
'can_access_admin' => $this->checkBoardPermission($slug, 'admin.manage'),
];
}
@@ -325,6 +325,7 @@ class PostResource extends BaseApiResource
private function getBoardInfo(): array
{
return [
'id' => $this->board->id,
'slug' => $this->board->slug,
'name' => $this->board->getLocalizedName(),
'type' => $this->board->type,
@@ -601,11 +602,19 @@ class PostResource extends BaseApiResource
'can_manage' => "sirsoft-board.{$slug}.manager",
];
return collect($permissionMap)
$abilities = collect($permissionMap)
->mapWithKeys(fn (string $identifier, string $key) => [
$key => $this->checkPermissionByIdentifier($identifier),
])
->toArray();
// 유저 상세 화면에서만: 관리자 게시판 화면 진입 게이트(Admin 타입 admin.manage).
// Admin 요청에서는 can_manage 가 이미 admin.manage 이므로 중복 노출 불필요.
if (! $this->isAdminRequest($request)) {
$abilities['can_access_admin'] = $this->checkPermissionByIdentifier("sirsoft-board.{$slug}.admin.manage");
}
return $abilities;
}
/**
@@ -37,6 +37,29 @@ class Board extends Model
'max_file_count' => ['label_key' => 'sirsoft-board::activity_log.fields.max_file_count', 'type' => 'number'],
];
/**
* 라우트 모델 바인딩 시 슬러그(slug) 또는 숫자 ID로 게시판을 조회합니다.
*
* 관리자 게시판 URL 은 slug 기준(/admin/boards/:slug/edit)이며,
* 숫자 ID 도 하위호환으로 허용합니다.
*
* @param mixed $value 라우트 파라미터 값 (slug 또는 ID)
* @param string|null $field 검색할 필드명
* @return Model|null
*/
public function resolveRouteBinding($value, $field = null)
{
if ($field) {
return $this->where($field, $value)->firstOrFail();
}
if (is_numeric($value)) {
return $this->where('id', $value)->firstOrFail();
}
return $this->where('slug', $value)->firstOrFail();
}
/**
* 팩토리 생성
*/
@@ -373,6 +373,83 @@ class BoardManagementTest extends ModuleTestCase
$this->assertTrue($response->json('data.is_active'));
}
/**
* 게시판 상세를 slug 로 조회할 수 있다 (#450 관리자 라우팅 slug 전환)
*/
public function test_board_detail_can_be_fetched_by_slug(): void
{
// Given
$board = Board::factory()->create(['is_active' => true]);
// When: 숫자 id 가 아닌 slug 로 상세 조회
$response = $this->actingAs($this->adminUser)
->getJson("/api/modules/sirsoft-board/admin/boards/{$board->slug}");
// Then: 동일 게시판 반환
$response->assertStatus(200);
$this->assertSame($board->id, $response->json('data.id'));
$this->assertSame($board->slug, $response->json('data.slug'));
}
/**
* 게시판을 slug 로 수정할 수 있다 (#450)
*/
public function test_board_can_be_updated_by_slug(): void
{
// Given
$board = Board::factory()->create(['is_active' => true]);
// When: slug 로 PUT
$response = $this->actingAs($this->adminUser)
->putJson("/api/modules/sirsoft-board/admin/boards/{$board->slug}", [
'is_active' => false,
]);
// Then
$response->assertStatus(200);
$this->assertFalse($board->fresh()->is_active);
}
/**
* 폼 데이터를 board_slug 로 수정 모드 조회할 수 있다 (#450)
*/
public function test_get_form_data_edit_mode_by_board_slug(): void
{
// Given
$board = Board::factory()->create(['is_active' => true]);
// When: board_slug 쿼리로 폼 데이터 조회
$response = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-board/admin/boards/form-data?board_slug='.$board->slug);
// Then: 해당 게시판 데이터가 로드됨
$response->assertStatus(200);
$this->assertSame($board->id, $response->json('data.id'));
}
/**
* 게시판을 slug 로 관리자 메뉴에 추가할 수 있다 (#450 add-to-menu slug 바인딩)
*
* 라우트 파라미터가 {board} 이므로 컨트롤러가 Board route-model binding 으로
* slug 를 해석해야 한다. int $id 로 받으면 slug 문자열이 전달될 때 실패한다.
*/
public function test_add_to_admin_menu_resolves_board_by_slug(): void
{
// Given: 활성 게시판
$board = Board::factory()->create([
'slug' => 'test-'.substr(md5(microtime().'addmenu'), 0, 8),
'is_active' => true,
]);
// When: 숫자 id 가 아닌 slug 로 add-to-menu 호출
$response = $this->actingAs($this->adminUser)
->postJson("/api/modules/sirsoft-board/admin/boards/{$board->slug}/add-to-menu");
// Then: slug 가 정상 해석되어 메뉴가 추가됨 (route-model binding 성공)
$response->assertStatus(200);
$this->assertNotNull($response->json('data.menu'));
}
/**
* 부분 업데이트 시 is_active만 변경 가능 테스트
*/
@@ -262,6 +262,122 @@ class PostResourcePermissionTest extends BoardTestCase
$this->assertArrayNotHasKey('admin_manage', $abilities);
}
// ==========================================
// can_access_admin 게이트 테스트 (#450)
// 유저 화면에서 관리자 게시판 화면 진입 크로스링크 노출 여부
// ==========================================
/**
* User 상세: admin.manage(Admin 타입) 권한 보유자는 can_access_admin = true
*
* @scenario link=user_board_to_admin, permitted=true
* @effects board_admin_gate_true_shows_link
*/
#[Test]
public function user_detail_returns_can_access_admin_true_for_admin_permission_holder(): void
{
// Given: 게시글 + admin.manage 권한 보유 사용자
$postId = $this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'Test Post',
'content' => 'Test Content',
]);
// When: 관리 권한 보유자가 유저 라우트로 상세 조회
$response = $this->actingAs($this->adminUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then: 유저 컨텍스트에도 can_access_admin 노출 + true
$response->assertStatus(200);
$abilities = $response->json('data.abilities');
$this->assertArrayHasKey('can_access_admin', $abilities);
$this->assertTrue($abilities['can_access_admin']);
}
/**
* User 상세: 일반 사용자(admin.manage 미보유)는 can_access_admin = false
*
* @scenario link=user_board_to_admin, permitted=false
* @effects board_admin_gate_false_hides_link
*/
#[Test]
public function user_detail_returns_can_access_admin_false_for_regular_user(): void
{
// Given
$postId = $this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'Test Post',
'content' => 'Test Content',
]);
// When: user 권한만 보유한 사용자가 유저 라우트로 상세 조회
$response = $this->actingAs($this->regularUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then: can_access_admin 은 노출되나 false
$response->assertStatus(200);
$abilities = $response->json('data.abilities');
$this->assertArrayHasKey('can_access_admin', $abilities);
$this->assertFalse($abilities['can_access_admin']);
}
/**
* User 목록: 컬렉션 abilities 에도 can_access_admin 노출
*/
#[Test]
public function user_list_collection_exposes_can_access_admin(): void
{
// Given
$this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'List Post',
'content' => 'List Content',
]);
// When: 관리 권한 보유자가 유저 라우트로 목록 조회
$response = $this->actingAs($this->adminUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts");
// Then: 컬렉션 abilities 에 can_access_admin = true
$response->assertStatus(200);
$abilities = $response->json('data.abilities');
$this->assertArrayHasKey('can_access_admin', $abilities);
$this->assertTrue($abilities['can_access_admin']);
}
/**
* User 목록/상세 응답의 board 정보에 id 가 포함된다 (#450)
*
* "게시판 관리" 크로스링크가 /admin/boards/:id/edit 로 이동하려면 board id 필요.
*/
#[Test]
public function user_response_board_info_includes_id(): void
{
// Given
$postId = $this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'Board Id Post',
'content' => 'Board Id Content',
]);
// When: 목록
$listResponse = $this->actingAs($this->regularUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts");
// And: 상세
$detailResponse = $this->actingAs($this->regularUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then: 목록/상세 board 정보 모두 실제 게시판 id 노출
$listResponse->assertStatus(200);
$this->assertSame($this->board->id, $listResponse->json('data.board.id'));
$detailResponse->assertStatus(200);
$this->assertSame($this->board->id, $detailResponse->json('data.board.id'));
}
// ==========================================
// 소유권 플래그 테스트
// ==========================================
@@ -4,6 +4,23 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-08
### Added
- 관리자 상품 수정 화면에 "유저 화면 보기" 버튼을 추가했습니다 — 클릭하면 해당 상품의 실제 사용자 상세 화면이 새 탭으로 열립니다.
### Changed
- 상품 상세 페이지 주소가 상품 번호 대신 상품코드 기준으로 바뀌었습니다 (상품 목록·최근 본 상품·검색·장바구니·주문내역·마이페이지 상품 문의내역 등 모든 상품 링크 포함). 기존 번호 기반 주소도 계속 동작합니다.
- 상품 검색에서 상품코드와 상품 번호로도 상품을 찾을 수 있습니다 — 관리자 상품 검색과 사용자 통합검색 모두 지원합니다. 통합검색 결과에서 상품을 클릭하면 상품코드 주소로 이동합니다.
### Fixed
- 관리자 주문 상세에서 주문상품을 "구매확정"으로 변경할 때 구매확정 처리가 고객이 직접 구매확정한 경우와 동일하게 이뤄지지 않던 문제를 수정했습니다 — 이제 구매확정 시점이 정확히 기록되어(부분 수량만 구매확정한 경우 포함), 구매확정 기준으로 지급되는 마일리지 적립이 정상적으로 지급되고, 리뷰 작성 기한도 올바르게 계산됩니다. 기존에는 관리자가 구매확정 처리한 주문상품이 마일리지 적립 대상에서 누락되고 리뷰 작성 기한이 계산되지 않을 수 있었습니다. (#61 @miles44229 님께서 제보해주셨습니다.)
- 주문상품을 여러 번에 나누어 구매확정할 때(예: 2개 중 1개씩 따로 확정) 적립 포인트가 일부만 지급되던 문제를 수정했습니다 — 이제 나누어 확정해도 예정 적립 포인트가 정확한 총액으로 지급됩니다.
- 마일리지 기능을 사용 안 함으로 설정한 뒤에는 지연 적립뿐 아니라 즉시 적립도 지급되지 않도록 통일했습니다 — 기존에는 지급 시점 설정에 따라 어떤 적립은 지급되고 어떤 적립은 지급되지 않을 수 있었습니다.
## [1.0.0] - 2026-07-01
### Fixed
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-ecommerce",
"description": "Ecommerce module for Gnuboard7",
"type": "library",
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"autoload": {
"psr-4": {
@@ -5,7 +5,7 @@
"ko": "이커머스",
"en": "Ecommerce"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"description": {
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-ecommerce",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-ecommerce",
"version": "1.0.0",
"version": "1.0.1",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-ecommerce",
"version": "1.0.0",
"version": "1.0.1",
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -87,7 +87,8 @@
"edit_search": "Edit Search",
"search": "Search",
"reset": "Reset",
"save_preset": "Save Preset"
"save_preset": "Save Preset",
"view_user_page": "View on site"
},
"bulk_apply": "Apply",
"bulk_success": "Selected products have been changed.",
@@ -87,7 +87,8 @@
"edit_search": "검색 편집",
"search": "검색",
"reset": "초기화",
"save_preset": "프리셋 저장"
"save_preset": "프리셋 저장",
"view_user_page": "유저 화면 보기"
},
"bulk_apply": "일괄 적용",
"bulk_success": "선택한 상품이 일괄 변경되었습니다.",
@@ -393,7 +393,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "page-header-padded"
"className": "page-header-padded flex items-start justify-between gap-3"
},
"children": [
{
@@ -417,6 +417,34 @@
"text": "{{route.itemCode ? '$t:sirsoft-ecommerce.admin.product.description_edit' : '$t:sirsoft-ecommerce.admin.product.description_create'}}"
}
]
},
{
"id": "view_user_page_link",
"type": "basic",
"name": "A",
"if": "{{!!route.itemCode}}",
"props": {
"href": "{{(_global.modules?.['sirsoft-ecommerce']?.basic_info?.no_route ? '' : ('/' + (_global.modules?.['sirsoft-ecommerce']?.basic_info?.route_path ?? 'shop'))) + '/products/' + route.itemCode}}",
"target": "_blank",
"rel": "noopener noreferrer",
"title": "$t:sirsoft-ecommerce.admin.product.action.view_user_page",
"className": "flex-center gap-2 flex-shrink-0 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors no-underline"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "external-link",
"size": "sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-ecommerce.admin.product.action.view_user_page"
}
]
}
]
}
@@ -1914,7 +1914,7 @@
"type": "basic",
"name": "A",
"props": {
"href": "{{`/admin/ecommerce/products/${row.product?.id}`}}",
"href": "{{`/admin/ecommerce/products/${row.product?.product_code ?? row.product?.id}/edit`}}",
"className": "text-blue-600 hover:underline dark:text-blue-400 font-medium text-sm leading-snug"
},
"text": "{{row.product?.name ?? ''}}"
@@ -1565,7 +1565,7 @@
"edit": {
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/products/{{$args[1].id}}/edit"
"path": "/admin/ecommerce/products/{{$args[1].product_code ?? $args[1].id}}/edit"
}
},
"copy": {
@@ -13,6 +13,7 @@ use Modules\Sirsoft\Ecommerce\Http\Requests\Public\PublicProductRecentRequest;
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductCollection;
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductListResource;
use Modules\Sirsoft\Ecommerce\Http\Resources\PublicProductResource;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Services\ProductService;
/**
@@ -167,16 +168,19 @@ class ProductController extends PublicBaseController
* 공개 상품 상세 정보를 조회합니다.
*
* 전시상태가 visible인 상품만 조회할 수 있습니다.
* 라우트 파라미터는 상품코드(product_code) 기준이며, 숫자 ID도 하위호환으로 허용됩니다
* (Product::resolveRouteBinding 이 code/id 를 자동 해석).
*
* @param int $id 상품 ID
* @param Product $product 라우트 바인딩된 상품 (product_code 또는 id)
* @return JsonResponse 상품 상세 정보를 포함한 JSON 응답
*/
public function show(int $id): JsonResponse
public function show(Product $product): JsonResponse
{
try {
$this->logApiUsage('products.show', ['product_id' => $id]);
$this->logApiUsage('products.show', ['product_id' => $product->id]);
$product = $this->productService->getDetail($id);
// 바인딩된 상품의 id 로 상세(옵션/관계 포함)를 재조회
$product = $this->productService->getDetail($product->id);
if (! $product || $product->display_status->value !== 'visible') {
return ResponseHelper::notFound(
@@ -7,6 +7,7 @@ use App\Http\Controllers\Api\Base\PublicBaseController;
use Exception;
use Illuminate\Http\JsonResponse;
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\StoreInquiryRequest;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Services\ProductInquiryService;
/**
@@ -24,10 +25,10 @@ class ProductInquiryController extends PublicBaseController
/**
* 상품 문의 목록 조회
*
* @param int $productId 상품 ID
* @param Product $product 라우트 바인딩된 상품 (product_code 또는 id)
* @return JsonResponse 문의 목록 및 board_settings 메타 JSON 응답
*/
public function index(int $productId): JsonResponse
public function index(Product $product): JsonResponse
{
try {
$this->logApiUsage('inquiry.index');
@@ -35,7 +36,7 @@ class ProductInquiryController extends PublicBaseController
$page = (int) (request()->query('page', 1));
$excludeSecret = filter_var(request()->query('exclude_secret', false), FILTER_VALIDATE_BOOLEAN);
$result = $this->inquiryService->getProductInquiries($productId, $perPage, $page, $excludeSecret);
$result = $this->inquiryService->getProductInquiries($product->id, $perPage, $page, $excludeSecret);
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
@@ -55,14 +56,14 @@ class ProductInquiryController extends PublicBaseController
* 상품 문의 작성
*
* @param StoreInquiryRequest $request 문의 작성 요청
* @param int $productId 상품 ID
* @param Product $product 라우트 바인딩된 상품 (product_code 또는 id)
* @return JsonResponse 작성된 문의 JSON 응답
*/
public function store(StoreInquiryRequest $request, int $productId): JsonResponse
public function store(StoreInquiryRequest $request, Product $product): JsonResponse
{
try {
$this->logApiUsage('inquiry.store');
$inquiry = $this->inquiryService->createInquiry($productId, $request->validated());
$inquiry = $this->inquiryService->createInquiry($product->id, $request->validated());
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
@@ -8,6 +8,7 @@ use Exception;
use Illuminate\Http\JsonResponse;
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\PublicReviewListRequest;
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductReviewResource;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Services\ProductReviewService;
/**
@@ -24,11 +25,11 @@ class ProductReviewController extends PublicBaseController
/**
* 상품 공개 리뷰 목록 및 별점 통계 조회
*
* @param Request $request 요청
* @param int $productId 상품 ID
* @param PublicReviewListRequest $request 요청
* @param Product $product 라우트 바인딩된 상품 (product_code 또는 id)
* @return JsonResponse 리뷰 목록 및 별점 통계 JSON 응답
*/
public function index(PublicReviewListRequest $request, int $productId): JsonResponse
public function index(PublicReviewListRequest $request, Product $product): JsonResponse
{
try {
$this->logApiUsage('review.index');
@@ -40,7 +41,7 @@ class ProductReviewController extends PublicBaseController
: (json_decode($rawOptionFilters, true) ?? []);
$perPage = (int) ($filters['per_page'] ?? 10);
$result = $this->reviewService->getProductReviews($productId, $filters, $perPage);
$result = $this->reviewService->getProductReviews($product->id, $filters, $perPage);
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
@@ -7,6 +7,7 @@ use App\Http\Controllers\Api\Base\PublicBaseController;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Services\UserCouponService;
/**
@@ -27,16 +28,16 @@ class PublicCouponController extends PublicBaseController
* 로그인 시 is_downloaded 정보가 추가됩니다.
*
* @param Request $request 요청 데이터
* @param int $productId 상품 ID
* @param Product $product 라우트 바인딩된 상품 (product_code 또는 id)
* @return JsonResponse 쿠폰 목록
*/
public function downloadableCoupons(Request $request, int $productId): JsonResponse
public function downloadableCoupons(Request $request, Product $product): JsonResponse
{
try {
$this->logApiUsage('products.downloadable_coupons');
$userId = $request->user('sanctum')?->id;
$coupons = $this->userCouponService->getProductDownloadableCoupons($productId, $userId);
$coupons = $this->userCouponService->getProductDownloadableCoupons($product->id, $userId);
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
@@ -34,6 +34,7 @@ class ProductReviewResource extends BaseApiResource
// 상품 정보
'product' => $this->whenLoaded('product', fn () => [
'id' => $this->product->id,
'product_code' => $this->product->product_code,
'name' => $this->product->getLocalizedName(),
'thumbnail_url' => $this->product->getThumbnailUrl(),
]),

Some files were not shown because too many files have changed in this diff Show More