Commit 64efd378 authored by shengnan hu's avatar shengnan hu
Browse files

commit test

parent 9491af7a
Pipeline #266 passed with stage
in 1 minute and 41 seconds
server:
port: 7091
console:
user:
username: seata
password: seata
seata:
config:
# support: nacos, consul, apollo, zk, etcd3
type: nacos
nacos:
server-addr: 192.168.1.46:8848
namespace: 4b70485d-72dd-44df-a76a-7a3f578a3001
group: SEATA_GROUP
username: nacos
password: nacos
registry:
# support: nacos, eureka, redis, zk, consul, etcd3, sofa
type: nacos
nacos:
application: seata-server
server-addr: 192.168.1.46:8848
group: SEATA_GROUP
cluster: default
namespace: 4b70485d-72dd-44df-a76a-7a3f578a3001
username: nacos
password: nacos
# server:
# service-port: 8091 #If not configured, the default is '${server.port} + 1000'
security:
secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf3850a017
tokenValidityInMilliseconds: 1800000
ignore:
urls: /,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.ico,/console-fe/public/**,/api/v1/auth/login
`mall4cloud`是一个前后端分离的项目,所以由多个项目组成,如下:
- `mall4cloud` : java微服务后台代码(包含后台、前端、所有微服务相关的接口)
- `mall4cloud-multishop` : 商家端vue代码
- `mall4cloud-platform` : 平台端vue代码
- `mall4cloud-uniapp` : 移动端uniapp代码(包含 H5、小程序、android、ios)
后台基础框架采用使用mit开源协议的 `vue-element-admin`
具体可以查看 [vue-element-admin 介绍](https://panjiachen.gitee.io/vue-element-admin-site/zh/guide/)
https://panjiachen.gitee.io/vue-element-admin-site/zh/guide/
```
├── build # 构建相关
├── public # 静态资源
│ │── favicon.ico # favicon图标
│ └── index.html # html模板
├── src # 源代码
│ ├── api # 所有请求(根据不同后台不同的服务分包)
│ ├── assets # 主题 字体等静态资源
│ ├── components # 全局公用组件
│ ├── directive # 全局指令
│ ├── filters # 全局 filter
│ ├── icons # 项目所有 svg icons
│ ├── lang # 国际化 language
│ ├── layout # 全局 layout
│ ├── router # 路由
│ ├── store # 全局 store管理
│ ├── styles # 全局样式
│ ├── utils # 全局公用方法
│ ├── views # views 所有页面
│ ├── App.vue # 入口页面
│ ├── main.js # 入口文件 加载组件 初始化等
│ └── permission.js # 权限管理
├── tests # 测试
├── .env.xxx # 环境变量配置
├── .eslintrc.js # eslint 配置项
├── .babelrc # babel-loader 配置
├── .travis.yml # 自动化CI配置
├── vue.config.js # vue-cli 配置
├── postcss.config.js # postcss 配置
└── package.json # package.json
```
```
├── public # 公共文件目录
│ └── index.html # html模版
├── src # 源代码
│ ├── components # 公共组件
│ ├── js_sdk # uniapp第三方插件目录
│ ├── packageActivities # 活动功能分包
│ ├── packageShop # 店铺功能分包
│ ├── pages # 主包
│ ├── static # 静态资源
│ │ │── empty-img # 提示图片
│ │ │── images # 各页面的图片资源
│ │ └── tabbar # 底部tab栏的图标
│ ├── utils # 存放通用工具
│ ├── wxs # wxs文件目录
│ ├── app.css # 全局样式
│ ├── App.vue # 入口页面
│ ├── main.js # 初始化入口文件
│ ├── mainfest.json # uniapp项目配置文件
│ ├── pages.json # 全局页面配置文件
│ ├── popup.css # 公共弹窗css样式
│ ├── router.js # 导航路由
│ └── uni.scss # uni-app内置的常用样式变量
├── .eslintignore # eslint忽略配置
├── .eslintrc.js # eslint规则制定文件
├── babel.config.js # babel配置
├── package-lock.json # 锁定安装时包的版本号
├── package.json # package.json 项目基本信息
├── postcss.config # postcss配置文件
└── vue.config.js # vue-cli 配置
```
> 为了让项目更加方便检测出代码规范的问题,我们在项目中使用的是阿里的规范(详细可以看https://github.com/alibaba/p3c 这里面的[Java开发手册(嵩山版).pdf](https://github.com/alibaba/p3c/blob/master/Java开发手册(嵩山版).pdf)),同时使用 `Alibaba Java Coding Guidelines` 这款插件进行规约扫描
我们先来看下规范当中的目录结构
![](../img/目录结构和规范/阿里应用分层.png)
- 开放 API 层:可直接封装 Service 接口暴露成 RPC 接口;通过 Web 封装成 http 接口;网关控制层等。
- 终端显示层:各个端的模板渲染并执行显示的层。当前主要是 velocity 渲染,JS 渲染,JSP 渲染,移 动端展示等。
- Web 层:主要是对访问控制进行转发,各类基本参数校验,或者不复用的业务简单处理等。
- Service 层:相对具体的业务逻辑服务层。
- Manager 层:通用业务处理层,它有如下特征:
- 1) 对第三方平台封装的层,预处理返回结果及转化异常信息,适配上层接口。
- 2) 对 Service 层通用能力的下沉,如缓存方案、中间件通用处理。
- 3) 与 DAO 层交互,对多个 DAO 的组合复用。
- DAO 层:数据访问层,与底层 MySQL、Oracle、Hbase、OB 等进行数据交互。
- 第三方服务:包括其它部门 RPC 服务接口,基础平台,其它公司的 HTTP 接口,如淘宝开放平台、支 付宝付款服务、高德地图服务等。
- 外部数据接口:外部(应用)数据存储服务提供的接口,多见于数据迁移场景中。
------
以上是阿里规范当中的目录结构,我们也有自己的目录结构
![](../img/目录结构和规范/应用分层.png)
- VO(View Object):显示层对象,通常是 Web 向模板渲染引擎层传输的对象。
- DTO(Data Transfer Object):数据传输对象,前端像后台进行传输的对象,类似于param。
- BO(Business Object):业务对象,内部业务对象,只在内部传递,不对外进行传递。
- Model:模型层,此对象与数据库表结构一一对应,通过 Mapper 层向上传输数据源对象。
- Controller:主要是对外部访问控制进行转发,各类基本参数校验,或者不复用的业务简单处理等。为了简单起见,一些与事务无关的代码也在这里编写。
- FeignClient:由于微服务之间存在互相调用,这里是内部请求的接口。
- Controller:主要是对内部访问控制进行转发,各类基本参数校验,或者不复用的业务简单处理等。为了简单起见,一些与事务无关的代码也在这里编写。
- Service 层:相对具体的业务逻辑服务层。
- Manager 层:通用业务处理层,它有如下特征:
- 1) 对第三方平台封装的层,预处理返回结果及转化异常信息,适配上层接口。
- 2) 对 Service 层通用能力的下沉,如缓存方案、中间件通用处理。
- 3) 与 DAO 层交互,对多个 DAO 的组合复用。
- Mapper持久层:数据访问层,与底层 MySQL进行数据交互。
- Task层:由于每个服务之间会存在定时任务,比如定时确认收货,定时将活动失效等情况,这里面的Task实际上连接的是`xxl-job`(具体可以查看 https://github.com/xuxueli/xxl-job )进行任务调度。
- Listener:监听 `RocketMQ` 进行处理,有时候会监听`easyexcel`相关数据。
关于`FeignClient`,由于微服务之间存在互相调用,`Feign` 是http协议,理论上是为了解耦,而实际上提供方接口进行修改,调用方却没有进行修改的时候,会造成异常,所以我们抽取出来。还有就是对内暴露的接口,是很多地方都公用的,所以我们还将接口抽取了出了一个模块,方便引用。可以看到`mall4cloud-api`这个模块下是所有对内`feign`接口的信息。
## 目录结构
```
mall4cloud
├─mall4cloud-api -- 内网接口
│ ├─mall4cloud-api-auth -- 授权对内接口
│ ├─mall4cloud-api-biz -- biz对内接口
│ ├─mall4cloud-api-leaf -- 美团分布式id生成接口
│ ├─mall4cloud-api-multishop -- 店铺对内接口
│ ├─mall4cloud-api-order -- 订单对内接口
│ ├─mall4cloud-api-platform -- 平台对内接口
│ ├─mall4cloud-api-product -- 商品对内接口
│ ├─mall4cloud-api-rbac -- 用户角色权限对内接口
│ ├─mall4cloud-api-search -- 搜索对内接口
│ └─mall4cloud-api-user -- 用户对内接口
├─mall4cloud-auth -- 授权校验模块
├─mall4cloud-biz -- mall4cloud 业务代码。如图片上传/短信等
├─mall4cloud-common -- 一些公共的方法
│ ├─mall4cloud-common-cache -- 缓存相关公共代码
│ ├─mall4cloud-common-core -- 公共模块核心(公共中的公共代码)
│ ├─mall4cloud-common-database -- 数据库连接相关公共代码
│ ├─mall4cloud-common-order -- 订单相关公共代码
│ ├─mall4cloud-common-product -- 商品相关公共代码
│ ├─mall4cloud-common-rocketmq -- rocketmq相关公共代码
│ └─mall4cloud-common-security -- 安全相关公共代码
├─mall4cloud-gateway -- 网关
├─mall4cloud-leaf -- 基于美团leaf的生成id服务
├─mall4cloud-multishop -- 商家端
├─mall4cloud-order -- 订单服务
├─mall4cloud-payment -- 支付服务
├─mall4cloud-platform -- 平台端
├─mall4cloud-product -- 商品服务
├─mall4cloud-rbac -- 用户角色权限模块
├─mall4cloud-search -- 搜索模块
└─mall4cloud-user -- 用户服务
```
## 1. 中间件安装
本项目是一个分布式的项目,依赖较多的中间件,所以要先将中间件搭建起来才能够启动后台项目。
中间件安装参考,可以看`中间件docker-compse一键安装` 安装对应的中间件。
## 2. 导入项目
### 2.1 安装jdk + maven + git
使用gitee下载开源项目。
使用IDEA打开项目。
使用`ctrl + shift + r` 全局替换掉 `192.168.1.46` 为中间件服务器ip。
## 3. 设置idea内存
在idea启动所有的项目,是很吃力的事情。所以要修改下idea的配置,让其能有足够的内存启动项目。
### 3.1 减小jar启动占用内存
编辑虚拟机配置,将每个服务的内存改为512M,`-Xms512m -Xmx512m -Xss256k`,如果机器实在内存不够,可以将512适当减少,但是减少到一定程度,如256m会造成java虚拟机进行频繁的垃圾回收,会更加卡,所以推荐512m。
![image-20210706101932640](../img/开发文档/idea配置-1.png)
![image-20210706101954376](../img/开发文档/idea配置-2.png)
### 3.2 增加idea可使用内存
编辑idea配置,增加内存,至少变为2G,根据需要,可以适当增大,以提高流畅度。
```vmoptions
-Xms512m
-Xmx2048m
-XX:ReservedCodeCacheSize=512m
-XX:+UseConcMarkSweepGC
-XX:SoftRefLRUPolicyMSPerMB=100
```
![image-20210706102108314](../img/开发文档/ideavm配置-1.png)
![image-20210706102135990](../img/开发文档/ideavm配置-2.png)
配置完毕,重启idea,此时可以启动所有项目。
## 4. 启动项目
![image-20210706102545837](../img/开发文档/必须启动的服务.png)
图中的红框是必须启动的项目,其他是按需启动,推荐全部启动。
## 授权校验流程
为了确保系统的安全与获取用户信息,一般情况下都是用token解决的,那么我们的系统,token是如何生成,又是如何校验的呢?
### token的生成
`TokenStore` 有几个方法
```java
public class TokenStore {
/**
* 将用户的部分信息存储在token中,并返回token信息
* @param userInfoInToken 用户在token中的信息
* @return token信息
*/
public TokenInfoBO storeAccessToken(UserInfoInTokenBO userInfoInToken) {}
/**
* 根据accessToken 获取用户信息
* @param accessToken accessToken
* @param needDecrypt 是否需要解密
* @return 用户信息
*/
public ServerResponseEntity<UserInfoInTokenBO> getUserInfoByAccessToken(String accessToken, boolean needDecrypt) {}
/**
* 刷新token,并返回新的token
* @param refreshToken
* @return
*/
public ServerResponseEntity<TokenInfoBO> refreshToken(String refreshToken) {}
}
```
`LoginController#login()` 方法中,登录完毕之后使用`storeAccessToken`将登录的用户信息保存在redis中
### token的校验
在我们的设计当中,会一个授权中心,专门用于用户的授权登录,并校验token。从而不需要在每个服务都去创建自身的授权方法。
我们用商品的服务`mall4cloud-product`来举例,我们可以发现在`pom.xml`中依赖了`mall4cloud-common-security`模块。
在模块中有个过滤器`AuthFilter`,里面有这么一段
```java
tokenFeignClient.checkToken(accessToken)
```
其中`tokenFeignClient``mall4cloud-api-auth` 模块的方法,该接口其实是`feign`的一个接口,而实现就是`mall4cloud-auth`进行实现。因为我们说过,我们的认证授权应该是一个统一的服务来的,而这个服务就是`mall4cloud-auth`服务。也就是说项目启动,几乎是必须启动该项目先的。
### 配置不需要授权就能访问的url
其实并不是所有url都应该登录才能够被用户所访问到的,如浏览商品,搜索商品的时候,用户是不需要登录就能进行的操作,这个时候该怎么办呢?我们在回到我们的`AuthFilter`,里面有一段
```java
List<String> excludePathPatterns = authConfigAdapter.excludePathPatterns();
```
这里边有个`authConfigAdapter`其实实现该类就能将对应的连接设置为可以访问,或不可以访问了。
### 用户角色权限
在用户角色权限的模型中,一个用户的权限往往是需要登录才能知道的。也细化到每个url,每个方法某个用户是否能够访问。我们的系统有的需要rbac模型,有的不需要,所以我们提取了一个rbac模型的服务`mall4cloud-rbac`。我们回到`AuthFilter`,里面有一段
```java
// 省略...
authConfigAdapter.needRbac() && !checkRbac(userInfoInToken, req.getRequestURI(), req.getMethod())
// 省略...
permissionFeignClient.checkPermission(checkPermissionDTO)
// 省略...
```
这里面的`permissionFeignClient` 其实也是一个feign服务,用于连接 `mall4cloud-rbac` 这个服务,进行rbac模型的校验。
## 文件上传
本系统支持minio文件上传
文件上传的配置,一般配置一遍就不需要配置了。
文件上传的流程分成两种:
1. 将文件上传到服务器,再通过服务器上传到minio保存,再保存到本地。这种上传形式是需要消耗两倍的流量的
2. 通过服务器返回一个token之类的密钥,然后前端有直接上传到minio的权限。这种上传形式只需要消耗单次上传的流量
我们采用的是第二种上传的形式,因为要前端去兼容minio的上传,所以不仅是后台,前端也是需要做文件上传的配置的
首先我们要修改后台的文件上传配置,后台的文件上传配置在 `nacos` 的配置中心进行配置
登录 `nacos` ,进入配置管理 - 配置列表,根据生产环境or测试环境不同,选择不同的命名空间,如测试环境是`public` 的命名空间
根据打包的配置,找到`application-{环境}.yml`进行编辑
```yaml
biz:
oss:
# resources-url是带有bucket的
resources-url: http://192.168.1.46:9000/mall4cloud
type: 1
endpoint: http://192.168.1.46:9000
bucket: mall4cloud
access-key-id: admin
access-key-secret: admin123456
```
这里对这些变量进行下解释:
- type: 文件上传类型 1.minio
- bucket: 文件上传归档的一个桶(当成是一个最大的文件夹就好)
-`minio`在中间件搭建的时候创建的桶,参考中间件一键安装,创建的bucket
- access-key-id:
- minio可以直接根据docker启动的命令获取账号密码,这里取的是`MINIO_ROOT_USER`,也就是登录的账号
- access-key-secret:
- minio可以直接根据docker启动的命令获取账号密码,这里取的是`MINIO_ROOT_PASSWORD`,也就是登录的密码
- endpoint: 文件上传的时候,需要上传的路径
- minio就是minio的路径
- resources-url: resources-url是带有bucket的
- minio就是minio的路径 + bucket
除了后台要修改图片上传的配置,前端也是需要修改文件上传配置的
1. `mall4cloud-admin``mall4cloud-platform` 对于这两个项目修改根目录下的`.env.{环境}`相关文件,如开发环境修改`.env.development`文件。
- VUE_APP_RESOURCES_URL: 对应上面后台配置的resources-url
- VUE_APP_RESOURCES_TYPE: 对应上面后台配置的type
2. `mall4cloud-uniapp` 这个项目修改 `src/utils/config.js`
- resourcesUrl: 对应上面后台配置的resources-url
- resourcesActionType: 对应上面后台配置的type
# 目录结构
```
mall4cloud
├── mall4cloud-api -- api接口,仅对内使用,一般用来放feign的接口,对内使用
├ └── mall4cloud-api-auth -- 授权 feign接口(只要需要授权验证的微服务,就需要用到该接口)
├ └── mall4cloud-api-leaf -- 分布式id feign接口(需要生成分布式唯一id的,就需要用到该接口)
├ └── mall4cloud-api-rbac -- 用户角色权限 feign接口(如果一个服务,需要校验菜单权限,就需要用到该接口)
├── mall4cloud-auth -- 授权服务,用户登陆生成token并返回,token的校验等就是使用该服务的
├── mall4cloud-biz -- 第三方业务服务,如minio文件上传等
├── mall4cloud-common -- 一些公共业务
├ └── mall4cloud-common-cache -- 缓存模块
├ └── mall4cloud-common-core -- 一些常用核心代码模块
├ └── mall4cloud-common-database -- 数据库模块
├ └── mall4cloud-common-database -- 验证授权等安全模块
├── mall4cloud-gateway -- 网关服务
├── mall4cloud-leaf -- 分布式id服务(使用美团的leaf创建分布式id)
├── mall4cloud-multishop -- 商家服务
├── mall4cloud-order -- 订单服务
├── mall4cloud-payment -- 支付服务
├── mall4cloud-platform -- 平台服务
├── mall4cloud-product -- 商品服务
├── mall4cloud-rbac -- 菜单服务
├── mall4cloud-search -- 搜索服务(使用elasticsearch实现)
├── mall4cloud-user -- 用户服务
```
## 统一异常处理
### 后端异常处理
在开发过程中,不可避免的是需要处理各种异常,异常处理方法随处可见,所以代码中就会出现大量的`try {...} catch {...} finally {...}` 代码块,不仅会造成大量的冗余代码,而且还影响代码的可读性,所以对异常统一处理非常有必要。为此,我们定义了一个统一的异常类`mall4cloudException` 与异常管理类 `DefaultExceptionHandlerConfig`
我们先来看下 `mall4cloudException`的代码
```java
public class mall4cloudException extends RuntimeException {
private static final long serialVersionUID = 1L;
private Object object;
/**
* 响应状态码枚举
*/
private ResponseEnum responseEnum;
public mall4cloudException(String msg) {
super(msg);
}
public mall4cloudException(String msg, Object object) {
super(msg);
this.object = object;
}
public mall4cloudException(String msg, Throwable cause) {
super(msg, cause);
}
public mall4cloudException(ResponseEnum responseEnum) {
super(responseEnum.getMsg());
this.responseEnum = responseEnum;
}
public mall4cloudException(ResponseEnum responseEnum,Object object) {
super(responseEnum.getMsg());
this.responseEnum = responseEnum;
this.object = object;
}
public Object getObject() {
return object;
}
public ResponseEnum getResponseEnum() {
return responseEnum;
}
}
```
`ResponseEnum`为我们自定义的返回状态码的枚举类,定义为一个枚举类,更直观处理异常返回的状态码及异常内容,以后每增加一种异常情况,只需增加一个枚举实例即可,不用每一种异常都定义一个异常类。
```java
public enum ResponseEnum {
/**
* ok
*/
OK("00000", "ok"),
/**
* 用于直接显示提示用户的错误,内容由输入内容决定
*/
SHOW_FAIL("A00001", ""),
/**
* 方法参数没有校验,内容由输入内容决定
*/
METHOD_ARGUMENT_NOT_VALID("A00002", ""),
/**
* 无法读取获取请求参数
*/
HTTP_MESSAGE_NOT_READABLE("A00003", "请求参数格式有误"),
/**
* 未授权
*/
UNAUTHORIZED("A00004", "Unauthorized"),
/**
* 服务器出了点小差
*/
EXCEPTION("A00005", "服务器出了点小差");
private final String code;
private final String msg;
public String value() {
return code;
}
public String getMsg() {
return msg;
}
ResponseEnum(String code, String msg) {
this.code = code;
this.msg = msg;
}
@Override
public String toString() {
return "ResponseEnum{" + "code='" + code + '\'' + ", msg='" + msg + '\'' + "} " + super.toString();
}
}
```
再来看看 `DefaultExceptionHandlerConfig`
```java
@RestController
@RestControllerAdvice
public class DefaultExceptionHandlerConfig {
private static final Logger logger = LoggerFactory.getLogger(DefaultExceptionHandlerConfig.class);
@ExceptionHandler(mall4cloudException.class)
public ResponseEntity<ServerResponseEntity<Object>> mall4cloudExceptionHandler(mall4cloudException e) {
logger.error("mall4cloudExceptionHandler", e);
ResponseEnum responseEnum = e.getResponseEnum();
// 失败返回失败消息 + 状态码
if (responseEnum != null) {
return ResponseEntity.status(HttpStatus.OK).body(ServerResponseEntity.fail(responseEnum, e.getObject()));
}
// 失败返回消息 状态码固定为直接显示消息的状态码
return ResponseEntity.status(HttpStatus.OK).body(ServerResponseEntity.showFailMsg(e.getMessage()));
}
}
```
---
### 前端异常处理
前端请求与相应做了封装,请求响应的内容会被拦截器所拦截,当后台返回给前台特定的状态码,前台将显示不同报错信息。请求响应非常常见,我们查看在`src\utils\request.js`里面的其中一段代码
```javascript
service.interceptors.response.use(
response => {
const res = response.data
if (res.code === '00000') {
return res.data
}
// A00001 用于直接显示提示用户的错误,内容由输入内容决定
// A00003 无法读取获取请求参数
if (res.code === 'A00001' || res.code === 'A00003' || res.code === 'A00005') {
Message({
message: res.msg || 'Error',
type: 'error',
duration: 1.5 * 1000
})
return Promise.reject(res)
}
// A00002 方法参数没有校验,内容由输入内容决定
if (res.code === 'A00002') {
if (res.data && res.data.length) {
res.data.forEach(errorMsg => {
Message({
message: errorMsg || 'Error',
type: 'error',
duration: 1.5 * 1000
})
})
} else {
Message({
message: res.msg || 'Error',
type: 'error',
duration: 1.5 * 1000
})
}
return Promise.reject()
}
// A00004 未授权
if (res.code === 'A00004') {
// to re-login
MessageBox.confirm('您已注销,您可以取消停留在该页上,或重新登录', '确认注销', {
confirmButtonText: '重新登陆',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
store.dispatch('user/resetToken').then(() => {
location.reload()
})
})
return Promise.reject()
}
return Promise.reject(res)
},
error => {
console.log('err' + error) // for debug
Message({
message: error.message,
type: 'error',
duration: 1.5 * 1000
})
return Promise.reject(error)
}
)
```
这里将会统一拦截返回的状态码如`A00001`,进行错误提示。
## 统一验证
本商城使用spring提供的统一校验工具`spring-boot-starter-validation`对请求进行校验
### 导入依赖
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
```
这里通过注解封装了几种常用的校验
- `@NotNull` 不能为null
- `@NotEmpty` 不能为null、空字符串、空集合
- `@NotBlank` 不能为null、空字符串、纯空格的字符串
- `@Min` 数字最小值不能小于x
- `@Max` 数字最大值不能大于x
- `@Email` 字符串为邮件格式
- `@Max` 数字最大值不能大于x
- `@Size` 字符串长度最小为x、集合长度最小为x
- `@Pattern` 正则表达式
我们以`SpuDTO`为例,看看怎么使用
```java
public class SpuDTO{
private static final long serialVersionUID = 1L;
@Schema(description = "spuId" )
private Long spuId;
@Schema(description = "品牌ID" )
private Long brandId;
@NotNull(message = "分类不能为空")
@Schema(description = "分类ID" )
private Long categoryId;
@NotNull(message = "店铺分类不能为空")
@Schema(description = "店铺分类ID" )
private Long shopCategoryId;
@NotNull(message = "商品名称不能为空")
@Schema(description = "spu名称" )
private String name;
/** 省略其余字段以及get、set、tostring方法*/
}
```
我们在Controller层使用该bean,并使用`@Valid`注解,使校验的注解生效,如`SpuController`
```java
@RestController("platformSpuController")
@RequestMapping("/admin/spu")
@Tag(name = "admin-spu信息")
public class SpuController {
@Autowired
private SpuService spuService;
@PostMapping
@Operation(summary = "保存spu信息" , description = "保存spu信息")
public ServerResponseEntity<Void> save(@Valid @RequestBody SpuDTO spuDTO) {
checkSaveOrUpdateInfo(spuDTO);
spuService.save(spuDTO);
return ServerResponseEntity.success();
}
}
```
并且在`DefaultExceptionHandlerConfig` 拦截由`@Valid` 触发的异常信息并返回:
```java
@RestController
@RestControllerAdvice
public class DefaultExceptionHandlerConfig {
@ExceptionHandler({ MethodArgumentNotValidException.class, BindException.class })
public ResponseEntity<ServerResponseEntity<List<String>>> methodArgumentNotValidExceptionHandler(Exception e) {
logger.error("methodArgumentNotValidExceptionHandler", e);
List<FieldError> fieldErrors = null;
if (e instanceof MethodArgumentNotValidException) {
fieldErrors = ((MethodArgumentNotValidException) e).getBindingResult().getFieldErrors();
}
if (e instanceof BindException) {
fieldErrors = ((BindException) e).getBindingResult().getFieldErrors();
}
if (fieldErrors == null) {
return ResponseEntity.status(HttpStatus.OK)
.body(ServerResponseEntity.fail(ResponseEnum.METHOD_ARGUMENT_NOT_VALID));
}
List<String> defaultMessages = new ArrayList<>(fieldErrors.size());
for (FieldError fieldError : fieldErrors) {
defaultMessages.add(fieldError.getField() + ":" + fieldError.getDefaultMessage());
}
return ResponseEntity.status(HttpStatus.OK)
.body(ServerResponseEntity.fail(ResponseEnum.METHOD_ARGUMENT_NOT_VALID, defaultMessages));
}
@ExceptionHandler({ HttpMessageNotReadableException.class })
public ResponseEntity<ServerResponseEntity<List<FieldError>>> methodArgumentNotValidExceptionHandler(
HttpMessageNotReadableException e) {
logger.error("methodArgumentNotValidExceptionHandler", e);
return ResponseEntity.status(HttpStatus.OK)
.body(ServerResponseEntity.fail(ResponseEnum.HTTP_MESSAGE_NOT_READABLE));
}
}
```
## 表格分页
本商城前端采用Element数据分页组件`Pagination`分页,后端采用`Mybatis``PageHelper`分页插件。
### 前端分页
前端采用`Pagination`分页,具体文档参考[Element UI][https://element.eleme.cn/#/zh-CN/component/pagination]
本商城中,组件定义位置为:`src/components/Pagination/index.vue`
```html
<el-pagination
:background="background"
:current-page.sync="currentPage"
:page-size.sync="pageSize"
:layout="layout"
:page-sizes="pageSizes"
:total="total"
v-bind="$attrs"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
```
该组件中定义了两个事件,其中`@size-change`为组件页数改变时会触发,而` @current-change`事件中,当前页数改变时即会触发该事件。
其他页面需要用到分页时,通过import导入该组件,并通过设置相关参数来使用,以下代码参考`src/views/order/order/index.vue`
```html
<pagination
v-show="pageVO.total > 0"
:total="pageVO.total"
:page.sync="pageQuery.pageNum"
:limit.sync="pageQuery.pageSize"
@pagination="getDataList()"
/>
```
组件在被引用时,页面可以调用他的同名参数 `@pagination`
```js
getPage() {
this.pageLoading = true
api.page({ ...this.pageQuery, ...this.searchParam }).then(pageVO => {
this.pageVO = pageVO
this.pageLoading = false
})
}
```
### 后台分页
后端采用`Mybatis``PageHelper`分页插件,由`PageHelper-Spring-Boot-Starter`集成分页插件到Spring Boot来完成表格分页。
```
使用pagehelper进行分页,该分页只能一对一。
```
#### 导入依赖
```xml
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.4</version>
</dependency>
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.3.0</version>
</dependency>
```
#### 建立分页工具类
```java
public class PageUtil {
/**
* 使用pagehelper进行分页,该分页只能一对一
*/
public static <T> PageVO<T> doPage(PageDTO pageDTO, ISelect select) {
PageSerializable<T> simplePageInfo = PageHelper.startPage(pageDTO).doSelectPageSerializable(select);
PageVO<T> pageVO = new PageVO<>();
pageVO.setList(simplePageInfo.getList());
pageVO.setTotal(simplePageInfo.getTotal());
pageVO.setPages(getPages(simplePageInfo.getTotal(), pageDTO.getPageSize()));
return pageVO;
}
public static Integer getPages(long total, Integer pageSize) {
if (total == -1) {
return 1;
}
if (pageSize > 0) {
return (int) (total / pageSize + ((total % pageSize == 0) ? 0 : 1));
}
return 0;
}
}
```
#### 搜索
服务端`ShopUserController`
```java
@RequestMapping(value = "/m/shop_user")
@RestController("multishopShopUserController")
@Tag(name = "店铺用户信息")
public class ShopUserController {
@Autowired
private ShopUserService shopUserService;
@GetMapping("/page")
@Operation(summary = "店铺用户列表" , description = "获取店铺用户列表")
public ServerResponseEntity<PageVO<ShopUserVO>> page(@Valid PageDTO pageDTO, String nickName) {
UserInfoInTokenBO userInfoInTokenBO = AuthUserContext.get();
PageVO<ShopUserVO> shopUserPage = shopUserService.pageByShopId(pageDTO, userInfoInTokenBO.getTenantId(), nickName);
return ServerResponseEntity.success(shopUserPage);
}
}
```
```java
@Service
public class ShopUserServiceImpl implements ShopUserService {
@Resource
private ShopUserMapper shopUserMapper;
@Override
public PageVO<ShopUserVO> pageByShopId(PageDTO pageDTO, Long shopId, String nickName) {
return PageUtil.doPage(pageDTO, () -> shopUserMapper.listByShopId(shopId, nickName));
}
}
```
可见,传入的参数为`pageDTO`,该对象是根据**POJO**,即“Plain Old Java Object”->“简单java对象”而得。
> POJO的意义就在于它的简单而灵活性,因为它的简单和灵活,使得POJO能够任意扩展,从而胜任多个场合,也就让一个模型贯穿多个层成为现实。
| 名称 | 含义 | 说明 |
| :-----------------------: | :----------------------------------------: | :----------------------------------------------------------: |
| PO(Persistant Object) | 代表持久层对象的意思,对应数据库中表的字段 | 一个PO就是数据库中的一条记录 |
| BO(Business Object) | 把业务逻辑封装成一个对象 | 教育经历是一个PO,技术能力是一个PO,工作经历是一个PO,建立一个HR对象,也即BO去处理简历,每个BO均包含这些PO |
| VO(View Object) | 表现层对象 | 后台返回给前端的对象 |
| DTO(Data Transfer Object) | 数据传输对象 | 前端传给后台的对象 |
`pageDTO`如下
```java
public class PageDTO implements IPage {
/** ...省略*/
/**
* 最大分页大小,如果分页大小大于500,则用500作为分页的大小。防止有人直接传入一个较大的数,导致服务器内存溢出宕机
*/
public static final Integer MAX_PAGE_SIZE = 500;
/**
* 当前页
*/
@NotNull(message = "pageNum 不能为空")
@Schema(description = "当前页" , requiredMode = Schema.RequiredMode.REQUIRED)
private Integer pageNum;
@NotNull(message = "pageSize 不能为空")
@Schema(description = "每页大小" , requiredMode = Schema.RequiredMode.REQUIRED)
private Integer pageSize;
@Schema(description = "排序字段数组,用逗号分割" )
private String[] columns;
@Schema(description = "排序字段方式,用逗号分割,ASC正序,DESC倒序" )
private String[] orders;
/** ...省略*/
}
```
返回给前端的参数`PageVO`如下:
```java
public class PageVO<T> {
@Schema(description = "总页数" )
private Integer pages;
@Schema(description = "总条目数" )
private Long total;
@Schema(description = "结果集" )
private List<T> list;
/** ...省略*/
}
```
调用`PageUtil.doPage(pageDTO, () -> shopUserMapper.listByShopId(shopId, nickName))`方法,对返回的列表进行分页。
## 服务启动时常见的nacos问题
错误一:
![img.png](../img/常见问题及处理/nacos-01.png)
排查步骤:
1. naocs是否启动成功
2. nacos地址是否正确
3. nacos的8848、9848端口是否开放
## 服务启动时常见的seata问题
错误一:
![img.png](../img/常见问题及处理/seata-01.png)
错误2:
![img.png](../img/常见问题及处理/seata-03.png)
排查步骤:
1. 检查nacos是否成功启动
2. 检查seata服务器端的配置文件是否正确(如果只是替换ip,没有其他改动,可以忽略这一步),例如:nacos地址、账号、密码、命名空间(namespace)
3. 检查nacos中的seata配置文件`seataServer.properties`是否存在, 如果存在编辑页面中重新点下保存按钮,或者重启nacos
4. 检查nacos中的seata配置文件`seataServer.properties`中的数据库配置
5. seata能在nacos中注册,并不代表上面配置都是正确的,如果遇到seata注册成功,但java服务报错时,可以尝试重启nacos和确认下seata上诉的配置是否正确
### 步骤一、检查nacos是否成功启动
服务器查看容器日志 `docker logs -f mall4cloud-seata`
###### seata的配置文件放置在 `中间件docker-compse一键安装\seata` 文件下
![img.png](../img/常见问题及处理/seata-02.png)
## 一、创建更新商品后,列表数据错误
- 常见问题一、成功发布商品后商品列表没有看到新发布的商品
- 常见问题二、成功修改了商品的信息,但列表中显示的数据还是旧的
### 以下为问题排查方案
#### 1.canal没有读取到mysql的binlog
mysql查询binglog位置
```mysql
SHOW MASTER STATUS
```
编辑`./canal/conf/example/instance.properties`
修改以下四个参数
```properties
# 填写数据库地址
canal.instance.master.address=192.168.1.46:3306
# 填写mysql执行命令`SHOW MASTER STATUS`后的File内容
canal.instance.master.journal.name=mysql-binlog.000001
# username/password
# 填写数据库账号
canal.instance.dbUsername=canal
# 填写数据库密码
canal.instance.dbPassword=canal
```
#### 2.canal没有连接上RocketMQ
根据 `./canal/logs/example` 中的 `example.log` 判断无法连接mq
编辑`./canal/conf/canal.properties`
```properties
# 填写RocketMQ地址
rocketmq.namesrv.addr = 192.168.1.46:9876
```
重启`canal`
```shell
docker restart mall4cloud-canal
```
重启`canal`
```shell
docker restart mall4cloud-canal
```
#### 3. canal其他错误
大部分错误可以根据 `./canal/logs/example``example.log` 的报错信息查找到解决方案
但修改配置后要记得重启对应的容器,使配置生效
#### 4. search服务消费mq
canal的日志文件 `./canal/logs/example/example.log` 不再报错,但es中的商品数据还是没有更新
es商品数据保存流程:mall4cloud-product.spu表中的数据发生变动,canal监听到变动,发送mq,`mall4cloud-search`服务消费mq, 并更新es中的商品数据
排查步骤:es中的商品数据没有更新,可以先检查`mall4cloud-search`服务是否正常运行,如果正常运行,再看日志中mq的消费情况
- 如果没有mq消费的日志,可以去mq控制台看看是没有消费,还是canal没有发送mq
- canal有mq,但是服务没有消费,这种情况是mq名称不一致,如果没有更改代码,可以忽略此问题
- 还有就是mq消费失败了,这种情况大部分是es中没有创建索引或者索引结构异常导致
## 订单接口-1
用户下单时调用的订单接口有两个:
1、确认订单[生成订单信息]
2、提交订单[此时默认支付成功]
### 1.确认订单
确认订单有以下几个步骤:
1、判断是购物车还是直接购买
2、组装购物项信息
3、根据店铺计算金额
4、计算总额
---
用户通过点击"**立即购买**"或进入购物车选择"**结算**"进入到"**确认订单**"页面,具体接口:"`/a/order/confirm`"
由于有两种方式进入到确认订单界面,但本商城设计为一个接口处理,统一下单:
```java
public class OrderDTO {
@Schema(description = "立即购买时提交的商品项,如果该值为空,则说明是从购物车进入,如果该值不为空则说明为立即购买" )
private ShopCartItemDTO shopCartItem;
@NotNull(message = "配送类型不能为空")
@Schema(description = "配送类型3:无需快递" )
private Integer dvyType;
}
```
通过购物车适配器来**获取购物项组装信息**
```java
public ServerResponseEntity<List<ShopCartItemVO>> getShopCartItems(ShopCartItemDTO shopCartItemParam) {
ServerResponseEntity<List<ShopCartItemVO>> shopCartItemResponse;
// 当立即购买时,没有提交的订单是没有购物车信息的
if (shopCartItemParam != null) {
shopCartItemResponse = conversionShopCartItem(shopCartItemParam);
}
// 从购物车提交订单
else {
shopCartItemResponse = shopCartFeignClient.getCheckedShopCartItems();
}
if (!shopCartItemResponse.isSuccess()) {
return ServerResponseEntity.transform(shopCartItemResponse);
}
// 请选择您需要的商品加入购物车
if (CollectionUtil.isEmpty(shopCartItemResponse.getData())) {
return ServerResponseEntity.fail(ResponseEnum.SHOP_CART_NOT_EXIST);
}
// 返回购物车选择的商品信息
return shopCartItemResponse;
}
```
当购物项`shopCartItem`为空时,则说明是从购物车进入,此时调用获取**用户的购物车信息**的方法`shopCartFeignClient.getCheckedShopCartItems()`
```java
public ServerResponseEntity<List<ShopCartItemVO>> getCheckedShopCartItems() {
//该方法从数据库查询购物车的商品
List<ShopCartItemVO> checkedShopCartItems = shopCartService.getCheckedShopCartItems();
if (CollectionUtil.isNotEmpty(checkedShopCartItems)) {
for (ShopCartItemVO shopCartItem : checkedShopCartItems) {
shopCartItem.setTotalAmount(shopCartItem.getCount() * shopCartItem.getSkuPriceFee());
}
}
return ServerResponseEntity.success(checkedShopCartItems);
}
```
而若`shopCartItem`不为空,则是直接购买进入该页面,调用`shopCartAdapter.conversionShopCartItem()`方法,组装购物信息:
```java
public ServerResponseEntity<List<ShopCartItemVO>> conversionShopCartItem(ShopCartItemDTO shopCartItemParam){
ServerResponseEntity<SpuAndSkuVO> spuAndSkuResponse = spuFeignClient.getSpuAndSkuById(shopCartItemParam.getSpuId(),shopCartItemParam.getSkuId());
if (!spuAndSkuResponse.isSuccess()) {
return ServerResponseEntity.transform(spuAndSkuResponse);
}
SkuVO sku = spuAndSkuResponse.getData().getSku();
SpuVO spu = spuAndSkuResponse.getData().getSpu();
// 拿到购物车的所有item
ShopCartItemVO shopCartItem = new ShopCartItemVO();
shopCartItem.setCartItemId(0L);
shopCartItem.setSkuId(shopCartItemParam.getSkuId());
shopCartItem.setCount(shopCartItemParam.getCount());
shopCartItem.setSpuId(shopCartItemParam.getSpuId());
shopCartItem.setSkuName(sku.getSkuName());
shopCartItem.setSpuName(spu.getName());
shopCartItem.setImgUrl(BooleanUtil.isTrue(spu.getHasSkuImg()) ? sku.getImgUrl() : spu.getMainImgUrl());
shopCartItem.setSkuPriceFee(sku.getPriceFee());
shopCartItem.setTotalAmount(shopCartItem.getCount() * shopCartItem.getSkuPriceFee());
shopCartItem.setCreateTime(new Date());
shopCartItem.setShopId(shopCartItemParam.getShopId());
return ServerResponseEntity.success(Collections.singletonList(shopCartItem));
}
```
两个路径的购物信息统一组装完毕后,不能在这一步删除购物车信息,万一用户点击返回后,发现购物车的商品虽然还没提交,但是已经消失,会造成信息差。
下一步,根据不同店铺来划分购物项,调用`shopCartAdapter.conversionShopCart()`
```java
public List<ShopCartVO> conversionShopCart(List<ShopCartItemVO> shopCartItems){
// 根据店铺ID划分item
Map<Long, List<ShopCartItemVO>> shopCartMap = shopCartItems.stream().collect(Collectors.groupingBy(ShopCartItemVO::getShopId));
// 返回一个店铺的所有信息
List<ShopCartVO> shopCarts = Lists.newArrayList();
for (Long shopId : shopCartMap.keySet()) {
// 构建每个店铺的购物车信息
ShopCartVO shopCart = buildShopCart(shopId,shopCartMap.get(shopId));
shopCart.setShopId(shopId);
shopCart.setShopCartItemVOS(shopCartMap.get(shopId));
// 店铺信息
ServerResponseEntity<String> shopNameResponse = shopDetailFeignClient.getShopNameByShopId(shopId);
if (!shopNameResponse.isSuccess()) {
throw new mall4cloudException(shopNameResponse.getMsg());
}
shopCart.setShopName(shopNameResponse.getData());
shopCarts.add(shopCart);
}
return shopCarts;
}
```
`buildShopCart(shopId,shopCartMap.get(shopId))`的时候,根据每个店铺来划分商品,再计算每个店铺商品的全部金额:
```java
private ShopCartVO buildShopCart(Long shopId, List<ShopCartItemVO> shopCartItems) {
ShopCartVO shopCart = new ShopCartVO();
shopCart.setShopId(shopId);
long total = 0L;
int totalCount = 0;
for (ShopCartItemVO shopCartItem : shopCartItems) {
total += shopCartItem.getTotalAmount();
totalCount += shopCartItem.getCount();
}
shopCart.setTotal(total);
shopCart.setTotalCount(totalCount);
return shopCart;
}
```
此时根据店铺计算完金额后,在确认订单的时候,会重新计算一次总金额,返回给前端
```java
private void recalculateAmountWhenFinishingCalculateShop(ShopCartOrderMergerVO shopCartOrderMerger, List<ShopCartVO> shopCarts) {
// 所有店铺的订单信息
List<ShopCartOrderVO> shopCartOrders = new ArrayList<>();
long total = 0;
int totalCount = 0;
// 所有店铺所有的商品item
for (ShopCartVO shopCart : shopCarts) {
// 每个店铺的订单信息
ShopCartOrderVO shopCartOrder = new ShopCartOrderVO();
shopCartOrder.setShopId(shopCart.getShopId());
shopCartOrder.setShopName(shopCart.getShopName());
total += shopCart.getTotal();
totalCount += shopCart.getTotalCount();
shopCartOrder.setTotal(shopCart.getTotal());
shopCartOrder.setTotalCount(shopCart.getTotalCount());
shopCartOrder.setShopCartItemVO(shopCart.getShopCartItemVOS());
shopCartOrders.add(shopCartOrder);
}
shopCartOrderMerger.setTotal(total);
shopCartOrderMerger.setTotalCount(totalCount);
shopCartOrderMerger.setShopCartOrders(shopCartOrders);
}
```
最后,使用工具类对重复提交的订单做判断,并且将结果存入缓存中,提交订单时可取出使用:
```java
// 防止重复提交
RedisUtil.STRING_REDIS_TEMPLATE.opsForValue().set(OrderCacheNames.ORDER_CONFIRM_UUID_KEY + CacheNames.UNION + userId, String.valueOf(userId));
// 保存订单计算结果缓存,省得重新计算并且用户确认的订单金额与提交的一致
cacheManagerUtil.putCache(OrderCacheNames.ORDER_CONFIRM_KEY,String.valueOf(userId),shopCartOrderMerger);
```
提交订单的接口设计请看下节:**订单接口-2**
## 订单接口-2
上节说到确认订单,这节讨论**提交订单**的接口设计。
调用接口为:`/a/order/submit`
```java
@PostMapping("/submit")
@Operation(summary = "提交订单,返回支付流水号" , description = "根据传入的参数判断是否为购物车提交订单,同时对购物车进行删除,用户开始进行支付")
public ServerResponseEntity<List<Long>> submitOrders() {
Long userId = AuthUserContext.get().getUserId();
//确认订单时将shopCartOrderMerger放入缓存中,提交时使用userId将其从缓存中取出
ShopCartOrderMergerVO mergerOrder = cacheManagerUtil.getCache(OrderCacheNames.ORDER_CONFIRM_KEY,String.valueOf(userId));
// 看看订单有没有过期
if (mergerOrder == null) {
return ServerResponseEntity.fail(ResponseEnum.ORDER_EXPIRED);
}
// 与确认订单相同,使用RedisUtil.cad来检测原子性,判断是否重复提交
boolean cad = RedisUtil.cad(OrderCacheNames.ORDER_CONFIRM_UUID_KEY + CacheNames.UNION + userId, String.valueOf(userId));
if (!cad) {
return ServerResponseEntity.fail(ResponseEnum.REPEAT_ORDER);
}
List<Long> orderIds = orderService.submit(userId,mergerOrder);
return ServerResponseEntity.success(orderIds);
}
```
提交订单时,将订单信息存入缓存,用户在提交订单的时候将会判断缓存内订单过期没有过期且非重复提交后,调用`orderService.submit(userId,mergerOrder)`方法来提交订单。
```java
@Override
@Transactional(rollbackFor = Exception.class)
public List<Long> submit(Long userId, ShopCartOrderMergerVO mergerOrder) {
List<Order> orders = saveOrder(userId, mergerOrder);
// 省略部分见下文
}
```
首先将订单存入数据库,调用`saveOrder`方法:
```java
public List<Order> saveOrder(Long userId, ShopCartOrderMergerVO mergerOrder) {
OrderAddr orderAddr = BeanUtil.map(mergerOrder.getUserAddr(), OrderAddr.class);
// 地址信息
if (Objects.isNull(orderAddr)) {
// 请填写收货地址
throw new mall4cloudException("请填写收货地址");
}
// 保存收货地址
orderAddrService.save(orderAddr);
// 订单商品参数
List<ShopCartOrderVO> shopCartOrders = mergerOrder.getShopCartOrders();
List<Order> orders = new ArrayList<>();
List<OrderItem> orderItems = new ArrayList<>();
List<Long> shopCartItemIds = new ArrayList<>();
if(CollectionUtil.isNotEmpty(shopCartOrders)) {
// 每个店铺生成一个订单
for (ShopCartOrderVO shopCartOrderDto : shopCartOrders) {
Order order = getOrder(userId, mergerOrder.getDvyType(), shopCartOrderDto);
for (ShopCartItemVO shopCartItemVO : shopCartOrderDto.getShopCartItemVO()) {
OrderItem orderItem = getOrderItem(order, shopCartItemVO);
orderItems.add(orderItem);
shopCartItemIds.add(shopCartItemVO.getCartItemId());
}
order.setOrderItems(orderItems);
order.setOrderAddrId(orderAddr.getOrderAddrId());
orders.add(order);
}
}
orderMapper.saveBatch(orders);
orderItemService.saveBatch(orderItems);
// 清空购物车
shopCartFeignClient.deleteItem(shopCartItemIds);
return orders;
}
```
每个店铺生成订单的时候,使用了`getOrder`方法,写入订单信息,将其状态设置为"**未付款**"。
```java
private Order getOrder(Long userId, Integer dvyType, ShopCartOrderVO shopCartOrderDto) {
ServerResponseEntity<Long> segmentIdResponse = segmentFeignClient.getSegmentId(Order.DISTRIBUTED_ID_KEY);
if (!segmentIdResponse.isSuccess()) {
throw new mall4cloudException("获取订单id失败");
}
// 订单信息
Order order = new Order();
order.setOrderId(segmentIdResponse.getData());
order.setShopId(shopCartOrderDto.getShopId());
order.setShopName(shopCartOrderDto.getShopName());
// 用户id
order.setUserId(userId);
// 商品总额
order.setTotal(shopCartOrderDto.getTotal());
order.setStatus(OrderStatus.UNPAY.value());
order.setIsPayed(0);
order.setDeleteStatus(0);
order.setAllCount(shopCartOrderDto.getTotalCount());
order.setDeliveryType(DeliveryType.NOT_DELIVERY.value());
return order;
}
```
完成后回到`orderService.submit(userId,mergerOrder)`方法
```java
List<SkuStockLockDTO> skuStockLocks = new ArrayList<>();
for (Order order : orders) {
orderIds.add(order.getOrderId());
List<OrderItem> orderItems = order.getOrderItems();
for (OrderItem orderItem : orderItems) {
skuStockLocks.add(new SkuStockLockDTO(orderItem.getSpuId(), orderItem.getSkuId(), orderItem.getOrderId(), orderItem.getCount()));
}
}
// 锁定库存
ServerResponseEntity<Void> lockStockResponse = skuStockLockFeignClient.lock(skuStockLocks);
// 锁定不成,抛异常,让回滚订单
if (!lockStockResponse.isSuccess()) {
throw new mall4cloudException(lockStockResponse.getMsg());
}
```
提交订单的时候会把该订单的库存锁上`skuStockLockFeignClient.lock(skuStockLocks)`,多加一个库存锁定表,先减完spu和sku的库存后,再将锁库存的信息存入表中`sku_stock_lock`,倘若此时订单取消或者超过30分钟未支付,则通过`StockUnlockConsumer`监听来解锁库存。
```java
@Override
@Transactional(rollbackFor = Exception.class)
public ServerResponseEntity<Void> lock(List<SkuStockLockDTO> skuStockLocksParam) {
List<SkuStockLock> skuStockLocks = new ArrayList<>();
for (SkuStockLockDTO skuStockLockDTO : skuStockLocksParam) {
//略...
// 减sku库存
int skuStockUpdateIsSuccess = skuStockMapper.reduceStockByOrder(skuStockLockDTO.getSkuId(), skuStockLockDTO.getCount());
if (skuStockUpdateIsSuccess < 1) {
throw new mall4cloudException(ResponseEnum.NOT_STOCK, skuStockLockDTO.getSkuId());
}
// 减商品库存
int spuStockUpdateIsSuccess = spuExtensionMapper.reduceStockByOrder(skuStockLockDTO.getSpuId(), skuStockLockDTO.getCount());
if (spuStockUpdateIsSuccess < 1) {
throw new mall4cloudException(ResponseEnum.NOT_STOCK, skuStockLockDTO.getSkuId());
}
}
// 保存库存锁定信息
skuStockLockMapper.saveBatch(skuStockLocks);
List<Long> orderIds = skuStockLocksParam.stream().map(SkuStockLockDTO::getOrderId).collect(Collectors.toList());
// 一个小时后解锁库存
SendStatus sendStatus = stockMqTemplate.syncSend(RocketMqConstant.STOCK_UNLOCK_TOPIC, new GenericMessage<>(orderIds), RocketMqConstant.TIMEOUT, RocketMqConstant.CANCEL_ORDER_DELAY_LEVEL + 1).getSendStatus();
if (!Objects.equals(sendStatus,SendStatus.SEND_OK)) {
// 消息发不出去就抛异常,发的出去无所谓
throw new mall4cloudException(ResponseEnum.EXCEPTION);
}
return ServerResponseEntity.success();
}
```
`stockMqTemplate.syncSend(...).getSendStatus();`此处发送库存解锁的消息,使用的`RocketMqConstant.CANCEL_ORDER_DELAY_LEVEL`的参数
参考`RocketMqConstant`,16+1即第十七个,为一小时。
```java
public class RocketMqConstant {
// 延迟消息 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h (1-18)
/**
* 取消订单时间,实际上30分钟
*/
public static final int CANCEL_ORDER_DELAY_LEVEL = 16;
}
```
锁定库存之后,发送消息,假如30分钟后尚未支付,则取消订单
```java
SendStatus sendStatus = orderCancelTemplate.syncSend(RocketMqConstant.ORDER_CANCEL_TOPIC, new GenericMessage<>(orderIds), RocketMqConstant.TIMEOUT, RocketMqConstant.CANCEL_ORDER_DELAY_LEVEL).getSendStatus();
if (!Objects.equals(sendStatus,SendStatus.SEND_OK)) {
// 消息发不出去就抛异常,发的出去无所谓
throw new mall4cloudException(ResponseEnum.EXCEPTION);
}
```
最后返回的是订单ID列表,方便下一步进行支付
```java
return ServerResponseEntity.success(orderIds);
```
订单支付的接口设计请看下节:**订单接口-3**
## 订单接口-3
确认订单-提交订单之后,就是支付了。
用户点击提交订单之后,选择确定付款,此时进入支付界面
前端调用接口为`/mall4cloud_payment/pay/order`,根据订单号进行支付,上一步提交订单返回的便是订单id列表
接口详情:
```java
@PostMapping("/order")
@Operation(summary = "根据订单号进行支付" , description = "根据订单号进行支付")
public ServerResponseEntity<?> pay(HttpServletRequest request, @Valid @RequestBody PayInfoDTO payParam) {
// 这里的地址是网关通过转发过来的时候,获取到当前服务器的地址,测试环境要用测试环境的uri
String gatewayUri = "http://192.168.1.17:8126/mall4cloud_payment";
UserInfoInTokenBO userInfoInTokenBO = AuthUserContext.get();
Long userId = userInfoInTokenBO.getUserId();
PayInfoBO payInfo = payInfoService.pay(userId, payParam);
payInfo.setBizUserId(userInfoInTokenBO.getBizUserId());
...
}
```
这里关键的是调用了`payInfoService.pay(userId, payParam)`
该方法由于涉及多个表库的增改,故此加了事务注解来保证一致性。
```java
@Override
@Transactional(rollbackFor = Exception.class)
public PayInfoBO pay(Long userId, PayInfoDTO payParam) {
// 支付单号
ServerResponseEntity<Long> segmentIdResponse = segmentFeignClient.getSegmentId(PayInfo.DISTRIBUTED_ID_KEY);
if (!segmentIdResponse.isSuccess()) {
throw new mall4cloudException(ResponseEnum.EXCEPTION);
}
Long payId = segmentIdResponse.getData();
List<Long> orderIds = payParam.getOrderIds();
// 如果订单没有被取消的话,获取订单金额,否之会获取失败
ServerResponseEntity<OrderAmountVO> ordersAmountAndIfNoCancelResponse = orderFeignClient.getOrdersAmountAndIfNoCancel(orderIds);
// 如果订单已经关闭了,此时不能够支付了
if (!ordersAmountAndIfNoCancelResponse.isSuccess()) {
throw new mall4cloudException(ordersAmountAndIfNoCancelResponse.getMsg());
}
//将数据存到数据库中
OrderAmountVO orderAmount = ordersAmountAndIfNoCancelResponse.getData();
PayInfo payInfo = new PayInfo();
payInfo.setPayId(payId);
payInfo.setUserId(userId);
//支付的金额是从数据库查询的,并非前端传过来的值
payInfo.setPayAmount(orderAmount.getPayAmount());
payInfo.setPayStatus(PayStatus.UNPAY.value());
payInfo.setSysType(AuthUserContext.get().getSysType());
payInfo.setVersion(0);
// 保存多个支付订单号
payInfo.setOrderIds(StrUtil.join(StrUtil.COMMA, orderIds));
// 保存预支付信息
payInfoMapper.save(payInfo);
PayInfoBO payInfoDto = new PayInfoBO();
payInfoDto.setBody("商城订单");
payInfoDto.setPayAmount(orderAmount.getPayAmount());
payInfoDto.setPayId(payId);
//返回支付信息
return payInfoDto;
}
```
`controller`方法剩下的便是支付回调了,前面的`gatewayUri`便有用了
```java
// 回调地址
payInfo.setApiNoticeUrl(gatewayUri + "/notice/pay/order");
payInfo.setReturnUrl(payParam.getReturnUrl());
payNoticeController.submit(payInfo.getPayId());
return ServerResponseEntity.success(payInfo.getPayId());
```
执行完`pay`方法后,由于没有对接微信和支付宝接口,故直接调用submit中`payInfoService.paySuccess(payInfoResult,orderIdList);`使其支付成功
```java
@Override
@Transactional(rollbackFor = Exception.class)
public void paySuccess(PayInfoResultBO payInfoResult, List<Long> orderIds) {
// 标记为支付成功状态
PayInfo payInfo = new PayInfo();
payInfo.setPayId(payInfoResult.getPayId());
payInfo.setBizPayNo(payInfoResult.getBizPayNo());
payInfo.setCallbackContent(payInfoResult.getCallbackContent());
payInfo.setCallbackTime(new Date());
payInfo.setPayStatus(PayStatus.PAYED.value());
payInfoMapper.update(payInfo);
// 发送消息,订单支付成功
SendStatus sendStatus = orderNotifyTemplate.syncSend(RocketMqConstant.ORDER_NOTIFY_TOPIC, new GenericMessage<>(new PayNotifyBO(orderIds))).getSendStatus();
if (!Objects.equals(sendStatus, SendStatus.SEND_OK)) {
// 消息发不出去就抛异常,因为订单回调会有多次,几乎不可能每次都无法发送出去,发的出去无所谓因为接口是幂等的
throw new mall4cloudException(ResponseEnum.EXCEPTION);
}
}
```
使用`rocketMq`来发送支付成功的消息,`OrderNotifyConsumer`进行订单回调,此时将订单改为已支付状态,并且发送消息通知库存可以扣减了
```java
@Override
public void onMessage(PayNotifyBO message) {
LOG.info("订单回调开始... message: " + Json.toJsonString(message));
orderService.updateByToPaySuccess(message.getOrderIds());
// 发送消息,订单支付成功,通知库存扣减
SendStatus sendStockStatus = orderNotifyStockTemplate.syncSend(RocketMqConstant.ORDER_NOTIFY_STOCK_TOPIC, new GenericMessage<>(message)).getSendStatus();
if (!Objects.equals(sendStockStatus,SendStatus.SEND_OK)) {
throw new mall4cloudException(ResponseEnum.EXCEPTION);
}
}
```
将支付单号返回给前端即可。
## 订单表结构
### 1 数据库结构
![image-20210623140003690](../img/表设计/订单表结构.png)
一个订单表(order)中每一项对应多个`order_item`,通过`order_id`关联
- 一个order记录每个订单的状态、时间、总金额等
- 一个order有多个order_item,每个订单项记录每件商品的信息
#### 1.1 Order-订单
每一次下单即生成一条订单记录,其中有多件或一件商品。
```java
/**
* 订单ID
*/
private Long orderId;
/**
* 用户ID
*/
private Long userId;
/**
* 店铺id
*/
private Long shopId;
/**
* 店铺名称
*/
private String shopName;
/**
* 订单状态 1:待付款 2:待发货 3:待收货(已发货) 5:成功 6:失败
*/
private Integer status;
/**
* 订单关闭原因 1-超时未支付 4-买家取消
*/
private Integer closeType;
/**
* 总值
*/
private Long total;
/**
* 配送类型 :无需快递
*/
private Integer deliveryType;
/**
* 订单商品总数
*/
private Integer allCount;
/**
* 付款时间
*/
private Date payTime;
/**
* 发货时间
*/
private Date deliveryTime;
/**
* 完成时间
*/
private Date finallyTime;
/**
* 取消时间
*/
private Date cancelTime;
/**
* 是否已支付,1.已支付0.未支付
*/
private Integer isPayed;
/**
* 用户订单删除状态,0:没有删除, 1:回收站, 2:永久删除
*/
private Integer deleteStatus;
```
每个订单关联一个用户(`user_id`),一家店铺(`shop_id`)
- `status`订单状态一共有六种:1:待付款 2:待发货 3:待收货(已发货) 5:成功 6:失败
**状态转换如下:**
![image-20210623145605185](../img/表设计/订单状态转换.png)
- `closeType`订单关闭原因有两种:1:超时未支付 2:买家取消
- `total`总值,指一个订单内所有商品的总金额,即这个订单关联的订单项中`spuTotalAmount`的总和
- `deliveryType`配送类型,目前只有无需快递这一类型
- `allCount`订单商品总数,即一个订单中包含的商品总数
#### 1.2 order_item-订单项
每个订单根据order_id关联订单项表,一个订单可以有多个订单项
```java
/**
* 订单项ID
*/
private Long orderItemId;
/**
* 店铺id
*/
private Long shopId;
/**
* 订单id
*/
private Long orderId;
/**
* 产品ID
*/
private Long spuId;
/**
* 产品SkuID
*/
private Long skuId;
/**
* 用户Id
*/
private Long userId;
/**
* 购物车产品个数
*/
private Integer count;
/**
* 产品名称
*/
private String spuName;
/**
* sku名称
*/
private String skuName;
/**
* 产品主图片路径
*/
private String pic;
/**
* 单个orderItem的配送类型 无需快递
*/
private Integer deliveryType;
/**
* 加入购物车时间
*/
private Date shopCartTime;
/**
* 产品价格
*/
private Long price;
/**
* 商品总金额
*/
private Long spuTotalAmount;
```
- `count`购物车产品个数:添加到购物车的数量
- `spuTotalAmount`商品总金额:为产品价格`price`与购物车产品个数`count`的乘积
### 2 界面
![image-20210705151729559](../img/表设计/订单界面.png)
\ No newline at end of file
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment