知光项目用户关系模块

1 用户关系模块实现

1.1 准备用户关系模块的相关配置和常量

1.1.1 Canal->kafka桥接器

那么这个时候就有人问了,桥接器是个什么玩意?初学kafka和canal,肯定和我一样不太明白。这里我就简单的说一下。首先说一下mysql,canal,kafka这几个之间的关系。

MySQL (数据库) :是**"工厂"**。它负责源源不断地生产数据(比如用户注册了、有人下单了)。

Canal Server :是**"安插在工厂里的间谍"**。它伪装成工厂的内部人员(Slave),工厂每生产一样东西,它的小本本(Binlog)上就记一笔。但是,它记的内容很原始,外人看不懂。

Kafka (消息队列) :是**"国际物流港口"**。所有要发给下游(消费者)的货物,都必须堆到这里,等待卡车来拉走。

CanalKafkaBridge (这段代码) :就是**"跑腿小哥"(或者叫搬运工**)

如果没有这个桥接器,情况是这样的:

Canal手里拿着一大堆加密的生产记录,站在工厂门口。 港口(Kafka)那边空荡荡的,等着发货。 两者中间隔着一条河,语言也不通,没人运货!

这个桥接器(Bridge)的作用就是把这两头接起来:

  1. 进货 :它跑到 Canal 那里,问:"刚才工厂生产了什么?把小本本给我看看。" (connector.getWithoutAck)

  2. 翻译 & 包装 :它发现 Canal 给的是二进制的原始数据,太难懂了。于是它把它翻译成大家都能看懂的 JSON 格式 ,并且只挑出你关心的内容(比如 payload 字段)。(objectMapper 转换)

  3. 发货 :它把打包好的 JSON 包裹,扔到 Kafka 的集装箱(Topic)里。(kafka.send)

  4. 签字画押 :发完货后,它跑回去跟 Canal 说:"这批货我发走了,你可以把这一页撕掉了。" (connector.ack)

    package com.xiaoce.zhiguang.relation.config;

    import com.alibaba.otter.canal.client.CanalConnector;
    import com.alibaba.otter.canal.client.CanalConnectors;
    import com.alibaba.otter.canal.protocol.CanalEntry;
    import com.alibaba.otter.canal.protocol.Message;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.fasterxml.jackson.databind.node.ArrayNode;
    import com.fasterxml.jackson.databind.node.ObjectNode;
    import com.xiaoce.zhiguang.common.constant.Kafka.KafkaTopic;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Qualifier;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.context.SmartLifecycle;
    import org.springframework.core.task.TaskExecutor;
    import org.springframework.kafka.core.KafkaTemplate;
    import org.springframework.stereotype.Service;

    import java.net.InetSocketAddress;

    /**

    • CanalKafkaBridge

    • 这段代码的作用就是订阅outbox,当有修改的时候,将消息发送给kafka

    • @author 小策

    • @date 2026/2/5 12:17
      */
      @Service
      @Slf4j
      public class CanalKafkaBridge implements SmartLifecycle {

      private final KafkaTemplate<String, String> kafka; // 用于发送 Kafka 消息
      private CanalConnector connector; // Canal 的客户端连接对象
      private volatile boolean running; // 控制主循环的开关
      private final ObjectMapper objectMapper;
      private final boolean enabled;
      private final String host;
      private final int port;
      private final String destination;
      private final String username;
      private final String password;
      private final String filter;
      private final int batchSize;
      private final long intervalMs;
      private final TaskExecutor taskExecutor;

      /**

      • Canal 到 Kafka 的桥接器。

      • @param kafka Kafka 模板

      • @param objectMapper JSON 序列化器

      • @param enabled 是否启用

      • @param host Canal 主机

      • @param port Canal 端口

      • @param destination 实例名

      • @param username 用户名

      • @param password 密码

      • @param filter 订阅过滤表达式

      • @param batchSize 拉取批次大小

      • @param intervalMs 空轮询间隔毫秒
        /
        public CanalKafkaBridge(KafkaTemplate<String, String> kafka,
        ObjectMapper objectMapper,
        @Qualifier("taskExecutor") TaskExecutor taskExecutor,
        @Value("{canal.enabled}") boolean enabled, @Value("{canal.host}") String host,
        @Value("{canal.port}") int port, @Value("{canal.destination}") String destination,
        @Value("{canal.username}") String username, @Value("{canal.password}") String password,
        @Value("{canal.filter}") String filter, @Value("{canal.batchSize}") int batchSize,
        @Value("${canal.intervalMs}") long intervalMs) {
        this.kafka = kafka;
        this.objectMapper = objectMapper;
        this.taskExecutor = taskExecutor;
        this.enabled = enabled;
        this.host = host;
        this.port = port;
        this.destination = destination;
        this.username = username;
        this.password = password;
        this.filter = filter;
        this.batchSize = batchSize;
        this.intervalMs = intervalMs;
        }
        /
        *

      • 启动桥接器:消费 Canal 并投递到 Kafka。

      • 该方法通常在应用启动时调用(如实现 SmartLifecycle 或 CommandLineRunner)。
        */
        @Override
        public void start() {
        // 1. 防止重复启动
        if (running) {
        log.info("Canal bridge start skipped: running={} enabled={} host={} port={} dest={} filter={}", running, enabled, host, port, destination, filter);
        return;
        }

        // 2. 标记状态为运行中
        running = true;

        // 3. 异步执行主循环
        // Canal 的消费是一个死循环(Long Polling),必须在独立线程中执行,
        // 否则会阻塞主线程导致应用无法完全启动。
        taskExecutor.execute(() -> {
        try {
        // ================= 连接初始化阶段 =================

        复制代码
             // 创建 Canal 连接器:指定 Canal Server 地址、Destination(实例名)、账号密码
             connector = CanalConnectors.newSingleConnector(new InetSocketAddress(host, port), destination, username, password);
             log.info("Canal connecting to {}:{} dest={} user={} filter={}", host, port, destination, username, filter);
        
             // 建立 TCP 连接
             connector.connect();
        
             // 订阅 Filter:告诉 Canal Server 我们只关心哪些库表的变更
             // 例如:filter 可能配置为 "mydb.outbox_table",这样 Server 端会过滤掉其他表的 Binlog
             connector.subscribe(filter);
        
             // 回滚位点:防止上次异常退出导致部分数据已处理但未 ACK,
             // 回滚到服务端记录的最后一次成功 ACK 的位置,保证"至少一次"消费语义。
             connector.rollback();
        
             log.info("Canal connected and subscribed: host={} port={} dest={} filter={} batchSize={} intervalMs={}ms", host, port, destination, filter, batchSize, intervalMs);
        
             // ================= 消息拉取主循环 =================
             while (running) {
                 // 1. 拉取数据(关键点:getWithoutAck)
                 // 这里的机制是:拉取 batchSize 条数据,但在我们显式调用 ack() 之前,
                 // Canal Server 不会认为这批数据已被消费。如果此时崩溃,下次连接会重发。
                 Message message = connector.getWithoutAck(batchSize);
                 long batchId = message.getId();
        
                 // 2. 空批次处理(心跳或无数据)
                 // batchId == -1 表示没有拉取到数据
                 // entries 为空表示虽然有包交互但没有实际 Binlog 变更
                 if (batchId == -1 || message.getEntries() == null || message.getEntries().isEmpty()) {
                     try {
                         // 短暂休眠,避免在无数据时进行高频空轮询消耗 CPU
                         Thread.sleep(intervalMs);
                     } catch (InterruptedException ignored) {}
                     continue;
                 }
        
                 // 3. 遍历处理这批次中的每一条 Entry(一个事务可能包含多个 Entry)
                 for (CanalEntry.Entry entry : message.getEntries()) {
        
                     // 过滤掉非行级数据变更
                     // Canal 会推送 TransactionBegin, TransactionEnd, Heartbeat 等类型,
                     // 我们只关心 ROWDATA (也就是 INSERT/UPDATE/DELETE 产生的数据)
                     if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) {
                         continue;
                     }
        
                     CanalEntry.RowChange rowChange;
                     try {
                         // 反序列化:Canal 使用 Protocol Buffer 传输数据
                         // 将二进制 storeValue 解析为 RowChange 对象
                         rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
                     } catch (Exception e) {
                         // 解析失败通常是严重错误,这里为了健壮性选择了跳过 (生产环境建议记录 Error 日志)
                         continue;
                     }
        
                     CanalEntry.EventType eventType = rowChange.getEventType();
        
                     // 4. 事件类型过滤
                     // 业务逻辑决定:只处理 INSERT 和 UPDATE。
                     // 通常在 Outbox 模式中,我们只关心新写入的消息,DELETE 操作通常不包含业务负载。
                     if (eventType != CanalEntry.EventType.INSERT && eventType != CanalEntry.EventType.UPDATE) {
                         continue;
                     }
        
                     // 准备构建发送给 Kafka 的 JSON 数组
                     ArrayNode dataArray = objectMapper.createArrayNode();
        
                     // 遍历行数据集(一个 SQL 可能影响多行,例如 batch insert)
                     for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
                         ObjectNode rowNode = objectMapper.createObjectNode();
        
                         // getAfterColumnsList() 获取变更后的列数据
                         // getBeforeColumnsList() 获取变更前的列数据(Update 时有用,这里没用)
                         for (CanalEntry.Column col : rowData.getAfterColumnsList()) {
        
                             // 5. 提取核心业务字段 "payload"
                             // 这里假设数据库表中有一个名为 `payload` 的字段存储了实际的业务 JSON 字符串。
                             // 这是典型的 Outbox 表结构设计。
                             if ("payload".equalsIgnoreCase(col.getName())) {
                                 rowNode.put("payload", col.getValue());
                             }
                             // 如果需要其他字段(如 id, created_at),也可以在这里提取
                         }
                         dataArray.add(rowNode);
                     }
        
                     // 6. 组装最终发往 Kafka 的消息体
                     ObjectNode msgNode = objectMapper.createObjectNode();
                     msgNode.put("table", entry.getHeader().getTableName()); // 来源表名
                     msgNode.put("type", eventType == CanalEntry.EventType.INSERT ? "INSERT" : "UPDATE"); // 操作类型
                     msgNode.set("data", dataArray); // 变更数据列表
        
                     try {
                         // 7. 发送至 Kafka
                         // 将组装好的 JSON 对象序列化并发送
                         String json = objectMapper.writeValueAsString(msgNode);
                         kafka.send(KafkaTopic.CANAL_OUTBOX, json);
                     } catch (Exception ignored) {
                         // 注意:这里忽略异常意味着 Kafka 发送失败也会进行 ACK,会导致消息丢失!
                         // 生产环境建议:如果发送失败,应该抛出异常跳出循环,或者重试,
                         // 绝不能执行下面的 connector.ack(batchId)。
                     }
                 }
        
                 // 8. 提交确认 (ACK)
                 // 只有当 Kafka 发送成功后(理想情况下),才向 Canal Server 确认该 batchId。
                 // 这样如果中途程序崩溃,下次启动会重新拉取该 batch,保证数据不丢。
                 connector.ack(batchId);
             }
         } catch (Exception e) {
             log.error("Canal bridge error", e);
         } finally {
             // ================= 资源清理 =================
             if (connector != null) {
                 try {
                     // 循环结束(stop被调用)或异常退出时,断开连接
                     connector.disconnect();
                     log.info("Canal disconnected: dest={}", destination);
                 } catch (Exception ex) {
                     log.warn("Canal disconnect failed: dest={} err={}", destination, ex.getMessage());
                 }
             }
         }

        });
        }

      /**

      • 停止桥接器。
      • 将 running 置为 false,主线程循环会在下一次判断时退出,并进入 finally 块断开连接。
        */
        @Override
        public void stop() {
        running = false;
        }

      /**

      • 是否处于运行状态。
      • @return 运行状态
        */
        @Override
        public boolean isRunning() {
        return running;
        }
        }

