|
|
|
@ -12,6 +12,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
|
|
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
|
|
|
import com.youlai.boot.common.constant.CommonConstants; |
|
|
|
import com.youlai.boot.common.exception.MsgException; |
|
|
|
import com.youlai.boot.common.result.Result; |
|
|
|
import com.youlai.boot.common.util.CoordinateTransformUtils; |
|
|
|
import com.youlai.boot.common.util.FileUtils; |
|
|
|
import com.youlai.boot.common.util.JavaVCUtils; |
|
|
|
@ -24,6 +25,12 @@ import com.youlai.boot.mini.mapper.MiniStrayAnimalNoteMapper; |
|
|
|
import com.youlai.boot.mini.mapper.MiniStrayAnimalNoteCollectMapper; |
|
|
|
import com.youlai.boot.mini.mapper.MiniStrayAnimalNoteLikeMapper; |
|
|
|
import com.youlai.boot.mini.mapper.MiniStrayAnimalNoteMediaMapper; |
|
|
|
import com.youlai.boot.mini.mapper.MiniStrayAnimalNoteViewMapper; |
|
|
|
import com.youlai.boot.mini.model.entity.MiniStrayAnimalNoteView; |
|
|
|
import org.redisson.api.RBloomFilter; |
|
|
|
import org.redisson.api.RLock; |
|
|
|
import org.redisson.api.RedissonClient; |
|
|
|
import org.springframework.dao.DuplicateKeyException; |
|
|
|
import com.youlai.boot.mini.model.entity.MiniStrayAnimalNoteCollect; |
|
|
|
import com.youlai.boot.mini.model.entity.MiniStrayAnimalNoteLike; |
|
|
|
import com.youlai.boot.mini.model.form.NoteCollectForm; |
|
|
|
@ -60,6 +67,8 @@ import javax.imageio.ImageIO; |
|
|
|
import java.awt.image.BufferedImage; |
|
|
|
import java.io.File; |
|
|
|
import java.util.*; |
|
|
|
import java.util.concurrent.TimeUnit; |
|
|
|
import java.util.stream.Collectors; |
|
|
|
|
|
|
|
/** |
|
|
|
* 流浪动物业务实现类 |
|
|
|
@ -75,10 +84,22 @@ public class StrayAnimalServiceImpl extends ServiceImpl<MiniStrayAnimalMapper, M |
|
|
|
@Value("${oss.aliyun.bucket-name}") |
|
|
|
private String bucketName; |
|
|
|
|
|
|
|
@Value("${bloom.view.expected-insertions:1000}") |
|
|
|
private Integer bloomExpectedInsertions; |
|
|
|
|
|
|
|
@Value("${bloom.view.fpp:0.001}") |
|
|
|
private Double bloomFpp; |
|
|
|
|
|
|
|
@Value("${bloom.view.expire-days:}") |
|
|
|
private Integer bloomExpireDays; |
|
|
|
|
|
|
|
private final static String OSS_THUMBNAIL_DIR = "animal_note/thumbnail/"; |
|
|
|
private final static String OSS_IMAGE_DIR = "animal_note/image/"; |
|
|
|
private final static String OSS_VIDEO_DIR = "animal_note/video/"; |
|
|
|
|
|
|
|
// 布隆过滤器常量
|
|
|
|
private static final String BLOOM_VIEW_KEY_PREFIX = "mini:note:view:bloom:"; |
|
|
|
// 布隆重建分布式锁前缀
|
|
|
|
private static final String BLOOM_REBUILD_LOCK_PREFIX = "lock:rebuild:bloom:"; |
|
|
|
|
|
|
|
private final AliyunFileService aliyunFileService; |
|
|
|
private final MiniPointRecordService pointRecordService; |
|
|
|
@ -87,8 +108,11 @@ public class StrayAnimalServiceImpl extends ServiceImpl<MiniStrayAnimalMapper, M |
|
|
|
private final MiniStrayAnimalNoteLikeMapper miniStrayAnimalNoteLikeMapper; |
|
|
|
private final MiniStrayAnimalNoteCollectMapper miniStrayAnimalNoteCollectMapper; |
|
|
|
private final MiniStrayAnimalNoteMediaMapper miniStrayAnimalNoteMediaMapper; |
|
|
|
private final MiniStrayAnimalNoteViewMapper noteViewMapper; |
|
|
|
private final MiniStrayAnimalMapper miniStrayAnimalMapper; |
|
|
|
private final UserMapper userMapper; |
|
|
|
private final MiniStrayAnimalNoteViewMapper miniStrayAnimalNoteViewMapper; |
|
|
|
private final RedissonClient redissonClient; |
|
|
|
|
|
|
|
private final MiniStrayAnimalConverter miniStrayAnimalConverter; |
|
|
|
|
|
|
|
@ -612,6 +636,7 @@ public class StrayAnimalServiceImpl extends ServiceImpl<MiniStrayAnimalMapper, M |
|
|
|
|
|
|
|
MiniStrayAnimalNote appStrayAnimalNote = miniStrayAnimalNoteMapper.selectOne(new LambdaQueryWrapper<MiniStrayAnimalNote>() |
|
|
|
.eq(MiniStrayAnimalNote::getStrayAnimalId, animal.getId())); |
|
|
|
|
|
|
|
if (appStrayAnimalNote != null) { |
|
|
|
Map<String, Object> param = new HashMap<>(); |
|
|
|
param.put("noteId", appStrayAnimalNote.getId()); |
|
|
|
@ -636,6 +661,44 @@ public class StrayAnimalServiceImpl extends ServiceImpl<MiniStrayAnimalMapper, M |
|
|
|
strayAnimalDetailsVO.setVideos(videos); |
|
|
|
} |
|
|
|
|
|
|
|
// 记录用户浏览历史
|
|
|
|
if (miniUserId != null && appStrayAnimalNote != null) { |
|
|
|
Long noteId = appStrayAnimalNote.getId(); |
|
|
|
long currentTime = System.currentTimeMillis(); |
|
|
|
// 1. 写数据库
|
|
|
|
try { |
|
|
|
MiniStrayAnimalNoteView view = new MiniStrayAnimalNoteView(); |
|
|
|
view.setUuid(IdWorker.get32UUID()); |
|
|
|
view.setMiniUserId(miniUserId); |
|
|
|
view.setNoteId(noteId); |
|
|
|
view.setCreateTimestamp(currentTime); |
|
|
|
view.setCreateTime(new Date(currentTime)); |
|
|
|
view.setCreateBy(miniUserId); |
|
|
|
noteViewMapper.insertOrUpdateView(view); |
|
|
|
} catch (DuplicateKeyException e) { |
|
|
|
log.debug("用户已浏览过该笔记,userId:{}, noteId:{}", miniUserId, noteId); |
|
|
|
} catch (Exception e) { |
|
|
|
log.error("记录浏览历史到数据库失败,userId:{}, noteId:{}", miniUserId, noteId, e); |
|
|
|
} |
|
|
|
|
|
|
|
// 2. 写布隆过滤器
|
|
|
|
try { |
|
|
|
String bloomKey = BLOOM_VIEW_KEY_PREFIX + miniUserId; |
|
|
|
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(bloomKey); |
|
|
|
if (!bloomFilter.isExists()) { |
|
|
|
// 初始化新布隆,用配置的参数
|
|
|
|
bloomFilter.tryInit(bloomExpectedInsertions, bloomFpp); |
|
|
|
// 只有配置了过期时间且大于0时才设置,否则永久有效
|
|
|
|
if (bloomExpireDays != null && bloomExpireDays > 0) { |
|
|
|
bloomFilter.expire(bloomExpireDays, TimeUnit.DAYS); |
|
|
|
} |
|
|
|
} |
|
|
|
bloomFilter.add(noteId); |
|
|
|
} catch (Exception e) { |
|
|
|
log.error("记录浏览历史到布隆失败,userId:{}, noteId:{}", miniUserId, noteId, e); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
return strayAnimalDetailsVO; |
|
|
|
} |
|
|
|
|
|
|
|
@ -781,17 +844,65 @@ public class StrayAnimalServiceImpl extends ServiceImpl<MiniStrayAnimalMapper, M |
|
|
|
int pageSize = query.getPageSize(); |
|
|
|
int querySize = pageSize + 1; |
|
|
|
|
|
|
|
// 2. 从DB查询 N+1 条数据
|
|
|
|
List<StrayAnimalShortVO> list = miniStrayAnimalMapper.getWaterfall( |
|
|
|
// 2. 从DB查询 N+1 条 原始未过滤数据
|
|
|
|
List<StrayAnimalShortVO> originalList = miniStrayAnimalMapper.getWaterfall( |
|
|
|
query.getCursor(), |
|
|
|
querySize, |
|
|
|
query.getAnimalType(), |
|
|
|
userId |
|
|
|
); |
|
|
|
|
|
|
|
// 3. 设置默认图片(如果没有封面图)
|
|
|
|
if (list != null && !list.isEmpty()) { |
|
|
|
list.forEach(item -> { |
|
|
|
// 3. 先基于原始数据计算分页信息(完全不受过滤影响)
|
|
|
|
Long nextCursor = null; |
|
|
|
boolean isLastPage = true; |
|
|
|
if (CollUtil.isNotEmpty(originalList)) { |
|
|
|
if (originalList.size() > pageSize) { |
|
|
|
nextCursor = originalList.get(pageSize - 1).getId(); |
|
|
|
isLastPage = false; |
|
|
|
} |
|
|
|
} else { |
|
|
|
return WaterfallResult.of(Collections.emptyList(), null, true); |
|
|
|
} |
|
|
|
|
|
|
|
// 4.布隆过滤已浏览内容 ==========
|
|
|
|
List<StrayAnimalShortVO> resultList = originalList.stream().limit(pageSize).collect(Collectors.toList()); |
|
|
|
// 仅登录用户做过滤
|
|
|
|
if (userId != null && CollUtil.isNotEmpty(resultList)) { |
|
|
|
try { |
|
|
|
String bloomKey = BLOOM_VIEW_KEY_PREFIX + userId; |
|
|
|
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(bloomKey); |
|
|
|
if (bloomFilter.isExists()) { |
|
|
|
// 布隆存在正常过滤
|
|
|
|
resultList = resultList.stream() |
|
|
|
.filter(item -> !bloomFilter.contains(item.getId())) |
|
|
|
.collect(Collectors.toList()); |
|
|
|
} else { |
|
|
|
log.debug("用户{}布隆不存在,同步重建浏览记录", userId); |
|
|
|
try { |
|
|
|
// 同步重建近1年的浏览记录,对接口响应影响极小
|
|
|
|
rebuildUserBloomFromDB(userId, 365); |
|
|
|
// 重建完成后,立即用新布隆过滤当前页数据,用户第一页就生效
|
|
|
|
RBloomFilter<Long> newBloomFilter = redissonClient.getBloomFilter(bloomKey); |
|
|
|
if (newBloomFilter.isExists()) { |
|
|
|
resultList = resultList.stream() |
|
|
|
.filter(item -> !newBloomFilter.contains(item.getId())) |
|
|
|
.collect(Collectors.toList()); |
|
|
|
} |
|
|
|
log.debug("用户{}布隆同步重建完成,本次已过滤已浏览内容", userId); |
|
|
|
} catch (Exception ex) { |
|
|
|
// 重建失败跳过过滤,不影响主流程,下次访问自动重试
|
|
|
|
log.error("用户{}布隆同步重建失败,本次不过滤,下次访问自动重试", userId, ex); |
|
|
|
} |
|
|
|
} |
|
|
|
} catch (Exception e) { |
|
|
|
// Redis异常自动降级为不过滤,不影响用户正常使用,恢复后下次访问自动重建
|
|
|
|
log.error("布隆操作异常,自动降级不过滤,userId:{}, cursor:{}", userId, query.getCursor(), e); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 5. 设置默认图片(原有逻辑不变)
|
|
|
|
if (CollUtil.isNotEmpty(resultList)) { |
|
|
|
resultList.forEach(item -> { |
|
|
|
if (StrUtil.isBlank(item.getFirstImageUrl())) { |
|
|
|
switch (item.getAnimalType()) { |
|
|
|
case "cat": |
|
|
|
@ -808,22 +919,113 @@ public class StrayAnimalServiceImpl extends ServiceImpl<MiniStrayAnimalMapper, M |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
// 4. 计算下一页游标(N+1 方案:如果返回数量 > pageSize 说明有下一页,取前 pageSize 条)
|
|
|
|
Long nextCursor = null; |
|
|
|
boolean isLastPage = true; |
|
|
|
List<StrayAnimalShortVO> resultList = list; |
|
|
|
|
|
|
|
if (list != null && !list.isEmpty()) { |
|
|
|
if (list.size() > pageSize) { |
|
|
|
// 取前 pageSize 条
|
|
|
|
resultList = list.subList(0, pageSize); |
|
|
|
// 用第 pageSize 条的 id 作为游标
|
|
|
|
nextCursor = list.get(pageSize - 1).getId(); |
|
|
|
isLastPage = false; |
|
|
|
return WaterfallResult.of(resultList, nextCursor, isLastPage); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 重建用户的所有浏览记录布隆过滤器(全量重建) |
|
|
|
* @param userId 用户ID |
|
|
|
*/ |
|
|
|
@Override |
|
|
|
public void rebuildUserBloomFromDB(Long userId) { |
|
|
|
// days传0代表重建所有历史记录
|
|
|
|
rebuildUserBloomFromDB(userId, 0); |
|
|
|
} |
|
|
|
|
|
|
|
return WaterfallResult.of(resultList, nextCursor, isLastPage); |
|
|
|
/** |
|
|
|
* 重建用户的浏览记录布隆过滤器 |
|
|
|
* @param userId 用户ID |
|
|
|
* @param days 重建最近多少天的记录,<=0时重建所有历史记录 |
|
|
|
*/ |
|
|
|
@Override |
|
|
|
public void rebuildUserBloomFromDB(Long userId, int days) { |
|
|
|
if (userId == null) { |
|
|
|
return; |
|
|
|
} |
|
|
|
Long startTime = null; |
|
|
|
if (days > 0) { |
|
|
|
startTime = System.currentTimeMillis() - (long) days * 24 * 60 * 60 * 1000; |
|
|
|
} |
|
|
|
// startTime为null时查询用户所有历史浏览记录,否则只查指定天数内的
|
|
|
|
List<Long> viewedNoteIds = miniStrayAnimalNoteViewMapper.selectViewedNoteIdsByUserAndTime(userId, startTime); |
|
|
|
|
|
|
|
if (CollUtil.isEmpty(viewedNoteIds)) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
String bloomKey = BLOOM_VIEW_KEY_PREFIX + userId; |
|
|
|
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(bloomKey); |
|
|
|
// 加分布式锁,避免同一个用户并发重建
|
|
|
|
RLock lock = redissonClient.getLock(BLOOM_REBUILD_LOCK_PREFIX + userId); |
|
|
|
|
|
|
|
try { |
|
|
|
if (lock.tryLock(3, TimeUnit.SECONDS)) { |
|
|
|
try { |
|
|
|
// 布隆已经存在的话先删除旧的
|
|
|
|
if (bloomFilter.isExists()) { |
|
|
|
bloomFilter.delete(); |
|
|
|
} |
|
|
|
// 初始化新布隆,用配置的参数
|
|
|
|
bloomFilter.tryInit(bloomExpectedInsertions, bloomFpp); |
|
|
|
// 只有配置了过期时间且大于0时才设置,否则永久有效
|
|
|
|
if (bloomExpireDays != null && bloomExpireDays > 0) { |
|
|
|
bloomFilter.expire(bloomExpireDays, TimeUnit.DAYS); |
|
|
|
} |
|
|
|
|
|
|
|
// 批量写入布隆,1000条只需要几毫秒
|
|
|
|
for (Long noteId : viewedNoteIds) { |
|
|
|
bloomFilter.add(noteId); |
|
|
|
} |
|
|
|
|
|
|
|
log.info("用户{}的浏览布隆重建完成,共写入{}条记录", userId, viewedNoteIds.size()); |
|
|
|
} finally { |
|
|
|
if (lock.isHeldByCurrentThread()) { |
|
|
|
lock.unlock(); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} catch (InterruptedException e) { |
|
|
|
Thread.currentThread().interrupt(); |
|
|
|
log.error("重建用户{}的布隆时获取锁异常", userId, e); |
|
|
|
} catch (Exception e) { |
|
|
|
log.error("重建用户{}的布隆失败", userId, e); |
|
|
|
// 异常不抛出,不影响主流程,下次访问会自动重试
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
@Override |
|
|
|
public void resetCurrentUserBloom(Long userId) { |
|
|
|
if (userId == null) { |
|
|
|
throw new MsgException("用户未登录"); |
|
|
|
} |
|
|
|
try { |
|
|
|
String bloomKey = BLOOM_VIEW_KEY_PREFIX + userId; |
|
|
|
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(bloomKey); |
|
|
|
// 1. 先删除旧布隆
|
|
|
|
if (bloomFilter.isExists()) { |
|
|
|
bloomFilter.delete(); |
|
|
|
} |
|
|
|
// 2. 初始化一个空的新布隆,不加载历史浏览记录
|
|
|
|
bloomFilter.tryInit(bloomExpectedInsertions, bloomFpp); |
|
|
|
// 保持和全局配置一致的过期时间
|
|
|
|
if (bloomExpireDays != null && bloomExpireDays > 0) { |
|
|
|
bloomFilter.expire(bloomExpireDays, TimeUnit.DAYS); |
|
|
|
} |
|
|
|
log.info("用户{}的布隆过滤器重置成功,已清空历史过滤规则,新浏览内容将继续记录", userId); |
|
|
|
} catch (Exception e) { |
|
|
|
log.error("重置用户{}的布隆过滤器失败", userId, e); |
|
|
|
throw new MsgException("重置浏览记录失败,请稍后重试"); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
@Override |
|
|
|
public void increaseNoteViewCount(String noteUuid) { |
|
|
|
try { |
|
|
|
miniStrayAnimalNoteMapper.incrementViewCount(noteUuid); |
|
|
|
} catch (Exception e) { |
|
|
|
// 浏览量增加失败不影响主流程,仅打日志
|
|
|
|
log.error("增加笔记{}浏览量失败", noteUuid, e); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|