Wang's Blog头像
Java 项目实战:交友项目视频功能——关注与取消关注的需求分析与代码实现封面图

Java 项目实战:交友项目视频功能——关注与取消关注的需求分析与代码实现

概述

小视频的发布和列表查询都做完之后,还剩最后一块拼图:关注视频作者。当前工程快照里也只有常量和字段占位、没有实现,这里把需求、存储设计、接口契约和参考实现一次讲透,把这个功能做扎实。

纲要

  • 业务场景:关注是视频生态的「订阅」关系
  • 关注与好友(双向)的本质区别(对照表)
  • 存储设计(这里核心)
  • 当前工程实现现状:只有 FOCUS_USER_KEY 常量 + VideoVo.hasFocus 占位
  • Redis Set 存关注关系:SADD / SREM / SISMEMBER / SMEMBERS
  • 未落库的现状与生产双写方案(MongoDB focus_user + Redis 缓存)
  • 接口设计:关注 / 取消关注(路径参数 uid,操作人取自 token)
  • 代码实现:Controller + Service 参考实现,视频列表回填 hasFocus
  • 流程时序图、原型线框图(关注按钮两态)
  • 异常与边界、验证要点、常见误解澄清

业务场景:关注是视频生态的「订阅」关系

在推荐系统里,关注是有实际业务含义的:关注了某个作者,他发布的视频会拿到更高的推荐权重。主流短视频平台基本都是这个逻辑,你关注的人,内容优先喂给你。当前工程的小视频模块也采用了这套规则,所以「关注」不只是个按钮状态,它是推荐系统的输入信号。

业务动作只有两个:

  • 关注:把「我关注了作者 X」这条关系存起来;
  • 取消关注:把这条关系删掉。

但有一个必须先掰清楚的概念:关注是单向关系。我关注你,你不必关注我;而好友是双向关系,A 加 B 为好友,系统里要写两条记录(A 的好友列表里有 B,B 的好友列表里有 A),参考实现中的即时通讯好友模块就是这么双写的。关注没有这个对称性,一个 Set 就够了。

对比项关注(当前模块)好友(参考实现)
方向单向:我关注你,与你无关双向:互为好友
对称性非对称,A 关注 B ≠ B 关注 A对称,一条关系两端共有
建立方式单方发起即生效,无需对方同意单方发起 + 双方同意(或直接双写)
解除方式随时单方取消,对方无感知删除好友,两端同时解除
存储一个 Set:FOCUS_USER_KEY_ + 我的id,成员为被关注者双写两条记录(各自的好友集合)
业务用途推荐加权 + 视频卡片「已关注」标记即时通讯、互相查看动态

还有一个容易被忽略的联动需求:从视频列表点进来时,作者名片上如果没关注就展示「关注」,已关注则展示「取消关注」。这意味着查询视频列表时必须顺带判存关注关系,给每张视频卡片回填一个 hasFocus 标记,关注不是孤立功能,它挂在视频列表的返回结构上。

存储设计:先看工程实读的现状

动手设计之前,先把当前工程快照翻一遍,确认目前实现进展。实测结论(以 08-小视频方案/01-代码/mf 实读为准):

  • Constants.java 里定义了关注关系的 Redis key 前缀:
package com.mf.commons.utils;

//常量定义
public class Constants {
    // ...省略其他常量...

    //关注用户的key
    public static final String FOCUS_USER_KEY = "FOCUS_USER_KEY_";
}
  • VideoVo 里已有标记字段,但 init() 方法把它写死为 0,当前快照里视频列表的 hasFocus 恒为假值:
private Integer hasFocus; //是否关注 (1是,0否)

public static VideoVo init(UserInfo userInfo, Video item) {
    // ...省略属性拷贝...
    vo.setHasFocus(0);
    vo.setHasLiked(0);
    return vo;
}
  • SmallVideosController 只有 saveVideos 和 queryVideoList 两个方法,SmallVideosService 里没有任何关注/取消关注方法,也没有 FocusUser 实体或落库实现。

