仿今日头条项目


相关技术栈


技术栈

  • Spring-Cloud-Gateway : 微服务之前架设的网关服务,实现服务注册中的API请求路由,以及控制流速控制和熔断处理都是常用的架构手段,而这些功能Gateway天然支持
  • 运用Spring Boot快速开发框架,构建项目工程;并结合Spring Cloud全家桶技术,实现后端个人中心、自媒体、管理中心等微服务。
  • 运用Spring Cloud Alibaba Nacos作为项目中的注册中心和配置中心
  • 运用mybatis-plus作为持久层提升开发效率
  • 运用Kafka完成内部系统消息通知;与客户端系统消息通知;以及实时数据计算
  • 运用Redis缓存技术,实现热数据的计算,提升系统性能指标
  • 使用Mysql存储用户数据,以保证上层数据查询的高性能
  • 使用Mongo存储用户热数据,以保证用户热数据高扩展和高性能指标
  • 使用FastDFS作为静态资源存储器,在其上实现热静态资源缓存、淘汰等功能
  • 运用Hbase技术,存储系统中的冷数据,保证系统数据的可靠性
  • 运用ES搜索技术,对冷数据、文章数据建立索引,以保证冷数据、文章查询性能
  • 运用AI技术,来完成系统自动化功能,以提升效率及节省成本。比如实名认证自动化
  • PMD&P3C : 静态代码扫描工具,在项目中扫描项目代码,检查异常点、优化点、代码规范等,为开发团队提供规范统一,提升项目代码质量

环境搭建


docker 安装 Nacos


  1. docker拉取镜像

    1
    docker pull nacos/nacos-server:1.2.0
  2. 创建容器

    1
    docker run --env MODE=standalone --name nacos --restart=always  -d -p 8848:8848 nacos/nacos-server:1.2.0
    • MODE=standalone 单机版

    • –restart=always 开机启动

    • -p 8848:8848 映射端口

    • -d 创建一个守护式容器在后台运行

  3. 访问地址:http://192.168.88.129:8848/nacos


初始项目模块介绍


  • 父工程
    • coo1heisenberg-todaynews
      • 统一管理项目依赖,继承springboot
  • 子工程
    • todaynews-common
      • 主要用来定义项目通用的配置(例如:全局异常)
        • 全局异常处理器会根据异常分为可预知异常(CostomException)、不可预知异常(Exception)
    • todaynews-feign-api
      • 主要定义远程接口
    • todaynews-gateway
      • 管理网关微服务
    • todaynews-model
      • 存储数据库中的表的对应的实体类(pojo、dto)
    • todaynews-service
      • 存储所有微服务相关的工程
    • todaynews-test
      • 主要是项目中常用的测试案例
    • todaynews-utils
      • 存储项目中常用的工具类

app端 登录


关于app端用户相关的内容较多,可以单独设置一个库leadnews_user

表名称 说明
ap_user APP用户信息表
ap_user_fan APP用户粉丝信息表
ap_user_follow APP用户关注信息表
ap_user_realname APP实名认证信息表

项目中的持久层使用的mybatis-plus,一般都使用mybais-plus逆向生成对应的实体类

Ap_User表

则app_user表对应的实体类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
@Data
@TableName("ap_user")
public class ApUser implements Serializable {

private static final long serialVersionUID = 1L;

/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Integer id;

/**
* 密码、通信等加密盐
*/
@TableField("salt")
private String salt;

/**
* 用户名
*/
@TableField("name")
private String name;

/**
* 密码,md5加密
*/
@TableField("password")
private String password;

/**
* 手机号
*/
@TableField("phone")
private String phone;

/**
* 头像
*/
@TableField("image")
private String image;

/**
* 0 男
* 1 女
* 2 未知
*/
@TableField("sex")
private Boolean sex;

/**
* 0 未
* 1 是
*/
@TableField("is_certification")
private Boolean certification;

/**
* 是否身份认证
*/
@TableField("is_identity_authentication")
private Boolean identityAuthentication;

/**
* 0正常
* 1锁定
*/
@TableField("status")
private Boolean status;

/**
* 0 普通用户
* 1 自媒体人
* 2 大V
*/
@TableField("flag")
private Short flag;

/**
* 注册时间
*/
@TableField("created_time")
private Date createdTime;

}

在这个表中有一项为salt,是手动加密(md5+随机字符串)

  • md5是不可逆加密,md5相同的密码每次加密都一样,不太安全。在md5的基础上手动加盐(salt)处理

  • 注册->生成盐

    注册流程

  • 登录->使用盐来配合验证

    登录流程


运营端微服务的搭建


微服务架构


app登录 接口定义


接口定义

常见端口的响应信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
/**
* 通用的结果返回类
* @param <T>
*/
public class ResponseResult<T> implements Serializable {

private String host;

private Integer code;

private String errorMessage;

private T data;

public ResponseResult() {
this.code = 200;
}

public ResponseResult(Integer code, T data) {
this.code = code;
this.data = data;
}

public ResponseResult(Integer code, String msg, T data) {
this.code = code;
this.errorMessage = msg;
this.data = data;
}

public ResponseResult(Integer code, String msg) {
this.code = code;
this.errorMessage = msg;
}

public static ResponseResult errorResult(int code, String msg) {
ResponseResult result = new ResponseResult();
return result.error(code, msg);
}

public static ResponseResult okResult(int code, String msg) {
ResponseResult result = new ResponseResult();
return result.ok(code, null, msg);
}

public static ResponseResult okResult(Object data) {
ResponseResult result = setAppHttpCodeEnum(AppHttpCodeEnum.SUCCESS, AppHttpCodeEnum.SUCCESS.getErrorMessage());
if(data!=null) {
result.setData(data);
}
return result;
}

// 查询一个对象
// Map map = new HashMap();
// map.put("name","zhangsan");
// map.put("age",18);
// ResponseResult result = ResponseResult.okResult(map);
// System.out.println(JSON.toJSONString(result));


public static ResponseResult errorResult(AppHttpCodeEnum enums){
return setAppHttpCodeEnum(enums,enums.getErrorMessage());
}

// 新增,修改,删除 在项目中统一返回成功即可
// ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.SUCCESS);
// System.out.println(JSON.toJSONString(result));

public static ResponseResult errorResult(AppHttpCodeEnum enums, String errorMessage){
return setAppHttpCodeEnum(enums,errorMessage);
}

//根据不用的业务返回不同的提示信息 比如:当前操作需要登录、参数错误
// ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
// System.out.println(JSON.toJSONString(result));

public static ResponseResult setAppHttpCodeEnum(AppHttpCodeEnum enums){
return okResult(enums.getCode(),enums.getErrorMessage());
}

// 前置
// AppHttpCodeEnum success = AppHttpCodeEnum.SUCCESS;
// System.out.println(success.getCode());
// System.out.println(success.getErrorMessage());*/

private static ResponseResult setAppHttpCodeEnum(AppHttpCodeEnum enums, String errorMessage){
return okResult(enums.getCode(),errorMessage);
}

public ResponseResult<?> error(Integer code, String msg) {
this.code = code;
this.errorMessage = msg;
return this;
}

public ResponseResult<?> ok(Integer code, T data) {
this.code = code;
this.data = data;
return this;
}

public ResponseResult<?> ok(Integer code, T data, String msg) {
this.code = code;
this.data = data;
this.errorMessage = msg;
return this;
}

public ResponseResult<?> ok(T data) {
this.data = data;
return this;
}

public Integer getCode() {
return code;
}

public void setCode(Integer code) {
this.code = code;
}

public String getErrorMessage() {
return errorMessage;
}

public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}

public T getData() {
return data;
}

public void setData(T data) {
this.data = data;
}

public String getHost() {
return host;
}

