需求分析
在分享源码之前,先将b2b2c系统中促销模块需求整理、明确,方便源码的理解。
业务需求
-
b2b2c电子商务系统中促销活动相关规则需以脚本数据的方式存放至redis缓存,在购物车与结算页面计算商品价格时从redis缓存中获取促销规则信息,实现商品价格的计算。
技术需求
-
促销规则脚本需要使用freemarker模板引擎,需向其中设置内置变量。
- 渲染脚本和调用脚本的方法放入工具类中,方便随时调用。
架构思路
一、脚本生成规则
1、需要生成脚本引擎的促销活动包括:满减满赠、单品立减、第二件半价、团购、限时抢购、拼团、优惠券和积分兑换。
2、根据促销活动规则的不同,生成脚本引擎的时机也不同,大致可分为四类:
第一类:满减满赠、单品立减、第二件半价和优惠券,这四种是在活动生效时生成脚本。需要设置延时任务,活动生效自动生成脚本。
第二类:拼团,由于拼团活动生效后,也可以再次添加或修改参与拼团活动的商品,并且平台可以关闭和开启拼团活动,因此与第一类稍有不同,除活动生效时需要生成脚本外,上述这些操作也要生成或更新脚本。
第三类:团购、限时抢购,这两种促销活动是平台发布商家选择商品进行参与的,参与的商品需要商家进行审核,因此是在审核通过时生成脚本。
第四类:积分兑换,积分兑换针对的是商品,因此是在商家新增和修改商品信息时,生成或更新脚本。
3、促销活动生成的脚本都需要放入缓存中,以便于减少查库操作。
4、清除缓存中无用的脚本引擎:除积分兑换外,其他促销活动都需要利用延时任务,在促销活动失效时,将缓存中的脚本数据清除掉。积分兑换在商家关闭商品的积分兑换操作时才对缓存中的脚本数据进行删除。
二、脚本生成流程图
三、缓存数据结构
1、根据促销活动的不同规则,分为三种缓存数据结构,分别是:SKU级别缓存、店铺级别缓存和优惠券级别缓存。
2、结构图:
SKU级别缓存结构和店铺级别缓存结构级别一致,如下:
而优惠券级别的缓存结构如下:
3、缓存结构说明
(1)、SKU级别缓存:
缓存key:{SKUPROMOTION} 加上SKU的ID,例如:{SKU_PROMOTION}_100。
缓存value:是一个泛型为PromotionScriptVO的List集合。
(2)、店铺级别缓存:
缓存key:{CARTPROMOTION} 加上店铺的ID,例如:{CART_PROMOTION}_100。
缓存value:是一个泛型为PromotionScriptVO的List集合。
(3)、优惠券级别缓存:
缓存key:{COUPONPROMOTION} 加上优惠券的ID,例如:{COUPON_PROMOTION}_100。
缓存value:是一个String类型的脚本字符串。
4、促销活动存储的缓存结构区分
(1)、针对满减满赠、单品立减、第二件半价这三种促销活动,如果商家在发布活动时选择的是全部商品参与,那么则存储的是店铺级别的缓存结构,如果选择的是部分商品参与,那么则存储的是SKU级别的缓存结构。
(2)、针对拼团、团购、显示抢购和积分兑换这些促销活动,都是存储的SKU级别的缓存结构。
(3)、针对优惠券,无论是店铺优惠券还是平台优惠券,存储的都是优惠券级别的缓存结构。
四、脚本规范
1、调用脚本传入的变量规范:
$currentTime | int | 当前时间,为了验证活动是否有效 |
$sku | Object | 详见下表 |
$price | double | 其他优惠活动优惠后总价 |
$sku说明:
$price | double | 商品单价 |
$num | int | 商品数量 |
$skuId | int | 商品skuID |
$totalPrice | double | 商品小计(单价*数量) |
2、各个促销活动脚本中的方法说明
满减满赠、优惠券促销活动脚本方法
validTime | $currentTime | Boolean | true/false | |
countPrice | $price | Double | 100.00 | |
giveGift | $price | Object | [{"type":"freeShip","value":true},{"type":"point","value":100},{"type":"gift","value":10},{"type":"coupon","value":20}] | 优惠券脚本没有此方法 |
单品立减、第二件半价、团购、限时抢购、团购活动脚本方法
validTime | $currentTime | Boolean | true/false |
countPrice | $sku | Double | 100.00 |
积分兑换活动脚本方法
validTime | $currentTime | Boolean | true/false | 此方法会直接返回true,积分兑换不涉及有效期,脚本中有此方法是为了脚本内容统一 |
countPrice | $sku | Double | 100.00 | |
countPoint | $sku | Integer | 50 |
源码分享
由于促销活动类型较多,此处只以团购活动为例进行相关代码的分享。
ScriptUtil
促销脚本渲染与调用工具类
1 import com.enation.app.javashop.framework.logs.Logger; 2 import com.enation.app.javashop.framework.logs.LoggerFactory; 3 import freemarker.template.Configuration; 4 import freemarker.template.Template; 5 6 import javax.script.Invocable; 7 import javax.script.ScriptEngine; 8 import javax.script.ScriptEngineManager; 9 import javax.script.ScriptException; 10 import java.io.IOException; 11 import java.io.StringWriter; 12 import java.util.*; 13 14 /** 15 * 脚本生成工具类 16 * @author duanmingyu 17 * @version v1.0 18 * @since v7.2.0 19 * @date 2020-01-06 20 */ 21 public class ScriptUtil { 22 23 private static final Logger log = LoggerFactory.getLogger(ScriptUtil.class); 24 25 /** 26 * 渲染并读取脚本内容 27 * @param name 脚本模板名称(例:test.js,test.html,test.ftl等) 28 * @param model 渲染脚本需要的数据内容 29 * @return 30 */ 31 public static String renderScript(String name, Map<String, Object> model) { 32 StringWriter stringWriter = new StringWriter(); 33 34 try { 35 Configuration cfg = new Configuration(Configuration.VERSION_2_3_28); 36 37 cfg.setClassLoaderForTemplateLoading(Thread.currentThread().getContextClassLoader(),"/script_tpl"); 38 cfg.setDefaultEncoding("UTF-8"); 39 cfg.setNumberFormat("#.##"); 40 41 Template temp = cfg.getTemplate(name); 42 43 temp.process(model, stringWriter); 44 45 stringWriter.flush(); 46 47 return stringWriter.toString(); 48 49 } catch (Exception e) { 50 log.error(e.getMessage()); 51 } finally { 52 try { 53 stringWriter.close(); 54 } catch (IOException ex) { 55 log.error(ex.getMessage()); 56 } 57 } 58 59 return null; 60 } 61 62 /** 63 * @Description:执行script脚本 64 * @param method script方法名 65 * @param params 参数 66 * @param script 脚本 67 * @return: 返回执行结果 68 * @Author: liuyulei 69 * @Date: 2020/1/7 70 */ 71 public static Object executeScript(String method,Map<String,Object> params,String script) { 72 if (StringUtil.isEmpty(script)){ 73 log.debug("script is " + script); 74 return new Object(); 75 } 76 77 try { 78 ScriptEngineManager manager = new ScriptEngineManager(); 79 ScriptEngine engine = manager.getEngineByName("javascript"); 80 81 82 log.debug("脚本参数:"); 83 for (String key:params.keySet()) { 84 log.debug(key + "=" + params.get(key)); 85 engine.put(key, params.get(key)); 86 } 87 88 engine.eval(script); 89 log.debug("script 脚本 :"); 90 log.debug(script); 91 92 Invocable invocable = (Invocable) engine; 93 94 return invocable.invokeFunction(method); 95 } catch (ScriptException e) { 96 log.error(e.getMessage(),e); 97 } catch (NoSuchMethodException e) { 98 log.error(e.getMessage(),e); 99 } 100 return new Object(); 101 } 102 }
groupbuy.ftl
团购活动脚本模板
1 <#-- 2 验证促销活动是否在有效期内 3 @param promotionActive 活动信息对象(内置常量) 4 .startTime 获取开始时间 5 .endTime 活动结束时间 6 @param $currentTime 当前时间(变量) 7 @returns {boolean} 8 --> 9 function validTime(){ 10 if (${promotionActive.startTime} <= $currentTime && $currentTime <= ${promotionActive.endTime}) { 11 return true; 12 } 13 return false; 14 } 15 16 <#-- 17 活动金额计算 18 @param promotionActive 活动信息对象(内置常量) 19 .price 商品促销活动价格 20 @param $sku 商品SKU信息对象(变量) 21 .$num 商品数量 22 @returns {*} 23 --> 24 function countPrice() { 25 var resultPrice = $sku.$num * ${promotionActive.price}; 26 return resultPrice < 0 ? 0 : resultPrice.toString(); 27 }
PromotionScriptVO
促销活动脚本数据结构实体
1 import com.fasterxml.jackson.databind.PropertyNamingStrategy; 2 import com.fasterxml.jackson.databind.annotation.JsonNaming; 3 import io.swagger.annotations.ApiModelProperty; 4 import org.apache.commons.lang.builder.EqualsBuilder; 5 import org.apache.commons.lang.builder.HashCodeBuilder; 6 7 import java.io.Serializable; 8 9 /** 10 * @description: 促销脚本VO 11 * @author: liuyulei 12 * @create: 2020-01-09 09:43 13 * @version:1.0 14 * @since:7.1.5 15 **/ 16 @JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class) 17 public class PromotionScriptVO implements Serializable { 18 private static final long serialVersionUID = 3566902764098210013L; 19 20 @ApiModelProperty(value = "促销活动id") 21 private Integer promotionId; 22 23 @ApiModelProperty(value = "促销活动名称") 24 private String promotionName; 25 26 @ApiModelProperty(value = "促销活动类型") 27 private String promotionType; 28 29 @ApiModelProperty(value = "是否可以被分组") 30 private Boolean isGrouped; 31 32 @ApiModelProperty(value = "促销脚本",hidden = true) 33 private String promotionScript; 34 35 @ApiModelProperty(value = "商品skuID") 36 private Integer skuId; 37 38 39 public Integer getPromotionId() { 40 return promotionId; 41 } 42 43 public void setPromotionId(Integer promotionId) { 44 this.promotionId = promotionId; 45 } 46 47 public String getPromotionName() { 48 return promotionName; 49 } 50 51 public void setPromotionName(String promotionName) { 52 this.promotionName = promotionName; 53 } 54 55 public String getPromotionType() { 56 return promotionType; 57 } 58 59 public void setPromotionType(String promotionType) { 60 this.promotionType = promotionType; 61 } 62 63 public Boolean getIsGrouped() { 64 return isGrouped; 65 } 66 67 public void setIsGrouped(Boolean grouped) { 68 isGrouped = grouped; 69 } 70 71 public String getPromotionScript() { 72 return promotionScript; 73 } 74 75 public void setPromotionScript(String promotionScript) { 76 this.promotionScript = promotionScript; 77 } 78 79 public Integer getSkuId() { 80 return skuId; 81 } 82 83 public void setSkuId(Integer skuId) { 84 this.skuId = skuId; 85 } 86 87 @Override 88 public boolean equals(Object o) { 89 if (this == o) { 90 return true; 91 } 92 93 if (o == null || getClass() != o.getClass()) { 94 return false; 95 } 96 PromotionScriptVO that = (PromotionScriptVO) o; 97 98 return new EqualsBuilder() 99 .append(promotionId, that.promotionId) 100 .append(promotionName, that.promotionName) 101 .append(promotionType, that.promotionType) 102 .append(isGrouped, that.isGrouped) 103 .isEquals(); 104 } 105 106 @Override 107 public int hashCode() { 108 return new HashCodeBuilder(17, 37) 109 .append(promotionId) 110 .append(promotionName) 111 .append(promotionType) 112 .append(isGrouped) 113 .toHashCode(); 114 } 115 116 @Override 117 public String toString() { 118 return "PromotionScriptVO{" + 119 "promotionId=" + promotionId + 120 ", promotionName='" + promotionName + '\'' + 121 ", promotionType='" + promotionType + '\'' + 122 ", isGrouped=" + isGrouped + 123 ", promotionScript='" + promotionScript + '\'' + 124 ", skuId=" + skuId + 125 '}'; 126 } 127 }
GroupbuyScriptManager
团购促销活动脚本业务接口
1 import com.enation.app.javashop.core.promotion.tool.model.dos.PromotionGoodsDO; 2 3 import java.util.List; 4 5 /** 6 * 团购促销活动脚本业务接口 7 * @author duanmingyu 8 * @version v1.0 9 * @since v7.2.0 10 * 2020-02-18 11 */ 12 public interface GroupbuyScriptManager { 13 14 /** 15 * 创建参与团购促销活动商品的脚本数据信息 16 * @param promotionId 团购促销活动ID 17 * @param goodsList 参与团购促销活动的商品集合 18 */ 19 void createCacheScript(Integer promotionId, List<PromotionGoodsDO> goodsList); 20 21 /** 22 * 删除商品存放在缓存中的团购促销活动相关的脚本数据信息 23 * @param promotionId 团购促销活动ID 24 * @param goodsList 参与团购促销活动的商品集合 25 */ 26 void deleteCacheScript(Integer promotionId, List<PromotionGoodsDO> goodsList); 27 }
GroupbuyScriptManagerImpl
团购促销活动脚本业务接口实现
1 import com.enation.app.javashop.core.base.CachePrefix; 2 import com.enation.app.javashop.core.promotion.groupbuy.model.dos.GroupbuyActiveDO; 3 import com.enation.app.javashop.core.promotion.groupbuy.service.GroupbuyActiveManager; 4 import com.enation.app.javashop.core.promotion.groupbuy.service.GroupbuyScriptManager; 5 import com.enation.app.javashop.core.promotion.tool.model.dos.PromotionGoodsDO; 6 import com.enation.app.javashop.core.promotion.tool.model.enums.PromotionTypeEnum; 7 import com.enation.app.javashop.core.promotion.tool.model.vo.PromotionScriptVO; 8 import com.enation.app.javashop.framework.cache.Cache; 9 import com.enation.app.javashop.framework.logs.Logger; 10 import com.enation.app.javashop.framework.logs.LoggerFactory; 11 import com.enation.app.javashop.framework.util.ScriptUtil; 12 import org.springframework.beans.factory.annotation.Autowired; 13 import org.springframework.stereotype.Service; 14 15 import java.util.ArrayList; 16 import java.util.HashMap; 17 import java.util.List; 18 import java.util.Map; 19 20 /** 21 * 团购促销活动脚本业务接口 22 * @author duanmingyu 23 * @version v1.0 24 * @since v7.2.0 25 * 2020-02-18 26 */ 27 @Service 28 public class GroupbuyScriptManagerImpl implements GroupbuyScriptManager { 29 30 protected final Logger logger = LoggerFactory.getLogger(this.getClass()); 31 32 @Autowired 33 private Cache cache; 34 35 @Autowired 36 private GroupbuyActiveManager groupbuyActiveManager; 37 38 @Override 39 public void createCacheScript(Integer promotionId, List<PromotionGoodsDO> goodsList) { 40 //如果参与团购促销活动的商品集合不为空并且集合长度不为0 41 if (goodsList != null && goodsList.size() != 0) { 42 //获取团购活动详细信息 43 GroupbuyActiveDO groupbuyActiveDO = this.groupbuyActiveManager.getModel(promotionId); 44 45 //批量放入缓存的数据集合 46 Map<String, List<PromotionScriptVO>> cacheMap = new HashMap<>(); 47 48 //循环参与团购活动的商品集合,将脚本放入缓存中 49 for (PromotionGoodsDO goods : goodsList) { 50 51 //缓存key 52 String cacheKey = CachePrefix.SKU_PROMOTION.getPrefix() + goods.getSkuId(); 53 54 //获取拼团活动脚本信息 55 PromotionScriptVO scriptVO = new PromotionScriptVO(); 56 57 //渲染并读取团购促销活动脚本信息 58 String script = renderScript(groupbuyActiveDO.getStartTime().toString(), groupbuyActiveDO.getEndTime().toString(), goods.getPrice()); 59 60 scriptVO.setPromotionScript(script); 61 scriptVO.setPromotionId(promotionId); 62 scriptVO.setPromotionType(PromotionTypeEnum.GROUPBUY.name()); 63 scriptVO.setIsGrouped(false); 64 scriptVO.setPromotionName("团购"); 65 scriptVO.setSkuId(goods.getSkuId()); 66 67 //从缓存中读取脚本信息 68 List<PromotionScriptVO> scriptList = (List<PromotionScriptVO>) cache.get(cacheKey); 69 if (scriptList == null) { 70 scriptList = new ArrayList<>(); 71 } 72 73 scriptList.add(scriptVO); 74 75 cacheMap.put(cacheKey, scriptList); 76 } 77 78 //将sku促销脚本数据批量放入缓存中 79 cache.multiSet(cacheMap); 80 } 81 } 82 83 @Override 84 public void deleteCacheScript(Integer promotionId, List<PromotionGoodsDO> goodsList) { 85 //如果参与团购促销活动的商品集合不为空并且集合长度不为0 86 if (goodsList != null && goodsList.size() != 0) { 87 //需要批量更新的缓存数据集合 88 Map<String, List<PromotionScriptVO>> updateCacheMap = new HashMap<>(); 89 90 //需要批量删除的缓存key集合 91 List<String> delKeyList = new ArrayList<>(); 92 93 for (PromotionGoodsDO goods : goodsList) { 94 //缓存key 95 String cacheKey = CachePrefix.SKU_PROMOTION.getPrefix() + goods.getSkuId(); 96 97 //从缓存中读取促销脚本缓存 98 List<PromotionScriptVO> scriptCacheList = (List<PromotionScriptVO>) cache.get(cacheKey); 99 100 if (scriptCacheList != null && scriptCacheList.size() != 0) { 101 //循环促销脚本缓存数据集合 102 for (PromotionScriptVO script : scriptCacheList) { 103 //如果脚本数据的促销活动信息与当前修改的促销活动信息一致,那么就将此信息删除 104 if (PromotionTypeEnum.GROUPBUY.name().equals(script.getPromotionType()) 105 && script.getPromotionId().intValue() == promotionId.intValue()) { 106 scriptCacheList.remove(script); 107 break; 108 } 109 } 110 111 if (scriptCacheList.size() == 0) { 112 delKeyList.add(cacheKey); 113 } else { 114 updateCacheMap.put(cacheKey, scriptCacheList); 115 } 116 } 117 } 118 119 cache.multiDel(delKeyList); 120 cache.multiSet(updateCacheMap); 121 } 122 } 123 124 /** 125 * 渲染并读取团购促销活动脚本信息 126 * @param startTime 活动开始时间 127 * @param endTime 活动结束时间 128 * @param price 限时抢购商品价格 129 * @return 130 */ 131 private String renderScript(String startTime, String endTime, Double price) { 132 Map<String, Object> model = new HashMap<>(); 133 134 Map<String, Object> params = new HashMap<>(); 135 params.put("startTime", startTime); 136 params.put("endTime", endTime); 137 params.put("price", price); 138 139 model.put("promotionActive", params); 140 141 String path = "groupbuy.ftl"; 142 String script = ScriptUtil.renderScript(path, model); 143 144 logger.debug("生成团购促销活动脚本:" + script); 145 146 return script; 147 } 148 }
以上是Javashop中基于脚本引擎的促销活动架构思路与部分源码分享。