天机学堂最全实战笔记(下)

day6

接口

点赞功能接口

点赞或取消点赞

接口说明用户可以给自己喜欢的内容点赞,也可以取消点赞
请求方式POST
请求路径/likes
请求参数格式java { "bizId": "1578558664933920770", // 点赞业务id "bizType": 1, // 点赞业务类型,1:问答;2:笔记;.. "liked": true, // 是否点赞,true:点赞,false:取消 }
返回值格式
LikedRecordController.java
@ApiOperation("点赞或取消赞")
@PostMapping
public void addLikeRecord(@Valid @RequestBody LikeRecordFormDTO dto) {
    likedRecordService.addLikeRecord(dto);
}
ILikedRecordService.java
  void addLikeRecord(@Valid LikeRecordFormDTO dto);
LikedRecordServiceImpl.java
@Override
public void addLikeRecord(LikeRecordFormDTO dto) {
    // 获取当前登录用户
    Long userId = UserContext.getUser();
    // 判断是点赞还是取消点赞
    boolean flag = dto.getLiked() ? liked(dto, userId) : unliked(dto, userId);
    if (!flag) {
        // 说明点赞或者取消赞失败
        return;
    }
    // 统计该业务逻辑下的总点赞数
    Integer totalLikesNum = this.lambdaQuery()
    .eq(LikedRecord::getBizId, dto.getBizId())
    .count();
    
    // 发送消息到mq
    String routingKey = StringUtils.format(MqConstants.Key.LIKED_TIMES_KEY_TEMPLATE,dto.getBizType());
    // LikedTimesDTO.LikedTimesDTOBuilder msg = LikedTimesDTO.builder().bizId(dto.getBizId()).likedTimes(totalLikesNum);
    LikedTimesDTO msg = LikedTimesDTO.of(dto.getBizId(), totalLikesNum);
    log.debug("发送点赞消息 消息内容{}", msg);
    rabbitMqHelper.send(
        MqConstants.Exchange.LIKE_RECORD_EXCHANGE,
        routingKey,
        msg);
}
com/tianji/learning/mq/LikeRecordListener.java
@Slf4j
@Component
@RequiredArgsConstructor
public class LikeRecordListener {

    private final IInteractionReplyService replyService;

    /**
     * QA问答系统 消费者
     */
    @RabbitListener(bindings = @QueueBinding(
        value = @Queue(value = "qa.liked.times.queue", durable = "true"),
        exchange = @Exchange(value = MqConstants.Exchange.LIKE_RECORD_EXCHANGE, type = ExchangeTypes.TOPIC),
        key = MqConstants.Key.QA_LIKED_TIMES_KEY))
    public void onMsg(LikedTimesDTO dto) {
        log.debug("LikeRecordListener 监听到消息 {}", dto);
        InteractionReply reply = replyService.getById(dto.getBizId());
        if (reply == null) {
            return;
        }
        reply.setLikedTimes(dto.getLikedTimes());
        replyService.updateById(reply);
    }
}

批量查询点赞状态

接口说明查询当前用户是否点赞了指定的业务
请求方式GET
请求路径/likes/list
请求参数格式请求数据类型:application/x-www-form-urlencoded
例如:bizIds=1,2,3
代表业务id集合
返回值格式java [ "业务id1", "业务id2", "业务id3", "业务id4" ]
LikedRecordController.java
@ApiOperation("批量查询点赞状态")
@GetMapping("list")
public Set<Long> getLikesStatusByBizIds(@RequestParam("bizIds") List<Long> bizIds) {
    return likedRecordService.getLikesStatusByBizIds(bizIds);
}
ILikedRecordService.java
Set<Long> getLikesStatusByBizIds(List<Long> bizIds);
LikedRecordServiceImpl.java
@Override
public Set<Long> getLikesStatusByBizIds(List<Long> bizIds) {
    //获取用户id
    Long userId = UserContext.getUser();
    //查询点赞记录表 in bizIds
    List<LikedRecord> recordList = this.lambdaQuery().in(LikedRecord::getBizId, bizIds)
    .eq(LikedRecord::getUserId, userId).list();
    //将查询到的点赞过的bizId转集合返回
    Set<Long> likedBizIds = recordList.stream().map(LikedRecord::getBizId).collect(Collectors.toSet());
    return likedBizIds;
}

暴露feign接口

com/tianji/api/client/remark/RemarkClient.java

@FeignClient("remark-service")//被调用方的服务名
public interface RemarkClient {

    @GetMapping("likes/list")
    public Set<Long> getLikesStatusByBizIds(@RequestParam("bizIds") List<Long> bizIds);
}

点赞功能改进接口

点赞接口改进

com/tianji/remark/service/impl/LikedRecordRedisServiceImpl.java
@Service
@RequiredArgsConstructor
@Slf4j
public class LikedRecordRedisServiceImpl extends ServiceImpl<LikedRecordMapper, LikedRecord> implements ILikedRecordService {

    private final RabbitMqHelper rabbitMqHelper;
    private final StringRedisTemplate redisTemplate;

    @Override
    public void addLikeRecord(LikeRecordFormDTO dto) {
        // 获取当前登录用户
        Long userId = UserContext.getUser();
        // 判断是点赞还是取消点赞
        boolean flag = dto.getLiked() ? liked(dto, userId) : unliked(dto, userId);
        if (!flag) {
            // 说明点赞或者取消赞失败
            return;
        }

        /* 统计该业务逻辑下的总点赞数
        Integer totalLikesNum = this.lambdaQuery()
        .eq(LikedRecord::getBizId, dto.getBizId())
        .count();

        */
        // 基于Redis统计 业务id的点赞量
        //拼接key likes:set:biz:评论id
        String key = RedisConstants.LIKE_BIZ_KEY_PREFIX + dto.getBizId();
        Long totalLikesNum= redisTemplate.opsForSet().size(key);
        if (totalLikesNum == null) {
            return;
        }

        //采用zset的结构 缓存点赞的总数 likes:times:type:QA likes:times:type:NOTE
        String bizTypeTotalLikeKey = RedisConstants.LIKES_COUNT_KEY_PREFIX + dto.getBizType();
        redisTemplate.opsForZSet().add(bizTypeTotalLikeKey,dto.getBizId().toString(),totalLikesNum);

        /*// 发送消息到mq
        String routingKey = StringUtils.format(MqConstants.Key.LIKED_TIMES_KEY_TEMPLATE,dto.getBizType());
        // LikedTimesDTO.LikedTimesDTOBuilder msg = LikedTimesDTO.builder().bizId(dto.getBizId()).likedTimes(totalLikesNum);
        LikedTimesDTO msg = LikedTimesDTO.of(dto.getBizId(), totalLikesNum);
        log.debug("发送点赞消息 消息内容{}", msg);
        rabbitMqHelper.send(
                MqConstants.Exchange.LIKE_RECORD_EXCHANGE,
                routingKey,
                msg);

         */

    }

    private boolean unliked(LikeRecordFormDTO dto, Long userId) {
        //拼接key
        String key = RedisConstants.LIKE_BIZ_KEY_PREFIX + dto.getBizId();
        //从set结构中删除 当前userId
        Long result = redisTemplate.opsForSet().remove(key, userId.toString());
        return result != null && result > 0;
        /*LikedRecord record = this.lambdaQuery()
                .eq(LikedRecord::getUserId, userId)
                .eq(LikedRecord::getBizId, dto.getBizId())
                .one();
        if (record == null) {
            // 说明之前没有点过赞 取消失败
            return false;
        }
        // 删除点赞记录
        boolean result = this.removeById(record.getId());
        return result;
         */
    }

    private boolean liked(LikeRecordFormDTO dto, Long userId) {
        //基于Redis做点赞
        //拼接key
        String key = RedisConstants.LIKE_BIZ_KEY_PREFIX + dto.getBizId();
        //redisTemplate 往redis的set结构添加 点赞记录
        // redisTemplate.boundSetOps().add(userId.toString());
        Long result = redisTemplate.opsForSet().add(key, userId.toString());
        return result != null && result > 0;

        /*LikedRecord record = this.lambdaQuery()
                .eq(LikedRecord::getUserId, userId)
                .eq(LikedRecord::getBizId, dto.getBizId())
                .one();
        if (record != null) {
            // 说明之前点过赞
            return false;
        }
        // 保存点赞记录到表中
        LikedRecord likedRecord = new LikedRecord();
        likedRecord.setUserId(userId);
        likedRecord.setBizId(dto.getBizId());
        likedRecord.setBizType(dto.getBizType());
        this.save(likedRecord);
        return true;
         */
    }

    @Override
    public Set<Long> getLikesStatusByBizIds(List<Long> bizIds) {
        //获取用户id
        Long userId = UserContext.getUser();
        //查询点赞记录表 in bizIds
        List<LikedRecord> recordList = this.lambdaQuery()
                .in(LikedRecord::getBizId, bizIds)
                .eq(LikedRecord::getUserId, userId).list();
        //将查询到的点赞过的bizId转集合返回
        Set<Long> likedBizIds = recordList.stream().map(LikedRecord::getBizId).collect(Collectors.toSet());
        return likedBizIds;
    }
}

定时任务

com/tianji/remark/task/LikedTimesCheckTask.java
@Component
@RequiredArgsConstructor
@Slf4j
public class LikedTimesCheckTask {

    private static final List<String> BIZ_TYPES = List.of("QA", "NOTE");//业务类型
    private static final int MAX_BIZ_SIZE = 30;//任务每次取的biz数量 防止一次性往mq发送消息太多

    private final ILikedRecordService likedRecordService;

    //每20秒执行一次 将Redis中 业务类型 下面  某业务的点赞总数  发送消息到mq
    // @Scheduled(fixedDelay = 20000)
    @Scheduled(cron = "0/20 * * * * ?")
    public void checkLikedTimes(){
        for (String bizType : BIZ_TYPES) {
            likedRecordService.readLikedTimesAndSendMessage(bizType, MAX_BIZ_SIZE);
        }
    }
}
com/tianji/remark/service/ILikedRecordService.java
void readLikedTimesAndSendMessage(String bizType, int maxBizSize);
LikedRecordRedisServiceImpl.java
 @Override
    public void readLikedTimesAndSendMessage(String bizType, int maxBizSize) {
        // 拼接key
        String bizTypeTotalLikeKey = RedisConstants.LIKES_COUNT_KEY_PREFIX + bizType;

        List<LikedTimesDTO> list = new ArrayList<>();

        // 从redis的zset结构中取maxBizSize 的业务点赞信息
        Set<ZSetOperations.TypedTuple<String>> typedTuples = redisTemplate.opsForZSet().popMin(bizTypeTotalLikeKey, maxBizSize);
        for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
            String bizId = typedTuple.getValue();
            Double likedTimes = typedTuple.getScore();
            if (StringUtils.isBlank(bizId) || likedTimes == null) {
                continue;
            }
            // 封装LikedTimesDTO 消息数据
            LikedTimesDTO msg = LikedTimesDTO.of(Long.valueOf(bizId), likedTimes.intValue());
            list.add(msg);
        }

        // 发送消息到mq
        log.debug("批量发送点赞消息 消息内容{}", list);
        if (CollUtils.isNotEmpty(list)) {
            String routingKey = StringUtils.format(MqConstants.Key.LIKED_TIMES_KEY_TEMPLATE, bizType);
            rabbitMqHelper.send(
                    MqConstants.Exchange.LIKE_RECORD_EXCHANGE,
                    routingKey,
                    list);
        }
    }
com/tianji/learning/mq/LikeRecordListener.java
@Slf4j
@Component
@RequiredArgsConstructor
public class LikeRecordListener {

    private final IInteractionReplyService replyService;

    /**
     * QA问答系统 消费者
     */
    @RabbitListener(bindings = @QueueBinding(
        value = @Queue(value = "qa.liked.times.queue", durable = "true"),
        exchange = @Exchange(value = MqConstants.Exchange.LIKE_RECORD_EXCHANGE, type = ExchangeTypes.TOPIC),
        key = MqConstants.Key.QA_LIKED_TIMES_KEY))
    public void onMsg(List<LikedTimesDTO> list) {
        log.debug("LikeRecordListener 监听到消息 {}", list);

        //消息转po
        List<InteractionReply> replyList = new ArrayList<>();
        for (LikedTimesDTO dto : list) {
            InteractionReply reply = new InteractionReply();
            reply.setId(dto.getBizId());
            reply.setLikedTimes(dto.getLikedTimes());
            replyList.add(reply);
        }
        replyService.updateBatchById(replyList);
    }

点赞状态查询接口

com/tianji/remark/service/impl/LikedRecordRedisServiceImpl.java
@Override
public Set<Long> getLikesStatusByBizIds(List<Long> bizIds) {
    // 获取用户id
    Long userId = UserContext.getUser();
    if (CollUtils.isEmpty(bizIds)) {
        return CollUtils.emptySet();
    }
    // 循环bizIds
    Set<Long> likedBizIds = new HashSet<>();
    for (Long bizId : bizIds) {
        //判断该业务id的点赞用户集合中是否包含当前用户
        Boolean member = redisTemplate.opsForSet()
        .isMember(RedisConstants.LIKE_BIZ_KEY_PREFIX + bizId, userId.toString());
        if (member) {
            likedBizIds.add(bizId);
        }
    }

    return likedBizIds;
}
@Override
public Set<Long> getLikesStatusByBizIds(List<Long> bizIds) {
    // 1.获取登录用户id
    Long userId = UserContext.getUser();
    // 2.查询点赞状态
    List<Object> objects = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
        StringRedisConnection src = (StringRedisConnection) connection;
        for (Long bizId : bizIds) {
            String key = RedisConstants.LIKES_BIZ_KEY_PREFIX + bizId;
            src.sIsMember(key, userId.toString());
        }
        return null;
    });
    // 3.返回结果
    return IntStream.range(0, objects.size()) // 创建从0到集合size的流
    .filter(i -> (boolean) objects.get(i)) // 遍历每个元素,保留结果为true的角标i
    .mapToObj(bizIds::get)// 用角标i取bizIds中的对应数据,就是点赞过的id
    .collect(Collectors.toSet());// 收集
}

二选一即可

涉及到的知识点

配置feign降级

引入依赖
<!--feign远程调用依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

<!--sentinel 微服务保护依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
编写feign接口 com/tianji/api/client/remark/RemarkClient.java
@FeignClient(value = "remark-service",fallbackFactory = RemarkClientFallBack.class)//被调用方的服务名
public interface RemarkClient {

    @GetMapping("likes/list")
    public Set<Long> getLikesStatusByBizIds(@RequestParam("bizIds") List<Long> bizIds);
}
feign降级类 com/tianji/api/client/remark/fallback/RemarkClientFallBack.java
@Slf4j
public class RemarkClientFallBack implements FallbackFactory<RemarkClient> {

    // 如果remark服务没启动 或者其他服务调用remark服务超时 则走create降级
    @Override
    public RemarkClient create(Throwable cause) {
        log.error("调用remark服务降级了", cause);
        return new RemarkClient() {
            @Override
            public Set<Long> getLikesStatusByBizIds(List<Long> bizIds) {
                return null;
            }
        };
    }
}
编写配置类 com/tianji/api/config/FallbackConfig.java
//remark服务降级类对象
@Bean
public RemarkClientFallBack remarkClientFallBack(){
    return new RemarkClientFallBack();
}
设置FallBackConfig配置类的路径

在tj-api/src/main/resources/META-INF/spring.factories中设置FallBackConfig配置类的路径

其他路径只要依靠该模块 其他模块启动时 利用自动装配原理自动加载该文件 使降级类生效

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
    com.tianji.api.config.RequestIdRelayConfiguration, \
    com.tianji.api.config.RoleCacheConfig, \
    com.tianji.api.config.FallbackConfig, \
    com.tianji.api.config.CategoryCacheConfig
开启feign对sentinel的降级支持

nacous的shared-feign.yaml配置中已经开启

feign:
  client:
    config:
      default: # default全局的配置
        loggerLevel: BASIC # 日志级别,BASIC就是基本的请求和响应信息
  httpclient:
    enabled: true # 开启feign对HttpClient的支持
    max-connections: 200 # 最大的连接数
    max-connections-per-route: 50 # 每个路径的最大连接数
  sentinel: #feign整合sentinel开启降级
    enabled: true

feign拦截器

编写feign拦截器并使生效

tj-auth/tj-auth-resource-sdk/src/main/java/com/tianji/authsdk/resource/interceptors/FeignRelayUserInterceptor.java

package com.tianji.authsdk.resource.interceptors;

import com.tianji.auth.common.constants.JwtConstants;
import com.tianji.common.utils.UserContext;
import feign.RequestInterceptor;
import feign.RequestTemplate;

public class FeignRelayUserInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        Long userId = UserContext.getUser();
        if (userId == null) {
            return;
        }
        //将当前系统的登录用户id 重新放入feign请求头中
        template.header(JwtConstants.USER_HEADER, userId.toString());
    }
}
编写配置类
package com.tianji.authsdk.resource.config;

import feign.Feign;
import com.tianji.authsdk.resource.interceptors.FeignRelayUserInterceptor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConditionalOnClass(Feign.class)
public class FeignRelayUserAutoConfiguration {

    @Bean
    public FeignRelayUserInterceptor feignRelayUserInterceptor(){
        return new FeignRelayUserInterceptor();
    }
}
添加配置类路径

