起因:小迪把库存判断写在了页面上
周三下午,小迪在改活动页的「加入购物车」。
她写的是这样的:
if (product.stock > 0) {
showButton('加入购物车');
}
// 点按钮时,把数量和价格一起提交
fetch('/api/cart/add', {
method: 'POST',
body: JSON.stringify({
id: product.id, count: 1, price: product.price })
});
阿杰看了一眼:「这样不对。」
「哪里不对?点之前我判断了库存啊,没库存就不给按钮。」
老陈端着咖啡从旁边经过,停下来看了两秒:「你把『有没有库存』这个判断,交给了用户的浏览器。」
小迪没听懂:「可是它确实判断了啊,页面上没库存真的不显示按钮。」
「你能不能让按钮显示出来?」
「……能啊,F12 把那行 if 删掉就显示了。」
「那这个判断还算数吗?」
小迪停住了。
老陈拉开椅子:「今天把这个说清楚。不是『前端能做多少、后端能做多少』,是这段逻辑的最后决定权归谁。」
一条判定线
老陈在白板中间画了一道竖线,左边写「服务端」,右边写「页面」。
「左边是算数的,右边是给你看的。」
| 问自己一句 | 答案在服务端 | 答案在页面 |
|---|---|---|
| 用户能绕过它吗? | 绕过就会出错 → 服务端 | 绕过了只是不好看 → 页面 |
| 算它需要哪些数据? | 要连表、要别人的数据、要分页 | 只有这一屏已经拿到的数据 |
| 算错了谁负责? | 钱、库存、状态、权限 | 高亮、折叠、动画、提示 |
| 你要的是快,还是对? | 要对 → 服务端 | 要快 → 页面 |
小迪把表格抄下来:「所以……四个问题里有任何一个指向服务端,就写服务端?」
「对。这道线是取严的,不是取巧的。」
第 1 问:用户能不能绕过它?
这题的答案永远是「能」。
老陈点开浏览器控制台:「用户手里有你这页面上的全部代码。他能改,也能绕过页面直接打你的接口——不用浏览器都行,一条 curl 就够。」
curl -X POST https://xxx/api/cart/add \
-d '{"id":1,"count":1000,"price":0.01}'
「你那个 if (product.stock > 0),在这条命令面前等于不存在。而且你看他把 price 传成多少了。」
小迪:「……0.01。」
「所以页面上的校验,作用只有一个:让用户少犯一次错、少点一次提交。它管的是体验,不管对错。」
那对错谁管?服务端再判一遍。
// controller_api/cart.php
if_post('/api/cart/add', function () {
$product_id = input_json('id');
$count = input_json('count');
$product = dao('product')->find($product_id);
otherwise_error_code('INVALID_PARAM', $product->is_not_null(), ['param' => 'id']);
otherwise_error_code('INVALID_PARAM', $count > 0, ['param' => 'count']);
otherwise_error_code('INVALID_PARAM', $product->stock >= $count, ['param' => 'count']);
// 价格由服务端自己查,前端传什么都不作数
$cart = cart::create($product_id, $count, $product->price);
return $cart;
});
「注意三件事。」老陈指着代码,「第一,价格不是你传上来的,是我自己从库里查的——你传的那个 price 字段干脆没读。第二,库存判断在这里又做了一遍,这次是真的。第三,错误码是配置里定义的,INVALID_PARAM、PERMISSION_DENIED、USER_NOT_FOUND 这些,前端只看 code。」
小迪:「那前端那个 if 还有用吗?」
「有。用户点下去之前就告诉他没货,比提交完等两秒弹个红字好得多。前端求快,服务端求对,这两件事不冲突。」
第 2 问:算它需要什么数据?
「这题是分界线里最容易判断的一条。」
- 数据已经在页面上了(这一屏渲染用的那几条)→ 页面算
- 数据要现查(连表、别人家的数据、全量里挑一部分)→ 服务端算
阿杰插了一句他踩过的坑:「我上次偷懒,把一个列表的总金额交给前端算。结果前端是把全量数据拿过去算的——为了显示一个合计,接口一次吐了两百多条。用户手机上白屏了四秒。」
「对。这就是第二个信号。」老陈说,「为了在前端算一个数,你要先把原料搬过去。搬原料的成本,经常比算这个数本身还高。」
在这套框架里,取数有明确的地方:
// 分页、筛选都在服务端做,前端只拿到这一页
$res = dao('order')->find_all_paginated_by_current_page_and_column(
$page, $size, ['status' => 'paid']
);
$orders = $res['list'];
$pagination = $res['pagination']; // page_size / current_page / count / pages
「排序、分页、聚合,天然属于服务端。因为你不想让前端知道全量有多少条。 除了性能,这里面还有个信息量的问题——不该给用户看的数据,你别先发过去再在页面上藏起来。」
第 3 问:算错了谁负责?
「钱、库存、订单状态、用户身份——这几样东西,整个系统里只能有一个地方说了算。」
小迪:「为什么不能两个地方都算,对一下不就行了?」
「两个地方算,就一定会有两个答案。你要的不是两个答案,是一个答案和一个展示。」
拿之前那篇里那个 version 字段举例,它也是同一个道理:
UPDATE `order` SET amount = ?, version = version + 1
WHERE id = ? AND version = ?
「乐观锁这件事为什么必须写服务端?因为它要保证『我读到的还是那条数据』。这个保证需要数据库的事务,前端连数据库在哪儿都不知道,谈什么保证。」
「那我明白了。」小迪说,「前端可以算,但算出来的数只是给人看的一个预览,不是那个数本身。」
「对。你的前端算出来 199.00,提交的时候只提交数量。价格、折扣、运费、合计,全部服务端重算一遍。」
第 4 问:你要的是快,还是对?
这一问最像「感觉」,但落到代码上是具体的。
| 场景 | 页面做的事(求快) | 服务端做的事(求对) |
|---|---|---|
| 表单 | 输入完立刻提示「手机号格式不对」 | 再校验一次,不合法就拒绝写入 |
| 按钮 | 点下去马上置灰、转圈,防止连点 | 幂等:同一个请求来两次,只生效一次 |
| 权限 | 不是管理员就不显示这个按钮 | 请求打进来先判身份,不合法直接 PERMISSION_DENIED |
| 列表 | 滚动时先把骨架屏画出来 | 真正那页数据由服务端给 |
「你会发现每一行的左列和右列,做的是同一件事的两个版本。」老陈说,「一个为了手感,一个为了正确。」
小迪:「那不是重复劳动吗?」
「是重复的,而且这个重复值得付。」老陈说,「因为这两个目标从根上就不一样:左边要的是这一秒的体验,右边要的是永久的正确。你不可能用左边那个去替代右边那个——左边那份代码在用户机器上,用户随时能让它不生效。」
框架已经把这条线画在地上了
老陈把项目目录投到屏幕上:
controller/ # 页面路由,只返回 HTML
controller_api/ # API 路由,路由以 /api/ 开头,只返回 JSON
controller_sse/ # SSE 流式路由
view/ # Blade 模板
domain/knowledge/ # 复杂业务逻辑下沉到这里
public/assets/ # 静态资源(nginx 直接返回,不进 PHP)
「这套框架的文档里有一句话:入口决定响应格式。你顺着目录就能看出来,它在用物理位置给『逻辑归谁』划边界。」
controller/的闭包只能返回字符串(HTML)。不小心返回了数组,框架直接抛错:页面路由必须返回字符串(HTML),实际返回:array——「它不让你在这里吐出 JSON,因为页面入口的职责就是把页面交出去。」controller_api/的闭包返回什么都会被包成{"code":0,"msg":"","data":...}——「接口是给程序读的,格式必须固定,前端只看code。」controller_sse/用生成器逐段吐数据:
sse_route('/echo', function ($params) {
$text = $params['text'] ?? 'hello';
foreach (str_split($text) as $char) {
yield $char;
usleep(1000000);
}
yield true; // 流结束
});
「长连接和普通请求不是一类东西,所以它也单独开了一个目录。」
view/只负责渲染。「模板里出现的{ { $user->name }}是把已经算好的结果摆上去,不是在这儿算。模板里一旦开始写业务判断,你这个页面就没法被第二个入口复用了。」- 复杂逻辑下沉
domain/knowledge/,取数走 DAO,不在路由闭包里直接写 SQL。「这条规矩的作用跟今天的话题一样:让『这段逻辑在哪儿』变成一个能一眼看见的问题,而不是散在几十个文件里。」
「还有一道门得单独说。」老陈滚动到全局拦截器,
if_verify(function ($action, $args) {
$user = get_current_user();
if ($user->is_null()) {
redirect('/login');
}
return $action;
});
「这是所有请求的统一入口。鉴权这种事只能在这儿做,因为它是用户碰不到的一层。页面上的『不显示按钮』永远只是顺手,不是防线。」
小迪问:「那我前端藏起来的那些字段呢?比如管理员的 user_id。」
「你可以藏,但服务端不能信。前端传上来的任何字段,都可以是别人编的。服务端只相信两样东西:自己的数据库,和已经验过的登录态。」
三个最常被放错位置的例子
一、金额。
前端:只提交数量,最多顺手显示个估算。
服务端:拿库里价格重算,不接受任何来自前端的钱。
「上面那个 curl 把 price 传成 0.01,就是这一类事故的标准开场。」
二、权限。
前端:不显示管理按钮,管理员看到的菜单也不一样。
服务端:每个接口自己判身份,判不过就 PERMISSION_DENIED。
「我见过把管理接口做成『只有管理员能打开的页面』的。页面是只有管理员能打开,接口不是。」
三、防重复提交。
前端:点下去立刻置灰。
服务端:幂等,同一个业务键来两次只落一次单。
「只做前端那个『置灰』,用户手快点了两次、或者网卡了重发一次,你就多了一张订单。前端那层只是不让用户手滑,服务端那层才是不让系统出错。」
反过来:这些别往服务端塞
小迪问了个好问题:「那有没有反过来放错的?」
「有,而且很常见——把纯交互的事做成了接口。」
下面这些,交给页面就好:
- 输入框字数统计、「密码强度」这种实时提示
- 展开收起、Tab 切换、弹窗关闭
- 还没提交时的表单格式提醒
- 按钮的禁用/加载态
- 列表滚动到哪儿、哪一项高亮
判据很简单:它只影响这个人的这次操作,不影响任何数据。
「你要是把『输入框里数了几个字』做成接口,会有两个后果:用户每敲一下发一次请求,以及你多了一堆永远不会有人看的日志。」
收尾:小迪复述一遍
「来,你自己说。」老陈把笔递过去。
小迪看着板子:
「拿不准的时候问四句。用户能不能绕过它?算它要不要现查数据?算错了谁负责?我要的是快还是对? 只要有一句指向服务端,就写服务端。」
「嗯。」
「页面上的校验、置灰、隐藏按钮,都是为了让人少犯错,不是为了不出错。真正拦人的那道门在服务端。」
「还有呢?」
「金额、库存、订单状态、权限,这几个东西全系统只能有一个地方说了算,就是数据库和它的服务端。前端算出来的数只是预览,提交的时候只交『我做了什么』,不交『我算出来是多少』。」
老陈点头,把咖啡喝完:「你再看一眼你那个购物车。」
小迪改完,回来贴了两段代码。
// 页面:只提交意图
fetch('/api/cart/add', {
method: 'POST',
body: JSON.stringify({
id: product.id, count: 1 })
});
// 服务端:自己查价、自己判库存、自己算钱
if_post('/api/cart/add', function () {
$product = dao('product')->find(input_json('id'));
$count = input_json('count');
otherwise_error_code('INVALID_PARAM', $product->is_not_null() && $count > 0);
otherwise_error_code('INVALID_PARAM', $product->stock >= $count, ['param' => 'count']);
return cart::create($product->id, $count, $product->price);
});
阿杰在旁边摸鱼五分钟,看完了:「我有个挺土的判断标准——这行代码要是被删掉,会不会有人因此亏钱?会,就往上挪一层。」
「差不多是这个意思。」老陈说,「前端是给人看的,服务端是算数的。分不清的时候就问一句:这事要是被绕过去,谁吃亏。」
附:一页速查
| 逻辑 | 写哪 | 一句话理由 |
|---|---|---|
| 金额、折扣、运费、合计 | 服务端 | 前端能改,改一次就亏一次 |
| 库存判断、扣减 | 服务端 | 必须唯一权威 |
| 订单状态流转 | 服务端 | 状态机不能有第二个入口 |
| 登录态、权限校验 | 服务端(if_verify + 接口内显式判) |
前端碰不到这层 |
| 手机号/邮箱格式校验 | 两边都写 | 前端为提示,服务端为正确 |
| 按钮禁用、加载态 | 页面 | 只影响手感 |
| 表单实时字数、强度提示 | 页面 | 纯交互 |
| 展开收起、Tab、动画 | 页面 | 不碰数据 |
| 分页、排序、聚合 | 服务端 | 少搬原料,少暴露全量 |
| 防重复提交 | 两边都写 | 前端防手滑,服务端保幂等 |
| 唯一 ID 生成 | 服务端(Redis INCR) |
前端拿不到也伪造不了 |
| 乐观锁版本比对 | 服务端 | 要事务保证 |
想试一下这条线怎么落地?
git clone https://github.com/smarty-kiki/php-vibe-coding-frame my-project && cd my-project
sh project/tool/start_development_server.sh # 需要 Docker
打开 http://localhost,然后翻 controller/、controller_api/、controller_sse/ 三个目录——这三个入口的差别,就是这条线在代码里的样子。