1.1.2 kafka主题常量

复制代码
package com.xiaoce.zhiguang.common.constant.Kafka;

/**
 * Topic
 * <p>
 * kafka主题常量
 *
 * @author 小策
 * @date 2026/1/22 14:41
 */
public class KafkaTopic {
    private  KafkaTopic(){}
    public static final String EVENTS = "counter-events"; // 计数事件主题(点赞/收藏等增量)
    public static final String CANAL_OUTBOX = "canal-outbox"; // canal binlog 主题

}

1.1.3 点赞状态枚举

复制代码
package com.xiaoce.zhiguang.relation.Enum;

import lombok.Getter;

/**
 * FollowingType
 * <p>
 * TODO: 请在此处简要描述类的功能
 *
 * @author 小策
 * @date 2026/1/31 19:18
 */
@Getter
public enum FollowingStatus {
    FOLLOWING_CANCEL(0, "取消关注"),
    FOLLOWING_SUCCESS(1, "关注成功");

    private final int code;
    private final String desc;
    FollowingStatus(int code , String desc){
        this.code = code;
        this.desc = desc;
    }
}

1.1.4 关系事件类型枚举

复制代码
package com.xiaoce.zhiguang.relation.Enum;

import lombok.Getter;

/**
 * RelationEventType
 * <p>
 * TODO: 请在此处简要描述类的功能
 *
 * @author 小策
 * @date 2026/2/5 15:19
 */
@Getter
public enum RelationEventType {
    FollowCreated("FollowCreated"),

    FollowCanceled("FollowCanceled");

    private final String desc;
    RelationEventType(String desc) {
        this.desc = desc;
    }

}

1.2 准备用户关系模块的事件配置

1.2.1 事件Record类

这个更上一个计数模块的**"发票"** 类似,这里就不在多说

复制代码
package com.xiaoce.zhiguang.relation.event;

/**
 * RelationEvent
 * <p>
 * 使用 Java Record 类型定义,适用于 Canal 监听 binlog 后解析出来的不可变数据载荷。
 *主要用于描述用户之间的社交关系变动(如关注、取消关注、屏蔽等)。
 *
 * @param type   事件的具体动作类型。
 * 例如:"FOLLOWER_ADDED"   - 新增关注"FOLLOWER_REMOVED" - 取消关注"FRIEND_ADDED"     - 互粉成好友
 * @param fromUserId 动作的发起者用户 ID(例如:点击"关注"按钮的人)。
 * @param toUserId   动作的接收者用户 ID(例如:被关注的人)。
 * @param id         对应数据库业务表(如 user_follower 表)的主键 ID。
 * 在异步处理或追溯日志时,用于关联底层持久化记录。
 * @author 小策
 * @date 2026/1/31 14:12
 */
public record RelationEvent(
        String type,
        Long fromUserId,
        Long toUserId,
        Long id
) {
}

1.2.2 定义用户关系模块消费者

这里主要是结合前面定义的桥接器去使用,当canal订阅的outbox发生改变的时候,会发送消息到kafka。具体流程图如下:

暂时无法在飞书文档外展示此内容

复制代码
package com.xiaoce.zhiguang.relation.event;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xiaoce.zhiguang.common.constant.Kafka.KafkaTopic;
import com.xiaoce.zhiguang.common.utils.OutboxMessageUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Service;

import java.util.List;

/**
 * CanalOutboxConsumer 消费者
 * <p>
 * 场景说明:
 * 我们不直接在业务代码里发 Kafka 消息(因为可能会丢失),而是先把消息存到数据库的 Outbox 表。
 * Canal 监听数据库变动,把 Outbox 表的数据推送到 Kafka。
 * 这个类就是用来消费 Kafka 里这些数据的。
 * @author 小策
 * @date 2026/2/5 16:10
 */
@Service
@RequiredArgsConstructor
public class CanalOutboxConsumer {
    private final ObjectMapper objectMapper;
    private final RelationEventProcessor processor;
    @KafkaListener(topics = KafkaTopic.CANAL_OUTBOX, groupId = "relation-outbox-consumer")
    public void onMessage(String message, Acknowledgment ack) {
        try {
            // 第一步:拆外包装。
            // Canal 传过来的消息通常包了一层壳(比如包含 table, type, data 等字段),我们只需要里面的 data 行数据。
            List<JsonNode> rows = OutboxMessageUtil.extractRows(objectMapper, message);
            // 如果包裹是空的,直接签收(ACK),然后休息(return)
            if (rows.isEmpty()) {
                ack.acknowledge();
                return;
            }
            for (JsonNode row : rows) {
                JsonNode payloadNode = row.get("payload");
                if (payloadNode == null) {
                    continue;
                }
                // 翻译。
                // 把 payload 里的字符串变成我们可以操作的 RelationEvent Java 对象
                // 比如:payload 是 "{"userId":1, "targetId":2, "type":"FOLLOW"}" -> 变成 Java 对象
                RelationEvent evt = objectMapper.readValue(payloadNode.asText(), RelationEvent.class);
                processor.process(evt);
            }
            //全部搞定,签字确认!
            ack.acknowledge();
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }

    }
}

1.2.3 定义消费者里面的工作类

可能有人会问,这个方法为什么不直接定义在消费者里面?其实,把这段代码(业务逻辑)单独抽出来,遵循的是软件工程中最重要的原则之一:单一职责原则 (Single Responsibility Principle, SRP)。也就是俗话说的 "各司其职"。以下是给出的具体理由:

A. 隔离变化(解耦)

假设有一天,你的老板说:"Kafka 太贵了,我们换成 RabbitMQ 吧!"或者"我们不用消息队列了,改用 HTTP 调用吧!"

  • 如果逻辑没抽出来:你需要把写数据库、改 Redis 的几百行代码从 Kafka 的代码里抠出来,再粘贴到 RabbitMQ 的代码里。容易拷错,风险极大。
  • 如果抽出来了 :你只需要改 Consumer(服务员),换个监听器就行。Processor(厨师)完全不用动,因为炒菜的步骤(业务逻辑)是不会变的。

B. 代码可读性与维护

你的 process 方法里包含了 4 大块核心逻辑

  1. 防重 (Deduplication): Redis 锁。
  2. 写库 (DB) : followerMapper.insertOrUpdate
  3. 写缓存 (Cache) : redis.opsForZSet()
  4. 写计数 (Counter) : userCounterService.increment

