一.项目介绍

1.项目概述

​ 随着智能手机的普及,人们更加习惯于通过手机来看新闻。由于生活节奏的加快,很多人只能利用碎片时间来获取信息,因此,对于移动资讯客户端的需求也越来越高。黑马头条项目正是在这样背景下开发出来。黑马头条项目采用当下火热的微服务+大数据技术架构实现。本项目主要着手于获取最新最热新闻资讯,通过大数据分析用户喜好精确推送咨询新闻。

image-20230810153039562

2.业务说明

功能架构图

image-20230810153700690

3.技术栈

  • 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 : 静态代码扫描工具,在项目中扫描项目代码,检查异常点、优化点、代码规范等,为开发团队提供规范统一,提升项目代码质量

技术栈

image-20230809170653756

解决方案

image-20230809170929986

二.环境搭建

1.Linxu环境的搭建

1.1 虚拟机的安装

1.解压分享的虚拟机镜像文件

image-20230811103629846

2.使用VmWare打开.vmx文件
image-20230811103800217

3.配置虚拟机的网络环境

image-20230811104150226

image-20230811104320075

4.开启虚拟机

image-20230811104523506

5.使用FinalShell连接此虚拟机

用户名: root 密码:root IP地址: 192.168.200.130

image-20230811104741394

1.2 Linux软件安装

Linux中开发环境的搭建 | The Blog (qingling.icu)

2.开发环境的配置

2.1 项目依赖的环境

  • JDK1.8

  • Intellij Idea

  • maven-3.6.1

  • Git

2.2 后端工程的搭建

image-20230811160530674

解压heima-leadnews.zip文件并用IDEA工具打开

image-20230811152915886

编码格式的设置

image-20230811154426723

三.app端功能开发

1.app登录

登录相关的表结构

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

ap_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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
package com.heima.model.user.pojos;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.io.Serializable;
import java.util.Date;

/**
* <p>
* APP用户信息表
* </p>
*
* @author itheima
*/
@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;

}

1.1 用户登录逻辑

注册加盐的过程

​ 用户在登录的时候会生成一个随机的字符串(salt),这个随机的字符串会加到密码后面然后连同密码加密存储到数据库。

image-20230811165505210

登录加盐的过程

​ 先根据账号查询是否存在该用户,如果存在的话,根据用户输入的密码和数据库中的salt进行md5加密,并和数据库中的密码比对,一致的话,比对通过,不一样的话不通过。

image-20230811165808912

1.2 用户模块搭建

heima-leadnews-service父工程依赖文件

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>heima-leadnews</artifactId>
<groupId>com.heima</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<packaging>pom</packaging>
<modules>
<module>heima-leadnews-user</module>
</modules>
<modelVersion>4.0.0</modelVersion>

<artifactId>heima-leadnews-service</artifactId>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>

<dependencies>
<!-- 数据模型子模块 -->
<dependency>
<groupId>com.heima</groupId>
<artifactId>heima-leadnews-model</artifactId>
</dependency>

<!-- 公共子模块 -->
<dependency>
<groupId>com.heima</groupId>
<artifactId>heima-leadnews-common</artifactId>
</dependency>

<!-- 远程调用子模块 -->
<dependency>
<groupId>com.heima</groupId>
<artifactId>heima-leadnews-feign-api</artifactId>
</dependency>

<!-- Spring Boot Web starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Spring Boot Test测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<!-- Nacos注册中心 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

<!-- Nacos配置中心 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

<!-- Feign远程调用客户端 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
</dependencies>

</project>
  1. 创建子模块并创建出对应的目录结构

    在heima-leadnews-service父工程下创建工程heima-leadnews-user

    image-20230812172036450

  2. 编写用户模块的配置文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    server:
    port: 51801
    spring:
    application:
    name: leadnews-user
    cloud:
    nacos:
    discovery:
    server-addr: 192.168.200.130:8848
    config:
    server-addr: 192.168.200.130:8848
    file-extension: yml
  3. 在配置中心中添加数据库等相关的配置

    image-20230812173024036

  4. 在resources目录下添加日志的配置文件

    logback.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
    <?xml version="1.0" encoding="UTF-8"?>

    <configuration>
    <!--定义日志文件的存储地址,使用绝对路径-->
    <property name="LOG_HOME" value="e:/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>

遇到的问题:

  1. 问题一:引入@EnableDiscoveryClient注解的时候爆红

    解决方案:在heima-leadnews-service父工程下加入如下的注解

    1
    2
    3
    4
    5
    <!-- Feign远程调用客户端 -->
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>

1.3 登录功能实现

1.3.1 接口定义

接口路径 /api/v1/login/login_auth
请求方式 POST
参数 LoginDto
响应结果 ResponseResult

LoginDto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Data
public class LoginDto {

/**
* 手机号
*/
@ApiModelProperty(value = "手机号",required = true)
private String phone;

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

统一返回结果类

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
package com.heima.model.common.dtos;

import com.alibaba.fastjson.JSON;
import com.heima.model.common.enums.AppHttpCodeEnum;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
* 通用的结果返回类
* @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;
}

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

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

public static ResponseResult setAppHttpCodeEnum(AppHttpCodeEnum enums){
return okResult(enums.getCode(),enums.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;
}


public static void main(String[] args) {
//前置
/*AppHttpCodeEnum success = AppHttpCodeEnum.SUCCESS;
System.out.println(success.getCode());
System.out.println(success.getErrorMessage());*/

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


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


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

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

}

}

1.3.2 登录思路分析

image-20230812180918302

1.3.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
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
package com.heima.user.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.heima.model.common.dtos.ResponseResult;
import com.heima.model.common.enums.AppHttpCodeEnum;
import com.heima.model.user.dtos.LoginDto;
import com.heima.model.user.pojos.ApUser;
import com.heima.user.mapper.ApUserMapper;
import com.heima.user.service.ApUserService;
import com.heima.utils.common.AppJwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.DigestUtils;

import java.util.HashMap;
import java.util.Map;


/**
* @author Jason Gong
* @version 1.0
* @website https://qingling.icu
* @Date 2023/8/12
* @Description
*/
@Service
@Transactional
@Slf4j
public class ApUserServiceImpl extends ServiceImpl<ApUserMapper, ApUser> implements ApUserService {
@Override
public ResponseResult login(LoginDto loginDto) {
//1 正常的登录 用户名和密码
if(StringUtils.isNotBlank(loginDto.getPhone()) && StringUtils.isNotBlank(loginDto.getPassword())) {
//1.1 根据手机号查询用户的信息
ApUser dbUser = getOne(new LambdaQueryWrapper<ApUser>().eq(ApUser::getPhone, loginDto.getPhone()));
if(dbUser == null){
return ResponseResult.errorResult(AppHttpCodeEnum.DATA_NOT_EXIST,"用户信息不存在");
}
String salt = dbUser.getSalt();
String password = loginDto.getPassword();
String pwd = DigestUtils.md5DigestAsHex((password + salt).getBytes());
//1.2 比对密码
if(!pwd.equals(dbUser.getPassword())){
return ResponseResult.errorResult(AppHttpCodeEnum.LOGIN_PASSWORD_ERROR);
}
//1.3 生成token
String token = AppJwtUtil.getToken(dbUser.getId().longValue());
Map<String,Object> map = new HashMap<>();
map.put("token",token);
dbUser.setSalt("");
dbUser.setPassword("");
map.put("user",dbUser);
return ResponseResult.okResult(map);
}else {
//2.游客登录
String token = AppJwtUtil.getToken(0L);
Map<String,Object> map = new HashMap<>();
map.put("token",token);
return ResponseResult.okResult(map);
}
}
}

1.3.4 使用接口工具测试

接口测试工具使用教程:https://qingling.icu/posts/35630.html

image-20230813165436808

2. app端网关搭建

网关的概述

image-20230814142355139

项目中搭建的网关

image-20230814142700349

2.1 搭建过程

1.在heima-leadnews-gateway导入以下依赖

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>

2.创建网关的模块

image-20230814143113564

3.创建启动类和bootstrap.yml配置文件

1
2
3
4
5
6
7
@EnableDiscoveryClient
@SpringBootApplication
public class AppGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(AppGatewayApplication.class,args);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
server:
port: 51601
spring:
application:
name: leadnews-app-gateway
cloud:
nacos:
discovery:
server-addr: 192.168.200.130:8848
config:
server-addr: 192.168.200.130:8848
file-extension: yml

4.在nacos中创建app端网关的配置

image-20230814143939252

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

5.使用Postman测试网关

http://localhost:51601/user/api/v1/login/login_auth

image-20230814144314358

2.2 全局过滤器实现jwt校验

网关的过滤流程

image-20230814144806282

思路分析:

  1. 用户进入网关开始登陆,网关过滤器进行判断,如果是登录,则路由到后台管理微服务进行登录
  2. 用户登录成功,后台管理微服务签发JWT TOKEN信息返回给用户
  3. 用户再次进入网关开始访问,网关过滤器接收用户携带的TOKEN
  4. 网关过滤器解析TOKEN ,判断是否有权限,如果有,则放行,如果没有则返回未认证错误

JWT认证的过滤器

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
/**
* @author Jason Gong
* @version 1.0
* @website https://qingling.icu
* @Date 2023/8/14
* @Description 权限认证的过滤器
*/
@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.判断是否登录
//通过请求的路径的url判断
if (request.getURI().getPath().contains("/login")) {
//放行
return chain.filter(exchange);
}
//3.获取token
String token = request.getHeaders().getFirst("token");

//4.判断token是否存在
if (StringUtils.isBlank(token)) {
//设置401的状态码
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}

//5.判断token是否有效
try {
//获取token中的数据
Claims claims = AppJwtUtil.getClaimsBody(token);
//判断token是否过期 -1:有效,0:有效,1:过期,2:过期
int res = AppJwtUtil.verifyToken(claims);
if (res == 1 || res == 2) {
//设置401的状态码
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
} catch (Exception e) {
e.printStackTrace();
//设置401的状态码
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
//6.放行
return chain.filter(exchange);
}

/**
* 优先级设置 值越小 优先值越高
*/
@Override
public int getOrder() {
return 0;
}
}

3.app前端项目集成

image-20230814151319382

通过nginx来进行配置,功能如下

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

3.1 Nginx集成前端项目步骤

①:解压资料文件夹中的压缩包nginx-1.18.0.zip

cmd切换到nginx所有的目录输入nginx启动nginx

image-20230814152058708

image-20230814152144765

②:解压资料文件夹中的前端项目app-web.zip

解压到一个没有中文的文件夹中,后面nginx配置中会指向这个目录

③:配置nginx.conf文件

在nginx安装的conf目录下新建一个文件夹leadnews.conf,在当前文件夹中新建heima-leadnews-app.conf文件

