# IShareIT
#### 系统说明[参考掘金](https://juejin.im/timeline)
系统说明,本系统是面向用户的IT社区系统,分为两个角色,后台管理员、用户
用户说明:用户拥有个人账号,在前台可以浏览、发布、评论、收藏、点赞文章,可以关注喜欢的作者,回复评论,可以对自己的个人信息修改, 同时也可以删除、编辑文章,删除评论等功能,用户不可以进入后台管理,只可以在前台对内容进行修改
管理员说明:管理员可访问后台,可查看系统数据,可对文章、评论进行删除,对分类与标签CURD,用户CURD,系统设置,发布公告等功能
![](./imgs/main.png)
#### 开发说明
##### 所用技术栈
本次项目采用前后端分离模式,接口规范采用restful风格,前端**vue**全家桶,后台使用**springboot 2.3.1.RELEASE**+**maven**(项目管理)+**mybatis-plus**(数据库操作)+**redis**(缓存)+**shiro**+**jwt**(权限认证),接口文档使用**swagger2**,数据库采用**mysql5.7.29**
### 后端开发过程及注意事项
#### Restful风格api[参考这篇文章](https://www.cnblogs.com/yinzhengjie/p/12037939.html)
#### 整合mybatis-plus
1. 添加依赖
```xml
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.2.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<!--mp代码生成器-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.2.0</version>
</dependency>
```
2. 配置文件
```yml
mybatis-plus:
mapper-locations: classpath*:/mapper/**Mapper.xml
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
type-aliases-package: com.rjpyy.ishareit.entity
```
3. 配置类
```java
@Configuration
@EnableTransactionManagement
@MapperScan("com.rjpyy.ishareit.mapper")
public class MybatisPlusConfig {
@Bean
public PaginationInterceptor paginationInterceptor() {
return new PaginationInterceptor();
}
}
```
4. 代码生成
```java
public class CodeGenerator {
/**
* <p>
* 读取控制台内容
* </p>
*/
public static String scanner(String tip) {
Scanner scanner = new Scanner(System.in);
StringBuilder help = new StringBuilder();
help.append("请输入" + tip + ":");
System.out.println(help.toString());
if (scanner.hasNext()) {
String ipt = scanner.next();
if (StringUtils.isNotEmpty(ipt)) {
return ipt;
}
}
throw new MybatisPlusException("请输入正确的" + tip + "!");
}
public static void main(String[] args) {
// 代码生成器
AutoGenerator mpg = new AutoGenerator();
// 全局配置
GlobalConfig gc = new GlobalConfig();
String projectPath = System.getProperty("user.dir");
gc.setOutputDir(projectPath + "/src/main/java");
gc.setAuthor("gzhhh");
gc.setOpen(false);
gc.setIdType(IdType.AUTO);
gc.setDateType(DateType.ONLY_DATE);
gc.setSwagger2(true);// 实体属性 Swagger2 注解
mpg.setGlobalConfig(gc);
// 数据源配置
DataSourceConfig dsc = new DataSourceConfig();
dsc.setUrl("jdbc:mysql://localhost:3306/ishareit?useUnicode=true&useSSL=false&characterEncoding=utf8");
// dsc.setSchemaName("public");
dsc.setDbType(DbType.MYSQL);
dsc.setDriverName("com.mysql.cj.jdbc.Driver");
dsc.setUsername("root");
dsc.setPassword("123456");
mpg.setDataSource(dsc);
// 包配置
PackageConfig pc = new PackageConfig();
pc.setModuleName(scanner("模块名"));
pc.setParent("com.rjpyy");
pc.setEntity("entity");
pc.setMapper("mapper");
pc.setService("service");
pc.setController("controller");
mpg.setPackageInfo(pc);
// 自定义配置
InjectionConfig cfg = new InjectionConfig() {
@Override
public void initMap() {
// to do nothing
}
};
// 如果模板引擎是 freemarker
String templatePath = "/templates/mapper.xml.ftl";
// 如果模板引擎是 velocity
// String templatePath = "/templates/mapper.xml.vm";
// 自定义输出配置
List<FileOutConfig> focList = new ArrayList<>();
// 自定义配置会被优先输出
focList.add(new FileOutConfig(templatePath) {
@Override
public String outputFile(TableInfo tableInfo) {
// 自定义输出文件名 , 如果你 Entity 设置了前后缀、此处注意 xml 的名称会跟着发生变化!!
return projectPath + "/src/main/resources/mapper/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
}
});
cfg.setFileOutConfigList(focList);
mpg.setCfg(cfg);
// 配置模板
TemplateConfig templateConfig = new TemplateConfig();
// 配置自定义输出模板
//指定自定义模板路径,注意不要带上.ftl/.vm, 会根据使用的模板引擎自动识别
// templateConfig.setEntity("templates/entity2.java");
// templateConfig.setService();
// templateConfig.setController();
templateConfig.setXml(null);
mpg.setTemplate(templateConfig);
// 策略配置
StrategyConfig strategy = new StrategyConfig();
strategy.setNaming(NamingStrategy.underline_to_camel);
strategy.setColumnNaming(NamingStrategy.underline_to_camel);
strategy.setEntityLombokModel(true);
strategy.setRestControllerStyle(true);
// 自动填充更新时间,创建时间
TableFill gmtCreate = new TableFill("create_time", FieldFill.INSERT);
TableFill gmtModified = new TableFill("update_time",
FieldFill.INSERT_UPDATE);
ArrayList<TableFill> tableFills = new ArrayList<>(); tableFills.add(gmtCreate); tableFills.add(gmtModified); strategy.setTableFillList(tableFills);
strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
strategy.setControllerMappingHyphenStyle(true);
strategy.setTablePrefix("t_");
mpg.setStrategy(strategy);
mpg.setTemplateEngine(new FreemarkerTemplateEngine());
mpg.execute();
}
}
```
#### 整合**swagger2**
> 启动地址 localhost:8990/swagger-ui.html
1. 添加依赖
```xml
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.9.2</version>
</dependency>
```
2. 配置类
```java
@Configuration
@EnableSwagger2
public class SwaggerConfig {
public static final String VERSION = "1.0.0";
public static final String SWAGGER_SCAN_BASE_PACKAGE = "com.rjpyy.ishareit.controller";
@Bean
public Docket createRestApi() {
return new Docket(DocumentationType.SWAGGER_2)
.pathMapping("/")
.select()
.apis(RequestHandlerSelectors.basePackage(SWAGGER_SCAN_BASE_PACKAGE))
.paths(PathSelectors.any())
.build().apiInfo(new ApiInfoBuilder()
.title("IShareIT IT社区") // 文档标题
.description("IShareIT IT接口文档") //文档描述
.version(VERSION) // api版本
.contact(new Contact("gzhhh",null,"972134688@qq.com")) //联系人
.license("The Apache License") // 所属版权
.licenseUrl("http://www.apache.org/licenses/LICENSE-2.0")
.build());
}
}
```
3. 在控制器加注解
```java
@RestController
@Api(tags="User")
@RequestMapping("/users")
public class UserController {
@Autowired
IUserService userService;
@GetMapping("/{id}")
@ApiOperation(value = "根据id获取user", notes = "前端传递id", httpMethod = "GET")
@ApiImplicitParam(name = "id",value = "用户id",required = true,paramType = "path",dataType = "Integer")
public User getById(@PathVariable("id") long id){
return userService.getById(id);
}
}
```
[这里有更多注解](https://blog.csdn.net/xiaojin21cen/article/details/78654652)
#### 统一结果封装
1. 在bean包新建`ResponseBean`类
```java
@Data
public class ResponseBean {
Integer code; //响应码
private String msg; //响应消息
private Object data; //具体前端数据
//返回成功的消息
public static ResponseBean success(Object data) {
return new ResponseBean(ResultCode.SUCCESS, data);
}
public static ResponseBean success() {
return new ResponseBean(ResultCode.SUCCESS,null);
}
//返回失败的消息
public static ResponseBean fail() {
return new ResponseBean(ResultCode.ERROR, null);
}
// 自定义失败消息
public static ResponseBean fail(String msg){
return new ResponseBean(msg);
}
// 自定义失败状态码与信息
public static ResponseBean fail(Integer code,String msg){
return new ResponseBean(code,msg);
}
//返回失败的消息
public static ResponseBean notFound() {
return new ResponseBean(ResultCode.NOT_FOUND, null);
}
//返回参数异常、内部错误等其他情况,根据resultCode构造响应结果
public static ResponseBean setStatus(ResultCode resultCode){
return new ResponseBean(resultCode, null);
}
//构造函数
private ResponseBean(ResultCode resultCode, Object data) {
this.code = resultCode.getCode();
this.msg = resultCode.getMsg();
this.data = data;
}
private ResponseBean(String msg) {
this.code = 400;
this.msg = msg;
this.data = null;
}
private ResponseBean(Integer code,String msg) {
this.code = code;
this.msg = msg;
this.data = null;
}
}
```
2. 新建枚举类(用于枚举响应状态码)
```java
package com.zsc.ticketsys.enums;
import lombok.Getter;
/**
* @author Hevean
* @description 响应码枚举
*/
@Getter
public enum ResultCode {
SUCCESS(200, "success"),
FAILED(400, "failed"),
UNAUTHORIZED(401, "没有相应权限"),
NOT_FOUND(404, "没有相应权限"),
VALIDATE_FAILED(405, "参数校验失败"),
ERROR(500, "服务器内部异常");
private int code;
private String msg;
ResultCode(int code, String msg) {
this.code = code;
this.msg = msg;
}
}
```
3. 返回的时候返回
```java
return ResponseBean.fail()
```
4. 返回结果
![](./imgs/result.png)
#### 整合shiro+jwt+redis
![](./imgs/shiro.png)
开发说明:
> 需要开启redis服务器,会将sessionid存到redis里
在需要认证才能访问的接口加上`@RequiresAuthentication`注解
需要角色权限的加上`@RequiresAuthentication`和`@RequiresRoles({"ROLE_admin"})`
#### 统一异常处理
> 使用@RestControllerAdvice注解,所有的异常都会捕获到,除了Filter里抛出的异常
```java
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// 用户登录认证不通过异常
@ExceptionHandler(ShiroException.class)
public ResponseBean handle401(ShiroException e){
log.error("认证异常:-------->{}",e.getMessage());
return ResponseBean.setStatus(ResultCode.UNAUTHORIZED);
}
}
```
开发说明:如果有需要返回给前端的异常,捕获后返回响应的信息。
#### 参数校验
表单验证时加上必要的参数校验
[具体参数校验注解](https://blog.csdn.net/justry_deng/article/details/86571671)
> 具体看代码...
#### 整合aliyunoss 上传文件
1. 添加依赖
```java
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>2.8.3</version>
</dependency>
```
2. 在配置文件中添加 配置
```
aliyun:
bucketName: xxx
accessKeyId: xxx
accessKeySecret: xxx
endpoint: xxx
```
3. 编写ossUtil
```java
@Component
@Slf4j
public class OssUtil {
@Value("${aliyun.endpoint}")
private String endpoint;
@Value("${aliyun.accessKeyId}")
private String accessKeyId;
@Value("${aliyun.accessKeySecret}")
private String accessKeySecret;
@Value("${aliyun.bucketName}")
private String bucketName;
//文件储存前缀目录
private String prefiledir = "ishareit/";
// 文件储存路径
private String filedir = "";
/**
*
* 上传图片
* @param file
* @return
*/
private String uploadImg2Oss(MultipartFile file) {
// 图片最大20M
if (file.getSize() > 1024 * 1024 *20) {
return "图片太大";//RestResultGenerator.createErrorResult(ResponseEnum.PHOTO_TOO_MAX);
}
String originalFilename = file.getOriginalFilename();
// 获取文件后缀
String substring = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase();
//生成随机数
Random random = new Random();
// 生成文件名
String name = random.nextInt(10000) + System.currentTimeMillis() + substring;
try {
InputStream inputStream = file.getInputStream();
this.uploadFile2OSS(inputStream, name);
return name;//RestResultGenerator.createSuccessResult(name);
} catch (Exception e) {
return "上传失败";//RestResultGenerator.createErrorResult(ResponseEnum.PHOTO_UPLOAD);
}
}
/**
* 上传图片获取fileUrl
* @param instream
* @param fileName
* @return
*/
private String uploadFile2OSS(InputStream instream, String fileName) {
String ret = "";
try {
//创建上传Object的Metadata
ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentLength(instream.available());
objectMetadata.setCacheControl("no-cache");
objectMetadata.setHeader("Pragma", "no-cache");
objectMetadata.setContentType(getcontentType(fileName.substring(fileName.lastIndexOf("."))));
objectMetadata.setContentDisposition("inline;filename=" + fileName);
//上传文件
// 核心
OSSClient ossClient = new OSSClient(endpoint, accessKeyId, accessKeySecret);
PutObjectResult putResult = ossClient.putObject(bucketName, filedir + fileName, instream, objectMetadata);
ret = putResult.getETag();
} catch (IOException e) {
log.error(e.getMessage(), e);
} finally {
try {
if (instream != null) {
instream.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
return ret;
}
private static String getcontentType(String FilenameExtension) {
if (FilenameExtension.equalsIgnoreCase(".bmp")) {
return "image/bmp";
}
if (FilenameExtension.equalsIgnoreCase(".gif")) {
return "image/gif";
}
if (FilenameExtension.equalsIgnoreCase(".jpeg") ||
FilenameExtension.equalsIgnoreCase(".jpg") ||
FilenameExtension.equalsIgnoreCase(".png")) {
return "image/jpg";
}
if (FilenameExtension.equalsIgnoreCase(".html")) {
return "text/html";
}
if (FilenameExtension.equalsIgnoreCase(".txt")) {
return "text/plain";
}
if (FilenameExtension.equalsIgnoreCase(".vsd")) {
return "application/vnd.visio";
}
if (FilenameExtension.equalsIgnoreCase(".pptx") ||
FilenameExtension.equalsIgnoreCase(".ppt")) {
return "application/vnd.ms-powerpoint";
}
if (FilenameExtension.equalsIgnoreCase(".docx") ||
FilenameExtension.equalsIgnoreCase(".doc")) {
return "application/msword";
}
if (FilenameExtension.equalsIgnoreCase(".xml")) {
return "text/xml";
}
return "image/jpg";
}
/**
* 获取图片路径
* @param fileUrl
* @return
*/
private String getImgUrl(String fileUrl) {
if (!StringUtils.isEmpty(fileUrl)) {
String[] split = fileUrl.split("/");
String url = this.getUrl(this.filedir + split[split.length - 1]);
return url;
}
return null;
}
/**
* 获得url链接
*
* @param key
* @return
*/
private String getUrl(String key) {
// 设置URL过期时间为10年 3600l* 1000*24*365*10
Date expiration = new Date(new Date().getTime() + 3600l * 1000 * 24 * 365 * 10);
// 生成URL
OSSClient ossClient = new OSSClient(endpoint, accessKeyId, accessKeySecret);
URL url = ossClient.generatePresignedUrl(bucketName, key, expiration);
log.warn("url+ossClient:"+url);
if (url != null) {
return url.toString();
}
return null;
}
/**
* 多图片上传
* @param fileList
* @return
*/
public String uploadImageList(List<MultipartFile> fileList) {
this.filedir = this.prefiledir+"muti/";
String fileUrl = "";
String str = "";
String photoUrl = "";
for(int i = 0;i< fileList.size();i++){
fileUrl = uploadImg2Oss(fileList.get(i));
str = getImgUrl(fileUrl);
str = str.split("\\?")[0];
if(i == 0){
photoUrl = str;
}else {
photoUrl += "," + str;
}
}
return photoUrl.trim();
}
/**
* 单个图片上传
*
* @param file
* @param type 1为头像文件夹 ,2位文章缩略图
* @return
*/
public String uploadOneImage(MultipartFile file,Integer type){
if(type==1){
this.filedir = this.prefiledir + "avatar/";
}else{
this.filedir = this.prefiledir + "article/";
}
String fileUrl = uploadImg2Oss(file);
log.warn("fileurl:"+fileUrl);
String str = getImgUrl(fileUrl);
return str.trim();
}
}
```
#### 获取当前用户的信息
```java
AccountProfile accountProfile = ShiroUtil.getProfile();
```
#### 后端遇到的坑
##### 1. 跨域sessionid问题
使用cors跨域默认是不带cookies的,而后端获取不到cookies的SESSIONID就无法判断用户是哪个用户,导致每次请求都会使服务器增加一个session。也是就跨域sessionid问题。
解决办法:前端发送请求时加上
```js
//jQuery
xhrFields: {
withCredentials: true,
}
// axios
axios.default.withCredentials=true
```
后端也同时开启`httpServletResponse.setHeader("Access-Control-Allow-Credentials", "true");`
即可解决这个问题,后来发现,我他妈用jwt还要session干嘛,概念混乱..
[CORS跨域详解]([http://www.ruanyifeng.com/blog/2016/04/cors.html](http://www.ruanyifeng.com/blog/2016/04/cors.html))
因为用json数据,所以请求都是application/json属于非简单请求,所以每次请求都需要发一次OPTION请求,也称预检,但我们不希望他每次都发,可以设置一段时间内发送一次,设置如下
服务器设置Max-Age`httpServletResponse.setHeader("Access-Control-Max-Age", "43200"); // 预检时间间隔为60 * 60 * 12 = 43200 (12小时)`
##### 2. 在控制器上加@Transactional导致该Controller无法访问
##### 3. 插入数据时,数据库时间对应不上
时区问题,数据库连接url使用`serverTimezone=Asia/Shanghai`时区
##### 4. 数据库字段命名为数据库关键字导致的错误
article表有个文章描述的字段,开始用desc命名,但是插入的时候报错
```java
bad SQL grammar []; nested exception is java.sql.SQLSyntaxErrorException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'desc ) VALUES ( '',
```
后来网上查了才知道是命名错误