如果把这些代码全塞进 Consumer 里,加上 JSON 解析那一堆 try-catch,那个方法可能要 100 多行。代码越长,Bug 越喜欢藏在里面。抽出来后,Consumer 只有短短几行,清清爽爽。

C. 方便测试

如果你想测试"关注逻辑写得对不对":

  • 抽出来后 :你可以直接写个单元测试 new RelationEventProcessor().process(evt),根本不需要启动 Kafka。

  • 不抽出来:你必须得启动一个 Kafka,还得发一条真实消息,才能测试这段逻辑,太麻烦了。

    package com.xiaoce.zhiguang.relation.event;

    import com.xiaoce.zhiguang.counter.service.IUserCounterService;
    import com.xiaoce.zhiguang.relation.Enum.FollowingStatus;
    import com.xiaoce.zhiguang.relation.Enum.RelationEventType;
    import com.xiaoce.zhiguang.relation.domain.po.Follower;
    import com.xiaoce.zhiguang.relation.mapper.FollowerMapper;
    import lombok.RequiredArgsConstructor;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Service;

    import java.time.Duration;

    /**

    • RelationEventProcessor

    • 通过订阅outbox和kafka异步更新粉丝表,作为关注表的伪从

    • @author 小策

    • @date 2026/2/5 14:58
      */
      @Service
      @RequiredArgsConstructor
      public class RelationEventProcessor {
      private final StringRedisTemplate redis;
      private final FollowerMapper followerMapper;
      private final IUserCounterService userCounterService;

      public void process(RelationEvent evt) {
      String dk = "dedup:rel:" + evt.type() + ":" + evt.fromUserId() + ":" + evt.toUserId() + ":" + (evt.id() == null ? "0" : String.valueOf(evt.id()));
      Boolean first = redis.opsForValue().setIfAbsent(dk, "1", Duration.ofMinutes(10));
      if(first == null || !first) return;
      //如果是关注事件,为什么是更新粉丝表?当用户点击关注的时候,粉丝表是关注表的伪从,通过outbox和kafka异步更新
      if (evt.type().equals(RelationEventType.FollowCreated.getDesc())) {
      //更新粉丝表
      Follower follower = Follower.builder()
      .id(evt.id())
      .fromUserId(evt.fromUserId())
      .toUserId(evt.toUserId())
      .relStatus(FollowingStatus.FOLLOWING_SUCCESS.getCode())
      .build();
      followerMapper.insertOrUpdate(follower);
      //更新关注表与粉丝表缓存
      long now = System.currentTimeMillis();
      redis.opsForZSet().add("uf:flws:" + evt.fromUserId(), String.valueOf(evt.toUserId()), now);
      redis.opsForZSet().add("uf:fans:" + evt.toUserId(), String.valueOf(evt.fromUserId()), now);
      redis.expire("uf:flws:" + evt.fromUserId(), Duration.ofHours(2));
      redis.expire("uf:fans:" + evt.toUserId(), Duration.ofHours(2));
      //更新计数器
      userCounterService.incrementFollowers(evt.toUserId(),1);
      userCounterService.incrementFollowings(evt.fromUserId(),1);
      }
      else if (evt.type().equals(RelationEventType.FollowCanceled.getDesc())) {
      //更新关注
      Follower follower = Follower.builder()
      .id(evt.id())
      .fromUserId(evt.fromUserId())
      .toUserId(evt.toUserId())
      .relStatus(FollowingStatus.FOLLOWING_CANCEL.getCode())
      .build();
      followerMapper.insertOrUpdate(follower);

      复制代码
           // 更新关注表与粉丝表缓存:移除 ZSet 项并刷新 TTL
           redis.opsForZSet().remove("uf:flws:" + evt.fromUserId(), String.valueOf(evt.toUserId()));
           redis.opsForZSet().remove("uf:fans:" + evt.toUserId(), String.valueOf(evt.fromUserId()));
           redis.expire("uf:flws:" + evt.fromUserId(), Duration.ofHours(2));
           redis.expire("uf:fans:" + evt.toUserId(), Duration.ofHours(2));
      
           // 更新关注数与粉丝数
           userCounterService.incrementFollowings(evt.fromUserId(), -1);
           userCounterService.incrementFollowers(evt.toUserId(), -1);
       }

      }
      }

1.3 用户关系模块controller层实现

复制代码
package com.xiaoce.zhiguang.relation.controller;

import com.xiaoce.zhiguang.auth.service.impl.JwtServiceImpl;
import com.xiaoce.zhiguang.relation.service.IRelationService;
import com.xiaoce.zhiguang.user.domain.vo.ProfileResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

/**
 * RelationController
 * <p>
 *
 * 关系接口控制器
 * 职责:关注/取消关注、关系三态查询、关注/粉丝列表(偏移与游标)、用户维度计数读取与采样自检。
 * 缓存:ZSet 存储关注/粉丝列表;用户计数采用 SDS 固定结构(5×4 字节,大端编码),提供采样一致性校验与按需重建。
 *
 *
 * @author 小策
 * @date 2026/2/4 19:47
 */
@RestController
@RequestMapping("/api/vi/relation")
@RequiredArgsConstructor
@Validated
@Tag(name = "用户关系模块")
public class RelationController {

    private final IRelationService relationService;
    private final JwtServiceImpl jwtService;

/**
 * 处理用户关注请求的接口方法
 * @param toUserId 被关注用户的ID
 * @param jwt 包含当前用户认证信息的JWT令牌
 * @return 关注操作是否成功
 */
    @PostMapping("/follow")
    @Operation(summary = "关注用户", description = "关注指定用户",
            responses = {
            @ApiResponse(responseCode = "200", description = "关注成功"),
            @ApiResponse(responseCode = "401", description = "用户未登录或 Token 过期"),
            @ApiResponse(responseCode = "400", description = "请求参数错误")
            })
    public boolean follow(@RequestParam("toUserId") long toUserId, @AuthenticationPrincipal Jwt jwt) {
        long uid = jwtService.extractUserId(jwt);  // 从JWT令牌中提取当前用户的ID
        return relationService.follow(uid, toUserId);  // 调用关注服务执行关注操作
    }

/**
 * 取消关注用户的接口方法
 * 该方法处理HTTP POST请求,用于取消当前用户对指定用户的关注关系
 *
 * @param toUserId 要被取消关注的用户ID
 * @param jwt 包含当前用户认证信息的JWT令牌
 * @return 操作结果,true表示取消关注成功,false表示失败
 */
    @PostMapping("/unfollow")
    @Operation(summary = "取消关注用户", description = "取消关注指定用户",
    responses = {
            @ApiResponse(responseCode = "200", description = "取消关注成功"),
            @ApiResponse(responseCode = "401", description = "用户未登录或 Token 过期"),
            @ApiResponse(responseCode = "400", description = "请求参数错误")})
    public boolean unfollow(@RequestParam("toUserId") long toUserId, @AuthenticationPrincipal Jwt jwt) {
    // 从JWT令牌中提取当前用户的ID
        long uid = jwtService.extractUserId(jwt);
    // 调用关系服务层的方法执行取消关注操作
        return relationService.unfollow(uid, toUserId);
    }

/**
 * 获取当前用户与指定用户之间的关系状态
 *
 * @param toUserId 目标用户的ID
 * @param jwt 包含当前用户信息的JWT认证主体
 * @return 包含关系状态的Map,键为目标用户ID,值为是否关注/被关注等关系状态
 */