heima-leadnews-app.conf配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
upstream  heima-app-gateway{
#APP端网关所在的端口
server localhost:51601;
}

server {
listen 8801;
location / {
root C:/Gong/data/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 把里面注释的内容和静态资源配置相关删除,引入heima-leadnews-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 leadnews.conf/*.conf;
}

④ :启动nginx

​ 在nginx安装包中使用命令提示符打开,输入命令nginx启动项目

​ 可查看进程,检查nginx是否启动

​ 重新加载配置文件:nginx -s reload

⑤:打开前端项目进行测试 – > http://localhost:8801

​ 用谷歌浏览器打开,调试移动端模式进行访问

image-20230814153335349

4.app端文章列表功能

开发前app的首页面

image-20230814154205134

文章的布局展示

image-20230814154405531

4.1 数据库表的创建

文章的相关的数据库

表名称 说明
ap_article 文章信息表,存储已发布的文章
ap_article_config APP已发布文章配置表
ap_article_content APP已发布文章内容表
ap_author APP文章作者信息表
ap_collection APP收藏信息表

导入资料中的sql文件创建相关的数据库表

image-20230814160312382

关键的数据库表

文章基本信息表

image-20230814160547893

APP已发布文章配置表

image-20230814160652330

APP已发布文章内容表

image-20230814160728715

APP文章作者信息表

image-20230814160805398

APP收藏信息表

image-20230814160823771

垂直分表

将文章相关的表分成文章配置表和文章内容表和文章信息表

image-20230814161909312

垂直分表:将一个表的字段分散到多个表中,每个表存储其中一部分字段

优势:

  1. 减少IO争抢,减少锁表的几率,查看文章概述与文章详情互不影响

  2. 充分发挥高频数据的操作效率,对文章概述数据操作的高效率不会被操作文章详情数据的低效率所拖累

拆分规则:

1.把不常用的字段单独放在一张表

2.把text,blob等大字段拆分出来单独放在一张表

3.经常组合查询的字段单独放在一张表中

4.2 文章模块搭建

导入资料中的模块

image-20230815184540157

在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

image-20230815162119814

踩坑: 在导入项目的时候提示Caused by: java.io.FileNotFoundException: class path resource [com/heima/apis/article/IArticleClient.class] cannot be opened because it does not exist ,先删除这个项目,手动创建这个项目,然后复制资料里面文件到这个项目即可,直接复制整个项目可能会报这个错!

4.3 首页文章的列表显示

首页上拉和下拉的实现思路

image-20230814163019582

Sql语句实现

1
2
3
4
5
6
7
8
9
10
#按照发布时间倒序查询十条文章
select * from ap_article aa order by aa.publish_time desc limit 10
#频道筛选
select * from ap_article aa where aa.channel_id = 1 order by aa.publish_time desc limit 10
#加载首页
select * from ap_article aa where aa.channel_id = 1 and aa.publish_time < '2063-09-08 10:20:12' order by aa.publish_time desc limit 10
#加载更多
select * from ap_article aa where aa.channel_id = 1 and aa.publish_time < '2020-09-07 22:30:09' order by aa.publish_time desc limit 10
#加载最新
select * from ap_article aa where aa.channel_id = 1 and aa.publish_time > '2020-09-07 22:30:09' order by aa.publish_time desc limit 10

image-20230815160034436

4.2.1 接口定义

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

ArticleHomeDto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
}

4.2.2 实现思路

①:导入heima-leadnews-article微服务,资料在当天的文件夹中

需要在nacos中添加对应的配置

②:定义接口

接口路径、请求方式、入参、出参

③:编写mapper文件

文章表与文章配置表多表查询

④:编写业务层代码

⑤:编写控制器代码

⑥:swagger测试或前后端联调测试

4.2.3 功能的关键代码实现

mapper层的代码

image-20230816142705510

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>

service层的代码

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
package com.heima.article.service.Impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.heima.article.mapper.ApArticleMapper;
import com.heima.article.service.ApArticleService;
import com.heima.common.constants.ArticleConstants;
import com.heima.model.article.dtos.ArticleHomeDto;
import com.heima.model.article.pojos.ApArticle;
import com.heima.model.common.dtos.ResponseResult;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Date;
import java.util.List;

/**
* @author Jason Gong
* @version 1.0
* @website https://qingling.icu
* @Date 2023/8/15
* @Description
*/
@Service
@Transactional
@Slf4j
public class ApArticleServiceImpl extends ServiceImpl<ApArticleMapper, ApArticle> implements ApArticleService {
@Autowired
private ApArticleMapper apArticleMapper;

private static final Integer MAX_PAGE_SIZE = 50;

@Override
public ResponseResult load(ArticleHomeDto dto, Short type) {
//1.参数校验
//分页条数的校验
Integer size = dto.getSize();
if (size == null || size == 0) {
size = 0;
}
//最大分页条数的限制
size = Math.min(size, 50);

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

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

//时间检验
if (dto.getMinBehotTime() == null) dto.setMinBehotTime(new Date());
if (dto.getMaxBehotTime() == null) dto.setMaxBehotTime(new Date());

//2.查询
List<ApArticle> apArticles = apArticleMapper.loadArticleList(dto, type);
//3.数据返回
return ResponseResult.okResult(apArticles);
}
}

controller层的代码

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
package com.heima.article.controller.v1;

import com.heima.article.service.ApArticleService;
import com.heima.common.constants.ArticleConstants;
import com.heima.model.article.dtos.ArticleHomeDto;
import com.heima.model.common.dtos.ResponseResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

/**
* @author Jason Gong
* @version 1.0
* @website https://qingling.icu
* @Date 2023/8/15
* @Description
*/
@RestController
@RequestMapping("/api/v1/article")
public class ArticleHomeController {

@Autowired
private ApArticleService apArticleService;

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

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


/**
* 加载更新
*/
@PostMapping("/loadnew")
public ResponseResult<Object> loadnew(@RequestBody ArticleHomeDto articleHomeDto){
return apArticleService.load(articleHomeDto, ArticleConstants.LOADTYPE_LOAD_NEW);
}
}

网关中增加文章模块的路由

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

5. app端文章详情功能

5.1 需求分析

image-20230816143201242

5.2 实现方案-静态模板展示

image-20230816143544351

静态模板展示关键技术-Freemarker

Freemarker教程: https://qingling.icu/posts/29367.html

5.3 对象存储服务MinIO

MinIO 教程: https://qingling.icu/posts/36397.html

5.4 实现思路以及代码实现

image-20230818210507999

image-20230818210638036

代码实现

1.在文章模块的pom.xml文件中加入以下的依赖

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>
<dependency>
<groupId>com.heima</groupId>
<artifactId>heima-file-starter</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

2.在nacos中有关文章模块的配置中添加以下的内容

1
2
3
4
5
6
minio:
accessKey: minio
secretKey: minio123
bucket: leadnews
endpoint: http://192.168.200.130:9000
readPath: http://192.168.200.130:9000

image-20230818211310728

3.将资料中的article.ftl文件拷贝到文章模块的templates目录下,article.ftl文件内容如下

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
<!DOCTYPE html>
<html>

<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover">
<title>黑马头条</title>
<!-- 引入样式文件 -->
<link rel="stylesheet" href="https://fastly.jsdelivr.net/npm/vant@2.12.20/lib/index.css">
<!-- 页面样式 -->
<link rel="stylesheet" href="../../../plugins/css/index.css">
</head>

<body>
<div id="app">
<div class="article">
<van-row>
<van-col span="24" class="article-title" v-html="title"></van-col>
</van-row>

<van-row type="flex" align="center" class="article-header">
<van-col span="3">
<van-image round class="article-avatar" src="https://p3.pstatp.com/thumb/1480/7186611868"></van-image>
</van-col>
<van-col span="16">
<div v-html="authorName"></div>
<div>{{ publishTime | timestampToDateTime }}</div>
</van-col>
<van-col span="5">
<van-button round :icon="relation.isfollow ? '' : 'plus'" type="info" class="article-focus"
:text="relation.isfollow ? '取消关注' : '关注'" :loading="followLoading" @click="handleClickArticleFollow">
</van-button>
</van-col>
</van-row>

<van-row class="article-content">
<#if content??>
<#list content as item>
<#if item.type='text'>
<van-col span="24" class="article-text">${item.value}</van-col>
<#else>
<van-col span="24" class="article-image">
<van-image width="100%" src="${item.value}"></van-image>
</van-col>
</#if>
</#list>
</#if>
</van-row>

<van-row type="flex" justify="center" class="article-action">
<van-col>
<van-button round :icon="relation.islike ? 'good-job' : 'good-job-o'" class="article-like"
:loading="likeLoading" :text="relation.islike ? '取消赞' : '点赞'" @click="handleClickArticleLike"></van-button>
<van-button round :icon="relation.isunlike ? 'delete' : 'delete-o'" class="article-unlike"
:loading="unlikeLoading" @click="handleClickArticleUnlike">不喜欢</van-button>
</van-col>
</van-row>

<!-- 文章评论列表 -->
<van-list v-model="commentsLoading" :finished="commentsFinished" finished-text="没有更多了"
@load="onLoadArticleComments">
<van-row id="#comment-view" type="flex" class="article-comment" v-for="(item, index) in comments" :key="index">
<van-col span="3">
<van-image round src="https://p3.pstatp.com/thumb/1480/7186611868" class="article-avatar"></van-image>
</van-col>
<van-col span="21">
<van-row type="flex" align="center" justify="space-between">
<van-col class="comment-author" v-html="item.authorName"></van-col>
<van-col>
<van-button round :icon="item.operation === 0 ? 'good-job' : 'good-job-o'" size="normal"
@click="handleClickCommentLike(item)">{{ item.likes || '' }}
</van-button>
</van-col>
</van-row>

<van-row>
<van-col class="comment-content" v-html="item.content"></van-col>
</van-row>
<van-row type="flex" align="center">
<van-col span="10" class="comment-time">
{{ item.createdTime | timestampToDateTime }}
</van-col>
<van-col span="3">
<van-button round size="normal" v-html="item.reply" @click="showCommentRepliesPopup(item.id)">回复 {{
item.reply || '' }}
</van-button>
</van-col>
</van-row>
</van-col>
</van-row>
</van-list>
</div>
<!-- 文章底部栏 -->
<van-row type="flex" justify="space-around" align="center" class="article-bottom-bar">
<van-col span="13">
<van-field v-model="commentValue" placeholder="写评论">
<template #button>
<van-button icon="back-top" @click="handleSaveComment"></van-button>
</template>
</van-field>
</van-col>
<van-col span="3">
<van-button icon="comment-o" @click="handleScrollIntoCommentView"></van-button>
</van-col>
<van-col span="3">
<van-button :icon="relation.iscollection ? 'star' : 'star-o'" :loading="collectionLoading"
@click="handleClickArticleCollection"></van-button>
</van-col>
<van-col span="3">
<van-button icon="share-o"></van-button>
</van-col>
</van-row>

<!-- 评论Popup 弹出层 -->
<van-popup v-model="showPopup" closeable position="bottom"
:style="{ width: '750px', height: '60%', left: '50%', 'margin-left': '-375px' }">
<!-- 评论回复列表 -->
<van-list v-model="commentRepliesLoading" :finished="commentRepliesFinished" finished-text="没有更多了"
@load="onLoadCommentReplies">
<van-row id="#comment-reply-view" type="flex" class="article-comment-reply"
v-for="(item, index) in commentReplies" :key="index">
<van-col span="3">
<van-image round src="https://p3.pstatp.com/thumb/1480/7186611868" class="article-avatar"></van-image>
</van-col>
<van-col span="21">
<van-row type="flex" align="center" justify="space-between">
<van-col class="comment-author" v-html="item.authorName"></van-col>
<van-col>
<van-button round :icon="item.operation === 0 ? 'good-job' : 'good-job-o'" size="normal"
@click="handleClickCommentReplyLike(item)">{{ item.likes || '' }}
</van-button>
</van-col>
</van-row>

<van-row>
<van-col class="comment-content" v-html="item.content"></van-col>
</van-row>
<van-row type="flex" align="center">
<!-- TODO: js计算时间差 -->
<van-col span="10" class="comment-time">
{{ item.createdTime | timestampToDateTime }}
</van-col>
</van-row>
</van-col>
</van-row>
</van-list>
<!-- 评论回复底部栏 -->
<van-row type="flex" justify="space-around" align="center" class="comment-reply-bottom-bar">
<van-col span="13">
<van-field v-model="commentReplyValue" placeholder="写评论">
<template #button>
<van-button icon="back-top" @click="handleSaveCommentReply"></van-button>
</template>
</van-field>
</van-col>
<van-col span="3">
<van-button icon="comment-o"></van-button>
</van-col>
<van-col span="3">
<van-button icon="star-o"></van-button>
</van-col>
<van-col span="3">
<van-button icon="share-o"></van-button>
</van-col>
</van-row>
</van-popup>
</div>

<!-- 引入 Vue 和 Vant 的 JS 文件 -->
<script src=" https://fastly.jsdelivr.net/npm/vue/dist/vue.min.js">
</script>
<script src="https://fastly.jsdelivr.net/npm/vant@2.12.20/lib/vant.min.js"></script>
<!-- 引入 Axios 的 JS 文件 -->
<#--<script src="https://unpkg.com/axios/dist/axios.min.js"></script>-->
<script src="../../../plugins/js/axios.min.js"></script>
<!-- 页面逻辑 -->
<script src="../../../plugins/js/index.js"></script>
</body>

</html>

4.手动上传资料中index.js和index.css两个文件到MinIO中

上传index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
public void testMinIO(){
try {
//读取一个文件
FileInputStream inputStream = new FileInputStream("C:\\Gong\\java\\黑马头条\\day02-app端文章查看,静态化freemarker,分布式文件系统minIO\\资料\\模板文件\\plugins\\js\\index.js");
//1.获取MinIO的连接信息,创建一个minio的客户端
MinioClient minioClient = MinioClient.builder()
.credentials("minio", "minio123")//minio的账号密码
.endpoint("http://192.168.200.130:9000")//minio的地址
.build();
//2.上传
PutObjectArgs putObjectArgs = PutObjectArgs.builder()
.object("plugins/js/index.js")//文件的名称
.contentType("text/js")//文件的类型
.bucket("leadnews")//桶的名称,与之前的minio管理界面创建的bucket名称一致即可
.stream(inputStream,inputStream.available(),-1)
.build();
minioClient.putObject(putObjectArgs);
} catch (Exception e) {
e.printStackTrace();
}
}

上传index.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
public void testMinIO(){
try {
//读取一个文件
FileInputStream inputStream = new FileInputStream("C:\\Gong\\java\\黑马头条\\day02-app端文章查看,静态化freemarker,分布式文件系统minIO\\资料\\模板文件\\plugins\\css\\index.css");
//1.获取MinIO的连接信息,创建一个minio的客户端
MinioClient minioClient = MinioClient.builder()
.credentials("minio", "minio123")//minio的账号密码
.endpoint("http://192.168.200.130:9000")//minio的地址
.build();
//2.上传
PutObjectArgs putObjectArgs = PutObjectArgs.builder()
.object("plugins/css/index.css")//文件的名称
.contentType("text/css")//文件的类型
.bucket("leadnews")//桶的名称,与之前的minio管理界面创建的bucket名称一致即可
.stream(inputStream,inputStream.available(),-1)
.build();
minioClient.putObject(putObjectArgs);
} catch (Exception e) {
e.printStackTrace();
}
}

5.测试根据文章的内容生成html文件上传到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
package com.heima.article.test;

import com.alibaba.fastjson.JSONArray;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.heima.article.ArticleApplication;
import com.heima.article.mapper.ApArticleContentMapper;
import com.heima.article.service.ApArticleService;
import com.heima.file.service.FileStorageService;
import com.heima.model.article.pojos.ApArticle;
import com.heima.model.article.pojos.ApArticleContent;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import org.apache.commons.lang.StringUtils;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.util.HashMap;

/**
* @author Jason Gong
* @version 1.0
* @website https://qingling.icu
* @Date 2023/8/19
* @Description
*/
@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 IOException, TemplateException {
//已知文章的id
//1.获取文章的内容
ApArticleContent apArticleContent = apArticleContentMapper.selectOne(new LambdaQueryWrapper<ApArticleContent>().eq(ApArticleContent::getArticleId, "1383827995813531650L"));
if (apArticleContent != null && StringUtils.isNotBlank(apArticleContent.getContent())) {
//2.文章内容通过freemarker生成html文件 详细教程见:https://qingling.icu/posts/29367.html
Template template = configuration.getTemplate("article.ftl");
//构建数据模型
HashMap<String, Object> map = new HashMap<>();
map.put("content", JSONArray.parseArray(apArticleContent.getContent()));
//输出流
StringWriter out = new StringWriter();
//合成html文件
template.process(map, out);
//3.把html文件上传到minio中
//构建一个输入流
InputStream in = new ByteArrayInputStream(out.toString().getBytes());
//上传到minio并返回访问的路径
String path = fileStorageService.uploadHtmlFile("", apArticleContent.getArticleId() + ".html", in);
System.out.println("文件在minio中的路径:"+path);
//4.修改ap_article表,保存static_url字段
ApArticle apArticle = new ApArticle();
apArticle.setId(apArticleContent.getArticleId());
apArticle.setStaticUrl(path);
boolean isSuccess = apArticleService.updateById(apArticle);
System.out.println(isSuccess ? "文件上传成功" : "文件上传失败");
}

}
}

6.实现效果

image-20230819153507360

四.自媒体端功能开发

1.后端环境搭建

需要搭建的模块

image-20230819155333131

搭建步骤

image-20230819155229550

1.创建自媒体模块的数据库

image-20230819155853252

2.导入相应的工程文件

image-20230819161650118

3.配置自媒体模块和网关模块在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_wemedia?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.media.pojos

image-20230819161148830

网关模块的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]': # 匹配所有请求
allowedOrigins: "*" #跨域处理 允许所有的域
allowedMethods: # 支持的方法
- GET
- POST
- PUT
- DELETE
routes:
# 平台管理
- id: wemedia
uri: lb://leadnews-wemedia
predicates:
- Path=/wemedia/**
filters:
- StripPrefix= 1

image-20230819161529210

2.前端环境搭建

搭建思路

image-20230819162116240

搭建步骤

image-20230819162230133

heima-leadnews-wemedia配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
upstream  heima-wemedia-gateway{
#APP端网关所在的端口
server localhost:51602;
}

server {
listen 8802;
location / {
root C:/Gong/data/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; #记录代理信息
}
}

重启nginx访问

image-20230902145814017

3.自媒体素材管理功能

3.1 素材管理-图片上传

图片素材相关的实体类

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
package com.heima.model.wemedia.pojos;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.io.Serializable;
import java.util.Date;

/**
* <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;

}

3.1.1 解决图片素材实体类中获取图片userId的问题

实现思路

image-20230902154806019

1.token解析为用户存入header

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
package com.heima.wemedia.gateway.filter;


import com.heima.wemedia.gateway.util.AppJwtUtil;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@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中
ServerHttpRequest serverHttpRequest = request.mutate().headers(httpHeaders -> {
httpHeaders.add("userId", userId + "");
}).build();
//重置请求
exchange.mutate().request(serverHttpRequest);

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

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

/**
* 优先级设置 值越小 优先级越高
* @return
*/
@Override
public int getOrder() {
return 0;
}
}

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
package com.heima.wemedia.interceptor;

