Browse Source

增加浏览记录使用布隆过滤器过滤已看内容

glx_phase2
glx 3 weeks ago
parent
commit
60c76855e0
  1. 1
      src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java
  2. 18
      src/main/java/com/youlai/boot/mini/controller/StrayAnimalController.java
  3. 7
      src/main/java/com/youlai/boot/mini/mapper/MiniStrayAnimalNoteMapper.java
  4. 27
      src/main/java/com/youlai/boot/mini/mapper/MiniStrayAnimalNoteViewMapper.java
  5. 57
      src/main/java/com/youlai/boot/mini/model/entity/MiniStrayAnimalNoteView.java
  6. 8
      src/main/java/com/youlai/boot/mini/service/StrayAnimalService.java
  7. 240
      src/main/java/com/youlai/boot/mini/service/impl/StrayAnimalServiceImpl.java
  8. 10
      src/main/resources/application-dev.yml
  9. 8
      src/main/resources/mapper/mini/MiniStrayAnimalNoteMapper.xml
  10. 31
      src/main/resources/mapper/mini/MiniStrayAnimalNoteViewMapper.xml

1
src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java

@ -103,6 +103,7 @@ public class MyBatisPlusGenerator {
,new TableConfig("mini_stray_animal_note_comment_like", IdType.AUTO, "mini")
,new TableConfig("mini_stray_animal_note_like", IdType.AUTO, "mini")
,new TableConfig("mini_stray_animal_note_collect", IdType.AUTO, "mini")
,new TableConfig("mini_stray_animal_note_view", IdType.AUTO, "mini")
// ,new TableConfig("mini_stray_animal", IdType.AUTO, "mini")
// ,new TableConfig("mini_stray_animal", IdType.INPUT, "minitest")

18
src/main/java/com/youlai/boot/mini/controller/StrayAnimalController.java

@ -187,6 +187,22 @@ public class StrayAnimalController {
return Result.success(result);
}
//增加浏览量
@Operation(summary = "重置当前用户的浏览记录过滤规则", description = "重置后瀑布流会重新展示所有看过的内容")
@PostMapping(value = "/resetViewBloom")
@Log(module = LogModuleEnum.STRAY_ANIMAL_INFO, value = ActionTypeEnum.UPDATE)
public Result<?> resetViewBloom() {
Long userId = SecurityUtils.getUserId();
strayAnimalService.resetCurrentUserBloom(userId);
return Result.success("重置成功");
}
@Operation(summary = "增加动物笔记浏览量")
@PostMapping(value = "/note/increase-view")
@Log(module = LogModuleEnum.STRAY_ANIMAL_INFO, value = ActionTypeEnum.UPDATE)
@RepeatSubmit(expire = 1)
public Result<?> increaseNoteViewCount(@RequestParam String noteUuid) {
strayAnimalService.increaseNoteViewCount(noteUuid);
return Result.success();
}
}

7
src/main/java/com/youlai/boot/mini/mapper/MiniStrayAnimalNoteMapper.java

@ -8,7 +8,7 @@ import org.apache.ibatis.annotations.Param;
* 流浪信息笔记 Mapper 接口
*
* @author jwy
* @since
* @since
*/
public interface MiniStrayAnimalNoteMapper extends BaseMapper<MiniStrayAnimalNote> {
@ -57,4 +57,9 @@ public interface MiniStrayAnimalNoteMapper extends BaseMapper<MiniStrayAnimalNot
*/
int batchCalibrateAllCollectCounts();
/**
* 原子增加笔记浏览量
*/
int incrementViewCount(@Param("noteUuid") String noteUuid);
}

27
src/main/java/com/youlai/boot/mini/mapper/MiniStrayAnimalNoteViewMapper.java

@ -0,0 +1,27 @@
package com.youlai.boot.mini.mapper;
import com.youlai.boot.mini.model.entity.MiniStrayAnimalNoteView;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 动物笔记浏览记录表 Mapper 接口
*
* @author jwy
* @since
*/
public interface MiniStrayAnimalNoteViewMapper extends BaseMapper<MiniStrayAnimalNoteView> {
/**
* 新增或更新浏览记录重复则更新最后浏览时间
*/
int insertOrUpdateView(MiniStrayAnimalNoteView view);
/**
* 查询指定用户的浏览过的笔记ID
*/
List<Long> selectViewedNoteIdsByUserAndTime(@Param("userId") Long userId, @Param("startTime") Long startTime);
}