    @GetMapping("/status")
    @Operation(summary = "获取用户关系状态", description = "获取当前用户与指定用户之间的关系状态",
            responses = {
            @ApiResponse(responseCode = "200", description = "获取关系状态成功"),
            @ApiResponse(responseCode = "401", description = "用户未登录或 Token 过期"),
            @ApiResponse(responseCode = "400", description = "请求参数错误")
            })
    public Map<String, Boolean> status(@RequestParam("toUserId") long toUserId, @AuthenticationPrincipal Jwt jwt) {
        long uid = jwtService.extractUserId(jwt);
        return relationService.relationStatus(uid, toUserId);
    }
/**
 * 获取用户关注列表的接口方法
 * 通过GET请求访问,需要提供用户ID参数,并支持分页查询
 *
 * @param userId 用户ID,必填参数,用于指定要查询的用户
 * @param limit 每页返回的关注用户数量,默认值为20,最小值为1,最大值为100
 * @param offset 分页偏移量,默认值为0,用于指定从第几个记录开始返回
 * @param cursor 分页游标,可选参数,用于实现基于游标的分页
 * @return 返回ProfileResponse类型的用户关注列表
 */
    @GetMapping("/following")
    @Operation(summary = "获取关注列表", description = "获取当前用户关注的用户列表",
            responses = {
            @ApiResponse(responseCode = "200", description = "获取关注列表成功"),
            @ApiResponse(responseCode = "401", description = "用户未登录或 Token 过期"),
            @ApiResponse(responseCode = "400", description = "请求参数错误")
            })
    public List<ProfileResponse> following(@RequestParam("userId") long userId,
                                           @RequestParam(value = "limit", defaultValue = "20") int limit,
                                           @RequestParam(value = "offset", defaultValue = "0") int offset,
                                           @RequestParam(value = "cursor", required = false) Long cursor){
    // 对limit参数进行校验,确保其在1-100的范围内
        int need = Math.min(Math.max(1, limit), 100);
    // 调用relationService的followingProfiles方法获取关注列表
        return relationService.followingProfiles(userId, need, Math.max(offset, 0), cursor);
    }

/**
 * 获取粉丝列表的接口方法
 * 使用 GET 方式访问,需要传入用户ID及相关参数
 *
 * @param userId 用户ID,必填参数
 * @param limit 返回记录数量,默认20,最大100,最小1
 * @param offset 偏移量,默认0,不能为负数
 * @param cursor 分页游标,可选参数,用于分页查询
 * @return 返回粉丝列表,类型为ProfileResponse的List集合
 */
    @GetMapping("/followers")
    @Operation(summary = "获取粉丝列表", description = "获取当前用户的粉丝列表",
            responses = {
            @ApiResponse(responseCode = "200", description = "获取粉丝列表成功"),
            @ApiResponse(responseCode = "401", description = "用户未登录或 Token 过期"),
            @ApiResponse(responseCode = "400", description = "请求参数错误")
            })
    public List<ProfileResponse> followers(@RequestParam("userId") long userId,
                                           @RequestParam(value = "limit", defaultValue = "20") int limit,
                                           @RequestParam(value = "offset", defaultValue = "0") int offset,
                                           @RequestParam(value = "cursor", required = false) Long cursor) {
    // 对limit参数进行校验,确保在1-100之间
        int need = Math.min(Math.max(limit, 1), 100);
    // 调用服务层方法获取粉丝列表,确保offset不小于0
        return relationService.followersProfiles(userId, need, Math.max(offset, 0), cursor);
    }
/**
 * 获取指定用户的计数信息接口
 * @param userId 用户ID,作为查询参数
 * @return 返回包含计数信息的Map,键为String类型,值为Long类型
 */
    @GetMapping("/counter")
    @Operation(summary = "获取用户计数信息", description = "获取指定用户的计数信息",
            responses = {
            @ApiResponse(responseCode = "200", description = "获取计数信息成功"),
            @ApiResponse(responseCode = "401", description = "用户未登录或 Token 过期"),
            @ApiResponse(responseCode = "400", description = "请求参数错误")
            })
    public Map<String, Long> counter(@RequestParam("userId") long userId) {
    // 调用relationService的counter方法获取指定用户的计数信息
        return relationService.counter(userId);
    }



}

1.4 用户关系模块service层接口实现

复制代码
package com.xiaoce.zhiguang.relation.service;

import com.xiaoce.zhiguang.user.domain.vo.ProfileResponse;

import java.util.List;
import java.util.Map;

/**
 * IRelationService
 * <p>
 * 该接口负责维护用户间的社交拓扑关系。为了应对高并发和大数据量,
 * 提供了基于传统 Offset 的分页和基于时间戳 Cursor 的高性能分页。
 *
 * @author 小策
 * @date 2026/1/31 14:28
 */
public interface IRelationService {
    /**
     * 【核心写入】关注操作
     * <p>实现逻辑建议:在事务内插入关系记录并同时写入 Outbox 表,以便 Canal 同步</p>
     * @param fromUserId 发起关注的用户ID (A)
     * @param toUserId   被关注的用户ID (B)
     * @return true: 关注成功; false: 已关注或操作失败
     */
    boolean follow(long fromUserId, long toUserId);

    /**
     * 【核心写入】取消关注
     * @param fromUserId 发起取消的用户ID
     * @param toUserId   被取消的用户ID
     * @return true: 取消成功; false: 未关注或操作失败
     */
    boolean unfollow(long fromUserId, long toUserId);

    /**
     * 【状态查询】单向关注判断
     * @return true: A 关注了 B
     */
    boolean isFollowing(long fromUserId, long toUserId);

    /**
     * 【列表查询】关注列表 (偏移量分页)
     * <p>适用于数据量较小或后台管理系统</p>
     * @param offset 跳过的记录数
     * @return 关注的用户 ID 列表
     */
    List<Long> following(long userId, int limit, int offset);

    /**
     * 【列表查询】粉丝列表 (偏移量分页)
     */
    List<Long> followers(long userId, int limit, int offset);

    /**
     * 【状态查询】关系三态分析
     * 返回 Map 包含:
     * 1. following: 我是否关注了他
     * 2. followedBy: 他是否关注了我
     * 3. mutual: 是否互粉
     * </p>
     */
    Map<String, Boolean> relationStatus(long userId, long otherUserId);

    /**
     * 【高性能查询】关注列表 (游标分页)
     * <p>采用时间戳作为游标,避免深分页性能问题,适用于移动端无限下拉
     * @param cursor 上一页最后一条记录的时间戳(毫秒)
     */
    List<Long> followingCursor(long userId, int limit, Long cursor);

    /**
     * 【高性能查询】粉丝列表 (游标分页)
     */
    List<Long> followersCursor(long userId, int limit, Long cursor);

    /**
     * 【视图聚合】关注列表资料版
     * 逻辑:先查 ID 列表,再批量调用 Profile 接口填充头像、昵称等资料
     */
    List<ProfileResponse> followingProfiles(long userId, int limit, int offset, Long cursor);


    /**
     * 【视图聚合】粉丝列表资料版
     */
    List<ProfileResponse> followersProfiles(long userId, int limit, int offset, Long cursor);

    Map<String, Long> counter(long userId);
}

1.5 用户关系模块service层实现类实现

1.5.1 关注方法的流程图

暂时无法在飞书文档外展示此内容

1.5.2 获取关注列表id的流程图

暂时无法在飞书文档外展示此内容

1.5.3 用户关系模块service层实现类的具体实现

对于关注/取消关注是否加令牌桶进行限流,解释是这样的:

follow(关注)方法上加令牌桶限流,而在 unfollow(取关)上不加,主要基于以下 4 个核心原因:

防止"刷粉"和恶意营销(核心原因)

这是最直接的业务原因。

  • 关注 (Follow)
    • 攻击模式 :黑产账号或营销号通常会通过脚本疯狂批量关注正常用户,以此诱导对方回粉(互粉),或者单纯为了在对方的消息列表中刷存在感("某某关注了你")。
    • 后果:如果不加限流,一个脚本一分钟关注 1000 人,会给大量用户造成骚扰,破坏社区体验。
  • 取关 (Unfollow)
    • 业务性质:取关通常没有任何"营销收益"。黑产不会通过疯狂取关来获得流量。
    • 结论:限制"关注"是为了打击垃圾账号,而限制"取关"没有太大的反垃圾价值。

下游系统的压力差异(通知风暴)

虽然在你的代码中,followunfollow 都会写入 Outbox 发送消息,但在实际的下游消费链路中,它们的处理成本不同:

  • 关注 (Follow)
    • 触发通知 :关注通常会触发**"消息推送"(Push Notification)或站内信("UserA 关注了您")。这是一连串昂贵的操作(查库、调第三方推送接口)。如果允许瞬间高频关注,会引发下游的通知风暴**。
  • 取关 (Unfollow)
    • 静默操作 :绝大多数社交软件的取关都是静默的(不会发通知告诉对方"UserA 取关了您")。因此,取关操作对下游通知服务的压力几乎为零。

用户体验 (UX) 的容错性

  • 清理需求:正常用户确实存在"批量取关"的场景。例如,用户想清理时间线,可能会在一分钟内连续取关几十个不再感兴趣的博主。如果此时系统提示"操作太快,请稍后再试",用户体验会非常差,感觉系统在故意阻拦他/她。
  • 误操作修正:如果用户手抖误关注了别人,他希望立刻、马上取关。如果在关注后被限流导致无法取关,会引发用户焦虑。

数据库存储增长风险

  • 关注 (INSERT) :代码中 followingMapper.insert新增数据 。不限流的话,恶意攻击可以在短时间内让数据库的 FollowingFollower 表体积爆炸式增长,消耗存储资源。
  • 取关 (UPDATE) :代码中 followingMapper.update 只是修改状态(逻辑删除)。虽然也会产生 Binlog,但它不会导致表记录数无限膨胀(甚至如果是物理删除,还能释放空间)。

对于有一个根据偏移量来获取粉丝/关注列表,为什么还要设计一个根据时间戳来获取粉丝/关注列表的解释如下:

为什么需要 Offset (基于偏移量)?

场景:PC 端网页、管理后台

想象你在 PC 浏览器上看 B 站或 GitHub 的粉丝列表,底部通常有一排按钮:[1] [2] [3] ... [Next]

  • 交互习惯:用户习惯点"第 3 页"去查看特定的数据。
  • 代码对应getListWithOffset \\rightarrow Redis ZREVRANGE key 0 9 (第1页),10 19 (第2页)。
  • 优点:可以随机跳转(比如直接跳到第 10 页)。
  • 缺点(致命)数据漂移(Data Shifting)

Offset 的致命缺陷:数据"乱跳"

假设你正在看第 1 页(展示第 1-10 个粉丝)。

