惊!号称支持开店的PHP商城源码多店铺功能如此简陋?

最近在GitHub上看到一个号称"支持开店"的PHP商城源码,下载下来一看差点没把咖啡喷在显示器上——所谓的多店铺功能就是在数据库里加了个shop_id字段。这让我想起当年刚入行时写的第一个商城系统,那时候连Shopify是什么都不知道,现在回头看简直惨不忍睹。

今天就来聊聊怎么用PHP实现真正可用的多店铺商城系统。先看个真实案例:去年给某农产品平台做改造,原本的单店铺系统要支持200多个农户独立开店,结果发现原来的架构根本扛不住。

数据库设计是第一个坑。很多新手会这样设计商品表:

CREATE TABLE products (

id INT PRIMARY KEY,

name VARCHAR(255),

price DECIMAL(10,2),

shop_id INT -- 就加这么个字段完事了?

);

这种设计在店铺数量少的时候还能凑合,但等到要查"所有店铺中价格低于50元的商品"时,索引直接罢工给你看。正确的做法是把店铺相关数据拆分到单独的表空间:

CREATE TABLE shops (

name VARCHAR(255) NOT NULL,

subdomain VARCHAR(63) UNIQUE,

status ENUM('active','pending','suspended') DEFAULT 'pending'

-- 其他店铺配置项

) ENGINE=InnoDB PARTITION BY HASH(id) PARTITIONS 16;

id BIGINT PRIMARY KEY,

shop_id INT NOT NULL,

-- 其他字段

FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE

) ENGINE=InnoDB;

看到没?分区表加外键约束,这才是专业玩家的操作。PARTITION BY HASH能让查询自动路由到对应的表分区,店铺数据再多也不怕。

接下来是URL路由这个重灾区。很多开源项目处理子域名的方式简直让人想砸键盘:

$subdomain = explode('.', $_SERVER['HTTP_HOST'])[0];

if($subdomain == 'www') {

// 主站逻辑

} else {

// 店铺逻辑

}

这种写法在Nginx反向代理后面会死得很难看。正确的打开方式应该是:

// 在bootstrap阶段初始化店铺上下文

$shop = null;

$host = parse_url($_SERVER['HTTP_HOST'], PHP_URL_HOST);

if(preg_match('/^(.)\.example\.com$/', $host, $matches)) {

$shop = Shop::where('subdomain', $matches[1])->first();

}

