项目总结

面试是每个程序员都逃不过的一环。在我面试过的程序员中,有一半的程序员都描述不好自己做过的项目,有些都讲不到3分钟就结束了,听完我都不知道这个项目是做什么的,所以,决定写下这遍手记,希望对正在找工作的你有所帮助。

在面试过程中,程序员都需要介绍自己做过的项目,有的是在工作中做过的,有的是业余时间完成的,有的是团队合作完成的,有的是个人独立完成的。丰富的开场是赢下面试的基础。我总结了如下几个方面的项目介绍流程,供大家参考:

项目描述

这是一个类似慕课网的在线视频课程项目,也可以作为网校平台,项目分为三大块,前端网站+管理控台+服务端。
【管理控台】:供内部运营人员使用,用于管理课程、章节、讲师等核心精选信息,也包含了用户资源权限等系统管理。
【前端网站】:供网站会员使用,可以报名课程之后开始学习课程。
【服务端】:为管理控台和前端网站提供各种接口,具体分为了注册中心、网关路由、系统模块、业务模块、文件模块、公共模块,共6个模块。

image-20210518193535736

技术架构

image-20210518203106340

整个项目采用目前最热门的前后端分离架构

技术 方案
前端——框架 Vue CLI
前端——UI Bootstrap,一套页面兼容PC、PAD、移动端
后端——持久层 Mybatis
后端——数据库 Mysql
后端——微服务框架 Spring Cloud
后端——中间件 Redis
工程化——代码管理 Git
工程化——代码生成器 freemarker
工程化——Jar包管理 Maven
阿里云的服务——OSS服务 用来存储图片视频
阿里云的服务——视频点播服务 对视频做加密转码并授权播放,保证视频安全

场景解决方案

技术 解决方案 说明
代码生成器 freemarker模板引擎 自己制作了代码生成器,
集成到项目中,使用freemarker模板引擎(课程中有介绍怎么制作代码生成器),
用于生成service层、controller层,dto层和vue界面代码,
配合上mybatis-generator生成持久层代码,极大的提高了开发效率。
在一张表设计完成后,只要1分钟,就可完成单表的增删改查管理功能(包含界面)。
单点登录 token+redis 使用统一登录标识token+分布式缓存redis的方案,实现单点登录。
短信验证码注册 包括了短信验证码生成和验证码校验,并对验证码的时效性做了控制,比如5分钟有效;同一手机号1分钟内只能发送一次验证码;验证码只能使用一次等。
图片验证码登录 redis 使用redis存储验证码,图片验码登录,可以有效防止撞库攻击、暴力破解,保障用户信息安全。
权限管理 使用经典的用户+资源+角色的权限设计方案,适用于绝大多数项目的权限管理,纯手工打造,未使用任何现成的权限框架,代码没有盲区,安全,易扩展。
文件上传 Vue+SpringBoot 实现基本的Vue+SpringBoot文件上传功能
文件存储 OSS 项目中实现了两种文件存储方法,一是自己搭建文件服务器,二是使用阿里云OSS服务。(实际项目中推荐使用第二种,大大减少了运维工作)
极速秒传 对于同一个文件,上传过一次后,再次上传时,会直接提示极速秒传成功,提高用户体验。
分片上传 当文件较大时,文件上传受网络影响较大,容易失败。在上面基本的文件上传的基础上,扩展成分片上传,提高大文件的上传成功率。
断点续传 在分片上传的基础上,再扩展出断点续传,当传到某一个分片失败了之后,下次再上传同一文件时,从余下的分片开始上传。
授权播放 aliplayer 视频经过加密后,需要授权,才能播放,这里我们使用阿里云aliplayer+阿里云授权接口,实现授权播放。
视频加密 视频点播 作为视频网站,视频安全是核心功能,这里用到了阿里云的视频点播服务(慕课网也是用的阿里云的视频点播服务)。使用接口直接和阿里云对接,实现控台统一管理

断点续传

属性

