SpringBoot系列(十九)优惠券的使用

系统搭建优惠券,思路如下:1)创建 2)领取 3)核销

  • 创建优惠券
  • 选择优惠券类型(如:满减券、折扣券、无门槛券等)
  • 优惠券审核(审核可用时间、门槛条件、是否拥有优惠券)
  • 投放优惠券:用户领取、系统自动投放(生日券、新人券等)
  • 领取(定时、抢券)
  • 使用优惠券,金额使用(购物车、订单的使用)
  • 优惠券的核销(优惠券金额平摊到货物上)

从业务的复用性,一般我们都会创建一个优惠券模板,该模板会注明:发放时间、开始时间和结束时间。

优惠券使用范围:商品分类(商品分类的哪些类)、指定商品、指定品牌、指定商品、

活动的理解: 整合所有优惠券的专题和整合活动的商品

优惠券与活动的关系(活动跟分类必须有关系)
方法一:优惠券->分类->活动
SPU->Category->优惠券
方案二:优惠券->活动->分类
SPU->Category ->Activity ->优惠券

下面我们先看看Activity和Coupon的Model

Activity(Model)


package com.lin.missyou.model;

import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Where;

import javax.persistence.*;
import java.util.Date;
import java.util.List;

@Entity
@Setter
@Getter
@Where(clause = "delete_time is null and online = 1")
public class Activity extends BaseEntity {
    @Id
    private Long id;
    private String title;
    private String name;
    private String description;
    private Date startTime;
    private Date endTime;
    private Boolean online;
    private String entranceImg;
    private String internalTopImg;
    private String remark;
    

    @OneToMany(fetch = FetchType.LAZY)
    @JoinColumn(name="activityId")
    private List<Coupon> couponList;

}

Coupon( Model )


package com.lin.missyou.model;

import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Where;

import javax.persistence.*;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;

@Entity
@Getter
@Setter
@Where(clause = "delete_time is null")
public class Coupon extends BaseEntity {
    @Id
    private Long id;
    private Long activityId;
    private String title;
    private Date startTime;
    private Date endTime;
    private String description;
    private BigDecimal fullMoney;
    private BigDecimal minus;
    /*折扣比例*/
    private BigDecimal rate;
    /*优惠券的描述*/
    private String remark;
    /*是否全场券*/
    private Boolean wholeStore;
    /*优惠券的类型*/
    private Integer type;

    @ManyToMany(fetch = FetchType.LAZY, mappedBy = "couponList")
    private List<Category> categoryList;
}

思考一个问题?为什么前文说到,在活动与优惠券之间引入一个优惠券券模板呢?

首先我们明确:活动与优惠券的关系是多对多;任何一个多对多都可以转换成两个一对多;

例如我们可以通过:活动–>活动模板(一对多)–>优惠券(一对多)

下面通过展示活动接口来讲解一些高级用法

1、优惠券的领取

创建Controller


package com.lin.missyou.api.v1;

import com.lin.missyou.exception.http.NotFoundException;
import com.lin.missyou.model.Activity;
import com.lin.missyou.service.ActivityService;
import com.lin.missyou.vo.ActivityCouponVO;
import com.lin.missyou.vo.ActivityPureVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequestMapping("activity")
@RestController
public class ActivityController {
    @Autowired
    private ActivityService activityService;
    /* 标准活动内容 */
    @GetMapping("/name/{name}")
    public ActivityPureVO getHomeActivity(@PathVariable String name) {
        Activity activity = activityService.getByName(name);
        if (activity == null) {
            throw new NotFoundException(40001);
        }
        ActivityPureVO vo = new ActivityPureVO(activity);
        return vo;
    }
    /* 带优惠券活动容 */
    @GetMapping("/name/{name}/with_coupon")
    public ActivityCouponVO getActivityWithCoupons(@PathVariable String name) {
        Activity activity = activityService.getByName(name);
        if (activity == null) {
            throw new NotFoundException(40001);
        }
        return new ActivityCouponVO(activity);
    }

}

创建Service


package com.lin.missyou.service;

import com.lin.missyou.model.Activity;
import com.lin.missyou.repository.ActivityRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Date;
import java.util.List;

@Service
public class ActivityService {