import com.heima.model.wemedia.pojos.WmUser;
import com.heima.utils.thread.WmThreadLocalUtil;
import io.swagger.models.auth.In;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Enumeration;

/**
* @author Jason Gong
* @version 1.0
* @website https://qingling.icu
* @Date 2023/9/2
* @Description
*/
public class WmTokenInterceptor implements HandlerInterceptor {

/**
* 获取header中的信息 存入当前的线程中
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取header中的userId信息
String userId = request.getHeader("userId");
//判断userId是否为空
if(userId != null){
//将userId存入当前的线程中,我们可以在任何位置获取
WmUser wmUser = new WmUser();
wmUser.setId(Integer.valueOf(userId));
WmThreadLocalUtil.setUser(wmUser);
}
return true;
}


/**
* 清理线程中的数据
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
WmThreadLocalUtil.clear();
}
}

ThreadLocal工具类,实现在线程中存储、获取、清理用户信息

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
package com.heima.utils.thread;

import com.heima.model.wemedia.pojos.WmUser;

public class WmThreadLocalUtil {

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

/**
* 添加用户
* @param wmUser
*/
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();
}
}

3.让拦截器生效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.heima.wemedia.config;

import com.heima.wemedia.interceptor.WmTokenInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
* @author Jason Gong
* @version 1.0
* @website https://qingling.icu
* @Date 2023/9/2
* @Description
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new WmTokenInterceptor()).addPathPatterns("/**");
}
}

3.1.2 图片上传接口的定义

说明
接口路径 /api/v1/material/upload_picture
请求方式 POST
参数 MultipartFile
响应结果 ResponseResult

3.1.3 代码实现

1.在pom.xml中引入自定义minio的starter依赖

1
2
3
4
5
<dependency>
<groupId>com.heima</groupId>
<artifactId>heima-file-starter</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

2.在项目中添加minio的配置(在nacos中配置)

