From b21949693efceffd979e83708581e75a04bf32c3 Mon Sep 17 00:00:00 2001 From: glx <783262171@qq.com> Date: Tue, 9 Jun 2026 17:33:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=94=A8=E6=88=B7=E5=85=B3?= =?UTF-8?q?=E6=B3=A8=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../freemarker/MyBatisPlusGenerator.java | 1 + .../boot/common/constant/RedisConstants.java | 8 + .../mini/controller/MiniFollowController.java | 54 ++++++ .../mini/mapper/MiniUserFollowMapper.java | 28 +++ .../mini/model/entity/MiniUserFollow.java | 45 +++++ .../mini/model/query/OwnUserPostQuery.java | 3 + .../boot/mini/model/vo/FollowUserVO.java | 31 +++ .../youlai/boot/mini/model/vo/UserPostVO.java | 3 + .../boot/mini/service/MiniFollowService.java | 50 +++++ .../service/impl/MiniFollowServiceImpl.java | 183 ++++++++++++++++++ .../service/impl/UserPostServiceImpl.java | 183 ++++++++++++++---- .../mapper/mini/MiniUserFollowMapper.xml | 46 +++++ .../mapper/mini/MiniUserPostMapper.xml | 8 + 13 files changed, 600 insertions(+), 43 deletions(-) create mode 100644 src/main/java/com/youlai/boot/mini/controller/MiniFollowController.java create mode 100644 src/main/java/com/youlai/boot/mini/mapper/MiniUserFollowMapper.java create mode 100644 src/main/java/com/youlai/boot/mini/model/entity/MiniUserFollow.java create mode 100644 src/main/java/com/youlai/boot/mini/model/vo/FollowUserVO.java create mode 100644 src/main/java/com/youlai/boot/mini/service/MiniFollowService.java create mode 100644 src/main/java/com/youlai/boot/mini/service/impl/MiniFollowServiceImpl.java create mode 100644 src/main/resources/mapper/mini/MiniUserFollowMapper.xml 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 f025b86..68cff6e 100644 --- a/src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java +++ b/src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java @@ -125,6 +125,7 @@ public class MyBatisPlusGenerator { ,new TableConfig("mini_content_audit_config", IdType.AUTO, "admin") ,new TableConfig("mini_content_audit", IdType.AUTO, "admin") ,new TableConfig("mini_content_audit_task", IdType.AUTO, "admin") + ,new TableConfig("mini_user_follow", IdType.AUTO, "mini") // ,new TableConfig("mini_stray_animal", IdType.AUTO, "mini") diff --git a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java index b54c045..637800e 100644 --- a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java @@ -60,4 +60,12 @@ public interface RedisConstants { String ROLE_PERMS = "system:role:perms"; // 系统角色和权限映射 } + /** + * 社交模块 + */ + interface Social { + String FOLLOWING = "social:follow:following:{}"; // 用户关注列表(userId → Set) + String FOLLOWERS = "social:follow:followers:{}"; // 用户粉丝列表(userId → Set) + } + } diff --git a/src/main/java/com/youlai/boot/mini/controller/MiniFollowController.java b/src/main/java/com/youlai/boot/mini/controller/MiniFollowController.java new file mode 100644 index 0000000..21a31a5 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/controller/MiniFollowController.java @@ -0,0 +1,54 @@ +package com.youlai.boot.mini.controller; + +import com.youlai.boot.common.base.BaseQuery; +import com.youlai.boot.common.result.PageResult; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.mini.model.vo.FollowUserVO; +import com.youlai.boot.mini.service.MiniFollowService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Tag(name = "用户关注相关接口") +@RestController +@RequestMapping("/api/v1/mini/follow") +@RequiredArgsConstructor +public class MiniFollowController { + + private final MiniFollowService followService; + + @Operation(summary = "关注/取关用户") + @PostMapping("/toggle") + public Result> toggleFollow(@RequestParam String targetUserUuid) { + Long userId = SecurityUtils.getUserId(); + boolean following = followService.toggleFollow(userId, targetUserUuid); + return Result.success(Map.of("following", following)); + } + + @Operation(summary = "检查是否已关注某用户") + @GetMapping("/isFollowing/{targetUserUuid}") + public Result> isFollowing(@PathVariable String targetUserUuid) { + Long userId = SecurityUtils.getUserId(); + boolean following = followService.isFollowingByUuid(userId, targetUserUuid); + return Result.success(Map.of("following", following)); + } + + @Operation(summary = "获取我的关注列表") + @GetMapping("/followingPage") + public PageResult getFollowingPage(BaseQuery query) { + Long userId = SecurityUtils.getUserId(); + return PageResult.success(followService.getFollowingPage(userId, query.getPageNum(), query.getPageSize())); + } + + @Operation(summary = "获取我的粉丝列表") + @GetMapping("/followerPage") + public PageResult getFollowerPage(BaseQuery query) { + Long userId = SecurityUtils.getUserId(); + return PageResult.success(followService.getFollowerPage(userId, query.getPageNum(), query.getPageSize())); + } + +} diff --git a/src/main/java/com/youlai/boot/mini/mapper/MiniUserFollowMapper.java b/src/main/java/com/youlai/boot/mini/mapper/MiniUserFollowMapper.java new file mode 100644 index 0000000..1dfd129 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/mapper/MiniUserFollowMapper.java @@ -0,0 +1,28 @@ +package com.youlai.boot.mini.mapper; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.mini.model.entity.MiniUserFollow; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.youlai.boot.mini.model.vo.FollowUserVO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** +* 用户关注表 Mapper 接口 +* +* @author jwy +* @since +*/ +public interface MiniUserFollowMapper extends BaseMapper { + + List selectFollowingIds(@Param("userId") Long userId); + + List selectFollowerIds(@Param("userId") Long userId); + + IPage selectFollowingPage(Page page, @Param("userId") Long userId); + + IPage selectFollowerPage(Page page, @Param("userId") Long userId); + +} diff --git a/src/main/java/com/youlai/boot/mini/model/entity/MiniUserFollow.java b/src/main/java/com/youlai/boot/mini/model/entity/MiniUserFollow.java new file mode 100644 index 0000000..8470c58 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/entity/MiniUserFollow.java @@ -0,0 +1,45 @@ +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_user_follow") +@Schema(description = "用户关注表") +public class MiniUserFollow implements Serializable { + + @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "") + private Long id; + + + @TableField("follower_id") + @Schema(description = "关注者用户ID") + private Long followerId; + + @TableField("followed_id") + @Schema(description = "被关注者用户ID") + private Long followedId; + + @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; + + +} diff --git a/src/main/java/com/youlai/boot/mini/model/query/OwnUserPostQuery.java b/src/main/java/com/youlai/boot/mini/model/query/OwnUserPostQuery.java index 5e2116f..ab22f0e 100644 --- a/src/main/java/com/youlai/boot/mini/model/query/OwnUserPostQuery.java +++ b/src/main/java/com/youlai/boot/mini/model/query/OwnUserPostQuery.java @@ -20,4 +20,7 @@ public class OwnUserPostQuery extends UserPostQuery { @Schema(description = "权限", example = "public", hidden = true) private String visibility; + @Schema(description = "允许的可见性列表(用于他人页面访问控制)", hidden = true) + private java.util.List allowedVisibilities; + } diff --git a/src/main/java/com/youlai/boot/mini/model/vo/FollowUserVO.java b/src/main/java/com/youlai/boot/mini/model/vo/FollowUserVO.java new file mode 100644 index 0000000..10a12ae --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/vo/FollowUserVO.java @@ -0,0 +1,31 @@ +package com.youlai.boot.mini.model.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Date; + +@Data +@Schema(description = "关注用户VO") +public class FollowUserVO { + + @Hidden + @Schema(description = "用户ID", hidden = true) + private Long userId; + + @Schema(description = "用户UUID") + private String userUuid; + + @Schema(description = "昵称") + private String nickname; + + @Schema(description = "头像URL") + private String avatar; + + @Schema(description = "关注时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date followTime; + +} diff --git a/src/main/java/com/youlai/boot/mini/model/vo/UserPostVO.java b/src/main/java/com/youlai/boot/mini/model/vo/UserPostVO.java index 56d92fa..e83fd9f 100644 --- a/src/main/java/com/youlai/boot/mini/model/vo/UserPostVO.java +++ b/src/main/java/com/youlai/boot/mini/model/vo/UserPostVO.java @@ -12,6 +12,9 @@ public class UserPostVO { @Schema(description = "作品ID", hidden = true) private Long id; + @Schema(description = "作者用户ID", hidden = true) + private Long miniUserId; + @Schema(description = "作者uuid") private String authorUuid; diff --git a/src/main/java/com/youlai/boot/mini/service/MiniFollowService.java b/src/main/java/com/youlai/boot/mini/service/MiniFollowService.java new file mode 100644 index 0000000..7e3c189 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/service/MiniFollowService.java @@ -0,0 +1,50 @@ +package com.youlai.boot.mini.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.youlai.boot.mini.model.vo.FollowUserVO; + +import java.util.Set; + +public interface MiniFollowService { + + /** + * 关注/取关切换。返回最新状态: true=已关注, false=已取关。 + */ + boolean toggleFollow(Long userId, String targetUserUuid); + + /** + * 检查 userId 是否关注了 targetUserId。 + */ + boolean isFollowing(Long userId, Long targetUserId); + + /** + * 检查 userId 是否关注了指定 UUID 的用户。返回两个值:following + targetUserId。 + */ + boolean isFollowingByUuid(Long userId, String targetUserUuid); + + /** + * 获取用户关注的所有用户ID集合,优先从Redis缓存读取。 + */ + Set getFollowingIds(Long userId); + + /** + * 检查两人是否互相关注(互为好友)。 + */ + boolean isMutualFollow(Long userA, Long userB); + + /** + * 从候选用户ID集合中,筛选出与userId互相关注的用户ID。 + */ + Set filterMutualFollows(Long userId, Set candidateIds); + + /** + * 分页获取关注列表。 + */ + IPage getFollowingPage(Long userId, int pageNum, int pageSize); + + /** + * 分页获取粉丝列表。 + */ + IPage getFollowerPage(Long userId, int pageNum, int pageSize); + +} diff --git a/src/main/java/com/youlai/boot/mini/service/impl/MiniFollowServiceImpl.java b/src/main/java/com/youlai/boot/mini/service/impl/MiniFollowServiceImpl.java new file mode 100644 index 0000000..28c4eb2 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/service/impl/MiniFollowServiceImpl.java @@ -0,0 +1,183 @@ +package com.youlai.boot.mini.service.impl; + +import cn.hutool.core.collection.CollUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.common.constant.RedisConstants; +import com.youlai.boot.common.exception.MsgException; +import com.youlai.boot.mini.mapper.MiniUserFollowMapper; +import com.youlai.boot.mini.model.entity.MiniUserFollow; +import com.youlai.boot.mini.model.vo.FollowUserVO; +import com.youlai.boot.mini.service.MiniFollowService; +import com.youlai.boot.system.mapper.UserMapper; +import com.youlai.boot.system.model.entity.SysUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MiniFollowServiceImpl implements MiniFollowService { + + private final MiniUserFollowMapper followMapper; + private final UserMapper userMapper; + private final StringRedisTemplate stringRedisTemplate; + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean toggleFollow(Long userId, String targetUserUuid) { + SysUser targetUser = userMapper.selectOne(new LambdaQueryWrapper() + .eq(SysUser::getUuid, targetUserUuid) + .eq(SysUser::getIsDeleted, 0)); + if (targetUser == null) { + throw new MsgException("用户不存在"); + } + Long targetUserId = targetUser.getId(); + if (userId.equals(targetUserId)) { + throw new MsgException("不能关注自己"); + } + + Long exists = followMapper.selectCount(new LambdaQueryWrapper() + .eq(MiniUserFollow::getFollowerId, userId) + .eq(MiniUserFollow::getFollowedId, targetUserId)); + boolean nowFollowing; + + if (exists != null && exists > 0) { + followMapper.delete(new LambdaQueryWrapper() + .eq(MiniUserFollow::getFollowerId, userId) + .eq(MiniUserFollow::getFollowedId, targetUserId)); + nowFollowing = false; + } else { + MiniUserFollow follow = new MiniUserFollow(); + follow.setFollowerId(userId); + follow.setFollowedId(targetUserId); + follow.setCreateTime(new Date()); + follow.setCreateTimestamp(System.currentTimeMillis()); + followMapper.insert(follow); + nowFollowing = true; + } + + updateRedisCache(userId, targetUserId, nowFollowing); + return nowFollowing; + } + + @Override + public boolean isFollowing(Long userId, Long targetUserId) { + String key = RedisConstants.Social.FOLLOWING.replace("{}", String.valueOf(userId)); + Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, String.valueOf(targetUserId)); + if (Boolean.TRUE.equals(isMember)) { + return true; + } + Long count = followMapper.selectCount(new LambdaQueryWrapper() + .eq(MiniUserFollow::getFollowerId, userId) + .eq(MiniUserFollow::getFollowedId, targetUserId)); + return count != null && count > 0; + } + + @Override + public boolean isFollowingByUuid(Long userId, String targetUserUuid) { + SysUser targetUser = userMapper.selectOne(new LambdaQueryWrapper() + .eq(SysUser::getUuid, targetUserUuid) + .eq(SysUser::getIsDeleted, 0)); + if (targetUser == null) { + throw new MsgException("用户不存在"); + } + return isFollowing(userId, targetUser.getId()); + } + + @Override + public Set getFollowingIds(Long userId) { + String key = RedisConstants.Social.FOLLOWING.replace("{}", String.valueOf(userId)); + Set members = stringRedisTemplate.opsForSet().members(key); + if (CollUtil.isNotEmpty(members)) { + return members.stream().map(Long::valueOf).collect(Collectors.toSet()); + } + // Cold start: load from DB and cache + List ids = followMapper.selectFollowingIds(userId); + if (CollUtil.isNotEmpty(ids)) { + String[] strIds = ids.stream().map(String::valueOf).toArray(String[]::new); + stringRedisTemplate.opsForSet().add(key, strIds); + } + return new HashSet<>(ids); + } + + @Override + public boolean isMutualFollow(Long userA, Long userB) { + return isFollowing(userA, userB) && isFollowing(userB, userA); + } + + @Override + public Set filterMutualFollows(Long userId, Set candidateIds) { + if (CollUtil.isEmpty(candidateIds)) { + return Collections.emptySet(); + } + // I follow them + Set iFollow = getFollowingIds(userId); + if (CollUtil.isEmpty(iFollow)) { + return Collections.emptySet(); + } + + Set mutuals = new HashSet<>(); + for (Long candidateId : candidateIds) { + if (iFollow.contains(candidateId)) { + String theirKey = RedisConstants.Social.FOLLOWING.replace("{}", String.valueOf(candidateId)); + Boolean theyFollowMe = stringRedisTemplate.opsForSet() + .isMember(theirKey, String.valueOf(userId)); + if (Boolean.TRUE.equals(theyFollowMe)) { + mutuals.add(candidateId); + } else { + // Redis miss for their set, fallback to DB + Long count = followMapper.selectCount(new LambdaQueryWrapper() + .eq(MiniUserFollow::getFollowerId, candidateId) + .eq(MiniUserFollow::getFollowedId, userId)); + if (count != null && count > 0) { + mutuals.add(candidateId); + // warm their cache + refreshFollowingCache(candidateId); + } + } + } + } + return mutuals; + } + + @Override + public IPage getFollowingPage(Long userId, int pageNum, int pageSize) { + Page page = new Page<>(pageNum, pageSize); + return followMapper.selectFollowingPage(page, userId); + } + + @Override + public IPage getFollowerPage(Long userId, int pageNum, int pageSize) { + Page page = new Page<>(pageNum, pageSize); + return followMapper.selectFollowerPage(page, userId); + } + + private void updateRedisCache(Long followerId, Long followedId, boolean follow) { + String followingKey = RedisConstants.Social.FOLLOWING.replace("{}", String.valueOf(followerId)); + String followersKey = RedisConstants.Social.FOLLOWERS.replace("{}", String.valueOf(followedId)); + String member = String.valueOf(followedId); + String followerMember = String.valueOf(followerId); + if (follow) { + stringRedisTemplate.opsForSet().add(followingKey, member); + stringRedisTemplate.opsForSet().add(followersKey, followerMember); + } else { + stringRedisTemplate.opsForSet().remove(followingKey, member); + stringRedisTemplate.opsForSet().remove(followersKey, followerMember); + } + } + + private void refreshFollowingCache(Long userId) { + String key = RedisConstants.Social.FOLLOWING.replace("{}", String.valueOf(userId)); + stringRedisTemplate.delete(key); + getFollowingIds(userId); + } + +} diff --git a/src/main/java/com/youlai/boot/mini/service/impl/UserPostServiceImpl.java b/src/main/java/com/youlai/boot/mini/service/impl/UserPostServiceImpl.java index 87c7784..644384a 100644 --- a/src/main/java/com/youlai/boot/mini/service/impl/UserPostServiceImpl.java +++ b/src/main/java/com/youlai/boot/mini/service/impl/UserPostServiceImpl.java @@ -47,6 +47,7 @@ import com.youlai.boot.mini.model.vo.UserPostDetailsVO; import com.youlai.boot.mini.model.vo.UserPostMediaVO; import com.youlai.boot.mini.model.vo.UserPostVO; import com.youlai.boot.mini.model.vo.WaterfallResult; +import com.youlai.boot.mini.service.MiniFollowService; import com.youlai.boot.mini.service.UserPostService; import com.youlai.boot.system.mapper.UserMapper; import com.youlai.boot.system.model.entity.SysUser; @@ -79,6 +80,7 @@ public class UserPostServiceImpl extends ServiceImpl param = new HashMap<>(); @@ -750,65 +756,76 @@ public class UserPostServiceImpl extends ServiceImpl allowed = new ArrayList<>(); + allowed.add("public"); + if (followService.isMutualFollow(viewerId, authorId)) { + allowed.add("friends"); + } + queryParams.setAllowedVisibilities(allowed); + } Page page = new Page<>(queryParams.getPageNum(), queryParams.getPageSize()); return baseMapper.getUserPostPage(page, queryParams); } + /** + * 瀑布流(cursor-based 分页 + 应用层可见性过滤)。 + * + * 流程:取 pageSize × 3 行 → 布隆去重 → 可见性过滤 → 截取 pageSize 条 + * 核心:SQL 不做可见性过滤,在 Java 层用 Redis 批量判断好友关系后过滤。 + * + * @param query cursor=上一页最后一条的ID(首页不传), pageSize=每页条数 + * @param userId 当前登录用户ID(可为null, 未登录只看public) + */ @Override public WaterfallResult getWaterfall(UserPostWaterfallQuery query, Long userId) { int pageSize = query.getPageSize(); - int querySize = pageSize + 1; + // 取3倍数据,因为后续布隆+可见性两次过滤会丢掉一部分 + int fetchSize = pageSize * 3; - List originalList = baseMapper.getWaterfall( - query.getCursor(), - querySize, - userId - ); + // 1. 从DB取原始数据(仅按id降序,不含可见性过滤) + List rawList = baseMapper.getWaterfall(query.getCursor(), fetchSize, userId); - Long nextCursor = null; - boolean isLastPage = true; - if (CollUtil.isNotEmpty(originalList)) { - if (originalList.size() > pageSize) { - nextCursor = originalList.get(pageSize - 1).getId(); - isLastPage = false; - } - } else { + if (CollUtil.isEmpty(rawList)) { return WaterfallResult.of(Collections.emptyList(), null, true); } - List resultList = originalList.stream().limit(pageSize).collect(Collectors.toList()); + // DB返回了满3倍数据,说明后面可能还有更多 + boolean hasMore = rawList.size() >= fetchSize; - 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 { - 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) { - log.error("布隆操作异常,自动降级不过滤,userId:{}, cursor:{}", userId, query.getCursor(), e); - } + // 2. 布隆过滤器去重:过滤当前用户已看过的内容 + List afterBloom = applyBloomDedup(rawList, userId); + + // 3. 可见性过滤: + // - 未登录 → 只看 public + // - 已登录 → public 全看,friends 需互相关注,private 仅作者本人 + List afterVisibility = applyVisibilityFilter(afterBloom, userId); + + // 4. 截取 pageSize 条作为本页结果 + List resultList = afterVisibility.stream().limit(pageSize).toList(); + + // 5. 游标逻辑: + // - 过滤后还有 ≥ pageSize 条 → 用最后一条的ID作为下页游标 + // - 过滤后不够,但DB原始数据是满的 → 用原始数据最后一条ID继续翻(被过滤的内容后面还会出现) + // - 否则最后一页 + Long nextCursor = null; + boolean isLastPage = true; + if (resultList.size() >= pageSize) { + nextCursor = resultList.get(pageSize - 1).getId(); + isLastPage = false; + } else if (hasMore) { + nextCursor = rawList.get(rawList.size() - 1).getId(); + isLastPage = false; } + // 没有封面的给默认图 if (CollUtil.isNotEmpty(resultList)) { resultList.forEach(item -> { if (StrUtil.isBlank(item.getFirstImageUrl())) { @@ -907,5 +924,85 @@ public class UserPostServiceImpl extends ServiceImpl applyBloomDedup(List list, Long userId) { + if (userId == null || CollUtil.isEmpty(list)) { + return list; + } + try { + String bloomKey = BLOOM_VIEW_KEY_PREFIX + userId; + RBloomFilter bloomFilter = redissonClient.getBloomFilter(bloomKey); + if (bloomFilter.isExists()) { + return list.stream() + .filter(item -> !bloomFilter.contains(item.getId())) + .toList(); + } + rebuildUserBloomFromDB(userId, 365); + RBloomFilter newBloom = redissonClient.getBloomFilter(bloomKey); + if (newBloom.isExists()) { + return list.stream() + .filter(item -> !newBloom.contains(item.getId())) + .toList(); + } + } catch (Exception e) { + log.error("布隆操作异常,降级不过滤, userId:{}", userId, e); + } + return list; + } + + /** + * 可见性过滤(批量版):收集所有作者ID → 批量查Redis互关关系 → 逐条判断。 + * Redis操作数 = 1(SMEMBERS 我关注的人) + N(SISMEMBER 对方是否关注我),N=去重作者数。 + */ + private List applyVisibilityFilter(List list, Long viewerId) { + if (CollUtil.isEmpty(list)) { + return list; + } + if (viewerId == null) { + return list.stream() + .filter(item -> "public".equals(item.getVisibility())) + .toList(); + } + Set authorIds = list.stream() + .map(UserPostVO::getMiniUserId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Set mutualFollowIds = followService.filterMutualFollows(viewerId, authorIds); + return list.stream() + .filter(item -> canView(viewerId, item.getMiniUserId(), item.getVisibility(), mutualFollowIds)) + .toList(); + } + + /** + * 单条可见性判断:作者本人全可见 → public全可见 → friends需互相关注 → private仅作者。 + * @param mutualFollowIds 与当前用户互相关注的作者ID集合(调用前已批量算好) + */ + private boolean canView(Long viewerId, Long authorId, String visibility, Set mutualFollowIds) { + if (authorId != null && viewerId.equals(authorId)) { + return true; + } + return switch (visibility != null ? visibility : "public") { + case "public" -> true; + case "friends" -> authorId != null && mutualFollowIds.contains(authorId); + default -> false; + }; + } + + /** + * 详情页可见性校验(单条):校验失败直接抛异常,拦截越权访问。 + */ + private boolean canViewDetail(MiniUserPost post, Long viewerId) { + if (viewerId != null && viewerId.equals(post.getMiniUserId())) { + return true; + } + return switch (post.getVisibility()) { + case "public" -> true; + case "friends" -> viewerId != null && followService.isMutualFollow(viewerId, post.getMiniUserId()); + default -> false; + }; + } } diff --git a/src/main/resources/mapper/mini/MiniUserFollowMapper.xml b/src/main/resources/mapper/mini/MiniUserFollowMapper.xml new file mode 100644 index 0000000..c0e0db7 --- /dev/null +++ b/src/main/resources/mapper/mini/MiniUserFollowMapper.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + diff --git a/src/main/resources/mapper/mini/MiniUserPostMapper.xml b/src/main/resources/mapper/mini/MiniUserPostMapper.xml index caaf7bb..d7d69c7 100644 --- a/src/main/resources/mapper/mini/MiniUserPostMapper.xml +++ b/src/main/resources/mapper/mini/MiniUserPostMapper.xml @@ -20,6 +20,7 @@ ) SELECT p.id, + p.mini_user_id AS miniUserId, p.uuid AS postUuid, p.title, LEFT(p.content, 100) AS content, @@ -66,6 +67,12 @@ AND p.visibility = #{query.visibility} + + AND p.visibility IN + + #{v} + + AND p.create_timestamp >= #{query.createStartTimestamp} @@ -142,6 +149,7 @@ ) SELECT p.id, + p.mini_user_id AS miniUserId, u.uuid AS authorUuid, u.nickname AS authorName, u.avatar AS authorAvatar,