博主給大家推薦一套全部開源的H5電商項目waynboot-mall。由博主在2020年開發至今,已有三年之久。那時候網上很多的H5商城項目都是半開源版本,要麼沒有H5前端代碼,要麼需要加群咨詢,屬實噁心。於是博主決定自己開發一套完整的移動端H5商城,包含一個管理後臺、一個前臺H5商城、一套後端介面。 ...
博主給大家推薦一套全部開源的H5電商項目waynboot-mall。由博主在2020年開發至今,已有三年之久。那時候網上很多的H5商城項目都是半開源版本,要麼沒有H5前端代碼,要麼需要加群咨詢,屬實噁心。於是博主決定自己開發一套完整的移動端H5商城,包含一個管理後臺、一個前臺H5商城、一套後端介面。項目地址如下:
- H5商城前端代碼:https://github.com/wayn111/waynboot-mobile
- 運營後臺前端代碼:https://github.com/wayn111/waynboot-admin
- 後端介面代碼:https://github.com/wayn111/waynboot-mall
歡迎大家關註這個項目,點個Star讓更多的人瞭解到這個項目。
一、簡介
waynboot-mall是一套全部開源的微商城項目,實現了一個商城所需的首頁展示、商品分類、商品詳情、sku組合、商品搜索、購物車、結算下單、訂單狀態流轉、商品評論等一系列功能。
技術上基於最新得Spring Boot3.0、Jdk17,整合了Redis、RabbitMQ、ElasticSearch等常用中間件,
貼近生產環境實際經驗開發而來。
二、技術特點
- 訂單金額計算使用BigDeciaml類型,支持小數點後兩位
- 支持微信內JsApi支付、H5網頁支付
- 商城介面代碼清晰、註釋完善、模塊拆分合理
- 使用Spring-Security進行訪問許可權控制
- 使用jwt進行介面授權驗證
- ORM層使用Mybatis Plus提升開發效率
- 添加全局異常處理器,統一異常處理
- 使用Spring Boot admin進行服務監控
- 集成七牛雲存儲配置,支持上傳文件至七牛獲取cdn下載鏈接
- 集成常用郵箱配置,方便發送郵件
- 添加策略模式使用示例,優化首頁金剛區跳轉邏輯
- 拆分出通用的數據訪問模塊,統一Redis & Elastic配置與訪問
- 使用Elasticsearch高級客戶端依賴對Elasticsearch進行操作
- 支持商品數據同步Elasticsearch操作以及中文分詞搜索
- RabbitMQ生產者發送消息採用非同步confirm模式,消費者消費消息時需手動確認確保消息不丟失
- 下單處理過程引入RabbitMQ,非同步生成訂單記錄,提高系統下單處理能力
三、商城設計
文項目目錄
|-- waynboot-monitor // 監控模塊
|-- waynboot-admin-api // 運營後臺api模塊,提供後臺項目api介面
|-- waynboot-common // 通用模塊,包含項目核心基礎類
|-- waynboot-data // 數據模塊,通用中間件數據訪問
| |-- waynboot-data-redis // redis訪問配置模塊
| |-- waynboot-data-elastic // elastic訪問配置模塊
|-- waynboot-generator // 代碼生成模塊
|-- waynboot-message-consumer // 消費者模塊,處理訂單消息和郵件消息
|-- waynboot-message-core // 消費者核心模塊,隊列、交換機配置
|-- waynboot-mobile-api // h5商城api模塊,提供h5商城api介面
|-- pom.xml // maven父項目依賴,定義子項目依賴版本
|-- ...
技術亮點
2.1 庫存扣減
庫存扣減操作是在下單操作扣減還是在支付成功時扣減?(ps:扣減庫存使用樂觀鎖機制 where goods_num - num >= 0
)
- 下單時扣減,這個方案屬於實時扣減,當有大量下單請求時,由於訂單數小於請求數,會發生下單失敗,但是無法防止短時間大量惡意請求占用庫存,
造成普通用戶無法下單 - 支付成功扣減,這個方案可以預防惡意請求占用庫存,但是會存在多個請求同時下單後,在支付回調中扣減庫存失敗,導致訂單還是下單失敗並且還要退還訂單金額(這種請求就是訂單數超過了庫存數,無法發貨,影響用戶體驗)
- 還是下單時扣減,但是對於未支付訂單設置一個超時過期機制,比如下單時庫存減一,生成訂單後,對於未在15分鐘內完成支付的訂單,
自動取消超期未支付訂單並將庫存加一,該方案基本滿足了大部分使用場景 - 針對大流量下單場景,比如一分鐘內五十萬次下單請求,可以通過設置虛擬庫存的方式減少下單介面對資料庫的訪問。具體來說就是把商品庫存緩存到redis中,
下單時配合lua腳本原子的get和decr商品庫存數量(這一步就攔截了大部分請求),執行成功後在扣減實際庫存
2.2 首頁查詢
首頁商品展示介面利用多線程技術進行查詢優化,將多個sql語句的排隊查詢變成非同步查詢,介面時長只跟查詢時長最大的sql查詢掛鉤
// 使用CompletableFuture非同步查詢
List<CompletableFuture<Void>> list = new ArrayList<>();
CompletableFuture<Void> f1 = CompletableFuture.supplyAsync(() -> iBannerService.list(Wrappers.lambdaQuery(Banner.class).eq(Banner::getStatus, 0).orderByAsc(Banner::getSort)), homeThreadPoolTaskExecutor).thenAccept(data -> {
String key = "bannerList";
redisCache.setCacheMapValue(SHOP_HOME_INDEX_HASH, key, data);
success.add(key, data);
});
CompletableFuture<Void> f2 = CompletableFuture.supplyAsync(() -> iDiamondService.list(Wrappers.lambdaQuery(Diamond.class).orderByAsc(Diamond::getSort).last("limit 10")), homeThreadPoolTaskExecutor).thenAccept(data -> {
String key = "categoryList";
redisCache.setCacheMapValue(SHOP_HOME_INDEX_HASH, key, data);
success.add(key, data);
});
list.add(f1);
list.add(f2);
// 主線程等待子線程執行完畢
CompletableFuture.allOf(list.toArray(new CompletableFuture[0])).join();
2.3 中文分詞搜索
ElasticSearch
搜索查詢,查詢包含搜索關鍵字並且是上架中的商品,在根據指定欄位進行排序,最後分頁返回
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
MatchQueryBuilder matchFiler = QueryBuilders.matchQuery("isOnSale", true);
MatchQueryBuilder matchQuery = QueryBuilders.matchQuery("name", keyword);
MatchPhraseQueryBuilder matchPhraseQueryBuilder = QueryBuilders.matchPhraseQuery("keyword", keyword);
boolQueryBuilder.filter(matchFiler).should(matchQuery).should(matchPhraseQueryBuilder).minimumShouldMatch(1);
searchSourceBuilder.timeout(new TimeValue(10, TimeUnit.SECONDS));
// 按是否新品排序
if (isNew) {
searchSourceBuilder.sort(new FieldSortBuilder("isNew").order(SortOrder.DESC));
}
// 按是否熱品排序
if (isHot) {
searchSourceBuilder.sort(new FieldSortBuilder("isHot").order(SortOrder.DESC));
}
// 按價格高低排序
if (isPrice) {
searchSourceBuilder.sort(new FieldSortBuilder("retailPrice").order("asc".equals(orderBy) ? SortOrder.ASC : SortOrder.DESC));
}
// 按銷量排序
if (isSales) {
searchSourceBuilder.sort(new FieldSortBuilder("sales").order(SortOrder.DESC));
}
// 篩選新品
if (filterNew) {
MatchQueryBuilder filterQuery = QueryBuilders.matchQuery("isNew", true);
boolQueryBuilder.filter(filterQuery);
}
// 篩選熱品
if (filterHot) {
MatchQueryBuilder filterQuery = QueryBuilders.matchQuery("isHot", true);
boolQueryBuilder.filter(filterQuery);
}
searchSourceBuilder.query(boolQueryBuilder);
searchSourceBuilder.from((int) (page.getCurrent() - 1) * (int) page.getSize());
searchSourceBuilder.size((int) page.getSize());
List<JSONObject> list = elasticDocument.search("goods", searchSourceBuilder, JSONObject.class);
2.4 訂單編號
訂單編號生成規則:秒級時間戳 + 加密用戶ID + 今日第幾次下單
- 秒級時間戳:時間遞增保證唯一性
- 加密用戶ID:加密處理,返回用戶ID6位數字,可以防併發訪問,同一秒用戶不會產生2個訂單
- 今日第幾次下單:便於運營查詢處理用戶當日訂單
/**
* 返回訂單編號,生成規則:秒級時間戳 + 加密用戶ID + 今日第幾次下單
*
* @param userId 用戶ID
* @return 訂單編號
*/
public static String generateOrderSn(Long userId) {
long now = LocalDateTime.now().toEpochSecond(ZoneOffset.of("+8"));
return now + encryptUserId(String.valueOf(userId), 6) + countByOrderSn(userId);
}
/**
* 計算該用戶今日內第幾次下單
*
* @param userId 用戶ID
* @return 該用戶今日第幾次下單
*/
public static int countByOrderSn(Long userId) {
IOrderService orderService = SpringContextUtil.getBean(IOrderService.class);
return orderService.count(new QueryWrapper<Order>().eq("user_id", userId)
.gt("create_time", LocalDate.now())
.lt("create_time", LocalDate.now().plusDays(1)));
}
/**
* 加密用戶ID,返回num位字元串
*
* @param userId 用戶ID
* @param num 長度
* @return num位加密字元串
*/
private static String encryptUserId(String userId, int num) {
return String.format("%0" + num + "d", Integer.parseInt(userId) + 1);
}
2.5 非同步下單
下單流程處理過程,通過rabbitMQ非同步生成訂單,提高系統下單處理能力
- 用戶點擊提交訂單按鈕,後臺生成訂單編號和訂單金額跳轉到訂單支付頁面,並將訂單編號等信息發送rabbitMQ消息(生成訂單編號,還未生成訂單)
- 訂單消費者接受到訂單消息後,獲取訂單編號生成訂單記錄(訂單創建成功,用戶待支付)
- 下單頁面,前端根據訂單編號輪詢訂單介面,訂單已創建則跳轉支付頁面,否則提示下單失敗(訂單創建失敗)
- 支付頁面,用戶點擊支付按鈕時,後臺調用微信/支付寶下單介面後,前端喚醒微信/支付寶支付,用戶輸入密碼
- 用戶支付完成後在微信/支付寶下回調通知里更新訂單狀態為已支付(訂單已支付)
- 用戶支付完成後,返回支付狀態查看頁面。
2.6 設計模式
金剛區跳轉使用策略模式進行代碼編寫
1.定義金剛位跳轉策略介面以及跳轉枚舉類
public interface DiamondJumpType {
List<Goods> getGoods(Page<Goods> page, Diamond diamond);
Integer getType();
}
// 金剛位跳轉類型枚舉
public enum JumpTypeEnum {
COLUMN(0),
CATEGORY(1);
private Integer type;
JumpTypeEnum(Integer type) {
this.type = type;
}
public Integer getType() {
return type;
}
public JumpTypeEnum setType(Integer type) {
this.type = type;
return this;
}
}
2.定義策略實現類,並使用@Component註解註入spring
// 分類策略實現
@Component
public class CategoryStrategy implements DiamondJumpType {
@Autowired
private GoodsMapper goodsMapper;
@Override
public List<Goods> getGoods(Page<Goods> page, Diamond diamond) {
List<Long> cateList = Arrays.asList(diamond.getValueId());
return goodsMapper.selectGoodsListPageByl2CateId(page, cateList).getRecords();
}
@Override
public Integer getType() {
return JumpTypeEnum.CATEGORY.getType();
}
}
// 欄目策略實現
@Component
public class ColumnStrategy implements DiamondJumpType {
@Autowired
private IColumnGoodsRelationService iColumnGoodsRelationService;
@Autowired
private IGoodsService iGoodsService;
@Override
public List<Goods> getGoods(Page<Goods> page, Diamond diamond) {
List<ColumnGoodsRelation> goodsRelationList = iColumnGoodsRelationService.list(new QueryWrapper<ColumnGoodsRelation>()
.eq("column_id", diamond.getValueId()));
List<Long> goodsIdList = goodsRelationList.stream().map(ColumnGoodsRelation::getGoodsId).collect(Collectors.toList());
Page<Goods> goodsPage = iGoodsService.page(page, new QueryWrapper<Goods>().in("id", goodsIdList).eq("is_on_sale", true));
return goodsPage.getRecords();
}
@Override
public Integer getType() {
return JumpTypeEnum.COLUMN.getType();
}
}
3.定義策略上下文,通過構造器註入spring,定義map屬性,通過key獲取對應策略實現類
@Component
public class DiamondJumpContext {
private final Map<Integer, DiamondJumpType> map = new HashMap<>();
/**
* 由spring自動註入DiamondJumpType子類
*
* @param diamondJumpTypes 金剛位跳轉類型集合
*/
public DiamondJumpContext(List<DiamondJumpType> diamondJumpTypes) {
for (DiamondJumpType diamondJumpType : diamondJumpTypes) {
map.put(diamondJumpType.getType(), diamondJumpType);
}
}
public DiamondJumpType getInstance(Integer jumpType) {
return map.get(jumpType);
}
}
4.使用,註入DiamondJumpContext對象,調用getInstance方法傳入枚舉類型
@Autowired
private DiamondJumpContext diamondJumpContext;
@Test
public void test(){
DiamondJumpType diamondJumpType=diamondJumpContext.getInstance(JumpTypeEnum.COLUMN.getType());
}
四、演示圖
商城登陸 | 商城註冊 |
商城首頁 | 商城搜索 |
搜索結果展示 | 金剛位跳轉 |
商品分類 | 商品詳情 |
商品sku選擇 | 購物車查看 |
確認下單 | 選擇支付方式 |
商城我的頁面 | 我的訂單列表 |
添加商品評論 | 查看商品評論 |
後臺登陸 | 後臺首頁 |
後臺會員管理 | 後臺評論管理 |
後臺地址管理 | 後臺添加商品 |
後臺商品管理 | 後臺banner管理 |
後臺訂單管理 | 後臺分類管理 |
後臺金剛區管理 | 後臺欄目管理 |
五、線上體驗
最後說兩句waynboot-mall作為博主的開源項目集大成者,對於沒有接觸過商城項目的小伙伴來說是非常具有幫助和學習價值的。看完這個項目你能瞭解到一個商城項目的基本全貌,提前避坑。
感謝大家閱讀,希望這篇文章能為你提供價值。公眾號【waynblog】每周分享技術乾貨、開源項目、實戰經驗、高效開發工具等,您的關註將是我的更新動力