1
2
3
4
5
6
minio:
accessKey: minio
secretKey: minio123
bucket: leadnews
endpoint: http://192.168.200.130:9000
readPath: http://192.168.200.130:9000

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
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
package com.heima.wemedia.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.heima.file.service.FileStorageService;
import com.heima.model.common.dtos.ResponseResult;
import com.heima.model.common.enums.AppHttpCodeEnum;
import com.heima.model.wemedia.pojos.WmMaterial;
import com.heima.utils.thread.WmThreadLocalUtil;
import com.heima.wemedia.mapper.WmMaterialMapper;
import com.heima.wemedia.service.WmMaterialService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.Date;
import java.util.UUID;


@Slf4j
@Service
@Transactional
public class WmMaterialServiceImpl extends ServiceImpl<WmMaterialMapper, WmMaterial> implements WmMaterialService {

@Autowired
private FileStorageService fileStorageService; //使用minio

@Override
public ResponseResult uploadPicture(MultipartFile multipartFile) {
//1.检查参数
if(multipartFile == null || multipartFile.getSize() == 0){
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
//2.上传图片到minio中
String fileName = UUID.randomUUID().toString().replace("-", "");
String originalFilename = multipartFile.getOriginalFilename();
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) {
log.info("WmMaterialServiceImpl-上传图片失败");
e.printStackTrace();
}
//3.将图片的信息保存在数据库中
WmMaterial wmMaterial = new WmMaterial();
wmMaterial.setUserId(WmThreadLocalUtil.getUser().getId());
wmMaterial.setUrl(fileId);
wmMaterial.setType((short)0);
wmMaterial.setIsCollection((short)0);
wmMaterial.setCreatedTime(new Date());
save(wmMaterial);
//4.返回参数
return ResponseResult.okResult(wmMaterial);
}
}

4.测试上传功能

image-20230902220022201

3.2 素材管理-图片列表

接口定义

接口路径 /api//v1/material/list
请求方式 POST
参数 WmMaterialDto
响应结果 ResponseResult

请求参数的DTO

1
2
3
4
5
6
7
8
9
@Data
public class WmMaterialDto extends PageRequestDto {

/**
* 1 收藏
* 0 未收藏
*/
private Short isCollection;
}

关键代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 素材图片的列表显示
*/
@Override
public ResponseResult findList(WmMaterialDto wmMaterialDto) {
//1.检查参数
wmMaterialDto.checkParam();
//2.分页查询
IPage page = new Page(wmMaterialDto.getPage(),wmMaterialDto.getSize());
LambdaQueryWrapper<WmMaterial> queryWrapper = new LambdaQueryWrapper<>();
//是否收藏
if(wmMaterialDto.getIsCollection() != null && wmMaterialDto.getIsCollection() == 1){
queryWrapper.eq(WmMaterial::getIsCollection,wmMaterialDto.getIsCollection());
}
//按照用户查询
queryWrapper.eq(WmMaterial::getUserId,WmThreadLocalUtil.getUser().getId());
//按照时间查询
queryWrapper.orderByDesc(WmMaterial::getCreatedTime);
page = page(page,queryWrapper);
//3.结果返回
ResponseResult responseResult = new PageResponseResult(wmMaterialDto.getPage(), wmMaterialDto.getSize(), (int) page.getTotal());
responseResult.setData(page.getRecords());
return responseResult;
}

MP的配置

1
2
3
4
5
6
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}

实现效果

image-20230903125528872

4.自媒体文章管理功能

4.1 频道列表查询

频道对应的实体类

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
package com.heima.model.wemedia.pojos;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.io.Serializable;
import java.util.Date;

/**
* <p>
* 频道信息表
* </p>
*
* @author itheima
*/
@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;
}

接口定义

说明
接口路径 /api/v1/channel/channels
请求方式 GET
参数
响应结果 ResponseResult

关键代码

1
2
3
4
5
6
7
8
/**
* 查询所有的频道信息
*/
@GetMapping("/channels")
public ResponseResult findAllChannels(){
List<WmChannel> channels = wmChannelService.list();
return ResponseResult.okResult(channels);
}

实现效果

image-20230903151216766

4.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
package com.heima.model.wemedia.pojos;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import org.apache.ibatis.type.Alias;

import java.io.Serializable;
import java.util.Date;

/**
* <p>
* 自媒体图文内容信息表
* </p>
*
* @author itheima
*/
@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;
}
}

}

接口定义

接口路径 /api//v1/news/list
请求方式 POST
参数 WmNewsPageReqDto
响应结果 ResponseResult

关键代码

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
@Override
public ResponseResult findNewsList(WmNewsPageReqDto wmNewsPageReqDto) {
//1.检查参数
wmNewsPageReqDto.checkParam();
//2.根据条件查询
IPage page = new Page(wmNewsPageReqDto.getPage(), wmNewsPageReqDto.getSize());
LambdaQueryWrapper<WmNews> queryWrapper = new LambdaQueryWrapper<>();
//状态
if(wmNewsPageReqDto.getStatus() != null){
queryWrapper.eq(WmNews::getStatus,wmNewsPageReqDto.getStatus());
}
//开始时间 结束时间
if(wmNewsPageReqDto.getBeginPubDate() != null && wmNewsPageReqDto.getEndPubDate()!=null){
queryWrapper.between(WmNews::getPublishTime,wmNewsPageReqDto.getBeginPubDate(),wmNewsPageReqDto.getEndPubDate());
}
//所属频道id
if(wmNewsPageReqDto.getChannelId() != null){
queryWrapper.eq(WmNews::getChannelId,wmNewsPageReqDto.getChannelId());
}
//关键字
if(wmNewsPageReqDto.getKeyword() != null){
queryWrapper.like(WmNews::getTitle,wmNewsPageReqDto.getKeyword());
}
//查询当前登录人的文章
queryWrapper.eq(WmNews::getUserId, WmThreadLocalUtil.getUser().getId());
//按照发布时间倒序查询
queryWrapper.orderByDesc(WmNews::getPublishTime);
page = page(page,queryWrapper);
//3.结果返回
ResponseResult responseResult = new PageResponseResult(wmNewsPageReqDto.getPage(), wmNewsPageReqDto.getSize(), (int) page.getTotal());
responseResult.setData(page.getRecords());
return responseResult;
}

实现效果

image-20230903185956428

4.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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package com.heima.model.wemedia.pojos;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.io.Serializable;

/**
* <p>
* 自媒体图文引用素材信息表
* </p>
*
* @author itheima
*/
@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;

}

实现流程

image-20230904151910210

接口定义

说明
接口路径 /api/v1/news/submit
请求方式 POST
参数 WmNewsDto
响应结果 ResponseResult

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
43
44
45
46
47
48
49
package com.heima.model.wemedia.dtos;

import lombok.Data;

import java.util.Date;
import java.util.List;

@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;
}

前端传递的json格式数据举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"title":"黑马头条项目背景",
"type":"1",//这个 0 是无图 1 是单图 3 是多图 -1 是自动
"labels":"黑马头条",
"publishTime":"2020-03-14T11:35:49.000Z",
"channelId":1,
"images":[
"http://192.168.200.130/group1/M00/00/00/wKjIgl5swbGATaSAAAEPfZfx6Iw790.png"
],
"status":1,
"content":"[
{
"type":"text",
"value":"随着智能手机的普及,人们更加习惯于通过手机来看新闻。由于生活节奏的加快,很多人只能利用碎片时间来获取信息,因此,对于移动资讯客户端的需求也越来越高。黑马头条项目正是在这样背景下开发出来。黑马头条项目采用当下火热的微服务+大数据技术架构实现。本项目主要着手于获取最新最热新闻资讯,通过大数据分析用户喜好精确推送咨询新闻"
},
{
"type":"image",
"value":"http://192.168.200.130/group1/M00/00/00/wKjIgl5swbGATaSAAAEPfZfx6Iw790.png"
}
]"
}

保存文章和素材对应关系mapper接口

mapper接口

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


/**
* 批量保存文章和素材之间的关系
* @param materialIds 素材的id
* @param newsId 文章的id
* @param type 类型
*/
void saveRelations(@Param("materialIds") List<Integer> materialIds,@Param("newsId") Integer newsId, @Param("type")Short type);
}

mapper.xml文件

1
2
3
4
5
6
7
8
9
10
11
12
13
<?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>

使用到的常量类

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

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;
}

发布文章功能的关键代码

存在许多的bug

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
package com.heima.wemedia.service.impl;