    @Autowired
    private ActivityRepository activityRepository;

    public Activity getByName(String name) {
        return activityRepository.findByName(name);
    }
}

创建Repository

package com.lin.missyou.repository;

import com.lin.missyou.model.Activity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.Date;
import java.util.List;
import java.util.Optional;

@Repository
public interface ActivityRepository extends JpaRepository<Activity, Long> {

    Activity findByName(String name);

    Optional<Activity> findByCouponListId(Long couponId);
}

创建ActivityPureVO 用于输出标准活动接口


package com.lin.missyou.vo;

import com.lin.missyou.model.Activity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.beans.BeanUtils;

@Getter
@Setter
@NoArgsConstructor
public class ActivityPureVO {
    private Long id;
    private String title;
    private String entranceImg;
    private Boolean online;
    private String remark;
    private String startTime;
    private String endTime;

    public ActivityPureVO(Activity activity) {
        BeanUtils.copyProperties(activity,this);
    }

    public ActivityPureVO(Object object){
        BeanUtils.copyProperties(object, this);
    }

}

创建ActivityCouponVO 用于输出带优惠券的活动接口

package com.lin.missyou.vo;

import com.lin.missyou.model.Activity;
import com.lin.missyou.model.Coupon;
import lombok.Getter;
import lombok.Setter;

import java.util.List;
import java.util.stream.Collectors;

@Getter
@Setter
public class ActivityCouponVO extends ActivityPureVO {
    private List<CouponPureVO> coupons;

    public ActivityCouponVO(Activity activity) {
        super(activity);
        coupons = activity.getCouponList()
                .stream().map(CouponPureVO::new)
                .collect(Collectors.toList());
    }
}

创建CouponPureVO 优惠券过滤VO

package com.lin.missyou.vo;

import com.lin.missyou.model.Coupon;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.beans.BeanUtils;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;

@Getter
@Setter
@NoArgsConstructor
public class CouponPureVO {
    private Long id;
    private String title;
    private Date startTime;
    private Date endTime;
    private String description;
    private BigDecimal fullMoney;
    private BigDecimal minus;
    private BigDecimal rate;
    private Integer type;
    private String remark;
    private Boolean wholeStore;

    public CouponPureVO(Object[] objects){
        Coupon coupon = (Coupon) objects[0];
        BeanUtils.copyProperties(coupon, this);
    }

    public CouponPureVO(Coupon coupon){
        BeanUtils.copyProperties(coupon, this);
    }

    public static List<CouponPureVO> getList(List<Coupon> couponList) {
        return couponList.stream()
                .map(CouponPureVO::new)
                .collect(Collectors.toList());
    }
}

=========扩展学习JPQL

在业务中,我们需要根据活动ID获得所适应的优惠券。下面将从原生SQL和Model的两种JPQ进行讲解。

原生SQL的JPQL

Alt+Enter 选择“Edit Spring Data QL Fragment”

package com.lin.missyou.repository;

import com.lin.missyou.model.Coupon;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Date;
import java.util.List;
import java.util.Optional;

public interface CouponRepository extends JpaRepository<Coupon, Long>, JpaSpecificationExecutor<Coupon> {

    @Query("select * from coupon\n" +
            "join coupon_catepory\n" +
            "on coupon.id=coupon_catepory.cpupon_id\n" +
            "join category\n" +
            "on coupon_catepory.categpry_id=category.id\n" +
            "join Activity a on a.id=coupon_catepory.activityId\n" +
            "where category.id=:id\n" +
            "and a.startTime < :now\n" +
            "and a.endTime > :now")
            List<Coupon> findByCategory(Long cid, Date now);
}

原生SQL写起来比较复杂,而且在序列化成数组型Json需要写逻辑代码。

Model的JPQL

首先我们先看看coupon、category和Activity的三个model

couponModel

package com.lin.missyou.model;

import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Where;

import javax.persistence.*;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;

@Entity
@Getter
@Setter
@Where(clause = "delete_time is null")
public class Coupon extends BaseEntity {
    @Id
    private Long id;
    private Long activityId;
    private String title;
    private Date startTime;
    private Date endTime;
    private String description;
    private BigDecimal fullMoney;
    private BigDecimal minus;
    /*折扣比例*/
    private BigDecimal rate;
    /*优惠券的描述*/
    private String remark;
    /*是否全场券*/
    private Boolean wholeStore;
    /*优惠券的类型*/
    private Integer type;

