拓冰建站拓冰建站
首页 / 资讯中心 / 正文

供应收紧与企业直购暂停:交易系统的渠道策略、订单与库存设计

最近如果关注消费市场和供应链动态可能已经看到一条讨论度很高的消息白酒头部品牌下半年存在收紧飞天供应的预期企业直购业务也出现暂停安排。如果只把它当市场新闻看大家讨论的往往是价格走势、渠道策略和经销商预期。但作为长期和后端交易系统、供应链系统打交道的技术人员我看到这类消息时会多一层思考一个面向企业客户的直销渠道被突然叫停后台的订单、库存、财务、消息通知、结算开票等模块要怎样才能平稳响应本文不讨论白酒行情也不对任何品牌的具体商业策略做预测更不会评价该消息是否属实、对企业销售影响有多大。而是把“供应收紧 企业直购暂停”当成一个非常典型的业务突发变更场景聊聊交易系统中渠道策略、订单状态机、库存预占释放、任务幂等与对账审计的设计思路。文章会给出可参考的数据模型、核心代码片段和排查清单适合正在负责交易、订单、供应链、中后台系统的开发同学阅读。如果你是这类系统的技术负责人或核心开发读完之后可以收获一套比较完整的应对框架当业务方提出“某个渠道要暂停”“某个商品要限量供应”“存量订单不能继续履约”的时候你知道该改哪些模块、新增哪些配置、使用什么批处理方式以及如何避免线上订单和库存数据出现不可逆的错乱。1. 为什么“暂停企业直购”不是简单下架一个商品很多不了解系统复杂度的同学第一反应可能是“既然企业直购要暂停那就把前端的购买按钮隐藏或者把商品下架不就行了”真实情况远没有这么简单。在稍微成熟一点的电商或 B 端采购平台里一个企业直购渠道通常不是独立页面而是和商品中心、交易中心、库存中心、财务中心、消息中心耦合在一起的。业务方说的“暂停”背后往往包含一连串系统动作。1.1 企业直购暂停会冲击哪些系统模块我从系统边界来梳理一下影响范围。假设一个平台既有个人零售渠道也有面向企业客户的企业直购渠道当企业直购暂停时比较典型的波及范围如下表所示。业务动作对应系统模块典型影响关闭企业直购入口商品中心、前端页面PC / H5 / 小程序页面入口需要隐藏商品价格和可售状态需要重新判断停止创建新订单交易链路、购物车企业用户购物车中的商品需要提示不可售结算页需要拦截下单请求停止继续履约订单系统、WMS已经支付但未出库的订单需要重新确认是否能继续发货释放被占库存库存中心未完成订单占用的库存如果不释放会影响其他渠道正常销售停止企业结算与开票财务、结算中心企业月结额度、授信额度、开票申请等流程要同步冻结通知已下单客户消息中心需要给存量订单用户发送通知告知取消或暂停原因内部审批与权限审批流、权限中心企业直购相关白名单、客户价格协议等需要同步调整生效时间从这个表可以看出一次“暂停”动作实际上是一次跨多系统的业务流程变更而不是简单调用一个delete接口或者执行一条updateSQL 就能收尾的。1.2 最容易出问题的其实是存量订单最容易被业务人员忽视的是暂停动作对“存量订单”的影响。所谓存量订单是指在暂停生效之前用户已经创建的订单。它们往往处于不同状态已经创建但还没有支付已经支付但仓库还没有发货已经发货但还在运输途中已经签收但还没走完对账流程。不同状态的订单处理策略完全不同。比如待支付订单可以选择系统自动关闭并释放预占库存已支付未发货订单需要评估是继续发货还是退款处理已发货订单通常只能继续履约否则会产生大量的客诉和财务差异。如果系统没有良好的订单状态管理和幂等机制开发同学在处理存量订单时很容易出现重复关闭、库存释放两次、用户收到错误通知等问题。后面我会专门讲订单状态机的设计思路。1.3 为什么这类需求很容易引发线上事故从工程经验来看这类业务变更引发生产事故的原因通常有以下几个。第一只处理了增量入口没有处理存量数据。团队接到需求后第一件事就是改前端按钮或网关路由把新的下单请求拦住了但数据库里已有的待支付订单和预付订单没有处理导致库存一直被占用或者订单超时后自动任务又来执行关闭逻辑和新的暂停策略冲突。第二直接改数据库状态缺少配置化设计。业务人员说“暂停”开发图快直接UPDATE商品表或者渠道表结果本地环境验证没问题上线后因为缓存没有刷新线上用户还能继续购买。这种操作也缺少回滚能力。第三批量清理任务没有做幂等。存量订单如果很多通常会写一个定时任务去批量关闭或批量释放库存。但如果没有在操作流水里做唯一约束任务重复执行一次就可能出现库存被重复释放最终账面库存变成了负数。所以一个负责任的技术方案不应该只写“在哪行代码里加一个 if 判断”而是要从配置模型、状态机、库存事务、批量任务、监控告警几个层面做整体设计。2. 先梳理业务规则再转化成技术配置在动手写代码之前我建议先做一件事把业务方提出的“暂停企业直购”拆解成系统可以理解和执行的规则。2.1 不要写死“企业直购等于暂停”一个常见的初级做法是在订单创建代码里直接加一个判断if (enterprise_direct.equals(channelCode)) { throw new BusinessException(该渠道已暂停); }这种写法虽然能快速挡住新增订单但很快会暴露问题。业务方可能过两天说“不是所有企业直购都暂停只是部分高端商品暂停。”再过一周又说“个人零售渠道不受影响普通企业客户可以限量购买。”如果每次都是硬编码 if 判断系统会变成一团乱麻。更合理的做法是把“渠道 商品 供应状态 售卖配额 生效时间”抽象成一条策略配置。业务方需要暂停某个渠道时不是让开发改代码而是新增或修改一条策略记录。2.2 供应渠道策略表设计参考下面给出一个简化版的供应链策略表设计可以作为起点按需扩展。-- 文件路径src/main/resources/db/schema.sql CREATE TABLE supply_strategy ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, channel_code VARCHAR(32) NOT NULL COMMENT 渠道编码enterprise_direct 表示企业直购, product_code VARCHAR(64) NOT NULL COMMENT 商品编码如对应一个 SKU, supply_status TINYINT NOT NULL COMMENT 供应状态1-正常0-暂停2-限量, daily_quota INT NOT NULL DEFAULT 0 COMMENT 每日限量0 表示不限制, enterprise_scope VARCHAR(512) DEFAULT NULL COMMENT 适用企业白名单多个企业用逗号分隔空表示全部, effective_start DATETIME NOT NULL COMMENT 策略生效开始时间, effective_end DATETIME NOT NULL COMMENT 策略生效结束时间, need_approval TINYINT NOT NULL DEFAULT 0 COMMENT 是否需要审批, audit_status TINYINT NOT NULL DEFAULT 1 COMMENT 审批状态0-草稿1-已生效2-已驳回, created_by VARCHAR(32) DEFAULT NULL COMMENT 创建人, updated_by VARCHAR(32) DEFAULT NULL COMMENT 更新人, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, KEY idx_channel_product (channel_code, product_code) ) COMMENT 供应渠道策略表;这里有几个字段值得单独说明。daily_quota表达“限量”的概念。如果业务方希望企业直购每天最多只有 100 箱供应量那么可以把状态设置为限量并设置每日配额。系统在创建订单前先判断当天已消耗的配额没有超过就允许继续下单超过则提示无货。enterprise_scope表达适用客户范围。如果“企业直购暂停”只针对非白名单客户而部分重点企业客户仍然可以采购那么这个字段就能派上用场。更复杂的场景可以拆成一张企业授权子表但本文为了演示先保留这样的设计方式。effective_start和effective_end可以让策略在指定时间自动生效和自动过期。这在实际操作中很重要因为业务方往往不会记得手动恢复配置定时策略能减少人工操作的遗漏。2.3 配置化带来的实际好处把规则做成配置之后后续很多操作就不再需要上线发版了。业务人员在管理后台填写暂停策略后台审批通过后订单校验服务读取最新配置就能立即生效。这个过程天然会留下操作记录和审批链路出了问题也更容易回溯。当然配置化也不是银弹。策略表本身必须有缓存设计否则每次下单都查询数据库并发上来之后数据库压力会很大。同时配置变更必须有完善的刷新机制避免缓存里一直是旧数据。3. 环境准备与示例工程结构为了让后续代码更容易理解我们先用一套常见的 Java Spring Boot 技术栈搭建一个示例工程。这里不对版本做过死约束因为不同的公司内部基础组件版本差异较大。3.1 建议技术栈与版本说明JDK建议 JDK 8 或 JDK 17取决于 Spring Boot 主版本Spring Boot2.7.x 或 3.x本文示例以 2.7.x 为主代码里会标注不同版本配置文件的差异MySQL5.7 或 8.x需要支持ON UPDATE CURRENT_TIMESTAMP语法Redis任意稳定版本用于缓存渠道策略和限流配额计数持久层框架MyBatis-Plus 或 Spring Data JPA本文使用 MyBatis-Plus 风格示例定时任务本地开发可以直接使用 SpringScheduled生产环境建议改为分布式任务调度平台。如果你的项目还在使用 Spring Boot 2.x那么 Redis 配置前缀是spring.redis如果使用 Spring Boot 3.x则配置前缀调整为spring.data.redis。这一点比较容易踩坑需要根据实际情况调整。3.2 示例项目结构supply-demo ├── pom.xml ├── src/main/java/com/example/supply │ ├── SupplyApplication.java │ ├── controller │ │ └── OrderCreateController.java │ ├── service │ │ ├── SupplyStrategyChecker.java │ │ ├── PendingOrderCancelJob.java │ │ └── InventoryService.java │ ├── dao │ │ └── SupplyStrategyMapper.java │ ├── entity │ │ └── SupplyStrategyDO.java │ └── enums │ └── SupplyStatus.java └── src/main/resources ├── application.yml └── db/schema.sql这是一个很标准的单体分层结构。本文重点在service层和entity层其他部分读者可以根据自己的项目情况补充。3.3 配置文件参考server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/supply_demo?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: change-me redis: host: 127.0.0.1 port: 6379 timeout: 3000ms mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true这里给出的是 Spring Boot 2.x 时代的配置写法。如果你使用的是 Spring Boot 3.x需要将spring.redis改为spring.data.redis。具体的连接池参数、密码配置、SSL 配置等请结合公司的中间件规范调整。4. 核心设计一渠道策略的读取与校验4.1 渠道策略实体的简单实现有了策略表之后我们需要在 Java 代码里定义一个对应的实体类。package com.example.supply.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; Data TableName(supply_strategy) public class SupplyStrategyDO { TableId(type IdType.AUTO) private Long id; private String channelCode; private String productCode; private Integer supplyStatus; private Integer dailyQuota; private String enterpriseScope; private LocalDateTime effectiveStart; private LocalDateTime effectiveEnd; private Integer needApproval; private Integer auditStatus; private String createdBy; private String updatedBy; private LocalDateTime createTime; private LocalDateTime updateTime; }实际项目中建议分离 DO、DTO、BO但示例工程为了减少文件数量直接用 DO 承载查询结果。4.2 供应状态枚举供应状态尽量用枚举管理不要散落在各处魔法数字。package com.example.supply.enums; public enum SupplyStatus { NORMAL(1, 正常供应), PAUSED(0, 暂停供应), LIMITED(2, 限量供应); private final Integer code; private final String desc; SupplyStatus(Integer code, String desc) { this.code code; this.desc desc; } public Integer getCode() { return code; } public String getDesc() { return desc; } }当后续代码里出现strategy.getSupplyStatus() 0这类写法时可以用枚举语义替代可读性会好很多。4.3 策略校验的核心服务下面这段代码是渠道策略校验的核心逻辑。需要提前说明的是这里给出的只是核心方法片段不是完整可直接启动的工程。读者需要将它放入自己的 Service 类中并结合实际 DAO 和 Redis 序列化方案调整。package com.example.supply.service; import com.example.supply.entity.SupplyStrategyDO; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.time.Duration; import java.time.LocalDateTime; Slf4j Service public class SupplyStrategyChecker { Autowired private SupplyStrategyMapper strategyMapper; Autowired private StringRedisTemplate redisTemplate; /** * 检查指定渠道和商品当前是否允许售卖 */ public boolean checkChannelCanSale(String channelCode, String productCode, LocalDateTime now) { SupplyStrategyDO strategy getEffectiveStrategy(channelCode, productCode, now); // 没有配置策略时默认拒绝避免漏配置导致渠道继续售卖 if (strategy null) { log.warn(当前渠道商品未配置供应策略, channel{}, product{}, channelCode, productCode); return false; } Integer status strategy.getSupplyStatus(); // 暂停状态直接拒绝 if (SupplyStatus.PAUSED.getCode().equals(status)) { return false; } // 限量状态需要判断当日配额 if (SupplyStatus.LIMITED.getCode().equals(status)) { return consumeDailyQuota(channelCode, productCode, strategy.getDailyQuota(), now); } return true; } private SupplyStrategyDO getEffectiveStrategy(String channelCode, String productCode, LocalDateTime now) { String cacheKey buildCacheKey(channelCode, productCode); String cached redisTemplate.opsForValue().get(cacheKey); if (cached ! null) { try { // 实际项目里建议统一使用 JSON 序列化工具例如 Jackson、Fastjson 等 return parseStrategy(cached); } catch (Exception e) { log.error(解析供应策略缓存失败, key{}, cacheKey, e); // 缓存异常时继续查库避免缓存数据损坏导致功能不可用 } } SupplyStrategyDO strategy strategyMapper.selectEffectiveOne( channelCode, productCode, now.toLocalDate()); if (strategy ! null) { // 实际项目建议设置随机过期时间避免缓存雪崩 redisTemplate.opsForValue().set(cacheKey, toJson(strategy), Duration.ofMinutes(5)); } return strategy; } private boolean consumeDailyQuota(String channelCode, String productCode, Integer dailyQuota, LocalDateTime now) { if (dailyQuota null || dailyQuota 0) { return true; } String quotaKey supply:quota: channelCode : productCode : now.toLocalDate(); Long used redisTemplate.opsForValue().increment(quotaKey); // 第一次创建 key 时设置过期时间 if (used ! null used 1L) { redisTemplate.expire(quotaKey, Duration.ofMinutes(30)); } return used ! null used dailyQuota; } private String buildCacheKey(String channelCode, String productCode) { return supply:strategy: channelCode : productCode; } // 以下两个方法结合项目里的 JSON 工具实现 private SupplyStrategyDO parseStrategy(String json) { // return JSON.parseObject(json, SupplyStrategyDO.class); throw new UnsupportedOperationException(请替换为项目实际 JSON 工具); } private String toJson(SupplyStrategyDO strategy) { // return JSON.toJSONString(strategy); throw new UnsupportedOperationException(请替换为项目实际 JSON 工具); } }4.4 配置缓存和更新机制的注意事项这段代码中缓存key按照“渠道编码 商品编码”维度存储。如果策略表里加入了企业白名单维度key还要考虑企业维度否则一个企业被移出白名单后另一个企业仍然会读到相同的旧缓存。缓存过期时间设在 5 分钟左右可以在业务紧急变更后快速生效。但如果业务要求“立即生效”那么后台修改策略时还需要主动删除 Redis 缓存而不是等它自动过期。使用 Redis 配额计数时要注意示例代码里的increment是一个原子操作能够解决大部分并发下的超卖问题。不过这个方案也有边界如果用户在下单前校验消耗了配额但最终没有完成支付那么配额需要回补。更严谨的做法是在订单成功创建后真正扣减配额或者在用户取消订单时回补配额。4.5 在下单接口中应用渠道策略核心校验逻辑写好后只需要在下单创建接口的最前面调用它。package com.example.supply.controller; import com.example.supply.service.SupplyStrategyChecker; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.time.LocalDateTime; RestController RequestMapping(/order) public class OrderCreateController { Autowired private SupplyStrategyChecker supplyStrategyChecker; PostMapping(/create) public String createOrder(String channelCode, String productCode) { boolean canSale supplyStrategyChecker.checkChannelCanSale( channelCode, productCode, LocalDateTime.now()); if (!canSale) { return 当前渠道暂停供应或超出当日限量; } // 继续走创建订单主流程这里只做示意 return 下单成功; } }5. 核心设计二订单状态机与存量订单处理渠道入口关停之后接下来要处理的是已经存在的企业直购订单。处理存量订单前必须先搞清楚订单现在处于什么状态以及每个状态能流转到哪些终态。5.1 简化的订单状态机以电商订单为例可以把订单状态设计成如下几类状态码状态含义常见下一步0INIT已创建但未支付也可能还没占用库存去支付或超时关闭10PENDING_PAYMENT待支付支付成功或取消关闭20PAID已支付等待仓库发货占用库存后发货30OCCUPY_STOCK库存已占用等待仓库出库出库变成配送中40DELIVERING配送中用户签收50COMPLETED已完成进入售后或对账60SUSPENDED已暂停可能需要人工介入继续履约或关闭退款70CANCELED已取消终态80CLOSED已关闭通常是系统超时或业务关停终态当收到“企业直购暂停”需求时我们不能简单把所有订单都改成CLOSED。比较稳妥的原则是待支付订单自动关闭释放未使用的优惠券和库存预占已支付未发货订单标记为SUSPENDED先冻结发货等待业务决策已发货订单保持继续配送不阻断正在履约的包裹已完成订单不需要受影响正常进入售后和财务流程。这样做可以减少客诉和赔偿范围。不要一上来就把所有已支付订单都取消掉因为用户可能已经为这批商品安排了下一步使用计划而且大规模退款会造成资金和税务处理压力。5.2 使用枚举定义订单状态package com.example.supply.enums; public enum OrderStatus { INIT(0, 已创建), PENDING_PAYMENT(10, 待支付), PAID(20, 已支付), OCCUPY_STOCK(30, 库存已占用), DELIVERING(40, 配送中), COMPLETED(50, 已完成), SUSPENDED(60, 已暂停), CANCELED(70, 已取消), CLOSED(80, 已关闭); private final Integer code; private final String desc; OrderStatus(Integer code, String desc) { this.code code; this.desc desc; } public Integer getCode() { return code; } public String getDesc() { return desc; } }5.3 批量处理存量订单的任务设计存量订单数量少的时候人工处理还来得及。数量一旦上千上万就必须靠定时任务或异步任务来处理。下面是一个示意性的定时任务。生产环境不建议直接用 SpringScheduled方式在多实例下裸跑而是应该接公司的分布式调度平台或者在执行入口加分布式锁。package com.example.supply.service; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; Slf4j Component public class PendingOrderCancelJob { Autowired private SupplyStrategyChecker supplyStrategyChecker; Autowired private OrderService orderService; Scheduled(cron 0 */5 * * * ?) public void cancelOrdersForPausedStrategy() { log.info(开始处理暂停渠道下的存量订单); // 1. 查询当前暂停中的策略列表 // 2. 遍历策略列表查询对应的存量订单 // 3. 对订单逐个执行关闭或挂起操作 // 4. 每个订单的处理都要记录操作日志 // 这里省略具体 DAO 调用实际项目中会分页查询避免一次加载过多数据 log.info(存量订单处理结束); } }为什么这里强调“分页查询”如果一次性查出几万条待处理订单然后再循环执行更新数据库连接可能被长时间占用主从延迟也会扩大。一个更合理的批量处理方式是每查出一页数据就放在一个小事务里处理提交后继续处理下一页。每个订单的关闭操作要尽量独立避免因为一条脏数据导致整个批量任务回滚。5.4 幂等设计防止重复关闭和重复退款批量任务最怕重复执行。假设任务第一次执行到一半应用宕机了重启后任务又从第一条开始这时已经把状态改为关闭的订单很可能会被再次处理。解决思路是增加一张订单操作流水表并对“订单号 操作类型”建立唯一索引。CREATE TABLE order_operation_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL COMMENT 订单号, operation_code VARCHAR(32) NOT NULL COMMENT 操作类型如 PAUSE_ORDER / CLOSE_ORDER / RELEASE_STOCK, status TINYINT NOT NULL DEFAULT 0 COMMENT 操作状态0-处理中1-成功2-失败, operator VARCHAR(32) DEFAULT NULL COMMENT 操作人系统任务可填 system, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_operation (order_no, operation_code) ) COMMENT 订单操作流水表;在更新订单状态之前先尝试插入一条operation_code为CLOSE_ORDER的流水。如果插入成功说明这个操作没有被执行过可以继续后续处理如果插入发生唯一键冲突说明已经处理过直接跳过。这个方案成本低而且天然支持幂等。6. 核心设计三库存预占与释放闭环订单暂停之后库存数据容易变成一本糊涂账。很多线上问题并不是直接发生在订单表而是库存表。6.1 区分可用库存和占用库存库存系统常见的字段包括available_qty可用库存也就是用户下单时能看到的可售数量occupied_qty占用库存也就是订单已经预占但还没出库的数量。一次完整的购买流程典型库存变化如下用户下单成功后扣减available_qty增加occupied_qty仓库发货成功扣减occupied_qty用户取消订单扣减occupied_qty回补available_qty
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门