运费计算引擎

01 · 核心亮点

亮点一 · 一句话说清运费

四级模板 + 轻抛系数 + 三段取整阶梯。难点不在公式,在于计费重量这个概念本身;困难在于整条下单主链路没有任何缓存。

涉及模块:chain-cloud-ms-carriage-service · 18094

亮点一 · 一句话说清运费

业务问题: 客户点"确认下单"的那一瞬间,系统必须立刻报出一个准确的运费。 报便宜了,公司亏;报贵了,客户跑了。而运费还会被四端的客服反复质疑, 所以它不只是"算对",还必须算得符合行业惯例、能解释清楚。


一、先把业务规则讲成人话

在讲代码之前,先说清楚快递行业为什么要这么算。这是最容易被面试官认可的一步—— 它证明你在做业务,而不是在做翻译。

1.1 为什么不能简单地 重量 × 单价

因为快递按体积收费。

  • 一台 60 寸电视,标 25kg,但它是个 1.2m × 0.7m × 0.8m 的箱子。它能塞满半个车厢,但重量只有 25kg。

如果只按重量收钱,车会被装得半空,运力白白浪费。

  • 所以行业引入了轻抛系数(体积重系数):计费体积重 = 体积 ÷ 轻抛系数,

常见系数是 5000(1m³ = 200kg)或 6000。系数越大,体积重越轻,也就是对泡货越宽容。

结论:计费重量 = max(实际重量, 体积重量)。

1.2 为什么运费要分"首重"和"续重"

因为小件要保本。第一公斤的价格里,包含了面单打印、扫码枪、称重、录入、 网点场地摊销这些固定成本。如果按比例收费,一张 1 元的单子,公司连条码纸都亏进去。

所以:首重是一个起步价,续重才是边际价。