public void setHost(String host) {
this.host = host;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* 分页的返回结果
*/

public class PageResponseResult extends ResponseResult implements Serializable {
private Integer currentPage;
private Integer size;
private Integer total;

public PageResponseResult(Integer currentPage, Integer size, Integer total) {
this.currentPage = currentPage;
this.size = size;
this.total = total;
}

// 查询分页信息
// PageResponseResult responseResult = new PageResponseResult(1,5,50);
// List list = new ArrayList();
// list.add("todaynews");
// list.add("coo1heisenberg");
// responseResult.setData(list);
// System.out.println(JSON.toJSONString(responseResult));

public PageResponseResult() {

}


public int getCurrentPage() {
return currentPage;
}

public void setCurrentPage(int currentPage) {
this.currentPage = currentPage;
}

public int getSize() {
return size;
}

public void setSize(int size) {
this.size = size;
}

public int getTotal() {
return total;
}

public void setTotal(int total) {
this.total = total;
}
}

app登录 分析


登录分析


app登录 微服务框架

微服务架构

控制层 controller.v1

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("/api/v1/login")
public class ApUserLoginController {

@Autowired
private ApUserService apUserService;

// @RequestBody:因为传过来的都是JSON的数据,需要这个注释转换
@PostMapping("/login_auth")
public ResponseResult login(@RequestBody LoginDto dto) {
return apUserService.login(dto);
}
}

持久层 mapper

1
2
3
4
5
@Mapper
// @Mapper注解的接口继承BaseMapper进行增删改查
public interface ApUserMapper extends BaseMapper<ApUser> {

}

业务层 service

1
2
3
4
5
6
7
8
9
// Service接口继承IService,实现类继承ServiceImpl。也可以使用Service类进行增删改查
public interface ApUserService extends IService<ApUser> {
/**
* app端登录功能
* @param dto
* @return
*/
public ResponseResult login(LoginDto dto);
}

实现类 service.Impl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@Service
@Transactional
@Slf4j
public class ApUserServiceImpl extends ServiceImpl<ApUserMapper, ApUser> implements ApUserService {
/**
* app端登录功能
*
* @param dto
* @return
*/
@Override
public ResponseResult login(LoginDto dto) {
// 1. 正常登录 用户名和密码

if (StringUtils.isNoneBlank(dto.getPhone())
&& StringUtils.isNoneBlank(dto.getPassword())) {
// 1.1 根据手机号查询用户信息
// Mybatis-Plus中的定义(复习!!!)
ApUser dbUser = getOne(Wrappers.<ApUser>lambdaQuery().eq(ApUser::getPhone, dto.getPhone()));
if (dbUser == null) {
return ResponseResult.errorResult(AppHttpCodeEnum.DATA_NOT_EXIST, "用户信息不存在");
}

// 1.2 比对密码
String salt = dbUser.getSalt();
String password = dto.getPassword();
// Spring框架提供的MD5加密
String pswd = DigestUtils.md5DigestAsHex((password + salt).getBytes());

if (!pswd.equals(dbUser.getPassword())) {
return ResponseResult.errorResult(AppHttpCodeEnum.LOGIN_PASSWORD_ERROR);
}

// 1.3 返回数据 生成jwt user
String token = AppJwtUtil.getToken(dbUser.getId().longValue());
Map<String, Object> map = new HashMap<>();
map.put("token", token);
// Salt和password是不需要返回给前端的
dbUser.setSalt("");
dbUser.setPassword("");
map.put("user",dbUser);

return ResponseResult.okResult(map);
} else {

// 2. 游客登录
Map<String, Object> map = new HashMap<>();
map.put("token", AppJwtUtil.getToken(0L));

return ResponseResult.okResult(map);
}

}
}

UserApplication.java

1
2
3
4
5
6
7
8
@EnableDiscoveryClient
@SpringBootApplication
@MapperScan("com.heima.user.mapper")
public class UserApplication {
public static void main(String[] args) {
SpringApplication.run(UserApplication.class, args);
}
}

资源类 resources

bootstrap.yml
1
2
3
4
5
6
7
8
9
10
11
12
server:
port: 51801
spring:
application:
name: todaynews-user
cloud:
nacos:
discovery:
server-addr: 192.168.200.130:8848
config:
server-addr: 192.168.200.130:8848
file-extension: yml
logback.vml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<?xml version="1.0" encoding="UTF-8"?>

<configuration>
<!--定义日志文件的存储地址,使用绝对路径-->
<property name="LOG_HOME" value="d:/logs"/>

<!-- Console 输出设置 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度%msg:日志消息,%n是换行符-->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>utf8</charset>
</encoder>
</appender>

<!-- 按照每天生成日志文件 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!--日志文件输出的文件名-->
<fileNamePattern>${LOG_HOME}/leadnews.%d{yyyy-MM-dd}.log</fileNamePattern>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<!-- 异步输出 -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>512</queueSize>
<!-- 添加附加的appender,最多只能添加一个 -->
<appender-ref ref="FILE"/>
</appender>


<logger name="org.apache.ibatis.cache.decorators.LoggingCache" level="DEBUG" additivity="false">
<appender-ref ref="CONSOLE"/>
</logger>
<logger name="org.springframework.boot" level="debug"/>
<root level="info">
<!--<appender-ref ref="ASYNC"/>-->
<appender-ref ref="FILE"/>
<appender-ref ref="CONSOLE"/>
</root>
</configuration>

注册中心 nacos

nacos


接口测试工具


Postman


测试用户登录

测试游客登录

测试错误登录


Swagger


Swagger是一个在线的接口文档管理中心

SpringBoot集成Swagger

  • 引入依赖,在todaynews-model和todaynews-common中引入依赖

    1
    2
    3
    4
    5
    6
    7
    8
    <dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger2</artifactId>
    </dependency>
    <dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger-ui</artifactId>
    </dependency>
  • 在todaynews-common工程中添加一个配置类,新增com.heima.common.swagger.SwaggerConfiguration

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    package com.heima.common.swagger;

    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import springfox.documentation.builders.ApiInfoBuilder;
    import springfox.documentation.builders.PathSelectors;
    import springfox.documentation.builders.RequestHandlerSelectors;
    import springfox.documentation.service.ApiInfo;
    import springfox.documentation.service.Contact;
    import springfox.documentation.spi.DocumentationType;
    import springfox.documentation.spring.web.plugins.Docket;
    import springfox.documentation.swagger2.annotations.EnableSwagger2;

    @Configuration
    @EnableSwagger2
    public class SwaggerConfiguration {

    @Bean
    public Docket buildDocket() {
    return new Docket(DocumentationType.SWAGGER_2)
    .apiInfo(buildApiInfo())
    .select()
    // 要扫描的API(Controller)基础包
    .apis(RequestHandlerSelectors.basePackage("com.heima"))
    .paths(PathSelectors.any())
    .build();
    }

    private ApiInfo buildApiInfo() {
    Contact contact = new Contact("海森堡","","");
    return new ApiInfoBuilder()
    .title("头条点评-平台管理API文档")
    .description("头条点评后台api")
    .contact(contact)
    .version("1.0.0").build();
    }
    }
  • 在todaynews-common模块中的resources目录中新增以下目录和文件

    • 文件:resources/META-INF/Spring.factories

      1
      2
      3
      org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
      com.heima.common.exception.ExceptionCatch,\
      com.heima.common.swagger.SwaggerConfiguration
  • 常用Swagger注解如下:

    • @Api:修饰整个类,描述Controller的作用

    • @ApiOperation:描述一个类的一个方法,或者说一个接口

    • @ApiParam:单个参数的描述信息

    • @ApiModel:用对象来接收参数

    • @ApiModelProperty:用对象接收参数时,描述对象的一个字段

    • @ApiResponse:HTTP响应其中1个描述

    • @ApiResponses:HTTP响应整体描述

    • @ApiIgnore:使用该注解忽略这个API

    • @ApiError :发生错误返回的信息

    • @ApiImplicitParam:一个请求参数

    • @ApiImplicitParams:多个请求参数的描述信息

  • 启动user微服务,访问地址:http://localhost:51801/swagger-ui.html

eg:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ApUserLoginController.java文件下

@RestController
@RequestMapping("/api/v1/login")
@Api(value = "app端用户登录", tags = "app端用户登录")
public class ApUserLoginController {

@Autowired
private ApUserService apUserService;

// @RequestBody:因为传过来的都是JSON的数据,需要这个注释转换
@ApiOperation("用户登录")
@PostMapping("/login_auth")
public ResponseResult login(@RequestBody LoginDto dto) {
return apUserService.login(dto);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// model类下的LoginDTO

@Data
public class LoginDto {

/**
* 登录用的手机号和密码
*
*/
// required = true 指必须输入的
@ApiModelProperty(value = "手机号", required = true)
private String phone;

@ApiModelProperty(value = "密码", required = true)
private String password;
}

测试结果


knife4j


核心功能:

  • 文档说明
  • 在线调试
  • 个性化配置
  • 离线文档
  • 接口排序

快速集成

  • 在todaynews-common模块中的pom.xml文件中引入knife4j的依赖

    1
    2
    3
    4
    <dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
    </dependency>
  • 在todaynews-common模块中新建配置类,新建Swagger的配置文件SwaggerConfiguration.java文件,创建springfox提供的Docket分组对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    package com.heima.common.knife4j;

    import com.github.xiaoymin.knife4j.spring.annotations.EnableKnife4j;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.context.annotation.Import;
    import springfox.bean.validators.configuration.BeanValidatorPluginsConfiguration;
    import springfox.documentation.builders.ApiInfoBuilder;
    import springfox.documentation.builders.PathSelectors;
    import springfox.documentation.builders.RequestHandlerSelectors;
    import springfox.documentation.service.ApiInfo;
    import springfox.documentation.spi.DocumentationType;
    import springfox.documentation.spring.web.plugins.Docket;
    import springfox.documentation.swagger2.annotations.EnableSwagger2;

    @Configuration
    // @EnableSwagger2:该注解是Springfox-swagger框架提供的使用Swagger注解
    @EnableSwagger2
    // @EnableKnife4j:该注解是knife4j提供的增强注解,Ui提供了例如动态参数、参数过滤、接口排序等增强功能,如果你想使用这些增强功能就必须加该注解,否则可以不用加
    @EnableKnife4j
    @Import(BeanValidatorPluginsConfiguration.class)
    public class Swagger2Configuration {

    @Bean(value = "defaultApi2")
    public Docket defaultApi2() {
    Docket docket=new Docket(DocumentationType.SWAGGER_2)
    .apiInfo(apiInfo())
    //分组名称
    .groupName("1.0")
    .select()
    //这里指定Controller扫描包路径
    .apis(RequestHandlerSelectors.basePackage("com.heima"))
    .paths(PathSelectors.any())
    .build();
    return docket;
    }
    private ApiInfo apiInfo() {
    return new ApiInfoBuilder()
    .title("头条点评API文档")
    .description("头条点评API文档")
    .version("1.0")
    .build();
    }
    }
  • 在todaynews-common模块中的resources目录中新增以下目录和文件

    • 文件:resources/META-INF/Spring.factories

      1
      2
      3
      org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
      com.heima.common.exception.ExceptionCatch,\
      com.heima.common.swagger.SwaggerConfiguration
  • 访问,在浏览器输入地址:http://localhost:51801/doc.html

输出示例


app端 网关


网关介绍

本项目要搭建的网关

网关的搭建流程

导入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
</dependencies>

注册中心 nacos

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
spring:
cloud:
gateway:
globalcors:
add-to-simple-url-handler-mapping: true
corsConfigurations:
'[/**]':
allowedHeaders: "*"
allowedOrigins: "*"
allowedMethods:
- GET
- POST
- DELETE
- PUT
- OPTION
routes:
# 平台管理
- id: user
uri: lb://leadnews-user
predicates:
- Path=/user/**
filters:
- StripPrefix= 1

nacos 注册中心

bootstrap.yml

1
2
3
4
5
6
7
8
9
10
11
12
server:
port: 51601
spring:
application:
name: todaynews-app-gateway
cloud:
nacos:
discovery:
server-addr: 192.168.200.130:8848
config:
server-addr: 192.168.200.130:8848
file-extension: yml

AppGatewayApplication.java

1
2
3
4
5
6
7
8
9
@SpringBootApplication
// 加入注册中心
@EnableDiscoveryClient
public class AppGatewayApplication {

public static void main(String[] args) {
SpringApplication.run(AppGatewayApplication.class, args);
}
}

认证过滤器


主要用来校验当前的token

过滤流程

实现流程:

  1. 在com.heima.app.gateway.util包下拷贝AppJwtUtil.java

  2. 在com.heima.app.gateway.filter包下新建全局过滤器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    @Component
    @Slf4j
    // AuthorizeFilter实现了GatewayFilter和Ordered接口,用于实现全局过滤器的逻辑
    public class AuthorizeFilter implements Ordered, GlobalFilter {

    /**
    * 过滤的方法
    *
    * @param exchange
    * @param chain
    * @return
    */

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    // 1. 获取 request 和 response
    ServerHttpRequest request = exchange.getRequest();
    ServerHttpResponse response = exchange.getResponse();

    // 2. 判断是否是登录
    if (request.getURI().getPath().contains("/login")) {
    // 放行
    return chain.filter(exchange);
    }

    // 3. 获取token
    String token = request.getHeaders().getFirst("token");

    // 4. 判断token是否存在
    if (StringUtils.isBlank(token)) {
    // HttpStatus.UNAUTHORIZED:401
    response.setStatusCode(HttpStatus.UNAUTHORIZED);
    return response.setComplete();
    }

    // 5. 判断token是否有效
    try {
    // 获取token中的数据
    Claims claimsBody = AppJwtUtil.getClaimsBody(token);
    // 是否是过期
    // -1:有效,0:有效,1:过期,2:过期
    int result = AppJwtUtil.verifyToken(claimsBody);
    if (result == 1 || result == 2) {
    // HttpStatus.UNAUTHORIZED:401
    response.setStatusCode(HttpStatus.UNAUTHORIZED);
    return response.setComplete();
    }
    } catch (Exception e) {
    e.printStackTrace();
    // HttpStatus.UNAUTHORIZED:401
    response.setStatusCode(HttpStatus.UNAUTHORIZED);
    return response.setComplete();
    }


    // 6. 放行
    return chain.filter(exchange);
    }

    /**
    * 优先级设置 值越小 优先级越高
    *
    * @return
    */
    @Override
    public int getOrder() {
    return 0;
    }
    }
  3. 启动user服务,继续访问其他微服务,会提示需要认证才能访问,这个时候需要在heads中设置设置token才能正常访问


app端 前端项目集成


nginx

  • 通过nginx的反向代理功能访问后台的网关资源
  • 通过nginx的静态服务器功能访问前端静态页面

流程

  • 在 nginx 安装的 conf 目录下新建一个文件夹todaynews.conf,在当前文件夹中新建todaynews-app.conf文件,配置如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    upstream  heima-app-gateway{
    server localhost:51601;
    }

    server {
    listen 8801;
    location / {
    root D:/BaiduNetdiskDownload/todaynews/app-web/;
    index index.html;
    }

    location ~/app/(.*) {
    proxy_pass http://heima-app-gateway/$1;
    proxy_set_header HOST $host; # 不改变源请求头的值
    proxy_pass_request_body on; #开启获取请求体
    proxy_pass_request_headers on; #开启获取请求头
    proxy_set_header X-Real-IP $remote_addr; # 记录真实发出请求的客户端IP
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; #记录代理信息
    }
    }
  • nginx.conf 把里面注释的内容和静态资源配置相关删除,引入 todaynews-app.conf文件加载,配置如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #user  nobody;
    worker_processes 1;

    events {
    worker_connections 1024;
    }
    http {
    include mime.types;
    default_type application/octet-stream;
    sendfile on;
    keepalive_timeout 65;
    # 引入自定义配置文件
    include todaynews.conf/*.conf;
    }

app端 文章列表


导入数据库 leadnews_article

leadnews_article

为什么文章表要拆分成多个表?

  • 垂直分表:将一个表的字段分散到多个表中,每个表存储其中一部分字段
  • 优势:
    1. 减少IO争抢,减少锁表的几率,查看文章概述与文章详情互不影响
    2. 充分发挥高频数据的操作效率,对文章概述数据操作的高效率不会被操作文章详情数据的低效率所累
  • 拆分规则:
    1. 把不常用的字段单独放在一张表
    2. 把 text,blob 等大字段拆分出来单独放在一张表
    3. 经常组合查询的字段单独放在一张表中

文章列表加载


实现思路:

  1. 在默认频道展示10条文章信息
  2. 可以切换频道查看不同种类文章
  3. 当用户下拉可以加载最新的文章(分页)本页文章列表中发布时间为最大的时间为依据
  4. 当用户上拉可以加载更多的文章信息(按照发布时间)本页文章列表中发布时间最小的时间为依据
  5. 如果是当前频道的首页,前端传递默认参数:
    • minBehotTime:0(毫秒)
    • maxBehotTime:20000000000000(毫秒)—>2063年

表结构分析:

  • ap_article 文章基本信息表

    ap_article

  • ap_article_config 文章配置表

    ap_article_config

  • ap_article_content 文章内容表

    ap_article_content

  • 三表之间的关系

    三表之间的关系

接口定义:

加载首页 加载更多 加载最新
接口路径 /api/v1/article/load /api/v1/article/loadmore /api/v1/article/loadnew
请求方式 POST POST POST
参数 ArticleHomeDto ArticleHomeDto ArticleHomeDto
响应结果 ResponseResult ResponseResult ResponseResult

注意:POST请求可以把数据携带在请求体里,GET请求只能将数据拼接在uri的后面,请求参数比较多一般用POST

其中,ArticleHomeDto 为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.heima.model.article.dtos;

import lombok.Data;

import java.util.Date;

@Data
public class ArticleHomeDto {

// 最大时间
Date maxBehotTime;
// 最小时间
Date minBehotTime;
// 分页size
Integer size;
// 频道ID
String tag;
}

app端 文章列表加载的步骤:

  1. 在 nacos 中添加对应的配置
  2. 定义接口,包括:接口路径、请求方式、入参、出参
  3. 编写mapper文件,包括:文章表与文章配置表多表查询
  4. 编写业务层代码
  5. 编写控制器代码
  6. swagger测试或前后端联调测试
  • 在 nacos 中添加对应的配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    spring:
    datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/leadnews_article?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
    username: root
    password: 123456
    # 设置Mapper接口所对应的XML文件位置,如果你在Mapper接口中有自定义方法,需要进行该配置
    mybatis-plus:
    mapper-locations: classpath*:mapper/*.xml
    # 设置别名包扫描路径,通过该属性可以给包中的类注册别名
    type-aliases-package: com.heima.model.article.pojos
  • 定义接口,在com.heima.article.controller.v1目录下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    @RestController
    @RequestMapping("/api/v1/article")
    public class ArticleHomeController {

    /**
    * 加载首页
    *
    * @param dto
    * @return
    */
    @PostMapping("/load")
    public ResponseResult load(@RequestBody ArticleHomeDto dto) {
    return null;
    }

    /**
    * 加载更多
    *
    * @param dto
    * @return
    */
    @PostMapping("/loadmore")
    public ResponseResult loadmore(@RequestBody ArticleHomeDto dto) {
    return null;
    }

    /**
    * 加载最新
    *
    * @param dto
    * @return
    */
    @PostMapping("/loadnew")
    public ResponseResult loadnew(@RequestBody ArticleHomeDto dto) {
    return null;
    }
    }
  • 编写mapper文件

    1. 在com.heima.article.mapper目录下,编写一个接口文件

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      @Mapper
      public interface ApArticleMapper extends BaseMapper<ApArticle> {

      /**
      * 加载文章列表
      * @param dto
      * @param type 1 加载更多 2 加载最新
      * @return
      */
      public List<ApArticle> loadArticleList(ArticleHomeDto dto, Short type);

      }
    2. 在resources.mapper目录下,编写mapper对应的映射文件 ApArticleMapper.xml

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      <?xml version="1.0" encoding="UTF-8"?>
      <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
      <mapper namespace="com.heima.article.mapper.ApArticleMapper">
      <resultMap id="resultMap" type="com.heima.model.article.pojos.ApArticle">
      <id column="id" property="id"/>
      <result column="title" property="title"/>
      <result column="author_id" property="authorId"/>
      <result column="author_name" property="authorName"/>
      <result column="channel_id" property="channelId"/>
      <result column="channel_name" property="channelName"/>
      <result column="layout" property="layout"/>
      <result column="flag" property="flag"/>
      <result column="images" property="images"/>
      <result column="labels" property="labels"/>
      <result column="likes" property="likes"/>
      <result column="collection" property="collection"/>
      <result column="comment" property="comment"/>
      <result column="views" property="views"/>
      <result column="province_id" property="provinceId"/>
      <result column="city_id" property="cityId"/>
      <result column="county_id" property="countyId"/>
      <result column="created_time" property="createdTime"/>
      <result column="publish_time" property="publishTime"/>
      <result column="sync_status" property="syncStatus"/>
      <result column="static_url" property="staticUrl"/>
      </resultMap>
      <select id="loadArticleList" resultMap="resultMap">
      SELECT
      aa.*
      FROM
      `ap_article` aa
      LEFT JOIN ap_article_config aac ON aa.id = aac.article_id
      <where>
      and aac.is_delete != 1
      and aac.is_down != 1
      <!-- loadmore -->
      <if test="type != null and type == 1">
      and aa.publish_time <![CDATA[<]]> #{dto.minBehotTime}
      </if>
      <if test="type != null and type == 2">
      and aa.publish_time <![CDATA[>]]> #{dto.maxBehotTime}
      </if>
      <if test="dto.tag != '__all__'">
      and aa.channel_id = #{dto.tag}
      </if>
      </where>
      order by aa.publish_time desc
      limit #{dto.size}
      </select>
      </mapper>
  • 编写业务层代码

    1. 在 com.heima.article.service目录下定义接口

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      public interface ApArticleService extends IService<ApArticle> {

      /**
      * 加载文章列表
      * @param dto
      * @param type 1 加载更多 2 加载最新
      * @return
      */
      public ResponseResult load(ArticleHomeDto dto, Short type);
      }

    2. 在com.heima.article.service.Impl目录下定义实现类

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      @Service
      @Transactional
      @Slf4j
      public class ApArticleServiceImpl extends ServiceImpl<ApArticleMapper, ApArticle> implements ApArticleService {

      @Autowired
      private ApArticleMapper apArticleMapper;

      private final static short MAX_PAGE_SIZE = 50;

      /**
      * public static final Short LOADTYPE_LOAD_MORE = 1;
      *
      * public static final Short LOADTYPE_LOAD_NEW = 2;
      *
      * public static final String DEFAULT_TAG = "__all__";
      */

      /**
      * 加载文章列表
      *
      * @param dto
      * @param type 1 加载更多 2 加载最新
      * @return
      */
      @Override
      public ResponseResult load(ArticleHomeDto dto, Short type) {

      // 1. 检验参数
      // 分页条数的校验
      Integer size = dto.getSize();
      if (size == null || size == 0) {
      size = 10;
      }

      // 分页值不超过50
      size = Math.min(size, MAX_PAGE_SIZE);

      dto.setsize(size);

      // 校验参数 --> type
      if (!type.equals(ArticleConstants.LOADTYPE_LOAD_MORE) && !type.equals(ArticleConstants.LOADTYPE_LOAD_NEW)) {
      type = ArticleConstants.LOADTYPE_LOAD_MORE;
      }

      // 频道参数的校验
      if (StringUtils.isBlank(dto.getTag())) {
      dto.setTag(ArticleConstants.DEFAULT_TAG);
      }

      // 时间校验
      if (dto.getMaxBehotTime() == null) {
      dto.setMaxBehotTime(new Date());
      }

      if (dto.getMinBehotTime() == null) {
      dto.setMinBehotTime(new Date());
      }

      // 2. 查询
      List<ApArticle> articleList = apArticleMapper.loadArticleList(dto, type);

      // 3. 返回
      return ResponseResult.okResult(articleList);
      }
      }
  • 编写控制器代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    @RestController
    @RequestMapping("/api/v1/article")
    public class ArticleHomeController {

    @Autowired
    private ApArticleService apArticleService;


    /**
    * 加载首页
    *
    * @param dto
    * @return
    */
    @PostMapping("/load")
    public ResponseResult load(@RequestBody ArticleHomeDto dto) {
    return apArticleService.load(dto, ArticleConstants.LOADTYPE_LOAD_MORE);
    }

    /**
    * 加载更多
    *
    * @param dto
    * @return
    */
    @PostMapping("/loadmore")
    public ResponseResult loadmore(@RequestBody ArticleHomeDto dto) {
    return apArticleService.load(dto, ArticleConstants.LOADTYPE_LOAD_MORE);
    }

    /**
    * 加载最新
    *
    * @param dto
    * @return
    */
    @PostMapping("/loadnew")
    public ResponseResult loadnew(@RequestBody ArticleHomeDto dto) {
    return apArticleService.load(dto, ArticleConstants.LOADTYPE_LOAD_NEW);
    }
    }
  • 还需要配置这个微服务的网关路由,在 nacos 中进行配置 leadnews-app-gateway

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    spring:
    cloud:
    gateway:
    globalcors:
    add-to-simple-url-handler-mapping: true
    corsConfigurations:
    '[/**]':
    allowedHeaders: "*"
    allowedOrigins: "*"
    allowedMethods:
    - GET
    - POST
    - DELETE
    - PUT
    - OPTION
    routes:
    # 用户微服务
    - id: user
    uri: lb://leadnews-user
    predicates:
    - Path=/user/**
    filters:
    - StripPrefix= 1
    # 文章微服务
    - id: article
    uri: lb://leadnews-article
    predicates:
    - Path=/article/**
    filters:
    - StripPrefix= 1

app端 文章详情


实现方案一:用户某一条文章,根据文章的id去查询文章内容表,返回渲染页面

方案一

实现方案二:静态模板展示

方案二


Freemarker

Freemarker 是一款 模板引擎技术 : 即一种基于模板和要改变的数据, 并用来生成输出文本(HTML网页,电子邮件,配置文件,源代码等)的通用工具。 它不是面向最终用户的,而是一个Java类库,是一款程序员可以嵌入他们所开发产品的组件。

Freemarker

模板引擎对比

注意:freemarker 模板文件通常都是以 ftl 作为扩展名,也可以为 html 、xml 、jsp 等

测试案例:

  • 导入依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    <dependencies>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-freemarker</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
    <!-- lombok -->
    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    </dependency>
    <!-- apache 对 java io 的封装工具库 -->
    <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-io</artifactId>
    <version>1.3.2</version>
    </dependency>
    </dependencies>
  • 配置 application.yml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    server:
    port: 8881 #服务端口
    spring:
    application:
    name: freemarker-demo #指定服务名
    freemarker:
    cache: false #关闭模板缓存,方便测试
    settings:
    template_update_delay: 0 #检查模板更新延迟时间,设置为0表示立即检查,如果时间大于0会有缓存不方便进行模板测试
    suffix: .ftl #指定Freemarker模板文件的后缀名
  • 在resources下创建templates,此目录为 freemarker的默认模板存放目录

  • 在templates下创建模板文件 01-basic.ftl ,模板中的插值表达式最终会被freemarker替换成具体的数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    <!--模板文件 01-basic.ftl-->

    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="utf-8">
    <title>Hello World!</title>
    </head>
    <body>
    <b>普通文本 String 展示:</b><br><br>
    Hello ${name} <br>
    <hr>
    <b>对象Student中的数据展示:</b><br/>
    姓名:${stu.name}<br/>
    年龄:${stu.age}
    <hr>
    </body>
    </html>
  • 创建一个 Student 类

    1
    2
    3
    4
    5
    6
    7
    @Data
    public class Student {
    private String name;//姓名
    private int age;//年龄
    private Date birthday;//生日
    private Float money;//钱包
    }
  • 创建Controller类,通过Model类来传参,最后返回模板文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Controller
    public class HelloController {
    @GetMapping("/basic")
    public String hello(Model model) {

    // name
    model.addAttribute("name","freemarker");

    // stu
    Student student = new Student();
    student.setName("xiaoming");
    student.setAge(18);
    model.addAttribute("stu",student);

    return "01-basic";
    }
    }
  • 创建启动类

    1
    2
    3
    4
    5
    6
    @SpringBootApplication
    public class FreemarkerDemoApplication {
    public static void main(String[] args) {
    SpringApplication.run(FreemarkerDemoApplication.class, args);
    }
    }
  • 测试,访问 http://localhost:8881/basic

    测试结果


Freemarker 指令语法

  • 注释

    1
    <#-- ... -->
  • 插值

    1
    ${...}
  • FTL指令:和HTML标记类似,名字前加#予以区分,Freemarker会解析标签中的表达式或逻辑

    1
    <# > FTL指令 </#>
  • 文本:仅文本信息,这些不是freemarker的注释、插值、FTL指令的内容会被freemarker忽略解析,直接输出内容

集合指令 - List:用来遍历 list

1
<#list></#list>

eg:

1
2
3
4
5
6
7
8
<#list stus as stu>
<tr>
<td>${stu_index+1}</td>
<td>${stu.name}</td>
<td>${stu.age}</td>
<td>${stu.money}</td>
</tr>
</#list>

集合指令 - Map:用来遍历 map

  1. 获取 map 中的值

    1
    map['keyname'].property
    1
    map.keyname.property
  2. 遍历 map

    1
    2
    3
    <#list userMap?keys as key>
    key:${key} -- value:${userMap["${key}"]}
    </#list>

eg:

1
2
3
4
5
6
7
8
9
10
<a href="###">方式一:通过map['keyname'].property</a><br/>
输出stu1的学生信息:<br/>
姓名:${stuMap['stu1'].name}<br/>
年龄:${stuMap['stu1'].age}<br/>
<br/>

<a href="###">方式二:通过map.keyname.property</a><br/>
输出stu2的学生信息:<br/>
姓名:${stuMap.stu2.name}<br/>
年龄:${stuMap.stu2.age}<br/>

if指令:if 指令即判断指令,是常用的FTL指令,freemarker在解析时遇到if会进行判断,条件为真则输出if中间的内容,否则跳过内容不再输出。

1
<#if ></if>
1
2
3
<#if expression>
<#else>
</#if>

注意:在freemarker中,判断是否相等,= 与 == 是一样的

eg:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<table>
<tr>
<td>姓名</td>
<td>年龄</td>
<td>钱包</td>
</tr>
<#list stus as stu >
<#if stu.name='小红'>
<tr style="color: red">
<td>${stu_index}</td>
<td>${stu.name}</td>
<td>${stu.age}</td>
<td>${stu.money}</td>
</tr>
<#else >
<tr>
<td>${stu_index}</td>
<td>${stu.name}</td>
<td>${stu.age}</td>
<td>${stu.money}</td>
</tr>
</#if>
</#list>
</table>

运算符:

  1. 算数运算符:FreeMarker表达式中完全支持算术运算

    • 加法: +
    • 减法: -
    • 乘法: *
    • 除法: /
    • 求模 (求余): %
  2. 比较运算符

    • =或者==:判断两个值是否相等.
    • !=:判断两个值是否不等.
    • >或者gt:判断左边值是否大于右边值
    • >=或者gte:判断左边值是否大于等于右边值
    • <或者lt:判断左边值是否小于右边值
    • <=或者lte:判断左边值是否小于等于右边值
  3. 比较运算符注意事项:

    • **=和!=**可以用于字符串、数值和日期来比较是否相等
    • **=和!=**两边必须是相同类型的值,否则会产生错误
    • 字符串 "x" 、"x " 、**"X"**比较是不等的.因为FreeMarker是精确比较
    • 其它的运行符可以作用于数字和日期,但不能作用于字符串
    • 使用**gt等字母运算符代替>会有更好的效果,因为 FreeMarker会把>**解释成FTL标签的结束字符
    • 可以使用括号来避免这种情况,如:<#if (x>y)>
  4. 逻辑运算符

    • 逻辑与:&&
    • 逻辑或:||
    • 逻辑非:!
  5. 空值处理

    • 判断某变量是否存在使用 ??,用法为:variable??,如果该变量存在,返回true,否则返回false

      1
      2
      3
      4
      5
      6
      7
      <#-- 为防止stus为空报错可以加上判断 -->

      <#if stus??>
      <#list stus as stu>
      ......
      </#list>
      </#if>
    • 缺失变量默认值使用 !

      1. 使用 ! 要以指定一个默认值,当变量为空时显示默认值

        1
        2
        3
        ${name!}

        <#-- 表示如果name为空显示空字符串 -->
      2. 如果是嵌套对象则建议使用 () 括起来

        1
        2
        3
        ${(stu.bestFriend.name)!}

        <#-- 如果stu或bestFriend或name为空默认显示空字符串 -->

内建函数:

1
变量 + ? + 函数名称
  1. 和到某个集合的大小

    1
    ${集合名?size}
  2. 日期格式化

    显示年月日: ${today?date}
    显示时分秒:${today?time}
    显示日期+时间:${today?datetime}
    自定义格式化: ${today?string("yyyy年MM月")}

  3. 内建函数c

    使用c函数将数字型转成字符串输出:${point?c}

  4. 将 json 字符串转成对象 eval

    1
    2
    3
    4
    <#-- assign标签,assign的作用是定义一个变量 -->

    <#assign text="{'bank':'工商银行','account':'10101920201920212'}" />
    <#assign data=text?eval />

输出静态化文件

使用 freemarker 原生api 将 页面 生成 html 文件

输出静态化文件

  1. 修改 application.yml 文件,添加以下模板存放位置的配置信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    server:
    port: 8881 #服务端口
    spring:
    application:
    name: freemarker-demo #指定服务名
    freemarker:
    cache: false #关闭模板缓存,方便测试
    settings:
    template_update_delay: 0 #检查模板更新延迟时间,设置为0表示立即检查,如果时间大于0会有缓存不方便进行模板测试
    suffix: .ftl #指定Freemarker模板文件的后缀名
    template-loader-path: classpath:/templates
  2. 在 test 下创建测试类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    @SpringBootTest(classes = FreemarkerDemoApplication.class)
    @RunWith(SpringRunner.class)
    public class FreemarkerTest {

    @Autowired
    private Configuration configuration;

    @Test
    public void test() throws IOException, TemplateException {
    Template template = configuration.getTemplate("01-basic.ftl");

    /**
    * 合成方法
    *
    * 两个参数
    * 第一个参数:模型数据
    * 第二个参数:输出流
    */
    template.process(getDate(), new FileWriter("d:\\list.html"));
    }

    private Map getDate() {
    Map<String, Object> map = new HashMap<>();

    //小强对象模型数据
    Student stu1 = new Student();
    stu1.setName("小强");
    stu1.setAge(18);
    stu1.setMoney(1000.86f);
    stu1.setBirthday(new Date());

    //小红对象模型数据
    Student stu2 = new Student();
    stu2.setName("小红");
    stu2.setMoney(200.1f);
    stu2.setAge(19);

    //将两个对象模型数据存放到List集合中
    List<Student> stus = new ArrayList<>();
    stus.add(stu1);
    stus.add(stu2);

    //向map中存放List集合数据
    map.put("stus", stus);


    //创建Map数据
    HashMap<String, Student> stuMap = new HashMap<>();
    stuMap.put("stu1", stu1);
    stuMap.put("stu2", stu2);
    //向map中存放Map数据
    map.put("stuMap", stuMap);

    //返回Map
    return map;
    }
    }

