From a7b7c6457382d56376a57ed0f1850dc51accccb1 Mon Sep 17 00:00:00 2001
From: HeuJung
Date: Thu, 20 Aug 2026 17:01:27 +0900
Subject: [PATCH 1/7] =?UTF-8?q?feat(settings):=20=EC=BD=94=EC=96=B4=20?=
=?UTF-8?q?=EC=95=84=EC=9B=83=EB=B0=94=EC=9A=B4=EB=93=9C=20HTTP=20?=
=?UTF-8?q?=ED=94=84=EB=A1=9D=EC=8B=9C=20=EC=84=A4=EC=A0=95=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
접속 IP 를 제한하는 결제사 API 를 로컬·스테이징에서 연동하려면 서버가
내보내는 요청의 출발지 IP 를 바꿔야 한다. 브라우저 프록시로는 바뀌지 않는
축이라 코어 환경설정으로 도입한다.
게이트는 디버그 모드이며 판정은 OutboundProxy 한 곳이 소유한다. 화면의
조건부 렌더링은 편의일 뿐이라 저장 API 직접 호출을 막지 못하므로, 실질
게이트를 판정 지점에 둔다. 주입·적용 지점은 결과만 소비한다.
적용은 Http::globalOptions 전역 옵션이라 확장의 Http:: 호출까지 함께
경유한다. 외부 연동 규약상 curl 핸들을 직접 다뤄야 하는 확장은
OutboundProxy::curlOptions 로 같은 프록시를 탄다 — KG이니시스 본인인증
승인 요청과 CBT 연결 점검을 이 통로로 편입했다. CBT 의 TCP 443 확인은
원시 소켓으로는 프록시를 태울 수 없어 curl CONNECT_ONLY 로 교체했다.
저장 전 연결 테스트는 저장값이 아니라 제출값을 검사하고, 그 프록시를
거쳤을 때 외부에 보이는 IP 를 함께 보고한다. 운영자가 결제사에 등록할
값이라 저장하고 나서 되짚지 않도록 했다. 적용과 같은 조립을 거치므로
확인한 구성과 저장 후 적용되는 구성이 어긋나지 않는다.
---
.env.example | 2 +-
.env.testing.example | 2 +-
CHANGELOG.md | 8 +
INSTALL.md | 2 +-
README.ko.md | 2 +-
README.md | 2 +-
.../Commands/MigrateSettingsToJsonCommand.php | 2 +
.../Api/Admin/SettingsController.php | 31 +-
.../Requests/Settings/SaveSettingsRequest.php | 17 +
.../Settings/TestOutboundProxyRequest.php | 76 +++++
app/Providers/AppServiceProvider.php | 27 ++
app/Providers/SettingsServiceProvider.php | 6 +
app/Rules/ValidOutboundProxyUrl.php | 47 +++
app/Services/OutboundProxyTester.php | 116 +++++++
app/Support/ApiDoc/ParameterDescriber.php | 2 +
app/Support/OutboundProxy.php | 191 +++++++++++
config/app.php | 2 +-
config/core.php | 21 ++
config/settings/defaults.json | 8 +-
docs/backend/admin-settings-access.md | 41 +++
docs/backend/api/settings.md | 76 +++++
lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 7 +
.../g7-core-ja/backend/ja/settings.php | 6 +
.../g7-core-ja/backend/ja/validation.php | 11 +
.../_bundled/g7-core-ja/language-pack.json | 2 +-
.../CHANGELOG.md | 6 +
.../frontend/partial/admin.json | 7 +
.../language-pack.json | 2 +-
lang/en/settings.php | 6 +
lang/en/validation.php | 11 +
lang/ko/settings.php | 6 +
lang/ko/validation.php | 11 +
.../sirsoft-pay_kginicis/CHANGELOG.md | 2 +
.../sirsoft-pay_kginicis/docs/api/cbt.md | 4 +-
.../_bundled/sirsoft-pay_kginicis/plugin.json | 2 +-
.../AdminCbtConnectivityCheckController.php | 49 ++-
.../CHANGELOG.md | 7 +
.../composer.json | 2 +-
.../package.json | 2 +-
.../sirsoft-verification_kginicis/plugin.json | 4 +-
.../src/Services/InicisGateway.php | 7 +
routes/api.php | 1 +
.../_bundled/sirsoft-admin_basic/CHANGELOG.md | 7 +
...ettings-outbound-proxy-visibility.test.tsx | 203 +++++++++++
.../editor-spec/sampleData.json | 6 +-
.../lang/partial/en/admin.json | 7 +
.../lang/partial/ko/admin.json | 7 +
.../admin_settings/_tab_advanced.json | 213 ++++++++++++
.../sirsoft-admin_basic/package-lock.json | 4 +-
.../_bundled/sirsoft-admin_basic/package.json | 2 +-
.../sirsoft-admin_basic/template.json | 2 +-
.../Settings/OutboundProxySettingTest.php | 322 ++++++++++++++++++
.../admin/settings-outbound-proxy.spec.ts | 152 +++++++++
tests/Unit/Support/OutboundProxyTest.php | 258 ++++++++++++++
tests/scenarios/outbound-http-proxy.yaml | 51 +++
55 files changed, 2040 insertions(+), 30 deletions(-)
create mode 100644 app/Http/Requests/Settings/TestOutboundProxyRequest.php
create mode 100644 app/Rules/ValidOutboundProxyUrl.php
create mode 100644 app/Services/OutboundProxyTester.php
create mode 100644 app/Support/OutboundProxy.php
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-outbound-proxy-visibility.test.tsx
create mode 100644 tests/Feature/Settings/OutboundProxySettingTest.php
create mode 100644 tests/Playwright/specs/admin/settings-outbound-proxy.spec.ts
create mode 100644 tests/Unit/Support/OutboundProxyTest.php
create mode 100644 tests/scenarios/outbound-http-proxy.yaml
diff --git a/.env.example b/.env.example
index 373dd7dd..aae511b2 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.1.0
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/.env.testing.example b/.env.testing.example
index 6aaace74..4635cbd2 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.1.0
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45dccead..6295dc62 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,14 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [7.1.0] - 2026-08-20
+
+### Added
+
+- 사이트가 외부로 보내는 요청을 지정한 프록시 서버를 거쳐 나가도록 설정할 수 있습니다. 결제사처럼 접속 IP 를 제한하는 외부 서비스를 연동할 때, 개발·스테이징 환경에서도 허용된 IP 로 요청을 보낼 수 있습니다. 환경설정 > 고급에서 디버그 모드를 켜면 프록시 주소 입력칸이 나타나며, 프록시를 거치지 않을 주소를 예외 목록으로 따로 지정할 수 있습니다. 디버그 모드를 끄면 저장된 주소가 남아 있어도 프록시는 적용되지 않습니다.
+- 프록시 주소 옆의 「연결 테스트」로 저장하기 전에 연결 여부를 확인할 수 있습니다. 성공하면 그 프록시를 거쳤을 때 외부 서비스에 보이는 IP 주소를 함께 알려주므로, 결제사에 어떤 IP 를 등록해야 하는지 미리 확인할 수 있습니다.
+- 확장 개발자용: 사이트 표준 HTTP 호출은 프록시 설정이 자동으로 적용됩니다. 외부 연동 규약상 별도 방식으로 통신해야 하는 확장은 코어가 제공하는 프록시 설정을 받아 같은 경로로 내보낼 수 있습니다.
+
## [7.0.7] - 2026-08-19
### Security
diff --git a/INSTALL.md b/INSTALL.md
index 802b5d98..e6a9c93d 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.1.0 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
diff --git a/README.ko.md b/README.ko.md
index 06a43295..dda31a78 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -10,7 +10,7 @@
-
+
diff --git a/README.md b/README.md
index 3410c40b..506ff5c6 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/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/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..17e93828
--- /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/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..80c2261f
--- /dev/null
+++ b/app/Rules/ValidOutboundProxyUrl.php
@@ -0,0 +1,47 @@
+ implode(', ', OutboundProxy::ALLOWED_SCHEMES),
+ ]));
+ }
+ }
+}
diff --git a/app/Services/OutboundProxyTester.php b/app/Services/OutboundProxyTester.php
new file mode 100644
index 00000000..53cdae46
--- /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/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..cbfc8592
--- /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..ccc01849 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.1.0'),
/*
|--------------------------------------------------------------------------
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/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/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
index 7caf1bb9..a9c6bc09 100644
--- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-core-ja/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.7] - 2026-08-20
+
+### Added
+
+- 아웃바운드 프록시 설정의 검증 메시지와 항목명 일본어 번역을 추가했습니다 (`settings.outbound_proxy_*`, `attributes.outbound_proxy*`).
+- 아웃바운드 프록시 연결 테스트 결과 안내 문구의 일본어 번역을 추가했습니다 (`settings.outbound_proxy_test_*`).
+
## [1.0.6] - 2026-08-19
### Changed
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-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
index a957a097..0b64535e 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-20
+
+### 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/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/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/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
index 5b3a6acc..f83bc8f0 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
@@ -12,6 +12,7 @@
### Changed
+- 코어 최소 요구 버전을 7.1.0 로 상향했습니다.
- 에스크로 배송등록 오류 안내가 다국어로 제공됩니다.
- 결제창 닫힘 보고 사유의 길이 제한을 다른 결제 플러그인과 동일하게 160자로 통일했습니다.
- 해외결제(CBT) 해시 요청에서 체크아웃 토큰 누락 응답이 표준 검증 응답(422)으로 통일되었습니다.
@@ -19,6 +20,7 @@
### Fixed
+- CBT 연결 점검이 서버 출발지 IP 와 호스트 연결 가능 여부를 사이트 환경설정의 아웃바운드 프록시 설정을 따라 확인하도록 했습니다. 이전에는 프록시를 사용하는 환경에서도 프록시를 거치지 않고 확인해, 결제사에 등록해야 할 IP 와 다른 값을 알려주고 실제로는 결제가 되는 상황에서도 "연결 불가" 로 보고했습니다.
- 플러그인 설정 화면의 안내·상태 아이콘이 의도한 크기와 다르게 보이던 문제와, 해외결제(CBT) 연결 상태 표시에 일부 스타일이 적용되지 않던 문제를 수정했습니다.
- 결제 실패 시 이동하는 페이지 주소에 서버 내부 오류 원문이 그대로 실려 나가던 문제를 수정했습니다. 이제 안내 문구만 전달되며, 원인 파악에 필요한 원문은 서버 로그에만 기록됩니다.
- 플러그인 설정 저장과 관리자 주문 결제 조회의 해외결제 대사 재시도·편의점 결제 처리가 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다.
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/plugin.json b/plugins/_bundled/sirsoft-pay_kginicis/plugin.json
index f0e400de..1f78ad77 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/plugin.json
+++ b/plugins/_bundled/sirsoft-pay_kginicis/plugin.json
@@ -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.1.0",
"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-verification_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md
index 6dc4c422..e12bdeab 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-20
+
+### Changed
+
+- 본인인증 승인 요청이 사이트 환경설정의 아웃바운드 프록시 설정을 따르도록 했습니다. 이전에는 이 요청만 프록시를 거치지 않고 직접 나가서, 프록시를 사용하는 환경에서 결제사에 등록한 IP 와 다른 IP 로 접속했습니다.
+- 코어 최소 요구 버전을 7.1.0 로 상향했습니다.
+
## [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.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..041c39ec 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.1.0",
"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/routes/api.php b/routes/api.php
index a64b4d81..6f77af6d 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -632,6 +632,7 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin'
Route::post('restore', [AdminSettingsController::class, 'restore'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.restore');
Route::post('test-mail', [AdminSettingsController::class, 'testMail'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.test-mail');
Route::post('test-driver', [AdminSettingsController::class, 'testDriverConnection'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.test-driver');
+ Route::post('test-outbound-proxy', [AdminSettingsController::class, 'testOutboundProxy'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.test-outbound-proxy');
Route::post('geoip/update', [AdminGeoIpController::class, 'update'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.geoip.update');
Route::get('{key}', [AdminSettingsController::class, 'show'])->middleware('permission:admin,core.settings.read')->name('api.admin.settings.show');
Route::put('{key}', [AdminSettingsController::class, 'update'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.update');
diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
index 072e5e09..d7b739fc 100644
--- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
+++ b/templates/_bundled/sirsoft-admin_basic/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.6] - 2026-08-20
+
+### Added
+
+- 환경설정 > 고급 > 디버그 카드에 아웃바운드 프록시 설정이 추가되었습니다 — 디버그 모드를 켜면 사이트가 외부로 보내는 요청이 거쳐 갈 프록시 주소와, 프록시를 거치지 않을 주소 목록을 입력할 수 있습니다.
+- 프록시 주소 옆에 「연결 테스트」 버튼이 추가되었습니다 — 저장하기 전에 연결 여부를 확인하고, 성공 시 외부 서비스에 보이는 IP 주소를 함께 표시합니다.
+
## [1.0.5] - 2026-08-19
### Added
diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-outbound-proxy-visibility.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-outbound-proxy-visibility.test.tsx
new file mode 100644
index 00000000..09d7e06e
--- /dev/null
+++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-outbound-proxy-visibility.test.tsx
@@ -0,0 +1,203 @@
+/**
+ * @file admin-settings-outbound-proxy-visibility.test.tsx
+ * @description 아웃바운드 프록시 입력칸의 디버그 모드 조건부 노출 테스트
+ *
+ * 프록시는 코어가 바깥으로 내보내는 모든 요청의 경로를 바꾼다. 그래서 입력칸은 디버그 모드가
+ * 켜진 상태에서만 드러나야 한다. 다만 화면의 조건부 렌더링은 편의이지 게이트가 아니다 —
+ * 실제 차단은 서버측 판정(App\Support\OutboundProxy)이 맡고, 이 테스트는 화면이 그 의도와
+ * 어긋나지 않는지만 고정한다.
+ *
+ * 디버그 모드 OFF 케이스에서 SQL 쿼리 로그 토글이 함께 렌더되는 것을 먼저 확인한다.
+ * 그 확인이 없으면 "아직 렌더되지 않아서 없는 것" 과 "조건에 걸려 없는 것" 이 구분되지 않는다.
+ */
+
+import React from 'react';
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+import { createLayoutTest, screen } from '@core/template-engine/__tests__/utils/layoutTestUtils';
+import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
+
+const advancedPartial = JSON.parse(
+ readFileSync(resolve(__dirname, '../../layouts/partials/admin_settings/_tab_advanced.json'), 'utf-8')
+);
+
+// ---------------------------------------------------------------------------
+// 테스트용 컴포넌트
+// ---------------------------------------------------------------------------
+
+const TestDiv: React.FC = ({ className, children }) => {children}
;
+const TestInput: React.FC = ({ name, type }) => ;
+const TestToggle: React.FC = ({ name }) => (
+
+);
+const TestTagInput: React.FC = ({ name }) => ;
+const TestButton: React.FC = ({ children, text, disabled }) => (
+
+);
+const TestA: React.FC = ({ children, text }) => {children || text};
+const TestSpan: React.FC = ({ children, text }) => {children || text};
+const TestP: React.FC = ({ children, text }) => {children || text}
;
+const TestH3: React.FC = ({ children, text }) => {children || text}
;
+const TestFragment: React.FC = ({ children }) => <>{children}>;
+
+/**
+ * 테스트용 컴포넌트 레지스트리를 구성합니다.
+ *
+ * @returns 구성된 레지스트리
+ */
+function setupTestRegistry(): ComponentRegistry {
+ const registry = ComponentRegistry.getInstance();
+
+ (registry as any).registry = {
+ Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
+ Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } },
+ Toggle: { component: TestToggle, metadata: { name: 'Toggle', type: 'composite' } },
+ TagInput: { component: TestTagInput, metadata: { name: 'TagInput', type: 'composite' } },
+ A: { component: TestA, metadata: { name: 'A', type: 'basic' } },
+ Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
+ Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
+ P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
+ H3: { component: TestH3, metadata: { name: 'H3', type: 'basic' } },
+ Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
+ };
+
+ return registry;
+}
+
+/**
+ * id 로 노드를 깊이 우선 탐색합니다.
+ *
+ * @param node 탐색 시작 노드
+ * @param id 찾을 노드 id
+ * @returns 찾은 노드 또는 null
+ */
+function findNodeById(node: any, id: string): any {
+ if (!node || typeof node !== 'object') return null;
+ if (node.id === id) return node;
+ for (const child of node.children ?? []) {
+ const found = findNodeById(child, id);
+ if (found) return found;
+ }
+ return null;
+}
+
+/**
+ * partial 루트들에서 id 노드를 찾습니다.
+ *
+ * @param id 찾을 노드 id
+ * @returns 찾은 노드 또는 null
+ */
+function findInPartial(id: string): any {
+ for (const root of advancedPartial.components ?? [advancedPartial]) {
+ const found = findNodeById(root, id);
+ if (found) return found;
+ }
+ return null;
+}
+
+/**
+ * 주어진 폼 상태로 디버그 설정 카드를 렌더합니다.
+ *
+ * @param advanced 폼의 advanced 하위 상태
+ * @returns 레이아웃 테스트 유틸
+ */
+function renderDebugCard(advanced: Record) {
+ const card = findInPartial('card_debug_settings');
+ expect(card).not.toBeNull();
+
+ return createLayoutTest(
+ {
+ version: '1.0.0',
+ layout_name: 'test_outbound_proxy_visibility',
+ components: [card],
+ } as any,
+ {
+ initialState: {
+ _local: {
+ form: { advanced },
+ errors: {},
+ },
+ },
+ }
+ );
+}
+
+describe('아웃바운드 프록시 입력칸 노출 조건', () => {
+ let registry: ComponentRegistry;
+
+ beforeEach(() => {
+ registry = setupTestRegistry();
+ });
+
+ afterEach(() => {
+ (registry as any).registry = {};
+ });
+
+ // @scenario debug_mode=on, proxy_value=empty, bypass_list=empty
+ // @effects proxy_inputs_visible_when_debug_mode_on
+ it('디버그 모드가 켜져 있으면 프록시 주소와 예외 목록이 렌더된다', async () => {
+ const testUtils = renderDebugCard({ debug_mode: true, sql_query_log: false });
+ await testUtils.render();
+
+ expect(screen.getByTestId('advanced.outbound_proxy')).toBeInTheDocument();
+ expect(screen.getByTestId('tags-advanced.outbound_proxy_bypass')).toBeInTheDocument();
+
+ testUtils.cleanup();
+ });
+
+ // @scenario debug_mode=off, proxy_value=valid, bypass_list=empty
+ // @effects proxy_inputs_hidden_when_debug_mode_off
+ it('디버그 모드가 꺼져 있으면 프록시 입력칸이 렌더되지 않는다', async () => {
+ const testUtils = renderDebugCard({ debug_mode: false, sql_query_log: false });
+ await testUtils.render();
+
+ // 카드 자체는 렌더됐음을 먼저 확정한다 — 그래야 아래 부재 단언이 의미를 갖는다.
+ expect(screen.getByTestId('toggle-advanced.sql_query_log')).toBeInTheDocument();
+
+ expect(screen.queryByTestId('advanced.outbound_proxy')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('tags-advanced.outbound_proxy_bypass')).not.toBeInTheDocument();
+
+ testUtils.cleanup();
+ });
+
+ // @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
+ // @effects proxy_inputs_visible_when_debug_mode_on
+ it('레이아웃이 참조하는 폼 필드명이 서버 저장 키와 일치한다', () => {
+ const block = findInPartial('outbound_proxy_settings');
+ expect(block).not.toBeNull();
+
+ const names: string[] = [];
+ const collect = (node: any) => {
+ if (!node || typeof node !== 'object') return;
+ if (node.props?.name) names.push(node.props.name);
+ for (const child of node.children ?? []) collect(child);
+ };
+ collect(block);
+
+ expect(names).toContain('advanced.outbound_proxy');
+ expect(names).toContain('advanced.outbound_proxy_bypass');
+ });
+ // @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
+ // @effects proxy_connection_test_button_wired
+ it('연결 테스트 버튼이 제출값을 실어 테스트 엔드포인트를 호출하도록 배선되어 있다', () => {
+ const block = findInPartial('btn_test_outbound_proxy');
+ expect(block).not.toBeNull();
+
+ const apiCall = (block.actions ?? []).find((a: any) => a.handler === 'apiCall');
+ expect(apiCall).toBeDefined();
+ expect(apiCall.target).toBe('/api/admin/settings/test-outbound-proxy');
+ expect(apiCall.params.method).toBe('POST');
+
+ // 저장된 설정이 아니라 입력창의 현재 값을 보내야 저장 전 확인이 성립한다.
+ expect(apiCall.params.body.outbound_proxy).toContain('_local.form?.advanced?.outbound_proxy');
+ expect(apiCall.params.body.outbound_proxy_bypass).toContain('_local.form?.advanced?.outbound_proxy_bypass');
+
+ // 응답 후 로딩 해제가 성공/실패 양쪽에 걸려 있어야 버튼이 잠긴 채 남지 않는다.
+ for (const branch of ['onSuccess', 'onError']) {
+ const setState = (apiCall[branch] ?? []).find((a: any) => a.handler === 'setState');
+ expect(setState, branch).toBeDefined();
+ expect(setState.params.outboundProxyTesting, branch).toBe(false);
+ }
+ });
+});
diff --git a/templates/_bundled/sirsoft-admin_basic/editor-spec/sampleData.json b/templates/_bundled/sirsoft-admin_basic/editor-spec/sampleData.json
index 45ad464a..c0d1c358 100644
--- a/templates/_bundled/sirsoft-admin_basic/editor-spec/sampleData.json
+++ b/templates/_bundled/sirsoft-admin_basic/editor-spec/sampleData.json
@@ -13902,6 +13902,8 @@
"debug_mode": true,
"sql_query_log": false,
"log_level": "error",
+ "outbound_proxy": "",
+ "outbound_proxy_bypass": [],
"core_update_github_url": "https://github.com/gnuboard/g7",
"core_update_github_token": null,
"geoip_enabled": false,
@@ -13928,7 +13930,9 @@
"debug": {
"debug_mode": true,
"sql_query_log": false,
- "log_level": "error"
+ "log_level": "error",
+ "outbound_proxy": "",
+ "outbound_proxy_bypass": []
},
"drivers": {
"storage_driver": "local",
diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json
index 345b8e39..3daac052 100644
--- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json
+++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json
@@ -1782,6 +1782,13 @@
"dev_dashboard": "Dev Dashboard",
"sql_query_log": "SQL Query Log",
"sql_query_log_desc": "Log executed SQL queries. Log file location: /storage/logs/query.log",
+ "outbound_proxy": "Outbound proxy address",
+ "outbound_proxy_desc": "Every request this site sends out (payment approvals, core update checks, notifications) goes through this server. Use it when an external service restricts which IP addresses may connect. Leave empty to disable.",
+ "outbound_proxy_placeholder": "socks5h://127.0.0.1:1080",
+ "outbound_proxy_bypass": "Proxy bypass list",
+ "outbound_proxy_bypass_desc": "Requests to these addresses go out directly instead of through the proxy. Adding internal addresses avoids unnecessary detours.",
+ "outbound_proxy_bypass_placeholder": "Type an address and press Enter",
+ "outbound_proxy_test": "Test connection",
"pagination": "List limits",
"pagination_desc": "How far totals are counted on large lists, and the highest page number that can be requested directly. Beyond the cap the total is shown as \"N+\" and only the last-page jump is hidden — moving to the next page stays available.",
"pagination_result_cap": "Total count cap",
diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json
index 318a030e..613ffb2d 100644
--- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json
+++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/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": "연결 테스트",
"pagination": "목록 한계값",
"pagination_desc": "대용량 목록에서 총 건수를 세는 범위와 직접 요청 가능한 페이지 번호의 상한입니다. 상한을 넘으면 총 건수를 \"N건 이상\" 으로 표시하고 마지막 페이지 점프만 감춥니다 — 다음 페이지 이동은 그대로 열려 있습니다.",
"pagination_result_cap": "총 건수 집계 상한",
diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json
index d2cbd402..b27f4596 100644
--- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json
+++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json
@@ -905,6 +905,219 @@
}
}
]
+ },
+ {
+ "id": "outbound_proxy_settings",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{_local.form?.advanced?.debug_mode}}",
+ "props": {
+ "className": "ml-4 pl-4 border-l-2 border-blue-200 dark:border-blue-800 space-y-4"
+ },
+ "children": [
+ {
+ "id": "input_outbound_proxy",
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "row-stack"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-heading"
+ },
+ "text": "$t:admin.settings.advanced.outbound_proxy"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-label-subtle"
+ },
+ "text": "$t:admin.settings.advanced.outbound_proxy_desc"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "type": "text",
+ "name": "advanced.outbound_proxy",
+ "placeholder": "$t:admin.settings.advanced.outbound_proxy_placeholder",
+ "autoComplete": "off",
+ "disabled": "{{_computed.isReadOnly}}",
+ "className": "{{_local.errors?.['advanced.outbound_proxy'] ? 'input-error' : ''}}"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{_local.errors?.['advanced.outbound_proxy']}}",
+ "props": {
+ "className": "form-error"
+ },
+ "text": "{{_local.errors?.['advanced.outbound_proxy']?.[0] ?? ''}}"
+ },
+ {
+ "id": "outbound_proxy_test_row",
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex items-center gap-3 mt-2"
+ },
+ "children": [
+ {
+ "id": "btn_test_outbound_proxy",
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "px-3 py-1.5 bg-gray-700 dark:bg-gray-600 text-white dark:text-white text-xs font-medium rounded-md hover:bg-gray-800 dark:hover:bg-gray-500 transition-colors whitespace-nowrap disabled:opacity-50",
+ "disabled": "{{_computed.isReadOnly || !_local.form?.advanced?.outbound_proxy || _local.outboundProxyTesting}}"
+ },
+ "text": "$t:admin.settings.advanced.outbound_proxy_test",
+ "actions": [
+ {
+ "event": "click",
+ "handler": "setState",
+ "params": {
+ "target": "local",
+ "outboundProxyTesting": true
+ }
+ },
+ {
+ "event": "click",
+ "handler": "apiCall",
+ "target": "/api/admin/settings/test-outbound-proxy",
+ "auth_required": true,
+ "params": {
+ "method": "POST",
+ "body": {
+ "outbound_proxy": "{{_local.form?.advanced?.outbound_proxy}}",
+ "outbound_proxy_bypass": "{{_local.form?.advanced?.outbound_proxy_bypass ?? []}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "local",
+ "outboundProxyTesting": false,
+ "outboundProxyTest": "{{response.data}}",
+ "outboundProxyTestMessage": "{{response.message}}"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "type": "{{response.data?.success ? 'success' : 'error'}}",
+ "message": "{{response.message}}"
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "local",
+ "outboundProxyTesting": false,
+ "outboundProxyTest": null,
+ "outboundProxyTestMessage": "{{error.message}}"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "type": "error",
+ "message": "{{error.message}}"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "outbound_proxy_test_result",
+ "type": "basic",
+ "name": "Span",
+ "if": "{{!!_local.outboundProxyTestMessage && !_local.outboundProxyTesting}}",
+ "props": {
+ "className": "{{_local.outboundProxyTest?.success ? 'text-xs text-green-600 dark:text-green-400' : 'text-xs text-red-600 dark:text-red-400'}}"
+ },
+ "text": "{{_local.outboundProxyTest?.egress_ip ? (_local.outboundProxyTestMessage + ' (' + _local.outboundProxyTest.egress_ip + ')') : _local.outboundProxyTestMessage}}"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "input_outbound_proxy_bypass",
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "row-stack"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-heading"
+ },
+ "text": "$t:admin.settings.advanced.outbound_proxy_bypass"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-label-subtle"
+ },
+ "text": "$t:admin.settings.advanced.outbound_proxy_bypass_desc"
+ },
+ {
+ "type": "composite",
+ "name": "TagInput",
+ "props": {
+ "name": "advanced.outbound_proxy_bypass",
+ "creatable": true,
+ "placeholder": "$t:admin.settings.advanced.outbound_proxy_bypass_placeholder",
+ "className": "w-full",
+ "defaultVariant": "blue",
+ "disabled": "{{_computed.isReadOnly}}"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{_local.errors?.['advanced.outbound_proxy_bypass']}}",
+ "props": {
+ "className": "form-error"
+ },
+ "text": "{{_local.errors?.['advanced.outbound_proxy_bypass']?.[0] ?? ''}}"
+ }
+ ]
+ }
+ ]
}
]
}
diff --git a/templates/_bundled/sirsoft-admin_basic/package-lock.json b/templates/_bundled/sirsoft-admin_basic/package-lock.json
index 04fc26af..c783e329 100644
--- a/templates/_bundled/sirsoft-admin_basic/package-lock.json
+++ b/templates/_bundled/sirsoft-admin_basic/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sirsoft-admin_basic",
- "version": "1.0.5",
+ "version": "1.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sirsoft-admin_basic",
- "version": "1.0.5",
+ "version": "1.0.6",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
diff --git a/templates/_bundled/sirsoft-admin_basic/package.json b/templates/_bundled/sirsoft-admin_basic/package.json
index 745366a6..90f5d65b 100644
--- a/templates/_bundled/sirsoft-admin_basic/package.json
+++ b/templates/_bundled/sirsoft-admin_basic/package.json
@@ -1,6 +1,6 @@
{
"name": "sirsoft-admin_basic",
- "version": "1.0.5",
+ "version": "1.0.6",
"description": "Gnuboard7 Basic Admin Template Components",
"type": "module",
"main": "dist/components.js",
diff --git a/templates/_bundled/sirsoft-admin_basic/template.json b/templates/_bundled/sirsoft-admin_basic/template.json
index 70f7689b..9c1f06ec 100644
--- a/templates/_bundled/sirsoft-admin_basic/template.json
+++ b/templates/_bundled/sirsoft-admin_basic/template.json
@@ -5,7 +5,7 @@
"ko": "Admin Basic",
"en": "Admin Basic"
},
- "version": "1.0.5",
+ "version": "1.0.6",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 관리자 템플릿",
diff --git a/tests/Feature/Settings/OutboundProxySettingTest.php b/tests/Feature/Settings/OutboundProxySettingTest.php
new file mode 100644
index 00000000..7801ac0f
--- /dev/null
+++ b/tests/Feature/Settings/OutboundProxySettingTest.php
@@ -0,0 +1,322 @@
+saveSettings([
+ '_tab' => 'advanced',
+ 'advanced' => [
+ 'outbound_proxy' => 'socks5h://127.0.0.1:1080',
+ 'outbound_proxy_bypass' => ['g7.dev', 'localhost'],
+ ],
+ ]);
+
+ $this->assertTrue($saved, '고급 탭 저장이 실패했습니다.');
+
+ $debug = app(ConfigRepositoryInterface::class)->getCategory('debug');
+
+ $this->assertSame(
+ 'socks5h://127.0.0.1:1080',
+ $debug['outbound_proxy'] ?? null,
+ 'debug.outbound_proxy 가 저장되지 않았습니다 — 고급 탭 분류표에서 누락되면 값이 조용히 버려집니다.'
+ );
+ $this->assertSame(['g7.dev', 'localhost'], $debug['outbound_proxy_bypass'] ?? null);
+ }
+
+ /**
+ * 기존 설치본에도 새 키가 기본값으로 노출되는지 확인합니다.
+ *
+ * 설정 JSON 은 defaults 와 병합되어 읽히므로 별도 마이그레이션 없이 키가 생겨야 합니다.
+ *
+ * @scenario debug_mode=on, proxy_value=empty, bypass_list=empty
+ *
+ * @effects proxy_setting_persists_to_debug_category
+ */
+ public function test_new_keys_are_present_through_defaults_merge(): void
+ {
+ $debug = app(ConfigRepositoryInterface::class)->getCategory('debug');
+
+ $this->assertArrayHasKey('outbound_proxy', $debug);
+ $this->assertArrayHasKey('outbound_proxy_bypass', $debug);
+ $this->assertSame('', $debug['outbound_proxy']);
+ $this->assertSame([], $debug['outbound_proxy_bypass']);
+ }
+
+ /**
+ * 적용할 수 없는 형태의 주소는 저장 검증에서 막습니다.
+ *
+ * @scenario debug_mode=on, proxy_value=invalid, bypass_list=empty
+ *
+ * @effects proxy_setting_rejects_invalid_url
+ */
+ public function test_invalid_proxy_url_fails_validation(): void
+ {
+ $validator = $this->validatorForAdvanced([
+ 'outbound_proxy' => 'ftp://proxy.internal:21',
+ ]);
+
+ $this->assertTrue(
+ $validator->fails(),
+ '허용 목록에 없는 스킴이 검증을 통과했습니다 — 저장은 되고 적용은 안 되는 상태가 됩니다.'
+ );
+ $this->assertArrayHasKey('advanced.outbound_proxy', $validator->errors()->messages());
+ }
+
+ /**
+ * 유효한 주소와 빈 값은 검증을 통과합니다.
+ *
+ * @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
+ *
+ * @effects proxy_setting_rejects_invalid_url
+ */
+ public function test_valid_and_empty_proxy_url_pass_validation(): void
+ {
+ foreach (['socks5h://127.0.0.1:1080', 'http://proxy.internal:3128', ''] as $url) {
+ $validator = $this->validatorForAdvanced(['outbound_proxy' => $url]);
+
+ $this->assertFalse(
+ $validator->errors()->has('advanced.outbound_proxy'),
+ "유효한 주소 '{$url}' 가 거부됐습니다."
+ );
+ }
+ }
+
+ /**
+ * 예외 목록의 각 항목이 문자열·길이 검증을 받는지 확인합니다.
+ *
+ * @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
+ *
+ * @effects proxy_setting_rejects_invalid_url
+ */
+ public function test_bypass_list_items_are_validated(): void
+ {
+ $validator = $this->validatorForAdvanced([
+ 'outbound_proxy_bypass' => [str_repeat('a', 256)],
+ ]);
+
+ $this->assertTrue($validator->fails());
+ $this->assertArrayHasKey('advanced.outbound_proxy_bypass.0', $validator->errors()->messages());
+ }
+
+ /**
+ * 판정 결과가 있으면 Http 전역 옵션에 프록시가 실립니다.
+ *
+ * @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
+ *
+ * @effects proxy_applied_to_global_http_options
+ */
+ public function test_resolved_proxy_is_applied_to_global_http_options(): void
+ {
+ $proxy = [
+ 'http' => 'socks5h://127.0.0.1:1080',
+ 'https' => 'socks5h://127.0.0.1:1080',
+ 'no' => ['g7.dev'],
+ ];
+
+ config(['g7.outbound_proxy' => $proxy]);
+ $this->invokeOutboundProxyConfigurer();
+
+ $this->assertSame(
+ ['proxy' => $proxy],
+ $this->globalHttpOptions(),
+ 'Http 전역 옵션에 프록시가 실리지 않았습니다 — 설정은 저장되어도 실제 요청은 그대로 나갑니다.'
+ );
+ }
+
+ /**
+ * 판정 결과가 없으면 Http 전역 옵션을 건드리지 않습니다.
+ *
+ * 디버그 모드가 꺼져 있으면 판정이 null 을 돌려주므로, 이 경로가 프록시 미적용을 보장합니다.
+ *
+ * @scenario debug_mode=off, proxy_value=valid, bypass_list=empty
+ *
+ * @effects proxy_absent_leaves_global_http_options_untouched
+ */
+ public function test_absent_proxy_leaves_global_http_options_untouched(): void
+ {
+ foreach ([null, [], ''] as $value) {
+ config(['g7.outbound_proxy' => $value]);
+ $this->invokeOutboundProxyConfigurer();
+
+ $this->assertSame(
+ [],
+ $this->globalHttpOptions(),
+ '프록시 미적용 상태인데 Http 전역 옵션이 설정됐습니다.'
+ );
+ }
+ }
+
+ /**
+ * 고급 탭 검증기를 구성합니다.
+ *
+ * @param array $advanced advanced 탭 입력값
+ */
+ private function validatorForAdvanced(array $advanced): Validator
+ {
+ $request = new SaveSettingsRequest;
+ $request->merge(['_tab' => 'advanced', 'advanced' => $advanced]);
+ $request->setContainer(app());
+
+ return validator(
+ $request->all(),
+ $request->rules(),
+ $request->messages(),
+ $request->attributes()
+ );
+ }
+
+ /**
+ * AppServiceProvider 의 프록시 적용 로직만 실행합니다.
+ */
+ private function invokeOutboundProxyConfigurer(): void
+ {
+ $this->resetGlobalHttpOptions();
+
+ $provider = new AppServiceProvider($this->app);
+ $method = new ReflectionMethod($provider, 'configureOutboundProxy');
+ $method->setAccessible(true);
+ $method->invoke($provider);
+ }
+
+ /**
+ * Http 팩토리의 전역 옵션을 비웁니다.
+ */
+ private function resetGlobalHttpOptions(): void
+ {
+ Http::globalOptions([]);
+ }
+
+ /**
+ * Http 팩토리에 설정된 전역 옵션을 읽습니다.
+ *
+ * @return array
+ */
+ private function globalHttpOptions(): array
+ {
+ $factory = $this->app->make(Factory::class);
+ $property = new ReflectionProperty($factory, 'globalOptions');
+ $property->setAccessible(true);
+
+ return (array) value($property->getValue($factory));
+ }
+
+ /**
+ * 연결 테스트가 프록시를 거친 출발지 IP 를 보고합니다.
+ *
+ * 출발지 IP 는 운영자가 결제사에 등록해야 하는 값이라, 저장하기 전에 알 수 있어야 합니다.
+ *
+ * @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
+ *
+ * @effects proxy_connection_test_reports_egress_ip
+ */
+ public function test_connection_test_reports_egress_ip(): void
+ {
+ Http::fake(['*' => Http::response('203.0.113.9', 200)]);
+
+ $result = app(OutboundProxyTester::class)->test('socks5h://127.0.0.1:1080');
+
+ $this->assertTrue($result['success']);
+ $this->assertSame('203.0.113.9', $result['egress_ip']);
+ $this->assertSame('settings.outbound_proxy_test_success', $result['message_key']);
+ }
+
+ /**
+ * 연결 테스트는 제출된 값을 검사합니다 — 저장된 설정이나 전역 옵션을 보지 않습니다.
+ *
+ * @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
+ *
+ * @effects proxy_connection_test_reports_egress_ip
+ */
+ public function test_connection_test_uses_submitted_proxy_not_stored_settings(): void
+ {
+ config(['g7.outbound_proxy' => null]);
+ Http::globalOptions([]);
+
+ Http::fake(['*' => Http::response('203.0.113.9', 200)]);
+
+ $result = app(OutboundProxyTester::class)->test('socks5h://10.0.0.9:1080', ['g7.dev']);
+
+ // 전역 프록시가 없는 상태에서도 제출값으로 요청이 나갔다.
+ Http::assertSentCount(1);
+ $this->assertTrue($result['success']);
+
+ // 전역 옵션은 비어 있는 채여야 한다 — 테스트가 전역 상태를 바꾸면 이후 모든 요청이
+ // 저장하지도 않은 프록시를 타게 된다.
+ $this->assertSame([], $this->globalHttpOptions());
+ }
+
+ /**
+ * 적용 불가 주소는 외부 호출 없이 즉시 거부합니다.
+ *
+ * @scenario debug_mode=on, proxy_value=invalid, bypass_list=empty
+ *
+ * @effects proxy_connection_test_reports_egress_ip
+ */
+ public function test_connection_test_rejects_invalid_url_without_calling_out(): void
+ {
+ Http::fake();
+
+ $result = app(OutboundProxyTester::class)->test('ftp://proxy.internal:21');
+
+ $this->assertFalse($result['success']);
+ $this->assertSame('settings.outbound_proxy_test_invalid_url', $result['message_key']);
+ Http::assertNothingSent();
+ }
+
+ /**
+ * 조회 대상이 없으면 확인 불가로 보고합니다 — 실패와 구분되어야 합니다.
+ *
+ * @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
+ *
+ * @effects proxy_connection_test_reports_egress_ip
+ */
+ public function test_connection_test_reports_when_no_lookup_target_is_configured(): void
+ {
+ config(['core.outbound_proxy.egress_lookup_urls' => []]);
+ Http::fake();
+
+ $result = app(OutboundProxyTester::class)->test('socks5h://127.0.0.1:1080');
+
+ $this->assertFalse($result['success']);
+ $this->assertSame('settings.outbound_proxy_test_no_lookup_url', $result['message_key']);
+ Http::assertNothingSent();
+ }
+}
diff --git a/tests/Playwright/specs/admin/settings-outbound-proxy.spec.ts b/tests/Playwright/specs/admin/settings-outbound-proxy.spec.ts
new file mode 100644
index 00000000..2059f61f
--- /dev/null
+++ b/tests/Playwright/specs/admin/settings-outbound-proxy.spec.ts
@@ -0,0 +1,152 @@
+/**
+ * E2E: 환경설정 > 고급 — 아웃바운드 HTTP 프록시 (#600)
+ *
+ * 시나리오 매니페스트: tests/scenarios/outbound-http-proxy.yaml — 마킹은 각 테스트의
+ * scenario(k=v 조합)·effects 주석이 담당하며, 헤더 요약 마킹은 파서 형식이 아니다
+ *
+ * 배경: 접속 IP 를 제한하는 결제사 API 를 로컬에서 테스트하려면 서버가 내보내는 요청의
+ * 출발지 IP 를 바꿔야 한다. 브라우저 프록시로는 바뀌지 않는 축이라 코어 설정으로 도입했다.
+ *
+ * 검증:
+ * 1. 디버그 모드가 꺼져 있으면 프록시 입력칸이 화면에 없다
+ * 2. 디버그 모드를 켜면 프록시 주소 Input 과 예외 목록 TagInput 이 마운트된다
+ * 3. 적용할 수 없는 형태의 주소는 저장이 거부되고 해당 필드에 inline 에러가 표시된다
+ */
+import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
+
+/**
+ * 관리자 환경설정 고급 탭 진입.
+ *
+ * 이 화면은 하드 로드 시 설정 응답이 도착하기 전에도 폼이 렌더되어 조작할 수 있고,
+ * 그 조작은 뒤늦게 도착한 `initLocal` 시드에 덮인다(실측 노출 창 300~430ms).
+ * 제품 차원의 잠금은 두지 않기로 했으므로(#600 PO 결정), 테스트가 시드 완료를
+ * 기다린 뒤에 조작한다.
+ */
+async function gotoAdvancedTab(page: import('@playwright/test').Page): Promise {
+ await page.goto('/admin/settings?tab=advanced');
+ await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
+ await expect(page.locator('#card_debug_settings')).toBeAttached({ timeout: 20_000 });
+
+ await expect
+ .poll(
+ () =>
+ page.evaluate(
+ () => Object.keys((window as any).G7Core?.state?.getLocal?.()?.form?.advanced ?? {}).length
+ ),
+ { timeout: 20_000 }
+ )
+ .toBeGreaterThan(0);
+}
+
+/** 디버그 모드 토글을 켠다 (이미 켜져 있으면 그대로 둔다) */
+async function enableDebugMode(page: import('@playwright/test').Page): Promise {
+ const checkbox = page.locator('input[name="advanced.debug_mode"]').first();
+ await expect(checkbox).toBeAttached({ timeout: 10_000 });
+
+ if (!(await checkbox.isChecked())) {
+ // Toggle 은 sr-only checkbox 를 감싼 wrapper 가 클릭 대상이다
+ await page.locator('#toggle_debug_mode .toggle-switch-wrapper').first().click();
+ }
+
+ await expect(page.locator('#outbound_proxy_settings')).toBeAttached({ timeout: 10_000 });
+}
+
+// @scenario debug_mode=off, proxy_value=valid, bypass_list=empty
+// @effects proxy_inputs_hidden_when_debug_mode_off
+test('#600 - 디버그 모드가 꺼져 있으면 프록시 입력칸이 없다', async ({ page }) => {
+ const token = issueToken('core.settings.read', 'core.settings.update');
+ await authenticatePage(page, token);
+
+ await gotoAdvancedTab(page);
+ expect(page.url()).not.toMatch(/\/admin\/login/);
+
+ // 존재 확정: 같은 카드의 SQL 쿼리 로그 토글은 조건 없이 렌더된다.
+ // 이 확인이 없으면 아래 부재 단언이 "아직 렌더 전" 과 구분되지 않는다.
+ await expect(page.locator('[name="advanced.sql_query_log"]').first()).toBeAttached();
+
+ await expect(page.locator('#outbound_proxy_settings')).toHaveCount(0);
+ await expect(page.locator('input[name="advanced.outbound_proxy"]')).toHaveCount(0);
+});
+
+// @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
+// @effects proxy_inputs_visible_when_debug_mode_on
+test('@smoke #600 - 디버그 모드를 켜면 프록시 주소와 예외 목록이 마운트된다', async ({ page }) => {
+ const token = issueToken('core.settings.read', 'core.settings.update');
+ await authenticatePage(page, token);
+
+ await gotoAdvancedTab(page);
+ await enableDebugMode(page);
+
+ const proxyInput = page.locator('input[name="advanced.outbound_proxy"]').first();
+ await expect(proxyInput).toBeAttached();
+
+ await proxyInput.fill('socks5h://127.0.0.1:1080');
+ await expect(proxyInput).toHaveValue('socks5h://127.0.0.1:1080');
+
+ // 예외 목록은 TagInput — 자유 입력 후 Enter 로 항목이 된다
+ await expect(page.locator('#input_outbound_proxy_bypass')).toBeAttached();
+});
+
+// @scenario debug_mode=on, proxy_value=invalid, bypass_list=empty
+// @effects proxy_setting_rejects_invalid_url
+test('#600 - 허용되지 않는 형태의 프록시 주소는 저장이 거부된다', async ({ page }) => {
+ const token = issueToken('core.settings.read', 'core.settings.update');
+ await authenticatePage(page, token);
+
+ await gotoAdvancedTab(page);
+ await enableDebugMode(page);
+
+ const proxyInput = page.locator('input[name="advanced.outbound_proxy"]').first();
+ await proxyInput.fill('ftp://proxy.internal:21');
+
+ const saveResponse = page.waitForResponse(
+ (response) => response.request().method() === 'POST' && /\/api\/admin\/settings\/?$/.test(new URL(response.url()).pathname),
+ { timeout: 20_000 }
+ );
+
+ await page.getByRole('button', { name: /저장|Save/ }).first().click();
+
+ const response = await saveResponse;
+ expect(response.status()).toBe(422);
+
+ await expect(page.locator('#input_outbound_proxy .form-error')).toBeVisible({ timeout: 10_000 });
+});
+
+// @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
+// @effects proxy_connection_test_button_wired
+test('#600 - 연결 테스트가 프록시를 거친 출발지 IP 를 화면에 보고한다', async ({ page }) => {
+ const token = issueToken('core.settings.read', 'core.settings.update');
+ await authenticatePage(page, token);
+
+ await gotoAdvancedTab(page);
+ await enableDebugMode(page);
+
+ // 프록시 주소를 비운 상태에서는 테스트 버튼이 잠겨 있다 (보낼 값이 없다)
+ await expect(page.locator('#btn_test_outbound_proxy')).toBeDisabled();
+
+ await page.locator('input[name="advanced.outbound_proxy"]').first().fill('socks5h://127.0.0.1:1080');
+ await expect(page.locator('#btn_test_outbound_proxy')).toBeEnabled();
+
+ // 외부 프록시 서버는 E2E 환경에 없으므로 응답만 스텁한다 —
+ // 버튼 배선·요청 페이로드·결과 렌더는 전부 실경로다.
+ await page.route('**/api/admin/settings/test-outbound-proxy', async (route) => {
+ const body = route.request().postDataJSON();
+ expect(body.outbound_proxy).toBe('socks5h://127.0.0.1:1080');
+
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ success: true,
+ message: '프록시 연결에 성공했습니다. 외부 서비스에는 이 IP 로 보입니다.',
+ data: { success: true, egress_ip: '203.0.113.9', elapsed_ms: 120, error: null },
+ }),
+ });
+ });
+
+ await page.locator('#btn_test_outbound_proxy').click();
+
+ const result = page.locator('#outbound_proxy_test_result');
+ await expect(result).toBeVisible({ timeout: 10_000 });
+ await expect(result).toContainText('203.0.113.9');
+});
diff --git a/tests/Unit/Support/OutboundProxyTest.php b/tests/Unit/Support/OutboundProxyTest.php
new file mode 100644
index 00000000..4a8cc9ed
--- /dev/null
+++ b/tests/Unit/Support/OutboundProxyTest.php
@@ -0,0 +1,258 @@
+ false,
+ 'outbound_proxy' => 'socks5h://127.0.0.1:1080',
+ 'outbound_proxy_bypass' => ['internal.example'],
+ ]);
+
+ $this->assertNull(
+ $resolved,
+ '디버그 모드가 꺼진 상태에서 프록시가 적용되었습니다 — 화면 조건부 렌더링은 저장 API 직접 호출을 막지 못하므로 이 판정이 유일한 게이트입니다.'
+ );
+ }
+
+ /**
+ * mode 키 자체가 없는 설정도 미적용으로 판정합니다.
+ *
+ * @scenario debug_mode=off, proxy_value=valid, bypass_list=empty
+ *
+ * @effects proxy_not_applied_when_debug_mode_off
+ */
+ public function test_proxy_is_not_applied_when_mode_key_is_absent(): void
+ {
+ $this->assertNull(
+ OutboundProxy::resolve(['outbound_proxy' => 'http://proxy.internal:3128']),
+ 'mode 키 부재를 디버그 모드 ON 으로 해석했습니다 — 판정은 fail-closed 여야 합니다.'
+ );
+ }
+
+ /**
+ * 디버그 모드가 켜져 있고 주소가 유효하면 http/https 양쪽에 적용합니다.
+ *
+ * @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
+ *
+ * @effects proxy_applied_when_debug_mode_on
+ */
+ public function test_proxy_is_applied_for_both_schemes_when_debug_mode_is_on(): void
+ {
+ $resolved = OutboundProxy::resolve([
+ 'mode' => true,
+ 'outbound_proxy' => 'socks5h://127.0.0.1:1080',
+ ]);
+
+ $this->assertSame([
+ 'http' => 'socks5h://127.0.0.1:1080',
+ 'https' => 'socks5h://127.0.0.1:1080',
+ 'no' => [],
+ ], $resolved);
+ }
+
+ /**
+ * 값이 비어 있으면 프록시를 쓰지 않는 상태로 판정합니다.
+ *
+ * @scenario debug_mode=on, proxy_value=empty, bypass_list=empty
+ *
+ * @effects proxy_applied_when_debug_mode_on
+ */
+ public function test_empty_proxy_value_disables_proxy(): void
+ {
+ $this->assertNull(OutboundProxy::resolve(['mode' => true, 'outbound_proxy' => '']));
+ $this->assertNull(OutboundProxy::resolve(['mode' => true, 'outbound_proxy' => ' ']));
+ $this->assertNull(OutboundProxy::resolve(['mode' => true]));
+ }
+
+ /**
+ * 허용 목록에 없는 스킴과 호스트 없는 주소는 적용하지 않습니다.
+ *
+ * @scenario debug_mode=on, proxy_value=invalid, bypass_list=empty
+ *
+ * @effects proxy_rejects_disallowed_scheme
+ *
+ * @dataProvider invalidProxyUrls
+ */
+ public function test_invalid_proxy_url_is_not_applied(string $url, string $why): void
+ {
+ $this->assertNull(
+ OutboundProxy::resolve(['mode' => true, 'outbound_proxy' => $url]),
+ $why
+ );
+ $this->assertFalse(OutboundProxy::isValidUrl($url), $why);
+ }
+
+ /**
+ * 적용 불가 주소 목록.
+ *
+ * @return array
+ */
+ public static function invalidProxyUrls(): array
+ {
+ return [
+ '스킴 없음' => ['127.0.0.1:1080', '스킴 없는 주소가 통과했습니다 — cURL 이 프록시로 해석하지 못합니다.'],
+ '파일 스킴' => ['file:///etc/passwd', 'file 스킴이 통과했습니다.'],
+ 'ftp 스킴' => ['ftp://proxy.internal:21', '허용 목록에 없는 스킴이 통과했습니다.'],
+ '호스트 없음' => ['http://', '호스트 없는 주소가 통과했습니다.'],
+ '빈 문자열' => ['', '빈 값이 유효 주소로 판정됐습니다.'],
+ ];
+ }
+
+ /**
+ * 허용 스킴은 모두 적용 가능해야 합니다.
+ *
+ * @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
+ *
+ * @effects proxy_applied_when_debug_mode_on
+ */
+ public function test_every_allowed_scheme_is_accepted(): void
+ {
+ foreach (OutboundProxy::ALLOWED_SCHEMES as $scheme) {
+ $this->assertTrue(
+ OutboundProxy::isValidUrl($scheme.'://proxy.internal:1080'),
+ "허용 목록의 스킴 {$scheme} 이 거부됐습니다 — 목록과 판정이 어긋나면 저장은 되는데 적용되지 않습니다."
+ );
+ }
+ }
+
+ /**
+ * 예외 목록은 공백 제거·빈 항목 제거·중복 제거 후 순번을 다시 매깁니다.
+ *
+ * @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
+ *
+ * @effects proxy_bypass_list_normalized
+ */
+ public function test_bypass_list_is_normalized(): void
+ {
+ $resolved = OutboundProxy::resolve([
+ 'mode' => true,
+ 'outbound_proxy' => 'http://proxy.internal:3128',
+ 'outbound_proxy_bypass' => [' g7.dev ', '', 'g7.dev', 'localhost', 42, null],
+ ]);
+
+ $this->assertSame(['g7.dev', 'localhost'], $resolved['no']);
+ $this->assertSame(
+ [0, 1],
+ array_keys($resolved['no']),
+ '예외 목록 키가 비연속입니다 — JSON 직렬화 시 객체가 되어 목록으로 읽히지 않습니다.'
+ );
+ }
+
+ /**
+ * 예외 목록이 배열이 아니면 빈 목록으로 취급합니다.
+ *
+ * @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
+ *
+ * @effects proxy_bypass_list_normalized
+ */
+ public function test_non_array_bypass_list_becomes_empty(): void
+ {
+ $resolved = OutboundProxy::resolve([
+ 'mode' => true,
+ 'outbound_proxy' => 'http://proxy.internal:3128',
+ 'outbound_proxy_bypass' => 'g7.dev',
+ ]);
+
+ $this->assertSame([], $resolved['no']);
+ }
+
+ /**
+ * 적용 중인 프록시는 curl 옵션 형태로도 제공됩니다.
+ *
+ * `Http::` 파사드를 쓰지 못하는 호출 지점(외부 SDK 규약상 curl 핸들을 직접 다뤄야 하는
+ * 경우)이 같은 프록시를 타려면 이 통로가 필요합니다. 이 통로가 없으면 그 경로만 조용히
+ * 직접 나가고, 요청은 정상 성공하므로 아무 신호도 남지 않습니다.
+ *
+ * @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
+ *
+ * @effects proxy_exposed_as_curl_options
+ */
+ public function test_resolved_proxy_is_exposed_as_curl_options(): void
+ {
+ config(['g7.outbound_proxy' => OutboundProxy::resolve([
+ 'mode' => true,
+ 'outbound_proxy' => 'socks5h://127.0.0.1:1080',
+ 'outbound_proxy_bypass' => ['g7.dev', 'localhost'],
+ ])]);
+
+ $this->assertSame([
+ CURLOPT_PROXY => 'socks5h://127.0.0.1:1080',
+ CURLOPT_NOPROXY => 'g7.dev,localhost',
+ ], OutboundProxy::curlOptions());
+ }
+
+ /**
+ * 프록시 미적용 상태에서는 빈 배열이라 curl_setopt_array 에 그대로 넘겨도 무해합니다.
+ *
+ * @scenario debug_mode=off, proxy_value=valid, bypass_list=empty
+ *
+ * @effects proxy_exposed_as_curl_options
+ */
+ public function test_curl_options_are_empty_when_proxy_is_not_applied(): void
+ {
+ config(['g7.outbound_proxy' => OutboundProxy::resolve([
+ 'mode' => false,
+ 'outbound_proxy' => 'socks5h://127.0.0.1:1080',
+ ])]);
+
+ $this->assertSame([], OutboundProxy::curlOptions());
+ }
+
+ /**
+ * 저장 전 연결 테스트와 실제 적용이 같은 조립·정규화를 거칩니다.
+ *
+ * 이 테스트의 목적은 "저장 전에 확인한다" 는 기능의 전제를 지키는 것입니다. 조립을 두 곳에서
+ * 각각 하면 예외 목록의 공백·빈 항목·중복 처리가 어긋나, 운영자가 확인한 구성과 저장 후
+ * 실제로 적용되는 구성이 달라집니다. 두 구성 모두 정상 동작하므로 어긋남 자체는 아무런
+ * 오류도 남기지 않습니다.
+ *
+ * @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
+ *
+ * @effects proxy_bypass_list_normalized
+ */
+ public function test_connection_test_and_applied_config_share_the_same_assembly(): void
+ {
+ $url = ' socks5h://127.0.0.1:1080 ';
+ $bypass = [' g7.dev ', '', 'g7.dev', 'localhost'];
+
+ // 실제 적용 경로 (SettingsServiceProvider → config('g7.outbound_proxy'))
+ $applied = OutboundProxy::resolve([
+ 'mode' => true,
+ 'outbound_proxy' => $url,
+ 'outbound_proxy_bypass' => $bypass,
+ ]);
+
+ // 저장 전 연결 테스트 경로 (OutboundProxyTester)
+ $tested = OutboundProxy::options($url, $bypass);
+
+ $this->assertSame($applied, $tested);
+ $this->assertSame(['g7.dev', 'localhost'], $tested['no']);
+ $this->assertSame('socks5h://127.0.0.1:1080', $tested['https']);
+ }
+}
diff --git a/tests/scenarios/outbound-http-proxy.yaml b/tests/scenarios/outbound-http-proxy.yaml
new file mode 100644
index 00000000..f1a10153
--- /dev/null
+++ b/tests/scenarios/outbound-http-proxy.yaml
@@ -0,0 +1,51 @@
+feature: 코어 아웃바운드 HTTP 프록시 설정
+
+description: |
+ 코어가 바깥으로 내보내는 모든 HTTP 요청(결제 승인, 코어 업데이트 조회, GeoIP 내려받기,
+ 알림 웹훅)을 운영자가 지정한 프록시로 경유시키는 환경설정. 접속 IP 를 제한하는 외부
+ 서비스를 로컬·스테이징 환경에서 연동하기 위한 것이다.
+
+ 핵심 동작:
+ - 저장 키는 debug 카테고리 소속 (debug.outbound_proxy, debug.outbound_proxy_bypass)
+ - 적용 게이트는 디버그 모드 — App\Support\OutboundProxy 가 단독 판정
+ - 디버그 모드 OFF 면 저장값이 남아 있어도 미적용 (화면 조건부 렌더링은 게이트가 아님)
+ - 적용은 Http::globalOptions 전역 옵션 — 확장의 Http:: 호출까지 함께 경유
+ - 개별 요청의 withOptions(['proxy' => ...]) 가 전역 옵션보다 우선
+ - Http:: 파사드를 쓰지 못하는 curl 직접 호출은 OutboundProxy::curlOptions() 로 편입
+ - 저장 전 연결 테스트는 저장값이 아니라 제출값을 검사하고 출발지 IP 를 보고
+
+ 축 설계: 판정에 실제로 영향을 주는 입력만 축으로 둔다. 검증 지점(단위/저장/적용/화면)은
+ 같은 조합을 여러 계층에서 확인하는 것이므로 축이 아니라 테스트 파일의 분담이다.
+
+axes:
+ debug_mode: [on, off]
+ proxy_value: [valid, invalid, empty]
+ bypass_list: [empty, normalizable]
+
+exclusions:
+ - { debug_mode: off, proxy_value: invalid, reason: "게이트가 먼저 걸려 주소 형태를 평가하지 않는다" }
+ - { debug_mode: off, proxy_value: empty, reason: "동일 — 미적용 판정이 주소보다 앞선다" }
+ - { debug_mode: off, bypass_list: normalizable, reason: "미적용 판정에서는 예외 목록을 계산하지 않는다" }
+ - { proxy_value: invalid, bypass_list: normalizable, reason: "주소가 거부되면 예외 목록은 계산되지 않는다" }
+ - { proxy_value: empty, bypass_list: normalizable, reason: "프록시를 쓰지 않으면 예외 목록은 의미가 없다" }
+
+effects:
+ - proxy_not_applied_when_debug_mode_off
+ - proxy_applied_when_debug_mode_on
+ - proxy_rejects_disallowed_scheme
+ - proxy_bypass_list_normalized
+ - proxy_setting_persists_to_debug_category
+ - proxy_setting_rejects_invalid_url
+ - proxy_applied_to_global_http_options
+ - proxy_absent_leaves_global_http_options_untouched
+ - proxy_inputs_hidden_when_debug_mode_off
+ - proxy_inputs_visible_when_debug_mode_on
+ - proxy_exposed_as_curl_options
+ - proxy_connection_test_reports_egress_ip
+ - proxy_connection_test_button_wired
+
+test_files:
+ - tests/Unit/Support/OutboundProxyTest.php
+ - tests/Feature/Settings/OutboundProxySettingTest.php
+ - templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-outbound-proxy-visibility.test.tsx
+ - tests/Playwright/specs/admin/settings-outbound-proxy.spec.ts
From d8ca2814647f0ecc334916a50413ea1c076bd4ba Mon Sep 17 00:00:00 2001
From: HeuJung
Date: Fri, 21 Aug 2026 13:53:42 +0900
Subject: [PATCH 2/7] =?UTF-8?q?chore(release):=20=EC=BD=94=EC=96=B4=207.1.?=
=?UTF-8?q?0=20=E2=86=92=207.0.8=20=ED=95=98=ED=96=A5=20=EB=B0=8F=207.0.7?=
=?UTF-8?q?=20=EC=9D=B4=ED=9B=84=20=EB=B3=80=EA=B2=BD=EB=B6=84=20=EC=B6=9C?=
=?UTF-8?q?=EC=8B=9C=EC=9D=BC=20=EC=A0=95=EB=A0=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
프록시 설정 추가분을 마이너(7.1.0)가 아닌 패치(7.0.8)로 내보낸다.
7.0.7 이후 변경된 코어·템플릿·플러그인·언어팩의 출시일을 2026-08-21 로 맞춘다.
>=7.1.0 을 요구하던 본인인증 플러그인의 코어 최소 버전을 >=7.0.8 로 내린다 —
그대로 두면 존재하지 않는 버전을 요구해 플러그인이 자동 비활성화된다.
앞선 작업에서 누락된 package-lock.json 버전(1.0.3)도 함께 맞춘다.
---
.env.example | 2 +-
.env.testing.example | 2 +-
CHANGELOG.md | 2 +-
INSTALL.md | 2 +-
README.ko.md | 2 +-
README.md | 2 +-
app/Http/Requests/Settings/TestOutboundProxyRequest.php | 2 +-
app/Rules/ValidOutboundProxyUrl.php | 2 +-
app/Services/OutboundProxyTester.php | 2 +-
app/Support/OutboundProxy.php | 2 +-
config/app.php | 2 +-
lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 2 +-
.../_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md | 2 +-
plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md | 4 ++--
.../_bundled/sirsoft-verification_kginicis/package-lock.json | 4 ++--
plugins/_bundled/sirsoft-verification_kginicis/plugin.json | 2 +-
templates/_bundled/sirsoft-admin_basic/CHANGELOG.md | 2 +-
17 files changed, 19 insertions(+), 19 deletions(-)
diff --git a/.env.example b/.env.example
index aae511b2..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.1.0
+APP_VERSION=7.0.8
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/.env.testing.example b/.env.testing.example
index 4635cbd2..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.1.0
+APP_VERSION=7.0.8
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6295dc62..09c94ea9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
-## [7.1.0] - 2026-08-20
+## [7.0.8] - 2026-08-21
### Added
diff --git a/INSTALL.md b/INSTALL.md
index e6a9c93d..2946a231 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -289,7 +289,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
-# (필요 시) mv g7-7.1.0 g7
+# (필요 시) mv g7-7.0.8 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
diff --git a/README.ko.md b/README.ko.md
index dda31a78..b0aa6a8d 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -10,7 +10,7 @@
-
+
diff --git a/README.md b/README.md
index 506ff5c6..fdd76c39 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
diff --git a/app/Http/Requests/Settings/TestOutboundProxyRequest.php b/app/Http/Requests/Settings/TestOutboundProxyRequest.php
index 17e93828..d45b4f8d 100644
--- a/app/Http/Requests/Settings/TestOutboundProxyRequest.php
+++ b/app/Http/Requests/Settings/TestOutboundProxyRequest.php
@@ -14,7 +14,7 @@ use Illuminate\Foundation\Http\FormRequest;
* 검증 강도는 저장 경로(SaveSettingsRequest)와 같게 둡니다 — 테스트만 통과하고 저장에서
* 거부되거나 그 반대가 되면 운영자가 어느 쪽을 믿어야 할지 알 수 없습니다.
*
- * @since 7.1.0
+ * @since 7.0.8
*/
class TestOutboundProxyRequest extends FormRequest
{
diff --git a/app/Rules/ValidOutboundProxyUrl.php b/app/Rules/ValidOutboundProxyUrl.php
index 80c2261f..195060ee 100644
--- a/app/Rules/ValidOutboundProxyUrl.php
+++ b/app/Rules/ValidOutboundProxyUrl.php
@@ -15,7 +15,7 @@ use Illuminate\Contracts\Validation\ValidationRule;
* 빈 값은 통과시킨다. 프록시를 쓰지 않는 것이 기본 상태이며, 필수 여부는 FormRequest 의
* nullable 규칙이 정한다.
*
- * @since 7.1.0
+ * @since 7.0.8
*/
class ValidOutboundProxyUrl implements ValidationRule
{
diff --git a/app/Services/OutboundProxyTester.php b/app/Services/OutboundProxyTester.php
index 53cdae46..b967f5ba 100644
--- a/app/Services/OutboundProxyTester.php
+++ b/app/Services/OutboundProxyTester.php
@@ -17,7 +17,7 @@ use Illuminate\Support\Facades\Log;
* 검사 대상은 **저장된 설정이 아니라 이번에 제출된 값**입니다. 그래서 전역 옵션에 의존하지
* 않고 요청마다 프록시를 명시합니다 — 저장 전에 확인하는 것이 목적이기 때문입니다.
*
- * @since 7.1.0
+ * @since 7.0.8
*/
class OutboundProxyTester
{
diff --git a/app/Support/OutboundProxy.php b/app/Support/OutboundProxy.php
index cbfc8592..d6fb7bd9 100644
--- a/app/Support/OutboundProxy.php
+++ b/app/Support/OutboundProxy.php
@@ -14,7 +14,7 @@ namespace App\Support;
* 않는다 — 관리자 화면에서 입력칸을 감추는 것만으로는 저장 API 를 직접 호출하는 경로를 막지
* 못하므로, 화면이 아니라 이 판정이 실질 게이트다.
*
- * @since 7.1.0
+ * @since 7.0.8
*/
final class OutboundProxy
{
diff --git a/config/app.php b/config/app.php
index ccc01849..d1a68dcd 100644
--- a/config/app.php
+++ b/config/app.php
@@ -231,7 +231,7 @@ return [
|
*/
- 'version' => env('APP_VERSION', '7.1.0'),
+ 'version' => env('APP_VERSION', '7.0.8'),
/*
|--------------------------------------------------------------------------
diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
index a9c6bc09..92e813c7 100644
--- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
@@ -4,7 +4,7 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
-## [1.0.7] - 2026-08-20
+## [1.0.7] - 2026-08-21
### Added
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 0b64535e..e9b4b3ca 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,7 +4,7 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
-## [1.0.6] - 2026-08-20
+## [1.0.6] - 2026-08-21
### Added
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md
index e12bdeab..49fdac4e 100644
--- a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md
@@ -4,12 +4,12 @@ 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-20
+## [1.0.4] - 2026-08-21
### Changed
- 본인인증 승인 요청이 사이트 환경설정의 아웃바운드 프록시 설정을 따르도록 했습니다. 이전에는 이 요청만 프록시를 거치지 않고 직접 나가서, 프록시를 사용하는 환경에서 결제사에 등록한 IP 와 다른 IP 로 접속했습니다.
-- 코어 최소 요구 버전을 7.1.0 로 상향했습니다.
+- 코어 최소 요구 버전을 7.0.8 로 상향했습니다.
## [1.0.3] - 2026-08-19
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/plugin.json b/plugins/_bundled/sirsoft-verification_kginicis/plugin.json
index 041c39ec..05ba7270 100644
--- a/plugins/_bundled/sirsoft-verification_kginicis/plugin.json
+++ b/plugins/_bundled/sirsoft-verification_kginicis/plugin.json
@@ -11,7 +11,7 @@
"ko": "KG이니시스 통합인증의 본인확인(reqSvcCd=03)을 G7 코어 IDV 인프라에 Provider 로 등록하는 플러그인",
"en": "KG Inicis Identity Verification (reqSvcCd=03) provider for G7 core IDV infrastructure"
},
- "g7_version": ">=7.1.0",
+ "g7_version": ">=7.0.8",
"dependencies": {
"modules": {},
"plugins": {}
diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
index d7b739fc..257a45ea 100644
--- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
+++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
@@ -4,7 +4,7 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
-## [1.0.6] - 2026-08-20
+## [1.0.6] - 2026-08-21
### Added
From 565f6c0117879c82aa579b9be771916f3357298a Mon Sep 17 00:00:00 2001
From: HeuJung
Date: Fri, 21 Aug 2026 13:53:49 +0900
Subject: [PATCH 3/7] =?UTF-8?q?chore(pay=5Fkginicis):=207.0.7=20=EB=B0=B0?=
=?UTF-8?q?=ED=8F=AC=20=EC=9D=B4=ED=9B=84=20=EB=B3=80=EA=B2=BD=EB=B6=84?=
=?UTF-8?q?=EC=9D=84=201.1.2=20=EB=A1=9C=20=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CBT 연결 점검의 프록시 경유 수정과 코어 최소 버전 상향이 이미 배포된
1.1.1 섹션에 누적돼 있었다. 배포본과 내용이 어긋나지 않도록 1.1.2 로 분리한다.
---
plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md | 12 ++++++++++--
plugins/_bundled/sirsoft-pay_kginicis/composer.json | 2 +-
.../_bundled/sirsoft-pay_kginicis/package-lock.json | 4 ++--
plugins/_bundled/sirsoft-pay_kginicis/package.json | 2 +-
plugins/_bundled/sirsoft-pay_kginicis/plugin.json | 4 ++--
5 files changed, 16 insertions(+), 8 deletions(-)
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
index f83bc8f0..562adda7 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-21
+
+### Changed
+
+- 코어 최소 요구 버전을 7.0.8 로 상향했습니다.
+
+### Fixed
+
+- CBT 연결 점검이 서버 출발지 IP 와 호스트 연결 가능 여부를 사이트 환경설정의 아웃바운드 프록시 설정을 따라 확인하도록 했습니다. 이전에는 프록시를 사용하는 환경에서도 프록시를 거치지 않고 확인해, 결제사에 등록해야 할 IP 와 다른 값을 알려주고 실제로는 결제가 되는 상황에서도 "연결 불가" 로 보고했습니다.
+
## [1.1.1] - 2026-08-19
### Security
@@ -12,7 +22,6 @@
### Changed
-- 코어 최소 요구 버전을 7.1.0 로 상향했습니다.
- 에스크로 배송등록 오류 안내가 다국어로 제공됩니다.
- 결제창 닫힘 보고 사유의 길이 제한을 다른 결제 플러그인과 동일하게 160자로 통일했습니다.
- 해외결제(CBT) 해시 요청에서 체크아웃 토큰 누락 응답이 표준 검증 응답(422)으로 통일되었습니다.
@@ -20,7 +29,6 @@
### Fixed
-- CBT 연결 점검이 서버 출발지 IP 와 호스트 연결 가능 여부를 사이트 환경설정의 아웃바운드 프록시 설정을 따라 확인하도록 했습니다. 이전에는 프록시를 사용하는 환경에서도 프록시를 거치지 않고 확인해, 결제사에 등록해야 할 IP 와 다른 값을 알려주고 실제로는 결제가 되는 상황에서도 "연결 불가" 로 보고했습니다.
- 플러그인 설정 화면의 안내·상태 아이콘이 의도한 크기와 다르게 보이던 문제와, 해외결제(CBT) 연결 상태 표시에 일부 스타일이 적용되지 않던 문제를 수정했습니다.
- 결제 실패 시 이동하는 페이지 주소에 서버 내부 오류 원문이 그대로 실려 나가던 문제를 수정했습니다. 이제 안내 문구만 전달되며, 원인 파악에 필요한 원문은 서버 로그에만 기록됩니다.
- 플러그인 설정 저장과 관리자 주문 결제 조회의 해외결제 대사 재시도·편의점 결제 처리가 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다.
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/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 1f78ad77..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.1.0",
+ "g7_version": ">=7.0.8",
"dependencies": {
"modules": {
"sirsoft-ecommerce": ">=1.1.0"
From 03cbb99196750d81b4b7c374d01fe62125a9e1d4 Mon Sep 17 00:00:00 2001
From: HeuJung
Date: Fri, 21 Aug 2026 17:09:30 +0900
Subject: [PATCH 4/7] =?UTF-8?q?fix(extension):=20=ED=99=95=EC=9E=A5=20?=
=?UTF-8?q?=EC=88=98=EB=AA=85=EC=A3=BC=EA=B8=B0=20=EC=BA=90=EC=8B=9C=20?=
=?UTF-8?q?=EB=AC=B4=ED=9A=A8=ED=99=94=20=EC=88=9C=EC=84=9C=20=ED=9A=8C?=
=?UTF-8?q?=EA=B7=80=20=EB=B0=8F=20=EC=8B=A4=ED=8C=A8=20=EC=82=AC=EC=9C=A0?=
=?UTF-8?q?=20=EC=A0=84=EB=8B=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
route:cache 는 새 앱을 부팅해 라우트를 수집하고, 그 부팅의 확장 라우트 프로바이더는
DB 가 아니라 캐시된 활성 확장 목록을 읽는다. 그래서 rebuild 가
invalidate*StatusCache 보다 앞서면 방금 바뀐 상태가 빠진 채 라우트가 박제되고,
라우트 캐시에는 스캔 폴백이 없어 오류도 로그도 없이 404 가 된다. 무효화를 굽기 직전이
아니라 DB 상태 쓰기 직후로 올려, 같은 목록을 읽는 훅 매핑 캐시까지 함께 바로잡았다.
update 경로는 Updating 전이 직후에 비우면 그 창의 오토로드 갱신이 확장을 비활성으로
판정하므로, 상태 복원 직후에 비운 뒤 훅 캐시를 다시 굽는다.
플러그인 라우트 프로바이더에는 활성 게이트가 없어 비활성 플러그인의 API 가 계속
응답했다. 화면·메뉴만 사라지고 기능은 살아 있는 상태였다. 모듈과 같은 기준을 적용했다.
실패 사유가 하위 계층에서 버려져 관리자 화면에 :error 자리표시자가 그대로 노출되던
문제도 고쳤다. 반환 경로를 깨지 않도록 배열 키 reason 과 뒤에 붙인 선택적 out
파라미터로 사유를 실어 올리고, 확장이 수명주기 훅에서 사유를 남길 수 있는 통로를
추가했다. 설치 경로의 광역 RuntimeException catch 는 도메인 예외로 좁혀 원본 키와
파라미터를 응답에 싣는다 — 상태코드 422 는 유지해 사용자 계약을 함께 바꾸지 않는다.
언어팩 화면은 프로덕션에서 예외 원문을 싣지 않는 것이 확정된 계약이므로, 자리를
일반 문구로 채우는 대신 치환 자리 자체를 제거했다. 원문은 종전대로 errors 통로를
거쳐 디버그 모드에서 도달한다.
---
AGENTS.md | 9 +-
CHANGELOG.md | 10 +
app/Extension/AbstractModule.php | 3 +
app/Extension/AbstractPlugin.php | 3 +
app/Extension/ExtensionManager.php | 6 +-
app/Extension/ModuleManager.php | 105 ++++++-
app/Extension/PluginManager.php | 101 ++++++-
app/Extension/TemplateManager.php | 6 +
.../Traits/ReportsLifecycleFailure.php | 58 ++++
.../Api/Admin/ModuleController.php | 49 +--
.../Api/Admin/PluginController.php | 53 ++--
.../Api/Admin/PluginSettingsController.php | 6 +-
.../Api/Admin/TemplateController.php | 47 ++-
app/Providers/PluginRouteServiceProvider.php | 17 ++
app/Services/LanguagePackService.php | 7 +-
app/Services/ModuleService.php | 25 +-
app/Services/PluginService.php | 25 +-
app/Services/PluginSettingsService.php | 12 +-
app/Services/TemplateService.php | 21 +-
docs/backend/api/README.md | 4 +
docs/backend/api/language-packs.md | 6 +-
docs/backend/exceptions.md | 21 ++
docs/backend/routing.md | 52 +++-
docs/extension/module-basics.md | 22 ++
lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 4 +
.../g7-core-ja/backend/ja/language_packs.php | 18 +-
lang/en/language_packs.php | 20 +-
lang/ko/language_packs.php | 18 +-
.../Api/Admin/ModuleControllerTest.php | 111 ++++---
.../Api/Admin/PluginControllerTest.php | 72 ++---
.../Api/Admin/TemplateControllerTest.php | 13 +-
.../ExtensionRouteActiveGateTest.php | 256 ++++++++++++++++
.../ExtensionRouteCacheInvalidationTest.php | 105 ++++++-
.../ErrorMessageParamSubstitutionTest.php | 283 ++++++++++++++++++
.../GenericCatchStatusCodeContractTest.php | 33 +-
35 files changed, 1362 insertions(+), 239 deletions(-)
create mode 100644 app/Extension/Traits/ReportsLifecycleFailure.php
create mode 100644 tests/Feature/Extension/ExtensionRouteActiveGateTest.php
create mode 100644 tests/Feature/Http/ErrorMessageParamSubstitutionTest.php
diff --git a/AGENTS.md b/AGENTS.md
index 8dd1a4df..327f801c 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 데이터 접근
@@ -1051,6 +1056,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 커맨드로 수행
```
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 09c94ea9..9fa8c1ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,16 @@
- 프록시 주소 옆의 「연결 테스트」로 저장하기 전에 연결 여부를 확인할 수 있습니다. 성공하면 그 프록시를 거쳤을 때 외부 서비스에 보이는 IP 주소를 함께 알려주므로, 결제사에 어떤 IP 를 등록해야 하는지 미리 확인할 수 있습니다.
- 확장 개발자용: 사이트 표준 HTTP 호출은 프록시 설정이 자동으로 적용됩니다. 외부 연동 규약상 별도 방식으로 통신해야 하는 확장은 코어가 제공하는 프록시 설정을 받아 같은 경로로 내보낼 수 있습니다.
+### Changed
+
+- 비활성화한 플러그인의 기능이 더 이상 동작하지 않습니다. 이전에는 플러그인을 꺼도 화면·메뉴·스크립트만 사라지고 그 플러그인의 기능 주소는 계속 응답해, 꺼진 결제수단으로 결제가 시도되는 등 "껐는데 아직 살아 있는" 상태가 남았습니다. 이제 모듈과 동일하게 활성화된 플러그인의 기능만 동작합니다. **결제·본인인증처럼 외부 서비스가 직접 호출하는 주소도 함께 닫히므로, 진행 중인 거래가 있을 때의 비활성화·업데이트는 처리가 끝난 뒤에 하시기 바랍니다.**
+- 확장 개발자용: 모듈·플러그인의 설치·활성화·비활성화·제거 처리에서 실패 사유를 코어에 전달할 수 있습니다. `failWith()` 로 사유를 남기고 실패를 반환하면 관리자 화면의 실패 안내에 그 사유가 함께 표시됩니다. 사유를 남기지 않아도 종전처럼 동작합니다.
+
+### Fixed
+
+- 관리자 화면의 실패 안내에 원인이 들어갈 자리가 채워지지 않아 `:error` 라는 내부 표시가 그대로 보이던 문제를 수정했습니다. 모듈·플러그인·템플릿·언어팩 관리와 플러그인 설정 저장의 실패 안내 전반에서 발생했습니다. 이제 실패 원인을 알 수 있으면 그 원인이, 알 수 없으면 일반 안내 문구가 표시됩니다. 언어팩 관리처럼 원인을 표시하지 않기로 한 안내는 문구 자체를 정리했습니다.
+- 모듈·플러그인을 활성화한 직후 그 확장의 화면과 기능이 "주소를 찾을 수 없음" 오류만 내던 문제를 수정했습니다. 활성화 시점에 사이트 내부 주소록이 활성화 이전 상태를 기준으로 다시 만들어져, 방금 켠 확장의 주소가 빠진 채로 굳어졌습니다. 시간이 지나도 스스로 복구되지 않았습니다. 같은 원인으로 비활성화한 확장의 기능이 계속 호출 가능하던 문제, 확장을 업데이트한 직후 그 확장의 기능과 다른 기능과의 연동 동작이 함께 누락되던 문제도 바로잡았습니다.
+
## [7.0.7] - 2026-08-19
### Security
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/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/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/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..28ad15d1 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);
@@ -867,10 +876,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/PluginService.php b/app/Services/PluginService.php
index 2139aa24..2af946db 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);
@@ -983,10 +992,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..37cc899f 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) {
// 템플릿 매니저에서 업데이트된 정보 조회
@@ -2021,7 +2032,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/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/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/module-basics.md b/docs/extension/module-basics.md
index d9429367..428c9888 100644
--- a/docs/extension/module-basics.md
+++ b/docs/extension/module-basics.md
@@ -143,6 +143,28 @@
| `getMetadata()` | `[]` | 메타데이터 |
| `getMiddleware()` | `[]` | 확장 미들웨어 선언 (self-gate targets) — `{class, groups, timing?, targets}` ([middleware.md](../backend/middleware.md)) |
| `getBenchmarkProfiles()` | `[]` | 성능 계측 대상 선언 (`g7:bench` 가 수집) — 목록/화면/쓰기/배치 4축 ([benchmark.md](../backend/benchmark.md)) |
+
+#### 수명주기 훅이 실패를 알리는 방법
+
+`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`)에도 동일하게 적용된다.
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 모듈은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `ModuleManager::runUpgradeSteps` 가 `RuntimeException` throw |
#### 동적 권한/역할/메뉴 보존 규칙
diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
index 92e813c7..ca13d9d8 100644
--- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
@@ -11,6 +11,10 @@
- 아웃바운드 프록시 설정의 검증 메시지와 항목명 일본어 번역을 추가했습니다 (`settings.outbound_proxy_*`, `attributes.outbound_proxy*`).
- 아웃바운드 프록시 연결 테스트 결과 안내 문구의 일본어 번역을 추가했습니다 (`settings.outbound_proxy_test_*`).
+### Changed
+
+- 언어팩 관리(조회·설치·활성화·비활성화·제거·업데이트 확인·업데이트·캐시 갱신·manifest 미리보기) 실패 안내에서 오류 원문 노출(`:error`)이 제거된 것에 맞춰, 해당 실패 문구의 일본어 번역을 갱신했습니다 (`language_packs.*_failed`).
+
## [1.0.6] - 2026-08-19
### Changed
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/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/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/tests/Feature/Api/Admin/ModuleControllerTest.php b/tests/Feature/Api/Admin/ModuleControllerTest.php
index c4179478..114c214c 100644
--- a/tests/Feature/Api/Admin/ModuleControllerTest.php
+++ b/tests/Feature/Api/Admin/ModuleControllerTest.php
@@ -3,11 +3,15 @@
namespace Tests\Feature\Api\Admin;
use App\Enums\ExtensionOwnerType;
+use App\Enums\PermissionType;
+use App\Exceptions\ModuleOperationException;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
+use App\Services\ModuleService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
@@ -54,7 +58,7 @@ class ModuleControllerTest extends TestCase
'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
- 'type' => \App\Enums\PermissionType::Admin,
+ 'type' => PermissionType::Admin,
]
);
$permissionIds[] = $permission->id;
@@ -498,7 +502,7 @@ class ModuleControllerTest extends TestCase
*/
public function test_install_from_file_succeeds_with_valid_zip(): void
{
- $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
+ $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('installFromZipFile')
->once()
->andReturn([
@@ -506,7 +510,7 @@ class ModuleControllerTest extends TestCase
'name' => 'Test Module',
'version' => '1.0.0',
]);
- $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
+ $this->app->instance(ModuleService::class, $moduleServiceMock);
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
@@ -520,15 +524,20 @@ class ModuleControllerTest extends TestCase
}
/**
- * ModuleService에서 RuntimeException 발생 시 422 반환
+ * ModuleService에서 도메인 예외 발생 시 422 반환 + 키가 해석된 문구
+ *
+ * 종전에는 raw `\RuntimeException` 이 이미 번역된 문장을 들고 오는 것을 컨트롤러가
+ * 메시지 **키** 자리로 넘겨 그대로 내보냈다. 키 해석에 실패한 원문이 나가는 형태라
+ * 계약 테스트가 이 지점을 위반으로 등재해 두고 있었다. 이제 도메인 실패는 키와
+ * 파라미터를 들고 다니는 예외로 오고, 컨트롤러가 그 키를 해석해 내보낸다.
*/
- public function test_install_from_file_returns_422_on_runtime_exception(): void
+ public function test_install_from_file_returns_422_on_domain_exception(): void
{
- $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
+ $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('installFromZipFile')
->once()
- ->andThrow(new \RuntimeException('module.json을 찾을 수 없습니다.'));
- $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
+ ->andThrow(new ModuleOperationException('modules.errors.module_json_not_found'));
+ $this->app->instance(ModuleService::class, $moduleServiceMock);
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
@@ -537,7 +546,27 @@ class ModuleControllerTest extends TestCase
]);
$response->assertStatus(422);
- $response->assertJsonPath('message', 'module.json을 찾을 수 없습니다.');
+ $response->assertJsonPath('message', __('modules.errors.module_json_not_found'));
+ }
+
+ /**
+ * 도메인 예외가 아닌 raw RuntimeException 은 500 (인프라 장애를 입력 오류로 위장하지 않는다)
+ */
+ public function test_install_from_file_returns_500_on_raw_runtime_exception(): void
+ {
+ $moduleServiceMock = \Mockery::mock(ModuleService::class);
+ $moduleServiceMock->shouldReceive('installFromZipFile')
+ ->once()
+ ->andThrow(new \RuntimeException('disk full'));
+ $this->app->instance(ModuleService::class, $moduleServiceMock);
+
+ $file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
+
+ $response = $this->authRequest()->postJson('/api/admin/modules/install-from-file', [
+ 'file' => $file,
+ ]);
+
+ $response->assertStatus(500);
}
/**
@@ -545,11 +574,11 @@ class ModuleControllerTest extends TestCase
*/
public function test_install_from_file_returns_500_on_general_exception(): void
{
- $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
+ $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('installFromZipFile')
->once()
->andThrow(new \Exception('예상치 못한 오류'));
- $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
+ $this->app->instance(ModuleService::class, $moduleServiceMock);
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
@@ -569,7 +598,7 @@ class ModuleControllerTest extends TestCase
*/
public function test_install_from_github_calls_service_with_valid_url(): void
{
- $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
+ $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('installFromGithub')
->once()
->with('https://github.com/sirsoft/sample-module')
@@ -578,7 +607,7 @@ class ModuleControllerTest extends TestCase
'name' => 'Sample Module',
'version' => '1.0.0',
]);
- $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
+ $this->app->instance(ModuleService::class, $moduleServiceMock);
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-module',
@@ -592,20 +621,20 @@ class ModuleControllerTest extends TestCase
/**
* ModuleService에서 RuntimeException 발생 시 422 반환 (GitHub)
*/
- public function test_install_from_github_returns_422_on_runtime_exception(): void
+ public function test_install_from_github_returns_422_on_domain_exception(): void
{
- $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
+ $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('installFromGithub')
->once()
- ->andThrow(new \RuntimeException('GitHub 저장소를 찾을 수 없습니다.'));
- $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
+ ->andThrow(new ModuleOperationException('modules.errors.github_repo_not_found'));
+ $this->app->instance(ModuleService::class, $moduleServiceMock);
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-module',
]);
$response->assertStatus(422);
- $response->assertJsonPath('message', 'GitHub 저장소를 찾을 수 없습니다.');
+ $response->assertJsonPath('message', __('modules.errors.github_repo_not_found'));
}
/**
@@ -613,11 +642,11 @@ class ModuleControllerTest extends TestCase
*/
public function test_install_from_github_returns_500_on_general_exception(): void
{
- $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
+ $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('installFromGithub')
->once()
->andThrow(new \Exception('예상치 못한 오류'));
- $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
+ $this->app->instance(ModuleService::class, $moduleServiceMock);
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-module',
@@ -682,7 +711,7 @@ class ModuleControllerTest extends TestCase
public function test_activate_returns_409_when_dependencies_not_met(): void
{
// Arrange: ModuleService를 Mock
- $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
+ $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('activateModule')
->with('test-module', false)
->andReturn([
@@ -696,7 +725,7 @@ class ModuleControllerTest extends TestCase
],
'message' => '이 모듈을 활성화하려면 필요한 의존성이 충족되어야 합니다.',
]);
- $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
+ $this->app->instance(ModuleService::class, $moduleServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/modules/activate', [
@@ -731,7 +760,7 @@ class ModuleControllerTest extends TestCase
public function test_activate_with_force_bypasses_dependency_check(): void
{
// Arrange: ModuleService를 Mock
- $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
+ $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('activateModule')
->with('test-module', true)
->andReturn([
@@ -742,7 +771,7 @@ class ModuleControllerTest extends TestCase
'status' => 'active',
],
]);
- $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
+ $this->app->instance(ModuleService::class, $moduleServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/modules/activate', [
@@ -761,7 +790,7 @@ class ModuleControllerTest extends TestCase
public function test_deactivate_returns_409_when_dependents_exist(): void
{
// Arrange: ModuleService를 Mock
- $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
+ $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('deactivateModule')
->with('test-module', false)
->andReturn([
@@ -774,7 +803,7 @@ class ModuleControllerTest extends TestCase
'dependent_plugins' => [],
'message' => '이 모듈에 의존하는 활성화된 확장이 있습니다.',
]);
- $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
+ $this->app->instance(ModuleService::class, $moduleServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [
@@ -801,7 +830,7 @@ class ModuleControllerTest extends TestCase
public function test_deactivate_with_force_bypasses_dependent_check(): void
{
// Arrange: ModuleService를 Mock
- $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
+ $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('deactivateModule')
->with('test-module', true)
->andReturn([
@@ -812,7 +841,7 @@ class ModuleControllerTest extends TestCase
'status' => 'inactive',
],
]);
- $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
+ $this->app->instance(ModuleService::class, $moduleServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [
@@ -835,7 +864,7 @@ class ModuleControllerTest extends TestCase
public function test_activate_response_includes_assets_when_module_has_assets(): void
{
// Arrange: ModuleService를 Mock
- $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
+ $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('activateModule')
->with('test-module', false)
->andReturn([
@@ -851,7 +880,7 @@ class ModuleControllerTest extends TestCase
],
],
]);
- $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
+ $this->app->instance(ModuleService::class, $moduleServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/modules/activate', [
@@ -872,7 +901,7 @@ class ModuleControllerTest extends TestCase
public function test_deactivate_response_includes_assets_when_module_has_assets(): void
{
// Arrange: ModuleService를 Mock
- $moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
+ $moduleServiceMock = \Mockery::mock(ModuleService::class);
$moduleServiceMock->shouldReceive('deactivateModule')
->with('test-module', false)
->andReturn([
@@ -888,7 +917,7 @@ class ModuleControllerTest extends TestCase
],
],
]);
- $this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
+ $this->app->instance(ModuleService::class, $moduleServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [
@@ -913,8 +942,8 @@ class ModuleControllerTest extends TestCase
{
// Arrange: 테스트용 CHANGELOG.md 생성
$modulePath = base_path('modules/test-changelog-mod');
- \Illuminate\Support\Facades\File::ensureDirectoryExists($modulePath);
- \Illuminate\Support\Facades\File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 새 기능\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n");
+ File::ensureDirectoryExists($modulePath);
+ File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 새 기능\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n");
try {
$response = $this->authRequest()->getJson('/api/admin/modules/test-changelog-mod/changelog');
@@ -924,7 +953,7 @@ class ModuleControllerTest extends TestCase
->assertJsonPath('data.changelog.0.categories.0.name', 'Added')
->assertJsonPath('data.changelog.1.version', '0.1.0');
} finally {
- \Illuminate\Support\Facades\File::deleteDirectory($modulePath);
+ File::deleteDirectory($modulePath);
}
}
@@ -945,8 +974,8 @@ class ModuleControllerTest extends TestCase
public function test_changelog_with_version_range(): void
{
$modulePath = base_path('modules/test-changelog-range');
- \Illuminate\Support\Facades\File::ensureDirectoryExists($modulePath);
- \Illuminate\Support\Facades\File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.2] - 2026-02-28\n\n### Added\n- 기능 C\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 기능 B\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n");
+ File::ensureDirectoryExists($modulePath);
+ File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.2] - 2026-02-28\n\n### Added\n- 기능 C\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 기능 B\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n");
try {
$response = $this->authRequest()->getJson('/api/admin/modules/test-changelog-range/changelog?from_version=0.1.0&to_version=0.1.2');
@@ -957,7 +986,7 @@ class ModuleControllerTest extends TestCase
$this->assertSame('0.1.2', $changelog[0]['version']);
$this->assertSame('0.1.1', $changelog[1]['version']);
} finally {
- \Illuminate\Support\Facades\File::deleteDirectory($modulePath);
+ File::deleteDirectory($modulePath);
}
}
@@ -980,8 +1009,8 @@ class ModuleControllerTest extends TestCase
public function test_license_returns_content(): void
{
$modulePath = base_path('modules/test-license-mod');
- \Illuminate\Support\Facades\File::ensureDirectoryExists($modulePath);
- \Illuminate\Support\Facades\File::put($modulePath.'/LICENSE', 'MIT License - Test');
+ File::ensureDirectoryExists($modulePath);
+ File::put($modulePath.'/LICENSE', 'MIT License - Test');
try {
$response = $this->authRequest()->getJson('/api/admin/modules/test-license-mod/license');
@@ -989,7 +1018,7 @@ class ModuleControllerTest extends TestCase
$response->assertStatus(200)
->assertJsonPath('data.content', 'MIT License - Test');
} finally {
- \Illuminate\Support\Facades\File::deleteDirectory($modulePath);
+ File::deleteDirectory($modulePath);
}
}
diff --git a/tests/Feature/Api/Admin/PluginControllerTest.php b/tests/Feature/Api/Admin/PluginControllerTest.php
index 4aeeece4..ed9dc95a 100644
--- a/tests/Feature/Api/Admin/PluginControllerTest.php
+++ b/tests/Feature/Api/Admin/PluginControllerTest.php
@@ -3,11 +3,15 @@
namespace Tests\Feature\Api\Admin;
use App\Enums\ExtensionOwnerType;
+use App\Enums\PermissionType;
+use App\Exceptions\PluginOperationException;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
+use App\Services\PluginService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
@@ -51,7 +55,7 @@ class PluginControllerTest extends TestCase
'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
- 'type' => \App\Enums\PermissionType::Admin,
+ 'type' => PermissionType::Admin,
]
);
$permissionIds[] = $permission->id;
@@ -423,7 +427,7 @@ class PluginControllerTest extends TestCase
public function test_activate_returns_409_when_dependencies_not_met(): void
{
// Arrange: PluginService를 Mock
- $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class);
+ $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('activatePlugin')
->with('test-plugin', false)
->andReturn([
@@ -437,7 +441,7 @@ class PluginControllerTest extends TestCase
],
'message' => '플러그인 활성화를 위해 필요한 의존성이 충족되지 않았습니다.',
]);
- $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock);
+ $this->app->instance(PluginService::class, $pluginServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/plugins/activate', [
@@ -472,7 +476,7 @@ class PluginControllerTest extends TestCase
public function test_activate_with_force_bypasses_dependency_check(): void
{
// Arrange: PluginService를 Mock
- $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class);
+ $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('activatePlugin')
->with('test-plugin', true)
->andReturn([
@@ -483,7 +487,7 @@ class PluginControllerTest extends TestCase
'status' => 'active',
],
]);
- $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock);
+ $this->app->instance(PluginService::class, $pluginServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/plugins/activate', [
@@ -502,7 +506,7 @@ class PluginControllerTest extends TestCase
public function test_deactivate_returns_409_when_dependents_exist(): void
{
// Arrange: PluginService를 Mock
- $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class);
+ $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('deactivatePlugin')
->with('test-plugin', false)
->andReturn([
@@ -517,7 +521,7 @@ class PluginControllerTest extends TestCase
],
'message' => '이 플러그인에 의존하는 활성화된 확장이 있습니다.',
]);
- $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock);
+ $this->app->instance(PluginService::class, $pluginServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/plugins/deactivate', [
@@ -545,7 +549,7 @@ class PluginControllerTest extends TestCase
public function test_deactivate_with_force_bypasses_dependent_check(): void
{
// Arrange: PluginService를 Mock
- $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class);
+ $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('deactivatePlugin')
->with('test-plugin', true)
->andReturn([
@@ -556,7 +560,7 @@ class PluginControllerTest extends TestCase
'status' => 'inactive',
],
]);
- $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock);
+ $this->app->instance(PluginService::class, $pluginServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/plugins/deactivate', [
@@ -579,7 +583,7 @@ class PluginControllerTest extends TestCase
public function test_activate_response_includes_assets_when_plugin_has_assets(): void
{
// Arrange: PluginService를 Mock
- $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class);
+ $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('activatePlugin')
->with('test-plugin', false)
->andReturn([
@@ -595,7 +599,7 @@ class PluginControllerTest extends TestCase
],
],
]);
- $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock);
+ $this->app->instance(PluginService::class, $pluginServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/plugins/activate', [
@@ -616,7 +620,7 @@ class PluginControllerTest extends TestCase
public function test_deactivate_response_includes_assets_when_plugin_has_assets(): void
{
// Arrange: PluginService를 Mock
- $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class);
+ $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('deactivatePlugin')
->with('test-plugin', false)
->andReturn([
@@ -632,7 +636,7 @@ class PluginControllerTest extends TestCase
],
],
]);
- $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock);
+ $this->app->instance(PluginService::class, $pluginServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/plugins/deactivate', [
@@ -656,8 +660,8 @@ class PluginControllerTest extends TestCase
public function test_changelog_returns_parsed_entries(): void
{
$pluginPath = base_path('plugins/test-changelog-plg');
- \Illuminate\Support\Facades\File::ensureDirectoryExists($pluginPath);
- \Illuminate\Support\Facades\File::put($pluginPath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.1] - 2026-02-25\n\n### Fixed\n- 버그 수정\n");
+ File::ensureDirectoryExists($pluginPath);
+ File::put($pluginPath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.1] - 2026-02-25\n\n### Fixed\n- 버그 수정\n");
try {
$response = $this->authRequest()->getJson('/api/admin/plugins/test-changelog-plg/changelog');
@@ -666,7 +670,7 @@ class PluginControllerTest extends TestCase
->assertJsonPath('data.changelog.0.version', '0.1.1')
->assertJsonPath('data.changelog.0.categories.0.name', 'Fixed');
} finally {
- \Illuminate\Support\Facades\File::deleteDirectory($pluginPath);
+ File::deleteDirectory($pluginPath);
}
}
@@ -817,7 +821,7 @@ class PluginControllerTest extends TestCase
*/
public function test_install_from_file_calls_service_with_valid_zip(): void
{
- $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class);
+ $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('installFromZipFile')
->once()
->andReturn([
@@ -825,7 +829,7 @@ class PluginControllerTest extends TestCase
'name' => 'Test Plugin',
'version' => '1.0.0',
]);
- $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock);
+ $this->app->instance(PluginService::class, $pluginServiceMock);
$file = UploadedFile::fake()->create('plugin.zip', 100, 'application/zip');
@@ -841,13 +845,13 @@ class PluginControllerTest extends TestCase
/**
* PluginService에서 RuntimeException 발생 시 422 반환
*/
- public function test_install_from_file_returns_422_on_runtime_exception(): void
+ public function test_install_from_file_returns_422_on_domain_exception(): void
{
- $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class);
+ $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('installFromZipFile')
->once()
- ->andThrow(new \RuntimeException('plugin.json을 찾을 수 없습니다.'));
- $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock);
+ ->andThrow(new PluginOperationException('plugins.errors.plugin_json_not_found'));
+ $this->app->instance(PluginService::class, $pluginServiceMock);
$file = UploadedFile::fake()->create('plugin.zip', 100, 'application/zip');
@@ -856,7 +860,7 @@ class PluginControllerTest extends TestCase
]);
$response->assertStatus(422);
- $response->assertJsonPath('message', 'plugin.json을 찾을 수 없습니다.');
+ $response->assertJsonPath('message', __('plugins.errors.plugin_json_not_found'));
}
/**
@@ -864,11 +868,11 @@ class PluginControllerTest extends TestCase
*/
public function test_install_from_file_returns_500_on_general_exception(): void
{
- $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class);
+ $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('installFromZipFile')
->once()
->andThrow(new \Exception('예상치 못한 오류'));
- $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock);
+ $this->app->instance(PluginService::class, $pluginServiceMock);
$file = UploadedFile::fake()->create('plugin.zip', 100, 'application/zip');
@@ -970,7 +974,7 @@ class PluginControllerTest extends TestCase
*/
public function test_install_from_github_calls_service_with_valid_url(): void
{
- $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class);
+ $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('installFromGithub')
->once()
->with('https://github.com/sirsoft/sample-plugin')
@@ -979,7 +983,7 @@ class PluginControllerTest extends TestCase
'name' => 'Sample Plugin',
'version' => '1.0.0',
]);
- $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock);
+ $this->app->instance(PluginService::class, $pluginServiceMock);
$response = $this->authRequest()->postJson('/api/admin/plugins/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-plugin',
@@ -993,20 +997,20 @@ class PluginControllerTest extends TestCase
/**
* PluginService에서 RuntimeException 발생 시 422 반환
*/
- public function test_install_from_github_returns_422_on_runtime_exception(): void
+ public function test_install_from_github_returns_422_on_domain_exception(): void
{
- $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class);
+ $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('installFromGithub')
->once()
- ->andThrow(new \RuntimeException('GitHub 저장소를 다운로드할 수 없습니다.'));
- $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock);
+ ->andThrow(new PluginOperationException('plugins.errors.github_repo_not_found'));
+ $this->app->instance(PluginService::class, $pluginServiceMock);
$response = $this->authRequest()->postJson('/api/admin/plugins/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-plugin',
]);
$response->assertStatus(422);
- $response->assertJsonPath('message', 'GitHub 저장소를 다운로드할 수 없습니다.');
+ $response->assertJsonPath('message', __('plugins.errors.github_repo_not_found'));
}
/**
@@ -1014,11 +1018,11 @@ class PluginControllerTest extends TestCase
*/
public function test_install_from_github_returns_500_on_general_exception(): void
{
- $pluginServiceMock = \Mockery::mock(\App\Services\PluginService::class);
+ $pluginServiceMock = \Mockery::mock(PluginService::class);
$pluginServiceMock->shouldReceive('installFromGithub')
->once()
->andThrow(new \Exception('예상치 못한 오류'));
- $this->app->instance(\App\Services\PluginService::class, $pluginServiceMock);
+ $this->app->instance(PluginService::class, $pluginServiceMock);
$response = $this->authRequest()->postJson('/api/admin/plugins/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-plugin',
diff --git a/tests/Feature/Api/Admin/TemplateControllerTest.php b/tests/Feature/Api/Admin/TemplateControllerTest.php
index a18dcce2..b9d9ee47 100644
--- a/tests/Feature/Api/Admin/TemplateControllerTest.php
+++ b/tests/Feature/Api/Admin/TemplateControllerTest.php
@@ -4,6 +4,7 @@ namespace Tests\Feature\Api\Admin;
use App\Contracts\Extension\TemplateManagerInterface;
use App\Enums\ExtensionOwnerType;
+use App\Exceptions\TemplateOperationException;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Template;
@@ -1024,12 +1025,12 @@ class TemplateControllerTest extends TestCase
/**
* TemplateService에서 RuntimeException 발생 시 422 반환
*/
- public function test_install_from_file_returns_422_on_runtime_exception(): void
+ public function test_install_from_file_returns_422_on_domain_exception(): void
{
$templateServiceMock = Mockery::mock(TemplateService::class);
$templateServiceMock->shouldReceive('installFromZipFile')
->once()
- ->andThrow(new \RuntimeException('template.json을 찾을 수 없습니다.'));
+ ->andThrow(new TemplateOperationException('templates.errors.template_json_not_found'));
$this->app->instance(TemplateService::class, $templateServiceMock);
$file = UploadedFile::fake()->create('template.zip', 100, 'application/zip');
@@ -1039,7 +1040,7 @@ class TemplateControllerTest extends TestCase
]);
$response->assertStatus(422);
- $response->assertJsonPath('message', 'template.json을 찾을 수 없습니다.');
+ $response->assertJsonPath('message', __('templates.errors.template_json_not_found'));
}
/**
@@ -1094,12 +1095,12 @@ class TemplateControllerTest extends TestCase
/**
* TemplateService에서 RuntimeException 발생 시 422 반환 (GitHub)
*/
- public function test_install_from_github_returns_422_on_runtime_exception(): void
+ public function test_install_from_github_returns_422_on_domain_exception(): void
{
$templateServiceMock = Mockery::mock(TemplateService::class);
$templateServiceMock->shouldReceive('installFromGithub')
->once()
- ->andThrow(new \RuntimeException('GitHub 저장소를 찾을 수 없습니다.'));
+ ->andThrow(new TemplateOperationException('templates.errors.github_repo_not_found'));
$this->app->instance(TemplateService::class, $templateServiceMock);
$response = $this->authRequest()->postJson('/api/admin/templates/install-from-github', [
@@ -1107,7 +1108,7 @@ class TemplateControllerTest extends TestCase
]);
$response->assertStatus(422);
- $response->assertJsonPath('message', 'GitHub 저장소를 찾을 수 없습니다.');
+ $response->assertJsonPath('message', __('templates.errors.github_repo_not_found'));
}
/**
diff --git a/tests/Feature/Extension/ExtensionRouteActiveGateTest.php b/tests/Feature/Extension/ExtensionRouteActiveGateTest.php
new file mode 100644
index 00000000..784b638a
--- /dev/null
+++ b/tests/Feature/Extension/ExtensionRouteActiveGateTest.php
@@ -0,0 +1,256 @@
+ 이 테스트가 라우트 등록까지 확인할 확장 */
+ protected array $requiredExtensions = ['plugins/sirsoft-gdpr', 'modules/sirsoft-page'];
+
+ /** @var Router|null 교체 전 원본 라우터 */
+ private ?Router $originalRouter = null;
+
+ protected function tearDown(): void
+ {
+ $this->restoreRouter();
+
+ parent::tearDown();
+ }
+
+ #[Test]
+ public function 비활성_플러그인의_라우트는_등록되지_않는다(): void
+ {
+ $this->seedPlugin('sirsoft-gdpr', ExtensionStatus::Active);
+ PluginManager::invalidatePluginStatusCache();
+
+ $active = $this->registerPluginRoutes();
+
+ $this->assertNotEmpty(
+ $this->urisFor($active, 'api/plugins/sirsoft-gdpr'),
+ '활성 플러그인의 라우트가 등록되지 않았다 — 판정기 모집단이 비어 아래 단언이 무의미해진다'
+ );
+
+ $this->seedPlugin('sirsoft-gdpr', ExtensionStatus::Inactive);
+ PluginManager::invalidatePluginStatusCache();
+
+ $inactive = $this->registerPluginRoutes();
+
+ $this->assertSame(
+ [],
+ $this->urisFor($inactive, 'api/plugins/sirsoft-gdpr'),
+ '비활성 플러그인의 라우트가 등록됐다 — 화면·메뉴만 사라지고 API 는 계속 호출 가능한 상태가 된다'
+ );
+ }
+
+ #[Test]
+ public function 비활성_모듈의_라우트는_등록되지_않는다(): void
+ {
+ $this->seedModule('sirsoft-page', ExtensionStatus::Active);
+ ModuleManager::invalidateModuleStatusCache();
+
+ $active = $this->registerModuleRoutes();
+
+ $this->assertNotEmpty(
+ $this->urisFor($active, 'api/modules/sirsoft-page'),
+ '활성 모듈의 라우트가 등록되지 않았다 — 판정기 모집단이 비어 아래 단언이 무의미해진다'
+ );
+
+ $this->seedModule('sirsoft-page', ExtensionStatus::Inactive);
+ ModuleManager::invalidateModuleStatusCache();
+
+ $inactive = $this->registerModuleRoutes();
+
+ $this->assertSame(
+ [],
+ $this->urisFor($inactive, 'api/modules/sirsoft-page'),
+ '비활성 모듈의 라우트가 등록됐다'
+ );
+ }
+
+ #[Test]
+ public function 두_라우트_프로바이더가_같은_활성_게이트를_쓴다(): void
+ {
+ // 한쪽에만 게이트가 있으면 그 비대칭은 오류가 아니라 "조용히 열린 경로" 로만
+ // 나타난다. 소스에서 게이트 존재를 함께 확인해, 한쪽만 제거되는 회귀를 막는다.
+ $cases = [
+ ModuleRouteServiceProvider::class => 'getActiveModuleIdentifiers',
+ PluginRouteServiceProvider::class => 'getActivePluginIdentifiers',
+ ];
+
+ foreach ($cases as $class => $resolver) {
+ $source = file_get_contents((new ReflectionClass($class))->getFileName());
+
+ $this->assertStringContainsString(
+ 'self::'.$resolver.'()',
+ $source,
+ $class.' 가 활성 확장 목록을 조회하지 않는다'
+ );
+
+ $this->assertMatchesRegularExpression(
+ '/if \(! in_array\(\$\w+, \$active\w+\)\) \{\s*continue;/',
+ $source,
+ $class.' 가 활성 목록으로 라우트 등록을 게이트하지 않는다 — '
+ .'비활성 확장의 API 가 계속 호출 가능해진다'
+ );
+ }
+ }
+
+ /**
+ * 플러그인 라우트를 격리된 라우터에 등록하고 그 라우터를 반환합니다.
+ *
+ * @return Router 등록 결과가 담긴 라우터
+ */
+ private function registerPluginRoutes(): Router
+ {
+ return $this->registerRoutesWith(PluginRouteServiceProvider::class, 'loadPluginRoutes');
+ }
+
+ /**
+ * 모듈 라우트를 격리된 라우터에 등록하고 그 라우터를 반환합니다.
+ *
+ * @return Router 등록 결과가 담긴 라우터
+ */
+ private function registerModuleRoutes(): Router
+ {
+ return $this->registerRoutesWith(ModuleRouteServiceProvider::class, 'loadModuleRoutes');
+ }
+
+ /**
+ * 프로바이더의 라우트 로더를 격리된 라우터 위에서 실행합니다.
+ *
+ * 애플리케이션 라우터를 그대로 쓰면 이미 부팅 시 등록된 라우트와 섞여 "이번 호출이
+ * 등록한 것" 을 분리할 수 없다. 빈 라우터로 교체해 이번 호출의 결과만 관측한다.
+ *
+ * @param string $providerClass 라우트 프로바이더 클래스
+ * @param string $loader 라우트 로더 메서드명
+ * @return Router 등록 결과가 담긴 라우터
+ */
+ private function registerRoutesWith(string $providerClass, string $loader): Router
+ {
+ $this->restoreRouter();
+ $this->originalRouter = app('router');
+
+ $fresh = new Router(app('events'), app());
+ app()->instance('router', $fresh);
+ Facade::clearResolvedInstance('router');
+
+ $provider = new $providerClass(app());
+ (new ReflectionClass($provider))->getMethod($loader)->invoke($provider);
+
+ $this->restoreRouter();
+
+ return $fresh;
+ }
+
+ /**
+ * 교체했던 애플리케이션 라우터를 원복합니다.
+ */
+ private function restoreRouter(): void
+ {
+ if ($this->originalRouter === null) {
+ return;
+ }
+
+ app()->instance('router', $this->originalRouter);
+ Facade::clearResolvedInstance('router');
+ $this->originalRouter = null;
+ }
+
+ /**
+ * 라우터에 등록된 URI 중 주어진 접두사로 시작하는 것을 반환합니다.
+ *
+ * @param Router $router 대상 라우터
+ * @param string $prefix URI 접두사
+ * @return array 매칭된 URI 목록
+ */
+ private function urisFor(Router $router, string $prefix): array
+ {
+ $uris = [];
+
+ foreach ($router->getRoutes() as $route) {
+ if (str_starts_with($route->uri(), $prefix)) {
+ $uris[] = $route->uri();
+ }
+ }
+
+ return array_values(array_unique($uris));
+ }
+
+ /**
+ * plugins 테이블에 지정한 상태로 행을 준비합니다.
+ *
+ * @param string $identifier 플러그인 식별자
+ * @param ExtensionStatus $status 적용할 상태
+ */
+ private function seedPlugin(string $identifier, ExtensionStatus $status): void
+ {
+ $this->seedExtensionRow('plugins', $identifier, $status);
+ }
+
+ /**
+ * modules 테이블에 지정한 상태로 행을 준비합니다.
+ *
+ * @param string $identifier 모듈 식별자
+ * @param ExtensionStatus $status 적용할 상태
+ */
+ private function seedModule(string $identifier, ExtensionStatus $status): void
+ {
+ $this->seedExtensionRow('modules', $identifier, $status);
+ }
+
+ /**
+ * 확장 테이블에 식별자/상태 행을 upsert 합니다.
+ *
+ * @param string $table 대상 테이블 (modules|plugins)
+ * @param string $identifier 확장 식별자
+ * @param ExtensionStatus $status 적용할 상태
+ */
+ private function seedExtensionRow(string $table, string $identifier, ExtensionStatus $status): void
+ {
+ $existing = DB::table($table)->where('identifier', $identifier)->first();
+
+ if ($existing !== null) {
+ DB::table($table)->where('identifier', $identifier)->update([
+ 'status' => $status->value,
+ 'updated_at' => now(),
+ ]);
+
+ return;
+ }
+
+ DB::table($table)->insert([
+ 'identifier' => $identifier,
+ 'vendor' => explode('-', $identifier)[0],
+ 'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
+ 'version' => '1.0.0',
+ 'status' => $status->value,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+}
diff --git a/tests/Feature/Extension/ExtensionRouteCacheInvalidationTest.php b/tests/Feature/Extension/ExtensionRouteCacheInvalidationTest.php
index 558af3fd..dd68e24f 100644
--- a/tests/Feature/Extension/ExtensionRouteCacheInvalidationTest.php
+++ b/tests/Feature/Extension/ExtensionRouteCacheInvalidationTest.php
@@ -71,7 +71,9 @@ class ExtensionRouteCacheInvalidationTest extends TestCase
foreach ($methods as $method) {
$this->assertStringContainsString(
'RouteCacheHelper::rebuild()',
- $this->methodSource($class, $method),
+ // 주석을 걷어낸 뒤 본다 — 설명 주석의 언급만으로 통과하면
+ // 호출이 사라져도 green 이 된다.
+ $this->stripComments($this->methodSource($class, $method)),
$class.'::'.$method.' 이 라우트 캐시를 갱신하지 않는다 — '
.'캐시가 걸린 사이트에서 이 조작 뒤 확장 라우트가 404 가 된다'
);
@@ -105,6 +107,77 @@ class ExtensionRouteCacheInvalidationTest extends TestCase
}
}
+ #[Test]
+ public function 라우트_캐시_재생성은_상태_캐시_무효화_뒤에_온다(): void
+ {
+ $targets = [
+ ModuleManager::class => 'invalidateModuleStatusCache',
+ PluginManager::class => 'invalidatePluginStatusCache',
+ ];
+
+ foreach ($targets as $class => $invalidator) {
+ $found = 0;
+
+ $reflection = new ReflectionClass($class);
+
+ foreach ($reflection->getMethods() as $method) {
+ // trait 메서드는 getDeclaringClass() 가 사용 클래스를 가리키므로 이 필터만으로는
+ // 걸러지지 않는다. methodSource() 는 클래스 파일을 줄 번호로 자르므로,
+ // 다른 파일에 정의된 메서드가 섞이면 엉뚱한 구간을 읽고 판정이 오염된다.
+ if ($method->getFileName() !== $reflection->getFileName()) {
+ continue;
+ }
+
+ // 주석을 걷어낸 뒤 판정한다 — 주석 안의 호출 언급을 실제 호출로 오인하면
+ // 순서 판정이 통째로 뒤집힌다(설명 주석이 호출보다 앞서는 것이 보통이다).
+ $source = $this->stripComments($this->methodSource($class, $method->getName()));
+ $rebuildAt = strpos($source, 'RouteCacheHelper::rebuild()');
+
+ if ($rebuildAt === false) {
+ continue;
+ }
+
+ $found++;
+ $invalidateAt = strpos($source, $invalidator.'()');
+
+ $this->assertNotFalse(
+ $invalidateAt,
+ $class.'::'.$method->getName().' 이 라우트 캐시를 구우면서 상태 캐시를 무효화하지 않는다'
+ );
+
+ // route:cache 는 새 앱을 부팅해 캐시된 활성 확장 목록을 읽는다.
+ // 무효화가 뒤에 오면 방금 바뀐 상태가 반영되지 않은 채 라우트가 박제된다.
+ $this->assertLessThan(
+ $rebuildAt,
+ $invalidateAt,
+ $class.'::'.$method->getName().' 이 상태 캐시 무효화보다 먼저 라우트 캐시를 굽는다 — '
+ .'그 확장의 라우트가 빠진 채 박제되어 오류 없이 404 가 된다 (#519 회귀)'
+ );
+ }
+
+ // 판정기 자신이 모집단을 잃는 것을 막는 가드 — 리플렉션 필터가 잘못되면
+ // 0건을 순회하고도 green 이 된다.
+ $this->assertGreaterThanOrEqual(5, $found, $class.' 에서 굽기 지점을 도출하지 못했다 — 판정기 모집단이 비었다');
+ }
+ }
+
+ #[Test]
+ public function 업데이트는_상태_복원_뒤에_훅_캐시를_다시_굽는다(): void
+ {
+ foreach ([[ModuleManager::class, 'updateModule'], [PluginManager::class, 'updatePlugin']] as [$class, $method]) {
+ // 주석을 걷어낸 뒤 판정한다 — 설명 주석의 언급만으로 통과하면 안 된다.
+ $source = $this->stripComments($this->methodSource($class, $method));
+
+ // Updating 창 안에서 구워진 훅 캐시는 그 확장의 리스너를 누락한 채 남는다.
+ // 훅 캐시 폴백은 파일 부재/손상에만 작동하므로 stale 은 조용히 통과한다.
+ $this->assertStringContainsString(
+ 'regenerateHookCache()',
+ $source,
+ $class.'::'.$method.' 이 상태 복원 뒤 훅 캐시를 재생성하지 않는다'
+ );
+ }
+ }
+
/**
* 지정한 메서드의 소스 본문을 반환합니다.
*
@@ -124,4 +197,34 @@ class ExtensionRouteCacheInvalidationTest extends TestCase
$target->getEndLine() - $target->getStartLine() + 1
));
}
+
+ /**
+ * 소스에서 주석을 제거합니다.
+ *
+ * 호출 순서를 문자열 위치로 판정하므로, 그 호출을 설명하는 주석이 실제 호출로
+ * 오인되면 판정이 뒤집힌다. 어휘 분석으로 주석 토큰만 걷어낸다.
+ *
+ * @param string $source 대상 소스
+ * @return string 주석이 제거된 소스
+ */
+ private function stripComments(string $source): string
+ {
+ $stripped = '';
+
+ foreach (token_get_all('phpFilesIn(app_path()) as $file) {
+ $source = File::get($file);
+
+ foreach ($this->errorCallsIn($source) as [$offset, $call]) {
+ if (! preg_match("/^\(\s*'([^']+)'/", $call, $m)) {
+ continue;
+ }
+
+ // 키를 실제로 해석해 판정한다. lang 파일을 직접 파싱하면 중첩 키
+ // (`modules.errors.install_failed`)의 경로를 잃어 오탐·누락이 함께 생긴다.
+ // 존재하지 않는 키는 번역기가 키 자체를 돌려주므로 자연히 걸러진다.
+ if (! str_contains(trans($m[1], [], 'ko'), ':error')) {
+ continue;
+ }
+
+ $checked++;
+
+ // messageParams(넷째 인자)에 'error' 키가 실렸는지 본다.
+ // 셋째 인자 errors 페이로드는 다른 통로이므로 치환에 쓰이지 않는다.
+ // 배열의 첫 키가 아닐 수 있으므로(`['module' => ..., 'error' => ...]`)
+ // 인자를 최상위 쉼표 기준으로 갈라 넷째 인자만 본다.
+ $params = $this->topLevelArguments($call)[3] ?? '';
+
+ if (preg_match("/'error'\s*=>/", $params) === 1) {
+ continue;
+ }
+
+ $line = substr_count(substr($source, 0, $offset), "\n") + 1;
+ $missing[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file).':'.$line.' '.$m[1];
+ }
+ }
+
+ $this->assertGreaterThan(
+ 50,
+ $checked,
+ '검사한 호출부가 너무 적다 — 호출부 탐지 정규식이 모집단을 잃었다'
+ );
+
+ $this->assertSame(
+ [],
+ $missing,
+ '다음 호출부가 :error 치환 파라미터 없이 오류 문구를 반환한다 — '
+ ."운영자 화면에 자리표시자 \":error\" 가 그대로 노출된다:\n "
+ .implode("\n ", $missing)
+ );
+ }
+
+ #[Test]
+ public function error_치환_자리를_가진_키는_예외_생성자에서도_파라미터와_함께_전달된다(): void
+ {
+ $missing = [];
+ $checked = 0;
+
+ foreach ($this->phpFilesIn(app_path()) as $file) {
+ $source = File::get($file);
+
+ foreach ($this->exceptionConstructorsIn($source) as [$offset, $call]) {
+ $args = $this->topLevelArguments($call);
+
+ if (! preg_match("/^\s*'([^']+)'/", $args[0] ?? '', $m)) {
+ continue;
+ }
+
+ if (! str_contains(trans($m[1], [], 'ko'), ':error')) {
+ continue;
+ }
+
+ $checked++;
+
+ // 예외 생성자는 `->error()` 와 인자 배치가 다르다 —
+ // 파라미터 배열이 넷째가 아니라 **둘째** 인자다.
+ if (preg_match("/'error'\s*=>/", $args[1] ?? '') === 1) {
+ continue;
+ }
+
+ $line = substr_count(substr($source, 0, $offset), "\n") + 1;
+ $missing[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file).':'.$line.' '.$m[1];
+ }
+ }
+
+ $this->assertGreaterThan(
+ 0,
+ $checked,
+ '검사한 예외 생성자가 0건이다 — 탐지 정규식이 모집단을 잃었다'
+ );
+
+ $this->assertSame(
+ [],
+ $missing,
+ '다음 예외 생성자가 :error 치환 파라미터 없이 던져진다 — 그 메시지가 응답으로 '
+ ."나가면 운영자 화면에 자리표시자 \":error\" 가 그대로 노출된다:\n "
+ .implode("\n ", $missing)
+ );
+ }
+
+ /**
+ * 소스에서 `new *OperationException(...)` 생성자 호출 전체를 잘라 반환합니다.
+ *
+ * `->error()` 스캐너는 첫 인자가 문자열 리터럴이 아니면 건너뛰므로, 키를 들고 다니는
+ * 예외로 던지는 경로는 그 판정에 걸리지 않는다. 그 축을 이 스캐너가 덮는다.
+ *
+ * @param string $source 대상 소스
+ * @return array [호출 시작 오프셋, 호출 전체 문자열]
+ */
+ private function exceptionConstructorsIn(string $source): array
+ {
+ $calls = [];
+
+ if (! preg_match_all('/new\s+\\\\?(\w*OperationException)\(/', $source, $m, PREG_OFFSET_CAPTURE)) {
+ return $calls;
+ }
+
+ foreach ($m[0] as [$match, $at]) {
+ foreach ($this->callsIn(substr($source, $at), $match) as [$rel, $call]) {
+ if ($rel === 0) {
+ $calls[] = [$at, $call];
+ break;
+ }
+ }
+ }
+
+ return $calls;
+ }
+
+ /**
+ * 소스에서 `->error(...)` 호출 전체를 괄호 균형으로 잘라 반환합니다.
+ *
+ * 한 줄만 보면 여러 줄에 걸친 호출의 뒷부분(치환 파라미터)을 놓쳐 오탐이 된다.
+ *
+ * @param string $source 대상 소스
+ * @return array [호출 시작 오프셋, 호출 전체 문자열]
+ */
+ private function errorCallsIn(string $source): array
+ {
+ return $this->callsIn($source, '->error(');
+ }
+
+ /**
+ * 소스에서 지정한 여는 토큰으로 시작하는 호출 전체를 괄호 균형으로 잘라 반환합니다.
+ *
+ * @param string $source 대상 소스
+ * @param string $token 호출 시작 토큰 (여는 괄호 포함, 예: `->error(`)
+ * @return array [호출 시작 오프셋, 호출 전체 문자열]
+ */
+ private function callsIn(string $source, string $token): array
+ {
+ $calls = [];
+ $offset = 0;
+
+ while (($at = strpos($source, $token, $offset)) !== false) {
+ $open = $at + strlen($token) - 1;
+ $depth = 0;
+ $end = null;
+
+ for ($i = $open, $len = strlen($source); $i < $len; $i++) {
+ if ($source[$i] === '(') {
+ $depth++;
+ } elseif ($source[$i] === ')') {
+ $depth--;
+
+ if ($depth === 0) {
+ $end = $i;
+ break;
+ }
+ }
+ }
+
+ if ($end === null) {
+ break;
+ }
+
+ $calls[] = [$at, substr($source, $open, $end - $open + 1)];
+ $offset = $at + 1;
+ }
+
+ return $calls;
+ }
+
+ /**
+ * 호출 문자열 `( ... )` 을 최상위 쉼표 기준으로 갈라 인자 목록을 반환합니다.
+ *
+ * 중첩 괄호·대괄호와 따옴표 안의 쉼표는 경계로 보지 않는다.
+ *
+ * @param string $call 괄호를 포함한 호출 문자열
+ * @return array 인자 문자열 목록
+ */
+ private function topLevelArguments(string $call): array
+ {
+ $inner = substr($call, 1, -1);
+ $args = [];
+ $buffer = '';
+ $depth = 0;
+ $quote = null;
+
+ for ($i = 0, $len = strlen($inner); $i < $len; $i++) {
+ $char = $inner[$i];
+
+ if ($quote !== null) {
+ $buffer .= $char;
+
+ if ($char === '\\') {
+ $buffer .= $inner[++$i] ?? '';
+ } elseif ($char === $quote) {
+ $quote = null;
+ }
+
+ continue;
+ }
+
+ if ($char === "'" || $char === '"') {
+ $quote = $char;
+ $buffer .= $char;
+
+ continue;
+ }
+
+ if ($char === '(' || $char === '[') {
+ $depth++;
+ } elseif ($char === ')' || $char === ']') {
+ $depth--;
+ } elseif ($char === ',' && $depth === 0) {
+ $args[] = trim($buffer);
+ $buffer = '';
+
+ continue;
+ }
+
+ $buffer .= $char;
+ }
+
+ if (trim($buffer) !== '') {
+ $args[] = trim($buffer);
+ }
+
+ return $args;
+ }
+
+ /**
+ * 주어진 디렉토리 아래의 PHP 파일 경로를 모두 반환합니다.
+ *
+ * @param string $directory 대상 디렉토리
+ * @return array PHP 파일 경로 목록
+ */
+ private function phpFilesIn(string $directory): array
+ {
+ $files = [];
+
+ foreach (File::allFiles($directory) as $file) {
+ if ($file->getExtension() === 'php') {
+ $files[] = $file->getPathname();
+ }
+ }
+
+ return $files;
+ }
+}
diff --git a/tests/Feature/Http/GenericCatchStatusCodeContractTest.php b/tests/Feature/Http/GenericCatchStatusCodeContractTest.php
index 5c7474a3..846d6ba2 100644
--- a/tests/Feature/Http/GenericCatchStatusCodeContractTest.php
+++ b/tests/Feature/Http/GenericCatchStatusCodeContractTest.php
@@ -38,40 +38,29 @@ class GenericCatchStatusCodeContractTest extends TestCase
/**
* 예외 원문을 메시지 키로 넘기는 것이 아직 남아 있는 지점.
*
- * 코어 확장 설치 경로는 `\RuntimeException(__('key', [...]))` 형태로 서비스·헬퍼
- * 40여 곳에서 던져진다. 키를 들고 다니는 예외로 승격하려면 설치/업데이트 경로 전체를
- * 건드려야 하므로 별도 작업으로 분리한다 — 여기 남겨 두는 이유는 "판정기가 못 봐서
- * 통과한 것" 과 "알고 남긴 것" 을 구분하기 위해서다. 목록이 늘어나면 실패한다.
+ * 확장 설치 경로 6곳(모듈·플러그인·템플릿 × from-file/from-github)이 여기 있었다.
+ * 도메인 실패가 이미 `*OperationException`(errorKey + params)으로 승격되어 있었는데
+ * catch 만 부모 `\RuntimeException` 으로 남아, 이미 번역된 문장을 키 자리로 넘기고
+ * 있었다. typed catch 로 좁히고 원본 키·파라미터를 넘기도록 바꿔 전부 해소했다.
+ *
+ * 비운 채로 둔다 — 새 위반이 생기면 그 자리에서 실패해야 한다.
*
* @var array
*/
- private const KNOWN_EXCEPTION_TEXT_AS_KEY = [
- 'app/Http/Controllers/Api/Admin/ModuleController.php:437',
- 'app/Http/Controllers/Api/Admin/ModuleController.php:461',
- 'app/Http/Controllers/Api/Admin/PluginController.php:448',
- 'app/Http/Controllers/Api/Admin/PluginController.php:472',
- 'app/Http/Controllers/Api/Admin/TemplateController.php:352',
- 'app/Http/Controllers/Api/Admin/TemplateController.php:376',
- ];
+ private const KNOWN_EXCEPTION_TEXT_AS_KEY = [];
/**
* 광역 `\RuntimeException` catch 가 4xx 를 반환하는 것이 아직 남아 있는 지점.
*
* `\RuntimeException` 은 도메인 예외의 부모가 되기 쉬워, 도메인 실패를 typed 로
* 승격한 뒤에도 이 catch 를 남겨 두면 남는 것은 인프라 예외뿐인데 그것까지 4xx 로
- * 뭉갠다. 위 `KNOWN_EXCEPTION_TEXT_AS_KEY` 와 같은 줄이며, 같은 설치 경로 개편에서
- * 함께 해소된다.
+ * 뭉갠다. 위 `KNOWN_EXCEPTION_TEXT_AS_KEY` 와 같은 줄이었고 함께 해소되었다.
+ *
+ * 비운 채로 둔다 — 새 위반이 생기면 그 자리에서 실패해야 한다.
*
* @var array
*/
- private const KNOWN_BROAD_RUNTIME_CATCH_4XX = [
- 'app/Http/Controllers/Api/Admin/ModuleController.php::installFromFile' => '설치 경로가 도메인 실패를 키 없는 RuntimeException 으로 던진다 — 예외 승격과 함께 해소',
- 'app/Http/Controllers/Api/Admin/ModuleController.php::installFromGithub' => '동일',
- 'app/Http/Controllers/Api/Admin/PluginController.php::installFromFile' => '동일',
- 'app/Http/Controllers/Api/Admin/PluginController.php::installFromGithub' => '동일',
- 'app/Http/Controllers/Api/Admin/TemplateController.php::installFromFile' => '동일',
- 'app/Http/Controllers/Api/Admin/TemplateController.php::installFromGithub' => '동일',
- ];
+ private const KNOWN_BROAD_RUNTIME_CATCH_4XX = [];
/**
* 스캔 대상 컨트롤러 루트 목록 (코어 + 번들 모듈/플러그인).
From 0db8f9c5fbc00e65c1af8475b9b83e3c3fc6d05e Mon Sep 17 00:00:00 2001
From: HeuJung
Date: Fri, 21 Aug 2026 20:55:57 +0900
Subject: [PATCH 5/7] =?UTF-8?q?fix(engine):=20=EA=B5=AC=ED=98=95=20Safari?=
=?UTF-8?q?=20=EB=B6=80=ED=8C=85=20=EC=8B=A4=ED=8C=A8=20=ED=95=B4=EC=86=8C?=
=?UTF-8?q?=20=EB=B0=8F=20=EB=B0=B0=ED=8F=AC=20=EB=B2=88=EB=93=A4=20?=
=?UTF-8?q?=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=ED=95=98=ED=95=9C=20?=
=?UTF-8?q?=ED=9A=8C=EA=B7=80=20=EB=B0=A9=EC=A7=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
코어 엔진 번들의 정규식 lookbehind 2건이 Safari 16.4 미만에서 파싱 자체를
거부당해 iOS 15 대 전 기기에서 SPA 가 통째로 뜨지 않았다. 정규식 리터럴은
스크립트 파싱 단계에서 검증되므로 그 문법을 모르는 엔진은 파일을 한 줄도
실행하지 못하고, 번들이 async/defer 없는 동기 classic 스크립트라 폴백 경로도
없었다. 빌드 타깃(es2020)이 이를 막지 못한 이유는 ES 연도와 브라우저 지원
연도가 다르기 때문이며, 정규식 리터럴 문법은 다운레벨 자체가 불가능해
어떤 타깃 설정으로도 걸러지지 않는다.
- 두 정규식을 lookbehind 없는 형태로 교체(engine-v1.60.6). 선행 따옴표·구분자를
소비한 뒤 캡처 그룹으로 분기하며, 식별자 인덱스가 match[1] → match[2] 로
이동한다. 무작위 200,034 표본 퍼징에서 교체 전후 결과 불일치 0건.
파싱 하한 실측 Safari 16.4 → 14.1 (제보자 기기 15.6 포함).
- 부팅 실패 안내를 사유별로 가른다. 번들을 받았는데 실행되지 않은 경우와
끝내 받지 못한 경우는 사용자가 취할 행동이 정반대인데 같은 문구였다.
사유는 추측이 아니라 window error 이벤트로 SyntaxError 를 관측해 판정한다
(파싱 실패 시 script 는 error 가 아니라 load 를 발생시켜 element 이벤트로는
구분 불가). 비호환 분기는 새로고침해도 낫지 않으므로 버튼을 렌더하지 않는다.
인라인 부트스트랩은 ES5 를 유지해야 이 안내 자체가 구형에서 살아남는다.
- 재발 방지: 배포 JS 산출물의 브라우저 하한 초과 문법·API 를 정적 검사로 차단.
부팅 임계 번들(코어 엔진·템플릿 컴포넌트)은 파싱에 실패하면 안내 화면조차
렌더되지 않으므로 하한과 같은 버전을 요구하는 정규식 리터럴 문법도 금지한다.
lookbehind 의 Safari 16.4 는 하한과 정확히 같아 하한 초과만 보는 검사로는
영원히 잡히지 않았다 — 그 지점이 이번 사각이었다.
- 지원 브라우저 최소 버전(Chrome 111 / Safari 16.4 / Firefox 128)과, 하한 미만
브라우저에서도 화면 표시와 기본 이용은 유지한다는 방침을 문서에 명시.
부수 수정: network-resilience E2E 4건이 자산 URL extensionless 모드에서 URL
glob 이 한 건도 매칭되지 않아 실패하고 있었다. 같은 파일의 컴포넌트 번들
테스트만 이중 모드를 알고 형제 케이스는 몰랐던 불균형이라 매처를 통일했다.
---
AGENTS.md | 15 ++
CHANGELOG.md | 3 +
docs/requirements.md | 22 +-
lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 1 +
.../_bundled/g7-core-ja/backend/ja/errors.php | 2 +
lang/en/errors.php | 5 +
lang/ko/errors.php | 5 +
public/build/core/template-engine.min.js | 2 +-
.../js/core/template-engine/CHANGELOG.md | 12 +
.../core/template-engine/DataBindingEngine.ts | 14 +-
.../__tests__/DataBindingEngine.test.ts | 84 +++++++
.../partials/bootstrap-scripts.blade.php | 91 ++++++--
.../View/BootstrapFallbackDiagnosisTest.php | 154 ++++++++++++
.../smoke/bootstrap-parse-failure.spec.ts | 220 ++++++++++++++++++
.../specs/smoke/network-resilience.spec.ts | 39 +++-
tests/scenarios/bootstrap-parse-failure.yaml | 48 ++++
16 files changed, 685 insertions(+), 32 deletions(-)
create mode 100644 tests/Feature/View/BootstrapFallbackDiagnosisTest.php
create mode 100644 tests/Playwright/specs/smoke/bootstrap-parse-failure.spec.ts
create mode 100644 tests/scenarios/bootstrap-parse-failure.yaml
diff --git a/AGENTS.md b/AGENTS.md
index 327f801c..b391e36e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1126,6 +1126,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 `(? '画面を読み込めませんでした',
'message' => 'ネットワーク接続が不安定な可能性があります。ページを更新してからもう一度お試しください。',
'reload' => '更新',
+ 'incompatible_title' => 'このブラウザでは画面を表示できません',
+ 'incompatible_message' => 'ブラウザが古いため、サイトを実行できませんでした。ブラウザを最新バージョンに更新するか、別のブラウザでアクセスしてください。',
],
];
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/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/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..be74a094 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-21
+
+### 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;
+ }
+
// 부트스트랩 상태 — 정적