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