MinIO


对象存储方式的对比

分布式文件系统对比

MinIO基于Apache License v2.0开源协议的对象存储服务,可以做为云存储的解决方案用来保存海量的图片,视频,文档。由于采用Golang实现,服务端可以工作在Windows,Linux, OS X和FreeBSD上。配置简单,基本是复制可执行程序,单行命令可以运行起来。


Docker安装流程

  1. 拉取镜像

    1
    docker pull minio/minio
  2. 创建容器

    1
    docker run -d \-p 9000:9000 \-p 9001:9001 \--name minio --restart=always --privileged=true \-v /home/minio/data:/data \-e "MINIO_ROOT_USER=minio" \-e "MINIO_ROOT_PASSWORD=minio123" \minio/minio server /data --console-address ":9001"
  3. 访问 minio 系统

    http://192.168.88.129:9001/login

Minio登录界面

Minio的使用

  • bucket – 类比于文件系统的目录
  • Object – 类比文件系统的文件
  • Keys – 类比文件名

Minio上传文件进行静态访问

  1. 导入依赖

    1
    2
    3
    4
    5
    <dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>7.1.0</version>
    </dependency>
  2. 创建测试类,上传html文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    public class MinIOTest {
    /**
    * 将list.html上传到minio中,并且可以在浏览器中访问
    *
    * @param args
    */
    public static void main(String[] args) {

    try {
    FileInputStream fileInputStream = new FileInputStream("D:\\list.html");

    // 1. 获取minio的链接信息,创建一个minio的客户端
    MinioClient minioClient = MinioClient.builder()
    .credentials("minio", "minio123")
    .endpoint("http://192.168.88.129:9000")
    .build();

    // 2. 上传
    PutObjectArgs putObjectArgs = PutObjectArgs.builder()
    .object("list.html") // 文件名称
    .contentType("text/html") // 文件类型
    .bucket("leadnews") // 桶名词 与minio创建的名词一致
    .stream(fileInputStream, fileInputStream.available(), -1) // 文件流
    .build();
    minioClient.putObject(putObjectArgs);

    // 访问路径
    System.out.println("http://192.168.88.129:9000/leadnews/list.html");
    } catch (Exception e) {
    e.printStackTrace();
    }
    }
    }

封装MinIO为starter

抽取文件服务为starter

在heima.leadnews.starter目录下封装了MinIO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
@Slf4j
@EnableConfigurationProperties(MinIOConfigProperties.class)
@Import(MinIOConfig.class)
public class MinIOFileStorageService implements FileStorageService {

@Autowired
private MinioClient minioClient;

@Autowired
private MinIOConfigProperties minIOConfigProperties;

private final static String separator = "/";

/**
* @param dirPath
* @param filename yyyy/mm/dd/file.jpg
* @return
*/
public String builderFilePath(String dirPath,String filename) {
StringBuilder stringBuilder = new StringBuilder(50);
if(!StringUtils.isEmpty(dirPath)){
stringBuilder.append(dirPath).append(separator);
}
SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
String todayStr = sdf.format(new Date());
stringBuilder.append(todayStr).append(separator);
stringBuilder.append(filename);
return stringBuilder.toString();
}

/**
* 上传图片文件
* @param prefix 文件前缀
* @param filename 文件名
* @param inputStream 文件流
* @return 文件全路径
*/
@Override
public String uploadImgFile(String prefix, String filename,InputStream inputStream) {
String filePath = builderFilePath(prefix, filename);
try {
PutObjectArgs putObjectArgs = PutObjectArgs.builder()
.object(filePath)
.contentType("image/jpg")
.bucket(minIOConfigProperties.getBucket()).stream(inputStream,inputStream.available(),-1)
.build();
minioClient.putObject(putObjectArgs);
StringBuilder urlPath = new StringBuilder(minIOConfigProperties.getReadPath());
urlPath.append(separator+minIOConfigProperties.getBucket());
urlPath.append(separator);
urlPath.append(filePath);
return urlPath.toString();
}catch (Exception ex){
log.error("minio put file error.",ex);
throw new RuntimeException("上传文件失败");
}
}

/**
* 上传html文件
* @param prefix 文件前缀
* @param filename 文件名
* @param inputStream 文件流
* @return 文件全路径
*/
@Override
public String uploadHtmlFile(String prefix, String filename,InputStream inputStream) {
String filePath = builderFilePath(prefix, filename);
try {
PutObjectArgs putObjectArgs = PutObjectArgs.builder()
.object(filePath)
.contentType("text/html")
.bucket(minIOConfigProperties.getBucket()).stream(inputStream,inputStream.available(),-1)
.build();
minioClient.putObject(putObjectArgs);
StringBuilder urlPath = new StringBuilder(minIOConfigProperties.getReadPath());
urlPath.append(separator+minIOConfigProperties.getBucket());
urlPath.append(separator);
urlPath.append(filePath);
return urlPath.toString();
}catch (Exception ex){
log.error("minio put file error.",ex);
ex.printStackTrace();
throw new RuntimeException("上传文件失败");
}
}

/**
* 删除文件
* @param pathUrl 文件全路径
*/
@Override
public void delete(String pathUrl) {
String key = pathUrl.replace(minIOConfigProperties.getEndpoint()+"/","");
int index = key.indexOf(separator);
String bucket = key.substring(0,index);
String filePath = key.substring(index+1);
// 删除Objects
RemoveObjectArgs removeObjectArgs = RemoveObjectArgs.builder().bucket(bucket).object(filePath).build();
try {
minioClient.removeObject(removeObjectArgs);
} catch (Exception e) {
log.error("minio remove file error. pathUrl:{}",pathUrl);
e.printStackTrace();
}
}


/**
* 下载文件
* @param pathUrl 文件全路径
* @return 文件流
*
*/
@Override
public byte[] downLoadFile(String pathUrl) {
String key = pathUrl.replace(minIOConfigProperties.getEndpoint()+"/","");
int index = key.indexOf(separator);
String bucket = key.substring(0,index);
String filePath = key.substring(index+1);
InputStream inputStream = null;
try {
inputStream = minioClient.getObject(GetObjectArgs.builder().bucket(minIOConfigProperties.getBucket()).object(filePath).build());
} catch (Exception e) {
log.error("minio down file error. pathUrl:{}",pathUrl);
e.printStackTrace();
}

ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
byte[] buff = new byte[100];
int rc = 0;
while (true) {
try {
if (!((rc = inputStream.read(buff, 0, 100)) > 0)) break;
} catch (IOException e) {
e.printStackTrace();
}
byteArrayOutputStream.write(buff, 0, rc);
}
return byteArrayOutputStream.toByteArray();
}
}

在项目中集成:

  • 导入文件管理依赖,在 pom.xml 文件下

    1
    2
    3
    4
    5
    <dependency>
    <groupId>com.heima</groupId>
    <artifactId>heima-file-starter</artifactId>
    <version>1.0-SNAPSHOT</version>
    </dependency>
  • 添加minio配置文件,在 resources下面创建 application.yml

    1
    2
    3
    4
    5
    6
    minio:
    accessKey: minio
    secretKey: minio123
    bucket: leadnews
    endpoint: http://192.168.88.129:9000
    readPath: http://192.168.88.129:9000
  • 在spring管理的bean中注入FileStorageService

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    @SpringBootTest(classes = MinIOApplication.class)
    @RunWith(SpringRunner.class)
    public class MinIOTest {
    /**
    * 将list.html上传到minio中,并且可以在浏览器中访问
    *
    * @param args
    */
    @Autowired
    private FileStorageService fileStorageService;

    @Test
    public void test() throws FileNotFoundException {

    FileInputStream fileInputStream = new FileInputStream("D:\\list.html");

    String path = fileStorageService.uploadHtmlFile("", "list.html", fileInputStream);

    System.out.println(path);
    }
    }

文章详情页的展示

实现步骤:

  • 在article微服务中添加MinIO和freemarker的支持

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <dependencies>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-freemarker</artifactId>
    </dependency>
    <!--集成了minio的配置文件-->
    <dependency>
    <groupId>com.heima</groupId>
    <artifactId>heima-file-starter</artifactId>
    <version>1.0-SNAPSHOT</version>
    </dependency>
  • 在nacos的leadnews-article中在后面添加配置信息

    1
    2
    3
    4
    5
    6
    minio:
    accessKey: minio
    secretKey: minio123
    bucket: leadnews
    endpoint: http://192.168.88.129:9000
    readPath: http://192.168.88.129:9000
  • 将模板文件(article.ftl)拷贝到article微服务下

    实现结果

  • 将 index.js 和 index.css 两个文件手动上传到MinIO中(为了加快文件的响应速度,在js文件夹中上传了axios.min.js文件)

    css与js依赖

  • 新建 ApArticleContentMapper

    1
    2
    public interface ApArticleContentMapper extends BaseMapper<ApArticleContent> {
    }
  • 在 artile 微服务中新增 测试类(暂时手动生成详情静态页)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    @SpringBootTest(classes = ArticleApplication.class)
    @RunWith(SpringRunner.class)
    public class ArticleFreemarkerTest {

    @Autowired
    private ApArticleContentMapper apArticleContentMapper;

    @Autowired
    private Configuration configuration;

    @Autowired
    private FileStorageService fileStorageService;

    @Autowired
    private ApArticleService apArticleService;

    @Test
    public void createStaticUrlTest() throws Exception {
    // 已知文章id
    // 1. 获取文章内容
    ApArticleContent apArticleContent = apArticleContentMapper
    .selectOne(Wrappers.<ApArticleContent>lambdaQuery()
    .eq(ApArticleContent::getArticleId, "1383827787629252610L"));

    if (apArticleContent != null && StringUtils.isNotBlank(apArticleContent.getContent())) {

    // 2. 文章内容通过freemarker生成html文件
    Template template = configuration.getTemplate("article.ftl");

    // 数据模型
    Map<String, Object> content = new HashMap<>();

    content.put("content", JSONArray.parseArray(apArticleContent.getContent()));

    StringWriter out = new StringWriter();

    // 合成
    template.process(content, out);

    // 3. 把html文件上传到minio中
    InputStream in = new ByteArrayInputStream(out.toString().getBytes());
    String path = fileStorageService.uploadHtmlFile("", apArticleContent.getArticleId() + ".html", in);

    // 4. 修改ap_article表,保存static_url字段
    apArticleService.update(Wrappers.<ApArticle>lambdaUpdate().eq(ApArticle::getId, apArticleContent.getArticleId())
    .set(ApArticle::getStaticUrl, path));
    }
    }
    }

自媒体 文章发布


自媒体后台搭建


后台搭建

自媒体后台搭建流程:

  1. 基础环境 和 数据准备
    • sql 脚本
    • 拷贝对应的实体类到heima-leadnews-model模块下的com.heima.model
  2. 在heima-leadnews-service下指定初始子模块
  3. 添加对应的 nacos 配置
  4. 在heima-leadnews-gateway工程下指定初始子模块
  5. 添加对应的 nacos 配置
  6. 启动 gateway 与service 的SpringApplication的启动类,等待前端搭建完联调

自媒体前端搭建


通过nginx的虚拟主机功能,使用同一个nginx访问多个项目

自媒体前端搭建

自媒体前端搭建流程:

  1. 在 nginx 中 leadnews.conf 目录中新增 heima-leadnews-wemedia.conf 文件
  2. 网关地址修改(localhost:51602)
  3. 前端项目目录修改(wemedia-web解压的目录)
  4. 访问端口修改(8802)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
upstream  heima-wemedia-gateway{
server localhost:51602;
}

server {
listen 8802;
location / {
root D:/BaiduNetdiskDownload/todaynews/wemedia-web/;
index index.html;
}

location ~/wemedia/MEDIA/(.*) {
proxy_pass http://heima-wemedia-gateway/$1;
proxy_set_header HOST $host; # 不改变源请求头的值
proxy_pass_request_body on; #开启获取请求体
proxy_pass_request_headers on; #开启获取请求头
proxy_set_header X-Real-IP $remote_addr; # 记录真实发出请求的客户端IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; #记录代理信息
}
}
  1. 启动nginx,访问 http://localhost:8802

    前端展示


