0%

通用型支付系统接入电商平台

一个典型的电商平台,简要记录一下,,,主要是为了测试支付系统

主要分为以下5个模块

  1. 用户模块
  2. 分类模块
  3. 购物车模块
  4. 收货地址模块
  5. 订单模块

项目地址:https://github.com/liuurick/mall-pay/tree/master/mall

用户模块

用户模块开发中的几点总结:

1.MD5加密的方法

MD5摘要算法(Spring自带)

1
2
3
user.setPassword(DigestUtils.md5DigestAsHex(
user.getPassword().getBytes(StandardCharsets.UTF_8)
));

2.单元测试:service层

单元测试的时候一般只需要测试service层,可以在idea中直接右键goto Test—>create new Test

对于系统项目的开发,开发步骤一般为dao–>service–>controller,所以没有必要测试其他两层。

3.Mybatis打印SQL语句

控制台配置 Mybatis打印SQL语句

1
2
3
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

4.错误状态码使用枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Getter
public enum ResponseEnum {

ERROR(-1, "服务端错误"),

SUCCESS(0, "成功"),

PASSWORD_ERROR(1,"密码错误"),

USERNAME_EXIST(2, "用户名已存在"),

PARAM_ERROR(3, "参数错误"),

EMAIL_EXIST(4, "邮箱已存在"),

NEED_LOGIN(10, "用户未登录, 请先登录"),

USERNAME_OR_PASSWORD_ERROR(11, "用户名或密码错误"),

PRODUCT_OFF_SALE_OR_DELETE(12, "商品下架或删除"),

PRODUCT_NOT_EXIST(13, "商品不存在"),

PROODUCT_STOCK_ERROR(14, "库存不正确"),

CART_PRODUCT_NOT_EXIST(15, "购物车里无此商品"),

DELETE_SHIPPING_FAIL(16, "删除收货地址失败"),

SHIPPING_NOT_EXIST(17, "收货地址不存在"),

CART_SELECTED_IS_EMPTY(18, "请选择商品后下单"),

ORDER_NOT_EXIST(19, "订单不存在"),

ORDER_STATUS_ERROR(20, "订单状态有误"),

;

Integer code;

String desc;

ResponseEnum(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
}

5.session和cookie

彻底弄懂session,cookie,token:https://segmentfault.com/a/1190000017831088

对于cookie与session,第一印象就是session是服务器端技术,cookie是客户端技术。一般情况下,由于cookie具备安全性问题和很多游览器禁用cookie,我们平时使用session更广泛一些。但是其实session与cookie不分家,sessionId存储在cookie中。

另外还有一个重点,就是cookie跨域问题,localhost与127.0.0.1就是跨域

解决cookie跨域访问:https://www.cnblogs.com/hujunzheng/p/5744755.html

6.统一判断登录状态-拦截器

过滤器+监听器+拦截器+AOP 比较:https://segmentfault.com/a/1190000021823564

对于拦截器,主要有两种技术:

Interceptor 基于http的url 比较简单

AOP 基于包名 ,功能比较强大

这里使用Interceptor实现:

InterceptorConfig:

1
2
3
4
5
6
7
8
9
10
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new UserLoginInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/error", "/user/login", "/user/register", "/categories", "/products", "/products/*");
}
}

UserLoginInterceptor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Slf4j
public class UserLoginInterceptor implements HandlerInterceptor {

/**
* true表示继续流程,false表示中断
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("preHandle...");
User user = (User) request.getSession().getAttribute(MallConst.CURRENT_USER);
if (user == null) {
log.info("user=null");
throw new UserLoginException();
}
return true;
}
}

分类模块

完成分类管理模块功能的开发,并使用递归分类,支持分类无限层级树状结构。按照dao->service->api的顺序开发

对于商品分类的遍历有两种方法:

1.先查出1级目录–》查其子目录,一直查到的是null

2.查出目录–》查父目录,一直查到parent_id=0

这里使用的是第1种

categoryVo

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
public class CategoryVo {

private Integer id;

private Integer parentId;

private String name;

private Integer sortOrder;

private List<CategoryVo> subCategories;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public ResponseVo<List<CategoryVo>> selectAll() {
List<Category> categories = categoryMapper.selectAll();

List<CategoryVo> categoryVoList = new ArrayList<>();
//查出parent_id=0
for (Category category: categories) {
if (categories.equals(ROOT_PARENT_ID)){
CategoryVo categoryVo = new CategoryVo();
BeanUtils.copyProperties(category,categoryVo);
categoryVoList.add(categoryVo);
}
}

//查询子目录
findSubCategory(categoryVoList, categories);
return ResponseVo.success(categoryVoList);
}

遍历子分类,用到了递归思想

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
private void findSubCategory(List<CategoryVo> categoryVoList, List<Category> categories) {
for (CategoryVo categoryVo : categoryVoList) {
List<CategoryVo> subCategoryVoList = new ArrayList<>();

for (Category category : categories) {
//如果查到内容,设置subCategory, 继续往下查
if (categoryVo.getId().equals(category.getParentId())) {
CategoryVo subCategoryVo = category2CategoryVo(category);
subCategoryVoList.add(subCategoryVo);
}
//排序
subCategoryVoList.sort(Comparator.comparing(CategoryVo::getSortOrder).reversed());
categoryVo.setSubCategories(subCategoryVoList);

findSubCategory(subCategoryVoList, categories);
}
}
}

@Override
public void findSubCategoryId(Integer id, Set<Integer> resultSet) {
List<Category> categories = categoryMapper.selectAll();
findSubCategoryId(id, resultSet, categories);
}

private void findSubCategoryId(Integer id, Set<Integer> resultSet, List<Category> categories) {
for (Category category : categories) {
if (category.getParentId().equals(id)) {
resultSet.add(category.getId());
findSubCategoryId(category.getId(), resultSet, categories);
}
}
}

private CategoryVo category2CategoryVo(Category category) {
CategoryVo categoryVo = new CategoryVo();
BeanUtils.copyProperties(category, categoryVo);
return categoryVo;
}

使用lambda + stream对代码进行优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public ResponseVo<List<CategoryVo>> selectAll() {
List<Category> categories = categoryMapper.selectAll();

//lambda + stream
List<CategoryVo> categoryVoList = categories.stream()
.filter(e -> e.getParentId().equals(ROOT_PARENT_ID))
.map(this::category2CategoryVo)
.sorted(Comparator.comparing(CategoryVo::getSortOrder).reversed())
.collect(Collectors.toList());

//查询子目录
findSubCategory(categoryVoList, categories);

return ResponseVo.success(categoryVoList);
}

商品模块

完成商品列表、详情页、分页的开发

1.商品列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public ResponseVo<PageInfo> list(Integer categoryId, Integer pageNum, Integer pageSize) {
Set<Integer> categoryIdSet = new HashSet<>();
if (categoryId != null) {
categoryService.findSubCategoryId(categoryId, categoryIdSet);
categoryIdSet.add(categoryId);
}

//分页功能
PageHelper.startPage(pageNum, pageSize);
List<Product> productList = productMapper.selectByCategoryIdSet(categoryIdSet);
List<ProductVo> productVoList = productList.stream()
.map(e -> {
ProductVo productVo = new ProductVo();
BeanUtils.copyProperties(e, productVo);
return productVo;
})
.collect(Collectors.toList());

PageInfo pageInfo = new PageInfo<>(productList);
pageInfo.setList(productVoList);
return ResponseVo.success(pageInfo);
}

2.商品详情页

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public ResponseVo<ProductDetailVo> detail(Integer productId) {
Product product = productMapper.selectByPrimaryKey(productId);

//只对确定性条件判断
if (product.getStatus().equals(OFF_SALE.getCode())
|| product.getStatus().equals(DELETE.getCode())) {
return ResponseVo.error(PRODUCT_OFF_SALE_OR_DELETE);
}

ProductDetailVo productDetailVo = new ProductDetailVo();
BeanUtils.copyProperties(product, productDetailVo);
//敏感数据处理
productDetailVo.setStock(product.getStock() > 100 ? 100 : product.getStock());
return ResponseVo.success(productDetailVo);
}

3.分页功能

基于SpringBoot集成的mybatis-pagehelper进行分页实践。

POJO、BO、VO的关系与解决思路

购物车模块

对于购物车模块

  • Redis(高性能)
  • MongoDB(海量数据)
  • ElasticSearch/HBase(大数据)

这里我选用Redis完成购物车开发,首先基于传统的String结构完成购物车开发,然后,进行优化重构,基于Hashset数据结构替换,最终实现高性能购物车

CartVo

1
2
3
4
5
6
7
8
9
10
11
12
@Override
@Data
public class CartVo {

private List<CartProductVo> cartProductVoList;

private Boolean selectedAll;

private BigDecimal cartTotalPrice;

private Integer cartTotalQuantity;
}

CartProductVo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@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;

/**
* 等于 quantity * productPrice
*/
private BigDecimal productTotalPrice;

private Integer productStock;

/**
* 商品是否选中
*/
private Boolean productSelected;

public CartProductVo(Integer productId, Integer quantity, String productName, String productSubtitle, String productMainImage, BigDecimal productPrice, Integer productStatus, BigDecimal productTotalPrice, Integer productStock, Boolean productSelected) {
this.productId = productId;
this.quantity = quantity;
this.productName = productName;
this.productSubtitle = productSubtitle;
this.productMainImage = productMainImage;
this.productPrice = productPrice;
this.productStatus = productStatus;
this.productTotalPrice = productTotalPrice;
this.productStock = productStock;
this.productSelected = productSelected;
}
}

