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
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* HasUserOverrides Trait.
|
||||
*
|
||||
* 모델에 user_overrides 필드 자동 관리 기능을 추가합니다.
|
||||
*
|
||||
* 사용법:
|
||||
* ```php
|
||||
* class NotificationTemplate extends Model {
|
||||
* use HasUserOverrides;
|
||||
*
|
||||
* protected array $trackableFields = ['subject', 'body', 'is_active'];
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* 동작:
|
||||
* - 사용자가 trackable 필드를 수정 → user_overrides 에 자동 기록
|
||||
* - 사용자 경로가 Eloquent 인스턴스 `->update()`, `->save()` 또는 mass update
|
||||
* `Model::where(...)->update()` 중 어느 것이든 **모두 자동 추적**
|
||||
* (mass update 는 커스텀 Builder 가 per-row 로 분해하여 `updating` 이벤트 발화)
|
||||
* - 시더/업그레이드 스텝에서 syncFromUpgrade() 호출 → 기록된 필드 보존
|
||||
*
|
||||
* 컨테이너 플래그 `user_overrides.seeding` 으로 시더/사용자 컨텍스트를 구분합니다.
|
||||
*
|
||||
* 대량 처리(수천~수만 행) 가 필요해 이벤트 발화 비용을 피하려면
|
||||
* `DB::table('...')->update(...)` (Eloquent 우회) 사용을 권장합니다.
|
||||
*
|
||||
* @since 7.0.0-beta.2
|
||||
*/
|
||||
trait HasUserOverrides
|
||||
{
|
||||
/**
|
||||
* 커스텀 Eloquent Builder 반환 — mass update 시에도 user_overrides 자동 기록.
|
||||
*
|
||||
* @param \Illuminate\Database\Query\Builder $query
|
||||
* @return UserOverridesAwareBuilder
|
||||
*/
|
||||
public function newEloquentBuilder($query): UserOverridesAwareBuilder
|
||||
{
|
||||
return new UserOverridesAwareBuilder($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* trait 부팅 — Eloquent updating 이벤트로 사용자 수정 자동 기록.
|
||||
*/
|
||||
public static function bootHasUserOverrides(): void
|
||||
{
|
||||
static::updating(function (Model $model) {
|
||||
if (app()->bound('user_overrides.seeding') && app('user_overrides.seeding') === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userOverrides = $model->user_overrides ?? [];
|
||||
$original = $userOverrides;
|
||||
|
||||
foreach ($model->getTrackableFields() as $field) {
|
||||
if ($model->isDirty($field) && ! in_array($field, $userOverrides, true)) {
|
||||
$userOverrides[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
if ($userOverrides !== $original) {
|
||||
$model->user_overrides = $userOverrides;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* trackable 필드 목록을 반환합니다.
|
||||
*
|
||||
* 모델에서 `protected array $trackableFields = [...]` 로 오버라이드할 수 있습니다.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getTrackableFields(): array
|
||||
{
|
||||
return property_exists($this, 'trackableFields') ? $this->trackableFields : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 모델 상태와 신규 입력 값을 비교하여 user_overrides 에 추가될 필드 집합을 계산합니다.
|
||||
*
|
||||
* `UserOverridesAwareBuilder` 가 mass update 시에도 user_overrides 를 누적하도록
|
||||
* 호출합니다. Eloquent save/event 경로를 우회하여도 동일한 로직을 재사용할 수 있도록
|
||||
* 공개 메서드로 노출합니다.
|
||||
*
|
||||
* @param array<string, mixed> $incomingAttributes 사용자 입력/mass update 값
|
||||
* @return array<int, string> 계산된 user_overrides 배열
|
||||
*/
|
||||
public function calculateUserOverridesFor(array $incomingAttributes): array
|
||||
{
|
||||
$current = $this->user_overrides ?? [];
|
||||
|
||||
foreach ($this->getTrackableFields() as $field) {
|
||||
if (! array_key_exists($field, $incomingAttributes)) {
|
||||
continue;
|
||||
}
|
||||
$incoming = $incomingAttributes[$field];
|
||||
if ($this->{$field} != $incoming && ! in_array($field, $current, true)) {
|
||||
$current[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 업그레이드 스텝/시더에서 호출 — user_overrides 보존하며 갱신합니다.
|
||||
*
|
||||
* trackable 필드 + user_overrides 에 등록된 필드는 갱신을 건너뜁니다.
|
||||
*
|
||||
* @param array<string, mixed> $newAttributes 시더 정의 값
|
||||
*/
|
||||
public function syncFromUpgrade(array $newAttributes): void
|
||||
{
|
||||
$userOverrides = $this->user_overrides ?? [];
|
||||
$trackable = $this->getTrackableFields();
|
||||
$updateData = [];
|
||||
|
||||
foreach ($newAttributes as $field => $value) {
|
||||
if (in_array($field, $trackable, true) && in_array($field, $userOverrides, true)) {
|
||||
continue;
|
||||
}
|
||||
$updateData[$field] = $value;
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
app()->instance('user_overrides.seeding', true);
|
||||
try {
|
||||
$this->update($updateData);
|
||||
} finally {
|
||||
app()->forgetInstance('user_overrides.seeding');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 시더 컨텍스트에서 신규 생성 — user_overrides 자동 기록 비활성.
|
||||
*
|
||||
* @param array<string, mixed> $attributes 신규 생성 속성
|
||||
* @return static 생성된 모델 인스턴스
|
||||
*/
|
||||
public static function createFromUpgrade(array $attributes): self
|
||||
{
|
||||
app()->instance('user_overrides.seeding', true);
|
||||
try {
|
||||
return static::create(array_merge(
|
||||
$attributes,
|
||||
['user_overrides' => null],
|
||||
));
|
||||
} finally {
|
||||
app()->forgetInstance('user_overrides.seeding');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 시더 헬퍼 — 존재하지 않으면 생성, 존재하면 syncFromUpgrade 호출합니다.
|
||||
*
|
||||
* @param array<string, mixed> $finder updateOrCreate 의 첫 번째 인자
|
||||
* @param array<string, mixed> $attributes 시더 정의 값
|
||||
* @return static 생성/갱신된 모델 인스턴스
|
||||
*/
|
||||
public static function syncOrCreateFromUpgrade(array $finder, array $attributes): self
|
||||
{
|
||||
$existing = static::where($finder)->first();
|
||||
if (! $existing) {
|
||||
return static::createFromUpgrade(array_merge($finder, $attributes));
|
||||
}
|
||||
$existing->syncFromUpgrade($attributes);
|
||||
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
+26
-4
@@ -5,16 +5,17 @@ namespace App\Models\Concerns;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* 메일 템플릿 모델 공통 Trait.
|
||||
* 알림 템플릿 컨텐츠 Trait.
|
||||
*
|
||||
* 코어/모듈 메일 템플릿 모델에서 공통으로 사용하는 casts, 스코프, 헬퍼를 제공합니다.
|
||||
* NotificationTemplate 모델에서 공통으로 사용하는 casts, 스코프, 다국어 헬퍼,
|
||||
* 변수 치환 메서드를 제공합니다.
|
||||
*/
|
||||
trait MailTemplateBehavior
|
||||
trait NotificationContentBehavior
|
||||
{
|
||||
/**
|
||||
* 공통 casts를 초기화합니다.
|
||||
*/
|
||||
public function initializeMailTemplateBehavior(): void
|
||||
public function initializeNotificationContentBehavior(): void
|
||||
{
|
||||
$this->mergeCasts([
|
||||
'subject' => 'array',
|
||||
@@ -100,4 +101,25 @@ trait MailTemplateBehavior
|
||||
'body' => strtr($body, $replacements),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 문자열의 변수를 치환합니다.
|
||||
*
|
||||
* click_url 등 다국어가 아닌 단일 문자열에 {key} 패턴을 치환합니다.
|
||||
*
|
||||
* @param string $template 치환 대상 문자열 (예: /mypage/orders/{order_number})
|
||||
* @param array $data 변수 데이터
|
||||
* @return string 치환된 문자열
|
||||
*/
|
||||
public function replaceVariablesInString(string $template, array $data): string
|
||||
{
|
||||
$replacements = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_string($value) || is_numeric($value)) {
|
||||
$replacements['{' . $key . '}'] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return strtr($template, $replacements);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* user_overrides 인지 Eloquent Builder.
|
||||
*
|
||||
* `HasUserOverrides` trait 이 적용된 모델의 mass update 를 가로채 user_overrides
|
||||
* 컬럼에 수정 필드를 자동 누적합니다.
|
||||
*
|
||||
* 동작 흐름:
|
||||
* - `Model::where(...)->update([...])` 호출 시
|
||||
* - 대상 행을 preload (1 SELECT)
|
||||
* - 각 행별로 `calculateUserOverridesFor($values)` 호출하여 user_overrides 계산
|
||||
* - per-row UPDATE 를 parent::update() 로 수행 (재귀 없이)
|
||||
* - 시더 컨텍스트(`user_overrides.seeding`) 에서는 기본 경로 유지
|
||||
* - 입력 $values 에 trackable 필드가 하나도 없으면 계산 건너뛰고 기본 경로 (성능 최적화)
|
||||
*
|
||||
* 성능 트레이드오프:
|
||||
* - 기본: 1 UPDATE
|
||||
* - 추적 활성: 1 SELECT + N UPDATE (trackable 필드 포함 시에만)
|
||||
* - 관리자 UI 일괄 저장(수십 행 이하) 대상에는 차이 무시 가능.
|
||||
* - 대량 처리는 `DB::table()->update()` (Eloquent 우회) 권장.
|
||||
*
|
||||
* @since 7.0.0-beta.2
|
||||
*/
|
||||
class UserOverridesAwareBuilder extends Builder
|
||||
{
|
||||
/**
|
||||
* 쿼리 조건에 부합하는 모델을 업데이트합니다.
|
||||
*
|
||||
* @param array<string, mixed> $values
|
||||
* @return int 영향받은 행 수
|
||||
*/
|
||||
public function update(array $values)
|
||||
{
|
||||
// 시더 컨텍스트에서는 trait 이벤트 handler 가 어차피 early return 하므로
|
||||
// 성능을 위해 기본 mass update 경로 사용.
|
||||
if (app()->bound('user_overrides.seeding') && app('user_overrides.seeding') === true) {
|
||||
return parent::update($values);
|
||||
}
|
||||
|
||||
$model = $this->getModel();
|
||||
$trackable = method_exists($model, 'getTrackableFields')
|
||||
? $model->getTrackableFields()
|
||||
: [];
|
||||
|
||||
// 입력에 trackable 필드가 하나도 없으면 user_overrides 갱신 불필요 — 기본 경로.
|
||||
if (empty($trackable) || empty(array_intersect(array_keys($values), $trackable))) {
|
||||
return parent::update($values);
|
||||
}
|
||||
|
||||
$models = $this->get();
|
||||
if ($models->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$affected = 0;
|
||||
$keyName = $model->getKeyName();
|
||||
|
||||
foreach ($models as $row) {
|
||||
$newOverrides = $row->calculateUserOverridesFor($values);
|
||||
$rowValues = $values;
|
||||
|
||||
// user_overrides 에 변경이 있으면 UPDATE payload 에 포함 (json cast 는 Eloquent 가 처리)
|
||||
if ($newOverrides !== ($row->user_overrides ?? [])) {
|
||||
$rowValues['user_overrides'] = $newOverrides;
|
||||
}
|
||||
|
||||
// 재귀 없이 기본 UPDATE — PK 단일 행 UPDATE
|
||||
$affected += $row->newQueryWithoutScopes()
|
||||
->where($keyName, $row->getKey())
|
||||
->toBase()
|
||||
->update($this->prepareValuesForRow($rowValues, $row));
|
||||
}
|
||||
|
||||
return $affected;
|
||||
}
|
||||
|
||||
/**
|
||||
* array-cast 필드를 DB 저장 형식(JSON 문자열)으로 변환합니다.
|
||||
*
|
||||
* Eloquent Builder::update 는 toBase() 로 진입 시 array 값을 자동 JSON 인코딩하지
|
||||
* 않으므로, 본 메서드에서 모델의 cast 설정을 참조하여 사전 변환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $values
|
||||
* @param \Illuminate\Database\Eloquent\Model $model
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function prepareValuesForRow(array $values, $model): array
|
||||
{
|
||||
foreach ($values as $key => $value) {
|
||||
if (is_array($value) || is_object($value)) {
|
||||
// array/json cast 필드는 JSON 인코딩 후 저장
|
||||
$values[$key] = json_encode($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* 메일 발송 이력 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $recipient_email
|
||||
* @property string|null $recipient_name
|
||||
* @property string|null $subject
|
||||
* @property string|null $template_type
|
||||
* @property ExtensionOwnerType $extension_type
|
||||
* @property string $extension_identifier
|
||||
* @property string|null $source
|
||||
* @property string $status
|
||||
* @property string|null $sender_email
|
||||
* @property string|null $sender_name
|
||||
* @property string|null $error_message
|
||||
* @property \Carbon\Carbon $sent_at
|
||||
*/
|
||||
class MailSendLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @var string 테이블명
|
||||
*/
|
||||
protected $table = 'mail_send_logs';
|
||||
|
||||
/**
|
||||
* @var array<int, string> 대량 할당 가능 필드
|
||||
*/
|
||||
protected $fillable = [
|
||||
'sender_email',
|
||||
'sender_name',
|
||||
'recipient_email',
|
||||
'recipient_name',
|
||||
'subject',
|
||||
'body',
|
||||
'template_type',
|
||||
'extension_type',
|
||||
'extension_identifier',
|
||||
'source',
|
||||
'status',
|
||||
'error_message',
|
||||
'sent_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* 속성 캐스팅 정의
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'extension_type' => ExtensionOwnerType::class,
|
||||
'sent_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 유형별 필터 스코프
|
||||
*
|
||||
* @param Builder $query 쿼리 빌더
|
||||
* @param string|null $templateType 템플릿 유형
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeByTemplateType(Builder $query, ?string $templateType): Builder
|
||||
{
|
||||
if ($templateType === null) {
|
||||
return $query->whereNull('template_type');
|
||||
}
|
||||
|
||||
return $query->where('template_type', $templateType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 타입/식별자별 필터 스코프
|
||||
*
|
||||
* @param Builder $query 쿼리 빌더
|
||||
* @param ExtensionOwnerType|null $type 확장 타입
|
||||
* @param string|null $identifier 확장 식별자
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeByExtension(Builder $query, ?ExtensionOwnerType $type = null, ?string $identifier = null): Builder
|
||||
{
|
||||
if ($type !== null) {
|
||||
$query->where('extension_type', $type);
|
||||
}
|
||||
|
||||
if ($identifier !== null) {
|
||||
$query->where('extension_identifier', $identifier);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 출처별 필터 스코프
|
||||
*
|
||||
* @param Builder $query 쿼리 빌더
|
||||
* @param string|null $source 발송 출처
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeBySource(Builder $query, ?string $source): Builder
|
||||
{
|
||||
if ($source === null) {
|
||||
return $query->whereNull('source');
|
||||
}
|
||||
|
||||
return $query->where('source', $source);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태별 필터 스코프
|
||||
*
|
||||
* @param Builder $query 쿼리 빌더
|
||||
* @param string $status 상태값
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeByStatus(Builder $query, string $status): Builder
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
}
|
||||
+19
-57
@@ -2,71 +2,33 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\MailTemplateBehavior;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 코어 메일 템플릿 모델.
|
||||
* [한시적 호환 Shim — 7.0.0-beta.1 → 7.0.0-beta.2 업그레이드 전용]
|
||||
*
|
||||
* 코어 시스템의 메일 템플릿(welcome, reset_password, password_changed)을 관리합니다.
|
||||
* beta.2 에서 알림 시스템 통합(#256)으로 MailTemplate 은 공식 제거되었으나,
|
||||
* 업데이트 중 in-memory 로 실행되는 beta.1 의 `CoreUpdateService::syncCoreMailTemplates()`
|
||||
* 가 `MailTemplate::where()` / `::create()` 를 호출하여 autoload 가 필요하다.
|
||||
*
|
||||
* 본 shim 은 autoload 통과와 최소 Eloquent 쿼리 성공만 제공하며, 업그레이드 종료 시점에
|
||||
* `Upgrade_7_0_0_beta_2::run()` 이 파일과 `mail_templates` 테이블을 함께 제거한다.
|
||||
* beta.3 에서는 저장소에서도 완전히 삭제 예정.
|
||||
*
|
||||
* ※ 본 파일은 비즈니스 로직을 포함하지 않으며, 확장이나 재사용 대상이 아니다.
|
||||
*/
|
||||
class MailTemplate extends Model
|
||||
{
|
||||
use HasFactory, MailTemplateBehavior;
|
||||
|
||||
/** @var array<string, array> 활동 로그 추적 필드 */
|
||||
public static array $activityLogFields = [
|
||||
'subject' => ['label_key' => 'activity_log.fields.subject', 'type' => 'text'],
|
||||
'body' => ['label_key' => 'activity_log.fields.body', 'type' => 'text'],
|
||||
'is_active' => ['label_key' => 'activity_log.fields.is_active', 'type' => 'boolean'],
|
||||
'is_default' => ['label_key' => 'activity_log.fields.is_default', 'type' => 'boolean'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string 테이블명
|
||||
*/
|
||||
protected $table = 'mail_templates';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed> 기본 속성 값
|
||||
*/
|
||||
protected $attributes = [
|
||||
'variables' => '[]',
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'subject' => 'array',
|
||||
'body' => 'array',
|
||||
'variables' => 'array',
|
||||
'user_overrides' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'is_default' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<int, string> Mass assignable 필드
|
||||
*/
|
||||
protected $fillable = [
|
||||
'type',
|
||||
'subject',
|
||||
'body',
|
||||
'variables',
|
||||
'is_active',
|
||||
'is_default',
|
||||
'user_overrides',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string> 타입 캐스팅
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'user_overrides' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 수정자를 반환합니다.
|
||||
*
|
||||
* @return BelongsTo User 관계
|
||||
*/
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\MenuPermissionType;
|
||||
use App\Models\Concerns\HasUserOverrides;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -17,10 +18,23 @@ use Illuminate\Support\Facades\DB;
|
||||
* 메뉴 모델
|
||||
*
|
||||
* 역할 기반 접근 제어를 지원합니다.
|
||||
*
|
||||
* 사용자가 관리자 UI 로 수정한 필드(name/icon/order/url) 는 `HasUserOverrides` trait
|
||||
* 에 의해 `user_overrides` 컬럼에 자동 누적 기록되며, 이후 코어/확장 업데이트의
|
||||
* `syncMenu()` 경로에서 필드 단위로 보존됩니다.
|
||||
*/
|
||||
class Menu extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasFactory, HasUserOverrides;
|
||||
|
||||
/**
|
||||
* `HasUserOverrides` 가 추적할 필드.
|
||||
*
|
||||
* `ExtensionMenuSyncHelper::syncMenu()` L80~107 의 upsert 시 필드 단위 보존 대상과 일치.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $trackableFields = ['name', 'icon', 'order', 'url'];
|
||||
|
||||
/** @var array<string, array> 활동 로그 추적 필드 */
|
||||
public static array $activityLogFields = [
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUserOverrides;
|
||||
use App\Services\NotificationDefinitionService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class NotificationDefinition extends Model
|
||||
{
|
||||
use HasFactory, HasUserOverrides;
|
||||
|
||||
/**
|
||||
* 사용자 수정 보존 대상 필드.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $trackableFields = ['name', 'is_active'];
|
||||
|
||||
/**
|
||||
* 모델 이벤트 등록 — 모든 변경 시 알림 정의 캐시 자동 삭제.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
$invalidate = function () {
|
||||
try {
|
||||
app(NotificationDefinitionService::class)->invalidateAllCache();
|
||||
} catch (\Throwable) {
|
||||
// 테스트/마이그레이션 환경에서 서비스 미등록 시 무시
|
||||
}
|
||||
};
|
||||
|
||||
static::saved($invalidate);
|
||||
static::deleted($invalidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활동 로그 추적 필드.
|
||||
*
|
||||
* @var array<string, array<string, string>>
|
||||
*/
|
||||
public static array $activityLogFields = [
|
||||
'name' => ['label_key' => 'activity_log.fields.name', 'type' => 'text'],
|
||||
'channels' => ['label_key' => 'activity_log.fields.channels', 'type' => 'text'],
|
||||
'hooks' => ['label_key' => 'activity_log.fields.hooks', 'type' => 'text'],
|
||||
'is_active' => ['label_key' => 'activity_log.fields.is_active', 'type' => 'boolean'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'notification_definitions';
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $attributes = [
|
||||
'variables' => '[]',
|
||||
'channels' => '["mail"]',
|
||||
'hooks' => '[]',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'type',
|
||||
'hook_prefix',
|
||||
'extension_type',
|
||||
'extension_identifier',
|
||||
'name',
|
||||
'description',
|
||||
'variables',
|
||||
'channels',
|
||||
'hooks',
|
||||
'is_active',
|
||||
'is_default',
|
||||
'user_overrides',
|
||||
];
|
||||
|
||||
/**
|
||||
* 캐스팅 정의.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'array',
|
||||
'description' => 'array',
|
||||
'variables' => 'array',
|
||||
'channels' => 'array',
|
||||
'hooks' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'is_default' => 'boolean',
|
||||
'user_overrides' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 템플릿 관계.
|
||||
*
|
||||
* @return HasMany
|
||||
*/
|
||||
public function templates(): HasMany
|
||||
{
|
||||
return $this->hasMany(NotificationTemplate::class, 'definition_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 알림 정의만 조회.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 타입의 알림 정의 조회.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param string $type
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeByType(Builder $query, string $type): Builder
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 확장의 알림 정의 조회.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param string $extensionType
|
||||
* @param string $extensionIdentifier
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeByExtension(Builder $query, string $extensionType, string $extensionIdentifier): Builder
|
||||
{
|
||||
return $query->where('extension_type', $extensionType)
|
||||
->where('extension_identifier', $extensionIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로케일의 이름 반환.
|
||||
*
|
||||
* @param string|null $locale
|
||||
* @return string
|
||||
*/
|
||||
public function getLocalizedName(?string $locale = null): string
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
$name = $this->name ?? [];
|
||||
|
||||
return $name[$locale] ?? $name['ko'] ?? $name['en'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로케일의 설명 반환.
|
||||
*
|
||||
* @param string|null $locale
|
||||
* @return string
|
||||
*/
|
||||
public function getLocalizedDescription(?string $locale = null): string
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
$description = $this->description ?? [];
|
||||
|
||||
return $description[$locale] ?? $description['ko'] ?? $description['en'] ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\NotificationLogStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class NotificationLog extends Model
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'notification_logs';
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'channel',
|
||||
'notification_type',
|
||||
'extension_type',
|
||||
'extension_identifier',
|
||||
'recipient_user_id',
|
||||
'recipient_identifier',
|
||||
'recipient_name',
|
||||
'sender_user_id',
|
||||
'subject',
|
||||
'body',
|
||||
'status',
|
||||
'error_message',
|
||||
'source',
|
||||
'sent_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* 캐스팅 정의.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => NotificationLogStatus::class,
|
||||
'sent_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 수신자 사용자 관계.
|
||||
*
|
||||
* @return BelongsTo
|
||||
*/
|
||||
public function recipientUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'recipient_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송자 사용자 관계.
|
||||
*
|
||||
* @return BelongsTo
|
||||
*/
|
||||
public function senderUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'sender_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 채널의 로그만 조회.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param string $channel
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeByChannel(Builder $query, string $channel): Builder
|
||||
{
|
||||
return $query->where('channel', $channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 상태의 로그만 조회.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param NotificationLogStatus $status
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeByStatus(Builder $query, NotificationLogStatus $status): Builder
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 알림 타입의 로그만 조회.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param string $type
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeByNotificationType(Builder $query, string $type): Builder
|
||||
{
|
||||
return $query->where('notification_type', $type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUserOverrides;
|
||||
use App\Models\Concerns\NotificationContentBehavior;
|
||||
use App\Services\NotificationDefinitionService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class NotificationTemplate extends Model
|
||||
{
|
||||
use HasFactory, HasUserOverrides, NotificationContentBehavior;
|
||||
|
||||
/**
|
||||
* 사용자 수정 보존 대상 필드.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $trackableFields = [
|
||||
'subject',
|
||||
'body',
|
||||
'click_url',
|
||||
'recipients',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* 모델 이벤트 등록 — 템플릿 변경 시 알림 정의 캐시 자동 삭제.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
$invalidate = function () {
|
||||
try {
|
||||
app(NotificationDefinitionService::class)->invalidateAllCache();
|
||||
} catch (\Throwable) {
|
||||
// 테스트/마이그레이션 환경에서 서비스 미등록 시 무시
|
||||
}
|
||||
};
|
||||
|
||||
static::saved($invalidate);
|
||||
static::deleted($invalidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활동 로그 추적 필드.
|
||||
*
|
||||
* @var array<string, array<string, string>>
|
||||
*/
|
||||
public static array $activityLogFields = [
|
||||
'subject' => ['label_key' => 'activity_log.fields.subject', 'type' => 'text'],
|
||||
'body' => ['label_key' => 'activity_log.fields.body', 'type' => 'text'],
|
||||
'is_active' => ['label_key' => 'activity_log.fields.is_active', 'type' => 'boolean'],
|
||||
'recipients' => ['label_key' => 'activity_log.fields.recipients', 'type' => 'text'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'notification_templates';
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'definition_id',
|
||||
'channel',
|
||||
'subject',
|
||||
'body',
|
||||
'click_url',
|
||||
'recipients',
|
||||
'is_active',
|
||||
'is_default',
|
||||
'user_overrides',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
/**
|
||||
* 캐스팅 정의.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'recipients' => 'array',
|
||||
'user_overrides' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 정의 관계.
|
||||
*
|
||||
* @return BelongsTo
|
||||
*/
|
||||
public function definition(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(NotificationDefinition::class, 'definition_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정자 관계.
|
||||
*
|
||||
* @return BelongsTo
|
||||
*/
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 채널의 템플릿 조회.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param string $channel
|
||||
* @return Builder
|
||||
*/
|
||||
/**
|
||||
* 수신자 규칙이 설정되어 있는지 확인합니다.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasRecipientConfig(): bool
|
||||
{
|
||||
return ! empty($this->recipients);
|
||||
}
|
||||
|
||||
public function scopeByChannel(Builder $query, string $channel): Builder
|
||||
{
|
||||
return $query->where('channel', $channel);
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -3,15 +3,32 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Models\Concerns\HasUserOverrides;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* 역할 모델.
|
||||
*
|
||||
* 관리자 UI 에서 수정한 필드(name/description) 는 `HasUserOverrides` trait 에 의해
|
||||
* `user_overrides` 컬럼에 자동 누적 기록되며, 이후 코어/확장 업데이트의 `syncRole()`
|
||||
* 경로에서 필드 단위로 보존됩니다.
|
||||
*/
|
||||
class Role extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasFactory, HasUserOverrides;
|
||||
|
||||
/**
|
||||
* `HasUserOverrides` 가 추적할 필드.
|
||||
*
|
||||
* `ExtensionRoleSyncHelper::syncRole()` L79~85 의 upsert 시 필드 단위 보존 대상과 일치.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $trackableFields = ['name', 'description'];
|
||||
|
||||
/** @var array<string, array> 활동 로그 추적 필드 */
|
||||
public static array $activityLogFields = [
|
||||
|
||||
+39
-2
@@ -6,6 +6,7 @@ use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\ScheduleFrequency;
|
||||
use App\Enums\ScheduleResultStatus;
|
||||
use App\Enums\ScheduleType;
|
||||
use App\Models\Concerns\HasUserOverrides;
|
||||
use Cron\CronExpression;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -14,13 +15,47 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* 스케줄 모델
|
||||
* 스케줄 모델.
|
||||
*
|
||||
* 스케줄(크론) 작업을 관리하는 모델입니다.
|
||||
*
|
||||
* @since 7.0.0-beta.2 (HasUserOverrides 사전 설계)
|
||||
*
|
||||
* 현재는 시더 없이 사용자 수동 생성 중심입니다.
|
||||
* 사용자가 trackable 필드를 수정하면 trait 의 Eloquent updating 이벤트가
|
||||
* user_overrides 에 자동 기록합니다.
|
||||
*
|
||||
* 다음 버전에서 스케줄을 DB 로 관리할 때, 코어·확장 seeder 는
|
||||
* `GenericEntitySyncHelper` 를 호출하여 아래 패턴으로 동기화하면 됩니다
|
||||
* (사용자 수정 보존 + stale cleanup 자동 동작):
|
||||
*
|
||||
* ```php
|
||||
* $helper = app(\App\Extension\Helpers\GenericEntitySyncHelper::class);
|
||||
* $definedNames = [];
|
||||
* foreach ($defaultSchedules as $data) {
|
||||
* $helper->sync(Schedule::class, ['name' => $data['name']], $data);
|
||||
* $definedNames[] = $data['name'];
|
||||
* }
|
||||
* $helper->cleanupStale(Schedule::class,
|
||||
* ['extension_type' => $extType, 'extension_identifier' => $extId],
|
||||
* 'name', $definedNames);
|
||||
* ```
|
||||
*/
|
||||
class Schedule extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasFactory, HasUserOverrides;
|
||||
|
||||
/**
|
||||
* 사용자 수정 보존 대상 필드.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $trackableFields = [
|
||||
'expression',
|
||||
'command',
|
||||
'timeout',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/** @var array<string, array> 활동 로그 추적 필드 */
|
||||
public static array $activityLogFields = [
|
||||
@@ -63,6 +98,7 @@ class Schedule extends Model
|
||||
'extension_type',
|
||||
'extension_identifier',
|
||||
'created_by',
|
||||
'user_overrides',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -83,6 +119,7 @@ class Schedule extends Model
|
||||
'timeout' => 'integer',
|
||||
'last_run_at' => 'datetime',
|
||||
'next_run_at' => 'datetime',
|
||||
'user_overrides' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user