|
@@ -0,0 +1,333 @@
|
|
|
+package com.webchat.pgc.service;
|
|
|
+
|
|
|
+
|
|
|
+import com.webchat.common.bean.APIPageResponseBean;
|
|
|
+import com.webchat.common.constants.WebConstant;
|
|
|
+import com.webchat.common.enums.ArticleStatusEnum;
|
|
|
+import com.webchat.common.enums.RedisKeyEnum;
|
|
|
+import com.webchat.common.enums.messagequeue.MessageQueueEnum;
|
|
|
+import com.webchat.common.service.RedisService;
|
|
|
+import com.webchat.common.service.messagequeue.producer.MessageQueueProducer;
|
|
|
+import com.webchat.common.util.JsonUtil;
|
|
|
+import com.webchat.domain.dto.queue.ArticleDelayMessageDTO;
|
|
|
+import com.webchat.domain.vo.request.publicaccount.SaveArticleRequestVO;
|
|
|
+import com.webchat.domain.vo.response.UserBaseResponseInfoVO;
|
|
|
+import com.webchat.domain.vo.response.mess.PublicAccountArticleMessageVO;
|
|
|
+import com.webchat.domain.vo.response.publicaccount.ArticleBaseResponseVO;
|
|
|
+import com.webchat.pgc.repository.dao.IArticleDAO;
|
|
|
+import com.webchat.pgc.repository.entity.ArticleEntity;
|
|
|
+import org.apache.commons.lang3.ObjectUtils;
|
|
|
+import org.apache.commons.lang3.StringUtils;
|
|
|
+import org.springframework.beans.BeanUtils;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.data.domain.Page;
|
|
|
+import org.springframework.data.domain.PageRequest;
|
|
|
+import org.springframework.data.domain.Pageable;
|
|
|
+import org.springframework.data.domain.Sort;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.util.Assert;
|
|
|
+import org.springframework.util.CollectionUtils;
|
|
|
+
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 公众号文章服务
|
|
|
+ *
|
|
|
+ */
|
|
|
+@Service
|
|
|
+public class OfficialArticleService {
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private IArticleDAO articleDAO;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private AccountService accountService;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private RedisService redisService;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private MessageQueueProducer<ArticleDelayMessageDTO, Long> messageQueueProducer;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 公众号推文
|
|
|
+ *
|
|
|
+ * @param saveArticleRequest
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ public Long submit(SaveArticleRequestVO saveArticleRequest) {
|
|
|
+ /**
|
|
|
+ * 1. 持久化文化到数据库
|
|
|
+ */
|
|
|
+ ArticleEntity articleEntity = this.convert(saveArticleRequest);
|
|
|
+ articleEntity = articleDAO.save(articleEntity);
|
|
|
+ /**
|
|
|
+ * 2. 缓存文章详情到redis
|
|
|
+ */
|
|
|
+ this.refreshArticleRedisDetailCache(articleEntity);
|
|
|
+ /**
|
|
|
+ * 3. 推文:MQ ---> 延迟队列 + 普通列表
|
|
|
+ * 《实时推文》:用户未指定推送时间(默认当前时间)
|
|
|
+ * 《延迟推送》:用户指定未来时间推送
|
|
|
+ */
|
|
|
+ final String publicAccount = saveArticleRequest.getPublicAccount();
|
|
|
+ final Long articleId = articleEntity.getId();
|
|
|
+ final Long pushTime = saveArticleRequest.getPlanPushTime();
|
|
|
+ this.doSubmitDelayQueue(publicAccount, articleId, pushTime);
|
|
|
+ /**
|
|
|
+ * 4. 公众号文章消息数据持久化
|
|
|
+ */
|
|
|
+ // TODO
|
|
|
+ return articleId;
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 公众号文章推送计划加入延迟队列
|
|
|
+ *
|
|
|
+ * @param publicAccount 公众号账号
|
|
|
+ * @param articleId 公众号文章
|
|
|
+ * @param pushTime 设定的推送时间
|
|
|
+ */
|
|
|
+ private void doSubmitDelayQueue(String publicAccount, Long articleId, Long pushTime) {
|
|
|
+ ArticleDelayMessageDTO message = new ArticleDelayMessageDTO();
|
|
|
+ message.setArticleId(articleId);
|
|
|
+ message.setPublicAccount(publicAccount);
|
|
|
+ // 延迟推文时间(如果未设置发布时间,则立即发布)
|
|
|
+ pushTime = pushTime == null ? System.currentTimeMillis() : pushTime;
|
|
|
+ message.setTime(pushTime);
|
|
|
+ // 提交队列:优先级队列(一级队列)
|
|
|
+ messageQueueProducer.prioritySend(MessageQueueEnum.QUEUE_OFFICIAL_ARTICLE_PUSH_MESSAGE, message, pushTime);
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 刷新公众号文章redis缓存
|
|
|
+ *
|
|
|
+ * @param articleId
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ private ArticleBaseResponseVO refreshArticleRedisDetailCache(Long articleId) {
|
|
|
+ if (articleId == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ ArticleEntity articleEntity = articleDAO.findById(articleId).orElse(null);
|
|
|
+ return this.refreshArticleRedisDetailCache(articleEntity);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 刷新公众号文章redis缓存
|
|
|
+ *
|
|
|
+ * @param articleEntity
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ private ArticleBaseResponseVO refreshArticleRedisDetailCache(ArticleEntity articleEntity) {
|
|
|
+ if (articleEntity == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String cacheKey = RedisKeyEnum.ARTICLE_DETAIL_CACHE.getKey(String.valueOf(articleEntity.getId()));
|
|
|
+ // 文章详情我们使用string类型来缓存,每个文章有自己的失效时间,避免缓存雪崩
|
|
|
+ ArticleBaseResponseVO articleBaseResponseVO = this.convert(articleEntity);
|
|
|
+ redisService.set(cacheKey, JsonUtil.toJsonString(articleBaseResponseVO), RedisKeyEnum.ARTICLE_DETAIL_CACHE.getExpireTime());
|
|
|
+ return articleBaseResponseVO;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 缓存空值,防止缓存击穿
|
|
|
+ *
|
|
|
+ * @param articleId
|
|
|
+ */
|
|
|
+ private void refreshArticleNoneCache(Long articleId) {
|
|
|
+ String cacheKey = RedisKeyEnum.ARTICLE_DETAIL_CACHE.getKey(String.valueOf(articleId));
|
|
|
+ // 文章详情我们使用string类型来缓存,每个文章有自己的失效时间,避免缓存雪崩
|
|
|
+ redisService.set(cacheKey, WebConstant.CACHE_NONE, RedisKeyEnum.ARTICLE_DETAIL_CACHE.getExpireTime());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 浏览文章
|
|
|
+ *
|
|
|
+ * @param articleId
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ public ArticleBaseResponseVO viewArticle(Long articleId) {
|
|
|
+ ArticleBaseResponseVO article = this.getArticleDetailFromCache(articleId);
|
|
|
+ if (article == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ // 设置公众号信息
|
|
|
+ article.setPublicAccountInfo(accountService.accountInfo(article.getPublicAccount()));
|
|
|
+ return article;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询消息列表所有文章信息
|
|
|
+ *
|
|
|
+ * @param articleId
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ public PublicAccountArticleMessageVO getPublicAccountArticleMessage(Long articleId) {
|
|
|
+ ArticleBaseResponseVO article = this.getArticleDetailFromCache(articleId);
|
|
|
+ if (article == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ PublicAccountArticleMessageVO publicAccountArticleMessage = new PublicAccountArticleMessageVO();
|
|
|
+ publicAccountArticleMessage.setTitle(article.getTitle());
|
|
|
+ publicAccountArticleMessage.setDescription(article.getDescription());
|
|
|
+ publicAccountArticleMessage.setCover(article.getCover());
|
|
|
+ publicAccountArticleMessage.setArticleId(articleId);
|
|
|
+ publicAccountArticleMessage.setRedirectUrl(article.getRedirectUrl());
|
|
|
+ return publicAccountArticleMessage;
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ public ArticleBaseResponseVO getArticleDetailFromCache(Long articleId, boolean needContent) {
|
|
|
+ ArticleBaseResponseVO articleVO = this.getArticleDetailFromCache(articleId);
|
|
|
+ if (articleVO == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (!needContent) {
|
|
|
+ articleVO.setContent(null);
|
|
|
+ }
|
|
|
+ return articleVO;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询公众号文章详情
|
|
|
+ *
|
|
|
+ * @param articleId
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ public ArticleBaseResponseVO getArticleDetailFromCache(Long articleId) {
|
|
|
+
|
|
|
+ String cacheKey = RedisKeyEnum.ARTICLE_DETAIL_CACHE.getKey(String.valueOf(articleId));
|
|
|
+ String cache = redisService.get(cacheKey);
|
|
|
+ // 这里可能存在击穿问题(比如:有人恶意那不存在的文章一致查询)
|
|
|
+ // 文章缓存击穿解决办法:我们缓存一个空值
|
|
|
+ if (StringUtils.isNotBlank(cache)) {
|
|
|
+ if (WebConstant.CACHE_NONE.equals(cache)) {
|
|
|
+ // 文章不存在,直接返回null,不需要在查库
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return JsonUtil.fromJson(cache, ArticleBaseResponseVO.class);
|
|
|
+ }
|
|
|
+ // 缓存不存在主动查询数据库,重新刷新缓存
|
|
|
+ ArticleBaseResponseVO articleBase = this.refreshArticleRedisDetailCache(articleId);
|
|
|
+ if (articleBase == null) {
|
|
|
+ // 数据库文章不存在,这里缓存空值,防止redis击穿
|
|
|
+ this.refreshArticleNoneCache(articleId);
|
|
|
+ }
|
|
|
+ return articleBase;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 批量查询redis,获取文章详情缓存
|
|
|
+ *
|
|
|
+ * 场景:公众号详情页,一次可能查询10篇文章
|
|
|
+ * @param articleIdList
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ public Map<Long, ArticleBaseResponseVO> batchGetArticleDetailFromCache(List<Long> articleIdList) {
|
|
|
+ if (CollectionUtils.isEmpty(articleIdList)) {
|
|
|
+ return Collections.emptyMap();
|
|
|
+ }
|
|
|
+ Map<Long, ArticleBaseResponseVO> batchGetResult = new HashMap<>();
|
|
|
+ // 构造批量查询redis的缓存key
|
|
|
+ List<String> cacheKeys = articleIdList.stream().map(
|
|
|
+ id -> RedisKeyEnum.ARTICLE_DETAIL_CACHE.getKey(String.valueOf(id)))
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ // 批量查询redis
|
|
|
+ List<String> caches = redisService.mget(cacheKeys);
|
|
|
+ for (int i = 0; i < articleIdList.size(); i++) {
|
|
|
+ Long articleId = articleIdList.get(i);
|
|
|
+ String cache = caches.get(i);
|
|
|
+ ArticleBaseResponseVO articleBaseResponseVO;
|
|
|
+ if (StringUtils.isNotBlank(cache)) {
|
|
|
+ articleBaseResponseVO = JsonUtil.fromJson(cache, ArticleBaseResponseVO.class);
|
|
|
+ } else {
|
|
|
+ articleBaseResponseVO = this.refreshArticleRedisDetailCache(articleId);
|
|
|
+ }
|
|
|
+ batchGetResult.put(articleId, articleBaseResponseVO);
|
|
|
+ }
|
|
|
+ return batchGetResult;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 分页查询文章列表
|
|
|
+ *
|
|
|
+ * @param pageNo
|
|
|
+ * @param pageSize
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ public APIPageResponseBean<List<ArticleBaseResponseVO>> page(Integer pageNo, Integer pageSize) {
|
|
|
+ Pageable pageable = PageRequest.of(pageNo - 1, pageSize, Sort.by(Sort.Direction.DESC, "id"));
|
|
|
+ Page<ArticleEntity> userEntities = articleDAO.findAll(pageable);
|
|
|
+ List<ArticleBaseResponseVO> articles = new ArrayList<>();
|
|
|
+ if (userEntities != null && !CollectionUtils.isEmpty(userEntities.getContent())) {
|
|
|
+ // 走缓存批量查询公众号信息
|
|
|
+ Set<String> publicAccounts = userEntities.stream().map(ArticleEntity::getPublicAccount).collect(Collectors.toSet());
|
|
|
+ Map<String, UserBaseResponseInfoVO> accounts = accountService.batchGet(publicAccounts);
|
|
|
+ // 批量构造返回文章列表参数
|
|
|
+ articles = userEntities.getContent().stream().map(a -> {
|
|
|
+ ArticleBaseResponseVO article = convert(a);
|
|
|
+ // 列表一般不需要返回详情信息,减少网络数据包传输设置为null
|
|
|
+ article.setContent(null);
|
|
|
+ // 这里偷懒了,建议
|
|
|
+ article.setPublicAccountInfo(accounts.get(article.getPublicAccount()));
|
|
|
+ return article;
|
|
|
+ }).collect(Collectors.toList());
|
|
|
+ }
|
|
|
+ return APIPageResponseBean.success(pageNo, pageSize, userEntities.getTotalElements(), articles);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ private ArticleBaseResponseVO convert(ArticleEntity articleEntity) {
|
|
|
+ ArticleBaseResponseVO articleBase = new ArticleBaseResponseVO();
|
|
|
+ BeanUtils.copyProperties(articleEntity, articleBase);
|
|
|
+ if (articleEntity.getPlanPushDate() != null) {
|
|
|
+ articleBase.setPlanPushTime(articleEntity.getPlanPushDate().getTime());
|
|
|
+ }
|
|
|
+ if (articleEntity.getCreateDate() != null) {
|
|
|
+ articleBase.setPublishTime(articleEntity.getCreateDate().getTime());
|
|
|
+ }
|
|
|
+ return articleBase;
|
|
|
+ }
|
|
|
+
|
|
|
+ private ArticleEntity convert(SaveArticleRequestVO saveArticleRequest) {
|
|
|
+ Long articleId = saveArticleRequest.getId();
|
|
|
+ String author = saveArticleRequest.getAuthor();
|
|
|
+ Date now = new Date();
|
|
|
+ ArticleEntity articleEntity;
|
|
|
+ if (articleId != null) {
|
|
|
+ articleEntity = articleDAO.findById(articleId).orElse(null);
|
|
|
+ Assert.notNull(articleEntity, "文章更新失败: 文章不存在!");
|
|
|
+ Assert.isTrue(ObjectUtils.equals(articleEntity.getAuthor(), author), "没有更新权限!");
|
|
|
+ } else {
|
|
|
+ articleEntity = new ArticleEntity();
|
|
|
+ articleEntity.setCreateDate(now);
|
|
|
+ articleEntity.setCreateBy(author);
|
|
|
+ articleEntity.setAuthor(author);
|
|
|
+ articleEntity.setStatus(ArticleStatusEnum.WAIT_PUSH.getStatus());
|
|
|
+ }
|
|
|
+ articleEntity.setRedirectUrl(saveArticleRequest.getRedirectUrl());
|
|
|
+ articleEntity.setStatus(articleEntity.getStatus());
|
|
|
+ articleEntity.setCover(saveArticleRequest.getCover());
|
|
|
+ articleEntity.setDescription(saveArticleRequest.getDescription());
|
|
|
+ articleEntity.setTitle(saveArticleRequest.getTitle());
|
|
|
+ articleEntity.setContent(saveArticleRequest.getContent());
|
|
|
+ articleEntity.setPublicAccount(saveArticleRequest.getPublicAccount());
|
|
|
+ articleEntity.setSigns(saveArticleRequest.getSigns());
|
|
|
+ Date planPushDate = new Date();
|
|
|
+ if (saveArticleRequest.getPlanPushTime() != null) {
|
|
|
+ planPushDate = new Date(saveArticleRequest.getPlanPushTime());
|
|
|
+ }
|
|
|
+ articleEntity.setPlanPushDate(planPushDate);
|
|
|
+ return articleEntity;
|
|
|
+ }
|
|
|
+}
|