商品上新业务状态机接入实践 | 得物技术
目录
一、商品上新业务介绍
二、为什么考虑接入状态机
三、商品上新流程中涉及的状态
1. 新品来样状态枚举
2. SPU状态枚举
四、新品来样所有事件
五、新品来样状态流转
六、状态机技术选型
七、状态机接入面临的困难
八、预期收益
九、详细设计
1. 按渠道拆分的合理性
2. 业务操作按渠道解耦
2.1 业务操作通用接口
2.2 业务操作实现类
3. 状态机接入
3.1 状态机定义
3.2 Guard与Action的实现
3.2.1 目标状态固定的guard
3.2.2 目标状态固定的action
3.2.3 Choice state中的guard
3.2.4 Choice state中的action
3.3 商品上新过程中与SPU状态流转的联动
4. 后续扩展分析
4.1 新增商品来源渠道
4.2 新品来样新增状态节点
4.3 新品来样调整状态间顺序
十、小结
选品审核:根据新品申请提交的资料信息判定是否符合上架要求; 商品资料审核:对商品资料正确和完整性的审核,包含商管、风控、法务的多轮审核; 商研审核:商研审核是针对该商品在平台鉴别支持能力的判断,这也是得物业务的特色之处。
二
为什么考虑接入状态机
状态枚举值个数较多,且相互间的流转条件不明确,了解业务流程必须仔细研究代码,上手和维护成本高。 状态的转移完全由代码随意指定,状态间随意流转存在风险。 部分状态流转不支持幂等,重复操作可能造成不符合预期的后果。 新增状态、修改状态流转成本高、风险大,代码修改范围不可控,测试需要全流程回归。
三
商品上新流程中涉及的状态
新品来样状态枚举
public enum NewProductShowEnum {
DRAFT(0, "草稿"),
CHECKING(1, "选品中"),
UNPUT_ON_SALE_UNPASS(2, "选品不通过"),
UNPUT_ON_SALE_PASSED(3, "商研审核中"),
UNPUT_ON_SALE_PASSED_UNSEND(4, "商品资料待审核"),
UNPUT_ON_SALE_PASSED_UNSEND_NOT_PUT(5, "鉴别不通过"),
UNPUT_ON_SALE_PASSED_SEND(6, "请寄样"),
SEND_PRODUCT(7, "商品已寄样"),
SEND_PASS(8, "寄样鉴别通过"),
SEND_REJECT(9, "寄样鉴别不通过"),
GONDOR_INVALID(10, "作废"),
FINSH_SPU(11, "新品资料审核通过"),
}
SPU状态枚举
public enum SpuStatusEnum {
OFF_SHELF(0, "下架"),
ON_SHELF(1, "上架"),
TO_APPROVE(2, "待审核"),
APPROVED(3, "审核通过"),
REJECT(4, "审核不通过"),
TO_RISK_APPROVE(8, "待风控审核"),
TO_LEGAL_APPROVE(9, "待法务审核"),
}
四
新品来样所有事件
保存新品草稿
提交新品申请
选品通过
选品不通过
选品驳回后重新提交
发起商研审核
商研审核-支持鉴别
商研审核-不支持鉴别
商研审核-商品信息有误
SPU审核驳回超过X天
发起寄样
寄样进度更新
五
新品来样状态流转
六
状态机技术选型
七
状态机接入面临的困难
目前新品来样的代码中还面临着不同渠道之间代码耦合的问题,需要在本次接入中一起解决,否则状态机接入的成本会很高,质量也难以保证,后续维护更加困难。即使理想状态下经过了上述的状态机的改造,不进行其他改造,还会存在两方面的问题:
对目标状态判断逻辑的耦合; 实际执行动作的耦合。
问题集中反映在新品来样的选品审核、商研审核接口的代码中(这部分也是新品来样业务逻辑最多最复杂的部分),它夹杂了所有渠道所有通过不通过的逻辑、选品和商研的逻辑,全部糅合在一起,代码冗长且可读性不好,同时还存在大事务的问题(事务中多次RPC调用),因此在状态机接入的同时需要将这些代码进行拆分和合并,具体包括:
不同渠道的代码使用策略模式拆分; 不同状态、不同的操作事件处理逻辑归纳到状态机不同状态&事件的guard和action类中; 对不同渠道中相同的代码处理逻辑封装成一个个的代码模块,在各自渠道中调用。
八
预期收益
从上文可以了解到,虽然是状态机接入,实际上是要完成两方面的改造,一是完成对整个上新流程中分渠道、分操作的业务代码的解耦,这部分的改造,能够:
解决之前新品申请链路中的大事务问题,如:提交报名、新品审核; 各商品来源渠道之间业务隔离,代码变更范围更加可控,更利于测试; 提高代码的可扩展性,降低代码理解门槛,提高日常需求的迭代开发效率。
二是状态机的接入,可以解决新品来样流程中的状态流转问题,包括:
统一集中管理状态变更规则,便于学习上手和后期维护; 避免不合法、重复的状态流转; 新增状态、状态流程之间的顺序调整变得更容易,代码修改更可控。
九
详细设计
按渠道拆分的合理性
业务操作按渠道解耦
业务操作通用接口
public interface NspOperate<C> {
/**
* 支持的商品来源渠道
* @return
*/
Integer supportApplyType();
/**
* 支持的操作类型
* @return
*/
String operateCode();
/**
* 请求预处理
* @param context
*/
void preProcessRequest(C context);
/**
* 校验
* @param context
*/
void verify(C context);
/**
* 执行业务逻辑
* @param context
*/
void process(C context);
/**
* 执行持久化
* @param context
*/
void persistent(C context);
/**
* 后处理
* @param context
*/
void post(C context);
}
后续状态机的每个事件都与该接口的操作类型一一对应。此外,还可以定义其他操作类型,用于不涉及状态流转的场景(比如:编辑新品申请、根据新品申请创建SPU)。 process方法的定义较为宽泛,在不同的业务操作中,实际执行的内容可能区别很大,比如提交新品审核可能只做一些数据组装的动作,而商研审核中则需要对本次操作后的目标状态进行判断。因此子类可以基于自己的业务需要,再进一步拆分定义新的待实现方法。 persistent持久化方法单独定义出来,是为了支持只在该方法上加事务,目前系统的代码中其实也有类似的设计,但事务加的太宽泛,包括了校验、业务处理等整个执行流程,中间可能包含了各种RPC调用,这也是导致大事务的其中一个重要原因,因此这里明确该方法的实现只有读写DB操作,不包含任何业务逻辑。 每一个该接口的实现以“商品来源渠道+操作类型”形成唯一键进行Spring Bean的管理,同时为了兼顾有些操作是不区分商品来源的,故允许定义一个特殊的applyType(比如-1)代表当前实现支持所有渠道。在获取实现时,优化获取当前渠道的实现,找不到则尝试查找全渠道的实现:
public NspOperate getNspOperate(Integer applyType, String operateCode) {
String key = buildKey(applyType, operateCode);
NspOperate nspOperate = operateMap.get(key);
if (Objects.isNull(nspOperate)) {
String generalKey = buildKey(-1, operateCode);
nspOperate = operateMap.get(generalKey);
}
AssertUtils.throwIf(Objects.isNull(nspOperate), "NspOperate not found! key = " + key);
return nspOperate;
}
业务操作实现类
第一层:对操作类型(业务事件)聚合的维度,比如商研审核,可以在这里定义商研审核中共用的代码、自定义方法,比如:商研审核通用的入参校验,字段非空之类。 第二层:具体到操作类型维度(业务事件),比如商研审核-支持鉴别、商研审核-不支持鉴别等,这里可以定义操作类型维度下所有商品来源渠道的公共代码。比如:不支持鉴别时原因必填,商研审核调用多个系统的一连串的判断逻辑。 第三层:具体到商品来源渠道级别的具体实现,可以复用父类中的代码。
只有一层:新品来样作废,与商品来源渠道无关,所有渠道都使用相同逻辑,只有一个实现类即可。 只有两层:提交新品申请,区分到不同的商品来源渠道即可。 有三层:新品商研审核,商研审核下还分多种操作类型(业务事件),如:商研审核-支持鉴别、商研审核-不支持鉴别、商研审核-发起寄样等,每种操作类型下各个商品来源渠道有各自的实现。
状态机接入
状态机定义
@Configuration
@Slf4j
@EnableStateMachineFactory(name = "newSpuApplyStateMachineFactory")
public class NewSpuApplyStateMachineConfig extends EnumStateMachineConfigurerAdapter<NewProductShowEnum, NewSpuApplyStateMachineEventsEnum> {
public final static String DEFAULT_MACHINEID = "spring/machine/commodity/newspuapply";
@Resource
private NewSpuApplyStateMachinePersist newSpuApplyStateMachinePersist;
@Resource
private NspNewApplyAction nspNewApplyAction;
@Resource
private NspNewApplyGuard nspNewApplyGuard;
@Bean
public StateMachinePersister<NewProductShowEnum, NewSpuApplyStateMachineEventsEnum, NewSpuApplySendEventContext> newSpuApplyMachinePersister() {
return new DefaultStateMachinePersister<>(newSpuApplyStateMachinePersist);
}
@Override
public void configure(StateMachineConfigurationConfigurer<NewProductShowEnum, NewSpuApplyStateMachineEventsEnum> config) throws Exception {
config.withConfiguration().machineId(DEFAULT_MACHINEID);
}
@Override
public void configure(StateMachineStateConfigurer<NewProductShowEnum, NewSpuApplyStateMachineEventsEnum> config) throws Exception {
config.withStates()
.initial(NewProductShowEnum.STM_INITIAL)
.state(NewProductShowEnum.CHECKING)
.state(NewProductShowEnum.UNPUT_ON_SALE_UNPASS)
.state(NewProductShowEnum.UNPUT_ON_SALE_PASSED_UNSEND)
.state(NewProductShowEnum.UNPUT_ON_SALE_PASSED)
.choice(NewProductShowEnum.STM_UNPUT_ON_SALE_PASSED_UNSEND)
.choice(NewProductShowEnum.STM_UNPUT_ON_SALE_PASSED)
.state(NewProductShowEnum.UNPUT_ON_SALE_PASSED_UNSEND_NOT_PUT)
.state(NewProductShowEnum.OTHER_UNPASS_FOR_SPU_STUDYER)
.state(NewProductShowEnum.FINSH_SPU)
.state(NewProductShowEnum.GONDOR_INVALID)
.states(EnumSet.allOf(NewProductShowEnum.class));
}
@Override
public void configure(StateMachineTransitionConfigurer<NewProductShowEnum, NewSpuApplyStateMachineEventsEnum> transitions) throws Exception {
transitions.withExternal()
//提交新的新品申请
.source(NewProductShowEnum.STM_INITIAL)
.target(NewProductShowEnum.CHECKING)
.event(NewSpuApplyStateMachineEventsEnum.NEW_APPLY)
.guard(nspNewApplyGuard)
.action(nspNewApplyAction)
//选品不通过
.and().withExternal()
.source(NewProductShowEnum.CHECKING)
.target(NewProductShowEnum.UNPUT_ON_SALE_UNPASS)
.event(NewSpuApplyStateMachineEventsEnum.OM_PICK_REJECT)
.guard(nspOmRejectGuard)
.action(nspOmRejectAction)
//选品通过
.and().withExternal()
.source(NewProductShowEnum.CHECKING)
.target(NewProductShowEnum.UNPUT_ON_SALE_PASSED_UNSEND)
.event(NewSpuApplyStateMachineEventsEnum.OM_PICK_PASS)
.guard(nspOmPassGuard)
.action(nspOmPassAction)
//发起商研审核
.and().withExternal()
.source(NewProductShowEnum.UNPUT_ON_SALE_PASSED_UNSEND)
.target(NewProductShowEnum.STM_UNPUT_ON_SALE_PASSED_UNSEND)
.event(NewSpuApplyStateMachineEventsEnum.START_BR_AUDIT)
.and().withChoice()
.source(NewProductShowEnum.STM_UNPUT_ON_SALE_PASSED_UNSEND)
.first(NewProductShowEnum.UNPUT_ON_SALE_PASSED, nspStartBrAuditWaitAuditStatusDecide, nspStartBrAuditWaitAuditChoiceAction)
.then(NewProductShowEnum.UNPUT_ON_SALE_PASSED_UNSEND_NOT_PUT, nspStartBrAuditRejctStatusDecide, nspStartBrAuditRejctChoiceAction)
.last(NewProductShowEnum.FINSH_SPU, nspStartBrAuditFinishChoiceAction)
//商研审核-支持鉴别
.and().withExternal()
.source(NewProductShowEnum.UNPUT_ON_SALE_PASSED)
.target(NewProductShowEnum.FINSH_SPU)
.event(NewSpuApplyStateMachineEventsEnum.BR_HUMAN_AUDIT_SUPPORT_ALL)
.guard(nspBrAuditSupportAllGuard)
.action(nspBrAuditSupportAllAction)
//商研审核-商品信息有误
.and().withExternal()
.source(NewProductShowEnum.UNPUT_ON_SALE_PASSED)
.target(NewProductShowEnum.OTHER_UNPASS_FOR_SPU_STUDYER)
.event(NewSpuApplyStateMachineEventsEnum.BR_HUMAN_AUDIT_WRONG_INFO)
.guard(nspBrAuditWrongInfoGuard)
.action(nspBrAuditWrongInfoAction)
//商研审核-不支持鉴别
.and().withExternal()
.source(NewProductShowEnum.UNPUT_ON_SALE_PASSED)
.target(NewProductShowEnum.UNPUT_ON_SALE_PASSED_UNSEND_NOT_PUT)
.event(NewSpuApplyStateMachineEventsEnum.BR_HUMAN_AUDIT_SUPPORT_NONE)
.guard(nspBrAuditRejectGuard)
.action(nspBrAuditRejectAction)
;
}
}
Guard与Action的实现
触发事件后的目标状态是固定的,比如选品审核时触发了选品不通过事件,新品申请的目标状态将确定为选品不通过; 触发事件后的目标状态需要经过代码逻辑判断,为此状态机配置中引入了choice state,比如发起商研审核的事件,新品申请的目标状态可能是直接不支持鉴别,也可能是新品申请直接通过,也可能是需要人工审核。
@Component
public class NspNewApplyGuard extends AbstractGuard<NewProductShowEnum, NewSpuApplyStateMachineEventsEnum, NewSpuApplySendEventContext> {
@Resource
private NewSpuApplyOperateHelper newSpuApplyOperateHelper;
@Override
protected boolean process(StateContext<NewProductShowEnum, NewSpuApplyStateMachineEventsEnum> context) {
final CatetorySendEventContextRequest<NewSpuApplyContext> request = getSendEventContext(context).getRequest();
NewSpuApplyContext ctx = request.getParams();
Integer applyType = ctx.getApplyType(); //从业务数据中取出商品来源
NspOperate<NewSpuApplyContext> nspOperate = newSpuApplyOperateHelper.getNspOperate(applyType, NewSpuApplyStateMachineEventsEnum.NEW_APPLY.getCode()); //固定的事件code
//做请求的预处理
nspOperate.preProcessRequest(ctx);
//对业务数据做校验,校验不通过即抛出异常
nspOperate.verify(ctx);
//正常执行完上述2个方法,代表是可以执行的
return Boolean.TRUE;
}
}
@Component
public class NspNewApplyAction extends AbstractSuccessAction<NewProductShowEnum, NewSpuApplyStateMachineEventsEnum, CategorySendEventContext> {
@Resource
private NewSpuApplyOperateHelper newSpuApplyOperateHelper;
@Override
protected void process(StateContext<NewProductShowEnum, NewSpuApplyStateMachineEventsEnum> context) {
final CatetorySendEventContextRequest<NewSpuApplyContext> request = getSendEventContext(context).getRequest();
NewSpuApplyContext ctx = request.getParams();
Integer applyType = ctx.getApplyType(); //从业务数据中取出商品来源
NspOperate<NewSpuApplyContext> nspOperate = newSpuApplyOperateHelper.getNspOperate(applyType, NewSpuApplyStateMachineEventsEnum.NEW_APPLY.getCode()); //固定的事件code
//执行业务逻辑
nspOperate.process(ctx);
//持久化
nspOperate.persistent(ctx);
//后处理
nspOperate.post(ctx);
}
}
public interface NspStatusDecider<C, R> {
/**
* 支持的商品来源渠道
* @return
*/
Integer supportApplyType();
/**
* 支持的操作类型
* @return
*/
String operateCode();
/**
* 判定目标状态
* @param context
*/
R decideStatus(C context);
}
@Component
public class NspBrAuditNoIdentifyGuard extends AbstractGuard<NewProductShowEnum, NewSpuApplyStateMachineEventsEnum, NewSpuApplySendEventContext> {
@Resource
private NewSpuApplyOperateHelper newSpuApplyOperateHelper;
@Override
protected boolean process(StateContext<NewProductShowEnum, NewSpuApplyStateMachineEventsEnum> context) {
final CatetorySendEventContextRequest<NewSpuApplyContext> request = getSendEventContext(context).getRequest();
NewSpuApplyContext ctx = request.getParams();
Integer applyType = ctx.getApplyType(); //从业务数据中取出商品来源
NspStatusDecider<NewSpuApplyContext, Result> nspStatusDecider = newSpuApplyOperateHelper.getNspStatusDecider(applyType, NewSpuApplyStateMachineEventsEnum.BR_HUMAN_AUDIT_SUPPORT_NONE.getCode()); //固定的事件code
//判定目标状态
Result result = nspStatusDecider.decideStatus(ctx);
ctx.setResult(result); //将判定结果放入上下文,其他的guard可以引用结果,避免重复判断
return Result.isSuccess(result); //根据判定结果决定是否匹配当前guard对应的目标状态
}
}
@Component
public class NspBrAuditNoIdentifyAction extends AbstractSuccessAction<NewProductShowEnum, NewSpuApplyStateMachineEventsEnum, CategorySendEventContext> {
@Resource
private NewSpuApplyOperateHelper newSpuApplyOperateHelper;
@Override
protected void process(StateContext<NewProductShowEnum, NewSpuApplyStateMachineEventsEnum> context) {
final CatetorySendEventContextRequest<NewSpuApplyContext> request = getSendEventContext(context).getRequest();
NewSpuApplyContext ctx = request.getParams();
Integer applyType = ctx.getApplyType(); //从业务数据中取出商品来源
NspOperate<NewSpuApplyContext> nspOperate = newSpuApplyOperateHelper.getNspOperate(applyType, NewSpuApplyStateMachineEventsEnum.BR_HUMAN_AUDIT_SUPPORT_NONE.getCode()); //固定的事件code
//做请求的预处理
nspOperate.preProcessRequest(ctx);
//对业务数据做校验
nspOperate.verify(ctx);
//执行业务逻辑
nspOperate.process(ctx);
//持久化
nspOperate.persistent(ctx);
//后处理
nspOperate.post(ctx);
}
}
有更换状态机实现的可能,因此不希望状态机实现相关的代码与业务逻辑代码耦合; 不涉及状态机的场景,同样存在按渠道拆分逻辑的需要,比如新品申请编辑等等。
商品上新过程中与SPU状态流转的联动
后续扩展分析
新增商品来源渠道
新品来样新增状态节点
新品来样调整状态间顺序
十
小结
往期回顾
文 / 甜橙
关注得物技术,每周一、三、五更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
“
扫码添加小助手微信
如有任何疑问,或想要了解更多技术资讯,请添加小助手微信:
线下活动推荐