文章目录
前言
文章上传之后要发布到app给用户看,需要经过文章审核,包括文本审核与图片审核。
该部分内容实现会学到的技术有:第三方内容安全审核接口、分布式主键、异步调用、feign远程接口和熔断降级。
审核方式:
1.自动审核
文章发布之后,系统自动审核,主要通过第三方接口对文章内容进行审核(成功、失败、不确定)
2.人工审核
自动审核返回不确定时,转人工审核,由管理员审核
一、自媒体文章审核流程
1.多端调用
包括:自媒体、文章微服务、阿里云、MinIO流程如下
2.阿里云内容安全接口
1)导入对应的依赖,参考sdk接口说明
2)拷贝资料文件夹的类到common模块下,并添加到自动配置
包含GreenImageScan和GreenTextScan及对应的工具类
3)在bootstrap.yml文件中添加阿里云的ak(需自己申请)
4)在自媒体微服务中测试类中注入审核文本和图片的bean进行测试
二、app端保存接口
1.分布式id
随着业务增长,文章表可能要占用很大的物理存储空间,为了解决该问题,后期使用数据库分片技术。将一个数据库进行拆分,通过数据库中间件连接。如果数据库中表使用ID自增策略,则可能产生重复的ID,此时应该使用分布式ID使用策略。
技术选型
redis:(INCR)生成一个全局连续递增的数字类型主键;但增加了一个外部组件的依赖,redis不可用,则整个数据库将无法再插入。
UUID:全局唯一, Mysql也有UUID实现;36个字符组成,占用空间大。
snowflake算法:全局唯一,数字类型,存储成本低;机器规模大于1024台无法支持。
2.雪花算法
@Data
@TableName("ap_article_content")
public class ApArticleContent implements Serializable {
@TableId(value = "id",type = IdType.ID_WORKER)
private Long id;
上表中的idType.ID_WORKER就是指雪花算法。
使用雪花算法需要在nacos中的leadnews-article中添加配置文件:
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: leosql
#设置Mapper接口所对应的xml文件位置,如果你在Mapper接口中有自定义方法,需要进行该配置
mybatis-plus:
mapper-locations: classpath*:mapper/*.xml
#设置别名包扫描路径,通过该属性可以给包中的类注册别名
type-aliases-package: com.heima.model.article.pojos
global-config:
datacenter-id: 1
workerId: 1
3.实现思路
保存数据是通过自媒体调用,调用了一个远程接口来实现文章保存。
接口实现
参数方面,需要添加文章内容参数,并继承ApArticle
响应:200,需要返回code;errorMessage:“操作成功”;data:“11318151315315…”(雪花算法id)
id需要回填到wm_news
有问题就返回错误信息。
4.实现步骤
1)在heima-leadnews-feign-api中定义接口
· 导入feign远程调用依赖(在feign模块下导入)
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
· 定义文章端的远程接口
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;
@FeignClient("leadnews-article")
public interface IArticleClient {
@PostMapping("/api/v1/article/save")
public ResponseResult saveArticle(@RequestBody ArticleDto dto);
}
2)在heima-leadnews-article实现feign接口
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ArticleClient implements IArticleClient {
@Autowired
private ApArticleService apArticleService;
@PostMapping("/api/v1/article/save")
@Override
public ResponseResult saveArticle(@RequestBody ArticleDto dto) {
return apArticleService.saveArticle(dto);
}
}
3)在资料文件夹中拷贝ApArticleConfigMapper类到文章微服务mapper包下
package com.heima.article.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.heima.model.article.pojos.ApArticleConfig;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ApArticleConfigMapper extends BaseMapper<ApArticleConfig> {
}
4)在ApArticleService中新增保存方法进行实现
@Autowired
private ApArticleConfigMapper apArticleConfigMapper;
@Autowired
private ApArticleContentMapper apArticleContentMapper;
/**
* 保存qpp端相关文章
* @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 不存在:保存文章、文章配置、文章内容
//保存文章
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 存在:修改文章、文章内容
//修改文章
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());
}
5)postman进行测试
三、单元测试
具体实现代码::
package com.heima.wemedia.service.impl;
import com.alibaba.fastjson.JSONArray;
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.PageRequestDto;
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.WmNewsMaterial;
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 com.heima.wemedia.service.WmNewsService;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.apache.commons.lang.StringUtils;
import org.apache.kafka.common.protocol.types.Field;
import org.checkerframework.checker.units.qual.A;
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.io.File;
import java.rmi.MarshalledObject;
import java.util.*;
@Service
@Slf4j
@Transactional
public class WmNewsAutoScanServiceImpl implements WmNewsAutoScanService {
@Autowired
private WmNewsMapper wmNewsMapper;
/**
* 自媒体文章审核
* @param id 自媒体文章
*/
@Override
public void autoScanWmNews(Integer id) {
//1.查询自媒体文章
WmNews wmNews = wmNewsMapper.selectById(id);
if(wmNews == null) {
throw new RuntimeException("WmNewsAutoScanServiceImpl-文章不存在");
}
if(wmNews.getStatus().equals(WmNews.Status.SUBMIT.getCode())){
//从内容中提取纯文本和图片
Map<String, Object> textAndImages = handleTextAndImages(wmNews);
//2.审核文本内容 阿里云接口
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, "审核成功");
}
}
//需要在引导类加注解@EnableFeignClients(basePackages = "com.heima.apis")
@Autowired
private IArticleClient iArticleClient;
@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 = iArticleClient.saveArticle(dto);
return responseResult;
}
@Autowired
private GreenImageScan greenImageScan;
/**
* 审核图片
* @param images
* @param wmNews
* @return
*/
private boolean handleImageScan(List<String> images, WmNews wmNews) {
boolean flag = true;
//1.校验参数
if(images == null || images.size() == 0) return flag;
//2.下载图片到minIO:由于使用了新版审核方式,直接传递url
//3.审核图片
try {
for (String image : images){
Map map = greenImageScan.imageScan(image);
if(!map.get("label").equals("nonLabel")){
flag = false;
updateWmNews(wmNews, (short) 2,"当前文章存在违规内容");
return flag;
}
}
} catch (Exception e) {
flag = false;
throw new RuntimeException(e);
}
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("block")) {
// updateWmNews(wmNews, (short) 3, "当前文章中存在不确定内容");
// }
}
} catch (Exception e) {
flag = false;
throw new RuntimeException(e);
}
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.提取文章的封面图片
* content:如下,JSON格式,内容是一个集合,集合里面有一个个对象,
* 对象中包含type和内容,type不同可以找到不同的数据
* [
* {"type":"text",
* "value":"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
* },
* {"type":"image",
* "value":"http://xxxxxxxxxxxxxxxxxxxxxx.png"
* }]
* @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())){
//使用JSONArray解析内容,返回保存map的列表
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;
}
}
测试结果成功。
四、feign远程调用服务降级处理
自媒体微服务是负责审核文章,文章微服务负责保存app端文章,其中有远程调用,若文章微服务不能支撑大量的远程调用请求有可能会中断服务。
使用服务降级处理,属于服务的自我保护的功能,或保护下游服务的一种方式,用于确保服务不会受请求突增影响变得不可用,确保服务不会崩溃
服务降级虽然会导致请求失败,但不会导致阻塞。
现在尝试在项目中集成该功能。
步骤:
五、同步调用与异步调用
六、综合测试
1、启动下面三个服务和nignx代理
2.分别对正常文章、含有敏感文本、含有违规图片三个内容进行测试。
遇见的问题与debug
1.由于阿里云内容识别1.0的版本比较落后,在直接使用原本《黑马头条》的源码会出现以下问题,因此需要重写api调用的包装类
{
“code”:596,
“msg”:“You have not opened Yundun Content Moderation Service, please go to the page(https://www.aliyun.com/product/lvwang) to open our service after login Ali cloud console”,
“requestId”:“E664F410-D5F1-5CC0-81DE-5CA1248C53CF”
}
文本部分可以参考以下博客:
https://blog.csdn.net/weixin_46078500/article/details/134341637
图片部分尝试自己进行api的调用重写:
根据文档https://help.aliyun.com/document_detail/467828.html?spm=a2c4g.467826.0.0.4f695e1feH4b95#63e4ba807395v
找到接入SDK的部分,有三种图片检测类型:
我在查看了本项目里需要测试的数据,具体是通过minIO的业务层代码实现了对minIO中bucket中图片的下载,可以看到输入的是图片url路径,返回的是文件流。
/**
* 下载文件
* @param pathUrl 文件全路径
* @return 文件流
*
*/
@Override
public byte[] downLoadFile(String pathUrl) {
String key = pathUrl.replace(minIOConfigProperties.getEndpoint()+"/","");
int index = key.indexOf(separator);
String bucket = key.substring(0,index);
String filePath = key.substring(index+1);
InputStream inputStream = null;
try {
inputStream = minioClient.getObject(GetObjectArgs.builder().bucket(minIOConfigProperties.getBucket()).object(filePath).build());
} catch (Exception e) {
log.error("minio down file error. pathUrl:{}",pathUrl);
e.printStackTrace();
}
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
byte[] buff = new byte[100];
int rc = 0;
while (true) {
try {
if (!((rc = inputStream.read(buff, 0, 100)) > 0)) break;
} catch (IOException e) {
e.printStackTrace();
}
byteArrayOutputStream.write(buff, 0, rc);
}
return byteArrayOutputStream.toByteArray();
}
因此需要考虑如何将文件流转化为上述三种能实现的图片检测方法中的一种。
而我的minIO是可以通过公网访问的,因此就直接使用第一种方式调用即可。
拓展知识点的学习
1.配置类要被自动注入需要在spring.factories里添加引用路径
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
2.配置阿里云时,由于教程中使用的内容安全版本和现有的版本不一致,因此需要重写包装类(具体内容见debug部分)
3.同样实体类,为什么有时候用save函数,有时候却要注入bean,并使用mapper接口得方法进行数据库操作
//2.判断是否存在id
if(dto.getId() == null){
//2.1 不存在:保存文章、文章配置、文章内容
//保存文章
save(apArticle);
//保存配置
ApArticleConfig apArticleConfig = new ApArticleConfig(apArticle.getId());
apArticleConfigMapper.insert(apArticleConfig);
在上述代码中,apArticle和apArticleConfig都是实体类,进行数据库操作方式却不同。原因是:
public class ApArticleServiceImpl extends ServiceImpl<ApArticleMapper, ApArticle>
public interface ApArticleService extends IService<ApArticle> {
该业务层接口时继承了ServiceImpl<ApArticleMapper, ApArticle>,并实现了ApArticleService接口,其中ApArticleService接口又实现了IService,其中就有save方法可以对数据库进行操作,同时泛型得限制,导致其他实体类只能直接使用Mapper方法操作数据库。
4.feign和controller在使用上的差异:
1)用途:
Feign 是一种声明式的、基于接口的 HTTP 客户端,用于简化服务之间的通信。它允许你通过定义接口来调用远程服务,而无需手动编写 HTTP 请求。Feign 会自动处理请求的构建和发送,以及响应的解析和处理。
Controller 是 Spring MVC 中的一种组件,用于处理客户端发送的 HTTP 请求并返回响应。它通常用于定义 RESTful API 的端点,接收来自客户端的请求,并根据请求执行相应的业务逻辑,并返回数据或视图给客户端。
2)角色:
Feign 通常扮演客户端的角色,用于向远程服务发送 HTTP 请求并处理响应。在微服务架构中,Feign 客户端通常位于服务的消费方。
Controller 通常扮演服务端的角色,用于接收来自客户端的 HTTP 请求,并根据请求执行相应的业务逻辑。在微服务架构中,Controller 通常位于服务的提供方。
3)调用方式:
Feign 是一种声明式的调用方式,你只需定义一个接口,并使用注解标记方法,Feign 就会根据接口定义自动生成具体的实现类,并处理请求的构建和发送,以及响应的解析和处理。
Controller 是一种显式的调用方式,你需要手动编写方法来处理请求,并通过注解标记方法来指定请求的 URL 和请求方法,并手动处理请求的参数和响应的数据。
4)实现方式:
Feign 是一个基于动态代理和注解处理器的框架,它会在运行时动态生成实现了指定接口的代理类,并根据接口定义自动生成 HTTP 请求。
Controller 是一个普通的 Spring Bean,通过在类上添加 @Controller 或 @RestController 注解来标记为控制器,然后在方法上使用 @RequestMapping、@GetMapping、@PostMapping 等注解来定义请求映射。