自媒体素材管理


  • 需求分析

    • 图片上传的页面,首先是展示素材信息,可以点击图片上传,弹窗后可以上传图片
  • 表结构分析

    • wm_material 媒体图文素材信息表

      wm_material

  • 创建对应的实体类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    /**
    * <p>
    * 自媒体图文素材信息表
    * </p>
    *
    * @author itheima
    */
    @Data
    @TableName("wm_material")
    public class WmMaterial implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
    * 主键
    */
    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;

    /**
    * 自媒体用户ID
    */
    @TableField("user_id")
    private Integer userId;

    /**
    * 图片地址
    */
    @TableField("url")
    private String url;

    /**
    * 素材类型
    0 图片
    1 视频
    */
    @TableField("type")
    private Short type;

    /**
    * 是否收藏
    */
    @TableField("is_collection")
    private Short isCollection;

    /**
    * 创建时间
    */
    @TableField("created_time")
    private Date createdTime;

    }
  • 实现流程

    实现思路

    1. token解析为用户存入header,在com.heima.wemedia.gateway.filter.AuthorizeFilter.java文件中

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      @Component
      @Slf4j
      public class AuthorizeFilter implements Ordered, GlobalFilter {
      @Override
      public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
      //1.获取request和response对象
      ServerHttpRequest request = exchange.getRequest();
      ServerHttpResponse response = exchange.getResponse();

      //2.判断是否是登录
      if (request.getURI().getPath().contains("/login")) {
      //放行
      return chain.filter(exchange);
      }

      //3.获取token
      String token = request.getHeaders().getFirst("token");

      //4.判断token是否存在
      if (StringUtils.isBlank(token)) {
      response.setStatusCode(HttpStatus.UNAUTHORIZED);
      return response.setComplete();
      }


      //5.判断token是否有效
      try {
      Claims claimsBody = AppJwtUtil.getClaimsBody(token);
      //是否是过期
      int result = AppJwtUtil.verifyToken(claimsBody);
      if (result == 1 || result == 2) {
      response.setStatusCode(HttpStatus.UNAUTHORIZED);
      return response.setComplete();
      }
      // 获取用户信息
      Object userId = claimsBody.get("id");

      // 存入到header中
      request.mutate().headers(httpHeaders -> {
      httpHeaders.add("userId", userId + "");
      }).build();

      // 重置请求
      exchange.mutate().request(request);

      } catch (Exception e) {
      e.printStackTrace();
      }


      //6.放行
      return chain.filter(exchange);
      }

      /**
      * 优先级设置 值越小 优先级越高
      *
      * @return
      */
      @Override
      public int getOrder() {
      return 0;
      }
      }
    2. 创建拦截器,从header中获取用户存入当前线程

      • 先在 com.heima.utils.thread.WmThreadLocalUtil.java下创建一个可以创建线程,获取线程以及清楚线程的工具类

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        public class WmThreadLocalUtil {

        private final static ThreadLocal<WmUser> WM_USER_THREAD_LOCAL = new ThreadLocal<>();

        // 存入线程
        public static void setUser(WmUser wmUser) {
        WM_USER_THREAD_LOCAL.set(wmUser);
        }

        // 从线程中获取
        public static WmUser getUser() {
        return WM_USER_THREAD_LOCAL.get();

        }
        // 清理
        public static void clear() {
        WM_USER_THREAD_LOCAL.remove();
        }
        }
      • 在com.heima.wemedia.interceptor.WmTokenInterceptor.java文件下定义拦截器

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        public class WmTokenInterceptor implements HandlerInterceptor {

        /**
        * 得到header中的用户信息,并且存入到当前线程中
        *
        * @param request
        * @param response
        * @param handler
        * @return
        * @throws Exception
        */

        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String userId = request.getHeader("userId");
        if (userId != null) {

        // 存入到当前线程中
        WmUser wmUser = new WmUser();
        wmUser.setId(Integer.valueOf(userId));
        WmThreadLocalUtil.setUser(wmUser);

        }

        return true;
        }

        /**
        * 清理线程中的数据
        *
        * @param request
        * @param response
        * @param handler
        * @param modelAndView
        * @throws Exception
        */
        @Override
        public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        WmThreadLocalUtil.clear();
        }
        }
      • 在com.heima.wemedia.config.WebMvcConfig.java文件下配置拦截器生效,拦截所有的请求

        1
        2
        3
        4
        5
        6
        7
        @Configuration
        public class WebMvcConfig implements WebMvcConfigurer {
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new WmTokenInterceptor()).addPathPatterns("/**");
        }
        }
    3. 上传图片

      其中MutlipartFile是SpringMVC默认的文件接收类型

      • 在com.heima.wemedia.controller.v1.WmMaterialController.java文件下定义接口

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        @RestController
        @RequestMapping("/api/v1/material")
        public class WmMaterialController {


        @PostMapping("/upload_picture")
        public ResponseResult uploadPicture(MultipartFile multipartFile){
        return null;
        }
        }
      • 在com.heima.wemedia.mapper.WmMaterialMapper.java中创建对应的mapper文件

        1
        2
        3
        @Mapper
        public interface WmMaterialMapper extends BaseMapper<WmMaterial> {
        }
      • 在com.heima.wemedia.service.WmMaterialService.java中创建业务层的接口类,并定义对应要实现的接口

        1
        2
        3
        4
        5
        6
        7
        8
        9
        public interface WmMaterialService extends IService<WmMaterial> {

        /**
        * 图片上传
        * @param multipartFile
        * @return
        */
        public ResponseResult uploadPicture(MultipartFile multipartFile);
        }
      • 在com.heima.wemedia.service.impl.WmMaterialServiceImpl.java中完成对应业务成的实现类

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55
        56
        57
        58
        @Slf4j
        @Service
        @Transactional
        public class WmMaterialServiceImpl extends ServiceImpl<WmMaterialMapper, WmMaterial> implements WmMaterialService {

        @Autowired
        private FileStorageService fileStorageService;

        /**
        * 图片上传
        *
        * @param multipartFile
        * @return
        */
        @Override
        public ResponseResult uploadPicture(MultipartFile multipartFile) {

        // 1. 检查参数
        if (multipartFile == null || multipartFile.getSize() == 0) {
        return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
        }

        // 2. 上传图片到MinIO中

        // 保证生成的图片不重复,可以用uuid
        String fileName = UUID.randomUUID().toString().replace("-", "");

        // 比如 originalFilename 获取的名字是 aa.jpg
        String originalFilename = multipartFile.getOriginalFilename();
        // 截取 . 的后半部分,比如:.jpg
        String postfix = originalFilename.substring(originalFilename.lastIndexOf("."));

        String fileId = null;

        try {
        fileId = fileStorageService
        .uploadImgFile("", fileName + postfix, multipartFile.getInputStream());
        log.info("上传图片到MinIO中,fileId:{}", fileId); // 打印上传成功的日志
        } catch (IOException e) {
        e.printStackTrace();
        log.error("WmMaterialServiceImpl-上传图片失败"); // 打印上传失败的日志
        }


        // 3. 把图片保存到数据库中
        WmMaterial wmMaterial = new WmMaterial();
        wmMaterial.setUserId(WmThreadLocalUtil.getUser().getId());
        wmMaterial.setUrl(fileId);
        wmMaterial.setIsCollection((short) 0); // 是否收藏 0 不收藏 1 收藏
        wmMaterial.setType((short) 0); // 0 图片 1 视频
        wmMaterial.setCreatedTime(new Date());
        save(wmMaterial);

        // 4. 返回结果

        return ResponseResult.okResult(wmMaterial);
        }
        }
      • 上面的实现类用到了MinIO,所以要分别在业务层的pom.xml中填入对应的依赖以及在nacos中添加对应的配置信息

        1
        2
        3
        4
        5
        6
        7
        <dependencies>
        <dependency>
        <groupId>com.heima</groupId>
        <artifactId>heima-file-starter</artifactId>
        <version>1.0-SNAPSHOT</version>
        </dependency>
        </dependencies>
        1
        2
        3
        4
        5
        6
        minio:
        accessKey: minio
        secretKey: minio123
        bucket: leadnews
        endpoint: http://192.168.88.129:9000
        readPath: http://192.168.88.129:9000
      • 完成com.heima.wemedia.controller.v1.WmMaterialController.java

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        @RestController
        @RequestMapping("/api/v1/material")
        public class WmMaterialController {

        @Autowired
        private WmMaterialService wmMaterialService;

        @PostMapping("/upload_picture")
        public ResponseResult uploadPicture(MultipartFile multipartFile) {
        return wmMaterialService.uploadPicture(multipartFile);
        }
        }
    4. 图片列表

      图片列表接口

      • 在com.heima.wemedia.controller.v1.WmMaterialController.java文件下定义接口

        1
        2
        3
        @PostMapping("/list")
        public ResponseResult findList(@RequestBody WmMaterialDto dto) {
        }
      • 在com.heima.model.wemedia.dtos.WmMaterialDto下创建这个接口需要的DTO

        1
        2
        3
        4
        5
        6
        7
        8
        @Data
        public class WmMaterialDto extends PageRequestDto {
        /**
        * 1 收藏
        * 0 未收藏
        */
        private Short isCollection;
        }
      • 在com.heima.wemedia.service.WmMaterialService.java文件下创建业务层的接口类

        1
        2
        3
        4
        5
        6
        /**
        * 素材列表查询
        * @param dto
        * @return
        */
        public ResponseResult findList(WmMaterialDto dto);
      • 在com.heima.wemedia.service.impl.WmMaterialServiceImpl.java文件下来实现业务成对应的接口

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        /**
        * 素材列表查询
        *
        * @param dto
        * @return
        */
        @Override
        public ResponseResult findList(WmMaterialDto dto) {
        // 1. 检查参数
        dto.checkParam();
        /**
        public void checkParam() {
        if (this.page == null || this.page < 0) {
        setPage(1);
        }
        if (this.size == null || this.size < 0 || this.size > 100) {
        setSize(10);
        }
        }
        */

        // 2. 分页查询
        IPage page = new Page(dto.getPage(), dto.getSize());
        LambdaQueryWrapper<WmMaterial> lambdaQueryWrapper = new LambdaQueryWrapper<>();
        // 是否收藏
        if (dto.getIsCollection() != null && dto.getIsCollection() == 1) {
        lambdaQueryWrapper.eq(WmMaterial::getIsCollection, dto.getIsCollection());
        }
        // 按照用户查询
        lambdaQueryWrapper.eq(WmMaterial::getUserId,WmThreadLocalUtil.getUser().getId());
        // 按照时间倒叙查询
        lambdaQueryWrapper.orderByDesc(WmMaterial::getCreatedTime);
        page(page, lambdaQueryWrapper);

        // 3. 结果返回
        ResponseResult responseResult = new PageResponseResult(dto.getPage(), dto.getSize(), (int)page.getTotal());
        responseResult.setData(page.getRecords());
        return responseResult;
        }
      • 把com.heima.wemedia.controller.v1.WmMaterialController.java文件补全

        1
        2
        3
        4
        @PostMapping("/list")
        public ResponseResult findList(@RequestBody WmMaterialDto dto) {
        return wmMaterialService.findList(dto);
        }
      • 在启动类加上mybatis-plus的分页插件

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        @SpringBootApplication
        @EnableDiscoveryClient
        @MapperScan("com.heima.wemedia.mapper")
        public class WemediaApplication {

        public static void main(String[] args) {
        SpringApplication.run(WemediaApplication.class, args);
        }

        @Bean
        // Mybatis-Plus的分页插件
        public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
        }


自媒体文章管理


频道列表查询


  1. 表结构分析

    • wm_channel 频道信息表

      wm_channel

  2. 创建对应数据库表的实体类 wm_channel

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    @Data
    @TableName("wm_channel")
    public class WmChannel implements Serializable {

    private static final long serialVersionUID = 1L;

    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;

    /**
    * 频道名称
    */
    @TableField("name")
    private String name;

    /**
    * 频道描述
    */
    @TableField("description")
    private String description;

    /**
    * 是否默认频道
    * 1:默认 true
    * 0:非默认 false
    */
    @TableField("is_default")
    private Boolean isDefault;

    /**
    * 是否启用
    * 1:启用 true
    * 0:禁用 false
    */
    @TableField("status")
    private Boolean status;

    /**
    * 默认排序
    */
    @TableField("ord")
    private Integer ord;

    /**
    * 创建时间
    */
    @TableField("created_time")
    private Date createdTime;

    }
  3. 频道列表查询对应的接口

    频道列表查询接口

    • 接口定义

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      @RestController
      @RequestMapping("/api/v1/channel")
      public class WmchannelController {


      @GetMapping("/channels")
      public ResponseResult findAll(){
      return null;
      }
      }
    • mapper

      1
      2
      3
      @Mapper
      public interface WmChannelMapper extends BaseMapper<WmChannel> {
      }
    • service

      1
      2
      3
      4
      5
      6
      7
      8
      public interface WmChannelService extends IService<WmChannel> {
      /**
      * 查询所有频道
      * @return
      */
      public ResponseResult findAll();

      }
    • 实现类

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      @Service
      @Transactional
      @Slf4j
      public class WmChannelServiceImpl extends ServiceImpl<WmChannelMapper, WmChannel> implements WmChannelService {


      /**
      * 查询所有频道
      *
      * @return
      */
      @Override
      public ResponseResult findAll() {
      return ResponseResult.okResult(list());
      // list()是mybatis-plus中的方法
      }
      }
    • 补全controller

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      @RestController
      @RequestMapping("/api/v1/channel")
      public class WmChannelController {

      @Autowired
      private WmChannelService wmChannelService;

      @GetMapping("/channels")
      public ResponseResult findAll() {
      return wmChannelService.findAll();
      }
      }

文章列表加载


  1. 表结构分析

    • wm_news 自媒体文章表

      wm_news

  2. 创建对应数据库表的实体类 wm_news

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    @Data
    @TableName("wm_news")
    public class WmNews implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
    * 主键
    */
    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;

    /**
    * 自媒体用户ID
    */
    @TableField("user_id")
    private Integer userId;

    /**
    * 标题
    */
    @TableField("title")
    private String title;

    /**
    * 图文内容
    */
    @TableField("content")
    private String content;

    /**
    * 文章布局
    0 无图文章
    1 单图文章
    3 多图文章
    */
    @TableField("type")
    private Short type;

    /**
    * 图文频道ID
    */
    @TableField("channel_id")
    private Integer channelId;

    @TableField("labels")
    private String labels;

    /**
    * 创建时间
    */
    @TableField("created_time")
    private Date createdTime;

    /**
    * 提交时间
    */
    @TableField("submited_time")
    private Date submitedTime;

    /**
    * 当前状态
    0 草稿
    1 提交(待审核)
    2 审核失败
    3 人工审核
    4 人工审核通过
    8 审核通过(待发布)
    9 已发布
    */
    @TableField("status")
    private Short status;

    /**
    * 定时发布时间,不定时则为空
    */
    @TableField("publish_time")
    private Date publishTime;

    /**
    * 拒绝理由
    */
    @TableField("reason")
    private String reason;

    /**
    * 发布库文章ID
    */
    @TableField("article_id")
    private Long articleId;

    /**
    * //图片用逗号分隔
    */
    @TableField("images")
    private String images;

    @TableField("enable")
    private Short enable;

    //状态枚举类
    @Alias("WmNewsStatus")
    public enum Status{
    NORMAL((short)0),SUBMIT((short)1),FAIL((short)2),ADMIN_AUTH((short)3),ADMIN_SUCCESS((short)4),SUCCESS((short)8),PUBLISHED((short)9);
    short code;
    Status(short code){
    this.code = code;
    }
    public short getCode(){
    return this.code;
    }
    }
    }
  3. 接口定义

    自媒体文件列表接口

    • 接口定义

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      @RestController
      @RequestMapping("/api/v1/news")
      public class WmNewsController {

      // 带参数查询一般都用 @PostMapping
      @PostMapping("/list")
      public ResponseResult findList(@RequestBody WmNewsPageReqDto dto) {
      return null;
      }
      }
    • 定义 WmNewsPageReqDto 类

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      @Data
      public class WmNewsPageReqDto extends PageRequestDto {

      /**
      * 状态
      */
      private Short status;
      /**
      * 开始时间
      */
      private Date beginPubDate;
      /**
      * 结束时间
      */
      private Date endPubDate;
      /**
      * 所属频道ID
      */
      private Integer channelId;
      /**
      * 关键字
      */
      private String keyword;
      }
    • mapper

      1
      2
      3
      4
      @Mapper
      public interface WmNewsMapper extends BaseMapper<WmNews> {

      }
    • service

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      public interface WmNewsService extends IService<WmNews> {

      /**
      * 条件查询文章列表
      * @param dto
      * @return
      */
      public ResponseResult findList(WmNewsPageReqDto dto);

      }
    • 实现类

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      @Service
      @Slf4j
      @Transactional
      public class WmNewsServiceImpl extends ServiceImpl<WmNewsMapper, WmNews> implements WmNewsService {


      /**
      * 条件查询文章列表
      *
      * @param dto
      * @return
      */
      @Override
      public ResponseResult findList(WmNewsPageReqDto dto) {
      // 1. 检查参数
      dto.checkParam();

      // 2. 分页的条件查询
      IPage page = new Page(dto.getPage(), dto.getSize());
      LambdaQueryWrapper<WmNews> lambdaQueryWrapper = new LambdaQueryWrapper();

      // 状态精确查询
      if (dto.getStatus() != null) {
      lambdaQueryWrapper.eq(WmNews::getStatus, dto.getStatus());
      }

      // 频道精确查询
      if (dto.getChannelId() != null) {
      lambdaQueryWrapper.eq(WmNews::getChannelId, dto.getChannelId());
      }

      // 时间范围查询
      if (dto.getBeginPubDate() != null && dto.getEndPubDate() != null) {
      lambdaQueryWrapper.between(WmNews::getSubmitedTime, dto.getBeginPubDate(), dto.getEndPubDate());
      }

      // 关键字的模糊查询
      if (StringUtils.isNotBlank(dto.getKeyword())) {
      lambdaQueryWrapper.like(WmNews::getTitle, dto.getKeyword());
      }

      // 查询当前登录人的文章
      lambdaQueryWrapper.eq(WmNews::getUserId, WmThreadLocalUtil.getUser().getId());

      // 按照发布时间倒叙查询
      lambdaQueryWrapper.orderByDesc(WmNews::getPublishTime);

      page(page, lambdaQueryWrapper);

      // 3. 结果返回
      ResponseResult responseResult = new PageResponseResult(dto.getPage(), dto.getSize(), (int) page.getTotal());
      responseResult.setData(page.getRecords());


      return responseResult;
      }
      }
    • 补全controller

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      @RestController
      @RequestMapping("/api/v1/news")
      public class WmNewsController {

      @Autowired
      private WmNewsService wmNewsService;

      // 带参数查询一般都用 @PostMapping
      @PostMapping("/list")
      public ResponseResult findList(@RequestBody WmNewsPageReqDto dto) {
      return wmNewsService.findList(dto);
      }
      }

