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 @@
-
+
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 는 네이티브 `