房东管理工具(MyHome365)的架构演进:基于 PHP 实现多角色权限的物理隔离

各位来宾,各位致力于让代码“活”过来、让生活“快”起来的 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 里。

我们的目标很明确:

  1. 房东只能看自己名下的房源、缴费记录。
  2. 租客只能看自己租的房子、缴费记录。
  3. 管理员能看全局,但必须经过严格的授权。

为了实现这个目标,我们不能再用简单的 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 条件,是不是还藏着未授权的“上帝之手”。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注