tj-auth/tj-auth-resource-sdk/src/main/resources/META-INF/spring.factories

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.tianji.authsdk.resource.config.ResourceInterceptorConfiguration, \
com.tianji.authsdk.resource.config.FeignRelayUserAutoConfiguration

day7

接口

签到功能接口

参数说明
请求方式POST
请求路径/sign-records
请求参数
返回值java { "signDays": 10, // 连续签到天数 "points" : 14 // 今日签到获取的积分 }
SignRecordController.java
@ApiOperation("签到")
@PostMapping
public SignResultVO addSignRecords(){
    return signRecordService.addSignRecords();
}
ISignRecordService.java
SignResultVO addSignRecords();
RedisConstants.java
public interface RedisConstants {
    /**
     * 签到记录的key前缀 完整格式为 sign:uid:用户id:年月
     */
    String SIGN_RECORD_KEY_PREFIX = "sign:uid:";
}
SignRecordServiceImpl.java
    @Override
    public SignResultVO addSignRecords() {
        // 获取当前登录用户id
        Long userId = UserContext.getUser();
        // 拼接key
        // SimpleDateFormat format = new SimpleDateFormat("yyyyMM");
        // format.format(new Date());
        LocalDate now = LocalDate.now();// 当前时间的年月
        String format = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
        String key = RedisConstants.SIGN_RECORD_KEY_PREFIX + userId.toString() + format;
        // 利用bitset命令 将签到记录保存到redis的bitmap结构中 需要校验是否已签到
        int offset = now.getDayOfMonth() - 1; // 偏移量
        Boolean setBit = redisTemplate.opsForValue().setBit(key, offset, true);
        // 返回值是该offset上原来的值 原来为0是false 原来为1是true
        if (setBit) {
            // 说明当前已经签过到了
            throw new BizIllegalException("不能重复签到");
        }
        // 计算连续签到的天数
        int days = countSignDays(key, now.getDayOfMonth());

        // 计算连续签到 奖励积分
        int rewardPoints = 0;
        switch (days) {
            case 7:
                rewardPoints = 10;
                break;
            case 14:
                rewardPoints = 20;
                break;
            case 28:
                rewardPoints = 40;
                break;
        }
        // 保存积分 发送消息到mq
        mqHelper.send(MqConstants.Exchange.LEARNING_EXCHANGE,
                MqConstants.Key.SIGN_IN,
                SignInMessage.of(userId,rewardPoints + 1));
        // 封装vo返回
        SignResultVO vo = new SignResultVO();
        vo.setSignDays(days);
        vo.setRewardPoints(rewardPoints);
        return vo;
    }

/**
 * 计算连续签到的天数
 *
 * @param key        缓存中的key
 * @param dayOfMonth 本月第一天到今天的天数
 * @return
 */
private int countSignDays(String key, int dayOfMonth) {
    // 求本月第一天到今天签到的天数 bitFiled 得到的是十进制
    // bitField key get u天数 0
    List<Long> bitField = redisTemplate.opsForValue().bitField(key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));
    if (CollUtils.isEmpty(bitField)) {
        return 0;
    }
    Long num = bitField.get(0);// 本月第一天到今天的签到天数 拿到的十进制
    log.debug("num {}", num);
    // num转二进制 从后往前推共有多少个一
    int counter = 0;// 计数器
    while ((num & 1) == 1) {
        counter++;
        num = num >>> 1;// 右移一位
    }
    return counter;
}

查询本月签到记录

参数说明
请求方式GET
请求路径/sign-records
请求参数
返回值json [ 0, // 第1天签到情况 1, // 第2天签到情况 1, // 第3天签到情况 // ... ]
tj-learning/src/main/java/com/tianji/learning/controller/SignRecordController.java
@ApiOperation("查询签到记录")
@GetMapping
public Byte[] querySignRecords() {
    return signRecordService.querySignRecords();
}
tj-learning/src/main/java/com/tianji/learning/service/ISignRecordService.java
Byte[] querySignRecords();
tj-learning/src/main/java/com/tianji/learning/service/impl/SignRecordServiceImpl.java
@Override
public Byte[] querySignRecords() {
    // 获取当前登录用户
    Long userId = UserContext.getUser();
    // 拼接key
    LocalDate now = LocalDate.now();// 当前时间的年月
    String format = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    String key = RedisConstants.SIGN_RECORD_KEY_PREFIX + userId.toString() + format;
    // 取本月第一天到今天所有的签到记录
    int dayOfMonth = now.getDayOfMonth();
    // bitfield key get u天数 0
    List<Long> bitField = redisTemplate.opsForValue().bitField(key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));
    if (CollUtils.isEmpty(bitField)) {
        return new Byte[0];
    }
    Long num = bitField.get(0);
    int offset = now.getDayOfMonth() - 1;
    // 利用& 封装结构
    Byte[] arr = new Byte[dayOfMonth];
    while (offset >= 0) {
        arr[offset] = (byte) (num & 1);// 计算最后一天是否签到 赋值结果
        offset--;
        num = num >>> 1;
    }
    return arr;
}

新增积分记录

com/tianji/learning/mq/LearningPointsListener.java
@Component
@RequiredArgsConstructor
@Slf4j
public class LearningPointsListener {

    private final IPointsRecordService PointsRecordService;

    // 监听签到事件
    @RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "sign.points.queue", durable = "true"),
        exchange = @Exchange(name = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
        key = MqConstants.Key.SIGN_IN
    ))
    public void listenSignInMessage(SignInMessage msg){
        log.debug("签到增加的积分 消费到消息 {}",msg);
        PointsRecordService.addPointsRecord(msg,PointsRecordType.SIGN);
    }

}
com/tianji/learning/service/IPointsRecordService.java
public interface IPointsRecordService extends IService<PointsRecord> {

    //增加积分
    void addPointsRecord(SignInMessage msg, PointsRecordType type);
}
com/tianji/learning/service/impl/PointsRecordServiceImpl.java
@Service
public class PointsRecordServiceImpl extends ServiceImpl<PointsRecordMapper, PointsRecord> implements IPointsRecordService {


    @Override
    public void addPointsRecord(SignInMessage msg, PointsRecordType type) {
        if (msg.getUserId() == null || msg.getPoints() == null) {
            return;
        }
        int realPoint = msg.getPoints();//代表实际可以增加的积分
        //判断该积分类型是否有上限 type.maxPoints是否大于0
        int maxPoints = type.getMaxPoints();//积分类型上限
        if (maxPoints > 0) {
            LocalDateTime now = LocalDateTime.now();
            LocalDateTime dayStartTime = DateUtils.getDayStartTime(now);
            LocalDateTime dayEndTime = DateUtils.getDayEndTime(now);
            //如果有上限 查询该用户 该积分类型 今日已得积分 points_record 条件userId type
            QueryWrapper<PointsRecord> wrapper = new QueryWrapper<>();
            wrapper.select("sun(points) as totalPoints");
            wrapper.eq("user_id", msg.getUserId());
            wrapper.eq("type", type);
            wrapper.between("create_time", dayStartTime, dayEndTime);
            Map<String, Object> map = this.getMap(wrapper);
            int currentPoints = 0;//当前用户该积分类型 已得积分
            if (map != null) {
                BigDecimal totalPoints = (BigDecimal) map.get("totalPoints");
                currentPoints = totalPoints.intValue();
            }
            //判断已得积分是否超过上限
            if (currentPoints >= maxPoints) {
                //说明已得积分 达到上限
                return;
            }
            if (currentPoints + realPoint > maxPoints ){
                realPoint = maxPoints - currentPoints;
            }
        }
        //保存积分
        PointsRecord record = new PointsRecord();
        record.setUserId(msg.getUserId());
        record.setType(type);
        record.setPoints(realPoint);
        this.save(record);
    }
}

查询今日积分情况

