17 Commits
Author SHA1 Message Date
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
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
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
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
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
217 changed files with 6919 additions and 2058 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0
APP_VERSION=7.0.1
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0
APP_VERSION=7.0.1
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+12
View File
@@ -776,6 +776,18 @@ 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/` 로 치환한다.
### 빌드 명령어 (Artisan)
```bash
+16
View File
@@ -4,6 +4,22 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.1] - 2026-07-03
### Changed
- 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다.
- 설치가 끝난 사이트에서 매 요청마다 반복되던 불필요한 데이터베이스 조회를 줄여 페이지 응답 속도를 개선했습니다. 알림·본인인증 설정을 준비하는 과정에서 이미 존재가 보장된 테이블을 매번 다시 확인하던 동작을 설치 완료 상태에서는 건너뛰도록 했습니다.
- 화면을 그릴 때마다 서버가 매번 확장 기능의 연결 정보를 처음부터 다시 계산하던 부담을 줄여 페이지 응답 속도를 개선했습니다. 이 연결 정보는 확장을 설치·수정하거나 코어를 업데이트할 때만 바뀌므로 그 시점에 한 번 저장해 두고, 이후 요청에서는 저장된 값을 재사용하도록 했습니다. 확장이나 코어를 변경하면 저장 값이 자동으로 갱신됩니다.
- 설치된 모듈·플러그인의 코드를 찾을 때마다 서버가 파일을 하나하나 탐색하던 부담을 줄여, 특히 공유 호스팅 등 파일 접근이 느린 환경에서 페이지 응답 속도를 개선했습니다. 확장 코드의 위치를 설치·수정 시점에 미리 정리해 두고 이후 요청에서는 그 목록을 바로 참조하도록 했습니다. 확장을 설치·수정·삭제하거나 코어를 업데이트하면 목록이 자동으로 갱신됩니다.
- 코어 업데이트 중 데이터 변환 단계가 현재 환경에서 자동으로 끝까지 실행되지 못하고 중단될 때, 남은 단계를 이어서 실행하는 안내를 실행 권한에 맞게 개선했습니다. 특히 관리자 권한(sudo)으로 업데이트한 경우, 남은 단계를 관리자 권한 그대로 실행하면 그 과정에서 만들어지는 파일이 관리자 소유로 남아 이후 웹사이트가 그 파일에 쓰지 못하는 문제가 생길 수 있습니다. 이제 이런 경우 웹 서버 실행 계정(예: www-data)으로 실행하도록 정확한 명령을 함께 안내하며, 웹 서버 계정을 자동으로 확인하지 못하면 어떤 계정으로 실행해야 하는지 확인 방법을 안내합니다.
### Fixed
- Windows 환경에서 코어 업데이트가 `public/storage` 링크를 복사하는 단계에서 "디렉토리는 복사할 수 없습니다" 오류로 중단되던 문제를 수정했습니다. Windows의 저장소 링크(정션)가 이제 링크 그대로 안전하게 보존되어 업데이트가 정상 완료됩니다.
- 별도 데이터 변환 작업이 필요 없는 버전으로 코어를 업데이트할 때, "실행할 업그레이드 단계가 없다"는 정상 상황을 실패로 표시하고 중단하던 문제를 수정했습니다. 이제 변환 작업이 없는 업데이트는 매끄럽게 완료되며, 이어서 설치된 확장의 최신 번들 반영 단계까지 정상적으로 진행됩니다.
- 일부 호스팅 환경에서 관리자 환경설정의 보안 화면에 들어갈 때 시스템 정보 조회가 전체 실패(오류 응답)하던 문제를 수정했습니다. 이제 CPU·메모리·디스크 등 특정 항목을 서버 정책상 읽을 수 없더라도 그 항목만 "알 수 없음"으로 표시하고 나머지 시스템 정보는 정상적으로 보여줍니다. 읽지 못한 항목은 로그로 원인을 확인할 수 있습니다. (#59 @glitter-gim 님께서 제보해주셨습니다.)
## [7.0.0] - 2026-07-01
### Added
+1 -1
View File
@@ -246,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.0 g7
# (필요 시) mv g7-7.0.1 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
+1 -1
View File
@@ -8,7 +8,7 @@
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.1-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/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>
+97 -15
View File
@@ -85,27 +85,95 @@ 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);
// ── 1) 템플릿 엔진 번들 (편집기 코드 제외) ──────────────────────────────
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
$engineResult = $this->runNpmCommand(['npm', 'run', 'build:core'], $projectPath, true);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info('✅ 코어 빌드 완료 (템플릿 엔진)');
$this->showEngineBuildResults($projectPath);
$this->incrementExtensionCacheVersion();
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);
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);
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;
}
/**
@@ -155,12 +223,26 @@ class BuildCoreCommand extends Command
$this->line(' 빌드 결과:');
// template-engine.min.js 확인
// template-engine.min.js 확인 (일반 페이지 초기 로드 = 이 번들만)
$engineFile = $corePath.'/template-engine.min.js';
if (file_exists($engineFile)) {
$fileSize = number_format(filesize($engineFile) / 1024, 2);
$this->line(" - template-engine.min.js ({$fileSize} KB)");
}
// layout-editor.min.js 확인 (편집기 lazy 번들, /admin/layout-editor/* 진입 시 로드)
$editorFile = $corePath.'/layout-editor.min.js';
if (file_exists($editorFile)) {
$fileSize = number_format(filesize($editorFile) / 1024, 2);
$this->line(" - layout-editor.min.js ({$fileSize} KB, lazy)");
}
// devtools.min.js 확인 (DevTools lazy 번들, 디버그 모드에서만 로드)
$devtoolsFile = $corePath.'/devtools.min.js';
if (file_exists($devtoolsFile)) {
$fileSize = number_format(filesize($devtoolsFile) / 1024, 2);
$this->line(" - devtools.min.js ({$fileSize} KB, lazy/debug)");
}
}
/**
+201 -7
View File
@@ -15,6 +15,7 @@ use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorMode;
use App\Services\CoreUpdateService;
use App\Support\ConfigCacheHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -484,6 +485,13 @@ class CoreUpdateCommand extends Command
$maintenanceEnabled = false;
}
// 모든 파일(코어 config/lang/vendor)이 안착한 뒤 config 캐시를 재생성한다.
// clearAllCaches() 는 흐름 중간에서 stale 캐시를 비우기만 하므로, 여기서
// 재생성하지 않으면 업데이트 후 config:cache 가 비활성 상태로 남아 이후 모든
// 요청이 config 파일을 재파싱한다(성능 손실). ConfigCacheHelper 는 설치 미완료
// 상태를 가드하고 실패를 안전하게 흡수한다.
ConfigCacheHelper::rebuild();
$log('정리 완료');
$bar->finish();
@@ -593,7 +601,7 @@ class CoreUpdateCommand extends Command
$this->line(" 사유: {$e->reason}");
$this->newLine();
$this->info('나머지 스텝을 적용하려면 아래 명령을 실행하세요 (재다운로드 없이 스텝만 실행):');
$this->line(" {$resumeCommand}");
$this->surfaceResumeCommandWithPermissionGuidance($resumeCommand);
$this->newLine();
$this->warn('⚠ 위 명령을 실행하지 않으면 버전 표시는 최신이지만 일부 스텝이 미실행 상태로 남습니다.');
$this->newLine();
@@ -806,6 +814,7 @@ class CoreUpdateCommand extends Command
// 페이로드만 보관한다 (각각 UpgradeHandoffException 재구성 / silent skip 가드용).
$handoffPayload = null;
$stepsExecuted = null;
$stepsDiscovered = null;
while (! feof($pipes[1])) {
$line = fgets($pipes[1]);
if ($line !== false) {
@@ -837,7 +846,13 @@ class CoreUpdateCommand extends Command
$decoded = json_decode($json, true);
if (is_array($decoded) && isset($decoded['count']) && is_int($decoded['count']) && $decoded['count'] >= 0) {
$stepsExecuted = $decoded['count'];
$log('[spawn] 실행된 step 수: '.$stepsExecuted);
// discovered 는 신버전 자식만 발행하는 필드. 구버전 자식(필드 부재)은
// null 로 남아 handleSpawnExit 가 기존(레거시) 판정 경로를 탄다.
if (isset($decoded['discovered']) && is_int($decoded['discovered']) && $decoded['discovered'] >= 0) {
$stepsDiscovered = $decoded['discovered'];
}
$log('[spawn] 실행된 step 수: '.$stepsExecuted
.($stepsDiscovered !== null ? " (발견 {$stepsDiscovered})" : ''));
continue;
}
@@ -852,6 +867,50 @@ class CoreUpdateCommand extends Command
fclose($pipes[2]);
$exitCode = proc_close($process);
return $this->handleSpawnExit(
$exitCode,
$handoffPayload,
$stepsExecuted,
$stepsDiscovered,
$fromVersion,
$toVersion,
$log,
);
}
/**
* spawn 자식 종료 후 exit code · 신호를 해석해 성공/실패/핸드오프를 판정합니다.
*
* 판정 규칙 (exit=0 경우):
* - `[STEPS_EXECUTED]` 신호 미수신 (`$stepsExecuted === null`) → fail-fast.
* 이전 버전 자식 (신호 미발행) 또는 silent skip 의심.
* - executed=0 인데 **discovered>0** → fail-fast. 범위 내에 스텝 파일이 실제로
* 존재하는데 자식이 하나도 실행하지 못함 (gnuboard/g7#28 silent skip — 케이스 A).
* - executed=0 이고 **discovered=0** → 정상 통과. 해당 from~to 범위에 스텝 파일이
* 애초에 없는 릴리즈 (예: 데이터/설정 변경 없는 패치). from<to 여도 실패 아님 (케이스 B).
* - discovered 신호 부재 (`null`, 구버전 자식) + executed=0 + from<to → 레거시 판정 유지
* (fail-fast). 신버전 자식은 항상 discovered 를 발행하므로 이 경로는 구버전 자식 한정.
*
* @param int $exitCode 자식 프로세스 종료 코드
* @param array|null $handoffPayload 파싱된 [HANDOFF] 페이로드 (없으면 null)
* @param int|null $stepsExecuted 실행된 스텝 수 ([STEPS_EXECUTED] 미수신 시 null)
* @param int|null $stepsDiscovered 범위 내 발견된 스텝 파일 수 (구버전 자식은 null)
* @param string $fromVersion 업그레이드 시작 버전
* @param string $toVersion 업그레이드 대상 버전
* @param \Closure $log 로그 수집 콜백
* @return bool spawn 성공 여부 (mode=fallback 실패 시 false — 호출자 in-process fallback)
*
* @throws UpgradeHandoffException 핸드오프 수신 또는 mode=abort 실패 시
*/
private function handleSpawnExit(
int $exitCode,
?array $handoffPayload,
?int $stepsExecuted,
?int $stepsDiscovered,
string $fromVersion,
string $toVersion,
\Closure $log,
): bool {
if ($exitCode === UpgradeHandoffException::EXIT_CODE && $handoffPayload !== null) {
$log("spawn 핸드오프 종료 (exit={$exitCode})");
@@ -866,9 +925,7 @@ class CoreUpdateCommand extends Command
}
if ($exitCode === 0) {
// silent skip 가드 — 자식이 [STEPS_EXECUTED] 신호를 발행하지 않거나, step 0건
// 실행한 채 exit=0 으로 종료한 경우. 이전 버전 자식 (beta.5 이전 디스크) 또는
// 자식이 비정상 종료 직전 silent skip 한 상태로 추정. fail-fast 모드 가드 적용.
// 신호 미수신 — 이전 버전 자식 (신호 미발행) 또는 silent skip 의심. fail-fast.
if ($stepsExecuted === null) {
return $this->failSpawnWithMode(
'spawn 자식이 [STEPS_EXECUTED] 신호 미발행 — 이전 버전 자식 또는 silent skip 의심',
@@ -878,9 +935,27 @@ class CoreUpdateCommand extends Command
);
}
if ($stepsExecuted === 0 && version_compare($fromVersion, $toVersion, '<')) {
// executed=0 + discovered=0 → 범위 내 스텝 파일 부재. 정상 통과 (케이스 B).
// 스텝이 필요 없는 릴리즈(데이터/설정 변경 없음)를 실패로 오판하지 않는다.
if ($stepsExecuted === 0 && $stepsDiscovered === 0) {
$log("spawn 완료 (exit=0, 실행할 스텝 없음 — 범위 내 스텝 파일 부재, from={$fromVersion} to={$toVersion})");
return true;
}
// executed=0 + (discovered>0 또는 discovered 신호 부재) + from<to → fail-fast.
// discovered>0: 스텝 파일이 있는데 자식이 실행 못함 (gnuboard/g7#28 케이스 A).
// discovered=null: 구버전 자식 (discovered 미발행) — 레거시 판정 유지.
if ($stepsExecuted === 0
&& $stepsDiscovered !== 0
&& version_compare($fromVersion, $toVersion, '<')) {
return $this->failSpawnWithMode(
sprintf('spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s)', $fromVersion, $toVersion),
sprintf(
'spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s%s)',
$fromVersion,
$toVersion,
$stepsDiscovered !== null ? ", 발견 {$stepsDiscovered}건" : '',
),
$log,
$fromVersion,
$toVersion,
@@ -1105,6 +1180,125 @@ class CoreUpdateCommand extends Command
]);
}
/**
* 업그레이드 스텝 재실행 명령을 실행 사용자 권한에 맞는 안내와 함께 출력합니다.
*
* 배경: sudo(root) 로 `core:update` 를 실행하면 업그레이드 스텝 spawn 자식이 root 로
* 돌아가고, 그 자식이 실패(proc_open 미지원 · 비정상 종료 · silent skip)하면 스텝이
* 미실행 상태로 남아 운영자에게 재실행을 안내한다. 이때 운영자가 안내받은 명령을 그대로
* root 로 재실행하면 스텝이 만드는 파일·캐시가 root 소유로 생성되어, 이후 웹서버
* (php-fpm www-data 등) 요청이 그 경로에 쓰기 실패한다.
*
* 실행 환경을 4가지로 분기해 안내한다:
* 1. non-root (일반 SSH 사용자 = 파일 소유자) 또는 posix 미지원(Windows 등)
* → 명령만 그대로 안내 (권한 분기 불필요, 공유 호스팅 = 웹서버·PHP·실행 유저 동일 포함).
* 2. root 실행 + 웹서버 계정 식별 가능 + 실행 사용자와 다름
* → `sudo -u {webUser}` 로 재실행 안내 + root 그대로 실행 시 위험 경고.
* 3. root 실행 + 웹서버 계정을 root 로 추정 (root 로 서비스하는 비표준/컨테이너 구성)
* → 권한 문제 없으므로 명령만 그대로 안내.
* 4. root 실행 + 웹서버 계정 식별 불가 (스냅샷/추정 실패)
* → 명령 그대로 안내하되 "웹서버 계정으로 실행" 일반 경고 (계정명 미상).
*
* @param string $resumeCommand 재실행할 `core:execute-upgrade-steps` 명령
*/
private function surfaceResumeCommandWithPermissionGuidance(string $resumeCommand): void
{
[$mode, $webUser] = $this->classifyResumeExecutionContext();
$this->renderResumeGuidance($resumeCommand, $mode, $webUser);
}
/**
* 실행 환경 모드에 따라 재실행 명령과 권한 안내를 콘솔에 출력합니다.
*
* `classifyResumeExecutionContext()` 판정 결과를 입력으로 받아 출력만 담당한다 —
* posix/파일시스템 상태에 의존하지 않으므로 4가지 모드 전부 결정적으로 테스트 가능하다.
*
* @param string $resumeCommand 재실행할 `core:execute-upgrade-steps` 명령
* @param string $mode `classifyResumeExecutionContext()` 가 반환한 실행 환경 모드
* @param string|null $webUser 웹서버 계정명 (`root_web_known` 일 때만 유효)
*/
private function renderResumeGuidance(string $resumeCommand, string $mode, ?string $webUser): void
{
// 케이스 1·3: 권한 분기 불필요 — 명령만 그대로 안내.
// - non_root: 일반 SSH 사용자 / 공유 호스팅(웹서버=PHP=실행 유저 동일) / posix 미지원
// - root_web_symmetric: root 로 서비스하는 구성 → 재실행도 root 로 무해
if ($mode === 'non_root' || $mode === 'root_web_symmetric') {
$this->line(" {$resumeCommand}");
return;
}
// 케이스 2: root 실행 + 웹서버 계정 식별 → 웹서버 권한 재실행 안내.
if ($mode === 'root_web_known' && $webUser !== null) {
$this->line(" sudo -u {$webUser} {$resumeCommand}");
$this->newLine();
$this->warn('⚠ 현재 sudo(root) 로 실행 중입니다. 위 명령을 root 로 그대로 실행하면 업그레이드');
$this->warn(" 스텝이 생성하는 파일이 root 소유가 되어 이후 웹서버({$webUser}) 요청이 쓰기 실패할 수");
$this->warn(" 있습니다. 반드시 웹서버 계정({$webUser})으로 재실행하세요.");
return;
}
// 케이스 4: root 실행 + 웹서버 계정 식별 불가 — 계정명 미상 상태의 일반 경고.
$this->line(" sudo -u <웹서버계정> {$resumeCommand}");
$this->newLine();
$this->warn('⚠ 현재 sudo(root) 로 실행 중입니다. 웹서버 계정을 자동으로 식별하지 못했습니다.');
$this->warn(' 위 명령을 root 로 그대로 실행하면 업그레이드 스텝이 생성하는 파일이 root 소유가 되어');
$this->warn(' 이후 웹서버(php-fpm) 요청이 쓰기 실패할 수 있습니다. 웹서버 계정(예: www-data,');
$this->warn(' nginx, apache)을 확인해 그 계정으로 재실행하세요.');
}
/**
* 재실행 안내의 실행 환경을 분류합니다.
*
* 반환하는 모드:
* - `non_root` : posix 미지원 또는 현재 유효 사용자가 root 가 아님
* (일반 SSH 사용자 = 파일 소유자, 공유 호스팅 대칭 구성 포함).
* - `root_web_known` : root 실행 + 웹서버 계정 식별 성공 + 실행 사용자(root)와 다름.
* - `root_web_symmetric` : root 실행 + 웹서버 계정이 root 로 추정됨 (root 서비스 구성).
* - `root_web_unknown` : root 실행 + 웹서버 계정 추정 실패 (스냅샷/추정 불가).
*
* 웹서버 계정은 `FilePermissionHelper::inferWebServerOwnership()` 이 storage/bootstrap
* 쓰기 영역 소유자로 추정한다.
*
* @return array{0: string, 1: string|null} [모드, 웹서버 계정명 또는 null]
*/
private function classifyResumeExecutionContext(): array
{
if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) {
return ['non_root', null];
}
// root(sudo) 실행이 아니면 일반 SSH 사용자 = 파일 소유자 → 권한 분기 안내 불필요.
// (공유 호스팅에서 웹서버·PHP·실행 유저가 같은 경우도 여기서 non_root 로 처리됨.)
if (posix_geteuid() !== 0) {
return ['non_root', null];
}
[$owner] = FilePermissionHelper::inferWebServerOwnership();
// 추정 실패 (스냅샷 불가) — 계정명 미상 경고 경로.
if ($owner === false) {
return ['root_web_unknown', null];
}
// 웹서버 계정이 root 로 추정됨 — root 로 서비스하는 구성이라 재실행도 root 로 무해.
if ($owner === 0) {
return ['root_web_symmetric', null];
}
$entry = posix_getpwuid($owner);
$name = $entry['name'] ?? null;
// uid 는 나왔지만 이름 해석 실패 — 미상 경로로 처리 (uid 노출은 오히려 혼란).
if ($name === null) {
return ['root_web_unknown', null];
}
return ['root_web_known', $name];
}
/**
* `restoreOwnership()` 직후 누적된 권한 정상화 실패 경고를 콘솔/로그에 즉시 노출합니다.
*
@@ -164,6 +164,7 @@ class ExecuteUpgradeStepsCommand extends Command
}
$stepsExecuted = 0;
$stepsDiscovered = 0;
try {
$service->runUpgradeSteps(
@@ -174,6 +175,9 @@ class ExecuteUpgradeStepsCommand extends Command
$stepsExecuted++;
},
$force,
function (int $discovered) use (&$stepsDiscovered): void {
$stepsDiscovered = $discovered;
},
);
} catch (UpgradeHandoffException $e) {
// 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청했다.
@@ -221,6 +225,7 @@ class ExecuteUpgradeStepsCommand extends Command
// mode=fallback 시 in-process 진행 (`spawn_failure_mode`).
$this->line('[STEPS_EXECUTED] '.json_encode([
'count' => $stepsExecuted,
'discovered' => $stepsDiscovered,
'fromVersion' => $from,
'toVersion' => $to,
], JSON_UNESCAPED_UNICODE));
@@ -242,6 +247,11 @@ class ExecuteUpgradeStepsCommand extends Command
if (! $this->option('skip-cache-clear') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('캐시 정리 (config/route/view/services/packages)');
$service->clearAllCaches();
// clearAllCaches() 는 config:clear 만 하므로, 단독 실행 흐름에서는 config 캐시가
// 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를
// 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다.
// spawn 자식·steps-only 는 이 블록을 스킵하고 부모(CoreUpdateCommand)가 재생성한다.
\App\Support\ConfigCacheHelper::rebuild();
// 업그레이드 스텝 단독 실행 시에도 코어 lang/routes/layout 변경이
// 프론트엔드 캐시 stale 로 가려지지 않도록 `ext.cache_version` bump.
// spawn 자식·steps-only 모드에서는 부모가 처리하므로 스킵.
@@ -3,6 +3,9 @@
namespace App\Console\Commands\Extension;
use App\Extension\ExtensionManager;
use App\Extension\HookCacheManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
class UpdateAutoloadCommand extends Command
@@ -24,8 +27,12 @@ class UpdateAutoloadCommand extends Command
/**
* Execute the console command.
*/
public function handle(ExtensionManager $extensionManager): int
{
public function handle(
ExtensionManager $extensionManager,
HookCacheManager $hookCacheManager,
ModuleManager $moduleManager,
PluginManager $pluginManager,
): int {
$this->info('확장 오토로드 파일을 생성합니다...');
try {
@@ -34,6 +41,16 @@ class UpdateAutoloadCommand extends Command
$this->info('오토로드 파일이 성공적으로 생성되었습니다.');
$this->line(' → bootstrap/cache/autoload-extensions.php');
// 오토로드 캐시와 동일 생명주기의 정적 훅 매핑 캐시도 함께 재생성.
// 코어 업데이트(clearAllCaches → 이 커맨드) 시 코어 리스너 추가/변경/삭제가
// 캐시에 반영되도록 보장한다 (미갱신 시 stale 매핑으로 훅 발화 누락/과잉 위험).
$moduleManager->loadModules();
$pluginManager->loadPlugins();
$hookCacheManager->generate($moduleManager, $pluginManager);
$this->info('정적 훅 매핑 캐시가 재생성되었습니다.');
$this->line(' → bootstrap/cache/hooks.php');
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error('오토로드 파일 생성 중 오류가 발생했습니다: '.$e->getMessage());
@@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use App\Extension\HookCacheManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
/**
* 정적 훅 매핑 캐시 생성 Artisan 커맨드.
*
* 코어(app/Listeners) + 활성 모듈/플러그인의 정적 훅 리스너를 사전 계산하여
* bootstrap/cache/hooks.php 에 저장한다. route:cache 동형 — 코어 리스너를
* 추가/변경하는 코드 배포 시 배포 파이프라인에서 실행한다.
* (확장 install/update 시에는 ExtensionManager 가 자동 재생성하므로 수동 실행 불필요.)
*/
class HooksCacheCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'hooks:cache';
/**
* @var string 커맨드 설명
*/
protected $description = '정적 훅 매핑 캐시 생성 (bootstrap/cache/hooks.php)';
/**
* 커맨드를 실행합니다.
*
* @param HookCacheManager $hookCacheManager 훅 캐시 매니저
* @param ModuleManager $moduleManager 모듈 매니저
* @param PluginManager $pluginManager 플러그인 매니저
* @return int 종료 코드
*/
public function handle(
HookCacheManager $hookCacheManager,
ModuleManager $moduleManager,
PluginManager $pluginManager,
): int {
$moduleManager->loadModules();
$pluginManager->loadPlugins();
$counts = $hookCacheManager->generate($moduleManager, $pluginManager);
$this->info(__('hooks.cache_generated', [
'core' => $counts['core'],
'modules' => $counts['modules'],
'plugins' => $counts['plugins'],
'path' => $hookCacheManager->getCacheFilePath(),
]));
return Command::SUCCESS;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands;
use App\Extension\HookCacheManager;
use Illuminate\Console\Command;
/**
* 정적 훅 매핑 캐시 삭제 Artisan 커맨드.
*
* bootstrap/cache/hooks.php 를 삭제한다. 캐시 삭제 후에는 부팅 시 스캔 폴백으로
* 동작하므로 항상 안전하다 (route:clear 동형).
*/
class HooksClearCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'hooks:clear';
/**
* @var string 커맨드 설명
*/
protected $description = '정적 훅 매핑 캐시 삭제 (bootstrap/cache/hooks.php)';
/**
* 커맨드를 실행합니다.
*
* @param HookCacheManager $hookCacheManager 훅 캐시 매니저
* @return int 종료 코드
*/
public function handle(HookCacheManager $hookCacheManager): int
{
if (! $hookCacheManager->isCached()) {
$this->info(__('hooks.cache_absent'));
return Command::SUCCESS;
}
$hookCacheManager->clear();
$this->info(__('hooks.cache_cleared'));
return Command::SUCCESS;
}
}
+189 -18
View File
@@ -5,6 +5,9 @@ namespace App\Extension;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Extension\Helpers\GithubHelper;
use App\Rules\ValidExtensionIdentifier;
use App\Support\ConfigCacheHelper;
use Composer\Autoload\ClassLoader;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
@@ -37,14 +40,6 @@ class ExtensionManager
$this->autoloadFilePath = base_path('bootstrap/cache/autoload-extensions.php');
}
/**
* 설치된 모듈/플러그인의 오토로드 파일을 생성합니다.
*
* composer.json을 수정하지 않고, bootstrap/cache/autoload-extensions.php 파일을 생성하여
* 런타임에 Composer ClassLoader에 PSR-4 네임스페이스를 등록합니다.
*
* 테스트 환경(APP_ENV=testing)에서는 자동으로 스킵됩니다.
*/
public function updateComposerAutoload(): void
{
// 테스트 환경에서 오토로드 업데이트 스킵 (성능 최적화)
@@ -61,6 +56,41 @@ class ExtensionManager
// 신규 네임스페이스(beta 업그레이드로 추가된 Seeder/Model 등) 의 autoload 가
// 실패하지 않도록 런타임 재등록을 수행한다.
$this->reregisterRuntimeAutoload();
// 정적 훅 매핑 캐시도 오토로드 캐시와 나란히 재생성.
// 훅 매핑은 확장 install/activate/deactivate/uninstall/update 시에만 바뀌므로
// 이 지점에 편승하면 별도 무효화 발굴 없이 오토로드 캐시와 동일 생명주기를 갖는다.
$this->regenerateHookCache();
// config 캐시도 함께 재생성. 확장 설치/삭제/업데이트는 활성 확장 목록을 바꾸므로
// (optimizeSystem 이 config:cache 를 만든 시점의 스냅샷이 stale 해짐), 오토로드/훅
// 캐시와 동일 생명주기로 config 캐시를 재빌드해 캐시가 비활성 상태로 남지 않게 한다.
// activate/deactivate 는 이 메서드를 거치지 않으므로 각 Manager 에서 별도 호출한다.
ConfigCacheHelper::rebuild();
}
/**
* 정적 훅 매핑 캐시(bootstrap/cache/hooks.php)를 재생성합니다.
*
* 모듈/플러그인 리스너 수집을 위해 각 Manager 를 (재)로드한 뒤 HookCacheManager 에 위임한다.
* 생성 실패는 부팅 시 스캔 폴백으로 흡수되므로 확장 업데이트 흐름을 중단시키지 않는다.
*/
protected function regenerateHookCache(): void
{
try {
$moduleManager = app(ModuleManager::class);
$moduleManager->loadModules();
$pluginManager = app(PluginManager::class);
$pluginManager->loadPlugins();
app(HookCacheManager::class)->generate($moduleManager, $pluginManager);
} catch (\Throwable $e) {
// 훅 캐시 생성 실패는 치명적이지 않다 — 부팅 시 스캔 폴백이 안전망.
Log::warning('훅 매핑 캐시 재생성 실패 (스캔 폴백으로 동작)', [
'error' => $e->getMessage(),
]);
}
}
/**
@@ -78,7 +108,7 @@ class ExtensionManager
*/
protected function reregisterRuntimeAutoload(): void
{
if (! class_exists(\Composer\Autoload\ClassLoader::class, false)) {
if (! class_exists(ClassLoader::class, false)) {
return;
}
@@ -86,7 +116,7 @@ class ExtensionManager
return;
}
$loaders = \Composer\Autoload\ClassLoader::getRegisteredLoaders();
$loaders = ClassLoader::getRegisteredLoaders();
if (empty($loaders)) {
return;
}
@@ -135,8 +165,13 @@ class ExtensionManager
$pluginAutoloads['vendor_autoloads']
);
// 확장 소스 classmap 생성 (FQCN → 상대경로).
// 런타임에 ClassLoader::addClassMap() 으로 등록되어 findFile 이 파일시스템 스캔 없이
// 즉시 경로를 반환하도록 한다(성능). 클래스 로딩은 여전히 lazy — 사용 시점에만 include.
$srcClassmap = $this->buildSourceClassmap($psr4);
// 파일 내용 생성
$content = $this->buildAutoloadFileContent($psr4, $classmap, $files, $vendorAutoloads);
$content = $this->buildAutoloadFileContent($psr4, $classmap, $files, $vendorAutoloads, $srcClassmap);
// 디렉토리 확인
$dir = dirname($this->autoloadFilePath);
@@ -151,6 +186,7 @@ class ExtensionManager
'path' => $this->autoloadFilePath,
'psr4_count' => count($psr4),
'classmap_count' => count($classmap),
'src_classmap_count' => count($srcClassmap),
'files_count' => count($files),
'vendor_autoloads_count' => count($vendorAutoloads),
]);
@@ -164,7 +200,7 @@ class ExtensionManager
* @param array $files 헬퍼 파일 목록
* @return string PHP 파일 내용
*/
protected function buildAutoloadFileContent(array $psr4, array $classmap, array $files = [], array $vendorAutoloads = []): string
protected function buildAutoloadFileContent(array $psr4, array $classmap, array $files = [], array $vendorAutoloads = [], array $srcClassmap = []): string
{
$generatedAt = now()->toDateTimeString();
@@ -173,12 +209,14 @@ class ExtensionManager
$classmapJson = json_encode($classmap, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$filesJson = json_encode($files, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$vendorAutoloadsJson = json_encode($vendorAutoloads, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$srcClassmapJson = json_encode($srcClassmap, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// JSON을 PHP 배열 문법으로 변환
$psr4Php = $this->jsonToPhpArray($psr4Json, 1);
$classmapPhp = $this->jsonToPhpArray($classmapJson, 1);
$filesPhp = $this->jsonToPhpArray($filesJson, 1);
$vendorAutoloadsPhp = $this->jsonToPhpArray($vendorAutoloadsJson, 1);
$srcClassmapPhp = $this->jsonToPhpArray($srcClassmapJson, 1);
return <<<PHP
<?php
@@ -195,6 +233,7 @@ class ExtensionManager
return [
'psr4' => {$psr4Php},
'classmap' => {$classmapPhp},
'src_classmap' => {$srcClassmapPhp},
'files' => {$filesPhp},
'vendor_autoloads' => {$vendorAutoloadsPhp},
];
@@ -240,7 +279,7 @@ PHP;
*
* public/index.php 및 artisan에서 호출됩니다.
*
* @param \Composer\Autoload\ClassLoader $loader Composer ClassLoader 인스턴스
* @param ClassLoader $loader Composer ClassLoader 인스턴스
*/
public static function registerExtensionAutoload($loader): void
{
@@ -266,6 +305,17 @@ PHP;
}
}
// 확장 소스 classmap 등록 (FQCN → 절대경로).
// findFile 이 파일시스템 스캔 없이 즉시 반환하도록 in-memory 맵에 추가한다(lazy include).
// PSR-4 등록은 위에서 유지되므로 classmap 에 없는 클래스는 PSR-4 폴백(안전망).
if (! empty($autoloads['src_classmap'])) {
$absoluteClassmap = [];
foreach ($autoloads['src_classmap'] as $fqcn => $relPath) {
$absoluteClassmap[$fqcn] = base_path($relPath);
}
$loader->addClassMap($absoluteClassmap);
}
// Classmap 파일 로드 (module.php, plugin.php)
if (! empty($autoloads['classmap'])) {
foreach ($autoloads['classmap'] as $file) {
@@ -471,6 +521,130 @@ PHP;
return ['psr4' => $psr4, 'classmap' => $classmap, 'files' => $files, 'vendor_autoloads' => $vendorAutoloads];
}
/**
* PSR-4 매핑을 기반으로 확장 소스 클래스의 classmap 을 생성합니다.
*
* 각 PSR-4 네임스페이스 디렉토리를 재귀 스캔하여 PHP 파일마다 FQCN → 상대경로 를
* 산출한다. 이 classmap 은 런타임에 `ClassLoader::addClassMap()` 으로 등록되어
* `findFile()` 이 파일시스템 스캔 없이 즉시 경로를 반환하도록 한다(성능: cold OPcache /
* 느린 파일시스템 환경에서 findFile stat 비용 제거). 클래스 로딩은 여전히 지연(lazy)이다.
*
* 확장 **소스** 클래스만 대상으로 하며, 확장별 독립 vendor(서드파티)는 각 확장의
* `vendor/autoload.php`(별도 ClassLoader) 가 담당하므로 여기서 다루지 않는다
* (vendor 격리 불변조건 보존).
*
* @param array<string, string|array<int, string>> $psr4 네임스페이스 → base_path 상대경로(들)
* @return array<string, string> FQCN → base_path 상대경로 (.php 포함)
*/
protected function buildSourceClassmap(array $psr4): array
{
$classmap = [];
foreach ($psr4 as $namespace => $paths) {
foreach ((array) $paths as $relativePath) {
$absoluteDir = base_path($relativePath);
if (! is_dir($absoluteDir)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($absoluteDir, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$fqcn = $this->extractFqcnFromFile($file->getPathname());
if ($fqcn === null) {
continue;
}
// PSR-4 계약상 이 파일의 네임스페이스는 $namespace 로 시작해야 한다.
// (다른 네임스페이스 파일은 다른 PSR-4 항목이 담당 — 중복 방지)
if (! str_starts_with($fqcn, rtrim($namespace, '\\'))) {
continue;
}
// base_path 상대경로로 정규화 (autoload-extensions.php 의 다른 경로와 일관)
$rel = str_replace('\\', '/', substr($file->getPathname(), strlen(base_path()) + 1));
$classmap[$fqcn] = $rel;
}
}
}
ksort($classmap);
return $classmap;
}
/**
* PHP 파일에서 FQCN(네임스페이스 + 클래스/인터페이스/트레이트/enum 명)을 추출합니다.
*
* 토큰 기반 파싱으로 파일당 최상위 선언 1개의 FQCN 을 반환한다. 선언이 없으면 null.
*
* @param string $filePath PHP 파일 절대경로
* @return string|null FQCN 또는 null
*/
protected function extractFqcnFromFile(string $filePath): ?string
{
$contents = @file_get_contents($filePath);
if ($contents === false) {
return null;
}
$tokens = token_get_all($contents);
$namespace = '';
$count = count($tokens);
for ($i = 0; $i < $count; $i++) {
$token = $tokens[$i];
if (! is_array($token)) {
continue;
}
// 네임스페이스 수집
if ($token[0] === T_NAMESPACE) {
$namespace = '';
for ($j = $i + 1; $j < $count; $j++) {
$t = $tokens[$j];
if ($t === ';' || $t === '{') {
break;
}
if (is_array($t) && in_array($t[0], [T_STRING, T_NS_SEPARATOR], true)) {
$namespace .= $t[1];
} elseif (is_array($t) && defined('T_NAME_QUALIFIED') && $t[0] === T_NAME_QUALIFIED) {
$namespace .= $t[1];
}
}
continue;
}
// 최상위 선언(class/interface/trait/enum) 감지
if (in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT], true)
|| (defined('T_ENUM') && $token[0] === T_ENUM)) {
// ::class, new class 등 익명/상수 사용 배제: 다음 유효 토큰이 T_STRING 이어야 함
for ($j = $i + 1; $j < $count; $j++) {
$t = $tokens[$j];
if (is_array($t) && $t[0] === T_WHITESPACE) {
continue;
}
if (is_array($t) && $t[0] === T_STRING) {
$class = $t[1];
return $namespace !== '' ? $namespace.'\\'.$class : $class;
}
break;
}
}
}
return null;
}
/**
* 모듈 identifier를 네임스페이스로 변환합니다.
*
@@ -640,7 +814,7 @@ PHP;
$failed = false;
$message = '';
(new \App\Rules\ValidExtensionIdentifier)->validate(
(new ValidExtensionIdentifier)->validate(
'identifier',
$identifier,
function ($msg) use (&$failed, &$message) {
@@ -718,7 +892,6 @@ PHP;
return ! empty($this->getComposerDependenciesAt($path));
}
/**
* 확장의 composer.json에서 외부 패키지 의존성 목록을 반환합니다.
*
@@ -1096,7 +1269,6 @@ PHP;
*
* @param string $zipPath ZIP 파일 경로
* @param string $extractDir 추출 대상 디렉토리
* @return void
*
* @throws \RuntimeException 추출 실패 시
*/
@@ -1116,7 +1288,6 @@ PHP;
*
* @param string $zipPath ZIP 파일 경로
* @param string $extractDir 추출 대상 디렉토리
* @return void
*
* @throws \RuntimeException 추출 실패 시
*/
@@ -1138,7 +1309,7 @@ PHP;
/**
* unzip 명령어 사용 가능 여부를 확인합니다.
*
* @return bool
* @return bool unzip 실행 파일이 PATH 에 존재하면 true
*/
public function isUnzipAvailable(): bool
{
+96 -28
View File
@@ -53,15 +53,25 @@ class FilePermissionHelper
$destPath = $destination.DIRECTORY_SEPARATOR.$itemName;
// symlink 자체 보존: target 추적 없이 동일 symlink 재생성.
// symlink / Windows junction 자체 보존: target 추적 없이 동일 링크 재생성.
// SplFileInfo::isDir() 가 symlink 를 추적하므로 검사 순서가 isDir() 보다 먼저여야 한다.
// 미적용 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상.
if ($item->isLink()) {
if (static::copySymlink($item->getPathname(), $destPath)) {
// 미적용 시 `public/storage` 등 링크가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상.
//
// Windows JUNCTION(`mklink /J`)은 reparse point 지만 PHP `isLink()`/`isDir()` 이 모두
// false 를 반환한다(`storage:link` 가 Windows 에서 생성하는 형태). isLink() 만 검사하면
// junction 이 파일로 오판되어 copyFile → `copy(디렉토리)` 로 코어 업데이트가 실패한다.
// isReparsePoint() 로 junction 도 이 분기에 포함시킨다.
if ($item->isLink() || static::isReparsePoint($item->getPathname())) {
if (static::copyLink($item->getPathname(), $destPath)) {
continue;
}
// 복원 실패 (Windows SeCreateSymbolicLink 권한 부족 등) — 일반 복사로 fall-through.
// 단, junction 은 isDir() 이 false 라 아래 else 분기의 copyFile 로 새어 파일 복사가
// 다시 실패하므로, fall-through 대신 skip 하여 손상 없이 넘어간다.
// 운영자가 추후 `php artisan storage:link` 수동 실행으로 회복 가능.
if (! $item->isLink()) {
continue;
}
}
if ($item->isDir()) {
@@ -79,25 +89,63 @@ class FilePermissionHelper
}
/**
* symlink 자체를 보존 복사합니다 (target 미추적).
* 경로가 Windows JUNCTION 등 PHP `isLink()` 가 인식하지 못하는 reparse point 인지 판정합니다.
*
* 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 symlink 재생성.
* readlink / symlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through.
* Windows JUNCTION(`mklink /J`)은 디렉토리 reparse point 로, `is_link()` / `is_file()` /
* `is_dir()` 이 **모두** false 를 반환한다(그러나 경로 항목으로는 존재하며 `readlink()` 로
* target 을 얻을 수 있다). `php artisan storage:link` 가 Windows 에서 생성하는
* `public/storage` 가 이 형태다.
*
* Windows 환경에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하며 일반 사용자는
* 권한이 없을 가능성이 높다. 실패 시 warning 로그 후 false 반환하여 호출자가 일반 복사로
* fall-through 하도록 한다. 운영자는 업그레이드 후 `php artisan storage:link` 등 수동
* 명령으로 symlink 를 회복할 수 있다.
* 주의: Windows 의 `readlink()` 는 일반 파일/디렉토리에도 자기 경로를 반환하므로 junction
* 판별 기준이 될 수 없다. 따라서 "존재하는 경로인데 link/file/dir 어디에도 해당하지 않음"
* 을 판정 기준으로 삼는다. 여기에 추가로 `readlink()` 성공(!== false)을 요구해 존재하지
* 않는 경로를 배제한다.
*
* @param string $source 소스 symlink 경로
* @param string $destination 대상 symlink 경로
* @return bool symlink 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백)
* 일반 symlink(`is_link()` true)는 호출부가 별도로 처리하므로 여기서는 false 를 반환한다.
* junction 은 Windows 전용이므로 비-Windows 에서는 항상 false.
*
* @param string $path 검사할 경로
* @return bool junction 등 isLink() 미인식 reparse point 여부
*/
protected static function copySymlink(string $source, string $destination): bool
protected static function isReparsePoint(string $path): bool
{
if (PHP_OS_FAMILY !== 'Windows') {
return false;
}
// 일반 symlink / 파일 / 디렉토리는 각 검사가 true → junction 아님.
if (is_link($path) || is_file($path) || is_dir($path)) {
return false;
}
// 위 셋에 모두 해당하지 않으면서 readlink 가 target 을 반환하면 junction(reparse point).
return @readlink($path) !== false;
}
/**
* symlink 또는 Windows junction 자체를 보존 복사합니다 (target 미추적).
*
* 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 링크 재생성.
* readlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through.
*
* 재생성 전략:
* - 일반 symlink: `symlink()`
* - target 이 디렉토리인 경우(Windows junction 포함): `symlink()` 실패 시 `mklink /J` 로 폴백.
* Windows 에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하지만 junction 은
* 권한 없이 생성 가능하므로, junction 복원의 실질 경로가 된다.
*
* 모든 재생성 시도 실패 시 warning 로그 후 false 반환. 운영자는 업그레이드 후
* `php artisan storage:link` 등 수동 명령으로 링크를 회복할 수 있다.
*
* @param string $source 소스 링크(symlink/junction) 경로
* @param string $destination 대상 링크 경로
* @return bool 링크 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백)
*/
protected static function copyLink(string $source, string $destination): bool
{
$target = @readlink($source);
if ($target === false) {
Log::warning('copySymlink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]);
Log::warning('copyLink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]);
return false;
}
@@ -109,17 +157,29 @@ class FilePermissionHelper
File::deleteDirectory($destination);
}
if (! @symlink($target, $destination)) {
Log::warning('copySymlink: symlink 생성 실패 — 일반 복사로 폴백', [
'source' => $source,
'destination' => $destination,
'target' => $target,
]);
return false;
if (@symlink($target, $destination)) {
return true;
}
return true;
// symlink 실패 + target 이 디렉토리 → Windows junction 으로 폴백 재생성.
if (PHP_OS_FAMILY === 'Windows' && is_dir($target)) {
exec(
'cmd /c mklink /J '.escapeshellarg($destination).' '.escapeshellarg($target).' 2>&1',
$out,
$code
);
if ($code === 0 && is_dir($destination)) {
return true;
}
}
Log::warning('copyLink: 링크 생성 실패 — 일반 복사로 폴백', [
'source' => $source,
'destination' => $destination,
'target' => $target,
]);
return false;
}
/**
@@ -157,11 +217,19 @@ class FilePermissionHelper
// 소스에 존재하지 않는 항목만 삭제
if (! File::exists($srcPath) && ! File::isDirectory($srcPath)) {
// symlink 는 항상 unlink — File::deleteDirectory 는 is_dir() 추적 검사 후
// 재귀 삭제하므로 symlink-to-dir 인 경우 target 의 모든 파일을 삭제하는 사고
// 발생 가능. is_link() 가 isDir() 보다 먼저 평가되어야 한다.
// symlink / Windows junction 은 링크 자체만 제거 — File::deleteDirectory 는 is_dir()
// 추적 검사 후 재귀 삭제하므로 link-to-dir 인 경우 target 의 모든 파일을 삭제하는
// 사고 발생 가능. 링크 검사가 isDir() 보다 먼저 평가되어야 한다.
//
// junction 은 is_link() 가 false 지만 isReparsePoint() 가 감지한다. junction 링크
// 제거는 파일 대상 unlink 가 아닌 rmdir() 로 수행해야 target 내용을 보존한다.
if (is_link($destItem->getPathname())) {
@unlink($destItem->getPathname());
} elseif (static::isReparsePoint($destItem->getPathname())) {
// junction: rmdir 로 링크만 제거 (target 미추적). 폴백으로 unlink 시도.
if (! @rmdir($destItem->getPathname())) {
@unlink($destItem->getPathname());
}
} elseif ($destItem->isDir()) {
File::deleteDirectory($destItem->getPathname());
} else {
+320
View File
@@ -0,0 +1,320 @@
<?php
namespace App\Extension;
use App\Contracts\Extension\HookListenerInterface;
use App\Contracts\Extension\ModuleInterface;
use App\Contracts\Extension\PluginInterface;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 정적 훅 매핑 캐시 관리자.
*
* 코어(app/Listeners 재귀 스캔) + 모듈/플러그인(getHookListeners)의 정적 훅 리스너를
* 사전 계산하여 bootstrap/cache/hooks.php 에 저장하고, 부팅 시 이 캐시를 읽어
* 매 요청 디렉토리 스캔·리플렉션·getSubscribedHooks() 클래스 로딩을 제거한다.
*
* 캐시는 "무엇을 등록할지 목록"만 제공하며, 실제 등록은 여전히 boot 에서 수행되므로
* 등록↔발화 순서 계약은 불변이다. 캐시 미스(파일 부재/설치 직후/캐시 클리어 후)는
* 항상 스캔 폴백으로 안전하게 동작한다.
*
* 오토로드 캐시(autoload-extensions.php)와 동일 위치·생명주기를 갖는다:
* 확장 install/activate/deactivate/uninstall/update 시
* ExtensionManager::updateComposerAutoload() 가 함께 재생성한다.
*
* @see HookListenerRegistrar::registerFromCache()
*/
class HookCacheManager
{
/**
* 캐시 파일 경로.
*/
protected string $cacheFilePath;
/**
* Request-scoped 메모리 캐시 (read() 결과).
*
* HookCacheManager 는 싱글톤이 아니라 부팅 경로에서 매번 새 인스턴스로
* 생성되므로(app(HookCacheManager::class)), 요청 단위 캐시는 static 이어야 한다.
* 세 상태를 구분한다:
* - null = 아직 read() 전 (미초기화). PHP static 프로퍼티는 unset() 불가하므로
* 무효화(generate/clear)는 이 값으로 되돌린다.
* - false = 이번 요청 캐시 미스 확정 (파일 부재/손상) — 재시도 없이 폴백 유지.
* - array = 유효 캐시 데이터.
*
* @var array{core: array, modules: array, plugins: array}|false|null
*/
private static array|false|null $memo = null;
public function __construct()
{
$this->cacheFilePath = base_path('bootstrap/cache/hooks.php');
}
/**
* 캐시 파일 경로를 반환합니다.
*
* @return string bootstrap/cache/hooks.php 절대 경로
*/
public function getCacheFilePath(): string
{
return $this->cacheFilePath;
}
/**
* 캐시 파일 존재 여부를 반환합니다.
*
* @return bool 캐시 파일이 존재하면 true
*/
public function isCached(): bool
{
return File::exists($this->cacheFilePath);
}
/**
* 캐시 파일을 읽어 반환합니다.
*
* 파일이 없거나 구조가 손상된 경우 null 을 반환하여 호출측이 스캔 폴백하도록 한다.
*
* @return array{core: array<int, array<string, mixed>>, modules: array<string, array<int, array<string, mixed>>>, plugins: array<string, array<int, array<string, mixed>>>}|null
*/
public function read(): ?array
{
// Request-scoped 메모리 캐시: 한 요청 동안 부팅 경로에서
// core/module/plugin 등록이 이 메서드를 여러 번 호출한다
// (호출당 86KB 캐시 파일 재파싱 — OPcache 부재 시 요청당 십수 ms 누적).
// 캐시 파일 내용은 요청 수명 동안 불변이므로 첫 로드 결과를 static 에 보관해
// 파일 접근을 요청당 1회로 고정한다. self::$memo 는 세 상태를 구분한다:
// - 미설정(unset): 아직 read() 호출 전
// - false: 이번 요청에서 캐시 미스(파일 부재/손상) 확정 — 재시도 없이 폴백 유지
// - array: 유효 캐시 데이터
if (self::$memo !== null) {
return self::$memo === false ? null : self::$memo;
}
if (! $this->isCached()) {
self::$memo = false;
return null;
}
try {
$data = require $this->cacheFilePath;
} catch (\Throwable $e) {
Log::warning('훅 캐시 파일 로드 실패 — 스캔 폴백', [
'path' => $this->cacheFilePath,
'error' => $e->getMessage(),
]);
self::$memo = false;
return null;
}
if (! is_array($data) || ! isset($data['core'], $data['modules'], $data['plugins'])) {
Log::warning('훅 캐시 파일 구조가 올바르지 않습니다 — 스캔 폴백', [
'path' => $this->cacheFilePath,
]);
self::$memo = false;
return null;
}
self::$memo = $data;
return $data;
}
/**
* 캐시 파일을 삭제합니다.
*
* @return bool 삭제 성공 또는 파일 부재 시 true
*/
public function clear(): bool
{
self::$memo = null;
if (! $this->isCached()) {
return true;
}
return File::delete($this->cacheFilePath);
}
/**
* 정적 훅 매핑을 스캔·리플렉션하여 캐시 파일로 생성합니다.
*
* 모듈/플러그인 리스너는 로드된 Module/Plugin 인스턴스의 getHookListeners() 에서
* 수집하므로, 호출 전에 각 Manager 의 loadModules()/loadPlugins() 가 선행되어야 한다.
*
* @param ModuleManager $moduleManager 로드 완료된 모듈 매니저
* @param PluginManager $pluginManager 로드 완료된 플러그인 매니저
* @return array{core: int, modules: int, plugins: int} 소스별 리스너 수
*/
public function generate(ModuleManager $moduleManager, PluginManager $pluginManager): array
{
$cache = [
'core' => $this->collectCoreListeners(),
'modules' => $this->collectExtensionListeners($moduleManager->getActiveModules()),
'plugins' => $this->collectExtensionListeners($pluginManager->getActivePlugins()),
];
$content = $this->buildCacheFileContent($cache);
$dir = dirname($this->cacheFilePath);
if (! File::isDirectory($dir)) {
File::makeDirectory($dir, 0755, true);
}
File::put($this->cacheFilePath, $content);
// 방금 쓴 새 매핑으로 request-scoped 메모리 캐시를 갱신
// (이후 read() 가 옛 memo 를 반환하지 않도록 무효화).
self::$memo = null;
$counts = [
'core' => count($cache['core']),
'modules' => array_sum(array_map('count', $cache['modules'])),
'plugins' => array_sum(array_map('count', $cache['plugins'])),
];
Log::info('훅 매핑 캐시 파일 생성 완료', [
'path' => $this->cacheFilePath,
'core' => $counts['core'],
'modules' => $counts['modules'],
'plugins' => $counts['plugins'],
]);
return $counts;
}
/**
* app/Listeners/ 재귀 스캔으로 코어 리스너 항목을 수집합니다.
*
* registerCoreHookListeners() 스캔 로직과 동일하게 HookListenerInterface 구현체만 대상.
*
* @return array<int, array<string, mixed>> [['listener' => FQCN, 'hooks' => [...], 'dynamic' => bool], ...]
*/
protected function collectCoreListeners(): array
{
$listenersPath = app_path('Listeners');
if (! is_dir($listenersPath)) {
return [];
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($listenersPath, \RecursiveDirectoryIterator::SKIP_DOTS)
);
$entries = [];
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$relativePath = str_replace($listenersPath.DIRECTORY_SEPARATOR, '', $file->getPathname());
$relativePath = str_replace('.php', '', $relativePath);
$relativePath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
$listenerClass = 'App\\Listeners\\'.$relativePath;
$entry = $this->buildListenerEntry($listenerClass);
if ($entry !== null) {
$entries[] = $entry;
}
}
return $entries;
}
/**
* 로드된 모듈/플러그인 인스턴스에서 훅 리스너 항목을 수집합니다.
*
* @param array<string, ModuleInterface|PluginInterface> $extensions 식별자(디렉토리명) => 확장 인스턴스
* @return array<string, array<int, array<string, mixed>>> 확장 식별자 => 리스너 항목 목록
*/
protected function collectExtensionListeners(array $extensions): array
{
$result = [];
foreach ($extensions as $extension) {
if (! method_exists($extension, 'getHookListeners')) {
continue;
}
$identifier = $extension->getIdentifier();
$entries = [];
foreach ($extension->getHookListeners() as $listenerClass) {
$entry = $this->buildListenerEntry($listenerClass);
if ($entry !== null) {
$entries[] = $entry;
}
}
if (! empty($entries)) {
$result[$identifier] = $entries;
}
}
return $result;
}
/**
* 단일 리스너 클래스의 캐시 항목을 생성합니다.
*
* HookListenerInterface 미구현·클래스 부재 시 null 을 반환하여 제외합니다.
* 사전 계산된 getSubscribedHooks() 결과와 동적 훅 여부(registerDynamicHooks 존재)를 함께 저장합니다.
*
* @param string $listenerClass 리스너 FQCN
* @return array{listener: string, hooks: array<string, array<string, mixed>>, dynamic: bool}|null
*/
protected function buildListenerEntry(string $listenerClass): ?array
{
if (! class_exists($listenerClass)) {
Log::warning('훅 캐시 생성: 리스너 클래스를 찾을 수 없습니다', ['listener' => $listenerClass]);
return null;
}
if (! in_array(HookListenerInterface::class, class_implements($listenerClass), true)) {
return null;
}
try {
$hooks = $listenerClass::getSubscribedHooks();
} catch (\Throwable $e) {
Log::warning('훅 캐시 생성: getSubscribedHooks() 오류 — 항목 제외', [
'listener' => $listenerClass,
'error' => $e->getMessage(),
]);
return null;
}
return [
'listener' => $listenerClass,
'hooks' => $hooks,
'dynamic' => method_exists($listenerClass, 'registerDynamicHooks'),
];
}
protected function buildCacheFileContent(array $cache): string
{
$exported = var_export($cache, true);
$generatedAt = now()->toDateTimeString();
$header = "<?php\n\n"
."/**\n"
." * 정적 훅 매핑 캐시\n"
." *\n"
." * 이 파일은 자동 생성됩니다. 직접 수정하지 마세요.\n"
." * Generated at: {$generatedAt}\n"
." *\n"
." * @see \\App\\Extension\\HookCacheManager::generate()\n"
." */\n\n";
return $header."return {$exported};\n";
}
}
+46 -5
View File
@@ -34,7 +34,9 @@ class HookListenerRegistrar
/**
* 리스너 클래스를 HookManager에 등록합니다.
*
* 동일 source + listenerClass 조합이 이미 등록된 경우 skip (idempotent).
* 런타임에 $listenerClass::getSubscribedHooks() 를 호출해 구독 훅을 조회한 뒤
* 등록한다. 동일 source + listenerClass 조합이 이미 등록된 경우 skip (idempotent).
* 사전 계산된 캐시 매핑으로 등록하려면 registerFromCache() 를 사용한다.
*
* @param string $listenerClass HookListenerInterface 구현 클래스의 FQCN
* @param string|null $source 등록 출처 (로그용: 'core', 모듈/플러그인 식별자)
@@ -45,14 +47,10 @@ class HookListenerRegistrar
if (isset(self::$registered[$key])) {
return; // 동일 PHP process 내 중복 등록 방지
}
self::$registered[$key] = true;
try {
$subscribedHooks = $listenerClass::getSubscribedHooks();
} catch (\Throwable $e) {
// 실패 시 캐시 롤백하여 재시도 가능 상태 유지
unset(self::$registered[$key]);
Log::error('훅 리스너 등록 실패: getSubscribedHooks() 오류', [
'listener' => $listenerClass,
'source' => $source,
@@ -62,6 +60,49 @@ class HookListenerRegistrar
return;
}
self::applySubscribedHooks($listenerClass, $subscribedHooks, $source);
}
/**
* 캐시된 구독 훅 매핑으로 리스너를 등록합니다 (클래스 로딩 없음).
*
* bootstrap/cache/hooks.php 에서 읽어온 사전 계산 getSubscribedHooks() 결과를
* 그대로 받아 HookManager 에 등록한다. register() 와 달리 $listenerClass::getSubscribedHooks()
* 를 호출하지 않으므로 리스너 클래스가 부팅 시점에 오토로딩되지 않는다 —
* 실제 클래스 로딩은 훅 발화 시 클로저 내부 app($listenerClass) 에서 지연 수행된다.
*
* 등록 결과(HookManager 훅 매핑)는 register() 스캔 경로와 바이트 동일해야 한다
* (동일 applySubscribedHooks 위임).
*
* @param string $listenerClass 리스너 FQCN
* @param array<string, array<string, mixed>> $subscribedHooks 사전 계산된 getSubscribedHooks() 결과
* @param string|null $source 등록 출처 ('core', 모듈/플러그인 식별자)
*/
public static function registerFromCache(string $listenerClass, array $subscribedHooks, ?string $source = null): void
{
$key = ($source ?? 'unknown').'::'.$listenerClass;
if (isset(self::$registered[$key])) {
return; // 동일 PHP process 내 중복 등록 방지 (스캔 경로와 동일 계약)
}
self::applySubscribedHooks($listenerClass, $subscribedHooks, $source);
}
/**
* 구독 훅 배열을 HookManager 에 등록합니다 (스캔/캐시 경로 공통).
*
* register()(런타임 getSubscribedHooks 호출)와 registerFromCache()(캐시 읽기)가
* 이 메서드에 위임하여 등록 결과의 동일성을 보장한다.
*
* @param string $listenerClass 리스너 FQCN
* @param array<string, array<string, mixed>> $subscribedHooks 구독 훅 매핑
* @param string|null $source 등록 출처
*/
private static function applySubscribedHooks(string $listenerClass, array $subscribedHooks, ?string $source): void
{
$key = ($source ?? 'unknown').'::'.$listenerClass;
self::$registered[$key] = true;
foreach ($subscribedHooks as $hookName => $config) {
$method = $config['method'] ?? 'handle';
$priority = $config['priority'] ?? 10;
+43 -7
View File
@@ -2961,13 +2961,6 @@ class ModuleManager implements ModuleManagerInterface
}
}
/**
* 모듈의 훅 리스너를 자동으로 등록합니다.
*
* 비활성화된 모듈의 훅 리스너는 등록하지 않습니다.
*
* @param ModuleInterface $module 모듈 인스턴스
*/
protected function registerModuleHookListeners(ModuleInterface $module): void
{
// 모듈이 getHookListeners 메서드를 가지고 있는지 확인
@@ -2981,6 +2974,11 @@ class ModuleManager implements ModuleManagerInterface
return;
}
// 캐시 우선: 사전 계산된 훅 매핑이 있으면 클래스 로딩 없이 등록.
if ($this->registerExtensionHookListenersFromCache('modules', $module->getIdentifier())) {
return;
}
$listeners = $module->getHookListeners();
foreach ($listeners as $listenerClass) {
@@ -3010,6 +3008,44 @@ class ModuleManager implements ModuleManagerInterface
}
}
/**
* 훅 캐시에서 확장(모듈/플러그인)의 정적 훅 리스너를 등록합니다.
*
* 캐시 파일(bootstrap/cache/hooks.php)에 해당 확장 식별자의 항목이 있으면
* getSubscribedHooks() 클래스 로딩·리플렉션 없이 사전 계산 매핑으로 등록한다.
* 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔 폴백).
*
* @param string $bucket 캐시 버킷 ('modules' 또는 'plugins')
* @param string $identifier 확장 식별자
* @return bool 캐시로 등록했으면 true, 캐시 부재/해당 항목 없음/테스트 환경이면 false (스캔 폴백)
*/
protected function registerExtensionHookListenersFromCache(string $bucket, string $identifier): bool
{
if (app()->environment('testing')) {
return false;
}
$cache = app(HookCacheManager::class)->read();
if ($cache === null || ! isset($cache[$bucket][$identifier])) {
return false;
}
foreach ($cache[$bucket][$identifier] as $entry) {
try {
HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], $identifier);
} catch (\Throwable $e) {
Log::error('확장 훅 리스너 캐시 등록 중 오류 발생', [
'listener' => $entry['listener'] ?? null,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
}
}
return true;
}
/**
* 모듈의 브로드캐스트 채널을 등록합니다.
*
+43 -7
View File
@@ -2802,13 +2802,6 @@ class PluginManager implements PluginManagerInterface
}
}
/**
* 플러그인의 훅 리스너를 자동으로 등록합니다.
*
* 비활성화된 플러그인의 훅 리스너는 등록하지 않습니다.
*
* @param PluginInterface $plugin 플러그인 인스턴스
*/
protected function registerPluginHookListeners(PluginInterface $plugin): void
{
// 플러그인 활성화 상태 확인 (비활성화된 플러그인의 훅은 등록하지 않음)
@@ -2817,6 +2810,11 @@ class PluginManager implements PluginManagerInterface
return;
}
// 캐시 우선: 사전 계산된 훅 매핑이 있으면 클래스 로딩 없이 등록.
if ($this->registerExtensionHookListenersFromCache('plugins', $plugin->getIdentifier())) {
return;
}
$listeners = $plugin->getHookListeners();
foreach ($listeners as $listenerClass) {
@@ -2846,6 +2844,44 @@ class PluginManager implements PluginManagerInterface
}
}
/**
* 훅 캐시에서 플러그인의 정적 훅 리스너를 등록합니다.
*
* 캐시 파일(bootstrap/cache/hooks.php)에 해당 플러그인 식별자의 항목이 있으면
* getSubscribedHooks() 클래스 로딩·리플렉션 없이 사전 계산 매핑으로 등록한다.
* 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔 폴백).
*
* @param string $bucket 캐시 버킷 ('plugins')
* @param string $identifier 플러그인 식별자
* @return bool 캐시로 등록했으면 true, 캐시 부재/해당 항목 없음/테스트 환경이면 false (스캔 폴백)
*/
protected function registerExtensionHookListenersFromCache(string $bucket, string $identifier): bool
{
if (app()->environment('testing')) {
return false;
}
$cache = app(HookCacheManager::class)->read();
if ($cache === null || ! isset($cache[$bucket][$identifier])) {
return false;
}
foreach ($cache[$bucket][$identifier] as $entry) {
try {
HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], $identifier);
} catch (\Throwable $e) {
Log::error('확장 훅 리스너 캐시 등록 중 오류 발생', [
'listener' => $entry['listener'] ?? null,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
}
}
return true;
}
/**
* 플러그인의 브로드캐스트 채널을 등록합니다.
*
@@ -187,7 +187,7 @@ class AdminTemplateAssetController extends AdminBaseController
/**
* 편집기 프리뷰 전용 CSS 서빙 — 다크 셀렉터를 프리뷰 마커로 치환.
*
* PO admin 환경이 다크 테마면 `<html class="dark">` 가 조상으로 남아, 프리뷰
* 관리자 admin 환경이 다크 테마면 `<html class="dark">` 가 조상으로 남아, 프리뷰
* 프레임에서 `.dark` 를 빼도(라이트 토글) Tailwind `.dark &` 가 활성돼 프리뷰가
* 라이트로 격리되지 않는다. 이를 해소하기 위해, 편집기 진입 시에만 components.css 의
* 다크 조상 셀렉터(`.dark`)를 프리뷰 전용 마커(`.g7le-preview-dark`)로 치환해 서빙한다.
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Settings\RegenerateAppKeyRequest;
use App\Http\Requests\Settings\RestoreSettingsRequest;
use App\Http\Requests\Settings\SaveSettingsRequest;
use App\Http\Requests\Settings\TestDriverConnectionRequest;
use App\Http\Requests\Settings\TestMailRequest;
@@ -13,7 +14,7 @@ use App\Services\DriverConnectionTester;
use App\Services\DriverRegistryService;
use App\Services\SettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
/**
@@ -137,7 +138,9 @@ class SettingsController extends AdminBaseController
$systemInfo = $this->settingsService->getSystemInfo();
return $this->success('common.success', $systemInfo);
} catch (\Exception $e) {
} catch (\Throwable $e) {
Log::error('시스템 정보 조회 실패', ['error' => $e->getMessage()]);
return $this->error('common.error_occurred', 500, $e->getMessage());
}
}
@@ -264,16 +267,13 @@ class SettingsController extends AdminBaseController
/**
* 백업에서 설정을 복원합니다.
*
* @param RestoreSettingsRequest $request 복원 요청 데이터 (백업 경로)
* @return JsonResponse 복원 결과 JSON 응답
*/
public function restore(Request $request): JsonResponse
public function restore(RestoreSettingsRequest $request): JsonResponse
{
try {
$backupPath = $request->input('backup_path');
if (empty($backupPath)) {
return $this->error('settings.backup_path_required', 422);
}
$backupPath = $request->validated('backup_path');
$result = $this->settingsService->restoreSettings($backupPath);
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Requests\Settings;
use App\Extension\HookManager;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 설정 복원 요청 FormRequest
*
* 백업에서 설정을 복원할 때 백업 경로를 검증합니다.
* 권한은 라우트의 permission 미들웨어(core.settings.update)가 담당합니다.
*/
class RestoreSettingsRequest extends FormRequest
{
/**
* 요청 권한을 확인합니다.
*
* 권한 검사는 permission 미들웨어 체인에 위임하므로 항상 true 를 반환합니다.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [
'backup_path' => 'required|string',
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
return HookManager::applyFilters('core.settings.restore_validation_rules', $rules, $this);
}
/**
* 검증 오류 메시지를 반환합니다.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'backup_path.required' => __('settings.backup_path_required'),
];
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Support\ConfigCacheHelper;
use Illuminate\Support\Facades\Log;
/**
* 확장 활성화/비활성화 시 config 캐시를 재생성하는 리스너.
*
* 확장이 활성화·비활성화되면 다음 요청부터 그 확장의 config/settings 가 부팅 시
* 주입 대상에 포함/제외된다. config:cache 가 켜진 환경에서는 optimizeSystem 이
* 캐시를 만든 시점의 "활성 확장 스냅샷" 이 stale 해지므로, 라이프사이클 직후
* config 캐시를 재빌드해 캐시가 최신 활성 목록을 반영하도록 한다.
*
* install/uninstall/update 는 ExtensionManager::updateComposerAutoload() 가 오토로드·훅
* 캐시와 나란히 config 캐시를 재생성하므로 여기서 구독하지 않는다(이중 재생성 회피).
* 이 리스너는 updateComposerAutoload 를 거치지 않는 activate/deactivate 만 담당한다.
*
* @see \App\Extension\ExtensionManager::updateComposerAutoload()
* @see \App\Support\ConfigCacheHelper
*/
class ExtensionConfigCacheListener implements HookListenerInterface
{
/**
* 구독할 훅 목록을 반환합니다.
*
* activate/deactivate 만 구독한다 (install/uninstall/update 는 updateComposerAutoload 담당).
*
* @return array<string, array{method: string, priority: int}> 훅 이름 → 메서드/우선순위 매핑
*/
public static function getSubscribedHooks(): array
{
$hooks = [];
$hookNames = [
'core.modules.activated',
'core.modules.after_deactivate',
'core.plugins.activated',
'core.plugins.after_deactivate',
'core.templates.activated',
'core.templates.after_deactivate',
];
foreach ($hookNames as $hookName) {
$hooks[$hookName] = [
'method' => 'onExtensionToggled',
'priority' => 30,
// config 캐시 재생성은 인프라 작업이라 큐로 미루면 안 된다. Action 훅의 기본
// 등록은 큐 디스패치(HookListenerRegistrar) 이므로, sync 를 명시하지 않으면
// 워커 미가동 환경에서 config:cache 가 재생성되지 않는다. 또한 확장 토글은
// CLI 커맨드(plugin:deactivate 등)로도 수행되며, 커맨드 프로세스는 실행 후
// 즉시 종료되어 큐에 쌓인 작업을 처리할 주체가 없다. 반드시 동기 실행한다.
'sync' => true,
];
}
return $hooks;
}
/**
* 기본 훅 핸들러 (HookListenerInterface 필수 메서드).
*
* @param mixed ...$args 훅 인자
*/
public function handle(...$args): void
{
// 개별 메서드에서 처리
}
/**
* 확장 활성화/비활성화 직후 config 캐시를 재생성합니다.
*
* @param mixed ...$args 훅 인자 (확장 식별자 등)
*/
public function onExtensionToggled(...$args): void
{
try {
ConfigCacheHelper::rebuild();
} catch (\Throwable $e) {
Log::warning('[Config] 확장 토글 후 config 캐시 재생성 실패', [
'error' => $e->getMessage(),
]);
}
}
}
+3 -1
View File
@@ -50,7 +50,9 @@ class NotificationHookListener implements HookListenerInterface
*/
public function registerDynamicHooks(): void
{
if (! Schema::hasTable('notification_definitions')) {
// 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 information_schema 쿼리 제거).
// 인스톨러 이전/마이그레이션 전 환경에서는 기존 hasTable 폴백으로 안전하게 스킵.
if (! config('app.installer_completed') && ! Schema::hasTable('notification_definitions')) {
return;
}
+1 -1
View File
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* 레이아웃 편집 중 업로드되는 파일(배경 이미지 등)을 표현한다. 파일 자체는
* 코어 StorageInterface 로 저장되고 본 모델은 저장 위치(disk/path)와 메타데이터를
* 보관한다. 템플릿 삭제 시 행은 cascadeOnDelete 로 삭제되나, 스토리지 파일
* 실삭제는 Service 가 명시적으로 수행한다(CLAUDE.md "DB CASCADE 의존 삭제 금지").
* 실삭제는 Service 가 명시적으로 수행한다(코어 규정: DB CASCADE 의존 삭제 금지).
*
* @property int $id 첨부 파일 ID
* @property int $template_id 소속 템플릿 ID
+51 -4
View File
@@ -42,6 +42,7 @@ use App\Enums\DeactivationReason;
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\CoreVersionChecker;
use App\Extension\ExtensionManager;
use App\Extension\HookCacheManager;
use App\Extension\HookListenerRegistrar;
use App\Extension\HookManager;
use App\Extension\IdentityVerification\IdentityVerificationManager;
@@ -151,6 +152,15 @@ class CoreServiceProvider extends ServiceProvider
}
}
// 확장 소스 classmap 등록 (FQCN → 절대경로) — findFile 파일시스템 스캔 제거 (lazy include).
if (! empty($extensionAutoloads['src_classmap'])) {
$absoluteClassmap = [];
foreach ($extensionAutoloads['src_classmap'] as $fqcn => $relPath) {
$absoluteClassmap[$fqcn] = base_path($relPath);
}
$loader->addClassMap($absoluteClassmap);
}
// Classmap 파일 로드 (module.php, plugin.php)
if (! empty($extensionAutoloads['classmap'])) {
foreach ($extensionAutoloads['classmap'] as $file) {
@@ -866,12 +876,14 @@ class CoreServiceProvider extends ServiceProvider
// registerActivityLogManager() 제거됨 — Monolog 채널(config/logging.php 'activity')로 대체
/**
* app/Listeners/ 디렉토리에서 HookListenerInterface 구현체를 자동 발견하여 등록합니다.
* 하위 디렉토리까지 재귀적으로 스캔합니다.
*/
private function registerCoreHookListeners(): void
{
// 캐시 우선: bootstrap/cache/hooks.php 가 있으면 스캔·리플렉션 없이 등록.
// 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔).
if (! $this->app->environment('testing') && $this->registerCoreHookListenersFromCache()) {
return;
}
$listenersPath = app_path('Listeners');
if (! is_dir($listenersPath)) {
@@ -910,6 +922,41 @@ class CoreServiceProvider extends ServiceProvider
}
}
/**
* 훅 캐시에서 코어 리스너를 등록합니다.
*
* 캐시 파일(bootstrap/cache/hooks.php)이 존재하면 디렉토리 스캔·class_implements
* 리플렉션·getSubscribedHooks() 클래스 로딩 없이 사전 계산 매핑으로 등록한다.
* 동적 훅(registerDynamicHooks) 보유 리스너는 스캔 경로와 동일하게 boot 후반부 실행을 위해 지연 목록에 담는다.
*
* @return bool 캐시로 등록했으면 true, 캐시 부재/손상 시 false (스캔 폴백)
*/
private function registerCoreHookListenersFromCache(): bool
{
$cache = $this->app->make(HookCacheManager::class)->read();
if ($cache === null) {
return false;
}
foreach ($cache['core'] as $entry) {
try {
HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], 'core');
if (! empty($entry['dynamic'])) {
$this->deferredDynamicListeners[] = $entry['listener'];
}
} catch (\Throwable $e) {
Log::error('코어 훅 리스너 캐시 등록 중 오류 발생', [
'listener' => $entry['listener'] ?? null,
'error' => $e->getMessage(),
]);
}
}
return true;
}
/**
* 단일 코어 리스너를 HookManager에 등록합니다.
*
@@ -340,7 +340,9 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface
public function listHookTargets(): array
{
try {
if (! Schema::hasTable('identity_policies')) {
// 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 information_schema 쿼리 제거).
// 인스톨러 이전/마이그레이션 전 환경에서는 기존 hasTable 폴백으로 안전하게 빈 배열 반환.
if (! config('app.installer_completed') && ! Schema::hasTable('identity_policies')) {
return [];
}
@@ -404,7 +406,9 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface
{
try {
$table = (new $modelClass)->getTable();
if (! Schema::hasTable($table)) {
// 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 information_schema 쿼리 제거).
// 인스톨러 이전/마이그레이션 전 환경에서는 기존 hasTable 폴백으로 null 반환(필터 미적용).
if (! config('app.installer_completed') && ! Schema::hasTable($table)) {
return null;
}
+1 -1
View File
@@ -11,7 +11,7 @@ use App\Contracts\Extension\PluginManagerInterface;
* 데이터소스 종류=websocket 편집 시 채널을 "구독 가능한 목록 기준 검색 드롭다운"으로
* 제시하기 위해, "지금 이 설치본에서 구독 가능한 채널"을 런타임 수집한다. 코어/확장이
* `getChannels()` 오버라이드로 등록한 채널명을 출처(core/module/plugin)와 함께 모은다
* (CLAUDE.md 채널 프리픽스 규약 — 코어 `core.*`, 모듈 `module.{id}.*`, 플러그인
* (코어 채널 프리픽스 규약 — 코어 `core.*`, 모듈 `module.{id}.*`, 플러그인
* `plugin.{id}.*`).
*
* 이벤트는 `HookManager::broadcast($channel, $eventName, $payload)` 호출 시점에 이름이
+11 -1
View File
@@ -1558,8 +1558,9 @@ class CoreUpdateService
* @param string $toVersion 종료 버전
* @param \Closure|null $onStep 각 스텝 실행 시 콜백 (버전 문자열 전달)
* @param bool $force true 시 fromVersion == toVersion이면 해당 버전 스텝도 포함
* @param \Closure|null $onDiscovered 범위 내 발견된 스텝 파일 수를 실행 전 1회 통지 (int 전달)
*/
public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false): void
public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false, ?\Closure $onDiscovered = null): void
{
// 부모 in-process fallback 진입 시 stale 메모리 가드.
//
@@ -1597,6 +1598,9 @@ class CoreUpdateService
$upgradesPath = base_path('upgrades');
if (! File::isDirectory($upgradesPath)) {
// upgrades 디렉토리 자체가 없으면 발견된 스텝 0건 — 정상 통지 후 종료.
$onDiscovered?->__invoke(0);
return;
}
@@ -1656,6 +1660,12 @@ class CoreUpdateService
uksort($steps, 'version_compare');
// 범위 내에서 발견된 스텝 파일 수(discovered)를 먼저 통지한다.
// 호출자(spawn 자식)는 이 값으로 "스텝 파일이 애초에 없어 0건(정상)" 과 "스텝 파일은
// 있는데 실행이 0건(비정상 — gnuboard/g7#28 silent skip)" 을 구분한다. onStep 은 실제 실행
// 건마다 호출되므로 executed 수만 세며, discovered 는 실행 전에 1회 통지한다.
$onDiscovered?->__invoke(count($steps));
$context = new UpgradeContext($fromVersion, $toVersion);
foreach ($steps as $version => $step) {
+3 -3
View File
@@ -4,7 +4,7 @@ namespace App\Services;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Extension\HookManager;
use Illuminate\Support\Facades\Artisan;
use App\Support\ConfigCacheHelper;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -369,8 +369,8 @@ class GeoIpDatabaseService
$current['last_updated_at'] = now()->toIso8601String();
$this->configRepository->saveCategory('geoip', $current);
// Laravel config 캐시 재로드 강제
Artisan::call('config:clear');
// config 캐시 재로드 강제 + 즉시 재생성 (clear 만 하면 캐시 비활성 잔존).
ConfigCacheHelper::rebuild();
} catch (\Throwable $e) {
Log::warning('GeoIP last_updated_at 갱신 실패', ['error' => $e->getMessage()]);
}
+75 -29
View File
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\AttachmentRepositoryInterface;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Extension\HookManager;
use App\Http\Resources\AttachmentResource;
use App\Support\ConfigCacheHelper;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@@ -32,11 +33,14 @@ class SettingsService
* 시스템 설정 캐시를 무효화합니다.
*
* 설정 저장 시 4곳에서 중복되던 로직을 단일 메서드로 통합했습니다.
* config 캐시는 clear 후 즉시 재생성한다(ConfigCacheHelper). config:clear 만
* 하면 config:cache 가 비워진 채 재생성되지 않아 이후 모든 요청이 config 파일을
* 재파싱하는 성능 손실이 발생하기 때문이다.
*/
private function invalidateSettingsCache(): void
{
$this->cache->forget('settings.system');
Artisan::call('config:clear');
ConfigCacheHelper::rebuild();
}
/**
@@ -822,30 +826,74 @@ class SettingsService
private function buildSystemInfo(): array
{
return [
'os_info' => php_uname('s').' '.php_uname('r'),
// php_uname / ini_get 은 disable_functions 로 차단될 수 있어 개별 격리한다.
'os_info' => $this->safeSystemProbe('os_info', fn () => php_uname('s').' '.php_uname('r'), __('common.unknown')),
'web_server' => $_SERVER['SERVER_SOFTWARE'] ?? __('common.unknown'),
'php_version' => PHP_VERSION,
'mysql_version' => $this->getDatabaseInfo(),
'mysql_version' => $this->safeSystemProbe('mysql_version', fn () => $this->getDatabaseInfo(), __('common.unknown')),
'g7_version' => config('app.version', '1.0.0'),
'g7_release_year' => config('app.release_year', '2026'),
'laravel_version' => app()->version(),
'environment' => app()->environment(),
'cpu_info' => $this->getCpuInfo(),
'memory_usage' => $this->getMemoryUsage(),
'disk_usage' => $this->getDiskUsage(),
'php_memory_limit' => ini_get('memory_limit'),
'max_execution_time' => ini_get('max_execution_time').__('settings.seconds'),
'upload_max_filesize' => ini_get('upload_max_filesize'),
'cpu_info' => $this->safeSystemProbe('cpu_info', fn () => $this->getCpuInfo(), __('common.unknown')),
'memory_usage' => $this->safeSystemProbe('memory_usage', fn () => $this->getMemoryUsage(), $this->unknownUsage()),
'disk_usage' => $this->safeSystemProbe('disk_usage', fn () => $this->getDiskUsage(), $this->unknownUsage()),
'php_memory_limit' => $this->safeSystemProbe('php_memory_limit', fn () => ini_get('memory_limit'), __('common.unknown')),
'max_execution_time' => $this->safeSystemProbe('max_execution_time', fn () => ini_get('max_execution_time').__('settings.seconds'), __('common.unknown')),
'upload_max_filesize' => $this->safeSystemProbe('upload_max_filesize', fn () => ini_get('upload_max_filesize'), __('common.unknown')),
'install_path' => base_path(),
'config_path' => storage_path('app/settings'),
'log_path' => storage_path('logs'),
'upload_path' => storage_path('app/public'),
'php_extensions' => $this->getPhpExtensions(),
'database_config' => $this->getDatabaseConfig(),
'php_extensions' => $this->safeSystemProbe('php_extensions', fn () => $this->getPhpExtensions(), ['required' => [], 'optional' => []]),
'database_config' => $this->safeSystemProbe('database_config', fn () => $this->getDatabaseConfig(), ['has_read_write_split' => false, 'write' => [], 'read' => []]),
'timezone' => config('app.timezone'),
];
}
/**
* 시스템 정보 probe 를 안전하게 실행합니다.
*
* probe 가 예외(ErrorException·Error·disable_functions 로 인한 Error 포함)를
* 던지면 전파하지 않고 폴백값을 반환하고 경고 로그를 남깁니다. 개별 항목 수집
* 실패가 system-info API 전체를 500 으로 만들지 않도록 격리합니다.
*
* @param string $label 실패 로그 식별용 항목명 (예: 'cpu_info')
* @param callable $cb 실행할 probe 콜백
* @param mixed $fallback 실패 시 반환할 폴백 값
* @return mixed probe 결과 또는 폴백
*/
private function safeSystemProbe(string $label, callable $cb, mixed $fallback): mixed
{
try {
return $cb();
} catch (\Throwable $e) {
Log::warning('시스템 정보 수집 실패', [
'item' => $label,
'error' => $e->getMessage(),
]);
return $fallback;
}
}
/**
* 메모리/디스크 사용량 조회 실패 시 사용할 폴백 배열을 반환합니다.
*
* total/used/free/percentage 형식을 memory_usage·disk_usage 와 동일하게 유지합니다.
*
* @return array 사용량 폴백 배열
*/
private function unknownUsage(): array
{
return [
'total' => __('common.unknown'),
'used' => __('common.unknown'),
'free' => __('common.unknown'),
'percentage' => 0,
];
}
/**
* 시스템 캐시를 정리합니다.
*
@@ -859,10 +907,12 @@ class SettingsService
}
Artisan::call('cache:clear');
Artisan::call('config:clear');
Artisan::call('route:clear');
Artisan::call('view:clear');
// config 는 clear 후 즉시 재생성 (비운 채 두면 이후 모든 요청이 config 재파싱).
ConfigCacheHelper::rebuild();
return true;
} catch (\Exception $e) {
return false;
@@ -908,7 +958,8 @@ class SettingsService
$envContent = preg_replace('/^APP_KEY=.*/m', 'APP_KEY='.$newKey, $envContent);
file_put_contents($envPath, $envContent);
Artisan::call('config:clear');
// .env 변경 반영 + config 캐시 재생성 (clear 만 하면 캐시 비활성 상태로 잔존).
ConfigCacheHelper::rebuild();
return [
'success' => true,
@@ -949,11 +1000,16 @@ class SettingsService
try {
$connection = DB::connection();
$driver = $connection->getDriverName();
$version = $connection->select('SELECT VERSION() as version')[0]->version ?? 'Unknown';
$version = $connection->select('SELECT VERSION() as version')[0]->version ?? __('common.unknown');
return ucfirst($driver).' '.$version;
} catch (\Exception $e) {
return 'Unknown';
} catch (\Throwable $e) {
Log::warning('시스템 정보 수집 실패', [
'item' => 'mysql_version',
'error' => $e->getMessage(),
]);
return __('common.unknown');
}
}
@@ -999,12 +1055,7 @@ class SettingsService
}
if ($total <= 0) {
return [
'total' => __('common.unknown'),
'used' => __('common.unknown'),
'free' => __('common.unknown'),
'percentage' => 0,
];
return $this->unknownUsage();
}
$used = max(0, $total - $free);
@@ -1034,12 +1085,7 @@ class SettingsService
$free = is_numeric($freeRaw) ? (int) $freeRaw : 0;
if ($total <= 0) {
return [
'total' => __('common.unknown'),
'used' => __('common.unknown'),
'free' => __('common.unknown'),
'percentage' => 0,
];
return $this->unknownUsage();
}
$used = max(0, $total - $free);
@@ -1077,7 +1123,7 @@ class SettingsService
*
* @return string CPU 정보 문자열
*/
private function getCpuInfo(): string
protected function getCpuInfo(): string
{
if (PHP_OS_FAMILY === 'Windows') {
$output = @shell_exec('powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Processor | Select-Object -First 1).Name" 2>&1');
@@ -18,7 +18,7 @@ use Illuminate\Support\Str;
* 레이아웃 편집 중 업로드되는 파일(배경 이미지 등)의 업로드·조회·삭제를 처리한다.
* 파일 저장은 코어 StorageInterface(CoreStorageDriver)를 통해서만 수행하고,
* 저장 위치(disk/path)와 메타데이터를 template_layout_attachments 에 기록한다.
* (Storage::disk() 직접 호출 금지 — CLAUDE.md 스토리지 규칙)
* (Storage::disk() 직접 호출 금지 — 코어 스토리지 규칙)
*/
class TemplateLayoutAttachmentService
{
@@ -114,7 +114,7 @@ class TemplateLayoutAttachmentService
* 첨부 파일을 삭제합니다 — 스토리지 파일 실삭제 후 DB 행 삭제.
*
* DB CASCADE 에 의존하지 않고 스토리지 파일을 명시적으로 삭제한다
* (CLAUDE.md "DB CASCADE 의존 삭제 금지" — 파일 정리 보장).
* (코어 규정: DB CASCADE 의존 삭제 금지 — 파일 정리 보장).
*
* @param TemplateLayoutAttachment $attachment 삭제할 첨부 파일
* @return bool 삭제 성공 여부
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* config 캐시(bootstrap/cache/config.php) 재빌드 헬퍼.
*
* config 의 소스(config/*.php, .env, storage/app/settings/*.json, 활성 확장 목록)를
* 변경하는 모든 라이프사이클 지점이 이 헬퍼를 호출해 "변경 반영 + 캐시 재최적화"를
* 일관되게 수행한다. 산발적으로 config:clear/config:cache 를 각 지점에 뿌리면
* 누락이 생겨(설정 저장은 clear 만, 확장 설치는 clear 조차 안 함) config:cache 가
* 한 번 비워진 뒤 재생성되지 않아 성능 이점이 영구히 사라진다 — 이를 단일 SSoT 로 막는다.
*
* 정책: 환경 무관 항상 재생성. config:cache 는 그 자체로 부팅 비용을
* 절감하고, G7 설정은 config 캐시에 박제되지 않고 매 요청 SettingsServiceProvider /
* CoreServiceProvider 의 런타임 Config::set() 으로 재주입되므로(설정 stale 없음),
* 항상 켜두는 것이 이득이다. local 개발 시 config/*.php 수정이 즉시 반영되지 않는 점은
* 개발자가 `php artisan config:clear` 로 대응하는 개발자 책임 영역이다.
*/
class ConfigCacheHelper
{
/**
* config 캐시를 비우고 즉시 재생성합니다.
*
* 설치 미완료(installer 실행 전) 환경에서는 config:cache 가 불완전한 부팅을
* 캐시에 박제해 부팅 실패를 유발할 수 있으므로, clear 만 수행하고 재생성은 건너뛴다.
* 테스트 환경(APP_ENV=testing)은 tests/bootstrap.php 가 config 캐시를 자동 삭제하며
* 캐시 생성이 테스트 격리를 깨므로 no-op 로 스킵한다.
*
* 재생성 실패(권한/디스크 등)는 치명적이지 않다 — config:clear 로 stale 캐시는
* 이미 제거되어 다음 요청이 fresh config 로 안전하게 부팅되므로, 경고만 남기고 넘어간다.
*
* @return void
*/
public static function rebuild(): void
{
// 항상 stale 캐시부터 제거 (값 반영 보장). testing 환경에서도 clear 는 수행한다 —
// 캐시 파일이 없으면 사실상 no-op 이고 격리를 깨지 않으며, 설정 저장 후 값 반영을
// 검증하는 기존 테스트(SettingsServiceConfigClearTest) 계약을 유지한다.
Artisan::call('config:clear');
// config:cache 생성은 격리를 깨므로(캐시된 config 가 다음 테스트로 누출) testing 에서
// 스킵한다. 설치 미완료 상태에서도 불완전 config 박제를 피하려 재생성하지 않는다.
if (app()->environment('testing') || ! self::isInstalled()) {
return;
}
try {
Artisan::call('config:cache');
} catch (\Throwable $e) {
Log::warning('config 캐시 재생성 실패 (config:clear 로 stale 은 제거됨 — 다음 요청은 비캐시 부팅)', [
'error' => $e->getMessage(),
]);
}
}
/**
* config 캐시만 제거합니다 (재생성 없음).
*
* 재생성이 부적절한 특수 경로(예: 설치 직전 초기화)를 위한 보조 진입점.
*
* @return void
*/
public static function clear(): void
{
Artisan::call('config:clear');
}
/**
* G7 설치 완료 여부를 확인합니다.
*
* config('app.installer_completed') 는 config:clear 직후 재로드되어 신뢰 가능하며,
* 보조로 storage/app/g7_installed 플래그 파일도 확인한다.
*
* @return bool 설치 완료 시 true
*/
private static function isInstalled(): bool
{
if (config('app.installer_completed')) {
return true;
}
return File::exists(storage_path('app/g7_installed'));
}
}
+1 -1
View File
@@ -231,7 +231,7 @@ return [
|
*/
'version' => env('APP_VERSION', '7.0.0'),
'version' => env('APP_VERSION', '7.0.1'),
/*
|--------------------------------------------------------------------------
+15
View File
@@ -217,6 +217,21 @@ v접두사 자동 감지 (resolveGithubArchiveUrl):
> **단독 실행 안전성 (beta.6 이후)**: `core:execute-upgrade-steps` 는 HANDOFF 안내 또는 수동 복구 목적으로 운영자가 직접 호출되는 경로가 있다. 단독 실행 시 자식은 기본값으로 부모 Step 9 (`runMigrations` + `reloadCoreConfigAndResync`), Step 11 (`updateVersionInEnv` + `clearAllCaches`), Step 12 (번들 확장 일괄 업데이트) 를 자체적으로 수행해 단일 명령으로 업그레이드를 완결한다. 부모 `CoreUpdateCommand::spawnUpgradeStepsProcess()` 는 자식 명령 라인에 `--skip-migrations`, `--skip-resync`, `--skip-version-env`, `--skip-cache-clear`, `--skip-bundled-updates` 5개를 무조건 추가해 중복 회피한다 — 부모가 자식 종료 후 동일 단계를 직접 수행하기 때문이다.
#### 재실행 안내의 권한 분기 (핸드오프 catch)
spawn 자식이 실패(`proc_open` 미지원 · 비정상 종료 · silent skip)하고 `spawn_failure_mode=abort`(기본값) 이면, 파일·버전은 이미 `toVersion` 으로 반영되지만 업그레이드 스텝이 미실행 상태로 남아 운영자에게 `core:execute-upgrade-steps` 재실행을 안내한다. 이때 **sudo(root) 로 `core:update` 를 실행한 경우**, 안내받은 명령을 root 로 그대로 재실행하면 스텝이 만드는 파일·캐시가 root 소유로 생성되어 이후 웹서버(php-fpm www-data 등) 요청이 그 경로에 쓰기 실패한다.
`CoreUpdateCommand::surfaceResumeCommandWithPermissionGuidance()` 는 실행 환경을 4가지로 분류(`classifyResumeExecutionContext()`)하여 안내를 분기한다:
| 모드 | 조건 | 안내 |
|------|------|------|
| `non_root` | root 아님(일반 SSH 사용자 = 파일 소유자) / posix 미지원(Windows) / 공유 호스팅(웹서버·PHP·실행 유저 동일) | 명령만 그대로 출력 |
| `root_web_known` | root 실행 + 웹서버 계정 식별 가능 + 실행 사용자와 다름 | `sudo -u {계정} {명령}` + 계정명 명시 경고 |
| `root_web_symmetric` | root 실행 + 웹서버 계정이 root 로 추정 (root 서비스 구성) | 명령만 그대로 출력 |
| `root_web_unknown` | root 실행 + 웹서버 계정 추정 실패 | `sudo -u <웹서버계정>` placeholder + 계정 확인 안내 |
웹서버 계정은 `FilePermissionHelper::inferWebServerOwnership()` 이 `storage/*`·`bootstrap/cache` 쓰기 영역 소유자로 추정한다.
### Step 11: 마무리
```text
+55 -1
View File
@@ -346,7 +346,7 @@ protected function loadModuleRoutes(): void
- **`hasTable` 폴백 경로는 반드시 유지** — `installer_completed=false` 환경에서도 안전하게 부팅되어야 함
- **try/catch 도 그대로 유지** — DB 연결 실패 시 안전한 스킵 계약 준수
- 가드는 hasTable 체크 **전체 블록을 `if (! config('app.installer_completed'))` 로 래핑** 하는 형태
- `config:cache` 를 사용하는 환경에서는 `.env` 값 변경 후 반드시 `php artisan config:clear && php artisan config:cache` 를 실행해야 함
- `config:cache` 는 config 소스를 변경하는 라이프사이클에서 `App\Support\ConfigCacheHelper::rebuild()` 로 자동 재생성된다 (아래 "config 캐시 자동 재생성" 참조). `.env` 를 직접 편집한 경우처럼 헬퍼를 거치지 않는 변경은 여전히 `php artisan config:clear && php artisan config:cache` 를 수동 실행한다
### 확장 Trait 패턴
@@ -369,6 +369,28 @@ private static function isExtensionTableReady(string $table): bool
}
```
### 부팅 경로 Listener / Repository 적용
ServiceProvider 와 Trait 외에도, `CoreServiceProvider::boot()` 말미(동적 훅 등록 / IDV 정책 동기화)에서 매 요청 실행되는 다음 지점도 동일 가드를 적용합니다:
- `NotificationHookListener::registerDynamicHooks()` — `notification_definitions` 존재 확인 (단일 최대 비용)
- `IdentityPolicyRepository::listHookTargets()` — `identity_policies` 존재 확인
- `IdentityPolicyRepository::activeExtensionIdentifiers()` — `modules` / `plugins` 존재 확인 (테이블 부재 시 `null` 반환으로 필터 미적용 계약 보존)
```php
// hasTable 을 단독 조건이 아니라 installer_completed 와 && 로 단락.
// 설치 완료 시 hasTable 호출 자체를 건너뛰고 후속 로직으로 진행한다.
if (! config('app.installer_completed') && ! Schema::hasTable('notification_definitions')) {
return;
}
```
`try/catch` 블록 안에 있는 Repository 조회는 catch 계약(테이블 부재/DB 오류 시 `[]` 또는 `null` 반환)을 그대로 보존한 채 조건만 확장한다.
### 부팅 경로 정적 훅 등록 캐시
`installer_completed` 가드가 부팅 시 DB 조회를 줄이는 것과 별개로, `CoreServiceProvider::boot()` 의 정적 훅 리스너 등록(`app/Listeners` 재귀 스캔 + 모듈/플러그인 `getHookListeners()` 클래스 로딩)은 `bootstrap/cache/hooks.php` 캐시로 매 요청 스캔·리플렉션을 제거한다. 캐시 부재/테스트 환경에서는 기존 스캔 경로로 안전 폴백하며, 등록 결과는 스캔 경로와 바이트 동일하다. 상세: [extension/hooks.md "정적 훅 매핑 캐시"](../extension/hooks.md).
### 주의사항
- **인스톨러 설치 완료 시점에 `INSTALLER_COMPLETED=true` 가 `.env` 에 기록되어야 함** — G7 인스톨러(`public/install/includes/task-runner.php`)는 이를 이미 수행
@@ -408,6 +430,38 @@ php artisan migrate
---
## config 캐시 자동 재생성 (ConfigCacheHelper)
config 소스(`config/*.php`, `.env`, `storage/app/settings/*.json`, 활성 확장 목록)를 변경하는 라이프사이클은 `App\Support\ConfigCacheHelper::rebuild()` 를 호출해 config 캐시를 재빌드한다.
### 배경
과거 설정 변경/코어 업데이트/APP_KEY 재생성 등은 `config:clear` 만 하고 `config:cache` 를 재생성하지 않았다. 그 결과 관리자가 "시스템 최적화"(`optimizeSystem`)로 캐시를 만들어도, 이후 설정을 한 번 저장하면 캐시가 비워진 채 재생성되지 않아 이후 모든 요청이 config 파일을 재파싱했다(성능 손실). `rebuild()` 는 clear 직후 재생성까지 수행해 캐시가 비활성 상태로 잔존하지 않게 한다.
### 정책
- **환경 무관 항상 재생성** — config:cache 는 그 자체로 부팅 비용을 줄이고, G7 설정은 config 캐시에 박제되지 않고 매 요청 런타임 `Config::set()` 으로 재주입되므로(설정 stale 없음) 항상 켜두는 것이 이득이다.
- **testing 환경**: `config:clear` 는 수행(값 반영 계약 유지)하되 `config:cache` 생성은 스킵(캐시된 config 가 다음 테스트로 누출되는 격리 파괴 방지).
- **설치 미완료 상태**: 불완전 config 박제를 피하려 재생성을 스킵하고 clear 만 한다.
- **local 개발 주의**: config:cache 가 켜지면 `config/*.php` 를 직접 수정해도 다음 요청에 반영되지 않는다. 이는 개발자가 `php artisan config:clear` 로 대응하는 개발자 책임 영역이다.
### 적용 지점
| 라이프사이클 | 재생성 위치 |
| --- | --- |
| 코어 설치 완료 | 인스톨러 `complete_flag` 직후 `config:cache` task |
| 코어 업데이트 | `CoreUpdateCommand` Step 11 완료 지점 |
| 코어 업그레이드 스텝(단독 실행) | `ExecuteUpgradeStepsCommand` 캐시 정리 블록 (spawn 자식은 부모가 처리) |
| 코어 관리자 설정 저장 / 캐시 정리 / GeoIP / APP_KEY | `SettingsService` / `GeoIpDatabaseService` 의 각 지점 |
| 확장 설치/삭제/업데이트 | `ExtensionManager::updateComposerAutoload()` (오토로드·훅 캐시와 동일 생명주기) |
| 확장 활성화/비활성화 | `ExtensionConfigCacheListener` (`core.*.activated` / `core.*.after_deactivate` 훅 구독) |
`ExtensionConfigCacheListener` 의 구독 훅은 `getSubscribedHooks()` 에서 `'sync' => true` 로 선언한다. 훅 Action 리스너의 기본 등록 정책은 큐 디스패치(`HookListenerRegistrar`)인데, config 캐시 재생성을 큐로 미루면 (1) 워커 미가동 환경에서 영영 재생성되지 않고, (2) 확장 토글을 수행하는 CLI 커맨드(`plugin:deactivate` 등)는 실행 후 프로세스가 종료되어 큐 작업을 처리할 주체가 없다. config 캐시 재생성은 인프라 부수효과이므로 반드시 동기 실행한다. 회귀 방지 테스트: `ExtensionConfigCacheListenerTest::test_all_hooks_are_synchronous`.
**확장별 개별 환경설정 저장(board/ecommerce/plugin SettingsService)은 재생성 대상이 아니다.** 확장 설정은 `config/*.php` 가 아니라 settings JSON 에 저장되고, 매 요청 `CoreServiceProvider::boot` 의 `loadModule/PluginSettingsToConfig` 가 런타임 `Config::set('g7_settings.*', 최신값)` 으로 config 캐시에 박제된 값을 덮어쓴다. 따라서 값 stale 이 없고(즉시 반영), config 캐시를 clear 하지도 않으므로(성능 손실 없음) 재생성이 실효가 없다.
---
## 관련 이슈
- 인스톨러에서 `composer install` 시 DB 접근 오류
+7 -1
View File
@@ -172,9 +172,15 @@ php artisan language-pack:update [identifier] [--force] [--source=auto|bundled|g
php artisan extension:composer-install # 모든 모듈+플러그인
# 오토로드
php artisan extension:update-autoload
php artisan extension:update-autoload # 오토로드 캐시 + 정적 훅 매핑 캐시 함께 재생성
# 정적 훅 매핑 캐시 (부팅 비용 절감 — route:cache 동형)
php artisan hooks:cache # bootstrap/cache/hooks.php 생성
php artisan hooks:clear # 캐시 삭제 (삭제 후 스캔 폴백 — 항상 안전)
```
> 훅 캐시는 확장 install/update 및 코어 업데이트(`clearAllCaches` → `extension:update-autoload`) 시 자동 재생성됩니다. 코어 리스너 코드 배포 시에만 `hooks:cache` 수동 실행. 상세: [extension/hooks.md "정적 훅 매핑 캐시"](extension/hooks.md).
### 단발성 결함 보정 (hotfix)
`hotfix:*` prefix 는 특정 버전의 결함 회복을 위해 신설되는 단발성 도구를 위한 표준 prefix 다. `core:*` (영구 운영 도구) 와 명확히 구분되며 dev-dashboard 자동 노출 면제 대상이다.
+1 -1
View File
@@ -136,7 +136,7 @@ editor-spec.json 이 커지면(코어 admin 템플릿은 단일 파일 18,000줄
### actionRecipes — 친화 명칭 → 핸들러 JSON
`params`(친화 입력란) + `build`(핸들러 JSON 생성 틀)로 구성한다. `build` 값의 `{{paramKey}}`(중괄호 2개)가 입력값으로 치환된다. `onSuccess`/`onError` 가 `{{key}}` 이고 그 파라미터가 `action-list` 면 치환 결과는 중첩 액션 **배열**이 된다. 핸들러명/파라미터 키는 사용자에게 노출하지 않는다 — 사용자는 `label` 친화 명칭만 본다. `build.handler` 는 CLAUDE.md CRITICAL RULES 의 올바른 핸들러(`navigate`/`apiCall`/`setState`/...)를 쓴다. `comment` 키는 레시피가 아닌 작성자 메모다.
`params`(친화 입력란) + `build`(핸들러 JSON 생성 틀)로 구성한다. `build` 값의 `{{paramKey}}`(중괄호 2개)가 입력값으로 치환된다. `onSuccess`/`onError` 가 `{{key}}` 이고 그 파라미터가 `action-list` 면 치환 결과는 중첩 액션 **배열**이 된다. 핸들러명/파라미터 키는 사용자에게 노출하지 않는다 — 사용자는 `label` 친화 명칭만 본다. `build.handler` 는 코어 핸들러 규칙의 올바른 핸들러(`navigate`/`apiCall`/`setState`/...)를 쓴다. `comment` 키는 레시피가 아닌 작성자 메모다.
`build.handler` 는 리터럴 핸들러명 대신 `{{paramKey}}` **placeholder** 도 가능하다 — 호출할 핸들러를 응답값 등 데이터로 결정하는 provider-agnostic 액션(예: ecommerce 모듈 `requestPgPayment` "결제 진입" — 핸들러를 `{{response.data.pg_payment_handler}}` 데이터 칩으로 연결)에 쓴다. 결제 같은 **도메인 동작은 코어가 아니라 그 도메인 확장(모듈/플러그인/템플릿)이 자기 editor-spec `actionRecipes` 에 소유**한다 — 코어는 PG/도메인을 모른다. 확장 레시피는 로더가 `__source` 메타를 붙여 〔확장식별자〕 배지·'extension' 그룹으로 자동 분류한다. 라벨은 그 확장의 lang 네임스페이스를 명시한다(예: `$t:sirsoft-ecommerce.editor.action.request_pg_payment.label`).
+16
View File
@@ -13,6 +13,7 @@
3. 설치/삭제 시 자동 갱신: updateComposerAutoload() → generateAutoloadFile()
4. 수동 갱신: php artisan extension:update-autoload
5. _bundled/_pending 디렉토리 자동 제외 (str_starts_with($name, '_') → skip)
6. src_classmap: 확장 소스 FQCN→경로 사전 계산 → findFile 파일시스템 stat 제거 (지연 로드·vendor 격리 유지)
```
---
@@ -79,6 +80,10 @@ return [
"modules/sirsoft-ecommerce/module.php",
"plugins/sirsoft-payment/plugin.php",
],
'src_classmap' => [
"Modules\\Sirsoft\\Ecommerce\\Services\\OrderService" => "modules/sirsoft-ecommerce/src/Services/OrderService.php",
"Plugins\\Sirsoft\\Payment\\Providers\\PaymentServiceProvider" => "plugins/sirsoft-payment/src/Providers/PaymentServiceProvider.php",
],
'files' => [],
'vendor_autoloads' => [
"modules/sirsoft-ecommerce/vendor/autoload.php",
@@ -86,8 +91,19 @@ return [
];
```
- `classmap`: `module.php` / `plugin.php` 등 부트스트랩 파일 목록 — 진입점에서 `require_once` (즉시 로드)
- `src_classmap`: 확장 **소스** 클래스의 `FQCN → 상대경로` 맵 — 진입점에서 `$loader->addClassMap()` (지연 로드)
- `vendor_autoloads`: 모듈/플러그인의 `vendor/autoload.php` 경로 목록 (Composer 의존성이 설치된 경우만 포함)
#### src_classmap — findFile 파일시스템 스캔 제거 (성능)
확장 소스 클래스는 `addPsr4()` 로만 등록되면 Composer `findFile()` 이 클래스마다 파일시스템을 스캔(`is_dir`/`file_exists` stat)한다. OPcache 가 cold 이거나 파일시스템이 느린 환경에서는 이 stat 비용이 요청 시간의 큰 비중을 차지할 수 있다. `src_classmap` 은 `generateAutoloadFile()` 이 각 PSR-4 디렉토리를 스캔해 `FQCN → 경로` 를 사전 계산하고, 진입점(`public/index.php` / `CoreServiceProvider` / `ExtensionManager::registerExtensionAutoload`)이 `ClassLoader::addClassMap()` 으로 등록한다. `findFile()` 은 classMap 을 **최우선 조회**하므로 확장 클래스는 파일시스템 접근 0 으로 즉시 경로를 얻는다.
- **지연 로드 유지**: classmap 은 경로만 제공하며 `include` 는 실제 사용 시점에. 매 요청 전량 로드가 아니다.
- **PSR-4 폴백 보존**: classmap 에 없는 클래스(신규 추가 후 미재생성 등)는 기존 PSR-4 경로로 폴백 — 안전망.
- **vendor 격리 불변**: `src_classmap` 은 확장 **소스**(`Modules\*`/`Plugins\*`)만 담는다. 확장별 독립 서드파티 vendor 는 각 확장 `vendor/autoload.php`(별도 ClassLoader) 가 담당하므로 여기 포함되지 않는다.
- **재생성**: `psr4` 와 동일 생명주기 — install / update / uninstall 시 `updateComposerAutoload()`, 코어 업데이트 시 `extension:update-autoload` 가 함께 재생성.
### 중요 규칙
```
+38
View File
@@ -484,6 +484,44 @@ private function registerCoreHookListeners(): void
| 위치 | `app/Listeners/**/*.php` | `modules/**/Listeners/`, `plugins/**/Listeners/` |
| 등록 주체 | `CoreServiceProvider` | `ModuleServiceProvider`, `PluginServiceProvider` |
---
## 정적 훅 매핑 캐시 (Static Hook Cache)
매 요청 부팅 시 코어(`app/Listeners` 재귀 스캔) + 모듈/플러그인(`getHookListeners()`)의 정적 훅 리스너를 발견·리플렉션·`getSubscribedHooks()` 클래스 로딩하는 비용을 제거하기 위해, 사전 계산한 훅 매핑을 `bootstrap/cache/hooks.php` 에 캐시합니다 (오토로드 캐시 `autoload-extensions.php` 와 동일 위치·생명주기).
캐시는 "무엇을 등록할지 목록" 만 제공하며 **등록 자체는 여전히 부팅에서 수행** 되므로 등록↔발화 순서 계약은 불변입니다. 캐시 경로 등록 결과는 스캔 경로와 **훅 매핑이 바이트 동일** 합니다 (`HookListenerRegistrar::registerFromCache()` 가 `register()` 와 동일한 `applySubscribedHooks()` 에 위임).
### 동작
| 상태 | 부팅 시 동작 |
|------|-------------|
| 캐시 존재 (`bootstrap/cache/hooks.php`) | 스캔·리플렉션·클래스 로딩 없이 캐시 매핑으로 등록 |
| 캐시 부재 / 손상 / 구조 불일치 | 기존 스캔 경로로 **안전 폴백** (항상 동작) |
| 테스트 환경 (`APP_ENV=testing`) | 캐시 미사용 — 매 setUp 스캔이 정확·격리 우선 |
- 동적 훅(알림 등 `registerDynamicHooks()`)은 캐시 대상이 아니며 코드 변경 없이 그대로 동작합니다. 캐시에는 각 리스너의 `dynamic` 플래그만 저장하여, 동적 훅 보유 코어 리스너의 boot 후반부 지연 실행 순서를 스캔 경로와 동일하게 유지합니다.
- 동적 훅의 DB 조회(`NotificationDefinitionService::getAllActive()`)는 이미 `['notification']` 태그로 캐시되어 있으므로 첫 요청(캐시 워밍) 이후 DB 조회가 발생하지 않습니다.
### 재생성 (무효화 = 재생성)
정적 훅 매핑은 **확장 변경 또는 코어 리스너 코드 배포 시에만** 바뀝니다. 해당 시점에 자동/수동 재생성됩니다:
| 사건 | 재생성 경로 |
|------|-----------|
| 확장 install / activate / deactivate / uninstall / update | `ExtensionManager::updateComposerAutoload()` 가 오토로드 캐시와 나란히 재생성 (자동) |
| 코어 업데이트 (`core:update`) | `clearAllCaches()` → `extension:update-autoload` 가 오토로드 + 훅 캐시 함께 재생성 (자동). 코어 리스너 추가/변경/삭제 반영 |
| 코어 리스너 코드 배포 | `php artisan hooks:cache` (배포 파이프라인 — `route:cache` 동형) |
```bash
php artisan hooks:cache # 정적 훅 매핑 캐시 생성 (bootstrap/cache/hooks.php)
php artisan hooks:clear # 캐시 삭제 (삭제 후 스캔 폴백 — 항상 안전)
```
캐시 파일은 Git 미추적(`bootstrap/cache/*`) 이며 배포 환경마다 생성됩니다. 캐시가 없어도 스캔 폴백으로 정상 동작하므로 필수는 아니지만, 프로덕션 배포 시 부팅 비용 절감을 위해 `config:cache`/`route:cache` 와 함께 실행하는 것을 권장합니다.
**핵심 파일**: `app/Extension/HookCacheManager.php`, `app/Extension/HookListenerRegistrar.php` (`registerFromCache`)
### 동적 훅 리스너 (DB 기반)
DB 설정에 따라 훅 구독 대상이 동적으로 변하는 경우, 리스너에 `registerDynamicHooks()` 메서드를 구현합니다.
@@ -606,7 +606,7 @@ iteration 내부에서 상태를 관리하는 컴포넌트(Input, Select, TagInp
- id가 없으면 동적으로 행 추가/삭제 시 이전 인스턴스의 내부 상태가 새 인스턴스에 표시될 수 있음
- 특히 내부 상태를 관리하는 컴포넌트(TagInput, Select, Input 등)에서 중요
> **iteration id 표현식 보간** (engine-v1.52.4+): 노드의 최상위 `id` 필드에 쓴 표현식(`id: "item_{{$idx}}"`, `id: "row_{{item.id}}"` 등)은 일반 렌더 경로(DynamicRenderer)에서 iteration 컨텍스트로 보간되어 행마다 고유 DOM id 가 된다. `index_var`(예 `"index_var": "$idx"`) 또는 `item_var` 의 필드를 활용한다. 정적 id 를 iteration 안에 그대로 두면 모든 행이 같은 HTML id 를 갖게 되어 W3C id 유일성을 위반하므로(렌더 누락까지 유발), 반복 항목 노드와 그 자식의 id 는 반드시 동적화한다. 내부 식별(slot 등록·React key)에는 원본 id 가 쓰이므로 보간은 DOM 출력에만 적용된다.
> **iteration id 표현식 보간** (engine-v1.50.0+): 노드의 최상위 `id` 필드에 쓴 표현식(`id: "item_{{$idx}}"`, `id: "row_{{item.id}}"` 등)은 일반 렌더 경로(DynamicRenderer)에서 iteration 컨텍스트로 보간되어 행마다 고유 DOM id 가 된다. `index_var`(예 `"index_var": "$idx"`) 또는 `item_var` 의 필드를 활용한다. 정적 id 를 iteration 안에 그대로 두면 모든 행이 같은 HTML id 를 갖게 되어 W3C id 유일성을 위반하므로(렌더 누락까지 유발), 반복 항목 노드와 그 자식의 id 는 반드시 동적화한다. 내부 식별(slot 등록·React key)에는 원본 id 가 쓰이므로 보간은 DOM 출력에만 적용된다.
---
+2 -2
View File
@@ -219,7 +219,7 @@ test('unauthenticated_401 × no_token', async ({ page }) => {
`audit-branch.cjs` 의 `test-scenario-coverage` 룰이 매니페스트 axes ↔ docblock 매칭을 자동 검증.
## §8. 회귀 테스트 4단계 (CLAUDE.md 의무 절차 준수)
## §8. 회귀 테스트 4단계
버그 수정 시 다음 4단계를 스킵 불가:
@@ -236,7 +236,7 @@ E2E spec 작성 중 발견한 무관 에러는 같은 세션에서 처리:
- **테스트 stale** (logic 정상, 테스트가 오래됨) → 테스트 수정
- **로직 회귀** (테스트 정상, 로직이 의도와 어긋남) → 코드 수정
- **데이터 구조 불일치** (양쪽 모두 오래됨) → PO 보고 + 보류
- **데이터 구조 불일치** (양쪽 모두 오래됨) → 보고 + 보류
상세: `testing-guide.md` "무관 에러 처리 분기".
@@ -4,6 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-03
### Added
- 설정 복원 관련 안내 메시지 일본어 번역 추가 (`settings.backup_path_required`·`restore_success`·`restore_failed`·`restore_error`) — 백업 경로 미입력 안내와 설정 복원 성공/실패 결과가 일본어 로케일에서 자연스럽게 표시됩니다.
- 훅 매핑 캐시 관리 안내 메시지 일본어 번역 추가 (`hooks.cache_generated`·`cache_cleared`·`cache_absent`) — 부팅 성능을 위한 훅 캐시 생성·삭제 결과가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.0] - 2026-07-01
### Added
@@ -0,0 +1,7 @@
<?php
return [
'cache_generated' => '静的フック マッピング キャッシュを作成しました。(コア :core / モジュール :modules / プラグイン :plugins) → :path',
'cache_cleared' => '静的フック マッピング キャッシュを削除しました。',
'cache_absent' => '削除する静的フック マッピング キャッシュがありません。',
];
@@ -145,4 +145,8 @@ return [
'source_vendor_missing' => 'ソースディレクトリにvendorがありません。composer installが実行されていない可能性があります。',
'composer_failed_with_output' => 'composer installの実行に失敗しました。:output',
],
'backup_path_required' => 'バックアップパスを入力してください。',
'restore_success' => '設定が正常に復元されました。',
'restore_failed' => '設定の復元に失敗しました。',
'restore_error' => '設定の復元中にエラーが発生しました。',
];
@@ -12,7 +12,7 @@
"en": "G7 core Japanese language pack (bundled)",
"ja": "G7 コア 日本語 言語パック(バンドル)"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"scope": "core",
"target_identifier": null,
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
// Static hook mapping cache (hooks:cache / hooks:clear)
'cache_generated' => 'Static hook mapping cache generated. (core :core / modules :modules / plugins :plugins) → :path',
'cache_cleared' => 'Static hook mapping cache cleared.',
'cache_absent' => 'No static hook mapping cache to clear.',
];
+4
View File
@@ -21,6 +21,10 @@ return [
'backup_success' => 'Database backup started successfully.',
'backup_failed' => 'Failed to start database backup.',
'backup_error' => 'An error occurred while backing up database.',
'backup_path_required' => 'Please enter a backup path.',
'restore_success' => 'Settings restored successfully.',
'restore_failed' => 'Failed to restore settings.',
'restore_error' => 'An error occurred while restoring settings.',
'save_individual_failed' => 'Settings save failed: :error',
// App key related messages
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
// 정적 훅 매핑 캐시 (hooks:cache / hooks:clear)
'cache_generated' => '정적 훅 매핑 캐시를 생성했습니다. (코어 :core / 모듈 :modules / 플러그인 :plugins) → :path',
'cache_cleared' => '정적 훅 매핑 캐시를 삭제했습니다.',
'cache_absent' => '삭제할 정적 훅 매핑 캐시가 없습니다.',
];
+4
View File
@@ -21,6 +21,10 @@ return [
'backup_success' => '데이터베이스 백업이 성공적으로 시작되었습니다.',
'backup_failed' => '데이터베이스 백업에 실패했습니다.',
'backup_error' => '데이터베이스 백업 중 오류가 발생했습니다.',
'backup_path_required' => '백업 경로를 입력해주세요.',
'restore_success' => '설정이 성공적으로 복원되었습니다.',
'restore_failed' => '설정 복원에 실패했습니다.',
'restore_error' => '설정 복원 중 오류가 발생했습니다.',
'save_individual_failed' => '설정 저장에 실패했습니다: :error',
// 앱 키 관련 메시지
@@ -968,7 +968,7 @@ describe('_tab_report_policy.json - 신고 정책 탭', () => {
it('admin-card 안 4 섹션 description 이 .card-description 시맨틱이고 card-title 의 직계 sibling 이다 (#408)', () => {
// _tab_report_policy.json 의 4 섹션 (자동 숨김, abuse_prevention, permissions, notification)
// description 이 stack 안에 묻혀있으면 .card-title:has(+ .card-description) 셀렉터 불발 →
// card-title 의 하단 여백이 의도(mb-1)와 다르게 mb-6 으로 적용됨 (PO 보고 #408)
// card-title 의 하단 여백이 의도(mb-1)와 다르게 mb-6 으로 적용됨 (보고 #408)
//
// 기대: admin-card 직계 자식 순서가 [card-title, card-description, stack, ...] 이고,
// card-description 의 className 에 'card-description' 시맨틱 자산이 포함되어야 함.
@@ -1045,7 +1045,7 @@ describe('_tab_report_policy.json - 신고 정책 탭', () => {
});
it('board layout 의 모든 .card-description 은 Div 태그여야 한다 — Span/P 금지 (#408)', () => {
// PO 표준: span.card-description / p.card-description 는 잘못된 시맨틱 HTML
// 표준: span.card-description / p.card-description 는 잘못된 시맨틱 HTML
// → div.card-description 만 허용.
const layouts: Array<[string, any]> = [
['mainLayout', mainLayout],
@@ -5,7 +5,7 @@
* 게시글 삭제로 함께 숨겨진(cascade) 댓글이 사용자 직접 삭제 댓글과 동일하게
* "삭제된 댓글입니다" 로 마스킹되던 회귀의 방지.
*
* 정책(PO 확정): 관리 권한자에게 cascade 댓글은 마스킹 없이 원문을 노출한다.
* 정책(확정): 관리 권한자에게 cascade 댓글은 마스킹 없이 원문을 노출한다.
* 백엔드(CommentResource)가 is_cascade_deleted 플래그를 내려주고,
* 레이아웃은 그 플래그로 분기를 결정한다.
*
@@ -265,7 +265,7 @@ describe('admin_dashboard_community.json - community 슬롯 주입', () => {
// 카드는 Pending/Review 신고만 표시한다 (ReportRepository::getPendingAcrossBoards).
// 배지는 신고 대상(게시글/댓글)의 상태가 아니라 신고 건 자체의 처리상태(status)를 보여준다.
// 색상은 신고현황 페이지(admin_board_reports_index 의 status 컬럼) 와 동일한 매핑을 사용한다.
// PO 시각 검증 피드백: "대상 상태가 아닌 처리상태가 보여져야 한다" + "처리상태는 게시판 신고현황과 동일한 색상으로".
// 시각 검증 피드백: "대상 상태가 아닌 처리상태가 보여져야 한다" + "처리상태는 게시판 신고현황과 동일한 색상으로".
const statusBadge = findById(communityExt as any, 'report_status_badge');
expect(statusBadge).not.toBeNull();
expect(statusBadge.if).toBe('{{!!report?.status_label}}');
@@ -2,7 +2,7 @@
* @file board-copy-modal-info.test.tsx
* @description 게시판 복사 확인 모달 - 안내 박스(배지 박스 + 불릿 3항목) 레이아웃 JSON 구조 검증
*
* 이슈 #413-16 후속(PO 요청): 복사 모달 안내를 "설정·권한·관리자 복사 / 게시글·댓글 제외 /
* 이슈 #413-16 후속(요청): 복사 모달 안내를 "설정·권한·관리자 복사 / 게시글·댓글 제외 /
* 새 슬러그 확인" 3항목으로 구조화. 안내 박스가 테두리+배경 컨테이너 안에 불릿 3개를
* 포함하는지 JSON 트리로 검증한다 (DOM 비의존 구조 검증).
*/
@@ -216,7 +216,7 @@ class CommentRepository implements CommentRepositoryInterface
* 재귀적으로 모든 하위 자손 수를 계산합니다.
*
* tombstone(삭제) 댓글은 트리 구조 유지를 위해 노드로는 남기지만,
* "답글 N" 집계 모수에서는 제외한다(PO 확정). 단, 삭제 댓글의
* "답글 N" 집계 모수에서는 제외한다(확정 정책). 단, 삭제 댓글의
* 살아있는 하위 자손은 계속 카운트해 누락되지 않게 한다.
*
* @param Collection $grouped parent_id로 그룹화된 댓글 컬렉션
@@ -21,7 +21,7 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase;
* 증상2 — cascade 로 함께 숨겨진 댓글이 user 선삭제 댓글과 동일하게
* "삭제된 댓글입니다" 로 마스킹됨 (cascade/user 구분 신호 부재)
*
* 정책(PO 확정):
* 정책(확정):
* - 첨부: 관리 권한자에게는 댓글과 동일하게 cascade 삭제분도 노출
* - cascade 댓글: 관리 권한자에게는 마스킹 없이 원문 노출 (is_cascade_deleted 신호 제공)
* - user 선삭제분: 기존대로 마스킹 유지
@@ -132,7 +132,7 @@ class PostDeleteCascadeTest extends BoardTestCase
}
/**
* 게시글 복원 시 cascade 항목만 복원되고, 사용자가 먼저 지운 것은 trashed 로 유지된다 (PO 우려 차단).
* 게시글 복원 시 cascade 항목만 복원되고, 사용자가 먼저 지운 것은 trashed 로 유지된다 (우려 차단).
*
* @effects cascaded_comment_restored_on_post_restore,cascaded_attachment_restored_on_post_restore,user_pre_deleted_comment_stays_trashed,user_pre_deleted_attachment_stays_trashed
*/
@@ -16,7 +16,7 @@ use PHPUnit\Framework\Attributes\Test;
*
* 게시판 내부 검색은 별도 엔드포인트가 아니라 목록 조회(index)에 search 필터를 얹은 것이다.
* 비밀글(is_secret)·블라인드(status=blinded) 게시글은 목록/검색 응답에서 본문 요약이
* 권한과 무관하게 빈 문자열로 차단되어야 한다 (PO 확정: 목록 미리보기는 권한자도 숨김).
* 권한과 무관하게 빈 문자열로 차단되어야 한다 (확정 정책: 목록 미리보기는 권한자도 숨김).
*
* 제목은 현행 유지(노출), 차단 대상은 content_preview 뿐.
* 상세 본문(content) 권한자 노출은 기존 정책(PostBlindedAccessControlTest)이 보장 — 본 테스트 범위 밖.
@@ -237,7 +237,7 @@ class PostSearchSecretPreviewTest extends BoardTestCase
/**
* 비밀글 미리보기는 작성자 본인에게도 목록에서는 차단된다(빈 문자열).
* (PO 확정: 목록 미리보기는 권한자도 숨김 — 본문은 상세에서만 노출)
* (확정 정책: 목록 미리보기는 권한자도 숨김 — 본문은 상세에서만 노출)
*
* @scenario viewer=author
*
@@ -183,7 +183,7 @@ class BoardDashboardServiceTest extends ModuleTestCase
// FormatsBoardDate 표준형 규칙:
// - 24시간 이상 차이: 올해는 'MM-DD', 작년 이전은 'YY-MM-DD'
// 캡션이 게시판 날짜 표시 규칙과 일관되어야 한다는 회귀 가드.
// (게시글 created_at 표시와 동일 컨벤션 — PO 시각 검증 피드백)
// (게시글 created_at 표시와 동일 컨벤션 — 시각 검증 피드백)
$today = CarbonImmutable::today();
// 어제 자정에 마지막 스케줄러가 돈 상황을 시뮬레이션
$yesterdayBoundary = $today->subDay()->setTime(0, 0, 0);
@@ -223,7 +223,7 @@ class BoardDashboardServiceTest extends ModuleTestCase
public function test_get_recent_posts_excludes_replies(): void
{
// 대시보드 "최신 게시글" 카드는 본문 글만 보여줘야 한다 (답글 Re: 제외).
// PO 시각 검증 피드백: "최신 게시글이 최신순으로 안 나오는 것 같다" — 원인은 답글이 섞여 표시된 것.
// 시각 검증 피드백: "최신 게시글이 최신순으로 안 나오는 것 같다" — 원인은 답글이 섞여 표시된 것.
// PostRepository::getRecentAcrossBoards 가 whereNull('parent_id') 누락했던 회귀 가드.
$parent = $this->makePost('원글');
$reply = $this->makePost('Re: 원글');
@@ -17,7 +17,7 @@ description: |
(composite/ImageGallery.tsx 의 downloadAuthenticatedFile 과 동일 패턴).
· 레이아웃 카드 3곳 전환: 사용자 _post_attachments(권한 분기 보존),
관리자 _post_card_content / _reply_card_content(무분기) 의 <A href> → Div + click 핸들러.
- PO 정책 확정:
- 정책 확정:
· 범위 = 게시판만 3곳 (페이지 첨부는 별도). 비회원 = NULL 유지(현행). 성공 토스트 없음.
· 서버(라우트/컨트롤러/서비스) 무변경 — optional.sanctum 유지가 비회원 NULL 정책과 정합.
@@ -16,7 +16,7 @@ description: |
canViewBlindedContent(관리자 OR 작성자 본인) 게이트 추가, 권한 없으면 null
· CommentResource::getFilteredContent — 동일 결함 → 동일 게이트 추가
· PostResource::toListArray — 블라인드 글 content_preview 를 빈 문자열로 차단
- PO 정책 확정 (일반 관례): 원문 = 관리자 + 작성자 본인. 안내/사유/제목은 전원 노출.
- 정책 확정 (일반 관례): 원문 = 관리자 + 작성자 본인. 안내/사유/제목은 전원 노출.
- 비밀글(canViewSecretContent)과 달리 비밀번호 검증·posts.read-secret 은 인정하지 않음
(블라인드는 운영 제재이므로 비밀번호로 해제되지 않음).
@@ -13,7 +13,7 @@ description: |
identifier 는 stripBoardScopeRoles 로 제거 (attachRoles 의 전역 whereIn 으로 인한 교차 누수 차단)
· 새 게시판 manager/step 역할은 생성 시 injectBoardRolesToPermissions 가 자동 주입
· 비-스코프 역할(member 등) 은 그대로 보존
- PO 정책 확정: 복제는 원본 manager/step 명단을 승계 (순수 생성 모드는 본인 단독)
- 정책 확정: 복제는 원본 manager/step 명단을 승계 (순수 생성 모드는 본인 단독)
- role 조회 로직은 BoardResource::getBoardRoleData 를 public static 으로 공유 (SSoT)
axes:
@@ -16,7 +16,7 @@ description: |
고아 자식의 조상 체인을 따라 누락된 삭제 부모를 일괄 IN 조회(N+1 회피)로
tombstone 노드 복구. 자식 없는 삭제 부모는 고아 자식이 없어 자연 제외.
· countDescendants() 보정 — tombstone(삭제) 댓글은 "답글 N" 집계 모수에서 제외
(트리 노드로는 유지, 살아있는 자손은 계속 카운트). PO 확정.
(트리 노드로는 유지, 살아있는 자손은 계속 카운트). 확정 정책.
· content 권한·표시(tombstone 안내 + 관리자 원문 토글)는 #413-50 기구현 재사용.
· withTrashed=true(manager del_cmt 토글) 경로는 이미 전체 노출 → 고아 복구 비적용.
@@ -15,7 +15,7 @@ description: |
(b) 저장 경로는 slug 기준({slug}/...)인데 삭제는 board_id 기준 디렉토리를
지우려 해 경로 불일치로 아무 파일도 안 지워짐.
조치 (PO 결정 B안):
조치 (결정 B안):
· Repository 3종(Post/Comment/Attachment)에 forceDeleteByBoardId 신설
(기존 softDeleteByBoardId 의 delete → forceDelete). 인터페이스 + 구현.
· BoardService::deleteBoard() 의 자식 정리를 force 로 교체.
@@ -14,7 +14,7 @@ description: |
미처리 → 비로그인·제3자도 게시판 내부 검색/목록 응답에서 비밀글 본문 요약 열람 가능 (보안 결함).
- 수정: PostResource::getMaskedContentPreviewForList 신설 — status=Blinded 또는 is_secret 이면
권한 무관 빈 문자열. toListArray 가 이 헬퍼를 사용.
- PO 정책 확정: 목록 미리보기는 권한자(작성자/관리자)에게도 숨김(상세에서만 본문 노출).
- 정책 확정: 목록 미리보기는 권한자(작성자/관리자)에게도 숨김(상세에서만 본문 노출).
DB select 심층방어는 미포함(응답 계층 차단만 — 노출 경계 차단 관례).
- 통합검색(/api/search)은 쿼리에서 published+non-secret 만 검색하므로 누수 없음(무관).
@@ -5,7 +5,7 @@ description: |
정리되지 않아, 게시글만 trashed 되고 댓글·첨부는 살아남아 목록/상세에서 고아로
노출되던 문제를 수정한다. 동시에 게시글 복원 시 cascade 로 지워진 항목만 선택
복원하여, 삭제 이전에 사용자가 직접 지운 항목이 게시글 복원과 함께 부활하는
문제(PO 우려)를 차단한다.
문제(우려)를 차단한다.
근본 원인:
· PostService::deletePost() 가 게시글 자신만 soft delete 하고 댓글·첨부는
@@ -14,7 +14,7 @@ description: |
· 첨부 테이블(board_attachments)에는 cascade 와 사용자 삭제를 구분할
trigger_type 컬럼 자체가 없었다.
조치 (PO 결정 방법1: 연쇄 soft delete + cascade 플래그 기반 선별 복원):
조치 (결정 방법1: 연쇄 soft delete + cascade 플래그 기반 선별 복원):
· board_comments.trigger_type enum 에 'cascade' 추가 (마이그레이션).
· board_attachments 에 trigger_type enum 컬럼 신설 (+ board_id/post_id/trigger_type
복합 인덱스).
@@ -1,4 +1,4 @@
// e2e:allow MP13 — i18n/UX 소품 묶음(라벨 표시·링크 분기·모달 조건부 필드·placeholder 치환). 핵심 회귀는 Vitest(모듈 22 + 템플릿 13)로 커버, 라이브 검수는 계획서 §14 Chrome MCP 매트릭스(PO 환경)로 수행.
// e2e:allow MP13 — i18n/UX 소품 묶음(라벨 표시·링크 분기·모달 조건부 필드·placeholder 치환). 핵심 회귀는 Vitest(모듈 22 + 템플릿 13)로 커버, 라이브 검수는 계획서 §14 Chrome MCP 매트릭스(검수 환경)로 수행.
/**
* bulkUpdateHandlers 테스트
*
@@ -4,7 +4,7 @@
* 배경:
* - partial_cancelled 주문상태 제거 후, 일부 취소 사실은 파생 플래그 is_partially_cancelled 로 표시한다.
* - DataGrid order_status 컬럼 cellChildren 에 StatusBadge + 보조 배지를 평면 형제로 두면
* 목록 셀과 모바일 카드(responsiveBreakpoint)에서 한 줄로 나란히 떠 깨진다(PO 발견).
* 목록 셀과 모바일 카드(responsiveBreakpoint)에서 한 줄로 나란히 떠 깨진다(검수 발견).
* → 둘을 flex-col 묶음 Div 로 감싸 상태 배지 아래(두 줄)에 보조 배지를 배치한다.
* DataGrid 는 동일 columns 정의를 목록·카드 양쪽에 재사용하므로 한 곳 수정으로 둘 다 적용된다.
*
@@ -406,7 +406,7 @@ describe('DataGrid (_transactions_table.json)', () => {
expect(edit).toBeTruthy();
// 비적립계 또는 권한없음이면 비활성 — Resource 가 can_edit = can_manage && isEarning 으로 계산
expect(edit.disabledField).toBe('abilities.can_edit');
// 원장 불변: 삭제 액션은 존재하지 않는다 (PO 확정)
// 원장 불변: 삭제 액션은 존재하지 않는다 (정책 확정)
const ids = grid.props.rowActions.map((a: any) => a.id);
expect(ids).not.toContain('delete');
expect(ids).not.toContain('manual');
@@ -3,7 +3,7 @@
*
* @description
* - 마일리지 설정은 이커머스 환경설정(admin_ecommerce_settings.json)의 서브탭으로 통합됨
* (별도 독립 화면 아님 — PO 합의사항)
* (별도 독립 화면 아님 — 합의사항)
* - 환경설정 메인: mileage 탭 등록 + 탭 콘텐츠 partial(_tab_mileage.json) 참조 + 탭 if 가드
* - 탭 콘텐츠 partial 5종(탭/기본/통화테이블/통화카드/유효기간/소멸알림) 구조 검증
* - 카드 4종(기본/통화/유효기간/소멸알림), 토글 3종, 적립시점 기본=구매확정
@@ -180,7 +180,7 @@ describe('마일리지 탭 콘텐츠 (_tab_mileage.json)', () => {
});
it('마일리지 내역 보기 버튼이 기본 설정 카드 헤더에 있고 내역 화면으로 이동한다', () => {
// 공간 절약 위해 별도 헤더 행 대신 기본 설정 카드 우측 상단으로 이동 (PO 요청)
// 공간 절약 위해 별도 헤더 행 대신 기본 설정 카드 우측 상단으로 이동 (요청)
const btn = findById(basicCard, 'view_transactions_button');
expect(btn, '내역 보기 버튼이 기본 설정 카드에 없다').toBeTruthy();
expect(btn.actions[0].handler).toBe('navigate');
@@ -102,7 +102,7 @@ describe('마이페이지 프로필 내 마일리지 카드 주입 (mypage-profi
it('사용자 화면(마이페이지) 카드는 ExtensionBadge 를 사용하지 않는다 (회귀 차단)', () => {
// ExtensionBadge 는 관리자 템플릿(sirsoft-admin_basic)에만 존재하는 컴포넌트로,
// 사용자 템플릿(sirsoft-basic)에는 미등록이라 사용자 화면에 주입 시 "컴포넌트를 찾을 수 없습니다" 렌더 실패.
// PO 정책: 사용자 화면에서 ExtensionBadge 사용 금지.
// 정책: 사용자 화면에서 ExtensionBadge 사용 금지.
const json = jsonOf(mypageCard);
expect(json).not.toContain('ExtensionBadge');
});
@@ -258,7 +258,7 @@ describe('productFormLayouts', () => {
* "Failed to execute action: navigate" 오류 토스트가 추가로 발생하고
* 목록 페이지로 이동하지 않음 (성공 토스트 + 오류 토스트 2건 동시 노출).
* 원인: onSuccess 시퀀스의 navigate 핸들러가 params.url 로 경로를 전달했으나,
* G7 navigate 핸들러는 params.path 만 인식한다 (CLAUDE.md "navigate path 필수").
* G7 navigate 핸들러는 params.path 만 인식한다 (코어 규정: navigate path 필수).
* url 키는 무시되어 finalPath=undefined → navigate throw → 오류 토스트.
* 동일 모듈의 다른 navigate(주문 상세/브랜드 목록 등)는 모두 path 사용.
* 해결: onSuccess navigate 의 params.url → params.path 로 정정.
@@ -527,7 +527,7 @@ describe('productFormLayouts', () => {
findNodeById(otherInfoPartial, 'field_max_purchase_qty'),
]);
// $event.target.value 는 허용하되, parseInt($event)/Number($event) 같은
// 이벤트 객체 직접 숫자화 패턴은 금지 (CLAUDE.md 데이터 바인딩 규정)
// 이벤트 객체 직접 숫자화 패턴은 금지 (코어 데이터 바인딩 규정)
expect(serialized).not.toMatch(/parseInt\(\$event\)/);
expect(serialized).not.toMatch(/Number\(\$event\)/);
});
@@ -95,7 +95,7 @@ describe('header-currency-selector-user.json — 헤더 공용 셀렉터 2섹션
});
it('루트는 if(마운트 게이트) 없이 항상 마운트 + className 으로 기본 hidden 토글 (깜빡임 방지)', () => {
// PO 회귀(유저 헤더): if 마운트/언마운트 대신 항상 마운트 + className 기본 hidden(display:none) → 조건 충족 시 해제.
// 회귀(유저 헤더): if 마운트/언마운트 대신 항상 마운트 + className 기본 hidden(display:none) → 조건 충족 시 해제.
expect(root.if).toBeUndefined();
const cls = root.props?.className ?? '';
expect(cls).toContain("'hidden'");
@@ -160,7 +160,7 @@ describe('header-currency-selector-admin.json — 관리자 헤더 2섹션', ()
});
it('루트는 if(마운트 게이트) 없이 항상 마운트 + className 으로 기본 hidden 토글 (깜빡임 방지)', () => {
// PO 회귀: if 로 마운트/언마운트하면 첫 페인트에 잠깐 보였다 사라진다.
// 회귀: if 로 마운트/언마운트하면 첫 페인트에 잠깐 보였다 사라진다.
// 항상 마운트하되 className 에 기본 hidden(display:none) 을 깔고 조건 충족 시에만 해제한다.
expect(root.if).toBeUndefined();
const cls = root.props?.className ?? '';
@@ -1201,7 +1201,7 @@ export function testShippingApiHandler(
G7Core.state.set?.({ apiTestLoading: true, apiTestResult: null });
// apiCall 구조: URL 은 액션 top-level target, method/body 는 params,
// onSuccess/onError 도 top-level (CLAUDE.md apiCall 규약). 백엔드 test-api-call
// onSuccess/onError 도 top-level (코어 apiCall 규약). 백엔드 test-api-call
// 엔드포인트가 사용자가 입력한 설정으로 외부 API 를 대신 호출하고 결과를 서빙한다.
G7Core.dispatch({
handler: 'apiCall',
@@ -421,7 +421,7 @@
]
},
{
"comment": "환불 우선순위 라디오 — PG 환불 가능(requires_pg_cancellation)일 때 노출한다. 포인트 사용분이 없으면(total_points_used_amount=0) PG↔포인트 배분 순서가 무의미하므로 노출은 하되 선택 불가(disabled)로 둔다 (A28, PO 2026-06-22).",
"comment": "환불 우선순위 라디오 — PG 환불 가능(requires_pg_cancellation)일 때 노출한다. 포인트 사용분이 없으면(total_points_used_amount=0) PG↔포인트 배분 순서가 무의미하므로 노출은 하되 선택 불가(disabled)로 둔다 (A28, 2026-06-22).",
"type": "basic",
"name": "Div",
"if": "{{order.data?.payment?.requires_pg_cancellation === true}}",
@@ -339,7 +339,7 @@ enum OrderStatusEnum: string
* - 취소(CANCELLED) 진입은 이 목록에 포함하지 않는다(canTransitionTo 가 별도 통과 처리).
* - 현재 상태가 CANCELLED 면 판매 반영 상태(salesEligibleStatuses)로만 복원 허용
* (취소된 주문/옵션을 다시 판매 진행 상태로 되살리는 reactivation 운영 기능 — 재고 재차감 동반.
* 결제 전 상태(pending_order/pending_payment)로의 복원은 차단. PO 2026-06-23).
* 결제 전 상태(pending_order/pending_payment)로의 복원은 차단. 2026-06-23).
*
* @return array<self>
*/
@@ -116,7 +116,7 @@ class MileageTransactionResource extends BaseApiResource
* 행 레벨 능력을 계산합니다.
*
* 표준 can_manage 에 더해, 행 편집 메뉴('사유·기간 변경') 게이팅용 can_edit 를 노출합니다.
* can_edit = manage 권한 보유 && 적립계 거래 (비적립계 행은 메뉴 비활성 — PO 합의 C-2).
* can_edit = manage 권한 보유 && 적립계 거래 (비적립계 행은 메뉴 비활성 — 합의 C-2).
*
* @param Request $request HTTP 요청
* @return array<string, bool> 능력 불리언 맵
@@ -175,7 +175,7 @@ class MileageTransactionListener implements HookListenerInterface
// 취소 전이: 기적립 건 회수.
// 취소는 옵션 단위로 처리되므로 회수도 옵션(CANCELLED) 기준이다 — 부분취소든 전체취소든
// 취소된 옵션마다 이 분기를 타 개별 회수된다(별도 partial_cancelled 상태 불요). PO 2026-06-22
// 취소된 옵션마다 이 분기를 타 개별 회수된다(별도 partial_cancelled 상태 불요). 2026-06-22
if ($newStatus === OrderStatusEnum::CANCELLED) {
$this->mileageService->cancelEarnForOption($order, $option);
}
@@ -186,7 +186,7 @@ class MileageTransactionListener implements HookListenerInterface
*
* mileage.enabled 토글은 주문 당시 적립액 "계산"(OrderCalculationService) 단계에서만 작용한다.
* 리스너는 이미 계산·저장된 subtotal_earned_points_amount 를 실행할 뿐이므로 enabled 를 재검사하지 않는다.
* (저장값이 0 이면 Service 가 자연 no-op — PO 확정: 계산 완료된 적립은 토글로 막지 않음)
* (저장값이 0 이면 Service 가 자연 no-op — 정책 확정: 계산 완료된 적립은 토글로 막지 않음)
*
* @param Order $order 주문
* @param OrderOption $option 주문옵션
@@ -7,12 +7,12 @@ use App\Models\User;
use Modules\Sirsoft\Ecommerce\Services\UserMileageService;
/**
* 회원 탈퇴/삭제 시 마일리지 데이터 정리 리스너 (PO 확정 #8)
* 회원 탈퇴/삭제 시 마일리지 데이터 정리 리스너 (정책 확정 #8)
*
* 코어 UserService 의 탈퇴(before_withdraw)·삭제(before_delete) 훅을 구독해,
* 활성 lot 을 expired 거래로 소멸 기록(감사 흐름 보존)한 뒤 원장·잔액 캐시 행을 명시 삭제합니다.
* 탈퇴는 soft 상태 변경이라 cascadeOnDelete 가 발화하지 않으므로 명시 정리가 필수입니다
* (CLAUDE.md "DB CASCADE 의존 삭제 금지"). cascade 는 삭제 경로의 안전망으로만 둡니다.
* (코어 규정: DB CASCADE 의존 삭제 금지). cascade 는 삭제 경로의 안전망으로만 둡니다.
*/
class UserMileageCleanupListener implements HookListenerInterface
{
@@ -526,7 +526,7 @@ class Order extends Model
*
* 부분취소는 별도 주문 상태(partial_cancelled)가 아니라 "취소된 옵션이 있으면서
* 잔여 활성 옵션도 존재"하는 파생 상태다. 주문 상태(order_status)는 잔여 활성 옵션 기준으로
* 결정되고, "일부가 취소되었다"는 사실은 이 플래그로 노출한다 (PO 2026-06-22, partial_cancelled 제거).
* 결정되고, "일부가 취소되었다"는 사실은 이 플래그로 노출한다 (2026-06-22, partial_cancelled 제거).
*
* @return bool 일부 옵션만 취소된 주문이면 true
*/
@@ -664,7 +664,7 @@ class OrderAdjustmentService
{
$updates = [];
// 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼도 재산출 (PO 확정)
// 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼도 재산출 (정책 확정)
$currencySnapshot = $order->currency_snapshot;
// 재계산 결과에서 옵션별 금액 반영
@@ -118,7 +118,7 @@ class OrderCalculationService
// 쿠폰 조합 가능 여부 검증 (is_combinable) — 전체 슬롯 통합 1회 (A15/MP06)
// 슬롯별 독립 검증은 "상품별(false) 1개 + 주문(true) 1개" 같은 슬롯 교차 조합을
// 각 슬롯 count=1 이라 놓친다. 적용 대상 전체 쿠폰을 한 집합으로 모아 1회 검증한다.
// 스냅샷 모드(환불 재계산)에서도 동일 검증한다(PO 2026-06-19 확정).
// 스냅샷 모드(환불 재계산)에서도 동일 검증한다(2026-06-19 확정).
$allAppliedCoupons = $this->collectAllCouponsForCombination($coupons, $itemCouponIssues);
$combinationViolations = $this->findNonCombinableViolations($allAppliedCoupons);
foreach ($combinationViolations as $violationIssue) {
@@ -874,7 +874,7 @@ class OrderCalculationService
// 마일리지 기능 비활성 시 주문 시점 적립 "계산" 자체를 수행하지 않는다 (전부 0).
// mileage_value/mileage_type 은 상품 옵션(상품 등록 당시) 속성이므로,
// 전역 OFF 면 상품옵션 명시 적립률·기본 적립률 모두 계산 대상에서 제외된다 (PO 확정).
// 전역 OFF 면 상품옵션 명시 적립률·기본 적립률 모두 계산 대상에서 제외된다 (정책 확정).
if (! (bool) $this->settingsService->getSetting('mileage.enabled', false)) {
$pointsPerItem[$optionId] = 0;
@@ -683,7 +683,7 @@ class OrderCancellationService
}
// 부분취소는 별도 주문 상태(partial_cancelled)를 두지 않는다 — 옵션은 이미 CANCELLED 로 전이됐고,
// 주문 상태는 아래 syncParentOrderStatus 가 "취소 제외 잔여 활성 옵션" 기준으로 파생 결정한다.
// (잔여 옵션이 모두 취소된 경우 syncParentOrderStatus 가 CANCELLED 로 전이.) — PO 2026-06-22
// (잔여 옵션이 모두 취소된 경우 syncParentOrderStatus 가 CANCELLED 로 전이.) — 2026-06-22
$order->update($updateData);
@@ -603,7 +603,7 @@ class OrderOptionService
*
* 부분취소는 별도 주문 상태가 아니라 "취소 옵션 + 잔여 활성 옵션 공존" 파생 상태다.
* 따라서 주문 상태는 항상 잔여 활성 옵션 기준으로 결정하고, 부분취소 여부는
* Order::isPartiallyCancelled() 파생 플래그로 노출한다 (PO 2026-06-22, partial_cancelled 제거).
* Order::isPartiallyCancelled() 파생 플래그로 노출한다 (2026-06-22, partial_cancelled 제거).
*
* @param int $orderId 동기화할 주문 ID
*/
@@ -7,7 +7,7 @@ namespace Modules\Sirsoft\Ecommerce\Services;
*
* 유저 locale 을 환경설정의 통화별 언어(currencies[].locales) 매핑과 대조해 부여 통화를 결정한다.
*
* 매칭 우선순위(D-SIGNUP, PO 확정):
* 매칭 우선순위(D-SIGNUP, 정책 확정):
* 1) locale 이 매칭되는 통화가 1개 → 그 통화
* 2) 여러 통화에 중복 매칭 → 매칭된 통화 중 is_default 통화 우선
* 3) 매칭 없음/모호 → is_default 통화 → 그래도 없으면 default_currency 폴백
@@ -731,7 +731,7 @@ class UserMileageService
}
/**
* 회원 탈퇴/삭제 시 활성 lot 소멸 기록 (감사 흐름 보존 — PO 확정 #8)
* 회원 탈퇴/삭제 시 활성 lot 소멸 기록 (감사 흐름 보존 — 정책 확정 #8)
*
* 활성 lot 을 expired 거래로 소멸 기록(활동로그에 금액·통화·회원ID 잔존)합니다.
* 거래/캐시 행은 이후 cascade 또는 명시 삭제로 함께 사라집니다.
@@ -764,7 +764,7 @@ class UserMileageService
}
/**
* 회원 탈퇴/삭제 시 마일리지 데이터를 명시적으로 정리합니다 (PO 확정 #8).
* 회원 탈퇴/삭제 시 마일리지 데이터를 명시적으로 정리합니다 (정책 확정 #8).
*
* 활성 lot 을 expired 거래로 소멸 기록(활동로그 보존)한 뒤, 원장·잔액 캐시 행을
* 동일 트랜잭션에서 삭제합니다. CASCADE 는 안전망으로만 두고 명시 삭제합니다
@@ -986,7 +986,7 @@ class UserMileageService
* 마일리지 사용이 가능한지 여부를 반환합니다.
*
* 기본 통화(base_currency)에 대한 마일리지 통화별 사용 단위 규칙이 설정되어 있지 않으면
* 마일리지 사용을 허용하지 않는다(PO 정책). 마일리지는 base_currency 기준으로 사용/정산되므로
* 마일리지 사용을 허용하지 않는다(정책). 마일리지는 base_currency 기준으로 사용/정산되므로
* base 규칙이 없으면 사용 단위·최소 사용액·한도를 알 수 없어 사용 자체가 불가하다.
*
* @return bool 기본 통화 마일리지 사용 규칙이 설정되어 있으면 true
@@ -164,7 +164,7 @@ class CheckoutVerificationGuardTest extends ModuleTestCase
*
* 기존 G1(훅 발화)·코어 가드(enforce 예외)·리스너 구독 테스트는 각 조각만 따로 검증한다.
* 이 테스트는 그 셋이 실제 HTTP 흐름에서 연결되어 최종적으로 428 이 나오는지를 통합 검증한다.
* (PO 검수: 조각 테스트는 green 인데 실제로 본인인증이 발동하지 않던 결함의 회귀 가드)
* (검수: 조각 테스트는 green 인데 실제로 본인인증이 발동하지 않던 결함의 회귀 가드)
*
* @scenario policy=checkout, enabled=on, actor=member, verified_state=unverified, path=http_order_create
*
@@ -120,7 +120,7 @@ await page.route('https://api.tosspayments.com/v1/payments/confirm', route => ro
```
분기별 가맹점 측 처리(주문 상태 전환, 재고 차감, 알림 발송 등) 가 정확한지 검증.
실 카드 결제 자동화는 권장하지 않음 — PO 수동 검증.
실 카드 결제 자동화는 권장하지 않음 — 수동 검증.
## 참고
@@ -134,7 +134,7 @@ class OrderStatusTransitionTest extends ModuleTestCase
/**
* 취소 → 판매 반영 상태 복원(reactivation)은 허용, 결제 전 상태 복귀는 차단됨을 명시 단언합니다.
* (취소 주문/옵션을 다시 판매 진행 상태로 되살리는 운영 기능 — 재고 재차감 동반. PO 2026-06-23)
* (취소 주문/옵션을 다시 판매 진행 상태로 되살리는 운영 기능 — 재고 재차감 동반. 2026-06-23)
*/
public function test_cancelled_reactivation_to_sales_status_allowed_but_pre_payment_blocked(): void
{
@@ -11,7 +11,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageBalanceRepositoryInt
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* UserMileageCleanupListener 테스트 (회원 탈퇴/삭제 시 마일리지 정리 — PO 확정 #8)
* UserMileageCleanupListener 테스트 (회원 탈퇴/삭제 시 마일리지 정리 — 정책 확정 #8)
*/
class UserMileageCleanupListenerTest extends ModuleTestCase
{
@@ -482,7 +482,7 @@ class OrderRepositoryTest extends ModuleTestCase
}
/**
* 주문대기(pending_order)는 통계에서 숨김 유지된다 (PO 확정, listHiddenValues).
* 주문대기(pending_order)는 통계에서 숨김 유지된다 (정책 확정, listHiddenValues).
*/
public function test_user_statistics_hides_pending_order(): void
{
@@ -367,7 +367,7 @@ class OrderResourceFieldsTest extends ModuleTestCase
/**
* OrderResource 의 is_partially_cancelled 파생 플래그가 "취소 옵션 ∧ 잔여 활성 옵션" 일 때만 true 인지 확인.
* (부분취소는 별도 order_status 가 아니라 파생 플래그로 표시 — PO 2026-06-22.)
* (부분취소는 별도 order_status 가 아니라 파생 플래그로 표시 — 2026-06-22.)
*/
public function test_order_resource_exposes_is_partially_cancelled_flag(): void
{
@@ -13,7 +13,7 @@ use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
* 마일리지 기본 통화 사용 규칙 게이트 테스트 (M2)
*
* 기본 통화(base_currency)에 대한 마일리지 통화별 사용 단위 규칙이 설정되어 있지 않으면
* 마일리지 사용이 불가해야 한다(PO 정책).
* 마일리지 사용이 불가해야 한다(정책).
* - isMileageUsable(): base 규칙 존재 시 true, 없으면 false
* - getMaxUsable(): base 규칙 없으면 0
* - validateUsage(): base 규칙 없으면 MileageValidationException
@@ -6820,7 +6820,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase
}
/**
* §6.3 회귀: 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼이 재안분값으로 갱신된다 (PO 확정).
* §6.3 회귀: 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼이 재안분값으로 갱신된다 (정책 확정).
*
* 결함: buildOptionUpdates 가 plain subtotal_* 만 갱신하고 mc_subtotal_* 는 부분취소 후 stale.
*/
@@ -6869,7 +6869,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase
}
// ================================================================
// §6.3 복합 상황: 배송정책 · 할인쿠폰 · 도서산간 · 상품별 정책 + 마일리지 (PO 추가 요구)
// §6.3 복합 상황: 배송정책 · 할인쿠폰 · 도서산간 · 상품별 정책 + 마일리지 (추가 요구)
// OrderCalculation 테스트 수준의 정확값 검증. 각 케이스는 적립 재안분값이
// 환불 우선순위에 무관(우선순위는 refund split 만 변경)함을 함께 단언한다.
// ================================================================
@@ -1208,7 +1208,7 @@ class OrderProcessingServiceTest extends ModuleTestCase
}
/**
* PO 검증 4조합: base/order 통화 전 경우의 수.
* 검증 4조합: base/order 통화 전 경우의 수.
*
* @return array<string, array{0:string,1:string,2:float,3:array,4:int}>
*/
@@ -98,7 +98,7 @@ class OrderServiceStockRedeductTest extends ModuleTestCase
public function test_update_to_non_sales_status_is_blocked_by_transition_rule(): void
{
// 전이 규칙 도입(A30) 이후: 취소 → 결제 전 상태(결제대기) 복귀는 전이 게이트가 차단한다.
// (취소 복원은 판매 반영 상태로만 허용 — PO 2026-06-23.)
// (취소 복원은 판매 반영 상태로만 허용 — 2026-06-23.)
// 차단되므로 update 가 OrderProcessingException 을 던지고, 재차감은 물론 상태/재고 모두 불변.
[$order, $option] = $this->createCancelledOrderWithRestoredStock(stock: 8, quantity: 3);
@@ -19,7 +19,7 @@ description: |
shouldConvertToFullCancel 이 담당(프론트 isFullCancel 휴리스틱 제거).
- U20③: 취소 차단 메시지를 쿠폰 단정 없이 일반화.
- U20④: cancelled_at native 컬럼에 전체·부분취소 모두 기록 + Resource 노출(cancelled_at_formatted).
- 부분취소 모델 변경(PO 2026-06-22): partial_cancelled 주문 상태를 제거. 부분취소는 별도 상태가 아니라
- 부분취소 모델 변경(2026-06-22): partial_cancelled 주문 상태를 제거. 부분취소는 별도 상태가 아니라
"취소 옵션 + 잔여 활성 옵션 공존"이며, 주문 상태는 잔여 활성 옵션 기준으로 파생(syncParentOrderStatus).
잔여 항목 재취소는 진행 상태(payment_complete 등)가 취소 가능 목록에 있어 자연 허용. 잔여 전량 취소 시 CANCELLED 전이.
"일부 취소됨" 표시는 파생 플래그 is_partially_cancelled(취소옵션∧활성옵션)로 노출(목록·상세 보조 배지).
@@ -17,7 +17,7 @@ description: |
(SHIPPING_HOLD↔PREPARING, SHIPPING→{SHIPPING_READY,PREPARING}, DELIVERED→SHIPPING)만 추가 허용.
- CONFIRMED 되돌림 전부 금지.
- CANCELLED 발 전이는 판매 반영 상태(salesEligibleStatuses)로의 복원(reactivation)만 허용
(재고 재차감 동반 운영 기능, PO 2026-06-23). 결제 전 상태(pending_order/pending_payment) 복귀는 차단.
(재고 재차감 동반 운영 기능, 2026-06-23). 결제 전 상태(pending_order/pending_payment) 복귀는 차단.
- 취소(CANCELLED) 진입은 게이트가 통과 처리(OrderCancellationService 전담, 이중 관리 방지).
- 3경로 모두 FormRequest withValidator 1차 차단(422 + 다국어) + Service 진입부 2차 도메인 불변식 가드.
- 일괄은 all-or-nothing: 1건이라도 위반이면 전체 422, DB 무변경.
@@ -2,7 +2,7 @@
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
# base 통화 정책 axis 는 PHPUnit(OrderProcessingService/buildCurrencySnapshot/OrderAdjustmentService/
# StoreEcommerceSettingsRequest)·Playwright 가 커버하며 green 이다.
feature: base 통화 정책 + 변경 가드 + 결제/통지/부분환불 base 기준 (A2 — PO 신규)
feature: base 통화 정책 + 변경 가드 + 결제/통지/부분환불 base 기준 (A2 — 신규)
description: |
기본통화 = 모든 금액 테이블의 base(진실값). 비-base 선택 시 base 정수 × 스냅샷 환율로 PG 청구·통지·환불.
@@ -2,7 +2,7 @@
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
# 가입 통화 부여 axis 는 PHPUnit(AssignDefaultCurrencyOnRegisterListener/StoreEcommerceSettingsRequest
# locales 검증)·Vitest(통화별 언어 Tag UI)가 커버하며 green 이다.
feature: 가입 시 locale 기반 통화 부여 + 통화별 언어 다중 매핑 (A4 — PO 신규)
feature: 가입 시 locale 기반 통화 부여 + 통화별 언어 다중 매핑 (A4 — 신규)
description: |
가입/회원생성 시 브라우저/locale 기반 기본통화 부여(환경설정>언어/통화의 통화별 언어 Tag 매핑 기준).
@@ -2,7 +2,7 @@
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
# 유저별 영속 통화 axis 는 PHPUnit(EcommerceUserProfile/Repository/통화 저장 컨트롤러)·
# Vitest(설정 UI)·Playwright(설치 게이트)가 커버하며 green 이다.
feature: 유저별 영속 통화 — user-profile 확장 + 4곳 설정 (A3·A5 — PO 신규)
feature: 유저별 영속 통화 — user-profile 확장 + 4곳 설정 (A3·A5 — 신규)
description: |
유저별 통화 = 커머스 책임. 코어 users 미변경, 모듈 ecommerce_user_profiles 테이블 + layout_extensions.

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