商品添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Override
public ResponseVo<CartVo> add(Integer uid, CartAddForm form) {
Integer quantity = 1;

Product product = productMapper.selectByPrimaryKey(form.getProductId());

//商品是否存在
if (product == null) {
return ResponseVo.error(ResponseEnum.PRODUCT_NOT_EXIST);
}

//商品是否正常在售
if (!product.getStatus().equals(ProductStatusEnum.ON_SALE.getCode())) {
return ResponseVo.error(ResponseEnum.PRODUCT_OFF_SALE_OR_DELETE);
}

//商品库存是否充足
if (product.getStock() <= 0) {
return ResponseVo.error(ResponseEnum.PROODUCT_STOCK_ERROR);
}

//写入到redis
//key: cart_1
HashOperations<String, String, String> opsForHash = redisTemplate.opsForHash();
String redisKey = String.format(CART_REDIS_KEY_TEMPLATE, uid);

Cart cart;
String value = opsForHash.get(redisKey, String.valueOf(product.getId()));
if (StringUtils.isEmpty(value)) {
//没有该商品, 新增
cart = new Cart(product.getId(), quantity, form.getSelected());
}else {
//已经有了,数量+1
cart = gson.fromJson(value, Cart.class);
cart.setQuantity(cart.getQuantity() + quantity);
}

opsForHash.put(redisKey,
String.valueOf(product.getId()),
gson.toJson(cart));

return list(uid);
}

