fix(core,ecommerce): Windows 확장 업데이트 파일 잠금 해소 + 통화 삭제 영속화

Windows 는 하위 트리에 열린 핸들이 하나라도 있으면 디렉토리 rename 을 막는다.
잠금 프로세스를 찾아 종료하던 기존 대응은 디렉토리 핸들을 감지하지 못했고
사용 중인 편집기를 예고 없이 죽였다. rename 이 막히면 파일 단위 연산으로
폴백해 어떤 프로세스도 건드리지 않고 교체를 끝낸다. 커밋 전 점검에서
pint.json 이 코어 업데이트 대상에 빠져 있던 것도 함께 등재했다.

이커머스는 기본 제공 통화 삭제가 저장 응답에서 즉시 부활했다. 항목 단위
보충 병합이 "소실" 과 "의도적 삭제" 를 구분하지 못한 탓이라, 삭제 의도를
서버가 도출해 저장본에 기록하고 병합이 그 기록을 존중하게 했다. 그 통화로
결제된 과거 주문의 표기가 흔들리지 않도록 소수 자릿수 해석도 스냅샷 우선으로
바꿨다 — 금액은 원래 스냅샷 환율을 써 안전했으나 자릿수만 현재 설정을 봤다.
This commit is contained in:
HeuJung
2026-08-12 10:10:01 +09:00
parent 5ae34002f3
commit c77ddee03b
39 changed files with 1886 additions and 138 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.6
APP_VERSION=7.0.7
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.6
APP_VERSION=7.0.7
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+9
View File
@@ -500,6 +500,15 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통
주문·결제·환불 금액은 **거래 시점 통화로 동결**한다(`currency_snapshot.base_currency`). 운영자가 이후 기본 통화를 바꿔도 과거 주문의 표기는 불변이어야 한다.
동결 대상은 환율만이 아니다 — **소수 자릿수·절사 규칙·환산 분모(base_unit)까지 스냅샷이 SSoT** 다. 이 값들을 현재 설정에서 조회하면, 운영자가 그 통화를 삭제하는 순간 설정에서 사라져 폴백(자릿수 2)이 적용된다. 소수 0자리 통화의 과거 주문 표기가 `¥14,835` → `¥14,835.00` 으로 바뀌고, 3자리 이상으로 설정했던 통화는 표시 금액이 절사된다. 금액 계산은 스냅샷을 쓰는데 표기만 현재 설정을 따라가면 같은 화면 안에서 근거가 갈린다.
| ❌ 금지 | ✅ 올바른 사용 |
| --- | --- |
| 주문·환불 표시에서 `getDecimalPlaces($code)` 를 스냅샷 없이 호출 | `getDecimalPlaces($code, $currencySnapshot)` — 스냅샷이 있으면 그것이 우선 |
| 리소스가 주문 스냅샷을 자식에게 전파하지 않음 | `withOrderCurrency()` 를 전파하는 지점마다 `withCurrencySnapshot()` 도 함께 전파 |
| 상품·카탈로그 표시까지 스냅샷으로 고정 | 현재 판매가는 **현재 설정**이 정답 — 스냅샷 없이 호출한다 |
| 자릿수를 박제하지 않은 구형 스냅샷에서 예외/0 자리 강제 | 박제값이 없으면 현재 설정 폴백을 그대로 탄다 (하위호환) |
> 상세: [api-resources.md](docs/backend/api-resources.md), [service-repository.md](docs/backend/service-repository.md)
### 확장 결제수단은 자기 능력을 선언한다
+6
View File
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.7] - 2026-08-11
### Fixed
- Windows 에서 확장(모듈·플러그인·템플릿) 업데이트가 "디렉토리 이동 실패" 로 중단되던 문제를 해소했습니다. Windows 는 편집기·개발 도구·파일 감시 프로그램이 폴더를 보고 있기만 해도 폴더 이름 바꾸기를 차단하므로, 폴더를 통째로 이동하는 방식으로는 실패를 피할 수 없었습니다. 이제 이동이 차단되면 파일 단위 교체로 자동 전환해 어떤 프로그램도 종료하지 않고 업데이트를 완료합니다. 잠근 프로그램을 찾아 강제 종료하던 이전 동작은 제거했습니다 — 사용 중인 편집기나 개발 도구가 예고 없이 종료되는 부작용이 있었고, 폴더를 감시만 하는 프로그램은 찾아내지도 못했습니다. 백업 복원에도 같은 방식이 적용되며, 실패한 업데이트가 남긴 임시 폴더는 다음 업데이트 때 자동으로 정리됩니다.
## [7.0.6] - 2026-08-10
### Security
+1 -1
View File
@@ -289,7 +289,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.6 g7
# (필요 시) mv g7-7.0.7 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
+2 -2
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.6-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.7-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>
@@ -518,9 +518,9 @@ cp .env.example .env
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
<a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a>
<a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a>
+2 -2
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.6-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.7-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>
@@ -534,9 +534,9 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
<a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a>
<a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a>
+354 -61
View File
@@ -141,11 +141,22 @@ class ExtensionPendingHelper
// Windows에서 deleteDirectory 직후 같은 이름으로 rename이 실패하는
// 타이밍 이슈를 회피하기 위해 rename→rename→delete 패턴을 사용합니다.
// 임시 디렉토리를 _pending/ 하위에 생성하여 오토로드 오염 방지
//
// Windows 잠금 대응: 디렉토리 rename 은 하위 트리에 열린 핸들(파일 워처의
// 디렉토리 핸들, IDE/Node 프로세스가 열어 둔 파일 등)이 하나라도 있으면
// 실패한다. 잠금 프로세스의 식별·종료는 신뢰할 수 없으므로(디렉토리 핸들은
// Restart Manager 로 감지 불가), rename 이 차단되면 파일 단위 연산으로
// 폴백한다 — 파일 생성/덮어쓰기/읽기는 디렉토리 핸들 잠금의 영향을 받지
// 않아 어떤 프로세스도 종료하지 않고 교체를 완료할 수 있다.
$basePath = dirname($targetPath);
$pendingPath = $basePath.DIRECTORY_SEPARATOR.'_pending';
File::ensureDirectoryExists($pendingPath, 0775);
$identifier = basename($targetPath);
// 이전 실패 실행이 남긴 교체용 임시 디렉토리 정리 (best-effort)
self::cleanupSwapLeftovers($pendingPath, $identifier);
$tempPath = $pendingPath.DIRECTORY_SEPARATOR.$identifier.'_updating_'.uniqid();
$oldPath = $pendingPath.DIRECTORY_SEPARATOR.$identifier.'_old_'.uniqid();
@@ -161,40 +172,77 @@ class ExtensionPendingHelper
// 기존 → _old 이동 (rename, Windows NTFS 타이밍 이슈 대응 재시도)
if (! self::retryMoveDirectory($targetPath, $oldPath)) {
// 파일 잠금 감지 및 해제 시도
if (self::tryReleaseLocks($targetPath, $onProgress)) {
// 잠금 해제 후 재시도
if (! self::retryMoveDirectory($targetPath, $oldPath)) {
File::deleteDirectory($tempPath);
throw new \RuntimeException(
"Failed to move existing directory: {$targetPath} → {$oldPath}"
);
}
} else {
File::deleteDirectory($tempPath);
throw new \RuntimeException(
"Failed to move existing directory: {$targetPath} → {$oldPath}"
);
// 활성 디렉토리 rename 차단 (하위 트리에 열린 핸들 존재)
// → 파일 단위 제자리 동기화로 폴백. 덮어쓰기는 잠금의 영향을 받지 않는다.
$onProgress?->__invoke(null, '디렉토리 이동이 차단되어 파일 단위 제자리 교체로 전환합니다...');
Log::info('확장 교체: 활성 디렉토리 rename 차단 — 제자리 동기화 폴백', [
'target' => $targetPath,
]);
try {
self::syncDirectoryContents($tempPath, $targetPath, $onProgress);
} finally {
self::bestEffortDeleteDirectory($tempPath);
}
self::refreshRuntimeCaches();
return;
}
// 임시 → 활성 이동 (rename, Windows NTFS 타이밍 이슈 대응 재시도)
if (! self::retryMoveDirectory($tempPath, $targetPath)) {
// 롤백: _old를 원래 위치로 복원
File::moveDirectory($oldPath, $targetPath);
throw new \RuntimeException(
"Failed to move directory: {$tempPath} → {$targetPath}"
);
// 방금 복사한 스테이징 트리를 파일 워처가 이미 열어 rename 이 차단된 경우
// → 파일 단위 복사로 폴백. 소스에는 읽기 접근만 필요해 잠금과 무관하게 성공한다.
$onProgress?->__invoke(null, '디렉토리 이동이 차단되어 파일 단위 복사로 전환합니다...');
Log::info('확장 교체: 스테이징 rename 차단 — 파일 단위 복사 폴백', [
'staging' => $tempPath,
'target' => $targetPath,
]);
try {
self::copyDirectoryWithProgress($tempPath, $targetPath, $tempPath, $onProgress);
} catch (\Exception $e) {
// 복사 실패: 부분 복사본 제거 후 _old 를 원래 위치로 복원
self::bestEffortDeleteDirectory($targetPath);
if (! self::retryMoveDirectory($oldPath, $targetPath)) {
try {
self::syncDirectoryContents($oldPath, $targetPath, $onProgress);
} catch (\Throwable $restoreError) {
Log::error('확장 교체 롤백 실패 — 백업 복원이 필요합니다', [
'target' => $targetPath,
'old' => $oldPath,
'error' => $restoreError->getMessage(),
]);
}
}
throw new \RuntimeException(
"Failed to move directory: {$tempPath} → {$targetPath}",
0,
$e
);
}
self::bestEffortDeleteDirectory($tempPath);
}
// 교체 완료 후 _old 삭제 (실패해도 무해)
File::deleteDirectory($oldPath);
// 교체 완료 후 _old 삭제 (실패해도 무해 — 다음 교체 시작 시 잔존물 정리가 재시도)
self::bestEffortDeleteDirectory($oldPath);
// 원자적 rename 은 inode 단위로 교체되므로 PHP realpath/stat 캐시가
// 이전 디렉토리의 파일 존재 여부를 기준으로 판단할 수 있다. 직후 Composer
// PSR-4 autoload 가 신규 파일(beta.1 에 없던 Seeder/Model)을 file_exists 로
// 탐색할 때 false 반환 → "Class not found" fatal 로 업그레이드 스텝이 실패.
// clearstatcache(true) 로 전체 stat 캐시를 비워 신규 파일이 즉시 보이도록 한다.
self::refreshRuntimeCaches();
}
/**
* 교체 완료 후 PHP 런타임 캐시를 갱신합니다.
*
* 원자적 rename 은 inode 단위로 교체되므로 PHP realpath/stat 캐시가
* 이전 디렉토리의 파일 존재 여부를 기준으로 판단할 수 있다. 직후 Composer
* PSR-4 autoload 가 신규 파일(beta.1 에 없던 Seeder/Model)을 file_exists 로
* 탐색할 때 false 반환 → "Class not found" fatal 로 업그레이드 스텝이 실패.
* clearstatcache(true) 로 전체 stat 캐시를 비워 신규 파일이 즉시 보이도록 한다.
*/
private static function refreshRuntimeCaches(): void
{
clearstatcache(true);
// opcache 가 활성화된 프로덕션에서는 활성 디렉토리 하위의 이전 컴파일 바이트코드가
@@ -237,41 +285,6 @@ class ExtensionPendingHelper
]);
}
/**
* 파일 잠금을 감지하고 해제를 시도합니다.
*
* Windows에서 다른 프로세스(IDE 등)가 파일 핸들을 보유하고 있을 때,
* 해당 프로세스를 감지하고 종료하여 디렉토리 이동이 가능하도록 합니다.
* 프로그레스바와 별개로 STDERR에 직접 출력하여 메시지가 덮어씌워지지 않습니다.
*
* @param string $directoryPath 잠금 해제할 디렉토리 경로
* @param \Closure|null $onProgress 진행 콜백
* @return bool 잠금 해제 성공 여부
*/
private static function tryReleaseLocks(string $directoryPath, ?\Closure $onProgress = null): bool
{
if (! FileHandleHelper::isWindows()) {
return false;
}
// 프로그레스바에 의해 메시지가 덮어씌워지지 않도록 STDERR에 직접 출력
$stderr = fopen('php://stderr', 'w');
$outputCallback = function (string $message) use ($stderr) {
if ($stderr) {
fwrite($stderr, $message.PHP_EOL);
}
Log::info($message, ['context' => 'file_lock_release']);
};
$result = FileHandleHelper::releaseLocks($directoryPath, $outputCallback);
if ($stderr) {
fclose($stderr);
}
return $result;
}
/**
* 디렉토리 이동을 재시도합니다 (Windows NTFS 타이밍 이슈 대응).
*
@@ -333,6 +346,282 @@ class ExtensionPendingHelper
}
}
/**
* 새 버전 콘텐츠를 활성 디렉토리에 파일 단위로 제자리 동기화합니다.
*
* 디렉토리 rename 이 외부 프로세스의 핸들 잠금으로 차단될 때 사용하는 폴백입니다.
* 디렉토리 inode 를 건드리지 않고 파일 생성/덮어쓰기/삭제만 수행하므로
* 디렉토리 핸들 잠금(파일 워처 등)의 영향을 받지 않습니다.
*
* (1) 새 버전 파일 전체를 덮어쓰기 → (2) 새 버전에 없는 잔존 파일 제거.
* (1) 실패는 예외로 전파해 호출자(매니저)가 백업 복원으로 수습하게 하고,
* (2) 실패는 로그만 남기고 진행합니다 (업데이트 전체 실패보다 잔존이 낫다).
*
* @param string $source 새 버전 콘텐츠 디렉토리
* @param string $dest 활성 디렉토리
* @param \Closure|null $onProgress 진행 콜백
*
* @throws \RuntimeException 새 버전 파일을 심지 못했을 때
*/
private static function syncDirectoryContents(string $source, string $dest, ?\Closure $onProgress = null): void
{
File::ensureDirectoryExists($dest, 0775);
$failed = [];
self::overlayDirectory($source, $dest, $source, $onProgress, $failed);
if (! empty($failed)) {
throw new \RuntimeException(
'Failed to replace files locked by another process: '
.implode(', ', array_slice($failed, 0, 5))
.(count($failed) > 5 ? ' (+'.(count($failed) - 5).' more)' : '')
);
}
$staleFailures = [];
self::removeStaleEntries($source, $dest, $staleFailures);
if (! empty($staleFailures)) {
Log::warning('확장 제자리 교체: 일부 잔존 파일을 삭제하지 못했습니다 (다음 교체 시 재시도)', [
'dest' => $dest,
'failed' => $staleFailures,
]);
}
}
/**
* 소스 디렉토리의 파일을 대상 디렉토리에 재귀적으로 덮어씁니다.
*
* @param string $source 소스 디렉토리
* @param string $dest 대상 디렉토리
* @param string $basePath 상대 경로 계산 기준
* @param \Closure|null $onProgress 진행 콜백
* @param array $failed 교체 실패한 상대 경로 수집 (참조)
*/
private static function overlayDirectory(
string $source,
string $dest,
string $basePath,
?\Closure $onProgress,
array &$failed
): void {
File::ensureDirectoryExists($dest, 0775);
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
if ($item->isDir() && in_array($item->getBasename(), self::EXCLUDED_DIRECTORIES, true)) {
continue;
}
$target = $dest.DIRECTORY_SEPARATOR.$item->getBasename();
$relativePath = ltrim(str_replace($basePath, '', $item->getPathname()), '/\\');
if ($item->isDir()) {
if (is_file($target) && ! self::deleteFileWithFallback($target)) {
// 파일 → 디렉토리로 바뀐 경로인데 기존 파일을 치우지 못함
$failed[] = $relativePath;
continue;
}
self::overlayDirectory($item->getPathname(), $target, $basePath, $onProgress, $failed);
} else {
if (is_dir($target)) {
// 디렉토리 → 파일로 바뀐 경로
File::deleteDirectory($target);
}
$onProgress?->__invoke(null, $relativePath);
if (! self::replaceFile($item->getPathname(), $target)) {
$failed[] = $relativePath;
}
}
}
}
/**
* 파일 하나를 덮어씁니다 (잠금 대응 단계적 폴백).
*
* Windows 의 일반적인 파일 열기 모드는 읽기/쓰기 공유를 허용하므로,
* 다른 프로세스가 열어 둔 파일이라도 내용 덮어쓰기는 대부분 성공합니다.
* 덮어쓰기가 막힌 경우에만 삭제 후 재생성 → 옆으로 치우기(rename) 순으로
* 시도합니다. FilePermissionHelper::copyFile() 은 복사 실패를 보고하지 않으므로
* (File::copy 반환값 무시) 이 경로에서는 네이티브 copy 반환값으로 판정합니다.
*
* @param string $source 소스 파일
* @param string $destination 대상 파일
* @return bool 교체 성공 여부
*/
private static function replaceFile(string $source, string $destination): bool
{
File::ensureDirectoryExists(dirname($destination));
$isExisting = is_file($destination);
$existingPerms = $isExisting ? @fileperms($destination) : null;
$existingOwner = $isExisting ? @fileowner($destination) : null;
$existingGroup = $isExisting ? @filegroup($destination) : null;
$restoreMeta = function () use ($destination, $isExisting, $existingPerms, $existingOwner, $existingGroup) {
if (! $isExisting) {
// 신규 파일: 부모 디렉토리의 소유자/그룹 상속 (sudo 실행 시 root 소유 방지)
FilePermissionHelper::inheritOwnershipFromParent($destination);
return;
}
if ($existingPerms !== false && $existingPerms !== null) {
@chmod($destination, $existingPerms);
}
if ($existingOwner !== false && $existingOwner !== null && function_exists('chown')) {
@chown($destination, $existingOwner);
}
if ($existingGroup !== false && $existingGroup !== null && function_exists('chgrp')) {
@chgrp($destination, $existingGroup);
}
};
// 1차: 그대로 덮어쓰기
if (@copy($source, $destination)) {
$restoreMeta();
return true;
}
// 2차: 읽기 전용 속성 해제 후 재시도
@chmod($destination, 0666);
if (@copy($source, $destination)) {
$restoreMeta();
return true;
}
// 3차: 삭제 후 재생성
if (@unlink($destination) && @copy($source, $destination)) {
$restoreMeta();
return true;
}
// 4차: 잠긴 파일을 옆으로 치우고(rename 은 덮어쓰기와 별개 권한) 새 파일 생성.
// 옆으로 치운 파일은 즉시 삭제를 시도하고, 실패해도 새 버전에 없는 파일이므로
// 다음 제자리 동기화의 잔존 파일 제거가 다시 삭제를 시도한다.
$aside = $destination.'.g7stale_'.uniqid();
if (@rename($destination, $aside)) {
@unlink($aside);
if (@copy($source, $destination)) {
$restoreMeta();
return true;
}
}
return false;
}
/**
* 파일 하나를 삭제합니다 (잠금 대응 단계적 폴백).
*
* @param string $path 삭제할 파일 경로
* @return bool 삭제(또는 옆으로 치우기) 성공 여부
*/
private static function deleteFileWithFallback(string $path): bool
{
if (@unlink($path)) {
return true;
}
@chmod($path, 0666);
if (@unlink($path)) {
return true;
}
// 삭제가 막힌 경우 옆으로 치워 원래 이름을 비운다
$aside = $path.'.g7stale_'.uniqid();
if (@rename($path, $aside)) {
@unlink($aside);
return true;
}
return false;
}
/**
* 대상 디렉토리에서 소스에 존재하지 않는 파일/디렉토리를 제거합니다.
*
* @param string $source 새 버전 콘텐츠 디렉토리
* @param string $dest 활성 디렉토리
* @param array $failures 삭제 실패 경로 수집 (참조)
*/
private static function removeStaleEntries(string $source, string $dest, array &$failures): void
{
if (! is_dir($dest)) {
return;
}
$items = new \FilesystemIterator($dest, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
$counterpart = $source.DIRECTORY_SEPARATOR.$item->getBasename();
if ($item->isDir()) {
if (is_dir($counterpart)) {
self::removeStaleEntries($counterpart, $item->getPathname(), $failures);
} else {
File::deleteDirectory($item->getPathname());
if (is_dir($item->getPathname())) {
$failures[] = $item->getPathname();
}
}
} elseif (! is_file($counterpart)) {
if (! self::deleteFileWithFallback($item->getPathname())) {
$failures[] = $item->getPathname();
}
}
}
}
/**
* 이전 실패 실행이 남긴 교체용 임시 디렉토리를 정리합니다 (best-effort).
*
* 잠금으로 인해 삭제하지 못한 `_updating_`/`_old_` 디렉토리는 다음 교체 시작
* 시점(잠금이 풀린 뒤일 가능성이 높음)에 다시 삭제를 시도합니다.
* 호출자가 소유한 스테이징 디렉토리(`{identifier}_{Ymd_His}`)는 건드리지 않습니다.
*
* @param string $pendingPath _pending 디렉토리 경로
* @param string $identifier 확장 식별자
*/
private static function cleanupSwapLeftovers(string $pendingPath, string $identifier): void
{
foreach (['_updating_', '_old_'] as $marker) {
$pattern = $pendingPath.DIRECTORY_SEPARATOR.$identifier.$marker.'*';
foreach (glob($pattern) ?: [] as $leftover) {
if (is_dir($leftover)) {
self::bestEffortDeleteDirectory($leftover);
}
}
}
}
/**
* 디렉토리를 삭제하되, 실패해도 예외를 던지지 않습니다.
*
* 잠금으로 삭제하지 못한 디렉토리는 다음 교체 시작 시 잔존물 정리가 재시도합니다.
*
* @param string $path 삭제할 디렉토리 경로
*/
private static function bestEffortDeleteDirectory(string $path): void
{
if (! File::isDirectory($path)) {
return;
}
File::deleteDirectory($path);
if (File::isDirectory($path)) {
Log::info('디렉토리를 완전히 삭제하지 못했습니다 (다음 교체 시 잔존물 정리에서 재시도)', [
'path' => $path,
]);
}
}
/**
* 확장 디렉토리를 삭제합니다.
*
@@ -353,6 +642,7 @@ class ExtensionPendingHelper
*
* @param string $basePath 확장 타입의 기본 경로
* @param string $identifier 확장 식별자
* @return bool _pending 존재 여부
*/
public static function isPending(string $basePath, string $identifier): bool
{
@@ -364,6 +654,7 @@ class ExtensionPendingHelper
*
* @param string $basePath 확장 타입의 기본 경로
* @param string $identifier 확장 식별자
* @return bool _bundled 존재 여부
*/
public static function isBundled(string $basePath, string $identifier): bool
{
@@ -375,6 +666,7 @@ class ExtensionPendingHelper
*
* @param string $basePath 확장 타입의 기본 경로
* @param string $identifier 확장 식별자
* @return string _pending 절대 경로
*/
public static function getPendingPath(string $basePath, string $identifier): string
{
@@ -386,6 +678,7 @@ class ExtensionPendingHelper
*
* @param string $basePath 확장 타입의 기본 경로
* @param string $identifier 확장 식별자
* @return string _bundled 절대 경로
*/
public static function getBundledPath(string $basePath, string $identifier): string
{
+1 -1
View File
@@ -231,7 +231,7 @@ return [
|
*/
'version' => env('APP_VERSION', '7.0.6'),
'version' => env('APP_VERSION', '7.0.7'),
/*
|--------------------------------------------------------------------------
+12 -2
View File
@@ -359,12 +359,22 @@ public static function getBundledPath(string $basePath, string $identifier): str
| 실패 시점 | 결과 |
| --------- | ---- |
| 1단계 실패 (복사) | 기존 디렉토리 **온전히 보존**, `_pending/` 내 임시 디렉토리 정리 후 예외 |
| 2단계 실패 (rename) | 기존 디렉토리 **온전히 보존**, `_pending/` 내 임시만 정리 |
| 3단계 실패 (rename) | `_pending/` 내 _old를 원래 위치로 **롤백**, 예외 |
| 2단계 실패 (rename) | **파일 단위 제자리 동기화로 폴백** — 새 버전 파일을 활성 디렉토리에 직접 덮어쓰고, 새 버전에 없는 잔존 파일 제거 |
| 3단계 실패 (rename) | **파일 단위 복사로 폴백** — 스테이징 내용을 활성 경로에 파일별 복사. 복사까지 실패하면 _old 를 원래 위치로 **롤백**, 예외 |
> **Windows 참고**: `deleteDirectory` 직후 같은 이름으로 `rename`이 실패하는 타이밍 이슈가 있어, delete→copy 대신 rename→rename→delete 패턴을 사용합니다.
> **오토로드 안전성**: 임시 디렉토리가 `_pending/` 하위에 생성되므로, IDE 잠금 등으로 잔존하더라도 `str_starts_with($name, '_')` 필터에 의해 오토로드에서 자동 제외됩니다.
#### Windows 파일 잠금 폴백 (rename 차단 시)
Windows 에서 디렉토리 rename 은 하위 트리에 열린 핸들(파일 워처의 디렉토리 핸들, 편집기·개발 도구가 열어 둔 파일 등)이 하나라도 있으면 실패한다. 잠금 프로세스의 식별·종료는 신뢰할 수 없고(디렉토리 핸들은 Restart Manager API 로 감지되지 않는다) 사용자의 도구를 예고 없이 종료시키는 부작용이 있으므로, **rename 이 차단되면 프로세스를 종료하는 대신 파일 단위 연산으로 폴백**한다:
- 파일 생성·덮어쓰기·읽기는 디렉토리 핸들 잠금의 영향을 받지 않는다 (Windows 의 일반적인 파일 열기 모드는 읽기/쓰기 공유를 허용).
- 덮어쓰기가 막힌 개별 파일은 삭제 후 재생성 → 옆으로 치우기(rename) 순으로 단계적 재시도한다.
- 새 버전 파일을 심지 못하면 예외로 전파해 호출자(매니저)가 백업 복원으로 수습하고, 잔존 파일 삭제 실패는 로그만 남기고 진행한다 (업데이트 전체 실패보다 잔존이 낫다).
- 폴백 경로는 원자적이지 않다 — 파일별로 순차 교체되므로 교체 도중의 요청은 신·구 파일이 섞인 상태를 볼 수 있다. 프로덕션(Linux)에서는 rename 이 차단되지 않아 항상 원자적 fast path 를 탄다.
- 잠금으로 삭제하지 못한 `_updating_*`/`_old_*` 임시 디렉토리는 다음 교체 시작 시 자동으로 재정리된다.
이 메서드는 다음 위치에서 공통으로 사용됩니다:
- `ExtensionBackupHelper::restoreFromBackup()` — 백업 복원
+30 -2
View File
@@ -15,7 +15,8 @@
7. [레이아웃 연동](#7-레이아웃-연동)
8. [백엔드에서 설정 조회](#8-백엔드에서-설정-조회)
9. [카탈로그 병합 설정의 공개 응답](#9-카탈로그-병합-설정의-공개-응답)
10. [관련 문서](#10-관련-문서)
10. [항목 단위 보충 병합과 삭제 의도](#10-항목-단위-보충-병합과-삭제-의도)
11. [관련 문서](#11-관련-문서)
---
@@ -593,7 +594,34 @@ GET /api/modules/{id}/settings/payment # 고아 제외 (정상)
---
## 10. 관련 문서
## 10. 항목 단위 보충 병합과 삭제 의도
`defaults.json` 의 목록 설정(정수키 리스트)은 저장 시 `array_merge` 가 병합이 아니라 통째 교체를 수행한다. 그래서 관리자가 목록에서 일부 항목을 빼고 저장하면 defaults 항목이 저장본에서 사라지고, 그 상태가 그대로 유지된다. 이 통째 교체가 "관리자가 편집한 목록이 그대로 남는다" 는 뜻이라 대부분의 목록에는 옳은 동작이다.
문제는 여기에 **항목 단위 보충 병합**(저장본에 없는 defaults 항목을 code/key 기준으로 다시 채워 넣는 처리)을 얹을 때다. 보충은 보통 "의도치 않은 소실 복구" 를 목적으로 도입되는데, 소실과 삭제는 저장본에서 똑같이 "그 항목이 없다" 로 보이기 때문에 구분할 근거가 없다. 그 결과 관리자의 의도적 삭제까지 되돌아간다.
| ❌ 금지 | ✅ 올바른 사용 |
| --- | --- |
| 보충 병합이 저장본에 없는 defaults 항목을 무조건 다시 채움 | 저장 시점에 삭제 의도를 도출해 저장본에 기록하고, 병합이 그 기록을 존중 |
| 삭제 기록을 클라이언트 제출값에서 받음 | 서버가 `defaults 항목 − 제출 항목 − 삭제 불가 항목` 으로 재계산 (제출된 같은 키는 버린다) |
| 그 목록 키가 제출되지 않은 저장에서 기록만 이월 | 목록 값과 기록을 **함께** 이월 — 저장이 파일 통째 교체라 목록 키가 사라지면 defaults 가 다시 들어와 삭제가 전부 부활한다 |
| 항상 살아 있어야 하는 항목(기본 통화 등)까지 기록 대상에 포함 | 그 항목은 기록에서 제외해 보충 경로로 항상 생존시킨다 |
| 삭제 기록을 공개 응답에 그대로 노출 | `frontend_schema` 의 필드 화이트리스트로 차단 (관리자 응답에는 남겨도 무해) |
| 기록 배열을 비연속 키로 저장 | `array_values()` 로 재정렬 — 비연속 키는 JSON 객체로 직렬화되어 화면 반복이 깨진다 |
이 결함은 예외도 경고도 로그도 남기지 않는다. 저장 요청은 200 을 반환하고, 그 응답 본문에 이미 삭제한 항목이 되살아나 있다.
보충 병합과 통째 교체를 혼동하지 않도록, 목록 설정을 다룰 때는 다음 세 가지를 구분해 판정한다.
| 모델 | 조회 시 처리 | 화면 | 삭제 의미 |
| --- | --- | --- | --- |
| 통째 교체 | 저장본을 그대로 사용 | 항목 추가·삭제 | 삭제가 그대로 유지된다 |
| 항목 단위 보충 | 저장본에 없는 defaults 항목을 채움 | 항목 추가·삭제 | **삭제 기록이 없으면 되돌아간다** |
| 카탈로그 병합 | 확장이 등록한 카탈로그와 저장값을 합침 | `is_active` 토글 | 삭제 개념이 없다 (9장 참조) |
---
## 11. 관련 문서
- [모듈 기초](module-basics.md) - 모듈 구조, AbstractModule
- [모듈 라우트](module-routing.md) - API 라우트 규칙
@@ -4,6 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.1.1] - 2026-08-11
### Fixed
- 언어/통화 설정에서 기본 제공 통화(달러·엔·위안·유로)를 삭제해도 저장 직후 다시 나타나던 문제를 수정했습니다. 삭제한 통화는 설정 화면과 쇼핑몰 화면 양쪽에서 유지되며, 통화 추가로 다시 등록하면 복원됩니다. 사용 중지된 통화를 표시 통화로 갖고 있던 구매자의 주문은 기본 통화로 안전하게 진행됩니다. (#91 @koojunho 님께서 제보해주셨습니다.)
- 통화를 삭제한 뒤 그 통화로 결제된 과거 주문의 금액 표기가 달라지던 문제를 수정했습니다. 주문·환불 금액의 소수 자릿수는 주문 시점 기준을 유지하므로, 엔화처럼 소수점을 쓰지 않는 통화가 `¥14,835.00` 으로 바뀌거나 소수점 이하 자릿수가 많은 통화의 표시 금액이 잘리지 않습니다. (#91 @koojunho 님께서 제보해주셨습니다.)
## [1.1.0] - 2026-08-10
### Security
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-ecommerce",
"description": "Ecommerce module for Gnuboard7",
"type": "library",
"version": "1.1.0",
"version": "1.1.1",
"license": "MIT",
"autoload": {
"psr-4": {
@@ -43,7 +43,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙). `removed_default_currencies` 는 관리자가 삭제한 기본 제공 통화 코드 목록으로, 서버가 저장 시점에 도출해 기록한다 (관리자 응답 전용 — 공개 설정에는 노출되지 않음) |
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료·현금영수증 발급 제공자·자진발급·배송비 과세 방식 등) |
| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) |
| seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) |
@@ -452,6 +452,7 @@ HTTP/1.1 200
| language_currency | body | array | 아니오 | — | 통화 설정 섹션 (기본 통화·통화 목록: 코드·다국어명·환율·반올림 규칙·통화별 로케일) |
| language_currency.default_currency | body | string | 아니오 | max 10 | 쇼핑몰 기본(base) 통화 코드. 상품/주문이 1건이라도 생성된 뒤에는 변경 불가 |
| language_currency.currencies | body | array | 아니오 | — | 등록 통화 목록. 항목별 `code`(ISO 4217 3자리 대문자, 필수)·`name`(다국어 배열, 필수)·`symbol`·`exchange_rate`·`base_unit`·`rounding_unit`·`rounding_method`(`floor`\|`round`\|`ceil`)·`decimal_places`·`is_default`·`locales` |
| language_currency.removed_default_currencies | body | array | — | — | 서버 관리 필드. 요청에 실어 보내도 무시되며, 제출된 `currencies` 와 기본 제공 통화 목록의 차집합으로 서버가 재계산한다. `currencies` 를 보내지 않은 저장은 기존 값을 그대로 이월한다 |
| seo | body | array | 아니오 | — | SEO 메타 설정 섹션 (페이지 유형별 메타 타이틀/설명·SEO 활성 토글) |
| seo.meta_category_title | body | string | 아니오 | max 500 | 카테고리 페이지 메타 Title (`{commerce_name}`·`{category_name}` 등 변수 사용 가능) |
| seo.meta_category_description | body | string | 아니오 | max 1000 | 카테고리 페이지 메타 Description |
@@ -655,7 +656,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙). `removed_default_currencies` 는 관리자가 삭제한 기본 제공 통화 코드 목록으로, 서버가 저장 시점에 도출해 기록한다 (관리자 응답 전용 — 공개 설정에는 노출되지 않음) |
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료·현금영수증 발급 제공자·자진발급·배송비 과세 방식 등) |
| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) |
| seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) |
@@ -1071,7 +1072,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙). `removed_default_currencies` 는 관리자가 삭제한 기본 제공 통화 코드 목록으로, 서버가 저장 시점에 도출해 기록한다 (관리자 응답 전용 — 공개 설정에는 노출되지 않음) |
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료·현금영수증 발급 제공자·자진발급·배송비 과세 방식 등) |
| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) |
| seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) |
@@ -5,7 +5,7 @@
"ko": "이커머스",
"en": "Ecommerce"
},
"version": "1.1.0",
"version": "1.1.1",
"license": "MIT",
"description": {
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-ecommerce",
"version": "1.1.0",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-ecommerce",
"version": "1.1.0",
"version": "1.1.1",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-ecommerce",
"version": "1.1.0",
"version": "1.1.1",
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -39,6 +39,9 @@ class GuestOrderResource extends BaseApiResource
{
// 주문 시점 기준 통화 — 과거 주문의 *_formatted 는 설정 변경과 무관하게 이 통화로 고정 표기한다.
$orderCurrency = $this->resolveOrderBaseCurrencyCode($this->resource);
// 주문 시점 통화 스냅샷 — 소수 자릿수를 현재 설정이 아닌 주문 시점 값으로 고정 (공개 #91 후속).
$currencySnapshot = $this->resource->currency_snapshot ?? null;
$this->withCurrencySnapshot($currencySnapshot);
// 결제 통화(order_currency) — 현금영수증 금액 표기 기준(구매자가 실제 낸 통화).
$paymentCurrency = $this->currency
@@ -118,14 +121,14 @@ class GuestOrderResource extends BaseApiResource
// 주문 옵션 (품목) — 주문 시점 통화를 자식에 전파
'options' => $this->whenLoaded('options', fn () => OrderOptionResource::collection($this->options)->each(
fn ($r) => $r->withOrderCurrency($orderCurrency)
fn ($r) => $r->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)
)),
// 배송지/결제/배송 정보
'shipping_address' => new OrderAddressResource($this->whenLoaded('shippingAddress')),
'payment' => $this->whenLoaded('payment', fn () => (new OrderPaymentResource($this->payment))->withOrderCurrency($orderCurrency)),
'payment' => $this->whenLoaded('payment', fn () => (new OrderPaymentResource($this->payment))->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)),
'shippings' => $this->whenLoaded('shippings', fn () => OrderShippingResource::collection($this->shippings)->each(
fn ($r) => $r->withOrderCurrency($orderCurrency)
fn ($r) => $r->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)
)),
// 취소 이력 (취소 사유·상세 사유·취소일시 표시용) — 최근 취소가 먼저 오도록 정렬
@@ -141,13 +144,13 @@ class GuestOrderResource extends BaseApiResource
$active = OrderCashReceipt::filterActive($this->cashReceipts)[0] ?? null;
return $active
? (new CashReceiptResource($active))->withOrderCurrency($orderCurrency)->withReceiptCurrency($paymentCurrency)
? (new CashReceiptResource($active))->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)->withReceiptCurrency($paymentCurrency)
: null;
}),
// 발급/취소 전체 이력 — 카드의 "직전 발급이 실패했는가" 분기가 cash_receipts[0] 를 본다.
'cash_receipts' => $this->whenLoaded('cashReceipts', fn () => CashReceiptResource::collection(
$this->cashReceipts->sortByDesc('id')->values()
)->each(fn ($r) => $r->withOrderCurrency($orderCurrency)->withReceiptCurrency($paymentCurrency))),
)->each(fn ($r) => $r->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)->withReceiptCurrency($paymentCurrency))),
// 배송정책 — **표시용 필드만**. 비회원 주문 상세 화면은 회원 마이페이지와 같은
// partial 을 써서 상품별 정책명·개별 배송비를 그리는데, 이 필드를 통째로 빼면
@@ -30,6 +30,9 @@ class OrderListResource extends BaseApiResource
{
// 주문 시점 기준 통화 — 과거 주문의 *_formatted 는 설정 변경과 무관하게 이 통화로 고정 표기한다.
$orderCurrency = $this->resolveOrderBaseCurrencyCode($this->resource);
// 주문 시점 통화 스냅샷 — 소수 자릿수를 현재 설정이 아닌 주문 시점 값으로 고정 (공개 #91 후속).
$currencySnapshot = $this->resource->currency_snapshot ?? null;
$this->withCurrencySnapshot($currencySnapshot);
// 결제 통화(order_currency) — 유저가 선택·결제한 통화. base 통화와 다를 때 함께 표기.
$paymentCurrency = $this->currency
@@ -27,6 +27,9 @@ class OrderResource extends BaseApiResource
{
// 주문 시점 기준 통화 — 과거 주문의 *_formatted 는 설정 변경과 무관하게 이 통화로 고정 표기한다.
$orderCurrency = $this->resolveOrderBaseCurrencyCode($this->resource);
// 주문 시점 통화 스냅샷 — 소수 자릿수를 현재 설정이 아닌 주문 시점 값으로 고정 (공개 #91 후속).
$currencySnapshot = $this->resource->currency_snapshot ?? null;
$this->withCurrencySnapshot($currencySnapshot);
// 결제 통화(order_currency) — 유저가 선택·결제한 통화. base 통화와 다를 때 화면에 함께 표기한다.
$paymentCurrency = $this->currency
@@ -177,7 +180,7 @@ class OrderResource extends BaseApiResource
// 주문 옵션 (품목) — 주문 시점 통화를 자식에 전파
'options' => $this->whenLoaded('options', fn () => OrderOptionResource::collection($this->options)->each(
fn ($r) => $r->withOrderCurrency($orderCurrency)
fn ($r) => $r->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)
)),
// 배송지 정보
@@ -185,9 +188,9 @@ class OrderResource extends BaseApiResource
'billing_address' => new OrderAddressResource($this->whenLoaded('billingAddress')),
// 결제 정보
'payment' => $this->whenLoaded('payment', fn () => (new OrderPaymentResource($this->payment))->withOrderCurrency($orderCurrency)),
'payment' => $this->whenLoaded('payment', fn () => (new OrderPaymentResource($this->payment))->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)),
'payments' => $this->whenLoaded('payments', fn () => OrderPaymentResource::collection($this->payments)->each(
fn ($r) => $r->withOrderCurrency($orderCurrency)
fn ($r) => $r->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)
)),
// 현금영수증 — 현재 활성 영수증 1건(없으면 null). 발급 카드의 "발급완료" 상태 근거.
@@ -195,17 +198,17 @@ class OrderResource extends BaseApiResource
$active = OrderCashReceipt::filterActive($this->cashReceipts)[0] ?? null;
return $active
? (new CashReceiptResource($active))->withOrderCurrency($orderCurrency)->withReceiptCurrency($paymentCurrency)
? (new CashReceiptResource($active))->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)->withReceiptCurrency($paymentCurrency)
: null;
}),
// 발급/취소 전체 이력 — 관리자 화면의 "취소 성공 + 재발급 실패" 경고 배지 판정 근거.
'cash_receipts' => $this->whenLoaded('cashReceipts', fn () => CashReceiptResource::collection(
$this->cashReceipts->sortByDesc('id')->values()
)->each(fn ($r) => $r->withOrderCurrency($orderCurrency)->withReceiptCurrency($paymentCurrency))),
)->each(fn ($r) => $r->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)->withReceiptCurrency($paymentCurrency))),
// 배송 정보
'shippings' => $this->whenLoaded('shippings', fn () => OrderShippingResource::collection($this->shippings)->each(
fn ($r) => $r->withOrderCurrency($orderCurrency)
fn ($r) => $r->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)
)),
// 취소 이력 (취소 사유·상세 사유·취소일시 표시용) — 최근 취소가 먼저 오도록 정렬
@@ -20,6 +20,14 @@ trait HasMultiCurrencyPrices
*/
protected ?string $orderCurrencyCode = null;
/**
* 부모 주문 리소스가 주입한 주문 시점 통화 스냅샷 (공개 #91 후속).
*
* 소수 자릿수를 현재 설정이 아니라 주문 시점 값으로 고정하기 위해 전파한다.
* null 이면 현재 설정으로 폴백 — 상품 등 카탈로그 경로는 그것이 정답이다.
*/
protected ?array $currencySnapshot = null;
/**
* 주문 시점 기준 통화 코드를 주입합니다 (부모 → 자식 리소스 전파).
*
@@ -33,6 +41,23 @@ trait HasMultiCurrencyPrices
return $this;
}
/**
* 주문 시점 통화 스냅샷을 주입합니다 (부모 → 자식 리소스 전파).
*
* 관리자가 통화를 삭제하면 현재 설정에서 사라져 자릿수가 폴백 2자리로 바뀐다.
* 그러면 0자리 통화로 결제된 과거 주문의 표기가 `¥14,835` → `¥14,835.00` 이 되고,
* 3자리 이상 통화는 표시 금액이 절사된다. 스냅샷이 있으면 그것을 SSoT 로 삼는다.
*
* @param array|null $snapshot 주문의 `currency_snapshot`
* @return $this 메서드 체이닝을 위한 자기 자신
*/
public function withCurrencySnapshot(?array $snapshot): static
{
$this->currencySnapshot = $snapshot;
return $this;
}
/**
* 다중 통화 가격 정보를 생성합니다.
*
@@ -302,11 +327,20 @@ trait HasMultiCurrencyPrices
/**
* 통화의 소수 자릿수를 반환합니다.
*
* 주문 시점 스냅샷이 주입돼 있고 그 통화의 자릿수를 박제해 두었으면 그것이 SSoT 다
* (공개 #91 후속 — 삭제된 통화가 현재 설정에 없어 폴백 2자리로 바뀌는 것을 막는다).
* 스냅샷이 없으면(상품 등 카탈로그 경로) 현재 설정을 그대로 따른다.
*
* @param string $code 통화 코드
* @return int 소수 자릿수 (기본값: 2)
*/
protected function getDecimalPlacesForCurrency(string $code): int
{
$snapshotPlaces = $this->currencySnapshot['exchange_rates'][$code]['decimal_places'] ?? null;
if ($snapshotPlaces !== null) {
return (int) $snapshotPlaces;
}
$currencies = $this->getCurrencySettings();
foreach ($currencies as $currency) {
@@ -5,9 +5,11 @@ namespace Modules\Sirsoft\Ecommerce\Http\Resources;
use App\Helpers\PermissionHelper;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\MissingValue;
use Modules\Sirsoft\Ecommerce\Http\Resources\Concerns\LocalizesCountryName;
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\SummarizesAdditionalOptions;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
/**
* 사용자 주문 목록 리소스
@@ -31,6 +33,9 @@ class UserOrderListResource extends BaseApiResource
{
// 주문 시점 기준 통화 — 과거 주문의 *_formatted 는 설정 변경과 무관하게 이 통화로 고정 표기한다.
$orderCurrency = $this->resolveOrderBaseCurrencyCode($this->resource);
// 주문 시점 통화 스냅샷 — 소수 자릿수를 현재 설정이 아닌 주문 시점 값으로 고정 (공개 #91 후속).
$currencySnapshot = $this->resource->currency_snapshot ?? null;
$this->withCurrencySnapshot($currencySnapshot);
// 컬렉션(UserOrderCollection)이 이 배열을 그대로 응답에 실어 Laravel 의 MissingValue
// 제거 단계를 거치지 않는다 — 미충족 조건부 필드를 직접 걸러낸다. 걸러내지 않으면
@@ -98,7 +103,7 @@ class UserOrderListResource extends BaseApiResource
* 주문" 이라는 사실 아닌 단언이 된다.
*
* @param string|null $orderCurrency 주문 시점 기준 통화
* @return array<int, array<string, mixed>>|\Illuminate\Http\Resources\MissingValue 아이템 배열
* @return array<int, array<string, mixed>>|MissingValue 아이템 배열
*/
private function resolveItems(?string $orderCurrency)
{
@@ -117,13 +122,13 @@ class UserOrderListResource extends BaseApiResource
: [];
}
return new \Illuminate\Http\Resources\MissingValue;
return new MissingValue;
}
/**
* 주문 아이템 1건을 표시용 배열로 변환합니다.
*
* @param \Modules\Sirsoft\Ecommerce\Models\OrderOption $option 주문 옵션
* @param OrderOption $option 주문 옵션
* @param string|null $orderCurrency 주문 시점 기준 통화
* @return array<string, mixed> 표시용 아이템 배열
*/
@@ -157,7 +162,7 @@ class UserOrderListResource extends BaseApiResource
* 유무는 값이 아니라 속성 키로 판정한다 — 0건의 `0` 과 "집계 안 함" 은 값으로 구분되지
* 않는다.
*
* @return int|\Illuminate\Http\Resources\MissingValue 아이템 수
* @return int|MissingValue 아이템 수
*/
private function resolveItemCount()
{
@@ -169,7 +174,7 @@ class UserOrderListResource extends BaseApiResource
return $this->resource->options->count();
}
return new \Illuminate\Http\Resources\MissingValue;
return new MissingValue;
}
/**
@@ -82,11 +82,23 @@ class CurrencyConversionService
/**
* 통화의 소수 자릿수를 반환합니다.
*
* 주문 시점 스냅샷이 주어지고 그 통화의 자릿수가 박제돼 있으면 그것을 우선합니다.
*
* @param string $code 통화 코드
* @param array|null $currencySnapshot 주문 시점 통화 스냅샷 (없으면 현재 설정 사용)
* @return int 소수 자릿수 (기본값: 2)
*/
public function getDecimalPlaces(string $code): int
public function getDecimalPlaces(string $code, ?array $currencySnapshot = null): int
{
// 주문 시점 스냅샷이 자릿수를 박제해 두었으면 그것이 SSoT (공개 #91 후속).
// 관리자가 그 통화를 삭제하면 현재 설정에서 사라져 폴백 2자리가 적용되는데,
// 그러면 0자리 통화의 과거 주문 표기가 `¥18,860` → `¥18,860.00` 으로 바뀌고
// 3자리 이상 통화는 표시 금액이 절사된다.
$snapshotPlaces = $this->snapshotDecimalPlaces($currencySnapshot, $code);
if ($snapshotPlaces !== null) {
return $snapshotPlaces;
}
$currencies = $this->getCurrencySettings();
foreach ($currencies as $currency) {
@@ -99,6 +111,27 @@ class CurrencyConversionService
return 2;
}
/**
* 통화 스냅샷에 박제된 소수 자릿수를 반환합니다. (공개 #91 후속)
*
* 자릿수를 박제하지 않은 구형 스냅샷(환율이 단순 숫자)은 null 을 돌려주어
* 호출부가 현재 설정 폴백을 그대로 타게 합니다.
*
* @param array|null $currencySnapshot 주문 시점 통화 스냅샷
* @param string $code 통화 코드
* @return int|null 박제된 자릿수 (없으면 null)
*/
private function snapshotDecimalPlaces(?array $currencySnapshot, string $code): ?int
{
$rateData = $currencySnapshot['exchange_rates'][$code] ?? null;
if (! is_array($rateData) || ! isset($rateData['decimal_places'])) {
return null;
}
return (int) $rateData['decimal_places'];
}
/**
* 통화의 base_unit(기본 통화일 때 환율 분모가 되는 1단위 금액)을 반환합니다.
*
@@ -342,12 +375,12 @@ class CurrencyConversionService
$snapshotRate = (float) $rateData;
$roundingUnit = '0.01';
$roundingMethod = 'round';
$decimalPlaces = $this->getDecimalPlaces($orderCurrency);
$decimalPlaces = $this->getDecimalPlaces($orderCurrency, $currencySnapshot);
} else {
$snapshotRate = (float) ($rateData['rate'] ?? 0);
$roundingUnit = $rateData['rounding_unit'] ?? '0.01';
$roundingMethod = $rateData['rounding_method'] ?? 'round';
$decimalPlaces = (int) ($rateData['decimal_places'] ?? $this->getDecimalPlaces($orderCurrency));
$decimalPlaces = (int) ($rateData['decimal_places'] ?? $this->getDecimalPlaces($orderCurrency, $currencySnapshot));
}
$isBase = ($orderCurrency === $baseCurrency);
@@ -355,7 +388,7 @@ class CurrencyConversionService
if ($isBase) {
// base 통화 결제: 환산 없이 그대로. base 의 decimal_places 로 정수화.
$convertedAmount = (float) $baseAmount;
$decimalPlaces = (int) ($rateData['decimal_places'] ?? $this->getDecimalPlaces($orderCurrency));
$decimalPlaces = (int) ($rateData['decimal_places'] ?? $this->getDecimalPlaces($orderCurrency, $currencySnapshot));
$snapshotRate = 1.0;
} else {
if ($snapshotRate <= 0) {
@@ -433,11 +466,15 @@ class CurrencyConversionService
/**
* 통화별 가격을 포맷팅합니다.
*
* 주문 시점 스냅샷을 넘기면 소수 자릿수를 그 시점 값으로 고정합니다 — 통화가 삭제되어도
* 과거 주문의 표기가 바뀌지 않습니다. 상품 등 카탈로그 표시는 스냅샷 없이 호출합니다.
*
* @param float|int $price 가격
* @param string $code 통화 코드
* @param array|null $currencySnapshot 주문 시점 통화 스냅샷 (없으면 현재 설정 사용)
* @return string 포맷팅된 가격
*/
public function formatPrice(float|int $price, string $code): string
public function formatPrice(float|int $price, string $code, ?array $currencySnapshot = null): string
{
$prefix = __('sirsoft-ecommerce::messages.currency.prefix.'.$code, [], app()->getLocale());
$suffix = __('sirsoft-ecommerce::messages.currency.suffix.'.$code, [], app()->getLocale());
@@ -450,7 +487,7 @@ class CurrencyConversionService
$suffix = '';
}
$decimalPlaces = $this->getDecimalPlaces($code);
$decimalPlaces = $this->getDecimalPlaces($code, $currencySnapshot);
$formattedNumber = number_format($price, $decimalPlaces);
// prefix나 suffix가 없으면 기본 포맷
@@ -507,7 +544,7 @@ class CurrencyConversionService
foreach ($amounts as $field => $baseAmount) {
if ($isDefault) {
$currencyAmounts[$field] = $baseAmount;
$currencyAmounts[$field.'_formatted'] = $this->formatPrice($baseAmount, $code);
$currencyAmounts[$field.'_formatted'] = $this->formatPrice($baseAmount, $code, $currencySnapshot);
} else {
if ($snapshotRate > 0) {
$convertedPrice = ($baseAmount / $baseUnit) * $snapshotRate;
@@ -517,7 +554,7 @@ class CurrencyConversionService
$roundingMethod
);
$currencyAmounts[$field] = $convertedAmount;
$currencyAmounts[$field.'_formatted'] = $this->formatPrice($convertedAmount, $code);
$currencyAmounts[$field.'_formatted'] = $this->formatPrice($convertedAmount, $code, $currencySnapshot);
}
}
}
@@ -10,6 +10,7 @@ use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\ShippingFeeTaxPolicy;
use Modules\Sirsoft\Ecommerce\Support\CurrencySettingsCache;
/**
* 이커머스 모듈 환경설정 서비스
@@ -28,6 +29,11 @@ class EcommerceSettingsService implements ModuleSettingsInterface
*/
private const MODULE_IDENTIFIER = 'sirsoft-ecommerce';
/**
* 관리자가 삭제한 기본 제공 통화 코드 목록의 저장 키 (공개 #91)
*/
private const REMOVED_CURRENCIES_KEY = 'removed_default_currencies';
/**
* 설정 기본값 (캐시)
*/
@@ -119,10 +125,14 @@ class EcommerceSettingsService implements ModuleSettingsInterface
// language_currency.currencies 는 정수키 리스트라 array_merge 가 통째 교체 →
// defaults 에 있고 저장본에 없는 통화는 code 기준으로 보충(환율은 저장본 우선 보존).
// 관리자가 의도적으로 삭제한 게 아니라 array_merge 부작용으로 소실되던 영속성 공백 수정(U11-A).
// 단, 관리자가 실제로 삭제한 통화는 저장 시점에 기록되므로 보충 대상에서 뺀다(공개 #91).
if (isset($defaultValues['language_currency']['currencies'])) {
$removedCodes = $settings['language_currency'][self::REMOVED_CURRENCIES_KEY] ?? [];
$settings['language_currency']['currencies'] = $this->mergeCurrenciesByCode(
$defaultValues['language_currency']['currencies'],
$settings['language_currency']['currencies'] ?? []
$settings['language_currency']['currencies'] ?? [],
is_array($removedCodes) ? $removedCodes : []
);
}
@@ -150,7 +160,8 @@ class EcommerceSettingsService implements ModuleSettingsInterface
);
}
// 셀렉터(_currency_selector.json)가 참조하는 symbol/flag 보강
// (settings 스키마에 없는 표시 메타 — 저장 시 normalize 가 떨궈 round-trip 오염 없음)
// (flag 는 저장 규칙에 없는 표시 메타 — 폼이 되돌려 보내도 FormRequest 검증에서
// 탈락해 round-trip 오염이 없다)
if (! empty($currency['code'])) {
$meta = $this->currencyDisplayMeta($currency['code']);
// 관리자가 직접 지정한 기호는 보존, 없을 때만 표준 매핑으로 보충
@@ -263,6 +274,11 @@ class EcommerceSettingsService implements ModuleSettingsInterface
);
}
// language_currency: 관리자가 삭제한 기본 제공 통화를 서버가 도출해 기록 (공개 #91)
if ($category === 'language_currency') {
$processedSettings = $this->applyRemovedDefaultCurrencies($processedSettings);
}
if (! $this->saveCategorySettings($category, $processedSettings)) {
$success = false;
}
@@ -271,9 +287,84 @@ class EcommerceSettingsService implements ModuleSettingsInterface
// 캐시 초기화
$this->settings = null;
// 통화 목록이 바뀌었을 수 있으므로 요청 단위 통화 캐시도 함께 비운다.
// (리소스 계층이 이 캐시로 다통화 금액을 만들기 때문에, 비우지 않으면 같은 요청 안에서
// 저장 전 통화 구성으로 금액이 계산된다)
CurrencySettingsCache::clear();
return $success;
}
/**
* 관리자가 삭제한 기본 제공 통화를 저장본에 기록합니다. (공개 #91)
*
* 조회 병합(mergeCurrenciesByCode)은 저장본에 없는 defaults 통화를 무조건 보충하는데,
* 그 보충은 "array_merge 통째 교체로 인한 의도치 않은 소실"(U11-A)을 되돌리기 위한 것이다.
* 관리자의 의도적 삭제와 구분할 장치가 없으면 삭제까지 되돌아가므로, 저장 시점에
* 삭제 의도를 도출해 남긴다.
*
* - 클라이언트가 보낸 같은 키는 신뢰하지 않고 항상 서버가 재계산한다.
* - `currencies` 미제출 저장(기본 통화만 변경 등)은 기존 통화 목록과 기록을 함께 이월한다.
* 저장이 파일 통째 교체이므로 이월하지 않으면 저장본에서 두 키가 모두 사라지고, 조회
* 병합이 defaults 를 그대로 들여와 삭제가 전부 부활한다.
* - 기본 통화 코드는 기록 대상에서 제외한다 (기본 통화는 항상 생존해야 한다).
*
* @param array $processed 정규화까지 끝난 language_currency 저장값
* @return array 삭제 기록이 반영된 저장값
*/
private function applyRemovedDefaultCurrencies(array $processed): array
{
// 클라이언트 주입 방어 — 검증에서 떨어지지만 프로그램 직접 호출 경로도 막는다
unset($processed[self::REMOVED_CURRENCIES_KEY]);
$saved = $this->loadCategorySettings('language_currency');
$defaultValues = $this->getDefaults()['defaults']['language_currency'] ?? [];
// currencies 미제출 → 기존 통화 목록과 삭제 기록을 함께 이월
if (! isset($processed['currencies']) || ! is_array($processed['currencies'])) {
$carried = $saved[self::REMOVED_CURRENCIES_KEY] ?? [];
$processed[self::REMOVED_CURRENCIES_KEY] = array_values(array_filter(
is_array($carried) ? $carried : [],
'is_string'
));
if (isset($saved['currencies']) && is_array($saved['currencies'])) {
$processed['currencies'] = array_values($saved['currencies']);
// 같은 저장에서 기본 통화가 바뀌었을 수 있으므로 is_default 를 다시 맞춘다
$processed = $this->syncCurrencyDefaults($processed);
}
return $processed;
}
// 제출된 통화 코드 집합
$submitted = [];
foreach ($processed['currencies'] as $currency) {
$code = is_array($currency) ? ($currency['code'] ?? null) : null;
if (is_string($code) && $code !== '') {
$submitted[$code] = true;
}
}
// 유효 기본 통화: 제출값 → 기존 저장본 → defaults 순
$baseCode = $processed['default_currency']
?? ($saved['default_currency'] ?? ($defaultValues['default_currency'] ?? null));
$removed = [];
foreach ($defaultValues['currencies'] ?? [] as $defaultCurrency) {
$code = $defaultCurrency['code'] ?? null;
if (! is_string($code) || $code === '' || isset($submitted[$code]) || $code === $baseCode) {
continue;
}
$removed[] = $code;
}
// 비연속 키가 JSON 객체로 직렬화되지 않도록 재정렬
$processed[self::REMOVED_CURRENCIES_KEY] = array_values($removed);
return $processed;
}
/**
* 은행 목록만 저장합니다.
*
@@ -1105,12 +1196,17 @@ class EcommerceSettingsService implements ModuleSettingsInterface
* - 저장본에 있는 통화: 저장본(환율 포함) 채택 (관리자 편집 보존)
* - 저장본에 없는 defaults 통화: defaults 항목 보충 (소실 방지)
* - 저장본에만 있는(관리자 신규 추가) 통화: 그대로 보존
* - 관리자가 삭제한 defaults 통화($removedCodes): 보충하지 않음 (공개 #91)
*
* 삭제 기록에 있어도 저장본에 그 통화가 다시 들어 있으면 저장본을 우선해 되살린다 —
* 재추가 저장의 결과가 즉시 반영되어야 하기 때문이다(기록은 다음 저장에서 재계산된다).
*
* @param array $defaults defaults.json 의 통화 목록
* @param array $saved 저장본 통화 목록
* @param array $removedCodes 관리자가 삭제한 기본 제공 통화 코드 목록
* @return array code 기준으로 병합된 통화 목록
*/
private function mergeCurrenciesByCode(array $defaults, array $saved): array
private function mergeCurrenciesByCode(array $defaults, array $saved, array $removedCodes = []): array
{
// defaults 를 code 인덱스로 매핑 (필드 보충용)
$defaultsByCode = [];
@@ -1139,6 +1235,14 @@ class EcommerceSettingsService implements ModuleSettingsInterface
if ($code === null) {
continue;
}
// 관리자가 삭제한 통화는 보충하지 않는다 (저장본에 있으면 재추가된 것이므로 채택)
if (! isset($savedByCode[$code]) && in_array($code, $removedCodes, true)) {
$usedCodes[$code] = true;
continue;
}
$merged[] = $this->backfillBaseUnit($savedByCode[$code] ?? $defaultCurrency, $defaultsByCode[$code] ?? []);
$usedCodes[$code] = true;
}
@@ -524,15 +524,15 @@ class OrderAdjustmentService
if ($mcOriginalSnapshot) {
$zeroFormattedSubtotal = [];
foreach ($mcOriginalSnapshot['mc_subtotal_amount'] ?? [] as $code => $amount) {
$zeroFormattedSubtotal[$code] = $this->currencyService->formatPrice(0, $code);
$zeroFormattedSubtotal[$code] = $this->currencyService->formatPrice(0, $code, $currencySnapshot);
}
$zeroFormattedTotalPaid = [];
foreach ($mcOriginalSnapshot['mc_total_paid_amount'] ?? [] as $code => $amount) {
$zeroFormattedTotalPaid[$code] = $this->currencyService->formatPrice(0, $code);
$zeroFormattedTotalPaid[$code] = $this->currencyService->formatPrice(0, $code, $currencySnapshot);
}
$zeroFormattedListPrice = [];
foreach ($mcOriginalSnapshot['mc_total_list_price_amount'] ?? [] as $code => $amount) {
$zeroFormattedListPrice[$code] = $this->currencyService->formatPrice(0, $code);
$zeroFormattedListPrice[$code] = $this->currencyService->formatPrice(0, $code, $currencySnapshot);
}
$zeroMcSnapshot = [
@@ -1014,7 +1014,7 @@ class OrderAdjustmentService
$restoredCoupons[] = [
'coupon_name' => $issue->coupon?->getLocalizedName() ?? '',
'discount_amount' => $discountAmount,
'discount_amount_formatted' => $this->currencyService->formatPrice($discountAmount, $baseCurrency),
'discount_amount_formatted' => $this->currencyService->formatPrice($discountAmount, $baseCurrency, $currencySnapshot),
];
}
@@ -1093,7 +1093,7 @@ class OrderAdjustmentService
'name' => $coupon['name'] ?? '',
'target_type' => $coupon['target_type'] ?? '',
'discount_amount' => $discountAmount,
'discount_amount_formatted' => $this->currencyService->formatPrice($discountAmount, $baseCurrency),
'discount_amount_formatted' => $this->currencyService->formatPrice($discountAmount, $baseCurrency, $currencySnapshot),
];
}
}
@@ -1119,7 +1119,7 @@ class OrderAdjustmentService
$formatted = [];
foreach ($snapshot as $key => $value) {
if (is_numeric($value)) {
$formatted[$key] = $this->currencyService->formatPrice((float) $value, $baseCurrency);
$formatted[$key] = $this->currencyService->formatPrice((float) $value, $baseCurrency, $currencySnapshot);
}
}
@@ -1145,7 +1145,7 @@ class OrderAdjustmentService
$base = [];
foreach ($amounts as $field => $value) {
$base[$field] = $this->currencyService->formatPrice((float) $value, $baseCurrency);
$base[$field] = $this->currencyService->formatPrice((float) $value, $baseCurrency, $currencySnapshot);
}
$mc = [];
@@ -1184,12 +1184,12 @@ class OrderAdjustmentService
$formattedSubtotal = [];
foreach ($order->mc_subtotal_amount ?? [] as $code => $amount) {
$formattedSubtotal[$code] = $this->currencyService->formatPrice($amount, $code);
$formattedSubtotal[$code] = $this->currencyService->formatPrice($amount, $code, $order->currency_snapshot);
}
$formattedTotalPaid = [];
foreach ($order->mc_total_paid_amount ?? [] as $code => $amount) {
$formattedTotalPaid[$code] = $this->currencyService->formatPrice($amount, $code);
$formattedTotalPaid[$code] = $this->currencyService->formatPrice($amount, $code, $order->currency_snapshot);
}
// 정가 합계 다통화 변환
@@ -386,6 +386,11 @@ class OrderProcessingService
*
* 게스트 체크아웃(비로그인)은 유저 컨텍스트 부재 → 헤더/base 로 안전 폴백한다.
*
* 해석 결과가 현재 등록된 통화가 아니면 base 로 폴백한다 (공개 #91). 관리자가 통화를
* 삭제해도 유저의 영속 선호 통화와 클라이언트 헤더에는 그 코드가 남아 있는데, 그대로
* 채택하면 스냅샷 환율에 코드가 없어 체크아웃이 차단된다. 저장된 선호 값 자체는 지우지
* 않는다 — 그 통화가 다시 등록되면 자동으로 복원되는 것이 올바른 동작이다.
*
* @param string $baseCurrency 기본 통화(폴백)
* @return string 결정된 통화 코드
*/
@@ -393,12 +398,15 @@ class OrderProcessingService
{
// 1순위: 로그인 유저의 영속 통화 (§A3 user-profile)
$persisted = $this->resolvePersistedUserCurrency();
if ($persisted !== null && $persisted !== '') {
return $persisted;
}
// 2순위: X-Currency 헤더 (비로그인/세션 표시) → 3순위: base 폴백
return request()->header('X-Currency', $baseCurrency) ?: $baseCurrency;
$resolved = ($persisted !== null && $persisted !== '')
? $persisted
: (request()->header('X-Currency', $baseCurrency) ?: $baseCurrency);
return $this->currencyConversionService->isSupportedCurrency($resolved)
? $resolved
: $baseCurrency;
}
/**
@@ -0,0 +1,81 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Admin;
use App\Models\User;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 통화 삭제 저장 왕복 테스트 (공개 #91)
*
* 제보된 증상은 "저장 응답에 이미 삭제한 통화가 되살아나 있다" 였다. 서비스 단위 테스트만으로는
* 저장 응답이 재조회 결과를 그대로 싣는 이 층을 잡지 못하므로, PUT 응답과 재-GET 양쪽을 검증한다.
*/
class EcommerceSettingsCurrencyDeletionTest extends ModuleTestCase
{
private string $apiBase = '/api/modules/sirsoft-ecommerce/admin/settings';
private User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createAdminUser([
'sirsoft-ecommerce.settings.read',
'sirsoft-ecommerce.settings.update',
]);
}
/**
* 응답 payload 에서 통화 코드 목록을 추출합니다.
*
* @param array|null $currencies 통화 배열
* @return array<int, string> 통화 코드 목록
*/
private function codes(?array $currencies): array
{
return array_values(array_map(fn ($c) => $c['code'] ?? null, $currencies ?? []));
}
/**
* 기본 제공 통화를 삭제해 저장하면 저장 응답과 재조회 양쪽에서 사라진다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects intentionally_removed_default_currency_stays_deleted
*/
public function test_put_response_reflects_currency_deletion(): void
{
$response = $this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'language_currency',
'language_currency' => [
'default_currency' => 'KRW',
'currencies' => [
[
'code' => 'KRW',
'name' => ['ko' => 'KRW (원)', 'en' => 'KRW (Won)'],
'exchange_rate' => null,
'base_unit' => 1000,
'rounding_unit' => '1',
'rounding_method' => 'floor',
'decimal_places' => 0,
'is_default' => true,
],
],
],
]);
$response->assertOk();
// 저장 응답 자체가 삭제를 반영해야 한다 (제보된 증상: 응답에 5종 부활)
$saved = $this->codes($response->json('data.language_currency.currencies'));
$this->assertSame(['KRW'], $saved, '저장 응답에 삭제한 통화가 되살아났습니다.');
// 재조회에서도 유지 (새로고침 시 부활 여부)
$fetched = $this->actingAs($this->adminUser)->getJson($this->apiBase);
$fetched->assertOk();
$this->assertSame(['KRW'], $this->codes($fetched->json('data.language_currency.currencies')), '재조회에서 삭제한 통화가 되살아났습니다.');
}
}
@@ -4,13 +4,12 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Seo;
use App\Jobs\GenerateSitemapJob;
use App\Models\SitemapUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Config;
use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
use Modules\Sirsoft\Ecommerce\Listeners\SeoCategoryCacheListener;
use Modules\Sirsoft\Ecommerce\Listeners\SeoProductCacheListener;
use Tests\TestCase;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* Ecommerce 리스너 사이트맵 증분 색인 테스트 (S4 ⑲)
@@ -20,10 +19,8 @@ use Tests\TestCase;
* - 활성 카테고리 변경 → 색인, 비활성/삭제 → 색인 제거
* - 리스너가 사이트맵 재생성 잡을 디스패치
*/
class EcommerceSitemapIndexTest extends TestCase
class EcommerceSitemapIndexTest extends ModuleTestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
@@ -0,0 +1,287 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Support\CurrencySettingsCache;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 통화 삭제 후 기존 주문의 금액·표기 불변성 테스트 (공개 #91 후속)
*
* 관리자가 통화를 삭제해도 그 통화로 결제된 과거 주문은 환불 재계산·표기 모두 주문 시점
* 스냅샷을 따라야 한다. 환산 금액은 스냅샷 환율·절사규칙을 쓰므로 이미 불변이지만,
* 소수 자릿수만은 현재 설정에서 조회해 왔다 — 삭제된 통화는 설정에서 사라지므로
* 폴백 2자리가 적용되어 0자리 통화(JPY 등)의 표기가 `¥18,860` → `¥18,860.00` 으로 바뀌고,
* 3자리 이상으로 설정된 통화는 표시 금액이 절사된다.
*/
class DeletedCurrencyOrderIntegrityTest extends ModuleTestCase
{
private string $storagePath;
/**
* 주문 시점 스냅샷 — JPY(0자리)·XAU(3자리)가 살아 있던 시점.
*/
private const SNAPSHOT = [
'base_currency' => 'KRW',
'order_currency' => 'JPY',
'exchange_rate' => 115.0,
'base_unit' => 1000,
'exchange_rates' => [
'KRW' => ['rate' => 1.0, 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0, 'base_unit' => 1000],
'JPY' => ['rate' => 115.0, 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0, 'base_unit' => 100],
'XAU' => ['rate' => 0.002, 'rounding_unit' => '0.001', 'rounding_method' => 'round', 'decimal_places' => 3, 'base_unit' => 1],
],
];
protected function setUp(): void
{
parent::setUp();
$this->storagePath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
// 현재 설정 = 관리자가 JPY·XAU 를 삭제한 뒤 (KRW 만 남음)
File::ensureDirectoryExists($this->storagePath);
File::put(
$this->storagePath.'/language_currency.json',
json_encode([
'default_currency' => 'KRW',
'currencies' => [
[
'code' => 'KRW',
'name' => ['ko' => 'KRW (원)', 'en' => 'KRW (Won)'],
'exchange_rate' => null,
'base_unit' => 1000,
'rounding_unit' => '1',
'rounding_method' => 'floor',
'decimal_places' => 0,
'is_default' => true,
],
],
'removed_default_currencies' => ['USD', 'JPY', 'CNY', 'EUR'],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
config(['g7_settings.modules.sirsoft-ecommerce.language_currency' => [
'default_currency' => 'KRW',
'currencies' => [
['code' => 'KRW', 'name' => ['ko' => 'KRW', 'en' => 'KRW'], 'exchange_rate' => null, 'base_unit' => 1000, 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0, 'is_default' => true],
],
]]);
CurrencySettingsCache::clear();
}
protected function tearDown(): void
{
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
CurrencySettingsCache::clear();
parent::tearDown();
}
/**
* 리소스 계층(트레이트)을 단독으로 쓰기 위한 익명 클래스를 만듭니다.
*/
private function resourceStub(): object
{
return new class
{
use HasMultiCurrencyPrices;
public function formatStored(?array $amounts): array
{
return $this->formatStoredMultiCurrency($amounts);
}
public function roundTo(float|int|null $price, string $code): float|int
{
return $this->roundToCurrency($price, $code);
}
};
}
// ──────────────────────────────────────────────
// 환불 재계산 금액 (스냅샷 고정 — 회귀 방지)
// ──────────────────────────────────────────────
/**
* 삭제된 통화로 결제된 주문의 부분환불 금액이 스냅샷 환율로 계산된다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects deleted_currency_order_refund_uses_snapshot_rate
*/
public function test_partial_refund_of_deleted_currency_order_uses_snapshot_rate(): void
{
/** @var CurrencyConversionService $svc */
$svc = app(CurrencyConversionService::class);
// 부분환불 10,000원 → JPY 환산 = (10000/1000) * 115 = 1,150
$result = $svc->convertMultipleAmountsWithSnapshot(['refund_amount' => 10000], self::SNAPSHOT);
$this->assertArrayHasKey('JPY', $result, '삭제된 통화가 스냅샷 환산 결과에서 사라졌습니다.');
$this->assertEquals(1150, $result['JPY']['refund_amount']);
$this->assertEquals(10000, $result['KRW']['refund_amount']);
}
/**
* 삭제된 통화의 결제 청구액도 스냅샷 환율·자릿수로 산출된다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects deleted_currency_order_refund_uses_snapshot_rate
*/
public function test_payment_charge_of_deleted_currency_uses_snapshot(): void
{
/** @var CurrencyConversionService $svc */
$svc = app(CurrencyConversionService::class);
$charge = $svc->resolveSnapshotPaymentCharge(10000, self::SNAPSHOT);
$this->assertSame('JPY', $charge['currency']);
$this->assertEquals(1150, $charge['amount']);
}
// ──────────────────────────────────────────────
// 자릿수 — 스냅샷 우선 (이번 수정 대상)
// ──────────────────────────────────────────────
/**
* 삭제된 0자리 통화의 환산 표기가 스냅샷 자릿수(0)를 유지한다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects deleted_currency_display_keeps_snapshot_decimal_places
*/
public function test_converted_amount_formatting_keeps_snapshot_decimal_places(): void
{
/** @var CurrencyConversionService $svc */
$svc = app(CurrencyConversionService::class);
$result = $svc->convertMultipleAmountsWithSnapshot(['refund_amount' => 10000], self::SNAPSHOT);
$this->assertStringNotContainsString(
'.00',
$result['JPY']['refund_amount_formatted'],
'삭제된 0자리 통화가 폴백 2자리로 표기되었습니다.'
);
}
/**
* 삭제된 3자리 통화의 표기가 절사되지 않는다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects deleted_currency_display_keeps_snapshot_decimal_places
*/
public function test_high_precision_deleted_currency_is_not_truncated(): void
{
/** @var CurrencyConversionService $svc */
$svc = app(CurrencyConversionService::class);
// (10000/1000) * 0.002 = 0.02 → 절사단위 0.001 → 0.02, 3자리 표기 '0.020'
$result = $svc->convertMultipleAmountsWithSnapshot(['refund_amount' => 10000], self::SNAPSHOT);
$this->assertStringContainsString(
'0.020',
$result['XAU']['refund_amount_formatted'],
'삭제된 3자리 통화가 폴백 2자리로 절사되었습니다.'
);
}
/**
* 서비스의 단일 금액 포맷도 스냅샷 자릿수를 따른다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects deleted_currency_display_keeps_snapshot_decimal_places
*/
public function test_format_price_accepts_snapshot(): void
{
/** @var CurrencyConversionService $svc */
$svc = app(CurrencyConversionService::class);
$this->assertSame('¥18,860', $svc->formatPrice(18860, 'JPY', self::SNAPSHOT));
// 스냅샷 없이 호출하면 현행대로 현재 설정 폴백(2자리)
$this->assertSame('¥18,860.00', $svc->formatPrice(18860, 'JPY'));
}
/**
* 주문의 저장된 mc_* 금액 표기가 스냅샷 자릿수를 따른다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects deleted_currency_display_keeps_snapshot_decimal_places
*/
public function test_stored_multi_currency_formatting_keeps_snapshot_decimal_places(): void
{
$resource = $this->resourceStub()->withCurrencySnapshot(self::SNAPSHOT);
$formatted = $resource->formatStored(['KRW' => 129000, 'JPY' => 14835]);
$this->assertSame('129,000원', $formatted['KRW']['formatted']);
$this->assertSame('¥14,835', $formatted['JPY']['formatted'], '삭제된 0자리 통화가 폴백 2자리로 표기되었습니다.');
}
/**
* raw 금액 라운딩도 스냅샷 자릿수를 따른다 (0자리 통화는 int 유지).
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects deleted_currency_display_keeps_snapshot_decimal_places
*/
public function test_round_to_currency_keeps_snapshot_decimal_places(): void
{
$resource = $this->resourceStub()->withCurrencySnapshot(self::SNAPSHOT);
$this->assertSame(14835, $resource->roundTo(14835.0, 'JPY'), '삭제된 0자리 통화가 float 로 반환되었습니다.');
}
// ──────────────────────────────────────────────
// 폴백 (레거시 스냅샷 / 스냅샷 부재)
// ──────────────────────────────────────────────
/**
* 자릿수가 없는 구형 스냅샷은 현재 설정 폴백을 그대로 유지한다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=none_legacy
*
* @effects legacy_snapshot_without_decimal_places_falls_back_to_settings
*/
public function test_legacy_snapshot_without_decimal_places_falls_back(): void
{
/** @var CurrencyConversionService $svc */
$svc = app(CurrencyConversionService::class);
// 구형 스냅샷: 환율이 단순 숫자 (절사규칙·자릿수 미박제)
$legacy = [
'base_currency' => 'KRW',
'order_currency' => 'JPY',
'exchange_rates' => ['JPY' => 115.0],
];
$this->assertSame('¥18,860.00', $svc->formatPrice(18860, 'JPY', $legacy));
}
/**
* 스냅샷을 주지 않은 리소스는 현재 설정으로 동작한다 (상품 등 카탈로그 경로 회귀 방지).
*
* @scenario saved_currency_set=all_five, deletion_tombstone=none_legacy
*
* @effects legacy_snapshot_without_decimal_places_falls_back_to_settings
*/
public function test_resource_without_snapshot_uses_live_settings(): void
{
$resource = $this->resourceStub();
// KRW 는 현재 설정에 살아 있고 0자리
$formatted = $resource->formatStored(['KRW' => 129000]);
$this->assertSame('129,000원', $formatted['KRW']['formatted']);
}
}
@@ -0,0 +1,365 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 기본 제공 통화의 의도적 삭제 영속화 테스트 (공개 #91)
*
* U11-A 는 array_merge 통째 교체로 defaults 통화가 소실되던 문제를 code 기준 보충으로 막았으나,
* "부작용 소실" 과 "관리자 의도 삭제" 를 구분할 장치가 없어 의도 삭제까지 되돌렸다.
* 저장 시점에 삭제 의도를 서버가 도출해 `removed_default_currencies` 로 기록하고,
* 조회 병합이 그 기록을 존중하는지 검증한다.
*/
class EcommerceSettingsServiceCurrencyTombstoneTest extends ModuleTestCase
{
private EcommerceSettingsService $service;
private string $storagePath;
protected function setUp(): void
{
parent::setUp();
$this->storagePath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
$this->service = new EcommerceSettingsService;
}
protected function tearDown(): void
{
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
parent::tearDown();
}
// ──────────────────────────────────────────────
// 헬퍼
// ──────────────────────────────────────────────
/**
* 통화 코드 목록으로 저장 payload 의 currencies 배열을 만듭니다.
*
* @param array<int, string> $codes 통화 코드 목록
* @param string $defaultCode 기본 통화 코드
* @return array 통화 항목 배열
*/
private function currencies(array $codes, string $defaultCode = 'KRW'): array
{
$meta = [
'KRW' => ['rate' => null, 'unit' => '1', 'method' => 'floor', 'dp' => 0, 'base_unit' => 1000],
'USD' => ['rate' => 0.85, 'unit' => '0.01', 'method' => 'round', 'dp' => 2, 'base_unit' => 1],
'JPY' => ['rate' => 115, 'unit' => '1', 'method' => 'floor', 'dp' => 0, 'base_unit' => 100],
'CNY' => ['rate' => 5.8, 'unit' => '0.01', 'method' => 'round', 'dp' => 2, 'base_unit' => 1],
'EUR' => ['rate' => 0.78, 'unit' => '0.01', 'method' => 'round', 'dp' => 2, 'base_unit' => 1],
'GBP' => ['rate' => 0.6, 'unit' => '0.01', 'method' => 'round', 'dp' => 2, 'base_unit' => 1],
];
return array_map(function (string $code) use ($meta, $defaultCode) {
$m = $meta[$code] ?? ['rate' => 1.0, 'unit' => '0.01', 'method' => 'round', 'dp' => 2, 'base_unit' => 1];
return [
'code' => $code,
'name' => ['ko' => $code, 'en' => $code],
'exchange_rate' => $code === $defaultCode ? null : $m['rate'],
'base_unit' => $m['base_unit'],
'rounding_unit' => $m['unit'],
'rounding_method' => $m['method'],
'decimal_places' => $m['dp'],
'is_default' => $code === $defaultCode,
];
}, $codes);
}
/**
* 관리자 저장 경로(saveSettings)로 language_currency 를 저장합니다.
*
* @param array $languageCurrency language_currency 카테고리 payload
*/
private function save(array $languageCurrency): void
{
$this->service->saveSettings(['language_currency' => $languageCurrency]);
$this->service->clearCache();
}
/**
* 저장된 language_currency.json 을 그대로 읽습니다.
*
* @return array 저장 파일의 디코드 결과
*/
private function savedFile(): array
{
$path = $this->storagePath.'/language_currency.json';
if (! File::exists($path)) {
return [];
}
return json_decode(File::get($path), true) ?? [];
}
/**
* 현재 조회 결과의 통화 코드 목록을 반환합니다.
*
* @return array<int, string> 통화 코드 목록
*/
private function resolvedCodes(): array
{
$lc = $this->service->getSettings('language_currency');
return array_values(array_map(fn ($c) => $c['code'] ?? null, $lc['currencies'] ?? []));
}
// ──────────────────────────────────────────────
// 삭제 영속화 (핵심)
// ──────────────────────────────────────────────
/**
* 관리자가 삭제한 기본 제공 통화가 저장 후에도 되살아나지 않는다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects intentionally_removed_default_currency_stays_deleted
*/
public function test_deleted_default_currency_stays_deleted_after_save(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD']),
]);
$codes = $this->resolvedCodes();
$this->assertContains('KRW', $codes);
$this->assertContains('USD', $codes);
$this->assertNotContains('JPY', $codes, '관리자가 삭제한 JPY 가 defaults 에서 되살아났습니다.');
$this->assertNotContains('CNY', $codes, '관리자가 삭제한 CNY 가 defaults 에서 되살아났습니다.');
$this->assertNotContains('EUR', $codes, '관리자가 삭제한 EUR 가 defaults 에서 되살아났습니다.');
}
/**
* 삭제 의도가 저장 파일에 tombstone 으로 기록된다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects tombstone_recomputed_server_side_on_save
*/
public function test_tombstone_written_to_saved_file(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD']),
]);
$saved = $this->savedFile();
$this->assertArrayHasKey('removed_default_currencies', $saved, '삭제 기록이 저장되지 않았습니다.');
$this->assertSame(['JPY', 'CNY', 'EUR'], $saved['removed_default_currencies']);
// 비연속 키가 JSON 객체로 직렬화되지 않도록 array_values 재정렬 (CLAUDE.md 규칙)
$this->assertSame([0, 1, 2], array_keys($saved['removed_default_currencies']));
}
/**
* 기본 통화 코드는 제출에서 빠져도 tombstone 되지 않는다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects base_currency_never_tombstoned
*/
public function test_default_currency_code_never_tombstoned(): void
{
// API 직접 제출로 기본 통화(KRW)가 빠진 목록을 보낸 상황
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['USD'], 'USD'),
]);
$saved = $this->savedFile();
$this->assertNotContains('KRW', $saved['removed_default_currencies'] ?? [], '기본 통화가 tombstone 되었습니다.');
$codes = $this->resolvedCodes();
$this->assertContains('KRW', $codes, '기본 통화가 조회 결과에서 사라졌습니다.');
$lc = $this->service->getSettings('language_currency');
$krw = collect($lc['currencies'])->firstWhere('code', 'KRW');
$this->assertTrue($krw['is_default'], '기본 통화의 is_default 가 유지되어야 합니다.');
}
/**
* 삭제한 통화를 다시 추가하면 tombstone 이 해제되고 복원된다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=readded
*
* @effects intentionally_removed_default_currency_stays_deleted, tombstone_recomputed_server_side_on_save
*/
public function test_readding_currency_clears_tombstone_and_resurrects(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW']),
]);
$this->assertSame(['USD', 'JPY', 'CNY', 'EUR'], $this->savedFile()['removed_default_currencies'] ?? []);
// USD 재등록
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD']),
]);
$codes = $this->resolvedCodes();
$this->assertContains('USD', $codes, '재추가한 통화가 복원되지 않았습니다.');
$this->assertSame(['JPY', 'CNY', 'EUR'], $this->savedFile()['removed_default_currencies'] ?? []);
}
/**
* 관리자가 추가한 통화(defaults 밖)의 삭제는 종전대로 유지된다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects intentionally_removed_default_currency_stays_deleted
*/
public function test_admin_added_currency_still_deletable(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'GBP']),
]);
$this->assertContains('GBP', $this->resolvedCodes());
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW']),
]);
$this->assertNotContains('GBP', $this->resolvedCodes(), '관리자 추가 통화의 삭제가 유지되지 않았습니다.');
// defaults 밖 통화는 tombstone 대상이 아니다 (보충 병합이 되살리지 않으므로 기록 불필요)
$this->assertNotContains('GBP', $this->savedFile()['removed_default_currencies'] ?? []);
}
/**
* 다른 탭 저장은 language_currency 의 삭제 상태를 되돌리지 않는다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects other_category_save_preserves_tombstone
*/
public function test_other_category_save_preserves_tombstone(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW']),
]);
$before = $this->savedFile();
// 기본정보 탭만 저장
$this->service->saveSettings(['basic_info' => ['shop_name' => ['ko' => '테스트샵', 'en' => 'Test Shop']]]);
$this->service->clearCache();
$this->assertSame($before, $this->savedFile(), '타 카테고리 저장이 language_currency 파일을 변경했습니다.');
$this->assertSame(['KRW'], $this->resolvedCodes());
}
/**
* currencies 키 없이 language_currency 를 저장해도 tombstone 이 이월된다.
*
* saveCategorySettings 는 파일 통째 교체라 이월하지 않으면 삭제가 전부 부활한다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects other_category_save_preserves_tombstone
*/
public function test_language_currency_save_without_currencies_preserves_tombstone(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW']),
]);
// default_currency 만 담긴 저장 (currencies 키 부재)
$this->save(['default_currency' => 'KRW']);
$this->assertSame(['USD', 'JPY', 'CNY', 'EUR'], $this->savedFile()['removed_default_currencies'] ?? []);
$this->assertSame(['KRW'], $this->resolvedCodes(), 'currencies 미제출 저장이 삭제 통화를 부활시켰습니다.');
}
/**
* tombstone 키가 없는 구 저장본은 U11-A 보충 동작을 그대로 유지한다.
*
* @scenario saved_currency_set=jpy_missing, deletion_tombstone=none_legacy
*
* @effects legacy_file_without_tombstone_keeps_supplement
*/
public function test_legacy_saved_file_without_tombstone_supplements_defaults(): void
{
File::ensureDirectoryExists($this->storagePath);
File::put(
$this->storagePath.'/language_currency.json',
json_encode([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD']),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
$this->service->clearCache();
$codes = $this->resolvedCodes();
$this->assertContains('JPY', $codes, '구 저장본의 defaults 보충(U11-A)이 깨졌습니다.');
$this->assertContains('CNY', $codes);
$this->assertContains('EUR', $codes);
}
/**
* 클라이언트가 보낸 tombstone 값은 무시되고 서버가 재계산한다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects tombstone_recomputed_server_side_on_save
*/
public function test_client_supplied_tombstone_is_recomputed(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD', 'JPY', 'CNY', 'EUR']),
'removed_default_currencies' => ['KRW', 'USD'],
]);
$this->assertSame([], $this->savedFile()['removed_default_currencies'] ?? null, '클라이언트 값이 그대로 저장되었습니다.');
$codes = $this->resolvedCodes();
$this->assertContains('KRW', $codes);
$this->assertContains('USD', $codes);
}
/**
* tombstone 은 프론트엔드 공개 설정에 노출되지 않는다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects tombstone_recomputed_server_side_on_save
*/
public function test_tombstone_not_exposed_in_frontend_settings(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW']),
]);
$frontend = $this->service->getFrontendSettings();
$this->assertArrayHasKey('language_currency', $frontend);
$this->assertArrayNotHasKey(
'removed_default_currencies',
$frontend['language_currency'],
'내부 삭제 기록이 공개 설정에 노출되었습니다.'
);
}
}
@@ -4,9 +4,11 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\EcommerceUserProfileRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Modules\Sirsoft\Ecommerce\Support\CurrencySettingsCache;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use ReflectionMethod;
@@ -96,6 +98,80 @@ class OrderProcessingCurrencySnapshotTest extends ModuleTestCase
$this->assertSame(1, $snapshot['exchange_rates']['USD']['base_unit']);
}
// ──────────────────────────────────────────────
// 미등록 통화 방어 (공개 #91) — 삭제 영속화로 처음 열리는 경로
// ──────────────────────────────────────────────
/**
* 등록 통화를 KRW 단독으로 축소합니다. (관리자가 나머지를 삭제한 상태)
*/
private function registerOnlyBaseCurrency(): void
{
Config::set('g7_settings.modules.sirsoft-ecommerce.language_currency', [
'default_currency' => 'KRW',
'currencies' => [
[
'code' => 'KRW',
'name' => ['ko' => 'KRW', 'en' => 'KRW'],
'exchange_rate' => null,
'base_unit' => 1000,
'rounding_unit' => '1',
'rounding_method' => 'floor',
'decimal_places' => 0,
'is_default' => true,
],
],
]);
CurrencySettingsCache::clear();
$this->app->forgetInstance(CurrencyConversionService::class);
$this->app->forgetInstance(OrderProcessingService::class);
}
/**
* 사용 중지된 통화를 선호 통화로 가진 유저는 기본 통화로 폴백한다.
*
* 폴백이 없으면 스냅샷 exchange_rates 에 그 코드가 없어 체크아웃이 차단된다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects unregistered_active_currency_falls_back_to_base
*/
public function test_unregistered_persisted_currency_falls_back_to_base(): void
{
$user = User::factory()->create();
Auth::login($user);
app(EcommerceUserProfileRepositoryInterface::class)->setPreferredCurrency($user->id, 'EUR');
request()->headers->remove('X-Currency');
$this->registerOnlyBaseCurrency();
$snapshot = $this->invokeBuildSnapshot();
$this->assertSame('KRW', $snapshot['order_currency'], '미등록 선호 통화가 결제 통화로 그대로 채택되었습니다.');
$this->assertArrayHasKey('KRW', $snapshot['exchange_rates']);
Auth::logout();
}
/**
* 사용 중지된 통화가 X-Currency 헤더로 들어와도 기본 통화로 폴백한다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects unregistered_active_currency_falls_back_to_base
*/
public function test_unregistered_x_currency_header_falls_back_to_base(): void
{
request()->headers->set('X-Currency', 'JPY');
$this->registerOnlyBaseCurrency();
$snapshot = $this->invokeBuildSnapshot();
$this->assertSame('KRW', $snapshot['order_currency'], '미등록 헤더 통화가 결제 통화로 그대로 채택되었습니다.');
request()->headers->remove('X-Currency');
}
// ──────────────────────────────────────────────
// 환불 불변식 (A2 D-BASE-3) — 핵심
// ──────────────────────────────────────────────
@@ -0,0 +1,152 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 목록형 설정의 삭제 영속성 실측 (공개 #91 전수 조사)
*
* defaults.json 이 항목을 제공하고 관리자 화면이 그 항목의 삭제를 제공하는 목록 설정 전부에 대해,
* 삭제 저장 후 재조회에서 항목이 되살아나지 않는지 확인한다. 통화(currencies)만 code 기준
* 보충 병합을 거치고 나머지는 통째 교체이므로 삭제가 그대로 유지되어야 하는데, 이 구분은
* 코드를 읽어야만 드러나므로 실측으로 고정한다.
*/
class SettingsListDeletionPersistenceTest extends ModuleTestCase
{
private EcommerceSettingsService $service;
private string $storagePath;
protected function setUp(): void
{
parent::setUp();
$this->storagePath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
$this->service = new EcommerceSettingsService;
}
protected function tearDown(): void
{
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
parent::tearDown();
}
/**
* 카테고리의 목록 값을 저장하고 재조회 결과를 돌려줍니다.
*
* @param string $category 설정 카테고리
* @param string $key 목록 키
* @param array $list 저장할 목록
* @return array 재조회된 목록
*/
private function saveAndReload(string $category, string $key, array $list): array
{
$current = $this->service->getSettings($category);
$current[$key] = $list;
$this->service->saveSettings([$category => $current]);
$this->service->clearCache();
return $this->service->getSettings($category)[$key] ?? [];
}
/**
* 은행 목록에서 항목을 삭제하면 유지된다. (통째 교체)
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects non_supplemented_list_settings_keep_deletion
*/
public function test_deleted_bank_stays_deleted(): void
{
$banks = $this->service->getSettings('order_settings')['banks'] ?? [];
$this->assertGreaterThan(1, count($banks), '기본 은행 목록이 비어 있어 삭제를 측정할 수 없습니다.');
$kept = array_values(array_slice($banks, 0, 2));
$reloaded = $this->saveAndReload('order_settings', 'banks', $kept);
$this->assertCount(2, $reloaded, '삭제한 은행이 defaults 에서 되살아났습니다.');
}
/**
* 입금 계좌 목록에서 항목을 삭제하면 유지된다. (통째 교체)
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects non_supplemented_list_settings_keep_deletion
*/
public function test_deleted_bank_account_stays_deleted(): void
{
$accounts = $this->service->getSettings('order_settings')['bank_accounts'] ?? [];
$this->assertNotEmpty($accounts, '기본 입금 계좌가 없어 삭제를 측정할 수 없습니다.');
$reloaded = $this->saveAndReload('order_settings', 'bank_accounts', []);
$this->assertSame([], $reloaded, '삭제한 입금 계좌가 defaults 에서 되살아났습니다.');
}
/**
* 배송 가능 국가에서 항목을 삭제하면 유지된다. (통째 교체 + 라벨 보강만)
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects non_supplemented_list_settings_keep_deletion
*/
public function test_deleted_shipping_country_stays_deleted(): void
{
$countries = $this->service->getSettings('shipping')['available_countries'] ?? [];
$this->assertGreaterThan(1, count($countries), '기본 배송 국가 목록이 비어 있어 삭제를 측정할 수 없습니다.');
$kept = array_values(array_slice($countries, 0, 1));
$reloaded = $this->saveAndReload('shipping', 'available_countries', $kept);
$this->assertCount(1, $reloaded, '삭제한 배송 국가가 defaults 에서 되살아났습니다.');
}
/**
* 마일리지 통화 규칙에서 항목을 삭제하면 유지된다. (통째 교체)
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects non_supplemented_list_settings_keep_deletion
*/
public function test_deleted_mileage_currency_rule_stays_deleted(): void
{
$rules = $this->service->getSettings('mileage')['currency_rules'] ?? [];
$this->assertNotEmpty($rules, '기본 마일리지 통화 규칙이 없어 삭제를 측정할 수 없습니다.');
$reloaded = $this->saveAndReload('mileage', 'currency_rules', []);
$this->assertSame([], $reloaded, '삭제한 마일리지 통화 규칙이 defaults 에서 되살아났습니다.');
}
/**
* 결제수단은 카탈로그 병합 모델이라 저장본에서 빠져도 카탈로그에서 다시 채워진다.
*
* 삭제 UX 가 아니라 is_active 토글 모델이므로 #91 과 다른 구조다 — 그 사실을 고정한다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects catalog_merged_list_settings_refill_by_design
*/
public function test_payment_methods_are_catalog_merged_not_deletable(): void
{
$methods = $this->service->getSettings('order_settings')['payment_methods'] ?? [];
$this->assertNotEmpty($methods, '기본 결제수단 카탈로그가 비어 있습니다.');
$reloaded = $this->saveAndReload('order_settings', 'payment_methods', []);
$this->assertNotEmpty($reloaded, '결제수단은 카탈로그에서 다시 채워져야 합니다(토글 모델).');
}
}
@@ -1,23 +1,34 @@
# audit:allow test-scenario-coverage reason: |
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
# 환율 영속성 axis 는 PHPUnit(EcommerceSettingsServiceTest) 가 전수 커버하며 green 이다.
feature: 통화 환율 영속성 — code 기준 병합 (U11-A)
feature: 통화 목록 영속성 — code 기준 병합(U11-A) + 의도적 삭제 기록(공개 #91)
description: |
language_currency.currencies 는 정수키 리스트라 array_merge 가 통째 교체되어,
관리자가 일부 통화를 빼고 저장하면 defaults 통화가 영구 소실되던 영속성 공백을 수정한다.
getAllSettings() 에서 code 기준 병합(mergeCurrenciesByCode)으로 defaults 보충(환율은 저장본 우선).
그 보충은 "array_merge 부작용으로 인한 소실" 을 되돌리기 위한 것이었으나 관리자의 의도적
삭제와 구분할 장치가 없어, 기본 제공 통화를 삭제하면 저장 응답에서 이미 되살아났다(공개 #91).
저장 시점에 삭제 의도를 서버가 도출해 저장본에 기록하고, 조회 병합이 그 기록을 존중한다.
삭제가 실제로 영속되면 처음 도달 가능해지는 후속 경로(사용 중지된 통화를 표시 통화로 가진
구매자의 체크아웃 차단)도 같은 축에서 방어한다.
axes:
saved_currency_set:
- all_five # 전 5종 저장
- jpy_missing # JPY 빼고 저장
- usd_missing # USD 빼고 저장
- new_added # 관리자 신규 통화 추가
- all_five # 전 5종 저장
- jpy_missing # JPY 빼고 저장
- usd_missing # USD 빼고 저장
- new_added # 관리자 신규 통화 추가
- default_removed_by_admin # 관리자 의도 삭제 (공개 #91)
get_settings_result:
- defaults_supplemented # 누락 통화 defaults 보충됨
- defaults_supplemented # 누락 통화 defaults 보충됨
exchange_rate_preservation:
- saved_value_kept # 저장본 exchange_rate 보존
- saved_value_kept # 저장본 exchange_rate 보존
deletion_tombstone: # 공개 #91 — 의도적 삭제 영속화
- none_legacy # 구 저장본(키 없음) → U11-A 보충 유지
- has_removed_codes # 삭제 기록 보유 → defaults 보충 스킵
- readded # 재추가 저장 → 기록 해제·부활
effects:
- missing_currency_supplemented_from_defaults # array_merge 통째교체로 통화 소실 안 됨
@@ -25,6 +36,64 @@ effects:
- new_added_currency_preserved # 저장본에만 있는 신규 통화 보존
- default_currency_exchange_rate_null # 기본통화 exchange_rate null 유지(syncCurrencyDefaults)
- currency_conversion_uses_persisted_rate # 환율 109 설정 시 JPY 정확 환산, null 시 제외
# 공개 #91
- intentionally_removed_default_currency_stays_deleted # 삭제한 기본 제공 통화 미부활
- tombstone_recomputed_server_side_on_save # 삭제 기록은 서버가 재계산(클라이언트 값 불신)
- base_currency_never_tombstoned # 기본 통화는 기록 대상 제외 → 항상 생존
- other_category_save_preserves_tombstone # 타 탭/통화 미제출 저장이 삭제를 되돌리지 않음
- legacy_file_without_tombstone_keeps_supplement # 구 저장본은 U11-A 보충 그대로
- unregistered_active_currency_falls_back_to_base # 미등록 표시/선호 통화 → 기본 통화 폴백
- non_supplemented_list_settings_keep_deletion # 보충 병합을 거치지 않는 목록 설정은 삭제 유지
- catalog_merged_list_settings_refill_by_design # 카탈로그 병합 목록(결제수단)은 토글 모델이라 재충전
# 공개 #91 후속 — 삭제된 통화로 결제된 과거 주문
- deleted_currency_order_refund_uses_snapshot_rate # 환불 재계산이 스냅샷 환율을 사용
- deleted_currency_display_keeps_snapshot_decimal_places # 표기 자릿수도 주문 시점 값 유지
- legacy_snapshot_without_decimal_places_falls_back_to_settings # 구형 스냅샷은 현재 설정 폴백
sub_flows:
- id: intentional_deletion_round_trip
description: |
삭제 → 저장 → 재조회 왕복. 서비스 계층뿐 아니라 저장 응답(PUT) 자체가 삭제를
반영해야 한다 — 제보된 증상이 "저장 응답에 이미 5종이 부활" 이었기 때문이다.
effects:
- intentionally_removed_default_currency_stays_deleted
- tombstone_recomputed_server_side_on_save
- base_currency_never_tombstoned
- id: deletion_side_effects
description: 삭제가 영속되면 처음 열리는 후속 경로(미등록 통화가 결제 통화로 채택되는 경우) 방어
effects:
- unregistered_active_currency_falls_back_to_base
- id: deleted_currency_existing_orders
description: |
통화를 삭제해도 그 통화로 결제된 과거 주문은 환불 재계산·표기가 불변이어야 한다.
금액은 스냅샷 환율·절사규칙을 쓰므로 원래 불변이었으나, 소수 자릿수만 현재 설정에서
조회해 삭제 시 폴백 2자리가 적용됐다(0자리 통화 표기 변화 / 3자리 이상 통화 절사).
effects:
- deleted_currency_order_refund_uses_snapshot_rate
- deleted_currency_display_keeps_snapshot_decimal_places
- legacy_snapshot_without_decimal_places_falls_back_to_settings
- id: list_settings_deletion_sweep
description: |
defaults 가 항목을 제공하고 화면이 삭제를 제공하는 다른 목록 설정(은행·입금계좌·배송 국가·
마일리지 통화 규칙)의 삭제 영속성 실측. 통화만 항목 단위 보충을 거치고 나머지는 통째
교체라 삭제가 유지되는데, 이 구분은 코드를 읽어야만 드러나므로 실측으로 고정한다.
effects:
- non_supplemented_list_settings_keep_deletion
- catalog_merged_list_settings_refill_by_design
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsServiceCurrencyMergeTest.php
# 공개 #91 — 삭제 영속화 (서비스 계층)
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsServiceCurrencyTombstoneTest.php
# 공개 #91 — 저장 응답 왕복 (제보된 증상 층)
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsCurrencyDeletionTest.php
# 공개 #91 — 미등록 통화 폴백 (후속 경로 방어)
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingCurrencySnapshotTest.php
# 공개 #91 전수 조사 — 다른 목록형 설정의 삭제 영속성 실측
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/SettingsListDeletionPersistenceTest.php
# 공개 #91 후속 — 통화 삭제 후 기존 주문의 금액·표기 불변성
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/DeletedCurrencyOrderIntegrityTest.php
notes: |
E2E 미해당: 변경 범위가 백엔드 전용(Service)이며 레이아웃 JSON·컴포넌트·엔진 코드 변경이 없다.
브라우저 검증은 Chrome MCP 매트릭스가 담당한다.
@@ -97,7 +97,7 @@ test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/ConfirmDepositControllerTest.php
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminOrderConfirmDeposit.test.tsx
- templates/_bundled/sirsoft-basic/__tests__/layouts/orderReviewButton.test.tsx
- tests/Playwright/specs/order-option-status-review.spec.ts
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/order-option-status-review.spec.ts
validation:
audit_rule: frontend-change-requires-e2e
@@ -90,7 +90,7 @@ test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderServiceTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Notifications/OrderStatusNotificationTest.php
- tests/Playwright/specs/admin/order-status-notification.spec.ts
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/order-status-notification.spec.ts
validation:
audit_rule: test-scenario-coverage
@@ -1,11 +1,11 @@
{
"schema_version": "1.0",
"generated_at": "2026-08-06T03:32:02+00:00",
"generated_at": "2026-08-11T07:27:43+00:00",
"generator": "g7 vendor-bundle:build",
"target": "module:sirsoft-ecommerce",
"composer_json_sha256": "cf5460aed4b90364c33889e3bd479eeb87c05b8f4b1ee94886575e14dcf441d1",
"composer_json_sha256": "c1a7e6cb8fcf68b62af5b7817509e2a2df6cb19348b9951ec8ea30634f3030d3",
"composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d",
"zip_sha256": "a475756e4c92ad685d324cacb83beab2c8f94e5d29d8b33bf64dd63b12180f67",
"zip_sha256": "9c9bbaa646fdc7500234699362177f825bd421a2981afac5168a4fee554f5d5e",
"zip_size": 435548,
"package_count": 1,
"php_requirement": "^8.2",
Binary file not shown.
@@ -403,6 +403,169 @@ class ExtensionPendingHelperTest extends TestCase
$this->assertEmpty($tempDirs, '임시 디렉토리가 정리되어야 합니다');
}
// ========================================================================
// Windows 파일 잠금 대응 — rename 실패 시 파일 단위 폴백 (회귀 테스트)
//
// Windows 에서 디렉토리 rename 은 하위 트리에 열린 핸들(파일 워처의 디렉토리
// 핸들, 열린 파일 등)이 하나라도 있으면 실패한다. 프로세스 식별/종료는 신뢰할
// 수 없으므로, rename 이 차단되면 파일 단위 연산(잠금의 영향을 받지 않음)으로
// 폴백하여 업데이트가 반드시 완료되어야 한다.
// ========================================================================
/**
* 기존 활성 디렉토리의 rename(활성 → _old)이 차단될 때
* 파일 단위 제자리 동기화로 폴백하여 교체가 완료되는지 확인합니다.
*/
public function test_copy_to_active_syncs_in_place_when_target_rename_is_blocked(): void
{
$targetPath = $this->modulesPath.'/test-pending-mod';
File::ensureDirectoryExists($targetPath);
File::put($targetPath.'/module.json', '{"identifier":"test-pending-mod","version":"0.9.0"}');
File::put($targetPath.'/old-file.txt', 'stale content');
$sourcePath = $this->modulesPath.'/_pending/test-pending-mod';
// File Facade Spy: 활성 디렉토리 자체의 rename 만 차단 (워처의 디렉토리 핸들 잠금 재현)
$originalFilesystem = File::getFacadeRoot();
$mock = Mockery::mock($originalFilesystem)->makePartial();
$mock->shouldReceive('moveDirectory')
->andReturnUsing(function (string $from, string $to, bool $overwrite = false) use ($originalFilesystem, $targetPath) {
if (rtrim($from, '/\\') === rtrim($targetPath, '/\\')) {
return false; // 활성 → _old rename 차단
}
return $originalFilesystem->moveDirectory($from, $to, $overwrite);
});
File::swap($mock);
try {
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath);
} finally {
File::swap($originalFilesystem);
}
// 새 버전 파일이 제자리에 반영되어야 함
$this->assertDirectoryExists($targetPath);
$this->assertFileExists($targetPath.'/src/Module.php');
$manifest = json_decode(File::get($targetPath.'/module.json'), true);
$this->assertEquals('1.0.0', $manifest['version'], '새 버전 manifest 로 교체되어야 함');
// 새 버전에 없는 잔존 파일은 제거되어야 함
$this->assertFileDoesNotExist($targetPath.'/old-file.txt');
// 임시 디렉토리가 정리되어야 함
$this->assertEmpty(glob($this->modulesPath.'/_pending/test-pending-mod_updating_*'));
}
/**
* 스테이징 디렉토리의 rename(_updating_ → 활성)이 차단될 때
* 파일 단위 복사로 폴백하여 교체가 완료되는지 확인합니다.
*
* PO 실사용 로그의 실패 지점: 활성 → _old 는 성공했으나, 방금 복사된 스테이징
* 트리를 워처가 이미 열어 _updating_ → 활성 rename 이 차단된 케이스.
*/
public function test_copy_to_active_copies_per_file_when_staging_rename_is_blocked(): void
{
$targetPath = $this->modulesPath.'/test-pending-mod';
File::ensureDirectoryExists($targetPath);
File::put($targetPath.'/module.json', '{"identifier":"test-pending-mod","version":"0.9.0"}');
File::put($targetPath.'/old-file.txt', 'stale content');
$sourcePath = $this->modulesPath.'/_pending/test-pending-mod';
// File Facade Spy: _updating_ → 활성 rename 만 차단
$originalFilesystem = File::getFacadeRoot();
$mock = Mockery::mock($originalFilesystem)->makePartial();
$mock->shouldReceive('moveDirectory')
->andReturnUsing(function (string $from, string $to, bool $overwrite = false) use ($originalFilesystem) {
if (str_contains($from, '_updating_')) {
return false; // 스테이징 → 활성 rename 차단
}
return $originalFilesystem->moveDirectory($from, $to, $overwrite);
});
File::swap($mock);
try {
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath);
} finally {
File::swap($originalFilesystem);
}
// 새 버전 파일이 반영되어야 함
$this->assertDirectoryExists($targetPath);
$this->assertFileExists($targetPath.'/src/Module.php');
$manifest = json_decode(File::get($targetPath.'/module.json'), true);
$this->assertEquals('1.0.0', $manifest['version'], '새 버전 manifest 로 교체되어야 함');
// 원자적 교체(활성 → _old)를 거쳤으므로 잔존 파일도 없어야 함
$this->assertFileDoesNotExist($targetPath.'/old-file.txt');
// 임시/백업 디렉토리가 정리되어야 함
$this->assertEmpty(glob($this->modulesPath.'/_pending/test-pending-mod_updating_*'));
$this->assertEmpty(glob($this->modulesPath.'/_pending/test-pending-mod_old_*'));
}
/**
* 실제 Windows 파일 잠금(열린 파일 핸들) 상황에서 교체가 완료되는지 확인합니다.
*
* Windows 는 하위 트리에 열린 파일이 있으면 그 경로의 모든 상위 디렉토리
* rename 을 차단한다. 열린 파일이라도 일반적인 공유 모드(read/write 공유)로
* 열려 있으면 내용 덮어쓰기는 허용되므로, 파일 단위 폴백은 성공해야 한다.
*/
public function test_copy_to_active_survives_open_file_handle_on_windows(): void
{
if (PHP_OS_FAMILY !== 'Windows') {
$this->markTestSkipped('Windows 파일 잠금 동작 검증 전용 테스트');
}
$targetPath = $this->modulesPath.'/test-pending-mod';
File::ensureDirectoryExists($targetPath);
File::put($targetPath.'/module.json', '{"identifier":"test-pending-mod","version":"0.9.0"}');
File::put($targetPath.'/old-file.txt', 'stale content');
$sourcePath = $this->modulesPath.'/_pending/test-pending-mod';
// 활성 디렉토리 내 파일에 핸들을 열어 둔 채 교체 시도 (디렉토리 rename 차단 재현)
$handle = fopen($targetPath.'/module.json', 'rb');
$this->assertIsResource($handle);
try {
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath);
} finally {
fclose($handle);
}
$this->assertFileExists($targetPath.'/src/Module.php');
$manifest = json_decode(File::get($targetPath.'/module.json'), true);
$this->assertEquals('1.0.0', $manifest['version'], '핸들이 열린 파일도 내용이 교체되어야 함');
$this->assertFileDoesNotExist($targetPath.'/old-file.txt');
}
/**
* 이전 실패 실행이 남긴 _updating_/_old_ 잔존 디렉토리가
* 다음 교체 시작 시 자동 정리되는지 확인합니다.
*/
public function test_copy_to_active_cleans_leftover_swap_directories(): void
{
$pendingPath = $this->modulesPath.'/_pending';
File::ensureDirectoryExists($pendingPath.'/test-pending-mod_updating_stale123');
File::put($pendingPath.'/test-pending-mod_updating_stale123/junk.txt', 'junk');
File::ensureDirectoryExists($pendingPath.'/test-pending-mod_old_stale456');
File::put($pendingPath.'/test-pending-mod_old_stale456/junk.txt', 'junk');
$targetPath = $this->modulesPath.'/test-pending-mod';
File::ensureDirectoryExists($targetPath);
File::put($targetPath.'/module.json', '{"identifier":"test-pending-mod","version":"0.9.0"}');
$sourcePath = $this->modulesPath.'/_pending/test-pending-mod';
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath);
$this->assertDirectoryDoesNotExist($pendingPath.'/test-pending-mod_updating_stale123');
$this->assertDirectoryDoesNotExist($pendingPath.'/test-pending-mod_old_stale456');
$this->assertFileExists($targetPath.'/src/Module.php');
}
/**
* EXCLUDED_DIRECTORIES 상수에 node_modules가 포함되어 있는지 확인합니다.
*/
@@ -109,6 +109,13 @@ class SettingsServiceAppConfigTest extends TestCase
/**
* getAppConfigForFrontend()가 core.frontend.filter_app_config 훅으로 확장이 주입한 값을
* appConfig 에 반영하는지 테스트합니다. (예: 이커머스 모듈이 요청 기기 유형 isIos 를 주입)
*
* 체크아웃 브랜드 마크 시나리오의 iOS 게이팅 체인 중 "모듈이 감지한 기기 정보가 코어
* appConfig 를 거쳐 프론트로 전달되는" 구간을 이 테스트가 떠받친다.
*
* @scenario requires_ios=true, device=ios
*
* @effects is_ios_flows_to_global_appconfig
*/
public function test_get_app_config_applies_frontend_filter_hook(): void
{