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
+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)
|
||||
Reference in new issue
Block a user