商品遍历

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Override
public ResponseVo<CartVo> list(Integer uid) {
HashOperations<String, String, String> opsForHash = redisTemplate.opsForHash();
String redisKey = String.format(CART_REDIS_KEY_TEMPLATE, uid);
Map<String, String> entries = opsForHash.entries(redisKey);

boolean selectAll = true;
Integer cartTotalQuantity = 0;
BigDecimal cartTotalPrice = BigDecimal.ZERO;
CartVo cartVo = new CartVo();
List<CartProductVo> cartProductVoList = new ArrayList<>();
for (Map.Entry<String, String> entry : entries.entrySet()) {
Integer productId = Integer.valueOf(entry.getKey());
Cart cart = gson.fromJson(entry.getValue(), Cart.class);

//TODO 需要优化,使用mysql里的in
Product product = productMapper.selectByPrimaryKey(productId);
if (product != null) {
CartProductVo cartProductVo = new CartProductVo(productId,
cart.getQuantity(),
product.getName(),
product.getSubtitle(),
product.getMainImage(),
product.getPrice(),
product.getStatus(),
product.getPrice().multiply(BigDecimal.valueOf(cart.getQuantity())),
product.getStock(),
cart.getProductSelected()
);
cartProductVoList.add(cartProductVo);

if (!cart.getProductSelected()) {
selectAll = false;
}

//计算总价(只计算选中的)
if (cart.getProductSelected()) {
cartTotalPrice = cartTotalPrice.add(cartProductVo.getProductTotalPrice());
}
}

cartTotalQuantity += cart.getQuantity();
}

//有一个没有选中,就不叫全选
cartVo.setSelectedAll(selectAll);
cartVo.setCartTotalQuantity(cartTotalQuantity);
cartVo.setCartTotalPrice(cartTotalPrice);
cartVo.setCartProductVoList(cartProductVoList);
return ResponseVo.success(cartVo);
}

商品更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public ResponseVo<CartVo> update(Integer uid, Integer productId, CartUpdateForm form) {
HashOperations<String, String, String> opsForHash = redisTemplate.opsForHash();
String redisKey = String.format(CART_REDIS_KEY_TEMPLATE, uid);

String value = opsForHash.get(redisKey, String.valueOf(productId));
if (StringUtils.isEmpty(value)) {
//没有该商品, 报错
return ResponseVo.error(ResponseEnum.CART_PRODUCT_NOT_EXIST);
}

//已经有了,修改内容
Cart cart = gson.fromJson(value, Cart.class);
if (form.getQuantity() != null
&& form.getQuantity() >= 0) {
cart.setQuantity(form.getQuantity());
}
if (form.getSelected() != null) {
cart.setProductSelected(form.getSelected());
}

opsForHash.put(redisKey, String.valueOf(productId), gson.toJson(cart));
return list(uid);
}

