# SAP商城系统
**Repository Path**: mlming/sapmall
## Basic Information
- **Project Name**: SAP商城系统
- **Description**: 使用SpringBoot + Redis + RabbitMQ 实现的一个商城的后端接口项目
- **Primary Language**: Java
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2022-01-28
- **Last Updated**: 2022-05-26
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# SAPMall 电商系统
## 项目背景:
* 随着在线支付的风潮,现在基本所有系统都要一套支付流程,所以我着手于开发这样一套通用型的支付系统,只提供对外接口,从而实现所有的系统都可以接入该支付系统,通过该支付系统去实现支付流程,实现了原系统的业务流程与支付流程的解耦合
* 同时,当前电商项目比较热门,其涉及高并发等企业级问题,所以我着手开发一个电商系统,着手于实现高并发的解决,同时与支付系统进行对接,实现企业级的电商购物流程+支付流程的完整业务
## 项目核心功能流程:


## 项目整体感知

## 数据库设计 : 这里数据库设计顺便把支付系统的加上了
* 数据库表关系设计:

* 数据库表结构:

**在产品表中,价格用decimal类型。decimal(20,2)表示最大整数位支持18位,小数位2位。**
**decimal:数字型,128bit,不存在精度损失,常用于银行帐目计算**





* 索引设计:
***设置索引的原因: 对于经常查询且很少修改的字段, 加以索引,加快条件查询的效率***

* 时间戳的设计:
一直对时间戳这个概念比较模糊,相信有很多朋友也都会误认为:时间戳是一个时间字段,每次增加数据时,填入当前的时间值。其实这误导了很多朋友。
**时间戳:数据库中自动生成的唯一二进制数字,与时间和日期无关的, 通常用作给表行加版本戳的机制。存储大小为 8个字节。**
**每个数据库都有一个计数器,当对数据库中包含 timestamp 列的表执行插入或更新操作时,该计数器值就会增加。该计数器是数据库时间戳。这可以跟踪数据库内的相对时间,而不是时钟相关联的实际时间。一个表只能有一个 timestamp 列。每次修改或插入包含 timestamp 列的行时,就会在 timestamp 列中插入增量数据库时间戳值**。这一属性使 timestamp 列不适合作为键使用,尤其是不能作为主键使用。对行的任何更新都会更改 timestamp 值,从而更改键值。如果该列属于主键,那么旧的键值将无效,进而引用该旧值的外键也将不再有效。如果该表在动态游标中引用,则所有更新均会更改游标中行的位置。如果该列属于索引键,则对数据行的所有更新还将导致索引更新。
使用某一行中的 timestamp 列可以很容易地确定该行中的任何值自上次读取以后是否发生了更改。如果对行进行了更改,就会更新该时间戳值。如果没有对行进行更改,则该时间戳值将与以前读取该行时的时间戳值一致。若要返回数据库的当前时间戳值,请使用 @@DBTS。
在控制并发时起到作用:
**用户A/B同时打开某条记录开始编辑,保存是可以判断时间戳,因为记录每次被更新时,系统都会自动维护时间戳,所以如果保存时发现取出来的时间戳与数据库中的时间戳如果不相等,说明在这个过程中记录被更新过,这样的话可以防止别人的更新被覆盖.**

* 数据库建立:

## 技术栈:
* SpringBoot 2.1.7
* MySQL 5.7
* MyBatis 2.1
* Redis
* RabbitMQ
## 依赖:
```xml
org.springframework.boot
spring-boot-starter-web
mysql
mysql-connector-java
org.mybatis.spring.boot
mybatis-spring-boot-starter
2.1.0
org.projectlombok
lombok
com.github.pagehelper
pagehelper-spring-boot-starter
1.2.13
org.springframework.boot
spring-boot-starter-data-redis
com.google.code.gson
gson
org.springframework.boot
spring-boot-starter-amqp
```
## 配置: application.yml
```yml
# 配置连接mysql,redis数据库信息
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: iq20010318.
url: jdbc:mysql://127.0.0.1:3306/mall?characterEncoding=utf-8&serverTimezone=UTC
redis:
host: 192.168.131.133
port: 6379
# rabbitMQ 的相关配置
rabbitmq:
addresses: 192.168.131.133
port: 5672
username: admin
password: admin
virtual-host: /sapmall
# 配置mapper文件所在位置
mybatis:
mapper-locations: classpath:mappers/*.xml
configuration:
# MyBatis打印sql语句-控制台打印
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
server:
port: 8081
```
## Dao层初始化: 使用MyBatis-generator生成
* dao层架构:

* 配置mapper文件位置: application.yml
```yml
# 配置mapper文件所在位置
mybatis:
mapper-locations: classpath:mappers/*.xml
configuration:
# MyBatis打印sql语句-控制台打印
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
```
* Application类上加上扫描包注解@MapperScan
```java
@SpringBootApplication
@MapperScan("com.mlming.springboot.dao")
public class SapmallApplication {
public static void main(String[] args) {
SpringApplication.run(SapmallApplication.class, args);
}
}
```
## 项目开发注意事项:
* **严格按照前后端开发api接口进行**: **一般前后端项目开发,都会在开发之前就确立下一个接口文档, 前端只需要关心如何使用接口, 后端只需要关心如何开发接口**
**对于后端程序员而言,面向接口文档开发**
开发文档路径: F:\acer\ProjectForWork\sapmall\doc\api接口文档
* **前后端分离的项目,后端程序员一般就是在开发接口,所以我们一般都是只使用PostMan,ApiPost等接口测试工具来实现对接口的测试,而不是结合前端来测试接口,结合前端一般是最后开发完毕才做的**
* **开发推荐: 给每一个service层类对应一个测试类,单独测试里面各个方法**
实现方式: 在每个service层接口,右键->goto->Test 从而创建对应测试类
**因为对于后端程序员而言,Service层是最重要的最需要测试的,它代表着业务逻辑的正确性, 至于Controller大多数情况下也只是封装交互数据的, 而且Controller一般是交由测试人员根据api接口文档进行测试的, 所以后端程序员平时一定要注意对Service层的单例测试**
**注意: 由于在SpringBoot环境下,所以必须加上@RunWith(SpringRunner.class),@SpringBootTest注解**
**所以,为了方便开发测试类, 我们可以将主测试类加上两个接口后(注意要写个空方法,否则maven打包时会提示没有方法所以报错), 其他测试类去继承该主测试类,那么就不用手动添加两个注解了**
## 用户模块开发:
* 接口文档:
* 1.登录
**POST /user/login**
> request
Content-Type: application/json
```
{
"username":"admin",
"password":"admin",
}
```
> response
fail
```
{
"status": 1,
"msg": "密码错误"
}
```
success
```
{
"status": 0,
"data": {
"id": 12,
"username": "aaa",
"email": "aaa@163.com",
"phone": null,
"role": 0,
"createTime": 1479048325000,
"updateTime": 1479048325000
}
}
```
-------
* 2.注册
**POST /user/register**
> request
```
{
"username":"admin",
"password":"admin",
"email":"admin@qq.com"
}
```
> response
success
```
{
"status": 0,
"msg": "校验成功"
}
```
fail
```
{
"status": 2,
"msg": "用户已存在"
}
```
* 3.获取登录用户信息
**GET /user**
> request
```
无参数
```
> response
success
```
{
"status": 0,
"data": {
"id": 12,
"username": "aaa",
"email": "aaa@163.com",
"phone": null,
"role": 0,
"createTime": 1479048325000,
"updateTime": 1479048325000
}
}
```
fail
```
{
"status": 10,
"msg": "用户未登录,无法获取当前用户信息"
}
```
* 4.退出登录
**POST /user/logout**
> request
```
无
```
> response
success
```
{
"status": 0,
"msg": "退出成功"
}
```
fail
```
{
"status": -1,
"msg": "服务端异常"
}
```
* 代码实现:
* 架构:

