Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56836a2b66 | ||
|
|
78bb192bb0 | ||
|
|
9ff363bd14 | ||
|
|
7b26c4beec | ||
|
|
28d6a7752c | ||
|
|
0bccd867a7 | ||
|
|
e7fa1b5535 | ||
|
|
40f62e79aa | ||
|
|
e17731d8cc | ||
|
|
6a8a537f10 | ||
|
|
acdff53792 | ||
|
|
1ba67ca7cf | ||
|
|
14eb2e39e4 | ||
|
|
ca4834bbb9 | ||
|
|
df918de2a6 | ||
|
|
494e581973 | ||
|
|
510e498fec | ||
|
|
0db8f9c5fb | ||
|
|
80d16c80bc | ||
|
|
03cbb99196 | ||
|
|
565f6c0117 | ||
|
|
d8ca281464 | ||
|
|
6d2677e7bd | ||
|
|
a7b7c64573 |
+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.9
|
||||
|
||||
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.9
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 |
|
||||
|
||||
|
||||
### 확장 API 레퍼런스 (13개 확장, 자동 스캔)
|
||||
### 확장 API 레퍼런스 (14개 확장, 자동 스캔)
|
||||
|
||||
> 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 3 / 5 |
|
||||
| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
|
||||
| `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
|
||||
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 21 |
|
||||
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
|
||||
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
|
||||
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
|
||||
@@ -584,10 +585,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 +605,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 +1059,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 +1129,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,42 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.9] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- 게시글·상품 설명·페이지처럼 에디터로 작성한 본문에서 첫 번째 내부 이미지를 찾아 목록 썸네일이나 공유 미리보기 이미지로 활용할 수 있는 공용 기능이 추가되었습니다. 게시판·쇼핑몰·페이지 확장이 이 기능을 사용합니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
||||
- 사이트 기본 공유 이미지(og:image)를 환경설정 > SEO 에서 지정할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 사용되어, 사이트 주소를 공유했을 때 미리보기 카드에 이미지가 비어 보이는 일이 없어집니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 알림 템플릿·본인인증 메시지에서 [기본값 복원]을 실행하면 언어팩이 제공한 번역(일본어 등)이 함께 지워지던 문제를 수정했습니다. 이제 복원 기본값에도 활성 언어팩의 번역이 포함되어, 복원 후에도 해당 언어 화면에 번역이 유지됩니다. 또한 이번 버전으로 업그레이드하면 이전 버전에서 이미 지워졌던 언어팩 번역이 활성 언어팩 기준으로 자동 복구됩니다.
|
||||
- 설치·복구 과정의 데이터 동기화에서 코어 알림 정의와 본인인증 메시지에 언어팩 번역이 주입되지 않을 수 있던 문제를 수정했습니다.
|
||||
- 언어팩 업데이트가 실패했을 때 사용 중이던 언어팩이 비활성 상태로 남아, 해당 언어의 화면·메일 문구가 안내 없이 기본 언어로 표시되던 문제를 수정했습니다. 이제 실패 시 이전 상태로 복원됩니다.
|
||||
- 언어팩을 다시 활성화하거나 업데이트할 때 관리자가 직접 수정해 둔 알림·본인인증 템플릿의 해당 언어 문구가 언어팩 기본값으로 덮어써지던 문제를 수정했습니다. 이제 관리자가 수정한 언어만 보존되고, 수정하지 않은 언어는 언어팩 갱신을 그대로 따라갑니다.
|
||||
- 언어팩 활성화 직후 같은 화면 흐름에서 일부 기본 데이터에 새 언어 번역이 반영되지 않던 문제를 수정했습니다.
|
||||
|
||||
## [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.9 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
|
||||
+4
-2
@@ -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.9-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>
|
||||
@@ -401,6 +401,7 @@ cp .env.example .env
|
||||
| **sirsoft-marketing** | 마케팅 도구 |
|
||||
| **sirsoft-ckeditor5** | CKEditor 5 에디터 |
|
||||
| **sirsoft-gdpr** | 개인정보 보호(GDPR) |
|
||||
| **sirsoft-message_bizppurio** | 비즈뿌리오 메시지 발송 (문자·카카오 알림톡) |
|
||||
|
||||
### 템플릿
|
||||
|
||||
@@ -423,6 +424,7 @@ cp .env.example .env
|
||||
| **g7-plugin-sirsoft-daum_postcode-ja** | 다음 우편번호 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-gdpr-ja** | 개인정보 보호(GDPR) 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-marketing-ja** | 마케팅 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-message_bizppurio-ja** | 비즈뿌리오 메시지 발송 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG이니시스 결제 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-pay_nicepayments-ja** | 나이스페이먼츠 결제 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-pay_nhnkcp-ja** | NHN KCP 결제 플러그인 일본어 |
|
||||
@@ -520,9 +522,9 @@ cp .env.example .env
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
|
||||
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
|
||||
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></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.9-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>
|
||||
@@ -413,6 +413,7 @@ The setup wizard creates the application key, configures the database connection
|
||||
| **sirsoft-marketing** | Marketing tools |
|
||||
| **sirsoft-ckeditor5** | CKEditor 5 editor |
|
||||
| **sirsoft-gdpr** | Privacy and GDPR support |
|
||||
| **sirsoft-message_bizppurio** | Bizppurio messaging (SMS/LMS and KakaoTalk alimtalk delivery) |
|
||||
|
||||
### Templates
|
||||
|
||||
@@ -435,6 +436,7 @@ Official language packs you can install alongside the initial setup, so the core
|
||||
| **g7-plugin-sirsoft-daum_postcode-ja** | Daum postcode plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-gdpr-ja** | Privacy/GDPR plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-marketing-ja** | Marketing plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-message_bizppurio-ja** | Bizppurio messaging plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG Inicis payment plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-pay_nicepayments-ja** | NICE Payments plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-pay_nhnkcp-ja** | NHN KCP payment plugin, Japanese |
|
||||
@@ -534,9 +536,9 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
|
||||
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
|
||||
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></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;
|
||||
@@ -96,6 +97,20 @@ class SaveSettingsRequest extends FormRequest
|
||||
]);
|
||||
}
|
||||
|
||||
// 사이트 기본 OG 이미지도 동일하게 첨부 객체 배열 → 정수 ID 배열로 정규화
|
||||
$ogImageDefault = $this->input('seo.og_image_default');
|
||||
|
||||
if (is_array($ogImageDefault)) {
|
||||
$this->merge([
|
||||
'seo' => array_merge($this->input('seo', []), [
|
||||
'og_image_default' => array_map(
|
||||
fn ($item) => is_array($item) ? ($item['id'] ?? $item) : $item,
|
||||
$ogImageDefault
|
||||
),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// GeoIP 토글 필드: 신규 필드라 초기값이 settings에 없을 수 있음.
|
||||
// Toggle 컴포넌트가 undefined → true 전환 시 비표준 타입을 보낼 수 있으므로
|
||||
// boolean으로 명시 캐스팅하여 Laravel boolean 검증 호환 보장.
|
||||
@@ -242,6 +257,8 @@ class SaveSettingsRequest extends FormRequest
|
||||
'seo.bot_detection_enabled' => ['nullable', 'boolean'],
|
||||
'seo.bot_detection_library_enabled' => ['nullable', 'boolean'],
|
||||
'seo.og_default_site_name' => ['nullable', 'string', 'max:200'],
|
||||
'seo.og_image_default' => ['nullable', 'array'],
|
||||
'seo.og_image_default.*' => ['integer', Rule::exists(Attachment::class, 'id')],
|
||||
'seo.og_image_default_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_width_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_width_max', 8000)],
|
||||
'seo.og_image_default_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_height_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_height_max', 8000)],
|
||||
'seo.twitter_default_card' => ['nullable', 'string', Rule::in(['summary', 'summary_large_image', 'app', 'player', ''])],
|
||||
@@ -296,6 +313,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 +803,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'),
|
||||
@@ -900,6 +931,7 @@ class SaveSettingsRequest extends FormRequest
|
||||
'general.maintenance_mode' => __('validation.attributes.maintenance_mode'),
|
||||
'general.asset_url_mode' => __('validation.attributes.asset_url_mode'),
|
||||
'general.site_logo' => __('validation.attributes.site_logo'),
|
||||
'seo.og_image_default' => __('validation.attributes.og_image_default'),
|
||||
// mail
|
||||
'mail.mailer' => __('validation.attributes.mailer'),
|
||||
'mail.host' => __('validation.attributes.smtp_host'),
|
||||
@@ -972,6 +1004,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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -112,11 +112,13 @@ class RunSeedersOnLanguagePackLifecycle
|
||||
*/
|
||||
private function refreshRegistry(): void
|
||||
{
|
||||
// Singleton 캐시된 registry/injector 가 stale 활성 팩 목록을 들고 있을 수 있으므로 forget
|
||||
$this->app->forgetInstance(LanguagePackRegistry::class);
|
||||
$this->app->forgetInstance(LanguagePackSeedInjector::class);
|
||||
|
||||
// boot 시점에 등록된 seed 필터 클로저들은 injector→registry "인스턴스"를 캡처하고 있다.
|
||||
// forgetInstance 로 바인딩을 교체하면 캡처된 구 인스턴스의 stale 캐시(활성 팩 목록)가
|
||||
// 그대로 남아, 같은 프로세스에서 활성화된 신규 팩이 코어 시더의 번역 필터에 보이지
|
||||
// 않는다 (오류 없이 해당 로케일만 미주입). 싱글톤을 유지한 채 내부 캐시만 무효화해
|
||||
// 캡처된 참조에도 전파한다 (#597 에서 실측·교정).
|
||||
$registry = $this->app->make(LanguagePackRegistry::class);
|
||||
$registry->invalidate();
|
||||
config([
|
||||
'app.supported_locales' => $registry->getActiveCoreLocales(),
|
||||
'app.locale_names' => $registry->getLocaleNames(),
|
||||
|
||||
@@ -9,17 +9,19 @@ use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* `core.language_packs.after_activate` / `after_deactivate` 액션 훅을 받아 DB JSON 다국어
|
||||
* `core.language_packs.activated` / `deactivated` 액션 훅을 받아 DB JSON 다국어
|
||||
* 컬럼을 동기화하는 thin Listener (G7 표준 HookManager::doAction → addAction 메커니즘).
|
||||
*
|
||||
* 영속/도메인 책임은 모두 LanguagePackTranslationRepository 에 위임. 본 클래스는 seed 파일
|
||||
* 로딩 + Repository 호출 + 감사 로그 라우팅만 수행합니다.
|
||||
*
|
||||
* 정책 (계획서 §6.6, §9.4):
|
||||
* 정책 (계획서 §6.6, §9.4) — 보존 판정은 운영자 수정의 실제 기록 형식인 dot-path
|
||||
* 항목(`"{컬럼}.{로케일}"`, HasUserOverrides updating 이벤트가 기록)을 기준으로 한다:
|
||||
* - Case A: 기존 row 의 다국어 JSON 에 해당 locale 키 부재 → 추가
|
||||
* - Case B: locale 키 존재 + user_overrides 에 컬럼 등록 → 건너뜀 (사용자 수정 보존)
|
||||
* - Case C: locale 키 존재 + user_overrides 미등록 → 시드 값으로 덮어쓰기
|
||||
* - 비활성화/제거 시: user_overrides 등록 locale 은 JSON 에서 제거하지 않음
|
||||
* - Case B: locale 키 존재 + user_overrides 에 `컬럼.로케일` 등록 → 건너뜀 (사용자 수정 보존)
|
||||
* - Case C: locale 키 존재 + 해당 dot-path 미등록 → 시드 값으로 덮어쓰기
|
||||
* (컬럼 전체 항목 `"subject"` 등은 시더 재실행 보호 선언이라 팩 병합을 막지 않는다)
|
||||
* - 비활성화/제거 시: user_overrides 에 `컬럼.로케일` 등록된 locale 은 JSON 에서 제거하지 않음
|
||||
*/
|
||||
// audit:allow listener-must-implement-hooklistenerinterface reason: LanguagePackServiceProvider 가 HookManager::addAction 으로 직접 등록하는 명시 등록 패턴 (auto-discovery 대상 아님)
|
||||
class SyncDatabaseTranslations
|
||||
|
||||
@@ -30,6 +30,17 @@ class IdentityMessageTemplate extends Model
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록.
|
||||
* (NotificationTemplate 과 동형 — 미선언의 결과는 그쪽 주석 참조, #597 에서 교정)
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $translatableTrackableFields = [
|
||||
'subject',
|
||||
'body',
|
||||
];
|
||||
|
||||
/**
|
||||
* 활동 로그 추적 필드.
|
||||
*
|
||||
|
||||
@@ -27,6 +27,20 @@ class NotificationTemplate extends Model
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록.
|
||||
*
|
||||
* 미선언 시 수정이 컬럼 전체 항목(`"subject"`)으로 기록되어 시더 재실행이 컬럼을
|
||||
* 통째로 얼리고(다른 로케일 갱신·신규 팩 로케일 추가 불가), 언어팩 병합의
|
||||
* 사용자 수정 보존(Case B)도 로케일 단위로 판정할 수 없다 (#597 에서 교정).
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $translatableTrackableFields = [
|
||||
'subject',
|
||||
'body',
|
||||
];
|
||||
|
||||
/**
|
||||
* 모델 이벤트 등록 — 템플릿 변경 시 알림 정의 캐시 자동 삭제.
|
||||
*/
|
||||
@@ -110,13 +124,6 @@ class NotificationTemplate extends Model
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 채널의 템플릿 조회.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param string $channel
|
||||
* @return Builder
|
||||
*/
|
||||
/**
|
||||
* 수신자 규칙이 설정되어 있는지 확인합니다.
|
||||
*
|
||||
@@ -127,6 +134,13 @@ class NotificationTemplate extends Model
|
||||
return ! empty($this->recipients);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 채널의 템플릿 조회.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param string $channel
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeByChannel(Builder $query, string $channel): Builder
|
||||
{
|
||||
return $query->where('channel', $channel);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,29 +41,37 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
*/
|
||||
public function applySeedFromPack(LanguagePack $pack, array $seedBundle): array
|
||||
{
|
||||
$audit = [];
|
||||
$locale = $pack->locale;
|
||||
// 팩 주입은 시딩 컨텍스트다 — 바인딩 없이 저장하면 UserOverridesAwareBuilder 가
|
||||
// 주입 자체를 "사용자 수정"으로 오인해 user_overrides 를 오염시키고, 그 선언이
|
||||
// 이후 시더 재실행의 컬럼 갱신을 영구히 얼린다 (#597 에서 실측·교정).
|
||||
app()->instance('user_overrides.seeding', true);
|
||||
try {
|
||||
$audit = [];
|
||||
$locale = $pack->locale;
|
||||
|
||||
if (! empty($seedBundle['permissions'])) {
|
||||
$this->applyByIdentifier(Permission::class, $pack, $seedBundle['permissions'], $locale, ['name', 'description'], 'identifier', $audit);
|
||||
}
|
||||
if (! empty($seedBundle['roles'])) {
|
||||
$this->applyByIdentifier(Role::class, $pack, $seedBundle['roles'], $locale, ['name', 'description'], 'identifier', $audit);
|
||||
}
|
||||
if (! empty($seedBundle['menus']) && $pack->scope !== LanguagePackScope::Plugin->value) {
|
||||
$this->applyByIdentifier(Menu::class, $pack, $seedBundle['menus'], $locale, ['name'], 'slug', $audit);
|
||||
}
|
||||
if (! empty($seedBundle['notifications'])) {
|
||||
$this->applyNotifications($pack, $seedBundle['notifications'], $locale, $audit);
|
||||
}
|
||||
if (! empty($seedBundle['identity_messages'])) {
|
||||
$this->applyIdentityMessages($pack, $seedBundle['identity_messages'], $locale, $audit);
|
||||
}
|
||||
if (! empty($seedBundle['manifest'])) {
|
||||
$this->applyManifest($pack, $seedBundle['manifest'], $locale, $audit);
|
||||
}
|
||||
if (! empty($seedBundle['permissions'])) {
|
||||
$this->applyByIdentifier(Permission::class, $pack, $seedBundle['permissions'], $locale, ['name', 'description'], 'identifier', $audit);
|
||||
}
|
||||
if (! empty($seedBundle['roles'])) {
|
||||
$this->applyByIdentifier(Role::class, $pack, $seedBundle['roles'], $locale, ['name', 'description'], 'identifier', $audit);
|
||||
}
|
||||
if (! empty($seedBundle['menus']) && $pack->scope !== LanguagePackScope::Plugin->value) {
|
||||
$this->applyByIdentifier(Menu::class, $pack, $seedBundle['menus'], $locale, ['name'], 'slug', $audit);
|
||||
}
|
||||
if (! empty($seedBundle['notifications'])) {
|
||||
$this->applyNotifications($pack, $seedBundle['notifications'], $locale, $audit);
|
||||
}
|
||||
if (! empty($seedBundle['identity_messages'])) {
|
||||
$this->applyIdentityMessages($pack, $seedBundle['identity_messages'], $locale, $audit);
|
||||
}
|
||||
if (! empty($seedBundle['manifest'])) {
|
||||
$this->applyManifest($pack, $seedBundle['manifest'], $locale, $audit);
|
||||
}
|
||||
|
||||
return $audit;
|
||||
return $audit;
|
||||
} finally {
|
||||
app()->forgetInstance('user_overrides.seeding');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +81,23 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
* @return array<int, array<string, mixed>> 감사 로그 항목
|
||||
*/
|
||||
public function stripLocaleFromPack(LanguagePack $pack): array
|
||||
{
|
||||
// applySeedFromPack 과 동일 — 제거도 시딩 컨텍스트 (user_overrides 오염 차단)
|
||||
app()->instance('user_overrides.seeding', true);
|
||||
try {
|
||||
return $this->stripLocaleFromPackBody($pack);
|
||||
} finally {
|
||||
app()->forgetInstance('user_overrides.seeding');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* stripLocaleFromPack 의 본문 (시딩 바인딩 하에서 실행).
|
||||
*
|
||||
* @param LanguagePack $pack 비활성화된 언어팩
|
||||
* @return array<int, array<string, mixed>> 감사 로그 항목
|
||||
*/
|
||||
private function stripLocaleFromPackBody(LanguagePack $pack): array
|
||||
{
|
||||
$audit = [];
|
||||
$locale = $pack->locale;
|
||||
@@ -402,7 +427,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
}
|
||||
|
||||
$hasLocaleKey = array_key_exists($locale, $current);
|
||||
$isOverridden = isset($overrides[$column]);
|
||||
$isOverridden = $this->isColumnLocaleOverridden($overrides, $column, $locale);
|
||||
|
||||
if ($hasLocaleKey && $isOverridden) {
|
||||
$audit[] = [
|
||||
@@ -413,6 +438,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
'locale' => $locale,
|
||||
'reason' => 'user_overrides',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -443,7 +469,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($overrides[$column])) {
|
||||
if ($this->isColumnLocaleOverridden($overrides, $column, $locale)) {
|
||||
$audit[] = [
|
||||
'action' => 'preserved',
|
||||
'table' => $row->getTable(),
|
||||
@@ -452,6 +478,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
'locale' => $locale,
|
||||
'reason' => 'user_overrides',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -464,4 +491,27 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
||||
$row->saveQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 컬럼·로케일이 운영자 수정(user_overrides)으로 보존 대상인지 판정합니다.
|
||||
*
|
||||
* 운영자 수정의 실제 기록 형식은 dot-path 리스트(`"{column}.{locale}"` —
|
||||
* HasUserOverrides 의 updating 이벤트가 기록, beta.4 가 레거시 항목을 dot-path 로
|
||||
* 전환)이므로 그 형식만 본다. 종전의 `isset($overrides[$column])` 판정은 어떤
|
||||
* 생산자도 쓰지 않는 연관 형식을 전제해 항상 false 였고, 그 결과 활성화/비활성화가
|
||||
* 운영자 수정 번역을 오류 없이 덮어쓰거나 제거했다 (#597 에서 발견·교정).
|
||||
*
|
||||
* 컬럼 전체 항목(`"subject"` 등)은 의도적으로 보존 사유로 보지 않는다 — 그 형식은
|
||||
* 시더 재실행(syncFromUpgrade)의 컬럼 보호 선언이며, 팩 병합까지 차단하면 팩
|
||||
* 업데이트가 자기 로케일 번역을 갱신하지 못하게 된다 (종전에도 차단된 적 없음).
|
||||
*
|
||||
* @param array<int, string> $overrides user_overrides 리스트
|
||||
* @param string $column 다국어 JSON 컬럼명
|
||||
* @param string $locale 대상 로케일
|
||||
* @return bool 보존 대상 여부
|
||||
*/
|
||||
protected function isColumnLocaleOverridden(array $overrides, string $column, string $locale): bool
|
||||
{
|
||||
return in_array($column.'.'.$locale, $overrides, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Seo;
|
||||
|
||||
use App\Seo\Concerns\LocalizesSeoValues;
|
||||
use App\Seo\Concerns\SubstitutesSeoVariables;
|
||||
use App\Services\SettingsService;
|
||||
|
||||
class SeoMetaResolver
|
||||
{
|
||||
@@ -515,6 +516,19 @@ class SeoMetaResolver
|
||||
$title = $this->stripHtml($this->safeEval($og['title'] ?? '', $context)) ?: $fallbackTitle;
|
||||
$description = $this->stripHtml($this->safeEval($og['description'] ?? '', $context)) ?: $fallbackDescription;
|
||||
$image = $this->absoluteUrl($this->safeEval($og['image'] ?? '', $context));
|
||||
|
||||
// 사이트 기본 OG 이미지 폴백 (공개 이슈 #22) — 도메인 콘텐츠 폴백(본문 캐시)은
|
||||
// 각 모듈 레이아웃/훅 바인딩이 공급하므로 코어는 사이트 기본값 한 단만 담당한다.
|
||||
// 체인: 레이아웃 선언(도메인 캐시 포함) → 사이트 기본 이미지 → 빈 값(태그 미출력).
|
||||
// secure_url 파생은 반드시 이 폴백 적용 후의 $image 기준이어야 한다.
|
||||
if ($image === '') {
|
||||
$defaultImage = app(SettingsService::class)->getOgDefaultImageUrl();
|
||||
|
||||
if (is_string($defaultImage) && $defaultImage !== '') {
|
||||
$image = $this->absoluteUrl($defaultImage);
|
||||
}
|
||||
}
|
||||
|
||||
$secureUrl = isset($og['image_secure_url'])
|
||||
? $this->absoluteUrl($this->safeEval($og['image_secure_url'], $context))
|
||||
: ($image !== '' && str_starts_with($image, 'https://') ? $image : '');
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\IdentityMessageTemplateRepositoryInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\IdentityMessageDefinition;
|
||||
use App\Models\IdentityMessageTemplate;
|
||||
use App\Services\LanguagePack\LanguagePackSeedInjector;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
@@ -37,6 +38,7 @@ class IdentityMessageTemplateService
|
||||
private readonly IdentityMessageTemplateRepositoryInterface $repository,
|
||||
private readonly IdentityMessageDefinitionService $definitionService,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LanguagePackSeedInjector $seedInjector,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -271,11 +273,19 @@ class IdentityMessageTemplateService
|
||||
{
|
||||
$coreDefinitions = $this->loadCoreMessageDefinitions();
|
||||
|
||||
return HookManager::applyFilters(
|
||||
$definitions = HookManager::applyFilters(
|
||||
'core.identity.filter_default_message_definitions',
|
||||
$coreDefinitions,
|
||||
[]
|
||||
);
|
||||
|
||||
// 활성 언어팩 seed 로케일 병합 — 시딩(seed.identity_messages.translations)과 같은 SSoT.
|
||||
// 이 병합이 없으면 [기본값 복원]이 팩이 주입해 둔 로케일(ja 등)을 config 의
|
||||
// ko/en 만으로 대체해 영구 소실시킨다 (notification 쪽 실사례의 동형 — #597 보완 실측).
|
||||
// injectIdentityMessages 는 string 키('mail.purpose.signup' 등)로 seed 를 매칭하므로
|
||||
// loadCoreMessageDefinitions 가 config 키를 보존해 넘긴다. 확장 리스너가 정수 키로
|
||||
// append 한 항목은 seed 에 매칭되지 않아 그대로 통과한다.
|
||||
return $this->seedInjector->injectIdentityMessages($definitions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,13 +302,14 @@ class IdentityMessageTemplateService
|
||||
$common = $this->commonVariables();
|
||||
$result = [];
|
||||
|
||||
foreach ($messages as $data) {
|
||||
foreach ($messages as $key => $data) {
|
||||
if (($data['variables'] ?? null) === '__common__') {
|
||||
$data['variables'] = $common;
|
||||
}
|
||||
$data['extension_type'] = 'core';
|
||||
$data['extension_identifier'] = 'core';
|
||||
$result[] = $data;
|
||||
// config 키('mail.provider_default' 등)를 보존 — 언어팩 seed 매칭 키와 동일 (collectDefaultDefinitions 참조)
|
||||
$result[$key] = $data;
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
@@ -112,7 +112,7 @@ class LanguagePackSeedInjector
|
||||
}
|
||||
|
||||
foreach ($definitions as $defIdx => $def) {
|
||||
$type = $def['type'] ?? null;
|
||||
$type = $this->resolveDefinitionType($defIdx, $def);
|
||||
if (! $type || ! isset($seed[$type])) {
|
||||
continue;
|
||||
}
|
||||
@@ -166,7 +166,7 @@ class LanguagePackSeedInjector
|
||||
continue;
|
||||
}
|
||||
foreach ($definitions as $defIdx => $def) {
|
||||
$type = $def['type'] ?? null;
|
||||
$type = $this->resolveDefinitionType($defIdx, $def);
|
||||
if (! $type || ! isset($seed[$type])) {
|
||||
continue;
|
||||
}
|
||||
@@ -722,4 +722,30 @@ class LanguagePackSeedInjector
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정의 배열 항목에서 알림 type 을 해석합니다.
|
||||
*
|
||||
* 코어 시더(`loadConfigSeed('core.notification_definitions', ...)`)는 config 원형
|
||||
* (연관 배열 — 키가 곧 type, 항목에 type 필드 없음)을 그대로 필터에 넘기고,
|
||||
* 확장 시더(ModuleManager/PluginManager)와 [기본값 복원] 경로는 각 항목에 type
|
||||
* 필드를 포함한 리스트를 넘긴다. 양쪽 형태를 모두 수용한다 —
|
||||
* 문자열 키가 있으면 그것이 type 이고, 아니면 항목의 type 필드를 본다.
|
||||
* (형태 불일치 시 예외 없이 전 항목이 스킵되어 ja 가 조용히 주입되지 않는
|
||||
* 회귀가 있었다 — 2026-08-24 #597 보완 실측. NotificationSeedInjectionTest 가 고정.)
|
||||
*
|
||||
* @param int|string $defIdx 정의 배열의 키
|
||||
* @param mixed $def 정의 항목
|
||||
* @return string|null 해석된 type (해석 불가 시 null)
|
||||
*/
|
||||
private function resolveDefinitionType(int|string $defIdx, mixed $def): ?string
|
||||
{
|
||||
if (is_string($defIdx) && $defIdx !== '') {
|
||||
return $defIdx;
|
||||
}
|
||||
|
||||
$type = is_array($def) ? ($def['type'] ?? null) : null;
|
||||
|
||||
return is_string($type) && $type !== '' ? $type : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -1809,10 +1814,21 @@ class LanguagePackService
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 복원 (DB 행이 finalizeInstall 에서 status 를 다시 쓰지 않은 경우만)
|
||||
// 상태 복원 — 파일은 위에서 백업으로 원상 복원되므로 상태도 이전 상태로 되돌린다.
|
||||
// Updating: 설치 트랜잭션 이전에 실패한 경우.
|
||||
// Installed: 설치 트랜잭션이 installed 를 기록한 뒤(활성화 단계 이전/도중) 실패한 경우 —
|
||||
// 여기서 복원하지 않으면 active 였던 팩이 installed 로 방치되어 해당 로케일의
|
||||
// 백엔드 번역이 오류·로그 없이 통째로 폴백된다 (#597 보완 실측에서 유사 상태 실측).
|
||||
// 활성화가 성공적으로 끝난 뒤(active) 실패한 경우는 덮어쓰지 않는다.
|
||||
try {
|
||||
$current = $this->repository->findByIdentifier($identifier);
|
||||
if ($current && $current->status === LanguagePackStatus::Updating->value) {
|
||||
$restorable = [
|
||||
LanguagePackStatus::Updating->value,
|
||||
LanguagePackStatus::Installed->value,
|
||||
];
|
||||
if ($current
|
||||
&& $current->status !== $previousStatus
|
||||
&& in_array($current->status, $restorable, true)) {
|
||||
$this->repository->update($current, ['status' => $previousStatus]);
|
||||
}
|
||||
} catch (Throwable $statusError) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
|
||||
use App\Contracts\Repositories\NotificationTemplateRepositoryInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Services\LanguagePack\LanguagePackSeedInjector;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class NotificationTemplateService
|
||||
@@ -33,6 +34,7 @@ class NotificationTemplateService
|
||||
private readonly NotificationTemplateRepositoryInterface $templateRepository,
|
||||
private readonly NotificationDefinitionRepositoryInterface $definitionRepository,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LanguagePackSeedInjector $seedInjector,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -189,6 +191,11 @@ class NotificationTemplateService
|
||||
['type' => $type, 'channel' => $channel]
|
||||
);
|
||||
|
||||
// 활성 언어팩 seed 로케일 병합 — 시딩(seed.notifications.translations)과 같은 SSoT.
|
||||
// 이 병합이 없으면 [기본값 복원]이 팩이 주입해 둔 로케일(ja 등)을 config 의
|
||||
// ko/en 만으로 대체해 영구 소실시킨다 (2026-08-23 실사례 — #597 보완 실측).
|
||||
$definitions = $this->mergeLanguagePackLocalesIntoDefaults($definitions);
|
||||
|
||||
foreach ($definitions as $def) {
|
||||
if ($def['type'] === $type) {
|
||||
foreach ($def['templates'] as $tpl) {
|
||||
@@ -207,6 +214,36 @@ class NotificationTemplateService
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 정의 배열에 활성 언어팩 seed 로케일을 병합합니다.
|
||||
*
|
||||
* 코어 소유 정의는 활성 코어 팩(injectNotifications), 확장 소유 정의는
|
||||
* 해당 확장의 활성 모듈/플러그인 팩(injectExtensionNotifications)이 공급한다.
|
||||
* definition type 은 전역 고유(UNIQUE)이므로 확장별 호출이 다른 확장의
|
||||
* 정의에 오병합될 여지는 없다.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $definitions type 필드를 포함한 기본 정의 배열
|
||||
* @return array<int, array<string, mixed>> 활성 팩 로케일이 병합된 정의 배열
|
||||
*/
|
||||
private function mergeLanguagePackLocalesIntoDefaults(array $definitions): array
|
||||
{
|
||||
$definitions = $this->seedInjector->injectNotifications($definitions);
|
||||
|
||||
$extensionIdentifiers = [];
|
||||
foreach ($definitions as $def) {
|
||||
$identifier = is_array($def) ? ($def['extension_identifier'] ?? null) : null;
|
||||
if (is_string($identifier) && $identifier !== '' && $identifier !== 'core') {
|
||||
$extensionIdentifiers[$identifier] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_keys($extensionIdentifiers) as $identifier) {
|
||||
$definitions = $this->seedInjector->injectExtensionNotifications($definitions, $identifier);
|
||||
}
|
||||
|
||||
return $definitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리보기용 렌더링.
|
||||
*
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -147,6 +147,9 @@ class SettingsService
|
||||
// 첨부파일 정보 추가 (general 카테고리에)
|
||||
$settings['general']['site_logo'] = $this->getSiteLogoAttachment();
|
||||
|
||||
// 사이트 기본 OG 이미지 첨부 정보 (seo 카테고리에 — site_logo 와 동형)
|
||||
$settings['seo']['og_image_default'] = $this->getAttachmentListSetting('seo.og_image_default');
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
@@ -437,16 +440,30 @@ class SettingsService
|
||||
*/
|
||||
private function getSiteLogoAttachment(): array
|
||||
{
|
||||
// JSON 설정에서 site_logo ID 배열 조회
|
||||
$siteLogoIds = $this->configRepository->get('general.site_logo', []);
|
||||
return $this->getAttachmentListSetting('general.site_logo');
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 ID 배열 설정을 첨부 목록(리소스 배열)으로 해석합니다.
|
||||
*
|
||||
* site_logo / og_image_default 처럼 "첨부 ID 배열 + 화면은 첨부 객체" 패턴의
|
||||
* 설정이 공유하는 로드 경로입니다.
|
||||
*
|
||||
* @param string $configKey 설정 키 (예: 'general.site_logo')
|
||||
* @return array 첨부 목록 (없으면 빈 배열)
|
||||
*/
|
||||
private function getAttachmentListSetting(string $configKey): array
|
||||
{
|
||||
// JSON 설정에서 첨부 ID 배열 조회
|
||||
$ids = $this->configRepository->get($configKey, []);
|
||||
|
||||
// 빈 배열이거나 배열이 아닌 경우
|
||||
if (empty($siteLogoIds) || ! is_array($siteLogoIds)) {
|
||||
if (empty($ids) || ! is_array($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// ID 배열로 첨부파일 조회 (DB order 기준 정렬)
|
||||
$attachments = $this->attachmentRepository->findByIds($siteLogoIds);
|
||||
$attachments = $this->attachmentRepository->findByIds($ids);
|
||||
|
||||
if ($attachments->isEmpty()) {
|
||||
return [];
|
||||
@@ -455,6 +472,21 @@ class SettingsService
|
||||
return $attachments->map(fn ($attachment) => (new AttachmentResource($attachment))->toListArray())->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 기본 OG 이미지 URL 을 반환합니다.
|
||||
*
|
||||
* SeoMetaResolver 가 레이아웃 og.image 선언(도메인 캐시 포함)이 빈 값일 때
|
||||
* 마지막 폴백으로 사용합니다 (공개 이슈 #22 — 사이트 기본 공유 이미지).
|
||||
*
|
||||
* @return string|null 첫 번째 이미지 첨부의 다운로드 URL (미설정 시 null)
|
||||
*/
|
||||
public function getOgDefaultImageUrl(): ?string
|
||||
{
|
||||
$ids = $this->configRepository->get('seo.og_image_default', []);
|
||||
|
||||
return $this->getFirstImageUrl(is_array($ids) ? $ids : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 ID 배열에서 첫 번째 이미지의 URL을 반환합니다.
|
||||
*
|
||||
@@ -530,6 +562,22 @@ class SettingsService
|
||||
$tabSettings['site_logo'] = $this->resolveSiteLogoIds($tabSettings['site_logo']);
|
||||
}
|
||||
|
||||
// 사이트 기본 OG 이미지도 site_logo 와 동형으로 처리 (공개 이슈 #22)
|
||||
$removedOgImageIds = [];
|
||||
|
||||
if ($tab === 'seo' && is_array($tabSettings['og_image_default'] ?? null)) {
|
||||
$removedOgImageIds = $this->resolveRemovedAttachmentIds(
|
||||
'seo',
|
||||
'og_image_default',
|
||||
$tabSettings['og_image_default']
|
||||
);
|
||||
|
||||
$tabSettings['og_image_default'] = $this->resolveSubmittedAttachmentIds(
|
||||
'og_image_default',
|
||||
$tabSettings['og_image_default']
|
||||
);
|
||||
}
|
||||
|
||||
// 기존 설정과 병합 (탭별로 일부 필드만 전송되어도 기존 설정 유지)
|
||||
$existingSettings = $this->configRepository->getCategory($tab);
|
||||
$mergedSettings = array_merge($existingSettings, $tabSettings);
|
||||
@@ -548,6 +596,7 @@ class SettingsService
|
||||
|
||||
// 저장이 확정된 뒤에야 파일을 파기한다 (위 판정 시점 주석 참조).
|
||||
$this->purgeSiteLogoAttachments($removedSiteLogoIds);
|
||||
$this->purgeRemovedAttachments($removedOgImageIds, '기본 OG 이미지');
|
||||
|
||||
// SEO 프리렌더 캐시에는 생성 시점의 자산 URL 이 그대로 구워져 있다.
|
||||
// 모드가 바뀌면 그 URL 들이 전부 어긋나는데, 봇은 JavaScript 를 실행하지
|
||||
@@ -709,6 +758,18 @@ class SettingsService
|
||||
* @return array<int, int> 저장할 첨부파일 ID 배열
|
||||
*/
|
||||
private function resolveSiteLogoIds(array $submitted): array
|
||||
{
|
||||
return $this->resolveSubmittedAttachmentIds('site_logo', $submitted);
|
||||
}
|
||||
|
||||
/**
|
||||
* 제출된 첨부 목록에서 해당 컬렉션에 실재하는 첨부 ID 만 남깁니다.
|
||||
*
|
||||
* @param string $collection 첨부 컬렉션명 (예: 'site_logo', 'og_image_default')
|
||||
* @param array $submitted 제출값 (첨부 객체 배열 또는 ID 배열)
|
||||
* @return array<int, int> 저장할 첨부 ID 목록
|
||||
*/
|
||||
private function resolveSubmittedAttachmentIds(string $collection, array $submitted): array
|
||||
{
|
||||
$submittedIds = $this->extractAttachmentIds($submitted);
|
||||
|
||||
@@ -716,13 +777,72 @@ class SettingsService
|
||||
return [];
|
||||
}
|
||||
|
||||
$existingIds = $this->attachmentRepository->getByCollection('site_logo')
|
||||
$existingIds = $this->attachmentRepository->getByCollection($collection)
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
return array_values(array_intersect($submittedIds, $existingIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 요청에서 빠진(= 운영자가 화면에서 제거한) 첨부 설정 ID 를 가려냅니다.
|
||||
*
|
||||
* 판정 기준·시점 규율은 resolveRemovedSiteLogoIds 와 동일합니다 — 직전 저장값에
|
||||
* 있었는데 이번 제출에서 빠진 id 만 파기 대상이며, 저장으로 값이 덮이기 전에
|
||||
* 판정해야 합니다.
|
||||
*
|
||||
* @param string $category 설정 카테고리 (예: 'seo')
|
||||
* @param string $key 설정 키 (예: 'og_image_default')
|
||||
* @param mixed $submitted 제출값 (미제출이면 null)
|
||||
* @return array<int, int> 파기 대상 첨부 ID 목록
|
||||
*/
|
||||
private function resolveRemovedAttachmentIds(string $category, string $key, mixed $submitted): array
|
||||
{
|
||||
if (! is_array($submitted)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$previousIds = $this->extractAttachmentIds(
|
||||
$this->configRepository->getCategory($category)[$key] ?? []
|
||||
);
|
||||
|
||||
if ($previousIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keptIds = $this->extractAttachmentIds($submitted);
|
||||
|
||||
return array_values(array_diff($previousIds, $keptIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 제거가 확정된 설정 첨부를 파일까지 파기합니다 (저장 성공 후에만 호출).
|
||||
*
|
||||
* @param array<int, int> $removedIds 파기 대상 첨부 ID 목록
|
||||
* @param string $label 로그 표기용 설정 이름
|
||||
*/
|
||||
private function purgeRemovedAttachments(array $removedIds, string $label): void
|
||||
{
|
||||
if ($removedIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($removedIds as $removedId) {
|
||||
// 설정 저장은 이미 확정된 뒤다 — 파기 실패가 저장 실패(422)로 위장되면
|
||||
// 운영자는 성공한 저장을 실패로 오인한다. 실패 파일은 로그로만 남긴다.
|
||||
try {
|
||||
$this->attachmentService->delete($removedId);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning($label.' 첨부 파기 실패 — 저장은 확정됨', [
|
||||
'attachment_id' => $removedId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info($label.' 첨부 제거', ['attachment_ids' => $removedIds]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 요청에서 빠진(= 운영자가 화면에서 제거한) 사이트 로고 첨부 ID 를 가려냅니다.
|
||||
*
|
||||
|
||||
@@ -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,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 본문 HTML 에서 이미지 URL 후보를 추출하는 공용 유틸리티.
|
||||
*
|
||||
* 게시글(board)·상품 설명(ecommerce)·페이지(page)처럼 에디터로 작성된 본문에서
|
||||
* "첫 번째 내부 이미지" 를 뽑아 목록 썸네일·og:image 폴백 캐시로 쓰기 위한 계약이다.
|
||||
* 내부/외부 판정은 origin 정규화 SSoT 인
|
||||
* {@see TrustedScriptHosts::normalizeForOriginCheck()} 를 그대로 재사용한다 —
|
||||
* 문자열상 path 로 보이지만 브라우저가 외부 origin 으로 읽는 형태
|
||||
* (`/\/evil.com/x.jpg` 등)를 내부로 오판하지 않기 위함이다.
|
||||
*
|
||||
* 다국어 JSON 콘텐츠(로케일별 HTML 배열)는 호출측이 로케일을 순회하며 이 클래스에는
|
||||
* 단일 HTML 문자열만 전달한다.
|
||||
*/
|
||||
final class HtmlImageExtractor
|
||||
{
|
||||
/**
|
||||
* 반환 URL 최대 길이 (캐시 컬럼 `string(1000)` 상한 정합).
|
||||
*/
|
||||
private const MAX_URL_LENGTH = 1000;
|
||||
|
||||
/**
|
||||
* 파서가 요구하는 문서 골격을 씌울 래퍼 요소 id.
|
||||
*/
|
||||
private const ROOT_ID = 'g7-html-image-extractor-root';
|
||||
|
||||
/**
|
||||
* HTML 에서 모든 `<img>` 의 `src` 속성을 문서 순서로 수집합니다.
|
||||
*
|
||||
* ext-dom 부재·파싱 실패 시에는 안전측(빈 배열)으로 폴백합니다.
|
||||
*
|
||||
* @param string $html 본문 HTML
|
||||
* @return array<int, string> src 속성값 목록 (빈 값 제외, 문서 순서)
|
||||
*/
|
||||
public static function candidates(string $html): array
|
||||
{
|
||||
if (trim($html) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! class_exists(\DOMDocument::class)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$document = new \DOMDocument('1.0', 'UTF-8');
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
|
||||
try {
|
||||
$loaded = $document->loadHTML(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><div id="'
|
||||
.self::ROOT_ID.'">'.$html.'</div></body></html>',
|
||||
LIBXML_NOERROR | LIBXML_NOWARNING
|
||||
);
|
||||
|
||||
if (! $loaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sources = [];
|
||||
|
||||
foreach ($document->getElementsByTagName('img') as $img) {
|
||||
if (! $img instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$src = trim($img->getAttribute('src'));
|
||||
|
||||
if ($src !== '') {
|
||||
$sources[] = $src;
|
||||
}
|
||||
}
|
||||
|
||||
return $sources;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('본문 이미지 추출 실패 — 후보 없음으로 폴백', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
} finally {
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 HTML 에서 첫 번째 **내부** 이미지 URL 을 반환합니다.
|
||||
|
||||
* 내부 판정 규칙 (후보를 문서 순서로 순회하며 첫 통과 값):
|
||||
* 1. {@see TrustedScriptHosts::normalizeForOriginCheck()} 로 정규화
|
||||
* 2. 정규화 결과 `//` 시작(protocol-relative) → 외부로 제외, `/` 단일 시작 → 내부
|
||||
* 3. http(s) 절대 URL 은 host 가 `config('app.url')` host 와 동일(대소문자 무시)할
|
||||
* 때만 통과하고 path 부터의 상대 형태로 변환해 반환 (응답 절대화는 소비측 규약)
|
||||
* 4. `$extraAllowedPrefixes` 중 하나로 시작(정규화 후 비교)하면 통과 — CDN 스토리지
|
||||
* 확장용이며 이 경우 원형 그대로 반환
|
||||
* 5. `data:` / `javascript:` 등 비 http(s)·비 path 스킴은 전부 제외
|
||||
*
|
||||
* @param string $html 본문 HTML
|
||||
* @param array<int, string> $extraAllowedPrefixes 추가 허용 URL prefix 목록
|
||||
* @return string|null 첫 내부 이미지 URL (후보 없으면 null)
|
||||
*/
|
||||
public static function firstInternal(string $html, array $extraAllowedPrefixes = []): ?string
|
||||
{
|
||||
foreach (self::candidates($html) as $src) {
|
||||
$resolved = self::resolveInternal($src, $extraAllowedPrefixes);
|
||||
|
||||
if ($resolved === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 컬럼 상한 초과 URL 은 캐시 불가 — 다음 후보로 넘어간다
|
||||
if (mb_strlen($resolved) > self::MAX_URL_LENGTH) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 src 후보를 내부 URL 로 해석합니다.
|
||||
*
|
||||
* @param string $src img src 속성값
|
||||
* @param array<int, string> $extraAllowedPrefixes 추가 허용 URL prefix 목록
|
||||
* @return string|null 내부로 판정된 URL (외부/비허용 스킴이면 null)
|
||||
*/
|
||||
private static function resolveInternal(string $src, array $extraAllowedPrefixes): ?string
|
||||
{
|
||||
$trimmed = trim($src);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = TrustedScriptHosts::normalizeForOriginCheck($trimmed);
|
||||
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// protocol-relative 는 외부 origin
|
||||
if (str_starts_with($normalized, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 단일 슬래시 시작 = same-origin 경로
|
||||
if (str_starts_with($normalized, '/')) {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
$scheme = parse_url($normalized, PHP_URL_SCHEME);
|
||||
|
||||
if (is_string($scheme) && in_array(strtolower($scheme), ['http', 'https'], true)) {
|
||||
$host = parse_url($normalized, PHP_URL_HOST);
|
||||
$appHost = parse_url((string) config('app.url'), PHP_URL_HOST);
|
||||
|
||||
if (is_string($host) && is_string($appHost) && strcasecmp($host, $appHost) === 0) {
|
||||
$path = parse_url($normalized, PHP_URL_PATH);
|
||||
$query = parse_url($normalized, PHP_URL_QUERY);
|
||||
|
||||
return (is_string($path) && $path !== '' ? $path : '/')
|
||||
.(is_string($query) && $query !== '' ? '?'.$query : '');
|
||||
}
|
||||
}
|
||||
|
||||
// 확장이 선언한 허용 prefix (CDN 직접 URL 스토리지 등) — 원형 유지 반환
|
||||
foreach ($extraAllowedPrefixes as $prefix) {
|
||||
if (is_string($prefix) && $prefix !== '' && str_starts_with($normalized, $prefix)) {
|
||||
return $trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
// 그 외 스킴(data:/javascript:/blob: 등)·미허용 host 전부 제외
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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.9'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -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 별 부분일치 연산자
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"bot_detection_enabled": true,
|
||||
"bot_detection_library_enabled": true,
|
||||
"og_default_site_name": "",
|
||||
"og_image_default": [],
|
||||
"og_image_default_width": 1200,
|
||||
"og_image_default_height": 630,
|
||||
"twitter_default_card": "summary_large_image",
|
||||
@@ -102,7 +103,9 @@
|
||||
"debug": {
|
||||
"mode": false,
|
||||
"sql_query_log": false,
|
||||
"log_level": "error"
|
||||
"log_level": "error",
|
||||
"outbound_proxy": "",
|
||||
"outbound_proxy_bypass": []
|
||||
},
|
||||
"core_update": {
|
||||
"github_url": "",
|
||||
@@ -223,6 +226,8 @@
|
||||
"bot_detection_enabled": { "type": "boolean", "sensitive": false },
|
||||
"bot_detection_library_enabled": { "type": "boolean", "sensitive": false },
|
||||
"og_default_site_name": { "type": "string", "sensitive": false },
|
||||
"og_image_default": { "type": "array", "sensitive": false, "transform": "attachments" },
|
||||
"og_image_default_url": { "type": "string", "sensitive": false, "computed": true, "source": "og_image_default", "transform": "first_image_url" },
|
||||
"og_image_default_width": { "type": "integer", "sensitive": false },
|
||||
"og_image_default_height": { "type": "integer", "sensitive": false },
|
||||
"twitter_default_card": { "type": "string", "sensitive": false },
|
||||
@@ -270,7 +275,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": {
|
||||
|
||||
@@ -72,20 +72,25 @@ class IdentityMessageDefinitionSeeder extends Seeder
|
||||
* `extension_type='core'` / `extension_identifier='core'` 자동 주입.
|
||||
* `variables: '__common__'` 마커는 commonVariables() 로 expand.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
* @return array<string, array<string, mixed>> config 복합 키('mail.purpose.signup' 등) => 정의
|
||||
*/
|
||||
private function getDefaultDefinitions(): array
|
||||
{
|
||||
$messages = config('core.identity_messages', []);
|
||||
$result = [];
|
||||
|
||||
foreach ($messages as $data) {
|
||||
foreach ($messages as $key => $data) {
|
||||
if (($data['variables'] ?? null) === '__common__') {
|
||||
$data['variables'] = $this->commonVariables();
|
||||
}
|
||||
$data['extension_type'] = 'core';
|
||||
$data['extension_identifier'] = 'core';
|
||||
$result[] = $data;
|
||||
// config 복합 키('mail.purpose.signup' 등)를 보존 — 언어팩 주입 필터
|
||||
// (seed.identity_messages.translations)가 이 키로 seed 를 매칭한다.
|
||||
// 리스트로 만들면 전 항목이 매칭 불가로 스킵되어 팩 로케일이 오류 없이
|
||||
// 주입되지 않는다 (#597 에서 실측·교정 — IdentityMessageTemplateService
|
||||
// ::loadCoreMessageDefinitions 의 동형 보존과 짝을 이룬다).
|
||||
$result[$key] = $data;
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
@@ -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` 객체를 반환합니다.
|
||||
클라이언트는 이 값으로 본인인증 화면을 띄운 뒤 원래 요청을 재시도합니다.
|
||||
|
||||
@@ -255,6 +259,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
|
||||
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
|
||||
| `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
|
||||
| `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
|
||||
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 12 |
|
||||
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
|
||||
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
|
||||
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
|
||||
|
||||
@@ -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 (`언어팩 업데이트에 실패했습니다.`) 으로 응답합니다.
|
||||
|
||||
**에러 응답**
|
||||
|
||||
|
||||
@@ -214,6 +214,7 @@ HTTP/1.1 200
|
||||
| seo.bot_detection_enabled | body | boolean | 아니오 | — | 검색엔진 봇 감지 사용 여부 (봇 요청에 SEO 렌더링 적용) |
|
||||
| seo.bot_detection_library_enabled | body | boolean | 아니오 | — | 봇 감지 라이브러리 사용 여부 (User-Agent 목록 대신 라이브러리 판정) |
|
||||
| seo.og_default_site_name | body | string | 아니오 | max 200 | seo.og default site 이름 (식별자) |
|
||||
| seo.og_image_default | body | array | 아니오 | 항목: 존재하는 첨부 ID | 사이트 기본 공유 이미지(og:image) 첨부 ID 배열 — 화면별 og:image 가 없을 때 폴백으로 사용. 화면 제출 형태(첨부 객체 배열)도 수용하며 정수 ID 배열로 정규화된다 (7.0.9+) |
|
||||
| seo.og_image_default_width | body | integer | 아니오 | min 0, max 8000 | 기본 Open Graph 이미지 너비 (px) |
|
||||
| seo.og_image_default_height | body | integer | 아니오 | min 0, max 8000 | 기본 Open Graph 이미지 높이 (px) |
|
||||
| seo.twitter_default_card | body | string | 아니오 | — | 기본 트위터 카드 유형 (summary 등) |
|
||||
@@ -240,6 +241,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 +1254,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` 뿐이다. 한쪽만 채우면 자리표시자는 그대로 남는다.
|
||||
|
||||
응답 문구에 예외 원문을 싣지 않기로 정한 화면(언어팩 관리 등)은 **파라미터를 채우는 대신 키에서
|
||||
치환 자리를 없앤다.** 자리를 남긴 채 일반 문구로 채우면 "…실패했습니다: 알 수 없는 오류" 처럼
|
||||
의미 없는 꼬리가 붙고, 나중에 누군가 그 자리를 예외 원문으로 채우는 회귀를 부른다.
|
||||
|
||||
---
|
||||
|
||||
## 예외 → 응답 매핑
|
||||
|
||||
@@ -611,6 +611,49 @@ user_id 없는 비회원(주문자 이메일/이름만 보유)도 회원과 동
|
||||
- 중복 제거 키는 네임스페이스 분리: 회원 `user:{id}` / 비회원 `guest:{이메일 해시}` — null-id 게스트가 하나로 뭉개지지 않습니다.
|
||||
- `exclude_trigger_user` 는 회원(user_id) 기준이라 게스트 수신자를 깨뜨리지 않습니다.
|
||||
|
||||
### 채널 메타의 화면 표시 키 (hidden_tab · tab_channels · tab_label_key · hidden_template_editor)
|
||||
|
||||
확장이 `core.notification.filter_available_channels` 훅으로 등록하는 채널 메타는 임의 필드를 보존해 프론트(`availableChannels` 데이터소스)까지 그대로 도달한다. 관리자 알림 설정 화면(코어 `admin_settings` · 게시판 `admin_board_settings` · 이커머스 `admin_ecommerce_settings`)은 다음 범용 키를 해석한다. 특정 확장 이름을 알지 못하며, 키가 없으면 종전과 동일하게 동작한다.
|
||||
|
||||
| 키 | 타입 | 효과 |
|
||||
| --- | --- | --- |
|
||||
| `hidden_tab` | bool | 채널 서브탭만 숨긴다. 채널 토글 카드·발송 축은 그대로 |
|
||||
| `tab_channels` | string[] | 이 채널의 탭이 대표하는 채널 id 목록. 목록 중 하나라도 활성 저장이면 탭을 노출한다(여러 채널을 하나의 탭으로 묶을 때) |
|
||||
| `tab_label_key` | string | 탭 라벨용 프론트 lang 키(`$t` 해석). 카드 라벨(`name_key`)과 분리 |
|
||||
| `hidden_template_editor` | bool | 알림 템플릿 [편집] 모달에서 코어의 언어탭·제목·본문·클릭 URL·변수 안내·[미리보기]·코어 [저장]을 숨긴다. 그 채널의 본문 규격이 코어 템플릿과 달라 확장이 편집기와 저장을 대신할 때 선언한다. 수신자 규칙·[취소]는 남는다 |
|
||||
|
||||
`hidden_template_editor` 를 선언한 확장은 편집 모달의 확장 지점 두 곳에 자기 UI 를 주입한다(3면 공통 이름 — extension_point 파일 1본으로 세 화면에 주입된다).
|
||||
|
||||
| extension_point | 위치 | props |
|
||||
| --- | --- | --- |
|
||||
| `notification_template_form_sections` | 수신자 규칙 다음, 코어 언어탭 앞 | `definition` · `template` · `channel` · `modalId` · `stateKey`(면별 코어 모달 상태 키) · `saveEndpoint`(코어 채널 템플릿 PUT) · `refetchDataSourceId`(면별 알림 정의 목록) |
|
||||
| `notification_template_form_footer_actions` | [취소] 와 코어 [저장] 사이 | 위와 동일 |
|
||||
|
||||
확장은 이 props 만으로 코어 저장 계약을 이행한다 — 코어 엔드포인트·상태 키를 리터럴로 적지 않는다. 코어 채널 템플릿의 수신자 규칙은 `_global?.[extensionPointProps.stateKey]?.recipients` 로 읽어 바뀐 경우에만 `saveEndpoint` 로 PUT 한다(매번 PUT 하면 코어가 행을 "사용자 수정" 으로 표시한다). 선례: `plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_*.json`. 행 하단 확장 지점 `notification_definition_row_footer`(props `definition` · `activeChannel`)는 상태 요약 같은 읽기 전용 UI 용이다.
|
||||
|
||||
### 정의 타입 라벨의 공급처
|
||||
|
||||
관리자 알림 설정 화면의 정의 목록 제목은 DB `name` 컬럼이 아니라 프론트 다국어 키
|
||||
`admin.settings.notification_definitions.types.{type}` 로 렌더된다. 이 키의 공급처는 정의 소유자를 따른다.
|
||||
|
||||
| 정의 소유 | 라벨 위치 |
|
||||
| --- | --- |
|
||||
| 코어 (`config/core.php` `notification_definitions`) | admin 템플릿 `lang/partial/{ko,en}/admin.json` 의 `settings.notification_definitions.types` (ja 는 템플릿 언어팩) |
|
||||
| 모듈 | 각 모듈의 알림 설정 화면과 lang 이 담당 |
|
||||
|
||||
키가 없으면 예외도 경고도 없이 원시 키 문자열이 화면 제목으로 그대로 노출된다. 코어 정의를
|
||||
추가하는 변경은 반드시 admin 템플릿 라벨(ko·en)과 템플릿 언어팩(ja)을 함께 추가해야 하며,
|
||||
코어 정의 전수 ↔ 템플릿 라벨 패리티는 정적 검사(테스트)가 자동 대조한다.
|
||||
|
||||
## 채널 템플릿을 시드하는 확장의 [기본값 복원] 의무
|
||||
|
||||
확장이 시딩 필터(`seed.notifications.translations` 등)로 자기 채널의 템플릿 행을 알림 정의에 끼워 넣으면, 같은 증강을
|
||||
`core.notification.filter_default_definitions` 에도 걸어야 한다. 관리자 화면의 [기본값 복원]은
|
||||
`NotificationTemplateService::getDefaultTemplateData()` 가 이 필터를 통과시킨 정의 배열에서 해당 채널의 template 을 찾아
|
||||
복원하므로, 시드 출처와 복원 출처가 같은 함수가 아니면 그 채널 행의 복원은 "기본 템플릿 데이터를 찾을 수 없습니다" 로 끝난다
|
||||
(시드는 되는데 복원만 안 되는 상태라 오류 로그도 남지 않는다). 모듈이 자기 정의를 이 필터에 보태는 priority(20) 뒤에서 돌아야
|
||||
모듈 정의도 증강된다. 선례: `plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php`.
|
||||
|
||||
### 채널별 비회원 발송 허용 (allow_guest)
|
||||
|
||||
채널이 비회원 발송을 허용하는지는 `config/notification.php` 채널 메타의 `allow_guest` 로 선언합니다.
|
||||
|
||||
+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()` 이 캐시 파일 로드로 분기해 **라우트 파일 자체가
|
||||
|
||||
@@ -133,6 +133,14 @@ core.activity_log.before_prune core.activity_log.after_prune
|
||||
core.notification_log.before_prune core.notification_log.after_prune
|
||||
core.schedule.before_prune_history core.schedule.after_prune_history
|
||||
|
||||
# 본문 첫 내부 이미지 썸네일 캐시 (Filter, 공개#22) — 각 모델 saving 이벤트가 발행.
|
||||
# 값 = 추출된 첫 내부 이미지 URL(없으면 null), 인자 = (값, 모델, 전체 후보 src 배열).
|
||||
# 확장이 후보를 대체(CDN prefix 승격 등)하거나 차단(null 반환)할 수 있다.
|
||||
# 특정 에디터 확장에 의존하지 않는다 — 페이로드는 일반 HTML 파싱 결과뿐이다.
|
||||
sirsoft-board.post.filter_content_thumbnail
|
||||
sirsoft-ecommerce.product.filter_content_thumbnail
|
||||
sirsoft-page.page.filter_content_thumbnail
|
||||
|
||||
# 업로드 트라이어드 — 사용자 첨부 업로드 지점의 표준 3훅 패턴
|
||||
# before_upload(액션) → filter_upload_file(필터: UploadedFile 을 받아 변형본을 반환.
|
||||
# 저장 파일명·MIME·크기가 모두 반환 파일 기준이 된다) → after_upload(액션)
|
||||
@@ -456,6 +464,19 @@ public static function getSubscribedHooks(): array
|
||||
}
|
||||
```
|
||||
|
||||
#### 호출자 트랜잭션 안에서 끝나야 하는 처리는 `sync` 가 필수다
|
||||
|
||||
기본값(큐 래핑)은 `DispatchHookListenerJob` 의 `afterCommit` 정책을 탄다. 즉 **호출자 트랜잭션이 커밋된 뒤에** 실행된다 — 큐 드라이버가 `sync` 여도 마찬가지다(같은 요청 안에서, 커밋 이후에 실행된다).
|
||||
|
||||
따라서 훅 안에서 실패했을 때 **호출자의 작업을 되돌려야 하는 처리**는 기본값으로 두면 안 된다. 되돌릴 대상이 이미 커밋된 뒤라 예외를 던져도 롤백되지 않고, 호출자는 오류 응답을 받는데 데이터는 남는다.
|
||||
|
||||
| 판정 | 예 |
|
||||
|------|-----|
|
||||
| `sync` 필수 | 쿠폰 차감·복원, 적립금 차감·복원 등 실패 시 호출자 트랜잭션을 되돌려야 하는 처리 |
|
||||
| 기본값(큐) 유지 | 활동 로그, 알림 발송, 통계 갱신 등 실패해도 호출자를 되돌리지 않는 후속 처리 |
|
||||
|
||||
선언만으로는 검증되지 않는다 — 회귀 테스트는 리스너를 손으로 `addAction` 하지 말고 실제 등록 경로(`HookListenerRegistrar::register()`)를 태운 뒤, **호출자 트랜잭션 안에서 반영되는지**와 **예외가 호출자를 롤백시키는지**를 단언한다. 손으로 등록하면 프로덕션이 쓰지 않는 경로를 검증하게 되어, 커밋 이후 실행 문제를 그대로 통과시킨다.
|
||||
|
||||
### getSubscribedHooks() 옵션 요약
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|
||||
@@ -287,6 +287,48 @@ foreach ($config['categories'] as $cat) { ... }
|
||||
- `permissions.json` → `{scope}.{target}.permissions.translations` 필터 (3-레벨 트리: module/categories/permissions)
|
||||
- 기타 → `seed.{target}.{entity}.translations` 필터 (단순 entity 시드)
|
||||
|
||||
### 복원 경로의 언어팩 병합
|
||||
|
||||
[기본값 복원](알림 템플릿·본인인증 메시지)은 위 시딩 필터가 아니라 자체 기본값 필터
|
||||
(`core.notification.filter_default_definitions` / `core.identity.filter_default_message_definitions`)를
|
||||
탄다. 복원 기본값에는 **활성 언어팩 seed 로케일이 반드시 병합**되어야 한다 — 병합하지 않으면
|
||||
복원이 팩이 주입해 둔 로케일(ja 등)을 config 의 ko/en 만으로 대체해 영구 소실시키며,
|
||||
오류도 로그도 남지 않는다. 코어 서비스(NotificationTemplateService·IdentityMessageTemplateService)가
|
||||
필터 적용 후 시딩과 같은 주입기(SSoT)로 병합하며, 정적 검사(테스트)가 이 계약을 고정한다.
|
||||
|
||||
시딩 필터의 페이로드는 **발화 주체의 원형 키를 보존**해야 한다 — 코어 알림 시더는 config
|
||||
원형(연관 배열, 키가 곧 type), 본인인증은 복합 키(`mail.purpose.signup` 등)가 매칭 키다.
|
||||
키를 버리고 리스트로 만들면 주입기가 전 항목을 스킵해 팩 로케일이 오류 없이 주입되지 않는다.
|
||||
알림 주입기는 두 형태(문자열 키 우선, 없으면 type 필드)를 수용한다.
|
||||
|
||||
### 사용자 수정 보존 판정과 시딩 컨텍스트
|
||||
|
||||
언어팩 병합·제거의 사용자 수정 보존 판정은 운영자 수정의 실제 기록 형식인 **dot-path 항목**
|
||||
(`user_overrides` 의 `"{컬럼}.{로케일}"`)을 기준으로 한다. 컬럼 전체 항목(`"subject"`)은 시더
|
||||
재실행의 컬럼 보호 선언이며 팩 병합을 막지 않는다 — 막으면 팩 업데이트가 자기 로케일 번역을
|
||||
갱신하지 못한다.
|
||||
|
||||
이를 위해 두 가지가 함께 지켜져야 한다:
|
||||
|
||||
- 다국어 JSON 컬럼을 추적하는 모델은 `translatableTrackableFields` 를 선언한다 — 미선언 시
|
||||
운영자 수정이 컬럼 전체 항목으로 기록되어 로케일 단위 보존이 불가능해진다.
|
||||
- 언어팩 주입·제거처럼 시스템이 수행하는 저장은 시딩 컨텍스트(`user_overrides.seeding` 바인딩)
|
||||
안에서 실행한다 — 바인딩 없이 저장하면 주입 자체가 "사용자 수정"으로 오인 기록되어 이후
|
||||
시더 재실행의 컬럼 갱신이 영구히 얼어붙는다.
|
||||
|
||||
### 실패한 업데이트의 상태 복원
|
||||
|
||||
`language-pack:update` 가 설치 트랜잭션 이후(활성화 단계)에 실패하면 팩 상태를 이전 상태로
|
||||
복원한다. 복원하지 않으면 active 였던 팩이 installed 로 방치되어 해당 로케일의 백엔드 번역이
|
||||
안내 없이 기본 로케일로 폴백된다.
|
||||
|
||||
### 같은 프로세스 활성화와 필터 인스턴스
|
||||
|
||||
시더 번역 필터 클로저는 boot 시점의 injector→registry **인스턴스**를 캡처한다. 활성 팩 목록
|
||||
캐시를 갱신할 때는 싱글톤을 유지한 채 `LanguagePackRegistry::invalidate()` 로 내부 캐시만
|
||||
비운다 — `forgetInstance` 로 바인딩을 교체하면 캡처된 구 인스턴스의 stale 캐시가 남아, 같은
|
||||
프로세스에서 활성화된 신규 팩이 시더 필터에 보이지 않는다(오류 없이 해당 로케일만 미주입).
|
||||
|
||||
### IDV 도메인 lang pack 커버리지
|
||||
|
||||
`config/core.php` 의 본인인증 SSoT 4개 블록 중 lang pack seed 대상은 1개:
|
||||
@@ -450,7 +492,20 @@ G7 는 일본어 번들 언어팩 12종을 공식 제공합니다. ko 원본을
|
||||
- 통화/숫자 단위 변환 금지 (런타임 처리)
|
||||
- 공식체(です·ます) 강제, 친근체 금지
|
||||
- 한글 미포함 value(영문/숫자/기호만) 는 번역 스킵 → 원문 유지
|
||||
- 고정 용어집 50항목으로 일관성 강제 (예: `관리자 → 管理者`, `장바구니 → カート`)
|
||||
- 고정 용어집으로 일관성 강제 (예: `관리자 → 管理者`, `장바구니 → カート`)
|
||||
|
||||
### 용어집 등록 의무
|
||||
|
||||
번역 결과의 표기는 고정 용어집이 정한다. 용어집에 없는 도메인 용어는 어간만 번역되어
|
||||
**원본 로케일 문자가 값에 그대로 남는다** — 예외도 오류도 나지 않고, 키 대칭 검사도 값은 보지 않으므로
|
||||
그 로케일로 화면을 열기 전까지 드러나지 않는다.
|
||||
|
||||
- 새 도메인 용어를 도입하면 번역 빌드 **전에** 용어집에 등록한다
|
||||
- 이미 생성된 값을 손으로 고쳤다면 용어집도 함께 고친다 — 용어집을 두면 다음 키에서 같은 오역이 되풀이된다
|
||||
- 같은 용어는 팩 전체에서 한 표기로 통일한다 (용어집이 단일 출처)
|
||||
|
||||
정적 검사가 번역 값의 원본 로케일 문자 잔존을 검출한다. 매니페스트와 CHANGELOG,
|
||||
그리고 한국어 주석은 대상이 아니다.
|
||||
|
||||
### 설치 및 활성화
|
||||
|
||||
|
||||
@@ -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,24 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.8] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- 사이트 기본 공유 이미지(og:image) 설정의 검증 속성 라벨 번역을 추가했습니다.
|
||||
|
||||
## [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字を超えることはできません。',
|
||||
@@ -1120,6 +1129,7 @@ return [
|
||||
'maintenance_mode' => 'メンテナンスモード',
|
||||
'asset_url_mode' => 'アセットURL方式',
|
||||
'site_logo' => 'サイトロゴ',
|
||||
'og_image_default' => '既定の共有画像',
|
||||
'bot_user_agents' => 'ボット User-Agent 一覧',
|
||||
'bot_detection_enabled' => 'ボット検出の使用',
|
||||
'bot_detection_library_enabled' => 'ボット検出ライブラリの使用',
|
||||
@@ -1161,6 +1171,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.8",
|
||||
"license": "MIT",
|
||||
"scope": "core",
|
||||
"target_identifier": null,
|
||||
|
||||
@@ -4,6 +4,19 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.3] - 2026-08-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- 배송업체 이름(한진·롯데·로젠)이 일본어 화면에서 "택배" 부분만 한국어로 표시되던 문제를 수정했습니다. 관리자 배송정책 화면과 초기 배송업체 데이터 모두 일본어 표기로 통일했습니다.
|
||||
- 모듈 소개 문구의 제품명이 한국어로 표시되던 문제를 수정했습니다.
|
||||
|
||||
## [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' => '現在の状態では購入確定できない注文オプションです。',
|
||||
|
||||
+3
-3
@@ -16,9 +16,9 @@
|
||||
},
|
||||
"carrier": {
|
||||
"cj": "CJ大韓通運",
|
||||
"hanjin": "韓進택배",
|
||||
"lotte": "ロッテ택배",
|
||||
"logen": "ロジェン택배",
|
||||
"hanjin": "ハンジン宅配",
|
||||
"lotte": "ロッテ宅配",
|
||||
"logen": "ロジェン宅配",
|
||||
"ups": "UPS",
|
||||
"fedex": "FedEx",
|
||||
"dhl": "DHL",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
|
||||
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.3",
|
||||
"license": "MIT",
|
||||
"scope": "module",
|
||||
"target_identifier": "sirsoft-ecommerce",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "eコマース",
|
||||
"description": "그누보드7 eコマース モジュール - 商品、注文、決済管理"
|
||||
"description": "グヌボード7 eコマース モジュール - 商品、注文、決済管理"
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
"name": "CJ大韓通運"
|
||||
},
|
||||
"hanjin": {
|
||||
"name": "韓進택배"
|
||||
"name": "ハンジン宅配"
|
||||
},
|
||||
"lotte": {
|
||||
"name": "ロッテ택배"
|
||||
"name": "ロッテ宅配"
|
||||
},
|
||||
"logen": {
|
||||
"name": "ロジェン택배"
|
||||
"name": "ロジェン宅配"
|
||||
},
|
||||
"ups": {
|
||||
"name": "UPS"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
이 언어팩의 모든 주요 변경사항을 기록합니다.
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.0] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- 비즈뿌리오 메시지 발송 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩 초기 제공
|
||||
- 관리자 환경설정 문구(비즈뿌리오 아이디·비밀번호·발신번호·알림톡 발신프로필키·결과 수신 주소, 검수 모드와 운영 전환 경고)의 일본어 번역
|
||||
- 알림 편집 창에 통합된 알림톡 템플릿·문자(SMS) 섹션의 제목·안내 문구와 승인 여부 배지(승인됨 / 미승인)의 일본어 번역
|
||||
- 검수자 전달 의견 입력란의 라벨·안내 문구의 일본어 번역
|
||||
- 알림톡 템플릿 작성·검수 신청·승인 관리 화면 문구의 일본어 번역
|
||||
- 템플릿 통합 관리 화면과 발송 이력 화면 문구의 일본어 번역
|
||||
- 발송 결과 안내 문구의 일본어 번역
|
||||
- 템플릿 작성 시 입력값 오류 안내의 일본어 번역 — 항목 이름까지 일본어로 표시됩니다.
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'action' => [],
|
||||
'description' => [],
|
||||
];
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'channel' => [
|
||||
'sms' => 'SMS',
|
||||
'lms' => 'LMS',
|
||||
'alimtalk' => 'アラートトーク',
|
||||
],
|
||||
'channel_group' => [
|
||||
'text' => '文字',
|
||||
'alimtalk' => '通知トーク',
|
||||
],
|
||||
'status' => [
|
||||
'pending' => '待機中',
|
||||
'sent' => '送信中',
|
||||
'success' => '成功',
|
||||
'failed' => '失敗',
|
||||
],
|
||||
'source' => [
|
||||
'auto' => '自動',
|
||||
'manual' => '手動',
|
||||
'bulk' => '一括',
|
||||
],
|
||||
'result_category' => [
|
||||
'success' => '成功',
|
||||
'retry' => '再試行',
|
||||
'permanent_failure' => '永久失敗',
|
||||
'balance_low' => '残高不足',
|
||||
],
|
||||
'channels' => [
|
||||
'source_label' => 'ビズプリオ',
|
||||
'sms' => [
|
||||
'name' => 'ビズプリオ 文字',
|
||||
'description' => 'ビズプリオを通じてテキスト(SMS/LMS)で通知を送信します。',
|
||||
],
|
||||
'alimtalk' => [
|
||||
'name' => 'ビズプリオ アラートトーク',
|
||||
'description' => 'ビズプリオを通じてカカオアラートトークで通知を送信します。',
|
||||
],
|
||||
],
|
||||
'readiness' => [
|
||||
'sms_credentials_missing' => 'ビズプリオのアイディとパスワードを設定してください。',
|
||||
'sms_sender_number_missing' => '発信番号を設定してください。',
|
||||
'alimtalk_api_key_missing' => 'カカオ管理API キーを設定してください。',
|
||||
'alimtalk_sender_key_missing' => 'アラートトーク発信プロフィールキーを設定してください。',
|
||||
],
|
||||
'settings' => [
|
||||
'bizppurio_id_attribute' => 'ビズプリオアイディ',
|
||||
'password_attribute' => 'パスワード',
|
||||
'sender_number_attribute' => '発信番号',
|
||||
],
|
||||
'webhook' => [
|
||||
'received' => 'レポートを受け取りました。',
|
||||
],
|
||||
'error' => [
|
||||
'credentials_missing' => 'ビズプリオのアイディとパスワードを先に設定してください。',
|
||||
'token_issue_failed' => 'ビズプリオ認証トークン発行に失敗しました。',
|
||||
'token_issue_failed_with_reason' => 'ビズプリオ認証トークンの発行に失敗しました。(:reason)',
|
||||
'send_failed' => 'メッセージ送信リクエストに失敗しました。',
|
||||
'send_retryable' => 'メッセージ送信が一時的に失敗しました。(コード: :code)',
|
||||
'invalid_response' => 'ビズプリオレスポンスを解析できません。',
|
||||
'connection_failed' => 'ビズプリオ サーバーに接続できません。しばらく後にもう一度お試しください。',
|
||||
'kakao_credentials_missing' => 'カカオ管理API 使用のためにアイディとAPI キーを先に設定してください。',
|
||||
'kakao_request_failed' => 'カカオ管理API リクエストに失敗しました。',
|
||||
'sender_key_missing' => 'アラートトーク発信プロフィールキーを先に設定してください。',
|
||||
'image_unreadable' => 'アップロードした画像ファイルを読み込めません。',
|
||||
],
|
||||
'send_skipped' => [
|
||||
'alimtalk_template_not_approved' => '承認されたアラートトークテンプレートがないか、アラートトーク送信がオフになっているため送信をスキップしました。(通知タイプ: :type)',
|
||||
'sms_template_missing' => 'SMS本文が設定されていないため送信をスキップしました。(通知タイプ: :type)',
|
||||
'recipient_phone_missing' => '受信者の電話番号がないため送信をスキップしました。(通知タイプ: :type)',
|
||||
'message_body_empty' => '送信本文が空いているため送信をスキップしました。(通知タイプ: :type)',
|
||||
],
|
||||
'template' => [
|
||||
'created' => '通知テンプレートを保存しました。',
|
||||
'updated' => '通知テンプレートを更新しました。',
|
||||
'requested' => '審査を申請しました。承認結果は自動的に同期され、[更新]ですぐに確認できます。',
|
||||
'request_cancelled' => '審査申請を取り消しました。',
|
||||
'approval_cancelled' => '承認を取り消しました。この通知のアラートトーク送信が停止されました。',
|
||||
'released' => '休眠状態を解除しました。',
|
||||
'synced' => 'カカオ審査ステータスを同期しました。',
|
||||
'deleted' => '通知テンプレートを削除しました。',
|
||||
'image_uploaded' => '画像をアップロードしました。',
|
||||
'content_locked' => '現在のステータス(:status)ではアラートトークの内容を修正できません。審査中の場合は申請の取り消しを、承認済みの場合は承認の取り消しを先に行ってください。',
|
||||
'content_missing' => 'アラートトークテンプレートの内容を先に作成してください。',
|
||||
'request_not_allowed' => '現在のステータス(:status)では審査を申請できません。',
|
||||
'cancel_request_not_allowed' => '審査中ステータスではないため、申請を取り消せません。(現在: :status)',
|
||||
'cancel_approval_not_allowed' => '承認ステータスではないため、承認を取り消せません。(現在: :status)',
|
||||
'release_not_allowed' => '休眠ステータスではないため、解除できません。(現在: :status)',
|
||||
'code_generation_failed' => 'テンプレートコードを採番できませんでした。しばらくしてからもう一度お試しください。',
|
||||
],
|
||||
'validation' => [
|
||||
'link_mo_required' => 'ウェブリンク(WL)ボタンにはモバイルリンクが必要です。',
|
||||
'link_and_required' => 'アプリリンク(AL)ボタンにはAndroidスキームが必要です。',
|
||||
'link_ios_required' => 'アプリリンク(AL)ボタンにはiOSスキームが必要です。',
|
||||
'tel_number_required' => '電話(TN)ボタンには電話番号が必要です。',
|
||||
'plugin_id_required' => 'プラグイン(P1~P3)ボタンにはプラグインIDが必要です。',
|
||||
'highlight_title_too_long' => 'アイテムハイライトのタイトルは:max文字以内である必要があります。',
|
||||
'highlight_description_too_long' => 'アイテムハイライトの説明は:max文字以内である必要があります。',
|
||||
'image_ratio_invalid' => '画像は横:縦 2:1 の比率である必要があります。(例: 1000×500px)',
|
||||
|
||||
// 検証エラー文に使うフィールドラベル (FormRequest::attributes)
|
||||
'attributes' => [
|
||||
'notification_type' => '通知タイプ',
|
||||
'content' => 'アラートトーク内容',
|
||||
'template_name' => 'テンプレート名',
|
||||
'message_type' => 'メッセージタイプ',
|
||||
'emphasize_type' => '強調タイプ',
|
||||
'template_content' => '本文',
|
||||
'comment' => '審査担当者への伝達事項',
|
||||
'preview_message' => 'プレビュー文言',
|
||||
'category_code' => 'カテゴリ',
|
||||
'security_flag' => 'セキュリティテンプレート',
|
||||
'extra' => '付加情報',
|
||||
'title' => '強調タイトル',
|
||||
'subtitle' => '強調サブタイトル',
|
||||
'header' => 'ヘッダー',
|
||||
'image_name' => '画像ファイル名',
|
||||
'image_url' => '画像URL',
|
||||
'item' => 'アイテムリスト',
|
||||
'item_list' => 'アイテム一覧',
|
||||
'item_title' => 'アイテム名',
|
||||
'item_description' => 'アイテム説明',
|
||||
'summary' => '要約',
|
||||
'summary_title' => '要約タイトル',
|
||||
'summary_description' => '要約説明',
|
||||
'highlight' => 'ハイライト',
|
||||
'highlight_title' => 'ハイライトタイトル',
|
||||
'highlight_description' => 'ハイライト説明',
|
||||
'highlight_image_url' => 'ハイライト画像URL',
|
||||
'represent_link' => '代表リンク',
|
||||
'buttons' => 'ボタン',
|
||||
'button_name' => 'ボタン名',
|
||||
'button_link_type' => 'ボタンリンクタイプ',
|
||||
'quick_replies' => 'クイックリプライ',
|
||||
'quick_reply_name' => 'クイックリプライ名',
|
||||
'quick_reply_link_type' => 'クイックリプライリンクタイプ',
|
||||
'link_mo' => 'モバイルリンク',
|
||||
'link_pc' => 'PCリンク',
|
||||
'link_and' => 'Androidスキーム',
|
||||
'link_ios' => 'iOSスキーム',
|
||||
'tel_number' => '電話番号',
|
||||
'plugin_id' => 'プラグインID',
|
||||
'image' => '画像ファイル',
|
||||
'sms_body' => 'SMS本文',
|
||||
'alimtalk_enabled' => 'アラートトーク利用',
|
||||
'fallback_sms_enabled' => '代替SMS利用',
|
||||
'sms_only' => 'SMS単独送信',
|
||||
'is_active' => '有効',
|
||||
'status' => 'ステータス',
|
||||
'search' => '検索キーワード',
|
||||
'page' => 'ページ',
|
||||
'per_page' => '1ページあたりの件数',
|
||||
],
|
||||
],
|
||||
'token_check' => [
|
||||
'success' => '認証が正常に確認されました。ユーザーIDとパスワードが正しいです。',
|
||||
'failed' => '認証の確認に失敗しました。詳細な理由をご確認ください。',
|
||||
],
|
||||
];
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'1000' => '成功',
|
||||
'2000' => 'メッセージが無効です',
|
||||
'3001' => '認証情報が無効です(Basic)',
|
||||
'3002' => 'トークンが無効です(期限切れ·廃止)',
|
||||
'3003' => 'IPが無効です',
|
||||
'3004' => 'アカウントが無効です',
|
||||
'3005' => '認証情報が無効です(Bearer)',
|
||||
'3006' => 'アカウントが存在しません',
|
||||
'3007' => 'アカウントパスワードが無効です',
|
||||
'3009' => 'アカウントが停止状態です',
|
||||
'3010' => 'アクセス許可IPが一致しません',
|
||||
'3011' => '不明なエラー(bizppurio)',
|
||||
'3013' => '完了処理されていないメッセージ',
|
||||
'4100' => '成功',
|
||||
'4400' => '電波の弱い地域',
|
||||
'4401' => '電源オフ',
|
||||
'4402' => 'ストレージ超過',
|
||||
'4410' => '不正な番号',
|
||||
'4414' => '結番·停止',
|
||||
'4420' => 'その他端末エラー',
|
||||
'4430' => 'スパム',
|
||||
'4431' => '送信制限受信拒否(スパム)',
|
||||
'4443' => 'スパムブロック',
|
||||
'5002' => 'リクエストが多すぎます',
|
||||
'5003' => '一時的な送信エラー',
|
||||
'5004' => '一時的な送信エラー',
|
||||
'5005' => '一時的な送信エラー',
|
||||
'6600' => '成功',
|
||||
'6603' => '電波の弱い地域',
|
||||
'6604' => '電源オフ',
|
||||
'6606' => '不正な番号',
|
||||
'6621' => 'メッセージ長超過',
|
||||
'6641' => 'スパムブロック',
|
||||
'7000' => '成功',
|
||||
'7103' => '発信プロフィールキーが無効です',
|
||||
'7106' => '削除された発信キー',
|
||||
'7107' => 'ブロックされた発信キー',
|
||||
'7204' => 'メッセージ内容がテンプレートと不一致です',
|
||||
'7206' => 'シリアルナンバー形式が不一致です',
|
||||
'7306' => 'カカオシステムエラー',
|
||||
'7307' => '処理遅延',
|
||||
'7308' => '電話番号エラー',
|
||||
'7320' => '受信ブロック',
|
||||
'7325' => '変数長超過',
|
||||
'7421' => 'タイムアウト',
|
||||
'7436' => 'ウォレット残高不足(アラートトーク)',
|
||||
'7437' => 'メッセージリクエスト失敗',
|
||||
'7523' => '080受信拒否(スパム)',
|
||||
'9000' => '一時的なシステムエラー',
|
||||
'9070' => '残高不足(SMS)',
|
||||
'9071' => '後払い限度額超過',
|
||||
];
|
||||
@@ -0,0 +1,304 @@
|
||||
{
|
||||
"name": "ビズプリオ メッセージ発送",
|
||||
"description": "ビズプリオ 連動 SMS/LMS・カカオ アラートトーク 発送プラグインです。",
|
||||
"settings": {
|
||||
"title": "ビズプリオ メッセージ発送 設定",
|
||||
"description": "ビズプリオ 連動情報と発送 設定を管理します。",
|
||||
"save": "保存",
|
||||
"saving": "保存 中...",
|
||||
"save_success": "設定が保存されました。",
|
||||
"save_failed": "設定 保存に失敗しました。",
|
||||
"test_mode": {
|
||||
"label": "検査モード",
|
||||
"hint": "検査モードでは ビズプリオ 検査ドメインで発送されます。オフにすると運用ドメインで実際に発送されます。",
|
||||
"account_notice": "検査と運用は別の ビズプリオ アカウントで運営することをお勧めします。"
|
||||
},
|
||||
"live_mode_warning_title": "運用環境で発送されます",
|
||||
"live_mode_warning_body": "検査モードがオフになっているため、実際の顧客にSMS・アラートトークが発送され、発送費用が請求されます。検査段階では検査モードをオンにしてください。",
|
||||
"sections": {
|
||||
"api": {
|
||||
"title": "API 連動",
|
||||
"description": "発送システムとカカオ管理システムに共通に使用する連動情報です。"
|
||||
},
|
||||
"sending": {
|
||||
"title": "発送 設定",
|
||||
"description": "文字・アラートトーク発送に使用する発信情報です。"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"bizppurio_id": {
|
||||
"label": "ビズプリオ ID",
|
||||
"hint": "発送・カカオ管理に共通に使用する ビズプリオ ID です。"
|
||||
},
|
||||
"password": {
|
||||
"label": "ビズプリオモジュールパスワード",
|
||||
"hint": "ビズプリオモジュールパスワードを入力してください。(ビズプリオコンソール > モジュール連携環境設定 > モジュールパスワード変更)"
|
||||
},
|
||||
"connection_check": {
|
||||
"label": "接続確認",
|
||||
"hint": "保存されたID·パスワードが有効かどうか、クリックしてすぐに確認してください。(保存後に反映されます)",
|
||||
"button": "接続確認",
|
||||
"checking": "確認中...",
|
||||
"success": "認証が正常に確認されました。IDとパスワードが正しいです。",
|
||||
"failed": "認証確認に失敗しました。",
|
||||
"unsaved_changes": "変更内容を先に保存してください。"
|
||||
},
|
||||
"api_key": {
|
||||
"label": "API キー",
|
||||
"hint": "API キーは ビズプリオ カスタマーセンターに ID とともに申し込むと確認後、発行されます。"
|
||||
},
|
||||
"sender_number": {
|
||||
"label": "発信番号",
|
||||
"hint": "文字・アラートトーク発送に使用する発信電話番号です。"
|
||||
},
|
||||
"sender_key": {
|
||||
"label": "アラートトーク 発信プロフィール キー",
|
||||
"hint": "アラートトーク 発送・テンプレート確認に使用する発信プロフィール キー(40文字)です。"
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"section_title": "レポート受信 設定",
|
||||
"hint": "ビズプリオ は文字・アラートトーク発送後、成功/失敗の結果をこのアドレスに転送(URL PUSH)するレポート受信方式を提供します。以下のアドレスを ビズプリオ ビジネスチーム(または管理コンソールのレポート受信 設定)に登録してください。",
|
||||
"note": "登録すると発送結果が履歴に自動記録され、実際に到達したかどうかを確認できます。登録前は発送リクエストまで確認され、最終結果(成功/失敗)は更新されません。",
|
||||
"copy": "コピー",
|
||||
"copied": "レポート受信アドレスがクリップボードにコピーされました。"
|
||||
},
|
||||
"tabs": {
|
||||
"connection": "環境設定",
|
||||
"templates": "通知テンプレート管理"
|
||||
},
|
||||
"preparation": {
|
||||
"intro": "文字・カカオ アラートトークを発送するには、まず ビズプリオ コンソールで以下の事前準備を完了する必要があります。",
|
||||
"sms_label": "文字(SMS/LMS)",
|
||||
"sms_sender": "発信番号を登録してください。",
|
||||
"kakao_label": "カカオ アラートトーク",
|
||||
"kakao_channel": "カカオトーク ビジネスチャネルを作成し、発信プロフィールを登録してください。",
|
||||
"kakao_template": "アラートトークテンプレートは管理者の通知設定(ビズプリオタブ)で作成して審査を申請します。",
|
||||
"kakao_apikey": "カスタマーセンターに API キーをリクエストしてください。",
|
||||
"console_link": "ビズプリオ コンソールを開く"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"bizppurio_tab": "ビズプリオ"
|
||||
},
|
||||
"binding": {
|
||||
"variables_hint": "提供変数(クリックすると本文のカーソル位置に挿入されます)"
|
||||
},
|
||||
"banner": {
|
||||
"not_ready": "発送に必要な 設定が完了していません。",
|
||||
"setup_action": "設定する",
|
||||
"test_mode": "検査モードです — 実際の発送は行われません。"
|
||||
},
|
||||
"dispatch_result": {
|
||||
"column_header": "文字・アラートトーク 結果",
|
||||
"inspection_label": "検査",
|
||||
"low_balance": "残高不足",
|
||||
"fallback": "SMS 代替発送 {status}",
|
||||
"detail_title": "文字・アラートトーク 発送 結果",
|
||||
"channel_label": "発送チャネル: {channel}",
|
||||
"sent_content_label": "実際の発送内容",
|
||||
"sent_content_hint": "アラートトーク は承認確定されたテンプレート内容で発送され、上記の\"本文\"と異なる場合があります。"
|
||||
},
|
||||
"editor": {
|
||||
"data_source": {
|
||||
"dispatch_results": "ビズプリオ送信結果"
|
||||
}
|
||||
},
|
||||
"template": {
|
||||
"tab_guide": "各通知の[編集]でアラートトークのテンプレートとSMS本文をまとめて作成し、審査を申請してください。カカオの承認後にアラートトークが自動送信され、代替SMS・SMS単独送信も同じ画面で設定します。",
|
||||
"status": {
|
||||
"unwritten": "未作成",
|
||||
"draft": "作成中",
|
||||
"requested": "審査中",
|
||||
"approved": "承認済み",
|
||||
"rejected": "却下",
|
||||
"stopped": "停止",
|
||||
"blocked": "ブロック",
|
||||
"dormant": "休眠"
|
||||
},
|
||||
"row": {
|
||||
"alimtalk_label": "アラートトーク",
|
||||
"requested_on": "{date} 申請",
|
||||
"synced_on": "{date} 同期",
|
||||
"alimtalk_enabled": "送信を使用",
|
||||
"btn_view_reason": "理由を表示",
|
||||
"btn_compose": "アラートトークテンプレート作成",
|
||||
"btn_edit": "編集",
|
||||
"btn_edit_approved": "編集 (承認取り消し)",
|
||||
"btn_request": "審査申請",
|
||||
"btn_cancel_request": "申請取り消し",
|
||||
"btn_release": "休眠解除",
|
||||
"btn_refresh": "更新",
|
||||
"requested_toast": "審査を申請しました。",
|
||||
"cancel_request_toast": "審査申請を取り消しました。",
|
||||
"released_toast": "休眠状態を解除しました。",
|
||||
"synced_toast": "カカオ審査ステータスを同期しました。",
|
||||
"fallback_sms": "代替SMS",
|
||||
"sms_only": "SMS単独",
|
||||
"sms_body_prefix": "本文:",
|
||||
"sms_body_missing": "SMS本文未設定",
|
||||
"btn_edit_sms": "SMS本文を編集",
|
||||
"sms_label": "SMS",
|
||||
"on": "使用",
|
||||
"off": "未使用",
|
||||
"alimtalk_disabled": "送信停止中"
|
||||
},
|
||||
"form": {
|
||||
"modal_title": "アラートトークテンプレート — {name}",
|
||||
"rejected_banner": "却下されたテンプレートです。以下の理由を確認し、修正後に再度申請してください。",
|
||||
"sender_profile": "発信プロフィール",
|
||||
"sender_profile_placeholder": "発信プロフィール選択(参考用)",
|
||||
"sender_profile_hint": "審査申請は環境設定に保存された発信プロフィールキーで行われます。",
|
||||
"category": "カテゴリ",
|
||||
"category_placeholder": "カテゴリ選択",
|
||||
"name": "テンプレート名",
|
||||
"message_type": "メッセージ種別",
|
||||
"templateMessageType_options": {
|
||||
"BA": "基本型",
|
||||
"EX": "付加情報型",
|
||||
"AD": "チャネル追加型",
|
||||
"MI": "複合型"
|
||||
},
|
||||
"emphasize_type": "強調種別",
|
||||
"templateEmphasizeType_options": {
|
||||
"NONE": "なし",
|
||||
"TEXT": "強調表記",
|
||||
"IMAGE": "画像",
|
||||
"ITEM_LIST": "アイテムリスト"
|
||||
},
|
||||
"emphasize_title": "強調表記タイトル",
|
||||
"emphasize_subtitle": "強調表記サブタイトル",
|
||||
"image": "画像",
|
||||
"image_hint": "jpg/png · 500KB以下 · 横500px以上 · 横:縦 2:1 (アップロードするとURLが自動入力されます)",
|
||||
"image_uploading": "画像をアップロード中...",
|
||||
"image_upload_failed": "画像のアップロードに失敗しました。",
|
||||
"content": "本文",
|
||||
"extra": "付加情報",
|
||||
"header": "ヘッダー",
|
||||
"header_hint": "本文上部に表示される最大16文字の文言(任意)",
|
||||
"buttons": "ボタン (最大5個)",
|
||||
"button_add": "ボタン追加",
|
||||
"button_name": "ボタン名 (最大14文字)",
|
||||
"quick_replies": "クイックリンク (最大10個)",
|
||||
"quick_reply_add": "クイックリンク追加",
|
||||
"link_mo": "モバイルリンク (必須)",
|
||||
"link_pc": "PCリンク (任意)",
|
||||
"link_and": "Androidスキーム (必須)",
|
||||
"link_ios": "iOSスキーム (必須)",
|
||||
"tel_number": "電話番号 (必須)",
|
||||
"plugin_id": "プラグインID (必須)",
|
||||
"item_highlight": "アイテムハイライト (任意)",
|
||||
"hl_title": "ハイライトタイトル",
|
||||
"hl_description": "ハイライト説明",
|
||||
"hl_image_url": "サムネイル画像URL (任意)",
|
||||
"item_list": "アイテム一覧 (2~10個)",
|
||||
"item_list_hint": "アイテムは最小2個、最大10個です。(タイトル6文字・説明23文字以内)",
|
||||
"item_add": "アイテム追加",
|
||||
"item_title": "タイトル",
|
||||
"item_description": "説明",
|
||||
"item_summary": "要約(価格情報)",
|
||||
"summary_description": "要約値 (14文字以内)",
|
||||
"btn_remove_item": "アイテム削除",
|
||||
"btn_remove_button": "ボタン削除",
|
||||
"btn_remove_quick_reply": "クイックリプライ削除",
|
||||
"btn_save": "保存",
|
||||
"btn_save_request": "保存して審査申請",
|
||||
"request_comment": "審査担当者への伝達事項",
|
||||
"request_comment_placeholder": "例: #{name}=山田太郎, #{order_number}=20260823-0001",
|
||||
"request_comment_hint": "審査申請時にカカオの審査担当者へ一緒に送られます(任意・500文字以内)。カカオの審査ガイドは本文の変数ごとに例示値を求めるため、変数ごとの例を書いておくと差し戻しを減らせます。保存はされず、申請時にのみ送信されます。",
|
||||
"saved_toast": "通知テンプレートを保存しました。",
|
||||
"requested_toast": "審査を申請しました。承認されるとアラートトーク送信が開始されます。"
|
||||
},
|
||||
"sms": {
|
||||
"modal_title": "SMS本文 — {name}",
|
||||
"hint": "代替SMSとSMS単独送信に共通で使用する本文です。#{変数} は送信時に自動置換されます。",
|
||||
"btn_save": "保存",
|
||||
"saved_toast": "SMS本文を保存しました。"
|
||||
},
|
||||
"cancel_approval": {
|
||||
"modal_title": "承認取り消しの確認",
|
||||
"warning": "「{name}」テンプレートの承認を取り消すと、直ちにこの通知のアラートトーク送信が停止されます。(代替SMS・SMS単独送信はSMS設定に従って継続されます。)修正後に再度審査を申請して承認を受けると、アラートトーク送信が再開されます。",
|
||||
"btn_confirm": "承認を取り消して編集",
|
||||
"done_toast": "承認を取り消しました。修正後に再度審査を申請してください。"
|
||||
},
|
||||
"rejection": {
|
||||
"modal_title": "却下理由 — {name}",
|
||||
"btn_close": "閉じる"
|
||||
},
|
||||
"approval": {
|
||||
"approved": "承認済み",
|
||||
"not_approved": "未承認"
|
||||
},
|
||||
"section": {
|
||||
"alimtalk_title": "アラートトークテンプレート",
|
||||
"sms_title": "SMS",
|
||||
"alimtalk_enabled": "アラートトーク送信を使用",
|
||||
"locked_hint": "審査中または承認済みのテンプレートは内容を変更できません。変更するには先に[申請取消]または[編集(承認取消)]を押してください。",
|
||||
"saved_toast": "通知テンプレートを保存しました。",
|
||||
"loading": "テンプレートを読み込み中…"
|
||||
}
|
||||
},
|
||||
"manage": {
|
||||
"guide": "すべての通知のビズプリオテンプレート状態を一目で管理します。作成・審査申請は通知設定画面と同じように動作します。",
|
||||
"filter_all": "すべてのステータス",
|
||||
"search_placeholder": "通知名/タイプ検索",
|
||||
"btn_search": "検索",
|
||||
"total_count": "合計 {count}件",
|
||||
"btn_compose": "作成",
|
||||
"btn_edit": "編集",
|
||||
"btn_delete": "削除",
|
||||
"empty": "登録されたビズプリオ通知テンプレートがありません。",
|
||||
"empty_hint": "管理者の通知設定のビズプリオタブでアラートトークテンプレートを作成するか、SMSを設定するとここに表示されます。",
|
||||
"sms_only": "単独",
|
||||
"sms_fallback": "代替",
|
||||
"owner": {
|
||||
"core": "コア",
|
||||
"module": "モジュール",
|
||||
"plugin": "プラグイン"
|
||||
},
|
||||
"columns": {
|
||||
"notification": "通知",
|
||||
"owner": "所属",
|
||||
"status": "アラートトークステータス",
|
||||
"sms": "SMS",
|
||||
"requested_at": "申請日",
|
||||
"synced_at": "同期",
|
||||
"actions": "管理"
|
||||
},
|
||||
"delete": {
|
||||
"modal_title": "テンプレート削除",
|
||||
"warning": "「{name}」のビズプリオテンプレート設定を削除します。カカオに登録されたテンプレートは削除可能なステータス(登録/却下)の場合のみ一緒に削除されます。",
|
||||
"btn_confirm": "削除",
|
||||
"done_with_kakao": "テンプレートを削除しました。(カカオ側テンプレートを含む)",
|
||||
"done_db_only": "テンプレート設定を削除しました。カカオ側テンプレートはステータス制約により残っています。"
|
||||
}
|
||||
},
|
||||
"templates": {
|
||||
"readiness": {
|
||||
"title": "アラートトークテンプレートを使用するには、以下の設定が必要です",
|
||||
"go_settings": "環境設定に移動",
|
||||
"missing_label": "未設定項目",
|
||||
"api_key_missing": "カカオ管理API キー",
|
||||
"sender_key_missing": "アラートトーク送信プロフィールキー",
|
||||
"note": "上記項目を環境設定タブで入力すると、テンプレートの作成・審査申請が可能になります。実際の送信は検査モードをオフにして運用に切り替えた後、承認されたテンプレートのみ使用できます。"
|
||||
},
|
||||
"list": {
|
||||
"refresh": "更新"
|
||||
},
|
||||
"link_type": {
|
||||
"WL": "ウェブリンク",
|
||||
"AL": "アプリリンク",
|
||||
"DS": "配送追跡",
|
||||
"BK": "ボットキーワード",
|
||||
"MD": "メッセージ転送",
|
||||
"AC": "チャネル追加",
|
||||
"BC": "相談トーク転換",
|
||||
"BT": "ボット転換",
|
||||
"TN": "電話をかける",
|
||||
"MP": "地図表示",
|
||||
"P1": "画像セキュア送信",
|
||||
"P2": "個人情報利用",
|
||||
"P3": "ワンクリック決済"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"identifier": "g7-plugin-sirsoft-message_bizppurio-ja",
|
||||
"namespace": "g7",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "G7 플러그인 (sirsoft-message_bizppurio) 일본어 언어팩",
|
||||
"en": "G7 plugin (sirsoft-message_bizppurio) Japanese language pack",
|
||||
"ja": "G7 プラグイン (sirsoft-message_bizppurio) 日本語 言語パック"
|
||||
},
|
||||
"description": {
|
||||
"ko": "G7 플러그인 (sirsoft-message_bizppurio) 일본어 언어팩 (번들)",
|
||||
"en": "G7 plugin (sirsoft-message_bizppurio) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-message_bizppurio) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-message_bizppurio",
|
||||
"locale": "ja",
|
||||
"locale_name": "Japanese",
|
||||
"locale_native_name": "日本語",
|
||||
"text_direction": "ltr",
|
||||
"g7_version": ">=7.0.0-beta.4",
|
||||
"requires": {
|
||||
"target_version": null,
|
||||
"depends_on_core_locale": true
|
||||
},
|
||||
"github_url": "",
|
||||
"github_changelog_url": ""
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Bizppurio メッセージ発送",
|
||||
"description": "Bizppurio 連動 SMS/LMS・カカオ アラート トーク発送プラグインです。コア通知システムチャネルとして文字・アラートトークを発送し、発送結果を webhook で受信します。"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"bizppurio_balance_low": {
|
||||
"definition": {
|
||||
"name": "Bizssprrio残高不足",
|
||||
"description": "Bizssprrio ウォレット残高が不足しており、SMS/アラートトーク送信に失敗した場合、管理者に送信されます"
|
||||
},
|
||||
"templates": {
|
||||
"mail": {
|
||||
"subject": "[{app_name}] Bizssprrio 残高が不足しています",
|
||||
"body": "{name}様、Bizssprrio ウォレット残高が不足しているため、{channel_label} 送信に失敗しました (コード: {result_code})。チャージ後、送信が正常化されます。"
|
||||
},
|
||||
"database": {
|
||||
"subject": "Bizssprrio残高不足",
|
||||
"body": "Bizssprrio残高不足により、{channel_label} 送信に失敗しました (コード: {result_code})。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"sirsoft-message_bizppurio": {
|
||||
"name": "ビズプリオ メッセージ発送",
|
||||
"description": "ビズプリオ メッセージ発送プラグインが提供する権限"
|
||||
},
|
||||
"sirsoft-message_bizppurio.messaging": {
|
||||
"name": "メッセージ発送",
|
||||
"description": "メッセージ発送ドメイン権限 (閲覧·管理)"
|
||||
},
|
||||
"sirsoft-message_bizppurio.messaging.view": {
|
||||
"name": "メッセージ閲覧",
|
||||
"description": "発送履歴·アラートトークテンプレート閲覧 (モニタリング)"
|
||||
},
|
||||
"sirsoft-message_bizppurio.messaging.manage": {
|
||||
"name": "メッセージ管理",
|
||||
"description": "環境設定·アラートトークテンプレート登録/検証·イベント連動管理"
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,19 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.7] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- 환경설정 SEO 탭의 기본 공유 이미지(og:image) 라벨·설명 번역을 추가했습니다.
|
||||
- 알림 설정 화면의 「사이트맵 재생성 완료」・「사이트맵 재생성 실패」 알림 이름 번역을 추가했습니다.
|
||||
|
||||
## [1.0.6] - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- 환경설정 > 고급의 아웃바운드 프록시 설정 항목명·설명·입력 안내와 연결 테스트 버튼 라벨의 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.0.5] - 2026-08-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1423,6 +1423,8 @@
|
||||
"og_image_default_width": "OG 画像 デフォルト横幅 (px)",
|
||||
"og_image_default_height": "OG 画像 デフォルト高さ (px)",
|
||||
"og_image_default_size_hint": "サムネイルの実際のサイズが不明な場合のデフォルト値です。Facebookは推奨 1200×630、最小 200×200。",
|
||||
"og_image_default": "既定の共有画像",
|
||||
"og_image_default_desc": "画面ごとの共有画像がない場合に使用するサイト既定の画像です。リンク共有時のプレビューカードに表示されます。",
|
||||
"twitter_default_card": "Twitterカード デフォルト タイプ",
|
||||
"twitter_default_card_hint": "大きい画像のカードは summary_large_image、小さいカードは summary を推奨します。",
|
||||
"twitter_default_site": "Twitter サイト ハンドル",
|
||||
@@ -1786,6 +1788,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",
|
||||
@@ -1853,7 +1862,9 @@
|
||||
"types": {
|
||||
"welcome": "会員登録ウェルカム",
|
||||
"reset_password": "パスワード再設定",
|
||||
"password_changed": "パスワード変更通知"
|
||||
"password_changed": "パスワード変更通知",
|
||||
"sitemap_regenerated": "サイトマップ再生成完了",
|
||||
"sitemap_regenerate_failed": "サイトマップ再生成失敗"
|
||||
},
|
||||
"channels_label": "チャネル:",
|
||||
"edit_modal": {
|
||||
|
||||
@@ -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.7",
|
||||
"license": "MIT",
|
||||
"scope": "template",
|
||||
"target_identifier": "sirsoft-admin_basic",
|
||||
|
||||
@@ -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-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- 첫 화면 소개 문구의 제품명이 한국어로 표시되던 문제를 수정했습니다.
|
||||
|
||||
## [1.1.1] - 2026-08-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"welcome": {
|
||||
"title": "いらっしゃいませ!",
|
||||
"description": "그누보드7 コミュニティへようこそ。",
|
||||
"description": "グヌボード7 コミュニティへようこそ。",
|
||||
"login_prompt": "ログインしてコミュニティに参加してください。",
|
||||
"greeting": "こんにちは、{{name}}さん!"
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 template (sirsoft-basic) Japanese language pack (bundled)",
|
||||
"ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"license": "MIT",
|
||||
"scope": "template",
|
||||
"target_identifier": "sirsoft-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)',
|
||||
@@ -1263,6 +1272,7 @@ return [
|
||||
'maintenance_mode' => 'maintenance mode',
|
||||
'asset_url_mode' => 'asset URL mode',
|
||||
'site_logo' => 'site logo',
|
||||
'og_image_default' => 'default share image',
|
||||
// SEO settings (additional)
|
||||
'bot_user_agents' => 'bot user agents',
|
||||
'bot_detection_enabled' => 'bot detection',
|
||||
@@ -1307,6 +1317,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 = 무제한)',
|
||||
@@ -1256,6 +1265,7 @@ return [
|
||||
'maintenance_mode' => '점검 모드',
|
||||
'asset_url_mode' => '자산 주소 방식',
|
||||
'site_logo' => '사이트 로고',
|
||||
'og_image_default' => '기본 공유 이미지',
|
||||
// SEO 설정 (추가)
|
||||
'bot_user_agents' => '봇 User-Agent 목록',
|
||||
'bot_detection_enabled' => '봇 감지 사용',
|
||||
@@ -1300,6 +1310,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,24 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.0] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- 본문에 삽입한 이미지가 카드형·갤러리형 목록의 썸네일로 표시됩니다. 이미지 첨부파일이 있으면 종전대로 첨부가 우선하며, 첨부가 없을 때 본문의 첫 내부 이미지를 사용합니다. 외부 주소의 이미지는 사용하지 않습니다. 기존 게시물은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
||||
- 글 상세를 공유할 때의 미리보기 이미지(og:image)에도 같은 기준이 적용됩니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
|
||||
- 게시판 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다.
|
||||
|
||||
## [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.1.0",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* board_posts 에 본문 첫 내부 이미지 URL 캐시 컬럼 추가 (공개 이슈 #22)
|
||||
*
|
||||
* 목록 쿼리는 content 를 SELECT 하지 않으므로(컬럼 프루닝) 조회 시점 본문 파싱이
|
||||
* 불가하다 — 저장 시점(모델 saving 이벤트)에 추출해 이 컬럼에 캐시한다.
|
||||
* 기존 행 백필은 Upgrade_1_1_0 업그레이드 스텝이 수행한다 (마이그레이션은 스키마만).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('board_posts', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('board_posts', 'content_thumbnail_url')) {
|
||||
$table->string('content_thumbnail_url', 1000)
|
||||
->nullable()
|
||||
->after('content_mode')
|
||||
->comment('본문 첫 내부 이미지 URL 캐시 — 이미지 첨부가 없을 때 목록 썸네일 폴백');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 마이그레이션 롤백
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasTable('board_posts')) {
|
||||
$columns = Schema::getColumnListing('board_posts');
|
||||
|
||||
Schema::table('board_posts', function (Blueprint $table) use ($columns) {
|
||||
if (in_array('content_thumbnail_url', $columns)) {
|
||||
$table->dropColumn('content_thumbnail_url');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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). 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시·본문 이미지 URL 노출 차단 — 필드 자체는 유지) |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
@@ -529,7 +529,7 @@ _단건 응답: `data` 객체의 필드 (`PostResource` — 게시글 수정(PUT
|
||||
| reply_count | integer | `0` | 답변글 수 (집계) |
|
||||
| attachment_count | integer | `0` | 첨부파일 수 (집계) |
|
||||
| has_attachment | boolean | `false` | 첨부파일 보유 여부 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 (없으면 null) |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | 원글 ID (답변글일 때만 값 존재) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위) |
|
||||
| is_reply | boolean | `false` | 답변글 여부 |
|
||||
@@ -880,7 +880,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| reply_count | integer | `0` | reply 개수 (집계) |
|
||||
| attachment_count | integer | `0` | attachment 개수 (집계) |
|
||||
| has_attachment | boolean | `false` | attachment 여부 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `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). 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시·본문 이미지 URL 노출 차단 — 필드 자체는 유지) |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
@@ -1300,7 +1300,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| reply_count | integer | `0` | reply 개수 (집계) |
|
||||
| attachment_count | integer | `0` | attachment 개수 (집계) |
|
||||
| has_attachment | boolean | `false` | attachment 여부 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
@@ -1459,7 +1459,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| reply_count | integer | `0` | reply 개수 (집계) |
|
||||
| attachment_count | integer | `0` | attachment 개수 (집계) |
|
||||
| has_attachment | boolean | `false` | attachment 여부 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
@@ -1625,7 +1625,7 @@ _단건 응답: `data` 객체의 필드 (`PostResource` — 복원된 게시글.
|
||||
| reply_count | integer | `0` | 답변글 수 (집계) |
|
||||
| attachment_count | integer | `0` | 첨부파일 수 (집계) |
|
||||
| has_attachment | boolean | `false` | 첨부파일 보유 여부 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | 원글 ID (답변글일 때만 값 존재) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 |
|
||||
| is_reply | boolean | `false` | 답변글 여부 |
|
||||
|
||||
@@ -2848,7 +2848,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/경로 |
|
||||
| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
@@ -3020,7 +3020,7 @@ _단건 응답: `data` 객체의 필드 (생성된 게시글 = `PostResource`,
|
||||
| reply_count | integer | `0` | 답변글 수 (집계) |
|
||||
| attachment_count | integer | `0` | 첨부 수 (집계) |
|
||||
| has_attachment | boolean | `false` | 첨부 보유 여부 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL (첫 이미지 첨부 — 없으면 null) |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | 답변글의 원글 식별자 (원글이면 null) |
|
||||
| depth | integer | `0` | 계층 트리 깊이 (0 = 원글) |
|
||||
| is_reply | boolean | `false` | 답변글 여부 |
|
||||
@@ -3438,7 +3438,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/경로 |
|
||||
| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
@@ -3696,7 +3696,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| reply_count | integer | `0` | reply 개수 (집계) |
|
||||
| attachment_count | integer | `0` | attachment 개수 (집계) |
|
||||
| has_attachment | boolean | `false` | attachment 여부 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 |
|
||||
| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
|
||||
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
|
||||
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
|
||||
| is_reply | boolean | `false` | reply 여부 |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user