旅游平台搭建:本地出游套餐与订单结算逻辑解析
本地出游套餐是当下文旅、同城旅游平台的核心营收产品,区别于长线跟团游、异地自由行,主打同城一日游、景点套票、亲子套餐、景点+餐饮、景点+住宿等轻量化组合产品。具备sku组合灵活、出行周期短、核销即时、复购率高、用户决策成本低等特点。
在旅游平台开发中,很多团队只关注套餐上架、用户下单功能,忽略了套餐组合库存校验、阶梯定价、核销触发结算、多方分账、周期对账核心逻辑,极易出现套餐超卖、价格计算错乱、结算资金偏差、渠道佣金对账不平、商家回款异常等线上问题。本文基于SpringBoot实战架构,完整拆解本地出游套餐产品设计、下单交易流程、订单结算机制、自动分账、售后回滚全链路逻辑,并附带核心Java落地代码,为同城旅游平台、本地文旅小程序搭建提供标准化技术方案。
一、本地出游套餐业务特性与开发痛点
1.1 核心业务特性
本地出游套餐属于组合型轻旅游产品,和单一票务、长线旅游订单有明显区别:
组件化组合售卖:一套套餐可自由搭配景点门票、体验项目、餐饮、短途接送、民宿住宿等多个子商品;
动态阶梯定价:支持成人/儿童/老人多价位、节假日溢价、多人团购优惠、会员折扣叠加;
即时核销履约:用户到店/入园扫码即可核销,无需等待出行批次,履约链路短、时效高;
结算后置机制:行业通用规则:用户下单支付资金先平台托管,核销履约完成后触发结算分账,规避未履约退款资金风险;
多方分账场景:一笔订单资金需拆分平台服务费、商家营收、渠道分销佣金、达人推广分成。
1.2 高频开发痛点
组合库存校验缺失:套餐包含多个子项目,仅校验主商品库存,子项目库存不足导致套餐下单成功但无法履约;
价格计算混乱:多优惠叠加、节假日溢价、人数计价逻辑混乱,出现多收、少收金额问题;
结算时机错误:下单即结算分账,用户退款、过期未核销后资金无法回滚,造成平台资金亏损;
分账比例固化:无法按节假日、活动档期、渠道类型动态调整分成比例,运营灵活性差;
对账链路不闭环:订单、支付、核销、分账、退款数据孤立,人工对账成本高、错账无法溯源。
二、整体架构与技术设计思路
2.1 技术栈选型
针对套餐组合交易、高并发下单、后置结算、资金一致性要求,采用稳定轻量化技术栈:
Java SpringBoot + MyBatis Plus + MySQL8.0 + Redis + 分布式锁 + 定时任务
核心能力支撑:组合库存预扣、价格规则引擎、订单状态机、资金冻结解冻、核销后置结算、自动分账、周期对账兜底。
2.2 分层业务架构
产品层:本地套餐管理、子商品组件配置、价格规则、库存规则、退改规则配置;
交易层:套餐下单、多维度价格计算、组合库存校验、支付回调、订单状态流转;
履约层:扫码核销、履约状态变更、套餐子项目逐一核销校验;
结算层:资金冻结、核销后自动分账、佣金结算、商家回款、退款资金回滚;
兜底层:超时未核销订单退款、库存回收、每日对账、异常订单修复。
三、本地出游套餐产品核心设计
3.1 套餐组件化模型
采用套餐主商品+多子项组件设计,主套餐负责展示、定价、下单,子项绑定具体履约资源(景点、体验项目、餐饮)。下单时必须校验所有子项库存充足,采用全有或全无机制,避免部分资源缺失导致履约失败。
3.2 多层价格规则引擎
价格计算采用优先级顺序:原价 → 人数阶梯价 → 会员折扣 → 平台优惠券 → 节假日溢价,每一层计算单独留存日志,保证价格透明可溯源,杜绝计算误差。
3.3 套餐订单状态闭环
完整状态流转:待支付 → 已支付(资金冻结) → 部分核销 / 全部核销 → 结算分账 / 过期退款 / 主动退款
核心原则:未核销、未履约订单,所有资金、佣金始终处于冻结状态,不进行真实结算。
四、订单结算核心逻辑(行业标准方案)
4.1 资金托管机制
用户支付成功后,资金直接进入平台托管账户,不立即分给商家和渠道。同时冻结对应比例的平台服务费、渠道佣金、商家营收,仅记录账务数据,不实际转账。
4.2 核销后置结算触发机制
本地出游套餐统一采用核销完成后结算方案:
单项目套餐:核销成功瞬间,触发自动分账;
多项目组合套餐:全部子项核销完成后统一结算;
过期未核销:系统自动退款,所有冻结资金全部解冻回滚。
4.3 多方分账规则
一笔订单资金拆分逻辑:
订单实付金额 = 平台服务费 + 商家营收 + 渠道分销佣金
支持固定比例分账、阶梯分账、定额抽成三种模式,后台可针对不同套餐、不同渠道独立配置。
4.4 售后回滚机制
结算前退款:全额解冻资金,无任何资金损耗;结算后退款:从商家、平台、渠道已结算资金中按比例扣回,生成负向结算账单,保证账务平衡。
五、核心Java代码实战落地
5.1 订单结算状态枚举
/** * 本地出游套餐订单结算状态枚举 * 资金冻结、核销结算、退款回滚闭环管控 */ public enum PackageSettleStatusEnum { PAY_FROZEN(1, "已支付资金冻结"), PART_VERIFY(2, "部分核销待结算"), FULL_VERIFY_SETTLE(3, "全部核销已结算"), REFUND_ROLLBACK(4, "退款资金已回滚"), EXPIRE_CANCEL(5, "过期未核销自动取消"); private final Integer code; private final String desc; PackageSettleStatusEnum(Integer code, String desc) { this.code = code; this.desc = desc; } public Integer getCode() { return code; } }
5.2 套餐下单库存冻结与资金预结算
@Service @Transactional(rollbackFor = Exception.class) @Slf4j public class TravelPackageOrderServiceImpl implements TravelPackageOrderService { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private PackageStockMapper stockMapper; @Autowired private PackageOrderMapper orderMapper; @Autowired private PackageSettleMapper settleMapper; // 套餐子项库存key private static final String PACKAGE_STOCK_KEY = "travel:package:stock:"; @Override public Result<String> createPackageOrder(PackageOrderDTO dto, Long userId) { // 1.遍历校验所有套餐子项库存(组合商品全量校验) List<Long> itemIdList = dto.getItemIdList(); for (Long itemId : itemIdList) { String stockKey = PACKAGE_STOCK_KEY + itemId; Integer stock = (Integer) redisTemplate.opsForValue().get(stockKey); if (stock == null || stock < dto.getBuyNum()) { return Result.error("套餐子商品库存不足,无法下单"); } } // 2.批量预扣库存 for (Long itemId : itemIdList) { String stockKey = PACKAGE_STOCK_KEY + itemId; redisTemplate.opsForValue().decrement(stockKey, dto.getBuyNum()); } // 3.创建套餐订单,资金冻结状态 PackageOrder order = new PackageOrder(); order.setOrderNo(UUIDUtil.getOrderNo()); order.setUserId(userId); order.setPackageId(dto.getPackageId()); order.setTotalAmount(dto.getTotalAmount()); order.setSettleStatus(PackageSettleStatusEnum.PAY_FROZEN.getCode()); order.setExpireTime(System.currentTimeMillis() + 15 * 60 * 1000); orderMapper.insert(order); // 4.生成预结算冻结记录 createFrozenSettleRecord(order); log.info("用户{}创建本地出游套餐订单成功,订单号:{}", userId, order.getOrderNo()); return Result.success(order.getOrderNo(), "下单成功,请完成支付"); } /** * 创建资金冻结预结算记录 */ private void createFrozenSettleRecord(PackageOrder order) { PackageSettle settle = new PackageSettle(); settle.setOrderId(order.getId()); settle.setOrderNo(order.getOrderNo()); settle.setTotalAmount(order.getTotalAmount()); // 按预设比例冻结资金(平台、商家、渠道) settle.setPlatformServiceFee(BigDecimal.valueOf(order.getTotalAmount()).multiply(new BigDecimal("0.10")).doubleValue()); settle.setChannelCommission(BigDecimal.valueOf(order.getTotalAmount()).multiply(new BigDecimal("0.15")).doubleValue()); settle.setMerchantIncome(BigDecimal.valueOf(order.getTotalAmount()).multiply(new BigDecimal("0.75")).doubleValue()); settle.setSettleStatus(1); settleMapper.insert(settle); } }
5.3 核销完成自动结算核心逻辑
@Service @Transactional(rollbackFor = Exception.class) @Slf4j public class PackageSettleServiceImpl implements PackageSettleService { @Autowired private PackageOrderMapper orderMapper; @Autowired private PackageSettleMapper settleMapper; @Override public Result<Boolean> settleAfterVerify(Long orderId) { // 查询订单与结算冻结记录 PackageOrder order = orderMapper.selectById(orderId); if (Objects.isNull(order) || !order.getSettleStatus().equals(1)) { return Result.error("订单状态异常,无法结算"); } PackageSettle settle = settleMapper.selectByOrderId(orderId); if (Objects.isNull(settle)) { return Result.error("无结算冻结记录"); } // 校验:所有套餐子项全部核销完成方可结算 boolean allVerify = checkAllItemVerify(orderId); if (!allVerify) { return Result.success(false, "存在未核销项目,暂不结算"); } // 状态变更为已结算,完成资金拆分入账 order.setSettleStatus(PackageSettleStatusEnum.FULL_VERIFY_SETTLE.getCode()); settle.setSettleStatus(2); settle.setSettleTime(new Date()); orderMapper.updateById(order); settleMapper.updateById(settle); // 此处可对接支付商户后台,执行真实资金分账转账 log.info("套餐订单{}核销完成,自动分账结算成功", order.getOrderNo()); return Result.success(true, "结算完成"); } // 校验所有子项是否全部核销(省略具体查询逻辑) private boolean checkAllItemVerify(Long orderId) { return true; } }
5.4 过期未核销订单资金回滚定时任务
@Component @EnableScheduling @Slf4j public class PackageExpireRecycleTask { @Autowired private PackageOrderMapper orderMapper; @Autowired private PackageSettleMapper settleMapper; @Autowired private RedisTemplate<String, Object> redisTemplate; // 每小时扫描过期套餐订单 @Scheduled(cron = "0 0 * * * ?") public void scanExpireOrder() { List<PackageOrder> expireList = orderMapper.selectExpireUnVerifyOrder(); if (CollectionUtils.isEmpty(expireList)) { return; } int rollbackNum = 0; for (PackageOrder order : expireList) { // 更新订单状态 order.setSettleStatus(PackageSettleStatusEnum.EXPIRE_CANCEL.getCode()); orderMapper.updateById(order); // 资金冻结记录回滚 PackageSettle settle = settleMapper.selectByOrderId(order.getId()); if (settle != null) { settle.setSettleStatus(3); settleMapper.updateById(settle); } // 归还套餐库存 recyclePackageStock(order); rollbackNum++; } log.info("套餐过期兜底任务执行,资金回滚订单{}条", rollbackNum); } private void recyclePackageStock(PackageOrder order) { // 库存归还逻辑省略 } }
六、核心数据库表设计
6.1 出游套餐主表(travel_local_package)
核心字段:id、package_name、package_type、price_type、discount_rule、status、create_time
说明:存储本地套餐基础信息、定价规则、优惠策略。
6.2 套餐子项关联表(travel_package_item)
核心字段:id、package_id、item_name、item_type、stock、price、sort
说明:存储套餐包含的所有子产品,用于组合库存校验与履约核销。
6.3 套餐订单表(travel_package_order)
核心字段:id、order_no、user_id、package_id、total_amount、settle_status、expire_time、create_time
说明:settle_status管控资金冻结、核销、结算、回滚全状态。
6.4 订单结算分账表(travel_package_settle)
核心字段:id、order_id、platform_fee、channel_commission、merchant_income、settle_status、settle_time
说明:记录每笔订单分账明细,是平台、商家、渠道对账的唯一依据。
七、开发优化与避坑总结
7.1 核心优化点
组合库存原子校验:套餐下单必须校验全部子项库存,全部充足方可预扣,杜绝部分履约问题;
后置结算机制:严格遵循核销后结算,彻底解决退款、过期导致的资金亏损问题;
分账规则可配置:后台可视化配置分成比例,无需改代码适配不同活动、不同渠道;
全链路日志留存:价格计算、库存扣减、资金冻结、分账结算全程日志,错账可精准溯源。
7.2 高频避坑要点
禁止下单即分账结算,本地短周期出游套餐必须采用履约后置结算方案;
多组件套餐不能只校验主商品库存,否则极易出现下单成功、子项目无资源的客诉问题;
资金冻结、结算、回滚必须加事务,防止部分执行导致账务数据错乱;
过期未核销订单必须定时兜底回收库存、回滚资金,避免长期占用资源与资金。
7.3 业务扩展方向
该架构可快速拓展拼团套餐、节假日专属套餐、会员专属价、自动周期回款、OTA渠道批量对账、财务报表自动生成等功能,适配本地文旅平台、同城旅游小程序、商家自营旅游系统的商业化落地需求。
八、总结
本地出游套餐的平台搭建,核心难点不在于商品展示与下单,而在于组合资源管控与资金结算闭环。区别于普通电商交易,旅游套餐具备“先支付、后履约、可过期、可退款、多方分账”的特殊属性,必须通过库存预扣、资金托管、核销后置结算、自动回滚的设计思路,才能保障系统稳定、账务平衡、运营合规。
本文梳理的套餐组合设计、价格引擎、冻结结算、分账逻辑、兜底回收方案,完全贴合本地旅游业务真实场景,架构轻量化、稳定性高、落地性强,可直接用于同城旅游小程序、文旅本地生活平台、商家旅游系统的开发与迭代。