    @ManyToMany(fetch = FetchType.LAZY, mappedBy = "couponList")
    private List<Category> categoryList;
}

categoryModel

package com.lin.missyou.model;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Where;

import javax.persistence.*;
import java.util.List;

@Entity
@Getter
@Setter
@Where(clause = "delete_time is null and online = 1")
public class Category extends BaseEntity {

    @Id
    private Long id;

    private String name;

    private String description;

    private Boolean isRoot;

    private String img;

    private Long parentId;

    private Long index;

    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(name = "coupon_category",
            joinColumns = @JoinColumn(name = "category_id"),
            inverseJoinColumns = @JoinColumn(name = "coupon_id"))
    private List<Coupon> couponList;

}

ActivityModel

package com.lin.missyou.model;

import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Where;

import javax.persistence.*;
import java.util.Date;
import java.util.List;

@Entity
@Setter
@Getter
@Where(clause = "delete_time is null and online = 1")
public class Activity extends BaseEntity {
    @Id
    private Long id;
    private String title;
    private String name;
    private String description;
    private Date startTime;
    private Date endTime;
    private Boolean online;
    private String entranceImg;
    private String internalTopImg;
    private String remark;


    @OneToMany(fetch = FetchType.LAZY)
    @JoinColumn(name="activityId")
    private List<Coupon> couponList;

}

ModelSPQL

package com.lin.missyou.repository;

import com.lin.missyou.model.Coupon;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Date;
import java.util.List;
import java.util.Optional;

public interface CouponRepository extends JpaRepository<Coupon, Long>, JpaSpecificationExecutor<Coupon> {

    @Query("select c from Coupon c\n" +
            "join c.categoryList ca\n" +
            "join Activity a on a.id = c.activityId\n" +
            "where ca.id = :cid\n" +
            "and a.startTime < :now\n" +
            "and a.endTime > :now\n")

            List<Coupon> findByCategory(Long cid, Date now);
}

关于单表查询和联表查询的一些看法:
单表查询:
JAVA逻辑代码 进行过滤盒组合。使用内存
搜索或分页:使用ES中
联表查询:
select join 但是会有因为笛卡尔级,不符合阿里的开发规范。性能比较低

2、优惠券的核销

关于优惠券的核销,我们需要查看超权问题;

  • 当前用户是否存在此优惠券
  • 优惠券是否过期
  • 优惠券状态

问题一:如果获得当前用户信息?

  • 请求中带上uid【安全性极差】
  • 通过依赖注入的方式,把token,再通过Jwt解析获得uid
  • 编写通用的LocalUser类

方法二:把token通过JWT解析加密的uid

String bearerToken = request.getHeader("Authorization");
        if(bearerToken == null){
            throw new UnAuthenticatedException(10001);
        }


        if (!bearerToken.startsWith("Bearer")) {
            throw new UnAuthenticatedException(10004);
        }
        String tokens[] = bearerToken.split(" ");
        if (!(tokens.length == 2)) {
            throw new UnAuthenticatedException(10004);
        }
        String token = tokens[1];
        Optional<Map<String, Claim>> optionalMap = JwtToken.getClaims(token);
        /*解析token,获得之前生成token的信息
        * 在service/VankeAuthenticationServiceImpl这里的make token传的参数
        * */
        Map<String, Claim> map = optionalMap
                .orElseThrow(() -> new UnAuthenticatedException(10004));

方法三:编写通用的LocalUser类

首先我们写一个Localuser的类

JAVA、基础技术、技术与框架SpringBoot系列(十九)优惠券的使用插图

然后在拦截器中set用户信息,再使用的地方getUser。但我们需要注意,该类是静态方法,如果多个用户同时操作的话,会存有问题。如何避免这个问题呢?下面引入线程来处理。

首先在拦截器 PermissionInterceptor 中定义方法 setToThreadLocal

package gani.vankek3api.core.interceptors;

