feat(market):新增缓存库存和用户端抢卷的功能

This commit is contained in:
JIAN 2024-09-26 20:00:52 +08:00
parent cea88fc351
commit c09e6bf680
8 changed files with 121 additions and 68 deletions

View File

@ -2,38 +2,22 @@ package com.jzo2o.market.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.GenericToStringSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.scripting.support.ResourceScriptSource;
import java.util.Objects;
/**
* 加载Redis的Lua脚本配置类
* @author JIAN
*/
@Configuration
public class RedisLuaConfiguration {
@Bean("seizeCouponScript")
public DefaultRedisScript<Integer> seizeCouponScript() {
DefaultRedisScript<Integer> redisScript = new DefaultRedisScript<>();
//resource目录下的scripts文件下的seizeCouponScript.lua文件
// resource目录下的scripts文件下的seizeCouponScript.lua文件
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/seizeCouponScript.lua")));
redisScript.setResultType(Integer.class);
return redisScript;
}
@Bean("lua_test01")
public DefaultRedisScript<Integer> getLuaTest01() {
DefaultRedisScript<Integer> redisScript = new DefaultRedisScript<>();
//resource目录下的scripts文件下的lua_test01.lua文件
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/lua_test01.lua")));
redisScript.setResultType(Integer.class);
return redisScript;
}
}
}

View File

@ -3,31 +3,35 @@ package com.jzo2o.market.controller.consumer;
import com.jzo2o.common.expcetions.BadRequestException;
import com.jzo2o.common.utils.ObjectUtils;
import com.jzo2o.market.enums.CouponStatusEnum;
import com.jzo2o.market.model.dto.request.SeizeCouponReqDTO;
import com.jzo2o.market.model.dto.response.CouponSimpleInfoResDTO;
import com.jzo2o.market.service.IActivityService;
import com.jzo2o.market.service.ICouponService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.List;
/**
* 运营 - 优惠卷管理控制器
* 用户 - 优惠卷管理控制器
* @author JIAN
*/
@Slf4j
@RestController("consumerCouponController")
@RequestMapping("/consumer/coupon")
public class CouponController {
@Resource
private IActivityService activityService;
@Resource
private ICouponService couponService;
/**
* 获取当前用户的优惠卷(滚动查询)
*/
@GetMapping("/my")
public List<CouponSimpleInfoResDTO> currentUserCoupon(@RequestParam Integer status,
@RequestParam(required = false) Integer lastId) {
@RequestParam(required = false) Integer lastId) {
CouponStatusEnum couponStatusEnum = CouponStatusEnum.statusOf(status);
if (ObjectUtils.isEmpty(couponStatusEnum)) {
throw new BadRequestException("优惠卷状态出错");
@ -35,4 +39,12 @@ public class CouponController {
return couponService.getCurrentUserCoupon(couponStatusEnum, lastId);
}
/**
* 用户抢卷接口
*/
@PostMapping("/seize")
public void seizeCoupon(@RequestBody SeizeCouponReqDTO seizeCouponReqDTO) {
activityService.seizeCoupon(seizeCouponReqDTO);
}
}

View File

@ -24,6 +24,6 @@ public class CouponController {
@GetMapping("/page")
public PageResult<CouponPageInfoResDTO> pageCoupon(CouponPageQueryDTO couponPageQueryDTO) {
return couponService.page(couponPageQueryDTO);
return couponService.page(couponPageQueryDTO);
}
}

View File

@ -14,6 +14,7 @@ import com.jzo2o.market.service.IActivityService;
import com.jzo2o.market.service.ICouponService;
import com.xxl.job.core.handler.annotation.XxlJob;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@ -133,12 +134,26 @@ public class XxlJobHandler {
List<SeizeCouponInfoResDTO> couponInfoList = activityList.stream()
.map(activity -> BeanUtils
.toBean(activity, SeizeCouponInfoResDTO.class)
.setRemainNum(activity.getStockNum()))
.setRemainNum(activity.getStockNum())
.setType(activity.getType().getType()))
.collect(Collectors.toList());
String couponInfoListJsonStr = JsonUtils.toJsonStr(couponInfoList);
// 缓存活动列表
redisTemplate
.opsForValue()
.set(RedisConstants.RedisKey.ACTIVITY_CACHE_LIST, couponInfoListJsonStr);
// 缓存优惠卷库存
HashOperations<String, Object, Object> hashOperations = redisTemplate.opsForHash();
couponInfoList.forEach(coupon -> {
String key = String.format(RedisConstants.RedisKey.COUPON_RESOURCE_STOCK, coupon.getId() % 10);
// 防止进行中的活动的库存被覆盖
if (coupon.getStatus() == ActivityStatusEnum.NO_DISTRIBUTE) {
hashOperations.put(key, coupon.getId(), coupon.getRemainNum());
} else {
hashOperations.putIfAbsent(key, coupon.getId(), coupon.getRemainNum());
}
});
}
}

