Skip to content

🧱 模块与分层

新增业务时优先遵循 modules/{Module} 结构,不是把所有业务都塞进 app/Http

🎯 本页目标

读完本章节后,能清楚三个问题:

  • 新增业务文件应该放在哪个目录。
  • Controller、接口业务动作 Action、Service、Dao、Model 的边界是什么。
  • 一个接口从接收请求到查询数据库,大概需要哪些类协作。

如果你只是想知道目录全貌,先看“指南 / 工程架构”;如果你准备开始写后端接口,本章节应该优先阅读。

🏷️ 模块命名

模块目录使用首字母大写的业务名,例如:

text
modules/System
modules/Shop

模块启动注册器命名:

php
Modules\Shop\Providers\ShopServiceProvider

这里仍然使用 Laravel 的 ServiceProvider 类名约定,但文档里可以理解为“模块启动注册器”:模块被加载时,它负责把本模块需要的路由、配置、事件监听、依赖绑定等接入 Laravel。具体业务流程不要写在这里,应放到接口业务动作 Action 或 Service。

只要类存在,并且模块已经启用,ModuleRegistry::providerClasses() 会自动注册。

模块根目录按约定放置 module.json 描述模块包信息。普通业务模块必须在 system_modules 中存在安装记录且 status=1,才会加载模块启动注册器和路由扫描。System 是核心模块,强制启用且不可卸载。

字段说明与新增模块示例见 模块配置

🎮 控制器目录

text
modules/{Module}/Http/Controllers/Admin
modules/{Module}/Http/Controllers/Api

两类控制器的区别:

目录默认前缀默认中间件用途
Admin/adminapi/{routePrefix}admin.auth后台管理端
Api/api/{routePrefix}api.site + api.auth用户端、H5、小程序、App,普通 Get/Post 默认必须登录,公开接口通过 OpenGet/OpenPost 放行

典型用法见

modules/System/Http/Controllers/Admin/Article/ArticleController.phpmodules/System/Http/Controllers/Api/ArticleController.php

📋 FormRequest 场景验证

项目扩展了 Laravel FormRequest,把"获取请求传参"与"传参验证"合并为一步。核心概念是验证场景:一个 Request 可以定义多组字段(如 saveeditSelf),Controller 在调用时指定需要哪组字段,框架只提取并验证该组的内容。

工作流程

  1. Request 中通过 rules() 定义每个字段的验证规则。
  2. Request 中通过 scenes() 声明每组场景包含哪些字段及其默认值。
  3. Controller 调用 $form->validated('save') 时,框架只提取 save 场景声明的字段。
  4. 如果某个字段在 rules() 中有对应规则,则执行验证;没有规则的字段只提取不验证。
  5. 返回值是经过验证的干净数组,可以直接传给 Dao 或 Service。

Request 声明示例

rules() 定义验证规则,scenes() 定义场景字段和默认值:

php
// 验证规则:定义每个字段的约束
public function rules(): array
{
    return [
        'id'          => ['nullable', 'integer', 'exists:articles,id'],
        'category_id' => ['required', 'integer', 'exists:article_categories,id'],
        'title'       => ['required', 'string', 'max:150'],
        'cover'       => ['nullable', 'string', 'max:1024'],
        'summary'     => ['nullable', 'string', 'max:500'],
        'author'      => ['nullable', 'string', 'max:50'],
        'content'     => ['nullable', 'string'],
        'sort'        => ['nullable', 'integer'],
        'status'      => ['nullable', 'boolean'],
    ];
}

// 场景声明:每个场景列出 [字段名, 默认值]
protected function scenes(): array
{
    return [
        'save' => [
            ['id', null],
            ['category_id', ''],
            ['title', ''],
            ['cover', ''],
            ['summary', ''],
            ['author', ''],
            ['content', ''],
            ['sort', 0],
            ['status', 1],
        ],
    ];
}

场景中的默认值会在字段未传入时生效。例如 ['sort', 0] 表示如果前端没有传 sort,则默认为 0

Controller 调用

php
$data = $form->validated('save');
// $data 是一个干净的关联数组,例如:
// ['id' => 1, 'category_id' => 1, 'title' => '示例标题', 'sort' => 0, ...]

典型用法见 modules/System/Http/Requests/Admin/Article/ArticleRequest.php

🗄️ Dao 约定

