news 2026/4/16 14:19:36

天机学堂项目文档Day10

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
天机学堂项目文档Day10

day10

放行拦截领取优惠卷地址

其中所指的两个类,分别是用户信息拦截器(只是存储用户信息,不登录不报错)和登录校验拦截器(不登录会报错)

/*** ****用户信息拦截器 ***/ public class UserInfoInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1.尝试获取头信息中的用户信息 String authorization = request.getHeader(JwtConstants.USER_HEADER); // 2.判断是否为空 if (authorization == null) { return true;//没有用户信息直接放行 } // 3.转为用户id并保存 try { Long userId = Long.valueOf(authorization); UserContext.setUser(userId); return true; } catch (NumberFormatException e) { log.error("用户身份信息格式不正确,{}, 原因:{}", authorization, e.getMessage()); return true; } } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 清理用户信息 UserContext.removeUser(); } }

可以看到,这个拦截器就是判断用户是否登录,未登录会直接拦截并且返回错误码。不过这个拦截器是通过UserContext.getUser()方法来判断用户是否登录的。也就是说它依赖于UserInfoInterceptor,因此两个拦截器是有先后顺序的,不能搞错。

/*** ****登录拦截器 ***/ public class LoginAuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1.尝试获取用户信息 Long userId = UserContext.getUser(); // 2.判断是否登录 if (userId == null) { response.setStatus(401); response.sendError(401, "未登录用户无法访问!"); // 2.3.未登录,直接拦截 return false; } // 3.登录则放行 return true; } }

那么问题来了:为什么我们要把登录用户信息获取、登录拦截分别写到两个拦截器呢?这是因为并不是所有的接口都对登录用户有需要,有些接口可能登录或未登录都能访问。比如我们的查询发放中的优惠券功能。而有些接口则是要求必须登录才能访问。如果把所有功能放在一个拦截器,也就意味着所有接口要么做拦截要求必须登录并且可以获取用户信息,要么不做拦截,无法获取登录用户信息。这不符合实际需求,所以我们将两个拦截器分离。要知道,拦截器定义好了以后要想生效必须经过SpringMVC的配置,并且设置要拦截的路径。

其中图片标注的依次为拦截路径和放行路径

可以看出,这里是一个典型的springboot的配置属性,我们完全可以通过配置文件来修改。我们只要把需要放行的接口路径通过tj.auth.resource.excludeLoginPaths配置进去即可。

该路径的层级与上述配置中的层级一一对应

实现领取优惠券功能

