作者按 :这是一篇从真实业务代码出发的架构拆解文。我们用一个后台「新增短视频」接口作为样本,把 Controller → Validate → Service → Model 每一层的职责、边界和协作方式讲清楚。适合正在从"能跑就行"过渡到"知道为什么这么写"的 PHP 开发者。
后台新增一条短视频,表单里有:标题、文件、分组、关联商品……
接口看起来很简单,但真正落地时你要面对的是:
我们直接看一段生产级的 add 方法,把它从头到尾拆开。
Controller 接参数、归一化、调校验、交棒
↓
Validate 格式守门员(require / integer / 自定义规则)
↓
Service 业务大脑(事务、多表、缓存、业务断言)
↓
Model 纯数据层(白名单、时间戳、单表 CRUD)
↓
MySQL 最终真相源
每一层只说一句话:
| 层 | 口头禅 |
|---|---|
| Controller | "参数我洗好了,校验你上" |
| Validate | "长得不对,直接拦" |
| Service | "要么全成,要么全败" |
| Model | "我只管安全地把这一行写进这张表" |
在 Service 被调用之前,Controller 其实已经干完了三件关键事:
// 1. 拆表单壳(前端 name="video[title]" 结构)
$data = $this->postDataDefault('video');
// 2. 关联字段单独剥离、归一化成纯 int 数组
$goodsIds = $this->normalizeIds($data['goods_ids'] ?? []);
unset($data['goods_ids']);
// 3. 组装"给验证器看的完整数据"去校验
$checkData = array_merge($data, ['goods_ids' => $goodsIds]);
$this->validateOrFail($checkData, 'add', $this->videoValidate);
这里有个 新手容易错的设计点 :
$data(给主表用)和$checkData(给校验用)是两份数据。
goods_ids 参与规则判断goods_ids 这个字段校验用完整语义数据,入库用干净表字段 ——两份数据故意不一样,是高级写法,不是多此一举。
public function add(array $data, array $goodsIds, int $wxappId): array
{
$data['wxapp_id'] = $wxappId;
unset($data['goods_ids']);
Db::startTrans();
try {
if (empty($data['video_title'])) {
throw new Exception('视频标题不能为空');
}
if (empty($data['video_file_id'])) {
throw new Exception('请上传视频文件');
}
$videoId = $this->model->insertVideo($data);
if (!empty($goodsIds)) {
VideoGoodsRel::bindGoods($videoId, $goodsIds, $wxappId);
}
$this->clearCache($wxappId);
Db::commit();
return [$videoId, ''];
} catch (Throwable $e) {
Db::rollback();
return [false, $e->getMessage()];
}
}
$data['wxapp_id'] = $wxappId;
多租户系统的铁律: wxapp_id 永远由服务端从登录态取,绝不信任前端传值 。Controller 不管归属,Service 在写库前强制打标。
Controller 已经清过 goods_ids,Service 为什么再清?
因为 Service 是公共业务入口 ,它不假设"上游一定干净":
每一层只信自己手里的数据。 这是分层架构能长期维护的核心原因。
Db::startTrans();
MySQL 进入"暂存模式"——后续所有写操作对外部连接 不可见 ,只有 commit 那一刻同时生效。
if (empty($data['video_title'])) {
throw new Exception('视频标题不能为空');
}
你可能会问:验证器不是校验过了吗?
对,但职责不同:
| 位置 | 校验的是什么 |
|---|---|
| Validate | HTTP 请求参数的格式合法性 |
| Service 这里 | 业务上下文里"能不能干这件事" |
Validate 可以被绕过(内部调用、脚本、别人 new 了 Service 直接用)。Service 里的断言是 最后一道代码级保险 ,而且放在事务开头——还没写任何东西就发现不对,rollback 成本为零。
$videoId = $this->model->insertVideo($data);
Model 内部:
public function insertVideo(array $data): int
{
$data['create_time'] = time();
$data['update_time'] = time();
return $this->allowField($this->allowField)->insertGetId($data);
}
三件事:
allowField 白名单过滤(多余字段直接丢弃,防脏写)insertGetId 执行 INSERT + 取 LAST_INSERT_ID()关键认知: 拿到 $videoId 的那一瞬间,数据已经写进表了 (事务内可见,外部不可见)。不是先插再查,是 MySQL 自增分配完 ID 后顺手把号码还给你。高并发下每个连接拿到的 LAST_INSERT_ID 互相隔离,不会串。
VideoGoodsRel::bindGoods($videoId, $goodsIds, $wxappId);
内部逻辑(精简后):
// 先清旧关联(新增时实际无旧数据,但方法复用于编辑)
self::where('video_id', $videoId)->delete();
$data = [];
$sort = 100;
foreach ($goodsIds as $goodsId) {
$data[] = [
'video_id' => $videoId,
'goods_id' => $goodsId,
'goods_sort' => $sort,
'wxapp_id' => $wxappId,
'create_time' => time()
];
$sort += 10;
}
(new self)->insertAll($data);
主表一条 + 关联表 N 条, 靠 $videoId 串联,在同一个事务里 。
$this->clearCache($wxappId);
Db::commit();
commit 之前:数据在事务内存在,外部查不到。
commit 之后: 主表 + 关联表同时对全库可见 ,缓存已清,下次读拿新数据。
catch (Throwable $e) {
Db::rollback();
return [false, $e->getMessage()];
}
| 炸在哪儿 | 结果 |
|---|---|
| 标题为空(throw) | 一条没写,回滚 |
| 主表写入成功,关联表失败 | 主表也撤,无孤儿数据 |
| 数据库连接异常 | 全撤 |
不会出现"视频有、关联没有"的中间态。
Service 返回统一约定 [成功时ID, ''] 或 [false, 错误信息],Controller 只管翻译成 HTTP 响应格式,不碰业务逻辑。
| 阶段 | 位置 | 动作 | 数据状态 |
|---|---|---|---|
| 接参 | Controller | 拆壳、归一化 | $data干净 |
| 校验 | Validate | scene + rule | 不过直接拦 |
| 打标 | Service | 加 wxapp_id | 归属明确 |
| 开事务 | Service | startTrans | 草稿模式 |
| 断言 | Service try | 业务兜底 | 早失败 |
| 写主表 | Model | INSERT + 白名单 + 时间戳 | 表里有数据(事务内) |
| 拿 ID | insertGetId 返回 | MySQL 自增回执 | $videoId |
| 写关联 | RelModel | insertAll N条 | 两张表都有 |
| 清缓存 | Service | del key | 读不脏 |
| 提交 | commit | 对外可见 | ✅ 成功 |
| 异常 | rollback | 全部撤销 | ✅ 干净 |
30 行,同时覆盖了:
以后你写任何「主表 + 关联表」的新增接口(文章/商品/SKU/活动),把这个方法 改字段名就能用 。
insertVideo 放在事务外面,关联表写失败了,主表数据回得去吗?$data 丢给 Model,Model 有 allowField 兜底,为什么 Service 还要清?bindGoods 先 delete 再 insertAll,而不是逐条 diff 更新——你觉得是懒还是对?最后一句 :分层架构的价值不在于"多写几个文件夹",而在于 每一层被单独调用时,都不会把系统搞脏 。你项目里这个
add方法,就是这句话最好的注脚。
来团科技GEO优化&AI搜索优化系统,是通过大模型内容投喂+训练,将企业品牌及产品信息在多平台AI生成的答案中获取优先展现,更精准触达潜在目标客户,让企业品牌出现在AI搜索里。让客户一搜就看到你,实现一问就有你,一查就信你,一看就找你的营销效果。
来团智慧商业小程序零代码开发平台,多行业适配。无需代码,拖拽式设计,轻松打造订货商城、会员制商城、分销商城及小程序官网。不仅能满足通用需求,还支持定制化,从页面布局到功能模块,随心定制,助您快速搭建专属商业小程序,抢占市场先机。
来团科技微名通不止是电子名片,更是你的商业连接器。比起传统名片,它更像你的 “迷你商业工具”:信息多、好携带、能互动,还不浪费纸张。不管是跑业务、拓人脉,还是展示企业,一张「微名通」电子名片,就能帮你把商机揣在手机里。
来团科技CRM客户管理系统,帮你把 “线索→成交→回款” 全流程管明白。这就是一套 “让销售省心、老板放心” 的客户管理工具,从获客到回款,帮你把生意攥在手里。