Dao 继承 App\Core\Dao\BaseDao,核心能力包括:

  • query():返回模型查询。
  • filter($where):调用模型 scopeFilter
  • get() / getOrFail():单条读取。
  • getList():列表数据查询,并统一返回分页结构。
  • save():按主键新增或更新。
  • changeStatus():统一状态变更。

查询数据优先推荐用 filter() 能力,统一走模型 scopeFilter 模式,避免重复写where

调用 getList() 查询列表数据会自动根据前端传递的 page_nopage_size 处理分页逻辑,无需手工处理;如未传递则按照默认分页处理,page_no 传入 0 则不分页。

列表返回结构统一为:

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

BaseDao API

方法说明
query()返回 SunAdminBuilder 查询实例
filter(array $where)按模型 scope 自动过滤
get($id, ...)按主键或条件查询单条;主键查询默认绕过 scope
getOrFail($id, ...)get(),不存在时抛 DataNotFoundException
getList($where, ...)分页列表,自动读取 page_no / page_size
save($data, ...)按主键新增或更新(id=0 新增,id>0 更新)
create(array $data)直接新增,返回模型实例
firstOrCreate($attributes, $values)查找或创建
updateOrCreate($attributes, $values)查找或更新
changeStatus($id, $status)快速状态切换
has($id)存在性检查
getValue($id, $field)按主键取单字段值
getColumn($where, $field)按条件取某列集合
delete($id, ...)按主键删除
update($id, $data, ...)按主键更新
count($where)计数
sum($where, $field)求和
getMax($where, $field)取最大值
getMin($where, $field)取最小值

getList() 的默认分页大小由 config('sunadmin.list.page_size') 控制(默认 20),最大值由 config('sunadmin.list.page_size_max') 控制(默认 200)。

queryCallback 回调

get()getOrFail()getList() 支持 $queryCallback 参数,用于在不继承 Dao 的前提下追加查询条件:

php
// 获取订单时额外关联商品并筛选状态
$orderDao->get($orderId, '*', function (SunAdminBuilder $query, array $where) {
    return $query->with('items')->where('status', 'paid');
});

// 列表查询时追加分组统计
$list = $orderDao->getList($where, '*', ['id' => 'desc'], null, function (SunAdminBuilder $query) {
    return $query->withCount('items');
});

回调接收 $query$where 两个参数。返回 SunAdminBuilder 实例时会替换原查询,否则保留原查询不变。

save 与 create 的区别

  • save($data):按主键判断新增或更新,支持 id=0 新增、id>0 更新;id>0 但记录不存在时抛 DataNotFoundException
  • create($data):始终新增,不检查主键,返回新模型实例。
  • firstOrCreate($attributes, $values):按 $attributes 查找,不存在时用 $attributes + $values 创建。
  • updateOrCreate($attributes, $values):按 $attributes 查找,存在时更新,不存在时创建。

典型用法见 app/Core/Dao/BaseDao.php

FormRequest 辅助方法

FormRequest 提供 topLevelRuleFields() 方法,用于从 rules() 中提取顶层字段名列表并去重。该方法会自动剥离嵌套字段的点号后缀(如 user.name 提取为 user),适用于需要动态获取所有可操作字段的场景。

php
// rules() 中定义了 'id', 'category_id', 'title', 'user.name', 'user.email'
$fields = $this->topLevelRuleFields(['id' => null, 'title' => '']);
// 返回:[['id', null], ['category_id', ''], ['title', ''], ['user', '']]

日常开发中通常不需要直接调用此方法,scenes() 声明式场景已覆盖大多数需求。

⚙️ 接口业务动作 Action 与 Service

建议保持以下边界:

  • Controller 不写复杂业务,只负责入口、验证、响应或简单的CRUD操作。
  • Action 可以理解为“接口业务动作”,通常面向一个具体接口或一次明确操作,比如登录、保存页面、处理提现。
  • Service 面向可复用能力,比如上传、支付、AI、缓存、短信。
  • 功能模块应通过接口接入,例如业务模块依赖优惠券能力时注入 CouponServiceInterface,由已安装模块提供真实实现,未安装时由 System 的空实现兜底。
  • Dao 不承担复杂业务编排,尽量只处理查询和持久化。

分层职责示例

下面以“后台获取用户列表”为例说明各层职责。示例不追求完整类名,只展示推荐写法。

Controller 只负责入口、验证和响应:

php
#[Inject]
protected UserAction $action;