* 各种实体类与枚举准备: **实体类: 方便各层之间的数据传输 , 枚举: 避免代码内硬编码**
由于面向接口文档开发时,我们会发现有很多硬编码的东西,例如状态码status以及响应信息msg, 所以可以新建枚举
```java
public enum ResponseEnum {
ERROR(-1,"服务端异常"),
SUCCESS(0,"成功"),
USERNAME_OR_PASSWORD_ERROR(1,"用户名或密码错误"),
USER_EXISTS(2,"用户已存在"),
EMAIL_EXISTS(3,"邮箱已存在"),
PARAMS_ERROR(4,"参数错误"),
NEED_LOGIN(10,"用户未登陆,请先登录")
;
Integer code;
String desc;
ResponseEnum(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
public Integer getCode() {
return code;
}
public String getDesc() {
return desc;
}
}
```
同时,因为返回的数据都大差不差,为了方便与前端响应,可以新建vo包下的类(vo是规范的命名,一般用于放存储响应信息的对象)
```java
// 响应信息实体类对象
@Data
@JsonInclude(value = JsonInclude.Include.NON_NULL) // 在以json形式响应该对象时,会把里面的null值去除
// 由于看接口文档里面, 那个data属性是一个类型不定的对象,所以要用到泛型
public class ResponseVo {
private Integer status;
private String msg;
private T data;
public ResponseVo() {
}
public ResponseVo(Integer status, String msg) {
this.status = status;
this.msg = msg;
}
// 返回具有全部信息的响应对象
public static ResponseVo success(T data) {
ResponseVo responseVo = new ResponseVo();
responseVo.setData(data);
responseVo.setStatus(ResponseEnum.SUCCESS.getCode());
return responseVo;
}
// 返回只具有code,msg的成功的响应对象
public static ResponseVo success() {
return new ResponseVo(ResponseEnum.SUCCESS.getCode(), ResponseEnum.SUCCESS.getDesc());
}
// 传递枚举返回错误响应对象
public static ResponseVo error(ResponseEnum responseEnum) {
return new ResponseVo(responseEnum.getCode(), responseEnum.getDesc());
}
// 根据发过来的BindingResult来获取属性错误信息来返回错误响应对象
public static ResponseVo error(ResponseEnum responseEnum, BindingResult bindingResult) {
return new ResponseVo(responseEnum.getCode(), bindingResult.getFieldError().getField() + " " + bindingResult.getFieldError().getDefaultMessage());
}
}
```
同时,观察接口文档,我们会发现前端发过来的数据虽然是User的属性,但是并不全,只有一小部分,这种情况下,一般都要求新建form包下的类来专门作为前端传递参数的存储类
(
对于这种参数存储类, 一般经常要对各个参数进行非空判断,这样会导致controller里面if-else太多,不好看,而且代码极度重复,所以一般:
会使用@NotBlank,@NotNull,@NotEmpty注解标识这些属性
然后结合controller里面的@Valid注解以及参数BindingResult类来进行自动的内置判断并获取错误信息(这个详情看下文的controller)
)
```java
/**
* 注册时提交的部分User数据
*/
@Data
public class UserRegisterForm {
// @NotBlank: 用于String判断空格
// @NotEmpty: 用于集合
// @NotNull: 用于非null判断
@NotBlank
private String username;
@NotBlank
private String password;
@NotBlank
private String email;
}
```
```java
/**
* 登录时提交的部分User数据
*/
@Data
public class UserLoginForm {
// @NotBlank: 用于String判断空格
// @NotEmpty: 用于集合
// @NotNull: 用于非null判断
@NotBlank
private String username;
@NotBlank
private String password;
}
```
同时,我们新建一个consts包下面的类,专门用于存储一些不那么复杂,用不着枚举的一些**常量**
```java
/**
* 定义商城会用的一些常量,例如字符硬编码等
*/
public class MallConst {
public final static String CURRENT_USER = "currentUser"; //session存储用户登录状态的key名
}
```
* Dao层:
UserMapper.java
```java
/**
* 判断是否存在该用户名
*/
int countByUsername(String username);
/**
* 判断是否存在该邮箱
*/
int countByEmail(String email);
/**
* 根据用户名查询是否有该用户
*/
User selectByUserName(String username);
```
UserMapper.xml
```xml
```
* Service层:
```java
public interface UserService {
/**
* 注册功能
*/
ResponseVo register(User user);
/**
* 登陆功能
*/
ResponseVo login(String username,String password);
}
```
```java
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public ResponseVo register(User user) {
// 首先验证用户名是否存在:
int count = userMapper.countByUsername(user.getUsername());
if(count > 0) {
return ResponseVo.error(ResponseEnum.USER_EXISTS);
}
// 验证邮箱是否已经存在:
count = userMapper.countByEmail(user.getEmail());
if(count > 0) {
return ResponseVo.error(ResponseEnum.EMAIL_EXISTS);
}
// 设置用户的身份为Customer
user.setRole(RoleEnum.CUSTOMER.getCode());
// 对密码进行MD5加密
user.setPassword(DigestUtils.md5DigestAsHex(user.getPassword().getBytes(StandardCharsets.UTF_8)));
// 写入数据库
int res = userMapper.insertSelective(user);
if(res > 0) {
return ResponseVo.success();
} else {
return ResponseVo.error(ResponseEnum.ERROR);
}
}
@Override
public ResponseVo login(String username,String password) {
User user = userMapper.selectByUserName(username);
// 验证用户是否存在
if(user == null) {
return ResponseVo.error(ResponseEnum.USERNAME_OR_PASSWORD_ERROR);
}
// 验证密码
if(!user.getPassword().equals(DigestUtils.md5DigestAsHex(password.getBytes(StandardCharsets.UTF_8)))) {
return ResponseVo.error(ResponseEnum.USERNAME_OR_PASSWORD_ERROR);
}
// 如果都无误,根据接口文档,返回用户信息
// 注意: password要屏蔽掉
user.setPassword("");
return ResponseVo.success(user);
}
}
```
* Controller层:
```java
@RestController
public class UserController {
@Autowired
private UserService userService;
/**
* 注册
*/
// 使用@Valid与@NotXXX注解方便对内部属性进行判断,可以使用BindingResult类来接收错误信息
@PostMapping("/user/register")
public ResponseVo register(@Valid UserRegisterForm userRegisterForm,
BindingResult bindingResult) {
// 如果有错误属性,则找出错误
if(bindingResult.hasErrors()) {
return ResponseVo.error(ResponseEnum.PARAMS_ERROR,bindingResult);
}
// 复制抽取属性对象的属性到User对象里
User user = new User();
BeanUtils.copyProperties(userRegisterForm,user);
// 调用Service层的注册逻辑方法
return userService.register(user);
}
/**
* 登录
*/
// 使用@Valid与@NotXXX注解方便对内部属性进行判断,可以使用BindingResult类来接收错误信息
@PostMapping("/user/login")
public ResponseVo register(@Valid UserLoginForm userLoginForm,
BindingResult bindingResult,
HttpSession session) {
// 如果有错误属性,则找出错误
if(bindingResult.hasErrors()) {
return ResponseVo.error(ResponseEnum.PARAMS_ERROR,bindingResult);
}
// 调用Service层的登录逻辑方法
ResponseVo result = userService.login(userLoginForm.getUsername(), userLoginForm.getPassword());
// 判断登录是否成功,如果成功则设置session,保存登录状态
if(result.getStatus() == 0) {
session.setAttribute(MallConst.CURRENT_USER,result.getData());
}
return result;
}
/**
* 登出
*/
@PostMapping("/user/logout")
public ResponseVo logout(HttpSession session) {
// 由于之前登陆状态会由拦截器进行拦截,所以
session.removeAttribute(MallConst.CURRENT_USER);
return ResponseVo.success();
}
/**
* 查询登录状态的用户信息
*/
@GetMapping("/user")
public ResponseVo userInfo(HttpSession session) {
// 由于之前登陆状态会由拦截器进行拦截,所以
User user = (User) session.getAttribute(MallConst.CURRENT_USER);
return ResponseVo.success(user);
}
}
```
* 拦截器:
由于观察到我们要保存登录状态,而且其中两个请求: 请求用户详细信息,登出,是要首先有登录状态的,所以我们要设置一个拦截器来拦截登录状态
拦截器:
```java
public class UserLoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
User user = (User) request.getSession().getAttribute(MallConst.CURRENT_USER);
// 如果用户是null,则不能登录
if(user == null) {
// 不推荐使用response向前端打印信息,因为我们已经封装好一个vo类来返回信息了,所以我们推荐下面一种方式:
// 抛出RuntimeException类型错误,从而可以被全局异常处理器捕捉到,然后向前端返回信息
throw new UserLoginException();
}
// 如果上面没报错,说明是处于登录状态,放行
return true;
}
}
```
拦截器的配置类:
```java
@Configuration
public class UserInterceptConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
String[] addPathPatterns = {
"/user/**"
};
// 配置该拦截器不会拦截的请求路径(一般建立在 会拦截的范围 里面选几个特例)
String[] excludePathPatterns = {
"/user/login","/user/register"
};
// 相当于 标签里面的配置
// addInterceptor(new UserInterceptor()) => 添加拦截器
// addPathPatterns(拦截路径) => 添加该拦截器会拦截的路径的格式
// excludePathPatterns(放行路径) => 添加该拦截器的不会拦截的路径格式
registry.addInterceptor(new UserLoginInterceptor())
.addPathPatterns(addPathPatterns)
.excludePathPatterns(excludePathPatterns);
}
}
```
* 全局异常处理器:
一来是因为一般项目都会有一个全局异常处理器来统一处理项目的异常
二来是因为拦截器要以来全局异常处理器来向前端返回信息**(其他地方如果有不方便返回vo类对象的情况,也可以采取这种思想)**
```java
/**
* 全局异常处理类
*/
@ControllerAdvice
public class GlobalExceptionHandler {
/**
* 对用户状态未登录时抛出的异常的处理
* @return
*/
@ExceptionHandler(UserLoginException.class)
@ResponseBody //返回json格式给前端,所以说: 其实SpringMVC的异常处理器就相当于一个Controller
@ResponseStatus(HttpStatus.FORBIDDEN) // 响应的状态码,默认是200
public ResponseVo userLoginExceptionHandler() {
// 向浏览器返回错误信息的vo对象
return ResponseVo.error(ResponseEnum.NEED_LOGIN);
}
}
```
* 开发心得:
* **对于各层间格式比较确定的数据传输,最好设置一个类专门用于传输:**
* pojo(entity)包: 三层架构之间的传递: Controller,Service,Dao层之间的传递, 例如User类
一般由数据库表决定, 一般只有set,get方法
* vo包: 后端的响应对象
一般由接口文档决定,
一般除了set,get方法,**为了方便后端方便调用响应信息,一般会设置static静态方法来根据参数自动设置对应的响应格式**
例如:
```java
// 响应信息实体类对象
@Data
@JsonInclude(value = JsonInclude.Include.NON_NULL) // 在以json形式响应该对象时,会把里面的null值去除
// 由于看接口文档里面, 那个data属性是一个类型不定的对象,所以要用到泛型
public class ResponseVo {
private Integer status;
private String msg;
private T data;
public ResponseVo() {
}
public ResponseVo(Integer status, String msg) {
this.status = status;
this.msg = msg;
}
// 返回具有全部信息的响应对象
public static ResponseVo success(T data) {
ResponseVo responseVo = new ResponseVo();
responseVo.setData(data);
responseVo.setStatus(ResponseEnum.SUCCESS.getCode());
return responseVo;
}
// 返回只具有code,msg的成功的响应对象
public static ResponseVo success() {
return new ResponseVo(ResponseEnum.SUCCESS.getCode(), ResponseEnum.SUCCESS.getDesc());
}
// 传递枚举返回错误响应对象
public static ResponseVo error(ResponseEnum responseEnum) {
return new ResponseVo(responseEnum.getCode(), responseEnum.getDesc());
}
// 根据发过来的BindingResult来获取属性错误信息来返回错误响应对象
public static ResponseVo error(ResponseEnum responseEnum, BindingResult bindingResult) {
return new ResponseVo(responseEnum.getCode(), bindingResult.getFieldError().getField() + " " + bindingResult.getFieldError().getDefaultMessage());
}
}
```
* form包: 前端传递参数的封装
一般是pojo里面抽取部分属性封装而成(主要还是因为接口文档中,前端传递的数据一般只是pojo的部分属性)
一般由接口文档决定
一般只有get,set方法, **为了节省Controller对参数的判断,一般会使用@NotXXX注解配合@Valid与BindingResult类进行自动判断**
然后可以**通过BeanUtils.copyProperties(form对象,pojo对象)拷贝属性过去,然后把pojo对象传递给service进行处理**
例如: 上面的UserRegisterForm,UseLoginForm类
* **要避免代码中的硬编码, 一般通过枚举, 或者, 定义一个普通类里面通过static final定义的常量 进行修改**
* 例如: 状态码是0,1,2等, 要避免在代码里面直接写0,1,2, 这样可读性太差, 应该新建一个枚举或者一个类存储常量,然后通过枚举/类.XXXX来写
* 枚举与常量类的选择:
- 如果要存储的比较复杂, 例如上面的ResponseEnum, 不仅要存一个数字(状态码status),还得存一段字符串(描述msg),而且是绑定的,那就使用枚举
- 如果要存储的仅仅只是一个常量, 例如上面的MallConst里面的CURRENT_USER,仅仅只是一个字符串,那就使用常量类
* 对于有些地方需要向前端返回数据(vo类对象),但是又因为一些原因不能直接return vo类对象的,例如拦截器
那么,**就可以throw一个异常(可以是自定义的),然后交由全局异常处理器进行捕获,然后让全局异常处理器对应return vo类对象给前端**
* 可以使用Spring框架内置的DigestUtils.md5DigestAsHex()对字符串,例如密码等进行MD5加密
## 分类模块:
* 接口文档:
* 1.所有类目
**GET /categories**
> request
无需登录
> response
success
```markdown
{
"status": 0,
"data": [{
"id": 100001,
"parentId": 0,
"name": "家用电器",
"sortOrder": 1,
"subCategories": [{
"id": 100006,
"parentId": 100001,
"name": "冰箱",
"sortOrder": 2,
"subCategories": [{
"id": 100040,
"parentId": 100006,
"name": "进口冰箱",
"sortOrder": 1,
"subCategories": []
}]
}, {
"id": 100005,
"parentId": 0,
"name": "酒水饮料",
"sortOrder": 1,
"subCategories": [{
"id": 100026,
"parentId": 100005,
"name": "白酒",
"sortOrder": 1,
"subCategories": []
}, {
"id": 100027,
"parentId": 100005,
"name": "红酒",
"sortOrder": 1,
"subCategories": []
}]
}]
}
```
* 代码实现
* 架构:

* 实体类和常量的准备:
观察接口文档,其实响应信息格式还是按照之前的ResponseVo,但是我们发现返回的Category属性并非完全和数据库一样,故我们得新建一个CategoryVo专门用于响应,届时只需要把该List作为ResponseVo的data即可:
```java
@Data
public class CategoryVo {
private Integer id;
private Integer parentId;
private String name;
private Boolean status;
private Integer sortOrder;
/**
* 子类目列表
*/
private List subCategories;
}
```
由于涉及到一个常量: 一级目录时,其parentID=0,但是之前也说了要避免硬编码,但是由于这个只是一个数字,无需用到枚举,所以加到常量类里面
```java
/**
* 定义商城会用的一些常量,例如字符硬编码等
*/
public class MallConst {
public final static String CURRENT_USER = "currentUser"; //session存储用户登录状态的key名
public final static Integer ROOT_PARENT_ID = 0;// 第一级目录的parentID
}
```
* Dao层:
CategoryMapper.java:
```java
/**
* 获取所有的类目
* @return
*/
List selectAll();
```
CategoryMapper.xml:
```xml
```
* Service层:
```java
public interface CategoryService {
/**
* 获取所有的类目
* @return
*/
ResponseVo> getAllCategories();
}
```
```java
@Service
public class CategoryServiceImpl implements CategoryService {
@Autowired
private CategoryMapper categoryMapper;
@Override
public ResponseVo> getAllCategories() {
List categoryVoList = new ArrayList<>(); // 要返回的列表
/*
由于查询数据库是非常耗时的,所以一开始直接从数据库读取所有的数据(因为类目表一般数据量不大)
然后再进行操作,这样就减少了对数据库的查询次数
*/
List categories = categoryMapper.selectAll(); // 数据库中的所有类目数据
// 1) 先获取一级目录
for (Category category : categories) {
// 如果parentId==0,说明是一级目录
if(category.getParentId().equals(MallConst.ROOT_PARENT_ID)) {
// 由于返回的是vo对象,所以必须得转成vo
CategoryVo categoryVo = new CategoryVo();
BeanUtils.copyProperties(category,categoryVo);
// 将一级目录存入categoryVoList中
categoryVoList.add(categoryVo);
// 寻找其子目录
findAllChildren(categoryVo,categories);
}
}
// 按照业务要求,按照sortOrder字段进行排序 => 从大到小
// 所以最后还得reserved反转一下
categoryVoList.sort(Comparator.comparing(CategoryVo::getSortOrder).reversed());
return ResponseVo.success(categoryVoList);
}
// 递归函数: 不断往下寻找多级目录
private void findAllChildren(CategoryVo parentVo,List categories) {
parentVo.setSubCategories(new ArrayList<>()); // 初始化子目录列表
for (Category category : categories) {
// 如果有一个类目的parentId == 当前parentVo的id,则说明是子目录
if(category.getParentId().equals(parentVo.getId())) {
// 由于返回的是vo对象,所以必须得转成vo
CategoryVo categoryVo = new CategoryVo();
BeanUtils.copyProperties(category,categoryVo);
// 将子目录存到parentVo的子目录列表中
parentVo.getSubCategories().add(categoryVo);
// 寻找该子目录的子目录
findAllChildren(categoryVo,categories);
}
}
// 根据业务要求,要根据sortOrder字段进行排序 => 从大到小
// 所以sort之后,还要reversed反转一下
parentVo.getSubCategories().sort(Comparator.comparing(CategoryVo::getSortOrder).reversed());
}
}
```
* Controller层:
```java
@RestController
public class CategoryController {
@Autowired
private CategoryService categoryService;
@GetMapping("/category/getall")
public ResponseVo> getAllCategories() {
return categoryService.getAllCategories();
}
}
```
* 心得:
* **虽然有时候我们是想表达嵌套递归的目录结构,但是数据库存储时没必要这样,尤其是关系型数据库更做不到这样**
**所以,一般数据库只存着正常目录字段,至于目录的嵌套关系一般交由Java代码实时操控**
* **数据库映射的pojo类, 并不一定就是届时返回给前端的类, 这种情况一般都是新建一个vo类**(一般就算能直接返回pojo作为响应结果,也会新建一个vo类代替)
## 商品模块
* 接口文档:
* 1.商品列表
**GET /products
> request
```
categoryId(非必传,子类目的商品也要查出来)
pageNum(default=1)
pageSize(default=10)
```
> response
success
```
{
"status": 0,
"data": {
"pageNum": 1,
"pageSize": 10,
"size": 2,
"orderBy": null,
"startRow": 1,
"endRow": 2,
"total": 2,
"pages": 1,
"list": [
{
"id": 1,
"categoryId": 3,
"name": "iphone7",
"subtitle": "双十一促销",
"mainImage": "mainimage.jpg",
"status":1,
"price": 7199.22
},
{
"id": 2,
"categoryId": 2,
"name": "oppo R8",
"subtitle": "oppo促销进行中",
"mainImage": "mainimage.jpg",
"status":1,
"price": 2999.11
}
],
"firstPage": 1,
"prePage": 0,
"nextPage": 0,
"lastPage": 1,
"isFirstPage": true,
"isLastPage": true,
"hasPreviousPage": false,
"hasNextPage": false,
"navigatePages": 8,
"navigatepageNums": [
1
]
}
}
```
------
* 2.商品详情
**GET /products/{productId}
> request
```
productId
```
> response
success
```
{
"status": 0,
"data": {
"id": 2,
"categoryId": 2,
"name": "oppo R8",
"subtitle": "oppo促销进行中",
"mainImage": "mainimage.jpg",
"subImages": "[\"mmall/aa.jpg\",\"mmall/bb.jpg\",\"mmall/cc.jpg\",\"mmall/dd.jpg\",\"mmall/ee.jpg\"]",
"detail": "richtext",
"price": 2999.11,
"stock": 71,
"status": 1,
"createTime": "2016-11-20 14:21:53",
"updateTime": "2016-11-20 14:21:53"
}
}
```
fail
```
{
"status": 1,
"msg": "该商品已下架或删除"
}
```
* 商品列表功能的 业务逻辑的说明: 由于本次不是简单的面向一个模块的
```
如果传递了categoryId,则只是分页查询该类目以及其多级子目录类目下的商品
如果不传递, 则是分页查询所有的商品
所以,
如果传递了categoryId,我们必须通过category类目模块,查询到该id以及其下面所有目录categoryId的集合,然后通过这个集合再去商品表里面查询对应的商品
如果没有传递,则直接查出所有的商品表的商品
```
* 代码实现:
* 架构:

* 实体类与常量类准备:
由接口文档可知,要返回的商品信息与pojo中的不一致,所以要新建一个ProductVo类:
```java
@Data
public class ProductVo {
private Integer id;
private Integer categoryId;
private String name;
private String subtitle;
private String mainImage;
private BigDecimal price;
private Integer status;
}
```
虽然detail这个请求里面,返回的是与pojo一模一样的属性,但是因为一直以来都是以vo类作为返回,所以这里也新建一个ProductDetailVo类:
```java
@Data
public class ProductDetailVo {
private Integer id;
private Integer categoryId;
private String name;
private String subtitle;
private String mainImage;
private BigDecimal price;
private Integer stock;
private Integer status;
private String subImages;
private String detail;
private Date createTime;
private Date updateTime;
}
```
由于观察数据库,因为会涉及到一个status的字段,其含义是具有枚举意味的,所以新建一个枚举:
```java
public enum ProductStatusEnum {
ON_SALE(1,"在售"),
OFF_SALE(2,"下架"),
DELETE(3,"删除"),
;
Integer code;
String msg;
ProductStatusEnum(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
public Integer getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
```
* Dao层:
由接口文档可知,因为要根据类目id查询商品信息,所以:
```java
public interface ProductMapper {
/**
* 根据类目id集合查询Product记录
*/
List selectByCategoryIdSet(@Param("categoryIdSet") Set categoryIdSet);
}
```
```xml
```
* Service层:
前面业务逻辑说明也说了, 如果传递了categoryid,必须要借助category类目模块来进行查找多级目录的categoryId,所以这里涉及到了之前的categoryService:
```java
/**
* 根据categoryId查询所有的类目的id
* @param categoryId
*/
void findAllCategoryIdsByCategoryId(Integer categoryId, Set categoryIdSet);
```
```java
@Override
public void findAllCategoryIdsByCategoryId(Integer categoryId, Set categoryIdSet) {
List categories = categoryMapper.selectAll(); // 数据库中的所有类目数据
findSubCategoryIds(categoryId,categoryIdSet,categories);
}
// 递归函数: 不断往下寻找多级目录的id
private void findSubCategoryIds(Integer categoryId,Set categoryIdSet,List categories) {
for (Category category : categories) {
if(category.getParentId().equals(categoryId)) {
categoryIdSet.add(category.getId());
findSubCategoryIds(category.getId(),categoryIdSet,categories);
}
}
}
```
Product模块:
```java
public interface ProductService {
/**
* 按照categoryid查询商品列表
* @param categoryId
* @param pageNum
* @param pageSize
* @return
*/
ResponseVo getProducts(Integer categoryId, Integer pageNum, Integer pageSize);
/**
* 根据productId查询商品
* @param productId
* @return
*/
ResponseVo getProductDetailById(Integer productId);
}
```
```java
@Service
public class ProductServiceImpl implements ProductService {
@Autowired
private CategoryService categoryService;
@Autowired
private ProductMapper productMapper;
@Override
public ResponseVo getProducts(Integer categoryId, Integer pageNum, Integer pageSize) {
/*
业务逻辑:
如果传递了categoryId,则只是分页查询该类目以及其多级子目录类目下的商品
如果不传递, 则是分页查询所有的商品
体现在: productMapper.selectByCategoryIdSet(categoryIdSet) 执行的sql是:
select
from mall_product
where status = 1
and category_id in
#{categoryId}
可见,如果没有categoryId,则不会有if里面的where条件,所以就是查询所有的商品
*/
// 先获取到参数categoryId以及其下面的所有子目录的categoryid的集合
Set categoryIdSet = new HashSet<>();
if(categoryId != null) {
categoryService.findAllCategoryIdsByCategoryId(categoryId,categoryIdSet);
categoryIdSet.add(categoryId);
}
// 通过pageHelper实现分页功能: 会导致紧接着的查询自动加以分页
PageHelper.startPage(pageNum,pageSize);
// 根据set里面的categoryid查询实际Product列表
List products = productMapper.selectByCategoryIdSet(categoryIdSet);
// 返回的是ProductVo类,所以要进行转化:
List productVoList = new ArrayList<>();
for (Product product : products) {
ProductVo productVo = new ProductVo();
BeanUtils.copyProperties(product,productVo);
productVoList.add(productVo);
}
// 通过pageHelper加入接口文档中要求的那堆分页参数
PageInfo pageInfo = new PageInfo(products);
pageInfo.setList(productVoList);
return ResponseVo.success(pageInfo);
}
@Override
public ResponseVo getProductDetailById(Integer productId) {
Product product = productMapper.selectByPrimaryKey(productId);
// 如果商品编号不存在 或者 当前商品状态是已下架或者删除,则返回报错信息
if(product == null || product.getStatus().equals(ProductStatusEnum.OFF_SALE.getCode()) ||
product.getStatus().equals(ProductStatusEnum.DELETE.getCode())) {
return ResponseVo.error(ResponseEnum.PRODUCT_OFF_SALE_OR_DELETE);
}
// 否则,返回商品详情
ProductDetailVo productDetailVo = new ProductDetailVo();
BeanUtils.copyProperties(product,productDetailVo);
// 对商品的一些敏感信息进行一些虚假处理
productDetailVo.setStock(productDetailVo.getStock() > 100 ? 100 : productDetailVo.getStock());
return ResponseVo.success(productDetailVo);
}
}
```
* Controller层:
```java
@RestController
public class ProductController {
@Autowired
private ProductService productService;
/**
* 分页获取商品列表
*/
@GetMapping("/products")
public ResponseVo getProducts(@RequestParam(required = false) Integer categoryId,
@RequestParam(required = false,defaultValue = "1") Integer pageNum,
@RequestParam(required = false,defaultValue = "10") Integer pageSize) {
return productService.getProducts(categoryId, pageNum, pageSize);
}
/**
* 获取商品详细信息
*/
// 由接口文档可知: 该请求是RESTFUL风格的GET请求,所以要通过@PathVariable
@GetMapping("/products/{productId}")
public ResponseVo getProductDetail(@PathVariable Integer productId) {
if(productId == null) {
return ResponseVo.error(ResponseEnum.PARAMS_ERROR);
}
return productService.getProductDetailById(productId);
}
}
```
* 心得:
* 一个模块的一个功能的实现可能会涉及其他多个模块的交互,此时不能图方便全写到这个模块理,而是要归类然后写到对应模块里,供这个模块调用
* 就算接口文档中要返回的数据与pojo的一模一样,一般都会新建一个vo类代替其作为响应对象
* 不要在循环里面查询数据库, 因为每次查询数据库都要去连接数据库等是非常耗费时间的.
如果确实需要一个个进行对应条件查询, 可以交由Java代码处理好后再由sql处理
1) 事先把所有记录都查出来,用Java的集合类List等接收
2) 然后通过Java代码对集合进行遍历, 通过判断等找出需要的参数存在一个集合里
3) 然后通过 传递该集合作为dao层的参数 以及 sql语句使用 in作为条件, foreach拼接in 等实现一次性条件查询
例如: 上面的根据类目id查询商品
## 购物车模块
* 接口文档:
* 1.购物车List列表
** GET /carts
> request
```
无参数,需要登录状态
```
> response
success
```
{
"status": 0,
"data": {
"cartProductVoList": [
{
"productId": 1,
"quantity": 1,
"productName": "iphone7",
"productSubtitle": "双十一促销",
"productMainImage": "mainimage.jpg",
"productPrice": 7199.22,
"productStatus": 1,
"productTotalPrice": 7199.22,
"productStock": 86,
"productSelected": true,
},
{
"productId": 2,
"quantity": 1,
"productName": "oppo R8",
"productSubtitle": "oppo促销进行中",
"productMainImage": "mainimage.jpg",
"productPrice": 2999.11,
"productStatus": 1,
"productTotalPrice": 2999.11,
"productStock": 86,
"productSelected": false,
}
],
"selectedAll": false,
"cartTotalPrice": 10198.33,
"cartTotalQuantity": 2
}
}
```
fail
```
{
"status": 10,
"msg": "用户未登录,请登录"
}
```
* 2.购物车添加商品
** POST /carts
> request
```
productId
selected: true
```
`注意`数量不用传,添加商品永远是以1累加
> response
success
```
{
"status": 0,
"data": {
"cartProductVoList": [
{
"productId": 1,
"quantity": 12,
"productName": "iphone7",
"productSubtitle": "双十一促销",
"productMainImage": "mainimage.jpg",
"productPrice": 7199.22,
"productStatus": 1,
"productTotalPrice": 86390.64,
"productStock": 86,
"productSelected": true
},
{
"productId": 2,
"quantity": 1,
"productName": "oppo R8",
"productSubtitle": "oppo促销进行中",
"productMainImage": "mainimage.jpg",
"productPrice": 2999.11,
"productStatus": 1,
"productTotalPrice": 2999.11,
"productStock": 86,
"productSelected": true
}
],
"selectedAll": true,
"cartTotalPrice": 89389.75,
"cartTotalQuantity": 13
}
}
```
fail
```
{
"status": 10,
"msg": "用户未登录,请登录"
}
```
------
* 3.更新购物车
** PUT /carts/{productId}
> request
```
quantity //非必填
selected: true //非必填
```
> response
响应同2
success
```
{
"status": 0,
"data": {
"cartProductVoList": [
{
"productId": 1,
"quantity": 12,
"productName": "iphone7",
"productSubtitle": "双十一促销",
"productMainImage": "mainimage.jpg",
"productPrice": 7199.22,
"productStatus": 1,
"productTotalPrice": 86390.64,
"productStock": 86,
"productSelected": true
},
{
"productId": 2,
"quantity": 1,
"productName": "oppo R8",
"productSubtitle": "oppo促销进行中",
"productMainImage": "mainimage.jpg",
"productPrice": 2999.11,
"productStatus": 1,
"productTotalPrice": 2999.11,
"productStock": 86,
"productSelected": true,
}
],
"selectedAll": true,
"cartTotalPrice": 89389.75,
"cartTotalQuantity": 13
}
}
```
fail
```
{
"status": 10,
"msg": "用户未登录,请登录"
}
```
------
* 4.移除购物车某个产品
** DELETE /carts/{productId}
> request
```
productId
```
> response
success
```
{
"status": 0,
"data": {
"cartProductVoList": [
{
"productId": 2,
"quantity": 1,
"productName": "oppo R8",
"productSubtitle": "oppo促销进行中",
"productMainImage": "mainimage.jpg",
"productPrice": 2999.11,
"productStatus": 1,
"productTotalPrice": 2999.11,
"productStock": 86,
"productSelected": true
}
],
"selectedAll": true,
"cartTotalPrice": 2999.11,
"cartTotalQuantity": 1
}
}
```
fail
```
{
"status": 10,
"msg": "用户未登录,请登录"
}
```
------
* 5.全选中
** PUT /carts/selectAll
> request
```
无参数,需要登录状态
```
> response
success
同接口 获取购物车列表
------
* 6.全不选中
** PUT /carts/unSelectAll
> request
```
无参数,需要登录状态
```
> response
success
同接口 获取购物车列表
------
* 7.获取购物中所有商品数量总和
** GET /carts/products/sum
> request
```
无参数,需要登录状态
```
> response
```
{
"status": 0,
"data": 2
}
```
* 架构:

* 代码实现:
* 实体类与常量类准备:
由于接口文档有两种传递参数形式,所以创建两个form类用于存储:
```java
@Data
public class CartAddForm {
@NonNull
private Integer productId;
private Boolean selected = true;
public CartAddForm() {
}
public CartAddForm(@NonNull Integer productId, Boolean selected) {
this.productId = productId;
this.selected = selected;
}
}
```
```java
@Data
public class CartUpdateForm {
private Integer quantity;
private Boolean selected = true;
}
```
观察接口文档,返回值里面,首先data要是一个vo类, 然后该vo类里面有个List类型的,里面应该也要新建一个vo类
```java
@Data
public class CartVo {
private List cartProductVoList;
private Boolean selectedAll;
private BigDecimal cartTotalPrice;
private Integer cartTotalQuantity;
public CartVo() {
}
public CartVo(List cartProductVoList, Boolean selectedAll, BigDecimal cartTotalPrice, Integer cartTotalQuantity) {
this.cartProductVoList = cartProductVoList;
this.selectedAll = selectedAll;
this.cartTotalPrice = cartTotalPrice;
this.cartTotalQuantity = cartTotalQuantity;
}
}
```
```java
@Data
public class CartProductVo {
private Integer productId;
private Integer quantity;
private String productName;
private String productSubtitle;
private String productMainImage;
private BigDecimal productPrice;
private Integer productStatus;
private Integer productStock;
// productPrice * quantity
private BigDecimal productTotalPrice;
private Boolean productSelected;
}
```
由于我们购物车模块使用的是Redis存储,所以之前并没有新建该购物车模块对应的实体类:
```java
@Data
public class Cart {
private Integer productId;
private Integer quantity;
private Boolean productSelected;
public Cart() {
}
public Cart(Integer productId, Integer quantity, Boolean productSelected) {
this.productId = productId;
this.quantity = quantity;
this.productSelected = productSelected;
}
}
```
* Dao层:
Redis的起步依赖自动注入了一个RedisTemplate的dao层对象:

