控制器 Controller
控制器负责接收请求、调用业务逻辑、返回响应。框架不强制控制器继承基类, 开发者可按需选择:
- 需要 CRUD 的控制器:继承
Lychee\routing\ResourceController,获得请求注入、验证快捷方式和零代码 CRUD 能力 - 不需要 CRUD 的控制器:继承
Lychee\http\Controller,获得请求注入、initialize()生命周期与paginate()分页方法 - 返回 JSON:使用全局助手
json()自行组装响应数据结构,框架不强制固定格式
资源控制器 ResourceController
继承 Lychee\routing\ResourceController 可实现零代码 CRUD,同时获得请求注入、initialize() 生命周期钩子、分页响应与验证快捷方式。 配合 #[Resource] 路由注解使用,子类只需声明模型与验证器。
namespace App\controller;
use App\model\User;
use App\validate\UserValidate;
use Lychee\routing\Resource;
use Lychee\routing\ResourceController;
#[Resource('/users')]
class UserController extends ResourceController
{
protected string $modelClass = User::class;
protected string $validateClass = UserValidate::class;
}以上代码自动提供以下接口:
| 方法 | 路由 | 说明 |
|---|---|---|
index() | GET /users | 列表(分页 + 查询 DSL) |
save(Request $request) | POST /users | 新建(验证 + 唯一校验) |
read($id) | GET /users/{id} | 详情 |
update(Request $request, $id) | PUT /users/{id} | 更新 |
delete($id) | DELETE /users/{id} | 删除 |
batch_delete(Request $request) | DELETE /users | 批量删除 |
依赖注入
基类构造函数已注入容器与请求,子类无需重写构造函数。若需要其他依赖,可通过方法参数注入:
use App\model\User;
use Lychee\http\JsonResponse;
class UserController extends ResourceController
{
public function index(User $user): JsonResponse
{
// $this->request 已自动注入
return json(['data' => $user->select()]);
}
}方法参数自动注入请求参数
控制器方法的参数名与请求参数同名时,框架会自动从请求中取值并注入(支持类型自动转换)。 路由参数优先级最高,其次是 GET 查询参数和 POST 请求体参数。
class UserController extends ResourceController
{
// GET /users?page=2&page_size=20
public function index(int $page = 1, int $pageSize = 10): JsonResponse
{
// $page = 2, $pageSize = 20
return json(['page' => $page, 'pageSize' => $pageSize]);
}
// GET /users/{id}?include=profile
public function read(int $id, string $include = ''): JsonResponse
{
// $id 来自路由参数,$include 来自查询参数
return json(['id' => $id, 'include' => $include]);
}
// POST /users { "name": "Alice", "age": 30 }
public function save(string $name, int $age = 0): JsonResponse
{
// $name, $age 自动从请求体注入
return json(compact('name', 'age'));
}
}若参数在请求中不存在且无默认值、不允许
null,内核会抛出RuntimeException。 建议给可选参数设置默认值。
生命周期钩子 initialize()
initialize() 在中间件执行完毕后、方法调用前由内核自动调用。 子类覆盖此方法做初始化,无需调用 parent::initialize()。
class UserController extends ResourceController
{
protected array $config = [];
protected function initialize(): void
{
$this->config = config('user', []);
}
}由于
initialize()在中间件之后执行,认证、i18n、session 等中间件对请求的修改在此均已生效。
JSON 响应
框架不强制响应格式,也不提供 success() / fail() 之类的固定助手, 统一使用全局助手 json() 自行组装数据结构:
// 成功响应(结构自行约定)
return json(['errno' => 0, 'code' => 200, 'msg' => 'success', 'data' => $data]);
// 失败响应:第二个参数指定 HTTP 状态码
return json(['errno' => 0, 'code' => 400, 'msg' => '参数错误', 'data' => null], 400);
// 分页响应(资源控制器版本,第一个参数必须是 Query / Model)
return $this->paginate($query, $current, $pageSize);
ResourceController内置 CRUD 方法(index/save/read/update/delete/batch_delete)统一返回{errno, code, msg, data}结构; 覆盖这些方法时可用json()返回任意自定义结构。
paginate() 的分页数据统一放在 data.list,总数放在 data.total:
{
"errno": 0,
"code": 200,
"msg": "success",
"data": {
"list": [{ "id": 1 }, { "id": 2 }],
"total": 56
}
}
ResourceController重写了paginate():只接受Query/Model,内部调用 Lychee ORM 的分页器,供index()等 CRUD 方法使用。基础控制器Lychee\http\Controller提供的 通用版本还支持直接传入数组,详见下文基础控制器章节。
非 JSON 响应
json() 仅用于 JSON 响应。若需返回 HTML、下载或重定向, 直接返回对应的响应对象,内核会原样输出:
// HTML 视图
return view('user/index', ['list' => $list]);
// 文件下载
return download('report.pdf');
// 重定向
return redirect('/login');全局助手函数
未继承控制器基类或处于非控制器场景(如中间件)时,同样使用全局助手 json():
return json(['errno' => 0, 'code' => 200, 'msg' => 'success', 'data' => $data]);
return json(['errno' => 0, 'code' => 400, 'msg' => '错误', 'data' => null], 400);验证快捷方式
基类的 validate() 方法封装了验证器调用,失败时抛出 ValidateException。
// 传入验证器类名
$this->validate($request->post(), UserValidate::class);
// 支持场景语法 "类名.场景"
$this->validate($request->post(), 'User.save');
// 直接传入规则数组
$this->validate($data, [
'name' => 'require|max:25',
'email' => 'require|email',
]);模型自动推断
若未声明 $modelClass,框架按控制器名自动推断: UserController → app\model\User,OrderController → app\model\Order。
查询 DSL
index() 方法支持通过查询参数后缀构造复杂条件:
| 后缀 | 说明 | 示例 |
|---|---|---|
_like | 模糊匹配 | ?name_like=张 → name LIKE '%张%' |
_range | 时间范围 | ?create_time_range[]=2024-01-01&create_time_range[]=2024-12-31 |
_between | 数值范围 | ?age_between[]=18&age_between[]=60 |
_in | 集合匹配 | ?status_in=1,2,3 |
| 无后缀 | 精确匹配 | ?status=1 → status = 1 |
_in查询会自动识别模型的 JSON 数组字段,使用JSON_CONTAINS匹配。
唯一校验
声明 $uniqueFields 后,save() / update() 会自动检查唯一性,支持联合唯一:
class UserController extends ResourceController
{
protected string $modelClass = User::class;
// 邮箱唯一
protected array $uniqueFields = ['email'];
// 联合唯一:同一租户下用户名唯一
// protected array $uniqueFields = ['tenant_id', 'username'];
}列表搜索与排序(零代码配置)
继承 ResourceController 后,index() 和 read() 无需重写即可自动支持搜索、排序、关联预加载与访问器追加,通过属性配置:
class UserController extends ResourceController
{
protected string $modelClass = User::class;
/** 搜索字段白名单:只允许这些字段作为查询条件(支持 _like 等后缀 DSL) */
protected array $searchFields = ['username_like', 'status'];
/** 分页参数名:[当前页, 每页条数],默认 ['current', 'pageSize'] */
protected array $pageFields = ['page', 'limit'];
/** 追加属性与关联(混合列表,统一走 append,关联自动预加载),index 和 read 均生效 */
protected array $append = ['profile', 'roles', 'nickname', 'is_vip'];
/** 默认排序,请求未传 order 时使用 */
protected array $order = ['create_time' => 'desc'];
}searchFields:白名单内的字段才会从 GET 参数中提取为查询条件,避免前端传任意字段触发异常查询;为空(默认)时取全部 GET 参数。pageFields:分页参数名,默认['current', 'pageSize'],可自定义为['page', 'limit']等。append:追加属性与关联的混合列表,index(列表)和read(详情)均生效。框架统一通过append()处理:访问器字段走获取器,关联方法自动预加载(无 N+1)。关联名支持蛇形(ad_platform)与驼峰(adPlatform),输出 key 与传入名一致。order:列表默认排序,请求传了order参数时以请求为准。
若
index与read需要不同的追加属性/关联,重写对应方法并显式传入$append参数即可(baseIndex/baseRead均接受$append参数,优先级高于属性)。
请求示例:
GET /api/users?username_like=张&status=1¤t=1&pageSize=10自定义方法与 base* 复用
需要自定义逻辑时,可覆盖对应方法。框架提供了 baseIndex / baseSave / baseRead / baseUpdate / baseDelete / baseBatchDelete 六个 protected 方法, 覆盖后仍可调用它们复用分页、排序、验证、数据权限等逻辑。
自定义查询条件(index)
class UserController extends ResourceController
{
protected string $modelClass = User::class;
public function index(int $current = 1, int $pageSize = 20, ?string $name = null, ?int $status = null): JsonResponse
{
$where = [];
if ($name !== null) {
$where['name_like'] = $name; // applyWhere 会自动加 %,无需手写
}
if ($status !== null) {
$where['status'] = $status;
}
return $this->baseIndex($where, $current, $pageSize);
}
}baseIndex() 签名:
protected function baseIndex(
array $where = [], // 查询条件(支持 _like / _range / _between / _in 后缀)
int $current = 1, // 当前页码(调用方显式传入)
int $pageSize = 20, // 每页条数(调用方显式传入)
array $append = [], // 追加属性与关联的混合列表(统一走 append,关联自动预加载)
): JsonResponse分页参数
current/pageSize需由调用方显式传入;排序优先从请求order参数读取,未传或为空时使用$this->order属性(默认['create_time' => 'desc'])。子类可覆盖该属性自定义默认排序。
自定义新建/更新
class UserController extends ResourceController
{
protected string $modelClass = User::class;
// 请求体即为落库数据时,直接复用 baseSave / baseUpdate
public function save(Request $request): JsonResponse
{
return $this->baseSave($request);
}
public function update(Request $request, int $id): JsonResponse
{
return $this->baseUpdate($request, $id);
}
// 需要补充字段(如 created_by)时,直接操作模型
public function saveWithAudit(Request $request): JsonResponse
{
$data = $request->post();
$data['created_by'] = request()->loginId();
$model = $this->getModelClass();
return json(['errno' => 0, 'code' => 200, 'msg' => 'success', 'data' => $model->create($data)]);
}
}直接重写(不复用 base 方法)
class UserController extends ResourceController
{
protected string $modelClass = User::class;
public function read(int $id): JsonResponse
{
$user = $this->getModel()->with('profile')->find($id);
if (!$user) {
return json(['errno' => 0, 'code' => 400, 'msg' => $this->notExistMessage, 'data' => null]);
}
return json(['errno' => 0, 'code' => 200, 'msg' => 'success', 'data' => $user]);
}
}基础控制器 Controller
继承 Lychee\http\Controller 可获得请求注入与 initialize() 生命周期钩子, 适用于不需要 CRUD、但希望使用 $this->request 和 $this->paginate() 的场景。 普通 JSON 响应直接使用全局助手 json()。
namespace App\controller;
use Lychee\http\Controller;
use Lychee\http\JsonResponse;
use Lychee\routing\Route;
#[Route('/upload')]
class UploadController extends Controller
{
public function index(): JsonResponse
{
$file = $this->request->file('file');
return json(['data' => ['name' => $file?->getOriginalName()]]);
}
}提供的能力
| 成员 | 说明 |
|---|---|
$this->app | 容器实例 |
$this->request | 请求实例 |
initialize() | 初始化钩子,在中间件之后、方法调用前执行 |
json($data, $status, $headers) | 全局助手,组装任意结构的 JSON 响应 |
$this->paginate($items, $page, $pageSize, $message, $httpStatus) | 分页 JSON 响应 |
$this->validateRequest($rules, $message) | 一行完成取参 + 校验(支持规则数组或验证器类名/场景),失败抛 ValidateException(HTTP 400) |
Lychee\routing\ResourceController继承自此基类,因此资源控制器同样拥有以上能力。
分页响应 paginate()
paginate() 在传入查询构建器时自动完成总数统计与分页查询,也可以直接传入一个数组作为当页数据。
方法签名:
protected function paginate(
mixed $items = [], // Query、Model 或当页数据数组
?int $page = null, // 当前页码;null 时自动从 Request 读取(?page= / ?current=)
?int $pageSize = null, // 每页条数;null 时自动从 Request 读取(?pageSize= / ?limit=),限制在 1~200
string $message = 'success',
int $httpStatus = 200,
): JsonResponse$page / $pageSize 传 null(默认值)时会自动从当前 Request 读取,便于一行完成分页。 手动传值时则以传入值为准。
传入 Query / Model 时,框架会先执行 count() 统计总数,再查询当前页数据; 传入数组时数组原样作为当页数据,total 为 0。非数组(如 null、字符串)会被归一为空数组。
use App\model\Order;
use Lychee\orm\facade\Db;
class OrderController extends Controller
{
// 最简写法:页码 / 每页条数自动从 ?page= &pageSize= 读取
public function list(): JsonResponse
{
return $this->paginate(Db::table('orders')->where('status', 1));
}
// 显式指定分页参数
public function vipList(): JsonResponse
{
return $this->paginate(
Order::where('is_vip', 1),
page: 1,
pageSize: 20,
message: 'ok',
httpStatus: 200,
);
}
// 传入 Model 实例:框架自动调用其 db() 获取查询构建器(不带额外条件)
public function allList(): JsonResponse
{
return $this->paginate(new Order(), 1, 20);
}
// 传入数组:数据来自外部接口或自行组装时,直接作为当页数据
public function remoteList(): JsonResponse
{
$items = $this->fetchFromRemote();
return $this->paginate($items);
}
}响应结构(分页数据在 data.list,总数在 data.total):
{
"errno": 0,
"code": 200,
"msg": "success",
"data": {
"list": [{ "id": 3 }, { "id": 4 }],
"total": 5
}
}与
ResourceController::paginate()的区别:基础控制器版本接受任意Query/Model/ 数组, 自行通过count()+page()完成分页;资源控制器的重写版本只接受Query/Model, 内部使用 Lychee ORM 分页器。两者响应结构一致。
取参 + 校验一体化 validateRequest()
validateRequest() 把"从 Request 取参"和"校验"合并为一步,校验失败自动抛 ValidateException (由异常处理器转为 HTTP 400),通过则直接返回干净的参数数组。
public function save(): JsonResponse
{
$data = $this->validateRequest([
'name' => 'require|max:25',
'email' => 'require|email',
], [
'name.require' => '用户名必填',
]);
// $data 已是通过校验的请求参数
$user = User::create($data);
return json(['data' => $user]);
}除了规则数组,validateRequest() 也支持传入验证器类名(短名自动解析为 app\validate\类名)及场景语法:
// 使用验证器类 app\validate\User
$data = $this->validateRequest('User');
// 使用验证器类的 edit 场景
$data = $this->validateRequest('User.edit');
// 完全限定类名
$data = $this->validateRequest(\app\validate\User::class);资源控制器
ResourceController另提供$this->validate($data, $validate)方法, 支持传入验证器类名 / 场景语法 / 规则数组,适合需要复用验证器类的场景。
不继承基类的控制器
如果控制器不需要 CRUD,也不需要基类提供的便捷能力,可以不继承任何基类,直接使用全局助手返回响应:
namespace App\controller;
use Lychee\http\JsonResponse;
use Lychee\http\Request;
class HomeController
{
public function index(): JsonResponse
{
return json(['message' => 'hello']);
}
public function upload(Request $request): JsonResponse
{
$file = $request->file('file');
// ... 处理上传
return json(['url' => $url]);
}
}内核通过
method_exists检测initialize()方法,不继承基类的控制器同样可以定义initialize()钩子。
数据权限 HasDataPermission
Lychee\routing\HasDataPermission 是一个可选 Trait,为资源控制器提供数据范围过滤。 use 后会覆盖 applyDataPermission(),按当前登录用户的数据权限过滤查询。
支持四种权限级别:
| 级别 | 说明 |
|---|---|
all | 查看所有数据(不过滤) |
dept_tree | 本部门及所有下级部门的数据 |
dept | 仅本部门的数据 |
self | 仅自己的数据(默认) |
namespace App\controller;
use App\model\Order;
use Lychee\routing\HasDataPermission;
use Lychee\routing\Resource;
use Lychee\routing\ResourceController;
#[Resource('/orders')]
class OrderController extends ResourceController
{
use HasDataPermission;
protected string $modelClass = Order::class;
protected array $dataPermission = [
'enabled' => true,
'userIdField' => 'user_id',
'deptIdField' => 'dept_id', // dept / dept_tree 级别使用
'module' => 'order',
];
}配置项
| 配置 | 默认值 | 说明 |
|---|---|---|
enabled | false | 是否开启数据权限控制 |
userIdField | 'user_id' | 数据所有者字段名(self 级别使用) |
deptIdField | 'dept_id' | 数据所属部门字段名(dept / dept_tree 级别使用) |
module | '' | 当前模块标识,用于匹配权限级别 |
工作原理
- 从
request()->loginId()获取当前登录用户 ID(由认证中间件挂载) - 调用
isAdmin($userId)判断是否管理员(默认返回false) - 调用
getPermissionLevel($userId, $module)获取权限级别(默认返回'self') - 按级别应用过滤:
all:不附加过滤dept_tree:附加whereIn(dept_id, 本部门及所有下级部门ID)dept:附加where(dept_id, 本部门ID)self:附加where(user_id, 当前用户ID)
dept/dept_tree级别下,若用户无部门归属(getUserDeptId返回null),自动回退到self级别。
部门解析器 DeptResolverInterface
使用 dept / dept_tree 级别时,需在容器中绑定 DeptResolverInterface 实现:
namespace App\auth;
use Lychee\routing\DeptResolverInterface;
class DbDeptResolver implements DeptResolverInterface
{
public function getUserDeptId(int $userId): ?int
{
return User::find($userId)?->dept_id;
}
public function getSubDeptIds(int $deptId): array
{
// 递归查询部门及所有子部门 ID(建议缓存结果)
$ids = [$deptId];
$children = Dept::where('parent_id', $deptId)->column('id');
foreach ($children as $childId) {
$ids = array_merge($ids, $this->getSubDeptIds($childId));
}
return $ids;
}
}在 ServiceProvider 或启动文件中绑定:
$container->bind(
\Lychee\routing\DeptResolverInterface::class,
\App\auth\DbDeptResolver::class,
);未绑定实现时使用
dept/dept_tree级别会抛出RuntimeException, 仅使用all/self级别时无需绑定。
覆盖管理员与权限逻辑
Trait 默认采用最保守策略。实际项目中需覆盖 isAdmin() 和 getPermissionLevel():
class OrderController extends ResourceController
{
use HasDataPermission;
protected array $dataPermission = [
'enabled' => true,
'userIdField' => 'user_id',
'deptIdField' => 'dept_id',
'module' => 'order',
];
protected function isAdmin(int $userId): bool
{
return User::find($userId)?->is_admin ?? false;
}
protected function getPermissionLevel(int $userId, string $module): string
{
$role = User::find($userId)?->role;
if (!$role || empty($role->data_scope)) {
return 'self';
}
$dataScope = is_array($role->data_scope)
? $role->data_scope
: json_decode($role->data_scope, true);
// 返回 'all' | 'dept_tree' | 'dept' | 'self'
return $dataScope[$module] ?? 'self';
}
}单条数据权限检查
canAccessData() 用于在非查询场景(如更新、删除前)判断当前用户是否有权操作某条数据:
// self 级别:只需传 ownerId
if (!$this->canAccessData($order->user_id)) {
return json(['code' => 403, 'msg' => '无权操作'], 403);
}
// dept / dept_tree 级别:传 ownerId + deptId
if (!$this->canAccessData($order->user_id, $order->dept_id)) {
return json(['code' => 403, 'msg' => '无权操作'], 403);
}多租户 HasTenant
Lychee\routing\HasTenant 是一个可选 Trait,基于表字段 tenant_id 实现共享数据库 + 共享表的多租户隔离。 use 后会覆盖 applyTenantScope() 与 fillTenantId(),自动在查询时过滤当前租户数据、在新建/更新时写入 tenant_id。
namespace App\controller;
use App\model\Order;
use Lychee\routing\HasTenant;
use Lychee\routing\Resource;
use Lychee\routing\ResourceController;
#[Resource('/orders')]
class OrderController extends ResourceController
{
use HasTenant;
protected string $modelClass = Order::class;
protected array $tenantConfig = [
'enabled' => true,
'tenantIdField' => 'tenant_id',
'autoFill' => true,
'bypassForAdmin' => true,
];
protected function getTenantId(): ?int
{
// 从当前登录用户获取租户 ID
return User::find(request()->loginId())?->tenant_id;
}
}工作原理
- 查询隔离:
baseIndex/baseRead/baseUpdate/baseDelete/baseBatchDelete中的查询会自动附加where(tenant_id, 当前租户ID) - 写入填充:
baseSave/baseUpdate会自动在数据中填入tenant_id(若未显式指定) - 从
getTenantId()获取当前租户 ID(需子类覆盖) - 平台管理员可通过
isAdmin()判断跳过隔离(由bypassForAdmin控制)
配置项
| 配置 | 默认值 | 说明 |
|---|---|---|
enabled | true | 是否开启租户隔离 |
tenantIdField | 'tenant_id' | 租户 ID 字段名 |
autoFill | true | 新建/更新时是否自动填充 tenant_id |
bypassForAdmin | true | 平台管理员是否跳过租户隔离 |
覆盖管理员逻辑
isAdmin(int $userId) 默认返回 false,实际项目中需覆盖:
class OrderController extends ResourceController
{
use HasTenant;
protected function getTenantId(): ?int
{
return User::find(request()->loginId())?->tenant_id;
}
protected function isAdmin(int $userId): bool
{
return User::find($userId)?->is_platform_admin ?? false;
}
}单条数据归属检查
isTenantData($dataTenantId) 用于判断某条数据是否属于当前租户:
if (!$this->isTenantData($order->tenant_id)) {
return json(['code' => 403, 'msg' => '无权操作'], 403);
}与 HasDataPermission 同时使用
HasTenant 与 HasDataPermission 可以同时 use,两者分别覆盖不同的钩子,互不冲突。
但两者都定义了 isAdmin(int $userId): bool 方法,同时使用时需在控制器类中覆盖 isAdmin() 以解决 trait 方法冲突(类方法优先于 trait 方法):
class OrderController extends ResourceController
{
use HasDataPermission, HasTenant;
protected string $modelClass = Order::class;
protected function getTenantId(): ?int
{
return User::find(request()->loginId())?->tenant_id;
}
// 覆盖 isAdmin,同时满足两个 trait 的需求
protected function isAdmin(int $userId): bool
{
return User::find($userId)?->is_platform_admin ?? false;
}
}