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这种结构以及一些相关的操作命令
修改某个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:核心区别
特性 | ZRANK | ZRANGE |
---|---|---|
用途 | 获取单个成员的排名 | 获取索引范围内的多个成员 |
返回值 | 排名(整数)或 nil | 成员列表(可选包含分数) |
排序方向 | 默认升序(低到高) | 默认升序,可通过 REV 改为降序 |
典型场景 | 查询某个用户的排行榜位置 | 分页展示排行榜(如前10名) |
4.使用建议
- 优先使用
ZRANGE
的场景:- 批量获取排行榜数据(如分页)。
- 结合
WITHSCORES
同时获取分数。 - Redis 6.2+ 中利用
BYSCORE
或REV
实现复杂查询。
- 优先使用
ZRANK
的场景:- 需要快速定位单个成员的排名。
- 结合
ZSCORE
和ZRANK
实现动态排名更新。
5.版本注意事项
ZRANGE
在 Redis 6.2+ 支持BYSCORE
、BYLEX
、REV
等参数,旧版本需使用ZRANGEBYSCORE
、ZREVRANGE
等命令。ZRANK
始终返回升序排名,逆序需用ZREVRANK
。
通过合理使用 ZRANK
和 ZRANGE
,可以高效操作有序集合,适用于排行榜、优先级队列等场景。
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
作用
- 控制字段映射:在实体类与数据库表进行映射时,
<font style="color:rgb(0, 0, 0);">TableField</font>
注解可以指定实体类属性与数据库表字段之间的对应关系。如果数据库表字段名和实体类属性名不一致,可以通过该注解进行指定。 - 设置字段特性:可以用来设置字段在数据库操作中的一些特性,如是否为表中的字段(是否参与 SQL 语句的生成)、字段的填充策略等。
**<font style="color:rgb(0, 0, 0);">value</font>**
:指定数据库表中的字段名。当实体类属性名与数据库表字段名不一致时,通过此属性指定对应的表字段名。例如:java
@TableField(value = "user_name")
private String username;
**<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>
。**<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;
**<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中,DEL
和UNLINK
命令都用于删除键,但它们在实现机制和对系统性能的影响上有显著区别:
1. 同步 vs 异步
DEL
** 命令**:同步删除。直接删除键及其关联的数据,操作会立即释放内存。如果删除的键对应大型数据结构(如包含数百万元素的哈希或列表),DEL
可能会阻塞主线程,导致其他请求延迟。UNLINK
** 命令**:异步删除。首先将键从键空间(keyspace)中移除(逻辑删除),后续的内存回收由后台线程处理。命令立即返回,不会阻塞主线程,适合删除大对象。
2. 性能影响
DEL
:删除大键时可能引发明显延迟,影响Redis的响应时间。UNLINK
:几乎无阻塞,适合高吞吐场景,尤其适用于需要频繁删除大键的情况。
3. 使用场景
DEL
:适合删除小键或对内存释放时效性要求高的场景(如避免内存不足)。UNLINK
:推荐在大多数情况下使用,尤其是删除大键或需要低延迟的场景。
4. 返回值
- 两者均返回被删除键的数量,但
UNLINK
返回时数据可能尚未完全释放。
5. 版本要求
UNLINK
自 Redis 4.0 引入,需确保版本支持;DEL
在所有版本中可用。
示例对比
# 同步删除,可能阻塞主线程
DEL large_key
# 异步删除,立即返回,后台清理
UNLINK large_key
总结
特性 | DEL | UNLINK |
---|---|---|
删除方式 | 同步 | 异步 |
阻塞主线程 | 是(大键时) | 否 |
适用场景 | 小键或需立即释放内存 | 大键或高并发场景 |
版本支持 | 所有版本 | 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