Redis数据类型的选择:
```
// 选取的Redis数据类型:
// 由于Redis要存多用户,所以必须得是以userId作为key
// 同时又因为每个用户,即每个key对应的value,即购物车,必须是要有多个Cart类对象的
// 所以应该采用的是hash数据类型: 因为hash类型对应的是 对象,所以下面以对象的形式解释形式
// 对象名(key): 用户id相关的唯一标识
// 属性列:
// 属性名: productId唯一标识
// 属性值: Cart类转json格式 => 主要是为了方便存储,如果这里要存对象,Redis没有对应的数据类型
// 即: **以对象的形式存一个购物车, 属性列的形式存储购物车里面的多个Cart**
// 利用Redis对hash数据类型的添加操作的机制:
// 如果不存在key,则新建一个;如果key存在,则会覆盖已存在的属性名的属性值,并新建不存在的属性名
// 所以根据这个机制,**我们无论是添加还是修改,都只需要put往redis里添加数据就行了**
```
* Service层:
```java
public interface CartService {
/**
* 往购物车添加商品
*/
ResponseVo addCartItem(Integer userId, CartAddForm cartAddForm);
/**
* 返回购物车商品清单
*/
ResponseVo getCartList(Integer userId);
/**
* 更新购物车
*/
ResponseVo updateCartItem(Integer userId,Integer productId,CartUpdateForm cartUpdateForm);
/**
* 更新购物车
*/
ResponseVo deleteCartItem(Integer userId,Integer productId);
/**
* 全选
*/
ResponseVo selectAll(Integer userId);
/**
* 全不选
*/
ResponseVo unSelectAll(Integer userId);
/**
* 返回购物车内商品总和
*/
ResponseVo sum(Integer userId);
}
```
```java
@Service
public class CartServiceImpl implements CartService {
@Autowired
private ProductMapper productMapper;// 用于查询商品数据
@Autowired
private RedisTemplate redisTemplate; // redis依赖提供的内置dao层对象
private Gson gson = new Gson(); // 用于json与Cart对象之间的转换
@Override
public ResponseVo addCartItem(Integer userId, CartAddForm cartAddForm) {
Integer productId = cartAddForm.getProductId();// 商品id
// 通过productId从商品表查出该商品
Product product = productMapper.selectByPrimaryKey(productId);
// 判断该商品存在不存在,不存在就报错误信息
if(product == null) {
return ResponseVo.error(ResponseEnum.PRODUCT_NOT_EXISTS);
}
// 判断该商品是否在售:
if(product.getStatus().equals(ProductStatusEnum.OFF_SALE.getCode()) || product.getStatus().equals(ProductStatusEnum.DELETE.getCode())) {
return ResponseVo.error(ResponseEnum.PRODUCT_OFF_SALE_OR_DELETE);
}
// 判断该商品库存是否足够
if(product.getStock() <= 0) {
return ResponseVo.error(ResponseEnum.PRODUCT_STOCK_NOT_ENOUGH);
}
// 以上判断均成功后, 开始往Redis写入数据:
// 选取的Redis数据类型:
// 由于Redis要存多用户,所以必须得是以userId作为key
// 同时又因为每个用户,即每个key对应的value,即购物车,必须是要有多个Cart类对象的
// 所以应该采用的是hash数据类型: 因为hash类型对应的是 对象,所以下面以对象的形式解释形式
// 对象名(key): 用户id相关的唯一标识
// 属性列:
// 属性名: productId唯一标识
// 属性值: Cart类转json格式 => 主要是为了方便存储,如果这里要存对象,Redis没有对应的数据类型
// 即: **以对象的形式存一个购物车, 属性列的形式存储购物车里面的多个Cart**
// 利用Redis对hash数据类型的添加操作的机制:
// 如果不存在key,则新建一个;如果key存在,则会覆盖已存在的属性名的属性值,并新建不存在的属性名
// 所以根据这个机制,**我们无论是添加还是修改,都只需要put往redis里添加数据就行了**
HashOperations hashOperations = redisTemplate.opsForHash();
// 首先得查出该Cart是否存在:
String value = hashOperations.get(userId, productId);
// 如果不存在,则新建一个Cart类
Cart cart;
if(StringUtils.isNullOrEmpty(value)) {
cart = new Cart(productId, MallConst.CART_INIT_QUANTITY,cartAddForm.getSelected());
}
// 如果存在的话,根据接口文档,是在原有数量的基础上+1作为新数量
else {
cart = gson.fromJson(value, Cart.class);
cart.setQuantity(cart.getQuantity()+1);
}
cart.setProductSelected(cartAddForm.getSelected());
// 处理结束后,根据上述的存储格式规定, 写入redis
hashOperations.put(userId,productId,gson.toJson(cart));
return getCartList(userId);
}
@Override
public ResponseVo getCartList(Integer userId) {
HashOperations hashOperations = redisTemplate.opsForHash();
// 从Redis中查询出key==userId的value => 购物车的 所有属性列表 => 每个购物车记录
Map entries = hashOperations.entries(userId);
// 获取到所有的商品id,并从商品表中查询商品列表
Set productSet = entries.keySet();
List products = productMapper.selectByProductIdSet(productSet);
// 遍历所有的商品,装载到要返回的CartProductVo类里面
// 并把CartProductVo类加入到cartProductVoList,
// 中间要业务性地求出CartVo的其他两个字段cartTotalPrice,selectedAll,cartTotalQuantity
List cartProductVoList = new ArrayList<>(); // vo商品列表
Boolean selectedAll = true;// 全选
BigDecimal cartTotalPrice = BigDecimal.ZERO;// 总价格
Integer cartTotalQuantity = 0;// 总数
for (Product product : products) {
// 通过productId取出entries中的当前对应的Cart
Cart curCart = gson.fromJson(entries.get(product.getId()),Cart.class);
CartProductVo cartProductVo = new CartProductVo();
cartProductVo.setProductId(product.getId());
cartProductVo.setQuantity(curCart.getQuantity());
cartProductVo.setProductName(product.getName());
cartProductVo.setProductSubtitle(product.getSubtitle());
cartProductVo.setProductMainImage(product.getMainImage());
cartProductVo.setProductPrice(product.getPrice());
cartProductVo.setProductStatus(product.getStatus());
cartProductVo.setProductStock(product.getStock());
cartProductVo.setProductTotalPrice(product.getPrice().multiply(BigDecimal.valueOf(curCart.getQuantity())));
cartProductVo.setProductSelected(curCart.getProductSelected());
// CartVo类的参数修改:
// 注意这里有业务要求: 结合现实的购物车
// 关于在购物车里面的商品是否还在售的状态,即status:
// 商品列表不受影响,无论在售与否都要返回给前端显示
// 商品数量也不受影响,同上理
// 商品是否是全选, 由于不在售的商品没有选择可言,所以不能影响商品的全选属性
// 商品的总价格,肯定是求在售的商品的
cartProductVoList.add(cartProductVo);
cartTotalQuantity = cartTotalQuantity + cartProductVo.getQuantity();
if(cartProductVo.getProductStatus().equals(ProductStatusEnum.ON_SALE.getCode()) && cartProductVo.getProductSelected() == false) {
selectedAll = false;
}
// 业务要求: 总价格只求被选中的
if(cartProductVo.getProductStatus().equals(ProductStatusEnum.ON_SALE.getCode()) && cartProductVo.getProductSelected()) {
cartTotalPrice = cartTotalPrice.add(cartProductVo.getProductTotalPrice());
}
}
// 遍历结束后,设置CartVo作为返回的data
CartVo cartVo = new CartVo(cartProductVoList,selectedAll,cartTotalPrice,cartTotalQuantity);
return ResponseVo.success(cartVo);
}
@Override
public ResponseVo updateCartItem(Integer userId,Integer productId,CartUpdateForm cartUpdateForm) {
HashOperations hashOperations = redisTemplate.opsForHash();
String value = hashOperations.get(userId, productId);
// 如果redis原来不存在该商品,则报错
if(StringUtils.isNullOrEmpty(value)) {
return ResponseVo.error(ResponseEnum.CART_NOT_EXISTS);
}
// 如果存在,修改其内容
Cart cart = gson.fromJson(value,Cart.class);
if(cartUpdateForm.getQuantity() != null && cartUpdateForm.getQuantity() >= 0) {
cart.setQuantity(cartUpdateForm.getQuantity());
}
if(cartUpdateForm.getSelected() != null) {
cart.setProductSelected(cartUpdateForm.getSelected());
}
// 写入redis数据库
hashOperations.put(userId,productId,gson.toJson(cart));
return getCartList(userId);
}
@Override
public ResponseVo deleteCartItem(Integer userId, Integer productId) {
HashOperations hashOperations = redisTemplate.opsForHash();
String value = hashOperations.get(userId, productId);
// 如果redis原来不存在该商品,则报错
if(StringUtils.isNullOrEmpty(value)) {
return ResponseVo.error(ResponseEnum.CART_NOT_EXISTS);
}
// 如果存在,删除
hashOperations.delete(userId,productId);
return getCartList(userId);
}
@Override
public ResponseVo selectAll(Integer userId) {
HashOperations hashOperations = redisTemplate.opsForHash();
for(Cart cart : getCarts(userId)) {
cart.setProductSelected(true);
hashOperations.put(userId,cart.getProductId(),gson.toJson(cart));
}
return getCartList(userId);
}
@Override
public ResponseVo unSelectAll(Integer userId) {
HashOperations hashOperations = redisTemplate.opsForHash();
for(Cart cart : getCarts(userId)) {
cart.setProductSelected(false);
hashOperations.put(userId,cart.getProductId(),gson.toJson(cart));
}
return getCartList(userId);
}
@Override
public ResponseVo sum(Integer userId) {
Integer totalQuantity = 0;
for(Cart cart : getCarts(userId)) {
totalQuantity = totalQuantity + cart.getQuantity();
}
return ResponseVo.success(totalQuantity);
}
// 查询出所有的购物车内所有Cart列表
private List getCarts(Integer userId) {
HashOperations hashOperations = redisTemplate.opsForHash();
Map entries = hashOperations.entries(userId);
List list = new ArrayList<>();
for(Map.Entry entry : entries.entrySet()) {
list.add(gson.fromJson(entry.getValue(),Cart.class));
}
return list;
}
}
```
* Controller层:
```java
@RestController
public class CartController {
@Autowired
private CartService cartService;
@GetMapping("/carts")
public ResponseVo getCarts(HttpSession httpSession) {
User user = (User) httpSession.getAttribute(MallConst.CURRENT_USER);
return cartService.getCartList(user.getId());
}
@PostMapping("/carts")
public ResponseVo addCart(@Valid @RequestBody CartAddForm cartAddForm,
HttpSession httpSession) {
User user = (User) httpSession.getAttribute(MallConst.CURRENT_USER);
return cartService.addCartItem(user.getId(),cartAddForm);
}
@PutMapping("/carts/{productId}")
public ResponseVo updateCart(@PathVariable("productId") Integer productId,
CartUpdateForm cartUpdateForm,
HttpSession httpSession) {
User user = (User) httpSession.getAttribute(MallConst.CURRENT_USER);
return cartService.updateCartItem(user.getId(),productId,cartUpdateForm);
}
@DeleteMapping("/carts/{productId}")
public ResponseVo deleteCart(@PathVariable("productId") Integer productId,
HttpSession httpSession) {
User user = (User) httpSession.getAttribute(MallConst.CURRENT_USER);
return cartService.deleteCartItem(user.getId(),productId);
}
@PutMapping("/carts/selectAll")
public ResponseVo selectAll(HttpSession httpSession) {
User user = (User) httpSession.getAttribute(MallConst.CURRENT_USER);
return cartService.selectAll(user.getId());
}
@PutMapping("/carts/unSelectAll")
public ResponseVo unSelectAll(HttpSession httpSession) {
User user = (User) httpSession.getAttribute(MallConst.CURRENT_USER);
return cartService.unSelectAll(user.getId());
}
@GetMapping("/carts/products/sum")
public ResponseVo getCartCount(HttpSession httpSession) {
User user = (User) httpSession.getAttribute(MallConst.CURRENT_USER);
return cartService.sum(user.getId());
}
}
```
* @Valid搭配@NotNull,@NotEmpty,@NotBlank三大注解 以及 BindingResult类接收错误信息的 自动化判断参数 的改进:
* 之前: 在用到@Valid参数的controller方法处新增一个参数BindingResult类来接收错误信息并进行处理

