尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

顺风车高速费分摊系统设计:从纠纷到计费引擎实战

顺风车高速费分摊系统设计:从纠纷到计费引擎实战 之前在业务侧做出行类小程序时经常遇到一类线上问题订单金额没争议但线下“额外费用”吵得不可开交尤其是顺风车、城际拼车里的高速费。最近看到“两姐妹不承担高速费到后面因为高速费吵起来了”这类真实场景正好可以把它拆成一个系统设计问题来聊。表面上是“该谁出钱”的沟通问题落到软件工程里就是计费规则不透明、费用归属不清晰、缺乏事前确认与事后留痕。本文从顺风车/网约车的实际业务场景出发梳理费用分摊的计费模型给出数据库设计、核心计算逻辑和接口实现思路最后总结项目中如何通过规则配置、快照、审计等手段减少费用纠纷。内容偏向实战适合做出行平台、同城配送、拼车小程序以及所有涉及“多方分摊费用”场景的后端开发者参考。1. 从高速费纠纷说起顺风车费用问题的本质1.1 一次典型的高速费争议流程先还原一个很常见的场景乘客通过顺风车平台预约跨城订单。司机接单后与乘客口头约定“高速费平摊”。行程结束乘客认为高速费不应由自己承担或认为金额不对。双方各执一词平台客服介入时缺少可查证的记录。这类争议在技术上可以拆成几个关键问题高速费是否在预估价中展示行程开始前乘客是否完成“高速费承担”的确认拼车场景下高速费如何分摊实际通行费与预估费不一致时如何处理每一笔费用明细是否有快照可追溯很多团队在初期只实现了“总价 里程费 时长费”把高速费当作线下交易这就会留下巨大的规则黑洞。1.2 为什么费用问题必须在系统层面解决从产品角度说费用透明是出行平台的基本信任基础。从工程角度说费用计算是订单交易链路的核心环节涉及预估、锁定、结算、退款、对账多个阶段任一环节缺少数据支撑后续都会变成客服工单。因此本文会把高速费当作一个标准「费用项」纳入计费引擎而不是让它在系统外流转。2. 顺风车与网约车计费的核心概念2.1 基础计费模型无论顺风车还是快车主流计费模型通常包含以下费用项费用项说明示例起步价里程小于阈值时的固定费用10 元里程费按实际行驶里程计算2 元/公里时长费按行驶时间或等待时间计算0.5 元/分钟高速费实际通行费可选择不同分摊方式50 元/单动态调价供需紧张时上浮比例1.2 倍优惠券平台或商户补贴-5 元费用计算不是简单相加而是需要按“费用项”分别记录来源、计算规则、参与方才能支撑后续分摊和审计。2.2 高速费的处理方式高速费通常有以下几种处理策略司机承担定价时已经包含高速成本适合短途或高速费占比较低的订单。乘客承担订单明确展示“高速费由乘客承担”适合跨城订单。按人头平摊适合拼车、顺风车场景。按里程占比分摊根据每位乘客实际乘坐里程占订单总里程的比例分摊。谁产生谁承担针对途经不同收费路段的订单。系统设计上策略需要做成可配置项而不是写死在代码里。因为不同城市、不同产品线、不同活动期的规则都可能不同。2.3 费用预估、锁定、结算三阶段一站式订单费用需要经历三个阶段预估阶段用户下单前系统根据起终点预测里程、时长和高速费生成预估价。锁定阶段司机接单后展示给双方的费用明细即为规则快照行程中的价格变更要有依据。结算阶段行程结束系统按实际里程、实际高速费等因子重新计算生成账单。需要特别注意的是高速费在预估阶段只能估算实际通行费可能因路线、收费站、ETC折扣而不同。因此系统必须同时保存「预估快照」和「实际扣费记录」。3. 环境准备与项目结构3.1 技术选型本文示例使用以下技术栈版本可根据实际项目调整JDK 17Spring Boot 3.xSpring JDBCJdbcTemplateMySQL 8.xMaven这里没有引入 MyBatis 等 ORM 框架目的是把核心计算逻辑和数据库操作拆得更清晰。实际项目中替换成 MyBatis-Plus 或 JPA 并不影响算法本身。3.2 项目结构fare-settlement-demo ├── pom.xml └── src/main/java/com/example/fare ├── FareApplication.java ├── controller │ └── OrderController.java ├── service │ ├── FeeCalculateService.java │ └── OrderSettlementService.java ├── strategy │ ├── TollShareStrategy.java │ ├── AverageShareStrategy.java │ ├── DistanceShareStrategy.java │ └── StrategyContext.java ├── model │ ├── RideOrder.java │ ├── Passenger.java │ └── OrderFeeDetail.java └── dao └── FeeDao.java3.3 Maven 依赖parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.4/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependencies4. 数据库模型设计费用分账最忌讳“一个总数字”它会让后续审计无从下手。我们需要把费项拆到明细级别。4.1 订单表CREATE TABLE ride_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL COMMENT 订单号, driver_id BIGINT NOT NULL COMMENT 司机ID, start_city VARCHAR(64) NOT NULL COMMENT 出发城市, end_city VARCHAR(64) NOT NULL COMMENT 到达城市, total_km DECIMAL(10, 2) NOT NULL COMMENT 预估总里程, total_minutes INT NOT NULL COMMENT 预估总时长(分钟), status TINYINT NOT NULL COMMENT 订单状态 10预估 20已接单 30已确认 40行程中 50已完成 60已取消, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) ) COMMENT 出行订单主表;4.2 乘客明细表顺风车订单可能一次承载多位乘客所以乘客信息不能只放订单表。CREATE TABLE ride_passenger ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, passenger_id BIGINT NOT NULL COMMENT 乘客ID, passenger_type TINYINT NOT NULL COMMENT 1 乘客 2 同行人, onboard_seq INT NOT NULL COMMENT 上车顺序, getoff_seq INT NOT NULL COMMENT 下车顺序, start_km DECIMAL(10, 2) NOT NULL COMMENT 该乘客里程, share_toll_amount DECIMAL(10, 2) NOT NULL DEFAULT 0.00 COMMENT 应收该乘客高速费, confirm_status TINYINT NOT NULL DEFAULT 0 COMMENT 费用确认状态 0未确认 1已确认, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order_id (order_id) ) COMMENT 行程乘客明细表;4.3 费用明细表费用明细表是整个计费系统的核心每一条记录代表一个费用项对某个订单的参与方的应收应付关系。CREATE TABLE order_fee_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, passenger_id BIGINT NOT NULL, fee_type VARCHAR(32) NOT NULL COMMENT BASE_FEE 基础费 DISTANCE_FEE 里程费 TIME_FEE 时长费 TOLL_FEE 高速费 PLATFORM_FEE 平台费, fee_name VARCHAR(64) NOT NULL, amount DECIMAL(10, 2) NOT NULL COMMENT 金额, rule_version VARCHAR(64) NOT NULL COMMENT 规则版本号, snapshot_json TEXT COMMENT 计算快照, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order_id (order_id), KEY idx_passenger_id (passenger_id) ) COMMENT 订单费用明细表;4.4 费用规则表把分摊策略、高速费承担方、是否启用等配置放到数据库由运营后台动态管理。CREATE TABLE fee_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, rule_code VARCHAR(64) NOT NULL COMMENT 规则编码, rule_name VARCHAR(128) NOT NULL, scene_type VARCHAR(32) NOT NULL COMMENT RIDE_SHARE 顺风车 ONLINE_TAXI 网约车, toll_share_type VARCHAR(32) NOT NULL COMMENT DRIVER 司机承担 PASSENGER 乘客承担 AVERAGE 均摊 DISTANCE_RATIO 按里程占比, amount_scale INT NOT NULL DEFAULT 2 COMMENT 金额精度, enabled TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_rule_code (rule_code) ) COMMENT 费用规则表;5. 核心代码实现5.1 分摊策略接口设计上使用策略模式每一种分摊方式对应一个实现类后续新增规则不影响现有代码。package com.example.fare.strategy; import com.example.fare.model.Passenger; import java.math.BigDecimal; import java.util.List; public interface TollShareStrategy { /** * 分摊类型编码与数据库 toll_share_type 对应 */ String shareType(); /** * 对高速费进行分摊 * * param passengers 乘客列表 * param totalToll 总高速费 * return 每个乘客应承担金额与传入 passengers 顺序保持一致 */ ListBigDecimal share(ListPassenger passengers, BigDecimal totalToll); }5.2 均摊策略均摊策略适用于“人数明确、里程差异不大”的顺风车场景。实现时需要注意金额除以人数后可能存在无限小数必须进行精度处理并且要处理“尾差”保证所有乘客分摊金额之和等于总高速费。package com.example.fare.strategy; import com.example.fare.model.Passenger; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; import java.util.List; public class AverageShareStrategy implements TollShareStrategy { Override public String shareType() { return AVERAGE; } Override public ListBigDecimal share(ListPassenger passengers, BigDecimal totalToll) { int size passengers.size(); ListBigDecimal result new ArrayList(size); if (size 0) { return result; } // 保留2位小数每人均摊 BigDecimal avg totalToll.divide(BigDecimal.valueOf(size), 2, RoundingMode.HALF_UP); BigDecimal sum avg.multiply(BigDecimal.valueOf(size)); // 处理尾差如果均摊总和不等于原始金额把差额调整到最后一个人头上 BigDecimal diff totalToll.subtract(sum); for (int i 0; i size; i) { if (i size - 1) { result.add(avg.add(diff)); } else { result.add(avg); } } return result; } }这里有一个容易被忽略的点totalToll可能是 50.00 元分给 3 个人每人是 16.67 元总和是 50.01 元多出的 0.01 元必须通过尾差处理消除否则订单对账永远对不平。5.3 按里程占比分摊策略如果乘客上落点差异较大比如一位乘客只坐了一小段另一位乘客几乎坐完全程那么均摊就不太公平。按里程占比分摊更合理。package com.example.fare.strategy; import com.example.fare.model.Passenger; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; import java.util.List; public class DistanceShareStrategy implements TollShareStrategy { Override public String shareType() { return DISTANCE_RATIO; } Override public ListBigDecimal share(ListPassenger passengers, BigDecimal totalToll) { BigDecimal totalKm passengers.stream() .map(Passenger::getStartKm) .reduce(BigDecimal.ZERO, BigDecimal::add); ListBigDecimal result new ArrayList(passengers.size()); if (totalKm.compareTo(BigDecimal.ZERO) 0) { // 兜底总里程异常时退回均摊 return new AverageShareStrategy().share(passengers, totalToll); } BigDecimal allocatedSum BigDecimal.ZERO; for (int i 0; i passengers.size(); i) { BigDecimal ratio passengers.get(i).getStartKm() .divide(totalKm, 6, RoundingMode.HALF_UP); BigDecimal amount totalToll.multiply(ratio) .setScale(2, RoundingMode.HALF_UP); if (i passengers.size() - 1) { // 最后一个人承担尾差 amount totalToll.subtract(allocatedSum); } allocatedSum allocatedSum.add(amount); result.add(amount); } return result; } }注意divide方法必须指定小数位数和舍入模式否则遇到除不尽的情况会抛出ArithmeticException。这是费用计算代码里最常见的线上事故来源。5.4 策略上下文策略上下文负责根据规则编码加载对应的策略实现。package com.example.fare.strategy; import java.util.HashMap; import java.util.List; import java.util.Map; public class StrategyContext { private final MapString, TollShareStrategy strategyMap new HashMap(); public StrategyContext(ListTollShareStrategy strategyList) { for (TollShareStrategy strategy : strategyList) { strategyMap.put(strategy.shareType(), strategy); } } public TollShareStrategy getStrategy(String shareType) { TollShareStrategy strategy strategyMap.get(shareType); if (strategy null) { throw new IllegalArgumentException(unsupported toll share type: shareType); } return strategy; } }实际项目中可以把StrategyContext声明为 Spring Bean通过构造器注入所有TollShareStrategy实现类。5.5 订单费用计算服务下面是订单费用计算的入口服务。它接收订单和乘客信息根据规则计算每位乘客的各项费用并写入费用明细表。package com.example.fare.service; import com.example.fare.model.OrderFeeDetail; import com.example.fare.model.Passenger; import com.example.fare.model.RideOrder; import com.example.fare.strategy.StrategyContext; import com.example.fare.strategy.TollShareStrategy; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; Service public class FeeCalculateService { private final JdbcTemplate jdbcTemplate; private final StrategyContext strategyContext; public FeeCalculateService(JdbcTemplate jdbcTemplate, StrategyContext strategyContext) { this.jdbcTemplate jdbcTemplate; this.strategyContext strategyContext; } Transactional public void calculateAndSave(RideOrder order, ListPassenger passengers, String tollShareType) { // 1. 先清空旧明细避免重复计算 jdbcTemplate.update(DELETE FROM order_fee_detail WHERE order_id ?, order.getId()); BigDecimal distanceFee order.getTotalKm() .multiply(new BigDecimal(2.00)) .setScale(2, BigDecimal.ROUND_HALF_UP); BigDecimal timeFee BigDecimal.valueOf(order.getTotalMinutes()) .multiply(new BigDecimal(0.50)) .setScale(2, BigDecimal.ROUND_HALF_UP); BigDecimal tollFee queryActualTollFee(order.getId()); TollShareStrategy strategy strategyContext.getStrategy(tollShareType); ListBigDecimal tollShares strategy.share(passengers, tollFee); ListOrderFeeDetail detailList new ArrayList(); for (int i 0; i passengers.size(); i) { Passenger passenger passengers.get(i); // 基础费 里程费 时长费 高速费 addDetail(detailList, order, passenger, BASE_FEE, 基础费, new BigDecimal(6.00)); addDetail(detailList, order, passenger, DISTANCE_FEE, 里程费, distanceFee); addDetail(detailList, order, passenger, TIME_FEE, 时长费, timeFee); addDetail(detailList, order, passenger, TOLL_FEE, 高速费, tollShares.get(i)); } // 批量写入 for (OrderFeeDetail detail : detailList) { jdbcTemplate.update( INSERT INTO order_fee_detail(order_id, passenger_id, fee_type, fee_name, amount, rule_version, snapshot_json) VALUES (?, ?, ?, ?, ?, ?, ?), detail.getOrderId(), detail.getPassengerId(), detail.getFeeType(), detail.getFeeName(), detail.getAmount(), detail.getRuleVersion(), detail.getSnapshotJson() ); } } private void addDetail(ListOrderFeeDetail detailList, RideOrder order, Passenger passenger, String feeType, String feeName, BigDecimal amount) { OrderFeeDetail detail new OrderFeeDetail(); detail.setOrderId(order.getId()); detail.setPassengerId(passenger.getPassengerId()); detail.setFeeType(feeType); detail.setFeeName(feeName); detail.setAmount(amount); detail.setRuleVersion(V1.0); detailList.add(detail); } private BigDecimal queryActualTollFee(Long orderId) { // 实际项目中从计费系统或支付记录中查询实际高速通行费 // 这里使用示例值 return new BigDecimal(50.00); } }上面代码中BigDecimal.ROUND_HALF_UP在 JDK 17 中依然可用但官方更推荐使用RoundingMode.HALF_UP后者也是更清晰的写法。实际开发中建议统一封装金额计算工具类避免每次手写舍入模式。5.6 查询账单接口前端展示账单时要把费用明细按乘客、费用项分组返回让每个乘客都能看到自己承担的高速费金额而不是只看到一个总价。package com.example.fare.controller; import com.example.fare.model.OrderFeeDetail; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; RestController RequestMapping(/order) public class OrderController { private final JdbcTemplate jdbcTemplate; public OrderController(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } GetMapping(/{orderId}/fee-detail) public ListOrderFeeDetail feeDetail(PathVariable Long orderId) { return jdbcTemplate.query( SELECT id, order_id, passenger_id, fee_type, fee_name, amount, rule_version, snapshot_json, created_at FROM order_fee_detail WHERE order_id ? ORDER BY passenger_id, fee_type, new Object[]{orderId}, (rs, rowNum) - { OrderFeeDetail d new OrderFeeDetail(); d.setId(rs.getLong(id)); d.setOrderId(rs.getLong(order_id)); d.setPassengerId(rs.getLong(passenger_id)); d.setFeeType(rs.getString(fee_type)); d.setFeeName(rs.getString(fee_name)); d.setAmount(rs.getBigDecimal(amount)); d.setRuleVersion(rs.getString(rule_version)); d.setSnapshotJson(rs.getString(snapshot_json)); return d; } ); } }6. 从纠纷场景反推功能设计6.1 下单时明确高速费承担方回到开头的“两姐妹不承担高速费”场景。从产品角度这个问题的根源在于高速费承担方式没有在下单前确定或者没有以醒目方式展示。系统层面的做法是乘客发布行程时必须选择“高速费承担方式”。司机接单前页面必须展示该字段。订单确认时双方对“高速费分摊方式”进行二次确认。这个确认动作不是简单的弹窗而是要在数据库中留下明确记录包括确认时间、确认人、规则版本、展示给用户的具体文案。6.2 预估费用展示不能只给总价跨城订单的预估费用明细至少包括里程费时长费预估高速费高速费分摊方式说明每位乘客的预估应付金额如果用户看到的是“预估总计 120 元”但行程结束后账单变成“基础费 里程费 高速费”共 170 元纠纷几乎必然发生。因此前端展示和后台计算必须共用同一套费用项。6.3 实际高速费与预估不一致时的处理实际通行费可能因为路线调整、收费站变化而和预估不同。处理原则是实际高速费以上传到平台的通行费凭证为准。如金额差额较大需要司机上传发票或通行记录。行程结束后重新计算每位乘客的分摊金额并通过站内消息推送账单变更。这里需要给客服提供一个「费用重算」的操作入口但重算必须生成新的费用明细版本不能直接修改历史记录。6.4 账单明细留痕与申诉一旦发生“两姐妹不承担高速费”这类争议客服最需要的是证据链。系统至少要保留下单时的费用预估快照。乘客确认高速费分摊方式的记录。实际产生的通行费凭证。行程结束后的最终账单。账单推送与已读记录。具备这些数据后争议处理就不再依赖司机或乘客的单方面口述。7. 常见问题与排查思路以下问题在出行计费项目里非常高频建议提前做好预案。问题现象常见原因解决思路分摊金额之和与总高速费不一致对每个乘客分别四舍五入未处理尾差最后一位乘客承担差额保证总和恒等于总费用除不尽导致接口报错BigDecimal.divide 未指定精度统一封装金额工具类强制指定 scale 和 RoundingMode高速费重复计算行程结束回调与结算消息重复消费对订单结算加幂等控制使用唯一业务号乘客费用被改掉但无记录直接 update 了明细表费用明细只追加版本不覆盖历史记录预估高速费与实际高速费差距大预估逻辑使用固定值或过时路线数据接入地图路线规划 API结合实际路径和收费站司机线下收高速费又线上扣除线上线下的取消费用口径不一致明确线上费用规则司机端禁止线下加收7.1 一个典型排查流程当你接到“乘客费用被算错”的工单建议按以下顺序排查查订单状态和费用版本确认是否是行程结束后的最终账单。查order_fee_detail表看每位乘客的费用项是否完整。查snapshot_json字段确认当时使用的里程、时长、高速费额度。查规则版本确认费用计算时使用的分摊策略。检查是否存在重复的结算消息导致多次写入。如果金额差异很小优先怀疑舍入问题。8. 最佳实践与工程建议8.1 金额计算必须使用 BigDecimal这句话在技术博客里很常见但真正落实到项目中的团队并不多。费用计算涉及加减乘除和舍入double的二进制浮点误差在金额场景中不可接受。基础规则如下所有金额字段使用BigDecimal而不是double。数据库字段使用DECIMAL(10, 2)不要用FLOAT。所有除法必须先指定精度再指定舍入模式。8.2 规则配置化而非硬编码不要把“均摊”“按里程占比”这类策略写死在 if-else 里。推荐的实现方式策略类实现统一接口。数据库保存规则编码。规则变更通过后台配置发布。这样当运营决定调整分摊方式时不需要重新发版只要针对新订单启用新规则即可。8.3 费用计算接口必须幂等订单结算可能被消息队列重复消费也可能被客服手动重算。因此费用计算接口必须保证同一订单、同一规则版本、同一来源请求执行多次结果一致。重算前先删除旧明细再写入新明细。数据库写入使用事务避免明细写入一半的情况。8.4 快照与审计优先于“修改”用户看到的费用一旦确认尽量不要直接修改原记录。正确做法是每笔费用记录带rule_version和snapshot_json。费用变更生成新版本记录。投诉发生时能查到“当时用户看到的是什么”。这套审计机制的开发成本不高但能显著降低客服仲裁难度。8.5 前端展示与后端计算保持同一套口径费用纠纷很大一部分来自前端展示和后端计算不一致。比如前端预估价没有包含高速费后端结算时却加上了高速费用户自然不认可。建议前后端共用一套费用项枚举并由后端返回明细列表前端只做展示不自己拼装金额。8.6 注意合规与最小权限出行计费涉及资金交易生产环境操作必须遵循最小权限原则。费用重算、退款、人工调整等操作需要二次审批并在操作日志中记录操作人、操作原因、变更前后金额。如果涉及实际运营还需要遵循当地出行平台相关法规和平台规则以避免合规风险。9. 总结与下一步回到“两姐妹不承担高速费”的场景你会发现如果系统在下单时就明确了高速费分摊方式行程中保留了确认记录结算后提供了明细账单这类矛盾完全可以被前置化解。本文从出行计费的核心概念出发完成了费用模型、数据库设计、策略模式实现、分摊算法和账单查询的完整闭环。代码示例可以直接用于小型出行项目也可以作为大型计费系统中的模块参考。下一步建议从以下方向继续深入接入真实地图 API获取预估里程和预估高速费。引入消息队列处理订单完成后的异步结算。增加对账模块定时核对订单应收与实收金额。设计更完整的申诉工单系统把费用快照、确认记录、凭证整合成客服工作台。费用计算是出行系统的资金核心宁可前期多花一点精力把规则和审计设计清楚也不要等线上纠纷爆发后再去补窟窿。希望这篇文章能给你一个可落地的设计起点。
返回列表