需求分析
在分享源碼之前,先將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中基於腳本引擎的促銷活動架構思路與部分源碼分享。