import com.auth0.jwt.interfaces.Claim;
import gani.vankek3api.core.LocalUser;
import gani.vankek3api.exception.ForbiddenException;
import gani.vankek3api.exception.UnAuthenticatedException;
import gani.vankek3api.model.User;
import gani.vankek3api.service.UserService;
import gani.vankek3api.util.JwtToken;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.Optional;

@Deprecated
public class PermissionInterceptor extends HandlerInterceptorAdapter {

    @Autowired
    private UserService userService;

    public PermissionInterceptor() {
        super();
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        Optional<ScopeLevel> scopeLevel = this.getScopeLevel(handler);
        if (!scopeLevel.isPresent()) {
            return true;
        }

        String bearerToken = request.getHeader("Authorization");
        if(bearerToken == null){
            throw new UnAuthenticatedException(10001);
        }


        if (!bearerToken.startsWith("Bearer")) {
            throw new UnAuthenticatedException(10004);
        }
        String tokens[] = bearerToken.split(" ");
        if (!(tokens.length == 2)) {
            throw new UnAuthenticatedException(10004);
        }
        String token = tokens[1];
        Optional<Map<String, Claim>> optionalMap = JwtToken.getClaims(token);
        /*解析token,获得之前生成token的信息
        * 在service/VankeAuthenticationServiceImpl这里的make token传的参数
        * */
        Map<String, Claim> map = optionalMap
                .orElseThrow(() -> new UnAuthenticatedException(10004));

        boolean valid = this.hasPermission(scopeLevel.get(), map);
        if(valid){
            this.setToThreadLocal(map);
        }
        return valid;
    }

    private void setToThreadLocal(Map<String,Claim> map) {
        Long uid = map.get("uid").asLong();
        Integer scope = map.get("scope").asInt();
        User user = this.userService.getUserInfoById(uid);
        LocalUser.set(user, scope);
    }
    /* 判断请求的token权限与ScopeLevel注解等级对比 */
    private boolean hasPermission(ScopeLevel scopeLevel, Map<String, Claim> map) {
        Integer level = scopeLevel.value();
        Integer scope = map.get("scope").asInt();
        if (level > scope) {
            throw new ForbiddenException(10005);
        }
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        super.postHandle(request, response, handler, modelAndView);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

        super.afterCompletion(request, response, handler, ex);
    }

    private Optional<ScopeLevel> getScopeLevel(Object handler) {
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            ScopeLevel scopeLevel = handlerMethod.getMethod().getAnnotation(ScopeLevel.class);
            if (scopeLevel == null) {
                return Optional.empty();
            }
            return Optional.of(scopeLevel);
        }
        return Optional.empty();
    }

}

修改LocalUser类,使用 ThreadLocal

注意,使用线程后,为了不让资源被占用,记得定义清除线程的方法。

package gani.vankek3api.core;

import gani.vankek3api.model.User;

import java.util.HashMap;
import java.util.Map;

public class LocalUser {
    private static ThreadLocal<Map<String, Object>> threadLocal = new ThreadLocal<>();

    public static void set(User user, Integer scope) {
        Map<String, Object> map = new HashMap<>();
        map.put("user", user);
        map.put("scope", scope);
        LocalUser.threadLocal.set(map);
    }

    public static void clear() {
        LocalUser.threadLocal.remove();
    }

    public static User getUser() {
        Map<String, Object> map = LocalUser.threadLocal.get();
        User user = (User)map.get("user");
        return user;
    }

    public static Integer getScope() {
        Map<String, Object> map = LocalUser.threadLocal.get();
        Integer scope = (Integer)map.get("scope");
        return scope;
    }
}

在 PermissionInterceptor的afterCompletion方法处,加入LocalUser.clear() 。

至此,以后调用的时候直接使用LocalUser.getUser().getId(); 即可获得用户ID。

详细可参考之前的文章token认证文章

http://www.fcors.com/%e6%8a%80%e6%9c%af%e4%b8%8e%e6%a1%86%e6%9e%b6/springboot%E7%B3%BB%E5%88%97%EF%BC%88%E5%8D%81%E5%85%AB%EF%BC%89%EF%BC%9Atoken%E8%AE%A4%E8%AF%81/

JPA扩展学习

JPA查询导航属性

一对多的情况下,根据多方的id反查一方