import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.heima.common.constants.WemediaConstants;
import com.heima.common.exception.CustomException;
import com.heima.model.common.dtos.PageResponseResult;
import com.heima.model.common.dtos.ResponseResult;
import com.heima.model.common.enums.AppHttpCodeEnum;
import com.heima.model.wemedia.dtos.WmNewsDto;
import com.heima.model.wemedia.dtos.WmNewsPageReqDto;
import com.heima.model.wemedia.pojos.WmMaterial;
import com.heima.model.wemedia.pojos.WmNews;
import com.heima.model.wemedia.pojos.WmNewsMaterial;
import com.heima.utils.thread.WmThreadLocalUtil;
import com.heima.wemedia.mapper.WmMaterialMapper;
import com.heima.wemedia.mapper.WmNewsMapper;
import com.heima.wemedia.mapper.WmNewsMaterialMapper;
import com.heima.wemedia.service.WmNewsService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.apache.jute.compiler.JString;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Service
@Slf4j
@Transactional
public class WmNewsServiceImpl extends ServiceImpl<WmNewsMapper, WmNews> implements WmNewsService {

@Autowired
private WmNewsMaterialMapper wmNewsMaterialMapper;
@Autowired
private WmMaterialMapper wmMaterialMapper;


@Override
public ResponseResult findNewsList(WmNewsPageReqDto wmNewsPageReqDto) {
//1.检查参数
wmNewsPageReqDto.checkParam();
//2.根据条件查询
IPage page = new Page(wmNewsPageReqDto.getPage(), wmNewsPageReqDto.getSize());
LambdaQueryWrapper<WmNews> queryWrapper = new LambdaQueryWrapper<>();
//状态
if (wmNewsPageReqDto.getStatus() != null) {
queryWrapper.eq(WmNews::getStatus, wmNewsPageReqDto.getStatus());
}
//开始时间 结束时间
if (wmNewsPageReqDto.getBeginPubDate() != null && wmNewsPageReqDto.getEndPubDate() != null) {
queryWrapper.between(WmNews::getPublishTime, wmNewsPageReqDto.getBeginPubDate(), wmNewsPageReqDto.getEndPubDate());
}
//所属频道id
if (wmNewsPageReqDto.getChannelId() != null) {
queryWrapper.eq(WmNews::getChannelId, wmNewsPageReqDto.getChannelId());
}
//关键字
if (wmNewsPageReqDto.getKeyword() != null) {
queryWrapper.like(WmNews::getTitle, wmNewsPageReqDto.getKeyword());
}
//查询当前登录人的文章
queryWrapper.eq(WmNews::getUserId, WmThreadLocalUtil.getUser().getId());
//按照发布时间倒序查询
queryWrapper.orderByDesc(WmNews::getPublishTime);
page = page(page, queryWrapper);
//3.结果返回
ResponseResult responseResult = new PageResponseResult(wmNewsPageReqDto.getPage(), wmNewsPageReqDto.getSize(), (int) page.getTotal());
responseResult.setData(page.getRecords());
return responseResult;
}


@Override
public ResponseResult submitNews(WmNewsDto wmNewsDto) {
//参数校验
if (wmNewsDto == null || wmNewsDto.getContent() == null) {
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
//保存或者修改文章
WmNews wmNews = new WmNews();
BeanUtils.copyProperties(wmNewsDto, wmNews);
//封面图片从list集合转化成字符串(以,作为分割符号)
if (wmNewsDto.getImages() != null && wmNewsDto.getImages().size() > 0) {
String imageStr = StringUtils.join(wmNewsDto.getImages(), ",");
wmNews.setImages(imageStr);
}
//设置封面的类型(数据库中封面的类型是无符号的,无法使用-1表示,所以我们先置空)
if (wmNewsDto.getType().equals(WemediaConstants.WM_NEWS_TYPE_AUTO)) {
wmNews.setType(null);
}
//保存或者修改文章的方法
saveOrUpdateWmNews(wmNews);
//判断是否为草稿,如果是草稿就退出该方法,草稿是不保存文章与素材的关系的
if (wmNewsDto.getStatus().equals(WmNews.Status.NORMAL.getCode())) {
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}
//不是草稿,保存文章内容与素材的关系
//获取文章内容中的图片信息
List<String> materials = getUrlInfo(wmNewsDto.getContent());
//保存文章和素材的关系
saveRelativeInfoContent(materials,wmNews.getId());
//文章封面图片与素材的关系,布局是自动的,需要自动匹配图片
saveRelativeInfoForCover(wmNewsDto,wmNews,materials);
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}

/**
* 保存文章封面图片与素材的关系,布局是自动的,需要自动匹配图片
* 1.如果内容图片大于等于1 小于3 单图 type 1
* 2.如果内容图片大于3 多图 type 3
* 3.如果内容没有图片 无图 type 0
*/
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 同一篇文章的所有素材图片url
* @param newsId 文章的id
*/
private void saveRelativeInfoContent(List<String> materials, Integer newsId) {
saveRelativeInfo(materials,newsId,WemediaConstants.WM_CONTENT_REFERENCE);
}

/**
* 保存文章图片与素材的关系到数据库中
* @param materials 同一篇文章中所有的素材图片
* @param newsId 文章的id
*/
private void saveRelativeInfo(List<String> materials, Integer newsId, Short type) {
if (materials != null && !materials.isEmpty()){
//根据图片的url查询素材的id
LambdaQueryWrapper<WmMaterial> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.in(WmMaterial::getUrl,materials);
List<WmMaterial> wmMaterials = wmMaterialMapper.selectList(queryWrapper);
//判断素材是否被删除
if (wmMaterials == null || wmMaterials.size() == 0){
//手动的抛出异常,上面的操作可以回滚
throw new CustomException(AppHttpCodeEnum.MATERIASL_REFERENCE);
}
if (materials.size() != wmMaterials.size()){
throw new CustomException(AppHttpCodeEnum.MATERIASL_REFERENCE);
}
List<Integer> idList = wmMaterials.stream().map(WmMaterial::getId).collect(Collectors.toList());
wmNewsMaterialMapper.saveRelations(idList,newsId,type);
}
}

/**
* 提取文章内容中的图片信息
* @param content 文章的内容信息
* @return 图片url组成的集合
*/
private List<String> getUrlInfo(String content) {
ArrayList<String> materials = new ArrayList<>();
List<Map> maps = JSON.parseArray(content, Map.class);
for (Map map : maps) {
if (map.get("type").equals("image")){
String imgUrl = (String) map.get("value");
materials.add(imgUrl);
}
}
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 {
//修改操作
//删除文章与素材的关联关系
LambdaQueryWrapper<WmNewsMaterial> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(WmNewsMaterial::getNewsId, wmNews.getId());
wmNewsMaterialMapper.delete(queryWrapper);
updateById(wmNews);
}
}
}

image-20231029220319010

4.4 文章的审核功能(未实现)

4.4.1 文章审核功能介绍

调用第三方的接口(阿里云内容安全审核接口)实现审核功能

功能介绍

image-20231029220622019

image-20231029220707636

审核流程

image-20231029221745020

1 自媒体端发布文章后,开始审核文章

2 审核的主要是审核文章的内容(文本内容和图片)

3 借助第三方提供的接口审核文本

4 借助第三方提供的接口审核图片,由于图片存储到minIO中,需要先下载才能审核

5 如果审核失败,则需要修改自媒体文章的状态,status:2 审核失败 status:3 转到人工审核

6 如果审核成功,则需要在文章微服务中创建app端需要的文章

4.4.2 调用第三方的审核接口

第三方审核接口

1.内容安全接口介绍:

内容安全是识别服务,支持对图片、视频、文本、语音等对象进行多样化场景检测,有效降低内容违规风险。目前很多平台都支持内容检测,如阿里云、腾讯云、百度AI、网易云等国内大型互联网公司都对外提供了API。

2.文件检测和图片检测api文档

文本垃圾内容Java SDK: https://help.aliyun.com/document_detail/53427.html?spm=a2c4g.11186623.6.717.466d7544QbU8Lr

图片垃圾内容Java SDK: https://help.aliyun.com/document_detail/53424.html?spm=a2c4g.11186623.6.715.c8f69b12ey35j4

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

1.依赖导入

1
2
3
4
5
6
7
8
<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.相关的工具类

GreenImageScan(图片审核的工具类)

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
package com.heima.common.aliyun;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.green.model.v20180509.ImageSyncScanRequest;
import com.aliyuncs.http.FormatType;
import com.aliyuncs.http.HttpResponse;
import com.aliyuncs.http.MethodType;
import com.aliyuncs.http.ProtocolType;
import com.aliyuncs.profile.DefaultProfile;
import com.aliyuncs.profile.IClientProfile;
import com.heima.common.aliyun.util.ClientUploader;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Component;

import java.util.*;

@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "aliyun")
public class GreenImageScan {

private String accessKeyId;
private String secret;
private String scenes;

public Map imageScan(List<byte[]> imageList) throws Exception {
IClientProfile profile = DefaultProfile
.getProfile("cn-shanghai", accessKeyId, secret);
DefaultProfile
.addEndpoint("cn-shanghai", "cn-shanghai", "Green", "green.cn-shanghai.aliyuncs.com");
IAcsClient client = new DefaultAcsClient(profile);
ImageSyncScanRequest imageSyncScanRequest = new ImageSyncScanRequest();
// 指定api返回格式
imageSyncScanRequest.setAcceptFormat(FormatType.JSON);
// 指定请求方法
imageSyncScanRequest.setMethod(MethodType.POST);
imageSyncScanRequest.setEncoding("utf-8");
//支持http和https
imageSyncScanRequest.setProtocol(ProtocolType.HTTP);
JSONObject httpBody = new JSONObject();
/**
* 设置要检测的场景, 计费是按照该处传递的场景进行
* 一次请求中可以同时检测多张图片,每张图片可以同时检测多个风险场景,计费按照场景计算
* 例如:检测2张图片,场景传递porn、terrorism,计费会按照2张图片鉴黄,2张图片暴恐检测计算
* porn: porn表示色情场景检测
*/

httpBody.put("scenes", Arrays.asList(scenes.split(",")));

/**
* 如果您要检测的文件存于本地服务器上,可以通过下述代码片生成url
* 再将返回的url作为图片地址传递到服务端进行检测
*/
/**
* 设置待检测图片, 一张图片一个task
* 多张图片同时检测时,处理的时间由最后一个处理完的图片决定
* 通常情况下批量检测的平均rt比单张检测的要长, 一次批量提交的图片数越多,rt被拉长的概率越高
* 这里以单张图片检测作为示例, 如果是批量图片检测,请自行构建多个task
*/
ClientUploader clientUploader = ClientUploader.getImageClientUploader(profile, false);
String url = null;
List<JSONObject> urlList = new ArrayList<JSONObject>();
for (byte[] bytes : imageList) {
url = clientUploader.uploadBytes(bytes);
JSONObject task = new JSONObject();
task.put("dataId", UUID.randomUUID().toString());
//设置图片链接为上传后的url
task.put("url", url);
task.put("time", new Date());
urlList.add(task);
}
httpBody.put("tasks", urlList);
imageSyncScanRequest.setHttpContent(org.apache.commons.codec.binary.StringUtils.getBytesUtf8(httpBody.toJSONString()),
"UTF-8", FormatType.JSON);
/**
* 请设置超时时间, 服务端全链路处理超时时间为10秒,请做相应设置
* 如果您设置的ReadTimeout小于服务端处理的时间,程序中会获得一个read timeout异常
*/
imageSyncScanRequest.setConnectTimeout(3000);
imageSyncScanRequest.setReadTimeout(10000);
HttpResponse httpResponse = null;
try {
httpResponse = client.doAction(imageSyncScanRequest);
} catch (Exception e) {
e.printStackTrace();
}

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

//服务端接收到请求,并完成处理返回的结果
if (httpResponse != null && httpResponse.isSuccess()) {
JSONObject scrResponse = JSON.parseObject(org.apache.commons.codec.binary.StringUtils.newStringUtf8(httpResponse.getHttpContent()));
System.out.println(JSON.toJSONString(scrResponse, true));
int requestCode = scrResponse.getIntValue("code");
//每一张图片的检测结果
JSONArray taskResults = scrResponse.getJSONArray("data");
if (200 == requestCode) {
for (Object taskResult : taskResults) {
//单张图片的处理结果
int taskCode = ((JSONObject) taskResult).getIntValue("code");
//图片要检测的场景的处理结果, 如果是多个场景,则会有每个场景的结果
JSONArray sceneResults = ((JSONObject) taskResult).getJSONArray("results");
if (200 == taskCode) {
for (Object sceneResult : sceneResults) {
String scene = ((JSONObject) sceneResult).getString("scene");
String label = ((JSONObject) sceneResult).getString("label");
String suggestion = ((JSONObject) sceneResult).getString("suggestion");
//根据scene和suggetion做相关处理
//do something
System.out.println("scene = [" + scene + "]");
System.out.println("suggestion = [" + suggestion + "]");
System.out.println("suggestion = [" + label + "]");
if (!suggestion.equals("pass")) {
resultMap.put("suggestion", suggestion);
resultMap.put("label", label);
return resultMap;
}
}

} else {
//单张图片处理失败, 原因视具体的情况详细分析
System.out.println("task process fail. task response:" + JSON.toJSONString(taskResult));
return null;
}
}
resultMap.put("suggestion","pass");
return resultMap;
} else {
/**
* 表明请求整体处理失败,原因视具体的情况详细分析
*/
System.out.println("the whole image scan request failed. response:" + JSON.toJSONString(scrResponse));
return null;
}
}
return null;
}
}

