一套给实体花店用的门店管理系统:顾客端小程序下单、商家端小程序收银管库存、PC Web 端做全量管理,后端多租户 SaaS。这篇文章不讲架构有多漂亮,只讲那些真实发生过的、半夜把人叫醒的事故,和每个事故背后的机制。9 个坑,全部附根因和修法,希望你一个都别踩。
先交代背景
这套系统叫「小灵湾」,是给线下花店用的经营工具:收银、花材库存台账、损耗、会员、营销券、包月花订阅、经营看板、月报。目标用户是非互联网行业的花店老板,所以交互上抠得很细——这是另一个话题了。
技术栈和规模,直接上数字:
| 维度 | 数字 |
| --- | --- |
| Git 提交 | 989 个 |
| Flyway 数据库迁移 | 89 个 |
| 后端 Java | 508 个文件,约 6 万行 |
| 测试 | 131 个测试文件,1000+ 用例(含真库回归) |
| Web 管理端 | 91 个页面(Vue3)+ 20 个运营页面 |
| 小程序 | 2 个原生小程序(顾客端 + 商家端) |
| E2E | Playwright,29 个 spec |
后端 Spring Boot 4.0.7 + MyBatis-Plus + PostgreSQL + Flyway + Caffeine + Redis;OSS 直传和短信走自研 Go SDK;单机部署,systemd 拉起,发版全脚本化。
┌────────── 顾客端小程序 ──────────┐ ┌───────── 商家端小程序 ─────────┐
│ 下单 / 会员 / 券 / 包月花 / 社区 │ │ 收银 / 库存 / 报表 / 月报分享 │
└───────────────┬─────────────────┘ └───────────────┬────────────────┘
│ HTTPS + 微信支付回调 │
┌───────────────▼─────────────────────────────────────▼────────────────┐
│ flower 后端 · Spring Boot 4 · 多租户 · 单机 systemd │
│ 订单/库存/会员/营销/账本 · Flyway · Caffeine 本地缓存 · Redis │
└──────┬──────────────────────────────┬────────────────────────────────┘
│ │
┌──────▼───────┐ ┌───────▼───────┐ ┌─────────────────────┐
│ PostgreSQL │ │ core(Go SDK) │ │ 商家Web(Vue3) 91页 │
│ JSONB/RETURNING│ │ OSS直传 / 短信 │ │ 运营Web(Vue3) 20页 │
└──────────────┘ └───────────────┘ └─────────────────────┘
下面按"疼的程度"讲 9 个坑。前 6 个是后端的,后 3 个是小程序的。
坑 1:发版即崩,回滚也崩——bean 初始化顺序的雷
这是最戏剧性的一次。2026-10-13 生产发版,新包启动崩溃。按惯例回滚到上一个版本——旧包也崩了。
为什么回滚不是后悔药?因为旧版本当时能启动,靠的是 bean 初始化顺序恰好正确;回滚发布时重新构建旧代码,顺序又重新洗了一次牌。
根因链是这样的:
-
手机号列是密文存储,加解密走
PhoneCryptoTypeHandler,它由 MyBatis 实例化、不经 Spring 容器,密钥只能桥进静态字段; -
桥接动作原本放在
PhoneCryptobean 的@PostConstruct里; -
但
ManagerCache这类"启动期读库"的 bean,和PhoneCrypto之间没有任何依赖边,先后顺序完全随构建漂移; -
一旦缓存先跑,读到的密文列解不开,启动直接失败——同一份代码,这次构建能起,下次构建就崩。
修法是把桥接挪到比所有 bean 都早的阶段——EnvironmentPostProcessor,在 context 刷新之前把密钥灌进静态字段:
public class PhoneCryptoEarlyBridge implements EnvironmentPostProcessor {
@Override
public void postProcessEnvironment(ConfigurableEnvironment env, SpringApplication app) {
PhoneCrypto.initFromSecret(env.getProperty("app.secret-key", ""));
}
}
// 再在 spring.factories 里注册,并保留 @PostConstruct 作双保险(重复桥接幂等)
教训: 只要你的组件里存在"不经 Spring 管的静态状态",就要把它和 Spring 生命周期彻底解耦,提前到 EnvironmentPostProcessor 这种"容器出生前"的阶段。bean 顺序是构建的Implementation Detail,不是契约。
坑 2:@Select 形态的 DML,不清 MyBatis 一级缓存
发号器用了 PostgreSQL 的原子 upsert,写法是注解 SQL,形态是 @Select:
@Mapper
public interface IdSequenceDao {
@Options(flushCache = Options.FlushCachePolicy.TRUE) // ← 救命的这行
@Select("INSERT INTO t_id_sequence(name, next_id) VALUES(#{name}, 1) " +
"ON CONFLICT(name) DO UPDATE SET next_id = t_id_sequence.next_id + 1 " +
"RETURNING next_id")
Integer nextId(@Param("name") String name);
}
某天一个"未建账花材盘点"接口报 408"没有找到花材库存"——库里明明有数据。排查到最后发现另一条链路:同样的 @Select 形态 DML(带 RETURNING 的 UPDATE),MyBatis 按"查询"处理它:不清一级缓存,还会把结果放进缓存。同一个事务/同一个 SqlSession 里第二次同参调用,直接命中缓存,SQL 根本没发出去。
修法就两步:全仓扫一遍 @Select 形态的 DML,统一挂 @Options(flushCache = TRUE);再补真库回归测试锁住行为。
教训: MyBatis 判断"这是不是查询"只看注解形态,不看语义。@Select 里写 DML,缓存语义就是错的。这类代码平时跑得好好的,只在"同一会话里恰好调两次"时炸,极难复现。
坑 3:手写 SQL 不吃实体上的 typeHandler——两副面孔
MyBatis-Plus 的混合用法有个隐藏契约:实体字段上 @TableField(typeHandler=...) 只对 MP 自动生成的 SQL 生效。手写 @Insert/@Select 的话,MP 的解析器根本不经过,必须逐占位符显式挂 handler。
踩过两次,两个方向各一次:
-
手写
@Insert落一个 JSONB 字段,没显式挂 handler,PG 驱动收到AtomicInteger/实体对象直接炸; -
自定义
@Select返回带 JSONB 列的实体,autoResultMap同样不生效,必须@Results里逐列重新挂。
这类问题的恶心之处在于:单测里 mock 掉了 DAO 层根本测不出来,只有真库回归才能抓到。后来定了一条铁律:所有手写 SQL 涉及 JSONB/typeHandler 的,必须配真库测试,CI 里单独跑一组连真 PG 的用例。
坑 4:PostgreSQL 事务毒化——事务里不许 catch 唯一冲突
业务上大量场景需要"有则更新、无则插入"。早期写过这样的代码:
try {
orderStatisticalDao.insert(entity); // 拼一个不存在的唯一键
} catch (DuplicateKeyException e) {
orderStatisticalDao.updateById(entity); // 冲突就转更新
}
在 MySQL 里这是常见写法,在 PG 里是埋雷。PG 里唯一约束冲突会把当前事务打成 aborted 状态,之后这条事务里任何 SQL 都执行不了,只能回滚。你以为 catch 住转成了 update,实际上 update 也报错,而且如果外层还有 catch,错误会被吞掉,变成静默丢数据。
真实事故比这更隐蔽:微信支付回调路径里,这个异常恰好发生在落账事务提交之后,微信重推时唯一键已存在,逻辑提前返回 SUCCESS,统计增量被静默吞掉且不可自愈——对不上账才发现。
修法是一律改成数据库原子的 INSERT ... ON CONFLICT DO UPDATE,代码里永远不 catch 唯一冲突:
@Insert("INSERT INTO t_order_statistical(...) VALUES(...) " +
"ON CONFLICT(store_id, stat_date) DO UPDATE SET " +
"order_count = EXCLUDED.order_count, ...")
教训: MySQL 和 PG 的事务错误语义完全不同。在 PG 里,"catch 异常继续用这条事务"这条路基本不存在;要幂等 upsert,交给 ON CONFLICT,不要在应用层模拟。
坑 5:Spring Boot 4 + Jackson 3,原生类型收空串直接 400
升级 Spring Boot 4(带 Jackson 3)后,一批原本正常的接口开始返回 400。复现后发现规则极其简单:请求 DTO 里凡是 int/long/boolean 原生类型,前端传空串或 null,直接 400 打回;Jackson 2 时代会把空串宽容地转成 0/null。
而移动端表单里"没填的数字输入框传空串"几乎是常态,改前端是改不完的。
最终定死一条编码规约:请求 DTO 一律 Integer/Long/Boolean 包装类型 + @RequestParam 一律包装类型,金额字段用 Integer xxxFen 存分。上线前加了一道 ArchUnit 规则扫所有 *Dto,发现原生类型直接构建失败。
顺带一提:排查这类问题时复现脚本必须用 tools.jackson 的 ObjectMapper(注意不是 com.fasterxml),拿 Jackson 2 的行为推断 Jackson 3 会得出错误结论,我就这么绕过一圈弯路。
坑 6:primitive 字段的实体当 shell 用,四个时效列整行清零
这是影响面最大的一次生产事故,但代码只有一行:
storeDao.updateById(storeEntity); // storeEntity 是 new 出来的 shell,只填了 3 个字段
StoreEntity 里有四个 long(注意:原生类型)的时效列——下单时效、支付时效之类。shell 对象里这些字段是默认值 0,updateById 是整行回写(不是 selective),四个时效列全部被写成 0。
后果:0 分钟支付时效,导致所有未支付订单创建即超时取消。表面看是"新订单秒取消"的玄学 bug,根因在一行看起来人畜无害的更新代码里。同一场事故还捎带修了一个 dayjs 的坑:hh 是 12 小时制,要 HH——它把"假时差"伪装成了时效问题,误导排查两个小时。
教训: 含 primitive 字段的实体,永远不要当"部分更新"的 shell 用。要么字段全用包装类型(null 天然跳过),要么更新走显式的 UpdateWrapper/手写 SQL,要么给关键实体配一个专用 VO。
坑 7:小程序保留字——属性叫 slot,渲染层直接崩
商家端有个"配送时段选择器"组件,属性名起的是 slot(时段槽的意思)。写成 slot="{{x}}" 的一瞬间,渲染层直接崩,而且报错信息完全没有指向性(undefined is not iterable 之类),属性值静默传不进组件。
原因是 slot 是 WXML 的保留属性,框架会把它当插槽名解析。这类保留字还有 id、style、class、data-*——起组件属性名前先过一遍这个清单。后来改名 slotValue 解决,组件契约里也记了一笔。
坑 8:小程序三个高频小坑,一次说完
-
原生 input 必须定高。
input默认行高很小,配合border-box+ 纵向 padding 会塌成一条细线,placeholder 都看不见。规约:一律height: 72rpx; padding: 0 20rpx;,不接受"用 padding 撑高"的写法。 -
弹层里滚动必须用 scroll-view。 半屏弹层里的长列表,普通
view加overflow-y: scroll在真机上不可靠(配合 popup 的 preventScrollThrough 更是重灾区),规约是弹层内容区一律scroll-view,底部操作栏钉在 scroll 区域外。 -
npm 包必须子路径 import。
import Toast from 'tdesign-miniprogram/toast/index'可以,裸包名import ... from 'tdesign-miniprogram'在基础库 3.x 的新运行时下解析失败。全仓规约 + lint 兜底。
坑 9:微信审核红线——文案里不能出现"微信"二字
做小程序 SaaS 迟早撞上:接 getPhoneNumber 手机号快捷登录的页面,前置页面和 toast 文案里不能出现"微信"字样和微信 logo。"微信一键登录"这种写法审核会被打回,合规写法是"手机号快捷登录"这类中性表述。
我们的做法是把三端所有挂手机号组件的页面 grep 了一遍,把"微信"字样清干净,并在组件封装里写死合规文案,防止后来者改回去。发布前自查一条命令:grep -rn "微信" pages/ --include="*.wxml" 过一遍上下文。
工程上的几条心得
-
测试分三层,mock 层测不出的就交给真库层。 单元 1000+ 用例跑得快,但坑 2、坑 3 这类"SQL 语义"问题 mock 层完全无感,所以 DAO 涉及缓存语义/typeHandler/ON CONFLICT 的一律配真库回归;Web 端再铺 Playwright E2E(mock 后端,29 个 spec 覆盖主流程)。
-
发版全脚本化,单机也认真对待。 systemd + 一键发版脚本,Flyway 迁移随包执行。发版前的"上线前全链路安全审计"救过命——权限注解、缓存、并发写这些,自测永远测不全,换一双眼睛(哪怕是静态扫描)价值巨大。
-
数据库迁移 89 个,一个都没回滚过。 不是因为写得都对,而是每次都先想清楚"这列老数据怎么办、NULL 怎么显示",把回填写进迁移脚本本身。
最后,一点点私货
这套系统现在有真实商家在付费使用,从收银到损耗月报全流程跑通了。如果你或者你的朋友恰好开花店,可以看看「小灵湾」——官网 flower.xiaolingwan.com,完整的帮助文档在 flower.xiaolingwan.com/docs。
有问题欢迎评论区交流,坑我都踩过了,答案免费。