v7.0.0-beta.2 release

This commit is contained in:
HeuJung committed 2026-04-20 20:37:49 +09:00
1 parent 6595fd0eb5
commit fa8eba7a0d
1165 files changed
+107118 -762127

No files matched your search

+180
View File
@@ -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;
}
}
@@ -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;
}
}
-132
View File
@@ -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
View File
@@ -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
View File
@@ -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 = [
+177
View File
@@ -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'] ?? '';
}
}
+105
View File
@@ -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);
}
}
+134
View File
@@ -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
View File
@@ -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
View File
@@ -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',
];
}