社群团购系统类快团团源码开发——工程化(上):一套让 5 人小团队不出事故的开发规范
规范的失败方式只有一种:靠人肉执行
上一篇我们把权限模型收了口。代码骨架、登录态、权限体系都齐了,接下来是一个大多数团队"知道重要、但从来做不好"的话题——开发规范。
先给结论:规范不是一份文档,是一组可执行的约束。 文档写再厚,只要"遵守"和"不遵守"的结果一样,它三个月后一定变成摆设。我见过太多团队的规范文档停在 v1.0,最后一条 commit 规范执行的记录是半年前。规范要活下来,只有一个办法:把每条规则变成机器卡点——编译期拦截、CI 流水线拦截、Code Review 强制清单,让"不合规的代码"根本进不了主干。
还有一个反直觉的事实要先摆出来:5 人小团队的事故,90% 不是高并发、不是架构问题,而是低级错误——金额用了 double、回调没做幂等、查询没校验数据归属(越权)、事务边界画错。这类错误有一个共同点:每一类都可以用一条机器规则拦住。所以这套规范的裁剪逻辑非常功利:只保留"违反即事故"的规则,其余一律砍掉。
本篇讲规范的前半部分:代码规范裁剪、目录纪律、Git 分支与 commit 规范、Code Review 清单、API 先行、CI 卡点。测试策略单独立成下一篇,因为篇幅和它的重要性都配得上独立一篇。
1. 规范裁剪:阿里 Java 规范的 30% 落地版
《阿里巴巴 Java 开发手册》是好东西,但全文 40+ 章节直接照搬到 5 人团队,等于没有规范。我们的做法是只裁出违反即事故的那部分,其余让 IDE 默认处理。
1.1 保留清单与裁剪清单
| 类别 | 保留(违反即事故) | 裁剪(交给 IDE / 不值得卡) |
|---|---|---|
| 金额 | 禁用 float/double 存金额;禁用 new BigDecimal(double);金额一律 long(分)或 BigDecimal |
金额变量命名格式(priceSupply 已够用) |
| 事务 | @Transactional 必须显式指定 rollbackFor = Exception.class;事务方法内禁止远程调用 |
事务方法命名前缀约定 |
| 并发 | 禁止 SimpleDateFormat 静态共享;共享可变状态必须显式标注并发策略 |
线程池命名格式 |
| SQL | 禁止 SELECT *;UPDATE/DELETE 必须带 WHERE;批量操作必须限流分批 |
索引命名统一为 idx_/uk_(建表模板已固化) |
| 安全 | 手机号等敏感字段禁止日志明文输出;SQL 必须参数化,禁字符串拼接 | 注释覆盖率 |
| 注释 | 公开的资金规则方法必须有说明"为什么这么算" | 所有的 Javadoc 模板要求 |
裁剪的原则可以说成一句话:机器能拦的进 Checkstyle,机器拦不住的进 Review 清单,都拦不住的说明不值得立规矩。
1.2 Checkstyle 配置示例
只上真规则,配置越短越好维护。节选我们实际使用的 checkstyle.xml:
<module name="Checker">
<!-- 金额禁用 double/float:拦截字段声明与变量类型 -->
<module name="RegexpSinglelineJava">
<property name="format"
value="(?:float|double|Double|Float)\s+(?:(?!rate|ratio|percent)\w)*(?:amount|price|fee|commission)\w*"/>
<property name="message"
value="金额字段禁止使用浮点类型,请使用 long(分为单位)或 BigDecimal"/>
</module>
<!-- 拦截 new BigDecimal(double) 的经典精度陷阱 -->
<module name="RegexpSinglelineJava">
<property name="format" value="new\s+BigDecimal\s*\(\s*[\d.]+"/>
<property name="message" value="禁止 new BigDecimal(double),请使用 BigDecimal.valueOf() 或字符串构造"/>
</module>
<!-- 拦截 SimpleDateFormat 静态共享(线程安全) -->
<module name="RegexpSinglelineJava">
<property name="format" value="static.*SimpleDateFormat"/>
<property name="message" value="SimpleDateFormat 非线程安全,禁止静态共享,请使用 DateTimeFormatter"/>
</module>
<!-- SQL 拦截:SELECT * -->
<module name="RegexpSinglelineJava">
<property name="format" value="select\s+\*\s+from"/>
<property name="ignoreCase" value="true"/>
<property name="message" value="禁止 SELECT *,请显式列出字段(配合 MyBatis-Plus 的字段缓存)"/>
</module>
</module>
注意第一条正则里排除了 rate/ratio/percent——佣金比例确实用基数字(如 1500 表示 15%)存 int,但命名里带 rate 的字段多为比例而非金额,误报会摧毁团队对规范的信任。卡点规则的误报率必须控制在几乎为零,否则大家学会的第一件事是绕过卡点。
2. 目录纪律:把第 3 篇的模块纪律固化成代码
第 3 篇定过三条模块纪律:跨模块只走 Service 接口、资金规则只在 domain 层、依赖无环。口头约定守不住,我们用 ArchUnit 把它变成单元测试,跑在每次 CI 里:
@AnalyzeClasses(packages = "com.gbs")
class ArchitectureTest {
// 纪律一:commission 模块禁止依赖 order 模块的内部实现,只准调对方暴露的 Service
@ArchTest
static final ArchRule crossModuleAccess =
noClasses().that().resideInAPackage("..commission..")
.should().dependOnClassesThat()
.resideInAPackage("..order.domain..", "..order.mapper..");
// 纪律二:资金规则只允许出现在 domain 层——controller/service/job 出现金额计算类即失败
@ArchTest
static final ArchRule moneyOnlyInDomain =
classes().that().haveSimpleNameEndingWith("Calculator")
.or().haveSimpleNameEndingWith("SettlementRule")
.should().resideInAPackage("..domain..");
// 纪律三:模块依赖无环
@ArchTest
static final ArchRule noPackageCycle =
slices().matching("com.gbs.(**)").should().beFreeOfCycles();
}
这段测试的价值在第三条规则暴露得最充分:某次需求里,图省事的同事在 order 模块里 import 了 commission 的一个枚举,两周后 commission 又反过来引了 order 的工具类——环在没人察觉时闭合。ArchUnit 在 CI 里直接红了,重构成本是十分钟;如果等到要拆分模块时发现环,成本以月计。
3. Git 分支模型:小团队别照搬大厂
5 人团队上完整的 GitFlow(develop/release/hotfix 多长生命分支)是自杀行为——合并成本吃掉产能。我们的分支模型只有三条:
main # 可发布,受保护,只接受 PR 合入
├── feature/GB-142-commission-snapshot # 功能分支,生命周期 ≤ 3 天
└── fix/GB-155-idempotent-callback # 修复分支,同上
三条纪律:
- 分支命名带任务号。
feature/xxx里那个GB-142是任务系统编号——半年后排查"这行代码当时为什么这么写",commit 能反查到当时的讨论,这是唯一的原因追溯链; - 功能分支生命周期不超过 3 天。超过 3 天说明任务切太大,强制拆分。长生命分支是合并冲突的唯一来源,小团队没有任何理由养它;
- main 分支保护 + 线上 hotfix 也走 fix 分支,禁止直接 push。发布打 tag(
v1.4.0),回滚回滚到上一个 tag——部署细节留到第 25 篇展开。
commit message 用 Conventional Commits 的裁剪版,同样进 CI 卡点(commitlint):
// commitlint.config.js —— 只留 4 种类型,够用
module.exports = {
rules: {
'type-enum': [2, 'always', ['feat', 'fix', 'refactor', 'chore']],
'subject-max-length': [2, 'always', 40],
'subject-empty': [2, 'never'],
},
};
只留 feat / fix / refactor / chore 四种类型。多团队协作时 type 用于自动生成 changelog 才有价值,5 个人生 changelog 不如看任务系统,所以规范服务的目标要清楚:commit 规范在我们这里只服务于"半年后看懂历史",不服务于流水线自动化。
4. Code Review:一张清单,一票否决
5 人团队的 Review 不是仪式,是事故的最后防线。我们的清单只有 12 条,按"资金 > 幂等 > 越权 > 其他"排序,前 5 条一票否决:
| # | 检查项 | 级别 |
|---|---|---|
| 1 | 金额计算是否全部落在 domain 层、且基于快照而非当前主数据? | 一票否决 |
| 2 | 所有写操作是否有幂等保障(唯一索引 uk_biz_serial / 幂等 token)? |
一票否决 |
| 3 | 查询与写操作是否校验了数据归属(帮卖只能看/改自己的单,防水平越权)? | 一票否决 |
| 4 | @Transactional 边界是否覆盖了"价格快照 + 佣金快照 + 订单落库"三件事? |
一票否决 |
| 5 | 微信回调、定时任务重跑、消息重复消费,三条路径是否都重放了幂等? | 一票否决 |
| 6 | 新增 SQL 是否走索引、是否会在大表上产生全表扫描? | 必须修 |
| 7 | 外部调用(微信 API)是否包了防腐层、是否设置了超时与重试上限? | 必须修 |
| 8 | 日志是否带上下文(订单号/traceId)、是否泄露敏感字段? | 必须修 |
| 9 | 异常是否被吞掉(空 catch、catch 后只打日志不抛)? | 必须修 |
| 10 | 新增状态是否进了状态机迁移表,而不是散落的 if/else? | 必须修 |
| 11 | 命名是否与限界上下文一致(同一概念在两个模块里名字不同 = 边界在烂)? | 建议修 |
| 12 | 是否存在"顺手优化"(无关文件的格式化、重命名)? | 建议修(禁止混入) |
第 12 条值得单独说:PR 里混入顺手优化,是 Review 质量崩坏的开始。 Reviewer 看到 500 行 diff,前 300 行是格式化,真正要审的 200 行反而没人细看。格式化要么单独提 PR,要么配 spotless 进 CI 自动做——总之不能混在功能变更里。
5. API 先行:OpenAPI 契约驱动联调
五端共用一个后端(第 3 篇的架构决定),前后端联调成本是这里的隐性大头。我们的做法是契约先行:接口先用 OpenAPI 文档定义并评审,前后端各自基于契约开发,后端用契约做接口测试。
流程上只有三步:
- 后端在任务系统里提交接口设计(OpenAPI YAML 片段),前端在一个工作日内提出字段异议,评审通过即冻结为契约;
- 前端基于 YAML 生成 TypeScript 类型与 mock 服务,不等后端;后端基于 YAML 实现接口;
- 后端用 springdoc 导出的文档与契约做 diff,字段不匹配 CI 直接失败。
一个团购创建接口的契约片段:
POST /api/leader/groupbuy/create:
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [title, endTime, stockMode]
properties:
title:
type: string
maxLength: 64
endTime:
type: string
format: date-time
description: 截单时间,必须晚于当前时间 1 小时
stockMode:
type: integer
enum: [10, 20]
description: "10=限量库存 20=预售不限量(截单后按订单量要货)"
responses:
"0":
description: 返回团购 ID
"41001":
description: 截单时间非法
两个必须坚持的细节:错误码进契约(前端按 41001 这类业务码做交互分支,而不是解析中文 message);DTO 按端隔离(/api/leader/ 与 /api/c/ 的出入参对象禁止复用——第 3 篇讲过,这是"改一个小程序字段、PC 端莫名报错"事故的根源)。
契约的第三个细节是演进规则——契约冻结不等于永远不改,改要守兼容三原则:字段只增不改不删(改语义和删除都是破坏性变更,小程序端旧版本还在线上跑着,用户没有"全部升级"这回事);新增字段必须可缺省(旧客户端不传新参数,服务端要有默认行为,而不是报错);破坏性变更走新路径(/api/v2/...),新旧并行一个审核周期后再下线。小程序的发布天然有微信审核期缓冲,这反而要求后端的兼容窗口比 Web 更长——Web 服务发完版客户端立刻是新的,小程序用户手里可能是三个月前的版本。这三原则写进了契约评审的 checklist,CI 的 contract-diff 会拦截删字段和改类型的 diff。
6. CI 卡点:把所有规则串成一条流水线
前面所有规则的执行者,是一条 6 步流水线。节选 GitHub Actions 配置(Jenkins 逻辑完全一致):
name: ci
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build & Static Check # 编译 + Checkstyle,红即止
run: mvn -B verify -DskipTests
- name: Unit & Integration Tests # 含 ArchUnit 架构测试
run: mvn -B test
- name: Coverage Gate # 资金模块 80%,其余 60%(下一篇展开分配逻辑)
run: mvn -B jacoco:check -pl gbs-domain,gbs-commission
- name: Commit Lint
run: npx commitlint --from origin/main --to HEAD
- name: OpenAPI Contract Diff # 实现与契约比对
run: ./scripts/contract-diff.sh
流水线的排序有讲究:越便宜的检查越靠前。Checkstyle 秒级,放最前面;集成测试分钟级,放中间;契约 diff 依赖环境,放后面。目标只有一个——开发者收到失败反馈的时间最短,修规则的意愿才最强。
7. 接口规范的两块基石:统一响应与错误码分段
CI 卡点管住"代码怎么写",还有一类事故藏在"接口怎么约定"里。五端共用一个后端(第 3 篇的架构),接口约定不统一,前端联调成本会指数级上升,排障时更会互相甩锅。两条必须提前定死的约定:
统一响应体:所有接口返回 { code, message, data } 三段结构,code = 0 成功,非 0 一律由前端按业务码分支处理。业务异常统一走 BizException,由全局异常处理器兜底转成结构化响应——前端永远拿不到裸 500 堆栈:
/** 统一业务异常,携带分段错误码;所有 Service 抛出,Controller 不 try-catch */
public class BizException extends RuntimeException {
private final int code;
public BizException(ErrorCode ec, Object... args) {
super(ec.format(args));
this.code = ec.getCode();
}
}
// 抛出示例:库存不足
throw new BizException(ErrorCode.STOCK_NOT_ENOUGH, sku.getName());
错误码分段:0 成功;4xxxx 客户端错误(参数非法、权限不足、库存不足);5xxxx 服务端错误;7xxxx 第三方错误(微信支付回调失败、内容安全审核超时等)。分段的价值在排障现场:值班同学凌晨看到告警里是 70xxx,第一反应是查微信侧状态而不是翻自己的代码——错误码分段本质上是把"故障域归属"编码进了错误信息,5 人团队没有专职运维,这个信息差就是处理速度。
顺带一条与日志相关的纪律,它属于"机器拦不住但必须立"的规矩:每个对外接口的异常日志必须带上下文(订单号、用户 ID、traceId),traceId 由网关生成、贯穿整条调用链。售后纠纷最经典的场景是"用户说付了钱没订单"——拿一个订单号查出 traceId,整条链路一屏拉完,这是唯一的救命稻草。我们把它做进了 Review 清单第 8 条,也做了正则卡点拦截"没有 MDC 上下文的 error 日志"。
最后回答一个常见质疑:"5 个人有必要搞这么重吗?"——这套流水线从写到稳定运行花了两天半,之后每天替团队拦下的低级错误以个位数计。规范的建设成本是一次性的,而低级事故的成本按次收,且复利。 真正重的是测试策略,那是下一篇的话题。
8. 本篇小结
- 规范 = 可执行的约束——文档保证不了规范存活,CI 卡点可以;裁剪原则是只保留"违反即事故"的规则;
- 阿里规范裁剪 30% 落地——金额禁浮点、事务显式 rollbackFor、SQL 禁 SELECT *,全部进 Checkstyle,且误报率必须趋近于零;
- 模块纪律用 ArchUnit 固化——跨模块只走接口、资金规则只在 domain、依赖无环,三条纪律在 CI 里自动验证;
- 分支模型做减法——main + 短生命功能分支,commit 规范服务于追溯历史而非自动化;
- Review 清单 12 条,前 5 条一票否决——资金、幂等、越权、事务边界、三条幂等重放路径;PR 禁止混入顺手优化;
- API 契约先行——OpenAPI 冻结后前后端并行,错误码进契约,DTO 按端隔离;
- 错误码分段 = 故障域归属编码——4xxxx/5xxxx/7xxxx 让值班 10 秒定位该查谁;异常日志必带订单号与 traceId。
规范立完了,但"不出事故"的另一半是测试。下一篇《工程化(下):测试策略——资金模块 100% 覆盖,其他 60% 就够》——讲清楚测试资源怎么分配、佣金计算的 12 个边界用例怎么写、以及为什么我们放弃了 H2 而用 Testcontainers。
浙公网安备 33010602011771号