就在你看的时候,突然有一个新粉丝关注了你

  • 列表头插入了一个新数据,原来的第 10 个粉丝被挤到了第 11 个位置(也就是第 2 页的第 1 个)。
  • 当你点击"下一页"时,你请求的是 OFFSET 10
  • 结果 :你会再次看到刚才在第 1 页看到的那个被挤下来的粉丝。
  • 用户感受:"哎?这个人我刚才不是看过了吗?怎么又出来了?"

为什么需要 Cursor (基于时间戳/游标)?

场景:手机 App、抖音、朋友圈

你在刷抖音或朋友圈时,从来没见过"第几页"的按钮吧?你只是一直往下滑。

  • 交互习惯:无限滚动(Infinite Scroll)。
  • 逻辑核心:前端告诉后端"我手里最后一条数据的时间是 12:00,请给我 12:00 之前的数据"。
  • 代码对应getListWithCursor \\rightarrow Redis ZREVRANGEBYSCORE key -inf 12:00 LIMIT ...
  • 优点(核心)数据绝对稳定(Stable)

Cursor 如何解决问题?

不管在你阅读期间,有没有新粉丝进来,或者有没有人取关:

  • 你手里的最后一条数据 ID 是固定的(锚点)。

  • 你只要请求"这个 ID 之后的数据",得到的结果永远是无缝衔接的下一批数据。

  • 结果:永远不会出现重复数据,也不会漏掉数据。

    package com.xiaoce.zhiguang.relation.service.impl;

    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
    import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
    import com.fasterxml.jackson.core.JsonProcessingException;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.github.benmanes.caffeine.cache.Cache;
    import com.github.benmanes.caffeine.cache.Caffeine;
    import com.xiaoce.zhiguang.common.utils.CollectionUtil;
    import com.xiaoce.zhiguang.counter.schema.UserCounterSchema;
    import com.xiaoce.zhiguang.counter.service.IUserCounterService;
    import com.xiaoce.zhiguang.relation.Enum.FollowingStatus;
    import com.xiaoce.zhiguang.relation.domain.po.Follower;
    import com.xiaoce.zhiguang.relation.domain.po.Following;
    import com.xiaoce.zhiguang.relation.domain.po.Outbox;
    import com.xiaoce.zhiguang.relation.event.RelationEvent;
    import com.xiaoce.zhiguang.relation.mapper.FollowerMapper;
    import com.xiaoce.zhiguang.relation.mapper.FollowingMapper;
    import com.xiaoce.zhiguang.relation.mapper.OutboxMapper;
    import com.xiaoce.zhiguang.relation.service.IRelationService;
    import com.xiaoce.zhiguang.user.domain.po.Users;
    import com.xiaoce.zhiguang.user.domain.vo.ProfileResponse;
    import com.xiaoce.zhiguang.user.mapper.UsersMapper;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.core.io.ClassPathResource;
    import org.springframework.data.redis.core.RedisCallback;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.data.redis.core.script.DefaultRedisScript;
    import org.springframework.scripting.support.ResourceScriptSource;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;

    import java.nio.charset.StandardCharsets;
    import java.sql.Timestamp;
    import java.time.Duration;
    import java.util.*;
    import java.util.concurrent.ThreadLocalRandom;
    import java.util.function.IntFunction;
    import java.util.stream.Collectors;

    /**

    • RelationServiceImpl

    • TODO: 请在此处简要描述类的功能

    • @author 小策

    • @date 2026/1/31 14:44
      */
      @Service
      @Slf4j
      public class RelationServiceImpl implements IRelationService {
      private final FollowerMapper followerMapper;
      private final FollowingMapper followingMapper;
      private final OutboxMapper outboxMapper;
      private final StringRedisTemplate redis;
      private final DefaultRedisScript<Long> tokenScript;
      private final ObjectMapper objectMapper;
      private final Cache<Long,List<Long>> flwsTopCache;
      private final Cache<Long,List<Long>> fansTopCache;
      private final UsersMapper usersMapper;
      private final IUserCounterService userCounterService;

      private static final int IDX_FOLLOWER = 2; // (2 - 1) * 4, 下标从 4 开始

      private static final int IDX_FOLLOWING = 1; // 下标从 0 开始

      public RelationServiceImpl(FollowerMapper followerMapper, FollowingMapper followingMapper, OutboxMapper outboxMapper, StringRedisTemplate redis,
      ObjectMapper objectMapper, UsersMapper usersMapper,IUserCounterService userCounterService) {
      this.followerMapper = followerMapper;
      this.followingMapper = followingMapper;
      this.outboxMapper = outboxMapper;
      this.redis = redis;
      this.tokenScript = new DefaultRedisScript<>();
      this.userCounterService = userCounterService;
      tokenScript.setResultType(Long.class);
      tokenScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/token_bucket.lua")));
      this.objectMapper = objectMapper;
      this.usersMapper = usersMapper;
      // 初始化 Caffeine 本地缓存
      // maximumSize(1000): 最多缓存 1000 个大 V 用户的列表,防止内存溢出
      // expireAfterWrite(10 min): 写入 10 分钟后过期,保证数据不会太旧
      this.flwsTopCache =
      Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(Duration.ofMinutes(10)).build();
      this.fansTopCache =
      Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(Duration.ofMinutes(10)).build();
      }

    /**

    • 根据偏移量和限制获取列表,支持本地缓存和Redis缓存
    • @param key Redis中存储的键名
    • @param offset 起始偏移量
    • @param limit 每页限制数量
    • @param rowsFetcher 用于从数据库获取数据的函数式接口
    • @param idField 用户ID字段名
    • @param tsField 时间戳字段名
    • @param localCache 本地缓存(通常是Caffeine)
    • @param userId 用户ID
    • @return 返回符合条件的长整型列表
      */
      private List<Long> getListWithOffset(String key, int offset,
      int limit, IntFunction<Map<Long, Map<String, Object>>> rowsFetcher,
      String idField, String tsField,
      Cache<Long, List<Long>> localCache, long userId) {
      // ZSet 的 reverseRange 是按分数从大到小(时间倒序,时间越大,离当前时间越近)取值。
      // 比如 offset=0, limit=10,就是取第 0 到第 9 个。
      Set<String> cached = redis.opsForZSet().reverseRange(key, offset, offset + limit - 1L);
      if (cached != null && !cached.isEmpty()) {
      return toLongList(cached);
      }
      // localCache (通常是 Caffeine) 存的是极其热门的数据(比如大V的前1000个粉丝)。
      // 这样做是为了防止某个明星发微博时,Redis 被刷爆。
      List<Long> top = localCache != null ? localCache.getIfPresent(userId) : null;
      // 检查top是否不为null且不为空集合
      if (top != null && !top.isEmpty()) {
      // 计算起始索引,确保不超出集合范围
      int from = Math.min(offset, top.size());
      // 计算结束索引,确保不超出集合范围,同时考虑limit限制
      int to = Math.min(offset + limit - 1, top.size());
      // 返回从from到to的子列表的新ArrayList实例
      return new ArrayList<>(top.subList(from, to));
      }
      //缓存全挂了,只能查数据库 (回源)
      // 计算需要查多少条数据。
      // 为什么是 limit + offset?
      // 假设你要查第 2 页(offset=10, limit=10),为了保证 ZSet 的顺序连续性,
      // 这里采取了暴力策略:直接把前 20 条全查出来重修建立缓存。
      int need = Math.max(1, limit + offset);
      // 【这里调用了你传入的 Lambda】
      // 真正去执行 SQL,限制最多查 1000 条防止数据库炸裂。
      // 返回值是一个 Map,Key是ID,Value是包含字段数据的 Map。
      Map<Long, Map<String, Object>> rows = rowsFetcher.apply(Math.min(need, 1000));
      if (rows != null && !rows.isEmpty()) {
      // 3.1 填充 Redis
      // 这个函数会遍历 rows,利用反射或 Map.get(idField) 取出 ID 和 时间,
      // 然后把你查到的数据塞回 Redis ZSet 中。
      fillZSet(key, rows, idField, tsField, null);
      // 给 Redis Key 设置 2 小时过期时间
      redis.expire(key, Duration.ofHours(2));
      // 3.2 判断是否需要更新本地缓存 (大V逻辑)
      // 这里的 switch 是在判断当前查的是粉丝还是关注。
      // IDX_FOLLOWER 可能是指判断粉丝数的位置。
      int idx = switch (idField) {
      case "fromUserId" -> IDX_FOLLOWER;
      // 如果 ID 是 fromUserId,说明查的是粉丝列表
      case "toUserId" -> IDX_FOLLOWING; // 如果 ID 是 toUserId,说明查的是关注列表
      default -> 2;
      };
      // isBigV: 判断这个 userId 在 idx 这个维度(比如粉丝数,关注数)是不是特别多?
      // 如果是大V,把这些数据也塞一份到 localCache (JVM内存) 里。
      if (localCache != null && isBigV(userId, idx)) {
      maybeUpdateTopCache(userId, key, localCache);
      }
      // 为什么不直接返回 rows?
      // 因为 ZSet 会帮我们自动排好序(按时间),直接从 Redis 取能保证顺序绝对正确。
      Set<String> filled = redis.opsForZSet().reverseRange(key, offset, offset + limit - 1L);
      return filled == null ? Collections.emptyList() : toLongList(filled);
      }
      return Collections.emptyList();
      }

    /**

    • 将字符串类型的Set集合转换为Long类型的List集合

    • @param set 包含字符串的Set集合

    • @return 转换后的Long类型List集合,如果输入集合为空则返回空List
      /
      private List<Long> toLongList(Set<String> set) {
      // 检查输入集合是否为空
      if (set.isEmpty()) {
      // 如果为空,返回一个空List
      return List.of();
      }
      // 使用Stream API将字符串集合转换为Long集合
      return set.stream().map(Long::parseLong).toList();
      }
      private void fillZSet(String key,
      Map<Long, Map<String, Object>> rows,
      String idField,
      String tsField,
      Long cursor) {
      Collection<Map<String, Object>> values = rows.values();
      for (Map<String, Object> value : values) {
      Object idObj = value.get(idField);
      Object tsObj = value.get(tsField);
      if (idObj == null || tsObj == null) continue;
      long score = tsScore(tsObj);
      //场景:数据回填 你的系统发现 Redis 缺数据了,要去数据库捞第 2 页的数据(应该是 11:50 以前的)。
      // 但是!就在这一秒,有人发了一条新朋友圈,时间是 11:55。
      //如果没有 Cursor 判断: 数据库查的时候可能因为事务隔离级别或者其他原因,
      // 把这条 11:55 的新数据也带出来了。 如果不加过滤直接塞进 Redis,Redis 里就会突然多出来一条 11:55 的数据。
      // 当你还在刷 11:50 以前的老数据时,突然跳出来一条 11:55 的,这就叫
      *"时间线乱序"**。
      //有了 Cursor 判断 (score <= 11:50): 就算数据库把 11:55 的那条查出来了,
      // 代码走到这一行: 11:55 <= 11:50 ? False! 直接丢弃。 这就保证了你这次往 Redis 里填的数据,
      // 严格遵守了"11:50 以前"这个界限。
      if (cursor == null || score <= cursor) {
      redis.opsForZSet().add(key, String.valueOf(idObj), score);
      }
      }
      }

      /**

      • 根据输入的对象类型获取时间戳分数
      • @param tsObj 输入的时间对象,可以是Timestamp或Date类型
      • @return 返回当前时间的时间戳毫秒数,如果输入是Timestamp或Date则返回其对应的时间戳
        /
        private long tsScore(Object tsObj) { // 方法:计算时间戳分数
        if (tsObj instanceof Timestamp ts) { // 判断是否为Timestamp类型,如果是则获取其时间戳
        return ts.getTime();
        }
        if (tsObj instanceof Date d) { // 判断是否为Date类型,如果是则获取其时间戳
        return d.getTime();
        }
        return System.currentTimeMillis(); // 如果既不是Timestamp也不是Date,则返回当前系统时间的时间戳
        }
        /
        *
      • 判断是否为大V(基于 followers 计数阈值)。
      • @param userId 用户ID
      • @return 是否为大V
        /
        private boolean isBigV(long userId, int idx) {
        byte[] row = redis.execute((RedisCallback<byte[]>) connection ->
        connection.stringCommands().get((("ucnt:" + userId).getBytes(StandardCharsets.UTF_8))));
        // 如果 Redis 查不到,说明是第一次关注,直接返回 false
        if (row == null || row.length < 20) return false;
        // 如果查到了,说明不是第一次关注,解析出粉丝数和关注数
        // 初始化一个长整型变量n,值为0
        long n = 0L;
        // 计算偏移量,idx减1后乘以4,用于确定在row数组中的起始位置
        int offset = (idx -1) * 4;
        // 循环4次,每次处理一个字节
        for (int i = 0; i < 4; i++) {
        // 将n左移8位,然后与当前字节进行按位或操作
        // (row[offset + i] & 0xff)确保只取低8位,防止符号扩展
        n = (n << 8) | (row[offset + i] & 0xff);
        }
        // 判断n是否大于等于500,000,并返回结果
        return n >= 500_000L;
        }
        /
        *
      • 可能更新顶部缓存的方法
      • @param userId 用户ID,作为缓存的键
      • @param key Redis中的键,用于获取ZSet数据
      • @param cache 本地缓存对象,用于存储获取到的数据
        /
        private void maybeUpdateTopCache(long userId, String key, Cache<Long, List<Long>> cache) {
        // 从Redis中获取ZSet的排名前500的数据(从高到低)
        Set<String> allSet = redis.opsForZSet().reverseRange(key, 0, 499);
        // 如果获取到的集合为空或null,则直接返回
        if (allSet == null || allSet.isEmpty()) return;
        // 创建一个与allSet大小相同的ArrayList,用于存储转换后的Long类型数据
        List<Long> all = new ArrayList<>(allSet.size());
        // 遍历allSet,将String类型的元素转换为Long类型并添加到all列表中
        for (String s : allSet) all.add(Long.valueOf(s));
        // 将转换后的列表存入缓存,以userId作为键
        cache.put(userId, all);
        }
        /
        *
    • 根据游标获取列表数据

    • 该方法实现了二级缓存查询机制,首先查询Redis缓存,若未命中则查询数据库并更新缓存

    • @param key Redis中ZSet的键名

    • @param limit 需要获取的数据条数限制

    • @param cursor 游标值,用于分页获取数据,为null时表示获取最新数据

    • @param rowsFetcher 数据库查询函数,用于从数据库获取原始数据

    • @param idField 数据对象中ID字段名

    • @param tsField 数据对象中时间戳字段名

    • @return 返回Long类型的列表,包含查询到的ID值
      /
      private List<Long> getListWithCursor(String key,
      int limit,
      Long cursor,
      IntFunction<Map<Long, Map<String, Object>>> rowsFetcher,
      String idField,
      String tsField) {
      //ZSet 的 Score 本质是 double。当我们想获取"排行榜最顶端"或者"时间轴最顶端"的数据时,
      // 我们不知道最新的时间戳具体是几点几分,所以直接用"正无穷"来囊括所有最新的数据。
      double max = cursor == null ? Double.POSITIVE_INFINITY : cursor.doubleValue();
      // 尝试查询 Redis 缓存 (Level 1)
      // reverseRangeByScore: 按分数从大到小找。范围是 (-∞, max]。
      // offset 传 0,count 传 limit。
      Set<String> cached = redis.opsForZSet().reverseRangeByScore(key, Double.NEGATIVE_INFINITY, max, 0, limit);
      if (cached != null && !cached.isEmpty()) {
      return toLongList(cached);
      }
      // 缓存没有命中,查询数据库 (Level 2)
      int need = Math.max(limit,100);
      Map<Long, Map<String, Object>> raws = rowsFetcher.apply(need);
      if (raws != null || !raws.isEmpty()) {
      //写入缓存
      fillZSet(key, raws, idField, tsField , cursor);
      redis.expire(key, Duration.ofHours(2));
      Set<String> filled = redis.opsForZSet().reverseRangeByScore(key, Double.NEGATIVE_INFINITY, max, 0, limit);
      return filled == null ? Collections.emptyList() : toLongList(filled);
      }
      return Collections.emptyList();
      }
      /
      *

      • 将用户ID列表转换为用户档案响应对象列表

      • @param ids 用户ID列表

      • @return 用户档案响应对象列表,如果输入为空则返回空列表
        /
        private List<ProfileResponse> toProfiles(List<Long> ids) {
        // 如果输入的ID列表为空,直接返回空列表
        if (CollectionUtil.isEmpty(ids)) return Collections.emptyList();
        // 根据ID列表查询用户信息
        List<Users> users = usersMapper.selectList(new QueryWrapper<Users>().lambda().in(Users::getId, ids));
        // 将用户列表转换为以用户ID为键的Map,过滤掉null值
        Map<Long, Users> map = users.stream().filter(Objects::isNull).collect(Collectors.toMap(Users::getId, u -> u));
        // 创建结果列表,初始化大小与输入ID列表相同
        List<ProfileResponse> profiles = new ArrayList<>(ids.size());
        // 遍历ID列表,为每个ID构建档案响应对象
        for (Long id : ids) {
        // 从Map中获取用户对象
        Users u = map.get(id);
        // 如果用户不存在则跳过
        if (u == null) continue;
        // 将用户实体转换为档案响应对象并添加到结果列表
        profiles.add(ProfileResponse.fromEntity(u));
        }
        return profiles;
        }
        /
        *

      • 关注操作,限流通过令牌桶,并写入 Outbox 以异步构建缓存与粉丝表。

      • @param fromUserId 发起关注的用户ID(谁点的关注)

      • @param toUserId 被关注的用户ID(关注了谁)

      • @return 是否关注成功
        */
        @Override
        @Transactional
        public boolean follow(long fromUserId, long toUserId) {
        // ========== 第一步:令牌桶限流(防止恶意刷关注) ==========
        // "100" 是ARGV[1],令牌桶容量(最多100个令牌)
        // "1" 是ARGV[2],每秒恢复1个令牌
        Long ok = redis.execute(tokenScript, List.of("r1:follow:" + fromUserId), "100", "1");
        if (ok == 0L) {
        return false; //关注失败
        }
        // ThreadLocalRandom.current() 获取当前线程的随机数生成器
        // .nextLong(Long.MAX_VALUE) 生成0到Long.MAX_VALUE之间的随机数
        long id = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
        Following following = Following.builder()
        .id(id)
        .fromUserId(fromUserId)
        .toUserId(toUserId)
        .relStatus(FollowingStatus.FOLLOWING_SUCCESS.getCode())
        .build();
        int success = followingMapper.insert(following);
        if (success > 0) {
        try {
        // 生成事件ID(也是随机的)
        long outId = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
        // 创建事件对象:"FollowCreated"表示关注创建事件
        RelationEvent event = new RelationEvent("FollowCreated", fromUserId, toUserId, id);
        // 把事件对象转成JSON字符串(像把东西打包成快递)
        String payload = objectMapper.writeValueAsString(event);
        Outbox outbox = Outbox.builder()
        .id(outId) //事件id
        .aggregateId(id) //业务id
        .aggregateType("FollowCreated") //业务类型
        .payload(payload) //载荷
        .type("following") //事件类型
        .build();
        outboxMapper.insert(outbox);
        } catch (JsonProcessingException ignore) {

        复制代码
         }
         return true;

        }
        return false;
        }

      /**

      • 取消关注操作,并写入 Outbox 事件。

      • @param fromUserId 发起取消关注的用户ID

      • @param toUserId 被取消关注的用户ID

      • @return 是否取消成功
        */
        @Override
        @Transactional
        public boolean unfollow(long fromUserId, long toUserId) {
        //更新关注表,将状态改为0
        // 创建一个Following对象,使用建造者模式设置关系状态为"取消关注"
        Following following = Following.builder()
        .relStatus(FollowingStatus.FOLLOWING_CANCEL.getCode()) // 设置关系状态码为"取消关注"
        .build();
        // 创建一个LambdaUpdateWrapper对象,用于构建数据库更新条件
        LambdaUpdateWrapper<Following> wrapper = new LambdaUpdateWrapper<>();
        // 设置更新条件:fromUserId字段等于传入的fromUserId值
        wrapper.eq(Following::getFromUserId,fromUserId)
        // 添加条件:toUserId字段等于传入的toUserId值
        .eq(Following::getToUserId,toUserId)
        // 设置更新操作:将relStatus字段更新为following对象中的relStatus值
        .set(Following::getRelStatus,following.getRelStatus());
        // 执行更新操作,并返回受影响的行数
        int success = followingMapper.update(following,wrapper);
        if (success > 0) {
        try {
        Long outId = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
        RelationEvent event = new RelationEvent("FollowCanceled", fromUserId, toUserId, null);
        // 把事件对象转成JSON字符串(像把东西打包成快递)
        String payload = objectMapper.writeValueAsString(event);
        Outbox outbox = Outbox.builder()
        .id(outId) //事件id
        .aggregateId(null) //业务id
        .aggregateType("FollowCanceled") //业务类型
        .payload(payload) //载荷
        .type("following") //事件类型
        .build();
        outboxMapper.insert(outbox);
        } catch (JsonProcessingException ignore) {

        复制代码
         }
         return true;

        }
        return false;
        }

      @Override
      /**

      • 判断用户是否关注了另一个用户
      • @param fromUserId 发起关注请求的用户ID
      • @param toUserId 被关注的用户ID
      • @return 如果关注关系存在则返回true,否则返回false
        /
        public boolean isFollowing(long fromUserId, long toUserId) {
        // 创建Lambda查询包装器
        LambdaQueryWrapper<Following> wrapper = new LambdaQueryWrapper<>();
        // 设置查询条件:fromUserId等于传入的fromUserId且toUserId等于传入的toUserId
        wrapper.eq(Following::getFromUserId,fromUserId)
        .eq(Following::getToUserId,toUserId);
        // 查询数据库中符合条件的关注记录数量,如果大于0则表示存在关注关系
        return followingMapper.selectCount(wrapper) > 0;
        }
        /
        *
      • 获取关注列表(偏移分页),优先读取 Redis ZSet,未命中时回填并设置 TTL。
      • @param userId 用户ID
      • @param limit 返回数量上限
      • @param offset 偏移量
      • @return 关注的用户ID列表
        /
        @Override
        public List<Long> following(long userId, int limit, int offset) {
        String key = "uf:flws:" + userId; // 用户关注的ZSet键
        return getListWithOffset(
        key, // Redis键
        offset, // ZREVRANGE offset limit
        limit,// 分页大小
        //【关键】这是一个"回调函数"(Lambda表达式)。
        // 意思就是:如果 Redis 和 本地缓存都没数据,请执行这段代码去数据库查。
        // 这里的 'need' 是工具方法算出来的:到底需要从数据库查多少条数据才能填满缓存。
        need -> followingMapper.listFollowingRows(userId, need, 0),
        "toUserId", // 数据库返回的字段名
        "createdAt", // 时间戳字段名
        flwsTopCache, // 二级缓存(Caffeine)
        userId// 用户ID
        );
        }
        /
        *
      • 获取粉丝列表(偏移分页),ZSet 优先,DB 回填并设置 TTL。
      • @param userId 用户ID
      • @param limit 返回数量上限
      • @param offset 偏移量
      • @return 粉丝用户ID列表
        */
        @Override
        public List<Long> followers(long userId, int limit, int offset) {
        String key = "uf:fans:" + userId;
        return getListWithOffset(
        key, // Redis键
        offset, // 偏移量
        limit,// 分页大小
        //【关键】这是一个"回调函数"(Lambda表达式)。
        // 意思就是:如果 Redis 和 本地缓存都没数据,请执行这段代码去数据库查。
        // 这里的 'need' 是工具方法算出来的:到底需要从数据库查多少条数据才能填满缓存。
        need -> followerMapper.listFollowerRows(userId, need, 0),
        "fromUserId", // 数据库返回的字段名
        "createdAt", // 时间戳字段名
        fansTopCache, // 二级缓存(Caffeine)
        userId// 用户ID
        );

      }

      /**

      • 查询双方关系状态,方便前端去渲染
      • @param userId 当前用户ID
      • @param otherUserId 对方用户ID
      • @return 三态关系:following/followedBy/mutual
        */
        @Override
        public Map<String, Boolean> relationStatus(long userId, long otherUserId) {
        // 检查当前用户是否关注了另一个用户
        boolean following = isFollowing(userId, otherUserId);
        // 检查另一个用户是否关注了当前用户
        boolean followedBy = isFollowing(otherUserId, userId);
        // 判断是否互相关注
        boolean mutual = following && followedBy;
        // 创建一个LinkedHashMap来存储关注关系状态
        Map<String, Boolean> m = new LinkedHashMap<>();
        // 将当前用户关注对方的状态存入map
        m.put("following", following);
        // 将对方关注当前用户的状态存入map
        m.put("followedBy", followedBy);
        // 将互相关注的状态存入map
        m.put("mutual", mutual);
        // 返回包含关注关系状态的map
        return m;
        }

    /**

    • 获取用户关注列表的分页数据

    • @param userId 用户ID

    • @param limit 每页显示的数据条数

    • @param cursor 分页游标,用于获取下一页数据

    • @return 返回关注用户ID列表
      */
      @Override
      public List<Long> followingCursor(long userId, int limit, Long cursor) {
      // 构建Redis中的关注列表键
      String key = "uf:flws:" + userId;
      // 使用带游标的分页方法获取关注列表
      return getListWithCursor(
      key, // Redis中的键
      limit, // 每页条数
      cursor, // 分页游标
      need -> followingMapper.listFollowingRows(userId, need, 0), // 查询函数
      "toUserId", // 结果映射字段
      "createdAt" // 排序字段
      );
      }

      @Override
      /**

    • 获取指定用户的粉丝列表,支持分页查询

    • @param userId 用户ID

    • @param limit 每页返回的记录数

    • @param cursor 分页游标,用于获取下一页数据,null表示从第一页开始

    • @return 返回粉丝ID列表
      */
      public List<Long> followersCursor(long userId, int limit, Long cursor) {
      // 构建Redis中存储粉丝列表的键
      String key = "uf:fans:" + userId;
      // 使用带游标的分页查询方法获取粉丝列表
      return getListWithCursor(
      key, // Redis中的键
      limit, // 每页条数
      cursor, // 分页游标
      need -> followerMapper.listFollowerRows(userId, need, 0), // 查询函数
      "toUserId", // 结果映射字段
      "createdAt" // 排序字段
      );
      }

    /**

    • 获取用户关注列表的个人信息
    • @param userId 用户ID
    • @param limit 返回结果的最大数量
    • @param offset 分页偏移量
    • @param cursor 分页游标,用于分页查询
    • @return 返回关注用户的个人信息列表
      */
      @Override
      public List<ProfileResponse> followingProfiles(long userId, int limit, int offset, Long cursor) {
      // 根据cursor是否存在选择不同的查询方式
      // 如果cursor不为null,使用followingCursor方法进行分页查询
      // 如果cursor为null,使用following方法进行常规分页查询
      List<Long> ids = cursor != null ? followingCursor(userId, limit, cursor)
      : following(userId, limit, offset);
      // 将查询到的用户ID列表转换为个人信息响应列表
      return toProfiles(ids);
      }

    /**

    • 根据用户ID获取关注者资料列表

    • 支持分页查询和基于游标的分页两种方式

    • @param userId 用户ID

    • @param limit 每页返回的记录数

    • @param offset 分页偏移量(当cursor为null时使用)

    • @param cursor 分页游标(用于实现基于游标的分页)

    • @return 关注者资料列表
      */
      @Override
      public List<ProfileResponse> followersProfiles(long userId, int limit, int offset, Long cursor) {
      // 根据cursor是否存在选择不同的分页方式
      // 如果cursor不为null,使用基于游标的分页方式
      // 如果cursor为null,使用传统的limit-offset分页方式
      List<Long> ids = cursor != null ? followersCursor(userId, limit, cursor)
      : followers(userId, limit, offset);
      // 将用户ID列表转换为ProfileResponse对象列表并返回
      return toProfiles(ids);
      }

      @Override
      public Map<String, Long> counter(long userId) {
      //先去redis中的sds获取数据
      byte[] rows = redis.execute((RedisCallback<byte[]>) connection ->
      connection.stringCommands().get(("ucnt:" + userId).getBytes(StandardCharsets.UTF_8))
      );
      Map<String, Long> result = new LinkedHashMap<>(); // 用来存储最终结果的map
      //当前redis中没有数据或者其结构出现问题,则进行重建
      if(rows == null || rows.length < UserCounterSchema.SCHEMA_LEN * UserCounterSchema.FIELD_SIZE) {
      try {
      userCounterService.rebuildAllCounters(userId);
      } catch (Exception e) {
      log.error("重建用户计数器失败", e);
      }
      }
      // 重建后再拿一次数据
      rows = redis.execute((RedisCallback<byte[]>) connection ->
      connection.stringCommands().get(("ucnt:" + userId).getBytes(StandardCharsets.UTF_8)));
      if(rows == null || rows.length < UserCounterSchema.SCHEMA_LEN * UserCounterSchema.FIELD_SIZE){
      result.put("followings", 0L);
      result.put("followers", 0L);
      result.put("posts", 0L);
      result.put("likedPosts", 0L);
      result.put("favedPosts", 0L);
      return result;
      }
      byte[] buf = rows;
      final int seg = buf.length / UserCounterSchema.FIELD_SIZE;
      //关注数
      long followings = readInt32BE(buf,UserCounterSchema.IDX_FOLLOWING);
      //粉丝数
      long followers = readInt32BE(buf,UserCounterSchema.IDX_FOLLOWER);
      //判断数据库是否查询成功
      boolean isDbQuerySuccess = true;
      //去数据库中进行比对
      String chkKey = "ucnt:chk:" + userId;
      // 【流量控制】使用 Redis 的 setIfAbsent (SETNX) 实现一个简单的分布式锁/限流器。
      // 只有当 Key 不存在时才设置成功。有效期 300 秒。
      // 含义:每 300 秒(5分钟)内,只允许对该用户进行一次数据库比对校验。
      // 避免每次请求都查数据库,导致数据库压力过大。
      Boolean doCheck = redis.opsForValue().setIfAbsent(chkKey, "1", java.time.Duration.ofSeconds(300));
      Long dbFollowings = 0L;
      Long dbFollowers = 0L;
      if (Boolean.TRUE.equals(doCheck)){
      //抢到了锁,就去数据库中查询对应的数据
      //查询关注的数量
      try {
      dbFollowings = followingMapper.selectCount(new LambdaQueryWrapper<Following>()
      .eq(Following::getFromUserId, userId)
      .eq(Following::getRelStatus , FollowingStatus.FOLLOWING_SUCCESS.getCode()));
      } catch (Exception e) {
      isDbQuerySuccess = false;
      }
      //查询粉丝的数量
      try {
      dbFollowers = followerMapper.selectCount(new LambdaQueryWrapper<Follower>()
      .eq(Follower::getToUserId, userId)
      .eq(Follower::getRelStatus , FollowingStatus.FOLLOWING_SUCCESS.getCode()));
      } catch (Exception e) {
      isDbQuerySuccess = false;
      }
      if (isDbQuerySuccess && (seg != UserCounterSchema.SCHEMA_LEN || followings != dbFollowings || followers != dbFollowers)){
      //数据不一致,或结构有问题,再次重建
      userCounterService.rebuildAllCounters(userId);
      //拿到重建后的数据
      byte[] newRows = redis.execute((RedisCallback<byte[]>) connection -> connection.stringCommands().get(("ucnt:" + userId).getBytes(StandardCharsets.UTF_8)));

      复制代码
               if(newRows != null && newRows.length >= UserCounterSchema.SCHEMA_LEN * UserCounterSchema.FIELD_SIZE){
                   rows = newRows;
                   buf = rows;
                   followings = readInt32BE(buf,UserCounterSchema.IDX_FOLLOWING);
                   followers = readInt32BE(buf,UserCounterSchema.IDX_FOLLOWER);
               }
           }
      
       }
       result.put("followings", followings); // 使用上面解析好的变量,这里可能出现数据不一致的情况
       result.put("followers", followers);   // 使用上面解析好的变量,这里可能出现数据不一致的情况
       // 解析剩余字段
       result.put("posts", readInt32BE(buf, UserCounterSchema.IDX_POST));
       result.put("likedPosts", readInt32BE(buf, UserCounterSchema.IDX_LIKE_GOT));
       result.put("favedPosts", readInt32BE(buf, UserCounterSchema.IDX_FAV_GOT));
       return result;

      }

    /**

    • 从字节数组中读取32位大端序整数
    • @param buf 输入的字节数组
    • @param idx 起始索引位置
    • @return 解析得到的32位长整数值,如果索引超出范围则返回0
      */
      private Long readInt32BE(byte[] buf, int idx) {
      // 检查索引是否超出有效范围
      if (idx > UserCounterSchema.SCHEMA_LEN || idx < 1) return 0L;
      // 初始化结果值为0
      Long result = 0L;
      // 计算实际的字节偏移量(因为索引从1开始)
      int offset = idx - 1;
      // 循环处理4个字节(32位)
      for (int i = 0; i < UserCounterSchema.FIELD_SIZE; i++) {
      // 将已有结果左移8位,然后与当前字节的值进行或运算
      // 这样实现大端序字节的组合
      result = (result << 8) | buf[offset + i];
      }
      // 返回最终结果
      return result;
      }
      }