package com.lin.missyou.repository;

import com.lin.missyou.model.Activity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.Date;
import java.util.List;
import java.util.Optional;

@Repository
public interface ActivityRepository extends JpaRepository<Activity, Long> {

    Optional<Activity> findByCouponListId(Long couponId);
}

扩展学习JPA保存数据库枚举的使用

在core/enumeration下创建枚举CouponStatus

package com.lin.missyou.core.enumeration;

import java.util.stream.Stream;

public enum CouponStatus {

    AVAILABLE(1, "可以使用,未过期"),
    USED(2, "已使用"),
    EXPIRED(3, "未使用,已过期");

    private Integer value;

    public Integer getValue() {
        return this.value;
    }

    CouponStatus(Integer value, String description) {
        this.value =value;
    }

    public static CouponStatus toType(int value) {
        return Stream.of(CouponStatus.values())
                .filter(c -> c.value == value)
                .findAny()
                .orElse(null);
    }
}

使用JPA保存数据

        UserCoupon userCouponNew = UserCoupon.builder()
                .userId(uid)
                .couponId(couponId)
                .status(CouponStatus.AVAILABLE.getValue())
                .createTime(now)
                .build();
        userCouponRepository.save(userCouponNew);

创建更新数据库返回状态

  • CreateSuccess
  • DeleteSuccess
  • UpdateSuccess

在exception下面创建以上三个类

package com.lin.missyou.exception;

import com.lin.missyou.exception.http.HttpException;

public class CreateSuccess extends HttpException {
    public CreateSuccess(int code){
        this.httpStatusCode = 201;
        this.code = code;
    }
}
package com.lin.missyou.exception;

import com.lin.missyou.exception.http.HttpException;

public class DeleteSuccess extends HttpException {
    public DeleteSuccess(int code){
        this.httpStatusCode = 200;
        this.code = code;
    }
}
package com.lin.missyou.exception;

import com.lin.missyou.exception.http.HttpException;

public class UpdateSuccess extends HttpException {
    public UpdateSuccess(int code){
        this.httpStatusCode = 200;
        this.code = code;
    }
}

修改UnifyResponse

package com.lin.missyou.core;

import com.lin.missyou.exception.CreateSuccess;
import com.lin.missyou.exception.DeleteSuccess;
import com.lin.missyou.exception.UpdateSuccess;

public class UnifyResponse {
    private int code;

    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }

    public String getRequest() {
        return request;
    }

    private String message;
    private String request;

    public UnifyResponse(int code, String message, String request){
        this.code = code;
        this.message = message;
        this.request = request;
    }

    public static void createSuccess(int code) {
        
        throw new CreateSuccess(code);
    }
    public static void deleteSuccess(int code) {
        throw new DeleteSuccess(code);
    }
    public static void updateSuccess(int code) {
        throw new UpdateSuccess(code);
    }
}

修改InterceptorConfiguration

package gani.vankek3api.core.configuration;

import gani.vankek3api.core.interceptors.PermissionInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class InterceptorConfiguration implements WebMvcConfigurer {

    @Bean
    public HandlerInterceptor getPermissionInterceptor() {
        return new PermissionInterceptor();
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(this.getPermissionInterceptor());
    }
}

在调用更新save方法后,抛出Success

UnifyResponse.createSuccess(0);

枚举的类型转换

前文我们已经定义了枚举 CouponStatus

那我们怎么使用类型定义的类型呢?

switch(CouponStatus.toType(status)){
  case USED:
    ##do something
    break;
  case AVAILABLE:
    ##do something
    break;
  case EXPIRED:
    ##do something
    break;
}


=====触发器
优惠券的过期问题
优惠券的状态:未使用、已使用、已过期
可以通过触发机制,主要分为两种:
1、主动触发:轮询,写一个线程,定时扫描数据库,根据Start-EndTime 判断,最终更新优惠劵的状态

关于优惠券的状态:

优惠券的状态:未使用、已使用、已过期 ,那么如何修改呢此状态呢?

  1. 主动触发:轮询,写一个线程,定时扫描数据库,根据Start-EndTime 判断,最终更新优惠劵的状态
  2. 被动触发:使用第三方的库。例如Redis、RocketMQ