参数说明
请求方式GET
请求路径/points/today
请求参数
返回值json [ { "type": "每日签到", // 积分类型描述 "points" : 1, // 今日签到获取的积分 "maxPoints" : 5, // 每日积分上限 }, { "type": "回答问题", // 积分类型描述 "points" : 1, // 今日签到获取的积分 "maxPoints" : 5, // 每日积分上限 } ]
com/tianji/learning/controller/PointsRecordController.java
@ApiOperation("查询我的今日积分情况")
@GetMapping("today")
public List<PointsStatisticsVO> queryMyPointsToday(){
    return pointsRecordService.queryMyPointsToday();
}
com/tianji/learning/service/IPointsRecordService.java
//查询我的今日积分情况
List<PointsStatisticsVO> queryMyPointsToday();
com/tianji/learning/service/impl/PointsRecordServiceImpl.java
@Override
public List<PointsStatisticsVO> queryMyPointsToday() {
    // 获取用户id
    Long userId = UserContext.getUser();
    // 查询积分表 points_record 条件:user_id
    LocalDateTime now = LocalDateTime.now();
    LocalDateTime dayStartTime = DateUtils.getDayStartTime(now);
    LocalDateTime dayEndTime = DateUtils.getDayEndTime(now);
    QueryWrapper<PointsRecord> wrapper = new QueryWrapper<>();
    wrapper.select("type","sum(points) as userId");
    //利用PointsRecord实体类中userId字段暂存 但是注意暂存字段类型要求字段类型匹配
    wrapper.eq("user_id", userId);
    wrapper.between("create_time",dayStartTime, dayEndTime);
    wrapper.groupBy("type");
    List<PointsRecord> list = this.list(wrapper);
    if (CollUtils.isEmpty(list)){
        return CollUtils.emptyList();
    }
    //封装vo返回
    List<PointsStatisticsVO> voList = new ArrayList<>();
    for (PointsRecord record : list) {
        PointsStatisticsVO vo = new PointsStatisticsVO();
        vo.setType(record.getType().getDesc());//积分类型的中文
        vo.setMaxPoints(record.getType().getMaxPoints());//积分类型的上限
        vo.setPoints(record.getUserId().intValue());
        voList.add(vo);
    }
    return voList;
}

查询赛季列表功能

参数说明
请求方式GET
请求路径/boards/seasons/list
请求参数
返回值json [ { "id": "110", // 赛季id "name": "第一赛季", // 赛季名称 "beginTime": "2023-05-01", // 赛季开始时间 "endTime": "2023-05-31", // 赛季结束时间 } ]
tj-learning/src/main/java/com/tianji/learning/controller/PointsBoardSeasonController.java
@ApiOperation("查询赛季列表")
@GetMapping("list")
public List<PointsBoardSeason> queryPointBoardSeasonList() {
    return pointsBoardSeasonService.list();//要返回的结果字段与po字段一样 所以可以直接调用mp中的list方法
}

涉及到的知识点

利用BitMap实现签到和查询签到记录功能

好处

签到数据量非常大,BitMap则是用bit位来表示签到数据,31bit位就能表示1个月的签到记录,非常节省空间,而且查询效率也比较高

BitMap用法

Redis中就提供了BitMap这种结构以及一些相关的操作命令

Commands

修改某个bit位上的数据
SETBIT key offset value
  • offset:要修改第几个bit位的数据
  • value:0或1
# 第1天签到
SETBIT bm 0 1
# 第2天签到
SETBIT bm 1 1
# 第3天签到
SETBIT bm 2 1
# 第6天签到
SETBIT bm 5 1
# 第7天签到
SETBIT bm 6 1
# 第8天签到
SETBIT bm 7 1
读取BitMap中的数据
BITFIELD key GET encoding offset
  • key:就是BitMap的key
  • GET:代表查询
  • encoding:返回结果的编码方式,BitMap中是二进制保存,而返回结果会转为10进制,但需要一个转换规则,也就是这里的编码方式
    • u:无符号整数,例如 u2,代表读2个bit位,转为无符号整数返回
    • i:又符号整数,例如 i2,代表读2个bit位,转为有符号整数返回
  • offset:从第几个bit位开始读取,例如0:代表从第一个bit位开始

例如,我想查询从第1天到第3天的签到记录,可以这样

BITFIELD bm GET u3 0

返回的结果是7 签到记录是 11100111,从0开始,取3个bit位,刚好是111,转无符号整数,刚好是7

利用SETBIT命令来实现签到,利用BITFIELD命令来实现查询签到记录功能
拓展

Redis最基础的数据类型只有5种:String、List、Set、SortedSet、Hash,其它特殊数据结构大多都是基于以上5这种数据类型。

BitMap也不例外,它是基于String结构的。因为Redis的String类型底层是SDS,也会存在一个字节数组用来保存数据。而Redis就提供了几个按位操作这个数组中数据的命令,实现了BitMap效果。

由于String类型的最大空间是512MB,也就是2的31次幂个bit,因此可以保存的数据量级是十分恐怖的。

具体实现

按月来统计用户签到信息,签到记录为1,未签到则记录为0,就可以用一个长度为31位的二级制数来表示一个用户一个月的签到情况
为了便于统计,我们计划每个月为每个用户生成一个独立的KEY,因此KEY中必须包含用户信息、月份信息
sign:uid:xxx:202401

提前定义这样一个KEY前缀的常量

String SIGN_RECORD_KEY_PREFIX = "sign:uid:"

最终效果如下

LocalDate now = LocalDate.now();// 当前时间的年月
String format = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = RedisConstants.SIGN_RECORD_KEY_PREFIX + userId.toString() + format;
连续签到统计
  • 获取本月到今天为止的所有签到数据
  • 从今天开始,向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数
/**
 * 计算连续签到的天数
 *
 * @param key        缓存中的key
 * @param dayOfMonth 本月第一天到今天的天数
 * @return
 */
private int countSignDays(String key, int dayOfMonth) {
    // 求本月第一天到今天签到的天数 bitFiled 得到的是十进制
    // bitField key get u天数 0
    List<Long> bitField = redisTemplate.opsForValue().bitField(key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));
    if (CollUtils.isEmpty(bitField)) {
        return 0;
    }
    Long num = bitField.get(0);// 本月第一天到今天的签到天数 拿到的十进制
    log.debug("num {}", num);
    // num转二进制 从后往前推共有多少个一
    int counter = 0;// 计数器
    while ((num & 1) == 1) {
        counter++;
        num = num >>> 1;// 右移一位
    }
    return counter;
}
签到记录,可以利用我们之前讲的BITFIELD命令来获取,从0开始,到今天为止的记录,命令是这样的:
BITFIELD key GET u[dayOfMonth] 0
获取连续签到天数需要解决两个问题
  • 如何找到并获取签到记录中最后一个bit位
    • 任何数与1做与运算,得到的结果就是它本身。因此我们让签到记录与1做与运算,就得到了最后一个bit位
  • 如何移除这个bit位
    • 把数字右移一位,最后一位到了小数点右侧,由于我们保留整数,最后一位自然就被丢弃了
查询签到记录
public Byte[] querySignRecords() {
    // 获取当前登录用户id
    Long userId = UserContext.getUser();
    // 拼接key
    LocalDate now = LocalDate.now();// 当前时间的年月
    String format = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    String key = RedisConstants.SIGN_RECORD_KEY_PREFIX + userId.toString() + format;
    // 取本月第一天到今天所有的签到记录
    int dayOfMonth = now.getDayOfMonth();
    // bitfield key get u天数 0
    List<Long> bitField = redisTemplate.opsForValue().bitField(key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));
    if (CollUtils.isEmpty(bitField)) {
        return new Byte[0];
    }
    Long num = bitField.get(0);
    int offset = now.getDayOfMonth() - 1;
    // 利用& 封装结构
    Byte[] arr = new Byte[dayOfMonth];
    while (offset >= 0) {
        arr[offset] = (byte) (num & 1);// 计算最后一天是否签到 赋值结果
        offset--;
        num = num >>> 1;
    }
    return arr;
}

拓展问题

使用Redis保存签到记录,那如果Redis宕机怎么办

对于Redis的高可用数据安全问题,有很多种方案。

比如:我们可以给Redis添加数据持久化机制,比如使用AOF持久化。这样宕机后也丢失的数据量不多,可以接受。

或者呢,我们可以搭建Redis主从集群,再结合Redis哨兵。主节点会把数据持续的同步给从节点,宕机后也会有哨兵重新选主,基本不用担心数据丢失问题。

当然,如果对于数据的安全性要求非常高。肯定还是要用传统数据库来实现的。但是为了解决签到数据量较大的问题,我们可能就需要对数据做分表处理了。或者及时将历史数据存档。

总的来说,签到数据使用Redis的BitMap无论是安全性还是数据内存占用情况,都是可以接受的。但是具体是选择Redis还是数据库方案,最终还是要看公司的要求来选择。

QueryWrapper中的妙用

所要查询的是多行多列的结果 sql如下 不能用geMap 用list方法 通过构造QueryWrapper来设置查询条件

SELECT TYPE,SUM(points) FROM points_record
WHERE user_id=2 AND create_time BETWEEN '2023-05-2900:00:01' AND '2023-05-2923:59:59'
GROUP BY type
QueryWrapper<PointsRecord> wrapper = new QueryWrapper<>();
wrapper.select("type","sum(points) as userId");
//利用list中对应的PointsRecord实体类中userId字段暂存 但是注意暂存字段类型要求字段类型匹配
wrapper.eq("user_id", userId);
wrapper.between("create_time",dayStartTime, dayEndTime );
wrapper.groupBy("type");
List<PointsRecord> list = this.list(wrapper);

day8

接口

实时排行榜

实时排行榜功能

com/tianji/learning/constants/RedisConstants.java
/**
 * 积分排行榜的Key的前缀:boards:202301
 */
String POINTS_BOARD_KEY_PREFIX = "boards:";
com/tianji/learning/service/impl/PointsRecordServiceImpl.java
@Override
public void addPointsRecord(SignInMessage msg, PointsRecordType type) {
    if (msg.getUserId() == null || msg.getPoints() == null) {
        return;
    }
    int realPoint = msg.getPoints();// 代表实际可以增加的积分
    // 判断该积分类型是否有上限 type.maxPoints是否大于0
    int maxPoints = type.getMaxPoints();// 积分类型上限
    if (maxPoints > 0) {
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime dayStartTime = DateUtils.getDayStartTime(now);
        LocalDateTime dayEndTime = DateUtils.getDayEndTime(now);
        // 如果有上限 查询该用户 该积分类型 今日已得积分 points_record 条件userId type
        QueryWrapper<PointsRecord> wrapper = new QueryWrapper<>();
        wrapper.select("sun(points) as totalPoints");
        wrapper.eq("user_id", msg.getUserId());
        wrapper.eq("type", type);
        wrapper.between("create_time", dayStartTime, dayEndTime);
        Map<String, Object> map = this.getMap(wrapper);
        int currentPoints = 0;// 当前用户该积分类型 已得积分
        if (map != null) {
            BigDecimal totalPoints = (BigDecimal) map.get("totalPoints");
            currentPoints = totalPoints.intValue();
        }
        // 判断已得积分是否超过上限
        if (currentPoints >= maxPoints) {
            // 说明已得积分 达到上限
            return;
        }
        if (currentPoints + realPoint > maxPoints) {
            realPoint = maxPoints - currentPoints;
        }
    }
    // 保存积分
    PointsRecord record = new PointsRecord();
    record.setUserId(msg.getUserId());
    record.setType(type);
    record.setPoints(realPoint);//分值
    this.save(record);
    //累加并保存总积分值到Redis 当前赛季的排行榜
    LocalDate now = LocalDate.now();
    String format = now.format(DateTimeFormatter.ofPattern("yyyyMM"));
    String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + format;
    //要求累加 使用incrementScore()
    redisTemplate.opsForZSet().incrementScore(key,msg.getUserId().toString(),realPoint);

}

查询积分排行榜

接口说明查询指定赛季的积分排行榜以及当前用户的积分和排名信息
请求方式GET
请求路径/boards
请求参数+ 分页参数,例如PageNo、PageSize
+ 赛季id,为空或0时,代表查询当前赛季。否则就是查询历史赛季
返回值json { "rank": 8, // 当前用户的排名 "points": 21, // 当前用户的积分值 [ { "rank": 1, // 排名 "points": 81, // 积分值 "name": "Jack" // 姓名 }, { "rank": 2, // 排名 "points": 74, // 积分值 "name": "Rose" // 姓名 } ] }
com/tianji/learning/controller/PointsBoardController.java
@ApiOperation("查询学霸积分榜-当前赛季和历史赛季都可用")
@GetMapping
public PointsBoardVO queryPointsBoardList(PointsBoardQuery query) {
    return pointsBoardService.queryPointsBoardList(query);
}
com/tianji/learning/service/IPointsBoardService.java
PointsBoardVO queryPointsBoardList(PointsBoardQuery query);
com/tianji/learning/service/impl/PointsBoardServiceImpl.java
 @Override
    public PointsBoardVO queryPointsBoardList(PointsBoardQuery query) {
        // 获取当前登录用户id
        Long userId = UserContext.getUser();
        // 判断是查当前赛季还是历史赛季 query.season
        boolean isCurrent = query.getSeason() == null || query.getSeason() == 0;
        LocalDate now = LocalDate.now();
        String format = now.format(DateTimeFormatter.ofPattern("yyyyMM"));
        String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + format;
        Long season = query.getSeason();
        // 查询我的排名和积分
        PointsBoard board = isCurrent ? queryMyCurrentBoard(key) : queryMyHistoryBoard(season);
        // 分页查询赛季列表
        List<PointsBoard> list = isCurrent ? queryCurrentBoard(key, query.getPageNo(), query.getPageSize()) : queryHistoryBoard(query);

        // 封装vo返回
        PointsBoardVO vo = new PointsBoardVO();
        vo.setRank(board.getRank());//我的排名
        vo.setPoints(board.getPoints());//我的积分

        //封装用户id集合 调用用户服务 获取用户信息 转map
        Set<Long> uids = list.stream().map(PointsBoard::getUserId).collect(Collectors.toSet());
        List<UserDTO> userDTOS = userClient.queryUserByIds(uids);
        if (userDTOS.isEmpty()){
            throw new BizIllegalException("用户不存在");
        }
        //转map key:用户id value 用户名称
        Map<Long, String> userDtoMap = userDTOS.stream().collect(Collectors.toMap(UserDTO::getId, c -> c.getName()));


        List<PointsBoardItemVO> voList = new ArrayList<>();
        for (PointsBoard pointsBoard : list) {
            PointsBoardItemVO itemVO = new PointsBoardItemVO();
            itemVO.setName(userDtoMap.get(pointsBoard.getUserId()));
            itemVO.setPoints(pointsBoard.getPoints());
            itemVO.setRank(pointsBoard.getRank());
            voList.add(itemVO);
        }

        vo.setBoardList(voList);
        return vo;
    }



    /**
     * 查询历史赛季排行榜列表
     *
     * @param query
     * @return
     */
    private List<PointsBoard> queryHistoryBoard(PointsBoardQuery query) {
        if (query.getPageNo() <= 0 || query.getPageSize() <= 0) {
            throw new BadRequestException("非法参数");
        }
        int offset = query.getPageNo() - 1;
        List<PointsBoard> list = this.lambdaQuery().eq(PointsBoard::getSeason, query.getSeason())
                .orderByAsc(PointsBoard::getPoints)
                .last("LIMIT " + query.getPageSize() + " OFFSET " + offset)
                .list();
        return list;
    }

    /**
     * 查询当前赛季排行榜列表
     *
     * @param key
     * @param pageNo   页码
     * @param pageSize 条数
     * @return
     */
    private List<PointsBoard> queryCurrentBoard(String key, Integer pageNo, Integer pageSize) {
        // 计算start和stop 下标都是从零开始
        int start = (pageNo - 1) * pageSize;
        int end = start + pageSize - 1;
        // 利用zrevrange  按分数倒序 分页查询
        Set<ZSetOperations.TypedTuple<String>> typedTuples = redisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, start, end);
        if (CollUtils.isEmpty(typedTuples)) {
            return CollUtils.emptyList();
        }

        int rank = start + 1;
        List<PointsBoard> list = new ArrayList<>();
        //封装结果返回
        for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
            String value = typedTuple.getValue();//用户id
            Double score = typedTuple.getScore();//总积分值
            if (StringUtils.isBlank(value) || score == null) {
                continue;
            }
            PointsBoard board = new PointsBoard();
            board.setUserId(Long.valueOf(value));
            board.setPoints(score.intValue());
            board.setRank(rank++);
            list.add(board);
        }
        return list;
    }


    /**
     * 查询历史赛季我的积分和排名
     *
     * @param season
     * @return
     */
    private PointsBoard queryMyHistoryBoard(Long season) {
        Long userId = UserContext.getUser();
        if (season == null) {
            throw new BadRequestException("非法参数");
        }
        PointsBoard one = this.lambdaQuery()
                .eq(PointsBoard::getSeason, season)
                .eq(PointsBoard::getUserId, userId).one();
        return one;
    }


    /**
     * 查询当前赛季我的积分和排名
     *
     * @param key
     * @return
     */
    private PointsBoard queryMyCurrentBoard(String key) {
        // 获取当前登录用户id
        Long userId = UserContext.getUser();
        // 从Redis中获取分值
        Double score = redisTemplate.opsForZSet().score(key, userId.toString());
        // 获取排名 从0开始 需要加一
        Long rank = redisTemplate.opsForZSet().reverseRank(key, userId.toString());
        PointsBoard board = new PointsBoard();
        board.setRank(rank == null ? 0 : rank.intValue() + 1);
        board.setPoints(score == null ? 0 : score.intValue());
        return board;
    }

历史排行榜

定时任务生成榜单表

com/tianji/learning/task/PointsBoardPersistentHandler.java
    /**
     * 创建上赛季 榜单表
     */
    // @Scheduled(cron = "0 0 3 1 * ?")  每月1号,凌晨3点执行 单机版 定时任务调度
    @XxlJob("createTableJob")
    public void createPointsBoardTableOfLastSeason(){
        log.debug("创建上赛季榜单任务执行了");
        // 获取上月当前时间点
        LocalDate time = LocalDate.now().minusMonths(1);
        // 查询赛季id
        PointsBoardSeason one = pointsBoardSeasonService
                .lambdaQuery()
                .le(PointsBoardSeason::getBeginTime, time)
                .ge(PointsBoardSeason::getEndTime, time)
                .one();
        log.debug("上赛季信息 {}",one);
        if (one == null) {
            // 赛季不存在
            return;
        }
        // 创建表
        pointsBoardSeasonService.createPointsBoardLatestTable(one.getId());
    }
com/tianji/learning/service/IPointsBoardSeasonService.java
void createPointsBoardLatestTable(Integer id);
com/tianji/learning/service/impl/PointsBoardSeasonServiceImpl.java
/**
 * 创建上赛季的表
 * @param id
 */
@Override
public void createPointsBoardLatestTable(Integer id) {
getBaseMapper().createPointsBoardLatestTable(POINTS_BOARD_TABLE_PREFIX + id);
}
com/tianji/learning/mapper/PointsBoardSeasonMapper.java
@Insert("        CREATE TABLE `${tableName}`\n" +
        "        (\n" +
        "            `id`      BIGINT NOT NULL AUTO_INCREMENT COMMENT '榜单id',\n" +
        "            `user_id` BIGINT NOT NULL COMMENT '学生id',\n" +
        "            `points`  INT    NOT NULL COMMENT '积分值',\n" +
        "            PRIMARY KEY (`id`) USING BTREE,\n" +
        "            INDEX `idx_user_id` (`user_id`) USING BTREE\n" +
        "        )\n" +
        "            COMMENT ='学霸天梯榜'\n" +
        "            COLLATE = 'utf8mb4_0900_ai_ci'\n" +
        "            ENGINE = InnoDB\n" +
        "            ROW_FORMAT = DYNAMIC")
void createPointsBoardLatestTable(@Param("tableName") String tableName);

分布式任务调度改造

修改数据库中points_board_season的数据如下

tj-learning/src/main/resources/bootstrap.yml
spring:
  profiles:
    active: dev
  application:
    name: learning-service
  cloud:
    nacos:
      config:
        file-extension: yaml
        shared-configs: # 共享配置
          - data-id: shared-spring.yaml # 共享spring配置
            refresh: false
          - data-id: shared-redis.yaml # 共享redis配置
            refresh: false
          - data-id: shared-mybatis.yaml # 共享mybatis配置
            refresh: false
          - data-id: shared-logs.yaml # 共享日志配置
            refresh: false
          - data-id: shared-feign.yaml # 共享feign配置
            refresh: false
          - data-id: shared-mq.yaml # 共享mq配置
            refresh: false
          - data-id: shared-xxljob.yaml #共享xxljob配置
            refresh: false
com/tianji/learning/task/PointsBoardPersistentHandler.java
/**
 * 创建上赛季 榜单表
 */

@XxlJob("createTableJob")
public void createPointsBoardTableOfLastSeason(){
    log.debug("创建上赛季榜单任务执行了");
    // 获取上月当前时间点
    LocalDate time = LocalDate.now().minusMonths(1);
    // 查询赛季id
    PointsBoardSeason one = pointsBoardSeasonService
    .lambdaQuery()
    .le(PointsBoardSeason::getBeginTime, time)
    .ge(PointsBoardSeason::getEndTime, time)
    .one();
    log.debug("上赛季信息 {}",one);
    if (one == null) {
        // 赛季不存在
        return;
    }
    // 创建表
    pointsBoardService.createPointsBoardLatestTable(one.getId());
}

榜单持久化

tj-common/src/main/java/com/tianji/common/autoconfigure/mybatis/MybatisConfig.java
@Configuration
@ConditionalOnClass({MybatisPlusInterceptor.class, BaseMapper.class})
public class MybatisConfig {

    /**
     * @deprecated 存在任务更新数据导致updater写入0或null的问题,暂时废弃
     * @see MyBatisAutoFillInterceptor 通过自定义拦截器来实现自动注入creater和updater
     */
    // @Bean
    // @ConditionalOnMissingBean
    public BaseMetaObjectHandler baseMetaObjectHandler(){
        return new BaseMetaObjectHandler();
    }


    //配置mybatis plus的拦截器链
    //DynamicTableNameInnerInterceptor 插件不是所有服务都用到 目前只有tj-learning服务声明了
    //@Autowired(required = false)  注入时声明非必须
    @Bean
    @ConditionalOnMissingBean
    public MybatisPlusInterceptor mybatisPlusInterceptor(@Autowired(required = false) DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor) {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

        if (dynamicTableNameInnerInterceptor != null) {
            //声明了该拦截器 需要加入到mp的拦截器链中
            interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
        }
        PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
        paginationInnerInterceptor.setMaxLimit(200L);
        interceptor.addInnerInterceptor(paginationInnerInterceptor);//分页拦截器插件
        interceptor.addInnerInterceptor(new MyBatisAutoFillInterceptor());//自动填充拦截器插件
        return interceptor;
    }
}
tj-learning/src/main/java/com/tianji/learning/config/MybatisConfiguration.java
@Configuration
public class MybatisConfiguration {
    //声明动态表名 拦截器插件 如果对points_board表crud时  会被该拦截器动态替换成TableInfoContext.getInfo()
    @Bean
    public DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor() {
        // 准备一个Map,用于存储TableNameHandler
        Map<String, TableNameHandler> map = new HashMap<>(1);
        // 存入一个TableNameHandler,用来替换points_board表名称
        // 替换方式,就是从TableInfoContext中读取保存好的动态表名
        map.put("points_board", (sql, tableName) -> TableInfoContext.getInfo() == null ? tableName : TableInfoContext.getInfo());
        return new DynamicTableNameInnerInterceptor(map);
    }
}
IPointsBoardService.java
/**
 * 查询当前赛季排行榜列表
 *
 * @param key
 * @param pageNo   页码
 * @param pageSize 条数
 * @return
 */
List<PointsBoard> queryCurrentBoard(String key, Integer pageNo, Integer pageSize);
com/tianji/learning/task/PointsBoardPersistentHandler.java
// 持久化上赛季的排行榜数据到db中
@XxlJob("savePointsBoard2DB")// 任务名字要和xxljob控制台 任务的jobhandler值保持一致
public void savePointsBoard2DB() {
    // 获取上个月 当前时间点
    LocalDate time = LocalDate.now().minusMonths(1);
    // 查询赛季表 获取赛季信息
    PointsBoardSeason one = pointsBoardSeasonService
                .lambdaQuery()
                .le(PointsBoardSeason::getBeginTime, time)
                .ge(PointsBoardSeason::getEndTime, time)
                .one();
    log.debug("上赛季信息 {}", one);
    if (one == null) {
        // 赛季不存在
        return;
    }
    // 计算动态表名 存入ThreadLocal
    String tableName = POINTS_BOARD_TABLE_PREFIX + one.getId();
    log.debug("动态表名为 {}", tableName);
    TableInfoContext.setInfo(tableName);
    // 分页获取redis上赛季积分排行表数据
    String format = time.format(DateTimeFormatter.ofPattern("yyyyMM"));
    String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + format;

    int sharedIndex = XxlJobHelper.getShardIndex();//当前分片的索引 从0开始
    int sharedTotal = XxlJobHelper.getShardTotal();//总分片页数

    int pageNo = sharedIndex + 1;
    int pageSize = 1000;
    while (true) {
        List<PointsBoard> pointsBoardList = pointsBoardService.queryCurrentBoard(key, pageNo, pageSize);
        if (CollUtils.isEmpty(pointsBoardList)) {
            break;//跳出循环
        }
        pageNo+=sharedTotal;

        // 持久到db相应的赛季表中 批量新增
        for (PointsBoard board : pointsBoardList) {
            board.setId(Long.valueOf(board.getRank()));//历史排行榜中id 代表排名
            board.setRank(null);
        }
        pointsBoardService.saveBatch(pointsBoardList);
    }
    // 清空ThreadLocal中的数据
    TableInfoContext.remove();
}


   @XxlJob("clearPointsBoardFromRedis")
    public void clearPointsBoardFromRedis() {
        // 1.获取上月时间
        LocalDateTime time = LocalDateTime.now().minusMonths(1);
        // 2.计算key
        String format = time.format(DateTimeFormatter.ofPattern("yyyyMM"));
        String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + format;
        // 3.删除
        redisTemplate.unlink(key);
    }

涉及到的知识点

Redis中zrank和zrange命令

1.ZRANK:获取成员的排名

作用
  • 返回指定成员在有序集合中的排名(从低到高排序,0为最低分)
  • 如果成员不存在,返回 (nil)
语法
ZRANK key member

逆序排名:若需要从高到低的排名,使用 ZREVRANK

示例
# 添加数据
ZADD scores 100 "Alice" 200 "Bob" 300 "Charlie"

# 查询排名
ZRANK scores "Bob"   # 返回 1(Alice是0,Bob是1,Charlie是2)
ZRANK scores "Dave"  # 返回 (nil)

2.ZRANGE:获取指定范围的成员

作用
  • 返回有序集合中索引范围内的成员(默认按分数升序排列)。
  • 支持附加参数 WITHSCORES 返回成员及其分数。
语法
ZRANGE key start stop [WITHSCORES]

索引规则

  • 索引从0开始,支持负数(如 -1 表示最后一个成员)。
  • 若范围超出实际索引,返回有效部分。

扩展功能(Redis 6.2+):

  • 通过 BYSCORE 按分数范围查询。
  • 通过 REV 按逆序(高到低)返回结果。
  • 例如:ZRANGE key 0 -1 BYSCORE REV WITHSCORES
示例
# 获取前2名(升序)
ZRANGE scores 0 1 WITHSCORES
# 返回:
# 1) "Alice"
# 2) "100"
# 3) "Bob"
# 4) "200"

# 获取所有成员(逆序)
ZRANGE scores 0 -1 REV
# 返回:
# 1) "Charlie"
# 2) "Bob"
# 3) "Alice"

3.ZRANK vs ZRANGE:核心区别

特性ZRANKZRANGE
用途获取单个成员的排名获取索引范围内的多个成员
返回值排名(整数)或 nil成员列表(可选包含分数)
排序方向默认升序(低到高)默认升序,可通过 REV 改为降序
典型场景查询某个用户的排行榜位置分页展示排行榜(如前10名)

4.使用建议

  • 优先使用 ZRANGE 的场景
    • 批量获取排行榜数据(如分页)。
    • 结合 WITHSCORES 同时获取分数。
    • Redis 6.2+ 中利用 BYSCOREREV 实现复杂查询。
  • 优先使用 ZRANK 的场景
    • 需要快速定位单个成员的排名。
    • 结合 ZSCOREZRANK 实现动态排名更新。

5.版本注意事项

  • ZRANGE 在 Redis 6.2+ 支持 BYSCOREBYLEXREV 等参数,旧版本需使用 ZRANGEBYSCOREZREVRANGE 等命令。
  • ZRANK 始终返回升序排名,逆序需用 ZREVRANK

通过合理使用 ZRANKZRANGE,可以高效操作有序集合,适用于排行榜、优先级队列等场景。

6.在项目中的使用

// 计算start和stop 下标都是从零开始
int start = (pageNo - 1) * pageSize;
int end = start + pageSize - 1;
StringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, start, end);