也就是说,这一部分是真正需要动手实现的内容:需求在原始需求文档里、表结构在参考材料里、代码要自己写。原始需求文档给出的需求口径有三条:

  1. 关系数据要保存到 MongoDB,表名 focus_user(原始需求文档误写作「falseuser」),核心字段只有两个:userId(操作人)和 followUserId(被关注者);
  2. 接口是 POST + 路径参数 uid,关注和取消关注各一个;
  3. 内部要用 Redis 存一份,关注时写入、取消时删除。

原始需求文档里有一句「Redis 我们使用的是哈希的数据结构」。结合工程实读要辨证地看:同类需求里「用户喜欢」用的是 USER_LIKE_SET_ 前缀的 Set,后续关注列表查询的口径也是 SMEMBERS 取 id 列表,Hash 结构反而对不上这些用法,这里按 Redis Set 设计更合理,「哈希」大概率是表述混用。后面所有实现都以 Set 为准。

工程涉及文件的树状结构:

mf
├── mf-app-server
│   └── src/main/java/com/mf/server
│       ├── controller
│       │   └── SmallVideosController.java   // 新增关注/取消关注入口
│       ├── service
│       │   └── SmallVideosService.java      // 新增关注/取消关注/判存逻辑
│       └── interceptor
│           └── UserHolder.java              // 操作人从 token 解析而来
├── mf-dubbo
│   ├── mf-dubbo-interface
│   │   └── com/mf/dubbo/api
│   │       ├── VideoApi.java                // 已有:视频查询
│   │       └── FocusUserApi.java            // 生产方案新增:关注关系落库
│   └── mf-dubbo-mongo
│       └── com/mf/dubbo/api
│           └── VideoApiImpl.java
├── mf-model
│   └── src/main/java/com/mf/model
│       ├── mongo
│       │   └── FocusUser.java               // 生产方案新增:focus_user 集合实体
│       └── vo
│           └── VideoVo.java                 // 已有:hasFocus 标记字段
└── mf-commons
    └── src/main/java/com/mf/commons/utils
        └── Constants.java                   // 已有:FOCUS_USER_KEY 前缀

为什么关注关系适合 Redis Set

需求Set 的对应能力
视频列表要判存「我是否关注了该作者」SISMEMBER O(1) 判存,列表几十条视频逐个判断毫无压力
重复关注不能产生脏数据Set 元素天然去重,重复 SADD 是幂等的
取消关注SREM 直接移除,不存在也不报错,天然幂等
关注列表查询SMEMBERS 一次取回全部被关注者 id
单个用户的关注量级个人关注几百上千已是极限,全量放内存毫无负担

key 的设计是 FOCUS_USER_KEY_ + 操作人 id,成员是被关注者 id。比如用户 106 关注了 107 和 108:

FOCUS_USER_KEY_106  →  Set{ 107, 108 }

整个关注关系按「人」分片存储:查「我关注了谁」只碰我的 key,判存只碰我的 key,互不干扰。

未落库的现状与生产双写方案

按原始需求文档,关系本应同时写 MongoDB 的 focus_user 集合。当前工程快照里没有落库实现,Redis 成了唯一存储,这是参考实现的简化,但生产上不能这么干:Redis 一旦清空或重启丢数据,所有关注关系蒸发,推荐系统直接失明。

生产方案是经典的数据库 + 缓存双写:MongoDB focus_user 做持久层(可按 userId 查历史、做数据分析),Redis Set 做判存缓存。以 MongoDB 为准,Redis 可随时重建。

package com.mf.model.mongo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.mapping.Document;

import java.io.Serializable;

//关注用户表:生产方案持久层实体(当前工程快照中缺失,此处按原始需求文档补全)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "focus_user")
@CompoundIndex(def = "{'userId':1, 'followUserId':1}", unique = true) //一人对一人仅一条
public class FocusUser implements Serializable {

    @Id
    private ObjectId id;
    private Long userId;       //操作人(发起关注的人)
    private Long followUserId; //被关注的用户
}

写路径的时序:先落库,再同步缓存,任一步失败以库为准回滚缓存。

接口设计