View File

@ -6,6 +6,7 @@ import com.jzo2o.market.enums.ActivityStatusEnum;
import com.jzo2o.market.model.domain.Activity;
import com.jzo2o.market.model.dto.request.ActivityPageQueryDTO;
import com.jzo2o.market.model.dto.request.ActivitySaveReqDTO;
import com.jzo2o.market.model.dto.request.SeizeCouponReqDTO;
import com.jzo2o.market.model.dto.response.ActivityInfoResDTO;
import com.jzo2o.market.model.dto.response.SeizeCouponInfoResDTO;
@ -44,4 +45,9 @@ public interface IActivityService extends IService<Activity> {
* 撤销活动
*/
void revoke(Long id);
/**
* 用户端进行抢卷操作
*/
void seizeCoupon(SeizeCouponReqDTO seizeCouponReqDTO);
}

View File

@ -6,13 +6,14 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.baomidou.mybatisplus.extension.toolkit.ChainWrappers;
import com.jzo2o.common.constants.ErrorInfo;
import com.jzo2o.common.expcetions.CommonException;
import com.jzo2o.common.expcetions.DBException;
import com.jzo2o.common.expcetions.ForbiddenOperationException;
import com.jzo2o.common.model.PageResult;
import com.jzo2o.common.utils.BeanUtils;
import com.jzo2o.common.utils.CollUtils;
import com.jzo2o.common.utils.JsonUtils;
import com.jzo2o.market.constants.RedisConstants;
import com.jzo2o.market.enums.ActivityStatusEnum;
import com.jzo2o.market.enums.CouponStatusEnum;
import com.jzo2o.market.enums.CouponTypeEnum;
@ -21,21 +22,29 @@ import com.jzo2o.market.model.domain.Activity;
import com.jzo2o.market.model.domain.Coupon;
import com.jzo2o.market.model.dto.request.ActivityPageQueryDTO;
import com.jzo2o.market.model.dto.request.ActivitySaveReqDTO;
import com.jzo2o.market.model.dto.request.SeizeCouponReqDTO;
import com.jzo2o.market.model.dto.response.ActivityInfoResDTO;
import com.jzo2o.market.model.dto.response.SeizeCouponInfoResDTO;
import com.jzo2o.market.service.IActivityService;
import com.jzo2o.market.service.ICouponService;
import com.jzo2o.mvc.utils.UserContext;
import com.jzo2o.mysql.utils.PageUtils;
import com.jzo2o.redis.utils.RedisSyncQueueUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import static com.jzo2o.market.constants.RedisConstants.RedisKey.*;
/**
* <p>
* 服务实现类
@ -49,6 +58,10 @@ public class ActivityServiceImpl extends ServiceImpl<ActivityMapper, Activity> i
private ICouponService couponService;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource(name = "seizeCouponScript")
private RedisScript<Integer> seizeCouponScript;
@Resource
private RedisSyncProperties redisSyncProperties;
@Override
@SuppressWarnings("unchecked")
@ -92,7 +105,7 @@ public class ActivityServiceImpl extends ServiceImpl<ActivityMapper, Activity> i
@Override
public List<SeizeCouponInfoResDTO> getCachedActivity(ActivityStatusEnum status) {
List<SeizeCouponInfoResDTO> couponInfoList = JsonUtils.toList((String) redisTemplate.opsForValue()
.get(RedisConstants.RedisKey.ACTIVITY_CACHE_LIST), SeizeCouponInfoResDTO.class);
.get(ACTIVITY_CACHE_LIST), SeizeCouponInfoResDTO.class);
if (CollUtils.isEmpty(couponInfoList)) {
return new ArrayList<>();
@ -164,4 +177,44 @@ public class ActivityServiceImpl extends ServiceImpl<ActivityMapper, Activity> i
.set(Coupon::getStatus, CouponStatusEnum.VOIDED)
.update();
}
@Override
public void seizeCoupon(SeizeCouponReqDTO seizeCouponReqDTO) {
Long activityId = seizeCouponReqDTO.getId();
Activity activity = lambdaQuery()
.eq(Activity::getId, activityId)
.select(Activity::getDistributeStartTime, Activity::getDistributeEndTime)
.one();
int seizeCouponFailedCode = ErrorInfo.Code.SEIZE_COUPON_FAILD;
if (ObjectUtils.isEmpty(activity)) {
throw new CommonException(seizeCouponFailedCode, "活动不存在无法抢卷");
} else if (activity.getDistributeStartTime().isAfter(LocalDateTime.now())) {
throw new CommonException(seizeCouponFailedCode, "活动未开始无法抢卷");
} else if (activity.getDistributeEndTime().isBefore(LocalDateTime.now())) {
throw new CommonException(seizeCouponFailedCode, "活动已结束无法抢卷");
}
Long userId = UserContext.currentUserId();
if (ObjectUtils.isEmpty(userId)) {
throw new CommonException(seizeCouponFailedCode, "用户信息不存在无法抢卷");
}
int activityTag = (int) (activityId % redisSyncProperties.getQueueNum());
String syncQueueKey = RedisSyncQueueUtils.getQueueRedisKey(COUPON_SEIZE_SYNC_QUEUE_NAME, activityTag);
String stockTable = String.format(COUPON_RESOURCE_STOCK, activityTag);
String seizeSuccessList = String.format(COUPON_SEIZE_LIST, activityId, activityTag);
List<String> keys = Arrays.asList(syncQueueKey, stockTable, seizeSuccessList);
// 调用抢卷脚本
int state = Optional
.ofNullable(redisTemplate.execute(seizeCouponScript, keys, activityId, userId))
.orElseThrow(() -> new CommonException(seizeCouponFailedCode, "抢卷失败"));
if (state < 0) {
String failMag = state == -1 ? "请勿重复抢卷" :
(state == -2 || state == -4 ? "库存不足抢卷失败" : "内部错误抢卷失败");
throw new CommonException(seizeCouponFailedCode, failMag);
}
}
}

View File

@ -1,16 +0,0 @@
package com.jzo2o.market.utils;
import cn.hutool.core.util.ObjectUtil;
import com.jzo2o.market.enums.ActivityTypeEnum;
import com.jzo2o.market.model.domain.Coupon;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* 优惠券相关工具
*/
public class CouponUtils {
}

