Mall4j开源商城系统-基于SpringBoot+Vue系统开发介绍

 今天来介绍一款非常不错的Mall4j开源商城系统

Mall4j开源商城,一个基于spring boot、spring oauth2.0、mybatis、redis的轻量级、前后端分离、防范xss攻击、拥有分布式锁,为生产环境多实例完全准备,数据库为b2b2c设计,拥有完整sku和下单流程的开源商城。

目录

 今天来介绍一款非常不错的Mall4j开源商城系统​编辑

一、序言

二、项目基本信息

商城文档

项目链接

三、商城系统技术选型

四、系统部署常见问题 

项目有5个项目

1.java开发环境安装

1.1开发环境

 1.2 安装jdk + mysql + redis + maven

 2.启动

3.vue开发环境安装

五、商城系统设计商品分组

六、数据库实体关系结构设计

七、接口设计

1、购物车的订单设计

2、订单设计-确认订单

3、订单设计-提交订单

4、订单设计-支付

八、总结


博主介绍:✌专注于前后端领域开发的优质创作者、秉着互联网精神开源贡献精神,答疑解惑、坚持优质作品共享。本人是掘金/腾讯云/阿里云等平台优质作者、擅长前后端项目开发和毕业项目实战,深受全网粉丝喜爱与支持✌有需要可以联系作者我哦!

🍅文末三连哦🍅

👇🏻 精彩专栏推荐订阅👇🏻 不然下次找不到哟

一、序言

随着信息的全球化和国际互联网的普及化,越来越多的人想使用其无国界、无时间、无地域限制的便利环境来经营拓展商务。Mall4j商城系统致力于为中小企业打造一个完整、易于维护的开源的电商商城系统,采用现阶段流行技术实现。后台管理系统包含商品管理、订单管理、运费模板、规格管理、会员管理、运营管理、内容管理、统计报表、权限管理、设置等模块。开源版本商城属于B2C单商户商城系统,不含营销活动,如需更多模式的商城请查看Mall4j商城官网。 

二、项目基本信息

商城文档

这代码有没有文档呀? 当然有啦,你已经下载了,在doc这个文件夹上,实在不知道,我就给链接出来咯:

gitee:Java商城系统 企业商城系统 源码商城: ⭐️⭐️⭐️Mall4j商城系统,企业首选的商城系统,采用Springboot3+Vue3等流行框架,代码工整,注释清晰,商城包含小程序、APP、H5、PC端,支持拼团、秒杀、分销、套餐赠品、优惠券、满减折、直播、装修、跨境等商城营销功能。“企业级商城框架!” - Gitee.com

看云:https://www.kancloud.cn/yami/mall4j

开发环境搭建视频(推荐先看下文档再看视频):https://www.bilibili.com/video/BV1eW4y1V7c1

项目链接

java后台:Java商城系统 企业商城系统 源码商城: ⭐️⭐️⭐️Mall4j商城系统,企业首选的商城系统,采用Springboot3+Vue3等流行框架,代码工整,注释清晰,商城包含小程序、APP、H5、PC端,支持拼团、秒杀、分销、套餐赠品、优惠券、满减折、直播、装修、跨境等商城营销功能。“企业级商城框架!”

vue后台前端:Mall4j电商系统 java电商系统 后台界面-mall4v: Mall4j电商系统 java电商系统 后端界面

小程序:微信小程序商城系统 商城源码: Mall4j商城 小程序商城端 微信小程序商城源码

uni-app:Mall4j商城的Uniapp商城端(移动端商城): mall4j 商城uniapp商城端 基于uniapp的商城,可打包小程序_APP_H5

商城数据流图 

商城系统后台管理页面

商城地址管理 

商城运费管理模板 

小程序分组 

小程序-用户中心 

三、商城系统技术选型

技术版本说明
Spring Boot3.0.4MVC核心框架
Spring Security web3.0.4web应用安全防护
satoken1.34.0一个轻量级 Java 权限认证框架,取代spring oauth2
MyBatis3.5.10ORM框架
MyBatisPlus3.5.3.1基于mybatis,使用lambda表达式的
spring-doc2.0.0接口文档工具
jakarta-validation3.0.2验证框架
redisson3.19.3对redis进行封装、集成分布式锁等
hikari5.0.1数据库连接池
logback1.4.5log日志工具
lombok1.18.26简化对象封装工具
hutool5.8.15更适合国人的java工具集
knife4j4.0.0基于swagger,更便于国人使用的swagger ui

四、系统部署常见问题 

项目有5个项目

- mall4j:j代表java,java项目,这里面包含了小程序/后台vue连接需要的接口。
- mall4v:v代表vue项目,是后台管理员界面使用的前端项目,因为前后端分离的
- mall4m:m代表mini,小程序项目,这里的项目是小程序的项目
- mall4uni:uni代表uniapp,H5项目,这里的项目是H5的项目
- jvm:java虚拟机啦~

1.java开发环境安装

1.1开发环境

以下版本是最低要求的!!! 提问问题前请注意开发环境!!

| 工具      | 版本    |
|---------|-------|
| jdk     | 17    |
| mysql   | 5.7+  |
| redis   | 4.0+  |
| nodejs  | 14-16 |
| xxl-job | 2.4.0 |

 1.2 安装jdk + mysql + redis + maven

