diff --git a/src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java b/src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java index aceb48b..892e811 100644 --- a/src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java +++ b/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") diff --git a/src/main/java/com/youlai/boot/mini/controller/StrayAnimalController.java b/src/main/java/com/youlai/boot/mini/controller/StrayAnimalController.java index 0059228..2ffaa20 100644 --- a/src/main/java/com/youlai/boot/mini/controller/StrayAnimalController.java +++ b/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(); + } } diff --git a/src/main/java/com/youlai/boot/mini/mapper/MiniStrayAnimalNoteMapper.java b/src/main/java/com/youlai/boot/mini/mapper/MiniStrayAnimalNoteMapper.java index 46a6c52..2ccf75e 100644 --- a/src/main/java/com/youlai/boot/mini/mapper/MiniStrayAnimalNoteMapper.java +++ b/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 { @@ -57,4 +57,9 @@ public interface MiniStrayAnimalNoteMapper extends BaseMapper { + + /** + * 新增或更新浏览记录(重复则更新最后浏览时间) + */ + int insertOrUpdateView(MiniStrayAnimalNoteView view); + + /** + * 查询指定用户的浏览过的笔记ID + */ + List selectViewedNoteIdsByUserAndTime(@Param("userId") Long userId, @Param("startTime") Long startTime); + +} diff --git a/src/main/java/com/youlai/boot/mini/model/entity/MiniStrayAnimalNoteView.java b/src/main/java/com/youlai/boot/mini/model/entity/MiniStrayAnimalNoteView.java new file mode 100644 index 0000000..fdb3ddc --- /dev/null +++ b/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; + + +} diff --git a/src/main/java/com/youlai/boot/mini/service/StrayAnimalService.java b/src/main/java/com/youlai/boot/mini/service/StrayAnimalService.java index e2e2c25..1fa10b1 100644 --- a/src/main/java/com/youlai/boot/mini/service/StrayAnimalService.java +++ b/src/main/java/com/youlai/boot/mini/service/StrayAnimalService.java @@ -53,5 +53,13 @@ public interface StrayAnimalService extends IService { Map toggleNoteCollect(NoteCollectForm form, Long userId); WaterfallResult getWaterfall(WaterfallQuery query, Long userId); + + void rebuildUserBloomFromDB(Long userId); + + void rebuildUserBloomFromDB(Long userId, int days); + + void resetCurrentUserBloom(Long userId); + + void increaseNoteViewCount(String noteUuid); } diff --git a/src/main/java/com/youlai/boot/mini/service/impl/StrayAnimalServiceImpl.java b/src/main/java/com/youlai/boot/mini/service/impl/StrayAnimalServiceImpl.java index 3c6cfb8..323b765 100644 --- a/src/main/java/com/youlai/boot/mini/service/impl/StrayAnimalServiceImpl.java +++ b/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() .eq(MiniStrayAnimalNote::getStrayAnimalId, animal.getId())); + if (appStrayAnimalNote != null) { Map param = new HashMap<>(); param.put("noteId", appStrayAnimalNote.getId()); @@ -636,6 +661,44 @@ public class StrayAnimalServiceImpl extends ServiceImpl 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 list = miniStrayAnimalMapper.getWaterfall( + // 2. 从DB查询 N+1 条 原始未过滤数据 + List 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 resultList = originalList.stream().limit(pageSize).collect(Collectors.toList()); + // 仅登录用户做过滤 + if (userId != null && CollUtil.isNotEmpty(resultList)) { + try { + String bloomKey = BLOOM_VIEW_KEY_PREFIX + userId; + RBloomFilter 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 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 pageSize 说明有下一页,取前 pageSize 条) - Long nextCursor = null; - boolean isLastPage = true; - List 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 viewedNoteIds = miniStrayAnimalNoteViewMapper.selectViewedNoteIdsByUserAndTime(userId, startTime); + + if (CollUtil.isEmpty(viewedNoteIds)) { + return; + } + + String bloomKey = BLOOM_VIEW_KEY_PREFIX + userId; + RBloomFilter 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 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); + } } } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index de3307d..8dc1bf5 100644 --- a/src/main/resources/application-dev.yml +++ b/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 diff --git a/src/main/resources/mapper/mini/MiniStrayAnimalNoteMapper.xml b/src/main/resources/mapper/mini/MiniStrayAnimalNoteMapper.xml index 59202dc..7dc6a40 100644 --- a/src/main/resources/mapper/mini/MiniStrayAnimalNoteMapper.xml +++ b/src/main/resources/mapper/mini/MiniStrayAnimalNoteMapper.xml @@ -90,4 +90,12 @@ WHERE n.collect_count != COALESCE(c.actual_count, 0) + + + UPDATE mini_stray_animal_note + SET view_count = view_count + 1 + WHERE uuid = #{noteUuid} + AND is_deleted = 0 + + diff --git a/src/main/resources/mapper/mini/MiniStrayAnimalNoteViewMapper.xml b/src/main/resources/mapper/mini/MiniStrayAnimalNoteViewMapper.xml new file mode 100644 index 0000000..41fb25d --- /dev/null +++ b/src/main/resources/mapper/mini/MiniStrayAnimalNoteViewMapper.xml @@ -0,0 +1,31 @@ + + + + + + + + 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 + + + + + +