Merge branch 'develop'
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.7
|
||||
APP_VERSION=7.0.8
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -3,7 +3,7 @@ APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.7
|
||||
APP_VERSION=7.0.8
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -584,10 +584,15 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통
|
||||
| typed 예외 도입하면서 그 분기의 상태코드도 변경 | typed 는 **기존 상태코드 유지** — 예외 도입이 사용자 계약을 함께 바꾸면 회귀다 |
|
||||
| 공개(비인증) 엔드포인트 응답에 예외 원문 포함 | 원문은 `Log::error` 로만 — 관리자 전용 면의 `errors` 페이로드는 진단 정보로 허용된다 |
|
||||
| 원문을 직접 문자열로 조립해 노출 폭을 호출부가 정함 | 노출 폭은 `ResponseHelper` 가 정한다 — Throwable 을 넘기면 `app.debug` 에서만 펼쳐진다 |
|
||||
| 치환 자리(`:error`)를 가진 키를 파라미터 없이 호출 | 넷째 인자 `messageParams` 로 채운다 — 비워 두면 번역기가 자리표시자를 **그대로 둔 문장**을 돌려줘 운영자 화면에 `:error` 가 노출된다 (실패했을 때만 드러나 정상 흐름 테스트로는 안 잡힌다) |
|
||||
| 사유를 모른다고 치환 자리를 비워 두기 | 알 수 없으면 일반 문구(`errors.unknown_error`)로 채운다 |
|
||||
| 원문을 싣지 않기로 한 문구에 `:error` 자리를 남겨 두기 | 그 키에서 **치환 자리 자체를 없앤다** — 자리를 남기면 나중에 예외 원문으로 채우는 회귀를 부른다 |
|
||||
| 하위 계층이 `false`/`null` 만 돌려주고 실패 사유를 버림 | 사유를 반환 경로에 실어 올린다 (배열 키 `reason` 또는 **뒤에 붙인 선택적 out 파라미터**) — 기존 호출부를 깨지 않는다 |
|
||||
| 확장 수명주기 훅이 사유 없이 `false` 반환 | `AbstractModule`/`AbstractPlugin` 의 `failWith(__('...'))` — 코어가 그 사유를 원인 자리에 싣는다 |
|
||||
|
||||
`message`(첫 인자)와 `errors`(셋째 인자)는 다른 통로다. **키 자리에 원문을 넘기는 것은 언제나 금지**지만, `errors` 페이로드의 원문은 금지 대상이 아니다 — `ResponseHelper::error` 가 문자열 `errors` 를 `500+` 비디버그에서만 차단하고 배열은 통과시키는 것은 `tests/Unit/Helpers/ResponseHelperTest.php` 가 고정한 의도다. 관리자에게 결제대행사·외부 시스템이 돌려준 사유를 감추면 조치 근거가 사라지고, 다국어 키는 유한해서 예상 못 한 실패를 담지 못한다. 판단 축은 "원문이냐 키냐" 가 아니라 **누구에게 / 무엇의 원문인가 / 어느 통로인가** 셋이다.
|
||||
|
||||
상세: [exceptions.md "예외 → 응답 매핑"](docs/backend/exceptions.md). `tests/Feature/Http/GenericCatchStatusCodeContractTest.php` 가 코어와 모든 번들 확장의 컨트롤러를 전수 스캔해 두 규칙을 고정한다. 판정기를 한 확장 안에 두면 그 확장 밖의 동형 결함이 검출되지 않는다.
|
||||
상세: [exceptions.md "예외 → 응답 매핑"](docs/backend/exceptions.md). `tests/Feature/Http/GenericCatchStatusCodeContractTest.php` 가 코어와 모든 번들 확장의 컨트롤러를 전수 스캔해 두 규칙을 고정한다. 판정기를 한 확장 안에 두면 그 확장 밖의 동형 결함이 검출되지 않는다. 치환 자리 축은 `tests/Feature/Http/ErrorMessageParamSubstitutionTest.php` 가 고정한다 — 호출부를 열거하지 않고 `->error(...)` 전수를 괄호 균형으로 잘라, 키를 실제로 번역해 `:error` 를 요구하는지 판정한다. 같은 판정기가 `new *OperationException(...)` 생성자 축도 덮는다(파라미터 배열이 넷째가 아니라 둘째 인자다). 이 축이 없으면 키를 들고 다니는 예외로 던지는 경로가 통째로 사각이 된다.
|
||||
|
||||
### Listener 데이터 접근
|
||||
|
||||
@@ -599,6 +604,8 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통
|
||||
| Listener 생성자에 구체 Repository 직접 주입 | Repository Interface 주입 |
|
||||
| Listener 에서 `request()` / `$_POST` 직접 접근 | Service 가 검증 후 도메인 객체로 전달 받기 |
|
||||
| Filter 훅에 `'type' => 'filter'` 누락 | type 명시 필수 (반환값 무시 회귀 차단) |
|
||||
| 실패 시 호출자 트랜잭션을 되돌려야 하는 Action 훅에 `'sync' => true` 누락 | 금전 이동(쿠폰 차감·복원, 적립금 차감·복원)은 `sync` 필수. 기본값은 큐 래핑 + `afterCommit` 이라 **커밋 뒤에** 실행되어, 예외를 던져도 롤백되지 않고 오류 응답만 나간 채 데이터가 남는다 (큐 드라이버가 `sync` 여도 동일) |
|
||||
| 훅 회귀 테스트에서 리스너를 손으로 `addAction` 등록 | `HookListenerRegistrar::register()` 로 **실제 등록 경로**를 태운다 — 손으로 등록하면 큐 래핑을 건너뛰어 커밋 이후 실행 문제를 통과시킨다 |
|
||||
| Listener 가 `HookListenerInterface` 미구현 (auto-discovery 대상) | implements + `getSubscribedHooks()` 정적 메서드 |
|
||||
|
||||
> 상세: [hooks.md "Listener 데이터 접근 규정"](docs/extension/hooks.md), [service-repository.md](docs/backend/service-repository.md)
|
||||
@@ -1051,6 +1058,8 @@ BaseApiController (최상위)
|
||||
필수: ActionDispatcher 에 핸들러를 등록하는 확장은 재등록 진입점을 window 전역에 고정 이름으로 노출 — 모듈 window.__[Name].initModule, 플러그인 window.__[Name].initPlugin (미노출 시 로케일 전환 후 해당 확장 액션이 전부 무반응, 에러·토스트 없음). 진입점은 핸들러 재등록만 수행
|
||||
필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지
|
||||
필수: 라우트 정의를 바꾸는 지점은 App\Support\RouteCacheHelper::rebuild() 로 라우트 캐시 갱신 — 확장 설치/활성화/비활성화/삭제/업데이트, 코어 업데이트·업그레이드 스텝. route:clear/route:cache 를 각 지점에 직접 흩어 놓지 않는다 (누락 발생, 비우기만 하면 재생성되지 않아 성능 이점 영구 소실). 훅 캐시와 달리 라우트 캐시에는 스캔 폴백이 없어 캐시에 없는 라우트는 예외·경고 없이 404. 파일 교체 중인 코어 업데이트는 중간에 clear(), 끝에서 rebuild(). 템플릿·모듈 설정은 서버 라우트 무관 (상세: docs/backend/routing.md "라우트 캐시")
|
||||
필수: 확장 라우트는 활성 상태인 확장의 것만 등록한다 — 모듈·플러그인 두 라우트 프로바이더가 같은 기준을 쓴다. 게이트가 한쪽에만 있으면 그 비대칭은 오류가 아니라 "조용히 열린 경로" 로만 나타난다: 비활성화해도 화면·메뉴·에셋만 사라지고 API 는 계속 호출 가능하며, 컨트롤러가 정상 처리하므로 오류도 로그도 남지 않는다
|
||||
필수: 그 rebuild() 는 확장 상태 캐시 무효화(invalidate*StatusCache()) 뒤에 온다 — route:cache 는 새 앱을 부팅해 라우트를 수집하는데 그 부팅의 확장 라우트 프로바이더는 DB 가 아니라 캐시된 활성 확장 목록(TTL 기본 1일)을 읽으므로, 먼저 구우면 방금 바뀐 상태가 빠진 채 박제되고 자가 회복되지 않는다 (활성화 → 그 확장 API 전량 404 / 비활성화 → 끈 확장 API 가 계속 호출 가능 / 업데이트 → 404 + 훅 리스너 누락). 무효화는 굽기 직전이 아니라 DB 상태 쓰기 직후에 둔다 — 같은 목록을 읽는 굽기가 라우트 캐시 말고도 있다 (오토로드 갱신 안의 훅 매핑 캐시). update 경로만 예외: Updating 전이 직후에는 비우지 않고 (비우면 그 창의 오토로드 갱신이 그 확장을 비활성으로 판정해 훅 리스너를 떨군다) 상태 복원 직후에 비운 뒤 ExtensionManager::regenerateHookCache() 로 훅 캐시를 다시 굽는다. 훅 캐시 폴백은 파일 부재·손상에만 작동해 내용이 stale 한 경우는 조용히 통과한다
|
||||
필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용
|
||||
필수: 모든 확장 작업은 Artisan 커맨드로 수행
|
||||
```
|
||||
@@ -1119,6 +1128,21 @@ php artisan language-pack:update g7-core-ja --force
|
||||
|
||||
번들 언어팩도 `_bundled` 는 배포 원본일 뿐이다. 설치본(`lang-packs/{id}/`)을 갱신하지 않으면 새로 추가한 번역 키가 런타임에 존재하지 않아 해당 로케일이 조용히 기준 로케일로 폴백한다.
|
||||
|
||||
### 배포 산출물의 브라우저 하한
|
||||
|
||||
선언 하한은 **Chrome 111 / Safari 16.4 / Firefox 128** 이다 ([requirements.md §7](docs/requirements.md)). 빌드 타깃(`target: 'es2020'`)은 이 하한을 강제하지 못한다 — **ES 연도와 브라우저 지원 연도가 다르기 때문**이다. ES2018 인 정규식 lookbehind 를 WebKit 은 Safari 16.4 에서야 구현했고, 타깃 검사는 그대로 통과시킨다.
|
||||
|
||||
정규식 **리터럴** 문법은 그중에서도 다운레벨이 원리상 불가능하다. 번들러는 lookbehind 를 `new RegExp(...)` 로 옮길 뿐이라 파싱 오류가 **런타임 오류로 이동**할 뿐 사라지지 않는다. 따라서 타깃 하향은 해법이 아니다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 배포 JS 산출물에 선언 하한 **초과** 문법·API (`Object.groupBy`·`Promise.withResolvers`·`Array.fromAsync`·`RegExp.escape`·정규식 `v` 플래그) | 하한 이하 문법으로 작성 |
|
||||
| **부팅 임계 번들**(`public/build/core/template-engine.min.js`, `templates/_bundled/*/dist/js/components.iife.js`)에 정규식 리터럴 전용 문법(lookbehind `(?<!` `(?<=`, `v` 플래그) — 하한과 같은 버전이어도 | 그 두 파일만은 하한 미만 브라우저에서도 **파싱**돼야 한다 |
|
||||
| 빌드 타깃을 낮춰 해결 시도 | 소스에서 그 문법을 쓰지 않는다 |
|
||||
| 바이트 길이 비교식 번들러 검출기로 판정 | 문법·API 표 기반 검사 (프린터 표기 차이가 오탐을 낸다) |
|
||||
|
||||
부팅 임계 번들 둘은 `async`/`defer` 없는 동기 classic 스크립트다. 파싱에 실패하면 **폴백 안내 화면조차 렌더되지 않아** 사용자에게는 백지 또는 거짓 진단만 남는다. 하한 미만 브라우저에 "지원 범위 밖" 안내를 띄우려면 이 두 파일은 파싱에 성공해야 하므로, 하한과 **정확히 같은** 버전을 요구하는 문법(lookbehind = Safari 16.4)도 금지한다 — 하한 초과만 보는 검사로는 영원히 잡히지 않는 지점이다. 정적 검사가 이 규칙을 강제한다.
|
||||
|
||||
### 코어 3-번들 구조 + 공유 런타임 (engine-v1.51.0+)
|
||||
|
||||
`core:build` 는 코어 프론트엔드를 3개 IIFE 번들로 빌드한다:
|
||||
|
||||
@@ -4,6 +4,27 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.8] - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- 사이트가 외부로 보내는 요청을 지정한 프록시 서버를 거쳐 나가도록 설정할 수 있습니다. 결제사처럼 접속 IP 를 제한하는 외부 서비스를 연동할 때, 개발·스테이징 환경에서도 허용된 IP 로 요청을 보낼 수 있습니다. 환경설정 > 고급에서 디버그 모드를 켜면 프록시 주소 입력칸이 나타나며, 프록시를 거치지 않을 주소를 예외 목록으로 따로 지정할 수 있습니다. 디버그 모드를 끄면 저장된 주소가 남아 있어도 프록시는 적용되지 않습니다.
|
||||
- 프록시 주소 옆의 「연결 테스트」로 저장하기 전에 연결 여부를 확인할 수 있습니다. 성공하면 그 프록시를 거쳤을 때 외부 서비스에 보이는 IP 주소를 함께 알려주므로, 결제사에 어떤 IP 를 등록해야 하는지 미리 확인할 수 있습니다.
|
||||
- 확장 개발자용: 사이트 표준 HTTP 호출은 프록시 설정이 자동으로 적용됩니다. 외부 연동 규약상 별도 방식으로 통신해야 하는 확장은 코어가 제공하는 프록시 설정을 받아 같은 경로로 내보낼 수 있습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 비활성화한 플러그인의 기능이 더 이상 동작하지 않습니다. 이전에는 플러그인을 꺼도 화면·메뉴·스크립트만 사라지고 그 플러그인의 기능 주소는 계속 응답해, 꺼진 결제수단으로 결제가 시도되는 등 "껐는데 아직 살아 있는" 상태가 남았습니다. 이제 모듈과 동일하게 활성화된 플러그인의 기능만 동작합니다. **결제·본인인증처럼 외부 서비스가 직접 호출하는 주소도 함께 닫히므로, 진행 중인 거래가 있을 때의 비활성화·업데이트는 처리가 끝난 뒤에 하시기 바랍니다.**
|
||||
- 확장 개발자용: 모듈·플러그인의 설치·활성화·비활성화·제거 처리에서 실패 사유를 코어에 전달할 수 있습니다. `failWith()` 로 사유를 남기고 실패를 반환하면 관리자 화면의 실패 안내에 그 사유가 함께 표시됩니다. 사유를 남기지 않아도 종전처럼 동작합니다.
|
||||
- 지원 브라우저 문서에 최소 버전(Chrome 111 / Safari 16.4 / Firefox 128)을 명시했습니다. 이보다 오래된 브라우저에서는 스타일이 일부 깨질 수 있으나 화면 표시와 기본 이용은 가능하도록 유지한다는 방침도 함께 밝혔습니다. (#121 @bigmsg 님께서 건의해주셨습니다.)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 구형 iOS·macOS Safari(16.4 미만)에서 사이트가 전혀 표시되지 않던 문제를 수정했습니다. 화면 구성에 쓰이는 스크립트에 해당 브라우저가 해석하지 못하는 문법이 들어 있어, 스크립트 전체가 실행되지 못하고 사이트가 통째로 멈춰 있었습니다. (#121 @bigmsg 님께서 제보해주셨습니다.)
|
||||
- 화면을 불러오지 못했을 때 원인과 무관하게 "네트워크 연결이 불안정할 수 있습니다"로 안내되던 문제를 수정했습니다. 브라우저가 지원 범위보다 오래되어 화면을 실행하지 못한 경우에는 그에 맞는 안내를 표시하며, 새로고침해도 해결되지 않는 상황이므로 새로고침 버튼도 표시하지 않습니다.
|
||||
- 관리자 화면의 실패 안내에 원인이 들어갈 자리가 채워지지 않아 `:error` 라는 내부 표시가 그대로 보이던 문제를 수정했습니다. 모듈·플러그인·템플릿·언어팩 관리와 플러그인 설정 저장의 실패 안내 전반에서 발생했습니다. 이제 실패 원인을 알 수 있으면 그 원인이, 알 수 없으면 일반 안내 문구가 표시됩니다. 언어팩 관리처럼 원인을 표시하지 않기로 한 안내는 문구 자체를 정리했습니다.
|
||||
- 모듈·플러그인을 활성화한 직후 그 확장의 화면과 기능이 "주소를 찾을 수 없음" 오류만 내던 문제를 수정했습니다. 활성화 시점에 사이트 내부 주소록이 활성화 이전 상태를 기준으로 다시 만들어져, 방금 켠 확장의 주소가 빠진 채로 굳어졌습니다. 시간이 지나도 스스로 복구되지 않았습니다. 같은 원인으로 비활성화한 확장의 기능이 계속 호출 가능하던 문제, 확장을 업데이트한 직후 그 확장의 기능과 다른 기능과의 연동 동작이 함께 누락되던 문제도 바로잡았습니다.
|
||||
|
||||
## [7.0.7] - 2026-08-19
|
||||
|
||||
### Security
|
||||
|
||||
+1
-1
@@ -289,7 +289,7 @@ unzip g7-release.zip
|
||||
|
||||
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
|
||||
ls -la
|
||||
# (필요 시) mv g7-7.0.7 g7
|
||||
# (필요 시) mv g7-7.0.8 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.7-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.8-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.7-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.8-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 모듈 디렉토리 경로 (캐시)
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 플러그인 디렉토리 경로 (캐시)
|
||||
*/
|
||||
|
||||
@@ -74,8 +74,12 @@ class ExtensionManager
|
||||
*
|
||||
* 모듈/플러그인 리스너 수집을 위해 각 Manager 를 (재)로드한 뒤 HookCacheManager 에 위임한다.
|
||||
* 생성 실패는 부팅 시 스캔 폴백으로 흡수되므로 확장 업데이트 흐름을 중단시키지 않는다.
|
||||
*
|
||||
* 확장 수명주기에서 상태를 되돌린 뒤 다시 부를 수 있도록 public 이다 —
|
||||
* Updating 창 안에서 구워진 훅 캐시는 그 확장의 리스너가 빠진 채 남고,
|
||||
* 훅 캐시 폴백은 파일 부재/손상에만 작동해 stale 한 내용은 조용히 통과하기 때문이다.
|
||||
*/
|
||||
protected function regenerateHookCache(): void
|
||||
public function regenerateHookCache(): void
|
||||
{
|
||||
try {
|
||||
$moduleManager = app(ModuleManager::class);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Traits;
|
||||
|
||||
/**
|
||||
* 확장이 수명주기 훅에서 실패 사유를 알리는 통로.
|
||||
*
|
||||
* `install()` / `activate()` / `deactivate()` / `uninstall()` 은 bool 만 돌려주므로,
|
||||
* 확장이 "왜" 거부했는지가 호출자에게 전달되지 않는다. 그 결과 관리자 화면에는
|
||||
* 원인 자리가 빈 실패 문구만 남는다.
|
||||
*
|
||||
* 확장은 `failWith()` 로 사유를 남기며 false 를 돌려주고, 코어(Manager)는
|
||||
* `getLifecycleFailureReason()` 으로 그 사유를 읽어 응답에 싣는다.
|
||||
* 사유를 남기지 않은 확장은 null 이며, 이 경우 코어가 일반 문구로 대체한다.
|
||||
*
|
||||
* 사유는 이미 번역된 문장이어야 한다 — 확장의 언어 파일 키는 코어가 해석할 수 없다.
|
||||
*/
|
||||
trait ReportsLifecycleFailure
|
||||
{
|
||||
/** @var string|null 마지막 수명주기 실패 사유 (번역된 문장) */
|
||||
protected ?string $lifecycleFailureReason = null;
|
||||
|
||||
/**
|
||||
* 마지막 수명주기 훅이 남긴 실패 사유를 반환합니다.
|
||||
*
|
||||
* @return string|null 실패 사유. 남기지 않았으면 null
|
||||
*/
|
||||
public function getLifecycleFailureReason(): ?string
|
||||
{
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
// 저장 응답에도 카탈로그 재부착 — 화면 폼 상태가 응답으로 갱신되므로
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Rules\ValidOutboundProxyUrl;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 아웃바운드 프록시 연결 테스트 요청 검증
|
||||
*
|
||||
* 저장 전에 확인하는 것이 목적이므로 검사 대상은 저장된 설정이 아니라 이번에 제출된 값입니다.
|
||||
* 검증 강도는 저장 경로(SaveSettingsRequest)와 같게 둡니다 — 테스트만 통과하고 저장에서
|
||||
* 거부되거나 그 반대가 되면 운영자가 어느 쪽을 믿어야 할지 알 수 없습니다.
|
||||
*
|
||||
* @since 7.0.8
|
||||
*/
|
||||
class TestOutboundProxyRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 권한 검사는 permission 미들웨어가 담당합니다.
|
||||
*
|
||||
* @return bool 항상 true
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|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<string, string>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'outbound_proxy' => __('validation.attributes.outbound_proxy'),
|
||||
'outbound_proxy_bypass' => __('validation.attributes.outbound_proxy_bypass'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 쿼리 로깅을 설정합니다.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Support\OutboundProxy;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* 아웃바운드 프록시 URL 이 적용 가능한 형태인지 검증하는 Custom Rule.
|
||||
*
|
||||
* 허용 스킴 목록은 {@see OutboundProxy::ALLOWED_SCHEMES} 가 소유한다 — 저장 시점 검증과
|
||||
* 실제 적용 판정이 서로 다른 목록을 보면 "저장은 됐는데 적용되지 않는" 상태가 조용히 생긴다.
|
||||
*
|
||||
* 빈 값은 통과시킨다. 프록시를 쓰지 않는 것이 기본 상태이며, 필수 여부는 FormRequest 의
|
||||
* nullable 규칙이 정한다.
|
||||
*
|
||||
* @since 7.0.8
|
||||
*/
|
||||
class ValidOutboundProxyUrl implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* 값이 적용 가능한 프록시 URL 인지 검증합니다.
|
||||
*
|
||||
* @param string $attribute 검증 대상 필드명
|
||||
* @param mixed $value 검증 대상 값
|
||||
* @param Closure $fail 실패 콜백
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail(__('validation.settings.outbound_proxy_invalid', [
|
||||
'schemes' => implode(', ', OutboundProxy::ALLOWED_SCHEMES),
|
||||
]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! OutboundProxy::isValidUrl($value)) {
|
||||
$fail(__('validation.settings.outbound_proxy_invalid', [
|
||||
'schemes' => implode(', ', OutboundProxy::ALLOWED_SCHEMES),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -242,6 +242,7 @@ class ModuleService
|
||||
* @param string $moduleName 설치할 모듈명
|
||||
* @param VendorMode $vendorMode Vendor 설치 모드
|
||||
* @param bool $force Updating/Failed 등 진행 중 상태도 무시하고 강제 설치 여부
|
||||
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
|
||||
* @return array|null 설치된 모듈 정보 또는 null
|
||||
*
|
||||
* @throws ValidationException 모듈 설치 실패 시
|
||||
@@ -250,12 +251,15 @@ class ModuleService
|
||||
string $moduleName,
|
||||
VendorMode $vendorMode = VendorMode::Auto,
|
||||
bool $force = false,
|
||||
?string &$failureReason = null,
|
||||
): ?array {
|
||||
$failureReason = null;
|
||||
|
||||
HookManager::doAction('core.modules.before_install', $moduleName);
|
||||
|
||||
try {
|
||||
$this->moduleManager->loadModules();
|
||||
$result = $this->moduleManager->installModule($moduleName, null, $vendorMode, $force);
|
||||
$result = $this->moduleManager->installModule($moduleName, null, $vendorMode, $force, $failureReason);
|
||||
|
||||
if ($result) {
|
||||
// 설치 후 모듈 정보 반환
|
||||
@@ -307,7 +311,9 @@ class ModuleService
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => false];
|
||||
// 실패 사유(reason)를 그대로 전달한다 — 여기서 떨어뜨리면 관리자 화면의
|
||||
// 실패 문구에 원인 자리가 비어 자리표시자가 그대로 노출된다.
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
throw ValidationException::withMessages([
|
||||
'module_name' => [__('modules.activation_failed', ['error' => $e->getMessage()])],
|
||||
@@ -359,12 +365,15 @@ class ModuleService
|
||||
*
|
||||
* @param string $moduleName 제거할 모듈명
|
||||
* @param bool $deleteData 모듈 데이터(테이블) 삭제 여부
|
||||
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
|
||||
* @return bool 제거 성공 여부
|
||||
*
|
||||
* @throws ValidationException 모듈 제거 실패 시
|
||||
*/
|
||||
public function uninstallModule(string $moduleName, bool $deleteData = false): bool
|
||||
public function uninstallModule(string $moduleName, bool $deleteData = false, ?string &$failureReason = null): bool
|
||||
{
|
||||
$failureReason = null;
|
||||
|
||||
HookManager::doAction('core.modules.before_uninstall', $moduleName, $deleteData);
|
||||
|
||||
try {
|
||||
@@ -372,7 +381,7 @@ class ModuleService
|
||||
$this->moduleManager->loadModules();
|
||||
$moduleInfo = $this->moduleManager->getModuleInfo($moduleName);
|
||||
|
||||
$result = $this->moduleManager->uninstallModule($moduleName, $deleteData);
|
||||
$result = $this->moduleManager->uninstallModule($moduleName, $deleteData, null, $failureReason);
|
||||
|
||||
if ($result) {
|
||||
$module = $this->moduleRepository->findByName($moduleName);
|
||||
@@ -774,6 +783,13 @@ class ModuleService
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
} catch (ModuleOperationException $e) {
|
||||
throw $e;
|
||||
} catch (\RuntimeException $e) {
|
||||
// ZipInstallHelper 등 설치 원본 처리의 raw RuntimeException(깨진 zip·manifest
|
||||
// 누락 같은 사용자 입력 오류)을 도메인 예외로 승격한다 — 컨트롤러의 좁혀진
|
||||
// catch 가 인프라 예외와 구분해 종전 422 계약을 유지하고, 사유는 :error 로 보존.
|
||||
throw new ModuleOperationException('modules.errors.install_failed', ['error' => $e->getMessage()], $e);
|
||||
} finally {
|
||||
if (File::exists($extractPath)) {
|
||||
File::deleteDirectory($extractPath);
|
||||
@@ -827,6 +843,12 @@ class ModuleService
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
} catch (ModuleOperationException $e) {
|
||||
throw $e;
|
||||
} catch (\RuntimeException $e) {
|
||||
// GithubHelper·ZipInstallHelper 의 raw RuntimeException(잘못된 URL·다운로드
|
||||
// 실패·manifest 오류)을 도메인 예외로 승격한다 — 종전 422 계약 유지, 사유 보존.
|
||||
throw new ModuleOperationException('modules.errors.install_failed', ['error' => $e->getMessage()], $e);
|
||||
} finally {
|
||||
if (File::exists($extractPath)) {
|
||||
File::deleteDirectory($extractPath);
|
||||
@@ -867,10 +889,14 @@ class ModuleService
|
||||
private function executeModuleInstall(string $identifier): array
|
||||
{
|
||||
$this->moduleManager->loadModules();
|
||||
$result = $this->moduleManager->installModule($identifier);
|
||||
$result = $this->moduleManager->installModule($identifier, null, VendorMode::Auto, false, $failureReason);
|
||||
|
||||
if (! $result) {
|
||||
throw new ModuleOperationException('modules.errors.install_failed');
|
||||
// 사유를 실어 올리지 않으면 'modules.errors.install_failed' 의 치환 자리가
|
||||
// 비어 관리자 화면에 리터럴 ':error' 가 그대로 노출된다.
|
||||
throw new ModuleOperationException('modules.errors.install_failed', [
|
||||
'error' => $failureReason ?? __('modules.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->moduleManager->getModuleInfo($identifier);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\OutboundProxy;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 아웃바운드 프록시 연결 테스트.
|
||||
*
|
||||
* 운영자가 입력한 프록시가 실제로 동작하는지, 그리고 그 프록시를 거쳐 나갔을 때 상대편에
|
||||
* 어떤 IP 로 보이는지 확인합니다. 출발지 IP 는 결제사·외부 서비스에 등록해야 하는 값이라
|
||||
* 테스트의 핵심 산출물입니다 — 프록시를 켜기 전에 어떤 IP 를 등록해야 하는지 알 수 있어야
|
||||
* 저장하고 나서 되짚는 일이 없습니다.
|
||||
*
|
||||
* 검사 대상은 **저장된 설정이 아니라 이번에 제출된 값**입니다. 그래서 전역 옵션에 의존하지
|
||||
* 않고 요청마다 프록시를 명시합니다 — 저장 전에 확인하는 것이 목적이기 때문입니다.
|
||||
*
|
||||
* @since 7.0.8
|
||||
*/
|
||||
class OutboundProxyTester
|
||||
{
|
||||
/**
|
||||
* 제출된 프록시 설정으로 외부 연결을 시도합니다.
|
||||
*
|
||||
* @param string $proxyUrl 검사할 프록시 주소
|
||||
* @param array<int, string> $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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,7 @@ class PluginService
|
||||
* @param string $pluginName 플러그인 식별자
|
||||
* @param VendorMode $vendorMode vendor 설치 모드 (Auto/Composer/Bundled)
|
||||
* @param bool $force Updating/Failed 등 진행 중 상태도 무시하고 강제 설치 여부
|
||||
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
|
||||
* @return array|null 설치된 플러그인 정보 또는 설치 실패 시 null
|
||||
*
|
||||
* @throws ValidationException 플러그인 설치 실패 시
|
||||
@@ -123,12 +124,15 @@ class PluginService
|
||||
string $pluginName,
|
||||
VendorMode $vendorMode = VendorMode::Auto,
|
||||
bool $force = false,
|
||||
?string &$failureReason = null,
|
||||
): ?array {
|
||||
$failureReason = null;
|
||||
|
||||
HookManager::doAction('core.plugins.before_install', $pluginName);
|
||||
|
||||
try {
|
||||
$this->pluginManager->loadPlugins();
|
||||
$result = $this->pluginManager->installPlugin($pluginName, null, $vendorMode, $force);
|
||||
$result = $this->pluginManager->installPlugin($pluginName, null, $vendorMode, $force, $failureReason);
|
||||
|
||||
if ($result) {
|
||||
// 설치 후 플러그인 정보 반환
|
||||
@@ -182,7 +186,9 @@ class PluginService
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => false];
|
||||
// 실패 사유(reason)를 그대로 전달한다 — 여기서 떨어뜨리면 관리자 화면의
|
||||
// 실패 문구에 원인 자리가 비어 자리표시자가 그대로 노출된다.
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
throw ValidationException::withMessages([
|
||||
'plugin_name' => [__('plugins.activation_failed', ['error' => $e->getMessage()])],
|
||||
@@ -236,18 +242,21 @@ class PluginService
|
||||
*
|
||||
* @param string $pluginName 플러그인 식별자
|
||||
* @param bool $deleteData 플러그인이 생성한 DB 데이터/스토리지 디렉토리까지 삭제 여부
|
||||
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
|
||||
* @return bool 제거 성공 여부
|
||||
*
|
||||
* @throws ValidationException 제거 실패 시
|
||||
*/
|
||||
public function uninstallPlugin(string $pluginName, bool $deleteData = false): bool
|
||||
public function uninstallPlugin(string $pluginName, bool $deleteData = false, ?string &$failureReason = null): bool
|
||||
{
|
||||
$failureReason = null;
|
||||
|
||||
HookManager::doAction('core.plugins.before_uninstall', $pluginName, $deleteData);
|
||||
|
||||
try {
|
||||
$this->pluginManager->loadPlugins();
|
||||
|
||||
$result = $this->pluginManager->uninstallPlugin($pluginName, $deleteData);
|
||||
$result = $this->pluginManager->uninstallPlugin($pluginName, $deleteData, null, $failureReason);
|
||||
|
||||
HookManager::doAction('core.plugins.after_uninstall', $pluginName, $deleteData, $result);
|
||||
|
||||
@@ -890,6 +899,13 @@ class PluginService
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
} catch (PluginOperationException $e) {
|
||||
throw $e;
|
||||
} catch (\RuntimeException $e) {
|
||||
// ZipInstallHelper 등 설치 원본 처리의 raw RuntimeException(깨진 zip·manifest
|
||||
// 누락 같은 사용자 입력 오류)을 도메인 예외로 승격한다 — 컨트롤러의 좁혀진
|
||||
// catch 가 인프라 예외와 구분해 종전 422 계약을 유지하고, 사유는 :error 로 보존.
|
||||
throw new PluginOperationException('plugins.errors.install_failed', ['error' => $e->getMessage()], $e);
|
||||
} finally {
|
||||
if (File::exists($extractPath)) {
|
||||
File::deleteDirectory($extractPath);
|
||||
@@ -943,6 +959,12 @@ class PluginService
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
} catch (PluginOperationException $e) {
|
||||
throw $e;
|
||||
} catch (\RuntimeException $e) {
|
||||
// GithubHelper·ZipInstallHelper 의 raw RuntimeException(잘못된 URL·다운로드
|
||||
// 실패·manifest 오류)을 도메인 예외로 승격한다 — 종전 422 계약 유지, 사유 보존.
|
||||
throw new PluginOperationException('plugins.errors.install_failed', ['error' => $e->getMessage()], $e);
|
||||
} finally {
|
||||
if (File::exists($extractPath)) {
|
||||
File::deleteDirectory($extractPath);
|
||||
@@ -983,10 +1005,14 @@ class PluginService
|
||||
private function executePluginInstall(string $identifier): array
|
||||
{
|
||||
$this->pluginManager->loadPlugins();
|
||||
$result = $this->pluginManager->installPlugin($identifier);
|
||||
$result = $this->pluginManager->installPlugin($identifier, null, VendorMode::Auto, false, $failureReason);
|
||||
|
||||
if (! $result) {
|
||||
throw new PluginOperationException('plugins.errors.install_failed');
|
||||
// 사유를 실어 올리지 않으면 'plugins.errors.install_failed' 의 치환 자리가
|
||||
// 비어 관리자 화면에 리터럴 ':error' 가 그대로 노출된다.
|
||||
throw new PluginOperationException('plugins.errors.install_failed', [
|
||||
'error' => $failureReason ?? __('plugins.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->pluginManager->getPluginInfo($identifier);
|
||||
|
||||
@@ -151,10 +151,13 @@ class PluginSettingsService
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자
|
||||
* @param array<string, mixed> $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]);
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Contracts\Extension\PluginManagerInterface;
|
||||
use App\Contracts\Extension\TemplateManagerInterface;
|
||||
use App\Contracts\Repositories\LayoutVersionRepositoryInterface;
|
||||
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
||||
use App\Enums\DeactivationReason;
|
||||
use App\Enums\ExtensionStatus;
|
||||
use App\Exceptions\TemplateNotFoundException;
|
||||
use App\Exceptions\TemplateOperationException;
|
||||
@@ -476,12 +477,15 @@ class TemplateService
|
||||
* 템플릿을 비활성화합니다.
|
||||
*
|
||||
* @param int|string $idOrIdentifier 템플릿 ID 또는 식별자
|
||||
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
|
||||
* @return array|null 비활성화된 템플릿 정보 또는 null
|
||||
*
|
||||
* @throws ValidationException 비활성화 실패 시
|
||||
*/
|
||||
public function deactivateTemplate(int|string $idOrIdentifier): ?array
|
||||
public function deactivateTemplate(int|string $idOrIdentifier, ?string &$failureReason = null): ?array
|
||||
{
|
||||
$failureReason = null;
|
||||
|
||||
// ID 또는 identifier로 템플릿 조회
|
||||
$template = is_int($idOrIdentifier)
|
||||
? $this->templateRepository->findById($idOrIdentifier)
|
||||
@@ -496,7 +500,14 @@ class TemplateService
|
||||
HookManager::doAction('core.templates.before_deactivate', $template->identifier);
|
||||
|
||||
try {
|
||||
$result = $this->templateManager->deactivateTemplate($template->identifier);
|
||||
// 위치 인자로 넘긴다 — 이 의존성은 인터페이스 타입이고 테스트가 그 인터페이스를
|
||||
// mock 하므로, 이름 붙인 인자는 mock 의 __call 에 닿아 "Unknown named parameter" 가 된다.
|
||||
$result = $this->templateManager->deactivateTemplate(
|
||||
$template->identifier,
|
||||
DeactivationReason::Manual->value,
|
||||
null,
|
||||
$failureReason
|
||||
);
|
||||
|
||||
if ($result) {
|
||||
// 템플릿 매니저에서 업데이트된 정보 조회
|
||||
@@ -1925,6 +1936,13 @@ class TemplateService
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
} catch (TemplateOperationException $e) {
|
||||
throw $e;
|
||||
} catch (\RuntimeException $e) {
|
||||
// ZipInstallHelper 등 설치 원본 처리의 raw RuntimeException(깨진 zip·manifest
|
||||
// 누락 같은 사용자 입력 오류)을 도메인 예외로 승격한다 — 컨트롤러의 좁혀진
|
||||
// catch 가 인프라 예외와 구분해 종전 422 계약을 유지하고, 사유는 :error 로 보존.
|
||||
throw new TemplateOperationException('templates.errors.install_failed', ['error' => $e->getMessage()], $e);
|
||||
} finally {
|
||||
if (File::exists($extractPath)) {
|
||||
File::deleteDirectory($extractPath);
|
||||
@@ -1978,6 +1996,12 @@ class TemplateService
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
} catch (TemplateOperationException $e) {
|
||||
throw $e;
|
||||
} catch (\RuntimeException $e) {
|
||||
// GithubHelper·ZipInstallHelper 의 raw RuntimeException(잘못된 URL·다운로드
|
||||
// 실패·manifest 오류)을 도메인 예외로 승격한다 — 종전 422 계약 유지, 사유 보존.
|
||||
throw new TemplateOperationException('templates.errors.install_failed', ['error' => $e->getMessage()], $e);
|
||||
} finally {
|
||||
if (File::exists($extractPath)) {
|
||||
File::deleteDirectory($extractPath);
|
||||
@@ -2021,7 +2045,11 @@ class TemplateService
|
||||
$result = $this->templateManager->installTemplate($identifier);
|
||||
|
||||
if (! $result) {
|
||||
throw new TemplateOperationException('templates.errors.install_failed');
|
||||
// installTemplate 은 사유 out 파라미터를 갖지 않으므로 일반 문구로 채운다.
|
||||
// 비워 두면 치환 자리가 남아 관리자 화면에 리터럴 ':error' 가 노출된다.
|
||||
throw new TemplateOperationException('templates.errors.install_failed', [
|
||||
'error' => __('templates.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->templateManager->getTemplateInfo($identifier);
|
||||
|
||||
@@ -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' => '로그인 실패 허용 횟수 (초과 시 잠금)',
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 코어 아웃바운드 HTTP 프록시 판정 규약.
|
||||
*
|
||||
* 운영자가 환경설정(고급 탭)에 지정한 프록시를 코어의 모든 외부 HTTP 호출에 적용할지 결정한다.
|
||||
* 프록시가 걸리면 결제 승인 요청, 코어 업데이트 조회, GeoIP 내려받기, 알림 웹훅 등 코어가
|
||||
* 바깥으로 내보내는 트래픽 전부가 그 서버를 경유한다 — 그래서 판정을 이 클래스 한 곳에 모으고,
|
||||
* 설정 주입 지점(SettingsServiceProvider)과 적용 지점(AppServiceProvider)은 결과만 소비한다.
|
||||
*
|
||||
* 게이트는 디버그 모드다. 디버그 모드가 꺼져 있으면 저장된 값이 남아 있어도 프록시를 적용하지
|
||||
* 않는다 — 관리자 화면에서 입력칸을 감추는 것만으로는 저장 API 를 직접 호출하는 경로를 막지
|
||||
* 못하므로, 화면이 아니라 이 판정이 실질 게이트다.
|
||||
*
|
||||
* @since 7.0.8
|
||||
*/
|
||||
final class OutboundProxy
|
||||
{
|
||||
/**
|
||||
* 프록시 URL 로 허용하는 스킴.
|
||||
*
|
||||
* cURL 이 프록시로 이해하는 형태만 받는다. `socks5h` / `socks4a` 는 이름 해석을 프록시
|
||||
* 서버에 맡기는 형태로, 로컬에서 해석되지 않는 원격 전용 호스트를 다룰 때 필요하다.
|
||||
*/
|
||||
public const ALLOWED_SCHEMES = [
|
||||
'http',
|
||||
'https',
|
||||
'socks4',
|
||||
'socks4a',
|
||||
'socks5',
|
||||
'socks5h',
|
||||
];
|
||||
|
||||
/**
|
||||
* 디버그 설정으로부터 적용할 프록시 옵션을 판정합니다.
|
||||
*
|
||||
* 반환값은 Guzzle 의 `proxy` 옵션 형태이며, 적용하지 않을 때는 null 입니다.
|
||||
*
|
||||
* @param array<string, mixed> $debugSettings debug 카테고리 설정 배열
|
||||
* @return array{http: string, https: string, no: array<int, string>}|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<int, string>}|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<int, mixed> 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<int, string> 정규화된 호스트 목록
|
||||
*/
|
||||
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));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -231,7 +231,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('APP_VERSION', '7.0.7'),
|
||||
'version' => env('APP_VERSION', '7.0.8'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -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 별 부분일치 연산자
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 접근 가드)
|
||||
|
||||
@@ -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` 객체를 반환합니다.
|
||||
클라이언트는 이 값으로 본인인증 화면을 띄운 뒤 원래 요청을 재시도합니다.
|
||||
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
@@ -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 (`언어팩 업데이트에 실패했습니다.`) 으로 응답합니다.
|
||||
|
||||
**에러 응답**
|
||||
|
||||
|
||||
@@ -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
|
||||
<!-- @generated:start: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 | 프록시 주소가 비어 있거나 허용 스킴이 아닌 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
입력한 프록시로 외부에 연결해 보고, 성공하면 그 프록시를 거쳤을 때 상대편에 보이는 출발지 IP 를 함께 반환합니다. 이 IP 는 운영자가 결제사·외부 서비스의 허용 목록에 등록해야 하는 값이라, 설정을 저장하기 전에 확인할 수 있도록 제공합니다.
|
||||
|
||||
검사 대상은 **이번 요청이 제출한 값**입니다. 저장된 설정이나 전역 프록시 옵션을 보지 않으므로, 저장 전에도 그대로 확인할 수 있고 이 호출이 다른 요청의 경로를 바꾸지도 않습니다.
|
||||
|
||||
조회 대상은 `config('core.outbound_proxy.egress_lookup_urls')` 가 소유하며 순차 시도합니다. 목록을 비우면 도달성만 확인하고 IP 는 보고하지 않습니다(폐쇄망 대응).
|
||||
|
||||
|
||||
### POST /api/admin/settings/test-mail
|
||||
<!-- @generated:start:api.admin.settings.test-mail -->
|
||||
- **라우트명**: `api.admin.settings.test-mail`
|
||||
|
||||
@@ -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` 뿐이다. 한쪽만 채우면 자리표시자는 그대로 남는다.
|
||||
|
||||
응답 문구에 예외 원문을 싣지 않기로 정한 화면(언어팩 관리 등)은 **파라미터를 채우는 대신 키에서
|
||||
치환 자리를 없앤다.** 자리를 남긴 채 일반 문구로 채우면 "…실패했습니다: 알 수 없는 오류" 처럼
|
||||
의미 없는 꼬리가 붙고, 나중에 누군가 그 자리를 예외 원문으로 채우는 회귀를 부른다.
|
||||
|
||||
---
|
||||
|
||||
## 예외 → 응답 매핑
|
||||
|
||||
+50
-2
@@ -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()` 이 캐시 파일 로드로 분기해 **라우트 파일 자체가
|
||||
|
||||
@@ -456,6 +456,19 @@ public static function getSubscribedHooks(): array
|
||||
}
|
||||
```
|
||||
|
||||
#### 호출자 트랜잭션 안에서 끝나야 하는 처리는 `sync` 가 필수다
|
||||
|
||||
기본값(큐 래핑)은 `DispatchHookListenerJob` 의 `afterCommit` 정책을 탄다. 즉 **호출자 트랜잭션이 커밋된 뒤에** 실행된다 — 큐 드라이버가 `sync` 여도 마찬가지다(같은 요청 안에서, 커밋 이후에 실행된다).
|
||||
|
||||
따라서 훅 안에서 실패했을 때 **호출자의 작업을 되돌려야 하는 처리**는 기본값으로 두면 안 된다. 되돌릴 대상이 이미 커밋된 뒤라 예외를 던져도 롤백되지 않고, 호출자는 오류 응답을 받는데 데이터는 남는다.
|
||||
|
||||
| 판정 | 예 |
|
||||
|------|-----|
|
||||
| `sync` 필수 | 쿠폰 차감·복원, 적립금 차감·복원 등 실패 시 호출자 트랜잭션을 되돌려야 하는 처리 |
|
||||
| 기본값(큐) 유지 | 활동 로그, 알림 발송, 통계 갱신 등 실패해도 호출자를 되돌리지 않는 후속 처리 |
|
||||
|
||||
선언만으로는 검증되지 않는다 — 회귀 테스트는 리스너를 손으로 `addAction` 하지 말고 실제 등록 경로(`HookListenerRegistrar::register()`)를 태운 뒤, **호출자 트랜잭션 안에서 반영되는지**와 **예외가 호출자를 롤백시키는지**를 단언한다. 손으로 등록하면 프로덕션이 쓰지 않는 경로를 검증하게 되어, 커밋 이후 실행 문제를 그대로 통과시킨다.
|
||||
|
||||
### getSubscribedHooks() 옵션 요약
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|
||||
@@ -145,6 +145,28 @@
|
||||
| `getBenchmarkProfiles()` | `[]` | 성능 계측 대상 선언 (`g7:bench` 가 수집) — 목록/화면/쓰기/배치 4축 ([benchmark.md](../backend/benchmark.md)) |
|
||||
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 모듈은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `ModuleManager::runUpgradeSteps` 가 `RuntimeException` throw |
|
||||
|
||||
#### 수명주기 훅이 실패를 알리는 방법
|
||||
|
||||
`install()` / `activate()` / `deactivate()` / `uninstall()` 은 bool 만 돌려주므로, 그냥 `false` 를
|
||||
반환하면 **왜 거부했는지가 코어에 전달되지 않는다.** 그 결과 운영자는 원인이 빠진 실패 문구만 본다.
|
||||
|
||||
사유를 남기려면 `failWith()` 로 반환한다. 코어가 그 사유를 응답 문구의 원인 자리에 싣는다.
|
||||
|
||||
```php
|
||||
public function activate(): bool
|
||||
{
|
||||
if (! extension_loaded('gd')) {
|
||||
return $this->failWith(__('my-module::messages.gd_required'));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
- 사유는 **이미 번역된 문장**이어야 한다 — 확장의 언어 파일 키는 코어가 해석할 수 없다.
|
||||
- 사유를 남기지 않고 `false` 만 돌려주면 코어가 일반 문구로 대체한다(동작은 그대로).
|
||||
- 같은 규칙이 플러그인(`AbstractPlugin`)에도 동일하게 적용된다.
|
||||
|
||||
#### 동적 권한/역할/메뉴 보존 규칙
|
||||
|
||||
모듈이 런타임에 `Permission::updateOrCreate` / `Role::firstOrCreate` / `Menu::create` 등으로 동적 엔티티를 만드는 경우(예: sirsoft-board 의 게시판 slug 별 권한·역할·메뉴), 업데이트 시 `cleanupStale*` 로직이 **정적 정의에 없다** 는 이유로 전수 삭제되는 회귀가 발생한다. 이를 방지하려면 아래 3개 메서드를 override 해 **현재 DB 에 존재해야 하는 동적 식별자 전체** 를 반환한다.
|
||||
|
||||
+15
-7
@@ -320,15 +320,23 @@ Composer 설치 방식을 선택할 때만 필요하다.
|
||||
|
||||
## 7. 지원 브라우저
|
||||
|
||||
| 브라우저 | 지원 기준 | 검증 방식 |
|
||||
|---------|----------|----------|
|
||||
| Chrome / Edge | React 19 + Tailwind CSS 4 호환 범위 | 자동 브라우저 테스트(Chromium)로 상시 검증 |
|
||||
| Firefox | React 19 + Tailwind CSS 4 호환 범위 | 호환 범위 기준 지원 (상시 자동 테스트 대상 아님) |
|
||||
| Safari | React 19 + Tailwind CSS 4 호환 범위 | 호환 범위 기준 지원 (상시 자동 테스트 대상 아님, 실기기 검증 미수행) |
|
||||
| 브라우저 | 최소 지원 버전 | 검증 방식 |
|
||||
|---------|--------------|----------|
|
||||
| Chrome / Edge | **111** 이상 | 자동 브라우저 테스트(Chromium)로 상시 검증 |
|
||||
| Safari (macOS / iOS) | **16.4** 이상 | 호환 범위 기준 지원 (상시 자동 테스트 대상 아님, 실기기 검증 미수행) |
|
||||
| Firefox | **128** 이상 | 호환 범위 기준 지원 (상시 자동 테스트 대상 아님) |
|
||||
|
||||
- 지원 하한은 프론트엔드 핵심 의존성(React 19, Tailwind CSS 4)의 공식 지원 브라우저
|
||||
범위를 따르며, 해당 의존성 버전이 변경되면 이 기준도 함께 재검토한다
|
||||
- 참고: 2026-08 현재 Tailwind CSS 4 의 공식 하한은 Chrome 111 / Safari 16.4 / Firefox 128 이다
|
||||
범위를 따른다. 위 세 버전은 2026-08 현재 Tailwind CSS 4 의 공식 하한이며(`@property`
|
||||
등 CSS 기능이 결정한다), 해당 의존성 버전이 변경되면 이 기준도 함께 재검토한다
|
||||
- **하한 미만 브라우저에서도 화면 표시와 기본 이용은 가능하도록 유지한다.** 최신 CSS 기능을
|
||||
해석하지 못해 색상·간격 등 스타일이 일부 깨질 수 있으나, 그것이 이용 불가로 이어지지
|
||||
않도록 한다. 이를 위해 배포되는 JavaScript 산출물에는 하한을 넘는 문법·API 를 넣지 않으며,
|
||||
특히 부팅에 필요한 번들에는 다운레벨이 불가능한 정규식 리터럴 문법(lookbehind, `v` 플래그)
|
||||
을 하한과 같은 버전이라도 사용하지 않는다 — 이 번들이 파싱되지 않으면 사이트가 통째로
|
||||
뜨지 않기 때문이다
|
||||
- 브라우저가 화면 구성 스크립트를 끝내 실행하지 못하는 경우에는 백지 대신 그 사유를 밝히는
|
||||
안내 화면을 표시한다
|
||||
- 자동 테스트는 테스트 도구에 포함된 단일 브라우저 빌드로 수행하므로 특정 버전
|
||||
목록(예: "최신 N개 버전")을 상시 보장하지 않는다
|
||||
- Internet Explorer 미지원
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.7] - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- 아웃바운드 프록시 설정의 검증 메시지와 항목명 일본어 번역을 추가했습니다 (`settings.outbound_proxy_*`, `attributes.outbound_proxy*`).
|
||||
- 아웃바운드 프록시 연결 테스트 결과 안내 문구의 일본어 번역을 추가했습니다 (`settings.outbound_proxy_test_*`).
|
||||
- 브라우저가 지원 범위보다 오래되어 화면 구성 스크립트를 실행하지 못한 경우의 안내 문구 일본어 번역을 추가했습니다 (`errors.bootstrap.incompatible_title`, `errors.bootstrap.incompatible_message`).
|
||||
|
||||
### Changed
|
||||
|
||||
- 언어팩 관리(조회·설치·활성화·비활성화·제거·업데이트 확인·업데이트·캐시 갱신·manifest 미리보기) 실패 안내에서 오류 원문 노출(`:error`)이 제거된 것에 맞춰, 해당 실패 문구의 일본어 번역을 갱신했습니다 (`language_packs.*_failed`).
|
||||
|
||||
## [1.0.6] - 2026-08-19
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -31,5 +31,7 @@ return [
|
||||
'title' => '画面を読み込めませんでした',
|
||||
'message' => 'ネットワーク接続が不安定な可能性があります。ページを更新してからもう一度お試しください。',
|
||||
'reload' => '更新',
|
||||
'incompatible_title' => 'このブラウザでは画面を表示できません',
|
||||
'incompatible_message' => 'ブラウザが古いため、サイトを実行できませんでした。ブラウザを最新バージョンに更新するか、別のブラウザでアクセスしてください。',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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 形式が正しくありません。',
|
||||
|
||||
@@ -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設定が不足しています。(バケット、リージョン、アクセスキー、シークレットキー)',
|
||||
|
||||
@@ -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 の使用',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.2] - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- 이미 사용된 쿠폰으로 주문을 시도했을 때의 안내(`exceptions.coupon_already_used`)의 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.1.1] - 2026-08-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -82,6 +82,7 @@ return [
|
||||
'additional_option_custom_text_required' => '追加オプション(:name)の直接入力内容を入力してください。',
|
||||
'coupon_issue_not_found' => 'クーポン発行履歴が見つかりません。',
|
||||
'coupon_issue_not_cancellable' => '未使用状態の発行分のみキャンセルできます。',
|
||||
'coupon_already_used' => 'すでに使用されたクーポンです。クーポンをご確認のうえ、再度ご注文ください。',
|
||||
'country_not_shippable' => '選択された配送先国には配送できない商品です。',
|
||||
'order_shipping_address_update_failed' => '配送先変更処理中にエラーが発生しました。',
|
||||
'order_option_not_confirmable' => '現在の状態では購入確定できない注文オプションです。',
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
|
||||
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"license": "MIT",
|
||||
"scope": "module",
|
||||
"target_identifier": "sirsoft-ecommerce",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.6] - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- 환경설정 > 고급의 아웃바운드 프록시 설정 항목명·설명·입력 안내와 연결 테스트 버튼 라벨의 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.0.5] - 2026-08-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
];
|
||||
|
||||
+10
-10
@@ -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).',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -44,5 +44,10 @@ return [
|
||||
'title' => '화면을 불러오지 못했습니다',
|
||||
'message' => '네트워크 연결이 불안정할 수 있습니다. 새로고침 후 다시 시도해 주세요.',
|
||||
'reload' => '새로고침',
|
||||
|
||||
// 스크립트를 받았으나 브라우저가 실행하지 못한 경우 (지원 범위보다 오래된 브라우저 등).
|
||||
// 새로고침해도 낫지 않으므로 이 분기에서는 새로고침 버튼을 렌더하지 않는다.
|
||||
'incompatible_title' => '이 브라우저에서는 화면을 표시할 수 없습니다',
|
||||
'incompatible_message' => '브라우저가 오래되어 사이트를 실행하지 못했습니다. 브라우저를 최신 버전으로 업데이트하거나 다른 브라우저로 접속해 주세요.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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 파일을 찾을 수 없습니다.',
|
||||
|
||||
@@ -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 버킷 연결에 실패했습니다.',
|
||||
|
||||
@@ -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 사용',
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.5] - 2026-08-22
|
||||
|
||||
### Security
|
||||
|
||||
- 비밀글 목록·상세 응답에 첨부 이미지의 미리보기 주소가 그대로 실려 나가던 문제를 수정했습니다. 이미지 자체는 이미 열람 권한이 없으면 제공되지 않았지만, 주소에 담긴 파일 식별값이 응답에 노출되어 있었습니다. 이제 열람 권한이 없으면 썸네일 주소가 비어서 전달되며, 비밀글이 아닌 글은 종전과 동일하게 표시됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1894)
|
||||
|
||||
## [1.0.4] - 2026-08-19
|
||||
|
||||
### Security
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-board",
|
||||
"description": "Board module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -361,7 +361,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
|
||||
| reply_count | integer | `0` | reply 개수 (집계) |
|
||||
| attachment_count | integer | `0` | attachment 개수 (집계) |
|
||||
| has_attachment | boolean | `false` | attachment 여부 |
|
||||
| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL) |
|
||||
| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시 노출 차단 — 필드 자체는 유지) |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
@@ -1040,7 +1040,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| reply_count | integer | `0` | reply 개수 (집계) |
|
||||
| attachment_count | integer | `0` | attachment 개수 (집계) |
|
||||
| has_attachment | boolean | `false` | attachment 여부 |
|
||||
| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL) |
|
||||
| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시 노출 차단 — 필드 자체는 유지) |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "게시판",
|
||||
"en": "Board"
|
||||
},
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "게시판 관리를 위한 모듈",
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Enums\PermissionType;
|
||||
use App\Enums\UserStatus;
|
||||
use App\Http\Resources\BaseApiResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Sirsoft\Board\Enums\PostStatus;
|
||||
use Modules\Sirsoft\Board\Enums\ReportReasonType;
|
||||
@@ -308,6 +309,13 @@ class PostResource extends BaseApiResource
|
||||
*/
|
||||
private function getThumbnailUrlFromRelations(): ?string
|
||||
{
|
||||
// 비밀글은 썸네일 URL 자체를 방출하지 않는다 — 서빙은 이미 차단되어 이미지가 보이지는
|
||||
// 않지만, URL 에 실린 첨부 해시가 목록·상세 응답으로 나가 있었다(KVE-2026-1894).
|
||||
// 판정은 첨부 목록과 같은 SecretContentGate(SSoT)를 쓴다. 필드는 남기고 값만 가린다.
|
||||
if ($this->is_secret && ! $this->canViewSecretContent(request())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 목록용 경량 관계 우선 — slug를 직접 전달하여 Board::find() N+1 방지
|
||||
if ($this->relationLoaded('thumbnailAttachment') && $this->thumbnailAttachment) {
|
||||
$attachment = $this->thumbnailAttachment;
|
||||
@@ -678,9 +686,35 @@ class PostResource extends BaseApiResource
|
||||
*/
|
||||
private function canViewSecretContent(Request $request, ?string $slug = null): bool
|
||||
{
|
||||
$post = $this->resolvePostModel();
|
||||
|
||||
if ($post === null) {
|
||||
// 원본 모델을 확인할 수 없으면 열람 불가로 판정한다 (fail-closed)
|
||||
return false;
|
||||
}
|
||||
|
||||
// 판정 규칙은 SecretContentGate(SSoT)에 있다 — 리스너·댓글 경로와 규칙을 공유해
|
||||
// 드리프트를 방지한다.
|
||||
return self::canViewSecretForPost($this->resource, $request);
|
||||
return self::canViewSecretForPost($post, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 감싸인 원본 게시글 모델을 반환합니다.
|
||||
*
|
||||
* 컬렉션 경로에서는 리소스가 다시 리소스를 감싸고 있을 수 있어, $this->resource 가
|
||||
* 곧 Post 라고 가정하면 타입 오류로 응답 전체가 실패합니다.
|
||||
*
|
||||
* @return Post|null 원본 게시글 모델 (해석 불가 시 null)
|
||||
*/
|
||||
private function resolvePostModel(): ?Post
|
||||
{
|
||||
$candidate = $this->resource;
|
||||
|
||||
while ($candidate instanceof JsonResource) {
|
||||
$candidate = $candidate->resource;
|
||||
}
|
||||
|
||||
return $candidate instanceof Post ? $candidate : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+39
-2
@@ -25,9 +25,9 @@ class PostResourceThumbnailUrlTest extends BoardTestCase
|
||||
* @param string $mimeType 첨부 MIME 타입
|
||||
* @return array{post: Post, attachment: Attachment} 게시글/첨부
|
||||
*/
|
||||
private function createPostWithAttachment(string $mimeType): array
|
||||
private function createPostWithAttachment(string $mimeType, array $postAttributes = []): array
|
||||
{
|
||||
$postId = $this->createTestPost();
|
||||
$postId = $this->createTestPost($postAttributes);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'board_id' => $this->board->id,
|
||||
@@ -75,4 +75,41 @@ class PostResourceThumbnailUrlTest extends BoardTestCase
|
||||
|
||||
$this->assertNull($response['thumbnail']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀글의 썸네일 URL은 열람 권한이 없으면 방출되지 않아야 합니다 (KVE-2026-1894).
|
||||
*
|
||||
* 서빙은 막혀 있어 이미지가 보이지는 않지만, URL 에 실린 첨부 해시 자체가
|
||||
* 목록·상세 응답으로 나가 있었다. 필드는 남기고 값만 가린다.
|
||||
*
|
||||
* @effects secret_post_thumbnail_hash_not_exposed
|
||||
*/
|
||||
#[Test]
|
||||
public function thumbnail_is_null_for_secret_post_without_permission(): void
|
||||
{
|
||||
['post' => $post] = $this->createPostWithAttachment('image/jpeg', ['is_secret' => true]);
|
||||
|
||||
$response = (new PostResource($post))->toArray(Request::create('/'));
|
||||
|
||||
$this->assertArrayHasKey('thumbnail', $response, 'thumbnail 키 자체는 유지되어야 합니다.');
|
||||
$this->assertNull($response['thumbnail']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀글이 아니면 썸네일 URL이 그대로 유지되어야 합니다 (선택적 차단 회귀 방지).
|
||||
*
|
||||
* @effects secret_post_thumbnail_hash_not_exposed
|
||||
*/
|
||||
#[Test]
|
||||
public function thumbnail_is_preserved_for_non_secret_post(): void
|
||||
{
|
||||
['post' => $post, 'attachment' => $attachment] = $this->createPostWithAttachment('image/jpeg');
|
||||
|
||||
$response = (new PostResource($post))->toArray(Request::create('/'));
|
||||
|
||||
$this->assertSame(
|
||||
'/api/modules/sirsoft-board/boards/'.$this->board->slug.'/attachment/'.$attachment->hash.'/preview',
|
||||
$response['thumbnail'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,20 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.2] - 2026-08-22
|
||||
|
||||
### Security
|
||||
|
||||
- 1회만 쓸 수 있는 쿠폰이 동시에 주문 두 건에 적용되던 문제를 막았습니다. 주문이 거의 같은 시각에 확정되면 두 주문 모두 쿠폰을 사용 가능한 상태로 읽어 각자 할인을 받을 수 있었습니다. 이제 쿠폰 차감이 한 번에 하나만 성공하며, 쿠폰을 선점당한 주문은 확정되지 않고 쿠폰도 소모되지 않아 그대로 다시 시도할 수 있습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1886)
|
||||
- 쿠폰 차감·적립금 차감처럼 금액이 오가는 처리가 주문이 저장된 **뒤에** 실행되던 문제를 바로잡았습니다. 그래서 차감이 실패해도 주문은 이미 만들어진 뒤라 되돌릴 수 없었고, 위 쿠폰 중복 사용도 이 때문에 주문이 두 건 남을 수 있었습니다. 이제 이 처리들은 주문 저장과 같은 묶음 안에서 실행되어, 하나라도 실패하면 주문 전체가 취소됩니다. (KISA 측에서 제보해주신 내용을 확인하는 과정에서 함께 발견했습니다 — KVE-2026-1886)
|
||||
- 같은 주문 상품의 구매 적립 내역이 동시 확정으로 두 줄 생길 수 있던 문제를 막았습니다. 적립 내역은 상품당 한 줄이어야 취소 시 정확히 회수되는데, 두 줄이 되면 적립은 두 배가 되고 회수는 한 줄만 이뤄졌습니다. 이제 데이터베이스가 중복 자체를 막고, 동시에 들어온 요청은 기존 내역에 차액만 더합니다. 업그레이드하면 이미 쌓인 중복 내역도 한 줄로 합쳐집니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 부분취소가 짧은 간격으로 두 번 이뤄질 때 앞선 취소의 취소 총액·취소 횟수·결제 취소 이력이 사라지던 문제를 수정했습니다. 이제 취소 누적값이 두 건 모두 반영됩니다.
|
||||
- 주문 취소로 쿠폰을 되돌릴 때, 그 사이 상태가 바뀐 쿠폰까지 되살리던 문제를 수정했습니다. 이미 만료되었거나 다시 사용된 쿠폰은 복원 대상에서 제외됩니다.
|
||||
- 적립금 적립과 적립 취소가 겹칠 때 한쪽의 반영이 사라지던 문제를 수정했습니다. 적립 내역의 금액·잔여 금액이 항상 최신 값 기준으로 갱신됩니다.
|
||||
|
||||
## [1.1.1] - 2026-08-19
|
||||
|
||||
### Security
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-ecommerce",
|
||||
"description": "Ecommerce module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 주문옵션당 구매 적립(purchase_earn) lot 을 1건으로 강제합니다.
|
||||
*
|
||||
* 적립 코드는 "옵션당 적립 내역 한 줄"을 전제로 동작한다 — 목표 적립액과 기적립 합계의
|
||||
* 차액만 증액하고, 취소 회수는 그 한 줄을 찾아 되돌린다. 그런데 lot 이 아직 없는 최초
|
||||
* 적립은 조회와 생성 사이에 잠글 행이 없어, 같은 옵션의 확정이 동시에 겹치면 두 줄이
|
||||
* 만들어질 수 있다. 그 순간 적립액은 두 배가 되고 취소 회수는 한 줄만 되돌린다.
|
||||
*
|
||||
* 반복이 정상인 유형(부분취소마다 생기는 earn_cancel 등)까지 막으면 안 되므로,
|
||||
* purchase_earn 이면서 주문옵션이 있는 행만 값을 갖는 생성 컬럼에 유니크를 건다
|
||||
* (MySQL 유니크 인덱스는 NULL 중복을 허용한다).
|
||||
*
|
||||
* 기설치본에는 이미 중복이 쌓여 있을 수 있어 인덱스 생성 전에 통합한다. 통합과 제약은
|
||||
* 한 단계라도 어긋나면 다음 실행이 영구 실패하므로 각 단계를 독립적으로 가드한다.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('ecommerce_mileage_transactions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->consolidateDuplicateEarnLots();
|
||||
|
||||
if (! Schema::hasColumn('ecommerce_mileage_transactions', 'purchase_earn_option_key')) {
|
||||
$table = $this->qualifiedTable();
|
||||
|
||||
DB::statement(
|
||||
"ALTER TABLE {$table} ADD COLUMN `purchase_earn_option_key` BIGINT UNSIGNED"
|
||||
." GENERATED ALWAYS AS (CASE WHEN `type` = 'purchase_earn' THEN `order_option_id` ELSE NULL END) VIRTUAL"
|
||||
." COMMENT '주문옵션당 구매적립 1건 강제용 파생 키 (purchase_earn 이 아니면 NULL)'"
|
||||
);
|
||||
}
|
||||
|
||||
if (! $this->indexExists('ecommerce_mileage_transactions_purchase_earn_option_unique')) {
|
||||
$table = $this->qualifiedTable();
|
||||
|
||||
DB::statement(
|
||||
"ALTER TABLE {$table} ADD UNIQUE INDEX"
|
||||
.' `ecommerce_mileage_transactions_purchase_earn_option_unique` (`purchase_earn_option_key`)'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('ecommerce_mileage_transactions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = $this->qualifiedTable();
|
||||
|
||||
if ($this->indexExists('ecommerce_mileage_transactions_purchase_earn_option_unique')) {
|
||||
DB::statement("ALTER TABLE {$table} DROP INDEX `ecommerce_mileage_transactions_purchase_earn_option_unique`");
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('ecommerce_mileage_transactions', 'purchase_earn_option_key')) {
|
||||
DB::statement("ALTER TABLE {$table} DROP COLUMN `purchase_earn_option_key`");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 주문옵션에 중복 생성된 구매 적립 lot 을 가장 먼저 만들어진 한 줄로 통합합니다.
|
||||
*
|
||||
* 금액·잔여금액을 합산해 살아남는 lot 에 얹고 나머지는 삭제한다. 유효기간은 최초 적립
|
||||
* 시점을 유지하는 기존 정책(방식 A)과 같게 살아남는 lot 의 값을 그대로 둔다.
|
||||
*
|
||||
* 반복 종료는 데이터에 맡기지 않는다 — 한 바퀴에서 실제로 지운 행이 없으면 다음 바퀴도
|
||||
* 같은 목록을 다시 읽을 뿐이므로 그 자리에서 멈춘다 (진행 없는 반복 차단).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function consolidateDuplicateEarnLots(): void
|
||||
{
|
||||
// 중복 주문옵션 목록은 통합 대상이 남아 있는 동안만 반복 조회한다.
|
||||
// 한 번에 모두 읽지 않으므로 중복이 많은 설치본에서도 메모리가 늘지 않는다.
|
||||
while (true) {
|
||||
$duplicated = DB::table('ecommerce_mileage_transactions')
|
||||
->select('order_option_id')
|
||||
->where('type', 'purchase_earn')
|
||||
->whereNotNull('order_option_id')
|
||||
->groupBy('order_option_id')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->limit(200)
|
||||
->pluck('order_option_id');
|
||||
|
||||
if ($duplicated->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($duplicated as $orderOptionId) {
|
||||
$lots = DB::table('ecommerce_mileage_transactions')
|
||||
->where('type', 'purchase_earn')
|
||||
->where('order_option_id', $orderOptionId)
|
||||
->orderBy('id')
|
||||
->get(['id', 'amount', 'remaining_amount']);
|
||||
|
||||
if ($lots->count() < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$survivor = $lots->shift();
|
||||
|
||||
DB::table('ecommerce_mileage_transactions')
|
||||
->where('id', $survivor->id)
|
||||
->update([
|
||||
'amount' => (float) $survivor->amount + (float) $lots->sum(fn ($l) => (float) $l->amount),
|
||||
'remaining_amount' => (float) $survivor->remaining_amount + (float) $lots->sum(fn ($l) => (float) $l->remaining_amount),
|
||||
]);
|
||||
|
||||
$deleted += DB::table('ecommerce_mileage_transactions')
|
||||
->whereIn('id', $lots->pluck('id')->all())
|
||||
->delete();
|
||||
}
|
||||
|
||||
// 목록은 남아 있는데 한 행도 지우지 못했다면 더 진행할 수 없다.
|
||||
if ($deleted === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 프리픽스가 붙은 실제 테이블명을 반환합니다.
|
||||
*
|
||||
* @return string 백틱으로 감싼 테이블명
|
||||
*/
|
||||
private function qualifiedTable(): string
|
||||
{
|
||||
return '`'.DB::getTablePrefix().'ecommerce_mileage_transactions`';
|
||||
}
|
||||
|
||||
/**
|
||||
* 인덱스 존재 여부를 확인합니다.
|
||||
*
|
||||
* @param string $indexName 인덱스명
|
||||
* @return bool 존재 여부
|
||||
*/
|
||||
private function indexExists(string $indexName): bool
|
||||
{
|
||||
return ! empty(DB::select(
|
||||
'SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1',
|
||||
[DB::getTablePrefix().'ecommerce_mileage_transactions', $indexName]
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -2153,6 +2153,7 @@ HTTP/1.1 201
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.create`)이 없는 경우 |
|
||||
| 404 | Not Found | 임시 주문(주문서)이 없거나 만료된 경우 (`주문서를 찾을 수 없습니다.` 계열 — `exceptions.temp_order_not_found`) |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 예상 결제금액 불일치(`expected_total_amount` ≠ 서버 재계산값), 결제 통화 미지원(`errors.code = unsupported_payment_currency`), 재고 부족(`errors.insufficient_items`), 구매 불가 상품(`errors.code = cart_unavailable`), 주문 확정 재계산 검증 실패(쿠폰 만료·최소주문금액 미달 등 — `errors.code = order_calculation_validation_failed`). `payment_method` 가 결제수단 카탈로그에 없는 값이면 여기서 차단된다 |
|
||||
| 409 | Conflict | 적용한 쿠폰을 다른 주문이 먼저 사용한 경우 (`errors.code = coupon_already_used`, `errors.coupon_issue_id` 에 해당 발급 ID). 주문은 생성되지 않고 쿠폰도 소모되지 않으므로 그대로 재시도할 수 있습니다 |
|
||||
| 428 | Identity Verification Required | 결제 진입 본인인증(IDV) 정책이 활성이고 미인증(grace 만료)인 경우 |
|
||||
| 500 | Server Error | 주문 생성 중 예기치 못한 오류 (`주문 생성에 실패했습니다.`) |
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "이커머스",
|
||||
"en": "Ecommerce"
|
||||
},
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Exceptions;
|
||||
|
||||
/**
|
||||
* 쿠폰 선점 실패 예외
|
||||
*
|
||||
* 주문 확정 시점에 쿠폰 발급 레코드를 사용 상태로 차감하려 했으나 이미 다른 주문이
|
||||
* 선점했거나 사용 가능 상태가 아닌 경우에 사용합니다. 두 주문 모두 할인 금액이 확정된
|
||||
* 상태이므로, 경쟁에서 밀린 주문은 이 예외로 트랜잭션을 롤백해야 합니다.
|
||||
*/
|
||||
class CouponAlreadyUsedException extends CouponOperationException
|
||||
{
|
||||
/**
|
||||
* CouponAlreadyUsedException 생성자
|
||||
*
|
||||
* @param int $couponIssueId 선점 실패한 쿠폰 발급 ID
|
||||
*/
|
||||
public function __construct(private int $couponIssueId)
|
||||
{
|
||||
parent::__construct('sirsoft-ecommerce::exceptions.coupon_already_used');
|
||||
}
|
||||
|
||||
/**
|
||||
* 선점 실패한 쿠폰 발급 ID를 반환합니다.
|
||||
*
|
||||
* @return int 쿠폰 발급 ID
|
||||
*/
|
||||
public function getCouponIssueId(): int
|
||||
{
|
||||
return $this->couponIssueId;
|
||||
}
|
||||
}
|
||||
+17
@@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\CouponAlreadyUsedException;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\InsufficientStockException;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\MileageValidationException;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException;
|
||||
@@ -168,6 +169,22 @@ trait HandlesOrderCreation
|
||||
'has_restriction_issue' => $e->hasRestrictionIssue(),
|
||||
]);
|
||||
|
||||
} catch (CouponAlreadyUsedException $e) {
|
||||
// 주문 확정 시점에 다른 주문이 같은 쿠폰을 선점했다 — 주문 트랜잭션은 롤백된 상태다.
|
||||
// generic 500 이 아닌 409 로 알려 사용자가 쿠폰 소진 없이 재시도할 수 있게 한다.
|
||||
Log::warning('Order create: coupon already taken by another order', [
|
||||
'coupon_issue_id' => $e->getCouponIssueId(),
|
||||
]);
|
||||
|
||||
$messageKey = $e->getMessageKey();
|
||||
|
||||
return ResponseHelper::error(
|
||||
$messageKey,
|
||||
409,
|
||||
['code' => 'coupon_already_used', 'coupon_issue_id' => $e->getCouponIssueId()],
|
||||
$e->getMessageParams()
|
||||
);
|
||||
|
||||
} catch (MileageValidationException $e) {
|
||||
// 마일리지 사용 정책 위반(한도/단위/최소사용액/잔액) — generic 500 이 아닌 422 명시 차단.
|
||||
// 임시주문 생성 이후 설정이 바뀌었거나 임시주문이 조작된 경우 여기로 떨어진다.
|
||||
|
||||
@@ -35,12 +35,16 @@ class CouponRestoreListener implements HookListenerInterface
|
||||
'sirsoft-ecommerce.order.after_cancel' => [
|
||||
'method' => 'restoreCoupons',
|
||||
'priority' => 10,
|
||||
// 취소 트랜잭션 안에서 실행되어야 한다 — 큐 기본값(afterCommit)이면 취소가
|
||||
// 커밋된 뒤에 복원이 돌아, 취소가 되돌려져도 쿠폰만 복원된 상태가 남는다.
|
||||
'sync' => true,
|
||||
],
|
||||
// 부분취소(및 전체취소 트랜잭션 내부)에서 OrderCancellationService 가 발화하는
|
||||
// 명시적 복원 ID 훅. 스냅샷 파싱 없이 전달받은 ID 만 used→available 복원한다.
|
||||
'sirsoft-ecommerce.coupon.restore' => [
|
||||
'method' => 'restoreCouponsByIds',
|
||||
'priority' => 10,
|
||||
'sync' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -147,27 +151,43 @@ class CouponRestoreListener implements HookListenerInterface
|
||||
continue;
|
||||
}
|
||||
|
||||
// 만료 확인: 만료된 쿠폰은 expired 상태로 변경
|
||||
// 만료 확인: 만료된 쿠폰은 expired 상태로 변경.
|
||||
// 조회 시점의 USED 를 조건으로 실어 원자적으로 쓴다 — 그 사이 다른 요청이 상태를
|
||||
// 바꿨다면 갱신을 포기해야 낡은 스냅샷이 최신 상태를 덮어쓰지 않는다.
|
||||
if ($couponIssue->expired_at !== null && $couponIssue->expired_at->isPast()) {
|
||||
$this->couponIssueRepository->update($issueId, [
|
||||
'status' => CouponIssueRecordStatus::EXPIRED,
|
||||
'used_at' => null,
|
||||
]);
|
||||
$expiredAffected = $this->couponIssueRepository->updateIfStatus(
|
||||
$issueId,
|
||||
CouponIssueRecordStatus::USED,
|
||||
[
|
||||
'status' => CouponIssueRecordStatus::EXPIRED,
|
||||
'used_at' => null,
|
||||
]
|
||||
);
|
||||
|
||||
Log::info('CouponRestoreListener: 만료된 쿠폰 상태 변경', [
|
||||
'coupon_issue_id' => $issueId,
|
||||
'order_id' => $order->id,
|
||||
'new_status' => CouponIssueRecordStatus::EXPIRED->value,
|
||||
]);
|
||||
if ($expiredAffected > 0) {
|
||||
Log::info('CouponRestoreListener: 만료된 쿠폰 상태 변경', [
|
||||
'coupon_issue_id' => $issueId,
|
||||
'order_id' => $order->id,
|
||||
'new_status' => CouponIssueRecordStatus::EXPIRED->value,
|
||||
]);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 사용 가능 상태로 복원
|
||||
$this->couponIssueRepository->update($issueId, [
|
||||
'status' => CouponIssueRecordStatus::AVAILABLE,
|
||||
'used_at' => null,
|
||||
]);
|
||||
// 사용 가능 상태로 복원 (복원은 멱등이 정상이므로 경쟁에서 밀리면 조용히 skip)
|
||||
$affected = $this->couponIssueRepository->updateIfStatus(
|
||||
$issueId,
|
||||
CouponIssueRecordStatus::USED,
|
||||
[
|
||||
'status' => CouponIssueRecordStatus::AVAILABLE,
|
||||
'used_at' => null,
|
||||
]
|
||||
);
|
||||
|
||||
if ($affected === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$restoredCount++;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Listeners;
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\CouponAlreadyUsedException;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface;
|
||||
|
||||
@@ -39,6 +40,10 @@ class CouponUseListener implements HookListenerInterface
|
||||
'sirsoft-ecommerce.coupon.use' => [
|
||||
'method' => 'markCouponsUsed',
|
||||
'priority' => 10,
|
||||
// 호출자(주문 생성) 트랜잭션 안에서 실행되어야 한다. Action 훅 기본값은 큐 작업
|
||||
// 래핑 + afterCommit 이라, 그대로 두면 쿠폰 차감이 주문 커밋 뒤에 실행되어
|
||||
// 차감이 실패해도 주문을 되돌릴 수 없다 (1회 제한 쿠폰 다중 사용).
|
||||
'sync' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -67,24 +72,52 @@ class CouponUseListener implements HookListenerInterface
|
||||
$usedCount = 0;
|
||||
|
||||
foreach (array_unique($appliedCouponIds) as $issueId) {
|
||||
$couponIssue = $this->couponIssueRepository->findById((int) $issueId);
|
||||
$issueId = (int) $issueId;
|
||||
|
||||
// AVAILABLE 판정과 차감을 한 UPDATE 문에서 원자적으로 수행한다.
|
||||
// 조회 후 갱신하면 동시에 확정되는 두 주문이 모두 AVAILABLE 을 읽어
|
||||
// 각자 USED 로 덮어써 1회 제한 쿠폰이 여러 주문에 사용된다.
|
||||
$affected = $this->couponIssueRepository->updateIfStatus(
|
||||
$issueId,
|
||||
CouponIssueRecordStatus::AVAILABLE,
|
||||
[
|
||||
'status' => CouponIssueRecordStatus::USED,
|
||||
'used_at' => now(),
|
||||
'order_id' => $order->id,
|
||||
]
|
||||
);
|
||||
|
||||
if ($affected > 0) {
|
||||
$usedCount++;
|
||||
|
||||
if ($couponIssue === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이미 사용됨/취소됨 등은 skip (멱등성 — 재발화/재시도 안전)
|
||||
if ($couponIssue->status !== CouponIssueRecordStatus::AVAILABLE) {
|
||||
$current = $this->couponIssueRepository->findById($issueId);
|
||||
|
||||
// 존재하지 않는 발급 ID 는 종전대로 skip
|
||||
if ($current === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->couponIssueRepository->update((int) $issueId, [
|
||||
'status' => CouponIssueRecordStatus::USED,
|
||||
'used_at' => now(),
|
||||
// 같은 주문의 재발화(재시도/훅 중복 발화)는 멱등하게 skip
|
||||
if ($current->status === CouponIssueRecordStatus::USED
|
||||
&& (int) $current->order_id === (int) $order->id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 다른 주문이 선점했거나 사용 가능 상태가 아니다. 이 주문은 이미 할인 금액이
|
||||
// 확정된 상태이므로 검출만으로는 부족하고, 예외를 전파해 주문 트랜잭션 자체를
|
||||
// 롤백해야 쿠폰이 중복 사용되지 않는다.
|
||||
Log::warning('CouponUseListener: 쿠폰 선점 실패 — 주문 롤백', [
|
||||
'order_id' => $order->id,
|
||||
'order_number' => $order->order_number ?? null,
|
||||
'coupon_issue_id' => $issueId,
|
||||
'current_status' => $current->status?->value,
|
||||
'current_order_id' => $current->order_id,
|
||||
]);
|
||||
|
||||
$usedCount++;
|
||||
throw new CouponAlreadyUsedException($issueId);
|
||||
}
|
||||
|
||||
Log::info('CouponUseListener: 주문 쿠폰 사용 차감 완료', [
|
||||
@@ -93,6 +126,9 @@ class CouponUseListener implements HookListenerInterface
|
||||
'total_coupons' => count(array_unique($appliedCouponIds)),
|
||||
'used_count' => $usedCount,
|
||||
]);
|
||||
} catch (CouponAlreadyUsedException $e) {
|
||||
// 도메인 실패는 삼키지 않고 전파한다 (주문 트랜잭션 롤백 트리거)
|
||||
throw $e;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CouponUseListener: 쿠폰 사용 차감 실패', [
|
||||
'order_id' => $order->id,
|
||||
|
||||
@@ -40,8 +40,11 @@ class MileageTransactionListener implements HookListenerInterface
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'sirsoft-ecommerce.mileage.use' => ['method' => 'handleUse', 'priority' => 10],
|
||||
'sirsoft-ecommerce.mileage.restore' => ['method' => 'handleRestore', 'priority' => 10],
|
||||
// 마일리지 차감/복원은 호출자(주문 생성·취소) 트랜잭션 안에서 실행되어야 한다.
|
||||
// Action 훅 기본값은 큐 작업 래핑 + afterCommit 이라, 그대로 두면 주문이 커밋된
|
||||
// 뒤에 차감이 돌아 잔액 부족으로 실패해도 주문을 되돌릴 수 없다.
|
||||
'sirsoft-ecommerce.mileage.use' => ['method' => 'handleUse', 'priority' => 10, 'sync' => true],
|
||||
'sirsoft-ecommerce.mileage.restore' => ['method' => 'handleRestore', 'priority' => 10, 'sync' => true],
|
||||
'sirsoft-ecommerce.order-option.after_confirm' => ['method' => 'handleAfterConfirm', 'priority' => 10],
|
||||
'sirsoft-ecommerce.order_option.after_status_change' => ['method' => 'handleAfterStatusChange', 'priority' => 10],
|
||||
'sirsoft-ecommerce.order_option.after_bulk_status_change' => ['method' => 'handleAfterBulkStatusChange', 'priority' => 10],
|
||||
|
||||
+24
-7
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Repositories\Contracts;
|
||||
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
|
||||
|
||||
/**
|
||||
@@ -45,9 +48,9 @@ interface CouponIssueRepositoryInterface
|
||||
* @param int $userId 사용자 ID
|
||||
* @param string|null $status 필터 상태 (available, used, expired)
|
||||
* @param int $perPage 페이지당 항목 수
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 쿠폰함 페이지네이터
|
||||
* @return LengthAwarePaginator 쿠폰함 페이지네이터
|
||||
*/
|
||||
public function getUserCoupons(int $userId, ?string $status = null, int $perPage = 10): \Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
public function getUserCoupons(int $userId, ?string $status = null, int $perPage = 10): LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* 특정 사용자가 소유한 쿠폰만 조회 (소유권 검증용)
|
||||
@@ -57,14 +60,14 @@ interface CouponIssueRepositoryInterface
|
||||
*
|
||||
* @param array $couponIssueIds 쿠폰 발급 ID 배열
|
||||
* @param int $userId 사용자 ID
|
||||
* @return \Illuminate\Support\Collection CouponIssue 컬렉션
|
||||
* @return Collection CouponIssue 컬렉션
|
||||
*/
|
||||
public function findByIdsForUser(array $couponIssueIds, int $userId): \Illuminate\Support\Collection;
|
||||
public function findByIdsForUser(array $couponIssueIds, int $userId): Collection;
|
||||
|
||||
/**
|
||||
* 쿠폰 발급 레코드 생성
|
||||
*
|
||||
* @param array $data 발급 데이터
|
||||
* @param array $data 발급 데이터
|
||||
* @return CouponIssue
|
||||
*/
|
||||
public function create(array $data): CouponIssue;
|
||||
@@ -72,8 +75,8 @@ interface CouponIssueRepositoryInterface
|
||||
/**
|
||||
* 특정 사용자의 특정 쿠폰 발급 횟수 조회
|
||||
*
|
||||
* @param int $userId 사용자 ID
|
||||
* @param int $couponId 쿠폰 ID
|
||||
* @param int $userId 사용자 ID
|
||||
* @param int $couponId 쿠폰 ID
|
||||
* @return int 발급 횟수
|
||||
*/
|
||||
public function getUserIssuedCountForCoupon(int $userId, int $couponId): int;
|
||||
@@ -99,6 +102,20 @@ interface CouponIssueRepositoryInterface
|
||||
*/
|
||||
public function update(int $id, array $data): bool;
|
||||
|
||||
/**
|
||||
* 현재 상태가 기대값과 같을 때만 쿠폰 발급 레코드를 갱신합니다.
|
||||
*
|
||||
* 조회 후 갱신하는 방식은 두 요청이 같은 상태를 읽어 서로를 덮어쓰는 lost update 를
|
||||
* 허용하므로, 상태 판정과 갱신을 하나의 UPDATE 문에서 원자적으로 수행합니다.
|
||||
* 갱신된 행 수가 0 이면 다른 요청이 이미 상태를 바꾼 것입니다.
|
||||
*
|
||||
* @param int $id 쿠폰 발급 ID
|
||||
* @param CouponIssueRecordStatus $expected 기대하는 현재 상태
|
||||
* @param array $data 업데이트 데이터
|
||||
* @return int 갱신된 행 수 (0 이면 경쟁에서 밀렸거나 상태 불일치)
|
||||
*/
|
||||
public function updateIfStatus(int $id, CouponIssueRecordStatus $expected, array $data): int;
|
||||
|
||||
/**
|
||||
* ID 목록으로 쿠폰 발급 레코드를 조회합니다.
|
||||
*
|
||||
|
||||
+11
@@ -214,6 +214,17 @@ interface MileageTransactionRepositoryInterface
|
||||
*/
|
||||
public function findEarnLotForOption(int $orderOptionId): ?MileageTransaction;
|
||||
|
||||
/**
|
||||
* 주문옵션의 적립 lot 을 행 잠금과 함께 조회합니다.
|
||||
*
|
||||
* 적립 증액·취소 회수는 lot 의 현재 값을 읽어 더하거나 빼는 경로라, 두 요청이 같은
|
||||
* 값을 읽으면 한쪽 반영이 사라집니다. 갱신 트랜잭션 안에서 이 메서드로 잠급니다.
|
||||
*
|
||||
* @param int $orderOptionId 주문옵션 ID
|
||||
* @return MileageTransaction|null 잠긴 적립 lot (없으면 null)
|
||||
*/
|
||||
public function findEarnLotForOptionForUpdate(int $orderOptionId): ?MileageTransaction;
|
||||
|
||||
/**
|
||||
* 회원의 활성 적립건(lot) 전부를 조회합니다 (FOR UPDATE 없음 — 탈퇴 정리용).
|
||||
*
|
||||
|
||||
+11
@@ -12,6 +12,17 @@ use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
*/
|
||||
interface OrderPaymentRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* 주문 ID로 결제 행을 잠금과 함께 조회합니다.
|
||||
*
|
||||
* 취소 누적액·취소 이력은 현재 값을 읽어 더하거나 덧붙이는 컬럼이라, 두 요청이 같은
|
||||
* 값을 읽으면 후행이 선행을 덮어씁니다. 트랜잭션 안에서 행을 잠근 뒤 갱신합니다.
|
||||
*
|
||||
* @param int $orderId 주문 ID
|
||||
* @return OrderPayment|null 잠긴 결제 모델 (없으면 null)
|
||||
*/
|
||||
public function findByOrderIdForUpdate(int $orderId): ?OrderPayment;
|
||||
|
||||
/**
|
||||
* 현금영수증 발급 성공 시 결제의 요약 컬럼을 갱신합니다.
|
||||
*
|
||||
|
||||
+12
@@ -19,6 +19,18 @@ interface OrderRepositoryInterface
|
||||
*/
|
||||
public function find(int $id): ?Order;
|
||||
|
||||
/**
|
||||
* ID로 주문을 행 잠금과 함께 조회합니다.
|
||||
*
|
||||
* 취소 총액·취소 횟수처럼 현재 값을 읽어 더하는 컬럼은 두 요청이 같은 값을 읽으면
|
||||
* 후행이 선행을 덮어씁니다. 트랜잭션 안에서 이 메서드로 행을 잠근 뒤 갱신하면
|
||||
* 뒤따르는 요청이 앞선 커밋을 본 뒤에 진행합니다.
|
||||
*
|
||||
* @param int $id 주문 ID
|
||||
* @return Order|null 잠긴 주문 모델 (없으면 null)
|
||||
*/
|
||||
public function findByIdForUpdate(int $id): ?Order;
|
||||
|
||||
/**
|
||||
* 주문이 1건이라도 존재하는지 확인합니다. (A2 base 통화 변경 가드)
|
||||
*
|
||||
|
||||
@@ -248,6 +248,19 @@ class CouponIssueRepository implements CouponIssueRepositoryInterface
|
||||
->update($data) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function updateIfStatus(int $id, CouponIssueRecordStatus $expected, array $data): int
|
||||
{
|
||||
// 상태 판정을 WHERE 절에 실어 단일 UPDATE 로 수행한다 (compare-and-set).
|
||||
// 조회 후 갱신하면 두 요청이 같은 상태를 읽어 서로를 덮어쓴다.
|
||||
return $this->model
|
||||
->where('id', $id)
|
||||
->where('status', $expected->value)
|
||||
->update($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
|
||||
+26
-5
@@ -88,8 +88,11 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf
|
||||
*/
|
||||
public function decrementRemaining(MileageTransaction $lot, float $amount): void
|
||||
{
|
||||
$lot->remaining_amount = (float) $lot->remaining_amount - $amount;
|
||||
$lot->save();
|
||||
// 값을 PHP 에서 빼고 모델 전체를 저장하면, 스냅샷을 읽은 뒤 다른 요청이 반영한
|
||||
// 증감이 통째로 사라진다. 컬럼 연산으로 위임해 커밋된 값에서 차감한다.
|
||||
MileageTransaction::query()->where('id', $lot->id)->decrement('remaining_amount', $amount);
|
||||
|
||||
$lot->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,9 +133,14 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf
|
||||
*/
|
||||
public function incrementEarnLotAmount(MileageTransaction $lot, float $delta): void
|
||||
{
|
||||
$lot->amount = (float) $lot->amount + $delta;
|
||||
$lot->remaining_amount = (float) $lot->remaining_amount + $delta;
|
||||
$lot->save();
|
||||
// 스냅샷 기준 재계산 대신 컬럼 연산 — 그 사이 반영된 다른 증감을 덮어쓰지 않는다.
|
||||
MileageTransaction::query()->where('id', $lot->id)->incrementEach([
|
||||
'amount' => $delta,
|
||||
'remaining_amount' => $delta,
|
||||
]);
|
||||
|
||||
// 호출부가 이 모델을 그대로 반환·기록하므로 반영된 값으로 되읽는다
|
||||
$lot->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -448,6 +456,19 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function findEarnLotForOptionForUpdate(int $orderOptionId): ?MileageTransaction
|
||||
{
|
||||
// 잠금은 트랜잭션 안에서만 의미가 있다 — 적립/회수 갱신 트랜잭션에서 호출한다
|
||||
return MileageTransaction::query()
|
||||
->where('order_option_id', $orderOptionId)
|
||||
->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,14 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderPaymentRepositoryInter
|
||||
*/
|
||||
class OrderPaymentRepository implements OrderPaymentRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function findByOrderIdForUpdate(int $orderId): ?OrderPayment
|
||||
{
|
||||
return OrderPayment::query()->where('order_id', $orderId)->lockForUpdate()->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
|
||||
@@ -146,6 +146,14 @@ class OrderRepository implements OrderRepositoryInterface
|
||||
return $this->model->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function findByIdForUpdate(int $id): ?Order
|
||||
{
|
||||
return $this->model->newQuery()->lockForUpdate()->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
|
||||
@@ -32,6 +32,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterf
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderPaymentRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundOptionRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
|
||||
|
||||
@@ -57,6 +58,7 @@ class OrderCancellationService
|
||||
* @param OrderRefundOptionRepositoryInterface $orderRefundOptionRepository 주문 환불 옵션 Repository
|
||||
* @param CashReceiptService $cashReceiptService 현금영수증 발급/취소 서비스
|
||||
* @param OrderPaymentRepositoryInterface $orderPaymentRepository 주문 결제 Repository
|
||||
* @param OrderRepositoryInterface $orderRepository 주문 Repository
|
||||
*/
|
||||
public function __construct(
|
||||
protected OrderAdjustmentService $adjustmentService,
|
||||
@@ -72,6 +74,7 @@ class OrderCancellationService
|
||||
protected OrderRefundOptionRepositoryInterface $orderRefundOptionRepository,
|
||||
protected CashReceiptService $cashReceiptService,
|
||||
protected OrderPaymentRepositoryInterface $orderPaymentRepository,
|
||||
protected OrderRepositoryInterface $orderRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -314,6 +317,12 @@ class OrderCancellationService
|
||||
$cancelledBy, $cancelPg, $adjustmentResult,
|
||||
&$orderCancel, &$orderRefund,
|
||||
) {
|
||||
// ③-0. 누적 컬럼 갱신 전 주문·결제 행을 잠그고 커밋된 값으로 되읽는다.
|
||||
// total_cancelled_amount / cancellation_count / cancelled_amount / cancel_history 는
|
||||
// 현재 값을 읽어 더하거나 덧붙이는 컬럼이라, 동시 부분취소 두 건이 같은 값을 읽으면
|
||||
// 후행이 선행을 덮어써 취소 총액이 과소 기록되고 PG 환불 기준이 어긋난다.
|
||||
$this->lockAccumulatorRows($order);
|
||||
|
||||
$now = Carbon::now();
|
||||
$isFullCancel = $cancelType === CancelTypeEnum::FULL;
|
||||
$isPaid = ! $order->order_status->isBeforePayment();
|
||||
@@ -734,6 +743,32 @@ class OrderCancellationService
|
||||
// ③-e. Order 합계 업데이트
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 누적 컬럼을 가진 주문·결제 행을 잠그고 커밋된 값으로 되읽습니다.
|
||||
*
|
||||
* 관계(options/shippings 등)는 이 트랜잭션 안에서 이미 사용 중이므로 건드리지 않고,
|
||||
* 누적 판단에 쓰이는 속성만 교체한다. 잠금은 커밋까지 유지되어 뒤따르는 취소 요청이
|
||||
* 앞선 커밋을 본 뒤에 진행한다.
|
||||
*
|
||||
* @param Order $order 대상 주문 (속성이 최신 값으로 갱신됨)
|
||||
* @return void
|
||||
*/
|
||||
protected function lockAccumulatorRows(Order $order): void
|
||||
{
|
||||
$locked = $this->orderRepository->findByIdForUpdate($order->id);
|
||||
|
||||
if ($locked !== null) {
|
||||
// 관계는 유지한 채 속성만 커밋된 값으로 교체
|
||||
$order->setRawAttributes($locked->getAttributes(), true);
|
||||
}
|
||||
|
||||
$lockedPayment = $this->orderPaymentRepository->findByOrderIdForUpdate($order->id);
|
||||
|
||||
if ($lockedPayment !== null && $order->relationLoaded('payment') && $order->payment !== null) {
|
||||
$order->payment->setRawAttributes($lockedPayment->getAttributes(), true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문 합계를 재계산 결과에 따라 갱신합니다.
|
||||
*
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Extension\HookManager;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\MileageAdminDeductDto;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\MileageAdminEarnDto;
|
||||
@@ -379,36 +380,35 @@ class UserMileageService
|
||||
// 방식 A: 기존 purchase_earn lot 이 있으면 그 lot 에 델타를 증액(적립 내역 한 줄 유지).
|
||||
// 취소 회수(findEarnLotForOption 단일 lot 가정)·유효기간 정합을 위해 신규 lot 을 늘리지 않는다.
|
||||
if ($type === MileageTransactionTypeEnum::PURCHASE_EARN
|
||||
&& ($existingLot = $this->ledger->findEarnLotForOption($option->id)) !== null) {
|
||||
$this->ledger->incrementEarnLotAmount($existingLot, $amount);
|
||||
|
||||
$this->cache->recalculateForUser($order->user_id, $currency);
|
||||
$this->cache->recalculatePending($order->user_id, $currency);
|
||||
|
||||
$this->logActivity('mileage.earn', [
|
||||
'loggable' => $existingLot,
|
||||
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_earn',
|
||||
'description_params' => ['amount' => ecommerce_format_price((int) $amount, $currency)],
|
||||
'properties' => ['order_id' => $order->id, 'order_option_id' => $option->id, 'currency' => $currency, 'delta' => (int) $amount],
|
||||
]);
|
||||
|
||||
return $existingLot;
|
||||
&& $this->ledger->findEarnLotForOption($option->id) !== null) {
|
||||
return $this->applyDeltaToExistingEarnLot($order, $option, $currency, $target);
|
||||
}
|
||||
|
||||
$expiresAt = $this->resolveEarnExpiry();
|
||||
|
||||
$tx = $this->ledger->createTransaction([
|
||||
'user_id' => $order->user_id,
|
||||
'currency' => $currency,
|
||||
'type' => $type->value,
|
||||
'amount' => $amount,
|
||||
'remaining_amount' => $amount,
|
||||
'balance_after' => $this->ledger->getBalanceByCurrency($order->user_id, $currency) + $amount,
|
||||
'order_id' => $order->id,
|
||||
'order_option_id' => $option->id,
|
||||
'expires_at' => $expiresAt,
|
||||
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_earn', ['amount' => ecommerce_format_price($amount, $currency)]),
|
||||
]);
|
||||
try {
|
||||
$tx = $this->ledger->createTransaction([
|
||||
'user_id' => $order->user_id,
|
||||
'currency' => $currency,
|
||||
'type' => $type->value,
|
||||
'amount' => $amount,
|
||||
'remaining_amount' => $amount,
|
||||
'balance_after' => $this->ledger->getBalanceByCurrency($order->user_id, $currency) + $amount,
|
||||
'order_id' => $order->id,
|
||||
'order_option_id' => $option->id,
|
||||
'expires_at' => $expiresAt,
|
||||
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_earn', ['amount' => ecommerce_format_price($amount, $currency)]),
|
||||
]);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// 최초 적립이 동시에 겹쳐 다른 요청이 먼저 lot 을 만들었다. 옵션당 적립 lot 은
|
||||
// 한 줄이어야 하므로(취소 회수가 그 한 줄을 되돌린다) 새로 만들지 않고 증액 경로로
|
||||
// 흡수한다 — 델타는 잠근 행 기준으로 다시 산정되므로 이중 적립이 되지 않는다.
|
||||
if ($type !== MileageTransactionTypeEnum::PURCHASE_EARN) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->applyDeltaToExistingEarnLot($order, $option, $currency, $target);
|
||||
}
|
||||
|
||||
$this->cache->recalculateForUser($order->user_id, $currency);
|
||||
$this->cache->recalculatePending($order->user_id, $currency);
|
||||
@@ -558,17 +558,24 @@ class UserMileageService
|
||||
return null;
|
||||
}
|
||||
|
||||
// 해당 옵션 적립건 조회
|
||||
$earnLot = $this->ledger->findEarnLotForOption($option->id);
|
||||
|
||||
if ($earnLot === null) {
|
||||
// 해당 옵션 적립건 존재 확인 (실제 회수 대상은 트랜잭션 안에서 잠금과 함께 되읽는다)
|
||||
if ($this->ledger->findEarnLotForOption($option->id) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$currency = $this->baseCurrencyForOrder($order);
|
||||
$toRecover = (float) $earnLot->amount;
|
||||
|
||||
return DB::transaction(function () use ($order, $option, $earnLot, $currency, $toRecover) {
|
||||
return DB::transaction(function () use ($order, $option, $currency) {
|
||||
// 회수액은 잠근 행의 커밋된 금액 기준이어야 한다 — 트랜잭션 밖에서 읽은 값을 쓰면
|
||||
// 그 사이 반영된 적립 증액분이 회수에서 누락된다.
|
||||
$earnLot = $this->ledger->findEarnLotForOptionForUpdate($option->id);
|
||||
|
||||
if ($earnLot === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$toRecover = (float) $earnLot->amount;
|
||||
|
||||
$shortfall = $this->recoverPoints($order->user_id, $currency, $toRecover, $earnLot);
|
||||
|
||||
$tx = $this->ledger->createTransaction([
|
||||
@@ -965,6 +972,49 @@ class UserMileageService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 구매 적립 lot 에 목표액 대비 델타만 증액합니다 (방식 A — 적립 내역 한 줄 유지).
|
||||
*
|
||||
* 델타는 행을 잠근 뒤 다시 산정한다 — 두 확정 요청이 같은 기적립 합계를 읽으면 같은
|
||||
* 델타를 각자 증액해 목표 적립액을 넘어선다.
|
||||
*
|
||||
* @param Order $order 주문
|
||||
* @param OrderOption $option 주문옵션
|
||||
* @param string $currency 기준 통화
|
||||
* @param float $target 목표 적립액
|
||||
* @return MileageTransaction|null 증액된 적립건 (대상 없으면 null)
|
||||
*/
|
||||
private function applyDeltaToExistingEarnLot(Order $order, OrderOption $option, string $currency, float $target): ?MileageTransaction
|
||||
{
|
||||
return DB::transaction(function () use ($order, $option, $currency, $target) {
|
||||
$existingLot = $this->ledger->findEarnLotForOptionForUpdate($option->id);
|
||||
|
||||
if ($existingLot === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$delta = $target - $this->ledger->sumPurchaseEarnedForOption($option->id);
|
||||
|
||||
if ($delta <= 0) {
|
||||
return $existingLot;
|
||||
}
|
||||
|
||||
$this->ledger->incrementEarnLotAmount($existingLot, $delta);
|
||||
|
||||
$this->cache->recalculateForUser($order->user_id, $currency);
|
||||
$this->cache->recalculatePending($order->user_id, $currency);
|
||||
|
||||
$this->logActivity('mileage.earn', [
|
||||
'loggable' => $existingLot,
|
||||
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_earn',
|
||||
'description_params' => ['amount' => ecommerce_format_price((int) $delta, $currency)],
|
||||
'properties' => ['order_id' => $order->id, 'order_option_id' => $option->id, 'currency' => $currency, 'delta' => (int) $delta],
|
||||
]);
|
||||
|
||||
return $existingLot;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 적립 회수 시 lot 잔여를 우선 차감 → 타 lot FIFO → 부족분 반환 (§2 정책)
|
||||
*
|
||||
|
||||
@@ -32,6 +32,7 @@ return [
|
||||
'coupon_has_issues' => 'Cannot delete coupon because it has :count issued coupons.',
|
||||
'coupon_issue_not_found' => 'Coupon issue record not found.',
|
||||
'coupon_issue_not_cancellable' => 'Only unused issued coupons can be cancelled.',
|
||||
'coupon_already_used' => 'This coupon has already been used. Please review your coupons and try again.',
|
||||
'label_not_found' => 'Label not found.',
|
||||
'product_notice_template_not_found' => 'Product notice template not found.',
|
||||
'product_common_info_not_found' => 'Product common information not found.',
|
||||
|
||||
@@ -32,6 +32,7 @@ return [
|
||||
'coupon_has_issues' => '발급된 쿠폰이 :count건 있어 삭제할 수 없습니다.',
|
||||
'coupon_issue_not_found' => '쿠폰 발급 내역을 찾을 수 없습니다.',
|
||||
'coupon_issue_not_cancellable' => '미사용 상태의 발급 건만 취소할 수 있습니다.',
|
||||
'coupon_already_used' => '이미 사용된 쿠폰입니다. 쿠폰을 다시 확인한 뒤 주문해 주세요.',
|
||||
'label_not_found' => '라벨을 찾을 수 없습니다.',
|
||||
'product_notice_template_not_found' => '상품정보제공고시 템플릿을 찾을 수 없습니다.',
|
||||
'product_common_info_not_found' => '상품 공통정보를 찾을 수 없습니다.',
|
||||
|
||||
+38
-4
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\User;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
@@ -17,6 +19,7 @@ use Modules\Sirsoft\Ecommerce\Models\ProductOption;
|
||||
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
|
||||
use Modules\Sirsoft\Ecommerce\Models\UserAddress;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
@@ -68,6 +71,33 @@ class UserOrderControllerTest extends ModuleTestCase
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 PG 제공자를 설정하고, 그 제공자를 레지스트리에도 등록합니다.
|
||||
*
|
||||
* 설정만 바꾸면 카탈로그가 그 PG 를 "사라진 PG"(`_orphaned_pg`)로 판정해 해당 결제수단이
|
||||
* 주문 불가가 되고 주문 생성이 422 로 막힙니다(#570 고아 카탈로그 차단). 실제 운영에서는
|
||||
* PG 플러그인이 이 훅으로 자신을 등록하므로, 테스트도 같은 경로로 등록해야 합니다.
|
||||
*
|
||||
* @param string $providerId PG 제공자 식별자
|
||||
* @return void
|
||||
*/
|
||||
protected function registerDefaultPgProvider(string $providerId): void
|
||||
{
|
||||
app(EcommerceSettingsService::class)->setSetting('order_settings.default_pg_provider', $providerId);
|
||||
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.payment.registered_pg_providers',
|
||||
fn (array $providers) => array_merge($providers, [[
|
||||
'id' => $providerId,
|
||||
'name' => $providerId,
|
||||
'payment_handler' => 'sirsoft-pay_'.$providerId.'.requestPayment',
|
||||
]])
|
||||
);
|
||||
|
||||
app(PaymentMethodResolver::class)->flushCache();
|
||||
app(EcommerceSettingsService::class)->clearCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 주문 생성 헬퍼
|
||||
*/
|
||||
@@ -357,6 +387,7 @@ class UserOrderControllerTest extends ModuleTestCase
|
||||
* 화면은 `?with_items=1` 로 켠다.
|
||||
*
|
||||
* @scenario surface=my_page_list,option_profile=multiple
|
||||
*
|
||||
* @effects my_page_list_default_is_representative_only
|
||||
*/
|
||||
public function test_기본_목록은_대표_아이템_1건과_개수만_싣는다(): void
|
||||
@@ -386,6 +417,7 @@ class UserOrderControllerTest extends ModuleTestCase
|
||||
* 깨지면 주문마다 상품 한 줄만 남는다.
|
||||
*
|
||||
* @scenario surface=my_page_list,option_profile=multiple
|
||||
*
|
||||
* @effects my_page_list_enumerates_every_item_when_requested
|
||||
*/
|
||||
public function test_with_items_1_이면_아이템을_전부_싣는다(): void
|
||||
@@ -414,6 +446,7 @@ class UserOrderControllerTest extends ModuleTestCase
|
||||
* 페이로드를 줄이려다 쿼리를 늘리는 맞바꿈이 된다.
|
||||
*
|
||||
* @scenario surface=my_page_list,option_profile=multiple
|
||||
*
|
||||
* @effects my_page_list_option_query_count_is_constant
|
||||
*/
|
||||
public function test_아이템_조회_쿼리수가_주문수에_비례하지_않는다(): void
|
||||
@@ -433,7 +466,7 @@ class UserOrderControllerTest extends ModuleTestCase
|
||||
|
||||
$measure = function (): int {
|
||||
$count = 0;
|
||||
\Illuminate\Support\Facades\DB::listen(function ($query) use (&$count) {
|
||||
DB::listen(function ($query) use (&$count) {
|
||||
if (str_contains($query->sql, 'ecommerce_order_options')) {
|
||||
$count++;
|
||||
}
|
||||
@@ -465,6 +498,7 @@ class UserOrderControllerTest extends ModuleTestCase
|
||||
* null 로 정규화하면 오타 파라미터가 "미지정" 으로 통과해 호출자가 잘못을 알 수 없다.
|
||||
*
|
||||
* @scenario surface=my_page_list,option_profile=multiple
|
||||
*
|
||||
* @effects my_page_list_rejects_unparseable_with_items
|
||||
*/
|
||||
public function test_with_items_에_해석불가한_값이_오면_422(): void
|
||||
@@ -948,7 +982,7 @@ class UserOrderControllerTest extends ModuleTestCase
|
||||
public function test_p_g_체크_o_n_order_meta에_플래그_저장(): void
|
||||
{
|
||||
// PG 결제가 실제로 동작하도록 기본 PG 제공자 설정
|
||||
app(EcommerceSettingsService::class)->setSetting('order_settings.default_pg_provider', 'tosspayments');
|
||||
$this->registerDefaultPgProvider('tosspayments');
|
||||
|
||||
$user = $this->createUser();
|
||||
$this->actingAs($user);
|
||||
@@ -975,7 +1009,7 @@ class UserOrderControllerTest extends ModuleTestCase
|
||||
|
||||
public function test_p_g_체크_o_n_user_address_미생성(): void
|
||||
{
|
||||
app(EcommerceSettingsService::class)->setSetting('order_settings.default_pg_provider', 'tosspayments');
|
||||
$this->registerDefaultPgProvider('tosspayments');
|
||||
|
||||
$user = $this->createUser();
|
||||
$this->actingAs($user);
|
||||
@@ -1001,7 +1035,7 @@ class UserOrderControllerTest extends ModuleTestCase
|
||||
|
||||
public function test_p_g_체크_of_f_order_meta_미저장(): void
|
||||
{
|
||||
app(EcommerceSettingsService::class)->setSetting('order_settings.default_pg_provider', 'tosspayments');
|
||||
$this->registerDefaultPgProvider('tosspayments');
|
||||
|
||||
$user = $this->createUser();
|
||||
$this->actingAs($user);
|
||||
|
||||
+128
@@ -12,6 +12,7 @@ use Modules\Sirsoft\Ecommerce\Listeners\CouponRestoreListener;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Coupon;
|
||||
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
@@ -262,4 +263,131 @@ class CouponRestoreListenerTest extends ModuleTestCase
|
||||
$couponIssue->refresh();
|
||||
$this->assertEquals(CouponIssueRecordStatus::AVAILABLE, $couponIssue->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 복원은 조건부 갱신으로 수행되어 이미 복원된 건을 다시 건드리지 않아야 합니다.
|
||||
*
|
||||
* 무락 조회 후 무조건 갱신하면 두 취소 요청이 같은 USED 를 읽어 각자 복원을 수행하고,
|
||||
* 그 사이에 재사용된 쿠폰을 되돌려 놓을 수 있다. 복원 자체는 멱등이 정상이므로
|
||||
* 예외는 던지지 않고 조용히 skip 한다 (KVE-2026-1886 동종).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_restore_is_idempotent_and_does_not_touch_already_restored(): void
|
||||
{
|
||||
$couponIssue = $this->createCouponIssue();
|
||||
$order = $this->createOrderWithCoupons([$couponIssue->id]);
|
||||
|
||||
$this->listener->restoreCoupons($order);
|
||||
$couponIssue->refresh();
|
||||
$this->assertEquals(CouponIssueRecordStatus::AVAILABLE, $couponIssue->status);
|
||||
|
||||
$firstUpdatedAt = $couponIssue->updated_at;
|
||||
|
||||
// 재발화 — 이미 AVAILABLE 이므로 아무 갱신도 일어나면 안 된다
|
||||
$this->listener->restoreCoupons($order);
|
||||
|
||||
$couponIssue->refresh();
|
||||
$this->assertEquals(CouponIssueRecordStatus::AVAILABLE, $couponIssue->status);
|
||||
$this->assertEquals(
|
||||
$firstUpdatedAt->toIso8601String(),
|
||||
$couponIssue->updated_at->toIso8601String(),
|
||||
'이미 복원된 쿠폰은 다시 갱신되지 않아야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 복원 도중 쿠폰이 다시 사용되면(USED 아님) 그 건은 건드리지 않아야 합니다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_restore_skips_coupon_that_is_no_longer_used(): void
|
||||
{
|
||||
$couponIssue = $this->createCouponIssue(['status' => CouponIssueRecordStatus::CANCELLED]);
|
||||
$order = $this->createOrderWithCoupons([$couponIssue->id]);
|
||||
|
||||
$this->listener->restoreCoupons($order);
|
||||
|
||||
$couponIssue->refresh();
|
||||
$this->assertEquals(
|
||||
CouponIssueRecordStatus::CANCELLED,
|
||||
$couponIssue->status,
|
||||
'USED 가 아닌 쿠폰은 복원 대상이 아닙니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스냅샷을 읽은 뒤 다른 요청이 상태를 바꿨다면 복원 쓰기가 거부되어야 합니다.
|
||||
*
|
||||
* 조회와 갱신 사이의 창을 재현한다 — 리스너가 USED 스냅샷을 손에 든 사이 DB 행은
|
||||
* 이미 EXPIRED 로 바뀐 상황. 무조건 갱신이면 만료된 쿠폰이 다시 사용 가능 상태로
|
||||
* 되살아난다 (KVE-2026-1886 동종의 lost update).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_stale_snapshot_does_not_overwrite_concurrently_changed_row(): void
|
||||
{
|
||||
$couponIssue = $this->createCouponIssue();
|
||||
$order = $this->createOrderWithCoupons([$couponIssue->id]);
|
||||
|
||||
// 리스너가 손에 쥔 스냅샷 (USED, 미만료)
|
||||
$staleSnapshot = $couponIssue->replicate(); // @phpstan-ignore-line
|
||||
$staleSnapshot->id = $couponIssue->id;
|
||||
$staleSnapshot->status = CouponIssueRecordStatus::USED;
|
||||
$staleSnapshot->expired_at = now()->addDays(30);
|
||||
|
||||
// 그 사이 다른 요청이 행을 EXPIRED 로 바꿨다
|
||||
$real = app(CouponIssueRepositoryInterface::class);
|
||||
$real->update($couponIssue->id, [
|
||||
'status' => CouponIssueRecordStatus::EXPIRED,
|
||||
'used_at' => null,
|
||||
]);
|
||||
|
||||
// findById 만 낡은 스냅샷을 돌려주고, 쓰기는 실제 저장소로 위임한다
|
||||
$stale = $this->createMock(CouponIssueRepositoryInterface::class);
|
||||
$stale->method('findById')->willReturn($staleSnapshot);
|
||||
$stale->method('update')->willReturnCallback(
|
||||
fn (int $id, array $data) => $real->update($id, $data)
|
||||
);
|
||||
$stale->method('updateIfStatus')->willReturnCallback(
|
||||
fn (int $id, CouponIssueRecordStatus $expected, array $data) => $real->updateIfStatus($id, $expected, $data)
|
||||
);
|
||||
$this->app->instance(CouponIssueRepositoryInterface::class, $stale);
|
||||
|
||||
app(CouponRestoreListener::class)->restoreCoupons($order);
|
||||
|
||||
$couponIssue->refresh();
|
||||
$this->assertEquals(
|
||||
CouponIssueRecordStatus::EXPIRED,
|
||||
$couponIssue->status,
|
||||
'낡은 스냅샷으로 이미 바뀐 행을 덮어써서는 안 됩니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료된 쿠폰의 상태 변경도 조건부 갱신이어야 합니다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_expired_restore_is_conditional(): void
|
||||
{
|
||||
$couponIssue = $this->createCouponIssue(['expired_at' => now()->subDay()]);
|
||||
$order = $this->createOrderWithCoupons([$couponIssue->id]);
|
||||
|
||||
$this->listener->restoreCoupons($order);
|
||||
$couponIssue->refresh();
|
||||
$this->assertEquals(CouponIssueRecordStatus::EXPIRED, $couponIssue->status);
|
||||
|
||||
$firstUpdatedAt = $couponIssue->updated_at;
|
||||
|
||||
$this->listener->restoreCoupons($order);
|
||||
|
||||
$couponIssue->refresh();
|
||||
$this->assertEquals(CouponIssueRecordStatus::EXPIRED, $couponIssue->status);
|
||||
$this->assertEquals(
|
||||
$firstUpdatedAt->toIso8601String(),
|
||||
$couponIssue->updated_at->toIso8601String(),
|
||||
'이미 만료 처리된 쿠폰은 다시 갱신되지 않아야 합니다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user