商品删除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public ResponseVo<CartVo> delete(Integer uid, Integer productId) {
HashOperations<String, String, String> opsForHash = redisTemplate.opsForHash();
String redisKey = String.format(CART_REDIS_KEY_TEMPLATE, uid);

String value = opsForHash.get(redisKey, String.valueOf(productId));
if (StringUtils.isEmpty(value)) {
//没有该商品, 报错
return ResponseVo.error(ResponseEnum.CART_PRODUCT_NOT_EXIST);
}

opsForHash.delete(redisKey, String.valueOf(productId));
return list(uid);
}

商品的选择

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Override
public ResponseVo<CartVo> selectAll(Integer uid) {
HashOperations<String, String, String> opsForHash = redisTemplate.opsForHash();
String redisKey = String.format(CART_REDIS_KEY_TEMPLATE, uid);

for (Cart cart : listForCart(uid)) {
cart.setProductSelected(true);
opsForHash.put(redisKey,
String.valueOf(cart.getProductId()),
gson.toJson(cart));
}

return list(uid);
}

@Override
public ResponseVo<CartVo> unSelectAll(Integer uid) {
HashOperations<String, String, String> opsForHash = redisTemplate.opsForHash();
String redisKey = String.format(CART_REDIS_KEY_TEMPLATE, uid);

for (Cart cart : listForCart(uid)) {
cart.setProductSelected(false);
opsForHash.put(redisKey,
String.valueOf(cart.getProductId()),
gson.toJson(cart));
}

return list(uid);
}

收货地址模块

Spring数据绑定中的对象绑定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Override
public ResponseVo<Map<String, Integer>> add(Integer uid, ShippingForm form) {
Shipping shipping = new Shipping();
BeanUtils.copyProperties(form, shipping);
shipping.setUserId(uid);
int row = shippingMapper.insertSelective(shipping);
if (row == 0) {
return ResponseVo.error(ResponseEnum.ERROR);
}

Map<String, Integer> map = new HashMap<>();
map.put("shippingId", shipping.getId());

return ResponseVo.success(map);
}

@Override
public ResponseVo delete(Integer uid, Integer shippingId) {
int row = shippingMapper.deleteByIdAndUid(uid, shippingId);
if (row == 0) {
return ResponseVo.error(ResponseEnum.DELETE_SHIPPING_FAIL);
}

return ResponseVo.success();
}

@Override
public ResponseVo update(Integer uid, Integer shippingId, ShippingForm form) {
Shipping shipping = new Shipping();
BeanUtils.copyProperties(form, shipping);
shipping.setUserId(uid);
shipping.setId(shippingId);
int row = shippingMapper.updateByPrimaryKeySelective(shipping);
if (row == 0) {
return ResponseVo.error(ResponseEnum.ERROR);
}
return ResponseVo.success();
}

@Override
public ResponseVo<PageInfo> list(Integer uid, Integer pageNum, Integer pageSize) {
PageHelper.startPage(pageNum, pageSize);
List<Shipping> shippings = shippingMapper.selectByUid(uid);
PageInfo pageInfo = new PageInfo(shippings);
return ResponseVo.success(pageInfo);
}

订单模块

订单模块接收支付发送的MQ消息,MQ在业务解耦

如何为支付预留扩展性

docker安装rabbitmq:https://liuurick.github.io/2020/07/27/Docker%E5%AE%89%E8%A3%85RabbitMQ/

RabbitMQ的引入

1.引入依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

2.编写配置文件

1
2
3
4
5
6
spring:
rabbitmq:
addresses: 192.168.60.129
port: 5672
username: guest
password: guest

在支付系统中添加

1
2
3
4
5
6
7
@Autowired
private AmqpTemplate amqpTemplate;

private final static String QUEUE_PAY_NOTIFY = "payNotify";

//TODO pay发送MQ消息,mall接受MQ消息
amqpTemplate.convertAndSend(QUEUE_PAY_NOTIFY, new Gson().toJson(payInfo));

在商城系统中添加listener

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
@RabbitListener(queues = "payNotify")
@Slf4j
public class PayMsgListener {

@Autowired
private IOrderService orderService;

@RabbitHandler
public void process(String msg) {
log.info("【接收到消息】=> {}", msg);

PayInfo payInfo = new Gson().fromJson(msg, PayInfo.class);
if (payInfo.getPlatformStatus().equals("SUCCESS")) {
//修改订单里的状态
orderService.paid(payInfo.getOrderNo());
}
}
}