文章发布、修改、保存草稿


  1. 需求分析

    需求分析

  2. 表结构的分析

    • wm_material 素材表

      wm_material

    • wm_news_material 文章素材关系表

      wm_news_material

    • 保存文章,除了需要 wm_news 表以外,还需要 wm_material 素材表、wm_news_material 文章素材关系表

      三张表之间的关系

    • 文章素材关系表

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      @Data
      @TableName("wm_news_material")
      public class WmNewsMaterial implements Serializable {

      private static final long serialVersionUID = 1L;

      /**
      * 主键
      */
      @TableId(value = "id", type = IdType.AUTO)
      private Integer id;

      /**
      * 素材ID
      */
      @TableField("material_id")
      private Integer materialId;

      /**
      * 图文ID
      */
      @TableField("news_id")
      private Integer newsId;

      /**
      * 引用类型
      0 内容引用
      1 主图引用
      */
      @TableField("type")
      private Short type;

      /**
      * 引用排序
      */
      @TableField("ord")
      private Short ord;

      }
  3. 实现流程

    实现思路

  4. 接口定义

    发布文章接口

    WmNewsDto的Json字符串

  5. WmNewsDto的DTO对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    @Data
    public class WmNewsDto {

    private Integer id;
    /**
    * 标题
    */
    private String title;
    /**
    * 频道id
    */
    private Integer channelId;
    /**
    * 标签
    */
    private String labels;
    /**
    * 发布时间
    */
    private Date publishTime;
    /**
    * 文章内容
    */
    private String content;
    /**
    * 文章封面类型 0 无图 1 单图 3 多图 -1 自动
    */
    private Short type;
    /**
    * 提交时间
    */
    private Date submitedTime;
    /**
    * 状态 提交为1 草稿为0
    */
    private Short status;

    /**
    * 封面图片列表 多张图以逗号隔开
    */
    private List<String> images;
    }
  6. 接口定义

    1
    2
    3
    4
    @PostMapping("/submit")
    public ResponseResult submitNews(@RequestBody WmNewsDto wmNewsDto) {
    return null;
    }
  7. 新增 WmNewsMaterialMapper 类,文章与素材的关联关系需要批量保存,索引需要定义mapper文件和对应的映射文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Mapper
    public interface WmNewsMaterialMapper extends BaseMapper<WmNewsMaterial> {
    /**
    * 批量保存文章的方法
    * @param materialIds
    * @param newsId
    * @param type
    */
    void saveRelations(@Param("materialIds") List<Integer> materialIds, @Param("newsId") Integer newsId, @Param("type") Short type);
    }
  8. resources目录下的 WmNewsMaterialMapper.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="com.heima.wemedia.mapper.WmNewsMaterialMapper">
    <insert id="saveRelations">
    insert into wm_news_material (material_id,news_id,type,ord)
    values
    <foreach collection="materialIds" index="ord" item="mid" separator=",">
    (#{mid},#{newsId},#{type},#{ord})
    </foreach>
    </insert>
    </mapper>
  9. 常量类准备

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class WemediaConstants {

    public static final Short COLLECT_MATERIAL = 1;//收藏

    public static final Short CANCEL_COLLECT_MATERIAL = 0;//取消收藏

    public static final String WM_NEWS_TYPE_IMAGE = "image";

    public static final Short WM_NEWS_NONE_IMAGE = 0;
    public static final Short WM_NEWS_SINGLE_IMAGE = 1;
    public static final Short WM_NEWS_MANY_IMAGE = 3;
    public static final Short WM_NEWS_TYPE_AUTO = -1;

    public static final Short WM_CONTENT_REFERENCE = 0;
    public static final Short WM_COVER_REFERENCE = 1;
    }
  10. 在 WmNewsService 中新增方法

    1
    2
    3
    4
    5
    6
    /**
    * 发布修改文章、保存文章为草稿
    * @param wmNewsDto
    * @return
    */
    public ResponseResult submitNews(WmNewsDto wmNewsDto);
  11. 对应的实现类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    /**
    * 发布修改文章、保存文章为草稿
    *
    * @param wmNewsDto
    * @return
    */
    @Override
    public ResponseResult submitNews(WmNewsDto wmNewsDto) {
    // 0. 条件判断
    if (wmNewsDto == null || wmNewsDto.getContent() == null) {
    return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
    }
    // 1. 保存或修改文章
    WmNews wmNews = new WmNews();
    // 属性拷贝,属性名称和类型相同时才会拷贝
    BeanUtils.copyProperties(wmNewsDto, wmNews);
    // 封面图片 list ---> string
    if (wmNewsDto.getImages() != null && wmNewsDto.getImages().size() > 0) {
    // [wqqqdqd.jpg,oiqw.jpg] --> wqqqdqd.jpg,oiqw.jpg
    String imageStr = StringUtils.join(wmNewsDto.getImages(), ",");
    wmNews.setImages(imageStr);
    }
    // 如果当前封面类型为自动 -1
    if (wmNewsDto.getType().equals(WemediaConstants.WM_NEWS_TYPE_AUTO)) {
    wmNews.setType(null);
    }
    saveOrUpdateWmNews(wmNews);
    // 2. 判断是否为草稿,如果为草稿结束当前方法
    if (wmNewsDto.getStatus().equals(WmNews.Status.NORMAL.getCode())) {
    return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
    }
    // 3. 不是草稿,保存文章内容、图片与素材的关系
    // 获取文章内容中的图片信息
    List<String> materials = extractUrlInfo(wmNewsDto.getContent());
    saveRelativeInfoForContent(materials, wmNews.getId());
    // 4. 不是草稿,保存文章封面图片与素材的关系
    // 如果当前布局是自动,需要匹配封面图片
    saveRelativeInfoForCover(wmNewsDto, wmNews, materials);
    return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
    }
    /**
    * 第一个功能,如果当前封面类型为自动,则设置封面类型的数据
    * 1,如果内容图片大于等于1,小于3 单图 type 1
    * 2,如果内容图片大于等于3 多图 type 3
    * 3,如果内容没有图片,无图 type 0
    * <p>
    * 第二个功能:保存封面图片与素材的关系
    *
    * @param wmNewsDto
    * @param wmNews
    * @param materials
    */
    private void saveRelativeInfoForCover(WmNewsDto wmNewsDto, WmNews wmNews, List<String> materials) {
    List<String> images = wmNewsDto.getImages();
    // 如果当前封面类型为自动,则设置封面类型的数据
    if (wmNewsDto.getType().equals(WemediaConstants.WM_NEWS_TYPE_AUTO)) {
    // 多图
    if (materials.size() >= 3) {
    wmNews.setType(WemediaConstants.WM_NEWS_MANY_IMAGE);
    images = materials.stream().limit(3).collect(Collectors.toList());
    }
    // 单图
    else if (materials.size() > 1 && materials.size() < 3) {
    wmNews.setType(WemediaConstants.WM_NEWS_SINGLE_IMAGE);
    images = materials.stream().limit(1).collect(Collectors.toList());
    }
    // 无图
    else {
    wmNews.setType(WemediaConstants.WM_NEWS_NONE_IMAGE);
    }
    // 修改文章
    if (images != null && images.size() > 0) {
    wmNews.setImages(StringUtils.join(images, ","));
    }
    updateById(wmNews);
    }
    // 保存封面图片与素材的关系
    if (images != null && images.size() > 0) {
    saveRelativeInfo(images, wmNews.getId(), WemediaConstants.WM_COVER_REFERENCE);
    }
    }
    /**
    * 处理文章内容图片与素材的关系
    *
    * @param materials
    * @param newsId
    */
    private void saveRelativeInfoForContent(List<String> materials, Integer newsId) {
    // WemediaConstants.WM_CONTENT_REFERENCE 为 0
    saveRelativeInfo(materials, newsId, WemediaConstants.WM_CONTENT_REFERENCE);
    }
    @Autowired
    private WmNewsMaterialMapper wmNewsMaterialMapper;
    @Autowired
    private WmMaterialMapper wmMaterialMapper;
    /**
    * 保存文章图片与素材的关系到数据库中
    *
    * @param materials
    * @param newsId
    * @param wmContentReference
    */
    private void saveRelativeInfo(List<String> materials, Integer newsId, Short wmContentReference) {
    if (materials != null && !materials.isEmpty()) {
    // 通过图片的url查询素材的id
    List<WmMaterial> dbMaterials = wmMaterialMapper
    .selectList(Wrappers.<WmMaterial>lambdaQuery().in(WmMaterial::getUrl, materials));
    // 判断素材是否有效
    if (dbMaterials == null || dbMaterials.size() == 0) {
    // 手动抛出异常
    // 第一个功能:能够提示调用者素材失效了
    // 第二个功能:进行数据的回滚
    throw new CustomException(AppHttpCodeEnum.MATERIAL_REFERENCE_FAIL);
    }
    if (materials.size() != dbMaterials.size()) {
    throw new CustomException(AppHttpCodeEnum.MATERIAL_REFERENCE_FAIL);
    }
    List<Integer> idList = dbMaterials.stream().map(WmMaterial::getId).collect(Collectors.toList());
    // 批量保存
    wmNewsMaterialMapper.saveRelations(idList, newsId, wmContentReference);
    }
    }
    /**
    * 提取文章内容中的图片信息
    *
    * @param content
    * @return
    */
    private List<String> extractUrlInfo(String content) {
    List<String> materials = new ArrayList<>();
    // 将Json字符串转换成Map格式
    List<Map> maps = JSON.parseArray(content, Map.class);
    for (Map map : maps) {
    if (map.get("type").equals("image")) {
    String imageUrl = (String) map.get("value");
    materials.add(imageUrl);
    }
    }
    return materials;
    }
    /**
    * 保存或修改文章
    *
    * @param wmNews
    */
    private void saveOrUpdateWmNews(WmNews wmNews) {
    // 补全属性
    wmNews.setUserId(WmThreadLocalUtil.getUser().getId());
    wmNews.setCreatedTime(new Date());
    wmNews.setSubmitedTime(new Date());
    wmNews.setEnable((short) 1);
    if (wmNews.getId() == null) {
    // 保存
    save(wmNews);
    } else {
    // 修改
    // 删除文章图片与素材的关系
    wmNewsMaterialMapper
    .delete(Wrappers.<WmNewsMaterial>lambdaQuery()
    .eq(WmNewsMaterial::getNewsId, wmNews.getId()));
    updateById(wmNews);
    }
    }
  12. 补全controller

    1
    2
    3
    4
    @PostMapping("/submit")
    public ResponseResult submitNews(@RequestBody WmNewsDto dto){
    return wmNewsService.submitNews(dto);
    }

自媒体 文章审核


文章的审核

自媒体文章审核流程


内容安全第三方接口


内容安全是识别服务,支持对图片、视频、文本、语音等对象进行多样化场景检测,有效降低内容违规风险

按照性能和收费来看,本项目使用的就是阿里云的内容安全接口,使用到了图片和文本的审核

详见:开发参考_内容安全(Content Moderation)-阿里云帮助中心 (aliyun.com)


项目集成阿里云内容安全接口


流程

  1. 导入对应的依赖信息,参考接口 sdk 说明

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <!-- 在common模块下的pom文件 -->
    <dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>aliyun-java-sdk-core</artifactId>
    </dependency>
    <dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>aliyun-java-sdk-green</artifactId>
    </dependency>
  2. 拷贝资料文件夹中的类到 common模块 下面,并添加到自动配置

    • 包括了 GreenImageScan 和 GreenTextScan 及对应的工具类

    如何使用JavaSDK接口检测图片是否包含风险内容_内容安全(Content Moderation)-阿里云帮助中心 (aliyun.com)

    java文件位置

    添加到自动配置中

  3. 在 bootstarp.yml 中添加阿里云的ak

    • accessKeyId 和 secret(需自己申请)

    • 在heima-leadnews-wemedia中的nacos配置中心添加以下配置

      1
      2
      3
      4
      5
      aliyun:
      accessKeyId: XXX
      secret: XXX
      aliyun.scenes=porn,terrorism,ad,qrcode,live,logo
      scenes: terrorism
  4. 在自媒体微服务中测试类中注入审核文本和图片的bean进行测试


app端 文章保存接口实现


分布式id

产生背景:随着业务的增长,文章表可能要占用很大的物理存储空间,为了解决该问题,后期使用数据库分片技术。将一个数据库进行拆分,通过数据库中间件连接。如果数据库中该表选用ID自增策略,则 可能产生重复的ID ,此时应该使用 分布式ID生成策略 来生成ID。

分布式id图示

分布式id 的解决方案:

分布式id - 技术选型


雪花算法

snowflake 是 Twitter开源 的 分布式ID生成算法,结果是一个 long型 的 ID

其 核心思想 是:使用 41bit 作为 毫秒数,10bit 作为 机器的ID( 5个bit 是 数据中心 (可以理解为 机房的id), 5个bit 的 机器ID (可以理解为 机器的id)), 12bit 作为毫秒内的 流水号(意味着每个节点在每毫秒可以产生 4096 个 ID),最后还有一个符号位,永远是0

雪花算法

mybatis-plus 已经集成了雪花算法,完成以下两步即可在项目中集成雪花算法:

  1. 在实体类中的id上加入如下配置,指定类型为 id_worker

    1
    2
    @TableId(value = "id",type = IdType.ID_WORKER)
    private Long id;
  2. 在 application.yml 文件中配置 数据中心id 和 机器id

    1
    2
    3
    4
    5
    6
    7
    mybatis-plus:
    mapper-locations: classpath*:mapper/*.xml
    # 设置别名包扫描路径,通过该属性可以给包中的类注册别名
    type-aliases-package: com.heima.model.article.pojos
    global-config:
    datacenter-id: 1
    workerId: 1
  3. 也可以在对应的nacos中设置配置

    nacos中配置数据中心id 和 机器id


思路分析

保存app端文章思路分析

3张表的结构


保存app端文章 - feign 接口

接口说明


实现步骤

  1. 在heima-leadnews-feign-api中定义接口导入 feign远程调用依赖

    1
    2
    3
    4
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>
  2. 定义 ArticleDto

    1
    2
    3
    4
    5
    6
    7
    8
    @Data
    public class ArticleDto extends ApArticle {

    /**
    * 文章内容
    */
    private String content;
    }
  3. 定义文章端的远程接口

    1
    2
    3
    4
    5
    6
    7
    @FeignClient("leadnews-article")
    public interface IArticleClient {

    @PostMapping("/api/v1/article/save")
    // @RequestBody:通常会使用这个注解来接收客户端发送的 JSON、XML 或其他格式的数据,并将其转换为 Java 对象
    public ResponseResult saveArticle(@RequestBody ArticleDto dto);
    }
  4. 在 heima-leadnews-article 实现feign接口,在路径下创建 feign 的软件包

    1
    2
    3
    4
    5
    6
    7
    8
    @RestController
    public class ArticleClient implements IArticleClient {
    @PostMapping("/api/v1/article/save")
    @Override
    public ResponseResult saveArticle(@RequestBody ArticleDto dto) {
    return null;
    }
    }
  5. 创建 ApArticleConfigMapper 类到文章微服务mapper包下

    1
    2
    3
    4
    @Mapper
    public interface ApArticleConfigMapper extends BaseMapper<ApArticleConfig> {
    }

  6. 在 ApArticleService 中新增保存方法进行实现

    1
    2
    3
    4
    5
    6
    /**
    * 保存app端相关文章
    * @param dto
    * @return
    */
    public ResponseResult saveArticle(ArticleDto dto);
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    /**
    * 保存app端相关文章
    *
    * @param dto
    * @return
    */
    @Autowired
    private ApArticleConfigMapper apArticleConfigMapper;
    @Autowired
    private ApArticleContentMapper apArticleContentMapper;
    @Override
    public ResponseResult saveArticle(ArticleDto dto) {
    // 1. 检查参数
    if (dto == null) {
    return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
    }
    ApArticle apArticle = new ApArticle();
    BeanUtils.copyProperties(dto, apArticle);
    // 2. 判断是否存在id
    if (dto.getId() == null) {
    // 2.1 不存在id,保存 文章 文章配置 文章内容
    // 保存文章
    save(apArticle);
    // 保存配置
    /**
    * 这里重写了有参构造方法
    *
    * public ApArticleConfig(Long articleId) {
    * this.articleId = articleId;
    * this.isDelete = false;
    * this.isDown = false;
    * this.isForward = true;
    * this.isComment = true;
    * }
    */
    ApArticleConfig apArticleConfig = new ApArticleConfig(apArticle.getId());
    apArticleConfigMapper.insert(apArticleConfig);
    // 保存文章内容
    ApArticleContent apArticleContent = new ApArticleContent();
    apArticleContent.setArticleId(apArticle.getId());
    apArticleContent.setContent(dto.getContent());
    apArticleContentMapper.insert(apArticleContent);
    } else {
    // 2.2 存在id 修改 文章 文章内容
    // 修改文章
    updateById(apArticle);
    // 修改文章内容
    ApArticleContent apArticleContent = apArticleContentMapper.selectOne(Wrappers
    .<ApArticleContent>lambdaQuery().eq(ApArticleContent::getArticleId, dto.getId()));
    apArticleContent.setContent(dto.getContent());
    apArticleContentMapper.updateById(apArticleContent);
    }
    // 3. 结果返回 文章id
    return ResponseResult.okResult(apArticle.getId());

    }
  7. 补全 feign 包下的 ArticleClient.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @RestController
    public class ArticleClient implements IArticleClient {

    @Autowired
    private ApArticleService apArticleService;


    @PostMapping("/api/v1/article/save")
    @Override
    public ResponseResult saveArticle(@RequestBody ArticleDto dto) {
    return apArticleService.saveArticle(dto);
    }
    }
  8. postman进行测试


自媒体文章自动审核功能实现


表结构的说明

wm_news 自媒体文章表

  • status字段:0 草稿 1 待审核 2 审核失败 3 人工审核 4 人工审核通过 8 审核通过(待发布) 9 已发布

feign 远程接口调用方式

feign 远程接口调用方式

