最近如果关注消费市场和供应链动态,可能已经看到一条讨论度很高的消息:白酒头部品牌下半年存在收紧飞天供应的预期,企业直购业务也出现暂停安排。如果只把它当市场新闻看,大家讨论的往往是价格走势、渠道策略和经销商预期。但作为长期和后端交易系统、供应链系统打交道的技术人员,我看到这类消息时会多一层思考:一个面向企业客户的直销渠道被突然叫停,后台的订单、库存、财务、消息通知、结算开票等模块,要怎样才能平稳响应?
本文不讨论白酒行情,也不对任何品牌的具体商业策略做预测,更不会评价该消息是否属实、对企业销售影响有多大。而是把“供应收紧 + 企业直购暂停”当成一个非常典型的业务突发变更场景,聊聊交易系统中渠道策略、订单状态机、库存预占释放、任务幂等与对账审计的设计思路。文章会给出可参考的数据模型、核心代码片段和排查清单,适合正在负责交易、订单、供应链、中后台系统的开发同学阅读。
如果你是这类系统的技术负责人或核心开发,读完之后可以收获一套比较完整的应对框架:当业务方提出“某个渠道要暂停”“某个商品要限量供应”“存量订单不能继续履约”的时候,你知道该改哪些模块、新增哪些配置、使用什么批处理方式,以及如何避免线上订单和库存数据出现不可逆的错乱。
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 Boot:2.7.x 或 3.x,本文示例以 2.7.x 为主,代码里会标注不同版本配置文件的差异;
- MySQL:5.7 或 8.x,需要支持
ON UPDATE CURRENT_TIMESTAMP语法; - Redis:任意稳定版本,用于缓存渠道策略和限流配额计数;
- 持久层框架:MyBatis-Plus 或 Spring Data JPA,本文使用 MyBatis-Plus 风格示例;
- 定时任务:本地开发可以直接使用 Spring
@Scheduled,生产环境建议改为分布式任务调度平台。
如果你的项目还在使用 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?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/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 简化的订单状态机
以电商订单为例,可以把订单状态设计成如下几类:
| 状态码 | 状态 | 含义 | 常见下一步 |
|---|---|---|---|
| 0 | INIT | 已创建但未支付,也可能还没占用库存 | 去支付或超时关闭 |
| 10 | PENDING_PAYMENT | 待支付 | 支付成功或取消关闭 |
| 20 | PAID | 已支付,等待仓库发货 | 占用库存后发货 |
| 30 | OCCUPY_STOCK | 库存已占用,等待仓库出库 | 出库变成配送中 |
| 40 | DELIVERING | 配送中 | 用户签收 |
| 50 | COMPLETED | 已完成 | 进入售后或对账 |
| 60 | SUSPENDED | 已暂停,可能需要人工介入 | 继续履约或关闭退款 |
| 70 | CANCELED | 已取消 | 终态 |
| 80 | CLOSED | 已关闭,通常是系统超时或业务关停 | 终态 |
当收到“企业直购暂停”需求时,我们不能简单把所有订单都改成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 批量处理存量订单的任务设计
存量订单数量少的时候,人工处理还来得及。数量一旦上千上万,就必须靠定时任务或异步任务来处理。
下面是一个示意性的定时任务。生产环境不建议直接用 Spring@Scheduled方式在多实例下裸跑,而是应该接公司的分布式调度平台,或者在执行入口加分布式锁。
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