🧱 模块与分层
新增业务时优先遵循 modules/{Module} 结构,不是把所有业务都塞进 app/Http。
🎯 本页目标
读完本章节后,能清楚三个问题:
- 新增业务文件应该放在哪个目录。
- Controller、接口业务动作 Action、Service、Dao、Model 的边界是什么。
- 一个接口从接收请求到查询数据库,大概需要哪些类协作。
如果你只是想知道目录全貌,先看“指南 / 工程架构”;如果你准备开始写后端接口,本章节应该优先阅读。
🏷️ 模块命名
模块目录使用首字母大写的业务名,例如:
modules/System
modules/Shop模块启动注册器命名:
Modules\Shop\Providers\ShopServiceProvider这里仍然使用 Laravel 的 ServiceProvider 类名约定,但文档里可以理解为“模块启动注册器”:模块被加载时,它负责把本模块需要的路由、配置、事件监听、依赖绑定等接入 Laravel。具体业务流程不要写在这里,应放到接口业务动作 Action 或 Service。
只要类存在,并且模块已经启用,ModuleRegistry::providerClasses() 会自动注册。
模块根目录按约定放置 module.json 描述模块包信息。普通业务模块必须在 system_modules 中存在安装记录且 status=1,才会加载模块启动注册器和路由扫描。System 是核心模块,强制启用且不可卸载。
字段说明与新增模块示例见 模块配置。
🎮 控制器目录
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.php。 modules/System/Http/Controllers/Api/ArticleController.php。
📋 FormRequest 场景验证
项目扩展了 Laravel FormRequest,把"获取请求传参"与"传参验证"合并为一步。核心概念是验证场景:一个 Request 可以定义多组字段(如 save、editSelf),Controller 在调用时指定需要哪组字段,框架只提取并验证该组的内容。
工作流程
- Request 中通过
rules()定义每个字段的验证规则。 - Request 中通过
scenes()声明每组场景包含哪些字段及其默认值。 - Controller 调用
$form->validated('save')时,框架只提取save场景声明的字段。 - 如果某个字段在
rules()中有对应规则,则执行验证;没有规则的字段只提取不验证。 - 返回值是经过验证的干净数组,可以直接传给 Dao 或 Service。
Request 声明示例
rules() 定义验证规则,scenes() 定义场景字段和默认值:
// 验证规则:定义每个字段的约束
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 调用
$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_no、page_size 处理分页逻辑,无需手工处理;如未传递则按照默认分页处理,page_no 传入 0 则不分页。
列表返回结构统一为:
{
"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 的前提下追加查询条件:
// 获取订单时额外关联商品并筛选状态
$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),适用于需要动态获取所有可操作字段的场景。
// 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 只负责入口、验证和响应:
#[Inject]
protected UserAction $action;
#[Get('user/list')]
public function list()
{
$filters = $this->request()->pick([
['keyword', ''],
['status', '']
]);
return success($this->action->handleList($filters));
}Action 负责完成本次接口对应的业务动作:
#[Inject]
protected UserDao $userDao;
class UserAction
{
public function handleList(array $filters): array
{
return $this->userDao->getUserList($filters);
}
}Dao 负责数据查询和用 listData() 统一处理分页:
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 负责字段、关联和查询条件:
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 简化为:
#[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 或 Service | Controller 应保持薄层,方便复用和测试 |
| 在 Dao 里判断登录人、角色、按钮权限 | 权限判断放在 Controller / 中间件 | Dao 只负责数据,不应感知请求上下文 |
| 在 Model accessor 里查询其他表统计数量 | 用 withCount() 显式预加载 | accessor 在列表中每行触发,导致 N+1 查询 |
| 在 Service 里直接读取未验证的请求参数 | 先由 FormRequest 验证,再传入 Service | Service 不应依赖请求对象,接收干净参数更易测试 |
| 新增后台页面只改前端路由 | 同步维护菜单、权限、Seeder | 菜单和权限由数据库驱动,只改前端会导致页面 404 或无权限 |
🧪 模型与 Dao 目录约定
模型继承 BaseModel 后默认获得:
SunAdminBuilder查询构建器。- 日期序列化。
- 媒体 URL 自动转换能力。
scopeFilter过滤能力约定。
新增模型时建议放在:
modules/{Module}/Domain/Models/{Domain}/{Model}.php对应 Dao 放在:
modules/{Module}/Dao/{Domain}/{Model}Dao.php🌱 Seeder 与初始数据
模型和 Dao 定义好之后,还需要准备初始数据。菜单、权限、字典、配置等基础数据通过 Seeder 写入,否则新部署环境看不到对应功能。
System 模块使用:
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/admin 或 frontend/uniapp 中。
uni-app 可安装模块的源码统一发布到 frontend/uniapp/src/modules/{module},页面放在 frontend/uniapp/src/modules/{module}/pages,再由 pages.json 的 subPackages 指向 modules/{module}/pages。会被主包直接引用的模块 API、工具、类型应放在分包 root 外,避免微信小程序主包跨分包加载文件。
