PHP如何实现多商户商城系统并支持独立店铺后台管理

大家好,欢迎来到今天的“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 多商户系统有了宏观的认识。

  1. 路由拦截:先搞清楚谁来了。
  2. 数据隔离:把数据隔离开,这是底线。
  3. 独立认证:给每个商家一把独立的钥匙。
  4. 配置隔离:API Key、上传路径都要跟着租户走。

这就像是搭积木,每一块积木(中间件、模型、配置)都必须严格对齐。如果你偷懒,直接把所有数据混在一起,恭喜你,你在给自己挖坑。等到双十一流量一来,或者某个商家违规操作,你的系统就会像那个穿了一条一条内裤的老人一样——一戳就破。

最后,送给大家一句话:在多租户系统里,最大的敌人不是代码写错了,而是“我想当然”。 每一次写代码,都要问自己一句:“如果我是租户 B,这段代码会对我产生什么影响?”

好了,今天的讲座就到这里。如果觉得我的代码对你有用,记得给自己泡杯咖啡,毕竟架构师也是要续命的。下课!

发表回复

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