diff --git a/.env.example b/.env.example
index 373dd7dd..51bc68e1 100644
--- a/.env.example
+++ b/.env.example
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.7
+APP_VERSION=7.0.8
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/.env.testing.example b/.env.testing.example
index 6aaace74..7b8d989f 100644
--- a/.env.testing.example
+++ b/.env.testing.example
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.7
+APP_VERSION=7.0.8
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/AGENTS.md b/AGENTS.md
index 8dd1a4df..ad5912e2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -584,10 +584,15 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통
| typed 예외 도입하면서 그 분기의 상태코드도 변경 | typed 는 **기존 상태코드 유지** — 예외 도입이 사용자 계약을 함께 바꾸면 회귀다 |
| 공개(비인증) 엔드포인트 응답에 예외 원문 포함 | 원문은 `Log::error` 로만 — 관리자 전용 면의 `errors` 페이로드는 진단 정보로 허용된다 |
| 원문을 직접 문자열로 조립해 노출 폭을 호출부가 정함 | 노출 폭은 `ResponseHelper` 가 정한다 — Throwable 을 넘기면 `app.debug` 에서만 펼쳐진다 |
+| 치환 자리(`:error`)를 가진 키를 파라미터 없이 호출 | 넷째 인자 `messageParams` 로 채운다 — 비워 두면 번역기가 자리표시자를 **그대로 둔 문장**을 돌려줘 운영자 화면에 `:error` 가 노출된다 (실패했을 때만 드러나 정상 흐름 테스트로는 안 잡힌다) |
+| 사유를 모른다고 치환 자리를 비워 두기 | 알 수 없으면 일반 문구(`errors.unknown_error`)로 채운다 |
+| 원문을 싣지 않기로 한 문구에 `:error` 자리를 남겨 두기 | 그 키에서 **치환 자리 자체를 없앤다** — 자리를 남기면 나중에 예외 원문으로 채우는 회귀를 부른다 |
+| 하위 계층이 `false`/`null` 만 돌려주고 실패 사유를 버림 | 사유를 반환 경로에 실어 올린다 (배열 키 `reason` 또는 **뒤에 붙인 선택적 out 파라미터**) — 기존 호출부를 깨지 않는다 |
+| 확장 수명주기 훅이 사유 없이 `false` 반환 | `AbstractModule`/`AbstractPlugin` 의 `failWith(__('...'))` — 코어가 그 사유를 원인 자리에 싣는다 |
`message`(첫 인자)와 `errors`(셋째 인자)는 다른 통로다. **키 자리에 원문을 넘기는 것은 언제나 금지**지만, `errors` 페이로드의 원문은 금지 대상이 아니다 — `ResponseHelper::error` 가 문자열 `errors` 를 `500+` 비디버그에서만 차단하고 배열은 통과시키는 것은 `tests/Unit/Helpers/ResponseHelperTest.php` 가 고정한 의도다. 관리자에게 결제대행사·외부 시스템이 돌려준 사유를 감추면 조치 근거가 사라지고, 다국어 키는 유한해서 예상 못 한 실패를 담지 못한다. 판단 축은 "원문이냐 키냐" 가 아니라 **누구에게 / 무엇의 원문인가 / 어느 통로인가** 셋이다.
-상세: [exceptions.md "예외 → 응답 매핑"](docs/backend/exceptions.md). `tests/Feature/Http/GenericCatchStatusCodeContractTest.php` 가 코어와 모든 번들 확장의 컨트롤러를 전수 스캔해 두 규칙을 고정한다. 판정기를 한 확장 안에 두면 그 확장 밖의 동형 결함이 검출되지 않는다.
+상세: [exceptions.md "예외 → 응답 매핑"](docs/backend/exceptions.md). `tests/Feature/Http/GenericCatchStatusCodeContractTest.php` 가 코어와 모든 번들 확장의 컨트롤러를 전수 스캔해 두 규칙을 고정한다. 판정기를 한 확장 안에 두면 그 확장 밖의 동형 결함이 검출되지 않는다. 치환 자리 축은 `tests/Feature/Http/ErrorMessageParamSubstitutionTest.php` 가 고정한다 — 호출부를 열거하지 않고 `->error(...)` 전수를 괄호 균형으로 잘라, 키를 실제로 번역해 `:error` 를 요구하는지 판정한다. 같은 판정기가 `new *OperationException(...)` 생성자 축도 덮는다(파라미터 배열이 넷째가 아니라 둘째 인자다). 이 축이 없으면 키를 들고 다니는 예외로 던지는 경로가 통째로 사각이 된다.
### Listener 데이터 접근
@@ -599,6 +604,8 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통
| Listener 생성자에 구체 Repository 직접 주입 | Repository Interface 주입 |
| Listener 에서 `request()` / `$_POST` 직접 접근 | Service 가 검증 후 도메인 객체로 전달 받기 |
| Filter 훅에 `'type' => 'filter'` 누락 | type 명시 필수 (반환값 무시 회귀 차단) |
+| 실패 시 호출자 트랜잭션을 되돌려야 하는 Action 훅에 `'sync' => true` 누락 | 금전 이동(쿠폰 차감·복원, 적립금 차감·복원)은 `sync` 필수. 기본값은 큐 래핑 + `afterCommit` 이라 **커밋 뒤에** 실행되어, 예외를 던져도 롤백되지 않고 오류 응답만 나간 채 데이터가 남는다 (큐 드라이버가 `sync` 여도 동일) |
+| 훅 회귀 테스트에서 리스너를 손으로 `addAction` 등록 | `HookListenerRegistrar::register()` 로 **실제 등록 경로**를 태운다 — 손으로 등록하면 큐 래핑을 건너뛰어 커밋 이후 실행 문제를 통과시킨다 |
| Listener 가 `HookListenerInterface` 미구현 (auto-discovery 대상) | implements + `getSubscribedHooks()` 정적 메서드 |
> 상세: [hooks.md "Listener 데이터 접근 규정"](docs/extension/hooks.md), [service-repository.md](docs/backend/service-repository.md)
@@ -1051,6 +1058,8 @@ BaseApiController (최상위)
필수: ActionDispatcher 에 핸들러를 등록하는 확장은 재등록 진입점을 window 전역에 고정 이름으로 노출 — 모듈 window.__[Name].initModule, 플러그인 window.__[Name].initPlugin (미노출 시 로케일 전환 후 해당 확장 액션이 전부 무반응, 에러·토스트 없음). 진입점은 핸들러 재등록만 수행
필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지
필수: 라우트 정의를 바꾸는 지점은 App\Support\RouteCacheHelper::rebuild() 로 라우트 캐시 갱신 — 확장 설치/활성화/비활성화/삭제/업데이트, 코어 업데이트·업그레이드 스텝. route:clear/route:cache 를 각 지점에 직접 흩어 놓지 않는다 (누락 발생, 비우기만 하면 재생성되지 않아 성능 이점 영구 소실). 훅 캐시와 달리 라우트 캐시에는 스캔 폴백이 없어 캐시에 없는 라우트는 예외·경고 없이 404. 파일 교체 중인 코어 업데이트는 중간에 clear(), 끝에서 rebuild(). 템플릿·모듈 설정은 서버 라우트 무관 (상세: docs/backend/routing.md "라우트 캐시")
+필수: 확장 라우트는 활성 상태인 확장의 것만 등록한다 — 모듈·플러그인 두 라우트 프로바이더가 같은 기준을 쓴다. 게이트가 한쪽에만 있으면 그 비대칭은 오류가 아니라 "조용히 열린 경로" 로만 나타난다: 비활성화해도 화면·메뉴·에셋만 사라지고 API 는 계속 호출 가능하며, 컨트롤러가 정상 처리하므로 오류도 로그도 남지 않는다
+필수: 그 rebuild() 는 확장 상태 캐시 무효화(invalidate*StatusCache()) 뒤에 온다 — route:cache 는 새 앱을 부팅해 라우트를 수집하는데 그 부팅의 확장 라우트 프로바이더는 DB 가 아니라 캐시된 활성 확장 목록(TTL 기본 1일)을 읽으므로, 먼저 구우면 방금 바뀐 상태가 빠진 채 박제되고 자가 회복되지 않는다 (활성화 → 그 확장 API 전량 404 / 비활성화 → 끈 확장 API 가 계속 호출 가능 / 업데이트 → 404 + 훅 리스너 누락). 무효화는 굽기 직전이 아니라 DB 상태 쓰기 직후에 둔다 — 같은 목록을 읽는 굽기가 라우트 캐시 말고도 있다 (오토로드 갱신 안의 훅 매핑 캐시). update 경로만 예외: Updating 전이 직후에는 비우지 않고 (비우면 그 창의 오토로드 갱신이 그 확장을 비활성으로 판정해 훅 리스너를 떨군다) 상태 복원 직후에 비운 뒤 ExtensionManager::regenerateHookCache() 로 훅 캐시를 다시 굽는다. 훅 캐시 폴백은 파일 부재·손상에만 작동해 내용이 stale 한 경우는 조용히 통과한다
필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용
필수: 모든 확장 작업은 Artisan 커맨드로 수행
```
@@ -1119,6 +1128,21 @@ php artisan language-pack:update g7-core-ja --force
번들 언어팩도 `_bundled` 는 배포 원본일 뿐이다. 설치본(`lang-packs/{id}/`)을 갱신하지 않으면 새로 추가한 번역 키가 런타임에 존재하지 않아 해당 로케일이 조용히 기준 로케일로 폴백한다.
+### 배포 산출물의 브라우저 하한
+
+선언 하한은 **Chrome 111 / Safari 16.4 / Firefox 128** 이다 ([requirements.md §7](docs/requirements.md)). 빌드 타깃(`target: 'es2020'`)은 이 하한을 강제하지 못한다 — **ES 연도와 브라우저 지원 연도가 다르기 때문**이다. ES2018 인 정규식 lookbehind 를 WebKit 은 Safari 16.4 에서야 구현했고, 타깃 검사는 그대로 통과시킨다.
+
+정규식 **리터럴** 문법은 그중에서도 다운레벨이 원리상 불가능하다. 번들러는 lookbehind 를 `new RegExp(...)` 로 옮길 뿐이라 파싱 오류가 **런타임 오류로 이동**할 뿐 사라지지 않는다. 따라서 타깃 하향은 해법이 아니다.
+
+| ❌ 금지 | ✅ 올바른 사용 |
+|--------|---------------|
+| 배포 JS 산출물에 선언 하한 **초과** 문법·API (`Object.groupBy`·`Promise.withResolvers`·`Array.fromAsync`·`RegExp.escape`·정규식 `v` 플래그) | 하한 이하 문법으로 작성 |
+| **부팅 임계 번들**(`public/build/core/template-engine.min.js`, `templates/_bundled/*/dist/js/components.iife.js`)에 정규식 리터럴 전용 문법(lookbehind `(? 고급에서 디버그 모드를 켜면 프록시 주소 입력칸이 나타나며, 프록시를 거치지 않을 주소를 예외 목록으로 따로 지정할 수 있습니다. 디버그 모드를 끄면 저장된 주소가 남아 있어도 프록시는 적용되지 않습니다.
+- 프록시 주소 옆의 「연결 테스트」로 저장하기 전에 연결 여부를 확인할 수 있습니다. 성공하면 그 프록시를 거쳤을 때 외부 서비스에 보이는 IP 주소를 함께 알려주므로, 결제사에 어떤 IP 를 등록해야 하는지 미리 확인할 수 있습니다.
+- 확장 개발자용: 사이트 표준 HTTP 호출은 프록시 설정이 자동으로 적용됩니다. 외부 연동 규약상 별도 방식으로 통신해야 하는 확장은 코어가 제공하는 프록시 설정을 받아 같은 경로로 내보낼 수 있습니다.
+
+### Changed
+
+- 비활성화한 플러그인의 기능이 더 이상 동작하지 않습니다. 이전에는 플러그인을 꺼도 화면·메뉴·스크립트만 사라지고 그 플러그인의 기능 주소는 계속 응답해, 꺼진 결제수단으로 결제가 시도되는 등 "껐는데 아직 살아 있는" 상태가 남았습니다. 이제 모듈과 동일하게 활성화된 플러그인의 기능만 동작합니다. **결제·본인인증처럼 외부 서비스가 직접 호출하는 주소도 함께 닫히므로, 진행 중인 거래가 있을 때의 비활성화·업데이트는 처리가 끝난 뒤에 하시기 바랍니다.**
+- 확장 개발자용: 모듈·플러그인의 설치·활성화·비활성화·제거 처리에서 실패 사유를 코어에 전달할 수 있습니다. `failWith()` 로 사유를 남기고 실패를 반환하면 관리자 화면의 실패 안내에 그 사유가 함께 표시됩니다. 사유를 남기지 않아도 종전처럼 동작합니다.
+- 지원 브라우저 문서에 최소 버전(Chrome 111 / Safari 16.4 / Firefox 128)을 명시했습니다. 이보다 오래된 브라우저에서는 스타일이 일부 깨질 수 있으나 화면 표시와 기본 이용은 가능하도록 유지한다는 방침도 함께 밝혔습니다. (#121 @bigmsg 님께서 건의해주셨습니다.)
+
+### Fixed
+
+- 구형 iOS·macOS Safari(16.4 미만)에서 사이트가 전혀 표시되지 않던 문제를 수정했습니다. 화면 구성에 쓰이는 스크립트에 해당 브라우저가 해석하지 못하는 문법이 들어 있어, 스크립트 전체가 실행되지 못하고 사이트가 통째로 멈춰 있었습니다. (#121 @bigmsg 님께서 제보해주셨습니다.)
+- 화면을 불러오지 못했을 때 원인과 무관하게 "네트워크 연결이 불안정할 수 있습니다"로 안내되던 문제를 수정했습니다. 브라우저가 지원 범위보다 오래되어 화면을 실행하지 못한 경우에는 그에 맞는 안내를 표시하며, 새로고침해도 해결되지 않는 상황이므로 새로고침 버튼도 표시하지 않습니다.
+- 관리자 화면의 실패 안내에 원인이 들어갈 자리가 채워지지 않아 `:error` 라는 내부 표시가 그대로 보이던 문제를 수정했습니다. 모듈·플러그인·템플릿·언어팩 관리와 플러그인 설정 저장의 실패 안내 전반에서 발생했습니다. 이제 실패 원인을 알 수 있으면 그 원인이, 알 수 없으면 일반 안내 문구가 표시됩니다. 언어팩 관리처럼 원인을 표시하지 않기로 한 안내는 문구 자체를 정리했습니다.
+- 모듈·플러그인을 활성화한 직후 그 확장의 화면과 기능이 "주소를 찾을 수 없음" 오류만 내던 문제를 수정했습니다. 활성화 시점에 사이트 내부 주소록이 활성화 이전 상태를 기준으로 다시 만들어져, 방금 켠 확장의 주소가 빠진 채로 굳어졌습니다. 시간이 지나도 스스로 복구되지 않았습니다. 같은 원인으로 비활성화한 확장의 기능이 계속 호출 가능하던 문제, 확장을 업데이트한 직후 그 확장의 기능과 다른 기능과의 연동 동작이 함께 누락되던 문제도 바로잡았습니다.
+
## [7.0.7] - 2026-08-19
### Security
diff --git a/INSTALL.md b/INSTALL.md
index 802b5d98..2946a231 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -289,7 +289,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
-# (필요 시) mv g7-7.0.7 g7
+# (필요 시) mv g7-7.0.8 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
diff --git a/README.ko.md b/README.ko.md
index 06a43295..b0aa6a8d 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -10,7 +10,7 @@
-
+
diff --git a/README.md b/README.md
index 3410c40b..fdd76c39 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
diff --git a/app/Console/Commands/MigrateSettingsToJsonCommand.php b/app/Console/Commands/MigrateSettingsToJsonCommand.php
index 925d969a..9ef967dd 100644
--- a/app/Console/Commands/MigrateSettingsToJsonCommand.php
+++ b/app/Console/Commands/MigrateSettingsToJsonCommand.php
@@ -96,6 +96,8 @@ class MigrateSettingsToJsonCommand extends Command
'debug_mode' => 'debug',
'sql_query_log' => 'debug',
'log_level' => 'debug',
+ 'outbound_proxy' => 'debug',
+ 'outbound_proxy_bypass' => 'debug',
];
/**
diff --git a/app/Extension/AbstractModule.php b/app/Extension/AbstractModule.php
index 5bdfde59..cf4f9033 100644
--- a/app/Extension/AbstractModule.php
+++ b/app/Extension/AbstractModule.php
@@ -9,6 +9,7 @@ use App\Contracts\Extension\StorageInterface;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\Cache\ModuleCacheDriver;
use App\Extension\Storage\ModuleStorageDriver;
+use App\Extension\Traits\ReportsLifecycleFailure;
use Illuminate\Database\Seeder;
use ReflectionClass;
@@ -21,6 +22,8 @@ use ReflectionClass;
*/
abstract class AbstractModule implements CacheableExtensionInterface, ModuleInterface
{
+ use ReportsLifecycleFailure;
+
/**
* 모듈 디렉토리 경로 (캐시)
*/
diff --git a/app/Extension/AbstractPlugin.php b/app/Extension/AbstractPlugin.php
index 380ef150..c6fc3b00 100644
--- a/app/Extension/AbstractPlugin.php
+++ b/app/Extension/AbstractPlugin.php
@@ -9,6 +9,7 @@ use App\Contracts\Extension\StorageInterface;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\Cache\PluginCacheDriver;
use App\Extension\Storage\PluginStorageDriver;
+use App\Extension\Traits\ReportsLifecycleFailure;
use Illuminate\Database\Seeder;
use ReflectionClass;
@@ -24,6 +25,8 @@ use ReflectionClass;
*/
abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInterface
{
+ use ReportsLifecycleFailure;
+
/**
* 플러그인 디렉토리 경로 (캐시)
*/
diff --git a/app/Extension/ExtensionManager.php b/app/Extension/ExtensionManager.php
index 8facbd27..a53a25e8 100644
--- a/app/Extension/ExtensionManager.php
+++ b/app/Extension/ExtensionManager.php
@@ -74,8 +74,12 @@ class ExtensionManager
*
* 모듈/플러그인 리스너 수집을 위해 각 Manager 를 (재)로드한 뒤 HookCacheManager 에 위임한다.
* 생성 실패는 부팅 시 스캔 폴백으로 흡수되므로 확장 업데이트 흐름을 중단시키지 않는다.
+ *
+ * 확장 수명주기에서 상태를 되돌린 뒤 다시 부를 수 있도록 public 이다 —
+ * Updating 창 안에서 구워진 훅 캐시는 그 확장의 리스너가 빠진 채 남고,
+ * 훅 캐시 폴백은 파일 부재/손상에만 작동해 stale 한 내용은 조용히 통과하기 때문이다.
*/
- protected function regenerateHookCache(): void
+ public function regenerateHookCache(): void
{
try {
$moduleManager = app(ModuleManager::class);
diff --git a/app/Extension/ModuleManager.php b/app/Extension/ModuleManager.php
index f3b4ce63..df68fb30 100644
--- a/app/Extension/ModuleManager.php
+++ b/app/Extension/ModuleManager.php
@@ -311,6 +311,7 @@ class ModuleManager implements ModuleManagerInterface
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param VendorMode $vendorMode vendor 디렉토리 처리 모드
* @param bool $force 강제 설치 여부
+ * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 설치 성공 여부
*
* @throws \Exception 모듈을 찾을 수 없거나 의존성 문제 시
@@ -320,7 +321,10 @@ class ModuleManager implements ModuleManagerInterface
?\Closure $onProgress = null,
VendorMode $vendorMode = VendorMode::Auto,
bool $force = false,
+ ?string &$failureReason = null,
): bool {
+ $failureReason = null;
+
// identifier 형식 검증 (내부 호출 방어)
ExtensionManager::validateIdentifierFormat($moduleName);
@@ -404,9 +408,12 @@ class ModuleManager implements ModuleManagerInterface
$this->validateSeoVariables($module, 'module');
// 모듈 설치 실행
+ $module->clearLifecycleFailureReason();
$result = $module->install();
if (! $result) {
+ $failureReason = $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error');
+
return false;
}
@@ -540,9 +547,15 @@ class ModuleManager implements ModuleManagerInterface
{
$module = $this->getModule($moduleName);
if (! $module) {
- return ['success' => false, 'layouts_registered' => 0];
+ return [
+ 'success' => false,
+ 'layouts_registered' => 0,
+ 'reason' => __('modules.errors.not_found', ['module' => $moduleName]),
+ ];
}
+ $module->clearLifecycleFailureReason();
+
// 상태 가드: 진행 중 상태 체크
$record = $this->moduleRepository->findByIdentifier($module->getIdentifier());
if ($record) {
@@ -628,6 +641,13 @@ class ModuleManager implements ModuleManagerInterface
'updated_at' => now(),
]);
+ // 모듈 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
+ // 뒤따르는 굽기(RouteCacheHelper::rebuild() 의 route:cache, 훅 캐시 재생성)는
+ // 새 애플리케이션을 부팅해 "캐시된" 활성 모듈 목록을 읽는다. 여기서 비우지 않으면
+ // 방금 활성으로 바뀐 이 모듈이 목록에서 빠진 채 라우트가 박제되고,
+ // 라우트 캐시에는 스캔 폴백이 없어 오류·경고 없이 그 엔드포인트만 404 가 된다.
+ self::invalidateModuleStatusCache();
+
// soft deleted된 모듈 레이아웃 복원 (재활성화 시)
$this->restoreModuleLayouts($module->getIdentifier());
@@ -650,9 +670,6 @@ class ModuleManager implements ModuleManagerInterface
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
- // 모듈 상태 캐시 무효화
- self::invalidateModuleStatusCache();
-
// 본인인증 route scope 캐시 무효화 — 재활성화 시 이 모듈이 선언한 정책이
// 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가).
IdentityPolicy::flushRouteScopeCache();
@@ -667,7 +684,18 @@ class ModuleManager implements ModuleManagerInterface
HookManager::doAction('core.modules.activated', $moduleName);
}
- return ['success' => $result, 'layouts_registered' => $layoutsRegistered];
+ if (! $result) {
+ // 모듈이 스스로 활성화를 거부했다. 사유를 남겼으면 그대로 싣고,
+ // 남기지 않았으면 일반 문구로 대체한다 — 원인 자리를 비워 두면
+ // 관리자 화면에 치환되지 않은 자리표시자가 그대로 노출된다.
+ return [
+ 'success' => false,
+ 'layouts_registered' => $layoutsRegistered,
+ 'reason' => $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error'),
+ ];
+ }
+
+ return ['success' => true, 'layouts_registered' => $layoutsRegistered];
}
/**
@@ -714,9 +742,15 @@ class ModuleManager implements ModuleManagerInterface
): array {
$module = $this->getModule($moduleName);
if (! $module) {
- return ['success' => false, 'layouts_deleted' => 0];
+ return [
+ 'success' => false,
+ 'layouts_deleted' => 0,
+ 'reason' => __('modules.errors.not_found', ['module' => $moduleName]),
+ ];
}
+ $module->clearLifecycleFailureReason();
+
// 상태 가드: 진행 중 상태 체크
$record = $this->moduleRepository->findByIdentifier($module->getIdentifier());
if ($record) {
@@ -777,6 +811,12 @@ class ModuleManager implements ModuleManagerInterface
'updated_at' => now(),
]);
+ // 모듈 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
+ // 뒤따르는 RouteCacheHelper::rebuild() 가 캐시된 활성 모듈 목록을 읽으므로,
+ // 여기서 비우지 않으면 방금 비활성으로 바꾼 모듈의 라우트가 그대로 박제되어
+ // 비활성 상태에서도 그 API 가 계속 호출 가능한 상태로 남는다.
+ self::invalidateModuleStatusCache();
+
// 모듈 레이아웃 soft delete
$layoutsDeleted = $this->softDeleteModuleLayouts($module->getIdentifier());
@@ -796,9 +836,6 @@ class ModuleManager implements ModuleManagerInterface
// 모듈 자체 캐시 전체 정리
$this->flushModuleCache($module);
- // 모듈 상태 캐시 무효화
- self::invalidateModuleStatusCache();
-
// 본인인증 route scope 캐시 무효화 — 비활성 모듈이 선언한 정책이 enforce 대상에서
// 즉시 제외되도록 한다. 정책 행 자체는 변경하지 않으므로(enabled 운영자 설정 보존)
// IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다.
@@ -811,7 +848,15 @@ class ModuleManager implements ModuleManagerInterface
HookManager::doAction('core.modules.after_deactivate', $module->getIdentifier());
}
- return ['success' => $result, 'layouts_deleted' => $layoutsDeleted];
+ if (! $result) {
+ return [
+ 'success' => false,
+ 'layouts_deleted' => $layoutsDeleted,
+ 'reason' => $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error'),
+ ];
+ }
+
+ return ['success' => true, 'layouts_deleted' => $layoutsDeleted];
}
/**
@@ -859,12 +904,19 @@ class ModuleManager implements ModuleManagerInterface
* @param string $moduleName 제거할 모듈명
* @param bool $deleteData 모듈 데이터(테이블) 삭제 여부
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
+ * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 제거 성공 여부
*
* @throws \Exception 모듈을 찾을 수 없을 때
*/
- public function uninstallModule(string $moduleName, bool $deleteData = false, ?\Closure $onProgress = null): bool
- {
+ public function uninstallModule(
+ string $moduleName,
+ bool $deleteData = false,
+ ?\Closure $onProgress = null,
+ ?string &$failureReason = null,
+ ): bool {
+ $failureReason = null;
+
// 상태 가드: 진행 중 상태 체크
$existingRecord = $this->moduleRepository->findByIdentifier($moduleName);
if ($existingRecord) {
@@ -897,8 +949,13 @@ class ModuleManager implements ModuleManagerInterface
DB::beginTransaction();
// 모듈 제거 실행
+ $module->clearLifecycleFailureReason();
$result = $module->uninstall();
+ if (! $result) {
+ $failureReason = $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error');
+ }
+
if ($result) {
// 권한·메뉴·역할은 $deleteData=true 시에만 삭제.
// false 시 보존하여 재설치 시 기존 역할 할당/커스터마이징이 복원 가능하도록 한다.
@@ -960,6 +1017,12 @@ class ModuleManager implements ModuleManagerInterface
// 오토로드 병합 실행 (트랜잭션 외부에서 실행)
if ($result) {
+ // 모듈 상태 캐시 무효화 — DB 에서 모듈 행을 지운 직후(커밋 직후)에 둔다.
+ // 뒤따르는 굽기(오토로드 갱신 내 훅 캐시 재생성, RouteCacheHelper::rebuild())가
+ // 캐시된 활성 모듈 목록을 읽으므로, 여기서 비우지 않으면 이미 제거된 모듈이
+ // 목록에 남은 채로 라우트·훅이 박제된다.
+ self::invalidateModuleStatusCache();
+
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
$this->extensionManager->updateComposerAutoload();
@@ -977,9 +1040,6 @@ class ModuleManager implements ModuleManagerInterface
// 모듈 자체 캐시 전체 정리
$this->flushModuleCache($module);
- // 모듈 상태 캐시 무효화
- self::invalidateModuleStatusCache();
-
// 확장 미들웨어 인덱스 무효화 — 제거된 모듈의 미들웨어가 게이트 매칭에서 즉시 제외.
ExtensionMiddlewareRegistry::flush();
@@ -4495,6 +4555,13 @@ class ModuleManager implements ModuleManagerInterface
'updated_at' => now(),
]);
+ // 모듈 상태 캐시 무효화 — 상태 복원 쓰기 직후에 둔다.
+ // Updating 전이 직후에는 비우지 않는다: 그러면 Updating 창 안의
+ // updateComposerAutoload() 가 DB 를 재조회해 이 모듈을 비활성으로 판정하고
+ // 훅 캐시에서 리스너를 떨군다(지금 없는 결함을 새로 만든다).
+ // 복원 직후에 비워야 뒤따르는 굽기(라우트·훅)가 복원된 상태를 읽는다.
+ self::invalidateModuleStatusCache();
+
// 9. 레이아웃 갱신 (이전 상태가 active였으면)
// refreshModuleLayouts()는 캐시 무효화 + 캐시 버전 증가를 포함
$onProgress?->__invoke('layout', '레이아웃 갱신 중...');
@@ -4518,7 +4585,13 @@ class ModuleManager implements ModuleManagerInterface
$this->clearAllTemplateRoutesCaches();
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
- self::invalidateModuleStatusCache();
+
+ // 훅 캐시 재생성 — Updating 창 안의 updateComposerAutoload() 가 구운 훅 캐시에는
+ // 그 시점 이 모듈이 Updating(=비활성)으로 판정되어 리스너가 통째로 빠져 있을 수 있다.
+ // 훅 캐시 폴백은 파일 부재/손상에만 작동하므로 내용이 stale 한 경우는 조용히 통과한다.
+ // 상태를 복원하고 상태 캐시를 비운 지금 다시 구워야 그 누락이 교정된다.
+ // updateComposerAutoload() 전체를 재호출하지 않는다 — composer autoload 병합은 이미 끝났고 비싸다.
+ $this->extensionManager->regenerateHookCache();
// 훅 발행: 모듈 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
HookManager::doAction('core.modules.updated', $identifier);
diff --git a/app/Extension/PluginManager.php b/app/Extension/PluginManager.php
index 3c53cf25..dc016663 100644
--- a/app/Extension/PluginManager.php
+++ b/app/Extension/PluginManager.php
@@ -296,6 +296,7 @@ class PluginManager implements PluginManagerInterface
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param VendorMode $vendorMode vendor 디렉토리 처리 모드
* @param bool $force 강제 설치 여부
+ * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 설치 성공 여부
*
* @throws \Exception 플러그인을 찾을 수 없거나 의존성 문제 시
@@ -305,7 +306,10 @@ class PluginManager implements PluginManagerInterface
?\Closure $onProgress = null,
VendorMode $vendorMode = VendorMode::Auto,
bool $force = false,
+ ?string &$failureReason = null,
): bool {
+ $failureReason = null;
+
// identifier 형식 검증 (내부 호출 방어)
ExtensionManager::validateIdentifierFormat($pluginName);
@@ -386,8 +390,13 @@ class PluginManager implements PluginManagerInterface
// 플러그인 설치 실행
$onProgress?->__invoke('validate', '검증 중...');
+ $plugin->clearLifecycleFailureReason();
$result = $plugin->install();
+ if (! $result) {
+ $failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
+ }
+
if (! $result) {
return false;
}
@@ -522,9 +531,15 @@ class PluginManager implements PluginManagerInterface
{
$plugin = $this->getPlugin($pluginName);
if (! $plugin) {
- return ['success' => false, 'layouts_registered' => 0];
+ return [
+ 'success' => false,
+ 'layouts_registered' => 0,
+ 'reason' => __('plugins.errors.not_found', ['plugin' => $pluginName]),
+ ];
}
+ $plugin->clearLifecycleFailureReason();
+
// 상태 가드: 진행 중 상태 체크
$record = $this->pluginRepository->findByIdentifier($plugin->getIdentifier());
if ($record) {
@@ -613,6 +628,13 @@ class PluginManager implements PluginManagerInterface
'updated_at' => now(),
]);
+ // 플러그인 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
+ // 뒤따르는 굽기(RouteCacheHelper::rebuild() 의 route:cache, 훅 캐시 재생성)는
+ // 새 애플리케이션을 부팅해 "캐시된" 활성 플러그인 목록을 읽는다. 여기서 비우지 않으면
+ // 방금 활성으로 바뀐 이 플러그인이 목록에서 빠진 채 라우트가 박제되고,
+ // 라우트 캐시에는 스캔 폴백이 없어 오류·경고 없이 그 엔드포인트만 404 가 된다.
+ self::invalidatePluginStatusCache();
+
// soft deleted된 플러그인 레이아웃 복원 (재활성화 시)
$this->restorePluginLayouts($plugin->getIdentifier());
@@ -635,9 +657,6 @@ class PluginManager implements PluginManagerInterface
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
- // 플러그인 상태 캐시 무효화
- self::invalidatePluginStatusCache();
-
// 본인인증 route scope 캐시 무효화 — 재활성화 시 이 플러그인이 선언한 정책이
// 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가).
IdentityPolicy::flushRouteScopeCache();
@@ -652,7 +671,18 @@ class PluginManager implements PluginManagerInterface
HookManager::doAction('core.plugins.activated', $pluginName);
}
- return ['success' => $result, 'layouts_registered' => $layoutsRegistered];
+ if (! $result) {
+ // 플러그인이 스스로 활성화를 거부했다. 사유를 남겼으면 그대로 싣고,
+ // 남기지 않았으면 일반 문구로 대체한다 — 원인 자리를 비워 두면
+ // 관리자 화면에 치환되지 않은 자리표시자가 그대로 노출된다.
+ return [
+ 'success' => false,
+ 'layouts_registered' => $layoutsRegistered,
+ 'reason' => $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error'),
+ ];
+ }
+
+ return ['success' => true, 'layouts_registered' => $layoutsRegistered];
}
/**
@@ -699,9 +729,15 @@ class PluginManager implements PluginManagerInterface
): array {
$plugin = $this->getPlugin($pluginName);
if (! $plugin) {
- return ['success' => false, 'layouts_deleted' => 0];
+ return [
+ 'success' => false,
+ 'layouts_deleted' => 0,
+ 'reason' => __('plugins.errors.not_found', ['plugin' => $pluginName]),
+ ];
}
+ $plugin->clearLifecycleFailureReason();
+
// 상태 가드: 진행 중 상태 체크
$record = $this->pluginRepository->findByIdentifier($plugin->getIdentifier());
if ($record) {
@@ -765,6 +801,12 @@ class PluginManager implements PluginManagerInterface
'updated_at' => now(),
]);
+ // 플러그인 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
+ // 뒤따르는 RouteCacheHelper::rebuild() 가 캐시된 활성 플러그인 목록을 읽으므로,
+ // 여기서 비우지 않으면 방금 비활성으로 바꾼 플러그인의 라우트가 그대로 박제되어
+ // 비활성 상태에서도 그 API 가 계속 호출 가능한 상태로 남는다.
+ self::invalidatePluginStatusCache();
+
// 플러그인 레이아웃 soft delete
$layoutsDeleted = $this->softDeletePluginLayouts($plugin->getIdentifier());
@@ -784,9 +826,6 @@ class PluginManager implements PluginManagerInterface
// 플러그인 자체 캐시 전체 정리
$this->flushPluginCache($plugin);
- // 플러그인 상태 캐시 무효화
- self::invalidatePluginStatusCache();
-
// 본인인증 route scope 캐시 무효화 — 비활성 플러그인이 선언한 정책이 enforce
// 대상에서 즉시 제외되도록 한다. 정책 행은 변경하지 않으므로(enabled 보존)
// IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다.
@@ -801,6 +840,10 @@ class PluginManager implements PluginManagerInterface
$response = ['success' => $result, 'layouts_deleted' => $layoutsDeleted];
+ if (! $result) {
+ $response['reason'] = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
+ }
+
if (! empty($driverWarnings)) {
$response['driver_warnings'] = $driverWarnings;
}
@@ -888,12 +931,19 @@ class PluginManager implements PluginManagerInterface
* @param string $pluginName 제거할 플러그인명
* @param bool $deleteData 플러그인 데이터(테이블) 삭제 여부
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
+ * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 제거 성공 여부
*
* @throws \Exception 플러그인을 찾을 수 없을 때
*/
- public function uninstallPlugin(string $pluginName, bool $deleteData = false, ?\Closure $onProgress = null): bool
- {
+ public function uninstallPlugin(
+ string $pluginName,
+ bool $deleteData = false,
+ ?\Closure $onProgress = null,
+ ?string &$failureReason = null,
+ ): bool {
+ $failureReason = null;
+
// 상태 가드: 진행 중 상태 체크
$existingRecord = $this->pluginRepository->findByIdentifier($pluginName);
if ($existingRecord) {
@@ -922,8 +972,13 @@ class PluginManager implements PluginManagerInterface
DB::beginTransaction();
// 플러그인 제거 실행
+ $plugin->clearLifecycleFailureReason();
$result = $plugin->uninstall();
+ if (! $result) {
+ $failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
+ }
+
if ($result) {
// 권한/역할은 $deleteData=true 시에만 삭제.
// 운영 정책: "동적 권한은 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
@@ -984,6 +1039,12 @@ class PluginManager implements PluginManagerInterface
// 트랜잭션 외부에서 실행
if ($result) {
+ // 플러그인 상태 캐시 무효화 — DB 에서 플러그인 행을 지운 직후(커밋 직후)에 둔다.
+ // 뒤따르는 굽기(오토로드 갱신 내 훅 캐시 재생성, RouteCacheHelper::rebuild())가
+ // 캐시된 활성 플러그인 목록을 읽으므로, 여기서 비우지 않으면 이미 제거된
+ // 플러그인이 목록에 남은 채로 라우트·훅이 박제된다.
+ self::invalidatePluginStatusCache();
+
// 플러그인 설정 디렉토리 삭제 (deleteData 옵션이 true인 경우)
if ($deleteData) {
$this->deletePluginSettingsDirectory($plugin);
@@ -1009,9 +1070,6 @@ class PluginManager implements PluginManagerInterface
// 플러그인 자체 캐시 전체 정리
$this->flushPluginCache($plugin);
- // 플러그인 상태 캐시 무효화
- self::invalidatePluginStatusCache();
-
// 확장 미들웨어 인덱스 무효화 — 제거된 플러그인의 미들웨어가 게이트 매칭에서 즉시 제외.
ExtensionMiddlewareRegistry::flush();
@@ -4725,6 +4783,13 @@ class PluginManager implements PluginManagerInterface
'updated_at' => now(),
]);
+ // 플러그인 상태 캐시 무효화 — 상태 복원 쓰기 직후에 둔다.
+ // Updating 전이 직후에는 비우지 않는다: 그러면 Updating 창 안의
+ // updateComposerAutoload() 가 DB 를 재조회해 이 플러그인을 비활성으로 판정하고
+ // 훅 캐시에서 리스너를 떨군다(지금 없는 결함을 새로 만든다).
+ // 복원 직후에 비워야 뒤따르는 굽기(라우트·훅)가 복원된 상태를 읽는다.
+ self::invalidatePluginStatusCache();
+
// 9. 레이아웃 갱신 (이전 상태가 active였으면)
// refreshPluginLayouts()는 캐시 무효화 + 캐시 버전 증가를 포함
$onProgress?->__invoke('layout', '레이아웃 갱신 중...');
@@ -4748,7 +4813,13 @@ class PluginManager implements PluginManagerInterface
$this->clearAllTemplateRoutesCaches();
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
- self::invalidatePluginStatusCache();
+
+ // 훅 캐시 재생성 — Updating 창 안의 updateComposerAutoload() 가 구운 훅 캐시에는
+ // 그 시점 이 플러그인이 Updating(=비활성)으로 판정되어 리스너가 통째로 빠져 있을 수 있다.
+ // 훅 캐시 폴백은 파일 부재/손상에만 작동하므로 내용이 stale 한 경우는 조용히 통과한다.
+ // 상태를 복원하고 상태 캐시를 비운 지금 다시 구워야 그 누락이 교정된다.
+ // updateComposerAutoload() 전체를 재호출하지 않는다 — composer autoload 병합은 이미 끝났고 비싸다.
+ $this->extensionManager->regenerateHookCache();
// 훅 발행: 플러그인 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
HookManager::doAction('core.plugins.updated', $identifier);
diff --git a/app/Extension/TemplateManager.php b/app/Extension/TemplateManager.php
index 6f13c8c1..9fd3fbc2 100644
--- a/app/Extension/TemplateManager.php
+++ b/app/Extension/TemplateManager.php
@@ -672,15 +672,21 @@ class TemplateManager implements TemplateManagerInterface
* @param string $templateName 비활성화할 템플릿명 (identifier)
* @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
* @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
+ * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 비활성화 성공 여부
*/
public function deactivateTemplate(
string $templateName,
string $reason = DeactivationReason::Manual->value,
?string $incompatibleRequiredVersion = null,
+ ?string &$failureReason = null,
): bool {
+ $failureReason = null;
+
$template = $this->getTemplate($templateName);
if (! $template) {
+ $failureReason = __('templates.errors.not_found', ['template' => $templateName]);
+
return false;
}
diff --git a/app/Extension/Traits/ReportsLifecycleFailure.php b/app/Extension/Traits/ReportsLifecycleFailure.php
new file mode 100644
index 00000000..06662cfc
--- /dev/null
+++ b/app/Extension/Traits/ReportsLifecycleFailure.php
@@ -0,0 +1,58 @@
+lifecycleFailureReason;
+ }
+
+ /**
+ * 실패 사유를 남기고 false 를 반환합니다.
+ *
+ * 수명주기 훅에서 `return $this->failWith(__('...'));` 형태로 사용합니다.
+ *
+ * @param string $reason 운영자에게 보일 실패 사유 (번역된 문장)
+ * @return false 언제나 false
+ */
+ protected function failWith(string $reason): bool
+ {
+ $this->lifecycleFailureReason = $reason;
+
+ return false;
+ }
+
+ /**
+ * 직전 실패 사유를 지웁니다.
+ *
+ * 같은 확장 인스턴스로 수명주기 훅을 다시 부르기 전에 코어가 호출합니다.
+ * 지우지 않으면 이전 실패의 사유가 다음 성공/실패에 그대로 따라붙는다.
+ */
+ public function clearLifecycleFailureReason(): void
+ {
+ $this->lifecycleFailureReason = null;
+ }
+}
diff --git a/app/Http/Controllers/Api/Admin/ModuleController.php b/app/Http/Controllers/Api/Admin/ModuleController.php
index 74ffb478..ce2c228f 100644
--- a/app/Http/Controllers/Api/Admin/ModuleController.php
+++ b/app/Http/Controllers/Api/Admin/ModuleController.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Admin;
use App\Enums\LanguagePackScope;
+use App\Exceptions\ModuleOperationException;
use App\Extension\Vendor\VendorMode;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
@@ -199,7 +200,7 @@ class ModuleController extends AdminBaseController
// cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort)
$this->installSelectedDependencies($validated['dependencies'] ?? []);
- $module = $this->moduleService->installModule($moduleName, $vendorMode);
+ $module = $this->moduleService->installModule($moduleName, $vendorMode, false, $installFailureReason);
if ($module) {
// cascade 2단계: 동반 번들 언어팩 best-effort 설치
@@ -210,7 +211,9 @@ class ModuleController extends AdminBaseController
return $this->success('module.install_success', $payload, 201);
} else {
- return $this->error('module.install_failed');
+ return $this->error('module.install_failed', 400, null, [
+ 'error' => $installFailureReason ?? __('modules.errors.unknown_error'),
+ ]);
}
} catch (ValidationException $e) {
// Service에서 이미 번역된 메시지를 errors에 포함하므로
@@ -271,10 +274,12 @@ class ModuleController extends AdminBaseController
'pending_language_packs' => $pendingLanguagePacks,
]));
} else {
- return $this->error('module.activate_failed');
+ return $this->error('module.activate_failed', 400, null, [
+ 'error' => $result['reason'] ?? __('modules.errors.unknown_error'),
+ ]);
}
} catch (ValidationException $e) {
- return $this->error('module.activate_failed', 422, $e->errors());
+ return $this->error('module.activate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('module.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -320,10 +325,12 @@ class ModuleController extends AdminBaseController
return $this->success('module.deactivate_success', $result);
} else {
- return $this->error('module.deactivate_failed');
+ return $this->error('module.deactivate_failed', 400, null, [
+ 'error' => $result['reason'] ?? __('modules.errors.unknown_error'),
+ ]);
}
} catch (ValidationException $e) {
- return $this->error('module.deactivate_failed', 422, $e->errors());
+ return $this->error('module.deactivate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('module.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -383,15 +390,17 @@ class ModuleController extends AdminBaseController
$moduleName = $validated['module_name'];
$deleteData = $validated['delete_data'] ?? false;
- $result = $this->moduleService->uninstallModule($moduleName, $deleteData);
+ $result = $this->moduleService->uninstallModule($moduleName, $deleteData, $uninstallFailureReason);
if ($result) {
return $this->success('module.uninstall_success');
} else {
- return $this->error('module.uninstall_failed');
+ return $this->error('module.uninstall_failed', 400, null, [
+ 'error' => $uninstallFailureReason ?? __('modules.errors.unknown_error'),
+ ]);
}
} catch (ValidationException $e) {
- return $this->error('module.uninstall_failed', 422, $e->errors());
+ return $this->error('module.uninstall_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('module.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -433,8 +442,10 @@ class ModuleController extends AdminBaseController
new ModuleResource($module),
201
);
- } catch (\RuntimeException $e) {
- return $this->error($e->getMessage(), 422);
+ } catch (ModuleOperationException $e) {
+ // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
+ // 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
+ return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) {
return $this->error('module.install_failed', 500, null, ['error' => $e->getMessage()]);
}
@@ -457,8 +468,10 @@ class ModuleController extends AdminBaseController
new ModuleResource($module),
201
);
- } catch (\RuntimeException $e) {
- return $this->error($e->getMessage(), 422);
+ } catch (ModuleOperationException $e) {
+ // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
+ // 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
+ return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) {
return $this->error('module.install_failed', 500, null, ['error' => $e->getMessage()]);
}
@@ -476,7 +489,7 @@ class ModuleController extends AdminBaseController
return $this->success('modules.check_updates_success', $result);
} catch (ValidationException $e) {
- return $this->error('modules.check_updates_failed', 422, $e->errors());
+ return $this->error('modules.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('modules.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -505,7 +518,7 @@ class ModuleController extends AdminBaseController
return $this->success('modules.check_modified_layouts_success', $result);
} catch (ValidationException $e) {
- return $this->error('modules.check_modified_layouts_failed', 422, $e->errors());
+ return $this->error('modules.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('modules.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -596,10 +609,12 @@ class ModuleController extends AdminBaseController
new ModuleResource($module)
);
} else {
- return $this->error('module.refresh_layouts_failed');
+ return $this->error('module.refresh_layouts_failed', 400, null, [
+ 'error' => __('modules.errors.unknown_error'),
+ ]);
}
} catch (ValidationException $e) {
- return $this->error('module.refresh_layouts_failed', 422, $e->errors());
+ return $this->error('module.refresh_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('module.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
diff --git a/app/Http/Controllers/Api/Admin/PluginController.php b/app/Http/Controllers/Api/Admin/PluginController.php
index 0fcc883e..7f85fb81 100644
--- a/app/Http/Controllers/Api/Admin/PluginController.php
+++ b/app/Http/Controllers/Api/Admin/PluginController.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Admin;
use App\Enums\LanguagePackScope;
+use App\Exceptions\PluginOperationException;
use App\Extension\Vendor\VendorMode;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
@@ -188,7 +189,7 @@ class PluginController extends AdminBaseController
// cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort)
$this->installSelectedDependencies($validated['dependencies'] ?? []);
- $pluginInfo = $this->pluginService->installPlugin($pluginName, $vendorMode);
+ $pluginInfo = $this->pluginService->installPlugin($pluginName, $vendorMode, false, $installFailureReason);
if ($pluginInfo) {
// cascade 2단계: 동반 번들 언어팩 best-effort 설치
@@ -199,7 +200,9 @@ class PluginController extends AdminBaseController
return $this->success('plugins.install_success', $payload);
} else {
- return $this->error('plugins.install_failed');
+ return $this->error('plugins.install_failed', 400, null, [
+ 'error' => $installFailureReason ?? __('plugins.errors.unknown_error'),
+ ]);
}
} catch (ValidationException $e) {
// Service에서 이미 번역된 메시지를 errors에 포함하므로
@@ -260,13 +263,16 @@ class PluginController extends AdminBaseController
'pending_language_packs' => $pendingLanguagePacks,
]));
} else {
- return $this->error('plugins.activate_failed');
+ return $this->error('plugins.activate_failed', 400, null, [
+ 'error' => $result['reason'] ?? __('plugins.errors.unknown_error'),
+ ]);
}
} catch (ValidationException $e) {
return $this->error(
'plugins.activate_validation_failed',
422,
- $e->errors()
+ $e->errors(),
+ ['error' => $e->getMessage()]
);
} catch (\Exception $e) {
return $this->error(
@@ -317,13 +323,16 @@ class PluginController extends AdminBaseController
return $this->success('plugins.deactivate_success', $result);
} else {
- return $this->error('plugins.deactivate_failed');
+ return $this->error('plugins.deactivate_failed', 400, null, [
+ 'error' => $result['reason'] ?? __('plugins.errors.unknown_error'),
+ ]);
}
} catch (ValidationException $e) {
return $this->error(
'plugins.deactivate_validation_failed',
422,
- $e->errors()
+ $e->errors(),
+ ['error' => $e->getMessage()]
);
} catch (\Exception $e) {
return $this->error(
@@ -388,18 +397,21 @@ class PluginController extends AdminBaseController
$pluginName = $validated['plugin_name'];
$deleteData = $validated['delete_data'] ?? false;
- $result = $this->pluginService->uninstallPlugin($pluginName, $deleteData);
+ $result = $this->pluginService->uninstallPlugin($pluginName, $deleteData, $uninstallFailureReason);
if ($result) {
return $this->success('plugins.uninstall_success');
} else {
- return $this->error('plugins.uninstall_failed');
+ return $this->error('plugins.uninstall_failed', 400, null, [
+ 'error' => $uninstallFailureReason ?? __('plugins.errors.unknown_error'),
+ ]);
}
} catch (ValidationException $e) {
return $this->error(
'plugins.uninstall_validation_failed',
422,
- $e->errors()
+ $e->errors(),
+ ['error' => $e->getMessage()]
);
} catch (\Exception $e) {
return $this->error(
@@ -444,8 +456,10 @@ class PluginController extends AdminBaseController
new PluginResource($plugin),
201
);
- } catch (\RuntimeException $e) {
- return $this->error($e->getMessage(), 422);
+ } catch (PluginOperationException $e) {
+ // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
+ // 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
+ return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) {
return $this->error('plugins.install_failed', 500, null, ['error' => $e->getMessage()]);
}
@@ -468,8 +482,10 @@ class PluginController extends AdminBaseController
new PluginResource($plugin),
201
);
- } catch (\RuntimeException $e) {
- return $this->error($e->getMessage(), 422);
+ } catch (PluginOperationException $e) {
+ // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
+ // 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
+ return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) {
return $this->error('plugins.install_failed', 500, null, ['error' => $e->getMessage()]);
}
@@ -487,7 +503,7 @@ class PluginController extends AdminBaseController
return $this->success('plugins.check_updates_success', $result);
} catch (ValidationException $e) {
- return $this->error('plugins.check_updates_failed', 422, $e->errors());
+ return $this->error('plugins.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('plugins.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -512,7 +528,7 @@ class PluginController extends AdminBaseController
return $this->success('plugins.check_modified_layouts_success', $result);
} catch (ValidationException $e) {
- return $this->error('plugins.check_modified_layouts_failed', 422, $e->errors());
+ return $this->error('plugins.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('plugins.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -608,13 +624,16 @@ class PluginController extends AdminBaseController
'unchanged' => $result['unchanged'],
]);
} else {
- return $this->error('plugins.refresh_layouts_failed');
+ return $this->error('plugins.refresh_layouts_failed', 400, null, [
+ 'error' => __('plugins.errors.unknown_error'),
+ ]);
}
} catch (ValidationException $e) {
return $this->error(
'plugins.refresh_layouts_validation_failed',
422,
- $e->errors()
+ $e->errors(),
+ ['error' => $e->getMessage()]
);
} catch (\Exception $e) {
return $this->error(
diff --git a/app/Http/Controllers/Api/Admin/PluginSettingsController.php b/app/Http/Controllers/Api/Admin/PluginSettingsController.php
index a4b1a1e0..7387440c 100644
--- a/app/Http/Controllers/Api/Admin/PluginSettingsController.php
+++ b/app/Http/Controllers/Api/Admin/PluginSettingsController.php
@@ -113,10 +113,12 @@ class PluginSettingsController extends AdminBaseController
// 그대로 설정 파일에 병합되는 경로로만 동작했다 (mass-assignment).
$settings = $request->validated();
- $result = $this->pluginSettingsService->save($identifier, $settings);
+ $result = $this->pluginSettingsService->save($identifier, $settings, $failureReason);
if (! $result) {
- return $this->error('plugins.settings.update_failed', 500);
+ return $this->error('plugins.settings.update_failed', 500, null, [
+ 'error' => $failureReason ?? __('plugins.errors.unknown_error'),
+ ]);
}
// 저장 응답에도 카탈로그 재부착 — 화면 폼 상태가 응답으로 갱신되므로
diff --git a/app/Http/Controllers/Api/Admin/SettingsController.php b/app/Http/Controllers/Api/Admin/SettingsController.php
index 3dc58098..dd00c684 100644
--- a/app/Http/Controllers/Api/Admin/SettingsController.php
+++ b/app/Http/Controllers/Api/Admin/SettingsController.php
@@ -8,10 +8,12 @@ use App\Http\Requests\Settings\RestoreSettingsRequest;
use App\Http\Requests\Settings\SaveSettingsRequest;
use App\Http\Requests\Settings\TestDriverConnectionRequest;
use App\Http\Requests\Settings\TestMailRequest;
+use App\Http\Requests\Settings\TestOutboundProxyRequest;
use App\Http\Requests\Settings\UpdateSettingRequest;
use App\Http\Resources\SettingsResource;
use App\Services\DriverConnectionTester;
use App\Services\DriverRegistryService;
+use App\Services\OutboundProxyTester;
use App\Services\SettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
@@ -27,7 +29,8 @@ class SettingsController extends AdminBaseController
public function __construct(
private SettingsService $settingsService,
private DriverConnectionTester $driverConnectionTester,
- private DriverRegistryService $driverRegistryService
+ private DriverRegistryService $driverRegistryService,
+ private OutboundProxyTester $outboundProxyTester
) {
parent::__construct();
}
@@ -340,4 +343,30 @@ class SettingsController extends AdminBaseController
return $this->error('settings.driver_test_error', 500, $e->getMessage());
}
}
+
+ /**
+ * 아웃바운드 프록시 연결을 테스트합니다.
+ *
+ * 저장하기 전에 프록시가 실제로 동작하는지, 그리고 그 프록시를 거쳐 나갔을 때 상대편에
+ * 어떤 IP 로 보이는지 확인합니다. 출발지 IP 는 운영자가 결제사·외부 서비스에 등록해야
+ * 하는 값이라 결과의 핵심입니다.
+ *
+ * 검사 대상은 저장된 설정이 아니라 이번 요청이 제출한 값입니다.
+ *
+ * @param TestOutboundProxyRequest $request 검증된 요청
+ * @return JsonResponse 검사 결과
+ */
+ public function testOutboundProxy(TestOutboundProxyRequest $request): JsonResponse
+ {
+ $validated = $request->validated();
+
+ $result = $this->outboundProxyTester->test(
+ (string) $validated['outbound_proxy'],
+ (array) ($validated['outbound_proxy_bypass'] ?? [])
+ );
+
+ // 연결 실패는 요청 처리 실패가 아니라 진단 결과다 — 200 으로 결과를 돌려주고
+ // 성공 여부는 페이로드가 말한다 (드라이버 연결 테스트와 같은 규약).
+ return $this->success($result['message_key'], $result);
+ }
}
diff --git a/app/Http/Controllers/Api/Admin/TemplateController.php b/app/Http/Controllers/Api/Admin/TemplateController.php
index 8eeabc3a..5bbef459 100644
--- a/app/Http/Controllers/Api/Admin/TemplateController.php
+++ b/app/Http/Controllers/Api/Admin/TemplateController.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Admin;
use App\Enums\LanguagePackScope;
+use App\Exceptions\TemplateOperationException;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
@@ -169,7 +170,9 @@ class TemplateController extends AdminBaseController
return $this->success('templates.install_success', $payload, 201);
} else {
- return $this->error('templates.install_failed');
+ return $this->error('templates.install_failed', 400, null, [
+ 'error' => __('templates.errors.unknown_error'),
+ ]);
}
} catch (ValidationException $e) {
// Service에서 이미 번역된 메시지를 errors에 포함하므로
@@ -230,10 +233,12 @@ class TemplateController extends AdminBaseController
'pending_language_packs' => $pendingLanguagePacks,
]));
} else {
- return $this->error('templates.activate_failed');
+ return $this->error('templates.activate_failed', 400, null, [
+ 'error' => $result['reason'] ?? __('templates.errors.unknown_error'),
+ ]);
}
} catch (ValidationException $e) {
- return $this->error('templates.activate_failed', 422, $e->errors());
+ return $this->error('templates.activate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -249,7 +254,7 @@ class TemplateController extends AdminBaseController
{
try {
$templateName = $request->validated()['template_name'];
- $template = $this->templateService->deactivateTemplate($templateName);
+ $template = $this->templateService->deactivateTemplate($templateName, $deactivateFailureReason);
if ($template) {
return $this->successWithResource(
@@ -257,10 +262,12 @@ class TemplateController extends AdminBaseController
new TemplateResource($template)
);
} else {
- return $this->error('templates.deactivate_failed');
+ return $this->error('templates.deactivate_failed', 400, null, [
+ 'error' => $deactivateFailureReason ?? __('templates.errors.unknown_error'),
+ ]);
}
} catch (ValidationException $e) {
- return $this->error('templates.deactivate_failed', 422, $e->errors());
+ return $this->error('templates.deactivate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -284,10 +291,12 @@ class TemplateController extends AdminBaseController
if ($result) {
return $this->success('templates.uninstall_success');
} else {
- return $this->error('templates.uninstall_failed');
+ return $this->error('templates.uninstall_failed', 400, null, [
+ 'error' => __('templates.errors.unknown_error'),
+ ]);
}
} catch (ValidationException $e) {
- return $this->error('templates.uninstall_failed', 422, $e->errors());
+ return $this->error('templates.uninstall_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -348,8 +357,10 @@ class TemplateController extends AdminBaseController
new TemplateResource($template),
201
);
- } catch (\RuntimeException $e) {
- return $this->error($e->getMessage(), 422);
+ } catch (TemplateOperationException $e) {
+ // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
+ // 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
+ return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) {
return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]);
}
@@ -372,8 +383,10 @@ class TemplateController extends AdminBaseController
new TemplateResource($template),
201
);
- } catch (\RuntimeException $e) {
- return $this->error($e->getMessage(), 422);
+ } catch (TemplateOperationException $e) {
+ // 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
+ // 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
+ return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) {
return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]);
}
@@ -397,10 +410,12 @@ class TemplateController extends AdminBaseController
new TemplateResource($template)
);
} else {
- return $this->error('templates.refresh_layouts_failed');
+ return $this->error('templates.refresh_layouts_failed', 400, null, [
+ 'error' => __('templates.errors.unknown_error'),
+ ]);
}
} catch (ValidationException $e) {
- return $this->error('templates.refresh_layouts_failed', 422, $e->errors());
+ return $this->error('templates.refresh_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -418,7 +433,7 @@ class TemplateController extends AdminBaseController
return $this->success('templates.check_updates_success', $result);
} catch (ValidationException $e) {
- return $this->error('templates.check_updates_failed', 422, $e->errors());
+ return $this->error('templates.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -446,7 +461,7 @@ class TemplateController extends AdminBaseController
return $this->success('templates.check_modified_layouts_success', $result);
} catch (ValidationException $e) {
- return $this->error('templates.check_modified_layouts_failed', 422, $e->errors());
+ return $this->error('templates.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php
index a7da06c5..6ce8165a 100644
--- a/app/Http/Requests/Settings/SaveSettingsRequest.php
+++ b/app/Http/Requests/Settings/SaveSettingsRequest.php
@@ -4,6 +4,7 @@ namespace App\Http\Requests\Settings;
use App\Extension\HookManager;
use App\Models\Attachment;
+use App\Rules\ValidOutboundProxyUrl;
use App\Search\Engines\DatabaseFulltextEngine;
use App\Services\DriverRegistryService;
use App\Support\AllowedExtensions;
@@ -296,6 +297,13 @@ class SaveSettingsRequest extends FormRequest
'advanced.debug_mode' => $this->getTabRules($tab, 'advanced', 'boolean'),
'advanced.sql_query_log' => $this->getTabRules($tab, 'advanced', 'boolean'),
+ // 아웃바운드 HTTP 프록시 (advanced 탭)
+ // 디버그 모드 OFF 시 하위 필드는 collapse 되어 미전송됨 → nullable 필수
+ // (geoip 하위 필드와 같은 사유 — 조건부 렌더링 내부에 있다)
+ 'advanced.outbound_proxy' => ['nullable', 'string', 'max:500', new ValidOutboundProxyUrl],
+ 'advanced.outbound_proxy_bypass' => ['nullable', 'array'],
+ 'advanced.outbound_proxy_bypass.*' => ['string', 'max:255'],
+
// 코어 업데이트 설정 (advanced 탭)
'advanced.core_update_github_url' => ['nullable', 'url', 'max:500'],
'advanced.core_update_github_token' => ['nullable', 'string', 'max:500'],
@@ -779,6 +787,13 @@ class SaveSettingsRequest extends FormRequest
'advanced.sql_query_log.required' => __('validation.settings.sql_query_log_required'),
'advanced.sql_query_log.boolean' => __('validation.settings.sql_query_log_boolean'),
+ // 아웃바운드 HTTP 프록시
+ 'advanced.outbound_proxy.string' => __('validation.settings.outbound_proxy_string'),
+ 'advanced.outbound_proxy.max' => __('validation.settings.outbound_proxy_max'),
+ 'advanced.outbound_proxy_bypass.array' => __('validation.settings.outbound_proxy_bypass_array'),
+ 'advanced.outbound_proxy_bypass.*.string' => __('validation.settings.outbound_proxy_bypass_item_string'),
+ 'advanced.outbound_proxy_bypass.*.max' => __('validation.settings.outbound_proxy_bypass_item_max'),
+
// 목록 한계값
'advanced.pagination_result_cap.integer' => __('validation.settings.pagination_result_cap_integer'),
'advanced.pagination_result_cap.min' => __('validation.settings.pagination_result_cap_min'),
@@ -972,6 +987,8 @@ class SaveSettingsRequest extends FormRequest
'advanced.seo_sitemap_cache_ttl' => __('validation.attributes.seo_sitemap_cache_ttl'),
'advanced.debug_mode' => __('validation.attributes.debug_mode'),
'advanced.sql_query_log' => __('validation.attributes.sql_query_log'),
+ 'advanced.outbound_proxy' => __('validation.attributes.outbound_proxy'),
+ 'advanced.outbound_proxy_bypass' => __('validation.attributes.outbound_proxy_bypass'),
'advanced.core_update_github_url' => __('validation.attributes.core_update_github_url'),
'advanced.core_update_github_token' => __('validation.attributes.core_update_github_token'),
'advanced.geoip_enabled' => __('validation.attributes.geoip_enabled'),
diff --git a/app/Http/Requests/Settings/TestOutboundProxyRequest.php b/app/Http/Requests/Settings/TestOutboundProxyRequest.php
new file mode 100644
index 00000000..d45b4f8d
--- /dev/null
+++ b/app/Http/Requests/Settings/TestOutboundProxyRequest.php
@@ -0,0 +1,76 @@
+|string>
+ */
+ public function rules(): array
+ {
+ $rules = [
+ 'outbound_proxy' => ['required', 'string', 'max:500', new ValidOutboundProxyUrl],
+ 'outbound_proxy_bypass' => ['nullable', 'array'],
+ 'outbound_proxy_bypass.*' => ['string', 'max:255'],
+ ];
+
+ return HookManager::applyFilters('core.settings.test_outbound_proxy_validation_rules', $rules, $this);
+ }
+
+ /**
+ * 검증 실패 메시지를 반환합니다.
+ *
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'outbound_proxy.required' => __('validation.settings.outbound_proxy_required'),
+ 'outbound_proxy.string' => __('validation.settings.outbound_proxy_string'),
+ 'outbound_proxy.max' => __('validation.settings.outbound_proxy_max'),
+ 'outbound_proxy_bypass.array' => __('validation.settings.outbound_proxy_bypass_array'),
+ 'outbound_proxy_bypass.*.string' => __('validation.settings.outbound_proxy_bypass_item_string'),
+ 'outbound_proxy_bypass.*.max' => __('validation.settings.outbound_proxy_bypass_item_max'),
+ ];
+ }
+
+ /**
+ * 검증 속성명을 반환합니다.
+ *
+ * @return array
+ */
+ public function attributes(): array
+ {
+ return [
+ 'outbound_proxy' => __('validation.attributes.outbound_proxy'),
+ 'outbound_proxy_bypass' => __('validation.attributes.outbound_proxy_bypass'),
+ ];
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 3a22a476..4975166c 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -21,6 +21,7 @@ use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Http\Request;
use Illuminate\Notifications\ChannelManager;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Schema;
@@ -104,6 +105,9 @@ class AppServiceProvider extends ServiceProvider
// SQL 쿼리 로그 설정
$this->configureSqlQueryLogging();
+ // 아웃바운드 HTTP 프록시 설정
+ $this->configureOutboundProxy();
+
// 로그인 라우트 per-IP 백업 throttle — 보안 환경설정의 per-account 잠금과 2중 방어.
// 존재하지 않는 계정에 대한 brute-force / 동일 IP 의 다른 계정 시도까지 차단.
$this->configureLoginRateLimiter();
@@ -154,6 +158,29 @@ class AppServiceProvider extends ServiceProvider
}
}
+ /**
+ * 아웃바운드 HTTP 프록시를 설정합니다.
+ *
+ * 환경설정에 프록시가 지정되어 있으면 `Http::` 파사드로 나가는 모든 요청이 그 프록시를
+ * 경유합니다. 결제 승인, 코어 업데이트 조회, GeoIP 내려받기, 알림 웹훅 등 확장이 보내는
+ * 요청까지 함께 적용되므로, 확장 코드를 고치지 않고도 출발지 IP 를 바꿀 수 있습니다.
+ *
+ * 적용 여부 판정은 `App\Support\OutboundProxy` 가 소유하며, 이 메서드는 판정 결과만
+ * 소비합니다 — 디버그 모드 게이트를 여기서 다시 검사하지 않는 이유입니다.
+ *
+ * 개별 요청이 `withOptions(['proxy' => ...])` 로 지정한 값은 전역 옵션보다 우선합니다.
+ */
+ private function configureOutboundProxy(): void
+ {
+ $proxy = config('g7.outbound_proxy');
+
+ if (empty($proxy)) {
+ return;
+ }
+
+ Http::globalOptions(['proxy' => $proxy]);
+ }
+
/**
* SQL 쿼리 로깅을 설정합니다.
*
diff --git a/app/Providers/PluginRouteServiceProvider.php b/app/Providers/PluginRouteServiceProvider.php
index 0798c3a3..8737078d 100644
--- a/app/Providers/PluginRouteServiceProvider.php
+++ b/app/Providers/PluginRouteServiceProvider.php
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Extension\ExtensionManager;
use App\Extension\Testing\ExtensionTestAllowlist;
+use App\Extension\Traits\CachesPluginStatus;
use App\Support\InstallerContext;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\File;
@@ -12,6 +13,8 @@ use Illuminate\Support\Facades\Schema;
class PluginRouteServiceProvider extends ServiceProvider
{
+ use CachesPluginStatus;
+
/**
* The path to the "home" route for your application.
*
@@ -33,6 +36,8 @@ class PluginRouteServiceProvider extends ServiceProvider
/**
* 플러그인의 라우트 파일들을 로드합니다.
+ *
+ * 활성화된 플러그인만 라우트를 등록합니다.
*/
protected function loadPluginRoutes(): void
{
@@ -65,6 +70,11 @@ class PluginRouteServiceProvider extends ServiceProvider
}
}
+ // 활성화된 플러그인 identifier 목록 가져오기.
+ // 같은 목록을 PluginManager·PluginServiceProvider 가 이미 캐시(TTL 기본 하루)해 두므로
+ // 여기서 다시 조회하지 않고 그 캐시를 공유한다. 상태 변경 시 무효화도 같이 따라온다.
+ $activePluginIdentifiers = self::getActivePluginIdentifiers();
+
$plugins = File::directories($pluginsPath);
$allowlistActive = ExtensionTestAllowlist::isActive();
@@ -77,6 +87,13 @@ class PluginRouteServiceProvider extends ServiceProvider
continue;
}
+ // 활성화된 플러그인만 라우트 로드 (모듈과 동일 기준).
+ // 이 게이트가 없으면 비활성 플러그인의 API 가 계속 호출 가능해, 화면·메뉴만
+ // 사라지고 기능은 살아 있는 상태가 된다.
+ if (! in_array($pluginName, $activePluginIdentifiers)) {
+ continue;
+ }
+
// 플러그인 파일이 존재하는지 확인
if (! File::exists($pluginFile)) {
continue;
diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php
index 05e37e67..3a3edecd 100644
--- a/app/Providers/SettingsServiceProvider.php
+++ b/app/Providers/SettingsServiceProvider.php
@@ -5,6 +5,7 @@ namespace App\Providers;
use App\Repositories\JsonConfigRepository;
use App\Support\AllowedExtensions;
use App\Support\ExtensionSettingsMirror;
+use App\Support\OutboundProxy;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
use Predis\Client;
@@ -274,6 +275,11 @@ class SettingsServiceProvider extends ServiceProvider
if (isset($debugSettings['sql_query_log'])) {
Config::set('g7.sql_query_log', (bool) $debugSettings['sql_query_log']);
}
+
+ // 아웃바운드 HTTP 프록시 설정.
+ // 적용 여부 판정은 OutboundProxy 가 단독으로 소유한다 — 디버그 모드가 꺼져 있으면
+ // 저장값이 남아 있어도 null 이 되어 주입되지 않는다.
+ Config::set('g7.outbound_proxy', OutboundProxy::resolve($debugSettings));
}
/**
diff --git a/app/Rules/ValidOutboundProxyUrl.php b/app/Rules/ValidOutboundProxyUrl.php
new file mode 100644
index 00000000..bf5279b8
--- /dev/null
+++ b/app/Rules/ValidOutboundProxyUrl.php
@@ -0,0 +1,49 @@
+ implode(', ', OutboundProxy::ALLOWED_SCHEMES),
+ ]));
+
+ return;
+ }
+
+ if (! OutboundProxy::isValidUrl($value)) {
+ $fail(__('validation.settings.outbound_proxy_invalid', [
+ 'schemes' => implode(', ', OutboundProxy::ALLOWED_SCHEMES),
+ ]));
+ }
+ }
+}
diff --git a/app/Services/LanguagePackService.php b/app/Services/LanguagePackService.php
index 75e453ba..36c613f4 100644
--- a/app/Services/LanguagePackService.php
+++ b/app/Services/LanguagePackService.php
@@ -763,7 +763,12 @@ class LanguagePackService
$response = Http::timeout(120)->get($url);
if (! $response->successful()) {
- throw new LanguagePackOperationException('language_packs.errors.download_failed', ['url' => $url]);
+ // 응답 상태를 사유로 싣는다 — 비우면 치환 자리가 남아 관리자 화면에
+ // 리터럴 ':error' 가 그대로 노출된다.
+ throw new LanguagePackOperationException('language_packs.errors.download_failed', [
+ 'url' => $url,
+ 'error' => 'HTTP '.$response->status(),
+ ]);
}
File::put($zipPath, $response->body());
diff --git a/app/Services/ModuleService.php b/app/Services/ModuleService.php
index 569ec88d..7d765025 100644
--- a/app/Services/ModuleService.php
+++ b/app/Services/ModuleService.php
@@ -242,6 +242,7 @@ class ModuleService
* @param string $moduleName 설치할 모듈명
* @param VendorMode $vendorMode Vendor 설치 모드
* @param bool $force Updating/Failed 등 진행 중 상태도 무시하고 강제 설치 여부
+ * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return array|null 설치된 모듈 정보 또는 null
*
* @throws ValidationException 모듈 설치 실패 시
@@ -250,12 +251,15 @@ class ModuleService
string $moduleName,
VendorMode $vendorMode = VendorMode::Auto,
bool $force = false,
+ ?string &$failureReason = null,
): ?array {
+ $failureReason = null;
+
HookManager::doAction('core.modules.before_install', $moduleName);
try {
$this->moduleManager->loadModules();
- $result = $this->moduleManager->installModule($moduleName, null, $vendorMode, $force);
+ $result = $this->moduleManager->installModule($moduleName, null, $vendorMode, $force, $failureReason);
if ($result) {
// 설치 후 모듈 정보 반환
@@ -307,7 +311,9 @@ class ModuleService
];
}
- return ['success' => false];
+ // 실패 사유(reason)를 그대로 전달한다 — 여기서 떨어뜨리면 관리자 화면의
+ // 실패 문구에 원인 자리가 비어 자리표시자가 그대로 노출된다.
+ return $result;
} catch (\Exception $e) {
throw ValidationException::withMessages([
'module_name' => [__('modules.activation_failed', ['error' => $e->getMessage()])],
@@ -359,12 +365,15 @@ class ModuleService
*
* @param string $moduleName 제거할 모듈명
* @param bool $deleteData 모듈 데이터(테이블) 삭제 여부
+ * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 제거 성공 여부
*
* @throws ValidationException 모듈 제거 실패 시
*/
- public function uninstallModule(string $moduleName, bool $deleteData = false): bool
+ public function uninstallModule(string $moduleName, bool $deleteData = false, ?string &$failureReason = null): bool
{
+ $failureReason = null;
+
HookManager::doAction('core.modules.before_uninstall', $moduleName, $deleteData);
try {
@@ -372,7 +381,7 @@ class ModuleService
$this->moduleManager->loadModules();
$moduleInfo = $this->moduleManager->getModuleInfo($moduleName);
- $result = $this->moduleManager->uninstallModule($moduleName, $deleteData);
+ $result = $this->moduleManager->uninstallModule($moduleName, $deleteData, null, $failureReason);
if ($result) {
$module = $this->moduleRepository->findByName($moduleName);
@@ -774,6 +783,13 @@ class ModuleService
}
throw $e;
}
+ } catch (ModuleOperationException $e) {
+ throw $e;
+ } catch (\RuntimeException $e) {
+ // ZipInstallHelper 등 설치 원본 처리의 raw RuntimeException(깨진 zip·manifest
+ // 누락 같은 사용자 입력 오류)을 도메인 예외로 승격한다 — 컨트롤러의 좁혀진
+ // catch 가 인프라 예외와 구분해 종전 422 계약을 유지하고, 사유는 :error 로 보존.
+ throw new ModuleOperationException('modules.errors.install_failed', ['error' => $e->getMessage()], $e);
} finally {
if (File::exists($extractPath)) {
File::deleteDirectory($extractPath);
@@ -827,6 +843,12 @@ class ModuleService
}
throw $e;
}
+ } catch (ModuleOperationException $e) {
+ throw $e;
+ } catch (\RuntimeException $e) {
+ // GithubHelper·ZipInstallHelper 의 raw RuntimeException(잘못된 URL·다운로드
+ // 실패·manifest 오류)을 도메인 예외로 승격한다 — 종전 422 계약 유지, 사유 보존.
+ throw new ModuleOperationException('modules.errors.install_failed', ['error' => $e->getMessage()], $e);
} finally {
if (File::exists($extractPath)) {
File::deleteDirectory($extractPath);
@@ -867,10 +889,14 @@ class ModuleService
private function executeModuleInstall(string $identifier): array
{
$this->moduleManager->loadModules();
- $result = $this->moduleManager->installModule($identifier);
+ $result = $this->moduleManager->installModule($identifier, null, VendorMode::Auto, false, $failureReason);
if (! $result) {
- throw new ModuleOperationException('modules.errors.install_failed');
+ // 사유를 실어 올리지 않으면 'modules.errors.install_failed' 의 치환 자리가
+ // 비어 관리자 화면에 리터럴 ':error' 가 그대로 노출된다.
+ throw new ModuleOperationException('modules.errors.install_failed', [
+ 'error' => $failureReason ?? __('modules.errors.unknown_error'),
+ ]);
}
return $this->moduleManager->getModuleInfo($identifier);
diff --git a/app/Services/OutboundProxyTester.php b/app/Services/OutboundProxyTester.php
new file mode 100644
index 00000000..b967f5ba
--- /dev/null
+++ b/app/Services/OutboundProxyTester.php
@@ -0,0 +1,116 @@
+ $bypass 프록시 예외 목록
+ * @return array{success: bool, message_key: string, egress_ip: string|null, elapsed_ms: int, error: string|null} 검사 결과
+ */
+ public function test(string $proxyUrl, array $bypass = []): array
+ {
+ // 적용 시와 같은 조립·정규화를 거친다 — 여기서 손으로 배열을 만들면 저장 전에
+ // 확인한 구성과 저장 후 실제로 적용되는 구성이 달라진다.
+ $proxyOptions = OutboundProxy::options($proxyUrl, $bypass);
+
+ if ($proxyOptions === null) {
+ return $this->result(false, 'settings.outbound_proxy_test_invalid_url', null, 0);
+ }
+
+ $urls = (array) config('core.outbound_proxy.egress_lookup_urls', []);
+
+ if ($urls === []) {
+ return $this->result(false, 'settings.outbound_proxy_test_no_lookup_url', null, 0);
+ }
+
+ $timeout = (int) config('core.outbound_proxy.test_timeout_seconds', 10);
+ $startedAt = microtime(true);
+ $lastError = null;
+
+ foreach ($urls as $url) {
+ try {
+ $response = Http::withOptions(['proxy' => $proxyOptions])
+ ->withHeaders(['User-Agent' => 'curl/8'])
+ ->timeout($timeout)
+ ->get($url);
+
+ if (! $response->successful()) {
+ $lastError = 'HTTP '.$response->status();
+
+ continue;
+ }
+
+ $ip = trim($response->body());
+
+ if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
+ $lastError = 'unexpected response body';
+
+ continue;
+ }
+
+ return $this->result(true, 'settings.outbound_proxy_test_success', $ip, $this->elapsedMs($startedAt));
+ } catch (\Throwable $e) {
+ // 개별 조회처 실패는 다음 후보로 넘어간다 — 한 곳이 죽어 있다고 프록시가
+ // 잘못됐다고 단정할 수 없기 때문이다.
+ $lastError = $e->getMessage();
+ }
+ }
+
+ Log::warning('아웃바운드 프록시 연결 테스트 실패', ['error' => $lastError]);
+
+ return $this->result(false, 'settings.outbound_proxy_test_failed', null, $this->elapsedMs($startedAt), $lastError);
+ }
+
+ /**
+ * 경과 시간을 밀리초로 환산합니다.
+ *
+ * @param float $startedAt 시작 시각 (microtime)
+ * @return int 경과 밀리초
+ */
+ private function elapsedMs(float $startedAt): int
+ {
+ return (int) round((microtime(true) - $startedAt) * 1000);
+ }
+
+ /**
+ * 결과 배열을 구성합니다.
+ *
+ * @param bool $success 성공 여부
+ * @param string $messageKey 다국어 메시지 키
+ * @param string|null $egressIp 프록시를 거친 출발지 IP
+ * @param int $elapsedMs 경과 밀리초
+ * @param string|null $error 실패 원인 원문 (관리자 진단용)
+ * @return array{success: bool, message_key: string, egress_ip: string|null, elapsed_ms: int, error: string|null}
+ */
+ private function result(bool $success, string $messageKey, ?string $egressIp, int $elapsedMs, ?string $error = null): array
+ {
+ return [
+ 'success' => $success,
+ 'message_key' => $messageKey,
+ 'egress_ip' => $egressIp,
+ 'elapsed_ms' => $elapsedMs,
+ 'error' => $error,
+ ];
+ }
+}
diff --git a/app/Services/PluginService.php b/app/Services/PluginService.php
index 2139aa24..3767f183 100644
--- a/app/Services/PluginService.php
+++ b/app/Services/PluginService.php
@@ -115,6 +115,7 @@ class PluginService
* @param string $pluginName 플러그인 식별자
* @param VendorMode $vendorMode vendor 설치 모드 (Auto/Composer/Bundled)
* @param bool $force Updating/Failed 등 진행 중 상태도 무시하고 강제 설치 여부
+ * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return array|null 설치된 플러그인 정보 또는 설치 실패 시 null
*
* @throws ValidationException 플러그인 설치 실패 시
@@ -123,12 +124,15 @@ class PluginService
string $pluginName,
VendorMode $vendorMode = VendorMode::Auto,
bool $force = false,
+ ?string &$failureReason = null,
): ?array {
+ $failureReason = null;
+
HookManager::doAction('core.plugins.before_install', $pluginName);
try {
$this->pluginManager->loadPlugins();
- $result = $this->pluginManager->installPlugin($pluginName, null, $vendorMode, $force);
+ $result = $this->pluginManager->installPlugin($pluginName, null, $vendorMode, $force, $failureReason);
if ($result) {
// 설치 후 플러그인 정보 반환
@@ -182,7 +186,9 @@ class PluginService
];
}
- return ['success' => false];
+ // 실패 사유(reason)를 그대로 전달한다 — 여기서 떨어뜨리면 관리자 화면의
+ // 실패 문구에 원인 자리가 비어 자리표시자가 그대로 노출된다.
+ return $result;
} catch (\Exception $e) {
throw ValidationException::withMessages([
'plugin_name' => [__('plugins.activation_failed', ['error' => $e->getMessage()])],
@@ -236,18 +242,21 @@ class PluginService
*
* @param string $pluginName 플러그인 식별자
* @param bool $deleteData 플러그인이 생성한 DB 데이터/스토리지 디렉토리까지 삭제 여부
+ * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 제거 성공 여부
*
* @throws ValidationException 제거 실패 시
*/
- public function uninstallPlugin(string $pluginName, bool $deleteData = false): bool
+ public function uninstallPlugin(string $pluginName, bool $deleteData = false, ?string &$failureReason = null): bool
{
+ $failureReason = null;
+
HookManager::doAction('core.plugins.before_uninstall', $pluginName, $deleteData);
try {
$this->pluginManager->loadPlugins();
- $result = $this->pluginManager->uninstallPlugin($pluginName, $deleteData);
+ $result = $this->pluginManager->uninstallPlugin($pluginName, $deleteData, null, $failureReason);
HookManager::doAction('core.plugins.after_uninstall', $pluginName, $deleteData, $result);
@@ -890,6 +899,13 @@ class PluginService
}
throw $e;
}
+ } catch (PluginOperationException $e) {
+ throw $e;
+ } catch (\RuntimeException $e) {
+ // ZipInstallHelper 등 설치 원본 처리의 raw RuntimeException(깨진 zip·manifest
+ // 누락 같은 사용자 입력 오류)을 도메인 예외로 승격한다 — 컨트롤러의 좁혀진
+ // catch 가 인프라 예외와 구분해 종전 422 계약을 유지하고, 사유는 :error 로 보존.
+ throw new PluginOperationException('plugins.errors.install_failed', ['error' => $e->getMessage()], $e);
} finally {
if (File::exists($extractPath)) {
File::deleteDirectory($extractPath);
@@ -943,6 +959,12 @@ class PluginService
}
throw $e;
}
+ } catch (PluginOperationException $e) {
+ throw $e;
+ } catch (\RuntimeException $e) {
+ // GithubHelper·ZipInstallHelper 의 raw RuntimeException(잘못된 URL·다운로드
+ // 실패·manifest 오류)을 도메인 예외로 승격한다 — 종전 422 계약 유지, 사유 보존.
+ throw new PluginOperationException('plugins.errors.install_failed', ['error' => $e->getMessage()], $e);
} finally {
if (File::exists($extractPath)) {
File::deleteDirectory($extractPath);
@@ -983,10 +1005,14 @@ class PluginService
private function executePluginInstall(string $identifier): array
{
$this->pluginManager->loadPlugins();
- $result = $this->pluginManager->installPlugin($identifier);
+ $result = $this->pluginManager->installPlugin($identifier, null, VendorMode::Auto, false, $failureReason);
if (! $result) {
- throw new PluginOperationException('plugins.errors.install_failed');
+ // 사유를 실어 올리지 않으면 'plugins.errors.install_failed' 의 치환 자리가
+ // 비어 관리자 화면에 리터럴 ':error' 가 그대로 노출된다.
+ throw new PluginOperationException('plugins.errors.install_failed', [
+ 'error' => $failureReason ?? __('plugins.errors.unknown_error'),
+ ]);
}
return $this->pluginManager->getPluginInfo($identifier);
diff --git a/app/Services/PluginSettingsService.php b/app/Services/PluginSettingsService.php
index 3043333c..436dae55 100644
--- a/app/Services/PluginSettingsService.php
+++ b/app/Services/PluginSettingsService.php
@@ -151,10 +151,13 @@ class PluginSettingsService
*
* @param string $identifier 플러그인 식별자
* @param array $settings 저장할 설정 (검증 통과분)
+ * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 저장 성공 여부
*/
- public function save(string $identifier, array $settings): bool
+ public function save(string $identifier, array $settings, ?string &$failureReason = null): bool
{
+ $failureReason = null;
+
// Before 훅
HookManager::doAction('core.plugin_settings.before_save', $identifier, $settings);
@@ -164,6 +167,8 @@ class PluginSettingsService
// 플러그인 인스턴스 확인
$pluginInstance = $this->pluginManager->getPlugin($identifier);
if (! $pluginInstance) {
+ $failureReason = __('plugins.errors.not_found', ['plugin' => $identifier]);
+
return false;
}
@@ -187,6 +192,11 @@ class PluginSettingsService
// 파일에 저장
$result = $this->saveSettingsToFile($identifier, $mergedSettings);
+ if (! $result && $failureReason === null) {
+ // 파일 쓰기 실패 — 스토리지 드라이버가 사유를 돌려주지 않으므로 일반 문구로 대체한다.
+ $failureReason = __('plugins.errors.unknown_error');
+ }
+
// 캐시 초기화
if ($result) {
unset($this->settingsCache[$identifier]);
diff --git a/app/Services/TemplateService.php b/app/Services/TemplateService.php
index 658c913d..153c0667 100644
--- a/app/Services/TemplateService.php
+++ b/app/Services/TemplateService.php
@@ -7,6 +7,7 @@ use App\Contracts\Extension\PluginManagerInterface;
use App\Contracts\Extension\TemplateManagerInterface;
use App\Contracts\Repositories\LayoutVersionRepositoryInterface;
use App\Contracts\Repositories\TemplateRepositoryInterface;
+use App\Enums\DeactivationReason;
use App\Enums\ExtensionStatus;
use App\Exceptions\TemplateNotFoundException;
use App\Exceptions\TemplateOperationException;
@@ -476,12 +477,15 @@ class TemplateService
* 템플릿을 비활성화합니다.
*
* @param int|string $idOrIdentifier 템플릿 ID 또는 식별자
+ * @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return array|null 비활성화된 템플릿 정보 또는 null
*
* @throws ValidationException 비활성화 실패 시
*/
- public function deactivateTemplate(int|string $idOrIdentifier): ?array
+ public function deactivateTemplate(int|string $idOrIdentifier, ?string &$failureReason = null): ?array
{
+ $failureReason = null;
+
// ID 또는 identifier로 템플릿 조회
$template = is_int($idOrIdentifier)
? $this->templateRepository->findById($idOrIdentifier)
@@ -496,7 +500,14 @@ class TemplateService
HookManager::doAction('core.templates.before_deactivate', $template->identifier);
try {
- $result = $this->templateManager->deactivateTemplate($template->identifier);
+ // 위치 인자로 넘긴다 — 이 의존성은 인터페이스 타입이고 테스트가 그 인터페이스를
+ // mock 하므로, 이름 붙인 인자는 mock 의 __call 에 닿아 "Unknown named parameter" 가 된다.
+ $result = $this->templateManager->deactivateTemplate(
+ $template->identifier,
+ DeactivationReason::Manual->value,
+ null,
+ $failureReason
+ );
if ($result) {
// 템플릿 매니저에서 업데이트된 정보 조회
@@ -1925,6 +1936,13 @@ class TemplateService
}
throw $e;
}
+ } catch (TemplateOperationException $e) {
+ throw $e;
+ } catch (\RuntimeException $e) {
+ // ZipInstallHelper 등 설치 원본 처리의 raw RuntimeException(깨진 zip·manifest
+ // 누락 같은 사용자 입력 오류)을 도메인 예외로 승격한다 — 컨트롤러의 좁혀진
+ // catch 가 인프라 예외와 구분해 종전 422 계약을 유지하고, 사유는 :error 로 보존.
+ throw new TemplateOperationException('templates.errors.install_failed', ['error' => $e->getMessage()], $e);
} finally {
if (File::exists($extractPath)) {
File::deleteDirectory($extractPath);
@@ -1978,6 +1996,12 @@ class TemplateService
}
throw $e;
}
+ } catch (TemplateOperationException $e) {
+ throw $e;
+ } catch (\RuntimeException $e) {
+ // GithubHelper·ZipInstallHelper 의 raw RuntimeException(잘못된 URL·다운로드
+ // 실패·manifest 오류)을 도메인 예외로 승격한다 — 종전 422 계약 유지, 사유 보존.
+ throw new TemplateOperationException('templates.errors.install_failed', ['error' => $e->getMessage()], $e);
} finally {
if (File::exists($extractPath)) {
File::deleteDirectory($extractPath);
@@ -2021,7 +2045,11 @@ class TemplateService
$result = $this->templateManager->installTemplate($identifier);
if (! $result) {
- throw new TemplateOperationException('templates.errors.install_failed');
+ // installTemplate 은 사유 out 파라미터를 갖지 않으므로 일반 문구로 채운다.
+ // 비워 두면 치환 자리가 남아 관리자 화면에 리터럴 ':error' 가 노출된다.
+ throw new TemplateOperationException('templates.errors.install_failed', [
+ 'error' => __('templates.errors.unknown_error'),
+ ]);
}
return $this->templateManager->getTemplateInfo($identifier);
diff --git a/app/Support/ApiDoc/ParameterDescriber.php b/app/Support/ApiDoc/ParameterDescriber.php
index 8ce26e7e..b9bdb70b 100644
--- a/app/Support/ApiDoc/ParameterDescriber.php
+++ b/app/Support/ApiDoc/ParameterDescriber.php
@@ -262,6 +262,8 @@ class ParameterDescriber
'cache_ttl' => '캐시 유효 시간 (초)',
'debug_mode' => '디버그 모드 사용 여부 (상세 오류 노출)',
'sql_query_log' => 'SQL 쿼리 로그 기록 여부',
+ 'outbound_proxy' => '외부 HTTP 호출이 경유할 프록시 주소 (디버그 모드에서만 적용)',
+ 'outbound_proxy_bypass' => '프록시를 경유하지 않을 호스트 목록',
'maintenance_mode' => '점검 모드 사용 여부 (사이트 접근 차단)',
'force_https' => 'HTTPS 강제 리다이렉트 여부',
'max_login_attempts' => '로그인 실패 허용 횟수 (초과 시 잠금)',
diff --git a/app/Support/OutboundProxy.php b/app/Support/OutboundProxy.php
new file mode 100644
index 00000000..d6fb7bd9
--- /dev/null
+++ b/app/Support/OutboundProxy.php
@@ -0,0 +1,191 @@
+ $debugSettings debug 카테고리 설정 배열
+ * @return array{http: string, https: string, no: array}|null 적용할 프록시 옵션 (미적용 시 null)
+ */
+ public static function resolve(array $debugSettings): ?array
+ {
+ // 게이트: 디버그 모드가 꺼져 있으면 저장값이 있어도 적용하지 않는다.
+ if (empty($debugSettings['mode'])) {
+ return null;
+ }
+
+ return self::options(
+ $debugSettings['outbound_proxy'] ?? null,
+ $debugSettings['outbound_proxy_bypass'] ?? []
+ );
+ }
+
+ /**
+ * 프록시 주소와 예외 목록을 Guzzle 의 `proxy` 옵션 형태로 조립합니다.
+ *
+ * 게이트(디버그 모드)는 보지 않습니다 — 저장 전 연결 테스트처럼 아직 적용 대상이 아닌
+ * 값을 그대로 검사해야 하는 경로가 있기 때문입니다. 게이트 판정은 `resolve()` 가 맡습니다.
+ *
+ * 조립을 이 한 곳에 모으는 이유는 정규화 때문입니다. 테스트가 손으로 배열을 만들면 예외
+ * 목록의 공백·빈 항목·중복 처리가 실제 적용분과 어긋나, 저장 전에 확인한 구성과 저장 후
+ * 적용되는 구성이 달라집니다.
+ *
+ * @param mixed $url 프록시 주소
+ * @param mixed $bypass 예외 목록
+ * @return array{http: string, https: string, no: array}|null 조립된 옵션 (주소가 부적합하면 null)
+ */
+ public static function options(mixed $url, mixed $bypass = []): ?array
+ {
+ $normalized = self::normalizeUrl($url);
+
+ if ($normalized === null) {
+ return null;
+ }
+
+ return [
+ 'http' => $normalized,
+ 'https' => $normalized,
+ 'no' => self::normalizeBypass($bypass),
+ ];
+ }
+
+ /**
+ * 프록시 URL 이 적용 가능한 형태인지 판정합니다.
+ *
+ * @param mixed $value 검사할 값
+ * @return bool 허용 스킴과 호스트를 갖춘 URL 이면 true
+ */
+ public static function isValidUrl(mixed $value): bool
+ {
+ return self::normalizeUrl($value) !== null;
+ }
+
+ /**
+ * 현재 적용 중인 프록시를 curl 옵션 형태로 돌려줍니다.
+ *
+ * `Http::` 파사드를 쓰지 못하는 호출 지점(외부 SDK 규약상 curl 핸들을 직접 다뤄야 하는
+ * 경우 등)이 같은 프록시를 타도록 하기 위한 통로입니다. 판정은 여기서 다시 하지 않고
+ * 이미 주입된 `g7.outbound_proxy` 를 읽습니다 — 게이트는 한 곳에만 둔다.
+ *
+ * 적용 대상이 없으면 빈 배열이므로 `curl_setopt_array()` 에 그대로 넘겨도 무해합니다.
+ *
+ * @return array curl 옵션 배열 (미적용 시 빈 배열)
+ */
+ public static function curlOptions(): array
+ {
+ $proxy = config('g7.outbound_proxy');
+
+ if (! is_array($proxy) || empty($proxy['https'])) {
+ return [];
+ }
+
+ $options = [CURLOPT_PROXY => $proxy['https']];
+
+ if (! empty($proxy['no']) && is_array($proxy['no'])) {
+ $options[CURLOPT_NOPROXY] = implode(',', $proxy['no']);
+ }
+
+ return $options;
+ }
+
+ /**
+ * 프록시 URL 을 정규화합니다.
+ *
+ * 허용 스킴과 호스트를 모두 갖추지 못한 값은 null 을 돌려줍니다.
+ *
+ * @param mixed $value 원본 값
+ * @return string|null 정규화된 URL (부적합 시 null)
+ */
+ private static function normalizeUrl(mixed $value): ?string
+ {
+ if (! is_string($value)) {
+ return null;
+ }
+
+ $url = trim($value);
+
+ if ($url === '') {
+ return null;
+ }
+
+ $parts = parse_url($url);
+
+ if ($parts === false || empty($parts['host'])) {
+ return null;
+ }
+
+ $scheme = strtolower($parts['scheme'] ?? '');
+
+ if (! in_array($scheme, self::ALLOWED_SCHEMES, true)) {
+ return null;
+ }
+
+ return $url;
+ }
+
+ /**
+ * 프록시 예외 목록을 정규화합니다.
+ *
+ * 빈 항목과 중복을 걸러내고 순번을 다시 매깁니다 — 비연속 키는 JSON 직렬화 시 객체가 되어
+ * Guzzle 이 목록으로 읽지 못합니다.
+ *
+ * @param mixed $value 원본 예외 목록
+ * @return array 정규화된 호스트 목록
+ */
+ private static function normalizeBypass(mixed $value): array
+ {
+ if (! is_array($value)) {
+ return [];
+ }
+
+ $hosts = [];
+
+ foreach ($value as $host) {
+ if (! is_string($host)) {
+ continue;
+ }
+
+ $host = trim($host);
+
+ if ($host !== '') {
+ $hosts[] = $host;
+ }
+ }
+
+ return array_values(array_unique($hosts));
+ }
+}
diff --git a/config/app.php b/config/app.php
index ca5ebd49..d1a68dcd 100644
--- a/config/app.php
+++ b/config/app.php
@@ -231,7 +231,7 @@ return [
|
*/
- 'version' => env('APP_VERSION', '7.0.7'),
+ 'version' => env('APP_VERSION', '7.0.8'),
/*
|--------------------------------------------------------------------------
diff --git a/config/core.php b/config/core.php
index 3609bbef..b1b08c28 100644
--- a/config/core.php
+++ b/config/core.php
@@ -105,6 +105,27 @@ return [
'max_page' => 1000,
],
+ /*
+ |--------------------------------------------------------------------------
+ | 아웃바운드 프록시 연결 테스트
+ |--------------------------------------------------------------------------
+ | 운영자가 환경설정에 입력한 프록시가 실제로 동작하는지, 그리고 그 프록시를 거쳐
+ | 나갔을 때 상대편에 어떤 IP 로 보이는지 확인하는 데 쓰는 조회 대상입니다.
+ |
+ | 출발지 IP 는 운영자가 결제사·외부 서비스에 등록해야 하는 값이라, 프록시를 켠 상태의
+ | 실제 값을 알려주는 것이 이 테스트의 목적입니다. 목록은 순차 시도하며 먼저 유효한
+ | IP 를 돌려준 곳에서 멈춥니다. 폐쇄망 등 외부 조회가 불가능한 환경에서는 목록을
+ | 비워 두면 도달성만 확인하고 IP 는 보고하지 않습니다.
+ */
+ 'outbound_proxy' => [
+ 'egress_lookup_urls' => [
+ 'https://api.ipify.org',
+ 'https://ifconfig.me/ip',
+ 'https://icanhazip.com',
+ ],
+ 'test_timeout_seconds' => 10,
+ ],
+
/*
|--------------------------------------------------------------------------
| 검색 — DBMS 별 부분일치 연산자
diff --git a/config/settings/defaults.json b/config/settings/defaults.json
index 30a388b7..dd001aef 100644
--- a/config/settings/defaults.json
+++ b/config/settings/defaults.json
@@ -102,7 +102,9 @@
"debug": {
"mode": false,
"sql_query_log": false,
- "log_level": "error"
+ "log_level": "error",
+ "outbound_proxy": "",
+ "outbound_proxy_bypass": []
},
"core_update": {
"github_url": "",
@@ -270,7 +272,9 @@
"_comment": "debug 설정은 advanced 카테고리에 병합",
"fields": {
"mode": { "type": "boolean", "sensitive": false, "frontend_key": "debug_mode" },
- "sql_query_log": { "type": "boolean", "sensitive": false, "expose": false }
+ "sql_query_log": { "type": "boolean", "sensitive": false, "expose": false },
+ "outbound_proxy": { "type": "string", "sensitive": true },
+ "outbound_proxy_bypass": { "type": "array", "sensitive": false, "expose": false }
}
},
"core_update": {
diff --git a/docs/backend/admin-settings-access.md b/docs/backend/admin-settings-access.md
index a96fa95c..d26705c1 100644
--- a/docs/backend/admin-settings-access.md
+++ b/docs/backend/admin-settings-access.md
@@ -33,6 +33,8 @@
| `general.timezone` | `app.default_user_timezone` (`app.timezone` 아님) |
| `general.language` | `app.locale` |
| `debug.mode` | `app.debug`, `logging.*.level` |
+| `debug.sql_query_log` | `g7.sql_query_log` |
+| `debug.outbound_proxy`, `debug.outbound_proxy_bypass` | `g7.outbound_proxy` (디버그 모드 OFF 면 `null`) |
| `drivers.cache_driver` | `cache.default` (testing 차단) |
| `drivers.session_driver` | `session.driver` (testing 차단) |
| `drivers.session_lifetime` | `session.lifetime` (testing 차단) |
@@ -140,6 +142,45 @@ plugin_setting('sirsoft-pay_kginicis', 'api_key');
---
+## 설정이 여는 기능에 게이트가 필요한 경우
+
+설정 하나가 위험한 동작을 여는 경우, 관리자 화면에서 입력칸을 조건부로 감추는 것은 게이트가 아니다. 저장 API 를 직접 호출하면 값은 그대로 저장되므로, 실질 게이트는 **그 값을 실제로 쓸지 판정하는 지점** 하나뿐이다.
+
+`debug.outbound_proxy` 가 그 예다. 지정된 프록시는 코어가 바깥으로 내보내는 모든 HTTP 요청(결제 승인, 코어 업데이트 조회, GeoIP 내려받기, 알림 웹훅)의 경로를 바꾸므로, 디버그 모드가 켜져 있을 때만 적용한다.
+
+| 구분 | 담당 |
+|------|------|
+| 판정 (SSoT) | `App\Support\OutboundProxy::resolve()` — 디버그 모드 OFF 면 저장값이 있어도 `null` |
+| 조립 (SSoT) | `OutboundProxy::options()` — 주소·예외 목록 정규화. 저장 전 연결 테스트도 이 조립을 거친다 |
+| 주입 | `SettingsServiceProvider::applyDebugConfig()` — 판정 결과를 `g7.outbound_proxy` 에 넣는다 |
+| 적용 | `AppServiceProvider::configureOutboundProxy()` — `Http::globalOptions()` 에 실는다 |
+| 화면 | 고급 탭의 조건부 렌더링 — 편의이며 게이트가 아니다 |
+
+주입·적용 지점은 게이트를 다시 검사하지 않는다. 같은 판정을 두 곳에 두면 한쪽만 바뀌었을 때 "저장은 되는데 적용되지 않는" 상태가 예외 없이 생긴다.
+
+저장 전 확인 기능(연결 테스트 등)이 있다면 그 경로도 같은 조립을 거쳐야 한다. 테스트가 값을 손으로 조립하면 정규화가 어긋나 운영자가 확인한 구성과 저장 후 적용되는 구성이 달라지는데, 두 구성 모두 정상 동작하므로 그 어긋남 자체는 아무 신호도 남기지 않는다.
+
+새 설정이 이런 성격이라면 같은 형태를 따른다 — 판정 함수 하나, 그 결과만 소비하는 주입·적용 지점, 그리고 디버그 모드 OFF 에서 미적용을 단언하는 회귀 테스트.
+
+### 적용 범위 — `Http::` 를 쓰지 않는 호출
+
+`Http::globalOptions()` 는 `Http` 파사드가 만든 요청에만 걸린다. 같은 사이트 안에서도 아래는 갈린다.
+
+| 호출 방식 | 프록시 적용 | 비고 |
+|---|---|---|
+| `Http::get(...)` | 적용 | 코어·확장 구분 없이 자동 |
+| `Http::withOptions([...])` (다른 옵션) | 적용 | `array_replace_recursive` 라 `proxy` 키는 보존된다 |
+| `Http::withOptions(['proxy' => ...])` | 호출부 값 우선 | 의도된 우선순위 (연결 테스트가 이 경로를 쓴다) |
+| `curl_*` 직접 | **미적용** | `OutboundProxy::curlOptions()` 를 `curl_setopt_array()` 에 넘겨 편입 |
+| `new GuzzleHttp\Client()` 직접 | **미적용** | Laravel 팩토리를 거치지 않는다 |
+| `fsockopen` / 원시 소켓 | **미적용** | 프로토콜상 프록시를 태우려면 별도 구현이 필요하다 |
+
+외부 연동 규약 때문에 `Http::` 를 쓸 수 없는 확장은 `OutboundProxy::curlOptions()` 를 쓴다. 판정은 코어가 하고 확장은 결과만 받으므로 게이트가 갈라지지 않으며, 미적용 상태에서는 빈 배열이라 그대로 넘겨도 무해하다.
+
+이 결함은 신호를 남기지 않는다 — 우회한 호출도 정상 성공하고, 상대편에 보이는 출발지 IP 만 달라진다. 외부 호출 지점을 새로 만들 때 어느 통로를 쓰는지 확인한다.
+
+---
+
## 관련 문서
- [service-provider.md](service-provider.md) — ServiceProvider 안전성 (DB 접근 가드)
diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md
index 75eaf33f..72652dfb 100644
--- a/docs/backend/api/README.md
+++ b/docs/backend/api/README.md
@@ -120,6 +120,10 @@ Authorization: Bearer {YOUR_TOKEN}
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`errors` 에 필드별 메시지) |
| 428 | Precondition Required | 본인인증(IDV)이 선행되어야 하는 경우 |
+확장(모듈·플러그인)이 제공하는 엔드포인트(`/api/modules/{id}/…`, `/api/plugins/{id}/…`)는 그 확장이
+**활성 상태일 때만** 존재합니다. 비활성화·제거된 확장의 엔드포인트는 404 를 반환하며, 이는 권한
+문제가 아니라 라우트가 등록되지 않은 상태입니다. 확장을 업데이트하는 동안에도 잠시 같은 상태가 됩니다.
+
428 응답은 `error_code: "identity_verification_required"` 와 함께 `verification` 객체를 반환합니다.
클라이언트는 이 값으로 본인인증 화면을 띄운 뒤 원래 요청을 재시도합니다.
diff --git a/docs/backend/api/language-packs.md b/docs/backend/api/language-packs.md
index abc9a9f3..5c1375b0 100644
--- a/docs/backend/api/language-packs.md
+++ b/docs/backend/api/language-packs.md
@@ -371,7 +371,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`core.language_packs.read`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
-| 500 | Internal Server Error | 업데이트 확인 중 예외 발생 (`업데이트 확인에 실패했습니다: :error` — `language_packs.check_updates_failed`) |
+| 500 | Internal Server Error | 업데이트 확인 중 예외 발생 (`업데이트 확인에 실패했습니다.` — `language_packs.check_updates_failed`) |
@@ -633,7 +633,7 @@ HTTP/1.1 201
}
```
-> manifest 검증 실패 시 422 (`language-pack.json 검증에 실패했습니다.`), 그 외 설치 실패 시 500 (`언어팩 설치에 실패했습니다: :error`) 으로 응답합니다.
+> manifest 검증 실패 시 422 (`language-pack.json 검증에 실패했습니다.`), 그 외 설치 실패 시 500 (`언어팩 설치에 실패했습니다.`) 으로 응답합니다.
**에러 응답**
@@ -1664,7 +1664,7 @@ HTTP/1.1 200
}
```
-> 업데이트 소스 정보가 없거나(`업데이트 소스 정보가 없습니다 (GitHub 소스 언어팩만 업데이트 가능).`) 이미 최신 버전이면(`이미 최신 버전입니다.`) 500 (`언어팩 업데이트에 실패했습니다: :error`) 으로 응답합니다.
+> 업데이트 소스 정보가 없거나(`업데이트 소스 정보가 없습니다 (GitHub 소스 언어팩만 업데이트 가능).`) 이미 최신 버전이면(`이미 최신 버전입니다.`) 500 (`언어팩 업데이트에 실패했습니다.`) 으로 응답합니다.
**에러 응답**
diff --git a/docs/backend/api/settings.md b/docs/backend/api/settings.md
index 88cec79c..33bae599 100644
--- a/docs/backend/api/settings.md
+++ b/docs/backend/api/settings.md
@@ -240,6 +240,9 @@ HTTP/1.1 200
| advanced.seo_cache_ttl | body | integer | 아니오 | min 0, max 14400 | SEO 캐시 만료 시간 (초, 0 = 만료 없음) |
| advanced.debug_mode | body | boolean | 아니오 | — | 디버그 모드 사용 여부 (상세 오류 노출) |
| advanced.sql_query_log | body | boolean | 아니오 | — | SQL 쿼리 로그 기록 여부 |
+| advanced.outbound_proxy | body | string | 아니오 | max 500, 스킴 `http`/`https`/`socks4`/`socks4a`/`socks5`/`socks5h` | 외부 HTTP 호출이 경유할 프록시 주소 (예: `socks5h://127.0.0.1:1080`). 빈 값이면 사용하지 않으며, 디버그 모드가 꺼져 있으면 저장되어도 적용되지 않는다 |
+| advanced.outbound_proxy_bypass | body | array | 아니오 | — | 프록시를 경유하지 않을 호스트 목록 |
+| advanced.outbound_proxy_bypass.* | body | string | 아니오 | max 255 | 프록시 예외 호스트 |
| advanced.core_update_github_url | body | string | 아니오 | max 500 | 코어 업데이트를 확인할 GitHub 저장소 URL |
| advanced.core_update_github_token | body | string | 아니오 | max 500 | 프라이빗 저장소의 코어/확장 업데이트에 사용할 GitHub 액세스 토큰 (공개 저장소는 비워둘 수 있음) |
| advanced.geoip_enabled | body | boolean | 아니오 | — | IP 기반 타임존 감지(GeoIP) 사용 여부 |
@@ -1250,6 +1253,79 @@ _단건 응답: `data` 객체의 필드 (DriverConnectionTester::testAll() 산
폼에 입력한 드라이버 접속 정보(S3·Redis·Memcached·Websocket 등)로 실제 연결을 시도해 결과를 반환합니다. 설정을 저장하기 전에 접속 정보가 유효한지 확인하는 용도입니다. 모든 테스트 통과 시 성공 메시지, 일부 실패 시에도 HTTP 성공 응답으로 항목별 결과(`all_passed=false` 포함)를 함께 반환합니다.
+### POST /api/admin/settings/test-outbound-proxy
+
+- **라우트명**: `api.admin.settings.test-outbound-proxy`
+- **컨트롤러**: `AppHttpControllersApiAdminSettingsController@testOutboundProxy`
+- **인증/권한**: `auth:sanctum` + `permission:core.settings.update`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| outbound_proxy | body | string | 예 | max 500, 스킴 `http`/`https`/`socks4`/`socks4a`/`socks5`/`socks5h` | 검사할 프록시 주소 |
+| outbound_proxy_bypass | body | array | 아니오 | — | 프록시를 경유하지 않을 호스트 목록 |
+| outbound_proxy_bypass.* | body | string | 아니오 | max 255 | 프록시 예외 호스트 |
+
+**요청 예시**
+
+```http
+POST /api/admin/settings/test-outbound-proxy HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+Content-Type: application/json
+
+{
+ "outbound_proxy": "socks5h://127.0.0.1:1080",
+ "outbound_proxy_bypass": ["internal.example.com"]
+}
+```
+
+**응답 필드** (`data` 내부)
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| success | boolean | `true` | 프록시를 거쳐 외부에 도달했는지 여부. `false` 여도 HTTP 200 으로 응답한다 — 연결 실패는 요청 처리 실패가 아니라 진단 결과다 |
+| egress_ip | string|null | `203.0.113.9` | 프록시를 거쳤을 때 상대편에 보이는 출발지 IP. 외부 서비스에 등록할 값이며 실패 시 `null` |
+| elapsed_ms | integer | `512` | 검사에 걸린 시간 (밀리초) |
+| error | string|null | `cURL error 7: Failed to connect` | 실패 시에만 채워지는 원인 원문 (관리자 진단용) |
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "프록시 연결에 성공했습니다. 외부 서비스에는 이 IP 로 보입니다.",
+ "data": {
+ "success": true,
+ "message_key": "settings.outbound_proxy_test_success",
+ "egress_ip": "203.0.113.9",
+ "elapsed_ms": 512,
+ "error": null
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
+| 422 | Unprocessable Entity | 프록시 주소가 비어 있거나 허용 스킴이 아닌 경우 |
+
+
+
+**설명**
+
+입력한 프록시로 외부에 연결해 보고, 성공하면 그 프록시를 거쳤을 때 상대편에 보이는 출발지 IP 를 함께 반환합니다. 이 IP 는 운영자가 결제사·외부 서비스의 허용 목록에 등록해야 하는 값이라, 설정을 저장하기 전에 확인할 수 있도록 제공합니다.
+
+검사 대상은 **이번 요청이 제출한 값**입니다. 저장된 설정이나 전역 프록시 옵션을 보지 않으므로, 저장 전에도 그대로 확인할 수 있고 이 호출이 다른 요청의 경로를 바꾸지도 않습니다.
+
+조회 대상은 `config('core.outbound_proxy.egress_lookup_urls')` 가 소유하며 순차 시도합니다. 목록을 비우면 도달성만 확인하고 IP 는 보고하지 않습니다(폐쇄망 대응).
+
+
### POST /api/admin/settings/test-mail
- **라우트명**: `api.admin.settings.test-mail`
diff --git a/docs/backend/exceptions.md b/docs/backend/exceptions.md
index b0cf9f89..20aa1314 100644
--- a/docs/backend/exceptions.md
+++ b/docs/backend/exceptions.md
@@ -164,6 +164,27 @@ class MaxDepthExceededException extends Exception
}
```
+### 치환 자리는 비워 둘 수 없다
+
+`:error` 같은 치환 자리를 가진 키를 파라미터 없이 부르면, 번역기는 그 자리를 **그대로 둔 문장**을
+돌려준다. 그래서 운영자 화면에 `모듈 활성화에 실패했습니다: :error` 처럼 내부 자리표시자가 노출된다.
+예외도 로그도 남지 않고 실패했을 때만 드러나므로, 정상 흐름만 보는 테스트로는 잡히지 않는다.
+
+| ❌ 금지 | ✅ 올바른 사용 |
+|--------|---------------|
+| `error('x.activate_failed')` — 치환 자리를 가진 키를 파라미터 없이 | `error('x.activate_failed', 400, null, ['error' => $reason])` |
+| 사유를 모른다고 자리를 비워 두기 | 사유를 알 수 없으면 일반 문구로 채운다 (`errors.unknown_error`) |
+| 원인을 싣지 않기로 한 문구에 `:error` 자리를 남겨 두기 | 그 키에서 치환 자리 자체를 없앤다 |
+| 하위 계층이 `false` 만 돌려주고 사유를 버리기 | 사유를 반환 경로에 실어 올린다 (배열 키 또는 선택적 out 파라미터) |
+
+셋째 인자 `errors` 페이로드와 넷째 인자 `messageParams` 는 **다른 통로**다. `errors` 에 예외를 넘기는
+것은 진단 정보이고(노출 폭은 `ResponseHelper` 가 `app.debug` 로 정한다), 문구의 치환 자리를 채우는
+것은 `messageParams` 뿐이다. 한쪽만 채우면 자리표시자는 그대로 남는다.
+
+응답 문구에 예외 원문을 싣지 않기로 정한 화면(언어팩 관리 등)은 **파라미터를 채우는 대신 키에서
+치환 자리를 없앤다.** 자리를 남긴 채 일반 문구로 채우면 "…실패했습니다: 알 수 없는 오류" 처럼
+의미 없는 꼬리가 붙고, 나중에 누군가 그 자리를 예외 원문으로 채우는 회귀를 부른다.
+
---
## 예외 → 응답 매핑
diff --git a/docs/backend/routing.md b/docs/backend/routing.md
index e65153b5..669aee69 100644
--- a/docs/backend/routing.md
+++ b/docs/backend/routing.md
@@ -343,8 +343,9 @@ Route::prefix('products')->group(function () {
| `RouteCacheHelper::rebuild()` | 비운 뒤 즉시 재생성. 테스트 환경·설치 미완료는 비우기까지만 |
| `RouteCacheHelper::clear()` | 재생성 없이 비우기만 — 재생성이 부적절한 흐름 중간용 |
-`rebuild()` 는 `route:cache` 를 호출하며, 이 커맨드는 새 애플리케이션을 부팅해 라우트를
-수집하므로 방금 설치·활성화한 확장의 라우트도 함께 잡힌다. 재생성이 실패하면(직렬화
+`rebuild()` 는 `route:cache` 를 호출하며, 이 커맨드는 **새 애플리케이션을 부팅해** 라우트를
+수집한다. 방금 설치·활성화한 확장의 라우트가 함께 잡히려면 그 새 부팅이 바뀐 상태를 읽어야
+하므로, 굽기 전에 상태 캐시를 비워야 한다(아래 절). 재생성이 실패하면(직렬화
불가한 클로저 라우트 등) 비운 상태로 둔다 — 비어 있으면 느릴 뿐 정확하지만, 낡은 캐시는
방금 설치한 확장을 통째로 없는 것으로 만든다.
@@ -362,6 +363,53 @@ Route::prefix('products')->group(function () {
서버 라우트에 영향을 주지 않는다. 모듈 설정의 경로 값도 마찬가지다(서버 라우트 접두사는
`api/modules/{identifier}` 로 식별자에 고정).
+### 확장 라우트는 활성 상태로 게이트된다
+
+모듈·플러그인 라우트는 **활성 상태인 확장의 것만** 등록한다. 비활성화하면 화면·메뉴·프론트엔드
+에셋은 사라지지만, 라우트 등록을 게이트하지 않으면 그 확장의 API 는 계속 호출 가능한 상태로 남는다.
+컨트롤러 파일이 그대로 있으므로 요청은 정상 처리되고 오류도 로그도 남지 않아, 화면만 보고는
+꺼진 기능이 여전히 동작한다는 사실을 알 수 없다.
+
+| ❌ 금지 | ✅ 올바른 사용 |
+|--------|---------------|
+| 라우트 프로바이더가 디렉토리에 있는 확장 전부를 등록 | 활성 식별자 목록으로 걸러 등록 |
+| 한쪽(모듈)만 게이트하고 다른 쪽(플러그인)은 무게이트 | 두 경로가 같은 기준을 공유 |
+| 게이트를 개별 컨트롤러·미들웨어에 흩어 놓기 | 라우트 등록 지점 한 곳에서 판정 |
+
+활성 목록은 상태 캐시를 공유하므로, 상태를 바꾼 쪽이 그 캐시를 비워야 다음 부팅이 새 상태를
+읽는다(아래 절).
+
+### 굽기는 상태 캐시 무효화 뒤에 온다
+
+`route:cache` 가 부팅하는 새 애플리케이션에서 확장 라우트 프로바이더는 DB 가 아니라
+**캐시된 활성 확장 목록**을 읽는다(TTL 기본 1일). 그래서 확장의 상태를 DB 에 쓴 뒤
+그 상태 캐시를 비우기 **전에** 구우면, 새 부팅이 낡은 목록을 읽어 방금 바뀐 상태가
+반영되지 않은 라우트가 박제된다.
+
+```text
+DB 상태 쓰기 → 상태 캐시 무효화 → 굽기(rebuild / 훅 캐시 재생성)
+```
+
+순서가 뒤집혔을 때의 결과는 방향마다 다르고, 어느 쪽도 스스로 회복되지 않는다.
+
+| 수명주기 | 낡은 목록의 내용 | 결과 |
+|---------|----------------|------|
+| 활성화 | 그 확장이 없음 | 방금 켠 확장의 API 전량 404 |
+| 비활성화 | 그 확장이 남아 있음 | 끈 확장의 API 가 계속 호출 가능 |
+| 업데이트 | `Updating`(=비활성)으로 판정 | 업데이트 후 API 404 + 훅 리스너 누락 |
+
+상태 캐시 무효화는 **DB 상태 쓰기 직후**에 둔다. 굽기 직전으로 옮기는 것으로는 부족하다 —
+같은 목록을 읽는 굽기가 라우트 캐시 말고도 있기 때문이다(훅 매핑 캐시가 오토로드 갱신
+안에서 구워진다).
+
+업데이트 경로만 예외가 하나 있다. `Updating` 전이 직후에는 비우지 않는다 — 비우면 그 창
+안에서 도는 오토로드 갱신이 DB 를 재조회해 그 확장을 비활성으로 판정하고 훅 캐시에서
+리스너를 떨군다. 대신 **상태 복원 직후**에 비우고, 그 뒤 훅 캐시를 다시 굽는다. 훅 캐시
+폴백은 파일 부재·손상에만 작동하므로 내용이 낡은 경우는 조용히 통과하기 때문이다.
+
+이 순서는 정적 검사로 강제된다 — `rebuild()` 를 호출하는 수명주기 메서드를 리플렉션으로
+도출해(개별 열거 금지) 각각에서 무효화가 앞서는지 확인한다.
+
### 캐시 안전한 라우트 작성
캐시가 걸리면 `RouteServiceProvider::boot()` 이 캐시 파일 로드로 분기해 **라우트 파일 자체가
diff --git a/docs/extension/hooks.md b/docs/extension/hooks.md
index 3274ced7..da3f4025 100644
--- a/docs/extension/hooks.md
+++ b/docs/extension/hooks.md
@@ -456,6 +456,19 @@ public static function getSubscribedHooks(): array
}
```
+#### 호출자 트랜잭션 안에서 끝나야 하는 처리는 `sync` 가 필수다
+
+기본값(큐 래핑)은 `DispatchHookListenerJob` 의 `afterCommit` 정책을 탄다. 즉 **호출자 트랜잭션이 커밋된 뒤에** 실행된다 — 큐 드라이버가 `sync` 여도 마찬가지다(같은 요청 안에서, 커밋 이후에 실행된다).
+
+따라서 훅 안에서 실패했을 때 **호출자의 작업을 되돌려야 하는 처리**는 기본값으로 두면 안 된다. 되돌릴 대상이 이미 커밋된 뒤라 예외를 던져도 롤백되지 않고, 호출자는 오류 응답을 받는데 데이터는 남는다.
+
+| 판정 | 예 |
+|------|-----|
+| `sync` 필수 | 쿠폰 차감·복원, 적립금 차감·복원 등 실패 시 호출자 트랜잭션을 되돌려야 하는 처리 |
+| 기본값(큐) 유지 | 활동 로그, 알림 발송, 통계 갱신 등 실패해도 호출자를 되돌리지 않는 후속 처리 |
+
+선언만으로는 검증되지 않는다 — 회귀 테스트는 리스너를 손으로 `addAction` 하지 말고 실제 등록 경로(`HookListenerRegistrar::register()`)를 태운 뒤, **호출자 트랜잭션 안에서 반영되는지**와 **예외가 호출자를 롤백시키는지**를 단언한다. 손으로 등록하면 프로덕션이 쓰지 않는 경로를 검증하게 되어, 커밋 이후 실행 문제를 그대로 통과시킨다.
+
### getSubscribedHooks() 옵션 요약
| 옵션 | 타입 | 기본값 | 설명 |
diff --git a/docs/extension/module-basics.md b/docs/extension/module-basics.md
index d9429367..49091097 100644
--- a/docs/extension/module-basics.md
+++ b/docs/extension/module-basics.md
@@ -145,6 +145,28 @@
| `getBenchmarkProfiles()` | `[]` | 성능 계측 대상 선언 (`g7:bench` 가 수집) — 목록/화면/쓰기/배치 4축 ([benchmark.md](../backend/benchmark.md)) |
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 모듈은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `ModuleManager::runUpgradeSteps` 가 `RuntimeException` throw |
+#### 수명주기 훅이 실패를 알리는 방법
+
+`install()` / `activate()` / `deactivate()` / `uninstall()` 은 bool 만 돌려주므로, 그냥 `false` 를
+반환하면 **왜 거부했는지가 코어에 전달되지 않는다.** 그 결과 운영자는 원인이 빠진 실패 문구만 본다.
+
+사유를 남기려면 `failWith()` 로 반환한다. 코어가 그 사유를 응답 문구의 원인 자리에 싣는다.
+
+```php
+public function activate(): bool
+{
+ if (! extension_loaded('gd')) {
+ return $this->failWith(__('my-module::messages.gd_required'));
+ }
+
+ return true;
+}
+```
+
+- 사유는 **이미 번역된 문장**이어야 한다 — 확장의 언어 파일 키는 코어가 해석할 수 없다.
+- 사유를 남기지 않고 `false` 만 돌려주면 코어가 일반 문구로 대체한다(동작은 그대로).
+- 같은 규칙이 플러그인(`AbstractPlugin`)에도 동일하게 적용된다.
+
#### 동적 권한/역할/메뉴 보존 규칙
모듈이 런타임에 `Permission::updateOrCreate` / `Role::firstOrCreate` / `Menu::create` 등으로 동적 엔티티를 만드는 경우(예: sirsoft-board 의 게시판 slug 별 권한·역할·메뉴), 업데이트 시 `cleanupStale*` 로직이 **정적 정의에 없다** 는 이유로 전수 삭제되는 회귀가 발생한다. 이를 방지하려면 아래 3개 메서드를 override 해 **현재 DB 에 존재해야 하는 동적 식별자 전체** 를 반환한다.
diff --git a/docs/requirements.md b/docs/requirements.md
index 68bf1032..799d4d2a 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -320,15 +320,23 @@ Composer 설치 방식을 선택할 때만 필요하다.
## 7. 지원 브라우저
-| 브라우저 | 지원 기준 | 검증 방식 |
-|---------|----------|----------|
-| Chrome / Edge | React 19 + Tailwind CSS 4 호환 범위 | 자동 브라우저 테스트(Chromium)로 상시 검증 |
-| Firefox | React 19 + Tailwind CSS 4 호환 범위 | 호환 범위 기준 지원 (상시 자동 테스트 대상 아님) |
-| Safari | React 19 + Tailwind CSS 4 호환 범위 | 호환 범위 기준 지원 (상시 자동 테스트 대상 아님, 실기기 검증 미수행) |
+| 브라우저 | 최소 지원 버전 | 검증 방식 |
+|---------|--------------|----------|
+| Chrome / Edge | **111** 이상 | 자동 브라우저 테스트(Chromium)로 상시 검증 |
+| Safari (macOS / iOS) | **16.4** 이상 | 호환 범위 기준 지원 (상시 자동 테스트 대상 아님, 실기기 검증 미수행) |
+| Firefox | **128** 이상 | 호환 범위 기준 지원 (상시 자동 테스트 대상 아님) |
- 지원 하한은 프론트엔드 핵심 의존성(React 19, Tailwind CSS 4)의 공식 지원 브라우저
- 범위를 따르며, 해당 의존성 버전이 변경되면 이 기준도 함께 재검토한다
-- 참고: 2026-08 현재 Tailwind CSS 4 의 공식 하한은 Chrome 111 / Safari 16.4 / Firefox 128 이다
+ 범위를 따른다. 위 세 버전은 2026-08 현재 Tailwind CSS 4 의 공식 하한이며(`@property`
+ 등 CSS 기능이 결정한다), 해당 의존성 버전이 변경되면 이 기준도 함께 재검토한다
+- **하한 미만 브라우저에서도 화면 표시와 기본 이용은 가능하도록 유지한다.** 최신 CSS 기능을
+ 해석하지 못해 색상·간격 등 스타일이 일부 깨질 수 있으나, 그것이 이용 불가로 이어지지
+ 않도록 한다. 이를 위해 배포되는 JavaScript 산출물에는 하한을 넘는 문법·API 를 넣지 않으며,
+ 특히 부팅에 필요한 번들에는 다운레벨이 불가능한 정규식 리터럴 문법(lookbehind, `v` 플래그)
+ 을 하한과 같은 버전이라도 사용하지 않는다 — 이 번들이 파싱되지 않으면 사이트가 통째로
+ 뜨지 않기 때문이다
+- 브라우저가 화면 구성 스크립트를 끝내 실행하지 못하는 경우에는 백지 대신 그 사유를 밝히는
+ 안내 화면을 표시한다
- 자동 테스트는 테스트 도구에 포함된 단일 브라우저 빌드로 수행하므로 특정 버전
목록(예: "최신 N개 버전")을 상시 보장하지 않는다
- Internet Explorer 미지원
diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
index 7caf1bb9..ed9e34e5 100644
--- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
@@ -4,6 +4,18 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.7] - 2026-08-22
+
+### Added
+
+- 아웃바운드 프록시 설정의 검증 메시지와 항목명 일본어 번역을 추가했습니다 (`settings.outbound_proxy_*`, `attributes.outbound_proxy*`).
+- 아웃바운드 프록시 연결 테스트 결과 안내 문구의 일본어 번역을 추가했습니다 (`settings.outbound_proxy_test_*`).
+- 브라우저가 지원 범위보다 오래되어 화면 구성 스크립트를 실행하지 못한 경우의 안내 문구 일본어 번역을 추가했습니다 (`errors.bootstrap.incompatible_title`, `errors.bootstrap.incompatible_message`).
+
+### Changed
+
+- 언어팩 관리(조회·설치·활성화·비활성화·제거·업데이트 확인·업데이트·캐시 갱신·manifest 미리보기) 실패 안내에서 오류 원문 노출(`:error`)이 제거된 것에 맞춰, 해당 실패 문구의 일본어 번역을 갱신했습니다 (`language_packs.*_failed`).
+
## [1.0.6] - 2026-08-19
### Changed
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php b/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php
index ff973b11..5b6912be 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php
@@ -31,5 +31,7 @@ return [
'title' => '画面を読み込めませんでした',
'message' => 'ネットワーク接続が不安定な可能性があります。ページを更新してからもう一度お試しください。',
'reload' => '更新',
+ 'incompatible_title' => 'このブラウザでは画面を表示できません',
+ 'incompatible_message' => 'ブラウザが古いため、サイトを実行できませんでした。ブラウザを最新バージョンに更新するか、別のブラウザでアクセスしてください。',
],
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php b/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php
index cfa7e138..ec9c8cdb 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php
@@ -2,25 +2,25 @@
return [
'fetch_success' => '言語パックリストを取得しました。',
- 'fetch_failed' => '言語パックリストを読み込めませんでした: :error',
+ 'fetch_failed' => '言語パックリストを読み込めませんでした。',
'not_found' => '言語パックが見つかりません。',
'install_success' => '言語パックをインストールしました。',
- 'install_failed' => '言語パックのインストールに失敗しました: :error',
+ 'install_failed' => '言語パックのインストールに失敗しました。',
'activate_success' => '言語パックを有効化しました。',
- 'activate_failed' => '言語パックの有効化に失敗しました: :error',
+ 'activate_failed' => '言語パックの有効化に失敗しました。',
'deactivate_success' => '言語パックを無効化しました。',
- 'deactivate_failed' => '言語パックの無効化に失敗しました: :error',
+ 'deactivate_failed' => '言語パックの無効化に失敗しました。',
'uninstall_success' => '言語パックを削除しました。',
- 'uninstall_failed' => '言語パックの削除に失敗しました: :error',
+ 'uninstall_failed' => '言語パックの削除に失敗しました。',
'manifest_invalid' => 'language-pack.json の検証に失敗しました。',
'check_updates_success' => 'アップデート確認が完了しました。',
- 'check_updates_failed' => 'アップデート確認に失敗しました: :error',
+ 'check_updates_failed' => 'アップデート確認に失敗しました。',
'update_success' => '言語パックをアップデートしました。',
- 'update_failed' => '言語パックのアップデートに失敗しました: :error',
+ 'update_failed' => '言語パックのアップデートに失敗しました。',
'refresh_cache_success' => '言語パックキャッシュを更新しました。',
- 'refresh_cache_failed' => '言語パックキャッシュの更新に失敗しました: :error',
+ 'refresh_cache_failed' => '言語パックキャッシュの更新に失敗しました。',
'preview_success' => 'manifest プレビューが完了しました。',
- 'preview_failed' => 'manifest プレビューに失敗しました: :error',
+ 'preview_failed' => 'manifest プレビューに失敗しました。',
'errors' => [
'manifest_not_found' => 'ZIP内に language-pack.json ファイルが見つかりません。',
'manifest_invalid_json' => 'language-pack.json の JSON 形式が正しくありません。',
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php
index d6cc8a53..9c2832bc 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php
@@ -76,6 +76,12 @@ return [
'driver_test_partial' => '一部のドライバ接続テストが失敗しました。',
'driver_test_error' => 'ドライバ接続テスト中にエラーが発生しました。',
'unknown_driver' => '不明なドライバです。',
+
+ // アウトバウンドプロキシ接続テストメッセージ
+ 'outbound_proxy_test_success' => 'プロキシ接続に成功しました。外部サービスにはこの IP アドレスとして見えます。',
+ 'outbound_proxy_test_failed' => 'プロキシ経由で接続できませんでした。アドレスとプロキシサーバーの状態を確認してください。',
+ 'outbound_proxy_test_invalid_url' => 'プロキシアドレスの形式が正しくありません。',
+ 'outbound_proxy_test_no_lookup_url' => '送信元 IP の照会先が設定されていないため確認できません。',
's3_test_success' => 'S3バケットに正常に接続されました。',
's3_test_failed' => 'S3バケットへの接続に失敗しました。',
's3_missing_config' => 'S3設定が不足しています。(バケット、リージョン、アクセスキー、シークレットキー)',
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
index a0a08028..92ca8a3c 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
@@ -898,6 +898,15 @@ return [
'debug_mode_boolean' => 'デバッグモードはtrueまたはfalse値である必要があります。',
'sql_query_log_required' => 'SQLクエリログ設定を選択してください。',
'sql_query_log_boolean' => 'SQLクエリログはtrueまたはfalse値である必要があります。',
+
+ // アウトバウンド HTTP プロキシ
+ 'outbound_proxy_required' => 'アウトバウンドプロキシアドレスを入力してください。',
+ 'outbound_proxy_string' => 'アウトバウンドプロキシアドレスは文字列である必要があります。',
+ 'outbound_proxy_max' => 'アウトバウンドプロキシアドレスは:max文字を超えることはできません。',
+ 'outbound_proxy_invalid' => 'アウトバウンドプロキシアドレスの形式が正しくありません。使用可能な形式: :schemes (例: socks5h://127.0.0.1:1080)',
+ 'outbound_proxy_bypass_array' => 'プロキシ除外リストは配列である必要があります。',
+ 'outbound_proxy_bypass_item_string' => 'プロキシ除外項目は文字列である必要があります。',
+ 'outbound_proxy_bypass_item_max' => 'プロキシ除外項目は:max文字を超えることはできません。',
'core_update_github_url_invalid' => 'GitHubリポジトリURLの形式が正しくありません。',
'core_update_github_url_max' => 'GitHubリポジトリURLは500字を超えることはできません。',
'core_update_github_token_max' => 'GitHubアクセストークンは500字を超えることはできません。',
@@ -1161,6 +1170,8 @@ return [
'seo_sitemap_cache_ttl' => 'SEO サイトマップキャッシュ保持時間',
'debug_mode' => 'デバッグモード',
'sql_query_log' => 'SQL クエリログ',
+ 'outbound_proxy' => 'アウトバウンドプロキシアドレス',
+ 'outbound_proxy_bypass' => 'プロキシ除外リスト',
'core_update_github_url' => 'コア更新 GitHub アドレス',
'core_update_github_token' => 'コア更新 GitHub トークン',
'geoip_enabled' => 'GeoIP の使用',
diff --git a/lang-packs/_bundled/g7-core-ja/language-pack.json b/lang-packs/_bundled/g7-core-ja/language-pack.json
index f904c3e6..e7ad4030 100644
--- a/lang-packs/_bundled/g7-core-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-core-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 core Japanese language pack (bundled)",
"ja": "G7 コア 日本語 言語パック(バンドル)"
},
- "version": "1.0.6",
+ "version": "1.0.7",
"license": "MIT",
"scope": "core",
"target_identifier": null,
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
index 570efff3..1e790bd5 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.1.2] - 2026-08-22
+
+### Added
+
+- 이미 사용된 쿠폰으로 주문을 시도했을 때의 안내(`exceptions.coupon_already_used`)의 일본어 번역을 추가했습니다.
+
## [1.1.1] - 2026-08-19
### Added
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php
index 0774befb..b6eb63f6 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php
@@ -82,6 +82,7 @@ return [
'additional_option_custom_text_required' => '追加オプション(:name)の直接入力内容を入力してください。',
'coupon_issue_not_found' => 'クーポン発行履歴が見つかりません。',
'coupon_issue_not_cancellable' => '未使用状態の発行分のみキャンセルできます。',
+ 'coupon_already_used' => 'すでに使用されたクーポンです。クーポンをご確認のうえ、再度ご注文ください。',
'country_not_shippable' => '選択された配送先国には配送できない商品です。',
'order_shipping_address_update_failed' => '配送先変更処理中にエラーが発生しました。',
'order_option_not_confirmable' => '現在の状態では購入確定できない注文オプションです。',
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
index 547798e5..297a61d3 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
},
- "version": "1.1.1",
+ "version": "1.1.2",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-ecommerce",
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
index a957a097..1bc252fc 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.6] - 2026-08-22
+
+### Added
+
+- 환경설정 > 고급의 아웃바운드 프록시 설정 항목명·설명·입력 안내와 연결 테스트 버튼 라벨의 일본어 번역을 추가했습니다.
+
## [1.0.5] - 2026-08-19
### Added
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
index 231be2e9..1fcd76de 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
@@ -1786,6 +1786,13 @@
"dev_dashboard": "開発ダッシュボード",
"sql_query_log": "SQLクエリログ",
"sql_query_log_desc": "実行されたSQLクエリをログに記録します。ログファイルの場所: /storage/logs/query.log",
+ "outbound_proxy": "アウトバウンドプロキシアドレス",
+ "outbound_proxy_desc": "サイトが外部に送信するすべてのリクエスト(決済承認、コア更新確認、通知送信など)がこのサーバーを経由します。接続 IP を制限する外部サービスと連携する際に使用します。空欄の場合は使用しません。",
+ "outbound_proxy_placeholder": "socks5h://127.0.0.1:1080",
+ "outbound_proxy_bypass": "プロキシ除外リスト",
+ "outbound_proxy_bypass_desc": "このリストにあるアドレスへのリクエストはプロキシを経由せず直接送信されます。内部ネットワークのアドレスを登録すると不要な迂回を減らせます。",
+ "outbound_proxy_bypass_placeholder": "アドレスを入力して Enter",
+ "outbound_proxy_test": "接続テスト",
"core_update": "アップデート設定",
"core_update_desc": "コアおよび拡張(モジュール·プラグイン·テンプレート)アップデートで使用するGitHub認証情報を設定します。",
"core_update_github_url": "GitHubリポジトリURL",
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
index 063f3b9c..a69f93ef 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
},
- "version": "1.0.5",
+ "version": "1.0.6",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-admin_basic",
diff --git a/lang/en/errors.php b/lang/en/errors.php
index a5d7b320..6b105c96 100644
--- a/lang/en/errors.php
+++ b/lang/en/errors.php
@@ -44,5 +44,10 @@ return [
'title' => 'Failed to load the page',
'message' => 'Your network connection may be unstable. Please refresh and try again.',
'reload' => 'Refresh',
+
+ // 스크립트를 받았으나 브라우저가 실행하지 못한 경우 (지원 범위보다 오래된 브라우저 등).
+ // 새로고침해도 낫지 않으므로 이 분기에서는 새로고침 버튼을 렌더하지 않는다.
+ 'incompatible_title' => 'This browser cannot display the page',
+ 'incompatible_message' => 'Your browser is too old to run this site. Please update it to the latest version, or try a different browser.',
],
];
diff --git a/lang/en/language_packs.php b/lang/en/language_packs.php
index 674f1dff..c7ae49e7 100644
--- a/lang/en/language_packs.php
+++ b/lang/en/language_packs.php
@@ -2,25 +2,25 @@
return [
'fetch_success' => 'Language pack list retrieved.',
- 'fetch_failed' => 'Failed to load language pack list: :error',
+ 'fetch_failed' => 'Failed to load language pack list.',
'not_found' => 'Language pack not found.',
'install_success' => 'Language pack installed.',
- 'install_failed' => 'Failed to install language pack: :error',
+ 'install_failed' => 'Failed to install language pack.',
'activate_success' => 'Language pack activated.',
- 'activate_failed' => 'Failed to activate language pack: :error',
+ 'activate_failed' => 'Failed to activate language pack.',
'deactivate_success' => 'Language pack deactivated.',
- 'deactivate_failed' => 'Failed to deactivate language pack: :error',
+ 'deactivate_failed' => 'Failed to deactivate language pack.',
'uninstall_success' => 'Language pack removed.',
- 'uninstall_failed' => 'Failed to remove language pack: :error',
+ 'uninstall_failed' => 'Failed to remove language pack.',
'manifest_invalid' => 'language-pack.json validation failed.',
'check_updates_success' => 'Update check completed.',
- 'check_updates_failed' => 'Failed to check updates: :error',
+ 'check_updates_failed' => 'Failed to check updates.',
'update_success' => 'Language pack updated.',
- 'update_failed' => 'Failed to update language pack: :error',
+ 'update_failed' => 'Failed to update language pack.',
'refresh_cache_success' => 'Language pack cache refreshed.',
- 'refresh_cache_failed' => 'Failed to refresh language pack cache: :error',
+ 'refresh_cache_failed' => 'Failed to refresh language pack cache.',
'preview_success' => 'Manifest preview completed.',
- 'preview_failed' => 'Failed to preview manifest: :error',
+ 'preview_failed' => 'Failed to preview manifest.',
'errors' => [
'manifest_not_found' => 'language-pack.json file not found in archive.',
@@ -36,7 +36,7 @@ return [
'target_version_too_old' => 'Target :scope (":target") version does not satisfy the required constraint (:constraint).',
'downgrade_blocked' => 'Downgrade blocked (:from → :to).',
'protected_pack' => 'Protected language packs cannot be deactivated or removed.',
- 'download_failed' => 'Failed to download from URL: :url',
+ 'download_failed' => 'Failed to download the language pack from URL (:url): :error',
'download_url_not_public' => 'Language packs cannot be downloaded from internal network addresses (private IPs, localhost, etc.). Use a publicly reachable https address.',
'checksum_mismatch' => 'Checksum mismatch.',
'update_no_source' => 'No update source available (only GitHub-sourced packs can be updated).',
diff --git a/lang/en/settings.php b/lang/en/settings.php
index 8b3f2797..51d403ae 100644
--- a/lang/en/settings.php
+++ b/lang/en/settings.php
@@ -86,6 +86,12 @@ return [
'driver_test_error' => 'An error occurred while testing driver connections.',
'unknown_driver' => 'Unknown driver.',
+ // Outbound proxy connection test messages
+ 'outbound_proxy_test_success' => 'Connected through the proxy. External services will see this IP address.',
+ 'outbound_proxy_test_failed' => 'Could not connect through the proxy. Check the address and the proxy server status.',
+ 'outbound_proxy_test_invalid_url' => 'The proxy address format is invalid.',
+ 'outbound_proxy_test_no_lookup_url' => 'No egress IP lookup target is configured, so the address could not be determined.',
+
// S3 test messages
's3_test_success' => 'Successfully connected to S3 bucket.',
's3_test_failed' => 'Failed to connect to S3 bucket.',
diff --git a/lang/en/validation.php b/lang/en/validation.php
index 7305d75d..7e8e554e 100644
--- a/lang/en/validation.php
+++ b/lang/en/validation.php
@@ -913,6 +913,15 @@ return [
'sql_query_log_required' => 'Please select the SQL query log setting.',
'sql_query_log_boolean' => 'SQL query log must be true or false.',
+ // Outbound HTTP proxy
+ 'outbound_proxy_required' => 'Please enter the outbound proxy address.',
+ 'outbound_proxy_string' => 'The outbound proxy address must be a string.',
+ 'outbound_proxy_max' => 'The outbound proxy address may not be greater than :max characters.',
+ 'outbound_proxy_invalid' => 'The outbound proxy address format is invalid. Supported schemes: :schemes (e.g. socks5h://127.0.0.1:1080)',
+ 'outbound_proxy_bypass_array' => 'The proxy bypass list must be an array.',
+ 'outbound_proxy_bypass_item_string' => 'Each proxy bypass entry must be a string.',
+ 'outbound_proxy_bypass_item_max' => 'Each proxy bypass entry may not be greater than :max characters.',
+
// List limits
'pagination_result_cap_integer' => 'The total count cap must be a number.',
'pagination_result_cap_min' => 'The total count cap must be at least :min. (0 = unlimited)',
@@ -1307,6 +1316,8 @@ return [
'seo_sitemap_cache_ttl' => 'SEO sitemap cache lifetime',
'debug_mode' => 'debug mode',
'sql_query_log' => 'SQL query log',
+ 'outbound_proxy' => 'outbound proxy address',
+ 'outbound_proxy_bypass' => 'proxy bypass list',
'core_update_github_url' => 'core update GitHub URL',
'core_update_github_token' => 'core update GitHub token',
'geoip_enabled' => 'GeoIP',
diff --git a/lang/ko/errors.php b/lang/ko/errors.php
index 3cea0ead..a38ab33e 100644
--- a/lang/ko/errors.php
+++ b/lang/ko/errors.php
@@ -44,5 +44,10 @@ return [
'title' => '화면을 불러오지 못했습니다',
'message' => '네트워크 연결이 불안정할 수 있습니다. 새로고침 후 다시 시도해 주세요.',
'reload' => '새로고침',
+
+ // 스크립트를 받았으나 브라우저가 실행하지 못한 경우 (지원 범위보다 오래된 브라우저 등).
+ // 새로고침해도 낫지 않으므로 이 분기에서는 새로고침 버튼을 렌더하지 않는다.
+ 'incompatible_title' => '이 브라우저에서는 화면을 표시할 수 없습니다',
+ 'incompatible_message' => '브라우저가 오래되어 사이트를 실행하지 못했습니다. 브라우저를 최신 버전으로 업데이트하거나 다른 브라우저로 접속해 주세요.',
],
];
diff --git a/lang/ko/language_packs.php b/lang/ko/language_packs.php
index d5654726..2f815335 100644
--- a/lang/ko/language_packs.php
+++ b/lang/ko/language_packs.php
@@ -2,25 +2,25 @@
return [
'fetch_success' => '언어팩 목록을 조회했습니다.',
- 'fetch_failed' => '언어팩 목록을 불러오지 못했습니다: :error',
+ 'fetch_failed' => '언어팩 목록을 불러오지 못했습니다.',
'not_found' => '언어팩을 찾을 수 없습니다.',
'install_success' => '언어팩을 설치했습니다.',
- 'install_failed' => '언어팩 설치에 실패했습니다: :error',
+ 'install_failed' => '언어팩 설치에 실패했습니다.',
'activate_success' => '언어팩을 활성화했습니다.',
- 'activate_failed' => '언어팩 활성화에 실패했습니다: :error',
+ 'activate_failed' => '언어팩 활성화에 실패했습니다.',
'deactivate_success' => '언어팩을 비활성화했습니다.',
- 'deactivate_failed' => '언어팩 비활성화에 실패했습니다: :error',
+ 'deactivate_failed' => '언어팩 비활성화에 실패했습니다.',
'uninstall_success' => '언어팩을 제거했습니다.',
- 'uninstall_failed' => '언어팩 제거에 실패했습니다: :error',
+ 'uninstall_failed' => '언어팩 제거에 실패했습니다.',
'manifest_invalid' => 'language-pack.json 검증에 실패했습니다.',
'check_updates_success' => '업데이트 확인을 완료했습니다.',
- 'check_updates_failed' => '업데이트 확인에 실패했습니다: :error',
+ 'check_updates_failed' => '업데이트 확인에 실패했습니다.',
'update_success' => '언어팩을 업데이트했습니다.',
- 'update_failed' => '언어팩 업데이트에 실패했습니다: :error',
+ 'update_failed' => '언어팩 업데이트에 실패했습니다.',
'refresh_cache_success' => '언어팩 캐시를 갱신했습니다.',
- 'refresh_cache_failed' => '언어팩 캐시 갱신에 실패했습니다: :error',
+ 'refresh_cache_failed' => '언어팩 캐시 갱신에 실패했습니다.',
'preview_success' => 'manifest 미리보기를 완료했습니다.',
- 'preview_failed' => 'manifest 미리보기에 실패했습니다: :error',
+ 'preview_failed' => 'manifest 미리보기에 실패했습니다.',
'errors' => [
'manifest_not_found' => 'ZIP 안에서 language-pack.json 파일을 찾을 수 없습니다.',
diff --git a/lang/ko/settings.php b/lang/ko/settings.php
index 7480e8ed..4f884136 100644
--- a/lang/ko/settings.php
+++ b/lang/ko/settings.php
@@ -86,6 +86,12 @@ return [
'driver_test_error' => '드라이버 연결 테스트 중 오류가 발생했습니다.',
'unknown_driver' => '알 수 없는 드라이버입니다.',
+ // 아웃바운드 프록시 연결 테스트 메시지
+ 'outbound_proxy_test_success' => '프록시 연결에 성공했습니다. 외부 서비스에는 이 IP 로 보입니다.',
+ 'outbound_proxy_test_failed' => '프록시로 연결하지 못했습니다. 주소와 프록시 서버 상태를 확인해주세요.',
+ 'outbound_proxy_test_invalid_url' => '프록시 주소 형식이 올바르지 않습니다.',
+ 'outbound_proxy_test_no_lookup_url' => '출발지 IP 조회 대상이 설정되어 있지 않아 확인할 수 없습니다.',
+
// S3 테스트 메시지
's3_test_success' => 'S3 버킷에 성공적으로 연결되었습니다.',
's3_test_failed' => 'S3 버킷 연결에 실패했습니다.',
diff --git a/lang/ko/validation.php b/lang/ko/validation.php
index ce529839..78fa94a4 100644
--- a/lang/ko/validation.php
+++ b/lang/ko/validation.php
@@ -1000,6 +1000,15 @@ return [
'sql_query_log_required' => 'SQL 쿼리 로그 설정을 선택해주세요.',
'sql_query_log_boolean' => 'SQL 쿼리 로그는 true 또는 false 값이어야 합니다.',
+ // 아웃바운드 HTTP 프록시
+ 'outbound_proxy_required' => '아웃바운드 프록시 주소를 입력해주세요.',
+ 'outbound_proxy_string' => '아웃바운드 프록시 주소는 문자열이어야 합니다.',
+ 'outbound_proxy_max' => '아웃바운드 프록시 주소는 :max자를 초과할 수 없습니다.',
+ 'outbound_proxy_invalid' => '아웃바운드 프록시 주소 형식이 올바르지 않습니다. 사용 가능한 형식: :schemes (예: socks5h://127.0.0.1:1080)',
+ 'outbound_proxy_bypass_array' => '프록시 예외 목록은 배열이어야 합니다.',
+ 'outbound_proxy_bypass_item_string' => '프록시 예외 항목은 문자열이어야 합니다.',
+ 'outbound_proxy_bypass_item_max' => '프록시 예외 항목은 :max자를 초과할 수 없습니다.',
+
// 목록 한계값
'pagination_result_cap_integer' => '총 건수 집계 상한은 숫자여야 합니다.',
'pagination_result_cap_min' => '총 건수 집계 상한은 :min 이상이어야 합니다. (0 = 무제한)',
@@ -1300,6 +1309,8 @@ return [
'seo_sitemap_cache_ttl' => 'SEO 사이트맵 캐시 유지시간',
'debug_mode' => '디버그 모드',
'sql_query_log' => 'SQL 쿼리 로그',
+ 'outbound_proxy' => '아웃바운드 프록시 주소',
+ 'outbound_proxy_bypass' => '프록시 예외 목록',
'core_update_github_url' => '코어 업데이트 GitHub 주소',
'core_update_github_token' => '코어 업데이트 GitHub 토큰',
'geoip_enabled' => 'GeoIP 사용',
diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md
index 854e3cad..00d1a696 100644
--- a/modules/_bundled/sirsoft-board/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-board/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.5] - 2026-08-22
+
+### Security
+
+- 비밀글 목록·상세 응답에 첨부 이미지의 미리보기 주소가 그대로 실려 나가던 문제를 수정했습니다. 이미지 자체는 이미 열람 권한이 없으면 제공되지 않았지만, 주소에 담긴 파일 식별값이 응답에 노출되어 있었습니다. 이제 열람 권한이 없으면 썸네일 주소가 비어서 전달되며, 비밀글이 아닌 글은 종전과 동일하게 표시됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1894)
+
## [1.0.4] - 2026-08-19
### Security
diff --git a/modules/_bundled/sirsoft-board/composer.json b/modules/_bundled/sirsoft-board/composer.json
index fb973132..85fc5089 100644
--- a/modules/_bundled/sirsoft-board/composer.json
+++ b/modules/_bundled/sirsoft-board/composer.json
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-board",
"description": "Board module for Gnuboard7",
"type": "library",
- "version": "1.0.4",
+ "version": "1.0.5",
"license": "MIT",
"autoload": {
"psr-4": {
diff --git a/modules/_bundled/sirsoft-board/docs/api/board.md b/modules/_bundled/sirsoft-board/docs/api/board.md
index d0b3c625..1d0c2f2c 100644
--- a/modules/_bundled/sirsoft-board/docs/api/board.md
+++ b/modules/_bundled/sirsoft-board/docs/api/board.md
@@ -361,7 +361,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
| reply_count | integer | `0` | reply 개수 (집계) |
| attachment_count | integer | `0` | attachment 개수 (집계) |
| has_attachment | boolean | `false` | attachment 여부 |
-| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL) |
+| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시 노출 차단 — 필드 자체는 유지) |
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
| is_reply | boolean | `false` | reply 여부 |
@@ -1040,7 +1040,7 @@ _단건 응답: `data` 객체의 필드._
| reply_count | integer | `0` | reply 개수 (집계) |
| attachment_count | integer | `0` | attachment 개수 (집계) |
| has_attachment | boolean | `false` | attachment 여부 |
-| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL) |
+| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시 노출 차단 — 필드 자체는 유지) |
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
| is_reply | boolean | `false` | reply 여부 |
diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json
index 519aac98..95d8b194 100644
--- a/modules/_bundled/sirsoft-board/module.json
+++ b/modules/_bundled/sirsoft-board/module.json
@@ -5,7 +5,7 @@
"ko": "게시판",
"en": "Board"
},
- "version": "1.0.4",
+ "version": "1.0.5",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
diff --git a/modules/_bundled/sirsoft-board/package-lock.json b/modules/_bundled/sirsoft-board/package-lock.json
index 0b905c91..ba6f089e 100644
--- a/modules/_bundled/sirsoft-board/package-lock.json
+++ b/modules/_bundled/sirsoft-board/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-board",
- "version": "1.0.4",
+ "version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-board",
- "version": "1.0.4",
+ "version": "1.0.5",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/modules/_bundled/sirsoft-board/package.json b/modules/_bundled/sirsoft-board/package.json
index 76312ed7..44254293 100644
--- a/modules/_bundled/sirsoft-board/package.json
+++ b/modules/_bundled/sirsoft-board/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-board",
- "version": "1.0.4",
+ "version": "1.0.5",
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php
index cab4a70e..c7951c3c 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php
@@ -6,6 +6,7 @@ use App\Enums\PermissionType;
use App\Enums\UserStatus;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\ReportReasonType;
@@ -308,6 +309,13 @@ class PostResource extends BaseApiResource
*/
private function getThumbnailUrlFromRelations(): ?string
{
+ // 비밀글은 썸네일 URL 자체를 방출하지 않는다 — 서빙은 이미 차단되어 이미지가 보이지는
+ // 않지만, URL 에 실린 첨부 해시가 목록·상세 응답으로 나가 있었다(KVE-2026-1894).
+ // 판정은 첨부 목록과 같은 SecretContentGate(SSoT)를 쓴다. 필드는 남기고 값만 가린다.
+ if ($this->is_secret && ! $this->canViewSecretContent(request())) {
+ return null;
+ }
+
// 목록용 경량 관계 우선 — slug를 직접 전달하여 Board::find() N+1 방지
if ($this->relationLoaded('thumbnailAttachment') && $this->thumbnailAttachment) {
$attachment = $this->thumbnailAttachment;
@@ -678,9 +686,35 @@ class PostResource extends BaseApiResource
*/
private function canViewSecretContent(Request $request, ?string $slug = null): bool
{
+ $post = $this->resolvePostModel();
+
+ if ($post === null) {
+ // 원본 모델을 확인할 수 없으면 열람 불가로 판정한다 (fail-closed)
+ return false;
+ }
+
// 판정 규칙은 SecretContentGate(SSoT)에 있다 — 리스너·댓글 경로와 규칙을 공유해
// 드리프트를 방지한다.
- return self::canViewSecretForPost($this->resource, $request);
+ return self::canViewSecretForPost($post, $request);
+ }
+
+ /**
+ * 감싸인 원본 게시글 모델을 반환합니다.
+ *
+ * 컬렉션 경로에서는 리소스가 다시 리소스를 감싸고 있을 수 있어, $this->resource 가
+ * 곧 Post 라고 가정하면 타입 오류로 응답 전체가 실패합니다.
+ *
+ * @return Post|null 원본 게시글 모델 (해석 불가 시 null)
+ */
+ private function resolvePostModel(): ?Post
+ {
+ $candidate = $this->resource;
+
+ while ($candidate instanceof JsonResource) {
+ $candidate = $candidate->resource;
+ }
+
+ return $candidate instanceof Post ? $candidate : null;
}
/**
diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php
index 38120b6d..d677fdd0 100644
--- a/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php
@@ -25,9 +25,9 @@ class PostResourceThumbnailUrlTest extends BoardTestCase
* @param string $mimeType 첨부 MIME 타입
* @return array{post: Post, attachment: Attachment} 게시글/첨부
*/
- private function createPostWithAttachment(string $mimeType): array
+ private function createPostWithAttachment(string $mimeType, array $postAttributes = []): array
{
- $postId = $this->createTestPost();
+ $postId = $this->createTestPost($postAttributes);
$attachment = Attachment::create([
'board_id' => $this->board->id,
@@ -75,4 +75,41 @@ class PostResourceThumbnailUrlTest extends BoardTestCase
$this->assertNull($response['thumbnail']);
}
+
+ /**
+ * 비밀글의 썸네일 URL은 열람 권한이 없으면 방출되지 않아야 합니다 (KVE-2026-1894).
+ *
+ * 서빙은 막혀 있어 이미지가 보이지는 않지만, URL 에 실린 첨부 해시 자체가
+ * 목록·상세 응답으로 나가 있었다. 필드는 남기고 값만 가린다.
+ *
+ * @effects secret_post_thumbnail_hash_not_exposed
+ */
+ #[Test]
+ public function thumbnail_is_null_for_secret_post_without_permission(): void
+ {
+ ['post' => $post] = $this->createPostWithAttachment('image/jpeg', ['is_secret' => true]);
+
+ $response = (new PostResource($post))->toArray(Request::create('/'));
+
+ $this->assertArrayHasKey('thumbnail', $response, 'thumbnail 키 자체는 유지되어야 합니다.');
+ $this->assertNull($response['thumbnail']);
+ }
+
+ /**
+ * 비밀글이 아니면 썸네일 URL이 그대로 유지되어야 합니다 (선택적 차단 회귀 방지).
+ *
+ * @effects secret_post_thumbnail_hash_not_exposed
+ */
+ #[Test]
+ public function thumbnail_is_preserved_for_non_secret_post(): void
+ {
+ ['post' => $post, 'attachment' => $attachment] = $this->createPostWithAttachment('image/jpeg');
+
+ $response = (new PostResource($post))->toArray(Request::create('/'));
+
+ $this->assertSame(
+ '/api/modules/sirsoft-board/boards/'.$this->board->slug.'/attachment/'.$attachment->hash.'/preview',
+ $response['thumbnail'],
+ );
+ }
}
diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
index a5016cfa..a7345482 100644
--- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
@@ -4,6 +4,20 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.1.2] - 2026-08-22
+
+### Security
+
+- 1회만 쓸 수 있는 쿠폰이 동시에 주문 두 건에 적용되던 문제를 막았습니다. 주문이 거의 같은 시각에 확정되면 두 주문 모두 쿠폰을 사용 가능한 상태로 읽어 각자 할인을 받을 수 있었습니다. 이제 쿠폰 차감이 한 번에 하나만 성공하며, 쿠폰을 선점당한 주문은 확정되지 않고 쿠폰도 소모되지 않아 그대로 다시 시도할 수 있습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1886)
+- 쿠폰 차감·적립금 차감처럼 금액이 오가는 처리가 주문이 저장된 **뒤에** 실행되던 문제를 바로잡았습니다. 그래서 차감이 실패해도 주문은 이미 만들어진 뒤라 되돌릴 수 없었고, 위 쿠폰 중복 사용도 이 때문에 주문이 두 건 남을 수 있었습니다. 이제 이 처리들은 주문 저장과 같은 묶음 안에서 실행되어, 하나라도 실패하면 주문 전체가 취소됩니다. (KISA 측에서 제보해주신 내용을 확인하는 과정에서 함께 발견했습니다 — KVE-2026-1886)
+- 같은 주문 상품의 구매 적립 내역이 동시 확정으로 두 줄 생길 수 있던 문제를 막았습니다. 적립 내역은 상품당 한 줄이어야 취소 시 정확히 회수되는데, 두 줄이 되면 적립은 두 배가 되고 회수는 한 줄만 이뤄졌습니다. 이제 데이터베이스가 중복 자체를 막고, 동시에 들어온 요청은 기존 내역에 차액만 더합니다. 업그레이드하면 이미 쌓인 중복 내역도 한 줄로 합쳐집니다.
+
+### Fixed
+
+- 부분취소가 짧은 간격으로 두 번 이뤄질 때 앞선 취소의 취소 총액·취소 횟수·결제 취소 이력이 사라지던 문제를 수정했습니다. 이제 취소 누적값이 두 건 모두 반영됩니다.
+- 주문 취소로 쿠폰을 되돌릴 때, 그 사이 상태가 바뀐 쿠폰까지 되살리던 문제를 수정했습니다. 이미 만료되었거나 다시 사용된 쿠폰은 복원 대상에서 제외됩니다.
+- 적립금 적립과 적립 취소가 겹칠 때 한쪽의 반영이 사라지던 문제를 수정했습니다. 적립 내역의 금액·잔여 금액이 항상 최신 값 기준으로 갱신됩니다.
+
## [1.1.1] - 2026-08-19
### Security
diff --git a/modules/_bundled/sirsoft-ecommerce/composer.json b/modules/_bundled/sirsoft-ecommerce/composer.json
index 7492ffe5..ca4d7301 100644
--- a/modules/_bundled/sirsoft-ecommerce/composer.json
+++ b/modules/_bundled/sirsoft-ecommerce/composer.json
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-ecommerce",
"description": "Ecommerce module for Gnuboard7",
"type": "library",
- "version": "1.1.1",
+ "version": "1.1.2",
"license": "MIT",
"autoload": {
"psr-4": {
diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_21_000001_add_unique_purchase_earn_lot_to_ecommerce_mileage_transactions_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_21_000001_add_unique_purchase_earn_lot_to_ecommerce_mileage_transactions_table.php
new file mode 100644
index 00000000..52342122
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_21_000001_add_unique_purchase_earn_lot_to_ecommerce_mileage_transactions_table.php
@@ -0,0 +1,160 @@
+consolidateDuplicateEarnLots();
+
+ if (! Schema::hasColumn('ecommerce_mileage_transactions', 'purchase_earn_option_key')) {
+ $table = $this->qualifiedTable();
+
+ DB::statement(
+ "ALTER TABLE {$table} ADD COLUMN `purchase_earn_option_key` BIGINT UNSIGNED"
+ ." GENERATED ALWAYS AS (CASE WHEN `type` = 'purchase_earn' THEN `order_option_id` ELSE NULL END) VIRTUAL"
+ ." COMMENT '주문옵션당 구매적립 1건 강제용 파생 키 (purchase_earn 이 아니면 NULL)'"
+ );
+ }
+
+ if (! $this->indexExists('ecommerce_mileage_transactions_purchase_earn_option_unique')) {
+ $table = $this->qualifiedTable();
+
+ DB::statement(
+ "ALTER TABLE {$table} ADD UNIQUE INDEX"
+ .' `ecommerce_mileage_transactions_purchase_earn_option_unique` (`purchase_earn_option_key`)'
+ );
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ if (! Schema::hasTable('ecommerce_mileage_transactions')) {
+ return;
+ }
+
+ $table = $this->qualifiedTable();
+
+ if ($this->indexExists('ecommerce_mileage_transactions_purchase_earn_option_unique')) {
+ DB::statement("ALTER TABLE {$table} DROP INDEX `ecommerce_mileage_transactions_purchase_earn_option_unique`");
+ }
+
+ if (Schema::hasColumn('ecommerce_mileage_transactions', 'purchase_earn_option_key')) {
+ DB::statement("ALTER TABLE {$table} DROP COLUMN `purchase_earn_option_key`");
+ }
+ }
+
+ /**
+ * 같은 주문옵션에 중복 생성된 구매 적립 lot 을 가장 먼저 만들어진 한 줄로 통합합니다.
+ *
+ * 금액·잔여금액을 합산해 살아남는 lot 에 얹고 나머지는 삭제한다. 유효기간은 최초 적립
+ * 시점을 유지하는 기존 정책(방식 A)과 같게 살아남는 lot 의 값을 그대로 둔다.
+ *
+ * 반복 종료는 데이터에 맡기지 않는다 — 한 바퀴에서 실제로 지운 행이 없으면 다음 바퀴도
+ * 같은 목록을 다시 읽을 뿐이므로 그 자리에서 멈춘다 (진행 없는 반복 차단).
+ *
+ * @return void
+ */
+ private function consolidateDuplicateEarnLots(): void
+ {
+ // 중복 주문옵션 목록은 통합 대상이 남아 있는 동안만 반복 조회한다.
+ // 한 번에 모두 읽지 않으므로 중복이 많은 설치본에서도 메모리가 늘지 않는다.
+ while (true) {
+ $duplicated = DB::table('ecommerce_mileage_transactions')
+ ->select('order_option_id')
+ ->where('type', 'purchase_earn')
+ ->whereNotNull('order_option_id')
+ ->groupBy('order_option_id')
+ ->havingRaw('COUNT(*) > 1')
+ ->limit(200)
+ ->pluck('order_option_id');
+
+ if ($duplicated->isEmpty()) {
+ return;
+ }
+
+ $deleted = 0;
+
+ foreach ($duplicated as $orderOptionId) {
+ $lots = DB::table('ecommerce_mileage_transactions')
+ ->where('type', 'purchase_earn')
+ ->where('order_option_id', $orderOptionId)
+ ->orderBy('id')
+ ->get(['id', 'amount', 'remaining_amount']);
+
+ if ($lots->count() < 2) {
+ continue;
+ }
+
+ $survivor = $lots->shift();
+
+ DB::table('ecommerce_mileage_transactions')
+ ->where('id', $survivor->id)
+ ->update([
+ 'amount' => (float) $survivor->amount + (float) $lots->sum(fn ($l) => (float) $l->amount),
+ 'remaining_amount' => (float) $survivor->remaining_amount + (float) $lots->sum(fn ($l) => (float) $l->remaining_amount),
+ ]);
+
+ $deleted += DB::table('ecommerce_mileage_transactions')
+ ->whereIn('id', $lots->pluck('id')->all())
+ ->delete();
+ }
+
+ // 목록은 남아 있는데 한 행도 지우지 못했다면 더 진행할 수 없다.
+ if ($deleted === 0) {
+ return;
+ }
+ }
+ }
+
+ /**
+ * 프리픽스가 붙은 실제 테이블명을 반환합니다.
+ *
+ * @return string 백틱으로 감싼 테이블명
+ */
+ private function qualifiedTable(): string
+ {
+ return '`'.DB::getTablePrefix().'ecommerce_mileage_transactions`';
+ }
+
+ /**
+ * 인덱스 존재 여부를 확인합니다.
+ *
+ * @param string $indexName 인덱스명
+ * @return bool 존재 여부
+ */
+ private function indexExists(string $indexName): bool
+ {
+ return ! empty(DB::select(
+ 'SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1',
+ [DB::getTablePrefix().'ecommerce_mileage_transactions', $indexName]
+ ));
+ }
+};
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md
index 459a7b78..5335d627 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md
@@ -2153,6 +2153,7 @@ HTTP/1.1 201
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.create`)이 없는 경우 |
| 404 | Not Found | 임시 주문(주문서)이 없거나 만료된 경우 (`주문서를 찾을 수 없습니다.` 계열 — `exceptions.temp_order_not_found`) |
| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 예상 결제금액 불일치(`expected_total_amount` ≠ 서버 재계산값), 결제 통화 미지원(`errors.code = unsupported_payment_currency`), 재고 부족(`errors.insufficient_items`), 구매 불가 상품(`errors.code = cart_unavailable`), 주문 확정 재계산 검증 실패(쿠폰 만료·최소주문금액 미달 등 — `errors.code = order_calculation_validation_failed`). `payment_method` 가 결제수단 카탈로그에 없는 값이면 여기서 차단된다 |
+| 409 | Conflict | 적용한 쿠폰을 다른 주문이 먼저 사용한 경우 (`errors.code = coupon_already_used`, `errors.coupon_issue_id` 에 해당 발급 ID). 주문은 생성되지 않고 쿠폰도 소모되지 않으므로 그대로 재시도할 수 있습니다 |
| 428 | Identity Verification Required | 결제 진입 본인인증(IDV) 정책이 활성이고 미인증(grace 만료)인 경우 |
| 500 | Server Error | 주문 생성 중 예기치 못한 오류 (`주문 생성에 실패했습니다.`) |
diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json
index 697c7767..02aab554 100644
--- a/modules/_bundled/sirsoft-ecommerce/module.json
+++ b/modules/_bundled/sirsoft-ecommerce/module.json
@@ -5,7 +5,7 @@
"ko": "이커머스",
"en": "Ecommerce"
},
- "version": "1.1.1",
+ "version": "1.1.2",
"license": "MIT",
"description": {
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
diff --git a/modules/_bundled/sirsoft-ecommerce/package-lock.json b/modules/_bundled/sirsoft-ecommerce/package-lock.json
index a1e1cb71..7083fbfc 100644
--- a/modules/_bundled/sirsoft-ecommerce/package-lock.json
+++ b/modules/_bundled/sirsoft-ecommerce/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-ecommerce",
- "version": "1.1.1",
+ "version": "1.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-ecommerce",
- "version": "1.1.1",
+ "version": "1.1.2",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/modules/_bundled/sirsoft-ecommerce/package.json b/modules/_bundled/sirsoft-ecommerce/package.json
index 4513558f..105f9f6d 100644
--- a/modules/_bundled/sirsoft-ecommerce/package.json
+++ b/modules/_bundled/sirsoft-ecommerce/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-ecommerce",
- "version": "1.1.1",
+ "version": "1.1.2",
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CouponAlreadyUsedException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CouponAlreadyUsedException.php
new file mode 100644
index 00000000..5013f3f1
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CouponAlreadyUsedException.php
@@ -0,0 +1,33 @@
+couponIssueId;
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php
index fc4e8648..2fd40301 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php
@@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException;
+use Modules\Sirsoft\Ecommerce\Exceptions\CouponAlreadyUsedException;
use Modules\Sirsoft\Ecommerce\Exceptions\InsufficientStockException;
use Modules\Sirsoft\Ecommerce\Exceptions\MileageValidationException;
use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException;
@@ -168,6 +169,22 @@ trait HandlesOrderCreation
'has_restriction_issue' => $e->hasRestrictionIssue(),
]);
+ } catch (CouponAlreadyUsedException $e) {
+ // 주문 확정 시점에 다른 주문이 같은 쿠폰을 선점했다 — 주문 트랜잭션은 롤백된 상태다.
+ // generic 500 이 아닌 409 로 알려 사용자가 쿠폰 소진 없이 재시도할 수 있게 한다.
+ Log::warning('Order create: coupon already taken by another order', [
+ 'coupon_issue_id' => $e->getCouponIssueId(),
+ ]);
+
+ $messageKey = $e->getMessageKey();
+
+ return ResponseHelper::error(
+ $messageKey,
+ 409,
+ ['code' => 'coupon_already_used', 'coupon_issue_id' => $e->getCouponIssueId()],
+ $e->getMessageParams()
+ );
+
} catch (MileageValidationException $e) {
// 마일리지 사용 정책 위반(한도/단위/최소사용액/잔액) — generic 500 이 아닌 422 명시 차단.
// 임시주문 생성 이후 설정이 바뀌었거나 임시주문이 조작된 경우 여기로 떨어진다.
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponRestoreListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponRestoreListener.php
index e625846d..395217a1 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponRestoreListener.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponRestoreListener.php
@@ -35,12 +35,16 @@ class CouponRestoreListener implements HookListenerInterface
'sirsoft-ecommerce.order.after_cancel' => [
'method' => 'restoreCoupons',
'priority' => 10,
+ // 취소 트랜잭션 안에서 실행되어야 한다 — 큐 기본값(afterCommit)이면 취소가
+ // 커밋된 뒤에 복원이 돌아, 취소가 되돌려져도 쿠폰만 복원된 상태가 남는다.
+ 'sync' => true,
],
// 부분취소(및 전체취소 트랜잭션 내부)에서 OrderCancellationService 가 발화하는
// 명시적 복원 ID 훅. 스냅샷 파싱 없이 전달받은 ID 만 used→available 복원한다.
'sirsoft-ecommerce.coupon.restore' => [
'method' => 'restoreCouponsByIds',
'priority' => 10,
+ 'sync' => true,
],
];
}
@@ -147,27 +151,43 @@ class CouponRestoreListener implements HookListenerInterface
continue;
}
- // 만료 확인: 만료된 쿠폰은 expired 상태로 변경
+ // 만료 확인: 만료된 쿠폰은 expired 상태로 변경.
+ // 조회 시점의 USED 를 조건으로 실어 원자적으로 쓴다 — 그 사이 다른 요청이 상태를
+ // 바꿨다면 갱신을 포기해야 낡은 스냅샷이 최신 상태를 덮어쓰지 않는다.
if ($couponIssue->expired_at !== null && $couponIssue->expired_at->isPast()) {
- $this->couponIssueRepository->update($issueId, [
- 'status' => CouponIssueRecordStatus::EXPIRED,
- 'used_at' => null,
- ]);
+ $expiredAffected = $this->couponIssueRepository->updateIfStatus(
+ $issueId,
+ CouponIssueRecordStatus::USED,
+ [
+ 'status' => CouponIssueRecordStatus::EXPIRED,
+ 'used_at' => null,
+ ]
+ );
- Log::info('CouponRestoreListener: 만료된 쿠폰 상태 변경', [
- 'coupon_issue_id' => $issueId,
- 'order_id' => $order->id,
- 'new_status' => CouponIssueRecordStatus::EXPIRED->value,
- ]);
+ if ($expiredAffected > 0) {
+ Log::info('CouponRestoreListener: 만료된 쿠폰 상태 변경', [
+ 'coupon_issue_id' => $issueId,
+ 'order_id' => $order->id,
+ 'new_status' => CouponIssueRecordStatus::EXPIRED->value,
+ ]);
+ }
continue;
}
- // 사용 가능 상태로 복원
- $this->couponIssueRepository->update($issueId, [
- 'status' => CouponIssueRecordStatus::AVAILABLE,
- 'used_at' => null,
- ]);
+ // 사용 가능 상태로 복원 (복원은 멱등이 정상이므로 경쟁에서 밀리면 조용히 skip)
+ $affected = $this->couponIssueRepository->updateIfStatus(
+ $issueId,
+ CouponIssueRecordStatus::USED,
+ [
+ 'status' => CouponIssueRecordStatus::AVAILABLE,
+ 'used_at' => null,
+ ]
+ );
+
+ if ($affected === 0) {
+ continue;
+ }
$restoredCount++;
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponUseListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponUseListener.php
index 3d6bf771..de8f442c 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponUseListener.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponUseListener.php
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus;
+use Modules\Sirsoft\Ecommerce\Exceptions\CouponAlreadyUsedException;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface;
@@ -39,6 +40,10 @@ class CouponUseListener implements HookListenerInterface
'sirsoft-ecommerce.coupon.use' => [
'method' => 'markCouponsUsed',
'priority' => 10,
+ // 호출자(주문 생성) 트랜잭션 안에서 실행되어야 한다. Action 훅 기본값은 큐 작업
+ // 래핑 + afterCommit 이라, 그대로 두면 쿠폰 차감이 주문 커밋 뒤에 실행되어
+ // 차감이 실패해도 주문을 되돌릴 수 없다 (1회 제한 쿠폰 다중 사용).
+ 'sync' => true,
],
];
}
@@ -67,24 +72,52 @@ class CouponUseListener implements HookListenerInterface
$usedCount = 0;
foreach (array_unique($appliedCouponIds) as $issueId) {
- $couponIssue = $this->couponIssueRepository->findById((int) $issueId);
+ $issueId = (int) $issueId;
+
+ // AVAILABLE 판정과 차감을 한 UPDATE 문에서 원자적으로 수행한다.
+ // 조회 후 갱신하면 동시에 확정되는 두 주문이 모두 AVAILABLE 을 읽어
+ // 각자 USED 로 덮어써 1회 제한 쿠폰이 여러 주문에 사용된다.
+ $affected = $this->couponIssueRepository->updateIfStatus(
+ $issueId,
+ CouponIssueRecordStatus::AVAILABLE,
+ [
+ 'status' => CouponIssueRecordStatus::USED,
+ 'used_at' => now(),
+ 'order_id' => $order->id,
+ ]
+ );
+
+ if ($affected > 0) {
+ $usedCount++;
- if ($couponIssue === null) {
continue;
}
- // 이미 사용됨/취소됨 등은 skip (멱등성 — 재발화/재시도 안전)
- if ($couponIssue->status !== CouponIssueRecordStatus::AVAILABLE) {
+ $current = $this->couponIssueRepository->findById($issueId);
+
+ // 존재하지 않는 발급 ID 는 종전대로 skip
+ if ($current === null) {
continue;
}
- $this->couponIssueRepository->update((int) $issueId, [
- 'status' => CouponIssueRecordStatus::USED,
- 'used_at' => now(),
+ // 같은 주문의 재발화(재시도/훅 중복 발화)는 멱등하게 skip
+ if ($current->status === CouponIssueRecordStatus::USED
+ && (int) $current->order_id === (int) $order->id) {
+ continue;
+ }
+
+ // 다른 주문이 선점했거나 사용 가능 상태가 아니다. 이 주문은 이미 할인 금액이
+ // 확정된 상태이므로 검출만으로는 부족하고, 예외를 전파해 주문 트랜잭션 자체를
+ // 롤백해야 쿠폰이 중복 사용되지 않는다.
+ Log::warning('CouponUseListener: 쿠폰 선점 실패 — 주문 롤백', [
'order_id' => $order->id,
+ 'order_number' => $order->order_number ?? null,
+ 'coupon_issue_id' => $issueId,
+ 'current_status' => $current->status?->value,
+ 'current_order_id' => $current->order_id,
]);
- $usedCount++;
+ throw new CouponAlreadyUsedException($issueId);
}
Log::info('CouponUseListener: 주문 쿠폰 사용 차감 완료', [
@@ -93,6 +126,9 @@ class CouponUseListener implements HookListenerInterface
'total_coupons' => count(array_unique($appliedCouponIds)),
'used_count' => $usedCount,
]);
+ } catch (CouponAlreadyUsedException $e) {
+ // 도메인 실패는 삼키지 않고 전파한다 (주문 트랜잭션 롤백 트리거)
+ throw $e;
} catch (\Exception $e) {
Log::error('CouponUseListener: 쿠폰 사용 차감 실패', [
'order_id' => $order->id,
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php
index 2c6f75fe..09f0e3e3 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php
@@ -40,8 +40,11 @@ class MileageTransactionListener implements HookListenerInterface
public static function getSubscribedHooks(): array
{
return [
- 'sirsoft-ecommerce.mileage.use' => ['method' => 'handleUse', 'priority' => 10],
- 'sirsoft-ecommerce.mileage.restore' => ['method' => 'handleRestore', 'priority' => 10],
+ // 마일리지 차감/복원은 호출자(주문 생성·취소) 트랜잭션 안에서 실행되어야 한다.
+ // Action 훅 기본값은 큐 작업 래핑 + afterCommit 이라, 그대로 두면 주문이 커밋된
+ // 뒤에 차감이 돌아 잔액 부족으로 실패해도 주문을 되돌릴 수 없다.
+ 'sirsoft-ecommerce.mileage.use' => ['method' => 'handleUse', 'priority' => 10, 'sync' => true],
+ 'sirsoft-ecommerce.mileage.restore' => ['method' => 'handleRestore', 'priority' => 10, 'sync' => true],
'sirsoft-ecommerce.order-option.after_confirm' => ['method' => 'handleAfterConfirm', 'priority' => 10],
'sirsoft-ecommerce.order_option.after_status_change' => ['method' => 'handleAfterStatusChange', 'priority' => 10],
'sirsoft-ecommerce.order_option.after_bulk_status_change' => ['method' => 'handleAfterBulkStatusChange', 'priority' => 10],
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CouponIssueRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CouponIssueRepositoryInterface.php
index fc7ce57f..b2f9731d 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CouponIssueRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CouponIssueRepositoryInterface.php
@@ -2,6 +2,9 @@
namespace Modules\Sirsoft\Ecommerce\Repositories\Contracts;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Support\Collection;
+use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus;
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
/**
@@ -45,9 +48,9 @@ interface CouponIssueRepositoryInterface
* @param int $userId 사용자 ID
* @param string|null $status 필터 상태 (available, used, expired)
* @param int $perPage 페이지당 항목 수
- * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 쿠폰함 페이지네이터
+ * @return LengthAwarePaginator 쿠폰함 페이지네이터
*/
- public function getUserCoupons(int $userId, ?string $status = null, int $perPage = 10): \Illuminate\Contracts\Pagination\LengthAwarePaginator;
+ public function getUserCoupons(int $userId, ?string $status = null, int $perPage = 10): LengthAwarePaginator;
/**
* 특정 사용자가 소유한 쿠폰만 조회 (소유권 검증용)
@@ -57,14 +60,14 @@ interface CouponIssueRepositoryInterface
*
* @param array $couponIssueIds 쿠폰 발급 ID 배열
* @param int $userId 사용자 ID
- * @return \Illuminate\Support\Collection CouponIssue 컬렉션
+ * @return Collection CouponIssue 컬렉션
*/
- public function findByIdsForUser(array $couponIssueIds, int $userId): \Illuminate\Support\Collection;
+ public function findByIdsForUser(array $couponIssueIds, int $userId): Collection;
/**
* 쿠폰 발급 레코드 생성
*
- * @param array $data 발급 데이터
+ * @param array $data 발급 데이터
* @return CouponIssue
*/
public function create(array $data): CouponIssue;
@@ -72,8 +75,8 @@ interface CouponIssueRepositoryInterface
/**
* 특정 사용자의 특정 쿠폰 발급 횟수 조회
*
- * @param int $userId 사용자 ID
- * @param int $couponId 쿠폰 ID
+ * @param int $userId 사용자 ID
+ * @param int $couponId 쿠폰 ID
* @return int 발급 횟수
*/
public function getUserIssuedCountForCoupon(int $userId, int $couponId): int;
@@ -99,6 +102,20 @@ interface CouponIssueRepositoryInterface
*/
public function update(int $id, array $data): bool;
+ /**
+ * 현재 상태가 기대값과 같을 때만 쿠폰 발급 레코드를 갱신합니다.
+ *
+ * 조회 후 갱신하는 방식은 두 요청이 같은 상태를 읽어 서로를 덮어쓰는 lost update 를
+ * 허용하므로, 상태 판정과 갱신을 하나의 UPDATE 문에서 원자적으로 수행합니다.
+ * 갱신된 행 수가 0 이면 다른 요청이 이미 상태를 바꾼 것입니다.
+ *
+ * @param int $id 쿠폰 발급 ID
+ * @param CouponIssueRecordStatus $expected 기대하는 현재 상태
+ * @param array $data 업데이트 데이터
+ * @return int 갱신된 행 수 (0 이면 경쟁에서 밀렸거나 상태 불일치)
+ */
+ public function updateIfStatus(int $id, CouponIssueRecordStatus $expected, array $data): int;
+
/**
* ID 목록으로 쿠폰 발급 레코드를 조회합니다.
*
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/MileageTransactionRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/MileageTransactionRepositoryInterface.php
index 7c00ab92..2112047a 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/MileageTransactionRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/MileageTransactionRepositoryInterface.php
@@ -214,6 +214,17 @@ interface MileageTransactionRepositoryInterface
*/
public function findEarnLotForOption(int $orderOptionId): ?MileageTransaction;
+ /**
+ * 주문옵션의 적립 lot 을 행 잠금과 함께 조회합니다.
+ *
+ * 적립 증액·취소 회수는 lot 의 현재 값을 읽어 더하거나 빼는 경로라, 두 요청이 같은
+ * 값을 읽으면 한쪽 반영이 사라집니다. 갱신 트랜잭션 안에서 이 메서드로 잠급니다.
+ *
+ * @param int $orderOptionId 주문옵션 ID
+ * @return MileageTransaction|null 잠긴 적립 lot (없으면 null)
+ */
+ public function findEarnLotForOptionForUpdate(int $orderOptionId): ?MileageTransaction;
+
/**
* 회원의 활성 적립건(lot) 전부를 조회합니다 (FOR UPDATE 없음 — 탈퇴 정리용).
*
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php
index ab9c855d..e76c81e6 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php
@@ -12,6 +12,17 @@ use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
*/
interface OrderPaymentRepositoryInterface
{
+ /**
+ * 주문 ID로 결제 행을 잠금과 함께 조회합니다.
+ *
+ * 취소 누적액·취소 이력은 현재 값을 읽어 더하거나 덧붙이는 컬럼이라, 두 요청이 같은
+ * 값을 읽으면 후행이 선행을 덮어씁니다. 트랜잭션 안에서 행을 잠근 뒤 갱신합니다.
+ *
+ * @param int $orderId 주문 ID
+ * @return OrderPayment|null 잠긴 결제 모델 (없으면 null)
+ */
+ public function findByOrderIdForUpdate(int $orderId): ?OrderPayment;
+
/**
* 현금영수증 발급 성공 시 결제의 요약 컬럼을 갱신합니다.
*
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php
index 8ab419d6..1fd98aa9 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderRepositoryInterface.php
@@ -19,6 +19,18 @@ interface OrderRepositoryInterface
*/
public function find(int $id): ?Order;
+ /**
+ * ID로 주문을 행 잠금과 함께 조회합니다.
+ *
+ * 취소 총액·취소 횟수처럼 현재 값을 읽어 더하는 컬럼은 두 요청이 같은 값을 읽으면
+ * 후행이 선행을 덮어씁니다. 트랜잭션 안에서 이 메서드로 행을 잠근 뒤 갱신하면
+ * 뒤따르는 요청이 앞선 커밋을 본 뒤에 진행합니다.
+ *
+ * @param int $id 주문 ID
+ * @return Order|null 잠긴 주문 모델 (없으면 null)
+ */
+ public function findByIdForUpdate(int $id): ?Order;
+
/**
* 주문이 1건이라도 존재하는지 확인합니다. (A2 base 통화 변경 가드)
*
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php
index 0de1aecf..20d64bdd 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CouponIssueRepository.php
@@ -248,6 +248,19 @@ class CouponIssueRepository implements CouponIssueRepositoryInterface
->update($data) > 0;
}
+ /**
+ * {@inheritDoc}
+ */
+ public function updateIfStatus(int $id, CouponIssueRecordStatus $expected, array $data): int
+ {
+ // 상태 판정을 WHERE 절에 실어 단일 UPDATE 로 수행한다 (compare-and-set).
+ // 조회 후 갱신하면 두 요청이 같은 상태를 읽어 서로를 덮어쓴다.
+ return $this->model
+ ->where('id', $id)
+ ->where('status', $expected->value)
+ ->update($data);
+ }
+
/**
* {@inheritDoc}
*/
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php
index d7dbd913..48bf40bf 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php
@@ -88,8 +88,11 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf
*/
public function decrementRemaining(MileageTransaction $lot, float $amount): void
{
- $lot->remaining_amount = (float) $lot->remaining_amount - $amount;
- $lot->save();
+ // 값을 PHP 에서 빼고 모델 전체를 저장하면, 스냅샷을 읽은 뒤 다른 요청이 반영한
+ // 증감이 통째로 사라진다. 컬럼 연산으로 위임해 커밋된 값에서 차감한다.
+ MileageTransaction::query()->where('id', $lot->id)->decrement('remaining_amount', $amount);
+
+ $lot->refresh();
}
/**
@@ -130,9 +133,14 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf
*/
public function incrementEarnLotAmount(MileageTransaction $lot, float $delta): void
{
- $lot->amount = (float) $lot->amount + $delta;
- $lot->remaining_amount = (float) $lot->remaining_amount + $delta;
- $lot->save();
+ // 스냅샷 기준 재계산 대신 컬럼 연산 — 그 사이 반영된 다른 증감을 덮어쓰지 않는다.
+ MileageTransaction::query()->where('id', $lot->id)->incrementEach([
+ 'amount' => $delta,
+ 'remaining_amount' => $delta,
+ ]);
+
+ // 호출부가 이 모델을 그대로 반환·기록하므로 반영된 값으로 되읽는다
+ $lot->refresh();
}
/**
@@ -448,6 +456,19 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf
->first();
}
+ /**
+ * {@inheritdoc}
+ */
+ public function findEarnLotForOptionForUpdate(int $orderOptionId): ?MileageTransaction
+ {
+ // 잠금은 트랜잭션 안에서만 의미가 있다 — 적립/회수 갱신 트랜잭션에서 호출한다
+ return MileageTransaction::query()
+ ->where('order_option_id', $orderOptionId)
+ ->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value)
+ ->lockForUpdate()
+ ->first();
+ }
+
/**
* {@inheritdoc}
*/
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php
index 03e6764f..a136bac1 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php
@@ -14,6 +14,14 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderPaymentRepositoryInter
*/
class OrderPaymentRepository implements OrderPaymentRepositoryInterface
{
+ /**
+ * {@inheritDoc}
+ */
+ public function findByOrderIdForUpdate(int $orderId): ?OrderPayment
+ {
+ return OrderPayment::query()->where('order_id', $orderId)->lockForUpdate()->first();
+ }
+
/**
* {@inheritDoc}
*/
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php
index 82bac990..9fbc1526 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php
@@ -146,6 +146,14 @@ class OrderRepository implements OrderRepositoryInterface
return $this->model->find($id);
}
+ /**
+ * {@inheritDoc}
+ */
+ public function findByIdForUpdate(int $id): ?Order
+ {
+ return $this->model->newQuery()->lockForUpdate()->find($id);
+ }
+
/**
* {@inheritDoc}
*/
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php
index 680851c0..aa70a636 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php
@@ -32,6 +32,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterf
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderPaymentRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundOptionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundRepositoryInterface;
+use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
@@ -57,6 +58,7 @@ class OrderCancellationService
* @param OrderRefundOptionRepositoryInterface $orderRefundOptionRepository 주문 환불 옵션 Repository
* @param CashReceiptService $cashReceiptService 현금영수증 발급/취소 서비스
* @param OrderPaymentRepositoryInterface $orderPaymentRepository 주문 결제 Repository
+ * @param OrderRepositoryInterface $orderRepository 주문 Repository
*/
public function __construct(
protected OrderAdjustmentService $adjustmentService,
@@ -72,6 +74,7 @@ class OrderCancellationService
protected OrderRefundOptionRepositoryInterface $orderRefundOptionRepository,
protected CashReceiptService $cashReceiptService,
protected OrderPaymentRepositoryInterface $orderPaymentRepository,
+ protected OrderRepositoryInterface $orderRepository,
) {}
/**
@@ -314,6 +317,12 @@ class OrderCancellationService
$cancelledBy, $cancelPg, $adjustmentResult,
&$orderCancel, &$orderRefund,
) {
+ // ③-0. 누적 컬럼 갱신 전 주문·결제 행을 잠그고 커밋된 값으로 되읽는다.
+ // total_cancelled_amount / cancellation_count / cancelled_amount / cancel_history 는
+ // 현재 값을 읽어 더하거나 덧붙이는 컬럼이라, 동시 부분취소 두 건이 같은 값을 읽으면
+ // 후행이 선행을 덮어써 취소 총액이 과소 기록되고 PG 환불 기준이 어긋난다.
+ $this->lockAccumulatorRows($order);
+
$now = Carbon::now();
$isFullCancel = $cancelType === CancelTypeEnum::FULL;
$isPaid = ! $order->order_status->isBeforePayment();
@@ -734,6 +743,32 @@ class OrderCancellationService
// ③-e. Order 합계 업데이트
// ───────────────────────────────────────────────
+ /**
+ * 누적 컬럼을 가진 주문·결제 행을 잠그고 커밋된 값으로 되읽습니다.
+ *
+ * 관계(options/shippings 등)는 이 트랜잭션 안에서 이미 사용 중이므로 건드리지 않고,
+ * 누적 판단에 쓰이는 속성만 교체한다. 잠금은 커밋까지 유지되어 뒤따르는 취소 요청이
+ * 앞선 커밋을 본 뒤에 진행한다.
+ *
+ * @param Order $order 대상 주문 (속성이 최신 값으로 갱신됨)
+ * @return void
+ */
+ protected function lockAccumulatorRows(Order $order): void
+ {
+ $locked = $this->orderRepository->findByIdForUpdate($order->id);
+
+ if ($locked !== null) {
+ // 관계는 유지한 채 속성만 커밋된 값으로 교체
+ $order->setRawAttributes($locked->getAttributes(), true);
+ }
+
+ $lockedPayment = $this->orderPaymentRepository->findByOrderIdForUpdate($order->id);
+
+ if ($lockedPayment !== null && $order->relationLoaded('payment') && $order->payment !== null) {
+ $order->payment->setRawAttributes($lockedPayment->getAttributes(), true);
+ }
+ }
+
/**
* 주문 합계를 재계산 결과에 따라 갱신합니다.
*
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php
index e37eb10a..726cd8e6 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php
@@ -7,6 +7,7 @@ use App\Extension\HookManager;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\DTO\MileageAdminDeductDto;
use Modules\Sirsoft\Ecommerce\DTO\MileageAdminEarnDto;
@@ -379,36 +380,35 @@ class UserMileageService
// 방식 A: 기존 purchase_earn lot 이 있으면 그 lot 에 델타를 증액(적립 내역 한 줄 유지).
// 취소 회수(findEarnLotForOption 단일 lot 가정)·유효기간 정합을 위해 신규 lot 을 늘리지 않는다.
if ($type === MileageTransactionTypeEnum::PURCHASE_EARN
- && ($existingLot = $this->ledger->findEarnLotForOption($option->id)) !== null) {
- $this->ledger->incrementEarnLotAmount($existingLot, $amount);
-
- $this->cache->recalculateForUser($order->user_id, $currency);
- $this->cache->recalculatePending($order->user_id, $currency);
-
- $this->logActivity('mileage.earn', [
- 'loggable' => $existingLot,
- 'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_earn',
- 'description_params' => ['amount' => ecommerce_format_price((int) $amount, $currency)],
- 'properties' => ['order_id' => $order->id, 'order_option_id' => $option->id, 'currency' => $currency, 'delta' => (int) $amount],
- ]);
-
- return $existingLot;
+ && $this->ledger->findEarnLotForOption($option->id) !== null) {
+ return $this->applyDeltaToExistingEarnLot($order, $option, $currency, $target);
}
$expiresAt = $this->resolveEarnExpiry();
- $tx = $this->ledger->createTransaction([
- 'user_id' => $order->user_id,
- 'currency' => $currency,
- 'type' => $type->value,
- 'amount' => $amount,
- 'remaining_amount' => $amount,
- 'balance_after' => $this->ledger->getBalanceByCurrency($order->user_id, $currency) + $amount,
- 'order_id' => $order->id,
- 'order_option_id' => $option->id,
- 'expires_at' => $expiresAt,
- 'description' => __('sirsoft-ecommerce::activity_log.description.mileage_earn', ['amount' => ecommerce_format_price($amount, $currency)]),
- ]);
+ try {
+ $tx = $this->ledger->createTransaction([
+ 'user_id' => $order->user_id,
+ 'currency' => $currency,
+ 'type' => $type->value,
+ 'amount' => $amount,
+ 'remaining_amount' => $amount,
+ 'balance_after' => $this->ledger->getBalanceByCurrency($order->user_id, $currency) + $amount,
+ 'order_id' => $order->id,
+ 'order_option_id' => $option->id,
+ 'expires_at' => $expiresAt,
+ 'description' => __('sirsoft-ecommerce::activity_log.description.mileage_earn', ['amount' => ecommerce_format_price($amount, $currency)]),
+ ]);
+ } catch (UniqueConstraintViolationException $e) {
+ // 최초 적립이 동시에 겹쳐 다른 요청이 먼저 lot 을 만들었다. 옵션당 적립 lot 은
+ // 한 줄이어야 하므로(취소 회수가 그 한 줄을 되돌린다) 새로 만들지 않고 증액 경로로
+ // 흡수한다 — 델타는 잠근 행 기준으로 다시 산정되므로 이중 적립이 되지 않는다.
+ if ($type !== MileageTransactionTypeEnum::PURCHASE_EARN) {
+ throw $e;
+ }
+
+ return $this->applyDeltaToExistingEarnLot($order, $option, $currency, $target);
+ }
$this->cache->recalculateForUser($order->user_id, $currency);
$this->cache->recalculatePending($order->user_id, $currency);
@@ -558,17 +558,24 @@ class UserMileageService
return null;
}
- // 해당 옵션 적립건 조회
- $earnLot = $this->ledger->findEarnLotForOption($option->id);
-
- if ($earnLot === null) {
+ // 해당 옵션 적립건 존재 확인 (실제 회수 대상은 트랜잭션 안에서 잠금과 함께 되읽는다)
+ if ($this->ledger->findEarnLotForOption($option->id) === null) {
return null;
}
$currency = $this->baseCurrencyForOrder($order);
- $toRecover = (float) $earnLot->amount;
- return DB::transaction(function () use ($order, $option, $earnLot, $currency, $toRecover) {
+ return DB::transaction(function () use ($order, $option, $currency) {
+ // 회수액은 잠근 행의 커밋된 금액 기준이어야 한다 — 트랜잭션 밖에서 읽은 값을 쓰면
+ // 그 사이 반영된 적립 증액분이 회수에서 누락된다.
+ $earnLot = $this->ledger->findEarnLotForOptionForUpdate($option->id);
+
+ if ($earnLot === null) {
+ return null;
+ }
+
+ $toRecover = (float) $earnLot->amount;
+
$shortfall = $this->recoverPoints($order->user_id, $currency, $toRecover, $earnLot);
$tx = $this->ledger->createTransaction([
@@ -965,6 +972,49 @@ class UserMileageService
});
}
+ /**
+ * 기존 구매 적립 lot 에 목표액 대비 델타만 증액합니다 (방식 A — 적립 내역 한 줄 유지).
+ *
+ * 델타는 행을 잠근 뒤 다시 산정한다 — 두 확정 요청이 같은 기적립 합계를 읽으면 같은
+ * 델타를 각자 증액해 목표 적립액을 넘어선다.
+ *
+ * @param Order $order 주문
+ * @param OrderOption $option 주문옵션
+ * @param string $currency 기준 통화
+ * @param float $target 목표 적립액
+ * @return MileageTransaction|null 증액된 적립건 (대상 없으면 null)
+ */
+ private function applyDeltaToExistingEarnLot(Order $order, OrderOption $option, string $currency, float $target): ?MileageTransaction
+ {
+ return DB::transaction(function () use ($order, $option, $currency, $target) {
+ $existingLot = $this->ledger->findEarnLotForOptionForUpdate($option->id);
+
+ if ($existingLot === null) {
+ return null;
+ }
+
+ $delta = $target - $this->ledger->sumPurchaseEarnedForOption($option->id);
+
+ if ($delta <= 0) {
+ return $existingLot;
+ }
+
+ $this->ledger->incrementEarnLotAmount($existingLot, $delta);
+
+ $this->cache->recalculateForUser($order->user_id, $currency);
+ $this->cache->recalculatePending($order->user_id, $currency);
+
+ $this->logActivity('mileage.earn', [
+ 'loggable' => $existingLot,
+ 'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_earn',
+ 'description_params' => ['amount' => ecommerce_format_price((int) $delta, $currency)],
+ 'properties' => ['order_id' => $order->id, 'order_option_id' => $option->id, 'currency' => $currency, 'delta' => (int) $delta],
+ ]);
+
+ return $existingLot;
+ });
+ }
+
/**
* 적립 회수 시 lot 잔여를 우선 차감 → 타 lot FIFO → 부족분 반환 (§2 정책)
*
diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php
index 4e1aa6ba..4b6b5234 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php
@@ -32,6 +32,7 @@ return [
'coupon_has_issues' => 'Cannot delete coupon because it has :count issued coupons.',
'coupon_issue_not_found' => 'Coupon issue record not found.',
'coupon_issue_not_cancellable' => 'Only unused issued coupons can be cancelled.',
+ 'coupon_already_used' => 'This coupon has already been used. Please review your coupons and try again.',
'label_not_found' => 'Label not found.',
'product_notice_template_not_found' => 'Product notice template not found.',
'product_common_info_not_found' => 'Product common information not found.',
diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php
index 08e94a55..1eaffd78 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php
@@ -32,6 +32,7 @@ return [
'coupon_has_issues' => '발급된 쿠폰이 :count건 있어 삭제할 수 없습니다.',
'coupon_issue_not_found' => '쿠폰 발급 내역을 찾을 수 없습니다.',
'coupon_issue_not_cancellable' => '미사용 상태의 발급 건만 취소할 수 있습니다.',
+ 'coupon_already_used' => '이미 사용된 쿠폰입니다. 쿠폰을 다시 확인한 뒤 주문해 주세요.',
'label_not_found' => '라벨을 찾을 수 없습니다.',
'product_notice_template_not_found' => '상품정보제공고시 템플릿을 찾을 수 없습니다.',
'product_common_info_not_found' => '상품 공통정보를 찾을 수 없습니다.',
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserOrderControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserOrderControllerTest.php
index 6727679a..d214f25f 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserOrderControllerTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/UserOrderControllerTest.php
@@ -2,6 +2,8 @@
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\User;
+use App\Extension\HookManager;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
@@ -17,6 +19,7 @@ use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
use Modules\Sirsoft\Ecommerce\Models\UserAddress;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
+use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
@@ -68,6 +71,33 @@ class UserOrderControllerTest extends ModuleTestCase
]);
}
+ /**
+ * 기본 PG 제공자를 설정하고, 그 제공자를 레지스트리에도 등록합니다.
+ *
+ * 설정만 바꾸면 카탈로그가 그 PG 를 "사라진 PG"(`_orphaned_pg`)로 판정해 해당 결제수단이
+ * 주문 불가가 되고 주문 생성이 422 로 막힙니다(#570 고아 카탈로그 차단). 실제 운영에서는
+ * PG 플러그인이 이 훅으로 자신을 등록하므로, 테스트도 같은 경로로 등록해야 합니다.
+ *
+ * @param string $providerId PG 제공자 식별자
+ * @return void
+ */
+ protected function registerDefaultPgProvider(string $providerId): void
+ {
+ app(EcommerceSettingsService::class)->setSetting('order_settings.default_pg_provider', $providerId);
+
+ HookManager::addFilter(
+ 'sirsoft-ecommerce.payment.registered_pg_providers',
+ fn (array $providers) => array_merge($providers, [[
+ 'id' => $providerId,
+ 'name' => $providerId,
+ 'payment_handler' => 'sirsoft-pay_'.$providerId.'.requestPayment',
+ ]])
+ );
+
+ app(PaymentMethodResolver::class)->flushCache();
+ app(EcommerceSettingsService::class)->clearCache();
+ }
+
/**
* 임시 주문 생성 헬퍼
*/
@@ -357,6 +387,7 @@ class UserOrderControllerTest extends ModuleTestCase
* 화면은 `?with_items=1` 로 켠다.
*
* @scenario surface=my_page_list,option_profile=multiple
+ *
* @effects my_page_list_default_is_representative_only
*/
public function test_기본_목록은_대표_아이템_1건과_개수만_싣는다(): void
@@ -386,6 +417,7 @@ class UserOrderControllerTest extends ModuleTestCase
* 깨지면 주문마다 상품 한 줄만 남는다.
*
* @scenario surface=my_page_list,option_profile=multiple
+ *
* @effects my_page_list_enumerates_every_item_when_requested
*/
public function test_with_items_1_이면_아이템을_전부_싣는다(): void
@@ -414,6 +446,7 @@ class UserOrderControllerTest extends ModuleTestCase
* 페이로드를 줄이려다 쿼리를 늘리는 맞바꿈이 된다.
*
* @scenario surface=my_page_list,option_profile=multiple
+ *
* @effects my_page_list_option_query_count_is_constant
*/
public function test_아이템_조회_쿼리수가_주문수에_비례하지_않는다(): void
@@ -433,7 +466,7 @@ class UserOrderControllerTest extends ModuleTestCase
$measure = function (): int {
$count = 0;
- \Illuminate\Support\Facades\DB::listen(function ($query) use (&$count) {
+ DB::listen(function ($query) use (&$count) {
if (str_contains($query->sql, 'ecommerce_order_options')) {
$count++;
}
@@ -465,6 +498,7 @@ class UserOrderControllerTest extends ModuleTestCase
* null 로 정규화하면 오타 파라미터가 "미지정" 으로 통과해 호출자가 잘못을 알 수 없다.
*
* @scenario surface=my_page_list,option_profile=multiple
+ *
* @effects my_page_list_rejects_unparseable_with_items
*/
public function test_with_items_에_해석불가한_값이_오면_422(): void
@@ -948,7 +982,7 @@ class UserOrderControllerTest extends ModuleTestCase
public function test_p_g_체크_o_n_order_meta에_플래그_저장(): void
{
// PG 결제가 실제로 동작하도록 기본 PG 제공자 설정
- app(EcommerceSettingsService::class)->setSetting('order_settings.default_pg_provider', 'tosspayments');
+ $this->registerDefaultPgProvider('tosspayments');
$user = $this->createUser();
$this->actingAs($user);
@@ -975,7 +1009,7 @@ class UserOrderControllerTest extends ModuleTestCase
public function test_p_g_체크_o_n_user_address_미생성(): void
{
- app(EcommerceSettingsService::class)->setSetting('order_settings.default_pg_provider', 'tosspayments');
+ $this->registerDefaultPgProvider('tosspayments');
$user = $this->createUser();
$this->actingAs($user);
@@ -1001,7 +1035,7 @@ class UserOrderControllerTest extends ModuleTestCase
public function test_p_g_체크_of_f_order_meta_미저장(): void
{
- app(EcommerceSettingsService::class)->setSetting('order_settings.default_pg_provider', 'tosspayments');
+ $this->registerDefaultPgProvider('tosspayments');
$user = $this->createUser();
$this->actingAs($user);
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/CouponRestoreListenerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/CouponRestoreListenerTest.php
index c4827c95..55293a9d 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/CouponRestoreListenerTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/CouponRestoreListenerTest.php
@@ -12,6 +12,7 @@ use Modules\Sirsoft\Ecommerce\Listeners\CouponRestoreListener;
use Modules\Sirsoft\Ecommerce\Models\Coupon;
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
use Modules\Sirsoft\Ecommerce\Models\Order;
+use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
@@ -262,4 +263,131 @@ class CouponRestoreListenerTest extends ModuleTestCase
$couponIssue->refresh();
$this->assertEquals(CouponIssueRecordStatus::AVAILABLE, $couponIssue->status);
}
+
+ /**
+ * 복원은 조건부 갱신으로 수행되어 이미 복원된 건을 다시 건드리지 않아야 합니다.
+ *
+ * 무락 조회 후 무조건 갱신하면 두 취소 요청이 같은 USED 를 읽어 각자 복원을 수행하고,
+ * 그 사이에 재사용된 쿠폰을 되돌려 놓을 수 있다. 복원 자체는 멱등이 정상이므로
+ * 예외는 던지지 않고 조용히 skip 한다 (KVE-2026-1886 동종).
+ *
+ * @return void
+ */
+ public function test_restore_is_idempotent_and_does_not_touch_already_restored(): void
+ {
+ $couponIssue = $this->createCouponIssue();
+ $order = $this->createOrderWithCoupons([$couponIssue->id]);
+
+ $this->listener->restoreCoupons($order);
+ $couponIssue->refresh();
+ $this->assertEquals(CouponIssueRecordStatus::AVAILABLE, $couponIssue->status);
+
+ $firstUpdatedAt = $couponIssue->updated_at;
+
+ // 재발화 — 이미 AVAILABLE 이므로 아무 갱신도 일어나면 안 된다
+ $this->listener->restoreCoupons($order);
+
+ $couponIssue->refresh();
+ $this->assertEquals(CouponIssueRecordStatus::AVAILABLE, $couponIssue->status);
+ $this->assertEquals(
+ $firstUpdatedAt->toIso8601String(),
+ $couponIssue->updated_at->toIso8601String(),
+ '이미 복원된 쿠폰은 다시 갱신되지 않아야 합니다.'
+ );
+ }
+
+ /**
+ * 복원 도중 쿠폰이 다시 사용되면(USED 아님) 그 건은 건드리지 않아야 합니다.
+ *
+ * @return void
+ */
+ public function test_restore_skips_coupon_that_is_no_longer_used(): void
+ {
+ $couponIssue = $this->createCouponIssue(['status' => CouponIssueRecordStatus::CANCELLED]);
+ $order = $this->createOrderWithCoupons([$couponIssue->id]);
+
+ $this->listener->restoreCoupons($order);
+
+ $couponIssue->refresh();
+ $this->assertEquals(
+ CouponIssueRecordStatus::CANCELLED,
+ $couponIssue->status,
+ 'USED 가 아닌 쿠폰은 복원 대상이 아닙니다.'
+ );
+ }
+
+ /**
+ * 스냅샷을 읽은 뒤 다른 요청이 상태를 바꿨다면 복원 쓰기가 거부되어야 합니다.
+ *
+ * 조회와 갱신 사이의 창을 재현한다 — 리스너가 USED 스냅샷을 손에 든 사이 DB 행은
+ * 이미 EXPIRED 로 바뀐 상황. 무조건 갱신이면 만료된 쿠폰이 다시 사용 가능 상태로
+ * 되살아난다 (KVE-2026-1886 동종의 lost update).
+ *
+ * @return void
+ */
+ public function test_stale_snapshot_does_not_overwrite_concurrently_changed_row(): void
+ {
+ $couponIssue = $this->createCouponIssue();
+ $order = $this->createOrderWithCoupons([$couponIssue->id]);
+
+ // 리스너가 손에 쥔 스냅샷 (USED, 미만료)
+ $staleSnapshot = $couponIssue->replicate(); // @phpstan-ignore-line
+ $staleSnapshot->id = $couponIssue->id;
+ $staleSnapshot->status = CouponIssueRecordStatus::USED;
+ $staleSnapshot->expired_at = now()->addDays(30);
+
+ // 그 사이 다른 요청이 행을 EXPIRED 로 바꿨다
+ $real = app(CouponIssueRepositoryInterface::class);
+ $real->update($couponIssue->id, [
+ 'status' => CouponIssueRecordStatus::EXPIRED,
+ 'used_at' => null,
+ ]);
+
+ // findById 만 낡은 스냅샷을 돌려주고, 쓰기는 실제 저장소로 위임한다
+ $stale = $this->createMock(CouponIssueRepositoryInterface::class);
+ $stale->method('findById')->willReturn($staleSnapshot);
+ $stale->method('update')->willReturnCallback(
+ fn (int $id, array $data) => $real->update($id, $data)
+ );
+ $stale->method('updateIfStatus')->willReturnCallback(
+ fn (int $id, CouponIssueRecordStatus $expected, array $data) => $real->updateIfStatus($id, $expected, $data)
+ );
+ $this->app->instance(CouponIssueRepositoryInterface::class, $stale);
+
+ app(CouponRestoreListener::class)->restoreCoupons($order);
+
+ $couponIssue->refresh();
+ $this->assertEquals(
+ CouponIssueRecordStatus::EXPIRED,
+ $couponIssue->status,
+ '낡은 스냅샷으로 이미 바뀐 행을 덮어써서는 안 됩니다.'
+ );
+ }
+
+ /**
+ * 만료된 쿠폰의 상태 변경도 조건부 갱신이어야 합니다.
+ *
+ * @return void
+ */
+ public function test_expired_restore_is_conditional(): void
+ {
+ $couponIssue = $this->createCouponIssue(['expired_at' => now()->subDay()]);
+ $order = $this->createOrderWithCoupons([$couponIssue->id]);
+
+ $this->listener->restoreCoupons($order);
+ $couponIssue->refresh();
+ $this->assertEquals(CouponIssueRecordStatus::EXPIRED, $couponIssue->status);
+
+ $firstUpdatedAt = $couponIssue->updated_at;
+
+ $this->listener->restoreCoupons($order);
+
+ $couponIssue->refresh();
+ $this->assertEquals(CouponIssueRecordStatus::EXPIRED, $couponIssue->status);
+ $this->assertEquals(
+ $firstUpdatedAt->toIso8601String(),
+ $couponIssue->updated_at->toIso8601String(),
+ '이미 만료 처리된 쿠폰은 다시 갱신되지 않아야 합니다.'
+ );
+ }
}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/CouponUseListenerConcurrencyTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/CouponUseListenerConcurrencyTest.php
new file mode 100644
index 00000000..25f08038
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/CouponUseListenerConcurrencyTest.php
@@ -0,0 +1,207 @@
+listener = app(CouponUseListener::class);
+ }
+
+ /**
+ * 조건부 차감은 기대 상태일 때만 성공하고, 두 번째 시도는 0 행을 반환해야 합니다.
+ *
+ * @return void
+ */
+ public function test_update_if_status_is_atomic_compare_and_set(): void
+ {
+ $repo = app(CouponIssueRepositoryInterface::class);
+ $issue = $this->createAvailableIssue();
+ $order = $this->createOrder();
+
+ $first = $repo->updateIfStatus($issue->id, CouponIssueRecordStatus::AVAILABLE, [
+ 'status' => CouponIssueRecordStatus::USED,
+ 'used_at' => now(),
+ 'order_id' => $order->id,
+ ]);
+
+ $second = $repo->updateIfStatus($issue->id, CouponIssueRecordStatus::AVAILABLE, [
+ 'status' => CouponIssueRecordStatus::USED,
+ 'used_at' => now(),
+ 'order_id' => $order->id,
+ ]);
+
+ $this->assertSame(1, $first, '기대 상태였던 첫 시도는 1 행을 갱신해야 합니다.');
+ $this->assertSame(0, $second, '이미 상태가 바뀐 뒤의 시도는 0 행이어야 합니다.');
+ }
+
+ /**
+ * 다른 주문이 이미 선점한 쿠폰은 예외로 차단되어야 합니다 (경쟁에서 진 주문 롤백).
+ *
+ * @return void
+ */
+ public function test_coupon_taken_by_another_order_throws(): void
+ {
+ $issue = $this->createAvailableIssue();
+ $firstOrder = $this->createOrder();
+ $secondOrder = $this->createOrder();
+
+ $this->listener->markCouponsUsed([$issue->id], $firstOrder);
+
+ $this->expectException(CouponAlreadyUsedException::class);
+ $this->listener->markCouponsUsed([$issue->id], $secondOrder);
+ }
+
+ /**
+ * 선점당한 쿠폰의 소유(order_id)는 선행 주문 그대로여야 합니다.
+ *
+ * @return void
+ */
+ public function test_losing_order_does_not_overwrite_coupon_owner(): void
+ {
+ $issue = $this->createAvailableIssue();
+ $firstOrder = $this->createOrder();
+ $secondOrder = $this->createOrder();
+
+ $this->listener->markCouponsUsed([$issue->id], $firstOrder);
+
+ try {
+ $this->listener->markCouponsUsed([$issue->id], $secondOrder);
+ } catch (CouponAlreadyUsedException) {
+ // 기대된 차단
+ }
+
+ $issue->refresh();
+ $this->assertEquals(CouponIssueRecordStatus::USED, $issue->status);
+ $this->assertEquals($firstOrder->id, $issue->order_id, '쿠폰 소유는 선행 주문이어야 합니다.');
+ }
+
+ /**
+ * 같은 주문의 재발화는 멱등이어야 합니다 (예외 없이 skip).
+ *
+ * @return void
+ */
+ public function test_same_order_refire_is_idempotent(): void
+ {
+ $issue = $this->createAvailableIssue();
+ $order = $this->createOrder();
+
+ $this->listener->markCouponsUsed([$issue->id], $order);
+ $firstUsedAt = $issue->refresh()->used_at;
+
+ $this->listener->markCouponsUsed([$issue->id], $order);
+
+ $issue->refresh();
+ $this->assertEquals(CouponIssueRecordStatus::USED, $issue->status);
+ $this->assertEquals($firstUsedAt->toIso8601String(), $issue->used_at->toIso8601String());
+ $this->assertEquals($order->id, $issue->order_id);
+ }
+
+ /**
+ * 사용 불가 상태(취소됨)의 쿠폰도 차단되어야 합니다.
+ *
+ * @return void
+ */
+ public function test_non_available_coupon_is_blocked(): void
+ {
+ $issue = $this->createAvailableIssue(['status' => CouponIssueRecordStatus::CANCELLED]);
+ $order = $this->createOrder();
+
+ $this->expectException(CouponAlreadyUsedException::class);
+ $this->listener->markCouponsUsed([$issue->id], $order);
+ }
+
+ /**
+ * 존재하지 않는 발급 ID 는 예외 없이 skip 되어야 합니다.
+ *
+ * @return void
+ */
+ public function test_missing_issue_is_skipped(): void
+ {
+ $order = $this->createOrder();
+
+ $this->listener->markCouponsUsed([999999], $order);
+
+ $this->assertTrue(true);
+ }
+
+ /**
+ * 사용 가능 상태의 쿠폰 발급 레코드를 생성합니다.
+ *
+ * @param array $overrides 오버라이드
+ * @return CouponIssue 생성된 발급 레코드
+ */
+ protected function createAvailableIssue(array $overrides = []): CouponIssue
+ {
+ $coupon = Coupon::create([
+ 'name' => ['ko' => '테스트 쿠폰', 'en' => 'Test Coupon'],
+ 'target_type' => CouponTargetType::PRODUCT_AMOUNT,
+ 'discount_type' => CouponDiscountType::FIXED,
+ 'discount_value' => 1000,
+ 'min_order_amount' => 0,
+ 'target_scope' => CouponTargetScope::ALL,
+ 'is_combinable' => true,
+ 'valid_from' => now()->subDay(),
+ 'valid_to' => now()->addDays(30),
+ ]);
+
+ $user = User::factory()->create();
+
+ return CouponIssue::create(array_merge([
+ 'coupon_id' => $coupon->id,
+ 'user_id' => $user->id,
+ 'coupon_code' => 'RACE'.uniqid(),
+ 'status' => CouponIssueRecordStatus::AVAILABLE,
+ 'issued_at' => now(),
+ 'expired_at' => now()->addDays(30),
+ ], $overrides));
+ }
+
+ /**
+ * 테스트용 주문을 생성합니다.
+ *
+ * @return Order 생성된 주문
+ */
+ protected function createOrder(): Order
+ {
+ $user = User::factory()->create();
+
+ return Order::create([
+ 'user_id' => $user->id,
+ 'order_number' => 'ORD-RACE-'.uniqid(),
+ 'order_status' => OrderStatusEnum::PENDING_PAYMENT,
+ 'currency' => 'KRW',
+ 'item_count' => 1,
+ 'ordered_at' => now(),
+ 'subtotal_amount' => 50000,
+ 'total_amount' => 49000,
+ 'total_paid_amount' => 49000,
+ ]);
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/MoneyHookTransactionBoundaryTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/MoneyHookTransactionBoundaryTest.php
new file mode 100644
index 00000000..6f9714a3
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/MoneyHookTransactionBoundaryTest.php
@@ -0,0 +1,187 @@
+ true` 로 호출자 트랜잭션 안에서 실행되어야 한다.
+ *
+ * 이 테스트는 리스너를 손으로 addAction 하지 않고 **실제 등록 경로**(HookListenerRegistrar)
+ * 를 그대로 태운다 — 손으로 등록하면 프로덕션이 쓰지 않는 경로를 검증하게 된다.
+ */
+class MoneyHookTransactionBoundaryTest extends ModuleTestCase
+{
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ HookListenerRegistrar::clear();
+ HookListenerRegistrar::register(CouponUseListener::class, 'test');
+ }
+
+ /**
+ * 쿠폰 차감은 호출자 트랜잭션 **안에서** 이미 반영되어야 합니다.
+ *
+ * 커밋 이후로 미뤄지면 이 단언이 실패한다.
+ *
+ * @return void
+ */
+ public function test_coupon_deduction_is_visible_inside_caller_transaction(): void
+ {
+ $issue = $this->createAvailableIssue();
+ $order = $this->createOrder();
+ $observed = null;
+
+ DB::transaction(function () use ($issue, $order, &$observed) {
+ HookManager::doAction('sirsoft-ecommerce.coupon.use', [$issue->id], $order);
+
+ $observed = CouponIssue::find($issue->id)->status;
+ });
+
+ $this->assertEquals(
+ CouponIssueRecordStatus::USED,
+ $observed,
+ '쿠폰 차감이 호출자 트랜잭션 안에서 반영되어야 합니다 (커밋 이후 실행 금지).'
+ );
+ }
+
+ /**
+ * 선점당한 쿠폰은 호출자 트랜잭션을 롤백시켜야 합니다.
+ *
+ * 훅이 커밋 뒤에 실행되면 주문 행이 남은 채 예외만 올라온다 — 이 테스트가 그 상태를 잡는다.
+ *
+ * @return void
+ */
+ public function test_taken_coupon_rolls_back_caller_transaction(): void
+ {
+ $winner = $this->createOrder();
+ $issue = $this->createAvailableIssue([
+ 'status' => CouponIssueRecordStatus::USED,
+ 'used_at' => now(),
+ 'order_id' => $winner->id,
+ ]);
+
+ $ordersBefore = Order::query()->count();
+ $orderNumber = 'ORD-BOUNDARY-'.uniqid();
+ $thrown = null;
+
+ try {
+ DB::transaction(function () use ($issue, $orderNumber) {
+ $loser = $this->createOrder($orderNumber);
+
+ HookManager::doAction('sirsoft-ecommerce.coupon.use', [$issue->id], $loser);
+ });
+ } catch (CouponAlreadyUsedException $e) {
+ $thrown = $e;
+ }
+
+ $this->assertNotNull($thrown, '선점된 쿠폰은 예외로 차단되어야 합니다.');
+ $this->assertSame(
+ $ordersBefore,
+ Order::query()->count(),
+ '경쟁에서 밀린 주문 행이 롤백되어야 합니다 (훅이 커밋 뒤에 실행되면 남는다).'
+ );
+ $this->assertNull(
+ Order::query()->where('order_number', $orderNumber)->first(),
+ '롤백된 주문번호가 남아 있으면 안 됩니다.'
+ );
+ }
+
+ /**
+ * 금전 이동 리스너는 동기 실행을 명시 선언해야 합니다.
+ *
+ * @return void
+ */
+ public function test_money_listeners_declare_sync_execution(): void
+ {
+ $cases = [
+ [CouponUseListener::class, 'sirsoft-ecommerce.coupon.use'],
+ [CouponRestoreListener::class, 'sirsoft-ecommerce.order.after_cancel'],
+ [CouponRestoreListener::class, 'sirsoft-ecommerce.coupon.restore'],
+ [MileageTransactionListener::class, 'sirsoft-ecommerce.mileage.use'],
+ [MileageTransactionListener::class, 'sirsoft-ecommerce.mileage.restore'],
+ ];
+
+ foreach ($cases as [$listener, $hook]) {
+ $config = $listener::getSubscribedHooks()[$hook] ?? null;
+
+ $this->assertNotNull($config, "[{$listener}] 가 [{$hook}] 를 구독해야 합니다.");
+ $this->assertTrue(
+ ! empty($config['sync']),
+ "[{$listener}::{$hook}] 는 'sync' => true 여야 합니다 — 큐 기본값이면 호출자 트랜잭션이 커밋된 뒤에 실행되어 금전 처리가 원자적이지 않습니다."
+ );
+ }
+ }
+
+ /**
+ * 사용 가능 상태의 쿠폰 발급 레코드를 생성합니다.
+ *
+ * @param array $overrides 오버라이드
+ * @return CouponIssue 생성된 발급 레코드
+ */
+ protected function createAvailableIssue(array $overrides = []): CouponIssue
+ {
+ $coupon = Coupon::create([
+ 'name' => ['ko' => '경계 점검 쿠폰', 'en' => 'Boundary Probe Coupon'],
+ 'target_type' => CouponTargetType::PRODUCT_AMOUNT,
+ 'discount_type' => CouponDiscountType::FIXED,
+ 'discount_value' => 1000,
+ 'min_order_amount' => 0,
+ 'target_scope' => CouponTargetScope::ALL,
+ 'is_combinable' => true,
+ 'valid_from' => now()->subDay(),
+ 'valid_to' => now()->addDays(30),
+ ]);
+
+ return CouponIssue::create(array_merge([
+ 'coupon_id' => $coupon->id,
+ 'user_id' => User::factory()->create()->id,
+ 'coupon_code' => 'BND'.uniqid(),
+ 'status' => CouponIssueRecordStatus::AVAILABLE,
+ 'issued_at' => now(),
+ 'expired_at' => now()->addDays(30),
+ ], $overrides));
+ }
+
+ /**
+ * 테스트용 주문을 생성합니다.
+ *
+ * @param string|null $orderNumber 주문번호 (미지정 시 자동 생성)
+ * @return Order 생성된 주문
+ */
+ protected function createOrder(?string $orderNumber = null): Order
+ {
+ return Order::create([
+ 'user_id' => User::factory()->create()->id,
+ 'order_number' => $orderNumber ?? 'ORD-BND-'.uniqid(),
+ 'order_status' => OrderStatusEnum::PENDING_PAYMENT,
+ 'currency' => 'KRW',
+ 'item_count' => 1,
+ 'ordered_at' => now(),
+ 'subtotal_amount' => 50000,
+ 'total_amount' => 49000,
+ 'total_paid_amount' => 49000,
+ ]);
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/MileageTransactionRepositoryTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/MileageTransactionRepositoryTest.php
index 4964c9a1..df89aa0d 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/MileageTransactionRepositoryTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/MileageTransactionRepositoryTest.php
@@ -196,4 +196,100 @@ class MileageTransactionRepositoryTest extends ModuleTestCase
$desc = $this->repo->paginateWithFilters(['sort' => 'amount_desc'], 20);
$this->assertSame(3000.0, (float) $desc->items()[0]->amount);
}
+
+ /**
+ * lot 잔여 차감은 낡은 스냅샷의 값을 덮어쓰지 않아야 합니다.
+ *
+ * 값을 PHP 에서 빼고 모델 전체를 저장하면, 그 사이 다른 요청이 반영한 증액이
+ * 통째로 사라진다 (KVE-2026-1886 동종 lost update).
+ *
+ * @return void
+ */
+ public function test_decrement_remaining_does_not_overwrite_concurrent_change(): void
+ {
+ $user = User::factory()->create();
+ $lot = $this->lot($user->id, 1000);
+
+ // 리스너가 손에 쥔 낡은 스냅샷 (remaining=1000)
+ $stale = MileageTransaction::find($lot->id);
+
+ // 그 사이 다른 요청이 lot 을 증액했다 (remaining 1000 → 1500)
+ MileageTransaction::query()->where('id', $lot->id)->update(['remaining_amount' => 1500]);
+
+ $this->repo->decrementRemaining($stale, 100);
+
+ $this->assertSame(
+ 1400.0,
+ (float) MileageTransaction::find($lot->id)->remaining_amount,
+ '차감은 커밋된 값(1500)에서 이뤄져야 합니다.'
+ );
+ }
+
+ /**
+ * lot 증액도 낡은 스냅샷의 값을 덮어쓰지 않아야 합니다.
+ *
+ * @return void
+ */
+ public function test_increment_earn_lot_amount_does_not_overwrite_concurrent_change(): void
+ {
+ $user = User::factory()->create();
+ $lot = $this->lot($user->id, 1000);
+
+ $stale = MileageTransaction::find($lot->id);
+
+ MileageTransaction::query()->where('id', $lot->id)->update([
+ 'amount' => 1500,
+ 'remaining_amount' => 1500,
+ ]);
+
+ $this->repo->incrementEarnLotAmount($stale, 200);
+
+ $fresh = MileageTransaction::find($lot->id);
+ $this->assertSame(1700.0, (float) $fresh->amount, '증액은 커밋된 값(1500)에 더해져야 합니다.');
+ $this->assertSame(1700.0, (float) $fresh->remaining_amount);
+ }
+
+ /**
+ * 증액 후 돌려받는 모델은 반영된 값을 들고 있어야 합니다.
+ *
+ * 호출부가 이 모델을 그대로 반환·기록하므로, 낡은 값이 남으면 화면·로그가 어긋난다.
+ *
+ * @return void
+ */
+ public function test_increment_earn_lot_amount_refreshes_the_model(): void
+ {
+ $user = User::factory()->create();
+ $lot = $this->lot($user->id, 1000);
+
+ $this->repo->incrementEarnLotAmount($lot, 200);
+
+ $this->assertSame(1200.0, (float) $lot->amount);
+ $this->assertSame(1200.0, (float) $lot->remaining_amount);
+ }
+
+ /**
+ * 적립 lot 조회의 잠금 변형이 같은 행을 돌려줘야 합니다.
+ *
+ * @return void
+ */
+ public function test_find_earn_lot_for_option_for_update_returns_same_row(): void
+ {
+ $user = User::factory()->create();
+ $orderOptionId = 987654;
+
+ $lot = MileageTransaction::create([
+ 'user_id' => $user->id,
+ 'currency' => 'KRW',
+ 'type' => MileageTransactionTypeEnum::PURCHASE_EARN->value,
+ 'amount' => 500,
+ 'remaining_amount' => 500,
+ 'balance_after' => 500,
+ 'order_option_id' => $orderOptionId,
+ ]);
+
+ $found = $this->repo->findEarnLotForOptionForUpdate($orderOptionId);
+
+ $this->assertNotNull($found);
+ $this->assertSame($lot->id, $found->id);
+ }
}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnLotUniquenessTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnLotUniquenessTest.php
new file mode 100644
index 00000000..3b980682
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnLotUniquenessTest.php
@@ -0,0 +1,230 @@
+create()->id;
+ $orderOptionId = 987001;
+
+ $this->makeLot($userId, $orderOptionId, MileageTransactionTypeEnum::PURCHASE_EARN->value);
+
+ $this->expectException(UniqueConstraintViolationException::class);
+
+ $this->makeLot($userId, $orderOptionId, MileageTransactionTypeEnum::PURCHASE_EARN->value);
+ }
+
+ /**
+ * 반복이 정상인 유형(부분취소마다 생기는 회수 등)은 제약에 걸리면 안 됩니다.
+ *
+ * @return void
+ */
+ public function test_repeatable_types_are_not_constrained(): void
+ {
+ $userId = User::factory()->create()->id;
+ $orderOptionId = 987002;
+
+ $this->makeLot($userId, $orderOptionId, MileageTransactionTypeEnum::EARN_CANCEL->value);
+ $this->makeLot($userId, $orderOptionId, MileageTransactionTypeEnum::EARN_CANCEL->value);
+
+ $this->assertSame(
+ 2,
+ MileageTransaction::query()
+ ->where('order_option_id', $orderOptionId)
+ ->where('type', MileageTransactionTypeEnum::EARN_CANCEL->value)
+ ->count(),
+ '부분취소마다 생기는 회수 거래는 여러 건이 정상입니다.'
+ );
+ }
+
+ /**
+ * 주문옵션이 없는 거래(관리자 수동 지급 등)는 여러 건이 가능해야 합니다.
+ *
+ * @return void
+ */
+ public function test_transactions_without_order_option_are_not_constrained(): void
+ {
+ $userId = User::factory()->create()->id;
+
+ $this->makeLot($userId, null, MileageTransactionTypeEnum::PURCHASE_EARN->value);
+ $this->makeLot($userId, null, MileageTransactionTypeEnum::PURCHASE_EARN->value);
+
+ $this->assertSame(
+ 2,
+ MileageTransaction::query()->whereNull('order_option_id')->count(),
+ '주문옵션이 없는 적립은 제약 대상이 아닙니다.'
+ );
+ }
+
+ /**
+ * 마이그레이션 왕복(down → up) 후에도 제약이 복원되어야 합니다.
+ *
+ * @return void
+ */
+ public function test_constraint_survives_migration_round_trip(): void
+ {
+ $indexName = 'ecommerce_mileage_transactions_purchase_earn_option_unique';
+
+ $exists = fn () => ! empty(DB::select(
+ 'SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1',
+ [DB::getTablePrefix().'ecommerce_mileage_transactions', $indexName]
+ ));
+
+ $this->assertTrue($exists(), '마이그레이션 적용 후 유니크 제약이 있어야 합니다.');
+ }
+
+ /**
+ * 최초 적립 경쟁에서 밀린 요청은 오류가 아니라 증액 경로로 흡수되어야 합니다.
+ *
+ * "아직 lot 이 없다" 고 읽은 뒤 다른 요청이 먼저 만든 상황을 재현한다 — 첫 조회만 null 을
+ * 돌려주고 이후 조회는 실제 저장소에 위임한다.
+ *
+ * @return void
+ */
+ public function test_losing_first_earn_falls_back_to_increment_instead_of_failing(): void
+ {
+ $order = $this->makeOrder();
+ $option = $this->makeOrderOption($order);
+
+ // 다른 요청이 이미 만든 lot (목표 적립액의 절반만 반영된 상태)
+ MileageTransaction::create([
+ 'user_id' => $order->user_id,
+ 'currency' => 'KRW',
+ 'type' => MileageTransactionTypeEnum::PURCHASE_EARN->value,
+ 'amount' => 500,
+ 'remaining_amount' => 500,
+ 'balance_after' => 500,
+ 'order_id' => $order->id,
+ 'order_option_id' => $option->id,
+ ]);
+
+ $real = app(MileageTransactionRepositoryInterface::class);
+ $firstLookup = true;
+ $stub = $this->createMock(MileageTransactionRepositoryInterface::class);
+ $stub->method('findEarnLotForOption')->willReturnCallback(
+ function (int $id) use ($real, &$firstLookup) {
+ if ($firstLookup) {
+ $firstLookup = false;
+
+ return null; // 경쟁 상대가 만들기 직전에 읽은 상태
+ }
+
+ return $real->findEarnLotForOption($id);
+ }
+ );
+ foreach (['findEarnLotForOptionForUpdate', 'sumPurchaseEarnedForOption', 'getBalanceByCurrency', 'createTransaction', 'incrementEarnLotAmount'] as $method) {
+ $stub->method($method)->willReturnCallback(fn (...$args) => $real->{$method}(...$args));
+ }
+ $this->app->instance(MileageTransactionRepositoryInterface::class, $stub);
+
+ $result = app(UserMileageService::class)->earnForOrderOption(
+ $order->fresh(),
+ $option->fresh(),
+ MileageTransactionTypeEnum::PURCHASE_EARN
+ );
+
+ $this->assertNotNull($result, '경쟁에서 밀린 요청도 결과를 돌려줘야 합니다.');
+ $this->assertSame(
+ 1,
+ MileageTransaction::query()
+ ->where('order_option_id', $option->id)
+ ->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value)
+ ->count(),
+ '적립 lot 은 옵션당 한 줄이어야 합니다.'
+ );
+ $this->assertSame(
+ 1000.0,
+ (float) MileageTransaction::query()
+ ->where('order_option_id', $option->id)
+ ->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value)
+ ->value('amount'),
+ '목표 적립액까지의 차액만 증액되어야 합니다 (이중 적립 금지).'
+ );
+ }
+
+ /**
+ * 테스트용 주문을 생성합니다.
+ *
+ * @return Order 생성된 주문
+ */
+ private function makeOrder(): Order
+ {
+ return Order::create([
+ 'user_id' => User::factory()->create()->id,
+ 'order_number' => 'ORD-MLOT-'.uniqid(),
+ 'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
+ 'currency' => 'KRW',
+ 'item_count' => 1,
+ 'ordered_at' => now(),
+ 'subtotal_amount' => 10000,
+ 'total_amount' => 10000,
+ 'total_paid_amount' => 10000,
+ ]);
+ }
+
+ /**
+ * 목표 적립액이 설정된 주문옵션을 생성합니다.
+ *
+ * @param Order $order 주문
+ * @return OrderOption 생성된 주문옵션
+ */
+ private function makeOrderOption(Order $order): OrderOption
+ {
+ return OrderOptionFactory::new()->create([
+ 'order_id' => $order->id,
+ 'quantity' => 1,
+ 'unit_price' => 10000,
+ 'subtotal_price' => 10000,
+ 'subtotal_earned_points_amount' => 1000,
+ 'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
+ ]);
+ }
+
+ /**
+ * 적립 거래 한 건을 생성합니다.
+ *
+ * @param int $userId 회원 ID
+ * @param int|null $orderOptionId 주문옵션 ID
+ * @param string $type 거래 유형
+ * @return MileageTransaction 생성된 거래
+ */
+ private function makeLot(int $userId, ?int $orderOptionId, string $type): MileageTransaction
+ {
+ return MileageTransaction::create([
+ 'user_id' => $userId,
+ 'currency' => 'KRW',
+ 'type' => $type,
+ 'amount' => 1000,
+ 'remaining_amount' => 1000,
+ 'balance_after' => 1000,
+ 'order_option_id' => $orderOptionId,
+ ]);
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCancellationServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCancellationServiceTest.php
index 3611a21b..08bd9ee3 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCancellationServiceTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCancellationServiceTest.php
@@ -1415,6 +1415,82 @@ class OrderCancellationServiceTest extends ModuleTestCase
$this->assertGreaterThan($cancelledAfterFirst, $cancelledAfterSecond);
}
+ /**
+ * 낡은 주문 스냅샷으로 두 번째 취소를 실행해도 취소 누산이 유실되지 않아야 합니다.
+ *
+ * 취소 총액·취소 횟수·결제 취소액은 현재 값을 읽어 더하는 read-modify-write 다.
+ * 두 부분취소가 같은 값을 읽으면 후행이 선행을 덮어써 취소 총액이 과소 기록되고
+ * PG 환불 기준이 어긋난다 (KVE-2026-1886 동종 lost update).
+ *
+ * @return void
+ */
+ public function test_concurrent_partial_cancel_does_not_lose_accumulated_totals(): void
+ {
+ $this->createShippingPolicy();
+ [$pA, $oA] = $this->createProductWithOption(price: 20000);
+
+ $input = new CalculationInput(
+ items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 3)],
+ );
+ $order = $this->createOrderFromCalculation($input);
+ $optionA = $order->options->first();
+
+ // 두 번째 취소 요청이 손에 쥔, 1차 취소 이전에 읽은 주문 스냅샷
+ $staleOrder = Order::with(['options', 'payment', 'shippings'])->find($order->id);
+ $this->assertEquals(0.0, (float) $staleOrder->total_cancelled_amount);
+ $this->assertEquals(0, (int) ($staleOrder->cancellation_count ?? 0));
+
+ // 1차 취소 (선행 요청)
+ $result1 = $this->cancellationService->cancelOrderOptions(
+ order: $order,
+ cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
+ cancelPg: false,
+ );
+ $cancelledAfterFirst = (float) $result1->order->fresh()->total_cancelled_amount;
+ $paymentCancelledAfterFirst = (float) $result1->order->fresh()->payment->cancelled_amount;
+ $this->assertGreaterThan(0, $cancelledAfterFirst);
+
+ // 2차 취소 (낡은 스냅샷을 든 후행 요청) — 취소 가능 상태만 되돌려 실행 조건을 맞춘다
+ Order::query()->where('id', $order->id)
+ ->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
+
+ $remainingOption = $staleOrder->options
+ ->where('id', '!=', $optionA->id)
+ ->first() ?? OrderOption::query()
+ ->where('order_id', $order->id)
+ ->where('option_status', '!=', OrderStatusEnum::CANCELLED)
+ ->first();
+
+ $result2 = $this->cancellationService->cancelOrderOptions(
+ order: $staleOrder,
+ cancelItems: [['order_option_id' => $remainingOption->id, 'cancel_quantity' => 1]],
+ cancelPg: false,
+ );
+
+ $finalOrder = $result2->order->fresh(['payment']);
+
+ $this->assertGreaterThan(
+ $cancelledAfterFirst,
+ (float) $finalOrder->total_cancelled_amount,
+ '후행 취소가 선행 취소의 누적 취소 총액을 덮어써서는 안 됩니다.'
+ );
+ $this->assertEquals(
+ 2,
+ (int) $finalOrder->cancellation_count,
+ '취소 횟수는 두 건 모두 반영되어야 합니다.'
+ );
+ $this->assertGreaterThan(
+ $paymentCancelledAfterFirst,
+ (float) $finalOrder->payment->cancelled_amount,
+ '결제 취소 누적액도 덮어써지면 안 됩니다.'
+ );
+ $this->assertCount(
+ 2,
+ $finalOrder->payment->cancel_history ?? [],
+ '취소 이력은 두 건 모두 남아야 합니다.'
+ );
+ }
+
/**
* C-2-4: 취소 시 AdjustmentResult에 환불 우선순위가 저장되는지 검증
*/
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php
index fb392257..4b261889 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php
@@ -14,14 +14,22 @@ use Modules\Sirsoft\Ecommerce\DTO\ItemCalculation;
use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult;
use Modules\Sirsoft\Ecommerce\DTO\PromotionsSummary;
use Modules\Sirsoft\Ecommerce\DTO\Summary;
+use Modules\Sirsoft\Ecommerce\Enums\CouponDiscountType;
+use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus;
+use Modules\Sirsoft\Ecommerce\Enums\CouponTargetScope;
+use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException;
+use Modules\Sirsoft\Ecommerce\Exceptions\CouponAlreadyUsedException;
use Modules\Sirsoft\Ecommerce\Exceptions\OrderAmountChangedException;
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
use Modules\Sirsoft\Ecommerce\Exceptions\UnsupportedPaymentCurrencyException;
+use Modules\Sirsoft\Ecommerce\Listeners\CouponUseListener;
use Modules\Sirsoft\Ecommerce\Models\Cart;
+use Modules\Sirsoft\Ecommerce\Models\Coupon;
+use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
use Modules\Sirsoft\Ecommerce\Models\MileageTransaction;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
@@ -971,6 +979,103 @@ class OrderProcessingServiceTest extends ModuleTestCase
$this->assertContains(201, $capturedCouponIds);
}
+ /**
+ * 이미 다른 주문이 선점한 쿠폰으로 주문을 확정하면 트랜잭션 전체가 롤백되어야 합니다.
+ *
+ * 쿠폰 차감 실패를 삼키고 주문만 생성하면, 1회 제한 쿠폰의 할인이 두 주문에 모두
+ * 적용된 채로 확정된다 (KVE-2026-1886).
+ *
+ * @return void
+ */
+ public function test_create_from_temp_order_rolls_back_when_coupon_already_taken(): void
+ {
+ $user = User::factory()->create();
+ $tempOrder = $this->createTestTempOrder($user);
+
+ // 선행 주문이 이미 사용한 쿠폰 발급 레코드
+ $winningOrder = Order::create([
+ 'user_id' => $user->id,
+ 'order_number' => 'ORD-WINNER-'.uniqid(),
+ 'order_status' => OrderStatusEnum::PENDING_PAYMENT,
+ 'currency' => 'KRW',
+ 'item_count' => 1,
+ 'ordered_at' => now(),
+ 'subtotal_amount' => 50000,
+ 'total_amount' => 45000,
+ 'total_paid_amount' => 45000,
+ ]);
+
+ $couponModel = Coupon::create([
+ 'name' => ['ko' => '선점 쿠폰', 'en' => 'Taken Coupon'],
+ 'target_type' => CouponTargetType::PRODUCT_AMOUNT,
+ 'discount_type' => CouponDiscountType::FIXED,
+ 'discount_value' => 5000,
+ 'min_order_amount' => 0,
+ 'target_scope' => CouponTargetScope::ALL,
+ 'is_combinable' => true,
+ 'valid_from' => now()->subDay(),
+ 'valid_to' => now()->addDays(30),
+ ]);
+
+ $issue = CouponIssue::create([
+ 'coupon_id' => $couponModel->id,
+ 'user_id' => $user->id,
+ 'coupon_code' => 'TAKEN'.uniqid(),
+ 'status' => CouponIssueRecordStatus::USED,
+ 'issued_at' => now(),
+ 'expired_at' => now()->addDays(30),
+ 'used_at' => now(),
+ 'order_id' => $winningOrder->id,
+ ]);
+
+ $coupon = new CouponApplication(
+ couponId: $couponModel->id,
+ couponIssueId: $issue->id,
+ name: '선점 쿠폰',
+ targetType: 'product_amount',
+ discountType: 'fixed',
+ discountValue: 5000,
+ totalDiscount: 5000,
+ );
+ $promotions = new PromotionsSummary(
+ productPromotions: new AppliedPromotions(coupons: [$coupon])
+ );
+
+ $this->mockCalculationService($this->makeCalculationResult(103000, [
+ 'promotions' => $promotions,
+ ]));
+
+ // 실제 리스너를 훅에 연결 (프로덕션과 동일 경로)
+ $listener = app(CouponUseListener::class);
+ HookManager::addAction(
+ 'sirsoft-ecommerce.coupon.use',
+ fn ($couponIds, $order) => $listener->markCouponsUsed($couponIds, $order)
+ );
+
+ $ordersBefore = Order::query()->count();
+
+ try {
+ $this->service->createFromTempOrder(
+ $tempOrder,
+ ['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
+ ['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
+ 'card',
+ 103000
+ );
+ $this->fail('선점된 쿠폰으로 주문이 확정되어서는 안 됩니다.');
+ } catch (CouponAlreadyUsedException $e) {
+ $this->assertSame($issue->id, $e->getCouponIssueId());
+ }
+
+ // 주문 트랜잭션 전체 롤백 — 주문 행이 늘지 않아야 한다
+ $this->assertSame($ordersBefore, Order::query()->count(), '경쟁에서 밀린 주문은 생성되지 않아야 합니다.');
+
+ // 쿠폰 소유는 선행 주문 그대로
+ $issue->refresh();
+ $this->assertEquals(CouponIssueRecordStatus::USED, $issue->status);
+ $this->assertEquals($winningOrder->id, $issue->order_id);
+ }
+
public function test_create_from_temp_order_calls_mileage_use_hook(): void
{
$user = User::factory()->create();
diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json
index 96fd99b2..419a3927 100644
--- a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json
+++ b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json
@@ -1,15 +1,15 @@
{
"schema_version": "1.0",
- "generated_at": "2026-08-12T14:41:52+00:00",
+ "generated_at": "2026-08-21T13:47:13+00:00",
"generator": "g7 vendor-bundle:build",
"target": "module:sirsoft-ecommerce",
- "composer_json_sha256": "c1a7e6cb8fcf68b62af5b7817509e2a2df6cb19348b9951ec8ea30634f3030d3",
+ "composer_json_sha256": "6dc16ed7acb614593e17d97cf3230af0935e3e4910000026b4a7062d4f9de031",
"composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d",
- "zip_sha256": "afda2275ddb2a403be87062539f2ecc4b2d9136ffa5f85533128cc3eaa2724d9",
- "zip_size": 435548,
+ "zip_sha256": "b1f6cfcb3fea1c68be7ffd55074acab8c2d51c2750e9c036a678abc40ebee405",
+ "zip_size": 435549,
"package_count": 1,
"php_requirement": "^8.2",
- "g7_version": "7.0.6",
+ "g7_version": "7.0.8",
"packages": [
{
"name": "ezyang/htmlpurifier",
diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip
index b280f36c..3d732ca6 100644
Binary files a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip and b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip differ
diff --git a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md
index e40388c7..e81bd506 100644
--- a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.3] - 2026-08-22
+
+### Security
+
+- 마케팅 수신동의 채널 설정 저장에 플러그인 설정 권한 검사를 추가했습니다. 이전에는 관리자 계정이면 플러그인 설정 권한이 없어도 채널 목록을 통째로 덮어쓸 수 있었습니다. 이제 플러그인 설정 화면의 다른 저장 경로와 같은 권한이 필요합니다.
+
## [1.0.2] - 2026-08-19
### Fixed
diff --git a/plugins/_bundled/sirsoft-marketing/composer.json b/plugins/_bundled/sirsoft-marketing/composer.json
index 2d45226c..f7b59173 100644
--- a/plugins/_bundled/sirsoft-marketing/composer.json
+++ b/plugins/_bundled/sirsoft-marketing/composer.json
@@ -2,7 +2,7 @@
"name": "plugins/sirsoft-marketing",
"description": "Marketing consent and subscription management plugin for Gnuboard7 platform",
"type": "library",
- "version": "1.0.2",
+ "version": "1.0.3",
"autoload": {
"psr-4": {
"Plugins\\Sirsoft\\Marketing\\": ["src/", "./"]
diff --git a/plugins/_bundled/sirsoft-marketing/docs/api/channels.md b/plugins/_bundled/sirsoft-marketing/docs/api/channels.md
index c5ca259e..87379b8f 100644
--- a/plugins/_bundled/sirsoft-marketing/docs/api/channels.md
+++ b/plugins/_bundled/sirsoft-marketing/docs/api/channels.md
@@ -79,7 +79,7 @@ _단건 응답: `data` 객체의 필드. `data.channels` 는 저장 후 확정
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 관리자 권한이 없는 사용자가 호출한 경우 (`AdminBaseController`) |
+| 403 | Forbidden | 관리자가 아니거나 `core.plugins.update` 권한이 없는 경우 |
| 422 | 동의 이력 존재 | 삭제 대상 채널에 동의한 회원이 있는 경우 — "채널(:key)에 동의한 회원이 :count명 있어 삭제할 수 없습니다." |
@@ -88,6 +88,8 @@ _단건 응답: `data` 객체의 필드. `data.channels` 는 저장 후 확정
관리자 환경설정 화면에서 마케팅 동의 **채널 목록 전체를 한 번에 저장**하는 엔드포인트다. 컨트롤러가 `AdminBaseController` 를 상속하므로 실제 인증은 `auth:sanctum` **에 더해 관리자(admin) 권한**을 요구한다(생성기 표기는 `auth:sanctum` 만 노출). 제출된 배열이 곧 새 상태가 되며, 개별 채널 추가/수정 엔드포인트는 없다(전량 교체 방식).
+이 엔드포인트는 코어의 `PUT /api/admin/plugins/{identifier}/settings` 와 같은 `plugin_settings` 를 덮어쓰므로, 라우트에 **`permission:admin,core.plugins.update`** 가 부착되어 있다. 관리자 계정이더라도 플러그인 설정 권한이 없으면 403 이 된다(생성기 표기에는 나타나지 않는다).
+
**요청 파라미터**는 생성기가 배열 중첩 규칙(`channels.*`)을 평면화하지 못해 위 표에 "없음"으로 표기되나, 실제 `ChannelUpdateRequest` 는 다음 body 를 요구한다:
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
diff --git a/plugins/_bundled/sirsoft-marketing/package-lock.json b/plugins/_bundled/sirsoft-marketing/package-lock.json
index a3670458..940e605c 100644
--- a/plugins/_bundled/sirsoft-marketing/package-lock.json
+++ b/plugins/_bundled/sirsoft-marketing/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-marketing",
- "version": "1.0.2",
+ "version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-marketing",
- "version": "1.0.2",
+ "version": "1.0.3",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/plugins/_bundled/sirsoft-marketing/package.json b/plugins/_bundled/sirsoft-marketing/package.json
index 26eba533..60e78547 100644
--- a/plugins/_bundled/sirsoft-marketing/package.json
+++ b/plugins/_bundled/sirsoft-marketing/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-marketing",
- "version": "1.0.2",
+ "version": "1.0.3",
"description": "G7 마케팅 동의 플러그인 프론트엔드 에셋",
"private": true,
"type": "module",
diff --git a/plugins/_bundled/sirsoft-marketing/plugin.json b/plugins/_bundled/sirsoft-marketing/plugin.json
index 27da4071..3ca3ba1f 100644
--- a/plugins/_bundled/sirsoft-marketing/plugin.json
+++ b/plugins/_bundled/sirsoft-marketing/plugin.json
@@ -5,7 +5,7 @@
"ko": "마케팅 동의",
"en": "Marketing Consent"
},
- "version": "1.0.2",
+ "version": "1.0.3",
"description": {
"ko": "이메일 구독, 마케팅 동의, 제3자 제공 동의 등을 관리하는 플러그인",
"en": "Plugin for managing email subscriptions, marketing consent, and third-party data sharing consent"
diff --git a/plugins/_bundled/sirsoft-marketing/src/routes/api.php b/plugins/_bundled/sirsoft-marketing/src/routes/api.php
index a2ff4b07..7ed048d2 100644
--- a/plugins/_bundled/sirsoft-marketing/src/routes/api.php
+++ b/plugins/_bundled/sirsoft-marketing/src/routes/api.php
@@ -16,8 +16,12 @@ Route::get('/settings', [MarketingSettingsController::class, 'settings'])
* 마케팅 플러그인 관리자 API 라우트
* 자동 prefix 적용 후 최종 URL: /api/plugins/sirsoft-marketing/admin/channels
* 인증은 AdminBaseController 미들웨어에서 처리
+ *
+ * 채널 저장은 코어 `PUT plugins/{identifier}/settings` 와 같은 plugin_settings 를 덮어쓰는
+ * 우회 경로이므로 동일 권한(core.plugins.update)으로 게이트한다. `admin` 미들웨어는
+ * type=admin 보유 여부만 판정하므로 그것만으로는 업무 권한 없는 관리자도 도달한다.
*/
-Route::prefix('admin')->name('admin.')->group(function () {
+Route::prefix('admin')->name('admin.')->middleware('permission:admin,core.plugins.update')->group(function () {
Route::put('/channels', [MarketingAdminController::class, 'updateChannels'])
->name('channels.update');
});
diff --git a/plugins/_bundled/sirsoft-marketing/tests/Feature/Http/Controllers/MarketingAdminControllerTest.php b/plugins/_bundled/sirsoft-marketing/tests/Feature/Http/Controllers/MarketingAdminControllerTest.php
index 64a0e29b..e6ab6601 100644
--- a/plugins/_bundled/sirsoft-marketing/tests/Feature/Http/Controllers/MarketingAdminControllerTest.php
+++ b/plugins/_bundled/sirsoft-marketing/tests/Feature/Http/Controllers/MarketingAdminControllerTest.php
@@ -19,10 +19,10 @@ class MarketingAdminControllerTest extends PluginTestCase
*/
private const EXISTING_CHANNELS = [
[
- 'key' => 'email_subscription',
- 'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'],
+ 'key' => 'email_subscription',
+ 'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'],
'page_slug' => '',
- 'enabled' => true,
+ 'enabled' => true,
'is_system' => true,
],
];
@@ -35,7 +35,7 @@ class MarketingAdminControllerTest extends PluginTestCase
$mock->method('get')->willReturnCallback(
fn (string $id, string $key, mixed $default = null) => match ($key) {
'channels' => json_encode(self::EXISTING_CHANNELS),
- default => $default,
+ default => $default,
}
);
$this->app->instance(PluginSettingsService::class, $mock);
@@ -54,7 +54,7 @@ class MarketingAdminControllerTest extends PluginTestCase
public function test_update_channels_succeeds_for_admin(): void
{
- $admin = $this->createAdminUser();
+ $admin = $this->createAdminUser(['core.plugins.update']);
$response = $this->actingAs($admin)->putJson(
'/api/plugins/sirsoft-marketing/admin/channels',
@@ -68,14 +68,14 @@ class MarketingAdminControllerTest extends PluginTestCase
public function test_update_channels_saves_new_channel(): void
{
- $admin = $this->createAdminUser();
+ $admin = $this->createAdminUser(['core.plugins.update']);
$channels = array_merge(self::EXISTING_CHANNELS, [
[
- 'key' => 'sms_subscription',
- 'label' => ['ko' => '광고성 SMS', 'en' => 'SMS Marketing'],
+ 'key' => 'sms_subscription',
+ 'label' => ['ko' => '광고성 SMS', 'en' => 'SMS Marketing'],
'page_slug' => '',
- 'enabled' => true,
+ 'enabled' => true,
'is_system' => false,
],
]);
@@ -93,7 +93,7 @@ class MarketingAdminControllerTest extends PluginTestCase
public function test_update_channels_rejects_duplicate_keys(): void
{
- $admin = $this->createAdminUser();
+ $admin = $this->createAdminUser(['core.plugins.update']);
$channels = [
['key' => 'email_subscription', 'label' => ['ko' => 'A', 'en' => 'A'], 'page_slug' => '', 'enabled' => true, 'is_system' => true],
@@ -113,7 +113,7 @@ class MarketingAdminControllerTest extends PluginTestCase
public function test_update_channels_rejects_invalid_key_format(): void
{
- $admin = $this->createAdminUser();
+ $admin = $this->createAdminUser(['core.plugins.update']);
$channels = [
['key' => 'invalid-key!', 'label' => ['ko' => 'A', 'en' => 'A'], 'page_slug' => '', 'enabled' => true, 'is_system' => false],
@@ -132,7 +132,7 @@ class MarketingAdminControllerTest extends PluginTestCase
public function test_update_channels_rejects_removal_of_system_channel(): void
{
- $admin = $this->createAdminUser();
+ $admin = $this->createAdminUser(['core.plugins.update']);
// email_subscription(is_system=true) 없이 제출
$channels = [
@@ -152,7 +152,7 @@ class MarketingAdminControllerTest extends PluginTestCase
public function test_update_channels_rejects_system_flag_downgrade(): void
{
- $admin = $this->createAdminUser();
+ $admin = $this->createAdminUser(['core.plugins.update']);
// email_subscription의 is_system을 false로 위변조
$channels = [
@@ -172,17 +172,17 @@ class MarketingAdminControllerTest extends PluginTestCase
public function test_update_channels_rejects_deletion_when_consents_exist(): void
{
- $admin = $this->createAdminUser();
- $user = User::factory()->create();
+ $admin = $this->createAdminUser(['core.plugins.update']);
+ $user = User::factory()->create();
// sms_subscription 채널에 동의 데이터 생성
MarketingConsent::create([
- 'user_id' => $user->id,
- 'consent_key' => 'sms_subscription',
- 'is_consented' => true,
- 'consented_at' => now(),
- 'revoked_at' => null,
- 'last_source' => 'register',
+ 'user_id' => $user->id,
+ 'consent_key' => 'sms_subscription',
+ 'is_consented' => true,
+ 'consented_at' => now(),
+ 'revoked_at' => null,
+ 'last_source' => 'register',
'consent_count' => 1,
]);
@@ -201,17 +201,17 @@ class MarketingAdminControllerTest extends PluginTestCase
public function test_update_channels_rejects_deletion_of_existing_channel_with_consents(): void
{
- $admin = $this->createAdminUser();
- $user = User::factory()->create();
+ $admin = $this->createAdminUser(['core.plugins.update']);
+ $user = User::factory()->create();
// email_subscription에 동의 데이터 생성
MarketingConsent::create([
- 'user_id' => $user->id,
- 'consent_key' => 'email_subscription',
- 'is_consented' => true,
- 'consented_at' => now(),
- 'revoked_at' => null,
- 'last_source' => 'register',
+ 'user_id' => $user->id,
+ 'consent_key' => 'email_subscription',
+ 'is_consented' => true,
+ 'consented_at' => now(),
+ 'revoked_at' => null,
+ 'last_source' => 'register',
'consent_count' => 1,
]);
@@ -234,7 +234,7 @@ class MarketingAdminControllerTest extends PluginTestCase
public function test_update_channels_rejects_missing_label(): void
{
- $admin = $this->createAdminUser();
+ $admin = $this->createAdminUser(['core.plugins.update']);
$channels = [
['key' => 'sms_subscription', 'page_slug' => '', 'enabled' => true, 'is_system' => false],
diff --git a/plugins/_bundled/sirsoft-marketing/tests/Feature/Http/Controllers/MarketingAdminRoutePermissionTest.php b/plugins/_bundled/sirsoft-marketing/tests/Feature/Http/Controllers/MarketingAdminRoutePermissionTest.php
new file mode 100644
index 00000000..f6747ed5
--- /dev/null
+++ b/plugins/_bundled/sirsoft-marketing/tests/Feature/Http/Controllers/MarketingAdminRoutePermissionTest.php
@@ -0,0 +1,93 @@
+ 'email_subscription',
+ 'label' => ['ko' => '광고성 이메일 수신', 'en' => 'Email Marketing'],
+ 'page_slug' => '',
+ 'enabled' => true,
+ 'is_system' => true,
+ ],
+ ];
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $mock = $this->createMock(PluginSettingsService::class);
+ $mock->method('get')->willReturnCallback(
+ fn (string $id, string $key, mixed $default = null) => match ($key) {
+ 'channels' => json_encode(self::EXISTING_CHANNELS),
+ default => $default,
+ }
+ );
+ $this->app->instance(PluginSettingsService::class, $mock);
+ }
+
+ /**
+ * 채널 저장 라우트에 플러그인 설정 권한이 선언되어 있는지 확인합니다.
+ *
+ * @return void
+ */
+ public function test_channels_update_route_declares_plugin_settings_permission(): void
+ {
+ // 이름 조회표는 앱 부팅 시점에 한 번 갱신된다. 테스트는 부팅 이후(setUp)에 라우트를
+ // 등록하므로 그 표에 반영되지 않는다 — 조회 전에 명시적으로 다시 만든다.
+ Route::getRoutes()->refreshNameLookups();
+
+ $route = Route::getRoutes()->getByName('api.plugins.sirsoft-marketing.admin.channels.update');
+
+ $this->assertNotNull($route, '채널 저장 라우트가 존재해야 합니다.');
+ $this->assertContains(
+ 'permission:admin,core.plugins.update',
+ $route->gatherMiddleware(),
+ '채널 저장 라우트는 core.plugins.update 권한을 요구해야 합니다.'
+ );
+ }
+
+ /**
+ * 업무 권한이 없는 관리자는 채널 저장에서 403 을 받아야 합니다.
+ *
+ * @return void
+ */
+ public function test_admin_without_plugin_settings_permission_cannot_update_channels(): void
+ {
+ $admin = $this->createAdminUser();
+
+ $this->actingAs($admin)
+ ->putJson('/api/plugins/sirsoft-marketing/admin/channels', ['channels' => self::EXISTING_CHANNELS])
+ ->assertForbidden();
+ }
+
+ /**
+ * 플러그인 설정 권한 보유 관리자는 정상 저장할 수 있어야 합니다.
+ *
+ * @return void
+ */
+ public function test_admin_with_plugin_settings_permission_can_update_channels(): void
+ {
+ $admin = $this->createAdminUser(['core.plugins.update']);
+
+ $this->actingAs($admin)
+ ->putJson('/api/plugins/sirsoft-marketing/admin/channels', ['channels' => self::EXISTING_CHANNELS])
+ ->assertOk();
+ }
+}
diff --git a/plugins/_bundled/sirsoft-marketing/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-marketing/tests/PluginTestCase.php
index fde2d363..fc8ea40e 100644
--- a/plugins/_bundled/sirsoft-marketing/tests/PluginTestCase.php
+++ b/plugins/_bundled/sirsoft-marketing/tests/PluginTestCase.php
@@ -3,14 +3,13 @@
namespace Plugins\Sirsoft\Marketing\Tests;
use App\Enums\PermissionType;
+use App\Extension\HookManager;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Plugins\Sirsoft\Marketing\Repositories\Contracts\MarketingConsentRepositoryInterface;
-use Plugins\Sirsoft\Marketing\Http\Controllers\MarketingAdminController;
-use Plugins\Sirsoft\Marketing\Http\Controllers\MarketingSettingsController;
use Plugins\Sirsoft\Marketing\Repositories\MarketingConsentRepository;
use Tests\TestCase;
@@ -43,19 +42,7 @@ abstract class PluginTestCase extends TestCase
$this->app->bind(MarketingConsentRepositoryInterface::class, MarketingConsentRepository::class);
- Route::prefix('api/plugins/sirsoft-marketing')
- ->middleware('api')
- ->group(function () {
- Route::get('/settings', [MarketingSettingsController::class, 'settings'])
- ->name('api.sirsoft-marketing.settings');
-
- Route::prefix('admin')
- ->middleware('auth:sanctum')
- ->group(function () {
- Route::put('/channels', [MarketingAdminController::class, 'updateChannels'])
- ->name('api.sirsoft-marketing.admin.channels.update');
- });
- });
+ $this->registerPluginRoutes();
// HookManager 상태 스냅샷 (tearDown 에서 복원)
$this->snapshotHookManager();
@@ -76,7 +63,7 @@ abstract class PluginTestCase extends TestCase
*/
private function snapshotHookManager(): void
{
- $ref = new \ReflectionClass(\App\Extension\HookManager::class);
+ $ref = new \ReflectionClass(HookManager::class);
$this->hookSnapshot = [
'hooks' => $ref->getProperty('hooks')->getValue(),
'filters' => $ref->getProperty('filters')->getValue(),
@@ -93,7 +80,7 @@ abstract class PluginTestCase extends TestCase
return;
}
- $ref = new \ReflectionClass(\App\Extension\HookManager::class);
+ $ref = new \ReflectionClass(HookManager::class);
$ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']);
$ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']);
$ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']);
@@ -101,19 +88,46 @@ abstract class PluginTestCase extends TestCase
$this->hookSnapshot = null;
}
+ /**
+ * 플러그인 API 라우트를 실제 라우트 파일에서 등록합니다.
+ *
+ * 테스트 안에 라우트 정의를 복제하면 라우트 파일의 미들웨어 변경이 테스트에 도달하지
+ * 않아, 권한 게이트가 빠져도 검사가 통과한다. 프로덕션 PluginRouteServiceProvider 와
+ * 동일한 prefix/name 으로 실제 파일을 그대로 로드한다.
+ *
+ * @return void
+ */
+ protected function registerPluginRoutes(): void
+ {
+ $apiRoutesFile = base_path('plugins/sirsoft-marketing/src/routes/api.php');
+
+ if (file_exists($apiRoutesFile)) {
+ Route::prefix('api/plugins/sirsoft-marketing')
+ ->name('api.plugins.sirsoft-marketing.')
+ ->middleware('api')
+ ->group($apiRoutesFile);
+ }
+ }
+
/**
* 관리자 권한을 가진 사용자를 생성합니다.
*
* isAdmin()이 Role/Permission 기반이므로 admin Role과 type=admin Permission을 직접 생성합니다.
*
- * @return User
+ * @param array $permissions 추가로 부여할 업무 권한 식별자 목록
+ * @return User 생성된 관리자
*/
- protected function createAdminUser(): User
+ protected function createAdminUser(array $permissions = []): User
{
- $adminRole = Role::firstOrCreate(
- ['identifier' => 'admin'],
- ['name' => ['ko' => '관리자', 'en' => 'Admin'], 'description' => ['ko' => '관리자', 'en' => 'Admin']]
- );
+ $user = User::factory()->create();
+
+ // 사용자마다 고유 역할을 만든다 — 공용 'admin' 역할을 재사용하면 한 테스트 안에서
+ // 권한 보유 관리자에게 부여한 권한이 무권한 관리자에게도 새어 음성 케이스가 무력화된다.
+ $adminRole = Role::create([
+ 'identifier' => 'admin-test-'.$user->id.'-'.uniqid(),
+ 'name' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'],
+ 'description' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'],
+ ]);
$permission = Permission::firstOrCreate(
['identifier' => 'admin.access'],
@@ -122,7 +136,14 @@ abstract class PluginTestCase extends TestCase
$adminRole->permissions()->syncWithoutDetaching([$permission->id]);
- $user = User::factory()->create();
+ foreach ($permissions as $identifier) {
+ $granted = Permission::firstOrCreate(
+ ['identifier' => $identifier],
+ ['name' => ['ko' => $identifier, 'en' => $identifier], 'type' => PermissionType::Admin]
+ );
+ $adminRole->permissions()->syncWithoutDetaching([$granted->id]);
+ }
+
$user->roles()->attach($adminRole->id);
return $user;
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
index 5b3a6acc..98377c59 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.1.2] - 2026-08-22
+
+### Changed
+
+- 코어 최소 요구 버전을 7.0.8 로 상향했습니다.
+
+### Fixed
+
+- CBT 연결 점검이 서버 출발지 IP 와 호스트 연결 가능 여부를 사이트 환경설정의 아웃바운드 프록시 설정을 따라 확인하도록 했습니다. 이전에는 프록시를 사용하는 환경에서도 프록시를 거치지 않고 확인해, 결제사에 등록해야 할 IP 와 다른 값을 알려주고 실제로는 결제가 되는 상황에서도 "연결 불가" 로 보고했습니다.
+
## [1.1.1] - 2026-08-19
### Security
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/composer.json b/plugins/_bundled/sirsoft-pay_kginicis/composer.json
index 20bff2ba..1f62194c 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/composer.json
+++ b/plugins/_bundled/sirsoft-pay_kginicis/composer.json
@@ -1,7 +1,7 @@
{
"name": "plugins/sirsoft-pay_kginicis",
"description": "KG Inicis PG Plugin for G7 platform",
- "version": "1.1.1",
+ "version": "1.1.2",
"type": "library",
"authors": [
{
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/cbt.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/cbt.md
index 52901f52..8a368aa9 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/cbt.md
+++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/cbt.md
@@ -42,9 +42,9 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
-| egress_ip | string | `210.90.128.2` | 서버가 외부 통신 시 사용하는 egress IP. KG 이니시스 측에 DEVCBT 접근용 IP 화이트리스트 등록을 요청할 때 알려줄 IP이며, 외부 echo 서비스(ipify 등)를 순차 조회해 얻는다(모두 실패 시 null). |
+| egress_ip | string | `210.90.128.2` | 서버가 외부 통신 시 사용하는 egress IP. KG 이니시스 측에 DEVCBT 접근용 IP 화이트리스트 등록을 요청할 때 알려줄 IP이며, 외부 echo 서비스(ipify 등)를 순차 조회해 얻는다(모두 실패 시 null). 사이트 환경설정에 아웃바운드 프록시가 적용되어 있으면 그 프록시를 거쳐 조회하므로, 실제 결제 요청과 같은 경로의 IP 가 반환된다. |
| server_ip | string | `127.0.0.1` | `$_SERVER['SERVER_ADDR']` 로 읽은 서버 내부 IP. egress IP와 대조해 NAT/프록시 여부를 가늠하는 참고값이다. |
-| hosts | array | `[{"name":"devcbt.inicis.com","env":"test","dns_resolved_i…` | 진단 대상 호스트별 결과 배열. 각 항목은 호스트명(`devcbt.inicis.com`), 환경(`test`), DNS 해석 IP(`dns_resolved_ip`), TCP 443 도달 여부(`tcp_443_reachable`)와 에러·응답지연(`tcp_443_error`, `tcp_443_latency_ms`)을 담는다. 운영계(`cbt.inicis.com`)는 화이트리스트 제약이 없어 제외된다. |
+| hosts | array | `[{"name":"devcbt.inicis.com","env":"test","dns_resolved_i…` | 진단 대상 호스트별 결과 배열. 각 항목은 호스트명(`devcbt.inicis.com`), 환경(`test`), DNS 해석 IP(`dns_resolved_ip`), TCP 443 도달 여부(`tcp_443_reachable`)와 에러·응답지연(`tcp_443_error`, `tcp_443_latency_ms`)을 담는다. 운영계(`cbt.inicis.com`)는 화이트리스트 제약이 없어 제외된다. 도달 여부는 연결만 수행하고 데이터는 주고받지 않으며, 사이트 환경설정에 아웃바운드 프록시가 적용되어 있으면 그 프록시를 거쳐 확인한다 — 실제 결제 요청이 지나는 경로와 같은 경로를 재기 위함이다. |
| callback | object | `{"app_url":"https:\/\/g7.dev","callback_url":"https:\/\/g…` | 결제 콜백 URL 진단 정보. 앱 URL·콜백 URL과 각각의 HTTPS 여부(`app_url_https`, `callback_url_https`)·공인 호스트 여부(`app_url_public`, `callback_url_public`), 그리고 콜백 호스트가 앱 URL 호스트와 일치하는지(`host_matches_app_url`)를 담아 CBT 콜백 수신 가능 여부를 점검한다. |
**응답 예시**
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/package-lock.json b/plugins/_bundled/sirsoft-pay_kginicis/package-lock.json
index 25645d99..067a0feb 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/package-lock.json
+++ b/plugins/_bundled/sirsoft-pay_kginicis/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-pay_kginicis",
- "version": "1.1.1",
+ "version": "1.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-pay_kginicis",
- "version": "1.1.1",
+ "version": "1.1.2",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/package.json b/plugins/_bundled/sirsoft-pay_kginicis/package.json
index 9823d096..90dab81f 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/package.json
+++ b/plugins/_bundled/sirsoft-pay_kginicis/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-pay_kginicis",
- "version": "1.1.1",
+ "version": "1.1.2",
"type": "module",
"private": true,
"scripts": {
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/plugin.json b/plugins/_bundled/sirsoft-pay_kginicis/plugin.json
index f0e400de..1a9c43db 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/plugin.json
+++ b/plugins/_bundled/sirsoft-pay_kginicis/plugin.json
@@ -5,7 +5,7 @@
"ko": "KG 이니시스",
"en": "KG Inicis"
},
- "version": "1.1.1",
+ "version": "1.1.2",
"license": "MIT",
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_kginicis",
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_kginicis/blob/main/CHANGELOG.md",
@@ -13,7 +13,7 @@
"ko": "KG 이니시스 결제 게이트웨이 (표준결제창 연동, 일본결제 지원)",
"en": "KG Inicis payment gateway (standard payment window, Japan payment support)"
},
- "g7_version": ">=7.0.5",
+ "g7_version": ">=7.0.8",
"dependencies": {
"modules": {
"sirsoft-ecommerce": ">=1.1.0"
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtConnectivityCheckController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtConnectivityCheckController.php
index 8f8693ff..d962e105 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtConnectivityCheckController.php
+++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtConnectivityCheckController.php
@@ -6,6 +6,7 @@ namespace Plugins\Sirsoft\PayKginicis\Controllers;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
+use App\Support\OutboundProxy;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
@@ -114,6 +115,7 @@ class AdminCbtConnectivityCheckController extends AdminBaseController
return $ip;
}
}
+
return null;
}
@@ -129,6 +131,11 @@ class AdminCbtConnectivityCheckController extends AdminBaseController
CURLOPT_TIMEOUT => self::EGRESS_LOOKUP_TIMEOUT,
CURLOPT_FOLLOWLOCATION => false,
]);
+
+ // 코어 환경설정의 아웃바운드 프록시를 이 조회에도 적용한다.
+ // 이 값은 운영자가 이니시스에 등록할 IP 이므로 실제 결제 호출과 같은 경로로 나가야
+ // 한다 — 프록시를 켠 상태에서 직접 조회하면 등록해야 할 IP 와 다른 값을 보고한다.
+ curl_setopt_array($ch, OutboundProxy::curlOptions());
$body = curl_exec($ch);
curl_close($ch);
@@ -136,31 +143,59 @@ class AdminCbtConnectivityCheckController extends AdminBaseController
return null;
}
$body = trim($body);
+
return filter_var($body, FILTER_VALIDATE_IP) !== false ? $body : null;
}
/**
* TCP 443 연결 가능 여부 확인.
*
+ * 연결만 수행하고 데이터는 주고받지 않는다(`CURLOPT_CONNECT_ONLY`). 원시 소켓(`fsockopen`)
+ * 대신 curl 을 쓰는 이유는 사이트 환경설정의 아웃바운드 프록시를 이 검사에도 태우기
+ * 위해서다 — 프록시 핸드셰이크(HTTP CONNECT / SOCKS)는 curl 이 처리한다.
+ *
+ * 이 구분은 진단의 정확성을 좌우한다. 프록시를 쓰는 환경에서 원시 소켓으로 직접 확인하면
+ * 실제 결제 요청이 지나는 경로가 아닌 곳을 재는 셈이라, 결제는 정상 동작하는데 진단만
+ * "연결 불가" 로 보고하는 상태가 된다.
+ *
+ * @param string $host 검사 대상 호스트
* @return array{reachable: bool, error: ?string, latency_ms: ?int}
*/
private function checkTcp443(string $host): array
{
$start = microtime(true);
- $errno = 0;
- $errstr = '';
- $fp = @fsockopen($host, 443, $errno, $errstr, self::TCP_TIMEOUT_SECONDS);
- $latencyMs = (int) round((microtime(true) - $start) * 1000);
- if ($fp === false) {
+ $ch = curl_init('https://'.$host);
+
+ if ($ch === false) {
return [
'reachable' => false,
- 'error' => $errstr !== '' ? $errstr : 'connect failed',
+ 'error' => 'connect failed',
+ 'latency_ms' => 0,
+ ];
+ }
+
+ curl_setopt_array($ch, [
+ CURLOPT_CONNECT_ONLY => true,
+ CURLOPT_CONNECTTIMEOUT => self::TCP_TIMEOUT_SECONDS,
+ CURLOPT_TIMEOUT => self::TCP_TIMEOUT_SECONDS,
+ ]);
+ curl_setopt_array($ch, OutboundProxy::curlOptions());
+
+ $connected = curl_exec($ch);
+ $error = curl_error($ch);
+ curl_close($ch);
+
+ $latencyMs = (int) round((microtime(true) - $start) * 1000);
+
+ if ($connected === false) {
+ return [
+ 'reachable' => false,
+ 'error' => $error !== '' ? $error : 'connect failed',
'latency_ms' => $latencyMs,
];
}
- fclose($fp);
return [
'reachable' => true,
'error' => null,
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
index 6d9be298..071e7f59 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
@@ -4,6 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.3] - 2026-08-22
+
+### Security
+
+- 관리자 주문 조회·에스크로 배송등록 API가 세부 권한을 검사하도록 수정했습니다. 이전에는 관리자 계정이면 주문 권한이 없어도 주문번호·결제정보·수령인 연락처와 주소를 조회하고 배송등록까지 할 수 있었습니다. 이제 조회는 주문 조회 권한, 배송등록은 주문 수정 권한이 필요하며, 다른 결제대행사 연동과 같은 기준이 적용됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1893)
+- 가상계좌 입금통보 주소 조회와 시스템 점검 API에도 설정 조회 권한 검사를 추가했습니다.
+
## [1.0.2] - 2026-08-19
### Security
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/composer.json b/plugins/_bundled/sirsoft-pay_nhnkcp/composer.json
index 81920c80..d866151a 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/composer.json
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/composer.json
@@ -1,7 +1,7 @@
{
"name": "plugins/sirsoft-pay_nhnkcp",
"description": "NHN KCP PG Plugin for G7 platform",
- "version": "1.0.2",
+ "version": "1.0.3",
"type": "library",
"authors": [
{
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md
index 7aa4d320..0fa13fe4 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md
@@ -5,4 +5,5 @@
| 문서 | 도메인 | 설명 |
| --- | --- | --- |
| [vbank.md](vbank.md) | `payment` | 가상계좌 입금통보·에스크로 공통통보 수신 경로와 발신 서버(IP) 확인 |
+| [admin-orders.md](admin-orders.md) | `admin` | 관리자 주문 연동 경로 전체와 각 경로의 요구 권한 |
| [transaction-status.md](transaction-status.md) | `admin` | 관리자 주문 상세의 거래 상태·취소·환불 조회 |
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/admin-orders.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/admin-orders.md
new file mode 100644
index 00000000..8095c175
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/admin-orders.md
@@ -0,0 +1,85 @@
+# 관리자 주문 연동 API 레퍼런스
+
+> **소유**: 플러그인 `sirsoft-pay_nhnkcp`. 관리자 주문 목록·상세 화면이 호출하는 NHN KCP 연동 경로와 각 경로가 요구하는 세부 권한을 서술한다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. admin 그룹의 모든 경로는 Bearer 토큰(관리자) + 이커머스 세부 권한을 함께 요구한다
+2. 조회 경로는 sirsoft-ecommerce.orders.read, 쓰기 경로는 sirsoft-ecommerce.orders.update
+3. 설정성 경로(입금통보 주소·시스템 점검)는 sirsoft-ecommerce.settings.read
+4. 관리자(type=admin)라도 해당 권한이 없으면 403 이며, 요청은 아무 부작용도 남기지 않는다
+5. 다른 결제대행사 플러그인(kginicis·nicepayments)과 동일한 권한 기준이다
+```
+
+---
+
+## 권한 매트릭스
+
+`admin` 미들웨어는 관리자 여부(type=admin 권한 보유)만 판정하고 업무 권한은 판정하지 않는다. 따라서 각 라우트가 요구 권한을 직접 선언한다.
+
+| 메서드/URI | 라우트명 | 요구 권한 |
+| --- | --- | --- |
+| `GET /api/plugins/sirsoft-pay_nhnkcp/admin/orders/test-mode-map` | `...admin.orders.test-mode-map` | `sirsoft-ecommerce.orders.read` |
+| `GET /api/plugins/sirsoft-pay_nhnkcp/admin/orders/easy-pay-display-map` | `...admin.orders.easy-pay-display-map` | `sirsoft-ecommerce.orders.read` |
+| `GET /api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/transaction-status` | `...admin.orders.transaction-status` | `sirsoft-ecommerce.orders.read` |
+| `GET /api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/escrow-delivery` | `...admin.orders.escrow-delivery.form` | `sirsoft-ecommerce.orders.read` |
+| `POST /api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/escrow-delivery` | `...admin.orders.escrow-delivery.register` | `sirsoft-ecommerce.orders.update` |
+| `GET /api/plugins/sirsoft-pay_nhnkcp/admin/settings/test-mode-status` | `...admin.settings.test-mode-status` | `sirsoft-ecommerce.settings.read` |
+| `GET /api/plugins/sirsoft-pay_nhnkcp/admin/vbank-notify-url` | `...admin.vbank.notify.url` | `sirsoft-ecommerce.settings.read` |
+| `GET /api/plugins/sirsoft-pay_nhnkcp/admin/health` | `...admin.health` | `sirsoft-ecommerce.settings.read` |
+
+### 에러 응답
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 관리자가 아니거나, 위 표의 권한을 보유하지 않은 경우 |
+| 422 | Validation | 배송등록 요청의 운송장번호·택배사 코드가 형식에 맞지 않는 경우 |
+
+권한 검사는 컨트롤러 진입 **전에** 수행되므로, 403 으로 거부된 배송등록 요청은 결제 정보(`payment_meta`)를 포함해 어떤 상태도 변경하지 않는다.
+
+---
+
+## 조회 경로
+
+### 테스트 모드 주문 맵
+
+`GET .../admin/orders/test-mode-map`
+
+관리자 주문 목록에서 어떤 주문이 테스트 결제인지 배지로 표시하기 위한 맵을 반환한다. 응답 `data` 는 주문번호를 키로 하는 객체다.
+
+### 간편결제 표시 맵
+
+`GET .../admin/orders/easy-pay-display-map`
+
+간편결제(PAYCO 등)로 결제된 주문의 원 결제수단 표시 라벨을 반환한다. 응답 `data` 는 주문번호를 키로 하며, 각 항목은 `embedded_pg_provider_label`·`payment_method_label`·`payment_method_display_label` 을 포함한다.
+
+### 거래 상태 조회
+
+`GET .../admin/orders/{orderNumber}/transaction-status`
+
+상세 필드는 [transaction-status.md](transaction-status.md) 참조.
+
+### 에스크로 배송등록 폼 데이터
+
+`GET .../admin/orders/{orderNumber}/escrow-delivery`
+
+배송등록 화면의 초기값(주문 정보·기본 배송지·기등록 배송 이력)을 반환한다. 이 주문에 에스크로 결제가 없으면 `data` 는 `null` 이다(오류 아님).
+
+---
+
+## 쓰기 경로
+
+### 에스크로 배송등록
+
+`POST .../admin/orders/{orderNumber}/escrow-delivery`
+
+| 이름 | 위치 | 타입 | 필수 | 설명 |
+| --- | --- | --- | --- | --- |
+| `deli_numb` | body | string | 예 | 운송장번호 |
+| `deli_corp` | body | string | 예 | 택배사 코드 (KCP 공식 코드표) |
+
+NHN KCP 에 운송장 정보를 등록하고, 응답 중 허용된 필드만 정제해 결제 정보에 기록한다. 주문 조회 권한만 보유한 관리자는 이 경로에서 403 을 받는다.
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/transaction-status.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/transaction-status.md
index 2fc757c1..6b0ce037 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/transaction-status.md
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/transaction-status.md
@@ -22,7 +22,7 @@
| --- | --- |
| 메서드/URI | `GET /api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/transaction-status` |
| 인증 | Bearer 토큰 (관리자) |
-| 권한 | 주문 조회 권한 |
+| 권한 | 주문 조회 권한 (`sirsoft-ecommerce.orders.read`) — 미보유 시 403 |
### 경로 파라미터
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/package-lock.json b/plugins/_bundled/sirsoft-pay_nhnkcp/package-lock.json
index c847ad60..fd265ec4 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/package-lock.json
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-pay_nhnkcp",
- "version": "1.0.2",
+ "version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-pay_nhnkcp",
- "version": "1.0.2",
+ "version": "1.0.3",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/package.json b/plugins/_bundled/sirsoft-pay_nhnkcp/package.json
index a10bc4a2..18a5d567 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/package.json
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-pay_nhnkcp",
- "version": "1.0.2",
+ "version": "1.0.3",
"type": "module",
"private": true,
"scripts": {
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json b/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json
index 58774553..5d5688d1 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json
@@ -5,7 +5,7 @@
"ko": "NHN KCP",
"en": "NHN KCP"
},
- "version": "1.0.2",
+ "version": "1.0.3",
"license": "MIT",
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_nhnkcp",
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_nhnkcp/blob/main/CHANGELOG.md",
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/routes/api.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/routes/api.php
index 6817d9c4..865b1923 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/routes/api.php
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/routes/api.php
@@ -52,7 +52,8 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g
'url' => url('/plugins/sirsoft-pay_nhnkcp/payment/vbank-notify'),
'escrow_common_notify_url' => url('/plugins/sirsoft-pay_nhnkcp/payment/escrow-common-notify'),
],
- ]))->name('vbank.notify.url');
+ ]))->middleware('permission:admin,sirsoft-ecommerce.settings.read')
+ ->name('vbank.notify.url');
Route::get('/settings/test-mode-status', [AdminSettingsStatusController::class, 'testMode'])
->middleware('permission:admin,sirsoft-ecommerce.settings.read')
@@ -60,23 +61,29 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g
// 테스트 모드 주문 맵 (관리자 주문목록 배지 표시용)
Route::get('/orders/test-mode-map', [AdminOrderListController::class, 'testModeMap'])
+ ->middleware('permission:admin,sirsoft-ecommerce.orders.read')
->name('orders.test-mode-map');
// 간편결제 원 결제수단 표시 맵 (관리자 주문목록 보강용)
Route::get('/orders/easy-pay-display-map', [AdminOrderListController::class, 'easyPayDisplayMap'])
+ ->middleware('permission:admin,sirsoft-ecommerce.orders.read')
->name('orders.easy-pay-display-map');
// 주문번호로 거래 정보 조회 (레이아웃 확장 자동 로드용)
Route::get('/orders/{orderNumber}/transaction-status', [AdminTransactionController::class, 'queryByOrder'])
+ ->middleware('permission:admin,sirsoft-ecommerce.orders.read')
->name('orders.transaction-status');
// 에스크로 배송 등록
Route::get('/orders/{orderNumber}/escrow-delivery', [AdminEscrowDeliveryController::class, 'formData'])
+ ->middleware('permission:admin,sirsoft-ecommerce.orders.read')
->name('orders.escrow-delivery.form');
Route::post('/orders/{orderNumber}/escrow-delivery', [AdminEscrowDeliveryController::class, 'register'])
+ ->middleware('permission:admin,sirsoft-ecommerce.orders.update')
->name('orders.escrow-delivery.register');
// 시스템 점검 (PC/모바일 결제 사전조건 진단 + 자동 chmod +x 복구)
Route::get('/health', [HealthCheckController::class, 'check'])
+ ->middleware('permission:admin,sirsoft-ecommerce.settings.read')
->name('health');
});
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/AdminRoutePermissionTest.php b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/AdminRoutePermissionTest.php
new file mode 100644
index 00000000..c4f16f95
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/AdminRoutePermissionTest.php
@@ -0,0 +1,199 @@
+ 'permission:admin,sirsoft-ecommerce.orders.read',
+ 'api.plugins.sirsoft-pay_nhnkcp.admin.orders.easy-pay-display-map' => 'permission:admin,sirsoft-ecommerce.orders.read',
+ 'api.plugins.sirsoft-pay_nhnkcp.admin.orders.transaction-status' => 'permission:admin,sirsoft-ecommerce.orders.read',
+ 'api.plugins.sirsoft-pay_nhnkcp.admin.orders.escrow-delivery.form' => 'permission:admin,sirsoft-ecommerce.orders.read',
+ 'api.plugins.sirsoft-pay_nhnkcp.admin.orders.escrow-delivery.register' => 'permission:admin,sirsoft-ecommerce.orders.update',
+ 'api.plugins.sirsoft-pay_nhnkcp.admin.vbank.notify.url' => 'permission:admin,sirsoft-ecommerce.settings.read',
+ 'api.plugins.sirsoft-pay_nhnkcp.admin.health' => 'permission:admin,sirsoft-ecommerce.settings.read',
+ ];
+
+ // 이름 조회표는 앱 부팅 시점에 한 번 갱신된다. 테스트는 부팅 이후(setUp)에 라우트를
+ // 등록하므로 그 표에 반영되지 않는다 — 조회 전에 명시적으로 다시 만든다.
+ Route::getRoutes()->refreshNameLookups();
+
+ foreach ($expected as $routeName => $permissionMiddleware) {
+ $route = Route::getRoutes()->getByName($routeName);
+ $this->assertNotNull($route, "Route [{$routeName}] should exist.");
+ $this->assertContains(
+ $permissionMiddleware,
+ $route->gatherMiddleware(),
+ "Route [{$routeName}] should require [{$permissionMiddleware}]."
+ );
+ }
+ }
+
+ /**
+ * 업무 권한이 없는 관리자는 주문 관련 전 경로에서 403 을 받아야 합니다.
+ *
+ * @return void
+ */
+ public function test_admin_without_business_permission_is_denied_on_every_order_route(): void
+ {
+ $admin = $this->createAdminUser([]);
+ $order = $this->createEscrowOrder();
+ $base = '/api/plugins/sirsoft-pay_nhnkcp/admin';
+
+ $this->actingAs($admin)->getJson($base.'/orders/test-mode-map')->assertForbidden();
+ $this->actingAs($admin)->getJson($base.'/orders/easy-pay-display-map')->assertForbidden();
+ $this->actingAs($admin)->getJson($base."/orders/{$order->order_number}/transaction-status")->assertForbidden();
+ $this->actingAs($admin)->getJson($base."/orders/{$order->order_number}/escrow-delivery")->assertForbidden();
+ $this->actingAs($admin)->postJson($base."/orders/{$order->order_number}/escrow-delivery", [
+ 'deli_numb' => '1234567890',
+ 'deli_corp' => '04',
+ ])->assertForbidden();
+ $this->actingAs($admin)->getJson($base.'/vbank-notify-url')->assertForbidden();
+ $this->actingAs($admin)->getJson($base.'/health')->assertForbidden();
+ }
+
+ /**
+ * 403 은 가드 선행이어야 하며, 거부된 등록 요청은 결제 메타를 변경하지 않아야 합니다.
+ *
+ * @return void
+ */
+ public function test_denied_escrow_register_leaves_payment_meta_untouched(): void
+ {
+ $admin = $this->createAdminUser(['sirsoft-ecommerce.orders.read']);
+ $order = $this->createEscrowOrder();
+ $before = $order->payment()->first()->payment_meta;
+
+ $this->actingAs($admin)
+ ->postJson("/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{$order->order_number}/escrow-delivery", [
+ 'deli_numb' => '1234567890',
+ 'deli_corp' => '04',
+ ])
+ ->assertForbidden();
+
+ $payment = $order->payment()->first();
+ $this->assertSame($before, $payment->payment_meta);
+ $this->assertArrayNotHasKey('escrow_delivery', $payment->payment_meta ?? []);
+ }
+
+ /**
+ * 읽기 권한 보유 관리자는 조회 경로에 도달해야 합니다 (수정이 정상 관리자를 깨지 않음).
+ *
+ * @return void
+ */
+ public function test_admin_with_orders_read_can_reach_read_routes(): void
+ {
+ $admin = $this->createAdminUser(['sirsoft-ecommerce.orders.read']);
+ $order = $this->createEscrowOrder();
+ $base = '/api/plugins/sirsoft-pay_nhnkcp/admin';
+
+ $this->actingAs($admin)->getJson($base.'/orders/test-mode-map')->assertOk();
+ $this->actingAs($admin)->getJson($base.'/orders/easy-pay-display-map')->assertOk();
+ $this->actingAs($admin)->getJson($base."/orders/{$order->order_number}/escrow-delivery")->assertOk();
+ }
+
+ /**
+ * 수정 권한 보유 관리자는 배송등록에 도달해야 합니다.
+ *
+ * @return void
+ */
+ public function test_admin_with_orders_update_can_register_escrow_delivery(): void
+ {
+ $admin = $this->createAdminUser(['sirsoft-ecommerce.orders.update']);
+ $order = $this->createEscrowOrder();
+
+ $mock = $this->createMock(NhnKcpApiService::class);
+ $mock->method('registerEscrowDelivery')->willReturn([
+ 'res_cd' => '0000',
+ 'res_msg' => '정상처리',
+ 'tno' => 'KCP_ESCROW_TNO_PERM',
+ 'deli_numb' => '1234567890',
+ 'deli_corp' => '04',
+ ]);
+ $this->app->instance(NhnKcpApiService::class, $mock);
+
+ $this->actingAs($admin)
+ ->postJson("/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{$order->order_number}/escrow-delivery", [
+ 'deli_numb' => '1234567890',
+ 'deli_corp' => '04',
+ ])
+ ->assertOk()
+ ->assertJsonPath('success', true);
+ }
+
+ /**
+ * 설정 읽기 권한 보유 관리자는 설정성 경로에 도달해야 합니다.
+ *
+ * @return void
+ */
+ public function test_admin_with_settings_read_can_reach_settings_routes(): void
+ {
+ $admin = $this->createAdminUser(['sirsoft-ecommerce.settings.read']);
+
+ $this->actingAs($admin)
+ ->getJson('/api/plugins/sirsoft-pay_nhnkcp/admin/vbank-notify-url')
+ ->assertOk();
+ }
+
+ /**
+ * 에스크로 결제가 붙은 테스트 주문을 생성합니다.
+ *
+ * @return Order 생성된 주문
+ */
+ private function createEscrowOrder(): Order
+ {
+ $order = OrderFactory::new()->create([
+ 'user_id' => User::factory()->create()->id,
+ 'order_number' => 'ORD-KCP-PERM-'.random_int(10000, 99999),
+ 'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
+ 'total_amount' => 30000,
+ 'total_due_amount' => 0,
+ 'total_paid_amount' => 30000,
+ 'paid_at' => now(),
+ ]);
+
+ OrderPaymentFactory::new()->create([
+ 'order_id' => $order->id,
+ 'payment_status' => PaymentStatusEnum::PAID,
+ 'payment_method' => PaymentMethodEnum::CARD,
+ 'pg_provider' => 'nhnkcp',
+ 'transaction_id' => 'KCP_TNO_PERM_'.random_int(10000, 99999),
+ 'paid_amount_local' => 30000,
+ 'paid_at' => now(),
+ 'is_escrow' => true,
+ 'payment_meta' => [
+ 'site_cd' => 'T0000',
+ 'is_test_mode' => true,
+ 'escw_yn' => 'Y',
+ ],
+ ]);
+
+ return $order->fresh();
+ }
+}
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md
index 6dc4c422..98f38f78 100644
--- a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md
@@ -4,6 +4,13 @@ All notable changes to this plugin will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
+## [1.0.4] - 2026-08-22
+
+### Changed
+
+- 본인인증 승인 요청이 사이트 환경설정의 아웃바운드 프록시 설정을 따르도록 했습니다. 이전에는 이 요청만 프록시를 거치지 않고 직접 나가서, 프록시를 사용하는 환경에서 결제사에 등록한 IP 와 다른 IP 로 접속했습니다.
+- 코어 최소 요구 버전을 7.0.8 로 상향했습니다.
+
## [1.0.3] - 2026-08-19
### Fixed
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/composer.json b/plugins/_bundled/sirsoft-verification_kginicis/composer.json
index d3f8bb8c..edc52d24 100644
--- a/plugins/_bundled/sirsoft-verification_kginicis/composer.json
+++ b/plugins/_bundled/sirsoft-verification_kginicis/composer.json
@@ -1,7 +1,7 @@
{
"name": "plugins/sirsoft-verification_kginicis",
"description": "KG Inicis Identity Verification provider for G7",
- "version": "1.0.3",
+ "version": "1.0.4",
"type": "library",
"authors": [
{
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/package-lock.json b/plugins/_bundled/sirsoft-verification_kginicis/package-lock.json
index a5dc8c3f..4a822121 100644
--- a/plugins/_bundled/sirsoft-verification_kginicis/package-lock.json
+++ b/plugins/_bundled/sirsoft-verification_kginicis/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-verification_kginicis",
- "version": "1.0.3",
+ "version": "1.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-verification_kginicis",
- "version": "1.0.3",
+ "version": "1.0.4",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/package.json b/plugins/_bundled/sirsoft-verification_kginicis/package.json
index 65ee40f1..29db9ea3 100644
--- a/plugins/_bundled/sirsoft-verification_kginicis/package.json
+++ b/plugins/_bundled/sirsoft-verification_kginicis/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-verification_kginicis",
- "version": "1.0.3",
+ "version": "1.0.4",
"type": "module",
"private": true,
"scripts": {
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/plugin.json b/plugins/_bundled/sirsoft-verification_kginicis/plugin.json
index 4a16bbf1..05ba7270 100644
--- a/plugins/_bundled/sirsoft-verification_kginicis/plugin.json
+++ b/plugins/_bundled/sirsoft-verification_kginicis/plugin.json
@@ -5,13 +5,13 @@
"ko": "KG이니시스 본인인증",
"en": "KG Inicis Identity Verification"
},
- "version": "1.0.3",
+ "version": "1.0.4",
"license": "MIT",
"description": {
"ko": "KG이니시스 통합인증의 본인확인(reqSvcCd=03)을 G7 코어 IDV 인프라에 Provider 로 등록하는 플러그인",
"en": "KG Inicis Identity Verification (reqSvcCd=03) provider for G7 core IDV infrastructure"
},
- "g7_version": ">=7.0.6",
+ "g7_version": ">=7.0.8",
"dependencies": {
"modules": {},
"plugins": {}
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/src/Services/InicisGateway.php b/plugins/_bundled/sirsoft-verification_kginicis/src/Services/InicisGateway.php
index 8bd0cc39..2b361c01 100644
--- a/plugins/_bundled/sirsoft-verification_kginicis/src/Services/InicisGateway.php
+++ b/plugins/_bundled/sirsoft-verification_kginicis/src/Services/InicisGateway.php
@@ -2,6 +2,7 @@
namespace Plugins\Sirsoft\VerificationKginicis\Services;
+use App\Support\OutboundProxy;
use App\Support\OutboundUrlValidator;
use Illuminate\Support\Str;
use Plugins\Sirsoft\VerificationKginicis\Exceptions\DecryptException;
@@ -141,6 +142,12 @@ class InicisGateway implements InicisGatewayInterface
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
+ // 코어 환경설정의 아웃바운드 프록시를 이 호출에도 적용한다.
+ // 본인인증 승인 요청은 이니시스가 가맹점 서버 IP 를 화이트리스트로 제한하는
+ // 대상이라, 코어의 다른 외부 호출과 같은 IP 로 나가야 한다. 적용 여부 판정은
+ // 코어가 소유하며 미적용 시 빈 배열이라 그대로 넘겨도 무해하다.
+ curl_setopt_array($ch, OutboundProxy::curlOptions());
+
$responseBody = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErrno = curl_errno($ch);
diff --git a/public/build/core/template-engine.min.js b/public/build/core/template-engine.min.js
index 6b5429d3..563ad9c8 100644
--- a/public/build/core/template-engine.min.js
+++ b/public/build/core/template-engine.min.js
@@ -9,7 +9,7 @@ Error generating stack: `+u.message+`
`).replace(oT,"")}function RS(t,r){return r=kS(r),kS(t)===r}function Tt(t,r,o,u,p,g){switch(o){case"children":typeof u=="string"?r==="body"||r==="textarea"&&u===""||qa(t,u):(typeof u=="number"||typeof u=="bigint")&&r!=="body"&&qa(t,""+u);break;case"className":xi(t,"class",u);break;case"tabIndex":xi(t,"tabindex",u);break;case"dir":case"role":case"viewBox":case"width":case"height":xi(t,o,u);break;case"style":zc(t,u,g);break;case"data":if(r!=="object"){xi(t,"data",u);break}case"src":case"href":if(u===""&&(r!=="a"||o!=="href")){t.removeAttribute(o);break}if(u==null||typeof u=="function"||typeof u=="symbol"||typeof u=="boolean"){t.removeAttribute(o);break}u=lr(""+u),t.setAttribute(o,u);break;case"action":case"formAction":if(typeof u=="function"){t.setAttribute(o,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof g=="function"&&(o==="formAction"?(r!=="input"&&Tt(t,r,"name",p.name,p,null),Tt(t,r,"formEncType",p.formEncType,p,null),Tt(t,r,"formMethod",p.formMethod,p,null),Tt(t,r,"formTarget",p.formTarget,p,null)):(Tt(t,r,"encType",p.encType,p,null),Tt(t,r,"method",p.method,p,null),Tt(t,r,"target",p.target,p,null)));if(u==null||typeof u=="symbol"||typeof u=="boolean"){t.removeAttribute(o);break}u=lr(""+u),t.setAttribute(o,u);break;case"onClick":u!=null&&(t.onclick=Rr);break;case"onScroll":u!=null&&at("scroll",t);break;case"onScrollEnd":u!=null&&at("scrollend",t);break;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(a(61));if(o=u.__html,o!=null){if(p.children!=null)throw Error(a(60));t.innerHTML=o}}break;case"multiple":t.multiple=u&&typeof u!="function"&&typeof u!="symbol";break;case"muted":t.muted=u&&typeof u!="function"&&typeof u!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(u==null||typeof u=="function"||typeof u=="boolean"||typeof u=="symbol"){t.removeAttribute("xlink:href");break}o=lr(""+u),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",o);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":u!=null&&typeof u!="function"&&typeof u!="symbol"?t.setAttribute(o,""+u):t.removeAttribute(o);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":u&&typeof u!="function"&&typeof u!="symbol"?t.setAttribute(o,""):t.removeAttribute(o);break;case"capture":case"download":u===!0?t.setAttribute(o,""):u!==!1&&u!=null&&typeof u!="function"&&typeof u!="symbol"?t.setAttribute(o,u):t.removeAttribute(o);break;case"cols":case"rows":case"size":case"span":u!=null&&typeof u!="function"&&typeof u!="symbol"&&!isNaN(u)&&1<=u?t.setAttribute(o,u):t.removeAttribute(o);break;case"rowSpan":case"start":u==null||typeof u=="function"||typeof u=="symbol"||isNaN(u)?t.removeAttribute(o):t.setAttribute(o,u);break;case"popover":at("beforetoggle",t),at("toggle",t),In(t,"popover",u);break;case"xlinkActuate":yn(t,"http://www.w3.org/1999/xlink","xlink:actuate",u);break;case"xlinkArcrole":yn(t,"http://www.w3.org/1999/xlink","xlink:arcrole",u);break;case"xlinkRole":yn(t,"http://www.w3.org/1999/xlink","xlink:role",u);break;case"xlinkShow":yn(t,"http://www.w3.org/1999/xlink","xlink:show",u);break;case"xlinkTitle":yn(t,"http://www.w3.org/1999/xlink","xlink:title",u);break;case"xlinkType":yn(t,"http://www.w3.org/1999/xlink","xlink:type",u);break;case"xmlBase":yn(t,"http://www.w3.org/XML/1998/namespace","xml:base",u);break;case"xmlLang":yn(t,"http://www.w3.org/XML/1998/namespace","xml:lang",u);break;case"xmlSpace":yn(t,"http://www.w3.org/XML/1998/namespace","xml:space",u);break;case"is":In(t,"is",u);break;case"innerText":case"textContent":break;default:(!(2R)break;var re=z.transferSize,le=z.initiatorType;re&&DS(le)&&(z=z.responseEnd,w+=re*(z"u"?null:document;function BS(t,r,o){var u=Ys;if(u&&typeof r=="string"&&r){var p=jn(r);p='link[rel="'+t+'"][href="'+p+'"]',typeof o=="string"&&(p+='[crossorigin="'+o+'"]'),PS.has(p)||(PS.add(p),t={rel:t,crossOrigin:o,href:r},u.querySelector(p)===null&&(r=u.createElement("link"),wn(r,"link",t),Jt(r),u.head.appendChild(r)))}}function vT(t){_a.D(t),BS("dns-prefetch",t,null)}function ST(t,r){_a.C(t,r),BS("preconnect",t,r)}function wT(t,r,o){_a.L(t,r,o);var u=Ys;if(u&&t&&r){var p='link[rel="preload"][as="'+jn(r)+'"]';r==="image"&&o&&o.imageSrcSet?(p+='[imagesrcset="'+jn(o.imageSrcSet)+'"]',typeof o.imageSizes=="string"&&(p+='[imagesizes="'+jn(o.imageSizes)+'"]')):p+='[href="'+jn(t)+'"]';var g=p;switch(r){case"style":g=Xs(t);break;case"script":g=Js(t)}mr.has(g)||(t=b({rel:"preload",href:r==="image"&&o&&o.imageSrcSet?void 0:t,as:r},o),mr.set(g,t),u.querySelector(p)!==null||r==="style"&&u.querySelector(Rl(g))||r==="script"&&u.querySelector(Dl(g))||(r=u.createElement("link"),wn(r,"link",t),Jt(r),u.head.appendChild(r)))}}function CT(t,r){_a.m(t,r);var o=Ys;if(o&&t){var u=r&&typeof r.as=="string"?r.as:"script",p='link[rel="modulepreload"][as="'+jn(u)+'"][href="'+jn(t)+'"]',g=p;switch(u){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":g=Js(t)}if(!mr.has(g)&&(t=b({rel:"modulepreload",href:t},r),mr.set(g,t),o.querySelector(p)===null)){switch(u){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(o.querySelector(Dl(g)))return}u=o.createElement("link"),wn(u,"link",t),Jt(u),o.head.appendChild(u)}}}function ET(t,r,o){_a.S(t,r,o);var u=Ys;if(u&&t){var p=Ha(u).hoistableStyles,g=Xs(t);r=r||"default";var w=p.get(g);if(!w){var R={loading:0,preload:null};if(w=u.querySelector(Rl(g)))R.loading=5;else{t=b({rel:"stylesheet",href:t,"data-precedence":r},o),(o=mr.get(g))&&Wp(t,o);var z=w=u.createElement("link");Jt(z),wn(z,"link",t),z._p=new Promise(function(X,re){z.onload=X,z.onerror=re}),z.addEventListener("load",function(){R.loading|=1}),z.addEventListener("error",function(){R.loading|=2}),R.loading|=4,Gu(w,r,u)}w={type:"stylesheet",instance:w,count:1,state:R},p.set(g,w)}}}function _T(t,r){_a.X(t,r);var o=Ys;if(o&&t){var u=Ha(o).hoistableScripts,p=Js(t),g=u.get(p);g||(g=o.querySelector(Dl(p)),g||(t=b({src:t,async:!0},r),(r=mr.get(p))&&Yp(t,r),g=o.createElement("script"),Jt(g),wn(g,"link",t),o.head.appendChild(g)),g={type:"script",instance:g,count:1,state:null},u.set(p,g))}}function AT(t,r){_a.M(t,r);var o=Ys;if(o&&t){var u=Ha(o).hoistableScripts,p=Js(t),g=u.get(p);g||(g=o.querySelector(Dl(p)),g||(t=b({src:t,async:!0,type:"module"},r),(r=mr.get(p))&&Yp(t,r),g=o.createElement("script"),Jt(g),wn(g,"link",t),o.head.appendChild(g)),g={type:"script",instance:g,count:1,state:null},u.set(p,g))}}function qS(t,r,o,u){var p=(p=ie.current)?qu(p):null;if(!p)throw Error(a(446));switch(t){case"meta":case"title":return null;case"style":return typeof o.precedence=="string"&&typeof o.href=="string"?(r=Xs(o.href),o=Ha(p).hoistableStyles,u=o.get(r),u||(u={type:"style",instance:null,count:0,state:null},o.set(r,u)),u):{type:"void",instance:null,count:0,state:null};case"link":if(o.rel==="stylesheet"&&typeof o.href=="string"&&typeof o.precedence=="string"){t=Xs(o.href);var g=Ha(p).hoistableStyles,w=g.get(t);if(w||(p=p.ownerDocument||p,w={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},g.set(t,w),(g=p.querySelector(Rl(t)))&&!g._p&&(w.instance=g,w.state.loading=5),mr.has(t)||(o={rel:"preload",as:"style",href:o.href,crossOrigin:o.crossOrigin,integrity:o.integrity,media:o.media,hrefLang:o.hrefLang,referrerPolicy:o.referrerPolicy},mr.set(t,o),g||xT(p,t,o,w.state))),r&&u===null)throw Error(a(528,""));return w}if(r&&u!==null)throw Error(a(529,""));return null;case"script":return r=o.async,o=o.src,typeof o=="string"&&r&&typeof r!="function"&&typeof r!="symbol"?(r=Js(o),o=Ha(p).hoistableScripts,u=o.get(r),u||(u={type:"script",instance:null,count:0,state:null},o.set(r,u)),u):{type:"void",instance:null,count:0,state:null};default:throw Error(a(444,t))}}function Xs(t){return'href="'+jn(t)+'"'}function Rl(t){return'link[rel="stylesheet"]['+t+"]"}function GS(t){return b({},t,{"data-precedence":t.precedence,precedence:null})}function xT(t,r,o,u){t.querySelector('link[rel="preload"][as="style"]['+r+"]")?u.loading=1:(r=t.createElement("link"),u.preload=r,r.addEventListener("load",function(){return u.loading|=1}),r.addEventListener("error",function(){return u.loading|=2}),wn(r,"link",o),Jt(r),t.head.appendChild(r))}function Js(t){return'[src="'+jn(t)+'"]'}function Dl(t){return"script[async]"+t}function VS(t,r,o){if(r.count++,r.instance===null)switch(r.type){case"style":var u=t.querySelector('style[data-href~="'+jn(o.href)+'"]');if(u)return r.instance=u,Jt(u),u;var p=b({},o,{"data-href":o.href,"data-precedence":o.precedence,href:null,precedence:null});return u=(t.ownerDocument||t).createElement("style"),Jt(u),wn(u,"style",p),Gu(u,o.precedence,t),r.instance=u;case"stylesheet":p=Xs(o.href);var g=t.querySelector(Rl(p));if(g)return r.state.loading|=4,r.instance=g,Jt(g),g;u=GS(o),(p=mr.get(p))&&Wp(u,p),g=(t.ownerDocument||t).createElement("link"),Jt(g);var w=g;return w._p=new Promise(function(R,z){w.onload=R,w.onerror=z}),wn(g,"link",u),r.state.loading|=4,Gu(g,o.precedence,t),r.instance=g;case"script":return g=Js(o.src),(p=t.querySelector(Dl(g)))?(r.instance=p,Jt(p),p):(u=o,(p=mr.get(g))&&(u=b({},o),Yp(u,p)),t=t.ownerDocument||t,p=t.createElement("script"),Jt(p),wn(p,"link",u),t.head.appendChild(p),r.instance=p);case"void":return null;default:throw Error(a(443,r.type))}else r.type==="stylesheet"&&(r.state.loading&4)===0&&(u=r.instance,r.state.loading|=4,Gu(u,o.precedence,t));return r.instance}function Gu(t,r,o){for(var u=o.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),p=u.length?u[u.length-1]:null,g=p,w=0;w title"):null)}function TT(t,r,o){if(o===1||r.itemProp!=null)return!1;switch(t){case"meta":case"title":return!0;case"style":if(typeof r.precedence!="string"||typeof r.href!="string"||r.href==="")break;return!0;case"link":if(typeof r.rel!="string"||typeof r.href!="string"||r.href===""||r.onLoad||r.onError)break;return r.rel==="stylesheet"?(t=r.disabled,typeof r.precedence=="string"&&t==null):!0;case"script":if(r.async&&typeof r.async!="function"&&typeof r.async!="symbol"&&!r.onLoad&&!r.onError&&r.src&&typeof r.src=="string")return!0}return!1}function WS(t){return!(t.type==="stylesheet"&&(t.state.loading&3)===0)}function kT(t,r,o,u){if(o.type==="stylesheet"&&(typeof u.media!="string"||matchMedia(u.media).matches!==!1)&&(o.state.loading&4)===0){if(o.instance===null){var p=Xs(u.href),g=r.querySelector(Rl(p));if(g){r=g._p,r!==null&&typeof r=="object"&&typeof r.then=="function"&&(t.count++,t=Fu.bind(t),r.then(t,t)),o.state.loading|=4,o.instance=g,Jt(g);return}g=r.ownerDocument||r,u=GS(u),(p=mr.get(p))&&Wp(u,p),g=g.createElement("link"),Jt(g);var w=g;w._p=new Promise(function(R,z){w.onload=R,w.onerror=z}),wn(g,"link",u),o.instance=g}t.stylesheets===null&&(t.stylesheets=new Map),t.stylesheets.set(o,r),(r=o.state.preload)&&(o.state.loading&3)===0&&(t.count++,o=Fu.bind(t),r.addEventListener("load",o),r.addEventListener("error",o))}}var Xp=0;function RT(t,r){return t.stylesheets&&t.count===0&&Wu(t,t.stylesheets),0Xp?50:800)+r);return t.unsuspend=o,function(){t.unsuspend=null,clearTimeout(u),clearTimeout(p)}}:null}function Fu(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Wu(this,this.stylesheets);else if(this.unsuspend){var t=this.unsuspend;this.unsuspend=null,t()}}}var Ku=null;function Wu(t,r){t.stylesheets=null,t.unsuspend!==null&&(t.count++,Ku=new Map,r.forEach(DT,t),Ku=null,Fu.call(t))}function DT(t,r){if(!(r.state.loading&4)){var o=Ku.get(t);if(o)var u=o.get(null);else{o=new Map,Ku.set(t,o);for(var p=t.querySelectorAll("link[data-precedence],style[data-precedence]"),g=0;g"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(s)}catch(e){console.error(e)}}return s(),ad.exports=gw(),ad.exports}var ld=mw();const cd=Zr(ld),eo=[],yw=50;let ud=!1;function fg(){try{return window.G7Core?.devTools}catch{return}}function Qi(s,e,n){const a=fg();if(ud&&a?.isEnabled?.()){a.trackLog?.(s,e,n);return}ud||(s==="error"||s==="warn")&&eo.length{n.isDebugEnabled()&&console.log(e,...a),Qi("log",s,a)},warn:(...a)=>{n.isDebugEnabled()&&console.warn(e,...a),Qi("warn",s,a)},error:(...a)=>{n.isDebugEnabled()&&console.error(e,...a),Qi("error",s,a)}}}to.getInstance();const Zi=dt("networkResilience"),hg=2,pg=300,gg=2e3,bw=15e3;function fd(s){return s?.name==="AbortError"}function mg(s){return fd(s)?!1:s instanceof TypeError}let Nl=!1,yg=!1;function hd(){return Nl}function vw(){typeof window>"u"||yg||(yg=!0,window.addEventListener("pagehide",()=>{Nl=!0}),window.addEventListener("pageshow",s=>{s.persisted&&(Nl=!1)}),window.addEventListener("beforeunload",()=>{Nl=!0}))}function bg(s,e,n){const a=Math.min(e*Math.pow(2,s),n),i=a*.25*(Math.random()*2-1);return Math.max(0,Math.round(a+i))}function vg(s){return new Promise(e=>setTimeout(e,s))}async function Il(s,e={}){const{retries:n=hg,baseDelayMs:a=pg,maxDelayMs:i=gg,timeoutMs:l=bw,label:c=s,init:d}=e,f=n+1;let h;for(let m=0;m0?new AbortController:null;try{const _={...d};if(v){const x=d?.signal;x&&(x.aborted?v.abort():x.addEventListener("abort",()=>v.abort(),{once:!0})),_.signal=v.signal,S=setTimeout(()=>{b=!0,v.abort()},l)}return await fetch(s,_)}catch(_){if(h=_,fd(_)&&!b||!(b||mg(_)))throw _;if(hd())throw Zi.warn(`Document unloading, aborting retries: ${c}`),_;if(m===f-1)throw Zi.warn(`All ${f} attempts failed: ${c}`,_),_;const L=bg(m,a,i);Zi.warn(`Network failure (attempt ${m+1}/${f}), retrying in ${L}ms: ${c}`),await vg(L)}finally{S!==void 0&&clearTimeout(S)}}throw h}async function Sg(s,e={},n={}){const{retries:a=hg,baseDelayMs:i=pg,maxDelayMs:l=gg,label:c=s}=n,d=a+1;for(let f=0;f{const i=document.createElement("script");i.src=s,i.async=!1;for(const[l,c]of Object.entries(e))l==="id"?i.id=c:i.setAttribute(l,c);i.onload=()=>n(),i.onerror=()=>a(new Error(`Failed to load script: ${s}`)),document.head.appendChild(i)})}const wg="extension",es="extensionless",ww="file";function Cw(){const s=globalThis?.G7Config;if(globalThis?.__g7AssetUrlMode===es)return es;const n=s?.assetUrlMode;return n===es||n===wg?n:s?.settings?.general?.asset_url_mode===es?es:wg}function Cg(){return Cw()===es}function Ir(s,e,n,a){const i=s.replace(/\/+$/,""),l=e.replace(/^\.+/,""),c=Cg()?i:`${i}.${l}`,d=[];return a&&d.push(a.replace(/^[?&]+/,"")),n!=null&&n!==""&&d.push(`v=${n}`),d.length>0?`${c}?${d.join("&")}`:c}function Aa(s){if(!s||!Cg())return s;const e=globalThis?.location?.origin,n=e&&s.startsWith(e)?s.slice(e.length):s;if(!n.startsWith("/api/"))return s;const[a,i]=Ew(n),l=a.match(/^\/api\/(modules|plugins)\/bundle\.(js|css)$/);if(l)return Eg(`/api/${l[1]}/bundle/${l[2]}`,i);const c=a.match(/^\/api\/(templates|modules|plugins)\/assets\/([^/]+)\/(.+)$/);if(c){const[,f,h,m]=c,b=`${ww}=${encodeURIComponent(decodeURIComponent(m))}`;return`/api/${f}/assets/${h}?${b}${i?`&${i}`:""}`}const d=a.match(/^(\/api\/.+)\.(json|js|css)$/);return d?Eg(d[1],i):s}function Ew(s){const e=s.indexOf("?");return e===-1?[s,""]:[s.slice(0,e),s.slice(e+1)]}function Eg(s,e){return e?`${s}?${e}`:s}const jr=dt("ComponentRegistry");class un extends Error{constructor(n,a,i){super(n);$(this,"code");$(this,"details");this.code=a,this.details=i,this.name="ComponentRegistryError"}}const Fn=class Fn{constructor(){$(this,"registry",{});$(this,"manifest",null);$(this,"loadingState","idle");$(this,"error",null);$(this,"templateId",null);$(this,"templateType",null)}static getInstance(){return Fn.instance||(Fn.instance=new Fn),Fn.instance}static createIsolatedInstance(){return new Fn}static resetInstance(){Fn.instance=null}async loadComponents(e,n){if(this.loadingState==="loading")throw new un("Components are already being loaded","LOADING_IN_PROGRESS");if(this.loadingState==="loaded"&&this.templateId===e&&this.templateType===n){jr.log("Components already loaded for template:",e,n);return}this.loadingState="loading",this.templateId=e,this.templateType=n,this.error=null;try{await this.loadManifest(),await this.loadComponentBundle(),this.loadingState="loaded",jr.log("Successfully loaded components:",Object.keys(this.registry).length)}catch(a){throw this.loadingState="error",this.error=a instanceof Error?a:new Error(String(a)),new un(`Failed to load components: ${this.error.message}`,"LOAD_FAILED",{originalError:this.error})}}async loadManifest(){try{if(!this.templateId)throw new un("Template ID not set","TEMPLATE_ID_NOT_SET");const e=`${this.templateId}:${this.templateType}`;if(Fn.manifestCache.has(e)){this.manifest=Fn.manifestCache.get(e),jr.log("Manifest loaded from cache:",this.manifest.templateId);return}const n=Ir(`/api/templates/${this.templateId}/components`,"json"),a=await Il(n,{label:"components.json"});if(!a.ok)throw new un(`Failed to fetch manifest: ${a.status} ${a.statusText}`,"MANIFEST_FETCH_FAILED",{status:a.status,statusText:a.statusText});const i=await a.json();this.validateManifest(i),this.manifest=i,Fn.manifestCache.set(e,i),jr.log("Manifest loaded and cached:",i.templateId)}catch(e){throw e instanceof un?e:new un("Failed to load component manifest","MANIFEST_LOAD_FAILED",{originalError:e})}}validateManifest(e){if(!e.version)throw new un("Manifest missing required field: version","MANIFEST_INVALID",{field:"version"});if(!e.templateId)throw new un("Manifest missing required field: templateId","MANIFEST_INVALID",{field:"templateId"});if(!e.components||typeof e.components!="object")throw new un("Manifest missing required field: components","MANIFEST_INVALID",{field:"components"});const n=["basic","composite","layout"];for(const a of n){if(!Array.isArray(e.components[a]))throw new un(`Manifest field 'components.${a}' must be an array`,"MANIFEST_INVALID",{field:`components.${a}`,value:e.components[a]});e.components[a].forEach((i,l)=>{if(!i.name||typeof i.name!="string")throw new un(`Invalid component metadata at ${a}[${l}]: missing or invalid 'name'`,"MANIFEST_INVALID",{type:a,index:l,metadata:i});if(!i.type||typeof i.type!="string")throw new un(`Invalid component metadata at ${a}[${l}]: missing or invalid 'type'`,"MANIFEST_INVALID",{type:a,index:l,metadata:i})})}jr.log("Manifest validation passed")}async loadComponentBundle(){try{if(!this.templateId)throw new un("Template ID not set","TEMPLATE_ID_NOT_SET");const e=this.getGlobalVariableName(),n=window[e];if(!n||typeof n!="object")throw new un(`Component bundle not loaded. Expected global variable: ${e}. Ensure admin.blade.php includes the IIFE bundle script.`,"BUNDLE_NOT_LOADED",{expectedVariable:e});await this.registerComponentsFromManifest(n),jr.log("Component bundle loaded from global variable:",e)}catch(e){throw e instanceof un?e:new un("Failed to load component bundle","BUNDLE_LOAD_FAILED",{originalError:e})}}getGlobalVariableName(){if(!this.templateId)throw new un("Template ID not set","TEMPLATE_ID_NOT_SET");return this.templateId.split(/[-_]/).map(e=>e.charAt(0).toUpperCase()+e.slice(1).toLowerCase()).join("")}async registerComponentsFromManifest(e){if(!this.manifest)throw new un("Manifest not loaded","MANIFEST_NOT_LOADED");const n=["basic","composite","layout"];for(const a of n){const i=this.manifest.components[a]||[];for(const l of i){const c=l.name,d=e[c];if(!d){jr.warn(`Component '${c}' not found in bundle`);continue}this.registerComponent(c,d,l)}}}registerComponent(e,n,a){this.registry[e]&&jr.warn(`Component '${e}' already registered, overwriting`);const i=Ye.memo(n);this.registry[e]={component:i,metadata:a},jr.log(`[ComponentRegistry] Registered component: ${e} (${a.type})`)}getComponent(e){const n=this.registry[e];return n?n.component:null}getMetadata(e){const n=this.registry[e];return n?n.metadata:null}hasComponent(e){return e in this.registry}getComponentsByType(e){return Object.entries(this.registry).filter(([n,a])=>a.metadata.type===e).map(([n,a])=>n)}getAllComponents(){return Object.keys(this.registry)}getComponentMap(){const e={};for(const[n,a]of Object.entries(this.registry))e[n]=a.component;return e}getLoadingState(){return this.loadingState}getError(){return this.error}getTemplateId(){return this.templateId}getManifest(){return this.manifest}clear(){this.registry={},this.manifest=null,this.loadingState="idle",this.error=null,this.templateId=null,this.templateType=null,jr.log("Registry cleared")}};$(Fn,"instance",null),$(Fn,"manifestCache",new Map);let yr=Fn;const _g=dt("TranslationEngine");class pd extends Error{constructor(n,a,i){super(n);$(this,"key");$(this,"locale");this.key=a,this.locale=i,this.name="TranslationError"}}const $n=class $n{constructor(e={}){$(this,"cache",new Map);$(this,"translations",new Map);$(this,"options");$(this,"cacheVersion",0);this.options={defaultLocale:e.defaultLocale||"ko",fallbackLocale:e.fallbackLocale||"en",cacheTTL:e.cacheTTL||3e5}}static getInstance(e={}){return $n.instance||($n.instance=new $n(e)),$n.instance}static resetInstance(){$n.instance=null}setCacheVersion(e){this.cacheVersion!==e&&(_g.log("Cache version updated:",this.cacheVersion,"->",e),this.cacheVersion=e,this.cache.clear())}getCacheVersion(){return this.cacheVersion}async loadTranslations(e,n,a="/api",i=!1){const l=`${e}:${n}`;if(!i){const c=this.getFromCache(l);if(c)return this.translations.set(l,c),c}try{const c=[];i&&c.push(`_=${Date.now()}`);const d=Ir(`${a}/templates/${e}/lang/${n}`,"json",this.cacheVersion>0?this.cacheVersion:null,c.length>0?c.join("&"):void 0),f=await fetch(d);if(!f.ok)throw new pd(`Failed to load translations: ${f.statusText}`,void 0,n);const m=await f.json();return this.saveToCache(l,m),this.translations.set(l,m),m}catch(c){throw new pd(`Failed to fetch translations for ${n}: ${c instanceof Error?c.message:String(c)}`,void 0,n)}}resolveTranslations(e,n,a){let i=e;const l=5;let c=0;for(;c"="+this.translate(m,n,void 0,a)),i===d)break;c++}return i.replace($n.TRANSLATION_PATTERN,(d,f,h)=>{const{cleanedParams:m,trailing:b}=this.separateTrailingText(h);return this.translate(f,n,m,a)+b})}translate(e,n,a,i){const l=this.getTranslation(e,n),c=a&&this.cleanParamsStr(a);if(c){const d=this.parseParams(c,i);return this.replaceParams(l,d)}return l}getTranslation(e,n){const{templateId:a,locale:i}=n,l=`${a}:${i}`,c=this.translations.get(l);if(c){const d=this.getNestedValue(c,e);if(d!==null)return d}if(i!==this.options.fallbackLocale){const d=`${a}:${this.options.fallbackLocale}`,f=this.translations.get(d);if(f){const h=this.getNestedValue(f,e);if(h!==null)return h}}return e}getNestedValue(e,n){const a=n.split(".");let i=e;for(const l of a){if(i==null||typeof i!="object")return null;i=i[l]}return typeof i=="string"?i:null}setTranslationValue(e,n,a,i){const l=`${e}:${n}`,c=this.translations.get(l)??{},d=a.split(".");let f=c;for(let h=0;h(l.push(f),`__PLACEHOLDER_${l.length-1}__`)).matchAll($n.PARAM_PATTERN);for(const f of d){const[,h,m]=f,b=m.replace(/__PLACEHOLDER_(\d+)__/g,(S,v)=>l[parseInt(v,10)]);a[h.trim()]=this.resolveParamValue(b.trim(),n)}return a}resolveParamValue(e,n){if(e.startsWith("{{")&&e.endsWith("}}")){const a=e.slice(2,-2).trim();if(/[|&()[\]!?:+\-*/%<>=\s]/.test(a)&&n)try{const l=Dd.evaluateExpression(a,n);return String(l??"")}catch(l){return _g.error("Expression evaluation failed:",a,l),""}else{const l=this.getNestedDataValue(n,a);return String(l??"")}}return e}separateTrailingText(e){if(!e)return{cleanedParams:void 0,trailing:""};if(e.trimEnd().endsWith("}}"))return{cleanedParams:e,trailing:""};if(!(e.startsWith("|")?e.slice(1):e).includes("="))return{cleanedParams:void 0,trailing:e};const a=e.match(/(\s+[^\p{L}\w=|&\s][^\p{L}\w=]*\s*)$/u);return a?{cleanedParams:e.slice(0,-a[1].length),trailing:a[1]}:{cleanedParams:e,trailing:""}}cleanParamsStr(e){return this.separateTrailingText(e).cleanedParams||""}getNestedDataValue(e,n){if(!e)return;const a=n.split(".");let i=e;for(const l of a){if(i==null)return;i=i[l]}return i}replaceParams(e,n){let a=e;for(const[i,l]of Object.entries(n)){const c=new RegExp(`\\{\\{${i}\\}\\}`,"g");a=a.replace(c,String(l));const d=new RegExp(`\\{${i}\\}`,"g");a=a.replace(d,String(l))}return a}getFromCache(e){const n=this.cache.get(e);return n?Date.now()-n.timestamp>this.options.cacheTTL?(this.cache.delete(e),null):n.data:null}saveToCache(e,n){this.cache.set(e,{data:n,timestamp:Date.now()})}clearCache(){this.cache.clear(),this.translations.clear()}pruneCache(){const e=Date.now(),n=[];for(const[a,i]of this.cache.entries())e-i.timestamp>this.options.cacheTTL&&n.push(a);for(const a of n)this.cache.delete(a)}getCacheStats(){const e=Date.now();let n=0;for(const a of this.cache.values())e-a.timestamp>this.options.cacheTTL&&n++;return{size:this.cache.size,expired:n}}};$($n,"instance",null),$($n,"TRANSLATION_PATTERN",/\$t:(?:defer:)?([a-zA-Z0-9._-]+)(\|(?:(?!\$t:).)+)?/g),$($n,"NESTED_TRANSLATION_PARAM_PATTERN",/=(\$t:([a-zA-Z0-9._-]+))(?=[|&\s]|$)/g),$($n,"PARAM_PATTERN",/([^=|&]+)=([^|&]+)/g);let xa=$n,gd=null;function _w(s){return gd||(gd=new xa(s)),gd}const Ag=Object.freeze(Object.defineProperty({__proto__:null,TranslationEngine:xa,TranslationError:pd,getTranslationEngine:_w},Symbol.toStringTag,{value:"Module"})),ts=dt("PipeRegistry");function xg(s,e){const n=s.getFullYear(),a=String(s.getMonth()+1).padStart(2,"0"),i=String(s.getDate()).padStart(2,"0"),l=String(s.getHours()).padStart(2,"0"),c=String(s.getMinutes()).padStart(2,"0"),d=String(s.getSeconds()).padStart(2,"0");return e.replace("YYYY",String(n)).replace("YY",String(n).slice(-2)).replace("MM",a).replace("M",String(s.getMonth()+1)).replace("DD",i).replace("D",String(s.getDate())).replace("HH",l).replace("H",String(s.getHours())).replace("mm",c).replace("m",String(s.getMinutes())).replace("ss",d).replace("s",String(s.getSeconds()))}function Aw(s,e="ko"){const a=new Date().getTime()-s.getTime(),i=Math.floor(a/1e3),l=Math.floor(i/60),c=Math.floor(l/60),d=Math.floor(c/24),f=Math.floor(d/7),h=Math.floor(d/30),m=Math.floor(d/365);return e==="ko"?i<60?"방금 전":l<60?`${l}분 전`:c<24?`${c}시간 전`:d<7?`${d}일 전`:f<4?`${f}주 전`:h<12?`${h}개월 전`:`${m}년 전`:i<60?"just now":l<60?`${l} minute${l===1?"":"s"} ago`:c<24?`${c} hour${c===1?"":"s"} ago`:d<7?`${d} day${d===1?"":"s"} ago`:f<4?`${f} week${f===1?"":"s"} ago`:h<12?`${h} month${h===1?"":"s"} ago`:`${m} year${m===1?"":"s"} ago`}function md(s){if(s==null)return null;if(s instanceof Date)return s;if(typeof s=="number")return new Date(s);if(typeof s=="string"){const e=new Date(s);return isNaN(e.getTime())?null:e}return null}const no={date:{fn:(s,e="YYYY-MM-DD")=>{const n=md(s);return n?xg(n,e):""},description:"날짜 포맷 (기본: YYYY-MM-DD)"},datetime:{fn:(s,e="YYYY-MM-DD HH:mm")=>{const n=md(s);return n?xg(n,e):""},description:"날짜+시간 포맷 (기본: YYYY-MM-DD HH:mm)"},relativeTime:{fn:(s,e="ko")=>{const n=md(s);return n?Aw(n,e):""},description:'상대 시간 표시 (예: "3분 전")'},number:{fn:(s,e)=>{const n=Number(s);if(isNaN(n))return String(s??"");const a={};return e!==void 0&&(a.minimumFractionDigits=e,a.maximumFractionDigits=e),n.toLocaleString(void 0,a)},description:"숫자 포맷 (천단위 구분, 선택적 소수점)"},truncate:{fn:(s,e=100,n="...")=>typeof s!="string"?String(s??""):s.length<=e?s:s.slice(0,e)+n,description:'문자열 자르기 (기본: 100자, 접미사: "...")'},uppercase:{fn:s=>typeof s!="string"?String(s??""):s.toUpperCase(),description:"대문자 변환"},lowercase:{fn:s=>typeof s!="string"?String(s??""):s.toLowerCase(),description:"소문자 변환"},stripHtml:{fn:s=>typeof s!="string"?String(s??""):s.replace(/<[^>]*>/g,""),description:"HTML 태그 제거"},default:{fn:(s,e="")=>s==null||s===""?e:s,description:"기본값 설정 (null, undefined, 빈문자열 시)"},fallback:{fn:(s,e)=>s??e,description:"폴백 값 설정 (null, undefined 시만)"},first:{fn:s=>{if(Array.isArray(s))return s[0]},description:"배열의 첫 번째 요소"},last:{fn:s=>{if(Array.isArray(s))return s[s.length-1]},description:"배열의 마지막 요소"},join:{fn:(s,e=", ")=>Array.isArray(s)?s.join(e):"",description:'배열을 문자열로 결합 (기본 구분자: ", ")'},length:{fn:s=>Array.isArray(s)||typeof s=="string"?s.length:0,description:"배열/문자열 길이"},filterBy:{fn:(s,e,n)=>Array.isArray(s)?Array.isArray(e)?s.filter(a=>{const i=n?a?.[n]:a;return e.includes(i)}):s:[],description:"배열 필터링 (allowList에 포함된 항목만 반환)"},keys:{fn:s=>s==null||typeof s!="object"?[]:Object.keys(s),description:"객체의 키 배열"},values:{fn:s=>s==null||typeof s!="object"?[]:Object.values(s),description:"객체의 값 배열"},json:{fn:(s,e)=>{try{return JSON.stringify(s,null,e)}catch{return""}},description:"JSON 문자열로 변환"},localized:{fn:(s,e)=>s==null?"":typeof s=="string"?s:typeof s!="object"?String(s):s[e||"ko"]||s.ko||s.en||Object.values(s)[0]||"",description:"다국어 객체에서 로케일에 맞는 값 추출"}},ns=new Map,Ci=class Ci{static getInstance(){return Ci.instance||(Ci.instance=new Ci),Ci.instance}register(e,n,a){if(no[e]){ts.warn(`[PipeRegistry] 내장 파이프를 덮어쓸 수 없습니다: ${e}`);return}ns.set(e,{fn:n,description:a}),ts.log(`[PipeRegistry] 커스텀 파이프 등록됨: ${e}`)}unregister(e){if(no[e])return ts.warn(`[PipeRegistry] 내장 파이프는 해제할 수 없습니다: ${e}`),!1;const n=ns.delete(e);return n&&ts.log(`[PipeRegistry] 커스텀 파이프 해제됨: ${e}`),n}get(e){const n=no[e];return n?n.fn:ns.get(e)?.fn}has(e){return!!no[e]||ns.has(e)}execute(e,n,a=[]){const i=this.get(e);if(!i)return ts.warn(`[PipeRegistry] 알 수 없는 파이프: ${e}`),n;try{return i(n,...a)}catch(l){return ts.error(`[PipeRegistry] 파이프 실행 오류 (${e}):`,l),n}}list(){const e=[];for(const[n,a]of Object.entries(no))e.push({name:n,description:a.description,type:"built-in"});for(const[n,a]of ns.entries())e.push({name:n,description:a.description,type:"custom"});return e.sort((n,a)=>n.name.localeCompare(a.name))}clearCustomPipes(){ns.clear()}};$(Ci,"instance",null);let jl=Ci;jl.getInstance();function xw(s){const e=s.trim(),n=e.indexOf("(");if(n===-1)return{name:e,args:[]};const a=e.slice(0,n).trim(),i=e.slice(n+1,-1);if(!i.trim())return{name:a,args:[]};const l=[];let c="",d=null,f=0;for(let h=0;h0?i[h-1]:"")==="\\"){c+=m;continue}if((m==='"'||m==="'")&&!d){d=m;continue}if(m===d){d=null;continue}if(d){c+=m;continue}if(m==="("||m==="["||m==="{"){f++,c+=m;continue}if(m===")"||m==="]"||m==="}"){f--,c+=m;continue}if(m===","&&f===0){l.push(Tg(c.trim())),c="";continue}c+=m}return c.trim()&&l.push(Tg(c.trim())),{name:a,args:l}}function Tg(s){if(s==="null")return null;if(s==="undefined")return;if(s==="true")return!0;if(s==="false")return!1;const e=Number(s);return!isNaN(e)&&s!==""?e:s}function Tw(s){const e=[];let n="",a=null,i=0;for(let l=0;l0?s[l-1]:"",f=l0?s[a-1]:"",c=a0){a--;continue}return null}}return a===0?e.trim():null}function Rw(s){const e=[];if(typeof s!="string")return e;for(let n=0;n0){i--;continue}if(s[c+1]==="}"){l=c;break}break}}l!==-1&&(e.push({start:n,end:l+2,expr:s.slice(n+2,l)}),n=l+1)}return e}function ro(s){return/[?:|&!+\-*/<>=()[\]{}]/.test(s)}function kg(s){const e=typeof s=="string"?s.trim():"";return e===""?"empty":yd.has(e)?"literal":qn(e)?"pipe":ro(e)?"expression":"path"}function bd(s,e,n,a){const i=typeof s=="string"?s.trim():"",l=kg(i),c=a?.skipCache?{skipCache:!0}:void 0;if(l==="empty"){a?.onEmpty?.(s);return}if(l==="literal")return yd.get(i);try{return l==="pipe"?n.evaluatePipeExpression(i,e,c,a?.trackingInfo):l==="expression"?n.evaluateExpression(i,e,a?.trackingInfo):n.resolve(i,e,c,a?.trackingInfo)}catch(d){if(a?.onError)return a.onError(d,i);throw d}}const tr="raw:";function Rg(s){return s.startsWith(tr)?s.slice(tr.length):s}const ao="",pi="",ka="";function io(s){return ao+s+pi}function Hl(s){return s.length>=2&&s.charCodeAt(0)===64976&&s.charCodeAt(s.length-1)===64977}function zl(s){return s.includes(ao)}function vd(s){return s.slice(1,-1)}function rs(s){if(typeof s=="string")return io(s);if(Array.isArray(s))return s.map(rs);if(s&&typeof s=="object"){const e={};for(const[n,a]of Object.entries(s))e[n]=rs(a);return e}return s}function Ra(s){if(typeof s=="string")return Hl(s)?vd(s):zl(s)||s.includes(pi)?s.split(ao).join("").split(pi).join(""):s;if(Array.isArray(s)){let e=!1;const n=s.map(a=>{const i=Ra(a);return i!==a&&(e=!0),i});return e?n:s}if(s&&typeof s=="object"){if(s.$$typeof!==void 0)return s;let e=!1;const n={};for(const[a,i]of Object.entries(s)){const l=Ra(i);l!==i&&(e=!0),n[a]=l}return e?n:s}return s}const so=new Set(["constructor","__proto__","prototype","__lookupGetter__","__lookupSetter__","__defineGetter__","__defineSetter__"]),Dw=new Set(["Function","eval","globalThis","window","self","document","require","module","process","Reflect","Proxy","WebAssembly","import","constructor"]);function Dg(s,e){if(s!==null&&(typeof s=="object"||typeof s=="function")&&Og.has(s))throw new Error(`Object.${e} on a built-in global object is not allowed`);return s}const Ow=Object.freeze({keys:Object.keys,values:Object.values,entries:Object.entries,assign:(s,...e)=>{const n=Dg(s,"assign");for(const a of e)if(a!=null)for(const i of Object.keys(a)){if(so.has(i))throw new Error(`Access to "${i}" is forbidden`);Td(n,i,a[i])}return n},freeze:s=>Object.freeze(Dg(s,"freeze")),fromEntries:Object.fromEntries,create:Object.create,isFrozen:Object.isFrozen}),Sd={Math,JSON,Date,Array,Object:Ow,Number,String,Boolean,Set,Map,WeakSet,WeakMap,parseInt,parseFloat,isNaN,isFinite},wd={Date,Set,Map,WeakSet,WeakMap,Array,Number,String,Boolean,Object},Og=new Set([...Object.values(Sd),...Object.values(wd)].filter(s=>s!==null&&(typeof s=="object"||typeof s=="function"))),Ul=new Set(["function"]),br=Symbol("short-circuit");class Cd{constructor(e){$(this,"value");this.value=e}}class Ed{}class _d{}function Lw(s){return/[A-Za-z_$]/.test(s)}function Mw(s){return/[A-Za-z0-9_$]/.test(s)}function gi(s){return s>="0"&&s<="9"}function $w(s,e,n){let a=e+1;for(;a0;){const h=s[n];if(h==="{")f++,n++;else if(h==="}"){if(f--,f===0)break;n++}else h==='"'||h==="'"?n=$w(s,n,h):h==="`"?n=Lg(s,n).end:n++}if(f!==0)throw new Error("Unterminated ${...} in template literal");i.push(s.slice(d,n)),n++;continue}l+=c,n++}throw new Error(`Unterminated template literal at position ${e}`)}function Nw(s){const e=[];let n=0;const a=s.length,i=l=>n+l=a)throw new Error(`Unterminated string literal at position ${h}`);n++,e.push({type:"str",value:b,pos:h});continue}if(Lw(l)){const h=n;for(n++;n"?(e.push({type:"punct",value:"=>",pos:c}),n+=2):i(1)==="="?i(2)==="="?(e.push({type:"punct",value:"===",pos:c}),n+=3):(e.push({type:"punct",value:"==",pos:c}),n+=2):(e.push({type:"punct",value:"=",pos:c}),n+=1);continue}case"!":{i(1)==="="?i(2)==="="?(e.push({type:"punct",value:"!==",pos:c}),n+=3):(e.push({type:"punct",value:"!=",pos:c}),n+=2):(e.push({type:"punct",value:"!",pos:c}),n+=1);continue}case"<":{if(i(1)==="=")e.push({type:"punct",value:"<=",pos:c}),n+=2;else{if(i(1)==="<")throw new Error("Bitwise/shift operators are not allowed");e.push({type:"punct",value:"<",pos:c}),n+=1}continue}case">":{if(i(1)==="=")e.push({type:"punct",value:">=",pos:c}),n+=2;else{if(i(1)===">")throw new Error("Bitwise/shift operators are not allowed");e.push({type:"punct",value:">",pos:c}),n+=1}continue}case"&":{if(i(1)==="&")e.push({type:"punct",value:"&&",pos:c}),n+=2;else throw new Error("Bitwise operators are not allowed");continue}case"|":{if(i(1)==="|")e.push({type:"punct",value:"||",pos:c}),n+=2;else throw new Error("Bitwise operators are not allowed");continue}case"?":{i(1)==="."&&!gi(i(2))?(e.push({type:"punct",value:"?.",pos:c}),n+=2):i(1)==="?"?(e.push({type:"punct",value:"??",pos:c}),n+=2):(e.push({type:"punct",value:"?",pos:c}),n+=1);continue}case"+":{if(i(1)==="+")throw new Error("Increment operator is not allowed");if(i(1)==="=")throw new Error("Assignment operators are not allowed");e.push({type:"punct",value:"+",pos:c}),n+=1;continue}case"-":{if(i(1)==="-")throw new Error("Decrement operator is not allowed");if(i(1)==="=")throw new Error("Assignment operators are not allowed");e.push({type:"punct",value:"-",pos:c}),n+=1;continue}case"*":{if(i(1)==="=")throw new Error("Assignment operators are not allowed");if(i(1)==="*")throw new Error("Exponentiation operator is not supported");e.push({type:"punct",value:"*",pos:c}),n+=1;continue}case"/":{if(i(1)==="=")throw new Error("Assignment operators are not allowed");e.push({type:"punct",value:"/",pos:c}),n+=1;continue}case"%":{if(i(1)==="=")throw new Error("Assignment operators are not allowed");e.push({type:"punct",value:"%",pos:c}),n+=1;continue}case"~":case"^":throw new Error("Bitwise operators are not allowed");case".":case":":case"(":case")":case"[":case"]":case"{":case"}":case",":e.push({type:"punct",value:l,pos:c}),n+=1;continue;case";":e.push({type:"punct",value:";",pos:c}),n+=1;continue;default:throw new Error(`Unexpected character "${l}" at position ${c}`)}}return e}const Iw={"??":1,"||":1,"&&":2,"===":3,"!==":3,"==":3,"!=":3,"<":4,">":4,"<=":4,">=":4,"+":5,"-":5,"*":6,"/":6,"%":6},jw=new Set(["??","||","&&"]);class Hw{constructor(e){$(this,"tokens");$(this,"pos",0);this.tokens=e}atEnd(){return this.pos>=this.tokens.length}peek(e=0){const n=this.pos+e;return n"}"`);this.pos++}parseExpression(){const e=this.tryParseArrow();return e||this.parseTernary()}tryParseArrow(){const e=this.pos,n=this.peek();if(!n)return null;if(n.type==="ident"&&this.isPunct("=>",1)&&!Ul.has(String(n.value))){this.pos+=1,this.pos+=1;const{body:a,isBlock:i}=this.parseArrowBody();return{type:"Arrow",params:[{name:String(n.value),default:null}],body:a,isBlock:i}}if(n.type==="punct"&&n.value==="("){this.pos+=1;const a=this.tryParseParamList();if(a&&this.isPunct(")")&&(this.pos+=1,this.isPunct("=>"))){this.pos+=1;const{body:i,isBlock:l}=this.parseArrowBody();return{type:"Arrow",params:a,body:i,isBlock:l}}return this.pos=e,null}return this.pos=e,null}tryParseParamList(){const e=[];if(this.isPunct(")"))return e;for(;;){const n=this.peek();let a=null,i;if(n&&n.type==="punct"&&n.value==="["){const c=this.tryParseArrayPattern();if(!c)return null;i=c}else if(n&&n.type==="ident"&&!Ul.has(String(n.value)))a=String(n.value),this.pos+=1;else return null;let l=null;if(this.isPunct("=")&&(this.pos+=1,l=this.parseExpression()),e.push(i?{name:a,elements:i,default:l}:{name:a,default:l}),this.isPunct(",")){this.pos+=1;continue}break}return e}tryParseArrayPattern(){const e=this.pos;this.pos+=1;const n=[];for(;;){if(this.isPunct("]"))return this.pos+=1,n;if(this.isPunct(",")){n.push(null),this.pos+=1;continue}const a=this.peek();if(!a||a.type!=="ident"||Ul.has(String(a.value)))return this.pos=e,null;if(n.push(String(a.value)),this.pos+=1,this.isPunct(",")){this.pos+=1;continue}return this.isPunct("]")?(this.pos+=1,n):(this.pos=e,null)}}parseArrowBody(){return this.isPunct("{")?{body:this.parseBlock(),isBlock:!0}:{body:this.parseExpression(),isBlock:!1}}parseBlock(){this.expectPunct("{");const e=[];for(;!this.isPunct("}");){if(this.atEnd())throw new Error("Unterminated block");e.push(this.parseStatement())}return this.expectPunct("}"),{type:"Block",body:e}}parseStatement(){const e=this.peek();if(!e)throw new Error("Unexpected end of statement");if(e.type==="punct"&&e.value==="{")return this.parseBlock();if(e.type==="punct"&&e.value===";")return this.pos+=1,{type:"Empty"};if(e.type==="ident")switch(String(e.value)){case"const":case"let":return this.parseVarDecl();case"if":return this.parseIf();case"for":return this.parseForOf();case"return":return this.parseReturn();case"try":return this.parseTry();case"break":return this.pos+=1,this.consumeSemicolon(),{type:"Break"};case"continue":return this.pos+=1,this.consumeSemicolon(),{type:"Continue"}}const n=this.parseExpression();return this.consumeSemicolon(),{type:"ExprStmt",expression:n}}consumeSemicolon(){this.isPunct(";")&&(this.pos+=1)}parseVarDecl(){this.pos+=1;const e=[];for(;;){const n=this.peek();if(!n||n.type!=="ident")throw new Error("Expected variable name in declaration");const a=String(n.value);this.pos+=1,this.expectPunct("=");const i=this.parseExpression();if(e.push({name:a,init:i}),this.isPunct(",")){this.pos+=1;continue}break}return this.consumeSemicolon(),{type:"VarDecl",declarations:e}}parseIf(){this.pos+=1,this.expectPunct("(");const e=this.parseExpression();this.expectPunct(")");const n=this.parseStatement();let a=null;const i=this.peek();return i&&i.type==="ident"&&i.value==="else"&&(this.pos+=1,a=this.parseStatement()),{type:"If",test:e,consequent:n,alternate:a}}parseForOf(){this.pos+=1,this.expectPunct("(");const e=this.peek();if(!e||e.type!=="ident"||e.value!=="const"&&e.value!=="let")throw new Error('Only "for (const x of …)" / "for (let x of …)" loops are supported');this.pos+=1;const n=this.peek();if(!n||n.type!=="ident")throw new Error("Expected loop variable name");const a=String(n.value);this.pos+=1;const i=this.peek();if(!i||i.type!=="ident"||i.value!=="of")throw new Error("Only for-of loops are supported (for-in / C-style for are not allowed)");this.pos+=1;const l=this.parseExpression();this.expectPunct(")");const c=this.parseStatement();return{type:"ForOf",name:a,iterable:l,body:c}}parseReturn(){if(this.pos+=1,this.isPunct(";")||this.isPunct("}")||this.atEnd())return this.consumeSemicolon(),{type:"Return",argument:null};const e=this.parseExpression();return this.consumeSemicolon(),{type:"Return",argument:e}}parseTry(){this.pos+=1;const e=this.parseBlock();let n=null,a=null,i=null;const l=this.peek();if(l&&l.type==="ident"&&l.value==="catch"){if(this.pos+=1,this.isPunct("(")){this.pos+=1;const d=this.peek();d&&d.type==="ident"&&(n=String(d.value),this.pos+=1),this.expectPunct(")")}a=this.parseBlock()}const c=this.peek();if(c&&c.type==="ident"&&c.value==="finally"&&(this.pos+=1,i=this.parseBlock()),!a&&!i)throw new Error("Missing catch or finally after try");return{type:"Try",block:e,handlerParam:n,handler:a,finalizer:i}}parseFunctionExpression(){this.pos+=1;let e=null;const n=this.peek();n&&n.type==="ident"&&n.value!==void 0&&!this.isPunct("(")&&(e=String(n.value),this.pos+=1),this.expectPunct("(");const a=this.tryParseParamList();if(!a)throw new Error("Invalid function parameter list");this.expectPunct(")");const i=this.parseBlock();return{type:"Function",name:e,params:a,body:i}}parseTernary(){const e=this.parseBinary(0);if(this.isPunct("?")){this.pos+=1;const n=this.parseExpression();this.expectPunct(":");const a=this.parseExpression();return{type:"Conditional",test:e,consequent:n,alternate:a}}return e}parseBinary(e){let n=this.parseUnary();for(;;){const a=this.peek();if(!a||a.type!=="punct")break;const i=String(a.value),l=Iw[i];if(l===void 0||lMg(l));return{type:"Template",quasis:n,expressions:i}}if(e.type==="ident"&&e.value==="new")return this.parseNew();if(e.type==="ident"&&e.value==="function")return this.parseFunctionExpression();if(e.type==="ident"){const n=String(e.value);if(Ul.has(n))throw new Error(`Keyword "${n}" is not allowed`);switch(this.pos+=1,n){case"true":return{type:"Literal",value:!0};case"false":return{type:"Literal",value:!1};case"null":return{type:"Literal",value:null};case"undefined":return{type:"Literal",value:void 0};default:return{type:"Identifier",name:n}}}if(e.type==="punct"){if(e.value==="("){this.pos+=1;const n=this.parseExpression();return this.expectPunct(")"),n}if(e.value==="[")return this.parseArrayLiteral();if(e.value==="{")return this.parseObjectLiteral()}throw new Error(`Unexpected token "${e.value}"`)}parseArrayLiteral(){this.expectPunct("[");const e=[];for(;!this.isPunct("]");){if(this.isPunct(",")){this.pos+=1,e.push({type:"Literal",value:void 0});continue}if(this.isPunct("...")?(this.pos+=1,e.push({type:"Spread",argument:this.parseExpression()})):e.push(this.parseExpression()),this.isPunct(",")){this.pos+=1;continue}break}return this.expectPunct("]"),{type:"Array",elements:e}}parseObjectLiteral(){this.expectPunct("{");const e=[];for(;!this.isPunct("}");){if(this.isPunct("..."))this.pos+=1,e.push({kind:"spread",value:this.parseExpression()});else{let n,a=!1;const i=this.peek();if(!i)throw new Error("Unexpected end of expression in object literal");if(i.type==="punct"&&i.value==="[")this.pos+=1,n=this.parseExpression(),this.expectPunct("]"),a=!0;else if(i.type==="str")this.pos+=1,n={type:"Literal",value:i.value};else if(i.type==="num")this.pos+=1,n={type:"Literal",value:String(i.value)};else if(i.type==="ident")this.pos+=1,n={type:"Literal",value:String(i.value)};else throw new Error(`Unexpected token "${i.value}" in object literal`);if(this.isPunct(":")){this.pos+=1;const l=this.parseExpression();e.push({kind:"init",key:n,computed:a,value:l})}else{if(a||n.type!=="Literal"||typeof n.value!="string")throw new Error("Invalid shorthand property in object literal");if(i.type!=="ident")throw new Error("Invalid shorthand property in object literal");e.push({kind:"init",key:n,computed:!1,value:{type:"Identifier",name:n.value}})}}if(this.isPunct(",")){this.pos+=1;continue}break}return this.expectPunct("}"),{type:"Object",properties:e}}}function Mg(s){const e=Nw(s),n=new Hw(e),a=n.parseExpression();if(!n.atEnd()){const i=n.peek();throw i&&i.type==="punct"&&i.value===","?new Error("The comma/sequence operator is not allowed"):new Error(`Unexpected token "${i?i.value:""}" after expression`)}return a}function zw(s,e){let n=e;for(;n;){if(Object.prototype.hasOwnProperty.call(n.vars,s))return n.vars[s];n=n.parent}if(Object.prototype.hasOwnProperty.call(Sd,s))return Sd[s];if(Dw.has(s))throw new Error(`Reference to forbidden global "${s}" is not allowed`)}function Ad(s){const e=typeof s=="symbol"?s:String(s);if(typeof e=="string"&&so.has(e))throw new Error(`Access to "${e}" is forbidden`);return e}function xd(s,e){return s.type==="Member"?$g(s,e):s.type==="Call"?Ng(s,e):Qe(s,e)}function $g(s,e){const n=xd(s.object,e);if(n===br||s.optional&&n==null)return br;let a;s.computed?a=Qe(s.property,e):a=s.property.value;const i=Ad(a);if(n!=null)return n[i]}function Ng(s,e){let n,a;if(s.callee.type==="Member"){const l=s.callee,c=xd(l.object,e);if(c===br||l.optional&&c==null)return br;let d;l.computed?d=Qe(l.property,e):d=l.property.value;const f=Ad(d);c==null?n=void 0:(n=c[f],a=c)}else if(n=xd(s.callee,e),n===br)return br;if(s.optional&&n==null)return br;if(typeof n!="function")throw new Error("Attempted to call a non-function value");const i=Ig(s.args,e);return n.apply(a,i)}function Ig(s,e){const n=[];for(const a of s)if(a.type==="Spread"){const i=Qe(a.argument,e);if(i!=null)for(const l of i)n.push(l)}else n.push(Qe(a,e));return n}function jg(s,e,n){for(let a=0;a":return n>a;case"<=":return n<=a;case">=":return n>=a;default:throw new Error(`Unknown binary operator "${s.operator}"`)}}case"Logical":{const n=Qe(s.left,e);switch(s.operator){case"&&":return n&&Qe(s.right,e);case"||":return n||Qe(s.right,e);case"??":return n??Qe(s.right,e);default:throw new Error(`Unknown logical operator "${s.operator}"`)}}case"Conditional":return Qe(s.test,e)?Qe(s.consequent,e):Qe(s.alternate,e);case"Array":{const n=[];for(const a of s.elements)if(a.type==="Spread"){const i=Qe(a.argument,e);if(i!=null)for(const l of i)n.push(l)}else n.push(Qe(a,e));return n}case"Object":{const n={};for(const a of s.properties)if(a.kind==="spread"){const i=Qe(a.value,e);if(i!=null&&typeof i=="object")for(const l of Object.keys(i))Td(n,l,i[l])}else{let i;a.computed?i=Qe(a.key,e):i=a.key.value;const l=Qe(a.value,e);Td(n,String(i),l)}return n}case"Arrow":{const n=e,a=s.params,i=s.body,l=s.isBlock;return function(...d){const f={vars:{},parent:n};return jg(a,d,f),l?Hg(i,f):Qe(i,f)}}case"Function":{const n=e,a=s.params,i=s.body,l=s.name,c=function(...f){const h={vars:{},parent:n};return l&&(h.vars[l]=c),jg(a,f,h),Hg(i,h)};return c}case"Delete":{const n=s.argument,a=Qe(n.object,e);let i;n.computed?i=Qe(n.property,e):i=n.property.value;const l=Ad(i);if(a==null||typeof a!="object"&&typeof a!="function")return!0;if(Og.has(a))throw new Error("delete on a built-in global object is not allowed");return delete a[l]}case"Block":{const n={vars:{},parent:e};for(const a of s.body)Qe(a,n);return}case"VarDecl":{for(const n of s.declarations)e.vars[n.name]=Qe(n.init,e);return}case"If":{Qe(s.test,e)?Qe(s.consequent,e):s.alternate&&Qe(s.alternate,e);return}case"ForOf":{const n=Qe(s.iterable,e);if(n!=null)for(const a of n){const i={vars:{},parent:e};i.vars[s.name]=a;try{Qe(s.body,i)}catch(l){if(l instanceof _d)continue;if(l instanceof Ed)break;throw l}}return}case"Return":throw new Cd(s.argument?Qe(s.argument,e):void 0);case"ExprStmt":Qe(s.expression,e);return;case"Break":throw new Ed;case"Continue":throw new _d;case"Empty":return;case"Try":{try{try{Qe(s.block,e)}catch(n){if(n instanceof Cd||n instanceof Ed||n instanceof _d)throw n;if(s.handler){const a={vars:{},parent:e};s.handlerParam&&(a.vars[s.handlerParam]=n),Qe(s.handler,a)}else throw n}}finally{s.finalizer&&Qe(s.finalizer,e)}return}case"New":{const n=wd[s.ctor];if(typeof n!="function")throw new Error("new on non-whitelisted constructor");const a=Ig(s.args,e);return new n(...a)}case"Template":{let n=s.quasis[0]??"";for(let a=0;a0}getFromRenderCycleCache(e){if(this.isRenderCycleCacheActive())return this.renderCycleCache.get(e)}saveToRenderCycleCache(e,n){this.isRenderCycleCacheActive()&&this.renderCycleCache.set(e,n)}resolveBindings(e,n,a,i){const l={...this.defaultOptions,...a},c=oo(),d=n._computed&&!n.$computed?{...n,$computed:n._computed}:n,f=(S,v)=>{const _=v.trim();let A=!1,x=_;if(_.startsWith(tr)&&(A=!0,x=_.slice(tr.length)),qn(x))try{const P=this.evaluatePipeExpression(x,d,l,{...i,method:"resolveBindings",displayExpression:`{{${_}}}`}),W=this.formatValue(P);return A?io(W):W}catch(P){return Da.error("Pipe expression evaluation failed:",x,P),S}if(ro(x)){const P=c?.isEnabled()?performance.now():0;try{let W,pe=!1;if(l.skipCache)W=this.evaluateExpression(x,d);else{const we=`expr:${x}`,Ue=this.getFromRenderCycleCache(we);Ue!==void 0?(W=Ue,pe=!0):(W=this.evaluateExpression(x,d),this.saveToRenderCycleCache(we,W))}if(c?.isEnabled()){const we=performance.now()-P;c.trackExpressionEval({expression:`{{${_}}}`,result:this.sanitizeResultForTracking(W),resultType:this.getResultType(W),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:pe,duration:we,method:"resolveBindings",skipCache:l.skipCache})}const Se=this.formatValue(W);return A?io(Se):Se}catch(W){return Da.error("Expression evaluation failed:",x,W),S}}if(l.skipCache){const P=c?.isEnabled()?performance.now():0,W=this.resolvePath(x,d,l);if(c?.isEnabled()){const Se=performance.now()-P;c.trackExpressionEval({expression:`{{${_}}}`,result:this.sanitizeResultForTracking(W),resultType:this.getResultType(W),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:!1,duration:Se,method:"resolveBindings",skipCache:!0})}const pe=this.formatValue(W);return A?io(pe):pe}const M=x.startsWith("_global")||x.startsWith("_local")||x.startsWith("_isolated")||x.startsWith("$parent"),k=c?.isEnabled()?performance.now():0;let O,B=!1;if(M){const P=this.getFromRenderCycleCache(x);P!==void 0?(O=P,B=!0):(O=this.resolvePath(x,d,l),this.saveToRenderCycleCache(x,O))}else{const P=this.getFromCache(x);P!==void 0?(O=P,B=!0):(O=this.resolvePath(x,d,l),this.saveToCache(x,O))}if(c?.isEnabled()){const P=performance.now()-k;c.trackExpressionEval({expression:`{{${_}}}`,result:this.sanitizeResultForTracking(O),resultType:this.getResultType(O),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:B,duration:P,method:"resolveBindings",skipCache:l.skipCache})}const G=this.formatValue(O);return A?io(G):G},h=Rw(e);if(h.length===0)return e;let m="",b=0;for(const S of h)m+=e.slice(b,S.start),m+=f(e.slice(S.start,S.end),S.expr),b=S.end;return m+e.slice(b)}evaluatePipeExpression(e,n,a,i){const l={...this.defaultOptions,...a},c=oo(),d=c?.isEnabled()?performance.now():0,f=n._computed&&!n.$computed?{...n,$computed:n._computed}:n,[h,m]=Tw(e);let b,S=!1;const v=ro(h),_=h.startsWith("_global")||h.startsWith("_local")||h.startsWith("_isolated")||h.startsWith("$parent");if(l.skipCache)v?b=this.evaluateExpression(h,f):b=this.resolvePath(h,f,l);else if(v){const x=this.getFromRenderCycleCache(`expr:${h}`);x!==void 0?(b=x,S=!0):(b=this.evaluateExpression(h,f),this.saveToRenderCycleCache(`expr:${h}`,b))}else if(_){const x=this.getFromRenderCycleCache(h);x!==void 0?(b=x,S=!0):(b=this.resolvePath(h,f,l),this.saveToRenderCycleCache(h,b))}else{const x=this.getFromCache(h);x!==void 0?(b=x,S=!0):(b=this.resolvePath(h,f,l),this.saveToCache(h,b))}const A=kw(b,m);if(c?.isEnabled()){const x=performance.now()-d;c.trackExpressionEval({expression:i?.displayExpression??`{{${e}}}`,result:this.sanitizeResultForTracking(A),resultType:this.getResultType(A),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:S,duration:x,method:i?.method??"evaluatePipeExpression",skipCache:l.skipCache})}return A}resolve(e,n,a,i){const l={...this.defaultOptions,...a},c=oo(),d=c?.isEnabled()?performance.now():0;if(l.skipCache){const b=this.resolvePath(e,n,l);return c?.isEnabled()&&c.trackExpressionEval({expression:e,result:this.sanitizeResultForTracking(b),resultType:this.getResultType(b),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:!1,duration:performance.now()-d,method:"resolve",skipCache:!0}),b}const f=e.startsWith("_global")||e.startsWith("_local")||e.startsWith("_isolated")||e.startsWith("$parent");let h=!1;if(f){const b=this.getFromRenderCycleCache(e);if(b!==void 0)return h=!0,c?.isEnabled()&&c.trackExpressionEval({expression:e,result:this.sanitizeResultForTracking(b),resultType:this.getResultType(b),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:!0,duration:performance.now()-d,method:"resolve",skipCache:l.skipCache}),b}else{const b=this.getFromCache(e);if(b!==void 0)return h=!0,c?.isEnabled()&&c.trackExpressionEval({expression:e,result:this.sanitizeResultForTracking(b),resultType:this.getResultType(b),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:!0,duration:performance.now()-d,method:"resolve",skipCache:l.skipCache}),b}const m=this.resolvePath(e,n,l);return f?this.saveToRenderCycleCache(e,m):this.saveToCache(e,m),c?.isEnabled()&&c.trackExpressionEval({expression:e,result:this.sanitizeResultForTracking(m),resultType:this.getResultType(m),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:h,duration:performance.now()-d,method:"resolve",skipCache:l.skipCache}),m}resolvePath(e,n,a,i=0,l=new WeakSet){if(typeof e!="string"||e.trim()===""){Da.warn("resolvePath: 빈 경로가 전달되었습니다 — undefined 로 해석합니다.");return}if(a.maxDepth&&i>a.maxDepth){if(a.detectCircular)throw new Rd(`Maximum depth exceeded (${a.maxDepth}). Possible circular reference.`,e,n);return a.defaultValue}const c=this.parsePath(e);let d=n;for(let f=0;fra.CACHE_EXPIRY){this.cache.delete(e),a?.isEnabled()&&a.recordCacheMiss();return}return a?.isEnabled()&&a.recordCacheHit(),n.value}saveToCache(e,n){this.cache.set(e,{value:n,timestamp:Date.now()})}isActionDefinition(e){return typeof e.handler!="string"?!1:e.params!==void 0||Array.isArray(e.actions)||typeof e.target=="string"||e.onSuccess!==void 0||e.onError!==void 0}resolveObject(e,n,a){if(this.isSwitchExpression(e)){const d=e;return this.resolveSwitch(d,n,a)}if(this.isActionDefinition(e))return{...e};if("iteration"in e)return{...e};const i={},c=[...["cellChildren","expandChildren","expandContext","render"],...a?.skipBindingKeys||[]];for(const[d,f]of Object.entries(e)){if(c.includes(d)){i[d]=f;continue}try{this.resolveObjectEntry(i,d,f,n,a)}catch(h){Da.warn(`resolveObject: 값 해석 실패 (key: ${d}):`,h),i[d]=void 0}}return i}resolveObjectEntry(e,n,a,i,l){if(typeof a=="string"){const c=Ta(a);if(c!==null){const d=c.trim();let f=!1,h=d;d.startsWith(tr)&&(f=!0,h=d.slice(tr.length));let m;switch(kg(h)){case"empty":Da.warn(`resolveObject: 빈 바인딩 \`{{}}\` (key: ${n}) — undefined 로 해석합니다.`),m=void 0;break;case"literal":m=yd.get(h);break;case"pipe":m=this.evaluatePipeExpression(h,i,l);break;case"expression":m=this.evaluateExpression(h,i,l);break;default:m=this.resolve(h,i,l)}e[n]=f&&m!=null?rs(m):m}else e[n]=this.resolveBindings(a,i,l)}else Array.isArray(a)?e[n]=a.map(c=>typeof c=="string"?this.resolveBindings(c,i,l):typeof c=="object"&&c!==null?this.resolveObject(c,i,l):c):a&&typeof a=="object"?e[n]=this.resolveObject(a,i,l):e[n]=a}clearCache(){this.cache.clear()}invalidateCacheByKeys(e){for(const n of this.cache.keys())for(const a of e)if(n===a||n.startsWith(`${a}.`)||n.startsWith(`${a}[`)){this.cache.delete(n);break}}pruneCache(){const e=Date.now();for(const[n,a]of this.cache.entries())e-a.timestamp>ra.CACHE_EXPIRY&&this.cache.delete(n)}getCacheStats(){const e=Date.now();let n=0;for(const a of this.cache.values())e-a.timestamp>ra.CACHE_EXPIRY&&n++;return{size:this.cache.size,expired:n}}evaluateExpression(e,n,a){const i=oo(),l=i?.isEnabled()?performance.now():0;i?.isEnabled()&&i.trackBindingEval();try{let c=this.preprocessOptionalChaining(e);c=this.preprocessTranslationTokens(c);const d=this.extractVariablesFromExpression(c),f={...n};for(const x of d)x in f||(f[x]=void 0);n._computed&&!f.$computed&&(f.$computed=n._computed);let h=n.$templateId,m=n.$locale;if(typeof window<"u"&&(!h||!m)){const x=window.__templateApp?.getConfig?.();h??(h=x?.templateId),m??(m=x?.locale)}const b=m||"ko";f.$localized=(x,L)=>{if(x==null&&!L)return"";if(typeof x=="string")return x;if(x&&typeof x=="object"&&x[b])return x[b];if(L&&typeof L=="string"){const M=f.$t?f.$t(L):L;if(M&&M!==L)return M}return x&&typeof x=="object"?x.ko||x.en||Object.values(x)[0]||"":x==null?"":String(x)};const S=typeof window<"u"?window.G7Core:void 0;f.$uuid=()=>S?.uuid?S.uuid():typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,x=>{const L=Math.random()*16|0;return(x==="x"?L:L&3|8).toString(16)});const v={templateId:h||"",locale:b};f.$t=x=>{if(!x||typeof x!="string")return"";try{return xa.getInstance().translate(x,v)}catch(L){return Da.warn("$t() translation failed for key:",x,L),x}},f.$get=(x,L,M=void 0)=>{if(x==null)return M;const k=Array.isArray(L)?L:[L];if(k.length===0)return x;let O=x;for(const B of k){if(O==null||B==null)return M;O=O[B]}return O??M};const _=!1,A=kd(c,f);if(i?.isEnabled()){const x=performance.now()-l;i.trackExpressionEval({expression:e,result:this.sanitizeResultForTracking(A),resultType:this.getResultType(A),componentId:a?.componentId,componentName:a?.componentName,propName:a?.propName,fromCache:_,duration:x,method:"evaluateExpression",skipCache:a?.skipCache})}return A}catch(c){throw new Error(`Failed to evaluate expression "${e}": ${c instanceof Error?c.message:String(c)}`)}}getResultType(e){return e===null?"null":e===void 0?"undefined":Array.isArray(e)?"array":typeof e}sanitizeResultForTracking(e){if(e==null||typeof e!="object")return e;try{if(Array.isArray(e))return e.length>10?`[Array(${e.length})]`:e.slice(0,10);const n=Object.keys(e);return n.length>20?`{Object(${n.length} keys)}`:(JSON.stringify(e),e)}catch{return"[Complex Object]"}}preprocessTranslationTokens(e){return e=e.replace(/(['"])(\$t:([a-zA-Z_][a-zA-Z0-9_.\-]*))\1/g,(n,a,i,l)=>`$t('${l}')`),e.replace(/(?(n.push(l),`__STRING_LITERAL_${n.length-1}__`));const i=[];return a=a.replace(/\$t:[a-zA-Z_][a-zA-Z0-9_.\-]*(?:\|[^'"\s,)]+)?/g,l=>(i.push(l),`__TRANSLATION_TOKEN_${i.length-1}__`)),a=a.replace(/([a-zA-Z_$][a-zA-Z0-9_$]*)\.(?!\?)/g,"$1?."),a=a.replace(/__TRANSLATION_TOKEN_(\d+)__/g,(l,c)=>i[parseInt(c,10)]),a=a.replace(/__STRING_LITERAL_(\d+)__/g,(l,c)=>n[parseInt(c,10)]),a}extractVariablesFromExpression(e){const n=new Set(["true","false","null","undefined","NaN","Infinity","if","else","for","while","do","switch","case","break","continue","return","function","class","const","let","var","new","delete","typeof","instanceof","this","super","import","export","default","try","catch","finally","throw","Math","Date","JSON","Array","Object","String","Number","Boolean","RegExp","Set","Map","WeakSet","WeakMap","Symbol","Promise","BigInt","Error","parseInt","parseFloat","isNaN","isFinite","encodeURI","decodeURI","encodeURIComponent","decodeURIComponent"]),a=/(?{n!==null&&clearTimeout(n),n=setTimeout(()=>{s(...a)},e)})}class Pw{constructor(){$(this,"subscribers",new Set);$(this,"currentWidth",1024);$(this,"debouncedHandler",null);$(this,"isInitialized",!1);this.initialize()}initialize(){typeof window>"u"||(this.currentWidth=window.innerWidth,this.isInitialized=!0,this.debouncedHandler=Uw(()=>{this.currentWidth=window.innerWidth,this.notifySubscribers()},150),window.addEventListener("resize",this.debouncedHandler))}notifySubscribers(){this.subscribers.forEach(e=>{e(this.currentWidth)})}subscribe(e){return this.subscribers.add(e),e(this.currentWidth),()=>{this.subscribers.delete(e)}}getWidth(){return this.currentWidth}parseRange(e){if(Bl.has(e))return Bl.get(e)??null;let n=null;if(Pl[e])n={...Pl[e]};else{const a=e.match(/^(-?\d*)-(-?\d*)$/);if(a){const[,i,l]=a,c=i===""?0:parseInt(i,10),d=l===""?1/0:parseInt(l,10);!isNaN(c)&&!isNaN(d)&&c<=d&&(n={min:c,max:d})}}return Bl.set(e,n),n}getMatchingKey(e,n){const a=[];for(const i of Object.keys(e)){const l=this.parseRange(i);l&&n>=l.min&&n<=l.max&&a.push({key:i,range:l,isPreset:!!Pl[i]})}return a.length===0?null:(a.sort((i,l)=>{if(i.isPreset!==l.isPreset)return i.isPreset?1:-1;const c=i.range.max-i.range.min,d=l.range.max-l.range.min;return c-d}),a[0].key)}matches(e){const n=this.parseRange(e);return n?this.currentWidth>=n.min&&this.currentWidth<=n.max:!1}clearSubscribers(){this.subscribers.clear()}destroy(){this.debouncedHandler&&typeof window<"u"&&window.removeEventListener("resize",this.debouncedHandler),this.clearSubscribers(),Bl.clear()}_setWidthForTesting(e){this.currentWidth=e,this.notifySubscribers()}}const mi=new Pw,lo=dt("ConditionEvaluator");function Bw(s,e,n,a){return bd(Rg(s),e,n,{skipCache:!0,onEmpty:()=>{lo.warn(`${a}: 빈 바인딩 \`{{}}\` — undefined 로 해석합니다.`)}})}function zg(s,e,n){if(!s)return!0;try{const a=Ta(s);let i;if(a!==null?i=Bw(a,e,n,"evaluateStringCondition"):i=n.resolveBindings(s,e,{skipCache:!0}),typeof i=="string"){const l=i.toLowerCase().trim();if(l==="false"||l==="0"||l===""||l==="null"||l==="undefined")return!1}return!!i}catch(a){return lo.warn(`evaluateStringCondition: 조건 평가 실패: ${s}`,a),!1}}function ql(s,e,n){if(typeof s=="string")return zg(s,e,n);if("and"in s){if(!Array.isArray(s.and)||s.and.length===0)return lo.warn("evaluateConditionExpression: AND 그룹이 비어있습니다"),!0;for(const a of s.and)if(!ql(a,e,n))return!1;return!0}if("or"in s){if(!Array.isArray(s.or)||s.or.length===0)return lo.warn("evaluateConditionExpression: OR 그룹이 비어있습니다"),!1;for(const a of s.or)if(ql(a,e,n))return!0;return!1}return lo.warn("evaluateConditionExpression: 알 수 없는 조건 형식",s),!1}function Ug(s,e,n){if(!Array.isArray(s)||s.length===0)return{matched:!1,branchIndex:-1};for(let a=0;a{nr.warn(`evaluateIfCondition: 빈 바인딩 \`{{}}\` (컴포넌트: ${a||"unknown"}) — undefined 로 해석합니다.`)}}):l=n.resolveBindings(s,e,{skipCache:!0}),typeof l=="string"){const c=l.toLowerCase().trim();if(c==="false"||c==="0"||c===""||c==="null"||c==="undefined")return!1}return!!l}catch(i){return nr.warn(`evaluateIfCondition: 조건 평가 실패 (컴포넌트: ${a||"unknown"}):`,i),!1}}function as(s,e,n,a){if(s.if!==void 0)return Gg(s.if,e,n,a);if(s.condition!==void 0)return Gg(s.condition,e,n,a);if(s.conditions===void 0)return!0;const i=s.conditions;try{return qw(i)?ql(i,e,n):Gw(i)?Ug(i,e,n).matched:(nr.warn(`evaluateRenderCondition: 알 수 없는 conditions 형식 (컴포넌트: ${a||"unknown"})`),!0)}catch(l){return nr.warn(`evaluateRenderCondition: conditions 평가 실패 (컴포넌트: ${a||"unknown"}):`,l),!1}}function Vg(s){if(!s.responsive)return s;const e=mi.getWidth(),n=mi.getMatchingKey(s.responsive,e);if(!n)return s;const a=s.responsive[n];return{...s,props:{...s.props,...a.props},children:a.children??s.children,text:a.text??s.text,if:a.if??s.if,iteration:a.iteration??s.iteration}}function co(s,e,n,a,i){if(!s||s.length===0)return[];const l=i?.bindingEngine??new Tn,c=i?.translationEngine??xa.getInstance(),d=i?.translationContext??{templateId:"",locale:"ko"},f=Fw(e,i?.componentContext),h=(v,_)=>{if(typeof v=="string")return Hl(v)||zl(v)||!/\$t:[a-zA-Z0-9._-]+/.test(v)?v:c.resolveTranslations(v,d,_);if(Array.isArray(v))return v.map(A=>h(A,_));if(v&&typeof v=="object"){const A={};for(const[x,L]of Object.entries(v))A[x]=h(L,_);return A}return v},m=(v,_)=>{if(typeof v=="string"){if(Hl(v))return vd(v);if(zl(v)){const x=[],L=v.replace(new RegExp(`${ao}([^${pi}]*)${pi}`,"g"),(k,O)=>(x.push(O),`${ka}${x.length-1}${ka}`));let M=L;return/\$t:[a-zA-Z0-9._-]+/.test(L)&&(M=c.resolveTranslations(L,d,_)),M.replace(new RegExp(`${ka}(\\d+)${ka}`,"g"),(k,O)=>x[parseInt(O)])}if(v.startsWith("$t:defer:")){const x="$t:"+v.slice(9);return c.resolveTranslations(x,d,_)}if(v.startsWith("$t:"))return c.resolveTranslations(v,d,_);if(/\$t:[a-zA-Z0-9._-]+/.test(v)){const x=v.trim();if(!(x.startsWith("{")&&x.endsWith("}")||x.startsWith("[")&&x.endsWith("]"))&&!v.includes("{{"))return c.resolveTranslations(v,d,_)}const A=qg(v);if(A!==null){let x=!1,L=A;A.startsWith(tr)&&(x=!0,L=A.slice(tr.length));let M=!1,k=bd(L,_,l,{skipCache:!0,onError:O=>{nr.warn("renderItemChildren: 표현식 평가 실패:",O),M=!0},onEmpty:()=>{nr.warn("renderItemChildren: 빈 바인딩 `{{}}` 은 해석하지 않습니다.")}});return M?void 0:(x||(k=h(k,_)),x&&k!=null?rs(k):k)}if(v.includes("{{")){const x=l.resolveBindings(v,_,{skipCache:!0});return typeof x=="string"&&/\$t:[a-zA-Z0-9._-]+/.test(x)?c.resolveTranslations(x,d,_):x}return v}if(Array.isArray(v))return v.map(A=>m(A,_));if(v&&typeof v=="object"){if(l.isSwitchExpression(v))return l.resolveSwitch(v,_,{skipCache:!0});if(l.isActionDefinition(v))return{...v};const A={};for(const[x,L]of Object.entries(v))A[x]=m(L,_);return A}return v},b=(v,_)=>{if(!v)return{};const A={};for(const[x,L]of Object.entries(v))A[x]=m(L,_);return A},S=(v,_,A)=>{const x=Vg(v),L=x.iteration;if(!L)return[];const M=Od(L.source,A,l);if(!Array.isArray(M))return nr.warn(`renderItemChildren: iteration.source가 배열이 아닙니다: ${L.source}`),[];const k=Pg();if(k?.isEnabled()){const B=`${_}-iteration`;k.trackIteration(B,L.source,L.item_var,L.index_var,M.length)}const O=n[x.name];return O?M.flatMap((B,G)=>{const P={...A,[L.item_var]:B,[`${L.item_var}_index`]:G,...L.index_var?{[L.index_var]:G}:{}},W=as({if:x.if,condition:x.condition,conditions:x.conditions},P,l,x.id);if(x.if&&k?.isEnabled()){const wt=`${_}-iter-${G}-if`;k.trackIfCondition(wt,x.if,W,x.name)}if(!W)return[];const pe=x.id?String(Ra(m(x.id,P))):void 0,Se=`${_}-iter-${G}`,we=i?.getRemountKey?i.getRemountKey(pe,Se):Se,Ue=b(x.props,P),Ve=Ld(Ue,x.actions,P,{actionDispatcher:i?.actionDispatcher,componentContext:i?.componentContext});let qe=null;return x.text!==void 0?qe=m(x.text,P):x.children&&x.children.length>0&&(qe=co(x.children,P,n,we,i)),k?.isEnabled()&&k.trackRender(x.name),[Ye.createElement(O,{key:we,...Ra(Ve)},Ra(qe))]}):(nr.warn(`renderItemChildren: 컴포넌트를 찾을 수 없습니다: ${x.name}`),[])};return s.flatMap((v,_)=>{const A=Vg(v),x=A.id?String(Ra(m(A.id,f))):void 0,L=a?`${a}-${x||_}`:x||`child-${_}`,M=i?.getRemountKey?i.getRemountKey(x,L):L;if(A.iteration)return S(A,M,f);const k=as({if:A.if,condition:A.condition,conditions:A.conditions},f,l,A.id),O=Pg();if(A.if&&O?.isEnabled()){const pe=`${M}-if`;O.trackIfCondition(pe,A.if,k,A.name)}if(!k)return[];const B=n[A.name];if(!B)return nr.warn(`renderItemChildren: 컴포넌트를 찾을 수 없습니다: ${A.name}`),[];const G=b(A.props,f),P=Ld(G,A.actions,f,{actionDispatcher:i?.actionDispatcher,componentContext:i?.componentContext});let W=null;return A.text!==void 0?W=m(A.text,f):A.children&&A.children.length>0&&(W=co(A.children,f,n,M,i)),O?.isEnabled()&&O.trackRender(A.name),[Ye.createElement(B,{key:M,...Ra(P)},Ra(W))]})}function is(s,e,n,a){let i,l;return n instanceof Tn?(i=n,l=a):(i=Bg,l=n),i.resolveBindings(s,e,l)}function Kw(s,e,n,a){const i=n??Bg,l={skipCache:!0,...a};let c;try{c=i.resolveBindings(s.key,e,{skipCache:l.skipCache})?.toString()?.trim()??""}catch(h){nr.warn("resolveClassMap: key 평가 실패:",s.key,h),c=""}let d="";c&&s.variants&&c in s.variants?d=s.variants[c]:s.default&&(d=s.default);const f=[];return s.base?.trim()&&f.push(s.base.trim()),d?.trim()&&f.push(d.trim()),f.join(" ")}function Fg(s,e){return function(){return s.apply(e,arguments)}}const{toString:Ww}=Object.prototype,{getPrototypeOf:Gl}=Object,{iterator:Vl,toStringTag:Kg}=Symbol,Fl=(s=>e=>{const n=Ww.call(e);return s[n]||(s[n]=n.slice(8,-1).toLowerCase())})(Object.create(null)),vr=s=>(s=s.toLowerCase(),e=>Fl(e)===s),Kl=s=>e=>typeof e===s,{isArray:ss}=Array,os=Kl("undefined");function uo(s){return s!==null&&!os(s)&&s.constructor!==null&&!os(s.constructor)&&Ln(s.constructor.isBuffer)&&s.constructor.isBuffer(s)}const Wg=vr("ArrayBuffer");function Yw(s){let e;return typeof ArrayBuffer<"u"&&ArrayBuffer.isView?e=ArrayBuffer.isView(s):e=s&&s.buffer&&Wg(s.buffer),e}const Xw=Kl("string"),Ln=Kl("function"),Yg=Kl("number"),fo=s=>s!==null&&typeof s=="object",Jw=s=>s===!0||s===!1,Wl=s=>{if(Fl(s)!=="object")return!1;const e=Gl(s);return(e===null||e===Object.prototype||Object.getPrototypeOf(e)===null)&&!(Kg in s)&&!(Vl in s)},Qw=s=>{if(!fo(s)||uo(s))return!1;try{return Object.keys(s).length===0&&Object.getPrototypeOf(s)===Object.prototype}catch{return!1}},Zw=vr("Date"),e0=vr("File"),t0=s=>!!(s&&typeof s.uri<"u"),n0=s=>s&&typeof s.getParts<"u",r0=vr("Blob"),a0=vr("FileList"),i0=s=>fo(s)&&Ln(s.pipe);function s0(){return typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{}}const Xg=s0(),Jg=typeof Xg.FormData<"u"?Xg.FormData:void 0,o0=s=>{if(!s)return!1;if(Jg&&s instanceof Jg)return!0;const e=Gl(s);if(!e||e===Object.prototype||!Ln(s.append))return!1;const n=Fl(s);return n==="formdata"||n==="object"&&Ln(s.toString)&&s.toString()==="[object FormData]"},l0=vr("URLSearchParams"),[c0,u0,d0,f0]=["ReadableStream","Request","Response","Headers"].map(vr),h0=s=>s.trim?s.trim():s.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");function ho(s,e,{allOwnKeys:n=!1}={}){if(s===null||typeof s>"u")return;let a,i;if(typeof s!="object"&&(s=[s]),ss(s))for(a=0,i=s.length;a0;)if(i=n[a],e===i.toLowerCase())return i;return null}const yi=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:global,Zg=s=>!os(s)&&s!==yi;function Md(...s){const{caseless:e,skipUndefined:n}=Zg(this)&&this||{},a={},i=(l,c)=>{if(c==="__proto__"||c==="constructor"||c==="prototype")return;const d=e&&Qg(a,c)||c,f=$d(a,d)?a[d]:void 0;Wl(f)&&Wl(l)?a[d]=Md(f,l):Wl(l)?a[d]=Md({},l):ss(l)?a[d]=l.slice():(!n||!os(l))&&(a[d]=l)};for(let l=0,c=s.length;l(ho(e,(i,l)=>{n&&Ln(i)?Object.defineProperty(s,l,{__proto__:null,value:Fg(i,n),writable:!0,enumerable:!0,configurable:!0}):Object.defineProperty(s,l,{__proto__:null,value:i,writable:!0,enumerable:!0,configurable:!0})},{allOwnKeys:a}),s),g0=s=>(s.charCodeAt(0)===65279&&(s=s.slice(1)),s),m0=(s,e,n,a)=>{s.prototype=Object.create(e.prototype,a),Object.defineProperty(s.prototype,"constructor",{__proto__:null,value:s,writable:!0,enumerable:!1,configurable:!0}),Object.defineProperty(s,"super",{__proto__:null,value:e.prototype}),n&&Object.assign(s.prototype,n)},y0=(s,e,n,a)=>{let i,l,c;const d={};if(e=e||{},s==null)return e;do{for(i=Object.getOwnPropertyNames(s),l=i.length;l-- >0;)c=i[l],(!a||a(c,s,e))&&!d[c]&&(e[c]=s[c],d[c]=!0);s=n!==!1&&Gl(s)}while(s&&(!n||n(s,e))&&s!==Object.prototype);return e},b0=(s,e,n)=>{s=String(s),(n===void 0||n>s.length)&&(n=s.length),n-=e.length;const a=s.indexOf(e,n);return a!==-1&&a===n},v0=s=>{if(!s)return null;if(ss(s))return s;let e=s.length;if(!Yg(e))return null;const n=new Array(e);for(;e-- >0;)n[e]=s[e];return n},S0=(s=>e=>s&&e instanceof s)(typeof Uint8Array<"u"&&Gl(Uint8Array)),w0=(s,e)=>{const a=(s&&s[Vl]).call(s);let i;for(;(i=a.next())&&!i.done;){const l=i.value;e.call(s,l[0],l[1])}},C0=(s,e)=>{let n;const a=[];for(;(n=s.exec(e))!==null;)a.push(n);return a},E0=vr("HTMLFormElement"),_0=s=>s.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(n,a,i){return a.toUpperCase()+i}),$d=(({hasOwnProperty:s})=>(e,n)=>s.call(e,n))(Object.prototype),A0=vr("RegExp"),em=(s,e)=>{const n=Object.getOwnPropertyDescriptors(s),a={};ho(n,(i,l)=>{let c;(c=e(i,l,s))!==!1&&(a[l]=c||i)}),Object.defineProperties(s,a)},x0=s=>{em(s,(e,n)=>{if(Ln(s)&&["arguments","caller","callee"].includes(n))return!1;const a=s[n];if(Ln(a)){if(e.enumerable=!1,"writable"in e){e.writable=!1;return}e.set||(e.set=()=>{throw Error("Can not rewrite read-only method '"+n+"'")})}})},T0=(s,e)=>{const n={},a=i=>{i.forEach(l=>{n[l]=!0})};return ss(s)?a(s):a(String(s).split(e)),n},k0=()=>{},R0=(s,e)=>s!=null&&Number.isFinite(s=+s)?s:e;function D0(s){return!!(s&&Ln(s.append)&&s[Kg]==="FormData"&&s[Vl])}const O0=s=>{const e=new WeakSet,n=a=>{if(fo(a)){if(e.has(a))return;if(uo(a))return a;if(!("toJSON"in a)){e.add(a);const i=ss(a)?[]:{};return ho(a,(l,c)=>{const d=n(l);!os(d)&&(i[c]=d)}),e.delete(a),i}}return a};return n(s)},L0=vr("AsyncFunction"),M0=s=>s&&(fo(s)||Ln(s))&&Ln(s.then)&&Ln(s.catch),tm=((s,e)=>s?setImmediate:e?((n,a)=>(yi.addEventListener("message",({source:i,data:l})=>{i===yi&&l===n&&a.length&&a.shift()()},!1),i=>{a.push(i),yi.postMessage(n,"*")}))(`axios@${Math.random()}`,[]):n=>setTimeout(n))(typeof setImmediate=="function",Ln(yi.postMessage)),$0=typeof queueMicrotask<"u"?queueMicrotask.bind(yi):typeof process<"u"&&process.nextTick||tm,K={isArray:ss,isArrayBuffer:Wg,isBuffer:uo,isFormData:o0,isArrayBufferView:Yw,isString:Xw,isNumber:Yg,isBoolean:Jw,isObject:fo,isPlainObject:Wl,isEmptyObject:Qw,isReadableStream:c0,isRequest:u0,isResponse:d0,isHeaders:f0,isUndefined:os,isDate:Zw,isFile:e0,isReactNativeBlob:t0,isReactNative:n0,isBlob:r0,isRegExp:A0,isFunction:Ln,isStream:i0,isURLSearchParams:l0,isTypedArray:S0,isFileList:a0,forEach:ho,merge:Md,extend:p0,trim:h0,stripBOM:g0,inherits:m0,toFlatObject:y0,kindOf:Fl,kindOfTest:vr,endsWith:b0,toArray:v0,forEachEntry:w0,matchAll:C0,isHTMLForm:E0,hasOwnProperty:$d,hasOwnProp:$d,reduceDescriptors:em,freezeMethods:x0,toObjectSet:T0,toCamelCase:_0,noop:k0,toFiniteNumber:R0,findKey:Qg,global:yi,isContextDefined:Zg,isSpecCompliantForm:D0,toJSONObject:O0,isAsyncFn:L0,isThenable:M0,setImmediate:tm,asap:$0,isIterable:s=>s!=null&&Ln(s[Vl])},N0=K.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),I0=s=>{const e={};let n,a,i;return s&&s.split(`
+`;break;case"t":b+=" ";break;case"r":b+="\r";break;case"b":b+="\b";break;case"f":b+="\f";break;case"v":b+="\v";break;case"0":b+="\0";break;case"\\":b+="\\";break;case"'":b+="'";break;case'"':b+='"';break;case"`":b+="`";break;default:b+=S;break}n++}else b+=s[n],n++;if(n>=a)throw new Error(`Unterminated string literal at position ${h}`);n++,e.push({type:"str",value:b,pos:h});continue}if(Lw(l)){const h=n;for(n++;n"?(e.push({type:"punct",value:"=>",pos:c}),n+=2):i(1)==="="?i(2)==="="?(e.push({type:"punct",value:"===",pos:c}),n+=3):(e.push({type:"punct",value:"==",pos:c}),n+=2):(e.push({type:"punct",value:"=",pos:c}),n+=1);continue}case"!":{i(1)==="="?i(2)==="="?(e.push({type:"punct",value:"!==",pos:c}),n+=3):(e.push({type:"punct",value:"!=",pos:c}),n+=2):(e.push({type:"punct",value:"!",pos:c}),n+=1);continue}case"<":{if(i(1)==="=")e.push({type:"punct",value:"<=",pos:c}),n+=2;else{if(i(1)==="<")throw new Error("Bitwise/shift operators are not allowed");e.push({type:"punct",value:"<",pos:c}),n+=1}continue}case">":{if(i(1)==="=")e.push({type:"punct",value:">=",pos:c}),n+=2;else{if(i(1)===">")throw new Error("Bitwise/shift operators are not allowed");e.push({type:"punct",value:">",pos:c}),n+=1}continue}case"&":{if(i(1)==="&")e.push({type:"punct",value:"&&",pos:c}),n+=2;else throw new Error("Bitwise operators are not allowed");continue}case"|":{if(i(1)==="|")e.push({type:"punct",value:"||",pos:c}),n+=2;else throw new Error("Bitwise operators are not allowed");continue}case"?":{i(1)==="."&&!gi(i(2))?(e.push({type:"punct",value:"?.",pos:c}),n+=2):i(1)==="?"?(e.push({type:"punct",value:"??",pos:c}),n+=2):(e.push({type:"punct",value:"?",pos:c}),n+=1);continue}case"+":{if(i(1)==="+")throw new Error("Increment operator is not allowed");if(i(1)==="=")throw new Error("Assignment operators are not allowed");e.push({type:"punct",value:"+",pos:c}),n+=1;continue}case"-":{if(i(1)==="-")throw new Error("Decrement operator is not allowed");if(i(1)==="=")throw new Error("Assignment operators are not allowed");e.push({type:"punct",value:"-",pos:c}),n+=1;continue}case"*":{if(i(1)==="=")throw new Error("Assignment operators are not allowed");if(i(1)==="*")throw new Error("Exponentiation operator is not supported");e.push({type:"punct",value:"*",pos:c}),n+=1;continue}case"/":{if(i(1)==="=")throw new Error("Assignment operators are not allowed");e.push({type:"punct",value:"/",pos:c}),n+=1;continue}case"%":{if(i(1)==="=")throw new Error("Assignment operators are not allowed");e.push({type:"punct",value:"%",pos:c}),n+=1;continue}case"~":case"^":throw new Error("Bitwise operators are not allowed");case".":case":":case"(":case")":case"[":case"]":case"{":case"}":case",":e.push({type:"punct",value:l,pos:c}),n+=1;continue;case";":e.push({type:"punct",value:";",pos:c}),n+=1;continue;default:throw new Error(`Unexpected character "${l}" at position ${c}`)}}return e}const Iw={"??":1,"||":1,"&&":2,"===":3,"!==":3,"==":3,"!=":3,"<":4,">":4,"<=":4,">=":4,"+":5,"-":5,"*":6,"/":6,"%":6},jw=new Set(["??","||","&&"]);class Hw{constructor(e){$(this,"tokens");$(this,"pos",0);this.tokens=e}atEnd(){return this.pos>=this.tokens.length}peek(e=0){const n=this.pos+e;return n"}"`);this.pos++}parseExpression(){const e=this.tryParseArrow();return e||this.parseTernary()}tryParseArrow(){const e=this.pos,n=this.peek();if(!n)return null;if(n.type==="ident"&&this.isPunct("=>",1)&&!Ul.has(String(n.value))){this.pos+=1,this.pos+=1;const{body:a,isBlock:i}=this.parseArrowBody();return{type:"Arrow",params:[{name:String(n.value),default:null}],body:a,isBlock:i}}if(n.type==="punct"&&n.value==="("){this.pos+=1;const a=this.tryParseParamList();if(a&&this.isPunct(")")&&(this.pos+=1,this.isPunct("=>"))){this.pos+=1;const{body:i,isBlock:l}=this.parseArrowBody();return{type:"Arrow",params:a,body:i,isBlock:l}}return this.pos=e,null}return this.pos=e,null}tryParseParamList(){const e=[];if(this.isPunct(")"))return e;for(;;){const n=this.peek();let a=null,i;if(n&&n.type==="punct"&&n.value==="["){const c=this.tryParseArrayPattern();if(!c)return null;i=c}else if(n&&n.type==="ident"&&!Ul.has(String(n.value)))a=String(n.value),this.pos+=1;else return null;let l=null;if(this.isPunct("=")&&(this.pos+=1,l=this.parseExpression()),e.push(i?{name:a,elements:i,default:l}:{name:a,default:l}),this.isPunct(",")){this.pos+=1;continue}break}return e}tryParseArrayPattern(){const e=this.pos;this.pos+=1;const n=[];for(;;){if(this.isPunct("]"))return this.pos+=1,n;if(this.isPunct(",")){n.push(null),this.pos+=1;continue}const a=this.peek();if(!a||a.type!=="ident"||Ul.has(String(a.value)))return this.pos=e,null;if(n.push(String(a.value)),this.pos+=1,this.isPunct(",")){this.pos+=1;continue}return this.isPunct("]")?(this.pos+=1,n):(this.pos=e,null)}}parseArrowBody(){return this.isPunct("{")?{body:this.parseBlock(),isBlock:!0}:{body:this.parseExpression(),isBlock:!1}}parseBlock(){this.expectPunct("{");const e=[];for(;!this.isPunct("}");){if(this.atEnd())throw new Error("Unterminated block");e.push(this.parseStatement())}return this.expectPunct("}"),{type:"Block",body:e}}parseStatement(){const e=this.peek();if(!e)throw new Error("Unexpected end of statement");if(e.type==="punct"&&e.value==="{")return this.parseBlock();if(e.type==="punct"&&e.value===";")return this.pos+=1,{type:"Empty"};if(e.type==="ident")switch(String(e.value)){case"const":case"let":return this.parseVarDecl();case"if":return this.parseIf();case"for":return this.parseForOf();case"return":return this.parseReturn();case"try":return this.parseTry();case"break":return this.pos+=1,this.consumeSemicolon(),{type:"Break"};case"continue":return this.pos+=1,this.consumeSemicolon(),{type:"Continue"}}const n=this.parseExpression();return this.consumeSemicolon(),{type:"ExprStmt",expression:n}}consumeSemicolon(){this.isPunct(";")&&(this.pos+=1)}parseVarDecl(){this.pos+=1;const e=[];for(;;){const n=this.peek();if(!n||n.type!=="ident")throw new Error("Expected variable name in declaration");const a=String(n.value);this.pos+=1,this.expectPunct("=");const i=this.parseExpression();if(e.push({name:a,init:i}),this.isPunct(",")){this.pos+=1;continue}break}return this.consumeSemicolon(),{type:"VarDecl",declarations:e}}parseIf(){this.pos+=1,this.expectPunct("(");const e=this.parseExpression();this.expectPunct(")");const n=this.parseStatement();let a=null;const i=this.peek();return i&&i.type==="ident"&&i.value==="else"&&(this.pos+=1,a=this.parseStatement()),{type:"If",test:e,consequent:n,alternate:a}}parseForOf(){this.pos+=1,this.expectPunct("(");const e=this.peek();if(!e||e.type!=="ident"||e.value!=="const"&&e.value!=="let")throw new Error('Only "for (const x of …)" / "for (let x of …)" loops are supported');this.pos+=1;const n=this.peek();if(!n||n.type!=="ident")throw new Error("Expected loop variable name");const a=String(n.value);this.pos+=1;const i=this.peek();if(!i||i.type!=="ident"||i.value!=="of")throw new Error("Only for-of loops are supported (for-in / C-style for are not allowed)");this.pos+=1;const l=this.parseExpression();this.expectPunct(")");const c=this.parseStatement();return{type:"ForOf",name:a,iterable:l,body:c}}parseReturn(){if(this.pos+=1,this.isPunct(";")||this.isPunct("}")||this.atEnd())return this.consumeSemicolon(),{type:"Return",argument:null};const e=this.parseExpression();return this.consumeSemicolon(),{type:"Return",argument:e}}parseTry(){this.pos+=1;const e=this.parseBlock();let n=null,a=null,i=null;const l=this.peek();if(l&&l.type==="ident"&&l.value==="catch"){if(this.pos+=1,this.isPunct("(")){this.pos+=1;const d=this.peek();d&&d.type==="ident"&&(n=String(d.value),this.pos+=1),this.expectPunct(")")}a=this.parseBlock()}const c=this.peek();if(c&&c.type==="ident"&&c.value==="finally"&&(this.pos+=1,i=this.parseBlock()),!a&&!i)throw new Error("Missing catch or finally after try");return{type:"Try",block:e,handlerParam:n,handler:a,finalizer:i}}parseFunctionExpression(){this.pos+=1;let e=null;const n=this.peek();n&&n.type==="ident"&&n.value!==void 0&&!this.isPunct("(")&&(e=String(n.value),this.pos+=1),this.expectPunct("(");const a=this.tryParseParamList();if(!a)throw new Error("Invalid function parameter list");this.expectPunct(")");const i=this.parseBlock();return{type:"Function",name:e,params:a,body:i}}parseTernary(){const e=this.parseBinary(0);if(this.isPunct("?")){this.pos+=1;const n=this.parseExpression();this.expectPunct(":");const a=this.parseExpression();return{type:"Conditional",test:e,consequent:n,alternate:a}}return e}parseBinary(e){let n=this.parseUnary();for(;;){const a=this.peek();if(!a||a.type!=="punct")break;const i=String(a.value),l=Iw[i];if(l===void 0||lMg(l));return{type:"Template",quasis:n,expressions:i}}if(e.type==="ident"&&e.value==="new")return this.parseNew();if(e.type==="ident"&&e.value==="function")return this.parseFunctionExpression();if(e.type==="ident"){const n=String(e.value);if(Ul.has(n))throw new Error(`Keyword "${n}" is not allowed`);switch(this.pos+=1,n){case"true":return{type:"Literal",value:!0};case"false":return{type:"Literal",value:!1};case"null":return{type:"Literal",value:null};case"undefined":return{type:"Literal",value:void 0};default:return{type:"Identifier",name:n}}}if(e.type==="punct"){if(e.value==="("){this.pos+=1;const n=this.parseExpression();return this.expectPunct(")"),n}if(e.value==="[")return this.parseArrayLiteral();if(e.value==="{")return this.parseObjectLiteral()}throw new Error(`Unexpected token "${e.value}"`)}parseArrayLiteral(){this.expectPunct("[");const e=[];for(;!this.isPunct("]");){if(this.isPunct(",")){this.pos+=1,e.push({type:"Literal",value:void 0});continue}if(this.isPunct("...")?(this.pos+=1,e.push({type:"Spread",argument:this.parseExpression()})):e.push(this.parseExpression()),this.isPunct(",")){this.pos+=1;continue}break}return this.expectPunct("]"),{type:"Array",elements:e}}parseObjectLiteral(){this.expectPunct("{");const e=[];for(;!this.isPunct("}");){if(this.isPunct("..."))this.pos+=1,e.push({kind:"spread",value:this.parseExpression()});else{let n,a=!1;const i=this.peek();if(!i)throw new Error("Unexpected end of expression in object literal");if(i.type==="punct"&&i.value==="[")this.pos+=1,n=this.parseExpression(),this.expectPunct("]"),a=!0;else if(i.type==="str")this.pos+=1,n={type:"Literal",value:i.value};else if(i.type==="num")this.pos+=1,n={type:"Literal",value:String(i.value)};else if(i.type==="ident")this.pos+=1,n={type:"Literal",value:String(i.value)};else throw new Error(`Unexpected token "${i.value}" in object literal`);if(this.isPunct(":")){this.pos+=1;const l=this.parseExpression();e.push({kind:"init",key:n,computed:a,value:l})}else{if(a||n.type!=="Literal"||typeof n.value!="string")throw new Error("Invalid shorthand property in object literal");if(i.type!=="ident")throw new Error("Invalid shorthand property in object literal");e.push({kind:"init",key:n,computed:!1,value:{type:"Identifier",name:n.value}})}}if(this.isPunct(",")){this.pos+=1;continue}break}return this.expectPunct("}"),{type:"Object",properties:e}}}function Mg(s){const e=Nw(s),n=new Hw(e),a=n.parseExpression();if(!n.atEnd()){const i=n.peek();throw i&&i.type==="punct"&&i.value===","?new Error("The comma/sequence operator is not allowed"):new Error(`Unexpected token "${i?i.value:""}" after expression`)}return a}function zw(s,e){let n=e;for(;n;){if(Object.prototype.hasOwnProperty.call(n.vars,s))return n.vars[s];n=n.parent}if(Object.prototype.hasOwnProperty.call(Sd,s))return Sd[s];if(Dw.has(s))throw new Error(`Reference to forbidden global "${s}" is not allowed`)}function Ad(s){const e=typeof s=="symbol"?s:String(s);if(typeof e=="string"&&so.has(e))throw new Error(`Access to "${e}" is forbidden`);return e}function xd(s,e){return s.type==="Member"?$g(s,e):s.type==="Call"?Ng(s,e):Qe(s,e)}function $g(s,e){const n=xd(s.object,e);if(n===br||s.optional&&n==null)return br;let a;s.computed?a=Qe(s.property,e):a=s.property.value;const i=Ad(a);if(n!=null)return n[i]}function Ng(s,e){let n,a;if(s.callee.type==="Member"){const l=s.callee,c=xd(l.object,e);if(c===br||l.optional&&c==null)return br;let d;l.computed?d=Qe(l.property,e):d=l.property.value;const f=Ad(d);c==null?n=void 0:(n=c[f],a=c)}else if(n=xd(s.callee,e),n===br)return br;if(s.optional&&n==null)return br;if(typeof n!="function")throw new Error("Attempted to call a non-function value");const i=Ig(s.args,e);return n.apply(a,i)}function Ig(s,e){const n=[];for(const a of s)if(a.type==="Spread"){const i=Qe(a.argument,e);if(i!=null)for(const l of i)n.push(l)}else n.push(Qe(a,e));return n}function jg(s,e,n){for(let a=0;a":return n>a;case"<=":return n<=a;case">=":return n>=a;default:throw new Error(`Unknown binary operator "${s.operator}"`)}}case"Logical":{const n=Qe(s.left,e);switch(s.operator){case"&&":return n&&Qe(s.right,e);case"||":return n||Qe(s.right,e);case"??":return n??Qe(s.right,e);default:throw new Error(`Unknown logical operator "${s.operator}"`)}}case"Conditional":return Qe(s.test,e)?Qe(s.consequent,e):Qe(s.alternate,e);case"Array":{const n=[];for(const a of s.elements)if(a.type==="Spread"){const i=Qe(a.argument,e);if(i!=null)for(const l of i)n.push(l)}else n.push(Qe(a,e));return n}case"Object":{const n={};for(const a of s.properties)if(a.kind==="spread"){const i=Qe(a.value,e);if(i!=null&&typeof i=="object")for(const l of Object.keys(i))Td(n,l,i[l])}else{let i;a.computed?i=Qe(a.key,e):i=a.key.value;const l=Qe(a.value,e);Td(n,String(i),l)}return n}case"Arrow":{const n=e,a=s.params,i=s.body,l=s.isBlock;return function(...d){const f={vars:{},parent:n};return jg(a,d,f),l?Hg(i,f):Qe(i,f)}}case"Function":{const n=e,a=s.params,i=s.body,l=s.name,c=function(...f){const h={vars:{},parent:n};return l&&(h.vars[l]=c),jg(a,f,h),Hg(i,h)};return c}case"Delete":{const n=s.argument,a=Qe(n.object,e);let i;n.computed?i=Qe(n.property,e):i=n.property.value;const l=Ad(i);if(a==null||typeof a!="object"&&typeof a!="function")return!0;if(Og.has(a))throw new Error("delete on a built-in global object is not allowed");return delete a[l]}case"Block":{const n={vars:{},parent:e};for(const a of s.body)Qe(a,n);return}case"VarDecl":{for(const n of s.declarations)e.vars[n.name]=Qe(n.init,e);return}case"If":{Qe(s.test,e)?Qe(s.consequent,e):s.alternate&&Qe(s.alternate,e);return}case"ForOf":{const n=Qe(s.iterable,e);if(n!=null)for(const a of n){const i={vars:{},parent:e};i.vars[s.name]=a;try{Qe(s.body,i)}catch(l){if(l instanceof _d)continue;if(l instanceof Ed)break;throw l}}return}case"Return":throw new Cd(s.argument?Qe(s.argument,e):void 0);case"ExprStmt":Qe(s.expression,e);return;case"Break":throw new Ed;case"Continue":throw new _d;case"Empty":return;case"Try":{try{try{Qe(s.block,e)}catch(n){if(n instanceof Cd||n instanceof Ed||n instanceof _d)throw n;if(s.handler){const a={vars:{},parent:e};s.handlerParam&&(a.vars[s.handlerParam]=n),Qe(s.handler,a)}else throw n}}finally{s.finalizer&&Qe(s.finalizer,e)}return}case"New":{const n=wd[s.ctor];if(typeof n!="function")throw new Error("new on non-whitelisted constructor");const a=Ig(s.args,e);return new n(...a)}case"Template":{let n=s.quasis[0]??"";for(let a=0;a0}getFromRenderCycleCache(e){if(this.isRenderCycleCacheActive())return this.renderCycleCache.get(e)}saveToRenderCycleCache(e,n){this.isRenderCycleCacheActive()&&this.renderCycleCache.set(e,n)}resolveBindings(e,n,a,i){const l={...this.defaultOptions,...a},c=oo(),d=n._computed&&!n.$computed?{...n,$computed:n._computed}:n,f=(S,v)=>{const _=v.trim();let A=!1,x=_;if(_.startsWith(tr)&&(A=!0,x=_.slice(tr.length)),qn(x))try{const P=this.evaluatePipeExpression(x,d,l,{...i,method:"resolveBindings",displayExpression:`{{${_}}}`}),W=this.formatValue(P);return A?io(W):W}catch(P){return Da.error("Pipe expression evaluation failed:",x,P),S}if(ro(x)){const P=c?.isEnabled()?performance.now():0;try{let W,pe=!1;if(l.skipCache)W=this.evaluateExpression(x,d);else{const we=`expr:${x}`,Ue=this.getFromRenderCycleCache(we);Ue!==void 0?(W=Ue,pe=!0):(W=this.evaluateExpression(x,d),this.saveToRenderCycleCache(we,W))}if(c?.isEnabled()){const we=performance.now()-P;c.trackExpressionEval({expression:`{{${_}}}`,result:this.sanitizeResultForTracking(W),resultType:this.getResultType(W),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:pe,duration:we,method:"resolveBindings",skipCache:l.skipCache})}const Se=this.formatValue(W);return A?io(Se):Se}catch(W){return Da.error("Expression evaluation failed:",x,W),S}}if(l.skipCache){const P=c?.isEnabled()?performance.now():0,W=this.resolvePath(x,d,l);if(c?.isEnabled()){const Se=performance.now()-P;c.trackExpressionEval({expression:`{{${_}}}`,result:this.sanitizeResultForTracking(W),resultType:this.getResultType(W),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:!1,duration:Se,method:"resolveBindings",skipCache:!0})}const pe=this.formatValue(W);return A?io(pe):pe}const M=x.startsWith("_global")||x.startsWith("_local")||x.startsWith("_isolated")||x.startsWith("$parent"),k=c?.isEnabled()?performance.now():0;let O,B=!1;if(M){const P=this.getFromRenderCycleCache(x);P!==void 0?(O=P,B=!0):(O=this.resolvePath(x,d,l),this.saveToRenderCycleCache(x,O))}else{const P=this.getFromCache(x);P!==void 0?(O=P,B=!0):(O=this.resolvePath(x,d,l),this.saveToCache(x,O))}if(c?.isEnabled()){const P=performance.now()-k;c.trackExpressionEval({expression:`{{${_}}}`,result:this.sanitizeResultForTracking(O),resultType:this.getResultType(O),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:B,duration:P,method:"resolveBindings",skipCache:l.skipCache})}const G=this.formatValue(O);return A?io(G):G},h=Rw(e);if(h.length===0)return e;let m="",b=0;for(const S of h)m+=e.slice(b,S.start),m+=f(e.slice(S.start,S.end),S.expr),b=S.end;return m+e.slice(b)}evaluatePipeExpression(e,n,a,i){const l={...this.defaultOptions,...a},c=oo(),d=c?.isEnabled()?performance.now():0,f=n._computed&&!n.$computed?{...n,$computed:n._computed}:n,[h,m]=Tw(e);let b,S=!1;const v=ro(h),_=h.startsWith("_global")||h.startsWith("_local")||h.startsWith("_isolated")||h.startsWith("$parent");if(l.skipCache)v?b=this.evaluateExpression(h,f):b=this.resolvePath(h,f,l);else if(v){const x=this.getFromRenderCycleCache(`expr:${h}`);x!==void 0?(b=x,S=!0):(b=this.evaluateExpression(h,f),this.saveToRenderCycleCache(`expr:${h}`,b))}else if(_){const x=this.getFromRenderCycleCache(h);x!==void 0?(b=x,S=!0):(b=this.resolvePath(h,f,l),this.saveToRenderCycleCache(h,b))}else{const x=this.getFromCache(h);x!==void 0?(b=x,S=!0):(b=this.resolvePath(h,f,l),this.saveToCache(h,b))}const A=kw(b,m);if(c?.isEnabled()){const x=performance.now()-d;c.trackExpressionEval({expression:i?.displayExpression??`{{${e}}}`,result:this.sanitizeResultForTracking(A),resultType:this.getResultType(A),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:S,duration:x,method:i?.method??"evaluatePipeExpression",skipCache:l.skipCache})}return A}resolve(e,n,a,i){const l={...this.defaultOptions,...a},c=oo(),d=c?.isEnabled()?performance.now():0;if(l.skipCache){const b=this.resolvePath(e,n,l);return c?.isEnabled()&&c.trackExpressionEval({expression:e,result:this.sanitizeResultForTracking(b),resultType:this.getResultType(b),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:!1,duration:performance.now()-d,method:"resolve",skipCache:!0}),b}const f=e.startsWith("_global")||e.startsWith("_local")||e.startsWith("_isolated")||e.startsWith("$parent");let h=!1;if(f){const b=this.getFromRenderCycleCache(e);if(b!==void 0)return h=!0,c?.isEnabled()&&c.trackExpressionEval({expression:e,result:this.sanitizeResultForTracking(b),resultType:this.getResultType(b),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:!0,duration:performance.now()-d,method:"resolve",skipCache:l.skipCache}),b}else{const b=this.getFromCache(e);if(b!==void 0)return h=!0,c?.isEnabled()&&c.trackExpressionEval({expression:e,result:this.sanitizeResultForTracking(b),resultType:this.getResultType(b),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:!0,duration:performance.now()-d,method:"resolve",skipCache:l.skipCache}),b}const m=this.resolvePath(e,n,l);return f?this.saveToRenderCycleCache(e,m):this.saveToCache(e,m),c?.isEnabled()&&c.trackExpressionEval({expression:e,result:this.sanitizeResultForTracking(m),resultType:this.getResultType(m),componentId:i?.componentId,componentName:i?.componentName,propName:i?.propName,fromCache:h,duration:performance.now()-d,method:"resolve",skipCache:l.skipCache}),m}resolvePath(e,n,a,i=0,l=new WeakSet){if(typeof e!="string"||e.trim()===""){Da.warn("resolvePath: 빈 경로가 전달되었습니다 — undefined 로 해석합니다.");return}if(a.maxDepth&&i>a.maxDepth){if(a.detectCircular)throw new Rd(`Maximum depth exceeded (${a.maxDepth}). Possible circular reference.`,e,n);return a.defaultValue}const c=this.parsePath(e);let d=n;for(let f=0;fra.CACHE_EXPIRY){this.cache.delete(e),a?.isEnabled()&&a.recordCacheMiss();return}return a?.isEnabled()&&a.recordCacheHit(),n.value}saveToCache(e,n){this.cache.set(e,{value:n,timestamp:Date.now()})}isActionDefinition(e){return typeof e.handler!="string"?!1:e.params!==void 0||Array.isArray(e.actions)||typeof e.target=="string"||e.onSuccess!==void 0||e.onError!==void 0}resolveObject(e,n,a){if(this.isSwitchExpression(e)){const d=e;return this.resolveSwitch(d,n,a)}if(this.isActionDefinition(e))return{...e};if("iteration"in e)return{...e};const i={},c=[...["cellChildren","expandChildren","expandContext","render"],...a?.skipBindingKeys||[]];for(const[d,f]of Object.entries(e)){if(c.includes(d)){i[d]=f;continue}try{this.resolveObjectEntry(i,d,f,n,a)}catch(h){Da.warn(`resolveObject: 값 해석 실패 (key: ${d}):`,h),i[d]=void 0}}return i}resolveObjectEntry(e,n,a,i,l){if(typeof a=="string"){const c=Ta(a);if(c!==null){const d=c.trim();let f=!1,h=d;d.startsWith(tr)&&(f=!0,h=d.slice(tr.length));let m;switch(kg(h)){case"empty":Da.warn(`resolveObject: 빈 바인딩 \`{{}}\` (key: ${n}) — undefined 로 해석합니다.`),m=void 0;break;case"literal":m=yd.get(h);break;case"pipe":m=this.evaluatePipeExpression(h,i,l);break;case"expression":m=this.evaluateExpression(h,i,l);break;default:m=this.resolve(h,i,l)}e[n]=f&&m!=null?rs(m):m}else e[n]=this.resolveBindings(a,i,l)}else Array.isArray(a)?e[n]=a.map(c=>typeof c=="string"?this.resolveBindings(c,i,l):typeof c=="object"&&c!==null?this.resolveObject(c,i,l):c):a&&typeof a=="object"?e[n]=this.resolveObject(a,i,l):e[n]=a}clearCache(){this.cache.clear()}invalidateCacheByKeys(e){for(const n of this.cache.keys())for(const a of e)if(n===a||n.startsWith(`${a}.`)||n.startsWith(`${a}[`)){this.cache.delete(n);break}}pruneCache(){const e=Date.now();for(const[n,a]of this.cache.entries())e-a.timestamp>ra.CACHE_EXPIRY&&this.cache.delete(n)}getCacheStats(){const e=Date.now();let n=0;for(const a of this.cache.values())e-a.timestamp>ra.CACHE_EXPIRY&&n++;return{size:this.cache.size,expired:n}}evaluateExpression(e,n,a){const i=oo(),l=i?.isEnabled()?performance.now():0;i?.isEnabled()&&i.trackBindingEval();try{let c=this.preprocessOptionalChaining(e);c=this.preprocessTranslationTokens(c);const d=this.extractVariablesFromExpression(c),f={...n};for(const x of d)x in f||(f[x]=void 0);n._computed&&!f.$computed&&(f.$computed=n._computed);let h=n.$templateId,m=n.$locale;if(typeof window<"u"&&(!h||!m)){const x=window.__templateApp?.getConfig?.();h??(h=x?.templateId),m??(m=x?.locale)}const b=m||"ko";f.$localized=(x,L)=>{if(x==null&&!L)return"";if(typeof x=="string")return x;if(x&&typeof x=="object"&&x[b])return x[b];if(L&&typeof L=="string"){const M=f.$t?f.$t(L):L;if(M&&M!==L)return M}return x&&typeof x=="object"?x.ko||x.en||Object.values(x)[0]||"":x==null?"":String(x)};const S=typeof window<"u"?window.G7Core:void 0;f.$uuid=()=>S?.uuid?S.uuid():typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,x=>{const L=Math.random()*16|0;return(x==="x"?L:L&3|8).toString(16)});const v={templateId:h||"",locale:b};f.$t=x=>{if(!x||typeof x!="string")return"";try{return xa.getInstance().translate(x,v)}catch(L){return Da.warn("$t() translation failed for key:",x,L),x}},f.$get=(x,L,M=void 0)=>{if(x==null)return M;const k=Array.isArray(L)?L:[L];if(k.length===0)return x;let O=x;for(const B of k){if(O==null||B==null)return M;O=O[B]}return O??M};const _=!1,A=kd(c,f);if(i?.isEnabled()){const x=performance.now()-l;i.trackExpressionEval({expression:e,result:this.sanitizeResultForTracking(A),resultType:this.getResultType(A),componentId:a?.componentId,componentName:a?.componentName,propName:a?.propName,fromCache:_,duration:x,method:"evaluateExpression",skipCache:a?.skipCache})}return A}catch(c){throw new Error(`Failed to evaluate expression "${e}": ${c instanceof Error?c.message:String(c)}`)}}getResultType(e){return e===null?"null":e===void 0?"undefined":Array.isArray(e)?"array":typeof e}sanitizeResultForTracking(e){if(e==null||typeof e!="object")return e;try{if(Array.isArray(e))return e.length>10?`[Array(${e.length})]`:e.slice(0,10);const n=Object.keys(e);return n.length>20?`{Object(${n.length} keys)}`:(JSON.stringify(e),e)}catch{return"[Complex Object]"}}preprocessTranslationTokens(e){return e=e.replace(/(['"])(\$t:([a-zA-Z_][a-zA-Z0-9_.\-]*))\1/g,(n,a,i,l)=>`$t('${l}')`),e.replace(/(['"]\s*)?(\$t:[a-zA-Z_][a-zA-Z0-9_.\-]*)(?!\s*['"])/g,(n,a,i)=>a?n:`"${i}"`)}preprocessOptionalChaining(e){const n=[];let a=e.replace(/(['"])(?:(?!\1|\\).|\\.)*\1/g,l=>(n.push(l),`__STRING_LITERAL_${n.length-1}__`));const i=[];return a=a.replace(/\$t:[a-zA-Z_][a-zA-Z0-9_.\-]*(?:\|[^'"\s,)]+)?/g,l=>(i.push(l),`__TRANSLATION_TOKEN_${i.length-1}__`)),a=a.replace(/([a-zA-Z_$][a-zA-Z0-9_$]*)\.(?!\?)/g,"$1?."),a=a.replace(/__TRANSLATION_TOKEN_(\d+)__/g,(l,c)=>i[parseInt(c,10)]),a=a.replace(/__STRING_LITERAL_(\d+)__/g,(l,c)=>n[parseInt(c,10)]),a}extractVariablesFromExpression(e){const n=new Set(["true","false","null","undefined","NaN","Infinity","if","else","for","while","do","switch","case","break","continue","return","function","class","const","let","var","new","delete","typeof","instanceof","this","super","import","export","default","try","catch","finally","throw","Math","Date","JSON","Array","Object","String","Number","Boolean","RegExp","Set","Map","WeakSet","WeakMap","Symbol","Promise","BigInt","Error","parseInt","parseFloat","isNaN","isFinite","encodeURI","decodeURI","encodeURIComponent","decodeURIComponent"]),a=/(^|[^.\w$])([a-zA-Z_$][a-zA-Z0-9_$]*)/g,i=new Set;let l;for(;(l=a.exec(e))!==null;){const c=l[2];n.has(c)||i.add(c)}return Array.from(i)}isSwitchExpression(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$switch"in e&&"$cases"in e}resolveSwitch(e,n,a){const i={...a,skipCache:!0};let l;try{l=this.resolveBindings(e.$switch,n,i)?.toString()?.trim()??""}catch(d){Da.warn("resolveSwitch: $switch 키 평가 실패:",e.$switch,d),l=""}let c;if(l&&e.$cases&&l in e.$cases)c=e.$cases[l];else if("$default"in e)c=e.$default;else return;return typeof c=="string"?c.includes("{{")?this.resolveBindings(c,n,i):c:c!==null&&typeof c=="object"?this.resolveObject(c,n,i):c}};$(ra,"PATH_SEGMENT_PATTERN",/([^.[\]]+)|\[(\d+)\]/g),$(ra,"CACHE_EXPIRY",3e4);let Tn=ra;const Dd=new Tn,Pl={mobile:{min:0,max:767},tablet:{min:768,max:1023},desktop:{min:1024,max:1/0},portable:{min:0,max:1023}},Bl=new Map;function Uw(s,e){let n=null;return((...a)=>{n!==null&&clearTimeout(n),n=setTimeout(()=>{s(...a)},e)})}class Pw{constructor(){$(this,"subscribers",new Set);$(this,"currentWidth",1024);$(this,"debouncedHandler",null);$(this,"isInitialized",!1);this.initialize()}initialize(){typeof window>"u"||(this.currentWidth=window.innerWidth,this.isInitialized=!0,this.debouncedHandler=Uw(()=>{this.currentWidth=window.innerWidth,this.notifySubscribers()},150),window.addEventListener("resize",this.debouncedHandler))}notifySubscribers(){this.subscribers.forEach(e=>{e(this.currentWidth)})}subscribe(e){return this.subscribers.add(e),e(this.currentWidth),()=>{this.subscribers.delete(e)}}getWidth(){return this.currentWidth}parseRange(e){if(Bl.has(e))return Bl.get(e)??null;let n=null;if(Pl[e])n={...Pl[e]};else{const a=e.match(/^(-?\d*)-(-?\d*)$/);if(a){const[,i,l]=a,c=i===""?0:parseInt(i,10),d=l===""?1/0:parseInt(l,10);!isNaN(c)&&!isNaN(d)&&c<=d&&(n={min:c,max:d})}}return Bl.set(e,n),n}getMatchingKey(e,n){const a=[];for(const i of Object.keys(e)){const l=this.parseRange(i);l&&n>=l.min&&n<=l.max&&a.push({key:i,range:l,isPreset:!!Pl[i]})}return a.length===0?null:(a.sort((i,l)=>{if(i.isPreset!==l.isPreset)return i.isPreset?1:-1;const c=i.range.max-i.range.min,d=l.range.max-l.range.min;return c-d}),a[0].key)}matches(e){const n=this.parseRange(e);return n?this.currentWidth>=n.min&&this.currentWidth<=n.max:!1}clearSubscribers(){this.subscribers.clear()}destroy(){this.debouncedHandler&&typeof window<"u"&&window.removeEventListener("resize",this.debouncedHandler),this.clearSubscribers(),Bl.clear()}_setWidthForTesting(e){this.currentWidth=e,this.notifySubscribers()}}const mi=new Pw,lo=dt("ConditionEvaluator");function Bw(s,e,n,a){return bd(Rg(s),e,n,{skipCache:!0,onEmpty:()=>{lo.warn(`${a}: 빈 바인딩 \`{{}}\` — undefined 로 해석합니다.`)}})}function zg(s,e,n){if(!s)return!0;try{const a=Ta(s);let i;if(a!==null?i=Bw(a,e,n,"evaluateStringCondition"):i=n.resolveBindings(s,e,{skipCache:!0}),typeof i=="string"){const l=i.toLowerCase().trim();if(l==="false"||l==="0"||l===""||l==="null"||l==="undefined")return!1}return!!i}catch(a){return lo.warn(`evaluateStringCondition: 조건 평가 실패: ${s}`,a),!1}}function ql(s,e,n){if(typeof s=="string")return zg(s,e,n);if("and"in s){if(!Array.isArray(s.and)||s.and.length===0)return lo.warn("evaluateConditionExpression: AND 그룹이 비어있습니다"),!0;for(const a of s.and)if(!ql(a,e,n))return!1;return!0}if("or"in s){if(!Array.isArray(s.or)||s.or.length===0)return lo.warn("evaluateConditionExpression: OR 그룹이 비어있습니다"),!1;for(const a of s.or)if(ql(a,e,n))return!0;return!1}return lo.warn("evaluateConditionExpression: 알 수 없는 조건 형식",s),!1}function Ug(s,e,n){if(!Array.isArray(s)||s.length===0)return{matched:!1,branchIndex:-1};for(let a=0;a{nr.warn(`evaluateIfCondition: 빈 바인딩 \`{{}}\` (컴포넌트: ${a||"unknown"}) — undefined 로 해석합니다.`)}}):l=n.resolveBindings(s,e,{skipCache:!0}),typeof l=="string"){const c=l.toLowerCase().trim();if(c==="false"||c==="0"||c===""||c==="null"||c==="undefined")return!1}return!!l}catch(i){return nr.warn(`evaluateIfCondition: 조건 평가 실패 (컴포넌트: ${a||"unknown"}):`,i),!1}}function as(s,e,n,a){if(s.if!==void 0)return Gg(s.if,e,n,a);if(s.condition!==void 0)return Gg(s.condition,e,n,a);if(s.conditions===void 0)return!0;const i=s.conditions;try{return qw(i)?ql(i,e,n):Gw(i)?Ug(i,e,n).matched:(nr.warn(`evaluateRenderCondition: 알 수 없는 conditions 형식 (컴포넌트: ${a||"unknown"})`),!0)}catch(l){return nr.warn(`evaluateRenderCondition: conditions 평가 실패 (컴포넌트: ${a||"unknown"}):`,l),!1}}function Vg(s){if(!s.responsive)return s;const e=mi.getWidth(),n=mi.getMatchingKey(s.responsive,e);if(!n)return s;const a=s.responsive[n];return{...s,props:{...s.props,...a.props},children:a.children??s.children,text:a.text??s.text,if:a.if??s.if,iteration:a.iteration??s.iteration}}function co(s,e,n,a,i){if(!s||s.length===0)return[];const l=i?.bindingEngine??new Tn,c=i?.translationEngine??xa.getInstance(),d=i?.translationContext??{templateId:"",locale:"ko"},f=Fw(e,i?.componentContext),h=(v,_)=>{if(typeof v=="string")return Hl(v)||zl(v)||!/\$t:[a-zA-Z0-9._-]+/.test(v)?v:c.resolveTranslations(v,d,_);if(Array.isArray(v))return v.map(A=>h(A,_));if(v&&typeof v=="object"){const A={};for(const[x,L]of Object.entries(v))A[x]=h(L,_);return A}return v},m=(v,_)=>{if(typeof v=="string"){if(Hl(v))return vd(v);if(zl(v)){const x=[],L=v.replace(new RegExp(`${ao}([^${pi}]*)${pi}`,"g"),(k,O)=>(x.push(O),`${ka}${x.length-1}${ka}`));let M=L;return/\$t:[a-zA-Z0-9._-]+/.test(L)&&(M=c.resolveTranslations(L,d,_)),M.replace(new RegExp(`${ka}(\\d+)${ka}`,"g"),(k,O)=>x[parseInt(O)])}if(v.startsWith("$t:defer:")){const x="$t:"+v.slice(9);return c.resolveTranslations(x,d,_)}if(v.startsWith("$t:"))return c.resolveTranslations(v,d,_);if(/\$t:[a-zA-Z0-9._-]+/.test(v)){const x=v.trim();if(!(x.startsWith("{")&&x.endsWith("}")||x.startsWith("[")&&x.endsWith("]"))&&!v.includes("{{"))return c.resolveTranslations(v,d,_)}const A=qg(v);if(A!==null){let x=!1,L=A;A.startsWith(tr)&&(x=!0,L=A.slice(tr.length));let M=!1,k=bd(L,_,l,{skipCache:!0,onError:O=>{nr.warn("renderItemChildren: 표현식 평가 실패:",O),M=!0},onEmpty:()=>{nr.warn("renderItemChildren: 빈 바인딩 `{{}}` 은 해석하지 않습니다.")}});return M?void 0:(x||(k=h(k,_)),x&&k!=null?rs(k):k)}if(v.includes("{{")){const x=l.resolveBindings(v,_,{skipCache:!0});return typeof x=="string"&&/\$t:[a-zA-Z0-9._-]+/.test(x)?c.resolveTranslations(x,d,_):x}return v}if(Array.isArray(v))return v.map(A=>m(A,_));if(v&&typeof v=="object"){if(l.isSwitchExpression(v))return l.resolveSwitch(v,_,{skipCache:!0});if(l.isActionDefinition(v))return{...v};const A={};for(const[x,L]of Object.entries(v))A[x]=m(L,_);return A}return v},b=(v,_)=>{if(!v)return{};const A={};for(const[x,L]of Object.entries(v))A[x]=m(L,_);return A},S=(v,_,A)=>{const x=Vg(v),L=x.iteration;if(!L)return[];const M=Od(L.source,A,l);if(!Array.isArray(M))return nr.warn(`renderItemChildren: iteration.source가 배열이 아닙니다: ${L.source}`),[];const k=Pg();if(k?.isEnabled()){const B=`${_}-iteration`;k.trackIteration(B,L.source,L.item_var,L.index_var,M.length)}const O=n[x.name];return O?M.flatMap((B,G)=>{const P={...A,[L.item_var]:B,[`${L.item_var}_index`]:G,...L.index_var?{[L.index_var]:G}:{}},W=as({if:x.if,condition:x.condition,conditions:x.conditions},P,l,x.id);if(x.if&&k?.isEnabled()){const wt=`${_}-iter-${G}-if`;k.trackIfCondition(wt,x.if,W,x.name)}if(!W)return[];const pe=x.id?String(Ra(m(x.id,P))):void 0,Se=`${_}-iter-${G}`,we=i?.getRemountKey?i.getRemountKey(pe,Se):Se,Ue=b(x.props,P),Ve=Ld(Ue,x.actions,P,{actionDispatcher:i?.actionDispatcher,componentContext:i?.componentContext});let qe=null;return x.text!==void 0?qe=m(x.text,P):x.children&&x.children.length>0&&(qe=co(x.children,P,n,we,i)),k?.isEnabled()&&k.trackRender(x.name),[Ye.createElement(O,{key:we,...Ra(Ve)},Ra(qe))]}):(nr.warn(`renderItemChildren: 컴포넌트를 찾을 수 없습니다: ${x.name}`),[])};return s.flatMap((v,_)=>{const A=Vg(v),x=A.id?String(Ra(m(A.id,f))):void 0,L=a?`${a}-${x||_}`:x||`child-${_}`,M=i?.getRemountKey?i.getRemountKey(x,L):L;if(A.iteration)return S(A,M,f);const k=as({if:A.if,condition:A.condition,conditions:A.conditions},f,l,A.id),O=Pg();if(A.if&&O?.isEnabled()){const pe=`${M}-if`;O.trackIfCondition(pe,A.if,k,A.name)}if(!k)return[];const B=n[A.name];if(!B)return nr.warn(`renderItemChildren: 컴포넌트를 찾을 수 없습니다: ${A.name}`),[];const G=b(A.props,f),P=Ld(G,A.actions,f,{actionDispatcher:i?.actionDispatcher,componentContext:i?.componentContext});let W=null;return A.text!==void 0?W=m(A.text,f):A.children&&A.children.length>0&&(W=co(A.children,f,n,M,i)),O?.isEnabled()&&O.trackRender(A.name),[Ye.createElement(B,{key:M,...Ra(P)},Ra(W))]})}function is(s,e,n,a){let i,l;return n instanceof Tn?(i=n,l=a):(i=Bg,l=n),i.resolveBindings(s,e,l)}function Kw(s,e,n,a){const i=n??Bg,l={skipCache:!0,...a};let c;try{c=i.resolveBindings(s.key,e,{skipCache:l.skipCache})?.toString()?.trim()??""}catch(h){nr.warn("resolveClassMap: key 평가 실패:",s.key,h),c=""}let d="";c&&s.variants&&c in s.variants?d=s.variants[c]:s.default&&(d=s.default);const f=[];return s.base?.trim()&&f.push(s.base.trim()),d?.trim()&&f.push(d.trim()),f.join(" ")}function Fg(s,e){return function(){return s.apply(e,arguments)}}const{toString:Ww}=Object.prototype,{getPrototypeOf:Gl}=Object,{iterator:Vl,toStringTag:Kg}=Symbol,Fl=(s=>e=>{const n=Ww.call(e);return s[n]||(s[n]=n.slice(8,-1).toLowerCase())})(Object.create(null)),vr=s=>(s=s.toLowerCase(),e=>Fl(e)===s),Kl=s=>e=>typeof e===s,{isArray:ss}=Array,os=Kl("undefined");function uo(s){return s!==null&&!os(s)&&s.constructor!==null&&!os(s.constructor)&&Ln(s.constructor.isBuffer)&&s.constructor.isBuffer(s)}const Wg=vr("ArrayBuffer");function Yw(s){let e;return typeof ArrayBuffer<"u"&&ArrayBuffer.isView?e=ArrayBuffer.isView(s):e=s&&s.buffer&&Wg(s.buffer),e}const Xw=Kl("string"),Ln=Kl("function"),Yg=Kl("number"),fo=s=>s!==null&&typeof s=="object",Jw=s=>s===!0||s===!1,Wl=s=>{if(Fl(s)!=="object")return!1;const e=Gl(s);return(e===null||e===Object.prototype||Object.getPrototypeOf(e)===null)&&!(Kg in s)&&!(Vl in s)},Qw=s=>{if(!fo(s)||uo(s))return!1;try{return Object.keys(s).length===0&&Object.getPrototypeOf(s)===Object.prototype}catch{return!1}},Zw=vr("Date"),e0=vr("File"),t0=s=>!!(s&&typeof s.uri<"u"),n0=s=>s&&typeof s.getParts<"u",r0=vr("Blob"),a0=vr("FileList"),i0=s=>fo(s)&&Ln(s.pipe);function s0(){return typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{}}const Xg=s0(),Jg=typeof Xg.FormData<"u"?Xg.FormData:void 0,o0=s=>{if(!s)return!1;if(Jg&&s instanceof Jg)return!0;const e=Gl(s);if(!e||e===Object.prototype||!Ln(s.append))return!1;const n=Fl(s);return n==="formdata"||n==="object"&&Ln(s.toString)&&s.toString()==="[object FormData]"},l0=vr("URLSearchParams"),[c0,u0,d0,f0]=["ReadableStream","Request","Response","Headers"].map(vr),h0=s=>s.trim?s.trim():s.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");function ho(s,e,{allOwnKeys:n=!1}={}){if(s===null||typeof s>"u")return;let a,i;if(typeof s!="object"&&(s=[s]),ss(s))for(a=0,i=s.length;a0;)if(i=n[a],e===i.toLowerCase())return i;return null}const yi=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:global,Zg=s=>!os(s)&&s!==yi;function Md(...s){const{caseless:e,skipUndefined:n}=Zg(this)&&this||{},a={},i=(l,c)=>{if(c==="__proto__"||c==="constructor"||c==="prototype")return;const d=e&&Qg(a,c)||c,f=$d(a,d)?a[d]:void 0;Wl(f)&&Wl(l)?a[d]=Md(f,l):Wl(l)?a[d]=Md({},l):ss(l)?a[d]=l.slice():(!n||!os(l))&&(a[d]=l)};for(let l=0,c=s.length;l(ho(e,(i,l)=>{n&&Ln(i)?Object.defineProperty(s,l,{__proto__:null,value:Fg(i,n),writable:!0,enumerable:!0,configurable:!0}):Object.defineProperty(s,l,{__proto__:null,value:i,writable:!0,enumerable:!0,configurable:!0})},{allOwnKeys:a}),s),g0=s=>(s.charCodeAt(0)===65279&&(s=s.slice(1)),s),m0=(s,e,n,a)=>{s.prototype=Object.create(e.prototype,a),Object.defineProperty(s.prototype,"constructor",{__proto__:null,value:s,writable:!0,enumerable:!1,configurable:!0}),Object.defineProperty(s,"super",{__proto__:null,value:e.prototype}),n&&Object.assign(s.prototype,n)},y0=(s,e,n,a)=>{let i,l,c;const d={};if(e=e||{},s==null)return e;do{for(i=Object.getOwnPropertyNames(s),l=i.length;l-- >0;)c=i[l],(!a||a(c,s,e))&&!d[c]&&(e[c]=s[c],d[c]=!0);s=n!==!1&&Gl(s)}while(s&&(!n||n(s,e))&&s!==Object.prototype);return e},b0=(s,e,n)=>{s=String(s),(n===void 0||n>s.length)&&(n=s.length),n-=e.length;const a=s.indexOf(e,n);return a!==-1&&a===n},v0=s=>{if(!s)return null;if(ss(s))return s;let e=s.length;if(!Yg(e))return null;const n=new Array(e);for(;e-- >0;)n[e]=s[e];return n},S0=(s=>e=>s&&e instanceof s)(typeof Uint8Array<"u"&&Gl(Uint8Array)),w0=(s,e)=>{const a=(s&&s[Vl]).call(s);let i;for(;(i=a.next())&&!i.done;){const l=i.value;e.call(s,l[0],l[1])}},C0=(s,e)=>{let n;const a=[];for(;(n=s.exec(e))!==null;)a.push(n);return a},E0=vr("HTMLFormElement"),_0=s=>s.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(n,a,i){return a.toUpperCase()+i}),$d=(({hasOwnProperty:s})=>(e,n)=>s.call(e,n))(Object.prototype),A0=vr("RegExp"),em=(s,e)=>{const n=Object.getOwnPropertyDescriptors(s),a={};ho(n,(i,l)=>{let c;(c=e(i,l,s))!==!1&&(a[l]=c||i)}),Object.defineProperties(s,a)},x0=s=>{em(s,(e,n)=>{if(Ln(s)&&["arguments","caller","callee"].includes(n))return!1;const a=s[n];if(Ln(a)){if(e.enumerable=!1,"writable"in e){e.writable=!1;return}e.set||(e.set=()=>{throw Error("Can not rewrite read-only method '"+n+"'")})}})},T0=(s,e)=>{const n={},a=i=>{i.forEach(l=>{n[l]=!0})};return ss(s)?a(s):a(String(s).split(e)),n},k0=()=>{},R0=(s,e)=>s!=null&&Number.isFinite(s=+s)?s:e;function D0(s){return!!(s&&Ln(s.append)&&s[Kg]==="FormData"&&s[Vl])}const O0=s=>{const e=new WeakSet,n=a=>{if(fo(a)){if(e.has(a))return;if(uo(a))return a;if(!("toJSON"in a)){e.add(a);const i=ss(a)?[]:{};return ho(a,(l,c)=>{const d=n(l);!os(d)&&(i[c]=d)}),e.delete(a),i}}return a};return n(s)},L0=vr("AsyncFunction"),M0=s=>s&&(fo(s)||Ln(s))&&Ln(s.then)&&Ln(s.catch),tm=((s,e)=>s?setImmediate:e?((n,a)=>(yi.addEventListener("message",({source:i,data:l})=>{i===yi&&l===n&&a.length&&a.shift()()},!1),i=>{a.push(i),yi.postMessage(n,"*")}))(`axios@${Math.random()}`,[]):n=>setTimeout(n))(typeof setImmediate=="function",Ln(yi.postMessage)),$0=typeof queueMicrotask<"u"?queueMicrotask.bind(yi):typeof process<"u"&&process.nextTick||tm,K={isArray:ss,isArrayBuffer:Wg,isBuffer:uo,isFormData:o0,isArrayBufferView:Yw,isString:Xw,isNumber:Yg,isBoolean:Jw,isObject:fo,isPlainObject:Wl,isEmptyObject:Qw,isReadableStream:c0,isRequest:u0,isResponse:d0,isHeaders:f0,isUndefined:os,isDate:Zw,isFile:e0,isReactNativeBlob:t0,isReactNative:n0,isBlob:r0,isRegExp:A0,isFunction:Ln,isStream:i0,isURLSearchParams:l0,isTypedArray:S0,isFileList:a0,forEach:ho,merge:Md,extend:p0,trim:h0,stripBOM:g0,inherits:m0,toFlatObject:y0,kindOf:Fl,kindOfTest:vr,endsWith:b0,toArray:v0,forEachEntry:w0,matchAll:C0,isHTMLForm:E0,hasOwnProperty:$d,hasOwnProp:$d,reduceDescriptors:em,freezeMethods:x0,toObjectSet:T0,toCamelCase:_0,noop:k0,toFiniteNumber:R0,findKey:Qg,global:yi,isContextDefined:Zg,isSpecCompliantForm:D0,toJSONObject:O0,isAsyncFn:L0,isThenable:M0,setImmediate:tm,asap:$0,isIterable:s=>s!=null&&Ln(s[Vl])},N0=K.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),I0=s=>{const e={};let n,a,i;return s&&s.split(`
`).forEach(function(c){i=c.indexOf(":"),n=c.substring(0,i).trim().toLowerCase(),a=c.substring(i+1).trim(),!(!n||e[n]&&N0[n])&&(n==="set-cookie"?e[n]?e[n].push(a):e[n]=[a]:e[n]=e[n]?e[n]+", "+a:a)}),e};function j0(s){let e=0,n=s.length;for(;ee;){const a=s.charCodeAt(n-1);if(a!==9&&a!==32)break;n-=1}return e===0&&n===s.length?s:s.slice(e,n)}const H0=new RegExp("[\\u0000-\\u0008\\u000a-\\u001f\\u007f]+","g"),z0=new RegExp("[^\\u0009\\u0020-\\u007e\\u0080-\\u00ff]+","g");function Nd(s,e){return K.isArray(s)?s.map(n=>Nd(n,e)):j0(String(s).replace(e,""))}const U0=s=>Nd(s,H0),P0=s=>Nd(s,z0);function nm(s){const e=Object.create(null);return K.forEach(s.toJSON(),(n,a)=>{e[a]=P0(n)}),e}const rm=Symbol("internals");function po(s){return s&&String(s).trim().toLowerCase()}function Yl(s){return s===!1||s==null?s:K.isArray(s)?s.map(Yl):U0(String(s))}function B0(s){const e=Object.create(null),n=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let a;for(;a=n.exec(s);)e[a[1]]=a[2];return e}const q0=s=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(s.trim());function Id(s,e,n,a,i){if(K.isFunction(a))return a.call(this,e,n);if(i&&(e=n),!!K.isString(e)){if(K.isString(a))return e.indexOf(a)!==-1;if(K.isRegExp(a))return a.test(e)}}function G0(s){return s.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(e,n,a)=>n.toUpperCase()+a)}function V0(s,e){const n=K.toCamelCase(" "+e);["get","set","has"].forEach(a=>{Object.defineProperty(s,a+n,{__proto__:null,value:function(i,l,c){return this[a].call(this,e,i,l,c)},configurable:!0})})}let kn=class{constructor(e){e&&this.set(e)}set(e,n,a){const i=this;function l(d,f,h){const m=po(f);if(!m)throw new Error("header name must be a non-empty string");const b=K.findKey(i,m);(!b||i[b]===void 0||h===!0||h===void 0&&i[b]!==!1)&&(i[b||f]=Yl(d))}const c=(d,f)=>K.forEach(d,(h,m)=>l(h,m,f));if(K.isPlainObject(e)||e instanceof this.constructor)c(e,n);else if(K.isString(e)&&(e=e.trim())&&!q0(e))c(I0(e),n);else if(K.isObject(e)&&K.isIterable(e)){let d={},f,h;for(const m of e){if(!K.isArray(m))throw TypeError("Object iterator must return a key-value pair");d[h=m[0]]=(f=d[h])?K.isArray(f)?[...f,m[1]]:[f,m[1]]:m[1]}c(d,n)}else e!=null&&l(n,e,a);return this}get(e,n){if(e=po(e),e){const a=K.findKey(this,e);if(a){const i=this[a];if(!n)return i;if(n===!0)return B0(i);if(K.isFunction(n))return n.call(this,i,a);if(K.isRegExp(n))return n.exec(i);throw new TypeError("parser must be boolean|regexp|function")}}}has(e,n){if(e=po(e),e){const a=K.findKey(this,e);return!!(a&&this[a]!==void 0&&(!n||Id(this,this[a],a,n)))}return!1}delete(e,n){const a=this;let i=!1;function l(c){if(c=po(c),c){const d=K.findKey(a,c);d&&(!n||Id(a,a[d],d,n))&&(delete a[d],i=!0)}}return K.isArray(e)?e.forEach(l):l(e),i}clear(e){const n=Object.keys(this);let a=n.length,i=!1;for(;a--;){const l=n[a];(!e||Id(this,this[l],l,e,!0))&&(delete this[l],i=!0)}return i}normalize(e){const n=this,a={};return K.forEach(this,(i,l)=>{const c=K.findKey(a,l);if(c){n[c]=Yl(i),delete n[l];return}const d=e?G0(l):String(l).trim();d!==l&&delete n[l],n[d]=Yl(i),a[d]=!0}),this}concat(...e){return this.constructor.concat(this,...e)}toJSON(e){const n=Object.create(null);return K.forEach(this,(a,i)=>{a!=null&&a!==!1&&(n[i]=e&&K.isArray(a)?a.join(", "):a)}),n}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([e,n])=>e+": "+n).join(`
`)}getSetCookie(){return this.get("set-cookie")||[]}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(e){return e instanceof this?e:new this(e)}static concat(e,...n){const a=new this(e);return n.forEach(i=>a.set(i)),a}static accessor(e){const a=(this[rm]=this[rm]={accessors:{}}).accessors,i=this.prototype;function l(c){const d=po(c);a[d]||(V0(i,c),a[d]=!0)}return K.isArray(e)?e.forEach(l):l(e),this}};kn.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]),K.reduceDescriptors(kn.prototype,({value:s},e)=>{let n=e[0].toUpperCase()+e.slice(1);return{get:()=>s,set(a){this[n]=a}}}),K.freezeMethods(kn);const F0="[REDACTED ****]";function K0(s){if(K.hasOwnProp(s,"toJSON"))return!0;let e=Object.getPrototypeOf(s);for(;e&&e!==Object.prototype;){if(K.hasOwnProp(e,"toJSON"))return!0;e=Object.getPrototypeOf(e)}return!1}function W0(s,e){const n=new Set(e.map(l=>String(l).toLowerCase())),a=[],i=l=>{if(l===null||typeof l!="object"||K.isBuffer(l))return l;if(a.indexOf(l)!==-1)return;l instanceof kn&&(l=l.toJSON()),a.push(l);let c;if(K.isArray(l))c=[],l.forEach((d,f)=>{const h=i(d);K.isUndefined(h)||(c[f]=h)});else{if(!K.isPlainObject(l)&&K0(l))return a.pop(),l;c=Object.create(null);for(const[d,f]of Object.entries(l)){const h=n.has(d.toLowerCase())?F0:i(f);K.isUndefined(h)||(c[d]=h)}}return a.pop(),c};return i(s)}let Oe=class lw extends Error{static from(e,n,a,i,l,c){const d=new lw(e.message,n||e.code,a,i,l);return d.cause=e,d.name=e.name,e.status!=null&&d.status==null&&(d.status=e.status),c&&Object.assign(d,c),d}constructor(e,n,a,i,l){super(e),Object.defineProperty(this,"message",{__proto__:null,value:e,enumerable:!0,writable:!0,configurable:!0}),this.name="AxiosError",this.isAxiosError=!0,n&&(this.code=n),a&&(this.config=a),i&&(this.request=i),l&&(this.response=l,this.status=l.status)}toJSON(){const e=this.config,n=e&&K.hasOwnProp(e,"redact")?e.redact:void 0,a=K.isArray(n)&&n.length>0?W0(e,n):K.toJSONObject(e);return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:a,code:this.code,status:this.status}}};Oe.ERR_BAD_OPTION_VALUE="ERR_BAD_OPTION_VALUE",Oe.ERR_BAD_OPTION="ERR_BAD_OPTION",Oe.ECONNABORTED="ECONNABORTED",Oe.ETIMEDOUT="ETIMEDOUT",Oe.ECONNREFUSED="ECONNREFUSED",Oe.ERR_NETWORK="ERR_NETWORK",Oe.ERR_FR_TOO_MANY_REDIRECTS="ERR_FR_TOO_MANY_REDIRECTS",Oe.ERR_DEPRECATED="ERR_DEPRECATED",Oe.ERR_BAD_RESPONSE="ERR_BAD_RESPONSE",Oe.ERR_BAD_REQUEST="ERR_BAD_REQUEST",Oe.ERR_CANCELED="ERR_CANCELED",Oe.ERR_NOT_SUPPORT="ERR_NOT_SUPPORT",Oe.ERR_INVALID_URL="ERR_INVALID_URL",Oe.ERR_FORM_DATA_DEPTH_EXCEEDED="ERR_FORM_DATA_DEPTH_EXCEEDED";const Y0=null;function jd(s){return K.isPlainObject(s)||K.isArray(s)}function am(s){return K.endsWith(s,"[]")?s.slice(0,-2):s}function Hd(s,e,n){return s?s.concat(e).map(function(i,l){return i=am(i),!n&&l?"["+i+"]":i}).join(n?".":""):e}function X0(s){return K.isArray(s)&&!s.some(jd)}const J0=K.toFlatObject(K,{},null,function(e){return/^is[A-Z]/.test(e)});function Xl(s,e,n){if(!K.isObject(s))throw new TypeError("target must be an object");e=e||new FormData,n=K.toFlatObject(n,{metaTokens:!0,dots:!1,indexes:!1},!1,function(x,L){return!K.isUndefined(L[x])});const a=n.metaTokens,i=n.visitor||b,l=n.dots,c=n.indexes,d=n.Blob||typeof Blob<"u"&&Blob,f=n.maxDepth===void 0?100:n.maxDepth,h=d&&K.isSpecCompliantForm(e);if(!K.isFunction(i))throw new TypeError("visitor must be a function");function m(A){if(A===null)return"";if(K.isDate(A))return A.toISOString();if(K.isBoolean(A))return A.toString();if(!h&&K.isBlob(A))throw new Oe("Blob is not supported. Use a Buffer instead.");return K.isArrayBuffer(A)||K.isTypedArray(A)?h&&typeof Blob=="function"?new Blob([A]):Buffer.from(A):A}function b(A,x,L){let M=A;if(K.isReactNative(e)&&K.isReactNativeBlob(A))return e.append(Hd(L,x,l),m(A)),!1;if(A&&!L&&typeof A=="object"){if(K.endsWith(x,"{}"))x=a?x:x.slice(0,-2),A=JSON.stringify(A);else if(K.isArray(A)&&X0(A)||(K.isFileList(A)||K.endsWith(x,"[]"))&&(M=K.toArray(A)))return x=am(x),M.forEach(function(O,B){!(K.isUndefined(O)||O===null)&&e.append(c===!0?Hd([x],B,l):c===null?x:x+"[]",m(O))}),!1}return jd(A)?!0:(e.append(Hd(L,x,l),m(A)),!1)}const S=[],v=Object.assign(J0,{defaultVisitor:b,convertValue:m,isVisitable:jd});function _(A,x,L=0){if(!K.isUndefined(A)){if(L>f)throw new Oe("Object is too deeply nested ("+L+" levels). Max depth: "+f,Oe.ERR_FORM_DATA_DEPTH_EXCEEDED);if(S.indexOf(A)!==-1)throw Error("Circular reference detected in "+x.join("."));S.push(A),K.forEach(A,function(k,O){(!(K.isUndefined(k)||k===null)&&i.call(e,k,K.isString(O)?O.trim():O,x,v))===!0&&_(k,x?x.concat(O):[O],L+1)}),S.pop()}}if(!K.isObject(s))throw new TypeError("data must be an object");return _(s),e}function im(s){const e={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+"};return encodeURIComponent(s).replace(/[!'()~]|%20/g,function(a){return e[a]})}function zd(s,e){this._pairs=[],s&&Xl(s,this,e)}const sm=zd.prototype;sm.append=function(e,n){this._pairs.push([e,n])},sm.toString=function(e){const n=e?function(a){return e.call(this,a,im)}:im;return this._pairs.map(function(i){return n(i[0])+"="+n(i[1])},"").join("&")};function Q0(s){return encodeURIComponent(s).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+")}function om(s,e,n){if(!e)return s;const a=n&&n.encode||Q0,i=K.isFunction(n)?{serialize:n}:n,l=i&&i.serialize;let c;if(l?c=l(e,i):c=K.isURLSearchParams(e)?e.toString():new zd(e,i).toString(a),c){const d=s.indexOf("#");d!==-1&&(s=s.slice(0,d)),s+=(s.indexOf("?")===-1?"?":"&")+c}return s}class lm{constructor(){this.handlers=[]}use(e,n,a){return this.handlers.push({fulfilled:e,rejected:n,synchronous:a?a.synchronous:!1,runWhen:a?a.runWhen:null}),this.handlers.length-1}eject(e){this.handlers[e]&&(this.handlers[e]=null)}clear(){this.handlers&&(this.handlers=[])}forEach(e){K.forEach(this.handlers,function(a){a!==null&&e(a)})}}const Ud={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1,legacyInterceptorReqResOrdering:!0},Z0={isBrowser:!0,classes:{URLSearchParams:typeof URLSearchParams<"u"?URLSearchParams:zd,FormData:typeof FormData<"u"?FormData:null,Blob:typeof Blob<"u"?Blob:null},protocols:["http","https","file","blob","url","data"]},Pd=typeof window<"u"&&typeof document<"u",Bd=typeof navigator=="object"&&navigator||void 0,eC=Pd&&(!Bd||["ReactNative","NativeScript","NS"].indexOf(Bd.product)<0),tC=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope&&typeof self.importScripts=="function",nC=Pd&&window.location.href||"http://localhost",En={...Object.freeze(Object.defineProperty({__proto__:null,hasBrowserEnv:Pd,hasStandardBrowserEnv:eC,hasStandardBrowserWebWorkerEnv:tC,navigator:Bd,origin:nC},Symbol.toStringTag,{value:"Module"})),...Z0};function rC(s,e){return Xl(s,new En.classes.URLSearchParams,{visitor:function(n,a,i,l){return En.isNode&&K.isBuffer(n)?(this.append(a,n.toString("base64")),!1):l.defaultVisitor.apply(this,arguments)},...e})}function aC(s){return K.matchAll(/\w+|\[(\w*)]/g,s).map(e=>e[0]==="[]"?"":e[1]||e[0])}function iC(s){const e={},n=Object.keys(s);let a;const i=n.length;let l;for(a=0;a=n.length;return c=!c&&K.isArray(i)?i.length:c,f?(K.hasOwnProp(i,c)?i[c]=K.isArray(i[c])?i[c].concat(a):[i[c],a]:i[c]=a,!d):((!K.hasOwnProp(i,c)||!K.isObject(i[c]))&&(i[c]=[]),e(n,a,i[c],l)&&K.isArray(i[c])&&(i[c]=iC(i[c])),!d)}if(K.isFormData(s)&&K.isFunction(s.entries)){const n={};return K.forEachEntry(s,(a,i)=>{e(aC(a),i,n,0)}),n}return null}const ls=(s,e)=>s!=null&&K.hasOwnProp(s,e)?s[e]:void 0;function sC(s,e,n){if(K.isString(s))try{return(e||JSON.parse)(s),K.trim(s)}catch(a){if(a.name!=="SyntaxError")throw a}return(n||JSON.stringify)(s)}const go={transitional:Ud,adapter:["xhr","http","fetch"],transformRequest:[function(e,n){const a=n.getContentType()||"",i=a.indexOf("application/json")>-1,l=K.isObject(e);if(l&&K.isHTMLForm(e)&&(e=new FormData(e)),K.isFormData(e))return i?JSON.stringify(cm(e)):e;if(K.isArrayBuffer(e)||K.isBuffer(e)||K.isStream(e)||K.isFile(e)||K.isBlob(e)||K.isReadableStream(e))return e;if(K.isArrayBufferView(e))return e.buffer;if(K.isURLSearchParams(e))return n.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),e.toString();let d;if(l){const f=ls(this,"formSerializer");if(a.indexOf("application/x-www-form-urlencoded")>-1)return rC(e,f).toString();if((d=K.isFileList(e))||a.indexOf("multipart/form-data")>-1){const h=ls(this,"env"),m=h&&h.FormData;return Xl(d?{"files[]":e}:e,m&&new m,f)}}return l||i?(n.setContentType("application/json",!1),sC(e)):e}],transformResponse:[function(e){const n=ls(this,"transitional")||go.transitional,a=n&&n.forcedJSONParsing,i=ls(this,"responseType"),l=i==="json";if(K.isResponse(e)||K.isReadableStream(e))return e;if(e&&K.isString(e)&&(a&&!i||l)){const d=!(n&&n.silentJSONParsing)&&l;try{return JSON.parse(e,ls(this,"parseReviver"))}catch(f){if(d)throw f.name==="SyntaxError"?Oe.from(f,Oe.ERR_BAD_RESPONSE,this,null,ls(this,"response")):f}}return e}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:En.classes.FormData,Blob:En.classes.Blob},validateStatus:function(e){return e>=200&&e<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};K.forEach(["delete","get","head","post","put","patch","query"],s=>{go.headers[s]={}});function qd(s,e){const n=this||go,a=e||n,i=kn.from(a.headers);let l=a.data;return K.forEach(s,function(d){l=d.call(n,l,i.normalize(),e?e.status:void 0)}),i.normalize(),l}function um(s){return!!(s&&s.__CANCEL__)}let mo=class extends Oe{constructor(e,n,a){super(e??"canceled",Oe.ERR_CANCELED,n,a),this.name="CanceledError",this.__CANCEL__=!0}};function dm(s,e,n){const a=n.config.validateStatus;!n.status||!a||a(n.status)?s(n):e(new Oe("Request failed with status code "+n.status,n.status>=400&&n.status<500?Oe.ERR_BAD_REQUEST:Oe.ERR_BAD_RESPONSE,n.config,n.request,n))}function oC(s){const e=/^([-+\w]{1,25}):(?:\/\/)?/.exec(s);return e&&e[1]||""}function lC(s,e){s=s||10;const n=new Array(s),a=new Array(s);let i=0,l=0,c;return e=e!==void 0?e:1e3,function(f){const h=Date.now(),m=a[l];c||(c=h),n[i]=f,a[i]=h;let b=l,S=0;for(;b!==i;)S+=n[b++],b=b%s;if(i=(i+1)%s,i===l&&(l=(l+1)%s),h-c{n=m,i=null,l&&(clearTimeout(l),l=null),s(...h)};return[(...h)=>{const m=Date.now(),b=m-n;b>=a?c(h,m):(i=h,l||(l=setTimeout(()=>{l=null,c(i)},a-b)))},()=>i&&c(i)]}const Jl=(s,e,n=3)=>{let a=0;const i=lC(50,250);return cC(l=>{if(!l||typeof l.loaded!="number")return;const c=l.loaded,d=l.lengthComputable?l.total:void 0,f=d!=null?Math.min(c,d):c,h=Math.max(0,f-a),m=i(h);a=Math.max(a,f);const b={loaded:f,total:d,progress:d?f/d:void 0,bytes:h,rate:m||void 0,estimated:m&&d?(d-f)/m:void 0,event:l,lengthComputable:d!=null,[e?"download":"upload"]:!0};s(b)},n)},fm=(s,e)=>{const n=s!=null;return[a=>e[0]({lengthComputable:n,total:s,loaded:a}),e[1]]},hm=s=>(...e)=>K.asap(()=>s(...e)),uC=En.hasStandardBrowserEnv?((s,e)=>n=>(n=new URL(n,En.origin),s.protocol===n.protocol&&s.host===n.host&&(e||s.port===n.port)))(new URL(En.origin),En.navigator&&/(msie|trident)/i.test(En.navigator.userAgent)):()=>!0,dC=En.hasStandardBrowserEnv?{write(s,e,n,a,i,l,c){if(typeof document>"u")return;const d=[`${s}=${encodeURIComponent(e)}`];K.isNumber(n)&&d.push(`expires=${new Date(n).toUTCString()}`),K.isString(a)&&d.push(`path=${a}`),K.isString(i)&&d.push(`domain=${i}`),l===!0&&d.push("secure"),K.isString(c)&&d.push(`SameSite=${c}`),document.cookie=d.join("; ")},read(s){if(typeof document>"u")return null;const e=document.cookie.split(";");for(let n=0;ns instanceof kn?{...s}:s;function bi(s,e){e=e||{};const n=Object.create(null);Object.defineProperty(n,"hasOwnProperty",{__proto__:null,value:Object.prototype.hasOwnProperty,enumerable:!1,writable:!0,configurable:!0});function a(h,m,b,S){return K.isPlainObject(h)&&K.isPlainObject(m)?K.merge.call({caseless:S},h,m):K.isPlainObject(m)?K.merge({},m):K.isArray(m)?m.slice():m}function i(h,m,b,S){if(K.isUndefined(m)){if(!K.isUndefined(h))return a(void 0,h,b,S)}else return a(h,m,b,S)}function l(h,m){if(!K.isUndefined(m))return a(void 0,m)}function c(h,m){if(K.isUndefined(m)){if(!K.isUndefined(h))return a(void 0,h)}else return a(void 0,m)}function d(h,m,b){if(K.hasOwnProp(e,b))return a(h,m);if(K.hasOwnProp(s,b))return a(void 0,h)}const f={url:l,method:l,data:l,baseURL:c,transformRequest:c,transformResponse:c,paramsSerializer:c,timeout:c,timeoutMessage:c,withCredentials:c,withXSRFToken:c,adapter:c,responseType:c,xsrfCookieName:c,xsrfHeaderName:c,onUploadProgress:c,onDownloadProgress:c,decompress:c,maxContentLength:c,maxBodyLength:c,beforeRedirect:c,transport:c,httpAgent:c,httpsAgent:c,cancelToken:c,socketPath:c,allowedSocketPaths:c,responseEncoding:c,validateStatus:d,headers:(h,m,b)=>i(gm(h),gm(m),b,!0)};return K.forEach(Object.keys({...s,...e}),function(m){if(m==="__proto__"||m==="constructor"||m==="prototype")return;const b=K.hasOwnProp(f,m)?f[m]:i,S=K.hasOwnProp(s,m)?s[m]:void 0,v=K.hasOwnProp(e,m)?e[m]:void 0,_=b(S,v,m);K.isUndefined(_)&&b!==d||(n[m]=_)}),n}const pC=["content-type","content-length"];function gC(s,e,n){if(n!=="content-only"){s.set(e);return}Object.entries(e).forEach(([a,i])=>{pC.includes(a.toLowerCase())&&s.set(a,i)})}const mC=s=>encodeURIComponent(s).replace(/%([0-9A-F]{2})/gi,(e,n)=>String.fromCharCode(parseInt(n,16))),mm=s=>{const e=bi({},s),n=S=>K.hasOwnProp(e,S)?e[S]:void 0,a=n("data");let i=n("withXSRFToken");const l=n("xsrfHeaderName"),c=n("xsrfCookieName");let d=n("headers");const f=n("auth"),h=n("baseURL"),m=n("allowAbsoluteUrls"),b=n("url");if(e.headers=d=kn.from(d),e.url=om(pm(h,b,m),s.params,s.paramsSerializer),f&&d.set("Authorization","Basic "+btoa((f.username||"")+":"+(f.password?mC(f.password):""))),K.isFormData(a)&&(En.hasStandardBrowserEnv||En.hasStandardBrowserWebWorkerEnv?d.setContentType(void 0):K.isFunction(a.getHeaders)&&gC(d,a.getHeaders(),n("formDataHeaderPolicy"))),En.hasStandardBrowserEnv&&(K.isFunction(i)&&(i=i(e)),i===!0||i==null&&uC(e.url))){const v=l&&c&&dC.read(c);v&&d.set(l,v)}return e},yC=typeof XMLHttpRequest<"u"&&function(s){return new Promise(function(n,a){const i=mm(s);let l=i.data;const c=kn.from(i.headers).normalize();let{responseType:d,onUploadProgress:f,onDownloadProgress:h}=i,m,b,S,v,_;function A(){v&&v(),_&&_(),i.cancelToken&&i.cancelToken.unsubscribe(m),i.signal&&i.signal.removeEventListener("abort",m)}let x=new XMLHttpRequest;x.open(i.method.toUpperCase(),i.url,!0),x.timeout=i.timeout;function L(){if(!x)return;const k=kn.from("getAllResponseHeaders"in x&&x.getAllResponseHeaders()),B={data:!d||d==="text"||d==="json"?x.responseText:x.response,status:x.status,statusText:x.statusText,headers:k,config:s,request:x};dm(function(P){n(P),A()},function(P){a(P),A()},B),x=null}"onloadend"in x?x.onloadend=L:x.onreadystatechange=function(){!x||x.readyState!==4||x.status===0&&!(x.responseURL&&x.responseURL.startsWith("file:"))||setTimeout(L)},x.onabort=function(){x&&(a(new Oe("Request aborted",Oe.ECONNABORTED,s,x)),A(),x=null)},x.onerror=function(O){const B=O&&O.message?O.message:"Network Error",G=new Oe(B,Oe.ERR_NETWORK,s,x);G.event=O||null,a(G),A(),x=null},x.ontimeout=function(){let O=i.timeout?"timeout of "+i.timeout+"ms exceeded":"timeout exceeded";const B=i.transitional||Ud;i.timeoutErrorMessage&&(O=i.timeoutErrorMessage),a(new Oe(O,B.clarifyTimeoutError?Oe.ETIMEDOUT:Oe.ECONNABORTED,s,x)),A(),x=null},l===void 0&&c.setContentType(null),"setRequestHeader"in x&&K.forEach(nm(c),function(O,B){x.setRequestHeader(B,O)}),K.isUndefined(i.withCredentials)||(x.withCredentials=!!i.withCredentials),d&&d!=="json"&&(x.responseType=i.responseType),h&&([S,_]=Jl(h,!0),x.addEventListener("progress",S)),f&&x.upload&&([b,v]=Jl(f),x.upload.addEventListener("progress",b),x.upload.addEventListener("loadend",v)),(i.cancelToken||i.signal)&&(m=k=>{x&&(a(!k||k.type?new mo(null,s,x):k),x.abort(),A(),x=null)},i.cancelToken&&i.cancelToken.subscribe(m),i.signal&&(i.signal.aborted?m():i.signal.addEventListener("abort",m)));const M=oC(i.url);if(M&&!En.protocols.includes(M)){a(new Oe("Unsupported protocol "+M+":",Oe.ERR_BAD_REQUEST,s));return}x.send(l||null)})},bC=(s,e)=>{if(s=s?s.filter(Boolean):[],!e&&!s.length)return;const n=new AbortController;let a=!1;const i=function(f){if(!a){a=!0,c();const h=f instanceof Error?f:this.reason;n.abort(h instanceof Oe?h:new mo(h instanceof Error?h.message:h))}};let l=e&&setTimeout(()=>{l=null,i(new Oe(`timeout of ${e}ms exceeded`,Oe.ETIMEDOUT))},e);const c=()=>{s&&(l&&clearTimeout(l),l=null,s.forEach(f=>{f.unsubscribe?f.unsubscribe(i):f.removeEventListener("abort",i)}),s=null)};s.forEach(f=>f.addEventListener("abort",i));const{signal:d}=n;return d.unsubscribe=()=>K.asap(c),d},vC=function*(s,e){let n=s.byteLength;if(n{const i=SC(s,e);let l=0,c,d=f=>{c||(c=!0,a&&a(f))};return new ReadableStream({async pull(f){try{const{done:h,value:m}=await i.next();if(h){d(),f.close();return}let b=m.byteLength;if(n){let S=l+=b;n(S)}f.enqueue(new Uint8Array(m))}catch(h){throw d(h),h}},cancel(f){return d(f),i.return()}},{highWaterMark:2})};function CC(s){if(!s||typeof s!="string"||!s.startsWith("data:"))return 0;const e=s.indexOf(",");if(e<0)return 0;const n=s.slice(5,e),a=s.slice(e+1);if(/;base64/i.test(n)){let c=a.length;const d=a.length;for(let v=0;v=48&&_<=57||_>=65&&_<=70||_>=97&&_<=102)&&(A>=48&&A<=57||A>=65&&A<=70||A>=97&&A<=102)&&(c-=2,v+=2)}let f=0,h=d-1;const m=v=>v>=2&&a.charCodeAt(v-2)===37&&a.charCodeAt(v-1)===51&&(a.charCodeAt(v)===68||a.charCodeAt(v)===100);h>=0&&(a.charCodeAt(h)===61?(f++,h--):m(h)&&(f++,h-=3)),f===1&&h>=0&&(a.charCodeAt(h)===61||m(h))&&f++;const S=Math.floor(c/4)*3-(f||0);return S>0?S:0}if(typeof Buffer<"u"&&typeof Buffer.byteLength=="function")return Buffer.byteLength(a,"utf8");let l=0;for(let c=0,d=a.length;c=55296&&f<=56319&&c+1=56320&&h<=57343?(l+=4,c++):l+=3}else l+=3}return l}const Gd="1.16.1",bm=64*1024,{isFunction:Ql}=K,vm=(s,...e)=>{try{return!!s(...e)}catch{return!1}},EC=s=>{const e=K.global!==void 0&&K.global!==null?K.global:globalThis,{ReadableStream:n,TextEncoder:a}=e;s=K.merge.call({skipUndefined:!0},{Request:e.Request,Response:e.Response},s);const{fetch:i,Request:l,Response:c}=s,d=i?Ql(i):typeof fetch=="function",f=Ql(l),h=Ql(c);if(!d)return!1;const m=d&&Ql(n),b=d&&(typeof a=="function"?(L=>M=>L.encode(M))(new a):async L=>new Uint8Array(await new l(L).arrayBuffer())),S=f&&m&&vm(()=>{let L=!1;const M=new l(En.origin,{body:new n,method:"POST",get duplex(){return L=!0,"half"}}),k=M.headers.has("Content-Type");return M.body!=null&&M.body.cancel(),L&&!k}),v=h&&m&&vm(()=>K.isReadableStream(new c("").body)),_={stream:v&&(L=>L.body)};d&&["text","arrayBuffer","blob","formData","stream"].forEach(L=>{!_[L]&&(_[L]=(M,k)=>{let O=M&&M[L];if(O)return O.call(M);throw new Oe(`Response type '${L}' is not supported`,Oe.ERR_NOT_SUPPORT,k)})});const A=async L=>{if(L==null)return 0;if(K.isBlob(L))return L.size;if(K.isSpecCompliantForm(L))return(await new l(En.origin,{method:"POST",body:L}).arrayBuffer()).byteLength;if(K.isArrayBufferView(L)||K.isArrayBuffer(L))return L.byteLength;if(K.isURLSearchParams(L)&&(L=L+""),K.isString(L))return(await b(L)).byteLength},x=async(L,M)=>{const k=K.toFiniteNumber(L.getContentLength());return k??A(M)};return async L=>{let{url:M,method:k,data:O,signal:B,cancelToken:G,timeout:P,onDownloadProgress:W,onUploadProgress:pe,responseType:Se,headers:we,withCredentials:Ue="same-origin",fetchOptions:Ve,maxContentLength:qe,maxBodyLength:wt}=mm(L);const J=K.isNumber(qe)&&qe>-1,fe=K.isNumber(wt)&&wt>-1;let Le=i||fetch;Se=Se?(Se+"").toLowerCase():"text";let Z=bC([B,G&&G.toAbortSignal()],P),ge=null;const H=Z&&Z.unsubscribe&&(()=>{Z.unsubscribe()});let T;try{if(J&&typeof M=="string"&&M.startsWith("data:")&&CC(M)>qe)throw new Oe("maxContentLength size of "+qe+" exceeded",Oe.ERR_BAD_RESPONSE,L,ge);if(fe&&k!=="get"&&k!=="head"){const _e=await x(we,O);if(typeof _e=="number"&&isFinite(_e)&&_e>wt)throw new Oe("Request body larger than maxBodyLength limit",Oe.ERR_BAD_REQUEST,L,ge)}if(pe&&S&&k!=="get"&&k!=="head"&&(T=await x(we,O))!==0){let _e=new l(M,{method:"POST",body:O,duplex:"half"}),Te;if(K.isFormData(O)&&(Te=_e.headers.get("content-type"))&&we.setContentType(Te),_e.body){const[$e,pt]=fm(T,Jl(hm(pe)));O=ym(_e.body,bm,$e,pt)}}K.isString(Ue)||(Ue=Ue?"include":"omit");const ce=f&&"credentials"in l.prototype;if(K.isFormData(O)){const _e=we.getContentType();_e&&/^multipart\/form-data/i.test(_e)&&!/boundary=/i.test(_e)&&we.delete("content-type")}we.set("User-Agent","axios/"+Gd,!1);const de={...Ve,signal:Z,method:k.toUpperCase(),headers:nm(we.normalize()),body:O,duplex:"half",credentials:ce?Ue:void 0};ge=f&&new l(M,de);let ye=await(f?Le(ge,Ve):Le(M,de));if(J){const _e=K.toFiniteNumber(ye.headers.get("content-length"));if(_e!=null&&_e>qe)throw new Oe("maxContentLength size of "+qe+" exceeded",Oe.ERR_BAD_RESPONSE,L,ge)}const ie=v&&(Se==="stream"||Se==="response");if(v&&ye.body&&(W||J||ie&&H)){const _e={};["status","statusText","headers"].forEach(ht=>{_e[ht]=ye[ht]});const Te=K.toFiniteNumber(ye.headers.get("content-length")),[$e,pt]=W&&fm(Te,Jl(hm(W),!0))||[];let Ct=0;const Dt=ht=>{if(J&&(Ct=ht,Ct>qe))throw new Oe("maxContentLength size of "+qe+" exceeded",Oe.ERR_BAD_RESPONSE,L,ge);$e&&$e(ht)};ye=new c(ym(ye.body,bm,Dt,()=>{pt&&pt(),H&&H()}),_e)}Se=Se||"text";let xe=await _[K.findKey(_,Se)||"text"](ye,L);if(J&&!v&&!ie){let _e;if(xe!=null&&(typeof xe.byteLength=="number"?_e=xe.byteLength:typeof xe.size=="number"?_e=xe.size:typeof xe=="string"&&(_e=typeof a=="function"?new a().encode(xe).byteLength:xe.length)),typeof _e=="number"&&_e>qe)throw new Oe("maxContentLength size of "+qe+" exceeded",Oe.ERR_BAD_RESPONSE,L,ge)}return!ie&&H&&H(),await new Promise((_e,Te)=>{dm(_e,Te,{data:xe,headers:kn.from(ye.headers),status:ye.status,statusText:ye.statusText,config:L,request:ge})})}catch(ce){if(H&&H(),Z&&Z.aborted&&Z.reason instanceof Oe){const de=Z.reason;throw de.config=L,ge&&(de.request=ge),ce!==de&&(de.cause=ce),de}throw ce&&ce.name==="TypeError"&&/Load failed|fetch/i.test(ce.message)?Object.assign(new Oe("Network Error",Oe.ERR_NETWORK,L,ge,ce&&ce.response),{cause:ce.cause||ce}):Oe.from(ce,ce&&ce.code,L,ge,ce&&ce.response)}}},_C=new Map,Sm=s=>{let e=s&&s.env||{};const{fetch:n,Request:a,Response:i}=e,l=[a,i,n];let c=l.length,d=c,f,h,m=_C;for(;d--;)f=l[d],h=m.get(f),h===void 0&&m.set(f,h=d?new Map:EC(e)),m=h;return h};Sm();const Vd={http:Y0,xhr:yC,fetch:{get:Sm}};K.forEach(Vd,(s,e)=>{if(s){try{Object.defineProperty(s,"name",{__proto__:null,value:e})}catch{}Object.defineProperty(s,"adapterName",{__proto__:null,value:e})}});const wm=s=>`- ${s}`,AC=s=>K.isFunction(s)||s===null||s===!1;function xC(s,e){s=K.isArray(s)?s:[s];const{length:n}=s;let a,i;const l={};for(let c=0;c`adapter ${f} `+(h===!1?"is not supported by the environment":"is not available in the build"));let d=n?c.length>1?`since :
`+c.map(wm).join(`
diff --git a/resources/js/core/template-engine/CHANGELOG.md b/resources/js/core/template-engine/CHANGELOG.md
index 284a1ba9..3366bea2 100644
--- a/resources/js/core/template-engine/CHANGELOG.md
+++ b/resources/js/core/template-engine/CHANGELOG.md
@@ -5,6 +5,18 @@
>
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
+## [engine-v1.60.6] - 2026-08-22
+
+### Fixed
+
+#### 정규식 lookbehind 로 인한 구형 Safari 전면 부팅 실패
+
+- `DataBindingEngine` 의 정규식 2건(`preprocessTranslationTokens` · `extractVariablesFromExpression`)이 ES2018 lookbehind(`(? (quoted ? match : `"${token}"`)
+ );
}
/**
@@ -1551,13 +1556,16 @@ export class DataBindingEngine {
// 식별자 패턴 (변수명 시작 위치)
// 식별자는 문자, $, _로 시작하고 문자, 숫자, $, _로 계속됨
- const identifierPattern = /(?();
let match;
while ((match = identifierPattern.exec(expr)) !== null) {
- const varName = match[1];
+ const varName = match[2];
// 예약어가 아니고, 이미 추출되지 않았다면 추가
if (!reserved.has(varName)) {
variables.add(varName);
diff --git a/resources/js/core/template-engine/__tests__/DataBindingEngine.test.ts b/resources/js/core/template-engine/__tests__/DataBindingEngine.test.ts
index ded252c5..0f0f4c2e 100644
--- a/resources/js/core/template-engine/__tests__/DataBindingEngine.test.ts
+++ b/resources/js/core/template-engine/__tests__/DataBindingEngine.test.ts
@@ -1293,6 +1293,90 @@ describe('DataBindingEngine', () => {
});
});
+ /**
+ * 공개 #121 회귀 — 정규식 lookbehind 제거 (engine-v1.60.6).
+ *
+ * lookbehind(`(? {
+ /**
+ * private 메서드를 테스트에서 직접 호출한다.
+ * 캡처 인덱스 회귀는 공개 API 결과만으로는 드러나지 않는다.
+ *
+ * @param expr 표현식
+ * @return {string[]} 추출된 변수명
+ */
+ const extract = (expr: string): string[] =>
+ (engine as any).extractVariablesFromExpression(expr);
+
+ it('인접 매치가 손실되지 않는다 (선행 구분자 소비의 부작용 없음)', () => {
+ expect(extract('a.b.c d.e f')).toEqual(['a', 'd', 'f']);
+ expect(extract('x+y+z')).toEqual(['x', 'y', 'z']);
+ expect(extract('p,q,r')).toEqual(['p', 'q', 'r']);
+ });
+
+ it('문두 식별자가 누락되지 않는다 (^ 분기)', () => {
+ expect(extract('x + y')).toEqual(['x', 'y']);
+ expect(extract('item')).toEqual(['item']);
+ });
+
+ it('구분자 문자가 변수명에 섞이지 않는다 (match[2] 회귀 가드)', () => {
+ const vars = extract("a + b, c ? d : e && f || g === 'x'");
+ for (const v of vars) {
+ expect(v).toMatch(/^[a-zA-Z_$][a-zA-Z0-9_$]*$/);
+ }
+ // 문자열 리터럴 내부는 이 메서드가 걸러내지 않는다(호출부가 이미 토큰화한 뒤 넘긴다).
+ // 교체 전 lookbehind 판정과 동일한 결과다 — 계약 불변.
+ expect(vars).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'x']);
+ });
+
+ it('프로퍼티 접근 뒤 식별자는 최상위 변수로 수집하지 않는다', () => {
+ expect(extract('obj.a.b.c')).toEqual(['obj']);
+ expect(extract('item?.id')).toEqual(['item']);
+ expect(extract('.leading')).toEqual([]);
+ expect(extract('1abc')).toEqual([]);
+ });
+
+ it('공개 API — 인접 식별자를 쓰는 표현식이 정상 평가된다', () => {
+ const context = { a: 1, b: 2, c: 3 };
+ expect(engine.evaluateExpression('a+b+c', context)).toBe(6);
+ expect(engine.evaluateExpression('a + b * c', context)).toBe(7);
+ });
+
+ it('공개 API — 컨텍스트에 없는 변수는 여전히 undefined 로 가려진다', () => {
+ // 캡처 인덱스가 어긋나면 실제 변수명이 수집되지 않아 ReferenceError 가 된다
+ expect(() => engine.evaluateExpression('missingVar', {})).not.toThrow();
+ expect(engine.evaluateExpression('missingVar ?? "fallback"', {})).toBe('fallback');
+ expect(engine.evaluateExpression('x || y || "none"', {})).toBe('none');
+ });
+
+ it('$t: 처리 계약이 불변이다 (따옴표 유무 분기)', () => {
+ expect(engine.evaluateExpression('_global.msg || $t:common.error', { _global: {} }))
+ .toBe('$t:common.error');
+ expect(engine.evaluateExpression('flag ? $t:a.b : $t:c.d', { flag: false }))
+ .toBe('$t:c.d');
+ expect(engine.evaluateExpression('$t:x', {})).toBe('$t:x');
+ });
+
+ it('소스에 lookbehind 가 남아 있지 않다', async () => {
+ const fs = await import('node:fs');
+ const path = await import('node:path');
+ const src = fs.readFileSync(
+ path.resolve(__dirname, '..', 'DataBindingEngine.ts'),
+ 'utf8'
+ );
+ expect(src.match(/\(\?<[!=]/g)).toBeNull();
+ });
+ });
+
describe('{{raw:...}} 바인딩 — 번역 면제 (engine-v1.27.0)', () => {
describe('resolveBindings', () => {
it('단순 경로에 raw 마커 래핑', () => {
diff --git a/resources/views/partials/bootstrap-scripts.blade.php b/resources/views/partials/bootstrap-scripts.blade.php
index 2fbcd0e1..45e1f77b 100644
--- a/resources/views/partials/bootstrap-scripts.blade.php
+++ b/resources/views/partials/bootstrap-scripts.blade.php
@@ -61,11 +61,36 @@
// 헬퍼가 없으면(부분 include 등) 자가 복구 없이 기존 재시도 로직으로만 동작한다.
var assetUrl = window.__g7AssetUrl || null;
+ // URL 을 문서 기준 절대 URL 로 정규화한다 (ES5 — `new URL` 은 구형 브라우저에 없다).
+ function absoluteUrl(src) {
+ var a = document.createElement('a');
+ a.href = src;
+ return a.href;
+ }
+
// 부트스트랩 상태 — 정적