#[Get('user/list')]
public function list()
{
    $filters = $this->request()->pick([
        ['keyword', ''],
        ['status', '']
    ]);
    return success($this->action->handleList($filters));
}

Action 负责完成本次接口对应的业务动作:

php
#[Inject]
protected UserDao $userDao;

class UserAction
{
    public function handleList(array $filters): array
    {
        return $this->userDao->getUserList($filters);
    }
}

Dao 负责数据查询和用 listData() 统一处理分页:

php
class UserDao extends BaseDao
{
    #[Inject]
    protected User $userModel;

    protected function model(): User
    {
        return $this->userModel;
    }

    public function getUserList(array $filters): array
    {
        return $this->query()
            ->filter($filters)
            ->latest('id')
            ->listData();
    }
}

Model 负责字段、关联和查询条件:

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

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

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

这样拆分后,Controller 不会堆业务,Action 可以表达一次接口业务动作,Dao 可以复用查询能力,Model 的 scope 也能被多个列表复用。

对于简单的 CRUD 列表,可以省略 Action 层,让 Controller 直接调用 Dao 的 getList() 方法。Dao 的写法不变,Controller 简化为:

php
#[Inject]
protected UserDao $dao;

#[Get('user/list')]
public function list()
{
    $filters = $this->request()->pick([
        ['keyword', ''],
        ['status', '']
    ]);
    return success($this->dao->getList($filters, '*', ['id' => 'desc']));
}

是否需要 Action 层取决于业务复杂度:纯 CRUD 可以跳过;涉及多表事务、支付、状态机等业务逻辑时,建议保留。

🧯 常见放错位置

错误写法推荐写法为什么
在 Controller 里写多表事务、支付调用、上传处理放到 Action 或 ServiceController 应保持薄层,方便复用和测试
在 Dao 里判断登录人、角色、按钮权限权限判断放在 Controller / 中间件Dao 只负责数据,不应感知请求上下文
在 Model accessor 里查询其他表统计数量withCount() 显式预加载accessor 在列表中每行触发,导致 N+1 查询
在 Service 里直接读取未验证的请求参数先由 FormRequest 验证,再传入 ServiceService 不应依赖请求对象,接收干净参数更易测试
新增后台页面只改前端路由同步维护菜单、权限、Seeder菜单和权限由数据库驱动,只改前端会导致页面 404 或无权限

🧪 模型与 Dao 目录约定

模型继承 BaseModel 后默认获得:

  • SunAdminBuilder 查询构建器。
  • 日期序列化。
  • 媒体 URL 自动转换能力。
  • scopeFilter 过滤能力约定。

新增模型时建议放在:

text
modules/{Module}/Domain/Models/{Domain}/{Model}.php

对应 Dao 放在:

text
modules/{Module}/Dao/{Domain}/{Model}Dao.php

🌱 Seeder 与初始数据

模型和 Dao 定义好之后,还需要准备初始数据。菜单、权限、字典、配置等基础数据通过 Seeder 写入,否则新部署环境看不到对应功能。

System 模块使用:

text
modules/System/Database/Seeders/SystemDatabaseSeeder.php
modules/System/Database/Seeders/SystemInitialDataSeeder.php
modules/System/Database/Seeders/Data/SystemInitialRows.php

涉及菜单、权限、字典、初始配置时,不要只改前端页面;需要同步维护数据库初始数据,否则新部署环境不会出现对应菜单或配置。

业务模块应维护自己的 Seeder。业务模块菜单、权限、默认配置不应继续写进 System 模块的初始化数据。

模块菜单写入不要固定 id,应根据当前数据库自增值生成,再把实际生成的菜单 ID 关联到角色权限中。这样模块可以在已有系统中追加安装,不会依赖某一套固定菜单 ID。

模块安装器会在安装流程中执行模块迁移、Seeder 和资源发布。可选业务模块的 admin 页面、uni-app 模块源码、移动端 API、Store、类型文件建议放在 modules/{Module}/Resources,通过 Resources/publish-manifest.json 发布到主工程,而不是直接长期写死在 frontend/adminfrontend/uniapp 中。

uni-app 可安装模块的源码统一发布到 frontend/uniapp/src/modules/{module},页面放在 frontend/uniapp/src/modules/{module}/pages,再由 pages.jsonsubPackages 指向 modules/{module}/pages。会被主包直接引用的模块 API、工具、类型应放在分包 root 外,避免微信小程序主包跨分包加载文件。