大家好,欢迎来到今天的“PHP 架构进阶”讲座。我是你们的老朋友,一个在代码堆里摸爬滚打了十年的“资深专家”。
今天咱们不聊那些虚头巴脑的 Hello World,咱们来聊一个听起来很美、做起来让人头秃的话题:如何用 PHP 搞定一个多商户商城系统,还要让每个商户拥有独立的后台管理权限。
想象一下,你是一个房地产大亨。你有一栋大楼,你租给 1000 个商家。你不能让 A 商家看到 B 商家的财务报表,更不能让 A 商家用 B 商家的钱付水电费。你要做的,就是在这个大楼里,给每个人都装上一把不同的钥匙,还要让他们在自己的房间里能听到音乐(运行正常),却看不到隔壁的电视(数据隔离)。这就是多租户系统的精髓。
如果这听起来像是在描述一个“只有程序员才懂的公厕隔间”理论,那你理解对了。接下来,咱们就把这个公厕隔间,装修成五星级酒店。
第一部分:架构模式的选择——这可不是在选衣服
在动手敲代码之前,咱们得先穿好“内衣”(架构模式)。多租户架构主要分两类,咱们来对比一下,就像对比“吃自助餐”和“点外卖”。
模式一:单数据库,多表。
这是最简单粗暴的。所有商家的数据都在同一个数据库里,只是表名不一样,比如 brand1_products, brand2_products。
- 优点: 管理员容易查所有数据,维护成本低。
- 缺点: 就像把 1000 只猫关在一个笼子里,你想找一只蓝眼睛的猫,得遍历所有数据,性能炸裂。而且数据备份恢复是个噩梦,这是“共享猪圈”。
模式二:多数据库,多租户。
每个商家(租户)对应一个独立的数据库。租户 A 有自己的 MySQL,租户 B 有自己的 MySQL,甚至租户 C 用的是 PostgreSQL。
- 优点: 数据隔离做得最好,互不干扰,想删库就删库,不怕波及邻居。性能相对独立。
- 缺点: 服务器资源消耗大,运维复杂度指数级上升。这是“独栋别墅”。
咱们今天的讲座,默认走“独栋别墅”路线(多数据库),或者至少是“Schema Per Tenant”路线(按表结构隔离)。 为什么?因为你是资深专家,咱们追求的是专业。
第二部分:路由魔法——如何知道你是谁?
当一个用户访问 shop.example.com 时,Web 服务器首先把流量扔给了 PHP。这时候,PHP 程序是个瞎子,它不知道这个用户是想买东西,还是想登录后台。它得先“看门”。
在 PHP (Laravel/Symfony) 框架里,咱们用 Middleware(中间件) 来干这活。
代码实战:捕获子域名
假设你的域名是 shop.kaifa.com,A 商家的店铺是 shop.kaifa.com/a-brand,B 商家是 shop.kaifa.com/b-brand。
首先,你需要一个中间件,比如 DetectTenant.php:
// app/Http/Middleware/DetectTenant.php
namespace AppHttpMiddleware;
use Closure;
use IlluminateHttpRequest;
class DetectTenant
{
public function handle(Request $request, Closure $next)
{
// 1. 获取子域名
// 比如 'shop.kaifa.com' -> ['shop', 'kaifa', 'com'] -> 我们要的是中间那个
$subdomains = explode('.', $request->getHost());
// 2. 确定租户ID
// 如果是 demo.kaifa.com,说明是演示账号,租户ID可能是 0
$tenantId = count($subdomains) > 2 ? $subdomains[1] : 'default';
// 3. 将租户ID存入 Laravel 的 Request 对象中
// 就像邮递员把信件塞进专门的信封里
$request->merge(['tenant_id' => $tenantId]);
// 4. 保存到 Session,防止每次请求都要解析域名
session(['current_tenant_id' => $tenantId]);
return $next($request);
}
}
别忘了在 Kernel.php 里注册这个中间件,并且把它挂在 web 中间件组的最前面。这样,后续所有的路由都能通过 $request->tenant_id 轻松拿到当前访问者是哪一家店。
第三部分:数据库隔离——数据隔离是核心
现在我们知道是“张三”进来了。接下来的问题是,张三能不能看到“李四”的数据?答案绝对不能。
如果使用“多数据库”模式,最简单的做法是动态切换数据库连接。
代码实战:动态连接
假设你在 config/database.php 里配置了所有商家的数据库连接。
// config/database.php
return [
'connections' => [
'tenant_a' => [ // 张三的数据库
'driver' => 'mysql',
'host' => '192.168.1.10',
'database' => 'shop_a_db',
'username' => 'root',
'password' => 'secret',
],
'tenant_b' => [ // 李四的数据库
'driver' => 'mysql',
'host' => '192.168.1.11',
'database' => 'shop_b_db',
'username' => 'root',
'password' => 'secret',
],
],
];
然后,在模型层面,咱们得做个手脚。不要直接在 Product 模型里硬编码 DB::table('products')。咱们得用 withConnection。
// app/Models/Product.php
namespace AppModels;
use IlluminateDatabaseEloquentModel;
class Product extends Model
{
/**
* 根据当前租户动态切换连接
*/
protected static function boot()
{
parent::boot();
// 监听模型事件,每次查询前都把连接改了
static::creating(function ($model) {
$model->getConnectionName(request()->input('tenant_id'));
});
// 这里简化处理,实际生产中要结合前面的中间件逻辑
// 比如:
// static::addGlobalScope('tenant', function ($query) {
// $query->where('tenant_id', session('current_tenant_id'));
// });
}
}
更高级的玩法(Schema Per Tenant):
如果你不想拆分数据库,只想在同一个库里把数据分开,那就要玩 Schema 了。Laravel 有个非常棒的包叫 spatie/laravel-multitenancy。它的核心思想是:所有表都有一个 tenant_id 字段。
每当查询数据时,中间件自动帮你在 SQL 后面加上 AND tenant_id = 1。
-- 中间件自动帮你加的 WHERE 条件
SELECT * FROM products WHERE tenant_id = 1 AND status = 'published';
这种做法,就像是给每个租户发了带有条形码的文件,扫描仪(数据库)看到条形码不对,立马就把文件扔出去。这就是数据隔离的艺术。
第四部分:独立店铺后台——这才是重头戏
多商户系统最反人类的地方,往往不是前台,而是后台。你不能让商家去登录你的主站后台,那样商家要是把你的主站后台删了,你哭都来不及。商家得有自己的登录页,有自己独立的菜单,有自己独立的权限管理。
1. 独立的路由与认证
你需要一个完全不同的路由文件,比如 routes/tenant_admin.php。这个文件不需要在 web.php 里引入,而是通过中间件在 DetectTenant.php 里根据租户 ID 动态加载。
// app/Http/Middleware/LoadTenantRoutes.php
namespace AppHttpMiddleware;
use Closure;
use IlluminateSupportFacadesRoute;
class LoadTenantRoutes
{
public function handle($request, Closure $next)
{
// 逻辑:如果这是租户A的后台请求,就加载 tenant_a_admin.php
// 如果是租户B,就加载 tenant_b_admin.php
$tenantId = session('current_tenant_id');
$routeFile = base_path("routes/tenants/{$tenantId}_admin.php");
if (file_exists($routeFile)) {
require $routeFile;
}
return $next($request);
}
}
然后是独立的后台登录。
// routes/tenants/a_brand_admin.php
use IlluminateSupportFacadesRoute;
use AppHttpControllersTenantAuthController;
// 商家自己的登录入口,长得跟你的主站完全不一样
Route::get('/login', [AuthController::class, 'showLoginForm'])->name('tenant.login');
Route::post('/login', [AuthController::class, 'login']);
// 商家自己的仪表盘
Route::get('/dashboard', [TenantController::class, 'index'])->middleware('auth.tenant');
2. 独立的权限控制
这就是所谓的 RBAC(Role-Based Access Control)。张三是“管理员”,李四是“运营”。他们在后台看到的面板肯定不一样。
你可以利用 Laravel 的 Gate 或者 Policy。
// 在 AuthServiceProvider 中定义权限
use IlluminateSupportFacadesGate;
Gate::define('manage-products', function ($user) {
// $user 是当前登录的商家用户
// 检查这个商家是否有 'products' 模块的管理权限
return $user->permissions()->where('key', 'manage_products')->exists();
});
// 在控制器里使用
public function update(Request $request, $id)
{
// 如果没有权限,直接 403
$this->authorize('manage-products');
// 只有通过了权限检查,代码才能执行到这里
$product = Product::find($id);
$product->update($request->all());
}
第五部分:第三方集成与支付——每个租户都有自己的 API Key
这是多商户系统最容易出坑的地方。如果你写了死代码:
// 这种写法是自杀行为
Stripe::setApiKey('sk_live_51M...'); // 你的 API Key
那么所有商家都能看到你的 Stripe 账户里的钱,或者更糟,他们能修改你的 API Key。
解决方案:租户配置表。
在数据库里建一张 tenant_settings 表:
| id | tenant_id | key | value |
|---|---|---|---|
| 1 | a_brand | stripe_api_key | sk_test_xxx |
| 2 | b_brand | stripe_api_key | sk_live_yyy |
或者更简单的,直接存在租户的配置文件里。
// 假设你在中间件里已经把 $tenant 配置加载进来了
function getStripeClient($tenantId)
{
$apiKey = TenantConfig::get($tenantId, 'stripe_api_key');
return new StripeStripeClient([
'api_key' => $apiKey,
'stripe_version' => '2020-08-27',
]);
}
// 在支付逻辑里
public function createCheckoutSession(Request $request)
{
$stripe = getStripeClient(request()->tenant_id);
return $stripe->checkout->sessions->create([...]);
}
这样一来,租户 A 调用 Stripe,用的是 A 的 Key;租户 B 调用 Stripe,用的是 B 的 Key。钱一分不差,谁也赖不掉。
第六部分:文件存储——别把文件存错了地方
商家上传的头像、商品图片,总不能混在一起吧?如果商家 A 上传了一张图,然后你的代码逻辑写着 public/uploads/img.jpg,那商家 B 上传同名图片时,直接把 A 的图覆盖了!这可是严重的隐私泄露。
解决方案:基于租户的存储路径。
利用 Laravel 的 Storage facade。
use IlluminateSupportFacadesStorage;
// 租户 A 的图片存放在 storage/app/tenants/a_brand/products/xxx.jpg
// 租户 B 的图片存放在 storage/app/tenants/b_brand/products/xxx.jpg
// 看到没,路径里直接带上了 tenant_id,物理隔离!
public function uploadProductImage(Request $request)
{
$file = $request->file('image');
$filename = uniqid() . '.' . $file->getClientOriginalExtension();
// 拼接路径:租户ID/模块/文件名
$path = 'tenants/' . request()->tenant_id . '/products/' . $filename;
Storage::disk('local')->put($path, file_get_contents($file));
return response()->json(['url' => Storage::url($path)]);
}
当然,图片多了以后,存储服务器会爆。这时候你就得用云存储了。阿里云 OSS、腾讯云 COS 都支持“Bucket 前缀”。你让租户 A 的 Bucket 名字叫 myshop-a-brand,租户 B 叫 myshop-b-brand。原理是一样的。
第七部分:状态管理与并发——别让两个张三同时登录
多租户系统的并发控制非常微妙。如果租户 A 的员工在改库存,租户 B 的员工同时也在改库存,如果用的是共享数据库,怎么保证不超卖?
在单租户系统里,数据库的 TRANSACTION ISOLATION LEVEL 和行锁就能搞定。但在多租户系统里,因为数据隔离了,行锁的范围就被缩小到了“同一个表但不同的 tenant_id 行”。A 的锁不会锁住 B 的数据。
所以,你的代码里必须显式地加锁。
DB::transaction(function () use ($productId, $quantity) {
// 锁定这条商品记录
$product = Product::where('id', $productId)
->where('tenant_id', request()->tenant_id) // 必须带上租户ID,否则锁的是别人的锁
->lockForUpdate()
->first();
if ($product->stock < $quantity) {
throw new Exception('库存不足');
}
$product->stock -= $quantity;
$product->save();
});
第八部分:独立后台的 UI 设计
虽然你是写后端架构的,但作为资深专家,你不能只扔给商家一个全是 SQL 报错的白屏。
独立后台意味着你需要给每个租户一套前端资源。如果是单体应用,你可以用 Blade 模板渲染不同的布局。但更推荐的做法是多前端工程化。
给每个租户分配一个独立的前端项目,或者是通过 URL 参数区分视图。
<!-- tenant_a_admin.blade.php -->
<div class="sidebar">
<a href="/dashboard">总览</a>
<a href="/products">商品管理</a>
<a href="/orders">订单管理</a>
</div>
现在的技术栈,前端是 Vue/React。你可以搞一套通用的前端框架,然后通过后端返回不同的菜单配置(JSON),前端动态渲染侧边栏。这样商家想要加个“VIP管理”菜单,不需要你动代码,后台配置一下就行。
总结:这是一个系统工程
写到这里,相信你已经对 PHP 多商户系统有了宏观的认识。
- 路由拦截:先搞清楚谁来了。
- 数据隔离:把数据隔离开,这是底线。
- 独立认证:给每个商家一把独立的钥匙。
- 配置隔离:API Key、上传路径都要跟着租户走。
这就像是搭积木,每一块积木(中间件、模型、配置)都必须严格对齐。如果你偷懒,直接把所有数据混在一起,恭喜你,你在给自己挖坑。等到双十一流量一来,或者某个商家违规操作,你的系统就会像那个穿了一条一条内裤的老人一样——一戳就破。
最后,送给大家一句话:在多租户系统里,最大的敌人不是代码写错了,而是“我想当然”。 每一次写代码,都要问自己一句:“如果我是租户 B,这段代码会对我产生什么影响?”
好了,今天的讲座就到这里。如果觉得我的代码对你有用,记得给自己泡杯咖啡,毕竟架构师也是要续命的。下课!