alter table `file` add column (`shard_index` int comment '已上传分片');
alter table `file` add column (`shard_size` int comment '分片大小|B');
alter table `file` add column (`shard_total` int comment '分片总数');
alter table `file` add column (`key` varchar(32) comment '文件标识');
alter table `file` add unique key key_unique (`key`);
 // 文件分片
       let shardSize = 20 * 1024 * 1024;    //以20MB为一个分片
       let shardIndex = 1;		//分片索引
       let start = shardIndex * shardSize;	//当前分片起始位置
       let end = Math.min(file.size, start + shardSize); //当前分片结束位置
       let fileShard = file.slice(start, end); //从文件中截取当前的分片数据

合并文件

@GetMapping("/merge")
    public ResponseDto merge() throws Exception {
        File newFile = new File(FILE_PATH + "/course/test123.mp4");
        FileOutputStream outputStream = new FileOutputStream(newFile, true);//文件追加写入
        FileInputStream fileInputStream = null;//分片文件
        byte[] byt = new byte[10 * 1024 * 1024];
        int len;

        try {
            // 读取第一个分片
            fileInputStream = new FileInputStream(new File(FILE_PATH + "/course/Bc0SXtFn.blob"));
            while ((len = fileInputStream.read(byt)) != -1) {
                outputStream.write(byt, 0, len);
            }

            // 读取第二个分片
            fileInputStream = new FileInputStream(new File(FILE_PATH + "/course/roQbPm2x.blob"));
            while ((len = fileInputStream.read(byt)) != -1) {
                outputStream.write(byt, 0, len);
            }
        } catch (IOException e) {
            LOG.error("分片合并异常", e);
        } finally {
            try {
                if (fileInputStream != null) {
                    fileInputStream.close();
                }
                outputStream.close();
                LOG.info("IO流关闭");
            } catch (Exception e) {
                LOG.error("IO流关闭", e);
            }
        }
        ResponseDto responseDto = new ResponseDto();
        return responseDto;

唯一标识


       console.log(file);
       /*
         name: "test.mp4"
         lastModified: 1901173357457
         lastModifiedDate: Tue May 27 2099 14:49:17 GMT+0800 (中国标准时间) {}
         webkitRelativePath: ""
         size: 37415970
         type: "video/mp4"
       */

       // 生成文件标识,标识多次上传的是不是同一个文件
       let key = hex_md5(file);
       let key10 = parseInt(key, 16);
       let key62 = Tool._10to62(key10);
       console.log(key, key10, key62);
       /*
         d41d8cd98f00b204e9800998ecf8427e
         2.8194976848941264e+38
         6sfSqfOwzmik4A4icMYuUe
        */

是否存在

    public File selectByKey(String key) {
        FileExample example = new FileExample();
        example.createCriteria().andKeyEqualTo(key);
        List<File> fileList = fileMapper.selectByExample(example);
        if (CollectionUtils.isEmpty(fileList)) {
            return null;
        } else {
            return fileList.get(0);
        }
    }

   public void save(FileDto fileDto) {
       File file = CopyUtil.copy(fileDto, File.class);
       if (StringUtils.isEmpty(fileDto.getId())) {
       File fileDb = selectByKey(fileDto.getKey());
       if (fileDb == null) {
           this.insert(file);
       } else {
           this.update(file);
           fileDb.setShardIndex(fileDto.getShardIndex());
           this.update(fileDb);
       }
   }
 

单点登录

分布式无法保证服务器的session缓存

  • ip-hash;但是当一台服务器挂了,其他服务器还是要重新登录
  • 共享session: 使用第三方客户端redis

redis

  • 字符串
    • 阅读量:【INCR article:readcount:{文章id} 】
  • 列表
    • 信息流【LPUSH msg:{诸葛老师-ID} 10018】
  • hash
    • 购物车i清单【hset cart:1001 10088 1】
  • 集合
    • 抽奖【SRANDMEMBER key [count] / SPOP key [count]】
  • 有序集合
    • top 新闻排序【ZREVRANGE hotNews:20190819 0 9 WITHSCORES 】

验证码

         // 将生成的验证码放入会话缓存中,后续验证的时候用到
        request.getSession().setAttribute(imageCodeToken, createText);
        // request.getSession().setAttribute(imageCodeToken, createText);
        // 将生成的验证码放入redis缓存中,后续验证的时候用到
        redisTemplate.opsForValue().set(imageCodeToken, createText, 300, TimeUnit.SECONDS);

登录token

 @PostMapping("/login")
    public ResponseDto login(@RequestBody UserDto userDto, HttpServletRequest request) {
        LOG.info("用户登录开始");
        userDto.setPassword(DigestUtils.md5DigestAsHex(userDto.getPassword().getBytes()));
        ResponseDto responseDto = new ResponseDto();
        // 根据验证码token去获取缓存中的验证码,和用户输入的验证码是否一致
        // String imageCode = (String) request.getSession().getAttribute(userDto.getImageCodeToken());
        String imageCode = (String) redisTemplate.opsForValue().get(userDto.getImageCodeToken());
        LOG.info("从redis中获取到的验证码:{}", imageCode);
        if (StringUtils.isEmpty(imageCode)) {
            responseDto.setSuccess(false);
            responseDto.setMessage("验证码已过期");
            LOG.info("用户登录失败,验证码已过期");
            return responseDto;
        }
        if (!imageCode.toLowerCase().equals(userDto.getImageCode().toLowerCase())) {
            responseDto.setSuccess(false);
            responseDto.setMessage("验证码不对");
            LOG.info("用户登录失败,验证码不对");
            return responseDto;
        } else {
            // 验证通过后,移除验证码
			// request.getSession().removeAttribute(userDto.getImageCodeToken());
            redisTemplate.delete(userDto.getImageCodeToken());
        }

        LoginUserDto loginUserDto = userService.login(userDto);
        String token = UuidUtil.getShortUuid();
        loginUserDto.setToken(token);
		//request.getSession().setAttribute(Constants.LOGIN_USER, loginUserDto);
        redisTemplate.opsForValue().set(token, JSON.toJSONString(loginUserDto), 3600, TimeUnit.SECONDS);
        responseDto.setContent(loginUserDto);
        return responseDto;
    }

    /**
     * 退出登录
     */
    @GetMapping("/logout/{token}")
    public ResponseDto logout(@PathVariable String token) {
        ResponseDto responseDto = new ResponseDto();
		// request.getSession().removeAttribute(Constants.LOGIN_USER);
        redisTemplate.delete(token);
        LOG.info("从redis中删除token:{}", token);
        return responseDto;
    }
}
  let token = Tool.getLoginUser().token;
  if (Tool.isNotEmpty(token)) {
    config.headers.token = token;
    console.log("请求headers增加token:", token);
  }

网关拦截

@Component
public class LoginAdminGatewayFilter implements GatewayFilter, Ordered {

    private static final Logger LOG = LoggerFactory.getLogger(LoginAdminGatewayFilter.class);

    @Resource
    private RedisTemplate redisTemplate;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getPath();

        // 请求地址中不包含/admin/的,不是控台请求,不需要拦截
        if (!path.contains("/admin/")) {
            return chain.filter(exchange);
        }
        if (path.contains("/system/admin/user/login")
                || path.contains("/system/admin/user/logout")
                || path.contains("/system/admin/kaptcha")) {
            LOG.info("不需要控台登录验证:{}", path);
            return chain.filter(exchange);
        }
        //获取header的token参数
        String token = exchange.getRequest().getHeaders().getFirst("token");
        LOG.info("控台登录验证开始,token:{}", token);
        if (token == null || token.isEmpty()) {
            LOG.info( "token为空,请求被拦截" );
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
        Object object = redisTemplate.opsForValue().get(token);
        if (object == null) {
            LOG.warn( "token无效,请求被拦截" );
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        } else {
            LOG.info("已登录:{}", object);
            return chain.filter(exchange);
        }
    }

    @Override
    public int getOrder()
    {
        return 1;
    }
}

权限管理

权限分组

权限控制

/**
     * 登录
     * @param userDto
     */
    public LoginUserDto login(UserDto userDto) {
        User user = selectByLoginName(userDto.getLoginName());
        if (user == null) {
            LOG.info("用户名不存在, {}", userDto.getLoginName());
            throw new BusinessException(BusinessExceptionCode.LOGIN_ERROR);
        } else {
            if (user.getPassword().equals(userDto.getPassword())) {
                // 登录成功
                LoginUserDto loginUserDto = CopyUtil.copy(user, LoginUserDto.class);
                // 为登录用户读取权限
                setAuth(loginUserDto);
                return loginUserDto;
            } else {
                LOG.info("密码不对, 输入密码:{}, 数据库密码:{}", userDto.getPassword(), user.getPassword());
                throw new BusinessException(BusinessExceptionCode.LOGIN_ERROR);
            }
        }
    }



    /**
     * 为登录用户读取权限
     */
    private void setAuth(LoginUserDto loginUserDto) {
        List<ResourceDto> resourceDtoList = myUserMapper.findResources(loginUserDto.getId());
        loginUserDto.setResources(resourceDtoList);

        // 整理所有有权限的请求,用于接口拦截; 用hashset 用于去重
        HashSet<String> requestSet = new HashSet<>();
        if (!CollectionUtils.isEmpty(resourceDtoList)) {
            for (int i = 0, l = resourceDtoList.size(); i < l; i++) {
                ResourceDto resourceDto = resourceDtoList.get(i);
                String arrayString = resourceDto.getRequest();
                List<String> requestList = JSON.parseArray(arrayString, String.class);
                if (!CollectionUtils.isEmpty(requestList)) {
                    requestSet.addAll(requestList);
                }
            }
        }
        LOG.info("有权限的请求:{}", requestSet);
        loginUserDto.setRequests(requestSet);
    }

前端拦截

      /**
       * 查找是否有权限
       * @param router
       */
      hasResourceRouter(router) {
        let _this = this;
        let resources = Tool.getLoginUser().resources;
        if (Tool.isEmpty(resources)) {
          return false;
        }
        for (let i = 0; i < resources.length; i++) {
          if (router === resources[i].page) {
            return true;
          }
        }
        return false;
      },

后端拦截

 public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getPath();

        // 请求地址中不包含/admin/的,不是控台请求,不需要拦截
        if (!path.contains("/admin/")) {
            return chain.filter(exchange);
        }
        if (path.contains("/system/admin/user/login")
                || path.contains("/system/admin/user/logout")
                || path.contains("/system/admin/kaptcha")) {
            LOG.info("不需要控台登录验证:{}", path);
            return chain.filter(exchange);
        }
        //获取header的token参数
        String token = exchange.getRequest().getHeaders().getFirst("token");
        LOG.info("控台登录验证开始,token:{}", token);
        if (token == null || token.isEmpty()) {
            LOG.info( "token为空,请求被拦截" );
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
        Object object = redisTemplate.opsForValue().get(token);
        if (object == null) {
            LOG.warn( "token无效,请求被拦截" );
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        } else {
            LOG.info("已登录:{}", object);

            // 增加权限校验,gateway里没有LoginUserDto,所以全部用JSON操作
            LOG.info("接口权限校验,请求地址:{}", path);
            boolean exist = false;
            JSONObject loginUserDto = JSON.parseObject(String.valueOf(object));
            JSONArray requests = loginUserDto.getJSONArray("requests");
            // 遍历所有【权限请求】,判断当前请求的地址是否在【权限请求】里
            for (int i = 0, l = requests.size(); i < l; i++) {
                String request = (String) requests.get(i);
                if (path.contains(request)) {
                    exist = true;
                    break;
                }
            }
            if (exist) {
                LOG.info("权限校验通过");
            } else {
                LOG.warn("权限校验未通过");
                exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
                return exchange.getResponse().setComplete();
            }

            return chain.filter(exchange);
        }
    }

    @Override
    public int getOrder()
    {
        return 1;
    }
}
posted @ 2021-05-19 15:54  孙中明  阅读(126)  评论(0编辑  收藏  举报