实现流程

  1. 在 heima-leadnews-wemedia 中的 service 新增接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public interface WmNewsAutoScanService {

    /**
    * 自媒体文章审核
    * @param id 自媒体文章id
    */
    public void autoScanWmNews(Integer id);

    }
  2. 实现类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    @Service
    @Slf4j
    @Transactional
    public class WmNewsAutoScanServiceImpl implements WmNewsAutoScanService {
    /**
    * 自媒体文章审核
    *
    * @param id 自媒体文章id
    */

    @Autowired
    private WmNewsMapper wmNewsMapper;

    @Override
    public void autoScanWmNews(Integer id) {
    // 1. 查询自媒体文章
    WmNews wmNews = wmNewsMapper.selectById(id);

    if (wmNews == null) {
    throw new RuntimeException("WmNewsAutoScanServiceImpl-文章不存在");
    }

    if (wmNews.getStatus().equals(WmNews.Status.SUBMIT.getCode())) {

    // 从内容中提取纯文本内容和图片
    Map<String, Object> textAndImages = handleTextAndImages(wmNews);

    // 2. 审核文本内容,阿里云接口
    // 传wmNews为了修改文章的状态
    boolean isTextScan = handleTextScan((String) textAndImages.get("content"), wmNews);

    if (!isTextScan) {
    return;
    }

    // 3. 审核图片 阿里云接口
    boolean isImageScan = handleImageScan((List<String>) textAndImages.get("images"), wmNews);

    if (!isImageScan) {
    return;
    }

    // 4. 审核成功,保存app端相关文章数据
    ResponseResult responseResult = saveAppArticle(wmNews);
    if (!responseResult.getCode().equals(200)) {
    throw new RuntimeException("WmNewsAutoScanServiceImpl-文章审核,保存app端相关文章数据失败");
    }

    // 回填article_id
    wmNews.setArticleId((Long) responseResult.getData());

    updateWmNews(wmNews, (short) 9, "审核成功");
    }
    }

    /**
    * 保存app端相关的文章数据
    *
    * @param wmNews
    */

    @Autowired
    private IArticleClient iArticleClient;

    @Autowired
    private WmChannelMapper wmChannelMapper;

    @Autowired
    private WmUserMapper wmUserMapper;

    private ResponseResult saveAppArticle(WmNews wmNews) {

    ArticleDto articleDto = new ArticleDto();

    // 属性的拷贝
    BeanUtils.copyProperties(wmNews, articleDto);

    // 文章的布局
    articleDto.setLayout(wmNews.getType());

    // 频道
    WmChannel wmChannel = wmChannelMapper.selectById(wmNews.getChannelId());
    if (wmChannel != null) {
    articleDto.setChannelName(wmChannel.getName());
    }

    // 作者
    articleDto.setAuthorId(wmNews.getUserId().longValue());
    WmUser wmUser = wmUserMapper.selectById(wmNews.getUserId());
    if (wmUser != null) {
    articleDto.setAuthorName(wmUser.getName());
    }

    if (wmNews.getArticleId() != null) {
    articleDto.setId(wmNews.getArticleId());
    }

    articleDto.setCreatedTime(new Date());

    ResponseResult responseResult = iArticleClient.saveArticle(articleDto);
    return responseResult;
    }

    /**
    * 审核图片
    *
    * @param images
    * @param wmNews
    * @return
    */


    @Autowired
    private FileStorageService fileStorageService;

    @Autowired
    private GreenImageScan greenImageScan;

    private boolean handleImageScan(List<String> images, WmNews wmNews) {
    // 1. 下载图片从MinIO中
    boolean flag = true;

    if (images == null || images.size() == 0) {
    return flag;
    }

    // 图片去重
    images = images.stream().distinct().collect(Collectors.toList());

    List<byte[]> imageList = new ArrayList<>();

    for (String image : images) {
    byte[] bytes = fileStorageService.downLoadFile(image);
    imageList.add(bytes);
    }

    // 2. 审核图片

    try {
    Map map = greenImageScan.imageScan(imageList);
    if (map != null) {
    // 审核失败
    if (map.get("suggestion").equals("block")) {
    flag = false;
    updateWmNews(wmNews, (short) 2, "当前文章中存在违规内容");
    }

    // 不确定信息,需要人工审核
    if (map.get("suggestion").equals("review")) {
    flag = false;
    updateWmNews(wmNews, (short) 3, "当前文章中存在不确定内容");
    }
    }
    } catch (Exception e) {
    flag = false;
    e.printStackTrace();
    }

    return flag;
    }

    /**
    * 审核纯文本内容
    *
    * @param content
    * @param wmNews
    * @return
    */

    @Autowired
    private GreenTextScan greenTextScan;

    private boolean handleTextScan(String content, WmNews wmNews) {

    boolean flag = true;

    if ((wmNews.getTitle() + content).length() == 0) {
    return flag;
    }

    try {
    Map map = greenTextScan.greeTextScan(wmNews.getTitle() + content);
    if (map != null) {
    // 审核失败
    if (map.get("suggestion").equals("block")) {
    flag = false;
    updateWmNews(wmNews, (short) 2, "当前文章中存在违规内容");
    }

    // 不确定信息,需要人工审核
    if (map.get("suggestion").equals("review")) {
    flag = false;
    updateWmNews(wmNews, (short) 3, "当前文章中存在不确定内容");
    }
    }
    } catch (Exception e) {
    flag = false;
    e.printStackTrace();
    }
    return flag;
    }

    /**
    * 修改文章内容
    *
    * @param wmNews
    * @param status
    * @param reason
    */
    private void updateWmNews(WmNews wmNews, short status, String reason) {
    wmNews.setStatus(status);
    wmNews.setReason(reason);
    wmNewsMapper.updateById(wmNews);
    }

    /**
    * 1. 从自媒体文章内容中提取文本和图片
    * 2. 提取文章的封面图片
    *
    * @param wmNews
    * @return
    */
    private Map<String, Object> handleTextAndImages(WmNews wmNews) {

    // 存储纯文本内容
    StringBuilder sb = new StringBuilder();

    // 存储图片内容
    List<String> images = new ArrayList<>();

    // 1. 从自媒体文章内容中提取文本和图片
    if (StringUtils.isNoneBlank(wmNews.getContent())) {
    List<Map> maps = JSONArray.parseArray(wmNews.getContent(), Map.class);
    for (Map map : maps) {
    if (map.get("type").equals("text")) {
    sb.append(map.get("value"));
    }

    if (map.get("type").equals("image")) {
    images.add((String) map.get("value"));
    }
    }
    }

    // 2. 提取文章的封面图片
    if (StringUtils.isNoneBlank(wmNews.getImages())) {
    String[] split = wmNews.getImages().split(",");
    images.addAll(Arrays.asList(split));
    }

    Map<String, Object> resultMap = new HashMap<>();

    resultMap.put("content", sb.toString());
    resultMap.put("images", images);
    return resultMap;
    }
    }
  3. feign 远程接口调用方式,要先在依赖中导入feign-api的软件包,然后在启动类中填入注解

1
2
3
4
<dependency>
<groupId>com.heima</groupId>
<artifactId>heima-leadnews-feign-api</artifactId>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@SpringBootApplication
@EnableDiscoveryClient
@MapperScan("com.heima.wemedia.mapper")
@EnableFeignClients(basePackages = "com.heima.apis")
public class WemediaApplication {

public static void main(String[] args) {
SpringApplication.run(WemediaApplication.class, args);
}

@Bean
// Mybatis-Plus的分页插件
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
  • 在 heima-leadnews-wemedia 服务中已经依赖了 heima-leadnews-feign-apis 工程,只需要在自媒体的引导类中开启feign的远程调用即可
  • @EnableFeignClients(basePackages = "com.heima.apis") 需要指向 apis 这个包

feign 远程调用服务降级处理


示意图

  • 服务降级 是 服务自我保护 的一种方式,或者 保护下游服务 的一种方式,用于确保服务不会受请求突增影响变得不可用,确保服务不会崩溃
  • 服务降级 虽然 会导致请求失败,但是 不会导致阻塞

步骤:

  1. 在 com.heima.apis.article.fallback 包下编写降级逻辑

    1
    2
    3
    4
    5
    6
    7
    @Component
    public class IArticleClientFallback implements IArticleClient {
    @Override
    public ResponseResult saveArticle(ArticleDto dto) {
    return ResponseResult.errorResult(AppHttpCodeEnum.SERVER_ERROR, "获取数据失败");
    }
    }
  2. 远程接口中指向降级代码,在 com.heima.apis.article.IArticleClient 包下实现

    1
    2
    3
    4
    5
    6
    7
    @FeignClient(value = "leadnews-article", fallback = IArticleClientFallback.class)
    public interface IArticleClient {

    @PostMapping("/api/v1/article/save")
    // @RequestBody:通常会使用这个注解来接收客户端发送的 JSON、XML 或其他格式的数据,并将其转换为 Java 对象
    public ResponseResult saveArticle(@RequestBody ArticleDto dto);
    }
  3. 在 自媒体微服务 中添加类,扫描降级代码类的包,在 com.heima.wemedia.config 包下进行

    1
    2
    3
    4
    @Configuration
    @ComponentScan("com.heima.apis.article.fallback")
    public class InitConfig {
    }
  4. 客户端开启降级 heima-leadnews-wemedia,在 wemedia 的 nacos 配置中心里添加如下内容,开启服务降级,也可以指定服务响应的超时的时间

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    feign:
    # 开启feign对hystrix熔断降级的支持
    hystrix:
    enabled: true
    # 修改调用超时时间
    client:
    config:
    default:
    connectTimeout: 2000
    readTimeout: 2000

    nacos 配置中心


发布文章提交审核集成


文章审核的方式

Springboot集成异步线程调用:

  1. 在 自媒体文章审核 的方法上加上 @Async 注解(标明要 异步调用 )

    1
    2
    3
    4
    5
    @Override
    @Async //标明当前方法是一个异步方法
    public void autoScanWmNews(Integer id) {
    //代码略
    }
  2. 在 文章发布成功 后调用审核的方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    @Autowired
    private WmNewsAutoScanService wmNewsAutoScanService;

    /**
    * 发布修改文章或保存为草稿
    * @param dto
    * @return
    */
    @Override
    public ResponseResult submitNews(WmNewsDto dto) {

    //代码略

    //审核文章
    wmNewsAutoScanService.autoScanWmNews(wmNews.getId());

    return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);

    }
  3. 在 自媒体启动类 中使用 @EnableAsync 注解开启异步调用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    @SpringBootApplication
    @EnableDiscoveryClient
    @MapperScan("com.heima.wemedia.mapper")
    @EnableFeignClients(basePackages = "com.heima.apis")
    @EnableAsync // 开启异步调用
    public class WemediaApplication {

    public static void main(String[] args) {
    SpringApplication.run(WemediaApplication.class, args);
    }

    @Bean
    // Mybatis-Plus的分页插件
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
    return interceptor;
    }
    }

自管理敏感词过滤


需要实现的功能:需要自己维护一套敏感词,在文章审核的时候,需要验证文章是否包含这些敏感词

实现方案

DFA 实现原理

DFA全称为:Deterministic Finite Automaton,即确定有穷自动机

存储:一次性的把所有的敏感词存储到了多个map中,就是下图表示这种结构

敏感词:冰毒、大麻、大坏蛋

DFA举例

执行流程

在 com.heima.utils.common.SensitiveWordUtil 包下有一个工具类,来实现DFA算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
public class SensitiveWordUtil {

public static Map<String, Object> dictionaryMap = new HashMap<>();


/**
* 生成关键词字典库
* @param words
* @return
*/
public static void initMap(Collection<String> words) {
if (words == null) {
System.out.println("敏感词列表不能为空");
return ;
}

// map初始长度words.size(),整个字典库的入口字数(小于words.size(),因为不同的词可能会有相同的首字)
Map<String, Object> map = new HashMap<>(words.size());
// 遍历过程中当前层次的数据
Map<String, Object> curMap = null;
Iterator<String> iterator = words.iterator();

while (iterator.hasNext()) {
String word = iterator.next();
curMap = map;
int len = word.length();
for (int i =0; i < len; i++) {
// 遍历每个词的字
String key = String.valueOf(word.charAt(i));
// 当前字在当前层是否存在, 不存在则新建, 当前层数据指向下一个节点, 继续判断是否存在数据
Map<String, Object> wordMap = (Map<String, Object>) curMap.get(key);
if (wordMap == null) {
// 每个节点存在两个数据: 下一个节点和isEnd(是否结束标志)
wordMap = new HashMap<>(2);
wordMap.put("isEnd", "0");
curMap.put(key, wordMap);
}
curMap = wordMap;
// 如果当前字是词的最后一个字,则将isEnd标志置1
if (i == len -1) {
curMap.put("isEnd", "1");
}
}
}

dictionaryMap = map;
}

/**
* 搜索文本中某个文字是否匹配关键词
* @param text
* @param beginIndex
* @return
*/
private static int checkWord(String text, int beginIndex) {
if (dictionaryMap == null) {
throw new RuntimeException("字典不能为空");
}
boolean isEnd = false;
int wordLength = 0;
Map<String, Object> curMap = dictionaryMap;
int len = text.length();
// 从文本的第beginIndex开始匹配
for (int i = beginIndex; i < len; i++) {
String key = String.valueOf(text.charAt(i));
// 获取当前key的下一个节点
curMap = (Map<String, Object>) curMap.get(key);
if (curMap == null) {
break;
} else {
wordLength ++;
if ("1".equals(curMap.get("isEnd"))) {
isEnd = true;
}
}
}
if (!isEnd) {
wordLength = 0;
}
return wordLength;
}

/**
* 获取匹配的关键词和命中次数
* @param text
* @return
*/
public static Map<String, Integer> matchWords(String text) {
Map<String, Integer> wordMap = new HashMap<>();
int len = text.length();
for (int i = 0; i < len; i++) {
int wordLength = checkWord(text, i);
if (wordLength > 0) {
String word = text.substring(i, i + wordLength);
// 添加关键词匹配次数
if (wordMap.containsKey(word)) {
wordMap.put(word, wordMap.get(word) + 1);
} else {
wordMap.put(word, 1);
}

i += wordLength - 1;
}
}
return wordMap;
}

public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("法轮");
list.add("法轮功");
list.add("冰毒");
initMap(list);
String content="我是一个好人,并不会卖冰毒,也不操练法轮功,我真的不卖冰毒";
Map<String, Integer> map = matchWords(content);
System.out.println(map);
}
}

