各位来宾,各位致力于让代码“活”过来、让生活“快”起来的 PHP 程序员们,大家好!
我是你们的老朋友,也是 MyHome365 的首席架构师。今天,我不打算给大家讲什么高深的微服务架构,也不打算扯什么云原生、K8s、Docker 的那些听起来很美、用起来很痛的词儿。
今天,我们要聊点接地气的。我们要聊的是“物理隔离”。
为什么是物理隔离?因为我们的系统(MyHome365)要处理三类人:想偷懒的房东、想赖账的租客,以及想活得长久的我们(管理员)。如果把这三类人混在一个“大杂烩”的数据库和代码库里,那场面,啧啧,比早高峰的地铁还拥挤。
今天这场讲座的主题是:《MyHome365 的架构进化:基于 PHP 实现多角色权限的物理隔离》。
准备好了吗?让我们把时光倒流,看看我们是如何从一个“上帝模式”的混乱系统,进化到一个拥有“金钟罩铁布衫”的多角色系统的。
第一章:那是一个没有“门”的年代
想当年,MyHome365 还是个雏儿。那时候,我们只有一个管理员账号,密码是 123456。那个数据库里,既有王大爷的房租记录,也有小李的退租申请,甚至还有隔壁老王想卖房的委托信息。
那时候我们的 PHP 代码长什么样?简单,粗暴,充满了“爱”的味道。
那段令人怀念的(也是让人想吐血的)代码:
// 旧时代的 Controller.php
public function getLandlordList() {
// 逻辑:直接从数据库捞所有房东
$landlords = DB::table('users')->where('role', 'landlord')->get();
return view('list', ['data' => $landlords]);
}
public function getTenantInfo($id) {
// 逻辑:租客看房,不需要查角色,直接看
$tenant = DB::table('tenants')->where('id', $id)->first();
return view('detail', ['tenant' => $tenant]);
}
看懂了吗?这就是传说中的“上帝视角”。在这个架构下,一个租客只要稍微懂点 SQL 注入,或者仅仅是在浏览器的 URL 栏里把 /tenant/1 改成 /landlord/100,好家伙,他不仅能看房,还能看到所有房东的银行卡号、私人电话,甚至能修改全系统的配置!
那时候我们常说一句话:“代码无 Bug,只有逻辑不够变态。”
显然,我们的逻辑还不够变态。这种架构的崩溃是必然的。一旦用户量上来,或者是哪天老板的朋友当了房东,我们的系统就得崩。于是,我们痛定思痛,决定搞“物理隔离”。
第二章:物理隔离,听起来很硬核
什么叫“物理隔离”?
在建筑学里,物理隔离是两栋楼之间建个防火墙。在我们的 PHP 世界里,物理隔离就是:代码逻辑上、数据库访问上、甚至内存缓存里,严格禁止角色 A 去触碰角色 B 的数据。
哪怕他们用的是同一台服务器,同一个 PHP 进程,甚至在同一个 Controller 里。
我们的目标很明确:
- 房东只能看自己名下的房源、缴费记录。
- 租客只能看自己租的房子、缴费记录。
- 管理员能看全局,但必须经过严格的授权。
为了实现这个目标,我们不能再用简单的 if (role == 'admin') 来判断了。那太脆弱了,就像是用纸糊的墙。
我们开始引入 中间件 和 作用域 的概念。
第三章:构建“狱警”——中间件的艺术
首先,我们要在 PHP 的入口处,也就是 Web 请求进来的那一瞬间,就锁好门。
在 Laravel 或者 ThinkPHP 这类框架中,中间件就是那个看门大爷。我们设计了一个 RoleGuardMiddleware。它的任务很简单:在用户点开链接之前,先问它一句:你真的是你自称的那个人吗?
代码示例:核心中间件逻辑
// app/Http/Middleware/RoleGuard.php
class RoleGuardMiddleware
{
public function handle($request, Closure $next)
{
// 1. 获取当前登录用户(假设有个 Auth facade)
$user = Auth::user();
// 2. 如果没登录,滚蛋
if (!$user) {
return response()->json(['error' => 'Unauthorized'], 401);
}
// 3. 将用户角色注入到 Request 对象中
// 这样,后续所有的 Controller 都能通过 $request->get('role') 获取到身份
$request->merge(['current_role' => $user->role]);
// 4. 关键的一步:全局物理隔离校验
// 假设路由定义了需要“房东权限”,但用户是“租客”
if (!$this->checkPermission($request->route(), $user->role)) {
return response()->json(['error' => 'Permission Denied. You are not a landlord.'], 403);
}
return $next($request);
}
private function checkPermission($route, $userRole)
{
// 这里我们定义一个路由注解或者配置,说明哪些路由属于哪个角色
// 比如 'landlord.dashboard' 只有 landlord 能进
$allowedRoles = $route->middleware; // 假设我们在路由里加了 middleware('role:landlord')
return in_array("role:{$userRole}", $allowedRoles);
}
}
看到这里,你可能觉得:“嘿,这不就是权限控制吗?”
不,同志们,这不仅仅是控制。这是物理隔离的第一层:路由层面的隔离。当一个 PHP 请求进来,如果路由定义是 /api/landlord/properties,而中间件发现你是一个租客,它会在 PHP 执行业务逻辑之前,直接把你扔进 403 Forbidden 的坑里。它根本不会让你的 Controller 代码运行起来,更别提让你读到数据了。
第四章:构建“沙盒”——数据库的物理隔离
光有路由拦截还不够。万一我们那个不负责任的 PHP 程序员在代码里直接写了 SELECT * FROM users 怎么办?万一我们在写报表的时候忘了加条件怎么办?
为了防止这种情况,我们在 Model 层(模型层)实施了强制性的“物理隔离”。
我们使用了一个 Trait,叫 TenantScope。这东西就像是一个 GPS 追踪器,只要你试图查询数据库,它就会自动在你的 SQL 语句后面加上一段不可违抗的代码。
代码示例:Model 作用域
// app/Models/BaseModel.php
trait TenantScope
{
protected static function bootTenantScope()
{
static::addGlobalScope('tenant_guard', function ($builder) {
// 获取当前请求上下文中的租户 ID
// 注意:这里必须是全局唯一的标识,可能是 user_id,也可能是 company_id
$tenantId = request()->get('current_tenant_id');
// 如果是管理员,允许全局查询(管理员是豁免的)
if (auth()->user()->is_admin) {
return;
}
// 物理隔离的核心:强制拼接 WHERE 条件
// 即使是 $builder->get(),也会变成 SELECT * FROM table WHERE tenant_id = ?
$builder->where('tenant_id', $tenantId);
});
}
}
// app/Models/Property.php (房产模型)
class Property extends Model
{
use TenantScope; // 引入隔离 Trait
// ... 其他代码
}
这段代码有什么魔力?
让我们看看实际运行的 SQL。
如果我们的代码是这样写的:
$properties = Property::all(); // 程序员以为这是获取所有房产
实际执行的 SQL 是:
SELECT * FROM properties WHERE tenant_id = 101;
-- 注意,即使没有写 where,系统也帮你加了!
如果你是一个租客,你的 tenant_id 是 202。而你作为一个狡猾的程序员,试图在代码里硬改 ID 去查房东的数据,例如:
// app/Models/Property.php
class Property extends Model {
public static function findForce($id) {
// 想要强行绕过?
return self::where('id', $id)->first();
}
}
依然会失败,因为 Trait 里的 bootTenantScope 是全局注册的,它不管你 where 里写了什么,它直接在前面追加了条件。这就像是你想去隔壁家偷看,结果大门被焊死了。
第五章:房东、租客与 admin 的差异化体验
物理隔离不仅仅是数据的隔离,更是业务逻辑的隔离。不同角色的用户,看到的界面和功能是完全不同的。
我们要实现一种“伪装”机制。比如,系统里有一个“支付”功能。如果是房东,他点的是“我要收租”;如果是租客,他点的是“我要缴费”。
我们在路由定义上做文章。利用 PHP 的闭包函数和作用域。
代码示例:路由定义与闭包逻辑
// routes/web.php
// 场景一:房东收租
Route::group(['middleware' => ['auth', 'role:landlord']], function () {
Route::get('/landlord/dashboard', function () {
// 这里逻辑保证只有 landlord 能进
$myProperties = Property::where('status', 'rented')->get();
return view('landlord.dashboard', compact('myProperties'));
});
Route::post('/landlord/collect-rent', 'PaymentController@collect');
});
// 场景二:租客缴费
Route::group(['middleware' => ['auth', 'role:tenant']], function () {
Route::get('/tenant/my-house', function () {
// 这里逻辑保证只有 tenant 能进
$myProperty = Property::where('tenant_id', auth()->id())->first();
return view('tenant.dashboard', compact('myProperty'));
});
Route::post('/tenant/pay-bill', 'PaymentController@pay');
});
// 场景三:管理员全览
Route::group(['middleware' => ['auth', 'role:admin']], function () {
Route::get('/admin/users', 'AdminController@index');
});
看,通过 role:landlord 这样的中间件标签,我们物理上切断了租客访问房东路由的路径。
第六章:Redis 缓存中的隔离
PHP 是一个短生命周期的语言。请求一来,脚本运行,请求走,内存释放。所以,我们很多数据存在 Redis 里。
如果你把所有用户的 Session Key 都命名为 session:user_id,那没问题。但如果我们引入了更高级的缓存机制,比如 MyHome365:Properties:{id}。
假设我们的缓存键没有带上 tenant_id 前缀:
- 房东 1 缓存了一个房源数据。
- 租客 2 请求这个房源数据。
如果缓存没有隔离,租客 2 可能会直接读到房东 1 的数据!
物理隔离的进阶版:
// 缓存服务类
class CacheService
{
public function getProperty($id)
{
$userRole = auth()->user()->role;
$tenantId = auth()->user()->tenant_id;
// 构建物理隔离的 Key
// Key 格式:tenant_id_role_property_id
$cacheKey = "mh365:{$tenantId}:{$userRole}:property_{$id}";
$data = Cache::get($cacheKey);
if (!$data) {
// 去数据库捞
$data = Property::find($id);
// 存进去
Cache::put($cacheKey, $data, 60);
}
return $data;
}
}
注意那个 Key 的结构:mh365:{$tenantId}:{$userRole}:...。
这就做到了真正的物理隔离。哪怕缓存服务器是共享的,哪怕其他服务器上的 PHP 进程拿到了这个 Key,因为 Key 里包含了不可伪造的 tenant_id,租客 2 永远读不到房东 1 的缓存。
第七章:那些年我们踩过的坑(以及如何用 PHP 避坑)
架构演进的过程不是一帆风顺的,充满了血泪。我给你们讲个真实的段子。
有一次,我们要做一个“批量导出报表”的功能。需求是:管理员要把所有房东的房租导成 Excel。这是一个非常敏感的操作。
按照我们的物理隔离原则,这个接口应该只允许 role:admin 访问。
但是,有个新来的程序员,为了图省事,直接把 RoleGuardMiddleware 的检查注释掉了,说:“反正导出的时候是管理员在操作,没问题的。”
结果呢?系统上线那天,所有租客都知道了管理员有这个接口,于是他们通过爬虫,疯狂地调用这个接口,试图导出其他租客的数据,导致服务器 CPU 爆满,Excel 文件满天飞。
教训: 物理隔离不仅仅是代码层面的防御,更是流程层面的防御。我们要在代码里写死,甚至在文档里写死:这个接口是绝对禁区。
另一个坑是关于 JSON API 响应的。
如果你的前端是 SPA(单页应用),用户可以在浏览器控制台修改 Cookie 里的 role 字段,从 landlord 改成 admin。这时候,虽然我们的后端中间件拦截了路由,但如果前端没有做二次校验,显示的数据就会出错。
所以,我们的前端也有一套“前端路由守卫”。PHP 负责后端安全,JavaScript 负责前端体验。两者结合,才能形成闭环。
// 前端 Vue Router 守卫伪代码
router.beforeEach((to, from, next) => {
const userRole = store.state.user.role;
if (to.meta.roles.includes(userRole)) {
next();
} else {
next('/403');
}
});
第八章:架构演进后的 MyHome365
经过这一系列的折腾,MyHome365 的架构终于稳定了下来。
现在的它,就像是一个现代化的监狱。每个角色都被关进了属于自己的“牢房”。
- 房东的牢房:只能看自己的房产列表,不能看别人的。
- 租客的牢房:只能看到“我的账单”按钮,没有“添加房源”的按钮。
- 管理员的牢房:拥有钥匙,但他知道钥匙很贵重,所以尽量少用。
我们回头看 PHP 的代码,它变得优雅了许多。中间件干净利落,Model 里的 Scope 代码逻辑清晰,Redis 的 Key 命名规范统一。
代码示例:最终的资源控制器
让我们看一个最终形态的、符合物理隔离标准的 Controller。
class LandlordController extends Controller
{
public function __construct()
{
// 构造函数中注册中间件,这是 Laravel 的最佳实践
$this->middleware('auth');
$this->middleware('role:landlord'); // 物理隔离锁:只有房东能进来
$this->middleware('scope.tenant'); // 物理隔离锁:数据过滤
}
public function index()
{
// 在这里,我们不需要担心读到别人的数据
// 甚至不需要担心租客越权访问
// 数据库查询会自动加上 tenant_id 条件
// 业务逻辑
$properties = Property::where('status', 'active')->paginate(10);
return view('landlord.list', compact('properties'));
}
}
在这个 Controller 里,代码是纯粹的。没有 if (user.role != 'landlord') { return abort(403); } 这种恶心的防御性代码。这些逻辑都下沉到了中间件和 Scope 里。
这就是关注点分离的高级境界。
第九章:为什么选择 PHP?为什么选择物理隔离?
有人会问:“既然这么麻烦,为什么不直接用 Java 做个微服务,把房东、租客、管理员拆成三个独立的 App?”
这就是我要说的。MyHome365 是一个工具。房东们可能只有一个 iPhone,他们不想安装三个 App,也不想在微信小程序和网页之间切来切去。物理隔离允许我们用单体架构的代码结构,实现微服务架构的数据隔离效果。
PHP 的优势在于它的快速迭代和与 Web 的无缝结合。通过中间件、Trait、Scope 这些强大的特性,我们完全可以在 PHP 里实现这种高强度的物理隔离。
这是一种“伪装的微服务”。它节省了维护成本,保证了数据安全,又提升了用户体验。
第十章:未来的展望
当然,架构也是会老的。现在的物理隔离是基于 tenant_id 的。未来,如果我们要支持“多公司合作模式”(比如中介公司代表房东收租,房东通过后台查看),我们的物理隔离就需要从 tenant_id 升级到 company_id。
但核心思想不变:永远不要相信你的用户。永远不要相信你的代码逻辑。永远相信你的中间件和数据库约束。
各位,这就是 MyHome365 的架构进化史。从一盘散沙,到现在的严丝合缝。我们用 PHP 构建了一座坚固的城堡,把混乱挡在了门外。
记住,物理隔离不是一道墙,而是一种思维方式。 永远假设你的系统随时可能被攻破,永远假设你的代码随时可能被误用。
好了,今天的讲座就到这里。现在,请大家去检查一下你们代码里的 where 条件,是不是还藏着未授权的“上帝之手”。
谢谢大家!