项目总结
面试是每个程序员都逃不过的一环。在我面试过的程序员中,有一半的程序员都描述不好自己做过的项目,有些都讲不到3分钟就结束了,听完我都不知道这个项目是做什么的,所以,决定写下这遍手记,希望对正在找工作的你有所帮助。
在面试过程中,程序员都需要介绍自己做过的项目,有的是在工作中做过的,有的是业余时间完成的,有的是团队合作完成的,有的是个人独立完成的。丰富的开场是赢下面试的基础。我总结了如下几个方面的项目介绍流程,供大家参考:
项目描述
这是一个类似慕课网的在线视频课程项目,也可以作为网校平台,项目分为三大块,前端网站+管理控台+服务端。
【管理控台】:供内部运营人员使用,用于管理课程、章节、讲师等核心精选信息,也包含了用户资源权限等系统管理。
【前端网站】:供网站会员使用,可以报名课程之后开始学习课程。
【服务端】:为管理控台和前端网站提供各种接口,具体分为了注册中心、网关路由、系统模块、业务模块、文件模块、公共模块,共6个模块。
技术架构
整个项目采用目前最热门的前后端分离架构
技术 | 方案 |
---|---|
前端——框架 | 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;
}
}
🐳 作者:hiszm 📢 版权:本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,万分感谢。 💬 留言:同时 , 如果文中有什么错误,欢迎指出。以免更多的人被误导。 |