大家好,我是五阳,专注于分享电商交易系统设计~
最近我发现抖音上售卖的券包优惠力度很大,也囤了很多优惠券,我在想,能否模仿抖音券包,快速实现一个券包类虚拟商品售卖的交易系统呢?
说干就干,我借助于 memberclub 电商交易中台的SDK,用了将近1天的时间顺利完成开发。
关键需求分析
在抖音上券包的交易过程如下
- 进入商品页面,预览券包商品详情,可以看到抖音上的券包商品有有效期限制,过期后还支持过期退。
- 在提单页面,可以加购商品,支持单商品多份数 购买。如果多次勾选会发现,部分商品有单用户购买限额。(非商品库存)
- 支付完成后,抖音会和三方系统或自身券系统交互,为用户履约发券。
- 用户可以购买记录页,主动发起退款。
- 进入售后预览 页面后,可看到退款金额。用户确定退款后,抖音会逆向履约,回收优惠券,最终原路退款。
抖音券包的交易流程
关键词
包括
- 提单、用户限额、加购(多份数购买)、履约、逆向履约、售后预览、售后退款、过期退
为了叫着方便,模仿的抖音券包,我把它抖阳券包
仿抖音券包效果图
我花了1天时间,模仿抖音实现了券包交易过程。(五阳擅长后端,前端技术太差劲。后端这些功能有2个小时就搞定了,但是实现这些前端页面花了将近1天,然而效果图还是很low,意思一下,各位兄台多多包涵)
交易过程
- 进入主页后,可以看到券包商品、京东PLus会员商品列表。在主页可以选择直接提单购买,也可以加购物车,在购物车页面,可以选择多个商品一起提单支付和履约。
- 加购物车以后,点击结算,就会触发后端提单。示例中,我选择了两个券包商品,其中一个商品购买两份,模仿了抖音的多份数购买能力。选择提单并且模拟支付。(没有实现收银支付能力~ 坦白讲,五阳不擅长支付)
- 支付完成后,后端会履约券包,本次购买场景为:多商品、多份数购买。因为履约能力要支持多商品履约、多份数履约。
- 我在商品模型中,配置了券包购买限额为4,因此购买4份券包以后,再次提单会因购买限额不足而提单失败。
- 最后一步,在购买记录页,我点击退款按钮后,会触发售后预览,告诉我可以退的金额和赔付方式,点击确定后,系统会执行逆向履约,回收优惠券。同时要求系统要具备多商品、多份数场景的逆向履约能力。以上内容完成后,最终执行用户退款。
一点思考
虽然我不是抖音员工,但还是想说一句。抖音上有一点做的非常好,对三方系统的券包发起退款,抖音是极速垫付退款。在三方券可能还未回收之际,就先赔付用户。我猜测券包履约方为三方公司时,两套系统外加公网交互,数据不一致性问题会比较严重,如果先冻券,再退款,数据不一致时,极可能导致售后卡单,为了优化用户体验,就同时发起先退款和冻券。 虽然增加了资金损失风险,但是用户体验更好!这是一种平衡~
言归正传,看看,五阳1天时间如何实现的山寨版券包交易流程
实现思路
我认为,在系统设计阶段,正确的做法是先抽象业务共性,剥离业务特性,这样才能更好的抽象,构建的系统扩展性也更强。
从如上需求分析来看,抖音券包、京东会员等虚拟商品的交易过程具备较多的业务共性,在实现抖阳券包的交易能力时,我们应该首先抽象出这些业务共性,构建一个通用的虚拟商品交易中台。业务新接入时,为其分配新的业务身份,然后服用这部分通用能力,实现新业务的快速接入和未来快速扩展。
Memberclub项目提供了虚拟商品的交易解决方案,在各类购买场景下提供各类虚拟商品形态的履约及售后结算能力,它抽象了会员等虚拟商品的业务共性,同时对业务差异性预置了扩展点,通过插件方便扩展。
因此我会基于memberclub ,快速地搭建抖阳券包所需要的交易能力。
具体方案
定义业务身份
在 BizTypeEnum 业务身份枚举中定义,抖阳券包业务身份: 2
js
/**
* @author 掘金五阳
*/
public enum BizTypeEnum {
DEFAULT(0, "default_biz"),
DEMO_MEMBER(1, "demo_member"),
VIDEO_MEMBER(3, "video_member"),
MUSIC_MEMBER(4, "music_member"),
DOUYIN_COUPON_PACKAGE(2, "douyin_coupon_package"),//douyin 优惠券包,支持过期退、多份数购买
;
}
商品模型定义
memberclub 中商品模型如下,包括业务身份,商品展示信息、商品结算信息、商品售卖信息、商品履约信息、商品限额信息、商品库存信息、及其他扩展字段。
新增抖阳券包时,商品模型无需新增或修改,仅需要再数据库中,创建券包商品数据。
js
public class SkuInfoDO {
private long skuId;
private int bizType;
private SkuViewInfo viewInfo = new SkuViewInfo();
private SkuFinanceInfo financeInfo = new SkuFinanceInfo();
private SkuSaleInfo saleInfo = new SkuSaleInfo();
private SkuPerformConfigDO performConfig = new SkuPerformConfigDO();
private SkuInventoryInfo inventoryInfo = new SkuInventoryInfo();
private SkuRestrictInfo restrictInfo = new SkuRestrictInfo();
private SkuExtra extra = new SkuExtra();
private long utime;
private long ctime;
}
新增商品券包数据如下。(下面的json被打平了,感兴趣可以在 JSON 可视化以后查看。)
perl
{"skuId":200404,"buyCount":0,"bizType":2,"viewInfo":{"displayImage":"https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg.alicdn.com%2Fbao%2Fuploaded%2Fi3%2F374544688%2FO1CN016Zx2lK1kV9QkrD6gW_%21%210-item_pic.jpg&refer=http%3A%2F%2Fimg.alicdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1742951021&t=9c87d26097559e952d220dff49a9d060","displayName":"15元混合券包","displayDesc":"无门槛组合券15元;有效期14天;过期退;有效期内限购4次","internalName":"15元混合券包","internalDesc":"无门槛组合券15元"},"financeInfo":{"contractorId":"438098434","settlePriceFen":900,"periodCycle":1,"financeProductType":1},"saleInfo":{"originPriceFen":1500,"salePriceFen":900},"performConfig":{"configs":[{"bizType":2,"rightType":1,"rightId":32424,"assetCount":1,"periodCount":14,"periodType":1,"cycle":1,"providerId":"1","grantInfo":{},"settleInfo":{"contractorId":"438098434","settlePriceFen":500,"financeAssetType":1,"financeable":true},"viewInfo":{"displayName":"5元立减券"},"saleInfo":{}},{"bizType":2,"rightType":1,"rightId":32423,"assetCount":1,"periodCount":14,"periodType":1,"cycle":1,"providerId":"1","grantInfo":{},"settleInfo":{"contractorId":"438098434","settlePriceFen":1000,"financeAssetType":1,"financeable":true},"viewInfo":{"displayName":"10元立减券"},"saleInfo":{}}]},"inventoryInfo":{"enable":false,"type":0},"restrictInfo":{"enable":true,"restrictItems":[{"periodType":"TOTAL","periodCount":14,"itemType":"TOTAL","userTypes":["USERID"],"total":4}]},"extra":{},"utime":0,"ctime":0}
以上商品数据定义在 这里,可以自行插入到数据库。
购买域实现
购买域主要包括三个业务接口,分别为提交订单、(未支付,主动取消)取消订单、售后取消订单。
java
@ExtensionConfig(desc = "购买流程扩展点", type = ExtensionType.PURCHASE, must = true)
public interface PurchaseExtension extends BaseExtension {
public void submit(PurchaseSubmitContext context);
public void reverse(AfterSaleApplyContext context);
public void cancel(PurchaseCancelContext context);
}
我们定义 DouyinPkgPurchaseExtension 实现该接口的三个业务方法,需要明确的是MemberClub在设计阶段就是定义为电商交易中台项目,所提供的能力均可轻松复用。复用方式主要包括两类:流程编排复用、扩展点定义复用。
从下面的类的提单方法可以看到,每个业务方法并非定制化实现,而是通过流程引擎编排流程节点,每个流程节点执行某一类业务逻辑。如提单流程中一共6个节点,分别为
- 交易锁节点
- 商品数据查询、校验和初始化节点
- 提单上下文数据校验
- 检查限额
- 记录会员单
- 通用订单中心提单(委托于订单中台接入收银支付等)
由于需求上不包含库存限制,因此在流程编排上去除了库存相关节点。如果抖音券包在提单流程上还有其他流程,可以新增流程节点,编排进抖音券包提单扩展点即可,不会影响到已接入的其他业务,最大程度实现了业务隔离。
js
@ExtensionProvider(desc = "抖阳券包 购买提单扩展点", bizScenes = {
@Route(bizType = BizTypeEnum.DOUYIN_COUPON_PACKAGE, scenes = {SceneEnum.HOMEPAGE_SUBMIT_SCENE})
})
public class DouyinPkgPurchaseExtension implements PurchaseExtension {
private static FlowChain<PurchaseSubmitContext> submitChain = null;
private static FlowChain<AfterSaleApplyContext> purchaseReverseChain = null;
private static FlowChain<PurchaseCancelContext> purchaseCancelFlowChain = null;
@Autowired
private MemberOrderDomainService memberOrderDomainService;
@PostConstruct
public void init() {
submitChain = FlowChain.newChain(PurchaseSubmitContext.class)
.addNode(PurchaseSubmitLockFlow.class)
.addNode(SkuInfoInitalSubmitFlow.class)
.addNode(PurchaseSubmitCmdValidateFlow.class)
.addNode(PurchaseUserQuotaFlow.class) //检查限额
//.addNode(PurchaseValidateInventoryFlow.class) //检查库存
.addNode(MemberOrderSubmitFlow.class) // 会员提单
//.addNode(PurchaseMarkNewMemberFlow.class) //新会员标记
//.addNode(PurchaseOperateInventoryFlow.class) //扣减库存
.addNode(CommonOrderSubmitFlow.class) //订单系统提单
;
purchaseReverseChain = FlowChain.newChain(AfterSaleApplyContext.class)
//.addNode(PurchaseReverseNewMemberFlow.class)
//.addNode(PurchaseReverseInventoryFlow.class)
.addNode(PurchaseReverseMemberQuotaFlow.class)
//
;
purchaseCancelFlowChain = FlowChain.newChain(PurchaseCancelContext.class)
.addNode(PurchaseCancelLockFlow.class)
.addNode(PurchaseCancelOrderFlow.class)
//.addNode(PurchaseCancelNewMemberFlow.class)
.addNode(PurchaseCancelQuotaFlow.class)
//.addNode(PurchaseCancelInventoryFlow.class)
;
}
@Override
public void submit(PurchaseSubmitContext context) {
submitChain.execute(context);
}
@Override
public void reverse(AfterSaleApplyContext context) {
purchaseReverseChain.execute(context);
}
@Override
public void cancel(PurchaseCancelContext context) {
MemberOrderDO memberOrder = memberOrderDomainService.
getMemberOrderDO(context.getCmd().getUserId(), context.getCmd().getTradeId());
context.setMemberOrder(memberOrder);
purchaseCancelFlowChain.execute(context);
}
}
履约域实现
商品履约主要在拆合单和权益履约部分需要扩展。
拆单部分
商品购买时因为履约、结算、售后等方面的诉求往往需要拆单履约。怎么理解呢?举例说明
- 用户购买两个商品,系统会拆分为两个子订单,分别进行履约。
- 用户购买1个商品,但是包含多类权益,需要不同的履约方进行履约,会将商品中包含的多个权益,拆分到履约项,分别进行履约。
- 用户购买年卡等商品,需要多期履约。系统会拆分为12期,分别进行履约。
可以看到拆单部分主要是对订单中的商品、商品中的权益进行拆分,制定履约计划,分别进行履约。 抖阳券包支持多份数购买,因此在拆单阶段会根据购买份数,拆单为多个履约项进行履约。
考虑到抖阳券包和默认券包的拆单能力诉求相同,我们直接在原来的扩展点上,添加抖阳券包业务身份,这样抖阳券包就能直接复用这个扩展点。
js
@ExtensionConfig(desc = "履约拆单 扩展点", type = ExtensionType.PERFORM_MAIN, must = true)
public interface PerformSeparateOrderExtension extends BaseExtension {
public void separateOrder(PerformContext context);
}
js
@ExtensionProvider(desc = "默认 履约上下文构建", bizScenes = {
@Route(bizType = BizTypeEnum.DEMO_MEMBER, scenes = {SceneEnum.SCENE_MONTH_CARD}),
@Route(bizType = BizTypeEnum.DOUYIN_COUPON_PACKAGE, scenes = {SceneEnum.SCENE_MONTH_CARD}),
})
public class DemoMemberPerformSeparateOrderExtension implements PerformSeparateOrderExtension {
FlowChain<PerformContext> performSeparateOrderChain = null;
@Autowired
private FlowChainService flowChainService;
@PostConstruct
public void run() throws Exception {
performSeparateOrderChain = FlowChain.newChain(flowChainService, PerformContext.class)
.addNode(InitialSkuPerformContextsFlow.class)
.addNode(MutilBuyCountClonePerformItemFlow.class)
//如果年卡周期是自然月,则可以在此处根据当前期数计算每期的天数
.addNode(CalculateImmediatePerformItemPeriodFlow.class)//计算立即履约项 时间周期
.addNode(CalculateOrderPeriodFlow.class)//计算订单整体有效期
.addNode(PerformContextExtraInfoBuildFlow.class)// 构建扩展属性
;
}
@Override
public void separateOrder(PerformContext context) {
flowChainService.execute(performSeparateOrderChain, context);
}
}
履约执行阶段扩展点
履约执行阶段的主要工作包括
- 交易锁
- 会员交易主单和子单模型记录和修改
- 多商品履约能力
- 创建和修改履约项
- 调用履约方,发放权益。
由于抖阳券包需要支持过期退,因此在履约完成以后需要在任务表新增一条任务,系统每日定时执行已过期的任务。在券包过期后,系统自动发起售后。因此在执行扩展点新增了 MemberExpireRefundTaskCreatedFlow 流程节点,用来新增任务。
js
@ExtensionProvider(desc = "抖阳券包执行履约扩展点", bizScenes = {
@Route(bizType = BizTypeEnum.DOUYIN_COUPON_PACKAGE, scenes = {SceneEnum.SCENE_MONTH_CARD})//抖音券包月卡,多份数, 多商品
})
public class DouyinPkgPerformExecuteExtension implements PerformExecuteExtension {
private FlowChain<PerformContext> flowChain;
private FlowChain<PerformContext> subFlowChain;
@Autowired
private FlowChainService flowChainService;
@PostConstruct
public void init() {
subFlowChain = FlowChain.newChain(flowChainService, PerformContext.class)
.addNode(SingleSubOrderPerformFlow.class)
.addNodeWithSubNodes(ImmediatePerformFlow.class, PerformItemContext.class,
// 构建 MemberPerformItem, 发放权益
ImmutableList.of(MemberPerformItemFlow.class, PerformItemGrantFlow.class));
flowChain = FlowChain.newChain(flowChainService, PerformContext.class)
.addNode(MemberResourcesLockFlow.class)
.addNode(MemberOrderOnPerformSuccessFlow.class)
.addNode(MemberPerformMessageFlow.class)
.addNode(MemberExpireRefundTaskCreatedFlow.class)
.addNodeWithSubNodes(MutilSubOrderPerformFlow.class, subFlowChain)
;
}
@Override
public void execute(PerformContext context) {
flowChainService.execute(flowChain, context);
}
}
权益发放扩展点
抖阳券包在商品履约配置内容中,仅需要配置1张优惠券即可,毕竟券包售卖就是花钱买券包。 券包发放也比较简单,制定权益ID,绑定券模版ID、指定券包有效期和发券张数即可。(如果业务上还需要其他发放参数,在扩展字段中扩展即可)
我们定义了权益履约的通用SPI接口,履约方实现该接口(或者履约方定义接口,我们适配也行,只要保持接口协议稳定,不经常变化就行。)
less
public interface AssetsFacadeSPI {
@RequestMapping(method = RequestMethod.POST, value = "/items/grant")
public GrantResponseDO grant(@RequestBody GrantRequestDO requestDO);
@RequestMapping(method = RequestMethod.POST, value = "/items/fetch")
public AssetFetchResponseDO fetch(@RequestBody AssetFetchRequestDO request);
@RequestMapping(method = RequestMethod.POST, value = "/items/reverse")
public AssetReverseResponseDO reverse(@RequestBody AssetReverseRequestDO request);
}
由于抖阳券包基于最基础的立减优惠券权益,系统已基于SPI接口实现,因此复用系统扩展点即可,无需新增。
售后域实现
售后主要包括三个业务方法,分别为
- 售后预览:预览券包订单是否可以退,能退多少钱,如何赔付等。
- 售后提单:提交售后单,并且发起逆向履约、逆向提单、退款等流程。
- 过期退:一个特殊售后渠道,系统需要基于定时任务,为过期的订单自动发起退款!
售后预览
售后预览阶段主要是检查用户订单数据合法性,如状态机校验、有效期校验、券包使用状态校验。抖阳券包计算券包是否可退时,基于券使用状态,对每张券分摊的实付金额进行加和,计算未使用券包的金额,如果金额大于0,则可以退,如果金额为0,则不可退。
以下是售后预览阶段的扩展点。
js
@ExtensionProvider(desc = "抖音券包售后预览扩展点", bizScenes = {
@Route(bizType = BizTypeEnum.DOUYIN_COUPON_PACKAGE, scenes = {SceneEnum.SCENE_AFTERSALE_MONTH_CARD})
})
public class DouyinPkgAftersalePreviewExtension implements AftersalePreviewExtension {
private FlowChain<AftersalePreviewContext> previewChain = null;
private FlowChain<AftersalePreviewContext> subPreviewChain = null;
@Autowired
private FlowChainService flowChainService;
@PostConstruct
public void init() {
subPreviewChain = FlowChain.newChain(AftersalePreviewContext.class)
.addNode(RealtimeCalculateUsageAmountFlow.class) //实时计算使用类型
.addNode(OverallCheckUsageFlow.class) //完全检查使用类型
.addNode(CalculateRefundWayFlow.class) //计算赔付类型
.addNode(GenerateAftersalePlanDigestFlow.class) //生成售后计划摘要
;
previewChain = FlowChain.newChain(AftersalePreviewContext.class)
.addNode(AftersalePreviewDegradeFlow.class)
// TODO: 2025/1/1 //增加售后单 进行中校验,当前存在生效中受理单,不允许预览(数据处于不一致状态,无法获得准确的预览结果),返回特殊错误码
.addNode(AftersaleStatusCheckFlow.class)
.addNode(AftersaleGetAndCheckPeriodFlow.class)
.addNode(GetAndCheckAftersaleTimesFlow.class)
.addNodeWithSubNodes(MutilSubOrderPreviewFlow.class, subPreviewChain)
;
}
@Override
public void preview(AftersalePreviewContext context) {
flowChainService.execute(previewChain, context);
}
}
售后受理
售后受理阶段需要再次检查券包订单是否可退,是否和售后预览结果一致。售后受理流程主要通过售后单状态机进行驱动,包括调用履约域逆向履约方法、提单域逆向取消订单、和调用订单退款。
js
@ExtensionProvider(desc = "示例会员售后受理扩展点", bizScenes = {
@Route(bizType = BizTypeEnum.DOUYIN_COUPON_PACKAGE, scenes = {SceneEnum.SCENE_AFTERSALE_MONTH_CARD})
})
public class DouyinPkgAfterSaleApplyExtension implements AfterSaleApplyExtension {
FlowChain<AfterSaleApplyContext> applyFlowChain = null;
FlowChain<AfterSaleApplyContext> checkFlowChain = null;
FlowChain<AfterSaleApplyContext> doApplyFlowChain = null;
@Autowired
private FlowChainService flowChainService;
@PostConstruct
public void init() {
applyFlowChain = FlowChain.newChain(flowChainService, AfterSaleApplyContext.class)
.addNode(AftersaleApplyLockFlow.class) //加锁
.addNode(AftersaleApplyPreviewFlow.class) //售后预览
.addNode(AfterSalePlanDigestCheckFlow.class) //校验售后计划摘要
.addNode(AftersaleGenerateOrderFlow.class) //生成售后单
.addNode(AftersaleDoApplyFlow.class)
;
doApplyFlowChain = FlowChain.newChain(flowChainService, AfterSaleApplyContext.class)
.addNode(AftersaleOrderDomainFlow.class)
.addNode(MemberOrderRefundSuccessFlow.class) //售后成功后, 更新主单子单的状态为成功
.addNode(AftersaleAsyncRollbackFlow.class) // 失败异步回滚
.addNode(AftersaleReversePerformFlow.class) //逆向履约
.addNode(AftersaleReversePurchaseFlow.class) //逆向取消订单
.addNode(AftersaleRefundOrderFlow.class) //退款
//.addNode()
;
}
@Override
public void apply(AfterSaleApplyContext context) {
flowChainService.execute(applyFlowChain, context);
}
@Override
public void doApply(AfterSaleApplyContext context) {
flowChainService.execute(doApplyFlowChain, context);
}
@Override
public void customBuildAftersaleOrder(AfterSaleApplyContext context, AftersaleOrderDO aftersaleOrderDO) {
}
}
过期退
过期退流程依赖任务定时触发能力,memberclub 定义了 任务触发扩展点,每个业务身份和任务类型均可以自行扩展过期任务如何触发。
如下扩展点,分为两个流程,其一为触发流程,主要功能是根据数据库分库分表,并发的扫描过期的目标任务,扫描出以后,需要调用 执行流程,每个执行流程仅处理1个过期任务。执行流程也支持编排,如过期退执行流程,编排了两个节点,分别为
- 节点1:任务开始和终止时修改 任务状态
- 节点2: 调用售后域业务接口,执行过期退。
csharp
public abstract class DefaultExpireRefundTriggerExtension implements OnceTaskTriggerExtension {
private FlowChain<OnceTaskTriggerContext> triggerFlowChain = null;
private FlowChain<OnceTaskExecuteContext> executelowChain = null;
@PostConstruct
public void init() {
triggerFlowChain = FlowChain.newChain(OnceTaskTriggerContext.class)
.addNode(OnceTaskSeprateFlow.class)
.addNodeWithSubNodes(OnceTaskConcurrentTriggerFlow.class, OnceTaskTriggerJobContext.class,
ImmutableList.of(OnceTaskForceRouterFlow.class, OnceTaskScanDataFlow.class)
)
.addNode(OnceTaskTriggerMonitorFlow.class)
;
executelowChain = FlowChain.newChain(OnceTaskExecuteContext.class)
.addNode(OnceTaskRepositoryFlow.class)
.addNode(ExpiredRefundOnceTaskExecuteFlow.class)
;
}
@Override
public void trigger(OnceTaskTriggerContext context) {
triggerFlowChain.execute(context);
}
@Override
public void execute(OnceTaskExecuteContext context) {
executelowChain.execute(context);
}
}
模拟客户端页面
客户端部分在uniapp 插件市场,找到了一个开源模版,其中包括商品列表页和购物车页面,我修改了和后端接口交互部分,一共以下几个后端接口。我自己新增了购买记录页。
- 首页可查看商品列表,因此提供商品列表页接口
- 提单接口
- 模拟支付接口
- 购买记录、订单列表页
- 售后预览接口
- 售后提单受理接口
技术栈包括
- uniapp + vue
- element-ui
- axios
写在最后
以上后端部分大概花了2个小时就能完成,主要原因是我基于memberclub 进行二次开发,它提供了虚拟商品交易提单、履约、售后和结算的 基础SDK,并且提供了流程引擎和 扩展点引擎能力,可以非常快速的支持新增业务身份进行扩展。
如果是从0到1开发,别说2小时,两天甚至两周也不一定能开发完~
memberclub开源地放这里了,想学习电商交易系统设计的可以看看。
Gitee :gitee.com/juejinwuyan...
GitHub : github.com/juejin-wuya...
如何初始化环境
memberclub 在standalone模式下无需任何中间件即可启动,在集成测试环境默认依赖 mysql/redis/apollo/rabbitmq 等中间件。所以如果仅学习使用,只需要启动memberclub服务即可!
独立启动
cd bin && ./starter.sh -e ut
然后 git clone 下载memberclub H5项目,地址在 gitee.com/juejinwuyan...
下载完成后,需要下载 HBuilderX IDE 启动H5项目。这样就可以了