按原始需求文档,两个接口都是 POST + 路径参数 uid,操作人从 token 解析(UserHolder.getUserId()),绝不由前端传入,否则任何人都能替别人关注。

项目关注取消关注
请求路径POST /smallVideos/{uid}/userFocusPOST /smallVideos/{uid}/userUnFocus
路径参数uid:被关注用户 iduid:要取消关注的用户 id
操作人token 解析,UserHolder.getUserId()同左
响应无响应体,200 即成功同左
{
  "follow": {
    "method": "POST",
    "path": "/smallVideos/107/userFocus",
    "pathParam": { "uid": "被关注的用户ID" },
    "operator": "从token解析,前端不传",
    "response": { "status": 200, "body": null }
  },
  "unfollow": {
    "method": "POST",
    "path": "/smallVideos/107/userUnFocus",
    "pathParam": { "uid": "要取消关注的用户ID" },
    "operator": "从token解析,前端不传",
    "response": { "status": 200, "body": null }
  }
}

代码实现

Controller:接收两个请求

package com.mf.server.controller;

import com.mf.server.service.SmallVideosService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/smallVideos")
public class SmallVideosController {

    @Autowired
    private SmallVideosService videosService;

    /**
     * 关注视频作者
     * 路径参数 uid:被关注的用户ID
     */
    @PostMapping("/{uid}/userFocus")
    public ResponseEntity followUser(@PathVariable("uid") Long uid) {
        videosService.followUser(uid);
        return ResponseEntity.ok(null);
    }

    /**
     * 取消关注
     * 路径参数 uid:要取消关注的用户ID
     */
    @PostMapping("/{uid}/userUnFocus")
    public ResponseEntity unFollowUser(@PathVariable("uid") Long uid) {
        videosService.unFollowUser(uid);
        return ResponseEntity.ok(null);
    }
}

Service:SADD 写入、SREM 移除

package com.mf.server.service;

import com.mf.commons.utils.Constants;
import com.mf.server.exception.BusinessException;
import com.mf.server.interceptor.UserHolder;
import com.mf.model.vo.ErrorResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

@Service
public class SmallVideosService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * 关注视频作者
     * @param followUserId 被关注的用户ID
     */
    public void followUser(Long followUserId) {
        Long userId = UserHolder.getUserId();

        //边界:不能关注自己
        if (userId.equals(followUserId)) {
            throw new BusinessException(ErrorResult.error());
        }

        //生产方案:先落库MongoDB,再同步Redis;此处按参考实现直写Redis
        String key = Constants.FOCUS_USER_KEY + userId;
        redisTemplate.opsForSet().add(key, followUserId.toString());
    }

    /**
     * 取消关注
     * @param followUserId 要取消关注的用户ID
     */
    public void unFollowUser(Long followUserId) {
        Long userId = UserHolder.getUserId();
        String key = Constants.FOCUS_USER_KEY + userId;
        //SREM移除不存在的成员不报错,重复取消天然幂等
        redisTemplate.opsForSet().remove(key, followUserId.toString());
    }
}

实现就两个动作:关注 = SADD,取消 = SREM。Set 的去重和幂等特性把重复点击、重复取消这些边界全部消化掉了,Service 层不用写任何「查一下再决定」的逻辑。

视频列表回填 hasFocus:SISMEMBER 判存

这是需求中的联动部分,VideoVo.init() 里写死的 hasFocus=0 要在列表查询时按真实关注关系覆盖。改造 queryVideoList 的构建循环:

//在SmallVideosService.queryVideoList的构建循环中回填关注标记
for (Video video : list) {
    UserInfo info = map.get(video.getUserId());
    if (info != null) {
        VideoVo vo = VideoVo.init(info, video);
        //判存:我是否关注了该视频的作者
        String focusKey = Constants.FOCUS_USER_KEY + UserHolder.getUserId();
        Boolean isFocus = redisTemplate.opsForSet()
                .isMember(focusKey, video.getUserId().toString());
        vo.setHasFocus(Boolean.TRUE.equals(isFocus) ? 1 : 0);
        vos.add(vo);
    }
}