57
src/main/java/com/youlai/boot/mini/model/entity/MiniStrayAnimalNoteView.java

@ -0,0 +1,57 @@
package com.youlai.boot.mini.model.entity;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
@Getter
@Setter
@ToString
@Accessors(chain = true)
@TableName("mini_stray_animal_note_view")
@Schema(description = "动物笔记浏览记录表")
public class MiniStrayAnimalNoteView implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
@Schema(description = "观看记录表主键id")
private Long id;
@TableField("uuid")
@Schema(description = "uuid唯一标识,前后端用这个进行数据交互")
private String uuid;
@TableField("mini_user_id")
@Schema(description = "用户ID")
private Long miniUserId;
@TableField("note_id")
@Schema(description = "笔记ID")
private Long noteId;
@TableField("create_time")
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
@TableField("create_timestamp")
@Schema(description = "创建时间毫秒级时间戳")
private Long createTimestamp;
@TableField("create_by")
@Schema(description = "创建人ID")
private Long createBy;
@TableField("is_deleted")
@Schema(description = "逻辑删除标识(0-未删除 1-已删除)")
private Boolean deleted;
}

8
src/main/java/com/youlai/boot/mini/service/StrayAnimalService.java

@ -53,5 +53,13 @@ public interface StrayAnimalService extends IService<MiniStrayAnimal> {
Map<String, Object> toggleNoteCollect(NoteCollectForm form, Long userId);
WaterfallResult<StrayAnimalShortVO> getWaterfall(WaterfallQuery query, Long userId);
void rebuildUserBloomFromDB(Long userId);
void rebuildUserBloomFromDB(Long userId, int days);
void resetCurrentUserBloom(Long userId);
void increaseNoteViewCount(String noteUuid);
}

240
src/main/java/com/youlai/boot/mini/service/impl/StrayAnimalServiceImpl.java

@ -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);
}
/**
* 重建用户的浏览记录布隆过滤器
* @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);
// 异常不抛出,不影响主流程,下次访问会自动重试
}
}
return WaterfallResult.of(resultList, nextCursor, isLastPage);
@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);
}
}
}

10
src/main/resources/application-dev.yml

@ -225,3 +225,13 @@ wx:
miniapp:
appid: Your_AppId
secret: Your_AppSecret
# 布隆过滤器配置
bloom:
view:
# 单用户布隆预期最大存储浏览记录数,默认1000
expected-insertions: 1000
# 布隆误判率,范围0~1,越小占用内存越大,默认0.001(0.1%)
fpp: 0.001
# 布隆过期时间(单位天):【注释/留空/配0】都代表永久不过期
expire-days: 7

8
src/main/resources/mapper/mini/MiniStrayAnimalNoteMapper.xml

@ -90,4 +90,12 @@
WHERE n.collect_count != COALESCE(c.actual_count, 0)
</update>
<!-- 原子增加笔记浏览量 -->
<update id="incrementViewCount">
UPDATE mini_stray_animal_note
SET view_count = view_count + 1
WHERE uuid = #{noteUuid}
AND is_deleted = 0
</update>
</mapper>

31
src/main/resources/mapper/mini/MiniStrayAnimalNoteViewMapper.xml

@ -0,0 +1,31 @@
<?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.youlai.boot.mini.mapper.MiniStrayAnimalNoteViewMapper">
<!-- 新增或更新浏览记录,重复则更新最后浏览时间 -->
<insert id="insertOrUpdateView">
INSERT INTO mini_stray_animal_note_view
(uuid, mini_user_id, note_id, create_timestamp, create_time, create_by, is_deleted)
VALUES
(#{uuid}, #{miniUserId}, #{noteId}, #{createTimestamp}, #{createTime}, #{createBy}, 0)
ON DUPLICATE KEY UPDATE
create_timestamp = VALUES(create_timestamp),
is_deleted = 0
</insert>
<!-- 查询指定用户的浏览过的笔记ID 走idx_user_time联合索引 -->
<select id="selectViewedNoteIdsByUserAndTime" resultType="java.lang.Long">
SELECT note_id
FROM mini_stray_animal_note_view
WHERE mini_user_id = #{userId}
<if test="startTime != null">
AND create_timestamp >= #{startTime}
</if>
AND is_deleted = 0
ORDER BY create_timestamp DESC
</select>
</mapper>
Loading…
Cancel
Save