diff --git a/.env.example b/.env.example index 74fb9072..27d61180 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.4 +APP_VERSION=7.0.5 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.env.testing.example b/.env.testing.example index 94e4f911..bec41e3b 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -3,7 +3,7 @@ APP_ENV=testing APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.4 +APP_VERSION=7.0.5 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/AGENTS.md b/AGENTS.md index b70277e3..43d3de89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -750,6 +750,7 @@ BaseApiController (최상위) 필수: 확장 코드 변경 시 manifest 버전 업 (미변경 시 업데이트 감지 불가) 필수: 버전 업 시 CHANGELOG.md 기록 — Keep a Changelog 표준 (미기록 시 버전 업 불가) 필수: StorageInterface 사용 (Storage::disk() 직접 호출 금지) +필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지 필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용 필수: 모든 확장 작업은 Artisan 커맨드로 수행 ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 20eca2ea..1ce9f325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [7.0.5] - 2026-07-15 + +### Added + +- 확장 기능이 화면을 그릴 때, 방문자의 접속 기기 종류처럼 요청마다 달라지는 정보를 화면 구성 데이터에 함께 실어 보낼 수 있는 통로를 마련했습니다. 쇼핑몰의 애플페이가 iOS 기기에서만 표시되도록 하는 데 이 통로가 쓰입니다. +- 확장 기능이 특정 요청 경로에만 자기 처리 로직을 끼워 넣을 수 있는 표준 연결 방식을 마련했습니다. 각 확장은 자기가 개입할 대상 경로를 명시해 선언하고, 코어가 요청이 들어올 때 대상에 해당하는 확장 로직만 골라 실행합니다. 이를 통해 쿠키 동의 처리나 결제 입금통보의 발신 서버 확인 같은 기능이 필요한 경로에서만 정확히 동작하도록 정리했습니다. + +### Fixed + +- 검색엔진 노출용으로 만들어 내보내는 페이지에 실제로 존재하지 않는 스타일 파일 주소(`/build/assets/app.css`)가 포함되어, 브라우저 콘솔에 404 오류가 남던 문제를 수정했습니다. 이 주소는 화면 표시에 쓰이지 않는 파일을 가리키던 불필요한 참조였으며, 검색엔진 노출용 페이지의 스타일은 템플릿이 제공하는 파일이 그대로 담당하므로 화면에는 영향이 없었습니다. 이제 이 참조를 제거해 오류가 발생하지 않습니다. (#64 @bigmsg 님께서 제보해주셨습니다.) +- 코어 업데이트를 `--prune` 옵션으로 실행하면 업로드 파일 연결(`public/storage`)이 삭제되어, 그동안 올린 이미지·첨부 파일이 모두 404로 열리지 않던 문제를 수정했습니다. 이제 `--prune` 업데이트가 이 연결을 지우지 않고 보존하며, 혹시 끊어져 있더라도 업데이트가 끝나는 시점에 자동으로 다시 이어 붙여 파일이 정상적으로 표시됩니다. 관리자 권한으로 업데이트를 실행할 때 이 연결을 다시 이어 붙이면서 소유자가 관리자 계정으로 바뀌던 문제도 함께 바로잡아, 원래 계정 소유가 그대로 유지됩니다. (#43 @bigmsg 님께서 제보해주셨습니다.) + ## [7.0.4] - 2026-07-14 ### Security diff --git a/INSTALL.md b/INSTALL.md index 573b2221..6c55f3cf 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -246,7 +246,7 @@ unzip g7-release.zip # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 ls -la -# (필요 시) mv g7-7.0.4 g7 +# (필요 시) mv g7-7.0.5 g7 # ZIP 파일 정리 (선택) rm g7-release.zip diff --git a/README.md b/README.md index 906de834..3e74f2b8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

