v7.0.0-beta.5 release

This commit is contained in:
HeuJung
2026-05-12 17:58:34 +09:00
parent 1db039ff34
commit eb467aca79
159 changed files with 7599 additions and 218 deletions
+6 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0-beta.4
APP_VERSION=7.0.0-beta.5
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
@@ -103,3 +103,8 @@ VITE_APP_NAME="${APP_NAME}"
G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7
G7_UPDATE_GITHUB_TOKEN=
G7_UPDATE_PENDING_PATH=
# spawn 자식 프로세스 실패 시 동작 모드 (기본: abort)
# - abort : 즉시 abort + 운영자에게 수동 명령 안내 (안전 우선, stale 메모리 fatal 차단)
# - fallback : in-process fallback 진행 (proc_open 미지원 공유 호스팅 호환, stale 메모리 위험 잔존)
# G7_UPDATE_SPAWN_FAILURE_MODE=abort
+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-beta.4
APP_VERSION=7.0.0-beta.5
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+89
View File
@@ -4,6 +4,95 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.0-beta.5] - 2026-05-12
### Upgrade Notice
beta.4 → beta.5 업그레이드 시 beta.4 의 결함으로 인해 활성 확장 디렉토리 일부가 손실됩니다.
- 손실 항목 1: 운영자가 활성 디렉토리(`modules/{id}`, `plugins/{id}`, `templates/{id}`, `lang-packs/{id}`)에서 직접 수정한 코드
- 손실 항목 2: 외부 install 한 확장 (GitHub 등 `_bundled` 에 포함되지 않은 모듈/플러그인/템플릿/언어팩)
beta.5 의 자동 복구 스텝이 `_bundled` 기반으로 번들 확장은 복원하지만, 위 두 항목은 `_bundled` 에 없어 자동 복구가 불가능합니다.
업그레이드 전에 다음 디렉토리 전체를 별도 위치(외장 디스크, 별도 git 브랜치, tar.gz 파일)로 백업해두실 것을 권고합니다.
- `modules/`
- `plugins/`
- `templates/`
- `lang-packs/`
업그레이드 후 자동 복구된 활성 디렉토리에 본인의 수정 사항/외부 확장이 누락되어 있다면, 사전 백업에서 복원하거나 외부 확장은 원본 소스(GitHub 등)에서 재설치하세요. 실패한 외부 확장 식별자는 업그레이드 로그(`storage/logs/core_update_success_*.log`)에 명시됩니다.
#### 3가지 사용자군별 업그레이드 절차
- **사용자군 B — 정상 beta.4 사용자 (대다수)**: `php artisan core:update` 표준 절차. beta.5 의 자동 복구 스텝이 활성 확장 디렉토리 + lang-packs 정합성을 보장합니다.
- **사용자군 A — beta.3 → beta.4 업그레이드 도중 `Call to undefined method` 류 fatal 후 stuck 한 사용자**: `.env` `APP_VERSION` 을 변경하지 마시고 그대로 `php artisan core:update --force` 를 재실행하면 됩니다. 디스크는 이미 beta.4 이므로 새 자식 프로세스가 beta.5 메모리로 부팅되어 남은 업그레이드 스텝이 멱등 적용됩니다. 단, 활성 디렉토리에서 직접 수정한 코드 또는 `_bundled` 에 없는 외부 확장은 자동 복구 대상이 아니므로 업그레이드 전 위 디렉토리 백업이 동일하게 권장됩니다.
- **사용자군 C — beta.1 또는 beta.2 사용자**: `php artisan core:update` 1회 호출로 beta.5 까지 직접 업그레이드는 권장하지 않습니다. 중간 버전의 업그레이드 스텝이 이전 버전 메모리에 없는 신규 코드에 의존하여 fatal 위험이 있습니다. 다음 단계적 업그레이드를 권장합니다:
```text
beta.1 사용자: beta.1 → beta.2 → beta.3 → beta.4 → beta.5 (4 회)
beta.2 사용자: beta.2 → beta.3 → beta.4 → beta.5 (3 회)
```
각 단계마다 `.env` `APP_VERSION` 이 자동 갱신되므로 다음 호출은 그대로 `php artisan core:update` 실행하면 됩니다. 가까운 릴리즈 zip 으로 `--source=` 또는 `--zip=` 옵션을 사용해 단계별 진행 가능합니다.
#### 자동 롤백 후 `public/storage` 회복
업그레이드 도중 자동 롤백 후 화면에서 업로드 파일이 보이지 않거나 미디어 경로가 깨졌다면 `public/storage` symlink 가 일반 디렉토리로 변질된 상태일 수 있습니다.
**우선 시도 — beta.5 의 자동 복구**: beta.4 → beta.5 업그레이드 자체는 부모 beta.4 메모리 결함으로 symlink 가 깨질 수 있으나, beta.5 의 업그레이드 스텝이 다음 패턴을 자동 감지하여 복구합니다:
- 감지 조건: `public/storage` 가 일반 디렉토리 + `storage/app/public` 이 존재 (Laravel 표준 구성)
- 복구 동작: 손상된 디렉토리를 `public/storage.broken.YYYYMMDDHHMMSS` 로 보존 후 symlink 재생성
- 운영자 조치: 자동 복구 후 `.broken` 디렉토리의 콘텐츠를 검증한 뒤 수동 삭제 (false positive 방어를 위해 자동 삭제하지 않음)
**자동 복구가 작동하지 않은 경우 매뉴얼 회복**:
```bash
rm -rf public/storage
php artisan storage:link
```
beta.5 부터 백업/복원 과정에서도 symlink 가 보존되므로 beta.5 → beta.6 이후로는 본 결함이 자동 차단됩니다. Windows 환경에서는 PHP `symlink()` 가 `SeCreateSymbolicLink` 권한을 요구하므로 자동 복구가 silent skip 될 수 있어 매뉴얼 회복 절차가 여전히 필요합니다.
#### spawn 자식 프로세스 실패 시 동작 모드
beta.5 부터 코어 업데이트의 자식 프로세스 spawn 이 실패하면 즉시 abort 가 기본 동작입니다 (`G7_UPDATE_SPAWN_FAILURE_MODE=abort`). 다음 4가지 상황에서 부모 프로세스 메모리의 stale 클래스로 인한 fatal 위험을 차단합니다:
1. `proc_open` 비활성 (일부 공유 호스팅)
2. `proc_open` 자원 생성 실패
3. 자식 비정상 종료 (uncaught exception)
4. 자식 exit=0 이지만 업그레이드 스텝 0건 실행 (silent skip)
abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute-upgrade-steps --from=... --to=...`) 을 안내합니다. `proc_open` 비활성 환경 등 호환성이 필요한 경우 `.env` 에 `G7_UPDATE_SPAWN_FAILURE_MODE=fallback` 을 설정하여 in-process fallback 으로 진행할 수 있으나, 부모 메모리 stale 로 인한 fatal 위험이 잔존합니다.
### Added
- 본인인증 관리자 권한을 조회/수정으로 분리 — "프로바이더 설정 조회" / "정책 조회" 권한 신설로 단순 조회 권한만 부여받은 운영자도 환경설정 본인인증 화면에 접근 가능 (기존에는 수정 권한이 없으면 조회 화면도 403)
- 환경설정의 본인인증 정책/메시지 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자에게는 "정책 추가" / "수정" / "삭제" / "활성 토글" 버튼이 자동으로 비활성화 (이커머스/게시판 본인인증 탭 동일)
- 코어 업데이트의 spawn 자식 프로세스 실패 시 abort/fallback 동작 모드 선택 (`G7_UPDATE_SPAWN_FAILURE_MODE`, 기본 `abort`) — 부모 메모리 stale 로 인한 upgrade step fatal 위험을 fail-fast 로 차단
- 업그레이드 스텝에 "버전별 데이터 스냅샷" 규약 도입 — 멀티 버전 점프 시에도 사용자가 단계별로 업그레이드한 것과 동등한 결과를 보장하도록 각 스텝의 시드 카탈로그·적용 로직·단발성 핫픽스를 해당 버전 디렉토리로 격리. 외부 확장 작성자가 beta.5+ 신규 업그레이드 스텝 작성 시 적용 (상세: 업그레이드 스텝 가이드)
- 언어팩 일괄 활성화 검증 규칙에 동적 확장 훅 추가 (`core.language_packs.bulk_activate_validation_rules`) — 모듈/플러그인이 호스트 확장 재활성화 cascade 흐름에서 추가 검증 필드를 동적으로 등록 가능
### Changed
#### Breaking
- 본인인증 관리자 권한 키 정리 — 기존 `core.admin.identity.manage` / `core.admin.identity.policies.manage` 가 각각 `core.admin.identity.providers.update` / `core.admin.identity.policies.update` 로 변경. beta.4 환경에서 기존 권한을 부여받은 운영자 역할은 업그레이드 시 자동으로 새 권한 키로 마이그레이션 (부여 시점/부여자/스코프 메타데이터 모두 보존). 외부 확장 또는 운영 도구가 옛 키 문자열을 하드코딩한 경우 새 키로 갱신 필요
### Fixed
- 코어 업데이트 시 활성 모듈/플러그인/템플릿/언어팩 디렉토리가 삭제되던 문제 수정 — beta.4 → beta.5 업그레이드 시 번들 원본에서 활성 디렉토리를 자동 복원하고, 외부 설치 확장은 업데이트 로그로 재설치 안내
- 코어 업데이트의 단계 전환 신호를 비정상 입력으로부터 차단하도록 검증 강화
- 본인인증 정책 응답의 생성/수정 일시가 사용자 타임존이 아닌 UTC ISO 문자열로 노출되던 문제 수정
- 백업/롤백 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 추적 복사되어 symlink 가 일반 디렉토리로 변질되던 문제 수정 (Linux 환경 한정 — Windows 는 권한 한계로 일반 디렉토리 폴백 + 수동 회복 안내 유지)
- root/super user 환경에서 Composer 검증·설치가 비대화형 컨텍스트에서 실패하던 문제 수정 (코어 업데이트, 확장 의존성 설치, 인스톨러 포함)
- 본인인증 활동 로그의 "액션" 열에 `identity.verify` / `identity.verify_failed` 가 번역되지 않은 키 그대로 노출되던 문제 수정 — 코어 액션 라벨 매핑이 누락되어 있던 부분을 추가
- 본인인증 단계에서 운영자가 지정한 본인인증 수단이 무시되고 항상 기본 수단(메일)으로 발행되던 문제 수정 — 가입 정책에 설정한 본인인증 수단과 API 요청에서 명시한 수단이 실제 인증 발행에 반영되도록 변경
## [7.0.0-beta.4] - 2026-05-11
### Changed
+1 -1
View File
@@ -246,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.0-beta.4 g7
# (필요 시) mv g7-7.0.0-beta.5 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--beta.4-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0--beta.5-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>
+153 -18
View File
@@ -661,9 +661,12 @@ class CoreUpdateCommand extends Command
private function spawnUpgradeStepsProcess(string $fromVersion, string $toVersion, bool $force, \Closure $log): bool
{
if (! function_exists('proc_open')) {
$log('proc_open 비활성 — spawn 스킵, in-process fallback 진행');
return false;
return $this->failSpawnWithMode(
'proc_open 비활성',
$log,
$fromVersion,
$toVersion,
);
}
$phpBinary = config('process.php_binary', PHP_BINARY);
@@ -708,17 +711,21 @@ class CoreUpdateCommand extends Command
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
if (! is_resource($process)) {
$log('spawn 실패 — proc_open 자원 생성 실패');
return false;
return $this->failSpawnWithMode(
'proc_open 자원 생성 실패',
$log,
$fromVersion,
$toVersion,
);
}
fclose($pipes[0]);
// stdout 실시간 전달 — 상위 콘솔에서 진행 상황 확인 가능
// 단, [HANDOFF] 접두사 라인은 상위 콘솔로 노출하지 않고 페이로드만 보관한다
// (exit=UpgradeHandoffException::EXIT_CODE 감지 시 UpgradeHandoffException 재구성용).
// 단, [HANDOFF] / [STEPS_EXECUTED] 접두사 라인은 상위 콘솔로 노출하지 않고
// 페이로드만 보관한다 (각각 UpgradeHandoffException 재구성 / silent skip 가드용).
$handoffPayload = null;
$stepsExecuted = null;
while (! feof($pipes[1])) {
$line = fgets($pipes[1]);
if ($line !== false) {
@@ -730,19 +737,30 @@ class CoreUpdateCommand extends Command
if (str_starts_with($trimmed, '[HANDOFF] ')) {
$json = substr($trimmed, strlen('[HANDOFF] '));
$decoded = json_decode($json, true);
// resumeCommand 는 null 허용 (자식이 null 로 전달한 경우 부모가
// CoreUpdateCommand catch 분기에서 from/to 기반으로 자동 생성)
if (is_array($decoded)
&& array_key_exists('afterVersion', $decoded)
&& array_key_exists('reason', $decoded)
&& array_key_exists('resumeCommand', $decoded)
) {
// 페이로드 값 검증 — array_key_exists 만으로는 비정상 입력(빈 문자열·
// shell metacharacter·과도 길이) 이 통과한다. resumeCommand 는 null
// 허용 (자식이 null 로 전달한 경우 부모가 from/to 기반 자동 생성).
if ($this->isValidHandoffPayload($decoded)) {
$handoffPayload = $decoded;
$log('[spawn] 핸드오프 신호 수신: after='.$decoded['afterVersion']);
continue;
}
// 구조가 깨진 핸드오프 라인 — 정상 출력으로 간주해 그대로 전달
// 구조/값이 깨진 핸드오프 라인 — 정상 출력으로 간주해 그대로 전달
if (is_array($decoded)) {
$log('[spawn] 핸드오프 페이로드 검증 실패 — 정상 출력으로 처리: '.json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
}
if (str_starts_with($trimmed, '[STEPS_EXECUTED] ')) {
$json = substr($trimmed, strlen('[STEPS_EXECUTED] '));
$decoded = json_decode($json, true);
if (is_array($decoded) && isset($decoded['count']) && is_int($decoded['count']) && $decoded['count'] >= 0) {
$stepsExecuted = $decoded['count'];
$log('[spawn] 실행된 step 수: '.$stepsExecuted);
continue;
}
}
$this->line($trimmed);
@@ -768,16 +786,133 @@ class CoreUpdateCommand extends Command
}
if ($exitCode === 0) {
$log('spawn 완료 (exit=0)');
// silent skip 가드 — 자식이 [STEPS_EXECUTED] 신호를 발행하지 않거나, step 0건
// 실행한 채 exit=0 으로 종료한 경우. 이전 버전 자식 (beta.5 이전 디스크) 또는
// 자식이 비정상 종료 직전 silent skip 한 상태로 추정. fail-fast 모드 가드 적용.
if ($stepsExecuted === null) {
return $this->failSpawnWithMode(
'spawn 자식이 [STEPS_EXECUTED] 신호 미발행 — 이전 버전 자식 또는 silent skip 의심',
$log,
$fromVersion,
$toVersion,
);
}
if ($stepsExecuted === 0 && version_compare($fromVersion, $toVersion, '<')) {
return $this->failSpawnWithMode(
sprintf('spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s)', $fromVersion, $toVersion),
$log,
$fromVersion,
$toVersion,
);
}
$log("spawn 완료 (exit=0, steps={$stepsExecuted})");
return true;
}
$log("spawn 비정상 종료 (exit={$exitCode}) — fallback 진행");
return $this->failSpawnWithMode(
"spawn 비정상 종료 (exit={$exitCode})",
$log,
$fromVersion,
$toVersion,
);
}
/**
* spawn 자식 프로세스 실패 시 `app.update.spawn_failure_mode` 에 따라 분기합니다.
*
* - 'abort' (기본): `UpgradeHandoffException` 을 throw 하여 부모의 in-process
* fallback 진입을 차단. 부모 catch 블록이 cleanup 후 운영자에게
* `core:execute-upgrade-steps` 수동 명령을 안내한다.
* - 'fallback' (호환): 기존 동작 — log 한 줄 남기고 false 반환. 호출자가
* in-process fallback 으로 진행. 부모 메모리 stale 시 fatal 위험.
*
* @param string $reason 실패 사유 (로그/안내 메시지에 포함)
* @param \Closure $log 로그 엔트리 수집 콜백
* @param string $fromVersion 업그레이드 시작 버전 (handoff afterVersion / resumeCommand 구성)
* @param string $toVersion 업그레이드 대상 버전 (resumeCommand 구성)
* @return false fallback 모드일 때만 반환. abort 모드는 throw 후 미반환.
*
* @throws UpgradeHandoffException mode=abort 일 때
*/
private function failSpawnWithMode(string $reason, \Closure $log, string $fromVersion, string $toVersion): bool
{
$mode = config('app.update.spawn_failure_mode', 'fallback');
if ($mode === 'abort') {
$resumeCommand = sprintf(
'php artisan core:execute-upgrade-steps --from=%s --to=%s --force',
$fromVersion,
$toVersion,
);
$log("spawn 자식 실패 — {$reason}. fail-fast 모드 abort.");
throw new UpgradeHandoffException(
afterVersion: $fromVersion,
reason: "spawn 자식 실패 — {$reason}. fail-fast 모드. 수동 재개로 진행하세요.",
resumeCommand: $resumeCommand,
);
}
$log("{$reason} — in-process fallback 진행 (stale 메모리 위험)");
return false;
}
/**
* [HANDOFF] 페이로드의 값을 검증합니다.
*
* spawn 자식의 stdout 을 부모가 그대로 신뢰하면 (a) 빈 afterVersion 으로 Step 11
* fromVersion 해석 혼란, (b) shell injection 문자열이 운영자 안내에 노출, (c) 과도한
* reason 길이로 로그 폭증 위험. 본 메서드는 다음 4가지 키/값을 동시에 검증한다:
*
* - `afterVersion`: 정규식 `/^\d+\.\d+\.\d+/` 매치 + 비어있지 않음
* - `reason`: string + 길이 < 500
* - `resumeCommand`: null 허용. string 일 때 길이 < 1000 + shell metacharacter(`;&|`$()<>`) 부재
*
* @param mixed $payload json_decode 결과
*/
private function isValidHandoffPayload($payload): bool
{
if (! is_array($payload)) {
return false;
}
foreach (['afterVersion', 'reason', 'resumeCommand'] as $key) {
if (! array_key_exists($key, $payload)) {
return false;
}
}
$afterVersion = $payload['afterVersion'];
if (! is_string($afterVersion) || $afterVersion === '' || ! preg_match('/^\d+\.\d+\.\d+/', $afterVersion)) {
return false;
}
$reason = $payload['reason'];
if (! is_string($reason) || strlen($reason) >= 500) {
return false;
}
$resumeCommand = $payload['resumeCommand'];
if ($resumeCommand !== null) {
if (! is_string($resumeCommand)) {
return false;
}
if (strlen($resumeCommand) >= 1000) {
return false;
}
if (preg_match('/[;&|`$()<>]/', $resumeCommand)) {
return false;
}
}
return true;
}
/**
* 업데이트 로그를 파일로 저장합니다.
*
@@ -78,7 +78,7 @@ class ExecuteUpgradeStepsCommand extends Command
$service->ensureWritableDirectories(
$writablePaths,
function (string $level, string $msg): void {
// 콘솔 + upgrade 로그 채널 동시 출력 — PO 가 단일 파일(upgrade.log)에서
// 콘솔 + upgrade 로그 채널 동시 출력 — 운영자가 단일 파일(upgrade.log)에서
// spawn 자식의 권한 정상화 진행을 추적할 수 있도록 양쪽 모두 누적.
$this->{$level === 'warning' ? 'warn' : 'info'}($msg);
\Illuminate\Support\Facades\Log::channel('upgrade')->$level('[spawn] '.$msg);
@@ -91,11 +91,16 @@ class ExecuteUpgradeStepsCommand extends Command
]);
}
$stepsExecuted = 0;
try {
$service->runUpgradeSteps(
$from,
$to,
fn (string $version) => $this->info("upgrade step 실행: {$version}"),
function (string $version) use (&$stepsExecuted): void {
$this->info("upgrade step 실행: {$version}");
$stepsExecuted++;
},
$force,
);
} catch (UpgradeHandoffException $e) {
@@ -134,6 +139,16 @@ class ExecuteUpgradeStepsCommand extends Command
return self::FAILURE;
}
// 부모 (CoreUpdateCommand::spawnUpgradeStepsProcess) 가 silent skip 가드용으로 파싱하는
// 명시 종료 신호. [HANDOFF] 와 동일 구조의 라인 프로토콜. 정상 종료 (handoff 미발생) 경로
// 에서만 발행되며, 부모는 exit=0 인데 본 라인이 부재하면 mode=abort 시 throw,
// mode=fallback 시 in-process 진행 (`spawn_failure_mode`).
$this->line('[STEPS_EXECUTED] '.json_encode([
'count' => $stepsExecuted,
'fromVersion' => $from,
'toVersion' => $to,
], JSON_UNESCAPED_UNICODE));
return self::SUCCESS;
}
}
@@ -66,6 +66,17 @@ interface IdentityPolicyRepositoryInterface
*/
public function deleteByKey(string $key): bool;
/**
* 정책 모델을 영속화합니다 (Eloquent save 위임).
*
* upsert/updateByKey 가 다루지 않는 부분 갱신 경로 (예: user_overrides 부분 복원) 에서 사용합니다.
* Service 가 모델 객체에 직접 변경을 적용한 뒤 본 메서드로 저장 — Service-Repository 경유 패턴 유지.
*
* @param IdentityPolicy $policy 영속화할 모델
* @return bool 저장 성공 여부
*/
public function save(IdentityPolicy $policy): bool;
/**
* source_type+source_identifier 에 속하지 않은 stale 정책을 제거합니다.
*
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace App\Extension;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\Upgrade\DataMigration;
use App\Extension\Upgrade\DataSnapshot;
use ReflectionClass;
/**
* 버전별 데이터 스냅샷 규약을 따르는 업그레이드 스텝 기본 클래스.
*
* 7.0.0-beta.5 부터 신규 업그레이드 스텝은 본 추상 클래스를 상속해야 한다.
* runUpgradeSteps 가 인스턴스 검증 직후 상속 여부를 확인하고 미상속 시 fatal.
*
* 격리 원칙 ("각 스텝별 동작 100% 동일 보장"):
* - 카탈로그 시드는 data/{version}/*.delta.json 으로 동결
* - Applier 는 data/{version}/appliers/ 안에 버전 namespace 로 동결
* - 변환 / 단발성 핫픽스는 data/{version}/migrations/ 안에 버전 namespace 로 동결
* - 스텝 파일 자체는 본 추상 클래스를 상속한 빈 껍데기 (모든 비즈니스 로직은 data/ 로 위임)
*
* 사용 예:
*
* class Upgrade_7_0_0_beta_5 extends AbstractUpgradeStep
* {
* // 모든 default 사용 — data/7.0.0-beta.5/ 의 manifest/appliers/migrations 가 SSoT
* }
*
* 상세: docs/extension/upgrade-step-guide.md §12
*/
abstract class AbstractUpgradeStep implements UpgradeStepInterface
{
/**
* data/{currentStep}/manifest.json 을 로드 + Applier 동적 로드.
*
* 카탈로그 변동이 없는 스텝은 manifest 파일 없이 자동 빈 스냅샷 반환.
*/
protected function dataSnapshot(UpgradeContext $context): DataSnapshot
{
return DataSnapshot::fromManifest($this->dataDir($context), $context);
}
/**
* data/{currentStep}/migrations/*.php 를 require_once + 버전 namespace 의 DataMigration
* 구현체를 인스턴스화하여 배열로 반환. 디렉토리 부재 시 빈 배열.
*
* 파일명은 정렬용 prefix (예: `01_RecoverActiveExtensionDirs.php`, `02_...php`) 를 허용한다.
* prefix 는 정규식 `^\d{2,}_` 로 클래스명 매핑 시 제거되며, 클래스명 자체는 prefix 없이
* 선언한다 (PHP 식별자 제약). 파일 정렬 결과가 실행 순서 — 명시적 순서가 필요하면 prefix 사용.
*
* @return array<int, DataMigration>
*/
protected function dataMigrations(UpgradeContext $context): array
{
$dir = $this->dataDir($context).'/migrations';
if (! is_dir($dir)) {
return [];
}
$files = glob($dir.'/*.php') ?: [];
sort($files);
$migrations = [];
$namespace = DataSnapshot::versionedNamespace($context, $dir).'\\Migrations';
foreach ($files as $file) {
require_once $file;
$baseName = basename($file, '.php');
$className = preg_replace('/^\d{2,}_/', '', $baseName);
$fqcn = $namespace.'\\'.$className;
if (class_exists($fqcn) && is_subclass_of($fqcn, DataMigration::class)) {
$migrations[] = new $fqcn;
}
}
return $migrations;
}
/**
* 카탈로그/변환 외 프레임워크 레벨 부수 작업 (선택).
*
* 본 PR 의 beta.5 재작성에서는 사용 안 함 — 모든 비즈니스 로직은 data/ 로 격리.
* 미래에 *모든 버전 공통* 인 부수 작업이 필요하면 override.
*
* default: no-op.
*/
protected function postRun(UpgradeContext $context): void {}
/**
* 업그레이드 스텝 실행 — 순서: dataSnapshot → dataMigrations → postRun.
*
* 본 메서드는 final — 하위 클래스가 순서 무력화 못 함.
*/
final public function run(UpgradeContext $context): void
{
$snapshot = $this->dataSnapshot($context);
if ($snapshot->appliers !== []) {
$context->logger->info(sprintf(
'[%s] DataSnapshot 적용 — Applier %d개',
$context->currentStep,
count($snapshot->appliers),
));
}
$snapshot->apply($context);
$migrations = $this->dataMigrations($context);
if ($migrations !== []) {
$context->logger->info(sprintf(
'[%s] DataMigration 실행 — %d개',
$context->currentStep,
count($migrations),
));
}
foreach ($migrations as $migration) {
$context->logger->info(sprintf(
'[%s] migration: %s',
$context->currentStep,
$migration->name(),
));
$migration->run($context);
}
$this->postRun($context);
}
/**
* data/{currentStep}/ 절대 경로 계산. 스텝 파일 위치 기반 — 코어/확장 무관.
*/
protected function dataDir(UpgradeContext $context): string
{
$stepFile = (new ReflectionClass($this))->getFileName();
return dirname($stepFile).'/data/'.$context->currentStep;
}
}
+5
View File
@@ -927,6 +927,11 @@ PHP;
$env['COMPOSER_HOME'] = $composerHome;
$env['HOME'] = $composerHome;
// root/super user 환경 + 비대화형 컨텍스트에서 composer interactive 경고로 인한
// 비정상 종료를 차단 (Synology DSM 등 PHP-FPM root 실행 환경 대응)
$env['COMPOSER_ALLOW_SUPERUSER'] = '1';
$env['COMPOSER_NO_INTERACTION'] = '1';
// Windows: TEMP 디렉토리가 없거나 쓰기 불가능하면 storage/temp 사용
if (PHP_OS_FAMILY === 'Windows') {
if (! isset($env['TEMP']) || ! is_dir($env['TEMP']) || ! is_writable($env['TEMP'])) {
@@ -0,0 +1,128 @@
<?php
namespace App\Extension\Helpers;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\AbstractUpgradeStep;
/**
* 확장(모듈/플러그인) 업그레이드 스텝의 AbstractUpgradeStep 상속 의무 가드.
*
* 코어 7.0.0-beta.5 에서 도입된 "버전별 데이터 스냅샷 규약" (AbstractUpgradeStep 의무) 을
* 확장 측에 동일 수준으로 적용한다.
*
* 의무 시작 버전 결정 규칙:
* - 확장의 manifest g7_version 제약이 7.0.0-beta.5 미만의 코어를 허용한다면 legacy
* (가드 미발동) — 그 확장은 본 인프라가 없던 코어를 인지하는 작성본이라 호환 보장.
* - 제약 최소 버전이 7.0.0-beta.5 이상이면 확장이 본 인프라를 인지함 → 그 확장의 현재
* working version 부터 step 작성 시 AbstractUpgradeStep 상속 의무.
*
* 즉 확장 작성자가 g7_version 을 7.0.0-beta.5 이상으로 상향하는 시점이 본 규약의 자동
* 적용 첫 버전이 된다.
*
* 상세: docs/extension/upgrade-step-guide.md §13
*/
class ExtensionUpgradeGuardHelper
{
/**
* AbstractUpgradeStep 의무가 도입된 코어 버전.
*/
public const ABSTRACT_INTRODUCED_AT = '7.0.0-beta.5';
/**
* 확장의 AbstractUpgradeStep 의무 시작 버전을 계산합니다.
*
* @param string|null $g7VersionConstraint manifest 의 g7_version (예: ">=7.0.0-beta.5")
* @param string $extensionWorkingVersion manifest 의 version (예: "1.0.0-beta.5")
* @return string|null 의무 시작 버전 또는 null (가드 미발동 — legacy)
*/
public static function resolveSinceVersion(
?string $g7VersionConstraint,
string $extensionWorkingVersion,
): ?string {
if ($g7VersionConstraint === null || trim($g7VersionConstraint) === '') {
return null;
}
$minVersion = self::extractMinimumVersion($g7VersionConstraint);
if ($minVersion === null) {
return null;
}
if (version_compare($minVersion, self::ABSTRACT_INTRODUCED_AT, '<')) {
return null;
}
return $extensionWorkingVersion;
}
/**
* 확장 step 이 본 가드를 통과하는지 검증합니다.
*
* stepVersion < sinceVersion 인 step (확장 버전 이전의 legacy step) 은 미발동.
* stepVersion >= sinceVersion 인 step 이 AbstractUpgradeStep 미상속이면 fatal.
*
* @param string $stepVersion 실행 중인 step 의 버전
* @param string $sinceVersion 의무 시작 버전 (resolveSinceVersion 결과; null 아님)
* @param UpgradeStepInterface $step step 인스턴스
* @param string $extensionType 확장 타입 ("module" / "plugin")
* @param string $extensionIdentifier 확장 식별자
*
* @throws \RuntimeException stepVersion >= sinceVersion 인데 AbstractUpgradeStep 미상속 시
*/
public static function assertAbstractInheritance(
string $stepVersion,
string $sinceVersion,
UpgradeStepInterface $step,
string $extensionType,
string $extensionIdentifier,
): void {
if (version_compare($stepVersion, $sinceVersion, '<')) {
return;
}
if ($step instanceof AbstractUpgradeStep) {
return;
}
throw new \RuntimeException(sprintf(
'Extension %s "%s" upgrade step %s must extend App\\Extension\\AbstractUpgradeStep '
.'(required from %s; introduced in core %s).',
$extensionType,
$extensionIdentifier,
$stepVersion,
$sinceVersion,
self::ABSTRACT_INTRODUCED_AT,
));
}
/**
* Semantic Versioning 제약 문자열에서 최소 버전을 추출합니다.
*
* 지원 형식:
* - ">=X.Y.Z[-suffix]" / ">X.Y.Z" / "X.Y.Z"
* - "^X.Y.Z" / "~X.Y.Z"
* - "A|B" (OR) — 첫 후보 기준
*
* 파싱 실패 시 null 반환 (보수적으로 가드 미발동).
*/
private static function extractMinimumVersion(string $constraint): ?string
{
$constraint = trim($constraint);
$pipeIdx = strpos($constraint, '|');
if ($pipeIdx !== false) {
$constraint = trim(substr($constraint, 0, $pipeIdx));
}
if (preg_match(
'/^(?:>=|>|\^|~|=)?\s*([0-9]+\.[0-9]+\.[0-9]+(?:[\-\.][a-zA-Z0-9\.\-]+)?)/',
$constraint,
$m,
)) {
return $m[1];
}
return null;
}
}
+61 -1
View File
@@ -49,6 +49,17 @@ class FilePermissionHelper
$destPath = $destination.DIRECTORY_SEPARATOR.$itemName;
// symlink 자체 보존: target 추적 없이 동일 symlink 재생성.
// SplFileInfo::isDir() 가 symlink 를 추적하므로 검사 순서가 isDir() 보다 먼저여야 한다.
// 미적용 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상.
if ($item->isLink()) {
if (static::copySymlink($item->getPathname(), $destPath)) {
continue;
}
// 복원 실패 (Windows SeCreateSymbolicLink 권한 부족 등) — 일반 복사로 fall-through.
// 운영자가 추후 `php artisan storage:link` 수동 실행으로 회복 가능.
}
if ($item->isDir()) {
static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans);
} else {
@@ -62,6 +73,50 @@ class FilePermissionHelper
}
}
/**
* symlink 자체를 보존 복사합니다 (target 미추적).
*
* 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 symlink 재생성.
* readlink / symlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through.
*
* Windows 환경에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하며 일반 사용자는
* 권한이 없을 가능성이 높다. 실패 시 warning 로그 후 false 반환하여 호출자가 일반 복사로
* fall-through 하도록 한다. 운영자는 업그레이드 후 `php artisan storage:link` 등 수동
* 명령으로 symlink 를 회복할 수 있다.
*
* @param string $source 소스 symlink 경로
* @param string $destination 대상 symlink 경로
* @return bool symlink 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백)
*/
protected static function copySymlink(string $source, string $destination): bool
{
$target = @readlink($source);
if ($target === false) {
Log::warning('copySymlink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]);
return false;
}
// 기존 dest 정리 — symlink/파일 은 unlink, 디렉토리는 deleteDirectory
if (is_link($destination) || is_file($destination)) {
@unlink($destination);
} elseif (is_dir($destination)) {
File::deleteDirectory($destination);
}
if (! @symlink($target, $destination)) {
Log::warning('copySymlink: symlink 생성 실패 — 일반 복사로 폴백', [
'source' => $source,
'destination' => $destination,
'target' => $target,
]);
return false;
}
return true;
}
/**
* 소스에 없고 대상에만 있는 파일/디렉토리를 삭제합니다.
*
@@ -90,7 +145,12 @@ class FilePermissionHelper
// 소스에 존재하지 않는 항목만 삭제
if (! File::exists($srcPath) && ! File::isDirectory($srcPath)) {
if ($destItem->isDir()) {
// symlink 는 항상 unlink — File::deleteDirectory 는 is_dir() 추적 검사 후
// 재귀 삭제하므로 symlink-to-dir 인 경우 target 의 모든 파일을 삭제하는 사고
// 발생 가능. is_link() 가 isDir() 보다 먼저 평가되어야 한다.
if (is_link($destItem->getPathname())) {
@unlink($destItem->getPathname());
} elseif ($destItem->isDir()) {
File::deleteDirectory($destItem->getPathname());
} else {
File::delete($destItem->getPathname());
@@ -190,6 +190,7 @@ class IdentityVerificationManager
* 특정 purpose 에 사용할 프로바이더를 해석합니다.
*
* 해석 순서:
* 0. 명시 $providerId 가 등록 + supportsPurpose 통과 → 사용 (호출자 명시 우선)
* 1. `settings.identity.purpose_providers.{purpose}` 에 명시적 지정 + 해당 프로바이더가 purpose 지원 → 사용
* 2. 기본 프로바이더가 purpose 지원 → 사용
* 3. 등록된 프로바이더 중 purpose 지원하는 첫 번째 → 사용
@@ -198,13 +199,21 @@ class IdentityVerificationManager
* 정책의 provider_id 가 우선되므로 (IdentityPolicyService::resolveRenderHint 참조), 본 메서드는
* 정책에 provider_id 가 명시되지 않은 경우의 fallback 으로 사용됩니다.
*
* $providerId 가 등록되지 않았거나 purpose 미지원이면 silent fallback (1~4 단계 진행).
* 운영자가 정책에 명시한 provider 가 비활성화/미설치된 상태에서도 가입 흐름이 중단되지 않도록 한다.
*
* @param string $purpose IDV 목적 (signup, password_reset, sensitive_action 등)
* @param string|null $providerId 호출자 명시 provider id (Mode A controller 요청 / Mode B 정책)
* @return IdentityVerificationInterface 해석된 provider
*/
public function resolveForPurpose(string $purpose): IdentityVerificationInterface
public function resolveForPurpose(string $purpose, ?string $providerId = null): IdentityVerificationInterface
{
$providers = $this->all();
if ($providerId !== null && $providerId !== '' && isset($providers[$providerId]) && $providers[$providerId]->supportsPurpose($purpose)) {
return $providers[$providerId];
}
$explicitId = (string) config("settings.identity.purpose_providers.{$purpose}", '');
if ($explicitId !== '' && isset($providers[$explicitId]) && $providers[$explicitId]->supportsPurpose($purpose)) {
return $providers[$explicitId];
+18 -3
View File
@@ -24,6 +24,7 @@ use App\Extension\Helpers\ExtensionMenuSyncHelper;
use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionRoleSyncHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
use App\Extension\Helpers\ExtensionUpgradeGuardHelper;
use App\Extension\Helpers\GithubHelper;
use App\Providers\CoreServiceProvider;
use App\Extension\Concerns\ResolvesExtensionSharedRecords;
@@ -772,7 +773,7 @@ class ModuleManager implements ModuleManagerInterface
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
// PO #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
// 요구사항 #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
HookManager::doAction('core.modules.after_deactivate', $module->getIdentifier());
}
@@ -867,7 +868,7 @@ class ModuleManager implements ModuleManagerInterface
if ($result) {
// 권한·메뉴·역할은 $deleteData=true 시에만 삭제.
// false 시 보존하여 재설치 시 기존 역할 할당/커스터마이징이 복원 가능하도록 한다.
// PO 정책: "동적 권한/메뉴는 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
// 운영 정책: "동적 권한/메뉴는 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
if ($deleteData) {
$this->removeModulePermissionsAndMenus($module);
@@ -4044,6 +4045,20 @@ class ModuleManager implements ModuleManagerInterface
$stepContext->logger->info("Upgrading {$module->getIdentifier()} step {$stepVersion}...");
if ($step instanceof UpgradeStepInterface) {
$sinceVersion = ExtensionUpgradeGuardHelper::resolveSinceVersion(
$module->getRequiredCoreVersion(),
$module->getVersion(),
);
if ($sinceVersion !== null) {
ExtensionUpgradeGuardHelper::assertAbstractInheritance(
(string) $stepVersion,
$sinceVersion,
$step,
'module',
$module->getIdentifier(),
);
}
$step->run($stepContext);
} elseif (is_callable($step)) {
$step($stepContext);
@@ -4457,7 +4472,7 @@ class ModuleManager implements ModuleManagerInterface
/**
* --force 시 업데이트 소스를 결정합니다.
*
* PO 정책: --force 시에는 번들이 우선, 번들이 없는 경우에만 GitHub 사용.
* 운영 정책: --force 시에는 번들이 우선, 번들이 없는 경우에만 GitHub 사용.
* (일반 업데이트의 GitHub 우선과 반대 — 개발자가 로컬 번들로 되돌리려는 의도 존중)
*
* 우선순위:
+19 -4
View File
@@ -27,6 +27,7 @@ use App\Extension\Helpers\IdentityMessageSyncHelper;
use App\Extension\Helpers\IdentityPolicySyncHelper;
use App\Extension\Helpers\NotificationSyncHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
use App\Extension\Helpers\ExtensionUpgradeGuardHelper;
use App\Extension\Helpers\GithubHelper;
use App\Providers\CoreServiceProvider;
use App\Extension\Vendor\Exceptions\VendorInstallException;
@@ -756,7 +757,7 @@ class PluginManager implements PluginManagerInterface
// 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
// PO #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
// 요구사항 #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
HookManager::doAction('core.plugins.after_deactivate', $plugin->getIdentifier());
}
@@ -887,7 +888,7 @@ class PluginManager implements PluginManagerInterface
if ($result) {
// 권한/역할은 $deleteData=true 시에만 삭제.
// PO 정책: "동적 권한은 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
// 운영 정책: "동적 권한은 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
if ($deleteData) {
$this->removePluginPermissions($plugin);
@@ -4208,7 +4209,7 @@ class PluginManager implements PluginManagerInterface
if (empty($filteredSteps)) {
return;
}
}
// 버전순 정렬
uksort($filteredSteps, 'version_compare');
@@ -4226,6 +4227,20 @@ class PluginManager implements PluginManagerInterface
$stepContext->logger->info("Upgrading {$plugin->getIdentifier()} step {$stepVersion}...");
if ($step instanceof UpgradeStepInterface) {
$sinceVersion = ExtensionUpgradeGuardHelper::resolveSinceVersion(
$plugin->getRequiredCoreVersion(),
$plugin->getVersion(),
);
if ($sinceVersion !== null) {
ExtensionUpgradeGuardHelper::assertAbstractInheritance(
(string) $stepVersion,
$sinceVersion,
$step,
'plugin',
$plugin->getIdentifier(),
);
}
$step->run($stepContext);
} elseif (is_callable($step)) {
$step($stepContext);
@@ -4637,7 +4652,7 @@ class PluginManager implements PluginManagerInterface
/**
* --force 시 업데이트 소스를 결정합니다.
*
* PO 정책: --force 시에는 번들이 우선, 번들이 없는 경우에만 GitHub 사용.
* 운영 정책: --force 시에는 번들이 우선, 번들이 없는 경우에만 GitHub 사용.
* (일반 업데이트의 GitHub 우선과 반대 — 개발자가 로컬 번들로 되돌리려는 의도 존중)
*
* 우선순위:
+2 -2
View File
@@ -672,7 +672,7 @@ class TemplateManager implements TemplateManagerInterface
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
// PO #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
// 요구사항 #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
HookManager::doAction('core.templates.after_deactivate', $templateName);
Log::info(__('templates.messages.template_deactivated'), [
@@ -3116,7 +3116,7 @@ class TemplateManager implements TemplateManagerInterface
/**
* --force 시 업데이트 소스를 결정합니다.
*
* PO 정책: --force 시에는 번들이 우선, 번들이 없는 경우에만 GitHub 사용.
* 운영 정책: --force 시에는 번들이 우선, 번들이 없는 경우에만 GitHub 사용.
* (일반 업데이트의 GitHub 우선과 반대 — 개발자가 로컬 번들로 되돌리려는 의도 존중)
*
* 우선순위:
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Extension\Upgrade;
use App\Extension\UpgradeContext;
/**
* 카탈로그 JSON 으로 표현 불가한 imperative 변환 / 단발성 핫픽스 단위.
*
* 각 버전이 자기 시점의 변환 로직을 본 인터페이스 구현체로
* `upgrades/data/{version}/migrations/*.php` 에 작성한다.
*
* 적용 대상:
* - 컬럼 의미 변경 / 데이터 변환 (예: dot-path 변환)
* - 카탈로그 rename 의 충돌 경로 (Applier 가 처리하지 못하는 분기)
* - 단발성 파일시스템 핫픽스 (활성 디렉토리 복구, symlink 복구 등)
* - 그 버전 고유의 DB 보정 로직
*
* 구현 의무 (Applier 와 동일):
* - **idempotent**: 두 번 실행되어도 동일 결과
* - **V-1 안전**: 로컬 헬퍼 + `Illuminate\Support\Facades\*` 만 사용
* - **버전 격리**: 다른 버전 namespace 참조 금지
*
* 인스턴스화: `new {Migration}()` (인자 없음).
*
* 상세: docs/extension/upgrade-step-guide.md §12
*/
interface DataMigration
{
/**
* 마이그레이션 식별자 (로그용).
*
* @return string 사람이 읽을 수 있는 짧은 식별자 (예: "IdentityPermissionPivotMerge")
*/
public function name(): string;
/**
* 변환 / 핫픽스 적용. idempotent 의무.
*
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거, 현재 스텝 버전 등)
* @return void
*
* @throws \Throwable 적용 실패 시 — runUpgradeSteps 가 상위에서 백업 복원 처리
*/
public function run(UpgradeContext $context): void;
}
+222
View File
@@ -0,0 +1,222 @@
<?php
namespace App\Extension\Upgrade;
use App\Extension\UpgradeContext;
use RuntimeException;
/**
* 한 업그레이드 스텝이 적용할 카탈로그 delta 의 모음.
*
* `data/{version}/manifest.json` 을 읽어 각 kind 의 delta JSON 과 그에 대응하는
* 버전 namespace 의 Applier 를 동적 로드 + 인스턴스화하여 `SnapshotApplier` 배열로 보유.
*
* 사용:
* $snapshot = DataSnapshot::fromManifest($dataDir, $context);
* $snapshot->apply($context);
*
* 상세: docs/extension/upgrade-step-guide.md §12
*/
final class DataSnapshot
{
/** @var array<int, SnapshotApplier> */
public readonly array $appliers;
/**
* @param array<int, SnapshotApplier> $appliers
*/
public function __construct(array $appliers)
{
$this->appliers = $appliers;
}
/**
* 빈 스냅샷 (카탈로그 변동이 없는 스텝용).
*
* @return self 빈 Applier 배열을 보유한 DataSnapshot
*/
public static function empty(): self
{
return new self([]);
}
/**
* data/{version}/manifest.json 을 로드하여 DataSnapshot 인스턴스 생성.
*
* manifest 부재 시 빈 스냅샷 반환 (V-1 호환 — 디렉토리 없어도 동작).
*
* @param string $dataDir upgrades/data/{version} 절대 경로
* @param UpgradeContext $context 현재 스텝 버전 정보 (namespace 계산용)
* @return self manifest 의 kind 별로 Applier 를 로드한 DataSnapshot
*
* @throws RuntimeException manifest JSON 파싱 실패 / Applier 클래스 부재 시
*/
public static function fromManifest(string $dataDir, UpgradeContext $context): self
{
$manifestPath = $dataDir.'/manifest.json';
if (! is_file($manifestPath)) {
return self::empty();
}
$manifest = json_decode(
file_get_contents($manifestPath),
true,
flags: JSON_THROW_ON_ERROR,
);
$appliers = [];
foreach ($manifest['files'] ?? [] as $entry) {
$kind = $entry['kind'] ?? null;
$file = $entry['file'] ?? null;
if (! is_string($kind) || ! is_string($file)) {
throw new RuntimeException(
"Invalid manifest entry in {$manifestPath}: kind/file required"
);
}
$applierClass = $entry['applier_class'] ?? self::kindToClassName($kind);
$appliers[] = self::makeApplier(
applierClass: $applierClass,
kind: $kind,
dataDir: $dataDir,
jsonPath: $dataDir.'/'.$file,
context: $context,
);
}
return new self($appliers);
}
/**
* 보유한 모든 Applier 를 순차 실행.
*
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거, 현재 스텝 버전 등)
*/
public function apply(UpgradeContext $context): void
{
foreach ($this->appliers as $applier) {
$applier->apply($context);
}
}
/**
* 버전 namespace 의 Applier 클래스를 require_once + 인스턴스화.
*
* 예: kind="permissions", context->currentStep="7.0.0-beta.5"
* → 클래스 "App\Upgrades\Data\V7_0_0_beta_5\Appliers\PermissionsApplier"
* → 파일 "{dataDir}/appliers/PermissionsApplier.php"
*
* @throws RuntimeException Applier 파일/클래스 부재 또는 인터페이스 미구현 시
*/
private static function makeApplier(
string $applierClass,
string $kind,
string $dataDir,
string $jsonPath,
UpgradeContext $context,
): SnapshotApplier {
$applierFile = $dataDir.'/appliers/'.$applierClass.'.php';
if (! is_file($applierFile)) {
throw new RuntimeException(sprintf(
'Applier file not found: %s (kind=%s)',
$applierFile,
$kind,
));
}
require_once $applierFile;
$fqcn = self::versionedNamespace($context, $dataDir).'\\Appliers\\'.$applierClass;
if (! class_exists($fqcn)) {
throw new RuntimeException(sprintf(
'Applier class not declared in expected namespace: %s (file=%s)',
$fqcn,
$applierFile,
));
}
if (! is_subclass_of($fqcn, SnapshotApplier::class)) {
throw new RuntimeException(sprintf(
'Applier class must implement SnapshotApplier: %s',
$fqcn,
));
}
return new $fqcn($jsonPath);
}
/**
* 현재 스텝 버전의 namespace prefix 계산.
*
* 코어:
* "7.0.0-beta.5" → "App\Upgrades\Data\V7_0_0_beta_5"
* 번들/외부 모듈:
* path 가 `.../modules/.../upgrades/data/{ver}/` → "App\Upgrades\Data\Ext\Modules\{StudlyIdentifier}\V{token}"
* 번들/외부 플러그인:
* path 가 `.../plugins/.../upgrades/data/{ver}/` → "App\Upgrades\Data\Ext\Plugins\{StudlyIdentifier}\V{token}"
*
* 확장 식별자 segment 가 namespace 에 포함되어 두 다른 확장의 같은 step 버전 + 같은
* 클래스명 이 *별개* FQCN 으로 격리된다. 이로써 PHP compile-time fatal
* ("Cannot declare class ... because the name is already in use") 회귀가 차단된다.
*
* sourceLocation 미지정 시 코어 namespace 로 폴백 (기존 동작 보존).
*
* @param UpgradeContext $context 현재 스텝 버전 정보 보유
* @param string|null $sourceLocation step 파일 또는 dataDir 의 절대 경로 (코어/확장 분기 결정)
* @return string PSR-4 호환 namespace prefix (Applier/Migration 으로 이어짐)
*/
public static function versionedNamespace(UpgradeContext $context, ?string $sourceLocation = null): string
{
$token = 'V'.str_replace(['.', '-'], '_', $context->currentStep);
$base = 'App\\Upgrades\\Data';
if ($sourceLocation !== null) {
$normalized = str_replace('\\', '/', $sourceLocation);
if (preg_match(
'#(?:^|/)(modules|plugins)(?:/_bundled)?/([^/]+)/upgrades(?:/data)?/#',
$normalized,
$match,
)) {
$type = $match[1] === 'modules' ? 'Modules' : 'Plugins';
$identifier = self::studlyIdentifier($match[2]);
return "{$base}\\Ext\\{$type}\\{$identifier}\\{$token}";
}
}
return "{$base}\\{$token}";
}
/**
* 확장 식별자(`sirsoft-ecommerce` 등) 를 PHP namespace-safe StudlyCase 로 변환.
*
* "sirsoft-ecommerce" → "SirsoftEcommerce"
* "vendor_module_v2" → "VendorModuleV2"
*/
private static function studlyIdentifier(string $identifier): string
{
$parts = preg_split('/[-_]+/', $identifier) ?: [$identifier];
$studly = '';
foreach ($parts as $part) {
if ($part === '') {
continue;
}
$studly .= ucfirst($part);
}
return $studly !== '' ? $studly : 'Anonymous';
}
/**
* snake_case kind → StudlyCase Applier 클래스명.
*
* permissions → PermissionsApplier
* role_permissions → RolePermissionsApplier
* notification_definitions → NotificationDefinitionsApplier
*/
private static function kindToClassName(string $kind): string
{
$studly = str_replace(' ', '', ucwords(str_replace('_', ' ', $kind)));
return $studly.'Applier';
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Extension\Upgrade;
use App\Extension\UpgradeContext;
/**
* 카탈로그 delta JSON 한 파일을 DB 에 idempotent 하게 적용하는 단위.
*
* 각 버전이 자기가 사용하는 kind 별로 본 인터페이스 구현체를
* `upgrades/data/{version}/appliers/{Kind}Applier.php` 에 작성한다.
*
* 구현 의무:
* - **raw JSON 만 read**: 시더 클래스(`Database\Seeders\*`) 호출 금지
* - **idempotent**: `Schema::hasColumn` / `where->exists()` 가드 동반
* - **V-1 안전**: 로컬 SQL/Eloquent 만 사용, `app(*Service|*Manager|*Repository::class)` 금지
* - **버전 격리**: 다른 버전 namespace 의 클래스 참조 금지
*
* 인스턴스화: `new {Kind}Applier($absoluteJsonPath)`.
* 생성자는 delta JSON 파일의 절대 경로를 받아 `apply()` 시 read 한다.
*
* 상세: docs/extension/upgrade-step-guide.md §12
*/
interface SnapshotApplier
{
/**
* delta JSON 을 read 하여 DB 에 적용.
*
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거, 현재 스텝 버전 등)
* @return void
*
* @throws \Throwable 적용 실패 시 — runUpgradeSteps 가 상위에서 백업 복원 처리
*/
public function apply(UpgradeContext $context): void;
}
+41 -1
View File
@@ -24,6 +24,8 @@ class EnvironmentDetector
/**
* proc_open() 함수 사용 가능 여부.
*
* @return bool proc_open 호출 가능 여부 (disable_functions 차단 시 false)
*/
public function hasProcOpen(): bool
{
@@ -38,6 +40,8 @@ class EnvironmentDetector
/**
* shell_exec() 함수 사용 가능 여부.
*
* @return bool shell_exec 호출 가능 여부 (disable_functions 차단 시 false)
*/
public function hasShellExec(): bool
{
@@ -52,6 +56,8 @@ class EnvironmentDetector
/**
* ZipArchive 클래스 사용 가능 여부.
*
* @return bool ext-zip 활성화 여부
*/
public function hasZipArchive(): bool
{
@@ -63,6 +69,9 @@ class EnvironmentDetector
*
* 우선순위: hint → config(process.composer_binary) → $_ENV['COMPOSER_BINARY']
* → PATH 검색 → composer.phar
*
* @param string|null $hint 바이너리 경로 힌트 (null 이면 config/ENV/PATH 순으로 자동 탐색)
* @return string|null 발견된 composer 바이너리 경로. 없으면 null
*/
public function findComposerBinary(?string $hint = null): ?string
{
@@ -108,6 +117,9 @@ class EnvironmentDetector
* composer 실행 가능 여부 종합 판단.
*
* proc_open 사용 가능 + composer 바이너리 발견 + `composer --version` 종료 코드 0.
*
* @param string|null $hint composer 바이너리 경로 힌트 (캐시 우회용)
* @return bool composer 가 현재 환경에서 실행 가능한지 여부
*/
public function canExecuteComposer(?string $hint = null): bool
{
@@ -135,7 +147,7 @@ class EnvironmentDetector
],
$pipes,
null,
null,
self::buildComposerEnv(),
['bypass_shell' => true]
);
@@ -163,6 +175,9 @@ class EnvironmentDetector
/**
* 지정 경로에 쓰기 가능한지 확인.
*
* @param string $targetPath vendor 디렉토리 경로 (존재하지 않아도 부모 디렉토리 쓰기 권한 검사)
* @return bool 부모 디렉토리가 존재하고 쓰기 가능한지 여부
*/
public function canWriteVendor(string $targetPath): bool
{
@@ -174,6 +189,7 @@ class EnvironmentDetector
/**
* 인스톨러 Step 2 요구사항 체크용 종합 리포트.
*
* @param string|null $hint composer 바이너리 경로 힌트 (캐시 우회용)
* @return array{
* proc_open: bool,
* shell_exec: bool,
@@ -210,6 +226,30 @@ class EnvironmentDetector
$this->cachedComposerBinary = null;
}
/**
* proc_open() 5번째 인자용 composer 환경변수 배열을 구성합니다.
*
* root/super user 환경 + 비대화형(웹) 컨텍스트에서 composer 가 interactive
* 경고를 출력하며 비정상 종료하는 문제를 차단합니다.
*
* 현재 프로세스의 환경변수(getenv() + $_ENV)에 두 composer 전용 변수를 마지막에
* 덮어쓰기 — 외부에서 0 으로 설정되어 있어도 강제로 1.
*
* @return array<string, string>
*/
public static function buildComposerEnv(): array
{
$current = getenv();
if (! is_array($current)) {
$current = [];
}
return array_merge($current, $_ENV, [
'COMPOSER_ALLOW_SUPERUSER' => '1',
'COMPOSER_NO_INTERACTION' => '1',
]);
}
/**
* composer 실행 명령 문자열 구성.
*
+11 -2
View File
@@ -68,6 +68,8 @@ class VendorBundler
*
* 러너는 `fn (string $stagingDir): void` 형식이며, 주어진 스테이징 디렉토리에
* vendor/ 구조를 직접 생성해야 합니다.
*
* @param \Closure|null $runner 대체 러너 (null 이면 기본 composer install 사용)
*/
public function setComposerInstallRunner(?\Closure $runner): void
{
@@ -78,6 +80,9 @@ class VendorBundler
* 코어(루트) vendor/를 번들링합니다.
*
* 코어는 소스 = 출력 경로가 base_path() 로 동일합니다.
*
* @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드
* @return VendorBundleResult 번들 생성 결과 (zip 경로, manifest 경로, 패키지 수 등)
*/
public function buildForCore(bool $force = false): VendorBundleResult
{
@@ -98,7 +103,10 @@ class VendorBundler
* 이유: _bundled 는 Git 추적 소스 디렉토리로 vendor/ 를 두지 않는 것이 원칙.
* 활성 디렉토리는 설치 시 composer install 이 실행되어 실제 패키지가 설치된 상태.
*
* @param string $type 'module' | 'plugin'
* @param string $type 확장 타입 ('module' 또는 'plugin')
* @param string $identifier 확장 식별자 (vendor-name 형식, 예: 'sirsoft-ecommerce')
* @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드
* @return VendorBundleResult 번들 생성 결과 (활성 디렉토리 부재 시 skipped=true)
*/
public function buildForExtension(string $type, string $identifier, bool $force = false): VendorBundleResult
{
@@ -156,6 +164,7 @@ class VendorBundler
*
* @param string $sourcePath composer.json/lock 을 읽을 경로 (활성 디렉토리)
* @param string|null $outputPath 번들 출력 경로 (null 이면 sourcePath 와 동일 — 코어 케이스)
* @return bool 번들이 stale 한지 여부 (해시 불일치 또는 manifest 부재). 외부 의존성이 없으면 항상 false
*/
public function isStale(string $sourcePath, ?string $outputPath = null): bool
{
@@ -391,7 +400,7 @@ class VendorBundler
2 => ['pipe', 'w'],
];
$process = @proc_open($command, $descriptors, $pipes, $stagingDir);
$process = @proc_open($command, $descriptors, $pipes, $stagingDir, EnvironmentDetector::buildComposerEnv());
if (! is_resource($process)) {
throw new VendorInstallException(
@@ -242,7 +242,7 @@ class LanguagePackController extends AdminBaseController
}
/**
* 여러 언어팩을 일괄 활성화합니다 (PO #7 reactivate 모달 → "활성화" 버튼).
* 여러 언어팩을 일괄 활성화합니다 (요구사항 #7 reactivate 모달 → "활성화" 버튼).
*
* @param \App\Http\Requests\LanguagePack\BulkActivateRequest $request 요청
* @return JsonResponse 결과 (succeeded/failed 분리)
@@ -252,7 +252,7 @@ class ModuleController extends AdminBaseController
if ($result['success']) {
$moduleInfo = $result['module_info'] ?? null;
// PO #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함 (PO #8: 빈 배열이면 모달 표시 안 함)
// 요구사항 #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함 (요구사항 #8: 빈 배열이면 모달 표시 안 함)
$pendingLanguagePacks = app(\App\Services\LanguagePack\LanguagePackBundledRegistrar::class)
->getPendingForReactivation('module', $moduleName);
@@ -241,7 +241,7 @@ class PluginController extends AdminBaseController
if ($result['success']) {
$pluginInfo = $result['plugin_info'] ?? null;
// PO #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함
// 요구사항 #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함
$pendingLanguagePacks = app(\App\Services\LanguagePack\LanguagePackBundledRegistrar::class)
->getPendingForReactivation('plugin', $pluginName);
@@ -214,7 +214,7 @@ class TemplateController extends AdminBaseController
if ($result['success']) {
$templateInfo = $result['template_info'] ?? null;
// PO #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함
// 요구사항 #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함
$pendingLanguagePacks = app(\App\Services\LanguagePack\LanguagePackBundledRegistrar::class)
->getPendingForReactivation('template', $templateName);
@@ -57,6 +57,11 @@ class IdentityVerificationController extends PublicBaseController
return $this->error('identity.errors.missing_target', 422);
}
$providerId = $validated['provider_id'] ?? null;
if (is_string($providerId) && $providerId === '') {
$providerId = null;
}
$challenge = $this->service->start(
purpose: (string) $validated['purpose'],
target: $target,
@@ -66,6 +71,7 @@ class IdentityVerificationController extends PublicBaseController
'origin_type' => \App\Enums\IdentityOriginType::Api->value,
'origin_identifier' => '/api/identity/challenges',
],
providerId: $providerId,
);
return $this->success(
@@ -2,12 +2,13 @@
namespace App\Http\Requests\LanguagePack;
use App\Extension\HookManager;
use App\Models\LanguagePack;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 언어팩 일괄 활성화 요청 (PO #7 reactivate 모달 → "활성화" 버튼).
* 언어팩 일괄 활성화 요청 (요구사항 #7 reactivate 모달 → "활성화" 버튼).
*
* 호스트 확장 재활성화 시 cascade 비활성화됐던 언어팩 ID 배열을 받아
* Service 의 bulkActivate() 로 위임합니다.
@@ -31,9 +32,12 @@ class BulkActivateRequest extends FormRequest
*/
public function rules(): array
{
return [
$rules = [
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['required', 'integer', Rule::exists(LanguagePack::class, 'id')],
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
return HookManager::applyFilters('core.language_packs.bulk_activate_validation_rules', $rules, $this);
}
}
@@ -24,9 +24,9 @@ class PolicyCollection extends BaseApiCollection
protected function abilityMap(): array
{
return [
'can_create' => 'core.admin.identity.policies.manage',
'can_update' => 'core.admin.identity.policies.manage',
'can_delete' => 'core.admin.identity.policies.manage',
'can_create' => 'core.admin.identity.policies.update',
'can_update' => 'core.admin.identity.policies.update',
'can_delete' => 'core.admin.identity.policies.update',
];
}
@@ -20,8 +20,8 @@ class PolicyResource extends BaseApiResource
public function abilityMap(): array
{
return [
'can_update' => 'core.admin.identity.policies.manage',
'can_delete' => 'core.admin.identity.policies.manage',
'can_update' => 'core.admin.identity.policies.update',
'can_delete' => 'core.admin.identity.policies.update',
];
}
@@ -49,8 +49,7 @@ class PolicyResource extends BaseApiResource
'applies_to' => $this->applies_to,
'fail_mode' => $this->fail_mode,
'user_overrides' => $this->user_overrides ?? [],
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
...$this->formatTimestamps(),
...$this->resourceMeta($request),
];
}
@@ -14,6 +14,21 @@ use Illuminate\Http\Request;
*/
class ProviderResource extends BaseApiResource
{
/**
* Resource 수준 abilities 매핑.
*
* 프로바이더는 관리자 UI 의 S1c 서브섹션에서 설정값을 편집할 수 있으며,
* 편집 권한은 `core.admin.identity.providers.update` 로 분리되어 있다.
*
* @return array<string, string>
*/
public function abilityMap(): array
{
return [
'can_update' => 'core.admin.identity.providers.update',
];
}
/**
* 리소스를 배열로 변환합니다.
*
@@ -76,6 +76,7 @@ class InitiateIdentityChallengeAfterRegister implements HookListenerInterface
'origin_identifier' => 'core.auth.after_register',
'origin_policy_key' => $policy->key,
],
providerId: $policy->provider_id,
);
} catch (\Throwable $e) {
Log::warning('[IDV] signup_after_create challenge 발행 실패', [
+7
View File
@@ -32,6 +32,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string|null $license
* @property array<string, string>|null $description
* @property string $status
* @property string|null $deactivated_reason
* @property \Illuminate\Support\Carbon|null $deactivated_at
* @property string|null $incompatible_required_version
* @property bool $is_protected
* @property array<string, mixed> $manifest
* @property string|null $source_type
@@ -86,6 +89,9 @@ class LanguagePack extends Model
'license',
'description',
'status',
'deactivated_reason',
'deactivated_at',
'incompatible_required_version',
'is_protected',
'manifest',
'source_type',
@@ -109,6 +115,7 @@ class LanguagePack extends Model
'target_version_mismatch' => 'boolean',
'installed_at' => 'datetime',
'activated_at' => 'datetime',
'deactivated_at' => 'datetime',
];
}
@@ -338,7 +338,7 @@ class LanguagePackServiceProvider extends ServiceProvider
$registrar->cleanupForExtension('template', (string) $identifier);
});
// PO #6: 호스트 확장 비활성화 시 종속 언어팩 cascade 비활성화
// 요구사항 #6: 호스트 확장 비활성화 시 종속 언어팩 cascade 비활성화
HookManager::addAction('core.modules.after_deactivate', function ($identifier) use ($registrar) {
$registrar->deactivateForExtension('module', (string) $identifier);
});
@@ -120,6 +120,17 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface
return $model ? (bool) $model->delete() : false;
}
/**
* 정책 모델을 영속화합니다 (Eloquent save 위임).
*
* @param IdentityPolicy $policy 영속화할 모델
* @return bool 저장 성공 여부
*/
public function save(IdentityPolicy $policy): bool
{
return (bool) $policy->save();
}
/**
* 특정 소스의 정책 개수를 반환합니다.
*
+2 -2
View File
@@ -283,7 +283,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
$pack->license = $manifest['license'] ?? null;
$pack->description = is_array($manifest['description'] ?? null) ? $manifest['description'] : null;
$pack->status = \App\Enums\LanguagePackStatus::Uninstalled->value;
$pack->is_protected = false; // lang-packs/_bundled/ 패키지는 사용자가 install/uninstall 자유 (PO #3)
$pack->is_protected = false; // lang-packs/_bundled/ 패키지는 사용자가 install/uninstall 자유 (요구사항 #3)
$pack->manifest = $manifest;
$pack->source_type = \App\Enums\LanguagePackSourceType::Bundled->value;
$pack->source_url = $bundledIdentifier;
@@ -298,7 +298,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
* 코어/번들 확장의 lang/{ko,en}/ 디렉토리로부터 가상 보호 LanguagePack 인스턴스를 합성합니다.
*
* `built_in` 가상 행은 DB 행이 없는 상태로 항상 active+protected 표시되며, 사용자가
* install/uninstall/activate/deactivate 할 수 없습니다 (PO #1, #2).
* install/uninstall/activate/deactivate 할 수 없습니다 (요구사항 #1, #2).
*
* @param string $scope 스코프 (core/module/plugin/template)
* @param string|null $targetIdentifier 대상 확장 식별자 (core 일 때 null)
+69 -3
View File
@@ -6,6 +6,7 @@ use App\Contracts\Extension\UpgradeStepInterface;
use App\Enums\ExtensionOwnerType;
use App\Enums\PermissionType;
use App\Exceptions\CoreUpdateOperationException;
use App\Exceptions\UpgradeHandoffException;
use App\Extension\CoreVersionChecker;
use App\Extension\Helpers\ChangelogParser;
use App\Extension\Helpers\CoreBackupHelper;
@@ -15,6 +16,7 @@ use App\Extension\Helpers\ExtensionRoleSyncHelper;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\Helpers\GithubHelper;
use App\Extension\UpgradeContext;
use App\Extension\Vendor\EnvironmentDetector;
use App\Extension\Vendor\VendorInstallContext;
use App\Extension\Vendor\VendorInstallResult;
use App\Extension\Vendor\VendorMode;
@@ -976,7 +978,11 @@ class CoreUpdateService
$onProgress?->__invoke('apply', $name.' (auto)');
if (File::isDirectory($absPath)) {
FilePermissionHelper::copyDirectory($absPath, $dest, $onProgress, $excludes, removeOrphans: true);
// 자동 발견 폴백은 destination orphan 정리 책임이 없다.
// 부모 프로세스의 stale targets 누락을 메우는 안전망이므로,
// base 의 활성 서브디렉토리(예: templates/sirsoft-basic) 가 source 에 없다고
// 해서 삭제하면 사용자 데이터 영구 손실 위험. orphan 처리는 정상 targets 분기만 수행.
FilePermissionHelper::copyDirectory($absPath, $dest, $onProgress, $excludes, removeOrphans: false);
} else {
File::ensureDirectoryExists(dirname($dest));
FilePermissionHelper::copyFile($absPath, $dest);
@@ -993,7 +999,12 @@ class CoreUpdateService
}
/**
* 정규화된 path 가 이미 처리된 상위 path 의 하위 항목인지 검사합니다.
* 정규화된 path 가 이미 처리된 상위/하위 path 와 관계가 있는지 검사합니다.
*
* 양방향 검사:
* - 방향 1 — path 가 applied 의 자식: applied=['templates'], path='templates/sub' (자식 중복 복사 방지)
* - 방향 2 — path 가 applied 의 부모: applied=['templates/_bundled'], path='templates'
* (부모 통째로 복사 시 활성 서브디렉토리 orphan 판정 회피)
*
* @param array<string, bool> $applied
*/
@@ -1008,6 +1019,13 @@ class CoreUpdateService
}
}
$prefix = $path.'/';
foreach ($applied as $appliedPath => $_) {
if (str_starts_with((string) $appliedPath, $prefix)) {
return true;
}
}
return false;
}
@@ -1207,7 +1225,7 @@ class CoreUpdateService
2 => ['pipe', 'w'],
];
$process = proc_open($command, $descriptors, $pipes, $workingDir);
$process = proc_open($command, $descriptors, $pipes, $workingDir, EnvironmentDetector::buildComposerEnv());
if (! is_resource($process)) {
throw new CoreUpdateOperationException('settings.core_update.composer_failed');
@@ -1536,6 +1554,39 @@ class CoreUpdateService
*/
public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false): void
{
// 부모 in-process fallback 진입 시 stale 메모리 가드.
//
// 현재 메모리의 `config('app.version')` 이 toVersion 보다 낮으면 부모는 stale 코드를
// 보유한 채 step 을 실행 중. upgrade step 안에서 신규 메서드 호출 시 fatal 위험
// (이슈 #28 의 발현 메커니즘). `spawn_failure_mode` 와 연동하여 abort/fallback 분기.
//
// spawn 자식 (ExecuteUpgradeStepsCommand) 의 경우 spawn env 의 APP_VERSION=toVersion
// 이 적용된 채 새 프로세스에서 부팅되므로 memoryVersion === toVersion → 가드 미발동.
$memoryVersion = (string) config('app.version', $fromVersion);
if (version_compare($memoryVersion, $toVersion, '<')) {
$mode = config('app.update.spawn_failure_mode', 'fallback');
$message = sprintf(
'[core-update] runUpgradeSteps 부모 메모리 stale 감지 — memory=%s, target=%s, mode=%s',
$memoryVersion,
$toVersion,
$mode,
);
if ($mode === 'abort') {
throw new UpgradeHandoffException(
afterVersion: $fromVersion,
reason: $message.' — fail-fast 모드. 수동 재개로 진행하세요.',
resumeCommand: sprintf(
'php artisan core:execute-upgrade-steps --from=%s --to=%s --force',
$fromVersion,
$toVersion,
),
);
}
Log::warning($message.' — fallback 모드. upgrade step 안에서 신규 메서드 호출 시 fatal 가능.');
}
$upgradesPath = base_path('upgrades');
if (! File::isDirectory($upgradesPath)) {
@@ -1575,6 +1626,21 @@ class CoreUpdateService
if (class_exists($className)) {
$instance = new $className;
if ($instance instanceof UpgradeStepInterface) {
// 7.0.0-beta.5 부터 신규 업그레이드 스텝은 AbstractUpgradeStep 상속 의무.
// "각 스텝별 동작 100% 동일 보장" invariant 를 위해 카탈로그/변환/핫픽스를
// upgrades/data/{version}/ 의 버전 namespace 아래로 격리하도록 강제한다.
//
// 미상속 시점에 즉시 throw → core:update 전체 중단 → 상위 백업 복원.
// 상세: docs/extension/upgrade-step-guide.md §12
if (version_compare($version, '7.0.0-beta.5', '>=')
&& ! $instance instanceof \App\Extension\AbstractUpgradeStep) {
throw new CoreUpdateOperationException(sprintf(
'Upgrade step %s must extend App\\Extension\\AbstractUpgradeStep '
.'(introduced in 7.0.0-beta.5). See docs/extension/upgrade-step-guide.md §12.',
$version,
));
}
$steps[$version] = $instance;
}
}
+6 -2
View File
@@ -268,7 +268,7 @@ class IdentityPolicyService
// HasUserOverrides trait 의 auto-record 우회 — 이 저장은 운영자 수정이 아니라
// 기본값 복원이므로 trackable 필드가 변경되어도 user_overrides 에 재추가되면 안 됨.
return $this->withUserOverridesBypass(fn () => $policy->save());
return $this->withUserOverridesBypass(fn () => $this->policyRepository->save($policy));
}
/**
@@ -480,6 +480,10 @@ class IdentityPolicyService
* 정책의 provider 가 제공하는 렌더 힌트를 반환합니다.
* provider_id 가 명시되어 있으면 우선 사용, 미명시 시 purpose 기반 fallback.
*
* fallback 분기에서도 정책의 provider_id 를 Manager 의 0번 우선순위로 전달한다.
* 정책에 지정된 provider 가 비활성/언인스톨된 순간 Manager 가 같은 우선순위 체인으로
* 일관 처리하도록 정합화 — IdentityVerificationService::start 와 동일 패턴.
*
* @param IdentityPolicy $policy 대상 정책
* @return string|null Provider 의 렌더 힌트 (UI 분기용) 또는 null
*/
@@ -491,7 +495,7 @@ class IdentityPolicyService
return $this->manager->get($providerId)->getRenderHint();
}
return $this->manager->resolveForPurpose($policy->purpose)->getRenderHint();
return $this->manager->resolveForPurpose($policy->purpose, $providerId)->getRenderHint();
} catch (\Throwable) {
return null;
}
+11 -3
View File
@@ -38,14 +38,22 @@ class IdentityVerificationService
/**
* Challenge 를 시작합니다.
*
* $providerId 가 주어지면 Manager 의 0번째 우선순위로 사용된다 (IdentityVerificationManager::resolveForPurpose).
* 미등록/미지원이면 silent fallback (기존 우선순위 체인 진행).
*
* @param string $purpose signup|password_reset|self_update|sensitive_action|플러그인 등록값
* @param User|array<string, mixed> $target 로그인 사용자(User) 또는 ['email' => '...'] 배열
* @param array<string, mixed> $context origin_type / origin_identifier / origin_policy_key / ip_address / user_agent
* @param string|null $providerId Mode A controller 요청 또는 Mode B 정책의 명시 provider id
* @return VerificationChallenge 발행된 challenge DTO
*/
public function start(string $purpose, User|array $target, array $context = []): VerificationChallenge
{
$provider = $this->manager->resolveForPurpose($purpose);
public function start(
string $purpose,
User|array $target,
array $context = [],
?string $providerId = null,
): VerificationChallenge {
$provider = $this->manager->resolveForPurpose($purpose, $providerId);
$context['purpose'] = $purpose;
@@ -132,7 +132,7 @@ class LanguagePackBundledRegistrar
}
/**
* 호스트 확장 비활성화 시 해당 확장에 종속된 언어팩을 cascade 비활성화합니다 (PO #6).
* 호스트 확장 비활성화 시 해당 확장에 종속된 언어팩을 cascade 비활성화합니다 (요구사항 #6).
*
* 비활성화된 active 팩 ID 목록을 cache 에 stash 하여, 재활성화 시 §6 모달이
* 표시할 후보로 사용됩니다.
@@ -191,10 +191,10 @@ class LanguagePackBundledRegistrar
}
/**
* 호스트 확장 재활성화 시 모달에 표시할 pending 언어팩 목록을 반환합니다 (PO #7).
* 호스트 확장 재활성화 시 모달에 표시할 pending 언어팩 목록을 반환합니다 (요구사항 #7).
*
* stash 된 ID 중 현재도 DB 에 존재하고 inactive 상태인 팩만 필터링합니다.
* 빈 배열을 반환하면 프론트엔드는 모달을 띄우지 않습니다 (PO #8).
* 빈 배열을 반환하면 프론트엔드는 모달을 띄우지 않습니다 (요구사항 #8).
*
* @param string $scope 스코프
* @param string $targetIdentifier 대상 확장 식별자
+8 -8
View File
@@ -165,7 +165,7 @@ class LanguagePackService
/**
* 코어/번들 확장의 `lang/{ko,en}/` 디렉토리를 자동 스캔하여 가상 보호 LanguagePack
* 인스턴스 컬렉션을 반환합니다 (PO #1, #2).
* 인스턴스 컬렉션을 반환합니다 (요구사항 #1, #2).
*
* 스캔 대상:
* - 코어: lang/{ko,en}/
@@ -351,7 +351,7 @@ class LanguagePackService
return false;
}
if (array_key_exists('target_identifier', $filters) && $filters['target_identifier'] !== null) {
// 개별 확장 언어팩 관리 화면 — 그 확장에 해당되는 행만 노출 (PO 정정).
// 개별 확장 언어팩 관리 화면 — 그 확장에 해당되는 행만 노출 (정정 사항).
// 코어 보호 팩은 환경설정 > 언어팩 관리(필터 없음) 화면에서만 노출됨.
if ($pack->target_identifier !== $filters['target_identifier']) {
return false;
@@ -848,7 +848,7 @@ class LanguagePackService
return $pack;
}
// PO #5: activate 시점에도 의존성/버전 호환성 검사 (이전: install 시점만)
// 요구사항 #5: activate 시점에도 의존성/버전 호환성 검사 (이전: install 시점만)
$this->assertDependencies($pack->manifest ?? []);
$this->assertTargetExtensionExists($pack->manifest ?? []);
@@ -884,7 +884,7 @@ class LanguagePackService
}
/**
* 여러 언어팩을 일괄 활성화합니다 (PO #7 reactivate 모달 → "활성화" 버튼).
* 여러 언어팩을 일괄 활성화합니다 (요구사항 #7 reactivate 모달 → "활성화" 버튼).
*
* 각 ID 에 대해 activate() 를 호출하며, 실패한 항목은 reason 과 함께 응답에 포함합니다.
* 의존성 검사는 activate() 내부에서 자동 수행됩니다 (§4).
@@ -931,7 +931,7 @@ class LanguagePackService
return $pack;
}
// PO #6: 호스트 확장 cascade 컨텍스트에서는 protected 가드 우회
// 요구사항 #6: 호스트 확장 cascade 컨텍스트에서는 protected 가드 우회
if ($pack->isProtected() && ! $cascadeFromHost) {
throw new LanguagePackOperationException('language_packs.errors.protected_pack');
}
@@ -1322,7 +1322,7 @@ class LanguagePackService
*/
public function checkUpdates(?string $identifier = null): array
{
// PO #4: 모듈 패턴 — 모든 source_type 점검 (GitHub 1순위, 실패 시 bundled 폴백)
// 요구사항 #4: 모듈 패턴 — 모든 source_type 점검 (GitHub 1순위, 실패 시 bundled 폴백)
$packs = $identifier
? collect([$this->repository->findByIdentifier($identifier)])->filter()
: $this->repository->paginate([], 1000)->getCollection();
@@ -1435,7 +1435,7 @@ class LanguagePackService
}
/**
* 번들 manifest 로부터 최신 버전을 조회합니다 (PO #4 폴백 경로).
* 번들 manifest 로부터 최신 버전을 조회합니다 (요구사항 #4 폴백 경로).
*
* @param LanguagePack $pack 대상 언어팩
* @return string|null 최신 버전 문자열 또는 null
@@ -1538,7 +1538,7 @@ class LanguagePackService
*/
public function performUpdate(LanguagePack $pack, bool $force = false): LanguagePack
{
// PO #4: bundled 도 update 가능. force=true 시 bundled 1순위, 그 외 GitHub 1순위 + bundled 폴백
// 요구사항 #4: bundled 도 update 가능. force=true 시 bundled 1순위, 그 외 GitHub 1순위 + bundled 폴백
$githubUrl = $this->resolveGithubUrl($pack);
if (! $githubUrl && ! $this->hasBundledManifest($pack->identifier)) {
throw new LanguagePackOperationException('language_packs.errors.update_no_source');
+25 -2
View File
@@ -231,7 +231,7 @@ return [
|
*/
'version' => env('APP_VERSION', '7.0.0-beta.4'),
'version' => env('APP_VERSION', '7.0.0-beta.5'),
/*
|--------------------------------------------------------------------------
@@ -277,7 +277,11 @@ return [
// 개발 도구 메타(`.git`, `.claude`, `.serena` 등) 를 보호한다.
// 부모 프로세스의 stale `targets` 가 신버전 신규 디렉토리를 인식하지 못해
// 자동 발견이 트리거될 때만 참조된다 (정상 경로의 targets 는 항상 처리됨).
'protected_paths' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_PROTECTED_PATHS', '.env,.env.local,.env.production,.env.testing,.env.testing.example,storage,vendor,node_modules,.git,.github,.idea,.vscode,.serena,.claude,.mcp.json,.phpunit.result.cache,core_pending')))),
// modules,plugins,templates,lang-packs 부모 디렉토리도 보호 대상에 포함.
// targets 에 `{modules,plugins,templates,lang-packs}/_bundled` 가 명시되어 정상 흐름에서는
// 처리되므로 영향 없음. 자동 발견 폴백이 부모 디렉토리 단위로 base 를 통째로 복사해
// 활성 서브디렉토리(예: templates/sirsoft-basic) 를 잘못 삭제하는 회귀(#347) 차단용 방어 깊이.
'protected_paths' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_PROTECTED_PATHS', '.env,.env.local,.env.production,.env.testing,.env.testing.example,storage,vendor,node_modules,.git,.github,.idea,.vscode,.serena,.claude,.mcp.json,.phpunit.result.cache,core_pending,modules,plugins,templates,lang-packs')))),
'backup_only' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_BACKUP_ONLY', 'vendor')))),
'backup_extra' => ['storage/app/settings'],
// 업데이트 종료 시 base_path() 소유자/그룹 기준으로 소유권을 재귀 복원할 경로 목록.
@@ -331,6 +335,25 @@ return [
'G7_UPDATE_RESTORE_OWNERSHIP_GROUP_WRITABLE',
'storage/logs,storage/framework,storage/app/core_pending,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,lang-packs,lang-packs/_pending'
)))),
// spawn 자식 프로세스 실패 시 동작 모드.
//
// - 'abort' (기본 / 안전 우선): 즉시 abort 후 운영자에게 수동 명령
// (`php artisan core:execute-upgrade-steps --from=... --to=...`)
// 안내. 부모 메모리 stale 로 인한 upgrade step fatal
// 위험을 원천 차단한다.
// - 'fallback' (호환 모드) : 기존 동작 — in-process fallback 으로 진행.
// proc_open 비활성 공유 호스팅 등에서만 사용. 부모
// 메모리 stale 시 신규 메서드 호출 fatal 위험 잔존.
//
// 4가지 분기 모두 본 모드를 따른다:
// (1) proc_open 비활성
// (2) proc_open 자원 생성 실패
// (3) 자식 비정상 종료 (exit != 0)
// (4) 자식 exit=0 이지만 [STEPS_EXECUTED] 신호 미발행/step 0건 실행 (silent skip)
//
// `CoreUpdateService::runUpgradeSteps` 의 stale 메모리 가드도 본 모드와 연동한다.
'spawn_failure_mode' => env('G7_UPDATE_SPAWN_FAILURE_MODE', 'abort'),
],
];
+8 -6
View File
@@ -166,12 +166,14 @@ return [
'order' => 7.8,
'type' => 'admin',
'permissions' => [
['identifier' => 'core.admin.identity.manage', 'type' => 'admin', 'name' => ['ko' => '프로바이더 설정', 'en' => 'Manage IDV Providers'], 'description' => ['ko' => '본인인증 프로바이더 설정을 관리할 수 있습니다.', 'en' => 'Can manage identity verification providers.'], 'order' => 1],
['identifier' => 'core.admin.identity.policies.manage', 'type' => 'admin', 'name' => ['ko' => '정책 관리', 'en' => 'Manage IDV Policies'], 'description' => ['ko' => '본인인증 정책(라우트/훅별)을 CRUD 할 수 있습니다.', 'en' => 'Can CRUD identity verification policies by route/hook.'], 'order' => 2],
['identifier' => 'core.admin.identity.logs.read', 'type' => 'admin', 'name' => ['ko' => '로그 열람', 'en' => 'View IDV Logs'], 'description' => ['ko' => '본인인증 이력을 조회할 수 있습니다.', 'en' => 'Can view identity verification logs.'], 'order' => 3],
['identifier' => 'core.admin.identity.logs.purge', 'type' => 'admin', 'name' => ['ko' => '로그 파기', 'en' => 'Purge IDV Logs'], 'description' => ['ko' => '본인인증 이력을 파기(보관주기 외)할 수 있습니다.', 'en' => 'Can purge identity verification logs (retention-based).'], 'order' => 4],
['identifier' => 'core.admin.identity.messages.read', 'type' => 'admin', 'name' => ['ko' => '메시지 템플릿 조회', 'en' => 'View IDV Message Templates'], 'description' => ['ko' => '본인인증 메시지 정의/템플릿을 조회할 수 있습니다.', 'en' => 'Can view identity verification message definitions and templates.'], 'order' => 5],
['identifier' => 'core.admin.identity.messages.update', 'type' => 'admin', 'name' => ['ko' => '메시지 템플릿 수정', 'en' => 'Update IDV Message Templates'], 'description' => ['ko' => '본인인증 메시지 정의/템플릿을 수정할 수 있습니다.', 'en' => 'Can update identity verification message definitions and templates.'], 'order' => 6],
['identifier' => 'core.admin.identity.providers.read', 'type' => 'admin', 'name' => ['ko' => '프로바이더 설정 조회', 'en' => 'View IDV Providers'], 'description' => ['ko' => '본인인증 프로바이더 설정을 조회할 수 있습니다.', 'en' => 'Can view identity verification providers.'], 'order' => 1],
['identifier' => 'core.admin.identity.providers.update', 'type' => 'admin', 'name' => ['ko' => '프로바이더 설정 수정', 'en' => 'Update IDV Providers'], 'description' => ['ko' => '본인인증 프로바이더 설정을 수정할 수 있습니다.', 'en' => 'Can update identity verification providers.'], 'order' => 2],
['identifier' => 'core.admin.identity.policies.read', 'type' => 'admin', 'name' => ['ko' => '정책 조회', 'en' => 'View IDV Policies'], 'description' => ['ko' => '본인인증 정책(라우트/훅별)을 조회할 수 있습니다.', 'en' => 'Can view identity verification policies by route/hook.'], 'order' => 3],
['identifier' => 'core.admin.identity.policies.update', 'type' => 'admin', 'name' => ['ko' => '정책 수정', 'en' => 'Update IDV Policies'], 'description' => ['ko' => '본인인증 정책(라우트/훅별)을 CRUD 할 수 있습니다.', 'en' => 'Can CRUD identity verification policies by route/hook.'], 'order' => 4],
['identifier' => 'core.admin.identity.logs.read', 'type' => 'admin', 'name' => ['ko' => '로그 열람', 'en' => 'View IDV Logs'], 'description' => ['ko' => '본인인증 이력을 조회할 수 있습니다.', 'en' => 'Can view identity verification logs.'], 'order' => 5],
['identifier' => 'core.admin.identity.logs.purge', 'type' => 'admin', 'name' => ['ko' => '로그 파기', 'en' => 'Purge IDV Logs'], 'description' => ['ko' => '본인인증 이력을 파기(보관주기 외)할 수 있습니다.', 'en' => 'Can purge identity verification logs (retention-based).'], 'order' => 6],
['identifier' => 'core.admin.identity.messages.read', 'type' => 'admin', 'name' => ['ko' => '메시지 템플릿 조회', 'en' => 'View IDV Message Templates'], 'description' => ['ko' => '본인인증 메시지 정의/템플릿을 조회할 수 있습니다.', 'en' => 'Can view identity verification message definitions and templates.'], 'order' => 7],
['identifier' => 'core.admin.identity.messages.update', 'type' => 'admin', 'name' => ['ko' => '메시지 템플릿 수정', 'en' => 'Update IDV Message Templates'], 'description' => ['ko' => '본인인증 메시지 정의/템플릿을 수정할 수 있습니다.', 'en' => 'Can update identity verification message definitions and templates.'], 'order' => 8],
],
],
[
@@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 언어팩 테이블에 비활성화 사유 메타 컬럼 추가.
*
* 사용자 수동 비활성화(manual)와 코어 버전 비호환에 의한 자동 비활성화(incompatible_core)를
* 구분하여 (a) UI 라벨링, (b) 알림 영속화, (c) 재호환 시 원클릭 복구 판정에 사용합니다.
*
* 다른 확장 테이블(plugins/modules/templates) 의 deactivated_reason 컬럼 스키마와 일관되도록
* 동일한 컬럼 정의·인덱스 패턴을 적용합니다.
*/
public function up(): void
{
Schema::table('language_packs', function (Blueprint $table) {
if (! Schema::hasColumn('language_packs', 'deactivated_reason')) {
$table->string('deactivated_reason', 32)
->nullable()
->after('status')
->comment('비활성화 사유: manual(사용자 수동) | incompatible_core(코어 버전 호환성) | null(active)');
}
if (! Schema::hasColumn('language_packs', 'deactivated_at')) {
$table->timestamp('deactivated_at')
->nullable()
->after('deactivated_reason')
->comment('비활성화 시점');
}
if (! Schema::hasColumn('language_packs', 'incompatible_required_version')) {
$table->string('incompatible_required_version', 64)
->nullable()
->after('deactivated_at')
->comment('incompatible_core 시 요구된 g7_version 제약 (재호환 판정용)');
}
});
Schema::table('language_packs', function (Blueprint $table) {
$table->index('deactivated_reason', 'language_packs_deactivated_reason_index');
});
}
/**
* 마이그레이션 롤백.
*/
public function down(): void
{
Schema::table('language_packs', function (Blueprint $table) {
$table->dropIndex('language_packs_deactivated_reason_index');
});
Schema::table('language_packs', function (Blueprint $table) {
foreach (['incompatible_required_version', 'deactivated_at', 'deactivated_reason'] as $column) {
if (Schema::hasColumn('language_packs', $column)) {
$table->dropColumn($column);
}
}
});
}
};
+1 -1
View File
@@ -179,7 +179,7 @@ HookManager::broadcast("core.user.notifications.{$user->uuid}", 'notification.re
브로드캐스팅은 부가 기능이므로 실패해도 메인 작업(사용자 업데이트, 주문 처리, 알림 발송 등)을 중단시키지 않습니다. 개별 리스너에서 try-catch를 추가할 필요가 없습니다.
> **중요**: 웹소켓 OFF 시 `broadcasting.default`가 `'null'`로 강제되는 동작은 `.env`의 `BROADCAST_CONNECTION` 값을 무시하고 적용됩니다. 따라서 PO가 환경설정에서 OFF한 경우, `.env`에 `REVERB_HOST=localhost` 등이 남아 있어도 broadcast 시도가 발생하지 않습니다. 알림 시스템(mail/database 채널)은 이 설정과 독립적으로 정상 동작합니다.
> **중요**: 웹소켓 OFF 시 `broadcasting.default`가 `'null'`로 강제되는 동작은 `.env`의 `BROADCAST_CONNECTION` 값을 무시하고 적용됩니다. 따라서 운영자가 환경설정에서 OFF한 경우, `.env`에 `REVERB_HOST=localhost` 등이 남아 있어도 broadcast 시도가 발생하지 않습니다. 알림 시스템(mail/database 채널)은 이 설정과 독립적으로 정상 동작합니다.
### 채널 타입
+78
View File
@@ -126,6 +126,84 @@ return new class extends Migration
};
```
### 마이그레이션 멱등성
```
필수: Schema::table 안의 컬럼 추가 (`$table->{type}('{column}')`) 는 Schema::hasColumn 가드와 함께 작성
배경: 코어 업그레이드 도중 마이그레이션이 부분 적용된 채 fatal 한 경우, `core:update --force` 재실행 시 이미 적용된 컬럼이 재시도되어 "Column already exists" SQL error 가 발생
면제: `// audit:allow migration-idempotency-guard reason: ...` 인라인 주석 (의도적 비-멱등 사유 기재)
```
#### 멱등 작성 예시
```php
// ✅ 올바른 패턴 — Schema::hasColumn 가드
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
if (! Schema::hasColumn('users', 'locked_until')) {
$table->timestamp('locked_until')->nullable()->after('email');
}
if (! Schema::hasColumn('users', 'last_failed_login_at')) {
$table->timestamp('last_failed_login_at')->nullable();
}
});
}
// ❌ 잘못된 패턴 — 가드 부재
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->timestamp('locked_until')->nullable(); // partial migrate 후 재실행 시 fatal
$table->timestamp('last_failed_login_at')->nullable();
});
}
```
#### 인덱스 추가의 멱등성
인덱스 추가는 Laravel 의 Schema 빌더가 멱등 가드를 직접 제공하지 않으므로 다음 패턴 중 하나 적용:
```php
// 방식 1 — Schema::hasIndex (Laravel 11+ Schema::getIndexes 기반)
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
if (! collect(Schema::getIndexes('users'))->pluck('name')->contains('users_email_idx')) {
$table->index('email', 'users_email_idx');
}
});
}
// 방식 2 — try/catch (인덱스명이 명시되지 않은 경우)
public function up(): void
{
try {
Schema::table('users', function (Blueprint $table): void {
$table->index('email');
});
} catch (\Throwable $e) {
// 이미 존재 — 멱등 skip
}
}
```
#### `->change()` 패턴의 멱등성
`->change()` 는 이미 존재하는 컬럼의 타입/제약 변경이므로 컬럼 부재 fatal 위험은 없다. 단, MariaDB/MySQL 의 일부 ALTER 가 비-멱등인 경우 (예: enum 값 추가 후 재실행) 가 있으므로 마이그레이션 작성자가 의도 검증 필요. audit rule `migration-idempotency-guard` 는 `->change()` 라인을 자동 제외하므로 false positive 미발생.
#### 검증 절차
단일 마이그레이션의 dry-run 출력을 두 번 적용해도 동일 출력인지 확인:
```bash
php artisan migrate --pretend --path=database/migrations/2026_05_05_172526_add_login_attempt_columns_to_users_table.php
```
신규 마이그레이션 작성 후에는 같은 마이그레이션을 두 번 실행하는 시나리오를 PHPUnit Feature 테스트로 작성해 SQL error 미발생을 검증한다 (`tests/Feature/Upgrades/MultiVersionUpgradePathTest.php` 의 `hasColumn_가드된_마이그레이션은_두_번_적용해도_SQL_error_없다` 사례 참조).
---
### 모듈/플러그인 마이그레이션 down() 메서드
```
+9 -3
View File
@@ -141,7 +141,7 @@
| `getHookListeners()` | `[]` | 훅 리스너 |
| `getDependencies()` | `[]` | 의존성 |
| `getMetadata()` | `[]` | 메타데이터 |
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견) |
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 모듈은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `ModuleManager::runUpgradeSteps` 가 `RuntimeException` throw |
#### 동적 권한/역할/메뉴 보존 규칙
@@ -489,8 +489,14 @@ modules/_bundled/sirsoft-ecommerce/
├── dist/ # 프론트엔드 빌드 출력 (에셋 모듈만, gitignore 대상)
│ ├── js/module.iife.js
│ └── css/module.css
├── upgrades/ # 버전 업그레이드 스텝 (UpgradeStepInterface 구현)
│ └── Upgrade_1_1_0.php # 1.1.0 버전 업그레이드 로직
├── upgrades/ # 버전 업그레이드 스텝 (AbstractUpgradeStep 상속 — g7_version >= 7.0.0-beta.5 모듈 의무)
│ ├── Upgrade_1_1_0.php # 1.1.0 버전 업그레이드 스텝 (extends AbstractUpgradeStep)
│ └── data/ # 버전별 데이터 스냅샷 — 카탈로그 delta / Applier / Migration 동결
│ └── 1.1.0/
│ ├── manifest.json # kind → delta JSON 매핑
│ ├── *.delta.json # added/removed/renamed 시드
│ ├── appliers/ # SnapshotApplier 구현 (버전 namespace)
│ └── migrations/ # DataMigration 구현 (변환/핫픽스, 버전 namespace)
├── config/
│ └── ecommerce.php # 모듈 설정
├── database/
+9 -3
View File
@@ -240,7 +240,7 @@ class Plugin implements PluginInterface
| `getDynamicRoleIdentifiers()` | `[]` | 런타임 생성 역할 식별자 — stale cleanup 보존 대상 |
| `getDependencies()` | `[]` | 의존하는 모듈/플러그인 목록 |
| `getHookListeners()` | `[]` | 훅 리스너 클래스 목록 |
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견) |
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 플러그인은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `PluginManager::runUpgradeSteps` 가 `RuntimeException` throw |
> **동적 식별자 보존 규칙**: `Permission::updateOrCreate()` / `Role::firstOrCreate()` 등으로 런타임에 생성한 엔티티는 업데이트 시 `cleanupStalePluginEntries` 에 의해 "정적 정의에 없는 고아 레코드" 로 판정되어 삭제될 위험이 있습니다. 이를 방지하려면 동적 식별자 목록을 위 3개 훅에서 반환하세요 — 정적 정의 + 동적 식별자가 병합된 expected 목록을 기준으로 판정되어 보존됩니다. 상세는 [extension-update-system.md](extension-update-system.md) 참조.
@@ -296,8 +296,14 @@ plugins/_bundled/sirsoft-payment/
├── LICENSE # 라이선스 전문 (MIT)
├── composer.json # PSR-4 오토로딩 + 외부 패키지 의존성
├── vendor/ # Composer 의존성 (자동 생성, gitignore 대상)
├── upgrades/ # 버전 업그레이드 스텝 (UpgradeStepInterface 구현)
│ └── Upgrade_1_1_0.php # 1.1.0 버전 업그레이드 로직
├── upgrades/ # 버전 업그레이드 스텝 (AbstractUpgradeStep 상속 — g7_version >= 7.0.0-beta.5 플러그인 의무)
│ ├── Upgrade_1_1_0.php # 1.1.0 버전 업그레이드 스텝 (extends AbstractUpgradeStep)
│ └── data/ # 버전별 데이터 스냅샷 — 카탈로그 delta / Applier / Migration 동결
│ └── 1.1.0/
│ ├── manifest.json # kind → delta JSON 매핑
│ ├── *.delta.json # added/removed/renamed 시드
│ ├── appliers/ # SnapshotApplier 구현 (버전 namespace)
│ └── migrations/ # DataMigration 구현 (변환/핫픽스, 버전 namespace)
├── config/ # 플러그인 설정
│ ├── payment.php
│ └── settings/
+213
View File
@@ -11,6 +11,7 @@
4. 경로 A (모듈/플러그인) · 경로 C 규율: 기존 클래스의 신규 메서드 호출 금지, 로컬 private 헬퍼 우선
5. 모든 분기에 upgrade.log 출력 — 로그 없음 = 디버깅 단서 없음
6. beta.3+ 타깃 step 이 중간에 새 프로세스 재진입이 필요하면 `UpgradeHandoffException` throw (섹션 10.5)
7. 7.0.0-beta.5+ 신규 step 은 `AbstractUpgradeStep` 상속 의무 + 카탈로그/변환/핫픽스를 `upgrades/data/{version}/` 으로 격리 (섹션 13)
```
---
@@ -29,6 +30,8 @@
10. [경로 C 내부 inline spawn 패턴](#10-경로-c-내부-inline-spawn-패턴)
10.5. [업그레이드 핸드오프 (beta.3+ 인프라)](#105-업그레이드-핸드오프-beta3-인프라)
11. [업그레이드 후 데이터 정합성 (완전 동기화)](#11-업그레이드-후-데이터-정합성-완전-동기화)
12. [Declarative artifacts 일회성 보정 패턴](#12-declarative-artifacts-일회성-보정-패턴)
13. [버전별 데이터 스냅샷 (7.0.0-beta.5+)](#13-버전별-데이터-스냅샷-700-beta5)
---
@@ -399,6 +402,39 @@ upgrade step 작성 전 다음을 확인:
경로 B 라고 판단했더라도, proc_open 차단 환경에서는 in-process fallback 이 작동하므로 **가능하면 경로 A/C 규율도 충족** 하도록 작성하는 것이 안전하다.
### V-1 안전 작성 패턴 (경로 B 의 사각지대)
경로 B 의 "spawn 자식이 fresh 디스크 코드를 로드" 가정은 `proc_open` 정상 동작에 의존한다. 다음 4가지 상황에서 in-process fallback 으로 전환되어 V-1 (이전 버전 메모리에 부재한 신규 메서드 호출) fatal 위험이 부활:
1. `proc_open` 함수 비활성 (보안 설정 / 일부 공유 호스팅)
2. `proc_open` 자원 생성 실패 (메모리 부족 / pipe 한도 초과)
3. 자식 비정상 종료 (uncaught exception / fatal / OOM)
4. 자식 exit=0 이지만 `[STEPS_EXECUTED]` 신호 미발행 또는 step 0건 실행 (silent skip)
beta.5+ 의 `spawn_failure_mode` (기본 `abort`) 가 위 4분기 모두를 fail-fast 차단하지만, 운영자가 `G7_UPDATE_SPAWN_FAILURE_MODE=fallback` 으로 호환 모드를 선택하면 V-1 위험이 잔존한다.
따라서 신규 step 작성 시 다음 안전 패턴을 적용:
- 신규 도입 (현재 작성 중인 버전에서 처음 추가된) 클래스/메서드/Repository 를 upgrade step 안에서 호출 금지
- 부득이 호출이 필요하면 `@upgrade-path C` 어노테이션으로 명시 + 로컬 private 메서드로 인라인 작성
- 허용 호출: `FilePermissionHelper`, `File` / `DB` / `Schema` / `Cache` / `Log` 파사드 등 *이전 버전 디스크 코드에도 존재하는* 코어 헬퍼만
- 검증: PR review 단계에서 "이 step 이 호출하는 모든 메서드/클래스가 *이전 버전* 디스크 코드에도 존재하는가?" 자문
#### In-process fallback 진입 시 위험 메커니즘
부모 프로세스 메모리의 stale 클래스 인스턴스가 Laravel DI 컨테이너에서 반환되어, 디스크의 신버전 코드를 무시한 채 신규 메서드 호출 → `Call to undefined method` fatal. 이슈 #28 의 실 보고 사례:
```text
Call to undefined method App\Services\CoreUpdateService::ensureWritableDirectories()
at upgrades/Upgrade_7_0_0_beta_4.php:173 — ensureLangPacksPermissions()
```
beta.4 의 `Upgrade_7_0_0_beta_4` step 이 `app(CoreUpdateService::class)->ensureWritableDirectories(...)` 를 호출했으나, 부모 메모리의 stale beta.3 `CoreUpdateService` 인스턴스에는 `ensureWritableDirectories` 가 없어 fatal. 디스크는 이미 beta.4 였음에도 PHP autoloader 가 beta.3 인스턴스를 재사용한 결과.
#### 자동 검출 — `upgrade-step-vone-safety` audit rule (manual-only)
`upgrades/Upgrade_*.php` 안의 `app(\w+Service::class)` / `app(\w+Manager::class)` / `app(\w+Repository::class)` 패턴은 PR review reviewer 에게 manual-only 경고를 발행한다. 자동 차단은 아니지만, 매치된 위치를 보고 "이 메서드가 이전 버전 디스크에 존재했는가" 를 reviewer 가 수동 판정한다. 면제: `// audit:allow upgrade-step-vone-safety reason: ...` 인라인 주석.
---
## 10. 경로 C 내부 inline spawn 패턴
@@ -595,6 +631,183 @@ $pluginResult = app(PluginManager::class)->resyncAllActiveDeclarativeArtifacts()
---
## 13. 버전별 데이터 스냅샷 (7.0.0-beta.5+)
### 배경
spawn 자식 (경로 B) 은 디스크의 *최신* 코드/시더/카탈로그를 fresh-load 한다. 멀티 버전 점프 (예: beta.1 → beta.5) 시 beta.2/3/4 의 upgrade step 이 순차 실행되더라도, 각 step 이 호출하는 시더·Manager·헬퍼는 모두 **beta.5 메모리** 위에서 동작한다. 결과: 사용자가 하나씩 단계 업그레이드한 것과 동등하지 않은 데이터 상태.
본 섹션은 이 비대칭을 해소하는 규약을 정의한다 — **카탈로그 / 변환 / 핫픽스 모두를 그 버전 디렉토리 안에 동결**하여 "각 스텝별 동작 100% 동일 보장" invariant 를 성립시킨다.
### 적용 시점
- **코어**: 7.0.0-beta.5 부터 신규 step 의무 (beta.2~4 는 legacy 호환 유지)
- **번들 모듈/플러그인**: `module.json` / `plugin.json` 의 `g7_version` 제약 최소 버전이 `7.0.0-beta.5` 이상이면 *그 확장의 현재 version 부터* 신규 step 의무. 그 미만이면 legacy (가드 미발동)
- **외부 확장 (`modules/{not _bundled}` / `plugins/{not _bundled}`)**: 런타임 가드는 동일하게 발화 (manifest g7_version 판정), audit 만 적용 제외 (사용자 수정 코드 PR 차단 부적합)
- **번들 템플릿/언어팩**: upgrade step 시스템 자체가 부재 — 미래 도입 시 동일 규약 자동 상속
### 확장 작성자의 적용 트리거
확장 작성자가 `g7_version` 을 `>=7.0.0-beta.5` 이상으로 상향하는 시점이 본 규약의 *자동 적용 첫 버전* 이다 (`ExtensionUpgradeGuardHelper::resolveSinceVersion`). 그 이후 `upgrades()` 에서 반환하는 신규 step 은 모두 `AbstractUpgradeStep` 상속 의무 — 미상속 시 `ModuleManager::runUpgradeSteps` / `PluginManager::runUpgradeSteps` 가 `RuntimeException` throw.
| `g7_version` | 확장 working version | 의무 시작 버전 | 효과 |
| --- | --- | --- | --- |
| `>=7.0.0-beta.5` | `1.2.0` | `1.2.0` | `1.2.0` 이상 step 은 모두 AbstractUpgradeStep 의무 |
| `>=7.0.0-beta.4` | `1.2.0` | (legacy) | 가드 미발동 — 신규 step 도 자유 작성 가능 |
| (미선언 / null) | `1.2.0` | (legacy) | 가드 미발동 |
### `dataDir()` 의 코어/확장 자동 분기
`AbstractUpgradeStep::dataDir()` 는 `ReflectionClass($this)->getFileName()` 으로 *상속받은 구체 클래스의 파일 위치* 를 기준으로 data 디렉토리를 계산:
| 상속 위치 | `dataDir()` 결과 |
| --- | --- |
| `upgrades/Upgrade_7_0_0_beta_5.php` (코어) | `upgrades/data/7.0.0-beta.5/` |
| `modules/_bundled/vendor-foo/upgrades/Upgrade_1_2_0.php` | `modules/_bundled/vendor-foo/upgrades/data/1.2.0/` |
| `plugins/_bundled/vendor-bar/upgrades/Upgrade_2_0_0.php` | `plugins/_bundled/vendor-bar/upgrades/data/2.0.0/` |
확장은 코어 인프라(`AbstractUpgradeStep`, `DataSnapshot`, `SnapshotApplier` / `DataMigration` 인터페이스, manifest 스키마) 를 그대로 재사용한다 — 별도 사본 없음.
### 격리 원칙
각 step 은 다음을 보유:
- 스텝 파일 `upgrades/Upgrade_X_Y_Z.php` — `AbstractUpgradeStep` 상속만, 비즈니스 로직 없음
- `upgrades/data/{version}/manifest.json` — kind → delta JSON 파일 매핑
- `upgrades/data/{version}/*.delta.json` — 카탈로그 시드 delta (added / removed / renamed)
- `upgrades/data/{version}/appliers/{Kind}Applier.php` — delta JSON 적용기 (버전 namespace)
- `upgrades/data/{version}/migrations/*.php` — 변환 / 단발성 핫픽스 (버전 namespace)
namespace 규약 (코어/확장 자동 분기):
| 위치 | namespace |
| --- | --- |
| 코어 (`upgrades/data/{ver}/`) | `App\Upgrades\Data\V{token}\(Appliers\|Migrations)` |
| 번들 모듈 (`modules/_bundled/{id}/upgrades/data/{ver}/`) | `App\Upgrades\Data\Ext\Modules\{StudlyId}\V{token}\(Appliers\|Migrations)` |
| 번들 플러그인 (`plugins/_bundled/{id}/upgrades/data/{ver}/`) | `App\Upgrades\Data\Ext\Plugins\{StudlyId}\V{token}\(Appliers\|Migrations)` |
| 외부 모듈 (`modules/{id}/upgrades/data/{ver}/`) | 동일 패턴 (Ext\Modules) — 사용자 수정 경로도 격리 |
| 외부 플러그인 (`plugins/{id}/upgrades/data/{ver}/`) | 동일 패턴 (Ext\Plugins) |
`{token}` = 점·하이픈을 underscore 로 치환 (예: `7.0.0-beta.5` → `V7_0_0_beta_5`).
`{StudlyId}` = 확장 식별자 hyphen/underscore 를 StudlyCase 로 변환 (예: `sirsoft-ecommerce` → `SirsoftEcommerce`).
`DataSnapshot::versionedNamespace($context, $sourceLocation)` 가 `$sourceLocation` 경로의 `modules|plugins` 마커 substring 을 기준으로 분기 — 코어/확장의 같은 step 버전이라도 *서로 다른 namespace* 가 부여되어 PHP compile-time fatal ("Cannot declare class ...") 회귀가 차단된다.
### `AbstractUpgradeStep` 위임 흐름
```php
final public function run(UpgradeContext $context): void
{
$this->dataSnapshot($context)->apply($context); // Applier 순차 실행
foreach ($this->dataMigrations($context) as $m) { // Migration 순차 실행 (파일명 정렬 순)
$m->run($context);
}
$this->postRun($context); // 거의 사용 안 함
}
```
`dataSnapshot()` / `dataMigrations()` 모두 default impl 이 `data/{version}/` 을 스캔 + `require_once` + 버전 namespace 클래스 인스턴스화. 일반 케이스는 override 불필요.
### 실행 순서 제어
`dataMigrations()` 는 `data/{version}/migrations/*.php` 를 파일명 alphabetical 정렬 순으로 실행. 명시적 순서가 필요하면 파일명에 두 자리 숫자 prefix 사용:
```text
01_RecoverActiveExtensionDirs.php
02_RecoverPendingStubFiles.php
03_VerifyBundledLangPacksFallback.php
04_IdentityPermissionPivotMerge.php
05_RecoverPublicStorageSymlink.php
```
클래스명 자체는 prefix 없이 (PHP 식별자 제약). `AbstractUpgradeStep` 이 매핑 시 정규식 `/^\d{2,}_/` 으로 제거.
### Delta JSON 스키마
`permissions.delta.json` 예:
```json
{
"added": [
{
"identifier": "core.foo.read",
"type": "admin",
"category": "core.foo",
"name": { "ko": "Foo 조회", "en": "Read Foo" }
}
],
"removed": ["core.legacy.x"],
"renamed": [
{ "from": "core.old.key", "to": "core.new.key" }
]
}
```
`role_permissions.delta.json` 예:
```json
{
"grants": [{ "role": "user", "permission": "core.notifications.read" }],
"revokes": [{ "role": "user", "permission": "core.legacy.read" }]
}
```
각 Applier 가 동일 패턴으로 added/removed/renamed (또는 grants/revokes) 를 idempotent SQL 로 적용.
### Applier / Migration 작성 의무
- **raw JSON 만 read** (Applier) — 시더 클래스 `Database\Seeders\*` 참조 금지 (fresh-load invariant)
- **idempotent** — `Schema::hasColumn` / `where->exists()` 가드 동반
- **V-1 안전 강화** — `app(*Service|*Manager|*Repository::class)` 호출 금지 (audit `upgrade-step-data-snapshot` 가 error 로 차단)
- **버전 격리** — 다른 버전 namespace `App\Upgrades\Data\V{other}\*` 참조 금지
- **로컬 헬퍼 + Illuminate 파사드만 사용** — 신규 도입 클래스 / 미래 변경 가능 클래스 의존 회피
- **공용 헬퍼 사용 시 신중** — `FilePermissionHelper::copyDirectory` 처럼 V-1 안전 검증된 헬퍼만 허용 (audit 룰이 미허용 호출은 차단)
### 강제 메커니즘
| 시점 | 영역 | 메커니즘 | 동작 |
| --- | --- | --- | --- |
| 런타임 | 코어 | `CoreUpdateService::runUpgradeSteps()` 의 instance 검증 | 버전 ≥ beta.5 인데 `AbstractUpgradeStep` 미상속 → `CoreUpdateOperationException` throw → update 전체 중단 → 백업 복원 |
| 런타임 | 모듈 | `ModuleManager::runUpgradeSteps()` 내 `ExtensionUpgradeGuardHelper` 호출 | manifest g7_version 기반 since-version 판정 → 미상속 시 `RuntimeException` throw |
| 런타임 | 플러그인 | `PluginManager::runUpgradeSteps()` 내 `ExtensionUpgradeGuardHelper` 호출 | 동일 패턴 (식별자만 다름) |
| PR 시점 | 코어 + 번들 모듈/플러그인 | audit rule `upgrade-step-data-snapshot` (severity: error) | namespace 불일치 / Seeders use / app() 호출 / 다른 버전 참조 / 공용 디렉토리 사용 자동 차단. 확장 경로는 manifest g7_version 기반 since-version 으로 검사 범위 결정 |
면제: `// audit:allow upgrade-step-data-snapshot reason: ...` 인라인 주석. legacy 미들 케이스 또는 임시 우회 시.
### 확장간 namespace 격리
`DataSnapshot::versionedNamespace($context, $sourceLocation)` 가 step 파일/data 디렉토리 경로의 `modules|plugins` 마커로 코어/확장을 분기하여 namespace 를 결정. 결과:
- 두 다른 모듈 (`vendor-foo` / `vendor-bar`) 이 동일 step 버전(예: `1.0.0`) 을 가져도 namespace 가 각자 격리:
- `App\Upgrades\Data\Ext\Modules\VendorFoo\V1_0_0\Migrations\Shared`
- `App\Upgrades\Data\Ext\Modules\VendorBar\V1_0_0\Migrations\Shared`
- `require_once` 가 두 파일을 로드해도 별개 FQCN 이라 PHP compile-time fatal ("Cannot declare class ...") 없음.
- 모듈 vs 플러그인 vs 코어 namespace 도 서로 격리.
회귀 안전망:
- `tests/Unit/Extension/Upgrade/DataSnapshotTest::test_versionedNamespace_different_extensions_same_step_version_produce_different_namespaces` — 정적 검증
- `tests/Feature/Upgrades/ExtensionAbstractUpgradeStepFullFlowTest::test_two_extensions_with_same_step_version_isolated_by_namespace` — 실제 실행 회귀 검증 (두 확장 fixture 의 같은 step 버전 + 같은 클래스명 → 양쪽 모두 자기 코드 실행 확인)
### dogfood — 7.0.0-beta.5
본 규약의 첫 사례:
- `upgrades/Upgrade_7_0_0_beta_5.php` — `extends AbstractUpgradeStep` 만 선언, 본문 비어있음
- `upgrades/data/7.0.0-beta.5/manifest.json` — `permissions` kind 1건
- `upgrades/data/7.0.0-beta.5/permissions.delta.json` — IDV 권한 식별자 rename 2건
- `upgrades/data/7.0.0-beta.5/appliers/PermissionsApplier.php` — added/removed/renamed (부재 경로) 적용기
- `upgrades/data/7.0.0-beta.5/migrations/` — 5종:
1. `01_RecoverActiveExtensionDirs.php` — #347 회귀 후속: 4개 도메인 활성 디렉토리 복구
2. `02_RecoverPendingStubFiles.php` — #347 회귀 후속: _pending stub 재생성
3. `03_VerifyBundledLangPacksFallback.php` — #347 회귀 후속: lang-packs/_bundled fallback
4. `04_IdentityPermissionPivotMerge.php` — IDV 권한 rename 충돌 경로 피벗 병합
5. `05_RecoverPublicStorageSymlink.php` — public/storage symlink 복구
beta.4 까지 출시본의 박제된 핫픽스 모든 동작이 본 격리 구조로 100% 보존되며, 미래 버전이 이 디렉토리를 *수정하지 않는 한* (수정은 audit error) beta.1 → beta.7 같은 멀티 점프에서도 동일한 결과를 보장한다.
---
## 관련 문서
- [코어 업데이트 시스템](../backend/core-update-system.md)
@@ -4,6 +4,20 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.2] - 2026-05-12
### Added
- 본인인증 활동 로그 액션 라벨 일본어 번역 추가 (`verify`, `verify_failed`)
### Changed
- 본인인증 관리자 권한 시드를 코어의 조회/수정 분리 정책에 맞춰 갱신
- `core.admin.identity.manage` → `core.admin.identity.providers.update`
- `core.admin.identity.policies.manage` → `core.admin.identity.policies.update`
- `core.admin.identity.providers.read`, `core.admin.identity.policies.read` 신설
- 코어 최소 요구 버전을 `>=7.0.0-beta.5` 로 상향 (신 권한 키가 코어 beta.5 시드에 의존)
## [1.0.0-beta.1] - 2026-05-11
### Added
@@ -29,6 +29,8 @@ return [
'account_locked' => 'アカウントロック',
'request' => 'リクエスト',
'expired' => '期限切れ',
'verify' => '本人認証',
'verify_failed' => '本人認証失敗',
'export' => 'エクスポート',
'import' => 'インポート',
'index' => 'リスト表示',
@@ -12,7 +12,7 @@
"en": "G7 core Japanese language pack (bundled)",
"ja": "G7 コア 日本語 言語パック(バンドル)"
},
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"license": "MIT",
"scope": "core",
"target_identifier": null,
@@ -20,7 +20,7 @@
"locale_name": "Japanese",
"locale_native_name": "日本語",
"text_direction": "ltr",
"g7_version": ">=7.0.0-beta.4",
"g7_version": ">=7.0.0-beta.5",
"requires": {
"target_version": null,
"depends_on_core_locale": false
@@ -195,12 +195,20 @@
"name": "本人確認管理 (管理者)",
"description": "管理者用IDVプロバイダ/ポリシー/ログ管理権限"
},
"core.admin.identity.manage": {
"name": "プロバイダ設定",
"description": "本人確認プロバイダ設定を管理できます。"
"core.admin.identity.providers.read": {
"name": "プロバイダ設定閲覧",
"description": "本人確認プロバイダ設定を閲覧できます。"
},
"core.admin.identity.policies.manage": {
"name": "ポリシー管理",
"core.admin.identity.providers.update": {
"name": "プロバイダ設定編集",
"description": "本人確認プロバイダ設定を編集できます。"
},
"core.admin.identity.policies.read": {
"name": "ポリシー閲覧",
"description": "本人確認ポリシー(ルート/フック別)を閲覧できます。"
},
"core.admin.identity.policies.update": {
"name": "ポリシー編集",
"description": "本人確認ポリシー(ルート/フック別)をCRUD できます。"
},
"core.admin.identity.logs.read": {
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.2] - 2026-05-12
### Added
- 위시리스트 제거 활동 로그 액션 라벨 일본어 번역 추가 (`remove`)
## [1.0.0-beta.1] - 2026-05-11
### Added
@@ -27,6 +27,7 @@ return [
'partial_cancel' => '部分キャンセル',
'payment_complete' => '決済完了',
'payment_failed' => '決済失敗',
'remove' => '削除',
'reorder' => '順序変更',
'restore' => '復元',
'send_email' => 'メール送信',
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
},
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-ecommerce",
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.2] - 2026-05-12
### Added
- 페이지 발행/발행 취소 활동 로그 액션 라벨 일본어 번역 추가 (`publish`, `unpublish`)
## [1.0.0-beta.1] - 2026-05-11
### Added
@@ -5,7 +5,9 @@ return [
'action' => [
'create' => '作成',
'delete' => '削除',
'publish' => '公開',
'restore' => '復元',
'unpublish' => '公開キャンセル',
'update' => '編集',
'upload' => 'アップロード',
],
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-page) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-page) 日本語 言語パック(バンドル)"
},
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-page",
+2
View File
@@ -36,6 +36,8 @@ return [
'account_locked' => 'Account locked',
'request' => 'Request',
'expired' => 'Expired',
'verify' => 'Identity verified',
'verify_failed' => 'Identity verification failed',
'export' => 'Export',
'import' => 'Import',
'index' => 'List Viewed',
+2
View File
@@ -36,6 +36,8 @@ return [
'account_locked' => '계정 잠금',
'request' => '요청',
'expired' => '만료',
'verify' => '본인인증',
'verify_failed' => '본인인증 실패',
'export' => '내보내기',
'import' => '가져오기',
'index' => '목록 조회',
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.5] - 2026-05-12
### Added
- 게시판 환경설정의 본인인증 정책 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자는 "정책 추가" / "수정" / "삭제" / "활성 토글" 버튼이 자동 비활성화
## [1.0.0-beta.4] - 2026-05-11
### Added
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-board",
"description": "Board module for Gnuboard7",
"type": "library",
"version": "1.0.0-beta.4",
"version": "1.0.0-beta.5",
"license": "MIT",
"autoload": {
"psr-4": {
+2 -2
View File
@@ -5,13 +5,13 @@
"ko": "게시판",
"en": "Board"
},
"version": "1.0.0-beta.4",
"version": "1.0.0-beta.5",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
"en": "Module for board management"
},
"g7_version": ">=7.0.0-beta.4",
"g7_version": ">=7.0.0-beta.5",
"dependencies": {
"modules": {},
"plugins": {}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-board",
"version": "1.0.0-beta.4",
"version": "1.0.0-beta.5",
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -51,7 +51,8 @@
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 text-sm font-medium bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors whitespace-nowrap"
"className": "px-4 py-2 text-sm font-medium bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 text-white rounded-lg transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600 dark:disabled:hover:bg-indigo-500",
"disabled": "{{boardIdentityPolicies?.data?.abilities?.can_create !== true}}"
},
"actions": [
{
@@ -545,7 +546,8 @@
"name": "Toggle",
"props": {
"checked": "{{Boolean(policy.enabled)}}",
"size": "sm"
"size": "sm",
"disabled": "{{policy.abilities?.can_update !== true}}"
},
"actions": [
{
@@ -633,7 +635,8 @@
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors"
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600",
"disabled": "{{policy.abilities?.can_update !== true}}"
},
"text": "$t:common.edit",
"actions": [
@@ -681,7 +684,7 @@
{
"type": "basic",
"name": "Button",
"if": "{{policy.source_type === 'admin'}}",
"if": "{{policy.source_type === 'admin' && policy.abilities?.can_delete === true}}",
"props": {
"type": "button",
"className": "ml-2 text-sm px-3 py-1.5 rounded-md border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
@@ -859,7 +862,8 @@
"name": "Toggle",
"props": {
"checked": "{{Boolean(policy.enabled)}}",
"size": "sm"
"size": "sm",
"disabled": "{{policy.abilities?.can_update !== true}}"
},
"actions": [
{
@@ -930,7 +934,8 @@
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors"
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600",
"disabled": "{{policy.abilities?.can_update !== true}}"
},
"text": "$t:common.edit",
"actions": [
@@ -978,7 +983,7 @@
{
"type": "basic",
"name": "Button",
"if": "{{policy.source_type === 'admin'}}",
"if": "{{policy.source_type === 'admin' && policy.abilities?.can_delete === true}}",
"props": {
"type": "button",
"className": "text-sm px-3 py-1.5 rounded-md border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.4] - 2026-05-12
### Added
- 이커머스 환경설정의 본인인증 정책 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자는 "정책 추가" / "수정" / "삭제" / "활성 토글" 버튼이 자동 비활성화
### Fixed
- 위시리스트 활동 로그의 "액션" 열에 `wishlist.remove` 가 번역되지 않은 키 그대로 노출되던 문제 수정
## [1.0.0-beta.3] - 2026-05-11
### Added
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-ecommerce",
"description": "Ecommerce module for Gnuboard7",
"type": "library",
"version": "1.0.0-beta.3",
"version": "1.0.0-beta.4",
"license": "MIT",
"autoload": {
"psr-4": {
@@ -5,13 +5,13 @@
"ko": "이커머스",
"en": "Ecommerce"
},
"version": "1.0.0-beta.3",
"version": "1.0.0-beta.4",
"license": "MIT",
"description": {
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
"en": "Gnuboard7 Ecommerce Module - Product, Order, Payment Management"
},
"g7_version": ">=7.0.0-beta.4",
"g7_version": ">=7.0.0-beta.5",
"dependencies": {
"modules": {},
"plugins": {}
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-ecommerce",
"version": "1.0.0-beta.3",
"version": "1.0.0-beta.4",
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -173,7 +173,7 @@
"if": false,
"type": "basic",
"name": "Button",
"comment": "D-070 (PO 지시: 숨김 처리)",
"comment": "D-070 (요청 사항: 숨김 처리)",
"props": {
"type": "button",
"className": "btn btn-outline btn-sm"
@@ -51,7 +51,8 @@
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 text-sm font-medium bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors whitespace-nowrap"
"className": "px-4 py-2 text-sm font-medium bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 text-white rounded-lg transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600 dark:disabled:hover:bg-indigo-500",
"disabled": "{{ecommerceIdentityPolicies?.data?.abilities?.can_create !== true}}"
},
"actions": [
{
@@ -545,7 +546,8 @@
"name": "Toggle",
"props": {
"checked": "{{Boolean(policy.enabled)}}",
"size": "sm"
"size": "sm",
"disabled": "{{policy.abilities?.can_update !== true}}"
},
"actions": [
{
@@ -633,7 +635,8 @@
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors"
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600",
"disabled": "{{policy.abilities?.can_update !== true}}"
},
"text": "$t:common.edit",
"actions": [
@@ -681,7 +684,7 @@
{
"type": "basic",
"name": "Button",
"if": "{{policy.source_type === 'admin'}}",
"if": "{{policy.source_type === 'admin' && policy.abilities?.can_delete === true}}",
"props": {
"type": "button",
"className": "ml-2 text-sm px-3 py-1.5 rounded-md border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
@@ -859,7 +862,8 @@
"name": "Toggle",
"props": {
"checked": "{{Boolean(policy.enabled)}}",
"size": "sm"
"size": "sm",
"disabled": "{{policy.abilities?.can_update !== true}}"
},
"actions": [
{
@@ -930,7 +934,8 @@
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors"
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600",
"disabled": "{{policy.abilities?.can_update !== true}}"
},
"text": "$t:common.edit",
"actions": [
@@ -978,7 +983,7 @@
{
"type": "basic",
"name": "Button",
"if": "{{policy.source_type === 'admin'}}",
"if": "{{policy.source_type === 'admin' && policy.abilities?.can_delete === true}}",
"props": {
"type": "button",
"className": "text-sm px-3 py-1.5 rounded-md border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
@@ -28,6 +28,7 @@ return [
'partial_cancel' => 'Partially Cancelled',
'payment_complete' => 'Payment Completed',
'payment_failed' => 'Payment Failed',
'remove' => 'Removed',
'reorder' => 'Reordered',
'restore' => 'Restored',
'send_email' => 'Email Sent',
@@ -29,6 +29,7 @@ return [
'partial_cancel' => '부분 취소',
'payment_complete' => '결제 완료',
'payment_failed' => '결제 실패',
'remove' => '제거',
'reorder' => '순서 변경',
'restore' => '복원',
'send_email' => '이메일 발송',
@@ -32,6 +32,20 @@ class EcommerceLanguagePackSeederTriggerTest extends ModuleTestCase
protected function setUp(): void
{
parent::setUp();
// 본 테스트는 활성 hook 체인이 module:seed 를 호출하는 경로를 검증한다.
// SeedModuleCommand 는 base_path('modules/{id}/database/seeders') 활성 디렉토리를
// is_dir() 로 확인하므로, 활성 디렉토리가 부재한 환경에서는 시더가 silent skip 된다.
// 활성 디렉토리가 없으면 본 테스트의 production-like 시나리오를 재현할 수 없으므로 skip.
$activeModuleSeederDir = base_path('modules/sirsoft-ecommerce/database/seeders');
if (! is_dir($activeModuleSeederDir)) {
$this->markTestSkipped(
'sirsoft-ecommerce 모듈이 활성 디렉토리에 설치되지 않은 환경 — '
.'본 테스트는 module:seed → entity 시더 → ja 자동 동기 production 경로를 검증하므로 '
.'php artisan module:install sirsoft-ecommerce 후 재실행 (또는 CI 환경에서 자동 통과)'
);
}
// module:seed 가 outer transaction 외부에서 commit 한 데이터 정리
DB::table('language_packs')
->where('target_identifier', 'sirsoft-ecommerce')
@@ -15,6 +15,7 @@
### Fixed
- 키워드 검색 결과의 전체 건수가 발행된 모든 페이지 수로 부풀려지던 문제 수정
- 페이지 발행/발행 취소 활동 로그의 "액션" 열에 `page.publish` / `page.unpublish` 가 번역되지 않은 키 그대로 노출되던 문제 수정
## [1.0.0-beta.2] - 2026-04-20
@@ -6,7 +6,9 @@ return [
'action' => [
'create' => 'Created',
'delete' => 'Deleted',
'publish' => 'Published',
'restore' => 'Restored',
'unpublish' => 'Unpublished',
'update' => 'Updated',
'upload' => 'Uploaded',
],
@@ -6,7 +6,9 @@ return [
'action' => [
'create' => '생성',
'delete' => '삭제',
'publish' => '발행',
'restore' => '복원',
'unpublish' => '발행 취소',
'update' => '수정',
'upload' => '업로드',
],
@@ -865,6 +865,9 @@ class ValidationApi
$output = [];
$returnCode = -1;
// root/super user 환경 + 비대화형 컨텍스트에서 composer interactive 경고로 인한
// 비정상 종료를 차단 (Synology DSM 등 PHP-FPM root 실행 환경 대응)
applyInstallerComposerEnvVars();
exec($command, $output, $returnCode);
if ($returnCode !== 0) {
+2 -2
View File
@@ -988,7 +988,7 @@
if (result.success) {
// Write DB 기존 테이블 감지: 인라인 상세 카드로 표시 (이슈 #244 대응)
// PO 요구: 모달 대신 페이지 본문에 즉시 노출, 백업 동의 체크박스로
// 요구사항: 모달 대신 페이지 본문에 즉시 노출, 백업 동의 체크박스로
// 다음 단계 진행 여부 제어. 실제 삭제는 Step 5의 db_cleanup task에서 수행.
let existingCardHtml = '';
if (type === 'write' && result.existing_tables && result.existing_tables.has_tables) {
@@ -3960,7 +3960,7 @@
});
} else {
// 처음 시작하는 경우 — 필수 파일 체크 후 "설치 시작" 버튼 표시 (자동 시작 금지)
// PO가 모드(SSE/폴링)를 선택한 뒤 명시적으로 시작할 수 있도록 대기
// 운영자가 모드(SSE/폴링)를 선택한 뒤 명시적으로 시작할 수 있도록 대기
const filesReady = await checkFilesBeforeInstall();
if (filesReady) {
showInstallationStartSection();
+34 -1
View File
@@ -157,6 +157,39 @@ function checkDatabasePrivileges(PDO $pdo, string $database, bool $isReadDb = fa
}
}
/**
* 현재 PHP 프로세스에 composer root/non-interactive 환경변수를 적용합니다.
*
* exec() 자식 프로세스는 부모의 환경변수를 상속하므로 putenv() 시점에 설정해두면
* 후속 composer 자식 프로세스가 root 환경에서도 정상 동작합니다.
*
* Synology DSM 등 PHP-FPM root 실행 환경 + 비대화형 컨텍스트 대응.
*/
if (!function_exists('applyInstallerComposerEnvVars')) {
function applyInstallerComposerEnvVars(): void
{
putenv('COMPOSER_ALLOW_SUPERUSER=1');
putenv('COMPOSER_NO_INTERACTION=1');
}
}
/**
* proc_open() 5번째 인자에 병합할 composer 환경변수 배열을 반환합니다.
*
* 호출자는 기존 $env 와 array_merge 하여 사용합니다.
*
* @return array<string, string>
*/
if (!function_exists('buildInstallerComposerEnv')) {
function buildInstallerComposerEnv(): array
{
return [
'COMPOSER_ALLOW_SUPERUSER' => '1',
'COMPOSER_NO_INTERACTION' => '1',
];
}
}
/**
* 인스톨러 에러 로깅
*
@@ -1201,7 +1234,7 @@ function checkExistingTables(PDO $pdo, string $database, string $tablePrefix = '
];
}
// G7 핵심 시그니처 테이블 — PO 결정: 4개 모두 일치 시 "G7 설치됨"으로 판단
// G7 핵심 시그니처 테이블 — 정책 결정: 4개 모두 일치 시 "G7 설치됨"으로 판단
// 테이블 prefix를 적용하여 비교 (예: prefix='g7_'이면 'g7_users', 'g7_migrations' 등)
$coreSignatures = ['users', 'migrations', 'roles', 'permissions'];
$g7Signatures = array_map(fn ($t) => $tablePrefix . $t, $coreSignatures);
+4
View File
@@ -268,6 +268,7 @@ if (!function_exists('checkComposerSSE')) {
}
putenv('COMPOSER_HOME=' . $composerHome);
putenv('HOME=' . $composerHome);
applyInstallerComposerEnvVars();
$output = [];
$returnCode = 0;
@@ -469,6 +470,9 @@ if (!function_exists('installComposerDependenciesSSE')) {
$env['COMPOSER_HOME'] = $composerHome;
$env['HOME'] = $composerHome;
// root/super user 환경 + 비대화형 컨텍스트 대응 (Synology DSM 등 PHP-FPM root 실행)
$env = array_merge($env, buildInstallerComposerEnv());
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
if (!isset($env['TEMP']) || !is_dir($env['TEMP']) || !is_writable($env['TEMP'])) {
$tempDir = BASE_PATH . '/storage/temp';
@@ -98,7 +98,7 @@
- IdentityGuardInterceptor — HTTP 428 `identity_verification_required` 응답을 감지해 모달 launcher 호출 후 return_request 자동 재실행. launcher 는 S8 공통 모달 레이아웃이 제공. ActionDispatcher.handleApiCall 응답 후처리 한 곳에 정적 메서드로 직접 위임 — 별도 디스패처 인프라 없이 모든 apiCall 의 choke point 효과 (IdentityGuardInterceptor)
<!-- ⚠️ 버전 재번호 부여 검토 필요 (PO 결정 대기): 아래 v1.43.1 은 시간상 v1.45.0/v1.44.0 보다 늦지만(2026-04-25), develop 브랜치에서 별도 발행되어 버전 번호 모순 발생. v1.45.1 또는 v1.46.0 으로 재번호 검토 필요. -->
<!-- ⚠️ 버전 재번호 부여 검토 필요 (정책 결정 대기): 아래 v1.43.1 은 시간상 v1.45.0/v1.44.0 보다 늦지만(2026-04-25), develop 브랜치에서 별도 발행되어 버전 번호 모순 발생. v1.45.1 또는 v1.46.0 으로 재번호 검토 필요. -->
## [engine-v1.43.1] - 2026-04-25
@@ -1313,7 +1313,7 @@ describe('트러블슈팅 회귀 테스트 - dataKey 자동 바인딩', () => {
// 두 저장소의 분리 구조 자체를 결함으로 보고 통합(SSOT)하려던 시도를 2회 진행했으나
// 모두 다른 회귀를 유발하여 원복됨 (2026-04-22 이력 문서 참조).
//
// 본 describe 는 PO 의 새 방향 (2026-04-26) — "두 저장소 분리를 인정한 상태의 동작 명세" —
// 본 describe 는 운영자의 새 방향 (2026-04-26) — "두 저장소 분리를 인정한 상태의 동작 명세" —
// 에 따라 각 저장소가 단독/혼합 사용 시 보이는 현재 동작을 단언한다.
//
// 의도된 분리 책임:
@@ -4487,7 +4487,7 @@ describe('[사례 12] _localInit + 자식 useEffect setState가 API 데이터를
* - A-4: CKEditor5 syncToForm에 selfManaged:true 명시로 자동 승격 제외 (성능 보존)
*
* 본 describe 블록은 엔진 내부 신규 구조의 contract test.
* 이슈 #282 실제 재현(게시판 WYSIWYG 저장 422)은 PO 브라우저 검증으로 증명.
* 이슈 #282 실제 재현(게시판 WYSIWYG 저장 422)은 브라우저 검증으로 증명.
*/
describe('[사례 32] 이슈 #282 이중 저장소 동기화 + 자동 승격 (engine-v1.43.0)', () => {
/**
@@ -186,7 +186,7 @@ export interface InjectedComponent {
overlayFile?: string;
};
/** 편집 가능 여부 (PO 결정: 항상 true) */
/** 편집 가능 여부 (정책 결정: 항상 true) */
isEditable: boolean;
/** 주입 위치 */
+8 -3
View File
@@ -297,7 +297,7 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin'
// IDV 관리자 라우트 (identity 정책/로그/프로바이더)
Route::prefix('identity')->group(function () {
Route::get('providers', [\App\Http\Controllers\Api\Admin\Identity\AdminIdentityProviderController::class, 'index'])
->middleware('permission:admin,core.admin.identity.manage')
->middleware('permission:admin,core.admin.identity.providers.read')
->name('api.admin.identity.providers.index');
Route::get('logs', [\App\Http\Controllers\Api\Admin\Identity\AdminIdentityLogController::class, 'index'])
@@ -308,16 +308,21 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin'
->middleware('permission:admin,core.admin.identity.logs.purge')
->name('api.admin.identity.logs.purge');
Route::prefix('policies')->middleware('permission:admin,core.admin.identity.policies.manage')->group(function () {
Route::prefix('policies')->group(function () {
Route::get('/', [\App\Http\Controllers\Api\Admin\Identity\AdminIdentityPolicyController::class, 'index'])
->middleware('permission:admin,core.admin.identity.policies.read')
->name('api.admin.identity.policies.index');
Route::post('/', [\App\Http\Controllers\Api\Admin\Identity\AdminIdentityPolicyController::class, 'store'])
->middleware('permission:admin,core.admin.identity.policies.update')
->name('api.admin.identity.policies.store');
Route::put('{id}', [\App\Http\Controllers\Api\Admin\Identity\AdminIdentityPolicyController::class, 'update'])
->middleware('permission:admin,core.admin.identity.policies.update')
->name('api.admin.identity.policies.update');
Route::delete('{id}', [\App\Http\Controllers\Api\Admin\Identity\AdminIdentityPolicyController::class, 'destroy'])
->middleware('permission:admin,core.admin.identity.policies.update')
->name('api.admin.identity.policies.destroy');
Route::post('{id}/reset-field', [\App\Http\Controllers\Api\Admin\Identity\AdminIdentityPolicyController::class, 'resetField'])
->middleware('permission:admin,core.admin.identity.policies.update')
->name('api.admin.identity.policies.reset-field');
});
@@ -434,7 +439,7 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin'
Route::post('install-from-bundled', [AdminLanguagePackController::class, 'installFromBundled'])
->middleware('permission:admin,core.language_packs.install')
->name('api.admin.language-packs.install-from-bundled');
// PO #7: 호스트 확장 재활성화 시 cascade 비활성화됐던 언어팩 일괄 활성화
// 요구사항 #7: 호스트 확장 재활성화 시 cascade 비활성화됐던 언어팩 일괄 활성화
Route::post('bulk-activate', [AdminLanguagePackController::class, 'bulkActivate'])
->middleware('permission:admin,core.language_packs.manage')
->name('api.admin.language-packs.bulk-activate');
@@ -4,6 +4,19 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.5] - 2026-05-12
### Added
- 환경설정 본인인증 정책 탭에 권한 기반 버튼 비활성화 적용 — "정책 추가" / "수정" / "삭제" / "활성 토글" 버튼이 운영자의 수정 권한 보유 여부에 따라 자동 비활성화
- 환경설정 본인인증 메시지 탭 "정의 추가" 버튼에도 권한 기반 비활성화 적용
- 본인인증 권한이 조회/수정으로 분리됨에 따라, 조회 권한만 있는 운영자도 환경설정 본인인증 화면에 정상 진입 가능 (코어 권한 변경 연동)
### Fixed
- 알림 센터의 미확인 알림 카운트 뱃지·알림 행의 unread 점·"읽지 않은 알림만" 체크박스가 다크 모드에서 색상이 부정합하게 노출되던 문제 수정
- 언어팩 설치 / 모듈·플러그인·템플릿 재활성화 확인 모달의 액션 버튼이 다크 모드에서 호버 색상이 라이트 모드와 동일하게 보이던 문제 수정
## [1.0.0-beta.4] - 2026-05-11
### Added
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
{
"meta": {
"is_partial": true,
"description": "번들 언어팩 설치 확인 모달 — 모듈/플러그인의 install 모달과 동일한 패턴. 행의 '설치' 버튼이 즉시 apiCall 하지 않고 본 모달을 열어 PO 가 확인 후 진행."
"description": "번들 언어팩 설치 확인 모달 — 모듈/플러그인의 install 모달과 동일한 패턴. 행의 '설치' 버튼이 즉시 apiCall 하지 않고 본 모달을 열어 운영자가 확인 후 진행."
},
"id": "language_pack_install_bundled_modal",
"type": "composite",
@@ -133,7 +133,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed",
"className": "flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isLanguagePackInstalling || _global.selectedBundledLanguagePack?.install_blocked_reason || _global.selectedBundledLanguagePack?.abilities?.can_install !== true}}"
},
"actions": [
@@ -1,7 +1,7 @@
{
"meta": {
"is_partial": true,
"description": "모듈 재활성화 후 cascade 비활성화됐던 언어팩 일괄 활성화 확인 모달 (PO #7)"
"description": "모듈 재활성화 후 cascade 비활성화됐던 언어팩 일괄 활성화 확인 모달 (요구사항 #7)"
},
"id": "module_reactivate_language_packs_modal",
"type": "composite",
@@ -118,7 +118,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isBulkActivatingLanguagePacks}}"
},
"actions": [
@@ -1,7 +1,7 @@
{
"meta": {
"is_partial": true,
"description": "플러그인 재활성화 후 cascade 비활성화됐던 언어팩 일괄 활성화 확인 모달 (PO #7)"
"description": "플러그인 재활성화 후 cascade 비활성화됐던 언어팩 일괄 활성화 확인 모달 (요구사항 #7)"
},
"id": "plugin_reactivate_language_packs_modal",
"type": "composite",
@@ -118,7 +118,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isBulkActivatingLanguagePacks}}"
},
"actions": [
@@ -45,7 +45,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "{{(_local.activeIdentityMessageChannel ?? 'mail') === 'mail' ? 'border-b-2 border-indigo-500 text-indigo-600 dark:text-indigo-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2.5 text-sm font-medium whitespace-nowrap flex items-center gap-2"
"className": "{{(_local.activeIdentityMessageChannel ?? 'mail') === 'mail' ? 'border-b-2 border-indigo-500 dark:border-indigo-400 text-indigo-600 dark:text-indigo-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2.5 text-sm font-medium whitespace-nowrap flex items-center gap-2"
},
"children": [
{
@@ -140,7 +140,8 @@
"name": "Button",
"props": {
"type": "button",
"className": "ml-2 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 rounded-md transition-colors"
"className": "ml-2 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600",
"disabled": "{{identityMessages?.data?.abilities?.can_create !== true}}"
},
"children": [
{
@@ -51,7 +51,8 @@
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 text-sm font-medium bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors whitespace-nowrap"
"className": "px-4 py-2 text-sm font-medium bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 text-white rounded-lg transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600 dark:disabled:hover:bg-indigo-500",
"disabled": "{{policies?.data?.abilities?.can_create !== true}}"
},
"actions": [
{
@@ -546,7 +547,8 @@
"name": "Toggle",
"props": {
"checked": "{{Boolean(policy.enabled)}}",
"size": "sm"
"size": "sm",
"disabled": "{{policy.abilities?.can_update !== true}}"
},
"actions": [
{
@@ -634,7 +636,8 @@
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors"
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600",
"disabled": "{{policy.abilities?.can_update !== true}}"
},
"text": "$t:common.edit",
"actions": [
@@ -682,7 +685,7 @@
{
"type": "basic",
"name": "Button",
"if": "{{policy.source_type === 'admin'}}",
"if": "{{policy.source_type === 'admin' && policy.abilities?.can_delete === true}}",
"props": {
"type": "button",
"className": "ml-2 text-sm px-3 py-1.5 rounded-md border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
@@ -860,7 +863,8 @@
"name": "Toggle",
"props": {
"checked": "{{Boolean(policy.enabled)}}",
"size": "sm"
"size": "sm",
"disabled": "{{policy.abilities?.can_update !== true}}"
},
"actions": [
{
@@ -931,7 +935,8 @@
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors"
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600",
"disabled": "{{policy.abilities?.can_update !== true}}"
},
"text": "$t:common.edit",
"actions": [
@@ -978,7 +983,7 @@
{
"type": "basic",
"name": "Button",
"if": "{{policy.source_type === 'admin'}}",
"if": "{{policy.source_type === 'admin' && policy.abilities?.can_delete === true}}",
"props": {
"type": "button",
"className": "text-sm px-3 py-1.5 rounded-md border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
@@ -1,7 +1,7 @@
{
"meta": {
"is_partial": true,
"description": "템플릿 재활성화 후 cascade 비활성화됐던 언어팩 일괄 활성화 확인 모달 (PO #7)"
"description": "템플릿 재활성화 후 cascade 비활성화됐던 언어팩 일괄 활성화 확인 모달 (요구사항 #7)"
},
"id": "template_reactivate_language_packs_modal",
"type": "composite",
@@ -118,7 +118,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isBulkActivatingLanguagePacks}}"
},
"actions": [
@@ -1,6 +1,6 @@
{
"name": "sirsoft-admin_basic",
"version": "1.0.0-beta.4",
"version": "1.0.0-beta.5",
"description": "Gnuboard7 Basic Admin Template Components",
"type": "module",
"main": "dist/components.js",
@@ -285,7 +285,7 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
>
<Icon name={IconName.Bell} className="w-5 h-5 text-gray-600 dark:text-gray-400" />
{displayCount > 0 && (
<Span className="absolute top-0 right-0 flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full">
<Span className="absolute top-0 right-0 flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 dark:bg-red-600 rounded-full">
{displayCount > 99 ? '99+' : displayCount}
</Span>
)}
@@ -310,7 +310,7 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
onUnreadOnlyToggle(e.target.checked);
}}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-400 focus:ring-blue-500 dark:focus:ring-blue-400 cursor-pointer"
/>
<Span className="text-xs text-gray-600 dark:text-gray-400">{unreadOnlyText}</Span>
</Div>
@@ -332,7 +332,7 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
<Button
onClick={() => {
// 드롭다운은 닫지 않음 — 확인 모달이 닫히면 다시 자연스럽게 노출되어야 하고,
// 사용자가 모달 취소 시에도 컨텍스트 유지 (PO 요구)
// 사용자가 모달 취소 시에도 컨텍스트 유지 (요구사항)
onDeleteAll();
}}
className="text-xs text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 hover:underline cursor-pointer"
@@ -398,7 +398,7 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
</Div>
<Div className="flex flex-col items-center gap-2 flex-shrink-0">
{isUnread && (
<Span className="w-2 h-2 bg-blue-500 rounded-full mt-1.5" />
<Span className="w-2 h-2 bg-blue-500 dark:bg-blue-400 rounded-full mt-1.5" />
)}
{onDelete && (
<Button
@@ -5,7 +5,7 @@
"ko": "Admin Basic",
"en": "Admin Basic"
},
"version": "1.0.0-beta.4",
"version": "1.0.0-beta.5",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 관리자 템플릿",
@@ -19,7 +19,7 @@
"url": "https://sirsoft.com"
},
"release_date": "2025-01-01",
"g7_version": ">=7.0.0-beta.4",
"g7_version": ">=7.0.0-beta.5",
"dependencies": {
"modules": {},
"plugins": {}
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.5] - 2026-05-12
### Fixed
- 알림 센터의 미확인 알림 카운트 뱃지·알림 행의 unread 점·"읽지 않은 알림만" 체크박스가 다크 모드에서 색상이 부정합하게 노출되던 문제 수정
## [1.0.0-beta.4] - 2026-05-11
### Added
File diff suppressed because one or more lines are too long

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