/*** ****思路分析:首先查询优惠券信息,从该信息中校验发放时间,结束发放时间,库存,以及每个人 ****限制领取的数量,当一切满足后,将coupon的已经领取数量加1,并在用户券表中将对应的数据写入。 ****需要注意的是,当前使用的是mapper,为了防止循环依赖。 ***/ @Transactional public void receiveCoupon(Long couponId) { // 1.查询优惠券 Coupon coupon = couponMapper.selectById(couponId); if (coupon == null) { throw new BadRequestException("优惠券不存在"); } // 2.校验发放时间 LocalDateTime now = LocalDateTime.now(); if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) { throw new BadRequestException("优惠券发放已经结束或尚未开始"); } // 3.校验库存 if (coupon.getIssueNum() >= coupon.getTotalNum()) { throw new BadRequestException("优惠券库存不足"); } Long userId = UserContext.getUser(); // 4.校验并生成用户券 checkAndCreateUserCoupon(coupon, userId); } private void saveUserCoupon(Coupon coupon, Long userId) { // 1.基本信息 UserCoupon uc = new UserCoupon(); uc.setUserId(userId); uc.setCouponId(coupon.getId()); // 2.有效期信息 LocalDateTime termBeginTime = coupon.getTermBeginTime(); LocalDateTime termEndTime = coupon.getTermEndTime(); if (termBeginTime == null) { termBeginTime = LocalDateTime.now(); termEndTime = termBeginTime.plusDays(coupon.getTermDays()); } uc.setTermBeginTime(termBeginTime); uc.setTermEndTime(termEndTime); // 3.保存 save(uc); } private void checkAndCreateUserCoupon(Coupon coupon, Long userId){ // 1.校验每人限领数量 // 1.1.统计当前用户对当前优惠券的已经领取的数量 Integer count = lambdaQuery() .eq(UserCoupon::getUserId, userId) .eq(UserCoupon::getCouponId, coupon.getId()) .count(); // 1.2.校验限领数量 if(count != null && count >= coupon.getUserLimit()){ throw new BadRequestException("超出领取数量"); } // 2.更新优惠券的已经发放的数量 + 1 couponMapper.incrIssueNum(coupon.getId()); // 3.新增一个用户券 saveUserCoupon(coupon, userId); }
public interface CouponMapper extends BaseMapper<Coupon> { @Update("update coupon set issue_num = issue_num + 1 where id = #{couponId} ) int incrIssueNum(@Param("couponId") Long couponId); }

实现兑换码兑换优惠券功能

/*** ****思路分析:先将兑换码解码拿到优惠券id,再去redis中使用setbit去判断是否使用(返回值为1为使用,****0为未使用,spring中显示的就是true和false),先直接设置为使用,拿到返回值判断是否为未使用,如 ***果为未使用,则去根据优惠券id查询判断是否过期,最后再去校验一些列信息,以及生成用户券。注,这里 ***由于需要修改redis中的兑换信息,因此,校验和生成用户券的方法还需要添加一个参数(优惠券id) ****/ @Transactional public void exchangeCoupon(String code) { // 1.校验并解析兑换码 long serialNum = CodeUtil.parseCode(code); // 2.校验是否已经兑换 SETBIT KEY 4 1 ,这里直接执行setbit,通过返回值来判断是否兑换过 boolean exchanged = codeService.updateExchangeMark(serialNum, true); if (exchanged) { throw new BizIllegalException("兑换码已经被兑换过了"); } try { // 3.查询兑换码对应的优惠券id ExchangeCode exchangeCode = codeService.getById(serialNum); if (exchangeCode == null) { throw new BizIllegalException("兑换码不存在!"); } // 4.是否过期 LocalDateTime now = LocalDateTime.now(); if (now.isAfter(exchangeCode.getExpireTime()) { throw new BizIllegalException("兑换码已经过期"); } // 5.校验并生成用户券 // 5.1.查询优惠券 Coupon coupon = couponMapper.selectById(exchangeCode.getCouponId()); // 5.2.查询用户 Long userId = UserContext.getUser(); // 5.3.校验并生成用户券,更新兑换码状态 checkAndCreateUserCoupon(coupon, userId, serialNum); } catch (Exception e) { // 重置兑换的标记 0 codeService.updateExchangeMark(serialNum, false); throw e; } }
/*** ****实现使用setbit去查询或者修改redis中兑换码的兑换状态 ****由于setbit只能使用opsForValue去调用,因此不能一劳永逸的绑定key ***/ private final StringRedisTemplate redisTemplate; private BoundValueOperations<String, String> serialOps; public ExchangeCodeServiceImpl(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; this.serialOps = redisTemplate.boundValueOps(COUPON_CODE_SERIAL_KEY); } public boolean updateExchangeMark(long serialNum, boolean mark) { Boolean boo = redisTemplate.opsForValue().setBit(COUPON_CODE_MAP_KEY, serialNum, mark); return boo != null && boo; }
/*** ****校验并生成用户券的方法修改 ***/ private void checkAndCreateUserCoupon(Coupon coupon, Long userId, Integer serialNum){ // 1.校验每人限领数量 // 1.1.统计当前用户对当前优惠券的已经领取的数量 Integer count = lambdaQuery() .eq(UserCoupon::getUserId, userId) .eq(UserCoupon::getCouponId, coupon.getId()) .count(); // 1.2.校验限领数量 if(count != null && count >= coupon.getUserLimit()){ throw new BadRequestException("超出领取数量"); } // 2.更新优惠券的已经发放的数量 + 1 couponMapper.incrIssueNum(coupon.getId()); // 3.新增一个用户券 saveUserCoupon(coupon, userId); // 4.更新兑换码状态 if (serialNum != null) { codeService.lambdaUpdate() .set(ExchangeCode::getUserId, userId) .set(ExchangeCode::getStatus, ExchangeCodeStatus.USED) .eq(ExchangeCode::getId, serialNum) .update(); } }

解决并发安全问题

领券的过程中有大量的校验,这些校验逻辑在高并发的场景下很容易出现问题。因此,我们必须对领券功能做并发测试,看看是否会出现并发安全问题。并发测试,比较常见的一种工具就是Jemeter了。可以根据资料总的jmx文件去测试。

针对超卖问题

经过测试,确实出现了超卖(或超发)的现象,优惠只有100个库存,结果发放了100多张券,已经严重的超出库存的限制。

1.分析原因

这里采用的是先查询,再判断,再更新的方案,而以上三步操作并不具备原子性。单线程的情况下确实没有问题。但如果是多线程并发运行,如果N个线程同时去查询(N大于剩余库存),此时大概率查询到的库存是充足的,然后判断库存自然没问题。最后一起更新库存,自然就会超卖。如下图的步骤:

总结一下,原因是:​多线程并行运行​多行代码操作共享资源,但不具备原子性​。这就是典型的线程并发安全问题。而对应的解决方案自然很容易的想到——加锁。但是锁又有两种,悲观锁和乐观锁。悲观锁是一种独占和排他的锁机制,保守地认为数据会被其他事务修改,所以在整个数据处理过程中将数据处于锁定状态。乐观锁是一种较为乐观的并发控制方法,假设多用户并发的不会产生安全问题,因此无需独占和锁定资源。但在更新数据前,会先检查是否有其他线程修改了该数据,如果有,则认为可能有风险,会放弃修改操作。而对于悲观锁来说,很显然,该锁能解决安全性问题,但是该锁会影响性能。?乐观锁采用CAS(Compare And Set)思想,在更新数据前先判断数据与我之前查询到的是否一致,不一致则证明有其它线程也在更新。为了避免出现安全问题,放弃本次更新或者重新尝试一次。(举个例子:假设有A,B两个线程一起来抢夺仓库中的最后的一个库存,A,B线程先后进行查询操作,当A线程修改的时候,会判断数据库中的库存数量是否和查出来的一致,如果一致,则插入,然后,轮到B线程,然后B线程也进行查询操作,由于A线程已经插入,已经数据库的数量和已经查出来的数量不一致,最后插入失败)乐观锁优点就是性能好、安全性也好,缺点就是并发较高时,可能出现更新成功率较低的问题(并行的N个线程只会有1个成功)。

2.解决操作

对更新成功率低的问题,在优惠券库存这个业务中,有一个乐观锁的改进方案:​​我们无需判断issue_num是否与原来一致只要判断issue_num是否小于total_num即可。这样,只issue_num小于total_num,不管有多少线程来执行,都会成功。​

public interface CouponMapper extends BaseMapper<Coupon> { @Update("update coupon set issue_num = issue_num + 1 where id = #{couponId} AND issue_num < total_num" ) int incrIssueNum(@Param("couponId") Long couponId); }

再检查和保存用户券的逻辑上添加这段逻辑:

针对锁失效的问题

除了优惠券库存判断,领券时还有对于用户限领数量的判断。这部分的方法的代码逻辑也是按照三步骤去走的:查询数据库、判断是否超出限领数量、新增用户券。那这里能不能用乐观锁呢?很显然是不可以的,乐观锁一般用在更新操作当中。而且这里用户和优惠券的关系并不具备唯一性,因此新增时无法基于乐观锁做判断。因此只能使用悲观锁。也就是Synchronized或者Lock。

1.锁的对象问题

用户限领数量判断是针对单个用户的,因此锁的范围不需要是整个方法,只要锁定某个用户即可。所以这里建议采用Synchronized的代码块,而不是同步方法。并且同步代码块的锁指定为用户id,那么同一个用户并发操作时会被锁定,不同用户互相没有影响,整体效率也是可以接受的。

加了上述锁之后,会发现,锁依然没有生效。加了锁,但锁没生效,可能的原因是什么?答案是用了不同的锁。是因为toString方法底层是new 一个新的string,因此每把锁都不一样。因此还需要使用String类中提供了一个intern()方法。其原理就是获取字符串字面值对应到常量池中的字符串常量。因此需要做如下改造。

经过测试后,发现问题依然存在,用户还是会超领。接着做如下分析

2.事务边界问题

其实这次的问题并不是由于锁导致的,而是由于事务的隔离导致。​要知道,整个领券发放是加了事务的,而在发放内部,我们加锁,处理限领数量的判断。

整体业务流程是这样的:开启事务、获取锁、统计用户已领券的数量、判断是否超出限领数量、如果没超,新增一条用户券、释放锁、提交事务。这里是先开启事务,再获取锁;而业务执行完毕后,是先释放锁,再提交事务。

接下来就能举个例子说流程了:假设有两个进程A,B,A进程先开启事务,获取锁,此时B进程也开启事务,但是由于锁被获取了,因此在等待。线程A做了一系列操作,显而易见,很轻松的成功了,然后释放锁(但是还没有提交事务哦)此时,B立马获取锁,做了一些列操作,由于A进程还没有提交事务,那么进程B读取不到未提交数据。因此认为当前用户没有领券。判断限领数量通过,于是也新增一条券安全问题发生了。总结:由于锁过早释放,导致了事务尚未提交,判断出现错误,最终导致并发安全问题发生。​这其实就是事务边界和锁边界的问题。因此解决的办法很简单,在事务提交后再释放锁就行了。如下:在检查和创建用户券的方法外上锁就行了。

这里加事务,同时事务修饰的方法必须为public方法

总结:在事务和锁并行存在时,一定要考虑事务和锁的边界问题。由于事务的隔离级别问题,可能会导致不同事务之间数据不可见,往往会产生一些不可预期的现象。

针对事务失效问题

虽然解决了并发安全问题,但其实我们的改造却埋下了另一个隐患。我们在领券业务的最后故意抛出一个异常。运行后发现事务并没有进行回滚。

1.分析原因

事务失效无非就以下原因:

很显然此处的问题就是非事务方法调用事务方法了,而其事务失效的原因是方法内部调用走的是this,而不是代理对象。那我们只要想办法获取代理对象不就可以了嘛。这里,我们可以借助AspectJ来实现。

2.解决方法

1.添加依赖
<!--aspecj--> <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> </dependency>
2.启动类上添加注解

3.使用代理对象

练习

实现查询我的优惠券功能

/*** ****思路分析:先从数据库中拿到用户券的分页记录,发现还缺少很多VO所需要的coupon属性 ****因此将记录中的所有couponI的收集起来,并去coupon表中查询对应的coupon信息,最后 ****再将查出来的coupon信息已couponId为key封装成map,方便后续查询,然后遍历record ****将record信息复制给vo,并填上缺失的coupon信息,最后统一返回。 ****/ public PageDTO<CouponVO> queryMyCoupon(CouponQuery query) { // 根据用户ID和未使用状态查询用户优惠券分页数据 Page<UserCoupon> page = lambdaQuery().eq(UserCoupon::getUserId, UserContext.getUser()) .eq(UserCoupon::getStatus, UserCouponStatus.UNUSED) .page(query.toMpPage()); // 获取分页记录 List<UserCoupon> records = page.getRecords(); // 如果记录为空,返回空分页结果 if (CollUtils.isEmpty(records)) return PageDTO.empty(page); // 创建优惠券VO列表 ArrayList<CouponVO> list = new ArrayList<>(records.size()); // 提取优惠券ID列表 List<Long> couponIds = records.stream().map(UserCoupon::getCouponId).collect(Collectors.toList()); //拿到对应的优惠券 List<Coupon> coupons = couponMapper.selectBatchIds(couponIds); Map<Long, Coupon> couponMap = coupons.stream().filter(Objects::nonNull).collect(Collectors.toMap(Coupon::getId, coupon -> coupon)); for (UserCoupon record : records) { CouponVO vo = BeanUtils.copyBean(record, CouponVO.class); //补全缺失的属性 Coupon coupon = couponMap.get(record.getCouponId()); vo.setName(coupon.getName()); vo.setSpecific(coupon.getSpecific()); vo.setDiscountType(coupon.getDiscountType()); vo.setThresholdAmount(coupon.getThresholdAmount()); vo.setMaxDiscountAmount(coupon.getMaxDiscountAmount()); vo.setDiscountValue(coupon.getDiscountValue()); list.add(vo); } return PageDTO.of(page, list); }

完善兑换优惠券功能

在原来的代码上加上获取代理对象的操作,在用这个代理对象去调用checkAndCreateCoupon方法

实现优惠券过期提醒功能

/** * 处理过期优惠券的定时任务方法 * 使用XXL-JOB注解标记该定时任务处理器 * 具体来说,这是个定时任务,如果使用定时任务来实现的话,那这个定时任务需要一直开启 *感觉性能不太行,后续看有没有机会修改这部分代码吧 */ @XxlJob("expireCouponJobHandler") public void expireCouponJobHandler() { //生产者,消费者在message模块那边,我也不知道咋实现 //查询过期前三天还未使用的优惠券 List<UserCoupon> list = userCouponService.lambdaQuery().eq(UserCoupon::getStatus, UserCouponStatus.UNUSED) .lt(UserCoupon::getTermEndTime, LocalDateTime.now().plusDays(3)) .list(); if(CollUtils.isEmpty(list)) return; //拿到用户id List<Long> userIds = list.stream().map(UserCoupon::getUserId).collect(Collectors.toList()); Map<Long, UserDTO> userMap = userClient.queryUserByIds(userIds).stream().collect(Collectors.toMap(UserDTO::getId, u -> u)); //拿到所有用户电话 List<String> phones = userIds.stream().map(userMap::get).map(UserDTO::getCellPhone).collect(Collectors.toList()); SmsInfoDTO message = new SmsInfoDTO(); //设置电话 message.setPhones(phones); //设置模版字符串 message.setTemplateCode("expire_coupon"); mqHelper.send(SMS_EXCHANGE, SMS_MESSAGE, message); }
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/16 6:57:32

场分布下的光子晶体色散研究:机理探索与性能分析

通过场分布得到光子晶体的色散光子晶体那彩虹般的色散特性总让人着迷&#xff0c;但真正上手计算时总有种「知道原理却不知怎么操作」的尴尬。今天咱们来点硬核实操&#xff0c;直接通过电磁场分布数据倒推色散关系——这个思路在缺陷态分析里尤其好用。先看核心逻辑&#xff1…

作者头像 李华
网站建设 2026/4/16 12:58:08

Langchain-Chatchat支持语音输入预处理:打通多模态交互链路

Langchain-Chatchat支持语音输入预处理&#xff1a;打通多模态交互链路 在企业知识库系统仍普遍依赖键盘输入和网页表单的今天&#xff0c;一个新员工想查“年假如何调休”还得翻三四个PDF文档——这种低效体验正被悄然改写。当用户只需轻声说一句“帮我查下报销流程”&#xf…

作者头像 李华
网站建设 2026/4/11 16:22:11

Langchain-Chatchat与RAG架构融合:构建下一代智能客服系统

Langchain-Chatchat与RAG架构融合&#xff1a;构建下一代智能客服系统 在企业服务数字化转型的浪潮中&#xff0c;一个老生常谈却又始终未被彻底解决的问题浮出水面&#xff1a;员工每天要花多少时间翻找公司制度文档&#xff1f;客户又要重复多少次“你们的退换货政策是什么”…

作者头像 李华
网站建设 2026/4/16 13:27:54

Langchain-Chatchat支持批量导入知识库:快速完成企业知识初始化

Langchain-Chatchat支持批量导入知识库&#xff1a;快速完成企业知识初始化 在企业数字化转型的浪潮中&#xff0c;一个老生常谈却又始终棘手的问题浮出水面&#xff1a;如何让堆积如山的内部文档真正“活”起来&#xff1f; HR部门刚更新了《员工手册》&#xff0c;但新员工依…

作者头像 李华
网站建设 2026/4/16 7:05:06

Langchain-Chatchat如何实现文档切片与向量化存储?技术细节曝光

Langchain-Chatchat 如何实现文档切片与向量化存储&#xff1f;技术细节深度解析 在企业智能化浪潮中&#xff0c;一个日益突出的矛盾正被越来越多开发者关注&#xff1a;通用大语言模型&#xff08;LLM&#xff09;虽然“见多识广”&#xff0c;却对企业的私有知识束手无策。你…

作者头像 李华
网站建设 2026/4/15 14:40:13

RocketMQ 介绍及适用场景

一、RocketMQ 简介RocketMQ 是阿里巴巴开源的分布式消息中间件&#xff0c;属于 Apache 顶级项目。它最初诞生于阿里巴巴集团&#xff0c;旨在解决大规模、高并发、低延迟下的消息传递需求。RocketMQ 使用 Java 语言开发&#xff0c;具有高可用、高性能、可扩展、强一致性等特点…

作者头像 李华