View File

@ -1,37 +1,36 @@
-- 抢券lua实现
-- key: 抢券同步队列,资源库存,抢券成功列表
-- argv活动id,用户id
-- key: 抢券同步队列, 资源库存, 抢券成功列表
-- argv活动id, 用户id
--优惠券是否已经抢过
-- 优惠券是否已经抢过
local couponNum = redis.call("HGET", KEYS[3], ARGV[2])
-- hget 获取不到数据返回false而不是nil
if couponNum ~= false and tonumber(couponNum) >= 1
then
return "-1";
return "-1"; -- 已抢卷
end
-- --库存是否充足校验
local stockNum = redis.call("HGET",KEYS[2], ARGV[1])
if stockNum == false or tonumber(stockNum) < 1
-- 库存是否充足校验
local stockNum = redis.call("HGET", KEYS[2], ARGV[1])
if stockNum == false or tonumber(stockNum) < 1
then
return "-2";
return "-2"; -- 库存不足抢卷失败
end
--抢券列表
local listNum = redis.call("HSET",KEYS[3], ARGV[2], 1)
if listNum == false or tonumber(listNum) < 1
-- 抢券成功列表
local listNum = redis.call("HSET", KEYS[3], ARGV[2], 1)
if listNum == false or tonumber(listNum) < 1
then
return "-3";
return "-3"; -- 写入抢卷成功列表失败
end
--减库存
stockNum = redis.call("HINCRBY",KEYS[2], ARGV[1], -1)
-- 减少库存
stockNum = redis.call("HINCRBY", KEYS[2], ARGV[1], -1)
if tonumber(stockNum) < 0
then
return "-4"
return "-4" -- 库存不足抢卷失败
end
-- 抢结果写入同步队列
local result = redis.call("HSETNX", KEYS[1], ARGV[2],ARGV[1])
-- 抢结果写入同步队列
local result = redis.call("HSETNX", KEYS[1], ARGV[2], ARGV[1])
if result > 0
then
return ARGV[1] ..""
return "100" -- 抢卷成功返回活动id
end
return "-5"
return "-5" -- 写入同步队列失败