给一台正在收钱的系统做手术
【摘要】 # 给一台正在收钱的系统做手术前几天,一个做手机连锁的老板甩给我一个 tar.gz,1.4MB,是他店里在用的进销存系统源码。系统跑在云服务器上,5 个商户、18 个账号,每天真金白银从里面过。他的原话是:"帮我看看有没有问题。"我看完的第一反应是:幸好他问了。## 第一刀:同一台机器能卖两次审查从收银开始。开单的代码先把所有扫到的串号一次性锁行,再逐行处理。问题出在去重——锁行结果放进一个...
# 给一台正在收钱的系统做手术
前几天,一个做手机连锁的老板甩给我一个 tar.gz,1.4MB,是他店里在用的进销存系统源码。系统跑在云服务器上,5 个商户、18 个账号,每天真金白银从里面过。他的原话是:"帮我看看有没有问题。"
我看完的第一反应是:幸好他问了。
## 第一刀:同一台机器能卖两次
审查从收银开始。开单的代码先把所有扫到的串号一次性锁行,再逐行处理。问题出在去重——锁行结果放进一个 Map,循环里第二遍再遇到同一个串号,Map 里拿到的还是内存里那份"在库"的旧快照,于是又插了一条明细,又记了一次销售额。设备状态只改了一次,账上却卖了两台。
这类问题单测很难覆盖,它需要"同一次请求里出现重复码"这种输入,正常前端不会传。但接口是裸的,谁都能传。
类似的问题越挖越多:
- 优惠金额没有下限,discount 填 99999 能开出一张负数订单,收款流水跟着变负
- 退货只写了一条负向流水,订单头纹丝不动,所以退货当天看板的营业额不降
- 一张已经退过货的单子还能整单作废,作废会把机器加回库存,等于退一次加两次
- 作废采购入库单回扣配件库存时,按"全公司数量最多的仓库"去扣,A 店作废、B 店扣库存
最有意思的是一个编译期和运行期都查不出来的问题。代码里有一句 `UPDATE device_code SET is_active = false`,我去 schema 里搜了一圈,`is_active` 这一列压根不存在。也就是说,作废入库单这个功能从上线那天起就没成功过,每次都在最后一步报"列不存在",然后整单回滚。写代码的人大概是先画了表结构,后来改表的时候忘了同步。
## 多租户的皇帝新衣
系统号称多租户,数据库层配了 RLS。但我翻了部署脚本:初始化的最后一步把所有表的 owner 改成了应用账号,而 PostgreSQL 的 RLS 对表 owner 默认不生效。换句话说,行级安全是摆设,隔离全靠每条 SQL 手写的 `tenant_id`。
手写就意味着会漏。我在员工管理接口里找到一个:查角色列表的 SQL 没带租户条件,任何登录用户都能看到全平台所有商户的角色。还有一处更隐蔽——用户名的唯一索引在多租户改造时被改成了"租户内唯一",但登录查询还是按用户名单值查。只要两个商户出现同名账号,登录就会随机落到其中一家。防线的形式变了,用防线的代码没跟着变。
## 上线那天,进程崩了 24 次
改动做完,打包上传。服务器上跑起来,半小时后店员开始反馈:页面一直弹"网络异常"。
我登上服务器一看,api 容器重启计数 24。
真凶是一段请求日志中间件。它注册的时候写的是 `app.use(new Logger().use)`——方法没绑定实例。平时没事,因为 `use` 里访问 `this` 的分支只在慢请求或 5xx 时才走。而新版本刚好有一个数据库权限错误制造了 500,第一个 500 一到,`this` 是 undefined,进程当场退出。Docker 拉起来,下一个请求又崩,循环 24 次。
这个 bug 在本地永远测不出来:类型检查全绿,启动正常,`/health` 返回 200。没有真实流量和真实慢请求,那段代码就是死代码。
同一个部署周期里还修了两个连带问题:用 postgres 超管账号手工执行迁移建的表没给应用账号授权,服务每次启动读 SMTP 配置都报 permission denied;另一处列表接口的总数查询复用了已经追加过分页参数的数组,报错是 `bind message supplies 4 parameters, but prepared statement requires 2`。
说到这个报错,后来它又出现了两次。一次在库存列表,一次在采购入库单,根因一模一样:有人改了列表的参数数组,总数查询还在用同一个数组,数组里已经多了 LIMIT 和 OFFSET 两个参数。第二次修的时候我以为这是最后一处,第三次出现时我给自己写了条备忘:这个项目里凡是有分页的接口,count 查询的参数必须单独构造。
## 老板开始挑毛病
Bug 修得差不多,用户开始从使用者的角度提需求。这一轮对我的冲击比修 bug 大,因为他提的东西,我一条都没预先想到。
他说登录页上有句话:"账号全局唯一,输完账号会自动带出商户名,不用记商户编码。"他问:这话给老板看有什么意义?还有员工登录后顶栏挂着"无成本权限"——谁需要知道这个?
他说后台没有门店管理。新开了店,改个门店名都得找我。
他说供应商只能在下拉里选,想加一个供应商没有入口。我查了代码,供应商模块只做了一半:采购单据到处引用它,管理接口从来没写过。
他说会员系统怎么也没了。我去翻代码,翻了个寂寞——会员相关的东西一点都没有,没表、没接口、没页面。它不是"没了",它从没存在过。我之前一直按"修 bug"的思路在工作,用户按的是"一个手机店该有什么"的清单:门店、员工、供应商、会员、商品、库存、报表。少一样,都是没做完。
这轮改动里有两处小地方值得记下来。
一是扫码查机页面。原来做了一个"把条码放进框里"的摄像头扫描动画,紫色边框加扫描线,每帧调识别。问题是这是个 PC 页面,电脑上既没有摄像头,动画还让页面明显卡顿。删掉之后只剩一句说明和自动聚焦的输入框——扫码枪扫完回车就查。
二是邮件测试。平台后台配好 SMTP,点"发送测试",页面提示已发送,邮箱里什么都没有。查代码发现后端明明返回了失败原因,前端只判断了一种降级情况,其余一律显示成功。改成先验证连接、失败原样回显错误码之后,他一次就看到了自己 SMTP 的真实报错。假成功比报错更伤人,这个教训值一次上线。
## 边用边建
用户对交互有一条很有意思的要求:开单的时候别让我先去会员管理建档再回来选,直接在收银台输手机号和名字,建完就用。
这个要求改变了后面一堆交互。收银台现在支持输手机号或姓名实时搜会员,搜不到就地新建;采购入库时供应商下拉旁边加了个"+新建",输入名字回车就创建并选中。原理很简单,以前没人这么想——所有"选实体"的控件都默认实体已经存在,可小店的现实是老板不会提前维护档案,他站在柜台前,顾客就在对面。
顺着这条线又补了一批:库存查询可以按品牌和型号筛选,导出跟着筛选走,选了华为就只导华为;任何能输串码的地方,输后五位也能搜到机器;采购入库可以一键导出明细,串码、品牌、型号、入库价都在表里;收银的收款区里,国补单独列一行——它不是顾客付的钱,不该混在微信支付宝里,但它得在对账里看得见。
## 写在最后
这单做到最后,打包发版。我让他自己在服务器上操作——备份、解压、构建、重启,整套流程写成命令发过去。
回顾这两个星期,最大的收获不是修了多少 bug,而是搞清楚了两件事。
第一,给一个正在跑的系统做手术,和从零写一个系统完全是两回事。它的历史包袱、它的命名习惯、它注释和实现之间的出入,都得先摸清再动刀。那列不存在的 `is_active`,靠读代码永远发现不了,只能去数据库里对。
第二,验收的人不是程序员。他能感知的是"这句话给我看干嘛""这个按钮在哪""为什么收款里没有国补"。代码写得再规范,界面上多一句解释系统机制的话,在他眼里就是没做完。
现在我手机里存着一条备忘,是这个项目留给我的:凡是有分页的接口,count 查询的参数必须单独构造。后面每次写列表接口,我都会先看一眼这行字。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)