Skip to content

控制器 Controller ​

控制器负责接收请求、调用业务逻辑、返回响应。框架不强制控制器继承基类, 开发者可按需选择:

  • 需要 CRUD 的控制器:继承 Lychee\routing\ResourceController,获得请求注入、验证快捷方式和零代码 CRUD 能力
  • 不需要 CRUD 的控制器:继承 Lychee\http\Controller,获得请求注入、initialize() 生命周期与 paginate() 分页方法
  • 返回 JSON:使用全局助手 json() 自行组装响应数据结构,框架不强制固定格式

资源控制器 ResourceController ​

继承 Lychee\routing\ResourceController 可实现零代码 CRUD,同时获得请求注入、initialize() 生命周期钩子、分页响应与验证快捷方式。 配合 #[Resource] 路由注解使用,子类只需声明模型与验证器。

php
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批量删除

依赖注入 ​

基类构造函数已注入容器与请求,子类无需重写构造函数。若需要其他依赖,可通过方法参数注入:

php
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 请求体参数。

php
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()。

php
class UserController extends ResourceController
{
    protected array $config = [];

    protected function initialize(): void
    {
        $this->config = config('user', []);
    }
}

由于 initialize() 在中间件之后执行,认证、i18n、session 等中间件对请求的修改在此均已生效。

JSON 响应 ​

框架不强制响应格式,也不提供 success() / fail() 之类的固定助手, 统一使用全局助手 json() 自行组装数据结构:

php
// 成功响应(结构自行约定)
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:

json
{
  "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、下载或重定向, 直接返回对应的响应对象,内核会原样输出:

php
// HTML 视图
return view('user/index', ['list' => $list]);

// 文件下载
return download('report.pdf');

// 重定向
return redirect('/login');

全局助手函数 ​

未继承控制器基类或处于非控制器场景(如中间件)时,同样使用全局助手 json():

php
return json(['errno' => 0, 'code' => 200, 'msg' => 'success', 'data' => $data]);
return json(['errno' => 0, 'code' => 400, 'msg' => '错误', 'data' => null], 400);

验证快捷方式 ​

基类的 validate() 方法封装了验证器调用,失败时抛出 ValidateException。

php
// 传入验证器类名
$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() 会自动检查唯一性,支持联合唯一:

php
class UserController extends ResourceController
{
    protected string $modelClass = User::class;

    // 邮箱唯一
    protected array $uniqueFields = ['email'];

    // 联合唯一:同一租户下用户名唯一
    // protected array $uniqueFields = ['tenant_id', 'username'];
}

列表搜索与排序(零代码配置) ​

继承 ResourceController 后,index() 和 read() 无需重写即可自动支持搜索、排序、关联预加载与访问器追加,通过属性配置:

php
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&current=1&pageSize=10

自定义方法与 base* 复用 ​

需要自定义逻辑时,可覆盖对应方法。框架提供了 baseIndex / baseSave / baseRead / baseUpdate / baseDelete / baseBatchDelete 六个 protected 方法, 覆盖后仍可调用它们复用分页、排序、验证、数据权限等逻辑。

自定义查询条件(index) ​

php
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() 签名:

php
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'])。子类可覆盖该属性自定义默认排序。

自定义新建/更新 ​

php
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 方法) ​

php
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()。

php
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() 在传入查询构建器时自动完成总数统计与分页查询,也可以直接传入一个数组作为当页数据。

方法签名:

php
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、字符串)会被归一为空数组。

php
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):

json
{
  "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),通过则直接返回干净的参数数组。

php
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\类名)及场景语法:

php
// 使用验证器类 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,也不需要基类提供的便捷能力,可以不继承任何基类,直接使用全局助手返回响应:

php
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仅自己的数据(默认)
php
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',
    ];
}

配置项 ​

配置默认值说明
enabledfalse是否开启数据权限控制
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 实现:

php
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 或启动文件中绑定:

php
$container->bind(
    \Lychee\routing\DeptResolverInterface::class,
    \App\auth\DbDeptResolver::class,
);

未绑定实现时使用 dept / dept_tree 级别会抛出 RuntimeException, 仅使用 all / self 级别时无需绑定。

覆盖管理员与权限逻辑 ​

Trait 默认采用最保守策略。实际项目中需覆盖 isAdmin() 和 getPermissionLevel():

php
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() 用于在非查询场景(如更新、删除前)判断当前用户是否有权操作某条数据:

php
// 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。

php
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 控制)

配置项 ​

配置默认值说明
enabledtrue是否开启租户隔离
tenantIdField'tenant_id'租户 ID 字段名
autoFilltrue新建/更新时是否自动填充 tenant_id
bypassForAdmintrue平台管理员是否跳过租户隔离

覆盖管理员逻辑 ​

isAdmin(int $userId) 默认返回 false,实际项目中需覆盖:

php
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) 用于判断某条数据是否属于当前租户:

php
if (!$this->isTenantData($order->tenant_id)) {
    return json(['code' => 403, 'msg' => '无权操作'], 403);
}

与 HasDataPermission 同时使用 ​

HasTenant 与 HasDataPermission 可以同时 use,两者分别覆盖不同的钩子,互不冲突。

但两者都定义了 isAdmin(int $userId): bool 方法,同时使用时需在控制器类中覆盖 isAdmin() 以解决 trait 方法冲突(类方法优先于 trait 方法):

php
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;
    }
}

Released under the MIT License.