Project B / XULIU

叙流

采购与销售台账 · 夕照

云裳进销存 · 服装/鞋帽网店

预览版

案例整理于

进入系统

面向服装零售与批发的多端进销存:色码矩阵与 SKU、采购与库存、销售与拣货发货、财务总账与结算、报表与数据大屏,再加一层需要签字回执的 AI 自然语言操作。测试是真门槛——1204 条用例、87.55% 行覆盖。

问题与约束

服装和鞋帽是进销存里最别扭的一类:同一款衣服有颜色有尺码,进货按色码矩阵进,出货按单只 SKU 出,缺码和压货同时发生;再叠加网店节奏——618 和双11 冲量、春节停摆,账要能按月结。业务上的这些麻烦,最后都落到几个技术问题上:库存怎么记才不会超卖、多端字段怎么才不会各写一套、多租户的数据怎么隔离。

另一条约束来自「一个人」:Web、微信小程序、Android、桌面四端都要有,后端、契约、测试、部署也都要有。所以这个系统的每个选型都在问同一件事——哪些东西一个人做了以后能自动守住,哪些东西一旦靠人记得就一定会漏。

  • 色码矩阵:商品与 SKU 两层,采购按矩阵、出货按单只,允许分批入库与部分作废。
  • 多端共用一套接口契约,字段漂移必须被机器发现而不是被人发现。
  • 多租户:默认租户与演示租户两套数据,跨租户既不能读到也不能被枚举。
  • 单人开发:任何需要「大家都记得加」的约束都视为不可靠,尽量下沉到数据库或门禁。
  • Windows 中文路径 + Gradle 的组合不可信,构建路径必须是 ASCII。
云裳进销存的工作台:左侧十个导航组,右侧待办驾驶舱与手绘 SVG 图表。
工作台 · 待办驾驶舱

我具体做了什么

从领域建模到部署全部一个人做:术语表与十条核心不变量(后来又加到十六条)、29 支 Flyway 迁移、Spring Boot 模块化单体与模块边界规则、OpenAPI 契约与本地门禁脚本、Web 端 58 个视图与 10 个路由模块、uni-app 一套出 H5/小程序/Android、Electron 桌面壳、MCP stdio 适配器、2322 行只走真实接口的造数脚本。

测试是这套东西的地基而不是附属品:Testcontainers 起真 PostgreSQL、jqwik 属性测试、ArchUnit 架构测试、JaCoCo 覆盖率门禁、Playwright E2E。V2 收尾那次合并验收是 1204 条用例全绿、行覆盖 87.55%(9344/10673)、分支 68.57%。

前端

  • Vue 3.5
  • TypeScript
  • Vite 6
  • Element Plus 2.9
  • Pinia
  • 手绘 SVG 图表(零图表库)
  • uni-app
  • Electron

后端与数据

  • Java 17
  • Spring Boot 3.3.5
  • PostgreSQL 16 + Flyway
  • JWT + Spring Security
  • OpenAPI 契约
  • MCP stdio 适配器
进货单列表:单据号、状态、供应商、仓库、业务日期、单据数量与入库进度,操作列按状态给出详情、入库、审核通过、驳回、作废。
进货单 · 分批入库与状态机

关键决策,以及放弃了什么

