黑马点评项目全面业务总结
1 黑马点评项目
1.1 短信登陆
1.1.1 短信登陆简介
session共享问题:多台服务器并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题。
- 在进行短信登录时,运用redis的String数据结构把手机号作为key,验证码作为value进行存储。
- 查询用户获得用户信息后,运用redis的hash结构,用token当做key存储(token的意思是“令牌”,是服务器生成的一段加密字符串),用户信息作为一个一个hash存储。
为什么不用String数据结构?
Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD,并且内存占用更少。String是以JSON字符串来保存的,虽然比较直观,但是不能进行CRUD。
1.1.2 校验登录状态
主要逻辑:验证手机号格式 如果不符合,返回错误信息,如果符合生成验证码,就运用redis的String数据结构对手机号和验证码进行存储 redis存储一般有个公共前缀并且设置有效时间 一般两分钟 最后返回ok;
//Slfg4 日志注解 public Result sendCode(String phone, HttpSession session) { //1:先验证手机号格式 不符合就返回错误信息 if(RegexUtils.isPhoneInvalid(phone)){ //2:如果不符合,返回错误信息 return Result.fail("手机号格式错误"); } //4:生成验证码 String code = RandomUtil.randomNumbers(6); //保存验证码到redis phone作为key 并且有一个公共前缀 stringRedisTemplate.opsForValue() .set(RedisConstants.LOGIN_CODE_KEY +phone,code ,RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES); //6:发送验证码 log.debug("验证码是{}",code); return Result.ok(); }
1.1.3短信验证码登录和注册
主要逻辑:前端给手机号,以手机号从redis里面拿出验证码,比较验证码,如果不等则返回错误"验证码不正确",如果相等看看是否被注册过,如果没有被注册过,需要插入数据,没注册过的话直接返回UserDto对象(注:UserDto对象相比User对象少了一些敏感信息,例如:密码),然后将对象存储到redis的hash结构里面,并设置有效期 一般为30分钟
@Override public Result login(LoginFormDTO loginForm, HttpSession session) { //获取手机号 String phone = loginForm.getPhone(); if(RegexUtils.isPhoneInvalid(phone)){ //2:如果不符合,返回错误信息 return Result.fail("手机号格式错误"); } //从reids中拿出验证码 String code = stringRedisTemplate.opsForValue() .get(RedisConstants.LOGIN_CODE_KEY + phone); if(loginForm.getPhone().equals(code)){ //如果验证码错误直接返回false return Result.fail("验证码不正确"); } //如果正确 在确定手机号是否已经被注册过 User user = query().eq("phone", phone).one(); //生成token 用hutool工具类生成的uuid toString(true)可以把uuid中的-去掉 String token = UUID.randomUUID().toString(true); if(user==null){ //没有注册过新建并插入新数据 user=CreateNewUser(phone); } //hutool工具类 Beanutil UserDTO userDTO= BeanUtil.copyProperties(user, UserDTO.class); //运用redis中的map数据结构存储userDto对象 Map<String,String> map=new HashMap<>(); map.put("id",userDTO.getId().toString()); map.put("nickName",userDTO.getNickName()); map.put("icon",userDTO.getIcon()); stringRedisTemplate.opsForHash() .putAll(RedisConstants.LOGIN_USER_KEY+token,map); //设置时间一般是30分钟不进行操作,就会失效 stringRedisTemplate .expire(RedisConstants.LOGIN_USER_KEY+token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES); return Result.ok(token); } private User CreateNewUser(String phone) { User user=new User(); user.setPhone(phone); user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(4)); save(user); return user; }
1.1.4 拦截器的实现
主要逻辑:一个拦截器是更新拦截器,主要是更新token的有效期,一个拦截不合法的路径,更新拦截器首先获得token对象 如果token为空直接放行。若不为空的话token刷新token的有效期,然后用token从redis里面拿出UserDTO的map对象,然后把map对象转换为UserDTO对象,存入ThreadLocal域中。在拦截器执行之后将TheadLocal域中的对象释放掉,避免发生内存泄漏.一个拦截器只用判断ThreadLocal域中有没有UserDTO对象,如果有则放行,如果没有就拦截.
//更新拦截器 主要是更新token有效期 另外拦截器不是spring管理的bean //里面不能用自动注入注解 需要用构造方法public class RefreshInterceptor implements HandlerInterceptor { private StringRedisTemplate stringRedisTemplate; public RefreshInterceptor(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //从对象头获得token String token = request.getHeader("authorization"); if(StrUtil.isBlank(token)){ return true; } //若不为空放行,并且把用户放进TheadLocal并且把时间重置为30分钟 Map<Object, Object> map = stringRedisTemplate.opsForHash() .entries(RedisConstants.LOGIN_USER_KEY+token); if(map.isEmpty()){ return true; } //hutool工具类 将map转换为实体类对象 UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false); UserHolder.saveUser(userDTO); stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY+token, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { //释放Thread中的user类 避免内存泄露 UserHolder.removeUser(); }}//目的是拦截不合法的路径public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request , HttpServletResponse response, Object handler) throws Exception { System.out.println("执行拦截器"); System.out.println(UserHolder.getUser()); if(UserHolder.getUser()==null){ //状态码401 表示没授权 response.setStatus(401); return false; } return true; }}
拦截器的配置
@Configurationpublic class MvcConfig implements WebMvcConfigurer { @Resource private StringRedisTemplate stringRedisTemplate; @Override //登录拦截器 public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()).excludePathPatterns( "/shop return proxy.createVoucherOrder(voucherId); } } @Transactional public Result createVoucherOrder(Long voucherId) { //为什么不在方法里面加锁? //因为 在释放锁之后事务spring才提交事务 释放锁 //还没提交的时候 可能另一个线程可能拿到锁 //线程不安全 Long userId= UserHolder.getUser().getId(); //实现一人一单 int count = query().eq("user_id", userId) .eq("voucher_id", voucherId).count(); if(count>0){ return Result.fail("您已经购买到了"); } //前面已经判断库存是否充足 //这次在判断 是一种乐观锁的机制 //运用cas机制 在进行库存加减的时候 需要再次进行判断 库存是否有 boolean success = seckillVoucherService.update() .setSql("stock=stock-1") .eq("voucher_id", voucherId) // .gt("stock",0) .update(); if(!success){ return Result.fail("优惠卷已经被发放完"); } VoucherOrder voucherOrder=new VoucherOrder(); long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); voucherOrder.setUserId(UserHolder.getUser().getId()); voucherOrder.setVoucherId(voucherId); this.save(voucherOrder); return Result.ok(orderId); }
然而上述锁只能运用在单体项目中,如果在分布式项目上并不能起到一人一单功能,所以需要分布式锁
分布式锁:满足分布式系统或集群模式下多线程可见并且互斥的锁
- 多进程可见
- 高可用
- 安全性
- 互斥
- 高性能 等等
private StringRedisTemplate stringRedisTemplate; private String name; public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) { this.stringRedisTemplate = stringRedisTemplate; this.name = name; } private static final String KEY_PREFIX="lock:"; private static final String ID_PREFIX= UUID.randomUUID().toString(true)+"-"; @Override // 上锁 public boolean tryLock(long timeoutSec) { //运用uuid和线程id生成 value String threadId = ID_PREFIX+Thread.currentThread().getId(); //前缀和name组成 key //设置过期时间 如果redis宕机后 锁还能等时间结束后释放 避免造成死锁 Boolean success = stringRedisTemplate .opsForValue().setIfAbsent(KEY_PREFIX + name, threadId ,timeoutSec, TimeUnit.SECONDS); return BooleanUtil.isTrue(success); } @Override public void unLock() { //在释放锁的时候 如果线程一 拿到锁 但是进行阻塞 然后锁失效了 //线程二拿到锁 线程一在阻塞消失后 直接删除了线程二的锁 //解决方案 lua脚本 String threadID = ID_PREFIX+Thread.currentThread().getId(); String id1=stringRedisTemplate.opsForValue().get(KEY_PREFIX + name); if(threadID.equals(id1)){ stringRedisTemplate.delete(KEY_PREFIX+name); } }
--- lua脚本能保证代码执行的原子性if(redis.call('get',KEYS[1])==ARGV[1]) then return redis.call('del',KEYS[1])endreturn 0
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT; static { //初始化 UNLOCK_SCRIPT=new DefaultRedisScript<>(); //设置脚本位置 classPath下的资源 UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua")); //设置返回值 UNLOCK_SCRIPT.setResultType(Long.class); } public void unLock() { stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX+name), ID_PREFIX+Thread.currentThread().getId() ); }
使用Redisson分布式锁
Redisson的锁重试,和看门狗机制
异步秒杀思路
异步秒杀的主要流程:在秒杀的时候 判断库存是否充足 如果不充足 直接返回错误,如果是充足的话,将优惠卷id,用户id和订单id存入阻塞队列,另开线程进行数据库交互
-- lua脚本在判断库存是否充足时 是原子性的 避免产生线程问题-- 优惠卷idlocal voucherId=ARGV[1]-- 1.2 用户idlocal userId=ARGV[2]-- 2.数据key-- 2.1 库存key-- .. 字符串连接符local stockKey='seckill:stock:' .. voucherIdlocal orderKey='seckill:order:' .. voucherId--3脚本业务--3.1判断库存是否充足 get stockKeyif(tonumber(redis.call('get',stockKey))<=0) then return 1endif(redis.call('sismember',orderKey,userId)==1) then return 2end--3.4 扣库存redis.call('incrby',stockKey,-1)redis.call('sadd',orderKey,userId)return 0
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Resource private SeckillVoucherServiceImpl seckillVoucherService; @Resource private RedisIdWorker redisIdWorker; @Resource private StringRedisTemplate stringRedisTemplate; @Resource private RedissonClient redissonClient; private IVoucherOrderService proxy; private BlockingQueue<VoucherOrder> orderTasks =new ArrayBlockingQueue<>(1024*1024); //线程池的创建 private static final ExecutorService SECKILL_ORDER_EXECUTOR= Executors.newSingleThreadExecutor(); private static final DefaultRedisScript<Long> SECKILL_SCRIPT; static { //初始化 SECKILL_SCRIPT=new DefaultRedisScript<>(); //设置脚本位置 classPath下的资源 SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua")); //设置返回值 SECKILL_SCRIPT.setResultType(Long.class); } //spring的知识 目的是为了让 在类创建时 对这个方法进行初始话 @PostConstruct//spring注解 private void init(){ SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler()); } @Override public Result seckillVoucher(Long voucherId){ //执行lua脚本 Long userId = UserHolder.getUser().getId(); Long result = stringRedisTemplate.execute( SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString() ); int r=result.intValue(); //判断结果为0 if(r!=0){ return Result.fail(r==1?"库存不足":"不能重复下单"); } // 保存阻塞队列 将新建的对象存入阻塞队列 VoucherOrder voucherOrder=new VoucherOrder(); long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); //不为0 代表没有购买职责 //处理spring事务失效问题 proxy=(IVoucherOrderService) AopContext.currentProxy(); //加入队列 开辟线程 orderTasks.add(voucherOrder); //直接返回 return Result.ok(orderId); } private class VoucherOrderHandler implements Runnable{ @Override public void run() { while(true){ try{//这个线程主要从阻塞队列拿出voucherOrder对象 一直循环 VoucherOrder voucherOrder=orderTasks.take(); handleVoucherOrder(voucherOrder); }catch (Exception e){ log.error("处理订单异常",e); } } } } private void handleVoucherOrder(VoucherOrder voucherOrder){ //不能在线程里面拿对象了 因为线程变了 Long userId=voucherOrder.getUserId(); //分布式锁 获取锁 双重保障 RLock lock = redissonClient.getLock("lock:order"+userId); boolean b = lock.tryLock(); if(!b){ log.error("不允许重复下单"); return; } try{ //调用方法 proxy.createVoucherOrder(voucherOrder); }finally { lock.unlock(); } } @Transactional public void createVoucherOrder(VoucherOrder voucherOrder) { Long userId= voucherOrder.getUserId(); //实现一人一单 int count = query().eq("user_id", userId) .eq("voucher_id",voucherOrder.getVoucherId()).count(); if(count>0){ log.error("用户已经购买过一次!"); return; } boolean success = seckillVoucherService.update() .setSql("stock=stock-1") .eq("voucher_id", voucherOrder.getVoucherId()) .gt("stock",0) .update(); if(!success){ log.error("库存不足"); } this.save(voucherOrder); }}
1.4点赞功能实现
需求:
- 同一个用户只能点赞一次,再次点击则取消点赞
- 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)
实现步骤:
- 给Blog类中添加一个isLike字段,标示是否被当前用户点赞
- 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,一点赞过则点赞-1
@Override public Result likeBlog(Long id) { //获得登录信息 Long userId = UserHolder.getUser().getId(); //判断当前登录用户是否已经点赞 //往redis里面存入key是前缀加上博客id,value是用户id Boolean member = stringRedisTemplate.opsForSet() .isMember(RedisConstants.BLOG_LIKED_KEY + id, userId.toString()); if(BooleanUtil.isFalse(member)){ //如果未点赞,可以点赞 //数据库点赞+1 boolean success = update().setSql("liked=liked+1") .eq("id", id).update(); if(success) { //保存用户到redis的set集合 //stringRedisTemplate.opsForZSet().add(RedisConstants.BLOG_LIKED_KEY + id, // userId.toString(),System.currentTimeMillis());存放在zet中 stringRedisTemplate.opsForSet() .add(RedisConstants.BLOG_LIKED_KEY + id, userId.toString()); } }else{ //如果已点赞,取消点赞 //数据库点赞数-1 boolean success = update().setSql("liked=liked-1").eq("id", id).update(); if(success) { //把用户从redis的set集合移除 stringRedisTemplate.opsForSet().remove(RedisConstants.BLOG_LIKED_KEY + id, userId.toString()); } } return Result.ok(); }//在每次查询的时候需要判断该用户是否已经点赞了这个博客 private void isBlogLiked(Blog blog){ // 获得这个用户 Long userId = UserHolder.getUser().getId(); //key前缀加博客id String key=RedisConstants.BLOG_LIKED_KEY+blog.getId(); //查redis里面有没有数据 如果有 isLike返回假 如果没有 则返回真 Boolean isMember = stringRedisTemplate.opsForSet() .isMember(key, userId.toString()); blog.setIsLike(BooleanUtil.isTrue(isMember)); }
1.4.1 点赞排行榜
在实现点赞排行榜时 不能用set集合做判断了 因为set是无序的 因此将set要改成zset 将时间戳存放到score中 实现排行
@Override public Result queryBlogLikes(Long id) { //1 查询top5的点赞用户 String key=RedisConstants.BLOG_LIKED_KEY+id; Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4); //解析出其中的用户id if(top5==null||top5.isEmpty()){ return Result.ok(Collections.emptyList()); } //运用了jdk8中的新特性 有时间学学 将set集合中的String统统改为Long List<Long> ids = top5.stream().map(Long::valueOf) .collect(Collectors.toList()); //hutool下的string工具类 String idsStr = StrUtil.join(",", ids); //用in不会根据根据自己的顺序进行排序 //需要用 select * from tb_user where in(5,1) order by field(id,5,1) List<UserDTO> userDTOS = userService.query() .in("id",ids) .last("ORDER BY FIELD(id,"+idsStr+")").list() .stream() .map(user -> BeanUtil.copyProperties(user, UserDTO.class)) .collect(Collectors.toList()); return Result.ok(userDTOS); }
1.4.2 关注和取关功能实现
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService { @Override public Result follow(Long followUserId, Boolean isFollow) { //获得登录用户 Long userId = UserHolder.getUser().getId(); //判断到底是关注还是取关 if(isFollow){ //直接往表里插入数据 Follow follow=new Follow(); follow.setUserId(userId); follow.setFollowUserId(followUserId); save(follow); }else { //删除数据 remove(new QueryWrapper<Follow>() .eq("user_id", userId).eq("follow_user_id", followUserId)); } //关注,新增取关 //取关,删除 return Result.ok(); } @Override public Result isFollow(Long followUserId) { //查看有没有关注 Long userId = UserHolder.getUser().getId(); Integer count = query() .eq("user_id", userId).eq("follow_user_id", followUserId).count(); return Result.ok(count>0); }
1.4.2 共同关注
在开发共同关注时,需要将关注和取关功能的功能改善一下 需要将用户id的key和关注的用户的id作为value 放到set 因为set可以进行查找供同拥有的value
@Override public Result followCommons(Long id) { //用户id Long userId=UserHolder.getUser().getId(); //用户id key String key=RedisConstants.FOLLOW+userId; //查找另一个用户的id String key1=RedisConstants.FOLLOW+id; //进行value查重 Set<String> intersect = stringRedisTemplate .opsForSet().intersect(key, key1); if(intersect==null||intersect.isEmpty()){ return Result.ok(Collections.emptyList()); } List<Long> ids= intersect.stream().map(Long::valueOf) .collect(Collectors.toList()); List<UserDTO> collect = userService.listByIds(ids) .stream() .map(user -> BeanUtil.copyProperties(user, UserDTO.class)) .collect(Collectors.toList()); return Result.ok(collect); }
1.4.3 订阅好友关注
feed流
拉模式:大v都有自己的发件箱,当发消息的时候会发到自己的发件箱里面,等用户上线查看收件箱,会将用户关注的所有大v的发件箱复制一份放到用户的收件箱,重新按时间戳进行排序,供用户读取!弊端:如果这个人是个变态,关注着几千多个人,成千上万个数据会复制到收件箱,耗费内存
推模式:大v在写消息时,会将关注自己的所有人的收件箱里面写一份,供粉丝阅读。缺点:如果大v有太多粉丝,也会造成太耗费内存
拉推结合模式:对待僵尸粉采用拉模式,对待活跃粉丝采用推模式
本次实现使用的是推模式
//在发布笔记时,向各个用户的收件箱发送 运用的时有序zset集合@Override public Result saveBlog(Blog blog) { //获得登录用户/ UserDTO user = UserHolder.getUser(); blog.setUserId(user.getId()); boolean success = save(blog); if(!success){ return Result.fail("新增笔记失败"); } //查询该用户的所有粉丝 select * from tb_follow where follow_id ='user.getId()' List<Follow> follows = followService.query() .eq("follow_user_id", user.getId()).list(); // long l = System.currentTimeMillis(); for(Follow follow:follows){ //对每个粉丝的收件箱进行推送 String key=RedisConstants.FEED_KEY+follow.getUserId(); stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),l); } return Result.ok(blog.getId()); }
//滚动查询的意思是 当查询的时候 本来是10条数据 分页 一页两条数据 如果这时候插入一条新数据 索引变化 分页会重复查询 数据 这时候需要滚动查询 在查询第二页数据时 记录最后一个数据的score数据 并记录 这一页score 有几个 然后根据这个score数据查第三页 @Override public Result queryBlogOfFollow(Long max, Integer offset) { //获得当前用户id Long userId = UserHolder.getUser().getId(); //查询收件箱 key max最大值 min最小值 limit offset偏移量 count 分页数量 String key=RedisConstants.FEED_KEY+userId; //value 是blogId score 就是分数 Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet(). reverseRangeByScoreWithScores(key, 0, max, offset, 2); //非空判断 if(typedTuples==null||typedTuples.isEmpty()){ return Result.ok(); } //解析数据:blogId,minTime,offset ArrayList<Long> ids = new ArrayList<>(typedTuples.size()); long minTime=0; int os=1; for(ZSetOperations.TypedTuple<String> tuple:typedTuples){ ids.add(Long.valueOf(tuple.getValue())); long time =tuple.getScore().longValue(); if(time==minTime){ os++; }else{ minTime=time; os=1; } } String idStr=StrUtil.join(",",ids); List<Blog> blogs = query().in("id", ids) .last("ORDER BY FIELD(id," + idStr + ")").list(); for(Blog blog:blogs){ //查询blog有关的用户 queryBlogUser(blog); //查询blog是否被点赞 isBlogLiked(blog); } ScrollResult r=new ScrollResult(); r.setList(blogs); r.setOffset(os); r.setMinTime(minTime); return Result.ok(r); }
1.5用户签到
@Override public Result sign() { //获取当前用户 Long userId = UserHolder.getUser().getId(); //获取日期 LocalDateTime now=LocalDateTime.now(); String keySuffix=now.format(DateTimeFormatter.ofPattern(":yyyyMM")); //拼接key String key=RedisConstants.USER_SIGN_KEY+userId+keySuffix; //获得今天是本月第几天 int dayOfMonth = now.getDayOfMonth(); //写入redis stringRedisTemplate.opsForValue().setBit(key,dayOfMonth-1,true); return Result.ok(); }
1.5.1 查询连续签到了几天
@Override public Result signCount() { //获取当前用户 Long userId = UserHolder.getUser().getId(); //获取日期 LocalDateTime now=LocalDateTime.now(); String keySuffix=now.format(DateTimeFormatter.ofPattern(":yyyyMM")); //拼接key String key=RedisConstants.USER_SIGN_KEY+userId+keySuffix; //获得今天是本月第几天 int dayOfMonth = now.getDayOfMonth(); List<Long> result=stringRedisTemplate.opsForValue().bitField(key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType .unsigned(dayOfMonth)).valueAt(0)); if(result==null||result.isEmpty()){ //没有任何签到结果 return Result.ok(0); } Long num=result.get(0); if(num==null || num==0){ return Result.ok(0); } //循环遍历 int count=0; while(true){ //让这个数字与1做与运算,得到数字的最后的一个bit if((num&1)==0){ //如果为0,说明未签到 ,结束 break; }else{ //如果不为0,说明已签到,计数器+1 count++; } //把数字右移一位,抛给最后的比特位,继续下一个bit位 num>>>=1; } return Result.ok(count); }
总结
jdk8的新特性学一下 spring再重新学一下
来源地址:https://blog.csdn.net/m0_57710472/article/details/127985653
免责声明:
① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。
② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341