Zsens Admin 插件分层实战:当 Service 开始"偷吃"Model 的脏活,我用"仓库契约"重新划定了三方边界
上周重构一个积分兑换插件,Controller 才 60 行,Service 却飙到 400+,里面混着原生 SQL、缓存逻辑、甚至直接操作 $_G['cache']。Model 层只剩个空壳,连表前缀都要 Service 自己拼。这不是分层,是 Service 把 Model 活埋了。
这篇不说大道理,直接上我踩过的三类"职责逃逸"现场,以及怎么用"仓库契约"把脏活赶回该去的地方。
一、Service 里的 SQL 拼装:Model 成了 DTO 摆设
最开始的代码长这样:
// Service/ExchangeService.php
public function getUserValidOrders($uid) {
$table = DB::table('plugin_exchange_orders');
$sql = "SELECT * FROM {$table}
WHERE uid='{$uid}'
AND status IN (1,2)
AND expire_time > ".TIMESTAMP;
return DB::fetch_all($sql);
}
问题在哪?Service 知道表结构、知道字段含义、知道 TIMESTAMP 常量。换个人维护,改个字段要翻三四个 Service 文件。
我的改法:Model 只暴露语义化接口,Service 只传业务参数。
// Model/OrderRepository.php(仓库契约)
interface OrderRepositoryInterface {
public function findValidByUser(int $uid, array $statuses, int $minExpire): array;
}
// Model/OrderRepositoryDb.php(实现)
public function findValidByUser(int $uid, array $statuses, int $minExpire): array {
return $this->newQuery()
->where('uid', $uid)
->whereIn('status', $statuses)
->where('expire_time', '>', $minExpire)
->get();
}
// Service/ExchangeService.php
public function getUserValidOrders($uid) {
return $this->orderRepo->findValidByUser(
$uid,
[OrderStatus::PAID, OrderStatus::SHIPPED],
TIMESTAMP
);
}
Service 不再知道 expire_time 这个字段名,只关心"有效订单"这个业务概念。Model 的仓库实现可以随便换(DB、缓存、甚至外部 API),Service 无感。
二、Controller 里的流程编排:Service 成了函数仓库
另一个插件的 Controller 典型场景:
// Controller/ExchangeController.php
public function submit() {
$this->checkLogin();
$this->checkToken();
$goods = $this->goodsService->getById($_GET['gid']);
if (!$goods || $goods['stock'] < 1) {
showmessage('库存不足');
}
$user = $this->userService->getCredit($_G['uid']);
if ($user['credit'] < $goods['price']) {
showmessage('积分不够');
}
$this->userService->deductCredit($_G['uid'], $goods['price']);
$this->goodsService->decrStock($goods['id']);
$orderId = $this->orderService->create([
'uid' => $_G['uid'],
'gid' => $goods['id'],
'price' => $goods['price']
]);
$this->logService->write('exchange', $orderId);
showmessage('兑换成功', "plugin.php?id=exchange:order&oid=$orderId");
}
Controller 成了"流程导演",Service 只是被调用的工具函数。更糟的是:扣积分、减库存、建订单三步没有事务,中间报错就数据不一致。
重构后:Controller 只负责"接请求、转参数、吐响应",Service 封装完整业务用例。
// Controller/ExchangeController.php
public function submit() {
$this->checkLogin();
$this->checkToken();
try {
$order = $this->exchangeService->execute(
new ExchangeCommand(
uid: $_G['uid'],
goodsId: (int)$_GET['gid']
)
);
} catch (ExchangeException $e) {
showmessage($e->getMessage());
}
showmessage('兑换成功', $order->detailUrl());
}
// Service/ExchangeService.php
public function execute(ExchangeCommand $cmd): OrderEntity {
return $this->db->transaction(function() use ($cmd) {
$goods = $this->goodsRepo->lockForUpdate($cmd->goodsId);
$goods->ensureStock();
$user = $this->userRepo->lockForUpdate($cmd->uid);
$user->ensureCredit($goods->price);
$user->deduct($goods->price);
$goods->decrStock();
$order = OrderEntity::create($user, $goods);
$this->orderRepo->save($order);
$this->eventBus->dispatch(new OrderCreated($order));
return $order;
});
}
Service 里的 execute 是一个完整的"领域用例",自带事务边界。Controller 连 $_G['uid'] 都不直接碰,通过 Command 对象隔离。
三、Model 里的业务规则:Entity 成了贫血结构
最隐蔽的逃逸:Model 只有 getter/setter,业务规则散落在 Service 各处。
// 到处出现的重复校验
if ($goods['status'] != 1) throw ...
if ($goods['start_time'] > TIMESTAMP) throw ...
if ($goods['end_time'] < TIMESTAMP) throw ...
改成充血模型后,规则内聚到 Entity:
// Model/Entity/GoodsEntity.php
public function ensureAvailable(): void {
if ($this->status !== self::STATUS_ONLINE) {
throw new GoodsNotAvailableException('商品已下架');
}
if ($this->startTime > TIMESTAMP) {
throw new GoodsNotAvailableException('兑换未开始');
}
if ($this->endTime < TIMESTAMP) {
throw new GoodsNotAvailableException('兑换已结束');
}
}
public function ensureStock(): void {
if ($this->stock < 1) {
throw new InsufficientStockException();
}
}
public function decrStock(): void {
$this->ensureStock();
$this->stock--;
$this->recordEvent(new StockChanged($this->id, -1));
}
Service 调用 $goods->decrStock() 就行,不用管"怎么判断库存、怎么扣、要不要发事件"。
我的分层 checklist(Zsens Admin 场景)
现在写新插件前,我会对着这张表自测:
| 层级 | 该做的事 | 红线(看到就重构) |
|---|---|---|
| Controller | 接收请求、校验输入、调用 Service、返回响应 | 出现 SQL、出现业务计算、直接操作缓存 |
| Service | 编排领域用例、控制事务边界、发布领域事件 | 知道表字段名、拼 SQL、直接读 $_G |
| Model/Repository | 数据映射、查询封装、持久化细节 | 包含业务规则判断(除了数据完整性约束) |
| Entity | 封装业务规则、维护自身状态一致性 | 直接操作 DB、依赖外部服务 |
最后一句话:分层不是为了"看起来规范",是为了"改一处不用翻遍全文"。当你的 Service 开始问 Model"这个字段叫什么"的时候,边界就已经塌了。
你们插件里遇到过哪些"看起来分了层,其实没分"的伪装现场?