GreenTextScan(文字审核的工具类)

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
package com.heima.common.aliyun;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.exceptions.ServerException;
import com.aliyuncs.green.model.v20180509.TextScanRequest;
import com.aliyuncs.http.FormatType;
import com.aliyuncs.http.HttpResponse;
import com.aliyuncs.profile.DefaultProfile;
import com.aliyuncs.profile.IClientProfile;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Component;

import java.util.*;

@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "aliyun")
public class GreenTextScan {

private String accessKeyId;
private String secret;

public Map greeTextScan(String content) throws Exception {
System.out.println(accessKeyId);
IClientProfile profile = DefaultProfile
.getProfile("cn-shanghai", accessKeyId, secret);
DefaultProfile.addEndpoint("cn-shanghai", "cn-shanghai", "Green", "green.cn-shanghai.aliyuncs.com");
IAcsClient client = new DefaultAcsClient(profile);
TextScanRequest textScanRequest = new TextScanRequest();
textScanRequest.setAcceptFormat(FormatType.JSON); // 指定api返回格式
textScanRequest.setHttpContentType(FormatType.JSON);
textScanRequest.setMethod(com.aliyuncs.http.MethodType.POST); // 指定请求方法
textScanRequest.setEncoding("UTF-8");
textScanRequest.setRegionId("cn-shanghai");
List<Map<String, Object>> tasks = new ArrayList<Map<String, Object>>();
Map<String, Object> task1 = new LinkedHashMap<String, Object>();
task1.put("dataId", UUID.randomUUID().toString());
/**
* 待检测的文本,长度不超过10000个字符
*/
task1.put("content", content);
tasks.add(task1);
JSONObject data = new JSONObject();

/**
* 检测场景,文本垃圾检测传递:antispam
**/
data.put("scenes", Arrays.asList("antispam"));
data.put("tasks", tasks);
System.out.println(JSON.toJSONString(data, true));
textScanRequest.setHttpContent(data.toJSONString().getBytes("UTF-8"), "UTF-8", FormatType.JSON);
// 请务必设置超时时间
textScanRequest.setConnectTimeout(3000);
textScanRequest.setReadTimeout(6000);

Map<String, String> resultMap = new HashMap<>();
try {
HttpResponse httpResponse = client.doAction(textScanRequest);
if (httpResponse.isSuccess()) {
JSONObject scrResponse = JSON.parseObject(new String(httpResponse.getHttpContent(), "UTF-8"));
System.out.println(JSON.toJSONString(scrResponse, true));
if (200 == scrResponse.getInteger("code")) {
JSONArray taskResults = scrResponse.getJSONArray("data");
for (Object taskResult : taskResults) {
if (200 == ((JSONObject) taskResult).getInteger("code")) {
JSONArray sceneResults = ((JSONObject) taskResult).getJSONArray("results");
for (Object sceneResult : sceneResults) {
String scene = ((JSONObject) sceneResult).getString("scene");
String label = ((JSONObject) sceneResult).getString("label");
String suggestion = ((JSONObject) sceneResult).getString("suggestion");
System.out.println("suggestion = [" + label + "]");
if (!suggestion.equals("pass")) {
resultMap.put("suggestion", suggestion);
resultMap.put("label", label);
return resultMap;
}

}
} else {
return null;
}
}
resultMap.put("suggestion", "pass");
return resultMap;
} else {
return null;
}
} else {
return null;
}
} catch (ServerException e) {
e.printStackTrace();
} catch (ClientException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

}

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

1
2
3
4
5
aliyun:
accessKeyId: xxxxxxxxxxxxxxxxxxx
secret: xxxxxxxxxxxxxxxxxxxxxxxx
#aliyun.scenes=porn,terrorism,ad,qrcode,live,logo
scenes: terrorism #检测的场景

4.编写测试类测试

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
import com.heima.common.aliyun.GreenImageScan;
import com.heima.common.aliyun.GreenTextScan;
import com.heima.file.service.FileStorageService;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.Arrays;
import java.util.Map;

@SpringBootTest(classes = WemediaApplication.class)
@RunWith(SpringRunner.class)
public class AliyunTest {

@Autowired
private GreenTextScan greenTextScan;

@Autowired
private GreenImageScan greenImageScan;

@Autowired
private FileStorageService fileStorageService;

@Test
public void testScanText() throws Exception {
Map map = greenTextScan.greeTextScan("我是一个好人,冰毒");
System.out.println(map);
}

@Test
public void testScanImage() throws Exception {
byte[] bytes = fileStorageService.downLoadFile("http://192.168.200.130:9000/leadnews/2021/04/26/ef3cbe458db249f7bd6fb4339e593e55.jpg");
Map map = greenImageScan.imageScan(Arrays.asList(bytes));
System.out.println(map);
}
}

4.4.3 分布式ID的实现

为什么使用分布式ID

image-20231116230439706

分布式ID的技术选型

image-20231116230714014

雪花算法的介绍

image-20231116230953909

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

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

1
2
@TableId(value = "id",type = IdType.ID_WORKER)
private Long id;

第二:在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

datacenter-id:数据中心id(取值范围:0-31) workerId:机器id(取值范围:0-31)

4.4.4 审核功能的具体实现

由于没有阿里云相关的ak和sk,所以本部分默认每篇文章的文字和图片都审核通过(中间会注释掉调用第三方审核接口的代码)

image-20231202174634986

service层代码实现

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
package com.heima.wemedia.service.impl;

import com.alibaba.fastjson.JSON;
import com.heima.apis.article.IArticleClient;
import com.heima.common.aliyun.GreenImageScan;
import com.heima.common.aliyun.GreenTextScan;
import com.heima.file.service.FileStorageService;
import com.heima.model.article.dtos.ArticleDto;
import com.heima.model.common.dtos.ResponseResult;
import com.heima.model.wemedia.pojos.WmChannel;
import com.heima.model.wemedia.pojos.WmNews;
import com.heima.model.wemedia.pojos.WmUser;
import com.heima.wemedia.mapper.WmChannelMapper;
import com.heima.wemedia.mapper.WmNewsMapper;
import com.heima.wemedia.mapper.WmUserMapper;
import com.heima.wemedia.service.WmNewsAutoScanService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.*;
import java.util.stream.Collectors;

/**
* @author Jason Gong
* @version 1.0
* @website https://qingling.icu
* @Date 2023/11/19
* @Description
*/
@Service
@Slf4j
@Transactional
public class WmNewsAutoScanServiceImpl implements WmNewsAutoScanService {

//涉及文本内容的审核,需要调用第三方的服务,这里我们直接设置成测试环境,跳过调用第三方服务校验的过程
@Value("${check.env}")
private String environment;

@Autowired
private WmNewsMapper wmNewsMapper;
@Autowired
private GreenTextScan greenTextScan;
@Autowired
private GreenImageScan greenImageScan;
@Autowired
private FileStorageService fileStorageService;
@Autowired
private IArticleClient iArticleClient;
@Autowired
private WmChannelMapper wmChannelMapper;
@Autowired
private WmUserMapper wmUserMapper;

@Override
public void autoScanWmNews(Integer id) {
//1.查询自媒体文章的信息
WmNews wmNews = wmNewsMapper.selectById(id);
//文章不存在的话,直接抛出异常
if (wmNews == null) {
throw new RuntimeException("文章不存在,无法审核!");
}
//判断文章是不是待审核的状态
if (wmNews.getStatus().equals(WmNews.Status.SUBMIT.getCode())) {
//2.抽取文章内容中的文字和图片信息
Map<String, Object> textAndImages = getTextAndImages(wmNews);
//3.调用阿里云的接口实现文章的审核功能
//审核文本内容
Boolean isPassText = checkText((String) textAndImages.get("content"), wmNews);
if (!isPassText) return;
//审核图片内容
Boolean isPassImage = checkImages((List<String>) textAndImages.get("images"), wmNews);
if (!isPassImage) return;
//4.审核成功,保存app端的相关文章数据
ResponseResult responseResult = saveAppArticle(wmNews);
if (!responseResult.getCode().equals(200)) {
throw new RuntimeException("文章审核-保存app端文章失败");
}
//回填article_id
wmNews.setArticleId((Long) responseResult.getData());
updateWmNews(wmNews, (short) 9, "审核成功");
}
}

/**
* 保存app端的相关文章数据
*
* @param wmNews 文章相关的信息
*/
private ResponseResult saveAppArticle(WmNews wmNews) {
ArticleDto dto = new ArticleDto();
BeanUtils.copyProperties(wmNews, dto);
//文章布局
dto.setLayout(wmNews.getType());
//频道
WmChannel wmChannel = wmChannelMapper.selectById(wmNews.getChannelId());
if (wmChannel != null) {
dto.setChannelName(wmChannel.getName());
}
//作者
dto.setAuthorId(wmNews.getUserId().longValue());
WmUser wmUser = wmUserMapper.selectById(wmNews.getUserId());
if (wmUser != null) {
dto.setAuthorName(wmUser.getName());
}
//设置文章的id
if (wmNews.getArticleId() != null) {
dto.setId(wmNews.getArticleId());
}
//设置创建时间
dto.setCreatedTime(new Date());
return iArticleClient.saveArticle(dto);
}

/**
* 审核文章的图片内容
*
* @param images 文章的图片信息
* @param wmNews 文章实体
* @return 是否通过校验
*/
private Boolean checkImages(List<String> images, WmNews wmNews) {
boolean flag = true;
if (images == null || images.size() == 0 || "test".equals(environment)) {
return true;
}
//下载图片
//去重
images = images.stream().distinct().collect(Collectors.toList());
//创建集合,用于存储下载的图片
ArrayList<byte[]> byteList = new ArrayList<>();
for (String image : images) {
byte[] bytes = fileStorageService.downLoadFile(image);
byteList.add(bytes);
}
//审核图片
//调用方法正式审核
try {
//调用阿里云的接口对文字进行审核
Map map = greenImageScan.imageScan(byteList);
//根据审核反馈的结果做出不同的处理
if (map != null) {
//审核失败
if (map.get("suggestion").equals("block")) {
updateWmNews(wmNews, (short) 2, "图片存在违规内容!");
flag = false;
}
//不确定,需要人工审核
if (map.get("suggestion").equals("review")) {
updateWmNews(wmNews, (short) 3, "图片存在违规内容!");
flag = false;
}
}
} catch (Exception e) {
flag = false;
e.printStackTrace();
throw new RuntimeException("图片审核时出现异常!");
}
return flag;
}

/**
* 审核文章的文字内容
*
* @param content 文章的文字信息
* @param wmNews 文章实体
* @return 是否通过审核
*/
private Boolean checkText(String content, WmNews wmNews) {
boolean flag = true;
if ((content + wmNews.getTitle()).length() == 0 || "test".equals(environment)) {
return true;
}
//调用方法正式审核
try {
//调用阿里云的接口对文字进行审核
Map map = greenTextScan.greeTextScan(content);
//根据审核反馈的结果做出不同的处理
if (map != null) {
//审核失败
if (map.get("suggestion").equals("block")) {
updateWmNews(wmNews, (short) 2, "文章中的文字信息出现违规内容!");
flag = false;
}
//不确定,需要人工审核
if (map.get("suggestion").equals("review")) {
updateWmNews(wmNews, (short) 3, "文章中的文字信息在审核时有不确定的内容!");
flag = false;
}
}
} catch (Exception e) {
flag = false;
e.printStackTrace();
throw new RuntimeException("文字审核时出现异常!");
}
return flag;
}

/**
* 修改文章审核相关信息的方法
*
* @param wmNews 文章实体
* @param status 文章的审核状态
* @param reason 审核不通过的原因
*/
public void updateWmNews(WmNews wmNews, short status, String reason) {
wmNews.setStatus(status);
wmNews.setReason(reason);
wmNewsMapper.updateById(wmNews);
}

/**
* 提取文章中的文字和图片信息
*
* @param wmNews 文章
* @return 图片和文字组成的集合
*/
private Map<String, Object> getTextAndImages(WmNews wmNews) {
//存储文字信息
StringBuilder stringBuilder = new StringBuilder();
//文章中图片信息组成的集合
ArrayList<String> images = new ArrayList<>();
//从文章中提取文字和图片信息
if (StringUtils.isNotBlank(wmNews.getContent())) {
List<Map> maps = JSON.parseArray(wmNews.getContent(), Map.class);
for (Map map : maps) {
if (map.get("type").equals("text")) {
stringBuilder.append(map.get("value"));
}
if (map.get("type").equals("image")) {
images.add((String) map.get("values"));
}
}
}
//保存文章的封面信息到图片的集合中
if (StringUtils.isNotBlank(wmNews.getImages())) {
String[] split = wmNews.getImages().split(",");
images.addAll(Arrays.asList(split));
}
//创建返回的结果
HashMap<String, Object> resultMap = new HashMap<>();
resultMap.put("content", stringBuilder.toString());
resultMap.put("images", images);
return resultMap;
}
}

单元测试

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
package com.heima.wemedia.service;

import com.heima.wemedia.WemediaApplication;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

/**
* @author Jason Gong
* @version 1.0
* @Date 2023/12/2
* @Description
*/
@SpringBootTest(classes = WemediaApplication.class)
@RunWith(SpringRunner.class)
public class WmNewsAutoScanServiceTest {

@Autowired
private WmNewsAutoScanService wmNewsAutoScanService;

@Test
public void autoScanWmNews() {
wmNewsAutoScanService.autoScanWmNews(3);
}
}

image-20231202232643073

实现步骤:

①:在heima-leadnews-feign-api编写降级逻辑

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

import com.heima.apis.article.IArticleClient;
import com.heima.model.article.dtos.ArticleDto;
import com.heima.model.common.dtos.ResponseResult;
import com.heima.model.common.enums.AppHttpCodeEnum;
import org.springframework.stereotype.Component;

/**
* feign失败配置
* @author itheima
*/
@Component
public class IArticleClientFallback implements IArticleClient {
@Override
public ResponseResult saveArticle(ArticleDto dto) {
return ResponseResult.errorResult(AppHttpCodeEnum.SERVER_ERROR,"获取数据失败");
}
}

在自媒体微服务中添加类,扫描降级代码类的包

1
2
3
4
5
6
7
8
9
package com.heima.wemedia.config;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan("com.heima.apis.article.fallback")
public class InitConfig {
}

②:远程接口中指向降级代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.heima.apis.article;

import com.heima.apis.article.fallback.IArticleClientFallback;
import com.heima.model.article.dtos.ArticleDto;
import com.heima.model.common.dtos.ResponseResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

@FeignClient(value = "leadnews-article",fallback = IArticleClientFallback.class)
public interface IArticleClient {

@PostMapping("/api/v1/article/save")
public ResponseResult saveArticle(@RequestBody ArticleDto dto);
}

③:客户端开启降级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

④:测试

在ApArticleServiceImpl类中saveArticle方法添加代码

1
2
3
4
5
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}

在自媒体端进行审核测试,会出现服务降级的现象

4.5 app端文章保存功能

实现思路

在文章审核成功以后需要在app的article库中新增文章数据

1.保存文章信息 ap_article

2.保存文章配置信息 ap_article_config

3.保存文章内容 ap_article_content

image-20231117221219911

保存文章的接口

说明
接口路径 /api/v1/article/save
请求方式 POST
参数 ArticleDto
响应结果 ResponseResult

ArticleDto

1
2
3
4
5
6
7
8
9
10
11
12
package com.heima.model.article.dtos;

import com.heima.model.article.pojos.ApArticle;
import lombok.Data;

@Data
public class ArticleDto extends ApArticle {
/**
* 文章内容
*/
private String content;
}

成功:

1
2
3
4
5
{
"code": 200,
"errorMessage" : "操作成功",
"data":"1302864436297442242"
}

失败:

1
2
3
4
{
"code":501,
"errorMessage":"参数失效",
}
1
2
3
4
{
"code":501,
"errorMessage":"文章没有找到",
}

实现步骤

image-20231117221333422

功能实现:

①:在heima-leadnews- feign-api中新增接口

第一:线导入feign的依赖

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

第二:定义文章端的接口

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

import com.heima.model.article.dtos.ArticleDto;
import com.heima.model.common.dtos.ResponseResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

import java.io.IOException;


@FeignClient(value = "leadnews-article")
public interface IArticleClient {

@PostMapping("/api/v1/article/save")
public ResponseResult saveArticle(@RequestBody ArticleDto dto) ;
}

②:在heima-leadnews-article中实现该方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.heima.article.feign;

import com.heima.apis.article.IArticleClient;
import com.heima.article.service.ApArticleService;
import com.heima.model.article.dtos.ArticleDto;
import com.heima.model.common.dtos.ResponseResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.io.IOException;

@RestController
public class ArticleClient implements IArticleClient {

@Autowired
private ApArticleService apArticleService;

@Override
@PostMapping("/api/v1/article/save")
public ResponseResult saveArticle(@RequestBody ArticleDto dto) {
return apArticleService.saveArticle(dto);
}

}

③:拷贝mapper

在资料文件夹中拷贝ApArticleConfigMapper类到mapper文件夹中

同时,修改ApArticleConfig类,添加如下构造函数

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
package com.heima.model.article.pojos;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

/**
* <p>
* APP已发布文章配置表
* </p>
*
* @author itheima
*/

@Data
@NoArgsConstructor
@TableName("ap_article_config")
public class ApArticleConfig implements Serializable {


public ApArticleConfig(Long articleId){
this.articleId = articleId;
this.isComment = true;
this.isForward = true;
this.isDelete = false;
this.isDown = false;
}

@TableId(value = "id",type = IdType.ID_WORKER)
private Long id;

/**
* 文章id
*/
@TableField("article_id")
private Long articleId;

/**
* 是否可评论
* true: 可以评论 1
* false: 不可评论 0
*/
@TableField("is_comment")
private Boolean isComment;

/**
* 是否转发
* true: 可以转发 1
* false: 不可转发 0
*/
@TableField("is_forward")
private Boolean isForward;

/**
* 是否下架
* true: 下架 1
* false: 没有下架 0
*/
@TableField("is_down")
private Boolean isDown;

/**
* 是否已删除
* true: 删除 1
* false: 没有删除 0
*/
@TableField("is_delete")
private Boolean isDelete;
}

④:在ApArticleService中新增方法

1
2
3
4
5
6
/**
* 保存app端相关文章
* @param dto
* @return
*/
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
@Autowired
private ApArticleConfigMapper apArticleConfigMapper;

@Autowired
private ApArticleContentMapper apArticleContentMapper;

/**
* 保存app端相关文章
* @param dto
* @return
*/
@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);

