「这段逻辑放前端还是后端?」——陪小迪画一条判定线

0 阅读1分钟

起因:小迪把库存判断写在了页面上

周三下午,小迪在改活动页的「加入购物车」。

她写的是这样的:

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/ 三个目录——这三个入口的差别,就是这条线在代码里的样子。