134 Commits
Author SHA1 Message Date
HeuJung 00ef91bda5 Merge branch 'develop' 2026-07-14 15:56:07 +09:00
HeuJung 8fcb79d60f docs(changelog): 공개 대상 아닌 나이스페이먼츠 플러그인 수정 항목 제거
7.0.4 에서 나이스페이먼츠 결제 플러그인을 번들에서 제외하므로,
해당 플러그인의 결함 수정 항목은 코어 릴리즈 노트에서 제거한다.
2026-07-14 15:54:46 +09:00
HeuJung 79b83626eb Merge branch 'develop' 2026-07-14 15:16:41 +09:00
HeuJung 29c25376bc 공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 번들 제거 2026-07-14 15:09:24 +09:00
HeuJung 512869e407 docs(pay_nhnkcp): 공개 배포 대상 주석에서 내부 역할 호칭 제거
RestrictKcpIp 의 테스트 모드 설명 주석에 내부 역할 호칭이 남아 있어
공개 release 누출 검사에서 경고로 검출되었다. 의미가 동일한 일반 표현으로
정정해 공개 저장소 발행 시 내부 단서가 실리지 않도록 한다.
2026-07-14 15:05:04 +09:00
정정홍andGitHub ab4f05c1c0 Merge pull request from gnuboard:HeuJung/issue473
fix(security): 스케줄러 Shell/Artisan 명령 실행 하드닝
2026-07-14 14:55:57 +09:00
HeuJung ce8e55b390 fix(security): 스케줄러 Shell/Artisan 명령 실행 하드닝
미출시 예약 작업 기능에서 스케줄 권한만 위임받은 계정이 Shell/Artisan
명령으로 임의 코드를 실행할 수 있던 문제를 선제적으로 차단(KVE-2026-1567).
Shell 은 기본 차단 + 실행 파일 화이트리스트 + 셸 미경유 인자배열 실행,
Artisan 은 차단목록. 저장 시점(FormRequest)과 실행 시점(Service) 이중 검증.

아울러 공개 release 보다 앞선 미출시 버전들의 CHANGELOG 출시 날짜를
2026-07-14 로 통일.
2026-07-14 14:48:33 +09:00
정정홍andGitHub 4c73db2e91 Merge pull request from gnuboard:HeuJung/issue471
HeuJung/issue471
2026-07-14 13:50:08 +09:00
HeuJung 47a2e815aa test(security): 활성 확장 디렉토리 소스맵 잔존 회귀 가드 추가
브라우저 실측(제기자 관점 재검증) 중 발견: 기존 가드 3종이 모두 git 추적
파일만 검사해, 실제 서빙 대상인 활성 확장 디렉토리(gitignore 대상)를 통째로
놓치고 있었다. _bundled 를 --production 으로 재빌드해도 {type}:update 를
돌리지 않은 확장은 활성 디렉토리에 예전 .map 과 sourceMappingURL 을 그대로
안고 있었고(6건), 그 상태로 테스트는 전부 green 이었다.

- 활성 디렉토리(templates|modules|plugins)/{id}/dist/js/*.map 를 glob 로 전수 검사
- 배포본 포함 여부는 _bundled 원본 존재로 판정. DB 레코드로 판정하면 테스트 DB 에
 확장 행이 0건이라 전부 skip 되어 영구 green 이 된다(실제로 이 함정에 빠져
 .map 을 심어도 통과했다)
- 가드 실효성은 설치된 확장에 .map 을 심어 red 확인 후 제거해 green 복귀로 실증

서빙 계층이 .map 을 거부하므로 원본 유출로는 이어지지 않았으나(브라우저 실측:
모든 .map 요청이 non-JSON, 원본 TS 4종 전부 404), 배포본 위생상 잔존해서는 안 된다.
2026-07-14 13:13:42 +09:00
HeuJung 90f154ecc0 fix(security): 배포본 소스맵 노출 차단 + 관리자 설정 탭 지연로딩 + 동반 결함 수정
## 소스맵 노출 차단 (공개 이슈 )

배포본에 .map 이 포함돼 원본 TS/TSX 전문(sourcesContent, 한글 주석 포함)이
그대로 노출됐다. 생성·서빙·저장소 유입 3계층을 모두 막는다.

- 빌드 config 15곳(코어 3 + _bundled 12): sourcemap 하드코딩 제거.
 G7_BUILD_SOURCEMAP 판정식으로 교체 — 미설정(로컬 npm run build)이면 생성해
 개발 디버깅 경험을 보존하고, 0 이면 미생성.
- Build{Core,Module,Plugin,Template}Command: 죽어 있던 --production 플래그 복구.
 $productionMode 를 콘솔 문자열에만 쓰고 npm 프로세스에 env 를 넘기지 않아
 산출물이 개발 빌드와 동일했다. runNpmCommand 에 $env 를 추가해 주입
 (--watch 에는 미주입). Symfony Process 는 부모 환경에 병합하므로 PATH 유실 없음.
- Allowed{Module,Plugin}FileType / {Module,Plugin}Service::getMimeType:
 허용 확장자 map 제거 — 익명 200 서빙 차단(템플릿은 원래 미허용, 3종 정책 일치).
- ExtensionBundleService: CSS 번들의 /*# sourceMappingURL=... */ strip 추가.
 CSS 는 블록 주석이라 JS 의 //# 패턴으로는 검출되지 않던 구멍.
- 추적 .map 13개 삭제 + .gitignore 전역 *.map 규칙(경로 한정 시 새 확장에서 누락).

실측: 산출 JS 의 sourceMappingURL 13 → 0, 추적 .map 13 → 0,
확장 디렉토리 npm run build 는 여전히 map 생성(개발 경험 회귀 없음).

## 관리자 환경설정 탭 지연 로딩

코어/이커머스/게시판 환경설정 화면이 진입 즉시 전 탭의 데이터소스를 한꺼번에
호출했다. 각 데이터소스에 활성 탭 조건(if)을 걸어 해당 탭에서만 fetch 하도록
변경하고, 결제 플러그인 3종의 테스트모드 상태 조회도 주문설정 탭으로 한정.

## 동반 결함 수정 (무관 결함 — 재발 방지 목적으로 같은 세션 처리)

- 나이스페이먼츠: 체크아웃 캐시 프리페치가 라우트를 가리지 않아 플러그인 번들이
 로드되는 모든 페이지(홈/게시판/로그인)에서 주문서를 조회 → 비회원 매번 422.
 형제 플러그인(kginicis)의 기존 가드 패턴을 적용해 체크아웃 경로로 한정.
- admin smoke 8건: 대부분 구현이 아니라 테스트 결함이었다 — URL 오타(단수 board),
 존재하지 않는 권한키, 앱 로케일이 en 인데 한국어 라벨 로케이터, DataGrid 버튼을
 먼저 잡던 휴리스틱 로케이터. 레이아웃에는 sticky className / 안정 id 를 부여.
- PHPDoc ↔ Pint 충돌 3건: 설명 없는 @param 은 no_superfluous_phpdoc_tags 가
 삭제해 audit phpdoc-public-method 와 무한 충돌. 한국어 설명을 붙여 양쪽 충족
 (리스너 3종의 설명 누락 메서드도 전수 보강).
- Log::spy 500 3건: spy 가 Log::channel('activity') 를 null 로 만들고
 logActivity 가 null->info 로 \Error → 같은 줄 catch(\Exception) 은 못 잡아
 통과. 운영에선 채널이 항상 로거이므로 제품 무결 → 테스트에 activity 채널
 보존 헬퍼 추가.
- NhnKcpApiServiceTest 5건: 스텁이 Unix 바이너리만 만들어 Windows 분기
 (pp_cli_exe.exe)에서만 실패. #!/bin/sh 는 Windows 실행 불가 → 파일 내 기존
 컨벤션대로 skip 가드.

## 테스트

- 소스맵 범위 130 passed (신규: BuildCommandSourcemapEnv, NoSourcemapArtifacts,
 ExtensionBundleService CSS strip, Allowed*FileType 반전, 자산 서빙 거부)
- 결제 3플러그인 505 passed / 0 failed (skipped 9 = Windows 전용 CLI, 사유 명시)
- 나이스페이먼츠 프론트 38 passed (가드 제거 시 red 실증 후 복원)
- E2E: production-sourcemap-exposure.spec.ts 6건 + 시나리오 매니페스트

버전: sirsoft-admin_basic 1.0.2 → 1.0.3, sirsoft-board 1.0.1 → 1.0.2
(각 manifest/package/lock/composer 동기화). 코어 7.0.4 는 미출시 누적.
2026-07-14 12:50:23 +09:00
정정홍andGitHub 84ca40f0d8 Merge pull request from gnuboard:HeuJung/issue466
HeuJung/issue466
2026-07-14 09:20:09 +09:00
HeuJung c4beb116ad fix(security): outbound URL 내부주소·host 검증 전수 강화
서버가 외부 입력으로 목적지가 제어되는 outbound HTTP 요청을 보내는 지점을
전수 조사해, 내부망(사설 IP·루프백·링크로컬·메타데이터)과 host 위조를 차단한다.
KVE-2026-1546(이니시스 인증 URL 접두사 매칭 우회)과 동일 클래스의
지점(언어팩 URL 설치, 스케줄 URL 호출, 배송비 계산 API, GitHub URL 판정)을
코어 공용 유틸 App\Support\OutboundUrlValidator 로 수렴시켰다.

- 접두사 매칭을 host 완전일치로 교체 — userinfo(@) 위장과 접미사 확장 도메인
 우회 벡터 차단.
- 사내 서버 호출이 정당한 관리자 지점(스케줄·외부 API)은 신규 설정
 security.allow_internal_outbound_urls(기본 off)로 옵트인.
- 언어팩 URL 설치는 원격 코드 다운로드라 옵트인과 무관하게 내부 주소 항상 차단.
- 웹소켓 연결 테스트는 localhost·사설 IP 가 정상 구성이므로 구조적 위조만 차단.

각 지점에 red 재현 → 수정 → green 회귀 테스트 + 실브라우저 E2E 로 검증.
수기 API 문서가 자동 재생성으로 소실되는 사고를 막는 PreToolUse 훅을 함께 추가한다.
2026-07-14 09:17:00 +09:00
정정홍andGitHub f848086fd6 Merge pull request from gnuboard:HeuJung/issue465
HeuJung/issue465
2026-07-13 17:05:21 +09:00
HeuJung cc705c2f21 test(installer): 엔드포인트 조립 계층 회귀 가드 추가
escapeEnvValue 재선언 fatal 이 통과한 사각지대를 구조적으로 봉인한다.

인스톨러는 Laravel 오토로드 밖의 플레인 PHP require 체인이라, 엔드포인트가
"어떤 파일을 어떤 순서로 조립하는가" 가 곧 함수 선언 순서다. 기존 단위 테스트는
함수를 직접 호출하므로 이 조립 계층을 전혀 검증하지 못했고, 그래서 재선언
fatal(빈 응답 → 프론트 JSON 파싱 에러)이 그대로 통과했다.

추가: 엔드포인트 5종(install-process / state-management / finalize-env /
install-worker / index)의 실제 require 체인을 격리 프로세스로 재현하고,
그 엔드포인트가 호출하는 함수가 모두 정의되는지 + fatal 이 없는지 검증.

이로써 "Call to undefined function"(정의 파일 미포함) 과 "Cannot redeclare"
(가드 없는 중복 선언) 두 fatal 유형이 모두 차단된다 — 증상은 빈 응답으로
동일하지만 원인이 달라 각각 잡아야 한다.

가드 제거 시 9건 중 3건이 red 로 전환됨을 확인 (거짓 통과 아님).
2026-07-13 17:00:59 +09:00
HeuJung 1922003917 fix(installer): 설치 시작 즉시 빈 응답 — escapeEnvValue 재선언 fatal
직전 커밋이 install-process.php 에 installer-runtime.php require 를
functions.php 앞에 추가하면서 발생한 회귀.

installer-runtime.php 는 escapeEnvValue 를 function_exists 가드와 함께
polyfill 로 선언하는데, functions.php 는 같은 함수를 가드 없이 선언하고 있었다.
로드 순서가 뒤바뀌자 "Cannot redeclare escapeEnvValue" fatal 이 발생하고,
JSON 이 출력되기 전에 스크립트가 죽어 응답 본문이 비었다. 브라우저의
res.json 이 이를 "Unexpected end of JSON input" 으로 보고해 설치 시작
즉시 실패했다 (SSE / 폴링 양 모드 — 둘 다 install-process.php 를 경유).

수정:
- functions.php 의 escapeEnvValue 에 function_exists 가드 추가 (근본 방어 —
 require 순서만 되돌리면 다음 사람이 순서를 바꿀 때 재발한다)
- install-process.php 의 require 순서를 다른 엔드포인트와 동일하게 정렬

인스톨러 전역 전수 스캔 결과 중복 선언 함수는 escapeEnvValue 하나뿐이며
이제 양쪽 모두 가드를 갖는다.

회귀 가드: InstallerIncludeOrderTest — 격리 프로세스로 로드 순서를 바꿔가며
fatal 부재 검증 + 중복 선언 함수의 가드 보유를 정적 검사 (수정 전 3/4 fail 확인).
2026-07-13 17:00:59 +09:00
HeuJung 9be8faaea2 fix(installer): 설치 상태 파일에 관리자 비밀번호가 평문으로 잔존하는 문제
비밀을 state.json(0664, 실패 시 무기한 잔존)에 기록하지 않고
runtime.php(0600, finalize 시 삭제)로 채널을 일원화한다.

정상 완료 경로에서는 finalize 가 state.json 을 삭제하지만, finalize
미호출 / unlink 실패 / 설치 중단 시에는 평문이 영구히 남았고, finalize
가드가 재호출을 410 차단해 자가 회복도 불가능했다. 삭제라는 단일 경로에
의존해 평문을 보호하던 구조 자체를 제거한다.

SSE 워커는 세션에 접근할 수 없어 파일 경유가 불가피한데, 그 파일이 0664 의
state.json 이었던 것이 구조적 원인이다. 세션을 보유한 유일한 공통 시작점인
install-process.php 를 비밀 이송 지점으로 삼고, db_seed 가 소비한 즉시
제거한다. 레거시 / 실패 경로는 완료·중단·초기화·finalize 시점 redact 로 방어.

동반 수정:
- 세션 config 재병합으로 무력화되던 기존 DB 비밀번호 마스킹 복구
- db_seed 후 putenv 미해제로 모든 exec 자식에 평문이 ENV 상속되던 누출 차단
- runtime read 비밀번호 폴백이 write 값으로 잘못 대체되던 문제 ( 인접)

이미 설치된 사이트는 7.0.4 업그레이드 스텝이 잔존 파일을 정리한다.
완료 증거가 있으면 삭제하고, 설치 진행 중일 수 있으면 비밀만 제거한다.
2026-07-13 17:00:59 +09:00
eunhyeandGitHub ae447c9cc4 Merge pull request from gnuboard/chym1217/issue458
feat(verification_kginicis): 라이브 MID 자동완성 방지 + SRB 프리픽스 전환
2026-07-13 16:32:22 +09:00
chym1217 57ac2b0b3b feat(verification_kginicis): 라이브 MID 자동완성 방지 + SRB 프리픽스 전환
- 라이브 자격증명 입력칸 브라우저 자동완성 자동 채움 차단
 (live_mid: one-time-code / live_api_key: new-password —
 크롬이 autoComplete=off 를 무시함을 실측 후 강화)
- 라이브 가맹점 MID 프리픽스를 이니시스 정책 변경에 맞춰 SRA→SRB 전환.
 런타임 프리픽스를 InicisIdentityProvider::LIVE_MID_PREFIX 상수로 추출,
 표시 문자열(라벨·Span·힌트 ko/en/ja)은 값 교체.
- 설정 저장 테스트가 실제 스토리지 설정 파일을 오염시키던 결함 격리
 (PluginTestCase 에서 plugins 디스크를 테스트 전용 경로로 격리)
- 설정 화면 배너 아이콘 크기 시맨틱 정정 (w-N h-N → text-*, 정렬 회귀 해결)
- 프리픽스 상수 ↔ 표시 문자열 정합 audit 룰 추가 (이중관리 불일치 차단)
2026-07-13 15:18:10 +09:00
HeuJung cd3f6ad2b7 Revert "공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 번들 제거"
This reverts commit bac963b011f7b180c0d64e4f050131b5f05b85c1.
2026-07-13 11:10:50 +09:00
HeuJung a690fdd2c4 Revert "공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 일본어 언어팩 번들 제거"
This reverts commit 2aead0879df6ab08ec58b9b3ab08c7cdaa50a935.
2026-07-13 11:10:50 +09:00
정정홍andGitHub 8cc40e0681 Merge pull request from gnuboard:HeuJung/issue463
fix(engine): 페이지 로드 요청 1건의 일시 실패가 앱 전체를 죽이는 문제
2026-07-12 17:22:56 +09:00
HeuJung d85ff9971a test(engine): 감사 지적 3건 반영 — 컴포넌트 번들 E2E 커버 + 약한 단언 실질화
D-4 (실질 커버리지 공백):
- 코어 번들은 "브라우저가 abort 된 script 를 스스로 재요청한다" 는 실측 근거로
 상시-부재 폴백 테스트를 대체본으로 뒀으면서, 형제 경로인 템플릿 컴포넌트
 번들에는 그 대체본조차 없었다. 6경로 중 이 1경로만 E2E 무커버였다.
 상시-부재 → 폴백 안내 E2E 추가 (수정 전 blade 에서 red 확인).

B-9 (제목↔단언 불일치):
- 제목은 "렌더는 진행된다" 인데 단언은 executeInitActions 가 reject 하지
 않는다는 것뿐이라, 그 뒤 init 체인이 죽어도 통과했다. renderTemplate 직접
 단언은 라우터/레이아웃 로드를 거치는 별개 경로라 단위에서는 모킹을 시험하는
 꼴이 되므로, 실제 본체인 "초기화가 에러 화면 없이 완주한다" 로 단언을 바꾸고
 제목도 그에 맞췄다.

B-1 (spy 미사용):
- setRoutes spy 를 선언만 하고 쓰지 않아 "에러 화면이 안 떴다" 만 단언했다.
 라우트가 비어도 에러 화면은 안 뜬다. 재시도로 받아온 routes 가 실제로
 라우터에 반영됐음을 단언한다 (재시도 제거 시 red 확인).
2026-07-12 17:09:03 +09:00
HeuJung 793afb95dc fix(engine): 감사 지적 반영 — 언어팩 버전 bump + 회귀 가드 실질화
전수조사에서 지적된 5건을 코드로 재확인하고 조치했다.

차단성:
- ja 언어팩 1.0.3 → 1.0.4. 1.0.3 은 이미 배포된 버전이라, 같은 버전에 키를
 추가하면 기설치 사이트가 업데이트를 받지 못해 번역이 영영 도달하지 않는다.

회귀 가드 실질화 (green ≠ 회귀를 잡는다):
- 미등록 핸들러 억제 테스트가 동어반복이었다. 값을 손으로 대입하고 그 값을
 읽어, 억제를 삭제해도 통과했다. 실제 dispatch 후 표시 계층이 호출되지
 않음을 감시하도록 교체 (억제 제거 시 red 확인).
- script 번들의 "1건 취소 → 복구" E2E 는 수정 전에도 통과한다. 브라우저가
 abort 된 script 를 스스로 재요청하기 때문 (수정 전 blade 로 실측: 재시도
 훅 0개인데 hits=2, 렌더 정상). 거짓 안심이므로 제거하고, blade 가 고유하게
 보장하는 것 — 재시도/폴백 훅 설치 + 정상 경로 오발동 없음 — 을 단언하도록
 교체 (수정 전 red 확인).
- 재시도 횟수 단언을 하한(>=2)에서 정확값(==2)으로. 과잉 재시도 회귀를
 통과시키던 구멍을 막는다.

정확도:
- waitForHandlers 가 전역 실패 플래그로 판정해, 무관한 확장의 실패가 정상
 로드된 확장의 핸들러 대기까지 포기시켰다. 실패 키 단위로 정밀화한다.
2026-07-12 16:30:38 +09:00
HeuJung 68cbee0e83 fix(engine): 좁은 창에서 레이아웃 편집기가 찌그러지는 문제
레이아웃 편집기는 라우트 트리(280px 고정) + 디바이스 미리보기 캔버스 +
라벨 있는 툴바 버튼 10여 개가 나란히 놓이는 대화면 전용 도구다. 그런데
셸에 너비 하한이 없어 창을 좁히면 이들이 창 너비에 맞춰 압착됐고, 툴바가
flex row 라 각 항목 폭이 깎이면서 라벨이 글자 단위로 줄바꿈됐다("요 소 추 가").

반응형으로 재배치하는 대신 셸이 최소 너비(EDITOR_MIN_WIDTH=1280)를 유지하고
모자란 폭은 브라우저 가로 스크롤이 흡수하도록 했다. 편집기 UI 는 축약 대상이
아니며, 압착을 허용하면 캔버스 실측 폭이 뷰포트에 종속돼 디바이스 미리보기의
의미 자체가 사라진다.

툴바 압착 차단은 직접 자식 전체를 덮는 CSS 규칙으로 뒀다. 버튼마다 인라인
style 로 붙이면 자체 style 을 가진 하위 컴포넌트(템플릿 전환기·언어 전환기)와
이후 추가될 항목이 빠져 같은 결함이 재발한다.

jsdom 은 레이아웃 엔진이 없어 단위 테스트로는 "style 선언 존재" 까지만 잠긴다.
실제로 압착이 멈췄는지는 렌더 박스를 재는 브라우저에서만 증명되므로 Playwright
spec 을 함께 추가했다(수정 되돌림 시 red 확인). 아울러 이 파일의 e2e:allow 면제
주석은 사유("브라우저 가시 UI 무변경")가 더 이상 참이 아니라 제거했다.
2026-07-12 16:13:51 +09:00
HeuJung bfaf551d41 fix(engine): 페이지 로드 요청 1건의 일시 실패가 앱 전체를 죽이는 문제
모바일에서 새로고침을 연타하면 진행 중이던 요청이 취소되는데, 화면 구성에
필요한 요청 하나만 그렇게 끊겨도 전면 "초기화 실패" 로 끝났다. 재시도가 전혀
없었고, 로컬은 왕복이 수 ms 라 재현되지 않아 오래 남아 있었다.

fetch 는 4xx/5xx 로 reject 하지 않고 Response.ok=false 로 resolve 한다.
따라서 TypeError 는 "응답 자체가 없었다"(취소·커넥션 유실)는 뜻이며, 그 경우만
재시도해야 한다. HTTP 응답은 그대로 호출부에 넘겨 기존 상태코드 분기와 이중으로
겹치지 않게 했다.

- routes.json / components.json / 레이아웃 JSON: 네트워크 실패에만 지수 백오프 재시도
- 실패한 promise 가 레이아웃 캐시에 남아 영구 실패가 되던 문제 수정
- 문서 이탈(pagehide) 중에는 에러 화면을 그리지 않는다 (버려질 문서)
- 확장 에셋 로더의 실패 계약을 resolve → reject 로 (실패를 발생 지점에서 표면화)
- 번들 로드가 실패로 확정되면 오지 않을 핸들러를 5초간 기다리지 않는다
- 미등록 핸들러의 raw 내부 식별자가 토스트로 노출되던 것을 차단
- 번들이 끝내 부재해도 백지 대신 새로고침 버튼이 있는 안내 화면

정상 경로 성능은 종전과 같다. script 태그는 정적으로 유지해 프리로드 스캐너
이점을 보존하고, 실패했을 때만 onerror 에서 동적 재시도를 건다.
2026-07-12 15:39:07 +09:00
정정홍andGitHub 8018c525b8 Merge pull request from gnuboard:HeuJung/issue461
HeuJung/issue461
2026-07-11 22:00:42 +09:00
HeuJung 25c0ab46ec fix(vendor-bundle): 확장에 새 패키지 추가 시 번들 누락 수정
빌드가 composer install 의 입력(스테이징에 복사할 composer.json/lock)만 활성
디렉토리에서 가져오고 manifest 해시는 _bundled 기준으로 기록해, 두 판단이 서로
다른 파일을 보고 있었다.

개발자는 규정상 _bundled 에서만 작업하므로 새 패키지를 추가하면 _bundled 에만
반영되고 활성은 다음 update 전까지 구버전으로 남는다. 그 결과 구버전 lock 으로
설치한 zip 에 신버전 해시가 붙어, 무결성 검증은 통과하는데 새 패키지가 번들에서
누락되고 설치 후 런타임에 클래스 not found 로 터졌다.

설치 입력·해시 기준·의존성 판정·stale 판정 네 가지를 모두 resolveHashTarget
(출력 우선, 없으면 소스 폴백) 으로 통일. 코어는 source == output 이라 무영향.

실증: 이커머스에 psr/log 추가 → 빌드 2 packages(수정 전 1) → zip 에 vendor/psr/log
포함 → module:update → 활성 vendor 배포 + Psr\Log\LoggerInterface 런타임 로드 확인.
2026-07-11 19:50:06 +09:00
HeuJung 13e65131f4 fix(vendor-bundle): 코어 빌드 무한 행 해결 + 이커머스 알림 라벨 raw 노출 수정
이커머스 알림 정의 화면에서 order_pending_deposit·order_delivered 두 알림이
라벨 대신 raw 다국어 키로 표시되던 문제를 수정. settings.json 3로케일에 라벨
추가 + 렌더 회귀 테스트 + 브라우저 실측.

vendor-bundle 시스템 결함 5종 해결:
- (A/B) manifest 해시 기준을 활성이 아닌 출력(_bundled) 디렉토리로 통일해
 확장 업데이트가 composer_json_sha_mismatch 순환에 빠지던 문제 해소
- (C) 코어 빌드 시 composer autoload dump 의 async 손자 프로세스가 부모
 파이프 핸들을 상속·점유해 무한 대기하던 교착을, stdout/stderr 를 파이프
 아닌 파일 descriptor 로 받아 원천 차단 (파이프=행/파일=완주 A/B 실측으로 확정)
- (D) 빌드 실패 시 원본 번들 유실 방지 — 임시파일 성공 후 원자적 교체
- (E) Windows .bat 실행 시 bypass_shell 통일

코어 7.0.4 / 이커머스·언어팩 1.0.3 bump.
2026-07-11 07:00:34 +09:00
HeuJung a34a03eb40 Merge branch 'develop' 2026-07-10 21:12:41 +09:00
HeuJung b8ceb26cf3 fix(ecommerce): vendor-bundle 재빌드 — composer.json 해시 불일치로 모듈 업데이트 실패
7.0.3 로 업데이트하는 서버에서 module:update sirsoft-ecommerce 가
"번들 파일의 무결성 검증에 실패했습니다" 로 중단됐다.

앞선 커밋이 composer.json 의 version 을 1.0.1 → 1.0.2 로 올리면서
vendor-bundle:build 재실행을 빠뜨려, vendor-bundle.json 이 옛 해시
(8a46fadc…)를 그대로 들고 있었다. 실제 파일은 aa8f7406… 이다.
이 상태가 v7.0.3 태그에 그대로 실렸다.

로컬에서 재현되지 않은 것은 ModuleManager 의 isComposerUnchanged 가
staging 과 active 의 composer.json 을 비교해 같으면 vendor 설치를
통째로 건너뛰기 때문이다. 이미 1.0.2 가 깔린 환경은 검사 자체를 타지
않는다. 1.0.1 에서 올라오는 서버만 설치 경로로 들어가 검증에 걸린다.

재빌드로 manifest 해시를 실제 파일과 맞추고 g7_version 도 7.0.3 으로
갱신했다. zip 내용물은 432개 파일 중 vendor/composer/installed.php 의
자기 버전 표기(1.0.1 → 1.0.2) 한 곳만 달라지고 htmlpurifier 를 포함한
나머지 431개는 바이트 단위로 동일하다. 의존성 변화는 없다.

vendor-bundle:verify-all 로 코어와 전 번들 확장이 통과함을 확인했고,
서버와 같은 경로(--vendor-mode=bundled)로 module:update 를 실제 실행해
성공을 확인했다.
2026-07-10 21:10:57 +09:00
HeuJung feca0c7146 Merge branch 'develop' 2026-07-10 20:44:07 +09:00
HeuJung 1ec9692cac 공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 일본어 언어팩 번들 제거 2026-07-10 20:42:59 +09:00
HeuJung b65c782447 Merge branch 'develop' 2026-07-10 20:24:14 +09:00
HeuJung 2832db6ffd chore(api-docs, sirsoft-ecommerce): 공개 release 누출 검사 위반 정리
공개 release 빌드의 파일 본문 누출 검사에서 검출된 항목을 정리한다.

api-documentation.md 는 릴리즈에 포함되지 않는 내부 지침 파일명을 참조하고
있었다. 공개본 독자에게는 존재하지 않는 파일을 가리키므로 공개 진입점
문서(AGENTS.md)로 교체했다.

이커머스 배송국가 테스트 2종의 주석에서 내부 역할 호칭을 제거했다. 판단
근거 서술은 그대로 두고 호칭만 걷어냈으므로 어서션·실행 경로는 불변이다.

검증: check-public-release-leakage error 0 / warn 0,
PruneEmptyShippingCountryNameLocalesTest 10/10 통과.
2026-07-10 20:23:13 +09:00
HeuJung f432df9d9c Merge branch 'develop' 2026-07-10 20:06:10 +09:00
HeuJung edb4d13096 공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 번들 제거 2026-07-10 20:05:23 +09:00
정정홍andGitHub abe3fe85d4 Merge pull request from gnuboard:HeuJung/issue459
HeuJung/issue459
2026-07-10 19:33:00 +09:00
HeuJung 809409f3cf fix(api-docs): 문서 샘플 언어팩이 전역 지원 로케일을 오염시키는 문제 수정
api:docgen --seed 가 생성하는 샘플 언어팩이 scope=core + status=active 로
만들어져 getActiveCoreLocales 승격 쿼리에 걸렸다. 그 결과 시스템 전역
supported_locales/translatable_locales 가 넓어지고, 그 아래에서 시드된
다국어 데이터에 실재하지 않는 로케일 키가 박혔다. 샘플을 비활성화하면
그 키가 TranslatableField 검증을 통과하지 못해 저장이 막힌다.

문서용 샘플은 조회 예시일 뿐 시스템 동작을 바꿀 자격이 없으므로,
승격 경로를 벗어난 조합(module 스코프 + installed 상태 + 실재 번들 로케일)
으로 생성하도록 바꾸고 audit 룰로 재발을 차단한다.

README 재생성 시 사람이 덧붙인 헤딩 섹션이 통째로 소실되던 사전 결함도
함께 수정했다(경계를 임의의 ## 이 아닌 확장 목차 헤딩으로 특정).
2026-07-10 19:27:27 +09:00
HeuJung 12968b76ca fix(core): 다중 progressive 데이터소스의 initLocal 유실 수정
_localInit 은 단일 슬롯이고 updateTemplateData 가 얕은 스프레드로 교체했다.
소비부(DynamicRenderer useEffect)는 React commit 이후에 실행되므로,
initLocal 을 가진 progressive 소스가 둘 이상이면 두 payload 가 같은 commit
사이에 도착할 때 먼저 온 것이 한 번도 관측되지 않고 사라졌다.

무조건 누적 병합은 refetchDataSource 시 소비가 끝난 payload 를 재적용해
사용자 폼 편집을 되돌린다. 그래서 아직 관측되지 않은 슬롯만 병합하고,
관측된 슬롯은 종전대로 교체한다. 소비 여부는 소비부가 기록한 슬롯 참조로
판정해 해시 계산식을 생산부에 복제하지 않는다.

레이아웃 전환으로 _local 을 리셋할 때 추적 레지스트리도 함께 비운다.

검증: CPU 6배 스로틀로 경합 창을 강제로 열어 수정 전 0/8 → 수정 후 8/8.
동반: template-engine.test.ts 의 vi.mock 이 공유 런타임 export 를 빠뜨려
스위트가 로드되지 않던 것과, 코어 spec 2개가 없는 fixture 를 import 해
playwright 수집이 실패하던 것을 함께 복구했다.
2026-07-10 19:27:27 +09:00
HeuJung 051ed1e528 fix(core, sirsoft-ecommerce, sirsoft-basic): 배송국가 다국어 표시·입력 지원
$localized 가 ActionDispatcher 경로에서 항상 ko 를 반환했다. 컨텍스트에
$locale 이 없다는 것은 "로케일이 ko" 라는 뜻이 아니라 "이 경로가 로케일을
넘기지 않는다" 는 뜻인데, context.$locale || 'ko' 로 하드코딩 폴백하고 있었다.
바로 아래 $t 는 engine-v1.38.2 에서 이미 앱 설정 회수 폴백을 갖췄으나
$localized 만 정의 순서상 그 폴백보다 위에 있어 혜택을 받지 못했다.
로케일/templateId 해석을 두 헬퍼 앞으로 끌어올려 공유한다 (engine-v1.52.1).

국가 추가 폼은 이름 입력칸이 ko/en 두 칸으로 고정돼 그 외 언어의 국가명을
넣을 수 없었다. 설치된 언어를 $locales 로 순회해 입력칸을 만들고, 추가 조건도
특정 로케일 대신 "어느 한 언어라도 채워졌는가" 로 바꾼다. 백엔드
StoreEcommerceSettingsRequest 도 name 을 array 로만 요구하므로 계약이 일치한다.

저장 시 입력하지 않은 로케일은 남기지 않는다. 기존 저장본에 박힌 빈 문자열은
업그레이드 스텝이 청소한다 — 부재 로케일은 서버 localize_catalog_field 가
읽기 시점에 언어팩으로 보강하므로, 빈 문자열은 그 보강을 막는 어중간한 상태였다.

동일 결함 재발을 막는 audit 룰(engine-context-locale-hardcoded-fallback)을
함께 추가한다. 수정 전 코드에서 1건 검출, 수정 후 0건을 확인했다.
2026-07-10 19:27:26 +09:00
HeuJung 0f84657a5c fix(sirsoft-ecommerce): 주문상세 상품 목록 숫자 열 우측 정렬 통일
상품 목록의 숫자 6개 열 중 `구매수량`·`적립예정` 만 가운데 정렬이라 나머지 네 열과
세로 기준선이 어긋나 보였다. 두 열은 값 아래 배지를 쌓기 위해 `flex flex-col` 을
쓰는데 교차축 정렬이 `items-center` 로 남아 있던 것이 원인. `items-end` 로 바꿔
배지 세로 배치를 유지한 채 `text-right` 열과 같은 우측 기준선에 맞췄다.

공유 컴포넌트인 DataGrid 는 8개 이상 레이아웃이 사용하므로 손대지 않았다.

배포 후 실제 DOM 실측으로 확인: Quantity·Points 는 alignItems=flex-end,
Price·Discount·Actual Price·Subtotal 은 textAlign=right, 비숫자 열(상품정보·상태·
배송)은 정렬 대상에서 제외.

회귀 spec 추가 — `items-center` 는 text-align 이 아니라 align-items 로 정렬하므로
textAlign 만 단언하면 두 셀을 영영 못 잡는다. 콘텐츠의 우측 여백을 재고, 절대
임계값 대신 같은 행 안에서 열끼리 여백이 서로 같은지를 단언한다. 헤더 텍스트로
열을 찾지 않는다(E2E 세션 로케일이 en).

함께 수정 — 모듈 E2E 스위트가 수집 단계에서 전체 실패하고 있었다. 모듈 config 는
--config 로만 실행되어 여태 아무도 돌린 적이 없었다.

- 미정의 fixture 3종 추가(userToken/customerToken/ordersReadToken). Playwright 는
 describe.skip 블록도 수집 시점에 fixture 파라미터를 해석한다.
- mp06 spec 주석에서 `OrderCalculation*/` 의 `*/` 가 블록 주석을 조기 종료시켜
 SyntaxError 가 나던 문제 수정.

검증: 모듈 E2E 2 passed/63 skipped/0 failed, Vitest 2382 passed/5 failed
(clean HEAD 와 동일 — 선행 결함, 무회귀), audit 0 error.
2026-07-10 19:27:26 +09:00
HeuJung 0ff4b674ac fix(sirsoft-basic, sirsoft-admin_basic): 모바일 드로어 선호설정 아코디언화 및 뷰포트 개선
모바일 헤더 우측 그룹이 420px 를 요구해 폰 뷰포트에서 통화 선택기가 잘렸다.
언어·통화·배송국가를 드로어로 옮겨 가로 칩으로 통일했으나, 이번엔 드로어가
세로로 길어져 정작 메뉴가 스크롤 밖으로 밀렸다.

언어와 통화·배송국가를 각각 독립 아코디언(기본 접힘)으로 바꾸고, 접힘 상태에서도
트리거에 현재 선택값을 요약 표기해 펼치지 않고 확인할 수 있게 했다. 언어는 템플릿이,
통화·배송국가는 이커머스 주입 조각이 각자 상태를 소유해 서로 간섭하지 않는다.

통화 패널은 새 플래그를 만들지 않고 데스크톱 드롭다운의 기존 토글을 재사용한다 —
portable 오버라이드에서 if 를 생략하면 엔진이 base if 를 상속하므로 자동으로 기본
접힘이 된다. 이 과정에서 모바일로 펼칠 때 전체 화면 배경 오버레이가 드로어 클릭을
가로채는 결함을 함께 차단했다.

그 밖에 모달 좌우 여백, 주문 취소·일괄변경 팝업의 세로 스택 전환, 은행 관리 입력칸
축소, 쇼핑몰 환경설정·쿠폰·엑셀 다운로드 화면의 가로 넘침을 함께 해소했다.
2026-07-10 19:27:25 +09:00
HeuJung e3c00addd8 fix(sirsoft-basic): 모바일 뷰포트 가로 오버플로·압착 해소 및 확장 조각 audit 사각지대 수정
390px 실측 결과 26개 라우트 중 24개에서 문서가 뷰포트를 11px 넘겼고,
320px 에서는 햄버거 버튼이 화면 밖으로 밀려 내비게이션 자체가 불가능했다.
원인은 예외 없이 헤더 우측의 언어(71px)+통화(91px) 선택기였다.

이를 모바일 드로어로 옮기고, 좁은 화면에서는 세로 목록 대신 가로 칩으로
나열하도록 했다. 통화·배송국가는 이커머스 모듈이 슬롯에 주입하는 조각이므로
모듈 쪽에도 responsive.portable 오버라이드를 넣었다. 템플릿의 드로어 자리는
overflow-y-auto 라 옛 absolute 드롭다운이 잘리므로, 템플릿의 이커머스 모듈
의존 하한을 >=1.0.2 로 올렸다.

게시글 상단 네비는 성격이 다른 회귀였다. 게시판 이름이 길수록 flex 형제인
목록·이전글·다음글 버튼이 좁아지는데(87→73→62px), document.scrollWidth 는
내내 뷰포트와 같아 오버플로 검사로는 잡히지 않았다. 넘치는 게 아니라 줄어드는
회귀다. shrink-0 으로 버튼 폭을 고정하고, 모바일에서만 게시판 이름을 자기 줄로
올렸다. 버튼 3개 고정폭 261px + 21자 제목 323px = 584px < 태블릿 콘텐츠 735px
이므로 portable 이 아니라 mobile 스코프로 한정했다.

커밋 전 점검에서 audit 결함 두 건이 드러나 함께 고쳤다.

첫째, layout-* 룰 28개 중 27개가 appliesTo 에 확장 주입 조각 경로를 빠뜨려
조각 32개가 정적 검사에서 통째로 빠져 있었다. 실제 위반 60건이 은닉돼 있었고
그중 45건은 error 다. 다만 glob 만 넓히면 layout-grammar 의 root 화이트리스트가
조각의 정당한 키(target_layout/injections/extension_point/mode/priority)를 전부
오탐해 90건의 error 를 새로 만든다. 조각은 레이아웃이 아니라 별도 스키마이므로
extension-fragment 스키마를 신설해 오탐을 없애는 동시에 조각 고유의 결함(대상 키
누락으로 인한 silent drop, target_layout ↔ extension_point 동시 정의, 무동작 조각)을
새로 검출하게 했다.

둘째, Icon 의 w-N h-N 을 size prop 으로 바꾸라는 규정이 틀렸다. Icon 은 <i> 글리프라
박스 크기가 곧 font-size 인데, size prop 은 Font Awesome fa-* 로 매핑되어 상대값
0.875em + line-height 붕괴를 동반한다. 실측하면 w-4 h-4(16×16)가 fa-sm 에서
12.25×0.88 이 된다. 등가는 text-base 하나뿐이다. 앞선 작업에서 size:"sm" 으로
치환했던 2건을 되돌리고, 규정과 룰 메시지를 실측 등가표로 교체했다.
2026-07-10 19:27:25 +09:00
thisgun 0200f1d043 fix(nicepayments): 가상계좌 close-report 상태 보존
가상계좌 발급 후 입금대기 상태인 주문에 PC 결제창 닫힘 보고가 뒤늦게 도착해도 주문/결제 상태를 실패로 덮지 않도록 READY 상태만 실패 처리 대상으로 제한한다.
2026-07-09 12:53:16 +09:00
thisgun 5f99b104c1 Merge branch 'develop' of github-gnuboard:gnuboard/dev-g7 into thisgun/pay_develop 2026-07-09 12:52:08 +09:00
HeuJung 5f3b904aab docs: API 레퍼런스 진입점 정비 + README 데모 링크 추가
최상위 README 의 "API 레퍼런스" 가 문서 작성 규정(api-documentation.md)을
가리켜, 개발자·AI 가 실제 엔드포인트 레퍼런스에 도달할 경로가 없었다.
코어 목차(docs/backend/api/README.md)도 어느 인덱스에서도 참조되지 않았고,
확장 API 문서 9종 역시 공개 진입점이 없었다.

코어 목차를 코어+확장 통합 진입 문서로 승격하고, 상단에 공통 규약(인증·
응답 봉투·페이지네이션·에러)을 실측 근거로 서술했다. 확장 목차는 파일 시스템
패턴 스캔으로 생성해 확장명 하드코딩을 두지 않았고, --scope 를 좁혀 실행해도
개요와 확장 표가 소실되지 않도록 보존 경로를 분리했다.

아울러 설치 없이 동작을 확인할 수 있도록 README 상단에 데모 사이트와
관리자 데모 링크를 추가했다.
2026-07-09 10:24:17 +09:00
정정홍andGitHub 45bf59472a Merge pull request from gnuboard:HeuJung/issue456
fix(core): installer_completed fast-path가 빈 DB 배포 시 부팅을 깨뜨리는 문제 수정 및 7.0.3 릴리스
2026-07-09 09:15:05 +09:00
thisgun 2493bf424a fix(nicepayments): 부분취소 후 잔액 취소 판정 보정
이미 부분취소된 거래의 남은 잔액을 취소할 때도 나이스페이에 부분취소 코드가 전달되도록 보정합니다.

회귀 테스트와 나이스페이 플러그인 CHANGELOG를 함께 추가합니다.
2026-07-08 23:24:20 +09:00
HeuJung c26b49a997 fix(core): installer_completed fast-path가 빈 DB 배포 시 부팅을 깨뜨리는 문제 수정 및 7.0.3 릴리스
INSTALLER_COMPLETED=true 가 적힌 .env 를 빈 DB 새 서버에 복사한 뒤
php artisan migrate 로 테이블을 만들기 전에 앱이 부팅되면, installer_completed
fast-path 가 테이블 존재를 잘못 전제하여 뒤따르는 쿼리가 table not found 로
부팅을 깨뜨렸다. 마이그레이션을 실행하려는 부팅에서 그 명령이 오히려 실패하는
닭-달걀 상태가 된다.

fast-path 자체(매 요청 information_schema 쿼리 제거 성능 최적화)는 유지하되,
이미 존재하던 참조 구현(LanguagePackServiceProvider::isRegistryReady)의 방어
패턴을 InstallerContext::isSchemaMutatingCommand 로 SSoT 화하여 나머지 5곳에
통일 적용했다.

- app/Support/InstallerContext.php: 마이그레이션 계열 콘솔 명령 판정 헬퍼
 (migrate/migrate:fresh/migrate:refresh/migrate:rollback/migrate:reset/db:wipe)
- Caches{Module,Plugin,Template}Status: fast-path 앞에 && 가드
- {Module,Plugin}RouteServiceProvider: hasTable 검증 진입 조건에 || 가드로
 무방비 pluck 도달 차단
- LanguagePackServiceProvider: 인라인 argv 검사를 헬퍼로 치환(migrate:refresh 보강)

부류 B(NotificationHookListener, IdentityPolicyRepository)는 fast-path 이후
쿼리가 이미 try/catch 로 보호되어 빈 DB 에서도 안전하므로 수정 제외.

최초 설치 흐름은 migrate 시점에 완료 플래그가 없어(false) 이번 가드의 영향권
밖이며 원래대로 hasTable 폴백을 탄다. 가드가 실제 발동하는 유일 케이스는
"플래그 true + 빈 DB" 뿐이고, 정상 운영 웹 요청은 fast-path 성능이 유지된다.

회귀 테스트: InstallerContextTest 8건 + InstallerCompletedGuardTest 5건 추가
(수정 전 5건 fail → 수정 후 green). 관련 스위트 355 passed.

7.0.3 출시 버전 bump: 코어 버전 SSoT 7곳 + CHANGELOG 동기화.
2026-07-08 22:01:53 +09:00
정정홍andGitHub 13aeb433cc Merge pull request from gnuboard:HeuJung/issue447
HeuJung/issue447
2026-07-08 20:01:18 +09:00
HeuJung 168f439be3 feat(api-docs): 확장 API 레퍼런스 발견성 — README 목차 규약 + 동적 스캔 인덱스
확장 API 문서가 인덱스·목차 어디에도 노출되지 않아 처음 온 개발자/AI 가
발견할 수 없던 간극을 해소했다. 코어에 확장명을 하드코딩하지 않고(동적 로딩 원칙),
파일 시스템 패턴 스캔 + 확장 소유 목차 규약으로 발견성을 확보한다.

- ApiDocScaffolder::readmeIndex — 각 대상의 docs/api/README.md 목차(도메인 파일·
 엔드포인트 수)를 @generated 블록으로 생성(멱등, 사람 개요 보존). 커맨드가 대상별
 README 를 방출(readmeFile 경로 헬퍼). 코어 + 9 확장 = 10개 목차 생성.
- generate-docs-index.cjs scanExtensionApiReadmes — {modules,plugins}/_bundled/*/
 docs/api/README.md 를 패턴 스캔(확장명 하드코딩 0)해 ·AGENTS.md 에
 "확장 API 레퍼런스" 표로 자동 편입. 확장 추가/삭제 시 재생성만으로 반영.
- api-documentation.md 에 README 목차 규약 + 발견 경로 명문화. coverage.json 에
 api-doc-readme-index(manual-only) 등록. 루트 README API 레퍼런스 링크 갱신.

테스트: readmeIndex 목차 생성·멱등 2건 + 스캐너 편입·하드코딩부재 2건(node --test).
2026-07-08 18:22:35 +09:00
HeuJung 56a78e1d55 feat(api-docs): 요청/응답 예시 블록 + 8종 결함 근본 수정 + audit error 승격
API 레퍼런스에 요청 예시(raw HTTP)·응답 예시(envelope 전문)를 표준 포맷에
편입하고, 전 대상(코어+전 모듈+전 플러그인)에 방출했다. 예시 결함 8종
(multipart·Host 마스킹·write 실측·path 치환·현실적 바디·query 반영·중복
제거·민감값 마스킹)을 스캐폴더/프로브 공용 수정으로 근본 해소했다.

추가로 검증에서 드러난 결함을 마감했다:
- 응답 예시 body 의 절대 URL(페이지네이터·콜백) 호스트를 placeholder 로
 마스킹(maskResponseHost) — 요청 Host 헤더만 마스킹되던 누락 보완, 확장
 39건 정정
- api:docgen 커맨드 통합 테스트 신설(라우트 수집·확장 라우팅·rules 추출·
 멱등 재생성·check drift)
- module-routing.md URL 스킴 오류 정정(/api/admin → /api/modules)
- README·cheatsheet·api-documentation 에 API 문서 커맨드/레퍼런스 진입점 반영

전 대상 3칸 완료로 api-doc-coverage 룰을 warn→error 승격.
2026-07-08 18:22:34 +09:00
HeuJung 70452745c4 feat(api-docs): API 레퍼런스 문서 전면화 — 추출 파이프라인·전 대상 문서화·audit 강제
코드에만 존재하던 REST API 계약(약 663 엔드포인트)을 코어/확장 책임별로
분리된 마크다운 레퍼런스로 전면 문서화한다. 674개 규모에서 수기 문서는
반드시 drift 하므로 "코드 추출 → 스캐폴딩 → 사람이 서술 채움 → 하네스가
커버리지 강제" 하이브리드로 구성했다.

추출 파이프라인 (app/Support/ApiDoc):
- ApiRouteInventory / FormRequestIntrospector / ApiEndpointProbe(실측 HTTP) /
 ResponseSchemaInferrer / ApiDocScaffolder / ColumnCommentResolver /
 ResourceFieldDescriber / ParameterDescriber
- api:docgen 커맨드(--scope/--seed/--check/--dry-run/--base-url/--user) +
 응답/파라미터 in-place 백필 커맨드 2종(재생성 없이 TODO 셀만 치환, 멱등)
- ApiDocSampleSeeder 계약 + 코어/확장 시더로 실측용 완전 샘플 멱등 생성

문서화 (실측 기반, GET read-only 실호출):
- 코어 291엔드포인트 35파일(docs/backend/api) + 규정 docs/backend/api-documentation.md
- 확장: ecommerce(231)·board(80)·page(17)·hello_module(2)·pay_kginicis(22)·
 gdpr(15)·ckeditor5(2)·marketing(2)·verification_kginicis(1)
- 표준 4구성(헤더·요청 파라미터·응답 필드·에러 표) + 엔드포인트 용도 서술
- 파라미터 용도·응답 필드 설명 셀 전수 채움(도메인 지식 수기)

하네스:
- audit 룰 api-doc-coverage — API 표면(라우트/컨트롤러/FormRequest/Resource)
 변경 시 대응 문서 미동반이면 차단. 전 대상 문서 완비로 error 승격
- file-rules 리마인더(컨트롤러/라우트 편집 시), coverage.json, dev-dashboard 카드
- docs/backend/routing.md 확장 공개 API URL 스킴 정정(/api/modules|plugins/{id})
- /AGENTS.md/docs-index 동기
2026-07-08 18:22:33 +09:00
thisgun 79eb091351 docs(pay_kginicis): CHANGELOG [Unreleased] 항목을 [1.0.0] 으로 정리
미출시 1.0.0(공개 latest=beta.6) 이므로 [Unreleased] 의 테스트모드 경고·설정 오류 안내 항목을 [1.0.0] Fixed 로 흡수한다.
2026-07-08 16:39:46 +09:00
thisgun 848b408b60 Merge branch 'develop' of github-gnuboard:gnuboard/dev-g7 into thisgun/pay_develop 2026-07-08 16:23:21 +09:00
HeuJung 49b21e788c Merge branch 'develop' 2026-07-08 14:51:37 +09:00
HeuJung dbbc343734 chore(github): blank issue 생성 허용
이슈 템플릿 선택 없이 빈 이슈를 자유롭게 생성할 수 있도록
blank_issues_enabled 를 활성화한다.
2026-07-08 14:50:46 +09:00
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
thisgun 88116de788 fix(kginicis): 설정 저장 오류 안내 개선 2026-07-07 18:19:17 +09:00
thisgun c6cab1f668 fix(nicepayments): 주문설정 테스트결제 경고 표시 개선 2026-07-07 16:38:47 +09:00
thisgun f744b816b6 fix(nhnkcp): 주문설정 테스트결제 경고 표시 개선 2026-07-07 16:38:32 +09:00
thisgun b9355f051c fix(kginicis): 주문설정 테스트결제 경고 표시 개선 2026-07-07 16:38:15 +09:00
thisgun ff45a19b4b fix(payment): NHN KCP 부분취소 가능금액 전달값 수정 2026-07-07 14:11:57 +09:00
thisgun cebe352e46 fix(payment): NHN KCP close-report 경합 처리 보강 2026-07-07 14:11:16 +09:00
thisgun 48f877bdcd fix(payment): NHN KCP 관리자 결제 상태 표시 보강 2026-07-07 14:10:35 +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
thisgunandCodex e7e2936ea0 fix(payment): KG 이니시스 close-report 동시성 보강
PC 승인 콜백 처리 중 close-report가 동시에 도착해 결제완료 주문의 옵션을 취소로 덮는 race를 막기 위해 같은 주문 락을 공유하고 최신 주문 상태를 다시 확인합니다.

Co-Authored-By: Codex <codex@openai.com>
2026-07-06 14:38:58 +09:00
thisgunandCodex 8429d8ada5 fix(payment): KG 이니시스 결제 콜백 안정성 보강
KG 이니시스 결제에서 통화 스냅샷 이상 시 서버 오류로 새지 않도록 검증 실패/재통보 가능한 응답으로 처리하고, 주문 단위 콜백 락으로 동시 완료 콜백의 중복 후속 처리를 줄입니다.

백엔드 테스트의 통화 스냅샷 fixture도 최신 계약에 맞춰 복구합니다.

Co-Authored-By: Codex <codex@openai.com>
2026-07-06 13:46:35 +09:00
thisgun d18aaab1fd fix(payment): 나이스페이 SignData 방어와 비회원 영수증 조회 보강 2026-07-06 12:26:56 +09:00
thisgun 646efeb91c fix(payment): NHN KCP 비회원 영수증 조회 지원 2026-07-06 12:26:39 +09:00
thisgun dee5a12ded fix(payment): NHN KCP 다통화 결제 금액 검증 예외 처리 보강 2026-07-06 10:54:32 +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
thisgun c59819645a fix(payment): 나이스페이 인증 실패 재시도 상태 유지
인증 단계 실패는 PG 승인 전이므로 주문과 결제, 주문옵션을 취소 상태로 전환하지 않고 체크아웃에서 재시도할 수 있게 한다.
2026-07-04 12:48:53 +09:00
thisgun 1cdef4513a fix(payment): 나이스페이 다통화 결제 예외 처리 보강
환율 스냅샷이 누락되거나 0인 주문에서 SignData와 콜백 처리 중 통화 예외가 일반 승인 오류로 뭉개지지 않도록 실패 사유를 분리한다.
2026-07-04 11:56:59 +09:00
thisgun 5a9ad97327 fix(payment): 나이스페이 간편결제 표시 라벨 보강
나이스페이 간편결제 선택값을 콜백까지 보존하고 주문완료, 마이페이지, 관리자 주문목록/상세에서 실제 간편결제명을 표시한다.
2026-07-04 10:05:24 +09:00
thisgun b428734a06 fix(payment): NHN KCP 콜백 동시성 락 추가 2026-07-03 21:58:48 +09:00
thisgun 0e9bb39980 fix(payment): NHN KCP 간편결제 표시 라벨 보강 2026-07-03 19:54:51 +09:00
HeuJung 7c18adc09d Merge branch 'develop' 2026-07-03 19:16:37 +09:00
HeuJung a10200164c fix(core): 코어 업데이트 스텝 재실행 안내에 실행 권한별 4분기 지침 추가
sudo(root) 로 core:update 를 실행하면 업그레이드 스텝 spawn 자식이 실패할 때
운영자에게 core:execute-upgrade-steps 재실행을 안내하는데, 이 명령을 root 로
그대로 재실행하면 스텝이 만드는 파일이 root 소유가 되어 이후 웹서버(www-data)
요청이 그 경로에 쓰기 실패한다.

재실행 안내를 실행 환경 4가지로 분기한다:
- non_root: 일반 SSH 사용자 / 공유 호스팅(웹서버=PHP=실행 유저) / posix 미지원 → 명령만
- root_web_known: 웹서버 계정 식별 → sudo -u {계정} + 계정명 경고
- root_web_symmetric: root 서비스 구성 → 명령만
- root_web_unknown: 계정 추정 실패 → placeholder + 확인 방법 안내

웹서버 계정은 FilePermissionHelper::inferWebServerOwnership 로 추정한다.
출력 전담 renderResumeGuidance 를 분리해 4분기 출력 계약을 회귀 테스트로 고정.
2026-07-03 19:16:11 +09:00
thisgun 2a9f521487 fix(payment): NHN KCP 간편결제 메타와 중복 콜백 처리 보강 2026-07-03 19:03:54 +09:00
HeuJung a681908d54 Merge branch 'develop' 2026-07-03 18:45:59 +09:00
HeuJung 710f8e9135 fix(extension): 확장 토글 시 config 캐시 재생성을 동기 실행 (큐 지연 제거)
ExtensionConfigCacheListener 가 config 캐시 재생성을 큐로 디스패치해,
워커 미가동 환경·CLI 커맨드(plugin:deactivate 등)에서 재생성이 지연/누락되던
결함을 수정한다. 훅 Action 리스너의 기본 등록 정책이 큐 디스패치이므로,
getSubscribedHooks 각 훅에 'sync' => true 를 추가해 동기 실행한다.

- 회귀 테스트 test_all_hooks_are_synchronous 추가
- docs/backend/service-provider.md 에 sync 요구사항 명문화
- 2브라우저(Chrome MCP + Playwright MCP) 회귀 검증 중 발견
2026-07-03 18:40:19 +09:00
thisgun 76f7300e8f test(payment): NHN KCP 결제완료 부작용 통합 테스트 추가
NHN KCP 콜백 성공 후 코어 주문 완료 흐름까지 실행해 재고, 쿠폰, 마일리지, 장바구니 정합성을 자동 검증합니다.
2026-07-03 18:18:24 +09:00
thisgun 022d629144 test(payment): NHN KCP 테스트 주문 통화 스냅샷 보정
NHN KCP 결제 콜백/재시도/창닫기/모바일 승인 테스트가 현재 주문 금액 계산 로직의 KRW 통화 스냅샷 계약을 따르도록 보정합니다.
2026-07-03 17:55:12 +09:00
HeuJung c08dae7cb0 perf(core): 서빙 API 부팅 비용 최적화 (information_schema 가드 + 훅/오토로드/config 캐시)
매 요청 균일하게 발생하던 부팅 오버헤드를 4개 축에서 제거한다.

- 설치 완료 상태에서 매 요청 반복되던 information_schema 조회(알림·본인인증
 테이블 hasTable)를 installer_completed 가드로 스킵. 미설치 환경은 기존
 hasTable 폴백 유지.
- 코어+모듈+플러그인 정적 훅 매핑을 bootstrap/cache/hooks.php 에 사전 계산해
 매 요청 디렉토리 스캔·리플렉션·클래스 로딩을 제거(route:cache 동형).
 등록↔발화 계약·매핑 바이트 동일. 확장/코어 변경 시 자동 재생성, 캐시
 부재·손상은 스캔 폴백.
- 확장 소스(Modules\*/Plugins\*)를 autoload-extensions.php 의 classmap 에
 편입해 findFile 파일시스템 스캔을 제거(느린 FS·cold OPcache 환경 직격).
 클래스 로딩은 여전히 lazy, PSR-4 폴백 유지.
- config 캐시를 변경 지점(설정 저장/코어·확장 업데이트/APP_KEY 재생성/설치
 완료)에서 clear 후 즉시 재생성하도록 ConfigCacheHelper 로 일원화. clear 만
 하고 방치돼 캐시가 영구 비활성으로 남던 성능 손실 제거. HookCacheManager::read
 요청당 1회 로드(memo)로 중복 파싱 제거.

부팅 매핑 fingerprint 스캔↔캐시 완전 동일(229 action + 35 filter). 신규/수정
8개 스위트 54 pass. 순수 내부 부팅 인프라 — 확장 공개 표면·훅 계약 불변.
2026-07-03 17:47:23 +09:00
thisgun f0d44989e1 Merge branch 'develop' of github-gnuboard:gnuboard/dev-g7 into thisgun/pay_develop 2026-07-03 15:10:45 +09:00
thisgun 9863ea9d3a fix(payment): KG이니시스 간편결제 설정 노출 복구
신규 설치 사이트에서도 이커머스 주문설정에서 KG 이니시스 국내 간편결제 수단을 켤 수 있도록 배포 원본의 등록 조건을 정리합니다.
2026-07-03 11:51:12 +09:00
HeuJung deacbc7b66 Merge branch 'develop' 2026-07-03 10:03:19 +09:00
HeuJung 239bdffa10 docs(release): 7.0.1 출시일 7/3 조정 + 엔진 CHANGELOG 버전 병합·정합 정리
7.0.1 릴리즈 산출물 날짜를 2026-07-03 으로 조정하고, 엔진
CHANGELOG 의 engine-v1.52.0(DevTools 번들 분리) 과 engine-v1.51.0
(레이아웃 편집기 번들 분리) 을 단일 engine-v1.51.0 섹션으로 병합.

병합에 따라 DevTools 관련 코드 @since 를 v1.51.0 으로 정정하고,
그 과정에서 발견한 CHANGELOG 부재 유령 버전(v1.52.0/v1.52.2/
v1.52.4) 을 CHANGELOG 정본(engine-v1.50.0) 에 맞춰 정정.

주석/CHANGELOG 문자열 정정만으로 런타임 로직 변경 없음.
2026-07-03 10:02:38 +09:00
thisgun 479d537dfb Merge branch 'develop' of github-gnuboard:gnuboard/dev-g7 into thisgun/pay_develop 2026-07-02 20:11:44 +09:00
thisgun 657f546efc chore(payment): NHN KCP와 NICE 결제 플러그인 개발 브랜치 분리
정식 develop에서는 기본 번들 정책에 따라 제거된 결제 플러그인을 별도 개발 브랜치에서 계속 유지보수할 수 있도록 복원한다.
2026-07-02 20:03:22 +09:00
HeuJung 4371f0c90e Merge branch 'develop' 2026-07-02 18:03:37 +09:00
HeuJung 52ee8c855a chore(release): 공개 소스 본문 내부 단서(· 호칭) 정리
공개 release 발행 시 파일 본문 누출 검사(check-public-release-leakage)에
걸리던 내부 단서를 정리해 발행을 차단하지 않도록 함.

- 코드/테스트 주석·문서 본문의 "" 규정 인용을 "코어 규정/규칙" 등
 중립 표현으로 치환 (코어·이커머스·board·kginicis·gdpr·pay·템플릿 전 영역)
- 내부 역할 호칭 ""/""를 문맥에 맞는 중립 표현으로 일반화
- config/app.php 의 보호경로 목록(G7_UPDATE_PROTECTED_PATHS) 내 파일명은
 기능 코드이므로 삭제 대신 검사기 화이트리스트에 등록

주석/설명 문자열/문서 본문만 변경 — 로직·데이터·동작 무변경.
검증: 누출 재검사 error 0 / warn 0, 영향 테스트 TS 140/140·PHP 113/113 통과.
2026-07-02 17:58:33 +09:00
HeuJung 61608ca341 Merge branch 'develop' 2026-07-02 17:32:32 +09:00
HeuJung eb2b35ef36 chore(release): 7.0.1 출시일 오늘로 조정 + 공개 소스 내부 단서 정리
- 출시일: CHANGELOG [7.0.1] 및 엔진 CHANGELOG(v1.52.0/v1.51.0) 를 2026-07-02(오늘)로 조정
- 공개 release 포함 소스의 내부 이슈번호 정리:
 - 공개 이슈는 gnuboard/g7 명시 형식으로 정규화
 - 내부 이슈번호는 서술문으로 일반화
- 코드 주석의 내부 역할 호칭("")을 중립 표현으로 일반화
2026-07-02 17:19:23 +09:00
정정홍andGitHub 56d5200fcc Merge pull request from gnuboard:HeuJung/issue435
HeuJung/issue435
2026-07-02 15:14:53 +09:00
HeuJung ce8055e65c fix(core): 시스템 정보 조회가 일부 호스팅에서 전체 실패하던 문제 수정
disable_functions/open_basedir 제약 호스팅에서 CPU·메모리·디스크 등
개별 probe 실패가 system-info API 전체를 500 으로 떨구던 결함 수정.
buildSystemInfo 각 항목을 safeSystemProbe 로 격리해 실패 항목만 unknown
폴백으로 채우고 나머지는 정상 반환. 개별 실패는 Log::warning, 전체 실패는
컨트롤러 Log::error 로 기록. ( 대시보드 격리가 settings 에 미적용되어
재발한 공개 대응)

동반: SettingsController::restore 를 RestoreSettingsRequest 로 이전
(base Request + 컨트롤러 내 검증 제거), 미정의였던 backup_path_required·
restore_* lang 키 정의(ko/en/ja).
2026-07-02 15:11:59 +09:00
정정홍andGitHub 802e99bbcc Merge pull request from gnuboard:HeuJung/issue432
fix(core): 코어 업데이트가 특정 상황에서 중단되던 두 결함 수정
2026-07-02 12:43:17 +09:00
HeuJung 27901dd254 fix(core): 코어 업데이트가 특정 상황에서 중단되던 두 결함 수정
Windows JUNCTION 복사 실패:
- public/storage 는 storage:link 가 Windows 에서 생성하는 JUNCTION 으로,
 PHP is_link/is_dir 이 모두 false 를 반환해 copyDirectory 가 파일로
 오판 → copy(디렉토리) 예외로 코어 업데이트가 중단됐다.
- isReparsePoint 로 junction 을 정확 감지(link/file/dir 모두 false +
 readlink 성공)하고, copyLink 가 symlink 실패 시 mklink /J 로 폴백 복원.
 removeOrphanItems 도 junction orphan 을 rmdir 로 링크만 제거해 target
 재귀 삭제 사고를 차단.

실행할 스텝이 없는 업데이트를 실패로 오판:
- spawn 자식이 exit=0 + step 0건으로 종료하면 부모 가드가 from<to 만으로
 fail-fast 를 발동해, 스텝이 필요 없는 패치 릴리즈(예: 7.0.0→7.0.1)를
 실패로 처리하고 확장 업데이트 단계까지 도달하지 못했다.
- [STEPS_EXECUTED] 신호에 discovered(범위 내 발견된 스텝 파일 수)를 추가하고,
 handleSpawnExit 가 executed=0 && discovered=0 은 정상 통과, discovered>0
 일 때만 fail-fast 하도록 구분. 구버전 자식(discovered 부재)은 레거시 판정 유지.
 정상 통과 시 확장 일괄 업데이트 단계까지 진행된다.

테스트: FilePermissionHelperSymlinkTest / CoreUpdateCommandSpawnFailureTest /
CoreUpdateCommandHandoffTest / MultiVersionUpgradePathTest 전부 green.
2026-07-02 12:30:23 +09:00
정정홍andGitHub 68d2a68062 Merge pull request from gnuboard:HeuJung/issue429
perf(core): 코어 번들 3분할로 초기 로딩 최적화
2026-07-02 09:21:12 +09:00
HeuJung 32251b2a1d perf(core): 코어 번들 3분할로 초기 로딩 최적화
레이아웃 편집기·개발자 진단도구를 코어 번들에서 분리해 각각
편집 화면 진입 시·디버그 모드 시에만 지연 로드하도록 변경.
초기 접속 gzip payload 458KB → 221KB (52% 감소).

lazy 번들은 window.G7Core.__runtime 으로 코어 런타임을 공유해
React/컨텍스트/싱글톤 단일 인스턴스를 보장(재번들 0중복).
코어 7.0.1 bump.
2026-07-01 17:05:15 +09:00
880 changed files with 140996 additions and 10877 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.0
APP_VERSION=7.0.4
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.0
APP_VERSION=7.0.4
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: true
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/ 의 관련 로그
- 브라우저 콘솔 에러
-->
+10
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
@@ -133,3 +136,10 @@ playwright-report/
test-results/
# Playwright MCP 세션 아티팩트(스냅샷/콘솔 로그) — 커밋 대상 아님
.playwright-mcp/
# ===== 소스맵 =====
# 소스맵은 원본 코드 전문(sourcesContent)을 담고 있어 배포 대상이 아니다.
# 로컬 개발 빌드에서는 생성되지만(디버깅용) 저장소에는 유입되지 않는다.
# 배포용 빌드는 `--production` 이 G7_BUILD_SOURCEMAP=0 을 주입해 생성 자체를 막는다.
# 경로 한정 시 새 확장이 추가될 때 조용히 누락되므로 전역 규칙으로 둔다.
*.map
+66 -4
View File
@@ -6,13 +6,14 @@
<!-- AUTO-GENERATED-START: docs-quick-reference -->
### 백엔드 [backend/](docs/backend/) (31개)
### 백엔드 [backend/](docs/backend/) (32개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
| [activity-log-hooks.md](docs/backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 |
| [activity-log.md](docs/backend/activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel('activity... |
| [admin-settings-access.md](docs/backend/admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) | 동기화 SSoT: storage/app/settings/*.json → SettingsServicePr... |
| [api-documentation.md](docs/backend/api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 + 요청·응답 예시 ... |
| [api-resources.md](docs/backend/api-resources.md) | API 리소스 | Resource: BaseApiResource 상속 필수 / Collection: BaseApiColl... |
| [authentication.md](docs/backend/authentication.md) | 인증 및 세션 처리 | Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용) |
| [broadcasting.md](docs/backend/broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) |
@@ -116,7 +117,7 @@
| [module-i18n.md](docs/extension/module-i18n.md) | 모듈 다국어 시스템 | 백엔드: /lang/{locale}/*.php → __('vendor-module::key') |
| [module-identity-settings.md](docs/extension/module-identity-settings.md) | 모듈/플러그인 본인인증(IDV) 설정 통합 가이드 | 정책/목적/메시지: module.php::getIdentity{Policies,Purposes,Mess... |
| [module-layouts.md](docs/extension/module-layouts.md) | 모듈 레이아웃 시스템 | 위치: modules/_bundled/vendor-module/resources/layouts/admi... |
| [module-routing.md](docs/extension/module-routing.md) | 모듈 라우트 규칙 | URL prefix 자동: /api/admin/[vendor-module]/... |
| [module-routing.md](docs/extension/module-routing.md) | 모듈 라우트 규칙 | URL prefix 자동: /api/modules/[vendor-module]/... |
| [module-settings.md](docs/extension/module-settings.md) | 모듈 환경설정 시스템 개발 가이드 | - |
| [permissions.md](docs/extension/permissions.md) | 권한 시스템 | 구조: User → Role → Permission (기능 레벨) |
| [plugin-development.md](docs/extension/plugin-development.md) | 플러그인 개발 가이드 | 디렉토리: plugins/vendor-plugin (예: sirsoft-payment) |
@@ -143,6 +144,32 @@
| [e2e-testing.md](docs/testing/e2e-testing.md) | 그누보드7 Playwright E2E 테스트 가이드 | - |
### API 레퍼런스 진입점
> 엔드포인트별 요청 파라미터·응답 필드·요청/응답 예시. 공통 규약(Bearer 토큰 인증, 응답 봉투, 페이지네이션, 401/403/422/428)은 진입점 문서 상단에 정리되어 있다.
| 대상 | 진입점 | 문서/엔드포인트 |
|------|--------|----------------|
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 35 / 291 |
### 확장 API 레퍼런스 (9개 확장, 자동 스캔)
> 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 |
|------|------|--------------|----------------|
| `gnuboard7-hello_module` | 모듈 | [docs/api/](modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 2 |
| `sirsoft-board` | 모듈 | [docs/api/](modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 |
| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 231 |
| `sirsoft-page` | 모듈 | [docs/api/](modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 |
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
| `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 |
| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 |
<!-- AUTO-GENERATED-END: docs-quick-reference -->
---
@@ -221,11 +248,22 @@
| 금지 | 올바른 사용 |
|------|------------|
| `Icon className="w-4 h-4"` | `Icon size="sm"` 또는 `className="text-sm"` |
| `Icon className="w-4 h-4"` | `className="text-base"` (아래 등가표) 또는 `size` prop |
| `Select valueKey/labelKey` | computed로 `{ value, label }` 변환 |
| Form 내 `Button` type 없음 | `type="button"` 명시 (submit 방지) |
| `options={{options}}` | `options={{options ?? []}}` (fallback) |
Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 박스만 정하고 글리프는 부모 `font-size` 를 상속하므로 어긋난다. 기존 `w-N h-N` 을 옮길 때는 아래 등가표를 쓴다 (Chrome 실측).
| `w-N h-N` | px | 등가 `className` |
|---|---|---|
| `w-3 h-3` | 12 | `text-xs` |
| `w-3.5 h-3.5` | 14 | `text-sm` |
| `w-4 h-4` | 16 | `text-base` |
| `w-5 h-5` | 20 | `text-xl` |
`size` prop 은 Font Awesome `fa-*` 클래스로 매핑되며 등가가 아니다 — `size="sm"` → `fa-sm` → `font-size: 0.875em`(상대값) + `line-height` 붕괴로 16px 이 12.25×0.88px 이 된다. 새 아이콘에는 써도 되지만, 기존 `w-N h-N` 의 치환용으로는 쓰지 않는다.
### 상태 관리
| 금지 | 올바른 사용 |
@@ -776,6 +814,30 @@ php artisan module:update sirsoft-ecommerce --force
php artisan plugin:update sirsoft-payment --force
```
### 코어 3-번들 구조 + 공유 런타임 (engine-v1.51.0+)
`core:build` 는 코어 프론트엔드를 3개 IIFE 번들로 빌드한다:
| 번들 | 로드 시점 | vite config |
|------|----------|-------------|
| `template-engine.min.js` | 모든 페이지 (동기 `<script>`) | `vite.config.core.js` |
| `layout-editor.min.js` | `/admin/layout-editor/*` 진입 시 런타임 주입 | `vite.config.editor.js` |
| `devtools.min.js` | 디버그 모드에서만 런타임 주입 | `vite.config.devtools.js` |
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
@@ -890,7 +952,7 @@ php artisan migrate:rollback
| 수정 대상 파일 패턴 | 작업 전 필수 참조 |
| ------------------- | ------------------ |
| `app/Http/Controllers/**` | [controllers.md](docs/backend/controllers.md) |
| `app/Http/Controllers/**` | [controllers.md](docs/backend/controllers.md), [api-documentation.md](docs/backend/api-documentation.md) |
| `app/Services/**` | [service-repository.md](docs/backend/service-repository.md) |
| `app/Http/Requests/**` | [validation.md](docs/backend/validation.md) |
| `app/Repositories/**` | [service-repository.md](docs/backend/service-repository.md) |
+65
View File
@@ -4,6 +4,71 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.4] - 2026-07-14
### Security
- 서버가 외부 주소를 대신 호출하는 기능들에서, 사내 서버나 클라우드 관리 주소처럼 외부에 공개되지 않은 내부 주소를 호출 대상으로 지정할 수 없도록 차단했습니다. 언어팩을 주소로 내려받을 때, 예약 작업으로 주소를 호출할 때, 쇼핑몰 배송비를 외부 계산 서버로 문의할 때가 해당합니다. 사내 서버를 주기적으로 호출하는 등 내부 주소가 꼭 필요한 운영 환경을 위해, 환경설정 > 보안에 "내부 네트워크 주소 호출 허용" 항목을 새로 추가했습니다. 이 항목은 기본적으로 꺼져 있습니다.
- 주소를 검사할 때 앞부분만 맞으면 통과시키던 방식을 실제 주소가 정확히 일치하는지 확인하도록 바로잡았습니다. 이전에는 신뢰하는 주소를 앞에 붙인 가짜 주소가 검사를 통과할 수 있었습니다. 본인인증 연동과 언어팩 업데이트 확인에 적용됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1546)
- 배포본에 원본 소스코드가 그대로 담긴 파일(소스맵)이 함께 포함되어, 누구나 주소만 알면 내려받아 볼 수 있던 문제를 차단했습니다. 이제 배포용으로 빌드하면 이 파일이 아예 만들어지지 않으며, 기존에 포함되어 있던 파일도 모두 제거했습니다. 아울러 이 파일이 서버에 남아 있더라도 웹으로는 내려받을 수 없도록 막았습니다. 내 컴퓨터에서 개발용으로 빌드할 때는 디버깅에 필요하므로 이전과 동일하게 생성됩니다. (#72 @glitter-gim 님께서 건의해주셨습니다.)
- 아직 공개하지 않은 예약 작업 기능에서, 서버 명령을 실행하는 두 유형(쉘 명령·Artisan 명령)에 대해 향후 정식 공개에 대비한 실행 제한 장치를 미리 마련했습니다. 쉘 명령 예약은 기본적으로 완전히 차단되며 서버 관리자가 실행 파일을 명시적으로 등록해야만 사용할 수 있고(관리자 화면에서는 넓힐 수 없습니다), Artisan 명령 예약도 데이터를 지우거나 임의 코드를 실행할 수 있는 위험한 명령은 거부됩니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1567)
### Fixed
- 설치 과정에서 임시로 만들어지는 파일에 관리자 비밀번호가 그대로 남을 수 있던 문제를 수정했습니다. 설치가 정상적으로 끝나면 이 파일은 지워지지만, 설치를 중간에 그만두거나 마지막 정리가 이뤄지지 않은 경우에는 남아 있을 수 있었습니다. 이제 비밀번호는 이 파일에 아예 기록되지 않으며, 설치에 꼭 필요한 동안에만 접근이 제한된 별도 파일에 보관했다가 관리자 계정이 만들어지는 즉시 지워집니다. 이미 설치를 마친 사이트도 업데이트하면 남아 있던 파일이 자동으로 정리됩니다. (sir.kr 커뮤니티의 코ppp 님께서 제보해주셨습니다.)
- 화면 편집기를 작은 화면에서 열면 상단 도구 모음의 버튼 글자가 세로로 쪼개지는 등 화면이 찌그러지던 문제를 수정했습니다. 화면 편집기는 왼쪽 화면 목록과 미리보기 화면을 나란히 놓고 쓰는 도구라 넓은 화면이 필요합니다. 이제 창이 좁아도 편집기를 억지로 우겨넣지 않고 원래 크기를 유지하며, 모자란 만큼은 가로 스크롤로 넘겨 보면 됩니다.
- 모바일에서 새로고침을 연속으로 눌렀을 때 간헐적으로 "초기화 실패" 또는 "페이지 로딩 실패" 화면이 뜨던 문제를 수정했습니다. 화면을 그리는 데 필요한 요청 하나가 회선 사정으로 잠깐 끊기면 그것만으로 페이지 전체가 뜨지 않았습니다. 이제 이런 일시적인 끊김은 자동으로 다시 시도해 복구하므로, 대부분의 경우 사용자는 아무것도 느끼지 못한 채 화면이 정상적으로 열립니다.
- 화면 구성에 필요한 스크립트를 끝내 불러오지 못한 경우, 아무 안내 없이 빈 화면만 남거나 영문 오류 문구가 그대로 노출되던 문제를 수정했습니다. 이제 무슨 일이 있었는지 알려주는 안내와 새로고침 버튼이 표시됩니다. 관리자 화면에도 동일하게 적용됩니다.
- 쇼핑몰 등 확장 기능을 불러오지 못한 상태에서 화면이 5초가량 빈 채로 멈춰 있다가 뜨던 문제를 수정했습니다. 이제 지체 없이 화면이 열리고, 불러오지 못한 확장 기능만 조용히 빠집니다.
- 외부 라이브러리를 쓰는 확장의 버전을 올린 뒤 그 확장을 업데이트하면 "번들 파일의 무결성 검증에 실패했습니다"라는 오류로 업데이트가 막히던 문제를 수정했습니다. 오류를 풀려면 업데이트가 필요한데 그 업데이트 자체가 막혀 빠져나올 수 없는 상태였습니다. 아울러 확장 버전만 올린 경우에도 번들 재빌드가 필요함을 `--check` 점검이 올바르게 알려주도록 바로잡았습니다. (이미 배포된 확장을 내려받아 쓰는 환경에는 영향이 없으며, 확장을 직접 개발·수정하는 경우에 해당합니다.)
- Windows 개발 환경에서 코어 의존성 번들을 다시 만들 때, 마지막 정리 단계에서 진행이 멈춘 채 끝나지 않던 문제를 수정했습니다. 이제 번들 빌드가 정상적으로 완료됩니다. 아울러 빌드가 중간에 실패하더라도 기존 번들 파일이 손상되지 않도록 새 결과가 완성된 뒤에만 교체하도록 개선했습니다. (확장을 직접 개발·수정하는 환경에 해당하며, 배포본을 내려받아 쓰는 환경에는 영향이 없습니다.)
- 확장에 외부 라이브러리를 새로 추가한 뒤 번들을 만들면, 새로 추가한 라이브러리가 번들에 담기지 않은 채로 만들어지던 문제를 수정했습니다. 검증은 통과하지만 실제 파일이 빠져 있어 설치 후 실행 중에 오류가 나던 상태였습니다. 이제 새로 추가한 라이브러리가 번들에 정상적으로 포함됩니다. (확장을 직접 개발·수정하는 환경에 해당합니다.)
## [7.0.3] - 2026-07-10
### Fixed
- 화면 구성 파일이 목록을 만들어 내는 과정에서 다국어 이름을 골라 쓸 때, 사이트를 어떤 언어로 보고 있든 항상 한국어 이름이 선택되던 문제를 수정했습니다. 이제 보고 있는 언어의 이름이 표시됩니다. 헤더의 배송국가 선택처럼 화면이 처음 열릴 때 목록을 만들어 두는 곳이 여기에 해당합니다.
- 메뉴를 눌러 화면을 이동했을 때, 필요한 정보를 여러 갈래로 나눠 받아오는 화면에서 먼저 도착한 정보가 뒤늦게 도착한 정보에 덮여 사라지는 경우가 있었습니다. 그 탓에 목록이 있어야 할 자리가 간헐적으로 비어 보이고 새로고침하면 정상으로 돌아오곤 했습니다. 이제 어느 정보가 먼저 도착하든 모두 반영됩니다. 이커머스 환경설정의 배송설정 탭에서 배송가능 국가 목록이 비어 보이던 것이 여기에 해당합니다.
- 이미 설치를 마친 환경의 설정 파일(`.env`)을 새 서버로 복사해, 데이터베이스가 아직 비어 있는 상태에서 설치를 이어가려 할 때 `php artisan migrate`로 테이블을 만들기도 전에 부팅 단계에서 오류가 나며 중단되던 문제를 수정했습니다. 이제 데이터베이스 작업 명령이 실행되는 동안에는 테이블 존재 여부를 실제로 확인하므로, 기존 서버의 설정을 재사용하는 신규 배포에서도 설치가 정상적으로 진행됩니다. (#41 @GyusoonKim 님께서 제보해주셨습니다.)
## [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
- 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다.
- 설치가 끝난 사이트에서 매 요청마다 반복되던 불필요한 데이터베이스 조회를 줄여 페이지 응답 속도를 개선했습니다. 알림·본인인증 설정을 준비하는 과정에서 이미 존재가 보장된 테이블을 매번 다시 확인하던 동작을 설치 완료 상태에서는 건너뛰도록 했습니다.
- 화면을 그릴 때마다 서버가 매번 확장 기능의 연결 정보를 처음부터 다시 계산하던 부담을 줄여 페이지 응답 속도를 개선했습니다. 이 연결 정보는 확장을 설치·수정하거나 코어를 업데이트할 때만 바뀌므로 그 시점에 한 번 저장해 두고, 이후 요청에서는 저장된 값을 재사용하도록 했습니다. 확장이나 코어를 변경하면 저장 값이 자동으로 갱신됩니다.
- 설치된 모듈·플러그인의 코드를 찾을 때마다 서버가 파일을 하나하나 탐색하던 부담을 줄여, 특히 공유 호스팅 등 파일 접근이 느린 환경에서 페이지 응답 속도를 개선했습니다. 확장 코드의 위치를 설치·수정 시점에 미리 정리해 두고 이후 요청에서는 그 목록을 바로 참조하도록 했습니다. 확장을 설치·수정·삭제하거나 코어를 업데이트하면 목록이 자동으로 갱신됩니다.
- 코어 업데이트 중 데이터 변환 단계가 현재 환경에서 자동으로 끝까지 실행되지 못하고 중단될 때, 남은 단계를 이어서 실행하는 안내를 실행 권한에 맞게 개선했습니다. 특히 관리자 권한(sudo)으로 업데이트한 경우, 남은 단계를 관리자 권한 그대로 실행하면 그 과정에서 만들어지는 파일이 관리자 소유로 남아 이후 웹사이트가 그 파일에 쓰지 못하는 문제가 생길 수 있습니다. 이제 이런 경우 웹 서버 실행 계정(예: www-data)으로 실행하도록 정확한 명령을 함께 안내하며, 웹 서버 계정을 자동으로 확인하지 못하면 어떤 계정으로 실행해야 하는지 확인 방법을 안내합니다.
### Fixed
- Windows 환경에서 코어 업데이트가 `public/storage` 링크를 복사하는 단계에서 "디렉토리는 복사할 수 없습니다" 오류로 중단되던 문제를 수정했습니다. Windows의 저장소 링크(정션)가 이제 링크 그대로 안전하게 보존되어 업데이트가 정상 완료됩니다.
- 별도 데이터 변환 작업이 필요 없는 버전으로 코어를 업데이트할 때, "실행할 업그레이드 단계가 없다"는 정상 상황을 실패로 표시하고 중단하던 문제를 수정했습니다. 이제 변환 작업이 없는 업데이트는 매끄럽게 완료되며, 이어서 설치된 확장의 최신 번들 반영 단계까지 정상적으로 진행됩니다.
- 일부 호스팅 환경에서 관리자 환경설정의 보안 화면에 들어갈 때 시스템 정보 조회가 전체 실패(오류 응답)하던 문제를 수정했습니다. 이제 CPU·메모리·디스크 등 특정 항목을 서버 정책상 읽을 수 없더라도 그 항목만 "알 수 없음"으로 표시하고 나머지 시스템 정보는 정상적으로 보여줍니다. 읽지 못한 항목은 로그로 원인을 확인할 수 있습니다. (#59 @glitter-gim 님께서 제보해주셨습니다.)
## [7.0.0] - 2026-07-01
### Added
+1 -1
View File
@@ -246,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.0 g7
# (필요 시) mv g7-7.0.4 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
+8 -2
View File
@@ -8,7 +8,7 @@
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.4-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>
@@ -16,6 +16,11 @@
<a href="#"><img src="https://img.shields.io/badge/status-Stable-brightgreen" alt="Status"></a>
</p>
<p align="center">
<a href="https://g7.gnuboard.com"><strong>데모 사이트</strong></a> ·
<a href="https://g7.gnuboard.com/admin"><strong>관리자 데모</strong></a>
</p>
---
[소개](#그누보드7-소개) · [주요 기능](#주요-기능) · [기술 스택](#기술-스택) · [아키텍처](#아키텍처) · [빠른 시작](#빠른-시작) · [기본 제공 확장](#기본-제공-확장) · [비즈니스 모델](#비즈니스-모델) · [기존 사용자](#기존-그누보드-사용자) · [문서](#문서) · [기여하기](#기여하기) · [만든 사람들](#만든-사람들) · [커뮤니티](#커뮤니티) · [변경 기록](#변경-기록) · [라이선스](#라이선스)
@@ -462,7 +467,8 @@ cp .env.example .env
| 플러그인 개발 | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) |
| 템플릿 개발 | [docs/extension/template-basics.md](docs/extension/template-basics.md) |
| 테스트 | [docs/testing-guide.md](docs/testing-guide.md) |
| API 레퍼런스 | 준비 중 |
| API 레퍼런스 | [docs/backend/api/README.md](docs/backend/api/README.md) |
| API 문서 작성 규정 | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) |
---
@@ -0,0 +1,167 @@
<?php
namespace App\Console\Commands;
use App\Support\ApiDoc\ResourceFieldDescriber;
use Illuminate\Console\Command;
use Symfony\Component\Finder\Finder;
/**
* 기존 API 문서의 응답 필드 설명 TODO 를 리소스 계약 사전 설명으로 소급 채웁니다.
*
* 파라미터 TODO 는 `api:docgen-backfill-params` 가 채우지만, 응답 필드 TODO 는 전용
* 백필 커맨드가 없어 재생성(`api:docgen`)으로만 갱신되었습니다. 실측 서버 없이
* 재생성하면 이미 실측된 응답 예시값이 퇴행하므로, 이 커맨드는 재생성 대신 응답 필드
* 표의 `<!-- TODO: 설명 -->` 셀만 in-place 치환합니다.
*
* 채움 규칙 SSoT 는 ApiDocScaffolder::responseFieldTable 과 동일한 ResourceFieldDescriber
* 입니다 — 이후 정상 재생성(실측 서버 가동 시)도 같은 설명을 산출하므로 멱등합니다.
*
* 도메인 특이 필드(ResourceFieldDescriber 가 null 반환)는 TODO 를 그대로 둡니다.
* 응답 필드 표만 대상으로 하며, 파라미터 표(`<!-- TODO: 용도 -->`)는 건드리지 않습니다.
*/
class ApiDocBackfillFieldsCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'api:docgen-backfill-fields
{--dry-run : 치환하지 않고 채울 건수만 리포트}';
/**
* @var string 커맨드 설명
*/
protected $description = '기존 API 문서의 응답 필드 설명 TODO 를 리소스 계약 사전 설명으로 소급 채웁니다';
/**
* @var string 응답 필드 표를 식별하는 고유 헤더
*/
private const FIELD_TABLE_HEADER = '| 필드 | 타입 | 실측 예시값 | 용도/설명 |';
/**
* @var string 응답 필드 설명 TODO 마커
*/
private const TODO_MARKER = '<!-- TODO: 설명 -->';
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$describer = new ResourceFieldDescriber;
$dryRun = (bool) $this->option('dry-run');
$filled = 0;
$skipped = 0;
$filesChanged = 0;
foreach ($this->apiDocFiles() as $file) {
$content = file_get_contents($file);
$result = $this->backfillFile($content, $describer, $filled, $skipped);
if ($result !== $content) {
$filesChanged++;
if (! $dryRun) {
file_put_contents($file, $result);
}
}
}
$prefix = $dryRun ? '[dry-run] ' : '';
$this->info("{$prefix}응답 필드 설명 채움: {$filled}건, TODO 유지(도메인 특이): {$skipped}건, 변경 파일: {$filesChanged}개");
return self::SUCCESS;
}
/**
* 대상 API 문서 파일 경로를 수집합니다.
*
* @return array<int, string> 파일 경로 목록
*/
private function apiDocFiles(): array
{
$dirs = array_filter([
base_path('docs/backend/api'),
...glob(base_path('modules/_bundled/*/docs/api')),
...glob(base_path('plugins/_bundled/*/docs/api')),
], 'is_dir');
if ($dirs === []) {
return [];
}
$files = [];
foreach (Finder::create()->files()->in($dirs)->name('*.md') as $f) {
$files[] = $f->getRealPath();
}
return $files;
}
/**
* 단일 문서의 응답 필드 표 TODO 를 채웁니다.
*
* 응답 필드 표 헤더 이후 표 행만 대상으로 하며, `<!-- TODO: 설명 -->` 셀을
* ResourceFieldDescriber 결과로 치환합니다. 설명이 null(도메인 특이)이면 유지합니다.
* 파라미터 표는 헤더가 달라 진입하지 않으므로 건드리지 않습니다.
*
* @param string $content 문서 내용
* @param ResourceFieldDescriber $describer 설명기
* @param int $filled 채운 건수 (참조 누적)
* @param int $skipped 유지 건수 (참조 누적)
* @return string 치환된 문서 내용
*/
private function backfillFile(string $content, ResourceFieldDescriber $describer, int &$filled, int &$skipped): string
{
$lines = explode("\n", $content);
$inFieldTable = false;
foreach ($lines as $i => $line) {
// 응답 필드 표 헤더 진입 감지
if (str_contains($line, self::FIELD_TABLE_HEADER)) {
$inFieldTable = true;
continue;
}
// 표 구분선(| --- | ...) 은 건너뜀
if ($inFieldTable && preg_match('/^\|\s*-+\s*\|/', $line)) {
continue;
}
// 표가 아닌 라인(빈 줄 또는 | 로 시작하지 않음) → 표 종료
if ($inFieldTable && ! str_starts_with(ltrim($line), '|')) {
$inFieldTable = false;
continue;
}
if (! $inFieldTable || ! str_contains($line, self::TODO_MARKER)) {
continue;
}
// 응답 필드 행 파싱: | name | type | `sample` | <!-- TODO: 설명 --> |
$cells = array_map('trim', explode('|', trim($line, '| ')));
if (count($cells) < 4) {
continue;
}
[$name, $type] = [$cells[0], $cells[1]];
$desc = $describer->describe($name, $type);
if ($desc === null) {
$skipped++;
continue;
}
$safe = str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $desc);
$lines[$i] = str_replace(self::TODO_MARKER, $safe, $line);
$filled++;
}
return implode("\n", $lines);
}
}
@@ -0,0 +1,162 @@
<?php
namespace App\Console\Commands;
use App\Support\ApiDoc\ParameterDescriber;
use Illuminate\Console\Command;
use Symfony\Component\Finder\Finder;
/**
* 기존 API 문서의 파라미터 용도 TODO 를 공통 파라미터 설명으로 소급 채웁니다.
*
* 실측 서버 없이 재생성하면 이미 실측된 응답표·사람 서술이 퇴행하므로, 이 커맨드는
* 재생성 대신 파라미터 표의 `<!-- TODO: 용도 -->` 셀만 in-place 치환합니다.
* 채움 규칙 SSoT 는 ApiDocScaffolder 와 동일한 ParameterDescriber 입니다 —
* 이후 정상 재생성(실측 서버 가동 시)도 같은 설명을 산출하므로 멱등합니다.
*
* 도메인 특이 파라미터(ParameterDescriber 가 null 반환)는 TODO 를 그대로 둡니다.
*/
class ApiDocBackfillParamsCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'api:docgen-backfill-params
{--dry-run : 치환하지 않고 채울 건수만 리포트}';
/**
* @var string 커맨드 설명
*/
protected $description = '기존 API 문서의 파라미터 용도 TODO 를 공통 파라미터 설명으로 소급 채웁니다';
/**
* @var string 파라미터 표를 식별하는 고유 헤더
*/
private const PARAM_TABLE_HEADER = '| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |';
/**
* @var string 파라미터 용도 TODO 마커
*/
private const TODO_MARKER = '<!-- TODO: 용도 -->';
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$describer = new ParameterDescriber;
$dryRun = (bool) $this->option('dry-run');
$filled = 0;
$skipped = 0;
$filesChanged = 0;
foreach ($this->apiDocFiles() as $file) {
$content = file_get_contents($file);
$result = $this->backfillFile($content, $describer, $filled, $skipped);
if ($result !== $content) {
$filesChanged++;
if (! $dryRun) {
file_put_contents($file, $result);
}
}
}
$prefix = $dryRun ? '[dry-run] ' : '';
$this->info("{$prefix}파라미터 용도 채움: {$filled}건, TODO 유지(도메인 특이): {$skipped}건, 변경 파일: {$filesChanged}개");
return self::SUCCESS;
}
/**
* 대상 API 문서 파일 경로를 수집합니다.
*
* @return array<int, string> 파일 경로 목록
*/
private function apiDocFiles(): array
{
$dirs = array_filter([
base_path('docs/backend/api'),
...glob(base_path('modules/_bundled/*/docs/api')),
...glob(base_path('plugins/_bundled/*/docs/api')),
], 'is_dir');
if ($dirs === []) {
return [];
}
$files = [];
foreach (Finder::create()->files()->in($dirs)->name('*.md') as $f) {
$files[] = $f->getRealPath();
}
return $files;
}
/**
* 단일 문서의 파라미터 표 TODO 를 채웁니다.
*
* 파라미터 표 헤더 이후 표 행만 대상으로 하며, `<!-- TODO: 용도 -->` 셀을
* ParameterDescriber 결과로 치환합니다. 설명이 null(도메인 특이)이면 유지합니다.
*
* @param string $content 문서 내용
* @param ParameterDescriber $describer 설명기
* @param int $filled 채운 건수 (참조 누적)
* @param int $skipped 유지 건수 (참조 누적)
* @return string 치환된 문서 내용
*/
private function backfillFile(string $content, ParameterDescriber $describer, int &$filled, int &$skipped): string
{
$lines = explode("\n", $content);
$inParamTable = false;
foreach ($lines as $i => $line) {
// 파라미터 표 헤더 진입 감지
if (str_contains($line, self::PARAM_TABLE_HEADER)) {
$inParamTable = true;
continue;
}
// 표 구분선(| --- | ...) 은 건너뜀
if ($inParamTable && preg_match('/^\|\s*-+\s*\|/', $line)) {
continue;
}
// 표가 아닌 라인(빈 줄 또는 | 로 시작하지 않음) → 표 종료
if ($inParamTable && ! str_starts_with(ltrim($line), '|')) {
$inParamTable = false;
continue;
}
if (! $inParamTable || ! str_contains($line, self::TODO_MARKER)) {
continue;
}
// 파라미터 행 파싱: | name | location | type | 필수 | 허용값 | <!-- TODO: 용도 --> |
$cells = array_map('trim', explode('|', trim($line, '| ')));
if (count($cells) < 5) {
continue;
}
[$name, $location, $type] = [$cells[0], $cells[1], $cells[2]];
$desc = $describer->describe($name, $location, $type);
if ($desc === null) {
$skipped++;
continue;
}
$safe = str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $desc);
$lines[$i] = str_replace(self::TODO_MARKER, $safe, $line);
$filled++;
}
return implode("\n", $lines);
}
}
+937
View File
@@ -0,0 +1,937 @@
<?php
namespace App\Console\Commands;
use App\Contracts\ApiDoc\ApiDocSampleSeeder;
use App\Models\Module;
use App\Models\Plugin;
use App\Models\Template;
use App\Models\User;
use App\Support\ApiDoc\ApiDocSampleService;
use App\Support\ApiDoc\ApiDocScaffolder;
use App\Support\ApiDoc\ApiEndpointProbe;
use App\Support\ApiDoc\ApiRouteInventory;
use App\Support\ApiDoc\ColumnCommentResolver;
use App\Support\ApiDoc\FormRequestIntrospector;
use App\Support\ApiDoc\ResponseSchemaInferrer;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
/**
* API 레퍼런스 문서 생성 커맨드
*
* 등록된 API 라우트를 전수 수집하고, GET 엔드포인트는 실제 HTTP 호출로 응답을
* 실측하여 요청 파라미터·응답 필드를 담은 마크다운 레퍼런스 문서를 생성합니다.
*
* @generated 블록만 갱신하며 사람이 작성한 서술은 보존합니다.
*/
class ApiDocgenCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'api:docgen
{--scope=core : 범위 (core, module:vendor-id, plugin:vendor-id, all)}
{--base-url= : 실측 기준 URL (미지정 시 .env APP_URL)}
{--user= : 실측 토큰 발급 대상 사용자 ID}
{--seed : 실측 전 완전 샘플 데이터를 시드 (개발 환경 전용)}
{--check : 생성하지 않고 누락/미실측만 리포트}
{--examples-only : 기존 문서에 요청/응답 예시 블록만 in-place 삽입 (표·서술 불가침, 재생성 금지)}
{--dry-run : 생성 대상만 출력}';
/**
* @var string 커맨드 설명
*/
protected $description = 'API 레퍼런스 문서를 실측 기반으로 생성/갱신합니다';
/**
* 커맨드를 실행합니다.
*
* @param ApiRouteInventory $inventory 라우트 인벤토리
* @param FormRequestIntrospector $introspector FormRequest 분석기
* @param ResponseSchemaInferrer $inferrer 응답 스키마 추론기
* @param ApiDocScaffolder $scaffolder 스캐폴딩 생성기
* @param ColumnCommentResolver $commentResolver 컬럼 주석 해석기
* @return int 종료 코드
*/
public function handle(
ApiRouteInventory $inventory,
FormRequestIntrospector $introspector,
ResponseSchemaInferrer $inferrer,
ApiDocScaffolder $scaffolder,
ColumnCommentResolver $commentResolver
): int {
$scope = (string) $this->option('scope');
$routes = $inventory->collect($scope);
if ($routes === []) {
$this->warn("범위 '{$scope}' 에 해당하는 API 라우트가 없습니다.");
return self::SUCCESS;
}
$this->info(count($routes)."개 라우트 수집 (scope={$scope})");
// 도메인 파일 단위로 그룹핑
$grouped = [];
foreach ($routes as $route) {
$file = $this->targetFile($route);
$grouped[$file][] = $route;
}
if ($this->option('dry-run')) {
foreach ($grouped as $file => $items) {
$this->line(sprintf(' %s (%d endpoints)', $file, count($items)));
}
return self::SUCCESS;
}
// --seed: 도메인별 완전 샘플을 시드해 상세 GET 실측 시 null 응답을 최소화
// (인증보다 먼저 수행해 완전 샘플 사용자로 토큰을 발급 → /me 응답도 채워짐)
$sampleMap = [];
if ($this->option('seed')) {
if (app()->environment('production')) {
$this->error('--seed 는 개발 환경 전용입니다 (production 차단).');
return self::FAILURE;
}
// 코어 샘플 우선 시드 — 확장 샘플의 소유자/actor 로 쓰이는 완전 사용자를 확보한다.
$sampleMap = (new ApiDocSampleService)->seed();
$this->info('완전 샘플 시드: '.count($sampleMap).'개 도메인 (코어)');
// 확장 소유 라우트가 있으면 그 확장의 규약 시더를 발견해 도메인 샘플을 병합한다.
foreach ($this->discoverExtensionSeeders($routes) as $label => $seeder) {
$extMap = $seeder->seed();
$sampleMap = array_merge($sampleMap, $extMap);
$this->info('완전 샘플 시드: '.count($extMap)."개 도메인 ({$label})");
}
}
$probe = new ApiEndpointProbe($this->option('base-url') ?: null);
// 인증 사용자: --user 명시 > 완전 샘플 사용자(/me 응답 충실) > 첫 사용자
$authUserId = $this->option('user')
? (int) $this->option('user')
: $this->sampleUserId($sampleMap);
$probed = $probe->authenticate($authUserId);
if (! $probed) {
$this->warn('실측 토큰 발급 실패 — 실측 없이 정적 추출만 진행합니다.');
} else {
$this->info('실측 기준 URL: '.$probe->baseUrl());
}
$stats = ['files' => 0, 'endpoints' => 0, 'probed' => 0, 'skipped' => 0, 'examples' => 0];
$checkFindings = [];
$examplesOnly = (bool) $this->option('examples-only');
// 대상(코어/확장)별 README 목차 항목 누적: readmeKey => ['label' => ..., 'entries' => [...]]
$readmeIndex = [];
foreach ($grouped as $file => $items) {
$sections = [];
$sectionKeys = [];
$exampleBlocks = [];
// 목차용 도메인 파일 항목 (소유별). 예시-only/check 모드에서도 목차는 최신화한다.
$owner = $items[0]['owner'];
$readmeKey = $this->readmeFile($owner);
$readmeIndex[$readmeKey]['label'] = $owner['type'] === 'core'
? '코어'
: ($owner['type'] === 'module' ? '모듈' : '플러그인')." `{$owner['id']}`";
$readmeIndex[$readmeKey]['entries'][] = [
'domain' => $items[0]['domain_group'],
'file' => basename($file),
'count' => count($items),
];
foreach ($items as $route) {
$request = $introspector->introspect($route['controller'], $route['controller_method']);
[$schema, $probeMeta] = $this->probeEndpoint($probe, $inferrer, $route, $probed, $sampleMap, $request);
if ($probeMeta['skipped_reason'] === null) {
$stats['probed']++;
} else {
$stats['skipped']++;
$checkFindings[] = "{$route['method']} {$route['uri']} — {$probeMeta['skipped_reason']}";
}
$key = $route['name'] ?: $route['uri'];
if ($examplesOnly) {
// 표·서술을 건드리지 않고 예시 2블록만 산출 (SSoT=스캐폴더 예시 메서드).
// 단, 응답 필드 표가 아직 실측 제외 마커(사람이 채운 셀 없음)인데 이번 실측으로
// 스키마가 확보됐으면 그 마커를 실측 필드 표로 갱신한다(쓰기 메서드 실측 도입분).
$commentMap = $this->columnComments($route, $commentResolver, $sampleMap);
$exampleBlocks[$key] = [
'request' => $scaffolder->requestExampleBlock($route, $request, $probeMeta),
'response' => $scaffolder->responseExampleBlock($schema, $probeMeta),
'response_fields' => $schema !== null
? $scaffolder->responseFieldTableBlock($schema, $probeMeta, $commentMap)
: null,
];
$stats['endpoints']++;
continue;
}
$commentMap = $this->columnComments($route, $commentResolver, $sampleMap);
$sections[] = $scaffolder->endpointSection($route, $request, $schema, $probeMeta, $commentMap);
$sectionKeys[] = $key;
$stats['endpoints']++;
}
if ($this->option('check')) {
if (! File::exists($file)) {
$checkFindings[] = "문서 파일 없음: {$file}";
}
continue;
}
// examples-only: 재생성 없이 기존 문서에 예시 블록만 in-place 삽입.
// 전체 재생성은 @generated 블록 내부 표(파라미터/응답 필드)의 사람 서술을
// TODO 로 되돌리므로, 예시 방출을 위해 재생성을 쓰지 않는다.
if ($examplesOnly) {
if (! File::exists($file)) {
$this->warn("문서 없음(예시 삽입 건너뜀): {$file}");
continue;
}
$existing = File::get($file);
[$updated, $inserted] = $scaffolder->insertExampleBlocks($existing, $exampleBlocks);
if ($updated !== $existing) {
File::put($file, $updated);
$stats['files']++;
}
$stats['examples'] += $inserted;
continue;
}
$header = $this->documentHeader($file, $items[0]);
$existing = File::exists($file) ? File::get($file) : null;
$content = $scaffolder->mergeDocument($existing, $header, $sections, $sectionKeys);
File::ensureDirectoryExists(dirname($file));
File::put($file, $content);
$stats['files']++;
}
$probe->cleanup();
// 대상별 API 문서 목차(README.md) 생성/갱신 — 확장 API 레퍼런스 발견성의 규약 진입점.
// check/dry-run 은 파일을 쓰지 않으므로 제외. examples-only 는 목차도 최신화한다.
if (! $this->option('check')) {
$coreReadme = $this->readmeFile(['type' => 'core', 'id' => null]);
foreach ($readmeIndex as $readmePath => $meta) {
$existing = File::exists($readmePath) ? File::get($readmePath) : null;
// 코어 README 는 최상위 README 의 "API 레퍼런스" 진입점이므로 확장 목차를 함께 싣는다.
$extensions = $readmePath === $coreReadme ? $this->scanExtensionApiReadmes() : null;
$content = $scaffolder->readmeIndex($meta['label'], $meta['entries'], $existing, $extensions);
File::ensureDirectoryExists(dirname($readmePath));
File::put($readmePath, $content);
}
// 확장 스코프 실행이라 코어 README 를 다시 쓰지 않았어도, 그 확장의 엔드포인트 수가
// 바뀌었을 수 있으므로 코어 README 의 확장 목차 블록만 in-place 갱신한다.
if (! array_key_exists($coreReadme, $readmeIndex) && File::exists($coreReadme)) {
$updated = $scaffolder->refreshExtensionIndex(
File::get($coreReadme),
$this->scanExtensionApiReadmes()
);
if ($updated !== null) {
File::put($coreReadme, $updated);
}
}
}
if ($this->option('check')) {
if ($checkFindings !== []) {
$this->warn('실측 제외/문서 누락 '.count($checkFindings).'건:');
foreach (array_slice($checkFindings, 0, 50) as $f) {
$this->line(' - '.$f);
}
return self::FAILURE;
}
$this->info('drift 없음.');
return self::SUCCESS;
}
$this->newLine();
if ($examplesOnly) {
$this->info(sprintf(
'예시 삽입 완료: 파일 %d개, 예시 블록 %d개 (실측 %d, 제외 %d)',
$stats['files'],
$stats['examples'],
$stats['probed'],
$stats['skipped']
));
return self::SUCCESS;
}
$this->info(sprintf(
'완료: 파일 %d개, 엔드포인트 %d개 (실측 %d, 제외 %d)',
$stats['files'],
$stats['endpoints'],
$stats['probed'],
$stats['skipped']
));
return self::SUCCESS;
}
/**
* 수집된 라우트의 소유 확장에서 규약 위치의 샘플 시더를 발견합니다.
*
* 확장 컨트롤러 FQCN(`Modules\Vendor\Ext\Http\Controllers\...`)에서 확장 베이스
* 네임스페이스를 추출하고, `{Base}\Support\ApiDoc\ApiDocSampleService` 규약 클래스가
* 존재하며 ApiDocSampleSeeder 를 구현하면 인스턴스를 반환합니다. 각 확장은 1회만 처리합니다.
*
* @param array<int, array<string, mixed>> $routes 수집된 라우트 목록
* @return array<string, ApiDocSampleSeeder> 확장 라벨(key) => 시더 인스턴스
*/
private function discoverExtensionSeeders(array $routes): array
{
$seeders = [];
$seen = [];
foreach ($routes as $route) {
$owner = $route['owner'];
if ($owner['type'] === 'core' || $owner['id'] === null) {
continue;
}
$label = $owner['key'];
if (isset($seen[$label])) {
continue;
}
$seen[$label] = true;
$base = $this->extensionBaseNamespace($route['controller'] ?? null);
if ($base === null) {
continue;
}
$class = $base.'\\Support\\ApiDoc\\ApiDocSampleService';
if (class_exists($class) && is_subclass_of($class, ApiDocSampleSeeder::class)) {
$seeders[$label] = app($class);
}
}
return $seeders;
}
/**
* 확장 컨트롤러 FQCN 에서 확장 베이스 네임스페이스를 추출합니다.
*
* 두 컨트롤러 배치 규약을 모두 지원한다:
* - `\Http\Controllers\` (대다수 확장): `Modules\Sirsoft\Page\Http\Controllers\Admin\PageController`
* → `Modules\Sirsoft\Page`
* - `\Controllers\` (일부 플러그인): `Plugins\Sirsoft\PayKginicis\Controllers\PaymentSignatureController`
* → `Plugins\Sirsoft\PayKginicis`
*
* `\Http\Controllers\` 를 우선 판정하고, 없으면 `\Controllers\` 세그먼트 앞까지를 베이스로 본다.
*
* @param string|null $controller 컨트롤러 FQCN (클로저면 null)
* @return string|null 확장 베이스 네임스페이스 (추출 불가 시 null)
*/
private function extensionBaseNamespace(?string $controller): ?string
{
if ($controller === null) {
return null;
}
if (Str::contains($controller, '\\Http\\Controllers\\')) {
return Str::before($controller, '\\Http\\Controllers\\');
}
if (Str::contains($controller, '\\Controllers\\')) {
return Str::before($controller, '\\Controllers\\');
}
return null;
}
/**
* 엔드포인트를 실측하고 응답 스키마를 추론합니다.
*
* @param ApiEndpointProbe $probe 실측 프로브
* @param ResponseSchemaInferrer $inferrer 스키마 추론기
* @param array<string, mixed> $route 라우트 메타데이터
* @param bool $probed 실측 가능 여부
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 도메인별 대표 샘플 맵
* @param array<string, mixed> $request FormRequest 분석 결과 (쓰기 바디 구성용)
* @return array{0: array<string, mixed>|null, 1: array<string, mixed>} 스키마와 실측 메타
*/
private function probeEndpoint(ApiEndpointProbe $probe, ResponseSchemaInferrer $inferrer, array $route, bool $probed, array $sampleMap = [], array $request = []): array
{
$baseUrl = $probe->baseUrl();
if (! $probed) {
return [null, ['status' => null, 'skipped_reason' => 'no-token', 'base_url' => $baseUrl, 'resolved_uri' => null, 'body' => null]];
}
$uri = $this->resolvePathParams($route, $sampleMap);
$isWrite = in_array($route['method'], ['POST', 'PUT', 'PATCH', 'DELETE'], true);
// 요청 예시에 노출할 정규 URI (실측용 per_page 보정 제외).
$exampleUri = $this->exampleUri($route, $sampleMap);
// 부수효과가 트랜잭션 롤백으로 되돌릴 수 없는 쓰기(확장 install/activate/update,
// 언어팩 설치, 코어 업데이트, 파일 업로드 등)는 in-process 실측에서 제외한다.
// 이들은 파일시스템·프로세스·외부 네트워크를 건드려 롤백 불가·hang 위험이 있다.
if ($isWrite && $this->isSideEffectfulWrite($route)) {
return [null, [
'status' => null,
'skipped_reason' => 'side-effectful-write',
'base_url' => $baseUrl,
'resolved_uri' => $exampleUri,
'body' => null,
]];
}
// 쓰기 메서드는 FormRequest 규칙 기반 실측 바디를 만들어 in-process 롤백 실측에 사용한다.
$writeBody = in_array($route['method'], ['POST', 'PUT', 'PATCH'], true)
? $this->buildWriteBody($request, $sampleMap)
: [];
$result = $probe->probe($route['method'], $uri, $writeBody);
if (! $result['ok'] || $result['body'] === null) {
return [null, [
'status' => $result['status'],
'skipped_reason' => $result['skipped_reason'] ?? ('http-'.$result['status']),
'base_url' => $baseUrl,
'resolved_uri' => $exampleUri,
'body' => null,
]];
}
return [$inferrer->infer($result['body']), [
'status' => $result['status'],
'skipped_reason' => null,
'base_url' => $baseUrl,
'resolved_uri' => $exampleUri,
'body' => $result['body'],
]];
}
/**
* 부수효과가 트랜잭션 롤백으로 되돌릴 수 없는 쓰기 엔드포인트인지 판별합니다.
*
* 확장(모듈/플러그인/템플릿) 및 언어팩 설치·활성·업데이트, 코어 업데이트, 파일 업로드,
* 캐시/워밍업/생성 등 파일시스템·프로세스·외부 네트워크를 건드리는 쓰기는 in-process 롤백
* 실측이 불가능(hang·비가역 부수효과)하므로 실측에서 제외한다. URI 세그먼트로 판정한다.
*
* @param array<string, mixed> $route 라우트 메타데이터
* @return bool 부수효과 쓰기 여부
*/
private function isSideEffectfulWrite(array $route): bool
{
$uri = (string) $route['uri'];
// 확장/언어팩 관리 도메인의 쓰기는 전량 제외 (install/activate/... 은 실환경 변경).
if (preg_match('#/api/admin/(modules|plugins|templates|language-packs|core-update)(/|$)#', $uri)) {
return true;
}
// 동작성 세그먼트를 가진 쓰기 (도메인 무관).
$unsafeSegments = [
'install', 'install-from-file', 'install-from-github', 'install-from-url', 'install-from-bundled',
'uninstall', 'activate', 'deactivate', 'bulk-activate', 'update', 'check-updates',
'refresh-cache', 'clear', 'warmup', 'generate', 'sync', 'backup', 'restore',
'upload', 'import', 'export', 'manifest-preview', 'preview',
];
foreach ($unsafeSegments as $seg) {
if (str_contains($uri, '/'.$seg)) {
return true;
}
}
return false;
}
/**
* 요청 예시에 노출할 정규 URI 를 산출합니다.
*
* `resolvePathParams` 는 목록 GET 실측용으로 `?per_page=25` 를 덧붙이지만,
* 요청 예시에는 이 실측 보정을 제외한 순수 경로(치환된 path 파라미터 포함)를 노출한다.
* path 파라미터가 치환되지 않은(실측 제외) 경우 `{param}` placeholder 를 그대로 남긴다.
*
* @param array<string, mixed> $route 라우트 메타데이터
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 도메인별 대표 샘플 맵
* @return string 요청 예시용 URI
*/
private function exampleUri(array $route, array $sampleMap): string
{
$resolved = $this->resolvePathParams($route, $sampleMap);
// 실측용 per_page 보정 쿼리를 제거해 정규 경로만 남긴다.
return preg_replace('/[?&]per_page=25$/', '', $resolved) ?? $resolved;
}
/**
* 쓰기 메서드 in-process 실측용 요청 바디를 FormRequest 규칙 근거로 구성합니다.
*
* 필수 파라미터를 우선하고, 검증을 통과할 수 있도록 이름·타입·허용값에 맞는 유효값을 채웁니다.
* 파일(image/file) 파라미터는 실측 바디에서 제외한다(in-process dispatch 로 파일 전송 불가 →
* 해당 엔드포인트는 http-422 로 스킵되고 정적 요청 예시[multipart]만 방출된다).
*
* @param array<string, mixed> $request FormRequest 분석 결과
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 도메인별 대표 샘플 맵
* @return array<string, mixed> 실측 요청 바디
*/
private function buildWriteBody(array $request, array $sampleMap = []): array
{
$params = $request['params'] ?? [];
$body = [];
foreach ($params as $p) {
$name = (string) $p['name'];
$type = (string) ($p['type'] ?? 'string');
// 파일 타입은 in-process 바디로 전송 불가 → 생략.
if (in_array($type, ['file', 'image'], true)) {
continue;
}
$body[$name] = $this->writeValue($name, $type, (string) ($p['allowed'] ?? ''), $sampleMap);
}
return $body;
}
/**
* 쓰기 실측 바디의 파라미터 값을 검증 통과 가능하도록 생성합니다.
*
* @param string $name 파라미터명
* @param string $type 타입
* @param string $allowed 허용값 설명 (in: 열거 등)
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 도메인 샘플 맵
* @return mixed 유효 예시값
*/
private function writeValue(string $name, string $type, string $allowed, array $sampleMap): mixed
{
$lower = strtolower($name);
// 허용값 in: 열거(백틱)가 있으면 첫 값을 채택(검증 통과 보장).
if (preg_match('/`([^`]+)`/', $allowed, $m)) {
return $m[1];
}
return match (true) {
$type === 'boolean' => true,
$type === 'integer', $type === 'number' => 1,
$type === 'array' => [],
$type === 'email' || str_contains($lower, 'email') => 'probe_'.uniqid().'@example.com',
$type === 'uuid' => (string) Str::uuid(),
$type === 'date' => '2026-01-01',
str_contains($lower, 'password') => 'Password123!',
str_contains($lower, 'url') || str_contains($lower, 'homepage') => 'https://example.com',
// identifier/slug 는 소문자/숫자/밑줄만 허용하는 규칙이 흔하므로 하이픈 없이 생성.
str_contains($lower, 'identifier') || str_contains($lower, 'slug') => 'probe_'.uniqid(),
str_contains($lower, 'locale') || $lower === 'language' => 'ko',
str_contains($lower, 'country') => 'KR',
str_contains($lower, 'timezone') => 'Asia/Seoul',
default => '실측 예시값',
};
}
/**
* URI 의 path 파라미터를 바인딩된 모델의 실제 레코드로 치환합니다.
*
* @param array<string, mixed> $route 라우트 메타데이터
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 도메인별 대표 샘플 맵
* @return string 치환된 URI (치환 실패 시 원본 유지 → 프로브가 실측 제외)
*/
private function resolvePathParams(array $route, array $sampleMap = []): string
{
$uri = $route['uri'];
$domain = $route['domain_group'] ?? null;
// path 파라미터가 있으면: 완전 샘플 대표 레코드 우선, 없으면 첫 레코드 키로 치환
foreach ($route['path_params'] as $param) {
$modelClass = $route['path_bindings'][$param] ?? null;
// 라우트-모델 바인딩이 없는 확장 패턴(예: show(int $id))은 도메인 샘플 맵의
// 대표 모델로 폴백한다. 단, 파라미터명이 도메인의 단수 리소스명과 일치할 때만
// (pages/{page} → Page). {slug}/{hash}/{versionId} 등 route key 가 다른
// 문자열/보조 파라미터에는 폴백하지 않아 잘못된 치환(404)을 피한다.
if (! $modelClass && $domain !== null && isset($sampleMap[$domain]) && $this->paramMatchesDomain($param, $domain)) {
$modelClass = $sampleMap[$domain]['model'];
}
if (! $modelClass) {
// route-model binding 도 도메인 폴백도 없는 문자열 path 파라미터
// (예: board 의 boards/{slug}/posts/{id}). 도메인 대표 샘플이 명시한
// path_params 맵(param 명 => 실제 값)에 해당 param 이 있으면 그 값으로 치환한다.
// slug 라우팅을 쓰는 확장이 route key(id) 와 무관한 slug/id 조합을
// 실측할 수 있도록 하는 일반 경로다. (파라미터명 정확 일치만 허용 → 오치환 방지)
$explicit = $domain !== null ? ($sampleMap[$domain]['path_params'][$param] ?? null) : null;
if ($explicit !== null) {
$uri = str_replace('{'.$param.'}', (string) $explicit, $uri);
continue;
}
// 코어 다수 라우트는 route-model binding 없이 `{id}`/`{identifier}`/`{key}` 문자열
// 파라미터를 쓰고 컨트롤러가 직접 조회한다. 도메인 대표 샘플 모델의 route key(또는
// 해당 컬럼)로 치환해 실측 가능하게 한다. (마지막 세그먼트 리소스 파라미터에 한함)
$generic = $this->resolveGenericPathParam($param, $domain, $sampleMap);
if ($generic !== null) {
$uri = str_replace('{'.$param.'}', (string) $generic, $uri);
}
continue;
}
// 시드된 완전 샘플 중 같은 모델이 있으면 그 route key 를 우선 사용
$value = $this->sampleKeyForModel($modelClass, $sampleMap) ?? $this->firstRouteKey($modelClass);
if ($value !== null) {
$uri = str_replace('{'.$param.'}', (string) $value, $uri);
}
}
// 목록 GET 은 여러 행을 받아 필드별 non-null 대표 샘플을 확보한다
// (per_page=1 이면 첫 행이 우연히 비어 "항상 null" 처럼 보임).
if ($route['method'] === 'GET' && ! Str::contains($uri, '{')) {
return $uri.(Str::contains($uri, '?') ? '&' : '?').'per_page=25';
}
return $uri;
}
/**
* route-model binding 없는 제네릭 path 파라미터를 도메인 샘플 모델의 실제 키로 치환합니다.
*
* 코어 다수 라우트는 `{id}`/`{identifier}`/`{key}`/`{slug}` 문자열 파라미터를 쓰고 컨트롤러가
* 직접 조회한다. 도메인 대표 샘플 모델을 찾아 파라미터명에 대응하는 컬럼값으로 치환한다:
* - `id` → 모델 기본키
* - `identifier`/`slug`/`key`/`code` → 같은 이름 컬럼이 있으면 그 값, 없으면 route key
* - 그 외 → 치환 안 함(null 반환 → 실측 제외 유지, 오치환 방지)
*
* @param string $param path 파라미터명
* @param string|null $domain 도메인 그룹명
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 도메인 샘플 맵
* @return string|null 치환값 (없으면 null)
*/
private function resolveGenericPathParam(string $param, ?string $domain, array $sampleMap): ?string
{
$modelClass = ($domain !== null && isset($sampleMap[$domain]))
? $sampleMap[$domain]['model']
: $this->domainModelHint($domain);
if (! $modelClass) {
return null;
}
try {
/** @var Model $model */
$model = new $modelClass;
$record = $modelClass::query()->orderBy($model->getKeyName())->first();
if (! $record) {
return null;
}
$lower = strtolower($param);
// id 계열 → 기본키
if ($lower === 'id' || str_ends_with($lower, '_id')) {
return (string) $record->getKey();
}
// identifier/slug/key/code → 동일 컬럼이 있으면 그 값
if (in_array($lower, ['identifier', 'slug', 'key', 'code'], true)) {
$val = $record->getAttribute($lower);
return $val !== null ? (string) $val : (string) $record->getRouteKey();
}
// 도메인 단수 리소스명 파라미터 → route key
if ($domain !== null && $this->paramMatchesDomain($param, $domain)) {
return (string) $record->getRouteKey();
}
return null;
} catch (\Throwable) {
return null;
}
}
/**
* path 파라미터명이 도메인의 단수 리소스명과 일치하는지 판정합니다.
*
* 도메인 그룹은 복수형(pages)이고 라우트 파라미터는 단수(page)이므로,
* 파라미터명 == 도메인명 또는 파라미터명 == 도메인 단수형일 때만 매칭으로 본다.
* (page ↔ pages). {slug}/{hash}/{versionId} 는 어느 쪽과도 일치하지 않는다.
*
* @param string $param path 파라미터명
* @param string $domain 도메인 그룹명 (복수형 가능)
* @return bool 도메인 리소스 파라미터 여부
*/
private function paramMatchesDomain(string $param, string $domain): bool
{
return $param === $domain
|| $param === Str::singular($domain)
|| Str::plural($param) === $domain;
}
/**
* 라우트의 주 모델 컬럼 주석 맵을 반환합니다 (응답 필드 설명 기본값).
*
* path 파라미터에 바인딩된 모델을 우선하고, 없으면 도메인 샘플 맵의 대표
* 모델을 주 모델로 보고 그 테이블 주석을 사용합니다.
*
* @param array<string, mixed> $route 라우트 메타데이터
* @param ColumnCommentResolver $resolver 컬럼 주석 해석기
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 도메인별 대표 샘플 맵
* @return array<string, string> 컬럼명 => 주석
*/
private function columnComments(array $route, ColumnCommentResolver $resolver, array $sampleMap = []): array
{
$bindings = $route['path_bindings'] ?? [];
// 마지막 path 바인딩 모델(가장 구체적인 리소스)을 주 모델로 사용
$modelClass = null;
foreach ($bindings as $class) {
$modelClass = $class;
}
// path 바인딩이 없으면(목록/단건 me 등) 도메인 샘플 맵 → 정적 힌트 순으로 유추
if (! $modelClass) {
$domain = $route['domain_group'] ?? null;
$modelClass = ($domain && isset($sampleMap[$domain]) ? $sampleMap[$domain]['model'] : null)
?? $this->domainModelHint($domain);
}
return $modelClass ? $resolver->forModel($modelClass) : [];
}
/**
* 샘플 맵으로 유추되지 않는 도메인의 주 모델을 정적 힌트로 매핑합니다.
*
* me/auth/profile/password 등은 path 바인딩도 없고 sampleMap 키(users)와도
* 도메인명이 달라 자동 유추가 안 되지만, 모두 User 필드를 반환합니다.
*
* @param string|null $domain 도메인 그룹명
* @return class-string|null 주 모델 FQCN (없으면 null)
*/
private function domainModelHint(?string $domain): ?string
{
if ($domain === null) {
return null;
}
$hints = [
'me' => User::class,
'auth' => User::class,
'profile' => User::class,
'password' => User::class,
'modules' => Module::class,
'plugins' => Plugin::class,
'templates' => Template::class,
];
return $hints[$domain] ?? null;
}
/**
* 완전 샘플 맵에서 샘플 사용자의 DB id 를 조회합니다 (토큰 발급 대상).
*
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 대표 샘플 맵
* @return int|null 샘플 사용자 id (없으면 null → 첫 사용자로 fallback)
*/
private function sampleUserId(array $sampleMap): ?int
{
$users = $sampleMap['users'] ?? null;
if (! $users) {
return null;
}
return User::query()
->where($users['key'], $users['value'])
->value('id');
}
/**
* 시드된 완전 샘플 맵에서 해당 모델의 대표 route key 를 찾습니다.
*
* @param class-string $modelClass 모델 FQCN
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 대표 샘플 맵
* @return string|null 대표 route key 값 (없으면 null)
*/
private function sampleKeyForModel(string $modelClass, array $sampleMap): ?string
{
foreach ($sampleMap as $sample) {
if ($sample['model'] === $modelClass) {
return $sample['value'];
}
}
return null;
}
/**
* 모델의 첫 레코드 route key 값을 반환합니다.
*
* @param class-string $modelClass 모델 FQCN
* @return mixed route key 값 (레코드 없으면 null)
*/
private function firstRouteKey(string $modelClass): mixed
{
try {
/** @var Model $model */
$model = new $modelClass;
$keyName = $model->getRouteKeyName();
$record = $modelClass::query()->orderBy($model->getKeyName())->first();
return $record?->getAttribute($keyName);
} catch (\Throwable) {
return null;
}
}
/**
* 라우트가 저장될 문서 파일 경로를 산출합니다.
*
* @param array<string, mixed> $route 라우트 메타데이터
* @return string 절대 파일 경로
*/
private function targetFile(array $route): string
{
$owner = $route['owner'];
$domain = $route['domain_group'];
if ($owner['type'] === 'core') {
return base_path("docs/backend/api/{$domain}.md");
}
$base = $owner['type'] === 'module' ? 'modules' : 'plugins';
return base_path("{$base}/_bundled/{$owner['id']}/docs/api/{$domain}.md");
}
/**
* 번들 확장이 소유한 API 문서 목차(README.md)를 파일 시스템 패턴 스캔으로 수집합니다.
*
* 확장명을 하드코딩하지 않고 `{modules,plugins}/_bundled/{*}/docs/api/README.md` 존재
* 여부만으로 발견한다(동적 로딩 원칙). 문서 수·엔드포인트 수는 스캐폴더가 방출한
* 집계 라인(`**문서 수**: N · **엔드포인트 수**: M`)에서 읽는다.
*
* 링크 경로는 코어 README(`docs/backend/api/`) 기준 상대경로로 만든다.
*
* @return array<int, array{id: string, type: string, path: string, docs: int, endpoints: int}> 확장 목록
*/
private function scanExtensionApiReadmes(): array
{
$results = [];
foreach (['modules' => 'module', 'plugins' => 'plugin'] as $dir => $type) {
$bundled = base_path("{$dir}/_bundled");
if (! File::isDirectory($bundled)) {
continue;
}
foreach (File::directories($bundled) as $extDir) {
$readme = $extDir.DIRECTORY_SEPARATOR.'docs'.DIRECTORY_SEPARATOR.'api'.DIRECTORY_SEPARATOR.'README.md';
if (! File::exists($readme)) {
continue;
}
$id = basename($extDir);
$content = File::get($readme);
$docs = 0;
$endpoints = 0;
if (preg_match('/\*\*문서 수\*\*:\s*(\d+)\s*·\s*\*\*엔드포인트 수\*\*:\s*(\d+)/u', $content, $m)) {
$docs = (int) $m[1];
$endpoints = (int) $m[2];
}
$results[] = [
'id' => $id,
'type' => $type,
// docs/backend/api/README.md → 프로젝트 루트까지 3단계 상위
'path' => "../../../{$dir}/_bundled/{$id}/docs/api/README.md",
'docs' => $docs,
'endpoints' => $endpoints,
];
}
}
return $results;
}
/**
* 소유 대상의 API 문서 목차(README.md) 경로를 반환합니다.
*
* @param array{type: string, id: string|null} $owner 소유 메타
* @return string README.md 절대 경로
*/
private function readmeFile(array $owner): string
{
if ($owner['type'] === 'core') {
return base_path('docs/backend/api/README.md');
}
$base = $owner['type'] === 'module' ? 'modules' : 'plugins';
return base_path("{$base}/_bundled/{$owner['id']}/docs/api/README.md");
}
/**
* 문서 헤더(제목 + TL;DR)를 생성합니다.
*
* @param string $file 문서 파일 경로
* @param array<string, mixed> $firstRoute 대표 라우트
* @return string 문서 헤더
*/
private function documentHeader(string $file, array $firstRoute): string
{
$domain = Str::headline(pathinfo($file, PATHINFO_FILENAME));
$owner = $firstRoute['owner'];
$ownerLabel = $owner['type'] === 'core' ? '코어' : "{$owner['type']} `{$owner['id']}`";
return <<<MD
# {$domain} API 레퍼런스
> **소유**: {$ownerLabel} · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
---
## TL;DR (5초 요약)
```text
1. 이 문서는 실제 API 호출로 실측한 {$domain} 엔드포인트 레퍼런스입니다
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
5. 설명(TODO) 칸은 사람이 채웁니다
```
---
MD;
}
}
@@ -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;
}
}
+128 -17
View File
@@ -85,27 +85,98 @@ class BuildCoreCommand extends Command
*/
private function buildEngineOnly(string $projectPath, bool $watchMode, bool $productionMode): int
{
$buildCommand = ['npm', 'run'];
// 감시 모드: 엔진 번들 + 편집기 번들(layout-editor.min.js)을 각각 vite --watch 로
// 병렬 감시한다. (기존 dev 서버는 코어 lib 를 빌드하지 않으므로 사용 불가)
if ($watchMode) {
// build:core는 watch 모드가 없으므로 dev 사용
$buildCommand[] = 'dev';
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진)');
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진 + 레이아웃 편집기)');
$this->line(' Ctrl+C로 종료할 수 있습니다.');
} else {
$buildCommand[] = 'build:core';
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
return $this->runWatchBundles($projectPath);
}
$result = $this->runNpmCommand($buildCommand, $projectPath, ! $watchMode);
// 3개 번들을 npm 스크립트로 순차 호출하므로 빌드 환경변수를 세 곳 모두에 주입한다.
$buildEnv = $this->buildEnv($productionMode);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info('✅ 코어 빌드 완료 (템플릿 엔진)');
$this->showEngineBuildResults($projectPath);
$this->incrementExtensionCacheVersion();
// ── 1) 템플릿 엔진 번들 (편집기 코드 제외) ──────────────────────────────
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
$engineResult = $this->runNpmCommand(['npm', 'run', 'build:core'], $projectPath, true, $buildEnv);
if ($engineResult !== Command::SUCCESS) {
return $engineResult;
}
return $result;
// ── 2) 레이아웃 편집기 번들 (lazy, /admin/layout-editor/* 진입 시 로드) ──
$this->info('🔨 코어 빌드 시작 (레이아웃 편집기)'.($productionMode ? ' (프로덕션)' : ''));
$editorResult = $this->runNpmCommand(['npm', 'run', 'build:core-editor'], $projectPath, true, $buildEnv);
if ($editorResult !== Command::SUCCESS) {
$this->error('❌ 레이아웃 편집기 번들 빌드 실패');
return $editorResult;
}
// ── 3) DevTools 번들 (lazy, 디버그 모드에서만 로드) ──
$this->info('🔨 코어 빌드 시작 (DevTools)'.($productionMode ? ' (프로덕션)' : ''));
$devtoolsResult = $this->runNpmCommand(['npm', 'run', 'build:core-devtools'], $projectPath, true, $buildEnv);
if ($devtoolsResult !== Command::SUCCESS) {
$this->error('❌ DevTools 번들 빌드 실패');
return $devtoolsResult;
}
$this->info('✅ 코어 빌드 완료 (템플릿 엔진 + 레이아웃 편집기 + DevTools)');
$this->showEngineBuildResults($projectPath);
$this->incrementExtensionCacheVersion();
return Command::SUCCESS;
}
/**
* 감시 모드에서 엔진 번들 + 편집기 번들을 병렬로 vite --watch 실행합니다.
*
* @param string $projectPath 프로젝트 경로
* @return int 명령 실행 결과 코드
*/
private function runWatchBundles(string $projectPath): int
{
// 엔진 + 편집기 + DevTools 번들을 각각 vite --watch 로 병렬 감시
$bundles = [
'engine' => ['npm', 'run', 'build:core-watch'],
'editor' => ['npm', 'run', 'build:core-editor-watch'],
'devtools' => ['npm', 'run', 'build:core-devtools-watch'],
];
/** @var array<string, Process> $processes */
$processes = [];
foreach ($bundles as $label => $command) {
if (PHP_OS_FAMILY === 'Windows') {
$command = array_merge(['cmd', '/c'], $command);
}
$process = new Process($command);
$process->setWorkingDirectory($projectPath);
$process->setTimeout(null);
$process->start(function ($type, $buffer) use ($label) {
$this->output->write("[{$label}] ".$buffer);
});
$processes[$label] = $process;
}
// 하나라도 실행 중이면 계속 대기 (Ctrl+C 로 종료)
do {
$anyRunning = false;
foreach ($processes as $process) {
if ($process->isRunning()) {
$anyRunning = true;
}
}
usleep(100000); // 100ms
} while ($anyRunning);
return Command::SUCCESS;
}
/**
@@ -129,7 +200,13 @@ class BuildCoreCommand extends Command
$this->info('🔨 코어 빌드 시작 (전체)'.($productionMode ? ' (프로덕션)' : ''));
}
$result = $this->runNpmCommand($buildCommand, $projectPath, ! $watchMode);
// 감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요
$result = $this->runNpmCommand(
$buildCommand,
$projectPath,
! $watchMode,
$watchMode ? [] : $this->buildEnv($productionMode)
);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info('✅ 코어 빌드 완료 (전체)');
@@ -155,12 +232,26 @@ class BuildCoreCommand extends Command
$this->line(' 빌드 결과:');
// template-engine.min.js 확인
// template-engine.min.js 확인 (일반 페이지 초기 로드 = 이 번들만)
$engineFile = $corePath.'/template-engine.min.js';
if (file_exists($engineFile)) {
$fileSize = number_format(filesize($engineFile) / 1024, 2);
$this->line(" - template-engine.min.js ({$fileSize} KB)");
}
// layout-editor.min.js 확인 (편집기 lazy 번들, /admin/layout-editor/* 진입 시 로드)
$editorFile = $corePath.'/layout-editor.min.js';
if (file_exists($editorFile)) {
$fileSize = number_format(filesize($editorFile) / 1024, 2);
$this->line(" - layout-editor.min.js ({$fileSize} KB, lazy)");
}
// devtools.min.js 확인 (DevTools lazy 번들, 디버그 모드에서만 로드)
$devtoolsFile = $corePath.'/devtools.min.js';
if (file_exists($devtoolsFile)) {
$fileSize = number_format(filesize($devtoolsFile) / 1024, 2);
$this->line(" - devtools.min.js ({$fileSize} KB, lazy/debug)");
}
}
/**
@@ -229,15 +320,30 @@ class BuildCoreCommand extends Command
}
}
/**
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
*
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
*
* @param bool $productionMode 프로덕션 빌드 여부
* @return array<string, string> Process 에 주입할 환경변수
*/
private function buildEnv(bool $productionMode): array
{
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
}
/**
* npm 명령 실행
*
* @param array $command 실행할 명령
* @param string $cwd 작업 디렉토리
* @param bool $waitForCompletion 완료 대기 여부
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
* @return int 명령 실행 결과 코드
*/
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
{
// Windows 환경에서는 cmd /c 사용
if (PHP_OS_FAMILY === 'Windows') {
@@ -246,6 +352,11 @@ class BuildCoreCommand extends Command
$process = new Process($command);
$process->setWorkingDirectory($cwd);
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
if ($env !== []) {
$process->setEnv($env);
}
$process->setTimeout(null); // 타임아웃 없음
if ($waitForCompletion) {
+285 -10
View File
@@ -15,6 +15,7 @@ use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorMode;
use App\Services\CoreUpdateService;
use App\Support\ConfigCacheHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -27,6 +28,7 @@ class CoreUpdateCommand extends Command
protected $signature = 'core:update
{--force : 버전 비교 없이 강제 업데이트}
{--no-backup : 백업 생성 건너뛰기}
{--prune : 코어가 제거한 파일 정리 + targets 전체 덮어쓰기(기존 방식). 미지정 시 코어가 실제 변경/추가한 파일만 적용하고 나머지는 보존}
{--no-maintenance : 유지보수 모드 활성화 건너뛰기}
{--local : 로컬 코드베이스를 업데이트 소스로 사용 (GitHub 스킵)}
{--source= : 수동 업데이트용 소스 디렉토리 경로 (GitHub 다운로드 대신 지정 디렉토리 사용)}
@@ -345,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 생성 시작');
@@ -370,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) {
@@ -484,6 +528,13 @@ class CoreUpdateCommand extends Command
$maintenanceEnabled = false;
}
// 모든 파일(코어 config/lang/vendor)이 안착한 뒤 config 캐시를 재생성한다.
// clearAllCaches() 는 흐름 중간에서 stale 캐시를 비우기만 하므로, 여기서
// 재생성하지 않으면 업데이트 후 config:cache 가 비활성 상태로 남아 이후 모든
// 요청이 config 파일을 재파싱한다(성능 손실). ConfigCacheHelper 는 설치 미완료
// 상태를 가드하고 실패를 안전하게 흡수한다.
ConfigCacheHelper::rebuild();
$log('정리 완료');
$bar->finish();
@@ -495,6 +546,9 @@ class CoreUpdateCommand extends Command
$this->info("그누보드7 코어가 {$toVersion} 버전으로 업데이트되었습니다!");
$this->newLine();
// ── 파일 적용 방식 요약 (부수의무: 설명의무) ──
$this->summarizeApplyMode($prune, $applyList, $applyStats);
// _bundled 확장 일괄 업데이트 프롬프트 (번들에 신버전이 있을 때만 표시).
//
// 주의: Step 11 restoreOwnership 이후에 실행되므로, 프롬프트 내 실제
@@ -593,7 +647,7 @@ class CoreUpdateCommand extends Command
$this->line(" 사유: {$e->reason}");
$this->newLine();
$this->info('나머지 스텝을 적용하려면 아래 명령을 실행하세요 (재다운로드 없이 스텝만 실행):');
$this->line(" {$resumeCommand}");
$this->surfaceResumeCommandWithPermissionGuidance($resumeCommand);
$this->newLine();
$this->warn('⚠ 위 명령을 실행하지 않으면 버전 표시는 최신이지만 일부 스텝이 미실행 상태로 남습니다.');
$this->newLine();
@@ -806,6 +860,7 @@ class CoreUpdateCommand extends Command
// 페이로드만 보관한다 (각각 UpgradeHandoffException 재구성 / silent skip 가드용).
$handoffPayload = null;
$stepsExecuted = null;
$stepsDiscovered = null;
while (! feof($pipes[1])) {
$line = fgets($pipes[1]);
if ($line !== false) {
@@ -837,7 +892,13 @@ class CoreUpdateCommand extends Command
$decoded = json_decode($json, true);
if (is_array($decoded) && isset($decoded['count']) && is_int($decoded['count']) && $decoded['count'] >= 0) {
$stepsExecuted = $decoded['count'];
$log('[spawn] 실행된 step 수: '.$stepsExecuted);
// discovered 는 신버전 자식만 발행하는 필드. 구버전 자식(필드 부재)은
// null 로 남아 handleSpawnExit 가 기존(레거시) 판정 경로를 탄다.
if (isset($decoded['discovered']) && is_int($decoded['discovered']) && $decoded['discovered'] >= 0) {
$stepsDiscovered = $decoded['discovered'];
}
$log('[spawn] 실행된 step 수: '.$stepsExecuted
.($stepsDiscovered !== null ? " (발견 {$stepsDiscovered})" : ''));
continue;
}
@@ -852,6 +913,50 @@ class CoreUpdateCommand extends Command
fclose($pipes[2]);
$exitCode = proc_close($process);
return $this->handleSpawnExit(
$exitCode,
$handoffPayload,
$stepsExecuted,
$stepsDiscovered,
$fromVersion,
$toVersion,
$log,
);
}
/**
* spawn 자식 종료 후 exit code · 신호를 해석해 성공/실패/핸드오프를 판정합니다.
*
* 판정 규칙 (exit=0 경우):
* - `[STEPS_EXECUTED]` 신호 미수신 (`$stepsExecuted === null`) → fail-fast.
* 이전 버전 자식 (신호 미발행) 또는 silent skip 의심.
* - executed=0 인데 **discovered>0** → fail-fast. 범위 내에 스텝 파일이 실제로
* 존재하는데 자식이 하나도 실행하지 못함 (gnuboard/g7#28 silent skip — 케이스 A).
* - executed=0 이고 **discovered=0** → 정상 통과. 해당 from~to 범위에 스텝 파일이
* 애초에 없는 릴리즈 (예: 데이터/설정 변경 없는 패치). from<to 여도 실패 아님 (케이스 B).
* - discovered 신호 부재 (`null`, 구버전 자식) + executed=0 + from<to → 레거시 판정 유지
* (fail-fast). 신버전 자식은 항상 discovered 를 발행하므로 이 경로는 구버전 자식 한정.
*
* @param int $exitCode 자식 프로세스 종료 코드
* @param array|null $handoffPayload 파싱된 [HANDOFF] 페이로드 (없으면 null)
* @param int|null $stepsExecuted 실행된 스텝 수 ([STEPS_EXECUTED] 미수신 시 null)
* @param int|null $stepsDiscovered 범위 내 발견된 스텝 파일 수 (구버전 자식은 null)
* @param string $fromVersion 업그레이드 시작 버전
* @param string $toVersion 업그레이드 대상 버전
* @param \Closure $log 로그 수집 콜백
* @return bool spawn 성공 여부 (mode=fallback 실패 시 false — 호출자 in-process fallback)
*
* @throws UpgradeHandoffException 핸드오프 수신 또는 mode=abort 실패 시
*/
private function handleSpawnExit(
int $exitCode,
?array $handoffPayload,
?int $stepsExecuted,
?int $stepsDiscovered,
string $fromVersion,
string $toVersion,
\Closure $log,
): bool {
if ($exitCode === UpgradeHandoffException::EXIT_CODE && $handoffPayload !== null) {
$log("spawn 핸드오프 종료 (exit={$exitCode})");
@@ -866,9 +971,7 @@ class CoreUpdateCommand extends Command
}
if ($exitCode === 0) {
// silent skip 가드 — 자식이 [STEPS_EXECUTED] 신호를 발행하지 않거나, step 0건
// 실행한 채 exit=0 으로 종료한 경우. 이전 버전 자식 (beta.5 이전 디스크) 또는
// 자식이 비정상 종료 직전 silent skip 한 상태로 추정. fail-fast 모드 가드 적용.
// 신호 미수신 — 이전 버전 자식 (신호 미발행) 또는 silent skip 의심. fail-fast.
if ($stepsExecuted === null) {
return $this->failSpawnWithMode(
'spawn 자식이 [STEPS_EXECUTED] 신호 미발행 — 이전 버전 자식 또는 silent skip 의심',
@@ -878,9 +981,27 @@ class CoreUpdateCommand extends Command
);
}
if ($stepsExecuted === 0 && version_compare($fromVersion, $toVersion, '<')) {
// executed=0 + discovered=0 → 범위 내 스텝 파일 부재. 정상 통과 (케이스 B).
// 스텝이 필요 없는 릴리즈(데이터/설정 변경 없음)를 실패로 오판하지 않는다.
if ($stepsExecuted === 0 && $stepsDiscovered === 0) {
$log("spawn 완료 (exit=0, 실행할 스텝 없음 — 범위 내 스텝 파일 부재, from={$fromVersion} to={$toVersion})");
return true;
}
// executed=0 + (discovered>0 또는 discovered 신호 부재) + from<to → fail-fast.
// discovered>0: 스텝 파일이 있는데 자식이 실행 못함 (gnuboard/g7#28 케이스 A).
// discovered=null: 구버전 자식 (discovered 미발행) — 레거시 판정 유지.
if ($stepsExecuted === 0
&& $stepsDiscovered !== 0
&& version_compare($fromVersion, $toVersion, '<')) {
return $this->failSpawnWithMode(
sprintf('spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s)', $fromVersion, $toVersion),
sprintf(
'spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s%s)',
$fromVersion,
$toVersion,
$stepsDiscovered !== null ? ", 발견 {$stepsDiscovered}건" : '',
),
$log,
$fromVersion,
$toVersion,
@@ -993,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();
}
/**
* 업데이트 로그를 파일로 저장합니다.
*
@@ -1105,6 +1261,125 @@ class CoreUpdateCommand extends Command
]);
}
/**
* 업그레이드 스텝 재실행 명령을 실행 사용자 권한에 맞는 안내와 함께 출력합니다.
*
* 배경: sudo(root) 로 `core:update` 를 실행하면 업그레이드 스텝 spawn 자식이 root 로
* 돌아가고, 그 자식이 실패(proc_open 미지원 · 비정상 종료 · silent skip)하면 스텝이
* 미실행 상태로 남아 운영자에게 재실행을 안내한다. 이때 운영자가 안내받은 명령을 그대로
* root 로 재실행하면 스텝이 만드는 파일·캐시가 root 소유로 생성되어, 이후 웹서버
* (php-fpm www-data 등) 요청이 그 경로에 쓰기 실패한다.
*
* 실행 환경을 4가지로 분기해 안내한다:
* 1. non-root (일반 SSH 사용자 = 파일 소유자) 또는 posix 미지원(Windows 등)
* → 명령만 그대로 안내 (권한 분기 불필요, 공유 호스팅 = 웹서버·PHP·실행 유저 동일 포함).
* 2. root 실행 + 웹서버 계정 식별 가능 + 실행 사용자와 다름
* → `sudo -u {webUser}` 로 재실행 안내 + root 그대로 실행 시 위험 경고.
* 3. root 실행 + 웹서버 계정을 root 로 추정 (root 로 서비스하는 비표준/컨테이너 구성)
* → 권한 문제 없으므로 명령만 그대로 안내.
* 4. root 실행 + 웹서버 계정 식별 불가 (스냅샷/추정 실패)
* → 명령 그대로 안내하되 "웹서버 계정으로 실행" 일반 경고 (계정명 미상).
*
* @param string $resumeCommand 재실행할 `core:execute-upgrade-steps` 명령
*/
private function surfaceResumeCommandWithPermissionGuidance(string $resumeCommand): void
{
[$mode, $webUser] = $this->classifyResumeExecutionContext();
$this->renderResumeGuidance($resumeCommand, $mode, $webUser);
}
/**
* 실행 환경 모드에 따라 재실행 명령과 권한 안내를 콘솔에 출력합니다.
*
* `classifyResumeExecutionContext()` 판정 결과를 입력으로 받아 출력만 담당한다 —
* posix/파일시스템 상태에 의존하지 않으므로 4가지 모드 전부 결정적으로 테스트 가능하다.
*
* @param string $resumeCommand 재실행할 `core:execute-upgrade-steps` 명령
* @param string $mode `classifyResumeExecutionContext()` 가 반환한 실행 환경 모드
* @param string|null $webUser 웹서버 계정명 (`root_web_known` 일 때만 유효)
*/
private function renderResumeGuidance(string $resumeCommand, string $mode, ?string $webUser): void
{
// 케이스 1·3: 권한 분기 불필요 — 명령만 그대로 안내.
// - non_root: 일반 SSH 사용자 / 공유 호스팅(웹서버=PHP=실행 유저 동일) / posix 미지원
// - root_web_symmetric: root 로 서비스하는 구성 → 재실행도 root 로 무해
if ($mode === 'non_root' || $mode === 'root_web_symmetric') {
$this->line(" {$resumeCommand}");
return;
}
// 케이스 2: root 실행 + 웹서버 계정 식별 → 웹서버 권한 재실행 안내.
if ($mode === 'root_web_known' && $webUser !== null) {
$this->line(" sudo -u {$webUser} {$resumeCommand}");
$this->newLine();
$this->warn('⚠ 현재 sudo(root) 로 실행 중입니다. 위 명령을 root 로 그대로 실행하면 업그레이드');
$this->warn(" 스텝이 생성하는 파일이 root 소유가 되어 이후 웹서버({$webUser}) 요청이 쓰기 실패할 수");
$this->warn(" 있습니다. 반드시 웹서버 계정({$webUser})으로 재실행하세요.");
return;
}
// 케이스 4: root 실행 + 웹서버 계정 식별 불가 — 계정명 미상 상태의 일반 경고.
$this->line(" sudo -u <웹서버계정> {$resumeCommand}");
$this->newLine();
$this->warn('⚠ 현재 sudo(root) 로 실행 중입니다. 웹서버 계정을 자동으로 식별하지 못했습니다.');
$this->warn(' 위 명령을 root 로 그대로 실행하면 업그레이드 스텝이 생성하는 파일이 root 소유가 되어');
$this->warn(' 이후 웹서버(php-fpm) 요청이 쓰기 실패할 수 있습니다. 웹서버 계정(예: www-data,');
$this->warn(' nginx, apache)을 확인해 그 계정으로 재실행하세요.');
}
/**
* 재실행 안내의 실행 환경을 분류합니다.
*
* 반환하는 모드:
* - `non_root` : posix 미지원 또는 현재 유효 사용자가 root 가 아님
* (일반 SSH 사용자 = 파일 소유자, 공유 호스팅 대칭 구성 포함).
* - `root_web_known` : root 실행 + 웹서버 계정 식별 성공 + 실행 사용자(root)와 다름.
* - `root_web_symmetric` : root 실행 + 웹서버 계정이 root 로 추정됨 (root 서비스 구성).
* - `root_web_unknown` : root 실행 + 웹서버 계정 추정 실패 (스냅샷/추정 불가).
*
* 웹서버 계정은 `FilePermissionHelper::inferWebServerOwnership()` 이 storage/bootstrap
* 쓰기 영역 소유자로 추정한다.
*
* @return array{0: string, 1: string|null} [모드, 웹서버 계정명 또는 null]
*/
private function classifyResumeExecutionContext(): array
{
if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) {
return ['non_root', null];
}
// root(sudo) 실행이 아니면 일반 SSH 사용자 = 파일 소유자 → 권한 분기 안내 불필요.
// (공유 호스팅에서 웹서버·PHP·실행 유저가 같은 경우도 여기서 non_root 로 처리됨.)
if (posix_geteuid() !== 0) {
return ['non_root', null];
}
[$owner] = FilePermissionHelper::inferWebServerOwnership();
// 추정 실패 (스냅샷 불가) — 계정명 미상 경고 경로.
if ($owner === false) {
return ['root_web_unknown', null];
}
// 웹서버 계정이 root 로 추정됨 — root 로 서비스하는 구성이라 재실행도 root 로 무해.
if ($owner === 0) {
return ['root_web_symmetric', null];
}
$entry = posix_getpwuid($owner);
$name = $entry['name'] ?? null;
// uid 는 나왔지만 이름 해석 실패 — 미상 경로로 처리 (uid 노출은 오히려 혼란).
if ($name === null) {
return ['root_web_unknown', null];
}
return ['root_web_known', $name];
}
/**
* `restoreOwnership()` 직후 누적된 권한 정상화 실패 경고를 콘솔/로그에 즉시 노출합니다.
*
@@ -164,6 +164,7 @@ class ExecuteUpgradeStepsCommand extends Command
}
$stepsExecuted = 0;
$stepsDiscovered = 0;
try {
$service->runUpgradeSteps(
@@ -174,6 +175,9 @@ class ExecuteUpgradeStepsCommand extends Command
$stepsExecuted++;
},
$force,
function (int $discovered) use (&$stepsDiscovered): void {
$stepsDiscovered = $discovered;
},
);
} catch (UpgradeHandoffException $e) {
// 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청했다.
@@ -221,6 +225,7 @@ class ExecuteUpgradeStepsCommand extends Command
// mode=fallback 시 in-process 진행 (`spawn_failure_mode`).
$this->line('[STEPS_EXECUTED] '.json_encode([
'count' => $stepsExecuted,
'discovered' => $stepsDiscovered,
'fromVersion' => $from,
'toVersion' => $to,
], JSON_UNESCAPED_UNICODE));
@@ -242,6 +247,11 @@ class ExecuteUpgradeStepsCommand extends Command
if (! $this->option('skip-cache-clear') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('캐시 정리 (config/route/view/services/packages)');
$service->clearAllCaches();
// clearAllCaches() 는 config:clear 만 하므로, 단독 실행 흐름에서는 config 캐시가
// 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를
// 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다.
// spawn 자식·steps-only 는 이 블록을 스킵하고 부모(CoreUpdateCommand)가 재생성한다.
\App\Support\ConfigCacheHelper::rebuild();
// 업그레이드 스텝 단독 실행 시에도 코어 lang/routes/layout 변경이
// 프론트엔드 캐시 stale 로 가려지지 않도록 `ext.cache_version` bump.
// spawn 자식·steps-only 모드에서는 부모가 처리하므로 스킵.
@@ -3,6 +3,9 @@
namespace App\Console\Commands\Extension;
use App\Extension\ExtensionManager;
use App\Extension\HookCacheManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
class UpdateAutoloadCommand extends Command
@@ -24,8 +27,12 @@ class UpdateAutoloadCommand extends Command
/**
* Execute the console command.
*/
public function handle(ExtensionManager $extensionManager): int
{
public function handle(
ExtensionManager $extensionManager,
HookCacheManager $hookCacheManager,
ModuleManager $moduleManager,
PluginManager $pluginManager,
): int {
$this->info('확장 오토로드 파일을 생성합니다...');
try {
@@ -34,6 +41,16 @@ class UpdateAutoloadCommand extends Command
$this->info('오토로드 파일이 성공적으로 생성되었습니다.');
$this->line(' → bootstrap/cache/autoload-extensions.php');
// 오토로드 캐시와 동일 생명주기의 정적 훅 매핑 캐시도 함께 재생성.
// 코어 업데이트(clearAllCaches → 이 커맨드) 시 코어 리스너 추가/변경/삭제가
// 캐시에 반영되도록 보장한다 (미갱신 시 stale 매핑으로 훅 발화 누락/과잉 위험).
$moduleManager->loadModules();
$pluginManager->loadPlugins();
$hookCacheManager->generate($moduleManager, $pluginManager);
$this->info('정적 훅 매핑 캐시가 재생성되었습니다.');
$this->line(' → bootstrap/cache/hooks.php');
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error('오토로드 파일 생성 중 오류가 발생했습니다: '.$e->getMessage());
@@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use App\Extension\HookCacheManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
/**
* 정적 훅 매핑 캐시 생성 Artisan 커맨드.
*
* 코어(app/Listeners) + 활성 모듈/플러그인의 정적 훅 리스너를 사전 계산하여
* bootstrap/cache/hooks.php 에 저장한다. route:cache 동형 — 코어 리스너를
* 추가/변경하는 코드 배포 시 배포 파이프라인에서 실행한다.
* (확장 install/update 시에는 ExtensionManager 가 자동 재생성하므로 수동 실행 불필요.)
*/
class HooksCacheCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'hooks:cache';
/**
* @var string 커맨드 설명
*/
protected $description = '정적 훅 매핑 캐시 생성 (bootstrap/cache/hooks.php)';
/**
* 커맨드를 실행합니다.
*
* @param HookCacheManager $hookCacheManager 훅 캐시 매니저
* @param ModuleManager $moduleManager 모듈 매니저
* @param PluginManager $pluginManager 플러그인 매니저
* @return int 종료 코드
*/
public function handle(
HookCacheManager $hookCacheManager,
ModuleManager $moduleManager,
PluginManager $pluginManager,
): int {
$moduleManager->loadModules();
$pluginManager->loadPlugins();
$counts = $hookCacheManager->generate($moduleManager, $pluginManager);
$this->info(__('hooks.cache_generated', [
'core' => $counts['core'],
'modules' => $counts['modules'],
'plugins' => $counts['plugins'],
'path' => $hookCacheManager->getCacheFilePath(),
]));
return Command::SUCCESS;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands;
use App\Extension\HookCacheManager;
use Illuminate\Console\Command;
/**
* 정적 훅 매핑 캐시 삭제 Artisan 커맨드.
*
* bootstrap/cache/hooks.php 를 삭제한다. 캐시 삭제 후에는 부팅 시 스캔 폴백으로
* 동작하므로 항상 안전하다 (route:clear 동형).
*/
class HooksClearCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'hooks:clear';
/**
* @var string 커맨드 설명
*/
protected $description = '정적 훅 매핑 캐시 삭제 (bootstrap/cache/hooks.php)';
/**
* 커맨드를 실행합니다.
*
* @param HookCacheManager $hookCacheManager 훅 캐시 매니저
* @return int 종료 코드
*/
public function handle(HookCacheManager $hookCacheManager): int
{
if (! $hookCacheManager->isCached()) {
$this->info(__('hooks.cache_absent'));
return Command::SUCCESS;
}
$hookCacheManager->clear();
$this->info(__('hooks.cache_cleared'));
return Command::SUCCESS;
}
}
@@ -189,8 +189,13 @@ class BuildModuleCommand extends Command
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 빌드 실행
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
$result = $this->runNpmCommand(
$buildCommand,
$buildPath,
! $watchMode,
$watchMode ? [] : $this->buildEnv($productionMode)
);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info("✅ 빌드 완료: {$identifier}");
@@ -338,15 +343,30 @@ class BuildModuleCommand extends Command
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
*
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
*
* @param bool $productionMode 프로덕션 빌드 여부
* @return array<string, string> Process 에 주입할 환경변수
*/
private function buildEnv(bool $productionMode): array
{
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
}
/**
* npm 명령 실행
*
* @param array $command 실행할 명령
* @param string $cwd 작업 디렉토리
* @param bool $waitForCompletion 완료 대기 여부
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
* @return int 명령 실행 결과 코드
*/
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
{
// Windows 환경에서는 cmd /c 사용
if (PHP_OS_FAMILY === 'Windows') {
@@ -355,6 +375,11 @@ class BuildModuleCommand extends Command
$process = new Process($command);
$process->setWorkingDirectory($cwd);
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
if ($env !== []) {
$process->setEnv($env);
}
$process->setTimeout(null); // 타임아웃 없음
if ($waitForCompletion) {
@@ -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]));
}
@@ -189,8 +189,13 @@ class BuildPluginCommand extends Command
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 빌드 실행
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
$result = $this->runNpmCommand(
$buildCommand,
$buildPath,
! $watchMode,
$watchMode ? [] : $this->buildEnv($productionMode)
);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info("✅ 빌드 완료: {$identifier}");
@@ -338,15 +343,30 @@ class BuildPluginCommand extends Command
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
*
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
*
* @param bool $productionMode 프로덕션 빌드 여부
* @return array<string, string> Process 에 주입할 환경변수
*/
private function buildEnv(bool $productionMode): array
{
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
}
/**
* npm 명령 실행
*
* @param array $command 실행할 명령
* @param string $cwd 작업 디렉토리
* @param bool $waitForCompletion 완료 대기 여부
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
* @return int 명령 실행 결과 코드
*/
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
{
// Windows 환경에서는 cmd /c 사용
if (PHP_OS_FAMILY === 'Windows') {
@@ -355,6 +375,11 @@ class BuildPluginCommand extends Command
$process = new Process($command);
$process->setWorkingDirectory($cwd);
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
if ($env !== []) {
$process->setEnv($env);
}
$process->setTimeout(null); // 타임아웃 없음
if ($waitForCompletion) {
@@ -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]));
}
@@ -181,8 +181,13 @@ class BuildTemplateCommand extends Command
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 빌드 실행
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
$result = $this->runNpmCommand(
$buildCommand,
$buildPath,
! $watchMode,
$watchMode ? [] : $this->buildEnv($productionMode)
);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info("✅ 빌드 완료: {$identifier}");
@@ -285,15 +290,30 @@ class BuildTemplateCommand extends Command
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
*
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
*
* @param bool $productionMode 프로덕션 빌드 여부
* @return array<string, string> Process 에 주입할 환경변수
*/
private function buildEnv(bool $productionMode): array
{
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
}
/**
* npm 명령 실행
*
* @param array $command 실행할 명령
* @param string $cwd 작업 디렉토리
* @param bool $waitForCompletion 완료 대기 여부
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
* @return int 명령 실행 결과 코드
*/
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
{
// Windows 환경에서는 cmd /c 사용
if (PHP_OS_FAMILY === 'Windows') {
@@ -302,6 +322,11 @@ class BuildTemplateCommand extends Command
$process = new Process($command);
$process->setWorkingDirectory($cwd);
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
if ($env !== []) {
$process->setEnv($env);
}
$process->setTimeout(null); // 타임아웃 없음
if ($waitForCompletion) {
@@ -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,
]));
@@ -0,0 +1,33 @@
<?php
namespace App\Contracts\ApiDoc;
/**
* API 문서 실측용 완전 샘플 시더 계약
*
* 확장(모듈/플러그인)이 자신의 API 도메인 대표 엔티티에 완전한 샘플 레코드를
* 멱등하게 생성하고, docgen 이 상세 GET 실측(path 파라미터 치환)에 사용할
* 도메인별 대표 route key 맵을 제공하기 위한 인터페이스입니다.
*
* 확장은 이 인터페이스를 구현한 클래스를 `{Namespace}\Support\ApiDoc\ApiDocSampleService`
* 규약 위치에 두며, `api:docgen --scope=module:{id}` 실행 시 커맨드가 자동으로 발견합니다.
* 코어의 `App\Support\ApiDoc\ApiDocSampleService` 도 동일 계약을 구현합니다.
*/
interface ApiDocSampleSeeder
{
/**
* 도메인별 완전 샘플을 멱등 생성하고 대표 route key 맵을 반환합니다.
*
* 반환 맵의 키는 라우트 도메인 그룹명(`domain_group`, 예: pages)이며,
* 값은 그 도메인의 상세 GET path 파라미터를 실측 가능한 실제 레코드로
* 치환하기 위한 모델 FQCN·route key 이름·route key 값입니다.
*
* 선택 필드 `path_params`(param 명 => 실제 값 맵)를 두면, route-model binding
* 도 도메인 폴백도 없는 문자열 path 파라미터(예: 게시판 slug 라우팅
* `boards/{slug}/posts/{id}`)를 param 명 정확 일치로 치환해 상세 GET 을
* 실측할 수 있습니다. 미제공 시 route key 기반 자동 치환만 적용됩니다.
*
* @return array<string, array{model: class-string, key: string, value: string, path_params?: array<string, string>}> 도메인 => 대표 레코드 정보
*/
public function seed(): array;
}
+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;
}
/**
* 플러그인이 설정 페이지를 가지고 있는지 확인합니다.
*
+189 -18
View File
@@ -5,6 +5,9 @@ namespace App\Extension;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Extension\Helpers\GithubHelper;
use App\Rules\ValidExtensionIdentifier;
use App\Support\ConfigCacheHelper;
use Composer\Autoload\ClassLoader;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
@@ -37,14 +40,6 @@ class ExtensionManager
$this->autoloadFilePath = base_path('bootstrap/cache/autoload-extensions.php');
}
/**
* 설치된 모듈/플러그인의 오토로드 파일을 생성합니다.
*
* composer.json을 수정하지 않고, bootstrap/cache/autoload-extensions.php 파일을 생성하여
* 런타임에 Composer ClassLoader에 PSR-4 네임스페이스를 등록합니다.
*
* 테스트 환경(APP_ENV=testing)에서는 자동으로 스킵됩니다.
*/
public function updateComposerAutoload(): void
{
// 테스트 환경에서 오토로드 업데이트 스킵 (성능 최적화)
@@ -61,6 +56,41 @@ class ExtensionManager
// 신규 네임스페이스(beta 업그레이드로 추가된 Seeder/Model 등) 의 autoload 가
// 실패하지 않도록 런타임 재등록을 수행한다.
$this->reregisterRuntimeAutoload();
// 정적 훅 매핑 캐시도 오토로드 캐시와 나란히 재생성.
// 훅 매핑은 확장 install/activate/deactivate/uninstall/update 시에만 바뀌므로
// 이 지점에 편승하면 별도 무효화 발굴 없이 오토로드 캐시와 동일 생명주기를 갖는다.
$this->regenerateHookCache();
// config 캐시도 함께 재생성. 확장 설치/삭제/업데이트는 활성 확장 목록을 바꾸므로
// (optimizeSystem 이 config:cache 를 만든 시점의 스냅샷이 stale 해짐), 오토로드/훅
// 캐시와 동일 생명주기로 config 캐시를 재빌드해 캐시가 비활성 상태로 남지 않게 한다.
// activate/deactivate 는 이 메서드를 거치지 않으므로 각 Manager 에서 별도 호출한다.
ConfigCacheHelper::rebuild();
}
/**
* 정적 훅 매핑 캐시(bootstrap/cache/hooks.php)를 재생성합니다.
*
* 모듈/플러그인 리스너 수집을 위해 각 Manager 를 (재)로드한 뒤 HookCacheManager 에 위임한다.
* 생성 실패는 부팅 시 스캔 폴백으로 흡수되므로 확장 업데이트 흐름을 중단시키지 않는다.
*/
protected function regenerateHookCache(): void
{
try {
$moduleManager = app(ModuleManager::class);
$moduleManager->loadModules();
$pluginManager = app(PluginManager::class);
$pluginManager->loadPlugins();
app(HookCacheManager::class)->generate($moduleManager, $pluginManager);
} catch (\Throwable $e) {
// 훅 캐시 생성 실패는 치명적이지 않다 — 부팅 시 스캔 폴백이 안전망.
Log::warning('훅 매핑 캐시 재생성 실패 (스캔 폴백으로 동작)', [
'error' => $e->getMessage(),
]);
}
}
/**
@@ -78,7 +108,7 @@ class ExtensionManager
*/
protected function reregisterRuntimeAutoload(): void
{
if (! class_exists(\Composer\Autoload\ClassLoader::class, false)) {
if (! class_exists(ClassLoader::class, false)) {
return;
}
@@ -86,7 +116,7 @@ class ExtensionManager
return;
}
$loaders = \Composer\Autoload\ClassLoader::getRegisteredLoaders();
$loaders = ClassLoader::getRegisteredLoaders();
if (empty($loaders)) {
return;
}
@@ -135,8 +165,13 @@ class ExtensionManager
$pluginAutoloads['vendor_autoloads']
);
// 확장 소스 classmap 생성 (FQCN → 상대경로).
// 런타임에 ClassLoader::addClassMap() 으로 등록되어 findFile 이 파일시스템 스캔 없이
// 즉시 경로를 반환하도록 한다(성능). 클래스 로딩은 여전히 lazy — 사용 시점에만 include.
$srcClassmap = $this->buildSourceClassmap($psr4);
// 파일 내용 생성
$content = $this->buildAutoloadFileContent($psr4, $classmap, $files, $vendorAutoloads);
$content = $this->buildAutoloadFileContent($psr4, $classmap, $files, $vendorAutoloads, $srcClassmap);
// 디렉토리 확인
$dir = dirname($this->autoloadFilePath);
@@ -151,6 +186,7 @@ class ExtensionManager
'path' => $this->autoloadFilePath,
'psr4_count' => count($psr4),
'classmap_count' => count($classmap),
'src_classmap_count' => count($srcClassmap),
'files_count' => count($files),
'vendor_autoloads_count' => count($vendorAutoloads),
]);
@@ -164,7 +200,7 @@ class ExtensionManager
* @param array $files 헬퍼 파일 목록
* @return string PHP 파일 내용
*/
protected function buildAutoloadFileContent(array $psr4, array $classmap, array $files = [], array $vendorAutoloads = []): string
protected function buildAutoloadFileContent(array $psr4, array $classmap, array $files = [], array $vendorAutoloads = [], array $srcClassmap = []): string
{
$generatedAt = now()->toDateTimeString();
@@ -173,12 +209,14 @@ class ExtensionManager
$classmapJson = json_encode($classmap, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$filesJson = json_encode($files, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$vendorAutoloadsJson = json_encode($vendorAutoloads, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$srcClassmapJson = json_encode($srcClassmap, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// JSON을 PHP 배열 문법으로 변환
$psr4Php = $this->jsonToPhpArray($psr4Json, 1);
$classmapPhp = $this->jsonToPhpArray($classmapJson, 1);
$filesPhp = $this->jsonToPhpArray($filesJson, 1);
$vendorAutoloadsPhp = $this->jsonToPhpArray($vendorAutoloadsJson, 1);
$srcClassmapPhp = $this->jsonToPhpArray($srcClassmapJson, 1);
return <<<PHP
<?php
@@ -195,6 +233,7 @@ class ExtensionManager
return [
'psr4' => {$psr4Php},
'classmap' => {$classmapPhp},
'src_classmap' => {$srcClassmapPhp},
'files' => {$filesPhp},
'vendor_autoloads' => {$vendorAutoloadsPhp},
];
@@ -240,7 +279,7 @@ PHP;
*
* public/index.php 및 artisan에서 호출됩니다.
*
* @param \Composer\Autoload\ClassLoader $loader Composer ClassLoader 인스턴스
* @param ClassLoader $loader Composer ClassLoader 인스턴스
*/
public static function registerExtensionAutoload($loader): void
{
@@ -266,6 +305,17 @@ PHP;
}
}
// 확장 소스 classmap 등록 (FQCN → 절대경로).
// findFile 이 파일시스템 스캔 없이 즉시 반환하도록 in-memory 맵에 추가한다(lazy include).
// PSR-4 등록은 위에서 유지되므로 classmap 에 없는 클래스는 PSR-4 폴백(안전망).
if (! empty($autoloads['src_classmap'])) {
$absoluteClassmap = [];
foreach ($autoloads['src_classmap'] as $fqcn => $relPath) {
$absoluteClassmap[$fqcn] = base_path($relPath);
}
$loader->addClassMap($absoluteClassmap);
}
// Classmap 파일 로드 (module.php, plugin.php)
if (! empty($autoloads['classmap'])) {
foreach ($autoloads['classmap'] as $file) {
@@ -471,6 +521,130 @@ PHP;
return ['psr4' => $psr4, 'classmap' => $classmap, 'files' => $files, 'vendor_autoloads' => $vendorAutoloads];
}
/**
* PSR-4 매핑을 기반으로 확장 소스 클래스의 classmap 을 생성합니다.
*
* 각 PSR-4 네임스페이스 디렉토리를 재귀 스캔하여 PHP 파일마다 FQCN → 상대경로 를
* 산출한다. 이 classmap 은 런타임에 `ClassLoader::addClassMap()` 으로 등록되어
* `findFile()` 이 파일시스템 스캔 없이 즉시 경로를 반환하도록 한다(성능: cold OPcache /
* 느린 파일시스템 환경에서 findFile stat 비용 제거). 클래스 로딩은 여전히 지연(lazy)이다.
*
* 확장 **소스** 클래스만 대상으로 하며, 확장별 독립 vendor(서드파티)는 각 확장의
* `vendor/autoload.php`(별도 ClassLoader) 가 담당하므로 여기서 다루지 않는다
* (vendor 격리 불변조건 보존).
*
* @param array<string, string|array<int, string>> $psr4 네임스페이스 → base_path 상대경로(들)
* @return array<string, string> FQCN → base_path 상대경로 (.php 포함)
*/
protected function buildSourceClassmap(array $psr4): array
{
$classmap = [];
foreach ($psr4 as $namespace => $paths) {
foreach ((array) $paths as $relativePath) {
$absoluteDir = base_path($relativePath);
if (! is_dir($absoluteDir)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($absoluteDir, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$fqcn = $this->extractFqcnFromFile($file->getPathname());
if ($fqcn === null) {
continue;
}
// PSR-4 계약상 이 파일의 네임스페이스는 $namespace 로 시작해야 한다.
// (다른 네임스페이스 파일은 다른 PSR-4 항목이 담당 — 중복 방지)
if (! str_starts_with($fqcn, rtrim($namespace, '\\'))) {
continue;
}
// base_path 상대경로로 정규화 (autoload-extensions.php 의 다른 경로와 일관)
$rel = str_replace('\\', '/', substr($file->getPathname(), strlen(base_path()) + 1));
$classmap[$fqcn] = $rel;
}
}
}
ksort($classmap);
return $classmap;
}
/**
* PHP 파일에서 FQCN(네임스페이스 + 클래스/인터페이스/트레이트/enum 명)을 추출합니다.
*
* 토큰 기반 파싱으로 파일당 최상위 선언 1개의 FQCN 을 반환한다. 선언이 없으면 null.
*
* @param string $filePath PHP 파일 절대경로
* @return string|null FQCN 또는 null
*/
protected function extractFqcnFromFile(string $filePath): ?string
{
$contents = @file_get_contents($filePath);
if ($contents === false) {
return null;
}
$tokens = token_get_all($contents);
$namespace = '';
$count = count($tokens);
for ($i = 0; $i < $count; $i++) {
$token = $tokens[$i];
if (! is_array($token)) {
continue;
}
// 네임스페이스 수집
if ($token[0] === T_NAMESPACE) {
$namespace = '';
for ($j = $i + 1; $j < $count; $j++) {
$t = $tokens[$j];
if ($t === ';' || $t === '{') {
break;
}
if (is_array($t) && in_array($t[0], [T_STRING, T_NS_SEPARATOR], true)) {
$namespace .= $t[1];
} elseif (is_array($t) && defined('T_NAME_QUALIFIED') && $t[0] === T_NAME_QUALIFIED) {
$namespace .= $t[1];
}
}
continue;
}
// 최상위 선언(class/interface/trait/enum) 감지
if (in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT], true)
|| (defined('T_ENUM') && $token[0] === T_ENUM)) {
// ::class, new class 등 익명/상수 사용 배제: 다음 유효 토큰이 T_STRING 이어야 함
for ($j = $i + 1; $j < $count; $j++) {
$t = $tokens[$j];
if (is_array($t) && $t[0] === T_WHITESPACE) {
continue;
}
if (is_array($t) && $t[0] === T_STRING) {
$class = $t[1];
return $namespace !== '' ? $namespace.'\\'.$class : $class;
}
break;
}
}
}
return null;
}
/**
* 모듈 identifier를 네임스페이스로 변환합니다.
*
@@ -640,7 +814,7 @@ PHP;
$failed = false;
$message = '';
(new \App\Rules\ValidExtensionIdentifier)->validate(
(new ValidExtensionIdentifier)->validate(
'identifier',
$identifier,
function ($msg) use (&$failed, &$message) {
@@ -718,7 +892,6 @@ PHP;
return ! empty($this->getComposerDependenciesAt($path));
}
/**
* 확장의 composer.json에서 외부 패키지 의존성 목록을 반환합니다.
*
@@ -1096,7 +1269,6 @@ PHP;
*
* @param string $zipPath ZIP 파일 경로
* @param string $extractDir 추출 대상 디렉토리
* @return void
*
* @throws \RuntimeException 추출 실패 시
*/
@@ -1116,7 +1288,6 @@ PHP;
*
* @param string $zipPath ZIP 파일 경로
* @param string $extractDir 추출 대상 디렉토리
* @return void
*
* @throws \RuntimeException 추출 실패 시
*/
@@ -1138,7 +1309,7 @@ PHP;
/**
* unzip 명령어 사용 가능 여부를 확인합니다.
*
* @return bool
* @return bool unzip 실행 파일이 PATH 에 존재하면 true
*/
public function isUnzipAvailable(): bool
{
+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;
}
/**
+108 -30
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)) {
// 신규 디렉토리: 부모 디렉토리의 퍼미션/소유권 상속
@@ -53,21 +58,36 @@ class FilePermissionHelper
$destPath = $destination.DIRECTORY_SEPARATOR.$itemName;
// symlink 자체 보존: target 추적 없이 동일 symlink 재생성.
// symlink / Windows junction 자체 보존: target 추적 없이 동일 링크 재생성.
// SplFileInfo::isDir() 가 symlink 를 추적하므로 검사 순서가 isDir() 보다 먼저여야 한다.
// 미적용 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상.
if ($item->isLink()) {
if (static::copySymlink($item->getPathname(), $destPath)) {
// 미적용 시 `public/storage` 등 링크가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상.
//
// Windows JUNCTION(`mklink /J`)은 reparse point 지만 PHP `isLink()`/`isDir()` 이 모두
// false 를 반환한다(`storage:link` 가 Windows 에서 생성하는 형태). isLink() 만 검사하면
// junction 이 파일로 오판되어 copyFile → `copy(디렉토리)` 로 코어 업데이트가 실패한다.
// isReparsePoint() 로 junction 도 이 분기에 포함시킨다.
if ($item->isLink() || static::isReparsePoint($item->getPathname())) {
if (static::copyLink($item->getPathname(), $destPath)) {
continue;
}
// 복원 실패 (Windows SeCreateSymbolicLink 권한 부족 등) — 일반 복사로 fall-through.
// 단, junction 은 isDir() 이 false 라 아래 else 분기의 copyFile 로 새어 파일 복사가
// 다시 실패하므로, fall-through 대신 skip 하여 손상 없이 넘어간다.
// 운영자가 추후 `php artisan storage:link` 수동 실행으로 회복 가능.
if (! $item->isLink()) {
continue;
}
}
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);
}
}
@@ -79,25 +99,63 @@ class FilePermissionHelper
}
/**
* symlink 자체를 보존 복사합니다 (target 미추적).
* 경로가 Windows JUNCTION 등 PHP `isLink()` 가 인식하지 못하는 reparse point 인지 판정합니다.
*
* 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 symlink 재생성.
* readlink / symlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through.
* Windows JUNCTION(`mklink /J`)은 디렉토리 reparse point 로, `is_link()` / `is_file()` /
* `is_dir()` 이 **모두** false 를 반환한다(그러나 경로 항목으로는 존재하며 `readlink()` 로
* target 을 얻을 수 있다). `php artisan storage:link` 가 Windows 에서 생성하는
* `public/storage` 가 이 형태다.
*
* Windows 환경에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하며 일반 사용자는
* 권한이 없을 가능성이 높다. 실패 시 warning 로그 후 false 반환하여 호출자가 일반 복사로
* fall-through 하도록 한다. 운영자는 업그레이드 후 `php artisan storage:link` 등 수동
* 명령으로 symlink 를 회복할 수 있다.
* 주의: Windows 의 `readlink()` 는 일반 파일/디렉토리에도 자기 경로를 반환하므로 junction
* 판별 기준이 될 수 없다. 따라서 "존재하는 경로인데 link/file/dir 어디에도 해당하지 않음"
* 을 판정 기준으로 삼는다. 여기에 추가로 `readlink()` 성공(!== false)을 요구해 존재하지
* 않는 경로를 배제한다.
*
* @param string $source 소스 symlink 경로
* @param string $destination 대상 symlink 경로
* @return bool symlink 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백)
* 일반 symlink(`is_link()` true)는 호출부가 별도로 처리하므로 여기서는 false 를 반환한다.
* junction 은 Windows 전용이므로 비-Windows 에서는 항상 false.
*
* @param string $path 검사할 경로
* @return bool junction 등 isLink() 미인식 reparse point 여부
*/
protected static function copySymlink(string $source, string $destination): bool
protected static function isReparsePoint(string $path): bool
{
if (PHP_OS_FAMILY !== 'Windows') {
return false;
}
// 일반 symlink / 파일 / 디렉토리는 각 검사가 true → junction 아님.
if (is_link($path) || is_file($path) || is_dir($path)) {
return false;
}
// 위 셋에 모두 해당하지 않으면서 readlink 가 target 을 반환하면 junction(reparse point).
return @readlink($path) !== false;
}
/**
* symlink 또는 Windows junction 자체를 보존 복사합니다 (target 미추적).
*
* 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 링크 재생성.
* readlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through.
*
* 재생성 전략:
* - 일반 symlink: `symlink()`
* - target 이 디렉토리인 경우(Windows junction 포함): `symlink()` 실패 시 `mklink /J` 로 폴백.
* Windows 에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하지만 junction 은
* 권한 없이 생성 가능하므로, junction 복원의 실질 경로가 된다.
*
* 모든 재생성 시도 실패 시 warning 로그 후 false 반환. 운영자는 업그레이드 후
* `php artisan storage:link` 등 수동 명령으로 링크를 회복할 수 있다.
*
* @param string $source 소스 링크(symlink/junction) 경로
* @param string $destination 대상 링크 경로
* @return bool 링크 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백)
*/
protected static function copyLink(string $source, string $destination): bool
{
$target = @readlink($source);
if ($target === false) {
Log::warning('copySymlink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]);
Log::warning('copyLink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]);
return false;
}
@@ -109,17 +167,29 @@ class FilePermissionHelper
File::deleteDirectory($destination);
}
if (! @symlink($target, $destination)) {
Log::warning('copySymlink: symlink 생성 실패 — 일반 복사로 폴백', [
'source' => $source,
'destination' => $destination,
'target' => $target,
]);
return false;
if (@symlink($target, $destination)) {
return true;
}
return true;
// symlink 실패 + target 이 디렉토리 → Windows junction 으로 폴백 재생성.
if (PHP_OS_FAMILY === 'Windows' && is_dir($target)) {
exec(
'cmd /c mklink /J '.escapeshellarg($destination).' '.escapeshellarg($target).' 2>&1',
$out,
$code
);
if ($code === 0 && is_dir($destination)) {
return true;
}
}
Log::warning('copyLink: 링크 생성 실패 — 일반 복사로 폴백', [
'source' => $source,
'destination' => $destination,
'target' => $target,
]);
return false;
}
/**
@@ -157,11 +227,19 @@ class FilePermissionHelper
// 소스에 존재하지 않는 항목만 삭제
if (! File::exists($srcPath) && ! File::isDirectory($srcPath)) {
// symlink 는 항상 unlink — File::deleteDirectory 는 is_dir() 추적 검사 후
// 재귀 삭제하므로 symlink-to-dir 인 경우 target 의 모든 파일을 삭제하는 사고
// 발생 가능. is_link() 가 isDir() 보다 먼저 평가되어야 한다.
// symlink / Windows junction 은 링크 자체만 제거 — File::deleteDirectory 는 is_dir()
// 추적 검사 후 재귀 삭제하므로 link-to-dir 인 경우 target 의 모든 파일을 삭제하는
// 사고 발생 가능. 링크 검사가 isDir() 보다 먼저 평가되어야 한다.
//
// junction 은 is_link() 가 false 지만 isReparsePoint() 가 감지한다. junction 링크
// 제거는 파일 대상 unlink 가 아닌 rmdir() 로 수행해야 target 내용을 보존한다.
if (is_link($destItem->getPathname())) {
@unlink($destItem->getPathname());
} elseif (static::isReparsePoint($destItem->getPathname())) {
// junction: rmdir 로 링크만 제거 (target 미추적). 폴백으로 unlink 시도.
if (! @rmdir($destItem->getPathname())) {
@unlink($destItem->getPathname());
}
} elseif ($destItem->isDir()) {
File::deleteDirectory($destItem->getPathname());
} else {
+320
View File
@@ -0,0 +1,320 @@
<?php
namespace App\Extension;
use App\Contracts\Extension\HookListenerInterface;
use App\Contracts\Extension\ModuleInterface;
use App\Contracts\Extension\PluginInterface;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 정적 훅 매핑 캐시 관리자.
*
* 코어(app/Listeners 재귀 스캔) + 모듈/플러그인(getHookListeners)의 정적 훅 리스너를
* 사전 계산하여 bootstrap/cache/hooks.php 에 저장하고, 부팅 시 이 캐시를 읽어
* 매 요청 디렉토리 스캔·리플렉션·getSubscribedHooks() 클래스 로딩을 제거한다.
*
* 캐시는 "무엇을 등록할지 목록"만 제공하며, 실제 등록은 여전히 boot 에서 수행되므로
* 등록↔발화 순서 계약은 불변이다. 캐시 미스(파일 부재/설치 직후/캐시 클리어 후)는
* 항상 스캔 폴백으로 안전하게 동작한다.
*
* 오토로드 캐시(autoload-extensions.php)와 동일 위치·생명주기를 갖는다:
* 확장 install/activate/deactivate/uninstall/update 시
* ExtensionManager::updateComposerAutoload() 가 함께 재생성한다.
*
* @see HookListenerRegistrar::registerFromCache()
*/
class HookCacheManager
{
/**
* 캐시 파일 경로.
*/
protected string $cacheFilePath;
/**
* Request-scoped 메모리 캐시 (read() 결과).
*
* HookCacheManager 는 싱글톤이 아니라 부팅 경로에서 매번 새 인스턴스로
* 생성되므로(app(HookCacheManager::class)), 요청 단위 캐시는 static 이어야 한다.
* 세 상태를 구분한다:
* - null = 아직 read() 전 (미초기화). PHP static 프로퍼티는 unset() 불가하므로
* 무효화(generate/clear)는 이 값으로 되돌린다.
* - false = 이번 요청 캐시 미스 확정 (파일 부재/손상) — 재시도 없이 폴백 유지.
* - array = 유효 캐시 데이터.
*
* @var array{core: array, modules: array, plugins: array}|false|null
*/
private static array|false|null $memo = null;
public function __construct()
{
$this->cacheFilePath = base_path('bootstrap/cache/hooks.php');
}
/**
* 캐시 파일 경로를 반환합니다.
*
* @return string bootstrap/cache/hooks.php 절대 경로
*/
public function getCacheFilePath(): string
{
return $this->cacheFilePath;
}
/**
* 캐시 파일 존재 여부를 반환합니다.
*
* @return bool 캐시 파일이 존재하면 true
*/
public function isCached(): bool
{
return File::exists($this->cacheFilePath);
}
/**
* 캐시 파일을 읽어 반환합니다.
*
* 파일이 없거나 구조가 손상된 경우 null 을 반환하여 호출측이 스캔 폴백하도록 한다.
*
* @return array{core: array<int, array<string, mixed>>, modules: array<string, array<int, array<string, mixed>>>, plugins: array<string, array<int, array<string, mixed>>>}|null
*/
public function read(): ?array
{
// Request-scoped 메모리 캐시: 한 요청 동안 부팅 경로에서
// core/module/plugin 등록이 이 메서드를 여러 번 호출한다
// (호출당 86KB 캐시 파일 재파싱 — OPcache 부재 시 요청당 십수 ms 누적).
// 캐시 파일 내용은 요청 수명 동안 불변이므로 첫 로드 결과를 static 에 보관해
// 파일 접근을 요청당 1회로 고정한다. self::$memo 는 세 상태를 구분한다:
// - 미설정(unset): 아직 read() 호출 전
// - false: 이번 요청에서 캐시 미스(파일 부재/손상) 확정 — 재시도 없이 폴백 유지
// - array: 유효 캐시 데이터
if (self::$memo !== null) {
return self::$memo === false ? null : self::$memo;
}
if (! $this->isCached()) {
self::$memo = false;
return null;
}
try {
$data = require $this->cacheFilePath;
} catch (\Throwable $e) {
Log::warning('훅 캐시 파일 로드 실패 — 스캔 폴백', [
'path' => $this->cacheFilePath,
'error' => $e->getMessage(),
]);
self::$memo = false;
return null;
}
if (! is_array($data) || ! isset($data['core'], $data['modules'], $data['plugins'])) {
Log::warning('훅 캐시 파일 구조가 올바르지 않습니다 — 스캔 폴백', [
'path' => $this->cacheFilePath,
]);
self::$memo = false;
return null;
}
self::$memo = $data;
return $data;
}
/**
* 캐시 파일을 삭제합니다.
*
* @return bool 삭제 성공 또는 파일 부재 시 true
*/
public function clear(): bool
{
self::$memo = null;
if (! $this->isCached()) {
return true;
}
return File::delete($this->cacheFilePath);
}
/**
* 정적 훅 매핑을 스캔·리플렉션하여 캐시 파일로 생성합니다.
*
* 모듈/플러그인 리스너는 로드된 Module/Plugin 인스턴스의 getHookListeners() 에서
* 수집하므로, 호출 전에 각 Manager 의 loadModules()/loadPlugins() 가 선행되어야 한다.
*
* @param ModuleManager $moduleManager 로드 완료된 모듈 매니저
* @param PluginManager $pluginManager 로드 완료된 플러그인 매니저
* @return array{core: int, modules: int, plugins: int} 소스별 리스너 수
*/
public function generate(ModuleManager $moduleManager, PluginManager $pluginManager): array
{
$cache = [
'core' => $this->collectCoreListeners(),
'modules' => $this->collectExtensionListeners($moduleManager->getActiveModules()),
'plugins' => $this->collectExtensionListeners($pluginManager->getActivePlugins()),
];
$content = $this->buildCacheFileContent($cache);
$dir = dirname($this->cacheFilePath);
if (! File::isDirectory($dir)) {
File::makeDirectory($dir, 0755, true);
}
File::put($this->cacheFilePath, $content);
// 방금 쓴 새 매핑으로 request-scoped 메모리 캐시를 갱신
// (이후 read() 가 옛 memo 를 반환하지 않도록 무효화).
self::$memo = null;
$counts = [
'core' => count($cache['core']),
'modules' => array_sum(array_map('count', $cache['modules'])),
'plugins' => array_sum(array_map('count', $cache['plugins'])),
];
Log::info('훅 매핑 캐시 파일 생성 완료', [
'path' => $this->cacheFilePath,
'core' => $counts['core'],
'modules' => $counts['modules'],
'plugins' => $counts['plugins'],
]);
return $counts;
}
/**
* app/Listeners/ 재귀 스캔으로 코어 리스너 항목을 수집합니다.
*
* registerCoreHookListeners() 스캔 로직과 동일하게 HookListenerInterface 구현체만 대상.
*
* @return array<int, array<string, mixed>> [['listener' => FQCN, 'hooks' => [...], 'dynamic' => bool], ...]
*/
protected function collectCoreListeners(): array
{
$listenersPath = app_path('Listeners');
if (! is_dir($listenersPath)) {
return [];
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($listenersPath, \RecursiveDirectoryIterator::SKIP_DOTS)
);
$entries = [];
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$relativePath = str_replace($listenersPath.DIRECTORY_SEPARATOR, '', $file->getPathname());
$relativePath = str_replace('.php', '', $relativePath);
$relativePath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
$listenerClass = 'App\\Listeners\\'.$relativePath;
$entry = $this->buildListenerEntry($listenerClass);
if ($entry !== null) {
$entries[] = $entry;
}
}
return $entries;
}
/**
* 로드된 모듈/플러그인 인스턴스에서 훅 리스너 항목을 수집합니다.
*
* @param array<string, ModuleInterface|PluginInterface> $extensions 식별자(디렉토리명) => 확장 인스턴스
* @return array<string, array<int, array<string, mixed>>> 확장 식별자 => 리스너 항목 목록
*/
protected function collectExtensionListeners(array $extensions): array
{
$result = [];
foreach ($extensions as $extension) {
if (! method_exists($extension, 'getHookListeners')) {
continue;
}
$identifier = $extension->getIdentifier();
$entries = [];
foreach ($extension->getHookListeners() as $listenerClass) {
$entry = $this->buildListenerEntry($listenerClass);
if ($entry !== null) {
$entries[] = $entry;
}
}
if (! empty($entries)) {
$result[$identifier] = $entries;
}
}
return $result;
}
/**
* 단일 리스너 클래스의 캐시 항목을 생성합니다.
*
* HookListenerInterface 미구현·클래스 부재 시 null 을 반환하여 제외합니다.
* 사전 계산된 getSubscribedHooks() 결과와 동적 훅 여부(registerDynamicHooks 존재)를 함께 저장합니다.
*
* @param string $listenerClass 리스너 FQCN
* @return array{listener: string, hooks: array<string, array<string, mixed>>, dynamic: bool}|null
*/
protected function buildListenerEntry(string $listenerClass): ?array
{
if (! class_exists($listenerClass)) {
Log::warning('훅 캐시 생성: 리스너 클래스를 찾을 수 없습니다', ['listener' => $listenerClass]);
return null;
}
if (! in_array(HookListenerInterface::class, class_implements($listenerClass), true)) {
return null;
}
try {
$hooks = $listenerClass::getSubscribedHooks();
} catch (\Throwable $e) {
Log::warning('훅 캐시 생성: getSubscribedHooks() 오류 — 항목 제외', [
'listener' => $listenerClass,
'error' => $e->getMessage(),
]);
return null;
}
return [
'listener' => $listenerClass,
'hooks' => $hooks,
'dynamic' => method_exists($listenerClass, 'registerDynamicHooks'),
];
}
protected function buildCacheFileContent(array $cache): string
{
$exported = var_export($cache, true);
$generatedAt = now()->toDateTimeString();
$header = "<?php\n\n"
."/**\n"
." * 정적 훅 매핑 캐시\n"
." *\n"
." * 이 파일은 자동 생성됩니다. 직접 수정하지 마세요.\n"
." * Generated at: {$generatedAt}\n"
." *\n"
." * @see \\App\\Extension\\HookCacheManager::generate()\n"
." */\n\n";
return $header."return {$exported};\n";
}
}
+46 -5
View File
@@ -34,7 +34,9 @@ class HookListenerRegistrar
/**
* 리스너 클래스를 HookManager에 등록합니다.
*
* 동일 source + listenerClass 조합이 이미 등록된 경우 skip (idempotent).
* 런타임에 $listenerClass::getSubscribedHooks() 를 호출해 구독 훅을 조회한 뒤
* 등록한다. 동일 source + listenerClass 조합이 이미 등록된 경우 skip (idempotent).
* 사전 계산된 캐시 매핑으로 등록하려면 registerFromCache() 를 사용한다.
*
* @param string $listenerClass HookListenerInterface 구현 클래스의 FQCN
* @param string|null $source 등록 출처 (로그용: 'core', 모듈/플러그인 식별자)
@@ -45,14 +47,10 @@ class HookListenerRegistrar
if (isset(self::$registered[$key])) {
return; // 동일 PHP process 내 중복 등록 방지
}
self::$registered[$key] = true;
try {
$subscribedHooks = $listenerClass::getSubscribedHooks();
} catch (\Throwable $e) {
// 실패 시 캐시 롤백하여 재시도 가능 상태 유지
unset(self::$registered[$key]);
Log::error('훅 리스너 등록 실패: getSubscribedHooks() 오류', [
'listener' => $listenerClass,
'source' => $source,
@@ -62,6 +60,49 @@ class HookListenerRegistrar
return;
}
self::applySubscribedHooks($listenerClass, $subscribedHooks, $source);
}
/**
* 캐시된 구독 훅 매핑으로 리스너를 등록합니다 (클래스 로딩 없음).
*
* bootstrap/cache/hooks.php 에서 읽어온 사전 계산 getSubscribedHooks() 결과를
* 그대로 받아 HookManager 에 등록한다. register() 와 달리 $listenerClass::getSubscribedHooks()
* 를 호출하지 않으므로 리스너 클래스가 부팅 시점에 오토로딩되지 않는다 —
* 실제 클래스 로딩은 훅 발화 시 클로저 내부 app($listenerClass) 에서 지연 수행된다.
*
* 등록 결과(HookManager 훅 매핑)는 register() 스캔 경로와 바이트 동일해야 한다
* (동일 applySubscribedHooks 위임).
*
* @param string $listenerClass 리스너 FQCN
* @param array<string, array<string, mixed>> $subscribedHooks 사전 계산된 getSubscribedHooks() 결과
* @param string|null $source 등록 출처 ('core', 모듈/플러그인 식별자)
*/
public static function registerFromCache(string $listenerClass, array $subscribedHooks, ?string $source = null): void
{
$key = ($source ?? 'unknown').'::'.$listenerClass;
if (isset(self::$registered[$key])) {
return; // 동일 PHP process 내 중복 등록 방지 (스캔 경로와 동일 계약)
}
self::applySubscribedHooks($listenerClass, $subscribedHooks, $source);
}
/**
* 구독 훅 배열을 HookManager 에 등록합니다 (스캔/캐시 경로 공통).
*
* register()(런타임 getSubscribedHooks 호출)와 registerFromCache()(캐시 읽기)가
* 이 메서드에 위임하여 등록 결과의 동일성을 보장한다.
*
* @param string $listenerClass 리스너 FQCN
* @param array<string, array<string, mixed>> $subscribedHooks 구독 훅 매핑
* @param string|null $source 등록 출처
*/
private static function applySubscribedHooks(string $listenerClass, array $subscribedHooks, ?string $source): void
{
$key = ($source ?? 'unknown').'::'.$listenerClass;
self::$registered[$key] = true;
foreach ($subscribedHooks as $hookName => $config) {
$method = $config['method'] ?? 'handle';
$priority = $config['priority'] ?? 10;
+43 -7
View File
@@ -2961,13 +2961,6 @@ class ModuleManager implements ModuleManagerInterface
}
}
/**
* 모듈의 훅 리스너를 자동으로 등록합니다.
*
* 비활성화된 모듈의 훅 리스너는 등록하지 않습니다.
*
* @param ModuleInterface $module 모듈 인스턴스
*/
protected function registerModuleHookListeners(ModuleInterface $module): void
{
// 모듈이 getHookListeners 메서드를 가지고 있는지 확인
@@ -2981,6 +2974,11 @@ class ModuleManager implements ModuleManagerInterface
return;
}
// 캐시 우선: 사전 계산된 훅 매핑이 있으면 클래스 로딩 없이 등록.
if ($this->registerExtensionHookListenersFromCache('modules', $module->getIdentifier())) {
return;
}
$listeners = $module->getHookListeners();
foreach ($listeners as $listenerClass) {
@@ -3010,6 +3008,44 @@ class ModuleManager implements ModuleManagerInterface
}
}
/**
* 훅 캐시에서 확장(모듈/플러그인)의 정적 훅 리스너를 등록합니다.
*
* 캐시 파일(bootstrap/cache/hooks.php)에 해당 확장 식별자의 항목이 있으면
* getSubscribedHooks() 클래스 로딩·리플렉션 없이 사전 계산 매핑으로 등록한다.
* 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔 폴백).
*
* @param string $bucket 캐시 버킷 ('modules' 또는 'plugins')
* @param string $identifier 확장 식별자
* @return bool 캐시로 등록했으면 true, 캐시 부재/해당 항목 없음/테스트 환경이면 false (스캔 폴백)
*/
protected function registerExtensionHookListenersFromCache(string $bucket, string $identifier): bool
{
if (app()->environment('testing')) {
return false;
}
$cache = app(HookCacheManager::class)->read();
if ($cache === null || ! isset($cache[$bucket][$identifier])) {
return false;
}
foreach ($cache[$bucket][$identifier] as $entry) {
try {
HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], $identifier);
} catch (\Throwable $e) {
Log::error('확장 훅 리스너 캐시 등록 중 오류 발생', [
'listener' => $entry['listener'] ?? null,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
}
}
return true;
}
/**
* 모듈의 브로드캐스트 채널을 등록합니다.
*
+43 -7
View File
@@ -2802,13 +2802,6 @@ class PluginManager implements PluginManagerInterface
}
}
/**
* 플러그인의 훅 리스너를 자동으로 등록합니다.
*
* 비활성화된 플러그인의 훅 리스너는 등록하지 않습니다.
*
* @param PluginInterface $plugin 플러그인 인스턴스
*/
protected function registerPluginHookListeners(PluginInterface $plugin): void
{
// 플러그인 활성화 상태 확인 (비활성화된 플러그인의 훅은 등록하지 않음)
@@ -2817,6 +2810,11 @@ class PluginManager implements PluginManagerInterface
return;
}
// 캐시 우선: 사전 계산된 훅 매핑이 있으면 클래스 로딩 없이 등록.
if ($this->registerExtensionHookListenersFromCache('plugins', $plugin->getIdentifier())) {
return;
}
$listeners = $plugin->getHookListeners();
foreach ($listeners as $listenerClass) {
@@ -2846,6 +2844,44 @@ class PluginManager implements PluginManagerInterface
}
}
/**
* 훅 캐시에서 플러그인의 정적 훅 리스너를 등록합니다.
*
* 캐시 파일(bootstrap/cache/hooks.php)에 해당 플러그인 식별자의 항목이 있으면
* getSubscribedHooks() 클래스 로딩·리플렉션 없이 사전 계산 매핑으로 등록한다.
* 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔 폴백).
*
* @param string $bucket 캐시 버킷 ('plugins')
* @param string $identifier 플러그인 식별자
* @return bool 캐시로 등록했으면 true, 캐시 부재/해당 항목 없음/테스트 환경이면 false (스캔 폴백)
*/
protected function registerExtensionHookListenersFromCache(string $bucket, string $identifier): bool
{
if (app()->environment('testing')) {
return false;
}
$cache = app(HookCacheManager::class)->read();
if ($cache === null || ! isset($cache[$bucket][$identifier])) {
return false;
}
foreach ($cache[$bucket][$identifier] as $entry) {
try {
HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], $identifier);
} catch (\Throwable $e) {
Log::error('확장 훅 리스너 캐시 등록 중 오류 발생', [
'listener' => $entry['listener'] ?? null,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
}
}
return true;
}
/**
* 플러그인의 브로드캐스트 채널을 등록합니다.
*
+6 -3
View File
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
use App\Enums\ExtensionStatus;
use App\Extension\Cache\CoreCacheDriver;
use App\Models\Module;
use App\Support\InstallerContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -63,8 +64,6 @@ trait CachesModuleStatus
/**
* 모듈 상태 캐시를 무효화합니다.
* 모듈 상태 변경 시 (install, activate, deactivate, uninstall) 호출해야 합니다.
*
* @return void
*/
public static function invalidateModuleStatusCache(): void
{
@@ -79,10 +78,14 @@ trait CachesModuleStatus
* 설치 완료 상태(`config('app.installer_completed')`)일 때는 테이블 존재를
* 전제로 하여 `Schema::hasTable()` 호출을 건너뜁니다. 인스톨러 이전 환경이나
* 테스트에서는 기존 체크 경로로 폴백합니다.
*
* 단, 마이그레이션 계열 명령 실행 중에는 `INSTALLER_COMPLETED=true` 라도 테이블이
* 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를
* 신뢰하지 않고 실제 `Schema::hasTable()` 폴백으로 진입합니다.
*/
private static function isExtensionTableReady(string $table): bool
{
if (config('app.installer_completed')) {
if (! InstallerContext::isSchemaMutatingCommand() && config('app.installer_completed')) {
return true;
}
+6 -3
View File
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
use App\Enums\ExtensionStatus;
use App\Extension\Cache\CoreCacheDriver;
use App\Models\Plugin;
use App\Support\InstallerContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -63,8 +64,6 @@ trait CachesPluginStatus
/**
* 플러그인 상태 캐시를 무효화합니다.
* 플러그인 상태 변경 시 (install, activate, deactivate, uninstall) 호출해야 합니다.
*
* @return void
*/
public static function invalidatePluginStatusCache(): void
{
@@ -76,10 +75,14 @@ trait CachesPluginStatus
/**
* 설치 완료 상태에서는 `Schema::hasTable()` 호출을 건너뜁니다.
* 인스톨러 이전 환경이나 테스트에서는 기존 체크 경로로 폴백합니다.
*
* 단, 마이그레이션 계열 명령 실행 중에는 `INSTALLER_COMPLETED=true` 라도 테이블이
* 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를
* 신뢰하지 않고 실제 `Schema::hasTable()` 폴백으로 진입합니다.
*/
private static function isPluginTableReady(): bool
{
if (config('app.installer_completed')) {
if (! InstallerContext::isSchemaMutatingCommand() && config('app.installer_completed')) {
return true;
}
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
use App\Enums\ExtensionStatus;
use App\Extension\Cache\CoreCacheDriver;
use App\Models\Template;
use App\Support\InstallerContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -86,8 +87,6 @@ trait CachesTemplateStatus
/**
* 템플릿 상태 캐시를 무효화합니다.
* 템플릿 상태 변경 시 (install, activate, deactivate, uninstall) 호출해야 합니다.
*
* @return void
*/
public static function invalidateTemplateStatusCache(): void
{
@@ -101,10 +100,14 @@ trait CachesTemplateStatus
/**
* 설치 완료 상태에서는 `Schema::hasTable()` 호출을 건너뜁니다.
* 인스톨러 이전 환경이나 테스트에서는 기존 체크 경로로 폴백합니다.
*
* 단, 마이그레이션 계열 명령 실행 중에는 `INSTALLER_COMPLETED=true` 라도 테이블이
* 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를
* 신뢰하지 않고 실제 `Schema::hasTable()` 폴백으로 진입합니다.
*/
private static function isTemplateTableReady(): bool
{
if (config('app.installer_completed')) {
if (! InstallerContext::isSchemaMutatingCommand() && config('app.installer_completed')) {
return true;
}
+11 -5
View File
@@ -71,7 +71,7 @@ class EnvironmentDetector
* → PATH 검색 → composer.phar
*
* @param string|null $hint 바이너리 경로 힌트 (null 이면 config/ENV/PATH 순으로 자동 탐색)
* @return string|null 발견된 composer 바이너리 경로. 없으면 null
* @return string|null 발견된 composer 바이너리 경로. 없으면 null
*/
public function findComposerBinary(?string $hint = null): ?string
{
@@ -120,7 +120,7 @@ class EnvironmentDetector
* proc_open 사용 가능 + composer 바이너리 발견 + `composer --version` 종료 코드 0.
*
* @param string|null $hint composer 바이너리 경로 힌트 (캐시 우회용)
* @return bool composer 가 현재 환경에서 실행 가능한지 여부
* @return bool composer 가 현재 환경에서 실행 가능한지 여부
*/
public function canExecuteComposer(?string $hint = null): bool
{
@@ -178,7 +178,7 @@ class EnvironmentDetector
* 지정 경로에 쓰기 가능한지 확인.
*
* @param string $targetPath vendor 디렉토리 경로 (존재하지 않아도 부모 디렉토리 쓰기 권한 검사)
* @return bool 부모 디렉토리가 존재하고 쓰기 가능한지 여부
* @return bool 부모 디렉토리가 존재하고 쓰기 가능한지 여부
*/
public function canWriteVendor(string $targetPath): bool
{
@@ -254,9 +254,15 @@ class EnvironmentDetector
/**
* composer 실행 명령 문자열 구성.
*
* @param array<int, string> $args
* 반환된 문자열은 `proc_open(..., ['bypass_shell' => true])` 와 함께 사용해야 한다.
* Windows 에서 `bypass_shell` 없이 실행하면 `cmd.exe` 가 따옴표로 시작하는 `.bat`
* 경로의 앞뒤 따옴표를 벗겨 첫 인자(`install` 등)를 실행 파일로 오인한다.
*
* @param string $binary composer 바이너리 경로 (findComposerBinary 반환값)
* @param array<int, string> $args composer 하위 명령과 옵션 (예: ['install', '--no-dev'])
* @return string proc_open 에 넘길 실행 명령 문자열
*/
private function buildComposerCommand(string $binary, array $args): string
public function buildComposerCommand(string $binary, array $args): string
{
$escaped = array_map('escapeshellarg', $args);
+177 -57
View File
@@ -82,7 +82,7 @@ class VendorBundler
* 코어는 소스 = 출력 경로가 base_path() 로 동일합니다.
*
* @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드
* @return VendorBundleResult 번들 생성 결과 (zip 경로, manifest 경로, 패키지 수 등)
* @return VendorBundleResult 번들 생성 결과 (zip 경로, manifest 경로, 패키지 수 등)
*/
public function buildForCore(bool $force = false): VendorBundleResult
{
@@ -106,7 +106,7 @@ class VendorBundler
* @param string $type 확장 타입 ('module' 또는 'plugin')
* @param string $identifier 확장 식별자 (vendor-name 형식, 예: 'sirsoft-ecommerce')
* @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드
* @return VendorBundleResult 번들 생성 결과 (활성 디렉토리 부재 시 skipped=true)
* @return VendorBundleResult 번들 생성 결과 (활성 디렉토리 부재 시 skipped=true)
*/
public function buildForExtension(string $type, string $identifier, bool $force = false): VendorBundleResult
{
@@ -164,14 +164,17 @@ class VendorBundler
*
* @param string $sourcePath composer.json/lock 을 읽을 경로 (활성 디렉토리)
* @param string|null $outputPath 번들 출력 경로 (null 이면 sourcePath 와 동일 — 코어 케이스)
* @return bool 번들이 stale 한지 여부 (해시 불일치 또는 manifest 부재). 외부 의존성이 없으면 항상 false
* @return bool 번들이 stale 한지 여부 (해시 불일치 또는 manifest 부재). 외부 의존성이 없으면 항상 false
*/
public function isStale(string $sourcePath, ?string $outputPath = null): bool
{
$outputPath ??= $sourcePath;
$composerJsonPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.json';
if (file_exists($composerJsonPath) && ! $this->hasExternalDependencies($composerJsonPath)) {
// 의존성 판정도 build() 와 같은 파일을 본다 (출력 우선, 없으면 소스 폴백).
// 소스만 보면 _bundled 에 외부 패키지를 처음 추가한 상태를 "의존성 없음" 으로
// 오판해 재빌드가 필요 없다고 답한다.
$composerJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json');
if ($composerJsonPath !== null && ! $this->hasExternalDependencies($composerJsonPath)) {
return false;
}
@@ -187,16 +190,19 @@ class VendorBundler
return true;
}
if (file_exists($composerJsonPath)) {
$currentHash = $this->integrityChecker->computeFileHash($composerJsonPath);
// 해시 비교 대상은 manifest 가 놓인 출력 디렉토리 기준 ({@see build()} 참조).
// 소스만 검사하면 _bundled 의 composer.json 변경을 up-to-date 로 오보한다.
$hashJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json');
if ($hashJsonPath !== null) {
$currentHash = $this->integrityChecker->computeFileHash($hashJsonPath);
if (($manifest['composer_json_sha256'] ?? null) !== $currentHash) {
return true;
}
}
$composerLockPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.lock';
if (file_exists($composerLockPath)) {
$currentHash = $this->integrityChecker->computeFileHash($composerLockPath);
$hashLockPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.lock');
if ($hashLockPath !== null) {
$currentHash = $this->integrityChecker->computeFileHash($hashLockPath);
if (($manifest['composer_lock_sha256'] ?? null) !== $currentHash) {
return true;
}
@@ -205,6 +211,35 @@ class VendorBundler
return false;
}
/**
* manifest 해시 비교 기준이 될 파일 경로를 결정합니다.
*
* manifest 와 zip 은 출력 디렉토리(_bundled)에 함께 저장되어 그대로 배포되며,
* 런타임 검증({@see VendorIntegrityChecker::verify()})은 zip 이 놓인 디렉토리
* (_bundled 또는 설치/업데이트 시 그 복사본인 _pending/staging)의 composer.json 을
* 대조합니다. 따라서 해시도 그 디렉토리를 기준으로 계산해야 생성자와 검증자의
* 기준이 일치합니다.
*
* 출력 디렉토리에 파일이 없으면 소스(활성 디렉토리)로 폴백합니다.
* 코어는 소스와 출력이 동일하므로 어느 쪽을 골라도 같은 경로가 됩니다.
*
* @param string $sourcePath composer.json/lock 과 vendor/ 를 읽는 경로 (활성 디렉토리)
* @param string $outputPath manifest 와 zip 을 쓰는 경로 (_bundled, 코어는 소스와 동일)
* @param string $filename 'composer.json' 또는 'composer.lock'
* @return string|null 해시 대상 파일의 절대 경로. 양쪽 모두 없으면 null
*/
private function resolveHashTarget(string $sourcePath, string $outputPath, string $filename): ?string
{
$outputFile = $outputPath.DIRECTORY_SEPARATOR.$filename;
if (file_exists($outputFile)) {
return $outputFile;
}
$sourceFile = $sourcePath.DIRECTORY_SEPARATOR.$filename;
return file_exists($sourceFile) ? $sourceFile : null;
}
/**
* 빌드 실행.
*
@@ -215,12 +250,20 @@ class VendorBundler
*/
private function build(string $sourcePath, string $outputPath, string $target, bool $force): VendorBundleResult
{
// 전제 조건 검증
$composerJsonPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.json';
if (! file_exists($composerJsonPath)) {
// 빌드가 사용하는 composer.json/lock 은 **출력(_bundled) 우선, 없으면 소스 폴백** 이다
// ({@see resolveHashTarget()}). 설치 입력·해시 기준·런타임 검증이 모두 같은 파일을
// 보아야 한다.
//
// 개발자는 규정상 `_bundled` 에서만 작업하므로 새 패키지를 추가하면 `_bundled` 의
// composer.json/lock 에만 반영되고 활성 디렉토리는 구버전으로 남는다. 설치 입력만
// 활성에서 가져오면 구버전 lock 으로 install 한 zip 에 신버전 해시가 붙어, 무결성은
// 통과하는데 새 패키지가 번들에서 누락된다 (런타임에 클래스 not found).
// 코어는 source == output 이므로 항상 같은 파일이다.
$composerJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json');
if ($composerJsonPath === null) {
throw new VendorInstallException(
errorKey: 'composer_json_not_found',
context: ['path' => $composerJsonPath],
context: ['path' => $sourcePath.DIRECTORY_SEPARATOR.'composer.json'],
);
}
@@ -258,26 +301,33 @@ class VendorBundler
);
}
// 기존 파일 제거
if (file_exists($zipPath)) {
@unlink($zipPath);
}
if (file_exists($manifestPath)) {
@unlink($manifestPath);
}
// === 스테이징 빌드 ===
// 개발 머신의 vendor/ 를 직접 사용하지 않고, composer install --no-dev 를
// 스테이징 디렉토리에서 새로 실행하여 dev 의존성과 완전히 분리된 번들을 생성한다.
//
// 새 zip/manifest 는 임시 경로(.tmp)에 먼저 쓰고, 빌드가 성공한 뒤에만 원본을
// 교체한다. 기존 파일을 빌드 전에 삭제하면 composer install 실패 시 새 파일이
// 생성되지 못한 채 원본만 유실되어 이후 module:update 가 번들 부재로 실패한다.
$stagingDir = storage_path('app/vendor-bundle-staging/'.uniqid('build-', true));
File::ensureDirectoryExists($stagingDir, 0755);
$tmpZipPath = $zipPath.'.tmp';
$tmpManifestPath = $manifestPath.'.tmp';
if (file_exists($tmpZipPath)) {
@unlink($tmpZipPath);
}
if (file_exists($tmpManifestPath)) {
@unlink($tmpManifestPath);
}
// 설치 입력 = 해시 기준과 동일한 파일 (출력 우선, 없으면 소스 폴백).
$composerLockPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.lock');
try {
// 1. composer.json + composer.lock 을 스테이징으로 복사
File::copy($composerJsonPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.json');
$sourceLockPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.lock';
if (file_exists($sourceLockPath)) {
File::copy($sourceLockPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.lock');
if ($composerLockPath !== null) {
File::copy($composerLockPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.lock');
}
// 2. 스테이징에서 composer install --no-dev 실행
@@ -292,8 +342,8 @@ class VendorBundler
);
}
// 4. zip 생성 (EXCLUDE_PATTERNS 만 적용 — 화이트리스트 불필요)
[$zipSize, $fileCount] = $this->writeZip($stagedVendor, $zipPath);
// 4. zip 생성 — 임시 경로에 먼저 쓴다 (EXCLUDE_PATTERNS 만 적용)
[$zipSize, $fileCount] = $this->writeZip($stagedVendor, $tmpZipPath);
// 5. manifest 작성 — 스테이징의 composer.lock 을 기준으로 패키지 목록 추출
$stagedLockPath = $stagingDir.DIRECTORY_SEPARATOR.'composer.lock';
@@ -301,19 +351,19 @@ class VendorBundler
? $this->collectPackages($stagedLockPath)
: [];
// manifest 의 해시는 설치 입력과 **동일한** composer.json/lock 기준 (출력 우선).
// manifest·zip·composer.json 이 함께 배포되는 곳이 출력 디렉토리이며,
// 런타임 검증도 그 디렉토리를 대조한다 ({@see resolveHashTarget()}).
$manifest = [
'schema_version' => self::SCHEMA_VERSION,
'generated_at' => date('c'),
'generator' => 'g7 vendor-bundle:build',
'target' => $target,
// manifest 의 해시는 **소스** 의 composer.json/lock 기준 — 런타임에서
// 소스 디렉토리(또는 번들과 함께 배포된) composer.json 과 비교하여
// stale 여부를 감지한다.
'composer_json_sha256' => $this->integrityChecker->computeFileHash($composerJsonPath),
'composer_lock_sha256' => file_exists($sourceLockPath)
? $this->integrityChecker->computeFileHash($sourceLockPath)
'composer_lock_sha256' => $composerLockPath !== null
? $this->integrityChecker->computeFileHash($composerLockPath)
: null,
'zip_sha256' => $this->integrityChecker->computeFileHash($zipPath),
'zip_sha256' => $this->integrityChecker->computeFileHash($tmpZipPath),
'zip_size' => $zipSize,
'package_count' => count($packages),
'php_requirement' => $this->extractPhpRequirement($composerJsonPath),
@@ -321,7 +371,12 @@ class VendorBundler
'packages' => $packages,
];
File::put($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
File::put($tmpManifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
// 6. 원자적 교체 — 여기까지 성공했으므로 임시 파일을 최종 경로로 옮긴다.
// 이 지점 이전에 실패하면 기존 번들이 그대로 보존된다.
$this->promoteAtomic($tmpZipPath, $zipPath);
$this->promoteAtomic($tmpManifestPath, $manifestPath);
Log::info('Vendor 번들 빌드 완료', [
'target' => $target,
@@ -340,10 +395,42 @@ class VendorBundler
reason: 'built',
);
} finally {
// 스테이징 정리 (실패/성공 무관)
// 스테이징 및 미승격 임시 파일 정리 (실패/성공 무관)
if (File::isDirectory($stagingDir)) {
File::deleteDirectory($stagingDir);
}
if (file_exists($tmpZipPath)) {
@unlink($tmpZipPath);
}
if (file_exists($tmpManifestPath)) {
@unlink($tmpManifestPath);
}
}
}
/**
* 임시 파일을 최종 경로로 원자적으로 교체합니다.
*
* Windows 에서 `rename()` 은 대상이 이미 존재하면 실패하므로, 기존 파일을 먼저
* 제거한 뒤 이동한다. 이 메서드는 빌드가 성공적으로 완료된 뒤에만 호출되므로,
* 여기서 기존 파일을 제거해도 (제거 후 이동 실패 확률이 극히 낮아) 안전하다.
*
* @param string $tmpPath 임시 파일 경로 (성공적으로 생성 완료된 상태)
* @param string $finalPath 최종 경로
*
* @throws VendorInstallException 이동 실패 시
*/
private function promoteAtomic(string $tmpPath, string $finalPath): void
{
if (file_exists($finalPath)) {
@unlink($finalPath);
}
if (! @rename($tmpPath, $finalPath)) {
throw new VendorInstallException(
errorKey: 'bundle_build_promote_failed',
context: ['from' => $tmpPath, 'to' => $finalPath],
);
}
}
@@ -377,30 +464,37 @@ class VendorBundler
);
}
$phpBinary = config('process.php_binary', 'php');
if (str_contains($binary, ' ')) {
$composerCmd = $binary;
} elseif (str_ends_with(strtolower($binary), '.phar')) {
$composerCmd = escapeshellarg($phpBinary).' '.escapeshellarg($binary);
} else {
$composerCmd = escapeshellarg($binary);
}
$command = $composerCmd.' install --no-dev --no-scripts --prefer-dist --no-interaction --no-progress 2>&1';
// 명령 조립과 실행 옵션은 canExecuteComposer 와 동일한 방식으로 통일한다.
// buildComposerCommand + bypass_shell:true 를 함께 써야 Windows 에서 .bat
// 경로가 cmd.exe 의 따옴표 처리로 깨지지 않는다.
$command = $detector->buildComposerCommand($binary, [
'install', '--no-dev', '--no-scripts', '--prefer-dist', '--no-interaction', '--no-progress',
]);
Log::info('vendor-bundle 빌드: composer install 시작', [
'target' => $target,
'staging' => $stagingDir,
]);
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
// stdout/stderr 는 파이프가 아닌 파일 descriptor 로 받는다.
// (Windows 회귀) composer 는 autoload dump 단계에서 임시 추출물을
// `rmdir /S /Q` async `cmd.exe` 손자 프로세스로 정리하는데, stdout/stderr 를
// 파이프로 열면 이 손자들이 부모 파이프 핸들을 상속받아 붙잡은 채 종료하지 않아
// 부모(php)의 read/proc_close 가 자식 완료 신호를 영영 받지 못하고 무한 대기한다.
// 파일 descriptor 는 손자에게 상속될 열린 파이프 핸들이 없어 이 교착을 원천 차단한다
// (실측: 파이프=행, 파일=140s 완주 EXIT 0 + autoload 정상 생성).
$outPath = $stagingDir.DIRECTORY_SEPARATOR.'_composer_stdout.log';
$errPath = $stagingDir.DIRECTORY_SEPARATOR.'_composer_stderr.log';
$descriptors = self::composerOutputDescriptors($outPath, $errPath);
$process = @proc_open($command, $descriptors, $pipes, $stagingDir, EnvironmentDetector::buildComposerEnv());
$process = @proc_open(
$command,
$descriptors,
$pipes,
$stagingDir,
EnvironmentDetector::buildComposerEnv(),
['bypass_shell' => true],
);
if (! is_resource($process)) {
throw new VendorInstallException(
@@ -409,12 +503,17 @@ class VendorBundler
);
}
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
// stdin(파이프)만 즉시 닫고, stdout/stderr 는 파일이므로 배수 불필요 —
// proc_close 가 자식 종료까지 블록 대기한다.
if (isset($pipes[0]) && is_resource($pipes[0])) {
fclose($pipes[0]);
}
$exitCode = proc_close($process);
$stdout = is_file($outPath) ? (string) @file_get_contents($outPath) : '';
$stderr = is_file($errPath) ? (string) @file_get_contents($errPath) : '';
$output = trim($stdout.\PHP_EOL.$stderr);
if ($exitCode !== 0) {
throw new VendorInstallException(
errorKey: 'bundle_build_composer_failed',
@@ -428,13 +527,34 @@ class VendorBundler
]);
}
/**
* composer install 의 stdout/stderr 를 받을 proc_open descriptor 배열을 구성합니다.
*
* stdout/stderr 를 파이프가 아닌 파일로 여는 이유는 runComposerInstall 주석 참조 —
* Windows 에서 composer autoload dump 의 async 손자 프로세스가 상속받는 파이프 핸들로
* 인한 무한 대기(행)를 원천 차단한다. 이 메서드는 순수 함수로, descriptor 구조를
* 회귀 테스트로 고정하기 위해 분리했다.
*
* @param string $outPath stdout 로그 파일 경로
* @param string $errPath stderr 로그 파일 경로
* @return array<int, array<int, string>> proc_open 2번째 인자용 descriptor 배열
*/
public static function composerOutputDescriptors(string $outPath, string $errPath): array
{
return [
0 => ['pipe', 'r'],
1 => ['file', $outPath, 'w'],
2 => ['file', $errPath, 'w'],
];
}
/**
* vendor/ 디렉토리를 재귀적으로 zip에 쓰기.
*
* 스테이징에서 composer install --no-dev 로 새로 생성된 vendor/ 를 대상으로 하므로
* 화이트리스트 필터링은 불필요하며, EXCLUDE_PATTERNS (tests/, docs/ 등) 만 적용한다.
*
* @return array{0: int, 1: int} [zipSize, fileCount]
* @return array{0: int, 1: int} [zipSize, fileCount]
*/
private function writeZip(string $vendorPath, string $zipPath): array
{
+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);
}
}
@@ -187,7 +187,7 @@ class AdminTemplateAssetController extends AdminBaseController
/**
* 편집기 프리뷰 전용 CSS 서빙 — 다크 셀렉터를 프리뷰 마커로 치환.
*
* PO admin 환경이 다크 테마면 `<html class="dark">` 가 조상으로 남아, 프리뷰
* 관리자 admin 환경이 다크 테마면 `<html class="dark">` 가 조상으로 남아, 프리뷰
* 프레임에서 `.dark` 를 빼도(라이트 토글) Tailwind `.dark &` 가 활성돼 프리뷰가
* 라이트로 격리되지 않는다. 이를 해소하기 위해, 편집기 진입 시에만 components.css 의
* 다크 조상 셀렉터(`.dark`)를 프리뷰 전용 마커(`.g7le-preview-dark`)로 치환해 서빙한다.
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Settings\RegenerateAppKeyRequest;
use App\Http\Requests\Settings\RestoreSettingsRequest;
use App\Http\Requests\Settings\SaveSettingsRequest;
use App\Http\Requests\Settings\TestDriverConnectionRequest;
use App\Http\Requests\Settings\TestMailRequest;
@@ -13,7 +14,7 @@ use App\Services\DriverConnectionTester;
use App\Services\DriverRegistryService;
use App\Services\SettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
/**
@@ -137,7 +138,9 @@ class SettingsController extends AdminBaseController
$systemInfo = $this->settingsService->getSystemInfo();
return $this->success('common.success', $systemInfo);
} catch (\Exception $e) {
} catch (\Throwable $e) {
Log::error('시스템 정보 조회 실패', ['error' => $e->getMessage()]);
return $this->error('common.error_occurred', 500, $e->getMessage());
}
}
@@ -264,16 +267,13 @@ class SettingsController extends AdminBaseController
/**
* 백업에서 설정을 복원합니다.
*
* @param RestoreSettingsRequest $request 복원 요청 데이터 (백업 경로)
* @return JsonResponse 복원 결과 JSON 응답
*/
public function restore(Request $request): JsonResponse
public function restore(RestoreSettingsRequest $request): JsonResponse
{
try {
$backupPath = $request->input('backup_path');
if (empty($backupPath)) {
return $this->error('settings.backup_path_required', 422);
}
$backupPath = $request->validated('backup_path');
$result = $this->settingsService->restoreSettings($backupPath);
@@ -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;
}
}
@@ -2,6 +2,8 @@
namespace App\Http\Requests\LanguagePack;
use App\Extension\HookManager;
use App\Rules\PublicOutboundUrl;
use Illuminate\Foundation\Http\FormRequest;
/**
@@ -26,11 +28,15 @@ class InstallFromUrlRequest extends FormRequest
*/
public function rules(): array
{
return [
'url' => ['required', 'url', 'max:500'],
$rules = [
// 원격 코드를 내려받는 지점이므로 https 고정 + 내부 주소 허용 설정과 무관하게 항상 차단
'url' => ['required', 'url', 'max:500', new PublicOutboundUrl(schemes: ['https'], allowInternalOptIn: false)],
'checksum' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
'auto_activate' => ['nullable', 'boolean'],
];
// 모듈/플러그인이 validation rules 를 동적으로 추가할 수 있도록 훅 제공
return HookManager::applyFilters('core.language_packs.install_from_url_validation_rules', $rules, $this);
}
/**
@@ -6,13 +6,20 @@ use App\Enums\ExtensionOwnerType;
use App\Enums\ScheduleFrequency;
use App\Enums\ScheduleType;
use App\Extension\HookManager;
use App\Rules\AllowedArtisanCommand;
use App\Rules\AllowedShellCommand;
use App\Rules\PublicOutboundUrl;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class CreateScheduleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool 권한 검사는 라우트의 permission 미들웨어가 담당하므로 항상 true
*/
public function authorize(): bool
{
@@ -22,7 +29,7 @@ class CreateScheduleRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -33,7 +40,25 @@ class CreateScheduleRequest extends FormRequest
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'type' => "required|string|in:{$types}",
'command' => 'required|string|max:2000',
// command 는 타입별로 서버의 outbound 목적지(URL)·OS 명령(Shell)·PHP 코드(Artisan)가 되므로
// 각 타입에 맞는 실행 허용 검증을 저장 시점에 건다
'command' => [
'required',
'string',
'max:2000',
Rule::when(
$this->input('type') === ScheduleType::Url->value,
[new PublicOutboundUrl],
),
Rule::when(
$this->input('type') === ScheduleType::Shell->value,
[new AllowedShellCommand],
),
Rule::when(
$this->input('type') === ScheduleType::Artisan->value,
[new AllowedArtisanCommand],
),
],
'expression' => 'required|string|max:100',
'frequency' => "required|string|in:{$frequencies}",
'without_overlapping' => 'boolean',
@@ -86,7 +111,9 @@ class CreateScheduleRequest extends FormRequest
}
/**
* 검증된 데이터 반환
* 검증된 데이터에 생성자(created_by)를 덧붙여 반환합니다.
*
* @return array<string, mixed> 검증된 입력 + created_by
*/
public function validatedWithCreator(): array
{
@@ -6,12 +6,20 @@ use App\Enums\ExtensionOwnerType;
use App\Enums\ScheduleFrequency;
use App\Enums\ScheduleType;
use App\Extension\HookManager;
use App\Models\Schedule;
use App\Rules\AllowedArtisanCommand;
use App\Rules\AllowedShellCommand;
use App\Rules\PublicOutboundUrl;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateScheduleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool 권한 검사는 라우트의 permission 미들웨어가 담당하므로 항상 true
*/
public function authorize(): bool
{
@@ -21,7 +29,7 @@ class UpdateScheduleRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -32,7 +40,17 @@ class UpdateScheduleRequest extends FormRequest
'name' => 'sometimes|required|string|max:255',
'description' => 'nullable|string|max:1000',
'type' => "sometimes|required|string|in:{$types}",
'command' => 'sometimes|required|string|max:2000',
// command 는 타입별로 서버의 outbound 목적지(URL)·OS 명령(Shell)·PHP 코드(Artisan)가 되므로
// 각 타입에 맞는 실행 허용 검증을 저장 시점에 건다
'command' => [
'sometimes',
'required',
'string',
'max:2000',
Rule::when($this->resolvesToScheduleType(ScheduleType::Url), [new PublicOutboundUrl]),
Rule::when($this->resolvesToScheduleType(ScheduleType::Shell), [new AllowedShellCommand]),
Rule::when($this->resolvesToScheduleType(ScheduleType::Artisan), [new AllowedArtisanCommand]),
],
'expression' => 'sometimes|required|string|max:100',
'frequency' => "sometimes|required|string|in:{$frequencies}",
'without_overlapping' => 'boolean',
@@ -47,6 +65,26 @@ class UpdateScheduleRequest extends FormRequest
return HookManager::applyFilters('core.schedule.update_validation_rules', $rules, $this);
}
/**
* 이번 수정 결과 스케줄이 주어진 타입이 되는지 판정합니다.
*
* PATCH 라 `type` 이 요청에 없을 수 있으므로, 없으면 저장된 스케줄의 타입을 기준으로
* 판정한다 — 그렇지 않으면 기존 스케줄의 command 만 바꾸는 요청이 검증을 비껴간다.
*
* @param ScheduleType $type 판정할 스케줄 타입
* @return bool 해당 타입의 스케줄이면 true
*/
protected function resolvesToScheduleType(ScheduleType $type): bool
{
if ($this->has('type')) {
return $this->input('type') === $type->value;
}
$schedule = $this->route('schedule');
return $schedule instanceof Schedule && $schedule->type === $type;
}
/**
* Get custom messages for validator errors.
*
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Requests\Settings;
use App\Extension\HookManager;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 설정 복원 요청 FormRequest
*
* 백업에서 설정을 복원할 때 백업 경로를 검증합니다.
* 권한은 라우트의 permission 미들웨어(core.settings.update)가 담당합니다.
*/
class RestoreSettingsRequest extends FormRequest
{
/**
* 요청 권한을 확인합니다.
*
* 권한 검사는 permission 미들웨어 체인에 위임하므로 항상 true 를 반환합니다.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [
'backup_path' => 'required|string',
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
return HookManager::applyFilters('core.settings.restore_validation_rules', $rules, $this);
}
/**
* 검증 오류 메시지를 반환합니다.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'backup_path.required' => __('settings.backup_path_required'),
];
}
}
@@ -231,6 +231,8 @@ class SaveSettingsRequest extends FormRequest
'security.auth_token_lifetime' => $this->getSecurityTabAuthTokenRules($tab),
'security.max_login_attempts' => ['nullable', 'integer', 'min:0', 'max:100'],
'security.login_lockout_time' => ['nullable', 'integer', 'min:0', 'max:1440'],
// 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송 시 기본값 false 유지
'security.allow_internal_outbound_urls' => ['nullable', 'boolean'],
// 캐시 설정 (advanced 탭)
'advanced.cache_enabled' => $this->getTabRules($tab, 'advanced', 'boolean'),
@@ -614,6 +616,7 @@ class SaveSettingsRequest extends FormRequest
'security.auth_token_lifetime.integer' => __('validation.settings.auth_token_lifetime_integer'),
'security.auth_token_lifetime.min' => __('validation.settings.auth_token_lifetime_min'),
'security.auth_token_lifetime.max' => __('validation.settings.auth_token_lifetime_max'),
'security.allow_internal_outbound_urls.boolean' => __('validation.settings.allow_internal_outbound_urls_boolean'),
'security.max_login_attempts.integer' => __('validation.settings.max_login_attempts_integer'),
'security.max_login_attempts.min' => __('validation.settings.max_login_attempts_min'),
'security.max_login_attempts.max' => __('validation.settings.max_login_attempts_max'),
@@ -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,
];
}
/**
* 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다.
*
@@ -0,0 +1,86 @@
<?php
namespace App\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Support\ConfigCacheHelper;
use Illuminate\Support\Facades\Log;
/**
* 확장 활성화/비활성화 시 config 캐시를 재생성하는 리스너.
*
* 확장이 활성화·비활성화되면 다음 요청부터 그 확장의 config/settings 가 부팅 시
* 주입 대상에 포함/제외된다. config:cache 가 켜진 환경에서는 optimizeSystem 이
* 캐시를 만든 시점의 "활성 확장 스냅샷" 이 stale 해지므로, 라이프사이클 직후
* config 캐시를 재빌드해 캐시가 최신 활성 목록을 반영하도록 한다.
*
* install/uninstall/update 는 ExtensionManager::updateComposerAutoload() 가 오토로드·훅
* 캐시와 나란히 config 캐시를 재생성하므로 여기서 구독하지 않는다(이중 재생성 회피).
* 이 리스너는 updateComposerAutoload 를 거치지 않는 activate/deactivate 만 담당한다.
*
* @see \App\Extension\ExtensionManager::updateComposerAutoload()
* @see \App\Support\ConfigCacheHelper
*/
class ExtensionConfigCacheListener implements HookListenerInterface
{
/**
* 구독할 훅 목록을 반환합니다.
*
* activate/deactivate 만 구독한다 (install/uninstall/update 는 updateComposerAutoload 담당).
*
* @return array<string, array{method: string, priority: int}> 훅 이름 → 메서드/우선순위 매핑
*/
public static function getSubscribedHooks(): array
{
$hooks = [];
$hookNames = [
'core.modules.activated',
'core.modules.after_deactivate',
'core.plugins.activated',
'core.plugins.after_deactivate',
'core.templates.activated',
'core.templates.after_deactivate',
];
foreach ($hookNames as $hookName) {
$hooks[$hookName] = [
'method' => 'onExtensionToggled',
'priority' => 30,
// config 캐시 재생성은 인프라 작업이라 큐로 미루면 안 된다. Action 훅의 기본
// 등록은 큐 디스패치(HookListenerRegistrar) 이므로, sync 를 명시하지 않으면
// 워커 미가동 환경에서 config:cache 가 재생성되지 않는다. 또한 확장 토글은
// CLI 커맨드(plugin:deactivate 등)로도 수행되며, 커맨드 프로세스는 실행 후
// 즉시 종료되어 큐에 쌓인 작업을 처리할 주체가 없다. 반드시 동기 실행한다.
'sync' => true,
];
}
return $hooks;
}
/**
* 기본 훅 핸들러 (HookListenerInterface 필수 메서드).
*
* @param mixed ...$args 훅 인자
*/
public function handle(...$args): void
{
// 개별 메서드에서 처리
}
/**
* 확장 활성화/비활성화 직후 config 캐시를 재생성합니다.
*
* @param mixed ...$args 훅 인자 (확장 식별자 등)
*/
public function onExtensionToggled(...$args): void
{
try {
ConfigCacheHelper::rebuild();
} catch (\Throwable $e) {
Log::warning('[Config] 확장 토글 후 config 캐시 재생성 실패', [
'error' => $e->getMessage(),
]);
}
}
}
+3 -1
View File
@@ -50,7 +50,9 @@ class NotificationHookListener implements HookListenerInterface
*/
public function registerDynamicHooks(): void
{
if (! Schema::hasTable('notification_definitions')) {
// 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 information_schema 쿼리 제거).
// 인스톨러 이전/마이그레이션 전 환경에서는 기존 hasTable 폴백으로 안전하게 스킵.
if (! config('app.installer_completed') && ! Schema::hasTable('notification_definitions')) {
return;
}
+1 -1
View File
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* 레이아웃 편집 중 업로드되는 파일(배경 이미지 등)을 표현한다. 파일 자체는
* 코어 StorageInterface 로 저장되고 본 모델은 저장 위치(disk/path)와 메타데이터를
* 보관한다. 템플릿 삭제 시 행은 cascadeOnDelete 로 삭제되나, 스토리지 파일
* 실삭제는 Service 가 명시적으로 수행한다(CLAUDE.md "DB CASCADE 의존 삭제 금지").
* 실삭제는 Service 가 명시적으로 수행한다(코어 규정: DB CASCADE 의존 삭제 금지).
*
* @property int $id 첨부 파일 ID
* @property int $template_id 소속 템플릿 ID
+51 -4
View File
@@ -42,6 +42,7 @@ use App\Enums\DeactivationReason;
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\CoreVersionChecker;
use App\Extension\ExtensionManager;
use App\Extension\HookCacheManager;
use App\Extension\HookListenerRegistrar;
use App\Extension\HookManager;
use App\Extension\IdentityVerification\IdentityVerificationManager;
@@ -151,6 +152,15 @@ class CoreServiceProvider extends ServiceProvider
}
}
// 확장 소스 classmap 등록 (FQCN → 절대경로) — findFile 파일시스템 스캔 제거 (lazy include).
if (! empty($extensionAutoloads['src_classmap'])) {
$absoluteClassmap = [];
foreach ($extensionAutoloads['src_classmap'] as $fqcn => $relPath) {
$absoluteClassmap[$fqcn] = base_path($relPath);
}
$loader->addClassMap($absoluteClassmap);
}
// Classmap 파일 로드 (module.php, plugin.php)
if (! empty($extensionAutoloads['classmap'])) {
foreach ($extensionAutoloads['classmap'] as $file) {
@@ -866,12 +876,14 @@ class CoreServiceProvider extends ServiceProvider
// registerActivityLogManager() 제거됨 — Monolog 채널(config/logging.php 'activity')로 대체
/**
* app/Listeners/ 디렉토리에서 HookListenerInterface 구현체를 자동 발견하여 등록합니다.
* 하위 디렉토리까지 재귀적으로 스캔합니다.
*/
private function registerCoreHookListeners(): void
{
// 캐시 우선: bootstrap/cache/hooks.php 가 있으면 스캔·리플렉션 없이 등록.
// 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔).
if (! $this->app->environment('testing') && $this->registerCoreHookListenersFromCache()) {
return;
}
$listenersPath = app_path('Listeners');
if (! is_dir($listenersPath)) {
@@ -910,6 +922,41 @@ class CoreServiceProvider extends ServiceProvider
}
}
/**
* 훅 캐시에서 코어 리스너를 등록합니다.
*
* 캐시 파일(bootstrap/cache/hooks.php)이 존재하면 디렉토리 스캔·class_implements
* 리플렉션·getSubscribedHooks() 클래스 로딩 없이 사전 계산 매핑으로 등록한다.
* 동적 훅(registerDynamicHooks) 보유 리스너는 스캔 경로와 동일하게 boot 후반부 실행을 위해 지연 목록에 담는다.
*
* @return bool 캐시로 등록했으면 true, 캐시 부재/손상 시 false (스캔 폴백)
*/
private function registerCoreHookListenersFromCache(): bool
{
$cache = $this->app->make(HookCacheManager::class)->read();
if ($cache === null) {
return false;
}
foreach ($cache['core'] as $entry) {
try {
HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], 'core');
if (! empty($entry['dynamic'])) {
$this->deferredDynamicListeners[] = $entry['listener'];
}
} catch (\Throwable $e) {
Log::error('코어 훅 리스너 캐시 등록 중 오류 발생', [
'listener' => $entry['listener'] ?? null,
'error' => $e->getMessage(),
]);
}
}
return true;
}
/**
* 단일 코어 리스너를 HookManager에 등록합니다.
*
@@ -18,6 +18,7 @@ use App\Services\LanguagePack\LanguagePackBundledRegistrar;
use App\Services\LanguagePack\LanguagePackRegistry;
use App\Services\LanguagePack\LanguagePackSeedInjector;
use App\Services\LanguagePack\LanguagePackTranslator;
use App\Support\InstallerContext;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\File;
@@ -477,8 +478,7 @@ class LanguagePackServiceProvider extends ServiceProvider
return false;
}
if ($this->app->runningInConsole()
&& in_array($_SERVER['argv'][1] ?? null, ['migrate', 'migrate:fresh', 'migrate:rollback', 'migrate:reset', 'db:wipe'], true)) {
if (InstallerContext::isSchemaMutatingCommand()) {
return false;
}
+7 -1
View File
@@ -6,6 +6,7 @@ use App\Enums\ExtensionStatus;
use App\Extension\ExtensionManager;
use App\Extension\Testing\ExtensionTestAllowlist;
use App\Models\Module;
use App\Support\InstallerContext;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
@@ -52,7 +53,12 @@ class ModuleRouteServiceProvider extends ServiceProvider
// 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 쿼리 제거).
// 인스톨러 이전 환경에서는 기존 체크 경로로 폴백.
if (! config('app.installer_completed')) {
//
// 단, 마이그레이션 계열 명령 실행 중에는 INSTALLER_COMPLETED=true 라도 테이블이
// 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를
// 무력화하고 hasTable 검증 경로로 진입한다. 빈 DB 면 여기서 early return 하여
// 아래 무방비 pluck 이 table not found 로 부팅을 깨뜨리는 것을 막는다.
if (! config('app.installer_completed') || InstallerContext::isSchemaMutatingCommand()) {
try {
if (! Schema::hasTable('modules')) {
return;
+7 -1
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Extension\ExtensionManager;
use App\Extension\Testing\ExtensionTestAllowlist;
use App\Support\InstallerContext;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
@@ -48,7 +49,12 @@ class PluginRouteServiceProvider extends ServiceProvider
// 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 쿼리 제거).
// 인스톨러 이전 환경에서는 기존 체크 경로로 폴백.
if (! config('app.installer_completed')) {
//
// 단, 마이그레이션 계열 명령 실행 중에는 INSTALLER_COMPLETED=true 라도 테이블이
// 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를
// 무력화하고 hasTable 검증 경로로 진입한다. 빈 DB 면 여기서 early return 하여
// 이후 플러그인 부팅/상태 조회가 table not found 로 부팅을 깨뜨리는 것을 막는다.
if (! config('app.installer_completed') || InstallerContext::isSchemaMutatingCommand()) {
try {
if (! Schema::hasTable('plugins')) {
return;
@@ -340,7 +340,9 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface
public function listHookTargets(): array
{
try {
if (! Schema::hasTable('identity_policies')) {
// 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 information_schema 쿼리 제거).
// 인스톨러 이전/마이그레이션 전 환경에서는 기존 hasTable 폴백으로 안전하게 빈 배열 반환.
if (! config('app.installer_completed') && ! Schema::hasTable('identity_policies')) {
return [];
}
@@ -404,7 +406,9 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface
{
try {
$table = (new $modelClass)->getTable();
if (! Schema::hasTable($table)) {
// 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 information_schema 쿼리 제거).
// 인스톨러 이전/마이그레이션 전 환경에서는 기존 hasTable 폴백으로 null 반환(필터 미적용).
if (! config('app.installer_completed') && ! Schema::hasTable($table)) {
return null;
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Rules;
use App\Support\ScheduleCommandValidator;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Artisan 타입 스케줄의 command 가 차단목록에 걸리지 않는지 검증하는 Custom Rule.
*
* `tinker` 처럼 임의 PHP 코드를 실행하거나 `db:wipe` 처럼 파괴적인 명령을 스케줄로 등록하는 것을
* 저장 시점에 차단한다. 차단목록은 `config/schedule_security.php` 가 소유하며 관리자 환경설정에
* 노출되지 않는다.
*/
class AllowedArtisanCommand implements ValidationRule
{
/**
* 값이 실행 허용된 artisan 명령인지 검증합니다.
*
* @param string $attribute 검증 대상 필드명
* @param mixed $value 검증 대상 값
* @param Closure $fail 실패 콜백
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value === null || $value === '') {
return;
}
if (! is_string($value)) {
$fail(__('validation.schedule_command.artisan_denied'));
return;
}
if (! ScheduleCommandValidator::isArtisanCommandAllowed($value)) {
$fail(__('validation.schedule_command.artisan_denied'));
}
}
}
-3
View File
@@ -20,9 +20,6 @@ class AllowedModuleFileType implements ValidationRule
// Data
'json',
// Source Maps
'map',
// Images
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif', 'ico',
-3
View File
@@ -20,9 +20,6 @@ class AllowedPluginFileType implements ValidationRule
// Data
'json',
// Source Maps
'map',
// Images
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif', 'ico',
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Rules;
use App\Support\ScheduleCommandValidator;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Shell 타입 스케줄의 command 가 실행 허용된 명령인지 검증하는 Custom Rule.
*
* 스케줄 생성 권한만 위임받은 계정이 임의 OS 명령을 서버에서 실행하는 것을 저장 시점에 차단한다.
* 허용 여부는 `config/schedule_security.php` 의 opt-in 게이트와 실행 파일 화이트리스트가 결정하며,
* 관리자 환경설정에 노출되지 않으므로 위임 관리자가 스스로 범위를 넓힐 수 없다.
*/
class AllowedShellCommand implements ValidationRule
{
/**
* 값이 실행 허용된 shell 명령인지 검증합니다.
*
* @param string $attribute 검증 대상 필드명
* @param mixed $value 검증 대상 값
* @param Closure $fail 실패 콜백
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value === null || $value === '') {
return;
}
if (! is_string($value)) {
$fail(__('validation.schedule_command.shell_not_allowed'));
return;
}
if (! ScheduleCommandValidator::isShellCommandAllowed($value)) {
$fail(__('validation.schedule_command.shell_not_allowed'));
}
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Rules;
use App\Support\OutboundUrlValidator;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* 서버가 대신 호출하게 될 URL 이 내부망을 가리키지 않는지 검증하는 Custom Rule.
*
* 외부 API 엔드포인트·스케줄 URL 처럼 저장된 값이 나중에 서버의 outbound 요청 목적지가
* 되는 입력에 적용한다. 사설/루프백/링크로컬 IP 와 내부 도메인을 입력 시점에 차단해,
* 서버가 내부망 정찰 도구로 쓰이는 것을 막는다(SSRF).
*
* 사내 서버를 호출하는 것이 정당한 용도(외부 API 연동, 스케줄 URL 호출)에 한해, 관리자
* 환경설정의 `security.allow_internal_outbound_urls` 로 내부 주소를 허용할 수 있다.
* 원격 코드(언어팩 ZIP 등)를 내려받는 지점은 `allowInternalOptIn: false` 로 두어 이
* 설정과 무관하게 항상 차단한다.
*/
class PublicOutboundUrl implements ValidationRule
{
/**
* @param array<int, string> $schemes 허용 scheme (기본 http/https — 사내 API 는 http 인 경우가 있다)
* @param bool $allowInternalOptIn 관리자 설정으로 내부 주소를 허용할 수 있는 지점인지 (기본 true)
*/
public function __construct(
private readonly array $schemes = ['http', 'https'],
private readonly bool $allowInternalOptIn = true,
) {}
/**
* 값이 공개 인터넷 URL 인지 검증합니다.
*
* @param string $attribute 검증 대상 필드명
* @param mixed $value 검증 대상 값
* @param Closure $fail 실패 콜백
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value === null || $value === '') {
return;
}
if (! is_string($value)) {
$fail(__('validation.outbound_url.invalid'));
return;
}
$options = ['schemes' => $this->schemes];
// 내부 주소 허용이 켜져 있으면 구조 검증(scheme/userinfo)만 하고 내부망 차단은 건너뛴다
if ($this->allowInternalOptIn && (bool) g7_core_settings('security.allow_internal_outbound_urls', false)) {
if (! OutboundUrlValidator::isStructurallySafeUrl($value, $options)) {
$fail(__('validation.outbound_url.invalid'));
}
return;
}
if (! OutboundUrlValidator::isPublicHttpUrl($value, $options)) {
$fail(__('validation.outbound_url.internal_not_allowed'));
}
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ use App\Contracts\Extension\PluginManagerInterface;
* 데이터소스 종류=websocket 편집 시 채널을 "구독 가능한 목록 기준 검색 드롭다운"으로
* 제시하기 위해, "지금 이 설치본에서 구독 가능한 채널"을 런타임 수집한다. 코어/확장이
* `getChannels()` 오버라이드로 등록한 채널명을 출처(core/module/plugin)와 함께 모은다
* (CLAUDE.md 채널 프리픽스 규약 — 코어 `core.*`, 모듈 `module.{id}.*`, 플러그인
* (코어 채널 프리픽스 규약 — 코어 `core.*`, 모듈 `module.{id}.*`, 플러그인
* `plugin.{id}.*`).
*
* 이벤트는 `HookManager::broadcast($channel, $eventName, $payload)` 호출 시점에 이름이
+92 -7
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 와 관계가 있는지 검사합니다.
*
@@ -1558,8 +1633,9 @@ class CoreUpdateService
* @param string $toVersion 종료 버전
* @param \Closure|null $onStep 각 스텝 실행 시 콜백 (버전 문자열 전달)
* @param bool $force true 시 fromVersion == toVersion이면 해당 버전 스텝도 포함
* @param \Closure|null $onDiscovered 범위 내 발견된 스텝 파일 수를 실행 전 1회 통지 (int 전달)
*/
public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false): void
public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false, ?\Closure $onDiscovered = null): void
{
// 부모 in-process fallback 진입 시 stale 메모리 가드.
//
@@ -1597,6 +1673,9 @@ class CoreUpdateService
$upgradesPath = base_path('upgrades');
if (! File::isDirectory($upgradesPath)) {
// upgrades 디렉토리 자체가 없으면 발견된 스텝 0건 — 정상 통지 후 종료.
$onDiscovered?->__invoke(0);
return;
}
@@ -1656,6 +1735,12 @@ class CoreUpdateService
uksort($steps, 'version_compare');
// 범위 내에서 발견된 스텝 파일 수(discovered)를 먼저 통지한다.
// 호출자(spawn 자식)는 이 값으로 "스텝 파일이 애초에 없어 0건(정상)" 과 "스텝 파일은
// 있는데 실행이 0건(비정상 — gnuboard/g7#28 silent skip)" 을 구분한다. onStep 은 실제 실행
// 건마다 호출되므로 executed 수만 세며, discovered 는 실행 전에 1회 통지한다.
$onDiscovered?->__invoke(count($steps));
$context = new UpgradeContext($fromVersion, $toVersion);
foreach ($steps as $version => $step) {
+24 -8
View File
@@ -2,8 +2,13 @@
namespace App\Services;
use App\Support\OutboundUrlValidator;
use Aws\S3\Exception\S3Exception;
use Aws\S3\S3Client;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Predis\Client;
/**
* 드라이버 연결 테스트 서비스
@@ -89,7 +94,7 @@ class DriverConnectionTester
}
// AWS SDK가 설치되어 있는지 확인
if (! class_exists(\Aws\S3\S3Client::class)) {
if (! class_exists(S3Client::class)) {
return [
'success' => false,
'message' => __('settings.s3_sdk_missing'),
@@ -98,7 +103,7 @@ class DriverConnectionTester
$startTime = microtime(true);
$client = new \Aws\S3\S3Client([
$client = new S3Client([
'version' => 'latest',
'region' => $region,
'credentials' => [
@@ -121,7 +126,7 @@ class DriverConnectionTester
'message' => __('settings.s3_test_success'),
'latency' => $latency.'ms',
];
} catch (\Aws\S3\Exception\S3Exception $e) {
} catch (S3Exception $e) {
$errorMessage = match ($e->getAwsErrorCode()) {
'NoSuchBucket' => __('settings.s3_bucket_not_found'),
'AccessDenied' => __('settings.s3_access_denied'),
@@ -165,7 +170,7 @@ class DriverConnectionTester
// PHP Redis 확장 확인
if (! extension_loaded('redis')) {
// Predis 사용 시도
if (! class_exists(\Predis\Client::class)) {
if (! class_exists(Client::class)) {
return [
'success' => false,
'message' => __('settings.redis_extension_missing'),
@@ -177,7 +182,7 @@ class DriverConnectionTester
$startTime = microtime(true);
$redis = new \Redis();
$redis = new \Redis;
$connected = @$redis->connect($host, $port, 3.0);
if (! $connected) {
@@ -259,7 +264,7 @@ class DriverConnectionTester
$options['password'] = $password;
}
$client = new \Predis\Client($options);
$client = new Client($options);
$pong = $client->ping();
if ($pong->getPayload() !== 'PONG') {
@@ -307,7 +312,7 @@ class DriverConnectionTester
$startTime = microtime(true);
$memcached = new \Memcached();
$memcached = new \Memcached;
$memcached->setOption(\Memcached::OPT_CONNECT_TIMEOUT, 3000);
$memcached->addServer($host, $port);
@@ -376,6 +381,17 @@ class DriverConnectionTester
// Reverb 서버 상태 확인 (기본 HTTP 엔드포인트)
$url = sprintf('%s://%s:%d', $scheme, $host, $port);
// Reverb 는 통상 localhost·사내 IP 에서 동작하므로 사설 주소 자체는 정상 구성이다.
// 다만 이 진단 요청을 임의 목적지 탐색에 전용하지 못하도록, 정상 설정에서는 나올 수
// 없는 값(userinfo 위장, http/https 이외 scheme, 제어문자 주입)은 거부한다.
if (! OutboundUrlValidator::isStructurallySafeUrl($url, ['schemes' => ['http', 'https']])) {
return [
'success' => false,
'message' => __('settings.websocket_test_failed'),
'error' => __('settings.websocket_invalid_host'),
];
}
$startTime = microtime(true);
// HTTP 요청으로 서버 응답 확인
@@ -402,7 +418,7 @@ class DriverConnectionTester
'message' => __('settings.websocket_test_failed'),
'error' => "HTTP {$response->status()}",
];
} catch (\Illuminate\Http\Client\ConnectionException $e) {
} catch (ConnectionException $e) {
return [
'success' => false,
'message' => __('settings.websocket_connection_refused'),
+499
View File
@@ -0,0 +1,499 @@
<?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);
$isProduction = app()->environment('production');
$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[] = $this->processCssSourceMap($content, $isProduction);
} 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 소스맵 주석을 환경에 맞게 처리합니다.
*
* CSS 는 JS 와 주석 문법이 달라 소스맵 참조를 블록 주석으로 표기하므로
* processJsSourceMap() 의 `//#` 패턴으로는 검출되지 않는다.
*
* prod: 주석 strip(맵 참조 제거). dev: 원본 유지.
* 병합 번들에서는 개별 맵 URL 이 어차피 어긋나므로 dev rewrite 는 하지 않는다.
*
* @param string $content 원본 CSS 내용
* @param bool $isProduction 프로덕션 여부
* @return string 처리된 내용
*/
private function processCssSourceMap(string $content, bool $isProduction): string
{
if (! $isProduction) {
return $content;
}
// 구분자로 `~` 사용 — 패턴에 `/`, `#` 가 포함됨
return preg_replace('~/\*#\s*sourceMappingURL=\S+?\s*\*/~', '', $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();
}
}
+3 -3
View File
@@ -4,7 +4,7 @@ namespace App\Services;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Extension\HookManager;
use Illuminate\Support\Facades\Artisan;
use App\Support\ConfigCacheHelper;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -369,8 +369,8 @@ class GeoIpDatabaseService
$current['last_updated_at'] = now()->toIso8601String();
$this->configRepository->saveCategory('geoip', $current);
// Laravel config 캐시 재로드 강제
Artisan::call('config:clear');
// config 캐시 재로드 강제 + 즉시 재생성 (clear 만 하면 캐시 비활성 잔존).
ConfigCacheHelper::rebuild();
} catch (\Throwable $e) {
Log::warning('GeoIP last_updated_at 갱신 실패', ['error' => $e->getMessage()]);
}
+29 -17
View File
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
use App\Enums\LanguagePackScope;
use App\Enums\LanguagePackStatus;
use App\Exceptions\LanguagePackOperationException;
use App\Exceptions\LanguagePackSlotConflictException;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\GithubHelper;
@@ -16,6 +17,7 @@ use App\Models\LanguagePack;
use App\Services\LanguagePack\LanguagePackBaseLocales;
use App\Services\LanguagePack\LanguagePackManifestValidator;
use App\Services\LanguagePack\LanguagePackRegistry;
use App\Support\OutboundUrlValidator;
use Illuminate\Http\UploadedFile;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
@@ -25,7 +27,6 @@ use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use App\Exceptions\LanguagePackOperationException;
use RuntimeException;
use Throwable;
@@ -340,7 +341,6 @@ class LanguagePackService
*
* @param LanguagePack $pack 가상 행
* @param array<string, mixed> $filters 필터
* @return bool
*/
private function matchesBuiltInFilters(LanguagePack $pack, array $filters): bool
{
@@ -600,7 +600,7 @@ class LanguagePackService
ZipInstallHelper::extractZip($zipPath, $extractPath);
return $this->finalizeInstall($extractPath, 'zip', $file->getClientOriginalName(), $autoActivate, $installedBy);
} catch (\Throwable $e) {
} catch (Throwable $e) {
$this->cleanupPending($extractPath);
throw $e;
}
@@ -632,7 +632,7 @@ class LanguagePackService
ZipInstallHelper::extractZip($zipPath, $extractPath);
return $this->finalizeInstall($extractPath, 'github', $githubUrl, $autoActivate, $installedBy, $force);
} catch (\Throwable $e) {
} catch (Throwable $e) {
$this->cleanupPending($extractPath);
File::deleteDirectory($tempPath);
throw $e;
@@ -653,6 +653,11 @@ class LanguagePackService
*/
public function installFromUrl(string $url, ?string $checksum, bool $autoActivate = false, ?int $installedBy = null, bool $force = false): LanguagePack
{
// 서버가 이 URL 을 대신 내려받으므로, 내부 네트워크 주소가 목적지가 되지 않도록 차단한다.
if (! OutboundUrlValidator::isPublicHttpUrl($url)) {
throw new LanguagePackOperationException('language_packs.errors.download_url_not_public');
}
$this->assertInstallDirectoriesWritable();
$tempId = (string) Str::uuid();
@@ -679,7 +684,7 @@ class LanguagePackService
ZipInstallHelper::extractZip($zipPath, $extractPath);
return $this->finalizeInstall($extractPath, 'url', $url, $autoActivate, $installedBy, $force);
} catch (\Throwable $e) {
} catch (Throwable $e) {
$this->cleanupPending($extractPath);
File::deleteDirectory($tempPath);
throw $e;
@@ -718,7 +723,7 @@ class LanguagePackService
File::copyDirectory($bundledPath, $extractPath);
return $this->finalizeInstall($extractPath, 'bundled', $identifier, $autoActivate, $installedBy, $force);
} catch (\Throwable $e) {
} catch (Throwable $e) {
$this->cleanupPending($extractPath);
throw $e;
}
@@ -901,6 +906,7 @@ class LanguagePackService
$pack = $this->repository->findById((int) $id);
if (! $pack) {
$failed[] = ['id' => $id, 'reason' => 'not_found'];
continue;
}
try {
@@ -955,7 +961,6 @@ class LanguagePackService
*
* @param LanguagePack $pack 대상 언어팩
* @param bool $cascade 코어 제거 시 하위(module/plugin/template) 동일 locale 팩도 함께 제거
* @return void
*/
public function uninstall(LanguagePack $pack, bool $cascade = false): void
{
@@ -1000,7 +1005,6 @@ class LanguagePackService
* 슬롯 비활성화 후 다음 후보를 active 로 승격합니다.
*
* @param LanguagePack $pack 방금 비활성화된 언어팩
* @return void
*/
private function promoteSlotSuccessor(LanguagePack $pack): void
{
@@ -1056,7 +1060,6 @@ class LanguagePackService
*
* @param string $packageRoot 패키지 루트 디렉토리
* @param array<string, mixed> $manifest manifest 데이터
* @return void
*
* @throws RuntimeException 보안 위반 발견 시
*/
@@ -1103,7 +1106,6 @@ class LanguagePackService
* 모듈/플러그인/템플릿 install 과 동일 수준의 가드 — 권한 부족 시 chmod 안내 메시지가
* 포함된 `RuntimeException` 을 던집니다. 컨트롤러는 본 예외를 422 응답으로 변환합니다.
*
* @return void
*
* @throws RuntimeException 디렉토리 미존재/쓰기 불가 시
*/
@@ -1126,7 +1128,6 @@ class LanguagePackService
* 의존성 검증 — depends_on_core_locale 이 true 면 코어 언어팩 active 여부 확인.
*
* @param array<string, mixed> $manifest manifest 데이터
* @return void
*
* @throws RuntimeException 의존성 미충족 시
*/
@@ -1146,7 +1147,6 @@ class LanguagePackService
* 모듈/플러그인 시스템과 동일한 강도로 — 비활성 확장에도 언어팩이 들러붙지 않게 차단합니다.
*
* @param array<string, mixed> $manifest manifest 데이터
* @return void
*
* @throws RuntimeException 대상 확장 미설치/비활성 시
*/
@@ -1256,7 +1256,6 @@ class LanguagePackService
*
* @param LanguagePack $existing 기존 언어팩
* @param array<string, mixed> $manifest 새 manifest
* @return void
*
* @throws RuntimeException 다운그레이드 시도 시
*/
@@ -1301,7 +1300,6 @@ class LanguagePackService
* _pending 임시 디렉토리를 정리합니다.
*
* @param string $path 정리 대상 경로
* @return void
*/
private function cleanupPending(string $path): void
{
@@ -1309,7 +1307,7 @@ class LanguagePackService
if (File::isDirectory($path)) {
File::deleteDirectory($path);
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
Log::warning('lang-pack pending cleanup failed', ['path' => $path, 'error' => $e->getMessage()]);
}
}
@@ -1421,19 +1419,33 @@ class LanguagePackService
$manifest = $pack->manifest;
if (is_array($manifest)) {
$manifestUrl = $manifest['github_url'] ?? null;
if (is_string($manifestUrl) && Str::startsWith($manifestUrl, 'https://github.com/')) {
if (is_string($manifestUrl) && self::isGithubUrl($manifestUrl)) {
return $manifestUrl;
}
}
$sourceUrl = (string) $pack->source_url;
if (Str::startsWith($sourceUrl, 'https://github.com/')) {
if (self::isGithubUrl($sourceUrl)) {
return $sourceUrl;
}
return null;
}
/**
* URL 이 실제 GitHub host 를 가리키는지 판정합니다.
*
* 접두사 매칭은 `https://github.com@evil.example/` 같은 userinfo 위장을 통과시키므로
* host 를 완전 일치로 검증합니다.
*
* @param string $url 판정 대상 URL
* @return bool GitHub URL 이면 true
*/
private static function isGithubUrl(string $url): bool
{
return OutboundUrlValidator::isHostAllowed($url, ['github.com', 'www.github.com']);
}
/**
* 번들 manifest 로부터 최신 버전을 조회합니다 (요구사항 #4 폴백 경로).
*
+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합니다.
*
-1
View File
@@ -1043,7 +1043,6 @@ class ModuleService
'mjs' => 'application/javascript',
'css' => 'text/css',
'json' => 'application/json',
'map' => 'application/json',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
-1
View File
@@ -708,7 +708,6 @@ class PluginService
'mjs' => 'application/javascript',
'css' => 'text/css',
'json' => 'application/json',
'map' => 'application/json',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
+59 -8
View File
@@ -10,8 +10,9 @@ use App\Enums\ScheduleType;
use App\Extension\HookManager;
use App\Models\Schedule;
use App\Models\ScheduleHistory;
use App\Support\OutboundUrlValidator;
use App\Support\ScheduleCommandValidator;
use Exception;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
@@ -19,6 +20,7 @@ use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Validation\ValidationException;
use Symfony\Component\Console\Output\BufferedOutput;
class ScheduleService
{
@@ -205,10 +207,13 @@ class ScheduleService
'output' => $result['output'] ?? null,
]);
// 스케줄 상태 업데이트
// 스케줄 상태 업데이트 (다음 실행 시각은 모델이 계산, 영속화는 Repository 경유)
$schedule->last_result = ScheduleResultStatus::Success;
$schedule->calculateNextRunAt();
$schedule->save();
$this->scheduleRepository->update($schedule, [
'last_result' => ScheduleResultStatus::Success,
'next_run_at' => $schedule->next_run_at,
]);
// after_run 훅 실행
HookManager::doAction('core.schedule.after_run', $schedule, $history);
@@ -224,10 +229,13 @@ class ScheduleService
'error_output' => $e->getMessage(),
]);
// 스케줄 상태 업데이트
// 스케줄 상태 업데이트 (다음 실행 시각은 모델이 계산, 영속화는 Repository 경유)
$schedule->last_result = ScheduleResultStatus::Failed;
$schedule->calculateNextRunAt();
$schedule->save();
$this->scheduleRepository->update($schedule, [
'last_result' => ScheduleResultStatus::Failed,
'next_run_at' => $schedule->next_run_at,
]);
Log::error('Schedule execution failed', [
'schedule_id' => $schedule->id,
@@ -245,12 +253,18 @@ class ScheduleService
*
* @param Schedule $schedule 스케줄
* @return array 실행 결과
*
* @throws Exception 차단목록에 걸린 명령일 때
*/
private function executeArtisanCommand(Schedule $schedule): array
{
$output = '';
// 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다
// (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선).
if (! ScheduleCommandValidator::isArtisanCommandAllowed($schedule->command)) {
throw new Exception(__('schedule.artisan_not_allowed'));
}
Artisan::call($schedule->command, [], new \Symfony\Component\Console\Output\BufferedOutput);
Artisan::call($schedule->command, [], new BufferedOutput);
return [
'output' => Artisan::output(),
@@ -268,9 +282,19 @@ class ScheduleService
*/
private function executeShellCommand(Schedule $schedule): array
{
// 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다
// (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선).
$arguments = ScheduleCommandValidator::tokenizeShellCommand($schedule->command);
if ($arguments === null || ! ScheduleCommandValidator::isShellCommandAllowed($schedule->command)) {
throw new Exception(__('schedule.shell_not_allowed'));
}
$timeout = $schedule->timeout ?? 60;
$result = Process::timeout($timeout)->run($schedule->command);
// 문자열이 아닌 인자 배열로 넘겨 `/bin/sh -c` 를 경유하지 않는다 —
// 파이프·`;`·`$()` 같은 셸 메타문자가 명령으로 해석될 여지를 없앤다.
$result = Process::timeout($timeout)->run($arguments);
if ($result->failed()) {
throw new Exception($result->errorOutput() ?: __('schedule.shell_command_failed'), $result->exitCode());
@@ -292,6 +316,12 @@ class ScheduleService
*/
private function executeUrlCall(Schedule $schedule): array
{
// 저장된 URL 이 그대로 서버의 outbound 목적지가 되므로, 실행 직전에도 내부망 주소를 차단한다
// (저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값 방어).
if (! $this->isUrlCallAllowed($schedule->command)) {
throw new Exception(__('schedule.url_not_public'));
}
$timeout = $schedule->timeout ?? 30;
$response = Http::timeout($timeout)->get($schedule->command);
@@ -306,6 +336,27 @@ class ScheduleService
];
}
/**
* 스케줄의 URL 호출이 허용되는 목적지인지 판정합니다.
*
* 사설 IP·localhost 등 내부 네트워크 주소는 기본 차단하되, 사내 엔드포인트를 주기 호출하는
* 정당한 운영을 위해 `security.allow_internal_outbound_urls` 로 허용할 수 있습니다.
* 허용하더라도 userinfo 위장·비 HTTP scheme 은 계속 거부합니다.
*
* @param string $url 스케줄에 저장된 호출 URL
* @return bool 호출을 허용하면 true
*/
private function isUrlCallAllowed(string $url): bool
{
$options = ['schemes' => ['http', 'https']];
if ((bool) g7_core_settings('security.allow_internal_outbound_urls', false)) {
return OutboundUrlValidator::isStructurallySafeUrl($url, $options);
}
return OutboundUrlValidator::isPublicHttpUrl($url, $options);
}
/**
* 스케줄을 복제합니다.
*
+75 -29
View File
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\AttachmentRepositoryInterface;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Extension\HookManager;
use App\Http\Resources\AttachmentResource;
use App\Support\ConfigCacheHelper;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@@ -32,11 +33,14 @@ class SettingsService
* 시스템 설정 캐시를 무효화합니다.
*
* 설정 저장 시 4곳에서 중복되던 로직을 단일 메서드로 통합했습니다.
* config 캐시는 clear 후 즉시 재생성한다(ConfigCacheHelper). config:clear 만
* 하면 config:cache 가 비워진 채 재생성되지 않아 이후 모든 요청이 config 파일을
* 재파싱하는 성능 손실이 발생하기 때문이다.
*/
private function invalidateSettingsCache(): void
{
$this->cache->forget('settings.system');
Artisan::call('config:clear');
ConfigCacheHelper::rebuild();
}
/**
@@ -822,30 +826,74 @@ class SettingsService
private function buildSystemInfo(): array
{
return [
'os_info' => php_uname('s').' '.php_uname('r'),
// php_uname / ini_get 은 disable_functions 로 차단될 수 있어 개별 격리한다.
'os_info' => $this->safeSystemProbe('os_info', fn () => php_uname('s').' '.php_uname('r'), __('common.unknown')),
'web_server' => $_SERVER['SERVER_SOFTWARE'] ?? __('common.unknown'),
'php_version' => PHP_VERSION,
'mysql_version' => $this->getDatabaseInfo(),
'mysql_version' => $this->safeSystemProbe('mysql_version', fn () => $this->getDatabaseInfo(), __('common.unknown')),
'g7_version' => config('app.version', '1.0.0'),
'g7_release_year' => config('app.release_year', '2026'),
'laravel_version' => app()->version(),
'environment' => app()->environment(),
'cpu_info' => $this->getCpuInfo(),
'memory_usage' => $this->getMemoryUsage(),
'disk_usage' => $this->getDiskUsage(),
'php_memory_limit' => ini_get('memory_limit'),
'max_execution_time' => ini_get('max_execution_time').__('settings.seconds'),
'upload_max_filesize' => ini_get('upload_max_filesize'),
'cpu_info' => $this->safeSystemProbe('cpu_info', fn () => $this->getCpuInfo(), __('common.unknown')),
'memory_usage' => $this->safeSystemProbe('memory_usage', fn () => $this->getMemoryUsage(), $this->unknownUsage()),
'disk_usage' => $this->safeSystemProbe('disk_usage', fn () => $this->getDiskUsage(), $this->unknownUsage()),
'php_memory_limit' => $this->safeSystemProbe('php_memory_limit', fn () => ini_get('memory_limit'), __('common.unknown')),
'max_execution_time' => $this->safeSystemProbe('max_execution_time', fn () => ini_get('max_execution_time').__('settings.seconds'), __('common.unknown')),
'upload_max_filesize' => $this->safeSystemProbe('upload_max_filesize', fn () => ini_get('upload_max_filesize'), __('common.unknown')),
'install_path' => base_path(),
'config_path' => storage_path('app/settings'),
'log_path' => storage_path('logs'),
'upload_path' => storage_path('app/public'),
'php_extensions' => $this->getPhpExtensions(),
'database_config' => $this->getDatabaseConfig(),
'php_extensions' => $this->safeSystemProbe('php_extensions', fn () => $this->getPhpExtensions(), ['required' => [], 'optional' => []]),
'database_config' => $this->safeSystemProbe('database_config', fn () => $this->getDatabaseConfig(), ['has_read_write_split' => false, 'write' => [], 'read' => []]),
'timezone' => config('app.timezone'),
];
}
/**
* 시스템 정보 probe 를 안전하게 실행합니다.
*
* probe 가 예외(ErrorException·Error·disable_functions 로 인한 Error 포함)를
* 던지면 전파하지 않고 폴백값을 반환하고 경고 로그를 남깁니다. 개별 항목 수집
* 실패가 system-info API 전체를 500 으로 만들지 않도록 격리합니다.
*
* @param string $label 실패 로그 식별용 항목명 (예: 'cpu_info')
* @param callable $cb 실행할 probe 콜백
* @param mixed $fallback 실패 시 반환할 폴백 값
* @return mixed probe 결과 또는 폴백
*/
private function safeSystemProbe(string $label, callable $cb, mixed $fallback): mixed
{
try {
return $cb();
} catch (\Throwable $e) {
Log::warning('시스템 정보 수집 실패', [
'item' => $label,
'error' => $e->getMessage(),
]);
return $fallback;
}
}
/**
* 메모리/디스크 사용량 조회 실패 시 사용할 폴백 배열을 반환합니다.
*
* total/used/free/percentage 형식을 memory_usage·disk_usage 와 동일하게 유지합니다.
*
* @return array 사용량 폴백 배열
*/
private function unknownUsage(): array
{
return [
'total' => __('common.unknown'),
'used' => __('common.unknown'),
'free' => __('common.unknown'),
'percentage' => 0,
];
}
/**
* 시스템 캐시를 정리합니다.
*
@@ -859,10 +907,12 @@ class SettingsService
}
Artisan::call('cache:clear');
Artisan::call('config:clear');
Artisan::call('route:clear');
Artisan::call('view:clear');
// config 는 clear 후 즉시 재생성 (비운 채 두면 이후 모든 요청이 config 재파싱).
ConfigCacheHelper::rebuild();
return true;
} catch (\Exception $e) {
return false;
@@ -908,7 +958,8 @@ class SettingsService
$envContent = preg_replace('/^APP_KEY=.*/m', 'APP_KEY='.$newKey, $envContent);
file_put_contents($envPath, $envContent);
Artisan::call('config:clear');
// .env 변경 반영 + config 캐시 재생성 (clear 만 하면 캐시 비활성 상태로 잔존).
ConfigCacheHelper::rebuild();
return [
'success' => true,
@@ -949,11 +1000,16 @@ class SettingsService
try {
$connection = DB::connection();
$driver = $connection->getDriverName();
$version = $connection->select('SELECT VERSION() as version')[0]->version ?? 'Unknown';
$version = $connection->select('SELECT VERSION() as version')[0]->version ?? __('common.unknown');
return ucfirst($driver).' '.$version;
} catch (\Exception $e) {
return 'Unknown';
} catch (\Throwable $e) {
Log::warning('시스템 정보 수집 실패', [
'item' => 'mysql_version',
'error' => $e->getMessage(),
]);
return __('common.unknown');
}
}
@@ -999,12 +1055,7 @@ class SettingsService
}
if ($total <= 0) {
return [
'total' => __('common.unknown'),
'used' => __('common.unknown'),
'free' => __('common.unknown'),
'percentage' => 0,
];
return $this->unknownUsage();
}
$used = max(0, $total - $free);
@@ -1034,12 +1085,7 @@ class SettingsService
$free = is_numeric($freeRaw) ? (int) $freeRaw : 0;
if ($total <= 0) {
return [
'total' => __('common.unknown'),
'used' => __('common.unknown'),
'free' => __('common.unknown'),
'percentage' => 0,
];
return $this->unknownUsage();
}
$used = max(0, $total - $free);
@@ -1077,7 +1123,7 @@ class SettingsService
*
* @return string CPU 정보 문자열
*/
private function getCpuInfo(): string
protected function getCpuInfo(): string
{
if (PHP_OS_FAMILY === 'Windows') {
$output = @shell_exec('powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Processor | Select-Object -First 1).Name" 2>&1');
@@ -18,7 +18,7 @@ use Illuminate\Support\Str;
* 레이아웃 편집 중 업로드되는 파일(배경 이미지 등)의 업로드·조회·삭제를 처리한다.
* 파일 저장은 코어 StorageInterface(CoreStorageDriver)를 통해서만 수행하고,
* 저장 위치(disk/path)와 메타데이터를 template_layout_attachments 에 기록한다.
* (Storage::disk() 직접 호출 금지 — CLAUDE.md 스토리지 규칙)
* (Storage::disk() 직접 호출 금지 — 코어 스토리지 규칙)
*/
class TemplateLayoutAttachmentService
{
@@ -114,7 +114,7 @@ class TemplateLayoutAttachmentService
* 첨부 파일을 삭제합니다 — 스토리지 파일 실삭제 후 DB 행 삭제.
*
* DB CASCADE 에 의존하지 않고 스토리지 파일을 명시적으로 삭제한다
* (CLAUDE.md "DB CASCADE 의존 삭제 금지" — 파일 정리 보장).
* (코어 규정: DB CASCADE 의존 삭제 금지 — 파일 정리 보장).
*
* @param TemplateLayoutAttachment $attachment 삭제할 첨부 파일
* @return bool 삭제 성공 여부
+385
View File
@@ -0,0 +1,385 @@
<?php
namespace App\Support\ApiDoc;
use App\Contracts\ApiDoc\ApiDocSampleSeeder;
use App\Models\ActivityLog;
use App\Models\LanguagePack;
use App\Models\Menu;
use App\Models\NotificationDefinition;
use App\Models\NotificationLog;
use App\Models\NotificationTemplate;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Schedule;
use App\Models\User;
/**
* API 문서 실측용 완전 샘플 데이터 서비스
*
* 각 API 도메인의 대표 엔티티에 모든 필드가 채워진 완전한 샘플 레코드를
* 멱등하게 생성하고, docgen 이 상세 GET 실측에 사용할 도메인별 대표 route key
* 맵을 제공합니다. 개발 환경 전용 — 생성된 샘플은 개발 DB 에 남습니다.
*/
class ApiDocSampleService implements ApiDocSampleSeeder
{
/**
* @var string 샘플 레코드 식별용 마커 (이메일/식별자 prefix)
*/
private const MARKER = 'apidoc-sample';
/**
* 도메인별 완전 샘플을 멱등 생성하고 대표 route key 맵을 반환합니다.
*
* @return array<string, array{model: class-string, key: string, value: string}> 도메인 => 대표 레코드 정보
*/
public function seed(): array
{
$map = [];
// permissions → roles → users 순: user 가 role 을, role 이 permission 을 참조
$map['permissions'] = $this->seedPermission();
$map['roles'] = $this->seedRole();
$map['users'] = $this->seedUser();
$map['menus'] = $this->seedMenu();
$map['notification-definitions'] = $this->seedNotificationDefinition();
$map['schedules'] = $this->seedSchedule();
$map['activity-logs'] = $this->seedActivityLog();
$map['language-packs'] = $this->seedLanguagePack();
$map['notification-logs'] = $this->seedNotificationLog();
$map['notification-templates'] = $this->seedNotificationTemplate();
return array_filter($map);
}
/**
* 완전한 사용자 샘플을 생성하고 roles 관계를 연결합니다.
*
* @return array{model: class-string, key: string, value: string} 대표 레코드 정보
*/
private function seedUser(): array
{
$user = User::query()->where('email', self::MARKER.'-user@example.com')->first();
if (! $user) {
$user = User::factory()->complete()->create([
'name' => 'API 문서 샘플 사용자',
'email' => self::MARKER.'-user@example.com',
]);
$role = Role::query()->where('identifier', self::MARKER.'-role')->first()
?? Role::query()->where('identifier', 'admin')->first()
?? Role::query()->first()
?? Role::factory()->create(['identifier' => self::MARKER.'-role-fallback']);
$user->roles()->syncWithoutDetaching([$role->id]);
}
return ['model' => User::class, 'key' => $user->getRouteKeyName(), 'value' => (string) $user->getRouteKey()];
}
/**
* 완전한 역할 샘플을 생성하고 permissions 관계를 연결합니다.
*
* @return array{model: class-string, key: string, value: string} 대표 레코드 정보
*/
private function seedRole(): array
{
$role = Role::query()->where('identifier', self::MARKER.'-role')->first();
if (! $role) {
$role = Role::factory()->create([
'identifier' => self::MARKER.'-role',
'name' => ['ko' => 'API 문서 샘플 역할', 'en' => 'API Doc Sample Role'],
'description' => ['ko' => '문서 실측용 역할', 'en' => 'Sample role for API docs'],
'is_active' => true,
]);
$permissionIds = Permission::query()->limit(3)->pluck('id')->all();
if ($permissionIds !== []) {
$role->permissions()->syncWithoutDetaching($permissionIds);
}
}
return ['model' => Role::class, 'key' => $role->getRouteKeyName(), 'value' => (string) $role->getRouteKey()];
}
/**
* 완전한 권한 샘플(부모-자식 계층)을 생성합니다.
*
* @return array{model: class-string, key: string, value: string} 대표 레코드 정보
*/
private function seedPermission(): array
{
$permission = Permission::query()->where('identifier', self::MARKER.'.parent')->first();
if (! $permission) {
$permission = Permission::factory()->create([
'identifier' => self::MARKER.'.parent',
'name' => ['ko' => 'API 문서 샘플 권한', 'en' => 'API Doc Sample Permission'],
'description' => ['ko' => '문서 실측용 권한', 'en' => 'Sample permission'],
'type' => 'admin',
]);
Permission::factory()->create([
'parent_id' => $permission->id,
'identifier' => self::MARKER.'.child',
'name' => ['ko' => '하위 권한', 'en' => 'Child Permission'],
'type' => 'admin',
]);
}
return ['model' => Permission::class, 'key' => $permission->getRouteKeyName(), 'value' => (string) $permission->getRouteKey()];
}
/**
* 완전한 메뉴 샘플(부모-자식)을 생성합니다.
*
* @return array{model: class-string, key: string, value: string} 대표 레코드 정보
*/
private function seedMenu(): array
{
$menu = Menu::query()->where('slug', self::MARKER.'-menu')->first();
if (! $menu) {
$creator = User::query()->where('email', self::MARKER.'-user@example.com')->first();
$menu = Menu::factory()->create([
'name' => ['ko' => 'API 문서 샘플 메뉴', 'en' => 'API Doc Sample Menu'],
'slug' => self::MARKER.'-menu',
'url' => '/admin/apidoc-sample',
'icon' => 'fas fa-book',
'is_active' => true,
'created_by' => $creator?->id,
]);
Menu::factory()->create([
'name' => ['ko' => '하위 메뉴', 'en' => 'Child Menu'],
'slug' => self::MARKER.'-menu-child',
'parent_id' => $menu->id,
'created_by' => $creator?->id,
]);
}
return ['model' => Menu::class, 'key' => $menu->getRouteKeyName(), 'value' => (string) $menu->getRouteKey()];
}
/**
* 완전한 알림 정의 샘플(템플릿 포함)을 생성합니다.
*
* @return array{model: class-string, key: string, value: string} 대표 레코드 정보
*/
private function seedNotificationDefinition(): array
{
$definition = NotificationDefinition::query()->where('type', self::MARKER.'.event')->first();
if (! $definition) {
$definition = NotificationDefinition::factory()->create([
'type' => self::MARKER.'.event',
'name' => ['ko' => 'API 문서 샘플 알림', 'en' => 'API Doc Sample Notification'],
'description' => ['ko' => '문서 실측용 알림 정의', 'en' => 'Sample notification'],
'channels' => ['database', 'mail'],
'is_active' => true,
]);
}
return ['model' => NotificationDefinition::class, 'key' => $definition->getRouteKeyName(), 'value' => (string) $definition->getRouteKey()];
}
/**
* 완전한 스케줄 샘플(실행 이력 포함)을 생성합니다.
*
* @return array{model: class-string, key: string, value: string} 대표 레코드 정보
*/
private function seedSchedule(): array
{
$schedule = Schedule::query()->where('name', 'API 문서 샘플 스케줄')->first();
if (! $schedule) {
$creator = $this->sampleUser();
$schedule = Schedule::create([
'name' => 'API 문서 샘플 스케줄',
'description' => '문서 실측용 스케줄',
'type' => 'artisan',
'command' => 'cache:clear',
'expression' => '0 3 * * *',
'frequency' => 'daily',
'without_overlapping' => true,
'run_in_maintenance' => false,
'timeout' => 300,
'is_active' => true,
'last_result' => 'success',
'last_run_at' => now()->subDay(),
'next_run_at' => now()->addDay(),
'created_by' => $creator?->id,
]);
}
return ['model' => Schedule::class, 'key' => $schedule->getRouteKeyName(), 'value' => (string) $schedule->getRouteKey()];
}
/**
* 완전한 활동 로그 샘플(actor + changes 포함)을 생성합니다.
*
* @return array{model: class-string, key: string, value: string} 대표 레코드 정보
*/
private function seedActivityLog(): array
{
$log = ActivityLog::query()->where('description_key', 'apidoc.sample.action')->first();
if (! $log) {
$user = $this->sampleUser();
$log = ActivityLog::create([
'log_type' => 'admin',
'loggable_type' => User::class,
'loggable_id' => $user?->id,
'user_id' => $user?->id,
'action' => 'user.update',
'description_key' => 'apidoc.sample.action',
'description_params' => ['name' => 'API 문서 샘플'],
'properties' => ['source' => 'apidoc'],
'changes' => ['status' => ['old' => 'inactive', 'new' => 'active']],
'ip_address' => '127.0.0.1',
'user_agent' => 'ApiDocgen/1.0',
'created_at' => now(),
]);
}
return ['model' => ActivityLog::class, 'key' => $log->getRouteKeyName(), 'value' => (string) $log->getRouteKey()];
}
/**
* 완전한 언어팩 샘플(manifest 채움)을 생성합니다.
*
* `scope=core` + `status=active` 조합은 그 locale 을 시스템 전역 지원 로케일로 승격시킨다
* (LanguagePackRepository::getActiveCoreLocales → refreshSupportedLocales →
* config('app.supported_locales'/'app.translatable_locales')).
*
* 문서용 샘플은 조회 예시를 만들기 위한 것이지 시스템 동작을 바꿀 자격이 없다. 샘플이 그
* 경로를 타면 이후 시드/저장되는 모든 다국어 데이터에 실재하지 않는 로케일 키가 박히고,
* 샘플을 비활성화한 뒤에는 그 키가 검증(TranslatableField)을 통과하지 못해 저장이 막힌다.
*
* 따라서 승격 경로를 벗어난 조합(module 스코프 + installed 상태)으로 생성하며, locale 도
* 실재하는 번들 로케일만 사용한다.
*
* @return array{model: class-string, key: string, value: string} 대표 레코드 정보
*/
private function seedLanguagePack(): array
{
$pack = LanguagePack::query()->where('identifier', 'apidoc-sample-lang')->first();
if (! $pack) {
$installer = $this->sampleUser();
$pack = LanguagePack::create([
'identifier' => 'apidoc-sample-lang',
'vendor' => 'apidoc',
'scope' => 'module',
'target_identifier' => 'apidoc-sample-module',
'locale' => 'ja',
'locale_name' => 'Japanese',
'locale_native_name' => '日本語',
'text_direction' => 'ltr',
'version' => '1.0.0',
'latest_version' => '1.0.0',
'license' => 'MIT',
'description' => ['ko' => '문서 실측용 언어팩', 'en' => 'Sample language pack'],
'status' => 'installed',
'is_protected' => false,
'manifest' => [
'name' => ['ko' => 'API 문서 샘플 언어팩', 'en' => 'API Doc Sample Pack'],
'version' => '1.0.0',
'locale' => 'ja',
],
'source_type' => 'bundled',
'installed_by' => $installer?->id,
'installed_at' => now()->subDays(3),
'activated_at' => null,
]);
}
return ['model' => LanguagePack::class, 'key' => $pack->getRouteKeyName(), 'value' => (string) $pack->getRouteKey()];
}
/**
* 완전한 알림 로그 샘플(수신자/발신자 포함)을 생성합니다.
*
* @return array{model: class-string, key: string, value: string} 대표 레코드 정보
*/
private function seedNotificationLog(): array
{
$log = NotificationLog::query()->where('notification_type', 'apidoc.sample.event')->first();
if (! $log) {
$user = $this->sampleUser();
$log = NotificationLog::create([
'channel' => 'mail',
'notification_type' => 'apidoc.sample.event',
'extension_type' => 'core',
'extension_identifier' => '',
'recipient_user_id' => $user?->id,
'recipient_identifier' => $user?->email,
'recipient_name' => $user?->name,
'sender_user_id' => $user?->id,
'subject' => 'API 문서 샘플 알림',
'body' => '문서 실측용 알림 본문입니다.',
'status' => 'sent',
'error_message' => null,
'source' => 'apidoc',
'sent_at' => now()->subHour(),
]);
}
return ['model' => NotificationLog::class, 'key' => $log->getRouteKeyName(), 'value' => (string) $log->getRouteKey()];
}
/**
* 완전한 알림 템플릿 샘플(정의 연결)을 생성합니다.
*
* @return array{model: class-string, key: string, value: string} 대표 레코드 정보
*/
private function seedNotificationTemplate(): array
{
$this->seedNotificationDefinition();
$definitionId = NotificationDefinition::query()->where('type', self::MARKER.'.event')->value('id');
$template = NotificationTemplate::query()
->where('definition_id', $definitionId)
->where('channel', 'mail')
->first();
if (! $template && $definitionId) {
$updater = $this->sampleUser();
$template = NotificationTemplate::create([
'definition_id' => $definitionId,
'channel' => 'mail',
'subject' => 'API 문서 샘플 템플릿 제목',
'body' => '안녕하세요 {{name}} 님, 문서 실측용 본문입니다.',
'click_url' => '/admin/apidoc-sample',
'recipients' => [['type' => 'role', 'value' => 'admin']],
'is_active' => true,
'is_default' => false,
'updated_by' => $updater?->id,
]);
}
if (! $template) {
return [];
}
return ['model' => NotificationTemplate::class, 'key' => $template->getRouteKeyName(), 'value' => (string) $template->getRouteKey()];
}
/**
* 시드된 완전 샘플 사용자를 반환합니다 (연관 엔티티의 소유자/actor 용).
*
* @return User|null 샘플 사용자 (없으면 null)
*/
private function sampleUser(): ?User
{
return User::query()->where('email', self::MARKER.'-user@example.com')->first();
}
}
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
<?php
namespace App\Support\ApiDoc;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Laravel\Sanctum\PersonalAccessToken;
/**
* API 엔드포인트 실측 프로브
*
* 임시 Sanctum 토큰을 발급해 실제 HTTP 요청으로 엔드포인트를 호출하고,
* 실제 응답 JSON 에서 필드 스키마(키·타입·샘플값)를 관측합니다.
* 쓰기 메서드는 기본적으로 실호출하지 않으며 GET/HEAD 만 read-only 로 실측합니다.
*/
class ApiEndpointProbe
{
/**
* @var string 실측 대상 기준 URL
*/
private string $baseUrl;
/**
* @var string|null 발급된 임시 토큰 평문
*/
private ?string $token = null;
/**
* @var string 임시 토큰 식별용 이름
*/
private string $tokenName = 'api-docgen-probe';
/**
* @var User|null 실측 인증 사용자 (in-process 쓰기 실측 시 로그인 대상)
*/
private ?User $user = null;
/**
* @param string|null $baseUrl 기준 URL (null 이면 .env 의 APP_URL 직접 사용)
*/
public function __construct(?string $baseUrl = null)
{
// config('app.url') 은 테스트 환경에서 override 될 수 있으므로(test.example.com 등),
// 실측은 .env 의 APP_URL 을 우선 신뢰한다. 명시 인자가 있으면 그것을 최우선한다.
$resolved = $baseUrl
?: (string) env('APP_URL')
?: (string) config('app.url');
$this->baseUrl = rtrim($resolved, '/');
}
/**
* 실측 기준 URL 을 반환합니다.
*
* @return string 기준 URL
*/
public function baseUrl(): string
{
return $this->baseUrl;
}
/**
* 실측용 관리자 토큰을 발급합니다.
*
* @param int|null $userId 토큰 발급 대상 사용자 ID (null 이면 첫 관리자)
* @return bool 발급 성공 여부
*/
public function authenticate(?int $userId = null): bool
{
$user = $userId
? User::find($userId)
: User::query()->orderBy('id')->first();
if (! $user) {
return false;
}
$this->user = $user;
$this->cleanupTokens();
$this->token = $user->createToken($this->tokenName)->plainTextToken;
return true;
}
/**
* 엔드포인트를 실호출하여 응답을 관측합니다.
*
* GET/HEAD 는 외부 HTTP 로 read-only 실측하고, 쓰기 메서드(POST/PUT/PATCH/DELETE)는
* **DB 트랜잭션 안에서 in-process 로 dispatch 한 뒤 롤백**하여(영속 안 함) 응답 shape 만
* 관측한다. 외부 HTTP 는 별도 프로세스라 롤백이 불가하므로 쓰기는 in-process 경로를 쓴다.
*
* @param string $method HTTP 메서드
* @param string $uri 라우트 URI (path 파라미터 치환 완료된 실제 경로)
* @param array<string, mixed> $body 쓰기 요청 바디 (쓰기 메서드에서만 사용)
* @return array{ok: bool, status: int|null, body: array<string, mixed>|null, skipped_reason: string|null}
*/
public function probe(string $method, string $uri, array $body = []): array
{
$method = strtoupper($method);
// path 파라미터가 남아 있으면(치환 실패) 실호출 불가.
if (Str::contains($uri, '{')) {
return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'unresolved-path-param'];
}
if (! $this->token) {
return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'no-token'];
}
if (in_array($method, ['GET', 'HEAD'], true)) {
return $this->probeRead($method, $uri);
}
return $this->probeWrite($method, $uri, $body);
}
/**
* GET/HEAD 를 외부 HTTP 로 read-only 실측합니다.
*
* @param string $method HTTP 메서드
* @param string $uri 치환된 URI
* @return array{ok: bool, status: int|null, body: array<string, mixed>|null, skipped_reason: string|null}
*/
private function probeRead(string $method, string $uri): array
{
try {
$response = Http::withoutVerifying()
->withToken($this->token)
->acceptJson()
->timeout(15)
->get($this->baseUrl.$uri);
$json = $response->json();
return [
'ok' => $response->successful() && is_array($json),
'status' => $response->status(),
'body' => is_array($json) ? $json : null,
'skipped_reason' => null,
];
} catch (\Throwable $e) {
return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'request-failed: '.$e->getMessage()];
}
}
/**
* 쓰기 메서드를 DB 트랜잭션 안에서 in-process dispatch 후 롤백하여 실측합니다.
*
* 응답 shape 만 관측하고 부수효과(레코드 생성/수정/삭제)는 롤백으로 영속시키지 않는다.
* 인증은 샘플 사용자로 로그인(guard)해 수행하며, DELETE 등 바디 없는 메서드도 처리한다.
*
* @param string $method HTTP 메서드
* @param string $uri 치환된 URI
* @param array<string, mixed> $body 요청 바디
* @return array{ok: bool, status: int|null, body: array<string, mixed>|null, skipped_reason: string|null}
*/
private function probeWrite(string $method, string $uri, array $body): array
{
if (! $this->user) {
return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'no-token'];
}
DB::beginTransaction();
try {
// in-process dispatch 는 실제 Bearer 토큰 헤더로 인증한다(guard 종류 무관).
// Auth::login() 은 API 기본 guard 가 RequestGuard(토큰 전용)라 사용할 수 없다.
$request = Request::create($uri, $method, $body);
$request->headers->set('Accept', 'application/json');
$request->headers->set('Authorization', 'Bearer '.$this->token);
$response = app()->handle($request);
$content = $response->getContent();
$json = json_decode(is_string($content) ? $content : '', true);
$status = $response->getStatusCode();
return [
'ok' => $status >= 200 && $status < 300 && is_array($json),
'status' => $status,
'body' => is_array($json) ? $json : null,
'skipped_reason' => (is_array($json) && $status >= 200 && $status < 300) ? null : 'http-'.$status,
];
} catch (\Throwable $e) {
return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'write-probe-failed: '.$e->getMessage()];
} finally {
// 쓰기 부수효과를 영속시키지 않는다 (문서 생성 목적).
DB::rollBack();
// 다음 실측에 인증 컨텍스트가 새지 않도록 해석된 guard 를 초기화한다
// (API guard 는 RequestGuard 라 logout() 이 없으므로 forgetGuards 사용).
Auth::forgetGuards();
}
}
/**
* 발급한 임시 토큰을 정리합니다.
*/
public function cleanup(): void
{
$this->cleanupTokens();
$this->token = null;
}
/**
* 실측용 토큰 레코드를 모두 삭제합니다.
*/
private function cleanupTokens(): void
{
PersonalAccessToken::query()
->where('name', $this->tokenName)
->delete();
}
}
+442
View File
@@ -0,0 +1,442 @@
<?php
namespace App\Support\ApiDoc;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Str;
/**
* API 라우트 인벤토리
*
* 등록된 라우트를 전수 수집하여 API 문서 생성에 필요한 메타데이터
* (메서드·URI·라우트명·소유 확장·권한·컨트롤러 액션)로 정규화합니다.
*/
class ApiRouteInventory
{
/**
* API 라우트를 전수 수집하여 정규화된 배열로 반환합니다.
*
* @param string|null $scope 범위 필터 (null=전체, 'core', 'module:{id}', 'plugin:{id}')
* @return array<int, array<string, mixed>> 정규화된 라우트 메타데이터 목록
*/
public function collect(?string $scope = null): array
{
$routes = [];
foreach (RouteFacade::getRoutes() as $route) {
/** @var Route $route */
$uri = $route->uri();
if (! Str::startsWith($uri, 'api/')) {
continue;
}
$name = $route->getName() ?? '';
$owner = $this->resolveOwner($name, $uri);
if ($scope !== null && ! $this->matchesScope($owner, $scope)) {
continue;
}
foreach ($this->normalizeRoute($route, $uri, $name, $owner) as $entry) {
$routes[] = $entry;
}
}
// route:list 는 활성 확장만 노출한다. 비활성/미설치 확장을 명시 범위로 지정하면
// (module:{id} / plugin:{id}) 등록된 라우트가 0건이 되므로, 그 확장의 번들 라우트
// 파일을 프로바이더와 동일한 prefix/name 규약으로 임시 라우터에 로드해 폴백 수집한다.
if ($routes === [] && $scope !== null && $scope !== 'core' && $scope !== 'all') {
$routes = $this->collectFromBundledFiles($scope);
}
usort($routes, fn ($a, $b) => [$a['owner']['key'], $a['uri'], $a['method']] <=> [$b['owner']['key'], $b['uri'], $b['method']]);
return $routes;
}
/**
* 하나의 라우트를 HTTP 메서드별 정규화 엔트리 배열로 변환합니다.
*
* @param Route $route 라우트 인스턴스
* @param string $uri 라우트 URI ('api/' prefix 포함)
* @param string $name 라우트명
* @param array{type: string, id: string|null, key: string} $owner 소유 주체
* @return array<int, array<string, mixed>> 정규화된 엔트리 목록
*/
private function normalizeRoute(Route $route, string $uri, string $name, array $owner): array
{
$entries = [];
$middleware = $this->gatherMiddleware($route);
foreach ($this->httpMethods($route) as $method) {
$entries[] = [
'method' => $method,
'uri' => '/'.ltrim($uri, '/'),
'name' => $name,
'owner' => $owner,
'action' => $route->getActionName(),
'controller' => $this->controllerClass($route),
'controller_method' => $this->controllerMethod($route),
'middleware' => $middleware,
'permission' => $this->resolvePermission($middleware),
'path_params' => $this->pathParams($uri),
'path_bindings' => $this->pathBindings($route),
'domain_group' => $this->domainGroup($name, $uri, $owner),
];
}
return $entries;
}
/**
* 라우트의 미들웨어 목록을 안전하게 수집합니다.
*
* `gatherMiddleware()` 는 컨트롤러 정의 미들웨어를 읽기 위해 컨트롤러를 인스턴스화한다.
* 비활성/미설치 확장은 서비스 바인딩이 부팅되지 않아 인스턴스화가 실패할 수 있으므로,
* 실패 시 라우트에 직접 부여된 정적 미들웨어(`middleware()`)로 폴백한다.
*
* @param Route $route 라우트 인스턴스
* @return array<int, string> 미들웨어 목록
*/
private function gatherMiddleware(Route $route): array
{
try {
return $route->gatherMiddleware();
} catch (\Throwable) {
return $route->middleware();
}
}
/**
* 비활성/미설치 확장의 번들 라우트 파일을 임시 라우터에 로드해 수집합니다.
*
* 프로바이더(`ModuleRouteServiceProvider`/`PluginRouteServiceProvider`)와 동일한
* prefix(`api/{modules|plugins}/{id}`)·name(`api.{modules|plugins}.{id}.`)·`api`
* 미들웨어 그룹으로 `src/routes/api.php` 를 라우터에 로드한다. `api/` 로 시작하는
* 라우트만 대상이므로 web(admin) 라우트는 자동 제외된다(그 라우트는 API 문서 대상이 아니다).
*
* 라우트 파일은 `use Illuminate\Support\Facades\Route` 로 전역 파사드에 등록하므로,
* 등록 전 라우트 이름 스냅샷을 떠서 이번 로드로 추가된 라우트만 골라낸다. 이 커맨드는
* CLI 단발 프로세스라 전역 라우트 테이블 추가가 웹 요청에 영향을 주지 않는다.
*
* @param string $scope 범위 필터 (`module:{id}` / `plugin:{id}`)
* @return array<int, array<string, mixed>> 정규화된 라우트 메타데이터 목록
*/
private function collectFromBundledFiles(string $scope): array
{
if (! preg_match('/^(module|plugin):(.+)$/', $scope, $m)) {
return [];
}
[$type, $id] = [$m[1], $m[2]];
$dir = $type === 'module' ? 'modules' : 'plugins';
$apiRouteFile = base_path("{$dir}/_bundled/{$id}/src/routes/api.php");
if (! is_file($apiRouteFile)) {
return [];
}
$urlPrefix = "api/{$dir}/{$id}";
$namePrefix = "api.{$dir}.{$id}.";
try {
RouteFacade::prefix($urlPrefix)
->name($namePrefix)
->middleware('api')
->group($apiRouteFile);
RouteFacade::getRoutes()->refreshNameLookups();
} catch (\Throwable) {
return [];
}
// 이 확장 소유로 확정된 라우트를 전역 라우트 테이블에서 prefix(uri/name) 로 직접 골라낸다.
// object-id 스냅샷 방식은 refreshNameLookups() 가 RouteCollection 을 재색인하며 객체를
// 다시 만들어 dedup 이 어긋나므로(활성 확장 로드 순서·중복 로드에 취약) 쓰지 않는다.
// 대상 확장은 uri 가 `api/{dir}/{id}/`, name 이 `api.{dir}.{id}.` 로 시작해 결정적으로 식별된다.
$seen = [];
$routes = [];
foreach (RouteFacade::getRoutes() as $route) {
/** @var Route $route */
$uri = $route->uri();
if (! Str::startsWith($uri, 'api/')) {
continue;
}
$name = $route->getName() ?? '';
$owner = $this->resolveOwner($name, $uri);
// 폴백 대상 확장 소유로 확정되지 않으면(코어·타 확장) 제외
if ($owner['key'] !== $scope) {
continue;
}
foreach ($this->normalizeRoute($route, $uri, $name, $owner) as $entry) {
// 파일이 이미 한 번 로드돼 있으면 같은 (method, uri) 가 중복될 수 있어 dedup 한다.
$dedupKey = $entry['method'].' '.$entry['uri'];
if (isset($seen[$dedupKey])) {
continue;
}
$seen[$dedupKey] = true;
$routes[] = $entry;
}
}
return $routes;
}
/**
* 라우트명/URI 로 소유 주체(코어/모듈/플러그인)를 판별합니다.
*
* @param string $name 라우트명
* @param string $uri 라우트 URI
* @return array{type: string, id: string|null, key: string} 소유 주체 정보
*/
private function resolveOwner(string $name, string $uri): array
{
if (preg_match('/^api\.modules\.([^.]+)\./', $name, $m) && $this->isRealExtensionId($m[1])) {
return ['type' => 'module', 'id' => $m[1], 'key' => 'module:'.$m[1]];
}
if (preg_match('/^api\.plugins\.([^.]+)\./', $name, $m) && $this->isRealExtensionId($m[1])) {
return ['type' => 'plugin', 'id' => $m[1], 'key' => 'plugin:'.$m[1]];
}
// 라우트명이 비어도 URI prefix 로 보조 판별
if (preg_match('#^api/modules/([^/{]+)/#', $uri, $m) && $this->isRealExtensionId($m[1])) {
return ['type' => 'module', 'id' => $m[1], 'key' => 'module:'.$m[1]];
}
if (preg_match('#^api/plugins/([^/{]+)/#', $uri, $m) && $this->isRealExtensionId($m[1])) {
return ['type' => 'plugin', 'id' => $m[1], 'key' => 'plugin:'.$m[1]];
}
// 코어가 제공하는 확장 메타/에셋 라우트(/api/modules/{identifier}/license,
// /api/modules/bundle.js 등)는 실제 확장 소유가 아니라 코어 소유다.
return ['type' => 'core', 'id' => null, 'key' => 'core'];
}
/**
* 세그먼트가 실제 확장 식별자인지 판별합니다 (플레이스홀더/에셋 라우트 제외).
*
* @param string $segment URI/라우트명 세그먼트
* @return bool 실제 확장 식별자 여부
*/
private function isRealExtensionId(string $segment): bool
{
// {identifier} 같은 라우트 파라미터, bundle/assets 같은 코어 에셋 라우트 제외.
// 실제 확장 식별자는 vendor-name 형태로 하이픈을 포함한다.
if (Str::startsWith($segment, '{') || in_array($segment, ['assets', 'bundle', 'bundle.js', 'bundle.css'], true)) {
return false;
}
return Str::contains($segment, '-');
}
/**
* 소유 주체가 지정된 범위 필터에 매칭되는지 확인합니다.
*
* @param array{type: string, id: string|null, key: string} $owner 소유 주체
* @param string $scope 범위 필터
* @return bool 매칭 여부
*/
private function matchesScope(array $owner, string $scope): bool
{
if ($scope === 'all') {
return true;
}
if ($scope === 'core') {
return $owner['type'] === 'core';
}
return $owner['key'] === $scope;
}
/**
* 라우트의 HTTP 메서드 목록을 반환합니다 (HEAD 제외).
*
* @param Route $route 라우트 인스턴스
* @return array<int, string> HTTP 메서드 목록
*/
private function httpMethods(Route $route): array
{
return array_values(array_filter(
$route->methods(),
fn ($m) => $m !== 'HEAD'
));
}
/**
* 컨트롤러 클래스명을 반환합니다.
*
* @param Route $route 라우트 인스턴스
* @return string|null 컨트롤러 FQCN (클로저면 null)
*/
private function controllerClass(Route $route): ?string
{
$action = $route->getActionName();
if ($action === 'Closure' || ! Str::contains($action, '@')) {
return null;
}
return Str::before($action, '@');
}
/**
* 컨트롤러 메서드명을 반환합니다.
*
* @param Route $route 라우트 인스턴스
* @return string|null 메서드명 (클로저면 null)
*/
private function controllerMethod(Route $route): ?string
{
$action = $route->getActionName();
if ($action === 'Closure' || ! Str::contains($action, '@')) {
return null;
}
return Str::after($action, '@');
}
/**
* 미들웨어 목록에서 permission 식별자를 추출합니다.
*
* @param array<int, string> $middleware 미들웨어 목록
* @return string|null permission 식별자 (예: core.users.read)
*/
private function resolvePermission(array $middleware): ?string
{
foreach ($middleware as $mw) {
if (! Str::contains($mw, 'PermissionMiddleware') && ! Str::startsWith($mw, 'permission:')) {
continue;
}
$args = Str::after($mw, ':');
// permission:admin,core.users.read,except:self:user → core.users.read
foreach (explode(',', $args) as $part) {
$part = trim($part);
if (Str::contains($part, '.') && ! Str::startsWith($part, 'except')) {
return $part;
}
}
}
return null;
}
/**
* URI 의 path 파라미터 목록을 추출합니다.
*
* @param string $uri 라우트 URI
* @return array<int, string> path 파라미터명 목록
*/
private function pathParams(string $uri): array
{
preg_match_all('/\{([^}?]+)\??\}/', $uri, $m);
return $m[1] ?? [];
}
/**
* path 파라미터명 → 바인딩된 Eloquent 모델 클래스 맵을 추출합니다.
*
* @param Route $route 라우트 인스턴스
* @return array<string, string> 파라미터명 => 모델 FQCN
*/
private function pathBindings(Route $route): array
{
$bindings = [];
try {
foreach ($route->signatureParameters() as $param) {
$type = $param->getType();
if ($type instanceof \ReflectionNamedType && ! $type->isBuiltin()) {
$class = $type->getName();
if (is_subclass_of($class, Model::class)) {
$bindings[$param->getName()] = $class;
}
}
}
} catch (\Throwable) {
// signatureParameters 실패 시 빈 맵
}
return $bindings;
}
/**
* 문서 파일 그룹핑용 도메인 키를 산출합니다.
*
* @param string $name 라우트명
* @param string $uri 라우트 URI
* @param array{type: string, id: string|null, key: string} $owner 소유 주체
* @return string 도메인 키 (예: users, products)
*/
private function domainGroup(string $name, string $uri, array $owner): string
{
// 라우트명 구조: api.{context}.{resource}.{action} 또는
// api.modules.{ext-id}.{context}.{resource}.{action}
// 도메인 = 앞쪽 연속된 context/소유 세그먼트를 걷어낸 뒤의 첫 세그먼트(리소스).
// 위치 기반이므로 'auth' 같은 리소스가 skip 리스트에 걸려 소실되지 않는다.
// 'modules'/'plugins' 는 확장 소유 prefix(api.modules.{id}.*)로도, 코어 확장관리
// 리소스(api.admin.modules.*)로도 쓰인다. 소유 prefix 는 owner['id'] 스킵으로 이미
// 걷히므로, leading 에는 순수 컨텍스트(api/admin/user/me/public)만 둔다.
$leading = ['api', 'admin', 'user', 'me', 'public'];
$segments = array_values(array_filter(explode('.', $name), fn ($s) => $s !== ''));
$i = 0;
while ($i < count($segments)) {
$seg = $segments[$i];
// 확장 소유 prefix 세그먼트(modules/plugins + ext-id)만 스킵
$isOwnerPrefix = in_array($seg, ['modules', 'plugins'], true) && isset($segments[$i + 1]) && $segments[$i + 1] === $owner['id'];
$isLeading = in_array($seg, $leading, true) || $isOwnerPrefix || ($owner['id'] !== null && $seg === $owner['id']);
if (! $isLeading) {
break;
}
$i++;
}
// context(me 등) 직후가 REST 액션명이면 리소스가 아니라 그 컨텍스트가 도메인이다
// (api.me.show → 'me', api.me.destroy → 'me'). 아니면 그 세그먼트가 리소스.
$restActions = ['show', 'index', 'store', 'update', 'destroy', 'edit', 'create'];
if (isset($segments[$i])) {
if (in_array($segments[$i], $restActions, true) && $i > 0 && in_array($segments[$i - 1], $leading, true)) {
return Str::slug($segments[$i - 1]) ?: 'misc';
}
return Str::slug($segments[$i]) ?: 'misc';
}
// 리소스 세그먼트가 없으면(context 직속) 마지막 컨텍스트를 도메인으로.
if ($i > 0 && isset($segments[$i - 1]) && in_array($segments[$i - 1], $leading, true)) {
return Str::slug($segments[$i - 1]) ?: 'misc';
}
// 라우트명이 비었으면 URI 에서 소유/컨텍스트 prefix 를 걷어낸 첫 리소스 세그먼트
$parts = array_values(array_filter(explode('/', $uri), fn ($p) => $p !== '' && ! Str::startsWith($p, '{')));
$uriLeading = ['api', 'admin', 'user', 'me', 'public'];
foreach ($parts as $p) {
if (in_array($p, $uriLeading, true) || ($owner['id'] !== null && $p === $owner['id'])) {
continue;
}
if (in_array($p, ['modules', 'plugins'], true) && ($owner['id'] !== null)) {
continue;
}
return Str::slug($p) ?: 'misc';
}
return 'misc';
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Support\ApiDoc;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Schema;
/**
* 컬럼 주석 해석기
*
* 모델 테이블의 컬럼 주석(한국어 comment) 을 조회해 응답 필드 설명의 기본값으로
* 제공합니다. G7 은 마이그레이션 한국어 comment 를 필수화하므로, 이 주석이 필드
* 설명의 SSoT 가 됩니다. 정적 스키마 조회이므로 실측 없이도 동작합니다.
*/
class ColumnCommentResolver
{
/**
* @var array<string, array<string, string>> 테이블별 컬럼 주석 캐시
*/
private array $cache = [];
/**
* 모델 테이블의 컬럼명 => 주석 맵을 반환합니다.
*
* @param class-string|null $modelClass 모델 FQCN
* @return array<string, string> 컬럼명 => 주석
*/
public function forModel(?string $modelClass): array
{
if (! $modelClass || ! class_exists($modelClass) || ! is_subclass_of($modelClass, Model::class)) {
return [];
}
try {
$table = (new $modelClass)->getTable();
} catch (\Throwable) {
return [];
}
return $this->forTable($table);
}
/**
* 테이블의 컬럼명 => 주석 맵을 반환합니다 (캐시).
*
* @param string $table 테이블명
* @return array<string, string> 컬럼명 => 주석
*/
public function forTable(string $table): array
{
if (isset($this->cache[$table])) {
return $this->cache[$table];
}
$map = [];
try {
foreach (Schema::getColumns($table) as $column) {
$comment = $column['comment'] ?? null;
if (is_string($comment) && $comment !== '') {
$map[$column['name']] = $comment;
}
}
} catch (\Throwable) {
// 테이블 부재/드라이버 미지원 시 빈 맵
}
return $this->cache[$table] = $map;
}
}
@@ -0,0 +1,200 @@
<?php
namespace App\Support\ApiDoc;
use Illuminate\Foundation\Http\FormRequest;
use ReflectionClass;
use ReflectionMethod;
use ReflectionNamedType;
/**
* FormRequest 정적 분석기
*
* 컨트롤러 메서드 시그니처에서 FormRequest 를 찾아 rules() 를 리플렉션으로
* 읽고, 요청 파라미터의 타입·필수 여부·허용값을 문서용 메타데이터로 변환합니다.
*/
class FormRequestIntrospector
{
/**
* 컨트롤러 메서드의 첫 FormRequest 파라미터에서 rules 메타데이터를 추출합니다.
*
* @param string|null $controller 컨트롤러 FQCN
* @param string|null $method 메서드명
* @return array{request_class: string|null, params: array<int, array<string, mixed>>, hook_filters: array<int, string>}
*/
public function introspect(?string $controller, ?string $method): array
{
$empty = ['request_class' => null, 'params' => [], 'hook_filters' => []];
if (! $controller || ! $method || ! class_exists($controller)) {
return $empty;
}
try {
$ref = new ReflectionMethod($controller, $method);
} catch (\ReflectionException) {
return $empty;
}
$requestClass = $this->findFormRequestParam($ref);
if (! $requestClass) {
return $empty;
}
$rules = $this->extractRules($requestClass);
return [
'request_class' => $requestClass,
'params' => $this->rulesToParams($rules),
'hook_filters' => $this->extractHookFilters($requestClass),
];
}
/**
* 메서드 파라미터 중 FormRequest 하위 클래스를 찾습니다.
*
* @param ReflectionMethod $ref 메서드 리플렉션
* @return class-string<FormRequest>|null FormRequest FQCN
*/
private function findFormRequestParam(ReflectionMethod $ref): ?string
{
foreach ($ref->getParameters() as $param) {
$type = $param->getType();
if (! $type instanceof ReflectionNamedType || $type->isBuiltin()) {
continue;
}
$class = $type->getName();
if (is_subclass_of($class, FormRequest::class)) {
return $class;
}
}
return null;
}
/**
* FormRequest 인스턴스를 만들어 rules() 를 호출합니다.
*
* @param class-string<FormRequest> $requestClass FormRequest FQCN
* @return array<string, mixed> 검증 규칙 배열
*/
private function extractRules(string $requestClass): array
{
try {
$instance = new $requestClass;
if (! method_exists($instance, 'rules')) {
return [];
}
$rules = $instance->rules();
return is_array($rules) ? $rules : [];
} catch (\Throwable) {
// rules() 가 컨테이너/route 의존이면 정적 호출 실패 — 파라미터 표는 비운다.
return [];
}
}
/**
* 검증 규칙 배열을 문서용 파라미터 메타데이터로 변환합니다.
*
* @param array<string, mixed> $rules 검증 규칙 배열
* @return array<int, array<string, mixed>> 파라미터 메타데이터 목록
*/
private function rulesToParams(array $rules): array
{
$params = [];
foreach ($rules as $field => $rule) {
// 중첩 필드(items.*.id)는 상위만 대표로 노출
if (str_contains((string) $field, '.')) {
continue;
}
$tokens = is_array($rule) ? $rule : explode('|', (string) $rule);
$tokens = array_map(fn ($t) => is_string($t) ? $t : '', $tokens);
$params[] = [
'name' => $field,
'type' => $this->inferType($tokens),
'required' => in_array('required', $tokens, true),
'allowed' => $this->inferAllowed($tokens),
];
}
return $params;
}
/**
* 규칙 토큰에서 파라미터 타입을 유추합니다.
*
* @param array<int, string> $tokens 규칙 토큰 목록
* @return string 유추된 타입
*/
private function inferType(array $tokens): string
{
foreach (['integer', 'numeric', 'boolean', 'array', 'string', 'date', 'email', 'file', 'image', 'uuid'] as $type) {
foreach ($tokens as $t) {
if ($t === $type || str_starts_with($t, $type)) {
return $type === 'numeric' ? 'number' : $type;
}
}
}
return 'string';
}
/**
* 규칙 토큰에서 허용값(in:, max:, min: 등)을 유추합니다.
*
* @param array<int, string> $tokens 규칙 토큰 목록
* @return string 허용값 설명 (없으면 빈 문자열)
*/
private function inferAllowed(array $tokens): string
{
$parts = [];
foreach ($tokens as $t) {
if (str_starts_with($t, 'in:')) {
$parts[] = '`'.str_replace(',', '`, `', substr($t, 3)).'`';
} elseif (str_starts_with($t, 'max:')) {
$parts[] = 'max '.substr($t, 4);
} elseif (str_starts_with($t, 'min:')) {
$parts[] = 'min '.substr($t, 4);
} elseif (str_starts_with($t, 'between:')) {
$parts[] = 'between '.substr($t, 8);
}
}
return implode(', ', $parts);
}
/**
* FormRequest 소스에서 HookManager::applyFilters 훅 이름을 추출합니다.
*
* @param class-string<FormRequest> $requestClass FormRequest FQCN
* @return array<int, string> 훅 필터 이름 목록
*/
private function extractHookFilters(string $requestClass): array
{
try {
$file = (new ReflectionClass($requestClass))->getFileName();
if (! $file || ! is_readable($file)) {
return [];
}
$source = file_get_contents($file);
preg_match_all('/applyFilters\(\s*[\'"]([a-z0-9_.-]+)[\'"]/i', $source, $m);
return array_values(array_unique($m[1] ?? []));
} catch (\Throwable) {
return [];
}
}
}
+394
View File
@@ -0,0 +1,394 @@
<?php
namespace App\Support\ApiDoc;
use Illuminate\Support\Str;
/**
* 요청 파라미터 설명기
*
* G7 전역에서 의미가 표준화된 공통 요청 파라미터(페이지네이션·정렬·검색·필터·
* 소프트삭제 토글 등)와, 일관된 명명 규칙(*_id / *Id / is_* / *_date / sort_* 등)의
* 설명을 코드에서 확인된 계약 그대로 서술합니다.
*
* ResourceFieldDescriber(응답 필드)의 요청 파라미터 대응물입니다. 도메인 특이
* 파라미터(예: refund_priority, temp_key)는 여기서 커버하지 않고 사람 서술(TODO)로
* 남깁니다 — 자동 채움은 "도메인 무관하게 의미가 고정된 파라미터"에만 한정합니다.
*/
class ParameterDescriber
{
/**
* @var array<string, string> 정확 이름 => 설명 (위치 무관 공통 파라미터)
*/
private const EXACT = [
// 페이지네이션
'page' => '조회할 페이지 번호 (1부터 시작)',
'per_page' => '페이지당 항목 수',
'limit' => '반환할 최대 항목 수',
'offset' => '건너뛸 항목 수 (오프셋 페이지네이션)',
'cursor' => '커서 기반 페이지네이션의 다음 페이지 커서',
// 정렬
'sort' => '정렬 기준 (필드명, `-` 접두 시 내림차순)',
'sort_by' => '정렬 기준 필드명',
'sort_field' => '정렬 기준 필드명',
'sort_direction' => '정렬 방향 (asc / desc)',
'order_by' => '정렬 기준 필드명',
'direction' => '정렬 방향 (asc / desc)',
// sort_order / order 는 문맥에 따라 정렬 방향(문자열 asc/desc)과
// 표시 순서 값(정수)으로 갈리므로 EXACT 에 두지 않고 describe() 에서
// 타입으로 분기한다.
// 검색/필터
'search' => '검색어 (지정한 검색 대상 필드에서 부분 일치)',
'q' => '검색어 (부분 일치)',
'keyword' => '검색 키워드 (부분 일치)',
'search_keyword' => '검색 키워드 (부분 일치)',
'search_field' => '검색 대상 필드명 (검색어를 적용할 컬럼)',
'search_type' => '검색 유형 (검색 대상/방식 구분)',
'filters' => '추가 필터 조건 맵 (필드별 조건)',
'filter' => '필터 조건',
'start_date' => '조회 기간 시작일 (이 날짜 이후 데이터)',
'end_date' => '조회 기간 종료일 (이 날짜 이전 데이터)',
'date_from' => '조회 기간 시작일',
'date_to' => '조회 기간 종료일',
'from' => '조회 시작 값 (기간/범위 하한)',
'to' => '조회 종료 값 (기간/범위 상한)',
'scope' => '조회 범위 한정 키',
// 상태 토글/플래그
'is_active' => '활성 여부 (true 활성 / false 비활성)',
'is_default' => '기본값 지정 여부',
'active' => '활성 여부',
'published' => '발행 여부 (발행된 항목만 필터)',
'enabled' => '사용 여부',
'force' => '강제 실행 여부 (안전 확인/선행 검사 우회)',
'with_trashed' => '소프트 삭제된 항목 포함 여부',
'only_trashed' => '소프트 삭제된 항목만 조회 여부',
// 대량 처리
'ids' => '대상 리소스 식별자 배열 (대량 작업 대상)',
'items' => '처리 대상 항목 배열',
// 국제화
'locale' => '로케일 코드 (표시 언어/지역)',
'language' => '언어 코드',
'country_code' => '국가 코드 (ISO 3166-1 alpha-2)',
'timezone' => '타임존 식별자',
// 인증/보안 공통
'password' => '비밀번호',
'current_password' => '현재 비밀번호 (변경 전 확인용)',
'password_confirmation' => '비밀번호 확인 (password 와 일치해야 함)',
'token' => '인증/검증 토큰',
'email' => '이메일 주소',
// 주소 공통
'zipcode' => '우편번호',
'address' => '기본 주소',
'address_detail' => '상세 주소',
'recipient_name' => '수령인 이름',
'recipient_phone' => '수령인 연락처',
'address_line_1' => '주소 1행 (기본 주소)',
'address_line_2' => '주소 2행 (상세 주소)',
'intl_city' => '도시 (국제 주소)',
'intl_state' => '주/도 (국제 주소)',
'intl_postal_code' => '우편번호 (국제 주소)',
'region' => '지역/권역',
// SEO 메타 공통 (근거: Seo\* / Page\* / Product\* FormRequest 의
// meta_title/meta_description — 검색엔진 노출용 메타 태그 값. 도메인 무관.)
'meta_title' => 'SEO 메타 제목 (검색엔진/소셜 공유 표시 제목)',
'meta_description' => 'SEO 메타 설명 (검색엔진/소셜 공유 표시 요약)',
'alt_text' => '이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구)',
// 프로필/콘텐츠 공통 필드 (User/프로필/일반 리소스에서 의미 고정)
// 근거: User\{Create,Update}UserRequest, UpdateProfileRequest, Auth\RegisterRequest,
// Layout\* / Menu\* / Notification* / Schedule\* FormRequest
'name' => '대상의 이름/명칭',
'nickname' => '닉네임',
'description' => '설명',
'content' => '본문 내용',
'body' => '본문',
'subject' => '제목',
'title' => '제목',
'slug' => 'URL 친화 식별자 (slug)',
'label' => '표시용 라벨',
'phone' => '전화번호',
'mobile' => '휴대전화 번호',
'homepage' => '홈페이지 URL',
'bio' => '자기소개',
'signature' => '서명',
'country' => '국가 코드 (ISO 3166-1 alpha-2)',
'url' => 'URL',
'file' => '업로드 파일',
'files' => '업로드 파일 배열',
'collection' => '첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default)',
'avatar' => '아바타 이미지',
'icon' => '아이콘',
'value' => '값',
'values' => '값 배열',
'username' => '사용자명 (로그인/인증 아이디)',
'path' => '경로',
'data' => '데이터 페이로드',
// 확장/버전 공통 (module/plugin/template/language-pack 설치·업데이트 계약)
// 근거: Module/Plugin/Template/LanguagePack Install·Update·Activate FormRequest,
// Extension\ChangelogRequest, Menu/Schedule/Notification* Request
'extension_type' => '확장 유형 (core/module/plugin/template)',
'extension_identifier' => '확장 식별자',
'from_version' => '시작 버전 (범위 하한)',
'to_version' => '대상 버전 (범위 상한)',
'github_url' => 'GitHub 저장소 URL',
'vendor' => '벤더명 (확장 제작자 식별자)',
'vendor_mode' => '벤더 설치 모드 (auto/composer/bundled)',
'checksum' => '무결성 검증 체크섬 (SHA-256)',
'target_identifier' => '대상 확장 식별자',
'source_identifier' => '출처 식별자',
'auto_activate' => '설치 후 자동 활성화 여부',
'cascade' => '연쇄 처리 여부 (의존 항목 함께 처리)',
'exclude_protected' => '보호 항목 제외 여부',
// 스케줄/작업 공통 (근거: Schedule\{Create,Update}ScheduleRequest, ScheduleListRequest)
'command' => '실행할 아티즌 커맨드',
'frequency' => '실행 주기',
'priority' => '우선순위 (작을수록 우선)',
'timeout' => '타임아웃 (초)',
'run_in_maintenance' => '점검 모드 중 실행 여부',
'without_overlapping' => '중복 실행 방지 여부',
'expected_lock_version' => '낙관적 잠금 버전 (동시 편집 충돌 감지)',
// 메일/드라이버 설정 (근거: Settings\SaveSettingsRequest,
// Settings\TestMailRequest, Settings\TestDriverConnectionRequest)
'mailer' => '메일 발송 드라이버 (smtp/mailgun/ses)',
'from_address' => '발신자 주소',
'from_name' => '발신자 이름',
'to_email' => '테스트 수신 주소',
'host' => '호스트 주소',
'port' => '포트 번호',
'encryption' => '전송 암호화 방식 (tls/ssl)',
'storage_driver' => '스토리지 드라이버 (local/s3)',
'cache_driver' => '캐시 드라이버 (file/redis/memcached)',
'session_driver' => '세션 드라이버 (file/database/redis)',
'queue_driver' => '큐 드라이버 (sync/database/redis)',
'redis_host' => 'Redis 호스트 주소',
'redis_port' => 'Redis 포트 번호',
'redis_password' => 'Redis 비밀번호',
'redis_database' => 'Redis 데이터베이스 번호',
'memcached_host' => 'Memcached 호스트 주소',
'memcached_port' => 'Memcached 포트 번호',
's3_bucket' => 'S3 버킷명',
's3_region' => 'S3 리전',
's3_access_key' => 'S3 액세스 키',
's3_secret_key' => 'S3 시크릿 키',
's3_url' => 'S3 엔드포인트 URL',
'ses_key' => 'SES 액세스 키',
'ses_secret' => 'SES 시크릿 키',
'ses_region' => 'SES 리전',
'mailgun_domain' => 'Mailgun 도메인',
'mailgun_secret' => 'Mailgun 시크릿 키',
'mailgun_endpoint' => 'Mailgun 엔드포인트',
'websocket_enabled' => 'WebSocket 사용 여부',
'websocket_host' => 'WebSocket 호스트 주소',
'websocket_port' => 'WebSocket 포트 번호',
'websocket_scheme' => 'WebSocket 스킴 (http/https)',
'websocket_app_key' => 'WebSocket 앱 키',
];
/**
* 파라미터 설명을 반환합니다. 없으면 null (호출자가 TODO 로 폴백).
*
* @param string $name 파라미터명
* @param string $location 위치 (path/query/body)
* @param string $type 타입 (integer/string/boolean/array...)
* @return string|null 설명 (없으면 null)
*/
public function describe(string $name, string $location = '', string $type = ''): ?string
{
// sort_order / order 는 타입에 따라 의미가 갈린다:
// - 문자열: 정렬 방향(asc/desc)
// - 정수: 표시 정렬 순서 값(작을수록 우선 — 컬럼 값)
if (in_array($name, ['sort_order', 'order'], true)) {
return match ($type) {
'integer', 'number' => '표시 정렬 순서 값 (작을수록 우선)',
'string' => '정렬 방향 (asc 오름차순 / desc 내림차순)',
default => null,
};
}
// status / type / category 는 query(목록 조회)에서만 필터 의미가 고정된다.
// body(생성/수정)에서는 설정할 도메인 값이므로 의미가 도메인마다 달라
// 사람 서술(TODO)로 남긴다.
if (in_array($name, ['status', 'type', 'category'], true)) {
if ($location !== 'query') {
return null;
}
$label = ['status' => '상태', 'type' => '유형', 'category' => '분류'][$name];
return "{$label} 필터 (해당 {$label}의 항목만 조회)";
}
if (isset(self::EXACT[$name])) {
return self::EXACT[$name];
}
// path 파라미터는 대부분 리소스 식별자 — 위치를 근거로 유추.
if ($location === 'path') {
return $this->describePathParam($name);
}
return $this->byPattern($name, $type);
}
/**
* path 파라미터(리소스 식별자)의 설명을 유추합니다.
*
* @param string $name path 파라미터명
* @return string|null 설명 (미매칭 시 null)
*/
private function describePathParam(string $name): ?string
{
// 순수 id / *_id / *Id: 대상 리소스의 식별자
if ($name === 'id') {
return '대상 리소스의 식별자';
}
if (Str::endsWith($name, '_id')) {
$base = $this->humanize(Str::beforeLast($name, '_id'));
return "대상 {$base}의 식별자";
}
if (Str::endsWith($name, 'Id') && $name !== 'Id') {
$base = $this->humanizeCamel(Str::beforeLast($name, 'Id'));
return "대상 {$base}의 식별자";
}
// slug / identifier / hash / uuid: 리소스 지시 키
if (in_array($name, ['slug', 'identifier', 'hash', 'uuid', 'code'], true)) {
$labels = [
'slug' => '대상 리소스의 slug (URL 친화 식별자)',
'identifier' => '대상 리소스의 식별자',
'hash' => '대상 리소스의 해시 식별자',
'uuid' => '대상 리소스의 UUID',
'code' => '대상 리소스의 코드',
];
return $labels[$name];
}
// *Name (templateName, pluginName, moduleName): 확장/리소스 이름 식별자
if (Str::endsWith($name, 'Name') && $name !== 'Name') {
$base = $this->humanizeCamel(Str::beforeLast($name, 'Name'));
return "대상 {$base}의 이름 (식별자)";
}
// *Identifier (templateIdentifier 등): 확장/리소스 식별자
if (Str::endsWith($name, 'Identifier') && $name !== 'Identifier') {
$base = $this->humanizeCamel(Str::beforeLast($name, 'Identifier'));
return "대상 {$base}의 식별자";
}
// bare 리소스명 path 파라미터: Laravel route-model binding 은
// `/{user}`, `/{role}`, `/{definition}` 처럼 대상 모델의 단수형(또는
// camelCase)을 그대로 세그먼트로 쓴다. 접미 패턴(_id/slug/*Id 등)에
// 걸리지 않은 path 파라미터는 이 바인딩 대상 리소스의 식별자로 본다.
// 예외: key/version 은 리소스가 아니라 설정 키/버전 값이므로 EXACT 폴백.
$bareExact = [
'key' => '대상 설정/항목의 키',
'version' => '대상 버전 (버전 문자열)',
];
if (isset($bareExact[$name])) {
return $bareExact[$name];
}
$base = str_contains($name, '_')
? $this->humanize($name)
: $this->humanizeCamel($name);
return "대상 {$base}의 식별자";
}
/**
* query/body 파라미터의 일관된 명명 규칙으로 설명을 유추합니다.
*
* @param string $name 파라미터명
* @param string $type 타입
* @return string|null 설명 (미매칭 시 null)
*/
private function byPattern(string $name, string $type): ?string
{
// identifier: 확장/리소스 지시 식별자 (query/body).
// path 위치는 describePathParam 이 먼저 처리하므로 여기 도달하지 않는다.
if ($name === 'identifier') {
return '대상 확장/리소스의 식별자';
}
// *_name: 확장/리소스 이름 식별자 (template_name/plugin_name/module_name/layout_name 등).
// EXACT 의 recipient_name/from_name 은 여기 도달 전에 이미 처리된다.
if (Str::endsWith($name, '_name')) {
$base = $this->humanize(Str::beforeLast($name, '_name'));
return "{$base} 이름 (식별자)";
}
// *_id: 연관 리소스 식별자 참조
if (Str::endsWith($name, '_id')) {
$base = $this->humanize(Str::beforeLast($name, '_id'));
return "{$base} 식별자";
}
// *_ids: 연관 리소스 식별자 배열
if (Str::endsWith($name, '_ids')) {
$base = $this->humanize(Str::beforeLast($name, '_ids'));
return "{$base} 식별자 배열";
}
// is_*/has_*: 불리언 토글
if ((Str::startsWith($name, 'is_') || Str::startsWith($name, 'has_')) && $type === 'boolean') {
$base = $this->humanize(Str::after($name, '_'));
return "{$base} 여부";
}
// *_date: 날짜 값
if (Str::endsWith($name, '_date')) {
$base = $this->humanize(Str::beforeLast($name, '_date'));
return "{$base} 날짜";
}
// *_at: 일시 값
if (Str::endsWith($name, '_at')) {
$base = $this->humanize(Str::beforeLast($name, '_at'));
return "{$base} 일시";
}
return null;
}
/**
* snake_case 를 사람이 읽는 문구로 변환합니다.
*
* @param string $token snake_case 토큰
* @return string 공백 구분 문구
*/
private function humanize(string $token): string
{
return str_replace('_', ' ', $token);
}
/**
* camelCase 를 사람이 읽는 문구로 변환합니다.
*
* @param string $token camelCase 토큰
* @return string 공백 구분 소문자 문구
*/
private function humanizeCamel(string $token): string
{
return Str::lower(trim(preg_replace('/([A-Z])/', ' $1', $token)));
}
}
@@ -0,0 +1,297 @@
<?php
namespace App\Support\ApiDoc;
use Illuminate\Support\Str;
/**
* 리소스 계약 필드 설명기
*
* 컬럼 주석이 없는 accessor/computed 필드(Resource 의 toArray() 가 파생하는 값)의
* 설명을 제공합니다. G7 전역에서 의미가 표준화된 필드(BaseApiResource 의 is_owner /
* abilities, Enum 파생 status_label / status_variant, User::isAdmin() 등)와, 일관된
* 파생 규칙(_label / _variant / _flag / _name / is_* / *_at 접미·접두)을 코드에서
* 확인된 의미 그대로 서술합니다.
*
* 우선순위: 이 계약 사전은 컬럼 주석보다 앞섭니다 — created_at 은 어느 테이블이든
* "생성 일시" 이고, status_label 은 컬럼이 아니라 Enum label() 산물이기 때문입니다.
*/
class ResourceFieldDescriber
{
/**
* @var array<string, string> 정확 필드명 => 설명 (BaseApiResource/공통 Resource 계약)
*/
private const EXACT = [
// BaseApiResource 표준 메타 (resourceMeta)
'is_owner' => '현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타)',
'abilities' => '현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반)',
// 공통 식별/타임스탬프
'id' => '기본 키 (내부 식별자)',
'uuid' => '외부 노출용 UUID (URL/API 식별자, 내부 id 비노출)',
'created_at' => '생성 일시',
'updated_at' => '최종 수정 일시',
'deleted_at' => '소프트 삭제 일시 (미삭제 시 null)',
// 공통 콘텐츠/표시 필드 (도메인 무관하게 역할이 고정 — 요청 파라미터
// ParameterDescriber 의 name/title/content/description 대응물).
// 근거: 실측 시 board 게시글·category·brand·notification 등 전 도메인에서
// 동일 역할(명칭/제목/본문/설명)로 관측. 다국어(object)/문자열 무관.
'name' => '대상의 이름/명칭 (다국어 필드는 로케일별 값 객체)',
'title' => '제목',
'content' => '본문 내용',
'description' => '설명 (다국어 필드는 로케일별 값 객체)',
'slug' => 'URL 친화 식별자 (slug)',
'label' => '표시용 라벨',
'icon' => '아이콘 식별자 (아이콘 클래스/이름)',
'thumbnail' => '썸네일 이미지 URL/경로',
'ip_address' => '요청/행위가 발생한 IP 주소',
// User 계약 파생 필드 (UserResource)
'status_label' => '상태의 사람이 읽는 라벨 (상태 Enum label() 산물)',
'status_variant' => '상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용)',
'language_label' => '언어 코드의 현지화 라벨 (user.language.{code} 번역)',
'country_flag' => '국가 코드의 국기 이모지 (country 값에서 파생)',
'country_name' => '국가 코드의 현지화 국가명 (country 값에서 파생)',
'is_admin' => '관리자 역할 보유 여부 (User::isAdmin() — 역할 관계 기반 파생)',
// 확장 상태 계약 필드 (ModuleResource/PluginResource/TemplateResource)
'is_bundled' => '코어에 선탑재된 번들 확장인지 여부',
'is_pending' => '_pending 대기소에 있어 설치 대기 중인지 여부',
'update_available' => '최신 버전 대비 업데이트 가능 여부',
'update_source' => '업데이트 감지 출처 (github, bundled 등)',
'latest_version' => '감지된 최신 배포 버전',
'file_version' => '설치된 파일의 manifest 버전',
'incompatible_required_version' => '요구 코어 버전 미충족 시 필요한 버전 (호환되면 null)',
'status_variant_label' => '상태 표시용 라벨/변형',
'dependencies' => '의존하는 확장 맵 (manifest 파생 — {modules, plugins})',
'assets' => '프론트엔드 에셋 매니페스트 (manifest 파생 — js/css 진입점·로딩 전략)',
// 관계/연관 객체 (여러 Resource 에서 동일 계약)
'creator' => '생성자 정보 객체 (uuid/name/email — creator 관계 파생)',
'children' => '하위 항목 배열 (계층 트리 — children 관계 파생)',
'parent' => '상위 항목 객체 (parent 관계 파생)',
'permissions' => '연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생)',
'recipient' => '수신자 사용자 객체 (uuid/name/email — recipientUser 관계 파생)',
'sender' => '발신자 사용자 객체 (uuid/name/email — senderUser 관계 파생)',
'author' => '작성자 사용자 객체 (uuid/name — author 관계 파생)',
'actor_name' => '행위를 수행한 주체(사용자/시스템)의 이름',
// 시스템/집계 공통 (DashboardService/UserRepository/SettingsService)
'total' => '전체 개수 (집계)',
'total_users' => '전체 사용자 수 (통계 객체는 count/추이 포함)',
'time' => '상대 시각 표시 (예: "24초 전" — diffForHumans() 산물)',
'server_time' => '서버 현재 시각 (Y-m-d H:i:s)',
'number' => '목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생)',
// 로케일 (TemplateResource/LocaleController)
'locales' => '활성 로케일 코드 배열',
'locale_names' => '로케일 코드별 표시명 맵 (config app.locale_names)',
// 확장/버전 공통 (Module/Plugin/Template/LanguagePack Resource, 확장 Manager)
'extension_type' => '이 리소스를 소유한 확장의 타입 (core/module/plugin/template)',
'extension_identifier' => '이 리소스를 소유한 확장의 식별자',
'extension_name' => '이 리소스를 소유한 확장의 표시 이름 (manifest name)',
'github_url' => 'GitHub 저장소 URL (manifest 파생)',
'github_changelog_url' => 'GitHub 변경 이력(CHANGELOG) URL (manifest 파생)',
'changelog' => '변경 이력 텍스트 (원격/파일 CHANGELOG 본문)',
'current_core_version' => '현재 설치된 코어 버전',
'installed_modules' => '설치된 모듈 집계 객체 (total/active)',
'installed_templates' => '설치된 템플릿 집계 객체 (total/active)',
'active_plugins' => '활성 플러그인 집계 객체 (total/active)',
'bundled_identifier' => '대응하는 번들 확장 식별자 (번들 원본 매칭용)',
'origin' => '출처 (설치/등록 원천 구분 값)',
'target_name' => '대상 확장의 표시 이름 (scope+target_identifier 로 해석)',
'install_blocked_reason' => '설치가 차단된 사유 (차단 없으면 null)',
// 감사/관계 공통 (도메인 무관하게 역할 고정 — 쓰기 응답에서 흔히 등장)
'updated_by' => '최종 수정한 사용자 정보 (uuid/name — updated_by 관계 파생, 없으면 null)',
'creator' => '생성자 정보 객체 (uuid/name/email — creator 관계 파생)',
'user' => '대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생)',
'roles' => '보유 역할 목록 (각 원소 id/name/permissions — roles 관계 파생)',
'permissions' => '권한 목록 (각 원소 identifier/name — permissions 관계 파생)',
'avatar' => '아바타 이미지 URL (미등록 시 null)',
// 인증/토큰 (로그인/토큰 발급 응답)
'token' => '발급된 API 접근 토큰 평문 (Bearer 토큰으로 사용, 발급 시 1회만 노출)',
'token_type' => '토큰 타입 (일반적으로 Bearer)',
'trigger_type' => '동작을 유발한 방식/주체 구분 값',
// 스케줄/실행 이력 (Schedule 실행 결과)
'last_duration' => '마지막 실행 소요 시간 (초/밀리초 — 실행 이력 파생)',
'duration' => '실행 소요 시간 (초/밀리초)',
'exit_code' => '실행 종료 코드 (0=성공, 그 외=실패)',
'output' => '실행 표준 출력 내용',
'error_output' => '실행 표준 에러 출력 내용 (없으면 빈 문자열/null)',
'memory_usage' => '실행 중 최대 메모리 사용량 (바이트)',
// 캐시/파일/집계 (일회성 동작 응답)
'ttl' => '캐시 유효 시간 (Time To Live, 초)',
'size_bytes' => '크기 (바이트)',
'older_than_days' => '기준 경과 일수 (이 일수보다 오래된 대상 필터/집계)',
'entries' => '항목 목록 (각 원소는 대상 도메인 레코드)',
'changelog_entries' => '변경 이력 항목 목록 (버전별 변경 내용)',
'templates' => '템플릿 목록 (각 원소 identifier/name 등 — 템플릿 관계 파생)',
'spec' => '스펙 정의 객체 (편집기/컴포넌트 선언 스키마 등)',
'source_meta' => '원천 메타데이터 객체 (출처·경로·해석 정보)',
'validation_summary' => '검증 결과 요약 객체 (통과/실패 건수 등)',
];
/**
* 필드명에 대한 설명을 반환합니다. 없으면 null (호출자가 컬럼 주석/TODO 로 폴백).
*
* @param string $field 필드명
* @param string $type 실측 타입 (boolean/integer/string/object/array...)
* @return string|null 설명 (없으면 null)
*/
public function describe(string $field, string $type = ''): ?string
{
// sort_order 는 응답에서 표시 정렬 순서 값(정수 컬럼)으로 고정된다.
// 문자열이면 정렬 방향일 수 있어 도메인 특이 → TODO 유지.
// (ParameterDescriber 의 sort_order 타입 분기와 동일 계약)
if ($field === 'sort_order') {
return in_array($type, ['integer', 'number'], true)
? '표시 정렬 순서 값 (작을수록 우선)'
: null;
}
// status 는 도메인마다 허용값 집합이 다르나(active/blocked, success/failed 등) 역할은
// 공통이다. 문자열 상태값이면 일반 설명을 주고, 구체 허용값은 status_label/변형 필드와
// 각 도메인 문서에서 보강한다. (동반 필드 status_label/status_variant 는 EXACT 로 설명됨)
if ($field === 'status' && in_array($type, ['string', 'integer'], true)) {
return '상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조)';
}
if (isset(self::EXACT[$field])) {
return self::EXACT[$field];
}
return $this->byPattern($field, $type);
}
/**
* 일관된 파생 규칙(접미/접두 패턴)으로 설명을 유추합니다.
*
* @param string $field 필드명
* @param string $type 실측 타입
* @return string|null 설명 (패턴 미매칭 시 null)
*/
private function byPattern(string $field, string $type): ?string
{
// *_at: 타임스탬프 (UI 포맷 문자열 또는 ISO)
if (Str::endsWith($field, '_at')) {
$base = $this->humanize(Str::beforeLast($field, '_at'));
return "{$base} 일시";
}
// *_formatted: 원본 값을 사람이 읽는 문자열로 포맷한 표시용 파생 필드
// (근거: size_formatted accessor, formatCurrencyPrice/formatFileSize/
// formatCreatedAtFormat — 통화·용량·일시 등을 로케일/단위 포맷). 도메인
// 무관하게 "`base` 값의 표시용 포맷 문자열" 로 의미가 고정된다.
if (Str::endsWith($field, '_formatted')) {
$base = Str::beforeLast($field, '_formatted');
return "`{$base}` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷)";
}
// *_label: 원본 값의 현지화 라벨 (Enum label() 또는 번역)
if (Str::endsWith($field, '_label')) {
$base = Str::beforeLast($field, '_label');
return "`{$base}` 값의 사람이 읽는 라벨 (현지화/Enum 파생)";
}
// *_variant: UI 배지 색상/스타일 변형 키
if (Str::endsWith($field, '_variant')) {
$base = Str::beforeLast($field, '_variant');
return "`{$base}` 값의 표시 변형 키 (UI 배지 색상/스타일)";
}
// can_*: abilities 맵 내부 능력 불리언
if (Str::startsWith($field, 'can_')) {
$action = $this->humanize(Str::after($field, 'can_'));
return "{$action} 수행 가능 여부 (권한 기반)";
}
// is_*/has_*: 불리언 상태 플래그
if ((Str::startsWith($field, 'is_') || Str::startsWith($field, 'has_')) && $type === 'boolean') {
$base = $this->humanize(Str::after($field, '_'));
return "{$base} 여부";
}
// *_count: 집계 개수
if (Str::endsWith($field, '_count') && in_array($type, ['integer', 'number'], true)) {
$base = $this->humanize(Str::beforeLast($field, '_count'));
return "{$base} 개수 (집계)";
}
// *_raw: 다국어/현지화 이전 원본 값 (getValue 로 로케일 미해석 원본 반환)
// 예: name_raw = name 의 원본, description_raw = description 의 원본
if (Str::endsWith($field, '_raw')) {
$base = Str::beforeLast($field, '_raw');
return "`{$base}` 의 원본 값 (현재 로케일 미해석 원본 JSON/문자열)";
}
// localized_* / *_localized: 다국어 필드를 현재 로케일로 해석한 표시용 값
// (근거: getLocalizedName() / getLocalizedOptionName() 등 — 다국어 JSON 을
// 현재 로케일 문자열로 해석). 도메인 무관 파생 규칙.
if (Str::startsWith($field, 'localized_')) {
$base = $this->humanize(Str::after($field, 'localized_'));
return "`{$base}` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석)";
}
if (Str::endsWith($field, '_localized')) {
$base = $this->humanize(Str::beforeLast($field, '_localized'));
return "`{$base}` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석)";
}
// *_url: 리소스에 접근하는 URL (썸네일/다운로드/이미지 등)
// (근거: thumbnail_url => download_url / getThumbnailUrl()). 도메인 무관.
if (Str::endsWith($field, '_url')) {
$base = $this->humanize(Str::beforeLast($field, '_url'));
return "{$base} URL";
}
// *_id: 연관 리소스를 참조하는 정수/UUID 식별자 (parent_id/user_id/loggable_id 등).
// *_ids: 연관 리소스 식별자 배열.
// 예외: login_id 는 참조 식별자가 아니라 로그인 계정 아이디(문자열)이므로 제외
// (도메인 특이 → TODO 유지).
if (Str::endsWith($field, '_ids')) {
$base = $this->humanize(Str::beforeLast($field, '_ids'));
return "{$base} 식별자 배열 (연관 리소스 참조)";
}
if (Str::endsWith($field, '_id') && ! Str::endsWith($field, 'login_id')) {
$base = $this->humanize(Str::beforeLast($field, '_id'));
return "{$base} 식별자 (연관 리소스 참조)";
}
// depth: 계층 트리에서의 깊이 (0 = 최상위). children/parent 트리 파생 필드로
// 도메인 무관하게 의미가 고정된다 (근거: CommentResource/CategoryResource depth).
if ($field === 'depth' && in_array($type, ['integer', 'number'], true)) {
return '계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가)';
}
return null;
}
/**
* snake_case 를 사람이 읽는 문구로 변환합니다.
*
* @param string $token snake_case 토큰
* @return string 공백 구분 문구
*/
private function humanize(string $token): string
{
return str_replace('_', ' ', $token);
}
}
@@ -0,0 +1,220 @@
<?php
namespace App\Support\ApiDoc;
/**
* 응답 스키마 추론기
*
* 실측한 응답 JSON(ResponseHelper envelope)에서 data 내부의 필드 스키마를
* 추론합니다. 각 필드의 타입과 샘플값을 문서용 메타데이터로 변환합니다.
*/
class ResponseSchemaInferrer
{
/**
* envelope 응답 body 에서 data 필드 스키마를 추론합니다.
*
* @param array<string, mixed> $body 실측 응답 body (envelope)
* @return array{envelope: array<int, string>, shape: string, fields: array<int, array<string, mixed>>, pagination: bool}
*/
public function infer(array $body): array
{
$envelope = array_keys($body);
$data = $body['data'] ?? null;
// 목록 응답: data.data 가 배열 (BaseApiCollection)
if (is_array($data) && isset($data['data']) && is_array($data['data'])) {
$rows = array_values(array_filter($data['data'], 'is_array'));
return [
'envelope' => $envelope,
'shape' => 'collection',
'fields' => $this->fieldsFromRows($rows),
'pagination' => isset($data['pagination']),
];
}
// 단건 응답: data 가 연관 배열
if (is_array($data) && $this->isAssoc($data)) {
return [
'envelope' => $envelope,
'shape' => 'object',
'fields' => $this->fieldsFromRow($data),
'pagination' => false,
];
}
// data 가 순수 배열(목록만)
if (is_array($data) && ! $this->isAssoc($data) && isset($data[0]) && is_array($data[0])) {
$rows = array_values(array_filter($data, 'is_array'));
return [
'envelope' => $envelope,
'shape' => 'array',
'fields' => $this->fieldsFromRows($rows),
'pagination' => false,
];
}
return [
'envelope' => $envelope,
'shape' => 'scalar',
'fields' => [],
'pagination' => false,
];
}
/**
* 한 행(row)에서 필드별 타입·샘플값을 추출합니다.
*
* @param array<string, mixed> $row 응답 데이터 한 행
* @return array<int, array<string, mixed>> 필드 메타데이터 목록
*/
private function fieldsFromRow(array $row): array
{
$fields = [];
foreach ($row as $key => $value) {
$fields[] = [
'name' => (string) $key,
'type' => $this->typeOf($value),
'sample' => $this->sampleOf($value),
];
}
return $fields;
}
/**
* 여러 행을 병합해 필드별로 non-null 대표 샘플과 실제 타입을 선택합니다.
*
* 첫 행이 우연히 비어있어(null) "항상 null" 처럼 보이는 문제를 방지하기 위해,
* 각 필드에서 값이 채워진 행을 우선 채택합니다.
*
* @param array<int, array<string, mixed>> $rows 응답 데이터 행 목록
* @return array<int, array<string, mixed>> 필드 메타데이터 목록
*/
private function fieldsFromRows(array $rows): array
{
if ($rows === []) {
return [];
}
// 첫 행의 키 순서를 기준으로 필드 목록 확정
$keys = array_keys($rows[0]);
$fields = [];
foreach ($keys as $key) {
$chosenValue = null;
$chosenType = 'null';
foreach ($rows as $row) {
if (! array_key_exists($key, $row)) {
continue;
}
$value = $row[$key];
$type = $this->typeOf($value);
// 값이 채워진(non-null) 첫 행을 대표로 채택하고 탐색 종료
if ($type !== 'null') {
$chosenValue = $value;
$chosenType = $type;
break;
}
// 아직 non-null 을 못 찾았으면 null 이라도 후보로 유지
$chosenValue = $value;
}
$fields[] = [
'name' => (string) $key,
'type' => $chosenType,
'sample' => $this->sampleOf($chosenValue),
];
}
return $fields;
}
/**
* 값의 JSON 타입을 판별합니다.
*
* @param mixed $value 값
* @return string 타입 문자열
*/
private function typeOf(mixed $value): string
{
return match (true) {
is_bool($value) => 'boolean',
is_int($value) => 'integer',
is_float($value) => 'number',
is_string($value) => 'string',
is_null($value) => 'null',
is_array($value) && $this->isAssoc($value) => 'object',
is_array($value) => 'array',
default => 'mixed',
};
}
/**
* 값의 샘플 표현을 반환합니다 (문서 표에 표시할 축약형).
*
* @param mixed $value 값
* @return string 샘플 표현 (마크다운 표 셀 안전)
*/
private function sampleOf(mixed $value): string
{
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_null($value)) {
return 'null';
}
if (is_array($value)) {
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE);
$encoded = (string) $encoded;
if (mb_strlen($encoded) > 60) {
$encoded = mb_substr($encoded, 0, 57).'…';
}
return $this->escapeCell($encoded);
}
$str = (string) $value;
if (mb_strlen($str) > 40) {
$str = mb_substr($str, 0, 37).'…';
}
return $this->escapeCell($str);
}
/**
* 마크다운 표 셀 안에서 안전하도록 파이프/개행을 이스케이프합니다.
*
* @param string $text 원본 텍스트
* @return string 이스케이프된 텍스트
*/
private function escapeCell(string $text): string
{
return str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $text);
}
/**
* 배열이 연관 배열(맵)인지 판별합니다.
*
* @param array<mixed> $arr 배열
* @return bool 연관 배열 여부
*/
private function isAssoc(array $arr): bool
{
if ($arr === []) {
return false;
}
return array_keys($arr) !== range(0, count($arr) - 1);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* config 캐시(bootstrap/cache/config.php) 재빌드 헬퍼.
*
* config 의 소스(config/*.php, .env, storage/app/settings/*.json, 활성 확장 목록)를
* 변경하는 모든 라이프사이클 지점이 이 헬퍼를 호출해 "변경 반영 + 캐시 재최적화"를
* 일관되게 수행한다. 산발적으로 config:clear/config:cache 를 각 지점에 뿌리면
* 누락이 생겨(설정 저장은 clear 만, 확장 설치는 clear 조차 안 함) config:cache 가
* 한 번 비워진 뒤 재생성되지 않아 성능 이점이 영구히 사라진다 — 이를 단일 SSoT 로 막는다.
*
* 정책: 환경 무관 항상 재생성. config:cache 는 그 자체로 부팅 비용을
* 절감하고, G7 설정은 config 캐시에 박제되지 않고 매 요청 SettingsServiceProvider /
* CoreServiceProvider 의 런타임 Config::set() 으로 재주입되므로(설정 stale 없음),
* 항상 켜두는 것이 이득이다. local 개발 시 config/*.php 수정이 즉시 반영되지 않는 점은
* 개발자가 `php artisan config:clear` 로 대응하는 개발자 책임 영역이다.
*/
class ConfigCacheHelper
{
/**
* config 캐시를 비우고 즉시 재생성합니다.
*
* 설치 미완료(installer 실행 전) 환경에서는 config:cache 가 불완전한 부팅을
* 캐시에 박제해 부팅 실패를 유발할 수 있으므로, clear 만 수행하고 재생성은 건너뛴다.
* 테스트 환경(APP_ENV=testing)은 tests/bootstrap.php 가 config 캐시를 자동 삭제하며
* 캐시 생성이 테스트 격리를 깨므로 no-op 로 스킵한다.
*
* 재생성 실패(권한/디스크 등)는 치명적이지 않다 — config:clear 로 stale 캐시는
* 이미 제거되어 다음 요청이 fresh config 로 안전하게 부팅되므로, 경고만 남기고 넘어간다.
*
* @return void
*/
public static function rebuild(): void
{
// 항상 stale 캐시부터 제거 (값 반영 보장). testing 환경에서도 clear 는 수행한다 —
// 캐시 파일이 없으면 사실상 no-op 이고 격리를 깨지 않으며, 설정 저장 후 값 반영을
// 검증하는 기존 테스트(SettingsServiceConfigClearTest) 계약을 유지한다.
Artisan::call('config:clear');
// config:cache 생성은 격리를 깨므로(캐시된 config 가 다음 테스트로 누출) testing 에서
// 스킵한다. 설치 미완료 상태에서도 불완전 config 박제를 피하려 재생성하지 않는다.
if (app()->environment('testing') || ! self::isInstalled()) {
return;
}
try {
Artisan::call('config:cache');
} catch (\Throwable $e) {
Log::warning('config 캐시 재생성 실패 (config:clear 로 stale 은 제거됨 — 다음 요청은 비캐시 부팅)', [
'error' => $e->getMessage(),
]);
}
}
/**
* config 캐시만 제거합니다 (재생성 없음).
*
* 재생성이 부적절한 특수 경로(예: 설치 직전 초기화)를 위한 보조 진입점.
*
* @return void
*/
public static function clear(): void
{
Artisan::call('config:clear');
}
/**
* G7 설치 완료 여부를 확인합니다.
*
* config('app.installer_completed') 는 config:clear 직후 재로드되어 신뢰 가능하며,
* 보조로 storage/app/g7_installed 플래그 파일도 확인한다.
*
* @return bool 설치 완료 시 true
*/
private static function isInstalled(): bool
{
if (config('app.installer_completed')) {
return true;
}
return File::exists(storage_path('app/g7_installed'));
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Support;
/**
* 부팅 시점 "DB 스키마 준비성" 판정에 쓰이는 인스톨러 컨텍스트 헬퍼.
*
* `config('app.installer_completed')` fast-path 는 프로덕션에서 `Schema::hasTable()`
* 호출을 건너뛰어 매 요청 오버헤드를 제거한다. 그러나 `INSTALLER_COMPLETED=true` 가
* 기록된 `.env` 를 빈 DB 새 서버에 복사한 뒤 `php artisan migrate` 로 테이블을
* 만들기 전에 앱이 부팅되면, fast-path 가 테이블 존재를 잘못 전제하여 뒤따르는
* 쿼리가 "table not found" 로 부팅을 깨뜨린다.
*
* 이 헬퍼는 스키마를 파괴/재생성하는 마이그레이션 계열 콘솔 명령 판정을 단일
* SSoT 로 캡슐화하여, 해당 컨텍스트에서 fast-path 를 무력화하는 가드에 쓰인다.
*/
class InstallerContext
{
/**
* 스키마를 파괴/재생성하는 콘솔 명령(마이그레이션 계열)이 실행 중인지 판정합니다.
*
* 이 컨텍스트에서는 `installer_completed=true` 라도 테이블이 아직 없을 수 있으므로
* fast-path(hasTable 스킵)를 신뢰해서는 안 됩니다. 웹 요청 등 콘솔이 아닌
* 실행 경로에서는 항상 false 를 반환합니다.
*
* @return bool 마이그레이션 계열 명령 실행 중 여부
*/
public static function isSchemaMutatingCommand(): bool
{
if (! app()->runningInConsole()) {
return false;
}
return in_array(
$_SERVER['argv'][1] ?? null,
['migrate', 'migrate:fresh', 'migrate:refresh', 'migrate:rollback', 'migrate:reset', 'db:wipe'],
true
);
}
}
+213
View File
@@ -0,0 +1,213 @@
<?php
namespace App\Support;
/**
* 서버가 외부로 나가는(outbound) HTTP 요청의 목적지 URL 을 검증하는 순수 유틸.
*
* 사용자·관리자·외부 콜백이 제어할 수 있는 값이 그대로 목적지가 되면 서버는
* 내부망(사설 IP, 루프백, 클라우드 메타데이터 169.254.169.254)으로 요청을 대신
* 보내는 도구가 된다(SSRF). 본 유틸은 두 계층의 판정을 제공한다:
*
* 1. `isHostAllowed()` — 신뢰 도메인 목록이 정해진 경우(결제/본인인증 게이트웨이 등).
* host 를 **완전 일치**로 검증한다. 접두사 매칭(`str_starts_with`)은 금지 —
* `https://trusted.example@127.0.0.1/`(userinfo)와
* `https://trusted.example.attacker.com/`(접미사 확장)이 모두 통과하기 때문이다.
*
* 2. `isPublicHttpUrl()` — 신뢰 목록이 없고 "임의의 공개 URL 은 허용하되 내부망
* 타격만 막는" 경우(외부 API 연동, 원격 다운로드 등).
*
* 두 메서드 모두 예외를 던지지 않고 bool 만 반환한다. 차단 시 어떤 예외/응답을
* 낼지는 각 호출 지점이 자신의 반환 계약에 맞춰 결정한다.
*
* 방어 범위: **host 레벨**이다. DNS rebinding(검증 시점과 실제 연결 시점 사이에
* 같은 호스트명이 내부 IP 로 재해석되는 공격)은 막지 못한다. 이를 막으려면 요청
* 직전에 실제 해석된 IP 를 재검증해야 하며, 이는 본 유틸의 범위 밖이다.
*/
class OutboundUrlValidator
{
/** 공개 인터넷 호스트로 취급하지 않는 내부 도메인 접미사 */
private const INTERNAL_HOST_SUFFIXES = [
'.local',
'.localhost',
'.internal',
'.intranet',
'.lan',
'.home.arpa',
];
/** 그 자체로 내부를 가리키는 호스트명 */
private const INTERNAL_HOST_NAMES = [
'localhost',
'ip6-localhost',
'ip6-loopback',
];
/**
* URL 의 host 가 화이트리스트와 완전 일치하는지 검증한다.
*
* scheme 허용 목록·userinfo 부재·포트 표기까지 함께 강제하므로, 호출부는
* 이 메서드 하나만 통과시키면 목적지 위조를 막을 수 있다.
*
* @param string $url 검증 대상 URL (외부 입력)
* @param array<int, string> $allowedHosts 허용 host 목록 (예: ['kssa.inicis.com'])
* @param array{schemes?: array<int, string>, allowPort?: bool} $options
* schemes: 허용 scheme (기본 ['https'])
* allowPort: 명시 포트 허용 여부 (기본 false)
* @return bool 화이트리스트 host 와 완전 일치하면 true
*/
public static function isHostAllowed(string $url, array $allowedHosts, array $options = []): bool
{
$host = self::extractSafeHost($url, $options);
if ($host === null) {
return false;
}
foreach ($allowedHosts as $allowed) {
if ($host === strtolower(trim($allowed))) {
return true;
}
}
return false;
}
/**
* URL 의 host 가 공개 인터넷 주소인지(= 내부망이 아닌지) 검증한다.
*
* 사설 IP(10/172.16/192.168), 루프백(127.0.0.1, ::1), 링크로컬 및 클라우드
* 메타데이터(169.254.169.254), `localhost`·`*.local` 등 내부 도메인을 차단한다.
*
* @param string $url 검증 대상 URL (외부 입력)
* @param array{schemes?: array<int, string>, allowPort?: bool} $options
* schemes: 허용 scheme (기본 ['https'])
* allowPort: 명시 포트 허용 여부 (기본 true — 외부 API 는 비표준 포트를 쓸 수 있다)
* @return bool 공개 인터넷 host 이면 true
*/
public static function isPublicHttpUrl(string $url, array $options = []): bool
{
$host = self::extractSafeHost($url, $options + ['allowPort' => true]);
if ($host === null) {
return false;
}
return self::isPublicHost($host);
}
/**
* URL 이 구조적으로 안전한지만 판정한다 (내부망 여부는 보지 않는다).
*
* 내부 주소 호출을 의도적으로 허용한 환경에서도 userinfo(`@`) 위장·미허용 scheme·
* 제어문자 주입은 여전히 막아야 하므로, 그 최소 방어선을 제공한다.
*
* @param string $url 검증 대상 URL
* @param array{schemes?: array<int, string>, allowPort?: bool} $options 검증 옵션
* @return bool 구조적으로 안전하면 true
*/
public static function isStructurallySafeUrl(string $url, array $options = []): bool
{
return self::extractSafeHost($url, $options + ['allowPort' => true]) !== null;
}
/**
* host 문자열(URL 이 아닌 host 단독)이 공개 인터넷 주소인지 판정한다.
*
* @param string $host host 문자열 (예: 'example.com', '127.0.0.1', '[::1]')
* @return bool 공개 인터넷 host 이면 true
*/
public static function isPublicHost(string $host): bool
{
$host = strtolower(trim($host));
// parse_url 이 IPv6 를 대괄호째 반환하므로 벗겨낸다
if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
$host = substr($host, 1, -1);
}
if ($host === '' || in_array($host, self::INTERNAL_HOST_NAMES, true)) {
return false;
}
// IP 리터럴 — 사설/예약 대역 차단 (루프백·링크로컬·메타데이터 포함)
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
return filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
) !== false;
}
// 10진수·8진수·16진수로 인코딩된 IP 우회(예: http://2130706433/ = 127.0.0.1) 차단.
// 공개 도메인은 최상위 라벨이 숫자로만 이루어질 수 없다.
$lastLabel = substr(strrchr('.'.$host, '.') ?: '', 1);
if ($lastLabel === '' || ctype_digit($lastLabel) || str_starts_with($host, '0x')) {
return false;
}
foreach (self::INTERNAL_HOST_SUFFIXES as $suffix) {
if (str_ends_with($host, $suffix)) {
return false;
}
}
return true;
}
/**
* URL 을 파싱해 구조적으로 안전한 host 를 소문자로 반환한다.
*
* scheme 미허용, userinfo(`@`) 포함, 미허용 포트, host 부재이면 null 을 반환해
* 상위 판정을 즉시 실패시킨다.
*
* @param string $url 검증 대상 URL
* @param array{schemes?: array<int, string>, allowPort?: bool} $options 검증 옵션
* @return string|null 소문자 host, 구조적으로 안전하지 않으면 null
*/
private static function extractSafeHost(string $url, array $options): ?string
{
$schemes = $options['schemes'] ?? ['https'];
$allowPort = $options['allowPort'] ?? false;
$url = trim($url);
if ($url === '') {
return null;
}
// 개행·제어문자가 섞인 URL 은 헤더/요청 분리 시도로 간주하고 즉시 거부
if (preg_match('/[\x00-\x1F\x7F]/', $url) === 1) {
return null;
}
$parts = parse_url($url);
if ($parts === false || ! isset($parts['scheme'], $parts['host'])) {
return null;
}
// userinfo(`user:pass@host`) 는 host 위조의 핵심 벡터 — 존재만으로 거부
if (isset($parts['user']) || isset($parts['pass'])) {
return null;
}
$scheme = strtolower($parts['scheme']);
$normalizedSchemes = array_map(
static fn (string $s): string => strtolower(trim($s)),
$schemes,
);
if (! in_array($scheme, $normalizedSchemes, true)) {
return null;
}
if (! $allowPort && isset($parts['port'])) {
return null;
}
$host = strtolower(trim($parts['host']));
return $host === '' ? null : $host;
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
namespace App\Support;
/**
* 스케줄 Shell/Artisan command 가 실행 가능한 안전 명령인지 판정하는 순수 유틸.
*
* 스케줄 command 는 저장된 문자열이 그대로 서버에서 실행되는 값이므로, 스케줄 생성 권한을
* 위임받은 계정이 임의 OS 명령·임의 PHP 코드를 실행하는 통로가 될 수 있다(권한 상승형 RCE).
* 본 유틸은 두 축의 판정을 제공한다:
*
* 1. Shell — 기본 차단. `config/schedule_security.php` 의 opt-in 게이트가 켜져 있고,
* 셸 메타문자가 없으며, 첫 토큰의 basename 이 허용 실행 파일 목록과 완전 일치할 때만 허용.
* 허용되더라도 호출부는 `tokenizeShellCommand()` 로 얻은 인자 배열로 실행해야 한다
* (`/bin/sh -c` 를 경유하지 않으므로 파이프·`;`·`$()` 가 무력화된다).
*
* 2. Artisan — 기본 허용, 차단목록만 거부. `tinker` 처럼 임의 PHP 코드를 실행하거나
* `db:wipe` 처럼 파괴적인 명령을 스케줄로 실행하지 못하게 막는다.
*
* 예외를 던지지 않고 bool/배열만 반환한다 — 차단 시 어떤 응답/예외를 낼지는 호출부가 결정한다.
*/
class ScheduleCommandValidator
{
/** 셸 해석을 유발하는 메타문자 — 하나라도 있으면 인자 배열 실행이 불가하므로 거부 */
private const SHELL_METACHARACTERS = [
'|', '&', ';', '$', '`', '>', '<', '(', ')', '{', '}',
'*', '?', '!', '\\', '"', "'", "\n", "\r", "\t",
];
/**
* Shell 타입 command 를 실행해도 되는지 판정합니다.
*
* 게이트가 켜져 있고, 화이트리스트가 비어있지 않으며, 메타문자가 없고,
* 첫 토큰의 basename 이 화이트리스트와 완전 일치할 때만 true.
*
* @param string $command 스케줄에 저장된 shell command 문자열
* @return bool 실행을 허용하면 true
*/
public static function isShellCommandAllowed(string $command): bool
{
if (! (bool) config('schedule_security.shell.enabled', false)) {
return false;
}
$allowed = array_values(array_filter(array_map(
static fn ($binary): string => trim((string) $binary),
(array) config('schedule_security.shell.allowed_binaries', []),
)));
if ($allowed === []) {
return false;
}
$tokens = self::tokenize($command);
if ($tokens === null || $tokens === []) {
return false;
}
return in_array(basename($tokens[0]), $allowed, true);
}
/**
* Shell command 를 `Process::run(array)` 에 넘길 안전한 인자 배열로 변환합니다.
*
* 메타문자가 섞여 있으면 셸 미경유 실행이 불가하므로 null 을 반환한다.
*
* @param string $command 스케줄에 저장된 shell command 문자열
* @return array<int, string>|null 인자 배열, 안전하게 토큰화할 수 없으면 null
*/
public static function tokenizeShellCommand(string $command): ?array
{
return self::tokenize($command);
}
/**
* Artisan command 문자열에서 명령명(첫 토큰)을 추출합니다.
*
* @param string $command 스케줄에 저장된 artisan command 문자열
* @return string|null 명령명, 빈 문자열이면 null
*/
public static function extractArtisanCommandName(string $command): ?string
{
$trimmed = trim($command);
if ($trimmed === '') {
return null;
}
$tokens = preg_split('/\s+/', $trimmed);
return ($tokens === false || $tokens === []) ? null : $tokens[0];
}
/**
* Artisan command 가 차단목록에 걸리지 않는지 판정합니다.
*
* @param string $command 스케줄에 저장된 artisan command 문자열
* @return bool 실행을 허용하면 true
*/
public static function isArtisanCommandAllowed(string $command): bool
{
$name = self::extractArtisanCommandName($command);
if ($name === null) {
return false;
}
$denylist = array_map(
static fn ($denied): string => strtolower(trim((string) $denied)),
(array) config('schedule_security.artisan.denylist', []),
);
return ! in_array(strtolower($name), $denylist, true);
}
/**
* 메타문자 없는 공백 구분 토큰화. 메타문자가 있으면 null 을 반환합니다.
*
* 따옴표 escape 처리 버그를 원천 회피하기 위해, 따옴표를 포함한 복잡한 명령은
* 지원하지 않고 거부하는 보수적 구현이다(복합 명령은 래퍼 스크립트로 유도).
*
* @param string $command command 문자열
* @return array<int, string>|null 토큰 배열, 안전하지 않으면 null
*/
private static function tokenize(string $command): ?array
{
$command = trim($command);
if ($command === '') {
return null;
}
// 제어문자(개행 포함)가 섞인 명령은 즉시 거부
if (preg_match('/[\x00-\x1F\x7F]/', $command) === 1) {
return null;
}
foreach (self::SHELL_METACHARACTERS as $meta) {
if (str_contains($command, $meta)) {
return null;
}
}
$tokens = preg_split('/\s+/', $command);
return ($tokens === false || $tokens === []) ? null : $tokens;
}
}
+2 -2
View File
@@ -231,7 +231,7 @@ return [
|
*/
'version' => env('APP_VERSION', '7.0.0'),
'version' => env('APP_VERSION', '7.0.4'),
/*
|--------------------------------------------------------------------------
@@ -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'),
+32
View File
@@ -0,0 +1,32 @@
<?php
// 스케줄러 Shell/Artisan 실행 보안 정책.
// 관리자 환경설정에 노출하지 않는 코드 소유 정책 — command 실행 능력은 신뢰 경계 안에서만 넓힌다.
// (스케줄 권한만 위임받은 관리자가 화이트리스트를 넓혀 임의 명령 실행을 복구하는 것을 막는다.)
return [
'shell' => [
// Shell 타입 스케줄 실행 자체를 켜는 opt-in 게이트 (기본 차단).
'enabled' => env('SCHEDULE_SHELL_ENABLED', false),
// 허용 실행 파일 basename 목록. 빈 배열이면 모든 shell 명령을 거부한다.
'allowed_binaries' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('SCHEDULE_SHELL_ALLOWED_BINARIES', '')),
))),
],
'artisan' => [
// 파괴적·코드 실행형 artisan 명령 차단목록 (첫 토큰 기준).
'denylist' => [
'tinker',
'db:wipe',
'migrate:fresh',
'migrate:reset',
'migrate:rollback',
'schedule:run',
'schedule:work',
'env:decrypt',
'key:generate',
],
],
];
+2 -1
View File
@@ -24,7 +24,8 @@
"login_lockout_time": 5,
"two_factor_auth": false,
"password_min_length": 8,
"require_password_special_char": false
"require_password_special_char": false,
"allow_internal_outbound_urls": false
},
"mail": {
"mailer": "smtp",
+35 -1
View File
@@ -3,12 +3,13 @@
namespace Database\Factories;
use App\Enums\UserStatus;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
* @extends Factory<User>
*/
class UserFactory extends Factory
{
@@ -49,4 +50,37 @@ class UserFactory extends Factory
'email_verified_at' => null,
]);
}
/**
* 모든 프로필 필드가 채워진 완전한 상태를 만듭니다.
*
* API 문서 실측 시 응답 필드의 예시값이 null 이 되지 않도록, 모델 로직상
* 유효한 값으로 nullable 프로필 컬럼을 전수 채웁니다.
* (language=지원 로케일, country=ISO alpha-2, status=UserStatus enum 등)
*/
public function complete(): static
{
return $this->state(fn (array $attributes) => [
'nickname' => fake()->userName(),
'language' => 'ko',
'timezone' => 'Asia/Seoul',
'country' => 'KR',
'homepage' => 'https://example.com',
'mobile' => '010-'.fake()->numerify('####-####'),
'phone' => '02-'.fake()->numerify('###-####'),
'zipcode' => fake()->numerify('#####'),
'address' => fake()->address(),
'address_detail' => fake()->numerify('##동 ###호'),
'signature' => fake()->sentence(),
'bio' => fake()->paragraph(),
// avatar 는 users 테이블 컬럼이 아니라 avatarAttachment 관계에서 파생되는
// accessor(getAvatarUrl) 이므로 factory 에서 직접 세팅하지 않는다.
'admin_memo' => fake()->sentence(),
'ip_address' => fake()->ipv4(),
'last_login_at' => now()->subDays(1),
'identity_verified_at' => now()->subDays(5),
'mobile_verified_at' => now()->subDays(5),
'failed_login_attempts' => 0,
]);
}
}

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