- Version + Version PHP Laravel React diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php index 3012dc67..b15986f7 100644 --- a/app/Console/Commands/Core/CoreUpdateCommand.php +++ b/app/Console/Commands/Core/CoreUpdateCommand.php @@ -8,6 +8,7 @@ use App\Exceptions\UpgradeHandoffException; use App\Extension\CoreVersionChecker; use App\Extension\Helpers\CoreBackupHelper; use App\Extension\Helpers\FilePermissionHelper; +use App\Extension\Helpers\StorageLinkHelper; use App\Extension\ModuleManager; use App\Extension\PluginManager; use App\Extension\TemplateManager; @@ -517,6 +518,12 @@ class CoreUpdateCommand extends Command $this->surfacePermissionWarnings($service, $log); $log('업데이트 경로 소유권 복원 완료'); + // 소유권 복원 완료 후 · 유지보수 해제 전에 `public/storage` symlink 를 방어 복구한다. + // `--prune` 이 릴리즈 소스에 없는 symlink 를 orphan 으로 삭제했거나(#43) 손상된 + // 경우에도, 첫 요청부터 `/storage/...` 가 200 을 반환하도록 링크를 재생성한다. + StorageLinkHelper::ensurePublicStorageLink(); + $log('public/storage symlink 확인/복구 완료'); + $service->cleanupPending($pendingPath); if ($backupPath) { @@ -623,6 +630,11 @@ class CoreUpdateCommand extends Command $this->surfacePermissionWarnings($service, $log); $log('핸드오프 cleanup 완료 (버전 toVersion 고정 + 캐시 clear + 소유권 복원)'); + // 핸드오프 시에도 파일은 이미 toVersion 으로 반영돼 `public/storage` symlink 가 + // 삭제/손상됐을 수 있으므로 유지보수 해제 전에 동일하게 방어 복구한다(#43). + StorageLinkHelper::ensurePublicStorageLink(); + $log('public/storage symlink 확인/복구 완료 (핸드오프)'); + if (! empty($pendingPath)) { $service->cleanupPending($pendingPath); } diff --git a/app/Contracts/Extension/ExtensionMiddlewareRegistryInterface.php b/app/Contracts/Extension/ExtensionMiddlewareRegistryInterface.php new file mode 100644 index 00000000..44b094e7 --- /dev/null +++ b/app/Contracts/Extension/ExtensionMiddlewareRegistryInterface.php @@ -0,0 +1,34 @@ + 매칭된 확장 미들웨어 FQCN (선언 순서 = 확장 로드 순서) + */ + public function resolveForRoute(string $routeName, string $path, string $group, string $timing): array; +} diff --git a/app/Extension/AbstractModule.php b/app/Extension/AbstractModule.php index c7a3f532..c0347f53 100644 --- a/app/Extension/AbstractModule.php +++ b/app/Extension/AbstractModule.php @@ -551,6 +551,37 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte return []; } + /** + * 이 모듈이 등록할 HTTP 미들웨어 선언을 반환합니다. + * + * 모듈이 web/api 그룹에 미들웨어를 직접 넣는 대신, 코어의 self-gate 게이트 + * (`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 각 선언의 `targets` + * 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행합니다. 코어 IDV 정책의 라우트명 + * 인덱스 조회 모델과 동일합니다. 미들웨어 클래스 자체는 게이트 로직을 갖지 않아도 + * 됩니다 (순수하게 유지). + * + * 기본적으로 빈 배열 반환. 미들웨어가 필요한 모듈만 오버라이드합니다. + * + * @return array, timing?: string, targets: array}> + * [ + * [ + * 'class' => VerifyGuestOrderToken::class, // 미들웨어 FQCN (class_exists 검증) + * 'groups' => ['api'], // 등록 그룹 배열: ['web'] | ['api'] | ['web','api'] + * 'timing' => 'after_core', // 'after_core'(기본, 코어 그룹 미들웨어 뒤) | 'before_core'(코어 전처리보다 먼저) + * 'targets' => ['self'], // 라우트명/URI 패턴 배열. 'self' = 자기 확장 prefix 자동 치환 + * ], + * ] + * + * targets 카탈로그: 'self'(자기 라우트) | 'all_extensions'(모든 확장, 코어 제외) | + * 'core'(코어만) | 'everything'·'*'(전부) | 'module:{id}' | 'plugin:{id}' | + * 원시 라우트명 glob·brace(`api.modules.x.*`, `{a,b}`) | '/' 로 시작하는 URI 패턴(무명 라우트용). + * targets 누락/빈배열 시 등록 거부. 상세: docs/backend/middleware.md "확장 미들웨어 선언". + */ + public function getMiddleware(): array + { + return []; + } + /** * Declarative i18n getter family — 모듈이 선언하는 다국어/SSoT 데이터 4종. * @@ -1022,11 +1053,11 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte * * @param string $pageType 레이아웃 meta.seo.page_type * @return array}> - * 키별 데이터 경로 메타 — 예: - * [ - * 'image' => ['expr' => '{{product.data.thumbnail_url}}', 'label' => 'vendor-module::seo.auto_value.product_image'], - * 'image_alt' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'], - * ] + * 키별 데이터 경로 메타 — 예: + * [ + * 'image' => ['expr' => '{{product.data.thumbnail_url}}', 'label' => 'vendor-module::seo.auto_value.product_image'], + * 'image_alt' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'], + * ] */ public function seoOgDefaultMeta(string $pageType): array { @@ -1038,7 +1069,7 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte * * @param string $pageType 페이지 타입 * @return array}> 키별 데이터 경로 메타 - * (label = 번역 키 권장 — seoOgDefaultMeta 참조) + * (label = 번역 키 권장 — seoOgDefaultMeta 참조) */ public function seoTwitterDefaultMeta(string $pageType): array { @@ -1054,11 +1085,11 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte * * @param string $pageType 페이지 타입 * @return array}> - * 점 경로 키별 데이터 경로 메타 (label = 번역 키 권장 — seoOgDefaultMeta 참조) — 예: - * [ - * 'name' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'], - * 'offers.price' => ['expr' => '{{product.data.selling_price}}', 'label' => 'vendor-module::seo.auto_value.product_price'], - * ] + * 점 경로 키별 데이터 경로 메타 (label = 번역 키 권장 — seoOgDefaultMeta 참조) — 예: + * [ + * 'name' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'], + * 'offers.price' => ['expr' => '{{product.data.selling_price}}', 'label' => 'vendor-module::seo.auto_value.product_price'], + * ] */ public function seoStructuredDataMeta(string $pageType): array { diff --git a/app/Extension/AbstractPlugin.php b/app/Extension/AbstractPlugin.php index 1e76a1b8..3e811cd6 100644 --- a/app/Extension/AbstractPlugin.php +++ b/app/Extension/AbstractPlugin.php @@ -508,6 +508,37 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte return []; } + /** + * 이 플러그인이 등록할 HTTP 미들웨어 선언을 반환합니다. + * + * 플러그인이 web/api 그룹에 미들웨어를 직접 넣는 대신, 코어의 self-gate 게이트 + * (`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 각 선언의 `targets` + * 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행합니다. 코어 IDV 정책의 라우트명 + * 인덱스 조회 모델과 동일합니다. 미들웨어 클래스 자체는 게이트 로직을 갖지 않아도 + * 됩니다 (순수하게 유지). + * + * 기본적으로 빈 배열 반환. 미들웨어가 필요한 플러그인만 오버라이드합니다. + * + * @return array, timing?: string, targets: array}> + * [ + * [ + * 'class' => VbankNotifyIpWhitelist::class, // 미들웨어 FQCN (class_exists 검증) + * 'groups' => ['web'], // 등록 그룹 배열: ['web'] | ['api'] | ['web','api'] + * 'timing' => 'after_core', // 'after_core'(기본, 코어 그룹 미들웨어 뒤) | 'before_core'(코어 전처리보다 먼저) + * 'targets' => ['web.plugins.vendor-plugin.payment.vbank-notify'], // 라우트명/URI 패턴 배열 + * ], + * ] + * + * targets 카탈로그: 'self'(자기 라우트) | 'all_extensions'(모든 확장, 코어 제외) | + * 'core'(코어만) | 'everything'·'*'(전부) | 'module:{id}' | 'plugin:{id}' | + * 원시 라우트명 glob·brace(`web.plugins.x.*`, `{a,b}`) | '/' 로 시작하는 URI 패턴(무명 라우트용). + * targets 누락/빈배열 시 등록 거부. 상세: docs/backend/middleware.md "확장 미들웨어 선언". + */ + public function getMiddleware(): array + { + return []; + } + /** * 이 플러그인이 등록할 IDV(본인인증) 정책 선언을 반환합니다. * diff --git a/app/Extension/ExtensionMiddlewareRegistry.php b/app/Extension/ExtensionMiddlewareRegistry.php new file mode 100644 index 00000000..0af15b1c --- /dev/null +++ b/app/Extension/ExtensionMiddlewareRegistry.php @@ -0,0 +1,376 @@ + 미들웨어] 해시맵이 아니라 [group.timing => list<{predicates, class}>] + * 구조로 두고 요청 라우트명·URI 를 술어로 순회 매칭합니다. 확장 미들웨어 총량이 소수(수십 + * 이하)라 O(n) 순회 비용은 무시 가능. 결과는 (routeName|path)+group+timing 단위로 2차 캐시. + * + * 술어는 캐시를 위해 클로저 대신 `{op, arg}` 태그 배열로 보관하고 조회 시 평가합니다 + * (클로저는 직렬화 불가). op ∈ {name, name_ext, name_core, uri, always}. + * + * @since 7.0.5 + */ +class ExtensionMiddlewareRegistry implements ExtensionMiddlewareRegistryInterface +{ + /** + * 미들웨어 인덱스 캐시 키 (IDV `identity_policies.route_scope_index` 네이밍 컨벤션 미러). + * 물리 키는 CoreCacheDriver 접두사로 `g7:core:extension.middleware_index`. + */ + public const CACHE_KEY = 'extension.middleware_index'; + + /** + * 캐시 무효화 태그 (단수 slug). Manager 상태 변경 지점에서 flush(). + */ + public const CACHE_TAG = 'extension_middleware'; + + /** + * 허용 그룹. + */ + private const ALLOWED_GROUPS = ['web', 'api']; + + /** + * 확장 라우트 판별 glob (양 그룹). 라우트명이 이 중 하나에 매칭되면 확장 라우트. + */ + private const EXT_GLOB = ['*.modules.*', '*.plugins.*']; + + /** + * 요청 단위 2차 캐시 (동일 요청 내 게이트가 4회 조회해도 인덱스 1회 빌드). + * + * @var array> + */ + protected array $resolveCache = []; + + /** + * @param CacheInterface $cache 코어 캐시 드라이버 (CoreCacheDriver 자동 바인딩) + */ + public function __construct( + protected CacheInterface $cache, + ) {} + + /** + * {@inheritDoc} + */ + public function resolveForRoute(string $routeName, string $path, string $group, string $timing): array + { + $memoKey = $routeName."\0".$path."\0".$group."\0".$timing; + if (isset($this->resolveCache[$memoKey])) { + return $this->resolveCache[$memoKey]; + } + + $matched = []; + foreach ($this->buildIndex() as $entry) { + if ($entry['group'] !== $group || $entry['timing'] !== $timing) { + continue; + } + foreach ($entry['predicates'] as $predicate) { + if ($this->predicateMatches($predicate, $routeName, $path)) { + $matched[] = $entry; + break; + } + } + } + + usort($matched, static fn (array $a, array $b): int => $a['order'] <=> $b['order']); + + $classes = array_values(array_map(static fn (array $e): string => $e['class'], $matched)); + + return $this->resolveCache[$memoKey] = $classes; + } + + /** + * 활성 확장의 미들웨어 선언을 그룹별 엔트리로 펼쳐 캐시된 인덱스를 반환합니다. + * + * @return list, class: class-string, order: int}> + */ + protected function buildIndex(): array + { + $ttl = (int) g7_core_settings('cache.extension_middleware_ttl', 3600); + + return $this->cache->remember( + self::CACHE_KEY, + fn (): array => $this->collectEntries(), + $ttl, + [self::CACHE_TAG], + ); + } + + /** + * 활성 모듈·플러그인의 getMiddleware() 를 검증·정규화해 엔트리 배열로 수집합니다. + * + * @return list, class: class-string, order: int}> + */ + protected function collectEntries(): array + { + $entries = []; + $order = 0; + + foreach ($this->activeExtensions() as [$extension, $identifier, $kind]) { + if (! method_exists($extension, 'getMiddleware')) { + continue; + } + + $declarations = $extension->getMiddleware(); + if (! is_array($declarations)) { + continue; + } + + foreach ($declarations as $declaration) { + $normalized = $this->normalizeDeclaration($declaration, $identifier, $kind); + if ($normalized === null) { + continue; + } + + [$class, $groups, $timing, $targets] = $normalized; + + foreach ($groups as $group) { + $predicates = []; + foreach ($targets as $target) { + foreach ($this->normalizeTarget($target, $identifier, $kind, $group) as $predicate) { + $predicates[] = $predicate; + } + } + + $entries[] = [ + 'timing' => $timing, + 'group' => $group, + 'predicates' => $predicates, + 'class' => $class, + 'order' => $order, + ]; + } + + $order++; + } + } + + return $entries; + } + + /** + * 단일 미들웨어 선언을 검증·정규화합니다. 무효 시 warning 후 null. + * + * @param mixed $declaration getMiddleware() 항목 + * @param string $identifier 확장 식별자 + * @param string $kind 'module' | 'plugin' + * @return array{0: class-string, 1: list, 2: string, 3: list}|null + */ + protected function normalizeDeclaration(mixed $declaration, string $identifier, string $kind): ?array + { + if (! is_array($declaration)) { + return null; + } + + $class = $declaration['class'] ?? null; + if (! is_string($class) || $class === '' || ! class_exists($class)) { + Log::warning('확장 미들웨어 선언의 class 가 유효하지 않아 등록을 건너뜁니다.', [ + 'extension' => $identifier, + 'kind' => $kind, + 'class' => $class, + ]); + + return null; + } + + $groups = $declaration['groups'] ?? []; + $groups = is_array($groups) + ? array_values(array_filter($groups, static fn ($g): bool => in_array($g, self::ALLOWED_GROUPS, true))) + : []; + if ($groups === []) { + Log::warning('확장 미들웨어 선언의 groups 가 비어있거나 허용되지 않은 값이라 등록을 건너뜁니다.', [ + 'extension' => $identifier, + 'kind' => $kind, + 'class' => $class, + 'groups' => $declaration['groups'] ?? null, + ]); + + return null; + } + + $targets = $declaration['targets'] ?? []; + $targets = is_array($targets) + ? array_values(array_filter($targets, static fn ($t): bool => is_string($t) && $t !== '')) + : []; + if ($targets === []) { + Log::warning('확장 미들웨어 선언의 targets 가 비어있어 등록을 건너뜁니다 (대상 명시 필수).', [ + 'extension' => $identifier, + 'kind' => $kind, + 'class' => $class, + ]); + + return null; + } + + $timing = $declaration['timing'] ?? 'after_core'; + if (! in_array($timing, ['before_core', 'after_core'], true)) { + $timing = 'after_core'; + } + + return [$class, $groups, $timing, $targets]; + } + + /** + * target 값을 술어(들)로 정규화합니다 (§1 카탈로그 → 매칭식). + * + * brace 는 여러 name 술어로 전개되므로 list 반환. + * + * @param string $target targets 항목 + * @param string $identifier 확장 식별자 ('self' 치환용) + * @param string $kind 'module' | 'plugin' + * @param string $group 이 엔트리가 펼쳐진 단일 그룹 ('self' 치환용) + * @return list + */ + protected function normalizeTarget(string $target, string $identifier, string $kind, string $group): array + { + // URI 패턴 (무명 라우트 대응) — '/' 로 시작. arg 는 선행 슬래시를 포함한 원형을 유지하고 + // (매칭 시 path 도 선행 슬래시로 정규화), 이렇게 해야 루트('/')·글롭('/admin/*') 모두 + // $request->is() 와 동일하게 동작한다 ($request->path() 는 루트에서 '/' 를 반환). + if (str_starts_with($target, '/')) { + return [['op' => 'uri', 'arg' => $target]]; + } + + return match (strtolower($target)) { + 'self' => [['op' => 'name', 'arg' => "{$group}.{$kind}s.{$identifier}.*"]], + 'all_extensions' => [['op' => 'name_ext']], + 'core' => [['op' => 'name_core']], + 'everything', '*' => [['op' => 'always']], + default => $this->normalizeSpecificTarget($target), + }; + } + + /** + * module:{id} / plugin:{id} / brace / 원시 glob 을 name 술어로 변환합니다. + * + * @param string $target targets 항목 + * @return list + */ + protected function normalizeSpecificTarget(string $target): array + { + if (str_starts_with($target, 'module:')) { + $id = substr($target, strlen('module:')); + + return [['op' => 'name', 'arg' => "*.modules.{$id}.*"]]; + } + + if (str_starts_with($target, 'plugin:')) { + $id = substr($target, strlen('plugin:')); + + return [['op' => 'name', 'arg' => "*.plugins.{$id}.*"]]; + } + + // Str::is 는 brace 미지원 → 미리 전개해 개별 name 술어로 저장. + return array_map( + static fn (string $glob): array => ['op' => 'name', 'arg' => $glob], + $this->expandTargetBraces($target), + ); + } + + /** + * 술어가 현재 요청 라우트명·path 에 매칭되는지 평가합니다. + * + * op=uri → path 매칭; op=always → 무조건 true (무명 라우트 포함); + * name 계열 → 무명 라우트(routeName='')는 항상 miss. + * + * @param array{op: string, arg?: string} $predicate 술어 + * @param string $routeName 요청 라우트명 (무명이면 빈 문자열) + * @param string $path 요청 URI path (선행 슬래시 없음) + * @return bool 매칭 여부 + */ + protected function predicateMatches(array $predicate, string $routeName, string $path): bool + { + $op = $predicate['op']; + + if ($op === 'always') { + return true; + } + + if ($op === 'uri') { + // path 를 선행 슬래시로 정규화해 arg('/...') 와 정렬 ($request->is() 정합). + return Str::is($predicate['arg'] ?? '', '/'.ltrim($path, '/')); + } + + // name 계열: 무명 라우트는 URI/always 로만 잡히므로 여기서 miss. + if ($routeName === '') { + return false; + } + + return match ($op) { + 'name' => Str::is($predicate['arg'] ?? '', $routeName), + 'name_ext' => $this->isExtensionRoute($routeName), + 'name_core' => ! $this->isExtensionRoute($routeName), + default => false, + }; + } + + /** + * 라우트명이 확장 라우트(*.modules.* / *.plugins.*)인지 판별합니다. + * + * @param string $routeName 요청 라우트명 (비어있지 않음) + * @return bool 확장 라우트 여부 + */ + protected function isExtensionRoute(string $routeName): bool + { + return Str::is(self::EXT_GLOB, $routeName); + } + + /** + * brace expansion — 'a.{b,c}.d' → ['a.b.d', 'a.c.d']. 단일 그룹만 지원. + * IDV `IdentityPolicyRepository::expandTargetBraces()` 재사용. + * + * @param string $target targets 항목 + * @return list + */ + protected function expandTargetBraces(string $target): array + { + if (! preg_match('/\{([^{}]+)\}/', $target, $matches)) { + return [$target]; + } + $options = array_map('trim', explode(',', $matches[1])); + + return array_map( + static fn (string $opt): string => preg_replace('/\{[^{}]+\}/', $opt, $target, 1), + $options, + ); + } + + /** + * 활성 모듈·플러그인 인스턴스를 [instance, identifier, kind] 로 순회합니다. + * + * @return iterable + */ + protected function activeExtensions(): iterable + { + foreach (app(ModuleManager::class)->getActiveModules() as $module) { + yield [$module, $module->getIdentifier(), 'module']; + } + + foreach (app(PluginManager::class)->getActivePlugins() as $plugin) { + yield [$plugin, $plugin->getIdentifier(), 'plugin']; + } + } + + /** + * 인덱스 캐시를 무효화합니다 (확장 활성/비활성/설치/제거/리로드 시). + * + * IDV `IdentityPolicy::flushRouteScopeCache()` 미러 정적 메서드. + */ + public static function flush(): void + { + $cache = app(CacheInterface::class); + if ($cache->supportsTags()) { + $cache->flushTags([self::CACHE_TAG]); + + return; + } + $cache->forget(self::CACHE_KEY); + } +} diff --git a/app/Extension/Helpers/FilePermissionHelper.php b/app/Extension/Helpers/FilePermissionHelper.php index 35e99155..b6881b3a 100644 --- a/app/Extension/Helpers/FilePermissionHelper.php +++ b/app/Extension/Helpers/FilePermissionHelper.php @@ -33,12 +33,17 @@ class FilePermissionHelper * @param bool $removeOrphans 소스에 없는 대상 파일/디렉토리 삭제 여부 * @param bool $preserveTopLevelOrphans 최상위 한 레벨의 orphan 보존 여부 * @param array|null $applyList 적용 대상 파일 상대경로 화이트리스트 - * (이 디렉토리 루트 기준, `'sub/file.txt' => true` 형태의 lookup 맵). null 이면 - * 전체 파일 복사(기존 동작). 지정 시 이 맵에 없는 파일은 복사를 스킵한다 — 코어 - * 업데이트 증분 모드에서 "코어가 변경하지 않은 파일"을 건드리지 않기 위함. symlink - * 는 이 필터의 영향을 받지 않고 항상 재생성된다(링크는 목록 산출 대상이 아님). + * (이 디렉토리 루트 기준, `'sub/file.txt' => true` 형태의 lookup 맵). null 이면 + * 전체 파일 복사(기존 동작). 지정 시 이 맵에 없는 파일은 복사를 스킵한다 — 코어 + * 업데이트 증분 모드에서 "코어가 변경하지 않은 파일"을 건드리지 않기 위함. symlink + * 는 이 필터의 영향을 받지 않고 항상 재생성된다(링크는 목록 산출 대상이 아님). + * @param array $preserveLinkPaths orphan 정리 시 삭제하지 않고 보존할 + * symlink/junction 의 상대경로 화이트리스트(이 디렉토리 루트 기준, 예 `['storage']`). + * `removeOrphans=true` 라도 이 목록에 매칭되는 orphan 링크는 삭제하지 않는다 — 코어 + * 업데이트 prune 모드에서 `public/storage` symlink 가 orphan 으로 판정되어 삭제되던 + * 결함(#43) 차단. 화이트리스트 밖 orphan 링크는 기존대로 삭제된다. */ - public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false, bool $preserveTopLevelOrphans = false, ?array $applyList = null): void + public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false, bool $preserveTopLevelOrphans = false, ?array $applyList = null, array $preserveLinkPaths = []): void { if (! File::isDirectory($destination)) { // 신규 디렉토리: 부모 디렉토리의 퍼미션/소유권 상속 @@ -81,7 +86,10 @@ class FilePermissionHelper if ($item->isDir()) { // preserveTopLevelOrphans 는 최상위 한 레벨 한정 — 자식 재귀에는 항상 false 전달. - static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans, preserveTopLevelOrphans: false, applyList: $applyList); + // preserveLinkPaths 는 이 디렉토리 루트 기준 상대경로(예 `public/storage` → `storage`)라 + // 재귀에 그대로 하향 전달 — removeOrphanItems 가 각 레벨에서 itemRelativePath 전체로 + // 매칭하므로 중첩 보호 경로도 정확히 판정된다. + static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans, preserveTopLevelOrphans: false, applyList: $applyList, preserveLinkPaths: $preserveLinkPaths); } else { // 증분 모드(applyList 지정): 화이트리스트에 없는 파일은 스킵 — // 코어가 변경하지 않은 파일의 현재 디스크 상태(사용자 수정 포함)를 보존. @@ -94,7 +102,7 @@ class FilePermissionHelper // 소스에 없는 대상 파일/디렉토리 삭제 if ($removeOrphans && File::isDirectory($destination)) { - static::removeOrphanItems($source, $destination, $excludes, $relativePath, $preserveTopLevelOrphans); + static::removeOrphanItems($source, $destination, $excludes, $relativePath, $preserveTopLevelOrphans, $preserveLinkPaths); } } @@ -202,8 +210,11 @@ class FilePermissionHelper * @param array $excludes 제외할 이름 또는 경로 목록 * @param string $relativePath 현재 상대 경로 * @param bool $preserveTopLevelOrphans 최상위 한 레벨의 orphan 보존 여부 + * @param array $preserveLinkPaths 삭제하지 않고 보존할 symlink/junction + * 상대경로 화이트리스트(루트 기준). orphan 이 링크이면서 이 목록에 매칭되면 삭제하지 + * 않고 보존한다 — `public/storage` symlink 보호(#43). 화이트리스트 밖 orphan 링크는 삭제. */ - protected static function removeOrphanItems(string $source, string $destination, array $excludes, string $relativePath, bool $preserveTopLevelOrphans = false): void + protected static function removeOrphanItems(string $source, string $destination, array $excludes, string $relativePath, bool $preserveTopLevelOrphans = false, array $preserveLinkPaths = []): void { // 최상위 한 레벨에서 소스에 없는 항목(사용자 추가) 보존 — `_bundled/my-project` 등. // 호출자(copyDirectory)는 최상위 진입 시에만 relativePath='' + 플래그 true 로 들어오며, @@ -227,6 +238,21 @@ class FilePermissionHelper // 소스에 존재하지 않는 항목만 삭제 if (! File::exists($srcPath) && ! File::isDirectory($srcPath)) { + // 보호 화이트리스트 매칭 링크/junction 은 orphan 이어도 보존 — 코어 업데이트 + // prune 모드에서 `public/storage` symlink 가 릴리즈 소스에 없다는 이유로 삭제되어 + // 업로드 파일이 404 되던 결함(#43) 차단. 화이트리스트 밖 orphan 링크는 아래에서 삭제. + if ( + in_array($itemRelativePath, $preserveLinkPaths, true) + && (is_link($destItem->getPathname()) || static::isReparsePoint($destItem->getPathname())) + ) { + Log::info('removeOrphanItems: 보호 대상 orphan 링크 보존', [ + 'path' => $destItem->getPathname(), + 'relative' => $itemRelativePath, + ]); + + continue; + } + // symlink / Windows junction 은 링크 자체만 제거 — File::deleteDirectory 는 is_dir() // 추적 검사 후 재귀 삭제하므로 link-to-dir 인 경우 target 의 모든 파일을 삭제하는 // 사고 발생 가능. 링크 검사가 isDir() 보다 먼저 평가되어야 한다. diff --git a/app/Extension/Helpers/StorageLinkHelper.php b/app/Extension/Helpers/StorageLinkHelper.php new file mode 100644 index 00000000..67c2bb3c --- /dev/null +++ b/app/Extension/Helpers/StorageLinkHelper.php @@ -0,0 +1,207 @@ +logger`(채널 + * `upgrade`), 코어 업데이트 커맨드는 `Log::channel('upgrade')`. 파사드를 직접 쓰면 + * migration 의 채널 격리가 깨지므로 로거를 주입받고, `null` 이면 `upgrade` 채널로 폴백한다. + */ +class StorageLinkHelper +{ + /** + * `public/storage` 가 정상 symlink 인지 확인하고, 아니면 멱등 재생성합니다. + * + * 멱등 판정 순서: + * 1. `is_link(public/storage)` → 이미 정상 symlink, no-op. + * 2. `storage/app/public` 부재 → Laravel storage:link 컨벤션 미사용 환경, skip + * (false positive 차단 — 운영자 의도적 구성 존중). + * 3. `public/storage` 부재 → `symlink()` 로 신규 생성 (prune 삭제 갭 복구 핵심). + * 4. `public/storage` 가 일반 디렉토리 → `.broken.{YmdHis}` rename 백업 후 재생성 + * (dereferenced 콘텐츠 데이터 손실 없이 보존). + * + * Windows `SeCreateSymbolicLink` 권한 부족으로 `symlink()` 가 실패하면 target 이 + * 디렉토리일 때 `mklink /J`(junction) 로 폴백한다. 그래도 실패하면 rename 백업을 + * 원위치로 복원하고 warning 로그만 남긴다 (graceful degrade — 데이터 손실 없음). + * + * @param LoggerInterface|null $logger 로그 채널 격리용 주입 로거. + * `null` 이면 `Log::channel('upgrade')` 로 폴백. + */ + public static function ensurePublicStorageLink(?LoggerInterface $logger = null): void + { + $logger ??= Log::channel('upgrade'); + + $publicStorage = public_path('storage'); + $storageSource = storage_path('app/public'); + + // 1) 이미 정상 symlink → no-op (멱등) + if (is_link($publicStorage)) { + return; + } + + // 2) Laravel storage:link source 부재 → 표준 미사용 환경, skip (false positive 차단) + if (! is_dir($storageSource)) { + return; + } + + // 3) public/storage 부재 → 신규 생성 (prune 이 삭제한 케이스 — 기존 스텝 미커버) + if (! file_exists($publicStorage)) { + if (static::createLink($storageSource, $publicStorage)) { + $logger->info('[storage-link] public/storage symlink 신규 생성', [ + 'target' => $storageSource, + ]); + } else { + $logger->warning('[storage-link] public/storage symlink 생성 실패 — 수동 `php artisan storage:link` 필요', [ + 'target' => $storageSource, + ]); + } + + return; + } + + // Windows junction(`mklink /J`)은 `is_link()` 가 false 지만 이미 정상 링크다 + // (reparse point). rename 백업 대상에서 제외 — no-op 로 존중한다. + if (static::isReparsePoint($publicStorage)) { + return; + } + + // 4) public/storage 가 일반 디렉토리(손상) → rename 백업 후 재생성 + $backup = $publicStorage.'.broken.'.date('YmdHis'); + if (! @rename($publicStorage, $backup)) { + $logger->warning('[storage-link] public/storage rename 실패 — 복구 skip', [ + 'path' => $publicStorage, + ]); + + return; + } + + if (static::createLink($storageSource, $publicStorage)) { + $logger->info('[storage-link] public/storage symlink 재생성 완료 — 백업 디렉토리 검증 후 수동 삭제 권장', [ + 'backup' => $backup, + 'target' => $storageSource, + ]); + + return; + } + + // 재생성 실패 → rename 원복 (데이터 손실 없음) + @rename($backup, $publicStorage); + $logger->warning('[storage-link] public/storage symlink 재생성 실패 — rename 원복, 수동 `php artisan storage:link` 필요', [ + 'target' => $storageSource, + ]); + } + + /** + * `symlink()` 로 링크를 생성하고, 실패 시 Windows junction 폴백을 시도합니다. + * + * Windows 에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하지만 junction + * (`mklink /J`)은 권한 없이 생성 가능하므로, target 이 디렉토리이면 junction 으로 폴백한다. + * + * @param string $target 링크가 가리킬 대상 (`storage/app/public`) + * @param string $link 생성할 링크 경로 (`public/storage`) + * @return bool 링크 생성 성공 여부 + */ + protected static function createLink(string $target, string $link): bool + { + if (@symlink($target, $link)) { + static::inheritParentOwnership($link); + + return true; + } + + // symlink 실패 + target 이 디렉토리 → Windows junction 폴백. + if (PHP_OS_FAMILY === 'Windows' && is_dir($target)) { + exec( + 'cmd /c mklink /J '.escapeshellarg($link).' '.escapeshellarg($target).' 2>&1', + $out, + $code + ); + if ($code === 0 && is_dir($link)) { + return true; + } + } + + return false; + } + + /** + * 갓 생성한 링크의 소유자/그룹을 부모 디렉토리로부터 상속시킵니다. + * + * `symlink()` 는 소유권 지정 인자가 없어 실행 프로세스(sudo → root)의 소유로 링크를 + * 만든다. 이 때문에 sudo 로 실행된 코어 업데이트가 종료 시점에 `public/storage` 를 + * 재생성하면 링크가 root:root 로 남아, 원래 앱 실행 유저 소유였던 링크의 소유권이 + * 바뀌던 문제(#43 후속)를 차단한다. 부모 `public/` 의 owner/group 을 기준으로 삼아 + * 이 프로젝트의 "신규 항목은 부모 소유권 상속" 컨벤션(`FilePermissionHelper`)과 일치시킨다. + * + * `chown`/`chgrp` 은 심볼릭 링크의 target 을 따라가므로, 링크 *자체* 를 대상으로 하는 + * `lchown`/`lchgrp` 을 사용한다. 비-POSIX(Windows) 또는 함수 부재/권한 부족 시 무해하게 skip. + * + * @param string $link 방금 생성한 링크 경로 + */ + protected static function inheritParentOwnership(string $link): void + { + // lchown/lchgrp 은 POSIX 전용 — Windows 및 함수 비활성 환경에서는 no-op. + if (! function_exists('lchown') || ! function_exists('lchgrp')) { + return; + } + + $parent = dirname($link); + $owner = @fileowner($parent); + $group = @filegroup($parent); + + if ($owner !== false) { + @lchown($link, $owner); + } + if ($group !== false) { + @lchgrp($link, $group); + } + } + + /** + * 경로가 Windows JUNCTION 인지 판정합니다 (`is_link()` 미인식 reparse point). + * + * Windows 에서 junction 과 일반 디렉토리는 둘 다 `is_dir()` 이 true 라 구분되지 않고, + * `readlink()` 는 일반 디렉토리에는 **자기 경로**를, junction 에는 **target 경로**를 + * 반환한다. 따라서 "readlink 가 자기 경로와 다른 target 을 반환" 하는 경우만 junction 으로 + * 판정한다. 일반 symlink 는 호출부(case 1)가 먼저 처리하므로 여기서 false. 비-Windows 는 + * 항상 false. 이 판정으로 정상 junction 은 case 4(rename 재생성) 대상에서 제외된다. + * + * @param string $path 검사할 경로 + * @return bool junction 여부 + */ + protected static function isReparsePoint(string $path): bool + { + if (PHP_OS_FAMILY !== 'Windows') { + return false; + } + + // 일반 symlink / 파일 은 junction 아님. + if (is_link($path) || is_file($path)) { + return false; + } + + $target = @readlink($path); + if ($target === false) { + return false; + } + + // readlink 가 자기 경로와 다른 target 을 반환하면 junction. (일반 디렉토리는 자기 경로 + // 반환 → 같으므로 false.) 경로 구분자/대소문자 차이를 흡수하기 위해 정규화 후 비교. + $normalize = static fn (string $p): string => strtolower(str_replace('/', '\\', rtrim($p, '/\\'))); + + return $normalize($target) !== $normalize($path); + } +} diff --git a/app/Extension/ModuleManager.php b/app/Extension/ModuleManager.php index c91ae44b..5defac44 100644 --- a/app/Extension/ModuleManager.php +++ b/app/Extension/ModuleManager.php @@ -516,6 +516,9 @@ class ModuleManager implements ModuleManagerInterface // 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록) $this->incrementExtensionCacheVersion(); + // 확장 미들웨어 인덱스 무효화 — 새 모듈의 미들웨어 선언이 즉시 게이트에 반영. + ExtensionMiddlewareRegistry::flush(); + // 훅 발행: 모듈 설치 완료 HookManager::doAction('core.modules.installed', $moduleName); @@ -648,6 +651,10 @@ class ModuleManager implements ModuleManagerInterface // 본인인증 route scope 캐시 무효화 — 재활성화 시 이 모듈이 선언한 정책이 // 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가). IdentityPolicy::flushRouteScopeCache(); + + // 확장 미들웨어 인덱스 무효화 — 재활성화 시 이 모듈이 선언한 미들웨어가 + // 게이트 매칭 후보에 다시 포함되도록 한다. + ExtensionMiddlewareRegistry::flush(); } // 훅 발행: 모듈 활성화 완료 @@ -791,6 +798,9 @@ class ModuleManager implements ModuleManagerInterface // IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다. IdentityPolicy::flushRouteScopeCache(); + // 확장 미들웨어 인덱스 무효화 — 비활성 모듈의 미들웨어가 게이트 매칭에서 즉시 제외. + ExtensionMiddlewareRegistry::flush(); + // 요구사항 #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리 HookManager::doAction('core.modules.after_deactivate', $module->getIdentifier()); } @@ -963,6 +973,9 @@ class ModuleManager implements ModuleManagerInterface // 모듈 상태 캐시 무효화 self::invalidateModuleStatusCache(); + // 확장 미들웨어 인덱스 무효화 — 제거된 모듈의 미들웨어가 게이트 매칭에서 즉시 제외. + ExtensionMiddlewareRegistry::flush(); + // 활성 모듈 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능) $onProgress?->__invoke('files', '파일 삭제 중...'); ExtensionPendingHelper::deleteExtensionDirectory($this->modulesPath, $module->getIdentifier()); @@ -3763,6 +3776,9 @@ class ModuleManager implements ModuleManagerInterface // pending/bundled 목록에서 제거 unset($this->pendingModules[$moduleName]); unset($this->bundledModules[$moduleName]); + + // 확장 미들웨어 인덱스 무효화 — 재로드된 모듈의 갱신된 미들웨어 선언 반영. + ExtensionMiddlewareRegistry::flush(); } } diff --git a/app/Extension/PluginManager.php b/app/Extension/PluginManager.php index 3fbd6a32..197ee9b1 100644 --- a/app/Extension/PluginManager.php +++ b/app/Extension/PluginManager.php @@ -494,6 +494,9 @@ class PluginManager implements PluginManagerInterface // 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록) $this->incrementExtensionCacheVersion(); + // 확장 미들웨어 인덱스 무효화 — 새 플러그인의 미들웨어 선언이 즉시 게이트에 반영. + ExtensionMiddlewareRegistry::flush(); + // 훅 발행: 플러그인 설치 완료 HookManager::doAction('core.plugins.installed', $pluginName); @@ -629,6 +632,10 @@ class PluginManager implements PluginManagerInterface // 본인인증 route scope 캐시 무효화 — 재활성화 시 이 플러그인이 선언한 정책이 // 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가). IdentityPolicy::flushRouteScopeCache(); + + // 확장 미들웨어 인덱스 무효화 — 재활성화 시 이 플러그인이 선언한 미들웨어가 + // 게이트 매칭 후보에 다시 포함되도록 한다. + ExtensionMiddlewareRegistry::flush(); } // 훅 발행: 플러그인 활성화 완료 @@ -775,6 +782,9 @@ class PluginManager implements PluginManagerInterface // IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다. IdentityPolicy::flushRouteScopeCache(); + // 확장 미들웨어 인덱스 무효화 — 비활성 플러그인의 미들웨어가 게이트 매칭에서 즉시 제외. + ExtensionMiddlewareRegistry::flush(); + // 요구사항 #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리 HookManager::doAction('core.plugins.after_deactivate', $plugin->getIdentifier()); } @@ -991,6 +1001,9 @@ class PluginManager implements PluginManagerInterface // 플러그인 상태 캐시 무효화 self::invalidatePluginStatusCache(); + // 확장 미들웨어 인덱스 무효화 — 제거된 플러그인의 미들웨어가 게이트 매칭에서 즉시 제외. + ExtensionMiddlewareRegistry::flush(); + // 활성 플러그인 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능) $onProgress?->__invoke('files', '파일 삭제 중...'); ExtensionPendingHelper::deleteExtensionDirectory($this->pluginsPath, $plugin->getIdentifier()); @@ -3920,6 +3933,9 @@ class PluginManager implements PluginManagerInterface // pending/bundled 목록에서 제거 unset($this->pendingPlugins[$pluginName]); unset($this->bundledPlugins[$pluginName]); + + // 확장 미들웨어 인덱스 무효화 — 재로드된 플러그인의 갱신된 미들웨어 선언 반영. + ExtensionMiddlewareRegistry::flush(); } } diff --git a/app/Http/Middleware/ExtensionMiddlewareGate.php b/app/Http/Middleware/ExtensionMiddlewareGate.php new file mode 100644 index 00000000..c1bb1fc0 --- /dev/null +++ b/app/Http/Middleware/ExtensionMiddlewareGate.php @@ -0,0 +1,65 @@ +route()?->getName() ?? ''; + $path = $request->path(); + $matched = $this->registry->resolveForRoute($routeName, $path, $group, $timing); + + if ($matched === []) { + return $next($request); + } + + // 매칭된 확장 미들웨어들을 Laravel Pipeline 으로 순차 실행 후 $next. + return (new Pipeline($this->app)) + ->send($request) + ->through($matched) + ->then($next); + } +} diff --git a/app/Providers/CoreServiceProvider.php b/app/Providers/CoreServiceProvider.php index 529c5fdc..00cbac47 100644 --- a/app/Providers/CoreServiceProvider.php +++ b/app/Providers/CoreServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Contracts\Extension\CacheInterface; +use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface; use App\Contracts\Extension\HookListenerInterface; use App\Contracts\Extension\ModuleSettingsInterface; use App\Contracts\Extension\StorageInterface; @@ -42,6 +43,7 @@ use App\Enums\DeactivationReason; use App\Extension\Cache\CoreCacheDriver; use App\Extension\CoreVersionChecker; use App\Extension\ExtensionManager; +use App\Extension\ExtensionMiddlewareRegistry; use App\Extension\HookCacheManager; use App\Extension\HookListenerRegistrar; use App\Extension\HookManager; @@ -250,6 +252,9 @@ class CoreServiceProvider extends ServiceProvider $this->app->bind(CacheInterface::class, function () { return new CoreCacheDriver(config('cache.default')); }); + + // 확장 선언 미들웨어 self-gate 인덱스 (요청 내 인덱스 1회 빌드 → singleton) + $this->app->singleton(ExtensionMiddlewareRegistryInterface::class, ExtensionMiddlewareRegistry::class); } /** diff --git a/app/Seo/SeoRenderer.php b/app/Seo/SeoRenderer.php index cf2ab421..65f78ddb 100644 --- a/app/Seo/SeoRenderer.php +++ b/app/Seo/SeoRenderer.php @@ -464,7 +464,6 @@ class SeoRenderer implements SeoRendererInterface 'googleAnalyticsId' => $meta['googleAnalyticsId'], 'googleVerification' => $meta['googleVerification'], 'naverVerification' => $meta['naverVerification'], - 'cssPath' => $this->getCssPath(), 'stylesheets' => $allStylesheets, 'extraHeadTags' => '', 'extraBodyEnd' => '', @@ -732,28 +731,6 @@ class SeoRenderer implements SeoRendererInterface return $tags; } - /** - * Vite 빌드 CSS 경로를 반환합니다. - * - * @return string CSS 경로 - */ - private function getCssPath(): string - { - $manifestPath = public_path('build/manifest.json'); - if (file_exists($manifestPath)) { - $manifest = json_decode(file_get_contents($manifestPath), true); - foreach ($manifest as $entry) { - if (isset($entry['css'])) { - foreach ($entry['css'] as $css) { - return '/build/'.$css; - } - } - } - } - - return '/build/assets/app.css'; - } - /** * SEO 렌더링용 _global 컨텍스트를 구성합니다. * diff --git a/app/Services/CoreUpdateService.php b/app/Services/CoreUpdateService.php index 10cd302d..a7bcf6fc 100644 --- a/app/Services/CoreUpdateService.php +++ b/app/Services/CoreUpdateService.php @@ -904,7 +904,7 @@ class CoreUpdateService * @param \Closure|null $onProgress 진행 콜백 * @param bool $prune true 면 전체 덮어쓰기 + orphan 삭제(기존 동작), false 면 증분 적용 * @param array|null $applyList 적용 대상 상대경로 목록(target 접두사 포함). - * `CoreBackupHelper::computeApplyList()` 산출물. null 이면 증분 불가로 판단해 전체 덮어쓰기. + * `CoreBackupHelper::computeApplyList()` 산출물. null 이면 증분 불가로 판단해 전체 덮어쓰기. */ public function applyUpdate(string $sourcePath, ?\Closure $onProgress = null, bool $prune = false, ?array $applyList = null): void { @@ -953,6 +953,12 @@ class CoreUpdateService // 소스(번들 확장만 포함)에 없다는 이유로 삭제되던 결함 차단. // 번들 확장 디렉토리 *내부* stale 정리는 prune 모드에서만 수행된다. $preserveTopLevelOrphans = str_ends_with($normalizedTarget, '_bundled'); + + // `public` 타깃 prune 시 `public/storage` symlink 를 orphan 삭제에서 보호한다 — + // 릴리즈 소스에는 런타임 symlink 가 없으므로 orphan 으로 판정되어 삭제되면 + // 업로드 파일이 404 되는 결함(#43). 다른 타깃은 빈 배열이라 영향 없음. + $preserveLinkPaths = $normalizedTarget === 'public' ? ['storage'] : []; + FilePermissionHelper::copyDirectory( $src, $dest, @@ -961,6 +967,7 @@ class CoreUpdateService removeOrphans: $prune, preserveTopLevelOrphans: $preserveTopLevelOrphans, applyList: $targetApplyList, + preserveLinkPaths: $preserveLinkPaths, ); } else { // 단일 파일 target — 증분 모드에서는 적용 목록에 있을 때만 복사. diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index 475a8f0d..8f57e0ff 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -198,7 +198,10 @@ class SettingsService $result[$frontendKey] = $this->castValue($value, $fieldSchema); } - return $result; + // 확장이 요청 컨텍스트 기반 값(예: 기기 유형)을 appConfig 에 주입할 수 있도록 필터 훅 제공. + // config/frontend.php 는 정적 config 값만 담으므로, 요청별로 달라지는 값은 이 훅으로 확장이 채운다. + // 프론트는 window.G7Config.appConfig → _global.appConfig 로 그대로 받는다. + return HookManager::applyFilters('core.frontend.filter_app_config', $result); } /** diff --git a/bootstrap/app.php b/bootstrap/app.php index 8e06ca9f..cd1d946e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,6 +7,7 @@ use App\Http\Middleware\AdminMiddleware; use App\Http\Middleware\CheckTemplateDependencies; use App\Http\Middleware\CheckUserStatus; use App\Http\Middleware\EnforceIdentityPolicy; +use App\Http\Middleware\ExtensionMiddlewareGate; use App\Http\Middleware\GzipEncodeResponse; use App\Http\Middleware\MaintenanceModePage; use App\Http\Middleware\OptionalSanctumMiddleware; @@ -106,6 +107,17 @@ $app = Application::configure(basePath: dirname(__DIR__)) EnforceIdentityPolicy::class, ]); + // 확장 선언 미들웨어 self-gate 래퍼 — 확장이 getMiddleware() 로 선언한 미들웨어를 + // 요청 시점에 라우트명·URI 매칭으로 실행. web/api × before_core/after_core 로 4회 등록. + // - before_core → 그룹 선두(prepend): 코어 전처리(인증·SetLocale·IDV) 전체보다 먼저 실행 + // - after_core → 그룹 후미(append): 코어 그룹 미들웨어 뒤에서 실행 + // Kernel 의 prepend/append 는 코어 내부 구현이고, 확장 개발자·게이트 파라미터는 + // before_core/after_core 의도 명칭만 본다. 매칭 확장 미들웨어가 없으면 no-op. + foreach (['web', 'api'] as $extGroup) { + $middleware->prependToGroup($extGroup, ExtensionMiddlewareGate::class.":{$extGroup},before_core"); + $middleware->appendToGroup($extGroup, ExtensionMiddlewareGate::class.":{$extGroup},after_core"); + } + // 비인증 게스트 redirect 경로 가드 (공개#39). // Laravel 12 기본값은 `redirectGuestsTo(fn () => route('login'))` 인데, // 이 프로젝트엔 'login' 이름 라우트가 없어 Accept 헤더 없는 /api/* 비인증 요청이 @@ -134,6 +146,7 @@ $app = Application::configure(basePath: dirname(__DIR__)) 'start.api.session' => StartApiSession::class, 'seo' => SeoMiddleware::class, 'identity.policy' => EnforceIdentityPolicy::class, + 'extension.middleware' => ExtensionMiddlewareGate::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/config/app.php b/config/app.php index df842759..986558af 100644 --- a/config/app.php +++ b/config/app.php @@ -231,7 +231,7 @@ return [ | */ - 'version' => env('APP_VERSION', '7.0.4'), + 'version' => env('APP_VERSION', '7.0.5'), /* |-------------------------------------------------------------------------- diff --git a/docs/backend/core-update-system.md b/docs/backend/core-update-system.md index 0debc90d..eadcb7f1 100644 --- a/docs/backend/core-update-system.md +++ b/docs/backend/core-update-system.md @@ -210,6 +210,8 @@ v접두사 자동 감지 (resolveGithubArchiveUrl): --prune 모드 (또는 백업 부재 fallback): - targets 전체 무조건 덮어쓰기 + orphan 삭제 (기존 동작) - 백업 부재 시 base 가 없어 3-way 불가 → 안전하게 전체 덮어쓰기로 회귀 + 안내 출력 +- `public/storage` symlink 는 orphan 삭제에서 보호됨 — `public` 타깃 정리 시 + `preserveLinkPaths: ['storage']` 화이트리스트로 링크/junction 을 보존 (#43, 아래 §참조) 공통: - 자동 발견 폴백: targets 에 미등재된 source 최상위 항목도 스캔하여 적용 @@ -222,6 +224,12 @@ v접두사 자동 감지 (resolveGithubArchiveUrl): > **증분 모드 잔존 stale 파일 정리**: 기본(증분) 모드는 orphan 을 삭제하지 않으므로, 신 버전에서 제거된 파일이 활성 디렉토리에 잔존할 수 있다. 완료 요약이 잔존을 안내하며, 정리하려면 다음 업데이트를 `--prune` 으로 실행하거나 단발성 정리 도구 `php artisan hotfix:rollback-stale-files --prune` 을 사용한다 (진단 모드 기본, `--prune` 시 확인 프롬프트 후 정리, symlink/protected_paths 가드 적용). 상세 사용법: [docs/cheatsheet.md](../cheatsheet.md) "단발성 결함 보정 (hotfix)". +> **`public/storage` symlink 보존 + 종료 시 복구 (#43)**: 심층 방어 2층 구조로 `--prune` 실행 후에도 `public/storage` symlink 가 정상 유지된다. +> - **층 1 (예방)**: `--prune` 은 `public` 타깃에서 orphan(릴리즈 소스에 없는 항목)을 삭제하는데, 런타임 symlink 인 `public/storage` 는 릴리즈 소스에 없어 orphan 으로 판정되어 삭제되던 결함이 있었다(업로드 파일 404). `applyUpdate` 가 `public` 타깃 처리 시 `FilePermissionHelper::copyDirectory(..., preserveLinkPaths: ['storage'])` 로 화이트리스트를 전달해, 매칭되는 orphan symlink/junction 만 삭제에서 제외한다. 화이트리스트 밖 orphan 링크는 기존대로 삭제된다(정밀 보호 — 무조건 보존 아님). +> - **층 2 (복구)**: 업데이트 종료 시점(정상 Step 11 + 핸드오프 catch)에 `StorageLinkHelper::ensurePublicStorageLink()` 를 호출해 `public/storage` 가 정상 링크인지 확인하고, 부재/손상이면 `storage/app/public` 을 가리키는 링크를 (필요 시 `.broken.{YmdHis}` rename 백업 후) 재생성한다. 버전 무관 매 업데이트 실행. Windows `SeCreateSymbolicLink` 권한 부족 시 junction(`mklink /J`) 폴백까지 시도하고, 그래도 실패하면 rename 원복 + 수동 `storage:link` 안내(데이터 손실 없음). 롤백 catch 경로는 백업 복원이 링크를 원상 회복하므로 대상 아님. +> - **소유권 상속**: `symlink()` 은 소유권 인자가 없어 sudo 실행 시 링크가 root:root 로 생성된다. 재생성 직후 부모 `public/` 의 owner/group 을 기준으로 `lchown`/`lchgrp`(링크 자체 대상 — `chown` 은 target 을 따라감) 보정해 원래 앱 실행 유저 소유를 유지한다. 이 프로젝트의 "신규 항목은 부모 소유권 상속"(`FilePermissionHelper`) 컨벤션과 일치. 비-POSIX/함수 부재/권한 부족 시 무해하게 skip. +> - 이 복구 로직은 beta.5 DataMigration `RecoverPublicStorageSymlink` 와 `StorageLinkHelper` 로 일원화되어 있다(부재→재생성 케이스까지 상위호환). + > **자동 발견 폴백의 배경 (engine-v / beta.4 이후)**: Step 7 은 부모 프로세스의 `config('app.update.targets')` 를 사용한다. 부모는 업그레이드 *직전* 의 코드/메모리 상태이므로 신버전이 도입한 신규 최상위 디렉토리(예: beta.4 의 `lang-packs/`) 가 부모의 stale targets 에서 누락된다. 폴백은 이 결함을 안전망으로 차단하며, `config/app.php` 의 `update.protected_paths` 가 런타임 데이터(`storage`)·로컬 환경(`.env*`)·별도 파이프라인 산출물(`vendor`)·개발 메타(`.git`/`.claude`/`.serena` 등) 의 의도치 않은 덮어쓰기를 방지한다. ### Step 9: 마이그레이션 + 동기화 @@ -671,7 +679,8 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값') | 메서드 | 시그니처 | 설명 | |--------|---------|------| -| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress, bool $prune = false, ?array $applyList = null): void` | _pending → base_path 적용. `$applyList` 지정 + `!$prune` 이면 증분(코어 변경분만), 그 외 전체 덮어쓰기 + orphan 삭제 | +| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress, bool $prune = false, ?array $applyList = null): void` | _pending → base_path 적용. `$applyList` 지정 + `!$prune` 이면 증분(코어 변경분만), 그 외 전체 덮어쓰기 + orphan 삭제. `public` 타깃은 `copyDirectory(..., preserveLinkPaths: ['storage'])` 로 `public/storage` symlink/junction 을 orphan 삭제에서 보호 (#43) | +| `StorageLinkHelper::ensurePublicStorageLink()` | `(?\Psr\Log\LoggerInterface $logger = null): void` | `public/storage` 멱등 복구. 정상 링크면 no-op, 부재/손상이면 `storage/app/public` 링크 재생성(부재 시 신규, 손상 디렉토리는 `.broken.{YmdHis}` 백업 후). 재생성 링크는 부모 `public/` 의 소유자/그룹을 `lchown`/`lchgrp` 상속(sudo 후 root:root 잔존 차단). Windows junction 폴백. `CoreUpdateCommand` 종료 시점 + migration 05 가 호출 (#43) | | `runComposerInstallInPending()` | `(string $pendingPath, ?Closure $onProgress): void` | _pending에서 composer install (--no-scripts) | | `isComposerUnchangedForCore()` | `(string $pendingPath): bool` | composer.json/lock MD5 비교 | | `runComposerInstall()` | `(?Closure $onProgress): void` | base_path에서 composer install | diff --git a/docs/backend/middleware.md b/docs/backend/middleware.md index f06918c5..7226597e 100644 --- a/docs/backend/middleware.md +++ b/docs/backend/middleware.md @@ -12,6 +12,7 @@ 3. 실행 순서: 전역 → 그룹(web/api) → 라우트 → 컨트롤러 4. permission 미들웨어: scope_type 기반 접근 제어 (except/only/menu 옵션 폐기) 5. scope 체크: Permission.resource_route_key + owner_key + role_permissions.scope_type +6. 확장 미들웨어 → getMiddleware() 선언(self-gate). SP Kernel 직접 조작·라우트 파일 자기 FQCN 금지 ``` --- @@ -21,6 +22,7 @@ - [핵심 원칙](#핵심-원칙) - [미들웨어 실행 순서](#미들웨어-실행-순서) - [미들웨어 등록 방식](#미들웨어-등록-방식) +- [확장 미들웨어 선언 (self-gate)](#확장-미들웨어-선언-self-gate) - [올바른 등록 예시](#올바른-등록-예시) - [문제 상황과 해결책](#문제-상황과-해결책) - [디버깅 방법](#디버깅-방법) @@ -81,6 +83,7 @@ | `appendToGroup('api', ...)` | 인증 **후** | ✅ 가능 | 사용자별 설정 | | `appendToGroup('web', ...)` | 인증 **후** | ✅ 가능 | 사용자별 설정 | | `alias()` | 라우트 지정 시 | ✅ 가능 | 권한 체크 등 | +| `getMiddleware()` (확장 self-gate) | 요청 시점 대상 매칭 | timing 에 따름 | 확장(모듈/플러그인)의 미들웨어 — [아래 절](#확장-미들웨어-선언-self-gate) | ### append vs appendToGroup 차이 @@ -93,6 +96,73 @@ --- +## 확장 미들웨어 선언 (self-gate) + +확장(모듈/플러그인)은 자기 미들웨어를 web/api 그룹에 직접 넣지 않는다. 각 확장이 `getMiddleware()` 로 미들웨어와 그 부착 대상을 선언하고, 코어 게이트 래퍼(`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 대상 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행한다. 코어 IDV 정책의 라우트명 인덱스 조회 모델과 동일하다. + +배경: 확장 라우트는 부팅 이후 지연 등록되고 확장 간 부팅 순서 보장이 없어, 부팅 시점에 다른 확장(또는 코어 특정 라우트)의 라우트를 순회해 미들웨어를 부착하는 방식은 성립하지 않는다. 요청 시점 매칭은 모든 라우트가 등록 완료된 뒤라 이 제약을 원천 회피한다. + +### getMiddleware() 스키마 + +```php +public function getMiddleware(): array +{ + return [ + [ + 'class' => VerifyGuestOrderToken::class, // 미들웨어 FQCN (class_exists 검증) + 'groups' => ['api'], // ['web'] | ['api'] | ['web','api'] + 'timing' => 'after_core', // 'after_core'(기본) | 'before_core' + 'targets' => ['self'], // 라우트명/URI 패턴 배열 (필수, 아래 카탈로그) + ], + ]; +} +``` + +| 필드 | 필수 | 의미 | +|------|------|------| +| `class` | O | 미들웨어 FQCN. `class_exists` 실패 시 등록 거부 | +| `groups` | O | `['web']` \| `['api']` \| `['web','api']`. 빈 배열·미허용 그룹은 등록 거부 | +| `timing` | X | `after_core`(기본, 코어 그룹 미들웨어 뒤) \| `before_core`(코어 전처리 전체보다 먼저). `before_core` 는 인증 결과·로케일에 의존하는 미들웨어에 쓰지 않는다 (응답 사전차단/전처리 전용) | +| `targets` | O | 부착 대상 패턴 배열. 누락·빈 배열 시 등록 거부 | + +### targets 카탈로그 + +| 값 | 의미 | 매칭 방식 | +|----|------|----------| +| `self` | 자기 확장 라우트 전체 | 항목 groups 의 각 그룹마다 `{group}.modules.{id}.*` / `{group}.plugins.{id}.*` prefix 로 치환 | +| `all_extensions` | 모든 확장 라우트 (코어 제외) | 라우트명이 `*.modules.*` 또는 `*.plugins.*` | +| `core` | 코어 라우트 전체 (확장 제외) | 라우트명이 확장 prefix 가 **아닌** 것 (negative 판별). 무명 라우트는 core 로 간주하지 않음 | +| `everything` (별칭 `*`) | 코어 + 모든 확장 (전부) | 무조건 매칭 (무명 라우트 포함) | +| `module:{id}` | 특정 모듈 라우트 전체 | `*.modules.{id}.*` | +| `plugin:{id}` | 특정 플러그인 라우트 전체 | `*.plugins.{id}.*` | +| 원시 glob 문자열 | 임의 라우트 이름 패턴 (`api.modules.foo.cart.*`) | `Str::is()` 라우트명 매칭 | +| brace 문자열 | `{a,b}` 단일 그룹 전개 | 전개 후 각 glob 매칭 | +| `/` 로 시작하는 URI 패턴 | 임의 요청 URI 패턴 (`/`, `/admin/*`) — 무명 라우트 타게팅용 | `$request->path()` 매칭 | + +`everything`·`*`, `all_extensions`, `core` 는 코어·타 확장에 개입하는 **광역 타게팅** 이다. 선언에 대상이 명문화되므로 감사 가능하며, 리뷰 시 주의 대상으로 표기한다. 자기 라우트 중 일부만 노려야 하면 `self` 대신 원시 glob 으로 정밀화한다. + +### 이중 매칭 (라우트명 + URI) — 무명 catch-all 대응 + +target 이 `/` 로 시작하면 **URI 패턴**(`$request->path()` 매칭), 아니면 **라우트명 패턴**(`$request->route()?->getName()` 매칭)이다. 라우트명은 dot-notation 이라 `/` 로 시작하지 않으므로 구분이 명확하다. 코어 SSR 셸 catch-all 라우트는 `->name()` 이 없어(무명) 라우트명으로 타게팅할 수 없다 → 이런 무명 라우트를 대상으로 하려면 URI 패턴(`/`)이나 `everything` 을 쓴다. 라우트명 계열 target 은 무명 라우트에서 항상 miss 한다. + +### 게이트 동작과 무효화 + +- 게이트 래퍼는 코어가 bootstrap 에서 web/api × before_core/after_core 로 4회 등록한다. 매칭 확장 미들웨어가 없으면 no-op 이라 확장 0개 환경에서도 무해하다. +- registry 인덱스는 **활성 확장만** 포함한다. 비활성 확장의 미들웨어는 게이트가 실행하지 않는다. +- 인덱스는 확장 활성/비활성/설치/제거/리로드 시 자동 무효화된다. + +### 금지 패턴 + +| 금지 | 올바른 방식 | +|------|------------| +| 확장 SP `boot()` 에서 `HttpKernel::append/prepend/pushMiddlewareToGroup()` 직접 호출 | `getMiddleware()` 선언 | +| 확장 SP 에서 `aliasMiddleware()` 직접 호출 | `getMiddleware()` 선언 | +| 확장 라우트 파일에서 자기 `Http\Middleware\` FQCN 을 `->middleware(Foo::class)` 로 부착 | `getMiddleware()` 선언 | + +자동 차단: audit 룰 `extension-middleware-declarative-registration`, `extension-route-middleware-alias-reference`, `extension-middleware-gate-cache-coverage` (모두 error). 서드파티 확장의 구방식은 Laravel 공식 API 라 런타임 차단은 없고 문서로 안내한다. + +--- + ## 올바른 등록 예시 ### bootstrap/app.php diff --git a/docs/extension/module-basics.md b/docs/extension/module-basics.md index 080a61c9..35c56a63 100644 --- a/docs/extension/module-basics.md +++ b/docs/extension/module-basics.md @@ -141,6 +141,7 @@ | `getHookListeners()` | `[]` | 훅 리스너 | | `getDependencies()` | `[]` | 의존성 | | `getMetadata()` | `[]` | 메타데이터 | +| `getMiddleware()` | `[]` | 확장 미들웨어 선언 (self-gate targets) — `{class, groups, timing?, targets}` ([middleware.md](../backend/middleware.md)) | | `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 모듈은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `ModuleManager::runUpgradeSteps` 가 `RuntimeException` throw | #### 동적 권한/역할/메뉴 보존 규칙 diff --git a/docs/extension/module-routing.md b/docs/extension/module-routing.md index fe763e57..110b7070 100644 --- a/docs/extension/module-routing.md +++ b/docs/extension/module-routing.md @@ -74,6 +74,8 @@ vendor-my-module → Modules\Vendor\My\Module | URL prefix | `modules/{module-name}` | | Name prefix | `web.modules.{module-name}.` | +> 라우트 이름(`{web\|api}.modules.{module-name}.{name}`)은 확장 미들웨어 타게팅의 1급 키다. `Module::getMiddleware()` 의 `targets` 는 이 라우트명 패턴으로 미들웨어 부착 대상을 지정한다. 상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../backend/middleware.md#확장-미들웨어-선언-self-gate). + --- ## 4. 라우트 파일 작성 diff --git a/docs/extension/plugin-development.md b/docs/extension/plugin-development.md index a8691bde..b166c436 100644 --- a/docs/extension/plugin-development.md +++ b/docs/extension/plugin-development.md @@ -240,6 +240,7 @@ class Plugin implements PluginInterface | `getDynamicRoleIdentifiers()` | `[]` | 런타임 생성 역할 식별자 — stale cleanup 보존 대상 | | `getDependencies()` | `[]` | 의존하는 모듈/플러그인 목록 | | `getHookListeners()` | `[]` | 훅 리스너 클래스 목록 | +| `getMiddleware()` | `[]` | 확장 미들웨어 선언 (self-gate targets) — `{class, groups, timing?, targets}` ([middleware.md](../backend/middleware.md)) | | `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 플러그인은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `PluginManager::runUpgradeSteps` 가 `RuntimeException` throw | > **동적 식별자 보존 규칙**: `Permission::updateOrCreate()` / `Role::firstOrCreate()` 등으로 런타임에 생성한 엔티티는 업데이트 시 `cleanupStalePluginEntries` 에 의해 "정적 정의에 없는 고아 레코드" 로 판정되어 삭제될 위험이 있습니다. 이를 방지하려면 동적 식별자 목록을 위 3개 훅에서 반환하세요 — 정적 정의 + 동적 식별자가 병합된 expected 목록을 기준으로 판정되어 보존됩니다. 상세는 [extension-update-system.md](extension-update-system.md) 참조. @@ -394,6 +395,7 @@ class FooPluginServiceProvider extends BasePluginServiceProvider - **글로벌 `CacheInterface` / `StorageInterface` 바인딩 재정의 금지** — `$this->app->singleton(CacheInterface::class, ...)` 또는 `app()->bind(StorageInterface::class, ...)` 는 코어/타 확장 도메인을 누수시킨다. audit 룰 `extension-no-global-cache-rebind` 가 자동 차단한다. - **인라인 contextual binding 복제 금지** — `$this->app->when([X::class])->needs(StorageInterface::class)->give(...)` 패턴을 직접 작성하지 말고 `$storageServices` 배열에 등록한다. - **Listener 의 동적 인스턴스 생성에서 `app(CacheInterface::class)` 직접 호출 금지** — `app()->makeWith(MyService::class, [...])` 로 컨테이너 해석에 위임해야 contextual binding 이 적용된다. +- **HTTP Kernel 미들웨어 그룹 직접 조작 금지** — SP `boot()` 에서 `HttpKernel::append/prepend/pushMiddlewareToGroup()` / `aliasMiddleware()`, 또는 라우트 파일에서 자기 `Http\Middleware\` FQCN 을 `->middleware(Foo::class)` 로 부착하지 않는다. 대신 `Plugin::getMiddleware()` 로 미들웨어와 부착 대상(targets)을 선언하면 코어 self-gate 게이트가 요청 시점에 실행한다. audit 룰 `extension-middleware-declarative-registration` / `extension-route-middleware-alias-reference` 가 자동 차단한다. 상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../backend/middleware.md#확장-미들웨어-선언-self-gate). 상세: [cache-driver.md `## 확장에서 cache 바인딩`](cache-driver.md) diff --git a/docs/testing/e2e-testing.md b/docs/testing/e2e-testing.md index 8bd2dd88..392a24ce 100644 --- a/docs/testing/e2e-testing.md +++ b/docs/testing/e2e-testing.md @@ -74,14 +74,27 @@ test('@smoke 홈페이지 마운트', async ({ page }) => { 기존 PHPUnit testsuite (`Unit`/`Feature`/`Module`/`Plugin`) 및 Vitest 의 코어/확장 분리 원칙과 동일. -확장이 자체 config 를 가지므로 **확장 디렉토리에서 직접 실행**: +확장은 `tests/Playwright/playwright.config.ts` 에 자체 config 를 가지므로 **확장 디렉토리에서 직접 실행**한다. +config 가 확장 루트가 아니라 `tests/Playwright/` 아래에 있으므로 `npx playwright test` 를 인자 없이 부르면 +config 를 찾지 못한다 — `npm run test:e2e` (config 경로가 이미 묶여 있음) 를 쓴다. ```powershell cd modules/_bundled/sirsoft-ecommerce -$env:PLAYWRIGHT_BASE_URL='https://g7.dev' -npx playwright test +npm run test:e2e # 확장 전체 +npm run test:e2e -- specs/admin/some.spec.ts # 단일 spec +npm run test:e2e:ui # UI 디버깅 모드 ``` +base URL 은 코어 `.env` 의 `APP_URL` 에서 자동 해석된다 (`PLAYWRIGHT_BASE_URL` 로 덮어쓸 수 있다). + +### 실행 시 유의 (경험칙) + +| 항목 | 내용 | +| --- | --- | +| 산출물 위치 | 리포트·trace 는 **코어 루트**의 `test-results/{type}/{id}/`, `playwright-report/{type}/{id}/` 에 쌓인다. 확장 디렉토리 안에 쌓으면 Windows 에서 `{module\|template}:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다 | +| 레이아웃 수정 후 | 레이아웃 JSON 을 고쳤으면 `{type}:update {id} --force` **+ `template:cache-clear {템플릿}`** 까지 해야 브라우저에 반영된다. 레이아웃은 템플릿 캐시(`/api/layouts/{template}/...`)로 서빙되므로 `cache:clear` 만으로는 갱신되지 않는다 | +| 공유 상태 | 같은 관리자 설정 화면을 건드리는 spec 이 병렬로 돌면 서로의 저장 상태를 덮어써 실패할 수 있다. 설정 저장을 수반하는 spec 은 `--workers=1` 로 확인한다 | + ## §4. 데이터 생성 위치 — 책임 분리 매트릭스 | 데이터 종류 | 책임 영역 | 위치 | 호출 | diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 1ef02dee..35ef55bb 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.4] - 2026-07-14 + +### Added + +- 주문설정 결제수단 목록의 "PG 고정" 표시에 대한 일본어 번역을 추가했습니다. + ## [1.0.3] - 2026-07-14 ### Added diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json index fc0635e2..16f3a899 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json @@ -151,7 +151,8 @@ "pg_not_installed": "PG未インストール", "pg_select_first": "PG社をまず選択してください", "mileage_deduction_timing": "マイレージ差し引き時点", - "mileage_deduction_timing_disabled_hint": "マイレージ使用がオフになっているため、差し引き時点が適用されません。" + "mileage_deduction_timing_disabled_hint": "マイレージ使用がオフになっているため、差し引き時点が適用されません。", + "pg_fixed": "PG 固定" }, "bank_accounts": { "title": "無通帳口座番号設定", diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json index 0cd77c21..180054e3 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)", "ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)" }, - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "scope": "module", "target_identifier": "sirsoft-ecommerce", diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md new file mode 100644 index 00000000..5d8e4a68 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +이 언어팩의 모든 주요 변경사항을 기록합니다. +형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, +[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. + +## [1.0.1] + +### Added + +- 환불 진행 중 안내(`refund.in_progress`)와 KCP CLI 바이너리 누락·실행 권한 부족 안내(`errors.cli_binary_missing`, `errors.cli_binary_not_executable`) 일본어 번역 추가 — 환불 중복 요청·CLI 실행 환경 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다. +- 주문 설정 화면의 NHN KCP 테스트모드 경고(`nhnkcp_test_mode_status`, `test_mode_settings_warning_*`) 일본어 번역 추가 — 테스트결제 상태 안내와 실결제 설정 이동 버튼이 일본어 로케일에서 자연스럽게 표시됩니다. diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/activity_log.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/activity_log.php new file mode 100644 index 00000000..eec5866c --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/activity_log.php @@ -0,0 +1,10 @@ + [ + 'cancel' => 'PG決済キャンセル', + ], + 'description' => [ + 'payment_cancel' => 'NHN KCP決済キャンセルが確認されました(注文 :order_number、返金金額 :refund_amount円)。', + ], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php new file mode 100644 index 00000000..e520bb91 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php @@ -0,0 +1,16 @@ + [ + 'missing_tid' => '取引番号(tno)がないため返金を進めることができません。', + 'default_reason' => '購入者返金リクエスト', + 'in_progress' => 'NHN KCP 返金はすでに処理中です。', + ], + 'errors' => [ + 'wsdl_missing' => 'KCP WSDL ファイルがありません: :file', + 'approval_key_error' => 'KCP 承認キーエラー [:code]: :message', + 'soap_error' => 'KCP SOAP 連携エラー: :message', + 'cli_binary_missing' => 'KCP CLIバイナリ不足: :path', + 'cli_binary_not_executable' => 'KCP CLIバイナリ実行権限不足 — 管理者の対応が必要です (sudo chmod 755 :path)', + ], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/payment_methods.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/payment_methods.php new file mode 100644 index 00000000..694f9498 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/payment_methods.php @@ -0,0 +1,24 @@ + [ + 'name' => 'PAYCO', + 'description' => 'PAYCOで決済(NHN KCP)', + ], + 'naverpay' => [ + 'name' => 'ネイバーペイ(カード)', + 'description' => 'ネイバーペイクレジットカードで決済(NHN KCP)', + ], + 'naverpay_point' => [ + 'name' => 'ネイバーペイ(ポイント)', + 'description' => 'ネイバーペイマネー/ポイントで決済(NHN KCP)', + ], + 'kakaopay' => [ + 'name' => 'カカオペイ', + 'description' => 'カカオペイで決済(NHN KCP)', + ], + 'applepay' => [ + 'name' => 'アップルペイ', + 'description' => 'アップルペイで決済(NHN KCP)·アップルペイはiOSデバイスのモバイル決済のみ利用可能です。', + ], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/provider.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/provider.php new file mode 100644 index 00000000..29ebc927 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/provider.php @@ -0,0 +1,6 @@ + 'NHN KCP', + 'description' => 'NHN KCP決済ゲートウェイ(Standard Pay連動)— カード·口座振替·仮想口座·携帯電話決済対応', +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/frontend/ja.json new file mode 100644 index 00000000..52e22b53 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/frontend/ja.json @@ -0,0 +1,141 @@ +{ + "editor": { + "data_source": { + "health": "連携状態", + "kcp_escrow_delivery": "エスクロー配送", + "kcp_status": "決済状態", + "nhnkcp_easy_pay_config": "簡便決済設定", + "nhnkcp_test_mode_status": "テストモード状態", + "nhnkcp_test_map": "テスト決済マップ", + "nicepay_test_map": "テスト決済マップ", + "settings": "決済設定", + "vbank_info": "仮想口座情報" + } + }, + "messages": { + "refund": { + "missing_tid": "決済取引番号(tno)がありません。", + "default_reason": "管理者返金" + }, + "payment": { + "auth_failed": "KCP決済認証に失敗しました。", + "order_not_found": "注文が見つかりません。", + "amount_mismatch": "決済金額が一致しません。", + "confirm_failed": "KCP取引照会に失敗しました。" + } + }, + "payment_error": { + "title": "決済エラー", + "message": "決済処理中にエラーが発生しました。" + }, + "user": { + "test_mode_badge": "テスト決済", + "payment_type_label": "決済方法", + "receipt_label": "領収書", + "receipt_view": "領収書照会", + "cash_receipt_view": "現金領収書照会", + "vbank_title": "仮想口座入金案内", + "vbank_bank_name": "銀行", + "vbank_account": "口座番号", + "vbank_holder": "預金者名", + "vbank_due": "入金期限", + "vbank_notice": "入金期限内に入金いただかない場合、注文は自動キャンセルされます。", + "tax_breakdown_title": "税金詳細", + "supply_amount": "供給価額", + "vat_amount": "付加価値税", + "tax_free_amount": "非課税金額", + "escrow_badge": "エスクロー" + }, + "admin": { + "test_mode_badge": "テスト決済", + "test_mode_order_notice": "この注文はテスト決済として処理されました。", + "test_mode_settings_warning_plugin": "NHN KCP", + "test_mode_settings_warning_title": "(注意)NHN KCP決済設定が現在テスト決済になっています。", + "test_mode_settings_warning_body": "テスト決済では実際の決済は発生しません。ショップ運営中の場合は必ず実決済に設定して運営してください。", + "test_mode_settings_warning_action": "実決済に設定", + "order_info_title": "NHN KCP決済情報", + "order_info_description": "DBに保存されたKCP決済情報を表示します。", + "pay_status_approved": "承認", + "pay_status_cancelled": "キャンセル", + "pay_status_unknown": "未確認", + "result_tno": "取引番号 (TNO)", + "result_app_no": "承認番号", + "result_app_time": "承認日時", + "result_pay_method": "決済方法", + "result_res_cd": "応答コード", + "escrow_badge": "エスクロー", + "escrow_delivery_title": "KCPエスクロー配送登録", + "escrow_delivery_description": "商品発送後、KCPに送り状番号を登録してください。", + "escrow_delivery_invoice_label": "送り状番号", + "escrow_delivery_invoice_placeholder": "送り状番号入力", + "escrow_delivery_courier_label": "配送業者", + "escrow_delivery_courier_placeholder": "配送業者選択", + "escrow_delivery_already_registered": "配送登録完了", + "escrow_delivery_registered_at": "登録日時", + "escrow_delivery_invoice_info": "送り状番号", + "escrow_delivery_courier_info": "配送業者", + "escrow_delivery_register_button": "配送登録", + "escrow_delivery_change_button": "配送情報編集", + "escrow_delivery_registering": "登録中...", + "escrow_delivery_success": "配送情報がKCPに登録されました。", + "escrow_delivery_error": "配送登録に失敗しました。" + }, + "settings": { + "title": "NHN KCP設定", + "description": "NHN KCP決済プラグイン設定を管理します。", + "test_mode": "テストモード", + "test_mode_hint": "テストモードでは実際の決済は発生しません。", + "live_mode_warning_title": "ライブモード有効化", + "live_mode_warning_body": "実際の決済が発生します。ライブサイトコードおよびキーを正確に設定したことを確認してください。", + "section_test_keys": "テスト認証情報", + "test_site_cd": "テストサイトコード (site_cd)", + "test_site_cd_hint": "KCPから発行されたテストサイトコード (デフォルト値: T0000)", + "test_site_key": "テストサイトキー (site_key)", + "test_site_key_hint": "KCPから発行されたテストサイトキー", + "section_live_keys": "ライブ認証情報", + "live_site_cd": "ライブサイトコード (site_cd)", + "live_site_cd_hint": "KCPから発行されたライブサイトコード (SRプレフィックスを除外して入力)", + "live_site_cd_example": "例: xxxxxx → 実際に使用するsite_cdはSRxxxxxx", + "live_site_key": "ライブサイトキー (site_key)", + "live_site_key_hint": "外部に暴露されないよう注意してください。", + "section_easy_pay": "簡便決済設定", + "section_easy_pay_hint": "使用する簡便決済サービスを選択してください。KCP加盟店契約が完了したサービスのみ有効化してください。", + "easy_pay_allow_with_other_pg": "他のPGで使用可能です", + "easy_pay_allow_with_other_pg_hint": "有効化すると、他のPG事業者が基本決済方法として設定されている場合でも、チェックアウトページにNHN KCP簡便決済ボタンが表示されます。", + "easy_pay_payco": "PAYCO (ペイコ)", + "easy_pay_naverpay": "ネイバーペイ", + "easy_pay_naverpay_hint": "テストモードでは決済不可", + "easy_pay_naverpay_point": "ネイバーペイポイント決済", + "easy_pay_naverpay_point_hint": "KCP加盟店ではネイバーペイポイント決済の契約が必要です。[DR02]エラーが発生した場合は無効化してKCPにお問い合わせください。", + "easy_pay_kakaopay": "カカオペイ", + "easy_pay_kakaopay_hint": "テストモードでは決済不可", + "easy_pay_applepay": "アップルペイ", + "easy_pay_applepay_hint": "iOS Safariでのみ使用可能です", + "section_redirect": "リダイレクトURL", + "redirect_success_url": "決済成功リダイレクトURL", + "redirect_success_url_hint": "相対パス(/shop/...)または完全URLの両方が可能です。{orderId}は注文番号に自動置換されます。", + "redirect_fail_url": "決済失敗リダイレクトURL", + "redirect_fail_url_hint": "相対パスまたは完全URLの両方が可能です。エラー情報はクエリパラメータとして自動追加されます。", + "section_vbank": "仮想口座設定", + "section_vbank_hint": "仮想口座決済に関する設定です。", + "vbank_expire_days": "入金有効期限", + "vbank_expire_days_hint": "仮想口座発行後、入金可能な期限(1~7日)。", + "vbank_expire_days_unit": "日", + "section_vbank_notify": "仮想口座入金通知URL", + "vbank_notify_hint": "NHN KCP加盟店管理画面でこのURLを入金通知URLとして登録する必要があり、仮想口座決済が自動的に処理されます。", + "vbank_notify_copied": "URLがクリップボードにコピーされました。", + "copy": "コピー", + "section_escrow": "エスクロー設定", + "section_escrow_hint": "エスクロー決済を使用すると、購入者が購入確認後、販売者に決済金額が支払われます。", + "use_escrow": "エスクロー決済を使用", + "use_escrow_hint": "有効化するとすべての決済にエスクローが適用されます。", + "escrow_test_site_cd": "エスクロー テストサイトコード", + "escrow_test_site_cd_hint": "KCPエスクロー テスト用site_cd(デフォルト:T0007)", + "section_escrow_notify": "エスクロー共通通知URL", + "escrow_common_notify_hint": "NHN KCP加盟店管理画面でこのURLを共通通知URLとして登録する必要があり、購入確認·キャンセルが自動的に処理されます。", + "escrow_common_notify_copied": "URLがクリップボードにコピーされました。" + }, + "easy_pay": { + "section_title": "簡便決済" + } +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/language-pack.json new file mode 100644 index 00000000..4285e495 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/language-pack.json @@ -0,0 +1,30 @@ +{ + "identifier": "g7-plugin-sirsoft-pay_nhnkcp-ja", + "namespace": "g7", + "vendor": "sirsoft", + "name": { + "ko": "G7 플러그인 (sirsoft-pay_nhnkcp) 일본어 언어팩", + "en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack", + "ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック" + }, + "description": { + "ko": "G7 플러그인 (sirsoft-pay_nhnkcp) 일본어 언어팩 (번들)", + "en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack (bundled)", + "ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック(バンドル)" + }, + "version": "1.0.1", + "license": "MIT", + "scope": "plugin", + "target_identifier": "sirsoft-pay_nhnkcp", + "locale": "ja", + "locale_name": "Japanese", + "locale_native_name": "日本語", + "text_direction": "ltr", + "g7_version": ">=7.0.0-beta.4", + "requires": { + "target_version": null, + "depends_on_core_locale": true + }, + "github_url": "", + "github_changelog_url": "" +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/seed/manifest.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/seed/manifest.json new file mode 100644 index 00000000..d3a31272 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/seed/manifest.json @@ -0,0 +1,4 @@ +{ + "name": "NHN KCP", + "description": "NHN KCP 決済ゲートウェイ (Standard Pay 連携)" +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/CHANGELOG.md new file mode 100644 index 00000000..a59ecfb2 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +이 언어팩의 모든 주요 변경사항을 기록합니다. +형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, +[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. + +## [1.0.1] + +### Added + +- 가상계좌 환불 검증 안내(`errors.vbank_refund_*` — 환불 대상 주문 없음·환불 불가 결제·환불 금액 불일치·환불 처리 진행 중) 일본어 번역 추가 — 관리자 가상계좌 환불 요청 검증 실패 안내가 일본어 로케일에서 자연스럽게 표시됩니다. +- 주문 설정 화면의 나이스페이먼츠 테스트모드 경고(`nicepay_test_mode_status`, `test_mode_settings_warning_*`) 일본어 번역 추가 — 테스트결제 상태 안내와 실결제 설정 이동 버튼이 일본어 로케일에서 자연스럽게 표시됩니다. diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/activity_log.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/activity_log.php new file mode 100644 index 00000000..20112d44 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/activity_log.php @@ -0,0 +1,10 @@ + [ + 'cancel' => 'PG決済キャンセル', + ], + 'description' => [ + 'payment_cancel' => 'NICEペイメンツ決済キャンセルが確認されました (注文 :order_number、返金金額 :refund_amount円)。', + ], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/messages.php new file mode 100644 index 00000000..46f959b1 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/messages.php @@ -0,0 +1,24 @@ + [ + 'tid_required' => 'TIDを入力してください。', + 'order_not_found' => '注文が見つかりません。', + 'invalid_request' => '無効なリクエストです。', + 'invalid_amount' => 'リクエスト金額が有効ではありません。', + 'vbank_refund_required_fields' => 'TID、注文番号、返金金額、返金口座情報(口座番号·銀行コード·口座名義人)をすべて入力してください。', + 'vbank_completed_requires_bank_info' => '仮想口座入金完了の場合は返金口座情報が必要です。管理者APIを通じて返金を進めてください。', + 'invalid_refund_amount' => '返金金額が有効ではありません。(リクエスト::amount円)', + 'vbank_refund_order_not_found' => '返金する注文が見つかりません。', + 'vbank_refund_invalid_payment' => '返金可能なNICEペイメンツ仮想口座入金完了決済ではありません。', + 'vbank_refund_amount_mismatch' => '要求された返金金額がDBのキャンセル可能金額と一致していません。', + 'vbank_refund_already_processing' => '既に仮想口座返金処理が進行中です。', + ], + 'refund' => [ + 'missing_tid' => '取引ID(TID)がないため返金を進めることができません。', + 'default_reason' => '購入者返金リクエスト', + ], + 'defaults' => [ + 'vbank_refund_msg' => '仮想口座返金', + ], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/payment_methods.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/payment_methods.php new file mode 100644 index 00000000..daa59f28 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/payment_methods.php @@ -0,0 +1,36 @@ + [ + 'name' => 'Naver Pay (ナイスペイメンツ)', + 'description' => 'Naver Payで決済 (ナイスペイメンツ)', + ], + 'kakaopay' => [ + 'name' => 'KakaoPay (ナイスペイメンツ)', + 'description' => 'KakaoPayで決済 (ナイスペイメンツ)', + ], + 'samsungpay' => [ + 'name' => 'Samsung Pay (ナイスペイメンツ)', + 'description' => 'Samsung Payで決済 (ナイスペイメンツ)', + ], + 'applepay' => [ + 'name' => 'Apple Pay (ナイスペイメンツ)', + 'description' => 'Apple Payで決済 (ナイスペイメンツ)', + ], + 'payco' => [ + 'name' => 'PAYCO (ナイスペイメンツ)', + 'description' => 'PAYCOで決済 (ナイスペイメンツ)', + ], + 'skpay' => [ + 'name' => '11pay (ナイスペイメンツ)', + 'description' => '11payで決済 (ナイスペイメンツ)', + ], + 'ssgpay' => [ + 'name' => 'SSG Pay (ナイスペイメンツ)', + 'description' => 'SSG Payで決済 (ナイスペイメンツ)', + ], + 'lpay' => [ + 'name' => 'L.pay (ナイスペイメンツ)', + 'description' => 'L.payで決済 (ナイスペイメンツ)', + ], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/provider.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/provider.php new file mode 100644 index 00000000..c260ef55 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/provider.php @@ -0,0 +1,6 @@ + 'ナイスペイメンツ', + 'description' => '国内PG企業ナイスペイメンツ決済連携(カード/口座振替/仮想口座/携帯電話 + 7種簡便決済)', +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/frontend/ja.json new file mode 100644 index 00000000..50cb2574 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/frontend/ja.json @@ -0,0 +1,154 @@ +{ + "editor": { + "data_source": { + "nicepay_easy_pay_config": "簡易決済設定", + "nicepay_escrow": "エスクロー", + "nicepay_status": "決済ステータス", + "nicepay_test_mode_status": "テストモード状態", + "nicepay_test_map": "テスト決済マップ", + "settings": "決済設定", + "vbank_info": "仮想口座情報" + } + }, + "settings": { + "title": "NicePayments設定", + "description": "NicePayments PG連動設定", + "test_mode": "テストモード", + "test_mode_hint": "テストモードでも実際のカード承認・出金通知が発生する場合があり、テストアカウント決済は当日23時30分頃一括自動キャンセルされます。", + "section_test_keys": "テストキー設定", + "test_mid": "テスト加盟店ID(MID)", + "test_mid_hint": "NicePayments公用テストMIDです。テスト専用であり、変更する必要はありません。", + "test_merchant_key": "テスト加盟店キー", + "test_merchant_key_hint": "NicePayments公用テストキーです。テスト専用であり、変更する必要はありません。", + "section_live_keys": "ライブキー設定", + "live_mid": "ライブ加盟店ID(MID)", + "live_mid_hint": "NicePayments店舗管理者 > 加盟店情報 > KEY管理から確認。「SR」を除いた残りだけを入力してください。", + "live_mid_example": "例)店舗IDがSRpaytestmの場合、paytestmのみを入力 → 完成:SRpaytestm", + "live_merchant_key": "ライブ加盟店キー", + "live_merchant_key_hint": "外部に公開されないよう注意してください。", + "live_mode_warning_title": "ライブモード有効化", + "live_mode_warning_body": "現在、実際の決済が処理されています。ライブMIDと加盟店キーが正しく設定されていることを確認してください。", + "section_vbank_notify": "仮想口座入金通知URL", + "vbank_notify_hint": "仮想口座使用時は、以下のURLをNicePayments加盟店管理者 → 加盟店情報設定に登録してください。入金が完了するとこのアドレスに自動通知されます。", + "vbank_notify_copied": "URLがクリップボードにコピーされました。", + "copy": "コピー", + "section_redirect": "リダイレクトURL", + "redirect_success_url": "決済成功リダイレクトURL", + "redirect_success_url_hint": "相対パス(/shop/...)または完全URL(https://...)の両方が可能です。{orderId}は注文番号に自動置換されます。", + "redirect_fail_url": "決済失敗リダイレクトURL", + "redirect_fail_url_hint": "相対パスまたは完全URL両方が可能です。エラー情報はクエリパラメーターとして自動追加されます。", + "use_escrow": "エスクロー決済使用", + "use_escrow_hint": "実物商品販売時に購入者保護のためのエスクロー決済を有効化します。エスクロー決済後の配送登録が必要です。", + "section_easy_pay": "簡易決済設定", + "section_easy_pay_hint": "使用する簡易決済サービスを選択してください。NicePayments加盟店契約が完了したサービスのみ有効化してください。", + "easy_pay_naverpay": "NaverPay", + "easy_pay_naverpay_hint": "テストモードでも使用可能", + "easy_pay_kakaopay": "KakaoPay", + "easy_pay_samsungpay": "Samsung Pay", + "easy_pay_applepay": "Apple Pay", + "easy_pay_applepay_hint": "SafariおよびAppleデバイスでのみ使用可能", + "easy_pay_payco": "PAYCO", + "easy_pay_skpay": "11pay(SKペイ)", + "easy_pay_ssgpay": "SSGペイ", + "easy_pay_lpay": "L.pay", + "easy_pay_allow_with_other_pg": "他のPGと使用可能", + "easy_pay_allow_with_other_pg_hint": "有効化すると、他のPG企業が基本決済手段として設定されている場合でも、チェックアウトページにNicePay簡易決済ボタンが表示されます。" + }, + "admin": { + "transaction_query_title": "TID取引照会", + "transaction_query_description": "NicePayments取引ID(TID)で決済ステータスを直接照会します。", + "transaction_tid_label": "取引ID(TID)", + "transaction_query_button": "照会", + "transaction_query_error": "照会失敗", + "transaction_result_title": "照会結果", + "result_tid": "取引ID(TID)", + "result_order_id": "注文番号(Moid)", + "result_amount": "決済金額", + "result_status": "決済ステータス", + "result_method": "決済手段", + "result_auth_code": "承認番号", + "result_auth_date": "承認日時", + "result_result_code": "結果コード", + "order_verify_title": "NicePayments決済検証", + "order_verify_description": "NicePaymentsサーバーからリアルタイムで決済ステータスを照会します。", + "order_verify_button": "決済照会", + "order_verify_error": "照会失敗", + "pay_status_approved": "承認", + "pay_status_cancelled": "キャンセル", + "pay_status_unknown": "未確認", + "vbank_noti_title": "仮想口座入金通知履歴", + "vbank_noti_description": "NicePay入金通知受信記録(自動蓄積)", + "vbank_noti_count": "通知回数", + "vbank_noti_first_at": "初回通知受信", + "vbank_noti_last_at": "最終通知受信", + "vbank_noti_deposited_at": "入金完了通知時刻", + "vbank_noti_cancelled_at": "入金キャンセル通知時刻", + "vbank_noti_depositor": "入金者名(最終)", + "vbank_noti_history": "通知履歴(時間順)", + "vbank_noti_type_deposited": "入金完了", + "vbank_noti_type_cancelled": "入金キャンセル", + "vbank_noti_type_other": "その他の通知", + "vbank_noti_query_button": "通知履歴照会", + "vbank_noti_loading": "閲覧中...", + "vbank_noti_not_loaded": "閲覧ボタンをクリックすると、NicePay入金通知履歴を確認できます。", + "vbank_noti_empty": "受信された入金通知がありません。", + "vbank_noti_load_error": "入金通知履歴閲覧中にエラーが発生しました。", + "escrow_badge": "エスクロー", + "result_escrow": "エスクロー有無", + "escrow_yes": "エスクロー決済", + "escrow_no": "通常決済", + "escrow_payment_notice": "エスクロー決済です。配送登録後、購入者の購入確定が必要です。", + "escrow_delivery_title": "エスクロー配送登録", + "escrow_delivery_company": "配送業者名", + "escrow_delivery_company_placeholder": "例:CJ大韓通運", + "escrow_tracking_number": "送り状番号", + "escrow_tracking_number_placeholder": "送り状番号を入力してください", + "escrow_buyer_address": "配送先住所", + "escrow_register_name": "登録者名", + "escrow_register_button": "配送登録", + "escrow_register_success": "エスクロー配送が登録されました。", + "escrow_register_error": "エスクロー配送登録に失敗しました。", + "test_mode_badge": "テスト決済", + "test_mode_order_notice": "テスト決済として処理された注文です。実際のカード承認・出金通知が発生する可能性がありますが、NicePaymentsテストアカウント決済は当日23時30分頃一括自動キャンセルされます。配送前に運営決済の有無をご確認ください。", + "test_mode_settings_warning_plugin": "NicePayments", + "test_mode_settings_warning_title": "(注意)NicePayments決済設定が現在テスト決済になっています。", + "test_mode_settings_warning_body": "テスト決済では実際の決済は発生しません。ショップ運営中の場合は必ず実決済に設定して運営してください。", + "test_mode_settings_warning_action": "実決済に設定", + "test_mode_orders_in_list": "テスト決済注文を含む" + }, + "easy_pay": { + "section_title": "簡便決済" + }, + "user": { + "receipt_label": "領収書", + "view_receipt": "領収書を見る", + "view_cash_receipt": "現金領収書照会", + "easy_pay_label": "簡便決済サービス", + "test_mode_badge": "テスト決済", + "tax_breakdown_title": "税金詳細", + "supply_amount": "供給価額", + "vat_amount": "付加価値税", + "tax_free_amount": "非課税金額", + "vbank_deposit_info": "仮想口座入金ご案内", + "vbank_bank": "銀行", + "vbank_account": "口座番号", + "vbank_holder": "口座名義人", + "vbank_amount": "入金金額", + "vbank_due": "入金期限", + "vbank_notice": "入金期限内に入金が完了しない場合、注文は自動キャンセルされます。" + }, + "messages": { + "refund": { + "missing_tid": "決済取引番号(TID)がありません。", + "default_reason": "管理者返金" + }, + "payment": { + "auth_failed": "決済認証に失敗しました。", + "mid_mismatch": "加盟店IDが一致しません。", + "signature_mismatch": "決済署名検証に失敗しました。", + "order_not_found": "注文が見つかりません。", + "amount_mismatch": "決済金額が一致しません。", + "authorize_failed": "決済承認に失敗しました。" + } + } +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/language-pack.json new file mode 100644 index 00000000..bbe7abca --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/language-pack.json @@ -0,0 +1,30 @@ +{ + "identifier": "g7-plugin-sirsoft-pay_nicepayments-ja", + "namespace": "g7", + "vendor": "sirsoft", + "name": { + "ko": "G7 플러그인 (sirsoft-pay_nicepayments) 일본어 언어팩", + "en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack", + "ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック" + }, + "description": { + "ko": "G7 플러그인 (sirsoft-pay_nicepayments) 일본어 언어팩 (번들)", + "en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack (bundled)", + "ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック(バンドル)" + }, + "version": "1.0.1", + "license": "MIT", + "scope": "plugin", + "target_identifier": "sirsoft-pay_nicepayments", + "locale": "ja", + "locale_name": "Japanese", + "locale_native_name": "日本語", + "text_direction": "ltr", + "g7_version": ">=7.0.0-beta.4", + "requires": { + "target_version": null, + "depends_on_core_locale": true + }, + "github_url": "", + "github_changelog_url": "" +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/seed/manifest.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/seed/manifest.json new file mode 100644 index 00000000..0113a5f9 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/seed/manifest.json @@ -0,0 +1,4 @@ +{ + "name": "NICE Payments", + "description": "NICE Payments 決済ゲートウェイ (統合決済ウィンドウ連携)" +} diff --git a/modules/_bundled/sirsoft-board/package.json b/modules/_bundled/sirsoft-board/package.json index e063df2d..9106b48a 100644 --- a/modules/_bundled/sirsoft-board/package.json +++ b/modules/_bundled/sirsoft-board/package.json @@ -9,7 +9,9 @@ "build": "vite build", "preview": "vite preview", "test": "vitest", - "test:run": "vitest run" + "test:run": "vitest run", + "test:e2e": "playwright test --config=tests/Playwright/playwright.config.ts", + "test:e2e:ui": "playwright test --config=tests/Playwright/playwright.config.ts --ui" }, "devDependencies": { "jsdom": "^27.4.0", diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/playwright.config.ts b/modules/_bundled/sirsoft-board/tests/Playwright/playwright.config.ts index b3392758..6d809fb5 100644 --- a/modules/_bundled/sirsoft-board/tests/Playwright/playwright.config.ts +++ b/modules/_bundled/sirsoft-board/tests/Playwright/playwright.config.ts @@ -14,6 +14,18 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); +/** + * 코어 루트 (artisan / .env / Playwright 산출물의 기준 경로). + * + * 확장 config 는 확장 디렉토리에서 실행되지만, 산출물을 그 안에 쓰면 Windows 에서 + * `{module|template}:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다. + * 산출물은 코어 루트 아래로 모아 update 경로와 분리한다 (.gitignore 가 이미 덮는 위치). + */ +const CORE_ROOT = process.env.G7_ROOT || resolve(__dirname, '../../../../../'); + +/** 확장별 산출물 격리 — 확장끼리 리포트를 덮어쓰지 않도록 slug 로 네임스페이스. */ +const ARTIFACT_SLUG = 'modules/sirsoft-board'; + function readEnvFile(filePath: string, key: string): string | null { if (!existsSync(filePath)) return null; const content = readFileSync(filePath, { encoding: 'utf-8' }); @@ -31,8 +43,7 @@ function resolveBaseUrl(): string { if (process.env.PLAYWRIGHT_BASE_URL) { return process.env.PLAYWRIGHT_BASE_URL; } - const coreRoot = process.env.G7_ROOT || resolve(__dirname, '../../../../../'); - const appUrl = readEnvFile(resolve(coreRoot, '.env'), 'APP_URL'); + const appUrl = readEnvFile(resolve(CORE_ROOT, '.env'), 'APP_URL'); if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) { return appUrl; } @@ -43,12 +54,13 @@ function resolveBaseUrl(): string { export default defineConfig({ testDir: './specs', + outputDir: resolve(CORE_ROOT, 'test-results', ARTIFACT_SLUG), fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: [ - ['html', { outputFolder: 'playwright-report', open: 'never' }], + ['html', { outputFolder: resolve(CORE_ROOT, 'playwright-report', ARTIFACT_SLUG), open: 'never' }], ['list'], ], use: { diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 597ee66c..531204f7 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -4,6 +4,24 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.4] - 2026-07-14 + +### Fixed + +- 네이버페이·카카오페이 등 결제 플러그인이 제공하는 간편결제로 주문할 때, 결제가 실패했는데도 관리자에게 "신규 주문 접수" 알림이 발송되던 문제를 수정했습니다. 이제 다른 카드 결제와 마찬가지로 결제가 실제로 완료된 시점에만 알림이 발송됩니다. +- 간편결제로 결제하다 실패한 뒤 다시 결제하려 하면 "임시주문을 찾을 수 없습니다" 오류가 뜨며 재결제가 불가능하던 문제를 수정했습니다. 이제 결제 실패 후에도 주문서가 유지되어 결제수단을 바꾸거나 다시 시도할 수 있습니다. +- 간편결제 주문을 취소할 때 카드 취소(PG 취소)가 함께 이뤄지지 않고 주문 상태만 취소로 바뀔 수 있던 문제를 수정했습니다. +- 주문 목록의 결제수단 필터와 주문서의 결제수단 검증이 간편결제를 인식하지 못하던 문제를 수정했습니다. + +### Changed + +- 결제 플러그인이 제공하는 간편결제도 신용카드·가상계좌와 동일하게 정식 결제수단으로 취급됩니다. 주문 내역에 실제로 선택한 결제수단(예: 네이버페이)이 그대로 기록되어 관리자 주문 상세와 주문 목록에서 확인할 수 있습니다. +- 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 불필요"가 아니라 "PG 고정"으로 표시되고 어느 결제대행사를 통해 처리되는지 함께 보여줍니다. 간편결제는 해당 결제대행사 전용이므로 다른 대행사로 바꿀 수 없습니다. +- 주문서의 간편결제 버튼에 표시되는 브랜드 마크(네이버페이·카카오페이·삼성페이 등의 색상 배지와 로고)를 표준화했습니다. 이전에는 결제 플러그인이 화면이 그려진 뒤 버튼 모양을 덧입히는 방식이라 여러 결제사가 함께 켜져 있을 때 드물게 표시가 겹치거나 어긋날 수 있었는데, 이제 결제수단 정보에 담긴 브랜드 마크를 주문서가 처음부터 직접 그리도록 바꿔 표시가 안정적입니다. +- 애플페이 간편결제 버튼이 iOS 기기(iPhone·iPad)에서만 주문서에 표시되도록 했습니다. 애플페이는 iOS에서만 결제할 수 있으므로 다른 기기에서는 버튼을 감춰 혼선을 줄입니다. +- 배송 국가 해석·비회원 주문 접근 확인 등 요청 경로별 처리를 코어의 새로운 표준 연결 방식으로 옮겼습니다. 동작은 이전과 동일합니다. +- 위 개선을 위해 코어 최소 요구 버전을 7.0.5로 상향했습니다. 쇼핑몰 모듈을 업데이트하기 전에 코어를 7.0.5 이상으로 먼저 업데이트해 주세요. + ## [1.0.3] - 2026-07-14 ### Security diff --git a/modules/_bundled/sirsoft-ecommerce/composer.json b/modules/_bundled/sirsoft-ecommerce/composer.json index cc0f1bb7..3eda744e 100644 --- a/modules/_bundled/sirsoft-ecommerce/composer.json +++ b/modules/_bundled/sirsoft-ecommerce/composer.json @@ -2,7 +2,7 @@ "name": "modules/sirsoft-ecommerce", "description": "Ecommerce module for Gnuboard7", "type": "library", - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-ecommerce/database/factories/OrderPaymentFactory.php b/modules/_bundled/sirsoft-ecommerce/database/factories/OrderPaymentFactory.php index 47b08edd..9af55eff 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/factories/OrderPaymentFactory.php +++ b/modules/_bundled/sirsoft-ecommerce/database/factories/OrderPaymentFactory.php @@ -31,7 +31,7 @@ class OrderPaymentFactory extends Factory 'embedded_pg_provider' => null, 'transaction_id' => $faker->uuid(), 'merchant_order_id' => 'MO-'.strtoupper($faker->bothify('????-####-????')), - 'payment_method' => PaymentMethodEnum::CARD, + 'payment_method' => PaymentMethodEnum::CARD->value, 'payment_device' => $faker->randomElement(['pc', 'mobile']), 'paid_amount_local' => $amount, 'paid_amount_base' => $amount, @@ -130,7 +130,7 @@ class OrderPaymentFactory extends Factory $faker = \fake(); return $this->state(fn (array $attributes) => [ - 'payment_method' => PaymentMethodEnum::CARD, + 'payment_method' => PaymentMethodEnum::CARD->value, 'card_name' => $faker->randomElement(['신한카드', '삼성카드', 'KB국민카드', '현대카드']), 'card_number_masked' => $faker->numerify('####-****-****-####'), ]); @@ -144,7 +144,7 @@ class OrderPaymentFactory extends Factory $faker = \fake(); return $this->state(fn (array $attributes) => [ - 'payment_method' => PaymentMethodEnum::DBANK, + 'payment_method' => PaymentMethodEnum::DBANK->value, 'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT, 'pg_provider' => '', 'embedded_pg_provider' => null, @@ -169,7 +169,7 @@ class OrderPaymentFactory extends Factory $faker = \fake(); return $this->state(fn (array $attributes) => [ - 'payment_method' => PaymentMethodEnum::VBANK, + 'payment_method' => PaymentMethodEnum::VBANK->value, 'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT, 'vbank_code' => '088', 'vbank_name' => $faker->randomElement(['신한은행', '국민은행', '우리은행']), diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md index bb6a447b..1109b561 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md @@ -35,7 +35,7 @@ | order_status | query | array | 아니오 | — | 주문상태 다중 선택 필터 (OrderStatusEnum 값 배열, 해당 상태의 주문만 조회) | | option_status | query | array | 아니오 | — | 주문옵션 상태 다중 선택 필터 (OrderStatusEnum 값 배열, 해당 옵션 상태를 가진 주문만 조회) | | shipping_type | query | array | 아니오 | — | 배송유형 다중 선택 필터 (ShippingType 코드 배열) | -| payment_method | query | array | 아니오 | — | 결제수단 다중 선택 필터 (PaymentMethodEnum 값 배열) | +| payment_method | query | array | 아니오 | 결제수단 카탈로그에 등록된 ID | 결제수단 다중 선택 필터. 코어 8종과 PG 플러그인이 등록한 확장 결제수단 ID(예: `nhnkcp_naverpay`)를 모두 허용 | | category_id | query | integer | 아니오 | — | category 식별자 | | min_amount | query | integer | 아니오 | min 0 | 주문금액 범위 필터 하한 (이 금액 이상 주문만 조회) | | max_amount | query | integer | 아니오 | min 0 | 주문금액 범위 필터 상한 (이 금액 이하 주문만 조회) | @@ -1330,7 +1330,7 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| payment_method | body | string | 예 | — | 결제수단 (PaymentMethodEnum 값 — card/vbank/dbank 등) | +| payment_method | body | string | 예 | 결제수단 카탈로그에 등록된 ID | 결제수단. 코어 8종(`card`/`vbank`/`dbank`/`bank`/`phone`/`point`/`deposit`/`free`) 과 PG 플러그인이 등록한 확장 결제수단 ID(예: `nhnkcp_naverpay`, `kginicis_lpay`)를 모두 허용한다. 확장 결제수단도 1급 시민으로 그대로 저장된다. 카탈로그에 없는 값은 422 | | expected_total_amount | body | number | 예 | min 0 | 프론트가 계산한 예상 결제금액 (서버 재계산값과 대조해 금액 위변조 검증) | | shipping_memo | body | string | 아니오 | max 500 | 배송 요청사항 메모 | | depositor_name | body | string | 아니오 | max 50 | depositor 이름 (식별자) | @@ -1362,18 +1362,33 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +| 이름 | 타입 | 조건 | 용도 | +| --- | --- | --- | --- | +| order | object | 항상 | 생성된 주문 리소스 | +| redirect_url | string | 항상 | 결제가 필요 없는 주문의 이동 대상 (주문완료 페이지) | +| requires_pg_payment | boolean | 항상 | PG 결제창을 거쳐야 하는 주문인지. 결제수단이 PG 를 요구하고(`needs_pg`), 해석된 PG 가 `manual`/`internal`/`none` 이 아니며, 잔여 결제액이 0보다 클 때 `true` | +| pg_provider | string | `requires_pg_payment=true` | 결제를 처리할 PG (`sirsoft-` 접두사 부착 — 예: `sirsoft-nhnkcp`) | +| pg_payment_data | object | `requires_pg_payment=true` | PG 결제창에 전달할 결제 정보 (아래 표) | +| pg_payment_handler | string | `requires_pg_payment=true` **이고** PG 가 결제 진입 핸들러를 선언한 경우 | 프론트가 그대로 dispatch 할 결제 진입 핸들러 풀네임 (예: `sirsoft-pay_nhnkcp.requestPayment`). PG 가 미선언이면 이 키가 없으며, 템플릿은 PG 분기를 발화하지 않고 비-PG 흐름으로 안전 강하한다 | -**응답 예시** +`pg_payment_data` 필드: - +| 이름 | 타입 | 용도 | +| --- | --- | --- | +| order_number | string | 주문번호 | +| order_name | string | 주문명 (첫 상품명 + 외 N건) | +| amount | number | PG 청구액 (결제 통화의 최소 화폐단위 정수) | +| currency | string | 결제 통화 코드 | +| customer_name / customer_email / customer_phone | string\|null | 주문자 정보 | +| customer_key | string\|null | 회원 주문의 고객 식별자 (`user_{id}`), 비회원은 null | +| payment_method | string | 선택된 결제수단 ID. 확장 결제수단이면 확장 ID 그대로(예: `nhnkcp_naverpay`) — PG 플러그인이 이 값으로 결제창의 결제수단을 결정한다 | **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.create`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `payment_method` 가 결제수단 카탈로그에 없는 값이면 여기서 차단된다 | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md index 2d8668c0..a2293b6e 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md @@ -3570,3 +3570,33 @@ _대표 에러 없음 (공개 조회). diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json index 673f89fa..59be2a50 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.json +++ b/modules/_bundled/sirsoft-ecommerce/module.json @@ -5,13 +5,13 @@ "ko": "이커머스", "en": "Ecommerce" }, - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "description": { "ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", "en": "Gnuboard7 Ecommerce Module - Product, Order, Payment Management" }, - "g7_version": ">=7.0.0", + "g7_version": ">=7.0.5", "dependencies": { "modules": {}, "plugins": {} diff --git a/modules/_bundled/sirsoft-ecommerce/module.php b/modules/_bundled/sirsoft-ecommerce/module.php index 89b77bd3..39b125ad 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.php +++ b/modules/_bundled/sirsoft-ecommerce/module.php @@ -9,6 +9,9 @@ use Illuminate\Database\Seeder; use Modules\Sirsoft\Ecommerce\Database\Seeders\ClaimReasonSeeder; use Modules\Sirsoft\Ecommerce\Database\Seeders\SequenceSeeder; use Modules\Sirsoft\Ecommerce\Database\Seeders\ShippingCarrierSeeder; +use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice; +use Modules\Sirsoft\Ecommerce\Http\Middleware\ResolveShippingCountry; +use Modules\Sirsoft\Ecommerce\Http\Middleware\VerifyGuestOrderToken; use Modules\Sirsoft\Ecommerce\Listeners\ActivityLogDescriptionResolver; use Modules\Sirsoft\Ecommerce\Listeners\AssignDefaultCurrencyOnRegisterListener; use Modules\Sirsoft\Ecommerce\Listeners\AssignDefaultShippingCountryOnRegisterListener; @@ -19,6 +22,7 @@ use Modules\Sirsoft\Ecommerce\Listeners\CouponUseListener; use Modules\Sirsoft\Ecommerce\Listeners\EcommerceAdminActivityLogListener; use Modules\Sirsoft\Ecommerce\Listeners\EcommerceNotificationDataListener; use Modules\Sirsoft\Ecommerce\Listeners\EcommerceUserActivityLogListener; +use Modules\Sirsoft\Ecommerce\Listeners\InjectAppConfigDeviceListener; use Modules\Sirsoft\Ecommerce\Listeners\MergeCartOnLoginListener; use Modules\Sirsoft\Ecommerce\Listeners\MileageTransactionListener; use Modules\Sirsoft\Ecommerce\Listeners\OrderActivityLogListener; @@ -2007,6 +2011,7 @@ class Module extends AbstractModule UserMileageCleanupListener::class, EcommerceNotificationDataListener::class, OrderStatusNotificationListener::class, + InjectAppConfigDeviceListener::class, ]; } @@ -2075,6 +2080,51 @@ class Module extends AbstractModule ]; } + /** + * 이 모듈이 등록할 HTTP 미들웨어 선언을 반환합니다. + * + * 코어 self-gate 게이트가 요청 시점에 targets 패턴 매칭으로 실행합니다. + * + * - DetectDevice: 무명 User SSR 셸 catch-all(routes/web.php)에 라우트명이 없어 URI 패턴 '/' 로 + * 타게팅(모든 web 요청 = 기존 web 그룹 append 와 동등). InjectAppConfigDeviceListener 가 + * appConfig.isIos 로 주입(체크아웃 애플페이 iOS 게이팅). + * - ResolveShippingCountry: 상품/장바구니/체크아웃/주문생성 라우트에만 부착 (배송국가 해석). + * - VerifyGuestOrderToken: 비회원 주문 후속 액션 4개 라우트에만 부착 (verify 는 미부착 — + * 토큰 발급 전 단계라 개별 지정). + * + * @return array, timing?: string, targets: array}> + */ + public function getMiddleware(): array + { + return [ + [ + 'class' => DetectDevice::class, + 'groups' => ['web'], + 'targets' => ['/'], + ], + [ + 'class' => ResolveShippingCountry::class, + 'groups' => ['api'], + 'targets' => [ + 'api.modules.sirsoft-ecommerce.products.*', + 'api.modules.sirsoft-ecommerce.cart.*', + 'api.modules.sirsoft-ecommerce.checkout.*', + 'api.modules.sirsoft-ecommerce.user.orders.store', + ], + ], + [ + 'class' => VerifyGuestOrderToken::class, + 'groups' => ['api'], + 'targets' => [ + 'api.modules.sirsoft-ecommerce.guest.orders.cancel', + 'api.modules.sirsoft-ecommerce.guest.orders.estimate-refund', + 'api.modules.sirsoft-ecommerce.guest.orders.update-shipping-address', + 'api.modules.sirsoft-ecommerce.guest.orders.confirm-option', + ], + ], + ]; + } + /** * SEO 변수 메타데이터 정의 * diff --git a/modules/_bundled/sirsoft-ecommerce/package-lock.json b/modules/_bundled/sirsoft-ecommerce/package-lock.json index 33a0593a..63316555 100644 --- a/modules/_bundled/sirsoft-ecommerce/package-lock.json +++ b/modules/_bundled/sirsoft-ecommerce/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.3", + "version": "1.0.4", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/modules/_bundled/sirsoft-ecommerce/package.json b/modules/_bundled/sirsoft-ecommerce/package.json index 88de7fcf..746ded67 100644 --- a/modules/_bundled/sirsoft-ecommerce/package.json +++ b/modules/_bundled/sirsoft-ecommerce/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.3", + "version": "1.0.4", "description": "그누보드7 이커머스 모듈 프론트엔드 에셋", "private": true, "type": "module", @@ -9,7 +9,9 @@ "build": "vite build", "preview": "vite preview", "test": "vitest", - "test:run": "vitest run" + "test:run": "vitest run", + "test:e2e": "playwright test --config=tests/Playwright/playwright.config.ts", + "test:e2e:ui": "playwright test --config=tests/Playwright/playwright.config.ts --ui" }, "devDependencies": { "jsdom": "^27.4.0", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx index 0d6d397e..6aa2cfeb 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx @@ -422,6 +422,71 @@ describe('결제수단 Sortable 리스트 구조 검증 (_payment_methods_list.j ); }); }); + + // ─── PG 능력 기반 3분기 (#475) ─── + // 배경: 과거에는 PG 필요 여부를 하드코딩 배열(['point','deposit','free','dbank'])로 + // 정규식 판정해 간편결제를 "PG 불필요"로 오분류했다. 이제 결제수단 카탈로그의 + // pg_locked / needs_pg 능력으로 직접 3분기한다: + // ① pg_locked → "PG 고정 · {PG명}" 배지 (관리자 변경 불가) + // ② !pg_locked && needs_pg → PG 선택 셀렉트 (또는 미설치 안내) + // ③ !pg_locked && !needs_pg → "PG 불필요" + // 이 분기가 능력 대신 다시 하드코딩/orphaned 로 회귀하면 #475 재발이다. + describe('PG 능력 3분기 (#475)', () => { + const tpl = layout.itemTemplate; + const findByTestidExpr = (needle: string) => + findFirst(tpl, (n: any) => + typeof n?.props?.['data-testid'] === 'string' + && n.props['data-testid'].includes(needle), + ); + + it('PG 고정 배지가 $method.pg_locked 조건으로 렌더된다', () => { + const badge = findByTestidExpr('pg-locked-badge-'); + expect(badge).not.toBeNull(); + expect(badge.if).toBe('{{$method.pg_locked}}'); + // 배지 텍스트에 PG 표시명(available_pg_providers 의 name)이 들어가야 + // "PG 불필요"와 구분된다. + expect(badge.text).toContain('available_pg_providers'); + expect(badge.text).toContain('pg_provider'); + }); + + it('PG 선택 셀렉트가 !pg_locked && needs_pg && 제공자>0 조건으로만 렌더된다', () => { + const select = findByTestidExpr('pg-select-'); + expect(select).not.toBeNull(); + expect(select.name).toBe('Select'); + expect(select.if).toContain('!$method.pg_locked'); + expect(select.if).toContain('$method.needs_pg'); + expect(select.if).toContain('length > 0'); + // 셀렉트 변경이 pg_provider 를 배열에 반영해야 한다. + expect(select.actions[0].handler).toBe('setState'); + expect(select.actions[0].params['form.order_settings.payment_methods']).toContain( + 'pg_provider', + ); + }); + + it('"PG 불필요" 표시가 !pg_locked && !needs_pg 조건으로만 렌더된다', () => { + const notRequired = findByTestidExpr('pg-not-required-'); + expect(notRequired).not.toBeNull(); + expect(notRequired.if).toContain('!$method.pg_locked'); + expect(notRequired.if).toContain('!$method.needs_pg'); + expect(notRequired.text).toBe( + '$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_not_required', + ); + }); + + it('세 분기가 상호배타적이다 (pg_locked 우선, 나머지는 !pg_locked)', () => { + const badge = findByTestidExpr('pg-locked-badge-'); + const select = findByTestidExpr('pg-select-'); + const notInstalled = findByTestidExpr('pg-not-installed-'); + const notRequired = findByTestidExpr('pg-not-required-'); + + // pg_locked 분기만 pg_locked=true, 나머지 셋은 모두 !pg_locked 전제. + expect(badge.if).toBe('{{$method.pg_locked}}'); + for (const branch of [select, notInstalled, notRequired]) { + expect(branch).not.toBeNull(); + expect(branch.if).toContain('!$method.pg_locked'); + } + }); + }); }); // ─── _payment_methods_cards.json (모바일 Sortable) 구조 검증 ─── @@ -442,6 +507,42 @@ describe('결제수단 모바일 카드 구조 검증 (_payment_methods_cards.js expect(layout.itemTemplate.classMap).toBeDefined(); expect(layout.itemTemplate.classMap.variants.orphaned).toBeDefined(); }); + + // ─── PG 능력 3분기 (#475) — 모바일 카드도 PC 리스트와 동일하게 판정해야 한다 ─── + describe('PG 능력 3분기 (#475)', () => { + const tpl = layout.itemTemplate; + const findByTestidExpr = (needle: string) => + findFirst(tpl, (n: any) => + typeof n?.props?.['data-testid'] === 'string' + && n.props['data-testid'].includes(needle), + ); + + it('PG 고정 배지가 $method.pg_locked 조건으로 렌더된다', () => { + const badge = findByTestidExpr('pg-locked-badge-'); + expect(badge).not.toBeNull(); + expect(badge.if).toBe('{{$method.pg_locked}}'); + }); + + it('PG 선택 셀렉트가 !pg_locked && needs_pg 조건으로만 렌더된다', () => { + const select = findByTestidExpr('pg-select-'); + expect(select).not.toBeNull(); + expect(select.if).toContain('!$method.pg_locked'); + expect(select.if).toContain('$method.needs_pg'); + }); + + it('"PG 불필요" 표시가 !pg_locked && !needs_pg 조건으로만 렌더된다', () => { + const notRequired = findByTestidExpr('pg-not-required-'); + expect(notRequired).not.toBeNull(); + expect(notRequired.if).toContain('!$method.pg_locked'); + expect(notRequired.if).toContain('!$method.needs_pg'); + }); + + it('PC 리스트와 카드가 동일한 3개 data-testid 접두사를 쓴다 (표시 일관성)', () => { + for (const needle of ['pg-locked-badge-', 'pg-select-', 'pg-not-required-']) { + expect(findByTestidExpr(needle)).not.toBeNull(); + } + }); + }); }); // ─── _bank_accounts_table.json (PC 테이블) 구조 검증 ─── diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_dashboard_commerce_extension.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_dashboard_commerce_extension.test.tsx index 440f937a..78fb0893 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_dashboard_commerce_extension.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_dashboard_commerce_extension.test.tsx @@ -229,9 +229,11 @@ describe('admin_dashboard_commerce.json - commerce 슬롯 주입', () => { }); it('최신 리뷰 항목(latest_review_item)에 iteration 이 정의된다', () => { + // iteration 은 리스트 컨테이너가 아니라 반복 항목 노드에 있고, 항목 id 는 + // index_var 를 붙인 형태(latest_review_item_{{i}})다. const list = findById(commerceExt as any, 'latest_reviews_list'); expect(list.iteration).toBeUndefined(); - const item = findById(commerceExt as any, 'latest_review_item'); + const item = findById(commerceExt as any, 'latest_review_item_{{i}}'); expect(item.iteration).toBeDefined(); expect(item.iteration.source).toBe('commerce_recent_reviews?.data'); expect(item.iteration.item_var).toBe('review'); @@ -241,7 +243,7 @@ describe('admin_dashboard_commerce.json - commerce 슬롯 주입', () => { it('미답변 문의 항목(pending_inquiry_item)에 iteration 이 정의된다', () => { const list = findById(commerceExt as any, 'pending_inquiries_list'); expect(list.iteration).toBeUndefined(); - const item = findById(commerceExt as any, 'pending_inquiry_item'); + const item = findById(commerceExt as any, 'pending_inquiry_item_{{i}}'); expect(item.iteration).toBeDefined(); expect(item.iteration.source).toBe('commerce_pending_inquiries?.data?.items'); expect(item.iteration.item_var).toBe('inquiry'); @@ -264,7 +266,7 @@ describe('admin_dashboard_commerce.json - commerce 슬롯 주입', () => { // board_slug 미설정 시 링크 미노출 expect(viewAll.if).toBe('{{!!commerce_pending_inquiries?.data?.board_slug}}'); - const item = findById(commerceExt as any, 'pending_inquiry_item'); + const item = findById(commerceExt as any, 'pending_inquiry_item_{{i}}'); const click = item.actions.find((a: any) => a.type === 'click' && a.handler === 'navigate'); expect(click.params.path).toBe('/admin/board/{{commerce_pending_inquiries?.data?.board_slug}}/post/{{inquiry?.inquirable_id}}'); }); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypageOrders.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypageOrders.test.tsx index 767bd205..e9f761ee 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypageOrders.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypageOrders.test.tsx @@ -208,8 +208,11 @@ describe('주문 목록 partial 구조 검증 (_list.json)', () => { expect(listJsonStr).not.toContain('order.multi_currency_total'); }); - it('다통화 단가가 item.mc_unit_price를 사용해야 함', () => { - expect(listJsonStr).toContain('item.mc_unit_price'); + it('단가가 item.unit_price_formatted를 사용해야 함', () => { + // 마이페이지 주문목록은 옵션 소계(mc_subtotal_price) 로 다통화를 표시하고 + // 단가는 포맷 문자열(unit_price_formatted) 로만 노출한다. 단가 단위의 + // 다통화 필드(mc_unit_price)는 이 레이아웃에 존재하지 않는다(주문완료 화면 전용). + expect(listJsonStr).toContain('item.unit_price_formatted'); expect(listJsonStr).not.toContain('item.multi_currency_unit_price'); }); @@ -225,8 +228,11 @@ describe('주문 목록 partial 구조 검증 (_list.json)', () => { expect(listJsonStr).toContain('order.total_shipping_amount'); }); - it('다통화 배송비가 order.mc_total_shipping_amount를 사용해야 함', () => { - expect(listJsonStr).toContain('order.mc_total_shipping_amount'); + it('다통화 총액이 order.mc_total_amount를 사용해야 함', () => { + // 배송비 단위의 다통화 필드(mc_total_shipping_amount)는 없다. 다통화 금액은 + // 주문 총액(mc_total_amount) 단위로만 표시된다(위 '다통화 금액' 테스트와 동일 근거). + expect(listJsonStr).toContain('order.mc_total_amount'); + expect(listJsonStr).not.toContain('order.mc_total_shipping_amount'); }); }); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json index 13a76973..a4213fd2 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json @@ -150,6 +150,7 @@ "pg_use_default": "Default PG", "pg_not_required": "Not required", "pg_not_installed": "No PG", + "pg_fixed": "Fixed PG", "pg_select_first": "Select PG first" }, "bank_accounts": { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json index b6993c20..77700e27 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json @@ -150,6 +150,7 @@ "pg_use_default": "기본 PG", "pg_not_required": "PG 불필요", "pg_not_installed": "PG 미설치", + "pg_fixed": "PG 고정", "pg_select_first": "PG사를 먼저 선택하세요" }, "bank_accounts": { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_payment_methods_cards.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_payment_methods_cards.json index 29531147..51465cd8 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_payment_methods_cards.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_payment_methods_cards.json @@ -113,7 +113,7 @@ "if": "{{!$method._orphaned}}", "props": { "value": "{{$method.is_active}}", - "disabled": "{{_computed.isReadOnly || !['point','deposit','free','dbank'].includes($method.id) && !$method.pg_provider && !_local.form?.order_settings?.default_pg_provider}}" + "disabled": "{{_computed.isReadOnly || (!$method.pg_locked && $method.needs_pg && !$method.pg_provider && !_local.form?.order_settings?.default_pg_provider)}}" }, "actions": [ { @@ -191,11 +191,23 @@ }, "text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_provider" }, + { + "comment": "PG 고정 수단(간편결제 등) — 플러그인이 자기 PG 로 고정 선언했으므로 관리자가 바꿀 수 없다. PG 이름을 읽기전용으로 표시.", + "type": "basic", + "name": "Span", + "if": "{{$method.pg_locked}}", + "props": { + "data-testid": "{{'pg-locked-badge-' + $method.id}}", + "className": "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300" + }, + "text": "{{$t('sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_fixed') + ' · ' + (((_local.form?.available_pg_providers ?? []).find(p => p.id === $method.pg_provider))?.name ? $localized(((_local.form?.available_pg_providers ?? []).find(p => p.id === $method.pg_provider)).name) : ($method.pg_provider ?? ''))}}" + }, { "type": "basic", "name": "Select", - "if": "{{!['point','deposit','free','dbank'].includes($method.id) && (_local.form?.available_pg_providers ?? []).length > 0}}", + "if": "{{!$method.pg_locked && $method.needs_pg && (_local.form?.available_pg_providers ?? []).length > 0}}", "props": { + "data-testid": "{{'pg-select-' + $method.id}}", "value": "{{$method.pg_provider ?? ''}}", "className": "w-full", "options": "{{[{value: '', label: '---'}].concat((_local.form?.available_pg_providers ?? []).filter(p => (p.supported_methods ?? []).includes($method.id)).map(p => ({value: p.id, label: $localized(p.name)})))}}", @@ -216,8 +228,9 @@ { "type": "basic", "name": "Span", - "if": "{{!['point','deposit','free','dbank'].includes($method.id) && (_local.form?.available_pg_providers ?? []).length === 0}}", + "if": "{{!$method.pg_locked && $method.needs_pg && (_local.form?.available_pg_providers ?? []).length === 0}}", "props": { + "data-testid": "{{'pg-not-installed-' + $method.id}}", "className": "text-hint italic" }, "text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_not_installed" @@ -225,8 +238,9 @@ { "type": "basic", "name": "Span", - "if": "{{['point','deposit','free','dbank'].includes($method.id)}}", + "if": "{{!$method.pg_locked && !$method.needs_pg}}", "props": { + "data-testid": "{{'pg-not-required-' + $method.id}}", "className": "text-hint" }, "text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_not_required" diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_payment_methods_list.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_payment_methods_list.json index 7223c32f..c271da32 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_payment_methods_list.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_payment_methods_list.json @@ -126,11 +126,23 @@ }, "text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_provider" }, + { + "comment": "PG 고정 수단(간편결제 등) — 플러그인이 자기 PG 로 고정 선언했으므로 관리자가 바꿀 수 없다. PG 이름을 읽기전용으로 표시.", + "type": "basic", + "name": "Span", + "if": "{{$method.pg_locked}}", + "props": { + "data-testid": "{{'pg-locked-badge-' + $method.id}}", + "className": "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300" + }, + "text": "{{$t('sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_fixed') + ' · ' + (((_local.form?.available_pg_providers ?? []).find(p => p.id === $method.pg_provider))?.name ? $localized(((_local.form?.available_pg_providers ?? []).find(p => p.id === $method.pg_provider)).name) : ($method.pg_provider ?? ''))}}" + }, { "type": "basic", "name": "Select", - "if": "{{!['point','deposit','free','dbank'].includes($method.id) && (_local.form?.available_pg_providers ?? []).length > 0}}", + "if": "{{!$method.pg_locked && $method.needs_pg && (_local.form?.available_pg_providers ?? []).length > 0}}", "props": { + "data-testid": "{{'pg-select-' + $method.id}}", "value": "{{$method.pg_provider ?? ''}}", "className": "w-36", "options": "{{[{value: '', label: '---'}].concat((_local.form?.available_pg_providers ?? []).filter(p => (p.supported_methods ?? []).includes($method.id)).map(p => ({value: p.id, label: $localized(p.name)})))}}", @@ -151,8 +163,9 @@ { "type": "basic", "name": "Span", - "if": "{{!['point','deposit','free','dbank'].includes($method.id) && (_local.form?.available_pg_providers ?? []).length === 0}}", + "if": "{{!$method.pg_locked && $method.needs_pg && (_local.form?.available_pg_providers ?? []).length === 0}}", "props": { + "data-testid": "{{'pg-not-installed-' + $method.id}}", "className": "text-hint italic" }, "text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_not_installed" @@ -160,8 +173,9 @@ { "type": "basic", "name": "Span", - "if": "{{['point','deposit','free','dbank'].includes($method.id)}}", + "if": "{{!$method.pg_locked && !$method.needs_pg}}", "props": { + "data-testid": "{{'pg-not-required-' + $method.id}}", "className": "text-hint" }, "text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_not_required" @@ -317,7 +331,7 @@ "name": "Toggle", "props": { "value": "{{$method.is_active}}", - "disabled": "{{_computed.isReadOnly || !['point','deposit','free','dbank'].includes($method.id) && !$method.pg_provider && !_local.form?.order_settings?.default_pg_provider}}" + "disabled": "{{_computed.isReadOnly || (!$method.pg_locked && $method.needs_pg && !$method.pg_provider && !_local.form?.order_settings?.default_pg_provider)}}" }, "actions": [ { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Helpers/DeviceDetector.php b/modules/_bundled/sirsoft-ecommerce/src/Helpers/DeviceDetector.php index d3c63815..0f5ef01d 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Helpers/DeviceDetector.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Helpers/DeviceDetector.php @@ -24,6 +24,13 @@ class DeviceDetector */ private const MOBILE_UA_KEYWORDS = ['Mobile', 'Android', 'iPhone', 'iPad', 'iPod']; + /** + * iOS User-Agent 키워드 (iPhone/iPad/iPod) + * + * @var array + */ + private const IOS_UA_KEYWORDS = ['iPhone', 'iPad', 'iPod']; + /** * Request 의 User-Agent 로 결제 디바이스 판별 * @@ -51,4 +58,25 @@ class DeviceDetector return 'pc'; } + + /** + * User-Agent 문자열이 iOS 기기(iPhone/iPad/iPod)인지 판별 + * + * 애플페이는 iOS 기기에서만 결제 가능하므로, 체크아웃 결제수단 목록에서 + * 애플페이 노출 여부를 정하는 데 쓰인다. 데스크탑 UA 를 보내는 iPadOS 는 + * 이 판정에서 false 가 될 수 있으며, 그 경우 클라이언트(maxTouchPoints)가 보정한다. + * + * @param string $userAgent User-Agent 헤더 값 + * @return bool iOS 기기 여부 + */ + public static function isIosFromUserAgent(string $userAgent): bool + { + foreach (self::IOS_UA_KEYWORDS as $keyword) { + if (stripos($userAgent, $keyword) !== false) { + return true; + } + } + + return false; + } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php index 327dcb04..a279a326 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php @@ -8,7 +8,6 @@ use Exception; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Facades\Log; -use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException; use Modules\Sirsoft\Ecommerce\Exceptions\InsufficientStockException; use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException; @@ -96,10 +95,13 @@ trait HandlesOrderCreation $order->load(['options', 'payment', 'shippingAddress']); - // PG 결제 필요 여부 판단 - $paymentMethod = PaymentMethodEnum::tryFrom($order->payment->payment_method->value ?? $order->payment->payment_method); - $pgProvider = $this->orderProcessingService->determinePgProvider($paymentMethod->value); - $requiresPg = $paymentMethod->needsPgProvider() + // PG 결제 필요 여부 판단 — 확장 결제수단(간편결제)도 카탈로그 선언으로 올바르게 판정된다. + // enum 으로 판정하면 확장 ID 가 null → PG 불필요로 오인되어 관리자 알림이 오발송되고 + // TempOrder 가 즉시 삭제되어 재결제가 막힌다(#475). + $pgProvider = $this->orderProcessingService->determinePgProvider( + $order->payment->paymentMethodId() + ); + $requiresPg = $order->payment->needsPgProvider() && ! in_array($pgProvider, ['manual', 'internal', 'none']) // 결제할 금액이 0원이면(전액 마일리지/예치금 등 비현금 충당) PG 호출 불필요 — // 주문 생성 시점에 이미 결제완료 확정됨. 결제수단 선택과 무관. @@ -305,6 +307,11 @@ trait HandlesOrderCreation 'customer_email' => $shippingAddress?->orderer_email, 'customer_phone' => preg_replace('/[^0-9]/', '', $shippingAddress?->orderer_phone ?? ''), 'customer_key' => $order->user_id ? "user_{$order->user_id}" : null, + // 선택된 결제수단 ID (확장 수단이면 확장 ID 그대로 — 예: 'kginicis_lpay'). + // PG 플러그인의 결제 진입 핸들러가 이 값으로 결제창의 결제수단을 결정한다 + // (예: kginicis_lpay → gopaymethod=LPAY). 서버가 확장 ID 를 1급 시민으로 + // 저장하게 되면서 프론트 인터셉터가 원본 수단을 따로 전달할 필요가 없어졌다(#475). + 'payment_method' => $order->payment?->paymentMethodId(), ]; } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Middleware/DetectDevice.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Middleware/DetectDevice.php new file mode 100644 index 00000000..31bbe800 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Middleware/DetectDevice.php @@ -0,0 +1,61 @@ +userAgent() ?? '') + ); + + return $next($request); + } + + /** + * 현재 요청이 iOS 기기인지 반환합니다. + * + * 미들웨어가 실행되지 않은 컨텍스트에서는 false(애플페이 기본 숨김 — fail-safe)를 반환합니다. + * + * @return bool iOS 기기 여부 + */ + public static function isIos(): bool + { + if (App::bound(self::IS_IOS_KEY)) { + return (bool) App::make(self::IS_IOS_KEY); + } + + return false; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ConfirmDepositRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ConfirmDepositRequest.php index e8153dcf..b5bf594c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ConfirmDepositRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ConfirmDepositRequest.php @@ -6,7 +6,6 @@ use App\Helpers\ResponseHelper; use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\ValidationException; -use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; use Modules\Sirsoft\Ecommerce\Models\Order; /** @@ -81,7 +80,7 @@ class ConfirmDepositRequest extends FormRequest $order->loadMissing('payment'); // 무통장(dbank) 결제수단만 입금확인 대상 - if ($order->payment?->payment_method !== PaymentMethodEnum::DBANK) { + if (! $order->payment?->isBankTransfer()) { $validator->errors()->add( 'order', __('sirsoft-ecommerce::messages.orders.deposit_not_dbank') diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/OrderListRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/OrderListRequest.php index b72f2142..43719ff2 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/OrderListRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/OrderListRequest.php @@ -8,8 +8,8 @@ use Illuminate\Validation\Rule; use Modules\Sirsoft\Ecommerce\Enums\DeviceTypeEnum; use Modules\Sirsoft\Ecommerce\Enums\OrderDateTypeEnum; use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; -use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; use Modules\Sirsoft\Ecommerce\Models\ShippingType; +use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver; /** * 주문 목록 조회 요청 @@ -19,7 +19,7 @@ class OrderListRequest extends FormRequest /** * 권한 확인 * - * @return bool + * @return bool 항상 true (권한 체크는 라우트의 permission 미들웨어에서 수행) */ public function authorize(): bool { @@ -29,7 +29,7 @@ class OrderListRequest extends FormRequest /** * 유효성 검사 규칙 * - * @return array + * @return array 주문 목록 조회 파라미터 검증 규칙 */ public function rules(): array { @@ -57,7 +57,8 @@ class OrderListRequest extends FormRequest // 결제수단 (다중선택) 'payment_method' => ['nullable', 'array'], - 'payment_method.*' => ['string', Rule::in(PaymentMethodEnum::values())], + // 확장 결제수단(간편결제)도 주문 목록에서 필터링할 수 있어야 한다 (#475). + 'payment_method.*' => ['string', Rule::in(app(PaymentMethodResolver::class)->allValidIds())], // 카테고리 'category_id' => ['nullable', 'integer'], diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php index acf3723e..adf5bc8b 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php @@ -5,10 +5,10 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin; use App\Rules\LocaleRequiredTranslatable; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Validator; -use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService; +use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver; /** * 이커머스 설정 저장 요청 검증 @@ -525,13 +525,26 @@ class StoreEcommerceSettingsRequest extends FormRequest return; } + $resolver = app(PaymentMethodResolver::class); + foreach ($methods as $index => $method) { $id = $method['id'] ?? ''; $isActive = $method['is_active'] ?? false; $pgProvider = $method['pg_provider'] ?? null; - $enum = PaymentMethodEnum::tryFrom($id); - if ($enum && $enum->needsPgProvider() && $isActive && ! $pgProvider && ! $defaultPg) { + if ($id === '') { + continue; + } + + // PG 고정 수단(간편결제 등)은 플러그인이 자기 PG 로 고정하므로 관리자가 선택할 수 없다. + // 검증 대상에서 제외하지 않으면 저장 자체가 막힌다. + if ($resolver->isPgLocked($id)) { + continue; + } + + // 확장 결제수단도 카탈로그 선언으로 PG 필요 여부가 판정된다. + // enum tryFrom() 으로 판정하면 확장수단이 null → 검증이 통째로 스킵됐다(#475). + if ($resolver->needsPgProvider($id) && $isActive && ! $pgProvider && ! $defaultPg) { $validator->errors()->add( "order_settings.payment_methods.{$index}.pg_provider", __('sirsoft-ecommerce::validation.custom.order_settings.payment_methods.pg_required_for_activation') diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CreateOrderRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CreateOrderRequest.php index 08accce2..d58fdcce 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CreateOrderRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CreateOrderRequest.php @@ -6,6 +6,7 @@ use App\Extension\HookManager; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; +use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver; /** * 주문 생성 (결제하기) 요청 @@ -56,7 +57,14 @@ class CreateOrderRequest extends FormRequest 'shipping.intl_postal_code' => 'required_with:shipping.address_line_1|nullable|string|max:20', // 결제 정보 - 'payment_method' => ['required', 'string', Rule::in(array_column(PaymentMethodEnum::cases(), 'value'))], + // 결제수단 화이트리스트는 카탈로그(builtin 8종 + 플러그인 등록 확장수단)가 SSoT. + // enum cases 로 제한하면 확장 결제수단(간편결제)이 422 로 막혀, 프론트가 + // payment_method 를 'card' 로 위장해 보낼 수밖에 없게 된다(#475). + 'payment_method' => [ + 'required', + 'string', + Rule::in(app(PaymentMethodResolver::class)->allValidIds()), + ], 'expected_total_amount' => 'required|numeric|min:0', // 배송 메모 diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderListResource.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderListResource.php index 9ee3bb6b..c14e6c40 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderListResource.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderListResource.php @@ -117,7 +117,7 @@ class OrderListResource extends BaseApiResource // 결제 'payment' => $this->whenLoaded('payment', fn () => [ 'payment_method' => $this->payment->payment_method, - 'payment_method_label' => $this->payment->payment_method ? $this->payment->payment_method->label() : null, + 'payment_method_label' => $this->payment->payment_method ? $this->payment->paymentMethodLabel() : null, ]), // 배송 (첫 번째) diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderPaymentResource.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderPaymentResource.php index cd39eac3..c3b05d1a 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderPaymentResource.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderPaymentResource.php @@ -28,11 +28,11 @@ class OrderPaymentResource extends BaseApiResource 'payment_status_label' => $this->payment_status ? $this->payment_status->label() : null, 'payment_status_variant' => $this->payment_status ? $this->payment_status->variant() : null, 'payment_method' => $this->payment_method, - 'payment_method_label' => $this->payment_method ? $this->payment_method->label() : null, - 'payment_type_label' => $this->payment_method ? $this->payment_method->label() : null, + 'payment_method_label' => $this->payment_method ? $this->paymentMethodLabel() : null, + 'payment_type_label' => $this->payment_method ? $this->paymentMethodLabel() : null, // PG 환불(취소)이 필요한 결제수단인지 — 취소 모달의 'PG 함께 취소' 체크박스·'환불 우선순위' 라디오 // 노출 여부 SSoT(무통장/포인트/예치금/무료는 false → 두 UI 숨김). U1·A28 공유 플래그. - 'requires_pg_cancellation' => (bool) $this->payment_method?->needsPgProvider(), + 'requires_pg_cancellation' => $this->payment_method ? $this->needsPgProvider() : false, 'pg_provider' => $this->pg_provider, 'transaction_id' => $this->transaction_id, 'merchant_order_id' => $this->merchant_order_id, @@ -137,7 +137,7 @@ class OrderPaymentResource extends BaseApiResource */ private function getAccountInfo(): ?string { - $method = $this->payment_method?->value ?? null; + $method = $this->paymentMethodId(); if ($method === PaymentMethodEnum::CARD->value) { $info = $this->card_name ?? ''; diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/InjectAppConfigDeviceListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/InjectAppConfigDeviceListener.php new file mode 100644 index 00000000..f897ae72 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/InjectAppConfigDeviceListener.php @@ -0,0 +1,58 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'core.frontend.filter_app_config' => [ + 'method' => 'injectDeviceFlags', + 'priority' => 20, + 'type' => 'filter', + ], + ]; + } + + /** + * 기본 핸들러 (미사용). + * + * @param mixed ...$args + */ + public function handle(...$args): void {} + + /** + * appConfig 에 요청 기기 iOS 여부를 주입합니다. + * + * @param array $appConfig 코어가 조립한 프론트엔드 appConfig + * @return array isIos 가 추가된 appConfig + */ + public function injectDeviceFlags(array $appConfig): array + { + $appConfig['isIos'] = DetectDevice::isIos(); + + return $appConfig; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php b/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php index 807545fd..fdcbe6ac 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php @@ -404,7 +404,7 @@ class Order extends Model */ public function getPaymentMethodLabel(): ?string { - return $this->payment?->payment_method?->getLabel(); + return $this->payment?->paymentMethodLabel(); } /** diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/OrderPayment.php b/modules/_bundled/sirsoft-ecommerce/src/Models/OrderPayment.php index a8fcb3e8..bb7ec522 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Models/OrderPayment.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/OrderPayment.php @@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory; use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; +use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum; +use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver; /** * 주문 결제 모델 @@ -106,12 +108,28 @@ class OrderPayment extends Model 'paid_at' => 'datetime', 'cancelled_at' => 'datetime', 'payment_status' => PaymentStatusEnum::class, - 'payment_method' => PaymentMethodEnum::class, + // payment_method 는 의도적으로 캐스트하지 않는다 (순수 string) — 아래 주석 참조. // 다중 통화 컬럼 (JSON) 'mc_paid_amount' => 'array', 'mc_cancelled_amount' => 'array', ]; + /* + * payment_method 에 enum 캐스트를 두지 않는 이유 + * + * PG 플러그인이 등록하는 확장 결제수단(예: 'nhnkcp_naverpay')은 PaymentMethodEnum 에 + * case 가 없다. Laravel 의 enum 캐스트는 내부적으로 tryFrom() 이 아니라 from() 을 쓰므로 + * (HasAttributes::getEnumCaseFromValue), 확장 ID 를 저장하는 순간 ValueError → 500 이 된다. + * + * 능력 질의(PG 필요 여부 / 라벨 / 환불수단)는 아래 능력 메서드가 PaymentMethodResolver 에 + * 위임해 답한다 — 소비처는 payment_method 를 직접 비교하지 않는다. + * + * 하위호환: BackedEnum 은 JsonSerializable 이라 API 응답에는 이미 값 문자열('card')이 + * 나가고 있었다. 순수 string 으로 바꿔도 wire format 이 완전히 동일하다. + * + * @see https://github.com/gnuboard/dev-g7/issues/475 + */ + /** * 주문 관계 * @@ -139,7 +157,7 @@ class OrderPayment extends Model */ public function isCardPayment(): bool { - return $this->payment_method === PaymentMethodEnum::CARD; + return $this->paymentMethodId() === PaymentMethodEnum::CARD->value; } /** @@ -149,7 +167,78 @@ class OrderPayment extends Model */ public function isVirtualAccount(): bool { - return $this->payment_method === PaymentMethodEnum::VBANK; + return $this->paymentMethodId() === PaymentMethodEnum::VBANK->value; + } + + /** + * 무통장입금(수동 입금확인) 결제 여부 확인 + * + * @return bool 무통장입금 여부 + */ + public function isBankTransfer(): bool + { + return $this->paymentMethodId() === PaymentMethodEnum::DBANK->value; + } + + /** + * PG 결제창이 필요한 결제수단인지 확인합니다. + * + * 확장 결제수단(간편결제 등)도 카탈로그 선언을 통해 올바르게 판정된다. + * + * @return bool PG 결제창 필요 여부 + */ + public function needsPgProvider(): bool + { + return $this->resolver()->needsPgProvider($this->paymentMethodId()); + } + + /** + * 결제수단의 다국어 표시명을 반환합니다. + * + * @return string 표시명 + */ + public function paymentMethodLabel(): string + { + return $this->resolver()->label($this->paymentMethodId()); + } + + /** + * 결제수단에 대응하는 환불 수단을 반환합니다. + * + * @return RefundMethodEnum 환불 수단 + */ + public function refundMethod(): RefundMethodEnum + { + return $this->resolver()->refundMethod($this->paymentMethodId()); + } + + /** + * 결제수단 ID 를 문자열로 반환합니다. + * + * `payment_method` 는 순수 string 이지만, 과거 enum 캐스트 시절의 값이나 외부에서 + * enum 을 대입한 경우를 방어적으로 흡수한다. + * + * @return string 결제수단 ID (미설정 시 빈 문자열) + */ + public function paymentMethodId(): string + { + $method = $this->payment_method; + + if ($method instanceof \BackedEnum) { + return (string) $method->value; + } + + return (string) ($method ?? ''); + } + + /** + * 결제수단 능력 해석기를 반환합니다. + * + * @return PaymentMethodResolver 결제수단 능력 해석기 + */ + private function resolver(): PaymentMethodResolver + { + return app(PaymentMethodResolver::class); } /** diff --git a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php index 5eb27287..e22a4739 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php @@ -11,6 +11,7 @@ use Modules\Sirsoft\Ecommerce\Console\Commands\ExpireMileageCommand; use Modules\Sirsoft\Ecommerce\Console\Commands\NotifyExpiringMileageCommand; use Modules\Sirsoft\Ecommerce\Console\Commands\PruneExpiredCartsCommand; use Modules\Sirsoft\Ecommerce\Console\Commands\ReconcileMileageBalanceCommand; +use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice; use Modules\Sirsoft\Ecommerce\Repositories\BrandRepository; use Modules\Sirsoft\Ecommerce\Repositories\CartRepository; use Modules\Sirsoft\Ecommerce\Repositories\CategoryImageRepository; @@ -90,6 +91,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\UserAddressRepository; use Modules\Sirsoft\Ecommerce\Seo\EcommerceSitemapContributor; use Modules\Sirsoft\Ecommerce\Services\CategoryImageService; use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService; +use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver; use Modules\Sirsoft\Ecommerce\Services\ProductImageService; use Modules\Sirsoft\Ecommerce\Services\ProductReviewImageService; use Modules\Sirsoft\Ecommerce\Services\ProductReviewService; @@ -192,6 +194,9 @@ class EcommerceServiceProvider extends BaseModuleServiceProvider // ShippingPolicyResolver를 싱글톤으로 등록 (요청 내 기본 배송정책 조회 1회 캐시) $this->app->singleton(ShippingPolicyResolver::class); + + // PaymentMethodResolver를 싱글톤으로 등록 (요청 내 결제수단 카탈로그 조회 1회 캐시) + $this->app->singleton(PaymentMethodResolver::class); } /** @@ -206,6 +211,10 @@ class EcommerceServiceProvider extends BaseModuleServiceProvider $this->commands($this->commands); } + // 요청 기기 유형(iOS) 감지 미들웨어(DetectDevice)는 코어 self-gate 방식으로 이관됨. + // Module::getMiddleware() 가 targets=['/'](무명 User SSR 셸 URI 패턴)로 선언하고, + // 코어 ExtensionMiddlewareGate 가 요청 시점에 실행한다. (SP Kernel 직접 조작 제거) + // Sitemap 기여자 등록 $this->app->booted(function () { if ($this->app->bound(SitemapGenerator::class)) { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php index 30fc16cc..12680ee1 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php @@ -7,6 +7,8 @@ use App\Extension\HookManager; use App\Traits\NormalizesSettingsData; use Illuminate\Support\Arr; use Illuminate\Support\Facades\File; +use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; +use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum; /** * 이커머스 모듈 환경설정 서비스 @@ -610,6 +612,12 @@ class EcommerceSettingsService implements ModuleSettingsInterface 'source' => $method['_cached_source'] ?? 'builtin', 'defaults' => [ 'pg_provider' => $method['pg_provider'] ?? null, + // 능력(capability) 선언 — builtin 은 enum 에서 파생한다. + // 확장 결제수단은 PG 플러그인이 filter_available_payment_methods 훅에서 + // 직접 선언한다 (enum case 가 없으므로). + 'needs_pg' => $method['needs_pg'] ?? $this->builtinNeedsPg($id), + 'pg_locked' => $method['pg_locked'] ?? false, + 'refund_method' => $method['refund_method'] ?? $this->builtinRefundMethod($id), 'is_active' => $method['is_active'] ?? true, 'min_order_amount' => $method['min_order_amount'] ?? 0, 'stock_deduction_timing' => $method['stock_deduction_timing'] ?? 'payment_complete', @@ -620,6 +628,35 @@ class EcommerceSettingsService implements ModuleSettingsInterface }, $methods); } + /** + * builtin 결제수단의 PG 필요 여부를 enum 에서 파생합니다. + * + * @param string $id 결제수단 ID + * @return bool PG 결제창 필요 여부 + */ + private function builtinNeedsPg(string $id): bool + { + return PaymentMethodEnum::tryFrom($id)?->needsPgProvider() ?? true; + } + + /** + * builtin 결제수단의 환불 수단을 enum 에서 파생합니다. + * + * @param string $id 결제수단 ID + * @return string 환불 수단 값 (pg / bank / points) + */ + private function builtinRefundMethod(string $id): string + { + return match (PaymentMethodEnum::tryFrom($id)) { + PaymentMethodEnum::CARD, + PaymentMethodEnum::BANK, + PaymentMethodEnum::VBANK, + PaymentMethodEnum::PHONE => RefundMethodEnum::PG->value, + PaymentMethodEnum::POINT => RefundMethodEnum::POINTS->value, + default => RefundMethodEnum::BANK->value, + }; + } + /** * 사용 가능한 결제수단 정의 조회 (기본 + 플러그인 필터) * @@ -744,10 +781,33 @@ class EcommerceSettingsService implements ModuleSettingsInterface $id = $definition['id']; $savedItem = $savedById->get($id); + // 능력(capability) 선언은 정의(enum 파생 또는 플러그인 선언)가 SSoT — saved 로 덮지 않는다. + // 관리자가 편집하는 값이 아니며, 플러그인이 자기 결제수단의 성격(PG 필요/환불수단/PG 고정)을 + // 바꾸면 그 선언이 즉시 반영되어야 한다. + $needsPg = (bool) ($definition['defaults']['needs_pg'] ?? $this->builtinNeedsPg($id)); + $pgLocked = (bool) ($definition['defaults']['pg_locked'] ?? false); + $refundMethod = $definition['defaults']['refund_method'] ?? $this->builtinRefundMethod($id); + + // PG 고정 수단(간편결제 등)은 특정 PG 에 종속되므로 관리자가 다른 PG 로 바꿀 수 없다. + // saved 에 남은 과거 값(예: null)이 있어도 정의의 PG 를 강제한다. + $capabilities = [ + 'needs_pg' => $needsPg, + 'pg_locked' => $pgLocked, + 'refund_method' => $refundMethod, + ]; + + // iOS 전용 노출 플래그(애플페이 등) — 정의가 SSoT. 관리자가 편집하는 값이 아니며, + // 체크아웃 레이아웃이 비-iOS 기기에서 해당 수단을 렌더하지 않는 데 쓰인다. + if (! empty($definition['requires_ios'])) { + $capabilities['requires_ios'] = true; + } + if ($savedItem) { - $merged[] = [ + $merged[] = array_merge([ 'id' => $id, - 'pg_provider' => $savedItem['pg_provider'] ?? null, + 'pg_provider' => $pgLocked + ? ($definition['defaults']['pg_provider'] ?? null) + : ($savedItem['pg_provider'] ?? null), 'sort_order' => $savedItem['sort_order'] ?? count($merged) + 1, 'is_active' => $savedItem['is_active'] ?? $definition['defaults']['is_active'] ?? true, 'min_order_amount' => $savedItem['min_order_amount'] ?? $definition['defaults']['min_order_amount'] ?? 0, @@ -756,11 +816,12 @@ class EcommerceSettingsService implements ModuleSettingsInterface '_cached_name' => $definition['name'], '_cached_description' => $definition['description'] ?? ['ko' => '', 'en' => ''], '_cached_icon' => $definition['icon'] ?? 'circle-question', + '_cached_brand_mark' => $definition['brand_mark'] ?? null, '_cached_source' => $definition['source'] ?? 'builtin', - ]; + ], $capabilities); } else { // 신규 결제수단 (기본값 적용) - $merged[] = [ + $merged[] = array_merge([ 'id' => $id, 'pg_provider' => $definition['defaults']['pg_provider'] ?? null, 'sort_order' => count($merged) + 1, @@ -771,8 +832,9 @@ class EcommerceSettingsService implements ModuleSettingsInterface '_cached_name' => $definition['name'], '_cached_description' => $definition['description'] ?? ['ko' => '', 'en' => ''], '_cached_icon' => $definition['icon'] ?? 'circle-question', + '_cached_brand_mark' => $definition['brand_mark'] ?? null, '_cached_source' => $definition['source'] ?? 'builtin', - ]; + ], $capabilities); } } @@ -820,6 +882,7 @@ class EcommerceSettingsService implements ModuleSettingsInterface $savedMethods[$index]['_cached_name'] = $def['name'] ?? $method['_cached_name'] ?? null; $savedMethods[$index]['_cached_description'] = $def['description'] ?? $method['_cached_description'] ?? null; $savedMethods[$index]['_cached_icon'] = $def['icon'] ?? $method['_cached_icon'] ?? null; + $savedMethods[$index]['_cached_brand_mark'] = $def['brand_mark'] ?? $method['_cached_brand_mark'] ?? null; $savedMethods[$index]['_cached_source'] = $def['source'] ?? $method['_cached_source'] ?? 'builtin'; } // 고아 항목은 기존 _cached_* 유지 diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php index 7be48aec..324f6012 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php @@ -13,7 +13,6 @@ use Modules\Sirsoft\Ecommerce\Enums\CancelOptionStatusEnum; use Modules\Sirsoft\Ecommerce\Enums\CancelStatusEnum; use Modules\Sirsoft\Ecommerce\Enums\CancelTypeEnum; use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; -use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum; use Modules\Sirsoft\Ecommerce\Enums\RefundOptionStatusEnum; @@ -278,7 +277,7 @@ class OrderCancellationService // 무통장입금(dbank)·포인트·예치금·무료는 PG 리스너가 인식하지 못해 success=false → 트랜잭션 롤백. // 이들은 PG 훅을 건너뛰고 운영자 수동 환불(APPROVED) 로 처리한다. if ($cancelPg && $order->payment && $adjustmentResult->refundAmount > 0 - && $order->payment->payment_method?->needsPgProvider()) { + && $order->payment->needsPgProvider()) { $this->executePgRefund($order, $orderRefund, $adjustmentResult, $reason); } } @@ -740,14 +739,9 @@ class OrderCancellationService return RefundMethodEnum::BANK; } - $paymentMethod = $order->payment->payment_method; - - return match ($paymentMethod) { - PaymentMethodEnum::CARD, PaymentMethodEnum::BANK, PaymentMethodEnum::VBANK, PaymentMethodEnum::PHONE => RefundMethodEnum::PG, - PaymentMethodEnum::DBANK => RefundMethodEnum::BANK, - PaymentMethodEnum::POINT => RefundMethodEnum::POINTS, - default => RefundMethodEnum::BANK, - }; + // 확장 결제수단(간편결제 등)도 카탈로그 선언을 통해 올바른 환불수단으로 해석된다. + // enum match 로 두면 확장 ID 가 default → BANK 로 떨어져 카드 취소가 누락된다(#475). + return $order->payment->refundMethod(); } // ─────────────────────────────────────────────── diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php index 9a52af13..b64395bd 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php @@ -237,15 +237,11 @@ class OrderProcessingService */ protected function orderRequiresPgPayment(Order $order): bool { - $methodValue = $order->payment?->payment_method; - $methodValue = $methodValue instanceof \BackedEnum ? $methodValue->value : $methodValue; - $paymentMethod = PaymentMethodEnum::tryFrom((string) $methodValue); - - if ($paymentMethod === null || ! $paymentMethod->needsPgProvider()) { + if (! $order->payment || ! $order->payment->needsPgProvider()) { return false; } - $pgProvider = $this->determinePgProvider($paymentMethod->value); + $pgProvider = $this->determinePgProvider($order->payment->paymentMethodId()); return ! in_array($pgProvider, ['manual', 'internal', 'none'], true) && (float) $order->total_due_amount > 0; @@ -1495,7 +1491,7 @@ class OrderProcessingService } // 재고 차감 + 장바구니 처리 (payment_complete 타이밍: 트랜잭션 내부에서 실행) - $paymentMethodId = $order->payment->payment_method->value; + $paymentMethodId = $order->payment->paymentMethodId(); $timing = $this->settingsService->getStockDeductionTiming($paymentMethodId); if ($timing === 'payment_complete') { $this->stockService->deductStock($order->load('options')); diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/PaymentMethodResolver.php b/modules/_bundled/sirsoft-ecommerce/src/Services/PaymentMethodResolver.php new file mode 100644 index 00000000..7ea66e98 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/PaymentMethodResolver.php @@ -0,0 +1,214 @@ + config). null = 아직 미조회 + * + * @var array>|null + */ + private ?array $catalogCache = null; + + /** + * @param EcommerceSettingsService $settingsService 이커머스 설정 서비스 + */ + public function __construct( + private EcommerceSettingsService $settingsService + ) {} + + /** + * PG 결제창이 필요한 결제수단인지 판정합니다. + * + * 폴백: 카탈로그 `needs_pg` → enum `needsPgProvider()` → true(확장 수단 기본). + * + * 미등록 확장 ID 의 기본값이 true 인 이유: 플러그인이 등록하는 결제수단은 + * 대부분 PG 결제창을 거친다. false 로 오판하면 결제 실패 주문에 관리자 알림이 + * 발송되고 TempOrder 가 삭제되어 재결제가 막히는(이슈 #475) 실패 모드가 된다. + * 반대 방향의 오판(PG 불필요 수단을 PG 로 봄)은 `determinePgProvider()` 가 + * 'none'/'manual'/'internal' 을 반환해 걸러내므로 안전하다. + * + * @param string $methodId 결제수단 ID (builtin 8종 또는 확장 ID) + * @return bool PG 결제창 필요 여부 + */ + public function needsPgProvider(string $methodId): bool + { + $declared = $this->catalogValue($methodId, 'needs_pg'); + + if ($declared !== null) { + return (bool) $declared; + } + + return PaymentMethodEnum::tryFrom($methodId)?->needsPgProvider() ?? true; + } + + /** + * 결제수단의 다국어 표시명을 반환합니다. + * + * 폴백: 카탈로그 `_cached_name` 의 현재 locale → enum `label()` → raw id. + * + * @param string $methodId 결제수단 ID + * @return string 현재 locale 의 표시명 + */ + public function label(string $methodId): string + { + $name = $this->catalogValue($methodId, '_cached_name'); + + if (is_array($name)) { + $locale = app()->getLocale(); + $resolved = $name[$locale] + ?? $name[config('app.fallback_locale', 'ko')] + ?? (! empty($name) ? array_values($name)[0] : null); + + if (is_string($resolved) && $resolved !== '') { + return $resolved; + } + } + + return PaymentMethodEnum::tryFrom($methodId)?->label() ?? $methodId; + } + + /** + * 결제수단에 대응하는 환불 수단을 반환합니다. + * + * 폴백: 카탈로그 `refund_method` → enum 기반 분류 → BANK(기본). + * + * @param string $methodId 결제수단 ID + * @return RefundMethodEnum 환불 수단 + */ + public function refundMethod(string $methodId): RefundMethodEnum + { + $declared = $this->catalogValue($methodId, 'refund_method'); + + if (is_string($declared) && ($enum = RefundMethodEnum::tryFrom($declared)) !== null) { + return $enum; + } + + return match (PaymentMethodEnum::tryFrom($methodId)) { + PaymentMethodEnum::CARD, + PaymentMethodEnum::BANK, + PaymentMethodEnum::VBANK, + PaymentMethodEnum::PHONE => RefundMethodEnum::PG, + PaymentMethodEnum::POINT => RefundMethodEnum::POINTS, + PaymentMethodEnum::DBANK => RefundMethodEnum::BANK, + // 확장 결제수단(enum case 없음)은 PG 결제 여부로 판정한다. + // 미선언 확장수단이 BANK 로 떨어지면 카드 취소가 누락된다(안전 이슈). + default => $this->needsPgProvider($methodId) + ? RefundMethodEnum::PG + : RefundMethodEnum::BANK, + }; + } + + /** + * 관리자가 PG 를 변경할 수 없는(플러그인이 자기 PG 로 고정한) 결제수단인지 판정합니다. + * + * 간편결제 수단(네이버페이 등)은 특정 PG 에 종속되므로 다른 PG 로 바꿀 수 없다. + * 관리자 주문설정 UI 가 PG 선택 Select 대신 "PG 고정" 배지를 표시하는 근거. + * + * @param string $methodId 결제수단 ID + * @return bool PG 고정 여부 + */ + public function isPgLocked(string $methodId): bool + { + return (bool) $this->catalogValue($methodId, 'pg_locked'); + } + + /** + * 코어 builtin 8종 결제수단인지 판정합니다. + * + * @param string $methodId 결제수단 ID + * @return bool builtin 여부 + */ + public function isBuiltin(string $methodId): bool + { + return PaymentMethodEnum::tryFrom($methodId) !== null; + } + + /** + * 검증 화이트리스트 — builtin 8종 + 카탈로그에 등록된 확장 수단 전부. + * + * `CreateOrderRequest` / `OrderListRequest` 의 `Rule::in()` 에 사용한다. + * 확장 ID 를 1급 결제수단으로 받되, 카탈로그에 없는 임의 문자열은 여전히 거부한다. + * + * @return array 유효한 결제수단 ID 목록 + */ + public function allValidIds(): array + { + return array_values(array_unique(array_merge( + PaymentMethodEnum::values(), + array_keys($this->catalog()) + ))); + } + + /** + * 카탈로그에서 특정 결제수단의 특정 키 값을 조회합니다. + * + * @param string $methodId 결제수단 ID + * @param string $key 조회할 키 + * @return mixed 값 (미선언 시 null) + */ + private function catalogValue(string $methodId, string $key): mixed + { + return $this->catalog()[$methodId][$key] ?? null; + } + + /** + * 결제수단 카탈로그를 ID 키 맵으로 반환합니다 (요청 내 1회 캐시). + * + * @return array> 결제수단 ID => 설정 + */ + private function catalog(): array + { + if ($this->catalogCache !== null) { + return $this->catalogCache; + } + + $methods = $this->settingsService->getSetting('order_settings.payment_methods') ?? []; + + $keyed = []; + foreach ($methods as $method) { + $id = $method['id'] ?? null; + if (is_string($id) && $id !== '') { + $keyed[$id] = $method; + } + } + + return $this->catalogCache = $keyed; + } + + /** + * 카탈로그 캐시를 비웁니다 (설정 저장 직후 재조회 강제). + */ + public function flushCache(): void + { + $this->catalogCache = null; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/routes/api.php b/modules/_bundled/sirsoft-ecommerce/src/routes/api.php index 3251043e..8e4c0d3c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/routes/api.php +++ b/modules/_bundled/sirsoft-ecommerce/src/routes/api.php @@ -44,8 +44,6 @@ use Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserCouponController; use Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserCurrencyController; use Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserMileageController; use Modules\Sirsoft\Ecommerce\Http\Controllers\User\UserShippingCountryController; -use Modules\Sirsoft\Ecommerce\Http\Middleware\ResolveShippingCountry; -use Modules\Sirsoft\Ecommerce\Http\Middleware\VerifyGuestOrderToken; /* |-------------------------------------------------------------------------- @@ -81,7 +79,7 @@ Route::prefix('categories')->group(function () { // GET /api/modules/sirsoft-ecommerce/products/new - 신상품 조회 // GET /api/modules/sirsoft-ecommerce/products/recent - 최근 본 상품 조회 // GET /api/modules/sirsoft-ecommerce/products/{id} - 공개 상품 상세 조회 -Route::prefix('products')->middleware(['optional.sanctum', ResolveShippingCountry::class, 'permission:user,sirsoft-ecommerce.user-products.read'])->group(function () { +Route::prefix('products')->middleware(['optional.sanctum', 'permission:user,sirsoft-ecommerce.user-products.read'])->group(function () { Route::get('/', [PublicProductController::class, 'index']) ->name('products.index'); @@ -153,7 +151,7 @@ Route::get('review-image/{hash}', [ReviewImageController::class, 'download']) // DELETE /api/modules/sirsoft-ecommerce/cart/all - 전체 삭제 // POST /api/modules/sirsoft-ecommerce/cart/merge - 비회원→회원 병합 // GET /api/modules/sirsoft-ecommerce/cart/count - 아이템 수 조회 -Route::prefix('cart')->middleware(['optional.sanctum', ResolveShippingCountry::class])->group(function () { +Route::prefix('cart')->middleware(['optional.sanctum'])->group(function () { // cart_key 발급 (비회원용) - 와일드카드보다 먼저 정의 Route::post('/key', [CartController::class, 'issueCartKey']) ->name('cart.key'); @@ -208,7 +206,7 @@ Route::prefix('cart')->middleware(['optional.sanctum', ResolveShippingCountry::c // PUT /api/modules/sirsoft-ecommerce/checkout - 임시 주문 업데이트 (쿠폰/마일리지 재계산) // DELETE /api/modules/sirsoft-ecommerce/checkout - 임시 주문 삭제 // POST /api/modules/sirsoft-ecommerce/checkout/extend - 임시 주문 만료 연장 -Route::prefix('checkout')->middleware(['optional.sanctum', ResolveShippingCountry::class])->group(function () { +Route::prefix('checkout')->middleware(['optional.sanctum'])->group(function () { // 임시 주문 생성 Route::post('/', [CheckoutController::class, 'store']) ->name('checkout.store'); @@ -255,7 +253,7 @@ Route::prefix('settings')->group(function () { // /api/modules/sirsoft-ecommerce/user/orders 한 경로만 매칭하기 때문 — 비회원 주문에서도 // PG 결제창이 정상 노출되도록 단일 endpoint 로 통합. 회원/비회원 분기는 컨트롤러가 처리한다. Route::post('user/orders', [PublicOrderController::class, 'store']) - ->middleware(['optional.sanctum', ResolveShippingCountry::class, 'permission:user,sirsoft-ecommerce.user-orders.create']) + ->middleware(['optional.sanctum', 'permission:user,sirsoft-ecommerce.user-orders.create']) ->name('user.orders.store'); // 비회원 주문 조회 인증 API (주문번호+전화번호+비밀번호 → 조회 토큰 발급) @@ -273,7 +271,6 @@ Route::post('guest/orders/verify', [PublicOrderController::class, 'verify']) // (PublicOrderController::showByOrderNumber). 비회원 후속 액션 (cancel/estimate-refund/update-shipping-address/confirm-option) // 만 본 그룹에 둔다. Route::prefix('guest/orders/{orderNumber}') - ->middleware(VerifyGuestOrderToken::class) ->group(function () { // POST /guest/orders/{orderNumber}/cancel - 주문 취소 Route::post('/cancel', [PublicOrderController::class, 'cancel']) diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/OrderCancellationControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/OrderCancellationControllerTest.php index 6f907d49..2e2a9e72 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/OrderCancellationControllerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/OrderCancellationControllerTest.php @@ -346,6 +346,90 @@ class OrderCancellationControllerTest extends ModuleTestCase ->assertJsonPath('success', true); } + /** + * 확장 결제수단(간편결제) 주문을 취소하면 PG 환불 훅이 실제로 진입해야 한다 (#475). + * + * 취소 서비스는 `$order->payment->needsPgProvider()` 가 true 일 때만 PG 환불 + * 훅(`payment.refund`)에 진입한다(OrderCancellationService:279-281). 확장 ID 는 + * enum case 가 없어 과거에는 이 게이트에서 false 로 떨어져 카드 취소가 누락됐다. + * 이 테스트는 게이트 판정값이 아니라 실제 훅 발화를 스파이로 관측한다 — 판정과 + * 훅 진입 사이 로직이 바뀌어 환불 누락이 재발하면 이 테스트가 잡는다. + * + * @scenario method_kind=extension, capability_declared=declared, capability=refund_method + * + * @effects refund_method_is_pg, pg_refund_hook_invoked + */ + #[Test] + public function admin_cancel_extension_payment_order_enters_pg_refund_hook(): void + { + $this->createCancelSequences(); + + // PG 플러그인이 하는 것과 동일하게 간편결제 수단을 카탈로그에 등록해 + // needsPgProvider('nhnkcp_naverpay') 가 카탈로그 능력으로 true 가 되게 한다. + HookManager::addFilter( + 'sirsoft-ecommerce.settings.filter_available_payment_methods', + fn (array $methods) => array_merge($methods, [[ + 'id' => 'nhnkcp_naverpay', + 'name' => ['ko' => '네이버페이', 'en' => 'Naver Pay'], + 'description' => ['ko' => '', 'en' => ''], + 'icon' => 'credit-card', + 'source' => 'plugin:sirsoft-pay_nhnkcp', + 'defaults' => [ + 'pg_provider' => 'nhnkcp', + 'pg_locked' => true, + 'needs_pg' => true, + 'refund_method' => 'pg', + 'is_active' => true, + 'min_order_amount' => 0, + 'stock_deduction_timing' => 'payment_complete', + 'mileage_deduction_timing' => 'payment_complete', + ], + ]]) + ); + app(\Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver::class)->flushCache(); + + $data = $this->createPaidOrderWithOptions( + optionCount: 1, + unitPrice: 30000, + quantity: 1, + withPayment: true, + ); + $order = $data['order']; + // 결제 레코드를 확장 결제수단으로 지정 (기본 factory 는 card). + $order->payment()->update([ + 'payment_method' => 'nhnkcp_naverpay', + 'pg_provider' => 'nhnkcp', + ]); + + // PG 환불 훅 진입 여부를 관측하는 스파이 (성공 응답도 시뮬레이션). + $refundHook = new \ArrayObject(['count' => 0]); + HookManager::addFilter('sirsoft-ecommerce.payment.refund', function ($default) use ($refundHook) { + $refundHook['count']++; + + return [ + 'success' => true, + 'transaction_id' => 'TEST_TXN_EXT', + 'error_code' => null, + 'error_message' => null, + ]; + }, 10); + + $response = $this->actingAs($this->adminUser) + ->postJson("/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/cancel", [ + 'type' => 'full', + 'reason' => 'changed_mind', + 'cancel_pg' => true, + ]); + + $response->assertOk()->assertJsonPath('success', true); + $this->assertSame( + 1, + $refundHook['count'], + '확장 결제수단(간편결제) 취소 시 PG 환불 훅이 진입해야 한다 ' + .'(미진입 시 카드 취소가 누락되는 #475 안전 결함 재발)' + ); + } + /** * cancel_pg=false로 취소 요청 시 PG 연동 없이 취소가 처리된다. */ diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php index c9a72c0f..7414d9db 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php @@ -2,6 +2,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Public; +use App\Extension\HookManager; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; @@ -328,6 +329,120 @@ class OrderControllerTest extends ModuleTestCase ]); } + /** + * PG 플러그인이 하는 것과 동일하게 간편결제 수단·PG provider 를 카탈로그에 등록합니다. + * + * 확장 결제수단(nhnkcp_naverpay) 이 능력(PG 필요/고정/핸들러)을 갖도록 하여 + * 주문 생성 응답과 임시주문 보존이 실제 계약대로 동작하는지 검증할 수 있게 한다. + */ + private function registerExtensionEasyPayMethod(): void + { + HookManager::addFilter( + 'sirsoft-ecommerce.settings.filter_available_payment_methods', + fn (array $methods) => array_merge($methods, [[ + 'id' => 'nhnkcp_naverpay', + 'name' => ['ko' => '네이버페이', 'en' => 'Naver Pay'], + 'description' => ['ko' => '', 'en' => ''], + 'icon' => 'credit-card', + 'source' => 'plugin:sirsoft-pay_nhnkcp', + 'defaults' => [ + 'pg_provider' => 'nhnkcp', + 'pg_locked' => true, + 'needs_pg' => true, + 'refund_method' => 'pg', + 'is_active' => true, + 'min_order_amount' => 0, + 'stock_deduction_timing' => 'payment_complete', + 'mileage_deduction_timing' => 'payment_complete', + ], + ]]) + ); + + HookManager::addFilter( + 'sirsoft-ecommerce.payment.registered_pg_providers', + fn (array $providers) => array_merge($providers, [[ + 'id' => 'nhnkcp', + 'name' => 'NHN KCP', + 'payment_handler' => 'sirsoft-pay_nhnkcp.requestPayment', + ]]) + ); + + app(\Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver::class)->flushCache(); + app(\Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService::class)->clearCache(); + } + + /** + * T9 — 간편결제(확장 결제수단) 주문 생성 응답이 PG 결제 계약을 내려준다 (#475). + * + * 과거에는 확장 ID 가 PG 결제로 인식되지 않아 응답이 non-PG 로 내려갔고, 프론트가 + * payment_method 를 'card' 로 위장해야 결제창이 떴다. 이제 응답이 + * `requires_pg_payment=true` 와 provider 가 선언한 `pg_payment_handler` 를 실어 + * 템플릿이 위장 없이 표준 dispatch 한다. + * + * @scenario method_kind=extension, capability_declared=declared, capability=needs_pg + * + * @effects requires_pg_payment_true, pg_payment_handler_present, extension_id_passes_validation + */ + public function test_간편결제_주문_생성_응답이_PG_결제_계약을_내려준다(): void + { + $this->registerExtensionEasyPayMethod(); + $this->createGuestTempOrder(); + + $response = $this->postJson( + '/api/modules/sirsoft-ecommerce/user/orders', + $this->guestOrderPayload([ + 'payment_method' => 'nhnkcp_naverpay', + // 간편결제는 무통장 정보 불필요. + 'depositor_name' => null, + 'dbank' => null, + ]), + ['X-Cart-Key' => $this->cartKey] + ); + + $response->assertStatus(201) + ->assertJsonPath('data.requires_pg_payment', true) + ->assertJsonPath('data.pg_payment_handler', 'sirsoft-pay_nhnkcp.requestPayment'); + + // 저장된 결제수단이 확장 ID 그대로여야 한다 (card 위장 없음). + $order = Order::where('order_number', $response->json('data.order.order_number'))->first(); + $this->assertSame('nhnkcp_naverpay', $order->payment->payment_method); + } + + /** + * T10 — 간편결제 주문은 PG 결제 주문이므로 임시주문이 보존되어 재결제가 가능하다 (#475). + * + * 이슈의 핵심 증상: 결제 실패 후 재시도하면 "임시주문을 찾을 수 없습니다" 로 재결제가 + * 막혔다. 원인은 확장 결제수단이 non-PG 로 오인되어 주문 생성 시점에 TempOrder 가 + * 즉시 삭제된 것. 무통장(위 dbank 테스트)은 삭제가 정상이지만, PG 주문은 결제 완료 + * 콜백 전까지 TempOrder 를 남겨 재결제 진입을 보장해야 한다. + * + * @scenario method_kind=extension, capability_declared=declared, capability=needs_pg + * + * @effects temp_order_preserved + */ + public function test_간편결제_주문_생성후_임시주문이_보존된다(): void + { + $this->registerExtensionEasyPayMethod(); + $tempOrder = $this->createGuestTempOrder(); + + $response = $this->postJson( + '/api/modules/sirsoft-ecommerce/user/orders', + $this->guestOrderPayload([ + 'payment_method' => 'nhnkcp_naverpay', + 'depositor_name' => null, + 'dbank' => null, + ]), + ['X-Cart-Key' => $this->cartKey] + ); + + $response->assertStatus(201); + + // 간편결제(PG)는 결제 전 pending → 임시주문이 살아 있어야 재결제가 가능하다. + $this->assertDatabaseHas('ecommerce_temp_orders', [ + 'id' => $tempOrder->id, + ]); + } + /** * 9. 비회원 주문 생성 시 조회 비밀번호가 해시로 저장된다 (평문 미저장) */ diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Payment/ExtensionPaymentMethodRegressionTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Payment/ExtensionPaymentMethodRegressionTest.php new file mode 100644 index 00000000..faa1628d --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Payment/ExtensionPaymentMethodRegressionTest.php @@ -0,0 +1,554 @@ +registerExtensionPaymentMethod(); + } + + protected function tearDown(): void + { + HookManager::resetAll(); + parent::tearDown(); + } + + /** + * PG 플러그인이 하는 것과 동일하게 간편결제 수단을 카탈로그에 등록합니다. + * + * `RegisterEasyPayMethodsListener::buildEntry()` 가 내려주는 형태를 그대로 재현하되, + * 수정 후 선언될 능력 키(`pg_provider` / `needs_pg` / `refund_method` / `pg_locked`)를 + * 포함한다 — 이 선언을 코어가 존중하는지가 본 회귀 테스트의 검증 대상이다. + */ + private function registerExtensionPaymentMethod(): void + { + HookManager::addFilter( + 'sirsoft-ecommerce.settings.filter_available_payment_methods', + fn (array $methods) => array_merge($methods, [[ + 'id' => self::EXT_METHOD, + 'name' => ['ko' => '네이버페이', 'en' => 'Naver Pay'], + 'description' => ['ko' => '', 'en' => ''], + 'icon' => 'credit-card', + 'source' => 'plugin:sirsoft-pay_nhnkcp', + 'defaults' => [ + 'pg_provider' => self::EXT_PG, + 'pg_locked' => true, + 'needs_pg' => true, + 'refund_method' => 'pg', + 'is_active' => true, + 'min_order_amount' => 0, + 'stock_deduction_timing' => 'payment_complete', + 'mileage_deduction_timing' => 'payment_complete', + ], + ]]) + ); + + HookManager::addFilter( + 'sirsoft-ecommerce.payment.registered_pg_providers', + fn (array $providers) => array_merge($providers, [[ + 'id' => self::EXT_PG, + 'name' => 'NHN KCP', + 'payment_handler' => 'sirsoft-pay_nhnkcp.requestPayment', + ]]) + ); + } + + /** + * 확장 결제수단으로 결제 레코드를 만든 주문을 생성합니다. + * + * `payment_method` 컬럼에 확장 ID 를 1급 시민으로 저장한다 — 캐스트가 살아있는 + * 현재는 Eloquent 의 enum 캐스트(`from()`) 가 ValueError 를 던진다. + */ + private function createExtensionPaymentOrder(array $paymentOverrides = []): OrderPayment + { + $order = OrderFactory::new()->create(['total_due_amount' => 50000]); + + return OrderPaymentFactory::new()->forOrder($order)->create(array_merge([ + 'payment_method' => self::EXT_METHOD, + 'pg_provider' => self::EXT_PG, + ], $paymentOverrides)); + } + + /** + * 실제 주문 생성 흐름(createFromTempOrder)을 태우기 위한 최소 TempOrder 를 만듭니다. + * + * @param User $user 주문자 + * @param int $finalAmount 최종 결제금액 + * @return TempOrder 계산결과가 채워진 임시주문 + */ + private function makeTempOrder(User $user, int $finalAmount = 50000): TempOrder + { + return TempOrderFactory::new() + ->forUser($user) + ->withCalculationResult([ + 'summary' => [ + 'subtotal' => $finalAmount, + 'total_discount' => 0, + 'product_coupon_discount' => 0, + 'code_discount' => 0, + 'total_shipping' => 0, + 'final_amount' => $finalAmount, + 'taxable_amount' => $finalAmount, + 'tax_free_amount' => 0, + 'points_used' => 0, + ], + 'items' => [], + 'shippings' => [], + 'promotions' => [ + 'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []], + 'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []], + ], + 'validation_errors' => [], + ]) + ->create(); + } + + /** + * OrderCalculationService 를 mock 해 재계산 결과를 고정하고 서비스를 재바인딩합니다. + * + * createFromTempOrder 는 내부에서 재계산을 수행하므로, 저장금액과 동일한 결과를 + * 주입해야 금액 불일치 예외 없이 주문 생성 경로를 끝까지 태울 수 있다. + * + * @param int $finalAmount 재계산 최종금액 (저장 금액과 동일해야 함) + * @return OrderProcessingService mock 이 주입된 서비스 인스턴스 + */ + private function orderServiceWithFixedCalculation(int $finalAmount = 50000): OrderProcessingService + { + $summary = new Summary( + subtotal: $finalAmount, + totalDiscount: 0, + productCouponDiscount: 0, + codeDiscount: 0, + totalShipping: 0, + taxableAmount: $finalAmount, + taxFreeAmount: 0, + pointsUsed: 0, + pointsEarning: 0, + paymentAmount: $finalAmount, + finalAmount: $finalAmount, + ); + + $result = new OrderCalculationResult( + items: [], + summary: $summary, + promotions: new PromotionsSummary, + validationErrors: [], + ); + + $mock = $this->createMock(OrderCalculationService::class); + $mock->method('calculate')->willReturn($result); + $this->app->instance(OrderCalculationService::class, $mock); + + return app(OrderProcessingService::class); + } + + /** + * 지정 훅의 발화 횟수를 잡는 스파이를 부착하고, 라이브 카운터 객체를 돌려줍니다. + * + * Reflection 으로 가드 조건만 확인하면 호출지점 로직이 바뀌어도 회귀를 못 잡는다. + * 실제 주문 생성/취소 흐름에서 훅이 발화하는지를 직접 관측한다. + * + * 배열이 아닌 ArrayObject(참조형)를 돌려준다 — PHP 배열은 값 복사라 + * `return $arr` 이후 클로저가 카운트를 올려도 호출자 쪽 사본은 갱신되지 않는다. + * + * @param string $hook 관측할 액션 훅 이름 + * @return \ArrayObject 발화 횟수를 담는 라이브 카운터 (`$c['count']`) + */ + private function spyOnHook(string $hook): \ArrayObject + { + $counter = new \ArrayObject(['count' => 0]); + HookManager::addAction($hook, function () use ($counter): void { + $counter['count']++; + }); + + return $counter; + } + + // ───────────────────────────────────────────────────────────── + // 6. 확장 ID 저장/조회 — 캐스트가 ValueError 를 던지지 않아야 한다 + // ───────────────────────────────────────────────────────────── + + /** + * @scenario method_kind=extension, capability_declared=declared, capability=needs_pg + * + * @effects extension_id_persisted_as_is + */ + #[Test] + public function extension_payment_method_id_can_be_persisted_and_read_back(): void + { + // 현재 red: 'payment_method' => PaymentMethodEnum::class 캐스트가 내부적으로 + // from() 을 호출 → 확장 ID 는 enum case 가 없어 ValueError → 주문 생성 500. + $payment = $this->createExtensionPaymentOrder(); + + $this->assertSame(self::EXT_METHOD, $payment->fresh()->payment_method); + + $this->assertDatabaseHas('ecommerce_order_payments', [ + 'id' => $payment->id, + 'payment_method' => self::EXT_METHOD, + 'pg_provider' => self::EXT_PG, + ]); + } + + // ───────────────────────────────────────────────────────────── + // 1. 관리자 신규주문 알림이 주문 생성 시점에 발화되지 않아야 한다 + // ───────────────────────────────────────────────────────────── + + /** + * @scenario method_kind=extension, capability_declared=declared, capability=needs_pg + * + * @effects requires_pg_payment_true, temp_order_preserved, admin_new_order_notification_deferred + */ + #[Test] + public function extension_payment_order_does_not_require_pg_is_false(): void + { + // orderRequiresPgPayment() 가 확장 결제수단을 PG 주문으로 인식해야 한다. + // 현재 red: PaymentMethodEnum::tryFrom('nhnkcp_naverpay') === null → false 반환 + // → 주문 생성 즉시 order.after_admin_notify 발화 (관리자 548명 오발송). + $payment = $this->createExtensionPaymentOrder(); + $order = $payment->order->load('payment'); + + $service = app(OrderProcessingService::class); + + $method = new \ReflectionMethod($service, 'orderRequiresPgPayment'); + $method->setAccessible(true); + + $this->assertTrue( + $method->invoke($service, $order), + '확장 결제수단 주문은 PG 결제 주문으로 판정되어야 한다 ' + .'(false 면 주문 생성 즉시 관리자 알림이 오발송된다)' + ); + } + + /** + * 실제 주문 생성 흐름에서 관리자 신규주문 알림 훅이 발화하지 않아야 한다. + * + * 위 Reflection 테스트가 판정 값만 본다면, 이 테스트는 createFromTempOrder 를 + * 끝까지 태워 호출지점(OrderProcessingService:216-217)에서 실제로 + * `order.after_admin_notify` 훅이 발화하는지를 스파이로 관측한다 — 판정과 + * 호출지점 사이 로직이 바뀌어 오발송이 재발하면 이 테스트가 잡는다 (#475). + * + * @scenario method_kind=extension, capability_declared=declared, capability=needs_pg + * + * @effects admin_new_order_notification_deferred, temp_order_preserved, requires_pg_payment_true + */ + #[Test] + public function extension_payment_order_creation_does_not_fire_admin_notification(): void + { + $user = User::factory()->create(); + $tempOrder = $this->makeTempOrder($user, 50000); + $service = $this->orderServiceWithFixedCalculation(50000); + + $notify = $this->spyOnHook('sirsoft-ecommerce.order.after_admin_notify'); + + $order = $service->createFromTempOrder( + $tempOrder, + ['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'buyer@example.com'], + ['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구', 'address_detail' => '1동 2호'], + self::EXT_METHOD, + 50000, + null, + ); + + // 확장 결제수단(PG 결제) 주문은 생성 시점(결제 전 pending)에서 알림을 미룬다. + $this->assertSame( + 0, + $notify['count'], + '확장 결제수단(PG) 주문 생성 시 관리자 신규주문 알림이 발화되면 #475 오알림 재발이다', + ); + // 주문·결제 레코드가 실제로 만들어졌는지(경로가 끝까지 탔는지) 확인. + $this->assertNotNull($order->payment); + $this->assertSame(self::EXT_METHOD, $order->payment->payment_method); + } + + /** + * 대조군: 무통장입금(비-PG) 주문은 생성 시점에 관리자 알림이 발화되어야 한다. + * + * 스파이가 실제로 발화를 잡는지 검증하는 대조군이다 — 위 테스트가 항상 0 을 + * 반환하는 거짓 통과가 아님을 보증한다(비-PG 는 접수 시점 발송이 정상 동작). + * + * @scenario method_kind=builtin, capability_declared=declared, capability=needs_pg + * + * @effects builtin_capability_unchanged + */ + #[Test] + public function builtin_bank_transfer_order_creation_fires_admin_notification(): void + { + $user = User::factory()->create(); + $tempOrder = $this->makeTempOrder($user, 50000); + $service = $this->orderServiceWithFixedCalculation(50000); + + $notify = $this->spyOnHook('sirsoft-ecommerce.order.after_admin_notify'); + + $order = $service->createFromTempOrder( + $tempOrder, + ['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'buyer@example.com'], + ['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구', 'address_detail' => '1동 2호'], + 'dbank', + 50000, + null, + ); + + $this->assertSame( + 1, + $notify['count'], + '무통장입금(비-PG) 주문은 접수 시점에 관리자 알림이 발화되어야 한다 (스파이 동작 확인)', + ); + } + + // ───────────────────────────────────────────────────────────── + // 2. determinePgProvider 가 'none' 이 아닌 실제 PG 를 반환해야 한다 + // (= TempOrder 보존 / requires_pg_payment=true 의 근거) + // ───────────────────────────────────────────────────────────── + + /** + * @scenario method_kind=extension, capability_declared=declared, capability=needs_pg + * + * @effects pg_provider_resolved_not_none, temp_order_preserved + */ + #[Test] + public function extension_payment_method_resolves_to_its_own_pg_provider(): void + { + // 현재 red: 확장수단의 카탈로그 pg_provider 가 null 이면 default_pg_provider 로 + // 폴백하고 그것도 null 이면 'none' → requiresPg=false → TempOrder 즉시 삭제. + $provider = app(OrderProcessingService::class)->determinePgProvider(self::EXT_METHOD); + + $this->assertSame(self::EXT_PG, $provider); + $this->assertNotContains( + $provider, + ['manual', 'internal', 'none'], + "확장 결제수단이 'none' 으로 해석되면 TempOrder 가 즉시 삭제되어 재결제가 불가능해진다" + ); + } + + // ───────────────────────────────────────────────────────────── + // 4. 취소 시 PG 환불 훅이 호출되어야 한다 (안전 이슈) + // ───────────────────────────────────────────────────────────── + + /** + * @scenario method_kind=extension, capability_declared=declared, capability=refund_method + * + * @effects refund_method_is_pg, pg_refund_hook_invoked + */ + #[Test] + public function extension_payment_order_refund_method_is_pg(): void + { + // 현재 red: determineRefundMethod() 의 match 가 enum case 만 다뤄 default → BANK. + // PG 환불이 스킵되어 "취소는 됐는데 카드 취소가 안 되는" 실패가 발생한다. + $payment = $this->createExtensionPaymentOrder(); + $order = $payment->order->load('payment'); + + $service = app(OrderCancellationService::class); + + $method = new \ReflectionMethod($service, 'determineRefundMethod'); + $method->setAccessible(true); + + $this->assertSame( + RefundMethodEnum::PG, + $method->invoke($service, $order), + '확장 결제수단(PG 결제) 의 환불수단은 PG 여야 한다 (BANK 면 카드 취소가 누락된다)' + ); + } + + /** + * @scenario method_kind=extension, capability_declared=declared, capability=refund_method + * + * @effects pg_refund_hook_invoked + */ + #[Test] + public function extension_payment_needs_pg_provider_for_refund_hook(): void + { + // OrderCancellationService:281 의 PG 환불 훅 진입 가드. + // 현재 red: $payment->payment_method?->needsPgProvider() 가 확장 ID 에서 동작 불가. + $payment = $this->createExtensionPaymentOrder(); + + $this->assertTrue( + $payment->needsPgProvider(), + 'PG 환불 훅 진입 가드가 확장 결제수단을 PG 결제로 인식해야 한다' + ); + } + + // ───────────────────────────────────────────────────────────── + // 5. 확장 ID 가 주문 생성 검증(CreateOrderRequest)을 통과해야 한다 + // ───────────────────────────────────────────────────────────── + + /** + * @scenario method_kind=extension, capability_declared=declared, capability=needs_pg + * + * @effects extension_id_passes_validation, pg_payment_handler_present + */ + #[Test] + public function extension_payment_method_id_passes_create_order_validation(): void + { + // 현재 red: Rule::in(PaymentMethodEnum::cases()) 가 확장 ID 를 422 로 막는다. + // 이것이 프론트 인터셉터가 payment_method 를 'card' 로 위장하게 만든 원인. + $rules = (new CreateOrderRequest)->rules(); + + $validator = Validator::make( + ['payment_method' => self::EXT_METHOD], + ['payment_method' => $rules['payment_method']] + ); + + $this->assertFalse( + $validator->errors()->has('payment_method'), + '확장 결제수단 ID 가 검증을 통과해야 한다 ' + .'(422 로 막히면 프론트가 payment_method 를 card 로 위장할 수밖에 없다)' + ); + } + + /** + * @scenario method_kind=extension, capability_declared=undeclared, capability=needs_pg + * + * @effects unregistered_id_still_rejected + */ + #[Test] + public function unregistered_payment_method_id_is_still_rejected(): void + { + // 화이트리스트가 넓어지되 임의 문자열은 여전히 막아야 한다 (검증 우회 방지). + $rules = (new CreateOrderRequest)->rules(); + + $validator = Validator::make( + ['payment_method' => 'totally_unknown_method'], + ['payment_method' => $rules['payment_method']] + ); + + $this->assertTrue( + $validator->errors()->has('payment_method'), + '카탈로그에 등록되지 않은 결제수단은 여전히 거부되어야 한다' + ); + } + + // ───────────────────────────────────────────────────────────── + // 카탈로그 SSoT — 설정 병합이 확장수단의 PG 선언을 보존해야 한다 + // ───────────────────────────────────────────────────────────── + + /** + * @scenario method_kind=extension, capability_declared=declared, capability=pg_locked + * + * @effects saved_null_pg_provider_self_healed, admin_shows_pg_locked_badge + */ + #[Test] + public function catalog_preserves_extension_method_pg_declaration(): void + { + // mergePaymentMethodSettings() 가 확장수단의 defaults(pg_provider/needs_pg 등) 를 + // 병합 결과에 반영해야 한다. 현재 red: 신규 키 3종이 merge 대상이 아니다. + $config = app(EcommerceSettingsService::class)->getPaymentMethodConfig(self::EXT_METHOD); + + $this->assertNotNull($config, '확장 결제수단이 카탈로그에 존재해야 한다'); + $this->assertSame(self::EXT_PG, $config['pg_provider'] ?? null); + $this->assertTrue((bool) ($config['needs_pg'] ?? false)); + $this->assertTrue((bool) ($config['pg_locked'] ?? false)); + $this->assertSame('pg', $config['refund_method'] ?? null); + } + + // ───────────────────────────────────────────────────────────── + // 하위호환 — builtin 8종의 능력 판정이 변하지 않아야 한다 + // ───────────────────────────────────────────────────────────── + + /** + * @scenario method_kind=builtin, capability_declared=declared, capability=needs_pg + * + * @effects builtin_capability_unchanged + */ + #[Test] + public function builtin_card_payment_still_requires_pg(): void + { + $payment = $this->createExtensionPaymentOrder([ + 'payment_method' => 'card', + 'pg_provider' => 'inicis', + ]); + + $this->assertTrue($payment->needsPgProvider()); + $this->assertTrue($payment->isCardPayment()); + $this->assertFalse($payment->isVirtualAccount()); + } + + /** + * @scenario method_kind=builtin, capability_declared=declared, capability=needs_pg + * + * @effects builtin_capability_unchanged + */ + #[Test] + public function builtin_dbank_payment_still_does_not_require_pg(): void + { + // 무통장입금은 수동 입금확인 — PG 불필요. 캐스트 제거 후에도 유지되어야 한다. + // (ConfirmDepositRequest 의 입금확인 가드가 여기에 의존한다) + $payment = $this->createExtensionPaymentOrder([ + 'payment_method' => 'dbank', + 'pg_provider' => '', + ]); + + $this->assertFalse($payment->needsPgProvider()); + $this->assertTrue($payment->isBankTransfer()); + $this->assertFalse($payment->isCardPayment()); + } + + /** + * @scenario method_kind=builtin, capability_declared=declared, capability=needs_pg + * + * @effects builtin_capability_unchanged + */ + #[Test] + public function builtin_vbank_payment_is_still_recognized_as_virtual_account(): void + { + // PG 플러그인의 가상계좌 분기(입금통보/환불)가 이 판정에 의존한다. + // 캐스트 제거로 이것이 조용히 false 가 되면 가상계좌 환불이 통째로 막힌다. + $payment = $this->createExtensionPaymentOrder([ + 'payment_method' => 'vbank', + 'pg_provider' => 'nicepay', + ]); + + $this->assertTrue($payment->isVirtualAccount()); + $this->assertTrue($payment->needsPgProvider()); + $this->assertFalse($payment->isCardPayment()); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/playwright.config.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/playwright.config.ts index 4eb798fe..29e983c9 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/playwright.config.ts +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/playwright.config.ts @@ -25,6 +25,18 @@ import { fileURLToPath } from 'node:url'; // import.meta.url 로 재구성한다. const __dirname = dirname(fileURLToPath(import.meta.url)); +/** + * 코어 루트 (artisan / .env / Playwright 산출물의 기준 경로). + * + * 확장 config 는 확장 디렉토리에서 실행되지만, 산출물을 그 안에 쓰면 Windows 에서 + * `{module|template}:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다. + * 산출물은 코어 루트 아래로 모아 update 경로와 분리한다 (.gitignore 가 이미 덮는 위치). + */ +const CORE_ROOT = process.env.G7_ROOT || resolve(__dirname, '../../../../../'); + +/** 확장별 산출물 격리 — 확장끼리 리포트를 덮어쓰지 않도록 slug 로 네임스페이스. */ +const ARTIFACT_SLUG = 'modules/sirsoft-ecommerce'; + function readEnvFile(filePath: string, key: string): string | null { if (!existsSync(filePath)) return null; const content = readFileSync(filePath, { encoding: 'utf-8' }); @@ -42,9 +54,7 @@ function resolveBaseUrl(): string { if (process.env.PLAYWRIGHT_BASE_URL) { return process.env.PLAYWRIGHT_BASE_URL; } - // 모듈 디렉토리 기준 6단계 상위 = 코어 루트 (.env 위치) - const coreRoot = process.env.G7_ROOT || resolve(__dirname, '../../../../../'); - const appUrl = readEnvFile(resolve(coreRoot, '.env'), 'APP_URL'); + const appUrl = readEnvFile(resolve(CORE_ROOT, '.env'), 'APP_URL'); if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) { return appUrl; } @@ -55,12 +65,13 @@ function resolveBaseUrl(): string { export default defineConfig({ testDir: './specs', + outputDir: resolve(CORE_ROOT, 'test-results', ARTIFACT_SLUG), fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: [ - ['html', { outputFolder: 'playwright-report', open: 'never' }], + ['html', { outputFolder: resolve(CORE_ROOT, 'playwright-report', ARTIFACT_SLUG), open: 'never' }], ['list'], ], use: { diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/payment-method-pg-locked.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/payment-method-pg-locked.spec.ts new file mode 100644 index 00000000..3f307250 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/payment-method-pg-locked.spec.ts @@ -0,0 +1,208 @@ +/** + * 주문설정 결제수단 — PG 고정(간편결제) 표시 회귀 (#475). + * + * 배경: PG 플러그인이 등록하는 간편결제(네이버페이 등)는 특정 PG 전용 수단인데, + * 과거에는 이를 코어의 "PG 불필요" 목록(`['point','deposit','free','dbank']`)에 정규식으로 + * 끼워 넣어 PG 선택 셀렉트를 숨겼다. 화면에는 "PG 불필요" 로 보였고, 서버도 같은 오해를 + * 공유해 간편결제 주문을 PG 결제가 아닌 주문으로 처리했다 — 그 결과 결제 실패 주문에 + * 관리자 알림이 발송되고 임시주문이 삭제돼 재결제가 막혔다. + * + * 이제 결제수단 카탈로그가 `pg_locked` / `needs_pg` 를 내려주고 레이아웃이 그 값으로 직접 + * 3분기한다: PG 고정 배지 / PG 선택 셀렉트 / "PG 불필요". + * + * 측정 규율 (이 spec 을 고칠 사람에게): + * - **결제수단 ID·PG명·라벨 문자열을 하드코딩하지 않는다.** 설치된 PG 플러그인 구성은 + * 환경마다 다르다. 검증 대상 수단은 앱 상태의 카탈로그에서 `pg_locked` 플래그로 **런타임에 + * 선별**하고, 기대 PG 표시명도 `available_pg_providers` 에서 도출한다. + * - PG 고정 수단이 하나도 없는 환경(간편결제 플러그인 미설치)에서는 판별이 불가능하므로 + * skip 한다 — 거짓 통과를 만들지 않는다. + * - "셀렉트가 없다" 만으로는 회귀를 못 잡는다. 과거 결함도 셀렉트를 숨겼기 때문이다. + * **PG 고정 배지가 PG 이름과 함께 뜨는 것**까지 확인해야 "불필요" 와 "고정" 이 구분된다. + * - PG 선택 UI 는 네이티브 ``).join("");await new Promise((d,l)=>{document.getElementById("kcp-sdk-iframe")?.remove(),document.getElementById("kcp-dim-overlay")?.remove();const u=document.createElement("div");u.id="kcp-dim-overlay",u.style.cssText="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:99998;",document.body.appendChild(u);const p=document.createElement("iframe");p.id="kcp-sdk-iframe",p.setAttribute("allowtransparency","true"),p.style.cssText="position:fixed;top:0;left:0;width:100%;height:100%;border:0;z-index:99999;background:transparent;",document.body.appendChild(p);const f=p.contentWindow;let V=!1,y,k;const cn=()=>{k?.(),k=void 0,y!==void 0&&(clearTimeout(y),y=void 0),p.remove(),u.remove()},N=m=>{V||(V=!0,cn(),l(m))},dn=()=>{V=!0,k?.(),k=void 0,y!==void 0&&(clearTimeout(y),y=void 0)};k=We(p,()=>{N(new R("kcp-payment-frame-closed"))}),f.m_Completepayment=function(m){const A=m.elements.res_cd?.value??"",X=m.elements.res_msg?.value??"";if(A==="0000"){dn();const h=f.document.forms.namedItem("order_info"),C=h&&h!==m?h:m;if(h&&h!==m)for(let Z=0;Z{N(m)};const Q=p.contentDocument||f.document;Q.open(),Q.write(` +'; + const { container } = render(); + + // svg 자체는 렌더되지만 위험 요소는 제거되어야 한다. + expect(container.querySelector('svg')).not.toBeNull(); + expect(container.querySelector('script')).toBeNull(); + expect(container.querySelector('foreignObject')).toBeNull(); + expect(container.querySelector('rect')?.getAttribute('onload')).toBeNull(); + // innerHTML 에도 script 문자열이 남지 않아야 한다. + expect(container.innerHTML).not.toContain(' { + const { container } = render( + ', text: 'N', class: 'bg-green-500' }} /> + ); + expect(container.querySelector('svg')).not.toBeNull(); + // 배지 span 은 렌더되지 않음 + expect(container.querySelector('span')).toBeNull(); + }); + }); + + describe('빈 값 처리', () => { + it('brandMark 가 null 이면 아무것도 렌더하지 않는다 (아이콘 폴백 위임)', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('brandMark 가 빈 객체이면 아무것도 렌더하지 않는다', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + }); +}); diff --git a/templates/_bundled/sirsoft-basic/src/components/composite/index.ts b/templates/_bundled/sirsoft-basic/src/components/composite/index.ts index 36be5f5f..8c31bb39 100644 --- a/templates/_bundled/sirsoft-basic/src/components/composite/index.ts +++ b/templates/_bundled/sirsoft-basic/src/components/composite/index.ts @@ -15,6 +15,8 @@ export type { NotificationCenterProps, NotificationItem } from './NotificationCe // 상품 관련 컴포넌트 export { default as ProductCard } from './ProductCard'; +export { BrandMark } from './BrandMark'; +export type { BrandMarkProps, BrandMarkData } from './BrandMark'; export { default as ImageGallery } from './ImageGallery'; export { default as ProductImageViewer } from './ProductImageViewer'; export { default as QuantitySelector } from './QuantitySelector'; @@ -75,6 +77,8 @@ export const compositeComponents = { // 상품 ProductCard: () => import('./ProductCard'), + // 결제 브랜드 마크(배지/SVG) — 카탈로그 _cached_brand_mark 를 데이터 기반 렌더 + BrandMark: () => import('./BrandMark'), ImageGallery: () => import('./ImageGallery'), ProductImageViewer: () => import('./ProductImageViewer'), QuantitySelector: () => import('./QuantitySelector'), diff --git a/templates/_bundled/sirsoft-basic/src/handlers/__tests__/deviceCorrection.test.ts b/templates/_bundled/sirsoft-basic/src/handlers/__tests__/deviceCorrection.test.ts new file mode 100644 index 00000000..a51d99cd --- /dev/null +++ b/templates/_bundled/sirsoft-basic/src/handlers/__tests__/deviceCorrection.test.ts @@ -0,0 +1,93 @@ +/** + * deviceCorrection 테스트 + * + * @description 서버 UA 판정(appConfig.isIos)이 놓친 iPadOS(데스크탑 UA)를 클라 신호로 보정하는 로직 검증. + * + * @effects client_corrects_ipados_desktop_ua, applepay_shown_on_ios + */ +// @scenario mark_form=badge, requires_ios=true, device=ipados_desktop_ua +// @scenario mark_form=svg, requires_ios=false, device=ipados_desktop_ua +// @scenario mark_form=none, requires_ios=false, device=ipados_desktop_ua + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { isIosMobileDeviceClient, correctIosDeviceState } from '../deviceCorrection'; + +const originalNavigator = globalThis.navigator; + +function mockNavigator(overrides: Partial & Record): void { + Object.defineProperty(globalThis, 'navigator', { + value: { userAgent: '', platform: '', maxTouchPoints: 0, ...overrides }, + configurable: true, + writable: true, + }); +} + +afterEach(() => { + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + configurable: true, + writable: true, + }); + delete (window as any).G7Core; +}); + +describe('isIosMobileDeviceClient', () => { + it('iPhone UA 를 iOS 로 판정한다', () => { + mockNavigator({ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' }); + expect(isIosMobileDeviceClient()).toBe(true); + }); + + it('데스크탑 UA + maxTouchPoints>1 (iPadOS) 를 iOS 로 판정한다', () => { + mockNavigator({ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17', maxTouchPoints: 5 }); + expect(isIosMobileDeviceClient()).toBe(true); + }); + + it('데스크탑 Mac (터치 없음) 은 iOS 가 아니다', () => { + mockNavigator({ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17', maxTouchPoints: 0 }); + expect(isIosMobileDeviceClient()).toBe(false); + }); + + it('Android 는 iOS 가 아니다', () => { + mockNavigator({ userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8)', maxTouchPoints: 5 }); + expect(isIosMobileDeviceClient()).toBe(false); + }); +}); + +describe('correctIosDeviceState', () => { + beforeEach(() => { + const state: Record = { appConfig: { isIos: false } }; + (window as any).G7Core = { + state: { + get: vi.fn(() => state), + set: vi.fn((updates: Record) => Object.assign(state, updates)), + }, + }; + }); + + it('클라가 iOS 로 판정하고 서버가 false 였으면 appConfig.isIos 를 true 로 보정한다', () => { + mockNavigator({ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17', maxTouchPoints: 5 }); + + correctIosDeviceState(); + + const setMock = (window as any).G7Core.state.set; + expect(setMock).toHaveBeenCalledWith({ appConfig: { isIos: true } }); + }); + + it('클라가 iOS 가 아니면 보정하지 않는다', () => { + mockNavigator({ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120', maxTouchPoints: 0 }); + + correctIosDeviceState(); + + expect((window as any).G7Core.state.set).not.toHaveBeenCalled(); + }); + + it('서버가 이미 iOS 로 판정했으면 재보정하지 않는다 (다운그레이드 방지)', () => { + const state: Record = { appConfig: { isIos: true } }; + (window as any).G7Core.state.get = vi.fn(() => state); + mockNavigator({ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' }); + + correctIosDeviceState(); + + expect((window as any).G7Core.state.set).not.toHaveBeenCalled(); + }); +}); diff --git a/templates/_bundled/sirsoft-basic/src/handlers/deviceCorrection.ts b/templates/_bundled/sirsoft-basic/src/handlers/deviceCorrection.ts new file mode 100644 index 00000000..3a9b0470 --- /dev/null +++ b/templates/_bundled/sirsoft-basic/src/handlers/deviceCorrection.ts @@ -0,0 +1,72 @@ +/** + * 서버측 기기 감지값(appConfig.isIos)의 클라이언트 보정. + * + * 서버는 User-Agent 만으로 iOS 를 판정하므로, 데스크탑 UA 를 보내는 iPadOS 를 + * 놓칠 수 있다(iPad 에서 애플페이 버튼 미표시). 부트스트랩 시점에 클라이언트 + * 신호(maxTouchPoints/userAgentData.platform)로 iOS 를 재판정해, 서버가 false + * 로 내려보낸 경우에만 전역 상태 `appConfig.isIos` 를 true 로 보정한다. + * + * 서버가 이미 true 로 판정했으면 그대로 두어(다운그레이드 방지) fail-safe 를 유지한다. + */ + +const logger = ((window as any).G7Core?.createLogger?.('Template:sirsoft-basic:device')) + ?? { + log: (...args: unknown[]) => console.log('[Template:sirsoft-basic:device]', ...args), + warn: (...args: unknown[]) => console.warn('[Template:sirsoft-basic:device]', ...args), + }; + +/** + * 클라이언트 신호로 iOS 기기 여부를 판정합니다. + * + * 과거 checkoutEasyPayInjector 의 isIosMobileDevice 판정과 동일한 규칙: + * - UA/platform 에 iphone|ipad|ipod(+ios) 매칭 + * - 데스크탑 UA 를 보내는 iPadOS: macintosh UA + maxTouchPoints > 1 + * + * @returns iOS 기기 여부 + */ +export function isIosMobileDeviceClient(): boolean { + if (typeof navigator === 'undefined') return false; + + const nav = navigator as Navigator & { + userAgentData?: { platform?: string }; + maxTouchPoints?: number; + }; + const ua = (nav.userAgent || '').toLowerCase(); + const platform = ((nav.userAgentData?.platform ?? nav.platform) || '').toLowerCase(); + const touchPoints = nav.maxTouchPoints ?? 0; + + if (/iphone|ipad|ipod/.test(ua) || /iphone|ipad|ipod|ios/.test(platform)) { + return true; + } + + return /macintosh|mac os x/.test(ua) && touchPoints > 1; +} + +/** + * 서버가 내려준 `appConfig.isIos` 를 클라이언트 판정으로 보정합니다. + * + * 서버 값이 false 이고 클라이언트가 iOS 로 판정한 경우에만 전역 상태를 갱신한다. + * (iPadOS 데스크탑 UA 케이스 복구) + */ +export function correctIosDeviceState(): void { + const G7Core = (window as any).G7Core; + if (!G7Core?.state?.get || !G7Core?.state?.set) { + logger.warn('G7Core.state not available; skip iOS device correction'); + return; + } + + if (!isIosMobileDeviceClient()) { + return; + } + + const globalState = G7Core.state.get() || {}; + const appConfig = globalState.appConfig || {}; + + // 서버가 이미 iOS 로 판정했으면 보정 불필요. + if (appConfig.isIos === true) { + return; + } + + G7Core.state.set({ appConfig: { ...appConfig, isIos: true } }); + logger.log('appConfig.isIos corrected to true by client signals (iPadOS desktop-UA)'); +} diff --git a/templates/_bundled/sirsoft-basic/src/index.ts b/templates/_bundled/sirsoft-basic/src/index.ts index 368e7709..f8ab9411 100644 --- a/templates/_bundled/sirsoft-basic/src/index.ts +++ b/templates/_bundled/sirsoft-basic/src/index.ts @@ -111,6 +111,9 @@ import { handlerMap } from './handlers'; // IDV Modal Launcher (engine-v1.46.0+) import { registerSirsoftBasicIdentityLauncher } from './handlers/identityLauncher'; +// 서버 기기 감지값(appConfig.isIos) 클라이언트 보정 (iPadOS 데스크탑 UA 케이스) +import { correctIosDeviceState } from './handlers/deviceCorrection'; + // 레이아웃 편집기 커스텀 위젯(icon-picker 등) 등록 import { registerSirsoftBasicEditorWidgets } from './layout-editor/registerEditorWidgets'; @@ -151,6 +154,10 @@ export function initTemplate(): void { // IDV Modal Launcher 등록 (window.G7Core.identity.setLauncher 사용) registerSirsoftBasicIdentityLauncher(); + + // 서버 UA 판정이 놓친 iPadOS(데스크탑 UA) 를 클라 신호로 보정 → appConfig.isIos. + // 애플페이 iOS 게이팅(체크아웃 레이아웃 iteration 필터)이 이 값을 참조한다. + correctIosDeviceState(); } else { retryCount++; if (retryCount <= maxRetries) { diff --git a/templates/_bundled/sirsoft-basic/template.json b/templates/_bundled/sirsoft-basic/template.json index 2c26b961..54e1b21f 100644 --- a/templates/_bundled/sirsoft-basic/template.json +++ b/templates/_bundled/sirsoft-basic/template.json @@ -5,7 +5,7 @@ "ko": "Basic", "en": "Basic" }, - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "description": { "ko": "그누보드7 기본 사용자 템플릿", diff --git a/templates/_bundled/sirsoft-basic/tests/Playwright/playwright.config.ts b/templates/_bundled/sirsoft-basic/tests/Playwright/playwright.config.ts index f21a49ce..f48ec075 100644 --- a/templates/_bundled/sirsoft-basic/tests/Playwright/playwright.config.ts +++ b/templates/_bundled/sirsoft-basic/tests/Playwright/playwright.config.ts @@ -23,6 +23,18 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); +/** + * 코어 루트 (artisan / .env / Playwright 산출물의 기준 경로). + * + * 확장 config 는 확장 디렉토리에서 실행되지만, 산출물을 그 안에 쓰면 Windows 에서 + * `{module|template}:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다. + * 산출물은 코어 루트 아래로 모아 update 경로와 분리한다 (.gitignore 가 이미 덮는 위치). + */ +const CORE_ROOT = process.env.G7_ROOT || resolve(__dirname, '../../../../../'); + +/** 확장별 산출물 격리 — 확장끼리 리포트를 덮어쓰지 않도록 slug 로 네임스페이스. */ +const ARTIFACT_SLUG = 'templates/sirsoft-basic'; + /** * .env 파일에서 키 하나를 읽습니다. * @@ -53,9 +65,7 @@ function resolveBaseUrl(): string { if (process.env.PLAYWRIGHT_BASE_URL) { return process.env.PLAYWRIGHT_BASE_URL; } - // 템플릿 디렉토리 기준 5단계 상위 = 코어 루트 (.env 위치) - const coreRoot = process.env.G7_ROOT || resolve(__dirname, '../../../../../'); - const appUrl = readEnvFile(resolve(coreRoot, '.env'), 'APP_URL'); + const appUrl = readEnvFile(resolve(CORE_ROOT, '.env'), 'APP_URL'); if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) { return appUrl; } @@ -66,12 +76,13 @@ function resolveBaseUrl(): string { export default defineConfig({ testDir: './specs', + outputDir: resolve(CORE_ROOT, 'test-results', ARTIFACT_SLUG), fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: [ - ['html', { outputFolder: 'playwright-report', open: 'never' }], + ['html', { outputFolder: resolve(CORE_ROOT, 'playwright-report', ARTIFACT_SLUG), open: 'never' }], ['list'], ], use: { diff --git a/tests/Feature/Extension/ExtensionMiddlewareGateIntegrationTest.php b/tests/Feature/Extension/ExtensionMiddlewareGateIntegrationTest.php new file mode 100644 index 00000000..a9fda50d --- /dev/null +++ b/tests/Feature/Extension/ExtensionMiddlewareGateIntegrationTest.php @@ -0,0 +1,83 @@ +shouldReceive('resolveForRoute') + ->andReturnUsing(function (string $routeName, string $path, string $group, string $timing): array { + IntegrationSpyMiddleware::$seen[] = "{$group},{$timing}"; + + return ($group === 'web' && $timing === 'after_core') + ? [IntegrationSpyMiddleware::class] + : []; + }); + $this->app->instance(ExtensionMiddlewareRegistryInterface::class, $registry); + + // web 그룹에 게이트를 파라미터 문자열로 등록한 라우트 (bootstrap 등록 형태 복제). + Route::middleware([ + ExtensionMiddlewareGate::class.':web,before_core', + ExtensionMiddlewareGate::class.':web,after_core', + ])->get('/__gate_probe', fn () => new Response('ok'))->name('gate.probe'); + + $response = $this->get('/__gate_probe'); + + $response->assertOk(); + $response->assertSee('ok'); + + // 게이트가 group/timing 파라미터를 정확히 파싱해 registry 를 조회했는지 확인. + $this->assertContains('web,before_core', IntegrationSpyMiddleware::$seen); + $this->assertContains('web,after_core', IntegrationSpyMiddleware::$seen); + + // after_core 조회에서 반환된 매칭 미들웨어가 실제로 실행됐는지 확인. + $this->assertTrue(IntegrationSpyMiddleware::$ran, '파라미터 문자열 파싱 후 매칭 미들웨어가 실행되어야 합니다.'); + } +} + +/** 실행/조회 기록 픽스처 미들웨어. */ +class IntegrationSpyMiddleware +{ + public static bool $ran = false; + + /** @var array */ + public static array $seen = []; + + public function handle($request, \Closure $next) + { + self::$ran = true; + + return $next($request); + } +} diff --git a/tests/Feature/Extension/ExtensionMiddlewareRegistryTest.php b/tests/Feature/Extension/ExtensionMiddlewareRegistryTest.php new file mode 100644 index 00000000..c6c34cea --- /dev/null +++ b/tests/Feature/Extension/ExtensionMiddlewareRegistryTest.php @@ -0,0 +1,571 @@ +> $moduleMiddleware 모듈 getMiddleware() 반환값 + * @param array> $pluginMiddleware 플러그인 getMiddleware() 반환값 + * @param string $moduleId 모듈 식별자 + * @param string $pluginId 플러그인 식별자 + */ + private function makeRegistry( + array $moduleMiddleware = [], + array $pluginMiddleware = [], + string $moduleId = 'sirsoft-shop', + string $pluginId = 'sirsoft-pay', + ): ExtensionMiddlewareRegistry { + // getMiddleware() 는 interface 가 아닌 Abstract 에만 정의되므로(IDV 게터와 동일 컨벤션), + // registry 는 method_exists 로 게이트한다. interface mock 은 이 메서드를 갖지 않아 + // method_exists=false 로 스킵되므로, getMiddleware/getIdentifier 를 실제로 갖는 + // 경량 픽스처 확장을 사용한다 (실 확장은 Abstract 상속으로 항상 보유). + $activeModules = []; + if ($moduleMiddleware !== []) { + $activeModules[] = new FixtureExtension($moduleId, $moduleMiddleware); + } + + $activePlugins = []; + if ($pluginMiddleware !== []) { + $activePlugins[] = new FixtureExtension($pluginId, $pluginMiddleware); + } + + $moduleManager = Mockery::mock(ModuleManager::class); + $moduleManager->shouldReceive('getActiveModules')->andReturn($activeModules); + $pluginManager = Mockery::mock(PluginManager::class); + $pluginManager->shouldReceive('getActivePlugins')->andReturn($activePlugins); + + $this->app->instance(ModuleManager::class, $moduleManager); + $this->app->instance(PluginManager::class, $pluginManager); + + ExtensionMiddlewareRegistry::flush(); + + return $this->app->make(ExtensionMiddlewareRegistry::class); + } + + // === self 치환 (모듈·플러그인·web·api prefix) === + + /** + * @effects self_target_expands_to_own_extension_prefix_per_group, groups_array_expands_to_per_group_entries + */ + public function test_self_target_expands_to_own_module_prefix_per_group(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['web', 'api'], + 'targets' => ['self'], + ]]); + + // web 그룹: web.modules.{id}.* 만 매칭 + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('web.modules.sirsoft-shop.orders.index', 'x', 'web', 'after_core'), + ); + // api 그룹: api.modules.{id}.* 만 매칭 + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.modules.sirsoft-shop.orders.index', 'x', 'api', 'after_core'), + ); + // web self 는 api 라우트를 잡지 않음 + $this->assertSame( + [], + $registry->resolveForRoute('api.modules.sirsoft-shop.orders.index', 'x', 'web', 'after_core'), + ); + // 타 확장 라우트 miss + $this->assertSame( + [], + $registry->resolveForRoute('web.modules.other.orders.index', 'x', 'web', 'after_core'), + ); + } + + /** + * @effects self_target_expands_to_own_extension_prefix_per_group + */ + public function test_self_target_expands_to_own_plugin_prefix(): void + { + $registry = $this->makeRegistry(pluginMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['web'], + 'targets' => ['self'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('web.plugins.sirsoft-pay.payment.callback', 'x', 'web', 'after_core'), + ); + $this->assertSame( + [], + $registry->resolveForRoute('web.modules.sirsoft-pay.payment.callback', 'x', 'web', 'after_core'), + ); + } + + // === all_extensions / core (positive/negative 판별) === + + /** + * @effects all_extensions_matches_extension_routes_only + */ + public function test_all_extensions_matches_extension_routes_only(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => ['all_extensions'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.modules.sirsoft-shop.orders.index', 'x', 'api', 'after_core'), + ); + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.plugins.other.foo', 'x', 'api', 'after_core'), + ); + // 코어 라우트 miss + $this->assertSame( + [], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + } + + /** + * @effects core_target_matches_core_routes_only_negative_predicate + */ + public function test_core_target_matches_core_routes_only(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => ['core'], + ]]); + + // 코어 라우트 hit (negative 판별) + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + // 확장 라우트 miss + $this->assertSame( + [], + $registry->resolveForRoute('api.modules.sirsoft-shop.orders.index', 'x', 'api', 'after_core'), + ); + // 무명 라우트는 core 로 간주하지 않음 (URI 로 잡을 것) + $this->assertSame( + [], + $registry->resolveForRoute('', 'admin/foo', 'api', 'after_core'), + ); + } + + // === everything / * (무조건, 무명 포함) === + + /** + * @effects everything_and_star_match_all_routes_including_unnamed + */ + public function test_everything_matches_all_routes_including_unnamed(): void + { + $registry = $this->makeRegistry(pluginMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['web', 'api'], + 'targets' => ['everything'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('web.modules.other.foo', 'x', 'web', 'after_core'), + ); + // 무명 라우트도 hit + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('', '/', 'web', 'after_core'), + ); + } + + /** + * @effects everything_and_star_match_all_routes_including_unnamed + */ + public function test_star_alias_matches_everything(): void + { + $registry = $this->makeRegistry(pluginMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['web'], + 'targets' => ['*'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('', 'anything', 'web', 'after_core'), + ); + } + + // === module:{id} / plugin:{id} === + + /** + * @effects module_id_and_plugin_id_target_scope_to_that_extension + */ + public function test_module_id_target_matches_that_module_only(): void + { + $registry = $this->makeRegistry(pluginMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => ['module:sirsoft-shop'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.modules.sirsoft-shop.orders.index', 'x', 'api', 'after_core'), + ); + $this->assertSame( + [], + $registry->resolveForRoute('api.modules.sirsoft-board.posts.index', 'x', 'api', 'after_core'), + ); + } + + /** + * @effects module_id_and_plugin_id_target_scope_to_that_extension + */ + public function test_plugin_id_target_matches_that_plugin_only(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['web'], + 'targets' => ['plugin:sirsoft-pay'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('web.plugins.sirsoft-pay.payment.callback', 'x', 'web', 'after_core'), + ); + $this->assertSame( + [], + $registry->resolveForRoute('web.plugins.other.foo', 'x', 'web', 'after_core'), + ); + } + + // === 원시 glob / brace === + + /** + * @effects raw_glob_target_matches_route_name + */ + public function test_raw_glob_target_matches_route_name(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => ['api.modules.sirsoft-shop.cart.*'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.modules.sirsoft-shop.cart.add', 'x', 'api', 'after_core'), + ); + $this->assertSame( + [], + $registry->resolveForRoute('api.modules.sirsoft-shop.orders.index', 'x', 'api', 'after_core'), + ); + } + + /** + * @effects brace_target_expands_to_or_of_names + */ + public function test_brace_target_expands_to_or_of_names(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => ['api.modules.sirsoft-shop.{cart,orders}.index'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.modules.sirsoft-shop.cart.index', 'x', 'api', 'after_core'), + ); + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.modules.sirsoft-shop.orders.index', 'x', 'api', 'after_core'), + ); + $this->assertSame( + [], + $registry->resolveForRoute('api.modules.sirsoft-shop.checkout.index', 'x', 'api', 'after_core'), + ); + } + + // === URI 패턴 (무명 라우트) === + + /** + * @effects uri_pattern_target_matches_path_not_route_name + */ + public function test_uri_pattern_target_matches_path_not_route_name(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['web'], + 'targets' => ['/'], + ]]); + + // 무명 라우트 (routeName='') + path '/' → URI 매칭 + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('', '/', 'web', 'after_core'), + ); + } + + /** + * @effects uri_pattern_target_matches_path_not_route_name + */ + public function test_uri_glob_pattern_matches_path_prefix(): void + { + $registry = $this->makeRegistry(pluginMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['web'], + 'targets' => ['/admin/*'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('', 'admin/dashboard', 'web', 'after_core'), + ); + $this->assertSame( + [], + $registry->resolveForRoute('', 'user/profile', 'web', 'after_core'), + ); + } + + // === groups 검증 (펼침 / 거부) === + + /** + * @effects empty_or_disallowed_groups_declaration_is_rejected + */ + public function test_empty_groups_declaration_is_rejected(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => [], + 'targets' => ['everything'], + ]]); + + $this->assertSame( + [], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + } + + /** + * @effects empty_or_disallowed_groups_declaration_is_rejected + */ + public function test_disallowed_group_is_rejected(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['console'], + 'targets' => ['everything'], + ]]); + + $this->assertSame( + [], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + } + + // === targets 필수 / class 검증 === + + /** + * @effects missing_targets_declaration_is_rejected + */ + public function test_missing_targets_declaration_is_rejected(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => [], + ]]); + + $this->assertSame( + [], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + } + + /** + * @effects nonexistent_middleware_class_is_skipped + */ + public function test_nonexistent_class_is_skipped(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => 'App\\NonExistent\\Middleware\\Nope', + 'groups' => ['api'], + 'targets' => ['everything'], + ]]); + + $this->assertSame( + [], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + } + + // === timing 분리 === + + /** + * @effects before_core_and_after_core_timing_are_separated + */ + public function test_timing_before_and_after_are_separated(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [ + [ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'timing' => 'before_core', + 'targets' => ['everything'], + ], + [ + 'class' => TestMiddlewareB::class, + 'groups' => ['api'], + 'timing' => 'after_core', + 'targets' => ['everything'], + ], + ]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'before_core'), + ); + $this->assertSame( + [TestMiddlewareB::class], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + } + + /** + * @effects default_timing_is_after_core + */ + public function test_default_timing_is_after_core(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => ['everything'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + $this->assertSame( + [], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'before_core'), + ); + } + + // === 캐시 무효화 === + + /** + * @effects flush_rebuilds_index, inactive_extension_is_excluded_from_index + */ + public function test_flush_rebuilds_index(): void + { + $registry = $this->makeRegistry(moduleMiddleware: [[ + 'class' => TestMiddlewareA::class, + 'groups' => ['api'], + 'targets' => ['everything'], + ]]); + + $this->assertSame( + [TestMiddlewareA::class], + $registry->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + + // 활성 확장 목록을 비우고 flush → 재빌드 시 빈 인덱스 + $emptyModuleManager = Mockery::mock(ModuleManager::class); + $emptyModuleManager->shouldReceive('getActiveModules')->andReturn([]); + $emptyPluginManager = Mockery::mock(PluginManager::class); + $emptyPluginManager->shouldReceive('getActivePlugins')->andReturn([]); + $this->app->instance(ModuleManager::class, $emptyModuleManager); + $this->app->instance(PluginManager::class, $emptyPluginManager); + + ExtensionMiddlewareRegistry::flush(); + $fresh = $this->app->make(ExtensionMiddlewareRegistry::class); + + $this->assertSame( + [], + $fresh->resolveForRoute('api.users.index', 'x', 'api', 'after_core'), + ); + } +} + +/** + * 경량 픽스처 확장 — getIdentifier() + getMiddleware() 만 제공. + * + * 실 확장은 AbstractModule/AbstractPlugin 상속으로 이 두 메서드를 보유하며, + * registry 는 method_exists 로 getMiddleware 존재를 확인 후 호출한다. + */ +class FixtureExtension +{ + /** + * @param string $identifier 확장 식별자 + * @param array> $middleware getMiddleware() 반환값 + */ + public function __construct( + private string $identifier, + private array $middleware, + ) {} + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return array> + */ + public function getMiddleware(): array + { + return $this->middleware; + } +} + +/** 테스트 픽스처 미들웨어. */ +class TestMiddlewareA +{ + public function handle($request, \Closure $next) + { + return $next($request); + } +} + +/** 테스트 픽스처 미들웨어. */ +class TestMiddlewareB +{ + public function handle($request, \Closure $next) + { + return $next($request); + } +} diff --git a/tests/Feature/Extension/ExtensionTestIsolationGdprAllowedTest.php b/tests/Feature/Extension/ExtensionTestIsolationGdprAllowedTest.php index d7afed72..f3cd27f9 100644 --- a/tests/Feature/Extension/ExtensionTestIsolationGdprAllowedTest.php +++ b/tests/Feature/Extension/ExtensionTestIsolationGdprAllowedTest.php @@ -2,7 +2,13 @@ namespace Tests\Feature\Extension; +use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface; +use App\Enums\ExtensionStatus; +use App\Extension\ExtensionMiddlewareRegistry; +use App\Extension\PluginManager; use App\Extension\Testing\ExtensionTestAllowlist; +use App\Http\Middleware\ExtensionMiddlewareGate; +use App\Models\Plugin; use Illuminate\Contracts\Http\Kernel as HttpKernelContract; use Illuminate\Foundation\Http\Kernel as HttpKernel; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -13,8 +19,9 @@ use Tests\TestCase; /** * 테스트 환경 확장 격리 회귀 테스트 — allowlist 포함 시나리오 * - * requiredExtensions 에 GDPR 플러그인을 명시하면 해당 플러그인의 - * ServiceProvider 와 미들웨어가 정상적으로 로드되어야 한다. + * requiredExtensions 에 GDPR 플러그인을 명시하면 해당 플러그인의 ServiceProvider 가 로드되고, + * 코어 self-gate 게이트가 GDPR 이 getMiddleware() 로 선언한 CookieConsentMiddleware 를 실행한다. + * (이관 전: SP boot 이 web/api 그룹에 직접 prepend. 이관 후: getMiddleware() 선언 + 게이트 실행) */ class ExtensionTestIsolationGdprAllowedTest extends TestCase { @@ -45,17 +52,66 @@ class ExtensionTestIsolationGdprAllowedTest extends TestCase /** * @effects allowlisted_plugin_middleware_is_registered_in_web_group */ - public function test_allowlisted_plugin_middleware_is_registered_in_web_group(): void + public function test_self_gate_wrapper_is_registered_in_web_and_api_groups(): void { + // 코어 게이트 래퍼는 항상 web/api 그룹에 등록된다 (확장 미들웨어를 요청 시점에 실행). $kernel = $this->app->make(HttpKernelContract::class); $this->assertInstanceOf(HttpKernel::class, $kernel); $groups = $kernel->getMiddlewareGroups(); - $this->assertContains( - CookieConsentMiddleware::class, - $groups['web'] ?? [], - 'allowlist 에 GDPR 을 명시했으나 web 그룹에 CookieConsentMiddleware 가 없습니다' + foreach (['web', 'api'] as $group) { + $hasGate = collect($groups[$group] ?? []) + ->contains(fn ($m) => is_string($m) && str_starts_with($m, ExtensionMiddlewareGate::class)); + $this->assertTrue($hasGate, "{$group} 그룹에 ExtensionMiddlewareGate 래퍼가 등록되어야 합니다."); + } + } + + /** + * @effects allowlisted_plugin_middleware_is_registered_in_web_group + */ + public function test_gate_resolves_gdpr_cookie_consent_when_active(): void + { + // GDPR 을 활성 상태로 등록하면, 게이트가 CookieConsentMiddleware 를 + // 모든 요청(everything/before_core)에서 매칭한다 (EDPB §16 광역 타게팅). + $this->activateGdprForGate(); + + ExtensionMiddlewareRegistry::flush(); + $registry = $this->app->make(ExtensionMiddlewareRegistryInterface::class); + + foreach (['web', 'api'] as $group) { + $matched = $registry->resolveForRoute('any.route.name', 'any/path', $group, 'before_core'); + $this->assertContains( + CookieConsentMiddleware::class, + $matched, + "GDPR 활성 시 {$group}/before_core 에서 CookieConsentMiddleware 가 매칭되어야 합니다.", + ); + } + + ExtensionMiddlewareRegistry::flush(); + } + + /** + * GDPR 플러그인을 활성 상태로 등록해 게이트가 그 미들웨어를 수집하도록 합니다. + */ + private function activateGdprForGate(): void + { + Plugin::query()->updateOrCreate( + ['identifier' => 'sirsoft-gdpr'], + [ + 'vendor' => 'sirsoft', + 'name' => json_encode(['ko' => 'GDPR', 'en' => 'GDPR']), + 'version' => '1.0.0', + 'status' => ExtensionStatus::Active->value, + ] ); + PluginManager::invalidatePluginStatusCache(); + + $pluginManager = $this->app->make(PluginManager::class); + $property = new \ReflectionProperty($pluginManager, 'plugins'); + $property->setAccessible(true); + $plugins = $property->getValue($pluginManager); + $plugins['sirsoft-gdpr'] = new \Plugins\Sirsoft\Gdpr\Plugin; + $property->setValue($pluginManager, $plugins); } } diff --git a/tests/Feature/Seo/SeoBladeCssTest.php b/tests/Feature/Seo/SeoBladeCssTest.php new file mode 100644 index 00000000..db485ac0 --- /dev/null +++ b/tests/Feature/Seo/SeoBladeCssTest.php @@ -0,0 +1,77 @@ + 'ko', + 'title' => '베이직 오버핏 코튼 티셔츠', + 'titleSuffix' => ' | 코지홈', + 'description' => '사계절 입기 좋은 오버핏 기본 티셔츠', + 'keywords' => '티셔츠,오버핏', + 'canonicalUrl' => 'https://example.com/shop/products/1', + 'hreflangTags' => '', + 'ogTags' => '', + 'twitterTags' => '', + 'jsonLd' => '{"@type":"Product","name":"베이직 오버핏 코튼 티셔츠"}', + 'bodyHtml' => '

