用Laravel做权限系统,绝大多数人上来就找现成的扩展包,Spatie的laravel-permission几乎成了标配。但真正上线跑过几个项目之后你会发现,直接用扩展包只能解决60%的问题,剩下的40%才是真正考验功力的地方——多层级组织架构怎么处理、数据权限怎么和功能权限联动、API接口怎么做到毫秒级的鉴权响应。这些问题不解决,权限系统就是个花架子。
我经历过从零搭建到重构的完整周期,踩过的坑包括权限缓存击穿、递归查组织导致接口超时、中间件执行顺序混乱导致越权。下面直接讲具体怎么搭建一个能扛住真实业务压力的权限系统。
权限模型选型:别一上来就RBACRBAC是基础,但真实业务里角色往往是动态的。一个运营主管今天管华东区,明天可能兼管华南区,你不可能给他创建两个角色。更合理的做法是采用RBAC+ABAC混合模型:角色控制功能权限,属性控制数据范围。
具体设计上,用户表关联角色,角色关联权限,这是功能权限层。数据权限层则需要引入“数据域”的概念,比如部门、区域、项目组。用户和这些数据域之间是多对多关系,通过中间表存储。这样当用户同时属于华东区和华南区时,数据权限自动合并,不需要改角色配置。
数据库表结构设计核心表有六张:users、roles、permissions、role_permission、user_role、user_data_scope。很多人会漏掉user_data_scope这张表,导致数据权限没法灵活配置。
-- 权限表
CREATE TABLE permissions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL COMMENT '权限标识,如user.create',
display_name VARCHAR(100) NOT NULL COMMENT '显示名称',
group_name VARCHAR(50) NOT NULL COMMENT '权限分组,如用户管理',
type TINYINT NOT NULL DEFAULT 1 COMMENT '1功能权限 2数据权限',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_name (name)
);
-- 数据域表
CREATE TABLE data_scopes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL COMMENT '数据域名称',
type VARCHAR(30) NOT NULL COMMENT '类型:dept/region/project',
parent_id BIGINT UNSIGNED DEFAULT 0 COMMENT '父级ID,支持树形结构',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 用户数据域关联表
CREATE TABLE user_data_scope (
user_id BIGINT UNSIGNED NOT NULL,
scope_type VARCHAR(30) NOT NULL,
scope_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (user_id, scope_type, scope_id)
);
permissions表的type字段是关键设计,把功能权限和数据权限统一管理,后续做权限判断时逻辑更清晰。data_scopes支持树形结构,parent_id字段让数据权限可以按层级继承,比如拥有父部门的权限自动拥有子部门的数据访问权。
中间件层的精准控制Laravel中间件是权限校验的第一道关卡。不要在控制器里散落权限判断代码,后期维护成本极高。建议设计三层中间件:
第一层是认证中间件,检查token有效性,把用户信息注入上下文。第二层是功能权限中间件,匹配路由和权限标识。第三层是数据权限中间件,根据请求参数注入数据过滤条件。
// 功能权限中间件核心逻辑
public function handle($request, Closure $next, $permission)
{
$user = $request->user();
// 从缓存读取用户所有权限,避免每次请求查数据库
$permissions = Cache::remember(
'user_permissions_' . $user->id,
3600,
fn() => $user->getAllPermissions()->pluck('name')->toArray()
);
if (!in_array($permission, $permissions)) {
if ($request->expectsJson()) {
return response()->json(['message' => '无操作权限'], 403);
}
abort(403);
}
return $next($request);
}
// 数据权限中间件核心逻辑
public function handle($request, Closure $next)
{
$user = $request->user();
// 获取用户的数据域范围
$dataScopes = Cache::remember(
'user_data_scopes_' . $user->id,
3600,
fn() => $user->getDataScopes()
);
// 注入到请求上下文中,后续查询时自动过滤
$request->merge(['_data_scopes' => $dataScopes]);
return $next($request);
}
这里有个容易被忽视的细节:权限数据必须做缓存。一个中型系统每天权限校验可能上百万次,每次都查数据库的话,光权限校验就能吃掉大量数据库连接。用Laravel的Cache门面配合remember方法,设置合理的过期时间,权限变更时主动清除缓存即可。
数据权限的查询过滤方案数据权限最难的地方在于,它要侵入到业务查询中。一个常见的错误做法是在每个控制器方法里手动拼接where条件,代码又臭又长。正确的做法是用Laravel的全局作用域或Trait来统一处理。
// 数据权限Trait
trait DataScopeFilter
{
public function scopeApplyDataScope($query, $dataScopes, $scopeField = 'dept_id')
{
if (empty($dataScopes)) {
return $query;
}
return $query->where(function ($q) use ($dataScopes, $scopeField) {
foreach ($dataScopes as $scope) {
if ($scope['type'] === 'dept') {
// 包含子部门的数据
$childIds = self::getChildScopeIds($scope['id']);
$q->orWhereIn($scopeField, $childIds);
}
}
});
}
private static function getChildScopeIds($parentId)
{
// 递归获取所有子节点ID,结果也做缓存
return Cache::remember('scope_children_' . $parentId, 3600, function () use ($parentId) {
$ids = [$parentId];
$children = DataScope::where('parent_id', $parentId)->pluck('id')->toArray();
foreach ($children as $childId) {
$ids = array_merge($ids, self::getChildScopeIds($childId));
}
return $ids;
});
}
}
// 在模型中使用
class Order extends Model
{
use DataScopeFilter;
}
// 控制器中查询时
$orders = Order::applyDataScope($request->input('_data_scopes'), 'department_id')
->where('status', 'paid')
->paginate(20);
这个方案的精髓在于,数据权限的过滤逻辑完全封装在Trait里,业务代码只需要调用applyDataScope方法,传入中间件注入的数据域范围即可。子部门的数据继承通过递归查询实现,结果同样做了缓存处理。
权限变更后的缓存清理策略权限系统的缓存一致性是个大问题。管理员修改了某个角色的权限,所有拥有该角色的用户权限应该立即生效。这里不能简单地设置短过期时间,因为可能造成短时间内权限不一致。
推荐的做法是用Laravel的事件系统,在权限变更时触发事件,监听器负责清理相关用户的权限缓存。
// 权限变更事件
class PermissionChanged
{
public $affectedUserIds;
public function __construct($affectedUserIds)
{
$this->affectedUserIds = $affectedUserIds;
}
}
// 在角色权限更新时触发
public function updateRolePermissions($roleId, $permissionIds)
{
DB::transaction(function () use ($roleId, $permissionIds) {
// 更新角色权限关联
RolePermission::where('role_id', $roleId)->delete();
$data = array_map(fn($pid) => ['role_id' => $roleId, 'permission_id' => $pid], $permissionIds);
RolePermission::insert($data);
// 找出所有拥有该角色的用户
$userIds = UserRole::where('role_id', $roleId)->pluck('user_id')->toArray();
// 清除这些用户的权限缓存
foreach ($userIds as $userId) {
Cache::forget('user_permissions_' . $userId);
Cache::forget('user_data_scopes_' . $userId);
}
// 触发事件,通知其他需要响应的模块
event(new PermissionChanged($userIds));
});
}
用事务保证数据库操作和缓存清理的原子性,虽然缓存清理本身不支持回滚,但至少数据库层面的变更是可靠的。如果缓存清理失败,用户最多在缓存过期前看到旧权限,不会出现数据库和缓存都不一致的情况。
API接口的权限校验优化对于前后端分离的项目,API接口的权限校验要特别注意性能。每个请求都走一遍完整的RBAC判断链路,响应时间会增加几十毫秒。优化的思路是把权限判断前置到路由层面。
在RouteServiceProvider中,可以定义路由命名规范,让路由名称和权限标识自动映射。比如路由user.store自动对应权限user.create,路由user.index对应权限user.list。
// 路由定义
Route::middleware(['auth:api', 'permission:user.create'])->group(function () {
Route::post('/users', [UserController::class, 'store']);
});
// 或者更优雅的方式,自定义路由注册方法
class PermissionRouteRegistrar
{
public static function register($permission, Closure $callback)
{
Route::middleware(['auth:api', 'permission:' . $permission])->group($callback);
}
}
// 使用时
PermissionRouteRegistrar::register('user.manage', function () {
Route::get('/users', [UserController::class, 'index']);
Route::post('/users', [UserController::class, 'store']);
Route::put('/users/{id}', [UserController::class, 'update']);
Route::delete('/users/{id}', [UserController::class, 'destroy']);
});
这样做的好处是,权限校验在进入控制器之前就完成了,不合规的请求直接被拦截,减少了控制器层的代码量和执行开销。
前端权限控制的配合方案后端权限做得再好,前端不做配合也会出问题。按钮显示隐藏、菜单动态生成、路由守卫这些都要和后端权限体系打通。
登录成功后,后端返回用户的所有权限标识列表和菜单树。前端存储到状态管理中,封装一个权限判断指令或方法。
// 前端权限判断方法示例
function hasPermission(permission) {
const permissions = store.state.user.permissions;
// 支持通配符,如 user.* 匹配所有用户相关权限
if (permission.includes('*')) {
const regex = new RegExp('^' + permission.replace('*', '.*') + '$');
return permissions.some(p => regex.test(p));
}
return permissions.includes(permission);
}
// 组件中使用
菜单的生成也依赖权限列表,后端返回的菜单数据中每个菜单项都带有permission字段,前端过滤掉用户没有权限的菜单项,从源头控制可见性。
多租户场景下的权限隔离如果你的系统涉及多租户,权限系统还要加一层租户隔离。每个租户有自己的角色和权限体系,用户在不同租户下拥有不同的权限。这需要在所有权限相关的表中加入tenant_id字段。
中间件在解析用户时,同时确定当前租户上下文。权限缓存的key也要带上租户ID,避免不同租户的权限数据串了。
// 多租户权限缓存key
$cacheKey = 'tenant_' . $tenantId . '_user_permissions_' . $userId;
// 中间件中确定租户上下文
public function handle($request, Closure $next)
{
$tenantId = $request->header('X-Tenant-Id')
?? $request->user()->default_tenant_id;
app()->instance('current_tenant_id', $tenantId);
return $next($request);
}
全局的current_tenant_id可以在任何需要的地方通过app('current_tenant_id')获取,数据查询时自动加上租户条件,实现租户间的数据完全隔离。
日志与审计追踪权限系统必须有完整的操作日志,尤其是敏感操作。Laravel的Event系统很适合做这件事。定义敏感操作事件,监听器负责记录操作人、操作时间、操作内容、IP地址等信息。
// 敏感操作日志记录
class LogSensitiveAction
{
public function handle($event)
{
AuditLog::create([
'user_id' => auth()->id(),
'action' => $event->action,
'target_type' => $event->targetType,
'target_id' => $event->targetId,
'details' => json_encode($event->details),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
}
}
审计日志表建议按月分表或者定期归档,这类数据增长很快,单表数据量过大会影响查询性能。
整个权限系统的搭建,核心思路就一条:功能权限靠角色,数据权限靠数据域,性能靠缓存,一致性靠事件。把这四条线理清楚,代码写起来就有章法了。别一上来就引入复杂的扩展包,先理解清楚业务需要什么样的权限模型,再决定技术方案。很多时候,简单的自研方案比通用扩展包更适合自己的业务场景。