DFA 算法集成到文章审核中


  1. 创建敏感词表 wm_sensitive

    wm_senesitive

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    @Data
    @TableName("wm_sensitive")
    public class WmSensitive implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
    * 主键
    */
    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;

    /**
    * 敏感词
    */
    @TableField("sensitives")
    private String sensitives;

    /**
    * 创建时间
    */
    @TableField("created_time")
    private Date createdTime;

    }
  2. 在对应的 mapper 中创建 wm_sensitive 实体类

    1
    2
    3
    @Mapper
    public interface WmSensitiveMapper extends BaseMapper<WmSensitive> {
    }
  3. 在文章审核的代码中添加自管理敏感词审核

    • 在 WmNewsAutoScanServiceImpl 中的 autoScanWmNews 方法上添加如下代码

      1
      2
      3
      4
      5
      6
      7
      8
      9
      //从内容中提取纯文本内容和图片
      //.....省略

      //自管理的敏感词过滤
      boolean isSensitive = handleSensitiveScan((String) textAndImages.get("content"), wmNews);
      if(!isSensitive) return;

      //2.审核文本内容 阿里云接口
      //.....省略
    • 新增自管理敏感词审核代码

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      /**
      * 自管理敏感词审核
      *
      * @param content
      * @param wmNews
      * @return
      */
      @Autowired
      private WmSensitiveMapper wmSensitiveMapper;
      private boolean handleSensitiveScan(String content, WmNews wmNews) {
      boolean flag = true;
      // 1. 获取所有的敏感词
      List<WmSensitive> wmSensitives = wmSensitiveMapper.selectList(Wrappers
      .<WmSensitive>lambdaQuery().select(WmSensitive::getSensitives));

      List<String> sensitiveList = wmSensitives
      .stream().map(WmSensitive::getSensitives).collect(Collectors.toList()

      // 2. 初始化敏感词库
      SensitiveWordUtil.initMap(sensitiveList);

      // 3. 查看文章中是否包含敏感词
      Map<String, Integer> map = SensitiveWordUtil.matchWords(content);
      if (map.size() > 0) {
      updateWmNews(wmNews, (short) 2, "当前文章中存在违规内容" + map);
      flag = false;
      }
      return flag;
      }

图片中文字敏感词过滤


OCR (Optical Character Recognition,光学字符识别)是指电子设备(例如扫描仪或数码相机)检查纸上打印的字符,通过检测暗、亮的模式确定其形状,然后 用字符识别方法将形状翻译成计算机文字的过程

OCR技术分类

Tesseract-OCR

Tesseract-OCR 的特点:

  1. Tesseract 支持UTF-8编码格式,并且可以“开箱即用”地识别100多种语言
  2. Tesseract 支持多种输出格式:纯文本,hOCR(HTML),PDF等
  3. 官方建议,为了获得更好的OCR结果,最好提供给高质量的图像
  4. Tesseract 进行识别其他语言的训练

Tess4j 使用

  1. 创建项目导入 tess4j 对应的依赖

    1
    2
    3
    4
    5
    <dependency>
    <groupId>net.sourceforge.tess4j</groupId>
    <artifactId>tess4j</artifactId>
    <version>4.1.1</version>
    </dependency>
  2. 导入中文字体库, tessdata文件夹 拷贝到 todaynews 的工作空间下

  3. 编写测试类进行测试

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    public class Application {
    /**
    * 识别图片中的文字
    * @param args
    */
    public static void main(String[] args) {
    // 1. 创建实例
    ITesseract tesseract = new Tesseract();

    // 2. 设置字体库路径
    tesseract.setDatapath("D:\\BaiduNetdiskDownload\\todaynews\\tessdata");

    // 3. 设置语言 --> 简体中文
    // chi_sim 这是固定写法
    tesseract.setLanguage("chi_sim");

    File file = new File("D:\\BaiduNetdiskDownload\\todaynews\\test.png");

    // 4. 识别图片
    String result = null;
    try {
    result = tesseract.doOCR(file);
    } catch (TesseractException e) {
    e.printStackTrace();
    }

    System.out.println("识别的结果为:" + result.replaceAll("\\r|\\n","-"));
    /**
    * 识别的结果为:江户川柯南私人侦探-婚外情调查 商务调查 要账情债 ”企业打假-寻人找人 财产调查“私人调查--电话: 12345678901-
    */
    }
    }

管理敏感词和图片文字识别集成到文章审核

  1. 导入依赖

    1
    2
    3
    4
    5
    6
    <!-- tess4j -->
    <dependency>
    <groupId>net.sourceforge.tess4j</groupId>
    <artifactId>tess4j</artifactId>
    <version>4.1.1</version>
    </dependency>
  2. 在 heima-leadnews-common 中创建工具类,简单封装一下 tess4j

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    Getter
    @Setter
    @Component
    @ConfigurationProperties(prefix = "tess4j")
    public class Tess4jClient {

    private String dataPath;
    private String language;

    public String doOCR(BufferedImage image) throws TesseractException {
    //创建Tesseract对象
    ITesseract tesseract = new Tesseract();
    //设置字体库路径
    tesseract.setDatapath(dataPath);
    //中文识别
    tesseract.setLanguage(language);
    //执行ocr识别
    String result = tesseract.doOCR(image);
    //替换回车和tal键 使结果为一行
    result = result.replaceAll("\\r|\\n", "-").replaceAll(" ", "");
    return result;
    }

    }
  3. 在 spring.factories 配置中添加该类

    1
    2
    3
    4
    5
    6
    7
    org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
    com.heima.common.exception.ExceptionCatch,\
    com.heima.common.swagger.SwaggerConfiguration,\
    com.heima.common.swagger.Swagger2Configuration,\
    com.heima.common.aliyun.GreenImageScan,\
    com.heima.common.aliyun.GreenTextScan,\
    com.heima.common.tess4j.Tess4jClient
  4. 在 heima-leadnews-wemedia 中的配置中添加两个属性

    1
    2
    3
    tess4j:
    data-path: D:\BaiduNetdiskDownload\todaynews\tessdata
    language: chi_sim
  5. 在 WmNewsAutoScanServiceImpl 中的 handleImageScan 方法上添加如下代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    /**
    * 自管理的敏感图片识别
    *
    * @param images
    * @param wmNews
    * @return
    */
    @Autowired
    private FileStorageService fileStorageService;

    @Autowired
    private Tess4jClient tess4jClient;

    private boolean handleSensitivePictScan(List<String> images, WmNews wmNews) {
    // 1. 下载图片从MinIO中
    boolean flag = true;
    if (images == null || images.size() == 0) {
    return flag;
    }

    // 图片去重
    images = images.stream().distinct().collect(Collectors.toList());
    try {
    for (String image : images) {
    byte[] bytes = fileStorageService.downLoadFile(image);

    // byte[] 转换为 bufferImage
    ByteArrayInputStream in = new ByteArrayInputStream(bytes);
    BufferedImage bufferedImage = ImageIO.read(in);

    // 图片识别
    String result = tess4jClient.doOCR(bufferedImage);

    // 过滤文字
    flag = handleSensitiveScan(result, wmNews);
    }
    } catch (Exception e) {
    e.printStackTrace();
    }
    return flag;
    }

文章详情-静态文件生成


思路分析

文章端创建 app 相关文章时,生成文章详情静态页上传到 MinIO 中

流程分析

实现步骤

  1. 新建 ArticleFreemarkerService 创建静态文件并上传到 minIO 中,在com.heima.article.service.ArticleFreemarkerService 软件包目录下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public interface ArticleFreemarkerService {

    /**
    * 生成静态文件上传到minIO中
    * @param apArticle
    * @param content
    */
    public void buildArticleToMinIO(ApArticle apArticle,String content);
    }
  2. 实现类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    @Service
    @Slf4j
    @Transactional
    public class ArticleFreemarkerServiceImpl implements ArticleFreemarkerService {
    /**
    * 生成静态文件上传到minio中
    *
    * @param apArticle
    * @param content
    */

    @Autowired
    private Configuration configuration;

    @Autowired
    private FileStorageService fileStorageService;

    @Autowired
    private ApArticleService apArticleService;

    @Override
    @Async // 异步方式生成静态文件
    public void buildArticleToMinIO(ApArticle apArticle, String content) {
    // 已知文章id
    // 1. 获取文章内容
    if (StringUtils.isNotBlank(content)) {

    // 2. 文章内容通过freemarker生成html文件
    Template template = null;
    StringWriter out = null;
    try {
    template = configuration.getTemplate("article.ftl");

    // 数据模型
    Map<String, Object> contentDataModel = new HashMap<>();

    contentDataModel.put("content", JSONArray.parseArray(content));

    out = new StringWriter();

    // 合成
    template.process(contentDataModel, out);

    } catch (Exception e) {
    e.printStackTrace();
    }


    // 3. 把html文件上传到minio中
    InputStream in = new ByteArrayInputStream(out.toString().getBytes());
    String path = fileStorageService.uploadHtmlFile("", apArticle.getId() + ".html", in);

    // 4. 修改ap_article表,保存static_url字段
    apArticleService.update(Wrappers.<ApArticle>lambdaUpdate().eq(ApArticle::getId, apArticle.getId())
    .set(ApArticle::getStaticUrl, path));
    }
    }
    }
  3. 在 ApArticleService 的 saveArticle 实现方法中添加调用生成文件的方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    /**
    * 保存app端相关文章
    *
    * @param dto
    * @return
    */
    @Autowired
    private ApArticleConfigMapper apArticleConfigMapper;
    @Autowired
    private ApArticleContentMapper apArticleContentMapper;
    @Autowired
    private ArticleFreemarkerService articleFreemarkerService;
    @Override
    public ResponseResult saveArticle(ArticleDto dto) {
    try {
    Thread.sleep(3000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    // 1. 检查参数
    if (dto == null) {
    return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
    }
    ApArticle apArticle = new ApArticle();
    BeanUtils.copyProperties(dto, apArticle);
    // 2. 判断是否存在id
    if (dto.getId() == null) {
    // 2.1 不存在id,保存 文章 文章配置 文章内容
    // 保存文章
    save(apArticle);
    // 保存配置
    /**
    * 这里重写了有参构造方法
    *
    * public ApArticleConfig(Long articleId) {
    * this.articleId = articleId;
    * this.isDelete = false;
    * this.isDown = false;
    * this.isForward = true;
    * this.isComment = true;
    * }
    */
    ApArticleConfig apArticleConfig = new ApArticleConfig(apArticle.getId());
    apArticleConfigMapper.insert(apArticleConfig);
    // 保存文章内容
    ApArticleContent apArticleContent = new ApArticleContent();
    apArticleContent.setArticleId(apArticle.getId());
    apArticleContent.setContent(dto.getContent());
    apArticleContentMapper.insert(apArticleContent);
    } else {
    // 2.2 存在id 修改 文章 文章内容
    // 修改文章
    updateById(apArticle);
    // 修改文章内容
    ApArticleContent apArticleContent = apArticleContentMapper.selectOne(Wrappers
    .<ApArticleContent>lambdaQuery().eq(ApArticleContent::getArticleId, dto.getId()));
    apArticleContent.setContent(dto.getContent());
    apArticleContentMapper.updateById(apArticleContent);
    }

    // !!!
    // 异步调用,生成静态文件上传到minio中
    articleFreemarkerService.buildArticleToMinIO(apArticle, dto.getContent());
    // 3. 结果返回 文章id
    return ResponseResult.okResult(apArticle.getId());
    }
  4. 文章微服务开启异步调用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    @SpringBootApplication
    @EnableDiscoveryClient
    @MapperScan("com.heima.article.mapper")
    @EnableAsync // 开启异步调用
    public class ArticleApplication {

    public static void main(String[] args) {
    SpringApplication.run(ArticleApplication.class,args);
    }

    @Bean
    // Mybatis-Plus的分页插件
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
    return interceptor;
    }
    }

延迟任务精准发布文章


整体框架


延迟任务


定时任务:有固定周期的,有明确的触发时间

延迟队列:没有固定的开始时间,它常常是 由一个事件触发 的,而在 这个事件触发之后的一段时间内触发另一个事件,任务 可以立即执行,也 可以延迟

延迟时间


技术对比

  1. DelayQueue

    • JDK 自带 DelayQueue 是一个支持延时获取元素的阻塞队列, 内部采用优先队列 PriorityQueue 存储元素,同时元素必须实现 Delayed 接口;在创建元素时可以指定多久才可以从队列中获取当前元素,只有在延迟期满时才能从队列中提取元素
    • 存在的问题:使用线程池或者原生 DelayQueue 程序挂掉之后,任务都是放在内存,需要考虑未处理消息的丢失带来的影响,如何保证数据不丢失,需要持久化(磁盘)

    DelayQueue原理

  2. RabbitMQ

    • TTL:Time To Live (消息存活时间)
    • 死信队列:Dead Letter Exchange(死信交换机),当消息成为 Dead message 后,可以重新发送另一个交换机(死信交换机)

    RabbitMQ原理

  3. redis实现

    • zset 数据类型的 去重 有序(分数排序)特点进行延迟
    • 例如:时间戳作为score进行排序

    redis延迟任务原理


redis 实现延迟任务


Redis 实现延迟任务的实现思路

  1. 把 任务 需要存储在 数据库 中的 原因:延迟任务是一个通用的服务,任何需要延迟得任务都可以调用该服务,需要 考虑数据持久化 的问题,存储数据库中是一种 数据安全 的考虑
  2. redis 中使用两种数据类型,list 和 zset 的原因:
    • 原因一:list 存储立即执行的任务,zset 存储未来的数据
    • 原因二:任务量过大之后,zset 的性能会下降,因为 操作redis 中的 list 命令 LPUSH 的 时间复杂度: O(1),操作redis 中的 zset 命令 zadd 的时间复杂度:O(M*log(n))
  3. 在添加 zset 数据的时候,需要 预加载 的原因:如果 任务数据特别大,为了防止阻塞,只需要把 未来几分钟要执行的数据存入缓存 即可,是一种优化的形式

redis 实现延迟任务的流程


准备工作

可以通过feign调用延迟队列

  1. 在 heima-leadnews-service 工程下创建 heima-leadnews-schedule 模块

  2. 添加 bootstrap.yml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    server:
    port: 51701
    spring:
    application:
    name: leadnews-schedule
    cloud:
    nacos:
    discovery:
    server-addr: 192.168.88.129:8848
    config:
    server-addr: 192.168.88.129:8848
    file-extension: yml
  3. 在 nacos 中添加对应的配置,并添加数据库及 MP 的配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    spring:
    datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/leadnews_schedule?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
    username: root
    password: root
    # 设置Mapper接口所对应的XML文件位置,如果你在Mapper接口中有自定义方法,需要进行该配置
    mybatis-plus:
    mapper-locations: classpath*:mapper/*.xml
    # 设置别名包扫描路径,通过该属性可以给包中的类注册别名
    type-aliases-package: com.heima.model.schedule.pojos
  4. 导入数据库

    • taskinfo 任务表

      taskinfo 任务表

    • taskinfo_logs 任务日志表

      taskinfo_logs  任务日志表

      • MySQL中,BLOB是一个二进制大型对象,是一个可以存储大量数据的容器;LongBlob 最大存储4G
  5. 构建实体类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    // taskinfo 任务表

    @Data
    @TableName("taskinfo")
    public class Taskinfo implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
    * 任务id
    */
    @TableId(type = IdType.ID_WORKER)
    private Long taskId;

    /**
    * 执行时间
    */
    @TableField("execute_time")
    private Date executeTime;

    /**
    * 参数
    */
    @TableField("parameters")
    private byte[] parameters;

    /**
    * 优先级
    */
    @TableField("priority")
    private Integer priority;

    /**
    * 任务类型
    */
    @TableField("task_type")
    private Integer taskType;


    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    // taskinfo_logs 任务日志表

    @Data
    @TableName("taskinfo_logs")
    public class TaskinfoLogs implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
    * 任务id
    */
    @TableId(type = IdType.ID_WORKER)
    private Long taskId;

    /**
    * 执行时间
    */
    @TableField("execute_time")
    private Date executeTime;

    /**
    * 参数
    */
    @TableField("parameters")
    private byte[] parameters;

    /**
    * 优先级
    */
    @TableField("priority")
    private Integer priority;

    /**
    * 任务类型
    */
    @TableField("task_type")
    private Integer taskType;

    /**
    * 版本号,用乐观锁
    */
    @Version
    private Integer version;

    /**
    * 状态 0=int 1=EXECUTED 2=CANCELLED
    */
    @TableField("status")
    private Integer status;


    }

    • 悲观锁(Pessimistic Lock)
      • 每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁。
      • 效率较低
    • 乐观锁(Optimistic Lock)
      • 每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是 在更新的时候 会判断一下 在此期间 别人有没有去更新 这个数据,可以 使用版本号 等机制
      • 效率较高
    • 上面两个是 数据库自身解决并发的两种策略
  6. 乐观锁的使用

    • 在实体类中使用 @Version 标明是一个版本的字段

      1
      2
      3
      4
      5
      /**
      * 版本号,用乐观锁
      */
      @Version
      private Integer version;
    • Mybatis-plus 对乐观锁的支持,在 启动类 中向 容器 中放入 乐观锁 的 拦截器

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      /**
      * mybatis-plus乐观锁支持
      * @return
      */
      @Bean
      public MybatisPlusInterceptor optimisticLockerInterceptor(){
      MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
      interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
      return interceptor;
      }
  7. docker 安装 redis

    • 拉去镜像

      1
      docker pull redis
    • 创建容器

      1
      2
      3
      docker run -d --name redis --restart=always -p 6379:6379 redis --requirepass "leadnews"

      # --requirepass "leadnews":这是创建的密码是leadnews
    • 链接测试,打开 Another Redis Desktop Manager,测试host、port、password 连接测试

      连接测试

  8. 在项目中集成redis

    • 导入依赖

      1
      2
      3
      4
      5
      6
      7
      8
      9
      <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
      </dependency>
      <!-- redis依赖commons-pool 这个依赖一定要添加 -->
      <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-pool2</artifactId>
      </dependency>
    • 在 nacos 中添加配置

      1
      2
      3
      4
      5
      6
      7
      spring:
      redis:
      host: 192.168.200.130
      password: leadnews
      port: 6379

      # 这部分写在spring下

      Nacos 中添加配置

    • 拷贝一个 CacheService.java(redis集成配置) 这个工具类到 common 模块下,并添加自动配置

      • 这里不给出 CacheService.java 的源码(太多了),见项目
      • 编写测试类,测试 redis 是否可用
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      @SpringBootTest(classes = ScheduleApplication.class)
      @RunWith(SpringRunner.class)
      public class RedisTest {

      @Autowired
      private CacheService cacheService;

      @Test
      public void testList() {

      // 在list左边添加元素
      cacheService.lLeftPush("list_001", "hello redis");

      // 在list右边获取元素并删除
      String list001 = cacheService.lRightPop("list_001");
      System.out.println("list001 = " + list001);
      }

      @Test
      public void testZet() {

      // 添加数据到zset中 分值
      cacheService.zAdd("zset_key_001","hello zset 001",1000);
      cacheService.zAdd("zset_key_001","hello zset 002",8888);
      cacheService.zAdd("zset_key_001","hello zset 003",7777);
      cacheService.zAdd("zset_key_001","hello zset 004",9999);

      // 按照分值获取数据
      Set<String> zsetKey001 = cacheService.zRangeByScore("zset_key_001", 0, 8888);
      System.out.println("zsetKey001 = " + zsetKey001);
      // zsetKey001 = [hello zset 001, hello zset 003, hello zset 002]
      }
      }

添加任务

流程:

  1. 创建 TaskinfoMapper 和 TaskinfoLogsMapper 到项目的mapper文件包中

    1
    2
    3
    4
    5
    @Mapper
    public interface TaskinfoMapper extends BaseMapper<Taskinfo> {

    public List<Taskinfo> queryFutureTime(@Param("taskType")int type, @Param("priority")int priority, @Param("future")Date future);
    }
    1
    2
    3
    4
    @Mapper
    public interface TaskinfoLogsMapper extends BaseMapper<TaskinfoLogs> {

    }
  2. 创建 task 类,用于接收添加任务的参数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    @Data
    public class Task implements Serializable {

    /**
    * 任务id
    */
    private Long taskId;
    /**
    * 类型
    */
    private Integer taskType;

    /**
    * 优先级
    */
    private Integer priority;

    /**
    * 执行id
    */
    private long executeTime;

    /**
    * task参数
    */
    private byte[] parameters;

    }
  3. 创建 TaskService

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public interface TaskService {

    /**
    * 添加延迟任务
    * @param task
    * @return
    */
    public long addTask(Task task);
    }
    • 添加任务到数据库中
    • 添加任务到 redis 中
      • 如果任务的执行时间 小于等于 当前时间存入 list
      • 如果任务的执行时间大于当前时间,小于等于预设时间(未来5分钟)存入 zset 中
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    // TaskService的实现类

    @Service
    @Transactional
    @Slf4j
    public class TaskServiceImpl implements TaskService {
    /**
    * 添加延迟任务
    *
    * @param task
    * @return
    */
    @Override
    public long addTask(Task task) {
    // 1. 添加任务到数据库中

    boolean success = addTaskToDb(task);


    if (success) {
    // 2. 添加任务到redis
    addTaskToCache(task);
    }

    return task.getTaskId();
    }

    /**
    * 把任务添加到redis中
    *
    * @param task
    */

    @Autowired
    private CacheService cacheService;

    private void addTaskToCache(Task task) {

    // key 一般是 task的任务类型_task的优先级
    String key = task.getTaskType() + "_" + task.getPriority();

    // 获取5分钟之后的时间(毫秒值)
    Calendar calendar = Calendar.getInstance();
    calendar.add(Calendar.MINUTE, 5);
    long nextScheduleTime = calendar.getTimeInMillis();

    // 2.1 如果任务执行时间小于等于当前时间,存入带list中
    if (task.getExecuteTime() <= System.currentTimeMillis()) {
    // ScheduleConstants.TOPIC = "topic_"
    cacheService.lLeftPush(ScheduleConstants.TOPIC + key, JSON.toJSONString(task));
    } else if (task.getExecuteTime() <= nextScheduleTime) {
    // 2.2 如果任务执行时间大于当前时间,并且小于等于预设时间(未来5分钟),存入到zset中
    cacheService.zAdd(ScheduleConstants.FUTURE + key
    , JSON.toJSONString(task), task.getExecuteTime());
    }

    }

    /**
    * 添加任务到数据库中
    *
    * @param task
    * @return
    */

    @Autowired
    private TaskinfoMapper taskinfoMapper;

    @Autowired
    private TaskinfoLogsMapper taskinfoLogsMapper;

    private boolean addTaskToDb(Task task) {

    boolean flag = false;

    try {
    // 保存任务表
    Taskinfo taskinfo = new Taskinfo();
    BeanUtils.copyProperties(task, taskinfo);
    taskinfo.setExecuteTime(new Date(task.getExecuteTime()));
    taskinfoMapper.insert(taskinfo);

    // 设置taskid
    task.setTaskId(taskinfo.getTaskId());

    // 保存任务日志数据
    TaskinfoLogs taskinfoLogs = new TaskinfoLogs();
    BeanUtils.copyProperties(taskinfo, taskinfoLogs);

    // 设置乐观锁的版本号
    taskinfoLogs.setVersion(1);
    taskinfoLogs.setStatus(ScheduleConstants.SCHEDULED); // 0 阅读不方便,设置一个常量
    taskinfoLogsMapper.insert(taskinfoLogs);

    flag = true;

    } catch (Exception e) {
    e.printStackTrace();
    }

    return flag;
    }
    }
  4. 设置常量类 ScheduleConstants

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class ScheduleConstants {

    //task状态
    public static final int SCHEDULED=0; //初始化状态

    public static final int EXECUTED=1; //已执行状态

    public static final int CANCELLED=2; //已取消状态

    public static String FUTURE="future_"; //未来数据key前缀

    public static String TOPIC="topic_"; //当前数据key前缀
    }

取消任务

使用场景:第三接口网络不通,使用延迟任务进行重试,当达到阈值以后,取消任务

取消任务流程

流程:

  1. 在 TaskService 中添加方法

    1
    2
    3
    4
    5
    6
    /**
    * 取消任务
    * @param taskId
    * @return
    */
    public boolean cancelTask(long taskId);
  2. 实现类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    /**
    * 取消任务
    *
    * @param taskId
    * @return
    */
    @Override
    public boolean cancelTask(long taskId) {
    boolean flag = false;
    // 1. 删除任务,更新任务日志
    Task task = updateDb(taskId, ScheduleConstants.CANCELLED);

    // 2. 删除redis的数据
    if (task != null) {
    removeTaskFromCache(task);
    flag = true;
    }

    return flag;
    }

    /**
    * 删除redis中的数据
    *
    * @param task
    */
    private void removeTaskFromCache(Task task) {
    // key 一般是 task的任务类型_task的优先级
    String key = task.getTaskType() + "_" + task.getPriority();

    // 这里根据前面的条件,删除list的数据
    if (task.getExecuteTime() <= System.currentTimeMillis()) {
    cacheService.lRemove(ScheduleConstants.TOPIC + key, 0, JSON.toJSONString(task));
    } else {
    cacheService.zRemove(ScheduleConstants.FUTURE + key, JSON.toJSONString(task));
    }
    }

    /**
    * 删除任务,更新任务的日志
    *
    * @param taskId
    * @param status
    * @return
    */
    private Task updateDb(long taskId, int status) {
    Task task = null;
    try {
    // 1. 删除任务
    taskinfoMapper.deleteById(taskId);

    // 2. 更新任务的日志,主要修改任务日志的状态
    TaskinfoLogs taskinfoLogs = taskinfoLogsMapper.selectById(taskId);
    taskinfoLogs.setStatus(status);
    taskinfoLogsMapper.updateById(taskinfoLogs);

    task = new Task();
    BeanUtils.copyProperties(taskinfoLogs, task);
    task.setExecuteTime(taskinfoLogs.getExecuteTime().getTime());
    } catch (Exception e) {
    log.error("task cancel exception taskId = {}", taskId);
    }
    return task;
    }

消费任务

消费任务流程

流程:

  1. 在 TaskService 中添加方法

    1
    2
    3
    4
    5
    6
    7
    /**
    * 按照类型与优先级拉取
    * @param type
    * @param priority
    * @return
    */
    public Task poll(int type, int priority);
  2. 实现类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    /**
    * 按照类型与优先级拉取
    *
    * @param type
    * @param priority
    * @return
    */
    @Override
    public Task poll(int type, int priority) {
    Task task = null;
    try {
    String key = type + "_" + priority;

    // 1. 从redis中拉取数据
    String taskJson = cacheService.lRightPop(ScheduleConstants.TOPIC + key);

    if (StringUtils.isNotBlank(taskJson)) {

    task = JSON.parseObject(taskJson, Task.class);

    // 2. 删除数据库信息,修改任务日志为已完成的状态
    updateDb(task.getTaskId(), ScheduleConstants.EXECUTED);
    }
    } catch (Exception e) {
    e.printStackTrace();
    log.error("poll task exception");
    }
    return task;
    }

定时刷新

定时刷新流程

问题:如何获取 zset 中所有的 key ?

方案一:keys 模糊匹配

  • redis 是单线程,会被堵塞。并且使用 keys 的模糊匹配发现 redis 的 CPU使用率极高

    1
    Set<String> keys = cacheservice.keys("future_*");

keys 模糊匹配

方案二:scan(最常用的方法)

  • SCAN 命令是一个基于 游标 的迭代器,SCAN命令 每次被调用之后, 都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为 SCAN命令 的游标参数, 以此来延续之前的迭代过程

    1
    Set<String> scan = cacheservice.scan("future_*");

scan命令的流程

**问题:**数据如何进行同步?

主要就是:

  • 第一:从 zset 中查出数据,并删除
  • 第二:把数据存入到 list 中

原始方案:普通 redis 客户端和 服务器 交互模式

原始方案

redis 通道技术:Pipeline请求模型

Pipeline请求模型

流程:

  • 直接在 TaskServiceImpl 实现类下面定义实现方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    /**
    * 未来数据定时刷新
    */

    // @Scheduled:注解是创建定时任务,下面的意思是每分钟执行一次
    // cron中的含义:[秒] [分] [小时] [日] [月] [周] [年]
    // 在第一位的"5/15" 表示从5秒开始,每增15秒触发(5,20,35,50)
    // ? 表示不指定值。使用的场景为不需要关心当前设置这个字段的值。
    @Scheduled(cron = "0 */1 * * * ?")
    public void refresh() {
    log.info("未来数据定时刷新 -- 定时任务");

    // 获取所有未来数据的集合key
    Set<String> futureKeys = cacheService.scan(ScheduleConstants.FUTURE + "*");
    for (String futureKey : futureKeys) {

    // 获取当前数据的key topic
    String topicKey = ScheduleConstants.TOPIC + futureKey.split(ScheduleConstants.FUTURE)[1];

    // 按照key和分值查询符合条件的数据
    Set<String> tasks = cacheService.zRangeByScore(futureKey, 0, System.currentTimeMillis());

    // 同步数据
    if (!tasks.isEmpty()) {
    cacheService.refreshWithPipeline(futureKey, topicKey, tasks);
    log.info("成功将" + futureKey + "刷新到了" + topicKey);
    }
    }
    }
  • 在引导类加上注解 @EnableScheduling ,开启定时任务

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @SpringBootApplication
    @MapperScan("com.heima.schedule.mapper")
    @EnableScheduling // 开启调度任务
    public class ScheduleApplication {

    public static void main(String[] args) {
    SpringApplication.run(ScheduleApplication.class,args);
    }

    /**
    * mybatis-plus乐观锁支持
    * @return
    */
    @Bean
    public MybatisPlusInterceptor optimisticLockerInterceptor(){
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
    return interceptor;
    }
    }

定时同步

就是把数据库任务定时同步到 redis 中

示意图

流程:

  1. 清理缓存中的数据

    1
    2
    3
    4
    Set<String> futurekeys = cacheService.scan(ScheduleConstants.FUTURE + "*"); // future_
    Set<String> topickeys = cacheService.scan(ScheduleConstants.TOPIC + "*"); // topic_
    cacheService.delete(futurekeys);
    cacheService.delete(topickeys);
  2. 查询小于未来5分钟的所有任务

    1
    List<Taskinfo> allTasks = taskinfoMapper.selectList(Wrappers.<Taskinfo>lambdaQuery().lt(Taskinfo::getExecuteTime,calendar.getTime()));
  3. 新增任务到 redis

    1
    2
    3
    4
    5
    6
    for (Taskinfo taskinfo : allTasks) {
    Task task = new Task();
    BeanUtils.copyProperties(taskinfo,task);
    task.setExecuteTime(taskinfo.getExecuteTime().getTime());
    addTaskToCache(task);
    }

完成实现流程:

在 TaskServiceImpl 里面定义方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* 数据库任务定时同步到redis中
*/
// @PostConstruct:初始化方法,一旦微服务启动,就会做注解方法的同步操作
@PostConstruct
@Scheduled(cron = "0 */5 * * * ? ")
public void reloadData() {
// 1. 清理缓存中的数据(list中数据,zset中的数据)
clearCache();

// 2. 查询符合条件的任务,小于未来5分钟的数据
// 获取5分钟之后的时间(毫秒值)
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, 5);
List<Taskinfo> taskinfoList = taskinfoMapper.selectList(Wrappers.<Taskinfo>lambdaQuery()
.lt(Taskinfo::getExecuteTime, calendar.getTime()));

// 3. 把任务添加到redis
if (taskinfoList != null && taskinfoList.size() > 0) {
for (Taskinfo taskinfo : taskinfoList) {
Task task = new Task();
BeanUtils.copyProperties(taskinfo, task);
task.setExecuteTime(taskinfo.getExecuteTime().getTime());
addTaskToCache(task);
}
}
log.info("数据库任务同步到了redis");
}