//保存配置
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());
}

⑤:测试

编写junit单元测试,或使用postman进行测试

1
2
3
4
5
6
7
8
9
{
"title":"黑马头条项目背景",
"authoId":1102,
"layout":1,
"labels":"黑马头条",
"publishTime":"2028-03-14T11:35:49.000Z",
"images": "http://192.168.200.130:9000/leadnews/2021/04/26/5ddbdb5c68094ce393b08a47860da275.jpg",
"content":"黑马头条项目背景,黑马头条项目背景,黑马头条项目背景,黑马头条项目背景,黑马头条项目背景"
}

image-20231119212628556

4.6 发布文章提交审核集成

4.6.1 同步调用与异步调用

image-20231202234439362

4.6.2 Springboot集成异步线程调用

①:在自动审核的方法上加上@Async注解(标明要异步调用)

1
2
3
4
5
@Override
@Async //标明当前方法是一个异步方法
public void autoScanWmNews(Integer id) {
//代码略
}

②:在文章发布成功后调用审核的方法

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);

}

③:在自媒体引导类中使用@EnableAsync注解开启异步调用

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")
@EnableAsync //开启异步调用
public class WemediaApplication {

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

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}

4.7 文章审核功能-综合测试

4.7.1 服务启动列表

1,nacos服务端

2,article微服务

3,wemedia微服务

4,启动wemedia网关微服务

5,启动前端系统wemedia

4.7.2 测试情况列表

1,自媒体前端发布一篇正常的文章

审核成功后,app端的article相关数据是否可以正常保存,自媒体文章状态和app端文章id是否回显

2,自媒体前端发布一篇包含敏感词的文章

正常是审核失败, wm_news表中的状态是否改变,成功和失败原因正常保存

3,自媒体前端发布一篇包含敏感图片的文章

正常是审核失败, wm_news表中的状态是否改变,成功和失败原因正常保存

image-20231202235800149

4.8 自管理敏感词过滤

4.8.1 需求

image-20231203000259877

4.8.2 可选方案

方案 说明
数据库模糊查询 效率太低
String.indexOf(“”)查找 数据库量大的话也是比较慢
全文检索 分词再匹配
DFA算法 确定有穷自动机(一种数据结构)

4.8.3 DFA算法

image-20231203130434677

image-20231203130929391

4.8.4 工具类

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
package com.heima.utils.common;


