142 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
HeuJung 917bfb603a v7.0.0 release 2026-07-01 11:32:54 +09:00
HeuJung c4ea9a6cd1 v7.0.0 release 2026-07-01 10:30:32 +09:00
HeuJung 0e7fa04b03 v7.0.0-beta.7 release 2026-05-15 19:24:41 +09:00
HeuJung c186d9a9c2 v7.0.0-beta.6 release 2026-05-14 12:24:08 +09:00
HeuJung 61f5874fd2 v7.0.0-beta.6 release 2026-05-14 11:13:08 +09:00
HeuJung cc45c0b84c v7.0.0-beta.5 release 2026-05-12 18:35:03 +09:00
HeuJung eb467aca79 v7.0.0-beta.5 release 2026-05-12 17:58:34 +09:00
HeuJung 1db039ff34 v7.0.0-beta.4 release 2026-05-11 11:29:41 +09:00
4947 changed files with 902900 additions and 167498 deletions
+9 -2
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0-beta.3
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=
@@ -103,3 +105,8 @@ VITE_APP_NAME="${APP_NAME}"
G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7
G7_UPDATE_GITHUB_TOKEN=
G7_UPDATE_PENDING_PATH=
# spawn 자식 프로세스 실패 시 동작 모드 (기본: abort)
# - abort : 즉시 abort + 운영자에게 수동 명령 안내 (안전 우선, stale 메모리 fatal 차단)
# - fallback : in-process fallback 진행 (proc_open 미지원 공유 호스팅 호환, stale 메모리 위험 잔존)
# G7_UPDATE_SPAWN_FAILURE_MODE=abort
+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-beta.3
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/ 의 관련 로그
- 브라우저 콘솔 에러
-->
+37
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
@@ -75,12 +78,26 @@ templates/_bundled/*/node_modules/
!plugins/_pending/
!templates/_pending/
# ===== 언어팩 시스템 =====
# 활성 언어팩 디렉토리 (설치된 복사본 — Git 제외)
lang-packs/*/
!lang-packs/_bundled/
# _pending 디렉토리 (외부 다운로드 임시 영역)
lang-packs/_bundled/_pending/
!lang-packs/_pending/
# DevTools debug dump
storage/debug-dump/
# MaxMind GeoLite2 DB (재배포 금지 — 각 환경에서 직접 다운로드)
storage/app/geoip/
# 인스톨러 런타임 산출물 — 각 환경에서 자동 생성/소비
storage/installer-state.json
storage/installer/
# 개발 전용 파일
.api-test/
.serena/
@@ -90,6 +107,7 @@ boost.json
cypress/
cypress.config.js
.env.testing
.claude/settings.local.json
# ===== 보안: 민감 파일 방어 패턴 =====
*.pem
@@ -106,3 +124,22 @@ id_ed25519
*.sql
*.sqlite
*.db
# NHN KCP 결제플러그인 PG 공개키는 예외 처리
!plugins/_bundled/*/bin/pub.key
.claude/tmp/
# ===== Playwright E2E =====
tests/Playwright/.auth/
playwright-report/
test-results/
# Playwright MCP 세션 아티팩트(스냅샷/콘솔 로그) — 커밋 대상 아님
.playwright-mcp/
# ===== 소스맵 =====
# 소스맵은 원본 코드 전문(sourcesContent)을 담고 있어 배포 대상이 아니다.
# 로컬 개발 빌드에서는 생성되지만(디버깅용) 저장소에는 유입되지 않는다.
# 배포용 빌드는 `--production` 이 G7_BUILD_SOURCEMAP=0 을 주입해 생성 자체를 막는다.
# 경로 한정 시 새 확장이 추가될 때 조용히 누락되므로 전역 규칙으로 둔다.
*.map
+146 -10
View File
@@ -6,22 +6,30 @@
<!-- AUTO-GENERATED-START: docs-quick-reference -->
### 백엔드 [backend/](docs/backend/) (22개)
### 백엔드 [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) |
| [console-confirm.md](docs/backend/console-confirm.md) | 콘솔 yes/no 프롬프트 (ConsoleConfirm) | 콘솔 커맨드의 yes/no 프롬프트는 $this->unifiedConfirm() 사용 — Laravel... |
| [controllers.md](docs/backend/controllers.md) | 컨트롤러 계층 구조 | AdminBaseController / AuthBaseController / PublicBaseCont... |
| [core-config.md](docs/backend/core-config.md) | 코어 설정 (config/core.php) | config/core.php = 코어 권한/역할/메뉴/메일템플릿의 SSoT (Single Source ... |
| [core-update-system.md](docs/backend/core-update-system.md) | 코어 업데이트 시스템 (Core Update System) | 코어 업그레이드 스텝: upgrades/ 디렉토리 (프로젝트 루트), 네임스페이스 App\Upgrades |
| [data-sync-helpers.md](docs/backend/data-sync-helpers.md) | 데이터 동기화 Helper (Data Sync Helpers) | 모든 데이터 동기화는 Service/Seeder 가 Helper 를 호출해 수행 (직접 Model 조작... |
| [dto.md](docs/backend/dto.md) | DTO (Data Transfer Object) 사용 규칙 | DTO 두 패턴 — Value Object(불변 1회 전달) vs Data Carrier(다단계 변형/... |
| [enum.md](docs/backend/enum.md) | Enum 사용 규칙 | 상태/타입/분류 = Enum 필수 (PHP 8.1+ Backed Enum) |
| [exceptions.md](docs/backend/exceptions.md) | Custom Exception 다국어 처리 | 예외 메시지 하드코딩 금지 → __() 함수 필수 |
| [geoip.md](docs/backend/geoip.md) | GeoIP 시스템 (MaxMind GeoLite2) | MaxMind GeoLite2-City DB 기반 IP → 타임존 감지 (SetTimezone 미들웨어... |
| [identity-messages.md](docs/backend/identity-messages.md) | 본인인증 메시지 템플릿 시스템 (Identity Messages) | 알림 시스템(notification_*)과 완전 분리된 IDV 전용 템플릿 인프라 |
| [identity-policies.md](docs/backend/identity-policies.md) | 본인인증 정책 시스템 (Identity Policies) | - |
| [identity-providers.md](docs/backend/identity-providers.md) | IDV Provider 작성 가이드 (Identity Verification Providers) | VerificationProviderInterface 구현 + IdentityProviderManage... |
| [language-pack-service.md](docs/backend/language-pack-service.md) | LanguagePackService (백엔드 Service 레이어) | LanguagePackService 가 install/activate/deactivate/uninsta... |
| [middleware.md](docs/backend/middleware.md) | 미들웨어 등록 규칙 | 인증 필요 미들웨어 → 전역 등록 금지! |
| [notification-system.md](docs/backend/notification-system.md) | 알림 시스템 (Notification System) | GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스 불필요) |
| [response-helper.md](docs/backend/response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 |
@@ -30,10 +38,12 @@
| [seo-system.md](docs/backend/seo-system.md) | SEO 페이지 생성기 시스템 (SEO Page Generator) | SeoMiddleware: 봇 요청 감지 → ?locale= 파라미터 해석 → SeoRenderer가 ... |
| [service-provider.md](docs/backend/service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 |
| [service-repository.md](docs/backend/service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) |
| [settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 | settings JSON 의 다국어 카탈로그 라벨(_cached_name 등)은 카탈로그 빌드 시점에 보강 |
| [translatable-seeders.md](docs/backend/translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) | 다국어 JSON 컬럼(name 등)을 시드하는 확장 entity 시더는 TranslatableSeede... |
| [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... |
| [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
### 프론트엔드 [frontend/](docs/frontend/) (48개)
### 프론트엔드 [frontend/](docs/frontend/) (50개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -59,6 +69,8 @@
| [g7core-api-advanced.md](docs/frontend/g7core-api-advanced.md) | G7Core 전역 API 레퍼런스 - 고급 | - |
| [g7core-api.md](docs/frontend/g7core-api.md) | G7Core 전역 API 레퍼런스 | G7Core.state: get/set/subscribe 전역 상태 관리 |
| [g7core-helpers.md](docs/frontend/g7core-helpers.md) | G7Core 헬퍼 API | - |
| [identity-guard-interceptor.md](docs/frontend/identity-guard-interceptor.md) | IdentityGuardInterceptor — 코어 본인인증 인터셉터 레퍼런스 | ActionDispatcher.handleApiCall 응답 후처리에서 isIdentityRequire... |
| [identity-verification-ui.md](docs/frontend/identity-verification-ui.md) | 본인인증(IDV) 공통 UI 가이드 | 모든 IDV 강제 지점은 동일한 428 응답 형식을 공유 (코어 9 + 게시판 4 + 이커머스 4 + N) |
| [layout-json-components-loading.md](docs/frontend/layout-json-components-loading.md) | 레이아웃 JSON - 데이터 로딩 및 생명주기 | - |
| [layout-json-components-rendering.md](docs/frontend/layout-json-components-rendering.md) | 레이아웃 JSON - 조건부/반복 렌더링 | - |
| [layout-json-components-slots.md](docs/frontend/layout-json-components-slots.md) | 레이아웃 JSON - 슬롯 시스템 | - |
@@ -86,30 +98,35 @@
| [handlers.md](docs/frontend/templates/sirsoft-basic/handlers.md) | sirsoft-basic 핸들러 | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) |
| [layouts.md](docs/frontend/templates/sirsoft-basic/layouts.md) | sirsoft-basic 레이아웃 | 베이스: _user_base.json (헤더 + 푸터 + 모바일 네비 + 콘텐츠 슬롯) |
### 확장 시스템 [extension/](docs/extension/) (25개)
### 확장 시스템 [extension/](docs/extension/) (30개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
| [cache-driver.md](docs/extension/cache-driver.md) | 캐시 드라이버 시스템 (CacheInterface) | 모든 캐시 저장은 CacheInterface 사용 (Cache:: 직접 호출 금지) |
| [changelog-rules.md](docs/extension/changelog-rules.md) | Changelog 규칙 (Changelog Rules) | 확장/코어 버전 업 시 CHANGELOG.md에 변경사항 기록 필수 (미기록 시 버전 업 불가) |
| [editor-spec.md](docs/extension/editor-spec.md) | 편집기 스펙 (editor-spec.json) | editor-spec.json = 편집기 팔레트/스타일 컨트롤/중첩 규칙/샘플 데이터/레시피의 선언 (... |
| [extension-manager.md](docs/extension/extension-manager.md) | ExtensionManager (확장 관리자) | composer.json 수정 없음 - 런타임 오토로드 방식 사용 |
| [extension-update-system.md](docs/extension/extension-update-system.md) | 확장 업데이트 시스템 (Extension Update System) | 업데이트 감지 우선순위: GitHub > _bundled (2단계, _pending 미참여) |
| [hooks.md](docs/extension/hooks.md) | 훅 시스템 (Hook System) | Action 훅: doAction() - 부가 작업 (로그, 알림, 캐시) |
| [language-packs.md](docs/extension/language-packs.md) | 언어팩 시스템 (Language Packs) | 코어/번들 확장의 lang/{ko,en}/ 는 가상 보호 행으로 자동 노출 (DB 없이 항상 activ... |
| [layout-extensions.md](docs/extension/layout-extensions.md) | 레이아웃 확장 시스템 (Layout Extensions) | - |
| [menus.md](docs/extension/menus.md) | 메뉴 시스템 | 구조: User → Role → role_menus 피벗 → Menu |
| [module-assets.md](docs/extension/module-assets.md) | 모듈 프론트엔드 에셋 시스템 | module.json에 에셋 매니페스트 정의 (js, css, loading strategy) |
| [module-basics.md](docs/extension/module-basics.md) | 모듈 개발 기초 | 디렉토리: vendor-module (예: sirsoft-ecommerce) |
| [module-commands.md](docs/extension/module-commands.md) | 모듈 Artisan 커맨드 | 목록: php artisan module:list |
| [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) |
| [sample-extensions.md](docs/extension/sample-extensions.md) | 학습용 샘플 확장 (Sample Extensions) | 샘플 확장 4종: gnuboard7-hello_module / _plugin / _admin_templ... |
| [storage-driver.md](docs/extension/storage-driver.md) | 스토리지 드라이버 시스템 (StorageInterface) | 모든 파일 저장은 StorageInterface 사용 (Storage::disk() 직접 호출 금지) |
| [template-basics.md](docs/extension/template-basics.md) | 템플릿 시스템 기초 | 타입: Admin (관리자용), User (일반사용자용) |
| [template-caching.md](docs/extension/template-caching.md) | 템플릿 캐싱 전략 | - |
| [template-commands.md](docs/extension/template-commands.md) | 템플릿 Artisan 커맨드 | 목록: php artisan template:list |
| [template-idv-bootstrap.md](docs/extension/template-idv-bootstrap.md) | 템플릿 IDV launcher 등록 가이드 | 템플릿 부트스트랩(initTemplate)에서 window.G7Core.identity.setLaunc... |
| [template-routing.md](docs/extension/template-routing.md) | 템플릿 라우트/언어 파일 규칙 | - |
| [template-security.md](docs/extension/template-security.md) | 템플릿 보안 정책 | - |
| [template-workflow.md](docs/extension/template-workflow.md) | 템플릿 개발 워크플로우 | 필수 파일: template.json, routes.json, _base.json, errors/{40... |
@@ -124,7 +141,33 @@
| [database-guide.md](docs/database-guide.md) | 그누보드7 데이터베이스 개발 가이드 | 마이그레이션: 한국어 comment 필수, down() 구현 필수 |
| [requirements.md](docs/requirements.md) | 그누보드7 시스템 요구사항 (System Requirements) | PHP 8.2+ 필수 |
| [testing-guide.md](docs/testing-guide.md) | 그누보드7 테스트 가이드 | 테스트 통과 = 작업 완료 (작성만으로 불충분!) |
| [auto-document.md](.claude/docs/auto-document.md) | 자동 문서화 (auto-document) | - |
| [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 -->
@@ -176,6 +219,11 @@
| `handler: "nav"` | `handler: "navigate"` |
| `handler: "setLocalState"` | `handler: "setState"` + `target: "local"` |
| `navigate` + `replace: true` (URL만 변경 시) | `handler: "replaceUrl"` |
| apiCall `params.target` (params 내부) | `target` 은 액션 top-level. params 내부 위치 시 URL 미해석 |
| apiCall `params.onSuccess` / `params.onError` (params 내부) | 액션 top-level. params 내부면 무시됨 |
| `refetchDataSource` `params.id` | `params.dataSourceId` 사용 |
| `handler: "showToast"` | `handler: "toast"` |
| 모달 안에서 부모 `_local.*` 참조 | 데이터소스 응답 필드 또는 `_global` 사용 (모달은 별도 컨텍스트) |
### 데이터 바인딩
@@ -200,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` 의 치환용으로는 쓰지 않는다.
### 상태 관리
| 금지 | 올바른 사용 |
@@ -229,6 +288,29 @@
| 모든 API에 개별 headers 설정 | globalHeaders로 공통 헤더 정의 |
| pattern 없이 헤더 정의 | pattern 필수 (`*`, `/api/shop/*` 등) |
### 인증/리다이렉트 규칙 (engine-v1.47.0+)
| 금지 | 올바른 사용 |
|------|------------|
| 모듈/플러그인에서 `AuthManager.updateConfig()` 호출 | 템플릿 부트스트랩(`initTemplate`)에서만 호출 |
| `AuthManager.updateConfig({ loginPath: 'https://...' })` (외부 origin) | `loginPath` 는 `/` 로 시작하는 동일 origin path-only |
| `AuthManager.updateConfig({ loginPath: '//evil.com/...' })` (protocol-relative) | `//` 시작 금지 (open redirect 방지) |
| 401 에러 페이지(`errors/401.json`)에서 직접 로그인 리다이렉트 구현 | 코어 `TemplateApp.showRouteError` 가드에 위임 (자동 처리) |
### Listener 데이터 접근
| 금지 | 올바른 사용 |
|------|------------|
| Listener 에서 `Model::query/find/where/create` 직접 호출 | Repository 인터페이스 주입 후 위임 |
| Listener 에서 `DB::table()->update(...)` | Repository 의 도메인 의도 메서드 (recalculate*/anonymize* 등) |
| Listener 에서 `$row->save()` / `saveQuietly()` / `delete()` | Repository 의 update/save/delete 호출 |
| Listener 생성자에 구체 Repository 직접 주입 | Repository Interface 주입 |
| Listener 에서 `request()` / `$_POST` 직접 접근 | Service 가 검증 후 도메인 객체로 전달 받기 |
| Filter 훅에 `'type' => 'filter'` 누락 | type 명시 필수 (반환값 무시 회귀 차단) |
| Listener 가 `HookListenerInterface` 미구현 (auto-discovery 대상) | implements + `getSubscribedHooks()` 정적 메서드 |
> 상세: [hooks.md "Listener 데이터 접근 규정"](docs/extension/hooks.md), [service-repository.md](docs/backend/service-repository.md)
---
## 템플릿 엔진 내부 버전 (engine-v1.x.x)
@@ -338,8 +420,19 @@ Keep a Changelog 표준:
| API 변경 (엔드포인트, 파라미터) | 테스트 건수/파일명 |
| 기존 기능의 버그 수정 | 리팩토링 세부사항 |
| 성능 개선 (체감 가능한 것) | 내부 규정/문서 변경 |
| Breaking Change | 이슈 번호 |
| Breaking Change | 내부 작업 이슈 번호 단독 (예: `refs #347`) |
| 엔진 버전 참조 (engine-v1.X.Y) | 코드 패턴 설명 |
| **공개 제보자 attribution** (`(#N @login 님께서 제보해주셨습니다.)`) | — |
| **KISA 등 공식 보안 채널** (`(KISA 측에서 제보해주셨습니다 — KVE-XXXX-XXXXX)`) | — |
### 공개 제보자 attribution
공개 저장소(GitHub) 이슈로 제보·건의된 항목이 출시 CHANGELOG 에 반영되면, 항목 끝에 공개 이슈 번호와 제보자 GitHub 핸들 멘션을 부착합니다.
- 형식: `- (본문) (#N @login 님께서 제보해주셨습니다.)` 또는 `... 건의해주셨습니다.`
- 톤: 버그 리포트는 "제보", 제안형 개선 요청은 "건의"
- 다중 매칭: `(#A @x, #B @y 님께서 제보해주셨습니다.)` / 혼재: `(#A @x 님께서 제보해주시고, #B @y 님께서 건의해주셨습니다.)`
- KISA 등 공식 보안 채널: GitHub 멘션 없이 텍스트 "KISA 측에서" + 공개 가능한 식별자만
### 신규 기능의 버그 수정 제외 규칙
@@ -395,17 +488,18 @@ Added/Changed/Fixed 내 항목이 10개를 초과하면 `####` 서브 헤딩으
```text
기능 구현 = 테스트 코드 작성 필수
신규 기능 / 도메인 표면 변경 = 시나리오 매니페스트(tests/scenarios/<feature>.yaml) 작성 의무 — 입력 axis cross product + 후속 효과 체인 전수 커버
테스트 통과 = 작업 완료 (작성만으로 불충분!)
기존 테스트 있음 → 변경사항 반영하여 수정 후 실행
기능 구현 시 관련된 모든 계층(백엔드+프론트엔드+레이아웃 렌더링) 테스트 필수
주의: 모듈/플러그인 프론트엔드 테스트는 독립 vitest.config.ts 사용 (루트 config 포함 금지)
필수: 도메인 매트릭스로 테스트 유형 분류 (Pure Logic/CRUD/Hook/Migration)
필수: 도메인 매트릭스 = 테스트 위치/형식 가이드. 입력 조합 망라 의무는 시나리오 매니페스트가 SSoT
필수: 버그 수정은 먼저 실패하는 회귀 테스트 → fail 확인 → 수정 → green 4단계
필수: 테스트 중 발견한 무관 에러도 같은 세션에서 처리 (stale test 또는 로직 수정)
필수: 릴리스 전 composer test-smoke 통과
```
> 상세: [docs/testing-guide.md](docs/testing-guide.md) — 도메인 매트릭스, Pre-release Smoke Suite, 회귀 테스트 4단계, 무관 에러 처리
> 상세: [docs/testing-guide.md](docs/testing-guide.md) — 기능 단위 시나리오 매트릭스, 도메인 매트릭스, Pre-release Smoke Suite, 회귀 테스트 4단계, 무관 에러 처리
### 그누보드7 레이아웃 렌더링 테스트
@@ -512,6 +606,20 @@ powershell -Command "npm run test:run"
---
## npm install 규칙
기본 `npm install`은 `package-lock.json`을 자동 수정할 수 있으므로, lock 파일 변경 의도가 없는 의존성 복구나 작업 환경 재구성에는 `npm install --package-lock=false`를 사용합니다.
| 상황 | 권장 명령어 | 비고 |
| ---- | ----------- | ---- |
| 누락 의존성 복구 / 작업 환경 재구성 | `npm install --package-lock=false` | lock 파일 변경 없이 설치 |
| clean install | `npm ci` | `package.json`과 `package-lock.json`이 동기화된 경우 |
| 의존성 신규 추가/업데이트 | `npm install <pkg>` | lock 변경이 작업 범위에 포함된 경우만 |
lock 파일 변경 의도가 없는 상황에서 `npm install` 단독 실행을 피합니다. `module.json`, `plugin.json`, `template.json`의 `version`을 바꾸면 해당 확장의 `package.json`, `package-lock.json`, `composer.json` 버전도 함께 동기화합니다. 의존성 재설치 없이 lock 파일의 version 필드만 갱신할 때는 `npm install --package-lock-only`를 사용합니다.
---
## 핵심 원칙
### 1. 동적 로딩
@@ -706,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
@@ -744,6 +876,7 @@ php artisan plugin:build sirsoft-payment --active # 활성 디렉토리에
# 코어 업데이트
php artisan core:check-updates # 코어 업데이트 확인
php artisan core:update [--force] [--no-backup] [--no-maintenance] # 코어 업데이트 실행
php artisan core:execute-upgrade-steps --from=X.Y.Z --to=A.B.C [--force] # 업그레이드 스텝 단독 실행 (HANDOFF 안내/수동 복구용 — 사전·사후 단계 자동 수행)
# 모듈
php artisan module:list
@@ -819,11 +952,12 @@ 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) |
| `app/Http/Resources/**` | [api-resources.md](docs/backend/api-resources.md) |
| `app/**/DTO/**`, `modules/**/src/DTO/**`, `plugins/**/src/DTO/**` | [dto.md](docs/backend/dto.md) |
| `database/migrations/**` | [database-guide.md](docs/database-guide.md) |
| `database/seeders/**` | [database-guide.md](docs/database-guide.md) |
| `resources/layouts/**/*.json` | [layout-json.md](docs/frontend/layout-json.md) |
@@ -831,6 +965,8 @@ php artisan migrate:rollback
| `templates/**/src/components/**/*.tsx` | [components.md](docs/frontend/components.md) |
| `modules/**/Listeners/**` | [hooks.md](docs/extension/hooks.md) |
| `plugins/**/Listeners/**` | [hooks.md](docs/extension/hooks.md) |
| `lang/{ko,en}/**/*.php` | [database-guide.md](docs/database-guide.md) (다국어 섹션) — 코어 백엔드 다국어 |
| `lang/{ko,en}.json`, `lang/partial/{ko,en}/**` | [data-binding-i18n.md](docs/frontend/data-binding-i18n.md) — 코어 프론트엔드 다국어 (`$t:core.*`) |
| `lang/**` | [database-guide.md](docs/database-guide.md) (다국어 섹션) |
| `routes/**` | [routing.md](docs/backend/routing.md) |
| `app/Seo/**` | [seo-system.md](docs/backend/seo-system.md) |
+535 -6
View File
@@ -4,11 +4,539 @@
형식은 [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
- 모듈·플러그인이 기존 화면의 초기화 동작에 자신의 초기화 단계를 덧붙일 수 있도록 레이아웃 확장 기능을 확대했습니다. 화면이 특정 모듈을 직접 알지 않아도, 모듈이 설치되어 있을 때만 그 모듈의 초기화(예: 표시 통화 복원)가 함께 실행됩니다.
- 관리자 대시보드 통계에 설치된 템플릿 수(활성/전체)와 활성 언어팩 수(활성/전체)를 추가했습니다.
- 관리자 대시보드에 최근 발송된 알림 이력을 보여주는 "최근 알림" 위젯용 조회 기능을 추가했습니다. 발송 채널·수신자·제목·상태(성공/실패/건너뜀)·발송 시각을 최신순으로 확인할 수 있습니다.
- 본인인증 challenge 취소(cancel) 와 verification 토큰 소비(consume_token) 시점에 외부 본인인증 플러그인이 자기 데이터 정리 로직을 등록할 수 있는 훅 4건 추가.
- 플러그인 개발용 표준 베이스 클래스를 추가하여, 모듈 개발과 동일한 방식으로 Repository · 캐시 · 스토리지 의존성을 선언만으로 자동 주입받을 수 있도록 개선. 플러그인이 코어와 같은 표준 위에서 작성되어 일관된 캐시·파일 격리 동작을 제공합니다.
- 모듈·플러그인이 페이지에 주입하는 레이아웃 확장을 관리자 화면에서 직접 편집할 수 있도록 개선. 편집 내역은 버전으로 기록되어 이전 버전으로 복구할 수 있으며, 저장 전 미리보기를 제공합니다.
#### 위지윅 레이아웃 편집기 (신규 도입)
화면을 직접 보며 편집하는 위지윅 방식의 레이아웃 편집기를 이번 버전에서 새로 도입했습니다. 아래는 도입된 기능 영역별 상세입니다.
- 좌측 트리 — 편집 화면 좌측 목록을 트리 구조로 개편하여 일반 레이아웃과 확장 주입 레이아웃을 구분 표시. 확장 주입 레이아웃은 해당 템플릿에 실제 적용되는 항목만 표시됩니다.
- 편집 진입과 캔버스 — 템플릿 관리 화면에 코드 편집과 레이아웃 편집 진입을 분리하고, 운영 화면 좌측 하단에 편집 권한 보유자 전용 진입 버튼을 추가했습니다. 편집 캔버스는 실제 페이지와 동일한 렌더링 결과를 보여주며, 데스크탑·태블릿·모바일 폭을 즉시 전환해 반응형 결과를 확인할 수 있습니다. 데이터 소스가 정의된 화면은 샘플 데이터로 자동 채워져 실제 API 없이도 화면을 검토할 수 있고, 본문 영역에 사용된 위지윅 에디터 등 동적 자원도 캔버스 안에서 그대로 동작합니다. 좌측 트리에서 화면을 선택하면 주소창이 즉시 반영되어 새로고침·공유·뒤로가기 동작이 자연스럽고, 같은 주소로 직접 진입하면 해당 화면이 자동 선택됩니다. 편집 중 로그인이 풀리면 안내 후 자동으로 로그인 화면으로 이동하며 재로그인 시 편집 화면으로 복귀합니다. 권한 부족·서버 오류·네트워크 끊김 등은 상황별 안내 카드(아이콘·설명·다음 행동 버튼)로 표시합니다.
- 요소 추가·재배치 — 컴포넌트를 클릭해 선택하면 외곽에 + 버튼이 나타나고, + 버튼을 누르면 화면 중앙에 모달 팔레트가 열려 좌측 카테고리 사이드바와 우측 그리드 카드(아이콘 + 라벨 + 유형 뱃지)에서 추가할 컴포넌트를 고를 수 있습니다. 부모 컨테이너에 들어갈 수 있는 컴포넌트만 자동 필터링되며, 검색으로 컴포넌트를 빠르게 찾을 수 있습니다. 추가한 컴포넌트는 템플릿이 정의한 기본 모양·기본 텍스트로 캔버스에 바로 보이며, 추가 직후 해당 위치로 화면이 부드럽게 스크롤됩니다. 컴포넌트 우상단 ⓘ 아이콘으로 속성 설정·복사·삭제 메뉴를 열고, 호버/선택 시 외곽선·8방향 리사이즈 핸들 자리·잠금/네비게이션 어포던스가 표시됩니다. 변경 사항은 Ctrl+Z / Ctrl+Shift+Z 또는 툴바 ↶/↷ 로 되돌리거나 다시 실행할 수 있습니다. 편집 모드에서 컴포넌트를 클릭해도 페이지 이동이 일어나지 않고 선택만 되며, 내부 라우트로 가리키는 요소는 "→ 이 화면 편집" 어포던스로 목적지 화면 편집 모드로 즉시 전환됩니다.
- 저장·충돌 방지 — 낙관적 잠금으로 저장 결과는 성공·검증 실패·동시 저장 충돌·네트워크 오류 등 상황별 안내(성공 토스트 / 오류 배너 / 충돌 모달)로 즉시 표시됩니다. 같은 레이아웃을 다른 관리자가 먼저 저장했으면 충돌 안내 모달에서 최신 버전 불러오기를 선택할 수 있고, 신규 추가한 컴포넌트가 그 사이 비활성화된 모듈·플러그인 소속이면 저장이 자동 차단됩니다. 저장 직후에는 되돌리기 이력이 정리되어 저장 시점이 새 기준이 되며, 변경 사항이 있는 상태에서 다른 화면으로 이동하려 하면 저장하지 않은 내용이 있다는 안내가 먼저 표시됩니다. 로그인·회원가입 등 비로그인 전용 화면을 편집할 때는 로그인 상태 미리보기가 적용되지 않아 안내 토스트가 잘못 뜨지 않습니다.
- 속성 편집 — 요소를 선택하면 정렬·글자 크기·굵기·색상·여백·크기 등을 친화적인 컨트롤(분절 버튼·슬라이더·색상 선택·태그 입력)로 조정할 수 있고, 요소 모서리·변을 직접 드래그해 가로·세로 크기를 바꿀 수 있습니다(드래그 크기 조정도 되돌리기/다시 실행에 반영). 반복 영역(목록처럼 같은 틀이 여러 번 펼쳐지는 자리)의 속성을 편집하면 모든 항목에 함께 적용됩니다. 요소를 편집하는 동안에는 선택한 요소만 밝게 유지되고 나머지 화면은 어둡게 잠겨, 다른 요소를 실수로 건드리지 않습니다. 속성 편집 팝업은 헤더를 잡아 옮길 수 있으며 선택한 요소를 가리지 않도록 빈 공간으로 자동 배치되고, 큰 요소에서도 요소 추가·메뉴 버튼이 크기 조절 핸들과 겹치지 않도록 여백을 둡니다. 고급 설정에서 요소를 볼 수 있는 권한을 권한 목록에서 골라 지정할 수 있습니다. 목록 있음·없음, 회원·비회원, 검증 실패·저장 중 같은 화면 상태를 미리보기로 전환해 데이터 의존 영역을 실제와 가깝게 검토할 수 있고, 같은 데이터 이름을 여러 확장이 쓰더라도 각 화면이 자기 출처의 샘플로 올바르게 표시됩니다. 라우트를 전환해도 저장하지 않은 편집 내용이 유지되며, 미저장 상태로 화면을 떠나거나 새로고침하면 경고가 표시됩니다. 마지막 저장 상태로 한 번에 되돌리는 초기화 버튼도 제공합니다.
- 이미지 관리 — 배경 이미지 등 레이아웃 첨부 이미지를 업로드·관리할 수 있으며, 상단 툴바와 배경 이미지 컨트롤에서 썸네일 갤러리를 열어 업로드·삭제·배경 적용을 할 수 있으며, 지정한 배경 이미지가 편집 캔버스에 즉시 반영되고(채움·맞춤·타일 표시 모드 포함) 모달을 다시 열어도 현재 설정이 그대로 복원됩니다. 발행된 배경 이미지는 일반 방문자 화면에서도 정상적으로 표시됩니다.
- 동작·표시조건·정렬 편집 — 요소를 선택하고 속성 편집 팝업에서 (1) 클릭·마우스 오버 시 동작을 "다른 페이지로 이동"·"안내 메시지 보여주기"·"화면 상태 바꾸기"·"목록 새로고침"·"서버에 보내고 결과 처리"·"결제 진입" 같은 친화 명칭으로 조립하고(서버 호출은 성공·실패 후속 동작까지 단계로 구성), (2) 로그인 회원·관리자·데이터 있음/없음·수정/새 화면·입력 오류 등 친화 조건을 "그리고/또는"으로 결합해 요소를 보여줄 조건을 정하며, (3) 가로·세로 컨테이너의 자식 배치(방향·정렬·줄바꿈·간격)와 정렬 박스 안 요소의 늘어남·정렬·순서를 시각 컨트롤로 편집할 수 있습니다. 핸들러·코드 용어 없이 친화 입력만 노출되며, 이동할 페이지·데이터 이름은 목록에서 선택합니다. "결제 진입" 동작은 특정 결제사에 고정하지 않고, 서버 응답이 알려주는 결제 수단(결제 핸들러·결제 데이터)을 데이터 검색에서 골라 연결합니다 — 결제 수단을 바꾸거나 추가해도 화면 편집 없이 동작합니다.
- 색 모드·디바이스별 스타일 — 속성 편집에 라이트/다크 색 모드와 디바이스별 편집을 더해, 스타일·표시조건 탭 상단의 서브탭에서 라이트/다크 색 모드와 기본값·PC·태블릿·모바일(+ 직접 지정하는 커스텀 화면 폭)을 선택해, 각 범위에 스타일·정렬·표시 조건을 따로 지정할 수 있습니다. 글자색·배경색은 패널의 프리셋 색을 고르면 라이트와 다크 모두에 적용되어 다크 화면용 색을 비개발자도 지정할 수 있고(컬러 피커로 직접 고른 자유 색은 라이트 화면 전용), 어느 범위에 기본값과 다른 설정이 있으면 탭에 표시점이 뜨며 "기본값으로 초기화"로 그 범위만 되돌릴 수 있습니다. 편집기 상단 라이트/다크 토글로 미리보기 색상 테마를 전환하면, 관리자 화면이 다크 테마여도 미리보기가 라이트/다크를 정확히 구분해 보여줍니다.
- 화면 문구 다국어 편집 — 상단 툴바의 콘텐츠 언어 선택으로 미리보기에 표시되는 화면 문구의 언어를 전환할 수 있고, 캔버스의 텍스트를 더블클릭하면 그 자리에서 바로 고칠 수 있습니다(Enter 로 확정, Esc 로 취소). 문구를 고치면 그 문구가 다국어 키로 자동 등록되어, 입력한 내용이 즉시 화면에 반영됩니다. 편집 중 떠 있는 서식 막대로 굵게·기울임·밑줄을 켜고 끄거나, 글자 크기·정렬·글자색을 목록에서 골라 적용할 수 있으며(색은 색상 미리보기와 함께 선택), 바뀐 서식은 편집 중에도 즉시 보입니다. 편집 안내 배지를 누르면 속성 편집의 번역 탭이 열려 모든 언어의 값을 한 번에 입력할 수 있어, 한 언어만 고치고 다른 언어 번역을 놓치는 일을 줄입니다. (데이터에 연결된 문구·반복 영역 문구는 직접 편집 대상에서 제외됩니다.)
- 모든 컴포넌트로 속성 편집 확대 — 어떤 요소를 선택해도 속성 탭에 "요소 ID" 칸이 항상 제공되어 화면 내 요소에 고유 식별자를 부여할 수 있으며(화면 안전 문자만 입력되고 한글·공백 등은 자동으로 걸러집니다), 컴포넌트별 편집 항목(링크 주소·이미지 주소·입력칸 종류·선택지·정렬 옵션 등)도 친화 컨트롤로 편집할 수 있습니다. 아이콘은 자유 입력 대신 검색 가능한 그리드에서 골라 지정할 수 있어(템플릿이 제공하는 아이콘 목록을 검색으로 빠르게 찾기), 아이콘 이름을 외우지 않아도 됩니다. 입력칸의 선택지처럼 여러 항목으로 된 값은 항목 추가·삭제·순서 변경으로 편집하며, 이미 데이터에 연결된 값은 실수로 덮어쓰지 않도록 보호됩니다.
- 모달 시각 편집 — 좌측 트리에 각 화면이 사용하는 모달 목록이 "이 화면의 모달" 그룹으로 표시되고(공통 레이아웃의 모달, 모듈·플러그인 화면의 모달, 별도 파일로 분리된 모달 포함), 각 모달은 "주문 취소"·"비회원 조회 비밀번호 재설정"처럼 친화 제목으로 표시됩니다. 모달을 선택하면 실제 화면 위에 모달이 열린 상태로 문구 수정·요소 추가·저장을 할 수 있습니다. 모달 바깥 영역은 어둡게 잠겨 클릭·드래그·편집이 차단되어 실수로 호스트 화면을 건드리지 않습니다.
- 버전 배지·복원 — 좌측 트리에 각 화면과 확장 주입 조각의 현재 저장 버전 배지가 표시되고, 버전 기록 모달에서 이전 버전과의 비교(diff)와 복원을 확장 주입 조각에서도 동일하게 사용할 수 있으며, 저장·복원 시 배지가 새로고침 없이 즉시 갱신되고 복원 결과가 캔버스에 바로 반영됩니다.
- 공통 레이아웃 편집 — 각 화면의 콘텐츠가 채워질 슬롯 자리가 점선 박스와 라벨로 표시되고(슬롯 자체는 편집 대상이 아니라 잠금), 헤더·푸터를 클릭해 바로 선택·편집할 수 있습니다. 모달 편집 중에도 편집 캔버스의 스크롤이 그대로 유지됩니다.
- 화면 상태 미리보기 — 모달·공통 레이아웃까지 확대해, 배송지 직접 입력, 모바일 메뉴 펼침, 쿠키 배너 표시, 본인인증 진행 모달처럼 특정 조작에서만 나타나는 영역을 상태 전환만으로 미리보며 편집할 수 있고, 모달 안에 확장이 주입한 영역도 시각 편집할 수 있습니다.
- 일부 템플릿(사용자 템플릿 등)의 화면을 미리볼 때 헤더가 "데이터를 표시할 수 없습니다"로 표시되지 않고, 미리보기용 샘플 데이터가 모든 템플릿에서 안전하게 렌더됩니다.
- 회원이 아닌 사용자(비회원)에게도 이메일 알림을 보낼 수 있도록 알림 시스템을 확장했습니다. 회원에게 알림을 보내던 것과 동일한 방식으로, 입력한 이메일을 수신자로 알림이 발송되며 발송 내역에도 함께 기록됩니다. 알림 종류별로 비회원 발송을 허용할 수단을 선택할 수 있어, 이메일은 비회원에게 보내되 사이트 안 알림처럼 로그인이 필요한 수단은 보내지 않도록 구분됩니다(새 알림 수단이 추가될 때 비회원 허용 여부를 별도로 정하지 않으면 기본적으로 보내지 않습니다). 비회원에게 가는 알림은 그 사용자가 보던 화면 언어로 발송됩니다.
### Changed
- 모든 번들 플러그인이 위 표준 베이스 클래스로 이관되어 캐시·스토리지 도메인이 코어 및 다른 확장과 명확히 격리되도록 개선. 한 플러그인의 캐시 키가 코어 캐시 영역을 침범하던 잠재 결함을 차단하며, 관리자 화면의 변경사항이 의도대로 즉시 반영됩니다.
- 템플릿이 정의하는 에러 페이지(404 / 403 / 500 / 503 / 401 / 점검) 의 식별자를 다른 모든 페이지와 동일한 디렉토리 접두사 포함 형식(`errors/{코드}`) 으로 통일. 템플릿 설치 시 에러 페이지가 일관된 식별자로 시드되며, 일부 환경에서 에러 페이지가 "찾을 수 없음" 응답으로 떨어지던 회귀를 차단합니다. 번들 템플릿(`sirsoft-basic`, `sirsoft-admin_basic`) 의 에러 페이지 설정도 함께 정합화됩니다.
- 템플릿 외부 리소스 위임 — Font Awesome·웹폰트 등 외부 CDN 리소스를 페이지 코드에서 하드코딩하지 않고 템플릿의 `externals` 선언으로 위임. 외부 스타일시트·웹폰트·스크립트와 preconnect·preload 등 리소스 힌트를 한 곳에서 관리하며, 관리자 템플릿과 사용자 템플릿에 동일하게 적용
- 템플릿 컴포넌트 에셋 캐시 버스팅 — `?v=time()` → `?v={{ $extensionCacheVersion }}`로 변경하여 매 요청 무효화 → 확장 변경 시점에만 무효화
- 모듈·플러그인 업데이트 시 관리자가 편집한 확장 레이아웃을 보존하거나 덮어쓰는 전략을 선택할 수 있도록 개선.
- 코어 업그레이드 스텝 명령에 보조 단계(권한 정상화·마이그레이션·캐시 정리 등)를 건너뛰고 스텝만 실행하는 옵션 추가.
- 코어 영역의 프론트엔드 다국어 자원(에러 메시지·시스템 토스트 등) 위치를 정리하여 어떤 템플릿이 부팅되어도 자동 노출되도록 개선. 이전에 코어 에러 화면에서 `core.errors.*` 같은 키가 raw 로 노출되던 결함이 해소됩니다.
### Fixed
#### 본인인증(IDV)
- 정책 관리에서 코어·모듈·플러그인이 제공한 정책의 "인증 목적"·"적용 대상"·"우선순위"를 바꿔 저장해도 반영되지 않던 문제를 수정했습니다 — 이제 정책 키·강제 시점·강제 위치(정책이 걸리는 지점)만 고정되고, 그 외 모든 항목을 운영자가 자유롭게 변경할 수 있으며, 변경한 값은 확장 업데이트 후에도 유지됩니다.
- 한 화면에 본인인증 정책이 여러 개 걸리고 우선순위가 같을 때, 어느 정책이 먼저 적용될지 정해지지 않아 의도한 인증(예: 성인 인증)이 무시되고 다른 인증이 적용될 수 있던 결함을 수정했습니다. 이제 우선순위가 같아도 항상 동일한 정책이 적용되도록 적용 순서를 고정하고, 정책 추가·수정 화면에 우선순위 입력칸을 제공하며 같은 위치에 같은 우선순위의 활성 정책을 저장하려 하면 저장을 막고 안내합니다. 정책 목록에도 우선순위 열을 추가해 한눈에 확인할 수 있습니다.
- 성인 인증 같은 추가 조건을 함께 요구할 때, 그 조건을 충족하지 못해 인증이 실패하면 실패 사유 안내와 "본인 확인이 필요합니다" 안내가 동시에 뜨던 문제를 수정했습니다. 이제 추가 조건 미충족으로 실패하면 그 사유 안내 하나만 표시되며, 일반 본인인증 실패의 안내는 종전대로 표시됩니다.
- 환경설정의 기본 프로바이더 / 목적별 프로바이더 / 코드 유효시간 / 최대 시도 횟수 4개 항목이 admin UI 저장값을 무시하던 결함 수정. 변경한 환경설정값이 회원가입·비밀번호 재설정 등 모든 본인인증 흐름에 즉시 반영됩니다.
- 환경설정 > 본인인증 > 목적별 프로바이더에서 일부 본인인증 목적(예: 성인인증)의 프로바이더를 지정해 저장하면 "설정 저장에 실패했습니다" 오류로 전혀 저장되지 않던 결함 수정. 이제 모든 본인인증 목적의 프로바이더 매핑이 정상 저장됩니다. 아울러 저장 검증에 실패하면 문제가 된 목적별 프로바이더 입력칸이 빨갛게 강조되고 그 아래에 사유가 표시되어, 어느 항목을 고쳐야 하는지 바로 확인할 수 있습니다.
- 정책에 프로바이더가 지정되지 않은 경우 환경설정의 기본 프로바이더가 자동 적용되도록 보강. 운영자가 정책마다 프로바이더를 일일이 지정하지 않아도 환경설정 기본값으로 일관되게 동작합니다.
- 코드 입력 모달의 "남은 시도 횟수" 표시가 환경설정값과 무관하게 항상 5회로 굳어 있던 결함 수정. 운영자가 환경설정에서 지정한 한도가 사용자 화면에 정확히 표시됩니다.
- 인증 코드를 최대 횟수까지 잘못 입력해 시도가 소진되었을 때, 화면에 "최대 시도 초과 — 다시 요청해주세요" 안내 대신 일반 오류 문구("인증 코드가 올바르지 않습니다")가 표시되던 결함 수정. 시도가 소진되면 재요청을 안내하는 메시지가 정확히 표시됩니다.
- 본인인증을 사용하는 모듈·플러그인을 비활성화해도 해당 확장이 설정한 본인인증 요구가 계속 적용되던 결함 수정. 이제 확장을 비활성화하면 그 확장이 선언한 본인인증 정책도 함께 적용 해제되고, 다시 활성화하면 운영자가 조정한 설정을 유지한 채 정상 복구됩니다.
#### 확장 동기화·캐시·레이아웃 주입
- 확장 메뉴 동기화 시점에 운영자가 수정하지 않은 필드 (아이콘·이름·정렬·URL) 가 잘못 "사용자 수정" 으로 표시되어 이후 모듈/플러그인 정의값으로의 자동 동기화가 영구히 차단되던 결함 수정. 확장 재설치·업데이트 시 메뉴 정의 변경이 정상 반영됩니다.
- 확장 역할 동기화 시점에 운영자가 수정하지 않은 필드 (역할 이름·설명) 가 잘못 "사용자 수정" 으로 표시되어 이후 모듈/플러그인 정의값으로의 자동 동기화가 영구히 차단되던 결함 수정. 메뉴 동기화와 동일 맥락으로, 확장 재설치·업데이트 시 역할 정의 변경이 정상 반영됩니다.
- 모듈·플러그인의 레이아웃 확장이 대상이 아닌 템플릿(관리자 확장 ↔ 사용자 템플릿)에도 등록되던 결함 수정. 업그레이드 시 잘못 등록된 항목을 자동 정리하며, 이후로는 대상 레이아웃이 존재하는 템플릿에만 등록됩니다.
- 모듈·플러그인이 공통 레이아웃(헤더·푸터 등 모든 화면이 공유하는 틀)에 주입한 UI·초기화 동작이 실제 화면에는 전혀 나타나지 않던 결함 수정. 개별 화면이 공통 레이아웃을 물려받을 때 그 "물려받은 출처" 정보가 사라져, 공통 레이아웃을 대상으로 한 확장 주입이 어느 화면에도 적용되지 않았습니다. 이제 화면이 물려받은 공통 레이아웃을 대상으로 한 확장도 정상 적용되어, 모듈이 설치되어 있으면 해당 UI(예: 헤더 통화 선택기)와 초기화 동작이 모든 관련 화면에 표시됩니다.
- 확장(모듈·플러그인) 변경 후 라우트 트리·메뉴·다국어 응답이 옛 값으로 남아 있던 결함 수정. 이전에는 클라이언트가 캐시 버전 신호를 동반하지 않아 백엔드 캐시가 갱신 시점을 놓쳤습니다. 확장 설치/활성화/업데이트가 일어나면 다음 페이지 로드부터 새 값이 즉시 보입니다.
- 확장을 업데이트하거나 레이아웃을 저장해도 변경 내용이 화면에 반영되지 않고 옛 내용이 계속 보이던 결함 수정. 캐시 갱신 신호가 기록되는 저장소와 읽히는 저장소가 환경설정에 따라 어긋날 수 있어, 올린 갱신 신호가 화면 쪽에서 보이지 않던 문제였습니다. 이제 갱신 신호가 항상 같은 저장소로 일관되게 기록·조회되어 확장 업데이트·레이아웃 변경이 다음 화면 로드부터 즉시 반영됩니다.
- 코어 업데이트 시 사용자가 모듈·플러그인·템플릿·언어팩의 `_bundled` 폴더 아래에 직접 만들어 둔 커스텀 폴더·파일이 함께 삭제되던 결함 수정. 이제 코어 업데이트는 코어가 제공하는 번들 확장만 갱신하고, 사용자가 추가한 항목은 그대로 보존합니다. (#43 @bigmsg 님께서 제보해주셨습니다.)
- 관리자 권한(`sudo`)으로 코어를 업데이트한 뒤, 모듈·플러그인·템플릿·언어팩을 업데이트하려 하면 "백업 생성 중" 단계에서 `mkdir(): Permission denied` 오류로 실패하던 결함 수정. 관리자 권한 업데이트가 백업 폴더와 업그레이드 기록 파일을 관리자 소유로 남겨, 이후 웹서버 계정이 같은 위치에 쓰지 못하던 문제였습니다. 이제 이들 폴더·파일의 소유권과 그룹 쓰기 권한이 정상화되고, 코어와 확장의 업그레이드 기록을 별도 파일로 분리해 두 작업이 서로의 기록 파일을 두고 충돌하지 않습니다.
#### 알림
- 회원에게 보내는 알림(이메일·사이트 알림)이 보내는 사람(관리자 등)의 화면 언어가 아니라 받는 회원 본인의 언어로 발송되도록 수정. 관리자가 한국어 화면에서 다른 언어를 쓰는 회원의 주문 상태를 바꿔도, 알림은 그 회원의 언어로 전달됩니다.
- 신규 주문 접수 시 관리자에게 발송되는 알림 메일의 결제금액이 0원으로 표시되던 결함 수정. 결제 예정 금액이 정확히 표시됩니다.
- 알림 템플릿을 기본값으로 복원할 때 제목·본문만 복원되고 수신자·클릭 주소는 관리자가 수정한 값이 그대로 남던 결함 수정. 이제 복원 시 수신자·클릭 주소까지 기본값으로 정확히 되돌아갑니다.
#### 설치·인스톨러
- 설치 마법사에서 데이터베이스 테이블 접두사를 지나치게 길게 입력하면 일부 확장 설치 시 테이블 인덱스 이름이 데이터베이스 한도를 넘겨 설치가 실패할 수 있던 문제를 예방합니다 — 접두사 입력 길이를 6자로 제한하고, 한도를 넘기면 설치 전 단계에서 안내합니다.
- 인스톨러에서 "기존 테이블 삭제 후 설치"를 선택할 때, 입력한 테이블 접두사와 무관한 다른 테이블까지 모두 삭제되던 결함 수정. 이제 입력한 접두사로 시작하는 테이블만 삭제되어, 같은 데이터베이스를 다른 프로그램과 함께 쓰거나 다른 접두사로 재설치할 때 무관한 테이블이 보존됩니다.
#### 관리자 화면·검색·시스템·보안
- 관리자 대시보드의 최근 활동·모듈/플러그인/템플릿 상태·게시판/이커머스 위젯 등 같은 틀이 여러 번 펼쳐지는 목록에서, 항목마다 고유 식별자를 붙이도록 작성된 화면이 모든 행에 같은 식별자를 그대로 내보내 일부 보조 기능·접근성 도구가 항목을 구분하지 못하고 일부 항목이 화면에서 누락되어 보이던 결함을 수정했습니다 — 이제 반복 목록의 각 행이 고유하게 식별되어 모든 항목이 정상 표시됩니다.
- 한 화면 안에서 탭이나 필터를 클릭으로 전환할 때, 화면에 따라 표시 조건에 묶인 데이터 목록이 비어 보이던 결함 수정. 새로고침해야 보이던 목록이 이제 탭·필터 전환 즉시 정상 표시됩니다. (표시 조건에 따라 로딩되는 데이터 목록이 전환 시점에 다시 평가되지 않던 문제였습니다.)
- 환경설정 화면 등에서 별도 표시 텍스트 없이 켜고 끄는 토글·체크박스를 켜서 저장하면 "참/거짓 값이어야 합니다" 오류로 저장이 거부되던 결함 수정. 토글의 켜짐/꺼짐 상태가 문자열이 아닌 참/거짓 값으로 정확히 전달되어 정상적으로 저장됩니다.
- 관리자 목록 화면에서 필터(발급상태·사용여부 등)를 선택하고 검색하면 일부 필터가 검색 직후 "전체"로 풀려 보이던 결함 수정. 새로고침해야 선택한 필터가 다시 표시되던 불편을 없애고, 검색 직후에도 선택한 필터가 그대로 유지됩니다.
- 검색창에 `<`, `>`, `(`, `+` 등 특수문자를 입력하면 오류가 발생하던 결함 수정. 특수문자는 일반 검색어로 처리되어, 어떤 문자를 입력해도 오류 없이 검색 결과(또는 빈 결과)가 표시됩니다.
- IME(한글·일본어·중국어) 입력 도중 Enter 로 글자를 조합 확정할 때 마지막 글자가 누락되거나 검색·제출이 두 번 실행되던 결함 수정. 이제 조합이 끝난 뒤의 Enter 만 동작합니다. (#54 @devrhee16 님께서 제보해주셨습니다.)
- 관리자 대시보드의 시스템 리소스(CPU·메모리·디스크) 수집이 일부 호스팅 환경(open_basedir 제한, 일부 시스템 함수 비활성)에서 실패할 때 대시보드 리소스 조회 전체가 오류로 응답하던 결함 수정. 이제 수집할 수 없는 항목만 "알 수 없음"으로 표시되고 나머지 화면은 정상 동작합니다. (#40 @glitter-gim 님께서 제보해주셨습니다.)
- 시스템 정보 화면의 디스크 사용량 조회도 동일한 환경에서 안전하게 "알 수 없음"으로 표시되도록 보강했습니다.
- 웹소켓 사용을 끈 상태에서도 실시간 연결이 일부 동작할 수 있던 문제 수정. 이제 웹소켓 사용을 끄면 실시간 연결·채널 인증이 완전히 비활성화됩니다. (#50 @bigmsg 님께서 제보해주셨습니다.)
- 사이트 이름을 언어별로 다르게 저장한 환경에서 브라우저 탭 제목과 검색 메타데이터(JSON-LD)가 올바르게 표시되지 않거나 페이지가 깨질 수 있던 문제 수정. 이제 모든 메타 경로가 현재 언어의 사이트 이름을 일관되게 사용합니다. (#49 @glitter-gim 님께서 제보해주셨습니다.)
- 존재하지 않는 페이지에 접근할 때 정상 페이지처럼 200 응답을 반환하던 문제 수정. 이제 미등록 경로는 올바른 404 응답을 반환해 검색엔진이 없는 페이지를 색인하지 않습니다. (#47 @glitter-gim 님께서 제보해주셨습니다.)
- 인증이 필요한 관리자 API에 로그인 없이 접근할 때, 브라우저 주소창 직접 입력 등 일부 요청에서 서버 오류로 응답하던 결함 수정. 이제 어떤 방식으로 접근해도 일관되게 "인증 필요" 응답이 반환됩니다. (#39 @glitter-gim 님께서 제보해주셨습니다.)
### Security
- 사용되지 않는 파일 서빙 엔드포인트(`/storage` 직접 업로드·다운로드 경로) 노출을 제거했습니다. 실제 파일 업로드·다운로드는 별도의 인증된 경로로 처리되며 영향이 없습니다. (#52 @glitter-gim 님께서 제보해주셨습니다.)
- 설치가 완료된 시스템에서 설치 상태 관리 엔드포인트(`/install/api/state-management.php`)가 다른 설치 API 와 동일하게 차단되지 않아, 설치 완료 후에도 설치 상태 조회·초기화·중단 요청이 가능하던 문제를 수정했습니다. 설치가 완전히 끝난 환경에서는 해당 요청이 모두 HTTP 410 으로 차단됩니다. 설치 진행 중 완료 화면으로 정상 전환되는 동작은 영향받지 않습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1056)
### Removed
- 기본 제공 결제 수단을 KG이니시스로 단일화했습니다. 그 외 결제 연동(나이스페이먼츠·NHN KCP·토스페이먼츠)은 기본 번들에서 제외되며, 필요한 경우 별도 플러그인으로 설치해 사용할 수 있습니다. 이미 해당 결제 연동을 설치해 사용 중인 사이트는 영향받지 않습니다.
## [7.0.0-beta.7] - 2026-05-15
### Fixed
- 인스톨러 설치 완료 후 `.env` 에 DB 자격증명과 `APP_KEY` 가 기록되지 않고 `storage/installer/runtime.php` 에 평문으로 잔존하던 결함을 수정. 운영 동작은 정상이었으나 자격증명이 정상적인 위치로 옮겨지지 않고 임시 파일에 보존되는 상태였습니다. (#37 @glitter-gim 님께서 제보해주셨습니다.)
- beta.4~beta.6 출시본에서 위 결함으로 인해 자격증명이 잔존한 환경을 beta.7 업그레이드 시 자동으로 감지하여 `.env` 로 머지하고 임시 파일을 정리하도록 업그레이드 스텝을 추가했습니다. `.env` 의 `INSTALLER_COMPLETED` 마커가 이미 설정된 정합 이상 케이스는 자동 정리 대상에서 제외하고 운영자 수동 검토를 안내합니다.
- 위 업그레이드 스텝이 `.env` 를 머지할 때 권한·소유자·그룹을 웹 서버 기준으로 정렬하도록 보강. 기존 `.env` 가 있던 환경에서는 운영자가 설정한 권한을 그대로 보존하고, 새로 생성되는 환경에서는 웹 서버가 읽을 수 있는 권한과 그룹을 자동으로 적용합니다. 명령줄 업그레이드 실행 사용자와 웹 서버 사용자가 다른 자체 구축 서버 환경에서 머지 직후 웹 서버가 `.env` 를 읽지 못해 사이트가 다운되는 회귀를 차단합니다.
- 인스톨러 설치 완료 화면의 `.env` 권한 강화 안내(`chmod`/`chgrp` 명령 예시) 가 Windows 환경에서 비노출되도록 OS 가드 추가. POSIX 권한 모델이 없는 Windows 환경에서 무의미한 Linux/macOS 전용 명령이 노출되어 운영자 혼란을 유발하던 문제를 수정합니다.
- beta.4~6 출시본의 인스톨러 결함으로 자격증명이 `storage/installer/runtime.php` 에 잔존한 환경에서 beta.7 업그레이드 시 번들 확장 일괄 업데이트 단계가 `Access denied for user 'root'@'localhost'` 로 실패하던 회귀 수정. 업그레이드 스텝이 `.env` 머지 직후 부모 프로세스의 환경 변수도 함께 갱신하여 후속 자식 프로세스가 정상 자격증명을 상속받도록 보강합니다. 정상 설치 환경 (beta.3 등) 에서는 트리거되지 않습니다.
- 위 동일 회귀의 추가 차단 — 코어 업데이트가 별도 프로세스로 업그레이드 스텝을 실행하는 경로에서는 자식 종료 후 부모의 환경 변수가 갱신되지 않아 동일 증상이 재발하던 결함 수정. 자식 프로세스가 부팅 시점에 디스크의 `.env` 와 자신의 환경 변수를 대조하여 손상이 감지되면 디스크 값으로 보정하도록 보강하여, 다음 단계의 확장 일괄 업데이트가 정상 자격증명으로 진행되도록 합니다. 정상 설치 환경에서는 자동 비활성화됩니다.
### Changed
- `INSTALL.md` 에 "업그레이드" 섹션을 신설하여 자동 업데이트의 표준 절차, `sudo` 권장 사유 (`.env` · `bootstrap/cache` 의 권한 모델), 사용자군별 단계적 업그레이드 분기, 권한·캐시 트러블슈팅을 한곳에서 안내하도록 보완. 관리자 환경설정 > 정보 > 업데이트 확인 모달이 본 섹션으로 외부 링크를 노출합니다. (#30 @yks118 님께서 건의해주셨습니다.)
- 관리자 환경설정 > 정보 > 업데이트 확인 모달의 자동 업데이트 명령어 안내를 `sudo php artisan core:update` 권장 형태로 변경. 권한이 일치하지 않는 환경(`.env` 가 웹 서버 사용자 소유) 에서의 실행 실패를 사용자가 사전에 회피할 수 있도록 모달 하단에 "전체 업그레이드 가이드 보기" 외부 링크를 추가해 INSTALL.md 의 업그레이드 섹션으로 연결합니다. (#30 @yks118 님께서 제보해주셨습니다.)
### Notes — `.env` 권한 권장안
위 업그레이드 스텝은 `.env` 가 이미 존재하는 환경에서는 권한을 임의로 변경하지 않고 기존 상태를 그대로 보존합니다. 보안 위생 강화를 원하시면 다음 권장안을 참고하세요.
- 가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. `.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함하므로 외부 접근을 최소화하는 것이 권장됩니다.
- 단, `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다 (예: 단일 사용자 환경의 `chown www-data:www-data .env && chmod 600 .env`). 소유자가 웹 서버 사용자와 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다.
- 명령줄 사용자와 웹 서버 사용자가 다른 환경 (자체 구축 서버 등) 에서는 `0640` 권한 + 그룹을 웹 서버 그룹으로 설정 (`chgrp www-data .env && chmod 640 .env`) 하는 것이 안전한 절충안입니다. 웹 서버는 그룹 멤버로 읽고, 명령줄 사용자는 직접 수정할 수 있습니다.
## [7.0.0-beta.6] - 2026-05-14
### Fixed
- 코어 업데이트 자동 롤백 시 신 버전이 추가한 신규 파일이 활성 디렉토리에 잔존하여 `BindingResolutionException` 등 부정합 부팅 실패가 발생하던 결함 수정. 백업 디렉토리에 신규 파일 manifest 를 기록하고 롤백 시 정확히 그 목록만 정리합니다. 사용자가 코어 영역에 직접 추가한 파일과 모든 symlink (`public/storage` 등) 는 보존됩니다. (#34 @bigmsg 님께서 제보해주셨습니다.)
- 자동 롤백 후 `bootstrap/cache` 잔존 PHP 캐시로 인한 추가 부팅 실패 차단 — 롤백 직후 캐시를 자동으로 정리합니다.
- beta.5 이전 자동 롤백으로 인해 활성 디렉토리에 잔존했을 수 있는 ServiceProvider 후보를 beta.6 업그레이드 스텝이 진단 로그로 식별하여 운영자 수동 검토를 안내합니다. 자동 삭제하지 않으므로 사용자가 직접 추가한 ServiceProvider 는 보존됩니다.
- 업그레이드 스텝 단독 실행 명령(`core:execute-upgrade-steps`) 이 마이그레이션·코어 재동기화·버전 갱신·캐시 정리·번들 확장 일괄 업데이트를 자동으로 함께 수행하도록 보강. 자동 업데이트 중단 후 안내된 수동 명령을 그대로 실행하면 별도 보조 작업 없이 업그레이드가 완료됩니다.
- 인스톨러 Step 3 (PHP CLI / Composer / 자동 감지 / 코어 _pending 경로 검증) 의 프론트엔드 catch 분기에서 사용하는 다국어 키가 언어 파일에 정의되어 있지 않아, 서버 응답 실패 시 화면에 다국어 키 문자열이 그대로 노출되던 결함 수정.
- 인스톨러 다국어 파일에 중복 정의된 키 4건 정리. POST 전용 API 의 메서드 거부 메시지가 일반 문구로 덮여 표시되던 문제, 데이터베이스 연결 실패 로그에서 placeholder 가 치환되지 않던 문제, HTTPS 카드 라벨이 안내 문장으로 표시되던 문제가 함께 해결됩니다.
- 인스톨러 Step 3 의 PHP CLI 및 Composer 절대경로 검증이 공유 호스팅 환경(시놀로지 DSM 등 `open_basedir` 가 시스템 binary 영역을 차단하는 환경)과 Windows 환경에서 정상 경로임에도 거부되던 결함 수정. 셸 인자 escape 와 메타문자 차단은 유지하고, 파일 시스템 권한에 의존하던 사전 검사를 실제 실행 결과로 판정하도록 변경합니다. Windows 의 백슬래시 경로 구분자도 정상 입력으로 인식됩니다 (단, `C:\Program Files\...` 같은 공백 포함 디렉토리는 공백 없는 경로 또는 8.3 short path 사용 권장). (#33 @glitter-gim 님께서 제보해주셨습니다.)
- 코어 운영 단계의 composer 바이너리 자동 인식이 같은 `open_basedir` 환경에서 실패하여 모듈/플러그인 설치 시 vendor-bundle 모드만 사용 가능하던 결함 수정. composer 가 정상 설치되어 PATH 에 존재하는 환경에서도 인식되도록 검출 로직을 개선했습니다.
- 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP 등) 에서 Composer 를 특정 PHP 인터프리터로 실행하려는 "PHP 절대경로 + Composer 절대경로" 공백 분리 입력 형식이 거부되던 결함 수정. 입력을 두 토큰으로 분리한 뒤 각 토큰을 개별 escape 처리하여 안전을 유지하면서 멀티 PHP 운영 시나리오를 지원합니다.
- 인스톨러 세션 쿠키가 일부 환경(비표준 포트 + dynamic DNS 도메인 + 브라우저 추적 보호 등) 에서 브라우저에 의해 차단되어 첫 화면 "설치하기" 클릭이 동작하지 않고 새로고침처럼 보이던 결함을 보완. 세션 쿠키 SameSite 정책을 Strict → Lax 로 완화하여 차단 케이스 자체를 줄이고, Step 0 진입 시 쿠키 round-trip 사전 진단을 수행하여 차단이 감지되면 사용자에게 회피 방법(다른 브라우저 / 쿠키 차단 해제 / IP·localhost 접속) 을 명시 안내합니다.
### Added
- `hotfix:rollback-stale-files` Artisan 커맨드 추가 — 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 파일을 운영자가 명시 실행하여 진단/정리할 수 있는 단발성 회복 도구. 기본은 진단 모드 (실제 삭제 없음), `--prune` 옵션 시 확인 프롬프트 후 정리. symlink 와 보호 경로(`storage/`, `vendor/`, `.env*` 등) 는 자동 제외.
- 본인인증 메시지 정의 목록 API 응답에 `can_create` 권한 키 추가 — 운영자의 수정 권한 보유 여부에 따라 관리자 화면이 "정의 추가" 버튼을 정확히 활성화/비활성화할 수 있도록 보강.
## [7.0.0-beta.5] - 2026-05-12
### Upgrade Notice
beta.4 → beta.5 업그레이드 시 beta.4 의 결함으로 인해 활성 확장 디렉토리 일부가 손실됩니다. (#34 @bigmsg 님께서 제보해주셨습니다.)
- 손실 항목 1: 운영자가 활성 디렉토리(`modules/{id}`, `plugins/{id}`, `templates/{id}`, `lang-packs/{id}`)에서 직접 수정한 코드
- 손실 항목 2: 외부 install 한 확장 (GitHub 등 `_bundled` 에 포함되지 않은 모듈/플러그인/템플릿/언어팩)
beta.5 의 자동 복구 스텝이 `_bundled` 기반으로 번들 확장은 복원하지만, 위 두 항목은 `_bundled` 에 없어 자동 복구가 불가능합니다.
업그레이드 전에 다음 디렉토리 전체를 별도 위치(외장 디스크, 별도 git 브랜치, tar.gz 파일)로 백업해두실 것을 권고합니다.
- `modules/`
- `plugins/`
- `templates/`
- `lang-packs/`
업그레이드 후 자동 복구된 활성 디렉토리에 본인의 수정 사항/외부 확장이 누락되어 있다면, 사전 백업에서 복원하거나 외부 확장은 원본 소스(GitHub 등)에서 재설치하세요. 실패한 외부 확장 식별자는 업그레이드 로그(`storage/logs/core_update_success_*.log`)에 명시됩니다.
#### 3가지 사용자군별 업그레이드 절차
- **사용자군 B — 정상 beta.4 사용자 (대다수)**: `php artisan core:update` 표준 절차. beta.5 의 자동 복구 스텝이 활성 확장 디렉토리 + lang-packs 정합성을 보장합니다.
- **사용자군 A — beta.3 → beta.4 업그레이드 도중 `Call to undefined method` 류 fatal 후 stuck 한 사용자**: `.env` `APP_VERSION` 을 변경하지 마시고 그대로 `php artisan core:update --force` 를 재실행하면 됩니다. 디스크는 이미 beta.4 이므로 새 자식 프로세스가 beta.5 메모리로 부팅되어 남은 업그레이드 스텝이 멱등 적용됩니다. 단, 활성 디렉토리에서 직접 수정한 코드 또는 `_bundled` 에 없는 외부 확장은 자동 복구 대상이 아니므로 업그레이드 전 위 디렉토리 백업이 동일하게 권장됩니다.
- **사용자군 C — beta.1 또는 beta.2 사용자**: `php artisan core:update` 1회 호출로 beta.5 까지 직접 업그레이드는 권장하지 않습니다. 중간 버전의 업그레이드 스텝이 이전 버전 메모리에 없는 신규 코드에 의존하여 fatal 위험이 있습니다. 다음 단계적 업그레이드를 권장합니다:
```text
beta.1 사용자: beta.1 → beta.2 → beta.3 → beta.4 → beta.5 (4 회)
beta.2 사용자: beta.2 → beta.3 → beta.4 → beta.5 (3 회)
```
각 단계마다 `.env` `APP_VERSION` 이 자동 갱신되므로 다음 호출은 그대로 `php artisan core:update` 실행하면 됩니다. 가까운 릴리즈 zip 으로 `--source=` 또는 `--zip=` 옵션을 사용해 단계별 진행 가능합니다.
#### 자동 롤백 후 `public/storage` 회복
업그레이드 도중 자동 롤백 후 화면에서 업로드 파일이 보이지 않거나 미디어 경로가 깨졌다면 `public/storage` symlink 가 일반 디렉토리로 변질된 상태일 수 있습니다.
**우선 시도 — beta.5 의 자동 복구**: beta.4 → beta.5 업그레이드 자체는 부모 beta.4 메모리 결함으로 symlink 가 깨질 수 있으나, beta.5 의 업그레이드 스텝이 다음 패턴을 자동 감지하여 복구합니다:
- 감지 조건: `public/storage` 가 일반 디렉토리 + `storage/app/public` 이 존재 (Laravel 표준 구성)
- 복구 동작: 손상된 디렉토리를 `public/storage.broken.YYYYMMDDHHMMSS` 로 보존 후 symlink 재생성
- 운영자 조치: 자동 복구 후 `.broken` 디렉토리의 콘텐츠를 검증한 뒤 수동 삭제 (false positive 방어를 위해 자동 삭제하지 않음)
**자동 복구가 작동하지 않은 경우 매뉴얼 회복**:
```bash
rm -rf public/storage
php artisan storage:link
```
beta.5 부터 백업/복원 과정에서도 symlink 가 보존되므로 beta.5 → beta.6 이후로는 본 결함이 자동 차단됩니다. Windows 환경에서는 PHP `symlink()` 가 `SeCreateSymbolicLink` 권한을 요구하므로 자동 복구가 silent skip 될 수 있어 매뉴얼 회복 절차가 여전히 필요합니다.
#### spawn 자식 프로세스 실패 시 동작 모드
beta.5 부터 코어 업데이트의 자식 프로세스 spawn 이 실패하면 즉시 abort 가 기본 동작입니다 (`G7_UPDATE_SPAWN_FAILURE_MODE=abort`). 다음 4가지 상황에서 부모 프로세스 메모리의 stale 클래스로 인한 fatal 위험을 차단합니다:
1. `proc_open` 비활성 (일부 공유 호스팅)
2. `proc_open` 자원 생성 실패
3. 자식 비정상 종료 (uncaught exception)
4. 자식 exit=0 이지만 업그레이드 스텝 0건 실행 (silent skip)
abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute-upgrade-steps --from=... --to=...`) 을 안내합니다. `proc_open` 비활성 환경 등 호환성이 필요한 경우 `.env` 에 `G7_UPDATE_SPAWN_FAILURE_MODE=fallback` 을 설정하여 in-process fallback 으로 진행할 수 있으나, 부모 메모리 stale 로 인한 fatal 위험이 잔존합니다.
### Added
- 본인인증 관리자 권한을 조회/수정으로 분리 — "프로바이더 설정 조회" / "정책 조회" 권한 신설로 단순 조회 권한만 부여받은 운영자도 환경설정 본인인증 화면에 접근 가능 (기존에는 수정 권한이 없으면 조회 화면도 403)
- 환경설정의 본인인증 정책/메시지 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자에게는 "정책 추가" / "수정" / "삭제" / "활성 토글" 버튼이 자동으로 비활성화 (이커머스/게시판 본인인증 탭 동일)
- 코어 업데이트의 spawn 자식 프로세스 실패 시 abort/fallback 동작 모드 선택 (`G7_UPDATE_SPAWN_FAILURE_MODE`, 기본 `abort`) — 부모 메모리 stale 로 인한 upgrade step fatal 위험을 fail-fast 로 차단 (#28 @bigmsg 님께서 제보해주셨습니다.)
- 업그레이드 스텝에 "버전별 데이터 스냅샷" 규약 도입 — 멀티 버전 점프 시에도 사용자가 단계별로 업그레이드한 것과 동등한 결과를 보장하도록 각 스텝의 시드 카탈로그·적용 로직·단발성 핫픽스를 해당 버전 디렉토리로 격리. 외부 확장 작성자가 beta.5+ 신규 업그레이드 스텝 작성 시 적용 (상세: 업그레이드 스텝 가이드) (#29 @bigmsg 님께서 건의해주셨습니다.)
- 언어팩 일괄 활성화 검증 규칙에 동적 확장 훅 추가 (`core.language_packs.bulk_activate_validation_rules`) — 모듈/플러그인이 호스트 확장 재활성화 cascade 흐름에서 추가 검증 필드를 동적으로 등록 가능
### Changed
#### Breaking
- 본인인증 관리자 권한 키 정리 — 기존 `core.admin.identity.manage` / `core.admin.identity.policies.manage` 가 각각 `core.admin.identity.providers.update` / `core.admin.identity.policies.update` 로 변경. beta.4 환경에서 기존 권한을 부여받은 운영자 역할은 업그레이드 시 자동으로 새 권한 키로 마이그레이션 (부여 시점/부여자/스코프 메타데이터 모두 보존). 외부 확장 또는 운영 도구가 옛 키 문자열을 하드코딩한 경우 새 키로 갱신 필요
### Fixed
- 코어 업데이트 시 활성 모듈/플러그인/템플릿/언어팩 디렉토리가 삭제되던 문제 수정 — beta.4 → beta.5 업그레이드 시 번들 원본에서 활성 디렉토리를 자동 복원하고, 외부 설치 확장은 업데이트 로그로 재설치 안내
- 코어 업데이트의 단계 전환 신호를 비정상 입력으로부터 차단하도록 검증 강화
- 본인인증 정책 응답의 생성/수정 일시가 사용자 타임존이 아닌 UTC ISO 문자열로 노출되던 문제 수정
- 백업/롤백 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 추적 복사되어 symlink 가 일반 디렉토리로 변질되던 문제 수정 (Linux 환경 한정 — Windows 는 권한 한계로 일반 디렉토리 폴백 + 수동 회복 안내 유지)
- root/super user 환경에서 Composer 검증·설치가 비대화형 컨텍스트에서 실패하던 문제 수정 (코어 업데이트, 확장 의존성 설치, 인스톨러 포함) (#31 @glitter-gim 님께서 제보해주셨습니다.)
- 본인인증 활동 로그의 "액션" 열에 `identity.verify` / `identity.verify_failed` 가 번역되지 않은 키 그대로 노출되던 문제 수정 — 코어 액션 라벨 매핑이 누락되어 있던 부분을 추가
- 본인인증 단계에서 운영자가 지정한 본인인증 수단이 무시되고 항상 기본 수단(메일)으로 발행되던 문제 수정 — 가입 정책에 설정한 본인인증 수단과 API 요청에서 명시한 수단이 실제 인증 발행에 반영되도록 변경
## [7.0.0-beta.4] - 2026-05-11
### Changed
#### Breaking
- 레이아웃 `navigate` 액션의 기본 스크롤 동작이 페이지 이동 후 최상단 이동으로 변경됨 — 일반 하이퍼링크 이동 UX 와 일치. 이전처럼 스크롤 위치를 유지하려면 `scroll: "preserve"` 명시 필요. 검색 필터/페이지네이션 등 위치 유지가 필요한 화면에서 회귀가 의심되면 해당 옵션 추가 (engine-v1.45.0)
#### 언어팩 매니페스트 정합화
- 언어팩 매니페스트(`language-pack.json`) 를 모듈/플러그인/템플릿 매니페스트와 동일한 필드 구조로 정렬 — 외부 작성자가 다른 확장과 동일한 표준으로 언어팩을 만들 수 있도록 개선
- 언어팩 매니페스트에 GitHub 저장소 필드 추가로 GitHub 기반 업데이트 경로 지원 (모듈/플러그인 매니페스트와 동일한 동작)
- 관리자 환경설정의 언어팩 카드와 상세 모달이 모듈/플러그인/템플릿 카드와 동일한 형식으로 다국어 이름 · 설명 · GitHub 링크를 노출하도록 변경
### Added
#### Generator 메타 태그
- 관리자 환경설정 → SEO 탭에 Generator 메타 태그 카드 추가 — 토글로 노출 여부를 제어하고 내용 입력으로 W3Techs 등 CMS 시장 점유율 측정 도구가 인식하는 `<meta name="generator">` 태그를 SEO 봇 페이지·SPA·관리자 셸 모두에 출력. 내용 미입력 시 "GnuBoard7 {버전}" 자동 적용, 운영자가 버전 노출을 원치 않으면 "GnuBoard7" 만 입력 가능 (#26 @Lastorder-DC 님께서 건의해주셨습니다.)
#### 웹 인스톨러 — 번들 언어팩 동반 선택 · 설치
- 인스톨러 4단계 확장 선택 화면에 "언어팩" 카드 신설 — 번들 언어팩을 locale 별 서브헤딩으로 노출하고, 사용자가 모듈/플러그인/템플릿을 선택하면 그 확장에 종속된 번들 언어팩 카드가 즉시 활성화. 종속 확장을 해제하면 그 언어팩 카드는 자동으로 비활성화 + 선택 해제
- 5단계 설치 진행 시 모든 확장의 install/activate 가 끝난 뒤 선택된 번들 언어팩을 일괄 설치 — 1건 실패는 best-effort 처리로 전체 설치를 중단하지 않음
- 코어/확장당 다수 locale(일본어 + 중국어 등)이 동시에 번들된 시나리오 대응 — 각 locale 은 독립된 서브헤딩 + 카드로 노출
#### 인스톨러 SSE 호환성 자동 감지
- 인스톨러 시작 시 SSE 호환성을 사전 점검하여 환경에 맞는 모드(SSE 또는 폴링)로 단방향 진입 — 워커 동시 실행 race(테이블 / unique 키 중복 에러) 차단
- SSE 비호환 환경 사용자에게 폴링 모드 진행 여부를 명시적 다이얼로그로 확인 후 시작
#### ActivityLog 다국어 영역 분리
- 활동 로그의 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되도록 변경 — 그동안 코어에 일괄 등록되어야 했던 모듈 origin 라벨(이커머스 주문·상품·마일리지, 게시판 게시물·댓글·신고, 페이지 등) 이 각 영역으로 이전되어 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명. 미정의 시 코어 라벨로 자동 fallback 하여 회귀 없음
- 모듈/플러그인이 발화하는 활동 로그가 호출자/대상 모델의 영역을 자동으로 인식하여 자기 다국어 파일로 라우팅 — 새 활동 로그를 추가할 때 별도 분기 코드 없이 자기 lang 만 채우면 정합
#### 다국어 시더 인프라
- 확장 entity 시더가 활성 언어팩의 다국어 데이터를 자동 머지하도록 다국어 시더 인터페이스 도입 — 신규 시더 작성 시 회귀를 자동 차단
- 번들 일본어 언어팩 빌드 스크립트가 시더 메타데이터를 일관된 경로로 조회하도록 단순화
#### 다국어 라벨 helper + Provider/Registry 페이로드 정합화
- 다국어 라벨 표시 시 활성 언어팩의 lang key fallback 을 자동으로 처리하는 다국어 라벨 보강 helper 추가 — Provider/Registry 등록 페이로드(알림 채널·결제 PG 등) 와 settings JSON 다국어 데이터를 단일 시그니처로 처리
- 알림 채널(`config/notification.php` 의 `default_channels`) 의 라벨이 활성 언어팩(일본어 등) 으로 자동 보강되도록 변경 — 일본어 활성 시 한국어 fallback 으로 노출되던 회귀 차단
- 토스페이먼츠 PG 프로바이더 등록 페이로드가 lang key 기반으로 라벨을 선언하도록 변경 — 활성 언어팩으로 자동 보강
- Provider/Registry 등록 페이로드가 다국어 JSON(`['ko' => ..., 'en' => ...]`) 을 직접 보유하지 않도록 audit 룰 신설 (회귀 자동 차단)
- settings JSON 다국어 entry 의 식별 키(code/id/key) 보유 검증 audit 룰 신설 (lang pack fallback 키 조립 가능성 보장)
- 프론트엔드 `$localized()` 표현식이 두 번째 인수로 lang key fallback 을 받도록 확장 — 활성 로케일 라벨 부재 시 모듈/플러그인 언어팩의 키로 자동 보강
- 이커머스 환경설정 카탈로그(배송 가능 국가 / 통화 / 결제수단) 표시 시 활성 언어팩의 라벨이 자동 적용되도록 변경 — 일본어 활성 시 카탈로그 라벨이 한국어로 노출되던 회귀 차단 (다음 일본어 언어팩 빌드 시 자동 적용)
- audit 룰이 `getDefault*Channels/Providers/Methods` 등 registry 기본값 반환 메서드의 다국어 JSON 직접 보유도 감지하도록 강화
#### Settings 카탈로그 다국어 자동 보강
- 환경설정 카탈로그(결제수단·통화·배송 가능 국가 등) 의 다국어 라벨이 카탈로그 빌드 시점에 활성 언어팩으로 자동 보강되도록 변경 — 사용자 체크아웃 + 관리자 환경설정 양쪽 모두 일본어 등 활성 시 한국어 fallback 으로 노출되던 회귀 차단
- 모듈/플러그인 개발자가 카탈로그를 추가할 때 누락하지 않도록 audit 룰이 helper 호출 누락 검출
- [docs/backend/settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) 에 단순 helper 사용 패턴 문서화
#### 확장 시스템
- manifest `hidden: true` 플래그 추가 — 관리자 UI 목록에서 학습용/내부용 확장을 숨김 (CLI 는 정상 노출). 관리자 UI 에 슈퍼관리자 전용 "숨김 포함" 토글, artisan `module:list` / `plugin:list` / `template:list` 에 `--hidden` 플래그, 관리자 API `/api/admin/{modules,plugins,templates}` 에 `include_hidden` 쿼리 파라미터 지원
- 학습용 최소 샘플 확장 4종 번들 추가
- 모듈: `gnuboard7-hello_module` (Memo CRUD + 훅 발행 시연)
- 플러그인: `gnuboard7-hello_plugin` (Action/Filter 훅 구독 시연)
- Admin 템플릿: `gnuboard7-hello_admin_template` (Basic 컴포넌트 최소 셋)
- User 템플릿: `gnuboard7-hello_user_template` (홈 + Memo 리스트 연동)
- 모듈/플러그인/템플릿 정보 모달에 "지원 언어" 섹션 추가 — 코어/번들/사용자설치 출처 배지로 한눈에 확인
- 모듈/플러그인/템플릿 인스톨러에 의존 확장 + 동반 번들 언어팩 동반선택 UI 추가 — 미선택 의존성에 종속된 언어팩은 자동 비활성화
- 인스톨러 요구사항 검증 단계에 언어팩 디렉토리 쓰기 권한 점검 + 권한 부여 안내 추가
- 사용자 수동 비활성화와 코어 버전 호환성으로 인한 자동 비활성화를 DB 수준에서 구분 — 자동 비활성화된 확장만 재호환 감지/원클릭 복구 대상이 되도록 분리 (#18 @laelbe 님께서 제보해주셨습니다.)
- 코어 업그레이드 후 자동 비활성화 확장이 다시 호환되면 관리자 대시보드에 "다시 활성화" 알림 표시 + 원클릭 복구 버튼 제공 (자동 재활성화는 하지 않음 — 운영자가 명시적으로 복구)
- 모듈/플러그인/템플릿 목록 화면 상단에 자동 비활성화 확장 안내 배너 추가 (코어 업그레이드 가이드 링크 동반)
- 업데이트 모달에 코어 버전 호환성 안내 + "위험을 이해하고 강제로 진행" 체크박스 추가 — 운영자가 위험을 인지하면 비호환 확장도 강제 설치 가능
- 관리자 대시보드 "시스템 알림" 카드 노출 — 코어 호환성 자동 비활성화/재호환 알림이 분기 렌더되며 개별 dismiss 지원
#### 코어 업데이트 가시성
- 코어 업데이트 마무리 단계에서 sudo 환경 결함(파일시스템 ACL · immutable 비트 · NFS 권한 거부 등) 으로 일부 경로의 소유권/그룹 쓰기 권한을 정상화하지 못한 경우 즉시 콘솔에 실패 경로 + 운영자 수동 복구 명령(`sudo chown -R …` / `sudo chmod -R g+w …`) 안내 — 이전에는 silent fail 로 묻혀 운영자가 후속 권한 거부 발생 후에야 인지하던 문제 해소
#### 알림 시스템
- 권한 기반 수신자 타입 추가 — `permission` 타입으로 특정 권한을 가진 모든 사용자에게 알림 발송 가능 (예: 게시판 신고 알림은 신고 관리 권한자에게 자동 발송)
- 모듈 환경설정에서 알림이 비활성화된 경우 발송 자체를 사전 차단하는 정책 게이트 도입 — 이전에는 수신자 해석 단계가 정책을 우회하여 발송되던 문제 해소
- 게시판 환경설정 → 신고 정책 탭에 신고 알림 채널 선택 UI 추가 (이메일 / 사이트 알림 다중 선택)
- 모듈/플러그인이 자기가 발송하는 알림 정의를 manifest 에서 직접 선언하도록 통일 — 활성화/업데이트 시 운영자 편집값을 보존하면서 자동 등록되고, 제거 시 함께 정리됨. 권한·메뉴·본인인증과 동일한 declarative getter 패턴
- 코어 기본 알림(회원가입 환영·비밀번호 재설정·비밀번호 변경) 의 다국어 제목/본문/변수/채널 정의를 `config/core.php` 로 통합 — 운영자가 코드 수정 없이도 향후 표면 변경을 추적할 수 있는 단일 소스 확보
#### 본인인증
- 본인인증(IdentityVerification) 인프라 도입 — 코어에 범용 IDV 프로바이더 계약을 마련하고, 기본 메일 프로바이더를 내장. 플러그인이 KCP·이니시스·SMS 등 다른 경로를 동일 계약으로 붙일 수 있도록 확장점 제공
- 외부 본인인증 provider (KCP·PortOne·토스인증·Stripe Identity 등) 가 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입할 수 있는 슬롯 도입
- 비동기 검증 흐름을 위한 백엔드 폴링/콜백 엔드포인트 추가 — `GET /api/identity/challenges/{id}` (상태 폴링), `POST /api/identity/callback/{providerId}` (외부 redirect 콜백 수신)
- 본인인증 정책 시스템 신설 — 회원가입·비밀번호 재설정·민감 작업에 적용되는 모든 본인인증 시점을 라우트/훅 단위로 선언형 정책으로 통합 관리. 관리자 화면에서 정책 활성/유예 시간/프로바이더/실패 모드/단계(가입 제출 전 vs 가입 후 활성화 전)·적용 대상(self/admin/both)을 조정할 수 있으며, 운영자 수정값은 업데이트 재시딩 시 보존됨
- 본인인증 정책의 enable 토글이 라우트 코드 수정 없이 즉시 적용 — 모든 API 라우트가 정책 DB 와 자동 매칭되어 운영자가 admin UI 에서 정책을 켜는 즉시 본인인증이 강제됨. 코어/모듈/플러그인 어떤 라우트의 응답 처리 패턴에서도 본인인증 흐름이 일관되게 모달까지 도달하도록 처리
- 회원가입 단계 정책 2종 시드 — 가입 제출 전 동기 검증, 가입 후 활성화 전 비동기 challenge (기본값 비활성, 운영자 opt-in)
- 본인인증 정책이 활성화된 모든 강제 지점(회원가입·비밀번호 재설정·민감 작업·게시판/이커머스 정책 등)에서 사용자/관리자 화면에 동일한 모달 UX 가 자동으로 표시되도록 코어 인터셉터와 공통 모달 인프라 도입
- 전역 프론트엔드 인터셉트 — 서버가 HTTP 428 본인인증 요구 응답을 반환하면 자동으로 인증 모달을 띄우고 사용자가 인증에 성공하면 원 요청을 자동 재실행
- 본인인증 가드가 모듈/플러그인이 선언한 훅에도 자동 적용되도록 동적 구독 도입 — 결제 직전·민감 액션 직전 등 도메인 특화 가드를 운영자 토글 한 번으로 활성화
- 관리자 화면에 본인인증 정책 관리 페이지 추가 (정책 목록 DataGrid + 편집)
- 관리자 화면에 본인인증 이력 페이지 추가 — 알림 발송 이력과 동일한 수준의 UI 제공: 인증 수단(Provider) 탭(활성화된 프로바이더에 따라 자동 갱신), 통합 검색(자동 감지/사용자 ID/대상 식별자/IP/정책 키), 상태·인증 목적·채널·발생 유형 다중선택 OR 필터, 날짜 범위 + 고급 검색(발생 유형·출처·정책 키) 토글 영역, 정렬·페이지 사이즈 선택, 행 펼침으로 인라인 상세 표시, 모바일 반응형, 사용자 타임존 기준 시각, 보관주기(180일) 일괄 파기, 인증 수단/발생 위치 다국어 라벨 표시
- 본인인증 목적(purpose) 의 출처(source) 추적 — 코어/모듈/플러그인이 선언한 목적을 구분해 환경설정 화면에서 분리 표시하며, 코어 정책 화면 상단에 "코어가 제공하는 본인인증 목적" 칩 섹션(목적 코드/라벨 + 설명·허용 채널 툴팁) 추가
- 모듈 환경설정의 본인인증 정책 탭을 코어 정책 관리 화면과 동일한 UI/UX 로 통일 — 검색·필터(강제 시점/출처)·페이지네이션·정책 추가/편집 모달 직접 호출(코어 화면으로 이동 X)·이 모듈이 등록한 본인인증 목적 칩 섹션(없으면 안내 메시지). 정책 목록의 "인증 목적" 컬럼이 코드명 대신 사람이 읽을 수 있는 라벨로 표시
- 모듈/플러그인이 자기 컨텍스트의 본인인증 정책·목적·메시지 정의를 manifest 에서 직접 선언하도록 통일 — `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 선언만으로 코어 정책 관리에 자동 연동되며, 활성화/업데이트 시 운영자 편집값을 보존하면서 자동 등록·정리됨. 권한·메뉴·알림과 동일한 declarative getter 패턴
- 본인인증 정책/이력 목록 API 가 source 컨텍스트별 필터를 받아 모듈 환경설정 탭이 자기 정책/이력만 조회 가능하며, 운영자 정의 정책을 특정 모듈/플러그인 컨텍스트에 귀속시켜 추가할 수 있도록 source_identifier 입력 허용 (`admin` / `module:{id}` / `plugin:{id}`)
- 본인인증 메일 메시지 템플릿 시스템 도입 — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의 가능하며, 메시지 발송 시 정책 → 목적 → 프로바이더 기본값 순서로 fallback 해석. 회원가입/계정 변경/중요 작업/비밀번호 재설정 + 프로바이더 기본값 등 5종 메일 템플릿이 한국어/영어로 시드되어 즉시 발송 가능
- 본인인증 challenge 발급/검증/취소 라우트에 권한 미들웨어 + scope=self 가드 적용 — 게스트는 `core.identity.{request,verify,cancel}` 권한으로 비로그인 가입(Mode B) 흐름 진입, 로그인 사용자는 본인 challenge 만 다룰 수 있으며 관리자는 임의 challenge 도 처리 가능. 모달 취소 시 서버 cancel API 를 호출해 challenge 가 audit log 에 cancelled 상태로 즉시 기록되도록 정합화
- 환경설정 → 본인인증 탭에 "메시지 템플릿" 서브탭 신설 — 알림 템플릿 관리와 동일한 UX (채널 서브탭·페이지당 항목 수 셀렉터·카드 펼침 본문 미리보기·활성/기본 배지·페이지네이션) 로 정의 목록·활성 토글·다국어 편집(변수 가이드 + 기본값 복원) 제공. 운영자가 추가한 정책에 전용 메일 메시지 정의를 화면에서 직접 등록·삭제 가능 (시드 기본값 보호 + 정책 키 매칭 검증). 외부 본인인증 프로바이더 플러그인이 자기 메시지 기본값을 코어 복원 로직에 기여할 수 있는 필터 훅 노출
- 알림 발송 이력 / 본인인증 이력 샘플 시더를 코어/모듈별로 분리 — 코어 시더는 코어 정의·정책만, 각 모듈 시더는 자기 영역만 채우도록 영역 격리. 모듈은 `module:seed {id} --sample` 으로 자기 영역 이력만 독립 생성 가능. 샘플 데이터는 실제 등록된 사용자·정의·정책·프로바이더 기반으로 생성되어 운영 데이터와 동일한 스키마/분포 유지
- 모듈/플러그인 제거 시 코어 공유 테이블에 적재된 해당 확장의 데이터(권한·관리자 메뉴·알림 정의·본인인증 정책·본인인증 메시지 정의·본인인증 목적) 가 함께 정리되며, 제거 모달의 "삭제될 데이터" 에도 항목별 건수가 표시되도록 개선
- IDV 도메인 분류 데이터(목적·채널·트리거 출처·정책 범위·정책 실패 모드·정책 적용 대상·정책 출처·메시지 스코프) 8종을 PHP Backed Enum 으로 정의하여 정책 등록/수정 시 일관된 검증 적용
#### 인스톨러
- 인스톨러 설치 완료 화면에 설치된 코어 버전 표시 — 사용자가 어떤 버전이 설치되었는지 즉시 확인할 수 있도록 개선
#### 언어팩 시스템
- 새 언어(일본어/중국어 등)를 코어 수정 없이 추가할 수 있는 언어팩(Language Pack) 시스템 도입
- ZIP 업로드 또는 GitHub URL로 언어팩 설치/제거/활성화 지원
- 동일 언어 슬롯에 여러 벤더의 언어팩 공존 가능 — 라디오 전환으로 즉시 활성 변경
- 코어/모듈/플러그인/템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 가능
- 관리자 메뉴: 환경설정 > 언어팩 관리(통합) + 모듈/플러그인/템플릿별 진입점 추가
- 사용자가 직접 수정한 다국어 키는 언어팩이 덮어쓰지 않도록 보존 정책 적용
- 보안: 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단
- 언어팩 관리 화면을 모듈 관리와 동일 수준의 운영 도구로 보강 — 검색(식별자/벤더/언어), 업데이트 확인, 캐시 갱신, 다중 선택 일괄 제거 지원
- 활성/비활성 상태를 토글 스위치로 일원화 (보호된 팩은 비활성화된 토글로 표시)
- 업데이트 가능 항목에 "업데이트 가능" 배지와 행 단위 업데이트 실행 버튼 노출
- 정보 모달을 모듈 정보 모달과 동일한 4섹션 구조(기본정보 / 호환성 / 소스 정보 / 변경로그)로 제공하며, CHANGELOG.md 를 자동 파싱하여 버전별 카테고리로 표시
- 설치 모달에서 ZIP 업로드 전에 manifest 와 검증 결과를 사전 확인할 수 있는 미리보기 제공
- 모듈/플러그인/템플릿별 언어팩 페이지 진입 시 대상 확장 안내 배너와 환경설정 탭으로 회귀하는 링크 노출
- 언어팩 업데이트 권한을 별도 권한 키로 분리하여 설치/관리 권한과 독립적으로 부여 가능
- 언어팩 운영 풀 패리티 — 모듈/플러그인/템플릿 관리와 동일한 안전 장치/확장성 적용
- 공식 일본어(ja) 번들 언어팩 12종 추가 — 코어, 주요 모듈(전자상거래/게시판/페이지), 주요 플러그인(CKEditor5/마케팅/토스페이먼츠), 기본 템플릿(admin/user)을 일본어로 즉시 사용 가능
- 언어팩 관리 화면에서 번들 언어팩을 모듈/플러그인 관리와 동일하게 "미설치" 상태로 노출 — 행별 "설치" 버튼으로 즉시 설치 가능
- 언어팩 목록 필터에 "미설치 (번들)" 상태 옵션 추가
- 본인인증 메일 메시지 정의의 다국어 키를 언어팩으로 주입할 수 있도록 확장 — 코어 수정 없이 언어팩 ZIP 만으로 IDV 메일 본문/제목을 다국어화 가능
- 모듈/플러그인이 선언한 알림/본인인증 메시지 정의의 다국어 키도 언어팩으로 주입되도록 정합 — 이전에는 hook 발화 누락으로 코어 정의에만 적용되던 동작 정상화
- 업데이트 시 자동 백업 + 실패 시 직전 버전으로 자동 복구
- 동시성 가드 — 진행 중인 작업 동안 활성/비활성/제거/재업데이트 진입 차단
- 번들 디렉토리(`lang-packs/_bundled/{identifier}`) 에서 외부 다운로드 없이 (재)설치하는 경로 추가 — 코어 언어팩 복구/재배포 단순화
- 라이프사이클 훅 명명을 모듈/플러그인/템플릿과 통일 — `core.language_packs.{installed|updated|uninstalled|activated|deactivated}` 발행 (확장 가능성 확대)
- Artisan 커맨드 신규 — `language-pack:list`, `language-pack:install`, `language-pack:update`, `language-pack:uninstall`
- 코어와 번들 확장(모듈/플러그인/템플릿)에 내장된 한국어/영어를 가상 보호 언어팩으로 자동 노출 — 별도 설치 없이 언어팩 관리자에서 항상 활성/보호 상태로 확인 가능, 수정/제거 차단
- 호스트 확장 비활성화 시 그에 종속된 언어팩이 함께 비활성화되며, 재활성화 시 "다음 언어팩도 활성화하시겠습니까" 모달로 사용자 의사 확인 후 일괄 활성화
- 여러 언어팩을 한 번에 활성화하는 `POST /api/admin/language-packs/bulk-activate` API 추가 (의존성/버전 호환성 자동 검사)
- 언어팩 활성화 시 의존성 + 호스트 확장 버전 호환성 검사 자동 수행 — 호스트 확장이 비활성/미설치이거나 버전 미달이면 활성화 차단
- 언어팩 업데이트 우선순위를 모듈/플러그인 패턴으로 정합화 — GitHub 1순위 + bundled 폴백, 강제 업데이트 시 bundled 우선
- 언어팩 상세 모달에서 코어/번들 확장 내장 항목 클릭 시 "별도 언어팩이 아닌 내장 번역" 안내 배너 노출
- 모듈/플러그인/템플릿 상세 모달의 닫기 버튼이 콘텐츠 길이와 무관하게 모달 하단에 항상 보이도록 sticky 처리
#### SEO
- 관리자 환경설정 > SEO 탭의 Sitemap 카드에서 sitemap 을 즉시 재생성하고 마지막 생성 시각을 확인할 수 있는 "지금 생성" 버튼 추가 — 큐 드라이버와 무관하게 동기 실행되며 결과를 즉시 표시
#### SEO 봇 감지
- 봇 감지 엔진을 `jaybizzle/crawler-detect` 라이브러리로 교체. 기본 약 1,000종의 봇(검색엔진·링크 미리보기·AI 검색 등)이 자동 감지됨 — 링크를 슬랙·페이스북·LinkedIn·트위터·디스코드·텔레그램 등에 붙여넣으면 제목·설명·이미지 미리보기가 즉시 동작
- 라이브러리가 놓치는 봇 3종(`kakaotalk-scrap`·`Meta-ExternalAgent`·`ChatGPT-User`) 을 G7 보강 패턴으로 기본 포함
- 봇 감지 확장 훅 `core.seo.resolve_is_bot` 신설 — 플러그인이 IP 범위 검증·역방향 DNS·Cloudflare 봇 점수 등을 주입할 수 있는 슬롯
- "봇 라이브러리 사용" 관리자 토글 추가 (기본 on, 비활성 시 운영자 커스텀 목록만 사용하는 레거시 모드)
#### SEO OG / Twitter 카드 / 도메인 ownership
- 슬랙·페이스북 링크 미리보기에 이미지·카드가 표시되도록 OG 보강 태그를 자동 출력하도록 개선 — og:site_name, og:image:width, og:image:height, og:image:secure_url, og:image:type, og:image:alt, og:locale 추가
- Twitter 카드 메타태그(twitter:card / twitter:site / twitter:title / twitter:image 등) 출력 신설 — 슬랙 unfurl 폴백 경로 정상화
- 운영자 환경설정 SEO 탭에 "OG / Twitter 카드 기본값" 카드 추가 — 사이트 이름·이미지 기본 가로/세로·Twitter 카드 타입·Twitter 사이트 핸들 5개 입력
- 모듈·플러그인이 자기 도메인의 OG/Twitter/JSON-LD 를 직접 선언하도록 SEO declaration API 신설 — 이커머스 상품(Product/Offer/AggregateRating)·게시판 게시글(Article) 등 도메인 스키마가 레이아웃에서 확장 코드로 owned 되어 데이터에 따라 정확한 부속 태그 생성
- SEO 메타 확장 훅을 분기별·통합 모두 제공 — OG·Twitter·구조화 데이터 각각의 hook 슬롯과 통합 hook 모두 지원하여 확장이 원하는 단계에서 선택 변경 가능
- 모듈 설정 타이틀 템플릿에서 변수가 비어있을 때 인접 구분자(- – · |) 가 자동 정리되도록 개선 — 옵셔널 그룹 `[ ... ]` 표기도 지원하여 페이지별 구성 명시 가능
### Changed
- 콘솔 confirm 입력 처리 통일 — yes/y, no/n 외 입력 시 안내 메시지 출력 후 재질문, empty 입력 시 default 사용. 코어 업데이트·매니저 커맨드(module/plugin/template install·update·uninstall)·설정 마이그레이션의 모든 yes/no 프롬프트에 동일 규칙 적용 (#15 @laelbe 님께서 건의해주셨습니다.)
- 비밀번호 재설정 정책 기본값을 비활성으로 변경 — 본인인증 인프라가 미구성된 사이트에서도 기본 동작이 영향받지 않도록 운영자 opt-in 으로 전환
- 본인인증 정책의 인증 조건(`conditions`) 운영자 편집 허용 — 회원가입 단계 등 정책 조건을 코드 수정 없이 관리자 화면에서 조정 가능. 모듈 업데이트 시 운영자 수정값 보존
- 토큰 만료 등으로 권한 없는 레이아웃 진입 시 "페이지 로딩 실패" 에러 화면 대신 로그인 페이지로 자동 이동 — 로그인 화면에서 "세션이 만료되었습니다. 다시 로그인해 주세요." 토스트로 사용자에게 안내. 템플릿이 자체 로그인 경로를 사용하는 경우 부트스트랩에서 인증 설정을 커스터마이즈할 수 있는 공개 API 도 함께 제공 (#19 @abc101 님께서 건의해주셨습니다.)
- 템플릿 다국어 데이터 로딩 시 활성 언어팩의 다국어가 가장 높은 우선순위로 병합되도록 변경
- 권한·역할·메뉴·알림 등 코어 기본 데이터와 배송유형·클레임 사유·게시판 유형 등 모듈 기본 데이터에 활성 언어팩의 다국어가 자동 반영되도록 개선
- 사용자 수정 보존(user_overrides) 정책을 다국어 JSON 컬럼은 sub-key 단위(`name.ko` 등) 로 기록하도록 개선 — 운영자가 한 언어 라벨만 수정해도 그 언어만 보존되며, 신규 활성 언어팩(예: 일본어 추가) 의 라벨은 자동 동기화됨. 기존 컬럼 단위(`name`) 기록은 업그레이드 시 활성 locale dot-path 로 자동 변환
- 언어팩 활성/비활성 시점에 영향받는 모듈/플러그인의 entity 시더가 자동 재실행되어 신규 언어 라벨이 즉시 DB 에 반영되도록 라이프사이클 통합 — scope 별 라우팅 (코어 언어팩 → 모든 활성 확장, 모듈 언어팩 → 해당 모듈만, 플러그인 언어팩 → 해당 플러그인만)
- 환경설정 → SEO → "추가 봇 패턴" 필드의 역할 변경 — 기존에는 유일한 봇 매칭 소스(기본 5종)였으나, 이제는 라이브러리가 놓치는 조직별 커스텀 봇만 추가하는 보강 레이어로 동작. 기존 설치의 운영자 커스텀 값은 모두 보존되며, 신규 설치 기본값은 jaybizzle 미커버 3종으로 변경
### Security
- 회원가입·비밀번호 재설정 라우트에 본인인증 정책 강제 미들웨어 부착 — 정책이 활성화된 경우 미인증 요청을 라우트 단계에서 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 플러그인이 정책 해석 필터 훅에서 잘못된 타입을 반환해도 원본 정책이 유지되도록 우회 차단 강화 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 웹 인스톨러의 Composer/PHP 바이너리 경로 검증에서 사용자 입력이 그대로 shell 명령으로 실행될 수 있던 문제 수정 — 입력은 실행 가능한 단일 파일 경로로만 허용하고 모든 분기에서 인자 escape 강제. 설치 워커가 동일한 입력을 사용하던 내부 헬퍼도 같은 정책으로 정렬 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 설치 단계 4 의 확장 기능 선택 API 가 사용자가 보낸 모듈/플러그인/템플릿/언어팩 식별자에 셸 메타문자 검증을 적용하도록 강화 — 부적절한 식별자는 400 응답으로 거부되어 이후 설치 명령에 도달하지 않음 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 인스톨러의 코어 업데이트 _pending 경로 검증이 `..` 등 부모 디렉토리 우회 시도를 거부하고 응답 메시지를 단일화하여 임의 디렉토리 enumeration 신호 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 설치 시 `.env` 작성 헬퍼가 입력값에 포함된 개행 문자를 제거하도록 변경 — DB 비밀번호 등 사용자 입력으로 새로운 환경 변수 라인이 주입되는 시나리오 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 데이터베이스 연결 정보의 host/port/database 값에 DSN 키-밸류 구분자(`;`, `=`) 또는 NUL/CRLF 가 포함되면 연결을 거부하도록 추가 검증 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 설치가 완료된 시스템에서 `public/install/` 하위 모든 엔드포인트가 비즈니스 로직 진입 전 HTTP 410 으로 차단되도록 공통 가드 도입 — 운영 환경에서 인스톨러 노출형 결함의 공격 표면 제거. 운영자가 인스톨러를 다시 사용해야 하는 경우 설치 완료 마커(`storage/app/g7_installed`) 와 `.env` 의 `INSTALLER_COMPLETED` 를 모두 제거 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
### Fixed
- 코어 업데이트 후 일부 환경에서 모듈/플러그인이 저장한 사용자 데이터(상품 이미지·첨부파일 등)에 PHP 가 접근하지 못해 "찾을 수 없음" 오류가 발생하던 문제 수정 — 업데이트 종료 시점 권한 복원 범위를 PHP 쓰기 영역(storage/logs·storage/framework·bootstrap/cache·storage/app/core_pending)으로 한정하고 항목별 정확 복원으로 정합화. 이전 버전에서 본 릴리즈로 업그레이드한 환경에서는 업그레이드 시점에 storage/app 디렉토리/파일 권한을 PHP 가 접근 가능한 형태로 자동 정상화함. 향후 업데이트에서는 사용자 데이터 디렉토리에 자동 생성되는 보존 마커로 권한이 영구 보호됨
- PHP 8.5 환경에서 모든 페이지 응답이 손상되어 Firefox 에서는 "Content Encoding Error", Edge/Chromium 에서는 빈 화면으로 표시되던 문제 수정
- `php artisan serve` 환경에서 인스톨러 진행 중 .env 파일이 변경되면 개발 서버가 워커를 재시작하면서 설치 단계가 중도에 끊기던 문제 수정 — 설치 진행 중에는 .env 를 건드리지 않고 완료 화면 노출 후 한 번에 반영하도록 변경
- 관리자 환경설정 "시스템 사양" 카드의 메모리 항목이 서버 물리 메모리가 아닌 현재 PHP 프로세스가 사용 중인 메모리(수 MB 단위)로 표시되던 문제 수정 — 디스크 사용량과 동일한 형식(사용량/전체/백분율)으로 실제 서버 RAM 을 표시하도록 개선
- 관리자 환경설정 "시스템 사양" 카드의 CPU 항목이 Windows 11 / Windows Server 2025 에서 "operable program or batch file." 로 표시되던 문제 수정 — 해당 OS 에서 제거된 wmic 의존을 걷어내고 PowerShell 기반 조회로 전환, 구형 Windows 환경에서는 기존 방식으로 자동 폴백
- 관리자 환경설정 "정보" 탭을 한 번 조회한 뒤 다른 탭으로 전환할 때마다 수 초 지연이 반복되던 문제 수정 — 시스템 정보 조회 결과를 1시간 캐싱하여 탭 전환 시 대기 시간 제거 (시스템 캐시 초기화 시 함께 무효화)
- 큐 워커가 훅 페이로드의 enum 값(주문 상태 등)을 복원하지 못해 활동 로그·알림 등 일부 후속 처리가 실패하던 문제 수정
- 항목 삭제 후 큐 워커가 처리하는 후속 훅에서 대상 데이터를 복원하지 못해 처리가 중단되던 문제 수정 — 소프트 삭제 페이지·첨부파일, 하드 삭제 주문 옵션 등 포함
- 페이지 키워드 검색 결과의 전체 건수가 발행된 모든 페이지 수로 부풀려지던 문제 수정
- 사용자가 댓글을 단 게시글 활동 조회 시 500 에러가 발생하던 문제 수정
- 사용자 활동 통계에 삭제된 게시글의 댓글 수가 포함되던 문제 수정
- 일부 주문에서 옵션 정보 직렬화 시 500 에러가 발생하던 문제 수정
- 상품 옵션 삭제 검증 시 런타임 에러가 발생하던 문제 수정
- 설치 직후 또는 활성 모듈 디렉토리 부재 시 이커머스 환경설정 기본값이 비어있던 문제 수정
- 검색 가능 드롭다운(SearchableDropdown)에서 빠른 모달 전환 시 race condition 가능성 차단
- 반응형 설정과 반복 렌더링이 결합된 레이아웃에서 무한 재귀가 발생할 수 있던 엔진 결함 수정 (engine-v1.43.1)
- 슬롯 치환 시 텍스트 노드를 컴포넌트로 가정하여 발생할 수 있던 레이아웃 렌더링 오류 차단
- 인스톨러 실행 시 보안 키 생성 단계에서 개발용 패키지 ServiceProvider 를 찾지 못해 설치가 중단되던 문제 수정 — 이전 환경에서 남은 컴파일 캐시를 vendor 교체 직후와 보안 키 생성 직전에 자동 정리하도록 개선
- 폴링 모드 인스톨러가 큰 확장(레이아웃·테스트 수천 파일) 설치 도중 멈추던 문제 수정 — 명령 출력을 파일 기반으로 처리하여 OS 파이프 버퍼 한도와 무관하게 동작
- PHP 8.5 + Apache + mod_fcgid 환경에서 폴링 모드 진행 상황이 실시간 반영되지 않고 설치 완료 시점에 일괄 표시되던 문제 수정 — 어느 모드로도 진행 상황이 즉시 표시되도록 보정. Apache 환경별 권장 설정은 INSTALL.md 와 시스템 요구사항 문서에 명시
- 설치 완료 후 임시 파일이 자동 정리되지 않던 문제 수정
- 알림 템플릿 편집 모달의 입력 필드에서 글자를 입력할 때마다 화면이 심하게 버벅이던 문제 수정
- 슈퍼관리자가 다른 관리자 계정을 삭제할 수 없던 문제 수정 — 관리자 계정 삭제 가능 여부를 역할/권한/스코프 설정에 따라 판단하도록 개선 (슈퍼관리자 본인 보호는 유지)
- `seo:generate-sitemap` 커맨드가 큐 드라이버 설정과 무관하게 항상 "큐에 디스패치" 안내를 출력하던 문제 수정 — 동기 드라이버에서는 즉시 생성으로 동작하고 그에 맞는 안내를 표시하도록 변경
- 관리자 템플릿을 활성화해도 즉시 반영되지 않아 사용자가 직접 새로고침해야 하던 문제 수정 — 관리자 템플릿 활성화 시 자동으로 페이지를 갱신하여 새 템플릿이 즉시 적용되도록 개선
- 보안 환경설정의 "최대 로그인 시도 횟수 / 차단 시간" 설정이 실제로 적용되지 않아 무제한 로그인 시도가 가능하던 문제 수정 — 임계 도달 시 계정 잠금(HTTP 423), 잠금 해제 시각 안내 토스트, per-IP 백업 throttle, 활동 로그 기록까지 통합 구현
- 게시판 글쓰기 화면을 URL 로 직접 진입하거나 강제 새로고침했을 때 업로드한 첨부파일이 게시글에 연결되지 않던 문제 수정 (engine-v1.49.2) (#24 @minyho 님께서 제보해주셨습니다.)
- 코어 업그레이드 후 새 버전에서 추가된 권한·메뉴·알림 정의가 등록되지 않아 관리자 화면에서 "해당 권한이 없습니다" 가 반복 표시되거나 신규 메일 템플릿이 비어있던 문제 수정 — 업그레이드 시 새 버전 설정 파일을 정확히 인식하도록 보정. 본 릴리즈로 업그레이드하면 누락분이 자동 등록됨
- PHP 8.5 환경에서 관리자 로그인 시 `PDO::MYSQL_ATTR_SSL_CA` 등 PDO 드라이버 상수가 deprecation 경고를 발생시키던 문제 수정 — `Pdo\Mysql::ATTR_SSL_CA` 형태의 신규 상수로 전환하고, PHP 8.5 미만 환경에서는 기존 상수를 그대로 사용하도록 분기 처리 (#23 @yks118 님께서 제보해주셨습니다.)
- 회원가입 시 일부 환경에서 동의 항목 메타데이터가 누락되어 활동 로그 처리에서 예외가 발생, 후속 훅 체인이 중단되며 신규 회원에게 `user` 역할이 자동 부여되지 않던 문제 수정 — 동의 메타데이터를 nullable 로 받아 누락 케이스에서도 후속 권한 부여가 정상 동작하도록 개선 (#25 @comtylove-netizen 님께서 제보해주셨습니다.)
## [7.0.0-beta.3] - 2026-04-23
### Fixed
- CKEditor5 플러그인 활성 상태에서 게시판 글쓰기 저장 시 "제목은 필수입니다" 422 오류가 발생하던 호환성 문제 수정 — 제목·내용 입력 순서와 무관하게 정상 저장되도록 개선
- CKEditor5 플러그인 활성 상태에서 게시판 글쓰기 저장 시 "제목은 필수입니다" 422 오류가 발생하던 호환성 문제 수정 — 제목·내용 입력 순서와 무관하게 정상 저장되도록 개선 (#17 @laelbe 님께서 제보해주셨습니다.)
- 코어 업데이트가 sudo 로 실행된 환경에서 캐시·세션·확장 디렉토리의 그룹 쓰기 권한이 일부 손실되어 업데이트 직후 "Permission denied" 또는 플러그인 제거 검증 실패가 발생하던 문제 수정 — 업데이트 종료 시점에 그룹 쓰기 권한을 자동 정상화하며 기존 손실 분은 1회성 복구 스텝으로 회수
- 업데이트 완료 후 Laravel 런타임이 새로 만드는 캐시·세션 하위 디렉토리가 기본 umask(022) 때문에 다시 그룹 쓰기 권한을 잃어 재차 "Permission denied" 가 발생하던 문제 수정 — 업그레이드 스텝이 업데이트 진행 프로세스의 umask 를 그룹 쓰기 친화적으로 전환하고, 이후 부팅 시점에도 `storage/` 의 현재 그룹 쓰기 설정을 감지해 프로세스 umask 를 자동 동조 (운영자가 그룹 공유를 비활성화한 환경은 그대로 보존)
- 코어 업데이트 마지막 단계의 진행 표시줄이 끝난 뒤 줄바꿈 없이 다음 셸 프롬프트가 같은 줄에 붙어 표시되던 출력 문제 수정
@@ -48,17 +576,17 @@
#### GeoIP 및 타임존
- MaxMind GeoLite2 자동 다운로드 스케줄러 + 관리자 환경설정 UI 추가
- IANA 타임존 전체(약 425개) 지원 — 기존 7개 하드코딩 화이트리스트 폐기
- IANA 타임존 전체(약 425개) 지원 — 기존 7개 하드코딩 화이트리스트 폐기 (#8 @abc101 님께서 건의해주셨습니다.)
- Select 컴포넌트에 `searchable` prop 추가 — 타임존 등 대량 옵션에서 검색 가능
#### 인스톨러 개선
- SSE/폴링 듀얼 모드 지원 — Nginx 프록시 + Apache 환경에서 SSE 문제 시 폴링 모드로 전환 가능
- 확장 의존성 자동 해결 — 템플릿 선택 시 필요한 모듈/플러그인을 즉시 자동 선택, 전이적 의존성까지 해결
- 확장 의존성 자동 해결 — 템플릿 선택 시 필요한 모듈/플러그인을 즉시 자동 선택, 전이적 의존성까지 해결 (#10 @glitter-gim 님께서 건의해주셨습니다.)
- 자동 선택된 항목을 시각적으로 구분하고 요구한 확장 이름을 함께 표시
- 다른 확장이 의존하는 항목은 선택 해제 차단 (의존 관계 안내 메시지 포함)
- 의존성 버전 제약 사전 검증 — 버전 불일치 시 설치 진행 전 경고 (semver 비교: `>=`, `^`, `~` 등 지원)
- 기존 DB 테이블 감지 및 안전한 재설치 지원 — 백업 안내 + 명시적 동의 후 진행
- 의존성 버전 제약 사전 검증 — 버전 불일치 시 설치 진행 전 경고 (semver 비교: `>=`, `^`, `~` 등 지원) (#3 @laelbe 님께서 제보해주셨습니다.)
- 기존 DB 테이블 감지 및 안전한 재설치 지원 — 백업 안내 + 명시적 동의 후 진행 (#5 @laelbe 님께서 건의해주셨습니다.)
- 권한 안내 단순화 — `chmod -R 755` 단일 명령어로 통합 (업계 표준 정렬)
- 소유자 불일치(`ownership_mismatch`) 감지 — 전통적 Apache 환경에서 3가지 해결 옵션 제시
- Step 5에 "설치 시작" 버튼 도입 — 모드 선택 후 사용자 클릭으로 설치 시작
@@ -71,7 +599,7 @@
#### 어드민 UI
- 페이지 진입/탭 전환 시 로딩 spinner 표시 — 데이터 fetch 완료까지 유지
- 페이지 진입/탭 전환 시 로딩 spinner 표시 — 데이터 fetch 완료까지 유지 (#6 @laelbe 님께서 제보해주셨습니다.)
- 목록 페이지네이션 시 DataGrid body 영역 한정 spinner (pagination 버튼 가림 방지)
- DataGrid 컴포넌트에 `id` prop 추가
@@ -167,6 +695,7 @@
- 코어·확장 설정에서 제거된 메뉴·권한·역할·알림 정의·알림 템플릿·게시판 유형·클레임 사유가 업데이트 후에도 DB에 잔존하던 문제 수정 — 고아 레코드 자동 정리
- 관리자 UI에서 메뉴·역할·알림·게시판 유형·클레임 사유·배송 유형 등을 수정해도 다음 업데이트 시 기본값으로 덮어써지던 문제 수정 — 사용자가 수정한 필드는 모든 저장 경로에서 자동 추적되어 업데이트 후에도 보존
- 알림 정의·템플릿도 업데이트 기준으로 동기화 — 새 버전에서 제거된 알림은 DB에서도 삭제
- 관리자 모듈/플러그인 목록의 "작성자" 표기가 manifest 의 `vendor` 필드 대신 식별자 prefix 를 사용하던 문제 수정 — `vendor` 가 정의된 확장은 정확한 작성자명으로 표시 (#9 @glitter-gim 님께서 제보해주셨습니다.)
### Removed
+184 -1
View File
@@ -47,6 +47,14 @@ git clone https://github.com/gnuboard/g7.git
</VirtualHost>
```
**Apache + mod_fcgid 환경 추가 설정** (PHP 8.5 NTS Windows 등 mod_php 미제공 빌드):
`fcgid.conf` 에 다음 1줄을 추가 후 Apache 재시작. 미설정 시 mod_fcgid 의 default 64KB 출력 버퍼가 인스톨러 SSE 스트림과 폴링 응답을 스크립트 종료 시점까지 보관하여 설치 진행 상황이 화면에 실시간 반영되지 않는다.
```apache
FcgidOutputBufferSize 0
```
**Nginx 예시**:
```nginx
@@ -238,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.0-beta.3 g7
# (필요 시) mv g7-7.0.4 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
@@ -330,6 +338,157 @@ http://도메인/install
---
## 업그레이드
이미 운영 중인 그누보드7을 새 버전으로 업그레이드할 때의 절차입니다. 신버전 ZIP 또는 git pull 로 파일만 덮어쓰면 **데이터베이스 마이그레이션, 데이터 시드, 확장(모듈/플러그인/템플릿) 재동기화가 함께 수행되지 않아** 게시판/회원/상품 데이터 누락이나 부정합 부팅 실패가 발생합니다. 반드시 아래 절차를 따라주세요.
### 개요
그누보드7 업그레이드의 SSoT(Single Source of Truth)는 Artisan 커맨드입니다.
```bash
sudo php artisan core:update
```
이 커맨드 1회 실행으로 다음 작업이 자동 수행됩니다.
- 신버전 다운로드 및 압축 해제 (GitHub Release 또는 지정 ZIP)
- 자동 백업 생성 (실패 시 자동 롤백)
- 유지보수 모드 자동 진입
- 데이터베이스 마이그레이션 실행
- 업그레이드 스텝 실행 (버전별 데이터 보정)
- 번들 확장 자동 재동기화
- 캐시 정리 및 유지보수 모드 해제
### 업그레이드 사전 준비
업그레이드 전 다음을 확인하세요.
1. **데이터베이스 백업** (필수)
```bash
mysqldump -u DB사용자 -p DB이름 > backup-$(date +%Y%m%d).sql
```
2. **활성 디렉토리 백업 권장** — 직접 수정한 코드나 외부 확장(`_bundled` 에 포함되지 않은 GitHub install 확장) 이 있다면 다음 4개 디렉토리를 별도 위치로 백업하세요.
```bash
tar -czf extensions-backup-$(date +%Y%m%d).tar.gz modules plugins templates lang-packs
```
3. **SSH/CLI 접근 확보** — 코어 업그레이드는 보안상 웹 UI 에서 실행할 수 없으며, 반드시 서버 터미널(SSH) 에서 직접 실행해야 합니다.
### 방법 1: 관리자 UI 에서 업데이트 확인 (권장 동선 시작점)
운영자가 새 버전 출시 여부를 가장 빠르게 알 수 있는 동선입니다.
1. 관리자 페이지 로그인 → **환경설정 > 정보** 탭 이동
2. "**업데이트 확인**" 버튼 클릭 → 최신 버전과 변경사항 모달 표시
3. 신버전이 감지되면 "**변경사항 보기**" 로 changelog 확인 → "**업데이트 방법**" 버튼 클릭
4. 모달이 권장 명령어와 본 가이드 링크를 안내하므로 그 안내에 따라 **서버 터미널에서 직접 실행**
> 모달은 안내 전용입니다. 보안상 UI 에서 코어 업그레이드를 직접 실행하지 않으며, 운영자가 권한 일치를 확인한 후 CLI 에서 실행해야 합니다.
### 방법 2: CLI 자동 업그레이드 (표준 절차)
대다수 환경에서 권장되는 절차입니다.
```bash
sudo php artisan core:update
```
#### `sudo` 가 권장되는 이유
업그레이드는 다음 파일들을 수정·생성하므로, 운영 환경에서 일반 사용자 권한으로 모두 접근 가능한 경우가 드뭅니다.
- `.env` — `APP_VERSION` 갱신 (보안상 `0600` 또는 `0640` 권한, 소유자는 보통 웹 서버 사용자 `www-data` · `nginx` · `apache` 등)
- `bootstrap/cache/` — 설정·라우트·서비스 캐시 갱신
- `storage/framework/`, `storage/logs/` — 캐시·로그 파일 생성
- `modules/`, `plugins/`, `templates/`, `lang-packs/` — 확장 재동기화
`sudo` 로 root 권한을 위임받으면 위 파일들의 소유자가 누구든(웹 서버 사용자 또는 별도 배포 사용자) 권한 충돌 없이 한 번에 처리할 수 있습니다. 업그레이드 도중 `.env` 머지 단계는 기존 소유자·그룹을 그대로 보존하므로, root 로 실행해도 파일 권한이 임의로 변경되지 않습니다.
#### `sudo` 없이 실행해도 되는 환경
- 소유자가 명령줄 사용자와 일치하는 **단일 사용자 호스팅** (Cafe24 등 공유 호스팅, 개인 VPS 의 사용자 계정 운영) — 그냥 `php artisan core:update` 실행
- **Windows (XAMPP/Laragon 등)** — 관리자 권한 PowerShell 에서 `php artisan core:update` 실행
> 자신의 환경이 어느 쪽인지 확인하려면 `ls -l .env` 의 소유자(세 번째 컬럼) 가 명령줄 로그인 사용자와 일치하는지 보세요. 일치하면 `sudo` 없이 실행 가능합니다.
### 방법 3: 수동 ZIP 업그레이드 (PHP zip 확장 미설치 환경)
PHP `zip` 확장 또는 시스템 `unzip` 명령이 없는 서버에서는 GitHub Release ZIP 을 직접 다운로드한 뒤 압축 해제 경로를 지정해 업그레이드합니다.
```bash
# 1. GitHub 릴리스 페이지에서 ZIP 다운로드 후 서버에 압축 해제
wget https://github.com/gnuboard/g7/releases/download/<버전>/<파일명>.zip
unzip <파일명>.zip -d /tmp/g7-new
# 2. 압축 해제된 경로를 지정하여 업데이트 실행
sudo php artisan core:update --source=/tmp/g7-new
```
`--source=` 옵션은 다운로드 단계를 건너뛰고 지정 경로의 파일을 신버전으로 간주합니다.
### 사용자군별 단계적 업그레이드
여러 베타 버전을 건너뛰고 한 번에 최신 버전으로 올리는 것은 권장하지 않습니다. 중간 버전의 업그레이드 스텝이 이전 버전 메모리에 없는 신규 코드에 의존하여 부팅 실패 위험이 있습니다.
| 현재 버전 | 권장 경로 |
|----------|----------|
| **beta.5 이상** | `sudo php artisan core:update` 1회로 최신 버전까지 자동 처리 |
| **beta.3 / beta.4** | beta.5 → 최신 순서로 2회 분할 실행. 각 단계 후 `.env` `APP_VERSION` 이 자동 갱신되므로 같은 명령을 반복 실행 |
| **beta.1 / beta.2** | beta.2 → beta.3 → beta.4 → beta.5 → 최신 순으로 단계별 진행 |
| **beta.4 도중 fatal 후 중단** | `.env` `APP_VERSION` 을 변경하지 말고 `php artisan core:update --force` 재실행. 새 자식 프로세스가 신버전 메모리로 부팅되어 남은 스텝이 멱등 적용됨 |
특정 베타 버전으로의 단계 업그레이드는 `--source=` 또는 `--zip=` 옵션으로 해당 버전 릴리스 ZIP 을 지정하여 진행합니다.
> 사용자군별 상세 분기는 [CHANGELOG.md](CHANGELOG.md) 의 해당 버전 "Upgrade Notice" 섹션을 참고하세요.
### 권한/캐시 트러블슈팅
업그레이드 도중 또는 직후 사이트가 응답하지 않는 경우, 다음을 확인합니다. 본 절의 `chmod`/`chown`/`chgrp` 명령은 POSIX 권한 모델 (Linux/macOS/BSD) 환경 전용이며, Windows 환경에서는 해당하지 않습니다.
#### `.env` 읽기 실패 (`Permission denied`)
```bash
ls -l .env
# -rw------- 1 www-data www-data ... .env ← 세 번째 컬럼이 소유자
```
대부분의 경우 `sudo php artisan core:update` 로 재실행하면 root 권한으로 소유자에 관계없이 접근됩니다. 권한 보안을 강화하려면 본 문서 [`.env` 권한 강화 (선택)](#env-권한-강화-선택) 섹션의 `0600` / `0640` 가이드를 참고하세요. 명령줄 사용자와 웹 서버 사용자가 다른 환경에서는 `0640` + 웹 서버 그룹 부여가 안전한 절충안입니다.
#### `bootstrap/cache` 쓰기 실패
```bash
sudo chown -R www-data:www-data bootstrap/cache storage
```
소유자를 웹 서버 사용자로 일괄 정렬한 뒤 업그레이드를 재실행합니다.
#### 업그레이드 도중 자동 롤백 후 부팅 실패
beta.6 이상에서는 자동 롤백 후 잔존 파일을 추가 진단하는 `hotfix:rollback-stale-files` 커맨드가 제공됩니다.
```bash
# 진단 모드 (실제 삭제 없음)
sudo php artisan hotfix:rollback-stale-files
# 운영자 확인 후 실제 정리
sudo php artisan hotfix:rollback-stale-files --prune
```
### 업그레이드 후 확인
업그레이드가 끝나면 다음을 점검합니다.
1. 관리자 페이지 > **환경설정 > 정보** 에서 G7 버전이 갱신되었는지 확인
2. **확장 > 모듈/플러그인/템플릿** 목록이 모두 정상 표시되는지 확인
3. 사용자 페이지 진입하여 기존 콘텐츠(게시판/상품/페이지) 가 정상 노출되는지 확인
4. `storage/logs/laravel.log` 에 신규 에러가 누적되지 않는지 확인
---
## 프로덕션 환경 추가 설정
프로덕션 환경에서는 아래 항목을 추가로 설정하는 것을 권장합니다.
@@ -338,6 +497,30 @@ http://도메인/install
프로덕션 환경에서는 HTTPS를 사용해야 합니다. `.env` 파일에서 `APP_URL`을 `https://`로 설정하세요.
### `.env` 권한 강화 (선택)
`.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함합니다. 인스톨러는 설치 직후 `.env` 의 권한을 임의로 변경하지 않으므로, 운영자가 환경에 맞춰 직접 강화 권한을 적용할 수 있습니다.
- 가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. 다만 `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다. 소유자가 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다.
- **단일 사용자 환경** (예: 카페24 등 명령줄 사용자와 웹 서버 사용자가 같은 경우):
```bash
sudo chown www-data:www-data .env
sudo chmod 600 .env
```
- **자체 구축 서버** (명령줄 사용자 `jjh` 등과 웹 서버 사용자 `www-data` 가 다른 경우):
```bash
sudo chgrp www-data .env
sudo chmod 640 .env
```
웹 서버는 그룹 멤버로 읽기만 허용되고, 명령줄 사용자는 직접 수정할 수 있는 절충안입니다.
인스톨러 Step 5 완료 화면은 현재 `.env` 권한이 `0600` 이 아닐 경우 위 두 명령 예시를 조건부로 표시합니다.
### 데몬 프로세스
상시 실행이 필요한 프로세스입니다. Supervisor 등을 사용하여 관리합니다.
+107 -21
View File
@@ -8,12 +8,17 @@
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0--beta.3-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>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
<a href="#"><img src="https://img.shields.io/badge/status-Open%20Beta-orange" alt="Status"></a>
<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>
---
@@ -43,16 +48,19 @@ Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터
| 영역 | 설명 |
|------|------|
| **모듈 아키텍처** | 모듈 + 플러그인 + 템플릿 3중 확장 구조. 코어 수정 없이 독립적 모듈(게시판, 커머스 등) 개발이 가능합니다. Hook 기반 기능 주입으로 Service-Repository 패턴의 명확한 계층 분리를 유지합니다 |
| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 로케일 기반 UI, 확장 가능한 언어 팩을 지원합니다 |
| **해외 결제** | 로컬 비즈니스를 넘어 글로벌 커머스로 도약하기 위한 기반을 제공합니다 `정식버전에서 지원예정` |
| **언어팩 시스템** | 새 언어를 코어 수정 없이 ZIP 또는 GitHub URL 로 설치할 수 있습니다. 일본어 등 공식 번들 언어팩을 즉시 사용할 수 있고, 운영자가 직접 수정한 라벨은 언어팩이 덮어쓰지 않도록 sub-key 단위로 보존합니다. 모듈/플러그인/템플릿 단위로 별도 적용 가능 |
| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 활성 언어팩이 알림 채널 라벨, Provider/Registry 페이로드, 환경설정 카탈로그(결제수단·통화·배송 가능 국가)까지 자동 보강되며, 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명하도록 활동 로그·메시지 영역도 분리되어 있습니다 |
| **해외 결제** | 로컬 비즈니스를 넘어 글로벌 커머스로 도약하기 위한 기반을 제공합니다. 결제 연동은 동일한 Extension Point 패턴으로 붙일 수 있으며, 해외 결제 수단은 별도 플러그인으로 제공됩니다 |
| **권한 제어** | 역할별 메뉴와 기능, 데이터 범위까지 제어할 수 있습니다. 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로 조직 구조에 맞는 유연한 접근 관리를 제공합니다 |
| **보안** | 입력값 자동 검증과 토큰 기반 인증을 제공합니다. 설계부터 보안을 고려한 다층 방어 구조(CSRF/XSS/SQL Injection)를 구현합니다 |
| **본인인증 (IDV)** | 회원가입·비밀번호 재설정·민감 작업 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다. 코어가 메일 프로바이더를 기본 내장하고, 외부 KCP·이니시스·SMS·PortOne·Stripe Identity 등은 동일한 Provider 계약으로 붙일 수 있는 확장점을 제공합니다. 서버가 HTTP 428 응답을 반환하면 프론트엔드 인터셉터가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 재실행합니다 |
| **보안** | 입력값 자동 검증과 토큰 기반 인증을 제공합니다. 설계부터 보안을 고려한 다층 방어 구조(CSRF/XSS/SQL Injection), 로그인 시도 제한·계정 잠금(HTTP 423) 실제 구현, 설치 완료 후 인스톨러 엔드포인트 자동 차단(HTTP 410) 까지 다층 방어를 구성합니다 |
| **유연한 화면 구성** | 화면 구조를 정의하면 즉시 반영할 수 있습니다. 프론트엔드 인프라 없이 JSON 선언만으로 웹앱 수준의 동적 화면 구현이 가능합니다 |
| **레이아웃 편집기** | 위지윅 기반 레이아웃 편집 기능으로 화면 블록을 직접 배치하고 수정 결과를 바로 확인할 수 있습니다 `정식버전에서 지원예정` |
| **레이아웃 편집기** | 위지윅 기반 레이아웃 편집 기능으로 화면 블록을 직접 배치하고 수정 결과를 바로 확인할 수 있습니다 |
| **검증된 기반** | Laravel + React 기반을 제공합니다. 글로벌 기업이 채택한 기술 스택으로 높은 확장성과 유연한 UI 구현이 가능합니다 |
| **공통 캐시 시스템** | `CacheInterface` 와 코어/모듈/플러그인 3종 드라이버로 키 접두사(`g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:`) 를 자동 격리합니다. 태그 기반 자동 무효화와 `g7_core_settings('cache.*_ttl')` 중앙 관리로 하드코딩 없이 운영할 수 있습니다 |
| **알림 시스템** | 알림 정의(Definition) × 템플릿(Template) × 수신자(Recipients) 3계층 구조로 메일/DB/실시간 브로드캐스트(Reverb) 다채널 독립 발송을 지원합니다. 작성자·역할·특정 사용자 단위 타겟팅과 훅 기반 발송으로 모듈이 자체 알림을 자유롭게 등록할 수 있습니다 |
| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이합니다 |
| **알림 시스템** | 알림 정의(Definition) × 템플릿(Template) × 수신자(Recipients) 3계층 구조로 메일/DB/실시간 브로드캐스트(Reverb) 다채널 독립 발송을 지원합니다. 작성자·역할·특정 사용자·권한 보유자 단위 타겟팅과 훅 기반 발송으로 모듈이 자체 알림을 자유롭게 등록할 수 있습니다 |
| **SEO** | `jaybizzle/crawler-detect` 기반으로 약 1,000종 봇(검색엔진·SNS unfurl·AI 검색)을 자동 감지하여 봇 요청에는 정적 HTML 을, 일반 사용자에게는 SPA 를 응답합니다. OG/Twitter 카드 메타와 모듈이 선언한 도메인 스키마(Article/Product/Offer/AggregateRating), Sitemap 자동·수동 생성, Generator 메타 태그까지 표준 SEO 표면을 코어에서 제공합니다 |
| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이하며, 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되어 도메인별 자기설명이 가능합니다 |
| **검색** | Laravel Scout 기반 전문 검색을 지원합니다. 상품, 게시글 등 주요 콘텐츠를 대상으로 검색 기능을 제공합니다 |
---
@@ -77,12 +85,16 @@ Gnuboard7
│ ├── Controller → FormRequest → Service → Repository → Model
│ ├── Hook System (Action / Filter)
│ ├── Permission (Role → Permission → Scope)
│ ├── Identity Verification (Policy × Purpose × Provider × Message)
│ ├── Language Pack (가상 보호 행 + ZIP/GitHub 설치 + sub-key 보존)
│ ├── Notification (Definition × Template × Recipients)
│ └── SEO (Bot Detection → Static HTML → Cache → Sitemap)
│
├── Extensions
│ ├── Modules — 게시판, 쇼핑몰, 페이지 ...
│ ├── Plugins — 결제, 인증, 마케팅 ...
│ └── Templates — 관리자 UI, 사용자 UI
│ ├── Templates — 관리자 UI, 사용자 UI
│ └── LanguagePacks — 일본어 등 공식/외부 언어팩
│
└── Template Engine
├── JSON Layout → React Components
@@ -93,15 +105,12 @@ Gnuboard7
그누보드7의 템플릿 엔진은 **JSON으로 UI 구조를 선언**하면, 엔진이 이를 해석하여 React 컴포넌트로 렌더링합니다.
#### 현재 지원
#### 제공 기능
- JSON 선언만으로 React 기반 UI 구성 — React 전문 지식 없이도 화면 개발 가능
- 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장
- 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
#### 지원 예정
- UI가 코드가 아닌 데이터(JSON)로 정의되는 구조를 활용하여, **드래그 앤 드롭 방식의 비주얼 에디터**를 통해 비개발자도 화면을 직접 구성할 수 있도록 지원할 계획입니다
- UI가 코드가 아닌 데이터(JSON)로 정의되는 구조를 활용한 **위지윅 레이아웃 편집기** — 비개발자도 화면 블록을 직접 배치·편집하고 결과를 바로 확인할 수 있습니다
```mermaid
flowchart TB
@@ -294,6 +303,48 @@ HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
- 실시간 브로드캐스트는 Laravel Reverb (WebSocket) 기반. Reverb 미구성 환경에서는 graceful skip 으로 오류 없이 동작
- `GenericNotification` 단일 클래스가 모든 알림을 처리 — 신규 알림 타입 추가 시 개별 Notification 클래스 작성 불필요
#### 5. 언어팩 시스템
새 언어를 코어 수정 없이 추가할 수 있는 운영 도구로, 모듈/플러그인/템플릿 관리와 동일한 라이프사이클(설치 → 활성화 → 업데이트 → 제거 + 자동 백업/롤백) 을 제공합니다.
| 영역 | 동작 |
| --- | --- |
| 설치 경로 | ZIP 업로드 / GitHub URL / `lang-packs/_bundled` 번들 디렉토리 (코어 업데이트 시 일괄 동기화) |
| 적용 범위 | 코어, 모듈, 플러그인, 템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 |
| 사용자 수정 보존 | 다국어 JSON 컬럼은 sub-key 단위 (`name.ko` / `name.ja`) 로 user override 기록 — 한 언어 라벨만 수정해도 그 언어만 보존, 신규 언어는 자동 동기화 |
| 활성화 시점 | 활성/비활성 시 영향받는 모듈/플러그인의 entity 시더가 자동 재실행 → 메뉴·권한·역할·매니페스트·알림 라벨 즉시 DB 반영 |
| 가상 보호 행 | 코어/번들 확장에 내장된 한국어/영어는 별도 설치 없이 항상 활성/보호 상태로 노출 (수정/제거 차단) |
| 보안 | 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단 |
공식 일본어(ja) 번들 12종(코어 + 주요 모듈/플러그인/템플릿) 이 즉시 사용 가능하며, 인스톨러 4단계에서 모듈/플러그인/템플릿 선택과 종속된 언어팩 카드가 자동 연동되어 함께 설치할 수 있습니다.
> 상세: [docs/extension/language-packs.md](docs/extension/language-packs.md)
#### 6. 본인인증 (Identity Verification)
회원가입·비밀번호 재설정·민감 작업·결제 직전 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다.
```text
┌────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
│ Policy │ │ Purpose │ │ Provider │
│ (강제 시점·실패 모드│ │ (인증 목적·허용 채널│ │ (메일·KCP·이니시스 │
│ ·단계·conditions) │ ◀▶ │ ·source 추적) │ ◀▶ │ ·SMS·외부 IDV ...) │
└────────────────────┘ └─────────────────────┘ └──────────────────────┘
│ │
└──────────▶ Message Template (정책×목적 매핑) ◀───┘
│
GenericNotification
```
- **정책 SSoT** — 정책 enable 토글이 라우트 코드 수정 없이 즉시 적용. 모든 API 라우트가 정책 DB 와 자동 매칭
- **428 인터셉터** — 서버가 HTTP 428 응답을 반환하면 프론트엔드가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 자동 재실행
- **선언형 등록** — 모듈/플러그인은 `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 만 선언하면 활성화/업데이트 시 자동 등록되며 운영자 편집값 보존
- **메시지 템플릿** — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의. 정책 → 목적 → 프로바이더 기본값 순서로 fallback
- **외부 Provider 슬롯** — 플러그인이 KCP·PortOne·토스인증·Stripe Identity 등을 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입 가능
- **이력 관리** — 관리자 화면에서 인증 수단 탭, 통합 검색, 상태/목적/채널/IP 멀티 필터, 보관주기(180일) 일괄 파기 제공
> 상세: [docs/backend/identity-policies.md](docs/backend/identity-policies.md), [docs/backend/identity-providers.md](docs/backend/identity-providers.md), [docs/backend/identity-messages.md](docs/backend/identity-messages.md)
---
## 빠른 시작
@@ -336,11 +387,12 @@ cp .env.example .env
| 플러그인 | 설명 |
|---------|------|
| **sirsoft-tosspayments** | 토스페이먼츠 결제 연동 |
| **sirsoft-verification** | 본인인증 |
| **sirsoft-pay_kginicis** | KG이니시스 결제 연동 |
| **sirsoft-verification_kginicis** | KG이니시스 본인인증 |
| **sirsoft-daum_postcode** | 다음 우편번호 검색 |
| **sirsoft-marketing** | 마케팅 도구 |
| **sirsoft-ckeditor5** | CKEditor 5 에디터 |
| **sirsoft-gdpr** | 개인정보 보호(GDPR) |
### 템플릿
@@ -349,6 +401,38 @@ cp .env.example .env
| **sirsoft-admin_basic** | 관리자 기본 템플릿 |
| **sirsoft-basic** | 사용자 기본 템플릿 |
### 번들 언어팩
설치 시 함께 동반 설치할 수 있는 공식 언어팩입니다. 코어 + 주요 모듈/플러그인/템플릿이 일관된 번역으로 즉시 사용 가능합니다.
| 식별자 | 설명 |
| ------ | ---- |
| **g7-core-ja** | 코어 일본어 |
| **g7-module-sirsoft-board-ja** | 게시판 모듈 일본어 |
| **g7-module-sirsoft-ecommerce-ja** | 이커머스 모듈 일본어 |
| **g7-module-sirsoft-page-ja** | 페이지 모듈 일본어 |
| **g7-plugin-sirsoft-ckeditor5-ja** | CKEditor5 플러그인 일본어 |
| **g7-plugin-sirsoft-daum_postcode-ja** | 다음 우편번호 플러그인 일본어 |
| **g7-plugin-sirsoft-gdpr-ja** | 개인정보 보호(GDPR) 플러그인 일본어 |
| **g7-plugin-sirsoft-marketing-ja** | 마케팅 플러그인 일본어 |
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG이니시스 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG이니시스 본인인증 플러그인 일본어 |
| **g7-template-sirsoft-admin_basic-ja** | 관리자 기본 템플릿 일본어 |
| **g7-template-sirsoft-basic-ja** | 사용자 기본 템플릿 일본어 |
> 한국어/영어는 코어/번들 확장에 내장되어 있으며 설치 없이 항상 활성 상태로 동작합니다. 새 언어는 ZIP 또는 GitHub URL 로 자유롭게 추가할 수 있습니다.
### 학습용 샘플 확장
확장 시스템 학습을 위한 최소 구현 샘플입니다. 관리자 UI 에서 "숨김 포함" 토글로 노출되며 CLI 에서는 항상 보입니다.
| 식별자 | 종류 | 설명 |
| ------ | ---- | ---- |
| **gnuboard7-hello_module** | 모듈 | Memo CRUD + 훅 발행 시연 |
| **gnuboard7-hello_plugin** | 플러그인 | Action/Filter 훅 구독 시연 |
| **gnuboard7-hello_admin_template** | Admin 템플릿 | Basic 컴포넌트 최소 셋 |
| **gnuboard7-hello_user_template** | User 템플릿 | 홈 + Memo 리스트 연동 |
---
## 비즈니스 모델
@@ -357,8 +441,8 @@ cp .env.example .env
| 모델 | 설명 | 상태 |
|------|------|------|
| **커뮤니티** | 게시판, 댓글, 회원 관리 | Beta |
| **커머스** | 상품 등록, 주문, 결제, 배송 관리 | Beta |
| **커뮤니티** | 게시판, 댓글, 회원 관리 | 정식 |
| **커머스** | 상품 등록, 주문, 결제, 배송 관리 | 정식 |
---
@@ -383,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) |
---
@@ -406,7 +491,8 @@ cp .env.example .env
<p>
<a href="https://github.com/HeuJung"><img src="https://github.com/HeuJung.png" width="60" alt="HeuJung"></a>&nbsp;&nbsp;
<a href="https://github.com/chym1217"><img src="https://github.com/chym1217.png" width="60" alt="chym1217"></a>
<a href="https://github.com/chym1217"><img src="https://github.com/chym1217.png" width="60" alt="chym1217"></a>&nbsp;&nbsp;
<a href="https://github.com/thisgun"><img src="https://github.com/thisgun.png" width="60" alt="thisgun"></a>
</p>
### Contributors
@@ -433,7 +519,7 @@ cp .env.example .env
## 보안 취약점
보안 취약점을 발견하셨다면 [GitHub Issues](https://github.com/gnuboard/g7/issues)에 보고해 주세요.
보안 취약점을 발견하셨다면 [SIR 문의게시판](https://sir.kr/boards/co_qa)에 비밀글로 제보해 주세요.
---
@@ -3,6 +3,7 @@
namespace App\ActivityLog\Traits;
use App\Enums\ActivityLogType;
use App\Extension\ExtensionManager;
use Illuminate\Support\Facades\Log;
/**
@@ -39,6 +40,9 @@ trait ResolvesActivityLogType
* 활동 로그를 기록합니다.
*
* context에 log_type이 명시되지 않으면 resolveLogType()으로 자동 결정합니다.
* 호출 클래스 FQCN 으로부터 모듈/플러그인 origin 을 추론하여
* properties.extension_origin 에 자동 주입합니다 (loggable 미지정 케이스의
* action 라벨 namespace fallback 용).
*
* @param string $action 액션명 (예: 'user.create')
* @param array $context Monolog context 배열
@@ -47,6 +51,13 @@ trait ResolvesActivityLogType
{
$context['log_type'] ??= $this->resolveLogType();
$origin = ExtensionManager::resolveExtensionByFqcn(static::class);
if ($origin !== null) {
$properties = $context['properties'] ?? [];
$properties['extension_origin'] ??= $origin;
$context['properties'] = $properties;
}
try {
Log::channel('activity')->info($action, $context);
} catch (\Exception $e) {
@@ -0,0 +1,42 @@
<?php
namespace App\Concerns\Seeder;
use App\Extension\HookManager;
/**
* TranslatableSeederInterface 구현체용 헬퍼 트레이트.
*
* 시더의 run() 안에서 $this->resolveTranslatedDefaults() 호출 시
* 활성 언어팩의 ja/en 등 locale 키가 자동 머지된 entry 배열을 반환.
*
* @since 7.0.0-beta.5
*/
trait HasTranslatableSeeder
{
/**
* 활성 언어팩의 다국어 데이터로 머지된 시드 entry 를 반환합니다.
*
* @return array<int, array<string, mixed>>
*/
protected function resolveTranslatedDefaults(): array
{
return HookManager::applyFilters(
$this->resolveTranslationFilterName(),
$this->getDefaults(),
);
}
/**
* `seed.{ext}.{entity}.translations` 필터 키를 조립합니다.
*/
protected function resolveTranslationFilterName(): string
{
$extension = $this->getExtensionIdentifier();
$entity = $this->getTranslatableEntity();
return $extension !== ''
? "seed.{$extension}.{$entity}.translations"
: "seed.{$entity}.translations";
}
}
@@ -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;
}
}
+130 -25
View File
@@ -2,12 +2,15 @@
namespace App\Console\Commands\Core;
use App\Extension\Traits\ClearsTemplateCaches;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
class BuildCoreCommand extends Command
{
use ClearsTemplateCaches;
/**
* The name and signature of the console command.
*/
@@ -82,26 +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);
// ── 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;
}
/**
@@ -125,11 +200,18 @@ 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('✅ 코어 빌드 완료 (전체)');
$this->showFullBuildResults($projectPath);
$this->incrementExtensionCacheVersion();
}
return $result;
@@ -150,22 +232,25 @@ 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)");
}
// lang 파일 확인
$langPath = $corePath.'/lang';
if (is_dir($langPath)) {
$langFiles = glob($langPath.'/*.json');
foreach ($langFiles as $langFile) {
$fileName = 'lang/'.basename($langFile);
$fileSize = number_format(filesize($langFile) / 1024, 2);
$this->line(" - {$fileName} ({$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)");
}
}
@@ -235,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') {
@@ -252,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) {
@@ -2,10 +2,14 @@
namespace App\Console\Commands\Core\Concerns;
use App\Console\Commands\Core\ExecuteBundledUpdatesCommand;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Vendor\VendorMode;
use App\Services\CoreUpdateService;
use App\Services\LanguagePackService;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
@@ -21,13 +25,16 @@ use Illuminate\Support\Facades\Log;
* 6) 결과 요약 출력
*
* --force 옵션이 지정된 경우: 프롬프트 스킵 + 전역 전략 'overwrite' 로 즉시 실행.
*
* 본 트레이트를 사용하는 Command 는 HasUnifiedConfirm 트레이트도 함께 사용해야 한다
* (yes/no 입력 정규화 및 재질문 루프 제공).
*/
trait BundledExtensionUpdatePrompt
{
/**
* 번들 업데이트 목록 수집.
*
* @return array{modules: array, plugins: array, templates: array}
* @return array{modules: array, plugins: array, templates: array, lang_packs: array}
*/
protected function collectBundledUpdates(
ModuleManager $moduleManager,
@@ -37,7 +44,12 @@ trait BundledExtensionUpdatePrompt
// CoreUpdateService::collectBundledExtensionUpdates() 를 통해 _bundled manifest 버전을
// DB 현재 버전과 직접 비교한다. Manager::checkXxxUpdate() 의 "GitHub 엄격 우선" 정책을
// 우회하여 GitHub 미릴리스 상태에서도 _bundled 신버전을 정확히 감지.
return app(\App\Services\CoreUpdateService::class)->collectBundledExtensionUpdates();
$extUpdates = app(CoreUpdateService::class)->collectBundledExtensionUpdates();
// 언어팩도 동일 패턴으로 _bundled vs DB 직접 비교 (LanguagePackService::collectBundledLangPackUpdates).
$extUpdates['lang_packs'] = app(LanguagePackService::class)->collectBundledLangPackUpdates();
return $extUpdates;
}
/**
@@ -52,7 +64,8 @@ trait BundledExtensionUpdatePrompt
bool $force,
): array {
$updates = $this->collectBundledUpdates($moduleManager, $pluginManager, $templateManager);
$total = count($updates['modules']) + count($updates['plugins']) + count($updates['templates']);
$updates['lang_packs'] = $updates['lang_packs'] ?? [];
$total = count($updates['modules']) + count($updates['plugins']) + count($updates['templates']) + count($updates['lang_packs']);
if ($total === 0) {
$this->info('활성 확장이 최신 번들과 일치합니다.');
@@ -71,9 +84,12 @@ trait BundledExtensionUpdatePrompt
foreach ($updates['templates'] as $t) {
$this->line(" [템플릿] {$t['identifier']} {$t['current_version']} → {$t['latest_version']}");
}
foreach ($updates['lang_packs'] as $lp) {
$this->line(" [언어팩] {$lp['identifier']} {$lp['current_version']} → {$lp['latest_version']}");
}
$this->newLine();
if (! $force && ! $this->confirm('일괄 업데이트를 진행하시겠습니까?', true)) {
if (! $force && ! $this->unifiedConfirm('일괄 업데이트를 진행하시겠습니까?', true)) {
$this->info('일괄 업데이트를 건너뜁니다.');
return ['success' => 0, 'failed' => 0, 'skipped' => $total, 'has_updates' => true];
@@ -107,10 +123,12 @@ trait BundledExtensionUpdatePrompt
* 확장별 전략 오버라이드 수집.
*
* @param array $updates collectBundledUpdates() 반환값
* @return array<string, string> key: "{type}:{identifier}", value: strategy
* @return array<string, string> key: "{type}:{identifier}", value: strategy
*/
private function collectPerExtensionStrategies(array $updates, string $globalStrategy, bool $force): array
{
// lang_packs 는 layout 이 없어 overwrite/keep strategy 가 의미 없으므로 의도적으로 제외.
// 매니페스트에는 strategy 없이 그대로 전달되어 ExecuteBundledUpdatesCommand 가 일괄 처리.
$strategies = [];
foreach (['modules', 'plugins', 'templates'] as $type) {
foreach ($updates[$type] as $ext) {
@@ -122,7 +140,7 @@ trait BundledExtensionUpdatePrompt
return $strategies;
}
if (! $this->confirm('전역 전략과 다르게 적용할 확장이 있습니까?', false)) {
if (! $this->unifiedConfirm('전역 전략과 다르게 적용할 확장이 있습니까?', false)) {
return $strategies;
}
@@ -167,6 +185,13 @@ trait BundledExtensionUpdatePrompt
/**
* 실제 일괄 업데이트 실행.
*
* 구조 fix (beta.4 도입): 부모(`core:update`) 프로세스의 stale memory 가 신버전 sync
* 메서드를 호출하지 못하던 결함의 영구 차단. 사용자 선택을 매니페스트로 직렬화한 후
* `core:execute-bundled-updates` 를 별도 PHP 프로세스에서 spawn 하여 실행한다 (자식은
* 디스크의 fresh 코어 코드 로드).
*
* proc_open 미지원 / 실패 환경에서는 in-process fallback 으로 안전하게 전환 (기존 흐름).
*
* @return array{success: int, failed: int, skipped: int, has_updates: bool}
*/
private function executeBulkUpdate(
@@ -176,12 +201,194 @@ trait BundledExtensionUpdatePrompt
array $updates,
array $strategies,
): array {
$success = 0;
$failed = 0;
$manifest = $this->buildBundledUpdateManifest($updates, $strategies);
if ($this->bundledManifestIsEmpty($manifest)) {
return ['success' => 0, 'failed' => 0, 'skipped' => 0, 'has_updates' => false];
}
$this->newLine();
$this->info('── 일괄 업데이트 실행 ──');
$spawnResult = $this->spawnBundledUpdates($manifest);
if ($spawnResult !== null) {
return [
'success' => $spawnResult['success'],
'failed' => $spawnResult['failed'],
'skipped' => 0,
'has_updates' => true,
];
}
// proc_open 미지원 / spawn 실패 — in-process fallback
$this->warn('별도 프로세스 spawn 실패 — in-process fallback 으로 전환합니다.');
return $this->executeBulkUpdateInProcess(
$moduleManager,
$pluginManager,
$templateManager,
$updates,
$strategies,
);
}
/**
* 사용자 선택을 spawn 자식에 전달할 매니페스트 형식으로 직렬화.
*
* @return array{modules: array, plugins: array, templates: array, lang_packs: array}
*/
private function buildBundledUpdateManifest(array $updates, array $strategies): array
{
$manifest = [
'modules' => [],
'plugins' => [],
'templates' => [],
'lang_packs' => [],
];
foreach ($updates['modules'] ?? [] as $m) {
$id = $m['identifier'];
$manifest['modules'][] = [
'identifier' => $id,
'strategy' => $strategies["modules:{$id}"] ?? 'overwrite',
];
}
foreach ($updates['plugins'] ?? [] as $p) {
$id = $p['identifier'];
$manifest['plugins'][] = [
'identifier' => $id,
'strategy' => $strategies["plugins:{$id}"] ?? 'overwrite',
];
}
foreach ($updates['templates'] ?? [] as $t) {
$id = $t['identifier'];
$manifest['templates'][] = [
'identifier' => $id,
'strategy' => $strategies["templates:{$id}"] ?? 'overwrite',
];
}
foreach ($updates['lang_packs'] ?? [] as $lp) {
$manifest['lang_packs'][] = ['identifier' => $lp['identifier']];
}
return $manifest;
}
private function bundledManifestIsEmpty(array $manifest): bool
{
return empty($manifest['modules'])
&& empty($manifest['plugins'])
&& empty($manifest['templates'])
&& empty($manifest['lang_packs']);
}
/**
* `core:execute-bundled-updates` 를 별도 PHP 프로세스에서 실행.
*
* 자식 stdout 을 부모 콘솔로 실시간 전달 (단 `[BUNDLED-RESULT]` prefix 라인은
* 결과 페이로드로 보관 후 부모 콘솔로 노출하지 않음). 종료 후 페이로드 파싱.
*
* @return array{success: int, failed: int}|null spawn 성공 시 결과, 실패/미지원 시 null
*/
private function spawnBundledUpdates(array $manifest): ?array
{
if (! function_exists('proc_open')) {
return null;
}
$manifestPath = storage_path('app/core_pending'.DIRECTORY_SEPARATOR.'bundled-updates-manifest_'.uniqid().'.json');
File::ensureDirectoryExists(dirname($manifestPath));
File::put($manifestPath, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
try {
$phpBinary = config('process.php_binary', PHP_BINARY);
$artisan = base_path('artisan');
$command = [
$phpBinary,
$artisan,
'core:execute-bundled-updates',
'--manifest='.$manifestPath,
];
$commandLine = implode(' ', array_map('escapeshellarg', $command)).' 2>&1';
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
// ENV 합집합 (G7_UPDATE_IN_PROGRESS 등 핵심 플래그 자식에 전달)
$env = array_merge(getenv(), $_ENV);
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
if (! is_resource($process)) {
return null;
}
fclose($pipes[0]);
$resultPayload = null;
$resultPrefix = ExecuteBundledUpdatesCommand::RESULT_PREFIX;
while (! feof($pipes[1])) {
$line = fgets($pipes[1]);
if ($line === false) {
continue;
}
$trimmed = rtrim($line);
if (str_starts_with($trimmed, $resultPrefix)) {
$json = substr($trimmed, strlen($resultPrefix));
$decoded = json_decode($json, true);
if (is_array($decoded)) {
$resultPayload = $decoded;
}
continue; // 부모 콘솔에 노출 안 함
}
$this->line($trimmed);
}
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
if ($resultPayload === null) {
Log::warning('번들 spawn 자식이 결과 페이로드를 출력하지 않음 — 카운트 0 으로 처리');
return ['success' => 0, 'failed' => 0];
}
return [
'success' => (int) ($resultPayload['success'] ?? 0),
'failed' => (int) ($resultPayload['failed'] ?? 0),
];
} finally {
if (File::exists($manifestPath)) {
File::delete($manifestPath);
}
}
}
/**
* in-process fallback — proc_open 미지원 환경 전용.
*
* 기존 (beta.3) 흐름의 직접 호출 패턴 보존. 단 부모 메모리의 stale 코드로 인해
* sync 메서드 누락 결함이 재현될 수 있으므로 spawn 가능 환경에서는 사용되지 않는다.
*
* @return array{success: int, failed: int, skipped: int, has_updates: bool}
*/
private function executeBulkUpdateInProcess(
ModuleManager $moduleManager,
PluginManager $pluginManager,
TemplateManager $templateManager,
array $updates,
array $strategies,
): array {
$success = 0;
$failed = 0;
foreach ($updates['modules'] as $m) {
$id = $m['identifier'];
$strategy = $strategies["modules:{$id}"] ?? 'overwrite';
@@ -224,6 +431,24 @@ trait BundledExtensionUpdatePrompt
}
}
$langPackService = app(LanguagePackService::class);
foreach ($updates['lang_packs'] ?? [] as $lp) {
$id = $lp['identifier'];
$this->line("→ [언어팩] {$id}");
try {
$pack = $langPackService->findByIdentifier($id);
if (! $pack) {
throw new \RuntimeException(__('language_packs.errors.identifier_not_found', ['identifier' => $id]));
}
$langPackService->performUpdate($pack, true);
$success++;
} catch (\Throwable $e) {
$failed++;
$this->warn(" 실패: {$e->getMessage()}");
Log::error('번들 일괄 업데이트 실패', ['type' => 'lang_pack', 'id' => $id, 'error' => $e->getMessage()]);
}
}
$this->newLine();
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
@@ -234,4 +459,5 @@ trait BundledExtensionUpdatePrompt
'has_updates' => true,
];
}
}
+626 -31
View File
@@ -3,25 +3,32 @@
namespace App\Console\Commands\Core;
use App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Exceptions\UpgradeHandoffException;
use App\Extension\CoreVersionChecker;
use App\Extension\Helpers\CoreBackupHelper;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
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;
class CoreUpdateCommand extends Command
{
use BundledExtensionUpdatePrompt;
use ClearsTemplateCaches;
use HasUnifiedConfirm;
protected $signature = 'core:update
{--force : 버전 비교 없이 강제 업데이트}
{--no-backup : 백업 생성 건너뛰기}
{--prune : 코어가 제거한 파일 정리 + targets 전체 덮어쓰기(기존 방식). 미지정 시 코어가 실제 변경/추가한 파일만 적용하고 나머지는 보존}
{--no-maintenance : 유지보수 모드 활성화 건너뛰기}
{--local : 로컬 코드베이스를 업데이트 소스로 사용 (GitHub 스킵)}
{--source= : 수동 업데이트용 소스 디렉토리 경로 (GitHub 다운로드 대신 지정 디렉토리 사용)}
@@ -180,7 +187,7 @@ class CoreUpdateCommand extends Command
}
$this->newLine();
if (! $this->confirm('코어를 업데이트하시겠습니까?')) {
if (! $this->unifiedConfirm('코어를 업데이트하시겠습니까?', false)) {
return Command::SUCCESS;
}
@@ -285,6 +292,22 @@ class CoreUpdateCommand extends Command
$log('원본 소유권 스냅샷 수집: '.implode(',', array_keys($ownershipSnapshot)));
}
// PHP-FPM 쓰기 영역의 항목별 정확 스냅샷 (owner/group/perms). Stage 4.
// sudo update 가 root 로 만들 수 있는 좁은 영역(storage/logs, storage/framework,
// storage/app/core_pending, bootstrap/cache) 의 모든 하위 항목을 재귀 stat 하여
// Step 11/12 의 restoreOwnership 이 항목별 정확 복원하도록 전달한다.
// 사용자 데이터 영역(storage/app/{modules,plugins,attachments,public,settings})
// 은 본 스냅샷 대상이 아니며 chown 자체가 빠지므로 시드/업로드 owner 가 보존된다.
$detailedOwnershipSnapshot = $service->snapshotOwnershipDetailed([
'storage/logs',
'storage/framework',
'storage/app/core_pending',
'bootstrap/cache',
]);
if (! empty($detailedOwnershipSnapshot)) {
$log('항목별 정확 스냅샷 수집: '.count($detailedOwnershipSnapshot).'개 항목 (PHP-FPM 쓰기 영역)');
}
// ── Step 6: _pending에서 Vendor 설치 (composer 또는 bundled) ──
$vendorMode = VendorMode::fromStringOrAuto((string) $this->option('vendor-mode'));
$composerSkipped = $vendorMode !== VendorMode::Bundled
@@ -313,13 +336,93 @@ class CoreUpdateCommand extends Command
}
}
// ── Step 6.5: 신 버전 신규 파일 manifest 생성 ──
//
// applyUpdate 직전 시점에 백업 디렉토리(= 활성 디렉토리의 사전 스냅샷) 와
// _pending(= 신 버전 소스) 을 비교해 신 버전이 추가하는 파일/디렉토리 목록을
// `_new_files_manifest.json` 으로 백업 디렉토리에 기록한다. 자동 롤백 시
// `restoreFromBackup()` 이 본 manifest 를 참조해 활성 디렉토리에서 정확히 그
// 항목만 prune. 사용자가 활성 디렉토리에 직접 추가한 파일은 백업에 포함되어
// 있으므로 manifest 에서 제외되어 보존된다.
//
// `--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 생성 시작');
try {
$manifestStats = CoreBackupHelper::writeNewFilesManifest(
$backupPath,
$pendingPath,
(array) config('app.update.targets', []),
(array) config('app.update.protected_paths', []),
(array) config('app.update.excludes', []),
$fromVersion,
$toVersion,
);
$log(sprintf(
'신규 파일 manifest 작성 완료 (files=%d, dirs=%d)',
$manifestStats['new_files_count'],
$manifestStats['new_dirs_count'],
));
} catch (\Throwable $manifestError) {
// manifest 작성 실패는 fatal 이 아님 — 롤백 시 기존 overlay 만 수행 (기존 동작)
$log("신규 파일 manifest 작성 실패 (계속 진행): {$manifestError->getMessage()}");
Log::warning('코어 업데이트: manifest 작성 실패', [
'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) {
@@ -337,13 +440,20 @@ class CoreUpdateCommand extends Command
}
// ── Step 9: Migration + 역할/메뉴 동기화 ──
//
// 동기화는 반드시 reloadCoreConfigAndResync() 로 호출한다. 본 메서드는 부모
// 프로세스가 부팅 시점에 캐시한 stale config 를 우회해 디스크의 fresh
// config/core.php 를 require → Config Repository 에 재주입한 뒤 syncCore* 를
// 호출한다. 디스크는 Step 7(applyUpdate) 에서 이미 신버전으로 교체되어 있다.
//
// syncCoreRolesAndPermissions / syncCoreMenus 직접 호출 금지 — 부모 메모리의
// 구버전 config 로 sync 가 돌면 신규 권한/메뉴가 누락된다 (#326 회귀).
$bar->setMessage(__('settings.core_update.step_migration'));
$bar->advance();
$log('마이그레이션, 역할/메뉴 동기화 실행');
$service->runMigrations();
$service->syncCoreRolesAndPermissions();
$service->syncCoreMenus();
$service->reloadCoreConfigAndResync();
$log('마이그레이션, 역할/메뉴 동기화 완료');
// ── Step 10: Upgrade Steps ──
@@ -394,9 +504,17 @@ class CoreUpdateCommand extends Command
$service->updateVersionInEnv($toVersion);
$service->clearAllCaches();
// 코어 업데이트 후 프론트엔드가 새 lang/routes/layout 자원으로 fetch 하도록
// `ext.cache_version` bump. 코어 lang JSON 변경이 프론트엔드 캐시에
// 반영되지 않는 회귀를 차단한다 (core-frontend-i18n-infrastructure 계획서).
$this->incrementExtensionCacheVersion();
// sudo 실행 시 composer 등 외부 프로세스가 root 로 생성한 파일의 소유권을
// 백업 직후 수집한 원본 스냅샷 기준으로 복원 (각 경로 고유 소유자 유지)
$service->restoreOwnership($ownershipSnapshot, $onProgress);
// 백업 직후 수집한 원본 스냅샷 기준으로 복원 (각 경로 고유 소유자 유지).
// detailedSnapshot 동시 전달 — PHP-FPM 쓰기 영역의 owner/group/perms 를 항목별
// 정확 복원하여 #282 (sudo update 후 traversal 비트 손실) 회귀 차단.
$service->restoreOwnership($ownershipSnapshot, $onProgress, $detailedOwnershipSnapshot);
$this->surfacePermissionWarnings($service, $log);
$log('업데이트 경로 소유권 복원 완료');
$service->cleanupPending($pendingPath);
@@ -410,6 +528,13 @@ class CoreUpdateCommand extends Command
$maintenanceEnabled = false;
}
// 모든 파일(코어 config/lang/vendor)이 안착한 뒤 config 캐시를 재생성한다.
// clearAllCaches() 는 흐름 중간에서 stale 캐시를 비우기만 하므로, 여기서
// 재생성하지 않으면 업데이트 후 config:cache 가 비활성 상태로 남아 이후 모든
// 요청이 config 파일을 재파싱한다(성능 손실). ConfigCacheHelper 는 설치 미완료
// 상태를 가드하고 실패를 안전하게 흡수한다.
ConfigCacheHelper::rebuild();
$log('정리 완료');
$bar->finish();
@@ -421,6 +546,9 @@ class CoreUpdateCommand extends Command
$this->info("그누보드7 코어가 {$toVersion} 버전으로 업데이트되었습니다!");
$this->newLine();
// ── 파일 적용 방식 요약 (부수의무: 설명의무) ──
$this->summarizeApplyMode($prune, $applyList, $applyStats);
// _bundled 확장 일괄 업데이트 프롬프트 (번들에 신버전이 있을 때만 표시).
//
// 주의: Step 11 restoreOwnership 이후에 실행되므로, 프롬프트 내 실제
@@ -438,13 +566,20 @@ class CoreUpdateCommand extends Command
if (($promptResult['success'] ?? 0) > 0) {
$this->newLine();
$this->info('일괄 업데이트로 생성된 파일의 소유권을 복원하는 중...');
$service->restoreOwnership($ownershipSnapshot, $onProgress);
// 일괄 확장 update 가 sudo 컨텍스트에서 root 로 만들 수 있는 PHP-FPM 쓰기
// 영역의 owner/group/perms 를 항목별 정확 복원 (Stage 4).
$service->restoreOwnership($ownershipSnapshot, $onProgress, $detailedOwnershipSnapshot);
// restoreOwnership 의 진행 표시($onProgress → $bar->display())가
// 개행 없이 끝나므로 다음 셸 프롬프트가 같은 줄에 붙는 것을 방지.
$this->newLine(2);
$this->surfacePermissionWarnings($service, $log);
$log('일괄 확장 업데이트 후 소유권 재복원 완료');
}
// fallback(spawn 실패)로 부모가 upgrade step 을 직접 실행한 경우, 부모가 만든
// upgrade 로그가 root 로 남는다 — 모든 로그 쓰기가 끝난 이 시점에 정합한다.
$this->restoreUpgradeLogOwnership();
return Command::SUCCESS;
} catch (UpgradeHandoffException $e) {
@@ -479,7 +614,13 @@ class CoreUpdateCommand extends Command
try {
$service->updateVersionInEnv($toVersion);
$service->clearAllCaches();
$service->restoreOwnership($ownershipSnapshot, $onProgress);
// 핸드오프 cleanup 에서도 프론트엔드 캐시 버전 bump — 사용자가 resume 명령
// (execute-upgrade-steps) 을 실행하기 전이라도 이미 toVersion 으로 반영된
// 코어 lang/routes/layout 자원이 프론트엔드 캐시 stale 로 가려지지 않도록.
$this->incrementExtensionCacheVersion();
// Stage 4 — handoff cleanup 도 detailed snapshot 으로 정확 복원
$service->restoreOwnership($ownershipSnapshot, $onProgress, $detailedOwnershipSnapshot);
$this->surfacePermissionWarnings($service, $log);
$log('핸드오프 cleanup 완료 (버전 toVersion 고정 + 캐시 clear + 소유권 복원)');
if (! empty($pendingPath)) {
@@ -506,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();
@@ -516,6 +657,8 @@ class CoreUpdateCommand extends Command
$this->newLine();
}
$this->restoreUpgradeLogOwnership();
return Command::SUCCESS;
} catch (\Throwable $e) {
@@ -548,6 +691,17 @@ class CoreUpdateCommand extends Command
$log("백업 복원 실패: {$restoreError->getMessage()}");
$this->error("백업 복원 실패: {$restoreError->getMessage()}");
}
// 롤백 직후 캐시 자동 정리 — 신 코드가 `bootstrap/cache/*.php` 에 cache 된 채로
// 남아 부팅 실패하는 회귀 차단. 운영자의 수동 `php artisan optimize:clear` 단계 제거.
// 캐시 정리 실패는 fatal 아님 — warning 후 진행.
try {
$service->clearAllCaches();
$log('롤백 후 캐시 정리 완료');
} catch (\Throwable $cacheError) {
$log("롤백 후 캐시 정리 실패 (계속 진행): {$cacheError->getMessage()}");
$this->warn("캐시 정리 실패 — 부팅 후 'php artisan optimize:clear' 수동 실행 필요: {$cacheError->getMessage()}");
}
}
// _pending 정리 (실패 시에도)
@@ -598,6 +752,8 @@ class CoreUpdateCommand extends Command
}
}
$this->restoreUpgradeLogOwnership();
return Command::FAILURE;
}
}
@@ -621,16 +777,19 @@ class CoreUpdateCommand extends Command
* @param string $toVersion 대상 버전
* @param bool $force 동일 버전 강제 실행 여부
* @param \Closure $log 로그 엔트리 수집 콜백
* @return bool spawn 성공 여부 (false 면 fallback 실행 필요)
* @return bool spawn 성공 여부 (false 면 fallback 실행 필요)
*
* @throws UpgradeHandoffException 자식이 핸드오프 신호를 보낸 경우
* @throws UpgradeHandoffException 자식이 핸드오프 신호를 보낸 경우
*/
private function spawnUpgradeStepsProcess(string $fromVersion, string $toVersion, bool $force, \Closure $log): bool
{
if (! function_exists('proc_open')) {
$log('proc_open 비활성 — spawn 스킵, in-process fallback 진행');
return false;
return $this->failSpawnWithMode(
'proc_open 비활성',
$log,
$fromVersion,
$toVersion,
);
}
$phpBinary = config('process.php_binary', PHP_BINARY);
@@ -646,6 +805,17 @@ class CoreUpdateCommand extends Command
if ($force) {
$command[] = '--force';
}
// 부모는 이미 Step 9 (runMigrations + reloadCoreConfigAndResync, 라인 408-409),
// Step 11 (updateVersionInEnv + clearAllCaches, 라인 457-458), 번들 확장 일괄 업데이트
// prompt (라인 497-515) 를 자식 종료 후 실행하므로 자식 내부 중복 회피. 단독 실행 시
// (운영자 직접 호출) 옵션이 전달되지 않아 자식 기본값(5단계 모두 포함) 발동 →
// gnuboard/g7#34 의 운영자 수동 절차(migrate / resync / .env sed / cache:clear / module:update --source=bundled)
// 가 단일 명령으로 통합되어 단독 안전성 보장.
$command[] = '--skip-migrations';
$command[] = '--skip-resync';
$command[] = '--skip-version-env';
$command[] = '--skip-cache-clear';
$command[] = '--skip-bundled-updates';
$commandLine = implode(' ', array_map('escapeshellarg', $command)).' 2>&1';
@@ -675,17 +845,22 @@ class CoreUpdateCommand extends Command
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
if (! is_resource($process)) {
$log('spawn 실패 — proc_open 자원 생성 실패');
return false;
return $this->failSpawnWithMode(
'proc_open 자원 생성 실패',
$log,
$fromVersion,
$toVersion,
);
}
fclose($pipes[0]);
// stdout 실시간 전달 — 상위 콘솔에서 진행 상황 확인 가능
// 단, [HANDOFF] 접두사 라인은 상위 콘솔로 노출하지 않고 페이로드만 보관한다
// (exit=UpgradeHandoffException::EXIT_CODE 감지 시 UpgradeHandoffException 재구성용).
// 단, [HANDOFF] / [STEPS_EXECUTED] 접두사 라인은 상위 콘솔로 노출하지 않고
// 페이로드만 보관한다 (각각 UpgradeHandoffException 재구성 / silent skip 가드용).
$handoffPayload = null;
$stepsExecuted = null;
$stepsDiscovered = null;
while (! feof($pipes[1])) {
$line = fgets($pipes[1]);
if ($line !== false) {
@@ -697,19 +872,36 @@ class CoreUpdateCommand extends Command
if (str_starts_with($trimmed, '[HANDOFF] ')) {
$json = substr($trimmed, strlen('[HANDOFF] '));
$decoded = json_decode($json, true);
// resumeCommand 는 null 허용 (자식이 null 로 전달한 경우 부모가
// CoreUpdateCommand catch 분기에서 from/to 기반으로 자동 생성)
if (is_array($decoded)
&& array_key_exists('afterVersion', $decoded)
&& array_key_exists('reason', $decoded)
&& array_key_exists('resumeCommand', $decoded)
) {
// 페이로드 값 검증 — array_key_exists 만으로는 비정상 입력(빈 문자열·
// shell metacharacter·과도 길이) 이 통과한다. resumeCommand 는 null
// 허용 (자식이 null 로 전달한 경우 부모가 from/to 기반 자동 생성).
if ($this->isValidHandoffPayload($decoded)) {
$handoffPayload = $decoded;
$log('[spawn] 핸드오프 신호 수신: after='.$decoded['afterVersion']);
continue;
}
// 구조가 깨진 핸드오프 라인 — 정상 출력으로 간주해 그대로 전달
// 구조/값이 깨진 핸드오프 라인 — 정상 출력으로 간주해 그대로 전달
if (is_array($decoded)) {
$log('[spawn] 핸드오프 페이로드 검증 실패 — 정상 출력으로 처리: '.json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
}
if (str_starts_with($trimmed, '[STEPS_EXECUTED] ')) {
$json = substr($trimmed, strlen('[STEPS_EXECUTED] '));
$decoded = json_decode($json, true);
if (is_array($decoded) && isset($decoded['count']) && is_int($decoded['count']) && $decoded['count'] >= 0) {
$stepsExecuted = $decoded['count'];
// 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;
}
}
$this->line($trimmed);
@@ -721,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})");
@@ -735,16 +971,184 @@ class CoreUpdateCommand extends Command
}
if ($exitCode === 0) {
$log('spawn 완료 (exit=0)');
// 신호 미수신 — 이전 버전 자식 (신호 미발행) 또는 silent skip 의심. fail-fast.
if ($stepsExecuted === null) {
return $this->failSpawnWithMode(
'spawn 자식이 [STEPS_EXECUTED] 신호 미발행 — 이전 버전 자식 또는 silent skip 의심',
$log,
$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%s)',
$fromVersion,
$toVersion,
$stepsDiscovered !== null ? ", 발견 {$stepsDiscovered}건" : '',
),
$log,
$fromVersion,
$toVersion,
);
}
$log("spawn 완료 (exit=0, steps={$stepsExecuted})");
return true;
}
$log("spawn 비정상 종료 (exit={$exitCode}) — fallback 진행");
return $this->failSpawnWithMode(
"spawn 비정상 종료 (exit={$exitCode})",
$log,
$fromVersion,
$toVersion,
);
}
/**
* spawn 자식 프로세스 실패 시 `app.update.spawn_failure_mode` 에 따라 분기합니다.
*
* - 'abort' (기본): `UpgradeHandoffException` 을 throw 하여 부모의 in-process
* fallback 진입을 차단. 부모 catch 블록이 cleanup 후 운영자에게
* `core:execute-upgrade-steps` 수동 명령을 안내한다.
* - 'fallback' (호환): 기존 동작 — log 한 줄 남기고 false 반환. 호출자가
* in-process fallback 으로 진행. 부모 메모리 stale 시 fatal 위험.
*
* @param string $reason 실패 사유 (로그/안내 메시지에 포함)
* @param \Closure $log 로그 엔트리 수집 콜백
* @param string $fromVersion 업그레이드 시작 버전 (handoff afterVersion / resumeCommand 구성)
* @param string $toVersion 업그레이드 대상 버전 (resumeCommand 구성)
* @return false fallback 모드일 때만 반환. abort 모드는 throw 후 미반환.
*
* @throws UpgradeHandoffException mode=abort 일 때
*/
private function failSpawnWithMode(string $reason, \Closure $log, string $fromVersion, string $toVersion): bool
{
$mode = config('app.update.spawn_failure_mode', 'fallback');
if ($mode === 'abort') {
$resumeCommand = sprintf(
'php artisan core:execute-upgrade-steps --from=%s --to=%s --force',
$fromVersion,
$toVersion,
);
$log("spawn 자식 실패 — {$reason}. fail-fast 모드 abort.");
throw new UpgradeHandoffException(
afterVersion: $fromVersion,
reason: "spawn 자식 실패 — {$reason}. fail-fast 모드. 수동 재개로 진행하세요.",
resumeCommand: $resumeCommand,
);
}
$log("{$reason} — in-process fallback 진행 (stale 메모리 위험)");
return false;
}
/**
* [HANDOFF] 페이로드의 값을 검증합니다.
*
* spawn 자식의 stdout 을 부모가 그대로 신뢰하면 (a) 빈 afterVersion 으로 Step 11
* fromVersion 해석 혼란, (b) shell injection 문자열이 운영자 안내에 노출, (c) 과도한
* reason 길이로 로그 폭증 위험. 본 메서드는 다음 4가지 키/값을 동시에 검증한다:
*
* - `afterVersion`: 정규식 `/^\d+\.\d+\.\d+/` 매치 + 비어있지 않음
* - `reason`: string + 길이 < 500
* - `resumeCommand`: null 허용. string 일 때 길이 < 1000 + shell metacharacter(`;&|`$()<>`) 부재
*
* @param mixed $payload json_decode 결과
*/
private function isValidHandoffPayload($payload): bool
{
if (! is_array($payload)) {
return false;
}
foreach (['afterVersion', 'reason', 'resumeCommand'] as $key) {
if (! array_key_exists($key, $payload)) {
return false;
}
}
$afterVersion = $payload['afterVersion'];
if (! is_string($afterVersion) || $afterVersion === '' || ! preg_match('/^\d+\.\d+\.\d+/', $afterVersion)) {
return false;
}
$reason = $payload['reason'];
if (! is_string($reason) || strlen($reason) >= 500) {
return false;
}
$resumeCommand = $payload['resumeCommand'];
if ($resumeCommand !== null) {
if (! is_string($resumeCommand)) {
return false;
}
if (strlen($resumeCommand) >= 1000) {
return false;
}
if (preg_match('/[;&|`$()<>]/', $resumeCommand)) {
return false;
}
}
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();
}
/**
* 업데이트 로그를 파일로 저장합니다.
*
@@ -772,10 +1176,44 @@ class CoreUpdateCommand extends Command
$content = $header."\n".implode("\n", $entries)."\n";
file_put_contents($logPath, $content);
// sudo 업데이트가 로그 파일을 root 소유로 만들면 이후 www-data 의 tinker/로그 쓰기가
// 거부된다. 부모(storage/logs) 소유권을 상속한다(멱등, sudo 없으면 silent no-op).
FilePermissionHelper::inheritOwnershipFromParent($logPath);
Log::info("코어 업데이트 로그 저장: {$logPath}");
}
/**
* 코어 업데이트(부모 프로세스)가 생성한 upgrade 로그 파일의 소유권을 부모(storage/logs)
* 로 정합합니다 — **spawn 실패로 부모가 upgrade step 을 in-process 로 직접 실행한 경우** 대비.
*
* upgrade 로그(`upgrade-YYYY-MM-DD.log`)를 만드는 주체는 두 갈래다:
* - spawn 성공: 자식(ExecuteUpgradeStepsCommand)이 로그 생성 → 자식이 자기 종료 직전 정합
* - spawn 실패(fallback): 부모가 runUpgradeSteps + reloadCoreConfigAndResync 를 직접 실행
* 하여 부모 프로세스가 upgrade 로그를 root 로 생성 → **부모가** 정합해야 한다.
*
* sudo 업데이트는 root 로 실행되어 로그를 root 소유로 만들고, 이후 www-data(php-fpm) 의
* module:update upgrade step 이 같은 날짜 로그에 append 하지 못해 Permission denied 로
* 실패한다. 본 메서드를 코어 업데이트의 모든 로그 쓰기가 끝난 종료 시점(각 return 직전)에
* 호출한다. **본 메서드 자체는 로그를 쓰지 않는다**(쓰면 다시 root 가 됨). silent no-op 멱등.
*/
private function restoreUpgradeLogOwnership(): void
{
$logsDir = storage_path('logs');
if (! is_dir($logsDir)) {
return;
}
// 코어('upgrade-*.log') + 확장('extension-upgrade-*.log') 두 채널의 daily 로그를 모두
// 정합한다. glob 'upgrade-*.log' 는 'extension-' 접두사 파일을 매칭하지 못하므로
// 두 패턴을 각각 순회한다.
foreach (['upgrade-*.log', 'extension-upgrade-*.log'] as $pattern) {
foreach (glob($logsDir.DIRECTORY_SEPARATOR.$pattern) ?: [] as $logFile) {
FilePermissionHelper::inheritOwnershipFromParent($logFile);
}
}
}
/**
* 현재 실행 사용자와 코어 파일 소유자가 다른 경우 경고를 표시합니다.
*
@@ -822,4 +1260,161 @@ class CoreUpdateCommand extends Command
'owner_user' => $ownerUser,
]);
}
/**
* 업그레이드 스텝 재실행 명령을 실행 사용자 권한에 맞는 안내와 함께 출력합니다.
*
* 배경: 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()` 직후 누적된 권한 정상화 실패 경고를 콘솔/로그에 즉시 노출합니다.
*
* 운영자가 sudo 환경 결함(파일시스템 ACL, immutable 비트, NFS 권한 거부 등) 으로
* 일부 경로 chown / chmod 실패 시 그 경로와 운영자 수동 복구 명령을 즉시 보여준다.
* 본 메서드 호출 후 service 의 `lastPermissionWarnings` 가 다음 호출 시 초기화되므로
* 매 `restoreOwnership` 직후 1회 호출 패턴이 정합.
*
* @param callable $log 내부 로그 누적 콜백 (`saveUpdateLog` 입력용)
*/
private function surfacePermissionWarnings(CoreUpdateService $service, callable $log): void
{
$warnings = $service->getLastPermissionWarnings();
if (empty($warnings)) {
return;
}
$this->newLine();
$this->warn('⚠ 권한 정상화 실패 — 일부 경로의 소유권/그룹 쓰기 권한을 복원하지 못했습니다.');
foreach ($warnings as $w) {
$kind = $w['kind'] === 'chown' ? '소유권' : '그룹 쓰기';
$this->warn(sprintf(' - %s [%s]: %d 건 실패', $w['target'], $kind, $w['failed']));
foreach (array_slice($w['failed_paths'], 0, 5) as $p) {
$this->line(" · {$p}");
}
if (count($w['failed_paths']) > 5) {
$this->line(sprintf(' · … (총 %d건, 상위 5건만 표시)', $w['failed']));
}
}
$this->newLine();
$this->line(' 복구 예시:');
$this->line(' sudo chown -R <owner>:<group> <path>');
$this->line(' sudo chmod -R g+w <path>');
$this->newLine();
$log(sprintf('권한 정상화 실패 %d 건 — 운영자 수동 복구 필요', count($warnings)));
}
}
@@ -0,0 +1,178 @@
<?php
namespace App\Console\Commands\Core;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Vendor\VendorMode;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 번들 확장(모듈/플러그인/템플릿/언어팩) 일괄 업데이트를 별도 PHP 프로세스에서 실행합니다.
*
* `core:update` 의 `BundledExtensionUpdatePrompt::executeBulkUpdate` 가 사용자 선택을
* 매니페스트로 직렬화한 후 본 커맨드를 `proc_open` 으로 spawn 한다. 자식은 fresh PHP
* 프로세스이므로 디스크에 막 적용된 신버전 코어 코드(특히 `ModuleManager` /
* `PluginManager` / `TemplateManager` / `LanguagePackService`) 를 메모리에 로드한 상태
* 에서 update 메서드를 호출한다.
*
* 부모 프로세스의 stale memory 가 신규 sync 메서드(`syncDeclarativeArtifacts` 등) 를
* 호출하지 못하던 결함의 영구 차단 (beta.4 의 `Upgrade_7_0_0_beta_4` 사후 보정과는 별개의
* 구조적 fix — 향후 모든 코어 업그레이드에서 동일 패턴 회귀를 차단).
*
* 매니페스트 형식:
* ```json
* {
* "modules": [{"identifier": "...", "strategy": "overwrite|keep"}, ...],
* "plugins": [{"identifier": "...", "strategy": "overwrite|keep"}, ...],
* "templates": [{"identifier": "...", "strategy": "overwrite|keep"}, ...],
* "lang_packs": [{"identifier": "..."}, ...]
* }
* ```
*
* 출력:
* - stdout 에 진행 라인을 그대로 출력 (부모 콘솔이 forwarding)
* - 종료 직전 `[BUNDLED-RESULT] {json}` 표식으로 최종 카운트 페이로드를 1회 출력 →
* 부모가 stdout 파싱해 결과 복원.
*/
class ExecuteBundledUpdatesCommand extends Command
{
public const RESULT_PREFIX = '[BUNDLED-RESULT] ';
protected $signature = 'core:execute-bundled-updates
{--manifest= : 업데이트 매니페스트 JSON 파일 경로 (필수)}
{--force : 강제 업데이트 플래그 (확장 매니저 update 호출에 전달)}';
protected $description = '번들 확장 일괄 업데이트를 실행합니다 (CoreUpdateCommand 내부용 — fresh PHP 프로세스에서 호출)';
public function handle(): int
{
$manifestPath = (string) $this->option('manifest');
if ($manifestPath === '' || ! File::isFile($manifestPath)) {
$this->error('--manifest 옵션이 필수이며 존재하는 파일이어야 합니다.');
return self::INVALID;
}
// spawn 자식 진입 시 활성 모듈/플러그인의 PSR-4 매핑을 fresh 등록 — 자세한 배경은
// ExecuteUpgradeStepsCommand::handle 의 동일 호출 주석 참조.
// (Artisan::call 대신 직접 메서드 호출 — nested Artisan::call 이 outer 명령의
// output buffer 를 덮어쓰는 Laravel 동작 회피)
try {
app(\App\Extension\ExtensionManager::class)->updateComposerAutoload();
} catch (\Throwable $e) {
Log::warning('bundled update spawn 자식: updateComposerAutoload 호출 실패', [
'error' => $e->getMessage(),
]);
}
$manifest = json_decode(File::get($manifestPath), true);
if (! is_array($manifest)) {
$this->error('매니페스트 JSON 파싱 실패: '.$manifestPath);
return self::FAILURE;
}
$modules = $manifest['modules'] ?? [];
$plugins = $manifest['plugins'] ?? [];
$templates = $manifest['templates'] ?? [];
$langPacks = $manifest['lang_packs'] ?? [];
$moduleManager = app(ModuleManager::class);
$pluginManager = app(PluginManager::class);
$templateManager = app(TemplateManager::class);
$langPackService = app(LanguagePackService::class);
$success = 0;
$failed = 0;
$this->info('── 번들 일괄 업데이트 실행 (spawn child) ──');
foreach ($modules as $entry) {
$id = (string) ($entry['identifier'] ?? '');
$strategy = (string) ($entry['strategy'] ?? 'overwrite');
if ($id === '') {
continue;
}
$this->line("→ [모듈] {$id} ({$strategy})");
try {
$moduleManager->updateModule($id, true, null, VendorMode::Auto, $strategy, null, 'bundled');
$success++;
} catch (\Throwable $e) {
$failed++;
$this->warn(" 실패: {$e->getMessage()}");
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'module', 'id' => $id, 'error' => $e->getMessage()]);
}
}
foreach ($plugins as $entry) {
$id = (string) ($entry['identifier'] ?? '');
$strategy = (string) ($entry['strategy'] ?? 'overwrite');
if ($id === '') {
continue;
}
$this->line("→ [플러그인] {$id} ({$strategy})");
try {
$pluginManager->updatePlugin($id, true, null, VendorMode::Auto, $strategy, null, 'bundled');
$success++;
} catch (\Throwable $e) {
$failed++;
$this->warn(" 실패: {$e->getMessage()}");
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'plugin', 'id' => $id, 'error' => $e->getMessage()]);
}
}
foreach ($templates as $entry) {
$id = (string) ($entry['identifier'] ?? '');
$strategy = (string) ($entry['strategy'] ?? 'overwrite');
if ($id === '') {
continue;
}
$this->line("→ [템플릿] {$id} ({$strategy})");
try {
$templateManager->updateTemplate($id, true, null, $strategy, 'bundled');
$success++;
} catch (\Throwable $e) {
$failed++;
$this->warn(" 실패: {$e->getMessage()}");
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'template', 'id' => $id, 'error' => $e->getMessage()]);
}
}
foreach ($langPacks as $entry) {
$id = (string) ($entry['identifier'] ?? '');
if ($id === '') {
continue;
}
$this->line("→ [언어팩] {$id}");
try {
$pack = $langPackService->findByIdentifier($id);
if (! $pack) {
throw new \RuntimeException(__('language_packs.errors.identifier_not_found', ['identifier' => $id]));
}
$langPackService->performUpdate($pack, true);
$success++;
} catch (\Throwable $e) {
$failed++;
$this->warn(" 실패: {$e->getMessage()}");
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'lang_pack', 'id' => $id, 'error' => $e->getMessage()]);
}
}
$this->newLine();
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
// 부모 프로세스가 결과를 복원할 수 있도록 표식 라인으로 페이로드 출력
$this->line(self::RESULT_PREFIX.json_encode([
'success' => $success,
'failed' => $failed,
], JSON_UNESCAPED_UNICODE));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}
@@ -2,7 +2,15 @@
namespace App\Console\Commands\Core;
use App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Exceptions\UpgradeHandoffException;
use App\Extension\ExtensionManager;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Services\CoreUpdateService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -18,15 +26,35 @@ use Illuminate\Support\Facades\Log;
* 업그레이드는 beta.1 의 CoreUpdateCommand 가 본 커맨드를 알지 못하므로 spawn
* 효과를 받지 못한다(경로 A) — 이 경우 upgrade step 파일 내부 로컬 로직으로
* 후처리를 수행해야 한다. 상세는 docs/extension/upgrade-step-guide.md 참조.
*
* 단독 실행 안전성: 운영자가 HANDOFF 안내문 또는 수동 복구 목적으로 직접 호출하면
* 기본값으로 부모 CoreUpdateCommand 가 처리하던 사전(Migration + Resync) 및
* 사후(.env 버전 + 캐시 정리 + 번들 확장 일괄 업데이트) 단계를 자동 수행한다.
* CoreUpdateCommand spawn 호출 시엔 `--skip-*` 옵션 5개로 중복 회피.
*
* `--steps-only`: 업그레이드 스텝만 실행하고 모든 보조 단계(권한 정상화 ·
* 오토로드 재생성 · 마이그레이션 · resync · 버전 갱신 · 캐시 정리 · 번들 업데이트)를
* 생략한다. 특정 스텝의 데이터 보정만 단발성으로 재실행할 때 사용한다. 보조 단계가
* 이미 완료된 환경에서 무거운 권한 재귀 순회 등을 건너뛴다.
*/
class ExecuteUpgradeStepsCommand extends Command
{
use BundledExtensionUpdatePrompt;
use ClearsTemplateCaches;
use HasUnifiedConfirm;
protected $signature = 'core:execute-upgrade-steps
{--from= : 시작 버전}
{--to= : 대상 버전}
{--force : 동일 버전 강제 실행}';
{--force : 동일 버전 강제 실행 + 번들 확장 일괄 업데이트 prompt 스킵}
{--steps-only : 업그레이드 스텝만 실행 — 권한 정상화·오토로드 재생성·마이그레이션·resync·버전 갱신·캐시 정리·번들 업데이트 등 모든 사전/사후 보조 단계 생략}
{--skip-migrations : 마이그레이션 실행 생략 (CoreUpdateCommand spawn 시 부모 Step 9 가 이미 실행)}
{--skip-resync : 코어 config 재로드 및 권한/메뉴/시더 동기화 생략 (동일 사유)}
{--skip-version-env : .env APP_VERSION 갱신 생략 (부모 Step 11 가 처리)}
{--skip-cache-clear : 캐시 정리 생략 (부모 Step 11 가 처리)}
{--skip-bundled-updates : 번들 확장 일괄 업데이트 생략 (부모가 prompt 로 처리)}';
protected $description = '코어 업그레이드 스텝을 별도 프로세스에서 실행합니다 (CoreUpdateCommand 내부용)';
protected $description = '코어 업그레이드 스텝을 별도 프로세스에서 실행합니다. 단독 실행 시 사전/사후 단계를 자동 수행하며, --steps-only 로 스텝만 실행할 수 있습니다.';
/**
* 커맨드를 실행합니다.
@@ -40,18 +68,116 @@ class ExecuteUpgradeStepsCommand extends Command
$to = (string) $this->option('to');
$force = (bool) $this->option('force');
// --steps-only: 업그레이드 스텝만 실행하고 모든 보조 단계(권한 정상화 · 오토로드
// 재생성 · 마이그레이션 · resync · 버전 갱신 · 캐시 정리 · 번들 업데이트)를 생략한다.
// 운영자가 특정 스텝의 데이터 보정만 단발성으로 재실행할 때 사용한다.
$stepsOnly = (bool) $this->option('steps-only');
if ($from === '' || $to === '') {
$this->error('--from 과 --to 는 필수 옵션입니다.');
return self::INVALID;
}
// spawn 자식 진입 시 활성 모듈/플러그인의 PSR-4 매핑을 fresh 등록.
// 부모 프로세스의 autoload-extensions.php 가 stale 한 경우 upgrade step 안에서
// ModuleManager / PluginManager 호출 → declaration 메서드가 Models/Services 등
// 다른 클래스 lazy load 시 "Class not found" 발생. 진입 직후 1회 호출로 모든 후속
// upgrade step (현재 + 미래) 이 fresh autoload 환경에서 실행됨을 보장.
//
// 본 커맨드 자체가 spawn 자식 (proc_open 으로 fork 된 별개 PHP 프로세스) 의 진입점이라
// 디스크의 fresh ExtensionManager(beta.X+1) 클래스를 메모리에 로드한 상태. 따라서
// `app(ExtensionManager::class)->updateComposerAutoload()` 직접 호출은 stale 가능성
// 없음. (Artisan::call 대신 직접 호출 — nested Artisan::call 이 outer 명령의
// output buffer 를 덮어쓰는 Laravel 동작 회피)
if (! $stepsOnly) {
try {
app(ExtensionManager::class)->updateComposerAutoload();
} catch (\Throwable $e) {
Log::warning('upgrade step spawn 자식: updateComposerAutoload 호출 실패', [
'error' => $e->getMessage(),
]);
}
}
// spawn 자식 진입 시 활성 디렉토리의 쓰기 권한 디렉토리를 멱등적으로 보장.
// fresh 디스크 config (`app.update.restore_ownership_group_writable`) 를 읽어 처리하므로
// 미래 release 가 새 쓰기 권한 디렉토리를 도입할 때 본 호출은 자동으로 신규 항목을 처리한다.
// upgrade step 에 mkdir/chown 코드를 매번 하드코딩할 필요 없음.
//
// 한계: 부모(이전 버전) 만 알고 있던 신규 디렉토리는 처리 불가 — 그 일회성 케이스는
// 해당 release 의 upgrade step 단발 처리. (예: beta.3→beta.4 의 lang-packs/* 보정)
if (! $stepsOnly) {
try {
$writablePaths = (array) config('app.update.restore_ownership_group_writable', []);
if (! empty($writablePaths)) {
$service->ensureWritableDirectories(
$writablePaths,
function (string $level, string $msg): void {
// 콘솔 + upgrade 로그 채널 동시 출력 — 운영자가 단일 파일(upgrade.log)에서
// spawn 자식의 권한 정상화 진행을 추적할 수 있도록 양쪽 모두 누적.
$this->{$level === 'warning' ? 'warn' : 'info'}($msg);
Log::channel('upgrade')->$level('[spawn] '.$msg);
},
);
}
} catch (\Throwable $e) {
Log::warning('upgrade step spawn 자식: ensureWritableDirectories 호출 실패', [
'error' => $e->getMessage(),
]);
}
}
// 코어 업데이트 spawn 자식 감지.
//
// CoreUpdateCommand::spawnUpgradeStepsProcess 가 자식에 `G7_UPDATE_IN_PROGRESS=1` env 를
// 전달한다 (beta.3 부터 존재). 이 시그널은 자식이 부모의 `core:update` 컨텍스트에서
// 실행 중임을 의미한다. 부모는 본인이 사전(Step 9 migration + resync) + 사후
// (Step 11 version-env + cache-clear + bundled prompt) 단계를 모두 책임지므로 자식은
// 5단계 모두 스킵해야 한다.
//
// 구버전 부모(예: beta.5)는 신버전 자식이 도입한 `--skip-*` 옵션을 모른다. 옵션 부재
// 시 자식이 사후 단계를 실행하면 (특히 bundled prompt) non-interactive 환경에서
// `Aborted.` exit=1 회귀 발생 (882deb9b0 사각지대). env 시그널이 옵션 부재를 보완.
//
// 단독 호출 (운영자 수동) 시엔 env 미설정 → 모든 단계 정상 진행.
$isSpawnChild = getenv('G7_UPDATE_IN_PROGRESS') === '1';
// 사전 단계 (단독 실행 안전성 보장).
//
// CoreUpdateCommand 가 spawn 호출 시엔 부모 Step 9 (CoreUpdateCommand.php:408-409) 에서
// 이미 동일 단계를 실행했으므로 --skip-migrations / --skip-resync 로 중복 회피.
// 운영자 단독 호출 시 옵션 미전달 → 기본값으로 두 단계 자동 수행 →
// migration / permission / menu / seeder 누락 차단.
if (! $this->option('skip-migrations') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('마이그레이션 실행');
$service->runMigrations();
} else {
$this->info($stepsOnly ? '[steps-only] 마이그레이션 스킵' : '[spawn] 마이그레이션 스킵 — 부모가 이미 실행');
}
if (! $this->option('skip-resync') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('코어 config 재로드 및 권한/메뉴/시더 동기화');
$service->reloadCoreConfigAndResync();
} else {
$this->info($stepsOnly ? '[steps-only] resync 스킵' : '[spawn] resync 스킵 — 부모가 이미 실행');
}
$stepsExecuted = 0;
$stepsDiscovered = 0;
try {
$service->runUpgradeSteps(
$from,
$to,
fn (string $version) => $this->info("upgrade step 실행: {$version}"),
function (string $version) use (&$stepsExecuted): void {
$this->info("upgrade step 실행: {$version}");
$stepsExecuted++;
},
$force,
function (int $discovered) use (&$stepsDiscovered): void {
$stepsDiscovered = $discovered;
},
);
} catch (UpgradeHandoffException $e) {
// 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청했다.
@@ -77,6 +203,8 @@ class ExecuteUpgradeStepsCommand extends Command
'reason' => $e->reason,
]);
$this->restoreUpgradeLogOwnership();
return UpgradeHandoffException::EXIT_CODE;
} catch (\Throwable $e) {
Log::error('core:execute-upgrade-steps 실패', [
@@ -86,9 +214,97 @@ class ExecuteUpgradeStepsCommand extends Command
]);
$this->error($e->getMessage());
$this->restoreUpgradeLogOwnership();
return self::FAILURE;
}
// 부모 (CoreUpdateCommand::spawnUpgradeStepsProcess) 가 silent skip 가드용으로 파싱하는
// 명시 종료 신호. [HANDOFF] 와 동일 구조의 라인 프로토콜. 정상 종료 (handoff 미발생) 경로
// 에서만 발행되며, 부모는 exit=0 인데 본 라인이 부재하면 mode=abort 시 throw,
// mode=fallback 시 in-process 진행 (`spawn_failure_mode`).
$this->line('[STEPS_EXECUTED] '.json_encode([
'count' => $stepsExecuted,
'discovered' => $stepsDiscovered,
'fromVersion' => $from,
'toVersion' => $to,
], JSON_UNESCAPED_UNICODE));
// 사후 단계 (단독 실행 안전성 보장).
//
// CoreUpdateCommand spawn 시엔 부모 Step 11 (CoreUpdateCommand.php:457-458) 및
// 번들 확장 일괄 업데이트 prompt (라인 497-515) 가 자식 종료 후 실행하므로
// --skip-version-env / --skip-cache-clear / --skip-bundled-updates 로 중복 회피.
// 단독 실행 시엔 옵션 미전달 → 기본값으로 3단계 자동 수행 → gnuboard/g7#34 의 운영자 수동 절차
// (sed APP_VERSION + cache:clear + module/plugin/template:update --force --source=bundled) 통합.
if (! $this->option('skip-version-env') && ! $isSpawnChild && ! $stepsOnly) {
$this->info(".env APP_VERSION={$to} 갱신");
$service->updateVersionInEnv($to);
} else {
$this->info($stepsOnly ? '[steps-only] .env APP_VERSION 갱신 스킵' : '[spawn] .env APP_VERSION 갱신 스킵 — 부모가 처리');
}
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 모드에서는 부모가 처리하므로 스킵.
$this->incrementExtensionCacheVersion();
} else {
$this->info($stepsOnly ? '[steps-only] 캐시 정리 스킵' : '[spawn] 캐시 정리 스킵 — 부모가 처리');
}
if (! $this->option('skip-bundled-updates') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('번들 확장 일괄 업데이트 (모듈/플러그인/템플릿/언어팩)');
$this->runBundledExtensionUpdatePrompt(
app(ModuleManager::class),
app(PluginManager::class),
app(TemplateManager::class),
$force,
);
} else {
$this->info($stepsOnly ? '[steps-only] 번들 확장 일괄 업데이트 스킵' : '[spawn] 번들 확장 일괄 업데이트 스킵 — 부모가 처리');
}
$this->restoreUpgradeLogOwnership();
return self::SUCCESS;
}
/**
* spawn 자식이 root 로 만든 upgrade 로그 파일의 소유권을 부모(storage/logs) 로 정합합니다.
*
* upgrade step 은 sudo 코어 업데이트에서 별도 PHP 프로세스로 spawn 되며(이 커맨드),
* root 로 실행되어 `upgrade-YYYY-MM-DD.log`(Log::channel('upgrade')) 를 root 소유로
* 만든다. 부모(CoreUpdateCommand) 프로세스는 업데이트 시작 시점의 *이전 버전* 클래스를
* 메모리에 들고 있어, 부모 측 로그 정상화 로직이 추가돼 있어도 그 버전엔 없을 수 있다
* (클래스 캐싱). 반면 본 spawn 자식은 항상 *신버전* 코드로 실행되므로, upgrade 로그를
* 만든 주체인 자식이 자기 종료 직전에 직접 소유권을 정합하면 부모 버전과 무관하게 동작한다.
*
* 그 결과 이후 www-data(php-fpm) 의 module:update upgrade step 이 같은 날짜 upgrade
* 로그에 append 할 수 있다. 본 메서드는 어떤 로그도 쓰지 않는다(쓰면 다시 root 가 됨).
* sudo 아닌 환경에서는 silent no-op(멱등).
*/
private function restoreUpgradeLogOwnership(): void
{
$logsDir = storage_path('logs');
if (! is_dir($logsDir)) {
return;
}
// 코어('upgrade-*.log') + 확장('extension-upgrade-*.log') 두 채널의 daily 로그를 모두
// 정합한다. glob 'upgrade-*.log' 는 'extension-' 접두사 파일을 매칭하지 못하므로
// 두 패턴을 각각 순회한다.
foreach (['upgrade-*.log', 'extension-upgrade-*.log'] as $pattern) {
foreach (glob($logsDir.DIRECTORY_SEPARATOR.$pattern) ?: [] as $logFile) {
FilePermissionHelper::inheritOwnershipFromParent($logFile);
}
}
}
}
@@ -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;
}
}
@@ -0,0 +1,246 @@
<?php
namespace App\Console\Commands\Hotfix;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Extension\Helpers\CoreBackupHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 버전 신규 파일을 진단/정리하는 단발성
* 결함 보정 도구.
*
* 결함 컨텍스트: 7.0.0-beta.5 이전의 코어 자동 롤백은 backup → 활성 overlay 복사 방식
* 으로만 동작하여, 신 버전이 활성 디렉토리에 새로 추가한 파일은 백업에 없으므로 삭제되지
* 않고 잔존했다. 결과적으로 `bootstrap/providers.php` 는 백업본(구버전) 으로 되돌아가지만
* 디스크에는 신 ServiceProvider 파일이 잔존하여 `BindingResolutionException` 등 부팅
* 부정합 발생.
*
* 도입 버전: 7.0.0-beta.6 신설.
*
* 동작:
* - `--backup` 미지정 시 `storage/app/core_backups/` 의 가장 최근 백업 자동 선택
* - 백업 디렉토리에 `_new_files_manifest.json` 이 있으면 그 기반으로 잔존 파일 후보 식별 (정확)
* - manifest 부재 시 보수적 진단 모드 (실제 정리는 운영자 수동)
* - 기본은 진단 모드 (실제 삭제 없이 후보 목록만 출력)
* - `--prune` 옵션 시 확인 프롬프트 후 정리. symlink/protected_paths 가드 적용
* - 정리 결과는 `storage/logs/hotfix_rollback_stale_files_<timestamp>.log` 기록
*
* @permanent — 7.0.0-beta.5 이전 사용자의 잔존 결함 회복을 위해 영구 보존. 향후 운영자가
* 과거 롤백 흔적을 점검할 수 있도록 유지하며, deprecation 예정 없음.
*/
class RollbackStaleFilesCommand extends Command
{
use HasUnifiedConfirm;
protected $signature = 'hotfix:rollback-stale-files
{--backup= : 특정 백업 디렉토리 경로 지정 (미지정 시 가장 최근 백업 자동 선택)}
{--prune : 진단만이 아닌 실제 정리 수행 (확인 프롬프트 동반)}';
protected $description = '[hotfix/beta.6 신설] 코어 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 버전 신규 파일을 진단/정리합니다.';
public function handle(): int
{
$backupPath = $this->resolveBackupPath();
if ($backupPath === null) {
$this->info('사용 가능한 백업이 없습니다 (`storage/app/core_backups/` 비어 있음).');
return Command::SUCCESS;
}
$this->info("백업 디렉토리: {$backupPath}");
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.CoreBackupHelper::NEW_FILES_MANIFEST;
$manifestAvailable = File::exists($manifestPath);
if (! $manifestAvailable) {
$this->warn('⚠ _new_files_manifest.json 부재 — 보수적 진단 모드로 진행합니다.');
$this->line(' manifest 가 없으면 신 파일 vs 사용자 추가 파일을 정확히 구분할 수 없습니다.');
$this->line(' beta.5 이전 사용자: beta.6 업그레이드 스텝이 manifest 를 사후 작성하므로');
$this->line(' 먼저 `php artisan core:execute-upgrade-steps --from=<버전> --to=7.0.0-beta.6 --force`');
$this->line(' 를 실행한 뒤 본 커맨드를 재실행하는 것을 권장합니다.');
$this->newLine();
}
$candidates = $this->collectCandidates($backupPath, $manifestAvailable);
if ($candidates['files'] === [] && $candidates['dirs'] === []) {
$this->info('잔존 후보 없음 — 활성 디렉토리가 정상 상태입니다.');
return Command::SUCCESS;
}
$this->newLine();
$this->info(sprintf(
'잔존 후보: 파일 %d개, 디렉토리 %d개',
count($candidates['files']),
count($candidates['dirs']),
));
foreach (array_slice($candidates['files'], 0, 20) as $f) {
$this->line(" · 파일: {$f}");
}
if (count($candidates['files']) > 20) {
$this->line(sprintf(' ... (총 %d건, 상위 20건만 표시)', count($candidates['files'])));
}
foreach (array_slice($candidates['dirs'], 0, 10) as $d) {
$this->line(" · 디렉토리: {$d}");
}
if (count($candidates['dirs']) > 10) {
$this->line(sprintf(' ... (총 %d건, 상위 10건만 표시)', count($candidates['dirs'])));
}
if (! $this->option('prune')) {
$this->newLine();
$this->info('진단 모드: 실제 삭제하지 않았습니다. 정리하려면 `--prune` 옵션을 추가하세요.');
return Command::SUCCESS;
}
if (! $manifestAvailable) {
$this->newLine();
$this->error('manifest 부재 상태에서는 자동 prune 을 수행하지 않습니다 (사용자 추가 파일 손실 위험).');
$this->line('beta.6 업그레이드 스텝을 먼저 실행하여 manifest 를 사후 작성한 뒤 재시도하세요.');
return Command::FAILURE;
}
$this->newLine();
if (! $this->unifiedConfirm('위 후보를 활성 디렉토리에서 정리하시겠습니까?', false)) {
$this->info('취소되었습니다.');
return Command::SUCCESS;
}
$protectedPaths = (array) config('app.update.protected_paths', []);
$result = CoreBackupHelper::pruneNewFiles($backupPath, base_path(), $protectedPaths);
$this->newLine();
$this->info(sprintf(
'정리 완료: 파일 %d개, 디렉토리 %d개 제거. 보호 %d건, symlink skip %d건, 실패 %d건.',
$result['removed_files'],
$result['removed_dirs'],
$result['protected_count'],
$result['symlink_skipped'],
$result['failed_count'],
));
$logPath = storage_path(sprintf(
'logs/hotfix_rollback_stale_files_%s.log',
date('Ymd_His'),
));
@file_put_contents($logPath, $this->buildLogEntry($backupPath, $result, $candidates));
$this->line("로그: {$logPath}");
Log::info('hotfix:rollback-stale-files 정리 완료', [
'backup_path' => $backupPath,
'result' => $result,
]);
return Command::SUCCESS;
}
/**
* `--backup` 옵션 또는 가장 최근 백업 디렉토리 경로를 반환.
*/
private function resolveBackupPath(): ?string
{
$explicit = $this->option('backup');
if ($explicit !== null && $explicit !== '') {
$real = realpath($explicit);
if ($real === false || ! is_dir($real)) {
$this->error("지정된 백업 디렉토리가 존재하지 않습니다: {$explicit}");
return null;
}
return $real;
}
$backups = CoreBackupHelper::listBackups();
if ($backups === []) {
return null;
}
// listBackups 는 정렬 보장이 약하므로 mtime 기준으로 다시 정렬
usort($backups, fn ($a, $b) => filemtime($b['path']) <=> filemtime($a['path']));
return $backups[0]['path'] ?? null;
}
/**
* 잔존 후보 식별 — manifest 가 있으면 그 기반, 부재 시 빈 배열 반환 (운영자 수동 검토 안내).
*
* @return array{files:array<int,string>, dirs:array<int,string>}
*/
private function collectCandidates(string $backupPath, bool $manifestAvailable): array
{
if (! $manifestAvailable) {
return ['files' => [], 'dirs' => []];
}
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.CoreBackupHelper::NEW_FILES_MANIFEST;
$raw = @file_get_contents($manifestPath);
if ($raw === false) {
return ['files' => [], 'dirs' => []];
}
$manifest = json_decode($raw, true);
if (! is_array($manifest)) {
return ['files' => [], 'dirs' => []];
}
$files = [];
foreach ((array) ($manifest['new_files'] ?? []) as $rel) {
if (! is_string($rel) || $rel === '') {
continue;
}
$absolute = base_path($rel);
if (file_exists($absolute) || is_link($absolute)) {
$files[] = $rel;
}
}
$dirs = [];
foreach ((array) ($manifest['new_dirs'] ?? []) as $rel) {
if (! is_string($rel) || $rel === '') {
continue;
}
$absolute = base_path($rel);
if (is_dir($absolute) && ! is_link($absolute)) {
$dirs[] = $rel;
}
}
return ['files' => $files, 'dirs' => $dirs];
}
/**
* 진단/정리 결과를 로그 텍스트로 빌드.
*/
private function buildLogEntry(string $backupPath, array $result, array $candidates): string
{
$lines = [
'=== hotfix:rollback-stale-files 실행 로그 ===',
'날짜: '.date('Y-m-d H:i:s'),
"백업 디렉토리: {$backupPath}",
'',
'결과:',
sprintf(' 파일 제거: %d', $result['removed_files']),
sprintf(' 디렉토리 제거: %d', $result['removed_dirs']),
sprintf(' 보호 경로 skip: %d', $result['protected_count']),
sprintf(' symlink skip: %d', $result['symlink_skipped']),
sprintf(' 실패: %d', $result['failed_count']),
'',
'후보 목록 (실행 직전 스냅샷):',
];
foreach ($candidates['files'] as $f) {
$lines[] = " · 파일: {$f}";
}
foreach ($candidates['dirs'] as $d) {
$lines[] = " · 디렉토리: {$d}";
}
return implode("\n", $lines)."\n";
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Throwable;
/**
* 언어팩을 활성화합니다.
*
* 모듈/플러그인/템플릿의 activate 커맨드와 동일한 운영 도구.
* 동일 슬롯의 다른 활성 팩은 자동으로 inactive 로 전환됩니다.
*/
class ActivateLanguagePackCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:activate
{identifier : 언어팩 식별자}
{--force : 의존성/검증 경고를 무시하고 강제 활성화}';
/**
* @var string
*/
protected $description = '언어팩을 활성화합니다.';
/**
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$identifier = (string) $this->argument('identifier');
$force = (bool) $this->option('force');
$pack = $this->service->findByIdentifier($identifier);
if (! $pack) {
$this->error("언어팩을 찾을 수 없습니다: {$identifier}");
return self::FAILURE;
}
try {
$this->service->activate($pack, $force);
$this->info("언어팩 활성화 완료: {$identifier}");
return self::SUCCESS;
} catch (Throwable $e) {
$this->error('언어팩 활성화 실패: '.$e->getMessage());
return self::FAILURE;
}
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Throwable;
/**
* 언어팩 관련 캐시(레지스트리/Translator/템플릿/버전 스탬프)를 정리합니다.
*
* 모듈/플러그인/템플릿의 cache-clear 커맨드와 동일한 운영 도구.
* _bundled 의 다국어 파일을 손으로 수정한 뒤 즉시 반영하려는 운영 시나리오에 사용합니다.
*/
class CacheClearLanguagePackCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:cache-clear';
/**
* @var string
*/
protected $description = '언어팩 캐시(registry/translator/template/version)를 정리합니다.';
/**
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
try {
$result = $this->service->refreshCache();
$this->info('언어팩 캐시 정리 완료:');
foreach ($result as $key => $ok) {
$this->line(' - '.$key.': '.($ok ? 'ok' : 'failed'));
}
return collect($result)->every(fn ($v) => $v === true) ? self::SUCCESS : self::FAILURE;
} catch (Throwable $e) {
$this->error('언어팩 캐시 정리 실패: '.$e->getMessage());
return self::FAILURE;
}
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* GitHub 으로 설치된 언어팩의 최신 릴리스 버전을 확인하여 `latest_version` 컬럼을 갱신합니다.
*
* 관리 UI 에서 "업데이트 가능" 배지 노출에 사용되며, 스케줄러에서 주기적으로 실행됩니다.
*/
class CheckLanguagePackUpdatesCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:check-updates {--identifier= : 특정 언어팩만 확인 (선택)}';
/**
* @var string
*/
protected $description = '설치된 언어팩의 GitHub 업데이트를 확인합니다.';
/**
* @param LanguagePackService $service 언어팩 Service (실제 로직 SSoT)
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* 실제 갱신 로직은 LanguagePackService::checkUpdates() 에 위임합니다.
*
* @return int 종료 코드 (0=성공, 1=실패)
*/
public function handle(): int
{
$identifier = $this->option('identifier');
try {
$result = $this->service->checkUpdates($identifier);
if ($result['checked'] === 0) {
$this->info('확인할 GitHub 기반 언어팩이 없습니다.');
return Command::SUCCESS;
}
foreach ($result['details'] as $entry) {
if ($entry['error']) {
$this->warn(sprintf(' [%s] 조회 실패: %s', $entry['identifier'], $entry['error']));
continue;
}
$line = sprintf(
' [%s] %s → %s%s',
$entry['identifier'],
$entry['current'],
$entry['latest'] ?? '?',
$entry['has_update'] ? ' (업데이트 가능)' : '',
);
$this->line($line);
}
$this->info(sprintf('확인 완료: %d 건 검사, %d 건 업데이트 가능.', $result['checked'], $result['updates']));
return Command::SUCCESS;
} catch (Throwable $e) {
$this->error('업데이트 확인 실패: '.$e->getMessage());
Log::error('language-pack:check-updates 실패', [
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Throwable;
/**
* 언어팩을 비활성화합니다.
*
* 모듈/플러그인/템플릿의 deactivate 커맨드와 동일한 운영 도구.
* 보호된 팩(is_protected=true)은 거부되며, 호스트 cascade 컨텍스트에서만 우회됩니다.
*/
class DeactivateLanguagePackCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:deactivate
{identifier : 언어팩 식별자}';
/**
* @var string
*/
protected $description = '언어팩을 비활성화합니다.';
/**
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$identifier = (string) $this->argument('identifier');
$pack = $this->service->findByIdentifier($identifier);
if (! $pack) {
$this->error("언어팩을 찾을 수 없습니다: {$identifier}");
return self::FAILURE;
}
try {
$this->service->deactivate($pack);
$this->info("언어팩 비활성화 완료: {$identifier}");
return self::SUCCESS;
} catch (Throwable $e) {
$this->error('언어팩 비활성화 실패: '.$e->getMessage());
return self::FAILURE;
}
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Throwable;
/**
* 번들/GitHub/URL 소스에서 언어팩을 설치합니다.
*
* 모듈/플러그인/템플릿의 install 커맨드와 동일한 운영 도구.
* `--source=bundled` 시 `lang-packs/_bundled/{identifier}` 디렉토리에서 설치하며,
* 외부 다운로드 없이 코어 언어팩의 (재)설치/복구가 가능합니다.
*/
class InstallLanguagePackCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:install
{identifier : 번들 식별자 또는 소스에 따른 식별자/URL}
{--source=bundled : 설치 소스 (bundled|github|url)}
{--no-activate : 설치 후 자동 활성화하지 않음}';
/**
* @var string
*/
protected $description = '번들/GitHub/URL 소스에서 언어팩을 설치합니다.';
/**
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$identifier = (string) $this->argument('identifier');
$source = (string) $this->option('source');
$autoActivate = ! (bool) $this->option('no-activate');
try {
$pack = match ($source) {
'bundled' => $this->service->installFromBundled($identifier, $autoActivate),
'github' => $this->service->installFromGithub($identifier, $autoActivate),
'url' => $this->service->installFromUrl($identifier, null, $autoActivate),
default => throw new \InvalidArgumentException(
__('language_packs.errors.unsupported_source', ['source' => $source])
),
};
$this->info(sprintf(
'언어팩 설치 완료: %s v%s (status=%s)',
$pack->identifier,
$pack->version,
$pack->status,
));
return self::SUCCESS;
} catch (Throwable $e) {
$this->error('언어팩 설치 실패: '.$e->getMessage());
return self::FAILURE;
}
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
use Illuminate\Console\Command;
/**
* 설치된 언어팩 목록을 출력합니다.
*
* 모듈/플러그인/템플릿의 list 커맨드와 동일한 운영 도구.
*/
class ListLanguagePackCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:list {--scope= : 특정 스코프만 (core/module/plugin/template)}';
/**
* @var string
*/
protected $description = '설치된 언어팩 목록을 출력합니다.';
/**
* @param LanguagePackRepositoryInterface $repository Repository
*/
public function __construct(
private readonly LanguagePackRepositoryInterface $repository,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$filters = [];
if ($scope = $this->option('scope')) {
$filters['scope'] = $scope;
}
$paginator = $this->repository->paginate($filters, 100);
$packs = $paginator->items();
if (empty($packs)) {
$this->info('설치된 언어팩이 없습니다.');
return self::SUCCESS;
}
$rows = [];
foreach ($packs as $pack) {
$rows[] = [
$pack->identifier,
$pack->scope,
$pack->target_identifier ?? '-',
$pack->locale,
$pack->vendor,
$pack->version,
$pack->status,
];
}
$this->table(
['Identifier', 'Scope', 'Target', 'Locale', 'Vendor', 'Version', 'Status'],
$rows
);
return self::SUCCESS;
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Throwable;
/**
* 언어팩을 제거합니다.
*
* 모듈/플러그인/템플릿의 uninstall 커맨드와 동일한 운영 도구.
* 보호된 팩(`is_protected=true`)은 차단되며, `--force` 없이는 yes/no 확인을 요구합니다.
*/
class UninstallLanguagePackCommand extends Command
{
use HasUnifiedConfirm;
/**
* @var string
*/
protected $signature = 'language-pack:uninstall
{identifier : 언어팩 식별자}
{--cascade : 코어 팩 제거 시 동일 locale 의 module/plugin/template 팩도 inactive 로 강등}
{--force : 확인 없이 즉시 제거}';
/**
* @var string
*/
protected $description = '언어팩을 제거합니다.';
/**
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$identifier = (string) $this->argument('identifier');
$cascade = (bool) $this->option('cascade');
$force = (bool) $this->option('force');
$pack = $this->service->findByIdentifier($identifier);
if (! $pack) {
$this->error("언어팩을 찾을 수 없습니다: {$identifier}");
return self::FAILURE;
}
if (! $force && ! $this->unifiedConfirm("언어팩 '{$identifier}' 을(를) 제거하시겠습니까?", false)) {
$this->info('취소되었습니다.');
return self::SUCCESS;
}
try {
$this->service->uninstall($pack, $cascade);
$this->info("언어팩 제거 완료: {$identifier}");
return self::SUCCESS;
} catch (Throwable $e) {
$this->error('언어팩 제거 실패: '.$e->getMessage());
return self::FAILURE;
}
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Throwable;
/**
* 언어팩을 업데이트합니다.
*
* 모듈/플러그인/템플릿의 update 커맨드와 동일한 운영 도구.
* 동시성 가드 + 백업 + 롤백은 Service 의 performUpdate 가 담당합니다.
*
* 옵션:
* --force 버전이 동일해도 강제 재적용 (확장 update --force 와 동일 의미)
* --source=auto|bundled|github 업데이트 소스 우선순위. auto(기본): force 시 bundled, 외 GitHub
*/
class UpdateLanguagePackCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:update
{identifier : 언어팩 식별자}
{--force : 버전 변경이 없어도 강제 재적용 (_bundled 우선)}
{--source=auto : 업데이트 소스 (auto|bundled|github)}';
/**
* @var string
*/
protected $description = '언어팩을 GitHub 또는 _bundled 신버전으로 업데이트합니다.';
/**
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$identifier = (string) $this->argument('identifier');
$force = (bool) $this->option('force');
$source = (string) $this->option('source');
if (! in_array($source, ['auto', 'bundled', 'github'], true)) {
$this->error("알 수 없는 --source 값: {$source} (auto|bundled|github 중 선택)");
return self::FAILURE;
}
$pack = $this->service->findByIdentifier($identifier);
if (! $pack) {
$this->error("언어팩을 찾을 수 없습니다: {$identifier}");
return self::FAILURE;
}
try {
$fromVersion = $pack->version;
// source=bundled 는 force 와 동등 (bundled 1순위 강제). source=github 는 force 무시.
$effectiveForce = $force || $source === 'bundled';
$updated = $this->service->performUpdate($pack, $effectiveForce);
$this->info(sprintf(
'언어팩 업데이트 완료: %s (%s → %s)',
$updated->identifier,
$fromVersion,
$updated->version,
));
return self::SUCCESS;
} catch (Throwable $e) {
$this->error('언어팩 업데이트 실패: '.$e->getMessage());
return self::FAILURE;
}
}
}
@@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Models\SystemConfig;
use App\Repositories\JsonConfigRepository;
use Illuminate\Console\Command;
@@ -12,6 +13,8 @@ use Illuminate\Support\Facades\Schema;
*/
class MigrateSettingsToJsonCommand extends Command
{
use HasUnifiedConfirm;
/**
* 커맨드 시그니처
*
@@ -122,8 +125,6 @@ class MigrateSettingsToJsonCommand extends Command
* 커맨드를 실행합니다.
*
* DI 컨테이너 바인딩 시점 문제로 직접 인스턴스화합니다.
*
* @return int
*/
public function handle(): int
{
@@ -133,7 +134,7 @@ class MigrateSettingsToJsonCommand extends Command
if (! Schema::hasTable('system_configs')) {
$this->warn('system_configs 테이블이 존재하지 않습니다. 기본값으로 초기화합니다.');
$configRepository = new JsonConfigRepository();
$configRepository = new JsonConfigRepository;
$configRepository->initialize();
$this->info('기본 설정 파일이 생성되었습니다.');
@@ -142,11 +143,11 @@ class MigrateSettingsToJsonCommand extends Command
}
// JsonConfigRepository 직접 인스턴스화 (DI 컨테이너 바인딩 전 실행 가능)
$configRepository = new JsonConfigRepository();
$configRepository = new JsonConfigRepository;
// 기존 JSON 파일 존재 확인
if (file_exists(storage_path('app/settings/general.json')) && ! $this->option('force')) {
if (! $this->confirm('기존 JSON 설정 파일이 존재합니다. 덮어쓰시겠습니까?')) {
if (! $this->unifiedConfirm('기존 JSON 설정 파일이 존재합니다. 덮어쓰시겠습니까?', false)) {
$this->info('마이그레이션이 취소되었습니다.');
return Command::SUCCESS;
@@ -196,10 +197,6 @@ class MigrateSettingsToJsonCommand extends Command
/**
* 문자열 값을 원래 타입으로 파싱합니다.
*
* @param string $value
* @param string $type
* @return mixed
*/
private function parseValue(string $value, string $type): mixed
{
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Module;
use App\Extension\ModuleManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\GeneratesComponentManifest;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
@@ -11,6 +12,7 @@ use Symfony\Component\Process\Process;
class BuildModuleCommand extends Command
{
use ClearsTemplateCaches;
use GeneratesComponentManifest;
/**
* The name and signature of the console command.
@@ -187,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}");
@@ -196,6 +203,14 @@ class BuildModuleCommand extends Command
// 빌드 결과 파일 확인
$this->displayBuildResults($buildPath, $identifier);
// 편집기 컴포넌트 매니페스트(components.json) 생성
$manifestResult = $this->generateComponentManifest($buildPath, $identifier);
if ($manifestResult['written']) {
$this->line(" - components.json 생성됨 (컴포넌트 {$manifestResult['count']}개)");
} else {
$this->warn(' - components.json 생성 실패 (편집 모드 컨트롤 노출 제한)');
}
// 캐시 버전 증가 (브라우저 캐시 무효화)
$this->incrementExtensionCacheVersion();
$this->line(' - 캐시 버전 갱신됨');
@@ -328,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') {
@@ -345,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]));
}
@@ -13,7 +13,8 @@ class ListModuleCommand extends Command
* The name and signature of the console command.
*/
protected $signature = 'module:list
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}';
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}
{--hidden : 숨김(hidden=true) 모듈도 함께 출력}';
/**
* The console command description.
@@ -39,6 +40,7 @@ class ListModuleCommand extends Command
$this->moduleManager->loadModules();
$statusFilter = $this->option('status');
$includeHidden = (bool) $this->option('hidden');
// 상태 필터 검증
if ($statusFilter && ! in_array($statusFilter, ['installed', 'uninstalled', 'active', 'inactive'])) {
@@ -58,6 +60,11 @@ class ListModuleCommand extends Command
// 설치된 모듈 추가
foreach ($installedModules as $identifier => $module) {
// 숨김 필터: --hidden 미지정 시 hidden=true 모듈 제외
if (! $includeHidden && ! empty($module['hidden'])) {
continue;
}
// 상태 필터
if ($statusFilter) {
if ($statusFilter === 'uninstalled') {
@@ -86,6 +93,11 @@ class ListModuleCommand extends Command
// 미설치 모듈 추가
if (! $statusFilter || $statusFilter === 'uninstalled') {
foreach ($uninstalledModules as $identifier => $module) {
// 숨김 필터: --hidden 미지정 시 hidden=true 모듈 제외
if (! $includeHidden && ! empty($module['hidden'])) {
continue;
}
// 상태 필터 (uninstalled 또는 필터 없음)
if ($statusFilter && $statusFilter !== 'uninstalled') {
continue;
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\ModuleManager;
@@ -14,6 +15,7 @@ use Illuminate\Support\Facades\Log;
class UninstallModuleCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -82,7 +84,7 @@ class UninstallModuleCommand extends Command
}
$this->newLine();
if (! $this->confirm(__('modules.commands.uninstall.confirm_question'), false)) {
if (! $this->unifiedConfirm(__('modules.commands.uninstall.confirm_question'), false)) {
$this->info(__('modules.commands.uninstall.aborted'));
return Command::SUCCESS;
@@ -3,15 +3,19 @@
namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\ModuleManager;
use App\Extension\Vendor\VendorMode;
use App\Services\LayoutExtensionService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class UpdateModuleCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -34,7 +38,8 @@ class UpdateModuleCommand extends Command
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository
private ModuleRepositoryInterface $moduleRepository,
private LayoutExtensionService $layoutExtensionService
) {
parent::__construct();
}
@@ -118,10 +123,34 @@ class UpdateModuleCommand extends Command
$this->info('업데이트 버전: (module.json 추출 후 판별)');
}
$this->info(__('modules.commands.update.layout_strategy', ['strategy' => $layoutStrategy]));
// overwrite 전략일 때 관리자가 편집한 레이아웃 확장 경고
if ($layoutStrategy === 'overwrite') {
$modifiedExtensions = $this->layoutExtensionService->getModifiedExtensionsBySource(
LayoutSourceType::Module,
$identifier
);
if (! empty($modifiedExtensions)) {
$this->newLine();
$this->warn('⚠️ '.__('modules.commands.update.modified_extensions_warning', [
'count' => count($modifiedExtensions),
]));
foreach ($modifiedExtensions as $extension) {
$this->warn(__('modules.commands.update.modified_extension_item', [
'target' => $extension['target_name'],
'source' => $extension['source_identifier'],
]));
}
}
}
$this->newLine();
// 확인 프롬프트 (--force 시 건너뜀)
if (! $force && ! $this->confirm(__('modules.commands.update.confirm_question'), false)) {
if (! $force && ! $this->unifiedConfirm(__('modules.commands.update.confirm_question'), false)) {
$this->info(__('modules.commands.update.aborted'));
return Command::SUCCESS;
@@ -0,0 +1,130 @@
<?php
namespace App\Console\Commands;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
/**
* Playwright E2E 용 Sanctum 토큰 발급 커맨드.
*
* 임의 권한 식별자(코어/모듈/플러그인) 를 받아 권한 보유 관리자 유저를 즉시 생성하고
* Sanctum 개인 액세스 토큰을 발급한다. 발급된 토큰은 stdout 마지막 줄에 출력되어
* Playwright fixture 가 stdin 캡처로 사용한다.
*
* 보안 가드 (3중):
* ① CLI 한정 — `php_sapi_name() === 'cli'` 확인. production 웹 요청에서 절대 도달 불가
* ② 명시 옵트인 — `G7_PLAYWRIGHT_BYPASS=1` 환경변수 부여 필수.
* `.env` 영구 수정 없이 인라인 환경변수로만 활성화 가능 → 무심코 production 으로 새지 않음
* ③ APP_DEBUG 강제 — bypass flag 인지 후 `config('app.debug')` 를 true 로 inline override.
* SettingsServiceProvider 의 testing/bypass 분기가 이미 settings JSON 덮어쓰기를 건너뛰므로
* production + debug=false 환경에서도 토큰 발급이 가능하다
*
* 환경 매트릭스:
* - local + bypass=1 : 로컬 개발자 PC 에서 직접 spec 작성/실행
* - testing + bypass=1 : CI / PHPUnit 환경에서 .env.testing 로 동작하는 E2E 통합 (testing 환경은 이미 testing 가드로 통과)
* - production + bypass=1 : production DB 가 활성 호스트를 가리키는 환경(예: g7.dev) 에서 E2E
*
* 호출 예시 (PowerShell):
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:issue-token --permissions=core.templates.layouts.edit
*
* 로직 출처: tests/Feature/Api/Admin/LayoutAccessCheckEndpointTest::makeAdminUser (44~87행)
*/
class PlaywrightIssueToken extends Command
{
protected $signature = 'playwright:issue-token
{--permissions=* : 부여할 권한 식별자 (예: core.templates.layouts.edit). 다중 지정 가능}';
protected $description = 'Playwright E2E 용 Sanctum 토큰 발급 (CLI + G7_PLAYWRIGHT_BYPASS 3중 가드)';
public function handle(): int
{
// ① CLI 한정 — production 웹 요청에서 절대 도달 불가
if (php_sapi_name() !== 'cli') {
$this->error('CLI 전용 커맨드입니다. (현재 SAPI: '.php_sapi_name().')');
return self::FAILURE;
}
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
return self::FAILURE;
}
// ③ APP_DEBUG 강제 — production + debug=false 환경에서도 sanctum 토큰 발급 + 디버그 정보 누락 방지.
// SettingsServiceProvider::applyDebugConfig 는 bypass flag 가 있으면 settings JSON 덮어쓰기를 이미 건너뛴 상태.
Config::set('app.debug', true);
$permissions = $this->option('permissions') ?: [];
$user = $this->makeAdminUser($permissions);
$token = $user->createToken('playwright-'.uniqid())->plainTextToken;
$this->line($token);
return self::SUCCESS;
}
/**
* 권한 식별자 배열로 관리자 유저를 생성하고 권한을 부여한다.
*
* 절차:
* 1. User factory 로 신규 유저 생성
* 2. 권한 식별자별로 Permission 행 보장 (firstOrCreate)
* 3. uniqid 접미사로 격리된 test role 생성 + 권한 sync
* 4. admin role 보장 (firstOrCreate) + 유저-역할 부여
*/
private function makeAdminUser(array $permissions): User
{
$user = User::factory()->create();
$permissionIds = [];
foreach ($permissions as $identifier) {
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
]
);
$permissionIds[] = $permission->id;
}
$testRole = Role::create([
'identifier' => 'playwright_test_'.uniqid(),
'name' => json_encode(['ko' => 'Playwright 테스트 관리자', 'en' => 'Playwright Test Admin']),
'description' => json_encode(['ko' => 'E2E 자동화 전용', 'en' => 'E2E automation only']),
'is_active' => true,
]);
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Admin']),
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Admin']),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
'is_active' => true,
]
);
if (! empty($permissionIds)) {
$testRole->permissions()->sync($permissionIds);
}
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
$user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
return $user->fresh();
}
}
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Plugin;
use App\Extension\PluginManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\GeneratesComponentManifest;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
@@ -11,6 +12,7 @@ use Symfony\Component\Process\Process;
class BuildPluginCommand extends Command
{
use ClearsTemplateCaches;
use GeneratesComponentManifest;
/**
* The name and signature of the console command.
@@ -187,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}");
@@ -196,6 +203,14 @@ class BuildPluginCommand extends Command
// 빌드 결과 파일 확인
$this->displayBuildResults($buildPath, $identifier);
// 편집기 컴포넌트 매니페스트(components.json) 생성
$manifestResult = $this->generateComponentManifest($buildPath, $identifier);
if ($manifestResult['written']) {
$this->line(" - components.json 생성됨 (컴포넌트 {$manifestResult['count']}개)");
} else {
$this->warn(' - components.json 생성 실패 (편집 모드 컨트롤 노출 제한)');
}
// 캐시 버전 증가 (브라우저 캐시 무효화)
$this->incrementExtensionCacheVersion();
$this->line(' - 캐시 버전 갱신됨');
@@ -328,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') {
@@ -345,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]));
}
@@ -13,7 +13,8 @@ class ListPluginCommand extends Command
* The name and signature of the console command.
*/
protected $signature = 'plugin:list
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}';
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}
{--hidden : 숨김(hidden=true) 플러그인도 함께 출력}';
/**
* The console command description.
@@ -39,6 +40,7 @@ class ListPluginCommand extends Command
$this->pluginManager->loadPlugins();
$statusFilter = $this->option('status');
$includeHidden = (bool) $this->option('hidden');
// 상태 필터 검증
if ($statusFilter && ! in_array($statusFilter, ['installed', 'uninstalled', 'active', 'inactive'])) {
@@ -58,6 +60,11 @@ class ListPluginCommand extends Command
// 설치된 플러그인 추가
foreach ($installedPlugins as $identifier => $plugin) {
// 숨김 필터: --hidden 미지정 시 hidden=true 플러그인 제외
if (! $includeHidden && ! empty($plugin['hidden'])) {
continue;
}
// 상태 필터
if ($statusFilter) {
if ($statusFilter === 'uninstalled') {
@@ -86,6 +93,11 @@ class ListPluginCommand extends Command
// 미설치 플러그인 추가
if (! $statusFilter || $statusFilter === 'uninstalled') {
foreach ($uninstalledPlugins as $identifier => $plugin) {
// 숨김 필터: --hidden 미지정 시 hidden=true 플러그인 제외
if (! $includeHidden && ! empty($plugin['hidden'])) {
continue;
}
// 상태 필터 (uninstalled 또는 필터 없음)
if ($statusFilter && $statusFilter !== 'uninstalled') {
continue;
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\PluginManager;
@@ -13,6 +14,7 @@ use Illuminate\Support\Facades\Log;
class UninstallPluginCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -79,7 +81,7 @@ class UninstallPluginCommand extends Command
}
$this->newLine();
if (! $this->confirm(__('plugins.commands.uninstall.confirm_question'), false)) {
if (! $this->unifiedConfirm(__('plugins.commands.uninstall.confirm_question'), false)) {
$this->info(__('plugins.commands.uninstall.aborted'));
return Command::SUCCESS;
@@ -3,15 +3,19 @@
namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\PluginManager;
use App\Extension\Vendor\VendorMode;
use App\Services\LayoutExtensionService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class UpdatePluginCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -34,7 +38,8 @@ class UpdatePluginCommand extends Command
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository
private PluginRepositoryInterface $pluginRepository,
private LayoutExtensionService $layoutExtensionService
) {
parent::__construct();
}
@@ -117,10 +122,34 @@ class UpdatePluginCommand extends Command
$this->info('업데이트 버전: (plugin.json 추출 후 판별)');
}
$this->info(__('plugins.commands.update.layout_strategy', ['strategy' => $layoutStrategy]));
// overwrite 전략일 때 관리자가 편집한 레이아웃 확장 경고
if ($layoutStrategy === 'overwrite') {
$modifiedExtensions = $this->layoutExtensionService->getModifiedExtensionsBySource(
LayoutSourceType::Plugin,
$identifier
);
if (! empty($modifiedExtensions)) {
$this->newLine();
$this->warn('⚠️ '.__('plugins.commands.update.modified_extensions_warning', [
'count' => count($modifiedExtensions),
]));
foreach ($modifiedExtensions as $extension) {
$this->warn(__('plugins.commands.update.modified_extension_item', [
'target' => $extension['target_name'],
'source' => $extension['source_identifier'],
]));
}
}
}
$this->newLine();
// 확인 프롬프트 (--force 시 건너뜀)
if (! $force && ! $this->confirm(__('plugins.commands.update.confirm_question'), false)) {
if (! $force && ! $this->unifiedConfirm(__('plugins.commands.update.confirm_question'), false)) {
$this->info(__('plugins.commands.update.aborted'));
return Command::SUCCESS;
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Contracts\Repositories\LayoutVersionRepositoryInterface;
use Illuminate\Console\Command;
/**
* 레이아웃 버전의 변경 요약(changes_summary)을 현재 알고리즘으로 재계산하는 커맨드
*
* 버전 변경량 측정이 키 경로 단위에서 라인 단위(버전 비교 diff 뷰와 동일 SSoT)로
* 바뀌기 전에 저장된 버전들은 옛 기준의 changes_summary 를 담고 있어, 버전 목록의
* 변경량이 상세 diff 와 불일치한다. 본 커맨드로 기존 버전을 일괄 재계산해 정합시킨다.
*/
class RecalculateLayoutVersionDiffsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'layout-versions:recalculate-diffs';
/**
* The console command description.
*/
protected $description = '레이아웃 버전의 변경 요약을 현재 라인 단위 알고리즘으로 재계산합니다';
/**
* Execute the console command.
*
* @param LayoutVersionRepositoryInterface $repository 버전 저장소
* @return int 명령 실행 결과 코드
*/
public function handle(LayoutVersionRepositoryInterface $repository): int
{
$this->info('레이아웃 버전 변경 요약 재계산을 시작합니다...');
$updated = $repository->recalculateAllChangeSummaries();
$this->info("버전 {$updated}건의 변경 요약을 재계산했습니다.");
return self::SUCCESS;
}
}
@@ -4,18 +4,20 @@ namespace App\Console\Commands;
use App\Jobs\GenerateSitemapJob;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
/**
* Sitemap XML 생성 Artisan 커맨드
*
* 큐를 통한 비동기 실행 또는 --sync 옵션으로 동기 실행을 지원합니다.
* 큐 드라이버 설정에 따라 비동기/동기 실행을 자동 선택합니다.
* --sync 옵션을 명시하면 큐 드라이버와 무관하게 동기 실행합니다.
*/
class SeoGenerateSitemapCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'seo:generate-sitemap {--sync : 동기 실행}';
protected $signature = 'seo:generate-sitemap {--sync : 큐 드라이버를 무시하고 동기 실행}';
/**
* @var string 커맨드 설명
@@ -29,7 +31,13 @@ class SeoGenerateSitemapCommand extends Command
*/
public function handle(): int
{
if ($this->option('sync')) {
$forceSync = (bool) $this->option('sync');
// queue.default 는 SettingsServiceProvider 가 drivers.queue_driver 와 동기화하되,
// testing 환경에서는 phpunit.xml 값을 보존하므로 격리가 유지된다.
$connection = (string) Config::get('queue.default', 'sync');
$isSyncDriver = $connection === 'sync';
if ($forceSync || $isSyncDriver) {
GenerateSitemapJob::dispatchSync();
$this->info('Sitemap이 생성되었습니다.');
} else {
@@ -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,
]));
@@ -13,7 +13,8 @@ class ListTemplateCommand extends Command
*/
protected $signature = 'template:list
{--type= : 템플릿 타입으로 필터 (admin, user)}
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}';
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}
{--hidden : 숨김(hidden=true) 템플릿도 함께 출력}';
/**
* The console command description.
@@ -40,6 +41,7 @@ class ListTemplateCommand extends Command
$typeFilter = $this->option('type');
$statusFilter = $this->option('status');
$includeHidden = (bool) $this->option('hidden');
// 타입 필터 검증
if ($typeFilter && ! in_array($typeFilter, ['admin', 'user'])) {
@@ -66,6 +68,11 @@ class ListTemplateCommand extends Command
// 설치된 템플릿 추가
foreach ($installedTemplates as $identifier => $template) {
// 숨김 필터: --hidden 미지정 시 hidden=true 템플릿 제외
if (! $includeHidden && ! empty($template['hidden'])) {
continue;
}
// 타입 필터
if ($typeFilter && $template['type'] !== $typeFilter) {
continue;
@@ -99,6 +106,11 @@ class ListTemplateCommand extends Command
// 미설치 템플릿 추가
if (! $statusFilter || $statusFilter === 'uninstalled') {
foreach ($uninstalledTemplates as $identifier => $template) {
// 숨김 필터: --hidden 미지정 시 hidden=true 템플릿 제외
if (! $includeHidden && ! empty($template['hidden'])) {
continue;
}
// 타입 필터
if ($typeFilter && $template['type'] !== $typeFilter) {
continue;
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Template;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\TemplateManager;
use Illuminate\Console\Command;
@@ -11,6 +12,7 @@ use Illuminate\Support\Facades\Log;
class UninstallTemplateCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -57,7 +59,7 @@ class UninstallTemplateCommand extends Command
$this->warn(__('templates.commands.uninstall.confirm_details.layouts', ['count' => $template->layouts()->count()]));
$this->warn(__('templates.commands.uninstall.confirm_details.versions'));
if (! $this->confirm(__('templates.commands.uninstall.confirm_question'), false)) {
if (! $this->unifiedConfirm(__('templates.commands.uninstall.confirm_question'), false)) {
$this->info(__('templates.commands.uninstall.aborted'));
return Command::SUCCESS;
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Template;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\TemplateManager;
use Illuminate\Console\Command;
@@ -11,6 +12,7 @@ use Illuminate\Support\Facades\Log;
class UpdateTemplateCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -140,7 +142,7 @@ class UpdateTemplateCommand extends Command
$this->newLine();
// 확인 프롬프트 (--force 시 건너뜀)
if (! $force && ! $this->confirm(__('templates.commands.update.confirm_question'), false)) {
if (! $force && ! $this->unifiedConfirm(__('templates.commands.update.confirm_question'), false)) {
$this->info(__('templates.commands.update.aborted'));
return Command::SUCCESS;
@@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands\Traits;
use App\Console\Helpers\ConsoleConfirm;
/**
* Laravel Command 컨텍스트에서 표준화된 yes/no 프롬프트를 제공하는 트레이트.
*
* Symfony QuestionHelper 를 거쳐 입력을 받기 때문에 Laravel 테스트의
* expectsQuestion() / expectsConfirmation() 헬퍼와 호환된다. 입력 정규화 및
* 재질문 루프는 ConsoleConfirm::parse() 와 공유한다.
*
* - `--no-interaction` 시: $default 즉시 반환
* - empty 입력 시: $default 반환
* - yes/y → true, no/n → false (대소문자 무시)
* - 그 외 입력: "yes, y, no, n 중 하나로 입력해 주세요." 출력 후 재질문
*/
trait HasUnifiedConfirm
{
/**
* 표준 yes/no 프롬프트.
*
* @param string $question 질문 메시지
* @param bool $default 기본값 (true=[yes], false=[no])
*/
protected function unifiedConfirm(string $question, bool $default = false): bool
{
if (! $this->input->isInteractive()) {
return $default;
}
$hint = $default ? '[yes]' : '[no]';
$prompt = "{$question} (yes/no) {$hint}";
while (true) {
// Symfony QuestionHelper 의 default 표시(`[default]`)를 회피하기 위해 default 를
// null 로 넘긴다. empty 입력 시 Symfony 가 null 반환 → ConsoleConfirm::parse 가
// empty 로 처리하여 자체 default 적용.
$raw = (string) $this->ask($prompt);
$parsed = ConsoleConfirm::parse($raw, $default);
if ($parsed !== null) {
return $parsed;
}
$this->warn(' yes, y, no, n 중 하나로 입력해 주세요.');
}
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Console\Helpers;
/**
* 콘솔 yes/no 프롬프트 표준 헬퍼.
*
* Symfony Console 비의존(fgets 기반) — Laravel Command 외부, upgrade step,
* 단순 PHP 스크립트 어디서나 호출 가능하다. 입력 처리 규칙:
*
* 1. 입력을 trim 후 소문자 정규화한다
* 2. empty 입력은 default 값으로 처리한다
* 3. yes / y → true, no / n → false
* 4. 위 외 입력은 안내 메시지 출력 후 다시 질문한다 (재질문 루프)
* 5. STDIN 미연결(非TTY) 또는 EOF 시 default 를 즉시 반환한다 (CI/spawn 안전망)
*/
final class ConsoleConfirm
{
/**
* 표준 yes/no 프롬프트.
*
* @param string $question 질문 메시지 (말미 안내/콜론은 자동 부여)
* @param bool $default 기본값 (true=[yes], false=[no])
* @param resource|null $stdin 테스트용 STDIN 주입. null 이면 STDIN 사용
* @param callable|null $writer 테스트용 출력 콜백. null 이면 echo 사용
*/
public static function ask(
string $question,
bool $default = false,
$stdin = null,
?callable $writer = null,
): bool {
$writer ??= static fn (string $text) => print $text;
$useStdin = $stdin !== null;
if (! $useStdin && ! self::isTty()) {
return $default;
}
$hint = $default ? '[yes]' : '[no]';
$stream = $useStdin ? $stdin : (defined('STDIN') ? STDIN : null);
if ($stream === null) {
return $default;
}
while (true) {
$writer("{$question} (yes/no) {$hint}: ");
$raw = fgets($stream);
if ($raw === false) {
return $default;
}
$parsed = self::parse($raw, $default);
if ($parsed !== null) {
return $parsed;
}
$writer(" yes, y, no, n 중 하나로 입력해 주세요.\n");
}
}
/**
* 입력 문자열을 yes/no/null 로 정규화한다.
*
* @param string $raw 사용자 입력 원문 (개행 포함 가능)
* @param bool $default empty 입력 시 반환할 값
* @return bool|null true=yes, false=no, null=재질문 필요
*/
public static function parse(string $raw, bool $default): ?bool
{
$answer = strtolower(trim($raw));
if ($answer === '') {
return $default;
}
if ($answer === 'yes' || $answer === 'y') {
return true;
}
if ($answer === 'no' || $answer === 'n') {
return false;
}
return null;
}
/**
* STDIN 이 TTY 인지 검사한다.
*/
private static function isTty(): bool
{
if (! defined('STDIN')) {
return false;
}
if (function_exists('stream_isatty')) {
return @stream_isatty(STDIN);
}
if (function_exists('posix_isatty')) {
return @posix_isatty(STDIN);
}
return false;
}
}
@@ -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;
}
@@ -0,0 +1,34 @@
<?php
namespace App\Contracts\Extension;
/**
* 캐시/스토리지 도메인 분리를 제공하는 확장의 좁은 contract.
*
* AbstractModule / AbstractPlugin 이 공통으로 노출하는 캐시·스토리지
* 접근자를 한 인터페이스로 묶어, AbstractExtensionServiceProvider 가
* 모듈·플러그인 양쪽을 균등하게 다룰 수 있도록 합니다.
*/
interface CacheableExtensionInterface
{
/**
* 확장 식별자를 반환합니다 (vendor-extension 형식).
*
* @return string 확장 식별자
*/
public function getIdentifier(): string;
/**
* 확장 도메인에 격리된 스토리지 드라이버를 반환합니다.
*
* @return StorageInterface 확장 전용 스토리지 인스턴스
*/
public function getStorage(): StorageInterface;
/**
* 확장 도메인에 격리된 캐시 드라이버를 반환합니다.
*
* @return CacheInterface 확장 전용 캐시 인스턴스
*/
public function getCache(): CacheInterface;
}
@@ -0,0 +1,106 @@
<?php
namespace App\Contracts\Extension;
use App\Extension\IdentityVerification\DTO\VerificationChallenge;
use App\Extension\IdentityVerification\DTO\VerificationResult;
use App\Models\User;
/**
* 본인인증 프로바이더 인터페이스 (IdentityVerification / IDV)
*
* 이 계약을 구현하면 메일, KCP, 이니시스, SMS 등 어떤 본인인증 경로도
* 동일한 방식으로 코어 Manager 에 붙일 수 있습니다. IDV 가 적용되는 목적은
* purpose (signup / password_reset / self_update / sensitive_action / 플러그인 등록값) 로 구분합니다.
*/
interface IdentityVerificationInterface
{
/**
* 프로바이더 식별자.
*
* 코어 기본 프로바이더는 `g7:core.*` 접두사를 사용합니다 (예: `g7:core.mail`).
* 플러그인 프로바이더는 자신의 벤더-slug 식별자를 사용합니다 (예: `kcp`, `inicis`).
*/
public function getId(): string;
/**
* 관리자 UI 에 표시되는 라벨.
*/
public function getLabel(): string;
/**
* 지원 채널 목록 (예: ['email', 'sms', 'ipin']).
*
* @return array<int, string>
*/
public function getChannels(): array;
/**
* 채널 키 → 다국어 표시 라벨 맵.
*
* 각 프로바이더는 자신이 지원하는 모든 채널의 표시 라벨을 제공해야 합니다.
* 관리자 이력 화면 등에서 채널 식별자(`email`, `ipin`)를 사람이 읽는
* 이름(`이메일`, `아이핀`)으로 표시하는 데 사용됩니다.
* 라벨은 `getLabel()` 과 동일하게 `__()` 로 다국어 처리하여 언어팩 활성화 시
* 번역이 적용되도록 합니다.
*
* @return array<string, string> 채널 키 → 라벨 맵 (예: ['email' => '이메일'])
*/
public function getChannelLabels(): array;
/**
* 프론트가 challenge 를 렌더하는 방법 힌트.
*
* - text_code : 숫자 코드 입력 UI
* - link : 메일/SMS 링크 클릭 유도
* - external_redirect: 외부 인증 페이지 이동
*/
public function getRenderHint(): string;
/**
* 주어진 목적을 지원하는지 여부.
*
* purpose 는 최소 signup / password_reset / self_update / sensitive_action 을
* 포함하며, 플러그인은 `core.identity.purposes` 필터 훅으로 커스텀 purpose 를 추가할 수 있습니다.
*/
public function supportsPurpose(string $purpose): bool;
/**
* 런타임 설정(API 키/시크릿 등) 기준으로 실제 사용 가능한지 여부.
*/
public function isAvailable(): bool;
/**
* Challenge 를 발행합니다.
*
* @param User|array $target 대상 사용자(로그인 상태) 또는 이메일·전화 배열(가입 전)
* @param array $context origin_type / origin_identifier / origin_policy_key / purpose / ip / user_agent 등
*/
public function requestChallenge(User|array $target, array $context = []): VerificationChallenge;
/**
* Challenge 를 검증합니다.
*
* @param string $challengeId requestChallenge 가 반환한 id
* @param array $input 프로바이더별 입력 (코드, 토큰, 외부 CB 페이로드 등)
* @param array $context origin 정보, 요청 ip/ua 등
*/
public function verify(string $challengeId, array $input, array $context = []): VerificationResult;
/**
* Challenge 를 취소합니다.
*/
public function cancel(string $challengeId): bool;
/**
* 관리자 환경설정 UI 가 반복 렌더하기 위한 설정 스키마.
*
* @return array<string, array{label: string, type: string, default?: mixed, options?: array<mixed>, help?: string}>
*/
public function getSettingsSchema(): array;
/**
* 설정값을 주입한 새 인스턴스를 반환합니다. (withStore/withDisk 불변 복제 패턴 준용)
*/
public function withConfig(array $config): static;
}
+11 -1
View File
@@ -2,7 +2,7 @@
namespace App\Contracts\Extension;
interface ModuleInterface
interface ModuleInterface extends CacheableExtensionInterface
{
/**
* 모듈명 반환 (표시용)
@@ -204,6 +204,16 @@ interface ModuleInterface
*/
public function getLicense(): ?string;
/**
* 관리자 UI 에서 숨김 여부를 반환합니다.
*
* true 반환 시 관리자 모듈 목록 응답에서 기본 제외됩니다.
* CLI 명령, 설치/제거, 업데이트 감지는 영향 받지 않습니다.
*
* @return bool 숨김 여부
*/
public function isHidden(): bool;
/**
* 모듈의 메타데이터를 반환합니다.
*
@@ -2,6 +2,8 @@
namespace App\Contracts\Extension;
use App\Enums\DeactivationReason;
interface ModuleManagerInterface
{
/**
@@ -40,9 +42,16 @@ interface ModuleManagerInterface
*
* @param string $moduleName 비활성화할 모듈명
* @param bool $force 의존 템플릿이 있어도 강제 비활성화 여부
* @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
* @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
* @return array{success: bool, layouts_deleted: int, warning?: bool, dependent_templates?: array, message?: string} 비활성화 결과 및 삭제된 레이아웃 개수
*/
public function deactivateModule(string $moduleName, bool $force = false): array;
public function deactivateModule(
string $moduleName,
bool $force = false,
string $reason = DeactivationReason::Manual->value,
?string $incompatibleRequiredVersion = null,
): array;
/**
* 지정된 모듈을 시스템에서 제거합니다.
+11 -1
View File
@@ -2,7 +2,7 @@
namespace App\Contracts\Extension;
interface PluginInterface
interface PluginInterface extends CacheableExtensionInterface
{
/**
* 플러그인의 고유 식별자를 반환합니다 (vendor-plugin 형식).
@@ -47,6 +47,16 @@ interface PluginInterface
*/
public function getLicense(): ?string;
/**
* 관리자 UI 에서 숨김 여부를 반환합니다.
*
* true 반환 시 관리자 플러그인 목록 응답에서 기본 제외됩니다.
* CLI 명령, 설치/제거, 업데이트 감지는 영향 받지 않습니다.
*
* @return bool 숨김 여부
*/
public function isHidden(): bool;
/**
* 플러그인의 추가 메타데이터를 반환합니다.
*
@@ -2,6 +2,8 @@
namespace App\Contracts\Extension;
use App\Enums\DeactivationReason;
interface PluginManagerInterface
{
/**
@@ -40,9 +42,16 @@ interface PluginManagerInterface
*
* @param string $pluginName 비활성화할 플러그인명
* @param bool $force 의존 템플릿이 있어도 강제 비활성화 여부
* @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
* @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
* @return array{success: bool, layouts_deleted: int, warning?: bool, dependent_templates?: array, message?: string} 비활성화 결과
*/
public function deactivatePlugin(string $pluginName, bool $force = false): array;
public function deactivatePlugin(
string $pluginName,
bool $force = false,
string $reason = DeactivationReason::Manual->value,
?string $incompatibleRequiredVersion = null,
): array;
/**
* 지정된 플러그인을 시스템에서 제거합니다.
@@ -2,6 +2,8 @@
namespace App\Contracts\Extension;
use App\Enums\DeactivationReason;
interface TemplateManagerInterface
{
/**
@@ -74,9 +76,15 @@ interface TemplateManagerInterface
* 지정된 템플릿을 비활성화합니다.
*
* @param string $identifier 비활성화할 템플릿 식별자
* @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
* @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
* @return bool 비활성화 성공 여부
*/
public function deactivateTemplate(string $identifier): bool;
public function deactivateTemplate(
string $identifier,
string $reason = DeactivationReason::Manual->value,
?string $incompatibleRequiredVersion = null,
): bool;
/**
* 템플릿의 의존성을 검증합니다.
@@ -0,0 +1,23 @@
<?php
namespace App\Contracts\Notifications;
/**
* 비회원(게스트) 알림 수신자 계약
*
* user_id 없이 이메일/이름/로케일만 가진 익명 수신자를 1급 수신자로 표현합니다.
* 알림 채널 게이트(GenericNotification::via)는 구체 타입(User 등) 검사 대신
* 이 계약으로 게스트 여부를 판별하여, 채널별 게스트 발송 허용 정책을 적용합니다.
*
* 구현체는 Laravel Notifiable 트레잇을 함께 사용해 회원과 동일한
* `$notifiable->notify()` 발송 경로를 공유합니다.
*/
interface GuestRecipientInterface
{
/**
* 게스트(비회원) 수신자 여부를 반환합니다.
*
* @return bool true = 비회원 수신자
*/
public function isGuest(): bool;
}
@@ -52,4 +52,14 @@ interface ActivityLogRepositoryInterface
* @return Collection 활동 로그 컬렉션
*/
public function getRecent(string $permission, int $limit = 5): Collection;
/**
* 사용자 삭제 시 해당 사용자의 모든 activity_logs.user_id 컬럼을 NULL 로 익명화합니다.
*
* 외래키가 제거된 파티셔닝 호환 스키마에서 직접 익명화 책임을 갖습니다.
*
* @param int $userId 익명화 대상 사용자 ID
* @return int 익명화된 row 수
*/
public function anonymizeUserId(int $userId): int;
}
@@ -0,0 +1,97 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\IdentityMessageDefinition;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
interface IdentityMessageDefinitionRepositoryInterface
{
/**
* ID로 메시지 정의 조회.
*
* @param int $id
* @return IdentityMessageDefinition|null
*/
public function findById(int $id): ?IdentityMessageDefinition;
/**
* (provider, scope_type, scope_value) 조합으로 메시지 정의 조회.
*
* @param string $providerId
* @param string $scopeType
* @param string|null $scopeValue
* @return IdentityMessageDefinition|null
*/
public function findByScope(string $providerId, string $scopeType, ?string $scopeValue = null): ?IdentityMessageDefinition;
/**
* 활성 상태인 (provider, scope_type, scope_value) 메시지 정의 조회.
*
* @param string $providerId
* @param string $scopeType
* @param string|null $scopeValue
* @return IdentityMessageDefinition|null
*/
public function getActiveByScope(string $providerId, string $scopeType, ?string $scopeValue = null): ?IdentityMessageDefinition;
/**
* 모든 활성 메시지 정의 조회.
*
* @return Collection
*/
public function getAllActive(): Collection;
/**
* 활성 메시지 정의의 로케일별 라벨 맵 (N+1 회피용).
*
* 키: "{provider_id}|{scope_type}|{scope_value}", 값: 다국어 라벨
*
* @param string|null $locale
* @return array<string, string>
*/
public function getLabelMap(?string $locale = null): array;
/**
* 전체 메시지 정의 조회.
*
* @return Collection
*/
public function getAll(): Collection;
/**
* 특정 확장의 메시지 정의 목록 조회.
*
* @param string $extensionType
* @param string $extensionIdentifier
* @return Collection
*/
public function getByExtension(string $extensionType, string $extensionIdentifier): Collection;
/**
* 메시지 정의 신규 생성.
*
* @param array $data
* @return IdentityMessageDefinition
*/
public function store(array $data): IdentityMessageDefinition;
/**
* 메시지 정의 수정.
*
* @param IdentityMessageDefinition $definition
* @param array $data
* @return IdentityMessageDefinition
*/
public function update(IdentityMessageDefinition $definition, array $data): IdentityMessageDefinition;
/**
* 페이지네이션 목록 조회.
*
* @param array $filters
* @param int $perPage
* @return LengthAwarePaginator
*/
public function getPaginated(array $filters = [], int $perPage = 20): LengthAwarePaginator;
}
@@ -0,0 +1,69 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\IdentityMessageTemplate;
use Illuminate\Database\Eloquent\Collection;
interface IdentityMessageTemplateRepositoryInterface
{
/**
* ID로 메시지 템플릿 조회.
*
* @param int $id
* @return IdentityMessageTemplate|null
*/
public function findById(int $id): ?IdentityMessageTemplate;
/**
* 정의 ID + 채널로 템플릿 조회.
*
* @param int $definitionId
* @param string $channel
* @return IdentityMessageTemplate|null
*/
public function findByDefinitionAndChannel(int $definitionId, string $channel): ?IdentityMessageTemplate;
/**
* 활성 (정의 ID, 채널) 템플릿 조회.
*
* @param int $definitionId
* @param string $channel
* @return IdentityMessageTemplate|null
*/
public function getActiveByDefinitionAndChannel(int $definitionId, string $channel): ?IdentityMessageTemplate;
/**
* 특정 정의의 전체 템플릿 조회.
*
* @param int $definitionId
* @return Collection
*/
public function getByDefinitionId(int $definitionId): Collection;
/**
* 템플릿 수정.
*
* @param IdentityMessageTemplate $template
* @param array $data
* @return IdentityMessageTemplate
*/
public function update(IdentityMessageTemplate $template, array $data): IdentityMessageTemplate;
/**
* 템플릿 생성 또는 수정 (idempotent upsert).
*
* @param array $attributes
* @param array $values
* @return IdentityMessageTemplate
*/
public function updateOrCreate(array $attributes, array $values): IdentityMessageTemplate;
/**
* 템플릿 신규 생성.
*
* @param array $data
* @return IdentityMessageTemplate
*/
public function create(array $data): IdentityMessageTemplate;
}
@@ -0,0 +1,161 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\IdentityPolicy;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
/**
* identity_policies 테이블 Repository 계약.
*
* 정책은 선언형 Seeder (config/core.php.identity_policies + {벤더}IdentityPolicySeeder) 로 동기화됩니다.
* Repository 는 런타임 조회 + 운영자 UI 편집 경로 두 방향 모두 지원합니다.
*
* @since 7.0.0-beta.4
*/
interface IdentityPolicyRepositoryInterface
{
/**
* key 로 조회합니다.
*
* @param string $key 정책 키 (예: core.auth.signup_before_submit)
*/
public function findByKey(string $key): ?IdentityPolicy;
/**
* key + source_type 조합으로 정책 존재 여부를 확인합니다.
*
* 운영자 UI 의 scope_value 매핑 검증 등 "특정 출처 정책" 만 허용하는 검증 경로에서 사용.
*
* @param string $key 정책 키
* @param string $sourceType 'core' | 'module' | 'plugin' | 'admin'
*/
public function existsByKeyAndSourceType(string $key, string $sourceType): bool;
/**
* id 로 조회합니다.
*
* @param int $id 정책 PK
*/
public function findById(int $id): ?IdentityPolicy;
/**
* scope + target 조합으로 매칭되는 활성 정책들을 반환합니다 (priority DESC 정렬).
*
* @param string $scope 'route' | 'hook'
* @param string $target scope 별 식별자 (route name 또는 hook name)
* @return Collection<int, IdentityPolicy>
*/
public function resolveByScopeTarget(string $scope, string $target): Collection;
/**
* key 존재 시 업데이트, 없으면 생성합니다. Seeder/SyncHelper 가 사용하는 upsert 경로.
*
* @param array<string, mixed> $attributes 정책 속성
* @return IdentityPolicy upsert 된 정책
*/
public function upsertByKey(array $attributes): IdentityPolicy;
/**
* key 기준 업데이트 (운영자 UI 편집 경로).
*
* @param string $key 정책 키
* @param array<string, mixed> $attributes 변경할 속성
* @param array<int, string> $overridesFields user_overrides 에 append 할 필드명들
* @return bool 업데이트 성공 여부
*/
public function updateByKey(string $key, array $attributes, array $overridesFields = []): bool;
/**
* key 기준 삭제 (source_type=admin 인 정책만 허용).
*
* @param string $key 정책 키
* @return bool 삭제 성공 여부
*/
public function deleteByKey(string $key): bool;
/**
* 정책 모델을 영속화합니다 (Eloquent save 위임).
*
* upsert/updateByKey 가 다루지 않는 부분 갱신 경로 (예: user_overrides 부분 복원) 에서 사용합니다.
* Service 가 모델 객체에 직접 변경을 적용한 뒤 본 메서드로 저장 — Service-Repository 경유 패턴 유지.
*
* @param IdentityPolicy $policy 영속화할 모델
* @return bool 저장 성공 여부
*/
public function save(IdentityPolicy $policy): bool;
/**
* source_type+source_identifier 에 속하지 않은 stale 정책을 제거합니다.
*
* @param string $sourceType 'core' | 'module' | 'plugin' | 'admin'
* @param string $sourceIdentifier vendor 식별자 (예: sirsoft-ecommerce, core)
* @param array<int, string> $currentKeys 현재 선언된 key 목록
* @return int 삭제된 행 수
*/
public function cleanupStale(string $sourceType, string $sourceIdentifier, array $currentKeys): int;
/**
* source_type+source_identifier 에 속하면서 currentKeys 에 없는 stale 정책을 조회합니다.
*
* bulk delete(cleanupStale) 와 달리 모델 인스턴스를 반환하므로, 호출 측이 per-model
* delete()(deleted 이벤트 발화 — 라우트 스코프 캐시 flush)와 로깅을 수행할 수 있다.
*
* @param string $sourceType 'core' | 'module' | 'plugin' | 'admin'
* @param string $sourceIdentifier vendor 식별자
* @param array<int, string> $currentKeys 현재 선언된 key 목록
* @return Collection<int, IdentityPolicy> stale 정책 목록
*/
public function findStale(string $sourceType, string $sourceIdentifier, array $currentKeys): Collection;
/**
* 특정 source(확장) 가 등록한 정책 개수를 반환합니다.
*
* 모듈/플러그인 uninstall 모달의 "삭제될 데이터" 표시에 사용.
*
* @param string $sourceType 'core' | 'module' | 'plugin' | 'admin'
* @param string $sourceIdentifier 확장 식별자
*/
public function countBySource(string $sourceType, string $sourceIdentifier): int;
/**
* 목록 조회 (관리자 S1d DataGrid).
*
* @param array<string, mixed> $filters 필터 조건
* @param int $perPage 페이지 크기
* @return LengthAwarePaginator
*/
public function search(array $filters, int $perPage = 20);
/**
* 전체 활성 정책을 반환합니다.
*
* @return Collection<int, IdentityPolicy>
*/
public function allEnabled(): Collection;
/**
* scope='route' 활성 정책을 [target => Collection<IdentityPolicy>] 맵으로 반환합니다.
*
* EnforceIdentityPolicy 미들웨어의 자동 매핑 lookup 진입점으로 사용. 부팅 시 1회 캐싱되며
* IdentityPolicy 모델 saved/deleted 이벤트가 캐시를 즉시 invalidate 합니다.
*
* brace expansion 지원: target 'api.admin.{modules,plugins}.uninstall' 같은 표현은
* 두 개의 라우트명으로 펼쳐 동일 정책 인스턴스를 양쪽에 매핑합니다.
*
* @return array<string, Collection<int, IdentityPolicy>> route name → 매칭 정책 컬렉션
*/
public function getRouteScopeIndex(): array;
/**
* scope='hook' 활성 정책의 target 목록(중복 제거)을 반환합니다.
*
* EnforceIdentityPolicyListener::loadDynamicHookTargets() 가 부팅 시 동적 훅 구독을 위해
* 호출하는 단일 진입점입니다. identity_policies 테이블이 존재하지 않거나 DB 미연결 환경
* (마이그레이션 전 부팅) 에서는 빈 배열을 반환해 부팅을 보호합니다.
*
* @return list<string> 동적 hook target 목록
*/
public function listHookTargets(): array;
}
@@ -0,0 +1,101 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\IdentityVerificationLog;
/**
* identity_verification_logs 테이블 Repository 계약.
*
* Challenge 생명주기(requested/sent/verified/failed/expired/cancelled/policy_violation_logged)의
* 적재·조회·보관주기 파기 책임을 갖습니다.
*
* @since 7.0.0-beta.4
*/
interface IdentityVerificationLogRepositoryInterface
{
/**
* 로그를 생성합니다.
*
* @param array<string, mixed> $attributes 로그 속성
* @return IdentityVerificationLog 생성된 로그
*/
public function create(array $attributes): IdentityVerificationLog;
/**
* id(UUID) 로 조회합니다.
*
* @param string $id 로그 UUID
* @return IdentityVerificationLog|null
*/
public function findById(string $id): ?IdentityVerificationLog;
/**
* id 기준 업데이트.
*
* @param string $id 로그 UUID
* @param array<string, mixed> $attributes 변경할 속성
* @return bool 1건 이상 업데이트되었는지 여부
*/
public function updateById(string $id, array $attributes): bool;
/**
* target_hash + purpose 조합으로 최근 성공한 challenge 를 조회합니다.
* grace_minutes 내 재사용 가능 여부 판정에 사용합니다.
*
* @param string $purpose IDV 목적
* @param int|null $userId 로그인 상태에서는 user_id 우선 매칭
* @param string|null $targetHash user_id 가 null 일 때 대신 매칭하는 sha256 해시
* @param int $withinMinutes grace_minutes (이 분 내 verified 만 매칭)
* @return IdentityVerificationLog|null 매칭 로그 또는 null
*/
public function findRecentVerified(
string $purpose,
?int $userId,
?string $targetHash,
int $withinMinutes,
): ?IdentityVerificationLog;
/**
* 특정 토큰(verification_token) 으로 verified 상태의 challenge 를 찾습니다.
* IdvTokenRule 이 register 검증 시 사용합니다.
*
* @param string $token verification_token
* @param string $purpose IDV 목적 (예: signup)
* @return IdentityVerificationLog|null 매칭 로그 또는 null
*/
public function findVerifiedForToken(string $token, string $purpose): ?IdentityVerificationLog;
/**
* 만료 경과 challenge 를 일괄 expire 처리합니다.
*
* @return int 처리된 행 수
*/
public function expirePastDue(): int;
/**
* 보관주기 경과 로그를 일괄 삭제합니다.
*
* @param int $days 보관 일수 (이보다 오래된 로그가 삭제됨)
* @return int 삭제된 행 수
*/
public function purgeOlderThan(int $days): int;
/**
* 목록 조회 (관리자 인증 이력 화면).
*
* @param array<string, mixed> $filters provider_id/purpose/status/user_id/date_from/date_to 등
* @param int $perPage 페이지 크기
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
public function search(array $filters, int $perPage = 20);
/**
* user_id 백필 (signup 흐름에서 pre-signup challenge → 가입 성공 후 user_id 채움).
*
* @param string $id 로그 UUID
* @param int $userId 채울 user_id
* @return bool 백필 성공 여부 (이미 user_id 가 있으면 false)
*/
public function backfillUserId(string $id, int $userId): bool;
}
@@ -0,0 +1,182 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\LanguagePack;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
/**
* 언어팩 Repository 인터페이스.
*/
interface LanguagePackRepositoryInterface
{
/**
* 식별자로 언어팩을 조회합니다.
*
* @param string $identifier 언어팩 식별자
* @return LanguagePack|null 언어팩 또는 null
*/
public function findByIdentifier(string $identifier): ?LanguagePack;
/**
* ID 로 언어팩을 조회합니다.
*
* @param int $id 언어팩 ID
* @return LanguagePack|null 언어팩 또는 null
*/
public function findById(int $id): ?LanguagePack;
/**
* 모든 활성 언어팩을 조회합니다.
*
* @return Collection<int, LanguagePack> 활성 언어팩 컬렉션
*/
public function getActivePacks(): Collection;
/**
* 특정 슬롯의 활성 언어팩을 조회합니다.
*
* @param string $scope 스코프
* @param string|null $targetIdentifier 대상 확장 식별자
* @param string $locale 로케일
* @param int|null $excludeId 결과에서 제외할 언어팩 id (재설치 시 자기 자신 제외용)
* @return LanguagePack|null 활성 언어팩 또는 null
*/
public function findActiveForSlot(
string $scope,
?string $targetIdentifier,
string $locale,
?int $excludeId = null
): ?LanguagePack;
/**
* 특정 슬롯의 모든 후보 언어팩을 조회합니다 (벤더별).
*
* @param string $scope 스코프
* @param string|null $targetIdentifier 대상 확장 식별자
* @param string $locale 로케일
* @return Collection<int, LanguagePack> 후보 언어팩 컬렉션
*/
public function getPacksForSlot(
string $scope,
?string $targetIdentifier,
string $locale
): Collection;
/**
* 활성 코어 언어팩이 있는 모든 로케일을 반환합니다.
*
* @return array<int, string> 로케일 문자열 배열
*/
public function getActiveCoreLocales(): array;
/**
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다.
*
* @param array<string, mixed> $filters 필터 (scope, target_identifier, locale, status, vendor)
* @param int $perPage 페이지당 건수
* @return LengthAwarePaginator 페이지네이션 결과
*/
public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator;
/**
* 필터링된 언어팩 컬렉션을 페이지네이션 없이 조회합니다.
*
* 미설치 번들 가상 레코드(`lang-packs/_bundled/{identifier}`)와 병합한 뒤
* Service 계층에서 수동 페이지네이션을 수행하기 위해 사용됩니다.
*
* @param array<string, mixed> $filters 필터 (scope, target_identifier, locale, status, vendor, search)
* @return Collection<int, LanguagePack> 필터링된 언어팩 컬렉션
*/
public function getFilteredCollection(array $filters = []): Collection;
/**
* 언어팩을 생성합니다.
*
* @param array<string, mixed> $data 생성 데이터
* @return LanguagePack 생성된 언어팩
*/
public function create(array $data): LanguagePack;
/**
* 언어팩을 갱신합니다.
*
* @param LanguagePack $pack 대상 언어팩
* @param array<string, mixed> $data 갱신 데이터
* @return LanguagePack 갱신된 언어팩
*/
public function update(LanguagePack $pack, array $data): LanguagePack;
/**
* 언어팩을 삭제합니다.
*
* @param LanguagePack $pack 대상 언어팩
* @return bool 삭제 성공 여부
*/
public function delete(LanguagePack $pack): bool;
/**
* 특정 확장(scope, target_identifier)에 연결된 언어팩 전체를 조회합니다.
*
* @param string $scope 스코프
* @param string $targetIdentifier 대상 확장 식별자
* @return Collection<int, LanguagePack> 언어팩 컬렉션
*/
public function getPacksForTarget(string $scope, string $targetIdentifier): Collection;
/**
* 특정 로케일에 속하는 모든 언어팩을 조회합니다 (cascade 삭제 시 사용).
*
* @param string $locale 로케일
* @return Collection<int, LanguagePack> 언어팩 컬렉션
*/
public function getPacksForLocale(string $locale): Collection;
/**
* 번들 manifest 로부터 가상 LanguagePack 인스턴스를 합성합니다 (DB 미저장).
*
* 미설치(uninstalled) 번들의 가상 행 합성 책임을 Repository 로 일원화 — Service 가
* Model 을 직접 인스턴스화하지 않도록 합니다. exists=false 로 표시되며, `bundled_identifier`
* 가상 속성을 함께 채워 Resource 가 행 액션에 노출할 수 있게 합니다.
*
* @param array<string, mixed> $manifest 번들 manifest 데이터
* @param string $bundledIdentifier `lang-packs/_bundled/{이 값}` 디렉토리명
* @return LanguagePack 가상 LanguagePack 인스턴스 (DB 미저장)
*/
public function buildVirtualFromManifest(array $manifest, string $bundledIdentifier): LanguagePack;
/**
* 코어/번들 확장의 lang/{ko,en}/ 디렉토리로부터 가상 보호 LanguagePack 인스턴스를 합성합니다.
*
* 항상 active+protected 로 표시되며 사용자가 install/uninstall/activate/deactivate 할 수 없습니다.
*
* @param string $scope 스코프 (core/module/plugin/template)
* @param string|null $targetIdentifier 대상 확장 식별자 (core 일 때 null)
* @param string $locale 로케일 (예: 'ko', 'en')
* @param string $vendor 벤더 (확장 manifest.vendor 또는 'g7')
* @param string $version 버전
* @param string $langPathRelative lang 디렉토리 상대 경로
* @return LanguagePack 가상 LanguagePack 인스턴스
*/
public function buildVirtualBuiltInPack(
string $scope,
?string $targetIdentifier,
string $locale,
string $vendor,
string $version,
string $langPathRelative,
): LanguagePack;
/**
* 호스트 확장(modules/plugins/templates)의 status + version 행을 조회합니다.
*
* `language_packs.requires.target_version` 검사 등 cross-domain 조회를 Repository
* 경계 안으로 캡슐화하기 위한 메서드입니다. Service 가 DB facade 를 직접 호출하지 않도록 합니다.
*
* @param string $scope 스코프 (module/plugin/template). core 또는 알 수 없는 값은 null 반환.
* @param string $identifier 호스트 확장 식별자
* @return object|null `{status, version}` 객체 또는 행 부재/스코프 미지원 시 null
*/
public function findHostExtensionRow(string $scope, string $identifier): ?object;
}
@@ -0,0 +1,41 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\LanguagePack;
/**
* 언어팩 활성/비활성 시 DB JSON 다국어 컬럼을 일괄 동기화하는 집계 Repository 의 계약.
*
* Permission/Role/Menu/Module/Plugin/Template/NotificationDefinition/NotificationTemplate/
* IdentityMessageDefinition/IdentityMessageTemplate 10 모델의 JSON 컬럼에 대한 locale 키
* 병합/제거를 단일 진입점으로 캡슐화합니다 (Service-Repository 패턴: Listener/Service 가 직접
* 모델 정적 호출을 하지 못하도록 영속 책임을 일원화).
*
* 보존 정책 (활성/비활성 공통):
* - 모델의 `user_overrides` JSON 컬럼에 등록된 (column, locale) 키 또는 column 전체는 sync
* 대상에서 제외 — 사용자가 의도적으로 수정한 값을 보존.
* - 활성 시: locale 키 부재 → 추가, locale 키 존재 + override 미등록 → 덮어쓰기, override 등록 → skip
* - 비활성 시: override 미등록 → 제거, override 등록 → 보존
*
* @since 7.0.0-beta.4
*/
interface LanguagePackTranslationRepositoryInterface
{
/**
* 언어팩의 seed/*.json 을 DB JSON 컬럼에 병합합니다.
*
* @param LanguagePack $pack 활성화된 언어팩
* @param array<string, array<string, mixed>> $seedBundle 엔티티별 seed 데이터 (loadSeed 결과 묶음)
* @return array<int, array<string, mixed>> 감사 로그 항목 (skipped/applied 결정)
*/
public function applySeedFromPack(LanguagePack $pack, array $seedBundle): array;
/**
* 언어팩의 locale 키를 DB JSON 컬럼에서 제거합니다 (user_overrides 컬럼은 보존).
*
* @param LanguagePack $pack 비활성화된 언어팩
* @return array<int, array<string, mixed>> 감사 로그 항목 (preserved/stripped 결정)
*/
public function stripLocaleFromPack(LanguagePack $pack): array;
}
@@ -14,8 +14,8 @@ interface LayoutExtensionRepositoryInterface
/**
* 특정 확장점에 등록된 확장 목록 조회
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @return Collection<int, LayoutExtension>
*/
public function getByExtensionPoint(int $templateId, string $extensionPointName): Collection;
@@ -23,8 +23,8 @@ interface LayoutExtensionRepositoryInterface
/**
* 특정 레이아웃을 타겟으로 하는 오버레이 목록 조회
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @return Collection<int, LayoutExtension>
*/
public function getOverlaysByLayout(int $templateId, string $layoutName): Collection;
@@ -32,8 +32,7 @@ interface LayoutExtensionRepositoryInterface
/**
* 확장 등록
*
* @param array $data 확장 데이터
* @return LayoutExtension
* @param array $data 확장 데이터
*/
public function create(array $data): LayoutExtension;
@@ -42,17 +41,16 @@ interface LayoutExtensionRepositoryInterface
*
* 동일한 조건의 확장이 존재하면 업데이트하고, 없으면 생성합니다.
*
* @param array $attributes 조회 조건 (template_id, extension_type, target_name, source_type, source_identifier)
* @param array $values 생성/업데이트할 값
* @return LayoutExtension
* @param array $attributes 조회 조건 (template_id, extension_type, target_name, source_type, source_identifier)
* @param array $values 생성/업데이트할 값
*/
public function updateOrCreate(array $attributes, array $values): LayoutExtension;
/**
* 출처별 확장 삭제 (soft delete)
*
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @return int 삭제된 레코드 수
*/
public function softDeleteBySource(LayoutSourceType $sourceType, string $identifier): int;
@@ -60,8 +58,8 @@ interface LayoutExtensionRepositoryInterface
/**
* 출처별 확장 복원
*
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @return int 복원된 레코드 수
*/
public function restoreBySource(LayoutSourceType $sourceType, string $identifier): int;
@@ -69,8 +67,8 @@ interface LayoutExtensionRepositoryInterface
/**
* 출처별 확장 영구 삭제
*
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @return int 삭제된 레코드 수
*/
public function forceDeleteBySource(LayoutSourceType $sourceType, string $identifier): int;
@@ -80,9 +78,9 @@ interface LayoutExtensionRepositoryInterface
*
* 특정 extension_point에 대해 템플릿이 오버라이드를 정의했는지 확인합니다.
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @param string $moduleIdentifier 모듈/플러그인 식별자
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @param string $moduleIdentifier 모듈/플러그인 식별자
* @return LayoutExtension|null 템플릿 오버라이드 또는 null
*/
public function findTemplateOverrideForExtensionPoint(
@@ -96,9 +94,9 @@ interface LayoutExtensionRepositoryInterface
*
* 특정 target_layout에 대해 템플릿이 오버라이드를 정의했는지 확인합니다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param string $moduleIdentifier 모듈/플러그인 식별자
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param string $moduleIdentifier 모듈/플러그인 식별자
* @return LayoutExtension|null 템플릿 오버라이드 또는 null
*/
public function findTemplateOverrideForOverlay(
@@ -113,8 +111,8 @@ interface LayoutExtensionRepositoryInterface
* 템플릿 오버라이드가 있는 모듈 확장은 제외하고,
* 오버라이드된 버전과 원본 모듈 확장을 함께 반환합니다.
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @return Collection<int, LayoutExtension>
*/
public function getResolvedExtensionPoints(int $templateId, string $extensionPointName): Collection;
@@ -125,8 +123,8 @@ interface LayoutExtensionRepositoryInterface
* 템플릿 오버라이드가 있는 모듈 확장은 제외하고,
* 오버라이드된 버전과 원본 모듈 확장을 함께 반환합니다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @return Collection<int, LayoutExtension>
*/
public function getResolvedOverlays(int $templateId, string $layoutName): Collection;
@@ -134,15 +132,92 @@ interface LayoutExtensionRepositoryInterface
/**
* 특정 템플릿의 모든 확장 조회
*
* @param int $templateId 템플릿 ID
* @param int $templateId 템플릿 ID
* @return Collection<int, LayoutExtension>
*/
public function getByTemplateId(int $templateId): Collection;
/**
* 특정 템플릿의 확장을 오버라이드 해석을 적용해 조회
*
* 템플릿 오버라이드에 가려져 화면에 적용되지 않는 모듈/플러그인 확장을
* 제외합니다. 레이아웃 편집 화면 트리가 실제 화면에 반영되는 확장만
* 보여주도록 사용합니다.
*
* @param int $templateId 템플릿 ID
* @return Collection<int, LayoutExtension>
*/
public function getResolvedByTemplateId(int $templateId): Collection;
/**
* 템플릿 오버라이드 해석을 적용해 가려진 확장을 제거
*
* 동일한 (extension_type, target_name) 범위의 확장만 전달해야 합니다.
*
* @param Collection<int, LayoutExtension> $extensions 동일 범위 확장 컬렉션
* @return Collection<int, LayoutExtension>
*/
public function resolveOverrides(Collection $extensions): Collection;
/**
* ID로 단일 확장 조회
*
* @param int $extensionId 확장 ID
* @return LayoutExtension|null 확장 모델 또는 null
*/
public function findById(int $extensionId): ?LayoutExtension;
/**
* 특정 출처(모듈/플러그인)의 모든 확장 조회
*
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @return Collection<int, LayoutExtension>
*/
public function getBySource(LayoutSourceType $sourceType, string $identifier): Collection;
/**
* 조회 조건에 일치하는 확장을 조회
*
* @param array $attributes 조회 조건
* @return LayoutExtension|null 일치하는 확장 또는 null
*/
public function findByAttributes(array $attributes): ?LayoutExtension;
/**
* 조회 조건에 일치하는 모든 LIVE 확장을 템플릿 구분 없이 조회 (cross-template 판정용)
*
* @param array $attributes 조회 조건 (template_id 제외)
* @return \Illuminate\Database\Eloquent\Collection<int, LayoutExtension>
*/
public function getAllByAttributesAcrossTemplates(array $attributes): \Illuminate\Database\Eloquent\Collection;
/**
* 확장 업데이트
*
* @param int $extensionId 확장 ID
* @param array $data 업데이트할 데이터
* @return LayoutExtension 업데이트된 확장 모델
*/
public function update(int $extensionId, array $data): LayoutExtension;
/**
* 확장 content + lock_version 동시 갱신 (낙관적 잠금)
*
* Service 가 호출 직전에 expected_lock_version 검증을 마친 상태로,
* 본 메서드는 update 데이터에 lock_version 증가를 함께 반영한다.
*
* @param int $extensionId 확장 ID
* @param array $data 업데이트 데이터 (content, priority 등)
* @param int $newLockVersion 새 lock_version 값 (currentVersion + 1)
* @return LayoutExtension 업데이트된 확장 모델
*/
public function updateWithLock(int $extensionId, array $data, int $newLockVersion): LayoutExtension;
/**
* 특정 템플릿의 모든 확장 삭제
*
* @param int $templateId 템플릿 ID
* @param int $templateId 템플릿 ID
* @return int 삭제된 레코드 수
*/
public function deleteByTemplateId(int $templateId): int;
@@ -0,0 +1,75 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\TemplateLayoutExtensionVersion;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
interface LayoutExtensionVersionRepositoryInterface
{
/**
* 버전 저장 (자동 증가)
*
* @param int $extensionId 레이아웃 확장 ID
* @param array $oldContent 이전 콘텐츠
* @param array|null $newContent 새 콘텐츠 (null이면 현재 확장 content 사용)
* @return TemplateLayoutExtensionVersion 생성된 버전 모델
*/
public function saveVersion(int $extensionId, array $oldContent, ?array $newContent = null): TemplateLayoutExtensionVersion;
/**
* 특정 확장의 모든 버전 조회 (최신순)
*
* @param int $extensionId 레이아웃 확장 ID
* @return Collection 버전 컬렉션
*/
public function getVersions(int $extensionId): Collection;
/**
* 확장 ID 목록의 현재(최신) 버전 번호 맵 조회
*
* 레이아웃 편집기 라우트 트리의 확장 노드 버전 배지 데이터
* 소스로, 버전 이력이 1건 이상인 확장만 포함된다(미저장 확장 = 원본 → 맵 제외).
*
* @param array<int> $extensionIds 확장 ID 목록
* @return array<int, int> 확장 ID → 최신 버전 번호
*/
public function getCurrentVersionsByExtensionIds(array $extensionIds): array;
/**
* 특정 버전 조회
*
* @param int $versionId 버전 ID
* @return TemplateLayoutExtensionVersion|null 찾은 버전 모델 또는 null
*/
public function getVersion(int $versionId): ?TemplateLayoutExtensionVersion;
/**
* 다음 버전 번호 계산
*
* @param int $extensionId 레이아웃 확장 ID
* @return int 다음 버전 번호
*/
public function getNextVersion(int $extensionId): int;
/**
* JSON content 변경사항 카운트 계산 (라인 단위)
*
* @param array $oldContent 이전 콘텐츠
* @param array $newContent 새 콘텐츠
* @return array{added: int, removed: int, char_diff: int} 추가/삭제 라인 수 + 문자 수 변화
*/
public function calculateChanges(array $oldContent, array $newContent): array;
/**
* 버전 복원
*
* @param int $extensionId 레이아웃 확장 ID
* @param int $versionId 복원할 버전 ID
* @return TemplateLayoutExtensionVersion 복원 후 생성된 새 버전 모델
*
* @throws ModelNotFoundException 버전을 찾을 수 없는 경우
*/
public function restoreVersion(int $extensionId, int $versionId): TemplateLayoutExtensionVersion;
}
@@ -43,6 +43,44 @@ interface LayoutRepositoryInterface
*/
public function exists(int $templateId, string $name): bool;
/**
* 특정 템플릿의 레이아웃 중 지정한 확장점(extension_point)을 정의한 것이 있는지 확인
*
* 레이아웃 content JSON 트리를 재귀 순회하여 `type: extension_point` 노드의
* `name` 이 일치하는지 검사합니다.
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @return bool 정의 존재 여부
*/
public function hasExtensionPoint(int $templateId, string $extensionPointName): bool;
/**
* 지정 extension_point 를 포함하는 레이아웃 이름 목록을 반환합니다.
*
* 확장 편집 모드에서 extension_point 확장의 대표 호스트 레이아웃 선택(picker)용. 여러
* 레이아웃에 같은 확장점이 있으면 모두 반환한다.
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @return array<int, string> 호스트 레이아웃 이름 목록
*/
public function findLayoutNamesWithExtensionPoint(int $templateId, string $extensionPointName): array;
/**
* 레이아웃 content 트리에 지정 노드 id 가 존재하는지 확인합니다.
*
* overlay 확장의 호스트 유효성 판정용 — overlay 는 `injections[].target_id` 위치에 주입되므로,
* 호스트 레이아웃에 그 id 노드가 없으면 실제로 주입되지 않는다(`applyExtensions` no-op).
* 노드 식별자는 `id` 또는 `props.id` 두 형태를 모두 검사한다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param string $nodeId 찾을 노드 id
* @return bool 노드 id 존재 여부
*/
public function layoutContainsNodeId(int $templateId, string $layoutName, string $nodeId): bool;
/**
* extends를 가진 자식 레이아웃 조회
*
@@ -61,6 +99,19 @@ interface LayoutRepositoryInterface
*/
public function update(int $id, array $data): TemplateLayout;
/**
* 레이아웃 content + lock_version 동시 갱신 (낙관적 잠금)
*
* Service 가 호출 직전에 expected_lock_version 검증을 마친 상태로,
* 본 메서드는 content 교체와 lock_version 증가를 한 번의 UPDATE 로 수행한다.
*
* @param int $id 레이아웃 ID
* @param array $content 전체 레이아웃 JSON content
* @param int $newLockVersion 새 lock_version 값 (currentVersion + 1)
* @return TemplateLayout 업데이트된 레이아웃 모델
*/
public function updateContent(int $id, array $content, int $newLockVersion): TemplateLayout;
/**
* 특정 레이아웃의 모든 버전 조회
*
@@ -4,6 +4,7 @@ namespace App\Contracts\Repositories;
use App\Models\TemplateLayoutVersion;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
interface LayoutVersionRepositoryInterface
{
@@ -42,11 +43,11 @@ interface LayoutVersionRepositoryInterface
public function getNextVersion(int $layoutId): int;
/**
* JSON content 변경사항 계산
* JSON content 변경사항 카운트 계산 (라인 단위)
*
* @param array $oldContent 이전 콘텐츠
* @param array $newContent 새 콘텐츠
* @return array 변경사항 (added, removed, modified)
* @return array{added: int, removed: int, char_diff: int} 추가/삭제 라인 수 + 문자 수 변화
*/
public function calculateChanges(array $oldContent, array $newContent): array;
@@ -57,7 +58,30 @@ interface LayoutVersionRepositoryInterface
* @param int $versionId 복원할 버전 ID
* @return TemplateLayoutVersion 복원 후 생성된 새 버전 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException 버전을 찾을 수 없는 경우
* @throws ModelNotFoundException 버전을 찾을 수 없는 경우
*/
public function restoreVersion(int $layoutId, int $versionId): TemplateLayoutVersion;
}
/**
* 템플릿의 레이아웃별 현재(최신) 버전 번호 맵 조회
*
* 레이아웃 편집기 좌측 라우트 트리의 버전 배지 데이터 소스로,
* 해당 템플릿에 속한 레이아웃 중 버전 이력이 1건 이상인 레이아웃만 포함된다
* (한 번도 저장된 적 없는 레이아웃 = 원본 상태 → 맵에 없음 → 배지 미표시).
*
* @param int $templateId 대상 템플릿 ID
* @return array<string, int> 레이아웃 이름 → 최신 버전 번호
*/
public function getCurrentVersionsByTemplateId(int $templateId): array;
/**
* 모든 버전의 changes_summary 를 현재 알고리즘으로 재계산하여 갱신합니다.
*
* 측정 알고리즘 변경(키 경로 단위 → 라인 단위) 이전에 저장된 버전들은 옛 기준의
* changes_summary 를 담고 있다. 각 레이아웃의 버전을 버전 순으로 정렬해 인접 쌍으로
* 재계산하여 갱신한다(첫 버전은 baseline 으로 빈 요약).
*
* @return int 갱신된 버전 수
*/
public function recalculateAllChangeSummaries(): int;
}
@@ -193,4 +193,13 @@ interface ModuleRepositoryInterface
* @return Collection 해당 플러그인에 의존하는 활성 모듈 컬렉션
*/
public function findActiveByPluginDependency(string $pluginIdentifier): Collection;
/**
* 코어 버전 비호환으로 자동 비활성화된 모듈을 조회합니다.
*
* `deactivated_reason = 'incompatible_core'` 인 레코드만 반환합니다.
*
* @return Collection 자동 비활성화된 모듈 컬렉션
*/
public function findAutoDeactivated(): Collection;
}
@@ -4,10 +4,19 @@ namespace App\Contracts\Repositories;
use App\Models\NotificationLog;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
interface NotificationLogRepositoryInterface
{
/**
* 최근 발송된 알림 로그를 발송 시각 최신순으로 조회합니다 (대시보드 최근 알림).
*
* @param int $limit 조회 건수
* @return Collection<int, NotificationLog> 최근 알림 로그 컬렉션
*/
public function getRecent(int $limit): Collection;
/**
* ID로 알림 로그 조회.
*/
@@ -157,4 +157,14 @@ interface PluginRepositoryInterface
* @return array 활성화된 플러그인 identifier 배열
*/
public function getActivePluginIdentifiers(): array;
/**
* 코어 버전 비호환으로 자동 비활성화된 플러그인을 조회합니다.
*
* `deactivated_reason = 'incompatible_core'` 인 레코드만 반환합니다.
* 알림 영속화 + 재호환 판정 + 상단 배너 데이터 소스로 사용됩니다.
*
* @return Collection 자동 비활성화된 플러그인 컬렉션
*/
public function findAutoDeactivated(): Collection;
}
@@ -39,6 +39,14 @@ interface RoleRepositoryInterface
*/
public function findByIdentifier(string $identifier): ?Role;
/**
* 식별자로 역할에 소속된 사용자들을 조회합니다.
*
* @param string $identifier 역할 식별자
* @return Collection 역할 소속 사용자 컬렉션
*/
public function getUsersByIdentifier(string $identifier): Collection;
/**
* 새로운 역할을 생성합니다.
*
@@ -101,4 +101,14 @@ interface ScheduleRepositoryInterface
* @return Schedule 복제된 스케줄
*/
public function duplicate(Schedule $schedule): Schedule;
/**
* ID 목록으로 스케줄들을 조회하고 ID 키 맵으로 반환합니다.
*
* Bulk activity log 처리 시 N+1 회피용 단일 쿼리 진입점.
*
* @param array<int, int> $ids 스케줄 ID 목록
* @return Collection<int, Schedule> id => Schedule 매핑
*/
public function findManyByIdsKeyed(array $ids): Collection;
}
@@ -0,0 +1,104 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\TemplateCustomTranslation;
use Illuminate\Database\Eloquent\Collection;
interface TemplateCustomTranslationRepositoryInterface
{
/**
* 특정 템플릿의 커스텀 다국어 키 목록 조회
*
* @param int $templateId 템플릿 ID
* @param string|null $layoutName 레이아웃 이름 필터 (null 이면 전체)
* @param string|null $status 상태 필터 (null 이면 전체)
* @return Collection<int, TemplateCustomTranslation> 커스텀 키 컬렉션
*/
public function getByTemplateId(int $templateId, ?string $layoutName = null, ?string $status = null): Collection;
/**
* 특정 템플릿의 활성 커스텀 다국어 키 조회 (런타임 병합용)
*
* @param int $templateId 템플릿 ID
* @return Collection<int, TemplateCustomTranslation> 활성 커스텀 키 컬렉션
*/
public function getActiveByTemplateId(int $templateId): Collection;
/**
* 특정 템플릿 + 레이아웃의 커스텀 키 조회 (좀비 감지용)
*
* `layout_name` 으로 격리해, 해당 레이아웃 저장 시 그 레이아웃 출처 키만
* 좀비 후보로 검사합니다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름 (원본, 예: board/list)
* @param string|null $status 상태 필터 (null 이면 전체)
* @return Collection<int, TemplateCustomTranslation> 커스텀 키 컬렉션
*/
public function getByTemplateIdAndLayout(int $templateId, string $layoutName, ?string $status = null): Collection;
/**
* 주어진 ID 목록의 상태를 일괄 변경합니다 (active ↔ orphaned 전이).
*
* @param array<int, int> $ids 대상 커스텀 키 ID 목록
* @param string $status 새 상태 (active|orphaned)
* @return int 변경된 행 수
*/
public function updateStatus(array $ids, string $status): int;
/**
* ID로 커스텀 다국어 키 조회
*
* @param int $id 커스텀 키 ID
* @return TemplateCustomTranslation|null 찾은 모델 또는 null
*/
public function findById(int $id): ?TemplateCustomTranslation;
/**
* 템플릿 + 키로 조회
*
* @param int $templateId 템플릿 ID
* @param string $translationKey 다국어 키
* @return TemplateCustomTranslation|null 찾은 모델 또는 null
*/
public function findByKey(int $templateId, string $translationKey): ?TemplateCustomTranslation;
/**
* 특정 템플릿 + 레이아웃의 최대 seq 조회
*
* `custom.{layout}.{seq}` 형식 키에서 seq 의 현재 최대값을 반환합니다.
* 신규 키 생성 시 다음 seq 계산에 사용됩니다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutKey 키 네임스페이스 (레이아웃명 정규화)
* @return int 현재 최대 seq (없으면 0)
*/
public function getMaxSeq(int $templateId, string $layoutKey): int;
/**
* 커스텀 다국어 키 생성
*
* @param array<string, mixed> $data 생성 데이터
* @return TemplateCustomTranslation 생성된 모델
*/
public function create(array $data): TemplateCustomTranslation;
/**
* 커스텀 다국어 키 값 수정 (lock_version 갱신 포함)
*
* @param int $id 커스텀 키 ID
* @param array<string, mixed> $data 수정 데이터
* @param int $newLockVersion 새 잠금 버전
* @return TemplateCustomTranslation 수정된 모델
*/
public function update(int $id, array $data, int $newLockVersion): TemplateCustomTranslation;
/**
* 커스텀 다국어 키 삭제
*
* @param int $id 커스텀 키 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool;
}
@@ -0,0 +1,45 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\TemplateLayoutAttachment;
use Illuminate\Database\Eloquent\Collection;
/**
* 템플릿 레이아웃 첨부 파일 Repository 인터페이스
*/
interface TemplateLayoutAttachmentRepositoryInterface
{
/**
* ID로 첨부 파일 조회
*
* @param int $id 첨부 파일 ID
* @return TemplateLayoutAttachment|null 첨부 파일 또는 null
*/
public function findById(int $id): ?TemplateLayoutAttachment;
/**
* 템플릿(+선택적 레이아웃)별 첨부 파일 목록 조회 (최신순)
*
* @param int $templateId 템플릿 ID
* @param string|null $layoutName 레이아웃 이름 (null 이면 템플릿 전체)
* @return Collection<int, TemplateLayoutAttachment>
*/
public function listForTemplate(int $templateId, ?string $layoutName = null): Collection;
/**
* 첨부 파일 생성
*
* @param array<string, mixed> $data 생성 데이터
* @return TemplateLayoutAttachment 생성된 첨부 파일
*/
public function create(array $data): TemplateLayoutAttachment;
/**
* 첨부 파일 삭제 (DB 행만 — 스토리지 파일 삭제는 Service 책임)
*
* @param TemplateLayoutAttachment $attachment 삭제할 첨부 파일
* @return bool 삭제 성공 여부
*/
public function delete(TemplateLayoutAttachment $attachment): bool;
}
@@ -126,4 +126,13 @@ interface TemplateRepositoryInterface
* @return Collection 해당 플러그인에 의존하는 활성 템플릿 컬렉션
*/
public function findActiveByPluginDependency(string $pluginIdentifier): Collection;
/**
* 코어 버전 비호환으로 자동 비활성화된 템플릿을 조회합니다.
*
* `deactivated_reason = 'incompatible_core'` 인 레코드만 반환합니다.
*
* @return Collection 자동 비활성화된 템플릿 컬렉션
*/
public function findAutoDeactivated(): Collection;
}
@@ -5,6 +5,7 @@ namespace App\Contracts\Repositories;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
interface UserRepositoryInterface
{
@@ -93,4 +94,79 @@ interface UserRepositoryInterface
* @return array 언어별 사용자 수 배열
*/
public function getUsersByLanguage(): array;
}
/**
* UUID 목록으로 사용자들을 조회하고 UUID 키 맵으로 반환합니다.
*
* Bulk activity log 처리 시 N+1 회피용 단일 쿼리 진입점.
*
* @param array<int, string> $uuids 사용자 UUID 목록
* @return Collection<string, User> uuid => User 매핑
*/
public function findManyByUuidsKeyed(array $uuids): Collection;
/**
* UUID 목록으로 사용자들을 조회합니다.
*
* @param array<int, string> $uuids 사용자 UUID 목록
* @return Collection<int, User> 조회된 사용자 컬렉션
*/
public function findManyByUuids(array $uuids): Collection;
/**
* 슈퍼관리자 1명을 조회합니다.
*
* @return User|null 슈퍼관리자 또는 없으면 null
*/
public function findSuperAdmin(): ?User;
/**
* 특정 권한 identifier 를 가진 역할에 소속된 모든 사용자를 조회합니다.
*
* @param string $permissionIdentifier 권한 identifier
* @return Collection<int, User> 권한 보유 사용자 컬렉션
*/
public function findManyByPermissionIdentifier(string $permissionIdentifier): Collection;
/**
* 사용자의 연속 로그인 실패 카운터를 1 증가시킵니다.
*
* `last_failed_login_at` 도 현재 시각으로 갱신하며 새 카운트를 반환합니다.
*
* @param User $user 대상 사용자
* @return int 증가 후 카운트
*/
public function incrementFailedAttempts(User $user): int;
/**
* 사용자의 계정을 지정된 분만큼 잠급니다.
*
* `locked_until` 을 현재 시각 + $minutes 로 설정하고 `failed_login_attempts` 를
* 0 으로 리셋합니다 (다음 잠금 윈도우 시작점). 잠금 해제 시각을 반환합니다.
*
* @param User $user 잠글 사용자
* @param int $minutes 잠금 유지 시간(분)
* @return Carbon 잠금 해제 시각
*/
public function lockAccount(User $user, int $minutes): Carbon;
/**
* 사용자의 모든 로그인 시도 추적 컬럼을 초기화합니다.
*
* 정상 로그인 성공 시 호출됩니다 (`failed_login_attempts=0`,
* `locked_until=null`, `last_failed_login_at=null`).
*
* @param User $user 대상 사용자
*/
public function resetLoginAttempts(User $user): void;
/**
* 사용자의 계정이 현재 시점에 잠금 상태인지 판정합니다.
*
* `locked_until` 이 NULL 이거나 현재 시각보다 과거이면 false 를 반환합니다.
*
* @param User $user 대상 사용자
* @return bool 잠금 여부
*/
public function isLocked(User $user): bool;
}
@@ -0,0 +1,45 @@
<?php
namespace App\Contracts\Seeder;
/**
* 다국어 데이터를 시드하는 시더가 구현해야 할 인터페이스.
*
* 활성 언어팩의 seed/{entity}.json 자동 머지 인프라와 결선되어,
* `seed.{vendor-extension}.{entity}.translations` 필터를 자동 호출.
*
* @since 7.0.0-beta.5
*/
interface TranslatableSeederInterface
{
/**
* 확장 식별자 — `seed.{vendor-extension}.{entity}.translations` 필터 키 구성.
*
* 예: 'sirsoft-board', 'sirsoft-ecommerce'.
* 코어 시더는 빈 문자열 반환 (필터 키: `seed.{entity}.translations`).
*/
public function getExtensionIdentifier(): string;
/**
* Entity 이름 — `seed/{entity}.json` 파일명 (확장자 제외).
*
* 예: 'board_types', 'shipping_carriers'.
*/
public function getTranslatableEntity(): string;
/**
* 시드 entry 매칭 키 컬럼명.
*
* 우선순위: code > slug > key > identifier > id.
*/
public function getMatchKey(): string;
/**
* 기본 데이터 (활성 언어팩 머지 전 원본).
*
* `applyFilters` 호출 후 ja 등 활성 locale 키가 자동 보강된 결과를 시더가 사용.
*
* @return array<int, array<string, mixed>>
*/
public function getDefaults(): array;
}
@@ -0,0 +1,295 @@
<?php
namespace App\Database\Sample;
use App\Enums\IdentityOriginType;
use App\Enums\IdentityVerificationStatus;
use App\Extension\IdentityVerification\IdentityVerificationManager;
use App\Models\IdentityPolicy;
use App\Models\IdentityVerificationLog;
use App\Models\User;
use App\Traits\HasSeederCounts;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* 본인인증 이력 샘플 시더 추상 베이스.
*
* 코어/모듈/플러그인이 각자 영역의 IDV 이력을 채울 수 있도록 공통 골격을 제공한다.
* - 등록된 IdentityPolicy 중 자기 영역 정책만 추려서 사용
* - user_id = 실제 등록된 G7 사용자
* - provider_id = IdentityVerificationManager 에 등록된 실제 프로바이더
* - 상태 분포 = 운영 트래픽 비율 (verified 55, expired 15, failed 12, sent 7,
* cancelled 5, requested 3, policy_violation_logged 3)
* - attempts/expires_at/verified_at/consumed_at = 상태별 라이프사이클 일관성 보장
*
* 서브클래스는 영역 필터(applyPolicyScope) + 카운트 키/기본값 + 라벨을 정의한다.
*/
abstract class AbstractIdentityVerificationLogSampleSeeder extends Seeder
{
use HasSeederCounts;
/**
* 상태별 가중치 (총합 100).
*
* @var array<int, array{0: IdentityVerificationStatus, 1: int}>
*/
protected array $statusBuckets;
/**
* 한국/해외 IP 풀.
*
* @var array<int, string>
*/
protected array $ips = [
'121.78.45.12', '211.234.111.5', '125.142.88.91', '210.94.0.74',
'203.241.185.20', '180.182.50.7', '175.223.18.143', '218.236.42.61',
'14.45.110.222', '112.184.99.180', '61.43.232.18', '59.16.7.205',
'110.45.234.12', '106.247.83.190', '220.86.55.121',
'203.0.113.42', '198.51.100.7', '172.217.27.142',
'8.8.8.8', '1.1.1.1',
];
/**
* 데스크톱/모바일 UA 풀.
*
* @var array<int, string>
*/
protected array $userAgents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (Linux; Android 14; SM-S921N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0',
];
public function __construct()
{
$this->statusBuckets = [
[IdentityVerificationStatus::Verified, 55],
[IdentityVerificationStatus::Expired, 15],
[IdentityVerificationStatus::Failed, 12],
[IdentityVerificationStatus::Sent, 7],
[IdentityVerificationStatus::Cancelled, 5],
[IdentityVerificationStatus::Requested, 3],
[IdentityVerificationStatus::PolicyViolationLogged, 3],
];
}
/**
* IdentityPolicy 쿼리에 영역 필터를 적용한다.
*
* @param Builder $query IdentityPolicy 쿼리
* @return Builder 영역 필터가 적용된 쿼리
*/
abstract protected function applyPolicyScope(Builder $query): Builder;
/**
* 카운트 옵션 키.
*
* @return string 카운트 옵션 키
*/
abstract protected function countKey(): string;
/**
* 기본 생성 건수.
*
* @return int 기본 건수
*/
abstract protected function defaultCount(): int;
/**
* 콘솔 메시지에 사용할 영역 라벨.
*
* @return string 영역 라벨
*/
abstract protected function scopeLabel(): string;
/**
* 시더 실행.
*/
public function run(): void
{
$count = $this->getSeederCount($this->countKey(), $this->defaultCount());
$label = $this->scopeLabel();
$users = User::query()->get(['id', 'name', 'email']);
if ($users->isEmpty()) {
$this->command->warn("사용자 데이터가 없어 {$label} 본인인증 이력 시더를 건너뜁니다.");
return;
}
$policies = $this->applyPolicyScope(IdentityPolicy::query())
->get(['key', 'purpose', 'source_type', 'source_identifier', 'provider_id']);
if ($policies->isEmpty()) {
$this->command->warn("{$label} 영역 IdentityPolicy 가 없어 시더를 건너뜁니다.");
return;
}
$manager = app(IdentityVerificationManager::class);
$providerIds = array_keys($manager->all());
if (empty($providerIds)) {
$this->command->warn("등록된 본인인증 프로바이더가 없어 {$label} 시더를 건너뜁니다.");
return;
}
$this->command->info("{$label} 본인인증 이력 시딩 시작... ({$count}건)");
$ttlMinutes = (int) config('settings.identity.challenge_ttl_minutes', 15);
$maxAttempts = (int) config('settings.identity.max_attempts', 5);
$now = Carbon::now();
$batch = [];
for ($i = 0; $i < $count; $i++) {
$user = $users->random();
$policy = $policies->random();
$providerId = $policy->provider_id ?: $providerIds[array_rand($providerIds)];
$status = $this->pickStatus();
$renderHint = mt_rand(1, 100) <= 70 ? 'text_code' : 'email_link';
$createdAt = $this->randomCreatedAt($now);
[$expiresAt, $verifiedAt, $consumedAt, $attempts] = $this->buildLifecycle(
$status,
$createdAt,
$ttlMinutes,
$maxAttempts,
);
$properties = $renderHint === 'text_code'
? ['code_length' => 6]
: ['link_hint' => 'email_link'];
$metadata = $status === IdentityVerificationStatus::PolicyViolationLogged
? ['violation_reason' => 'fail_mode_log_only']
: ['hint_used' => $renderHint];
$batch[] = [
'id' => (string) Str::uuid(),
'provider_id' => $providerId,
'purpose' => $policy->purpose,
'channel' => 'email',
'user_id' => $user->id,
'target_hash' => hash('sha256', mb_strtolower($user->email)),
'status' => $status->value,
'render_hint' => $renderHint,
'attempts' => $attempts,
'max_attempts' => $maxAttempts,
'ip_address' => $this->ips[array_rand($this->ips)],
'user_agent' => $this->userAgents[array_rand($this->userAgents)],
// 본 시더의 모든 challenge 는 IdentityPolicy enforce 경로를 통한 것이므로
// origin_type 은 'policy' 로 분류한다 (이전 버전에서는 source_type 을 잘못 매핑).
'origin_type' => IdentityOriginType::Policy->value,
'origin_identifier' => $policy->source_identifier,
'origin_policy_key' => $policy->key,
'properties' => json_encode($properties, JSON_UNESCAPED_UNICODE),
'metadata' => json_encode($metadata, JSON_UNESCAPED_UNICODE),
'verification_token' => $status === IdentityVerificationStatus::Verified
? bin2hex(random_bytes(32))
: null,
'expires_at' => $expiresAt,
'verified_at' => $verifiedAt,
'consumed_at' => $consumedAt,
'created_at' => $createdAt,
'updated_at' => $verifiedAt ?? $createdAt,
];
}
foreach (array_chunk($batch, 100) as $chunk) {
IdentityVerificationLog::insert($chunk);
}
$this->command->info("{$label} 본인인증 이력 시딩 완료 ({$count}건)");
}
/**
* 가중치 기반 상태 선택.
*
* @return IdentityVerificationStatus 선택된 상태
*/
protected function pickStatus(): IdentityVerificationStatus
{
$r = mt_rand(1, 100);
$acc = 0;
foreach ($this->statusBuckets as [$status, $weight]) {
$acc += $weight;
if ($r <= $acc) {
return $status;
}
}
return IdentityVerificationStatus::Verified;
}
/**
* 상태별 라이프사이클 일관성 있게 구성.
*
* @param IdentityVerificationStatus $status Challenge 상태
* @param Carbon $createdAt 생성 시각
* @param int $ttlMinutes TTL (분)
* @param int $maxAttempts 최대 시도 횟수
* @return array{0: Carbon|null, 1: Carbon|null, 2: Carbon|null, 3: int} [expires_at, verified_at, consumed_at, attempts]
*/
protected function buildLifecycle(
IdentityVerificationStatus $status,
Carbon $createdAt,
int $ttlMinutes,
int $maxAttempts,
): array {
$expiresAt = (clone $createdAt)->addMinutes($ttlMinutes);
$verifiedAt = null;
$consumedAt = null;
$attempts = 0;
switch ($status) {
case IdentityVerificationStatus::Verified:
$attempts = mt_rand(1, 3);
$verifiedAt = (clone $createdAt)->addSeconds(mt_rand(20, 600));
if (mt_rand(0, 1)) {
$consumedAt = (clone $verifiedAt)->addSeconds(mt_rand(1, 30));
}
break;
case IdentityVerificationStatus::Expired:
$attempts = mt_rand(0, 2);
break;
case IdentityVerificationStatus::Failed:
$attempts = $maxAttempts;
break;
case IdentityVerificationStatus::Cancelled:
$attempts = mt_rand(0, 2);
break;
case IdentityVerificationStatus::Sent:
case IdentityVerificationStatus::Requested:
$attempts = 0;
break;
case IdentityVerificationStatus::PolicyViolationLogged:
$expiresAt = null;
$attempts = 0;
break;
}
return [$expiresAt, $verifiedAt, $consumedAt, $attempts];
}
/**
* 최근 60일 내 임의 생성 시각.
*
* @param Carbon $now 기준 시각
* @return Carbon Challenge 생성 시각
*/
protected function randomCreatedAt(Carbon $now): Carbon
{
return (clone $now)
->subDays(mt_rand(0, 60))
->subHours(mt_rand(0, 23))
->subMinutes(mt_rand(0, 59))
->subSeconds(mt_rand(0, 59));
}
}
@@ -0,0 +1,273 @@
<?php
namespace App\Database\Sample;
use App\Enums\NotificationLogStatus;
use App\Models\NotificationDefinition;
use App\Models\NotificationLog;
use App\Models\User;
use App\Traits\HasSeederCounts;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
/**
* 알림 발송 이력 샘플 시더 추상 베이스.
*
* 코어/모듈/플러그인이 각자 영역의 발송 이력을 채울 수 있도록 공통 골격을 제공한다.
* - 등록된 NotificationDefinition 중 자기 영역 정의만 추려서 사용
* - 수신자/발송자 = 실제 등록된 G7 사용자
* - 상태 분포 = 운영 트래픽 비율 (sent 80%, failed 13%, skipped 7%)
* - sent_at = 최근 60일 분포
*
* 서브클래스는 영역 필터(applyDefinitionScope) + 카운트 키/기본값 + 라벨 + 본문/제목 맵을 정의한다.
*/
abstract class AbstractNotificationLogSampleSeeder extends Seeder
{
use HasSeederCounts;
/**
* 발송 실패 시 사용할 실제 SMTP/메일 게이트웨이 에러 메시지.
*
* @var array<int, string>
*/
protected array $errorMessages = [
'SMTP connection refused: smtp.gmail.com:587',
'Connection timed out after 10s',
'Mailbox unavailable: 550 5.1.1 user unknown',
'TLS handshake failed',
'Rate limit exceeded (provider quota)',
'Recipient address rejected: domain not found',
'Authentication failed: invalid credentials',
'Greylisted, retry later (450 4.2.0)',
];
/**
* 발송 건너뜀 사유.
*
* @var array<int, string>
*/
protected array $skipReasons = [
'Template inactive',
'User opted out',
'Channel disabled by user preference',
'Quiet hours policy applied',
'Duplicate suppression window',
];
/**
* 알림 정의 쿼리에 영역 필터를 적용한다 (예: extension_type='core' 또는 extension_identifier='sirsoft-board').
*
* @param Builder $query NotificationDefinition 쿼리
* @return Builder 영역 필터가 적용된 쿼리
*/
abstract protected function applyDefinitionScope(Builder $query): Builder;
/**
* 카운트 옵션 키 (예: 'core_notification_logs', 'ecommerce_notification_logs').
*
* @return string 카운트 옵션 키
*/
abstract protected function countKey(): string;
/**
* 기본 생성 건수.
*
* @return int 기본 건수
*/
abstract protected function defaultCount(): int;
/**
* 콘솔 메시지에 사용할 영역 라벨 (예: '코어', '이커머스 모듈').
*
* @return string 영역 라벨
*/
abstract protected function scopeLabel(): string;
/**
* 알림 타입별 한국어 제목 맵.
*
* @return array<string, string> [type => subject]
*/
abstract protected function subjectMap(): array;
/**
* 알림 타입별 한국어 본문 빌더 맵.
*
* @return array<string, callable(User, Carbon): string> [type => fn($recipient, $sentAt) => string]
*/
abstract protected function bodyMap(): array;
/**
* 시더 실행.
*/
public function run(): void
{
$count = $this->getSeederCount($this->countKey(), $this->defaultCount());
$label = $this->scopeLabel();
$users = User::query()->get(['id', 'name', 'email']);
if ($users->isEmpty()) {
$this->command->warn("사용자 데이터가 없어 {$label} 알림 발송 이력 시더를 건너뜁니다.");
return;
}
$definitions = $this->applyDefinitionScope(
NotificationDefinition::query()->where('is_active', true)
)->get(['type', 'extension_type', 'extension_identifier', 'channels']);
if ($definitions->isEmpty()) {
$this->command->warn("{$label} 영역 활성 알림 정의가 없어 시더를 건너뜁니다.");
return;
}
$admin = User::query()
->whereHas('roles', fn ($q) => $q->where('identifier', 'admin'))
->first();
$adminId = $admin?->id;
$this->command->info("{$label} 알림 발송 이력 시딩 시작... ({$count}건)");
$now = Carbon::now();
$batch = [];
for ($i = 0; $i < $count; $i++) {
$definition = $definitions->random();
$channel = $this->pickChannel($definition->channels ?? ['mail']);
$recipient = $users->random();
[$status, $error] = $this->randomStatusAndError();
$sentAt = $this->randomSentAt($now);
$batch[] = [
'channel' => $channel,
'notification_type' => $definition->type,
'extension_type' => $definition->extension_type,
'extension_identifier' => $definition->extension_identifier,
'recipient_user_id' => $recipient->id,
'recipient_identifier' => $channel === 'mail'
? $recipient->email
: (string) $recipient->id,
'recipient_name' => $recipient->name,
'sender_user_id' => $this->isAdminBoundType($definition->type) ? null : $adminId,
'subject' => $this->renderSubject($definition->type),
'body' => $this->renderBody($definition->type, $recipient, $sentAt),
'status' => $status->value,
'error_message' => $error,
'source' => 'notification',
'sent_at' => $sentAt,
'created_at' => $sentAt,
'updated_at' => $sentAt,
];
}
foreach (array_chunk($batch, 100) as $chunk) {
NotificationLog::insert($chunk);
}
$this->command->info("{$label} 알림 발송 이력 시딩 완료 ({$count}건)");
}
/**
* 알림 정의에 등록된 채널 중 하나를 선택한다.
*
* @param array<int, string> $channels 활성 채널 배열
* @return string 선택된 채널
*/
protected function pickChannel(array $channels): string
{
if (empty($channels)) {
return 'mail';
}
if (in_array('mail', $channels, true) && in_array('database', $channels, true)) {
return mt_rand(1, 100) <= 60 ? 'mail' : 'database';
}
return $channels[array_rand($channels)];
}
/**
* 가중치 기반 상태/에러 메시지 페어를 반환한다.
*
* @return array{0: NotificationLogStatus, 1: string|null}
*/
protected function randomStatusAndError(): array
{
$rand = mt_rand(1, 100);
if ($rand <= 80) {
return [NotificationLogStatus::Sent, null];
}
if ($rand <= 93) {
return [
NotificationLogStatus::Failed,
$this->errorMessages[array_rand($this->errorMessages)],
];
}
return [
NotificationLogStatus::Skipped,
$this->skipReasons[array_rand($this->skipReasons)],
];
}
/**
* 최근 60일 내 임의 발송 시각을 반환한다.
*
* @param Carbon $now 기준 시각
* @return Carbon 발송 시각
*/
protected function randomSentAt(Carbon $now): Carbon
{
return (clone $now)
->subDays(mt_rand(0, 60))
->subHours(mt_rand(0, 23))
->subMinutes(mt_rand(0, 59))
->subSeconds(mt_rand(0, 59));
}
/**
* 알림 타입이 관리자 수신용인지 (sender_user_id null 처리).
*
* @param string $type 알림 타입
* @return bool 관리자 수신용이면 true
*/
protected function isAdminBoundType(string $type): bool
{
return str_ends_with($type, '_admin');
}
/**
* 알림 타입별 제목 (서브클래스 subjectMap 우선, fallback 은 generic).
*
* @param string $type 알림 타입
* @return string 제목
*/
protected function renderSubject(string $type): string
{
return $this->subjectMap()[$type] ?? "[G7] {$type} 알림";
}
/**
* 알림 타입별 본문 (서브클래스 bodyMap 우선, fallback 은 generic).
*
* @param string $type 알림 타입
* @param User $recipient 수신자
* @param Carbon $sentAt 발송 시각
* @return string 본문
*/
protected function renderBody(string $type, User $recipient, Carbon $sentAt): string
{
$builder = $this->bodyMap()[$type] ?? null;
if ($builder === null) {
return "안녕하세요 {$recipient->name}님,\n\n{$type} 알림이 도착했습니다.";
}
return $builder($recipient, $sentAt);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Enums;
/**
* 확장(모듈, 플러그인, 템플릿) 비활성화 사유 Enum
*
* `plugins/modules/templates.deactivated_reason` 컬럼의 값 도메인.
* 사용자 수동 비활성화와 시스템 자동 비활성화를 DB 레벨에서 구분하여
* UI 라벨링·알림 영속화·재호환 시 원클릭 복구 판정에 사용합니다.
*/
enum DeactivationReason: string
{
/**
* 관리자가 직접 비활성화
*/
case Manual = 'manual';
/**
* 코어 버전 호환성 검사 실패로 시스템이 자동 비활성화
*/
case IncompatibleCore = 'incompatible_core';
/**
* 사람이 읽을 수 있는 라벨 (i18n key 가 아닌 fallback 한국어)
*/
public function label(): string
{
return match ($this) {
self::Manual => '사용자 수동 비활성화',
self::IncompatibleCore => '코어 버전 호환성',
};
}
/**
* 시스템(자동) 트리거 여부
*
* true 인 경우 알림 영속화 + 원클릭 복구 UX 대상이 됩니다.
*/
public function isSystemTriggered(): bool
{
return match ($this) {
self::Manual => false,
self::IncompatibleCore => true,
};
}
/**
* 모든 값 배열
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 유효한 값인지 확인
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Enums;
/**
* 본인인증 메시지 정의 스코프 Enum.
*
* `identity_message_definitions.scope_type` 에 저장되어 메시지 정의가
* provider 기본 / purpose 별 / policy 별 중 어느 계층에 속하는지 분류합니다.
*
* 기존 `IdentityMessageDefinition::SCOPE_*` 상수를 대체합니다.
*
* @since 7.0.0-beta.5
*/
enum IdentityMessageScopeType: string
{
/** Provider 기본 메시지 */
case ProviderDefault = 'provider_default';
/** Purpose 별 메시지 */
case Purpose = 'purpose';
/** Policy 별 메시지 */
case Policy = 'policy';
/**
* 모든 scope_type 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 scope_type 라벨
*/
public function label(): string
{
return __('identity.message.scope_type.'.$this->value);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Enums;
/**
* 본인인증 트리거 출처 유형 Enum.
*
* `identity_verification_logs.origin_type` 에 저장되는 인증 호출 경로 분류.
* 이슈 #297 요구사항 8.3 에 따라 인증이 어디서 시작되었는지 추적합니다.
*
* @since 7.0.0-beta.5
*/
enum IdentityOriginType: string
{
/** 라우트 미들웨어 (가장 흔한 경로) */
case Route = 'route';
/** Service 훅 (예: core.user.before_update) */
case Hook = 'hook';
/** identity_policies 정책 강제 */
case Policy = 'policy';
/** 사용자 정의 미들웨어 */
case Middleware = 'middleware';
/** 클라이언트가 직접 호출한 API */
case Api = 'api';
/** 모듈/플러그인 커스텀 호출 */
case Custom = 'custom';
/** 시스템 자동 (cron 등) */
case System = 'system';
/**
* 모든 origin_type 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 origin_type 라벨
*/
public function label(): string
{
return __('identity.origin_types.'.$this->value);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Enums;
/**
* 본인인증 정책 적용 대상 사용자 Enum.
*
* `identity_policies.applies_to` 에 저장되어 일반 사용자/관리자 중 어느 쪽에
* 정책을 강제할지 결정합니다.
*
* @since 7.0.0-beta.5
*/
enum IdentityPolicyAppliesTo: string
{
/** 일반 사용자 본인 */
case Self_ = 'self';
/** 관리자 */
case Admin = 'admin';
/** 모두 */
case Both = 'both';
/**
* 모든 applies_to 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 applies_to 라벨
*/
public function label(): string
{
return __('identity.policy.applies_to.'.$this->value);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Enums;
/**
* 본인인증 정책 실패 시 동작 Enum.
*
* `identity_policies.fail_mode` 에 저장되어 정책 위반 시 요청을 차단할지
* 감사 로그만 남길지 결정합니다.
*
* @since 7.0.0-beta.5
*/
enum IdentityPolicyFailMode: string
{
/** HTTP 428 차단 (정책 강제) */
case Block = 'block';
/** 감사 로그만 기록하고 요청 통과 */
case LogOnly = 'log_only';
/**
* 모든 fail_mode 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 fail_mode 라벨
*/
public function label(): string
{
return __('identity.policy.fail_mode.'.$this->value);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Enums;
/**
* 본인인증 정책 적용 범위 Enum.
*
* `identity_policies.scope` 에 저장되어 정책이 어떤 단위에 적용되는지 결정합니다.
* 이슈 #297 에서 scope=route 분기가 강화되었으므로 enum 화로 회귀 방지.
*
* @since 7.0.0-beta.5
*/
enum IdentityPolicyScope: string
{
/** 라우트 패턴 매칭 */
case Route = 'route';
/** Service 훅 매칭 */
case Hook = 'hook';
/** 모듈/플러그인 커스텀 키 */
case Custom = 'custom';
/**
* 모든 scope 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 scope 라벨
*/
public function label(): string
{
return __('identity.policy.scope.'.$this->value);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Enums;
/**
* 본인인증 정책 출처 Enum.
*
* `identity_policies.source_type` 에 저장되어 정책이 코어/모듈/플러그인/관리자
* 중 어디서 등록되었는지 분류합니다. AdminIdentityLogIndexRequest 의 source_type
* 필터에도 동일 분류를 사용합니다.
*
* @since 7.0.0-beta.5
*/
enum IdentityPolicySourceType: string
{
/** 코어 */
case Core = 'core';
/** 모듈 */
case Module = 'module';
/** 플러그인 */
case Plugin = 'plugin';
/** 관리자 직접 등록 */
case Admin = 'admin';
/**
* 모든 source_type 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 source_type 라벨
*/
public function label(): string
{
return __('identity.policy.source_type.'.$this->value);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Enums;
/**
* 본인인증 전송 채널 Enum.
*
* `identity_verification_logs.channel` 에 저장되는 채널 분류.
* 현재 코어는 email 만 제공하며 — sms / ipin / kakao 등은 모듈/플러그인 provider 가
* 자체 식별자로 추가합니다. 모듈 채널은 enum 외부의 string 으로 유지되며,
* 본 enum 은 코어 분류만 보장합니다.
*
* 참고: `identity_message_templates.channel` 컬럼은 메시지 템플릿 도메인 분류로
* (`mail` 등) 별개 의미를 가지므로 본 enum 과 매핑되지 않습니다.
*
* @since 7.0.0-beta.5
*/
enum IdentityVerificationChannel: string
{
/** 이메일 */
case Email = 'email';
/**
* 코어 채널 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 코어 채널 여부.
*
* @param string $value 검증할 채널 값
* @return bool 코어 채널 여부
*/
public static function isCore(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 채널 라벨
*/
public function label(): string
{
return __('identity.channels.'.$this->value);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Enums;
/**
* 본인인증 코어 purpose Enum.
*
* 코어가 계약으로 보장하는 4종 — `MailIdentityProvider` 가 모두 지원합니다.
* 모듈/플러그인은 `AbstractModule::getIdentityPurposes()` / `AbstractPlugin::getIdentityPurposes()`
* 로 추가 purpose 를 선언할 수 있으며, 그 값은 `IdentityVerificationManager::declaredPurposes`
* 레지스트리에 string 으로 머지됩니다 — 본 enum 에는 코어 4종만 정의합니다.
*
* @since 7.0.0-beta.5
*/
enum IdentityVerificationPurpose: string
{
/** 회원가입 */
case Signup = 'signup';
/** 비밀번호 재설정 */
case PasswordReset = 'password_reset';
/** 본인 정보 변경 */
case SelfUpdate = 'self_update';
/** 민감 작업 (결제 등) */
case SensitiveAction = 'sensitive_action';
/**
* 코어 purpose 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 코어 정의 purpose 인지 확인합니다 (모듈 declared 는 false).
*
* @param string $value 검증할 purpose 값
* @return bool 코어 purpose 여부
*/
public static function isCore(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 purpose 라벨
*/
public function label(): string
{
return __('identity.purposes.'.$this->value.'.label');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Enums;
/**
* 본인인증 Challenge 생명주기 상태.
*
* 코어 고정 8값 — 로그 분석·모니터링 호환성을 위해 플러그인 확장 불가.
*
* @since 7.0.0-beta.4 (engine-v1.46.0 에서 Processing 추가)
*/
enum IdentityVerificationStatus: string
{
/** Challenge 생성 */
case Requested = 'requested';
/** 발송 완료 (메일·SMS 등) */
case Sent = 'sent';
/**
* 외부 비동기 검증 진행 중.
*
* Stripe Identity / 토스인증 push / 외부 SDK redirect 콜백 등 클라이언트가 즉시 verify 응답을 받지 못하고
* webhook/callback 으로 결과를 기다리는 흐름. 클라이언트는 GET /api/identity/challenges/{id} 폴링으로 상태 추적.
*/
case Processing = 'processing';
/** 검증 성공 */
case Verified = 'verified';
/** 검증 실패 */
case Failed = 'failed';
/** 만료 */
case Expired = 'expired';
/** 사용자/관리자가 취소 */
case Cancelled = 'cancelled';
/** fail_mode=log_only 인 정책이 위반되었으나 요청은 통과한 로그 */
case PolicyViolationLogged = 'policy_violation_logged';
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Enums;
/**
* 언어팩 행 단위 액션 가능 여부 키 Enum.
*
* `LanguagePackResource::abilityMap()` / `LanguagePackCollection::abilityMap()` 가 응답에
* 노출하는 abilities 객체의 키. UI 의 토글/제거/업데이트 버튼 disabled 분기에 사용됩니다.
*/
enum LanguagePackAbility: string
{
/**
* 설치 가능 여부 (미설치 가상 행에서 노출).
*/
case CanInstall = 'can_install';
/**
* 활성화 가능 여부 (status=installed/inactive 에서 가능).
*/
case CanActivate = 'can_activate';
/**
* 비활성화 가능 여부 (status=active 이고 보호 대상 아님).
*/
case CanDeactivate = 'can_deactivate';
/**
* 제거(uninstall) 가능 여부 (DB 행 존재 + 보호 대상 아님).
*/
case CanUninstall = 'can_uninstall';
/**
* 업데이트 가능 여부 (latest_version > version).
*/
case CanUpdate = 'can_update';
/**
* 다국어 라벨을 반환합니다.
*
* @return string 라벨 (lang/{locale}/language_packs.php 의 ability 키)
*/
public function label(): string
{
return __('language_packs.ability.'.$this->value);
}
/**
* 모든 ability 키를 문자열 배열로 반환합니다.
*
* @return array<int, string> ability 키 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 주어진 문자열이 유효한 ability 키인지 확인합니다.
*
* @param string $value 검사할 ability 키
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Enums;
/**
* 언어팩 의존성/호환성 검사 실패 사유 코드 Enum.
*
* `LanguagePackService::assertDependencies()` / `assertTargetExtensionExists()` 등
* 도메인 검증 함수가 차단 사유를 반환할 때 사용합니다. 컨트롤러는 이 코드를
* 다국어 메시지 키로 변환하여 422 응답에 포함합니다.
*/
enum LanguagePackErrorCode: string
{
/**
* 동일 locale 의 코어 언어팩이 active 상태가 아니어서 모듈/플러그인/템플릿 팩을 활성화할 수 없음.
*/
case CoreLocaleMissing = 'core_locale_missing';
/**
* 대상 확장(모듈/플러그인/템플릿)이 설치되어 있지 않음.
*/
case TargetNotInstalled = 'target_not_installed';
/**
* 대상 확장이 설치되어 있지만 비활성 상태임.
*/
case TargetInactive = 'target_inactive';
/**
* 대상 확장 버전이 manifest 의 `requires.target_version` 제약 미만.
*/
case TargetVersionTooOld = 'target_version_too_old';
/**
* 대상 확장 버전이 manifest 의 `requires.target_version` 제약과 불일치.
*/
case TargetVersionMismatch = 'target_version_mismatch';
/**
* 일반 의존성 누락 (manifest 의 `requires.dependencies` 등).
*/
case DependencyMissing = 'dependency_missing';
/**
* 언어팩 설치 디렉토리(`lang-packs/`, `lang-packs/_pending/`)에 쓰기 권한이 없음.
*
* 웹 서버 사용자(www-data 등)가 디렉토리를 생성/이동할 수 없을 때 발생.
* 사용자에게 chmod 안내 메시지를 함께 표시한다 (모듈/플러그인/템플릿 install 과 동일 수준).
*/
case DirectoryNotWritable = 'directory_not_writable';
/**
* 다국어 라벨/메시지를 반환합니다.
*
* @return string 라벨 (lang/{locale}/language_packs.php 의 error_code 키)
*/
public function label(): string
{
return __('language_packs.error_code.'.$this->value);
}
/**
* 모든 에러 코드 값을 문자열 배열로 반환합니다.
*
* @return array<int, string> 에러 코드 문자열 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 주어진 문자열이 유효한 에러 코드 값인지 확인합니다.
*
* @param string $value 검사할 에러 코드 문자열
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Enums;
/**
* 언어팩 출처(origin) 분류 Enum.
*
* `LanguagePackSourceType` 가 7가지 세부 소스 (bundled / bundled_with_extension /
* built_in / github / url / upload / zip) 를 갖는 반면, UI 배지·필터 등에서는 사용자
* 관점의 3분류(빌트인 / 번들 / 사용자 설치) 가 더 직관적이다. 본 Enum 은 그 메타
* 그룹핑을 표현하며 `LanguagePackSourceType::fromSourceType()` 1곳에서 매핑한다.
*
* - `built_in` : 코어/번들 확장의 lang/{locale}/ 가상 보호 행 + bundled_with_extension
* - `bundled` : lang-packs/_bundled/{identifier} 독립 패키지
* - `user_installed` : github / url / upload / zip 출처 외부 설치
*/
enum LanguagePackOrigin: string
{
/**
* 코어/번들 확장의 lang/{locale}/ 자원에서 합성된 가상 보호 행 또는 확장과
* 한 몸으로 등록된 bundled_with_extension 행.
*/
case BuiltIn = 'built_in';
/**
* lang-packs/_bundled/{identifier} 의 독립 번들 패키지 (install/uninstall 자유).
*/
case Bundled = 'bundled';
/**
* 사용자가 GitHub URL / 임의 URL / ZIP 업로드로 설치한 외부 언어팩.
*/
case UserInstalled = 'user_installed';
/**
* `LanguagePackSourceType` 으로부터 origin 을 결정합니다.
*
* @param LanguagePackSourceType $source 세부 소스 타입
* @return self 매칭된 origin
*/
public static function fromSourceType(LanguagePackSourceType $source): self
{
return match ($source) {
LanguagePackSourceType::BuiltIn,
LanguagePackSourceType::BundledWithExtension => self::BuiltIn,
LanguagePackSourceType::Bundled => self::Bundled,
LanguagePackSourceType::Github,
LanguagePackSourceType::Url,
LanguagePackSourceType::Upload,
LanguagePackSourceType::Zip => self::UserInstalled,
};
}
/**
* 문자열 source_type 값으로부터 origin 을 결정합니다 (직렬화 단계 편의 메서드).
*
* @param string|null $sourceType source_type 컬럼 값
* @return self|null 매칭된 origin (유효하지 않은 값은 null)
*/
public static function fromSourceTypeValue(?string $sourceType): ?self
{
if ($sourceType === null || ! LanguagePackSourceType::isValid($sourceType)) {
return null;
}
return self::fromSourceType(LanguagePackSourceType::from($sourceType));
}
/**
* 모든 origin 값을 문자열 배열로 반환합니다.
*
* @return array<int, string> origin 문자열 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 라벨 (lang/{locale}/language_packs.php 의 origin 키)
*/
public function label(): string
{
return __('language_packs.origin.'.$this->value);
}
}

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