每条视频做一次 SISMEMBER,单页 10 条也就是 10 次 O(1) 操作,完全可接受。要省网络往返,也可以先用 SMEMBERS 一次性取回我的关注集合,再在内存里 contains 判断,单页数据量大时后者更划算。

关注列表:SMEMBERS 取 id → video in 查询

「我的关注」列表页的查询口径(后续文章展开):SMEMBERS 取出我关注的全部用户 id,再拿 id 列表去 video 集合做 in 查询,把关注作者的视频一次性捞出。这正是 Set 存储设计的红利,关注列表查询不需要扫库,一个 SMEMBERS 就拿到全部被关注者。

流程时序图

RedisSmallVideosServiceSmallVideosController用户(106)RedisSmallVideosServiceSmallVideosController用户(106)之后用户刷新视频列表取消关注POST /smallVideos/107/userFocusfollowUser(107)UserHolder.getUserId() 取操作人SADD FOCUS_USER_KEY_106 107完成200GET /smallVideos?page=1&pagesize=10queryVideoList(1, 10)SISMEMBER FOCUS_USER_KEY_106 107truevo.setHasFocus(1)PageResult(视频卡片含hasFocus)卡片按钮显示"取消关注"POST /smallVideos/107/userUnFocusunFollowUser(107)SREM FOCUS_USER_KEY_106 107完成200(列表刷新后hasFocus=0,按钮回到"关注")

原型线框图

视频页作者栏的关注按钮有两个状态,一屏一图分开画。

屏 1:未关注态

┌─────────────────────────────┐
│  ← 小视频                   │
│ ┌─────────────────────────┐ │
│ │                         │ │
│ │      (视频播放区)      │ │
│ │                         │ │
│ └─────────────────────────┘ │
│  ┌────┐                     │
│  │头像│  作者昵称           │
│  └────┘  ┌──────────┐       │
│          │  + 关注   │       │
│          └──────────┘       │
│  视频签名文字……              │
│  ♡点赞   💬评论   ↗分享     │
└─────────────────────────────┘

要点:

  • 作者栏右侧固定展示「+ 关注」按钮,未关注态为高亮可点击;
    -点击后调用 POST /smallVideos/{uid}/userFocus,成功后按钮就地切换为屏 2 的「取消关注」,无需刷新页面;;
  • 按钮 uid 取当前视频卡片的 userId,操作人由 token 决定。

屏 2:已关注态

┌─────────────────────────────┐
│  ← 小视频                   │
│ ┌─────────────────────────┐ │
│ │                         │ │
│ │      (视频播放区)      │ │
│ │                         │ │
│ └─────────────────────────┘ │
│  ┌────┐                     │
│  │头像│  作者昵称           │
│  └────┘  ┌────────────┐     │
│          │  取消关注   │     │
│          └────────────┘     │
│  视频签名文字……              │
│  ♡点赞   💬评论   ↗分享     │
└─────────────────────────────┘
```实现要点:点:

- 已关注态按钮置灰或描边样式,文案变为「取消关注」;
- 按钮态由视频列表返回的 `hasFocus` 驱动(1 → 屏 2,0 → 屏 1),客户端不自行记忆;
- 点击调 `POST /smallVideos/{uid}/userUnFocus` 后回切屏 1;
- 上下滑切换视频时,每张卡片独立按各自的 `hasFocus` 渲染。

## API 速览

| 接口 | 方法 | 路径 | 关键参数 | Redis 操作 | 说明 |
|---|---|---|---|---|---|
| 关注作者 | POST | `/smallVideos/{uid}/userFocus` | uid(路径) | SADD | Set 去重,重复关注幂等 |
| 取消关注 | POST | `/smallVideos/{uid}/userUnFocus` | uid(路径) | SREM | 成员不存在不报错,幂等 |
| 判存(列表内) | GET | `/smallVideos` | page / pagesize | SISMEMBER | 回填 `VideoVo.hasFocus` | 关注列表(后续) | - | 后续文章 | - | SMEMBERS + video in 查询 | 取关注 id → 查其视频 | |

## 异常与边界

| 边界场景 | 现状/处理 | 说明 |
|---|---|---|
| 关注自己 | Service 显式拦截,抛业务异常 | `userId.equals(followUserId)` 判断,Set 技术上允许但业务上无意义 |
| 重复关注 | Set 天然幂等,无脏数据 | 第二次 SADD 是空操作,无需先查后插 |
| 取消不存在的关注 | SREM 幂等,返回 200 | 重复点击取消、取消从未关注的人都不报错 |
| Redis 与数据库不一致 当前工程快照未落库落库,Redis 是唯一存储 | 数据不可恢复是硬伤;生产按「先写 MongoDB focus_user,再同步 Redis Set」双写,以库为准,缓存可重建 |
| uid 对应用户不存在 | 建议先查 UserInfo 再写 | 当前实现未校验,关注一个已注销用户会留下悬挂关系,可由列表查询侧的判空兜底 |
| 未登录/伪造 uid | 操作人取自 token | 前端只传被关注者 uid,操作人不可伪造 |

## 验证要点

手工验证按这条链走,每步都能在 redis-cli 里看到确凿证据:

```bash
# 1. 用 106 的 token 关注 107
curl -X POST -H "Authorization: Bearer <token_106>" http://localhost:8080/smallVideos/107/userFocus