import java.util.*;

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("冰毒");
//初始hua敏感词库
initMap(list);
String content="我是一个好人,并不会卖冰毒,也不操练法轮功,我真的不卖冰毒";
Map<String, Integer> map = matchWords(content);
System.out.println(map);
}
}

4.8.5 项目中集成自管理敏感词过滤

①:创建敏感词表,导入资料中wm_sensitive到leadnews_wemedia库中,并创建对应的实体类

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
package com.heima.model.wemedia.pojos;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.io.Serializable;
import java.util.Date;

/**
* <p>
* 敏感词信息表
* </p>
*
* @author itheima
*/
@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;

}

②:拷贝对应的wm_sensitive的mapper到项目中

1
2
3
4
5
6
7
8
9
10
package com.heima.wemedia.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.heima.model.wemedia.pojos.WmSensitive;
import org.apache.ibatis.annotations.Mapper;


@Mapper
public interface WmSensitiveMapper extends BaseMapper<WmSensitive> {
}

③:在文章审核的代码中添加自管理敏感词审核

第一:在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
@Autowired
private WmSensitiveMapper wmSensitiveMapper;

/**
* 自管理的敏感词审核
* @param content
* @param wmNews
* @return
*/
private boolean handleSensitiveScan(String content, WmNews wmNews) {

boolean flag = true;

//获取所有的敏感词
List<WmSensitive> wmSensitives = wmSensitiveMapper.selectList(Wrappers.<WmSensitive>lambdaQuery().select(WmSensitive::getSensitives));
List<String> sensitiveList = wmSensitives.stream().map(WmSensitive::getSensitives).collect(Collectors.toList());

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

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

return flag;
}

image-20231203161902523

4.9 图片识别文字审核敏感词

详细教程: https://qingling.icu/posts/58456.html

①:在heima-leadnews-common中创建工具类,简单封装一下tess4j

需要先导入pom

1
2
3
4
5
<dependency>
<groupId>net.sourceforge.tess4j</groupId>
<artifactId>tess4j</artifactId>
<version>4.1.1</version>
</dependency>

工具类

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
package com.heima.common.tess4j;

import lombok.Getter;
import lombok.Setter;
import net.sourceforge.tess4j.ITesseract;
import net.sourceforge.tess4j.Tesseract;
import net.sourceforge.tess4j.TesseractException;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.awt.image.BufferedImage;

@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;
}

}

在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.GreenTextScan,\
com.heima.common.aliyun.GreenImageScan,\
com.heima.common.tess4j.Tess4jClient

②:在heima-leadnews-wemedia中的配置中添加两个属性

1
2
3
tess4j:
data-path: D:\workspace\tessdata
language: chi_sim

③:在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
try {
for (String image : images) {
byte[] bytes = fileStorageService.downLoadFile(image);

//图片识别文字审核---begin-----

//从byte[]转换为butteredImage
ByteArrayInputStream in = new ByteArrayInputStream(bytes);
BufferedImage imageFile = ImageIO.read(in);
//识别图片的文字
String result = tess4jClient.doOCR(imageFile);

//审核是否包含自管理的敏感词
boolean isSensitive = handleSensitiveScan(result, wmNews);
if(!isSensitive){
return isSensitive;
}

//图片识别文字审核---end-----


imageList.add(bytes);

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

最后附上文章审核的完整代码如下:

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
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
package com.heima.wemedia.service.impl;

import com.alibaba.fastjson.JSONArray;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.heima.apis.article.IArticleClient;
import com.heima.common.aliyun.GreenImageScan;
import com.heima.common.aliyun.GreenTextScan;
import com.heima.common.tess4j.Tess4jClient;
import com.heima.file.service.FileStorageService;
import com.heima.model.article.dtos.ArticleDto;
import com.heima.model.common.dtos.ResponseResult;
import com.heima.model.wemedia.pojos.WmChannel;
import com.heima.model.wemedia.pojos.WmNews;
import com.heima.model.wemedia.pojos.WmSensitive;
import com.heima.model.wemedia.pojos.WmUser;
import com.heima.utils.common.SensitiveWordUtil;
import com.heima.wemedia.mapper.WmChannelMapper;
import com.heima.wemedia.mapper.WmNewsMapper;
import com.heima.wemedia.mapper.WmSensitiveMapper;
import com.heima.wemedia.mapper.WmUserMapper;
import com.heima.wemedia.service.WmNewsAutoScanService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.util.*;
import java.util.stream.Collectors;


@Service
@Slf4j
@Transactional
public class WmNewsAutoScanServiceImpl implements WmNewsAutoScanService {

@Autowired
private WmNewsMapper wmNewsMapper;

/**
* 自媒体文章审核
*
* @param id 自媒体文章id
*/
@Override
@Async //标明当前方法是一个异步方法
public void autoScanWmNews(Integer id) {

// int a = 1/0;

//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);

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

//2.审核文本内容 阿里云接口
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, "审核成功");

}
}

@Autowired
private WmSensitiveMapper wmSensitiveMapper;

/**
* 自管理的敏感词审核
* @param content
* @param wmNews
* @return
*/
private boolean handleSensitiveScan(String content, WmNews wmNews) {

boolean flag = true;

//获取所有的敏感词
List<WmSensitive> wmSensitives = wmSensitiveMapper.selectList(Wrappers.<WmSensitive>lambdaQuery().select(WmSensitive::getSensitives));
List<String> sensitiveList = wmSensitives.stream().map(WmSensitive::getSensitives).collect(Collectors.toList());

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

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

return flag;
}

@Autowired
private IArticleClient articleClient;

@Autowired
private WmChannelMapper wmChannelMapper;

@Autowired
private WmUserMapper wmUserMapper;

/**
* 保存app端相关的文章数据
*
* @param wmNews
*/
private ResponseResult saveAppArticle(WmNews wmNews) {

ArticleDto dto = new ArticleDto();
//属性的拷贝
BeanUtils.copyProperties(wmNews, dto);
//文章的布局
dto.setLayout(wmNews.getType());
//频道
WmChannel wmChannel = wmChannelMapper.selectById(wmNews.getChannelId());
if (wmChannel != null) {
dto.setChannelName(wmChannel.getName());
}

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

//设置文章id
if (wmNews.getArticleId() != null) {
dto.setId(wmNews.getArticleId());
}
dto.setCreatedTime(new Date());

ResponseResult responseResult = articleClient.saveArticle(dto);
return responseResult;

}


@Autowired
private FileStorageService fileStorageService;

@Autowired
private GreenImageScan greenImageScan;

@Autowired
private Tess4jClient tess4jClient;

/**
* 审核图片
*
* @param images
* @param wmNews
* @return
*/
private boolean handleImageScan(List<String> images, WmNews wmNews) {

boolean flag = true;

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

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

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

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

//图片识别文字审核---begin-----

//从byte[]转换为butteredImage
ByteArrayInputStream in = new ByteArrayInputStream(bytes);
BufferedImage imageFile = ImageIO.read(in);
//识别图片的文字
String result = tess4jClient.doOCR(imageFile);

//审核是否包含自管理的敏感词
boolean isSensitive = handleSensitiveScan(result, wmNews);
if(!isSensitive){
return isSensitive;
}

//图片识别文字审核---end-----


imageList.add(bytes);

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


//审核图片
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;
}

@Autowired
private GreenTextScan greenTextScan;

/**
* 审核纯文本内容
*
* @param content
* @param wmNews
* @return
*/
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 stringBuilder = new StringBuilder();

List<String> images = new ArrayList<>();

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

if (map.get("type").equals("image")) {
images.add((String) map.get("value"));
}
}
}
//2.提取文章的封面图片
if (StringUtils.isNotBlank(wmNews.getImages())) {
String[] split = wmNews.getImages().split(",");
images.addAll(Arrays.asList(split));
}

Map<String, Object> resultMap = new HashMap<>();
resultMap.put("content", stringBuilder.toString());
resultMap.put("images", images);
return resultMap;

}
}

4.10 文章详情-静态文件生成

image-20231209210352188

实现步骤

1.新建ArticleFreemarkerService ,定义创建静态文件并上传到minIO中方法

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.heima.article.service;

import com.heima.model.article.pojos.ApArticle;

public interface ArticleFreemarkerService {

/**
* 生成静态文件上传到minIO中
* @param apArticle
* @param content
*/
public void buildArticleToMinIO(ApArticle apArticle,String content);
}
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
package com.heima.article.service.Impl;

import com.alibaba.fastjson.JSONArray;
import com.heima.article.service.ApArticleService;
import com.heima.article.service.ArticleFreemarkerService;
import com.heima.file.service.FileStorageService;
import com.heima.model.article.pojos.ApArticle;
import freemarker.template.Configuration;
import freemarker.template.Template;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.StringWriter;
import java.util.HashMap;

/**
* @author Jason Gong
* @version 1.0
* @Date 2023/12/9
* @Description
*/
@Service
@Slf4j
@Transactional
public class ArticleFreemarkerServiceImpl implements ArticleFreemarkerService {

@Autowired
private Configuration configuration;
@Autowired
private FileStorageService fileStorageService;
@Autowired
private ApArticleService apArticleService;

@Async //标记为一个异步调用的方法
@Override
public void buildArticleToMinio(ApArticle apArticle, String content) {
//已知文章的id
//1.获取文章的内容
if (StringUtils.isNotBlank(content)) {
StringWriter out = new StringWriter();
try {
//2.文章内容通过freemarker生成html文件 详细教程见:https://qingling.icu/posts/29367.html
Template template = configuration.getTemplate("article.ftl");
//构建数据模型
HashMap<String, Object> map = new HashMap<>();
map.put("content", JSONArray.parseArray(content));
//输出流
out = new StringWriter();
//合成html文件
template.process(map, out);
} catch (Exception e) {
e.printStackTrace();
}
//3.把html文件上传到minio中
//构建一个输入流
InputStream in = new ByteArrayInputStream(out.toString().getBytes());
//上传到minio并返回访问的路径
String path = fileStorageService.uploadHtmlFile("", apArticle.getId() + ".html", in);
log.info("文件在minio中的路径:" + path);
//4.修改ap_article表,保存static_url字段
apArticle.setStaticUrl(path);
boolean isSuccess = apArticleService.updateById(apArticle);
log.info(isSuccess ? "文件上传成功" : "文件上传失败");
}
}
}

2.在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
/**
* 保存app端相关文章
* @param dto
* @return
*/
@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);

//保存配置
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());
}

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
package com.heima.article;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableAsync;


@SpringBootApplication
@EnableDiscoveryClient
@EnableAsync //开启异步调用
@MapperScan("com.heima.article.mapper")
public class ArticleApplication {

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

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}

测试

image-20231209214205338

image-20231209214309957

4.11 文章定时发布