亮点一 · 一句话说清运费
业务问题: 客户点"确认下单"的那一瞬间,系统必须立刻报出一个准确的运费。 报便宜了,公司亏;报贵了,客户跑了。而运费还会被四端的客服反复质疑, 所以它不只是"算对",还必须算得符合行业惯例、能解释清楚。
一、先把业务规则讲成人话
在讲代码之前,先说清楚快递行业为什么要这么算。这是最容易被面试官认可的一步—— 它证明你在做业务,而不是在做翻译。
1.1 为什么不能简单地 重量 × 单价
因为快递按体积收费。
- 一台 60 寸电视,标 25kg,但它是个 1.2m × 0.7m × 0.8m 的箱子。它能塞满半个车厢,但重量只有 25kg。
如果只按重量收钱,车会被装得半空,运力白白浪费。
- 所以行业引入了轻抛系数(体积重系数):
计费体积重 = 体积 ÷ 轻抛系数,
常见系数是 5000(1m³ = 200kg)或 6000。系数越大,体积重越轻,也就是对泡货越宽容。
结论:计费重量 = max(实际重量, 体积重量)。
1.2 为什么运费要分"首重"和"续重"
因为小件要保本。第一公斤的价格里,包含了面单打印、扫码枪、称重、录入、 网点场地摊销这些固定成本。如果按比例收费,一张 1 元的单子,公司连条码纸都亏进去。
所以:首重是一个起步价,续重才是边际价。
运费 = 首重价格 + (计费重量 - 1) × 续重单价注意这个 -1:首重价格是第 1 公斤的总价,不是"每公斤单价"。 这是一个非常容易讲错、但面试官一听就知道你懂不懂的点。
1.3 为什么计费重量要"取整",而且规则还不一样
因为快递员是拿着秤和卷尺干活的,系统必须给出一个能用秤和尺当场复核的数。
| 重量区间 | 规则 | 为什么这样定 |
|---|---|---|
| ≤ 1 kg | 一律按 1 kg | 起步价,低于 1kg 也没法按秤的分度值算 |
| 1 ~ 10 kg | 四舍五入到 0.1 kg | 小件用电子秤,精度到 0.1kg |
| 10 ~ 100 kg | 向上取到 0.5 kg 的整数倍 | 中件用台秤,只能显示 0.5kg 的刻度 |
| ≥ 100 kg | 四舍五入到 1 kg | 大件用磅秤,精度到 1kg |
规则必须跟着设备走,这是业务驱动的设计,不是数学洁癖。
1.4 为什么模板要分四级
因为快递的价格是由距离决定的,而距离是分层的。行业惯例:
同城 → 最便宜(一个城市内,送货成本低)
省内 → 便宜
经济区互寄 → 中等(京津冀、江浙沪、川渝、黑吉辽)
跨省 → 最贵这四档不是随便定的,而是成本结构的自然分层。
二、方案设计
2.1 四级匹配链(注意:是"逐级兜底",不是 if-else 短路)
// chain-cloud-ms-carriage-service CarriageServiceImpl#findCarriageTemplate
private CarriageEntity findCarriageTemplate(WaybillDTO waybillDTO) {
// L1 判断是否为同城
if (ObjectUtil.equal(waybillDTO.getSenderCityId(), waybillDTO.getReceiverCityId())) {
CarriageEntity t = this.findByTemplateType(CarriageConstant.SAME_CITY);
if (ObjectUtil.isNotEmpty(t)) return t; // 命中就返回
}
// L2 判断是否为省内(要 Feign 查 base 服务拿 city 的 parentId)
var sendParentId = areaFeign.get(waybillDTO.getSenderCityId()).getParentId();
var receiveParentId = areaFeign.get(waybillDTO.getReceiverCityId()).getParentId();
if (ObjectUtil.equal(sendParentId, receiveParentId)) {
CarriageEntity t = this.findByTemplateType(CarriageConstant.SAME_PROVINCE);
if (ObjectUtil.isNotEmpty(t)) return t;
}
// L3 判断是否为经济区互寄
LinkedHashMap<String, EconomicRegionEnum> enumMap = EnumUtil.getEnumMap(EconomicRegionEnum.class);
String jjqCode = null;
for (var value : enumMap.values()) {
// 判断两个省是否都在同一个经济区里
boolean b = ArrayUtil.containsAll(value.getValue(), sendParentId, receiveParentId);
if (b) { jjqCode = value.getCode(); break; }
}
if (ObjectUtil.isNotEmpty(jjqCode)) {
LambdaQueryWrapper<CarriageEntity> qw = Wrappers.<CarriageEntity>lambdaQuery()
.eq(CarriageEntity::getTemplateType, CarriageConstant.ECONOMIC_ZONE)
.eq(CarriageEntity::getTransportType, CarriageConstant.REGULAR_FAST)
.apply("FIND_IN_SET({0}, associated_city)", jjqCode);
CarriageEntity one = this.getOne(qw);
if (ObjectUtil.isNotEmpty(one)) return one;
}
// L4 兜底
return this.findByTemplateType(CarriageConstant.TRANS_PROVINCE);
}源码定位
chain-cloud-ms-carriage-service/src/main/java/com/itheima/ms/carriage/service/impl/CarriageServiceImpl.java:188-236
这里有一个容易被追问、但很少有人主动讲的点:
"你这是四级匹配,但我看你不是
if/else if短路,而是每一级都判空后继续往下走。"
正确答案: "对,这是刻意的。因为运费模板是运营在后台配的数据,不是代码。 运营可能只配了跨省模板,其他三档还没配。 如果写成 if(同城) return 同城模板,那么同城件在模板缺失时会直接 NPE, 下单就报 500。所以我这里是优先级匹配 + 逐级兜底: 先按业务优先级试,都没配就退到跨省,保证任何地址都能算出一个价。 这本质上是把'配置可能不完整'这个现实情况纳入了算法。"
这个回答的杀伤力在于:它证明你考虑的是数据和代码的分离带来的脏数据容错, 而不是只考虑了理想路径。
2.2 计费重量:三段取整阶梯
// CarriageServiceImpl#computeWeight
private double computeWeight(WaybillDTO waybillDTO, CarriageEntity carriageTemplate) {
Integer volume = null;
// 体积:传入体积和 长×宽×高(cm) 取最大
try {
Integer weight = waybillDTO.getMeasureLong()
* waybillDTO.getMeasureWidth()
* waybillDTO.getMeasureHigh();
volume = Math.max(waybillDTO.getVolume(), weight);
} catch (Exception e) {
volume = 0;
}
// 体积重 = 体积 / 轻抛系数(Hutool div 默认 HALF_UP 保留 1 位)
var actualWeight = NumberUtil.div(volume, carriageTemplate.getLightThrowingCoefficient(), 1);
// 计费重量 = max(体积重, 实际重量)
double computeWeight = Math.max(actualWeight.doubleValue(), waybillDTO.getWeight());
if (computeWeight <= 1) return 1; // 阶梯一
if (computeWeight <= 10) return NumberUtil.round(computeWeight, 1).doubleValue(); // 阶梯二
if (computeWeight >= 100) return NumberUtil.round(computeWeight, 0).doubleValue(); // 阶梯四
// 阶梯三:10~100kg,向上取到 0.5 的整数倍
var integer = NumberUtil.round(computeWeight, 0, RoundingMode.DOWN).intValue();
if (NumberUtil.sub(computeWeight, integer) == 0) return integer;
if (NumberUtil.sub(computeWeight, integer) <= 0.5) return NumberUtil.add(integer, 0.5);
return NumberUtil.add(integer, 1);
}源码定位
chain-cloud-ms-carriage-service/src/main/java/com/itheima/ms/carriage/service/impl/CarriageServiceImpl.java:139-186
验证几个注释里给的例子(面试时可以直接说,显得你真的跑过):
| 实际体积重 | 走哪个分支 | 结果 | 对不对 |
|---|---|---|---|
| 18.1 | DOWN=18,小数 0.1 ≤ 0.5 | 18.5 | ✅ |
| 18.4 | DOWN=18,小数 0.4 ≤ 0.5 | 18.5 | ✅ |
| 18.8 | DOWN=18,小数 0.8 > 0.5 | 19.0 | ✅ |
| 108.4 | ≥100,round(,0) | 108 | ✅ |
| 108.5 | ≥100,HALF_UP | 109 | ✅ |
2.3 经济区互寄:一对多数据怎么处理
经济区模板是一个经济区一条记录,所以"同城/省内/跨省"那种 "按 type 查一条"的写法在这里会炸——getOne 遇到多行直接抛异常。
代码里做了两件事:
1. 显式禁止用通用方法查经济区模板
// CarriageServiceImpl#findByTemplateType
@Override
public CarriageEntity findByTemplateType(Integer templateType) {
if (ObjectUtil.equals(templateType, CarriageConstant.ECONOMIC_ZONE)) {
// 方法调用错误:经济区互寄不通过该方法查询模板
throw new CCException(CarriageExceptionEnum.METHOD_CALL_ERROR);
}
...
}这是防御性编程的正面例子:与其在调用方小心翼翼,不如在被调用方把错误用法直接拒掉。
2. 用 FIND_IN_SET 做 CSV 成员判断
SELECT * FROM cc_carriage
WHERE template_type = 3 AND transport_type = 1
AND FIND_IN_SET('2', associated_city) -- '2' = 京津冀associated_city 存的是 "2,3,4" 这样的逗号串。 FIND_IN_SET 是 MySQL 里做这种 CSV 成员判断的标准做法。 注意这里 {0} 是参数绑定的,没有注入风险。
3. 保存时的重复校验才是真正的业务难点
经济区模板会和其他经济区模板冲突(比如京津冀和江浙沪同时关联了江苏), 所以新增/更新时要取交集校验:
// CarriageServiceImpl#saveOrUpdate(经济区分支)
List<String> associatedCityList = StreamUtil.of(list)
.filter(e -> ObjectUtil.notEqual(e.getId(), carriageDto.getId())) // 排除自己
.map(CarriageEntity::getAssociatedCity)
.map(a -> StrUtil.split(a, ',')) // "2,3" -> ["2","3"]
.flatMap(StreamUtil::of) // 展开
.collect(Collectors.toList());
Collection<String> intersection =
CollUtil.intersection(associatedCityList, carriageDto.getAssociatedCityList());
if (CollUtil.isNotEmpty(intersection)) {
throw new CCException(CarriageExceptionEnum.ECONOMIC_ZONE_CITY_REPEAT);
}非经济区则是单例模板(同 type 下只允许一条),更新时要排除自己再判重。
源码定位
chain-cloud-ms-carriage-service/src/main/java/com/itheima/ms/carriage/service/impl/CarriageServiceImpl.java:57-118
三、难点拆解
难点 1:计费重量这个概念本身就是难点
难点在哪: "重量"在业务里有三个含义——物理重量、体积重量、计费重量。 它们在不同场景下分别指代什么,取整规则为什么要分四段, 都不是看代码能理解的,必须先理解快递作业现场。
为什么难: 新人常见的错误是直接 price = weight * unitPrice, 代码能跑、测试也能过,但在真实业务里每一单都算错,而且是系统性的、无法通过补数据修复的错。
怎么解: 把"取整阶梯"抽成一个独立的、可单元测试的纯函数(computeWeight), 不要和模板查找、模板查询混在一起。我会给每一档都写参数化测试, 把注释里的例子固化成断言:
// 应该长成这样
assertEquals(18.5, computeWeight(waybill(18.1)));
assertEquals(19.0, computeWeight(waybill(18.8)));
assertEquals(109, computeWeight(waybill(108.5)));代价: 没有抽象成策略类,四档规则写死在一个方法里。 好处是这个规则就是要清晰可读,不适合过度抽象。
难点 2:四级的判定依赖跨服务的行政区划
难点在哪: 判断"省内"不能只看城市名,必须拿到两个 city 的 parentId(即省份 id) 做比较。 而行政区划数据在 base-service,carriage-service 自己没有。
怎么解: 通过 AreaFeign 调 base-service。这里的关键决策是:
什么时候发起 Feign 调用?
现有实现是在 L1 没命中后才发起。这是一个性能上的隐性代价—— 最坏情况(所有模板都没配)会产生 2 次 Feign + 3 次 MySQL 查询。
如果重做: 我会把判定改成一次批量查询。AreaFeign 加一个 Map<Long, AreaDTO> findBatch(Collection<Long> ids) 接口,一次拿两个城市, 而不是两次单查。这样最坏情况的跨服务调用从 2 次降到 1 次, 并且给 AreaFeign 配 fallbackFactory + 本地缓存(区域数据几乎不变,TTL 给 30 分钟足够)。
难点 3:getOne 在多行数据下会炸,而不是返回 null
难点在哪: getOne(wrapper) 在 MyBatis-Plus 里的语义是"查一条,查到多条就抛 TooManyResultsException"。很多人以为它等价于 LIMIT 1。
怎么解: 识别出经济区是"一对多",然后走 FIND_IN_SET 的专用路径, 同时在通用方法里主动拒绝 type=3。
这个难点的价值在于: 它揭示了一个通用原则——ORM 的"看起来一样"的 API,语义可能完全不同。 面试时讲出来,比讲任何框架特性都能体现经验。
四、实际遇到的困难与踩坑
坑 1:整条下单主链路没有任何缓存
这是这个模块最大的问题,也是最值得主动讲的问题。
事实(已逐文件核对):
chain-cloud-ms-carriage-service/pom.xml里没有spring-boot-starter-data-redis,
也没有 spring-boot-starter-cache,没有 Caffeine,没有 Guava。
- 全仓库搜索
Cacheable/CachePut/CacheEvict/Caffeine/LoadingCache→
零命中。
compute()每次调用都会走完整的模板查找链,
最坏情况 2 次 Feign + 3 次 MySQL。
为什么这是个问题(业务角度): 运费模板是运营配置的数据,一天可能改不了几次。 而 compute() 在下单主链路上,一天会被调用几万次。 这是典型的"读多写少 + 强热点 + 小数据量",不缓存是设计失职。
如果重做: 用 Caffeine 做进程内缓存,Redis 做集群间共享,两级缓存:
// 缓存 key 的设计(这是难点,不是写 @Cacheable 本身)
String key = "carriage:template:"
+ sameCityFlag + ":" // 是否同城
+ sendProvinceId + ":" // 寄件省
+ receiveProvinceId; // 收件省
// 失效:用"模板配置版本号"做 key 后缀,改配置时 +1
// 这样一个 key 下挂着所有命中该模板的地址组合难点在于失效策略:不能用 @CacheEvict 按 key 删,因为一个地址组合对应多个查询路径。 更实际的做法是缓存"模板查找结果"这一层,而不是"运费计算结果"—— 因为模板查找才是跨服务的那部分,而运费计算是纯 CPU 的。
坑 2:用 Double 算钱
事实:
private Double firstWeight; // 首重价格
private Double continuousWeight; // 续重单价
...
double expense = carriageTemplate.getFirstWeight()
+ ((weight - 1) * carriageTemplate.getContinuousWeight());
expense = NumberUtil.round(expense, 1).doubleValue();而且这个类已经 import java.math.BigDecimal 了,却一次都没用—— 典型的"知道该用 BigDecimal,但没改过来"。
为什么危险:
0.1 + 0.2 = 0.30000000000000004 (二进制浮点无法精确表示十进制小数)单笔差额可能只有 1e-16,但累加到财务报表上就是真实资损, 对账时会出现一批"差 1 分钱"的脏数据,而这类问题排查极其痛苦。
如果重做: 金额一律 BigDecimal,并明确舍入模式:
BigDecimal weight = BigDecimal.valueOf(computeWeight); // 注意用 valueOf,不要 new BigDecimal(double)
BigDecimal amount = carriage.getFirstWeight()
.add(weight.subtract(BigDecimal.ONE)
.multiply(carriage.getContinuousWeight()));
carriage.setExpense(amount.setScale(2, RoundingMode.HALF_UP));
BigDecimal.valueOfvsnew BigDecimal(double)是高频追问。 前者走Double.toString,得到"0.1";后者会把二进制的0.1000000000000000055511151231257827原样保留下来。
坑 3:NOT_FOUND 异常定义了,但从来没被抛出
事实:
// CarriageExceptionEnum
NOT_FOUND(1003, "寄/收地址所属区域暂无计价规则,无法下单"),全仓库搜索,这个枚举只在声明处出现,从未被 throw。
实际发生的事:
四级都没匹配到
→ findByTemplateType 返回 null(MyBatis-Plus getOne 查 0 行返回 null,不抛异常)
→ findCarriageTemplate 返回 null,代码里没有任何判空
→ compute() 第 129 行 carriageTemplate.getFirstWeight() → NullPointerException
→ GlobalExceptionHandler 的兜底 handler 捕获
→ HTTP 500,响应体里带完整堆栈于是本该返回 {"code":1003, "msg":"寄/收地址所属区域暂无计价规则"} 的业务场景, 变成了 500 + 堆栈泄露。
修复只要三行:
// L4 兜底
CarriageEntity template = this.findByTemplateType(CarriageConstant.TRANS_PROVINCE);
if (ObjectUtil.isEmpty(template)) {
throw new CCException(CarriageExceptionEnum.NOT_FOUND);
}
return template;这个坑为什么值得在面试里讲: 它不是"我写错了",而是一个典型的架构性疏漏—— 枚举定义了、异常处理框架写了,唯独调用点忘了判空。 真实项目里这类问题往往来自异常驱动开发(先定错误码再写逻辑)的副作用。
坑 4:特快(express)通道实际上是死的
事实: transportType 在所有四处模板查询里都硬编码成 REGULAR_FAST(普快):
.eq(CarriageEntity::getTransportType, CarriageConstant.REGULAR_FAST)CarriageConstant.EXPRESS = 2 定义了,但从未被任何查询使用。 结果:特快模板能存进库,但永远不会被匹配到。
根因: 快递公司的"特快"通常不是通过运费模板区分的, 而是通过时效产品(次晨达、次日达)区分——那是完全不同的领域模型。 这里其实是领域模型建模不足,不是简单的漏写。
五、如果重做,我会怎么改
按投入产出比排序:
| 优先级 | 改动 | 收益 |
|---|---|---|
| P0 | BigDecimal 替代 Double | 消除资损风险 |
| P0 | L4 兜底后补 NOT_FOUND 抛异常 | 消除 NPE + 堆栈泄露 |
| P1 | Caffeine + Redis 两级缓存模板查找 | 下单链路去掉 2 次跨服务调用 |
| P1 | AreaFeign 加 fallbackFactory + 批量接口 | base 抖动不再拖垮下单 |
| P2 | 拆出 CarriageCalculator 纯领域对象 + 参数化测试 | 取整阶梯可回归 |
| P2 | 时效产品独立建模,不再挤在 transportType 里 | 领域模型正交 |
六、面试问答
Q1:为什么首重公式是 (weight - 1) × continuousWeight?
A: 因为首重价格是第 1 公斤的总价,里面已经包含了面单、扫码、录入、 称重、网点场地摊销这些固定成本。如果不减 1,会把首重的固定成本重复收一次。 weight = 1 时续重部分是 0,正好等于首重价;weight = 2 时是 首重 + 1 × 续重,符合直觉。
追问:那不足 1kg 怎么办? 计费重量阶梯第一步就是 ≤1 → 返回 1,所以不会减出负数。
Q2:你这个四级匹配,为什么不用 if-else 短路?
A: 见 2.1 的方框。核心是运费模板是运营配的数据,不保证完整。 逐级兜底保证了任何地址都能算出一个价。
追问:那会不会算错价?比如同城件没配同城模板,掉了跨省模板,报了个跨省价? 会。这是我这个方案的已知代价:正确性靠配置保证,配置错了业务就会报错价。 生产上的兜底做法是加一个配置完整性校验—— 后台保存时校验四档是否齐全,缺档时给运营告警。 这是"技术保证不了就靠流程兜"的典型例子。
Q3:轻抛系数是放在模板里的,如果不同品类的轻抛系数不一样怎么办?
A: 这是个好问题。当前模型是一个系数对应一个模板, 所以实际上是用"复制多条模板"来区分品类的,这会导致模板爆炸。 正确建模应该是把 lightThrowingCoefficient 从模板上下移到货物类型(goodsType)上—— 因为轻抛系数本质是货物的物理属性(泡货轻抛,金属重抛),不是运输服务的属性。 这正是 DDD 里说的实体归错位。
Q4:你这个模块有什么性能问题?
A: 如 4.坑1,整条链路没缓存。运费模板是典型的读多写少小数据, 应该做两级缓存。另外判定省内要跨服务调 AreaFeign 两次, 我可以合并成一次批量查询,并加本地缓存,因为行政区划数据几乎不变。
Q5:这个运费算错了会造成什么后果?你们怎么防的?
A: 三个后果:少收钱(直接资损)、多收钱(客诉 + 监管投诉)、 算不出来(下单失败,转化率直接归零)。 防的思路是三层:
- 口径统一 —— 计费重量的定义写进代码注释,和运营培训材料一致;
- 可复核 —— 取整阶梯跟着快递员的秤走,快递员能当场验算,减少纠纷;
- 异常显式化 —— 算不出来时抛业务异常而不是 NPE,让客服能拿到明确的失败原因。
诚实边界: 这三条是我们做的,但缺对账机制—— 生产环境应该把每笔运费和收入做 T+1 对账,差异告警。这是我们当时的缺口。
七、源码定位速查
| 文件 | 行 | 作用 |
|---|---|---|
ms/carriage/service/impl/CarriageServiceImpl.java | 120-137 | compute() 运费公式 |
| 同上 | 139-186 | computeWeight() 计费重量三段取整阶梯 |
| 同上 | 188-236 | findCarriageTemplate() 四级匹配链 |
| 同上 | 239-246 | findByTemplateType() 拒绝经济区走通用方法 |
| 同上 | 57-118 | saveOrUpdate() 模板重复校验(交集判定) |
ms/carriage/enums/CarriageExceptionEnum.java | 9-25 | 错误码定义(含未使用的 NOT_FOUND) |
chain-cloud-ms-domain/.../carriage/enums/EconomicRegionEnum.java | 8-11 | 四大经济区的省份 ID 硬编码 |
chain-cloud-ms-domain/.../carriage/constant/CarriageConstant.java | — | 模板类型 / 运送类型常量 |
chain-cloud-ms-oms-service/.../OrderServiceImpl.java | 178-190 | 下单时调用运费计算(主链路) |
chain-cloud-ms-web-courier/.../TaskServiceImpl.java | 128 | 快递员端二次调用运费计算 |