信息系统开发实践 | 系列文章传送门
ISDP001_课程概述
ISDP002_Maven上_创建Maven项目
ISDP003_Maven下_Maven项目依赖配置
ISDP004_创建SpringBoot3项目
ISDP005_Spring组件与自动装配
ISDP006_逻辑架构设计
ISDP007_Springboot日志配置与单元测试
ISDP008_SpringBoot Controller接口文档与测试
ISDP009_基于DDD架构设计ISDP的处理销售用例
ISDP010_基于DDD架构实现收银用例主成功场景
1 面向DDD重构mis-pos模块
重要说明:由于代码量增加,且经常需要重构。笔记将难以展示项目完整代码。本章笔记开始只展示部分代码。完整代码详见笔记最后项目仓库分支代码。
参考上篇分析与设计制品,参考DDD架构,重构的mis-pos模块的架构分层。
根据DDD架构分为application、domain、infrastructure三个包。
2 基础设施层
基础设施层暂时还没有写太多的类。只是添加了SaleFactory用于实例化Sale。
引入Hutool工具类,用于生成订单的雪花ID。
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.20</version>
</dependency>
编写SaleFactory,实例化Sale并设置初始化值。
package edu.scau.mis.pos.infrastructure.factory;
import cn.hutool.core.util.IdUtil;
import edu.scau.mis.pos.domain.entity.Sale;
import edu.scau.mis.pos.domain.enums.SaleStatusEnum;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Date;
/**
* Sale工厂类
*/
@Component
public class SaleFactory {
public Sale initSale()
{
Sale sale = new Sale();
sale.setSaleNo("so-" + IdUtil.getSnowflakeNextId());
sale.setSaleStatus(SaleStatusEnum.CREATED);
sale.setTotalAmount(BigDecimal.ZERO);
sale.setTotalQuantity(0);
sale.setSaleTime(new Date());
return sale;
}
}
3 领域层
在DDD架构中,领域层是重点关注层。
为了简化Setter和getter编写,引入了Lombok。
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
3.1 SaleProduct实体类
SaleProduct实体类包含了业务逻辑方法getSubTotal,计算每个订单明细的小计。
package edu.scau.mis.pos.domain.entity;
import edu.scau.mis.pos.domain.enums.SaleProductStatusEnum;
import lombok.Data;
import java.math.BigDecimal;
/**
* 订单-产品明细实体类
*/
@Data
public class SaleProduct {
private Long saleProductId;
private Long saleId;
private Long productId;
private Product product;
private Integer saleQuantity;
private BigDecimal salePrice;
private SaleProductStatusEnum saleProductStatus;
/**
* 计算小计
* @return
*/
public BigDecimal getSubTotal() {
return salePrice.multiply(new BigDecimal(saleQuantity));
}
}
3.2 支付实体类
支付类暂时还没有写业务逻辑方法。后期考虑通过适配器,连接第三方支付。
package edu.scau.mis.pos.domain.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import edu.scau.mis.pos.domain.enums.PaymentStatusEnum;
import edu.scau.mis.pos.domain.enums.PaymentStrategyEnum;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
* 支付实体类
*/
@Data
public class Payment {
private Long paymentId;
private Long paymentSaleId;
private PaymentStrategyEnum paymentStrategy;
private String paymentNo;
private BigDecimal paymentAmount;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date paymentTime;
private PaymentStatusEnum paymentStatus;
}
3.3 Sale聚合根
Sale类是收银领域层的聚合根。
该类内聚了两个业务逻辑方法,分别为添加订单明细和计算总金额。
package edu.scau.mis.pos.domain.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import edu.scau.mis.pos.domain.enums.SaleStatusEnum;
import lombok.Data;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 销售实体类
* 聚合根
*/
@Data
public class Sale {
private Long saleId;
private String saleNo;
private BigDecimal totalAmount;
private Integer totalQuantity;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date saleTime;
private Payment payment;
private List<SaleProduct> saleProducts = new ArrayList<>();
private SaleStatusEnum saleStatus;
/**
* 计算总金额
* @return
*/
public BigDecimal getTotal(){
totalAmount = BigDecimal.ZERO;
totalQuantity = 0;
for(SaleProduct saleProduct: saleProducts){
totalAmount = totalAmount.add(saleProduct.getSubTotal());
totalQuantity = totalQuantity + saleProduct.getSaleQuantity();
}
return totalAmount;
}
/**
* 添加订单明细
* @param product
* @param saleQuantity
* @return
*/
public List<SaleProduct> makeLineItem(Product product, Integer saleQuantity) {
// 判断商品是否已录入,未录入则新增。已录入则修改数量。
if(!isEntered(product.getProductSn(),saleQuantity)){
SaleProduct saleProduct = new SaleProduct();
saleProduct.setProduct(product);
saleProduct.setSaleQuantity(saleQuantity);
saleProduct.setSalePrice(product.getProductPrice());
saleProducts.add(saleProduct);
}
return saleProducts;
}
/**
* 判断商品是否已录入
* 业务逻辑:如果已录入,则修改数量,否则添加saleLineItem
* @param productSn
* @param saleQuantity
* @return
*/
private boolean isEntered(String productSn, Integer saleQuantity){
boolean flag = false;
for(SaleProduct sp : saleProducts){
if(productSn.equals(sp.getProduct().getProductSn())) {
flag = true;
Integer quantityOriginal = sp.getSaleQuantity();
sp.setSaleQuantity(quantityOriginal + saleQuantity);
}
}
return flag;
}
}
3.4 领域服务类SaleService
主要用于生成支付功能。ISDP项目POS系统设计支持挂单功能。
package edu.scau.mis.pos.domain.service.impl;
import cn.hutool.core.util.IdUtil;
import edu.scau.mis.pos.domain.entity.Payment;
import edu.scau.mis.pos.domain.entity.Sale;
import edu.scau.mis.pos.domain.enums.PaymentStatusEnum;
import edu.scau.mis.pos.domain.enums.PaymentStrategyEnum;
import edu.scau.mis.pos.domain.service.ISaleService;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.Date;
/**
* 领域服务
*/
@Service
public class SaleServiceImpl implements ISaleService {
@Override
public Payment makePayment(Sale sale, String paymentStrategy, BigDecimal paymentAmount) {
Payment payment = new Payment();
payment.setPaymentStrategy(PaymentStrategyEnum.valueOf(paymentStrategy));
payment.setPaymentNo(paymentStrategy + "-" + IdUtil.getSnowflakeNextId());
payment.setPaymentAmount(paymentAmount);
payment.setPaymentTime(new Date());
payment.setPaymentStatus(PaymentStatusEnum.PAID);
// TODO: 根据不同支付策略调用不同外部接口
return payment;
}
}
3.5 其他
Domain层还有仓库、枚举等包。由于暂时还没有使用数据库和Redis,仓库代码暂时没写。
写了一些枚举类。由于只是教学项目,没有设计过多状态。
package edu.scau.mis.pos.domain.enums;
/**
* 订单状态枚举
*/
public enum SaleStatusEnum {
CREATED("0","已预订"),
SUBMITTED("1","已提交"),
PAID("2","已支付");
private String value;
private String label;
SaleStatusEnum(String value, String label) {
this.value = value;
this.label = label;
}
public String getLabel() {
return label;
}
public String getValue() {
return value;
}
/**
* 根据匹配value的值获取Label
*
* @param value
* @return
*/
public static String getLabelByValue(String value){
for (SaleStatusEnum s : SaleStatusEnum.values()) {
if(value.equals(s.getValue())){
return s.getLabel();
}
}
return "";
}
/**
* 获取StatusEnum
*
* @param value
* @return
*/
public static SaleStatusEnum getStatusEnum(String value){
for (SaleStatusEnum s : SaleStatusEnum.values()) {
if(value.equals(s.getValue())){
return s;
}
}
return null;
}
}
支付策略枚举类
package edu.scau.mis.pos.domain.enums;
/**
* 支付策略枚举
*/
public enum PaymentStrategyEnum {
WECHAT("wechat","微信支付"),
ALIPAY("alipay","支付宝"),
CASH("cash","现金");
private String value;
private String label;
PaymentStrategyEnum(String value, String label) {
this.value = value;
this.label = label;
}
public String getLabel() {
return label;
}
public String getValue() {
return value;
}
/**
* 根据匹配value的值获取Label
*
* @param value
* @return
*/
public static String getLabelByValue(String value){
for (PaymentStrategyEnum s : PaymentStrategyEnum.values()) {
if(value.equals(s.getValue())){
return s.getLabel();
}
}
return "";
}
/**
* 获取StatusEnum
*
* @param value
* @return
*/
public static PaymentStrategyEnum getStrategyEnum(String value){
for (PaymentStrategyEnum s : PaymentStrategyEnum.values()) {
if(value.equals(s.getValue())){
return s;
}
}
return null;
}
}
4 应用层
4.1 应用服务类SaleApplicationService
编写SaleApplicationService类。该类主要负责跨领域协作。
目前主要就两个领域Sale(SaleProduct、Payment)和Product(Category)。
如果使用微服务,可以分别针对这两个领域创建两个微服务模块。
package edu.scau.mis.pos.application.service;
import edu.scau.mis.pos.application.assembler.SaleAssembler;
import edu.scau.mis.pos.application.dto.command.EnterItemCommand;
import edu.scau.mis.pos.application.dto.command.MakePaymentCommand;
import edu.scau.mis.pos.application.dto.vo.*;
import edu.scau.mis.pos.domain.entity.Payment;
import edu.scau.mis.pos.domain.entity.Product;
import edu.scau.mis.pos.domain.entity.Sale;
import edu.scau.mis.pos.domain.entity.SaleProduct;
import edu.scau.mis.pos.domain.enums.SaleStatusEnum;
import edu.scau.mis.pos.domain.service.IProductService;
import edu.scau.mis.pos.infrastructure.factory.SaleFactory;
import edu.scau.mis.pos.domain.service.ISaleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class SaleApplicationService {
@Autowired
private IProductService productService;
@Autowired
private ISaleService saleService;
@Autowired
private SaleFactory saleFactory;
@Autowired
private SaleAssembler saleAssembler;
private Sale currentSale; // 后期改成Redis缓存CurrentSale
/**
* 开始一次新销售
* @return
*/
public SaleVo makeNewSale(){
SaleVo saleVo = new SaleVo();
currentSale = saleFactory.initSale();
// TODO:引入Redis缓存
return saleAssembler.toSaleVo(currentSale);
}
/**
* 录入商品
* @param command
* @return
*/
public SaleAndProductListVo enterItem(EnterItemCommand command){
SaleAndProductListVo saleAndProductListVo = new SaleAndProductListVo();
Product product = productService.selectProductBySn(command.getProductSn());
List<SaleProduct> saleProducts = currentSale.makeLineItem(product, command.getSaleQuantity());
currentSale.getTotal();
List<SaleProductVo> saleProductVoList = saleProducts.stream()
.map(saleProduct -> new SaleProductVo(saleProduct.getProduct().getProductSn(), saleProduct.getProduct().getProductName(), saleProduct.getSalePrice(), saleProduct.getSaleQuantity()))
.toList();
saleAndProductListVo.setSaleVo(saleAssembler.toSaleVo(currentSale));
saleAndProductListVo.setSaleProductVoList(saleProductVoList);
return saleAndProductListVo;
}
/**
* 结束销售
* 计算优惠、持久化订单等
* @return
*/
public SaleVo endSale(){
currentSale.setSaleStatus(SaleStatusEnum.SUBMITTED);
// TODO: 持久化Sale和SaleProduct,添加事务注解
return saleAssembler.toSaleVo(currentSale);
}
/**
* 完成支付
* @param command
* @return
*/
public SaleAndPaymentVo makePayment(MakePaymentCommand command){
SaleAndPaymentVo saleAndPaymentVo = new SaleAndPaymentVo();
// TODO: 挂单--根据saleNo获取Sale
Payment payment = saleService.makePayment(currentSale,command.getPaymentStrategy(), command.getPaymentAmount());
currentSale.setPayment(payment);
currentSale.setSaleStatus(SaleStatusEnum.PAID);
// TODO: 持久化Sale和Payment,添加事务注解
// payment.setPaymentSaleId(sale.getSaleId());
saleAndPaymentVo.setSaleVo(saleAssembler.toSaleVo(currentSale));
saleAndPaymentVo.setPaymentVo(saleAssembler.toPaymentVo(payment));
return saleAndPaymentVo;
}
}
4.2 数据传输对象DTO
ISDP项目采用CQRS思想,该层编写大量的数据传输对象DTO。笔记只展示部分代码。详细参加项目仓库。
EnterItemCommand参考代码如下。
后期将使用Redis缓存currentSale,设计saleNo作为key。保留saleNo备用。
package edu.scau.mis.pos.application.dto.command;
import lombok.Data;
import java.io.Serializable;
/**
* 输入订单明细命令
*/
@Data
public class EnterItemCommand implements Serializable {
private String saleNo;
private String productSn;
private Integer saleQuantity;
}
MakePaymentCommand代码参考如下:
同上,saleNo暂时不需要。
package edu.scau.mis.pos.application.dto.command;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 创建支付命令
*/
@Data
public class MakePaymentCommand implements Serializable {
private String saleNo;
private BigDecimal paymentAmount;
private String paymentStrategy;
}
SaleVo类
package edu.scau.mis.pos.application.dto.vo;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
@Data
public class SaleVo implements Serializable {
private String saleNo;
private BigDecimal totalAmount;
private Integer totalQuantity;
private Date saleTime;
private String saleStatus;
}
SaleAndPaymentVo
package edu.scau.mis.pos.application.dto.vo;
import lombok.Data;
import java.io.Serializable;
@Data
public class SaleAndPaymentVo implements Serializable {
private SaleVo saleVo;
private PaymentVo paymentVo;
}
4.3 对象转换器SaleAssembler
面向接口层主要使用DTO对象,因此不可避免涉及到DTO与领域对象的转换。
package edu.scau.mis.pos.application.assembler;
import edu.scau.mis.pos.application.dto.vo.PaymentVo;
import edu.scau.mis.pos.application.dto.vo.SaleVo;
import edu.scau.mis.pos.domain.entity.Payment;
import edu.scau.mis.pos.domain.entity.Sale;
import org.springframework.stereotype.Component;
/**
* 订单转换器
* 实现DTO与Entity的转换
*/
@Component
public class SaleAssembler {
public SaleVo toSaleVo(Sale sale){
SaleVo saleVo = new SaleVo();
saleVo.setSaleNo(sale.getSaleNo());
saleVo.setTotalAmount(sale.getTotalAmount());
saleVo.setTotalQuantity(sale.getTotalQuantity());
saleVo.setSaleTime(sale.getSaleTime());
saleVo.setSaleStatus(sale.getSaleStatus().getLabel());
return saleVo;
}
public PaymentVo toPaymentVo(Payment payment){
PaymentVo paymentVo = new PaymentVo();
paymentVo.setPaymentId(payment.getPaymentId());
paymentVo.setPaymentSaleId(payment.getPaymentSaleId());
paymentVo.setPaymentNo(payment.getPaymentNo());
paymentVo.setPaymentAmount(payment.getPaymentAmount());
paymentVo.setPaymentTime(payment.getPaymentTime());
paymentVo.setPaymentStrategy(payment.getPaymentStrategy().getLabel());
paymentVo.setPaymentStatus(payment.getPaymentStatus().getLabel());
return paymentVo;
}
}
5 接口层
5.1 Controller接口
SaleController参考如下:
package edu.scau.mis.web.controller;
import edu.scau.mis.pos.application.dto.command.EnterItemCommand;
import edu.scau.mis.pos.application.dto.command.MakePaymentCommand;
import edu.scau.mis.pos.application.dto.vo.SaleAndPaymentVo;
import edu.scau.mis.pos.application.dto.vo.SaleAndProductListVo;
import edu.scau.mis.pos.application.dto.vo.SaleVo;
import edu.scau.mis.pos.application.service.SaleApplicationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/sale")
public class SaleController {
@Autowired
private SaleApplicationService saleApplicationService;
@GetMapping("/makeNewSale")
public ResponseEntity<SaleVo> makeNewSale()
{
return ResponseEntity.ok(saleApplicationService.makeNewSale());
}
@PostMapping("/enterItem")
public ResponseEntity<SaleAndProductListVo> enterItem(@RequestBody EnterItemCommand enterItemCommand)
{
return ResponseEntity.ok(saleApplicationService.enterItem(enterItemCommand));
}
@GetMapping("/endSale")
public ResponseEntity<SaleVo> endSale()
{
return ResponseEntity.ok(saleApplicationService.endSale());
}
@PostMapping("/makePayment")
public ResponseEntity<SaleAndPaymentVo> makePayment(@RequestBody MakePaymentCommand makePaymentCommand)
{
return ResponseEntity.ok(saleApplicationService.makePayment(makePaymentCommand));
}
}
5.2 接口测试
使用Knife4j对SaleController接口进行测试,简单验证后端业务逻辑。
5.2.1 makeNewSale接口
该接口目前只是初始化currentSale数据。
5.2.2 enterItem接口
接口接收产品编号和订购数量。
接口返回订单和订购商品集合的json数据。
5.2.3 endSale接口
该接口暂时未写太多业务逻辑,只是提交订单,更新订单状。
后期将会从redis中清除缓存currentSale,然后持久化currentSale数据。
5.2.4 makePayment接口
接口接收支付金额和支付方式两个参数。
接口返回订单和支付json数据。
本章笔记基于上篇的分析与设计模型,编写DDD架构基础设施层、领域层、应用层和接口层的代码。实现了收银用例的4个主要步骤makeNewSale、enterItem、endSale和makePayment。
下一篇笔记将应用适配器模式调用支付宝沙箱支付接口。
本笔记项目仓库地址:
https://gitcode.com/tiger2704/isdp-boot3/tree/isdp010