● 用户可以在登录状态下将商品添加到购物车,也可以查询、修改、删除购物车中的商品。
● 用户可以在未登录状态下将商品添加到离线购物车。即使关闭浏览器,离线购物车中的商品也会保留。用户登录后,系统自动将离线购物车中的商品添加到在线购物车中,然后清空离线购物车。
● 用户可以只选中购物车中的一部分商品,选中商品的总价格会实时计算。
● 用户可以结算选中的商品并下单。
● 购物车Cart:
private List
private Integer countNum; // 件数
private Integer countType; // 种类数
private BigDecimal totalAmount; // 总价
private BigDecimal reduce = new BigDecimal("0.00"); // 减免价格
● 商品CartItem:
private Long skuId; // sku ID
private Boolean check = true; // 是否选中
private String title; // 标题
private String image; // 图片
private List
private BigDecimal price; // 单价
private Integer count; // 数量
private BigDecimal totalPrice; // 总价
● 当前用户CurrentUser:
private String userId; // 用户ID
private String userKey; // 用户临时key
private boolean hasTempUser = false; // cookie中是否包含临时用户
● 常量Constant:
public static final String LOGIN_USER_KEY = "currentUser";
public static final String TEMP_USER_COOKIE_NAME = "user-key";
1. 创建购物车拦截器类CartInterceptor。代码示例见3.2节。
1.1 实现HandlerInterceptor接口。
1.2 重写preHandle方法,该方法将在执行业务方法前执行。return true则放行,否则拦截。
1.2.1 业务执行前,从session中获取当前用户currentUser:
● currentUser不为null,说明用户已登录。
● currentUser为null,说明用户未登录,创建一个临时用户。
1.2.2 无论是否已登录,给当前用户分配一个游客标识user-key:
● 如果request携带的cookies中包含了user-key,就把它分配给当前用户。
● 如果cookies中没有user-key,就创建一个uuid作为游客标识分配给当前用户。
● 这里的布尔值hasTempUser用作记录cookies中是否包含user-key。
1.2.3 将当前用户放入threadLocal。
1.2.4 放行。
1.3 重写postHandle方法,该方法将在执行业务方法后执行。
1.3.1 从threadLocal中获取当前用户。
1.3.2 创建cookie。
2. 配置拦截器。
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new CartInterceptor()).addPathPatterns("/**");
}
}
购物车拦截器类CartInterceptor:
public class CartInterceptor implements HandlerInterceptor {
public static ThreadLocal
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
// 从session获取当前用户, 如果为null, 创建新用户
HttpSession session = request.getSession();
CurrentUser currentUser = (CurrentUser) session.getAttribute(LOGIN_USER_KEY);
if (currentUser == null) {
currentUser = new CurrentUser();
}
// 给当前用户分配游客标识
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
String cookieName = cookie.getName();
if (cookieName.equals(TEMP_USER_COOKIE_NAME)) {
currentUser.setUserKey(cookie.getValue());
currentUser.setHasTempUser(true);
break;
}
}
}
if (StringUtils.isBlank(currentUser.getUserKey())) {
String uuid = UUID.randomUUID().toString();
currentUser.setUserKey(uuid);
}
// 向threadLocal中添加当前用户
threadLocal.set(currentUser);
// 放行
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {
// 从threadLocal中获取当前用户
CurrentUser currentUser = threadLocal.get();
// 创建cookie
if (!currentUser.isHasTempUser()) {
Cookie cookie = new Cookie(TEMP_USER_COOKIE_NAME, currentUser.getUserKey());
cookie.setDomain("xxx.com");
cookie.setMaxAge(86400 * 30);
response.addCookie(cookie);
}
}
}
购物车数据保存在Redis中,数据类型为hash:
使用redisTemplate通过cartKey绑定购物车。这里的operations是对Redis的一组操作(增删改查)的集合。
private BoundHashOperations
CurrentUser currentUser = CartInterceptor.threadLocal.get();
String cartKey = "";
if (currentUser.getUserId() != null) {
// CART_PREFIX = "gulimall:cart:"
cartKey = CART_PREFIX + userInfoTo.getUserId();
} else {
cartKey = CART_PREFIX + userInfoTo.getUserKey();
}
BoundHashOperations
return operations;
}
对购物车中的商品进行增删改查:
BoundHashOperations
● 增、改
cartOps.put(skuId.toString(), JSON.toJSONString(cartItem));
● 删
cartOps.delete(skuId.toString());
● 查
cartOps.get(skuId.toString());
电商系统涉及到3流,分别是信息流、资金流、物流,订单系统作为中枢将三者有机地结合起来。
● 用户信息。包括用户账号、用户等级、收货地址、收货人、收货人手机号等。
● 订单基础信息。包括订单编号、订单状态、订单流转时间、订单类型、父/子订单等。
其中订单流转时间包括下单时间、支付时间、发货时间、关闭时间等,订单类型包括实体商品订单和虚拟商品订单等。
● 商品信息。
● 优惠信息。
● 支付信息。包括支付流水单号、支付方式、商品总金额、运费、优惠金额、实付金额等。
用户实付金额=商品总金额+运费-优惠金额。
● 物流信息。包括物流单号、物流公司、物流状态等。
● 待付款。用户提交订单后,系统创建一个待付款状态的订单。
● 已付款/待发货。用户完成订单支付后,订单变更为已付款/待发货状态。
● 已发货/待收货。商家将商品出库后,订单进入物流环节,订单变更为已发货/待收货状态。
● 已完成。用户确认收货后,订单变更为已完成状态。
● 已取消。订单超时未支付,或用户、商家中的一方取消了订单,订单变更为已取消状态。
● 售后中。用户在付款后申请退款,或商家发货后用户申请退换等,订单变更为售后中状态。
1. 用户下单。在购物车点"去结算",构建订单确认对象OrderConfirmVO(见2.4节 模型设计,下同),进入订单确认页。
1.1 从session中获取当前用户的基本信息。如果获取不到,跳转到系统登录页。
1.2 构建订单确认对象OrderConfirmVO。
1.2.1 调用用户服务,获取当前用户的收货地址、收货人等信息。
1.2.2 调用购物车服务,获取用户购买的商品的信息。
1.2.3 调用优惠券服务,获取当前用户可用的优惠券。
1.2.4 计算用户应付金额。
1.2.5* 生成防重令牌,保证订单提交的幂等性。(见3.1节 防重令牌)
1.3 向页面返回订单确认对象OrderConfirmVO。
2. 订单提交。用户在订单确认页点"提交订单"后,根据页面提交表单生成订单提交对象OrderSubmitVO,提交订单。如果下单成功,系统跳转到支付页,否则返回订单确认页。
2.1 验证防重令牌。如果验证成功,删除令牌,如果验证失败,则下单失败。
2.2 构建订单创建对象OrderCreateTO。
2.2.1 生成订单对象OrderEntity。
2.2.1.1 调用仓储服务,计算运费。(比较复杂,该项目这里使用了随机数)
2.2.1.2 设置收货人信息。
2.2.1.3 设置其他信息(订单状态等)。
2.2.2 调用购物车服务,获取所有订单项信息。
2.2.3 验价。比较OrderSubmitVO携带的用户应付金额 和 所有订单项总价格,如果验价失败,则下单失败。
2.3 保存订单数据。
2.4 调用仓储服务,
2.5* 远程锁定库存。为用户预留商品,其他人无法再购买。传参仓储锁库存对象WareSkuLockVO,返回锁库存结果LockStockResult。(见4.1节 远程锁定库存)
2.5.1 保存任务单信息。
2.5.2 查询商品在哪些仓库有库存,返回一个仓库ID列表。如果都没有,则下单失败。
2.5.3 锁定库存。如果锁库存失败,则抛出异常,下单失败。
3. 订单支付。
3.1 订单支付失败,释放订单、解锁库存。订单支付失败的场景有:
● 用户未支付,订单自动取消。
● 用户手动取消。
● 锁定库存后,业务调用失败,订单回滚。
3.2* 调用第三方服务支付,如支付宝支付。(见5.1节 整合支付宝支付)
3.3 支付成功。
3.4 拆单、记录支付流水等。
3.5 订单下库,开始物流。
4. 物流。商品出库、物流跟踪、订单签收等。不做过多介绍。
5. 售后。退款申请、退货物流、退货入库、退款等。不做过多介绍。
1. 引入依赖。
groupId: org.springframework.boot
artifactId: spring-boot-starter-data-redis
2. 在配置文件中添加配置。
spring.redis.host=192.168.56.10
1. 引入依赖。
groupId: org.springframework.session
artifactId: spring-session-data-redis
2. 在配置文件中添加配置。
spring.session.store-type=redis
3. 使用注解开启session服务。
在启动类上添加注解@EnableRedisHttpSession。
4. 配置session。
@Configuration
public class GulimallSessionConfig{
@Bean
public CookieSerializer cookieSerializer(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("gulimall.com");
cookieSerializer.setCookieName("GULISESSION");
return cookieSerializer;
}
@Bean
public RedisSerializer
1. 创建线程池属性类。
@ConfigurationProperties(prefix="gulimall.thread")
@Component
@Getter
@Setter
public class ThreadPoolConfigProperties{
private Integer coreSize;
private Integer maxSize;
private Integer keepAliveTime;
private Integer blockingDequeSize;
}
2. 在配置文件中设置线程池参数。
gulimall.thread.core-size=20
gulimall.thread.max-size=200
gulimall.thread.keep-alive-time=10
gulimall.thread.blocking-deque-size=100000
3. 注入线程池。
@Configuration
public class MyThreadConfig{
@Bean
public ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties properties){
return new ThreadPoolExecutor(properties.getCoreSize(), properties.getMaxSize(), properties.getKeepAliveTime(), TimeUnit.SECONDS, new LinkedBlockingDeque<>(properties.getBlockingDequeSize()), new ThreadPoolExecutor.AbortPolicy());
}
}
● 订单购物项OrderItemVO:
private Long skuId; // SKU ID
private String title; // 标题
private String image; // 图片(地址)
private List
private BigDecimal price; // 单价
private Integer count; // 数量
private BigDecimal totalPrice; // 总价=单价*数量,重写get方法
● 订单确认对象OrderConfirmVO:
private List
private List
// TODO 优惠券列表
// TODO public BigDecimal getPayPrice(){ } // 计算用户应付金额
private String orderToken; // 订单令牌
● 订单提交对象OrderConfirmVO:
private Long addrId; // 收货地址的ID
private Integer payType; // 支付类型
// TODO 使用的优惠券
private String orderToken; // 防重令牌
private BigDecimal payPrice; // 应付价格,需要验价
private String note; // 订单备注
● 订单创建对象OrderCreateTO:
private OrderEntity order; // 订单
private List
private BigDecimal payPrice; // 订单应付价格,用来验价
private BigDecimal fare; // 运费
● 仓储锁库存对象WareSkuLockVO:
private String orderSn; // 订单号
private List
● 锁库存结果LockStockResult:
private Long skuId; // SKU ID
private Integer num; // 锁定数量
private boolean locked; // 是否已锁定
● 库存锁定对象StockLockedTO:
private Long taskId; // 任务ID
private StockDetailTO stockDetailTO; // 库存详情
● 库存详情对象StockDetailTO:
private Long id; // ID
private Long skuId; // SKU ID
private String skuName; // SKU名称
private Integer skuNum; // (购买的)SKU数目
private Long taskId; // 任务ID
private Long wareId; // 仓库ID
private Integer lockStatus; // 锁定状态(SKU已锁定数目)
1. 创建登录用户拦截器类LoginUserInterceptor。
@Component
public class LoginUserInterceptor implements HandlerInterceptor{
public static ThreadLocal
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception{
MemberRespVO attribute = (MemberRespVO) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
if(attribute != null){
loginUser.set(attribute);
return true;
} else{
request.getSession().setAttribute("msg", "请您先进行登录");
response.sendRedirect("http://auth.gulimall.com/login.html");
return false;
}
}
}
2. 配置拦截器。
@Configuration
public class MyWebConfig implements WebMvcConfigurer {
@Autowired
LoginUserInterceptor loginUserInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");
}
}
1. 引入依赖。
groupId: org.springframework.boot
artifactId: spring-boot-starter-amqp
2. 在配置文件中添加配置。
spring.rabbitmq.host=192.168.56.10
spring.rabbitmq.port=5672
spring.rabbitmq.virtual-host=/
3. 在服务启动类上添加@EnableRabbit注解。
4. 注入RabbitMQ组件。
@Configuration
public class MyRabbitMQConfig{
/**
* 消息转换器(使用JSON格式序列化)
*/
@Bean
public MessageConverter messageConverter(){
return new Jackson2JsonMessageConverter();
}
/**
* 订单事件交换器
*/
@Bean
public Exchange orderEventExchange(){
return new TopicExchange("order-event-exchange", true, false);
}
/**
* 订单解锁队列
*/
@Bean
public Queue orderReleaseOrderQueue(){
return new Queue("order.release.order.queue", true, false, false);
}
/**
* 订单延时队列
*/
@Bean
public Queue orderDelayQueue(){
Map
arguments.put("x-dead-letter-exchange", "order-event-exchange");
arguments.put("x-dead-letter-routing-key", "order.release.order");
arguments.put("x-message-ttl", 60000);
return new Queue("order.delay.queue", true, false, false, arguments);
}
/**
* 订单创建绑定关系
*/
@Bean
public Binding orderCreateOrderBinding(){
return new Binding("order.delay.queue", Binding.DestinationType.QUEUE, "order-event-exchange", "order.create.order", null);
}
/**
* 订单释放绑定关系
*/
@Bean
public Binding orderReleaseOrderBinding(){
return new Binding("order.release.order.queue", Binding.DestinationType.QUEUE, "order-event-exchange", "order.release.order", null);
}
}
1. 整合Spring Session。
2. 设置拦截器。
3. 整合RabbitMQ。
4. 注入RabbitMQ组件。
@Configuration
public class MyRabbitMQConfig{
/**
* 消息转换器(使用JSON格式序列化)
*/
@Bean
public MessageConverter messageConverter(){
return new Jackson2JsonMessageConverter();
}
/**
* 仓储事件交换器
*/
@Bean
public Exchange stockEventExchange(){
return new TopicExchange("stock-event-exchange", true, false);
}
/**
* 仓储释放库存队列
*/
@Bean
public Queue stockReleaseStockQueue(){
return new Queue("stock.release.stock.queue", true, false, false);
}
/**
* 仓储延时队列
*/
@Bean
public Queue stockDelayQueue(){
Map
arguments.put("x-dead-letter-exchange", "stock-event-exchange");
arguments.put("x-dead-letter-routing-key", "stock.release");
arguments.put("x-message-ttl", 120000);
return new Queue("stock.delay.queue", true, false, false, arguments);
}
/**
* 仓储锁定绑定关系
*/
@Bean
public Binding stockLockedBinding(){
return new Binding("stock.delay.queue", Binding.DestinationType.QUEUE, "stock-event-exchange", "stock.locked", null);
}
/**
* 仓储释放绑定关系
*/
@Bean
public Binding stockReleaseBinding(){
return new Binding("stock.release.stock.queue", Binding.DestinationType.QUEUE, "stock-event-exchange", "stock.release.#", null);
}
}
用户在购物车点"去结算"以后,系统会生成一个防重令牌,存入Redis,并随着订单确认对象OrderConfirmVO返回到页面。
当用户确认过订单信息 点"提交订单"时,在前端将订单令牌放到订单提交对象OrderConfirmVO的orderToken属性中。
在后端验证这个订单令牌:与Redis中存的令牌做比对,如果验证失败,则提交订单失败,如果验证成功,删除Redis中的数据,这样,订单服务再一次收到相同的请求时,也会验证失败。这样就保证了请求的幂等性。
这里需要注意的是,token的获取、比较和删除三个操作必须具备原子性,可以通过在Redis中执行LUA脚本来实现。
脚本示例:
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
锁定库存的核心SQL:
update 'wms_ware_sku'
set stock_locked = stock_locked + #{num}
where sku_id = #{skuId}
and ware_id = #{wareId}
and stock - stock_locked >= #{num}
锁定库存的全流程为:
1. 保存任务单。
2. 保存任务单详情。
3. 尝试锁定库存。如果锁定成功,就修改锁定状态,如果锁定失败,回滚前两步操作。
可以看出,远程锁定库存必须是事务操作。
● 本地事务注解:@Transactional
● Seata提供的分布式事务注解:@GlobalTransactional
锁定库存成功后,向RabbitMQ发送一条消息(存入延时队列),一段时间后用这条消息释放订单、解锁库存。
发送的消息是一个库存锁定对象StockLockedTO,见2.4节 模型设计。
进入蚂蚁金服开放平台,按照接入指南接入支付宝支付功能。
https://opendocs.alipay.com/open/270/105898
提示:必须保证系统字符集是UTF-8,否则在加密时可能会出错。
支付完成后的跳转页面必须是公网可访问的。如果服务器所在的网络是局域网,就必须使用内网穿透技术,使外网的用户可以访问处于内网的服务器。
【软件】续断内网穿透:https://www.zhexi.tech/
浏览器给订单服务发送请求时,请求头自动带了cookie,cookie中可能记录了用户的登录状态。
当订单服务使用Feign调用远程服务时,会构造一个新的请求发送给远程服务,新请求的请求头中自然是没有原来的cookie的,所以远程服务无法检测到用户的登录状态。
解决方法是注入一个请求拦截器,这个拦截器的功能是:
1. 获取浏览器请求的cookie。
2. 给每一个发出去的请求带上cookie。
代码示例:
@Configuration
public class GulimallRequestInterceptorConfig{
@Bean("requestInterceptor")
public RequestInterceptor requestInterceptor(){
return new RequestInterceptor(){
@Override
public void apply(RequestTemplate template){
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String cookie = request.getHeader("Cookie");
template.header("Cookie", cookie);
}
}
}
}
创建异步任务时,异步线程无法自动获取原线程ThreadLocal中的信息。
解决方法是手动从原线程中获取上下文信息,set到异步线程中。
1. 创建异步任务之前,获取原线程请求属性。
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
2. 创建异步任务时,把原线程请求属性set到异步线程中。
RequestContextHolder.setRequestAttributes(requestAttributes);