// 从Redis中获取分值
Double score = redisTemplate.opsForZSet().score(key, userId.toString());
// 获取排名 从0开始 需要加一
Long rank = redisTemplate.opsForZSet().reverseRank(key, userId.toString()) + 1;
 

mysql中limit

limit (PageNO-1) * pageSize pageSize

1. 基础语法

SELECT * FROM 表名 LIMIT [偏移量,] 行数;
  • 行数:指定返回的最大记录数。
  • 偏移量(可选):跳过前N条记录后开始返回(默认为0)。
    语法等价形式
LIMIT 行数 OFFSET 偏移量;
示例
-- 返回前5条记录
SELECT * FROM users LIMIT 5;

-- 跳过前10条,返回接下来的5条(第11-15条)
SELECT * FROM users LIMIT 10, 5;
-- 等价写法
SELECT * FROM users LIMIT 5 OFFSET 10;

2. 核心用途

(1)分页查询
-- 假设每页显示10条数据
-- 第1页
SELECT * FROM products LIMIT 0, 10;
-- 第2页
SELECT * FROM products LIMIT 10, 10;
-- 第n页(偏移量计算公式:offset = (page_num - 1) * page_size)
SELECT * FROM products LIMIT (n-1)*10, 10;
(2)快速取样
-- 随机取3条记录(结合ORDER BY RAND())
SELECT * FROM logs ORDER BY RAND() LIMIT 3;

-- 取最新插入的10条记录(按时间倒序)
SELECT * FROM orders ORDER BY create_time DESC LIMIT 10;
(3)性能优化
  • 对于大表查询,LIMIT 可减少结果集大小,降低内存和网络开销。
  • 结合索引使用可加速查询(需确保 ORDER BY 的字段有索引)。

3. 注意事项

(1)必须与 ORDER BY 配合
  • 若未指定 ORDER BY,查询结果的顺序可能不稳定(受数据存储物理顺序影响)。
  • 正确写法
SELECT * FROM users ORDER BY id DESC LIMIT 5;
(2)避免大偏移量性能问题
  • 偏移量 很大时(如 LIMIT 1000000, 10),MySQL需遍历前1000000条记录,效率极低。
  • 优化方案
    • 使用覆盖索引:仅查询索引字段。
    • 改用游标分页(基于上一页的最后一条记录的ID):
-- 传统分页(慢)
SELECT * FROM orders LIMIT 100000, 10;

-- 游标分页(快)
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 10;
(3)与聚合函数结合
  • LIMIT 在聚合后生效,需注意逻辑顺序:
-- 错误:LIMIT 在聚合前执行,可能返回不完整的分组统计
SELECT COUNT(*) FROM sales GROUP BY product_id LIMIT 5;

-- 正确:先聚合,再限制结果行数
SELECT product_id, COUNT(*) AS total 
FROM sales 
GROUP BY product_id 
ORDER BY total DESC 
LIMIT 5;

4. 特殊场景

(1)动态分页
  • 结合变量实现动态偏移量:
SET @page_num = 2, @page_size = 10;
SELECT * FROM users 
LIMIT (@page_num - 1) * @page_size, @page_size;
**(2)联合查询中的 **LIMIT
  • 在子查询中使用 LIMIT 需谨慎,可能影响最终结果逻辑:
-- 获取每个分类下销量前3的商品
SELECT category_id, product_id, sales
FROM (
  SELECT 
    category_id, 
    product_id, 
    sales,
    ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY sales DESC) AS rank
  FROM products
) AS ranked
WHERE rank <= 3;

分布式任务调度

导入依赖

<!--xxl-job-->
<dependency>
  <groupId>com.xuxueli</groupId>
  <artifactId>xxl-job-core</artifactId>
</dependency>

配置执行器

@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
    logger.info(">>>>>>>>>>> xxl-job config init.");
    XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
    xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
    xxlJobSpringExecutor.setAppname(appname);
    xxlJobSpringExecutor.setIp(ip);
    xxlJobSpringExecutor.setPort(port);
    xxlJobSpringExecutor.setAccessToken(accessToken);
    xxlJobSpringExecutor.setLogPath(logPath);
    xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);

    return xxlJobSpringExecutor;
}

参数说明:

  • adminAddress:调度中心地址,天机学堂中就是填虚拟机地址
  • appname:微服务名称
  • ip和port:当前执行器的ip和端口,无需配置,自动获取
  • accessToken:访问令牌,在调度中心中配置令牌,所有执行器访问时都必须携带该令牌,否则无法访问。咱们项目的令牌已经配好,就是tianji。如果要修改,可以到虚拟机的/usr/local/src/xxl-job/application.properties文件中,修改xxl.job.accessToken属性,然后重启XXL-JOB即可。
  • logPath:任务运行日志的保存目录
  • logRetentionDays:日志最长保留时长

准备配置文件和配置类并使其生效

XxlJobConfig.java
@Slf4j
@Configuration
@ConditionalOnClass(XxlJobSpringExecutor.class)
@EnableConfigurationProperties(XxlJobProperties.class)
public class XxlJobConfig {

    @Bean
    public XxlJobSpringExecutor xxlJobExecutor(XxlJobProperties prop) {
        log.info(">>>>>>>>>>> xxl-job config init.");
        XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
        XxlJobProperties.Admin admin = prop.getAdmin();
        if (admin != null && StringUtils.isNotEmpty(admin.getAddress())) {
            xxlJobSpringExecutor.setAdminAddresses(admin.getAddress());
        }
        XxlJobProperties.Executor executor = prop.getExecutor();
        if (executor != null) {
            if (executor.getAppName() != null)
                xxlJobSpringExecutor.setAppname(executor.getAppName());
            if (executor.getIp() != null)
                xxlJobSpringExecutor.setIp(executor.getIp());
            if (executor.getPort() != null)
                xxlJobSpringExecutor.setPort(executor.getPort());
            if (executor.getLogPath() != null)
                xxlJobSpringExecutor.setLogPath(executor.getLogPath());
            if (executor.getLogRetentionDays() != null)
                xxlJobSpringExecutor.setLogRetentionDays(executor.getLogRetentionDays());
        }
        if (prop.getAccessToken() != null)
            xxlJobSpringExecutor.setAccessToken(prop.getAccessToken());
        log.info(">>>>>>>>>>> xxl-job config end.");
        return xxlJobSpringExecutor;
    }
}
XxlJobProperties.java
@Data
@ConfigurationProperties(prefix = "tj.xxl-job")
public class XxlJobProperties {

    private String accessToken;
    private Admin admin;
    private Executor executor;

    @Data
    public static class Admin {
        private String address;
    }

    @Data
    public static class Executor {
        private String appName;
        private String address;
        private String ip;
        private Integer port;
        private String logPath;
        private Integer logRetentionDays;

    }
}
spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
 路径+XxlJobConfig

在nacos中配置公共配置文件注入数据到XxlJobProperties

tj:
  xxl-job:
    access-token: tianji
    admin:
      address: http://192.168.150.101:8880/xxl-job-admin
    executor:
      appname: ${spring.application.name}
      log-retention-days: 10
      logPath: job/${spring.application.name}

在本地需要读取nacos的公共配置文件的模块中配置bootstrap.yml

spring:
  profiles:
    active: dev
  application:
    name: 模块名
  cloud:
    nacos:
      config:
        file-extension: yaml
        shared-configs: # 共享配置
          - data-id: shared-xxljob.yaml #共享xxljob配置
            refresh: false

创建一个spring的bean

@Slf4j
@Comment
public class XxljobDemo{
   @XxlJob("xxltest")
   public void test(){
     System.out.println(new Date());
   }
}

注册执行器

在弹出的窗口中填写信息:

配置任务调度

进入任务管理菜单,选中学习中心执行器,然后新增任务:

在弹出表单中,填写任务调度信息:

其中比较关键的几个配置:

  • 调度配置:也就是什么时候执行,一般选择cron表达式
  • 任务配置:采用BEAN模式,指定JobHandler,这里指定的就是在项目中@XxlJob注解中的任务名称
  • 路由策略:就是指如果有多个任务执行器,该由谁执行?这里支持的策略非常多:

路由策略说明:

  • FIRST(第一个):固定选择第一个执行器;
  • LAST(最后一个):固定选择最后一个执行器;
  • ROUND(轮询):在线的执行器按照轮询策略选择一个执行
  • RANDOM(随机):随机选择在线的执行器;
  • CONSISTENT_HASH(一致性HASH):每个任务按照Hash算法固定选择某一台执行器,且所有任务均匀散列在不同执行器上。
  • LEAST_FREQUENTLY_USED(最不经常使用):使用频率最低的执行器优先被选举;
  • LEAST_RECENTLY_USED(最近最久未使用):最久未使用的执行器优先被选举;
  • FAILOVER(故障转移):按照顺序依次进行心跳检测,第一个心跳检测成功的执行器选定为目标执行器并发起调度;
  • BUSYOVER(忙碌转移):按照顺序依次进行空闲检测,第一个空闲检测成功的执行器选定为目标执行器并发起调度;
  • SHARDING_BROADCAST(分片广播):广播触发对应集群中所有执行器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务

执行一次

当任务配置完成后,就会按照设置的调度策略,定期去执行了。不过,我们想要测试的话也可以手动执行一次任务。

在任务管理界面,点击要执行的任务后面的操作按钮,点击执行一次

然后在弹出的窗口中,直接点保存即可执行:

注意,如果是分片广播模式, 这里还可以填写一些任务参数。

然后在调度日志中,可以看到执行成功的日志信息:

@TableField

作用

  1. 控制字段映射:在实体类与数据库表进行映射时,<font style="color:rgb(0, 0, 0);">TableField</font> 注解可以指定实体类属性与数据库表字段之间的对应关系。如果数据库表字段名和实体类属性名不一致,可以通过该注解进行指定。
  2. 设置字段特性:可以用来设置字段在数据库操作中的一些特性,如是否为表中的字段(是否参与 SQL 语句的生成)、字段的填充策略等。
  3. **<font style="color:rgb(0, 0, 0);">value</font>**:指定数据库表中的字段名。当实体类属性名与数据库表字段名不一致时,通过此属性指定对应的表字段名。例如:java
@TableField(value = "user_name")
private String username;

  1. **<font style="color:rgb(0, 0, 0);">exist</font>**:布尔类型,默认为 <font style="color:rgb(0, 0, 0);">true</font>。表示该属性是否为数据库表中的字段。如果设置为 <font style="color:rgb(0, 0, 0);">false</font>,则在生成 SQL 语句时,该属性不会参与。例如,在实体类中有一个用于临时计算或业务逻辑处理的属性,不需要与数据库表字段映射,可以设置 <font style="color:rgb(0, 0, 0);">@TableField(exist = false)</font>
  2. **<font style="color:rgb(0, 0, 0);">fill</font>**:用于指定字段的填充策略。常见的填充策略有 <font style="color:rgb(0, 0, 0);">FieldFill.DEFAULT</font>(默认不处理)、<font style="color:rgb(0, 0, 0);">FieldFill.INSERT</font>(插入时填充)、<font style="color:rgb(0, 0, 0);">FieldFill.UPDATE</font>(更新时填充)、<font style="color:rgb(0, 0, 0);">FieldFill.INSERT_UPDATE</font>(插入和更新时都填充)。结合自定义的元对象处理器 <font style="color:rgb(0, 0, 0);">MetaObjectHandler</font> 来实现自动填充功能。例如:java
@TableField(fill = FieldFill.INSERT)
private Date createTime;

  1. **<font style="color:rgb(0, 0, 0);">select</font>**:布尔类型,默认为 <font style="color:rgb(0, 0, 0);">true</font>。表示在进行查询操作时,该字段是否包含在 <font style="color:rgb(0, 0, 0);">SELECT</font> 语句中。如果设置为 <font style="color:rgb(0, 0, 0);">false</font>,查询时不会选择该字段。例如:java
@TableField(select = false)
private String password;
这样在查询用户信息时,密码字段就不会被查询出来,提高安全性。

Redis中del和unlink区别

在Redis中,DELUNLINK命令都用于删除键,但它们在实现机制和对系统性能的影响上有显著区别:

1. 同步 vs 异步

  • DEL** 命令**:同步删除。直接删除键及其关联的数据,操作会立即释放内存。如果删除的键对应大型数据结构(如包含数百万元素的哈希或列表),DEL 可能会阻塞主线程,导致其他请求延迟。
  • UNLINK** 命令**:异步删除。首先将键从键空间(keyspace)中移除(逻辑删除),后续的内存回收由后台线程处理。命令立即返回,不会阻塞主线程,适合删除大对象。

2. 性能影响

  • DEL:删除大键时可能引发明显延迟,影响Redis的响应时间。
  • UNLINK:几乎无阻塞,适合高吞吐场景,尤其适用于需要频繁删除大键的情况。

3. 使用场景

  • DEL:适合删除小键或对内存释放时效性要求高的场景(如避免内存不足)。
  • UNLINK:推荐在大多数情况下使用,尤其是删除大键或需要低延迟的场景。

4. 返回值

  • 两者均返回被删除键的数量,但UNLINK返回时数据可能尚未完全释放。

5. 版本要求

  • UNLINKRedis 4.0 引入,需确保版本支持;DEL 在所有版本中可用。

示例对比

# 同步删除,可能阻塞主线程
DEL large_key

# 异步删除,立即返回,后台清理
UNLINK large_key

总结

特性DELUNLINK
删除方式同步异步
阻塞主线程是(大键时)
适用场景小键或需立即释放内存大键或高并发场景
版本支持所有版本Redis 4.0+

建议:优先使用UNLINK以提升系统响应速度,除非明确需要同步释放内存。

day9

接口

优惠券管理

新增优惠券