if(!$shop) {

abort(404, '店铺不存在或已关闭');

// 注册全局中间件

app()->instance(Shop::class, $shop);

现在说说店铺自定义模板这个需求。千万别学某些框架把PHP文件直接存数据库,除非你想体验服务器被黑的感觉。我们采用更安全的方式:

// 店铺模板表结构

CREATE TABLE shop_templates (

content TEXT NOT NULL, -- 存Twig/Blade模板

FOREIGN KEY (shop_id) REFERENCES shops(id)

);

// 渲染时做安全过滤

public function renderTemplate(Shop $shop, $templateName)

{

$template = $shop->templates()->where('name', $templateName)->first();

if(!$template) {

return view('default.'.$templateName);

}

try {

return \Blade::render($template->content, $this->data);

} catch (\Throwable $e) {

Log::error("店铺模板渲染失败: {$shop->id}");

}

缓存策略也是个技术活。曾经见过有人用店铺ID当缓存键前缀,结果Redis内存爆了。我们的解决方案是二级缓存:

// 店铺级别的缓存封装

class ShopCache {

private $shopId;

public function __construct($shopId) {

$this->shopId = $shopId;

public function remember($key, $ttl, $callback) {

$fullKey = "shop:{$this->shopId}:".md5($key);

// 第一层:内存缓存

if($data = apcu_fetch($fullKey)) {

return $data;

// 第二层:Redis集群

$data = Redis::cluster()->get($fullKey);

if(!$data) {

$data = value($callback);

Redis::cluster()->setex($fullKey, $ttl, serialize($data));

} else {

$data = unserialize($data);

}

apcu_store($fullKey, $data, min(300, $ttl));

return $data;

}

}

支付接口对接最容易出幺蛾子。某次升级时发现,有店铺的支付宝收款全进了平台账户,原因是在支付回调里没校验店铺参数:

// 错误示范

Route::post('/pay/notify', function(Request $request) {

$order = Order::find($request->input('out_trade_no'));

$order->pay(); // 完蛋,没验证店铺

});

// 正确姿势

$order = $shop->orders()->where('id', $request->input('out_trade_no'))->first();

if(!$order) {

Log::alert("可疑支付通知: ".$request->fullUrl());

abort(403);

}

$order->pay();

})->middleware('verify_shop_signature');

最后说说性能优化。当你有500个店铺时,N+1查询问题会被放大500倍。看看这个典型的错误案例:

// 获取所有店铺及其商品

$shops = Shop::all();

foreach($shops as $shop) {

echo $shop->name;

foreach($shop->products as $product) { // 这里每次循环都查一次数据库

echo $product->name;

}

}

解决方案是用预加载和查询作用域:

// 在Shop模型里定义

public function scopeWithActiveProducts($query)

{

return $query->with(['products' => function($q) {

$q->where('status', 'active')->select('id','shop_id','name');

}]);

}

// 使用时

$shops = Shop::withActiveProducts()->paginate(50);

多店铺系统最考验人的其实是数据隔离。去年遇到个奇葩bug:A店铺的管理员登录后能看到B店铺的订单。排查发现是Session处理有问题:

// 错误的会话处理

$_SESSION['shop_id'] = $shop->id; // 只存个ID不够

// 应该这样

session()->put('shop_context', [

'id' => $shop->id,

'name' => $shop->name,

'permissions' => $user->getShopPermissions($shop->id)

]);

// 中间件校验

public function handle($request, $next)

{

if($request->session()->get('shop_context.id') != $request->route('shop')) {

auth()->logout();

return redirect()->route('shop.login');

}

return $next($request);

}

说到这不得不提一嘴,有些开发者喜欢用数据库视图来解决多店铺查询问题。比如:

CREATE VIEW shop_products AS

SELECT p. FROM products p

JOIN shops s ON p.shop_id = s.id

WHERE s.status = 'active';

看起来很美是不是?但在高并发下这东西就是性能黑洞。我们的做法是用查询构建器动态生成SQL:

class ShopProductQueryBuilder {

public static function forShop(Shop $shop)

{

return Product::query()

->where('shop_id', $shop->id)

->when(!$shop->isAdmin(), function($q) {

$q->where('is_public', true);

});

}

}

日志处理也有讲究。把所有店铺日志都写到一个文件里,等出问题时你就知道什么叫大海捞针了。我们的日志策略:

// 在config/logging.php配置

'channels' => [

'shop' => [

'driver' => 'daily',

'path' => storage_path('logs/shops/shop_{shop_id}.log'),

'tap' => [ShopLogFormatter::class],

'days' => 14,

]

],

// 使用时

Log::channel('shop:'.$shop->id)->info('订单创建', $order->toArray());

最后给个忠告:如果你正在开发多店铺系统,千万别在代码里写死店铺数量限制。我见过最蠢的实现:

// 千万别这么干!

if(Shop::count() > 100) {

throw new Exception("超出店铺数量限制");

}

// 应该用配置控制

config('shop.max_shops', 1000);

// 或者在店铺注册逻辑里判断

if(Shop::active()->count() >= $plan->max_shops) {

throw new ShopLimitReachedException;

}

写代码就像开餐馆,你以为加几张桌子就能接待更多顾客,结果发现厨房根本忙不过来。多店铺系统开发也是这个道理,表面上看只是加个店铺ID的事,实际上要考虑的远比你想象的复杂得多。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值