Skip to content

🧬 模型机制

SunAdmin 模型统一继承 App\Core\Models\BaseModel。模型层负责字段转换、关联、查询 scope、展示 accessor 等,不承担复杂业务编排。

🎯 本页目标

本页用于说明模型层应该写什么,以及 scopegetsetcast 各自解决什么问题。新增模型、列表筛选、字段展示转换、媒体 URL 自动补全、JSON 字段处理时,都应该先看本页。

机制解决的问题适合写什么
scope复用查询条件关键字搜索、状态筛选、分类筛选、时间范围
getXxxAttribute读取时追加或转换展示字段状态文案、分类名称、图片完整 URL、轻量格式化
setXxxAttribute保存时归一化字段URL 转相对路径、数组转 JSON、金额精度处理
casts字段类型自动转换JSON 数组、布尔值、时间、枚举
关联方法表关系表达belongsTohasManyhasOnebelongsToMany

🧫 BaseModel

text
app/Core/Models/BaseModel.php

默认能力:

  • 使用 SunAdminBuilder——项目自定义的查询构建器,扩展了 filter()listData() 方法。
  • $guarded = [],默认允许批量赋值。
  • HasLegacyDateSerialization——日期统一序列化为 Y-m-d H:i:s,避免 ISO 格式和旧格式混用。
  • HasMediaUrlAttributes——媒体字段读取时自动补全 URL,保存时自动还原为相对路径。
  • HasScopeFilters——Dao 的 filter() 数组自动映射到模型 scope 方法。
  • 提供 scopeId()——按主键查询的便捷 scope。

📅 日期序列化

HasLegacyDateSerialization 将日期统一序列化为:

text
Y-m-d H:i:s

这样接口返回不会出现 ISO 字符串和旧格式混用。

🎚️ scope 过滤机制

HasScopeFilters 允许 Dao 通过数组自动调用模型 scope:

php
$articleDao->filter([
    'keyword' => '新闻',
    'status' => 1,
    'category_id' => 3,
]);

模型中只要存在:

php
public function scopeKeyword(Builder $query, ?string $keyword): Builder
public function scopeStatus(Builder $query, string|int|bool|null $status): Builder
public function scopeCategoryId(Builder $query, string|int|null $categoryId): Builder

就会自动调用。

过滤规则:

  • null、空字符串、空数组会跳过。
  • filter key 会转成 scope{StudlyKey}
  • 如果 value 是 list array,且 scope 参数数量大于 1,会展开传参。
  • 不存在对应 scope 时忽略该 filter。

scope 示例

php
use Illuminate\Database\Eloquent\Builder;

public function scopeKeyword(Builder $query, ?string $keyword): Builder
{
    return $query->when($keyword, function (Builder $query) use ($keyword) {
        $query->where('title', 'like', "%{$keyword}%");
    });
}

public function scopeStatus(Builder $query, int|string|null $status): Builder
{
    return $query->when($status !== null && $status !== '', function (Builder $query) use ($status) {
        $query->where('status', (int) $status);
    });
}

public function scopeCreatedBetween(Builder $query, ?string $start, ?string $end): Builder
{
    return $query
        ->when($start, fn (Builder $query) => $query->where('created_at', '>=', $start))
        ->when($end, fn (Builder $query) => $query->where('created_at', '<=', $end));
}

Dao 中传参:

php
$dao->filter([
    'keyword' => '配置',
    'status' => 1,
    'created_between' => ['2026-01-01 00:00:00', '2026-01-31 23:59:59'],
]);

🔍 查询构建器

SunAdminBuilder 提供:

方法说明
filter(array $filters)调用模型 scopeFilter
autoPage()page_no/page_size 自动分页
listData()输出统一列表结构

listData() 返回:

json
{
  "list": [],
  "count": 0,
  "page_no": 1,
  "page_size": 20
}

📤 Accessor 示例

文章模型:

text
modules/System/Domain/Models/Article/Article.php

追加字段:

php
protected $appends = ['category_name', 'status_desc'];

访问器:

php
public function getCategoryNameAttribute(): string
public function getStatusDescAttribute(): string

代码示例

php
// 获取分类名称
public function getCategoryNameAttribute(): string
{
    if ($this->relationLoaded('category')) {
        return $this->category?->name ?? '';
    }
    return '';
}