# 2. redis-cli 确认 Set 写入
SMEMBERS FOCUS_USER_KEY_106        # → "107"
SISMEMBER FOCUS_USER_KEY_106 107   # → 1

# 3. 拉视频列表,107 发布的视频 hasFocus 应为 1
curl -H "Authorization: Bearer <token_106>" "http://localhost:8080/smallVideos?page=1&pagesize=10"

# 4. 取消关注
curl -X POST -H "Authorization: Bearer <token_106>" http://localhost:8080/smallVideos/107/userUnFocus

# 5. 复核
SISMEMBER FOCUS_USER_KEY_106 107   # → 0,列表刷新后 hasFocus=0

重复点关注接口几次再 SCARD FOCUS_USER_KEY_106,计数不变即验证了幂等。

常见误解澄清

误解事实
关注和好友是一回事,都要双写关注是单向关系,只需在关注者的 Set 里记一条;好友才需要两端各写一条
原始需求文档说 Redis 用「哈希」,那就该用 Hashsh 参考实现中:同类「用户喜欢」用 Set(USER_LIKE_SET_),关注列表查询用 SMEMBERS,按 Set 设计才自洽,「哈希」是口头混称混称
hasFocus 是数据库里的字段不是,它是 VideoVo 的展示字段,查询列表时由 Redis 判存动态回填,init() 里默认 0
重复关注要先查询再决定插不插Set 元素去重,直接 SADD 即可,先查后插反而多余
关注数据只在 Redis 就够了 当前工程快照确实如此,但生产必须落 MongoDB 并以库为准,Redis 只做判存缓存缓存
被关注者会收到通知本模块没有消息通知设计,关注是无感知的单向动作

总结

  • 关注是单向的「订阅」关系,与好友的双向双写形成鲜明对照:一个 Set 记一条方向即可。
  • 存储选 Redis Set 的理由就三条:SISMEMBER 判存 O(1)、元素去重天然幂等、SMEMBERS 一次取关注列表。
    当前工程快照的现状要心里有数:参考实现里只有 FOCUS_USER_KEY 常量和 hasFocus 占位,关注/取消是待完成的实现内容;原始需求文档要求的 MongoDB focus_user 落库在生产上不可省,正确姿势是库 + 缓存双写、以库为准。准。
  • 视频列表的 hasFocus 回填是关注的联动需求,卡片按钮两态完全由服务端数据驱动。
    后续文章将进入通用缓存 SpringCache:问题引入,视频列表查询里已经出现了 @Cacheable 的身影,而关注判存每次都要打 Redis,方法级缓存的通用方案是接下来要解决的问题,SpringCache 系列从这里开篇。篇。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/Tyro_java/article/details/170355325

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--