接口说明新增优惠券功能,如果优惠券有限定使用范围,注意保持范围数据
请求方式POST
请求路径/coupons
请求参数plain { "name" : "新年大促", // 优惠券名称 "specific" : true, // 是否限定使用范围 "scopes": ["2001", "2004", "2007"], // 限定的分类id集合 "discountType" : 1, // 折扣类型 "thresholdAmount" : 100, // 折扣阈值,0代表无门槛 "discountValue" : 15, // 优惠值。满减填优惠金额,折扣填折扣值 "maxDiscountAmount" : 0, // 最大优惠金额 "obtainWay" : 1, // 领取方式,1-手动领取,2-兑换码 "totalNum" : 200, // 优惠券总发放数量 "userLimit" : 1, // 每个人的限领数量 }
返回值
tj-promotion/src/main/java/com/tianji/promotion/controller/CouponController.java
@ApiOperation("新增优惠券")
@PostMapping
public void saveCoupon(@RequestBody @Validated CouponFormDTO dto) {
    couponService.saveCoupon(dto);
}
com/tianji/promotion/service/ICouponService.java
void saveCoupon(CouponFormDTO dto);
tj-promotion/src/main/java/com/tianji/promotion/service/impl/CouponServiceImpl.java
@Override
public void saveCoupon(CouponFormDTO dto) {
    //dto转po 保存优惠卷 coupon表
    Coupon coupon = BeanUtils.copyBean(dto, Coupon.class);
    this.save(coupon);
    //判断是否限定了范围 dto.specific
    if (!dto.getSpecific()){
        return;
    }
    //如果dto.specific为true 需要校验dto.scopes
    List<Long> scopes = dto.getScopes();
    if (CollUtils.isEmpty(scopes)){
        throw new BadRequestException("分类id不能为空");
    }
    //保存优惠券的限定范围 coupon_scope

    List<CouponScope> csList = scopes
                        .stream()
                        .map(aLong -> new CouponScope().setCouponId(coupon.getId())
                        .setBizId(aLong)
                        .setType(1))
                        .collect(Collectors.toList());
    couponScopeService.saveBatch(csList);
测试时注意:添加优惠券时优惠券名称需要不少于4

修改优惠券

  • 请求方式:PUT
  • 请求路径:/coupons/{id}
  • 请求参数:与新增类似,参考新增接口。
  • 返回值:无
tj-promotion/src/main/java/com/tianji/promotion/controller/CouponController.java
@ApiOperation("修改优惠券")
@PutMapping("{id}")
public void updateById(@RequestBody @Valid CouponFormDTO dto, @PathVariable("id") Long id){
    couponService.updateCouponById(dto, id);
}
com/tianji/promotion/service/ICouponService.java
void updateCouponById(@Valid CouponFormDTO dto, Long id);
tj-promotion/src/main/java/com/tianji/promotion/service/impl/CouponServiceImpl.java
@Override
public void updateCouponById(CouponFormDTO dto, Long id) {
    // 校验参数
    Long dtoId = dto.getId();
    if ((dtoId != null && id != null && !dtoId.equals(id)) || (dtoId == null && id == null)) {
        throw new BadRequestException("参数错误");
    }
    // 更新优惠券基本信息
    Coupon coupon = BeanUtils.copyBean(dto, Coupon.class);
    // 只更新状态为1的优惠券基本信息,如果失败则是状态已修改
    boolean update = lambdaUpdate().eq(Coupon::getStatus, 1).update(coupon);
    // 基本信息更新失败则无需更新优惠券范围信息
    if (!update) return;
    //  更新优惠券范围信息
    List<Long> scopeIds = dto.getScopes();
    // 优惠券不满减,或优惠券范围为空,则不更新优惠券范围信息
    // 先删除优惠券范围信息,再重新插入
    List<Long> ids = scopeService.lambdaQuery()
    .select(CouponScope::getId).eq(CouponScope::getCouponId, dto.getId()).list()
    .stream().map(CouponScope::getId).collect(Collectors.toList());
    scopeService.removeByIds(ids);
    // 删除成功后,并且有范围再插入
    if (CollUtils.isNotEmpty(scopeIds)) {
        List<CouponScope> lis = scopeIds.stream()
        .map(i -> new CouponScope().setCouponId(dto.getId()).setType(1).setBizId(i))
        .collect(Collectors.toList());
        scopeService.saveBatch(lis);
    }
}

分页查询优惠券

接口说明分页查询优惠券,默认按照创建时间排序
请求方式GET
请求路径/coupons/page
请求参数plain { "pageNo" : 1, // 页码 "pageSize" : 10, // 每页大小 "type" : 1, // 折扣类型 "status" : 1, // 优惠券状态 "name" : "大促", // 优惠券名称关键字 }
返回值plain { "list": [ { "id": "110", // 优惠券id "name": "年中大促", // 优惠券名称 "discountType": 1, // 优惠券折扣类型 "thresholdAmount": 100, // 优惠门槛 "discountValue": 10, // 优惠值 "maxDiscountAmount": 0, // 优惠上限 "specific": true, // 是否限定范围 "obtainWay": 1, // 领取方式 "totalNum": 1000, // 总发放数量 "issueNum": 800, // 已领取数量 "usedNum": 100 // 已使用数量 "createTime": "2023-05-01", // 创建时间 "issueBeginTime": "2023-06-01", // 发放开始时间 "issueEndTime": "2023-06-20", // 发放结束时间 "termBeginTime": "2023-06-10", // 使用有效期开始时间 "termEndTime": "2023-06-30", // 使用有效期结束时间 "termDays": 0, // 有效天数 "status": 1, // 状态 } ], "pages": 0, "total": 0 }
tj-promotion/src/main/java/com/tianji/promotion/controller/CouponController.java
@ApiOperation("分页查询优惠券")
@GetMapping("page")
public PageDTO<CouponPageVO> queryCouponPage(CouponQuery query) {
    return couponService.queryCouponPage(query);
}
com/tianji/promotion/service/ICouponService.java
PageDTO<CouponPageVO> queryCouponPage(CouponQuery query);
tj-promotion/src/main/java/com/tianji/promotion/service/impl/CouponServiceImpl.java
@Override
public PageDTO<CouponPageVO> queryCouponPage(CouponQuery query) {
    //分页查询优惠券表 coupon
    Page<Coupon> page = this.lambdaQuery()
            .eq(query.getType() != null, Coupon::getDiscountType, query.getType())
            .eq(query.getStatus() != null, Coupon::getStatus, query.getStatus())
            .like(StringUtils.isNotBlank(query.getName()), Coupon::getName, query.getName())
            .page(query.toMpPageDefaultSortByCreateTimeDesc());
    List<Coupon> records = page.getRecords();
    if (CollUtils.isEmpty(records)){
        return PageDTO.empty(page);
    }

    //封装vo返回
    List<CouponPageVO> voList = BeanUtils.copyList(records, CouponPageVO.class);
    return PageDTO.of(page,voList);
}

根据id查询优惠券

接口说明都需要根据id查询优惠券的详细信息
请求方式GET
请求路径/coupons/{id}
请求参数路径占位符id
返回值plain { "id": "110", // 优惠券id "name": "年中大促", // 优惠券名称 "discountType": 1, // 优惠券折扣类型 "thresholdAmount": 100, // 优惠门槛 "discountValue": 10, // 优惠值 "maxDiscountAmount": 0, // 优惠上限 "specific": true, // 是否限定范围 "scopes": [ // 限定的分类 {"id": "2001", "name": "IT互联网"} ] "obtainWay": 1, // 领取方式 "totalNum": 1000, // 总发放数量 "useLimit": 1, // 限领数量 "issueBeginTime": "2023-06-01", // 发放开始时间 "issueEndTime": "2023-06-20", // 发放结束时间 "termBeginTime": "2023-06-10", // 使用有效期开始时间 "termEndTime": "2023-06-30", // 使用有效期结束时间 "termDays": 0, // 有效天数 }
tj-promotion/src/main/java/com/tianji/promotion/controller/CouponController.java
@ApiOperation("修改优惠券")
@PutMapping("{id}")
public void updateById(@RequestBody @Valid CouponFormDTO dto, @PathVariable("id") Long id){
    couponService.updateCouponById(dto, id);
}
com/tianji/promotion/service/ICouponService.java
void updateCouponById(@Valid CouponFormDTO dto, Long id);
tj-promotion/src/main/java/com/tianji/promotion/service/impl/CouponServiceImpl.java

删除优惠券

  • 请求方式:DELETE
  • 请求路径:/coupons/{id}
  • 请求参数:与新增类似,参考新增接口。
  • 返回值:无
tj-promotion/src/main/java/com/tianji/promotion/controller/CouponController.java
@ApiOperation("删除优惠券")
@DeleteMapping("{id}")
public void deleteById(@ApiParam("优惠券id") @PathVariable("id") Long id){
    couponService.deleteById(id);
}
com/tianji/promotion/service/ICouponService.java
void deleteById(Long id);
tj-promotion/src/main/java/com/tianji/promotion/service/impl/CouponServiceImpl.java

优惠券发放

发放优惠券

接口说明发放优惠券
请求方式PUT
请求路径/coupons/{id}/issue
请求参数plain { "issueBeginTime": "2023-06-01", // 发放开始时间 "issueEndTime": "2023-06-20", // 发放结束时间 "termBeginTime": "2023-06-10", // 使用有效期开始时间 "termEndTime": "2023-06-30", // 使用有效期结束时间 "termDays": 0, // 有效天数 }
返回值
tj-promotion/src/main/java/com/tianji/promotion/controller/CouponController.java
@ApiOperation("发放优惠券")
@PutMapping("/{id}/issue")
public void issueCoupon(@PathVariable Long id,
                        @RequestBody @Valid CouponIssueFormDTO dto) {
    couponService.issueCoupon(id,dto);
}
com/tianji/promotion/service/ICouponService.java
void issueCoupon(Long id, @Valid CouponIssueFormDTO dto);
tj-promotion/src/main/java/com/tianji/promotion/service/impl/CouponServiceImpl.java
    @Override
    public void issueCoupon(Long id, CouponIssueFormDTO dto) {
        //校验
        if (id == null || !id.equals(dto.getId())) {
            throw new BadRequestException("非法参数");
        }

        //校验优惠券的id是否存在
        Coupon coupon = this.getById(id);
        if (coupon == null) {
            throw new BadRequestException("该优惠券不存在");
        }
        //校验优惠券状态 只有待发放和暂停状态才能发放
        if (coupon.getStatus() != CouponStatus.DRAFT && coupon.getStatus() != CouponStatus.PAUSE){
            throw new BizIllegalException("只有待发放和暂停状态才能发放");
        }

        LocalDateTime now = LocalDateTime.now();
        boolean isBeginIssue = dto.getIssueBeginTime() == null || !dto.getIssueBeginTime().isAfter(now);
        //修改优惠券的 领取开始和结束日期 使用有效期和结束日 天数 状态

        /*方式1
        if (isBeginIssue) {
            //代表是立刻发放
            coupon.setIssueBeginTime(now);
            coupon.setIssueEndTime(dto.getIssueEndTime());
            coupon.setStatus(CouponStatus.ISSUING);
            coupon.setTermDays(dto.getTermDays());
            coupon.setTermBeginTime(dto.getTermBeginTime());
            coupon.setTermEndTime(dto.getTermEndTime());
        }else {
            //代表是定时发放
            coupon.setIssueBeginTime(dto.getIssueBeginTime());
            coupon.setIssueEndTime(dto.getIssueEndTime());
            coupon.setStatus(CouponStatus.UN_ISSUE);
            coupon.setTermDays(dto.getTermDays());
            coupon.setTermBeginTime(dto.getTermBeginTime());
            coupon.setTermEndTime(dto.getTermEndTime());
        }

         */
        Coupon tmp = BeanUtils.copyBean(dto, Coupon.class);
        if (isBeginIssue) {
            //立刻发放
            tmp.setStatus(CouponStatus.ISSUING);
            tmp.setIssueBeginTime(now);
        }else {
            tmp.setStatus(CouponStatus.UN_ISSUE);
        }
        this.updateById(tmp);

        // 如果优惠券的领取方式为指定发放 需要生成兑换码
        if (coupon.getObtainWay() == ObtainType.ISSUE && coupon.getStatus() == CouponStatus.DRAFT){
            //兑换码的过期时间 就是优惠券领取的截至时间
            coupon.setIssueEndTime(tmp.getIssueEndTime());
            //异步生成兑换码
            exchangeCodeService.asyncGenerateExchangeCode(coupon);
        }
    }
com/tianji/promotion/service/impl/ExchangeCodeServiceImpl.java
    //异步生成兑换码
    @Override
    @Async("generateExchangeCodeExecutor")//使用generateExchangeCodeExecutor 自定义线程池中的线程异步执行
    public void asyncGenerateExchangeCode(Coupon coupon) {
        log.debug("生成兑换码  线程名{}",Thread.currentThread().getName());
        // 代表优惠券的发放方法总数量 即需要生成兑换码的总数量
        Integer totalNum = coupon.getTotalNum();
        // 先调用incrby 得到自增id最大值 然后在循环生成兑换码

        // 生成自增id 借助redis incrby
        Long increment = redisTemplate.opsForValue().increment(COUPON_CODE_SERIAL_KEY + totalNum);
        if (increment == null) {
            return;
        }
        int maxSerialNum = increment.intValue();// 本地自增id最大值
        int begin = maxSerialNum - totalNum + 1;// 自增id 开始值
        // 调用工具类 循环生成兑换码
        List<ExchangeCode> list = new ArrayList<>();
        for (int serialNum = begin; serialNum <= maxSerialNum; serialNum++) {
            String code = CodeUtil.generateCode(serialNum, coupon.getId());// 分别是自增id值 优惠券id
            ExchangeCode exchangeCode = new ExchangeCode();
            exchangeCode.setId(serialNum);// 兑换码id  ExchangeCode的主键生成策略改为INPUT
            exchangeCode.setCode(code);
            exchangeCode.setExchangeTargetId(coupon.getId());// 优惠券id
            exchangeCode.setExpiredTime(coupon.getIssueEndTime());// 兑换码 兑换的截止时间 就是优惠券领取的截止时间
            list.add(exchangeCode);
        }
        // 将兑换码保存db  批量保存
        this.saveBatch(list);

        // 写入Redis缓存,member:couponId,score:兑换码的最大序列号
        redisTemplate.opsForZSet().add(COUPON_RANGE_KEY, coupon.getId().toString(), maxSerialNum);
    }

暂停发放

  • 请求方式:PUT
  • 请求路径:/coupons/{id}/pause
  • 请求参数:路径占位符id
  • 返回值:无
tj-promotion/src/main/java/com/tianji/promotion/controller/CouponController.java
@ApiOperation("停发优惠券")
@PutMapping("/{id}/pause")
public void pauseIssue(@ApiParam("优惠券id") @PathVariable("id") long id) {
    couponService.pauseIssue(id);
}
com/tianji/promotion/service/ICouponService.java
void pauseIssue(long id);
tj-promotion/src/main/java/com/tianji/promotion/service/impl/CouponServiceImpl.java
@Override
public void pauseIssue(long id) {
    this.lambdaUpdate()
            .eq(Coupon::getId, id)
            .set(Coupon::getStatus, CouponStatus.PAUSE)
            .update();
}

生成兑换码

工具类代码资料已提供

查询兑换码

  • 请求方式:GET
  • 请求路径:/codes/page
  • 请求参数:
    • 分页参数
    • 兑换码状态
    • 有一个隐含条件,就是优惠券id,毕竟查询的是某一个优惠券的兑换码。
  • 返回值:传统分页结果,分页数据保护两个字段:
    • code:兑换码
    • id:兑换码id

定时任务

定时发放优惠券

定时结束优惠券

涉及的知识点

day10

接口

查询发放中的优惠券

接口说明查询发放中的优惠券
请求方式GET
请求路径/coupons/list
请求参数
返回值json [ { "id": "110", // 优惠券id "name": "年中大促", // 优惠券名称 "specific": true, // 优惠券是否限定了课程范围 "discountType": "", // 折扣类型 "thresholdAmount": 0 // 折扣门槛 "discountValue": 0, // 折扣值 "maxDiscountAmount": 0, // 最大折扣金额 "termDays": 0, // 有效天数 "termEndTime": "", // 过期时间 "available": true, // 是否可领取 "received": true, // 是否已领取 } ]
com/tianji/promotion/controller/CouponController.java
@ApiOperation("查询发放中的优惠券列表-用户端")
@GetMapping("list")
public List<CouponVO> queryIssuingCoupons() {
    return couponService.queryIssuingCoupons();
}
com/tianji/promotion/service/ICouponService.java
List<CouponVO> queryIssuingCoupons();
com/tianji/promotion/service/impl/CouponServiceImpl.java
@Override
public List<CouponVO> queryIssuingCoupons() {
    //查询db coupon
    List<Coupon> couponList = this.lambdaQuery()
    .eq(Coupon::getStatus, CouponStatus.ISSUING)
    .eq(Coupon::getObtainWay, ObtainType.PUBLIC).list();
    if (CollUtils.isEmpty(couponList)) {
        return CollUtils.emptyList();
    }

    //查询用户券表  条件 当前用户 正在发放中的优惠券id
    // 正在发放中的优惠券id集合
    Set<Long> couponIds = couponList.stream().map(Coupon::getId).collect(Collectors.toSet());
    //当前用户针对正在发放中的优惠券领取记录
    List<UserCoupon> list = userCouponService.lambdaQuery()
    .eq(UserCoupon::getUserId, UserContext.getUser())
    .in(UserCoupon::getCouponId, couponIds)
    .list();
    //针对每一个已领券的数量
    Map<Long, Long> issueMap = list.stream().collect(Collectors.groupingBy(UserCoupon::getCouponId, Collectors.counting()));
    //针对每一个已领券且未使用的数量
    Map<Long, Long> unUseMap = list.stream().filter(c -> c.getStatus() == UserCouponStatus.UNUSED).collect(Collectors.groupingBy(UserCoupon::getCouponId, Collectors.counting()));

    //po转vo返回
    List<CouponVO> voList = new ArrayList<>();
    for (Coupon c : couponList) {
        CouponVO vo = BeanUtils.copyBean(c, CouponVO.class);
        //优惠券还有剩余 已领数量小于限领数量
        Long issNum = issueMap.getOrDefault(c.getId(), 0L);
        boolean available = c.getIssueNum() < c.getTotalNum() && issNum.intValue() < c.getUserLimit();
        vo.setAvailable(available);//是否可以领取
        //已领取且未使用
        boolean received = unUseMap.getOrDefault(c.getId(), 0L) > 0;
        vo.setReceived(received);//是否可以使用
        voList.add(vo);
    }
    return voList;
}

领取优惠券

com/tianji/promotion/controller/UserCouponController.java
@ApiOperation("领取优惠券")
@PostMapping("{id}/receive")
public void receiveCoupon(@PathVariable Long id){
    userCouponService.receiveCoupon(id);
}
com/tianji/promotion/service/IUserCouponService.java
void receiveCoupon(Long id);
com/tianji/promotion/service/impl/UserCouponServiceImpl.java
@Override
@Transactional
public void receiveCoupon(Long id) {
    // 根据id查询优惠券列表
    // 校验
    if (id == null) {
        throw new BadRequestException("非法参数");
    }
    Coupon coupon = couponMapper.selectById(id);
    if (coupon == null) {
        throw new BadRequestException("优惠券不存在");
    }
    if (coupon.getStatus() != CouponStatus.ISSUING) {
        throw new BadRequestException("该优惠券状态不是正在发放");
    }
    LocalDateTime now = LocalDateTime.now();
    if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) {
        throw new BadRequestException("该优惠券已过期或未开始发放");
    }
    if (coupon.getTotalNum() <= 0 || coupon.getIssueNum() >= coupon.getTotalNum()) {
        throw new BadRequestException("该优惠券库存不足");
    }

    // 获取当前用户对该优惠券已领数量
    Long userId = UserContext.getUser();
    Integer count = this.lambdaQuery()
            .eq(UserCoupon::getUserId, userId)
            .eq(UserCoupon::getCouponId, coupon.getId())
            .count();
    if (count != null && count > coupon.getUserLimit()) {
        throw new BadRequestException("已达到领取上限");
    }

    // 优惠券数量加1
    couponMapper.incrIssueNum(id);

    //生成用户券
    saveUserCoupon(userId,coupon);
}

//保存用户券
private void saveUserCoupon(Long userId, Coupon coupon) {
    UserCoupon userCoupon = new UserCoupon();
    userCoupon.setUserId(userId);
    userCoupon.setCouponId(coupon.getId());
    LocalDateTime termBeginTime = coupon.getTermBeginTime();
    LocalDateTime termEndTime = coupon.getTermEndTime();
    if (termBeginTime == null && termEndTime == null) {
        termBeginTime = LocalDateTime.now();
        termEndTime = termBeginTime.plusDays(coupon.getTermDays());
    }
    userCoupon.setTermBeginTime(termBeginTime);
    userCoupon.setTermEndTime(termEndTime);
    this.save(userCoupon);
}

兑换码兑换优惠券

com/tianji/promotion/controller/UserCouponController.java
@ApiOperation("兑换码兑换优惠券")
@PostMapping("{code}/exchange")
public void exchangeCoupon(@PathVariable String code){
    userCouponService.exchangeCoupon(code);
}
com/tianji/promotion/service/IUserCouponService.java
void exchangeCoupon(String code);
com/tianji/promotion/service/impl/UserCouponServiceImpl.java
@Override
public void exchangeCoupon(String code) {
    // 校验
    if (StringUtils.isBlank(code)) {
        throw new BadRequestException("非法参数");
    }
    // 解析兑换码得到自增id
    long serialNum = CodeUtil.parseCode(code);
    // 判断兑换码是否已兑换
    boolean result = exchangeCodeService.updateExchangeCodeMark(serialNum, true);
    if (result) {
        //说明兑换码已经被兑换了
        throw new BizIllegalException("兑换码已经被使用了");
    }
    try {
        // 查询兑换码是否存在 根据自增id查询
        ExchangeCode exchangeCode = exchangeCodeService.getById(serialNum);
        if (exchangeCode == null) {
            throw new BizIllegalException("兑换码不存在");
        }
        //判断是否过期
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime expiredTime = exchangeCode.getExpiredTime();
        if (now.isAfter(expiredTime)) {
            throw new BizIllegalException("兑换码已过期");
        }
        Long userId = UserContext.getUser();

        Coupon coupon = couponMapper.selectById(exchangeCode.getExchangeTargetId());
        if (coupon == null) {
            throw new BizIllegalException("优惠券不存在");
        }
        checkAndCreateUserCoupon(userId,coupon,serialNum);
    } catch (Exception e) {
        // 将兑换码状态重置
        exchangeCodeService.updateExchangeCodeMark(serialNum, false);
        throw e;
    }
}

private void checkAndCreateUserCoupon(Long userId, Coupon coupon, Long serialNum) {
    // 获取当前用户对该优惠券已领数量
    Integer count = this.lambdaQuery()
            .eq(UserCoupon::getUserId, userId)
            .eq(UserCoupon::getCouponId, coupon.getId())
            .count();
    if (count != null && count >= coupon.getUserLimit()) {
        throw new BadRequestException("已达到领取上限");
    }

    // 优惠券数量+1
    couponMapper.incrIssueNum(coupon.getId());

    // 生成用户券
    saveUserCoupon(userId, coupon);
    // 更新兑换码状态
    if (serialNum != null){
        //修改兑换码状态
        exchangeCodeService.lambdaUpdate()
                .set(ExchangeCode::getStatus, ExchangeCodeStatus.USED)
                .set(ExchangeCode::getUserId, userId)
                .eq(ExchangeCode::getId,serialNum)
                .update();
    }
}
com/tianji/promotion/mapper/CouponMapper.java
//更新优惠券已领取数量
@Update("update coupon set issue_num = issue_num + 1 where id = #{id} ")
int incrIssueNum(Long id);

超卖问题

解决办法 加乐观锁
com/tianji/promotion/mapper/CouponMapper.java
//更新优惠券已领取数量
@Update("update coupon set issue_num = issue_num + 1 where id = #{id} and issue_num < total_num")
int incrIssueNum(Long id);

锁失效问题

解决办法 加悲观锁
com/tianji/promotion/service/impl/UserCouponServiceImpl.java
private void checkAndCreateUserCoupon(Long userId, Coupon coupon, Long serialNum) {
    //intern方法是强制从常量池中取字符串
    synchronized (userId.toString().intern()) {
        // 获取当前用户对该优惠券已领数量
        Integer count = this.lambdaQuery()
        .eq(UserCoupon::getUserId, userId)
        .eq(UserCoupon::getCouponId, coupon.getId())
        .count();
        if (count != null && count >= coupon.getUserLimit()) {
            throw new BadRequestException("已达到领取上限");
        }

        // 优惠券数量+1
        couponMapper.incrIssueNum(coupon.getId());

        // 生成用户券
        saveUserCoupon(userId, coupon);
        // 更新兑换码状态
        if (serialNum != null) {
            // 修改兑换码状态
            exchangeCodeService.lambdaUpdate()
            .set(ExchangeCode::getStatus, ExchangeCodeStatus.USED)
            .set(ExchangeCode::getUserId, userId)
            .eq(ExchangeCode::getId, serialNum)
            .update();
        }
    }
}

事务边界问题

com/tianji/promotion/service/impl/UserCouponServiceImpl.java
@Override
// @Transactional
public void receiveCoupon(Long id) {
    // 根据id查询优惠券列表
    // 校验
    if (id == null) {
        throw new BadRequestException("非法参数");
    }
    Coupon coupon = couponMapper.selectById(id);
    if (coupon == null) {
        throw new BadRequestException("优惠券不存在");
    }
    if (coupon.getStatus() != CouponStatus.ISSUING) {
        throw new BadRequestException("该优惠券状态不是正在发放");
    }
    LocalDateTime now = LocalDateTime.now();
    if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) {
        throw new BadRequestException("该优惠券已过期或未开始发放");
    }
    if (coupon.getTotalNum() <= 0 || coupon.getIssueNum() >= coupon.getTotalNum()) {
        throw new BadRequestException("该优惠券库存不足");
    }

    // 获取当前用户对该优惠券已领数量
    Long userId = UserContext.getUser();
    
    synchronized (userId.toString().intern()) {
        checkAndCreateUserCoupon(userId, coupon, null);
    }
}

@Transactional
public void checkAndCreateUserCoupon(Long userId, Coupon coupon, Long serialNum) {
    // intern方法是强制从常量池中取字符串

    // 获取当前用户对该优惠券已领数量
    Integer count = this.lambdaQuery()
            .eq(UserCoupon::getUserId, userId)
            .eq(UserCoupon::getCouponId, coupon.getId())
            .count();
    if (count != null && count >= coupon.getUserLimit()) {
        throw new BadRequestException("已达到领取上限");
    }

    // 优惠券数量+1
    couponMapper.incrIssueNum(coupon.getId());

    // 生成用户券
    saveUserCoupon(userId, coupon);
    // 更新兑换码状态
    if (serialNum != null) {
        // 修改兑换码状态
        exchangeCodeService.lambdaUpdate()
                .set(ExchangeCode::getStatus, ExchangeCodeStatus.USED)
                .set(ExchangeCode::getUserId, userId)
                .eq(ExchangeCode::getId, serialNum)
                .update();
    }
}

事务失效问题解决

引入AspectJ依赖:
<!--aspecj-->
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>
开启AspectJ的AOP功能 (默认就是开启的) 但是exposeProxy=true需要手动添加去暴露代理对象:

3)使用代理对象

改造领取优惠券的代码,获取代理对象来调用事务方法
com/tianji/promotion/service/impl/UserCouponServiceImpl.java
@Override
// @Transactional
public void receiveCoupon(Long id) {
    // 根据id查询优惠券列表
    // 校验
    if (id == null) {
        throw new BadRequestException("非法参数");
    }
    Coupon coupon = couponMapper.selectById(id);
    if (coupon == null) {
        throw new BadRequestException("优惠券不存在");
    }
    if (coupon.getStatus() != CouponStatus.ISSUING) {
        throw new BadRequestException("该优惠券状态不是正在发放");
    }
    LocalDateTime now = LocalDateTime.now();
    if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) {
        throw new BadRequestException("该优惠券已过期或未开始发放");
    }
    if (coupon.getTotalNum() <= 0 || coupon.getIssueNum() >= coupon.getTotalNum()) {
        throw new BadRequestException("该优惠券库存不足");
    }

    // 获取当前用户对该优惠券已领数量
    Long userId = UserContext.getUser();

    synchronized (userId.toString().intern()) {
        //从AOP上下文中 获取当前类的代理对象
        IUserCouponService userCouponServiceProxy = (IUserCouponService) AopContext.currentProxy();
        userCouponServiceProxy.checkAndCreateUserCoupon(userId, coupon, null);
    }
}
com/tianji/promotion/service/IUserCouponService.java
 void checkAndCreateUserCoupon(Long userId, Coupon coupon, Long serialNum);

涉及到的知识点

事务失效问题分析

分析原因

事务失效的原因有很多,接下来我们就逐一分析一些常见的原因:

事务方法非public修饰

由于Spring的事务是基于AOP的方式结合动态代理来实现的。因此事务方法一定要是public的,这样才能便于被Spring做事务的代理和增强。

而且,在Spring内部也会有一个 org.springframework.transaction.interceptor.AbstractFallbackTransactionAttributeSource类,去检查事务方法的修饰符:

@Nullable
protected TransactionAttribute computeTransactionAttribute(
    Method method, @Nullable Class<?> targetClass) {
    // Don't allow non-public methods, as configured.
    if (allowPublicMethodsOnly() && 
        !Modifier.isPublic(method.getModifiers())) {
        return null;
    }

    // ... 略

    return null;
}

所以,事务方法一定要被public修饰!

非事务方法调用事务方法

有这样一段代码:

@Service
public class OrderService {

    public void createOrder(){
        // ... 准备订单数据

        // 生成订单并扣减库存
        insertOrderAndReduceStock();
    }

    @Transactional
    public void insertOrderAndReduceStock(){
        // 生成订单
        insertOrder();
        // 扣减库存
        reduceStock();
    }
}

可以看到,insertOrderAndReduceStock方法是一个事务方法,肯定会被Spring事务管理。Spring会给OrderService类生成一个动态代理对象,对insertOrderAndReduceStock方法做增加,实现事务效果。

但是现在createOrder方法是一个非事务方法,在其中调用了insertOrderAndReduceStock方法,这个调用其实隐含了一个this.的前缀。也就是说,这里相当于是直接调用原始的OrderService中的普通方法,而非被Spring代理对象的代理方法。那事务肯定就失效了!

事务方法的异常被捕获了

示例:

@Service
public class OrderService {

    @Transactional
    public void createOrder(){
        // ... 准备订单数据
        // 生成订单
        insertOrder();
        // 扣减库存
        reduceStock();
    }

    private void reduceStock() {
        try {
            // ...扣库存
        } catch (Exception e) {
            // 处理异常
        }
    }

}

在这段代码中,reduceStock方法内部直接捕获了Exception类型的异常,也就是说方法执行过程中即便出现了异常也不会向外抛出。

而Spring的事务管理就是要感知业务方法的异常,当捕获到异常后才会回滚事务。

现在事务被捕获,就会导致Spring无法感知事务异常,自然不会回滚,事务就失效了。

事务异常类型不对

示例代码:

@Service
public class OrderService {

    @Transactional(rollbackFor = RuntimeException.class)
    public void createOrder() throws IOException {
        // ... 准备订单数据

        // 生成订单
        insertOrder();
        // 扣减库存
        reduceStock();

        throw new IOException();
    }
}

Spring的事务管理默认感知的异常类型是RuntimeException,当事务方法内部抛出了一个IOException时,不会被Spring捕获,因此就不会触发事务回滚,事务就失效了。

因此,当我们的业务中会抛出RuntimeException以外的异常时,应该通过@Transactional注解中的rollbackFor属性来指定异常类型:

@Transactional(rollbackFor = Exception.class)

事务传播行为不对

示例代码:

@Service
public class OrderService {
    @Transactional
    public void createOrder(){
        // 生成订单
        insertOrder();
        // 扣减库存
        reduceStock();
        throw new RuntimeException("业务异常");
    }
    @Transactional
    public void insertOrder() {
    }
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void reduceStock() {
    }
}

在示例代码中,事务的入口是createOrder()方法,会开启一个事务,可以成为外部事务。在createOrder()方法内部又调用了insertOrder()方法和reduceStock()方法。这两个都是事务方法。

不过,reduceStock()方法的事务传播行为是REQUIRES_NEW,这会导致在进入reduceStock()方法时会创建一个新的事务,可以成为子事务。insertOrder()则是默认,因此会与createOrder()合并事务。

因此,当createOrder方法最后抛出异常时,只会导致insertOrder方法回滚,而不会导致reduceStock方法回滚,因为reduceStock是一个独立事务。

所以,一定要慎用传播行为,注意外部事务与内部事务之间的关系。

没有被Spring管理

示例代码:

//  @Service
public class OrderService {
    @Transactional
    public void createOrder(){
        // 生成订单
        insertOrder();
        // 扣减库存
        reduceStock();
        throw new RuntimeException("业务异常");
    }
    @Transactional
    public void insertOrder() {
    }
    @Transactional
    public void reduceStock() {
    }
}

这个示例属于比较低级的错误,OrderService类没有添加@Service注解,因此就没有被Spring管理。你在方法上添加的@Transactional注解根本不会有人帮你动态代理,事务自然失效。

当然,有同学会说,我不会犯这么低级的错误。这可不一定,有的时候你没有忘了加@Service注解,但是你在获取某个对象的时候,可能并不是获取的Spring管理的对象,有可能是其它方式创建的。这同样会导致事务失效。

解决方案

我们可以借助AspectJ来实现。

1)引入AspectJ依赖:

<!--aspecj-->
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

2)暴露代理对象

在启动类上添加注解,暴露代理对象:

3)使用代理对象

最后,改造领取优惠券的代码,获取代理对象来调用事务方法:

问题解决。

day11

接口

分布式锁改造

Redis工具类实现分布式锁

com/tianji/promotion/service/impl/UserCouponRedisServiceImpl.java
@Override
public void receiveCoupon(Long id) {
    // 根据id查询优惠券列表
    // 校验
    if (id == null) {
        throw new BadRequestException("非法参数");
    }
    Coupon coupon = couponMapper.selectById(id);
    if (coupon == null) {
        throw new BadRequestException("优惠券不存在");
    }
    if (coupon.getStatus() != CouponStatus.ISSUING) {
        throw new BadRequestException("该优惠券状态不是正在发放");
    }
    LocalDateTime now = LocalDateTime.now();
    if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) {
        throw new BadRequestException("该优惠券已过期或未开始发放");
    }
    if (coupon.getTotalNum() <= 0 || coupon.getIssueNum() >= coupon.getTotalNum()) {
        throw new BadRequestException("该优惠券库存不足");
    }

    // 获取当前用户对该优惠券已领数量
    Long userId = UserContext.getUser();

    //通过工具类实现分布式锁
    String key = "lock:coupon:uid:" + userId;
    RedisLock redisLock = new RedisLock(key,redisTemplate);
    try {

        boolean isLock = redisLock.tryLock(5, TimeUnit.SECONDS);
        if (!isLock) {
            throw new BizIllegalException("操作太频繁了");
        }
        //从AOP上下文中 获取当前类的代理对象
        IUserCouponService userCouponServiceProxy = (IUserCouponService) AopContext.currentProxy();
        userCouponServiceProxy.checkAndCreateUserCoupon(userId, coupon, null);
    }finally {
        redisLock.unlock();
    }
}

Redisson实现分布式锁

com/tianji/promotion/service/impl/UserCouponRedissonServiceImpl.java
@Override
public void receiveCoupon(Long id) {
    // 根据id查询优惠券列表
    // 校验
    if (id == null) {
        throw new BadRequestException("非法参数");
    }
    Coupon coupon = couponMapper.selectById(id);
    if (coupon == null) {
        throw new BadRequestException("优惠券不存在");
    }
    if (coupon.getStatus() != CouponStatus.ISSUING) {
        throw new BadRequestException("该优惠券状态不是正在发放");
    }
    LocalDateTime now = LocalDateTime.now();
    if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) {
        throw new BadRequestException("该优惠券已过期或未开始发放");
    }
    if (coupon.getTotalNum() <= 0 || coupon.getIssueNum() >= coupon.getTotalNum()) {
        throw new BadRequestException("该优惠券库存不足");
    }

    // 获取当前用户对该优惠券已领数量
    Long userId = UserContext.getUser();

    // 通过Redisson实现分布式锁
    String key = "lock:coupon:uid:" + userId;
    RLock lock = redissonClient.getLock(key);
    try {
        boolean isLock = lock.tryLock();// 看门狗机制会生效 默认失效时间是30秒
        if (!isLock) {
            throw new BizIllegalException("操作太频繁了");
        }
        // 从AOP上下文中 获取当前类的代理对象
        IUserCouponService userCouponServiceProxy = (IUserCouponService) AopContext.currentProxy();
        userCouponServiceProxy.checkAndCreateUserCoupon(userId, coupon, null);
    } finally {
        lock.unlock();
    }
}

通用分布式锁组件

定义切面
com/tianji/promotion/service/impl/UserCouponRedissonCustomeServiceImpl.java
@Service
@RequiredArgsConstructor
public class UserCouponRedissonCustomeServiceImpl extends ServiceImpl<UserCouponMapper, UserCoupon> implements IUserCouponService {

    private final CouponMapper couponMapper;
    private final IExchangeCodeService exchangeCodeService;
    private final StringRedisTemplate redisTemplate;
    private final RedissonClient redissonClient;


    @Override
    public void receiveCoupon(Long id) {
        // 根据id查询优惠券列表
        // 校验
        if (id == null) {
            throw new BadRequestException("非法参数");
        }
        Coupon coupon = couponMapper.selectById(id);
        if (coupon == null) {
            throw new BadRequestException("优惠券不存在");
        }
        if (coupon.getStatus() != CouponStatus.ISSUING) {
            throw new BadRequestException("该优惠券状态不是正在发放");
        }
        LocalDateTime now = LocalDateTime.now();
        if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) {
            throw new BadRequestException("该优惠券已过期或未开始发放");
        }
        if (coupon.getTotalNum() <= 0 || coupon.getIssueNum() >= coupon.getTotalNum()) {
            throw new BadRequestException("该优惠券库存不足");
        }

        // 获取当前用户对该优惠券已领数量
        Long userId = UserContext.getUser();

        // 从AOP上下文中 获取当前类的代理对象
        IUserCouponService userCouponServiceProxy = (IUserCouponService) AopContext.currentProxy();
        userCouponServiceProxy.checkAndCreateUserCoupon(userId, coupon, null);

    }

    @Override
    @Transactional
    public void exchangeCoupon(String code) {
        // 校验
        if (StringUtils.isBlank(code)) {
            throw new BadRequestException("非法参数");
        }
        // 解析兑换码得到自增id
        long serialNum = CodeUtil.parseCode(code);
        // 判断兑换码是否已兑换
        boolean result = exchangeCodeService.updateExchangeCodeMark(serialNum, true);
        if (result) {
            // 说明兑换码已经被兑换了
            throw new BizIllegalException("兑换码已经被使用了");
        }
        try {
            // 查询兑换码是否存在 根据自增id查询
            ExchangeCode exchangeCode = exchangeCodeService.getById(serialNum);
            if (exchangeCode == null) {
                throw new BizIllegalException("兑换码不存在");
            }
            // 判断是否过期
            LocalDateTime now = LocalDateTime.now();
            LocalDateTime expiredTime = exchangeCode.getExpiredTime();
            if (now.isAfter(expiredTime)) {
                throw new BizIllegalException("兑换码已过期");
            }
            Long userId = UserContext.getUser();

            Coupon coupon = couponMapper.selectById(exchangeCode.getExchangeTargetId());
            if (coupon == null) {
                throw new BizIllegalException("优惠券不存在");
            }
            checkAndCreateUserCoupon(userId, coupon, serialNum);
        } catch (Exception e) {
            // 将兑换码状态重置
            exchangeCodeService.updateExchangeCodeMark(serialNum, false);
            throw e;
        }

    }

    @Transactional
    @MyLock(name = "lock:coupon:uid:")
    public void checkAndCreateUserCoupon(Long userId, Coupon coupon, Long serialNum) {
        // intern方法是强制从常量池中取字符串

        // 获取当前用户对该优惠券已领数量
        Integer count = this.lambdaQuery()
                .eq(UserCoupon::getUserId, userId)
                .eq(UserCoupon::getCouponId, coupon.getId())
                .count();
        if (count != null && count >= coupon.getUserLimit()) {
            throw new BadRequestException("已达到领取上限");
        }

        // 优惠券数量+1
        couponMapper.incrIssueNum(coupon.getId());

        // 生成用户券
        saveUserCoupon(userId, coupon);
        // 更新兑换码状态
        if (serialNum != null) {
            // 修改兑换码状态
            exchangeCodeService.lambdaUpdate()
                    .set(ExchangeCode::getStatus, ExchangeCodeStatus.USED)
                    .set(ExchangeCode::getUserId, userId)
                    .eq(ExchangeCode::getId, serialNum)
                    .update();
        }
    }


    // 保存用户券
    private void saveUserCoupon(Long userId, Coupon coupon) {
        UserCoupon userCoupon = new UserCoupon();
        userCoupon.setUserId(userId);
        userCoupon.setCouponId(coupon.getId());
        LocalDateTime termBeginTime = coupon.getTermBeginTime();
        LocalDateTime termEndTime = coupon.getTermEndTime();
        if (termBeginTime == null && termEndTime == null) {
            termBeginTime = LocalDateTime.now();
            termEndTime = termBeginTime.plusDays(coupon.getTermDays());
        }
        userCoupon.setTermBeginTime(termBeginTime);
        userCoupon.setTermEndTime(termEndTime);
        this.save(userCoupon);
    }
}
com/tianji/promotion/utils/MyLockAspect.java
package com.tianji.promotion.utils;