// 获取状态
public function getStatusDescAttribute(): string
{
    return $this->status ? '启用' : '停用';
}

建议:

  • 展示类字段可使用 accessor。
  • 复杂统计不要塞进 accessor,避免列表 N+1 或隐式查询。
  • 依赖关联的 accessor 应先判断 relationLoaded()

📥 Mutator 示例

Mutator 也叫“修改器”或“保存器”,用于在字段写入模型时做归一化处理。它的触发时机是给模型属性赋值或批量保存时,例如:

php
$config->value = $value;
$config->fill(['value' => $value]);
$configDao->save($data);

只要最终写入了对应属性,setXxxAttribute() 就会有机会处理这个字段。

Mutator 适合做什么

适合不适合
字段类型归一化多表事务
前端字段名兼容到真实字段支付、上传、AI 调用等外部业务
URL 转相对路径权限判断
数组转 JSON / 字符串复杂统计查询
布尔值、数字值规范化发送通知、写日志等副作用操作

简单说:Mutator 只处理“这个字段入库前应该变成什么样”,不要在里面写一整段业务流程。

配置 value 保存示例

配置模型:

text
modules/System/Domain/Models/Config.php

Config::setValueAttribute() 会:

  • image 类型保存为相对路径。
  • 富文本资源保存为相对路径。
  • 数组保存为 JSON。

例如后台保存图片配置时,前端可能传:

text
https://example.com/storage/uploads/logo.png

Mutator 会在入库前转换为更适合存储的相对值:

text
uploads/logo.png

这样数据库不会绑定当前域名;后续换域名、换 CDN、换部署环境时,读取阶段再统一转成公开 URL。

字段别名保存示例

菜单模型:

text
modules/System/Domain/Models/Auth/SystemMenu.php

它同时提供了 getter 和 setter,用来兼容前端字段名与数据库字段名:

php
public function getPidAttribute(): int
{
    return (int) ($this->attributes['parent_id'] ?? 0);
}

public function setPidAttribute(int|string|null $value): void
{
    $this->attributes['parent_id'] = (int) ($value ?? 0);
}

含义:

  • 读取 $menu->pid 时,实际返回的是 parent_id
  • 保存 pid 时,实际写入的是 parent_id
  • 前端可以使用更短的 pid 字段,数据库仍保持清晰的 parent_id 字段。

另一个例子:

php
public function setIsDisableAttribute(int|string|bool|null $value): void
{
    $this->attributes['is_disabled'] = (int) ($value ?? 0) === 1;
}

含义:

  • 前端传 is_disable = 1
  • 模型保存时转换为数据库字段 is_disabled = true
  • 避免 Controller 或 Dao 里到处写字段转换。

Mutator 注意事项

  • setXxxAttribute() 里的 Xxx 对应外部属性名,例如 setValueAttribute() 对应 value
  • 如果要写入真实数据库字段,需要操作 $this->attributes['field_name']
  • Mutator 会在模型赋值时触发,不要在里面做耗时操作或有副作用的操作。
  • 依赖其他字段时,要注意赋值顺序。例如 Config::setValueAttribute() 会读取当前 type,保存前应先保证 type 已经写入模型属性。
  • 批量更新如果直接走查询构造器 Model::query()->update([...]),不会触发模型 Mutator;需要模型转换能力时,应通过模型实例、Dao 保存方法或显式转换后再更新。
  • Mutator 只负责入库前转换;读取展示转换应放到 Accessor、Cast 或统一服务中。

🖼️ 媒体 URL 机制