每条决策都写清被放弃的替代方案和它当时的理由,并配一条能在仓库里查到的证据。

  1. 评估之后,主动不用 PostgreSQL RLS

    选了什么 隔离强制落在 Java 层:把租户谓词注入 SQL 文本,并且只有一个出口(带租户感知的 JdbcTemplate),于是新写的 SQL 自动带谓词,而不是靠人记得加。

    放弃了什么 放弃「最省事的那一层」——RLS 对子查询、JOIN、聚合、EXISTS 全都生效,看起来是白捡的隔离。实测装好策略并 FORCE 之后,超级用户依然绕过它,跨租户 select 依旧全表;而测试容器里应用正是以超级用户连接。结果是「策略已装、用例全绿、隔离零生效」同时成立。半开 RLS 比不开更糟:策略解析成默认租户时,第二个租户会被自己的策略过滤成空结果,症状看起来像「没数据」。

    证据 V16 迁移末尾专门写了一节「RLS 评估结论(不启用,理由见下)」,全仓没有任何 CREATE POLICY 语句;配套的 135 格跨租户矩阵(9 资源 × 5 读法 × 3 身份)里每格都用真实存在但属于另一租户的 id,并配同租户正向对照——否则「接口坏了」也会全 404,同样是一条会骗人的断言。

  2. 越权一律 404,不用 403

    选了什么 跨租户或超出数据权限的读取,返回 404 或空列表,响应体里不带任何「你无权限」的语义。

    放弃了什么 放弃 403。403 等于承认这个 id 存在,攻击者可以按 id 遍历枚举整个 id 空间;而且「无权限」和「不存在」在响应上可区分,本身就是信息泄露。

    证据 数据权限矩阵测试把这条写成硬断言(120 组合:8 资源 × 5 越权方式 × 3 身份),并且专门有一格用不存在的 id 做对照,用来证明 404 来自隔离而不是来自接口坏了。

  3. 库存以流水为事实源,余额只是派生快照

    选了什么 流水表结构上宣告不可变(没有 updated_at,也没有删除位),余额表由流水推导;两边不一致时以流水重算。

    放弃了什么 放弃「只用余额字段」——无法追溯也无法对账;也放弃「只用流水实时聚合」——查询性能不可接受。

    证据 不变量「实存 == 流水净和」是一个可调用的方法,多条集成测试在断完业务结果之后再断它;流水表还有一条 append-only 触发器,改和删都会被拦。

  4. 防超卖交给数据库,而不是交给「先查后写」

    选了什么 扣减用带条件的原子 UPDATE(条件不满足就影响 0 行),数据库层再加 CHECK (on_hand >= 0) 兜底;幂等交给 PostgreSQL 唯一索引。

    放弃了什么 放弃「先查可用量再决定扣不扣」这种读起来更顺的写法——两个请求同时读到同一份余额时就超卖了。也放弃在应用层做幂等表判定:进程重启与否不该影响判定结果。

    证据 100 个线程抢 50 件库存的集成测试断言成功数恰好 50、被拒 50、可用量归零、ACTIVE 预占记录恰好 50 条;另有两条用例直接构造违规 UPDATE,断言被数据库抛回应用。

  5. 金额一律整数「分」,折扣一律万分比

    选了什么 所有金额列是 BIGINT 分,舍入集中在一处,先算行再汇总。

    放弃了什么 放弃 DECIMAL(18,2)——它在库里精确,但过 JSON 与前端之后仍然要做精度转换;也放弃「浮点加误差容忍」,对账要求差额严格为 0,一旦允许容忍就没法区分「平的」和「凑平的」。

    证据 金额列建表时就带 CHECK 非负;成本守恒用属性测试跑随机流水序列验证,而不是靠几条固定用例。

  6. 模块化单体,不做微服务

    选了什么 一个进程内分模块,模块之间只走应用服务接口,禁止跨模块访问表,并用架构测试强制。

    放弃了什么 放弃「拆成 8 个微服务看起来更像大型系统」——分布式事务、服务发现、部署编排的成本一个人扛不动;也放弃放任成大泥球。

    证据 架构测试有 13 条规则,另外还有 7 条「规则自检」用例:把违规代码在内存里现场编译,断言规则确实会变红。这条是被坑出来的——之前有两条规则完全失效(一条把条件反向解释,一条因为方法全名带括号而永远匹配不上),规则绿了但什么都没守。

  7. OpenAPI 是单一事实源,契约双向核对

    选了什么 契约文件驱动:CI 里 diff 不一致就失败,SDK 由契约生成,本地也有一条门禁脚本。

    放弃了什么 放弃手写 API 文档(必然腐化)、放弃「只看代码不写契约」、放弃各端手写 HTTP 客户端。

    证据 有用例专门断言「契约声明与实现消费两个方向都必须对上」,还有用例把「契约里的豁免清单保持为空集」当成断言——一旦有人为了变绿偷偷加豁免就会失败。

  8. Windows 中文路径:改环境,不改项目位置

    选了什么 保留中文目录,建一个 ASCII 目录联接作为官方构建路径。

    放弃了什么 放弃 -Dsun.jnu.encoding=UTF-8 系列绕法——jvmArgs、gradle.properties、JAVA_TOOL_OPTIONS 三种都实测无效,Gradle 会过滤掉这个属性;放弃 chcp 65001;「把项目整个搬到英文目录」保留为备选,但它改变了用户的目录组织。

    证据 决策记录里留着实测对照表:纯 ASCII 通过、含空格通过、含中文失败(症状是 ClassNotFoundException,看着像没编译,实为编码不一致)。

库存查询界面,同一件商品同时给出实存、预占、可用三个口径。
库存查询 · 实存 / 预占 / 可用

可度量的结果

下面区分两类数字:一类我现在就能在仓库里当场复核(覆盖率来自 JaCoCo 的 XML,条数来自源码统计),一类是某个时点的验收快照,标注清楚时点,不拿旧数字冒充当前。