import com.tianji.common.exceptions.BizIllegalException;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.lang.annotation.Annotation;

@Component
@Aspect
@RequiredArgsConstructor
public class MyLockAspect implements Order {

    private final RedissonClient redissonClient;

    @Around("@annotation(myLock)")
    public Object tryLock(ProceedingJoinPoint pjp, MyLock myLock) throws Throwable {
        // 1.创建锁对象
        RLock lock = redissonClient.getLock(myLock.name());
        // 2.尝试获取锁
        boolean isLock = lock.tryLock(myLock.waitTime(), myLock.leaseTime(), myLock.unit());
        // 3.判断是否成功
        if(!isLock) {
            // 3.1.失败,快速结束
            throw new BizIllegalException("请求太频繁");
        }
        try {
            // 3.2.成功,执行业务
            return pjp.proceed();
        } finally {
            // 4.释放锁
            lock.unlock();
        }
    }

    @Override
    public int value() {
        return 0;//return的值代表了执行顺序 值越小优先级越高
    }

    @Override
    public Class<? extends Annotation> annotationType() {
        return null;
    }
}
使用工厂模式
com/tianji/promotion/service/impl/UserCouponRedissonCustomeServiceImpl.java
package com.tianji.promotion.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.tianji.common.exceptions.BadRequestException;
import com.tianji.common.exceptions.BizIllegalException;
import com.tianji.common.utils.StringUtils;
import com.tianji.common.utils.UserContext;
import com.tianji.promotion.domain.po.Coupon;
import com.tianji.promotion.domain.po.ExchangeCode;
import com.tianji.promotion.domain.po.UserCoupon;
import com.tianji.promotion.enums.CouponStatus;
import com.tianji.promotion.enums.ExchangeCodeStatus;
import com.tianji.promotion.mapper.CouponMapper;
import com.tianji.promotion.mapper.UserCouponMapper;
import com.tianji.promotion.service.IExchangeCodeService;
import com.tianji.promotion.service.IUserCouponService;
import com.tianji.promotion.utils.CodeUtil;
import com.tianji.promotion.utils.MyLock;
import com.tianji.promotion.utils.MyLockType;
import lombok.RequiredArgsConstructor;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.aop.framework.AopContext;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