* 现在:
由于大多数报错处理都一样,都是告知前端: 参数错误
同时我们发现@Valid自动检查会报错同一个异常: MethodArgumentNotValidException
然后SpringBoot会发起/error请求, 然后抛出该异常
所以我们可以:
1) 在Interceptor配置中放行/error
2) 在全局异常处理器里面统一处理该异常
从而就可以统一处理之前每个controller方法都要写的BindingResult类参数接收错误信息并处理的代码了



* 心得:
* 多去了解一些封装好的东西的实现原理,这样可以多使得代码更优雅
例如:@Valid与BindingResult的自动检测的报错原理,使得我们可以统一处理
* Redis的数据类型虽然只有五个,但是实际上基本所有情况都可以通过好设计而实现存储
* 像购物车等经常要被访问,修改等的, 放在Redis缓存数据库, 可以大大加快系统效率
* 进阶:
* 把持久性数据库与Redis数据库进行一定的联系:
* Redis作为系统运行时的存储,即:只要使用过了就放在Redis里面, 如果用户查询可以很快获得
* MongoDB,MySQL等持久性数据库作为持久化存储, 即:
* 平时数据持久化存储在持久性数据库里面, 如果那些常用数据,例如购物车等 , 用户查询过一次后, 就放在Redis里面方便之后用户读取
* 如果Redis的数据涉及到修改, 则必须实时同步到持久性数据库里面(如果只是查询则无需写入)
## 收货地址模块:
* 接口文档:
* 添加地址
** POST /shippings
> request
```
receiverName=廖师兄
receiverPhone=010
receiverMobile=18688888888
receiverProvince=北京
receiverCity=北京市
receiverDistrict=海淀区
receiverAddress=中关村
receiverZip=100000
```
> response
success
```
{
"status": 0,
"msg": "新建地址成功",
"data": {
"shippingId": 28
}
}
```
fail
```
{
"status": 1,
"msg": "新建地址失败"
}
```
* 删除地址
**DELETE /shippings/{shippingId}
DELETE /shippings/28
> request
```
shippingId
```
> response
success
```
{
"status": 0,
"msg": "删除地址成功"
}
```
fail
```
{
"status": 1,
"msg": "删除地址失败"
}
```
------
* 更新地址
**PUT /shippings/{shippingId}
> request
```
receiverName=廖师兄
receiverPhone=010
receiverMobile=18688888888
receiverProvince=北京
receiverCity=北京市
receiverDistrict=海淀区
receiverAddress=中关村
receiverZip=100000
```
> response
success
```
{
"status": 0,
"msg": "更新地址成功"
}
```
fail
```
{
"status": 1,
"msg": "更新地址失败"
}
```
* 地址列表
**GET /shippings**
> request
```
pageNum(默认1),pageSize(默认10)
```
> response
success
```
{
"status": 0,
"data": {
"pageNum": 1,
"pageSize": 10,
"size": 2,
"orderBy": null,
"startRow": 1,
"endRow": 2,
"total": 2,
"pages": 1,
"list": [
{
"id": 4,
"userId": 13,
"receiverName": "廖师兄",
"receiverPhone": "010",
"receiverMobile": "18688888888",
"receiverProvince": "北京",
"receiverCity": "北京市",
"receiverDistrict": "海淀区",
"receiverAddress": "中关村",
"receiverZip": "100000",
"createTime": 1485066385000,
"updateTime": 1485066385000
},
{
"id": 5,
"userId": 13,
"receiverName": "廖师兄",
"receiverPhone": "010",
"receiverMobile": "18688888888",
"receiverProvince": "北京",
"receiverCity": "北京市",
"receiverDistrict": "海淀区",
"receiverAddress": "中关村",
"receiverZip": "100000",
"createTime": 1485066392000,
"updateTime": 1485075875000
}
],
"firstPage": 1,
"prePage": 0,
"nextPage": 0,
"lastPage": 1,
"isFirstPage": true,
"isLastPage": true,
"hasPreviousPage": false,
"hasNextPage": false,
"navigatePages": 8,
"navigatepageNums": [
1
]
}
}
```
fail
```
{
"status": 1,
"msg": "请登录之后查询"
}
```
* 架构:

* 代码实现:
* 实体类和常量类准备:
观察接口文档,发现前端发来的参数不是全部的Shipping实体类中的字段,所以要新增一个form类:
```java
@Data
public class ShippingForm {
@NotBlank
private String receiverName;
@NotBlank
private String receiverPhone;
@NotBlank
private String receiverMobile;
@NotBlank
private String receiverProvince;
@NotBlank
private String receiverCity;
@NotBlank
private String receiverDistrict;
@NotBlank
private String receiverAddress;
@NotBlank
private String receiverZip;
}
```
我们发现其实返回的内容和实体类Shipping是一模一样的,但是之前说了,为了方便统一规范,一般会建立一个vo类:
```java
@Data
public class ShippingVo {
private Integer id;
private Integer userId;
private String receiverName;
private String receiverPhone;
private String receiverMobile;
private String receiverProvince;
private String receiverCity;
private String receiverDistrict;
private String receiverAddress;
private String receiverZip;
private Date createTime;
private Date updateTime;
}
```
我们发现接口文档存在一处硬编码,就是新增收货地址的返回值的"shippingid",为了避免硬编码,在之前就设立好的MallConst类里面新增属性:
```java
public final static String ADD_SHIPPING_RETURN_SHIPPING_ID = "shippingId";// 添加收货地址时返回的参数名
```
* Dao层:
这里由于insertSelective是一个增操作,返回值是: 影响了几行数据
但是我们实际要的是增加的那一行的自增id, 如果再一次去查, 可以,但是很麻烦,所以我们可以借用mybatis的一个功能:
在sql标签加入useGeneratedKeys="true" keyProperty="id"属性, 从而给传递进来的Shipping对象赋值自增id, 使得Service层可以通过该对象获得id值
```java
public interface ShippingMapper {
int insertSelective(Shipping record);
int deleteByUserIdAndShippingId(@Param("userId") Integer userId,
@Param("shippingId") Integer shippingId);
List selectByUserId(Integer userId);
}
```
```xml
insert into mall_shipping
id,
user_id,
receiver_name,
receiver_phone,
receiver_mobile,
receiver_province,
receiver_city,
receiver_district,
receiver_address,
receiver_zip,
#{id,jdbcType=INTEGER},
#{userId,jdbcType=INTEGER},
#{receiverName,jdbcType=VARCHAR},
#{receiverPhone,jdbcType=VARCHAR},
#{receiverMobile,jdbcType=VARCHAR},
#{receiverProvince,jdbcType=VARCHAR},
#{receiverCity,jdbcType=VARCHAR},
#{receiverDistrict,jdbcType=VARCHAR},
#{receiverAddress,jdbcType=VARCHAR},
#{receiverZip,jdbcType=VARCHAR},
delete from mall_shipping
where id = #{shippingId,jdbcType=INTEGER}
and user_id = #{userId,jdbcType=INTEGER}
```
* Service层:
```java
public interface ShippingService {
/**
* 添加收货地址
*/
ResponseVo