13 changed files with 600 additions and 43 deletions
@ -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<Map<String, Object>> 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<Map<String, Object>> 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<FollowUserVO> getFollowingPage(BaseQuery query) { |
|||
Long userId = SecurityUtils.getUserId(); |
|||
return PageResult.success(followService.getFollowingPage(userId, query.getPageNum(), query.getPageSize())); |
|||
} |
|||
|
|||
@Operation(summary = "获取我的粉丝列表") |
|||
@GetMapping("/followerPage") |
|||
public PageResult<FollowUserVO> getFollowerPage(BaseQuery query) { |
|||
Long userId = SecurityUtils.getUserId(); |
|||
return PageResult.success(followService.getFollowerPage(userId, query.getPageNum(), query.getPageSize())); |
|||
} |
|||
|
|||
} |
|||
@ -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<MiniUserFollow> { |
|||
|
|||
List<Long> selectFollowingIds(@Param("userId") Long userId); |
|||
|
|||
List<Long> selectFollowerIds(@Param("userId") Long userId); |
|||
|
|||
IPage<FollowUserVO> selectFollowingPage(Page<FollowUserVO> page, @Param("userId") Long userId); |
|||
|
|||
IPage<FollowUserVO> selectFollowerPage(Page<FollowUserVO> page, @Param("userId") Long userId); |
|||
|
|||
} |
|||
@ -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; |
|||
|
|||
|
|||
} |
|||
@ -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; |
|||
|
|||
} |
|||
@ -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<Long> getFollowingIds(Long userId); |
|||
|
|||
/** |
|||
* 检查两人是否互相关注(互为好友)。 |
|||
*/ |
|||
boolean isMutualFollow(Long userA, Long userB); |
|||
|
|||
/** |
|||
* 从候选用户ID集合中,筛选出与userId互相关注的用户ID。 |
|||
*/ |
|||
Set<Long> filterMutualFollows(Long userId, Set<Long> candidateIds); |
|||
|
|||
/** |
|||
* 分页获取关注列表。 |
|||
*/ |
|||
IPage<FollowUserVO> getFollowingPage(Long userId, int pageNum, int pageSize); |
|||
|
|||
/** |
|||
* 分页获取粉丝列表。 |
|||
*/ |
|||
IPage<FollowUserVO> getFollowerPage(Long userId, int pageNum, int pageSize); |
|||
|
|||
} |
|||
@ -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<SysUser>() |
|||
.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<MiniUserFollow>() |
|||
.eq(MiniUserFollow::getFollowerId, userId) |
|||
.eq(MiniUserFollow::getFollowedId, targetUserId)); |
|||
boolean nowFollowing; |
|||
|
|||
if (exists != null && exists > 0) { |
|||
followMapper.delete(new LambdaQueryWrapper<MiniUserFollow>() |
|||
.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<MiniUserFollow>() |
|||
.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<SysUser>() |
|||
.eq(SysUser::getUuid, targetUserUuid) |
|||
.eq(SysUser::getIsDeleted, 0)); |
|||
if (targetUser == null) { |
|||
throw new MsgException("用户不存在"); |
|||
} |
|||
return isFollowing(userId, targetUser.getId()); |
|||
} |
|||
|
|||
@Override |
|||
public Set<Long> getFollowingIds(Long userId) { |
|||
String key = RedisConstants.Social.FOLLOWING.replace("{}", String.valueOf(userId)); |
|||
Set<String> 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<Long> 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<Long> filterMutualFollows(Long userId, Set<Long> candidateIds) { |
|||
if (CollUtil.isEmpty(candidateIds)) { |
|||
return Collections.emptySet(); |
|||
} |
|||
// I follow them
|
|||
Set<Long> iFollow = getFollowingIds(userId); |
|||
if (CollUtil.isEmpty(iFollow)) { |
|||
return Collections.emptySet(); |
|||
} |
|||
|
|||
Set<Long> 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<MiniUserFollow>() |
|||
.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<FollowUserVO> getFollowingPage(Long userId, int pageNum, int pageSize) { |
|||
Page<FollowUserVO> page = new Page<>(pageNum, pageSize); |
|||
return followMapper.selectFollowingPage(page, userId); |
|||
} |
|||
|
|||
@Override |
|||
public IPage<FollowUserVO> getFollowerPage(Long userId, int pageNum, int pageSize) { |
|||
Page<FollowUserVO> 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); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,46 @@ |
|||
<?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.MiniUserFollowMapper"> |
|||
|
|||
<select id="selectFollowingIds" resultType="java.lang.Long"> |
|||
SELECT followed_id |
|||
FROM mini_user_follow |
|||
WHERE follower_id = #{userId} |
|||
</select> |
|||
|
|||
<select id="selectFollowerIds" resultType="java.lang.Long"> |
|||
SELECT follower_id |
|||
FROM mini_user_follow |
|||
WHERE followed_id = #{userId} |
|||
</select> |
|||
|
|||
<select id="selectFollowingPage" resultType="com.youlai.boot.mini.model.vo.FollowUserVO"> |
|||
SELECT |
|||
u.id AS userId, |
|||
u.uuid AS userUuid, |
|||
u.nickname, |
|||
u.avatar, |
|||
f.create_time AS followTime |
|||
FROM mini_user_follow f |
|||
INNER JOIN sys_user u ON f.followed_id = u.id AND u.is_deleted = 0 |
|||
WHERE f.follower_id = #{userId} |
|||
ORDER BY f.create_time DESC |
|||
</select> |
|||
|
|||
<select id="selectFollowerPage" resultType="com.youlai.boot.mini.model.vo.FollowUserVO"> |
|||
SELECT |
|||
u.id AS userId, |
|||
u.uuid AS userUuid, |
|||
u.nickname, |
|||
u.avatar, |
|||
f.create_time AS followTime |
|||
FROM mini_user_follow f |
|||
INNER JOIN sys_user u ON f.follower_id = u.id AND u.is_deleted = 0 |
|||
WHERE f.followed_id = #{userId} |
|||
ORDER BY f.create_time DESC |
|||
</select> |
|||
|
|||
</mapper> |
|||
Loading…
Reference in new issue