/**
 * <p>
 * 用户领取优惠券的记录,是真正使用的优惠券信息 服务实现类
 * </p>
 *
 * @author nl
 * @since 2025-04-21
 */
@Service
@RequiredArgsConstructor
public class UserCouponRedissonCustomeServiceImpl extends ServiceImpl<UserCouponMapper, UserCoupon> implements IUserCouponService {

    private final CouponMapper couponMapper;
    private final IExchangeCodeService exchangeCodeService;
    private final StringRedisTemplate redisTemplate;
    private final RedissonClient redissonClient;


    @Override
    public void receiveCoupon(Long id) {
        // 根据id查询优惠券列表
        // 校验
        if (id == null) {
            throw new BadRequestException("非法参数");
        }
        Coupon coupon = couponMapper.selectById(id);
        if (coupon == null) {
            throw new BadRequestException("优惠券不存在");
        }
        if (coupon.getStatus() != CouponStatus.ISSUING) {
            throw new BadRequestException("该优惠券状态不是正在发放");
        }
        LocalDateTime now = LocalDateTime.now();
        if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) {
            throw new BadRequestException("该优惠券已过期或未开始发放");
        }
        if (coupon.getTotalNum() <= 0 || coupon.getIssueNum() >= coupon.getTotalNum()) {
            throw new BadRequestException("该优惠券库存不足");
        }

        // 获取当前用户对该优惠券已领数量
        Long userId = UserContext.getUser();

        // 从AOP上下文中 获取当前类的代理对象
        IUserCouponService userCouponServiceProxy = (IUserCouponService) AopContext.currentProxy();
        userCouponServiceProxy.checkAndCreateUserCoupon(userId, coupon, null);

    }

    @Override
    @Transactional
    public void exchangeCoupon(String code) {
        // 校验
        if (StringUtils.isBlank(code)) {
            throw new BadRequestException("非法参数");
        }
        // 解析兑换码得到自增id
        long serialNum = CodeUtil.parseCode(code);
        // 判断兑换码是否已兑换
        boolean result = exchangeCodeService.updateExchangeCodeMark(serialNum, true);
        if (result) {
            // 说明兑换码已经被兑换了
            throw new BizIllegalException("兑换码已经被使用了");
        }
        try {
            // 查询兑换码是否存在 根据自增id查询
            ExchangeCode exchangeCode = exchangeCodeService.getById(serialNum);
            if (exchangeCode == null) {
                throw new BizIllegalException("兑换码不存在");
            }
            // 判断是否过期
            LocalDateTime now = LocalDateTime.now();
            LocalDateTime expiredTime = exchangeCode.getExpiredTime();
            if (now.isAfter(expiredTime)) {
                throw new BizIllegalException("兑换码已过期");
            }
            Long userId = UserContext.getUser();

            Coupon coupon = couponMapper.selectById(exchangeCode.getExchangeTargetId());
            if (coupon == null) {
                throw new BizIllegalException("优惠券不存在");
            }
            checkAndCreateUserCoupon(userId, coupon, serialNum);
        } catch (Exception e) {
            // 将兑换码状态重置
            exchangeCodeService.updateExchangeCodeMark(serialNum, false);
            throw e;
        }

    }

    @Transactional
    @MyLock(name = "lock:coupon:uid:",lockType = MyLockType.RE_ENTRANT_LOCK)
    public void checkAndCreateUserCoupon(Long userId, Coupon coupon, Long serialNum) {
        // intern方法是强制从常量池中取字符串

        // 获取当前用户对该优惠券已领数量
        Integer count = this.lambdaQuery()
                .eq(UserCoupon::getUserId, userId)
                .eq(UserCoupon::getCouponId, coupon.getId())
                .count();
        if (count != null && count >= coupon.getUserLimit()) {
            throw new BadRequestException("已达到领取上限");
        }

        // 优惠券数量+1
        couponMapper.incrIssueNum(coupon.getId());

        // 生成用户券
        saveUserCoupon(userId, coupon);
        // 更新兑换码状态
        if (serialNum != null) {
            // 修改兑换码状态
            exchangeCodeService.lambdaUpdate()
                    .set(ExchangeCode::getStatus, ExchangeCodeStatus.USED)
                    .set(ExchangeCode::getUserId, userId)
                    .eq(ExchangeCode::getId, serialNum)
                    .update();
        }
    }


    // 保存用户券
    private void saveUserCoupon(Long userId, Coupon coupon) {
        UserCoupon userCoupon = new UserCoupon();
        userCoupon.setUserId(userId);
        userCoupon.setCouponId(coupon.getId());
        LocalDateTime termBeginTime = coupon.getTermBeginTime();
        LocalDateTime termEndTime = coupon.getTermEndTime();
        if (termBeginTime == null && termEndTime == null) {
            termBeginTime = LocalDateTime.now();
            termEndTime = termBeginTime.plusDays(coupon.getTermDays());
        }
        userCoupon.setTermBeginTime(termBeginTime);
        userCoupon.setTermEndTime(termEndTime);
        this.save(userCoupon);
    }
}

com/tianji/promotion/utils/MyLockAspect.java
package com.tianji.promotion.utils;

import com.tianji.common.exceptions.BizIllegalException;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.lang.annotation.Annotation;

@Component
@Aspect
@RequiredArgsConstructor
public class MyLockAspect implements Order {

    private final RedissonClient redissonClient;
    private final MyLockFactory myLockFactory;

    @Around("@annotation(myLock)")
    public Object tryLock(ProceedingJoinPoint pjp, MyLock myLock) throws Throwable {
        // 1.根据工厂模式 获取用户指定类型的 锁对象
        RLock lock = myLockFactory.getLock(myLock.lockType(), myLock.name());
        // 2.尝试获取锁
        boolean isLock = lock.tryLock(myLock.waitTime(), myLock.leaseTime(), myLock.unit());
        // 3.判断是否成功
        if(!isLock) {
            // 3.1.失败,快速结束
            throw new BizIllegalException("请求太频繁");
        }
        try {
            // 3.2.成功,执行业务
            return pjp.proceed();
        } finally {
            // 4.释放锁
            lock.unlock();
        }
    }

    @Override
    public int value() {
        return 0;//return的值代表了执行顺序 值越小优先级越高
    }

    @Override
    public Class<? extends Annotation> annotationType() {
        return null;
    }
}
com/tianji/promotion/utils/MyLock.java
package com.tianji.promotion.utils;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MyLock {
    String name();

    long waitTime() default 1;

    long leaseTime() default -1;

    TimeUnit unit() default TimeUnit.SECONDS;

    MyLockType lockType() default MyLockType.RE_ENTRANT_LOCK;//代表锁类型 默认可重入锁

}
使用策略模式
com/tianji/promotion/service/impl/UserCouponRedissonCustomeServiceImpl.java
@Transactional
@MyLock(name = "lock:coupon:uid:",lockType = MyLockType.RE_ENTRANT_LOCK,lockStrategy = MyLockStrategy.FAIL_AFTER_RETRY_TIMEOUT)
public void checkAndCreateUserCoupon(Long userId, Coupon coupon, Long serialNum) {
    // intern方法是强制从常量池中取字符串

    // 获取当前用户对该优惠券已领数量
    Integer count = this.lambdaQuery()
            .eq(UserCoupon::getUserId, userId)
            .eq(UserCoupon::getCouponId, coupon.getId())
            .count();
    if (count != null && count >= coupon.getUserLimit()) {
        throw new BadRequestException("已达到领取上限");
    }

    // 优惠券数量+1
    couponMapper.incrIssueNum(coupon.getId());

    // 生成用户券
    saveUserCoupon(userId, coupon);
    // 更新兑换码状态
    if (serialNum != null) {
        // 修改兑换码状态
        exchangeCodeService.lambdaUpdate()
                .set(ExchangeCode::getStatus, ExchangeCodeStatus.USED)
                .set(ExchangeCode::getUserId, userId)
                .eq(ExchangeCode::getId, serialNum)
                .update();
    }
}
com/tianji/promotion/utils/MyLockAspect.java
com/tianji/promotion/utils/MyLock.java
package com.tianji.promotion.utils;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MyLock {
    String name();

    long waitTime() default 1;

    long leaseTime() default -1;

    TimeUnit unit() default TimeUnit.SECONDS;

    MyLockType lockType() default MyLockType.RE_ENTRANT_LOCK;//代表锁类型 默认可重入锁

    MyLockStrategy lockStrategy() default MyLockStrategy.FAIL_AFTER_RETRY_TIMEOUT;//代表获取锁的失败策略

}
基于SPEL的动态锁名
com/tianji/promotion/service/impl/UserCouponRedissonCustomeServiceImpl.java
@Transactional
@MyLock(name = "lock:coupon:uid:#{userId}",lockType = MyLockType.RE_ENTRANT_LOCK,lockStrategy = MyLockStrategy.FAIL_AFTER_RETRY_TIMEOUT)
public void checkAndCreateUserCoupon(Long userId, Coupon coupon, Long serialNum) {
    // intern方法是强制从常量池中取字符串

    // 获取当前用户对该优惠券已领数量
    Integer count = this.lambdaQuery()
            .eq(UserCoupon::getUserId, userId)
            .eq(UserCoupon::getCouponId, coupon.getId())
            .count();
    if (count != null && count >= coupon.getUserLimit()) {
        throw new BadRequestException("已达到领取上限");
    }

    // 优惠券数量+1
    couponMapper.incrIssueNum(coupon.getId());

    // 生成用户券
    saveUserCoupon(userId, coupon);
    // 更新兑换码状态
    if (serialNum != null) {
        // 修改兑换码状态
        exchangeCodeService.lambdaUpdate()
                .set(ExchangeCode::getStatus, ExchangeCodeStatus.USED)
                .set(ExchangeCode::getUserId, userId)
                .eq(ExchangeCode::getId, serialNum)
                .update();
    }
}
com/tianji/promotion/utils/MyLockAspect.java
@Component
@Aspect
@RequiredArgsConstructor
public class MyLockAspect implements Order {

