Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5065f1bc9 | ||
|
|
8828c80b56 | ||
|
|
a45e233b97 | ||
|
|
7b7a175c07 | ||
|
|
a7340f21ca | ||
|
|
f157ed1cc9 | ||
|
|
0a6ff23132 | ||
|
|
9251af5c6a | ||
|
|
7598b76498 | ||
|
|
c0e0d82459 | ||
|
|
8b9bfd0c91 | ||
|
|
e896a47d4f | ||
|
|
10fe28a16d |
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 커맨드로 수행
|
||||
```
|
||||
|
||||
@@ -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-16
|
||||
|
||||
### Added
|
||||
|
||||
- 확장 기능이 화면을 그릴 때, 방문자의 접속 기기 종류처럼 요청마다 달라지는 정보를 화면 구성 데이터에 함께 실어 보낼 수 있는 통로를 마련했습니다. 쇼핑몰의 애플페이가 iOS 기기에서만 표시되도록 하는 데 이 통로가 쓰입니다.
|
||||
- 확장 기능이 특정 요청 경로에만 자기 처리 로직을 끼워 넣을 수 있는 표준 연결 방식을 마련했습니다. 각 확장은 자기가 개입할 대상 경로를 명시해 선언하고, 코어가 요청이 들어올 때 대상에 해당하는 확장 로직만 골라 실행합니다. 이를 통해 쿠키 동의 처리나 결제 입금통보의 발신 서버 확인 같은 기능이 필요한 경로에서만 정확히 동작하도록 정리했습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 검색엔진 노출용으로 만들어 내보내는 페이지에 실제로 존재하지 않는 스타일 파일 주소(`/build/assets/app.css`)가 포함되어, 브라우저 콘솔에 404 오류가 남던 문제를 수정했습니다. 이 주소는 화면 표시에 쓰이지 않는 파일을 가리키던 불필요한 참조였으며, 검색엔진 노출용 페이지의 스타일은 템플릿이 제공하는 파일이 그대로 담당하므로 화면에는 영향이 없었습니다. 이제 이 참조를 제거해 오류가 발생하지 않습니다. (#64 @bigmsg 님께서 제보해주셨습니다.)
|
||||
- 코어 업데이트를 `--prune` 옵션으로 실행하면 업로드 파일 연결(`public/storage`)이 삭제되어, 그동안 올린 이미지·첨부 파일이 모두 404로 열리지 않던 문제를 수정했습니다. 이제 `--prune` 업데이트가 이 연결을 지우지 않고 보존하며, 혹시 끊어져 있더라도 업데이트가 끝나는 시점에 자동으로 다시 이어 붙여 파일이 정상적으로 표시됩니다. 관리자 권한으로 업데이트를 실행할 때 이 연결을 다시 이어 붙이면서 소유자가 관리자 계정으로 바뀌던 문제도 함께 바로잡아, 원래 계정 소유가 그대로 유지됩니다. (#43 @bigmsg 님께서 제보해주셨습니다.)
|
||||
|
||||
## [7.0.4] - 2026-07-14
|
||||
|
||||
### Security
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.4-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.5-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Extension;
|
||||
|
||||
/**
|
||||
* 확장 선언 미들웨어의 라우트 매칭 인덱스 계약.
|
||||
*
|
||||
* 활성 확장(모듈/플러그인)이 `getMiddleware()` 로 선언한 미들웨어를 수집해,
|
||||
* 요청 시점에 라우트 이름·URI 를 각 선언의 `targets` 술어와 대조하여 매칭되는
|
||||
* 미들웨어 FQCN 목록을 반환합니다. 코어 self-gate 게이트(`ExtensionMiddlewareGate`)가
|
||||
* 이 계약을 사용해 그룹/timing 별로 매칭 미들웨어를 동적 실행합니다.
|
||||
*
|
||||
* 부팅 시점 라우트 순회로 확장 간 미들웨어를 부착할 수 없는 제약(확장 라우트는
|
||||
* booted 지연 등록 + 순서 미보장)을 요청 시점 매칭으로 원천 회피합니다.
|
||||
* IDV `IdentityPolicyRepository::getRouteScopeIndex()` 미러.
|
||||
*
|
||||
* @since 7.0.5
|
||||
*/
|
||||
interface ExtensionMiddlewareRegistryInterface
|
||||
{
|
||||
/**
|
||||
* 현재 요청에 매칭되는 확장 미들웨어 FQCN 목록을 반환합니다.
|
||||
*
|
||||
* 게이트 래퍼가 자기 그룹·timing 을 전달하면, 그 축의 엔트리 중 라우트명 또는
|
||||
* URI 술어가 매칭되는 것을 확장 로드 순서대로 반환합니다.
|
||||
*
|
||||
* @param string $routeName 현재 요청 라우트 이름 (무명 라우트면 빈 문자열)
|
||||
* @param string $path 현재 요청 URI path (선행 슬래시 없음, 무명 라우트 타게팅용)
|
||||
* @param string $group 요청이 속한 그룹 ('web'|'api')
|
||||
* @param string $timing 실행 시점 ('before_core'|'after_core')
|
||||
* @return array<int, class-string> 매칭된 확장 미들웨어 FQCN (선언 순서 = 확장 로드 순서)
|
||||
*/
|
||||
public function resolveForRoute(string $routeName, string $path, string $group, string $timing): array;
|
||||
}
|
||||
@@ -551,6 +551,37 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 모듈이 등록할 HTTP 미들웨어 선언을 반환합니다.
|
||||
*
|
||||
* 모듈이 web/api 그룹에 미들웨어를 직접 넣는 대신, 코어의 self-gate 게이트
|
||||
* (`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 각 선언의 `targets`
|
||||
* 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행합니다. 코어 IDV 정책의 라우트명
|
||||
* 인덱스 조회 모델과 동일합니다. 미들웨어 클래스 자체는 게이트 로직을 갖지 않아도
|
||||
* 됩니다 (순수하게 유지).
|
||||
*
|
||||
* 기본적으로 빈 배열 반환. 미들웨어가 필요한 모듈만 오버라이드합니다.
|
||||
*
|
||||
* @return array<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
|
||||
* [
|
||||
* [
|
||||
* '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<string, array{expr: string, label: string|array<string, string>}>
|
||||
* 키별 데이터 경로 메타 — 예:
|
||||
* [
|
||||
* '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<string, array{expr: string, label: string|array<string, string>}> 키별 데이터 경로 메타
|
||||
* (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<string, array{expr: string, label: string|array<string, string>}>
|
||||
* 점 경로 키별 데이터 경로 메타 (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
|
||||
{
|
||||
|
||||
@@ -508,6 +508,37 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 플러그인이 등록할 HTTP 미들웨어 선언을 반환합니다.
|
||||
*
|
||||
* 플러그인이 web/api 그룹에 미들웨어를 직접 넣는 대신, 코어의 self-gate 게이트
|
||||
* (`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 각 선언의 `targets`
|
||||
* 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행합니다. 코어 IDV 정책의 라우트명
|
||||
* 인덱스 조회 모델과 동일합니다. 미들웨어 클래스 자체는 게이트 로직을 갖지 않아도
|
||||
* 됩니다 (순수하게 유지).
|
||||
*
|
||||
* 기본적으로 빈 배열 반환. 미들웨어가 필요한 플러그인만 오버라이드합니다.
|
||||
*
|
||||
* @return array<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
|
||||
* [
|
||||
* [
|
||||
* '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(본인인증) 정책 선언을 반환합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 활성 확장의 미들웨어 선언을 수집해 라우트 매칭 인덱스를 캐시로 제공합니다.
|
||||
*
|
||||
* IDV `getRouteScopeIndex` 와 달리, targets 가 정확한 이름이 아니라 glob/brace/URI
|
||||
* 패턴이므로 [정확한이름 => 미들웨어] 해시맵이 아니라 [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<string, list<class-string>>
|
||||
*/
|
||||
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<array{timing: string, group: string, predicates: list<array{op: string, arg?: string}>, 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<array{timing: string, group: string, predicates: list<array{op: string, arg?: string}>, 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<string>, 2: string, 3: list<string>}|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<array{op: string, arg?: string}>
|
||||
*/
|
||||
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<array{op: string, arg: string}>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
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<array{0: object, 1: string, 2: string}>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -33,12 +33,17 @@ class FilePermissionHelper
|
||||
* @param bool $removeOrphans 소스에 없는 대상 파일/디렉토리 삭제 여부
|
||||
* @param bool $preserveTopLevelOrphans 최상위 한 레벨의 orphan 보존 여부
|
||||
* @param array<string, bool>|null $applyList 적용 대상 파일 상대경로 화이트리스트
|
||||
* (이 디렉토리 루트 기준, `'sub/file.txt' => true` 형태의 lookup 맵). null 이면
|
||||
* 전체 파일 복사(기존 동작). 지정 시 이 맵에 없는 파일은 복사를 스킵한다 — 코어
|
||||
* 업데이트 증분 모드에서 "코어가 변경하지 않은 파일"을 건드리지 않기 위함. symlink
|
||||
* 는 이 필터의 영향을 받지 않고 항상 재생성된다(링크는 목록 산출 대상이 아님).
|
||||
* (이 디렉토리 루트 기준, `'sub/file.txt' => true` 형태의 lookup 맵). null 이면
|
||||
* 전체 파일 복사(기존 동작). 지정 시 이 맵에 없는 파일은 복사를 스킵한다 — 코어
|
||||
* 업데이트 증분 모드에서 "코어가 변경하지 않은 파일"을 건드리지 않기 위함. symlink
|
||||
* 는 이 필터의 영향을 받지 않고 항상 재생성된다(링크는 목록 산출 대상이 아님).
|
||||
* @param array<int, string> $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<int, string> $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() 보다 먼저 평가되어야 한다.
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Helpers;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* `public/storage` symlink 멱등 복구 헬퍼.
|
||||
*
|
||||
* 코어 업데이트(특히 `--prune`) 이후 `public/storage` symlink 가 부재/손상 상태로 남는
|
||||
* 결함(#43)을 종료 시점에 방어 복구한다. `is_link` 이면 no-op, 부재/일반 디렉토리이면
|
||||
* `storage/app/public` 을 가리키는 symlink 를 (필요 시 백업 후) 재생성한다.
|
||||
*
|
||||
* V-1 안전: 이 헬퍼는 beta.5 DataMigration `RecoverPublicStorageSymlink` 가 in-process
|
||||
* fallback 으로 호출할 수 있으므로, `Illuminate\Support\Facades\*` 파사드 + 네이티브
|
||||
* 함수 + 로컬 로직만 사용한다 (신규 Service/Manager/Repository 메서드 의존 금지).
|
||||
*
|
||||
* 로거 주입: 두 호출자의 로깅 채널이 다르다 — migration 은 `$context->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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pipeline\Pipeline;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 확장 선언 미들웨어의 self-gate 실행기.
|
||||
*
|
||||
* 확장이 getMiddleware() 로 선언한 미들웨어를 web/api 그룹에 직접 넣는 대신,
|
||||
* 이 래퍼가 그룹에 등록되어 요청 시점에 현재 라우트 이름·URI 가 각 확장 미들웨어의
|
||||
* targets 패턴에 매칭될 때만 해당 미들웨어를 동적으로 실행한다. 매칭 없으면
|
||||
* 즉시 통과 (코어 EnforceIdentityPolicy 의 라우트명 인덱스 조회 모델과 동일).
|
||||
*
|
||||
* 부팅 시점 라우트 순회로 확장 간 미들웨어를 부착할 수 없는 제약(확장 라우트는
|
||||
* booted 지연 등록 + 순서 미보장)을 요청 시점 매칭으로 원천 회피한다.
|
||||
*
|
||||
* bootstrap/app.php 에서 web/api × before_core/after_core 로 총 4회 등록된다.
|
||||
* Kernel 상 prepend/append 위치는 등록 시점 매핑이고, 게이트 실행 로직은 timing 을 안다.
|
||||
*
|
||||
* @since 7.0.5
|
||||
*/
|
||||
class ExtensionMiddlewareGate
|
||||
{
|
||||
/**
|
||||
* @param Application $app Pipeline 실행용 컨테이너
|
||||
* @param ExtensionMiddlewareRegistryInterface $registry 확장 미들웨어 매칭 인덱스
|
||||
*/
|
||||
public function __construct(
|
||||
protected Application $app,
|
||||
protected ExtensionMiddlewareRegistryInterface $registry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 미들웨어 진입점.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @param Closure $next 다음 파이프라인
|
||||
* @param string $group 이 래퍼가 등록된 그룹 ('web'|'api')
|
||||
* @param string $timing 이 래퍼 인스턴스가 담당하는 실행 시점 ('before_core'|'after_core')
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string $group, string $timing): Response
|
||||
{
|
||||
// 라우트명(무명이면 빈 문자열)과 URI path(항상 존재, 선행 슬래시 없음) 둘 다 전달 —
|
||||
// 무명 SSR 셸 catch-all 은 URI 패턴 target 으로만 매칭되므로 routeName 빈 값에 조기 통과하지 않는다.
|
||||
$routeName = $request->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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 컨텍스트를 구성합니다.
|
||||
*
|
||||
|
||||
@@ -904,7 +904,7 @@ class CoreUpdateService
|
||||
* @param \Closure|null $onProgress 진행 콜백
|
||||
* @param bool $prune true 면 전체 덮어쓰기 + orphan 삭제(기존 동작), false 면 증분 적용
|
||||
* @param array<int, string>|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 — 증분 모드에서는 적용 목록에 있을 때만 복사.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('APP_VERSION', '7.0.4'),
|
||||
'version' => env('APP_VERSION', '7.0.5'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
#### 동적 권한/역할/메뉴 보존 규칙
|
||||
|
||||
@@ -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. 라우트 파일 작성
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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. 데이터 생성 위치 — 책임 분리 매트릭스
|
||||
|
||||
| 데이터 종류 | 책임 영역 | 위치 | 호출 |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.4] - 2026-07-14
|
||||
## [1.0.4] - 2026-07-16
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -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-16
|
||||
|
||||
### Added
|
||||
|
||||
- 주문설정 결제수단 목록의 "PG 고정" 표시에 대한 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.0.3] - 2026-07-14
|
||||
|
||||
### Added
|
||||
|
||||
+2
-1
@@ -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": "無通帳口座番号設定",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [Unreleased]
|
||||
## [1.0.1] - 2026-07-16
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_kginicis",
|
||||
|
||||
@@ -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] - 2026-07-16
|
||||
|
||||
### 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_*`) 일본어 번역 추가 — 테스트결제 상태 안내와 실결제 설정 이동 버튼이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'action' => [
|
||||
'cancel' => 'PG決済キャンセル',
|
||||
],
|
||||
'description' => [
|
||||
'payment_cancel' => 'NHN KCP決済キャンセルが確認されました(注文 :order_number、返金金額 :refund_amount円)。',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'refund' => [
|
||||
'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)',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'payco' => [
|
||||
'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デバイスのモバイル決済のみ利用可能です。',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'NHN KCP',
|
||||
'description' => 'NHN KCP決済ゲートウェイ(Standard Pay連動)— カード·口座振替·仮想口座·携帯電話決済対応',
|
||||
];
|
||||
@@ -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": "簡便決済"
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "NHN KCP",
|
||||
"description": "NHN KCP 決済ゲートウェイ (Standard Pay 連携)"
|
||||
}
|
||||
@@ -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] - 2026-07-16
|
||||
|
||||
### Added
|
||||
|
||||
- 가상계좌 환불 검증 안내(`errors.vbank_refund_*` — 환불 대상 주문 없음·환불 불가 결제·환불 금액 불일치·환불 처리 진행 중) 일본어 번역 추가 — 관리자 가상계좌 환불 요청 검증 실패 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 주문 설정 화면의 나이스페이먼츠 테스트모드 경고(`nicepay_test_mode_status`, `test_mode_settings_warning_*`) 일본어 번역 추가 — 테스트결제 상태 안내와 실결제 설정 이동 버튼이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'action' => [
|
||||
'cancel' => 'PG決済キャンセル',
|
||||
],
|
||||
'description' => [
|
||||
'payment_cancel' => 'NICEペイメンツ決済キャンセルが確認されました (注文 :order_number、返金金額 :refund_amount円)。',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'errors' => [
|
||||
'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' => '仮想口座返金',
|
||||
],
|
||||
];
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'naverpay' => [
|
||||
'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で決済 (ナイスペイメンツ)',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'ナイスペイメンツ',
|
||||
'description' => '国内PG企業ナイスペイメンツ決済連携(カード/口座振替/仮想口座/携帯電話 + 7種簡便決済)',
|
||||
];
|
||||
@@ -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": "決済承認に失敗しました。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "NICE Payments",
|
||||
"description": "NICE Payments 決済ゲートウェイ (統合決済ウィンドウ連携)"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- 네이버페이·카카오페이 등 결제 플러그인이 제공하는 간편결제로 주문할 때, 결제가 실패했는데도 관리자에게 "신규 주문 접수" 알림이 발송되던 문제를 수정했습니다. 이제 다른 카드 결제와 마찬가지로 결제가 실제로 완료된 시점에만 알림이 발송됩니다.
|
||||
- 간편결제로 결제하다 실패한 뒤 다시 결제하려 하면 "임시주문을 찾을 수 없습니다" 오류가 뜨며 재결제가 불가능하던 문제를 수정했습니다. 이제 결제 실패 후에도 주문서가 유지되어 결제수단을 바꾸거나 다시 시도할 수 있습니다.
|
||||
- 간편결제 주문을 취소할 때 카드 취소(PG 취소)가 함께 이뤄지지 않고 주문 상태만 취소로 바뀔 수 있던 문제를 수정했습니다.
|
||||
- 주문 목록의 결제수단 필터와 주문서의 결제수단 검증이 간편결제를 인식하지 못하던 문제를 수정했습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 결제 플러그인이 제공하는 간편결제도 신용카드·가상계좌와 동일하게 정식 결제수단으로 취급됩니다. 주문 내역에 실제로 선택한 결제수단(예: 네이버페이)이 그대로 기록되어 관리자 주문 상세와 주문 목록에서 확인할 수 있습니다.
|
||||
- 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 불필요"가 아니라 "PG 고정"으로 표시되고 어느 결제대행사를 통해 처리되는지 함께 보여줍니다. 간편결제는 해당 결제대행사 전용이므로 다른 대행사로 바꿀 수 없습니다.
|
||||
- 주문서의 간편결제 버튼에 표시되는 브랜드 마크(네이버페이·카카오페이·삼성페이 등의 색상 배지와 로고)를 표준화했습니다. 이전에는 결제 플러그인이 화면이 그려진 뒤 버튼 모양을 덧입히는 방식이라 여러 결제사가 함께 켜져 있을 때 드물게 표시가 겹치거나 어긋날 수 있었는데, 이제 결제수단 정보에 담긴 브랜드 마크를 주문서가 처음부터 직접 그리도록 바꿔 표시가 안정적입니다.
|
||||
- 애플페이 간편결제 버튼이 iOS 기기(iPhone·iPad)에서만 주문서에 표시되도록 했습니다. 애플페이는 iOS에서만 결제할 수 있으므로 다른 기기에서는 버튼을 감춰 혼선을 줄입니다.
|
||||
- 배송 국가 해석·비회원 주문 접근 확인 등 요청 경로별 처리를 코어의 새로운 표준 연결 방식으로 옮겼습니다. 동작은 이전과 동일합니다.
|
||||
- 위 개선을 위해 코어 최소 요구 버전을 7.0.5로 상향했습니다. 쇼핑몰 모듈을 업데이트하기 전에 코어를 7.0.5 이상으로 먼저 업데이트해 주세요.
|
||||
|
||||
## [1.0.3] - 2026-07-14
|
||||
|
||||
### Security
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(['신한은행', '국민은행', '우리은행']),
|
||||
|
||||
@@ -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` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
| 이름 | 타입 | 조건 | 용도 |
|
||||
| --- | --- | --- | --- |
|
||||
| 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` 필드:
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
| 이름 | 타입 | 용도 |
|
||||
| --- | --- | --- |
|
||||
| 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` 가 결제수단 카탈로그에 없는 값이면 여기서 차단된다 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
|
||||
@@ -3570,3 +3570,33 @@ _대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있
|
||||
**설명** 인증 없이 접근 가능한 공개 엔드포인트로, 체크아웃에서 필요한 배송 설정을 반환합니다. `EcommerceSettingsService::getSettings('shipping')`로 기본 배송 국가·이용 가능한 국가 목록·국제 배송 활성화 여부·배송 타입·무료 배송 설정 등을 `shipping` 으로 내려줍니다. 프론트가 배송지 선택과 배송비 안내를 구성하는 데 사용하며, `logApiUsage('settings.shipping')`로 사용 로그를 남깁니다.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 결제수단 카탈로그 (`order_settings.payment_methods[]`)
|
||||
|
||||
결제수단 카탈로그는 코어 8종(builtin)과 PG 플러그인이 훅으로 등록한 확장 결제수단을 함께 담는다.
|
||||
설정 조회/저장 응답의 `order_settings.payment_methods` 배열 각 항목이 이 구조를 따른다.
|
||||
|
||||
아래 능력(capability) 필드는 **정의(builtin 은 코어, 확장은 플러그인 선언)가 SSoT** 이며 저장된 값으로
|
||||
덮이지 않는다 — 관리자가 편집하는 값이 아니라 결제수단의 성격이기 때문이다.
|
||||
|
||||
| 이름 | 타입 | 용도 |
|
||||
| --- | --- | --- |
|
||||
| id | string | 결제수단 ID. builtin(`card` / `vbank` / `dbank` / `bank` / `phone` / `point` / `deposit` / `free`) 또는 확장 ID(예: `nhnkcp_naverpay`, `kginicis_lpay`) |
|
||||
| needs_pg | boolean | PG 결제창이 필요한 수단인지. `false` 면 관리자 화면에 "PG 불필요" 로 표시되고 주문도 PG 를 거치지 않는다 (무통장·포인트·예치금·무료) |
|
||||
| pg_locked | boolean | PG 가 특정 대행사로 고정된 수단인지. 간편결제처럼 특정 PG 전용인 수단은 `true` 이며, 관리자가 PG 를 바꿀 수 없고 화면에는 "PG 고정" 배지로 표시된다. `true` 일 때 `pg_provider` 는 저장값 대신 플러그인 선언값이 강제된다 |
|
||||
| refund_method | string | 환불 수단 분류 (`pg` / `bank` / `points`). 주문 취소 시 PG 취소를 호출할지 결정한다 |
|
||||
| pg_provider | string\|null | 이 결제수단을 처리할 PG. `pg_locked=true` 면 플러그인이 선언한 PG 로 고정되고, 아니면 관리자가 선택한다 (미선택 시 `default_pg_provider` 로 폴백) |
|
||||
| is_active | boolean | 주문서에 노출할지 여부 |
|
||||
| min_order_amount | number | 이 결제수단을 쓸 수 있는 최소 주문금액 |
|
||||
| stock_deduction_timing | string | 재고 차감 시점 (`order_placed` / `payment_complete` / `none`) |
|
||||
| mileage_deduction_timing | string | 마일리지 차감 시점 |
|
||||
| sort_order | number | 주문서 노출 순서 |
|
||||
| _cached_name / _cached_description | object | 다국어 표시명/설명 (locale => 문자열) |
|
||||
| _cached_icon | string | 표시 아이콘 |
|
||||
| _cached_source | string | 제공 주체 (`builtin` 또는 `plugin:{식별자}`) |
|
||||
|
||||
확장 결제수단(간편결제)은 코어의 `card` 로 치환되지 않고 자기 ID 그대로 저장·조회된다.
|
||||
주문 생성 시 `payment_method` 로 확장 ID 를 그대로 보내면 되며, 서버는 이 카탈로그를 화이트리스트로
|
||||
사용해 검증한다.
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 요청별 배송국가 해석 (ResolveShippingCountry 미들웨어)
|
||||
|
||||
위 두 엔드포인트는 회원이 **저장해 둔** 선호 배송국가를 조회·저장한다. 이와 별개로, 상품 목록·장바구니·결제(`products/*`, `cart/*`, `checkout/*`)와 비회원 주문 생성(`user/orders`) 요청은 요청 시점의 배송국가를 해석하는 `ResolveShippingCountry` 미들웨어를 거친다. 이 미들웨어는 로그인 회원의 저장값·요청 파라미터·기본 배송국가를 순서대로 반영해 이후 배송비/배송가능 여부 계산의 기준 국가를 확정한다.
|
||||
|
||||
이 미들웨어는 코어의 확장 미들웨어 self-gate(`Module::getMiddleware()` 의 `targets` 로 위 라우트명에만 정밀 타게팅)로 부착된다. 라우트 파일에서 직접 부착하던 이전 방식과 적용 대상·동작은 동일하다. 상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../../../../../docs/backend/middleware.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /api/modules/sirsoft-ecommerce/user/shipping-country
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.user.shipping-country.show -->
|
||||
|
||||
@@ -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": {}
|
||||
|
||||
@@ -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<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
|
||||
*/
|
||||
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 변수 메타데이터 정의
|
||||
*
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+101
@@ -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 테이블) 구조 검증 ───
|
||||
|
||||
+5
-3
@@ -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}}');
|
||||
});
|
||||
|
||||
+10
-4
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+18
-4
@@ -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"
|
||||
|
||||
+18
-4
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -24,6 +24,13 @@ class DeviceDetector
|
||||
*/
|
||||
private const MOBILE_UA_KEYWORDS = ['Mobile', 'Android', 'iPhone', 'iPad', 'iPod'];
|
||||
|
||||
/**
|
||||
* iOS User-Agent 키워드 (iPhone/iPad/iPod)
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-5
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 요청 User-Agent 로 iOS 기기 여부를 결정해 컨테이너에 보관하는 미들웨어.
|
||||
*
|
||||
* SetTimezone / ResolveShippingCountry 패턴(App::instance + static accessor)을 미러한다.
|
||||
* web 그룹(SSR 셸 렌더 경로)에 적용되며, InjectAppConfigDeviceListener 가 코어의
|
||||
* `core.frontend.filter_app_config` 필터 훅에서 이 값을 읽어 appConfig.isIos 로 주입한다.
|
||||
* 프론트는 window.G7Config.appConfig → _global.appConfig.isIos 로 받는다.
|
||||
*
|
||||
* 서버 UA 판정은 데스크탑 UA 를 보내는 iPadOS 를 놓칠 수 있으므로, 템플릿 부트스트랩이
|
||||
* 클라이언트 신호(maxTouchPoints/userAgentData)로 이를 보정한다.
|
||||
*/
|
||||
class DetectDevice
|
||||
{
|
||||
/**
|
||||
* 애플리케이션 컨테이너에 저장되는 iOS 여부 키
|
||||
*/
|
||||
public const IS_IOS_KEY = 'ecommerce_request_is_ios';
|
||||
|
||||
/**
|
||||
* 들어오는 요청의 iOS 여부를 판별해 컨테이너에 보관합니다.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @param Closure $next 다음 미들웨어 또는 요청 핸들러
|
||||
* @return Response HTTP 응답
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
App::instance(
|
||||
self::IS_IOS_KEY,
|
||||
DeviceDetector::isIosFromUserAgent($request->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;
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -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')
|
||||
|
||||
@@ -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'],
|
||||
|
||||
+16
-3
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
// 배송 메모
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
|
||||
// 배송 (첫 번째)
|
||||
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice;
|
||||
|
||||
/**
|
||||
* 요청 기기 유형(iOS 여부)을 프론트엔드 appConfig 에 주입하는 리스너.
|
||||
*
|
||||
* 코어 `core.frontend.filter_app_config` 필터 훅을 구독해, DetectDevice 미들웨어가
|
||||
* 컨테이너에 보관한 iOS 판정값을 `appConfig.isIos` 로 주입한다. 프론트는
|
||||
* window.G7Config.appConfig → _global.appConfig.isIos 로 받아 체크아웃 레이아웃의
|
||||
* 애플페이 iOS 게이팅에 활용한다.
|
||||
*
|
||||
* 코어는 config/frontend.php 정적 값만 노출하므로, 요청별로 달라지는 값(기기 유형)은
|
||||
* 이 훅을 통해 이커머스 모듈이 채운다.
|
||||
*/
|
||||
class InjectAppConfigDeviceListener implements HookListenerInterface
|
||||
{
|
||||
/**
|
||||
* 구독할 훅 목록 반환
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed> $appConfig 코어가 조립한 프론트엔드 appConfig
|
||||
* @return array<string, mixed> isIos 가 추가된 appConfig
|
||||
*/
|
||||
public function injectDeviceFlags(array $appConfig): array
|
||||
{
|
||||
$appConfig['isIos'] = DetectDevice::isIos();
|
||||
|
||||
return $appConfig;
|
||||
}
|
||||
}
|
||||
@@ -404,7 +404,7 @@ class Order extends Model
|
||||
*/
|
||||
public function getPaymentMethodLabel(): ?string
|
||||
{
|
||||
return $this->payment?->payment_method?->getLabel();
|
||||
return $this->payment?->paymentMethodLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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_* 유지
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Services;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
|
||||
|
||||
/**
|
||||
* 결제수단 능력(capability) 해석기
|
||||
*
|
||||
* "이 결제수단이 PG 를 쓰는가 / 라벨이 뭔가 / 환불수단이 뭔가" 를 답하는 단일 SSoT.
|
||||
*
|
||||
* 코어 8종(card/vbank/dbank/bank/phone/point/deposit/free)은 `PaymentMethodEnum` 이
|
||||
* 정체성을 갖지만, PG 플러그인이 `filter_available_payment_methods` 훅으로 등록하는
|
||||
* 간편결제 수단(예: `nhnkcp_naverpay`)은 enum case 가 없어 enum 메서드로는 능력을
|
||||
* 물을 수 없다. 그래서 능력 질의를 **결제수단 설정 카탈로그**(`order_settings.payment_methods`)
|
||||
* 기준으로 옮긴다 — 카탈로그는 builtin + 플러그인 등록분을 모두 포함하므로 확장 ID 도
|
||||
* 자연스럽게 답이 나온다.
|
||||
*
|
||||
* 폴백 순서가 핵심이다:
|
||||
*
|
||||
* 카탈로그 선언 → enum(builtin) → 안전한 기본값
|
||||
*
|
||||
* 카탈로그에 선언이 없으면 enum 으로, enum 에도 없으면 기본값으로 내려간다. 그래서
|
||||
* 플러그인이 신규 키를 선언하지 않아도 기존 동작이 유지되고(하위호환), 선언하면 그
|
||||
* 값이 이긴다.
|
||||
*
|
||||
* 싱글톤으로 등록되어 요청 내 카탈로그 조회를 1회로 캐시한다.
|
||||
*
|
||||
* @see https://github.com/gnuboard/dev-g7/issues/475
|
||||
*/
|
||||
class PaymentMethodResolver
|
||||
{
|
||||
/**
|
||||
* 요청 내 결제수단 카탈로그 캐시 (id => config). null = 아직 미조회
|
||||
*
|
||||
* @var array<string, array<string, mixed>>|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<int, string> 유효한 결제수단 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<string, array<string, mixed>> 결제수단 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;
|
||||
}
|
||||
}
|
||||
@@ -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'])
|
||||
|
||||
+84
@@ -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 연동 없이 취소가 처리된다.
|
||||
*/
|
||||
|
||||
+115
@@ -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. 비회원 주문 생성 시 조회 비밀번호가 해시로 저장된다 (평문 미저장)
|
||||
*/
|
||||
|
||||
+554
@@ -0,0 +1,554 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Payment;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\TempOrderFactory;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\PromotionsSummary;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\Summary;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\CreateOrderRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderCancellationService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 확장 결제수단(간편결제) 1급화 회귀 테스트 — 이슈 #475
|
||||
*
|
||||
* PG 플러그인이 `filter_available_payment_methods` 로 등록하는 간편결제 수단
|
||||
* (예: `nhnkcp_naverpay`) 은 코어 `PaymentMethodEnum` 에 case 가 없다. 이 때문에
|
||||
* 서버가 해당 주문을 "PG 결제가 아닌 주문" 으로 오인하여 두 결함이 발생했다:
|
||||
*
|
||||
* 1. 결제 실패했는데 관리자에게 "신규 주문 접수" 알림이 발송됨
|
||||
* 2. 결제 실패 후 TempOrder 가 즉시 삭제되어 재결제 불가 ("임시주문을 찾을 수 없습니다")
|
||||
*
|
||||
* 두 결함은 하나의 원인이다 — 확장 결제수단의 "능력(PG 필요 여부 / 라벨 / 환불수단)" 을
|
||||
* enum 이 답하지 못한다. 본 테스트는 능력 해석이 결제수단 설정 카탈로그를 SSoT 로
|
||||
* 삼도록 전환된 뒤 green 이 된다.
|
||||
*
|
||||
* @see https://github.com/gnuboard/dev-g7/issues/475
|
||||
*/
|
||||
class ExtensionPaymentMethodRegressionTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* PG 플러그인이 등록하는 확장 결제수단 ID (NHN KCP 네이버페이).
|
||||
*/
|
||||
private const EXT_METHOD = 'nhnkcp_naverpay';
|
||||
|
||||
/**
|
||||
* 확장 결제수단이 소속된 PG.
|
||||
*/
|
||||
private const EXT_PG = 'nhnkcp';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
+208
@@ -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 는 네이티브 `<select>` 가 아니라 템플릿의 Select 컴포넌트(button 렌더)다.
|
||||
* 태그로 찾지 말고 `data-testid` (`pg-select-{id}` / `pg-locked-badge-{id}` / `pg-not-required-{id}`)
|
||||
* 로 분기 결과를 조회한다 — 컴포넌트 내부 구현이 바뀌어도 이 spec 은 유지된다.
|
||||
*
|
||||
* @scenario extension_payment_method method_kind=extension × capability_declared=declared × capability=pg_locked
|
||||
* @effects admin_shows_pg_locked_badge,
|
||||
* admin_hides_pg_select_for_locked,
|
||||
* admin_shows_pg_select_for_unlocked,
|
||||
* saved_null_pg_provider_self_healed
|
||||
*/
|
||||
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
const ORDER_SETTINGS_URL = '/admin/ecommerce/settings?tab=order_settings';
|
||||
|
||||
interface CatalogMethod {
|
||||
id: string;
|
||||
pg_provider: string | null;
|
||||
pg_locked?: boolean;
|
||||
needs_pg?: boolean;
|
||||
}
|
||||
|
||||
/** 폼에 시드된 결제수단 카탈로그 (화면이 실제로 그리는 원본). */
|
||||
async function catalogMethods(page: Page): Promise<CatalogMethod[]> {
|
||||
return page.evaluate(() => {
|
||||
const form = (window as any).G7Core?.state?.getLocal?.()?.form;
|
||||
return (form?.order_settings?.payment_methods ?? []) as CatalogMethod[];
|
||||
});
|
||||
}
|
||||
|
||||
/** 설치된 PG 제공자 목록 (id → 표시명 도출용). */
|
||||
async function pgProviders(page: Page): Promise<Array<{ id: string; name: unknown }>> {
|
||||
return page.evaluate(() => (window as any).G7Core?.state?.getLocal?.()?.form?.available_pg_providers ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* PG 제공자의 화면 표시명을 도출한다.
|
||||
*
|
||||
* `name` 은 활성 로케일로 이미 해석된 문자열이거나(현행), 로케일별 객체일 수 있다.
|
||||
* 두 형태를 모두 받아 화면에 실제로 찍히는 문자열을 돌려준다 — 어느 쪽이든 spec 은 유지된다.
|
||||
*
|
||||
* @param provider available_pg_providers 항목 (미설치 PG 면 undefined)
|
||||
* @param fallback 제공자를 못 찾았을 때 쓸 값 (결제수단의 pg_provider ID)
|
||||
* @returns 배지에 표시될 것으로 기대되는 문자열
|
||||
*/
|
||||
function displayName(provider: { name?: unknown } | undefined, fallback: string | null): string {
|
||||
const name = provider?.name;
|
||||
if (typeof name === 'string' && name.trim()) return name;
|
||||
if (name && typeof name === 'object') {
|
||||
const localized = Object.values(name as Record<string, string>).find(Boolean);
|
||||
if (localized) return localized;
|
||||
}
|
||||
return String(fallback ?? '');
|
||||
}
|
||||
|
||||
async function gotoOrderSettings(page: Page, token: string): Promise<void> {
|
||||
await authenticatePage(page, token);
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await page.goto(ORDER_SETTINGS_URL);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
// 결제수단 표가 시드될 때까지 대기 (_local.form 시드 → DOM 렌더)
|
||||
await expect.poll(async () => (await catalogMethods(page)).length, { timeout: 30_000 }).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
test.describe('@sirsoft-ecommerce 주문설정 결제수단 — PG 고정 표시', () => {
|
||||
// @scenario extension_payment_method:pg_locked_badge
|
||||
// @effects admin_shows_pg_locked_badge, admin_hides_pg_select_for_locked, saved_null_pg_provider_self_healed
|
||||
test('PG 고정 수단(간편결제)은 PG 셀렉트 대신 "PG 고정 · {PG명}" 배지로 표시된다', async ({
|
||||
page,
|
||||
settingsToken,
|
||||
}) => {
|
||||
await gotoOrderSettings(page, settingsToken);
|
||||
|
||||
const methods = await catalogMethods(page);
|
||||
const locked = methods.filter((m) => m.pg_locked === true);
|
||||
|
||||
test.skip(
|
||||
locked.length === 0,
|
||||
'PG 고정 결제수단이 없는 환경 (간편결제 플러그인 미설치) — 판별 불가',
|
||||
);
|
||||
|
||||
// 자가 치유 검증: 저장 파일에 pg_provider=null 이 남아 있어도 정의값이 강제되어야 한다.
|
||||
for (const method of locked) {
|
||||
expect(
|
||||
method.pg_provider,
|
||||
`${method.id} 는 PG 고정 수단이므로 pg_provider 가 비어 있으면 안 된다 (저장된 null 이 정의값을 이기면 #475 재발)`,
|
||||
).toBeTruthy();
|
||||
expect(method.needs_pg, `${method.id} 는 PG 결제 수단이어야 한다`).toBe(true);
|
||||
}
|
||||
|
||||
const providers = await pgProviders(page);
|
||||
|
||||
for (const method of locked) {
|
||||
const providerName = displayName(
|
||||
providers.find((p) => p.id === method.pg_provider),
|
||||
method.pg_provider,
|
||||
);
|
||||
|
||||
// ① PG 고정 배지가 뜨고, 그 안에 PG 이름이 들어 있어야 한다.
|
||||
// ("PG 불필요" 로 표시되면 #475 회귀 — 배지 자체가 없으므로 아래 단언이 실패한다.)
|
||||
const badge = page.getByTestId(`pg-locked-badge-${method.id}`);
|
||||
await expect(
|
||||
badge,
|
||||
`${method.id} 는 PG 고정 수단이므로 PG 고정 배지가 떠야 한다`,
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
badge,
|
||||
`${method.id} 의 배지에 PG 이름(${providerName})이 표시돼야 한다`,
|
||||
).toContainText(String(providerName));
|
||||
|
||||
// ② PG 고정 수단에는 관리자가 PG 를 바꿀 수 있는 셀렉트가 없어야 한다.
|
||||
await expect(
|
||||
page.getByTestId(`pg-select-${method.id}`),
|
||||
`${method.id} 는 PG 가 고정이므로 PG 선택 셀렉트가 뜨면 안 된다`,
|
||||
).toHaveCount(0);
|
||||
|
||||
// ③ PG 고정과 "PG 불필요" 는 상호배타 — 둘 다 뜨면 표시가 모순된다.
|
||||
await expect(
|
||||
page.getByTestId(`pg-not-required-${method.id}`),
|
||||
`${method.id} 가 "PG 불필요" 로 표시되면 #475 회귀`,
|
||||
).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
// @scenario extension_payment_method:pg_select_for_unlocked
|
||||
// @effects admin_shows_pg_select_for_unlocked
|
||||
test('일반 PG 수단(카드 등)은 PG 선택 셀렉트가 그대로 노출된다 (회귀 차단)', async ({
|
||||
page,
|
||||
settingsToken,
|
||||
}) => {
|
||||
await gotoOrderSettings(page, settingsToken);
|
||||
|
||||
const methods = await catalogMethods(page);
|
||||
const selectable = methods.filter((m) => !m.pg_locked && m.needs_pg);
|
||||
|
||||
test.skip(selectable.length === 0, 'PG 선택 가능한 결제수단이 없는 환경 — 판별 불가');
|
||||
|
||||
const providers = await pgProviders(page);
|
||||
test.skip(providers.length === 0, '설치된 PG 플러그인이 없어 셀렉트가 렌더되지 않는 환경');
|
||||
|
||||
// PG 고정 분기가 과잉 적용돼 일반 PG 수단의 셀렉트까지 삼키는 회귀를 차단한다.
|
||||
for (const method of selectable) {
|
||||
await expect(
|
||||
page.getByTestId(`pg-select-${method.id}`),
|
||||
`${method.id} 는 관리자가 PG 를 고를 수 있어야 하므로 PG 선택 셀렉트가 떠야 한다`,
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`pg-locked-badge-${method.id}`),
|
||||
`${method.id} 는 PG 고정이 아니므로 PG 고정 배지가 뜨면 안 된다`,
|
||||
).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
// @scenario extension_payment_method:no_pg_required
|
||||
// @effects admin_shows_pg_select_for_unlocked
|
||||
test('PG 불필요 수단(무통장·포인트 등)은 셀렉트도 배지도 없이 "PG 불필요"로 남는다', async ({
|
||||
page,
|
||||
settingsToken,
|
||||
}) => {
|
||||
await gotoOrderSettings(page, settingsToken);
|
||||
|
||||
const methods = await catalogMethods(page);
|
||||
const noPg = methods.filter((m) => m.needs_pg === false);
|
||||
|
||||
test.skip(noPg.length === 0, 'PG 불필요 결제수단이 없는 환경 — 판별 불가');
|
||||
|
||||
for (const method of noPg) {
|
||||
// PG 불필요 수단은 PG 고정도 아니어야 한다 (두 분기가 겹치면 표시가 모순된다).
|
||||
expect(method.pg_locked ?? false, `${method.id} 는 PG 불필요이므로 PG 고정일 수 없다`).toBe(false);
|
||||
|
||||
// 화면에도 "PG 불필요" 로만 남고, 셀렉트/배지 어느 쪽도 뜨지 않아야 한다.
|
||||
await expect(
|
||||
page.getByTestId(`pg-not-required-${method.id}`),
|
||||
`${method.id} 는 "PG 불필요" 로 표시돼야 한다`,
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId(`pg-select-${method.id}`),
|
||||
`${method.id} 는 PG 가 필요 없으므로 PG 선택 셀렉트가 뜨면 안 된다`,
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId(`pg-locked-badge-${method.id}`),
|
||||
`${method.id} 는 PG 가 필요 없으므로 PG 고정 배지가 뜨면 안 된다`,
|
||||
).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
+13
-1
@@ -164,7 +164,19 @@ test.describe('@sirsoft-ecommerce 배송국가 다국어', () => {
|
||||
const locales = Object.keys(localeNames);
|
||||
expect(locales.length, '설치된 언어를 못 읽었다').toBeGreaterThan(0);
|
||||
|
||||
await page.getByRole('button', { name: /Add Country|국가 추가|国追加/ }).click();
|
||||
// "국가 추가" 버튼은 해외배송이 켜져 있을 때만 렌더된다
|
||||
// (_tab_shipping.json: if "...international_shipping_enabled"). 시드 상태에서 꺼져
|
||||
// 있을 수 있으므로, 다국어 입력칸 검증이 목적인 이 테스트에서는 해외배송을
|
||||
// 로컬 상태로 켠 뒤 진행한다(토글 UI 자체 검증은 별도 spec 소관).
|
||||
await page.evaluate(() => {
|
||||
(window as any).G7Core?.state?.setLocal?.({ 'form.shipping.international_shipping_enabled': true });
|
||||
});
|
||||
|
||||
const addCountryButton = page.getByRole('button', { name: /Add Country|국가 추가|国追加/ });
|
||||
await expect(addCountryButton, '해외배송 활성 후 국가 추가 버튼이 노출돼야 한다').toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await addCountryButton.click();
|
||||
|
||||
// 국가 코드 입력칸(placeholder="KR")이 있는 행이 곧 추가 폼이다.
|
||||
const codeInput = page.locator('input[placeholder="KR"]');
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 체크아웃 간편결제 브랜드 마크 데이터 기반 렌더 + 애플페이 iOS 게이팅.
|
||||
*
|
||||
* 3사(kginicis/nhnkcp/nicepayments) 동시 활성 체크아웃에서, 각 간편결제 버튼의 브랜드 마크
|
||||
* (색 배지 / 브랜드 SVG 로고)가 카탈로그 _cached_brand_mark 데이터로 렌더되고(injector 후처리
|
||||
* 없이), 애플페이는 iOS 기기에서만 노출되는지 검증. 템플릿 sirsoft-basic (유저 화면).
|
||||
* (skeleton, placeholder)
|
||||
*
|
||||
* @effects badge_renders_text_and_color,
|
||||
* svg_renders_inline_logo,
|
||||
* svg_dangerous_content_sanitized,
|
||||
* none_form_falls_back_to_icon,
|
||||
* applepay_shown_on_ios,
|
||||
* applepay_hidden_on_non_ios,
|
||||
* non_ios_methods_unaffected,
|
||||
* injectors_removed
|
||||
*
|
||||
* e2e:allow 브랜드 마크 렌더/게이팅 — 카탈로그→레이아웃 계약을 단위/렌더/리소스 테스트로 1차 차단하고,
|
||||
* 브라우저 회귀는 본 placeholder(test.describe.skip)가 data-testid 보강 + 3-PG 동시활성 시드 후 활성화될 때 검증한다.
|
||||
* 현재 커버리지:
|
||||
* (1) 카탈로그 보강 — modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsOrderSettingsTest.php
|
||||
* (brand_mark→_cached_brand_mark 병합, requires_ios 전달, 마크 없는 builtin=null) green.
|
||||
* (2) 등록 리스너 — plugins/_bundled/sirsoft-pay_{nhnkcp,nicepayments,kginicis}/tests/Unit/Listeners/RegisterEasyPayMethodsListenerTest.php
|
||||
* (배지 9종 text/class, SVG 6종 markup, 애플페이만 requires_ios) green.
|
||||
* (3) BrandMark 컴포넌트 — templates/_bundled/sirsoft-basic/src/components/composite/__tests__/BrandMark.test.tsx
|
||||
* (배지/SVG 렌더, SVG script/onload/foreignObject sanitize 제거, 마크 없음→null) green.
|
||||
* (4) 레이아웃 렌더 + iOS 필터 — templates/_bundled/sirsoft-basic/__tests__/layouts/checkout-payment-brand-mark.test.tsx
|
||||
* (BrandMark 노드 바인딩 + Icon 폴백, iteration source 필터가 iOS↔비iOS 로 애플페이 포함/제외) green.
|
||||
* (5) 서버/클라 iOS 게이팅 — tests/Unit/Helpers/DeviceDetectorTest.php + tests/Unit/Middleware/DetectDeviceTest.php
|
||||
* + tests/Unit/Services/SettingsServiceAppConfigTest.php + templates/_bundled/sirsoft-basic/src/handlers/__tests__/deviceCorrection.test.ts
|
||||
* (UA→appConfig.isIos, iPadOS 데스크탑 UA 클라 보정) green.
|
||||
* 라이브 검수는 Chrome MCP 실측(3사 브랜드 버튼 공존 + 마크 표시, iOS 에뮬레이션 애플페이 노출/숨김, 스크린샷)으로 기록 예정.
|
||||
*/
|
||||
// @scenario mark_form=svg, requires_ios=false, device=ios
|
||||
// @scenario mark_form=svg, requires_ios=false, device=non_ios
|
||||
// @scenario mark_form=none, requires_ios=false, device=non_ios
|
||||
// @scenario mark_form=badge, requires_ios=false, device=non_ios
|
||||
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
|
||||
|
||||
test.describe.skip('체크아웃 간편결제 브랜드 마크 + 애플페이 iOS 게이팅 (skeleton)', () => {
|
||||
test('3사 간편결제 버튼이 브랜드 마크(배지/SVG)로 렌더된다', async ({ page, userToken }) => {
|
||||
await authenticatePage(page, userToken);
|
||||
await page.goto('/shop/checkout');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
// 활성화 시: 배지 형태(nhnkcp 네이버페이 'N')와 SVG 형태(kginicis 네이버페이 로고)가 함께 렌더
|
||||
await expect(page.getByTestId('payment-method-nhnkcp_naverpay').getByText('N')).toBeVisible();
|
||||
await expect(page.getByTestId('payment-method-kginicis_naverpay').locator('svg')).toBeVisible();
|
||||
// 마크 없는 무통장은 아이콘 폴백
|
||||
await expect(page.getByTestId('payment-method-dbank').locator('i')).toBeVisible();
|
||||
});
|
||||
|
||||
test('비-iOS 기기에서 애플페이 버튼이 노출되지 않는다', async ({ page, userToken }) => {
|
||||
await authenticatePage(page, userToken);
|
||||
await page.goto('/shop/checkout');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
// 활성화 시: 데스크탑(비-iOS)에서는 애플페이 수단 카드가 렌더되지 않음
|
||||
await expect(page.getByTestId('payment-method-nhnkcp_applepay')).toHaveCount(0);
|
||||
// 비-애플페이 수단은 정상 노출
|
||||
await expect(page.getByTestId('payment-method-nhnkcp_naverpay')).toBeVisible();
|
||||
});
|
||||
|
||||
test('iOS 기기 에뮬레이션에서 애플페이 버튼이 노출된다', async ({ browser }) => {
|
||||
// 활성화 시: iPhone UA 컨텍스트에서 애플페이 수단 카드가 렌더됨(서버 UA 게이팅)
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto('/shop/checkout');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
await expect(page.getByTestId('payment-method-nhnkcp_applepay')).toBeVisible();
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Helpers;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
|
||||
class DeviceDetectorTest extends ModuleTestCase
|
||||
{
|
||||
#[DataProvider('deviceTypeProvider')]
|
||||
public function test_detect_from_user_agent(string $userAgent, string $expected): void
|
||||
{
|
||||
$this->assertSame($expected, DeviceDetector::detectFromUserAgent($userAgent));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{0: string, 1: string}>
|
||||
*/
|
||||
public static function deviceTypeProvider(): array
|
||||
{
|
||||
return [
|
||||
'iPhone' => ['Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)', 'mobile'],
|
||||
'iPad' => ['Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)', 'mobile'],
|
||||
'Android' => ['Mozilla/5.0 (Linux; Android 14; Pixel 8)', 'mobile'],
|
||||
'desktop Chrome' => ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120', 'pc'],
|
||||
'macOS Safari' => ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17', 'pc'],
|
||||
'empty' => ['', 'pc'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario mark_form=badge, requires_ios=true, device=ios
|
||||
*
|
||||
* @effects server_ua_sets_is_ios, applepay_shown_on_ios
|
||||
*/
|
||||
#[DataProvider('iosProvider')]
|
||||
public function test_is_ios_from_user_agent(string $userAgent, bool $expected): void
|
||||
{
|
||||
$this->assertSame($expected, DeviceDetector::isIosFromUserAgent($userAgent));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{0: string, 1: bool}>
|
||||
*/
|
||||
public static function iosProvider(): array
|
||||
{
|
||||
return [
|
||||
'iPhone' => ['Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)', true],
|
||||
'iPad' => ['Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)', true],
|
||||
'iPod' => ['Mozilla/5.0 (iPod touch; CPU iPhone OS 15_0 like Mac OS X)', true],
|
||||
// Android 모바일은 iOS 가 아님(애플페이 미노출).
|
||||
'Android' => ['Mozilla/5.0 (Linux; Android 14; Pixel 8)', false],
|
||||
// 데스크탑 UA 를 보내는 iPadOS 는 서버 UA 판정으로 놓친다(클라 보정 대상).
|
||||
'macOS desktop UA' => ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17', false],
|
||||
'Windows' => ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120', false],
|
||||
'empty' => ['', false],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Middleware;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\InjectAppConfigDeviceListener;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
class DetectDeviceTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* @scenario mark_form=badge, requires_ios=true, device=ios
|
||||
*
|
||||
* @effects server_ua_sets_is_ios, applepay_shown_on_ios
|
||||
*/
|
||||
public function test_ios_user_agent_sets_is_ios_true_in_container(): void
|
||||
{
|
||||
$request = Request::create('/', 'GET', server: [
|
||||
'HTTP_USER_AGENT' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
|
||||
]);
|
||||
|
||||
(new DetectDevice)->handle($request, fn () => new Response);
|
||||
|
||||
$this->assertTrue(DetectDevice::isIos());
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario mark_form=badge, requires_ios=true, device=non_ios
|
||||
*
|
||||
* @effects server_ua_sets_is_ios, applepay_hidden_on_non_ios
|
||||
*/
|
||||
public function test_desktop_user_agent_sets_is_ios_false_in_container(): void
|
||||
{
|
||||
$request = Request::create('/', 'GET', server: [
|
||||
'HTTP_USER_AGENT' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120',
|
||||
]);
|
||||
|
||||
(new DetectDevice)->handle($request, fn () => new Response);
|
||||
|
||||
$this->assertFalse(DetectDevice::isIos());
|
||||
}
|
||||
|
||||
public function test_is_ios_defaults_false_when_middleware_not_run(): void
|
||||
{
|
||||
// 컨테이너 미바인딩(미들웨어 미실행) 시 fail-safe(애플페이 기본 숨김).
|
||||
$this->assertFalse(DetectDevice::isIos());
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario mark_form=badge, requires_ios=true, device=ipados_desktop_ua
|
||||
*
|
||||
* @effects is_ios_flows_to_global_appconfig
|
||||
*/
|
||||
public function test_listener_injects_is_ios_into_app_config_via_hook(): void
|
||||
{
|
||||
// iOS 미들웨어 통과 상태 재현.
|
||||
$request = Request::create('/', 'GET', server: [
|
||||
'HTTP_USER_AGENT' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
|
||||
]);
|
||||
(new DetectDevice)->handle($request, fn () => new Response);
|
||||
|
||||
// 리스너를 훅에 등록(모듈 부팅이 하는 것과 동일).
|
||||
HookManager::addFilter(
|
||||
'core.frontend.filter_app_config',
|
||||
[new InjectAppConfigDeviceListener, 'injectDeviceFlags'],
|
||||
20
|
||||
);
|
||||
|
||||
$appConfig = app(SettingsService::class)->getAppConfigForFrontend();
|
||||
|
||||
$this->assertArrayHasKey('isIos', $appConfig);
|
||||
$this->assertTrue($appConfig['isIos']);
|
||||
}
|
||||
}
|
||||
@@ -39,13 +39,28 @@ class OrderPaymentTest extends ModuleTestCase
|
||||
$this->assertEquals($order->id, $payment->order->id);
|
||||
}
|
||||
|
||||
public function test_order_payment_casts_method_to_enum(): void
|
||||
public function test_order_payment_method_is_stored_as_plain_string(): void
|
||||
{
|
||||
// payment_method 는 enum 캐스트를 두지 않는다 (#475) — PG 플러그인이 등록하는
|
||||
// 확장 결제수단(예: nhnkcp_naverpay)은 enum case 가 없어 캐스트가 ValueError 를 던지기 때문.
|
||||
// 능력 질의는 모델의 능력 메서드가 PaymentMethodResolver 에 위임해 답한다.
|
||||
$order = OrderFactory::new()->create();
|
||||
$payment = OrderPaymentFactory::new()->forOrder($order)->card()->create();
|
||||
|
||||
$this->assertInstanceOf(PaymentMethodEnum::class, $payment->payment_method);
|
||||
$this->assertEquals(PaymentMethodEnum::CARD, $payment->payment_method);
|
||||
$this->assertSame(PaymentMethodEnum::CARD->value, $payment->fresh()->payment_method);
|
||||
$this->assertTrue($payment->isCardPayment());
|
||||
}
|
||||
|
||||
public function test_order_payment_accepts_extension_payment_method_id(): void
|
||||
{
|
||||
// 확장 결제수단 ID 를 1급 시민으로 저장할 수 있어야 한다 (캐스트가 있으면 ValueError).
|
||||
$order = OrderFactory::new()->create();
|
||||
$payment = OrderPaymentFactory::new()->forOrder($order)->create([
|
||||
'payment_method' => 'nhnkcp_naverpay',
|
||||
'pg_provider' => 'nhnkcp',
|
||||
]);
|
||||
|
||||
$this->assertSame('nhnkcp_naverpay', $payment->fresh()->payment_method);
|
||||
}
|
||||
|
||||
public function test_order_payment_casts_status_to_enum(): void
|
||||
@@ -64,7 +79,7 @@ class OrderPaymentTest extends ModuleTestCase
|
||||
'card_name' => '신한카드',
|
||||
]);
|
||||
|
||||
$this->assertEquals(PaymentMethodEnum::CARD, $payment->payment_method);
|
||||
$this->assertTrue($payment->isCardPayment());
|
||||
$this->assertEquals('신한카드', $payment->card_name);
|
||||
}
|
||||
|
||||
@@ -73,7 +88,7 @@ class OrderPaymentTest extends ModuleTestCase
|
||||
$order = OrderFactory::new()->create();
|
||||
$payment = OrderPaymentFactory::new()->forOrder($order)->virtualAccount()->create();
|
||||
|
||||
$this->assertEquals(PaymentMethodEnum::VBANK, $payment->payment_method);
|
||||
$this->assertTrue($payment->isVirtualAccount());
|
||||
$this->assertNotNull($payment->vbank_name);
|
||||
$this->assertNotNull($payment->vbank_number);
|
||||
}
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Providers;
|
||||
|
||||
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
||||
use App\Extension\ExtensionMiddlewareRegistry;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice;
|
||||
use Modules\Sirsoft\Ecommerce\Module;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* DetectDevice 미들웨어가 코어 self-gate 선언 방식으로 등록·실행되는지 검증.
|
||||
*
|
||||
* 이관 전 회귀: SP 가 Router::pushMiddlewareToGroup 으로 등록하면 HTTP Kernel 에 반영되지 않아
|
||||
* SSR 셸 요청에서 미실행 → appConfig.isIos 항상 false. 이제는 Module::getMiddleware() 가
|
||||
* DetectDevice 를 targets=['/'](무명 User SSR 셸 URI 패턴)로 선언하고 코어 게이트가 실행한다.
|
||||
*
|
||||
* 본 테스트는 (1) 선언 계약이 올바른지, (2) registry 가 루트 URI(web/after_core)에서 DetectDevice
|
||||
* 를 매칭하는지를 검증한다. 미들웨어 자체 동작(isIos 판정)은 DetectDeviceTest 가 커버한다.
|
||||
*/
|
||||
class DetectDeviceMiddlewareRegistrationTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* @scenario mark_form=badge, requires_ios=true, device=non_ios
|
||||
*
|
||||
* @effects server_ua_sets_is_ios, detect_device_declared_and_resolved_for_root_uri_web_only
|
||||
*/
|
||||
public function test_detect_device_is_declared_via_get_middleware(): void
|
||||
{
|
||||
$module = new Module;
|
||||
|
||||
$declarations = $module->getMiddleware();
|
||||
|
||||
$detect = collect($declarations)->firstWhere('class', DetectDevice::class);
|
||||
|
||||
$this->assertNotNull($detect, 'Module::getMiddleware() 가 DetectDevice 를 선언해야 합니다.');
|
||||
$this->assertSame(['web'], $detect['groups'], 'DetectDevice 는 web 그룹 선언이어야 합니다.');
|
||||
$this->assertContains('/', $detect['targets'], 'DetectDevice 는 무명 SSR 셸용 URI 패턴 "/" 로 타게팅해야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario mark_form=badge, requires_ios=true, device=non_ios
|
||||
*
|
||||
* @effects server_ua_sets_is_ios
|
||||
*/
|
||||
public function test_gate_resolves_detect_device_for_root_uri(): void
|
||||
{
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
$registry = $this->app->make(ExtensionMiddlewareRegistryInterface::class);
|
||||
|
||||
// 무명 SSR 셸 catch-all: routeName='' + path '/' → web/after_core 에서 DetectDevice 매칭.
|
||||
$matched = $registry->resolveForRoute('', '/', 'web', 'after_core');
|
||||
|
||||
$this->assertContains(
|
||||
DetectDevice::class,
|
||||
$matched,
|
||||
'코어 게이트가 루트 URI(무명 라우트) 요청에서 DetectDevice 를 매칭해야 SSR 셸에서 실행된다.'
|
||||
);
|
||||
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario mark_form=badge, requires_ios=true, device=non_ios
|
||||
*
|
||||
* @effects server_ua_sets_is_ios
|
||||
*/
|
||||
public function test_gate_does_not_match_detect_device_for_api_group(): void
|
||||
{
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
$registry = $this->app->make(ExtensionMiddlewareRegistryInterface::class);
|
||||
|
||||
// DetectDevice 는 web 그룹만 선언 → api 그룹 조회에서는 매칭되지 않는다.
|
||||
$matched = $registry->resolveForRoute('', '/', 'api', 'after_core');
|
||||
|
||||
$this->assertNotContains(DetectDevice::class, $matched);
|
||||
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
}
|
||||
}
|
||||
+49
@@ -257,6 +257,55 @@ class EcommerceSettingsOrderSettingsTest extends TestCase
|
||||
$this->assertFalse($toss['is_active']); // 기본값: 비활성
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario mark_form=svg, requires_ios=false, device=non_ios
|
||||
*
|
||||
* @effects brand_mark_flows_to_cached, requires_ios_flag_carried, none_form_cached_brand_mark_null
|
||||
*/
|
||||
public function test_brand_mark_and_requires_ios_flow_from_definition_to_merged_catalog(): void
|
||||
{
|
||||
// 플러그인이 brand_mark(배지/SVG) + requires_ios 를 등록하면
|
||||
// 병합 카탈로그가 _cached_brand_mark / requires_ios 로 노출해 레이아웃까지 전달한다.
|
||||
$this->addPaymentMethodFilter(function (array $methods) {
|
||||
$methods[] = [
|
||||
'id' => 'pgtest_badge',
|
||||
'name' => ['ko' => '배지결제', 'en' => 'Badge Pay'],
|
||||
'description' => ['ko' => '배지 결제', 'en' => 'Badge Payment'],
|
||||
'icon' => 'wallet',
|
||||
'source' => 'plugin:sirsoft-pgtest',
|
||||
'brand_mark' => ['text' => 'B', 'class' => 'bg-green-500 text-white'],
|
||||
'defaults' => ['is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
|
||||
];
|
||||
$methods[] = [
|
||||
'id' => 'pgtest_svg_ios',
|
||||
'name' => ['ko' => 'SVG결제', 'en' => 'SVG Pay'],
|
||||
'description' => ['ko' => 'SVG 결제', 'en' => 'SVG Payment'],
|
||||
'icon' => 'smartphone',
|
||||
'source' => 'plugin:sirsoft-pgtest',
|
||||
'brand_mark' => ['svg' => '<svg><rect fill="#03C75A"/></svg>'],
|
||||
'requires_ios' => true,
|
||||
'defaults' => ['is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
|
||||
];
|
||||
|
||||
return $methods;
|
||||
});
|
||||
|
||||
$this->service->clearCache();
|
||||
$methods = collect($this->service->getSettings('order_settings')['payment_methods'])->keyBy('id');
|
||||
|
||||
$badge = $methods->get('pgtest_badge');
|
||||
$this->assertSame(['text' => 'B', 'class' => 'bg-green-500 text-white'], $badge['_cached_brand_mark']);
|
||||
$this->assertArrayNotHasKey('requires_ios', $badge);
|
||||
|
||||
$svg = $methods->get('pgtest_svg_ios');
|
||||
$this->assertSame(['svg' => '<svg><rect fill="#03C75A"/></svg>'], $svg['_cached_brand_mark']);
|
||||
$this->assertTrue($svg['requires_ios']);
|
||||
|
||||
// brand_mark 없는 builtin 은 _cached_brand_mark 가 null.
|
||||
$dbank = $methods->get('dbank');
|
||||
$this->assertNull($dbank['_cached_brand_mark']);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 결제수단 병합 (사용자 저장 설정 오버라이드)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* PaymentMethodResolver 단위 테스트 — 이슈 #475
|
||||
*
|
||||
* 결제수단 능력 해석의 폴백 3단계를 검증한다:
|
||||
*
|
||||
* 카탈로그 선언 → enum(builtin) → 안전한 기본값
|
||||
*
|
||||
* 폴백이 이 순서를 지켜야 (1) 플러그인이 신규 키를 선언하지 않아도 기존 동작이 유지되고
|
||||
* (2) 선언하면 그 값이 이기며 (3) 미등록 확장 ID 도 안전한 쪽으로 판정된다.
|
||||
*/
|
||||
class PaymentMethodResolverTest extends ModuleTestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
HookManager::resetAll();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function resolver(): PaymentMethodResolver
|
||||
{
|
||||
return app(PaymentMethodResolver::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 결제수단을 카탈로그에 등록합니다 (PG 플러그인이 하는 것과 동일).
|
||||
*
|
||||
* @param array<string, mixed> $defaults 선언할 능력 키
|
||||
*/
|
||||
private function registerMethod(string $id, array $defaults): void
|
||||
{
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.settings.filter_available_payment_methods',
|
||||
fn (array $methods) => array_merge($methods, [[
|
||||
'id' => $id,
|
||||
'name' => ['ko' => '확장수단', 'en' => 'Extension Method'],
|
||||
'description' => ['ko' => '', 'en' => ''],
|
||||
'icon' => 'credit-card',
|
||||
'source' => 'plugin:test',
|
||||
'defaults' => $defaults,
|
||||
]])
|
||||
);
|
||||
|
||||
// 카탈로그는 요청 내 1회 캐시되므로, 훅 등록 후 싱글톤을 새로 만들어야 한다.
|
||||
$this->app->forgetInstance(PaymentMethodResolver::class);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// needsPgProvider — 카탈로그 → enum → true
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @scenario method_kind=builtin, capability_declared=declared, capability=needs_pg
|
||||
*
|
||||
* @effects builtin_capability_unchanged
|
||||
*/
|
||||
#[Test]
|
||||
public function catalog_declaration_wins_over_enum_for_needs_pg(): void
|
||||
{
|
||||
// dbank 는 enum 상 PG 불필요(false)지만, 카탈로그가 true 로 선언하면 그것이 이긴다.
|
||||
$this->registerMethod('dbank', ['needs_pg' => true]);
|
||||
|
||||
$this->assertTrue($this->resolver()->needsPgProvider('dbank'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=builtin, capability_declared=declared, capability=needs_pg
|
||||
*
|
||||
* @effects builtin_capability_unchanged
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('builtinNeedsPgProvider')]
|
||||
public function builtin_falls_back_to_enum_when_catalog_does_not_declare(string $methodId, bool $expected): void
|
||||
{
|
||||
// 카탈로그에 needs_pg 선언이 없으면 enum 의 판정을 그대로 따른다 (하위호환).
|
||||
$this->assertSame($expected, $this->resolver()->needsPgProvider($methodId));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{string, bool}>
|
||||
*/
|
||||
public static function builtinNeedsPgProvider(): array
|
||||
{
|
||||
return [
|
||||
'card 는 PG 필요' => ['card', true],
|
||||
'vbank 는 PG 필요' => ['vbank', true],
|
||||
'bank 는 PG 필요' => ['bank', true],
|
||||
'phone 은 PG 필요' => ['phone', true],
|
||||
'dbank 는 수동 입금확인 — PG 불필요' => ['dbank', false],
|
||||
'point 는 내부 처리 — PG 불필요' => ['point', false],
|
||||
'deposit 은 내부 처리 — PG 불필요' => ['deposit', false],
|
||||
'free 는 내부 처리 — PG 불필요' => ['free', false],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=undeclared, capability=needs_pg
|
||||
*
|
||||
* @effects pg_provider_resolved_not_none
|
||||
*/
|
||||
#[Test]
|
||||
public function unregistered_extension_id_defaults_to_requiring_pg(): void
|
||||
{
|
||||
// 미등록 확장 ID 는 안전한 쪽(PG 필요)으로 판정한다.
|
||||
// false 로 오판하면 결제 실패 주문에 관리자 알림이 발송되고 TempOrder 가 삭제된다(#475).
|
||||
$this->assertTrue($this->resolver()->needsPgProvider('some_unknown_plugin_method'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=declared, capability=needs_pg
|
||||
*
|
||||
* @effects pg_provider_resolved_not_none
|
||||
*/
|
||||
#[Test]
|
||||
public function extension_method_declaring_needs_pg_false_is_respected(): void
|
||||
{
|
||||
// 확장이 명시적으로 PG 불필요를 선언하면 그것을 존중한다 (예: 후불 결제).
|
||||
$this->registerMethod('vendor_deferred', ['needs_pg' => false]);
|
||||
|
||||
$this->assertFalse($this->resolver()->needsPgProvider('vendor_deferred'));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// refundMethod — 카탈로그 → enum match → 능력 기반
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @scenario method_kind=builtin, capability_declared=declared, capability=refund_method
|
||||
*
|
||||
* @effects builtin_capability_unchanged
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('builtinRefundMethodProvider')]
|
||||
public function builtin_refund_method_matches_enum_classification(string $methodId, RefundMethodEnum $expected): void
|
||||
{
|
||||
$this->assertSame($expected, $this->resolver()->refundMethod($methodId));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{string, RefundMethodEnum}>
|
||||
*/
|
||||
public static function builtinRefundMethodProvider(): array
|
||||
{
|
||||
return [
|
||||
'card → PG 취소' => ['card', RefundMethodEnum::PG],
|
||||
'vbank → PG 취소' => ['vbank', RefundMethodEnum::PG],
|
||||
'bank → PG 취소' => ['bank', RefundMethodEnum::PG],
|
||||
'phone → PG 취소' => ['phone', RefundMethodEnum::PG],
|
||||
'dbank → 계좌 환불' => ['dbank', RefundMethodEnum::BANK],
|
||||
'point → 포인트 환불' => ['point', RefundMethodEnum::POINTS],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=builtin, capability_declared=declared, capability=refund_method
|
||||
*
|
||||
* @effects builtin_capability_unchanged
|
||||
*/
|
||||
#[Test]
|
||||
public function catalog_declaration_wins_over_enum_for_refund_method(): void
|
||||
{
|
||||
$this->registerMethod('card', ['refund_method' => 'bank']);
|
||||
|
||||
$this->assertSame(RefundMethodEnum::BANK, $this->resolver()->refundMethod('card'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=declared, capability=refund_method
|
||||
*
|
||||
* @effects refund_method_is_pg
|
||||
*/
|
||||
#[Test]
|
||||
public function extension_method_declaring_pg_refund_is_respected(): void
|
||||
{
|
||||
$this->registerMethod('nhnkcp_naverpay', [
|
||||
'needs_pg' => true,
|
||||
'refund_method' => 'pg',
|
||||
]);
|
||||
|
||||
$this->assertSame(RefundMethodEnum::PG, $this->resolver()->refundMethod('nhnkcp_naverpay'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=undeclared, capability=refund_method
|
||||
*
|
||||
* @effects refund_method_is_pg
|
||||
*/
|
||||
#[Test]
|
||||
public function undeclared_extension_method_refund_follows_pg_capability(): void
|
||||
{
|
||||
// refund_method 를 선언하지 않은 확장수단은 PG 결제 여부로 환불수단을 정한다.
|
||||
// BANK 로 떨어지면 카드 취소가 누락되는 안전 이슈가 된다.
|
||||
$this->assertSame(
|
||||
RefundMethodEnum::PG,
|
||||
$this->resolver()->refundMethod('some_unknown_plugin_method')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=builtin, capability_declared=declared, capability=refund_method
|
||||
*
|
||||
* @effects builtin_capability_unchanged
|
||||
*/
|
||||
#[Test]
|
||||
public function invalid_refund_method_declaration_falls_back_to_enum(): void
|
||||
{
|
||||
// 카탈로그에 유효하지 않은 값이 들어와도 폴백으로 안전하게 처리한다.
|
||||
$this->registerMethod('card', ['refund_method' => 'not_a_real_method']);
|
||||
|
||||
$this->assertSame(RefundMethodEnum::PG, $this->resolver()->refundMethod('card'));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// label — 카탈로그 다국어 → enum label → raw id
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=declared, capability=label
|
||||
*
|
||||
* @effects extension_id_persisted_as_is
|
||||
*/
|
||||
#[Test]
|
||||
public function extension_method_label_comes_from_catalog_in_current_locale(): void
|
||||
{
|
||||
$this->registerMethod('nhnkcp_naverpay', ['needs_pg' => true]);
|
||||
|
||||
app()->setLocale('ko');
|
||||
$this->assertSame('확장수단', $this->resolver()->label('nhnkcp_naverpay'));
|
||||
|
||||
app()->setLocale('en');
|
||||
$this->assertSame('Extension Method', $this->resolver()->label('nhnkcp_naverpay'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=builtin, capability_declared=declared, capability=label
|
||||
*
|
||||
* @effects builtin_capability_unchanged
|
||||
*/
|
||||
#[Test]
|
||||
public function builtin_label_falls_back_to_enum_label(): void
|
||||
{
|
||||
// builtin 은 enum 의 다국어 라벨을 그대로 쓴다.
|
||||
$this->assertSame(
|
||||
PaymentMethodEnum::CARD->label(),
|
||||
$this->resolver()->label('card')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=undeclared, capability=label
|
||||
*
|
||||
* @effects extension_id_persisted_as_is
|
||||
*/
|
||||
#[Test]
|
||||
public function unknown_method_label_falls_back_to_raw_id(): void
|
||||
{
|
||||
// 최후 폴백: 키가 없으면 raw id (빈 문자열/예외 대신 식별 가능한 값).
|
||||
$this->assertSame('mystery_method', $this->resolver()->label('mystery_method'));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// isPgLocked / isBuiltin
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
|
||||
*
|
||||
* @effects admin_shows_pg_locked_badge, admin_hides_pg_select_for_locked
|
||||
*/
|
||||
#[Test]
|
||||
public function extension_method_can_declare_pg_locked(): void
|
||||
{
|
||||
$this->registerMethod('nhnkcp_naverpay', [
|
||||
'pg_provider' => 'nhnkcp',
|
||||
'pg_locked' => true,
|
||||
'needs_pg' => true,
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->resolver()->isPgLocked('nhnkcp_naverpay'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=builtin, capability_declared=declared, capability=pg_locked
|
||||
*
|
||||
* @effects admin_shows_pg_select_for_unlocked
|
||||
*/
|
||||
#[Test]
|
||||
public function builtin_methods_are_not_pg_locked(): void
|
||||
{
|
||||
// builtin 은 관리자가 PG 를 자유롭게 선택할 수 있어야 한다.
|
||||
$this->assertFalse($this->resolver()->isPgLocked('card'));
|
||||
$this->assertFalse($this->resolver()->isPgLocked('vbank'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=undeclared, capability=pg_locked
|
||||
*
|
||||
* @effects admin_shows_pg_select_for_unlocked
|
||||
*/
|
||||
#[Test]
|
||||
public function is_builtin_distinguishes_core_methods_from_extension_ids(): void
|
||||
{
|
||||
$this->assertTrue($this->resolver()->isBuiltin('card'));
|
||||
$this->assertTrue($this->resolver()->isBuiltin('dbank'));
|
||||
$this->assertFalse($this->resolver()->isBuiltin('nhnkcp_naverpay'));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// allValidIds — 검증 화이트리스트
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function all_valid_ids_contains_every_builtin_method(): void
|
||||
{
|
||||
$ids = $this->resolver()->allValidIds();
|
||||
|
||||
foreach (PaymentMethodEnum::values() as $builtin) {
|
||||
$this->assertContains($builtin, $ids);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=declared, capability=needs_pg
|
||||
*
|
||||
* @effects extension_id_passes_validation
|
||||
*/
|
||||
#[Test]
|
||||
public function all_valid_ids_includes_registered_extension_methods(): void
|
||||
{
|
||||
$this->registerMethod('nhnkcp_naverpay', ['needs_pg' => true]);
|
||||
|
||||
$ids = $this->resolver()->allValidIds();
|
||||
|
||||
$this->assertContains('nhnkcp_naverpay', $ids);
|
||||
$this->assertContains('card', $ids, 'builtin 이 확장 등록으로 밀려나면 안 된다');
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=undeclared, capability=needs_pg
|
||||
*
|
||||
* @effects unregistered_id_still_rejected
|
||||
*/
|
||||
#[Test]
|
||||
public function all_valid_ids_excludes_unregistered_ids(): void
|
||||
{
|
||||
// 화이트리스트가 넓어지되 임의 문자열은 여전히 배제해야 한다 (검증 우회 방지).
|
||||
$this->assertNotContains('totally_unknown_method', $this->resolver()->allValidIds());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function all_valid_ids_has_no_duplicates(): void
|
||||
{
|
||||
// 확장이 builtin id 를 덮어쓰는 형태로 등록해도 중복이 생기지 않아야 한다.
|
||||
$this->registerMethod('card', ['needs_pg' => true]);
|
||||
|
||||
$ids = $this->resolver()->allValidIds();
|
||||
|
||||
$this->assertSame(count($ids), count(array_unique($ids)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 기존 설치 환경 자가 치유 — saved 설정이 정의를 이기지 못한다
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
|
||||
*
|
||||
* @effects saved_null_pg_provider_self_healed
|
||||
*/
|
||||
#[Test]
|
||||
public function saved_null_pg_provider_does_not_override_pg_locked_declaration(): void
|
||||
{
|
||||
// 기존 설치 환경의 order_settings.json 에는 확장 결제수단이 이미
|
||||
// `pg_provider: null` 로 영속화되어 있다(#475 결함 시절의 값).
|
||||
//
|
||||
// mergePaymentMethodSettings() 가 saved 우선이면 플러그인이 defaults 를 고쳐도
|
||||
// 기존 설정 파일이 이겨서 결함이 그대로 남는다. 능력 선언(pg_locked/needs_pg/
|
||||
// refund_method)과 PG 고정 수단의 pg_provider 는 정의가 SSoT 여야 한다.
|
||||
$this->registerMethod('nhnkcp_naverpay', [
|
||||
'pg_provider' => 'nhnkcp',
|
||||
'pg_locked' => true,
|
||||
'needs_pg' => true,
|
||||
'refund_method' => 'pg',
|
||||
]);
|
||||
|
||||
$settings = app(EcommerceSettingsService::class);
|
||||
|
||||
// 결함 시절의 saved 상태를 재현 — pg_provider 가 null 로 저장되어 있다.
|
||||
$settings->saveSettings([
|
||||
'order_settings' => [
|
||||
'payment_methods' => [
|
||||
['id' => 'nhnkcp_naverpay', 'pg_provider' => null, 'is_active' => true],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// 싱글톤 캐시를 비우고 재조회 (저장 후 읽기 경로)
|
||||
$this->app->forgetInstance(PaymentMethodResolver::class);
|
||||
|
||||
$config = $settings->getPaymentMethodConfig('nhnkcp_naverpay');
|
||||
|
||||
$this->assertSame('nhnkcp', $config['pg_provider'] ?? null, 'PG 고정 수단은 saved null 을 무시하고 정의의 PG 를 강제해야 한다');
|
||||
$this->assertTrue((bool) ($config['needs_pg'] ?? false));
|
||||
$this->assertTrue($this->resolver()->needsPgProvider('nhnkcp_naverpay'));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user