1.黑马头条微服务项目全面总结
1.1 app登录需求
1.1.1 表结构分析
tinyint类型:占一个字节,不指定unsigned(非负数),值范围(-128,127),指定了unsigned,值范围(0,255)
tinyint类型通常表示小范围的数值,或者表示true或false,通常值为0表示false,值为1表示true
1.1.2 用户登录的设计
@Override
public ResponseResult login(LoginDto loginDto) {
// 观察是不是游客登录
if(StringUtil.isBlank(loginDto.getPhone())||StringUtil.isBlank(loginDto.getPassword())){
//游客登陆将jwt设为0
Map<String,Object> map=new HashMap<>();
map.put("token", AppJwtUtil.getToken(0L));
return ResponseResult.okResult(map);
}
// 用户登录 检测是否有该手机号
ApUser apUser=apUserMapper.selectApUserByPhone(loginDto.getPhone());
if(apUser==null){
return ResponseResult.errorResult(AppHttpCodeEnum.DATA_NOT_EXIST,"用户信息不存在");
}
//如果有查看密码是否正确
//获得加盐值的密码
String password = loginDto.getPassword()+apUser.getSalt();
String s = DigestUtils.md5DigestAsHex(password.getBytes());
//判断密码是否正确
if(!s.equals(apUser.getPassword())){
return ResponseResult.errorResult(AppHttpCodeEnum.LOGIN_PASSWORD_ERROR);
}
//如果正确 则返回token
Map<String,Object> map=new HashMap<>();
apUser.setPassword("");
apUser.setSalt("");
map.put("token",AppJwtUtil.getToken(apUser.getId().longValue()));
map.put("user",apUser);
return ResponseResult.okResult(map);
}
1.1.2 网关全局过滤器校验token
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//获得request对象和 response对象
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
//判断是否登录
if(request.getURI().getPath().contains("/login")){
//如果是登录页面直接放行
return chain.filter(exchange);
}
//获取token
String token = request.getHeaders().getFirst("token");
//判断token是否存在
if(StringUtils.isBlank(token)){
//如果token不存在 则拦截
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
//判断token是否有效
Claims claimsBody = AppJwtUtil.getClaimsBody(token);
int result = AppJwtUtil.verifyToken(claimsBody);
try {
if (result == 1 || result == 2) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
}catch (Exception e){
e.printStackTrace();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
//获取用户信息
Object userId = claimsBody.get("id");
//存储到header中
ServerHttpRequest serverHttpRequest = request.mutate().headers(httpHeaders -> {
httpHeaders.add("userId", userId.toString());
}).build();
//重置请求
exchange.mutate().request(serverHttpRequest);
//放行
return chain.filter(exchange);
}
1.2 app文章列表查询需求
1.2.1 表结构分析
ap_article 文章信息表
ap_article_config 文章配置表
ap_article_content 文章内容表
三张表属于一对一关系 并且属于表拆分的垂直拆分
1.2.2 表的拆分-垂直分表
垂直分表:将一个表的字段分散到多个表中,每个表存储其中一部分字段
优势:
- 减少io争抢,减少锁表的几率,查看文章概述与文章详情互不影响
- 充分发挥高频数据的操作效率,对文章概述数据操作的高效率不会被操作文章详情数据的低效率锁拖累
拆分规则:
- 把不常用的字段单独放在一张表
- 把text,blob等大字段拆分出来单独放在一张表
- 经常组合查询的字段单独放在一张表中
1.2.3 文章列表查询的设计
需求分析:
- 在默认频道展示10条文章信息
- 可以切换频道查看不同种类的文章
- 当用户下拉可以加载最新的文章(分页)本页文章列表种发布时间为最大的时间为依据
- 当用户上拉可以加载更多文章信息(按照发布时间)本页文章列表中发布时间最小的时间为依据
/**
* 根据参数加载文章列表
* loadType 1为加载更多 2为加载最新
*
*/
// 校验参数 页数校验 size必须设置值 最小为10 最大为50
Integer size = dto.getSize();
if(size==null||size==0){
size =10;
}
Math.min(size,MAX_PAGE_SIZE);
dto.setSize(size);
// loadType 校验 如果不是加载更多 也不是加载最新 那就默认加载更多
if(!loadType.equals(ArticleConstants.LOADTYPE_LOAD_MORE)&&!loadType.equals(ArticleConstants.LOADTYPE_LOAD_NEW)){
loadType=ArticleConstants.LOADTYPE_LOAD_MORE;
}
//校验频道id 如果频道id 是空的话 就默认为 all
if(StringUtils.isEmpty(dto.getTag())){
dto.setTag(ArticleConstants.DEFAULT_TAG);
}
//时间校验 如果最大或者最小时间为null 设置为当前时间
if(dto.getMaxBehotTime()==null) dto.setMaxBehotTime(new Date());
if(dto.getMinBehotTime()==null) dto.setMinBehotTime(new Date());
//查询出来结果封装
List<ApArticle> apArticles = apArticleMapper.loadArticleList(dto, loadType);
return ResponseResult.okResult(apArticles);
<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 -->加载更多 如果type为1 那就是加载更多 让发布时间小于于当前页最小的时间
<if test="type != null and type == 1">
and aa.publish_time <![CDATA[<]]> #{dto.minBehotTime}
</if>
<!-- loadnew --> 加载最新 如果type为2 那就是加载最新 让发布时间大于当前页最大的时间
<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>
1.2.4 文章详情的实现步骤
1.3 自媒体的素材管理
1.3.1 表结构分析
1.3.2 素材管理思路分析与实现
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获得用户id
String userId = request.getHeader("userId");
if(userId!=null){
//如果用户id有效就放入ThreadLocal中 并且放行
WmUser wmUser=new WmUser();
wmUser.setId(Integer.valueOf(userId));
WmThreadLocalUtil.setUser(wmUser);
log.info("存入ThreadLocal完毕");
}
log.info("放行");
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
//后置处理器 用于清除ThreadLocal中数据 防止内存泄漏
WmThreadLocalUtil.clear();
log.info("清除ThreadLocal的数据");
}
// 检查参数
if(multipartFile==null|| multipartFile.getSize()==0){
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
//上传图片需要修改图片名字
String Uuid = UUID.randomUUID().toString().replace("-", "");//javaUtil包下的uuid会有-号 需要把-去掉
//需要将本来图片名字的后缀拿到 uud.jpg 需要将.后面的拿到
String originalFilename = multipartFile.getOriginalFilename();
String postfix = originalFilename.substring(originalFilename.lastIndexOf("."));//拿到后缀名字
String fileId=null;
try {
fileId = fileStorageService.uploadImgFile("", originalFilename + postfix, multipartFile.getInputStream());
log.info("上传图片到MinIO中,fileId:{}",fileId);
} catch (IOException e) {
e.printStackTrace();
log.error("WmMaterServiceImpl-上传文件失败");
}
WmMaterial wmMaterial=new WmMaterial();
wmMaterial.setUserId(WmThreadLocalUtil.getUser().getId());//设置用户id 从ThreadLocal拿出数据
wmMaterial.setUrl(fileId);//图片地址
wmMaterial.setIsCollection((short)0); //收藏字段 0就是没有收藏
wmMaterial.setType((short)0);//内容类型 0表示图片
wmMaterial.setCreatedTime(new Date());
save(wmMaterial);
return ResponseResult.okResult(wmMaterial);
1.4 自媒体的发布文章
1.4.1 发布文章的需求分析
1.4.2 发布文章所需要的表结构分析
wm_news 文章表
wm_material 文章素材表
wm_news_material 文章素材关系表
1.4.3 发布文章的实现
@Override
public ResponseResult submitNews(WmNewsDto dto) {
//条件判断
if(dto==null || dto.getContent()==null){
return ResponseResult.okResult(AppHttpCodeEnum.PARAM_INVALID);
}
//保存或者修改文章
WmNews wmNews=new WmNews();
//属性拷贝
BeanUtils.copyProperties(dto,wmNews);
//将集合中的照片转换为字符串
if(dto.getImages()!=null && dto.getImages().size()>0){
String image = StringUtils.join(dto.getImages(), ",");
wmNews.setImages(image);
}
//设置封面类型 如果封面为自动 那么就为-1
if(dto.getType().equals(WeMediaConstants.WM_NEWS_TYPE_AUTO)){
wmNews.setType(null);
}
saveOrUpdateWmNews(wmNews);
//判断是否为草稿
if(dto.getStatus().equals(WmNews.Status.NORMAL.getCode())){
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}
// 如果不是草稿 就要保存文章内容图片和素材的关系
// 获取到文章内容的图片关系
List<String> materials=ectractUrlInfo(dto.getContent());
saveRelativeInfoForContent(materials,wmNews.getId());
//不是草稿,保存文章封面图片与素材的关系,如果当前布局是自动,需要匹配封面图片
saveRelativeInfoForCover(dto,wmNews,materials);
//审核
//wmNewsAutoScanService.autoScanWmNews(wmNews.getId());
wmNewsTaskService.addNewsToTask(wmNews.getId(),wmNews.getPublishTime());
System.out.println("--------------"+wmNews.getId());
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}
private void saveOrUpdateWmNews(WmNews wmNews) {
//设置id
wmNews.setUserId(WmThreadLocalUtil.getUser().getId());
//设置创建时间
wmNews.setCreatedTime(new Date());
wmNews.setSubmitedTime(new Date());
wmNews.setEnable((short)1);
// 如果没有id则说明是保存的
if(wmNews.getId()==null){
save(wmNews);
}else {
//否则就是修改
//删除文章图片与素材的关系
wmNewsMaterialMapper.deleteByNewsId(wmNews.getId());
//更新数据
updateById(wmNews);
}
}
//将内容里面的图片提取出来
private List<String> ectractUrlInfo(String content) {
List<String> list=new ArrayList<>();
List<Map> maps = JSON.parseArray(content, Map.class);
for(Map map:maps){
if(map.get("type").equals("image")){
String value =(String) map.get("value");
list.add(value);
}
}
return list;
}
private void saveRelativeInfoForContent(List<String> materials, Integer id) {
saveRelativeInfo(materials,id,WeMediaConstants.WM_CONTENT_REFERENCE);
}
private void saveRelativeInfo(List<String> materials, Integer id, Short type) {
if(materials!=null&& !materials.isEmpty()){
//通过图片的url查询素材的id
List<WmMaterial> dbMaterials= wmMaterialMapper.selectByUrls(materials);
//手动抛异常
if(dbMaterials.size()==0|| dbMaterials==null){
throw new CustomException(AppHttpCodeEnum.MATERIASL_REFERENCE_FAIL);
}
if(materials.size()!= dbMaterials.size()){
throw new CustomException(AppHttpCodeEnum.MATERIASL_REFERENCE_FAIL);
}
//将查出来的类转换为id集合
List<Integer> idList = dbMaterials.stream().map(WmMaterial -> WmMaterial.getId()).collect(Collectors.toList());
wmNewsMaterialMapper.saveRelations(idList,id,type);
}
}
private void saveRelativeInfoForCover(WmNewsDto dto, WmNews wmNews, List<String> materials) {
//如果当前封面类型为自动,则设置封面类型的数据
List<String> images = dto.getImages();
if(dto.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_CONTENT_REFERENCE);
}
}
1.4.4 自管理敏感词过滤
DFA实现原理:DFA:即确定有穷自动机
存储:一次性的把所有的敏感词存储到多个map中,就是下图这种结构
敏感词:大麻,大坏蛋
自管理敏感词库表
private boolean handleSensitiveScan(String content, WmNews wmNews) {
boolean flag=true;
//查询数据库sensitive表所有的文本
List<String> sensitiveList=wmSensitiveMapper.selectByList();
//初始化敏感词库
SensitiveWordUtil.initMap(sensitiveList);
Map<String, Integer> map = SensitiveWordUtil.matchWords(content);
if(map.size()>0){
//如果含有敏感词 则修改wmNews表中的reason字段以及status字段
updateWmNews(wmNews,(short)2,"当前文章中存在违规内容"+map);
flag=false;
}
return flag;
}
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;
}
1.4.5 图片文字识别-敏感词过滤
什么是OCR?
OCR(Optical Character Recognition,光学字符识别)是指电子设备(例如扫描仪或数码相机)检查纸上打印的字符,通过检测暗,亮的模式确定其形状,然后用字符识别方法将形状翻译成计算机文字的过程
Tessercat-OCR特点:
- Tesseract支持utf-8编码格式,并且可以"开箱即用"地识别100多种语言
- Tesseract支持多种输出格式:纯文本,hOCR(html),PDF等
- 官方建议,为了获得更好的OCR结果,最好提供给高质量的图像
@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;
}
}
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());
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 b = handleSensitiveScan(result, wmNews);
// 如果包含敏感词 则
if (!b) {
flag = false;
}
}
}catch (Exception e){
e.printStackTrace();
}
return flag;
}
1.5 定时任务添加任务
1.5.1 什么是延迟任务
- 定时任务:有固定周期的,有明确的触发时间
- 延迟任务:没有固定的开始时间,它常常是由一个事件触发的,而在这个事件触发之后的一段时间内触发另一个事件,任务可以立即执行
- 为什么任务需要存储到数据库中呢?
延迟任务是一个通用的服务,任何有延迟需求的任务都可以调用该服务,内存数据库的存储是有限的,需要考虑数据持久化,存储数据库中是一种数据安全的考虑 - 为什么使用redis中的两种数据类型,list和zset
原因一:list存储立即执行的任务,zset存储未来的数据
原因二:任务量过大以后,zset性能会下降
时间复杂度: 执行时间随着数据规模增长的变化趋势- 操作redis中的list命令Lpush:时间复杂度:o(1)
- 操作redis中的zset命令zadd:时间复杂度:o(m*log(n))
1.5.2 延迟任务需要的表结构分析
taskinfo 任务信息表
taskinfo_log 任务信息日志表
mysql中,BLOB是一个二进制大型对象,是一个可以存储大量数据的容器:LongBlob最大存储4G
1.5.3 添加任务删除任务的实现
@Override//添加任务
public long addTask(Task task) {
//先将任务插入数据库
boolean success=addTaskToDB(task);
if(success){
//如果插入成功,则向redis中插入数据
addTaskToCache(task);
}
return task.getTaskId();
}
private boolean addTaskToDB(Task task) {
boolean flag=false;
try {
//先保存 Task信息表
TaskInfo taskinfo = new TaskInfo();
BeanUtils.copyProperties(task, taskinfo);
taskinfo.setExecuteTime(new Date(task.getExecuteTime()));
taskInfoMapper.insert(taskinfo);
//设置task的id
task.setTaskId(taskinfo.getTaskId());
//保存Task Task日志表
TaskInfoLogs taskInfoLogs = new TaskInfoLogs();
BeanUtils.copyProperties(taskinfo, taskInfoLogs);
taskInfoLogs.setVersion(1);
taskInfoLogs.setStatus(ScheduleConstants.SCHEDULED);
taskInfoLogsMapper.insert(taskInfoLogs);
flag=true;
}catch (Exception e){
e.printStackTrace();
}
return flag;
}
private void addTaskToCache(Task task) {
//如果是当前时间 则插入redis中的list集合
String key=task.getTaskType()+"_"+task.getPriority();
//获取5分钟后的时间 毫秒值
Calendar calendar=Calendar.getInstance();
calendar.add(Calendar.MINUTE,5);
long nextScheduleTime = calendar.getTimeInMillis();
if(task.getExecuteTime()<=System.currentTimeMillis()){
//如果执行时间小于当前时间则存入list集合
cacheService.lLeftPush(ScheduleConstants.TOPIC+key, JSON.toJSONString(task));
}else if(task.getExecuteTime()<=nextScheduleTime){
//如果执行时间大于当前时间并且小于当前时间+5分钟
cacheService.zAdd(ScheduleConstants.FUTURE+key,JSON.toJSONString(task),task.getExecuteTime());
}
}
@Override// 取消任务
public Boolean cancelTask(Long taskId) {
boolean flag=false;
//首先删除 数据库中的 task表 在修改task日志表
Task task=updateDb(taskId,ScheduleConstants.EXECUTED);
if(task!=null){
//删除缓存中的数据
RemoveTaskFromCache(task);
flag=true;
}
return flag;
}
private Task updateDb(Long taskId, int status) {
Task task=null;
try {
//删除任务
taskInfoMapper.deleteById(taskId);
//修改任务日志 (日志状态)
TaskInfoLogs taskInfoLogs = taskInfoLogsMapper.selectById(taskId);
taskInfoLogs.setStatus(status);
//返回任务类 填上执行时间
task = new Task();
BeanUtils.copyProperties(taskInfoLogs,task);
task.setExecuteTime(taskInfoLogs.getExecuteTime().getTime());
}catch (Exception e){
log.error("task cancel exception taskId= {}",taskId);
}
return task;
}
private void RemoveTaskFromCache(Task task) {
String key=task.getTaskType()+"_"+task.getPriority();
if(task.getExecuteTime()<=System.currentTimeMillis()){
//如果执行时间小于或者等于当前时间 则删除list集合里的数据
cacheService.lRemove(ScheduleConstants.TOPIC+key,0,JSON.toJSONString(task));
}else{
//不是 则去删除zset里面的数据
cacheService.zRemove(ScheduleConstants.FUTURE+key,JSON.toJSONString(task));
}
}
@Override
public Task poll(int type, int priority) {
//把task类从redis里面取出来 修改数据库状态 并返回
Task task=null;
try {
String key=type+"_"+priority;
String json_task = cacheService.lRightPop(ScheduleConstants.TOPIC + key);
if(StringUtils.isNotBlank(json_task)) {
task = JSON.parseObject(json_task, Task.class);
//删除Taskinfo的数据 修改TaskInfolog表中的字段状态
updateDb(task.getTaskId(),ScheduleConstants.EXECUTED);
}
}catch (Exception e){
e.printStackTrace();
log.error("poll task Exception");
}
return task;
}
1.5.4 未来数据定时刷新-实现步骤
如何获取到zset中所有的key?
-
方案1:keys模糊匹配
keys的模糊匹配功能很方便也很强大,但是在生产环境需要慎用!开发使用keys的模糊匹配慎用!开发中使用keys的模糊匹配却发现rediscpu使用率极高,所以公司的redis生产环境将keys命令禁用了! redis是单线程,会被堵塞 -
方案2:scan
SCAN命令是一个基于游标的迭代器,SCAN命令每次被调用之后,都会向用户返回一个新的游标,用户在下次迭代时需要使用这个新游标作为SCAN命令的游标参数,以此来延续之前的迭代过程
数据如何同步?
// 从zset定时同步到list
@Scheduled(cron="0 */1 * * * ?")
public void refresh() {
System.out.println(System.currentTimeMillis()/1000+"执行了定时任务");
//查看未来的任务
Set<String> futureKeys = cacheService.scan(ScheduleConstants.FUTURE + "*");
RLock redissonLock = redissonClient.getLock("redissonLock");
//加分布式锁
boolean b = redissonLock.tryLock();
if(b) {
try {
for (String futureKey : futureKeys) { //future_250_250 获取当前的key topic
String topicKey = ScheduleConstants.TOPIC +futureKey.split(ScheduleConstants.FUTURE)[1];
//按照key和分值查询符合条件的
Set<String> tasks = cacheService.zRangeByScore(futureKey, 0, System.currentTimeMillis());
if (!tasks.isEmpty()) {
//通道同步
cacheService.refreshWithPipeline(futureKey, topicKey, tasks);
System.out.println("成功的将" + futureKey + "下的当前需要执行的任务数据刷新到" + topicKey + "下");
}
}
}finally{
redissonLock.unlock();
}
}
}
1.5.5 发布文章集成添加延迟队列接口
//在文章发布时异步调用添加任务到延迟队列 将文章id和发布时间传参
@Override
@Async
public void addNewsToTask(Integer id, Date publishTime) {
log.info("添加任务到延迟队列中----begin");
Task task=new Task();
task.setExecuteTime(publishTime.getTime());
task.setTaskType(TaskTypeEnum.NEWS_SCAN_TIME.getTaskType());
task.setPriority(TaskTypeEnum.NEWS_SCAN_TIME.getPriority());
WmNews wmNews=new WmNews();
wmNews.setId(id);
//序列化
task.setParameters(ProtostuffUtil.serialize(wmNews));
//在经过schedule模块 进行添加任务
scheduleClient.addTask(task);
log.info("添加任务到延迟服务中----end");
}
@SneakyThrows //自动消除try catch模块
@Scheduled(fixedRate = 1000)
@Override
public void scanNewsByTask() {
log.info("文章审核--消费任务的执行--begin");
//从redis中拿出来任务 并且修改数据库状态 进行消费
ResponseResult responseResult = scheduleClient.poll(TaskTypeEnum.NEWS_SCAN_TIME.getTaskType(),
TaskTypeEnum.NEWS_SCAN_TIME.getPriority());
if(responseResult.getCode().equals(200)&&responseResult.getData()!=null){
//将任务转换为 字符串
String json_str = JSON.toJSONString(responseResult.getData());
//在将字符转换为task类
Task task=JSON.parseObject(json_str,Task.class);
//将task中的实体类取出来 并序列化
byte[] parameters = task.getParameters();
WmNews wmNews = ProtostuffUtil.deserialize(parameters, WmNews.class);
System.out.println(wmNews.getId()+"--------");
//消费审核 任务类
wmNewsAutoScanService.autoScanWmNews(wmNews.getId());
}
log.info("文章审核---消费任务执行--end--");
}
1.5.6 自媒体文章上下架
@Override
public ResponseResult downOrUp(WmNewsDto dto) {
//检查参数
if(dto==null){
return ResponseResult.errorResult(AppHttpCodeEnum.DATA_NOT_EXIST);
}
//查询文章
WmNews wmNews = getById(dto.getId());
if(wmNews==null){
return ResponseResult.errorResult(AppHttpCodeEnum.DATA_NOT_EXIST,"该文章不存在");
}
//判断文章是否已发布
if(!wmNews.getStatus().equals(WmNews.Status.PUBLISHED.getCode())){
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID,"该文章不是发布状态,不能上下架");
}
//修改enable enable必须为 0或者1 两种状态
if(dto.getEnable()!=null&&dto.getEnable()>-1&&dto.getEnable()<2) {
//wmnews修改上下架状态
wmNewsMapper.updateEnable(dto.getId(),dto.getEnable());
//发送消息,通知article修改文章配置
if(wmNews.getArticleId()!=null){
//将article和enable通过hashMap存储到kafka
Map<String,Object> map=new HashMap<>();
map.put("articleId",wmNews.getArticleId());
map.put("enable",wmNews.getEnable());
//kafka 发送消息
kafkaTemplate.send(WmNewsMessageConstants.WM_NEWS_UP_OR_DOWN_TOPIC,JSON.toJSONString(map));
}
}
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}
//在article端 用kafka接收消息 修改article表的上下架状态
@Component
@Slf4j
public class ArticleIsDownListener {
@Resource
private ApArticleConfigService aparticleConfigService;
@KafkaListener(topics = WmNewsMessageConstants.WM_NEWS_UP_OR_DOWN_TOPIC)
public void onMessage(String message){
if(StringUtils.isNotBlank(message)){
Map map = JSON.parseObject(message, Map.class);
aparticleConfigService.updateByMap(map);
log.info("article端文章配置修改,articleId={}",map.get("articleId"));
}
}
}
1.6 app文章搜索功能实现
1.6.1 ES索引的创建
搜索结果页面需要展示什么内容?
- 标题
- 布局
- 封面图片
- 发布时间
- 作者名称
- 文章id
- 作者id
- 静态url
哪些需要索引和分词
- 标题
- 内容
{
"mappings":{
"properties":{
"id":{
"type":"long"
},
"publishTime":{
"type":"date"
},
"layout":{
"type":"integer"
},
"images":{
"type":"keyword",
"index": false
},
"staticUrl":{
"type":"keyword",
"index": false
},
"authorId": {
"type": "long"
},
"authorName": {
"type": "text"
},
"title":{
"type":"text",
"analyzer":"ik_smart"
},
"content":{
"type":"text",
"analyzer":"ik_smart"
}
}
}
}
1.6.2 文章查询设置高亮功能
@Override
public ResponseResult search(UserSearchDto dto) throws IOException {
//1.检查参数
if(dto == null || StringUtils.isBlank(dto.getSearchWords())){
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
ApUser apUser= AppThreadLocalUtil.getUser();
//异步调用 保存搜索记录
if(apUser!=null&&dto.getFromIndex()==0){
apUserSearchService.insert(dto.getSearchWords(),apUser.getId());
}
//2.设置查询条件
SearchRequest searchRequest = new SearchRequest("app_info_article");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
//布尔查询
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
//关键字的分词之后查询
QueryStringQueryBuilder queryStringQueryBuilder = QueryBuilders.queryStringQuery(dto.getSearchWords()).field("title").field("content").defaultOperator(Operator.OR);
boolQueryBuilder.must(queryStringQueryBuilder);
//查询小于mindate的数据
RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery("publishTime").lt(dto.getMinBehotTime().getTime());
boolQueryBuilder.filter(rangeQueryBuilder);
//分页查询
searchSourceBuilder.from(0);
searchSourceBuilder.size(dto.getPageSize());
//按照发布时间倒序查询
searchSourceBuilder.sort("publishTime", SortOrder.DESC);
//设置高亮 title
HighlightBuilder highlightBuilder = new HighlightBuilder();
highlightBuilder.field("title");
highlightBuilder.preTags("<font style='color: red; font-size: inherit;'>");
highlightBuilder.postTags("</font>");
searchSourceBuilder.highlighter(highlightBuilder);
searchSourceBuilder.query(boolQueryBuilder);
searchRequest.source(searchSourceBuilder);
SearchResponse searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
//3.结果封装返回
List<Map> list = new ArrayList<>();
SearchHit[] hits = searchResponse.getHits().getHits();
for (SearchHit hit : hits) {
String json = hit.getSourceAsString();
Map map = JSON.parseObject(json, Map.class);
//处理高亮
if(hit.getHighlightFields() != null && hit.getHighlightFields().size() > 0){
Text[] titles = hit.getHighlightFields().get("title").getFragments();
String title = StringUtils.join(titles);
//高亮标题
map.put("h_title",title);
}else {
//原始标题
map.put("h_title",map.get("title"));
}
list.add(map);
}
return ResponseResult.okResult(list);
}
1.6.3 新增文章同步添加索引
// 在文章微服务中 使用kafka发送消息
private void createArticleESIndex(ApArticle apArticle, String content, String path) {
SearchArticleVo vo=new SearchArticleVo();
BeanUtils.copyProperties(apArticle,vo);
//设置内容 url
vo.setContent(content);
vo.setStaticUrl(path);
kafkaTemplate.send(ArticleConstants.ARTICLE_ES_SYNC_TOPIC,JSON.toJSONString(vo));
}
//在查询模块 接收消息 创建索引
@KafkaListener(topics = ArticleConstants.ARTICLE_ES_SYNC_TOPIC)
public void onMessage(String message){
if(StringUtils.isNotBlank(message)){
log.info("SyncArticleListener,message={}",message);
SearchArticleVo searchArticleVo = JSON.parseObject(message, SearchArticleVo.class);
IndexRequest indexRequest = new IndexRequest("app_info_article");
indexRequest.id(searchArticleVo.getId().toString());
indexRequest.source(message, XContentType.JSON);
try {
//创建索引
restHighLevelClient.index(indexRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
e.printStackTrace();
log.error("sync es error={}",e);
}
}
}
1.6.4 搜索记录crud
保存搜索记录实现步骤
//保存搜索记录到mongoDB中
@Override
@Async
public void insert(String keyword, Integer userId) {
//查询当前用户搜索的关键词
Query query = Query.query(Criteria.where("userId").is(userId).and("keyword").is(keyword));
ApUserSearch apUserSearch = mongoTemplate.findOne(query, ApUserSearch.class);
//存在 更新创新时间
if(apUserSearch!=null){
apUserSearch.setCreatedTime(new Date());
mongoTemplate.save(apUserSearch);
return ;
}
//不存在,判断当前历史记录总数量是否超过10
apUserSearch.setUserId(userId);
apUserSearch.setKeyword(keyword);
apUserSearch.setCreatedTime(new Date());
Query query1 = Query.query(Criteria.where("userId").is(userId));
query1.with(Sort.by(Sort.Direction.DESC,"createdTime"));
List<ApUserSearch> apUserSearchList = mongoTemplate.find(query1, ApUserSearch.class);
// 如果消息小于10 直接插入
if(apUserSearchList.size()<10){
mongoTemplate.save(apUserSearch);
return ;
}
//如果大于10 则查出来最后一条消息并修改
ApUserSearch lastUserSearch = apUserSearchList.get(apUserSearchList.size() - 1); mongoTemplate.findAndReplace(Query.query(Criteria.where("id").is(lastUserSearch.getId())),apUserSearch);
}
//查询搜索的历史记录
@Override
public ResponseResult findUserSearch() {
//获得当前用户
ApUser user = AppThreadLocalUtil.getUser();
if(user==null){
return ResponseResult.errorResult(AppHttpCodeEnum.AP_USER_DATA_NOT_EXIST);
}
//根据用户id找出历史搜索记录
List<ApUserSearch> apUserSearches = mongoTemplate.find(Query.query(Criteria.where("userId").is(user.getId())).with(Sort.by(Sort.Direction.DESC, "createTime"))
, ApUserSearch.class);
return ResponseResult.okResult(apUserSearches);
}
//删除某用户的搜索记录
@Override
public ResponseResult delUserSearch(HistorySearchDto dto) {
if(dto==null){
return ResponseResult.errorResult(AppHttpCodeEnum.DATA_NOT_EXIST);
}
//判断是否登录
ApUser user = AppThreadLocalUtil.getUser();
if(user==null){
return ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
}
//删除当前历史记录
mongoTemplate.remove(Query.query(Criteria.where("userId").is(user.getId()).and("id").is(dto.getId())),ApUserSearch.class);
return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS);
}
1.6.5 关键字联想词
搜索词-数据来源
通常是网上搜索比较高的一些词,通常在企业中由两部分来源
- 自己维护搜索词
通过分析用户搜索频率较高的词,按照排名作为搜索词 - 第三方获取
关键词规划师(百度),5118,爱站网
@Override
public ResponseResult findAssociate(UserSearchDto userSearchDto) {
// 参数检测
if(userSearchDto==null || StringUtils.isBlank(userSearchDto.getSearchWords())){
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
//分页检查
if(userSearchDto.getPageSize()>20){
userSearchDto.setPageSize(20);
}
//3 执行查询 模糊查询
Query query = Query.query(where("associateWords").regex(".*?\\" + userSearchDto.getSearchWords() + ".*"));
query.limit(userSearchDto.getPageSize());
List<ApAssociateWords> wordsList = mongoTemplate.find(query, ApAssociateWords.class);
return ResponseResult.okResult(wordsList);
}
1.7 热文章计算
1.7.1 分布式的任务调度
什么是分布式的任务调度?
- 在分布式架构下,一个服务往往会部署多个实例来运行我们的业务,如果在这种分布式系统环境下运行任务调度,我们称之为分布式任务调度
xxl-job简介
- XXL-JOB是一个分布式任务调度平台,其核心设计目标是开发迅速,学习简单,轻量级,及扩展.现已开放源代码并接入多家公司线上产品线,开箱即用
任务详解-执行器
- 执行器:任务的绑定执行器,任务触发调度时将会注册发现注册成功的执行器,实现任务自动发现功能
- 另一方面也可以方便的及进行任务分组.每个任务必须绑定一个执行器
任务详解-基础配置
- 执行器:每个任务必须绑定一个执行器,方便给任务进行分组
- 任务描述:任务的描述信息,便于任务管理
- 负责人:任务的负责人
- 报警邮件:任务调度失败时邮件通知的邮箱地址,支持配置多邮箱地址
调度类型:
- 无:该类型不会主动触发调度
- CRON:该类型将会通过CRON,触发任务调度
- 固定速度:该类型将会以固定速度,触发任务调度,按照固定的时间间隔,周期性触发
任务详情-基础配置
- 运行模式:
bean模式:任务以Jobhandler方式维护在执行器端;需要结合"JobHandler"属性匹配执行器中任务; - JobHandler:运行模式为"BEAN模式"时生效,对应执行器中新开发的JobHandler类"@JobHandler"注解自定义的value值
- 执行参数:任务执行所需的参数
任务详解-阻塞处理策略
阻塞处理策略:调度过于密集执行器来不及处理时的处理策略
- 单机串行(默认):调度请求进入单机执行器后,调度请求进入FIFO(First Input First Output)队列并以串行方式运行
- 丢弃后续调度:调度请求进入单机执行器后,发现执行器存在运行的调度任务,本次请求将会被丢弃并标记为失败
- 覆盖之前的调度:调度请求进入单机执行器后,发现执行器存在运行的调度任务,将会终止运行中的调度任务并清空队列,然后运行本地调度任务;
任务详解-路由策略
- 轮询
- 分片广播
1.7.2 热点文章的定时计算
/**
* 计算热点文章
*/
@Override
public void computerHotArticle() {
Date dateParam = DateTime.now().minusDays(5).toDate();
//查询出5天内的文章
List<ApArticle> apArticles=apArticleMapper.findArticleList5Days(dateParam);
//计算热点文章的分值
List<HotArticleVo> hotArticleVo=computerHotArticle(apArticles);
//将各个频道各自缓存热点30篇文章
cacheTagToRedis(hotArticleVo);
}
private void cacheTagToRedis(List<HotArticleVo> hotArticleVo) {
ResponseResult channels = iWemediaClient.getChannels();
if(channels.getCode().equals(200)){
String channelJson = JSON.toJSONString(channels.getData());
List<WmChannel> wmChannels= JSON.parseArray(channelJson, WmChannel.class);
if(wmChannels!=null && wmChannels.size()>0){
for (WmChannel wmChannel : wmChannels) {
//收集出相对频道的所有数据
List<HotArticleVo> hotArticleVos = hotArticleVo.stream().filter(x -> x.getChannelId().equals(wmChannel.getId())).collect(Collectors.toList());
//给文章进行排序 取三十条分数较高的 存入redis
hotArticleVos = hotArticleVos.stream().sorted(Comparator.comparing(HotArticleVo::getScore).reversed()).collect(Collectors.toList());
//如果大于30则取前30
if(hotArticleVos.size()>30){
hotArticleVos = hotArticleVos.subList(0, 30);
}
//缓存到redis
cacheService.set(ArticleConstants.HOT_ARTICLE_FIRST_PAGE+wmChannel.getId(), JSON.toJSONString(hotArticleVos));
}
}
//数据不分类的前三十条热门数据
hotArticleVo=hotArticleVo.stream().sorted(Comparator.comparing(HotArticleVo::getScore).reversed()).collect(Collectors.toList());
if(hotArticleVo.size()>30){
hotArticleVo=hotArticleVo.subList(0,30);
}
cacheService.set(ArticleConstants.HOT_ARTICLE_FIRST_PAGE+ArticleConstants.DEFAULT_TAG,JSON.toJSONString(hotArticleVo));
}
}
/**
* 将文章ApArticle集合转换为 HotArticleVo集合
* @param apArticles
* @return
*/
private List<HotArticleVo> computerHotArticle(List<ApArticle> apArticles) {
List<HotArticleVo> lists=new ArrayList<>();
if(apArticles!=null && apArticles.size()>0){
for (ApArticle apArticle : apArticles) {
HotArticleVo hotArticleVo=new HotArticleVo();
BeanUtils.copyProperties(apArticle,hotArticleVo);
Integer score=computeScore(hotArticleVo);
hotArticleVo.setScore(score);
lists.add(hotArticleVo);
}
}
return lists;
}
/**
* 计算分值权重
* @param apArticle
* @return
*/
private Integer computeScore(HotArticleVo apArticle){
Integer score = 0;
if(apArticle.getLikes() != null){
score += apArticle.getLikes() * ArticleConstants.HOT_ARTICLE_LIKE_WEIGHT;
}
if(apArticle.getViews() != null){
score += apArticle.getViews();
}
if(apArticle.getComment() != null){
score += apArticle.getComment() * ArticleConstants.HOT_ARTICLE_COMMENT_WEIGHT;
}
if(apArticle.getCollection() != null){
score += apArticle.getCollection() * ArticleConstants.HOT_ARTICLE_COLLECTION_WEIGHT;
}
return score;
}