    private final RedissonClient redissonClient;
    private final MyLockFactory myLockFactory;

    @Around("@annotation(myLock)")
    public Object tryLock(ProceedingJoinPoint pjp, MyLock myLock) throws Throwable {
        // 1.根据工厂模式 获取用户指定类型的 锁对象
        String lockName = getLockName(myLock.name(), pjp);
        RLock lock = myLockFactory.getLock(myLock.lockType(), lockName);

        // 2.尝试获取锁
        boolean isLock = myLock.lockStrategy().tryLock(lock,myLock);//采用策略工厂模式
        // 3.判断是否成功
        if(!isLock) {
            return null;
        }
        try {
            // 成功,执行业务
            return pjp.proceed();
        } finally {
            // 4.释放锁
            lock.unlock();
        }
    }

    @Override
    public int value() {
        return 0;//return的值代表了执行顺序 值越小优先级越高
    }

    @Override
    public Class<? extends Annotation> annotationType() {
        return null;
    }



    /**
     * SPEL的正则规则
     */
    private static final Pattern pattern = Pattern.compile("\\#\\{([^\\}]*)\\}");
    /**
     * 方法参数解析器
     */
    private static final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();

    /**
     * 解析锁名称
     * @param name 原始锁名称
     * @param pjp 切入点
     * @return 解析后的锁名称
     */
    private String getLockName(String name, ProceedingJoinPoint pjp) {
        // 1.判断是否存在spel表达式
        if (StringUtils.isBlank(name) || !name.contains("#")) {
            // 不存在,直接返回
            return name;
        }
        // 2.构建context,也就是SPEL表达式获取参数的上下文环境,这里上下文就是切入点的参数列表
        EvaluationContext context = new MethodBasedEvaluationContext(
                TypedValue.NULL, resolveMethod(pjp), pjp.getArgs(), parameterNameDiscoverer);
        // 3.构建SPEL解析器
        ExpressionParser parser = new SpelExpressionParser();
        // 4.循环处理,因为表达式中可以包含多个表达式
        Matcher matcher = pattern.matcher(name);
        while (matcher.find()) {
            // 4.1.获取表达式
            String tmp = matcher.group();
            String group = matcher.group(1);
            // 4.2.这里要判断表达式是否以 T字符开头,这种属于解析静态方法,不走上下文
            Expression expression = parser.parseExpression(group.charAt(0) == 'T' ? group : "#" + group);
            // 4.3.解析出表达式对应的值
            Object value = expression.getValue(context);
            // 4.4.用值替换锁名称中的SPEL表达式
            name = name.replace(tmp, ObjectUtils.nullSafeToString(value));
        }
        return name;
    }

    private Method resolveMethod(ProceedingJoinPoint pjp) {
        // 1.获取方法签名
        MethodSignature signature = (MethodSignature)pjp.getSignature();
        // 2.获取字节码
        Class<?> clazz = pjp.getTarget().getClass();
        // 3.方法名称
        String name = signature.getName();
        // 4.方法参数列表
        Class<?>[] parameterTypes = signature.getMethod().getParameterTypes();
        return tryGetDeclaredMethod(clazz, name, parameterTypes);
    }

    private Method tryGetDeclaredMethod(Class<?> clazz, String name, Class<?> ... parameterTypes){
        try {
            // 5.反射获取方法
            return clazz.getDeclaredMethod(name, parameterTypes);
        } catch (NoSuchMethodException e) {
            Class<?> superClass = clazz.getSuperclass();
            if (superClass != null) {
                // 尝试从父类寻找
                return tryGetDeclaredMethod(superClass, name, parameterTypes);
            }
        }
        return null;
    }
}

异步领券

com/tianji/promotion/service/impl/CouponServiceImpl.java
/**
 * 发放优惠券
 *
 * @param id
 * @param dto
 */
@Override
public void issueCoupon(Long id, CouponIssueFormDTO dto) {
    log.debug("发放优惠券 线程名 {}", Thread.currentThread().getName());
    // 校验
    if (id == null || !id.equals(dto.getId())) {
        throw new BadRequestException("非法参数");
    }

    // 校验优惠券的id是否存在
    Coupon coupon = this.getById(id);
    if (coupon == null) {
        throw new BadRequestException("该优惠券不存在");
    }
    // 校验优惠券状态 只有待发放和暂停状态才能发放
    if (coupon.getStatus() != CouponStatus.DRAFT && coupon.getStatus() != CouponStatus.PAUSE) {
        throw new BizIllegalException("只有待发放和暂停状态才能发放");
    }

    LocalDateTime now = LocalDateTime.now();
    boolean isBeginIssue = dto.getIssueBeginTime() == null || !dto.getIssueBeginTime().isAfter(now);
    // 修改优惠券的 领取开始和结束日期 使用有效期和结束日 天数 状态

    /*方式1
    if (isBeginIssue) {
        //代表是立刻发放
        coupon.setIssueBeginTime(now);
        coupon.setIssueEndTime(dto.getIssueEndTime());
        coupon.setStatus(CouponStatus.ISSUING);
        coupon.setTermDays(dto.getTermDays());
        coupon.setTermBeginTime(dto.getTermBeginTime());
        coupon.setTermEndTime(dto.getTermEndTime());
    }else {
        //代表是定时发放
        coupon.setIssueBeginTime(dto.getIssueBeginTime());
        coupon.setIssueEndTime(dto.getIssueEndTime());
        coupon.setStatus(CouponStatus.UN_ISSUE);
        coupon.setTermDays(dto.getTermDays());
        coupon.setTermBeginTime(dto.getTermBeginTime());
        coupon.setTermEndTime(dto.getTermEndTime());
    }

     */
    Coupon tmp = BeanUtils.copyBean(dto, Coupon.class);
    if (isBeginIssue) {
        // 立刻发放
        tmp.setStatus(CouponStatus.ISSUING);
        tmp.setIssueBeginTime(now);
    } else {
        tmp.setStatus(CouponStatus.UN_ISSUE);
    }
    this.updateById(tmp);

    //如果优惠券是立刻发放 将优惠券信息 采用hash存入Redis
    if (isBeginIssue){
        String key = COUPON_CACHE_KEY_PREFIX + id;   // prs:coupon:优惠券id
        // redisTemplate.opsForHash().put(key,"issueBeginTime", String.valueOf(DateUtils.toEpochMilli(now)));
        // redisTemplate.opsForHash().put(key,"issueEndTime",String.valueOf(DateUtils.toEpochMilli(dto.getIssueEndTime())));
        // redisTemplate.opsForHash().put(key,"totalNum",String.valueOf(coupon.getTotalNum()));
        // redisTemplate.opsForHash().put(key,"userLimit",String.valueOf(coupon.getUserLimit()));
        Map<String, String> map = new HashMap<>(4);
        map.put("issueBeginTime", String.valueOf(DateUtils.toEpochMilli(now)));
        map.put("issueEndTime", String.valueOf(DateUtils.toEpochMilli(coupon.getIssueEndTime())));
        map.put("totalNum", String.valueOf(coupon.getTotalNum()));
        map.put("userLimit", String.valueOf(coupon.getUserLimit()));
        redisTemplate.opsForHash().putAll(key, map);
    }


    // 如果优惠券的领取方式为指定发放 需要生成兑换码
    if (coupon.getObtainWay() == ObtainType.ISSUE && coupon.getStatus() == CouponStatus.DRAFT) {
        // 兑换码的过期时间 就是优惠券领取的截至时间
        coupon.setIssueEndTime(tmp.getIssueEndTime());
        // 异步生成兑换码
        exchangeCodeService.asyncGenerateExchangeCode(coupon);
    }
}
com/tianji/promotion/service/impl/UserCouponMqServiceImpl.java
/**
 * <p>
 * 用户领取优惠券的记录,是真正使用的优惠券信息 服务实现类
 * </p>
 *
 * @author nl
 * @since 2025-04-21
 */
@Service
@RequiredArgsConstructor
public class UserCouponMqServiceImpl extends ServiceImpl<UserCouponMapper, UserCoupon> implements IUserCouponService {

    private final CouponMapper couponMapper;
    private final IExchangeCodeService exchangeCodeService;
    private final RedisTemplate redisTemplate;
    private final RabbitMqHelper mqHelper;

    @Override
    @MyLock(name = "lock:coupon:uid:#{id}")
    public void receiveCoupon(Long id) {
        // 根据id查询优惠券列表
        // 校验
        if (id == null) {
            throw new BadRequestException("非法参数");
        }
        //从Redis中获取优惠券信息
        Coupon coupon = queryCouponByCache(id);

        if (coupon == null) {
            throw new BadRequestException("优惠券不存在");
        }
        LocalDateTime now = LocalDateTime.now();
        if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) {
            throw new BadRequestException("该优惠券已过期或未开始发放");
        }
        if (coupon.getTotalNum() <= 0) {
            throw new BadRequestException("该优惠券库存不足");
        }

        // 获取当前用户对该优惠券已领数量
        Long userId = UserContext.getUser();

        //统计已领取的数量
        String key = USER_COUPON_CACHE_KEY_PREFIX + id;
        //increment 代表本次领取后的数量
        Long increment = redisTemplate.opsForHash().increment(key, userId.toString(), 1);
        //校验是否超过限领数量
        if (increment > coupon.getUserLimit()) {
            throw new BizIllegalException("超出限领数量");
        }
        //修改优惠券的库存
        String couponKey = COUPON_CACHE_KEY_PREFIX + id;
        redisTemplate.opsForHash().increment(couponKey, "totalNum", -1);

        //发送给消息到mq userId couponId
        UserCouponDTO msg = new UserCouponDTO();
        msg.setUserId(userId);
        msg.setCouponId(id);
        mqHelper.send(MqConstants.Exchange.PROMOTION_EXCHANGE,
                MqConstants.Key.COUPON_RECEIVE
                ,msg);

    }

    /**
     * 从Redis中获取优惠券信息
     * @param id
     * @return
     */
    private Coupon queryCouponByCache(Long id) {
        String key = COUPON_CACHE_KEY_PREFIX + id;
        Map entries = redisTemplate.opsForHash().entries(key);
        Coupon coupon = BeanUtils.mapToBean(entries, Coupon.class, false, CopyOptions.create());
        return coupon;
    }

    @Override
    @Transactional
    public void exchangeCoupon(String code) {
        // 校验
        if (StringUtils.isBlank(code)) {
            throw new BadRequestException("非法参数");
        }
        // 解析兑换码得到自增id
        long serialNum = CodeUtil.parseCode(code);
        // 判断兑换码是否已兑换
        boolean result = exchangeCodeService.updateExchangeCodeMark(serialNum, true);
        if (result) {
            // 说明兑换码已经被兑换了
            throw new BizIllegalException("兑换码已经被使用了");
        }
        try {
            // 查询兑换码是否存在 根据自增id查询
            ExchangeCode exchangeCode = exchangeCodeService.getById(serialNum);
            if (exchangeCode == null) {
                throw new BizIllegalException("兑换码不存在");
            }
            // 判断是否过期
            LocalDateTime now = LocalDateTime.now();
            LocalDateTime expiredTime = exchangeCode.getExpiredTime();
            if (now.isAfter(expiredTime)) {
                throw new BizIllegalException("兑换码已过期");
            }
            Long userId = UserContext.getUser();

            Coupon coupon = couponMapper.selectById(exchangeCode.getExchangeTargetId());
            if (coupon == null) {
                throw new BizIllegalException("优惠券不存在");
            }
            checkAndCreateUserCoupon(userId, coupon, serialNum);
        } catch (Exception e) {
            // 将兑换码状态重置
            exchangeCodeService.updateExchangeCodeMark(serialNum, false);
            throw e;
        }

    }

    @Transactional
    @MyLock(name = "lock:coupon:uid:#{userId}",lockType = MyLockType.RE_ENTRANT_LOCK,lockStrategy = MyLockStrategy.FAIL_AFTER_RETRY_TIMEOUT)
    public void checkAndCreateUserCoupon(Long userId, Coupon coupon, Long serialNum) {
        // intern方法是强制从常量池中取字符串

        // 获取当前用户对该优惠券已领数量
        Integer count = this.lambdaQuery()
                .eq(UserCoupon::getUserId, userId)
                .eq(UserCoupon::getCouponId, coupon.getId())
                .count();
        if (count != null && count >= coupon.getUserLimit()) {
            throw new BadRequestException("已达到领取上限");
        }

        // 优惠券数量+1
        couponMapper.incrIssueNum(coupon.getId());

        // 生成用户券
        saveUserCoupon(userId, coupon);
        // 更新兑换码状态
        if (serialNum != null) {
            // 修改兑换码状态
            exchangeCodeService.lambdaUpdate()
                    .set(ExchangeCode::getStatus, ExchangeCodeStatus.USED)
                    .set(ExchangeCode::getUserId, userId)
                    .eq(ExchangeCode::getId, serialNum)
                    .update();
        }
    }

    @Transactional
    @Override
    public void checkAndCreateUserCouponNew(UserCouponDTO msg) {
        //从db查优惠券信息
        Coupon coupon = couponMapper.selectById(msg.getCouponId());
        if (coupon == null) {
            return;
        }
        // 优惠券数量+1
        int num = couponMapper.incrIssueNum(coupon.getId());
        if (num == 0) {
            return;
        }
        // 生成用户券
        saveUserCoupon(msg.getUserId(), coupon);
    }

    // 保存用户券
    private void saveUserCoupon(Long userId, Coupon coupon) {
        UserCoupon userCoupon = new UserCoupon();
        userCoupon.setUserId(userId);
        userCoupon.setCouponId(coupon.getId());
        LocalDateTime termBeginTime = coupon.getTermBeginTime();
        LocalDateTime termEndTime = coupon.getTermEndTime();
        if (termBeginTime == null && termEndTime == null) {
            termBeginTime = LocalDateTime.now();
            termEndTime = termBeginTime.plusDays(coupon.getTermDays());
        }
        userCoupon.setTermBeginTime(termBeginTime);
        userCoupon.setTermEndTime(termEndTime);
        this.save(userCoupon);
    }
}

com/tianji/promotion/handler/PromotionCouponHandler.java
@Component
@Slf4j
@RequiredArgsConstructor
public class PromotionCouponHandler {

    private final IUserCouponService userCouponService;

    @RabbitListener(bindings = @QueueBinding(
        value = @Queue(value = "coupon.revice.queue",durable = "true"),
        exchange = @Exchange(value = MqConstants.Exchange.PROMOTION_EXCHANGE,type = ExchangeTypes.TOPIC),
        key = MqConstants.Key.COUPON_RECEIVE
    ))
    public void onMsg(UserCouponDTO msg){
        log.debug("收到领券消息 {}",msg);
        userCouponService.checkAndCreateUserCouponNew(msg);
    }
}
tj-promotion/src/main/resources/bootstrap.yml
tj:
  swagger:
    enable: true
    enableResponseWrap: true
    package-path: com.tianji.promotion.controller
    title: 天机学堂 - 学习中心接口文档
    description: 该服务包含用户学习的各种辅助功能
    contact-name: 传智教育·研究院
    contact-url: http://www.itcast.cn/
    contact-email: zhanghuyi@itcast.cn
    version: v1.0
  jdbc:
    database: tj_promotion
  auth:
    resource:
      enable: true
      excludeLoginPaths:
        - /coupons/list #放行优惠券列表 用户端
  mq:
    listener:
      retry:
        stateless: false # true无状态;false有状态。如果业务中包含事务,这里改为false

day12

接口

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值