', + 'googleAnalyticsId' => '', + 'googleVerification' => '', + 'naverVerification' => '', + 'stylesheets' => [ + '/api/templates/assets/sirsoft-basic/css/components.css', + ], + 'extraHeadTags' => '', + 'extraBodyEnd' => '', + 'generatorTag' => '', + ], $overrides); + } + + private function renderSeo(array $overrides = []): string + { + return View::make('seo', $this->viewData($overrides))->render(); + } + + public function test_봇_HTML에_코어_app_css_고정경로_링크가_없다(): void + { + $out = $this->renderSeo(); + + $this->assertStringNotContainsString('/build/assets/app.css', $out); + $this->assertStringNotContainsString('/build/', $out); + } + + public function test_템플릿_stylesheets는_정상_링크된다(): void + { + $out = $this->renderSeo(); + + $this->assertStringContainsString( + '', + $out, + ); + } + + public function test_cssPath_변수_없이도_예외없이_렌더된다(): void + { + // cssPath 를 뷰 데이터에서 완전히 제거해도 (undefined variable 등) 예외가 없어야 한다. + $out = $this->renderSeo(); + + $this->assertStringContainsString('베이직 오버핏 코튼 티셔츠 | 코지홈', $out); + } +} diff --git a/tests/Feature/Seo/SeoPreviewBladeTest.php b/tests/Feature/Seo/SeoPreviewBladeTest.php index a593070a..68d9ff13 100644 --- a/tests/Feature/Seo/SeoPreviewBladeTest.php +++ b/tests/Feature/Seo/SeoPreviewBladeTest.php @@ -33,7 +33,6 @@ class SeoPreviewBladeTest extends TestCase 'googleAnalyticsId' => 'G-XXXX', 'googleVerification' => 'gverify123', 'naverVerification' => 'nverify456', - 'cssPath' => '/build/assets/app.css', 'stylesheets' => ['/api/templates/assets/sirsoft-basic/css/components.css', 'https://cdnjs.cloudflare.com/font-awesome.css'], 'extraHeadTags' => '', 'extraBodyEnd' => '', diff --git a/tests/Feature/Upgrades/Upgrade_7_0_0_beta_5RecoveryTest.php b/tests/Feature/Upgrades/Upgrade_7_0_0_beta_5RecoveryTest.php index 5705181c..078889f2 100644 --- a/tests/Feature/Upgrades/Upgrade_7_0_0_beta_5RecoveryTest.php +++ b/tests/Feature/Upgrades/Upgrade_7_0_0_beta_5RecoveryTest.php @@ -434,18 +434,44 @@ class Upgrade_7_0_0_beta_5RecoveryTest extends TestCase } /** - * public/storage 자체가 부재 (storage:link 미실행 환경) → skip. + * public/storage 자체가 부재 (prune 삭제 또는 storage:link 미실행) → 재생성 (#43). + * + * `StorageLinkHelper` 위임 이후 동작이 상위호환으로 확장됐다 — 기존 스텝은 "부재" 를 + * skip 했지만, 헬퍼는 Laravel storage:link source 가 존재하면 symlink 를 신규 생성한다. + * 이는 `--prune` 이 orphan 으로 삭제한 갭을 복구하는 핵심 케이스다. */ - public function test_recover_public_storage_symlink_skips_when_public_storage_absent(): void + public function test_recover_public_storage_symlink_recreates_when_public_storage_absent(): void { $fakeBase = $this->arrangeFakeBase(); File::ensureDirectoryExists($fakeBase.'/public'); File::ensureDirectoryExists($fakeBase.'/storage/app/public'); - // public/storage 의도적 미생성 — storage:link 가 한 번도 실행되지 않은 환경 + // public/storage 의도적 미생성 — prune 이 삭제했거나 storage:link 미실행 환경 + + // symlink 생성 권한 확인 (Windows 일반 사용자면 skip) + if (! @symlink($fakeBase.'/storage/app/public', $fakeBase.'/symlink_probe')) { + $this->markTestSkipped('symlink 생성 권한 부족 (Windows SeCreateSymbolicLink)'); + } + @unlink($fakeBase.'/symlink_probe'); $this->invokeRecoverPublicStorageSymlink(); - $this->assertFalse(is_link($fakeBase.'/public/storage'), 'public/storage 부재 시 자동 생성 안 함'); + $this->assertTrue(is_link($fakeBase.'/public/storage'), 'source 존재 시 부재 public/storage 를 symlink 로 재생성해야 한다 (#43)'); + $this->assertSame($fakeBase.'/storage/app/public', readlink($fakeBase.'/public/storage')); + } + + /** + * public/storage 부재 + Laravel storage:link source(storage/app/public) 도 부재 → + * 표준 미사용 환경으로 보고 skip (false positive 차단). + */ + public function test_recover_public_storage_symlink_skips_when_both_absent(): void + { + $fakeBase = $this->arrangeFakeBase(); + File::ensureDirectoryExists($fakeBase.'/public'); + // public/storage 와 storage/app/public 모두 의도적 미생성 + + $this->invokeRecoverPublicStorageSymlink(); + + $this->assertFalse(is_link($fakeBase.'/public/storage'), 'source 부재 시 자동 생성 안 함'); $this->assertFalse(file_exists($fakeBase.'/public/storage'), 'public/storage 자체가 생성되지 않아야 한다'); } diff --git a/tests/Playwright/fixtures/auth.ts b/tests/Playwright/fixtures/auth.ts index 8ef0f091..f5ad29ab 100644 --- a/tests/Playwright/fixtures/auth.ts +++ b/tests/Playwright/fixtures/auth.ts @@ -18,6 +18,25 @@ */ import { test as base, type Page } from '@playwright/test'; import { execSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// ESM 환경(package.json "type": "module")에서는 __dirname 이 정의되지 않으므로 +// import.meta.url 로 재구성한다. +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * `php artisan` 을 실행할 코어 루트를 결정한다. + * + * 확장(모듈/플러그인/템플릿) 은 자기 디렉토리의 config 로 Playwright 를 띄우므로 `process.cwd()` + * 가 확장 디렉토리다 — 그 위치에는 artisan 이 없다. 본 파일은 항상 `<코어루트>/tests/Playwright/fixtures/` + * 에 있으므로 자기 위치에서 코어 루트를 역산한다 (경로 하드코딩 회피). + * + * @returns artisan 이 존재하는 코어 루트 절대경로 + */ +function resolveCoreRoot(): string { + return process.env.G7_ROOT || resolve(__dirname, '../../../'); +} /** * 임의 권한 식별자(코어/모듈/플러그인) 로 Sanctum 토큰을 발급한다. @@ -35,7 +54,7 @@ export function issueToken(...permissions: string[]): string { for (let attempt = 0; attempt < 3; attempt += 1) { try { const stdout = execSync(command, { - cwd: process.env.G7_ROOT || process.cwd(), + cwd: resolveCoreRoot(), encoding: 'utf-8', env: { ...process.env, diff --git a/tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php b/tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php index 2a59e048..65c10aa8 100644 --- a/tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php +++ b/tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php @@ -41,7 +41,7 @@ class FilePermissionHelperSymlinkTest extends TestCase } #[Test] - public function copyDirectory_는_symlink_를_target_추적_없이_그대로_보존합니다(): void + public function copy_directory_는_symlink_를_target_추적_없이_그대로_보존합니다(): void { $source = $this->tempRoot.'/src'; $dest = $this->tempRoot.'/dst'; @@ -72,7 +72,7 @@ class FilePermissionHelperSymlinkTest extends TestCase } #[Test] - public function copyDirectory_는_기존_dest_디렉토리를_symlink_로_교체할_때_dest_먼저_정리합니다(): void + public function copy_directory_는_기존_dest_디렉토리를_symlink_로_교체할_때_dest_먼저_정리합니다(): void { $source = $this->tempRoot.'/src'; $dest = $this->tempRoot.'/dst'; @@ -97,7 +97,7 @@ class FilePermissionHelperSymlinkTest extends TestCase } #[Test] - public function removeOrphanItems_는_source_부재_symlink_를_unlink_로_삭제합니다(): void + public function remove_orphan_items_는_source_부재_symlink_를_unlink_로_삭제합니다(): void { $source = $this->tempRoot.'/src'; $dest = $this->tempRoot.'/dst'; @@ -119,8 +119,98 @@ class FilePermissionHelperSymlinkTest extends TestCase $this->assertFileExists($linkTarget.'/precious.txt', 'symlink target 의 파일은 보존되어야 한다 (재귀 삭제 사고 차단)'); } + /** + * 보호 화이트리스트(`preserveLinkPaths`)에 매칭되는 orphan symlink 는 삭제하지 않고 보존. + * + * 코어 업데이트 prune 모드에서 `public/storage` symlink 가 릴리즈 소스에 없다는 이유로 + * orphan 삭제되어 업로드 파일이 404 되던 결함(#43) 회귀 가드. + */ #[Test] - public function copyDirectory_는_심볼릭링크_없는_일반_디렉토리는_기존_동작을_유지합니다(): void + public function remove_orphan_items_는_화이트리스트_symlink_를_orphan_이어도_보존합니다(): void + { + $source = $this->tempRoot.'/src'; + $dest = $this->tempRoot.'/dst'; + $linkTarget = $this->tempRoot.'/link_target'; + + File::ensureDirectoryExists($source); + File::ensureDirectoryExists($dest); + File::ensureDirectoryExists($linkTarget); + File::put($linkTarget.'/uploaded.txt', '업로드 파일'); + + // dest 에만 존재하는 storage symlink (source 에는 없음 → orphan) + if (! @symlink($linkTarget, $dest.'/storage')) { + $this->markTestSkipped('symlink 생성 실패'); + } + + // preserveLinkPaths=['storage'] 로 prune 실행 → 보호되어야 함 + FilePermissionHelper::copyDirectory($source, $dest, null, [], '', true, false, null, ['storage']); + + $this->assertTrue(is_link($dest.'/storage'), '화이트리스트 symlink 는 orphan 이어도 보존되어야 한다'); + $this->assertSame($linkTarget, readlink($dest.'/storage')); + $this->assertFileExists($linkTarget.'/uploaded.txt', 'symlink target 파일 보존'); + } + + /** + * 화이트리스트 밖 orphan symlink 는 `preserveLinkPaths=['storage']` 여도 여전히 삭제. + * + * 정밀 보호(무조건 보존 아님)임을 보장 — `public/storage` 만 좁게 보존한다는 설계 확정. + */ + #[Test] + public function remove_orphan_items_는_화이트리스트_밖_symlink_는_여전히_삭제합니다(): void + { + $source = $this->tempRoot.'/src'; + $dest = $this->tempRoot.'/dst'; + $linkTarget = $this->tempRoot.'/link_target'; + + File::ensureDirectoryExists($source); + File::ensureDirectoryExists($dest); + File::ensureDirectoryExists($linkTarget); + File::put($linkTarget.'/precious.txt', '중요한 파일'); + + // dest 에만 존재하는 다른 이름의 orphan symlink + if (! @symlink($linkTarget, $dest.'/other_link')) { + $this->markTestSkipped('symlink 생성 실패'); + } + + // 화이트리스트는 'storage' 만 — 'other_link' 는 보호 대상 아님 + FilePermissionHelper::copyDirectory($source, $dest, null, [], '', true, false, null, ['storage']); + + $this->assertFalse(is_link($dest.'/other_link'), '화이트리스트 밖 orphan symlink 는 삭제되어야 한다'); + $this->assertFileExists($linkTarget.'/precious.txt', 'symlink target 파일은 보존 (재귀 삭제 사고 차단)'); + } + + /** + * 보호 화이트리스트에 매칭되는 orphan junction 도 보존 (Windows 전용). + */ + #[Test] + public function remove_orphan_items_는_화이트리스트_junction_을_orphan_이어도_보존합니다(): void + { + if (PHP_OS_FAMILY !== 'Windows') { + $this->markTestSkipped('junction 은 Windows 전용 (mklink /J)'); + } + + $source = $this->tempRoot.'/src'; + $dest = $this->tempRoot.'/dst'; + $linkTarget = $this->tempRoot.'/link_target'; + + File::ensureDirectoryExists($source); + File::ensureDirectoryExists($dest); + File::ensureDirectoryExists($linkTarget); + File::put($linkTarget.'/uploaded.txt', '업로드 파일'); + + exec('cmd /c mklink /J '.escapeshellarg($dest.'/storage').' '.escapeshellarg($linkTarget).' 2>&1', $out, $code); + if ($code !== 0 || ! is_dir($dest.'/storage')) { + $this->markTestSkipped('junction 생성 실패: '.implode("\n", $out)); + } + + FilePermissionHelper::copyDirectory($source, $dest, null, [], '', true, false, null, ['storage']); + + $this->assertDirectoryExists($dest.'/storage', '화이트리스트 junction 은 orphan 이어도 보존되어야 한다'); + $this->assertFileExists($dest.'/storage/uploaded.txt', 'junction 통과 시 target 파일 접근 가능'); + } + + #[Test] + public function copy_directory_는_심볼릭링크_없는_일반_디렉토리는_기존_동작을_유지합니다(): void { $source = $this->tempRoot.'/src'; $dest = $this->tempRoot.'/dst'; @@ -145,7 +235,7 @@ class FilePermissionHelperSymlinkTest extends TestCase * junction 은 Windows 전용이므로 비-Windows 환경에서는 skip. */ #[Test] - public function copyDirectory_는_windows_junction_을_파일로_오판하지_않고_보존합니다(): void + public function copy_directory_는_windows_junction_을_파일로_오판하지_않고_보존합니다(): void { if (PHP_OS_FAMILY !== 'Windows') { $this->markTestSkipped('junction 은 Windows 전용 (mklink /J)'); @@ -182,7 +272,7 @@ class FilePermissionHelperSymlinkTest extends TestCase * 링크 자체만 제거하는지 검증 (junction 은 isDir() 이 false 라 별도 rmdir 경로 필요). */ #[Test] - public function removeOrphanItems_는_source_부재_junction_을_target_추적_없이_링크만_제거합니다(): void + public function remove_orphan_items_는_source_부재_junction_을_target_추적_없이_링크만_제거합니다(): void { if (PHP_OS_FAMILY !== 'Windows') { $this->markTestSkipped('junction 은 Windows 전용 (mklink /J)'); diff --git a/tests/Unit/Extension/Helpers/StorageLinkHelperTest.php b/tests/Unit/Extension/Helpers/StorageLinkHelperTest.php new file mode 100644 index 00000000..231e48d5 --- /dev/null +++ b/tests/Unit/Extension/Helpers/StorageLinkHelperTest.php @@ -0,0 +1,316 @@ +setBasePath()` 로 + * 격리된 fake base 를 구성하여 `public_path()`/`storage_path()` 가 임시 트리를 가리키게 한다. + * + * Windows: PHP `symlink()` 는 `SeCreateSymbolicLink` 권한 필요. 권한 부족 환경에서는 + * junction 폴백이 작동하거나(둘 다 실패 시) markTestSkipped 로 건너뛴다. + */ +class StorageLinkHelperTest extends TestCase +{ + private string $fakeBase; + + private ?string $originalBasePath = null; + + protected function setUp(): void + { + parent::setUp(); + + $this->originalBasePath = base_path(); + $this->fakeBase = storage_path('app/testing/storage_link_helper_'.uniqid('', true)); + File::ensureDirectoryExists($this->fakeBase); + app()->setBasePath($this->fakeBase); + } + + protected function tearDown(): void + { + if ($this->originalBasePath !== null) { + app()->setBasePath($this->originalBasePath); + } + + if (File::isDirectory($this->fakeBase)) { + // symlink/junction 은 target 을 따라가지 않도록 링크만 먼저 제거 (재귀 삭제 사고 차단). + // 백업으로 생긴 `storage.broken.*` 링크까지 포함해 public/ 하위 링크를 모두 정리한다. + $publicDir = $this->fakeBase.'/public'; + if (is_dir($publicDir)) { + foreach (scandir($publicDir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $publicDir.'/'.$entry; + if (is_link($path)) { + @unlink($path); + } elseif (PHP_OS_FAMILY === 'Windows' && ! is_file($path) && @readlink($path) !== false) { + // Windows junction — rmdir 로 링크만 제거 (target 미추적) + @rmdir($path); + } + } + } + File::deleteDirectory($this->fakeBase); + } + + parent::tearDown(); + } + + /** + * symlink 생성 권한이 없으면 테스트를 건너뛴다 (Windows 일반 사용자). + */ + private function skipIfNoSymlink(): void + { + $probe = $this->fakeBase.'/symlink_probe'; + $target = $this->fakeBase.'/symlink_probe_target'; + File::ensureDirectoryExists($target); + if (! @symlink($target, $probe)) { + $this->markTestSkipped('symlink 생성 권한 부족 (Windows SeCreateSymbolicLink)'); + } + @unlink($probe); + File::deleteDirectory($target); + } + + #[Test] + public function 이미_정상_symlink_이면_no_op_으로_보존합니다(): void + { + $this->skipIfNoSymlink(); + + File::ensureDirectoryExists($this->fakeBase.'/public'); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + @symlink($this->fakeBase.'/storage/app/public', $this->fakeBase.'/public/storage'); + + $targetBefore = readlink($this->fakeBase.'/public/storage'); + + StorageLinkHelper::ensurePublicStorageLink(); + + $this->assertTrue(is_link($this->fakeBase.'/public/storage'), '정상 symlink 는 보존되어야 한다'); + $this->assertSame($targetBefore, readlink($this->fakeBase.'/public/storage'), 'target 미변경 (멱등)'); + } + + #[Test] + public function public_storage_부재_시_symlink_를_신규_생성합니다(): void + { + $this->skipIfNoSymlink(); + + File::ensureDirectoryExists($this->fakeBase.'/public'); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + // public/storage 의도적 미생성 — prune 이 삭제한 시나리오 + + StorageLinkHelper::ensurePublicStorageLink(); + + $this->assertTrue(is_link($this->fakeBase.'/public/storage'), '부재 시 symlink 가 신규 생성되어야 한다'); + $this->assertSame($this->fakeBase.'/storage/app/public', readlink($this->fakeBase.'/public/storage')); + } + + #[Test] + public function 일반_디렉토리_손상_시_broken_백업_후_재생성합니다(): void + { + $this->skipIfNoSymlink(); + + File::ensureDirectoryExists($this->fakeBase.'/public/storage'); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + + // 손상 상태: public/storage 가 dereferenced 콘텐츠를 가진 일반 디렉토리 + File::put($this->fakeBase.'/public/storage/uploaded.txt', 'dereferenced content'); + + StorageLinkHelper::ensurePublicStorageLink(); + + // 1) symlink 로 재생성 + $this->assertTrue(is_link($this->fakeBase.'/public/storage'), 'symlink 로 재생성되어야 한다'); + $this->assertSame($this->fakeBase.'/storage/app/public', readlink($this->fakeBase.'/public/storage')); + + // 2) .broken.{timestamp} 백업 존재 + 콘텐츠 보존 + $backups = array_values(array_filter( + scandir($this->fakeBase.'/public'), + fn ($e) => str_starts_with($e, 'storage.broken.'), + )); + $this->assertCount(1, $backups, '.broken.{timestamp} 백업 1개 존재'); + $this->assertFileExists($this->fakeBase.'/public/'.$backups[0].'/uploaded.txt'); + $this->assertSame('dereferenced content', File::get($this->fakeBase.'/public/'.$backups[0].'/uploaded.txt')); + } + + #[Test] + public function storage_app_public_source_부재_시_skip_합니다(): void + { + File::ensureDirectoryExists($this->fakeBase.'/public/storage'); + File::put($this->fakeBase.'/public/storage/custom.txt', 'operator content'); + // storage/app/public 의도적 미생성 — Laravel 표준 미사용 환경 + + StorageLinkHelper::ensurePublicStorageLink(); + + $this->assertFalse(is_link($this->fakeBase.'/public/storage'), 'source 부재 시 symlink 생성 안 함'); + $this->assertTrue(is_dir($this->fakeBase.'/public/storage'), '일반 디렉토리 그대로 유지'); + $this->assertFileExists($this->fakeBase.'/public/storage/custom.txt', '운영자 콘텐츠 보존'); + + // rename 백업도 없어야 함 + $backups = array_values(array_filter( + scandir($this->fakeBase.'/public'), + fn ($e) => str_starts_with($e, 'storage.broken.'), + )); + $this->assertEmpty($backups, 'source 부재 시 rename 백업도 생성되지 않아야 한다'); + } + + #[Test] + public function 로거_null_이면_upgrade_채널_폴백으로_예외없이_동작합니다(): void + { + $this->skipIfNoSymlink(); + + File::ensureDirectoryExists($this->fakeBase.'/public'); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + + // null 로거로 호출 — 폴백 채널로 예외 없이 완료되어야 함 + StorageLinkHelper::ensurePublicStorageLink(null); + + $this->assertTrue(is_link($this->fakeBase.'/public/storage')); + } + + /** + * 재생성된 링크의 소유자/그룹이 부모 public/ 디렉토리를 상속합니다 (#43 sudo 후속). + * + * sudo 로 실행된 종료 시점 복구가 `symlink()` 로 링크를 만들면 링크 소유자가 root:root 로 + * 남아, 원래 앱 실행 유저 소유였던 링크의 소유권이 바뀌던 문제. 링크 생성 직후 부모 + * 디렉토리의 owner/group 으로 `lchown`/`lchgrp` 보정하는지 검증한다. + * + * 실측 방법: uid 변경은 root 권한이 필요하므로, 부모 public/ 의 그룹을 프로세스의 보조 + * 그룹 중 하나로 바꾼 뒤 링크를 재생성 → 링크 그룹이 부모 그룹을 상속하는지로 검증한다 + * (sudo 상황에서 링크만 다른 소유로 남는 회귀와 동형). Windows / 보조 그룹 부재 시 skip. + */ + #[Test] + public function 재생성된_링크는_부모_디렉토리의_소유권을_상속합니다(): void + { + if (PHP_OS_FAMILY === 'Windows' || ! function_exists('lchgrp') || ! function_exists('posix_getgroups')) { + $this->markTestSkipped('링크 소유권 상속 검증은 POSIX(lchgrp/posix_getgroups) 환경 전용'); + } + + $this->skipIfNoSymlink(); + + $publicDir = $this->fakeBase.'/public'; + File::ensureDirectoryExists($publicDir); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + + // 프로세스가 chgrp 가능한 그룹 = 현재 그룹과 다른 보조 그룹을 찾는다. + $currentGid = posix_getgid(); + $candidateGids = array_values(array_filter(posix_getgroups(), fn ($g) => $g !== $currentGid)); + if ($candidateGids === []) { + $this->markTestSkipped('보조 그룹이 없어 소유권 상속 검증 불가'); + } + + // 부모 public/ 를 보조 그룹으로 변경 — 링크가 이 그룹을 상속해야 함. + $targetGid = $candidateGids[0]; + if (! @chgrp($publicDir, $targetGid)) { + $this->markTestSkipped('부모 디렉토리 chgrp 실패 (권한 부족)'); + } + + // public/storage 의도적 미생성 → 신규 생성 경로가 링크를 만든다. + StorageLinkHelper::ensurePublicStorageLink(); + + $linkPath = $this->fakeBase.'/public/storage'; + $this->assertTrue(is_link($linkPath), '링크가 생성되어야 한다'); + + $linkStat = lstat($linkPath); + $this->assertSame( + $targetGid, + $linkStat['gid'], + '재생성된 링크의 그룹은 부모 public/ 의 그룹을 상속해야 한다 (sudo 후 root 소유 잔존 차단, #43)', + ); + } + + /** + * public/storage 부재 시 junction 폴백으로 재생성 (Windows — symlink 권한 불요). + * + * Windows 일반 사용자는 symlink 를 못 만들지만 junction(`mklink /J`)은 만들 수 있으므로, + * `createLink` 의 junction 폴백이 부재 케이스를 실제로 복구하는지 검증한다. + */ + #[Test] + public function public_storage_부재_시_junction_폴백으로_재생성합니다(): void + { + if (PHP_OS_FAMILY !== 'Windows') { + $this->markTestSkipped('junction 은 Windows 전용 (mklink /J)'); + } + + File::ensureDirectoryExists($this->fakeBase.'/public'); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + File::put($this->fakeBase.'/storage/app/public/uploaded.txt', 'user upload'); + // public/storage 의도적 미생성 + + StorageLinkHelper::ensurePublicStorageLink(); + + $this->assertTrue(is_dir($this->fakeBase.'/public/storage'), '부재 시 junction 으로 재생성되어야 한다'); + $this->assertFileExists($this->fakeBase.'/public/storage/uploaded.txt', 'junction 통과 시 업로드 파일 접근 가능'); + } + + /** + * 일반 디렉토리 손상 시 junction 폴백으로 재생성 + .broken 백업 (Windows). + */ + #[Test] + public function 일반_디렉토리_손상_시_junction_폴백으로_재생성합니다(): void + { + if (PHP_OS_FAMILY !== 'Windows') { + $this->markTestSkipped('junction 은 Windows 전용 (mklink /J)'); + } + + File::ensureDirectoryExists($this->fakeBase.'/public/storage'); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + File::put($this->fakeBase.'/public/storage/uploaded.txt', 'dereferenced content'); + + // 재생성 전 source 에 마커 파일을 넣어, 재생성된 링크가 source 를 통과하는지로 검증한다. + File::put($this->fakeBase.'/storage/app/public/marker.txt', 'from source'); + + StorageLinkHelper::ensurePublicStorageLink(); + + // junction 으로 재생성 → source(storage/app/public) 를 통과해 marker.txt 접근 가능 + $this->assertTrue(is_dir($this->fakeBase.'/public/storage'), 'junction 으로 재생성되어야 한다'); + $this->assertSame( + 'from source', + File::get($this->fakeBase.'/public/storage/marker.txt'), + '재생성된 링크는 storage/app/public 을 가리켜야 한다 (source 마커 접근 가능)', + ); + + // .broken 백업 존재 + dereferenced 콘텐츠 보존 + $backups = array_values(array_filter( + scandir($this->fakeBase.'/public'), + fn ($e) => str_starts_with($e, 'storage.broken.'), + )); + $this->assertCount(1, $backups, '.broken 백업 1개 존재'); + $this->assertFileExists($this->fakeBase.'/public/'.$backups[0].'/uploaded.txt'); + $this->assertSame('dereferenced content', File::get($this->fakeBase.'/public/'.$backups[0].'/uploaded.txt')); + } + + /** + * 이미 junction(정상 링크)이면 no-op — rename 백업하지 않고 보존 (Windows). + */ + #[Test] + public function 이미_junction_이면_no_op_으로_보존합니다(): void + { + if (PHP_OS_FAMILY !== 'Windows') { + $this->markTestSkipped('junction 은 Windows 전용 (mklink /J)'); + } + + File::ensureDirectoryExists($this->fakeBase.'/public'); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + File::put($this->fakeBase.'/storage/app/public/uploaded.txt', 'user upload'); + + exec('cmd /c mklink /J '.escapeshellarg($this->fakeBase.'/public/storage').' '.escapeshellarg($this->fakeBase.'/storage/app/public').' 2>&1', $out, $code); + if ($code !== 0 || ! is_dir($this->fakeBase.'/public/storage')) { + $this->markTestSkipped('junction 생성 실패: '.implode("\n", $out)); + } + + StorageLinkHelper::ensurePublicStorageLink(); + + // .broken 백업이 생기지 않아야 함 (no-op) + $backups = array_values(array_filter( + scandir($this->fakeBase.'/public'), + fn ($e) => str_starts_with($e, 'storage.broken.'), + )); + $this->assertEmpty($backups, '이미 정상 junction 은 rename 백업 없이 보존되어야 한다'); + $this->assertFileExists($this->fakeBase.'/public/storage/uploaded.txt'); + } +} diff --git a/tests/Unit/Http/Middleware/ExtensionMiddlewareGateTest.php b/tests/Unit/Http/Middleware/ExtensionMiddlewareGateTest.php new file mode 100644 index 00000000..09534081 --- /dev/null +++ b/tests/Unit/Http/Middleware/ExtensionMiddlewareGateTest.php @@ -0,0 +1,165 @@ + $matched resolveForRoute 반환값 + */ + private function makeGate(array $matched): ExtensionMiddlewareGate + { + $registry = Mockery::mock(ExtensionMiddlewareRegistryInterface::class); + $registry->shouldReceive('resolveForRoute')->andReturn($matched); + + return new ExtensionMiddlewareGate($this->app, $registry); + } + + /** + * @effects gate_runs_matched_middleware_via_pipeline + */ + public function test_matched_middleware_runs(): void + { + GateSpyMiddleware::$ran = false; + $gate = $this->makeGate([GateSpyMiddleware::class]); + + $response = $gate->handle(Request::create('/api/foo', 'GET'), fn ($req) => new Response('ok'), 'api', 'after_core'); + + $this->assertTrue(GateSpyMiddleware::$ran, '매칭된 확장 미들웨어가 실행되어야 합니다.'); + $this->assertSame('ok', $response->getContent()); + } + + /** + * @effects gate_passes_through_when_no_match + */ + public function test_no_match_passes_through(): void + { + GateSpyMiddleware::$ran = false; + $gate = $this->makeGate([]); + + $response = $gate->handle(Request::create('/api/foo', 'GET'), fn ($req) => new Response('passed'), 'api', 'after_core'); + + $this->assertFalse(GateSpyMiddleware::$ran, '매칭 없으면 미들웨어를 실행하지 않습니다.'); + $this->assertSame('passed', $response->getContent()); + } + + /** + * @effects gate_matched_middleware_can_short_circuit_response + */ + public function test_matched_middleware_can_short_circuit_response(): void + { + $gate = $this->makeGate([GateBlockMiddleware::class]); + + $response = $gate->handle(Request::create('/api/foo', 'GET'), fn ($req) => new Response('should-not-reach'), 'api', 'after_core'); + + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('blocked', $response->getContent()); + } + + /** + * @effects gate_execution_order_follows_matched_list + */ + public function test_execution_order_follows_matched_list(): void + { + GateOrderMiddlewareA::$log = []; + $gate = $this->makeGate([GateOrderMiddlewareA::class, GateOrderMiddlewareB::class]); + + $gate->handle(Request::create('/api/foo', 'GET'), fn ($req) => new Response('ok'), 'api', 'after_core'); + + $this->assertSame(['A', 'B'], GateOrderMiddlewareA::$log, '매칭 리스트 순서대로 실행되어야 합니다.'); + } + + /** + * @effects gate_reaches_registry_and_runs_matched_for_unnamed_route + */ + public function test_unnamed_route_still_reaches_registry_and_runs_matched(): void + { + GateSpyMiddleware::$ran = false; + // 무명 라우트 요청 (route()->getName()=null) 에서도 게이트가 registry 를 조회하고 + // URI 매칭으로 반환된 미들웨어를 실행한다 (조기 통과하지 않음). + $gate = $this->makeGate([GateSpyMiddleware::class]); + + $gate->handle(Request::create('/', 'GET'), fn ($req) => new Response('ok'), 'web', 'after_core'); + + $this->assertTrue(GateSpyMiddleware::$ran, '무명 라우트에서도 매칭 미들웨어가 실행되어야 합니다.'); + } + + public function test_middleware_exception_propagates(): void + { + $gate = $this->makeGate([GateThrowMiddleware::class]); + + $this->expectException(\RuntimeException::class); + $gate->handle(Request::create('/api/foo', 'GET'), fn ($req) => new Response('ok'), 'api', 'after_core'); + } +} + +/** 실행 여부만 기록하는 픽스처 미들웨어. */ +class GateSpyMiddleware +{ + public static bool $ran = false; + + public function handle($request, \Closure $next) + { + self::$ran = true; + + return $next($request); + } +} + +/** 응답을 사전 차단하는 픽스처 미들웨어. */ +class GateBlockMiddleware +{ + public function handle($request, \Closure $next) + { + return new Response('blocked', 403); + } +} + +/** 실행 순서를 기록하는 픽스처 미들웨어 A. */ +class GateOrderMiddlewareA +{ + /** @var array */ + public static array $log = []; + + public function handle($request, \Closure $next) + { + self::$log[] = 'A'; + + return $next($request); + } +} + +/** 실행 순서를 기록하는 픽스처 미들웨어 B. */ +class GateOrderMiddlewareB +{ + public function handle($request, \Closure $next) + { + GateOrderMiddlewareA::$log[] = 'B'; + + return $next($request); + } +} + +/** 예외를 던지는 픽스처 미들웨어. */ +class GateThrowMiddleware +{ + public function handle($request, \Closure $next) + { + throw new \RuntimeException('boom'); + } +} diff --git a/tests/Unit/Seo/RealWorldRenderingTest.php b/tests/Unit/Seo/RealWorldRenderingTest.php index 8f71dcd8..aef5dbf4 100644 --- a/tests/Unit/Seo/RealWorldRenderingTest.php +++ b/tests/Unit/Seo/RealWorldRenderingTest.php @@ -770,27 +770,6 @@ class RealWorldRenderingTest extends TestCase } } - /** - * SeoRenderer.getCssPath()가 코어 Tailwind CSS를 반환해야 합니다. - * (코어 app.css가 아닌 Tailwind를 포함한 빌드 CSS) - */ - public function test_css_path_includes_tailwind(): void - { - // getCssPath()는 Vite manifest에서 CSS를 찾으므로, - // manifest가 없으면 fallback '/build/assets/app.css'를 반환합니다. - // 실제로는 Tailwind 클래스가 포함된 CSS여야 합니다. - $renderer = app(\App\Seo\SeoRenderer::class); - - // Reflection으로 getCssPath() 호출 - $method = new \ReflectionMethod($renderer, 'getCssPath'); - $method->setAccessible(true); - $cssPath = $method->invoke($renderer); - - $this->assertIsString($cssPath); - // CSS 경로가 존재해야 함 - $this->assertNotEmpty($cssPath); - } - // ======================================================== // 9. 복잡한 표현식 + 조건부 렌더링 검증 // ======================================================== diff --git a/tests/Unit/Seo/SeoRendererTest.php b/tests/Unit/Seo/SeoRendererTest.php index b24a5f07..654fe8c8 100644 --- a/tests/Unit/Seo/SeoRendererTest.php +++ b/tests/Unit/Seo/SeoRendererTest.php @@ -455,9 +455,16 @@ class SeoRendererTest extends TestCase } /** - * CSS 경로가 뷰 데이터에 포함됩니다. + * 코어 Laravel 에셋 빌드 산출물(app.css)은 뷰 데이터에 주입되지 않습니다. + * + * seo.blade.php 가 참조하던 코어 app.css 는 실제 사용자 화면 + * (app.blade.php·admin.blade.php)에서 쓰이지 않는 Laravel 기본 빌드 파일이며, + * Vite manifest 구조상 해시 파일로만 생성되어 고정 경로 `/build/assets/app.css` + * 는 404 였다. 봇 렌더링에 필요한 스타일은 템플릿 CSS($stylesheets)가 담당하므로 + * 코어 app.css 참조를 제거했다. 이 테스트는 cssPath 잔재가 재유입되지 않도록 가드한다. + * (공개#64 — 봇 SEO HTML CSS 404 제거) */ - public function test_render_includes_css_path(): void + public function test_render_does_not_inject_core_app_css_path(): void { $request = Request::create('/about'); @@ -486,11 +493,12 @@ class SeoRendererTest extends TestCase ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); - $viewMock->shouldReceive('render')->once()->andReturn(''); + $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make') ->with('seo', Mockery::on(function ($data) { - return isset($data['cssPath']) && is_string($data['cssPath']); + // cssPath 키가 더 이상 뷰 데이터에 존재하지 않아야 한다. + return ! array_key_exists('cssPath', $data); })) ->once() ->andReturn($viewMock); diff --git a/tests/Unit/Services/CoreUpdateServiceTest.php b/tests/Unit/Services/CoreUpdateServiceTest.php index 4a55bc20..646a5ddb 100644 --- a/tests/Unit/Services/CoreUpdateServiceTest.php +++ b/tests/Unit/Services/CoreUpdateServiceTest.php @@ -1291,6 +1291,69 @@ MD; } } + /** + * end-to-end 회귀 (#43): `applyUpdate(prune:true)` 가 `public` 타깃을 정리할 때 + * `public/storage` symlink 를 orphan 으로 삭제하지 않고 보존합니다. + * + * 릴리즈 소스(`public/`)에는 런타임 symlink 가 없으므로 prune 이 orphan 으로 판정하여 + * `@unlink` 하던 결함으로 업로드 파일이 404 되던 회귀를 차단한다. 층1 화이트리스트 + * 전파(`public` → `preserveLinkPaths: ['storage']`) 합류 지점 검증. + */ + public function test_apply_update_prune_preserves_public_storage_symlink(): void + { + [$source, $fakeBase, $restore] = $this->prepareApplyUpdateEnv(['public']); + + try { + // source(릴리즈): public/ 에 일반 파일만, storage symlink 는 없음 + File::ensureDirectoryExists($source.DIRECTORY_SEPARATOR.'public'); + File::put($source.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'index.php', "&1', $out, $code); + $created = $code === 0 && is_dir($linkPath); + } + if (! $created) { + $this->markTestSkipped('symlink/junction 생성 권한 부족 (Windows SeCreateSymbolicLink)'); + } + + $this->service->applyUpdate($source, null, prune: true, applyList: null); + + // symlink/junction 이 orphan 삭제되지 않고 보존되어야 함. Windows junction 은 + // is_dir()/is_link() 이 stat 캐시에 따라 불안정하므로, 링크 존재는 readlink 로, + // 접근성은 업로드 파일 존재로 검증한다 (#43 — 404 회귀의 실측 지표). + clearstatcache(true, $linkPath); + $this->assertNotFalse( + @readlink($linkPath), + 'public/storage 링크는 prune 후에도 보존되어야 한다 (#43)', + ); + $this->assertFileExists( + $linkPath.DIRECTORY_SEPARATOR.'uploaded.txt', + 'symlink 통과 시 업로드 파일에 접근 가능해야 한다 (404 회귀 차단)', + ); + + // 코어 변경분(index.php)은 정상 반영 + $this->assertStringContainsString('core', File::get($fakeBase.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'index.php')); + } finally { + // 링크는 target 을 따라가지 않도록 먼저 제거 (tearDown 재귀 삭제 사고 차단). + // symlink → unlink, Windows junction → rmdir. + $linkPath = $fakeBase.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'storage'; + if (is_link($linkPath)) { + @unlink($linkPath); + } elseif (PHP_OS_FAMILY === 'Windows' && @readlink($linkPath) !== false && ! @rmdir($linkPath)) { + @unlink($linkPath); + } + $restore(); + } + } + /** * applyList=null (백업 부재 fallback): prune 이 아니어도 전체 덮어쓰기로 회귀. */ diff --git a/tests/Unit/Services/SettingsServiceAppConfigTest.php b/tests/Unit/Services/SettingsServiceAppConfigTest.php index 217b1dd3..4b445dd3 100644 --- a/tests/Unit/Services/SettingsServiceAppConfigTest.php +++ b/tests/Unit/Services/SettingsServiceAppConfigTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Services; +use App\Extension\HookManager; use App\Services\SettingsService; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -106,11 +107,45 @@ class SettingsServiceAppConfigTest extends TestCase } /** - * config/frontend.php가 없는 경우 빈 배열을 반환하는지 테스트합니다. + * getAppConfigForFrontend()가 core.frontend.filter_app_config 훅으로 확장이 주입한 값을 + * appConfig 에 반영하는지 테스트합니다. (예: 이커머스 모듈이 요청 기기 유형 isIos 를 주입) */ - public function test_get_app_config_returns_empty_when_no_config(): void + public function test_get_app_config_applies_frontend_filter_hook(): void { - // config를 비움 + // 우선순위 100 — 이커머스 모듈이 등록한 InjectAppConfigDeviceListener(priority 20)보다 + // 뒤에 실행되어, 전체 스위트에서 그 리스너가 함께 등록돼 있어도 이 테스트의 주입값이 + // 최종적으로 반영됨을 검증한다(훅 적용 메커니즘 자체 검증, 스위트 순서 비의존). + HookManager::addFilter( + 'core.frontend.filter_app_config', + function (array $appConfig): array { + $appConfig['isIos'] = true; + $appConfig['__testHookMarker'] = 'applied'; + + return $appConfig; + }, + 100 + ); + + $service = app(SettingsService::class); + + $result = $service->getAppConfigForFrontend(); + + $this->assertTrue($result['isIos']); + // 훅이 실제로 적용됐음을 별도 마커로도 확인(다른 리스너 간섭과 무관). + $this->assertSame('applied', $result['__testHookMarker'] ?? null); + // 기존 정적 값은 그대로 유지된다. + $this->assertArrayHasKey('supportedLocales', $result); + } + + /** + * config/frontend.php의 app_config 스키마가 비면 config 파생 키가 없는지 테스트합니다. + * + * 참고: core.frontend.filter_app_config 훅으로 확장(예: 이커머스 isIos)이 값을 주입할 수 + * 있으므로 결과가 완전히 비어 있음을 단정하지 않고, config 스키마 파생 키의 부재만 확인한다. + */ + public function test_get_app_config_has_no_config_derived_keys_when_schema_empty(): void + { + // config 스키마를 비움 config(['frontend.app_config' => []]); $service = app(SettingsService::class); @@ -118,7 +153,10 @@ class SettingsServiceAppConfigTest extends TestCase $result = $service->getAppConfigForFrontend(); $this->assertIsArray($result); - $this->assertEmpty($result); + // 스키마 파생 키(config_key 기반)는 없어야 한다. + $this->assertArrayNotHasKey('supportedTimezones', $result); + $this->assertArrayNotHasKey('supportedLocales', $result); + $this->assertArrayNotHasKey('version', $result); } /** diff --git a/tests/scenarios/extension-declarative-middleware.yaml b/tests/scenarios/extension-declarative-middleware.yaml new file mode 100644 index 00000000..867532de --- /dev/null +++ b/tests/scenarios/extension-declarative-middleware.yaml @@ -0,0 +1,89 @@ +# audit:allow test-scenario-coverage reason: axes cross product(extension_type×group×timing×target_kind×extension_status×route_match = 256)는 조합 열거로, 실질 회귀 가드는 effects 가 담당한다. effects 는 test_files 의 메서드 docblock @effects 로 전수 매핑되어 개별 검증된다. target_kind 8종 매칭식·group 펼침·timing 분리·활성 필터·라우트명 vs URI 매칭은 registry 단위 테스트가 값별로 직접 관측하고, 게이트 실행/무명 라우트/실요청 파싱은 gate 테스트가 커버한다. 조합 개별 docblock 마킹은 effects 매핑과 중복이므로 생략. + +feature: 확장 선언 미들웨어 self-gate (getMiddleware + ExtensionMiddlewareGate) + +description: | + 확장(모듈/플러그인)이 getMiddleware() 로 선언한 미들웨어를 코어가 요청 시점에 + 대상 매칭으로 실행하는 self-gate 아키텍처. + + 핵심 동작: + - AbstractModule/AbstractPlugin::getMiddleware() 가 [class, groups, timing, targets] + 배열을 선언 (기본 []). interface 가 아닌 Abstract 에만 정의 — registry 는 + method_exists 로 게이트. + - ExtensionMiddlewareRegistry 가 활성 확장의 선언을 수집해 [group.timing => 술어] + 인덱스로 캐시. targets 는 술어(op=name|name_ext|name_core|uri|always)로 정규화. + - 코어 ExtensionMiddlewareGate 래퍼가 bootstrap 에서 web/api × before_core/after_core + 로 4회 등록. 요청 시점에 라우트명·URI 를 술어와 대조해 매칭 미들웨어만 Pipeline 실행. + - 무명 SSR 셸 catch-all 대응: routeName='' 이어도 URI 패턴(op=uri)·everything(op=always) + 술어는 매칭. 라우트명 계열(op=name*)은 무명에서 miss. + - 인덱스는 확장 활성/비활성/설치/제거/리로드 시 ExtensionMiddlewareRegistry::flush() + 로 무효화 (Module/PluginManager 상태 변경 지점). + + targets 카탈로그 (target_kind 축): + self / all_extensions / core / everything(·*) / module:{id} / plugin:{id} / + 원시 라우트명 glob / brace / URI 패턴('/' 시작). + + 정밀도 (pay IP 화이트리스트): + self 로 뭉치지 않고 webhook 라우트명만 정밀 타게팅해 브라우저 콜백(payment.callback)에는 + IP 가드가 부착되지 않도록 한다 — 결제 회귀 방지. + +axes: + extension_type: [module, plugin] + group: [web, api] + timing: [before_core, after_core] + target_kind: [self, all_extensions, core, everything, module_id, plugin_id, glob, brace, uri] + extension_status: [active, inactive] + route_match: [hit, miss] + +exclusions: + - { extension_status: inactive, route_match: hit, reason: "비활성 확장은 registry 인덱스에서 제외 → 어떤 라우트도 hit 불가" } + +effects: + # registry 매칭 (target_kind 값별) + - self_target_expands_to_own_extension_prefix_per_group + - all_extensions_matches_extension_routes_only + - core_target_matches_core_routes_only_negative_predicate + - everything_and_star_match_all_routes_including_unnamed + - module_id_and_plugin_id_target_scope_to_that_extension + - raw_glob_target_matches_route_name + - brace_target_expands_to_or_of_names + - uri_pattern_target_matches_path_not_route_name + # groups / timing / 검증 + - groups_array_expands_to_per_group_entries + - empty_or_disallowed_groups_declaration_is_rejected + - missing_targets_declaration_is_rejected + - nonexistent_middleware_class_is_skipped + - before_core_and_after_core_timing_are_separated + - default_timing_is_after_core + - inactive_extension_is_excluded_from_index + - flush_rebuilds_index + # 게이트 실행 + - gate_runs_matched_middleware_via_pipeline + - gate_passes_through_when_no_match + - gate_matched_middleware_can_short_circuit_response + - gate_execution_order_follows_matched_list + - gate_reaches_registry_and_runs_matched_for_unnamed_route + - gate_param_string_parses_group_and_timing_on_real_request + # 번들 이전 정밀도 + - detect_device_declared_and_resolved_for_root_uri_web_only + - pay_ip_guard_matched_for_webhook_routes + - pay_ip_guard_not_matched_for_browser_callback + - gdpr_cookie_consent_declared_everything_before_core + +test_files: + - tests/Feature/Extension/ExtensionMiddlewareRegistryTest.php + - tests/Unit/Http/Middleware/ExtensionMiddlewareGateTest.php + - tests/Feature/Extension/ExtensionMiddlewareGateIntegrationTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Providers/DetectDeviceMiddlewareRegistrationTest.php + - plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Middleware/RestrictKcpIpTest.php + - plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Middleware/InicisNotifyIpWhitelistGateTest.php + - plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Middleware/VbankNotifyIpWhitelistGateTest.php + - plugins/_bundled/sirsoft-gdpr/tests/Unit/PluginMiddlewareDeclarationTest.php + +rules_layer_coverage: + - rule: extension-middleware-declarative-registration + coverage: 번들 SP 의 Kernel 미들웨어 그룹 직접 조작 금지 — 이전 후 무검출, 이전 전 패턴 검출로 회귀 가드 + - rule: extension-route-middleware-alias-reference + coverage: 번들 라우트 파일의 자기 미들웨어 FQCN 부착 금지 — 동일 + - rule: extension-middleware-gate-cache-coverage + coverage: Abstract 에 getMiddleware 존재 시 Manager 에 registry flush 존재 — 배치 완전성은 flush_rebuilds_index effect 가 병행 diff --git a/tests/scenarios/extension-test-isolation.yaml b/tests/scenarios/extension-test-isolation.yaml index 152396df..52cd9b1b 100644 --- a/tests/scenarios/extension-test-isolation.yaml +++ b/tests/scenarios/extension-test-isolation.yaml @@ -23,9 +23,18 @@ description: | ModuleManager::loadModules / PluginManager::loadPlugins (hook listener / config / 채널 등록 경로) 에는 가드를 적용하지 않는다. 그 경로는 fixture 확장을 직접 생성해 loadModules/loadPlugins 를 호출하는 다수의 확장 테스트가 의존하므로 - 가드 적용 시 광범위한 회귀를 유발한다. 격리의 핵심 목적인 전역 미들웨어 - (GDPR CookieConsentMiddleware 등) 차단은 ServiceProvider register 가드만으로 - 충족된다 — 미들웨어는 provider boot 단계에서 등록되기 때문이다. + 가드 적용 시 광범위한 회귀를 유발한다. + + 확장 미들웨어와 self-gate: + 확장 미들웨어(GDPR CookieConsentMiddleware 등)는 더 이상 provider boot 에서 + web/api 그룹에 직접 등록되지 않는다. 코어 ExtensionMiddlewareGate 래퍼가 항상 + web/api 그룹에 등록되고, 각 확장이 getMiddleware() 로 선언한 미들웨어를 요청 + 시점에 대상 매칭될 때만 실행한다. 미들웨어를 실제로 실행하는 후보로 삼으려면 + 확장이 ExtensionMiddlewareRegistry 인덱스에 수집돼야 하며, 인덱스는 활성 확장 + (getActive{Modules,Plugins}) 만 포함한다. 따라서 core-only 테스트는 비활성 GDPR + 미들웨어를 게이트가 실행하지 않고, allowlist 로 GDPR 을 활성화한 테스트에서만 + 게이트가 그 미들웨어를 매칭한다. 격리의 핵심 목적(비대상 확장 미들웨어 미개입)은 + ServiceProvider register 가드 + registry 활성 확장 필터의 이중으로 충족된다. axes: app_env: [testing, production] diff --git a/upgrades/data/7.0.0-beta.5/migrations/05_RecoverPublicStorageSymlink.php b/upgrades/data/7.0.0-beta.5/migrations/05_RecoverPublicStorageSymlink.php index 341310e4..94583580 100644 --- a/upgrades/data/7.0.0-beta.5/migrations/05_RecoverPublicStorageSymlink.php +++ b/upgrades/data/7.0.0-beta.5/migrations/05_RecoverPublicStorageSymlink.php @@ -2,6 +2,7 @@ namespace App\Upgrades\Data\V7_0_0_beta_5\Migrations; +use App\Extension\Helpers\StorageLinkHelper; use App\Extension\Upgrade\DataMigration; use App\Extension\UpgradeContext; @@ -13,12 +14,14 @@ use App\Extension\UpgradeContext; * symlink 가 일반 디렉토리로 변질되는 결함이 잔존한다. 본 step 이 단발성 자동 복구 * (rename 보존 + symlink 재생성) 로 보완한다. * - * 안전 가드 (false positive 차단): - * 1. `is_link($publicStorage)` 가 true → 이미 정상 symlink, silent skip (멱등) - * 2. `! is_dir($publicStorage) || ! is_dir($storageSource)` → Laravel storage:link - * 컨벤션을 따르지 않는 환경, skip - * 3. rename 으로 `.broken.{timestamp}` 백업 후 symlink 재생성 — `rm -rf` 미사용 - * 4. symlink 생성 실패 (Windows 권한 부족 등) → rename 백업을 원위치로 복원 + * 복구 로직은 `StorageLinkHelper::ensurePublicStorageLink()` 로 일원화되어 있다 (#43 에서 + * 코어 업데이트 종료 시점과 공유). 헬퍼가 멱등이며 "부재→재생성" 케이스까지 처리하므로 + * 본 step 의 기존 동작(정상 symlink skip / 손상 디렉토리 rename+재생성 / 실패 원복)은 + * 상위호환으로 유지된다. + * + * V-1 안전: `StorageLinkHelper` 는 `Illuminate\Support\Facades\*` + 네이티브 함수 + 로컬 + * 로직만 사용하는 정적 헬퍼이므로, 이 step 이 이전 버전 프로세스 메모리에서 in-process + * fallback 으로 실행되어도 stale 인스턴스 의존이 없다. */ final class RecoverPublicStorageSymlink implements DataMigration { @@ -29,45 +32,6 @@ final class RecoverPublicStorageSymlink implements DataMigration public function run(UpgradeContext $context): void { - $publicStorage = public_path('storage'); - $storageSource = storage_path('app/public'); - - // 1) 정상 symlink → skip (멱등) - if (is_link($publicStorage)) { - return; - } - - // 2) public/storage 가 일반 디렉토리이면서 Laravel storage:link source 가 존재할 때만 - // 손상 후보로 판정 — 운영자가 Laravel 표준을 따르고 있다는 강한 신호. - // 둘 중 하나라도 부재하면 운영자 의도적 구성으로 간주하여 skip. - if (! is_dir($publicStorage) || ! is_dir($storageSource)) { - return; - } - - // 3) 안전 보존 — rm -rf 대신 rename 으로 백업 후 symlink 재생성. - // false positive (운영자가 의도적으로 일반 디렉토리 사용) 시에도 데이터 손실 없음. - $backup = $publicStorage.'.broken.'.date('YmdHis'); - if (! @rename($publicStorage, $backup)) { - $context->logger->warning('[7.0.0-beta.5] public/storage rename 실패 — 자동 복구 skip', [ - 'path' => $publicStorage, - ]); - - return; - } - - if (! @symlink($storageSource, $publicStorage)) { - // symlink 생성 실패 (Windows SeCreateSymbolicLink 권한 부족 등) → rename 원복 - @rename($backup, $publicStorage); - $context->logger->warning('[7.0.0-beta.5] public/storage symlink 재생성 실패 — rename 원복', [ - 'target' => $storageSource, - ]); - - return; - } - - $context->logger->info('[7.0.0-beta.5] public/storage symlink 자동 복구 완료 — 백업 디렉토리 검증 후 수동 삭제 권장', [ - 'backup' => $backup, - 'target' => $storageSource, - ]); + StorageLinkHelper::ensurePublicStorageLink($context->logger); } }