1.5.4 用户关系模块的令牌桶lua脚本

复制代码
-- 设置令牌桶,限制一段时间的水流(流量)
local key = KEYS[1]           -- Redis的键名(如:user:123:rate)
local capacity = tonumber(ARGV[1])  -- 桶容量(最大令牌数)
local rate = tonumber(ARGV[2])      -- 令牌生成速度(个/秒)
local now = redis.call('TIME')[1]   -- 当前时间戳(秒)

local last = redis.call('HGET', key, 'last')   -- 上次检查时间
local tokens = redis.call('HGET', key, 'tokens') -- 当前剩余令牌数
if not last then -- 如果没有上次检查时间为无,则将第一次检查时间设置为现在,并且初始化容量
    last = now;
    tokens = capacity
end  -- 第一次使用时初始化
local elapsed = tonumber(now) - tonumber(last)  -- 过了多少秒
local add = elapsed * rate  -- 这段时间应该加的令牌数
tokens = math.min(capacity, tonumber(tokens) + add)  -- 加令牌(但不能超过容量)

if tokens < 1 then  -- 桶里令牌不足1个
    redis.call('HSET', key, 'last', now)  -- 更新最后时间
    redis.call('HSET', key, 'tokens', tokens)  -- 保存当前令牌数
    return 0  -- 返回0表示"请求被拒绝"
