目录
1. 项目介绍
黑马点评项目是一个前后端分离项目,类似于大众点评,实现了发布查看商家,达人探店,点赞,关注等功能,业务可以帮助商家引流,增加曝光度,也可以为用户提供查看提供附近消费场所,主要。用来配合学习Redis的知识。
1.1 项目使用的技术栈
SpringBoot+MySql+Lombok+MyBatis-Plus+Hutool+Redis
1.2项目架构
采用单体架构
后端部署在Tomcat上,前端部分部署在Nginx 。
2.各个功能模块
2.1 登录模块
2.1.1 实现短信登录
编写一个工具类校验手机号格式,例如
public class RegexUtils { public static boolean isPhoneInvalid(String phone){ return mismatch(phone, RegexPatterns.PHONE_REGEX); } public static boolean isEmailInvalid(String email){ return mismatch(email, RegexPatterns.EMAIL_REGEX); } public static boolean isCodeInvalid(String code){ return mismatch(code, RegexPatterns.VERIFY_CODE_REGEX); } // 校验是否不符合正则格式 private static boolean mismatch(String str, String regex){ if (StrUtil.isBlank(str)) { return true; } return !str.matches(regex); }}
手机号码格式无误后生成验证码发送至手机,并将验证码内容写入到Redis。设置过期时间;
系统根据输入的手机号验证码进行与Redis中写入的验证码比对一致,即可登录成功,从MySQL中获取用户信息并生成Token,以Token为key将用户信息写入Redis中(hash),新用户则会注册信息并登录;
2.1.2 编写拦截器
登录拦截器,一些功能需要登录后才能使用
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1.判断是否要做拦截 if(UserHolder.getUser()==null){ response.setStatus(401); return false; }// 2.有用户则放行 return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserHolder.removeUser(); }}
刷新Token拦截器,用户长时间没有操作会使Token过期,每次用户点击可以刷新Token过期时间
public class RefreshTokenInterceptor implements HandlerInterceptor { private StringRedisTemplate stringRedisTemplate; public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate){ this.stringRedisTemplate=stringRedisTemplate; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1.获取token String token = request.getHeader("authorization");// 2.判断token是否为空 if(StrUtil.isBlank(token)){ return true; }// 4.基于token获取redis中的用户 Map
在注册中心添加这两个拦截器
@Configurationpublic class MVCConfig implements WebMvcConfigurer { @Resource private StringRedisTemplate stringRedisTemplate; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor())//登录拦截器 .excludePathPatterns( "/user/code", "/user/login", "/blog/hot", "/shop public void set(String key, Object value, Long time, TimeUnit unit){ stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,TimeUnit.MINUTES); } public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){// 1.设置逻辑过期 RedisData redisData = new RedisData(); redisData.setData(value); redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));// 2.写入Redis stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData)); } public R queryWithPassThrough( String keyPrefix,ID id,Class type,Function dbFallBack, Long time, TimeUnit unit ){ String key = keyPrefix + id;// 1.从Redis中查询缓存 String json = stringRedisTemplate.opsForValue().get(key);// 2.判断缓存是否存在 if(StrUtil.isNotBlank(json)){// 3.存在,直接返回 return JSONUtil.toBean(json,type); }// 4.不存在,判断是否是空字符串 if(json!=null){// 5.是空字符串 return null; }// 6.不是空字符串,则向数据库中查找 R r = dbFallBack.apply(id);// 7.数据库中未找到,设置值为空字符串并插入缓存 if (r==null) { stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES); return null; }// 8.找到数据源 this.set(key,r,time,unit); return r; } public R queryWithLogicalExpire(String keyPrefix,ID id,Class type,Function dbFallBack,Long time,TimeUnit unit){ String key = keyPrefix + id;// 1.从redis查询缓存 String json = stringRedisTemplate.opsForValue().get(key); if(StrUtil.isBlank(json)){// 2.如果缓存未命中 return null; }// 3.如果命中,把json字符反序列化为对象 RedisData redisData = JSONUtil.toBean(json, RedisData.class); R r = JSONUtil.toBean((JSONObject) redisData.getData(), type); LocalDateTime expireTime = redisData.getExpireTime(); System.out.println(expireTime);// 5.判断是否过期 if(expireTime.isAfter(LocalDateTime.now())){// 5.1.未过期,直接返回对象 return r; }// 5.2已过期,缓存重建// 6.缓存重建// 6.1获取互斥锁 String lockKey = LOCK_SHOP_KEY + id; boolean isLock = tryLock(lockKey);// 6.2判断是否获取锁成功 if(isLock){// 6.3成功 CACHE_REBUILD_EXECUTOR.submit(()->{ try { // 查询数据库 R r1 = dbFallBack.apply(id);// 写入缓存 this.setWithLogicalExpire(key,r1,time,unit); }catch (Exception e){ throw new RuntimeException(e); }finally { unLock(lockKey); } }); } return r; } private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(10); private boolean tryLock(String key){ Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } private void unLock(String key){ stringRedisTemplate.delete("key"); }}
2.2.3 按距离查询商户
第一步需要将商铺坐标按分类写入Redis(Geo),关键代码如下
@Test void loadShopData(){// 1.查询店铺信息 List shopList = shopService.list();// 2.把店铺分组,按照typeId分组,typeId一致发到一个集合 Map> map = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));// 3.分批完成写入Redis for (Map.Entry> entry : map.entrySet()) {// 获取类型id Long typeTd = entry.getKey(); String key = SHOP_GEO_KEY + typeTd;// 获取通类型的店铺集合 List shops = entry.getValue(); List> locations = new ArrayList<>(shops.size());// 写入Redis GEOADD key 经度 纬度 member for (Shop shop : shops) { locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(), new Point(shop.getX(), shop.getY()) )); } stringRedisTemplate.opsForGeo().add(key,locations); } }
请求参数中需要包含坐标,分页页码信息,类别ID,先向Redis中读取该类别的直到改页最后一个商铺商铺信息,并以距离排序,关键代码如下
GeoResults> results = stringRedisTemplate.opsForGeo() .search(key, GeoReference.fromCoordinate(x, y), new Distance(5000), RedisGeoCommands. GeoSearchCommandArgs. newGeoSearchArgs(). includeDistance().limit(end) );
再将数据进行解析,并把该页第一个商铺前面的商铺信息都跳过得到想要商铺的id和对应distance的键值对集合
List>> list=results.getContent(); if(list.size()<=from){ return Result.ok(); }// 5.截取from-end的部分 List ids= new ArrayList<>(list.size()); HashMap distanceMap = new HashMap<>(list.size());// 截取掉from之前的部分,不重复查询 list.stream().skip(from).forEach(result->{// 获取店铺id String shopId = result.getContent().getName(); ids.add(Long.valueOf(shopId));// 获取距离 Distance distance = result.getDistance(); distanceMap.put(shopId,distance); });
最后根据id查出商铺信息并将设置distance属性,返回商铺信息集合。
补充:如果不按距离排序则直接按页码和页面尺寸查询店铺信息
Page page=query.eq("type_id",typeId) .page(new Page<>(current,SystemConstants.DEFEAUT_PAGE_SIZE));
2.3 优惠券秒杀模块
采用异步下单的方式,先运行Lua脚本,判断是否下过单,若未下过单,则扣减Redis库存,脚本运行成功,有购买资格,则生成一个全局Id作为订单id,生成订单信息,把订单保存到一个阻塞队列,阻塞队列收到订单后,获取分布式锁后再把订单信息和库存信息同步到MySQL,然后释放锁。该模块利用分布式锁实现一人一单功能,利用Lua确保库存不会变负数。
@Slf4j@Servicepublic class VoucherOrderServiceImpl extends ServiceImpl implements IVoucherOrderService { @Resource private ISeckillVoucherService iSeckillVoucherService; @Resource private RedissonClient redissonClient; @Resource private RedisIdWorker redisIdWorker; @Resource private StringRedisTemplate stringRedisTemplate; private static final DefaultRedisScript SECKILL_SCRIPT; static { SECKILL_SCRIPT=new DefaultRedisScript<>(); SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua")); SECKILL_SCRIPT.setResultType(Long.class); }// 创建一个队列 private BlockingQueue orderTasks=new ArrayBlockingQueue<>(1024*1024);// 创建单线程化线程池,用来运行实现Runnable的类 private static final ExecutorService SECKILL_ORDER_EXCUTOR= Executors.newSingleThreadExecutor();// 等依赖加载完再全部执行 @PostConstruct private void init(){ SECKILL_ORDER_EXCUTOR.submit(new VoucherOrderHandler()); } private class VoucherOrderHandler implements Runnable{ @Override public void run() { while (true) { try { // 获取队列中的订单信息 VoucherOrder voucherOrder = orderTasks.take(); // 创建订单 handleVoucherOrder(voucherOrder); } catch (InterruptedException e) { log.error("订单处理异常",e); } } } } private void handleVoucherOrder(VoucherOrder voucherOrder){// 1.获取用户 Long userId = voucherOrder.getUserId();// 2.创建锁对象 RLock lock = redissonClient.getLock("lock:order:" + userId);// 3.判断是否获取锁成功 boolean isLock = lock.tryLock(); if(!isLock){// 获取锁失败 log.error("不允许重复下单"); return; } try { proxy.createVoucherOrder(voucherOrder); } finally { lock.unlock(); } } private IVoucherOrderService proxy; @Override public Result seckillVoucher(Long voucherId) {// 1.执行lua脚本// 获取userID Long userId = UserHolder.getUser().getId(); Long result = stringRedisTemplate.execute( SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString() );// 2.判断结果为0 int i = result.intValue(); if(i!=0)// 2.1不为0,代表没有购买资格 { return Result.fail(i==1 ? "库存不足" : "不能重复下单"); }// 2.2为0,有购买资格,把下单信息保存到阻塞队列 long orderId = redisIdWorker.nextId("order"); VoucherOrder voucherOrder = new VoucherOrder(); voucherOrder.setId(orderId); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId);// TODO保存阻塞队列 orderTasks.add(voucherOrder);// 获取代理对象 proxy=(IVoucherOrderService) AopContext.currentProxy();// 3.返回订单id return Result.ok(orderId); } @Transactional public void createVoucherOrder(VoucherOrder voucherOrder) { Long userId = voucherOrder.getUserId();// 查询订单 Integer count = query().eq("voucher_id", voucherOrder.getVoucherId()).eq("user_id", userId).count(); if(count>0){ log.error("用户已经购买过一次"); return ; }// 5.扣减库存 boolean result = iSeckillVoucherService.update() .setSql("stock=stock-1") .eq("voucher_id", voucherOrder.getVoucherId()).gt("stock",0).update();//where stock >0 if(!result){// 扣减失败 log.error("库存不足!"); return; } save(voucherOrder);// 返回订单id return; }}
2.4 博客模块
2.4.1 点赞
用户浏览博客时,可以对博客进行点赞,点赞过的用户id,写入,Redis缓存中(zset:博客id,用户ID,时间)博客页并展示点赞次数和点赞列表头像,展示点赞列表时,注意点赞列表按时间排序,点赞时间早的排在前面,SQL语句应拼接order By 。
点赞功能:
public Result addLike(Long id) {// 1.获取当前用户 Long userId = UserHolder.getUser().getId(); Blog blog = query().eq("id", id).one();// 2.判断当前用户是否已经点赞 String key = BLOG_LIKED_KEY + id; Double isLike = stringRedisTemplate.opsForZSet().score(key, userId.toString());// 3.如果未点赞,可以点赞 if(isLike==null) {// 4.数据库该帖点赞+1 boolean isSuccess = update().setSql("liked=liked+1").eq("id", id).update();// 5.保存用户id到该贴子的Redis的Zset集合,并更新blog的isLike属性 if(BooleanUtil.isTrue(isSuccess)){ stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis()); blog.setIsLike(true); } return Result.ok(); }// 6.如果已经点赞// 7.数据库该贴点赞-1; boolean isSuccess = update().setSql("liked=liked-1").eq("id", id).update();// 8.把set集合中的用户id移除 if(BooleanUtil.isTrue(isSuccess)){ stringRedisTemplate.opsForZSet().remove(key,userId.toString()); blog.setIsLike(false); } return Result.ok(); }
点赞列表:
public Result queryLikesById(Long id) {// 1.获取key String key = BLOG_LIKED_KEY + id;// 2.查询点赞时间前五的userId Set userIds = stringRedisTemplate.opsForZSet().range(key, 0, 4); if(userIds==null||userIds.isEmpty()){ return Result.ok(); }// 3.根据userId查询User List list = userIds.stream().map(Long::valueOf).collect(Collectors.toList()); String idStr = StrUtil.join(",", list);// 4.返回User集 List UserDTOS = userService.query() .in("id",list) .last("ORDER BY FIELD(id,"+idStr+")") .list() .stream() .map(user -> BeanUtil.copyProperties(user,UserDTO.class)) .collect(Collectors.toList()); return Result.ok(UserDTOS); }
2.4.2 关注作者
与点赞功能相似,将关注用户写入Redis中(String:用户id,被关注与id)
2.5 订阅模块
用户发布的内容推送给粉丝,实现策略有三种模式:拉取模式,推模式,推拉结合模式
该处实现了推模式,发布博客时,把博客推送给粉丝,会向粉丝的信箱(ZSet:粉丝id,博客id,时间)中存入博客id,用户查看订阅时,即根据信箱滚动分页查询最新的博客
public Result queryBlogByFollow(Long max, Integer offset) {// 1.获取当前用户id Long userId = UserHolder.getUser().getId(); String key = FEED_KEY+userId;// 2.查询信箱 Set> typedTuples = stringRedisTemplate.opsForZSet() .reverseRangeByScoreWithScores(key,0,max,offset,3); System.out.println(typedTuples); if(typedTuples==null||typedTuples.isEmpty()){ return Result.ok(); } List ids = new ArrayList<>(typedTuples.size());// 3.解析数据 long minTime=0; Integer os=1; for (ZSetOperations.TypedTuple tuple : typedTuples) {// 获取blogId ids.add(Long.valueOf(tuple.getValue()));// 获取分数 long score = tuple.getScore().longValue(); if(minTime==score){ os++; }else { os=1; minTime=score; } }// 4.根据id查询blog String idStr = StrUtil.join(",", ids); List blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list(); for (Blog blog : blogs) {// 获取点赞信息 isLiked(blog);// 获取用户信息 User user = userService.getById(blog.getUserId()); blog.setName(user.getNickName()); blog.setIcon(user.getIcon()); }// 5.封装并返回 ScrollResult scrollResult = new ScrollResult(); scrollResult.setList(blogs); scrollResult.setOffset(os); scrollResult.setMinTime(minTime); return Result.ok(scrollResult); }
2.6 签到模块
2.6.1 签到功能
使用时间bitMap,打卡取1,为打卡取0,从第0位开始,n日的打卡数据在n-1位
// 2.获取日期 LocalDateTime now = LocalDateTime.now();// 3.拼接key String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM")); String key = USER_SIGN_KEY + userId + keySuffix;// 4.获取今天是本月的第几天 int dayOfMonth = now.getDayOfMonth();// 5.写入Redis setBit key offset 1 stringRedisTemplate.opsForValue().setBit(key,dayOfMonth-1,true);
2.6.2 获取当月连续签到天数
把当月签到数据和1做与运算,得到最近一天是否打卡,为0则直接返回,为1则把签到数据右移一位和1做与运算,循环,直到与运算结果为0,循环次数为连续签到天数。
// 2.获取用户在本月当前签到数据 LocalDateTime now = LocalDateTime.now(); String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM")); String key = USER_SIGN_KEY + userId + keySuffix; int dayOfMonth = now.getDayOfMonth(); List result = stringRedisTemplate.opsForValue().bitField( key, BitFieldSubCommands.create() .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0) ); if(result==null||result.isEmpty()){ return Result.ok(0); } Long sign = result.get(0); if(sign==0||sign==null){ return Result.ok(0); }// 3.取出和1做与运算 int count=0; while (true){ if ((sign&1)==0) { // 4.判断是否为0 // 4.1为0则返回 break; }else {// 4.2为1则count++,并将sign右移 count++; } sign>>>=1; }
3.项目学习收获
项目实战可能碰到的场景,及问题,和解决方案
来源地址:https://blog.csdn.net/qq_47960640/article/details/127845888