public void clearCache() {
Set<String> topicKeys = cacheService.scan(ScheduleConstants.TOPIC + "*");
Set<String> futureKeys = cacheService.scan(ScheduleConstants.FUTURE + "*");
cacheService.delete(topicKeys);
cacheService.delete(futureKeys);
}

分布式锁


问题:启动两台 heima-leadnews-schedule 服务,每台服务都会去执行 refresh 定时任务方法,如下图所示:

图示

  • 这样会造成集群的资源抢占的问题

分布式锁:控制分布式系统有序的去对 共享资源 进行操作,通过 互斥 来保证数据的一致性

分布式锁是用来 解决集群下的方法抢占执行

分布式锁的解决方案:

分布式锁的解决方案


redis 分布式锁

sexnx (SET if Not eXists) 命令在 指定的 key 不存在时,为 key 设置指定的值

加锁的思路:

加锁的思路

这种加锁的思路是,如果 key 不存在则为 key 设置 value,如果 key 已存在则 SETNX 命令不做任何操作

  • 客户端A 请求服务器设置 key 的值,如果设置成功就表示加锁成功
  • 客户端B 也去请求服务器设置 key 的值,如果返回失败,那么就代表加锁失败
  • 客户端A 执行代码完成,删除锁
  • 客户端B 在等待一段时间后再去请求设置key的值,设置成功
  • 客户端B 执行代码完成,删除锁

总结:A获取到锁之后其他客户端就不能操作了,只能等到A释放锁以后,其他客户端才可以操作

实现流程:

  • 在工具类 CacheService 中添加方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    /**
    * 加锁
    *
    * @param name 名字
    * @param expire 过期的时间
    * @return
    */
    public String tryLock(String name, long expire) {
    name = name + "_lock";
    String token = UUID.randomUUID().toString();
    RedisConnectionFactory factory = stringRedisTemplate.getConnectionFactory();
    RedisConnection conn = factory.getConnection();
    try {

    //参考redis命令:
    //set key value [EX seconds] [PX milliseconds] [NX|XX]
    Boolean result = conn.set(
    name.getBytes(),
    token.getBytes(),
    Expiration.from(expire, TimeUnit.MILLISECONDS),
    RedisStringCommands.SetOption.SET_IF_ABSENT //NX
    );
    if (result != null && result)
    return token;
    } finally {
    RedisConnectionUtils.releaseConnection(conn, factory,false);
    }
    return null;
    }
  • 在 定时任务 中添加这个方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    /**
    * 未来数据定时刷新
    */
    // @Scheduled:注解是创建定时任务,下面的意思是每分钟执行一次
    // cron中的含义:[秒] [分] [小时] [日] [月] [周] [年]
    // 在第一位的"5/15" 表示从5秒开始,每增15秒触发(5,20,35,50)
    @Scheduled(cron = "0 */1 * * * ?")
    public void refresh() {

    // 如果token有值执行代码,如果token无值意为加锁了
    String token = cacheService.tryLock("FUTURE_TASK_SYNC", 100 * 30);
    if (StringUtils.isNotBlank(token)) {
    log.info("未来数据定时刷新 -- 定时任务");

    // 获取所有未来数据的集合key
    Set<String> futureKeys = cacheService.scan(ScheduleConstants.FUTURE + "*");
    for (String futureKey : futureKeys) {

    // 获取当前数据的key topic
    String topicKey = ScheduleConstants.TOPIC + futureKey.split(ScheduleConstants.FUTURE)[1];

    // 按照key和分值查询符合条件的数据
    Set<String> tasks = cacheService.zRangeByScore(futureKey, 0, System.currentTimeMillis());

    // 同步数据
    if (!tasks.isEmpty()) {
    cacheService.refreshWithPipeline(futureKey, topicKey, tasks);
    log.info("成功将" + futureKey + "刷新到了" + topicKey);
    }
    }
    }
    }

延迟队列服务 提供对外接口


feign 远程调用

流程:

  1. 提供远程的 feign 接口,在 heima-leadnews-feign-api 编写类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    @FeignClient("leadnews-schedule")
    public interface IScheduleClient {
    /**
    * 添加延迟任务
    *
    * @param task
    * @return
    */
    @PostMapping("/api/v1/task/add")
    public ResponseResult addTask(@RequestBody Task task);

    /**
    * 取消任务
    *
    * @param taskId
    * @return
    */
    @GetMapping("/api/v1/task/{taskId}")
    public ResponseResult cancelTask(@PathVariable("taskId") long taskId);

    /**
    * 按照类型与优先级拉取
    *
    * @param type
    * @param priority
    * @return
    */
    @GetMapping("/api/v1/task/{type}/{priority}")
    public ResponseResult poll(@PathVariable("type") int type, @PathVariable("priority") int priority);
    }
  2. 在 heima-leadnews-schedule 微服务下建立 feign 软件包下提供对应的实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    @RestController
    public class ScheduleClient implements IScheduleClient {

    @Autowired
    private TaskService taskService;

    /**
    * 添加延迟任务
    *
    * @param task
    * @return
    */
    @PostMapping("/api/v1/task/add")
    public ResponseResult addTask(@RequestBody Task task) {
    return ResponseResult.okResult(taskService.addTask(task));
    }

    /**
    * 取消任务
    *
    * @param taskId
    * @return
    */
    @GetMapping("/api/v1/task/{taskId}")
    public ResponseResult cancelTask(@PathVariable("taskId") long taskId) {
    return ResponseResult.okResult(taskService.cancelTask(taskId));
    }

    /**
    * 按照类型与优先级拉取
    *
    * @param type
    * @param priority
    * @return
    */
    @GetMapping("/api/v1/task/{type}/{priority}")
    public ResponseResult poll(@PathVariable("type") int type, @PathVariable("priority") int priority) {
    return ResponseResult.okResult(taskService.poll(type, priority));
    }
    }

发布文章添加 延迟队列接口


实现流程

流程:

  1. 在 heima-leadnews-wemedia 的业务层创建 WmNewsTaskService

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public interface WmNewsTaskService {

    /**
    * 添加任务到延时队列中
    * @param id 文章的id
    * @param publishTime 发布的时间 可以作为任务的执行时间
    */
    public void addNewsToTask(Integer id, Date publishTime);
    }
  2. 实现类 WmNewsTaskServiceImpl

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    @Service
    @Slf4j
    public class WmNewsTaskServiceImpl implements WmNewsTaskService {

    @Autowired
    private IScheduleClient scheduleClient;

    /**
    * 添加任务到延时队列中
    *
    * @param id 文章的id
    * @param publishTime 发布的时间 可以作为任务的执行时间
    */
    @Override
    @Async
    public void addNewsToTask(Integer id, Date publishTime) {

    log.info("添加任务到延迟服务中 --- begin");

    Task task = new Task();

    task.setExecuteTime(publishTime.getTime()); // 文章发布时间

    task.setTaskType(TaskTypeEnum.NEWS_SCAN_TIME.getTaskType());

    task.setPriority(TaskTypeEnum.NEWS_SCAN_TIME.getPriority());

    WmNews wmNews = new WmNews();
    wmNews.setId(id);

    task.setParameters(ProtostuffUtil.serialize(wmNews));

    scheduleClient.addTask(task);

    log.info("添加任务到延迟服务中 --- end");

    }
    }
  3. 在module模块的common包下的enum定义枚举类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Getter
    @AllArgsConstructor
    public enum TaskTypeEnum {

    NEWS_SCAN_TIME(1001, 1,"文章定时审核"),
    REMOTEERROR(1002, 2,"第三方接口调用失败,重试");
    private final int taskType; //对应具体业务
    private final int priority; //业务不同级别
    private final String desc; //描述信息
    }
  4. 修改发布文章代码,在 WmNewsServiceImpl 实现类下:

    • 把之前的异步调用修改为调用延迟任务
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
       /**
    * 发布修改文章、保存文章为草稿
    *
    * @param wmNewsDto
    * @return
    */
    @Autowired
    private WmNewsAutoScanService wmNewsAutoScanService;

    @Autowired
    private WmNewsTaskService wmNewsTaskService;

    @Override
    public ResponseResult submitNews(WmNewsDto wmNewsDto) {

    // 略

    // 审核文章
    // wmNewsAutoScanService.autoScanWmNews(wmNews.getId());
    wmNewsTaskService.addNewsToTask(wmNews.getId(), wmNews.getPublishTime());
    return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
    }





    ---



    ##### 序列化工具对比

    ---



    序列化工具对比

    - JdkSerialize:java内置的序列化能将实现了 Serilazable 接口的对象进行序列化和反序列化, ObjectOutputStream 的 writeObject() 方法可序列化对象生成字节数组
    - Protostuff:google开源的 protostuff 采用更为紧凑的二进制数组,表现更加优异,然后使用 protostuff 的编译工具生成 pojo 类

    Protostuff 需要引导依赖:

    ```xml
    <dependency>
    <groupId>io.protostuff</groupId>
    <artifactId>protostuff-core</artifactId>
    <version>1.6.0</version>
    </dependency>

    <dependency>
    <groupId>io.protostuff</groupId>
    <artifactId>protostuff-runtime</artifactId>
    <version>1.6.0</version>
    </dependency>

拉去任务 进行文章审核


流程

流程:

  1. 在 WmNewsTaskService 中添加方法

    1
    2
    3
    4
    /**
    * 消费任务,审核文章
    */
    public void scanNewsByTask();
  2. 实现类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    /**
    * 消费任务,审核文章
    */
    @Autowired
    private WmNewsAutoScanService wmNewsAutoScanService;

    @Override
    // fixedRate = 1000 表示固定的频率,每1s钟拉去一次
    @Scheduled(fixedRate = 1000)
    public void scanNewsByTask() {
    log.info("消费任务,审核文章");

    ResponseResult responseResult = scheduleClient.poll(TaskTypeEnum.NEWS_SCAN_TIME.getTaskType()
    , TaskTypeEnum.NEWS_SCAN_TIME.getPriority());

    if (responseResult.getCode().equals(AppHttpCodeEnum.SUCCESS)
    && responseResult.getData() != null) {

    Task task = JSON.parseObject(JSON.toJSONString(responseResult.getData()), Task.class);

    WmNews wmNews = ProtostuffUtil.deserialize(task.getParameters(), WmNews.class);

    wmNewsAutoScanService.autoScanWmNews(wmNews.getId());
    }
    }
  3. 在 WemediaApplication 自媒体的引导类中添加开启任务调度注解 @EnableScheduling

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @SpringBootApplication
    @EnableDiscoveryClient
    @MapperScan("com.heima.wemedia.mapper")
    @EnableFeignClients(basePackages = "com.heima.apis")
    @EnableAsync // 开启异步调用
    @EnableScheduling // 开启调度任务
    public class WemediaApplication {

    public static void main(String[] args) {
    SpringApplication.run(WemediaApplication.class, args);
    }

    @Bean
    // Mybatis-Plus的分页插件
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
    return interceptor;
    }
    }