end

tokens = tokens - 1  -- 拿走一个令牌
redis.call('HSET', key, 'last', now)  -- 更新最后操作时间
redis.call('HSET', key, 'tokens', tokens)  -- 保存新的令牌数
redis.call('PEXPIRE', key, 60000)  -- 设置60秒后自动删除这个桶(节省内存)
return 1  -- 返回1表示"请求通过"
相关推荐
m5655bj2 小时前
使用 C# 修改 PDF 页面尺寸
java·pdf·c#
专注VB编程开发20年2 小时前
c#模仿内置 Socket.Receive(无需 out/ref,直接写回数据)
开发语言·c#
bugcome_com2 小时前
【零基础入门】C# 核心教程:从 HelloWorld 到入门精髓
c#
JQLvopkk2 小时前
C# 实现Http Json格式 Post 、Get 方法请求 winform服务器
http·c#·json
JQLvopkk2 小时前
C# 实践AI 编码:Visual Studio + VSCode 组合方案
人工智能·c#·visual studio
暖馒2 小时前
深度剖析串口通讯(232/485)
开发语言·c#·wpf·智能硬件
Traced back13 小时前
WinForms 线程安全三剑客详解
安全·c#·winform
喵叔哟13 小时前
05-LINQ查询语言入门
c#·solr·linq
钰fly18 小时前
工具块与vs的联合编程(豆包总结生成)
c#