数据库中存储相对路径(如 uploads/logo.png),接口返回时自动补全为完整 URL(如 https://example.com/storage/uploads/logo.png)。这样做的好处是:换域名、换 CDN、换部署环境时,数据库记录不需要改动,只需更新应用配置。

HasMediaUrlAttributes 默认处理:

php
protected array $fileUrlAttributes = ['avatar', 'cover', 'image', 'uri'];
protected array $fileUrlArrayAttributes = ['images'];
protected array $contentUrlAttributes = ['content'];

读取时:

  • 相对路径转完整 URL。
  • images 输出始终是数组。
  • 富文本中的 src/href/poster 转完整 URL。

保存时:

  • 本站完整 URL 转相对路径。
  • images 入库仍保存为逗号字符串,兼容旧表结构。
  • 富文本中的本站资源 URL 转相对路径。

📄 JSON Cast

Laravel 原生 json cast 在遇到空值或已转义的 JSON 字符串时可能报错。项目自定义了 JsonArray cast,额外兼容了 stripslashes 处理和空值兜底,确保读写都不会因为历史数据格式异常而中断。

JsonArray cast:

text
app/Core/Casts/JsonArray.php

底层工具:

text
app/Core/Support/JsonField.php

能力:

  • 空值输出空数组。
  • 兼容普通 JSON 和被转义过的 JSON。
  • 保存数组时使用 JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES

📐 模型开发规范

  • 查询条件优先写成 scope,供 Dao filter() 复用。
  • accessor 只做轻量展示转换。
  • mutator 只做字段级归一化,不写跨表业务。
  • 列表查询需要关联字段时显式 with()
  • 可配置状态文案可放 Enum 或字典,不要散写多个魔法数字。
  • 删除能力按业务选择软删除或硬删除;软删除模型需 use SoftDeletes

软删除与唯一字段

软删除模型如果存在唯一字段,需要先判断这个唯一值是否允许在删除后重新使用。

  • 普通管理数据允许删除后重新创建同名数据,例如账号、页面标识、字典键名、语言编码等。删除前应释放唯一字段,避免软删除记录继续占用唯一索引;新增或编辑前如果需要兼容历史软删残留,也应先释放同值的已删除记录。
  • 可复用唯一字段可使用 App\Core\Models\Concerns\ReleasesUniqueKeys,在模型中引入后调用 releaseUniqueKeys(['field'])。释放后的旧值会追加内部删除后缀,旧记录仍保留软删除状态,新建数据按新记录处理。
  • 表单唯一校验需要与业务语义一致。软删除后允许复用的字段,Rule::unique() 应配合 withoutTrashed(),避免已删除记录在 Request 阶段提前拦截。
  • 支付单号、退款单号、流水号、审计编号等不可复用业务标识不能释放唯一值,也不应允许删除后复用同号;这类字段应保持全表唯一,保证幂等、对账和审计可追溯。

🧪 完整模型示例

下面示例展示一个典型模型应该包含哪些内容,按职责分为四个部分:

php
<?php

namespace Modules\System\Domain\Models\Article;

use App\Core\Models\BaseModel;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;

class Article extends BaseModel
{
    // ========== 1. Trait 与基础配置 ==========
    use SoftDeletes;

    // 追加到接口响应的虚拟字段
    protected $appends = ['category_name', 'status_desc'];

    // ========== 2. 关联 ==========
    public function category(): BelongsTo
    {
        return $this->belongsTo(ArticleCategory::class, 'category_id');
    }

    // ========== 3. 查询 Scope ==========
    // 供 Dao filter() 自动调用,命名规则:scope{StudlyKeyName}

    public function scopeKeyword(Builder $query, ?string $keyword): Builder
    {
        return $query->when($keyword, fn (Builder $query) => $query->where('title', 'like', "%{$keyword}%"));
    }

    public function scopeTitle(Builder $query, ?string $title): Builder
    {
        return $this->scopeKeyword($query, $title);
    }

    public function scopeStatus(Builder $query, string|int|bool|null $status): Builder
    {
        if ($status === '' || $status === null) {
            return $query;
        }

        return $query->where('status', (bool) $status);
    }

    public function scopeCategoryId(Builder $query, string|int|null $categoryId): Builder
    {
        if ($categoryId === '' || $categoryId === null) {
            return $query;
        }

        return $query->where('category_id', (int) $categoryId);
    }

    public function scopeEnabled(Builder $query): Builder
    {
        return $query->where('status', true);
    }

    // ========== 4. Accessor(读取展示转换) ==========

    // 关联字段需先判断 relationLoaded(),避免 N+1 查询
    public function getCategoryNameAttribute(): string
    {
        if ($this->relationLoaded('category')) {
            return $this->category?->name ?? '';
        }

        return '';
    }

    public function getStatusDescAttribute(): string
    {
        return $this->status ? '启用' : '停用';
    }
}