后端用例 / 行覆盖 / 分支覆盖
1204 条 · 87.55% · 68.57%
JaCoCo XML 里 LINE covered=9344 missed=1329、BRANCH covered=2686 missed=1231;时点是 V2 收尾合并验收(2026-10-25)
当前用例标记数
831 条 @CaseId
统计 backend/server/src/test 下的 @CaseId 注解;生成的用例索引表 825 行
接口契约
202 个路径 / 260 个操作
统计 packages/api-contract/openapi.yaml 的 paths 与 operationId(2026-10-27)
数据库迁移
29 支 Flyway 脚本
统计 db/migration 目录文件数,V1 到 V28 含 V19.1
前端规模
58 个 Web 视图 / 10 个路由模块 / 21 个移动端页面
统计 views 下 .vue、router/modules 分组数、uni-app 的 pages.json
隔离与越权矩阵
135 格 + 120 格
9 资源 × 5 读法 × 3 身份;8 资源 × 5 越权方式 × 3 身份。矩阵规模本身有一条自检断言「一格都不能少」
并发扣减验证
100 线程抢 50 件,成功恰 50
集成测试用例 TC-INV-006,双闸门起跑,断言成功数、被拒数、可用量、ACTIVE 预占数
决策记录
11 条 ADR,每条都写了放弃的方案
docs/07-决策记录ADR 目录

用例数与覆盖率会随批次增长:1204 是 V2 收尾的验收快照,之后每个批次报告里都有增量。写简历或面试时按「时点 + 口径」引用,不要只报一个数。

商品与 SKU 界面,按颜色与尺码展开成矩阵。
商品 · 色码矩阵
移动端库存页的高保真原型截图,窄屏布局。
移动端 · 高保真原型

踩过的坑

  1. 预占的货反而发不出去

    出货按效期先出拆批扣减时,代码用「可用量」判断这一批还能不能扣。但预占这件事本身就是把可用量扣成 0 的动作——于是自己把自己锁死:明明货在仓库里,系统判定无货可发。是 FEFO 拆批次出库那条集成测试在 M0 阶段真实抓出来的,不是靠读代码发现的。

    改成按预占量判断可扣减量,并单独找一遍「已预占」的候选批次。修复的理由直接写在方法注释里,防止后来人又「顺手改回可用量」。

    InventoryApplicationService 的注释 + 用例 TC-INV-009(同一条测试现在同时守着修复前后的两种语义)

  2. 合法拆批被当成重复提交

    一笔出货按效期先出拆成多个批次,就会写出多条流水;而幂等唯一索引原本是 (request_id, biz_type),第二条合法流水直接撞约束,被系统判成重复提交。这个缺陷只有跑真 PostgreSQL 才暴露——换 H2 就看不到部分唯一索引与真实冲突行为。

    索引把 batch_no 加进去,并在迁移注释里写清「为什么必须带批次号」:合法拆批各自成立,重复提交同一批次仍然被拦。幂等键的粒度必须等于业务上「一次不可分割的动作」。

    V1 迁移里 uq_ledger_request 的定义与它上面那段注释

  3. 架构测试自己失效了,而且绿得很干净

    两条 ArchUnit 规则完全没在守东西:一条把自定义条件的真假反向解释,另一条按不带括号的方法全名去比对带括号的名字,永远匹配不上。规则一直是绿的,模块边界一直是漏的。

    门禁必须能变红。现在除了 13 条规则,还有 7 条「规则自检」用例:把一段违规代码在内存里现场编译出来,断言规则确实会失败。

    docs/00 索引文档的 M1 缺陷表第 8 条 + 架构测试里的自检用例

  4. 一条「零副作用」断言,其实什么都没断

    在做漂移注入时,把审计切面里的一行属性写入注释掉之后,那 25 条幂等测试仍然全绿。原因是断言按「特定 action 且 result=SUCCESS 的行数」计数,而兜底逻辑在缺属性时补的是 result=FAIL 的行——谓词太宽,静默绕过。

    「没有副作用」这类断言要数全行、要显式断「不存在任何一行 FAIL」,否则它就是一条看起来最严格其实最松的断言。补了一条新用例并自查了其余同类断言。

    docs/00 索引文档 v2.11 验收记录里的漂移注入实验

  5. 并发测试把自己锁住了

    100 线程抢库存的用例里,线程池开得比线程数小,排在队列里的任务永远到不了起跑线,双闸门之一超时——症状像「被测代码有并发问题」,其实是测试自身的缺陷。

    并发用例的线程池必须 ≥ 并发数,这条注释现在直接写在那段测试代码上方。

    InventoryReserveIT 里 TC-INV-006 上方的注释

进入真实系统 / Access

线上预览已就绪

预览地址已配置,可直接在新窗口打开。

演示账号

  • demo / Demo@12345

    演示访客

    只读:工作台、全局搜索、库存流水、商品与进货/出货查询;写操作与审计日志、登录日志、用户管理会被拒绝(403)

这一套系统的各个端

公共演示库、数据全部虚构。演示账号刻意做成只读——写操作会改动别人正在看的数据;要看收货、发货、审批、过账这条写链路,请下载单机版自己跑一套。微信小程序还在体验版前的阶段(要过微信后台的域名白名单与上传),所以移动端入口给的是同一套代码的 H5 版。