text
运费 = 首重价格 + (计费重量 - 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 为什么模板要分四级

因为快递的价格是由距离决定的,而距离是分层的。行业惯例:

text
同城      → 最便宜(一个城市内,送货成本低)
省内      → 便宜
经济区互寄 → 中等(京津冀、江浙沪、川渝、黑吉辽)
跨省      → 最贵

这四档不是随便定的,而是成本结构的自然分层。


二、方案设计

2.1 四级匹配链(注意:是"逐级兜底",不是 if-else 短路)

java
// 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 计费重量:三段取整阶梯

java
// 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.1DOWN=18,小数 0.1 ≤ 0.518.5✅
18.4DOWN=18,小数 0.4 ≤ 0.518.5✅
18.8DOWN=18,小数 0.8 > 0.519.0✅
108.4≥100,round(,0)108✅
108.5≥100,HALF_UP109✅

2.3 经济区互寄:一对多数据怎么处理

经济区模板是一个经济区一条记录,所以"同城/省内/跨省"那种 "按 type 查一条"的写法在这里会炸——getOne 遇到多行直接抛异常。

代码里做了两件事:

1. 显式禁止用通用方法查经济区模板

java
// 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 成员判断

sql
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. 保存时的重复校验才是真正的业务难点

经济区模板会和其他经济区模板冲突(比如京津冀和江浙沪同时关联了江苏), 所以新增/更新时要取交集校验:

java
// 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), 不要和模板查找、模板查询混在一起。我会给每一档都写参数化测试, 把注释里的例子固化成断言:

java
// 应该长成这样
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 做集群间共享,两级缓存:

java
// 缓存 key 的设计(这是难点,不是写 @Cacheable 本身)
String key = "carriage:template:"
         + sameCityFlag + ":"            // 是否同城
         + sendProvinceId + ":"           // 寄件省
         + receiveProvinceId;             // 收件省
// 失效:用"模板配置版本号"做 key 后缀,改配置时 +1
// 这样一个 key 下挂着所有命中该模板的地址组合

难点在于失效策略:不能用 @CacheEvict 按 key 删,因为一个地址组合对应多个查询路径。 更实际的做法是缓存"模板查找结果"这一层,而不是"运费计算结果"—— 因为模板查找才是跨服务的那部分,而运费计算是纯 CPU 的。

坑 2:用 Double 算钱

事实:

java
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,但没改过来"。

为什么危险:

text
0.1 + 0.2 = 0.30000000000000004   (二进制浮点无法精确表示十进制小数)

单笔差额可能只有 1e-16,但累加到财务报表上就是真实资损, 对账时会出现一批"差 1 分钱"的脏数据,而这类问题排查极其痛苦。

如果重做: 金额一律 BigDecimal,并明确舍入模式:

java
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.valueOf vs new BigDecimal(double) 是高频追问。 前者走 Double.toString,得到 "0.1";后者会把二进制的 0.1000000000000000055511151231257827 原样保留下来。

坑 3:NOT_FOUND 异常定义了,但从来没被抛出

事实:

java
// CarriageExceptionEnum
NOT_FOUND(1003, "寄/收地址所属区域暂无计价规则,无法下单"),

全仓库搜索,这个枚举只在声明处出现,从未被 throw。

实际发生的事:

text
四级都没匹配到
  → findByTemplateType 返回 null(MyBatis-Plus getOne 查 0 行返回 null,不抛异常)
  → findCarriageTemplate 返回 null,代码里没有任何判空
  → compute() 第 129 行 carriageTemplate.getFirstWeight() → NullPointerException
  → GlobalExceptionHandler 的兜底 handler 捕获
  → HTTP 500,响应体里带完整堆栈

于是本该返回 {"code":1003, "msg":"寄/收地址所属区域暂无计价规则"} 的业务场景, 变成了 500 + 堆栈泄露。

修复只要三行:

java
// 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(普快):

java
.eq(CarriageEntity::getTransportType, CarriageConstant.REGULAR_FAST)

CarriageConstant.EXPRESS = 2 定义了,但从未被任何查询使用。 结果:特快模板能存进库,但永远不会被匹配到。

根因: 快递公司的"特快"通常不是通过运费模板区分的, 而是通过时效产品(次晨达、次日达)区分——那是完全不同的领域模型。 这里其实是领域模型建模不足,不是简单的漏写。


五、如果重做,我会怎么改

按投入产出比排序:

优先级改动收益
P0BigDecimal 替代 Double消除资损风险
P0L4 兜底后补 NOT_FOUND 抛异常消除 NPE + 堆栈泄露
P1Caffeine + Redis 两级缓存模板查找下单链路去掉 2 次跨服务调用
P1AreaFeign 加 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: 三个后果:少收钱(直接资损)、多收钱(客诉 + 监管投诉)、 算不出来(下单失败,转化率直接归零)。 防的思路是三层:

  1. 口径统一 —— 计费重量的定义写进代码注释,和运营培训材料一致;
  2. 可复核 —— 取整阶梯跟着快递员的秤走,快递员能当场验算,减少纠纷;
  3. 异常显式化 —— 算不出来时抛业务异常而不是 NPE,让客服能拿到明确的失败原因。

诚实边界: 这三条是我们做的,但缺对账机制—— 生产环境应该把每笔运费和收入做 T+1 对账,差异告警。这是我们当时的缺口。


七、源码定位速查

文件行作用
ms/carriage/service/impl/CarriageServiceImpl.java120-137compute() 运费公式
同上139-186computeWeight() 计费重量三段取整阶梯
同上188-236findCarriageTemplate() 四级匹配链
同上239-246findByTemplateType() 拒绝经济区走通用方法
同上57-118saveOrUpdate() 模板重复校验(交集判定)
ms/carriage/enums/CarriageExceptionEnum.java9-25错误码定义(含未使用的 NOT_FOUND)
chain-cloud-ms-domain/.../carriage/enums/EconomicRegionEnum.java8-11四大经济区的省份 ID 硬编码
chain-cloud-ms-domain/.../carriage/constant/CarriageConstant.java—模板类型 / 运送类型常量
chain-cloud-ms-oms-service/.../OrderServiceImpl.java178-190下单时调用运费计算(主链路)
chain-cloud-ms-web-courier/.../TaskServiceImpl.java128快递员端二次调用运费计算