如果不了解怎么安装jdk的,可以参考 [菜鸟教程的java相关](https://www.runoob.com/java/java-environment-setup.html)
- 教程展示的是oracle,需要自行搜索openjdk的下载链接,下载jdk17版本

如果不了解怎么安装mysql的,可以参考  [菜鸟教程的mysql相关](https://www.runoob.com/mysql/mysql-install.html) 

如果不了解怎么安装maven的,可以参考  [菜鸟教程的maven相关]( https://www.runoob.com/maven/maven-setup.html ) 

如果对于redis的安装并不了解的,可以参考 [菜鸟教程的redis相关](https://www.runoob.com/redis/redis-install.html)

安装相对简单,网上也有很多教程,这里就不多讲述。安装完按需对redis进行配置,后启动redis服务即可。

 2.启动

- 推荐使用idea,安装lombok插件后,使用idea导入maven项目
- 将yami_shop.sql导入到mysql中,修改`application-dev.yml`更改 datasource.url、user、password
- 通过修改`shop.properties` 修改七牛云、阿里大鱼等信息
- 修改`api.properties` 修改当前接口所在域名,用于支付回调
- 启动redis,端口6379
- 通过`WebApplication`启动项目后台接口,`ApiApplication` 启动项目前端接口
- xxl-job定时任务,通过github或者gitee下载xxl-job的已经打包好的源码,把`XxlJobConfig.class`这个文件的代码注释打开,配置yml文件中相关xxl-job配置即可使用

3.vue开发环境安装

这是一套正常的vue启动流程。如果你无法理解,可能要先学习一下vue...

3.1 安装nodejs

[NodeJS](https://nodejs.org)  项目要求最低 18.12.0,推荐 20.9.0

如果不了解怎么安装nodejs的,可以参考   [菜鸟教程的nodejs相关](https://www.runoob.com/nodejs/nodejs-install-setup.html)

3.2 安装依赖启动项目

项目要求使用 [pnpm](https://www.pnpm.cn/)  包管理工具

使用编辑器打开项目,在根目录执行以下命令安装依赖

pnpm i

如果不想使用 pnpm,请删除 `package.json` 文件中  `preinstall`  脚本后再进行安装

{
    "scripts" : {
        "preinstall": "npx only-allow pnpm"  // 使用其他包管理工具(npm、yarn、cnpm等)请删除此命令
    }
}

H5端和平台端修改文件`.env.production`(生产环境)/ `.env.development`(开发环境)
里面的`VITE_APP_BASE_API`为api接口请求地址, `VITE_APP_RESOURCES_URL`为静态资源文件url 

// api接口请求地址
VITE_APP_BASE_API = 'http://127.0.0.1:8085'

// 静态资源文件url
VITE_APP_RESOURCES_URL = 'https://img.mall4j.com/'

mall4m小程序端修改文件`utils\config.js`,里面的`domain`为api接口请求地址

注意!!如果启动uni项目或者小程序,默认后台api服务端口号为8086,
如果启动后台项目,默认后台admin服务端口号为8085,请对照仔细填写后再启动,如遇401状态码,仔细检查端口号是否配置正确!
如果后台启动后,图形验证码显示“接口验证失败数过多,请稍后再试”,请F12打开network确定连接的admin服务端口号是否正确,ip或域名是否正确,
如果有配置nginx,还要确认下项目访问路径是否正确,可以通过地址+/doc.html来访问接口文档确定是否正确访问到admin服务

运行dev环境:
npm run dev

运行dev环境(H5):
npm run dev:h5

五、商城系统设计
商品分组

在mall4j精选商城首页中,可以看到有`每日上新`、`商城热卖`、`更多商品`等标签栏,在每一栏位中用来展示特定的商品列表。

店铺商品分组有**两种**分组类型:

- 系统内置

  更多宝贝:在新增商品的时候,如果用户没有新增任何的分组标签,系统默认提供了一个默认标签。系统内置的标签不能够被删除。

- 商家自定义分组标签

   用户可以通过自定义分组标签,在首页根据自定义的分组情况对商品的经行展示。

实体类模型: 

model  实体类

商品标签类:

```java
@Data
@TableName("tz_prod_tag")
public class ProdTag implements Serializable {
    private static final long serialVersionUID = 1991508792679311621L;
    /**
     * 分组标签id
     */
    @TableId
    private Long id;
    /**
     * 店铺Id
     */
    private Long shopId;
    /**
     * 分组标题
     */
    private String title;
    /**
     * 状态(1为正常,0为删除)
     */
    private Integer status;
    /**
     * 默认类型(0:商家自定义,1:系统默认类型)
     */
    private Integer isDefault;
    /**
     * 商品数量
     */
    private Long prodCount;
    /**
     * 排序
     */
    private Integer seq;
    /**
     * 列表样式(0:一列一个,1:一列两个,2:一列三个)
     */
    private Integer style;
    /**
     * 创建时间
     */
    private Date createTime;
    /**
     * 修改时间
     */
    private Date updateTime;
    /**
     * 删除时间
     */
    private Date deleteTime;
}

```

- `id` ,商品分组编号,自增
- `shopId` ,店铺ID

​       用于取分每个店铺,可扩展为B2B2C模式

- `status` ,删除时,1为正常,0为删除
- `title`, 分组标题
- `isDefault` 是否为默认类型
  - 商家自定义:每日上新,商城热卖等
  - 系统内置:更多宝贝,默认内置的标签不能被删除,在用户
- `prodCount`,商品数量统计
- `seq` 排序顺序
- `style`列表样式(0:一列一个,1:一列两个,2:一列三个) ,用于扩展开发,用户可以根据自己喜欢的排版布局,对商品布局进行排版

商品分组引用:商品分组**引用**。一个商品可以有多个商品分组。

```java
@Data
@TableName("tz_prod_tag_reference")
public class ProdTagReference implements Serializable{
    private static final long serialVersionUID = 1L;
    /**
     * 分组引用id
     */
    @TableId
    private Long referenceId;
    /**
     * 店铺id
     */
    private Long shopId;
    /**
     * 标签id
     */
    private Long tagId;
    /**
     * 商品id
     */
    private Long prodId;
    /**
     * 状态(1:正常,0:删除)
     */
    private Integer status;
    /**
     * 创建时间
     */
    private Date createTime;
}		
```

- `referenceId` ,分组引用ID
- `shopId`  , 标识所属的店铺,用于取分每个店铺
- `tagId`, 所指向的标签ID
- `prodId`,所指向的商品ID
- `createTime` 创建时间

六、数据库实体关系结构设计

重要属性字段设计 

2.1 Product

Product 字段较多,我们进行简单的切块。

2.1.1 基础字段

```java
@Data
@TableName("tz_prod")
public class Product implements Serializable {

    /**
     * 商品ID
     */
    @TableId
    private Long prodId;

    /**
     * 店铺id
     */
    private Long shopId;

    /**
     * 商品名称
     */
    private String prodName;

    /**
     * 简要描述,卖点等
     */
    private String brief;

    /**
     * 商品主图
     */
    private String pic;

    /**
     * 商品图片
     */
    private String imgs;

    /**
     * 默认是1,表示正常状态, -1表示删除, 0下架
     */
    private Integer status;

    /**
     * 商品分类
     */
    private Long categoryId;

    /**
     * 已经销售数量
     */
    private Integer soldNum;

    /**
     * 录入时间
     */
    private Date createTime;

    /**
     * 修改时间
     */
    private Date updateTime;

    /**
     * 详细描述
     */
    private String content;

    /**
     * 上架时间
     */
    private Date putawayTime;


    @Data
    public static class DeliveryModeVO {

        /**
         * 用户自提
         */
        private Boolean hasUserPickUp;

        /**
         * 店铺配送
         */
        private Boolean hasShopDelivery;

    }
}
```

- `prodId` ,商品id,数据库自增。
- `shopId` ,店铺编号,支持多商户( 店铺 )。
- `categoryId` ,商品所在分类id,每个商品都有自己所属的分类



 2.1.2 价格库存


/**
 * 库存量
 * 基于 sku 的库存数量累加
 */
private Integer totalStocks;

/**
 * 原价
 */
private Double oriPrice;

/**
 * 现价
 */
private Double price;


```

- 在我们的数据库中规定,所有的商品都是具有sku的,就算是只有一种规格的商品,所以`product`里面的库存数量为所有sku库存数量的总和
- `price` ,商品价格为元,这里使用`Double`而没有使用`BigDecimal `,而数据库中使用`decimal` 进行存储,所以在数据库中是可以进行直接进行运算的,而在java当中需要使用`com.yami.shop.common.util.Arith`进行运算 。


2.1.3 运费信息

    /**
     * 配送方式json
     */
    private String deliveryMode;

    /**
     * 运费模板id
     */
    private Long deliveryTemplateId;
    
    @Data
    public static class DeliveryModeVO {

        /**
         * 用户自提
         */
        private Boolean hasUserPickUp;

        /**
         * 店铺配送
         */
        private Boolean hasShopDelivery;

    }


- 根据` deliveryMode `标记所含有的配送方式进行配送。

- `deliveryTemplateId` 运费模板id,根据不同的运费模板设计不同的配送费

  ![img](./img/配送与运费模板.png)

  运费模板的操作见 :运费模板的设计相关文章。


2.2 Sku

商品 SKU 。


@Data
@TableName("tz_sku")
public class Sku implements Serializable {
    /**
     * 单品ID
     */
    @TableId
    private Long skuId;

    /**
     * 商品ID
     */
    private Long prodId;

    /**
     * 销售属性组合字符串,格式是p1:v1;p2:v2
     */
    private String properties;

    /**
     * 原价
     */
    private Double oriPrice;

    /**
     * 价格
     */
    private Double price;

    /**
     * 库存
     */
    private Integer stocks;

    /**
     * 实际库存
     */
    private Integer actualStocks;

    /**
     * 修改时间
     */
    private Date updateTime;

    /**
     * 记录时间
     */
    private Date recTime;

    /**
     * 商家编码
     */
    private String partyCode;

    /**
     * 商品条形码
     */
    private String modelId;

    /**
     * sku图片
     */
    private String pic;

    /**
     * sku名称
     */
    private String skuName;

    /**
     * 商品名称
     */
    private String prodName;

    /**
     * 重量
     */
    private Double weight;

    /**
     * 体积
     */
    private Double volume;

    /**
     * 状态:0禁用 1 启用
     */
    private Integer status;

    /**
     * 0 正常 1 已被删除
     */
    private Integer isDelete;

}


- `skuId` ,SKU 编号,自增,唯一,参见分销场景。

- `prodId` ,商品编号,N:1 指向对应的 Product 。

- `status`,SKU 状态。编辑商品时,当禁用该sku时,前端将会将该sku置灰

- `stocks` ,库存数量。

- `properties`,商品规格,字符串拼接格式。

  绝大多数情况下,数据库里的该字段,不存在检索的需求,更多的时候,是查询整体记录,在内存中解析使用。

  少部分情况,灵活的检索,使用 Elasticsearch 进行解决。

  因为我们的规格是直接保存字符串的,所以可以选择,或直接输入



2.3 ProdProp

商品 SKU 规格属性,在数据库中保存的常用数据。不常用的数据可以直接手动输入即可。


public class ProdProp implements Serializable {
    /**
     * 属性id
     */
    @TableId
    private Long propId;

    /**
     * 属性名称
     */
    private String propName;

    private Long shopId;
}



- `propId` ,属性编号。
- `propName` ,属性名称。



2.4 ProdPropValue

商品 SKU 规格属性,在数据库中保存的常用数据。


public class ProdProp implements Serializable {
	/**
     * 属性值ID
     */
    @TableId
    private Long valueId;

    /**
     * 属性值名称
     */
    private String propValue;

    /**
     * 属性ID
     */
    private Long propId;
}

- `valueId` ,属性值ID。
- `propValue` ,属性值名称。

七、接口设计

1、购物车的订单设计

我们的购物车只有一个表:`tz_basket` 非常简单,但是关联了非常多的表。比如:

- 购物车有商品,关联商品表
- 每个商品都有sku,关联sku表
- 一个购物车有多个店铺的商品,关联店铺表
- 一个购物车肯定是和用户有关的,关联用户表

我们对商品进行添加,修改,其实都很简单,最为让人难以理解的是如何将这些字段进行组合,关联满减满折等一系列的活动。

我们先来看下是如何获取商品信息的(Java代码)

  @PostMapping("/info")
    @Operation(summary = "获取用户购物车信息" , description = "获取用户购物车信息,参数为用户选中的活动项数组,以购物车id为key")
    public ServerResponseEntity<List<ShopCartDto>> info(@RequestBody Map<Long, ShopCartParam> basketIdShopCartParamMap) {
        String userId = SecurityUtils.getUser().getUserId();

        // 更新购物车信息,
        if (MapUtil.isNotEmpty(basketIdShopCartParamMap)) {
            basketService.updateBasketByShopCartParam(userId, basketIdShopCartParamMap);
        }

        // 拿到购物车的所有item
        List<ShopCartItemDto> shopCartItems = basketService.getShopCartItems(userId);
        return ServerResponseEntity.success(basketService.getShopCarts(shopCartItems));

    }


///
这里面传了一个参数:`Map<Long, ShopCartParam> basketIdShopCartParamMap` 这里是当用户改变了某件商品的满减满折活动时,重新改变满减满折信息以后计算加个的一个方法。当然在开源是没有这个满减模块的,只有思路,具体实现需要靠自己了。

我们继续往下看,这里面`basketService.getShopCartItems(userId)`使用的直接是从数据库中获取的数据,而真正对满减满折、店铺等进行排列组合的,在于`basketService.getShopCarts(shopCartItems)` 这个方法。


我们进到`getShopCarts`方法内部,可以查看到一行代码`applicationContext.publishEvent(new ShopCartEvent(shopCart, shopCartItemDtoList));`,这里使用的事件的模式。这个事件的主要作用是用于对模块之间的解耦,比如我们清楚的知道当购物车需要计算价格的时候,需要满减模块的配合,进行“装饰”。最后将装饰回来的东西,返回给前端。 


我们现在看看购物车返回的数据`ServerResponseEntity<List<ShopCartDto>>`,我们清楚一个购物车是分多个店铺的,每一个店铺就是一个`ShopCartDto`,我们看下这个`bean`。
///
@Data
public class ShopCartDto implements Serializable {

   @Schema(description = "店铺ID" , required = true)
   private Long shopId;

   @Schema(description = "店铺名称" , required = true)
   private String shopName;

   @Schema(description = "购物车满减活动携带的商品" , required = true)
   private List<ShopCartItemDiscountDto> shopCartItemDiscounts;

}

//
其实一个店铺下面是有多个商品的,但是根据京东的划分,每当有满减之类的活动时,满减活动的商品总是要归到一类的,所以,每个店铺下面是多个满减活动(`List<ShopCartItemDiscountDto>`),满减活动下面是多个商品(购物项`List<ShopCartItemDto>`),到此你就能明白了`ShopCartItemDiscountDto` 里面的`ChooseDiscountItemDto` 是什么东西了,这个是选中的满减项。
//


public class ShopCartItemDiscountDto implements Serializable {

    @Schema(description = "已选满减项" , required = true)
    private ChooseDiscountItemDto chooseDiscountItemDto;

    @Schema(description = "商品列表" )
    private List<ShopCartItemDto> shopCartItems;
}
//

我们再留意`ShopCartItemDto` 这个`bean` ,发现还有这个东西:

//
@Schema(description = "参与满减活动列表" )
private List<DiscountDto> discounts = new ArrayList<>();
//

其实购物车的每个购物项,都是有很多个满减的活动的,可以自主选择满减活动,然后进行组合,生成新的优惠。而在这选择新的活动类型时,就需要购物车就行新的价格计算。这也就是为什么获取用户购物车信息,也就是`/info`接口需要一个这个参数的原因了`Map<Long, ShopCartParam> basketIdShopCartParamMap`
//

2、订单设计-确认订单

  • 1. 用户点击“立即购买”或“购物车-结算”进入到“确认订单”页面

  • 2. 在“确认订单”页面选择收货地址,优惠券等,重新计算运费、订单价格

  • 3. 提交订单,选择支付方式进行支付

  • 4. 支付完毕

 第一步:

1. 用户点击“立即购买”或“购物车-结算”进入到“确认订单”页面,相关url`/p/order/confirm`

我们希望能够有个统一下单的接口,不太希望“立即购买”和“购物车-结算”两个不同的接口影响到后面所有的流程,毕竟谁也不想一个差不多一样的接口,要写两遍,所以我们看下我们的系统是如何做的。

第二步:

2. 在“确认订单”页面选择收货地址,优惠券等,重新计算运费、订单价格

我们知道无论是在第一步还是第二步,本质上还是在确认订单的页面,其中订单页面的数据结构并没有发生任何的变化,所以其实第一步第二步是可以写在一起的。所以我们可以看到`OrderParam` 还多了两个参数。

public class OrderParam {
	@Schema(description = "购物车id 数组" )
	private List<Long> basketIds;

	@Schema(description = "立即购买时提交的商品项" )
	private OrderItemParam orderItem;
}
//

这里使用了两种情况:

- 假设`basketIds` 不为空,则说明是从购物车进入
- 假设`orderItem` 不为空,则说明是从立即购买进入

通过`basketService.getShopCartItemsByOrderItems(orderParam.getBasketIds(),orderParam.getOrderItem(),userId)` 这个方法对两种情况进行组合,此时并不能将购物车商品删除,因为删除购物车中的商品,是在第三步提交订单的时候进行的,不然用户点击返回键,看到购物车里面的东西还没提交订单,东西就消失了,会感觉很奇怪。

我们重新回到`controller`层,我们看到了一行熟悉的代码`basketService.getShopCarts`

//
    @PostMapping("/confirm")
    @Operation(summary = "结算,生成订单信息" , description = "传入下单所需要的参数进行下单")
    public ServerResponseEntity<ShopCartOrderMergerDto> confirm(@Valid @RequestBody OrderParam orderParam) {
    // 根据店铺组装购车中的商品信息,返回每个店铺中的购物车商品信息
    List<ShopCartDto> shopCarts = basketService.getShopCarts(shopCartItems);
    }

//

这行代码我们再《购物车的设计》这篇已经着重讲过了,但是我们在这为什么还需要这个东西呢?

很简单,无论是点击“立即购买”或“购物车-结算”,事实上都是通过用户计算过一遍金额了,而且甚至有满减满折之类的活动,都是通过了统一的计算的。而这一套计算的流程,我们并不希望重新写一遍。所以当然是能够使用之前计算的金额,那是最好的咯。

//
第二步:

public class OrderParam {
	@Schema(description = "地址ID,0为默认地址" ,required=true)
	@NotNull(message = "地址不能为空")
	private Long addrId;
	
	@Schema(description = "用户是否改变了优惠券的选择,如果用户改变了优惠券的选择,则完全根据传入参数进行优惠券的选择" )
	private Integer userChangeCoupon;

	@Schema(description = "优惠券id数组" )
	private List<Long> couponIds;
}
```

但是有个问题,就是在于用户点击立即购买的时候,没有地址,那样如何计算运费呢?答案就是使用默认地址进行计算呀~



我们看下计算订单的事件,事实上有很多营销活动的时候,订单的计算也是非常的复杂的,所以我们和购物车一样,采用事件的驱动,一个接一个的对订单进行“装饰”,最后生成`ShopCartOrderMergerDto`一个合并的对象

//
    @PostMapping("/confirm")
    @Operation(summary = "结算,生成订单信息" , description = "传入下单所需要的参数进行下单")
    public ServerResponseEntity<ShopCartOrderMergerDto> confirm(@Valid @RequestBody OrderParam orderParam) {
        for (ShopCartDto shopCart : shopCarts) {
            applicationContext.publishEvent(new ConfirmOrderEvent(shopCartOrder,orderParam,shopAllShopCartItems));

        }
    }
//

我们看下`ConfirmOrderListener` 这个事件里面的默认监听器,这里

//
public class ConfirmOrderListener {
        @EventListener(ConfirmOrderEvent.class)
    @Order(ConfirmOrderOrder.DEFAULT)
    public void defaultConfirmOrderEvent(ConfirmOrderEvent event) {


        ShopCartOrderDto shopCartOrderDto = event.getShopCartOrderDto();

        OrderParam orderParam = event.getOrderParam();

        String userId = SecurityUtils.getUser().getUserId();

        // 订单的地址信息
        UserAddr userAddr = userAddrService.getUserAddrByUserId(orderParam.getAddrId(), userId);

        double total = 0.0;

        int totalCount = 0;

        double transfee = 0.0;

        for (ShopCartItemDto shopCartItem : event.getShopCartItems()) {
            // 获取商品信息
            Product product = productService.getProductByProdId(shopCartItem.getProdId());
            // 获取sku信息
            Sku sku = skuService.getSkuBySkuId(shopCartItem.getSkuId());
            if (product == null || sku == null) {
                throw new YamiShopBindException("购物车包含无法识别的商品");
            }
            if (product.getStatus() != 1 || sku.getStatus() != 1) {
                throw new YamiShopBindException("商品[" + sku.getProdName() + "]已下架");
            }

            totalCount = shopCartItem.getProdCount() + totalCount;
            total = Arith.add(shopCartItem.getProductTotalAmount(), total);
            // 用户地址如果为空,则表示该用户从未设置过任何地址相关信息
            if (userAddr != null) {
                // 每个产品的运费相加
                transfee = Arith.add(transfee, transportManagerService.calculateTransfee(shopCartItem, userAddr));
            }

            shopCartItem.setActualTotal(shopCartItem.getProductTotalAmount());
            shopCartOrderDto.setActualTotal(Arith.sub(total, transfee));
            shopCartOrderDto.setTotal(total);
            shopCartOrderDto.setTotalCount(totalCount);
            shopCartOrderDto.setTransfee(transfee);
        }
    }
}
```

值得留意的是,有那么一行代码

```java
        // 用户地址如果为空,则表示该用户从未设置过任何地址相关信息
        if (userAddr != null) {
            // 每个产品的运费相加
            transfee = Arith.add(transfee, transportManagerService.calculateTransfee(shopCartItem, userAddr));
        }
```
运费是根据用户地址进行计算,当然还包括运费模板啦,想了解运费模板的,可以参考运费模板相关的章节。

那么有人就问了,那么优惠券呢?优惠券是有另一个监听器进行监听计算价格啦,购买了专业版或以上的版本就能看到源码咯~



我们看看返回给前端的订单信息:

```java
@Data
public class ShopCartOrderMergerDto implements Serializable{

    @Schema(description = "实际总值" , required = true)
    private Double actualTotal;

    @Schema(description = "商品总值" , required = true)
    private Double total;

    @Schema(description = "商品总数" , required = true)
    private Integer totalCount;

    @Schema(description = "订单优惠金额(所有店铺优惠金额相加)" , required = true)
    private Double orderReduce;

    @Schema(description = "地址Dto" , required = true)
    private UserAddrDto userAddr;

    @Schema(description = "每个店铺的购物车信息" , required = true)
    private List<ShopCartOrderDto> shopCartOrders;

    @Schema(description = "整个订单可以使用的优惠券列表" , required = true)
    private List<CouponOrderDto> coupons;
}

```

这里又有一段我们熟悉的代码:

```java
@Schema(description = "每个店铺的购物车信息" , required = true)
private List<ShopCartOrderDto> shopCartOrders;
```
没错这里返回的数据格式,和购物车的格式是一样的,因为第一步当中已经说明,订单来自于购物车的计算,所以会在基础上条件新的数据,基本上就是返回给前端的数据了。

3、订单设计-提交订单

首先我们在这里严重的批评一些,在接口订单的接口中,直接传订单金额,而不是使用下单是已经计算好金额的人,这些接口岂不是使用0.01就能将全部的商品都买下来了?

我们回到订单设计这一个模块,首先我们在确认订单的时候就已经将价格计算完成了,那么我们肯定是想将计算结果给保留下来的,至于计算的过程,我们并不希望这个过程还要进行一遍的计算。

我们返回确认订单的接口,看到这样一行代码:

    @Operation(summary = "结算,生成订单信息" , description = "传入下单所需要的参数进行下单")
    public ServerResponseEntity<ShopCartOrderMergerDto> confirm(@Valid @RequestBody OrderParam orderParam) {
    orderService.putConfirmOrderCache(userId,shopCartOrderMergerDto);
    }

这里每经过一次计算,就将整个订单通过`userId`进行了保存,而这个缓存的时间为30分钟,当用户使用


    @PostMapping("/submit")
    @Operation(summary = "提交订单,返回支付流水号" , description = "根据传入的参数判断是否为购物车提交订单,同时对购物车进行删除,用户开始进行支付")
    public ServerResponseEntity<OrderNumbersDto> submitOrders(@Valid @RequestBody SubmitOrderParam submitOrderParam) {
    ShopCartOrderMergerDto mergerOrder = orderService.getConfirmOrderCache(userId);
        if (mergerOrder == null) {
            throw new YamiShopBindException("订单已过期,请重新下单");
        }
        
        // 省略中间一大段。。。
        
        orderService.removeConfirmOrderCache(userId);
    }
```

当无法获取缓存的时候告知用户订单过期,当订单进行提交完毕的时候,将之前的缓存给清除。

我们又回到提交订单中间这几行代码:

```java
List<Order> orders = orderService.submit(userId,mergerOrder);
```

这行代码也就是提交订单的核心代码

```java
eventPublisher.publishEvent(new SubmitOrderEvent(mergerOrder, orderList));
```

其中这里依旧是使用时间的方式,将订单进行提交,看下这个`SubmitOrderEvent`的默认监听事件。

@Component("defaultSubmitOrderListener")
@AllArgsConstructor
public class SubmitOrderListener {
    public void defaultSubmitOrderListener(SubmitOrderEvent event) {
        // ...
    }
}

这里有几段值得注意的地方:

- 这里是`UserAddrOrder` 并不是`UserAddr`:

// 把订单地址保存到数据库
UserAddrOrder userAddrOrder = BeanUtil.copyProperties(mergerOrder.getUserAddr(), UserAddrOrder.class);
if (userAddrOrder == null) {
    throw new YamiShopBindException("请填写收货地址");
}
userAddrOrder.setUserId(userId);
userAddrOrder.setCreateTime(now);
userAddrOrderService.save(userAddrOrder);
```

这里是将订单的收货地址进行了保存入库的操作,这里是绝对不能只保存用户的地址id在订单中的,要将地址入库,原因是如果用户在订单中设置了一个地址,如果用户在订单还没配送的时候,将自己的地址改了的话。如果仅采用关联的地址,就会出现问题。

- 为每个店铺生成一个订单
// 每个店铺生成一个订单

for (ShopCartOrderDto shopCartOrderDto : shopCartOrders) {
}

这里为每个店铺创建一个订单,是为了,以后平台结算给商家时,每个商家的订单单独结算。用户确认收货时,也可以为每家店铺单独确认收货。

- 使用雪花算法生成订单id, 如果对雪花算法感兴趣的,可以去搜索下相关内容:

String orderNumber = String.valueOf(snowflake.nextId());

我们不想单多台服务器生成的id冲突,也不想生成uuid这样的很奇怪的字符串id,更不想直接使用数据库主键这种东西时,雪花算法就出现咯。

- 当用户提交订单的时候,购物车里面勾选的商品,理所当然的要清空掉


// 删除购物车的商品信息
if (!basketIds.isEmpty()) {
    basketMapper.deleteShopCartItemsByBasketIds(userId, basketIds);

}


- 使用数据库的乐观锁,防止超卖:
if (skuMapper.updateStocks(sku) == 0) {
                skuService.removeSkuCacheBySkuId(key, sku.getProdId());
                throw new YamiShopBindException("商品:[" + sku.getProdName() + "]库存不足");
            }
```

```sql
update tz_sku set stocks = stocks - #{sku.stocks}, version = version + 1,update_time = NOW() where sku_id = #{sku.skuId} and #{sku.stocks} &lt;= stocks

超卖一直是一件非常令人头疼的事情,如果对订单直接加悲观锁的话,那么下单的性能将会很差。商城最重要的就是下单啦,要是性能很差,那人家还下个鬼的单哟,所以我们采用数据库的乐观锁进行下单。

所谓乐观锁,就是在 where 条件下加上极限的条件,比如在这里就是更新的库存小于或等于商品的库存,在这种情况下可以对库存更新成功,则更新完成了,否则抛异常(真正的定义肯定不是这样的啦,你可以百度下 “乐观锁更新库存”)。注意这里在抛异常以前,应该将缓存也更新了,不然无法及时更新。

最后我们回到`controller`

return ServerResponseEntity.success(new OrderNumbersDto(orderNumbers.toString()));

这里面返回了多个订单项,这里就变成了并单支付咯,在多个店铺一起进行支付的时候需要进行并单支付的操作,一个店铺的时候,又要变成一个订单支付的操作,可是我们只希望有一个统一支付的接口进行调用,所以我们的支付接口要进行一点点的设计咯。

4、订单设计-支付

## 支付

我们来到`PayController` ,这里就是统一支付的接口,当然这里的统一支付采用的是模拟支付。

我们直接看一下核心代码:

```java
PayInfoDto payInfo = payService.pay(userId, payParam);
```

再看看里面的代码:

```java
        // 修改订单信息
        for (String orderNumber : orderNumbers) {
            OrderSettlement orderSettlement = new OrderSettlement();
            orderSettlement.setPayNo(payNo);
            orderSettlement.setPayType(payParam.getPayType());
            orderSettlement.setUserId(userId);
            orderSettlement.setOrderNumber(orderNumber);
            orderSettlementMapper.updateByOrderNumberAndUserId(orderSettlement);

            Order order = orderMapper.getOrderByOrderNumber(orderNumber);
            prodName.append(order.getProdName()).append(StrUtil.COMMA);
        }
```

这里对传过来的支付参数`orderNumbers`进行了拆分,为每个订单的结算信息都进行了更新,所以这里便支持了分单支付和并单支付的流程。

订单金额:

```java
// 除了ordernumber不一样,其他都一样
List<OrderSettlement> settlements = orderSettlementMapper.getSettlementsByPayNo(payNo);
// 应支付的总金额
double payAmount = 0.0;
for (OrderSettlement orderSettlement : settlements) {
    payAmount = Arith.add(payAmount, orderSettlement.getPayAmount());
}
```

这里面应支付的金额是通过数据库中获取的订单金额,是不接受任何前端传入的订单金额的。

## 支付回调

我们回到`controller`

```java
orderRequest.setNotifyUrl(apiConfig.getDomainName() + "/notice/pay/order");
```

这里面规定的,订单回调的地址,这也就是为什么需要`api.properties` 传入`api.domainName`的原因

根据订单配置`/notice/pay/order`,我们去到订单回调的`controller`既`PayNoticeController`

- 验签

因为订单的已经决定的订单已经支付成功,所以订单的回调是需要做一些验证的。不然谁都可以调用订单回调的地址,实在是十分危险。

其实`wxjava`这个工具包已经对返回的参数进行了校验

```java
WxPayOrderNotifyResult parseOrderNotifyResult = wxMiniPayService.parseOrderNotifyResult(xmlData);
```

在上面这个方法之下,就有那么一句话

```java
result.checkResult(this, this.getConfig().getSignType(), false);
```

- 更新支付状态

我们看看这里的业务核心方法:

```java
// 根据内部订单号更新order settlement
payService.paySuccess(payNo, bizPayNo);
```

```java
    @Override
    @Transactional(rollbackFor = Exception.class)
    public List<String> paySuccess(String payNo, String bizPayNo) {
        List<OrderSettlement> orderSettlements = orderSettlementMapper.selectList(new LambdaQueryWrapper<OrderSettlement>().eq(OrderSettlement::getPayNo, payNo));

        OrderSettlement settlement = orderSettlements.get(0);

        // 订单已支付
        if (settlement.getPayStatus() == 1) {
            log.info("订单已支付,settlement.id:{}",settlement.getSettlementId());
            return null;
        }
        // 修改订单结算信息
        if (orderSettlementMapper.updateToPay(payNo, settlement.getVersion()) < 1) {
            throw new YamiShopBindException("结算信息已更改");
        }


        List<String> orderNumbers = orderSettlements.stream().map(OrderSettlement::getOrderNumber).collect(Collectors.toList());

        // 将订单改为已支付状态
        orderMapper.updateByToPaySuccess(orderNumbers, PayType.WECHATPAY.value());

        List<Order> orders = orderNumbers.stream().map(orderNumber -> {
            Order order = orderMapper.getOrderByOrderNumber(orderNumber);
            order.setOrderItems(orderItemMapper.listByOrderNumber(orderNumber));
            return order;
        }).collect(Collectors.toList());
        eventPublisher.publishEvent(new PaySuccessOrderEvent(orders));
        return orderNumbers;
    }
```

这里无非就是找到原来的订单,将订单变成已支付的状态。

而这里同样有事件支付成功的事件

```java
eventPublisher.publishEvent(new PaySuccessOrderEvent(orders));
```

这里的事件也是和营销活动有关的,比如分销,这些代码也是商业版才有的。

八、总结


随着互联网的发展,用户的购物习惯逐渐从线下转移到微信等App平台,人们更倾向去通过网络进行便捷的消费。商家纷纷推出了自己的微信购物App,为了更好地提供更丰富更快捷的服务,一个商城管理系统对于商家来说很有必要。可以极大地提高用户购物需求,提高用户对商品的选择,间接降低线下商品交易额。可以说商城系统的兴起极大地改变我们购物的方式,也传统销售商造成极大的冲击,但是也创造出物流、客服等岗位,带动虚拟商品发展,促进大数据推荐、统计、人工智能客服、商品识别等技术进步。可以说商城购物领域已经彻底改变了人们传统消费观念。


 

  • 29
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
权限管理系统是一种用于管理用户权限和角色的系统,可以根据用户的身份和角色来控制其访问系统中的各种资源。基于SpringBootVue和Redis的前后端分离模式,可以更好地实现权限管理系统的功能。 在这个系统中,SpringBoot作为后端框架,提供了强大的功能和稳定的性能,可以处理用户的请求并进行权限验证。Vue作为前端框架,提供了友好的界面和良好的用户体验,可以让用户方便地进行权限管理操作。而Redis作为缓存数据库,可以用来存储权限信息和用户的登录状态,加快系统的响应速度和提高系统的性能。 在权限管理系统中,我们可以使用RBAC(基于角色的权限控制)模型,将用户分配到不同的角色,再将角色分配到不同的权限,从而实现对用户访问资源的控制。通过这种方式,可以实现灵活的权限管理,并且可以根据实际需求动态地调整用户的权限和角色。 通过使用SpringBootVue,我们可以实现前后端分离,让前端和后端分别进行开发和维护,降低了系统的耦合度,同时也增加了系统的灵活性和可维护性。而通过使用Redis,我们可以充分利用其高速的读取和写入能力,有效地提升系统的性能和响应速度。 综上所述,基于SpringBootVue和Redis的权限管理系统,可以实现灵活、高效和安全的权限管理功能,满足用户对于权限管理的各种需求。同时,前后端分离模式也使得系统更加灵活和易于维护。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序小勇

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值