diff --git a/.env.example b/.env.example
index 2db6325b..74fb9072 100644
--- a/.env.example
+++ b/.env.example
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.3
+APP_VERSION=7.0.4
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/.env.testing.example b/.env.testing.example
index 7a637226..94e4f911 100644
--- a/.env.testing.example
+++ b/.env.testing.example
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.3
+APP_VERSION=7.0.4
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7dc3db24..00fa2103 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [7.0.4] - 2026-07-11
+
+### Fixed
+
+- 외부 라이브러리를 쓰는 확장의 버전을 올린 뒤 그 확장을 업데이트하면 "번들 파일의 무결성 검증에 실패했습니다"라는 오류로 업데이트가 막히던 문제를 수정했습니다. 오류를 풀려면 업데이트가 필요한데 그 업데이트 자체가 막혀 빠져나올 수 없는 상태였습니다. 아울러 확장 버전만 올린 경우에도 번들 재빌드가 필요함을 `--check` 점검이 올바르게 알려주도록 바로잡았습니다. (이미 배포된 확장을 내려받아 쓰는 환경에는 영향이 없으며, 확장을 직접 개발·수정하는 경우에 해당합니다.)
+- Windows 개발 환경에서 코어 의존성 번들을 다시 만들 때, 마지막 정리 단계에서 진행이 멈춘 채 끝나지 않던 문제를 수정했습니다. 이제 번들 빌드가 정상적으로 완료됩니다. 아울러 빌드가 중간에 실패하더라도 기존 번들 파일이 손상되지 않도록 새 결과가 완성된 뒤에만 교체하도록 개선했습니다. (확장을 직접 개발·수정하는 환경에 해당하며, 배포본을 내려받아 쓰는 환경에는 영향이 없습니다.)
+
## [7.0.3] - 2026-07-10
### Fixed
diff --git a/INSTALL.md b/INSTALL.md
index ad65f5a1..573b2221 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -246,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
-# (필요 시) mv g7-7.0.3 g7
+# (필요 시) mv g7-7.0.4 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
diff --git a/README.md b/README.md
index 0c1abe3e..906de834 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
-
+
diff --git a/app/Extension/Vendor/EnvironmentDetector.php b/app/Extension/Vendor/EnvironmentDetector.php
index 574d8abf..91c1402a 100644
--- a/app/Extension/Vendor/EnvironmentDetector.php
+++ b/app/Extension/Vendor/EnvironmentDetector.php
@@ -71,7 +71,7 @@ class EnvironmentDetector
* → PATH 검색 → composer.phar
*
* @param string|null $hint 바이너리 경로 힌트 (null 이면 config/ENV/PATH 순으로 자동 탐색)
- * @return string|null 발견된 composer 바이너리 경로. 없으면 null
+ * @return string|null 발견된 composer 바이너리 경로. 없으면 null
*/
public function findComposerBinary(?string $hint = null): ?string
{
@@ -120,7 +120,7 @@ class EnvironmentDetector
* proc_open 사용 가능 + composer 바이너리 발견 + `composer --version` 종료 코드 0.
*
* @param string|null $hint composer 바이너리 경로 힌트 (캐시 우회용)
- * @return bool composer 가 현재 환경에서 실행 가능한지 여부
+ * @return bool composer 가 현재 환경에서 실행 가능한지 여부
*/
public function canExecuteComposer(?string $hint = null): bool
{
@@ -178,7 +178,7 @@ class EnvironmentDetector
* 지정 경로에 쓰기 가능한지 확인.
*
* @param string $targetPath vendor 디렉토리 경로 (존재하지 않아도 부모 디렉토리 쓰기 권한 검사)
- * @return bool 부모 디렉토리가 존재하고 쓰기 가능한지 여부
+ * @return bool 부모 디렉토리가 존재하고 쓰기 가능한지 여부
*/
public function canWriteVendor(string $targetPath): bool
{
@@ -254,9 +254,15 @@ class EnvironmentDetector
/**
* composer 실행 명령 문자열 구성.
*
- * @param array $args
+ * 반환된 문자열은 `proc_open(..., ['bypass_shell' => true])` 와 함께 사용해야 한다.
+ * Windows 에서 `bypass_shell` 없이 실행하면 `cmd.exe` 가 따옴표로 시작하는 `.bat`
+ * 경로의 앞뒤 따옴표를 벗겨 첫 인자(`install` 등)를 실행 파일로 오인한다.
+ *
+ * @param string $binary composer 바이너리 경로 (findComposerBinary 반환값)
+ * @param array $args composer 하위 명령과 옵션 (예: ['install', '--no-dev'])
+ * @return string proc_open 에 넘길 실행 명령 문자열
*/
- private function buildComposerCommand(string $binary, array $args): string
+ public function buildComposerCommand(string $binary, array $args): string
{
$escaped = array_map('escapeshellarg', $args);
diff --git a/app/Extension/Vendor/VendorBundler.php b/app/Extension/Vendor/VendorBundler.php
index 7084a4bc..8a3f5b6e 100644
--- a/app/Extension/Vendor/VendorBundler.php
+++ b/app/Extension/Vendor/VendorBundler.php
@@ -82,7 +82,7 @@ class VendorBundler
* 코어는 소스 = 출력 경로가 base_path() 로 동일합니다.
*
* @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드
- * @return VendorBundleResult 번들 생성 결과 (zip 경로, manifest 경로, 패키지 수 등)
+ * @return VendorBundleResult 번들 생성 결과 (zip 경로, manifest 경로, 패키지 수 등)
*/
public function buildForCore(bool $force = false): VendorBundleResult
{
@@ -106,7 +106,7 @@ class VendorBundler
* @param string $type 확장 타입 ('module' 또는 'plugin')
* @param string $identifier 확장 식별자 (vendor-name 형식, 예: 'sirsoft-ecommerce')
* @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드
- * @return VendorBundleResult 번들 생성 결과 (활성 디렉토리 부재 시 skipped=true)
+ * @return VendorBundleResult 번들 생성 결과 (활성 디렉토리 부재 시 skipped=true)
*/
public function buildForExtension(string $type, string $identifier, bool $force = false): VendorBundleResult
{
@@ -164,7 +164,7 @@ class VendorBundler
*
* @param string $sourcePath composer.json/lock 을 읽을 경로 (활성 디렉토리)
* @param string|null $outputPath 번들 출력 경로 (null 이면 sourcePath 와 동일 — 코어 케이스)
- * @return bool 번들이 stale 한지 여부 (해시 불일치 또는 manifest 부재). 외부 의존성이 없으면 항상 false
+ * @return bool 번들이 stale 한지 여부 (해시 불일치 또는 manifest 부재). 외부 의존성이 없으면 항상 false
*/
public function isStale(string $sourcePath, ?string $outputPath = null): bool
{
@@ -187,16 +187,19 @@ class VendorBundler
return true;
}
- if (file_exists($composerJsonPath)) {
- $currentHash = $this->integrityChecker->computeFileHash($composerJsonPath);
+ // 해시 비교 대상은 manifest 가 놓인 출력 디렉토리 기준 ({@see build()} 참조).
+ // 소스만 검사하면 _bundled 의 composer.json 변경을 up-to-date 로 오보한다.
+ $hashJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json');
+ if ($hashJsonPath !== null) {
+ $currentHash = $this->integrityChecker->computeFileHash($hashJsonPath);
if (($manifest['composer_json_sha256'] ?? null) !== $currentHash) {
return true;
}
}
- $composerLockPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.lock';
- if (file_exists($composerLockPath)) {
- $currentHash = $this->integrityChecker->computeFileHash($composerLockPath);
+ $hashLockPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.lock');
+ if ($hashLockPath !== null) {
+ $currentHash = $this->integrityChecker->computeFileHash($hashLockPath);
if (($manifest['composer_lock_sha256'] ?? null) !== $currentHash) {
return true;
}
@@ -205,6 +208,35 @@ class VendorBundler
return false;
}
+ /**
+ * manifest 해시 비교 기준이 될 파일 경로를 결정합니다.
+ *
+ * manifest 와 zip 은 출력 디렉토리(_bundled)에 함께 저장되어 그대로 배포되며,
+ * 런타임 검증({@see VendorIntegrityChecker::verify()})은 zip 이 놓인 디렉토리
+ * (_bundled 또는 설치/업데이트 시 그 복사본인 _pending/staging)의 composer.json 을
+ * 대조합니다. 따라서 해시도 그 디렉토리를 기준으로 계산해야 생성자와 검증자의
+ * 기준이 일치합니다.
+ *
+ * 출력 디렉토리에 파일이 없으면 소스(활성 디렉토리)로 폴백합니다.
+ * 코어는 소스와 출력이 동일하므로 어느 쪽을 골라도 같은 경로가 됩니다.
+ *
+ * @param string $sourcePath composer.json/lock 과 vendor/ 를 읽는 경로 (활성 디렉토리)
+ * @param string $outputPath manifest 와 zip 을 쓰는 경로 (_bundled, 코어는 소스와 동일)
+ * @param string $filename 'composer.json' 또는 'composer.lock'
+ * @return string|null 해시 대상 파일의 절대 경로. 양쪽 모두 없으면 null
+ */
+ private function resolveHashTarget(string $sourcePath, string $outputPath, string $filename): ?string
+ {
+ $outputFile = $outputPath.DIRECTORY_SEPARATOR.$filename;
+ if (file_exists($outputFile)) {
+ return $outputFile;
+ }
+
+ $sourceFile = $sourcePath.DIRECTORY_SEPARATOR.$filename;
+
+ return file_exists($sourceFile) ? $sourceFile : null;
+ }
+
/**
* 빌드 실행.
*
@@ -258,20 +290,25 @@ class VendorBundler
);
}
- // 기존 파일 제거
- if (file_exists($zipPath)) {
- @unlink($zipPath);
- }
- if (file_exists($manifestPath)) {
- @unlink($manifestPath);
- }
-
// === 스테이징 빌드 ===
// 개발 머신의 vendor/ 를 직접 사용하지 않고, composer install --no-dev 를
// 스테이징 디렉토리에서 새로 실행하여 dev 의존성과 완전히 분리된 번들을 생성한다.
+ //
+ // 새 zip/manifest 는 임시 경로(.tmp)에 먼저 쓰고, 빌드가 성공한 뒤에만 원본을
+ // 교체한다. 기존 파일을 빌드 전에 삭제하면 composer install 실패 시 새 파일이
+ // 생성되지 못한 채 원본만 유실되어 이후 module:update 가 번들 부재로 실패한다.
$stagingDir = storage_path('app/vendor-bundle-staging/'.uniqid('build-', true));
File::ensureDirectoryExists($stagingDir, 0755);
+ $tmpZipPath = $zipPath.'.tmp';
+ $tmpManifestPath = $manifestPath.'.tmp';
+ if (file_exists($tmpZipPath)) {
+ @unlink($tmpZipPath);
+ }
+ if (file_exists($tmpManifestPath)) {
+ @unlink($tmpManifestPath);
+ }
+
try {
// 1. composer.json + composer.lock 을 스테이징으로 복사
File::copy($composerJsonPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.json');
@@ -292,8 +329,8 @@ class VendorBundler
);
}
- // 4. zip 생성 (EXCLUDE_PATTERNS 만 적용 — 화이트리스트 불필요)
- [$zipSize, $fileCount] = $this->writeZip($stagedVendor, $zipPath);
+ // 4. zip 생성 — 임시 경로에 먼저 쓴다 (EXCLUDE_PATTERNS 만 적용)
+ [$zipSize, $fileCount] = $this->writeZip($stagedVendor, $tmpZipPath);
// 5. manifest 작성 — 스테이징의 composer.lock 을 기준으로 패키지 목록 추출
$stagedLockPath = $stagingDir.DIRECTORY_SEPARATOR.'composer.lock';
@@ -301,27 +338,35 @@ class VendorBundler
? $this->collectPackages($stagedLockPath)
: [];
+ // manifest 의 해시는 **출력 디렉토리** 의 composer.json/lock 기준.
+ // manifest·zip·composer.json 이 함께 배포되는 곳이 출력 디렉토리이며,
+ // 런타임 검증도 그 디렉토리를 대조한다 ({@see resolveHashTarget()}).
+ $hashJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json');
+ $hashLockPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.lock');
+
$manifest = [
'schema_version' => self::SCHEMA_VERSION,
'generated_at' => date('c'),
'generator' => 'g7 vendor-bundle:build',
'target' => $target,
- // manifest 의 해시는 **소스** 의 composer.json/lock 기준 — 런타임에서
- // 소스 디렉토리(또는 번들과 함께 배포된) composer.json 과 비교하여
- // stale 여부를 감지한다.
- 'composer_json_sha256' => $this->integrityChecker->computeFileHash($composerJsonPath),
- 'composer_lock_sha256' => file_exists($sourceLockPath)
- ? $this->integrityChecker->computeFileHash($sourceLockPath)
+ 'composer_json_sha256' => $this->integrityChecker->computeFileHash($hashJsonPath ?? $composerJsonPath),
+ 'composer_lock_sha256' => $hashLockPath !== null
+ ? $this->integrityChecker->computeFileHash($hashLockPath)
: null,
- 'zip_sha256' => $this->integrityChecker->computeFileHash($zipPath),
+ 'zip_sha256' => $this->integrityChecker->computeFileHash($tmpZipPath),
'zip_size' => $zipSize,
'package_count' => count($packages),
- 'php_requirement' => $this->extractPhpRequirement($composerJsonPath),
+ 'php_requirement' => $this->extractPhpRequirement($hashJsonPath ?? $composerJsonPath),
'g7_version' => config('app.version'),
'packages' => $packages,
];
- File::put($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+ File::put($tmpManifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+
+ // 6. 원자적 교체 — 여기까지 성공했으므로 임시 파일을 최종 경로로 옮긴다.
+ // 이 지점 이전에 실패하면 기존 번들이 그대로 보존된다.
+ $this->promoteAtomic($tmpZipPath, $zipPath);
+ $this->promoteAtomic($tmpManifestPath, $manifestPath);
Log::info('Vendor 번들 빌드 완료', [
'target' => $target,
@@ -340,10 +385,42 @@ class VendorBundler
reason: 'built',
);
} finally {
- // 스테이징 정리 (실패/성공 무관)
+ // 스테이징 및 미승격 임시 파일 정리 (실패/성공 무관)
if (File::isDirectory($stagingDir)) {
File::deleteDirectory($stagingDir);
}
+ if (file_exists($tmpZipPath)) {
+ @unlink($tmpZipPath);
+ }
+ if (file_exists($tmpManifestPath)) {
+ @unlink($tmpManifestPath);
+ }
+ }
+ }
+
+ /**
+ * 임시 파일을 최종 경로로 원자적으로 교체합니다.
+ *
+ * Windows 에서 `rename()` 은 대상이 이미 존재하면 실패하므로, 기존 파일을 먼저
+ * 제거한 뒤 이동한다. 이 메서드는 빌드가 성공적으로 완료된 뒤에만 호출되므로,
+ * 여기서 기존 파일을 제거해도 (제거 후 이동 실패 확률이 극히 낮아) 안전하다.
+ *
+ * @param string $tmpPath 임시 파일 경로 (성공적으로 생성 완료된 상태)
+ * @param string $finalPath 최종 경로
+ *
+ * @throws VendorInstallException 이동 실패 시
+ */
+ private function promoteAtomic(string $tmpPath, string $finalPath): void
+ {
+ if (file_exists($finalPath)) {
+ @unlink($finalPath);
+ }
+
+ if (! @rename($tmpPath, $finalPath)) {
+ throw new VendorInstallException(
+ errorKey: 'bundle_build_promote_failed',
+ context: ['from' => $tmpPath, 'to' => $finalPath],
+ );
}
}
@@ -377,30 +454,37 @@ class VendorBundler
);
}
- $phpBinary = config('process.php_binary', 'php');
-
- if (str_contains($binary, ' ')) {
- $composerCmd = $binary;
- } elseif (str_ends_with(strtolower($binary), '.phar')) {
- $composerCmd = escapeshellarg($phpBinary).' '.escapeshellarg($binary);
- } else {
- $composerCmd = escapeshellarg($binary);
- }
-
- $command = $composerCmd.' install --no-dev --no-scripts --prefer-dist --no-interaction --no-progress 2>&1';
+ // 명령 조립과 실행 옵션은 canExecuteComposer 와 동일한 방식으로 통일한다.
+ // buildComposerCommand + bypass_shell:true 를 함께 써야 Windows 에서 .bat
+ // 경로가 cmd.exe 의 따옴표 처리로 깨지지 않는다.
+ $command = $detector->buildComposerCommand($binary, [
+ 'install', '--no-dev', '--no-scripts', '--prefer-dist', '--no-interaction', '--no-progress',
+ ]);
Log::info('vendor-bundle 빌드: composer install 시작', [
'target' => $target,
'staging' => $stagingDir,
]);
- $descriptors = [
- 0 => ['pipe', 'r'],
- 1 => ['pipe', 'w'],
- 2 => ['pipe', 'w'],
- ];
+ // stdout/stderr 는 파이프가 아닌 파일 descriptor 로 받는다.
+ // (Windows 회귀) composer 는 autoload dump 단계에서 임시 추출물을
+ // `rmdir /S /Q` async `cmd.exe` 손자 프로세스로 정리하는데, stdout/stderr 를
+ // 파이프로 열면 이 손자들이 부모 파이프 핸들을 상속받아 붙잡은 채 종료하지 않아
+ // 부모(php)의 read/proc_close 가 자식 완료 신호를 영영 받지 못하고 무한 대기한다.
+ // 파일 descriptor 는 손자에게 상속될 열린 파이프 핸들이 없어 이 교착을 원천 차단한다
+ // (실측: 파이프=행, 파일=140s 완주 EXIT 0 + autoload 정상 생성).
+ $outPath = $stagingDir.DIRECTORY_SEPARATOR.'_composer_stdout.log';
+ $errPath = $stagingDir.DIRECTORY_SEPARATOR.'_composer_stderr.log';
+ $descriptors = self::composerOutputDescriptors($outPath, $errPath);
- $process = @proc_open($command, $descriptors, $pipes, $stagingDir, EnvironmentDetector::buildComposerEnv());
+ $process = @proc_open(
+ $command,
+ $descriptors,
+ $pipes,
+ $stagingDir,
+ EnvironmentDetector::buildComposerEnv(),
+ ['bypass_shell' => true],
+ );
if (! is_resource($process)) {
throw new VendorInstallException(
@@ -409,12 +493,17 @@ class VendorBundler
);
}
- fclose($pipes[0]);
- $output = stream_get_contents($pipes[1]);
- fclose($pipes[1]);
- fclose($pipes[2]);
+ // stdin(파이프)만 즉시 닫고, stdout/stderr 는 파일이므로 배수 불필요 —
+ // proc_close 가 자식 종료까지 블록 대기한다.
+ if (isset($pipes[0]) && is_resource($pipes[0])) {
+ fclose($pipes[0]);
+ }
$exitCode = proc_close($process);
+ $stdout = is_file($outPath) ? (string) @file_get_contents($outPath) : '';
+ $stderr = is_file($errPath) ? (string) @file_get_contents($errPath) : '';
+ $output = trim($stdout.\PHP_EOL.$stderr);
+
if ($exitCode !== 0) {
throw new VendorInstallException(
errorKey: 'bundle_build_composer_failed',
@@ -428,13 +517,34 @@ class VendorBundler
]);
}
+ /**
+ * composer install 의 stdout/stderr 를 받을 proc_open descriptor 배열을 구성합니다.
+ *
+ * stdout/stderr 를 파이프가 아닌 파일로 여는 이유는 runComposerInstall 주석 참조 —
+ * Windows 에서 composer autoload dump 의 async 손자 프로세스가 상속받는 파이프 핸들로
+ * 인한 무한 대기(행)를 원천 차단한다. 이 메서드는 순수 함수로, descriptor 구조를
+ * 회귀 테스트로 고정하기 위해 분리했다.
+ *
+ * @param string $outPath stdout 로그 파일 경로
+ * @param string $errPath stderr 로그 파일 경로
+ * @return array> proc_open 2번째 인자용 descriptor 배열
+ */
+ public static function composerOutputDescriptors(string $outPath, string $errPath): array
+ {
+ return [
+ 0 => ['pipe', 'r'],
+ 1 => ['file', $outPath, 'w'],
+ 2 => ['file', $errPath, 'w'],
+ ];
+ }
+
/**
* vendor/ 디렉토리를 재귀적으로 zip에 쓰기.
*
* 스테이징에서 composer install --no-dev 로 새로 생성된 vendor/ 를 대상으로 하므로
* 화이트리스트 필터링은 불필요하며, EXCLUDE_PATTERNS (tests/, docs/ 등) 만 적용한다.
*
- * @return array{0: int, 1: int} [zipSize, fileCount]
+ * @return array{0: int, 1: int} [zipSize, fileCount]
*/
private function writeZip(string $vendorPath, string $zipPath): array
{
diff --git a/config/app.php b/config/app.php
index 4885bc61..df842759 100644
--- a/config/app.php
+++ b/config/app.php
@@ -231,7 +231,7 @@ return [
|
*/
- 'version' => env('APP_VERSION', '7.0.3'),
+ 'version' => env('APP_VERSION', '7.0.4'),
/*
|--------------------------------------------------------------------------
diff --git a/docs/extension/vendor-bundle.md b/docs/extension/vendor-bundle.md
index 4a099b11..58a184fe 100644
--- a/docs/extension/vendor-bundle.md
+++ b/docs/extension/vendor-bundle.md
@@ -30,6 +30,28 @@ G7 코어와 모듈/플러그인은 PHP 의존성 관리에 Composer를 사용
이 네 파일(`composer.json` / `composer.lock` / `vendor-bundle.json` / `vendor-bundle.zip`)은 코어 루트 및 각 `_bundled` 확장 루트에 함께 존재하며 모두 Git 으로 추적한다. 추출 결과인 `vendor/` 디렉토리만 `.gitignore` 대상이다. `composer.lock` 이 누락되면 `VendorIntegrityChecker` 가 `composer_lock_sha256` 을 검증할 수 없으므로, `composer update` 직후 생성된 `composer.lock` 과 재빌드된 번들 두 파일을 같은 커밋에 함께 포함한다.
+### 1.1 소스와 출력의 분리
+
+확장의 번들 빌드는 읽는 곳과 쓰는 곳이 다르다.
+
+| 구분 | 경로 | 근거 |
+|------|------|------|
+| 소스 (`vendor/` 를 읽는 곳) | 활성 디렉토리 (`modules/{id}`) | `composer install` 이 실행되어 실제 패키지가 설치된 곳 |
+| 출력 (`vendor-bundle.{json,zip}` 을 쓰는 곳) | `_bundled` 디렉토리 | Git 추적 배포 아티팩트 저장 위치 (`_bundled` 오염 방지) |
+
+반면 manifest 에 기록하는 `composer_json_sha256` / `composer_lock_sha256` 은 **출력 디렉토리**의 `composer.json` / `composer.lock` 을 기준으로 계산한다 (`VendorBundler::resolveHashTarget()`). 출력 디렉토리에 해당 파일이 없으면 소스로 폴백하며, 코어는 소스와 출력이 동일하므로 결과가 같다.
+
+이유는 검증자의 대조 위치에 있다. `VendorIntegrityChecker::verify($sourceDir)` 의 `$sourceDir` 은 zip 과 manifest 가 놓인 디렉토리를 뜻하고, 실제 호출 경로는 모두 `_bundled` 계열을 넘긴다.
+
+| 경로 | verify 에 넘기는 디렉토리 |
+|------|--------------------------|
+| 신규 설치 | `_pending/{id}` (`_bundled` 복사본) |
+| 업데이트 | `_pending/{id}_updating_*` (`_bundled` 복사본) |
+| 코어 업데이트 | pending 경로 (소스 = 출력) |
+| `vendor-bundle:verify-all` | `_bundled/{id}` |
+
+해시를 소스(활성)에서 계산하면 생성 기준과 검증 기준이 어긋난다. 두 `composer.json` 의 내용이 같을 때만 우연히 통과하며, `_bundled` 의 `composer.json` 만 변경된 상태(버전 bump 직후 등)에서는 manifest 가 영구히 불일치하여 `module:update` 가 `composer_json_sha_mismatch` 로 차단된다. 그 오류를 해소할 유일한 명령이 `module:update` 이므로 순환이 발생한다. 같은 이유로 `isStale()` 도 출력 디렉토리를 검사해야 하며, 소스만 보면 `--check` 가 stale 을 `up-to-date` 로 오보한다.
+
`vendor-bundle.json` 스키마 (v1.0):
```json
@@ -212,11 +234,11 @@ Content-Type: application/json
### 7.1 SHA256 검증
-`VendorIntegrityChecker::verify()` 가 다음을 검증합니다:
+`VendorIntegrityChecker::verify($sourceDir)` 가 다음을 검증합니다. `$sourceDir` 은 zip 과 manifest 가 놓인 디렉토리이며, 확장의 경우 `_bundled` 또는 그 복사본(`_pending`)입니다 (§1.1).
- vendor-bundle.zip 파일의 실제 SHA256 vs manifest 기록 값
-- composer.json SHA256 (소스에 존재 시)
-- composer.lock SHA256 (소스에 존재 시)
+- composer.json SHA256 (해당 디렉토리에 존재 시)
+- composer.lock SHA256 (해당 디렉토리에 존재 시)
검증 실패 시 `VendorInstallException` 이 발생하며 설치가 중단됩니다.
@@ -230,6 +252,9 @@ Content-Type: application/json
## 8. 자동 트리거
`composer.json` 또는 `composer.lock` 파일이 변경되면 번들이 stale 상태가 됩니다.
+확장의 경우 판정 기준은 `_bundled` 디렉토리의 두 파일이다 (§1.1). 확장 버전을 올려
+`_bundled/composer.json` 의 `version` 만 바뀐 경우에도 stale 이 되므로 재빌드가 필요하다.
+
다음 명령으로 재빌드해야 합니다:
```bash
@@ -269,6 +294,8 @@ CI 통합 예시:
| `composer_not_available` | composer 모드 강제했으나 실행 불가 | --vendor-mode=auto 또는 bundled로 변경 |
| `no_vendor_strategy_available` | composer 불가 + 번들 없음 | vendor-bundle.zip 업로드 또는 호스팅 변경 |
| `bundle_contains_unsafe_path` | 외부 zip 파일이 zip slip 시도 | 신뢰할 수 있는 소스에서만 다운로드 |
+| `bundle_build_promote_failed` | 빌드 산출물을 최종 경로로 원자적 교체 실패 | 디스크 공간/권한 확인 후 재빌드 (원본 번들은 promoteAtomic 으로 보존됨) |
+| 빌드가 `Generating optimized autoload files` 직후 무한 대기 (Windows) | composer autoload dump 의 async 손자 프로세스가 부모 파이프 핸들을 상속·점유 | 코어 반영됨 — stdout/stderr 를 파이프가 아닌 파일 descriptor 로 실행 (`VendorBundler::composerOutputDescriptors`). 최신 코어에서는 재발하지 않음 |
## 11. 참고
diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
index d59a08bd..ec217b20 100644
--- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.3] - 2026-07-11
+
+### Added
+
+- vendor-bundle 빌드 결과 반영 실패 안내 메시지 일본어 번역 추가 (`exceptions.vendor.bundle_build_promote_failed`) — 빌드 산출물을 최종 경로로 원자적 교체하는 데 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
+
## [1.0.2] - 2026-07-08
### Added
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php
index cf6b007f..f77cd321 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php
@@ -60,6 +60,7 @@ return [
'no_composer_lock' => 'composer.lockファイルが必要です。まずcomposer installを実行してください: :path',
'vendor_dir_not_found' => 'vendor/ディレクトリが存在しません。まずcomposer installを実行してください: :path',
'composer_binary_not_found' => 'Composerバイナリが見つかりません。COMPOSER_BINARY環境変数を設定するか、composerをPATHに追加してください。',
+ 'bundle_build_promote_failed' => 'vendor-bundle ビルド結果を最終パスに反映できませんでした (:from → :to)',
],
'concurrent_modification' => '別のユーザーが先に保存しました。(リソース: :resource、現在のバージョン: :current、送信されたバージョン: :expected)',
];
diff --git a/lang-packs/_bundled/g7-core-ja/language-pack.json b/lang-packs/_bundled/g7-core-ja/language-pack.json
index 0acc94e5..6f9a724c 100644
--- a/lang-packs/_bundled/g7-core-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-core-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 core Japanese language pack (bundled)",
"ja": "G7 コア 日本語 言語パック(バンドル)"
},
- "version": "1.0.2",
+ "version": "1.0.3",
"license": "MIT",
"scope": "core",
"target_identifier": null,
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
index 00bebad6..b80766cb 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.3] - 2026-07-10
+
+### Added
+
+- 알림 설정의 "무통장 입금 안내"·"배송 완료" 알림 이름에 대한 일본어 번역을 추가했습니다.
+
## [1.0.2] - 2026-07-10
### Removed
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json
index 29a74807..fc0635e2 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json
@@ -458,7 +458,9 @@
"new_order_admin": "新規注文(管理者)",
"inquiry_received": "商品問い合わせ受け取り(管理者)",
"inquiry_replied": "商品問い合わせ回答",
- "mileage_expiring_soon": "マイレージ失効予定"
+ "mileage_expiring_soon": "マイレージ失効予定",
+ "order_pending_deposit": "銀行振込のご案内",
+ "order_delivered": "配送完了"
},
"btn_search": "検索",
"btn_reset_filter": "初期化",
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
index 814f617c..0cd77c21 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
},
- "version": "1.0.2",
+ "version": "1.0.3",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-ecommerce",
diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php
index 4ffdea8f..7e2c0016 100644
--- a/lang/en/exceptions.php
+++ b/lang/en/exceptions.php
@@ -57,6 +57,7 @@ return [
'composer_not_available' => 'Composer cannot be executed in this environment. Use bundled mode instead.',
'composer_not_available_for_build' => 'Building vendor-bundle requires Composer execution. Install/configure Composer in the development environment and retry.',
'bundle_build_composer_failed' => 'composer install failed while building vendor-bundle (exit :exit): :message',
+ 'bundle_build_promote_failed' => 'failed to move vendor-bundle build result to its final path (:from → :to)',
'composer_execution_failed' => 'Composer execution failed: :message',
'bundle_zip_missing' => 'vendor-bundle.zip not found: :path',
'bundle_manifest_missing' => 'vendor-bundle.json not found: :path',
diff --git a/lang/ko/exceptions.php b/lang/ko/exceptions.php
index e9703807..61bef32e 100644
--- a/lang/ko/exceptions.php
+++ b/lang/ko/exceptions.php
@@ -57,6 +57,7 @@ return [
'composer_not_available' => 'Composer를 실행할 수 없는 환경입니다. 번들 모드를 사용하세요.',
'composer_not_available_for_build' => 'vendor-bundle 빌드는 Composer 실행이 필요합니다. 개발 환경에서 Composer를 설치/설정한 후 다시 시도하세요.',
'bundle_build_composer_failed' => 'vendor-bundle 빌드 중 composer install 실행 실패 (exit :exit): :message',
+ 'bundle_build_promote_failed' => 'vendor-bundle 빌드 결과를 최종 경로로 반영하지 못했습니다 (:from → :to)',
'composer_execution_failed' => 'Composer 실행에 실패했습니다: :message',
'bundle_zip_missing' => 'vendor-bundle.zip 파일을 찾을 수 없습니다: :path',
'bundle_manifest_missing' => 'vendor-bundle.json 파일을 찾을 수 없습니다: :path',
diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
index 287f7454..777310d0 100644
--- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.3] - 2026-07-10
+
+### Fixed
+
+- 쇼핑몰 환경설정 > 알림 설정에서 "무통장 입금 안내"와 "배송 완료" 두 알림의 이름이 사람이 읽을 수 없는 내부 문자열로 표시되던 문제를 수정했습니다. 이제 다른 알림과 동일하게 이름이 표시되며, 알림을 기본값으로 되돌릴 때 뜨는 확인창과 알림 편집 화면에서도 함께 바로잡혔습니다.
+
## [1.0.2] - 2026-07-10
### Changed
diff --git a/modules/_bundled/sirsoft-ecommerce/composer.json b/modules/_bundled/sirsoft-ecommerce/composer.json
index d7af07c0..cc0f1bb7 100644
--- a/modules/_bundled/sirsoft-ecommerce/composer.json
+++ b/modules/_bundled/sirsoft-ecommerce/composer.json
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-ecommerce",
"description": "Ecommerce module for Gnuboard7",
"type": "library",
- "version": "1.0.2",
+ "version": "1.0.3",
"license": "MIT",
"autoload": {
"psr-4": {
diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json
index 8463b86d..673f89fa 100644
--- a/modules/_bundled/sirsoft-ecommerce/module.json
+++ b/modules/_bundled/sirsoft-ecommerce/module.json
@@ -5,7 +5,7 @@
"ko": "이커머스",
"en": "Ecommerce"
},
- "version": "1.0.2",
+ "version": "1.0.3",
"license": "MIT",
"description": {
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
diff --git a/modules/_bundled/sirsoft-ecommerce/package-lock.json b/modules/_bundled/sirsoft-ecommerce/package-lock.json
index dc9901a7..33a0593a 100644
--- a/modules/_bundled/sirsoft-ecommerce/package-lock.json
+++ b/modules/_bundled/sirsoft-ecommerce/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-ecommerce",
- "version": "1.0.2",
+ "version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-ecommerce",
- "version": "1.0.2",
+ "version": "1.0.3",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/modules/_bundled/sirsoft-ecommerce/package.json b/modules/_bundled/sirsoft-ecommerce/package.json
index 08a25b1e..88de7fcf 100644
--- a/modules/_bundled/sirsoft-ecommerce/package.json
+++ b/modules/_bundled/sirsoft-ecommerce/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-ecommerce",
- "version": "1.0.2",
+ "version": "1.0.3",
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationDefinitionTypeLabels.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationDefinitionTypeLabels.test.tsx
new file mode 100644
index 00000000..e07d3e06
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationDefinitionTypeLabels.test.tsx
@@ -0,0 +1,182 @@
+// e2e:allow 라벨 데이터(settings.json) + Vitest 렌더 회귀 테스트만 변경 — 레이아웃 동작/구조 무변경. 알림 정의 화면의 실제 렌더 경로($t: 동적 키 해석)는 본 render 테스트가 커버하고, 화면 수준 검증은 별도 브라우저 실측(handoff §10)으로 확인.
+/**
+ * @file notificationDefinitionTypeLabels.test.tsx
+ * @description 이커머스 알림 정의 화면 — 타입 라벨 다국어 키 raw 노출 회귀 가드.
+ *
+ * 배경: `order_pending_deposit`, `order_delivered` 두 알림 타입이 module.php 에는
+ * 정의됐으나 라벨을 읽는 settings.json 의 `notification_definitions.types` 에 이름이
+ * 등록되지 않아, 알림 정의 화면에서
+ * `sirsoft-ecommerce.admin.settings.notification_definitions.types.order_pending_deposit`
+ * 같은 raw 키가 그대로 노출됐다.
+ *
+ * 본 테스트는 _tab_notification_definitions.json 이 사용하는 실제 바인딩
+ * `$t:sirsoft-ecommerce.admin.settings.notification_definitions.types.{{def.type}}` 을
+ * 재현하고, **실제 settings.json** 라벨을 주입해 두 타입이 raw 키가 아닌 사람이 읽는
+ * 라벨로 렌더되는지 검증한다. settings.json 에서 어느 한 키가 제거되면 이 테스트가
+ * 다시 red 로 떨어져 회귀를 차단한다.
+ */
+
+import React from 'react';
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import {
+ createLayoutTest,
+ createMockComponentRegistryWithBasics,
+ screen,
+ type MockComponentRegistry,
+} from '@core/template-engine/__tests__/utils/layoutTestUtils';
+
+import settingsKo from '../../../lang/partial/ko/admin/settings.json';
+import settingsEn from '../../../lang/partial/en/admin/settings.json';
+
+// 실제 화면(_tab_notification_definitions.json line ~371)의 타입 라벨 바인딩을 그대로 축약 재현.
+// 알림 목록 항목마다 H4 에 `$t:...types.{{def.type}}` 로 이름을 표시한다.
+const LABEL_BINDING =
+ '$t:sirsoft-ecommerce.admin.settings.notification_definitions.types.{{def.type}}';
+
+function makeLayout() {
+ return {
+ name: 'notification_definition_type_labels_probe',
+ version: '1.0.0',
+ components: [
+ {
+ type: 'basic',
+ name: 'Div',
+ children: [
+ {
+ type: 'basic',
+ name: 'Div',
+ iteration: {
+ source: '{{ecommerceNotificationDefinitions?.data?.data ?? []}}',
+ item_var: 'def',
+ index_var: 'defIdx',
+ },
+ children: [
+ {
+ type: 'basic',
+ name: 'H4',
+ text: LABEL_BINDING,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+}
+
+// 실제 서빙 데이터를 모사한 알림 정의 목록 — 문제의 두 타입 포함.
+const DEFINITIONS = [
+ { id: 1, type: 'order_pending_deposit', templates: [], variables: [] },
+ { id: 2, type: 'order_delivered', templates: [], variables: [] },
+];
+
+// settings.json 은 `notification_definitions` 를 admin.settings 하위가 아닌 파일 루트에 둔다.
+// 화면 바인딩은 `sirsoft-ecommerce.admin.settings.notification_definitions...` 네임스페이스를
+// 쓰므로, 주입 시 admin.settings 아래로 매핑한다(활성 언어팩 로더의 병합 결과와 동일 구조).
+function buildTranslations(settings: Record) {
+ return {
+ 'sirsoft-ecommerce': {
+ admin: {
+ settings: settings,
+ },
+ },
+ };
+}
+
+let registry: MockComponentRegistry;
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('알림 정의 타입 라벨 — 다국어 키 raw 노출 회귀 가드', () => {
+ it('ko: order_pending_deposit / order_delivered 가 라벨로 렌더된다 (raw 키 미노출)', async () => {
+ registry = createMockComponentRegistryWithBasics();
+ const { render, cleanup } = createLayoutTest(makeLayout(), {
+ componentRegistry: registry as any,
+ locale: 'ko',
+ translations: buildTranslations(settingsKo),
+ initialData: {
+ ecommerceNotificationDefinitions: { data: { data: DEFINITIONS } },
+ },
+ });
+
+ await render();
+
+ expect(screen.getByText('무통장 입금 안내')).toBeInTheDocument();
+ expect(screen.getByText('배송 완료')).toBeInTheDocument();
+
+ // raw 키가 화면에 남아있으면 회귀
+ expect(
+ screen.queryByText(/notification_definitions\.types\.order_pending_deposit/),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(/notification_definitions\.types\.order_delivered/),
+ ).not.toBeInTheDocument();
+
+ cleanup();
+ });
+
+ it('en: order_pending_deposit / order_delivered 가 라벨로 렌더된다 (raw 키 미노출)', async () => {
+ registry = createMockComponentRegistryWithBasics();
+ const { render, cleanup } = createLayoutTest(makeLayout(), {
+ componentRegistry: registry as any,
+ locale: 'en',
+ translations: buildTranslations(settingsEn),
+ initialData: {
+ ecommerceNotificationDefinitions: { data: { data: DEFINITIONS } },
+ },
+ });
+
+ await render();
+
+ expect(screen.getByText('Bank Transfer Payment Guide')).toBeInTheDocument();
+ expect(screen.getByText('Order Delivered')).toBeInTheDocument();
+
+ expect(
+ screen.queryByText(/notification_definitions\.types\.order_pending_deposit/),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(/notification_definitions\.types\.order_delivered/),
+ ).not.toBeInTheDocument();
+
+ cleanup();
+ });
+
+ it('라벨 제거 시 raw 키가 노출됨 — 회귀 가드가 실제로 결함을 감지한다', async () => {
+ // 이 테스트는 위 두 가드가 "우연히 통과"하는 것이 아님을 증명한다:
+ // settings.json 에서 두 타입 라벨을 제거하면 화면에 raw 키가 그대로 노출된다.
+ registry = createMockComponentRegistryWithBasics();
+ const stripped = JSON.parse(JSON.stringify(settingsKo));
+ delete stripped.notification_definitions.types.order_pending_deposit;
+ delete stripped.notification_definitions.types.order_delivered;
+
+ const { render, cleanup } = createLayoutTest(makeLayout(), {
+ componentRegistry: registry as any,
+ locale: 'ko',
+ translations: buildTranslations(stripped),
+ initialData: {
+ ecommerceNotificationDefinitions: { data: { data: DEFINITIONS } },
+ },
+ });
+
+ await render();
+
+ expect(screen.queryByText('무통장 입금 안내')).not.toBeInTheDocument();
+ expect(
+ screen.getByText(/notification_definitions\.types\.order_pending_deposit/),
+ ).toBeInTheDocument();
+
+ cleanup();
+ });
+
+ it('settings.json(ko/en) 의 types 에 두 신규 타입 라벨이 존재한다', () => {
+ const koTypes = (settingsKo as any).notification_definitions.types;
+ const enTypes = (settingsEn as any).notification_definitions.types;
+
+ expect(koTypes.order_pending_deposit).toBe('무통장 입금 안내');
+ expect(koTypes.order_delivered).toBe('배송 완료');
+ expect(enTypes.order_pending_deposit).toBe('Bank Transfer Payment Guide');
+ expect(enTypes.order_delivered).toBe('Order Delivered');
+ });
+});
diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json
index 33d6470d..13a76973 100644
--- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json
+++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json
@@ -447,7 +447,9 @@
"save_error": "Failed to save notification settings.",
"types": {
"order_confirmed": "Order Confirmed",
+ "order_pending_deposit": "Bank Transfer Payment Guide",
"order_shipped": "Order Shipped",
+ "order_delivered": "Order Delivered",
"order_completed": "Purchase Completed",
"order_cancelled": "Order Cancelled",
"new_order_admin": "New Order (Admin)",
diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json
index 975c5e24..b6993c20 100644
--- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json
+++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json
@@ -447,7 +447,9 @@
"save_error": "알림 설정 저장에 실패했습니다.",
"types": {
"order_confirmed": "주문 확인",
+ "order_pending_deposit": "무통장 입금 안내",
"order_shipped": "배송 시작",
+ "order_delivered": "배송 완료",
"order_completed": "구매 확정",
"order_cancelled": "주문 취소",
"new_order_admin": "신규 주문 (관리자)",
diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json
index 42c229a6..a0448d27 100644
--- a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json
+++ b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json
@@ -1,11 +1,11 @@
{
"schema_version": "1.0",
- "generated_at": "2026-07-10T12:05:27+00:00",
+ "generated_at": "2026-07-10T20:22:38+00:00",
"generator": "g7 vendor-bundle:build",
"target": "module:sirsoft-ecommerce",
- "composer_json_sha256": "aa8f74063b7f9cd969cb4effb120e3c50bb944a4d3b2e16fae7c31bd8232fce8",
+ "composer_json_sha256": "5195d789bc9edd0b5140c42aeedd7c87be67c7252b477a879ff90607b4787935",
"composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d",
- "zip_sha256": "ef77515e4d920a52a8adba1e6d7e310389c6f3f459586d3cd5c24749f24d7fb0",
+ "zip_sha256": "c1767e8eca61163667e9289a3963454bab92806ce4e8a3651777f1e1aea14081",
"zip_size": 435549,
"package_count": 1,
"php_requirement": "^8.2",
diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip
index 0f7ac28f..a823e196 100644
Binary files a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip and b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip differ
diff --git a/tests/Unit/Extension/Vendor/VendorBundlerTest.php b/tests/Unit/Extension/Vendor/VendorBundlerTest.php
index 8bcb3416..6fcb0d5a 100644
--- a/tests/Unit/Extension/Vendor/VendorBundlerTest.php
+++ b/tests/Unit/Extension/Vendor/VendorBundlerTest.php
@@ -2,8 +2,10 @@
namespace Tests\Unit\Extension\Vendor;
+use App\Extension\Vendor\EnvironmentDetector;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorBundler;
+use App\Extension\Vendor\VendorBundleResult;
use App\Extension\Vendor\VendorIntegrityChecker;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
@@ -79,7 +81,7 @@ class VendorBundlerTest extends TestCase
*
* 테스트는 단일 testDir 을 소스/출력 동일하게 사용 (코어 케이스와 유사).
*/
- private function invokeBuild(string $target, bool $force = false, ?string $sourcePath = null, ?string $outputPath = null): \App\Extension\Vendor\VendorBundleResult
+ private function invokeBuild(string $target, bool $force = false, ?string $sourcePath = null, ?string $outputPath = null): VendorBundleResult
{
$reflection = new \ReflectionClass($this->bundler);
$method = $reflection->getMethod('build');
@@ -239,6 +241,157 @@ class VendorBundlerTest extends TestCase
$this->assertFileDoesNotExist($sourceDir.'/vendor-bundle.json');
}
+ /**
+ * 분리 구조에서 manifest 의 composer 해시는 **출력(_bundled)** 의 composer.json 기준이어야 합니다.
+ *
+ * 런타임 검증(VendorIntegrityChecker::verify)은 manifest 와 같은 디렉토리(_bundled 또는 그 복사본인
+ * staging)의 composer.json 을 대조한다. 해시를 활성 디렉토리에서 계산하면, _bundled 의 composer.json
+ * 만 변경된 상태(예: 버전 bump 직후)에서 manifest 가 영구히 불일치하여 module:update 가 막힌다.
+ */
+ public function test_build_hashes_output_composer_json_not_source(): void
+ {
+ $sourceDir = $this->testDir.'/active';
+ $outputDir = $this->testDir.'/bundled';
+ File::ensureDirectoryExists($sourceDir);
+ File::ensureDirectoryExists($outputDir);
+
+ // 활성 — 구버전 (아직 update 가 반영되지 않은 상태)
+ File::put($sourceDir.'/composer.json', json_encode([
+ 'name' => 'test/project',
+ 'version' => '1.0.2',
+ 'require' => ['php' => '^8.2', 'test/lib' => '^1.0'],
+ ]));
+ File::put($sourceDir.'/composer.lock', json_encode([
+ 'packages' => [
+ ['name' => 'test/lib', 'version' => '1.0.0', 'type' => 'library'],
+ ],
+ ]));
+
+ // _bundled — 신버전 (작업자가 version bump 한 상태)
+ File::put($outputDir.'/composer.json', json_encode([
+ 'name' => 'test/project',
+ 'version' => '1.0.3',
+ 'require' => ['php' => '^8.2', 'test/lib' => '^1.0'],
+ ]));
+
+ $this->invokeBuild('test:split-hash', sourcePath: $sourceDir, outputPath: $outputDir);
+
+ $manifest = json_decode(File::get($outputDir.'/vendor-bundle.json'), true);
+
+ $this->assertSame(
+ $this->checker->computeFileHash($outputDir.'/composer.json'),
+ $manifest['composer_json_sha256'],
+ 'manifest 의 composer_json_sha256 은 출력(_bundled) 의 composer.json 해시여야 합니다.'
+ );
+
+ // 검증자가 outputDir 을 sourceDir 로 verify 할 때 통과해야 한다 (module:update 경로 재현)
+ $this->assertTrue(
+ $this->checker->verify($outputDir)->valid,
+ 'build 직후 _bundled 를 verify 하면 무결성 검증을 통과해야 합니다.'
+ );
+ }
+
+ /**
+ * 분리 구조에서 isStale 은 **출력(_bundled)** 의 composer.json 변경을 감지해야 합니다.
+ *
+ * 활성 디렉토리만 검사하면 _bundled 의 stale 을 up-to-date 로 오보하여, --check 가 재빌드 필요를
+ * 놓치고 이후 module:update 가 무결성 오류로 실패한다.
+ */
+ public function test_is_stale_detects_output_composer_json_change(): void
+ {
+ $sourceDir = $this->testDir.'/active';
+ $outputDir = $this->testDir.'/bundled';
+ File::ensureDirectoryExists($sourceDir);
+ File::ensureDirectoryExists($outputDir);
+
+ $composerJson = json_encode([
+ 'name' => 'test/project',
+ 'version' => '1.0.2',
+ 'require' => ['php' => '^8.2', 'test/lib' => '^1.0'],
+ ]);
+ File::put($sourceDir.'/composer.json', $composerJson);
+ File::put($sourceDir.'/composer.lock', json_encode([
+ 'packages' => [
+ ['name' => 'test/lib', 'version' => '1.0.0', 'type' => 'library'],
+ ],
+ ]));
+ File::put($outputDir.'/composer.json', $composerJson);
+
+ $this->invokeBuild('test:split-stale', sourcePath: $sourceDir, outputPath: $outputDir);
+ $this->assertFalse(
+ $this->bundler->isStale($sourceDir, $outputDir),
+ '빌드 직후에는 stale 이 아니어야 합니다.'
+ );
+
+ // _bundled 의 composer.json 만 변경 (활성은 그대로) — 버전 bump 시나리오
+ File::put($outputDir.'/composer.json', json_encode([
+ 'name' => 'test/project',
+ 'version' => '1.0.3',
+ 'require' => ['php' => '^8.2', 'test/lib' => '^1.0'],
+ ]));
+
+ $this->assertTrue(
+ $this->bundler->isStale($sourceDir, $outputDir),
+ '_bundled 의 composer.json 이 변경되면 stale 로 판정해야 합니다.'
+ );
+ }
+
+ /**
+ * 재빌드가 실패하면 기존 번들 파일이 보존되어야 합니다.
+ *
+ * 기존 zip/manifest 를 staging 빌드 이전에 삭제하면, composer install 실패 시
+ * 새 파일이 생성되지 못한 채 원본만 유실되어 다음 module:update 가 번들 부재로
+ * 실패한다 (Windows 등에서 composer 실행이 불안정할 때 재현). 삭제는 재생성
+ * 성공이 확정된 뒤에 이뤄져야 한다.
+ */
+ public function test_failed_rebuild_preserves_existing_bundle(): void
+ {
+ $this->createFakeProject();
+
+ // 1차 빌드 성공 — 정상 번들 생성
+ $this->invokeBuild('test:project');
+ $this->assertFileExists($this->testDir.'/vendor-bundle.zip');
+ $this->assertFileExists($this->testDir.'/vendor-bundle.json');
+
+ $originalZip = File::get($this->testDir.'/vendor-bundle.zip');
+ $originalManifest = File::get($this->testDir.'/vendor-bundle.json');
+
+ // composer install 이 실패하도록 러너 교체
+ $this->bundler->setComposerInstallRunner(function (): void {
+ throw new VendorInstallException(
+ errorKey: 'bundle_build_composer_failed',
+ context: ['exit' => 1, 'message' => 'simulated composer failure'],
+ );
+ });
+
+ // 2차 빌드(force) — 실패해야 하지만 기존 번들은 보존되어야 함
+ try {
+ $this->invokeBuild('test:project', force: true);
+ $this->fail('composer install 실패 시 빌드는 예외를 던져야 합니다.');
+ } catch (VendorInstallException) {
+ // 예상된 실패
+ }
+
+ $this->assertFileExists(
+ $this->testDir.'/vendor-bundle.zip',
+ '재빌드 실패 시 기존 zip 이 보존되어야 합니다.'
+ );
+ $this->assertFileExists(
+ $this->testDir.'/vendor-bundle.json',
+ '재빌드 실패 시 기존 manifest 가 보존되어야 합니다.'
+ );
+ $this->assertSame(
+ $originalZip,
+ File::get($this->testDir.'/vendor-bundle.zip'),
+ '보존된 zip 은 원본과 동일해야 합니다.'
+ );
+ $this->assertSame(
+ $originalManifest,
+ File::get($this->testDir.'/vendor-bundle.json'),
+ '보존된 manifest 는 원본과 동일해야 합니다.'
+ );
+ }
+
/**
* 외부 의존성 없는 확장은 isStale 이 항상 false 를 반환해야 합니다.
*/
@@ -306,7 +459,7 @@ class VendorBundlerTest extends TestCase
*/
public function test_build_throws_when_composer_not_available(): void
{
- $detector = \Mockery::mock(\App\Extension\Vendor\EnvironmentDetector::class);
+ $detector = \Mockery::mock(EnvironmentDetector::class);
$detector->shouldReceive('canExecuteComposer')->andReturn(false);
$bundler = new VendorBundler($this->checker, $detector);
@@ -321,4 +474,33 @@ class VendorBundlerTest extends TestCase
$method->setAccessible(true);
$method->invoke($bundler, $this->testDir, $this->testDir, 'test:no-composer', false);
}
+
+ /**
+ * composer install 의 stdout/stderr 는 파이프가 아닌 파일 descriptor 로 열려야 한다.
+ *
+ * (Windows 회귀 가드) stdout/stderr 를 파이프로 열면 composer autoload dump 단계의
+ * async `cmd.exe` 손자 프로세스가 부모 파이프 핸들을 상속·점유해 proc_close 가
+ * 자식 완료 신호를 받지 못하고 무한 대기(행)한다. 실측: 파이프=행, 파일=140s 완주.
+ * descriptor 구조를 파이프로 되돌리면 이 테스트가 red 로 회귀를 차단한다.
+ */
+ public function test_composer_output_descriptors_use_files_not_pipes(): void
+ {
+ $descriptors = VendorBundler::composerOutputDescriptors('/tmp/out.log', '/tmp/err.log');
+
+ // stdin 은 파이프(입력 없음 즉시 닫힘) 로 유지.
+ $this->assertSame(['pipe', 'r'], $descriptors[0]);
+
+ // stdout/stderr 는 파일 descriptor 여야 한다 (파이프 금지 — 손자 핸들 상속 차단).
+ $this->assertSame('file', $descriptors[1][0], 'stdout 은 파일 descriptor 여야 함 (파이프 시 Windows 행 회귀)');
+ $this->assertSame('/tmp/out.log', $descriptors[1][1]);
+ $this->assertSame('w', $descriptors[1][2]);
+
+ $this->assertSame('file', $descriptors[2][0], 'stderr 은 파일 descriptor 여야 함 (파이프 시 Windows 행 회귀)');
+ $this->assertSame('/tmp/err.log', $descriptors[2][1]);
+ $this->assertSame('w', $descriptors[2][2]);
+
+ // 어떤 채널도 'pipe','w' 로 열리지 않아야 함 (손자 상속 가능한 열린 파이프 부재).
+ $this->assertNotSame('pipe', $descriptors[1][0]);
+ $this->assertNotSame('pipe', $descriptors[2][0]);
+ }
}
diff --git a/vendor-bundle.json b/vendor-bundle.json
index 5bc88ef4..3e5c26e5 100644
--- a/vendor-bundle.json
+++ b/vendor-bundle.json
@@ -1,15 +1,15 @@
{
"schema_version": "1.0",
- "generated_at": "2026-06-30T04:00:54+00:00",
+ "generated_at": "2026-07-10T21:33:36+00:00",
"generator": "g7 vendor-bundle:build",
"target": "core",
"composer_json_sha256": "76294945639dbbe4b6d054c0aea4b78377a55bcdffcd1c9136beb294bc7cfc90",
"composer_lock_sha256": "b4d173c40088a78c9fe42b3ca6d23436bee6cc850a17bb653cbc1aca59ca3a81",
- "zip_sha256": "0a11e8d918f58465d231bfe2ba84a0d9f749112ca4b79e19ac0224145abc0ef1",
+ "zip_sha256": "d5d21fdd930350332d2d58e5ab386ba9203795f72f7767eb3c214d4702152245",
"zip_size": 14750349,
"package_count": 104,
"php_requirement": "^8.2",
- "g7_version": "7.0.0",
+ "g7_version": "7.0.3",
"packages": [
{
"name": "aws/aws-crt-php",
diff --git a/vendor-bundle.zip b/vendor-bundle.zip
index 761e8d31..2bffbd05 100644
Binary files a/vendor-bundle.zip and b/vendor-bundle.zip differ