v7.0.0-beta.2 release
This commit is contained in:
1 parent
6595fd0eb5
commit
fa8eba7a0d
1165 files changed
+107118
-762127
No files matched your search
+10
-4
@@ -9,9 +9,9 @@
|
||||
|
||||
| 카테고리 | 문서 수 | 링크 상태 |
|
||||
|----------|---------|----------|
|
||||
| [백엔드](backend/) | 20개 | 정상 |
|
||||
| [백엔드](backend/) | 23개 | 정상 |
|
||||
| [프론트엔드](frontend/) | 49개 | 정상 |
|
||||
| [확장 시스템](extension/) | 23개 | 정상 |
|
||||
| [확장 시스템](extension/) | 26개 | 정상 |
|
||||
| 공통 | 19개 | 정상 |
|
||||
| [AI 도구](ai-tools/) | - | 정상 |
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
<!-- AUTO-GENERATED-START: docs-readme-full-list -->
|
||||
## 카테고리별 전체 문서 목록
|
||||
|
||||
### 백엔드 (20개)
|
||||
### 백엔드 (23개)
|
||||
|
||||
| 문서 | 제목 |
|
||||
|------|------|
|
||||
@@ -125,8 +125,10 @@
|
||||
| [controllers.md](backend/controllers.md) | 컨트롤러 계층 구조 |
|
||||
| [core-config.md](backend/core-config.md) | 코어 설정 (config/core.php) |
|
||||
| [core-update-system.md](backend/core-update-system.md) | 코어 업데이트 시스템 (Core Update System) |
|
||||
| [data-sync-helpers.md](backend/data-sync-helpers.md) | 데이터 동기화 Helper (Data Sync Helpers) |
|
||||
| [enum.md](backend/enum.md) | Enum 사용 규칙 |
|
||||
| [exceptions.md](backend/exceptions.md) | Custom Exception 다국어 처리 |
|
||||
| [geoip.md](backend/geoip.md) | GeoIP 시스템 (MaxMind GeoLite2) |
|
||||
| [middleware.md](backend/middleware.md) | 미들웨어 등록 규칙 |
|
||||
| [notification-system.md](backend/notification-system.md) | 알림 시스템 (Notification System) |
|
||||
| [README.md](backend/README.md) | 백엔드 개발 가이드 |
|
||||
@@ -136,6 +138,7 @@
|
||||
| [seo-system.md](backend/seo-system.md) | SEO 페이지 생성기 시스템 (SEO Page Generator) |
|
||||
| [service-provider.md](backend/service-provider.md) | 서비스 프로바이더 안전성 |
|
||||
| [service-repository.md](backend/service-repository.md) | Service-Repository 패턴 |
|
||||
| [user-overrides.md](backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) |
|
||||
| [validation.md](backend/validation.md) | 검증 (Validation) |
|
||||
|
||||
### 프론트엔드 (49개)
|
||||
@@ -192,10 +195,11 @@
|
||||
| [handlers.md](frontend/handlers.md) | sirsoft-basic 핸들러 |
|
||||
| [layouts.md](frontend/layouts.md) | sirsoft-basic 레이아웃 |
|
||||
|
||||
### 확장 시스템 (23개)
|
||||
### 확장 시스템 (26개)
|
||||
|
||||
| 문서 | 제목 |
|
||||
|------|------|
|
||||
| [cache-driver.md](extension/cache-driver.md) | 캐시 드라이버 시스템 (CacheInterface) |
|
||||
| [changelog-rules.md](extension/changelog-rules.md) | Changelog 규칙 (Changelog Rules) |
|
||||
| [extension-manager.md](extension/extension-manager.md) | ExtensionManager (확장 관리자) |
|
||||
| [extension-update-system.md](extension/extension-update-system.md) | 확장 업데이트 시스템 (Extension Update System) |
|
||||
@@ -219,6 +223,8 @@
|
||||
| [template-routing.md](extension/template-routing.md) | 템플릿 라우트/언어 파일 규칙 |
|
||||
| [template-security.md](extension/template-security.md) | 템플릿 보안 정책 |
|
||||
| [template-workflow.md](extension/template-workflow.md) | 템플릿 개발 워크플로우 |
|
||||
| [upgrade-step-guide.md](extension/upgrade-step-guide.md) | 업그레이드 스텝 작성 가이드 (Upgrade Step Guide) |
|
||||
| [vendor-bundle.md](extension/vendor-bundle.md) | Vendor 번들 시스템 (Vendor Bundle System) |
|
||||
|
||||
### 공통 (19개)
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+12
-2
@@ -22,22 +22,25 @@
|
||||
|------|------|----------|
|
||||
| [activity-log-hooks.md](activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 |
|
||||
| [activity-log.md](activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel... |
|
||||
| [api-resources.md](api-resources.md) | API 리소스 | BaseApiResource 상속 필수 |
|
||||
| [api-resources.md](api-resources.md) | API 리소스 | Resource: BaseApiResource 상속 필수 / Collection: B... |
|
||||
| [authentication.md](authentication.md) | 인증 및 세션 처리 | Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용) |
|
||||
| [broadcasting.md](broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) |
|
||||
| [controllers.md](controllers.md) | 컨트롤러 계층 구조 | AdminBaseController / AuthBaseController / Publ... |
|
||||
| [core-config.md](core-config.md) | 코어 설정 (config/core.php) | config/core.php = 코어 권한/역할/메뉴/메일템플릿의 SSoT (Sing... |
|
||||
| [core-update-system.md](core-update-system.md) | 코어 업데이트 시스템 (Core Update System) | 코어 업그레이드 스텝: upgrades/ 디렉토리 (프로젝트 루트), 네임스페이스 A... |
|
||||
| [data-sync-helpers.md](data-sync-helpers.md) | 데이터 동기화 Helper (Data Sync Helpers) | 모든 데이터 동기화는 Service/Seeder 가 Helper 를 호출해 수행 (직... |
|
||||
| [enum.md](enum.md) | Enum 사용 규칙 | 상태/타입/분류 = Enum 필수 (PHP 8.1+ Backed Enum) |
|
||||
| [exceptions.md](exceptions.md) | Custom Exception 다국어 처리 | 예외 메시지 하드코딩 금지 → __() 함수 필수 |
|
||||
| [geoip.md](geoip.md) | GeoIP 시스템 (MaxMind GeoLite2) | MaxMind GeoLite2-City DB 기반 IP → 타임존 감지 (SetTim... |
|
||||
| [middleware.md](middleware.md) | 미들웨어 등록 규칙 | 인증 필요 미들웨어 → 전역 등록 금지! |
|
||||
| [notification-system.md](notification-system.md) | 알림 시스템 (Notification System) | 모든 알림은 BaseNotification 상속 필수 (via() 보일러플레이트 제거) |
|
||||
| [notification-system.md](notification-system.md) | 알림 시스템 (Notification System) | GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스... |
|
||||
| [response-helper.md](response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 |
|
||||
| [routing.md](routing.md) | 라우트 네이밍 및 경로 | 모든 라우트는 name() 필수: ->name('api.users.index') |
|
||||
| [search-system.md](search-system.md) | Scout 검색 엔진 시스템 (Search System) | Laravel Scout + DatabaseFulltextEngine: MySQL F... |
|
||||
| [seo-system.md](seo-system.md) | SEO 페이지 생성기 시스템 (SEO Page Generator) | SeoMiddleware: 봇 요청 감지 → ?locale= 파라미터 해석 → Seo... |
|
||||
| [service-provider.md](service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 |
|
||||
| [service-repository.md](service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) |
|
||||
| [user-overrides.md](user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array ... |
|
||||
| [validation.md](validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
|
||||
|
||||
<!-- AUTO-GENERATED-END: backend-readme-docs -->
|
||||
@@ -122,6 +125,13 @@ auth()->user();
|
||||
- ✅ 환경 검증 후 로직 실행
|
||||
- ✅ 인스톨러 안정성 확보
|
||||
|
||||
### 6. 외부 HTTP 호출
|
||||
|
||||
- ❌ `file_get_contents($url)` / `fopen($url, ...)` / `stream_context_create([...])` 로 원격 URL 직접 호출 금지
|
||||
- ✅ `Illuminate\Support\Facades\Http` (Laravel Http 파사드) 사용
|
||||
- ✅ GitHub 연동은 `App\Extension\Helpers\GithubHelper` 재사용
|
||||
- **이유**: 공유 호스팅은 `allow_url_fopen=Off` 설정인 경우가 많아 URL 스트림 래퍼 기반 호출이 전부 실패합니다. Http 파사드는 cURL 기반이라 해당 설정의 영향을 받지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## 빠른 참조
|
||||
|
||||
@@ -120,14 +120,6 @@ Service → doAction('hook.name') → ActivityLogListener → Log::channel('acti
|
||||
| `core.attachment.after_delete` | `handleAttachmentAfterDelete` | `attachment.delete` | Admin | Attachment |
|
||||
| `core.attachment.after_bulk_delete` | `handleAttachmentAfterBulkDelete` | `attachment.bulk_delete` | Admin | - |
|
||||
|
||||
### Mail Template (3훅, 스냅샷 포함)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.mail_template.before_update` | `captureMailTemplateSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `core.mail_template.after_update` | `handleMailTemplateAfterUpdate` | `mail_template.update` | Admin | MailTemplate |
|
||||
| `core.mail_template.after_toggle_active` | `handleMailTemplateAfterToggleActive` | `mail_template.toggle_active` | Admin | MailTemplate |
|
||||
|
||||
### Module (6훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|
||||
@@ -133,6 +133,23 @@ private function logActivity(string $action, array $context): void
|
||||
1. **ActivityLogProcessor**: 리스너에서 명시적으로 전달하지 않은 `user_id`, `ip_address`, `user_agent`를 자동 주입
|
||||
2. **ActivityLogHandler**: context에서 구조화 데이터를 추출하여 `ActivityLog::create()` 호출
|
||||
|
||||
### 큐 워커에서의 사용자 컨텍스트
|
||||
|
||||
훅 리스너가 큐로 디스패치되는 경우(기본 동작), 큐 워커는 별도 프로세스이므로 `Auth::user()` / `request()->ip()` 등이 모두 리셋됩니다. 이로 인해 활동로그 행위자가 "시스템"으로 잘못 기록될 수 있습니다.
|
||||
|
||||
`HookContextCapture`가 디스패치 시점에 다음 항목을 자동 캡처하여 워커에서 복원합니다:
|
||||
|
||||
| 항목 | 복원 효과 |
|
||||
| ---- | --------- |
|
||||
| `user_id` | 활동로그 actor가 실제 로그인 사용자로 정상 기록 |
|
||||
| `ip_address` / `user_agent` | Processor가 자동 주입하는 IP/UA가 원래 요청 값으로 정상 기록 |
|
||||
| `path` | `ResolvesActivityLogType::resolveLogType()`이 워커에서 정상 동작 (Admin/User/System 분류) |
|
||||
| `locale` | 다국어 메시지가 원래 요청 로케일로 발송 |
|
||||
|
||||
리스너 코드는 변경 불필요 — `Log::channel('activity')->info()` 호출만으로 동작합니다.
|
||||
|
||||
> 자세한 큐 컨텍스트 동작은 [extension/hooks.md "사용자 컨텍스트 자동 복원"](../extension/hooks.md) 참조
|
||||
|
||||
### Context 배열 구조
|
||||
|
||||
| 키 | 타입 | 필수 | 설명 |
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. BaseApiResource 상속 필수
|
||||
1. Resource: BaseApiResource 상속 필수 / Collection: BaseApiCollection 상속 필수
|
||||
2. 민감 정보 제외: created_by, updated_by, deleted_at
|
||||
3. 관계 데이터: whenLoaded() 사용
|
||||
4. when() 주의: 커스텀 메서드에서는 삼항 연산자 사용 (MissingValue 누출 방지)
|
||||
5. 권한 메타: ...$this->resourceMeta($request) 스프레드로 is_owner + abilities(can_*) 표준화
|
||||
5. 권한 메타 (Resource): ...$this->resourceMeta($request) 스프레드로 is_owner + abilities(can_*) 표준화
|
||||
6. 권한 메타 (Collection): abilityMap() 오버라이드 + resolveCollectionAbilities($request) — 페이지 버튼 제어
|
||||
```
|
||||
|
||||
---
|
||||
@@ -27,19 +28,30 @@
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### BaseApiResource 상속 필수
|
||||
### BaseApiResource / BaseApiCollection 상속 필수
|
||||
|
||||
모든 API 리소스는 `BaseApiResource`를 상속해야 합니다.
|
||||
모든 API 리소스는 `BaseApiResource`를, 모든 API 컬렉션은 `BaseApiCollection`을 상속해야 합니다.
|
||||
|
||||
```php
|
||||
// 단건 리소스
|
||||
use App\Http\Resources\BaseApiResource;
|
||||
|
||||
class ProductResource extends BaseApiResource
|
||||
{
|
||||
// ...
|
||||
}
|
||||
|
||||
// 목록 컬렉션
|
||||
use App\Http\Resources\BaseApiCollection;
|
||||
|
||||
class ProductCollection extends BaseApiCollection
|
||||
{
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
`BaseApiCollection`은 `HasRowNumber` trait과 `abilityMap()` + `resolveCollectionAbilities()` 메서드를 제공합니다. 컬렉션 레벨 abilities가 필요하면 `abilityMap()`을 오버라이드합니다.
|
||||
|
||||
### 민감한 정보 제외
|
||||
|
||||
보안을 위해 다음 필드는 API 응답에서 제외합니다:
|
||||
@@ -161,37 +173,40 @@ protected function resolveAbilities(Request $request): array
|
||||
}
|
||||
```
|
||||
|
||||
### HasAbilityCheck 트레이트
|
||||
### 컬렉션 레벨 abilities (BaseApiCollection)
|
||||
|
||||
`BaseApiResource`와 `ResourceCollection` 모두에서 권한 체크 로직을 공유하기 위한 트레이트입니다:
|
||||
페이지 레벨 버튼 제어용 abilities는 `BaseApiCollection`의 `abilityMap()`을 오버라이드합니다. Controller가 아닌 Collection에서 담당합니다:
|
||||
|
||||
```php
|
||||
use App\Http\Resources\Traits\HasAbilityCheck;
|
||||
use App\Http\Resources\BaseApiCollection;
|
||||
|
||||
// ResourceCollection에서 컬렉션 레벨 abilities 제공
|
||||
class BrandCollection extends ResourceCollection
|
||||
class BrandCollection extends BaseApiCollection
|
||||
{
|
||||
use HasAbilityCheck;
|
||||
|
||||
protected function abilityMap(): array
|
||||
{
|
||||
return [
|
||||
'can_create' => 'sirsoft-ecommerce.brands.create',
|
||||
'can_update' => 'sirsoft-ecommerce.brands.update',
|
||||
'can_delete' => 'sirsoft-ecommerce.brands.delete',
|
||||
];
|
||||
}
|
||||
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$abilities = $this->resolveCollectionAbilities($request);
|
||||
|
||||
return [
|
||||
'data' => BrandResource::collection($this->collection),
|
||||
'abilities' => $this->resolveAbilitiesFromMap($this->abilityMap(), $request->user()),
|
||||
'data' => $this->mapWithRowNumber(function ($brand) {
|
||||
return (new BrandResource($brand))->toArray(request());
|
||||
}),
|
||||
'pagination' => [ /* ... */ ],
|
||||
...($abilities ? ['abilities' => $abilities] : []),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
abilities가 불필요한 컬렉션은 `abilityMap()`을 오버라이드하지 않습니다 (기본값 빈 배열).
|
||||
|
||||
### 커스텀 메서드에서의 사용
|
||||
|
||||
`toListArray()` 등 커스텀 메서드에서도 동일하게 스프레드합니다:
|
||||
|
||||
+226
-102
@@ -9,9 +9,9 @@
|
||||
|
||||
```text
|
||||
1. Laravel Reverb 사용 (WebSocket)
|
||||
2. 이벤트: ShouldBroadcast 인터페이스 구현
|
||||
3. 채널: public, private, presence
|
||||
4. 훅 연동: HookManager에서 broadcast() 호출
|
||||
2. 브로드캐스트 필수: HookManager::broadcast($channel, $eventName, $payload) 사용
|
||||
3. 채널: public, private, presence (인증은 routes/channels.php)
|
||||
4. 개별 Event 클래스 직접 생성 금지 → HookManager::broadcast() 사용
|
||||
5. 클라이언트: Laravel Echo + Pusher-js
|
||||
```
|
||||
|
||||
@@ -103,64 +103,84 @@ VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||||
|
||||
> **주의**: 프로덕션 환경에서는 `REVERB_VERIFY_SSL=true`로 설정해야 합니다.
|
||||
|
||||
### 클라이언트/서버 endpoint 분리 (리버스 프록시 환경)
|
||||
|
||||
WebSocket은 두 가지 endpoint를 가집니다:
|
||||
|
||||
| Endpoint | 용도 | 사용 주체 | 예시 |
|
||||
|----------|------|---------|------|
|
||||
| 클라이언트 (외부) | 브라우저가 WebSocket 접속 | 브라우저 → Reverb (직접 또는 리버스 프록시 경유) | `g7.dev:443` (https) |
|
||||
| 서버 (내부) | 백엔드가 broadcast HTTP API 호출 | Pusher SDK → Reverb (Laravel queue worker 내부) | `127.0.0.1:8080` (http) |
|
||||
|
||||
**환경설정 → 드라이버 → 웹소켓**에서 두 endpoint를 분리 입력할 수 있습니다:
|
||||
|
||||
- 호스트/포트/프로토콜 (클라이언트) — 브라우저용 외부 endpoint
|
||||
- 서버 호스트/포트/프로토콜 (내부) — 백엔드 broadcast HTTP API용
|
||||
|
||||
서버 endpoint가 비어있으면 클라이언트 값으로 fallback (단일 호스트 환경 호환). 리버스 프록시 환경(예: Apache가 `/apps/*`를 Reverb로 프록시하지 못하는 경우)에서는 반드시 server endpoint를 `127.0.0.1:8080` 등 내부 직접 주소로 입력해야 합니다. 그렇지 않으면 Pusher SDK가 외부 host로 POST하여 Apache가 받아 `Method Not Allowed` 발생.
|
||||
|
||||
`SettingsServiceProvider::applyWebsocketConfig()`는 클라이언트 endpoint를 `g7.websocket.client.{host,port,scheme}` config 키에, 서버 endpoint를 `broadcasting.connections.reverb.options.*` 및 `reverb.apps.apps.0.options.*`에 분리 적용합니다. Blade(`admin.blade.php`/`app.blade.php`)는 `g7.websocket.client.*`를 우선 읽어 브라우저로 전달합니다.
|
||||
|
||||
### Settings 저장 시 큐 워커 재시작 자동화
|
||||
|
||||
`SettingsService::saveSettings()`는 drivers 탭 저장 시 자동으로 `Artisan::call('queue:restart')`를 호출합니다. 이는 long-running queue worker가 SettingsServiceProvider boot 시점의 config를 메모리에 캐싱하기 때문입니다. drivers 변경이 워커에 반영되려면 워커가 정상 종료 후 supervisor/스크립트로 재시작되어야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 브로드캐스트 이벤트 생성
|
||||
## HookManager::broadcast() API
|
||||
|
||||
### 기본 구조
|
||||
### 사용법
|
||||
|
||||
```php
|
||||
<?php
|
||||
use App\Extension\HookManager;
|
||||
|
||||
namespace App\Events\Dashboard;
|
||||
// 기본 사용법 — 채널, 이벤트명, 데이터
|
||||
HookManager::broadcast('core.admin.dashboard', 'dashboard.stats.updated', [
|
||||
'type' => 'stats',
|
||||
'data' => $statsData,
|
||||
]);
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 대시보드 업데이트 브로드캐스트 이벤트
|
||||
*/
|
||||
class DashboardUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 이벤트 인스턴스를 생성합니다.
|
||||
*
|
||||
* @param string $type 업데이트 타입 ('stats', 'resources', 'activities')
|
||||
* @param array $data 업데이트된 데이터
|
||||
*/
|
||||
public function __construct(
|
||||
public string $type,
|
||||
public array $data
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 브로드캐스트할 채널을 반환합니다.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('admin.dashboard')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 브로드캐스트 이벤트명을 반환합니다.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return "dashboard.{$this->type}.updated";
|
||||
}
|
||||
}
|
||||
// 사용자별 알림 브로드캐스트 — UUID 사용 (User ID 노출 방지, 보안 강화)
|
||||
HookManager::broadcast("core.user.notifications.{$user->uuid}", 'notification.received', [
|
||||
'subject' => '새 주문',
|
||||
'body' => '주문이 접수되었습니다.',
|
||||
'type' => 'order_created',
|
||||
]);
|
||||
```
|
||||
|
||||
### 파라미터
|
||||
|
||||
| 파라미터 | 타입 | 설명 | 예시 |
|
||||
|---------|------|------|------|
|
||||
| `$channel` | string | Private 채널명 | `'core.admin.dashboard'`, `'core.user.notifications.{uuid}'` |
|
||||
| `$eventName` | string | 클라이언트 수신 이벤트명 | `'dashboard.stats.updated'` |
|
||||
| `$payload` | array | 브로드캐스트 데이터 | `['type' => 'stats', 'data' => [...]]` |
|
||||
|
||||
### 절대 금지
|
||||
|
||||
```
|
||||
❌ broadcast(new SpecificEvent(...)) — 개별 Event 클래스 직접 생성/사용 금지
|
||||
❌ event(new SpecificEvent(...)) — event() 헬퍼 직접 사용 금지
|
||||
✅ HookManager::broadcast(...) — 유일한 브로드캐스트 방법
|
||||
```
|
||||
|
||||
내부적으로 `GenericBroadcastEvent`를 사용하지만, 이는 HookManager의 구현 디테일이므로 외부에서 직접 참조하지 않습니다.
|
||||
|
||||
### Graceful Skip (안전한 건너뛰기)
|
||||
|
||||
`HookManager::broadcast()`는 아래 조건에서 브로드캐스트를 **시도하지 않고 즉시 반환**합니다:
|
||||
|
||||
| 조건 | 동작 |
|
||||
|------|------|
|
||||
| 관리자 환경설정 → 드라이버 → 웹소켓 사용 OFF (`websocket_enabled=false`) | `SettingsServiceProvider::applyWebsocketConfig()`가 `broadcasting.default`를 `'null'`로 강제 → 즉시 return |
|
||||
| `BROADCAST_CONNECTION=null` 또는 `log` | 즉시 return (연결 시도 없음) |
|
||||
| 드라이버의 `host` 미설정 (예: `REVERB_HOST` 비어있음) | 즉시 return (연결 시도 없음) |
|
||||
| 설정 정상이나 서버 미실행 (cURL 연결 실패) | `Log::warning` 기록 후 return (예외 미전파) |
|
||||
|
||||
브로드캐스팅은 부가 기능이므로 실패해도 메인 작업(사용자 업데이트, 주문 처리, 알림 발송 등)을 중단시키지 않습니다. 개별 리스너에서 try-catch를 추가할 필요가 없습니다.
|
||||
|
||||
> **중요**: 웹소켓 OFF 시 `broadcasting.default`가 `'null'`로 강제되는 동작은 `.env`의 `BROADCAST_CONNECTION` 값을 무시하고 적용됩니다. 따라서 PO가 환경설정에서 OFF한 경우, `.env`에 `REVERB_HOST=localhost` 등이 남아 있어도 broadcast 시도가 발생하지 않습니다. 알림 시스템(mail/database 채널)은 이 설정과 독립적으로 정상 동작합니다.
|
||||
|
||||
### 채널 타입
|
||||
|
||||
| 타입 | 클래스 | 용도 | 인증 필요 |
|
||||
@@ -169,17 +189,96 @@ class DashboardUpdated implements ShouldBroadcast
|
||||
| Private | `PrivateChannel` | 인증된 사용자만 접근 | ✅ |
|
||||
| Presence | `PresenceChannel` | 인증 + 접속자 목록 공유 | ✅ |
|
||||
|
||||
### 이벤트 발생
|
||||
현재 `HookManager::broadcast()`는 Private 채널만 지원합니다. Public/Presence 채널이 필요한 경우 HookManager 확장이 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
## 채널 네이밍 규칙
|
||||
|
||||
코어와 확장(모듈/플러그인) 간 채널명 충돌을 방지하기 위한 필수 컨벤션:
|
||||
|
||||
| 소스 | 패턴 | 예시 |
|
||||
|------|------|------|
|
||||
| 코어 | `core.*` | `core.admin.dashboard`, `core.user.notifications.{id}` |
|
||||
| 모듈 | `module.{identifier}.*` | `module.sirsoft-ecommerce.orders.{id}` |
|
||||
| 플러그인 | `plugin.{identifier}.*` | `plugin.sirsoft-payment.status.{id}` |
|
||||
|
||||
---
|
||||
|
||||
## 모듈/플러그인 채널 등록
|
||||
|
||||
모듈/플러그인에서 WebSocket 채널이 필요한 경우 `getChannels()` 메서드를 오버라이드합니다.
|
||||
`ModuleManager`/`PluginManager`가 로드 시 자동으로 `Broadcast::channel()`에 등록합니다.
|
||||
|
||||
### 모듈 예시
|
||||
|
||||
```php
|
||||
// 방법 1: broadcast() 헬퍼 (권장)
|
||||
broadcast(new DashboardUpdated('stats', $statsData));
|
||||
// modules/sirsoft-ecommerce/src/module.php
|
||||
class EcommerceModule extends AbstractModule
|
||||
{
|
||||
public function getChannels(): array
|
||||
{
|
||||
return [
|
||||
'module.sirsoft-ecommerce.orders.{id}' => [
|
||||
'permission' => 'sirsoft-ecommerce.orders.read',
|
||||
],
|
||||
'module.sirsoft-ecommerce.cart.{cartKey}' => [
|
||||
// permission 없음 → 인증만 필요
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
// 방법 2: event() 헬퍼
|
||||
event(new DashboardUpdated('resources', $resourceData));
|
||||
### 플러그인 예시
|
||||
|
||||
// 방법 3: 즉시 전송 (큐 미사용)
|
||||
broadcast(new DashboardUpdated('alerts', $alertData))->toOthers();
|
||||
```php
|
||||
// plugins/sirsoft-payment/src/plugin.php
|
||||
class PaymentPlugin extends AbstractPlugin
|
||||
{
|
||||
public function getChannels(): array
|
||||
{
|
||||
return [
|
||||
'plugin.sirsoft-payment.status.{id}' => [
|
||||
'permission' => 'sirsoft-payment.payments.read',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 채널 정의 형식
|
||||
|
||||
| 키 | 타입 | 설명 | 기본값 |
|
||||
|-----|------|------|--------|
|
||||
| `permission` | `string\|null` | 권한 식별자 (`hasPermission()` 체크) | `null` (인증만) |
|
||||
| `type` | `string` | 채널 타입 (`private`) | `private` |
|
||||
|
||||
### 프론트엔드에서 수신
|
||||
|
||||
모듈 레이아웃에서 WebSocket 데이터소스를 정의합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "order_updates_ws",
|
||||
"type": "websocket",
|
||||
"channel": "module.sirsoft-ecommerce.orders.{{_global.currentUser?.id}}",
|
||||
"event": "order.updated",
|
||||
"channel_type": "private",
|
||||
"target_source": "orders"
|
||||
}
|
||||
```
|
||||
|
||||
### 모듈에서 브로드캐스트 전송
|
||||
|
||||
훅 리스너에서 `HookManager::broadcast()`를 호출합니다:
|
||||
|
||||
```php
|
||||
HookManager::broadcast(
|
||||
"module.sirsoft-ecommerce.orders.{$userId}",
|
||||
'order.updated',
|
||||
['order_id' => $order->id, 'status' => $order->status]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
@@ -193,19 +292,40 @@ broadcast(new DashboardUpdated('alerts', $alertData))->toOthers();
|
||||
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
|
||||
// 사용자별 Private 채널
|
||||
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
|
||||
return (int) $user->id === (int) $id;
|
||||
// 사용자별 Private 채널 — UUID 사용 (User ID 노출 방지)
|
||||
Broadcast::channel('core.user.notifications.{uuid}', function ($user, $uuid) {
|
||||
return $user->uuid === $uuid && $user->hasPermission('core.user-notifications.read', \App\Enums\PermissionType::User);
|
||||
});
|
||||
|
||||
// 관리자 대시보드 채널 - 권한 체크
|
||||
Broadcast::channel('admin.dashboard', function ($user) {
|
||||
Broadcast::channel('core.admin.dashboard', function ($user) {
|
||||
return $user->hasPermission('core.dashboard.read');
|
||||
});
|
||||
|
||||
// 특정 리소스 채널
|
||||
Broadcast::channel('orders.{orderId}', function ($user, $orderId) {
|
||||
return $user->hasPermission('ecommerce.orders.read');
|
||||
// 모듈 리소스 채널 (모듈의 getChannels()로 자동 등록 — 참고용 예시)
|
||||
// Broadcast::channel('module.sirsoft-ecommerce.orders.{orderId}', function ($user, $orderId) {
|
||||
// return $user->hasPermission('sirsoft-ecommerce.orders.read');
|
||||
// });
|
||||
```
|
||||
|
||||
### 사용자별 채널은 UUID 사용 (보안)
|
||||
|
||||
```php
|
||||
// ✅ DO: UUID 기반 — 다른 사용자 채널 추측 사실상 불가능
|
||||
Broadcast::channel('core.user.notifications.{uuid}', function ($user, $uuid) {
|
||||
return $user->uuid === $uuid && $user->hasPermission('core.user-notifications.read', \App\Enums\PermissionType::User);
|
||||
});
|
||||
|
||||
// 백엔드 broadcast 시
|
||||
HookManager::broadcast(
|
||||
"core.user.notifications.{$user->uuid}",
|
||||
'notification.received',
|
||||
['subject' => '...', 'body' => '...']
|
||||
);
|
||||
|
||||
// ❌ DON'T: 정수 ID — 1, 2, 3... 순차 ID 노출로 채널 추측 가능
|
||||
Broadcast::channel('core.user.notifications.{id}', function ($user, $id) {
|
||||
return (int) $user->id === (int) $id;
|
||||
});
|
||||
```
|
||||
|
||||
@@ -213,7 +333,7 @@ Broadcast::channel('orders.{orderId}', function ($user, $orderId) {
|
||||
|
||||
```php
|
||||
// ✅ DO: boolean 반환 (Private 채널)
|
||||
Broadcast::channel('admin.dashboard', function ($user) {
|
||||
Broadcast::channel('core.admin.dashboard', function ($user) {
|
||||
return $user->hasPermission('core.dashboard.read');
|
||||
});
|
||||
|
||||
@@ -226,7 +346,7 @@ Broadcast::channel('chat.{roomId}', function ($user, $roomId) {
|
||||
});
|
||||
|
||||
// ❌ DON'T: 예외 던지기
|
||||
Broadcast::channel('admin.dashboard', function ($user) {
|
||||
Broadcast::channel('core.admin.dashboard', function ($user) {
|
||||
throw new \Exception('Unauthorized'); // 금지
|
||||
});
|
||||
```
|
||||
@@ -263,58 +383,60 @@ const pusherOptions = {
|
||||
|
||||
---
|
||||
|
||||
## 훅을 통한 이벤트 발생
|
||||
## 훅을 통한 브로드캐스트 발생
|
||||
|
||||
Service 계층에서 데이터 변경 시 훅 리스너를 통해 브로드캐스트 이벤트를 발생시킵니다.
|
||||
Service 계층에서 데이터 변경 시 훅 리스너를 통해 `HookManager::broadcast()`를 호출합니다.
|
||||
|
||||
### config/hooks.php
|
||||
|
||||
```php
|
||||
return [
|
||||
'listeners' => [
|
||||
// 대시보드 통계 업데이트 시 브로드캐스트
|
||||
'core.dashboard.stats_updated' => [
|
||||
\App\Listeners\BroadcastDashboardStats::class,
|
||||
],
|
||||
|
||||
// 사용자 생성 시 대시보드 통계 업데이트
|
||||
'core.user.after_create' => [
|
||||
\App\Listeners\UpdateDashboardStatsOnUserChange::class,
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### 리스너 구현
|
||||
### 리스너 구현 패턴
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
namespace App\Listeners\Dashboard;
|
||||
|
||||
use App\Events\Dashboard\DashboardUpdated;
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\DashboardService;
|
||||
|
||||
class BroadcastDashboardStats
|
||||
class DashboardStatsListener implements HookListenerInterface
|
||||
{
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.user.after_create' => ['method' => 'handleStatsUpdate', 'priority' => 10],
|
||||
'core.user.after_update' => ['method' => 'handleStatsUpdate', 'priority' => 10],
|
||||
];
|
||||
}
|
||||
|
||||
public function __construct(
|
||||
private DashboardService $dashboardService
|
||||
) {}
|
||||
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* 대시보드 통계 브로드캐스트
|
||||
*
|
||||
* @param array $data 훅 데이터
|
||||
* @return void
|
||||
* 대시보드 통계 업데이트를 브로드캐스트합니다.
|
||||
*/
|
||||
public function handle(array $data): void
|
||||
public function handleStatsUpdate(...$args): void
|
||||
{
|
||||
$stats = $this->dashboardService->getStats();
|
||||
broadcast(new DashboardUpdated('stats', $stats));
|
||||
HookManager::broadcast('core.admin.dashboard', 'dashboard.stats.updated', [
|
||||
'type' => 'stats',
|
||||
'data' => $this->dashboardService->getStats(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 큐 워커에서의 사용자 컨텍스트
|
||||
|
||||
훅 리스너는 기본적으로 큐로 디스패치되며, 큐 워커는 별도 프로세스이므로 `Auth::user()`/`request()->ip()`/`App::getLocale()`이 모두 리셋됩니다. 그러나 G7은 디스패치 시점의 컨텍스트를 자동 캡처/복원하므로 워커에서도 평소처럼 사용할 수 있습니다:
|
||||
|
||||
- broadcast 페이로드에 `Auth::user()->name` 같은 사용자 정보를 포함시켜도 안전
|
||||
- 활동로그/알림 발송 시 행위자가 실제 로그인 사용자로 정상 기록
|
||||
- 다국어 메시지가 원래 요청 로케일로 발송
|
||||
|
||||
> 자세한 동작과 플러그인 확장 방법은 [extension/hooks.md "사용자 컨텍스트 자동 복원"](../extension/hooks.md) 참조
|
||||
|
||||
---
|
||||
|
||||
## 스케줄러를 통한 주기적 브로드캐스트
|
||||
@@ -328,7 +450,7 @@ class BroadcastDashboardStats
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\Dashboard\DashboardUpdated;
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\DashboardService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@@ -339,8 +461,10 @@ class BroadcastDashboardResources extends Command
|
||||
|
||||
public function handle(DashboardService $dashboardService): int
|
||||
{
|
||||
$resources = $dashboardService->getSystemResources();
|
||||
broadcast(new DashboardUpdated('resources', $resources));
|
||||
HookManager::broadcast('core.admin.dashboard', 'dashboard.resources.updated', [
|
||||
'type' => 'resources',
|
||||
'data' => $dashboardService->getSystemResources(),
|
||||
]);
|
||||
|
||||
$this->info('시스템 리소스 정보가 브로드캐스트되었습니다.');
|
||||
return Command::SUCCESS;
|
||||
|
||||
+102
-28
@@ -22,7 +22,6 @@
|
||||
- [permissions — 권한 정의](#permissions--권한-정의)
|
||||
- [roles — 역할 정의](#roles--역할-정의)
|
||||
- [menus — 메뉴 정의](#menus--메뉴-정의)
|
||||
- [mail_templates — 메일 템플릿 정의](#mail_templates--메일-템플릿-정의)
|
||||
- [사용처](#사용처)
|
||||
- [관련 문서](#관련-문서)
|
||||
|
||||
@@ -30,17 +29,18 @@
|
||||
|
||||
## 파일 구조 개요
|
||||
|
||||
`config/core.php`는 4개 최상위 키로 구성됩니다:
|
||||
`config/core.php`는 3개 최상위 키로 구성됩니다:
|
||||
|
||||
```php
|
||||
return [
|
||||
'permissions' => [...], // 코어 권한 정의
|
||||
'roles' => [...], // 코어 역할 정의
|
||||
'menus' => [...], // 코어 메뉴 정의
|
||||
'mail_templates' => [...], // 코어 메일 템플릿 정의
|
||||
];
|
||||
```
|
||||
|
||||
> **변경 이력 (7.0.0-beta.2)**: `mail_templates` 키는 알림 시스템 통합(#146)으로 제거되었습니다. 코어 메일 템플릿은 `notification_definitions` + `notification_templates` 로 이전되었으며, `database/seeders/NotificationDefinitionSeeder` 가 시드 데이터를 직접 보유합니다.
|
||||
|
||||
---
|
||||
|
||||
## permissions — 권한 정의
|
||||
@@ -76,12 +76,14 @@ permissions
|
||||
'description' => [...],
|
||||
'category' => 'users', // 카테고리 슬러그
|
||||
'order' => 1,
|
||||
'type' => 'admin', // 카테고리 타입 (선택, 미지정 시 하위 권한에서 추론)
|
||||
'permissions' => [
|
||||
[
|
||||
'identifier' => 'core.users.read',
|
||||
'name' => ['ko' => '사용자 조회', 'en' => 'View Users'],
|
||||
'description' => [...],
|
||||
'order' => 1,
|
||||
'type' => 'admin', // 권한 타입 (선택, 미지정 시 카테고리 타입 상속)
|
||||
'resource_route_key' => 'user', // scope 체크 시 라우트 모델 키 (선택)
|
||||
'owner_key' => 'id', // scope 체크 시 소유자 필드 (선택)
|
||||
],
|
||||
@@ -98,9 +100,55 @@ permissions
|
||||
| `name` | ✅ | 다국어 이름 배열 `['ko' => ..., 'en' => ...]` |
|
||||
| `description` | ✅ | 다국어 설명 배열 |
|
||||
| `order` | ✅ | 정렬 순서 |
|
||||
| `type` | ❌ | 권한 컨텍스트 (`admin` / `user`). 미지정 시 카테고리 타입 상속, 카테고리도 미지정이면 `admin` 기본값 |
|
||||
| `resource_route_key` | ❌ | scope 기반 접근 체크 시 라우트 모델 바인딩 키 |
|
||||
| `owner_key` | ❌ | scope 기반 접근 체크 시 소유자 판단 필드 |
|
||||
|
||||
### type 필드 우선순위 (카테고리/권한 동기화 규칙)
|
||||
|
||||
`CoreUpdateService::syncCoreRolesAndPermissions()`는 다음 우선순위로 type을 결정합니다:
|
||||
|
||||
1. **명시 우선**: 카테고리/권한에 `type` 필드가 명시되어 있으면 그 값 사용
|
||||
2. **하위 추론**: 카테고리에 type 미지정 + 모든 하위 권한이 동일 type → 그 type을 카테고리에도 적용
|
||||
3. **기본값**: 그 외 → `admin`
|
||||
|
||||
권한 레벨 type이 미지정이면 카테고리 type을 상속합니다. (`ModuleManager::syncPermissions()`와 동일 패턴)
|
||||
|
||||
### admin/user 권한 분리 사례
|
||||
|
||||
같은 도메인이지만 관리자와 사용자 컨텍스트에서 모두 사용해야 한다면 **별도 식별자**로 분리합니다:
|
||||
|
||||
```php
|
||||
// 관리자가 시스템 전체 알림을 관리 (관리자 화면)
|
||||
[
|
||||
'identifier' => 'core.notifications',
|
||||
'name' => ['ko' => '알림 (관리자)', 'en' => 'Notifications (Admin)'],
|
||||
'type' => 'admin',
|
||||
'permissions' => [
|
||||
['identifier' => 'core.notifications.read', 'type' => 'admin', ...],
|
||||
['identifier' => 'core.notifications.update', 'type' => 'admin', ...],
|
||||
['identifier' => 'core.notifications.delete', 'type' => 'admin', ...],
|
||||
],
|
||||
],
|
||||
// 사용자가 본인의 알림을 관리 (사용자 화면)
|
||||
[
|
||||
'identifier' => 'core.user-notifications',
|
||||
'name' => ['ko' => '알림 (사용자)', 'en' => 'Notifications (User)'],
|
||||
'type' => 'user',
|
||||
'permissions' => [
|
||||
['identifier' => 'core.user-notifications.read', 'type' => 'user', ...],
|
||||
['identifier' => 'core.user-notifications.update', 'type' => 'user', ...],
|
||||
['identifier' => 'core.user-notifications.delete', 'type' => 'user', ...],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
```text
|
||||
⚠️ 함정 경고: permissions.identifier에 단일 unique 제약이 있어 같은 식별자로 admin/user 두 행을 동시에 만들 수 없습니다.
|
||||
✅ 사용자 컨텍스트 권한이 필요하면 반드시 별도 식별자(예: core.user-notifications.*)를 사용하세요.
|
||||
✅ 라우트 미들웨어 permission:user,xxx 는 type=user 권한 행이 존재해야만 통과합니다 (식별자 + type 모두 매칭).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## roles — 역할 정의
|
||||
@@ -157,45 +205,71 @@ permissions
|
||||
|
||||
---
|
||||
|
||||
## mail_templates — 메일 템플릿 정의
|
||||
|
||||
```php
|
||||
'mail_templates' => [
|
||||
[
|
||||
'type' => 'welcome',
|
||||
'subject' => ['ko' => '[{app_name}] 환영합니다', 'en' => '...'],
|
||||
'body' => ['ko' => '<h1>{name}님...</h1>', 'en' => '...'],
|
||||
'variables' => [
|
||||
['key' => 'name', 'description' => '수신자 이름'],
|
||||
['key' => 'app_name', 'description' => '사이트명'],
|
||||
],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
### variables 배열
|
||||
|
||||
메일 템플릿에서 사용 가능한 치환 변수를 정의합니다. `{key}` 형태로 subject/body에서 사용됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 사용처
|
||||
|
||||
| 사용처 | 시점 | 설명 |
|
||||
| ---------- | ---------- | ---------- |
|
||||
| `RolePermissionSeeder` | 초기 설치 | permissions, roles 데이터 시딩 |
|
||||
| `CoreAdminMenuSeeder` | 초기 설치 | menus 데이터 시딩 |
|
||||
| `MailTemplateSeeder` | 초기 설치 | mail_templates 데이터 시딩 |
|
||||
| `CoreUpdateService::syncCoreRolesAndPermissions()` | 코어 업데이트 | 권한/역할 동기화 |
|
||||
| `CoreUpdateService::syncCoreMenus()` | 코어 업데이트 | 메뉴 동기화 |
|
||||
| `CoreUpdateService::syncCoreMailTemplates()` | 코어 업데이트 | 메일 템플릿 동기화 |
|
||||
|
||||
**핵심 원칙**: 새 권한/역할/메뉴/메일템플릿 추가 시 이 파일에 추가하면 설치/업데이트 모두 자동 반영됩니다.
|
||||
**핵심 원칙**: 새 권한/역할/메뉴 추가 시 이 파일에 추가하면 설치/업데이트 모두 자동 반영됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 완전 동기화 원칙
|
||||
|
||||
`config/core.php` 는 **코어 권한·역할·메뉴의 SSoT** 입니다. 업데이트 시 sync 는 단순 upsert 가 아니라 **완전 동기화** 를 수행합니다.
|
||||
|
||||
### 4단계 패턴
|
||||
|
||||
| 단계 | 설명 | 책임 |
|
||||
|---|---|---|
|
||||
| 1. **Upsert** | config → DB (신규 생성 or user_overrides 보존 업데이트) | `syncMenu`/`syncRole`/`syncPermission` |
|
||||
| 2. **Orphan Delete** | config 에 없는 DB row 삭제 (user_overrides 무관) | `cleanupStaleMenus`/`cleanupStaleRoles`/`cleanupStalePermissions` |
|
||||
| 3. **Mapping Diff** | role_permissions 등 관계 테이블 재정렬 | `syncAllRoleAssignments` |
|
||||
| 4. **Dependent Cleanup** | FK cascade 또는 명시적 정리 | Service 책임 |
|
||||
|
||||
### 중요한 의미 구분
|
||||
|
||||
- **row 존재 여부**: config 기준으로만 결정 — user_overrides 무관 (제거된 건 삭제)
|
||||
- **필드 값 (유지 row)**: user_overrides 에 등록된 필드만 보존, 나머지는 갱신
|
||||
|
||||
즉 "사용자가 수정한 row 이니 보존" 이 아닙니다. "config 에 있어야 유지되고, 유지되는 row 중 사용자가 수정한 **필드만** 보존" 입니다.
|
||||
|
||||
### 적용 지점
|
||||
|
||||
| sync 메서드 | stale cleanup 호출 |
|
||||
|---|---|
|
||||
| `CoreUpdateService::syncCoreMenus` | 말미에 `cleanupStaleMenus(Core, 'core', $currentSlugs)` |
|
||||
| `CoreUpdateService::syncCoreRolesAndPermissions` | 말미에 `cleanupStalePermissions` 및 role identifier 기준 `cleanupStaleRoles` |
|
||||
| `CoreUpdateService::syncAllRoleAssignments` | `core/core` 소유 권한 **전체** 를 diff 기준으로 사용 (이관된 구 식별자도 detach) |
|
||||
| 확장 `ModuleManager::updateModule` / `PluginManager::updatePlugin` | 동일 4단계 (확장별 식별자 범위) |
|
||||
|
||||
### 예외 안전장치
|
||||
|
||||
- `cleanupStaleRoles`: 삭제 대상 역할을 참조하는 `user_roles` 피벗 row 가 있으면 **삭제 차단 + 경고 로그** (사용자 수동 재배정 유도)
|
||||
- FK cascade: `NotificationDefinition` 삭제 시 `NotificationTemplate` 자동 cascade
|
||||
|
||||
### Helper 사용 (권장)
|
||||
|
||||
이 원칙은 각 helper 에 캡슐화되어 있습니다. Service/Seeder 에서 직접 Model 조작 금지. 자세한 사용법은 [data-sync-helpers.md](data-sync-helpers.md) 참조.
|
||||
|
||||
```php
|
||||
// 올바른 패턴 (CoreUpdateService::syncCoreMenus 말미)
|
||||
$currentSlugs = $menuSyncHelper->collectSlugsRecursive($coreMenus);
|
||||
$menuSyncHelper->cleanupStaleMenus(ExtensionOwnerType::Core, 'core', $currentSlugs);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [permissions.md](../extension/permissions.md) - 권한 시스템 상세
|
||||
- [menus.md](../extension/menus.md) - 메뉴 시스템 상세
|
||||
- [module-basics.md](../extension/module-basics.md) - 모듈별 config.php 구조
|
||||
- [service-provider.md](service-provider.md) - 서비스 프로바이더에서 config 로드
|
||||
- [data-sync-helpers.md](data-sync-helpers.md) - 데이터 동기화 Helper 5종
|
||||
- [user-overrides.md](user-overrides.md) - 사용자 수정 보존 (HasUserOverrides)
|
||||
- [upgrade-step-guide.md](../extension/upgrade-step-guide.md) - 업그레이드 스텝 작성
|
||||
@@ -14,6 +14,37 @@
|
||||
|
||||
---
|
||||
|
||||
## 실행 환경 전제 (CRITICAL)
|
||||
|
||||
```text
|
||||
⚠️ core:update 는 SSH/CLI 환경에서 코어 파일 소유자(보통 FTP/SSH 사용자)가
|
||||
직접 실행한다고 가정합니다.
|
||||
```
|
||||
|
||||
### 권한 모델
|
||||
|
||||
| 실행 환경 | 권한 가정 | 권장 사용 |
|
||||
|----------|----------|----------|
|
||||
| **SSH/CLI 직접 실행** | 사용자 = 파일 소유자 → 모든 코어 파일 쓰기 가능 | ✅ 정상 사용 (권장) |
|
||||
| **웹 인스톨러 (`public/install/`)** | PHP-FPM 사용자 (www-data 등) — 권한 제한적 | ⚠️ vendor 번들 모드로만 신규 설치 가능. 코어 업데이트는 미지원 |
|
||||
| **www-data 로 CLI 실행** | 코어 파일 소유자와 다를 수 있음 | ⚠️ 비권장 — 권한 거부 가능 |
|
||||
|
||||
### 권한 거부 발생 시 대처
|
||||
|
||||
`core:update` 시작 시 현재 실행 사용자 UID와 코어 파일(`composer.json`) 소유자 UID를 비교하여 불일치 시 경고 로그를 남깁니다. 다음과 같은 경우 발생할 수 있습니다:
|
||||
|
||||
1. **vendor/ 가 다른 사용자 소유**: 과거에 웹 인스톨러로 설치되어 vendor/ 가 www-data 소유인 상태에서 SSH 사용자로 `core:update` 실행
|
||||
- **해결**: `chown -R $(whoami) vendor/` 후 재시도
|
||||
|
||||
2. **코어 파일이 다른 사용자 소유**: FTP 사용자와 SSH 사용자가 다른 환경
|
||||
- **해결**: 호스팅 제공자에게 권한 정리 요청 또는 수동 업데이트 수행
|
||||
|
||||
### 웹 인스톨러는 별개
|
||||
|
||||
웹 인스톨러(`public/install/`)는 PHP-FPM 사용자로 실행되므로 권한 제약이 큽니다. vendor 번들 시스템(244 이슈)으로 신규 설치 시점의 vendor/ 권한 이슈는 해소되었으나, **코어 업데이트는 웹에서 지원하지 않으며 SSH/CLI 로만 수행해야 합니다**.
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [업데이트 감지](#1-업데이트-감지)
|
||||
@@ -52,6 +83,8 @@
|
||||
- config('app.update.github_url') 에서 리포지토리 URL 읽기
|
||||
- config('app.update.github_token') 으로 인증 (선택)
|
||||
- GitHub Releases API 호출 → 최신 릴리스 태그에서 버전 추출
|
||||
- 모든 원격 HTTP 호출은 GithubHelper (Laravel Http 파사드 기반) 사용
|
||||
→ file_get_contents + stream_context_create 금지 (allow_url_fopen=Off 환경 대응)
|
||||
```
|
||||
|
||||
### 감지 결과
|
||||
@@ -91,8 +124,12 @@
|
||||
|------|------|
|
||||
| 일반 | `checkForUpdates()` → GitHub API 호출 → 사용자 확인 프롬프트 |
|
||||
| `--source={path}` | 지정된 디렉토리를 소스로 사용 (GitHub 스킵) |
|
||||
| `--zip={path}` | 지정된 ZIP 파일을 추출하여 소스로 사용 (GitHub 스킵). 추출 후 `config/app.php` 에서 버전 자동 판별. ZIP 구조가 GitHub zipball 의 `owner-repo-hash/` 래퍼이든 평탄 루트이든 모두 지원 |
|
||||
| `--local` | 현재 코드베이스를 소스로 사용 (GitHub 스킵) |
|
||||
| `--force` | 버전 비교 스킵, 동일 버전이어도 강제 실행 |
|
||||
| `--vendor-mode=auto\|composer\|bundled` | vendor 설치 모드 지정 (기본 `auto` — composer 가능 시 composer, 불가 시 vendor-bundle.zip 추출). `bundled` 강제 시 공유 호스팅에서도 설치 가능 |
|
||||
|
||||
> `--source` / `--zip` / `--local` 은 **상호 배타적**입니다. 동시 지정 시 커맨드는 시작 전에 FAILURE(1) 로 종료됩니다.
|
||||
|
||||
### Step 4: 다운로드 및 추출
|
||||
|
||||
@@ -117,15 +154,30 @@ v접두사 자동 감지 (resolveGithubArchiveUrl):
|
||||
- 백업 위치: storage 경로에 타임스탬프 디렉토리
|
||||
```
|
||||
|
||||
### Step 6: Composer 최적화
|
||||
### Step 6: Vendor 설치 (Composer 또는 Bundled)
|
||||
|
||||
`CoreUpdateService::runVendorInstallInPending()` 가 `VendorResolver` 경유로 모드에 따라 분기합니다.
|
||||
|
||||
```text
|
||||
1. composer.json + composer.lock의 MD5 비교 (_pending vs base_path)
|
||||
2. 동일 → "composer 의존성 변경 없음 — 스킵" (Step 6 + Step 8 모두 스킵)
|
||||
3. 변경됨 → composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
||||
1. --vendor-mode 결정:
|
||||
- composer 명시 / bundled 명시 → 그대로 사용
|
||||
- auto (기본) → EnvironmentDetector 로 composer 실행 가능 여부 자동 감지
|
||||
|
||||
2. Composer 모드 (기존 흐름):
|
||||
- composer.json + composer.lock 의 MD5 비교 (_pending vs base_path)
|
||||
- 동일 → "composer 의존성 변경 없음 — 스킵" (Step 6 + Step 8 모두 스킵)
|
||||
- 변경됨 → composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
||||
|
||||
3. Bundled 모드 (신규, 공유 호스팅 대응):
|
||||
- _pending/vendor-bundle.zip 무결성 검증 (SHA256)
|
||||
- VendorBundleInstaller::install() 로 zip 추출 → _pending/vendor/ 생성
|
||||
- 기존 vendor/ 를 vendor.old.{timestamp} 로 rename 후 추출 → 실패 시 복구
|
||||
|
||||
4. --no-scripts: post-autoload-dump 방지 (Step 11에서 package:discover로 처리)
|
||||
```
|
||||
|
||||
> Vendor 번들 시스템 상세: [docs/extension/vendor-bundle.md](../extension/vendor-bundle.md)
|
||||
|
||||
### Step 7: 파일 적용
|
||||
|
||||
```text
|
||||
@@ -140,9 +192,10 @@ v접두사 자동 감지 (resolveGithubArchiveUrl):
|
||||
1. php artisan migrate --force
|
||||
2. syncCoreRolesAndPermissions() — config/core.php의 roles/permissions 동기화
|
||||
3. syncCoreMenus() — config/core.php의 menus 동기화
|
||||
4. syncCoreMailTemplates() — config/core.php의 mail_templates 동기화
|
||||
```
|
||||
|
||||
> **변경 이력 (7.0.0-beta.2)**: `syncCoreMailTemplates()` 단계는 알림 시스템 통합(#146)으로 제거되었습니다. 메일 템플릿은 `notification_definitions` + `notification_templates` 로 통합되었으며, `Upgrade_7_0_0_beta_2` 가 운영 환경 데이터 이관과 알림 정의 시드를 처리합니다.
|
||||
|
||||
> 역할/권한/메뉴 동기화는 `user_overrides` 패턴을 사용하여 사용자 커스터마이징 보존.
|
||||
> 상세: [extension-update-system.md § 13](../extension/extension-update-system.md#13-역할권한메뉴-동기화)
|
||||
|
||||
@@ -169,6 +222,26 @@ v접두사 자동 감지 (resolveGithubArchiveUrl):
|
||||
8. 유지보수 모드 해제
|
||||
```
|
||||
|
||||
### Step 12: _bundled 확장 일괄 업데이트 프롬프트 (인터랙티브)
|
||||
|
||||
> Trait: `App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt`
|
||||
|
||||
코어 업데이트 완료 후, 번들(`_bundled/`) 에 설치된 확장보다 새 버전이 포함된 경우 일괄 업데이트를 제안합니다.
|
||||
|
||||
```text
|
||||
1. checkAllModulesForUpdates() / checkAllPluginsForUpdates() / checkAllTemplatesForUpdates() 호출
|
||||
2. update_source === 'bundled' 항목만 수집
|
||||
3. 감지 결과 없으면 "활성 확장이 최신 번들과 일치합니다" 출력 후 종료
|
||||
4. 감지 결과 있으면 목록 표시 + 일괄 업데이트 여부 확인 (기본값 yes)
|
||||
5. 동의 시:
|
||||
- 전역 레이아웃 전략 선택 (overwrite | keep) ※ 섹션 10 참조
|
||||
- 예외 확장 지정 여부 확인, yes 이면 다중 선택으로 전략 오버라이드
|
||||
- 각 확장을 순차 업데이트 (실패 시 해당 확장만 warn, 나머지 계속 진행)
|
||||
6. 결과 요약 출력 (성공/실패 건수)
|
||||
|
||||
--force 플래그가 코어 업데이트에 지정된 경우: 프롬프트 스킵 + 전역 overwrite 자동 적용 (CI 대응)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 백업 및 롤백
|
||||
@@ -331,15 +404,27 @@ class Upgrade_7_0_0_beta_15 implements UpgradeStepInterface
|
||||
|
||||
```text
|
||||
□ UpgradeStepInterface 구현
|
||||
□ 네임스페이스: App\Upgrades
|
||||
□ 네임스페이스: App\Upgrades (코어) / Modules\Vendor\Module\Upgrades (모듈)
|
||||
□ 파일명: Upgrade_X_Y_Z.php (버전에 맞는 언더스코어 표기)
|
||||
□ 사전 조건 확인 (테이블/컬럼 존재 여부)
|
||||
□ 로거를 통한 진행 상황 기록
|
||||
□ 멱등성 보장 (여러 번 실행해도 안전)
|
||||
□ 멱등성 보장 — 모든 DB 조작에 방어 로직 필수 (아래 참조)
|
||||
□ 대량 데이터는 chunk() 사용
|
||||
□ raw SQL 사용 시 $context->table()로 테이블 프리픽스 적용
|
||||
```
|
||||
|
||||
### 멱등성 방어 로직 (필수)
|
||||
|
||||
업그레이드 스텝은 재실행될 수 있다 (롤백 후 재시도, `--force` 등). 모든 DB 조작은 이미 완료된 상태에서 재실행해도 안전해야 한다.
|
||||
|
||||
| 조작 유형 | 방어 패턴 | 비고 |
|
||||
|----------|----------|------|
|
||||
| 레코드 삽입 | `firstOrCreate` 또는 `updateOrCreate` | `insert` 단독 사용 금지 |
|
||||
| 대량 데이터 이관 | 이관 완료 마커 확인 후 스킵 | 예: `source` 컬럼에 마커 기록 → `exists()` 체크 |
|
||||
| 식별자 rename (unique 컬럼) | 신규 존재 여부 확인 후 분기 | 양쪽 다 존재하면 역할 이관 후 구 레코드 삭제 |
|
||||
| 코드/상태값 변환 (update WHERE) | WHERE 조건이 이미 변환된 건을 제외 | 대부분 자동 방어됨 |
|
||||
| 캐시 무효화 | 항상 안전 | — |
|
||||
|
||||
---
|
||||
|
||||
## 6. 자동 발견 및 실행 규칙
|
||||
@@ -537,7 +622,6 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값')
|
||||
| `runMigrations()` | `(): void` | `php artisan migrate --force` |
|
||||
| `syncCoreRolesAndPermissions()` | `(): void` | config/core.php roles/permissions 동기화 |
|
||||
| `syncCoreMenus()` | `(): void` | config/core.php menus 동기화 |
|
||||
| `syncCoreMailTemplates()` | `(): void` | config/core.php mail_templates 동기화 (user_overrides 보존) |
|
||||
|
||||
### 업그레이드 및 마무리
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# 데이터 동기화 Helper (Data Sync Helpers)
|
||||
|
||||
> **배경**: 코어/확장의 업그레이드·시더·설치 경로에서 **config/seeder ↔ DB** 를 완전 동기화 (upsert + stale cleanup) 하기 위한 5종 helper 의 사용 가이드입니다.
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 모든 데이터 동기화는 Service/Seeder 가 Helper 를 호출해 수행 (직접 Model 조작 금지)
|
||||
2. 4종 전용 helper (Menu/Role/Notification/FilePermission) + 1종 범용 helper (Generic)
|
||||
3. 모든 helper 는 upsert + stale cleanup 4단계 패턴 (완전 동기화 원칙)
|
||||
4. user_overrides 보존은 helper 내부 HasUserOverrides trait 위임으로 자동 처리
|
||||
5. 새 엔티티 도입 시 대부분 GenericEntitySyncHelper 로 충분 — 전용 helper 는 도메인 로직이 필요할 때만
|
||||
```
|
||||
|
||||
## 1. 완전 동기화 원칙 (4단계)
|
||||
|
||||
코어/확장의 sync 메서드는 다음 4단계를 반드시 수행합니다:
|
||||
|
||||
| 단계 | 설명 | 책임 |
|
||||
|---|---|---|
|
||||
| 1. **Upsert** | config → DB (신규 생성 or user_overrides 보존 업데이트) | `Helper::sync*()` |
|
||||
| 2. **Orphan Delete** | config 에 없는 DB row 삭제 (user_overrides 무관) | `Helper::cleanupStale*()` |
|
||||
| 3. **Mapping Diff** | 관계 테이블(role_permissions 등) 재정렬 | Role Helper (해당 시) |
|
||||
| 4. **Dependent Cleanup** | 삭제된 상위 엔티티의 하위 정리 (FK cascade 또는 명시적) | Helper (해당 시) |
|
||||
|
||||
이 원칙은 "config 기반 모든 정의 데이터" 에 적용됩니다. 중요한 의미 구분:
|
||||
|
||||
- **row 존재 여부**: config 기준으로 결정 (user_overrides 무관)
|
||||
- **필드 값 (유지 row)**: user_overrides 에 등록된 필드만 보존
|
||||
|
||||
## 2. Helper 5종 개요
|
||||
|
||||
| Helper | 역할 | 대상 테이블 | 주요 도메인 로직 |
|
||||
|---|---|---|---|
|
||||
| `ExtensionMenuSyncHelper` | 메뉴 동기화 | `menus` | 재귀 슬러그 수집, parent/child 처리 |
|
||||
| `ExtensionRoleSyncHelper` | 역할·권한 동기화 | `roles`, `permissions`, `role_permissions` | 역할-권한 매핑 diff, `user_roles` 피벗 참조 차단 |
|
||||
| `NotificationSyncHelper` | 알림 동기화 | `notification_definitions`, `notification_templates` | Definition-Template 계층, FK cascade |
|
||||
| `FilePermissionHelper` | 파일 소유권/권한 복원 | 파일시스템 | 인스톨러 SSoT 경로 순회 |
|
||||
| `GenericEntitySyncHelper` | **범용** 단일 테이블 동기화 | 단일 unique 키 모델 | 단순 upsert + scope 필터 cleanup |
|
||||
|
||||
---
|
||||
|
||||
## 3. ExtensionMenuSyncHelper
|
||||
|
||||
**파일**: `app/Extension/Helpers/ExtensionMenuSyncHelper.php`
|
||||
|
||||
### 주요 메서드
|
||||
|
||||
```php
|
||||
public function syncMenu(
|
||||
string $slug,
|
||||
ExtensionOwnerType $extensionType,
|
||||
string $extensionIdentifier,
|
||||
array $newAttributes,
|
||||
?int $parentId = null,
|
||||
): Menu;
|
||||
|
||||
public function cleanupStaleMenus(
|
||||
ExtensionOwnerType $extensionType,
|
||||
string $extensionIdentifier,
|
||||
array $currentSlugs,
|
||||
): int;
|
||||
|
||||
public function collectSlugsRecursive(array $menus): array;
|
||||
```
|
||||
|
||||
### 사용 패턴 (코어 `CoreUpdateService::syncCoreMenus` 말미)
|
||||
|
||||
```php
|
||||
$currentSlugs = $menuSyncHelper->collectSlugsRecursive($coreMenus);
|
||||
$deleted = $menuSyncHelper->cleanupStaleMenus(
|
||||
ExtensionOwnerType::Core, 'core', $currentSlugs
|
||||
);
|
||||
```
|
||||
|
||||
확장 모듈 업데이트에서도 동일 패턴으로 `cleanupStaleMenus` 호출.
|
||||
|
||||
---
|
||||
|
||||
## 4. ExtensionRoleSyncHelper
|
||||
|
||||
**파일**: `app/Extension/Helpers/ExtensionRoleSyncHelper.php`
|
||||
|
||||
### 주요 메서드
|
||||
|
||||
```php
|
||||
public function syncRole(...): Role;
|
||||
|
||||
public function syncPermission(...): Permission;
|
||||
|
||||
public function cleanupStaleRoles(
|
||||
ExtensionOwnerType $extensionType,
|
||||
string $extensionIdentifier,
|
||||
array $currentIdentifiers,
|
||||
): int;
|
||||
|
||||
public function cleanupStalePermissions(
|
||||
ExtensionOwnerType $extensionType,
|
||||
string $extensionIdentifier,
|
||||
array $currentIdentifiers,
|
||||
): int;
|
||||
|
||||
public function syncAllRoleAssignments(array $permissionRoleMap, array $allExtensionPermIdentifiers): void;
|
||||
```
|
||||
|
||||
### 중요 정책
|
||||
|
||||
- `cleanupStaleRoles`: **삭제 대상 역할을 참조하는 `user_roles` 피벗 레코드가 있으면 삭제 차단 + 경고 로그**. 사용자 수동 재배정 유도 (안전장치)
|
||||
- `cleanupStalePermissions`: 순수 diff 삭제 (권한은 사용자 수정 불가 설계)
|
||||
- `syncAllRoleAssignments`: 코어는 `core/core` 소유 권한 전체를 diff 기준으로 사용 (이관된 구 식별자도 detach)
|
||||
|
||||
---
|
||||
|
||||
## 5. NotificationSyncHelper
|
||||
|
||||
**파일**: `app/Extension/Helpers/NotificationSyncHelper.php`
|
||||
|
||||
### 주요 메서드
|
||||
|
||||
```php
|
||||
public function syncDefinition(array $data): NotificationDefinition;
|
||||
|
||||
public function syncTemplate(int $definitionId, array $data): NotificationTemplate;
|
||||
|
||||
public function cleanupStaleDefinitions(
|
||||
string $extensionType,
|
||||
string $extensionIdentifier,
|
||||
array $currentTypes,
|
||||
): int;
|
||||
|
||||
public function cleanupStaleTemplates(int $definitionId, array $currentChannels): int;
|
||||
```
|
||||
|
||||
### 사용 패턴 (Seeder)
|
||||
|
||||
```php
|
||||
$helper = app(NotificationSyncHelper::class);
|
||||
$definedTypes = [];
|
||||
|
||||
foreach ($this->getDefaultDefinitions() as $data) {
|
||||
$definition = $helper->syncDefinition($data);
|
||||
$definedTypes[] = $definition->type;
|
||||
|
||||
$definedChannels = [];
|
||||
foreach ($data['templates'] as $template) {
|
||||
$helper->syncTemplate($definition->id, $template);
|
||||
$definedChannels[] = $template['channel'];
|
||||
}
|
||||
$helper->cleanupStaleTemplates($definition->id, $definedChannels);
|
||||
}
|
||||
|
||||
$helper->cleanupStaleDefinitions('core', 'core', $definedTypes);
|
||||
```
|
||||
|
||||
**호출처**: `NotificationDefinitionSeeder` (코어), `BoardNotificationDefinitionSeeder`, `EcommerceNotificationDefinitionSeeder`.
|
||||
|
||||
---
|
||||
|
||||
## 6. FilePermissionHelper
|
||||
|
||||
**파일**: `app/Extension/Helpers/FilePermissionHelper.php`
|
||||
|
||||
### 주요 메서드
|
||||
|
||||
```php
|
||||
public function chownRecursive(string $path, string $owner, string $group): void;
|
||||
|
||||
public function chmodRecursive(string $path, int $mode): void;
|
||||
```
|
||||
|
||||
### 사용
|
||||
|
||||
인스톨러 SSoT 경로 전체에 대한 소유권 복원. 실패 시 경로당 1건 로그 + 종료 시 요약 카운트.
|
||||
|
||||
대상 경로 (SSoT — `config/app.php restore_ownership` 과 인스톨러 일치):
|
||||
|
||||
```text
|
||||
storage, bootstrap/cache, vendor, modules, modules/_pending,
|
||||
plugins, plugins/_pending, templates, templates/_pending, storage/app/core_pending
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. GenericEntitySyncHelper
|
||||
|
||||
**파일**: `app/Extension/Helpers/GenericEntitySyncHelper.php`
|
||||
|
||||
### 역할 구분 — 언제 사용하나?
|
||||
|
||||
| 쓰임 | 전용 Helper | GenericEntitySyncHelper |
|
||||
|---|---|---|
|
||||
| 다중 모델 관계 (Definition-Template, Role-Permission) | ✅ | ❌ |
|
||||
| 피벗 참조 차단 / 매핑 diff | ✅ | ❌ |
|
||||
| 단일 테이블, 단일 unique 키 | ❌ | ✅ |
|
||||
| 선택적 scope 필터 (type별, extension별) | ❌ | ✅ |
|
||||
|
||||
대부분의 모듈 내부 도메인 엔티티 (ShippingType, BoardType, ClaimReason, 향후 Schedule 등) 는 GenericEntitySyncHelper 로 충분.
|
||||
|
||||
### 주요 메서드
|
||||
|
||||
```php
|
||||
public function sync(
|
||||
string $modelClass,
|
||||
array $finder,
|
||||
array $attributes,
|
||||
): Model;
|
||||
|
||||
public function cleanupStale(
|
||||
string $modelClass,
|
||||
array $scopeFilter, // ['extension_identifier' => 'x', 'type' => 'refund']
|
||||
string $keyField, // 'code', 'slug', 'name'
|
||||
array $currentKeys,
|
||||
): int;
|
||||
```
|
||||
|
||||
### 사용 예시 1 — 스코프 없음
|
||||
|
||||
```php
|
||||
$helper = app(GenericEntitySyncHelper::class);
|
||||
$definedSlugs = [];
|
||||
|
||||
foreach ($boardTypes as $data) {
|
||||
$helper->sync(BoardType::class, ['slug' => $data['slug']], $data);
|
||||
$definedSlugs[] = $data['slug'];
|
||||
}
|
||||
|
||||
$helper->cleanupStale(BoardType::class, [], 'slug', $definedSlugs);
|
||||
```
|
||||
|
||||
### 사용 예시 2 — scope 필터 (type 별)
|
||||
|
||||
```php
|
||||
$definedByType = [];
|
||||
foreach ($reasons as $data) {
|
||||
$helper->sync(ClaimReason::class, ['type' => $data['type'], 'code' => $data['code']], $data);
|
||||
$definedByType[$data['type']][] = $data['code'];
|
||||
}
|
||||
|
||||
foreach ($definedByType as $type => $codes) {
|
||||
$helper->cleanupStale(ClaimReason::class, ['type' => $type], 'code', $codes);
|
||||
}
|
||||
```
|
||||
|
||||
### 사용 예시 3 — 확장 scope (Schedule 도입 예정)
|
||||
|
||||
```php
|
||||
foreach ($extensionSchedules as $data) {
|
||||
$helper->sync(Schedule::class, ['name' => $data['name']], $data);
|
||||
$definedNames[] = $data['name'];
|
||||
}
|
||||
|
||||
$helper->cleanupStale(
|
||||
Schedule::class,
|
||||
['extension_type' => 'module', 'extension_identifier' => 'sirsoft-ecommerce'],
|
||||
'name',
|
||||
$definedNames,
|
||||
);
|
||||
```
|
||||
|
||||
### 모델 전제 조건
|
||||
|
||||
대상 모델은 **`HasUserOverrides` trait 이 적용** 되어 있어야 합니다. (`sync()` 내부가 `syncOrCreateFromUpgrade` trait 메서드를 호출)
|
||||
|
||||
---
|
||||
|
||||
## 8. 새 엔티티 도입 시 체크리스트
|
||||
|
||||
```text
|
||||
□ 1. 모델에 `use HasUserOverrides;` + `$trackableFields` 선언
|
||||
□ 2. 테이블에 `user_overrides` (text/json, nullable) 컬럼 추가 마이그레이션
|
||||
□ 3. fillable + casts 에 'user_overrides' 추가
|
||||
□ 4. Seeder 작성 시 GenericEntitySyncHelper 사용 (단순한 경우)
|
||||
□ 5. Seeder 는 반드시 sync + cleanupStale 둘 다 호출 (완전 동기화)
|
||||
□ 6. 복잡한 관계 (1:N, 매핑) 가 있다면 전용 helper 고려
|
||||
□ 7. 테스트 작성: upsert 동작, user_overrides 보존, stale cleanup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 절대 금지 패턴
|
||||
|
||||
```text
|
||||
❌ Seeder 에서 직접 `Model::create()` / `Model::updateOrCreate()` 만 호출 (cleanup 누락)
|
||||
❌ Seeder 에서 `deleteExistingTypes()` 후 `create()` 재생성 패턴 (사용자 수정 전량 손실)
|
||||
❌ 데이터 정합성 로직을 Service/Controller 에 분산 (helper 로 집중)
|
||||
❌ HasUserOverrides 미적용 모델에 업그레이드 시더를 연결 (보존 불가)
|
||||
```
|
||||
|
||||
## 10. 참고
|
||||
|
||||
- `HasUserOverrides` trait 가이드: [user-overrides.md](user-overrides.md)
|
||||
- 완전 동기화 원칙: [core-config.md](core-config.md#완전-동기화-원칙)
|
||||
- 업그레이드 스텝: [extension/upgrade-step-guide.md](../extension/upgrade-step-guide.md)
|
||||
@@ -0,0 +1,137 @@
|
||||
# GeoIP 시스템 (MaxMind GeoLite2)
|
||||
|
||||
> IP 주소 기반 타임존 자동 감지 시스템
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. MaxMind GeoLite2-City DB 기반 IP → 타임존 감지 (SetTimezone 미들웨어 3단계)
|
||||
2. 관리자 > 환경설정 > 고급 탭에서 마스터 토글 + 라이선스 키 관리
|
||||
3. geoip:update 커맨드로 DB 다운로드 (주 1회 자동 스케줄)
|
||||
4. 마스터 OFF 시 미들웨어 short-circuit — GeoIP 조회 완전 스킵
|
||||
5. MaxMind 라이선스: 재배포 금지 → 각 환경에서 직접 다운로드 필수
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```text
|
||||
[관리자 고급 탭]
|
||||
├── 마스터 토글 (geoip.feature_enabled)
|
||||
├── 라이선스 키 (geoip.license_key)
|
||||
├── 자동 업데이트 토글 (geoip.auto_update_enabled)
|
||||
└── 수동 업데이트 버튼 → POST /api/admin/settings/geoip/update
|
||||
|
||||
[스케줄러] routes/console.php
|
||||
└── 주 1회(수요일 03:00) geoip:update 실행
|
||||
└── 3개 조건 AND 게이트: 마스터 ON + 자동 업데이트 ON + 키 존재
|
||||
|
||||
[다운로드 흐름] GeoIpDatabaseService
|
||||
└── MaxMind API → tar.gz → PharData 추출 → 원자적 교체 → 메타데이터 갱신
|
||||
|
||||
[요청 처리] SetTimezone 미들웨어
|
||||
└── 1.유저 설정 → 2.X-Timezone 헤더 → 3.GeoIP(마스터 ON일 때만) → 4.기본값
|
||||
```
|
||||
|
||||
## 설정 카테고리
|
||||
|
||||
GeoIP 기능 설정은 독립 카테고리 `geoip`로 관리됩니다 (`merge_into: "advanced"`로 고급 탭에 병합).
|
||||
|
||||
| 저장 키 | 프론트 키 | 타입 | 기본값 | 설명 |
|
||||
|---------|----------|------|--------|------|
|
||||
| `geoip.feature_enabled` | `advanced.geoip_enabled` | boolean | `false` | 마스터 스위치 |
|
||||
| `geoip.license_key` | `advanced.geoip_license_key` | string (sensitive) | `""` | MaxMind 라이선스 키 |
|
||||
| `geoip.auto_update_enabled` | `advanced.geoip_auto_update_enabled` | boolean | `true` | 자동 업데이트 활성화 |
|
||||
| `geoip.last_updated_at` | `advanced.geoip_last_updated_at` | string | `""` | 마지막 성공 다운로드 시각 (ISO8601) |
|
||||
|
||||
**주의**: 기존 `cache.geoip_enabled` / `cache.geoip_ttl`은 **GeoIP 조회 결과 캐시** 설정으로 완전히 별개.
|
||||
|
||||
## 파일 구조
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `app/Services/GeoIpDatabaseService.php` | 다운로드/추출/교체/상태 조회 + 훅 체인 |
|
||||
| `app/Services/GeoIpService.php` | IP → 타임존 조회 (Reader 사용) |
|
||||
| `app/Console/Commands/GeoIp/UpdateGeoLiteDatabaseCommand.php` | `geoip:update` Artisan 커맨드 |
|
||||
| `app/Http/Controllers/Api/Admin/GeoIpController.php` | 수동 업데이트 API 트리거 |
|
||||
| `config/geoip.php` | GeoIP 전체 설정 (활성화, 키, DB 경로, 다운로드, 스케줄, 캐시) |
|
||||
| `config/settings/defaults.json` | `geoip` 카테고리 스키마 + 기본값 |
|
||||
| `storage/app/geoip/GeoLite2-City.mmdb` | MaxMind DB 파일 (`.gitignore` 제외) |
|
||||
|
||||
## Artisan 커맨드
|
||||
|
||||
```bash
|
||||
# DB 다운로드 (라이선스 키 필요)
|
||||
php artisan geoip:update
|
||||
|
||||
# 강제 재다운로드 (24시간 이내라도)
|
||||
php artisan geoip:update --force
|
||||
|
||||
# URL/키 검증만 (실제 다운로드 X)
|
||||
php artisan geoip:update --dry-run
|
||||
|
||||
# 스케줄 등록 확인
|
||||
php artisan schedule:list | grep geoip
|
||||
```
|
||||
|
||||
## 훅 시스템
|
||||
|
||||
`GeoIpDatabaseService`는 다운로드 전/후에 훅을 실행합니다:
|
||||
|
||||
| 훅 | 타입 | 시점 |
|
||||
|----|------|------|
|
||||
| `core.geoip.database.before_update` | Action | 다운로드 시작 전 |
|
||||
| `core.geoip.database.filter_download_context` | Filter | 다운로드 옵션 변형 (edition_id, base_url, timeout 등) |
|
||||
| `core.geoip.database.after_update` | Action | 다운로드 성공 후 |
|
||||
| `core.geoip.database.after_update_failed` | Action | 다운로드 실패 후 |
|
||||
|
||||
## 미들웨어 short-circuit
|
||||
|
||||
`SetTimezone::determineTimezone()` 3단계(GeoIP)는 마스터 스위치가 OFF이면 완전히 스킵됩니다:
|
||||
|
||||
```php
|
||||
if ((bool) g7_core_settings('geoip.feature_enabled', false)) {
|
||||
$geoTimezone = $this->geoIpService->getTimezoneByIp(...);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
- `g7_core_settings()` 헬퍼로 소스 오브 트루스 직접 참조
|
||||
- `config('geoip.enabled')` 대신 사용 — `SettingsServiceProvider` 파이프라인 실패에 방어적
|
||||
- 기본값 `false` — 최초 설치 직후 안전 비활성
|
||||
|
||||
## MaxMind 라이선스
|
||||
|
||||
- **무료 계정**: https://www.maxmind.com/en/geolite2/signup
|
||||
- **재배포 금지**: mmdb 파일을 Git에 포함하거나 배포 패키지에 동봉할 수 없음
|
||||
- `.gitignore`에 `storage/app/geoip/` 추가됨
|
||||
- 각 운영 환경에서 `geoip:update`로 직접 다운로드해야 함
|
||||
|
||||
## 다운로드 엔드포인트
|
||||
|
||||
```text
|
||||
https://download.maxmind.com/app/geoip_download
|
||||
?edition_id=GeoLite2-City
|
||||
&license_key={key}
|
||||
&suffix=tar.gz
|
||||
```
|
||||
|
||||
- 파일 형식: `.tar.gz` 내부에 `GeoLite2-City_YYYYMMDD/GeoLite2-City.mmdb`
|
||||
- MaxMind 갱신 주기: 주 2회 (화/금)
|
||||
- G7 기본 스케줄: 주 1회 (수요일 03:00)
|
||||
|
||||
## 검증 규칙
|
||||
|
||||
`SaveSettingsRequest`에서 3개 필드 검증:
|
||||
|
||||
```php
|
||||
'advanced.geoip_enabled' => ['nullable', 'boolean'],
|
||||
'advanced.geoip_license_key' => ['nullable', 'string', 'max:200', 'regex:/^[A-Za-z0-9_]+$/'],
|
||||
'advanced.geoip_auto_update_enabled' => ['nullable', 'boolean'],
|
||||
```
|
||||
|
||||
- boolean 필드는 `prepareForValidation()`에서 `filter_var(FILTER_VALIDATE_BOOLEAN)` 전처리
|
||||
- 라이선스 키: `regex` 영숫자/언더스코어만 (SSRF/삽입 방어)
|
||||
@@ -184,7 +184,7 @@ role_permissions 피벗:
|
||||
### 사용 예시
|
||||
|
||||
```php
|
||||
// 권한만 체크 (scope는 DB 설정에 따라 자동 적용)
|
||||
// 관리자 컨텍스트 — permission:admin + admin 타입 권한 식별자
|
||||
Route::get('{user}', [AdminUserController::class, 'show'])
|
||||
->middleware('permission:admin,core.users.read');
|
||||
|
||||
@@ -193,8 +193,29 @@ Route::put('{user}', [AdminUserController::class, 'update'])
|
||||
|
||||
Route::put('{menu}', [AdminMenuController::class, 'update'])
|
||||
->middleware('permission:admin,core.menus.update');
|
||||
|
||||
// 사용자 컨텍스트 — permission:user + user 타입 권한 식별자
|
||||
Route::get('/api/user/notifications', [UserNotificationController::class, 'index'])
|
||||
->middleware('permission:user,core.user-notifications.read');
|
||||
|
||||
Route::patch('/api/user/notifications/{notification}/read', [UserNotificationController::class, 'markAsRead'])
|
||||
->middleware('permission:user,core.user-notifications.update');
|
||||
|
||||
Route::delete('/api/user/notifications/{notification}', [UserNotificationController::class, 'destroy'])
|
||||
->middleware('permission:user,core.user-notifications.delete');
|
||||
```
|
||||
|
||||
### 권한 type 일치 규칙 (CRITICAL)
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: PermissionMiddleware는 (식별자, type) 두 필드 모두 매칭하여 권한 행을 조회합니다.
|
||||
✅ permission:admin,xxx → DB의 (identifier='xxx', type='admin') 행 필요
|
||||
✅ permission:user,xxx → DB의 (identifier='xxx', type='user') 행 필요
|
||||
❌ 사용자 라우트에 admin 타입 권한 식별자를 사용하면 항상 403 (type 불일치)
|
||||
```
|
||||
|
||||
`permissions.identifier` 컬럼은 단일 unique 제약이므로 같은 식별자로 admin/user 두 행을 동시에 만들 수 없습니다. 사용자 컨텍스트 권한이 필요하면 **별도 식별자**(예: `core.user-notifications.*`)를 정의하세요. 상세 규칙은 [extension/permissions.md](../extension/permissions.md#권한-타입-permission-type) 참조.
|
||||
|
||||
### 목록 엔드포인트 필터링
|
||||
|
||||
미들웨어는 모델 바인딩이 없는 목록 엔드포인트를 **통과**시킵니다.
|
||||
|
||||
+512
-126
@@ -5,181 +5,567 @@
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 모든 알림은 BaseNotification 상속 필수 (via() 보일러플레이트 제거)
|
||||
2. getHookPrefix() + getNotificationType() 구현 → 훅명 자동 생성
|
||||
3. toMail()은 각 서브클래스가 직접 구현 (Laravel 규약)
|
||||
4. 각 모듈은 자체 Mailable 클래스 소유 (AuthNotificationMail, BoardNotificationMail)
|
||||
5. 채널 확장: 플러그인이 Filter 훅으로 via() 채널 추가
|
||||
1. GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스 불필요)
|
||||
2. notification_definitions 테이블 = 알림 타입 SSoT (채널, 훅, 변수 정의)
|
||||
3. notification_templates 테이블 = 채널별 독립 템플릿 + 수신자 (mail/database/fcm)
|
||||
4. 훅 기반 트리거: NotificationHookListener가 template별 독립 발송
|
||||
5. 수신자 설정: template.recipients JSON으로 채널별 독립 수신자 (4종 타입)
|
||||
6. 채널 확장: Filter 훅 `{hookPrefix}.notification.channels`로 채널 추가/제거
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처 개요
|
||||
|
||||
### BaseNotification 추상 클래스
|
||||
### Before vs After
|
||||
|
||||
```text
|
||||
BaseNotification (abstract)
|
||||
├── getHookPrefix(): string → 'core.auth', 'sirsoft-board', 'sirsoft-ecommerce'
|
||||
├── getNotificationType(): string → 'welcome', 'new_comment', 'order_confirmed'
|
||||
└── via(): array → HookManager::applyFilters() 자동 호출
|
||||
Before (기존):
|
||||
17개 개별 Notification 클래스 → 각각 toMail() 하드코딩 → mail 채널만
|
||||
mail_templates 테이블 → mail 전용 템플릿
|
||||
BoardNotificationChannelListener → Board만 채널 설정 가능
|
||||
|
||||
After (현재):
|
||||
GenericNotification 1개 → DB 설정 기반 → 다채널 동시 발송
|
||||
notification_definitions 테이블 → 알림 타입 정의 (채널, 훅, 변수)
|
||||
notification_templates 테이블 → 채널별 독립 템플릿
|
||||
코어 채널 관리 API → 전체 모듈 공용
|
||||
```
|
||||
|
||||
**위치**: `app/Notifications/BaseNotification.php`
|
||||
### 핵심 테이블
|
||||
|
||||
**역할**: `via()` 메서드만 추상화. `toMail()`은 각 서브클래스가 직접 구현.
|
||||
**notification_definitions** — 알림 타입 정의 (SSoT)
|
||||
|
||||
### 훅 이름 규칙
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| type | 알림 타입 (unique): welcome, order_confirmed 등 |
|
||||
| hook_prefix | 훅 접두사: core.auth, sirsoft-ecommerce 등 |
|
||||
| extension_type | 확장 타입: core, module, plugin |
|
||||
| extension_identifier | 확장 식별자 |
|
||||
| name | 다국어 이름 (JSON) |
|
||||
| variables | 사용 가능 변수 메타데이터 (JSON) |
|
||||
| channels | 활성 채널 배열 (JSON): ["mail", "database"] |
|
||||
| hooks | 트리거 훅 목록 (JSON): ["core.auth.after_register"] |
|
||||
| is_active | 활성 여부 |
|
||||
|
||||
```text
|
||||
{hookPrefix}.notification.channels
|
||||
```
|
||||
**notification_templates** — 채널별 템플릿
|
||||
|
||||
| 모듈 | hookPrefix | 훅명 |
|
||||
|------|-----------|------|
|
||||
| 코어 인증 | `core.auth` | `core.auth.notification.channels` |
|
||||
| 게시판 | `sirsoft-board` | `sirsoft-board.notification.channels` |
|
||||
| 이커머스 | `sirsoft-ecommerce` | `sirsoft-ecommerce.notification.channels` |
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| definition_id | 알림 정의 FK |
|
||||
| channel | 채널: mail, database, fcm |
|
||||
| subject | 다국어 제목 (JSON) |
|
||||
| body | 다국어 본문 (JSON) |
|
||||
| is_active | 해당 채널 활성 여부 |
|
||||
| user_overrides | 사용자 수정 필드 목록 |
|
||||
| unique | (definition_id, channel) |
|
||||
|
||||
---
|
||||
### GenericNotification 클래스
|
||||
|
||||
## 코어 알림 클래스
|
||||
|
||||
### 인증 알림 (app/Notifications/Auth/)
|
||||
|
||||
| 클래스 | hookPrefix | notificationType | Mailable |
|
||||
|--------|-----------|-----------------|----------|
|
||||
| WelcomeNotification | `core.auth` | `welcome` | AuthNotificationMail |
|
||||
| ResetPasswordNotification | `core.auth` | `reset_password` | AuthNotificationMail |
|
||||
| PasswordChangedNotification | `core.auth` | `password_changed` | AuthNotificationMail |
|
||||
|
||||
### 게시판 알림 (modules/sirsoft-board/src/Notifications/)
|
||||
|
||||
| 클래스 | hookPrefix | notificationType | Mailable |
|
||||
|--------|-----------|-----------------|----------|
|
||||
| NewCommentNotification | `sirsoft-board` | `new_comment` | BoardNotificationMail |
|
||||
| ReplyCommentNotification | `sirsoft-board` | `reply_comment` | BoardNotificationMail |
|
||||
| PostReplyNotification | `sirsoft-board` | `post_reply` | BoardNotificationMail |
|
||||
| PostActionNotification | `sirsoft-board` | `post_action` | BoardNotificationMail |
|
||||
| NewPostAdminNotification | `sirsoft-board` | `new_post_admin` | BoardNotificationMail |
|
||||
|
||||
---
|
||||
|
||||
## 새 알림 클래스 작성법
|
||||
|
||||
### 1. BaseNotification 상속
|
||||
**위치**: `app/Notifications/GenericNotification.php`
|
||||
|
||||
```php
|
||||
namespace Modules\Sirsoft\Ecommerce\Notifications;
|
||||
$user->notify(new GenericNotification(
|
||||
type: 'welcome',
|
||||
hookPrefix: 'core.auth',
|
||||
data: ['name' => '홍길동', 'app_name' => config('app.name'), ...],
|
||||
extensionType: 'core',
|
||||
extensionIdentifier: 'core',
|
||||
));
|
||||
```
|
||||
|
||||
use App\Notifications\BaseNotification;
|
||||
use Modules\Sirsoft\Ecommerce\Mail\EcommerceNotificationMail;
|
||||
**3계층 구조**:
|
||||
|
||||
class OrderConfirmedNotification extends BaseNotification
|
||||
| 메서드 | 채널 | 동작 |
|
||||
|--------|------|------|
|
||||
| `via()` | - | notification_definitions.channels 조회 + Filter 훅 적용 |
|
||||
| `toMail()` | mail | notification_templates(mail) 조회 → DbTemplateMail 생성 |
|
||||
| `toArray()` | database | notification_templates(database) 조회 → 변수 치환 |
|
||||
| `__call()` | 기타 | Filter 훅으로 위임 (fcm 등 미래 채널) |
|
||||
|
||||
### 발송 흐름
|
||||
|
||||
```text
|
||||
1. Listener에서 GenericNotification 생성 + $user->notify() 호출
|
||||
2. via() → notification_definitions에서 채널 조회 → Filter 훅 적용
|
||||
→ NotificationChannelService 필터 (확장 단위 채널 토글 OFF 채널 제외) ← 0단계
|
||||
→ ChannelReadinessService 필터 (미설정 채널 제외)
|
||||
→ NotificationTemplateService 필터 (활성 템플릿 없는 채널 제외)
|
||||
→ skipped 채널 → notification_logs에 사유 기록
|
||||
3. toMail() → notification_templates(mail) 조회 → replaceVariables() → DbTemplateMail
|
||||
4. toArray() → notification_templates(database) 조회 → replaceVariables() → 배열
|
||||
```
|
||||
|
||||
> **템플릿 없는 채널 자동 제외**: 채널이 활성(channels 배열 포함)이고 readiness도 통과해도, 해당 채널에 활성 템플릿이 없으면 via()에서 제외됩니다. 이로써 빈 subject/body가 DB에 저장되는 것을 방지합니다.
|
||||
|
||||
### 확장 단위 채널 전역 토글 (channel_disabled_by_extension)
|
||||
|
||||
코어/모듈/플러그인 환경설정 > "알림 채널 관리"의 토글이 OFF인 채널은 발송 경로 0단계에서 차단됩니다 — 활성 템플릿 존재 여부와 무관하게 제외되며 `notification_logs`에 `channel_disabled_by_extension` 사유로 skipped 기록됩니다.
|
||||
|
||||
**저장 위치** (확장별 독립):
|
||||
|
||||
| 확장 타입 | 저장소 | 키 |
|
||||
|----------|--------|-----|
|
||||
| 코어 | `settings` 테이블 (`SettingsService`) | `notifications.channels` |
|
||||
| 모듈 | 모듈 settings (`ModuleSettingsService`) | `notifications.channels` |
|
||||
| 플러그인 | 플러그인 settings (`PluginSettingsService`) | `notifications.channels` |
|
||||
|
||||
**스키마**: `[{id: string, is_active: boolean, sort_order?: number}]`
|
||||
|
||||
**조회 API**: `NotificationChannelService::isChannelEnabledForExtension($extensionType, $extensionIdentifier, $channelId)` — 단일 진입점. 엔트리가 없는 채널은 기본 `true`(활성) 반환 — 하위호환 + 플러그인이 추가한 신규 채널 기본 활성 보장.
|
||||
|
||||
**훅 필터**: `core.notification.channel_enabled` — 시그니처 `(bool $enabled, string $extensionType, ?string $extensionIdentifier, string $channelId)`. 플러그인이 동적으로 재정의 가능.
|
||||
|
||||
**메모이제이션**: 같은 요청 내 동일 조합 반복 조회는 in-memory 캐시. `clearChannelEnabledCache()`로 초기화.
|
||||
|
||||
---
|
||||
|
||||
## 훅 기반 트리거
|
||||
|
||||
### NotificationHookListener
|
||||
|
||||
**위치**: `app/Listeners/NotificationHookListener.php`
|
||||
|
||||
notification_definitions의 hooks 필드에 정의된 훅을 동적으로 구독합니다.
|
||||
|
||||
```text
|
||||
notification_definitions 레코드:
|
||||
type: "order_confirmed"
|
||||
hooks: ["sirsoft-ecommerce.order.after_confirm"]
|
||||
|
||||
→ NotificationHookListener가 부팅 시 모든 정의된 훅을 구독
|
||||
→ 훅 발화 시 → definition 조회 → GenericNotification 생성 → 발송
|
||||
```
|
||||
|
||||
**데이터 추출**: `{hookPrefix}.notification.extract_data` Filter 훅으로 수신자와 데이터를 추출합니다.
|
||||
|
||||
### 큐 워커에서의 사용자/로케일 컨텍스트
|
||||
|
||||
알림 발송 리스너는 기본적으로 큐로 디스패치되며, 큐 워커는 별도 프로세스라 `Auth::user()`/`App::getLocale()`이 모두 리셋됩니다. 그러나 G7은 디스패치 시점의 컨텍스트를 자동 복원하므로 다음이 보장됩니다:
|
||||
|
||||
- 발송 시점의 사용자 로케일이 자동 복원되어 다국어 메시지(메일 본문, 푸시 텍스트 등)가 원래 요청 언어로 정확히 발송
|
||||
- 알림 발송 로그(`NotificationLogListener`)의 행위자가 실제 트리거한 사용자로 정상 기록
|
||||
|
||||
리스너 코드는 변경 불필요 — 평소처럼 `Auth::user()`, `__('...')` 호출하면 됩니다.
|
||||
|
||||
> 자세한 동작은 [extension/hooks.md "사용자 컨텍스트 자동 복원"](../extension/hooks.md) 참조
|
||||
|
||||
---
|
||||
|
||||
## 수신자 설정 (Recipients)
|
||||
|
||||
### 개요
|
||||
|
||||
`notification_templates.recipients` JSON 컬럼으로 **채널별 독립 수신자**를 DB에서 설정합니다.
|
||||
동일한 알림 정의라도 메일과 사이트내 알림에 서로 다른 수신자를 지정할 수 있습니다.
|
||||
|
||||
### 수신자 타입
|
||||
|
||||
| type | 설명 | value | 예시 |
|
||||
|------|------|-------|------|
|
||||
| `trigger_user` | 이벤트 유발자 | - | 주문자, 가입자 |
|
||||
| `related_user` | 관련 사용자 | relation 키 | 문의 작성자 |
|
||||
| `role` | 역할 기반 | role identifier | admin, manager |
|
||||
| `specific_users` | 특정 사용자 | user UUID 배열 | ["uuid1", "uuid2"] |
|
||||
|
||||
### JSON 구조
|
||||
|
||||
```json
|
||||
[
|
||||
{"type": "trigger_user"},
|
||||
{"type": "role", "value": "admin", "exclude_trigger_user": true},
|
||||
{"type": "related_user", "relation": "author"},
|
||||
{"type": "specific_users", "value": ["uuid1", "uuid2"]}
|
||||
]
|
||||
```
|
||||
|
||||
### 발송 흐름
|
||||
|
||||
`NotificationHookListener.dispatch()`에서 채널별 독립 발송:
|
||||
|
||||
1. `extract_data` 필터로 data/context 추출
|
||||
2. definition의 **활성 templates를 순회**
|
||||
3. 각 template의 `recipients`로 수신자 결정 → `NotificationRecipientResolver`
|
||||
4. recipients 미설정 시 extract_data의 notifiables로 fallback (레거시 호환)
|
||||
5. 채널별 독립 `GenericNotification(channel: template.channel)` 발송
|
||||
|
||||
### NotificationRecipientResolver
|
||||
|
||||
**위치**: `app/Services/NotificationRecipientResolver.php`
|
||||
|
||||
recipients 규칙 배열을 해석하여 `Collection<User>`를 반환합니다.
|
||||
|
||||
```php
|
||||
// 시그니처: 순수 규칙 배열 + 컨텍스트
|
||||
$resolver->resolve(array $rules, array $context): Collection
|
||||
```
|
||||
|
||||
- `exclude_trigger_user: true` 규칙이 있으면 최종 수신자에서 이벤트 유발자를 제외
|
||||
- 동일 사용자가 여러 규칙에 중복되면 자동 중복 제거
|
||||
- role 타입에서 역할 사용자가 없으면 superAdmin으로 폴백
|
||||
|
||||
### extract_data 필터
|
||||
|
||||
모듈/플러그인은 `{hookPrefix}.notification.extract_data` 필터를 구현하여 알림 데이터와 컨텍스트를 제공합니다:
|
||||
|
||||
```php
|
||||
// 반환 형태
|
||||
return [
|
||||
'notifiable' => null, // 단일 수신자 (레거시 fallback)
|
||||
'notifiables' => null, // 수신자 배열 (레거시 fallback)
|
||||
'data' => [...], // 알림 변수 데이터
|
||||
'context' => [ // 수신자 결정용 컨텍스트
|
||||
'trigger_user_id' => $userId,
|
||||
'trigger_user' => $user,
|
||||
'related_users' => ['author' => $author],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### {recipient_name} 플레이스홀더
|
||||
|
||||
복수 수신자 알림에서 `data.name`에 `{recipient_name}`을 설정하면, 발송 시 각 수신자의 이름으로 자동 치환됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 알림 정의 관리
|
||||
|
||||
### 코어 3종
|
||||
|
||||
| type | hooks | 채널 |
|
||||
|------|-------|------|
|
||||
| welcome | core.auth.after_register | mail, database |
|
||||
| reset_password | core.auth.after_reset_password_request | mail, database |
|
||||
| password_changed | core.auth.after_password_changed | mail, database |
|
||||
|
||||
### Board 7종
|
||||
|
||||
| type | hooks |
|
||||
|------|-------|
|
||||
| new_comment | sirsoft-board.comment.after_create |
|
||||
| reply_comment | sirsoft-board.comment.after_create |
|
||||
| post_reply | sirsoft-board.post.after_create |
|
||||
| post_action | sirsoft-board.post.after_blind/delete/restore |
|
||||
| new_post_admin | sirsoft-board.post.after_create |
|
||||
| report_received_admin | sirsoft-board.report.after_create |
|
||||
| report_action | sirsoft-board.report.after_action |
|
||||
|
||||
### 이커머스 7종
|
||||
|
||||
| type | hooks | 채널 |
|
||||
|------|-------|------|
|
||||
| order_confirmed | sirsoft-ecommerce.order.after_confirm | mail, database |
|
||||
| order_shipped | sirsoft-ecommerce.order.after_ship | mail, database |
|
||||
| order_completed | sirsoft-ecommerce.order.after_complete | mail, database |
|
||||
| order_cancelled | sirsoft-ecommerce.order.after_cancel | mail, database |
|
||||
| new_order_admin | sirsoft-ecommerce.order.after_create | mail, database |
|
||||
| inquiry_received | sirsoft-ecommerce.product_inquiry.after_create | mail, database |
|
||||
| inquiry_replied | sirsoft-ecommerce.product_inquiry.after_reply | mail, database |
|
||||
|
||||
**발송 리스너**: `EcommerceNotificationListener` (방식 A — 직접 호출)
|
||||
|
||||
게시판과 동일하게 각 훅 메서드에서 수신자를 결정하고 `$user->notify(new GenericNotification(...))` 직접 호출합니다.
|
||||
관리자 수신자는 `admin` Role 기반 조회 + superAdmin 폴백 패턴을 사용합니다.
|
||||
|
||||
---
|
||||
|
||||
## 알림 정의 동기화 (NotificationSyncHelper)
|
||||
|
||||
업그레이드/시더 재실행 시 알림 정의(Definition) 와 템플릿(Template) 의 정합성을 **완전 동기화** 패턴으로 유지합니다. 로직은 `NotificationSyncHelper` 에 집중되어 있고 Seeder 는 얇은 진입점입니다.
|
||||
|
||||
### Helper 메서드
|
||||
|
||||
**파일**: `app/Extension/Helpers/NotificationSyncHelper.php`
|
||||
|
||||
```php
|
||||
public function syncDefinition(array $data): NotificationDefinition;
|
||||
public function syncTemplate(int $definitionId, array $data): NotificationTemplate;
|
||||
public function cleanupStaleDefinitions(string $extensionType, string $extensionIdentifier, array $currentTypes): int;
|
||||
public function cleanupStaleTemplates(int $definitionId, array $currentChannels): int;
|
||||
```
|
||||
|
||||
### Seeder 패턴
|
||||
|
||||
```php
|
||||
public function run(): void
|
||||
{
|
||||
public function __construct(
|
||||
private Order $order
|
||||
) {}
|
||||
$helper = app(\App\Extension\Helpers\NotificationSyncHelper::class);
|
||||
$definedTypes = [];
|
||||
|
||||
protected function getHookPrefix(): string
|
||||
{
|
||||
return 'sirsoft-ecommerce';
|
||||
foreach ($this->getDefaultDefinitions() as $data) {
|
||||
$definition = $helper->syncDefinition($data);
|
||||
$definedTypes[] = $definition->type;
|
||||
|
||||
$definedChannels = [];
|
||||
foreach ($data['templates'] as $template) {
|
||||
$helper->syncTemplate($definition->id, $template);
|
||||
$definedChannels[] = $template['channel'];
|
||||
}
|
||||
// 정의 유지 + 채널 제거 시 stale 삭제
|
||||
$helper->cleanupStaleTemplates($definition->id, $definedChannels);
|
||||
}
|
||||
|
||||
protected function getNotificationType(): string
|
||||
{
|
||||
return 'order_confirmed';
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): EcommerceNotificationMail
|
||||
{
|
||||
return new EcommerceNotificationMail(
|
||||
type: 'order_confirmed',
|
||||
order: $this->order,
|
||||
recipient: $notifiable
|
||||
);
|
||||
}
|
||||
// seeder 에서 제거된 definition 삭제 (FK cascade 로 template 도 자동 정리)
|
||||
$helper->cleanupStaleDefinitions('module', 'sirsoft-ecommerce', $definedTypes);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Mailable 클래스는 모듈별 소유
|
||||
### 동작 보장
|
||||
|
||||
각 모듈은 자체 Mailable 클래스를 만듭니다. 데이터 형태가 모듈마다 근본적으로 다르기 때문입니다.
|
||||
| 상황 | 동작 |
|
||||
|---|---|
|
||||
| 정의 신규 추가 | 생성 |
|
||||
| 정의 유지 + 사용자가 `name`/`is_active` 수정 | user_overrides 에 등록된 필드 보존, 나머지 갱신 |
|
||||
| 정의 제거 (seeder 에 없음) | 삭제 + FK cascade 로 연결된 모든 template 자동 정리 |
|
||||
| 템플릿 채널 재구성 | 제거된 채널의 template 만 삭제, 유지된 채널은 user_overrides 보존 |
|
||||
|
||||
| 모듈 | Mailable | 주요 프로퍼티 |
|
||||
|------|----------|-------------|
|
||||
| 코어 인증 | AuthNotificationMail | type, recipient, actionUrl, extraData |
|
||||
| 게시판 | BoardNotificationMail | type, board, post, comment, recipient, commentAuthorName, actionType |
|
||||
| 이커머스 | (직접 구현) | type, order, recipient, ... |
|
||||
### 호출처
|
||||
|
||||
- `database/seeders/NotificationDefinitionSeeder.php` (코어)
|
||||
- `modules/_bundled/sirsoft-board/database/seeders/BoardNotificationDefinitionSeeder.php`
|
||||
- `modules/_bundled/sirsoft-ecommerce/database/seeders/EcommerceNotificationDefinitionSeeder.php`
|
||||
|
||||
### 참고
|
||||
|
||||
- [data-sync-helpers.md](data-sync-helpers.md) — Helper 5종 사용 가이드
|
||||
- [user-overrides.md](user-overrides.md) — 사용자 수정 보존
|
||||
|
||||
---
|
||||
|
||||
## 채널 확장 (플러그인)
|
||||
## 서비스 계층
|
||||
|
||||
### Filter 훅으로 채널 추가
|
||||
| 서비스 | 역할 |
|
||||
|--------|------|
|
||||
| `NotificationDefinitionService` | 정의 조회(캐싱), 수정, 토글, 캐시 무효화 |
|
||||
| `NotificationTemplateService` | 채널별 템플릿 조회(캐싱), 수정, 미리보기, 복원 |
|
||||
|
||||
### 캐시 전략
|
||||
|
||||
- 정의: `notification_definition:{type}` — 1시간
|
||||
- 템플릿: `notification_template:{type}:{channel}` — 1시간
|
||||
- 수정/토글 시 자동 무효화
|
||||
|
||||
---
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### 사용자 알림 API
|
||||
|
||||
| 메서드 | URL | 권한 | 설명 |
|
||||
|--------|-----|------|------|
|
||||
| GET | /api/user/notifications | `core.user-notifications.read` (user) | 알림 목록 (페이지네이션) |
|
||||
| GET | /api/user/notifications/unread-count | `core.user-notifications.read` (user) | 미읽음 카운트 |
|
||||
| PATCH | /api/user/notifications/{id}/read | `core.user-notifications.update` (user) | 개별 읽음 처리 |
|
||||
| POST | /api/user/notifications/read-batch | `core.user-notifications.update` (user) | 배치 읽음 (ID 배열) |
|
||||
| POST | /api/user/notifications/read-all | `core.user-notifications.update` (user) | 전체 읽음 |
|
||||
| DELETE | /api/user/notifications/all | `core.user-notifications.delete` (user) | 전체 삭제 |
|
||||
| DELETE | /api/user/notifications/{id} | `core.user-notifications.delete` (user) | 개별 삭제 |
|
||||
|
||||
> 사용자 알림 라우트는 `permission:user,...` 미들웨어 + `core.user-notifications.*` (`type=user`) 권한을 사용합니다. 관리자용 `core.notifications.*` (`type=admin`)와는 별도 식별자입니다. 상세: [permissions.md](../extension/permissions.md#코어-권한에서-타입-지정-configcorephp)
|
||||
|
||||
#### 응답 필드 (UserNotificationResource)
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | string | 알림 UUID |
|
||||
| type | string | 알림 타입 식별자 (예: `welcome`, `order_confirmed`) |
|
||||
| type_label | string | 다국어 라벨 (`NotificationDefinition.name`에서 사용자 로케일 기준 해석, 정의 없으면 빈 문자열) |
|
||||
| subject | string\|null | 제목 (database 채널 템플릿 기반) |
|
||||
| body | string\|null | 본문 (database 채널 템플릿 기반) |
|
||||
| data | object | 알림 데이터 (변수 등 원본) |
|
||||
| read_at | string\|null | 읽음 시각 (사용자 타임존, `Y-m-d H:i:s`) |
|
||||
| created_at | string | 생성 시각 (사용자 타임존, `Y-m-d H:i:s`) |
|
||||
|
||||
> `type_label`은 `UserNotificationCollection`에서 `NotificationDefinitionRepository::getLabelMap($locale)`을 한 번만 호출하여 N+1을 회피합니다. 새 알림 타입 추가는 시더만 갱신하면 자동 반영됩니다.
|
||||
|
||||
### 관리자 알림 API (Admin)
|
||||
|
||||
| 메서드 | URL | 설명 |
|
||||
|--------|-----|------|
|
||||
| GET | /api/admin/notifications | 관리자 본인 알림 목록 |
|
||||
| GET | /api/admin/notifications/unread-count | 미읽음 카운트 |
|
||||
| PATCH | /api/admin/notifications/{id}/read | 개별 읽음 |
|
||||
| POST | /api/admin/notifications/read-batch | 배치 읽음 |
|
||||
| POST | /api/admin/notifications/read-all | 전체 읽음 |
|
||||
| DELETE | /api/admin/notifications/all | 전체 삭제 |
|
||||
| DELETE | /api/admin/notifications/{id} | 개별 삭제 |
|
||||
|
||||
> 관리자 알림 API는 `permission:admin,core.notifications.*` 미들웨어를 사용합니다. 사용자 API와 동일한 `UserNotificationResource`/`UserNotificationCollection`을 공유합니다.
|
||||
|
||||
### 알림 정의/템플릿 관리 API (Admin)
|
||||
|
||||
| 메서드 | URL | 설명 |
|
||||
|--------|-----|------|
|
||||
| GET | /api/admin/notification-definitions | 알림 정의 목록 |
|
||||
| GET | /api/admin/notification-definitions/{id} | 알림 정의 상세 (templates 포함) |
|
||||
| PUT | /api/admin/notification-definitions/{id} | 채널/훅 수정 |
|
||||
| PATCH | /api/admin/notification-definitions/{id}/toggle-active | 활성 토글 |
|
||||
| POST | /api/admin/notification-definitions/{id}/reset | **정의 단위 일괄 기본값 복원** (소속 모든 채널 템플릿 리셋) |
|
||||
| PUT | /api/admin/notification-templates/{id} | 템플릿 수정 |
|
||||
| PATCH | /api/admin/notification-templates/{id}/toggle-active | 템플릿 활성 토글 |
|
||||
| POST | /api/admin/notification-templates/preview | 미리보기 |
|
||||
| POST | /api/admin/notification-templates/{id}/reset | 단일 템플릿 기본값 복원 |
|
||||
|
||||
**리셋 동작**:
|
||||
- 템플릿 편집 시 `Template.is_default = false` + `Definition.is_default = false` 자동 전환
|
||||
- 정의 리셋 시 모든 소속 템플릿을 `is_default = true`로 복원 + `Definition.is_default = true` 복구
|
||||
- 복원 대상 기본값은 각 확장의 `NotificationDefinitionSeeder::getDefaultDefinitions()`에서 조회
|
||||
|
||||
### 모듈/플러그인 기본 정의 기여 (Filter 훅)
|
||||
|
||||
코어 리셋 로직이 확장의 시더를 조회할 수 있도록 `core.notification.filter_default_definitions` 필터 훅을 노출합니다.
|
||||
|
||||
```php
|
||||
// plugins/sirsoft-site-notification/src/Listeners/SiteNotificationListener.php
|
||||
class SiteNotificationListener implements HookListenerInterface
|
||||
// 모듈 Listener에서
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
public function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.auth.notification.channels' => [
|
||||
'method' => 'addDatabaseChannel',
|
||||
'type' => 'filter', // type: 'filter' 필수!
|
||||
],
|
||||
];
|
||||
}
|
||||
return [
|
||||
'core.notification.filter_default_definitions' => [
|
||||
'method' => 'contributeDefaultDefinitions',
|
||||
'priority' => 20,
|
||||
'type' => 'filter',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function addDatabaseChannel(array $channels, string $type, object $notifiable): array
|
||||
{
|
||||
$channels[] = 'database';
|
||||
public function contributeDefaultDefinitions(array $definitions, array $context = []): array
|
||||
{
|
||||
$seeder = new \Modules\Vendor\Module\Database\Seeders\MyNotificationDefinitionSeeder();
|
||||
|
||||
return array_merge($definitions, $seeder->getDefaultDefinitions());
|
||||
}
|
||||
```
|
||||
|
||||
컨텍스트: `['type' => string, 'channel' => string]` — 필요 시 특정 타입/채널만 필터링 가능.
|
||||
|
||||
---
|
||||
|
||||
## 채널 확장
|
||||
|
||||
플러그인이 Filter 훅으로 채널을 추가할 수 있습니다:
|
||||
|
||||
```php
|
||||
// 플러그인 리스너에서
|
||||
HookManager::addFilter(
|
||||
'core.auth.notification.channels',
|
||||
function (array $channels, string $type, object $notifiable) {
|
||||
$channels[] = 'fcm';
|
||||
return $channels;
|
||||
}
|
||||
}
|
||||
},
|
||||
priority: 10,
|
||||
);
|
||||
```
|
||||
|
||||
### 커스텀 채널 클래스 (FCM, 카카오톡 등)
|
||||
GenericNotification의 `__call()`이 `toFcm()` 호출을 `{hookPrefix}.notification.to_fcm` Filter 훅으로 위임합니다.
|
||||
|
||||
### 채널 메타데이터 다국어 규칙
|
||||
|
||||
채널의 이름(`name`), 설명(`description`), 출처 라벨(`source_label`)은 **설정 파일에서 다국어 배열로 관리**합니다. 프론트엔드 번역 파일(`$t:`)에 하드코딩하면 안 됩니다.
|
||||
|
||||
```php
|
||||
// via()에 FQCN 추가 → Laravel이 자동 resolve
|
||||
$channels[] = \Plugins\Sirsoft\Push\Channels\FcmChannel::class;
|
||||
// config/notification.php — 올바른 패턴
|
||||
[
|
||||
'id' => 'mail',
|
||||
'name' => ['ko' => '메일', 'en' => 'Email'],
|
||||
'description' => ['ko' => '이메일로 알림 발송', 'en' => 'Send notification via email'],
|
||||
'source_label' => ['ko' => '코어 기본 채널', 'en' => 'Core default channel'],
|
||||
]
|
||||
```
|
||||
|
||||
커스텀 채널 클래스는 `send(object $notifiable, Notification $notification)` 메서드를 구현합니다.
|
||||
프론트엔드 레이아웃에서 접근:
|
||||
|
||||
```json
|
||||
"text": "{{ch.name?.[$locale] ?? ch.name?.ko ?? ch.id}}"
|
||||
"text": "{{ch.source_label?.[$locale] ?? ch.source_label?.ko ?? ch.source}}"
|
||||
```
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|------|------------|
|
||||
| `$t:admin.settings.notification_definitions.source_core` | `ch.source_label?.[$locale]` |
|
||||
| 번역 파일에 채널 메타데이터 하드코딩 | `config/notification.php`에서 다국어 배열로 관리 |
|
||||
|
||||
---
|
||||
|
||||
## 테스트
|
||||
## 채널 Readiness 검증
|
||||
|
||||
### BaseNotificationTest
|
||||
미설정 채널(SMTP 미구성 등)은 발송 시도 자체를 건너뜁니다.
|
||||
|
||||
**위치**: `tests/Unit/Notifications/BaseNotificationTest.php`
|
||||
|
||||
| 테스트 | 검증 항목 |
|
||||
|--------|----------|
|
||||
| extends_laravel_notification | Notification 상속 확인 |
|
||||
| via_returns_mail_channel_by_default | 기본 ['mail'] 반환 |
|
||||
| hook_can_add_channels | Filter 훅으로 채널 추가 |
|
||||
| hook_receives_notification_type | 훅에 type 전달 확인 |
|
||||
| hook_receives_notifiable | 훅에 notifiable 전달 확인 |
|
||||
| hook_name_format | 훅 이름 형식 확인 |
|
||||
| board_module_hook_prefix | 모듈 훅 접두사 확인 |
|
||||
|
||||
### 기존 알림 테스트 (수정 없이 통과)
|
||||
|
||||
- `WelcomeNotificationTest` (6건)
|
||||
- `ResetPasswordNotificationTest` (7건)
|
||||
- `PasswordChangedNotificationTest` (6건)
|
||||
|
||||
---
|
||||
|
||||
## 금지 사항
|
||||
### 아키텍처
|
||||
|
||||
```text
|
||||
❌ BaseNotification 없이 Notification 직접 상속
|
||||
❌ via()에서 HookManager 직접 호출 (BaseNotification이 처리)
|
||||
❌ Mailable 추상화 (모듈별 데이터 형태가 다름)
|
||||
❌ 훅 type: 'filter' 누락 (반환값 무시됨)
|
||||
GenericNotification::via()
|
||||
├→ definition.channels (DB)
|
||||
├→ hook filter (플러그인 채널 추가/제거)
|
||||
├→ ChannelReadinessService 필터 (미설정 채널 제외)
|
||||
├→ NotificationTemplateService 필터 (활성 템플릿 없는 채널 제외)
|
||||
│ └→ skipped 채널 → notification_logs에 사유 기록
|
||||
└→ return readyChannels
|
||||
|
||||
NotificationDispatcher::sendToNotifiable() — 모든 채널의 공통 게이트포인트
|
||||
├→ core.notification.before_channel_send 훅
|
||||
├→ parent::sendToNotifiable() (실제 발송)
|
||||
├→ 성공 → core.notification.after_channel_send 훅 → NotificationLogListener가 자동 로깅
|
||||
└→ 실패 → core.notification.channel_send_failed 훅 → 에러 로깅 + 다른 채널 계속 발송
|
||||
```
|
||||
|
||||
### 발송 공통 훅 (NotificationDispatcher)
|
||||
|
||||
| 훅 | 시점 | 용도 |
|
||||
|---|------|------|
|
||||
| `core.notification.before_channel_send` | 발송 전 | 전처리, 필터링 |
|
||||
| `core.notification.after_channel_send` | 발송 성공 | 로깅, 통계 |
|
||||
| `core.notification.channel_send_failed` | 발송 실패 | 에러 로깅 |
|
||||
|
||||
모든 채널(mail, database, 플러그인 추가 채널)이 자동으로 이 훅을 통과합니다.
|
||||
플러그인/모듈이 별도 조치 없이도 모든 채널 발송이 `notification_logs`에 자동 기록됩니다.
|
||||
커스텀 로깅이 필요하면 동일 훅을 별도 리스너에서 구독하면 됩니다.
|
||||
|
||||
### 코어 채널 체크 조건
|
||||
|
||||
| 채널 | mailer | 필수 조건 |
|
||||
|------|--------|----------|
|
||||
| mail | smtp | `host`, `port`, `from_address` (기본값 제외) |
|
||||
| mail | mailgun | `mailgun_domain`, `mailgun_secret`, `from_address` |
|
||||
| mail | ses | `ses_key`, `ses_secret`, `from_address` |
|
||||
| mail | log/array | 항상 ready (개발용) |
|
||||
| database | - | `notifications` 테이블 존재 |
|
||||
|
||||
### 플러그인 채널 확장
|
||||
|
||||
채널 등록 시 readiness 체커도 함께 등록해야 합니다:
|
||||
|
||||
```php
|
||||
// 1. 채널 추가 (기존)
|
||||
HookManager::addFilter('core.notification.filter_available_channels', function ($channels) {
|
||||
$channels[] = ['id' => 'fcm', 'name' => [...], ...];
|
||||
return $channels;
|
||||
}, priority: 10);
|
||||
|
||||
// 2. readiness 체커 등록 (필수)
|
||||
HookManager::addFilter('core.notification.channel_readiness', function ($result, $channelId) {
|
||||
if ($channelId !== 'fcm') return $result;
|
||||
if (empty(config('services.fcm.server_key'))) {
|
||||
return ['ready' => false, 'reason' => 'fcm.server_key_empty'];
|
||||
}
|
||||
return ['ready' => true, 'reason' => null];
|
||||
}, priority: 10);
|
||||
```
|
||||
|
||||
미등록 채널은 기본 `ready=true` (발송 시도 허용).
|
||||
|
||||
### 관리자 API
|
||||
|
||||
`GET /api/admin/notification-channels` 응답에 각 채널별 `readiness` 포함:
|
||||
|
||||
```json
|
||||
{ "id": "mail", "readiness": { "ready": false, "reason": "notification.readiness.mail_smtp_host_empty" } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 기존 시스템과의 호환
|
||||
|
||||
- `mail_templates`, `board_mail_templates`, `ecommerce_mail_templates` 테이블 + 모델/시더/컨트롤러/Repository는 **7.0.0-beta.2 에서 일괄 제거됨**
|
||||
- 운영 환경 데이터는 `Upgrade_7_0_0_beta_2` (코어) + `Upgrade_1_0_0_beta_2` (보드/이커머스) 가 `notification_definitions` + `notification_templates` 로 이관
|
||||
- `drop_*_mail_templates_table` 마이그레이션이 레거시 테이블을 제거 (down 시 스키마 복원)
|
||||
- 다국어 헬퍼 + 변수 치환 trait 은 `NotificationContentBehavior` 로 리네임되어 NotificationTemplate 전용으로 사용됨 (구 `MailTemplateBehavior`)
|
||||
@@ -268,10 +268,10 @@ class MenuService
|
||||
*/
|
||||
public function getAdminMenus(): array
|
||||
{
|
||||
// 1. 캐시 사용 (권장)
|
||||
return Cache::remember('admin_menus', 3600, function () {
|
||||
// 1. 캐시 사용 — CacheInterface DI (드라이버가 `g7:core:` 접두사 자동 적용)
|
||||
return $this->cache->remember('admin_menus', function () {
|
||||
return $this->repository->getActiveMenus();
|
||||
});
|
||||
}, 3600);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -219,6 +219,37 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function ()
|
||||
|
||||
> 상세 문법: [middleware.md](middleware.md) "permission 미들웨어 except 옵션" 참조
|
||||
|
||||
### 사용자 컨텍스트 라우트 (permission:user)
|
||||
|
||||
사용자(프론트엔드) 라우트는 `permission:user,...` 미들웨어와 **user 타입 권한 식별자**를 함께 사용합니다.
|
||||
|
||||
```php
|
||||
// routes/api.php — 사용자 알림 라우트 예시
|
||||
|
||||
Route::middleware('auth:sanctum')->prefix('user')->group(function () {
|
||||
Route::prefix('notifications')->group(function () {
|
||||
Route::get('/', [UserNotificationController::class, 'index'])
|
||||
->middleware('permission:user,core.user-notifications.read')
|
||||
->name('api.user.notifications.index');
|
||||
|
||||
Route::patch('{notification}/read', [UserNotificationController::class, 'markAsRead'])
|
||||
->middleware('permission:user,core.user-notifications.update')
|
||||
->name('api.user.notifications.read');
|
||||
|
||||
Route::delete('{notification}', [UserNotificationController::class, 'destroy'])
|
||||
->middleware('permission:user,core.user-notifications.delete')
|
||||
->name('api.user.notifications.destroy');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 사용자 라우트에 admin 타입 권한 식별자를 사용하면 항상 403 응답
|
||||
✅ 사용자 컨텍스트 권한은 별도 식별자(예: core.user-notifications.*)로 정의 + permission:user 미들웨어 사용
|
||||
```
|
||||
|
||||
`permissions.identifier` 단일 unique 제약 때문에 같은 식별자로 admin/user 권한 두 행을 생성할 수 없습니다. 같은 도메인이라도 컨텍스트가 다르면 식별자를 분리하세요. 상세 규칙: [extension/permissions.md](../extension/permissions.md#권한-타입-permission-type)
|
||||
|
||||
### 공개 API 라우트
|
||||
|
||||
```php
|
||||
|
||||
+118
-2
@@ -422,7 +422,7 @@ if (app()->bound(\App\Seo\SitemapGenerator::class)) {
|
||||
|
||||
> 확장 라이프사이클은 드문 이벤트이므로 전체 캐시 클리어(`clearAll()`)로 안전하게 처리합니다.
|
||||
|
||||
캐시 무효화 시 `Cache::forget('seo:sitemap')` + `SeoConfigMerger::clearCache()`도 함께 호출
|
||||
캐시 무효화 시 `app(CacheInterface::class)->forget('seo.sitemap')` + `SeoConfigMerger::clearCache()` 도 함께 호출 (드라이버가 `g7:core:` 접두사 자동 적용)
|
||||
|
||||
## Artisan 커맨드
|
||||
|
||||
@@ -1242,14 +1242,130 @@ SeoMiddleware의 `buildCacheUrl()`이 캐시 키용 URL을 구성합니다:
|
||||
- **`locale` 파라미터 제외**: locale은 캐시 키의 두 번째 차원(`$locale`)으로 별도 관리
|
||||
- **쿼리 파라미터 정렬**: `ksort()` — 동일 파라미터 조합 = 동일 캐시 키 보장
|
||||
|
||||
## SEO 변수 시스템
|
||||
|
||||
확장(모듈/플러그인)이 `seoVariables()` 메서드를 통해 SEO 변수를 선언하면, SeoRenderer가 자동으로 해석하여 설정 템플릿의 `{key}` 플레이스홀더를 치환합니다.
|
||||
|
||||
### seoVariables() API
|
||||
|
||||
`AbstractModule` / `AbstractPlugin`에서 오버라이드하여 페이지 유형별 SEO 변수를 선언합니다.
|
||||
|
||||
```php
|
||||
public function seoVariables(): array
|
||||
{
|
||||
return [
|
||||
'_common' => [
|
||||
'site_name' => ['source' => 'core_setting', 'key' => 'general.site_name'],
|
||||
'commerce_name' => ['source' => 'setting', 'key' => 'basic_info.shop_name'],
|
||||
],
|
||||
'product' => [
|
||||
'product_name' => ['source' => 'data', 'key' => 'product.data.name'],
|
||||
'product_description' => ['source' => 'data', 'key' => 'product.data.short_description'],
|
||||
],
|
||||
'category' => [
|
||||
'category_name' => ['source' => 'data', 'key' => 'category.data.name'],
|
||||
],
|
||||
'search' => [
|
||||
'keyword_name' => ['source' => 'query', 'key' => 'q'],
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### _common 키
|
||||
|
||||
`_common`에 선언된 변수는 **모든 page_type에 공통 적용**됩니다. 런타임에 page_type별 변수와 병합되며, 동일 키가 있으면 page_type별 선언이 우선합니다.
|
||||
|
||||
```text
|
||||
최종 변수 = _common 변수 + page_type별 변수 (page_type 우선)
|
||||
```
|
||||
|
||||
### 변수 소스 타입
|
||||
|
||||
| source | 설명 | 자동 해석 | 예시 |
|
||||
|--------|------|----------|------|
|
||||
| `setting` | 해당 확장(모듈/플러그인)의 설정 값 | ✅ | `{ "source": "setting", "key": "basic_info.shop_name" }` |
|
||||
| `core_setting` | 코어 설정 값 | ✅ | `{ "source": "core_setting", "key": "general.site_name" }` |
|
||||
| `query` | URL 쿼리 파라미터 | ✅ | `{ "source": "query", "key": "q" }` |
|
||||
| `route` | URL 라우트 파라미터 | ✅ | `{ "source": "route", "key": "slug" }` |
|
||||
| `data` | 데이터소스 응답 데이터 | ❌ (레이아웃 `vars`에서 매핑 필요) | `{ "source": "data", "key": "product.data.name" }` |
|
||||
|
||||
- `setting`, `core_setting`, `query`, `route` 소스는 SeoRenderer가 **자동으로 해석**합니다.
|
||||
- `data` 소스는 레이아웃 JSON의 `meta.seo.vars`에서 표현식으로 매핑해야 합니다.
|
||||
|
||||
### meta.seo.extensions — 확장 변수 로드 선언
|
||||
|
||||
레이아웃 JSON에서 SEO 변수를 제공하는 확장을 선언합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"seo": {
|
||||
"enabled": true,
|
||||
"extensions": [
|
||||
{ "type": "module", "id": "sirsoft-ecommerce" },
|
||||
{ "type": "plugin", "id": "sirsoft-payment" }
|
||||
],
|
||||
"page_type": "product",
|
||||
"vars": {
|
||||
"product_name": "{{product.data.name ?? ''}}",
|
||||
"product_description": "{{product.data.short_description ?? ''}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`extensions` 배열에 선언된 확장의 `seoVariables()`가 호출되어, 해당 page_type의 변수가 자동 해석됩니다. `vars`에는 `data` 소스 변수만 표현식으로 매핑하면 됩니다.
|
||||
|
||||
### 처리 흐름
|
||||
|
||||
```text
|
||||
1. SeoRenderer: 레이아웃 meta.seo.extensions 확인
|
||||
2. 각 확장의 seoVariables() 호출 → _common + page_type별 변수 병합
|
||||
3. 자동 해석 소스(setting, core_setting, query, route) 즉시 해석
|
||||
4. data 소스 → 레이아웃 vars에서 표현식 매핑 값 적용
|
||||
5. 확장 설정 title/description 템플릿의 {key} 치환
|
||||
6. 결과를 _seo.{page_type}.title / _seo.{page_type}.description 컨텍스트에 주입
|
||||
```
|
||||
|
||||
### _seo 컨텍스트 주입
|
||||
|
||||
SeoRenderer가 설정 템플릿을 해석한 후 결과를 `_seo` 네임스페이스에 주입합니다.
|
||||
|
||||
```text
|
||||
_seo.{page_type}.title — 해석된 SEO 제목
|
||||
_seo.{page_type}.description — 해석된 SEO 설명
|
||||
```
|
||||
|
||||
레이아웃 JSON에서 다음과 같이 참조할 수 있습니다:
|
||||
|
||||
```json
|
||||
"og": {
|
||||
"title": "{{_seo.product.title ?? product.data.name ?? ''}}",
|
||||
"description": "{{_seo.product.description ?? product.data.short_description ?? ''}}"
|
||||
}
|
||||
```
|
||||
|
||||
### 변수명 유효성 검증 (ValidatesSeoVariables)
|
||||
|
||||
모듈/플러그인 설치 시 `ValidatesSeoVariables` 트레이트가 변수명 고유성을 검증합니다.
|
||||
|
||||
- 동일 page_type 내에서 변수명 중복 시 설치 실패
|
||||
- `_common` 변수와 page_type별 변수 간 중복도 검증 대상
|
||||
- 서로 다른 확장 간 동일 page_type의 변수명 충돌 시 경고 발생
|
||||
|
||||
## 개발 체크리스트
|
||||
|
||||
```
|
||||
□ meta.seo 추가 시 UpdateLayoutContentRequest 검증 규칙 확인했는가?
|
||||
□ SitemapContributor 구현 시 ServiceProvider에서 등록했는가?
|
||||
□ 캐시 무효화 리스너에서 Cache::forget('seo:sitemap')도 무효화했는가?
|
||||
□ 캐시 무효화 리스너에서 `app(CacheInterface::class)->forget('seo.sitemap')` 도 무효화했는가?
|
||||
□ 봇 감지 패턴 변경 시 BotDetectorTest 통과하는가?
|
||||
□ 레이아웃 meta.seo.enabled 변경 시 SeoDeclarationCollectorTest 통과하는가?
|
||||
□ 다국어 SEO 변경 시 SeoMiddlewareTest/SeoRendererTest/SitemapGeneratorTest 통과하는가?
|
||||
□ 확장 라이프사이클 훅 추가 시 SeoExtensionCacheListener 구독 목록 업데이트했는가?
|
||||
□ seoVariables() 선언 시 변수명이 기존 확장과 중복되지 않는가?
|
||||
□ meta.seo.extensions에 변수 제공 확장을 선언했는가?
|
||||
□ data 소스 변수는 vars에서 표현식 매핑이 완료되었는가?
|
||||
```
|
||||
@@ -12,6 +12,7 @@
|
||||
3. 인스톨러 안정성: 마이그레이션 전에도 부팅 가능해야 함
|
||||
4. 조건 미충족 시 예외 대신 안전하게 스킵
|
||||
5. runningInConsole() + 'migrate' 명령 감지로 스킵
|
||||
6. 성능 최적화: config('app.installer_completed') 가드로 설치 완료 환경에서 hasTable 호출 제거
|
||||
```
|
||||
|
||||
---
|
||||
@@ -260,12 +261,129 @@ class ConfigServiceProvider extends ServiceProvider
|
||||
|
||||
---
|
||||
|
||||
## 성능 최적화: installer_completed 가드
|
||||
|
||||
```text
|
||||
필수: 인스톨러 안전 체크(hasTable 폴백) 는 유지하되,
|
||||
프로덕션(설치 완료) 환경에서는 `config('app.installer_completed')` 가드로
|
||||
매 요청 Schema::hasTable() 호출을 건너뛴다.
|
||||
```
|
||||
|
||||
### 배경
|
||||
|
||||
`Schema::hasTable()` 은 `information_schema.tables` 에 대한 DB 쿼리를 실행합니다. 매 HTTP 요청마다 여러 ServiceProvider 와 확장 Trait 에서 이 체크가 반복되면 수십 ms 의 누적 오버헤드가 발생합니다. 설치가 완료된 프로덕션 환경에서는 테이블 존재가 **앱 수명주기 동안 불변** 이므로 이 체크 자체가 불필요합니다.
|
||||
|
||||
### 구현 패턴
|
||||
|
||||
`.env` 의 `INSTALLER_COMPLETED=true` 플래그를 `config/app.php` 에 노출하여 사용합니다:
|
||||
|
||||
```php
|
||||
// config/app.php
|
||||
'installer_completed' => env('INSTALLER_COMPLETED', false),
|
||||
```
|
||||
|
||||
```php
|
||||
// ❌ 매 요청 DB 쿼리 (개선 전)
|
||||
protected function loadModuleRoutes(): void
|
||||
{
|
||||
if (! File::exists(base_path('.env'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (! Schema::hasTable('modules')) {
|
||||
return;
|
||||
}
|
||||
if (! Schema::hasColumn('modules', 'identifier')) {
|
||||
return;
|
||||
}
|
||||
} catch (\Exception) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 라우트 로드...
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// ✅ installer_completed 가드 적용 (개선 후)
|
||||
protected function loadModuleRoutes(): void
|
||||
{
|
||||
if (! File::exists(base_path('.env'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 쿼리 제거).
|
||||
// 인스톨러 이전 환경에서는 기존 체크 경로로 폴백.
|
||||
if (! config('app.installer_completed')) {
|
||||
try {
|
||||
if (! Schema::hasTable('modules')) {
|
||||
return;
|
||||
}
|
||||
if (! Schema::hasColumn('modules', 'identifier')) {
|
||||
return;
|
||||
}
|
||||
} catch (\Exception) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 라우트 로드...
|
||||
}
|
||||
```
|
||||
|
||||
### 동작 표
|
||||
|
||||
| 환경 | `installer_completed` | 동작 |
|
||||
|------|----------------------|------|
|
||||
| 프로덕션 (설치 완료) | `true` | 가드 통과 → `hasTable` 스킵 (쿼리 0건) |
|
||||
| 인스톨러 실행 중 / 마이그레이션 전 | `false` (기본값) | 기존 `hasTable` 폴백 경로 (원본 동작 보존) |
|
||||
| 테스트 (`.env.testing` 에 플래그 없음) | `false` | 기존 `hasTable` 경로 |
|
||||
|
||||
### 적용 가이드
|
||||
|
||||
- **`.env` 파일 체크는 반드시 유지** — 인스톨러가 아직 `.env` 를 생성하지 않은 시점을 커버
|
||||
- **`hasTable` 폴백 경로는 반드시 유지** — `installer_completed=false` 환경에서도 안전하게 부팅되어야 함
|
||||
- **try/catch 도 그대로 유지** — DB 연결 실패 시 안전한 스킵 계약 준수
|
||||
- 가드는 hasTable 체크 **전체 블록을 `if (! config('app.installer_completed'))` 로 래핑** 하는 형태
|
||||
- `config:cache` 를 사용하는 환경에서는 `.env` 값 변경 후 반드시 `php artisan config:clear && php artisan config:cache` 를 실행해야 함
|
||||
|
||||
### 확장 Trait 패턴
|
||||
|
||||
`CachesModuleStatus::isExtensionTableReady()`, `CachesPluginStatus::isPluginTableReady()`, `CachesTemplateStatus::isTemplateTableReady()` 등 확장 Trait 에도 동일 가드를 적용합니다:
|
||||
|
||||
```php
|
||||
private static function isExtensionTableReady(string $table): bool
|
||||
{
|
||||
if (config('app.installer_completed')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::connection()->getPdo();
|
||||
|
||||
return Schema::hasTable($table);
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 주의사항
|
||||
|
||||
- **인스톨러 설치 완료 시점에 `INSTALLER_COMPLETED=true` 가 `.env` 에 기록되어야 함** — G7 인스톨러(`public/install/includes/task-runner.php`)는 이를 이미 수행
|
||||
- 가드는 **성능 최적화이며 안전 체크 대체가 아님** — 하드웨어 장애 등으로 테이블이 소실된 경우를 커버하지 못함. 그런 상황은 별도 헬스체크로 처리
|
||||
- 같은 HTTP 요청 내에서 테이블을 생성/삭제하는 플로우(예: 설치 직후 검증)가 있다면 가드가 stale 결과를 줄 수 있으므로 그 경로에서는 직접 `Schema::hasTable()` 호출 권장
|
||||
|
||||
---
|
||||
|
||||
## 서비스 프로바이더 개발 체크리스트
|
||||
|
||||
- [ ] DB 접근이 필요한 경우 `.env` 파일 존재 확인
|
||||
- [ ] 특정 테이블 접근 시 `Schema::hasTable()` 확인
|
||||
- [ ] 조건 미충족 시 예외 발생 대신 `return`으로 안전하게 스킵
|
||||
- [ ] 인스톨러 환경에서 테스트 수행
|
||||
- [ ] `config('app.installer_completed')` 가드로 hasTable 블록 래핑 (성능 최적화)
|
||||
- [ ] 인스톨러 환경에서 테스트 수행 (`INSTALLER_COMPLETED` 미설정 상태)
|
||||
- [ ] `composer install` 단독 실행 테스트
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# 사용자 수정 보존 (HasUserOverrides Trait)
|
||||
|
||||
> **배경**: 그누보드7 은 업그레이드/시더 재실행 시 사용자가 UI/API 로 수정한 필드를 **자동 보존**하는 시스템을 제공합니다. 본 문서는 `HasUserOverrides` trait 의 사용법과 보장 범위를 정의합니다.
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 모델에 `use HasUserOverrides;` + `protected array $trackableFields = [...]` 선언
|
||||
2. `user_overrides` (JSON/array) 컬럼 마이그레이션 필수
|
||||
3. 사용자가 trackable 필드를 수정 → user_overrides 자동 누적 기록
|
||||
4. 시더가 `syncOrCreateFromUpgrade()` 호출 → 기록된 필드 **보존**, 나머지는 갱신
|
||||
5. mass update `Model::where(...)->update(...)` 도 **투명하게 자동 추적**
|
||||
```
|
||||
|
||||
## 1. 기본 사용법
|
||||
|
||||
### 1.1 모델 선언
|
||||
|
||||
```php
|
||||
use App\Models\Concerns\HasUserOverrides;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ShippingType extends Model
|
||||
{
|
||||
use HasUserOverrides;
|
||||
|
||||
/**
|
||||
* 사용자 수정 보존 대상 필드.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $trackableFields = ['name', 'category', 'is_active', 'sort_order'];
|
||||
|
||||
protected $fillable = [
|
||||
'code', 'name', 'category', 'is_active', 'sort_order',
|
||||
'user_overrides', // 필수
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'name' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
'user_overrides' => 'array', // 필수 (JSON ↔ array 자동 변환)
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 마이그레이션
|
||||
|
||||
```php
|
||||
Schema::table('ecommerce_shipping_types', function (Blueprint $table) {
|
||||
$table->text('user_overrides')->nullable()
|
||||
->comment('유저가 수정한 필드명 목록 (예: ["name", "category"])');
|
||||
});
|
||||
```
|
||||
|
||||
### 1.3 Seeder (업그레이드 경로)
|
||||
|
||||
```php
|
||||
public function run(): void
|
||||
{
|
||||
$helper = app(\App\Extension\Helpers\GenericEntitySyncHelper::class);
|
||||
$definedCodes = [];
|
||||
|
||||
foreach ($types as $data) {
|
||||
$helper->sync(ShippingType::class, ['code' => $data['code']], $data);
|
||||
$definedCodes[] = $data['code'];
|
||||
}
|
||||
|
||||
// 완전 동기화: seeder 에 없는 row 삭제 (user_overrides 무관)
|
||||
$helper->cleanupStale(ShippingType::class, [], 'code', $definedCodes);
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 동작 메커니즘
|
||||
|
||||
### 2.1 사용자 수정 기록 (자동)
|
||||
|
||||
사용자가 수정할 때 trait 의 `updating` 이벤트 핸들러가 자동으로 변경된 trackable 필드를 `user_overrides` 배열에 추가합니다.
|
||||
|
||||
```php
|
||||
$shippingType = ShippingType::find(1);
|
||||
$shippingType->name = ['ko' => '사용자 수정'];
|
||||
$shippingType->save();
|
||||
|
||||
// → user_overrides = ['name'] 자동 기록
|
||||
```
|
||||
|
||||
### 2.2 시더 재실행 시 보존
|
||||
|
||||
```php
|
||||
// 시더/업그레이드에서 호출
|
||||
$helper->sync(ShippingType::class, ['code' => 'parcel'], [
|
||||
'name' => ['ko' => '택배'], // 사용자가 수정했으므로 건너뜀
|
||||
'sort_order' => 1, // user_overrides 에 없으므로 갱신
|
||||
]);
|
||||
```
|
||||
|
||||
`syncOrCreateFromUpgrade()` 내부는:
|
||||
|
||||
- 기존 row 가 있으면 `user_overrides` 에 등록된 trait 필드는 갱신 건너뜀
|
||||
- 나머지 필드는 갱신
|
||||
- 시더 컨텍스트(`user_overrides.seeding` 플래그) 에서는 `updating` 이벤트가 자동 bypass 되어 user_overrides 가 추가 기록되지 않음
|
||||
|
||||
## 3. Mass Update 투명 추적
|
||||
|
||||
### 3.1 지원 경로
|
||||
|
||||
다음 수정 경로 모두에서 user_overrides 가 자동 기록됩니다:
|
||||
|
||||
| 경로 | 예시 | 기록 여부 |
|
||||
|---|---|---|
|
||||
| 인스턴스 update | `$model->update([...])` | ✅ 기록 |
|
||||
| 인스턴스 save | `$model->name = 'x'; $model->save()` | ✅ 기록 |
|
||||
| **mass update** | `Model::where(...)->update([...])` | ✅ **기록** |
|
||||
| `whereIn` mass update | `Model::whereIn('id', [...])->update([...])` | ✅ 기록 (행별 계산) |
|
||||
| Query Builder 우회 | `DB::table('...')->update(...)` | ❌ 기록 안 됨 (escape hatch) |
|
||||
|
||||
### 3.2 내부 구현
|
||||
|
||||
`HasUserOverrides` trait 이 커스텀 `UserOverridesAwareBuilder` 를 반환하여 mass update 를 가로챕니다:
|
||||
|
||||
1. 시더 컨텍스트면 기본 경로 유지
|
||||
2. 입력 `$values` 에 trackable 필드가 없으면 기본 경로 유지 (성능 최적화)
|
||||
3. 그 외: 대상 행 preload → 행별 `calculateUserOverridesFor($values)` 계산 → user_overrides 포함한 per-row UPDATE
|
||||
|
||||
### 3.3 성능 고려
|
||||
|
||||
| 상황 | 쿼리 수 |
|
||||
|---|---|
|
||||
| trackable 필드 미포함 update | 1 UPDATE (기존) |
|
||||
| trackable 필드 포함 mass update | 1 SELECT + N UPDATE |
|
||||
| 대량 처리 필요 (수천~수만 행) | **`DB::table(...)->update(...)` 사용 권장** (Eloquent 우회) |
|
||||
|
||||
## 4. 공개 API
|
||||
|
||||
### 4.1 `calculateUserOverridesFor(array $incoming): array`
|
||||
|
||||
현재 모델 상태와 신규 입력을 비교하여 user_overrides 배열을 계산합니다.
|
||||
|
||||
```php
|
||||
$model = ShippingType::find(1);
|
||||
$overrides = $model->calculateUserOverridesFor(['name' => ['ko' => '새 이름']]);
|
||||
// 결과: ['name'] (현재 값과 다른 trackable 필드만 포함)
|
||||
```
|
||||
|
||||
주로 `UserOverridesAwareBuilder` 가 내부적으로 사용하며, 복잡한 커스텀 sync 로직에서도 재사용 가능합니다.
|
||||
|
||||
### 4.2 `syncOrCreateFromUpgrade(array $finder, array $attributes): self`
|
||||
|
||||
시더/업그레이드에서 직접 호출하거나, helper 에 위임합니다.
|
||||
|
||||
```php
|
||||
ShippingType::syncOrCreateFromUpgrade(
|
||||
['code' => 'parcel'],
|
||||
['name' => ['ko' => '택배'], 'category' => 'domestic']
|
||||
);
|
||||
```
|
||||
|
||||
### 4.3 `getTrackableFields(): array`
|
||||
|
||||
`$trackableFields` 프로퍼티 반환. 커스텀 로직에서 참조.
|
||||
|
||||
## 5. 정책과 원칙
|
||||
|
||||
### 5.1 row 존재 여부 vs 필드 값
|
||||
|
||||
| 구분 | 정책 |
|
||||
|---|---|
|
||||
| **row 존재** | 오직 config/seeder 정의 기준 (user_overrides 무관) — stale cleanup 은 무조건 삭제 |
|
||||
| **필드 값 (유지되는 row)** | user_overrides 에 등록된 trackable 필드는 보존, 나머지는 갱신 |
|
||||
|
||||
즉 "사용자가 수정한 row 이니 보존" 이 아니라 "config 에 있으면 유지, 있으면서 사용자가 수정한 필드는 유지" 입니다.
|
||||
|
||||
### 5.2 Trait 적용 시점의 한계
|
||||
|
||||
Trait 이 적용되기 **이전에** 사용자가 수정한 row 는 user_overrides 가 비어있어 추적 불가. Trait 적용 이후 수정부터 보존 효과 발생.
|
||||
|
||||
해결: 마이그레이션 + trait 적용을 포함한 업그레이드 후 사용자에게 설정 재확인을 안내.
|
||||
|
||||
## 6. 적용 현황
|
||||
|
||||
| 모델 | 트랙 필드 |
|
||||
|---|---|
|
||||
| `Menu` | `name`, `icon`, `order`, `url` |
|
||||
| `Role` | `name`, `description` |
|
||||
| `NotificationDefinition` | `name`, `is_active` |
|
||||
| `NotificationTemplate` | `subject`, `body`, `click_url`, `recipients`, `is_active` |
|
||||
| `Schedule` | `expression`, `command`, `timeout`, `is_active` |
|
||||
| `BoardType` | `name` |
|
||||
| `ClaimReason` | `name`, `sort_order`, `is_active` |
|
||||
| `ShippingType` | `name`, `category`, `is_active`, `sort_order` |
|
||||
|
||||
## 7. 참고
|
||||
|
||||
- 완전 동기화 원칙: [core-config.md](core-config.md#완전-동기화-원칙)
|
||||
- Helper 5종 사용 가이드: [data-sync-helpers.md](data-sync-helpers.md)
|
||||
- 업그레이드 스텝: [extension/upgrade-step-guide.md](../extension/upgrade-step-guide.md)
|
||||
+35
-10
@@ -121,11 +121,11 @@ php artisan migrate:rollback
|
||||
```bash
|
||||
# 코어 업데이트
|
||||
php artisan core:check-updates # 코어 업데이트 확인
|
||||
php artisan core:update [--force] [--no-backup] [--no-maintenance] # 코어 업데이트 실행
|
||||
php artisan core:update [--force] [--no-backup] [--no-maintenance] [--vendor-mode=auto|composer|bundled]
|
||||
|
||||
# 모듈
|
||||
php artisan module:list
|
||||
php artisan module:install [identifier]
|
||||
php artisan module:install [identifier] [--vendor-mode=auto|composer|bundled] [--force]
|
||||
php artisan module:activate [identifier]
|
||||
php artisan module:deactivate [identifier]
|
||||
php artisan module:uninstall [identifier]
|
||||
@@ -133,11 +133,11 @@ php artisan module:composer-install [identifier?] [--all]
|
||||
php artisan module:cache-clear [identifier?]
|
||||
php artisan module:seed [identifier] [--sample] [--count=key=value]
|
||||
php artisan module:check-updates [identifier?]
|
||||
php artisan module:update [identifier] [--force]
|
||||
php artisan module:update [identifier] [--force] [--vendor-mode=auto|composer|bundled] [--layout-strategy=overwrite|keep] [--source=auto|bundled|github]
|
||||
|
||||
# 플러그인
|
||||
php artisan plugin:list
|
||||
php artisan plugin:install [identifier]
|
||||
php artisan plugin:install [identifier] [--vendor-mode=auto|composer|bundled] [--force]
|
||||
php artisan plugin:activate [identifier]
|
||||
php artisan plugin:deactivate [identifier]
|
||||
php artisan plugin:uninstall [identifier]
|
||||
@@ -145,17 +145,17 @@ php artisan plugin:composer-install [identifier?] [--all]
|
||||
php artisan plugin:cache-clear [identifier?]
|
||||
php artisan plugin:seed [identifier] [--sample] [--count=key=value]
|
||||
php artisan plugin:check-updates [identifier?]
|
||||
php artisan plugin:update [identifier] [--force]
|
||||
php artisan plugin:update [identifier] [--force] [--vendor-mode=auto|composer|bundled] [--layout-strategy=overwrite|keep] [--source=auto|bundled|github]
|
||||
|
||||
# 템플릿
|
||||
php artisan template:list
|
||||
php artisan template:install [identifier]
|
||||
php artisan template:install [identifier] [--force]
|
||||
php artisan template:activate [identifier]
|
||||
php artisan template:deactivate [identifier]
|
||||
php artisan template:uninstall [identifier]
|
||||
php artisan template:cache-clear
|
||||
php artisan template:check-updates [identifier?]
|
||||
php artisan template:update [identifier] [--layout-strategy=overwrite] [--force]
|
||||
php artisan template:update [identifier] [--layout-strategy=overwrite] [--force] [--source=auto|bundled|github]
|
||||
|
||||
# Composer 의존성 (모듈/플러그인별 독립 vendor/)
|
||||
php artisan extension:composer-install # 모든 모듈+플러그인
|
||||
@@ -164,6 +164,31 @@ php artisan extension:composer-install # 모든 모듈+플러그인
|
||||
php artisan extension:update-autoload
|
||||
```
|
||||
|
||||
### Vendor 번들 Artisan (공유 호스팅용 vendor/ 선탑재)
|
||||
|
||||
```bash
|
||||
# 개별 빌드 (기존 *:build 패턴과 동일 — positional identifier + --all)
|
||||
php artisan core:vendor-bundle [--check] [--force]
|
||||
php artisan module:vendor-bundle [identifier] [--all] [--check] [--force]
|
||||
php artisan plugin:vendor-bundle [identifier] [--all] [--check] [--force]
|
||||
|
||||
# 개별 무결성 검증
|
||||
php artisan core:vendor-verify
|
||||
php artisan module:vendor-verify [identifier] [--all]
|
||||
php artisan plugin:vendor-verify [identifier] [--all]
|
||||
|
||||
# 일괄 알리아스 (운영/CI 전용)
|
||||
php artisan vendor-bundle:build-all [--check] [--force] # 코어 + 모든 _bundled
|
||||
php artisan vendor-bundle:verify-all
|
||||
|
||||
# 옵션
|
||||
# --check : stale 여부만 확인 (CI 검증용, stale 시 종료 코드 1)
|
||||
# --force : 해시 체크 무시하고 강제 재빌드
|
||||
# --all : 모든 _bundled 확장 (식별자 대체, 미지정 시 명시적 에러)
|
||||
```
|
||||
|
||||
> 상세 가이드: [docs/extension/vendor-bundle.md](extension/vendor-bundle.md)
|
||||
|
||||
### SEO Artisan 커맨드
|
||||
|
||||
```bash
|
||||
@@ -188,11 +213,11 @@ php artisan core:update [--force] [--no-backup] [--no-maintenance] # 코어 업
|
||||
|
||||
# CLI (Artisan 커맨드)
|
||||
php artisan module:check-updates [identifier?] # 모듈 업데이트 확인
|
||||
php artisan module:update [identifier] [--force] # 모듈 업데이트 실행
|
||||
php artisan module:update [identifier] [--force] [--layout-strategy=overwrite|keep] # 모듈 업데이트 실행
|
||||
php artisan plugin:check-updates [identifier?] # 플러그인 업데이트 확인
|
||||
php artisan plugin:update [identifier] [--force] # 플러그인 업데이트 실행
|
||||
php artisan plugin:update [identifier] [--force] [--layout-strategy=overwrite|keep] # 플러그인 업데이트 실행
|
||||
php artisan template:check-updates [identifier?] # 템플릿 업데이트 확인
|
||||
php artisan template:update [identifier] [--layout-strategy=overwrite] [--force] # 템플릿 업데이트 실행
|
||||
php artisan template:update [identifier] [--force] [--layout-strategy=overwrite|keep] # 템플릿 업데이트 실행
|
||||
|
||||
# API 엔드포인트
|
||||
POST /api/admin/core-update/check # 코어 업데이트 확인
|
||||
|
||||
@@ -396,6 +396,58 @@ public function checkCanDelete(Product $product): array
|
||||
- 메서드 분리 (delete*, create*)
|
||||
- `truncate()` 대신 `delete()` 사용
|
||||
- PHPDoc 주석 한국어
|
||||
- **재실행 안전성 필수**: `delete + insert` 패턴 금지 → upsert 패턴 사용
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: install --force, module:seed, 업그레이드 재실행 시 시더가 반복 실행됨.
|
||||
사용자 수정 데이터나 counter 값이 리셋되면 안 된다.
|
||||
```
|
||||
|
||||
**재실행 안전 패턴 선택 기준**:
|
||||
|
||||
| 엔티티 유형 | 권장 패턴 | 비고 |
|
||||
|------------|----------|------|
|
||||
| 사용자 수정 가능한 마스터 데이터 (예: 배송사, 클레임 사유, 게시판 유형) | `GenericEntitySyncHelper::sync()` + `cleanupStale()` | 모델에 `HasUserOverrides` trait + `$trackableFields` 필요 |
|
||||
| Counter/상태 유지 데이터 (예: 시퀀스 current_value) | `firstOrCreate` | 재실행 시 기존 레코드 완전 보존 |
|
||||
| 단순 정적 참조 데이터 | `updateOrCreate` | 사용자 수정 개념이 없는 경우 |
|
||||
|
||||
**❌ 금지 패턴**:
|
||||
|
||||
```php
|
||||
// 전체 삭제 후 재삽입 — 사용자 수정 / counter 손실
|
||||
Model::query()->delete();
|
||||
foreach ($items as $item) {
|
||||
Model::create($item);
|
||||
}
|
||||
|
||||
// 특정 type 범위 삭제 후 재삽입 — 동일 문제
|
||||
Model::where('type', $type)->delete();
|
||||
foreach ($items as $item) {
|
||||
Model::create($item);
|
||||
}
|
||||
```
|
||||
|
||||
**✅ 안전 패턴 (GenericEntitySyncHelper)**:
|
||||
|
||||
```php
|
||||
$helper = app(GenericEntitySyncHelper::class);
|
||||
$codes = [];
|
||||
foreach ($items as $item) {
|
||||
$helper->sync(Model::class, ['code' => $item['code']], $item);
|
||||
$codes[] = $item['code'];
|
||||
}
|
||||
// 시더 정의에서 제거된 row 만 정리
|
||||
$helper->cleanupStale(Model::class, ['type' => 'foo'], 'code', $codes);
|
||||
```
|
||||
|
||||
**✅ 안전 패턴 (firstOrCreate — counter 엔티티)**:
|
||||
|
||||
```php
|
||||
Model::firstOrCreate(
|
||||
['type' => $type->value], // unique 조건
|
||||
[ /* 최초 생성 시에만 사용될 초기값 */ ],
|
||||
);
|
||||
```
|
||||
|
||||
#### 시더 디렉토리 구조
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
# 캐시 드라이버 시스템 (CacheInterface)
|
||||
|
||||
> **코어/모듈/플러그인에서 캐시를 관리하기 위한 표준화된 인터페이스**
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 모든 캐시 저장은 CacheInterface 사용 (Cache:: 직접 호출 금지)
|
||||
2. BaseModuleServiceProvider에서 cacheServices 배열에 Service 클래스 등록
|
||||
3. 접두사로 캐시 키 격리: g7:core:{key}, g7:module.{id}:{key}, g7:plugin.{id}:{key}
|
||||
4. TTL은 g7_core_settings('cache.*')로 중앙 관리 (하드코딩 금지)
|
||||
5. Service 생성자에서 CacheInterface 타입힌트하면 자동 주입
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
- [개요](#개요)
|
||||
- [아키텍처](#아키텍처)
|
||||
- [모듈에서 사용하기](#모듈에서-사용하기)
|
||||
- [코어 서비스에서 사용하기](#코어-서비스에서-사용하기)
|
||||
- [자동 무효화 (CacheInvalidatable)](#자동-무효화-cacheinvalidatable)
|
||||
- [TTL 중앙 관리](#ttl-중앙-관리)
|
||||
- [API 레퍼런스](#api-레퍼런스)
|
||||
- [StorageDriver와의 대응](#storagedriver와의-대응)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
G7 캐시 드라이버 시스템은 StorageDriver 패턴을 참고하여 설계되었습니다.
|
||||
코어, 모듈, 플러그인별로 캐시 키를 접두사로 격리하고, DI를 통해 자동 주입됩니다.
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
- **접두사 격리**: 각 확장의 캐시 키가 충돌하지 않음
|
||||
- **DI 자동 주입**: Service 생성자에서 `CacheInterface` 타입힌트만으로 해당 확장의 캐시 드라이버를 받음
|
||||
- **TTL 중앙 관리**: 모든 TTL은 `g7_core_settings('cache.*')`에서 관리
|
||||
- **확장 라이프사이클 통합**: 모듈/플러그인 비활성화/삭제 시 캐시 자동 정리
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
CacheInterface (인터페이스 — app/Contracts/Extension/)
|
||||
↑ implements
|
||||
AbstractCacheDriver (공통 구현 — app/Extension/Cache/)
|
||||
↑ extends
|
||||
├── CoreCacheDriver 접두사: g7:core:{key}
|
||||
├── ModuleCacheDriver 접두사: g7:module.{identifier}:{key}
|
||||
└── PluginCacheDriver 접두사: g7:plugin.{identifier}:{key}
|
||||
|
||||
CacheInvalidatable (트레이트 — app/Extension/Traits/)
|
||||
→ 모델에 적용, saved/deleted 시 관련 태그 캐시 자동 무효화
|
||||
```
|
||||
|
||||
### 키 접두사 체계
|
||||
|
||||
| 드라이버 | 접두사 패턴 | 예시 |
|
||||
|---------|-----------|------|
|
||||
| CoreCacheDriver | `g7:core:{key}` | `g7:core:system_settings` |
|
||||
| ModuleCacheDriver | `g7:module.{id}:{key}` | `g7:module.sirsoft-ecommerce:products:list` |
|
||||
| PluginCacheDriver | `g7:plugin.{id}:{key}` | `g7:plugin.sirsoft-payment:gateways` |
|
||||
|
||||
---
|
||||
|
||||
## 모듈에서 사용하기
|
||||
|
||||
### 1단계: ServiceProvider에 cacheServices 등록
|
||||
|
||||
```php
|
||||
class EcommerceServiceProvider extends BaseModuleServiceProvider
|
||||
{
|
||||
protected string $moduleIdentifier = 'sirsoft-ecommerce';
|
||||
|
||||
protected array $cacheServices = [
|
||||
ProductService::class,
|
||||
CategoryService::class,
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 2단계: Service 생성자에서 CacheInterface 주입
|
||||
|
||||
```php
|
||||
class ProductService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProductRepositoryInterface $productRepository,
|
||||
private readonly CacheInterface $cache,
|
||||
) {}
|
||||
|
||||
public function getProducts(array $filters): array
|
||||
{
|
||||
$queryHash = md5(json_encode($filters));
|
||||
|
||||
return $this->cache->rememberQuery(
|
||||
$queryHash,
|
||||
fn () => $this->productRepository->list($filters),
|
||||
ttl: 3600,
|
||||
tags: ['products'],
|
||||
);
|
||||
}
|
||||
|
||||
public function getProduct(int $id): ?Product
|
||||
{
|
||||
return $this->cache->remember(
|
||||
"product:{$id}",
|
||||
fn () => $this->productRepository->find($id),
|
||||
ttl: 3600,
|
||||
tags: ['products', "product:{$id}"],
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3단계 (선택): 캐시 스토어 오버라이드
|
||||
|
||||
모듈 클래스에서 `getCacheStore()`를 오버라이드하면 다른 캐시 드라이버를 사용할 수 있습니다.
|
||||
|
||||
```php
|
||||
class EcommerceModule extends AbstractModule
|
||||
{
|
||||
public function getCacheStore(): string
|
||||
{
|
||||
return 'redis'; // 기본값(config('cache.default')) 대신 redis 사용
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 코어 서비스에서 사용하기
|
||||
|
||||
코어 서비스는 `CoreServiceProvider`에서 `CacheInterface`를 바인딩합니다.
|
||||
|
||||
```php
|
||||
// CoreServiceProvider
|
||||
$this->app->bind(CacheInterface::class, function () {
|
||||
return new CoreCacheDriver(config('cache.default'));
|
||||
});
|
||||
```
|
||||
|
||||
코어 서비스에서도 동일하게 생성자 주입으로 사용합니다.
|
||||
|
||||
```php
|
||||
class LayoutService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CacheInterface $cache,
|
||||
) {}
|
||||
|
||||
public function loadAndMergeLayout(string $name, int $templateId): array
|
||||
{
|
||||
$ttl = (int) g7_core_settings('cache.layout_ttl', 3600);
|
||||
|
||||
return $this->cache->remember(
|
||||
"layout.{$templateId}.{$name}",
|
||||
fn () => $this->mergeLayout($name, $templateId),
|
||||
$ttl,
|
||||
['layout'],
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 자동 무효화 (CacheInvalidatable)
|
||||
|
||||
모델에 `CacheInvalidatable` 트레이트를 적용하면 `saved`/`deleted` 이벤트 시 관련 태그 캐시를 자동 삭제합니다.
|
||||
|
||||
```php
|
||||
use App\Extension\Traits\CacheInvalidatable;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use CacheInvalidatable;
|
||||
|
||||
protected function getCacheInvalidationTags(): array
|
||||
{
|
||||
return ['products', 'product:' . $this->id];
|
||||
}
|
||||
|
||||
protected function getCacheDriver(): ?CacheInterface
|
||||
{
|
||||
return app(ModuleManager::class)
|
||||
->getModule('sirsoft-ecommerce')?->getCache();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**흐름**: Product 수정 → `CacheInvalidatable`이 `saved` 이벤트 감지 → `flushTags(['products', 'product:123'])` → 해당 태그 캐시 삭제 → 다음 조회 시 `remember()` 콜백 실행으로 재생성
|
||||
|
||||
---
|
||||
|
||||
## TTL 중앙 관리
|
||||
|
||||
모든 캐시 TTL은 `g7_core_settings('cache.*')`로 중앙 관리합니다. 하드코딩 금지.
|
||||
|
||||
| 설정 키 | 기본값 | 용도 |
|
||||
|---------|--------|------|
|
||||
| `cache.default_ttl` | 86400 | 기본 TTL (24h) |
|
||||
| `cache.layout_ttl` | 3600 | 레이아웃 캐시 TTL |
|
||||
| `cache.seo_ttl` | 7200 | SEO 페이지 캐시 TTL |
|
||||
| `cache.seo_sitemap_ttl` | 86400 | Sitemap 캐시 TTL |
|
||||
| `cache.notification_ttl` | 3600 | 알림 정의/템플릿 캐시 TTL |
|
||||
| `cache.extension_status_ttl` | 86400 | 확장 상태 캐시 TTL |
|
||||
| `cache.geoip_ttl` | 86400 | GeoIP 캐시 TTL |
|
||||
| `cache.version_check_ttl` | 3600 | 버전 검증 캐시 TTL |
|
||||
|
||||
```php
|
||||
// 사용 예시
|
||||
$ttl = (int) g7_core_settings('cache.layout_ttl', 3600);
|
||||
$this->cache->remember($key, $callback, $ttl, $tags);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 레퍼런스
|
||||
|
||||
### 기본 CRUD
|
||||
|
||||
| 메서드 | 설명 |
|
||||
|--------|------|
|
||||
| `get(key, default)` | 캐시 조회 |
|
||||
| `put(key, value, ttl)` | 캐시 저장 |
|
||||
| `has(key)` | 존재 확인 |
|
||||
| `forget(key)` | 삭제 |
|
||||
|
||||
### Remember 패턴
|
||||
|
||||
| 메서드 | 설명 |
|
||||
|--------|------|
|
||||
| `remember(key, callback, ttl, tags)` | 캐시 미스 시 콜백 실행 + 저장 |
|
||||
| `rememberQuery(queryHash, callback, ttl, tags)` | 쿼리 해시 기반 캐싱 (`query:` 접두사) |
|
||||
| `refresh(key, callback, ttl, tags)` | forget + remember 원자적 실행 |
|
||||
|
||||
### 벌크 연산
|
||||
|
||||
| 메서드 | 설명 |
|
||||
|--------|------|
|
||||
| `many(keys)` | 여러 키 한 번에 조회 |
|
||||
| `putMany(values, ttl)` | 여러 키-값 한 번에 저장 |
|
||||
|
||||
### 무효화
|
||||
|
||||
| 메서드 | 설명 |
|
||||
|--------|------|
|
||||
| `flush()` | 이 드라이버 소속 전체 캐시 삭제 |
|
||||
| `flushTags(tags)` | 특정 태그의 캐시만 삭제 |
|
||||
|
||||
### 메타
|
||||
|
||||
| 메서드 | 설명 |
|
||||
|--------|------|
|
||||
| `supportsTags()` | 태그 지원 여부 |
|
||||
| `getStore()` | 현재 스토어 이름 |
|
||||
| `withStore(store)` | 스토어 변경 (불변 복제) |
|
||||
| `resolveKey(key)` | 전체 키 확인 (디버깅용) |
|
||||
|
||||
---
|
||||
|
||||
## StorageDriver와의 대응
|
||||
|
||||
| StorageDriver | CacheDriver | 비고 |
|
||||
|--------------|-------------|------|
|
||||
| `StorageInterface` | `CacheInterface` | 계약 |
|
||||
| `CoreStorageDriver` | `CoreCacheDriver` | 코어용 |
|
||||
| `ModuleStorageDriver` | `ModuleCacheDriver` | 모듈용 |
|
||||
| `PluginStorageDriver` | `PluginCacheDriver` | 플러그인용 |
|
||||
| `getStorage()` | `getCache()` | 추상 클래스 메서드 |
|
||||
| `getStorageDisk()` | `getCacheStore()` | 오버라이드 가능 |
|
||||
| `withDisk()` | `withStore()` | 불변 복제 |
|
||||
| `$storageServices` | `$cacheServices` | ServiceProvider 배열 |
|
||||
| `registerStorageBindings()` | `registerCacheBindings()` | DI 자동 바인딩 |
|
||||
@@ -23,6 +23,7 @@
|
||||
5. [API 엔드포인트](#5-api-엔드포인트)
|
||||
6. [관리 화면 표시](#6-관리-화면-표시)
|
||||
7. [템플릿 예시](#7-템플릿-예시)
|
||||
8. [코어 버전 제약 정책](#8-코어-버전-제약-정책)
|
||||
|
||||
---
|
||||
|
||||
@@ -201,6 +202,46 @@ setState(selectedX) → apiCall(changelog)
|
||||
|
||||
---
|
||||
|
||||
## 8. 코어 버전 제약 정책
|
||||
|
||||
확장 manifest(`module.json`, `plugin.json`, `template.json`)의 `g7_version` 과 `dependencies.{modules|plugins}` 버전 제약 작성 규칙.
|
||||
|
||||
### 표기 규칙
|
||||
|
||||
- 형식: `>=X.Y.Z[-prerelease]` 통일 (공백 없음). 예: `>=7.0.0-beta.2`, `>=1.0.0-beta.2`
|
||||
- 캐럿(`^`), 틸드(`~`), 엄격 일치(`=`) 사용 금지
|
||||
- placeholder 금지: 실존하지 않는 버전(`0.1.0`, `0.0.1` 등) 을 적어 사실상 "아무 버전이나 허용" 상태로 두지 않음
|
||||
|
||||
### `g7_version` — 코어 최소 요구 버전
|
||||
|
||||
- 확장이 실제로 의존하는 코어 API/기능의 **최초 도입 버전**을 최소값으로 기재
|
||||
- 확장 `version` 이 bump 될 때 `g7_version` 재검토 필수
|
||||
- 번들 확장은 일반적으로 코어의 현재 릴리스와 같은 단계(beta.X, rc.X, X.Y.Z)를 하한으로 둠
|
||||
- 예: 알림 시스템 3계층(NotificationDefinition/Template) 을 사용하는 모듈은 최소 `>=7.0.0-beta.2`
|
||||
|
||||
### `dependencies.{modules|plugins}` — 확장 간 버전 제약
|
||||
|
||||
- A 확장이 B 확장의 공개 Service/Contract/Model/Route/훅을 사용한다면 `dependencies.{type}.B: ">=X.Y.Z"` 기재
|
||||
- B 에서 공개 표면이 변경되거나 새 API 가 도입되어 A 가 그것을 소비하게 되면 A 의 최소 버전 제약을 **그 API 최초 도입 버전**으로 상향
|
||||
- 실존하지 않는 placeholder 버전(`>=0.1.0` 등)은 금지 — 버전 게이팅 무효화 유발
|
||||
|
||||
### 버전 제약 재검토 트리거
|
||||
|
||||
다음 이벤트 발생 시 관련 확장 manifest 를 전수 재검토한다.
|
||||
|
||||
| 이벤트 | 재검토 대상 |
|
||||
|--------|------------|
|
||||
| 코어 공개 확장 표면 변경 (AbstractModule/AbstractPlugin/HookManager/Contracts 등) | 모든 번들 확장의 `g7_version` |
|
||||
| 코어 minor/major/beta 번호 변경 | 번들 확장 전체 `g7_version` |
|
||||
| 번들 모듈/플러그인의 공개 Service/Contract/Model/Route/훅/CHANGELOG 변경 | 해당 확장을 `dependencies` 에 선언한 모든 확장 |
|
||||
|
||||
### CHANGELOG 기재
|
||||
|
||||
- `g7_version` 상향: `### Changed` 에 `- 코어 최소 요구 버전을 X.Y.Z 로 상향`
|
||||
- `dependencies.{id}` 상향: `### Changed` 에 `- {id} 의존성 버전 제약을 실제 릴리스 버전에 맞춰 정비` 또는 `- {id} 최소 버전을 X.Y.Z 로 상향` (변경 이유에 따라)
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- `app/Extension/Helpers/ChangelogParser.php` — Changelog 파서
|
||||
|
||||
@@ -144,6 +144,21 @@ registerExtensionAutoload($loader)
|
||||
✅ 장점: 확장 간 의존성 충돌 방지, 삭제 시 깔끔한 정리
|
||||
```
|
||||
|
||||
### Vendor 설치 모드 (VendorMode)
|
||||
|
||||
공유 호스팅 등 Composer 실행 불가 환경을 지원하기 위해 **VendorResolver** 경유로 설치 모드를 결정합니다.
|
||||
|
||||
```text
|
||||
VendorMode enum:
|
||||
- auto: composer 가능 시 composer, 불가 시 bundled (기본값)
|
||||
- composer: composer install 강제 (불가 시 예외)
|
||||
- bundled: vendor-bundle.zip 추출 강제
|
||||
```
|
||||
|
||||
설치/업데이트 시 `installModule()` / `installPlugin()` / `updateModule()` / `updatePlugin()` 의 `VendorMode $vendorMode` 파라미터로 전달되며, `VendorResolver::install()` 가 환경 감지 + DB 기록 이전 모드 상속 + 번들 zip 무결성 검증을 거쳐 적절한 전략으로 라우팅합니다. `modules.vendor_mode` / `plugins.vendor_mode` 컬럼에 최종 사용된 모드가 기록되어 업데이트 시 자동 상속됩니다.
|
||||
|
||||
> 상세: [vendor-bundle.md](vendor-bundle.md) — 번들 구조, 빌드/검증/설치 흐름, 무결성 검증
|
||||
|
||||
### 동작 방식
|
||||
|
||||
```text
|
||||
|
||||
@@ -66,6 +66,8 @@
|
||||
```
|
||||
|
||||
> **주의**: `_pending` 디렉토리는 업데이트 감지에 **참여하지 않습니다**.
|
||||
>
|
||||
> GitHub API 호출은 `GithubHelper` (Laravel Http 파사드 기반)로 일원화되어 있습니다. `file_get_contents` + `stream_context_create` 직접 사용 금지 — `allow_url_fopen=Off` 공유 호스팅 환경 대응.
|
||||
|
||||
### 감지 결과
|
||||
|
||||
@@ -78,6 +80,59 @@
|
||||
]
|
||||
```
|
||||
|
||||
### 소스 강제 경로 (sourceOverride)
|
||||
|
||||
기본 2단계 우선순위(GitHub > _bundled)를 우회해 특정 소스 단독으로 업데이트를 실행하는 경로입니다.
|
||||
|
||||
#### 감지
|
||||
|
||||
- 일반: `Manager::checkAll{Type}ForUpdates()` — GitHub > _bundled 순으로 먼저 응답하는 소스 사용
|
||||
- 번들 강제: `CoreUpdateService::collectBundledExtensionUpdates()` — DB 현재 버전 vs `_bundled/{id}/{manifest}.json` 직접 비교
|
||||
|
||||
#### 실행
|
||||
|
||||
- 일반: `Manager::update{Type}($id, $force, ...)` — `update_source` 자동 결정
|
||||
- 번들 강제: `sourceOverride='bundled'` — GitHub 응답과 무관하게 `_bundled` 버전 설치
|
||||
- GitHub 강제: `sourceOverride='github'` — `_bundled` 폴백 차단, `github_url` 미설정 시 `force_update_no_source` 오류로 명확히 차단
|
||||
|
||||
이유: GitHub 릴리즈가 `_bundled` 보다 구(舊) 버전이거나 일시적으로 API 가 실패해도 코어와 함께 배포된 `_bundled` 버전이 코어와의 호환성 보증 기준이 되어야 하기 때문. 반대로 GitHub 에서 핫픽스 태그를 먼저 받아야 하는 운영 상황에서는 `github` 강제가 필요합니다.
|
||||
|
||||
`sourceOverride` 파라미터는 `updateModule` / `updatePlugin` / `updateTemplate` 의 마지막 파라미터이며, 지정하지 않으면 기존 GitHub > _bundled 우선순위가 그대로 적용됩니다.
|
||||
|
||||
#### CLI 옵션
|
||||
|
||||
`module:update` / `plugin:update` / `template:update` 에 `--source=auto|bundled|github` 옵션이 제공됩니다.
|
||||
|
||||
| 값 | 동작 |
|
||||
| --- | --- |
|
||||
| `auto` (기본) | GitHub > _bundled 2단계 우선순위 |
|
||||
| `bundled` | `_bundled` manifest 단독 사용 (GitHub 전면 우회) |
|
||||
| `github` | GitHub 단독 사용 (`_bundled` 폴백 차단) |
|
||||
|
||||
사용 예: GitHub 에 임시 장애가 있거나 잘못된 태그가 퍼블리시되어 자동 경로가 실패할 때 `--source=bundled` 로 강제 설치, 반대로 긴급 핫픽스를 GitHub 태그로만 배포한 경우 `--source=github` 로 번들 폴백을 차단합니다.
|
||||
|
||||
#### 외부 ZIP 직접 지정 (`--zip`)
|
||||
|
||||
`module:update` / `plugin:update` / `template:update` 에 `--zip={path}` 옵션이 제공됩니다. GitHub 우회 + 번들 우회 경로로, 오프라인 환경·사설 레지스트리·긴급 수동 배포 용도입니다.
|
||||
|
||||
| 값 | 동작 |
|
||||
| --- | --- |
|
||||
| `--zip=/path/to/ext.zip` | 지정 ZIP 을 추출하여 소스로 사용. `manifest.json`(module.json / plugin.json / template.json)의 `identifier` 가 대상 확장과 일치해야 하며, `version` 은 manifest 기준으로 자동 결정 |
|
||||
|
||||
동작 요약:
|
||||
|
||||
1. `extractFromZip()` 이 ZIP 을 임시 디렉토리에 추출 (GitHub zipball 의 `owner-repo-hash/` 래퍼든 평탄 루트든 자동 감지)
|
||||
2. `prepareZipSource()` 가 manifest 존재·JSON 해석·identifier 일치·version 존재를 검증
|
||||
3. manifest 의 `version` 을 `$toVersion` 으로 채택하고 staging → 적용 → 마이그레이션 → upgrade step 순 진행
|
||||
|
||||
제약:
|
||||
|
||||
- `--zip` 은 `--source` 와 **동시에 지정할 수 없습니다** (커맨드 시작 전 FAILURE 종료)
|
||||
- ZIP 파일이 존재하지 않거나 파일이 아닌 경로인 경우 FAILURE 종료
|
||||
- `--zip` 사용 시 `checkModuleUpdate` / `checkPluginUpdate` / `checkTemplateUpdate` 의 `update_available` 판정은 우회합니다 (사용자가 명시적으로 ZIP 을 제공한 것이 업데이트 의도의 확인으로 간주)
|
||||
|
||||
프로그래매틱 호출 시 `updateModule` / `updatePlugin` / `updateTemplate` 의 **마지막** 파라미터 `$zipPath` 로 절대 경로를 전달합니다.
|
||||
|
||||
### check-updates API 흐름
|
||||
|
||||
1. Service 레이어에서 훅 발행: `core.{type}.before_check_updates`
|
||||
@@ -90,15 +145,17 @@
|
||||
|
||||
GitHub 소스 업데이트 시 아카이브 URL 해석 → 다운로드 → 압축 해제 과정을 거칩니다:
|
||||
|
||||
**v접두사 자동 감지** (`resolveGithubArchiveUrl`):
|
||||
**v접두사 자동 감지 및 태그 실재 검증** (`resolveGithubArchiveUrl`):
|
||||
|
||||
```text
|
||||
1. "v{version}" 태그로 HEAD 요청 시도 (예: v1.2.0)
|
||||
2. 실패 시 "{version}" 태그로 HEAD 요청 시도 (예: 1.2.0)
|
||||
3. HTTP 200/302 → 유효한 URL 반환
|
||||
4. 모두 실패 → null 반환 (업데이트 불가)
|
||||
1. "v{version}" 태그 실재 검증 — GET git/refs/tags/v{version} (200=존재, 404=부재)
|
||||
2. 실패 시 "{version}" 태그 실재 검증 — GET git/refs/tags/{version}
|
||||
3. 200 응답한 태그의 zipball URL 반환
|
||||
4. 모두 404 → null 반환 (업데이트 소스 없음 — "force_update_no_source" 안내)
|
||||
```
|
||||
|
||||
> **주의**: `zipball/{ref}` 엔드포인트는 태그 존재 여부와 무관하게 항상 302로 codeload 에 리다이렉트하기 때문에, 302 수신만으로는 태그 유효성을 판정할 수 없습니다. 반드시 `git/refs/tags/{tag}` 로 실재를 먼저 검증해야 합니다.
|
||||
|
||||
**아카이브 추출 전략** (2단계 폴백):
|
||||
|
||||
| 순서 | 방식 | 조건 |
|
||||
@@ -146,6 +203,62 @@ GitHub 소스 업데이트 시 아카이브 URL 해석 → 다운로드 → 압
|
||||
3. 에러 로깅 (upgrade 채널)
|
||||
```
|
||||
|
||||
### 업데이트 진행 플래그 (`G7_UPDATE_IN_PROGRESS`)
|
||||
|
||||
코어 업데이트는 파일 교체 / manifest 교체 / `.env` `APP_VERSION` 갱신이 서로 다른 단계에 수행되어, 중간 시점에 부팅되는 spawn 프로세스에서 코어·확장 버전이 일시적으로 불일치합니다. 이 상태에서 `CoreServiceProvider::validateAndDeactivateIncompatibleExtensions` / `validateAndDeactivateIncompatibleTemplates` 가 실행되면 호환성 판정이 부정확해 활성 확장이 자동 비활성화되고, 이어서 `updateModule` 이 `previousStatus='inactive'` 를 캡처해 영구 비활성 상태로 복원되는 회귀가 발생합니다.
|
||||
|
||||
방지 규약:
|
||||
|
||||
1. `CoreUpdateCommand::handle()` 진입 즉시 `G7_UPDATE_IN_PROGRESS=1` 을 `$_ENV / $_SERVER / putenv` 3 경로에 기록.
|
||||
2. 모든 spawn 호출(`proc_open`) 이 **`array_merge(getenv(), $_ENV, ['G7_UPDATE_IN_PROGRESS' => '1', ...])`** 패턴으로 `$env` 를 구성 → 플래그가 자식에 확실히 전파.
|
||||
3. `CoreServiceProvider::validateAndDeactivate*` 는 진입 직후 `isCoreUpdateInProgress()` 체크 → 플래그 감지 시 즉시 `return`.
|
||||
4. 업데이트 종료 → 부모 프로세스 종료 → env 소멸 → 다음 요청부터 정상 호환성 체크 복원.
|
||||
|
||||
#### proc_open `$env` 구성 주의 (CRITICAL)
|
||||
|
||||
`$_ENV` 만으로 spawn 자식에 전달하면 **플래그가 누락될 수 있습니다**.
|
||||
|
||||
원인: `$_ENV` 는 PHP `variables_order` ini 설정에 `E` 가 포함되어야 populate 됩니다 (기본 CLI `"EGPCS"`, 일부 호스팅 `"GPCS"` — E 없음). `E` 가 없는 환경에서는:
|
||||
|
||||
- `$_ENV['G7_UPDATE_IN_PROGRESS'] = '1'` 할당은 superglobal 에만 적용되고 프로세스 environ 에는 미반영
|
||||
- `putenv('G7_UPDATE_IN_PROGRESS=1')` 는 프로세스 environ 에 기록되지만 `$_ENV` 와 별개
|
||||
- `array_merge($_ENV, [...])` 결과는 플래그 누락 — 자식이 플래그 없이 boot → validateAndDeactivate 발동 → 확장 자동 비활성화
|
||||
|
||||
**올바른 패턴**:
|
||||
|
||||
```php
|
||||
// getenv() (인자 없이 호출, PHP 7.1+) 은 프로세스 environ 테이블을 직접 반환 →
|
||||
// putenv 로 설정된 값까지 확실히 포함. $_ENV 가 비어있어도 안전.
|
||||
$env = array_merge(getenv(), $_ENV, [
|
||||
'G7_UPDATE_IN_PROGRESS' => '1',
|
||||
// 필요한 추가 env ...
|
||||
]);
|
||||
$process = proc_open($cmd, $descriptors, $pipes, $cwd, $env);
|
||||
```
|
||||
|
||||
`isCoreUpdateInProgress()` 는 env 외에 `$argv[1]` 이 `core:update` / `core:execute-upgrade-steps` 인 경우도 true 판정 — env 전파 실패 극단 상황 방어용. 하지만 `php -r '...'` 로 기동되는 spawn (inline 스크립트) 은 argv 판정이 되지 않으므로 env 전파가 유일한 수단입니다.
|
||||
|
||||
### 동적 엔티티 보존 (Permission / Role / Menu)
|
||||
|
||||
모듈·플러그인이 런타임에 동적으로 생성한 Permission/Role/Menu 는 정적 정의(`getPermissions()`, `getRoles()`, `getAdminMenus()`)에 포함되지 않으므로, 업데이트 시 `cleanupStaleModuleEntries()` / `cleanupStalePluginEntries()` 가 stale 로 오판하지 않도록 모듈 측에서 아래 hook 을 override 해 현재 식별자 전체를 반환한다.
|
||||
|
||||
확장 측 override hook:
|
||||
|
||||
- `getDynamicPermissionIdentifiers()` — 런타임 생성 권한 식별자 (Module · Plugin)
|
||||
- `getDynamicRoleIdentifiers()` — 런타임 생성 역할 식별자 (Module · Plugin)
|
||||
- `getDynamicMenuSlugs()` — 런타임 생성 메뉴 slug (Module)
|
||||
|
||||
cleanup 로직은 정적 + 동적 식별자를 병합한 expected 목록을 기준으로 stale 판정한다. 상세 override 예시는 [module-basics.md "동적 권한/역할/메뉴 보존 규칙"](module-basics.md#동적-권한역할메뉴-보존-규칙) 참조.
|
||||
|
||||
### 언인스톨 시 권한/메뉴/역할 보존 정책
|
||||
|
||||
`uninstallModule() / uninstallPlugin()` 은 `$deleteData` 파라미터로 데이터 삭제 범위를 분기한다:
|
||||
|
||||
- `$deleteData=false` (기본): 권한·메뉴·역할 **보존** (재설치 시 사용자 역할 할당 복원 가능). 테이블/vendor/storage 도 보존.
|
||||
- `$deleteData=true`: 권한·메뉴·역할 + 테이블 + vendor + storage 전수 삭제.
|
||||
|
||||
동적 권한/역할/메뉴가 있는 모듈은 이 정책이 특히 중요하다. 사용자가 의도적으로 "데이터도 함께 삭제" 를 체크하지 않는 한 동적 엔티티는 유지된다.
|
||||
|
||||
---
|
||||
|
||||
## 4. ExtensionStatusGuard
|
||||
@@ -339,6 +452,19 @@ class Upgrade_2_0_0 implements UpgradeStepInterface
|
||||
}
|
||||
```
|
||||
|
||||
### 멱등성 방어 로직 (필수)
|
||||
|
||||
업그레이드 스텝은 재실행될 수 있다 (롤백 후 재시도, `--force` 등). 모든 DB 조작은 이미 완료된 상태에서 재실행해도 안전해야 한다.
|
||||
|
||||
| 조작 유형 | 방어 패턴 |
|
||||
|----------|----------|
|
||||
| 레코드 삽입 | `firstOrCreate` 또는 `updateOrCreate` |
|
||||
| 대량 데이터 이관 | 이관 완료 마커 확인 후 스킵 |
|
||||
| 식별자 rename (unique 컬럼) | 신규 존재 여부 확인 후 분기 |
|
||||
| 코드/상태값 변환 | WHERE 조건이 이미 변환된 건을 자동 제외 |
|
||||
|
||||
> 상세: [core-update-system.md](../backend/core-update-system.md) "멱등성 방어 로직" 참조
|
||||
|
||||
### 자동 발견 규칙
|
||||
|
||||
- 디렉토리: `{extensionPath}/upgrades/`
|
||||
@@ -468,22 +594,25 @@ class Upgrade_2_0_0 implements UpgradeStepInterface
|
||||
|
||||
---
|
||||
|
||||
## 10. 템플릿 레이아웃 충돌 전략
|
||||
## 10. 레이아웃 충돌 전략 (3종 공통)
|
||||
|
||||
템플릿 업데이트 시 관리자가 수정한 레이아웃이 있으면 충돌이 발생합니다.
|
||||
템플릿/모듈/플러그인 업데이트 시 관리자가 UI 에서 수정한 레이아웃이 있으면 충돌이 발생합니다. 3종 모두 동일한 전략과 API 구조를 공유합니다.
|
||||
|
||||
### 사전 확인 API
|
||||
|
||||
```
|
||||
GET /api/admin/templates/{templateName}/check-modified-layouts
|
||||
GET /api/admin/modules/{moduleName}/check-modified-layouts
|
||||
GET /api/admin/plugins/{pluginName}/check-modified-layouts
|
||||
```
|
||||
|
||||
응답:
|
||||
```json
|
||||
{
|
||||
"has_modified": true,
|
||||
"has_modified_layouts": true,
|
||||
"modified_count": 2,
|
||||
"modified_layouts": [
|
||||
{ "id": 1, "name": "dashboard", "updated_at": "2026-02-24T10:00:00Z" }
|
||||
{ "id": 1, "name": "sirsoft-board.admin_post_list", "updated_at": "2026-04-19 10:00:00", "size_diff": 128 }
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -492,32 +621,46 @@ GET /api/admin/templates/{templateName}/check-modified-layouts
|
||||
|
||||
| 전략 | 동작 |
|
||||
|------|------|
|
||||
| `apply_new` (기본값) | 새 버전의 레이아웃으로 덮어쓰기 (관리자 수정 사항 유실) |
|
||||
| `keep_current` | 기존 레이아웃 유지 (새 버전 레이아웃 미적용) |
|
||||
| `overwrite` (기본값) | 새 버전의 레이아웃으로 덮어쓰기 (관리자 수정 사항 유실) |
|
||||
| `keep` | 원본 해시 기준으로 사용자가 수정한 레이아웃만 보존, 미수정 레이아웃은 덮어쓰기 |
|
||||
|
||||
업데이트 API에 `layout_strategy` 파라미터로 전달:
|
||||
|
||||
```
|
||||
POST /api/admin/templates/{templateName}/update
|
||||
Body: { "layout_strategy": "apply_new" }
|
||||
POST /api/admin/{modules|plugins|templates}/{identifier}/update
|
||||
Body: { "layout_strategy": "keep" }
|
||||
```
|
||||
|
||||
### 수정 감지 원리
|
||||
|
||||
- 확장 설치/업데이트 시 `template_layouts.original_content_hash` / `original_content_size` 컬럼에 파일 원본 SHA-256 해시 저장
|
||||
- `hasModifiedLayouts()` 는 현재 DB content 의 해시를 다시 계산하여 원본과 비교
|
||||
- 해시가 다르면 사용자가 관리자 UI 에서 수정했다고 판단 → `keep` 전략에서 보존
|
||||
- 공용 Trait `App\Extension\Traits\ComputesLayoutContentHash` (normalize → JSON encode → SHA-256)
|
||||
|
||||
---
|
||||
|
||||
## 11. Artisan 커맨드
|
||||
|
||||
| 커맨드 | 설명 |
|
||||
|--------|------|
|
||||
| `module:install [identifier] [--vendor-mode=auto] [--force]` | 모듈 설치 (`--force` 시 활성 디렉토리를 원본으로 덮어쓰고 재설치) |
|
||||
| `module:check-updates [identifier?]` | 모듈 업데이트 확인 (전체 또는 단일) |
|
||||
| `module:update [identifier] [--force]` | 모듈 업데이트 실행 |
|
||||
| `module:update [identifier] [--force] [--vendor-mode=auto] [--layout-strategy=overwrite]` | 모듈 업데이트 실행 |
|
||||
| `plugin:install [identifier] [--vendor-mode=auto] [--force]` | 플러그인 설치 (`--force` 동작은 모듈과 동일) |
|
||||
| `plugin:check-updates [identifier?]` | 플러그인 업데이트 확인 (전체 또는 단일) |
|
||||
| `plugin:update [identifier] [--force]` | 플러그인 업데이트 실행 |
|
||||
| `plugin:update [identifier] [--force] [--vendor-mode=auto] [--layout-strategy=overwrite]` | 플러그인 업데이트 실행 |
|
||||
| `template:install [identifier] [--force]` | 템플릿 설치 (`--force` 동작은 모듈과 동일) |
|
||||
| `template:check-updates [identifier?]` | 템플릿 업데이트 확인 (전체 또는 단일) |
|
||||
| `template:update [identifier] [--layout-strategy=overwrite] [--force]` | 템플릿 업데이트 실행 |
|
||||
| `template:update [identifier] [--force] [--layout-strategy=overwrite]` | 템플릿 업데이트 실행 |
|
||||
|
||||
> 단일 `check-updates`는 `checkXxxUpdate()` 호출 후 커맨드에서 DB 갱신 (`updateByIdentifier()`)
|
||||
> 템플릿 `update`만 `--layout-strategy` 옵션 지원 (`overwrite` | `keep`)
|
||||
> `--force`: 버전 비교를 건너뛰고 강제 재설치 (파일 손상, 수동 수정 복원 시 사용)
|
||||
> `--layout-strategy`: 3종 공통. `overwrite` (기본) 또는 `keep` (사용자 수정 보존). 섹션 10 참조
|
||||
> `--vendor-mode`: 모듈/플러그인 전용. `auto|composer|bundled` — Vendor 설치 방식 선택 (템플릿 미지원)
|
||||
> `--force` (update): 버전 비교를 건너뛰고 강제 재설치 (파일 손상, 수동 수정 복원 시 사용)
|
||||
> `--force` (install): 이미 설치된 확장이라도 `_bundled`/`_pending` 원본으로 활성 디렉토리를 덮어쓰고 재설치 — 불완전 활성 디렉토리(manifest 누락 등) 복구 시 사용
|
||||
> 코어 업데이트 (`core:update`) 완료 후에는 `_bundled` 에 새 버전이 있는 확장을 감지하여 일괄 업데이트 인터랙티브 프롬프트가 자동으로 표시됨
|
||||
> 활성 디렉토리 로드 시 manifest(`module.json`·`plugin.json`·`template.json`) 가 없으면 경고 로그 + 복구 힌트(`install {id} --force`) 가 출력됨
|
||||
|
||||
---
|
||||
|
||||
@@ -528,14 +671,16 @@ Body: { "layout_strategy": "apply_new" }
|
||||
| 메서드 | 경로 | 권한 | 설명 |
|
||||
|--------|------|------|------|
|
||||
| POST | `/api/admin/modules/check-updates` | `core.modules.install` | 모듈 업데이트 일괄 확인 |
|
||||
| POST | `/api/admin/modules/{moduleName}/update` | `core.modules.install` | 모듈 업데이트 실행 |
|
||||
| GET | `/api/admin/modules/{moduleName}/check-modified-layouts` | `core.modules.read` | 수정된 레이아웃 확인 |
|
||||
| POST | `/api/admin/modules/{moduleName}/update` | `core.modules.install` | 모듈 업데이트 실행 (`layout_strategy`, `vendor_mode` body) |
|
||||
|
||||
### 플러그인
|
||||
|
||||
| 메서드 | 경로 | 권한 | 설명 |
|
||||
|--------|------|------|------|
|
||||
| POST | `/api/admin/plugins/check-updates` | `core.plugins.install` | 플러그인 업데이트 일괄 확인 |
|
||||
| POST | `/api/admin/plugins/{pluginName}/update` | `core.plugins.install` | 플러그인 업데이트 실행 |
|
||||
| GET | `/api/admin/plugins/{pluginName}/check-modified-layouts` | `core.plugins.read` | 수정된 레이아웃 확인 |
|
||||
| POST | `/api/admin/plugins/{pluginName}/update` | `core.plugins.install` | 플러그인 업데이트 실행 (`layout_strategy`, `vendor_mode` body) |
|
||||
|
||||
### 템플릿
|
||||
|
||||
@@ -543,7 +688,7 @@ Body: { "layout_strategy": "apply_new" }
|
||||
|--------|------|------|------|
|
||||
| POST | `/api/admin/templates/check-updates` | `core.templates.install` | 템플릿 업데이트 일괄 확인 |
|
||||
| GET | `/api/admin/templates/{templateName}/check-modified-layouts` | `core.templates.read` | 수정된 레이아웃 확인 |
|
||||
| POST | `/api/admin/templates/{templateName}/update` | `core.templates.install` | 템플릿 업데이트 실행 |
|
||||
| POST | `/api/admin/templates/{templateName}/update` | `core.templates.install` | 템플릿 업데이트 실행 (`layout_strategy` body) |
|
||||
|
||||
### 훅 (Hook)
|
||||
|
||||
@@ -643,6 +788,62 @@ Body: { "layout_strategy": "apply_new" }
|
||||
| `MenuUserOverridesListener` | `core.menu.after_update_order` | 순서 변경된 메뉴에 "order" |
|
||||
| `MenuUserOverridesListener` | `core.menu.after_sync_roles` | 변경된 개별 역할 식별자 |
|
||||
|
||||
### HasUserOverrides Trait — 표준 추상 (7.0.0-beta.2 신규)
|
||||
|
||||
신규 모델에 user_overrides 패턴을 적용할 때는 `app/Models/Concerns/HasUserOverrides.php` trait 을 사용합니다. 위 ExtensionRoleSyncHelper / ExtensionMenuSyncHelper 는 권한 식별자 보호 등 복잡 로직을 그대로 유지합니다 (legacy 호환).
|
||||
|
||||
**적용**:
|
||||
|
||||
```php
|
||||
use App\Models\Concerns\HasUserOverrides;
|
||||
|
||||
class NotificationTemplate extends Model
|
||||
{
|
||||
use HasUserOverrides;
|
||||
|
||||
protected array $trackableFields = ['subject', 'body', 'click_url', 'recipients', 'is_active'];
|
||||
}
|
||||
```
|
||||
|
||||
**동작**:
|
||||
|
||||
- 사용자가 trackable 필드를 수정하면 trait 의 `static::updating` 이벤트가 자동 감지 → `user_overrides` 에 자동 기록
|
||||
- 시더/업그레이드 스텝에서 `Model::syncOrCreateFromUpgrade($finder, $attributes)` 호출 → 기록된 필드는 보존, 나머지는 갱신
|
||||
- 시더 컨텍스트 플래그: `app('user_overrides.seeding')` 가 true 일 때 자동 기록 비활성
|
||||
|
||||
**제공 메서드**:
|
||||
|
||||
- `getTrackableFields(): array` — 추적 대상 필드 반환 (`$trackableFields` 속성으로 오버라이드)
|
||||
- `syncFromUpgrade(array $newAttributes): void` — 사용자 수정 보존 갱신
|
||||
- `createFromUpgrade(array $attributes): self` — 시더 컨텍스트 신규 생성
|
||||
- `syncOrCreateFromUpgrade(array $finder, array $attributes): self` — 통합 헬퍼
|
||||
|
||||
**신규 모델 적용 4단계**:
|
||||
|
||||
1. **컬럼 추가** — 마이그레이션에 `text user_overrides nullable` 추가, comment 에 보존 대상 필드명 예시 기록
|
||||
2. **모델 적용** — `use HasUserOverrides;` + `protected array $trackableFields = [...];` + `$fillable`/`$casts` 에 `'user_overrides'` (`array`) 추가
|
||||
3. **시더 호출** — 기존 `Model::updateOrCreate(...)` 를 `Model::syncOrCreateFromUpgrade($finder, $attributes)` 로 변경
|
||||
4. **회귀 테스트** — `tests/Feature/UserOverrides/{Model}UserOverridesTest.php` 작성
|
||||
|
||||
**helper / listener / 훅 신규 작성: 불필요** (trait 가 모두 처리)
|
||||
|
||||
**현재 적용 모델 (7.0.0-beta.2)**:
|
||||
|
||||
| 모델 | trackable 필드 | 적용 위치 |
|
||||
| ---- | -------------- | --------- |
|
||||
| `App\Models\NotificationTemplate` | `subject`, `body`, `click_url`, `recipients`, `is_active` | 코어 |
|
||||
| `App\Models\NotificationDefinition` | `name`, `is_active` | 코어 |
|
||||
| `App\Models\Schedule` | `expression`, `command`, `timeout`, `is_active` | 코어 (사전 설계, 시더 미존재) |
|
||||
| `Modules\Sirsoft\Board\Models\BoardType` | `name` | 게시판 모듈 |
|
||||
| `Modules\Sirsoft\Ecommerce\Models\ClaimReason` | `name`, `sort_order`, `is_active` | 이커머스 모듈 |
|
||||
|
||||
**모듈 차원 적용 가이드**:
|
||||
|
||||
- 모듈은 코어 trait 차용: `use App\Models\Concerns\HasUserOverrides;`
|
||||
- 시더 호출: `Model::syncOrCreateFromUpgrade()` 사용
|
||||
- listener / 훅 / `Module::registerHooks` 등록: 불필요 — trait 의 Eloquent updating 이벤트가 모듈 모델에도 동등 동작
|
||||
- trackable 필드는 사용자가 수정 가능한 필드만 (system 식별자 `type`/`code`/`slug` 등 절대 포함 금지)
|
||||
|
||||
---
|
||||
|
||||
## 14. SettingsMigrator
|
||||
|
||||
+165
-4
@@ -135,6 +135,18 @@ core.menu.update_validation_rules
|
||||
core.layout_extension.before_apply
|
||||
core.layout_extension.after_apply
|
||||
|
||||
# 드라이버 확장 훅 (Filter) — 플러그인이 새 드라이버를 등록
|
||||
core.settings.available_storage_drivers
|
||||
core.settings.available_cache_drivers
|
||||
core.settings.available_session_drivers
|
||||
core.settings.available_queue_drivers
|
||||
core.settings.available_log_drivers
|
||||
core.settings.available_websocket_drivers
|
||||
core.settings.available_mail_drivers
|
||||
|
||||
# 드라이버 확장 훅 (Action) — 플러그인 드라이버 선택 시 Config 적용
|
||||
core.settings.apply_driver_config
|
||||
|
||||
# SEO 렌더링 훅 (Filter)
|
||||
core.seo.filter_context
|
||||
core.seo.filter_meta
|
||||
@@ -181,13 +193,16 @@ class ProductCacheInvalidationListener implements HookListenerInterface
|
||||
*/
|
||||
public function handleProductChange(...$args): void
|
||||
{
|
||||
// CacheInterface 를 컨테이너에서 lazy resolve (모듈 리스너면 ModuleCacheDriver)
|
||||
$cache = app(\App\Contracts\Extension\CacheInterface::class);
|
||||
|
||||
// 모든 상품 목록 캐시 삭제
|
||||
Cache::forget('products.all');
|
||||
Cache::forget('products.active');
|
||||
$cache->forget('products.all');
|
||||
$cache->forget('products.active');
|
||||
|
||||
// 특정 상품 캐시 삭제
|
||||
if (isset($args[0]->id)) {
|
||||
Cache::forget("product.{$args[0]->id}");
|
||||
$cache->forget("product.{$args[0]->id}");
|
||||
}
|
||||
|
||||
Log::info('상품 캐시가 무효화되었습니다.', [
|
||||
@@ -199,6 +214,99 @@ class ProductCacheInvalidationListener implements HookListenerInterface
|
||||
|
||||
---
|
||||
|
||||
## 큐 자동 실행
|
||||
|
||||
Action 훅 리스너는 환경설정의 **큐 드라이버** 설정에 따라 자동으로 동기/비동기 실행됩니다. 리스너 개발자가 별도로 설정할 필요 없습니다.
|
||||
|
||||
### 큐 드라이버별 동작
|
||||
|
||||
| 큐 드라이버 | 리스너 실행 방식 | 큐 워커 필요 |
|
||||
|------------|-----------------|-------------|
|
||||
| `sync` | 즉시 실행 (동기) | 불필요 |
|
||||
| `database` | jobs 테이블 저장 → 워커 처리 | 필요 (`php artisan queue:work`) |
|
||||
| `redis` | Redis 큐 저장 → 워커 처리 | 필요 (`php artisan queue:work`) |
|
||||
| 커스텀 (플러그인 추가) | 해당 드라이버 큐 → 워커 처리 | 필요 |
|
||||
|
||||
> **Filter 훅**은 반환값 체인이므로 **항상 동기 실행**됩니다 (큐 드라이버 무관).
|
||||
|
||||
### 동기 실행 강제 (opt-out)
|
||||
|
||||
리스너가 반드시 HTTP 요청 스레드에서 동기 실행되어야 하는 경우, `'sync' => true`를 선언합니다.
|
||||
|
||||
```php
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.user.after_create' => [
|
||||
'method' => 'handleUserCreated',
|
||||
'priority' => 10,
|
||||
'sync' => true, // 큐 드라이버 무관하게 항상 즉시 실행
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### getSubscribedHooks() 옵션 요약
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `method` | string | `'handle'` | 실행할 메서드명 |
|
||||
| `priority` | int | `10` | 실행 우선순위 (낮을수록 먼저) |
|
||||
| `type` | string | `'action'` | `'action'` 또는 `'filter'` |
|
||||
| `sync` | bool | `false` | `true`: 큐 드라이버 무관하게 동기 실행 |
|
||||
|
||||
### 내부 구현
|
||||
|
||||
`HookListenerRegistrar`가 리스너 등록 시 큐/동기 분기를 처리합니다:
|
||||
|
||||
- Action + `sync: false` (기본) → `DispatchHookListenerJob`으로 래핑하여 `dispatch()`
|
||||
- Action + `sync: true` → 기존 방식 동기 실행
|
||||
- Filter → 항상 동기 실행
|
||||
|
||||
`DispatchHookListenerJob`은 리스너 클래스명과 메서드명을 직렬화하고, 큐 워커에서 DI 컨테이너로 리스너를 재생성하여 호출합니다.
|
||||
|
||||
### 사용자 컨텍스트 자동 복원
|
||||
|
||||
큐 워커는 별도 프로세스이므로 `Auth::user()`, `request()->ip()`, `App::getLocale()` 등이 모두 리셋됩니다. 이를 보완하기 위해 `HookContextCapture`가 디스패치 시점에 다음 항목을 자동 캡처하고, 워커에서 복원합니다:
|
||||
|
||||
| 항목 | 캡처 출처 | 복원 효과 |
|
||||
| ---- | --------- | --------- |
|
||||
| `user_id` | `Auth::id()` | 워커에서 `Auth::user()` 사용 가능 (활동로그 actor 정상 기록) |
|
||||
| `ip_address` | `request()->ip()` | 워커에서 `request()->ip()` 사용 가능 |
|
||||
| `user_agent` | `request()->userAgent()` | 워커에서 `request()->userAgent()` 사용 가능 |
|
||||
| `locale` | `App::getLocale()` | 워커에서 다국어 메시지가 원래 요청 로케일로 발송됨 |
|
||||
| `path` | `request()->path()` | `ResolvesActivityLogType`이 워커에서 정상 동작 |
|
||||
|
||||
리스너 코드는 변경 불필요 — 평소처럼 `Auth::user()`, `request()->ip()` 호출하면 됩니다.
|
||||
|
||||
#### 확장 컨텍스트 추가 (플러그인)
|
||||
|
||||
플러그인이 `tenant_id`, `trace_id` 등 추가 컨텍스트를 캡처/복원하려면 코어 수정 없이 훅으로 확장 가능:
|
||||
|
||||
```php
|
||||
// 캡처 시 키 추가
|
||||
HookManager::addFilter('hook.context.capture', function (array $context) {
|
||||
$context['tenant_id'] = app('tenant')->id;
|
||||
return $context;
|
||||
});
|
||||
|
||||
// 복원 시 처리
|
||||
HookManager::addAction('hook.context.restore', function (array $context) {
|
||||
if (! empty($context['tenant_id'])) {
|
||||
app('tenant')->setId($context['tenant_id']);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 주의사항
|
||||
|
||||
- 큐 디스패치 시 인자는 `HookArgumentSerializer`로 직렬화됩니다. Eloquent Model은 PK로 변환 후 워커에서 DB 재조회합니다.
|
||||
- Closure 등 직렬화 불가능한 인자는 null로 대체되므로, 훅 인자로 Closure를 전달하지 마세요.
|
||||
- 큐 드라이버가 `database`/`redis`일 때 큐 워커가 미실행이면 작업이 적체됩니다.
|
||||
- 사용자 컨텍스트는 자동 복원되지만, `request()->session()` 등 세션 의존 정보는 복원되지 않습니다 (큐 컨텍스트는 단발 실행).
|
||||
|
||||
---
|
||||
|
||||
## 코어 리스너 자동 발견
|
||||
|
||||
코어 애플리케이션의 훅 리스너는 `app/Listeners/` 디렉토리에서 **자동 발견**됩니다. `HookListenerInterface`를 구현한 클래스는 별도 등록 없이 자동으로 HookManager에 등록됩니다.
|
||||
@@ -287,6 +395,59 @@ private function registerCoreHookListeners(): void
|
||||
| 위치 | `app/Listeners/**/*.php` | `modules/**/Listeners/`, `plugins/**/Listeners/` |
|
||||
| 등록 주체 | `CoreServiceProvider` | `ModuleServiceProvider`, `PluginServiceProvider` |
|
||||
|
||||
### 동적 훅 리스너 (DB 기반)
|
||||
|
||||
DB 설정에 따라 훅 구독 대상이 동적으로 변하는 경우, 리스너에 `registerDynamicHooks()` 메서드를 구현합니다.
|
||||
자동 발견 시스템이 이 메서드를 감지하여 `boot()` 후반부(DB 접근 가능 시점)에서 자동 호출합니다.
|
||||
|
||||
```php
|
||||
class NotificationHookListener implements HookListenerInterface
|
||||
{
|
||||
// 정적 훅은 빈 배열 (DB 기반이므로)
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* DB 기반 동적 훅을 등록합니다.
|
||||
* CoreServiceProvider 자동 발견 시스템이 boot 후반부에서 자동 호출합니다.
|
||||
*/
|
||||
public function registerDynamicHooks(): void
|
||||
{
|
||||
// notification_definitions 테이블에서 훅 목록 조회
|
||||
// 각 훅에 대해 HookManager::addAction() 등록
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 조건 | 설명 |
|
||||
|------|------|
|
||||
| 메서드명 | `registerDynamicHooks()` (덕 타이핑) |
|
||||
| 호출 시점 | `CoreServiceProvider::boot()` 후반부 (DB 유효성 검증 후) |
|
||||
| 별도 인터페이스 | 불필요 — `method_exists()` 체크 |
|
||||
| 안전성 | 테이블 미존재 시 `Schema::hasTable()` 체크 필수 |
|
||||
|
||||
---
|
||||
|
||||
### HookManager::broadcast() — WebSocket 브로드캐스트
|
||||
|
||||
훅 리스너에서 WebSocket 브로드캐스트를 실행할 때 사용합니다.
|
||||
|
||||
```php
|
||||
use App\Extension\HookManager;
|
||||
|
||||
HookManager::broadcast(
|
||||
'user.notifications.123', // 채널
|
||||
'notification.received', // 이벤트명
|
||||
['subject' => '새 알림'] // 데이터
|
||||
);
|
||||
```
|
||||
|
||||
개별 Event 클래스(`app/Events/`)를 직접 생성하지 않습니다. `HookManager::broadcast()`가 내부적으로 `GenericBroadcastEvent`를 사용합니다.
|
||||
|
||||
---
|
||||
|
||||
## 우선순위 가이드라인
|
||||
@@ -870,7 +1031,7 @@ public function download(int $id, ?User $user): ?Attachment
|
||||
| `[module].product.after_update` | `onProductUpdate` | 해당 URL + 목록 |
|
||||
| `[module].*.after_delete` | `onProductChange` | 전체 관련 캐시 |
|
||||
|
||||
캐시 무효화 시 `Cache::forget('seo:sitemap')`도 함께 호출
|
||||
캐시 무효화 시 `app(CacheInterface::class)->forget('seo.sitemap')` 도 함께 호출 (드라이버가 `g7:core:` 접두사 자동 적용)
|
||||
|
||||
> 구현 상세: [seo-system.md](../backend/seo-system.md)
|
||||
|
||||
|
||||
@@ -288,7 +288,13 @@ v1.17.0 이전: modals 내부의 extension_point는 처리되지 않았음
|
||||
{
|
||||
"type": "extension_point",
|
||||
"name": "user_form_additional_fields",
|
||||
"default": []
|
||||
"default": [],
|
||||
"props": {
|
||||
"readOnlyFields": ["zipcode", "address"]
|
||||
},
|
||||
"callbacks": {
|
||||
"onAddressSelect": { "handler": "setState", "params": { "target": "local", "form.zipcode": "{{$event.zipcode}}" } }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -296,6 +302,13 @@ v1.17.0 이전: modals 내부의 extension_point는 처리되지 않았음
|
||||
}
|
||||
```
|
||||
|
||||
#### Extension Point 데이터 전달 (engine-v1.28.0+)
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `props` | object | 주입 컴포넌트에 전달할 데이터. 표현식 평가됨. 플러그인에서 `{{extensionPointProps.xxx}}`로 접근 |
|
||||
| `callbacks` | object | 주입 컴포넌트에 전달할 액션 객체. 평가 없이 그대로 전달. 플러그인에서 `{{extensionPointCallbacks.xxx}}`로 접근 |
|
||||
|
||||
### 모듈에서 Extension Point에 주입
|
||||
|
||||
```json
|
||||
|
||||
@@ -386,8 +386,38 @@ $role->menus()->wherePivot('permission_type', 'write')->get();
|
||||
|
||||
---
|
||||
|
||||
## 완전 동기화 (Stale Cleanup)
|
||||
|
||||
코어/확장 업그레이드 시 메뉴 동기화는 **완전 동기화** — config/manifest 에서 제거된 메뉴는 DB 에서도 삭제됩니다. 유지되는 메뉴는 사용자 수정(`user_overrides` 컬럼) 에 따라 필드 단위 보존이 적용됩니다.
|
||||
|
||||
### 적용 지점
|
||||
|
||||
| 경로 | 호출 시점 | cleanup 대상 |
|
||||
|---|---|---|
|
||||
| `CoreUpdateService::syncCoreMenus` 말미 | 코어 업데이트 | `core/core` 소유 메뉴 전체 slug |
|
||||
| `ModuleManager::updateModule` 내 sync 블록 말미 | 모듈 업데이트 | 해당 모듈 소유 메뉴 |
|
||||
|
||||
### 핵심 정책
|
||||
|
||||
- **row 존재 여부**: config 기준 (user_overrides 무관 삭제)
|
||||
- **필드 값 (유지 row)**: user_overrides 에 등록된 필드 (`name`, `icon`, `order`, `url`) 보존, 나머지 갱신
|
||||
- **role-menu 매핑**: 메뉴 삭제 시 `role_menus` 피벗은 FK cascade 로 자동 정리
|
||||
|
||||
### 사용자 수정 기록
|
||||
|
||||
`Menu` 모델은 `HasUserOverrides` trait + `$trackableFields = ['name', 'icon', 'order', 'url']` 적용. 사용자가 UI/API 로 수정하면 trait 이 자동으로 user_overrides 에 누적 기록합니다 (mass update 포함).
|
||||
|
||||
### Helper (권장)
|
||||
|
||||
직접 Model 조작 금지. `ExtensionMenuSyncHelper` 를 통해 sync/cleanup 수행. 자세한 사용법은 [data-sync-helpers.md](../backend/data-sync-helpers.md#3-extensionmenusynchelper) 참조.
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [permissions.md](./permissions.md) - 권한 시스템 (Role, Permission, 리소스 레벨 권한)
|
||||
- [module-basics.md](./module-basics.md) - 모듈 기본 구조
|
||||
- [index.md](./index.md) - 확장 시스템 인덱스
|
||||
- [../backend/core-config.md](../backend/core-config.md#완전-동기화-원칙) - 완전 동기화 원칙
|
||||
- [../backend/data-sync-helpers.md](../backend/data-sync-helpers.md) - 데이터 동기화 Helper
|
||||
- [../backend/user-overrides.md](../backend/user-overrides.md) - 사용자 수정 보존
|
||||
@@ -43,7 +43,9 @@
|
||||
2. TemplateApp.init()
|
||||
└─ ModuleAssetLoader.loadActiveExtensionAssets()
|
||||
├─ CSS 로드 (병렬)
|
||||
└─ JS 로드 (순차)
|
||||
└─ JS 로드 (병렬 fetch + 순차 실행)
|
||||
├─ priority 오름차순 정렬 후 DOM append
|
||||
├─ script.async = false → 삽입 순서대로 실행 보장 (HTML 사양)
|
||||
└─ 각 모듈의 initModule() 실행
|
||||
└─ ActionDispatcher.registerHandler()
|
||||
|
||||
@@ -51,6 +53,11 @@
|
||||
└─ 모듈 핸들러 사용 가능
|
||||
```
|
||||
|
||||
> **성능 참고**: JS 번들은 `Promise.all` 로 병렬 fetch 되며, `script.async = false` 와
|
||||
> priority 정렬된 DOM append 순서로 **실행 순서는 유지**됩니다. N 개의 확장 IIFE 로딩이
|
||||
> N × (fetch 시간) 에서 max(fetch 시간) 으로 단축됩니다. 단, 확장 간 런타임 의존성
|
||||
> (다른 확장의 window 전역/핸들러 참조) 이 발생하면 priority 필드로 실행 순서를 명시해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 에셋 매니페스트 스키마
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
| 메서드 | 반환 타입 | 설명 |
|
||||
|--------|----------|------|
|
||||
| `getIdentifier()` | `string` | 디렉토리명에서 자동 추론 (예: `sirsoft-ecommerce`) |
|
||||
| `getVendor()` | `string` | 디렉토리명에서 자동 추론 (예: `sirsoft`) |
|
||||
| `getVendor()` | `string` | `module.json` 의 `vendor` 필드를 우선 사용. 값이 없으면 디렉토리명의 첫 단어로 폴백 (예: `sirsoft`) |
|
||||
|
||||
### 기본값 제공 메서드 (필요시 오버라이드)
|
||||
|
||||
@@ -123,6 +123,9 @@
|
||||
| `getViews()` | `[]` | 뷰 파일 |
|
||||
| `getPermissions()` | `[]` | 권한 목록 (resource_route_key, owner_key, roles scope_type 지원) |
|
||||
| `getRoles()` | `[]` | 역할 목록 |
|
||||
| `getDynamicPermissionIdentifiers()` | `[]` | 런타임 생성 권한 식별자 — stale cleanup 보존 대상 (아래 참조) |
|
||||
| `getDynamicRoleIdentifiers()` | `[]` | 런타임 생성 역할 식별자 — stale cleanup 보존 대상 |
|
||||
| `getDynamicMenuSlugs()` | `[]` | 런타임 생성 메뉴 slug — stale cleanup 보존 대상 |
|
||||
| `getConfig()` | `[]` | 설정 |
|
||||
| `getAdminMenus()` | `[]` | 관리자 메뉴 |
|
||||
| `getHookListeners()` | `[]` | 훅 리스너 |
|
||||
@@ -130,6 +133,33 @@
|
||||
| `getMetadata()` | `[]` | 메타데이터 |
|
||||
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견) |
|
||||
|
||||
#### 동적 권한/역할/메뉴 보존 규칙
|
||||
|
||||
모듈이 런타임에 `Permission::updateOrCreate` / `Role::firstOrCreate` / `Menu::create` 등으로 동적 엔티티를 만드는 경우(예: sirsoft-board 의 게시판 slug 별 권한·역할·메뉴), 업데이트 시 `cleanupStale*` 로직이 **정적 정의에 없다** 는 이유로 전수 삭제되는 회귀가 발생한다. 이를 방지하려면 아래 3개 메서드를 override 해 **현재 DB 에 존재해야 하는 동적 식별자 전체** 를 반환한다.
|
||||
|
||||
```php
|
||||
public function getDynamicPermissionIdentifiers(): array
|
||||
{
|
||||
if (! Schema::hasTable('boards')) { return []; }
|
||||
$actions = array_keys((array) config('sirsoft-board.board_permission_definitions', []));
|
||||
$module = $this->getIdentifier();
|
||||
$ids = [];
|
||||
foreach (Board::query()->select('slug')->get() as $board) {
|
||||
$ids[] = "{$module}.{$board->slug}"; // 카테고리
|
||||
foreach ($actions as $a) {
|
||||
$ids[] = "{$module}.{$board->slug}.{$a}"; // 액션
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
```
|
||||
|
||||
보존 원칙:
|
||||
|
||||
- **업데이트 경로**: `updateModule()` → `cleanupStaleModuleEntries()` 는 정적 + 동적 식별자를 병합한 expected 목록을 기준으로 stale 판정. 동적 식별자가 정확히 반환되면 유실 없음.
|
||||
- **언인스톨 경로**: `uninstallModule($deleteData=false)` 는 권한·메뉴·역할을 **보존** (재설치 시 사용자 역할 할당 복원). `deleteData=true` 일 때만 전수 삭제.
|
||||
- **설치 경로**: `installModule(--force)` 는 cleanup 을 실행하지 않아 동적 엔티티 유실 없음.
|
||||
|
||||
### 간결한 모듈 구현 예시
|
||||
|
||||
> **참고**: `getName()`, `getVersion()`, `getDescription()`은 `module.json`에서 자동 파싱되므로 오버라이드가 불필요합니다.
|
||||
@@ -504,7 +534,7 @@ modules/_bundled/sirsoft-ecommerce/
|
||||
|
||||
| 디렉토리 | 설명 |
|
||||
|----------|------|
|
||||
| `module.json` | 메타데이터 SSoT (이름, 버전, 설명, 의존성, 라이선스 등) |
|
||||
| `module.json` | 메타데이터 SSoT (이름, 버전, 설명, 의존성, 라이선스 등) — 버전 제약 정책은 [changelog-rules.md](changelog-rules.md#8-코어-버전-제약-정책) 참조 |
|
||||
| `module.php` | ModuleInterface 구현 (진입점) |
|
||||
| `LICENSE` | 라이선스 전문 (MIT 등) — API 엔드포인트 `GET /api/admin/modules/{identifier}/license`로 제공 |
|
||||
| `composer.json` | PSR-4 오토로딩 + 외부 패키지 의존성 설정 |
|
||||
@@ -614,6 +644,78 @@ powershell -Command "npm run test:run"
|
||||
|
||||
---
|
||||
|
||||
## SEO 변수 선언 (seoVariables)
|
||||
|
||||
모듈이 SEO 메타 데이터(제목/설명)에 동적 변수를 제공하려면 `seoVariables()` 메서드를 오버라이드합니다.
|
||||
|
||||
### 오버라이드 시점
|
||||
|
||||
- 모듈이 SEO 대상 페이지를 제공하는 경우 (상품 상세, 카테고리 목록 등)
|
||||
- 모듈 설정의 SEO 템플릿(`seo.meta_{page_type}_title`)에서 `{key}` 변수 치환이 필요한 경우
|
||||
|
||||
### 구조
|
||||
|
||||
```php
|
||||
public function seoVariables(): array
|
||||
{
|
||||
return [
|
||||
// _common: 모든 page_type에 공통 적용
|
||||
'_common' => [
|
||||
'site_name' => ['source' => 'core_setting', 'key' => 'general.site_name'],
|
||||
'commerce_name' => ['source' => 'setting', 'key' => 'basic_info.shop_name'],
|
||||
],
|
||||
// page_type별 변수
|
||||
'product' => [
|
||||
'product_name' => ['source' => 'data', 'key' => 'product.data.name'],
|
||||
'product_description' => ['source' => 'data', 'key' => 'product.data.short_description'],
|
||||
],
|
||||
'category' => [
|
||||
'category_name' => ['source' => 'data', 'key' => 'category.data.name'],
|
||||
],
|
||||
'search' => [
|
||||
'keyword_name' => ['source' => 'query', 'key' => 'q'],
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 소스 타입
|
||||
|
||||
| source | 설명 | 자동 해석 |
|
||||
|--------|------|----------|
|
||||
| `setting` | 해당 모듈의 설정 값 | ✅ |
|
||||
| `core_setting` | 코어 설정 값 | ✅ |
|
||||
| `query` | URL 쿼리 파라미터 | ✅ |
|
||||
| `route` | URL 라우트 파라미터 | ✅ |
|
||||
| `data` | 데이터소스 응답 데이터 (레이아웃 `vars`에서 매핑 필요) | ❌ |
|
||||
|
||||
### 레이아웃에서 사용
|
||||
|
||||
레이아웃 JSON의 `meta.seo.extensions`에 모듈을 선언하면 `seoVariables()`가 자동 호출됩니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"seo": {
|
||||
"extensions": [{ "type": "module", "id": "sirsoft-ecommerce" }],
|
||||
"page_type": "product",
|
||||
"vars": {
|
||||
"product_name": "{{product.data.name ?? ''}}",
|
||||
"product_description": "{{product.data.short_description ?? ''}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `setting`, `core_setting`, `query`, `route` 소스는 SeoRenderer가 자동 해석
|
||||
- `data` 소스 변수는 `vars`에서 표현식으로 매핑 필요
|
||||
- 설치 시 `ValidatesSeoVariables` 트레이트가 변수명 고유성 검증
|
||||
|
||||
> 상세: [seo-system.md](../backend/seo-system.md) "SEO 변수 시스템" 참조
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [index.md](./index.md) - 확장 시스템 개요
|
||||
|
||||
@@ -220,7 +220,7 @@ php artisan module:check-updates sirsoft-ecommerce
|
||||
#### 모듈 업데이트
|
||||
|
||||
```bash
|
||||
php artisan module:update [identifier] [--force]
|
||||
php artisan module:update [identifier] [--force] [--source=auto|bundled|github] [--layout-strategy=overwrite|keep] [--vendor-mode=auto|composer|bundled]
|
||||
```
|
||||
|
||||
**인수**:
|
||||
@@ -228,6 +228,9 @@ php artisan module:update [identifier] [--force]
|
||||
|
||||
**옵션**:
|
||||
- `--force`: 버전 비교 없이 강제 업데이트 (파일 손상, 수동 수정 복원 시 사용)
|
||||
- `--source`: 업데이트 소스 강제 (`auto` 기본 = GitHub > `_bundled` 우선순위, `bundled` = `_bundled` 단독, `github` = GitHub 단독)
|
||||
- `--layout-strategy`: 사용자 수정 레이아웃 처리 (`overwrite` = 전체 덮어쓰기, `keep` = 수정분 보존)
|
||||
- `--vendor-mode`: vendor 설치 방식 (`auto` 기본, `composer`, `bundled`)
|
||||
|
||||
**수행 작업**:
|
||||
- 업데이트 가용 여부 확인 (없으면 info + SUCCESS, `--force` 시 무시하고 진행)
|
||||
@@ -239,6 +242,8 @@ php artisan module:update [identifier] [--force]
|
||||
```bash
|
||||
php artisan module:update sirsoft-ecommerce
|
||||
php artisan module:update sirsoft-ecommerce --force # 강제 재설치
|
||||
php artisan module:update sirsoft-ecommerce --source=bundled # GitHub 장애/잘못된 태그 우회
|
||||
php artisan module:update sirsoft-ecommerce --source=github --force # 긴급 핫픽스 태그로만 강제 설치
|
||||
```
|
||||
|
||||
#### 모듈 캐시 초기화
|
||||
|
||||
@@ -495,12 +495,14 @@ $content = $layoutData;
|
||||
### 무효화 메서드
|
||||
|
||||
```php
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
|
||||
protected function invalidateLayoutCache(string $moduleIdentifier): void
|
||||
{
|
||||
// 1. 태그 기반 캐시 삭제
|
||||
if (method_exists(Cache::getStore(), 'tags')) {
|
||||
Cache::tags(['layouts', $moduleIdentifier])->flush();
|
||||
}
|
||||
$cache = app(CacheInterface::class); // 드라이버가 `g7:core:` 접두사 자동 적용
|
||||
|
||||
// 1. 태그 기반 캐시 삭제 (CacheInterface::flushTags)
|
||||
$cache->flushTags(['layouts', $moduleIdentifier]);
|
||||
|
||||
// 2. 개별 레이아웃 캐시 삭제
|
||||
$moduleLayouts = TemplateLayout::where('source_type', LayoutSourceType::Module)
|
||||
@@ -508,8 +510,7 @@ protected function invalidateLayoutCache(string $moduleIdentifier): void
|
||||
->get();
|
||||
|
||||
foreach ($moduleLayouts as $layout) {
|
||||
$cacheKey = "layout.{$layout->template_id}.{$layout->name}";
|
||||
Cache::forget($cacheKey);
|
||||
$cache->forget("template.{$layout->template_id}.layout.{$layout->name}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -117,6 +117,83 @@ public function isAdmin(): bool
|
||||
],
|
||||
```
|
||||
|
||||
### 코어 권한에서 타입 지정 (config/core.php)
|
||||
|
||||
코어 권한(`config/core.php`의 `categories`)도 모듈/플러그인 매니페스트와 **동일하게 `type` 필드를 지원**합니다. `CoreUpdateService::syncCoreRolesAndPermissions()`가 이 필드를 읽어 admin/user 컨텍스트를 분리합니다.
|
||||
|
||||
```php
|
||||
// config/core.php
|
||||
'categories' => [
|
||||
[
|
||||
'identifier' => 'core.users',
|
||||
'category' => 'users',
|
||||
'order' => 1,
|
||||
'type' => 'admin', // ← 카테고리 타입 명시
|
||||
'permissions' => [
|
||||
['identifier' => 'core.users.read', 'type' => 'admin', ...], // ← 권한 타입 명시
|
||||
['identifier' => 'core.users.create', 'type' => 'admin', ...],
|
||||
],
|
||||
],
|
||||
[
|
||||
'identifier' => 'core.user-notifications',
|
||||
'category' => 'user-notifications',
|
||||
'order' => 7.6,
|
||||
'type' => 'user', // ← 사용자 컨텍스트 카테고리
|
||||
'permissions' => [
|
||||
['identifier' => 'core.user-notifications.read', 'type' => 'user', ...],
|
||||
],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
```text
|
||||
✅ 필수: 신규 코어 권한 정의 시 카테고리/개별 권한 모두에 `type` 명시 (가독성 + 의도 명확화)
|
||||
✅ 카테고리 type은 (1) 명시 필드 (2) 하위 권한이 모두 동일 type일 때 추론 (3) admin 기본값 우선순위로 결정됨
|
||||
✅ 권한 type 미지정 시 카테고리 type을 상속
|
||||
```
|
||||
|
||||
### CRITICAL: 단일 unique 제약 함정
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: permissions.identifier 컬럼은 단일 unique 제약입니다.
|
||||
⚠️ 같은 식별자로 (identifier='core.notifications.read', type='admin')과 (identifier='core.notifications.read', type='user') 두 행을 동시에 생성할 수 없습니다.
|
||||
```
|
||||
|
||||
| 잘못된 접근 | 올바른 접근 |
|
||||
|---|---|
|
||||
| `core.notifications.*` 하나의 식별자를 admin/user 양쪽에서 사용 | admin은 `core.notifications.*` (`type=admin`), 사용자는 `core.user-notifications.*` (`type=user`)로 **별도 식별자** 분리 |
|
||||
| 라우트 미들웨어에서 admin 식별자를 user 컨텍스트에 사용 | 사용자 라우트(`/api/user/...`)는 반드시 `type=user` 권한 행이 존재하는 식별자를 사용 |
|
||||
|
||||
`PermissionMiddleware`는 `(identifier, type)` 쌍으로 권한 행을 조회하므로, 라우트 미들웨어 `permission:user,xxx`는 DB에 `(identifier='xxx', type='user')` 행이 존재할 때만 통과합니다.
|
||||
|
||||
### 라우트 미들웨어 type 일치 규칙
|
||||
|
||||
```php
|
||||
// routes/api.php
|
||||
|
||||
// ❌ 잘못된 예: 사용자 라우트에 admin 타입 권한 식별자 사용
|
||||
Route::get('/api/user/notifications', [UserNotificationController::class, 'index'])
|
||||
->middleware('permission:user,core.notifications.read'); // 항상 403 — type 불일치
|
||||
|
||||
// ✅ 올바른 예: 사용자 라우트에는 user 타입 권한 식별자 사용
|
||||
Route::get('/api/user/notifications', [UserNotificationController::class, 'index'])
|
||||
->middleware('permission:user,core.user-notifications.read');
|
||||
|
||||
// ✅ 관리자 라우트에는 admin 타입 권한 식별자 사용
|
||||
Route::get('/api/admin/notifications', [AdminNotificationController::class, 'index'])
|
||||
->middleware('permission:admin,core.notifications.read');
|
||||
```
|
||||
|
||||
`Broadcast::channel()` 권한 체크에서도 동일하게 `PermissionType` 인자를 명시해야 합니다:
|
||||
|
||||
```php
|
||||
// routes/channels.php
|
||||
Broadcast::channel('core.user.notifications.{uuid}', function ($user, $uuid) {
|
||||
return $user->uuid === $uuid
|
||||
&& $user->hasPermission('core.user-notifications.read', PermissionType::User);
|
||||
});
|
||||
```
|
||||
|
||||
### Permission 모델 헬퍼
|
||||
|
||||
```php
|
||||
@@ -808,9 +885,36 @@ generateIdentifier(name)
|
||||
|
||||
---
|
||||
|
||||
## 완전 동기화 (Stale Cleanup)
|
||||
|
||||
코어/확장 업그레이드 시 권한·역할 동기화는 단순 upsert 가 아니라 **완전 동기화** 를 수행합니다. config/manifest 에서 제거된 권한·역할은 DB 에서도 삭제됩니다.
|
||||
|
||||
### 적용 지점
|
||||
|
||||
| 경로 | 호출 시점 | cleanup 대상 |
|
||||
|---|---|---|
|
||||
| `CoreUpdateService::syncCoreRolesAndPermissions` 말미 | 코어 업데이트 | `core/core` 소유 권한·역할 |
|
||||
| `ModuleManager::updateModule` 내 sync 블록 말미 | 모듈 업데이트 | 해당 모듈 소유 권한·역할 |
|
||||
| `PluginManager::updatePlugin` 내 sync 블록 말미 | 플러그인 업데이트 | 해당 플러그인 소유 권한·역할 |
|
||||
|
||||
### 핵심 정책
|
||||
|
||||
- **권한 stale 삭제**: 순수 diff — config 에 없으면 즉시 삭제 (user_overrides 개념 없음)
|
||||
- **역할 stale 삭제**: config 에 없으면 삭제. 단 **`user_roles` 피벗에 사용자가 참조 중인 역할은 삭제 차단 + 경고 로그** (수동 재배정 유도)
|
||||
- **역할-권한 매핑**: `syncAllRoleAssignments` 가 `core/core` 전체 권한을 diff 기준으로 사용하여 이관된 구 식별자도 detach
|
||||
|
||||
### Helper (권장)
|
||||
|
||||
직접 Model 조작 금지. `ExtensionRoleSyncHelper` 를 통해 sync/cleanup 수행. 자세한 사용법은 [data-sync-helpers.md](../backend/data-sync-helpers.md#4-extensionrolesynchelper) 참조.
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [menus.md](./menus.md) - 메뉴 권한 시스템
|
||||
- [hooks.md](./hooks.md) - 훅 권한 시스템
|
||||
- [module-basics.md](./module-basics.md) - 모듈 기본 구조
|
||||
- [index.md](./index.md) - 확장 시스템 인덱스
|
||||
- [../backend/core-config.md](../backend/core-config.md#완전-동기화-원칙) - 완전 동기화 원칙
|
||||
- [../backend/data-sync-helpers.md](../backend/data-sync-helpers.md) - 데이터 동기화 Helper
|
||||
- [../backend/user-overrides.md](../backend/user-overrides.md) - 사용자 수정 보존
|
||||
@@ -215,7 +215,7 @@ class Plugin implements PluginInterface
|
||||
| 메서드 | 반환 타입 | 설명 |
|
||||
|--------|----------|------|
|
||||
| `getIdentifier()` | `string` | 디렉토리명에서 자동 추론 (예: `sirsoft-payment`) |
|
||||
| `getVendor()` | `string` | 디렉토리명에서 자동 추론 (예: `sirsoft`) |
|
||||
| `getVendor()` | `string` | `plugin.json` 의 `vendor` 필드를 우선 사용. 값이 없으면 디렉토리명의 첫 단어로 폴백 (예: `sirsoft`) |
|
||||
|
||||
### 기본값 제공 메서드 (필요시 오버라이드)
|
||||
|
||||
@@ -226,10 +226,14 @@ class Plugin implements PluginInterface
|
||||
| `activate()` | `true` | 활성화 로직 |
|
||||
| `deactivate()` | `true` | 비활성화 로직 |
|
||||
| `getDynamicTables()` | `[]` | 런타임 동적 생성 테이블 목록 (언인스톨 시 Manager가 삭제) |
|
||||
| `getDynamicPermissionIdentifiers()` | `[]` | 런타임 생성 권한 식별자 — stale cleanup 보존 대상 |
|
||||
| `getDynamicRoleIdentifiers()` | `[]` | 런타임 생성 역할 식별자 — stale cleanup 보존 대상 |
|
||||
| `getDependencies()` | `[]` | 의존하는 모듈/플러그인 목록 |
|
||||
| `getHookListeners()` | `[]` | 훅 리스너 클래스 목록 |
|
||||
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견) |
|
||||
|
||||
> **동적 식별자 보존 규칙**: `Permission::updateOrCreate()` / `Role::firstOrCreate()` 등으로 런타임에 생성한 엔티티는 업데이트 시 `cleanupStalePluginEntries` 에 의해 "정적 정의에 없는 고아 레코드" 로 판정되어 삭제될 위험이 있습니다. 이를 방지하려면 동적 식별자 목록을 위 3개 훅에서 반환하세요 — 정적 정의 + 동적 식별자가 병합된 expected 목록을 기준으로 판정되어 보존됩니다. 상세는 [extension-update-system.md](extension-update-system.md) 참조.
|
||||
|
||||
---
|
||||
|
||||
## composer.json 작성법
|
||||
@@ -311,7 +315,7 @@ plugins/_bundled/sirsoft-payment/
|
||||
|
||||
| 디렉토리 | 설명 |
|
||||
|----------|------|
|
||||
| `plugin.json` | 메타데이터 SSoT (이름, 버전, 설명, 의존성, 라이선스 등) |
|
||||
| `plugin.json` | 메타데이터 SSoT (이름, 버전, 설명, 의존성, 라이선스 등) — 버전 제약 정책은 [changelog-rules.md](changelog-rules.md#8-코어-버전-제약-정책) 참조 |
|
||||
| `plugin.php` | PluginInterface 구현 파일 (루트에 위치) |
|
||||
| `LICENSE` | 라이선스 전문 (MIT 등) — API 엔드포인트 `GET /api/admin/plugins/{identifier}/license`로 제공 |
|
||||
| `composer.json` | PSR-4 오토로딩 + 외부 패키지 의존성 설정 |
|
||||
@@ -1144,6 +1148,64 @@ php artisan plugin:seed sirsoft-payment --force
|
||||
|
||||
---
|
||||
|
||||
## SEO 변수 선언 (seoVariables)
|
||||
|
||||
플러그인이 SEO 메타 데이터(제목/설명)에 동적 변수를 제공하려면 `seoVariables()` 메서드를 오버라이드합니다. 모듈과 동일한 API입니다.
|
||||
|
||||
### 오버라이드 시점
|
||||
|
||||
- 플러그인이 SEO 대상 페이지(예: 결제 완료 페이지)를 layout_extensions로 확장하는 경우
|
||||
- 플러그인 설정의 SEO 템플릿에서 `{key}` 변수 치환이 필요한 경우
|
||||
|
||||
### 구조
|
||||
|
||||
```php
|
||||
public function seoVariables(): array
|
||||
{
|
||||
return [
|
||||
'_common' => [
|
||||
'site_name' => ['source' => 'core_setting', 'key' => 'general.site_name'],
|
||||
],
|
||||
'checkout' => [
|
||||
'payment_name' => ['source' => 'setting', 'key' => 'basic.payment_name'],
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 소스 타입
|
||||
|
||||
| source | 설명 | 자동 해석 |
|
||||
|--------|------|----------|
|
||||
| `setting` | 해당 플러그인의 설정 값 | ✅ |
|
||||
| `core_setting` | 코어 설정 값 | ✅ |
|
||||
| `query` | URL 쿼리 파라미터 | ✅ |
|
||||
| `route` | URL 라우트 파라미터 | ✅ |
|
||||
| `data` | 데이터소스 응답 데이터 (레이아웃 `vars`에서 매핑 필요) | ❌ |
|
||||
|
||||
### 레이아웃에서 사용
|
||||
|
||||
레이아웃 JSON의 `meta.seo.extensions`에 플러그인을 선언합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"seo": {
|
||||
"extensions": [{ "type": "plugin", "id": "sirsoft-payment" }],
|
||||
"page_type": "checkout"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `_common` 키: 모든 page_type에 공통 적용 (page_type별 선언이 우선)
|
||||
- 설치 시 `ValidatesSeoVariables` 트레이트가 변수명 고유성 검증
|
||||
- `data` 소스 변수만 `vars`에서 표현식 매핑 필요, 나머지 소스는 자동 해석
|
||||
|
||||
> 상세: [seo-system.md](../backend/seo-system.md) "SEO 변수 시스템" 참조
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [index.md](index.md) - 확장 시스템 전체 개요
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# 템플릿 캐싱 전략
|
||||
|
||||
> **관련 문서**: [template-basics.md](./template-basics.md) | [template-security.md](./template-security.md) | [index.md](./index.md)
|
||||
> **관련 문서**: [cache-driver.md](./cache-driver.md) (공통 캐시 인터페이스 · 권장) | [template-basics.md](./template-basics.md) | [template-security.md](./template-security.md) | [index.md](./index.md)
|
||||
|
||||
> **⚠️ 중요**: 본 문서는 템플릿 캐싱의 **개념적 계층**을 설명합니다.
|
||||
> 실제 구현은 [`CacheInterface`](./cache-driver.md) 를 사용해야 하며, `Cache::` 파사드 직접 호출은 금지되었습니다.
|
||||
> 모든 캐시 키는 드라이버 접두사(`g7:core:` / `g7:module.{id}:` / `g7:plugin.{id}:`)가 자동 적용됩니다.
|
||||
> TTL 은 `g7_core_settings('cache.*_ttl')` 중앙 관리를 따릅니다.
|
||||
|
||||
---
|
||||
|
||||
@@ -123,36 +128,44 @@ Cache::rememberForever("template.components.sirsoft-admin_basic", function () us
|
||||
|
||||
#### ClearsTemplateCaches Trait
|
||||
|
||||
모듈/플러그인/템플릿 매니저에서 사용하는 공통 trait입니다.
|
||||
모듈/플러그인/템플릿 매니저에서 사용하는 공통 trait입니다. 내부적으로 `CacheInterface` 를 lazy resolve 해서 사용합니다.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Traits;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
|
||||
trait ClearsTemplateCaches
|
||||
{
|
||||
// 드라이버 접두사 `g7:core:` 가 자동 적용되어 실제 저장 키는 `g7:core:ext.cache_version`
|
||||
private static string $extensionCacheVersionKey = 'ext.cache_version';
|
||||
|
||||
/**
|
||||
* 확장 기능 캐시 버전을 증가시킵니다.
|
||||
*
|
||||
* 모듈/플러그인 활성화/비활성화 시 호출되어
|
||||
* 프론트엔드가 새로운 캐시 버전으로 API를 요청하도록 합니다.
|
||||
*/
|
||||
protected function incrementExtensionCacheVersion(): void
|
||||
{
|
||||
Cache::put('extension_cache_version', time());
|
||||
self::resolveExtensionCache()->put(self::$extensionCacheVersionKey, time());
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 확장 기능 캐시 버전을 반환합니다.
|
||||
*
|
||||
* @return int 캐시 버전 (타임스탬프) 또는 0 (미설정 시)
|
||||
*/
|
||||
public static function getExtensionCacheVersion(): int
|
||||
{
|
||||
return (int) Cache::get('extension_cache_version', 0);
|
||||
return (int) self::resolveExtensionCache()->get(self::$extensionCacheVersionKey, 0);
|
||||
}
|
||||
|
||||
private static function resolveExtensionCache(): CacheInterface
|
||||
{
|
||||
try {
|
||||
return app(CacheInterface::class);
|
||||
} catch (\Throwable $e) {
|
||||
return new CoreCacheDriver(config('cache.default', 'array'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,7 +307,7 @@ Route::get('/extension/cache-version', function () {
|
||||
|
||||
| 저장소 | 키 | 값 | 용도 |
|
||||
|--------|-----|-----|------|
|
||||
| Laravel Cache | `extension_cache_version` | 타임스탬프 | 서버 측 버전 관리 |
|
||||
| Laravel Cache | `g7:core:ext.cache_version` | 타임스탬프 | 서버 측 버전 관리 (CacheInterface 경유) |
|
||||
| localStorage | `g7_extension_cache_version` | 타임스탬프 | 클라이언트 측 버전 유지 |
|
||||
|
||||
---
|
||||
|
||||
@@ -147,7 +147,7 @@ php artisan template:check-updates sirsoft-admin_basic
|
||||
템플릿을 업데이트합니다.
|
||||
|
||||
```bash
|
||||
php artisan template:update [identifier] [--layout-strategy=overwrite] [--force]
|
||||
php artisan template:update [identifier] [--layout-strategy=overwrite] [--force] [--source=auto|bundled|github]
|
||||
```
|
||||
|
||||
**인수**:
|
||||
@@ -156,6 +156,7 @@ php artisan template:update [identifier] [--layout-strategy=overwrite] [--force]
|
||||
**옵션**:
|
||||
- `--layout-strategy=overwrite`: 레이아웃 전략 (`overwrite` | `keep`)
|
||||
- `--force`: 버전 비교 없이 강제 업데이트 (파일 손상, 수동 수정 복원 시 사용)
|
||||
- `--source`: 업데이트 소스 강제 (`auto` 기본 = GitHub > `_bundled` 우선순위, `bundled` = `_bundled` 단독, `github` = GitHub 단독)
|
||||
|
||||
**수행 작업**:
|
||||
- 레이아웃 전략 검증 (`overwrite` | `keep` 외 FAILURE)
|
||||
@@ -174,6 +175,9 @@ php artisan template:update sirsoft-admin_basic --layout-strategy=keep
|
||||
|
||||
# 강제 재설치
|
||||
php artisan template:update sirsoft-admin_basic --force
|
||||
|
||||
# GitHub 장애/잘못된 태그 우회 — _bundled 단독 사용
|
||||
php artisan template:update sirsoft-admin_basic --source=bundled
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -83,6 +83,8 @@ Phase 6: 빌드 및 설치
|
||||
|
||||
## Phase 1: 필수 메타데이터 파일
|
||||
|
||||
> `g7_version` / `dependencies` 등 버전 제약 작성 규칙은 [changelog-rules.md](changelog-rules.md#8-코어-버전-제약-정책) 참조.
|
||||
|
||||
### 1.1 template.json
|
||||
|
||||
```json
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
# 업그레이드 스텝 작성 가이드 (Upgrade Step Guide)
|
||||
|
||||
> 버전 업그레이드 시 실행되는 `upgrades/Upgrade_X_Y_Z.php` 작성 규정
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. upgrade step 이 실행되는 환경은 경로에 따라 다르다 — 섹션 9 "업그레이드 경로" 먼저 읽기
|
||||
2. 대부분의 코어 upgrade step = 경로 B (spawn) → 신규 클래스/메서드 자유 사용 가능
|
||||
3. 인프라 재설계 릴리즈의 특수 경로 = 경로 C → docblock 에 `@upgrade-path C` 선언 + 로컬 로직 필수
|
||||
4. 경로 A (모듈/플러그인) · 경로 C 규율: 기존 클래스의 신규 메서드 호출 금지, 로컬 private 헬퍼 우선
|
||||
5. 모든 분기에 upgrade.log 출력 — 로그 없음 = 디버깅 단서 없음
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [배경 — 왜 이 가이드가 필요한가](#1-배경--왜-이-가이드가-필요한가)
|
||||
2. [PHP 클래스 캐싱 제약](#2-php-클래스-캐싱-제약)
|
||||
3. [작성 규칙](#3-작성-규칙)
|
||||
4. [허용/금지 패턴](#4-허용금지-패턴)
|
||||
5. [체크리스트](#5-체크리스트)
|
||||
6. [sudo 환경 / 소유권 고려사항](#6-sudo-환경--소유권-고려사항)
|
||||
7. [생명주기와 제거 시점](#7-생명주기와-제거-시점)
|
||||
8. [실전 사례](#8-실전-사례)
|
||||
9. [업그레이드 경로별 규율 (모듈/플러그인 · 코어 beta.3+ · 코어 beta.2 특수)](#9-업그레이드-경로별-규율)
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 — 왜 이 가이드가 필요한가
|
||||
|
||||
`php artisan core:update` 실행 흐름:
|
||||
|
||||
```
|
||||
Step 7 applyUpdate — 디스크의 app/**, config/**, upgrades/** 파일을 새 버전으로 덮어쓰기
|
||||
Step 8 Composer / vendor — 외부 프로세스 실행으로 vendor 재구성
|
||||
Step 9 runMigrations — DB 마이그레이션
|
||||
Step 10 runUpgradeSteps — upgrades/Upgrade_*.php 의 run() 호출 ← 여기
|
||||
Step 11 Cleanup — 캐시 초기화, 소유권 복원 등
|
||||
```
|
||||
|
||||
**주의**: Step 10 시점에 실행되는 PHP 프로세스는 **Step 1 부터 시작한 "이전 버전" 프로세스** 이다. applyUpdate 로 디스크 파일이 모두 새 버전으로 바뀌어도, 이미 메모리에 로드된 클래스는 재로드되지 않는다.
|
||||
|
||||
즉:
|
||||
|
||||
- `require_once` 되는 **upgrade 파일 자체** → 신규 파일이므로 새 코드 로드 OK
|
||||
- upgrade 파일 안에서 참조하는 **다른 클래스** → 이미 메모리에 있는 **이전 버전** 클래스를 사용
|
||||
|
||||
## 2. PHP 클래스 캐싱 제약
|
||||
|
||||
### 작동 원리
|
||||
|
||||
PHP 는 클래스를 한 번 로드하면 동일 프로세스 내에서 재정의 불가. Laravel 의 Composer autoloader 도 마찬가지 — 네임스페이스·클래스명 매핑이 캐시된 뒤에는 파일 교체가 무시된다.
|
||||
|
||||
### 구체 시나리오
|
||||
|
||||
이전 버전에서 `App\Extension\Helpers\FilePermissionHelper` 가 이미 사용되어 메모리에 로드된 상태에서:
|
||||
|
||||
```php
|
||||
// upgrades/Upgrade_X_Y_Z.php (새 버전 파일)
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
|
||||
// 새 버전에서 신설한 메서드
|
||||
FilePermissionHelper::newMethod();
|
||||
// → Call to undefined method FilePermissionHelper::newMethod() Fatal
|
||||
```
|
||||
|
||||
디스크의 `FilePermissionHelper.php` 는 새 버전으로 바뀌어 `newMethod()` 가 정의되어 있어도, PHP 는 **메모리의 이전 버전 클래스** 를 사용하므로 Fatal.
|
||||
|
||||
### 영향 받지 않는 대상
|
||||
|
||||
- **신규 추가된 클래스** (이전 버전에 존재하지 않던 파일) → autoload 시 새 코드 로드
|
||||
- 예: `App\Models\NotificationDefinition` 가 beta.2 에서 처음 도입된 경우, beta.1 → beta.2 upgrade step 에서 사용 가능
|
||||
|
||||
### 영향 받는 대상
|
||||
|
||||
- **이미 이전 버전에 존재하던 클래스에 새로 추가된 메서드/프로퍼티**
|
||||
- **이미 이전 버전에 존재하던 클래스의 시그니처 변경**
|
||||
|
||||
## 3. 작성 규칙
|
||||
|
||||
### 원칙 1 — 로컬 private 로직 우선
|
||||
|
||||
upgrade step 이 필요로 하는 로직은 **Upgrade 클래스 내부 private 메서드** 로 직접 작성한다. 공용 Helper 에 유사 로직이 있더라도 **이전 버전에 없는 메서드** 라면 호출 금지.
|
||||
|
||||
```php
|
||||
class Upgrade_7_0_0_beta_2 implements UpgradeStepInterface
|
||||
{
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$this->restoreVendorOwnership($context);
|
||||
}
|
||||
|
||||
// ✅ 로컬 private 메서드 — 메모리의 이전 버전 클래스와 무관
|
||||
private function restoreVendorOwnership(UpgradeContext $context): void
|
||||
{
|
||||
// 로직 인라인 작성
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 원칙 2 — 프레임워크/기존 클래스만 use
|
||||
|
||||
use 문은 아래 범주만 허용:
|
||||
|
||||
- `Illuminate\*` (Laravel 프레임워크)
|
||||
- `App\Contracts\Extension\UpgradeStepInterface`, `App\Extension\UpgradeContext`
|
||||
- **이전 버전에도 이미 존재하던** `App\Models\*`, `App\Services\*` 등
|
||||
- **새 버전에서 처음 도입된** 클래스 (예: 새 Seeder, 새 Model)
|
||||
|
||||
### 원칙 3 — 모든 분기에 upgrade.log 출력
|
||||
|
||||
upgrade step 의 로거는 `storage/logs/upgrade-YYYY-MM-DD.log` 에 기록된다. 조기 return 경로에도 **이유를 명시** 하는 로그를 남긴다.
|
||||
|
||||
```php
|
||||
if ($currentOwner === $expectedOwner) {
|
||||
$context->logger->info('[X.Y.Z] 이미 일치 — 복원 스킵');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
로그 없음은 디버깅 불가를 의미한다. 실패 보고를 받았을 때 **어느 분기에서 return 됐는지 추적할 수 없으면 원인 파악에 많은 시간이 소요**된다.
|
||||
|
||||
### 원칙 4 — 멱등성 보장
|
||||
|
||||
upgrade step 은 동일 버전으로 재실행될 수 있다(`--force` 옵션). 어떤 분기에서든 반복 실행이 안전해야 한다.
|
||||
|
||||
- 파일 생성 전 `File::exists()` 체크
|
||||
- 테이블/컬럼 조작 전 `Schema::hasTable/hasColumn` 체크
|
||||
- 데이터 이관은 "이미 이관됨" 플래그로 skip 가능하게 설계
|
||||
|
||||
## 4. 허용/금지 패턴
|
||||
|
||||
### ❌ 금지 — 기존 클래스의 신규 메서드 호출
|
||||
|
||||
```php
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
|
||||
// FilePermissionHelper 는 이전 버전에도 존재 → 메모리에 구 클래스 로드됨
|
||||
// inferWebServerOwnership() 가 새 버전에서 신설된 메서드라면 Fatal
|
||||
FilePermissionHelper::inferWebServerOwnership();
|
||||
```
|
||||
|
||||
### ✅ 허용 — 로컬 헬퍼로 직접 구현
|
||||
|
||||
```php
|
||||
private function inferWebServerOwnershipLocal(): array
|
||||
{
|
||||
$baseOwner = @fileowner(base_path());
|
||||
$candidates = ['storage/logs', 'storage/framework/views', /* ... */];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$owner = @fileowner(base_path($candidate));
|
||||
if ($owner !== false && $owner !== $baseOwner) {
|
||||
return [$owner, @filegroup(base_path($candidate)), $candidate];
|
||||
}
|
||||
}
|
||||
|
||||
return [$baseOwner, @filegroup(base_path()), 'base_path'];
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ 금지 — 기존 Model 의 신규 메서드/스코프 호출
|
||||
|
||||
```php
|
||||
use App\Models\User;
|
||||
|
||||
// User 가 이전 버전에도 존재 → 메모리 구 클래스
|
||||
// 새 버전에서 추가된 scope 는 Fatal
|
||||
User::scopeNewlyAdded()->get();
|
||||
```
|
||||
|
||||
### ✅ 허용 — DB 파사드로 직접 쿼리
|
||||
|
||||
```php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
DB::table('users')->where(/* ... */)->get();
|
||||
```
|
||||
|
||||
### ✅ 허용 — 새 버전에서 처음 도입된 클래스
|
||||
|
||||
```php
|
||||
// 새 버전에서 처음 추가된 Seeder
|
||||
use Database\Seeders\NotificationDefinitionSeeder;
|
||||
|
||||
(new NotificationDefinitionSeeder())->run();
|
||||
```
|
||||
|
||||
### ✅ 허용 — 프레임워크 파사드
|
||||
|
||||
```php
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
```
|
||||
|
||||
## 5. 체크리스트
|
||||
|
||||
upgrade step PR 검토 시 아래 항목을 모두 확인:
|
||||
|
||||
- [ ] `use App\*` 중 이전 버전에 **이미 존재하던** 클래스의 **새 버전에서 신설된 메서드·프로퍼티** 호출이 없는가?
|
||||
- [ ] 필요한 로직이 로컬 private 메서드로 작성되어 있는가?
|
||||
- [ ] 모든 return 분기에 upgrade.log 메시지가 있는가?
|
||||
- [ ] `--force` 재실행 시 안전한 멱등 동작인가?
|
||||
- [ ] sudo 실행 환경에서 root 오염이 발생할 수 있는 파일 조작이 있다면 소유권 복원 로직이 포함되었는가?
|
||||
- [ ] 테스트 환경(`beta.N fresh` → `beta.N+1 update`) 에서 수동 검증을 통과했는가?
|
||||
|
||||
## 6. sudo 환경 / 소유권 고려사항
|
||||
|
||||
`sudo php artisan core:update` 실행 시:
|
||||
|
||||
- 외부 프로세스(composer 등) 가 root 로 파일을 생성
|
||||
- 파일 시스템 API(`File::copy`, `mkdir` 등) 도 root 소유로 생성
|
||||
- 원본 소유자(www-data 등) 를 보존하려면 **업데이트 후 명시적 chown 필요**
|
||||
|
||||
beta.2 이후 버전은 `CoreUpdateService::snapshotOwnership() + restoreOwnership()` 공통 로직이 Step 11 Cleanup 에서 자동 수행. upgrade step 에서 별도 소유권 복원이 필요한 예외 상황(이전 버전에 해당 로직이 없음) 에서만 인라인 작성.
|
||||
|
||||
## 7. 생명주기와 제거 시점
|
||||
|
||||
각 upgrade step 은 **해당 버전에서만 필요한 1회성 작업** 을 담당한다. 일반적으로 다음 버전(N+2) 릴리즈 시점에 제거 가능 — 단 아래 조건을 모두 만족해야:
|
||||
|
||||
- N → N+1 업그레이드를 수행해야 하는 사용자가 더 이상 없음
|
||||
- 또는 N → N+2 직접 업그레이드를 공식적으로 미지원
|
||||
- upgrade step 이 수행한 DB/파일 정리가 새 설치에서는 불필요함
|
||||
|
||||
제거 시 upgrade 파일 자체를 삭제하면 된다. upgrade step 내부에서 참조했던 "공용 Helper 메서드" 는 다른 용도로 재사용될 수 있으므로 별도 판단.
|
||||
|
||||
## 8. 실전 사례
|
||||
|
||||
### 사례 A — beta.2 MailTemplate shim (클래스 캐싱 회피)
|
||||
|
||||
beta.1 의 `CoreUpdateService::syncCoreMailTemplates()` 가 `App\Models\MailTemplate::where(...)` 를 호출하는데, beta.2 에서 MailTemplate 이 제거되어 autoload 실패.
|
||||
|
||||
해결: beta.2 릴리즈에 `App\Models\MailTemplate` 의 **극소 shim** 을 포함. beta.2 upgrade step 에서 shim 파일과 테이블 자가 정리.
|
||||
|
||||
이 경우 shim 파일은 "upgrade step 의 의존성" 이지만 **이전 버전(beta.1) 이 메모리에 올리는 대상** 이므로 upgrade step 밖의 신규 파일로 작성되어야 했다. upgrade step 내부에서 `class_exists` 체크만으로 충분.
|
||||
|
||||
### 사례 B — vendor 소유권 복원 (sudo + 비대칭 환경)
|
||||
|
||||
beta.1 의 `runComposerInstall(base_path())` 가 sudo 에서 vendor/ 를 root 로 재생성. beta.2 upgrade step 이 storage/ 디렉토리 기준으로 원본 웹서버 계정(www-data) 을 추정하여 vendor/ 복원.
|
||||
|
||||
여기서 `FilePermissionHelper::inferWebServerOwnership()` 을 호출했다가 **beta.1 메모리 클래스 캐싱으로 undefined method Fatal** 발생. 로컬 private 메서드로 재작성하여 해결.
|
||||
|
||||
이 사례가 본 가이드 작성의 직접 계기다. **"공용 Helper 로 승격" 은 신규 버전 코드 기반에서만 안전** 이라는 교훈.
|
||||
|
||||
---
|
||||
|
||||
## 9. 업그레이드 경로별 규율
|
||||
|
||||
본 가이드의 많은 규칙은 "upgrade step 이 **이전 버전 PHP 프로세스** 메모리에서 실행된다" 는 전제에서 출발한다. 하지만 그누보드7 에는 서로 다른 실행 환경을 가진 **3개 경로** 가 공존하므로, 자신이 작성하는 upgrade step 이 어느 경로인지 먼저 확인해야 한다.
|
||||
|
||||
### 경로 A — 모듈/플러그인 upgrade step (기존 규율 유지)
|
||||
|
||||
- 실행 주체: `ModuleManager::updateModule()` / `PluginManager::updatePlugin()`
|
||||
- 실행 환경: **메인 PHP 프로세스** (Artisan 커맨드가 로드된 상태)
|
||||
- 단, `reloadModule`/`reloadPlugin` 이 `evalFreshModule` 로 **진입점 클래스만 재로드** (`app/Extension/ModuleManager.php` 의 `evalFreshModule`, `PluginManager.php` 의 `evalFreshPlugin` 참조)
|
||||
- 진입점 메서드(`getPermissions`/`getMenus` 등)는 재로드 덕분에 최신 정의 반환
|
||||
- 하지만 **그 외 App\* 클래스**(Helper, Service, Model)는 여전히 이전 버전 메모리
|
||||
- **적용 규율**: 섹션 3~5 의 모든 작성 규칙 적용. 로컬 private 헬퍼 우선, 기존 클래스의 신규 메서드 호출 금지
|
||||
|
||||
### 경로 B — 코어 upgrade step (beta.3 이후, 규율 완화)
|
||||
|
||||
- 실행 주체: `CoreUpdateCommand` Step 10 → `proc_open` 으로 `core:execute-upgrade-steps` 커맨드 spawn
|
||||
- 실행 환경: **별도 PHP 프로세스** (새로 시작되어 디스크의 최신 파일로 Composer autoload 수행)
|
||||
- 모든 클래스·config 가 **최신 버전 기준** 으로 로드됨
|
||||
- **적용 규율**: 클래스 캐싱 제약 **해제**. upgrade step 이 beta.N+1 의 새 Service/Repository/Controller/Model 등을 자유롭게 호출 가능
|
||||
- 예외: `proc_open` 미지원 환경에서는 in-process fallback 으로 전환되므로, **신중하게 설계된 upgrade step 은 여전히 경로 A/C 규율도 충족** 하는 것이 안전
|
||||
- 관련 구현: `app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php`, `CoreUpdateCommand::spawnUpgradeStepsProcess`, `CoreUpdateService::reloadCoreConfigAndResync`
|
||||
|
||||
#### spawn 호출 시 필수 env 전파
|
||||
|
||||
`proc_open` 을 직접 사용할 때 `$env` 배열은 **반드시 아래 패턴** 으로 구성한다:
|
||||
|
||||
```php
|
||||
$env = array_merge(getenv(), $_ENV, [
|
||||
'G7_UPDATE_IN_PROGRESS' => '1',
|
||||
// 필요한 추가 env ...
|
||||
]);
|
||||
```
|
||||
|
||||
`$_ENV` 단독 사용 금지. `variables_order` php.ini 에 `E` 가 없는 환경에서는 `$_ENV` 가 비어있어 플래그 전파가 누락되고, 자식의 `CoreServiceProvider::validateAndDeactivate*` 가 발동해 활성 확장이 일괄 비활성화되는 회귀가 발생한다. `getenv()` 는 프로세스 environ 테이블을 직접 반환해 `putenv` 로 설정된 값까지 포함한다. 상세 배경: [extension-update-system.md "업데이트 진행 플래그"](extension-update-system.md#업데이트-진행-플래그-g7_update_in_progress).
|
||||
|
||||
### 경로 C — 코어 upgrade step (이전 버전 in-process 실행 특수 경로)
|
||||
|
||||
- 실행 주체: **이전 버전 CoreUpdateCommand** — 이미 운영 서버에 배포되어 변경 불가
|
||||
- 실행 환경: **이전 버전의 메인 PHP 프로세스** — 새 릴리즈에서 도입된 spawn 커맨드를 모르므로 호출 안 함
|
||||
- 새 릴리즈의 재작성된 `CoreUpdateCommand` / `ExecuteUpgradeStepsCommand` 가 설치되어 있어도 이전 버전 CoreUpdateCommand 가 호출하지 않으므로 무용
|
||||
- **적용 규율**: 섹션 3~5 의 모든 작성 규칙을 **강하게** 적용. upgrade step 파일 내부 로컬 private 로직으로 모든 후처리를 직접 수행
|
||||
- 허용: 이전 버전에 이미 존재하던 클래스(예: `App\Services\CoreUpdateService`) 의 **기존 메서드** 호출 (예: `syncCoreRolesAndPermissions`, `syncCoreMenus`) — 단 내부에서 `config()` 로 읽는 값이 최신이어야 하므로 `config(['core' => require config_path('core.php')])` 로 선행 재주입 필요
|
||||
- 금지: 새 릴리즈에서 신설된 메서드(예: `reloadCoreConfigAndResync`) 호출 — 이전 버전 메모리에 존재하지 않음
|
||||
- 역사적 인스턴스: [`upgrades/Upgrade_7_0_0_beta_2.php`](../../upgrades/Upgrade_7_0_0_beta_2.php) 의 `resyncCorePermissionsAndMenus` 로컬 메서드
|
||||
|
||||
### 경로 C 가 필요한 상황 — 발생 조건
|
||||
|
||||
대부분의 릴리즈는 경로 B(spawn) 만으로 충분하다. 경로 C 가 필요한 것은 아래 **좁은 조건**일 때만:
|
||||
|
||||
**대상 릴리즈가 `CoreUpdateCommand` 의 업그레이드 흐름 자체를 구조적으로 변경** 하여,
|
||||
|
||||
- 새 진입점(spawn 커맨드, 신규 Service 메서드, 신규 내부 단계 등)을 도입했고
|
||||
- 해당 진입점은 **새 버전이 실행 주체일 때만** 활성화되며
|
||||
- **이전 버전 CoreUpdateCommand 는 그 진입점을 호출하지 않는** 경우
|
||||
|
||||
일반적인 기능 추가 / 버그 수정 / 데이터 마이그레이션 릴리즈는 모두 경로 B. 예: beta.3 → beta.4 에서 신규 모듈 도입이나 신규 권한 추가는 모두 경로 B 로 처리된다.
|
||||
|
||||
경로 C 는 주로 **인프라 재설계 릴리즈** 에서 1회씩 발생한다. 과거 예: beta.1 → beta.2 의 spawn 구조 도입.
|
||||
|
||||
### 경로 C 파일 선언 — 메타데이터 규약
|
||||
|
||||
경로 C 로 작성하는 upgrade step 파일은 **docblock 에 메타데이터** 를 명시한다:
|
||||
|
||||
```php
|
||||
/**
|
||||
* 코어 N.N.N 업그레이드 스텝
|
||||
*
|
||||
* @upgrade-path C
|
||||
*
|
||||
* 경로 C(이전 버전 CoreUpdateCommand 의 in-process 메모리에서 실행) 선언.
|
||||
* ... 구체적 사유 ...
|
||||
*/
|
||||
class Upgrade_N_N_N implements UpgradeStepInterface
|
||||
```
|
||||
|
||||
`@upgrade-path C` 선언은 아래 자동화 스크립트가 인식한다:
|
||||
|
||||
- [`.claude/scripts/validate-upgrade-step.cjs`](../../.claude/scripts/validate-upgrade-step.cjs) — 파일 내용을 파싱하여 경로 C 선언 시 강한 규율 경고 적용
|
||||
- [`.claude/scripts/file-rules.cjs`](../../.claude/scripts/file-rules.cjs) — 동일 선언 기반으로 `upgradeStep` 규칙이 경로 C 위반(기존 코어 클래스 use 문) 감지
|
||||
|
||||
선언이 없으면 **경로 B** 로 판정되어 규율이 완화된다 (신규 클래스/메서드 자유 사용).
|
||||
|
||||
### 경로 판별 체크리스트
|
||||
|
||||
upgrade step 작성 전 다음을 확인:
|
||||
|
||||
1. 확장(모듈/플러그인) upgrade step 인가? → **경로 A**
|
||||
2. 코어 upgrade step 인가?
|
||||
- 대상 릴리즈가 인프라 재설계(spawn 구조 등) 를 포함하여 이전 버전이 새 진입점을 모르는가?
|
||||
- **예** → **경로 C**, 파일에 `@upgrade-path C` 선언 + 로컬 로직 필수
|
||||
- **아니오** → **경로 B**, 규율 완화 (대부분의 경우)
|
||||
|
||||
경로 B 라고 판단했더라도, proc_open 차단 환경에서는 in-process fallback 이 작동하므로 **가능하면 경로 A/C 규율도 충족** 하도록 작성하는 것이 안전하다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 경로 C 내부 inline spawn 패턴
|
||||
|
||||
경로 C 에서 **새 릴리즈 클래스 로직이 꼭 필요한** 경우, upgrade step 파일 자체가 `proc_open` 으로 새 PHP 프로세스를 띄우면 된다. 새 프로세스는 디스크의 최신 파일을 autoload 하므로 클래스 캐싱과 무관.
|
||||
|
||||
```php
|
||||
private function spawnResyncInline(UpgradeContext $context): bool
|
||||
{
|
||||
if (! function_exists('proc_open')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$basePath = base_path();
|
||||
$phpCode = <<<'PHP'
|
||||
$base = getenv('G7_BASE_PATH');
|
||||
chdir($base);
|
||||
require $base.'/vendor/autoload.php';
|
||||
$app = require $base.'/bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
app(App\Services\CoreUpdateService::class)->reloadCoreConfigAndResync();
|
||||
echo "OK\n";
|
||||
PHP;
|
||||
|
||||
$cmd = escapeshellarg(PHP_BINARY).' -r '.escapeshellarg($phpCode).' 2>&1';
|
||||
$process = proc_open($cmd, [/* descriptors */], $pipes, $basePath,
|
||||
array_merge($_ENV, ['G7_BASE_PATH' => $basePath]));
|
||||
|
||||
if (! is_resource($process)) return false;
|
||||
// ... stdout 수집 + proc_close 검증
|
||||
return $exitCode === 0 && str_contains($stdout, 'OK');
|
||||
}
|
||||
```
|
||||
|
||||
### 적용 원칙
|
||||
|
||||
- **전용 아티산 커맨드 금지**: 1회성 로직이 beta.3 cleanup 시 upgrade step 파일 삭제와 함께 자연 소거되어야 함
|
||||
- **Fallback 필수**: `proc_open` 미지원 환경에서는 in-process fallback + 수동 복구 안내 로그
|
||||
- **Idempotent 보장**: spawn 성공/실패 무관하게 재실행 시 no-op
|
||||
|
||||
### 역사적 인스턴스
|
||||
|
||||
- `upgrades/Upgrade_7_0_0_beta_2.php::spawnResyncInlineLocal` — 경로 C 에서 beta.2 최신 `reloadCoreConfigAndResync()` 호출
|
||||
|
||||
---
|
||||
|
||||
## 11. 업그레이드 후 데이터 정합성 (완전 동기화)
|
||||
|
||||
upgrade step 이 수행하는 데이터 변경은 단순 "마이그레이션" 이 아니라 **완전 동기화** 를 지향한다. 즉:
|
||||
|
||||
1. **Upsert**: config/seeder → DB 반영
|
||||
2. **Orphan Delete**: config 에 없는 DB row 삭제 (user_overrides 무관)
|
||||
3. **Mapping Diff**: 관계 테이블 재정렬
|
||||
4. **Dependent Cleanup**: 삭제된 상위 엔티티 하위 정리
|
||||
|
||||
세부 정책과 Helper 사용법은 다음을 참조:
|
||||
|
||||
- [완전 동기화 원칙](../backend/core-config.md#완전-동기화-원칙) — 4단계 패턴의 상세 정의
|
||||
- [데이터 동기화 Helper 5종](../backend/data-sync-helpers.md) — Menu/Role/Notification/FilePermission/Generic
|
||||
- [사용자 수정 보존 (HasUserOverrides)](../backend/user-overrides.md) — trait 사용 및 mass update 투명 추적
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [코어 업데이트 시스템](../backend/core-update-system.md)
|
||||
- [확장 업데이트 시스템](extension-update-system.md)
|
||||
- [Changelog 규칙](changelog-rules.md)
|
||||
- [데이터 동기화 Helper](../backend/data-sync-helpers.md)
|
||||
- [사용자 수정 보존](../backend/user-overrides.md)
|
||||
@@ -0,0 +1,274 @@
|
||||
# Vendor 번들 시스템 (Vendor Bundle System)
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
- Composer 사용 불가 환경(공유 호스팅 등)을 위해 vendor/ 디렉토리를 zip으로 선탑재
|
||||
- 개별 빌드: php artisan {core|module|plugin}:vendor-bundle [identifier|--all]
|
||||
- 일괄 빌드: php artisan vendor-bundle:build-all (코어 + 모든 _bundled)
|
||||
- 개별/일괄 검증: {core|module|plugin}:vendor-verify, vendor-bundle:verify-all
|
||||
- 모드: VendorMode = auto | composer | bundled
|
||||
- 결정 순서: 명시 → DB 이전 모드 → config → 환경감지(composer 가능 시 우선)
|
||||
```
|
||||
|
||||
## 1. 개요
|
||||
|
||||
G7 코어와 모듈/플러그인은 PHP 의존성 관리에 Composer를 사용합니다. 그러나 일부 운영 환경(공유 호스팅, 제한된 PaaS)에서는 `proc_open()`/`shell_exec()` 함수가 차단되어 Composer를 실행할 수 없습니다. **Vendor 번들 시스템**은 미리 압축된 `vendor-bundle.zip` 파일을 추출하여 vendor/ 디렉토리를 구성하는 대안 방식을 제공합니다.
|
||||
|
||||
### 1.1 핵심 파일
|
||||
|
||||
각 코어/확장 루트에 두 파일이 함께 존재합니다:
|
||||
|
||||
```text
|
||||
{root}/
|
||||
├── composer.json (의존성 정의)
|
||||
├── composer.lock (락 파일)
|
||||
├── vendor-bundle.zip (압축된 vendor 디렉토리)
|
||||
└── vendor-bundle.json (메타파일: SHA256, 패키지 목록 등)
|
||||
```
|
||||
|
||||
`vendor-bundle.json` 스키마 (v1.0):
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"generated_at": "2026-04-14T12:34:56+09:00",
|
||||
"generator": "g7 vendor-bundle:build",
|
||||
"target": "core" | "module:identifier" | "plugin:identifier",
|
||||
"composer_json_sha256": "...",
|
||||
"composer_lock_sha256": "...",
|
||||
"zip_sha256": "...",
|
||||
"zip_size": 12345678,
|
||||
"package_count": 142,
|
||||
"php_requirement": "^8.2",
|
||||
"g7_version": "7.0.0-beta.4",
|
||||
"packages": [
|
||||
{ "name": "laravel/framework", "version": "12.0.5", "type": "library" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 2. VendorMode
|
||||
|
||||
세 가지 모드가 있습니다:
|
||||
|
||||
| 모드 | 설명 |
|
||||
|------|------|
|
||||
| `auto` | 환경 감지 — composer 사용 가능 시 composer, 불가 시 bundled |
|
||||
| `composer` | 강제 composer 실행 (불가 시 예외) |
|
||||
| `bundled` | 강제 vendor-bundle.zip 추출 (zip 없으면 예외) |
|
||||
|
||||
### 2.1 모드 결정 우선순위
|
||||
|
||||
`VendorResolver::resolveMode()` 의 우선순위:
|
||||
|
||||
```text
|
||||
1. CLI/API 명시 (--vendor-mode 또는 vendor_mode 파라미터)
|
||||
└─ composer/bundled 명시 시 그대로 사용
|
||||
2. 업데이트 시 DB 이전 모드 상속
|
||||
└─ modules.vendor_mode / plugins.vendor_mode 컬럼이 auto가 아니면 상속
|
||||
3. 전역 설정 config('app.install.default_vendor_mode')
|
||||
4. auto 해석 → 환경 감지
|
||||
a. EnvironmentDetector::canExecuteComposer() == true → Composer
|
||||
b. composer 불가 + vendor-bundle.zip 존재 → Bundled
|
||||
c. 둘 다 불가 → 예외 (no_vendor_strategy_available)
|
||||
```
|
||||
|
||||
**핵심 원칙**: auto 모드에서는 **composer가 우선**입니다 (개발 환경과 프로덕션 동작 일치).
|
||||
|
||||
## 3. CLI 사용법
|
||||
|
||||
본 시스템은 **개별 명령** 6개와 **일괄 알리아스** 2개로 구성됩니다. 개별 명령은 기존
|
||||
`core:build` / `module:build` / `plugin:build` 패턴과 정렬되어 있습니다.
|
||||
|
||||
### 3.1 개별 빌드 — 코어/모듈/플러그인
|
||||
|
||||
```bash
|
||||
# 코어 단독 빌드
|
||||
php artisan core:vendor-bundle [--check] [--force]
|
||||
|
||||
# 특정 모듈 빌드 (positional identifier — module:build 와 동일 패턴)
|
||||
php artisan module:vendor-bundle sirsoft-ecommerce [--check] [--force]
|
||||
|
||||
# 모든 _bundled 모듈 빌드 (--all 명시 필요)
|
||||
php artisan module:vendor-bundle --all [--check] [--force]
|
||||
|
||||
# 특정 플러그인 빌드
|
||||
php artisan plugin:vendor-bundle sirsoft-payment [--check] [--force]
|
||||
|
||||
# 모든 _bundled 플러그인 빌드
|
||||
php artisan plugin:vendor-bundle --all [--check] [--force]
|
||||
```
|
||||
|
||||
식별자도 `--all` 도 지정하지 않으면 명시적 에러가 발생합니다 (의도하지 않은 일괄
|
||||
실행 방지). 기존 `module:build` / `plugin:build` 와 동일한 안전 동작입니다.
|
||||
|
||||
### 3.2 개별 검증
|
||||
|
||||
```bash
|
||||
php artisan core:vendor-verify
|
||||
php artisan module:vendor-verify sirsoft-ecommerce
|
||||
php artisan module:vendor-verify --all
|
||||
php artisan plugin:vendor-verify sirsoft-payment
|
||||
php artisan plugin:vendor-verify --all
|
||||
```
|
||||
|
||||
### 3.3 일괄 알리아스 — 운영/CI 시나리오
|
||||
|
||||
코어 + 모든 `_bundled` 모듈/플러그인을 한 번에 처리하려면 일괄 알리아스를 사용합니다.
|
||||
|
||||
```bash
|
||||
# 일괄 빌드 (코어 + 모든 _bundled 모듈/플러그인)
|
||||
php artisan vendor-bundle:build-all [--check] [--force]
|
||||
|
||||
# 일괄 검증
|
||||
php artisan vendor-bundle:verify-all
|
||||
```
|
||||
|
||||
`vendor-bundle:build-all --check` 는 stale 감지 시 종료 코드 1을 반환하므로 CI
|
||||
파이프라인의 사전 검증 단계에 유용합니다.
|
||||
|
||||
### 3.4 옵션 요약
|
||||
|
||||
| 옵션 | 동작 |
|
||||
| ---- | ---- |
|
||||
| `--check` | 실제 빌드 없이 stale 여부만 확인. stale 발견 시 종료 코드 1 |
|
||||
| `--force` | composer.json/lock 해시 체크를 무시하고 강제 재빌드 |
|
||||
| `--all` | (개별 명령에서) 해당 타입의 모든 `_bundled` 확장 대상 |
|
||||
|
||||
### 3.5 검증 항목
|
||||
|
||||
- vendor-bundle.zip / vendor-bundle.json 파일 존재
|
||||
- manifest의 schema_version 지원 여부
|
||||
- zip 파일의 SHA256 해시 일치
|
||||
- composer.json/composer.lock SHA256 일치 (소스에 존재 시)
|
||||
|
||||
## 4. 코어 업데이트
|
||||
|
||||
```bash
|
||||
# Auto 모드 (기본값)
|
||||
php artisan core:update
|
||||
|
||||
# Composer 강제
|
||||
php artisan core:update --vendor-mode=composer
|
||||
|
||||
# 번들 강제 (공유 호스팅 환경)
|
||||
php artisan core:update --vendor-mode=bundled
|
||||
|
||||
# 로컬 + 번들 강제 (개발 시 번들 검증)
|
||||
php artisan core:update --local --force --vendor-mode=bundled
|
||||
```
|
||||
|
||||
## 5. 확장 설치/업데이트
|
||||
|
||||
### 5.1 CLI
|
||||
|
||||
```bash
|
||||
# 모듈 설치 (auto)
|
||||
php artisan module:install sirsoft-ecommerce
|
||||
|
||||
# 모듈 bundled 강제 설치
|
||||
php artisan module:install sirsoft-ecommerce --vendor-mode=bundled
|
||||
|
||||
# 플러그인 업데이트 (이전 모드 상속)
|
||||
php artisan plugin:update sirsoft-payment
|
||||
|
||||
# 플러그인 force 업데이트 + composer 강제
|
||||
php artisan plugin:update sirsoft-payment --force --vendor-mode=composer
|
||||
```
|
||||
|
||||
### 5.2 Admin UI
|
||||
|
||||
관리자 페이지의 모듈/플러그인 설치 모달에 **Vendor 설치 방식** Select 필드가 있습니다:
|
||||
|
||||
- 자동 (권장)
|
||||
- Composer 실행
|
||||
- 번들 Vendor 사용
|
||||
|
||||
### 5.3 API
|
||||
|
||||
```http
|
||||
POST /api/admin/modules/install
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"module_name": "sirsoft-ecommerce",
|
||||
"vendor_mode": "bundled"
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 웹 인스톨러
|
||||
|
||||
`public/install/` 마법사의 Step 3(환경 설정)에서 Vendor 설치 방식을 선택할 수 있습니다.
|
||||
|
||||
- 환경 감지: ZipArchive 확장 + proc_open 사용 가능 여부
|
||||
- 비활성 옵션: 환경에서 사용 불가한 모드는 자동 비활성화
|
||||
- 자동 폴백: auto 모드에서 composer 실행 불가 시 vendor-bundle.zip이 있으면 자동 전환
|
||||
|
||||
## 7. 무결성 검증 및 보안
|
||||
|
||||
### 7.1 SHA256 검증
|
||||
|
||||
`VendorIntegrityChecker::verify()` 가 다음을 검증합니다:
|
||||
|
||||
- vendor-bundle.zip 파일의 실제 SHA256 vs manifest 기록 값
|
||||
- composer.json SHA256 (소스에 존재 시)
|
||||
- composer.lock SHA256 (소스에 존재 시)
|
||||
|
||||
검증 실패 시 `VendorInstallException` 이 발생하며 설치가 중단됩니다.
|
||||
|
||||
### 7.2 Zip Slip 방지
|
||||
|
||||
추출 전 zip 내부 모든 파일 경로를 검증합니다:
|
||||
|
||||
- `../` 포함 경로 거부
|
||||
- 절대 경로 거부 (`/...`, `C:/...`)
|
||||
|
||||
## 8. 자동 트리거
|
||||
|
||||
`composer.json` 또는 `composer.lock` 파일이 변경되면 번들이 stale 상태가 됩니다.
|
||||
다음 명령으로 재빌드해야 합니다:
|
||||
|
||||
```bash
|
||||
# stale 여부 확인
|
||||
php artisan vendor-bundle:build-all --check
|
||||
|
||||
# 일괄 재빌드
|
||||
php artisan vendor-bundle:build-all
|
||||
```
|
||||
|
||||
CI 통합 예시:
|
||||
|
||||
```yaml
|
||||
- name: Verify vendor bundles
|
||||
run: php artisan vendor-bundle:build-all --check
|
||||
```
|
||||
|
||||
## 9. 핵심 클래스
|
||||
|
||||
| 클래스 | 책임 |
|
||||
|--------|------|
|
||||
| [VendorMode](../../app/Extension/Vendor/VendorMode.php) | 모드 enum (Auto/Composer/Bundled) |
|
||||
| [VendorResolver](../../app/Extension/Vendor/VendorResolver.php) | 모드 결정 + 전략 디스패치 |
|
||||
| [VendorBundler](../../app/Extension/Vendor/VendorBundler.php) | zip 빌드 (개발 타임) |
|
||||
| [VendorBundleInstaller](../../app/Extension/Vendor/VendorBundleInstaller.php) | zip 추출 (런타임) |
|
||||
| [VendorIntegrityChecker](../../app/Extension/Vendor/VendorIntegrityChecker.php) | SHA256 무결성 검증 |
|
||||
| [EnvironmentDetector](../../app/Extension/Vendor/EnvironmentDetector.php) | composer/zip 환경 감지 |
|
||||
| [VendorInstallException](../../app/Extension/Vendor/Exceptions/VendorInstallException.php) | vendor 관련 예외 |
|
||||
|
||||
## 10. 트러블슈팅
|
||||
|
||||
| 증상 | 원인 | 해결 |
|
||||
|------|------|------|
|
||||
| `bundle_zip_missing` | vendor-bundle.zip 파일 없음 | `vendor-bundle:build` 실행 |
|
||||
| `zip_hash_mismatch` | zip 파일 손상 또는 변조 | `vendor-bundle:build --force` 재빌드 |
|
||||
| `composer_json_sha_mismatch` | composer.json 변경 후 번들 미갱신 | `vendor-bundle:build` 재빌드 |
|
||||
| `composer_not_available` | composer 모드 강제했으나 실행 불가 | --vendor-mode=auto 또는 bundled로 변경 |
|
||||
| `no_vendor_strategy_available` | composer 불가 + 번들 없음 | vendor-bundle.zip 업로드 또는 호스팅 변경 |
|
||||
| `bundle_contains_unsafe_path` | 외부 zip 파일이 zip slip 시도 | 신뢰할 수 있는 소스에서만 다운로드 |
|
||||
|
||||
## 11. 참고
|
||||
|
||||
- 본 문서는 vendor 번들 시스템의 사용자 가이드입니다.
|
||||
- Bundle 자체는 .gitignore 에 등록되어 Git 추적되지 않습니다 (Release asset 형태로 배포 권장).
|
||||
- 확장 설치 모드는 `modules.vendor_mode` / `plugins.vendor_mode` 컬럼에 기록되며, 업데이트 시 자동 상속됩니다.
|
||||
@@ -9,8 +9,9 @@
|
||||
1. [navigate](#navigate)
|
||||
2. [navigateBack](#navigateback)
|
||||
3. [openWindow](#openwindow)
|
||||
4. [reloadRoutes](#reloadroutes)
|
||||
5. [refresh](#refresh)
|
||||
4. [reloadExtensions](#reloadextensions) ⭐ NEW (engine-v1.38.0+)
|
||||
5. [reloadRoutes](#reloadroutes) (deprecated)
|
||||
6. [refresh](#refresh)
|
||||
|
||||
---
|
||||
|
||||
@@ -36,6 +37,120 @@
|
||||
| `query` | object | - | 쿼리 파라미터 객체 |
|
||||
| `mergeQuery` | boolean | false | 기존 쿼리 파라미터와 병합 |
|
||||
| `replace` | boolean | false | URL만 변경 (페이지 리로드 없음) |
|
||||
| `transition_overlay_target` | string | - | `replace: true` 호출 시 `transition_overlay.target` 을 동적으로 override (engine-v1.36.0+) |
|
||||
| `scroll` | string \| number \| object | `"top"` | 이동 후 스크롤 위치 (engine-v1.37.0+). `"top"` / `"preserve"` / 숫자 / `{x,y}` / `"#selector"` |
|
||||
| `scrollBehavior` | string | `"instant"` | 스크롤 애니메이션 (engine-v1.37.0+). `"instant"` (즉시) / `"smooth"` (부드럽게). 기본값 `"instant"`는 템플릿 CSS의 `scroll-behavior: smooth` 전역 설정을 무시하고 페이지 전환 시 즉시 이동 |
|
||||
| `fallback` | `false \| string \| object` | `"openWindow"` | 대상 경로가 현재 템플릿의 `routes.json`에 없을 때 실행할 fallback 핸들러 (engine-v1.40.0+). 기본값은 `openWindow`(새 창). `false` 시 비활성화(기존 동작), 문자열은 핸들러명만 지정, 객체는 `{handler, params}` 상세 지정. 관리자 ↔ 사용자 템플릿 교차 경로에 사용 |
|
||||
|
||||
### transition_overlay_target 옵션 (engine-v1.36.0+)
|
||||
|
||||
`replace: true` 로 탭 전환/페이지네이션 시 `updateQueryParams` 경로에 진입할 때, 레이아웃의 `transition_overlay.target` 대신 **이 호출에 한해** 다른 영역에만 spinner 를 표시하도록 한다.
|
||||
|
||||
**주 용도**: 탭 속 서브 탭(환경설정 > 알림 탭 > 채널 탭 등) 클릭 시 서브 탭 콘텐츠 영역에만 spinner 가 표시되어야 할 때.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/settings",
|
||||
"replace": true,
|
||||
"mergeQuery": true,
|
||||
"query": { "channel": "{{ch.id}}" },
|
||||
"transition_overlay_target": "notif_channel_content"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**동작**:
|
||||
|
||||
- 미지정 시: 레이아웃의 `transition_overlay.target` 사용 (탭 콘텐츠 wrapper 등)
|
||||
- 지정 시: 해당 ID 의 DOM 요소에만 spinner mount. 요소 미발견 시 `transition_overlay.fallback_target` → `#app` 순으로 3단계 폴백
|
||||
- `replace: true` 아닌 일반 navigate(다른 path)는 `handleRouteChange` 경로로 가며 이 옵션은 효과 없음
|
||||
|
||||
**DataGrid body 영역 한정 spinner 컨벤션**:
|
||||
|
||||
목록 페이지 페이지네이션 시 **pagination 영역은 제외하고 그리드 본문만** spinner 를 표시하려면 `transition_overlay_target` 에 DataGrid 의 `${id}__body` suffix 를 사용한다. DataGrid composite 컴포넌트는 root Div 에 `id` 를, 테이블/카드 목록을 감싸는 내부 wrapper Div 에 `${id}__body` 를 자동으로 부여한다 (pagination 은 body wrapper 밖의 형제).
|
||||
|
||||
```json
|
||||
// DataGrid 컴포넌트 정의
|
||||
{ "type": "composite", "name": "DataGrid", "id": "users_data_grid", "props": {...} }
|
||||
|
||||
// 페이지네이션 navigate
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/users",
|
||||
"replace": true,
|
||||
"mergeQuery": true,
|
||||
"query": { "page": "{{$args[0]}}" },
|
||||
"transition_overlay_target": "users_data_grid__body"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
이 컨벤션으로 pagination 버튼은 spinner 위에 표시되어 사용자가 연속 클릭 가능하다.
|
||||
|
||||
> 상세: [layout-json-components-loading.md#wait_for — 데이터소스 가드](../frontend/layout-json-components-loading.md#wait_for--데이터소스-가드-engine-v1340)
|
||||
|
||||
### fallback 옵션 (engine-v1.40.0+)
|
||||
|
||||
대상 경로가 현재 템플릿의 `routes.json`에 등록되어 있지 않을 때(예: 관리자 화면에서 사용자 페이지 URL로 navigate 시도), 404 라우트가 아닌 다른 핸들러로 분기시킨다. **기본값은 `openWindow`** — 미지정 시 자동으로 새 창에서 열림.
|
||||
|
||||
**주 용도**: 알림센터에서 알림 클릭 시 관리자 ↔ 사용자 템플릿 교차 경로 처리.
|
||||
|
||||
**판정 흐름**:
|
||||
|
||||
1. `params.fallback === false` → fallback 비활성화 (SPA navigate 강행, 기존 동작)
|
||||
2. `params.replace === true` → 쿼리 갱신 전용이므로 fallback 미적용
|
||||
3. Router 미초기화 또는 routes 미로드 → fallback 미적용 (정상 navigate)
|
||||
4. `Router.match(pathname)` 성공 → fallback 미적용 (정상 navigate)
|
||||
5. 매칭 실패 → fallback 핸들러로 dispatch
|
||||
|
||||
**파라미터 형태**:
|
||||
|
||||
```jsonc
|
||||
// 1. 미지정 (기본값: openWindow)
|
||||
{ "handler": "navigate", "params": { "path": "/shop/products" } }
|
||||
|
||||
// 2. 비활성화 (기존 동작 — 미등록 경로 그대로 SPA navigate)
|
||||
{ "handler": "navigate", "params": { "path": "/admin/x", "fallback": false } }
|
||||
|
||||
// 3. 핸들러명만 지정 (string)
|
||||
{ "handler": "navigate", "params": { "path": "/shop/products", "fallback": "openWindow" } }
|
||||
|
||||
// 4. 상세 지정 (object) — params는 finalPath 위에 merge
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/shop/products",
|
||||
"fallback": { "handler": "openWindow", "params": { "target": "_blank" } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**알림센터 클릭 패턴 (parallel + fallback)**:
|
||||
|
||||
알림센터에서 알림 클릭 시 mark-as-read API 호출과 navigate를 **`parallel`로 묶어** 동시 실행하면, openWindow fallback이 즉시 새 창을 열고 mark-as-read는 백그라운드에서 진행된다. `sequence`로 묶으면 mark-as-read 완료(1~2초)를 기다린 후에야 새 창이 열려 사용자 체감이 나빠진다.
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "onNotificationClick",
|
||||
"handler": "parallel",
|
||||
"params": {
|
||||
"actions": [
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": { "path": "{{$args[0].url ?? '/admin/notification-logs'}}" }
|
||||
},
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"target": "/api/admin/notifications/{{$args[0].id}}/read",
|
||||
"params": { "method": "PATCH" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### mergeQuery 옵션
|
||||
|
||||
@@ -231,6 +346,124 @@ replace: true는 같은 페이지 내에서만 사용
|
||||
}
|
||||
```
|
||||
|
||||
### scroll 옵션
|
||||
|
||||
> **버전**: engine-v1.37.0+
|
||||
|
||||
페이지 이동 후 스크롤 위치를 제어합니다. **기본값은 `"top"`** 으로, 일반적인 하이퍼링크 이동 UX와 동일하게 새 페이지가 최상단에서 시작됩니다.
|
||||
|
||||
#### 단축 문법
|
||||
|
||||
| 값 | 동작 |
|
||||
|----|------|
|
||||
| `"top"` (기본) | `#app` 내부의 모든 스크롤 컨테이너 + `window`를 상단으로 리셋 |
|
||||
| `"preserve"` | 이전 스크롤 위치 유지 (엔진이 건드리지 않음) |
|
||||
| `number` | `window`를 해당 Y 좌표로 이동 |
|
||||
| `{ x, y }` | `window`를 특정 좌표로 이동 |
|
||||
| `"#selector"` / `".class"` | 해당 엘리먼트로 `scrollIntoView({ block: 'start' })` |
|
||||
|
||||
#### 확장 객체 문법
|
||||
|
||||
특정 스크롤 컨테이너를 지정하거나, sticky 헤더 오프셋, `scrollIntoView`의 `block` 위치 등을 세밀하게 제어할 때 사용합니다.
|
||||
|
||||
```ts
|
||||
{
|
||||
container?: string; // 스크롤 컨테이너 선택자 (생략 시 window)
|
||||
to?: string | number | { x?, y? } | "top"; // 이동 대상 (생략 시 "top")
|
||||
block?: "start" | "center" | "end" | "nearest"; // scrollIntoView block (기본 "start")
|
||||
offset?: number; // sticky 헤더 보정 (px, 양수 = 위쪽 여유)
|
||||
}
|
||||
```
|
||||
|
||||
| 필드 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `container` | string | `window` | 스크롤 컨테이너 CSS 선택자. 관리자 템플릿처럼 내부 div가 실제 스크롤 영역인 경우 지정 (예: `"#right_content_area"`) |
|
||||
| `to` | string / number / object / `"top"` | `"top"` | 이동 대상. 엘리먼트 선택자 / Y 좌표 / `{x, y}` 좌표 / `"top"` |
|
||||
| `block` | string | `"start"` | `to`가 엘리먼트 선택자일 때 스크롤 위치 지정. 네이티브 `scrollIntoView`와 동일한 의미 |
|
||||
| `offset` | number | `0` | 최종 스크롤 위치에서 차감할 픽셀 수. sticky 헤더가 있는 경우 헤더 높이만큼 지정하면 대상이 헤더 아래로 가려지지 않음 |
|
||||
|
||||
**동작 방식**:
|
||||
|
||||
- 스크롤은 `requestAnimationFrame`을 통해 다음 tick에 적용되어 새 레이아웃이 DOM에 반영된 뒤 정확한 위치로 이동합니다
|
||||
- `replace: true` 분기 (`updateQueryParams`)에도 동일하게 기본 `"top"`이 적용됩니다. 검색/필터/페이지네이션에서 스크롤 유지를 원하면 `"preserve"` 명시
|
||||
- `scrollBehavior: "smooth"`와 조합하면 부드러운 스크롤 애니메이션이 적용됩니다
|
||||
- 관리자 템플릿(`sirsoft-admin_basic`)처럼 `html/body`가 `overflow: hidden`이고 내부 div(`#right_content_area`)가 실제 스크롤 컨테이너인 구조에서는 `"top"` 단축 문법이 자동으로 모든 스크롤 컨테이너를 리셋합니다. 숫자/좌표/선택자 단축 문법은 `window`만 스크롤하므로, 내부 컨테이너 제어가 필요하면 **확장 객체 문법에서 `container` 지정**
|
||||
|
||||
#### 예시
|
||||
|
||||
**검색 필터에서 스크롤 유지**:
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/products",
|
||||
"replace": true,
|
||||
"mergeQuery": true,
|
||||
"query": { "search_keyword": "{{_local.keyword}}" },
|
||||
"scroll": "preserve"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**특정 엘리먼트로 이동 (단축)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/docs",
|
||||
"scroll": "#section-api"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**관리자 템플릿 내부 컨테이너의 Y 좌표로 이동 (확장)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/users",
|
||||
"scroll": {
|
||||
"container": "#right_content_area",
|
||||
"to": 500
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**sticky 헤더가 있는 페이지에서 섹션으로 이동 (확장)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/docs",
|
||||
"scroll": {
|
||||
"to": "#section-api",
|
||||
"offset": 80
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**특정 엘리먼트를 화면 중앙에 위치 (확장)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/report",
|
||||
"scroll": {
|
||||
"container": "#right_content_area",
|
||||
"to": "#chart-revenue",
|
||||
"block": "center"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## openWindow
|
||||
@@ -338,8 +571,79 @@ replace: true는 같은 페이지 내에서만 사용
|
||||
|
||||
---
|
||||
|
||||
## reloadExtensions
|
||||
|
||||
> **버전**: engine-v1.38.0+
|
||||
|
||||
확장(모듈/플러그인/템플릿) install/activate/deactivate/uninstall 직후 onSuccess 체인에서 호출하여 **페이지 전체 새로고침 없이** 확장 상태를 원자적으로 재동기화합니다.
|
||||
|
||||
내부적으로 다음을 순차 수행합니다:
|
||||
|
||||
1. `/api/templates/{id}/config.json` 에서 최신 `cache_version` 획득
|
||||
2. localStorage 의 확장 캐시 버전 갱신
|
||||
3. `Router.loadRoutes(newVersion)` — 신 버전 쿼리로 routes.json 재fetch
|
||||
4. LayoutLoader 캐시 버전 갱신 및 클리어
|
||||
5. TranslationEngine 재로드 — 활성 사전은 원자 교체되어 `$t:` 해석 경합 없음
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "reloadExtensions"
|
||||
}
|
||||
```
|
||||
|
||||
### 선택 파라미터 (모듈/플러그인 에셋 동적 로드/제거)
|
||||
|
||||
모듈 또는 플러그인의 JS/CSS 에셋을 동시에 로드/제거하려면 `moduleInfo` 또는 `pluginInfo` 와 `action` 을 전달합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "reloadExtensions",
|
||||
"params": {
|
||||
"moduleInfo": "{{result}}",
|
||||
"action": "add"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 파라미터 | 타입 | 설명 |
|
||||
|---------|------|------|
|
||||
| `moduleInfo` | object | 모듈 activate/deactivate API 응답 (`{{result}}`) — `identifier` 및 `assets` 포함 |
|
||||
| `pluginInfo` | object | 플러그인 activate/deactivate API 응답 |
|
||||
| `action` | 문자열 | `"add"` 또는 `"remove"` — 에셋 로드 또는 제거 |
|
||||
|
||||
### 사용 사례
|
||||
|
||||
- 모듈/플러그인/템플릿 install/activate/deactivate/uninstall onSuccess
|
||||
- 관리자가 같은 세션 안에서 확장을 활성화한 뒤 새 라우트로 즉시 이동하는 UX
|
||||
- 전체 페이지 새로고침(`refresh`) 을 SPA 친화적으로 대체
|
||||
|
||||
### 올바른 사용
|
||||
|
||||
```json
|
||||
"onSuccess": [
|
||||
{ "handler": "toast", "params": { "type": "success", "message": "$t:admin.modules.activate_success" } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "modules" } },
|
||||
{
|
||||
"handler": "reloadExtensions",
|
||||
"params": {
|
||||
"moduleInfo": "{{result}}",
|
||||
"action": "add"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 주의
|
||||
|
||||
- 기존에 3~4건을 병렬 호출하던 `reloadRoutes` + `reloadTranslations` + `reloadModuleHandlers` / `reloadPluginHandlers` 패턴을 이 단일 핸들러로 대체하세요.
|
||||
- 템플릿 activate/force_activate 에서 `refresh`(전체 새로고침)로 해결하던 케이스도 `reloadExtensions` 로 교체 가능.
|
||||
|
||||
---
|
||||
|
||||
## reloadRoutes
|
||||
|
||||
> **Deprecated** (engine-v1.38.0+): `reloadExtensions` 사용을 권장합니다. 하위 호환을 위해 유지되며 내부적으로 `reloadExtensions` 와 동일한 `TemplateApp.reloadExtensionState()` 로 위임됩니다.
|
||||
|
||||
라우트(routes.json)를 다시 로드합니다.
|
||||
|
||||
```json
|
||||
@@ -350,8 +654,8 @@ replace: true는 같은 페이지 내에서만 사용
|
||||
|
||||
### 사용 사례
|
||||
|
||||
- 모듈/플러그인 설치 후 새 라우트 적용
|
||||
- 동적으로 라우트 설정이 변경된 경우
|
||||
- (deprecated) 모듈/플러그인 설치 후 새 라우트 적용 → `reloadExtensions` 사용
|
||||
- (deprecated) 동적으로 라우트 설정이 변경된 경우
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -612,6 +612,8 @@ sequence 내에서 `target: "isolated"` setState가 실행되면, 이후 스텝
|
||||
|
||||
## reloadTranslations
|
||||
|
||||
> **Deprecated** (engine-v1.38.0+): 모듈/플러그인/템플릿 라이프사이클 onSuccess 에서는 `reloadExtensions` 사용을 권장합니다. 하위 호환을 위해 유지되며 내부적으로 `TemplateApp.reloadExtensionState()` 로 위임되어 routes/translations/layouts 가 함께 재동기화됩니다. 자세한 내용은 [`reloadExtensions`](actions-handlers-navigation.md#reloadextensions) 참조.
|
||||
|
||||
다국어 파일을 다시 로드합니다.
|
||||
|
||||
```json
|
||||
@@ -622,8 +624,8 @@ sequence 내에서 `target: "isolated"` setState가 실행되면, 이후 스텝
|
||||
|
||||
### 사용 사례
|
||||
|
||||
- 언어 설정 변경 후 번역 적용
|
||||
- 모듈/플러그인 설치 후 새 번역 적용
|
||||
- 언어 설정 변경 후 번역 적용 (단독 사용)
|
||||
- (deprecated) 모듈/플러그인 설치 후 새 번역 적용 → `reloadExtensions` 사용
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
| 하위 문서 | 주요 핸들러 | 설명 |
|
||||
|----------|------------|------|
|
||||
| [actions-handlers-navigation.md](actions-handlers-navigation.md) | navigate, navigateBack, openWindow, replaceUrl, reloadRoutes, refresh | 페이지 이동 및 라우트 관리 |
|
||||
| [actions-handlers-navigation.md](actions-handlers-navigation.md) | navigate, navigateBack, openWindow, replaceUrl, **reloadExtensions**, reloadRoutes, refresh | 페이지 이동 및 라우트 관리 |
|
||||
| [actions-handlers-state.md](actions-handlers-state.md) | apiCall, setState, setError, refetchDataSource, remount | API 호출 및 상태 관리 |
|
||||
| [actions-handlers-ui.md](actions-handlers-ui.md) | login/logout, openModal/closeModal, toast, switch, sequence/parallel, loadScript, callExternal | UI 인터랙션 및 외부 스크립트 |
|
||||
|
||||
@@ -37,8 +37,9 @@
|
||||
2. [navigateBack](actions-handlers-navigation.md#navigateback) - 뒤로 가기
|
||||
3. [openWindow](actions-handlers-navigation.md#openwindow) - 새 창/탭에서 열기
|
||||
4. [replaceUrl](actions-handlers-navigation.md#replaceurl) - URL만 변경 (refetch 없음)
|
||||
5. [reloadRoutes](actions-handlers-navigation.md#reloadroutes) - 라우트 재로드
|
||||
6. [refresh](actions-handlers-navigation.md#refresh) - 페이지 새로고침
|
||||
5. [reloadExtensions](actions-handlers-navigation.md#reloadextensions) ⭐ NEW (engine-v1.38.0+) - 확장 상태 원자 재동기화
|
||||
6. [reloadRoutes](actions-handlers-navigation.md#reloadroutes) (deprecated) - 라우트 재로드
|
||||
7. [refresh](actions-handlers-navigation.md#refresh) - 페이지 새로고침
|
||||
|
||||
### 상태 관리 핸들러 → [상세 문서](actions-handlers-state.md)
|
||||
|
||||
@@ -60,7 +61,7 @@
|
||||
17. [confirm (액션 속성)](actions-handlers-ui.md#confirm-액션-속성) - 실행 전 확인 대화상자
|
||||
18. [switch](actions-handlers-ui.md#switch) - 조건부 액션
|
||||
19. [sequence / parallel](actions-handlers-ui.md#sequence--parallel) - 액션 조합
|
||||
20. [reloadTranslations](actions-handlers-ui.md#reloadtranslations) - 다국어 재로드
|
||||
20. [reloadTranslations](actions-handlers-ui.md#reloadtranslations) (deprecated) - 다국어 재로드 (extension 라이프사이클은 `reloadExtensions` 사용)
|
||||
21. [showErrorPage](actions-handlers-ui.md#showerrorpage) - 에러 페이지
|
||||
22. [loadScript](actions-handlers-ui.md#loadscript) ⭐ NEW - 외부 스크립트 로드
|
||||
23. [callExternal](actions-handlers-ui.md#callexternal) ⭐ NEW - 외부 라이브러리 호출
|
||||
@@ -89,7 +90,7 @@
|
||||
|
||||
| 핸들러 | 필수 속성 | 선택 속성 |
|
||||
|--------|----------|----------|
|
||||
| `navigate` | `params.path` | `params.query`, `params.mergeQuery`, `params.replace` |
|
||||
| `navigate` | `params.path` | `params.query`, `params.mergeQuery`, `params.replace`, `params.fallback` (engine-v1.40.0+, 미등록 경로 fallback, 기본 `openWindow`) |
|
||||
| `openWindow` | `params.path` | - |
|
||||
| `replaceUrl` | - | `params.path`, `params.query`, `params.mergeQuery` |
|
||||
| `apiCall` | `target` | `params.method`, `params.body`, `params.contentType`, `auth_required`, `onSuccess`, `onError` |
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
| `cases` | object | ❌ | switch 핸들러용 케이스별 액션 정의 |
|
||||
| `if` | string | ❌ | 조건부 실행 (`{{expression}}` 형식, truthy일 때만 실행) |
|
||||
| `debounce` | number \| object | ❌ | 연속 호출 제어 (300 또는 { delay, leading, trailing }) |
|
||||
| `render` | boolean | ❌ | `false`이면 상태 값만 저장하고 React 리렌더를 건너뜀 (engine-v1.42.0+). 기본: `true` |
|
||||
| `resultTo` | object | ❌ | 핸들러 실행 결과를 상태에 저장 (engine-v1.11.0+). `{ target, key, merge }` |
|
||||
| `actionRef` | string | ❌ | `named_actions`에 정의된 액션을 참조 (engine-v1.19.0+). handler/params 대신 사용 |
|
||||
|
||||
@@ -373,9 +374,10 @@ init_actions에서 _local 저장 시 globalStateUpdater 경유 (componentContext
|
||||
| `parallel` | 병렬 액션 실행 | - | actions (액션 배열) |
|
||||
| `refetchDataSource` | 데이터 소스 다시 fetch | - | dataSourceId |
|
||||
| `remount` | 컴포넌트 강제 리마운트 | - | componentId |
|
||||
| `reloadRoutes` | 라우트 다시 로드 | - | - |
|
||||
| `reloadTranslations` | 다국어 파일 다시 로드 | - | - |
|
||||
| `refresh` | 현재 페이지 새로고침 | - | - |
|
||||
| `reloadExtensions` ⭐ NEW | 확장 상태 원자 재동기화 (routes+translations+layouts) | - | moduleInfo, pluginInfo, action |
|
||||
| `reloadRoutes` (deprecated) | 라우트 다시 로드 — `reloadExtensions` 위임 | - | - |
|
||||
| `reloadTranslations` (deprecated) | 다국어 파일 다시 로드 — `reloadExtensions` 위임 | - | - |
|
||||
| `refresh` | 현재 페이지 전체 새로고침 | - | - |
|
||||
| `stopPropagation` | 이벤트 전파 중지 | - | - |
|
||||
| `preventDefault` | 기본 동작 방지 | - | - |
|
||||
| `showErrorPage` | 에러 페이지 렌더링 | - | errorCode, target, containerId, layout |
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
3. TagInput: creatable, delimiters, splitOnPaste, isMulti, tagVariants 색상 맵핑
|
||||
4. MultilingualInput: layout('inline'|'tabs'|'compact'), availableLocales, inputType
|
||||
5. Toggle: size('sm'|'md'|'lg'), description 텍스트 | RadioGroup: inline 가로 배치
|
||||
6. HtmlContent: isHtml prop으로 HTML/텍스트 분기 — DB *_mode 필드 → isHtml 연동 패턴 필수 숙지
|
||||
```
|
||||
|
||||
---
|
||||
@@ -26,6 +27,7 @@
|
||||
6. [Checkbox](#checkbox)
|
||||
7. [RadioGroup](#radiogroup)
|
||||
8. [SearchBar](#searchbar)
|
||||
9. [HtmlContent](#htmlcontent)
|
||||
|
||||
---
|
||||
|
||||
@@ -571,6 +573,82 @@
|
||||
|
||||
---
|
||||
|
||||
## HtmlContent
|
||||
|
||||
HTML 콘텐츠와 일반 텍스트를 안전하게 렌더링하는 컴포넌트입니다.
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | 타입 | 필수 | 기본값 | 설명 |
|
||||
|------|------|------|--------|------|
|
||||
| `content` | `string` | ❌ | `''` | 렌더링할 콘텐츠 (HTML 또는 텍스트) |
|
||||
| `text` | `string` | ❌ | - | 레이아웃 JSON용 콘텐츠 (`content`보다 우선) |
|
||||
| `isHtml` | `boolean` | ❌ | `true` | HTML 렌더링 여부 |
|
||||
| `className` | `string` | ❌ | `''` | 컨테이너 CSS 클래스 |
|
||||
| `purifyConfig` | `object` | ❌ | - | DOMPurify 커스텀 설정 (isHtml=true 시만) |
|
||||
|
||||
### isHtml에 따른 렌더링 분기
|
||||
|
||||
| `isHtml` | 렌더링 방식 | 스타일 |
|
||||
|----------|-----------|--------|
|
||||
| `true` | `dangerouslySetInnerHTML` + DOMPurify 정화 | `prose dark:prose-invert` (Tailwind Typography) |
|
||||
| `false` | React 텍스트 노드 (이스케이프됨) | `whitespace-pre-wrap` (줄바꿈 보존) |
|
||||
|
||||
### DB `*_mode` 필드 → `isHtml` 연동 패턴 (CRITICAL)
|
||||
|
||||
G7에서는 콘텐츠의 렌더링 모드를 DB의 `*_mode` 컬럼(`'text'` / `'html'`)으로 관리합니다.
|
||||
이 값이 API를 거쳐 프론트엔드 `isHtml` prop으로 변환되는 전체 흐름을 이해해야 합니다.
|
||||
|
||||
#### 데이터 흐름
|
||||
|
||||
```text
|
||||
1. DB 컬럼 → description_mode: 'text' | 'html'
|
||||
2. API Resource → 'description_mode' => $this->description_mode
|
||||
3. 레이아웃 JSON → "isHtml": "{{(product.data?.description_mode ?? 'text') === 'html'}}"
|
||||
4. HtmlContent → isHtml=true → HTML 렌더링 / isHtml=false → 텍스트 렌더링
|
||||
```
|
||||
|
||||
#### 적용 사례
|
||||
|
||||
| 기능 | DB 컬럼 | API Resource 필드 | 레이아웃 표현식 |
|
||||
|------|---------|-------------------|----------------|
|
||||
| 상품 설명 | `products.description_mode` | `description_mode` | `{{(product.data?.description_mode ?? 'text') === 'html'}}` |
|
||||
| 게시글 본문 | `posts.content_mode` | `content_mode` | `{{(post?.data?.content_mode ?? 'text') === 'html'}}` |
|
||||
| 페이지 콘텐츠 | `pages.content_mode` | `content_mode` | `{{(page?.data?.content_mode ?? 'html') === 'html'}}` |
|
||||
| 공통정보 | `common_infos.content_mode` | `common_info.content_mode` | `{{product.data?.common_info?.content_mode === 'html'}}` |
|
||||
|
||||
#### 레이아웃 JSON 사용 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "HtmlContent",
|
||||
"props": {
|
||||
"content": "{{product.data?.description_localized ?? ''}}",
|
||||
"isHtml": "{{(product.data?.description_mode ?? 'text') === 'html'}}",
|
||||
"className": "prose dark:prose-invert max-w-none"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 주의사항
|
||||
|
||||
```text
|
||||
1. API Resource에 *_mode 필드 누락 시 → 프론트엔드 기본값('text')으로 fallback → HTML 미렌더링
|
||||
2. Admin Resource와 Public Resource 양쪽 모두에 *_mode 필드 포함 필수
|
||||
3. 기본값은 기능별로 다름: 상품/게시글은 'text', 페이지는 'html'
|
||||
4. isHtml=true 시 DOMPurify 자동 적용 — 별도 sanitize 불필요
|
||||
5. 백엔드에서도 description_mode='html'일 때 HTMLPurifier로 서버 사이드 정화
|
||||
```
|
||||
|
||||
### 보안
|
||||
|
||||
- `isHtml=true`: DOMPurify FORBID 방식 — `script`, `iframe`, `form` 등 위험 태그 차단, 나머지 허용
|
||||
- 외부 링크(`http://`, `https://`)에 `rel="noopener noreferrer"` 자동 추가
|
||||
- `purifyConfig`로 커스터마이징 가능하나, 보안 기본값(`FORBID_TAGS`, `FORBID_ATTR`)은 항상 유지
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md)
|
||||
@@ -578,3 +656,5 @@
|
||||
- [컴포넌트 고급 기능](components-advanced.md)
|
||||
- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md)
|
||||
- [액션 핸들러 - 커스텀 콜백](actions.md#커스텀-이벤트-event-필드)
|
||||
- [보안 가이드 - HTML 렌더링](security.md#htmlcontent--htmleditor-html-렌더링이-필요한-경우)
|
||||
- [에디터 컴포넌트](editors.md)
|
||||
@@ -57,6 +57,8 @@ export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElemen
|
||||
options?: SelectOption[] | string[]; // 옵션 배열
|
||||
label?: string; // 레이블 텍스트 (상위 컴포넌트에서 처리)
|
||||
error?: string; // 에러 메시지 (상위 컴포넌트에서 처리)
|
||||
searchable?: boolean; // 드롭다운 내 검색 input 활성화 (engine-v1.40.0+)
|
||||
searchPlaceholder?: string; // 검색 input placeholder (searchable=true일 때)
|
||||
|
||||
// HTML Select 표준 Props (모두 지원)
|
||||
value?: string | number; // 선택된 값 (제어 컴포넌트)
|
||||
@@ -83,6 +85,8 @@ export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElemen
|
||||
| `required` | `boolean` | ❌ | `false` | 필수 입력 |
|
||||
| `className` | `string` | ❌ | - | CSS 클래스 |
|
||||
| `placeholder` | `string` | ❌ | - | 플레이스홀더 (옵션 배열 첫 항목) |
|
||||
| `searchable` | `boolean` | ❌ | `false` | 드롭다운 내 검색 input 활성화 (label/value 부분일치 필터링, engine-v1.40.0+) |
|
||||
| `searchPlaceholder` | `string` | ❌ | `'Search...'` | 검색 input placeholder (searchable=true일 때) |
|
||||
|
||||
### 렌더링 모드
|
||||
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
| 모드 | 토큰 전송 | 토큰 없을 때 | 사용 사례 |
|
||||
|------|----------|-------------|----------|
|
||||
| `none` (기본) | ❌ 안 보냄 | 정상 요청 | 공개 API (상품 목록, 게시판 등) |
|
||||
| `required` | ✅ 필수 | 요청 실패 | 마이페이지, 관리자 API |
|
||||
| `required` | ✅ 필수 | **요청 스킵 → fallback 사용** | 마이페이지, 관리자 API |
|
||||
| `optional` | ✅ 있으면 전송 | 비회원으로 요청 | **장바구니, 위시리스트** (비회원/회원 공용) |
|
||||
|
||||
### 사용 예시
|
||||
@@ -335,6 +335,12 @@
|
||||
### 동작 원리
|
||||
|
||||
```text
|
||||
auth_mode: "required" 동작 흐름:
|
||||
|
||||
1. 토큰 유무 확인 (AuthManager.isAuthenticated())
|
||||
2. 토큰 있음 → ApiClient 사용 (Authorization: Bearer xxx)
|
||||
3. 토큰 없음 → 요청 스킵, fallback 데이터 사용 (API 호출하지 않음)
|
||||
|
||||
auth_mode: "optional" 동작 흐름:
|
||||
|
||||
1. 토큰 유무 확인 (AuthManager.isAuthenticated())
|
||||
@@ -342,6 +348,10 @@ auth_mode: "optional" 동작 흐름:
|
||||
3. 토큰 없음 → 일반 fetch 사용 (headers에 X-Cart-Key 등 포함 가능)
|
||||
```
|
||||
|
||||
> **중요**: `auth_mode: "required"`에서 토큰이 없으면 API 요청 자체를 하지 않습니다.
|
||||
> `fallback`이 정의되어 있으면 fallback 데이터가 렌더링에 사용되고, 없으면 데이터 없이 처리됩니다.
|
||||
> 이를 통해 비로그인 상태에서 인증 필수 API 호출로 인한 401 → 로그인 리다이렉트 문제를 방지합니다.
|
||||
|
||||
### 하위 호환성
|
||||
|
||||
`auth_required: true`는 `auth_mode: "required"`와 동일하게 동작합니다.
|
||||
|
||||
@@ -618,6 +618,48 @@ const unsubscribe = G7Core.TransitionManager.subscribe((isPending) => {
|
||||
const { isPending } = G7Core.useTransitionState();
|
||||
```
|
||||
|
||||
### G7Core.updateQueryParams
|
||||
|
||||
같은 페이지 내에서 URL 쿼리 파라미터만 변경하면서 컴포넌트 리마운트 없이 데이터소스를 refetch 한다. 일반적으로 `navigate` 핸들러의 `replace: true` 옵션 사용 시 엔진이 자동 호출하므로 직접 호출은 드물다.
|
||||
|
||||
```typescript
|
||||
G7Core.updateQueryParams(
|
||||
newPath: string,
|
||||
options?: { transitionOverlayTarget?: string }
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
| 파라미터 | 타입 | 설명 |
|
||||
|---|---|---|
|
||||
| `newPath` | `string` | 새 경로 (쿼리스트링 포함). 예: `/admin/products?status=active&page=2` |
|
||||
| `options.transitionOverlayTarget` | `string` | `transition_overlay.target` 동적 override (engine-v1.36.0+). 탭 속 서브 탭 등 부분 영역만 spinner 표시 |
|
||||
|
||||
**동작**:
|
||||
|
||||
1. `window.history.replaceState` 로 URL 갱신 (페이지 리로드 없음)
|
||||
2. `auto_fetch: true` 인 데이터소스 refetch (websocket 제외)
|
||||
3. `transition_overlay` 가 정의되어 있고 `blocking` 또는 `wait_for` 일치 progressive 데이터소스가 1개 이상이면 오버레이 자동 표시 (engine-v1.35.0+)
|
||||
4. fetch 완료 시 `updateTemplateData` 호출 + `hideTransitionOverlay`
|
||||
5. `computed` 재계산
|
||||
|
||||
**사용 예시**:
|
||||
|
||||
```typescript
|
||||
// 일반: 쿼리 파라미터 변경 + 데이터 refetch
|
||||
await G7Core.updateQueryParams('/admin/products?status=active&page=2');
|
||||
|
||||
// 서브 탭 spinner target 동적 override
|
||||
await G7Core.updateQueryParams(
|
||||
'/admin/settings?tab=notification&channel=email',
|
||||
{ transitionOverlayTarget: 'notif_channel_content' }
|
||||
);
|
||||
```
|
||||
|
||||
**관련**:
|
||||
|
||||
- `navigate` 핸들러 (`replace: true`) → [actions-handlers-navigation.md](actions-handlers-navigation.md)
|
||||
- `transition_overlay.wait_for` / `overlay_target` → [layout-json-components-loading.md](layout-json-components-loading.md)
|
||||
|
||||
---
|
||||
|
||||
## React Hooks
|
||||
|
||||
@@ -112,6 +112,9 @@ const toast = (window as any).G7Core?.toast;
|
||||
setLocal(updates: Record<string, any>, options?: {
|
||||
scope?: 'current' | 'parent' | 'root';
|
||||
merge?: 'deep' | 'shallow' | 'replace'; // engine-v1.18.0+ (기본: 'deep')
|
||||
debounce?: number; // engine-v1.41.0+ — 디바운스 지연 시간 (ms)
|
||||
debounceKey?: string; // engine-v1.41.0+ — 디바운스 고유 키
|
||||
render?: boolean; // engine-v1.42.0+ (기본: true) — false이면 React 리렌더 건너뜀
|
||||
}): void
|
||||
getLocal(): Record<string, any>
|
||||
```
|
||||
@@ -121,6 +124,22 @@ getLocal(): Record<string, any>
|
||||
- 액션 실행 중: 컴포넌트의 `dynamicState`를 직접 업데이트하여 즉시 UI에 반영
|
||||
- 액션 외부: 전역 `_local` 업데이트 (fallback)
|
||||
|
||||
**render: false** (engine-v1.42.0+): 값은 `globalState._local`에 저장하되 React 리렌더를 건너뜁니다.
|
||||
외부 라이브러리(CKEditor 등)가 자체 DOM을 관리하는 경우, React 트리 리렌더가 불필요하므로 성능을 대폭 개선합니다.
|
||||
`flushPendingDebounceTimers` 실행 시(저장 직전)에는 항상 render: true로 강제되어 데이터 정합성이 보장됩니다.
|
||||
|
||||
```typescript
|
||||
// 외부 라이브러리에서 자체 DOM을 관리하는 경우 — 리렌더 없이 값만 저장
|
||||
G7Core.state.setLocal({
|
||||
[`form.${name}.${locale}`]: html,
|
||||
hasChanges: true,
|
||||
}, {
|
||||
debounce: 300,
|
||||
debounceKey: `editor-sync-${name}`,
|
||||
render: false,
|
||||
});
|
||||
```
|
||||
|
||||
**dot notation 지원** (engine-v1.2.0+): 중첩된 객체 경로를 dot notation으로 표현할 수 있습니다.
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -318,6 +318,78 @@
|
||||
| `enabled` | `boolean` | `false` | 전환 오버레이 활성화 |
|
||||
| `style` | `"opaque" \| "blur" \| "fade" \| "skeleton" \| "spinner"` | `"opaque"` | 오버레이 스타일 |
|
||||
| `target` | `string` | - | 오버레이 대상 요소 ID. 미지정 시 전체 화면 |
|
||||
| `wait_for` | `string[]` | - | 명시된 데이터소스 fetch 완료까지 오버레이 유지 (engine-v1.34.0+) |
|
||||
|
||||
### wait_for — 데이터소스 가드 (engine-v1.34.0+)
|
||||
|
||||
`blocking` 데이터소스가 없는 페이지에서도 명시된 progressive 데이터소스가 fetch 완료될 때까지 오버레이가 표시되도록 한다. 베이스 레이아웃에 spinner 설정을 두고, 자식 레이아웃이 `wait_for` 만 추가하는 패턴이 권장된다.
|
||||
|
||||
```json
|
||||
// 베이스 레이아웃 (_admin_base.json)
|
||||
{
|
||||
"transition_overlay": {
|
||||
"enabled": true,
|
||||
"style": "spinner",
|
||||
"target": "main_content",
|
||||
"spinner": { "component": "PageLoading" }
|
||||
}
|
||||
}
|
||||
|
||||
// 자식 레이아웃 (admin_settings.json) — wait_for 만 명시
|
||||
{
|
||||
"extends": "_admin_base",
|
||||
"transition_overlay": {
|
||||
"wait_for": ["settings"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`LayoutService` 가 `transition_overlay` 를 shallow merge 하므로 자식의 `wait_for` 만 추가되고 베이스의 `enabled/style/target/spinner` 설정이 보존된다.
|
||||
|
||||
#### 호환되는 loading_strategy
|
||||
|
||||
| loading_strategy | wait_for 동작 | 비고 |
|
||||
| ---------------- | ------------- | ---- |
|
||||
| `blocking` | step 2.5 에서 spinner 자동 트리거. wait_for 명시는 중복이지만 무해 | 명시 불필요 |
|
||||
| `progressive` (default) | wait_for 의 핵심 사용처 — fetch 완료까지 오버레이 유지 | 권장 |
|
||||
| `background` | **검증 단계에서 차단됨** — "사용자 차단 없음" 의도와 충돌 | UpdateLayoutContentRequest 422 |
|
||||
| `websocket` | **검증 단계에서 차단됨** — fetch 완료 이벤트 없음 | UpdateLayoutContentRequest 422 |
|
||||
|
||||
#### 트리거/해제 시점
|
||||
|
||||
- **트리거 1 — 페이지 진입**: handleRouteChange step 2.5 에서 `wait_for` 에 명시된 ID 중 progressive/blocking 데이터소스가 1개 이상 존재하면 오버레이 표시
|
||||
- **트리거 2 — 탭 전환/페이지네이션** (engine-v1.35.0+): `navigate replace:true` 로 `updateQueryParams` 경로에 진입할 때도 동일 조건으로 오버레이 표시. handleRouteChange 와 동일 정책으로 `wait_for` 검사 + 오버레이 호출
|
||||
- **해제**: 모든 progressive 데이터소스 fetch 완료 시 (hideTransitionOverlay 호출)
|
||||
- **존재하지 않는 ID**: 엔진이 자동 무시 (백엔드 검증과 별개로 런타임 안전 가드)
|
||||
|
||||
#### 동적 target override (engine-v1.36.0+)
|
||||
|
||||
`replace: true` navigate 의 `params.transition_overlay_target` 으로 호출별 target 을 override 할 수 있다. 탭 속 서브 탭(환경설정 > 알림 탭 > 채널 탭 등)에서 서브 탭 콘텐츠 영역에만 spinner 를 표시하고 싶을 때 사용.
|
||||
|
||||
```json
|
||||
// 상위 탭 전환: 베이스 transition_overlay.target 사용 (settings_tab_content)
|
||||
{ "handler": "navigate", "params": { "path": "/admin/settings", "replace": true, "query": { "tab": "notification_definitions" } } }
|
||||
|
||||
// 채널 서브 탭 전환: 채널 콘텐츠 영역에만 spinner
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/settings",
|
||||
"replace": true,
|
||||
"mergeQuery": true,
|
||||
"query": { "channel": "{{ch.id}}" },
|
||||
"transition_overlay_target": "notif_channel_content"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`transition_overlay_target` 미존재 시 레이아웃의 `transition_overlay.target` 사용. 해당 ID DOM 요소 미발견 시 `fallback_target` → `#app` 3단계 폴백.
|
||||
|
||||
> 상세: [actions-handlers-navigation.md#transition_overlay_target-옵션-engine-v1360](actions-handlers-navigation.md#transition_overlay_target-옵션-engine-v1360)
|
||||
|
||||
#### refetchDataSource 와의 관계
|
||||
|
||||
`refetchDataSource` 핸들러로 단독 데이터를 다시 부르는 경우는 `wait_for` 트리거 대상이 아니다. 이 경우 컴포넌트 단위 `blur_until_loaded` 의 객체 형태(`{ enabled: true, data_sources: ["xxx"] }`)로 처리한다 — 페이지 단위 vs 컴포넌트 단위의 역할 분리를 유지한다.
|
||||
|
||||
`target` 지정 시 CSS `<style>` 태그를 `<head>`에 주입하여 타겟 요소에 `::after` 의사 요소로 오버레이를 생성합니다. 타겟 요소에 `position:relative; z-index:0`을 설정하여 명시적 stacking context를 생성하므로, `::after`가 타겟 내부에 갇히고 헤더/네비게이션 등 형제 요소를 절대 가리지 않습니다. `target` 미지정 시 `document.body`에 `position:fixed` div를 삽입하는 폴백 방식을 사용합니다.
|
||||
|
||||
@@ -624,6 +696,45 @@ lifecycle 핸들러에서는 다음 데이터에 접근할 수 있습니다:
|
||||
|
||||
---
|
||||
|
||||
## _seo 컨텍스트 네임스페이스
|
||||
|
||||
> **버전**: SeoRenderer에서 주입
|
||||
|
||||
SEO 변수 시스템이 활성화된 레이아웃에서, SeoRenderer가 확장 설정 SEO 템플릿을 해석한 결과를 `_seo` 네임스페이스에 주입합니다.
|
||||
|
||||
### 구조
|
||||
|
||||
```text
|
||||
_seo.{page_type}.title — 해석된 SEO 제목
|
||||
_seo.{page_type}.description — 해석된 SEO 설명
|
||||
```
|
||||
|
||||
### 사용법
|
||||
|
||||
`_seo` 컨텍스트는 `og`, `structured_data`, 그리고 모든 표현식 영역에서 참조할 수 있습니다.
|
||||
|
||||
```json
|
||||
"og": {
|
||||
"title": "{{_seo.product.title ?? product.data.name ?? ''}}",
|
||||
"description": "{{_seo.product.description ?? product.data.short_description ?? ''}}"
|
||||
},
|
||||
"structured_data": {
|
||||
"@type": "Product",
|
||||
"name": "{{_seo.product.title ?? product.data.name ?? ''}}"
|
||||
}
|
||||
```
|
||||
|
||||
### 동작 조건
|
||||
|
||||
- `meta.seo.extensions`에 확장이 선언되어 있어야 함
|
||||
- 해당 확장의 `seoVariables()`에서 변수가 정의되어 있어야 함
|
||||
- 확장 설정에 `seo.meta_{page_type}_title` / `seo.meta_{page_type}_description` 템플릿이 존재해야 함
|
||||
- 조건 미충족 시 `_seo.{page_type}` 값이 `undefined` → fallback 표현식(예: `?? product.data.name ?? ''`) 사용 권장
|
||||
|
||||
> 상세: [seo-system.md](../backend/seo-system.md) "SEO 변수 시스템" 참조
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [레이아웃 JSON 컴포넌트 인덱스](layout-json-components.md)
|
||||
|
||||
@@ -678,6 +678,7 @@ onSortEnd에서 setState로 소스 배열을 업데이트해야 정렬 결과가
|
||||
| `handle` | string | ❌ | - | 드래그 핸들 CSS 선택자 |
|
||||
| `itemVar` | string | ❌ | `"$item"` | 아이템 컨텍스트 변수명 |
|
||||
| `indexVar` | string | ❌ | `"$index"` | 인덱스 컨텍스트 변수명 |
|
||||
| `wrapperElement` | string | ❌ | `"div"` | 래퍼 HTML 요소. Table 내부에서 `"tr"` 지정 시 itemTemplate의 children을 `<tr>` 안에 직접 렌더링 (engine-v1.30.0+) |
|
||||
|
||||
### itemTemplate
|
||||
|
||||
|
||||
@@ -120,11 +120,33 @@
|
||||
|------|------|------|------|
|
||||
| seo.enabled | boolean | X | SEO 렌더링 활성화 (기본: false) |
|
||||
| seo.data_sources | string[] | X | SEO 시 사전 로드할 data_sources ID |
|
||||
| seo.extensions | array | X | SEO 변수 제공 확장 목록 (아래 참조) |
|
||||
| seo.page_type | string | X | 확장 설정 SEO 템플릿 키 결정 (예: `"product"`) |
|
||||
| seo.toggle_setting | string | X | SEO 활성화 토글 설정 경로 |
|
||||
| seo.vars | object | X | SEO 변수 선언 — data 소스 변수의 표현식 매핑 |
|
||||
| seo.priority | number | X | sitemap priority (0.0~1.0) |
|
||||
| seo.changefreq | string | X | sitemap changefreq (daily/weekly 등) |
|
||||
| seo.og | object | X | Open Graph 메타태그 |
|
||||
| seo.structured_data | object | X | JSON-LD 구조화 데이터 |
|
||||
|
||||
#### seo.extensions
|
||||
|
||||
SEO 변수를 제공하는 확장(모듈/플러그인)을 선언합니다. 여기에 선언된 확장의 `seoVariables()` 메서드가 호출되어, `page_type`에 해당하는 변수가 자동 해석됩니다.
|
||||
|
||||
```json
|
||||
"extensions": [
|
||||
{ "type": "module", "id": "sirsoft-ecommerce" },
|
||||
{ "type": "plugin", "id": "sirsoft-payment" }
|
||||
]
|
||||
```
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| type | `"module"` \| `"plugin"` | ✅ | 확장 타입 |
|
||||
| id | string | ✅ | 확장 식별자 (예: `"sirsoft-ecommerce"`) |
|
||||
|
||||
이 방식은 레이아웃 이름에 dot-notation `moduleIdentifier`를 포함할 필요 없이, 레이아웃이 어떤 확장의 SEO 변수를 사용하는지 명시적으로 선언합니다.
|
||||
|
||||
Partial에 meta.seo 정의 금지 (무시됨)
|
||||
meta.seo 추가/변경 시 UpdateLayoutContentRequest 검증 규칙 동기화 필수
|
||||
data_sources는 부모-자식 간 합집합 병합 (permissions와 동일 전략)
|
||||
@@ -839,24 +861,52 @@ const renderChildren = useMemo(() => {
|
||||
|
||||
---
|
||||
|
||||
## extensionPointProps (engine-v1.15.0+)
|
||||
## Extension Point 데이터 전달 (engine-v1.15.0+, callbacks: engine-v1.28.0+)
|
||||
|
||||
`extension_point` 타입 컴포넌트에서 주입되는 컴포넌트에 추가 props를 전달합니다.
|
||||
`extension_point`에서 주입되는 컴포넌트에 데이터와 콜백을 전달합니다.
|
||||
|
||||
- `props`: 데이터 전달용 — 표현식이 평가되어 전달됨 (`resolveObject` 재귀 평가)
|
||||
- `callbacks`: 액션 객체 전달용 — 평가 없이 그대로 전달 (ActionDispatcher가 실행 시점에 평가)
|
||||
|
||||
### 호스트 레이아웃 (extension_point 정의)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "extension_point",
|
||||
"name": "product_detail_sidebar",
|
||||
"extensionPointProps": {
|
||||
"name": "address_search_slot",
|
||||
"props": {
|
||||
"productId": "{{route.id}}",
|
||||
"currency": "{{_global.currency ?? 'KRW'}}"
|
||||
"readOnlyFields": ["zipcode", "address"]
|
||||
},
|
||||
"callbacks": {
|
||||
"onAddressSelect": {
|
||||
"handler": "setState",
|
||||
"params": { "target": "local", "form.zipcode": "{{$event.zipcode}}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 플러그인 extension JSON (주입되는 컴포넌트)
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "myPlugin.doSomething",
|
||||
"params": {
|
||||
"id": "{{extensionPointProps.productId}}",
|
||||
"fields": "{{extensionPointProps.readOnlyFields}}",
|
||||
"callbackAction": "{{extensionPointCallbacks.onAddressSelect}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `{{extensionPointProps.xxx}}`: 호스트의 `props`에서 전달된 데이터 (표현식 평가됨)
|
||||
- `{{extensionPointCallbacks.xxx}}`: 호스트의 `callbacks`에서 전달된 액션 객체 (그대로 전달)
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `extensionPointProps` | object | ❌ | extension_point에 주입된 컴포넌트에 전달할 props (표현식 지원) |
|
||||
| `props` | object | ❌ | 주입 컴포넌트에 전달할 데이터 (표현식 평가) |
|
||||
| `callbacks` | object | ❌ | 주입 컴포넌트에 전달할 액션 객체 (평가 없이 전달) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -97,13 +97,16 @@ G7이 자동으로 주입하는 `_global` 속성입니다. 레이아웃에서
|
||||
|
||||
```json
|
||||
{
|
||||
"comment": "appConfig 활용 예시 - 동적 Select 옵션",
|
||||
"comment": "appConfig 활용 예시 - 동적 Select 옵션 (타임존)",
|
||||
"props": {
|
||||
"options": "{{_global.appConfig?.supportedTimezones?.map(tz => ({ value: tz, label: tz })) ?? []}}"
|
||||
"options": "{{_global.appConfig?.supportedTimezones ?? []}}",
|
||||
"searchable": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `_global.appConfig.supportedTimezones`는 `[{value: 'Asia/Seoul', label: '(UTC+09:00) Asia/Seoul'}, ...]` 형식의 `{value, label}` 객체 배열입니다(오프셋 오름차순 정렬). 백엔드 `SettingsService::buildTimezoneOptions()`가 매 요청 시점에 UTC 오프셋을 계산하여 라벨을 생성하므로 DST 전환이 자동 반영됩니다. 별도 `.map()` 변환 없이 Select `options`에 직접 바인딩합니다.
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```json
|
||||
|
||||
@@ -61,8 +61,8 @@ _admin_base.json (베이스 레이아웃)
|
||||
│ └── admin_settings.json (탭 네비게이션)
|
||||
│ └── partials/admin_settings/
|
||||
│ ├── _tab_general.json (일반)
|
||||
│ ├── _tab_mail.json (메일 발송)
|
||||
│ ├── _tab_mail_templates.json (메일 템플릿)
|
||||
│ ├── _tab_mail.json (메일 발송 SMTP 설정)
|
||||
│ ├── _tab_notification_definitions.json (알림 정의)
|
||||
│ ├── _tab_security.json (보안)
|
||||
│ ├── _tab_upload.json (업로드)
|
||||
│ ├── _tab_drivers.json (드라이버)
|
||||
@@ -73,8 +73,8 @@ _admin_base.json (베이스 레이아웃)
|
||||
│ ├── _modal_core_changelog.json
|
||||
│ ├── _modal_core_update_guide.json
|
||||
│ ├── _modal_core_update_result.json
|
||||
│ ├── _modal_mail_template_form.json
|
||||
│ ├── _modal_mail_template_preview.json
|
||||
│ ├── _modal_notification_template_form.json
|
||||
│ ├── _modal_notification_template_preview.json
|
||||
│ └── _modal_password_confirm.json
|
||||
│
|
||||
├── 모듈 관리
|
||||
|
||||
@@ -335,6 +335,40 @@ npm install
|
||||
|
||||
---
|
||||
|
||||
## 환경 격리 — Shared Settings 파일 주의
|
||||
|
||||
### 배경
|
||||
|
||||
`storage/app/settings/*.json` 파일(특히 `drivers.json`)은 dev / testing 환경이 **공유**하는 파일입니다. `SettingsServiceProvider::register()`가 부팅 시 이 파일을 읽어 `cache.default`, `session.driver`, `queue.default` 등을 runtime에 오버라이드합니다.
|
||||
|
||||
### 발생했던 문제
|
||||
|
||||
과거에는 `SettingsServiceProvider::applyDriverConfig()`가 testing 환경에서도 이 오버라이드를 적용하여, `phpunit.xml`이 지정한 `CACHE_STORE=array` 설정이 무효화되고 testing이 dev의 **Redis 인스턴스와 프리픽스를 공유**했습니다. 그 결과:
|
||||
|
||||
1. 테스트가 `RefreshDatabase`로 `notification_definitions` 테이블을 truncate
|
||||
2. 부팅 중 `NotificationDefinitionService::getAllActive()` 호출 → 빈 컬렉션
|
||||
3. 빈 컬렉션이 dev Redis에 cached
|
||||
4. 이후 dev 요청이 이 stale 빈 캐시를 읽어 `NotificationHookListener::registerDynamicHooks()`가 어떤 훅도 등록하지 못함
|
||||
5. **알림이 silent하게 발송 실패**
|
||||
|
||||
### 현재 보호 장치
|
||||
|
||||
`SettingsServiceProvider::applyDriverConfig()`는 `env('APP_ENV') === 'testing'`에서 cache/session/queue 드라이버 오버라이드를 **건너뜁니다**. testing 환경은 `phpunit.xml` + `.env.testing`이 지정한 드라이버를 그대로 사용합니다.
|
||||
|
||||
### 지침
|
||||
|
||||
```text
|
||||
⚠️ testing 환경은 dev의 Redis/DB/file 캐시를 절대 공유하지 않아야 함
|
||||
|
||||
✅ phpunit.xml: CACHE_STORE=array (in-memory, per-process)
|
||||
✅ .env.testing: CACHE_STORE=file 또는 array
|
||||
❌ drivers.json 오버라이드는 testing에서 비활성화됨 (SettingsServiceProvider 내 가드)
|
||||
```
|
||||
|
||||
**향후 변경 시 주의**: `SettingsServiceProvider`에 새 드라이버 오버라이드를 추가할 때, shared 파일 경로(storage/app/settings)에서 읽는다면 testing 환경 가드를 반드시 포함할 것.
|
||||
|
||||
---
|
||||
|
||||
## DDL 트랜잭션 격리 문제
|
||||
|
||||
### 문제 설명
|
||||
@@ -587,6 +621,34 @@ npm run test:run -- --testPathPattern=Router # 잘못된 명령
|
||||
필수: 테스트 작성 후 실행까지 완료
|
||||
```
|
||||
|
||||
### 테스트 커버리지 기준
|
||||
|
||||
코어, 모듈, 플러그인 전 영역에 걸쳐 아래 계층별 테스트가 존재해야 합니다.
|
||||
|
||||
**백엔드 (PHPUnit)**:
|
||||
|
||||
| 계층 | 테스트 대상 | 검증 항목 |
|
||||
|------|-----------|----------|
|
||||
| Controller (Feature) | API 엔드포인트 | 상태 코드, 응답 구조, abilities, 권한 403 |
|
||||
| Resource (Unit) | 직렬화 | 필드 반환, 관계 미로드 시 null, json_encode 안전성 |
|
||||
| Collection (Unit) | 목록 직렬화 | abilityMap, resolveCollectionAbilities, pagination |
|
||||
| Service (Unit) | 비즈니스 로직 | CRUD, 캐싱, 예외 처리 |
|
||||
| Listener (Unit) | 이벤트/훅 처리 | 훅 구독, 데이터 변환 |
|
||||
| Upgrade (Unit) | 업그레이드 스텝 | 데이터 이관, 멱등성 |
|
||||
|
||||
**프론트엔드 (Vitest)**:
|
||||
|
||||
| 계층 | 테스트 대상 | 검증 항목 |
|
||||
|------|-----------|----------|
|
||||
| 컴포넌트 | TSX 컴포넌트 | 렌더링, props, 이벤트 |
|
||||
| 레이아웃 | JSON 레이아웃 | 렌더링, 데이터 바인딩, 액션 |
|
||||
|
||||
**신규 기능 구현 시 최소 커버리지**:
|
||||
- Controller → Feature 테스트 (정상 + 권한 부족)
|
||||
- Resource → json_encode 안전성 (관계 미로드 시나리오 포함)
|
||||
- Collection → abilities 반환 검증 (abilityMap 정의 시)
|
||||
- Service → 핵심 메서드별 정상/예외 케이스
|
||||
|
||||
### 레이아웃 렌더링 테스트 (상세)
|
||||
|
||||
레이아웃 JSON 파일을 실제 렌더링하고 런타임 동작을 검증하는 테스트 시스템이 제공됩니다.
|
||||
|
||||
Reference in new issue
Block a user