知光项目知文发布模块

1 准备对应的DTO,VO,PO等文件

1.1 po

复制代码
package com.xiaoce.zhiguang.knowpost.domain.po;

import lombok.Builder;
import lombok.Data;

import java.time.Instant;

/**
 * KnowPostDetailRow
 * <p>
 * TODO: 请在此处简要描述类的功能
 *
 * @author 小策
 * @date 2026/2/6 14:52
 */
@Data
@Builder
public class KnowPostDetailRow {
    private Long id;
    private Long creatorId;
    private String title;
    private String description;
    private String tags;        // JSON 字符串
    private String imgUrls;     // JSON 字符串
    private String contentUrl;
    private String contentEtag;
    private String contentSha256;
    private String authorAvatar;
    private String authorNickname;
    private String authorTagJson;
    private Instant publishTime;
    private Boolean isTop;
    private String visible;
    private String type;
    private String status;
}

package com.xiaoce.zhiguang.knowpost.domain.po;

import lombok.Data;

import java.time.Instant;

/**
 * KnowPostFeedRow
 * <p>
 * TODO: 请在此处简要描述类的功能
 *
 * @author 小策
 * @date 2026/2/6 14:53
 */
@Data
public class KnowPostFeedRow {
    private Long id;
    private String title;
    private String description;
    private String tags;       // JSON 字符串
    private String imgUrls;    // JSON 字符串
    private String authorAvatar;
    private String authorNickname;
    private String authorTagJson; // 作者的领域标签 JSON
    private Instant publishTime;
    private Boolean isTop;
}

package com.xiaoce.zhiguang.knowpost.domain.po;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;

import java.io.Serializable;
import java.time.Instant;

/**
 * <p>
 * 知识库文章表
 * </p>
 *
 * @author 小策
 * @since 2026-01-20
 */
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("know_posts")
@Builder
@Schema(description = "知识库文章实体对象")
public class KnowPosts implements Serializable {

    private static final long serialVersionUID = 1L;

    @Schema(description = "主键ID (雪花算法生成)", example = "1745678901234567890")
    @TableId(value = "id", type = IdType.ASSIGN_ID)
    private Long id;

    @Schema(description = "作者ID,关联 users.id", example = "1745678901234567800")
    private Long creatorId;

    @Schema(description = "主分类ID", example = "101")
    private Long tagId;

    @Schema(description = "标签数组 JSON", example = "[\"Java\", \"Spring\"]")
    private String tags;

    @Schema(description = "标题", example = "深入理解 Java 虚拟机")
    private String title;

    @Schema(description = "摘要", example = "本文详细介绍了 JVM 的内存模型...")
    private String description;

    @Schema(description = "文章内容OSS地址 (大字段分离)", example = "https://oss.zhiguang.com/posts/content/1.md")
    private String contentUrl;

    @Schema(description = "OSS Key", example = "posts/content/1.md")
    private String contentObjectKey;

    @Schema(description = "OSS ETag校验")
    private String contentEtag;

    @Schema(description = "正文大小 (字节)")
    private Long contentSize;
    @Schema(description = "正文SHA-256哈希(hex)")
    private String contentSha256;

    @Schema(description = "是否置顶 (0:否, 1:是)", example = "false")
    private Boolean isTop;

    @Schema(description = "类型 (image, text, video)", example = "text")
    private String type;

    @Schema(description = "状态: draft(草稿), auditing(审核中), published(已发布)", example = "published")
    private String status;

    @Schema(description = "可见性 (public/private)", example = "public")
    private String visible;

    @Schema(description = "图片列表 JSON", example = "[\"https://oss.../1.jpg\"]")
    private String imgUrls;

    @Schema(description = "视频地址")
    private String videoUrl;

    @Schema(description = "创建时间")
    private Instant createTime;

    @Schema(description = "更新时间")
    private Instant updateTime;

    @Schema(description = "发布时间")
    private Instant publishTime;
}

1.2 DTO

复制代码
package com.xiaoce.zhiguang.knowpost.domain.dto;

import jakarta.validation.constraints.NotBlank;

/**
 * DescriptionSuggestRequest
 * <p>
 * 知文 AI 摘要请求
 *
 * @author 小策
 * @date 2026/2/6 15:03
 */
public record DescriptionSuggestRequest(
        @NotBlank(message = "content 不能为空") String content
) {
}

package com.xiaoce.zhiguang.knowpost.domain.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

/**
 * KnowPostContentConfirmRequest
 * <p>
 * 内容上传确认请求。
 *
 * @author 小策
 * @date 2026/2/6 15:05
 */
public record KnowPostContentConfirmRequest(
        @NotBlank String objectKey,
        @NotBlank String etag,
        @NotNull Long size,
        @NotBlank String sha256
) {
}

package com.xiaoce.zhiguang.knowpost.domain.dto;

import jakarta.validation.constraints.Size;

import java.util.List;

/**
 * KnowPostPatchRequest
 * <p>
 * 帖子元数据更新请求(部分字段可选)。
 *
 * @author 小策
 * @date 2026/2/6 15:09
 */
public record KnowPostPatchRequest(
        String title,
        Long tagId,
        @Size(max = 20) List<String> tags,
        @Size(max = 20) List<String> imgUrls,
        String visible,
        Boolean isTop,
        String description
) {
}

package com.xiaoce.zhiguang.knowpost.domain.dto;

import jakarta.validation.constraints.NotNull;

/**
 * KnowPostTopPatchRequest
 * <p>
 * TODO: 请在此处简要描述类的功能
 *
 * @author 小策
 * @date 2026/2/6 15:15
 */
public record KnowPostTopPatchRequest(
        @NotNull Boolean isTop
) {
}

package com.xiaoce.zhiguang.knowpost.domain.dto;

import jakarta.validation.constraints.NotBlank;

/**
 * KnowPostVisibilityPatchRequest
 * <p>
 * TODO: 请在此处简要描述类的功能
 *
 * @author 小策
 * @date 2026/2/6 15:16
 */
public record KnowPostVisibilityPatchRequest(
        @NotBlank String visible
) {
}

1.3 vo

复制代码
package com.xiaoce.zhiguang.knowpost.domain.vo;

/**
 * DescriptionSuggestResponse
 * <p>
 * 知文 AI 摘要响应
 *
 * @author 小策
 * @date 2026/2/6 15:06
 */
public record DescriptionSuggestResponse(
        String description
) {
}

package com.xiaoce.zhiguang.knowpost.domain.vo;

import java.util.List;

/**
 * FeedItemResponse
 * <p>
 * 首页 Feed 单条记录
 *
 * @author 小策
 * @date 2026/1/22 19:06
 */
public record FeedItemResponse(
        String id,
        String title,
        String description,
        String coverImage,
        List<String> tags,
        String authorAvatar,
        String authorNickname,
        String tagJson,
        Long likeCount,
        Long favoriteCount,
        Boolean liked,
        Boolean faved,
        Boolean isTop
) {
}

package com.xiaoce.zhiguang.knowpost.domain.vo;

import java.util.List;

/**
 * FeedPageResponse
 * <p>
 *  首页 Feed 分页响应。
 *
 * @author 小策
 * @date 2026/1/22 19:06
 */
public record FeedPageResponse(
        List<FeedItemResponse> items,
        int page,
        int size,
        boolean hasMore
) {
}

package com.xiaoce.zhiguang.knowpost.domain.vo;

import java.time.Instant;
import java.util.List;

/**
 * KnowPostDetailResponse
 * <p>
 * 知文详情响应。
 *
 * @author 小策
 * @date 2026/2/6 15:08
 */
public record KnowPostDetailResponse(
        String id,
        String title,
        String description,
        String contentUrl,
        List<String> images,
        List<String> tags,
        String authorId,
        String authorAvatar,
        String authorNickname,
        String authorTagJson,
        Long likeCount,
        Long favoriteCount,
        Boolean liked,
        Boolean faved,
        Boolean isTop,
        String visible,
        String type,
        Instant publishTime
) {
}

package com.xiaoce.zhiguang.knowpost.domain.vo;

/**
 * KnowPostDraftCreateResponse
 * <p>
 *  创建草稿响应:返回新建的帖子 ID(字符串避免前端精度丢失)。
 *
 * @author 小策
 * @date 2026/2/6 15:09
 */
public record KnowPostDraftCreateResponse(
        String id
) {
}

2 准备一些用到的工具

2.1 雪花ID生成器

这是一个非常经典的 分布式 ID 生成器 实现,采用的是 Twitter 的 Snowflake(雪花)算法

简单来说,这个类就像是一个 "全球唯一的发号机" 。无论你的系统部署了多少台服务器,只要每台服务器配置了不同的 datacenterId(数据中心ID)和 workerId(机器ID),它们生成的 ID 就永远不会重复,而且大致是按照时间递增的。

下面我为你详细拆解这个类。

这个类是干什么的?

它的作用是生成一个 64位的 Long 类型整数 (例如:1783492348234293),这个数字包含以下信息:

  • 符号位 (1位):始终为 0,保证 ID 是正数。
  • 时间戳 (41位):记录生成 ID 的时间(毫秒级)。可以使用 69 年。
  • 机器标识 (10位)
    • 5位 数据中心 ID (datacenterId)
    • 5位 工作机器 ID (workerId)
    • 这保证了不同服务器生成的 ID 不会冲突。
  • 序列号 (12位) :在 同一 毫秒 内,如果并发量很高,可以通过这个序列号区分不同的请求。一毫秒内最多生成 4096 个 ID。

为什么要写这个?(使用场景)

在单体应用(只有一台服务器,一个数据库)中,我们通常使用数据库的 自增 主键 (Auto Increment) 就够了。

但是在 分布式 系统 / 微服务架构 中,自增主键有很大的缺陷:

  1. 分库分表难:如果你的数据分布在不同的数据库里,每个库都从 1 开始自增,合并数据时 ID 就会冲突。
  2. 性能瓶颈:数据库自增需要加锁,高并发写时数据库压力大。
  3. 信息泄露:自增 ID 是连续的,竞争对手很容易爬取你的数据量(比如今天订单号是 100,明天是 200,就知道你卖了 100 单)。

UUID 也是一种选择,但它太长(字符串类型),且无序,作为数据库主键时会导致 B+ 树索引频繁分裂,写入性能极差

Snowflake 的优势:

  • 全局唯一:解决分库分表冲突。
  • 有序递增:基于时间戳,对数据库索引友好,写入性能高。
  • 高性能:纯内存位运算,不依赖数据库,每秒可生成几百万个 ID。

如果要自己写,怎么写出来?(开发逻辑)

如果你想手写一个,逻辑其实就是"拼积木"。你需要把 64 个 bit 填满。

核心步骤:

  1. 确定"基准时间" (Epoch)
    1. 你的代码里定义了 EPOCH = 1704067200000L (2024-01-01)。
    2. 所有的 ID 都是基于这个时间开始计算偏移量,这样可以延长 ID 的使用寿命。
  1. 定义位数结构
    1. 一般标准:41位时间 + 5位机房 + 5位机器 + 12位序列。
  1. 编写 nextId****核心方法 (加锁 synchronized**)**:
    1. 获取当前时间timestamp = currentMillis()
    2. 时钟回拨处理(难点):
      • 如果当前时间 < 上次生成时间,说明服务器时间被回调了(Bug 或 NTP 校时)。
      • 你的代码做了一个很好的优化:如果回拨时间很短(<=5ms),就睡过去,等时间追上来。如果回拨太多,直接报错拒绝生成。
    1. 同一 毫秒 内的处理
      • 如果 timestamp == lastTimestamp,说明并发很高,需要增加序列号 sequence++
      • 如果序列号超过 4095(12位最大值),必须等待下一毫秒waitNextMillis)。
    1. 不同 毫秒 的处理
      • 序列号归零 sequence = 0
  1. 位运算 拼接
    1. 利用位移 (<<) 和 或运算 (|) 把上面几个部分拼成一个 long

代码中的亮点

  • 时钟回拨的容错

  • Java

    if (offset <= 5) { Thread.sleep(offset); ... }

  • 很多简单的实现遇到时钟回拨直接抛异常,导致服务不可用。你的代码尝试"等待"几毫秒,增加了系统的健壮性。

有没有替代方案?(不要重复造轮子)

虽然了解原理很重要,但在实际的大厂项目中,我们很少自己手写这个类,而是直接用成熟的开源库,避免踩坑(比如时钟回拨的深坑)。

常见的替代方案:

  1. Hutool 工具包 (推荐中小项目)
    1. Java 最常用的工具库。
    2. 代码:IdUtil.getSnowflake(1, 1).nextId()
    3. 极其简单,开箱即用。
  1. MyBatis-Plus (推荐)
    1. 如果你用了 MyBatis-Plus,它内置了雪花算法。
    2. 实体类注解:@TableId(type = IdType.ASSIGN_ID)(其实在我的实体类上我写了这个注释)
    3. 插入数据库时自动生成,完全不用你操心。
  1. 美团 Leaf (大厂方案)
    1. 美团开源的分布式 ID 生成系统。
    2. 解决了 workerId 难以管理的问题(自动注册到 ZooKeeper),适合超大规模集群。
  1. 百度 UidGenerator
    1. 基于 Snowflake 优化,吞吐量更高,但配置稍复杂。

    package com.xiaoce.zhiguang.knowpost.id;

    import org.springframework.stereotype.Component;

    /**

    • SnowflakeIdGeneratorUtils

    • 线程安全的雪花算法 ID 生成器。

    • 41 位时间戳 + 5 位数据中心 + 5 位工作节点 + 12 位序列。

    • @author 小策

    • @date 2026/2/6 15:36
      */
      @Component
      public class SnowflakeIdGenerator {
      // 定义起始时间戳(2024-01-01 00:00:00 UTC)
      private static final long EPOCH = 1704067200000L; // 2024-01-01 00:00:00 UTC
      // 定义各部分的位数
      private static final long WORKER_ID_BITS = 5L; // 工作节点ID的位数
      private static final long DATACENTER_ID_BITS = 5L; // 数据中心ID的位数
      private static final long SEQUENCE_BITS = 12L; // 序列号的位数
      // 计算各部分的最大值
      private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); // 最大工作节点ID
      private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BITS); // 最大数据中心ID
      private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
      // 定义各部分的偏移量
      private static final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; // 工作节点ID的偏移量
      private static final long TIMESTAMP_LEFT_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS;
      private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS); // 数据中心ID的偏移量
      // 时间戳的偏移量
      private final long datacenterId; // 序列号掩码
      private final long workerId;
      // 数据中心ID和工作节点ID
      private long lastTimestamp = -1L;
      private long sequence = 0L;
      // 上次生成ID的时间戳和序列号
      public SnowflakeIdGenerator() {
      this(1, 1);
      }
      // 默认构造器,使用默认的数据中心ID和工作节点ID(都为1)
      public SnowflakeIdGenerator(long datacenterId, long workerId) {
      if (workerId > MAX_WORKER_ID || workerId < 0) {
      throw new IllegalArgumentException("workerId out of range");
      // 带参数的构造器,可以指定数据中心ID和工作节点ID
      }
      if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) {
      // 检查工作节点ID是否在有效范围内
      throw new IllegalArgumentException("datacenterId out of range");
      }
      this.datacenterId = datacenterId;
      this.workerId = workerId;
      }
      // 检查数据中心ID是否在有效范围内

      public synchronized long nextId() {
      long timestamp = currentTime();

    // if (timestamp < lastTimestamp) {
    // throw new IllegalStateException("Clock moved backwards. Refusing to generate id");
    // }
    /**
    * 生成下一个ID
    * @return 生成的64位ID
    */
    // 等待时钟追回的方案
    if (timestamp < lastTimestamp) {
    long offset = lastTimestamp - timestamp;

    复制代码
             // 1. 小幅度回拨(比如 NTP 校时导致的 1~5ms 间抖动):等待一会儿再试
             if (offset <= 5) {
                 try {
                     // 睡 offset 毫秒,给系统时钟一点时间"追上来"
                     Thread.sleep(offset);
                 } catch (InterruptedException e) {
                     Thread.currentThread().interrupt();
                     throw new IllegalStateException("Thread interrupted while waiting for clock to catch up", e);
                 }
    
                 timestamp = currentTime();
                 if (timestamp < lastTimestamp) {
                     // 等完还是没追上,说明问题较严重,直接拒绝
                     throw new IllegalStateException(
                             "Clock is still behind after waiting. last=" + lastTimestamp + ", now=" + timestamp);
                 }
             } else {
                 // 2. 回拨幅度太大,直接拒绝,避免线程长时间阻塞
                 throw new IllegalStateException(
                         "Clock moved backwards too much. Refusing to generate id. offset=" + offset + "ms");
             }
         }
    
         // 处理同一毫秒内的并发请求:序列号逻辑
         if (lastTimestamp == timestamp) {
             sequence = (sequence + 1) & SEQUENCE_MASK;
             if (sequence == 0) {
                 // 这一毫秒的 4096 个名额用完了
                 timestamp = waitNextMillis(lastTimestamp);
             }
         } else {
             sequence = 0L;
         }
    
         lastTimestamp = timestamp;
    
         // 组装 64 位 ID
         return ((timestamp - EPOCH) << TIMESTAMP_LEFT_SHIFT)
                 | (datacenterId << DATACENTER_ID_SHIFT)
                 | (workerId << WORKER_ID_SHIFT)
                 | sequence;
     }

    /**

    • 等待下一个毫秒时间戳

    • 该方法用于在当前时间戳小于等于上一个时间戳时,循环获取新的时间戳

    • 直到获取到大于上一个时间戳的新时间戳为止

    • @param lastTimestamp 上一次生成的时间戳

    • @return 返回新的时间戳
      */
      private long waitNextMillis(long lastTimestamp) {
      long timestamp = currentTime(); // 获取当前时间戳
      while (timestamp <= lastTimestamp) { // 如果当前时间戳小于等于上一个时间戳,则循环等待
      timestamp = currentTime(); // 重新获取当前时间戳
      }
      return timestamp; // 返回新的时间戳
      }

      private long currentTime() {
      return System.currentTimeMillis();
      }

    }

2.2 缓存页失效和计数监听器

FeedCacheInvalidationListener 监听器 详细解析

这个类是整个 Feed 流系统的 "实时数据维护者"。它的核心职责是监听系统中的点赞、收藏等事件,并利用反向索引技术,精准地找到所有包含该帖子的缓存页面,更新其中的计数,保证用户看到的数据是实时的。

类的核心作用与职责

简单来说,这个类就是一个 "后台自动修正员"

在 Feed 流(信息流)系统中,为了高性能,我们通常会把生成的页面(例如"推荐页第一页")整个缓存起来。但这就带来了一个大问题:如果用户给某个帖子点了赞,缓存里的数字还是旧的,怎么办?

这个类的作用就是解决这个问题:

  1. 监听动作 :时刻竖着耳朵听(@EventListener),看系统里有没有发生"点赞"或"收藏"的事件。
  2. 联动更新作者数据:如果有人给帖子点赞,顺便把帖子作者的"总获赞数"也加 1。
  3. 精准定位缓存 :利用 反向索引(Reverse Index),快速查出"这个帖子被缓存在了哪几个页面里"。
  4. 原地修正:把那些缓存页面拿出来,把点赞数 +1 或 -1,然后塞回去。

为什么要写这个类?

在没有这个类的情况下,你面临两个糟糕的选择:

  • 选择 A(不做处理):用户点赞后,刷新页面数字不跳动。用户体验极差,觉得系统卡了。
  • 选择 B(暴力删除):用户点赞后,直接把包含这个帖子的整个缓存页面删掉。用户下次刷新时触发回源查数据库。如果热点帖子并发高,数据库会被瞬间打爆(缓存击穿/雪崩)。

这个类实现了 "缓存原地更新",既保证了数据实时性(用户看得到变化),又保护了数据库(不需要回源查询)。


核心逻辑拆解(如果要自己写,怎么写?)

如果你要从零写一个这样的逻辑,可以按照以下步骤进行"拼积木":

第一步:搭建监听框架

你需要一个机制来接收消息。在 Spring 中,使用 @EventListener 监听自定义的 CounterEvent。这相当于一个大喇叭广播,谁关心谁就来听。

第二步:过滤无效信息

广播里可能有很多杂音(比如用户改了头像、关注了别人)。你需要用 if 判断:

  • 是不是"帖子"相关的事件?
  • 是不是"点赞"或"收藏"这种需要展示数字的事件?

第三步:利用"反向索引"找目标

这是最关键的一步。你不能遍历 Redis 里几百万个页面去检查有没有这个帖子。

  • 写入时 :每当生成一个缓存页面,就记录一条索引 feed:index:帖子ID -> [页面Key1, 页面Key2]
  • 更新时 (本类逻辑):直接查 feed:index:帖子ID,Redis 瞬间告诉你需要修改哪几个页面。
  • 时间分片技巧:为了防止索引无限膨胀,代码中按"小时"记录索引。查找时,查"当前小时"和"上一小时"即可覆盖绝大多数场景。

第四步:精细化手术(修改数据)

找到缓存页面(JSON)后,不能直接改字符串(太复杂)。

  1. 反序列化:把 JSON 变成 Java 对象。
  2. 遍历查找:在对象列表里找到那个 ID 对应的帖子。
  3. 数学计算:点赞数 +1,注意别减成负数。
  4. 脱敏处理
    1. 本地缓存(给特定用户看的):保留"我已点赞"的状态。
    2. Redis 缓存 (给所有人看的):必须抹除"我已点赞"的状态,否则张三点赞,李四也能看到红心。

第五步:安全写回( TTL 续命)

这是一个容易被忽视的细节。Redis 的 SET 命令默认会清除过期时间。

  • 错误做法 :直接 SET key value。结果:原本还有 10 秒过期的热点缓存,变成了永久有效,内存爆炸。
  • 正确做法 :先查 getExpire 剩余时间,然后 SET key value EX 剩余时间

代码中的关键技术点解析

  • @EventListener:Spring 的观察者模式实现,实现了业务逻辑(点赞)和辅助逻辑(更新缓存)的解耦。
  • LinkedHashSet:用于合并当前小时和上个小时查到的索引 Key,同时自动去重,防止同一个页面被处理两次。
  • preserveUserFlags****参数:这是一个非常细腻的设计。它解决了"公共缓存"与"个性化状态"的冲突问题,确保公共缓存永远是纯净的。

有没有替代方案?

除了这种"监听器 + 反向索引"的模式,业界还有以下几种做法:

  1. 写扩散( Push Model)
    1. 类似微博/Twitter。大 V 发帖时,直接把数据推送到所有粉丝的"收件箱(Timeline)"里。用户读取时不需要组装。
    2. 优点:读取极快。
    3. 缺点:存储成本极其巨大,不适合普通内容平台。
  1. 纯动态计算(Pull Model)
    1. 缓存里只存 ID 列表 [101, 102, 103]
    2. 每次读取时,根据 ID 实时去 Redis 查最新的计数和标题。
    3. 优点:不需要反向索引,逻辑简单,数据绝对实时。
    4. 缺点:每次刷新页面需要发起 N 次 Redis 请求(N+1 问题),网络开销大,适合小流量系统。
  1. 前端伪更新
    1. 用户点赞后,前端 JS 直接把数字 +1 变红。后端异步处理,不更新列表缓存。
    2. 优点:后端压力最小。
    3. 缺点:用户刷新页面后,数字又变回去了(如果缓存没过期),会有"回滚"的感觉。

再简单说一下反向索引

这是一个非常核心的架构设计问题。你代码里的"反向索引"其实就是整个缓存一致性系统的**"导航仪"**。

我就结合你的 FeedCacheInvalidationListener 类,用大白话把这个概念讲透,再看看大厂是怎么玩的。


什么是"反向索引"?

为了理解"反向",我们先得知道什么是"正向"。

➡️ 正向索引(Forward Index)

这是我们生成 Feed 流时的自然逻辑:

  • 逻辑:"我要看第 1 页,里面有哪些帖子?"
  • 结构页面 Key -> [帖子A, 帖子B, 帖子C]
  • 你的代码体现feed:public:ids:... (存的是 ID 列表)

⬅️ 反向索引(Reverse / Inverted Index)

这是为了更新数据而存在的"逆向逻辑":

  • 逻辑:"帖子 A 发生了变化(比如点赞了),它到底被藏在哪些页面里?"
  • 结构帖子ID -> [页面 Key 1, 页面 Key 2, 页面 Key 3]
  • 你的代码体现

形象的比喻:

  • 正向索引 就像**"书的目录"**:第一章讲了什么,第二章讲了什么。
  • 反向索引 就像**"书后的关键词索引"**:单词 "Java" 出现在了第 12 页、第 45 页。

为什么代码里必须用它?

FeedCacheInvalidationListener 里,当 onCounterChanged 监听到"帖子 1001 点赞 +1"时:

如果没有反向索引

  • 你不知道帖子 1001 在哪。你只能遍历 Redis 里成千上万个 feed:public:ids:* 的 Key,挨个检查里面有没有 1001。
    • 后果:Redis CPU 飙升,系统卡死,根本不可行。

有了反向索引:

  • 你直接问 Redis:SMEMBERS feed:public:index:1001:当前小时
  • Redis 秒回:["feed:public:ids:50:1", "feed:public:ids:50:2"]
    • 操作:你只需要去修改这两个页面即可,精准打击,效率极高。

大厂(字节/阿里/腾讯)是怎么用反向索引的?

在大厂的 Feed 流(信息流)电商 业务中,反向索引是标配,主要用于**"牵一发而动全身"**的场景。

场景一:内容合规与快速下架(字节/抖音/小红书)

  • 背景:一个大 V 发的视频,系统把它推给了 500 万人的"推荐流"缓存里。
  • 突发 :审核后台发现视频违规(涉黄/暴),必须秒级下架
  • 反向索引用法
    • 视频状态变更 status=banned
    • 查询反向索引:idx:video:123 -> 返回几万个 user_feed:uid_xyz 的 Key。
    • 异步队列:把这几万个 Key 丢给 Kafka/RocketMQ。
    • 消费者:批量删除这些缓存 Key。
    • 结果:所有用户刷新 Feed,该视频瞬间消失。

场景二:电商价格变动(淘宝/京东)

  • 背景:一个 iPhone 商品卡片,缓存在了"搜索结果页"、"购物车推荐"、"猜你喜欢"等无数个页面片段里。
  • 突发:商家把价格从 5999 改成了 4999。
  • 反向索引用法
    • idx:item:iphone15 -> [cache:search:mobile, cache:cart:rec, cache:home:feed...]
    • 一旦改价,系统顺着索引找到所有包含该商品价格的缓存片段,强制失效或更新。
    • 如果不这么做,用户看到 4999 点进去是 5999,会被投诉价格欺诈。

场景三:社交关系链(微博/Twitter)

  • 背景:你拉黑了一个人。
  • 需求:你的时间线(Timeline)里不能再出现他的微博。
  • 反向索引用法
    • 当拉黑动作发生时,查询 idx:author:被拉黑者ID -> 找到所有包含他微博的缓存页面。
    • 在你的 Timeline 缓存中精准剔除他的内容。

什么时候该用,什么时候不该用?

反向索引虽然好用,但它是有代价的(空间换时间,且增加了写入复杂度)。

必须用的情况:

  1. 读多写少,但要求高一致性:缓存要存很久(比如 10 分钟),但中间如果数据变了,用户必须立马看到变化(如点赞、改价、下架)。
  2. "多对多"映射:一个小的子元素(帖子)被包含在很多个大的父容器(页面)里,且父容器无法通过算法推导出来。
  3. 数据敏感:涉及合规、价格、库存等不能出错的数据。

不该用的情况(避坑):

  1. 高频更新的"热点"
    1. 如果一个帖子是"周杰伦发新歌",它可能出现在 1 亿人的缓存里。
    2. 这时候反向索引 idx:post:jay 会巨大无比(BigKey),光是读这个索引就能把 Redis 搞挂。
    3. 替代方案 :这种热点通常走读时动态合并(Lua 脚本实时查),不走静态缓存更新。
  1. 数据时效性要求低
    1. 比如"相关推荐",就算推的内容改了标题,用户 5 分钟后才看到也没关系。那就等缓存自然过期(TTL),别费劲维护索引了。
  1. 写入极其频繁
    1. 如果数据每秒变 100 次,你每秒都要去更新索引和缓存,系统会崩。

代码中的亮点:

代码里有一个非常聪明的点,这是很多初学者写不出来的:

为什么要加上 hourSlot**?**

  • 防止索引无限膨胀 :如果没有时间片,feed:public:index:1001 这个 Set 可能会存几十万个页面 Key,变成 BigKey
  • 自动过期:因为加上了时间,过了一个小时,旧的索引就没人查了(代码里只查当前和上个小时)。随着 Redis 的 TTL,旧索引自动消失,不需要专门写个定时任务去清理垃圾数据。

总结:

现在用的这套 " 监听器 + 时间分片反向索引 + 原地更新缓存" 的方案,已经是 标准的高并发 Feed 流缓存架构 了。

复制代码
package com.xiaoce.zhiguang.knowpost.listener;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.xiaoce.zhiguang.counter.event.CounterEvent;
import com.xiaoce.zhiguang.counter.service.impl.UserCounterService;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPosts;
import com.xiaoce.zhiguang.knowpost.domain.vo.FeedItemResponse;
import com.xiaoce.zhiguang.knowpost.domain.vo.FeedPageResponse;
import com.xiaoce.zhiguang.knowpost.mapper.KnowPostsMapper;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

/**
 * FeedCacheInvalidationListener
 * <p>
 * Feed 页面缓存失效与计数旁路更新监听器。
 * <p>职责:</p>
 * - 监听点赞/收藏等计数事件(仅处理实体类型为 "knowpost");
 * - 根据"页面反向索引"(`feed:public:index:{eid}:{hour}`)定位受影响页面,
 *   同步更新本地 Caffeine 缓存与 Redis 页面 JSON(保持 TTL 不变);
 * - 同步创作者收到的点赞/收藏用户维度计数(UserCounterService)。
 * <p>设计要点:</p>
 * - preserveUserFlags=true 时仅更新本地缓存并保留用户态标志 liked/faved,
 *   写回 Redis 页面 JSON 时不携带用户态标志,避免污染共享缓存;
 * - 页面 JSON 写回前读取并沿用剩余 TTL,防止覆盖过期策略;
 * - 反向索引按小时维护,监听器会同时覆盖当前与上一个小时段的页面键。
 * @author 小策
 * @date 2026/2/6 17:02
 */
@Component
public class FeedCacheInvalidationListener {
        // 【工具箱】
        // 1. 本地缓存(Caffeine):存取速度最快,放在应用内存里。
        private final Cache<String, FeedPageResponse> feedPublicCache;
        // 2. 远程缓存(Redis):所有服务器共享的中央仓库。
        private final StringRedisTemplate redis;
        // 3. 翻译官(Jackson):负责把 JSON 字符串转成 Java 对象,或者转回去。
        private final ObjectMapper objectMapper;
        // 4. 用户计数服务:负责更新"张三总共收到了多少个赞"。
        private final UserCounterService userCounterService;
        // 5. 数据库查询:用来查帖子的作者是谁。
        private final KnowPostsMapper knowPostMapper;

        // 构造函数:把上面的工具都注入进来
        // Qualifier:即便有再多同类型的缓存,Spring 也能精准地把对应的那个"喂"给你的字段。
        public FeedCacheInvalidationListener(@Qualifier("feedPublicCache") Cache<String, FeedPageResponse> feedPublicCache,
                                             StringRedisTemplate redis,
                                             ObjectMapper objectMapper,
                                             UserCounterService userCounterService,
                                              KnowPostsMapper knowPostMapper) {
            this.feedPublicCache = feedPublicCache;
            this.redis = redis;
            this.objectMapper = objectMapper;
            this.userCounterService = userCounterService;
            this.knowPostMapper = knowPostMapper;
        }

        /**
         * 【核心工作入口】监听计数变化事件
         * 当系统的任何地方发出 "CounterEvent" 广播时,这个方法会被触发。
         */
        @EventListener
        public void onCounterChanged(CounterEvent event) {
            // 1. 【过滤杂音】
            // 我们只关心"帖子(knowpost)"的变化。如果是"关注用户"或者"浏览量",这里不管。
            if (!"knowpost".equals(event.getEntityType())) {
                return;
            }

            String metric = event.getMetric(); // 是点赞(like)?还是收藏(fav)?

            // 2. 【再次过滤】只处理点赞和收藏,因为这两个需要在列表页实时展示数字。
            if ("like".equals(metric) || "fav".equals(metric)) {
                String eid = event.getEntityId(); // 帖子 ID
                int delta = event.getDelta();     // 变化量(+1 或 -1)

                // 3. 【更新作者的个人总数据】
                // 比如:帖子是张三写的,张三的"获赞总数"要 +1。
                try {
                    // 查一下这个帖子是谁写的
                    KnowPosts post = knowPostMapper.selectById(Long.valueOf(eid));
                    if (post != null && post.getCreatorId() != null) {
                        long owner = post.getCreatorId();
                        if ("like".equals(metric)) {
                            userCounterService.incrementLikesReceived(owner, delta);
                        }
                        if ("fav".equals(metric)) {
                            userCounterService.incrementFavsReceived(owner, delta);
                        }
                    }
                } catch (Exception ignored) {
                    // 容错:如果这一步挂了(比如数据库闪断),不要影响后面更新缓存的主逻辑,吞掉异常。
                }

                // ==========================================================
                // 重点来了!下面开始利用"反向索引"找缓存
                // ==========================================================

                // 4. 【定位时间段】
                // 我们的反向索引是按小时存储的。
                // 比如 key 是 "feed:index:帖子ID:2026020612" (第12个小时的索引)
                long hourSlot = System.currentTimeMillis() / 3600000L;
                Set<String> keys = new LinkedHashSet<>();

                // 5. 【查反向索引 - 当前小时】
                // 问 Redis:"在这个小时生成的 Feed 流里,有哪些页面包含了这篇帖子?"
                // 结果可能是一堆 Key,例如 ["feed:page:userA:1", "feed:page:public:5"]
                Set<String> cur = redis.opsForSet().members("feed:public:index:" + eid + ":" + hourSlot);
                if (cur != null) {
                    keys.addAll(cur);
                }

                // 6. 【查反向索引 - 上个小时】
                // 为什么要查上个小时?因为用户可能还没刷新,正在看 50 分钟前生成的旧页面。
                // 为了防止漏网之鱼,我们要把上一个时间段的页面也找出来更新。
                Set<String> prev = redis.opsForSet().members("feed:public:index:" + eid + ":" + (hourSlot - 1));
                if (prev != null) {
                    keys.addAll(prev);
                }

                // 如果两个时间段都没找到引用这篇帖子的页面,说明这篇帖子太冷门或者太老了,缓存里没有它。
                // 直接下班(return)。
                if (keys.isEmpty()) {
                    return;
                }

                // 7. 【循环修复】遍历找到的每一个缓存页面 Key,开始改数字
                for (String key : keys) {

                    // --- A. 修复本地内存 (Caffeine) ---
                    FeedPageResponse local = feedPublicCache.getIfPresent(key);
                    if (local != null) {
                        // 关键点:preserveUserFlags = true
                        // 本地缓存是用来给特定用户快速读取的,所以要保留 "liked=true" 这种红色心形状态。
                        FeedPageResponse updatedLocal = adjustPageCounts(local, eid, metric, delta, true);
                        feedPublicCache.put(key, updatedLocal);
                    }

                    // --- B. 修复远程缓存 (Redis) ---
                    // Redis 里的数据是公共的,所有人共用一份。
                    String cached = redis.opsForValue().get(key);
                    if (cached != null) {
                        try {
                            // 1. 把 JSON 字符串反序列化成 Java 对象
                            FeedPageResponse resp = objectMapper.readValue(cached, FeedPageResponse.class);

                            // 2. 修改对象里的数字
                            // 关键点:preserveUserFlags = false
                            // 写入 Redis 公共缓存时,必须把 "liked" 状态抹除(设为 null)。
                            // 否则:用户 A 点赞了,Redis 记住了 liked=true;用户 B 读这份缓存时,也会看到红心,以为自己点赞了。
                            FeedPageResponse updated = adjustPageCounts(resp, eid, metric, delta, false);

                            // 3. 写回 Redis(注意:这里有防止 TTL 重置的逻辑)
                            writePageJsonKeepingTtl(key, updated);
                        } catch (Exception ignored) {}
                    } else {
                        // 坑位清理:
                        // 如果发现 Redis 里其实已经没有这个页面了(可能刚好过期了),
                        // 那就在反向索引里把这个废弃的 Key 删掉,免得下次还来空跑一次。
                        redis.opsForSet().remove("feed:public:index:" + eid + ":" + hourSlot, key);
                    }
                }
            }
        }

        /**
         * 【精细化手术】修改页面里某一条帖子的点赞数
         * * @param page 整个页面数据(包含 10 条帖子)
         * @param eid 要修改的那条帖子 ID
         * @param preserveUserFlags 是否保留"我已点赞"这种个人状态?
         * 本地缓存选 true,Redis 公共缓存选 false。
         */
        private FeedPageResponse adjustPageCounts(FeedPageResponse page, String eid, String metric, int delta, boolean preserveUserFlags) {
            List<FeedItemResponse> items = new ArrayList<>(page.items().size());

            // 遍历这页的每一条帖子
            for (FeedItemResponse it : page.items()) {
                // 找到了目标帖子!
                if (eid.equals(it.id())) {
                    Long like = it.likeCount();
                    Long fav = it.favoriteCount();

                    // 数学计算:防止减成负数(Math.max(0L, ...))
                    if ("like".equals(metric)) {
                        like = Math.max(0L, (like == null ? 0L : like) + delta);
                    }
                    if ("fav".equals(metric)) {
                        fav = Math.max(0L, (fav == null ? 0L : fav) + delta);
                    }

                    // 状态处理:
                    // 如果 preserveUserFlags 是 false,强制把 liked/faved 设为 null。
                    // 这样前端拿到数据后,会根据用户自己的实时状态去判断,而不是盲目相信缓存。
                    Boolean liked = preserveUserFlags ? it.liked() : null;
                    Boolean faved = preserveUserFlags ? it.faved() : null;

                    // 重新构建一个新的帖子对象(因为 Java 的 Record 或某些对象是不可变的)
                    it = new FeedItemResponse(
                            it.id(), it.title(), it.description(), it.coverImage(), it.tags(),
                            it.authorAvatar(), it.authorNickname(), it.tagJson(),
                            like, // 新的点赞数
                            fav,  // 新的收藏数
                            liked, // 新的状态
                            faved,
                            it.isTop()
                    );
                }
                items.add(it);
            }

            // 返回修改后的新页面
            return new FeedPageResponse(items, page.page(), page.size(), page.hasMore());
        }

        /**
         * 【安全写入】写回 Redis 并保持原来的过期时间
         * * 为什么要写这个方法?
         * Redis 的默认 SET 命令会把 key 的过期时间(TTL)清除,变成永久有效。
         * 这会导致缓存里的旧数据永远不消失,这是个大 Bug。
         * 所以我们必须:先查剩余寿命,再写入数据并把寿命设回去。
         */
        private void writePageJsonKeepingTtl(String key, FeedPageResponse page) {
            try {
                String json = objectMapper.writeValueAsString(page);
                // 1. 查:这个 Key 还能活多久?
                long ttl = redis.getExpire(key);

                if (ttl > 0) {
                    // 2. 写:带上剩余寿命写入
                    redis.opsForValue().set(key, json, java.time.Duration.ofSeconds(ttl));
                } else {
                    // 如果本来就是永久的或者查不到 TTL,就直接写
                    redis.opsForValue().set(key, json);
                }
            } catch (Exception ignored) {}
        }
}

2.3 定义热键探测器

这是一个非常经典的 "滑动窗口计数器" (Sliding Window Counter) 的实现,采用了 环形数组 ( Ring Buffer ) 的设计思想。

简单来说,这个类就是一个 "热点探测仪"


这个类是干什么的?

它的核心作用是:在本地 内存 中,统计过去一段时间内(比如过去 60 秒),某个 Key 被访问了多少次。

基于这个统计数据,它实现了以下功能:

  1. 记账:谁被访问了,就在小本本上记一笔。
  2. 定级:根据访问量,给 Key 贴上标签(无热度、低热度、中热度、高热度)。
  3. 续命这是最核心的目的。如果发现某个 Key 是"高热度",就在生成缓存时给它设置更长的过期时间(TTL)。

形象的比喻:

想象一个 "环形计分板",上面有 6 个格子(代表 6 个 10秒的时间段)。

  • 指针指向格子 A,现在的访问量都记在 A 里。
  • 过 10 秒,指针指向格子 B,把 B 清零,新的访问量记在 B 里。
  • 如果要算"过去 60 秒的总热度",就把 A+B+C+D+E+F 的数字加起来。

为什么要写这个类?

在没有这个类的时候,所有的缓存过期时间通常是固定的(比如 5 分钟)。这会带来两个大问题:

  1. 缓存击穿 (Cache Stampede)
    1. 假设"周杰伦发新歌"这个热点新闻缓存设置了 5 分钟过期。
    2. 在 05:00 这一秒,缓存过期消失。
    3. 在 05:01 这一毫秒,哪怕只有 0.1 秒的间隙,可能有 10 万个用户同时请求。
    4. 因为缓存没了,这 10 万个请求全部打到数据库,数据库瞬间崩溃。
    5. 如果有这个类:系统检测到这是"高热点",自动把过期时间延长到 30 分钟,避免它在高峰期失效。
  1. 资源浪费
    1. 对于没人看的冷门数据,缓存存 5 分钟都嫌多,占内存。
    2. 对于热门数据,存 5 分钟太短,频繁回源查库浪费 CPU。
    3. 如果有这个类:实现"能者多劳,热者长存"。

如果要自己写,怎么写出来?

如果你要从零手写一个热点探测器,逻辑就是"搭积木"。

第一步:怎么存?(数据结构)

你不能只存一个总数 int count,因为你无法剔除"1小时前"的旧数据。

大厂写法 :使用时间分片

  • 定义一个 Map<String, int[]>
  • String 是 Key。
  • int[] 是一个数组(比如长度为 6),每个元素代表 10 秒钟。

第二步:怎么记?(写入逻辑)

  • 搞一个指针 current,指向当前的时间格。
  • 请求来了 -> map.get(key)[current]++
  • 并发控制 :你的代码用了 ConcurrentHashMap,保证多线程写不崩;虽然 arr[current]++ 不是原子的(会丢少量数据),但在统计热度场景下,丢失 1% 的计数完全可以接受,换来的是极致的性能。

第三步:怎么动?(时间轮转)

  • 关键点 :使用 @Scheduled 定时任务。
  • 每隔 10 秒,把指针往后移一格:index = (index + 1) % 6
  • 核心动作 :移过去之后,必须把新格子里的旧数据清零(因为那是 60 秒前的数据了)。这就是"滑动窗口"滑动的本质。

第四步:怎么算?(读取逻辑)

  • 把数组里所有数字加起来,就是过去 60 秒的总访问量。

替代方案(业界标准)

你的这个实现是轻量级、单机版的优秀实现,适合中等规模系统。但在业界,根据规模不同,有以下替代方案:

  1. Caffeine (内置能力)
    1. 如果你只是想做本地缓存,Caffeine 内部使用的是 W-TinyLFU 算法。它不需要你手动探测,它内部会自动识别热点,热的数据几乎永远不会被剔除,冷的自动淘汰。
    2. 适用场景:纯本地缓存。
  1. 京东 JD-HotKey (重量级)
    1. 这是一个专门的开源中间件。它有专门的 Server 端。
    2. 它能探测 集群热点。比如 Key 在机器 A 访问 5 次,在机器 B 访问 5 次,单机看都不热,但 JD-HotKey 能汇总发现它其实很热(10次)。
    3. 适用场景:超大规模集群,需要精确控制 Redis 热 key 的场景。
  1. 阿里 Sentinel
    1. Sentinel 不仅做限流熔断,也有热点参数限流的功能。原理和你的代码非常像(也是滑动窗口),但它主要用于"限流"(超过阈值直接拒绝),而不是"延长 TTL"。

HotKeyDetector 是一个非常标准的工业级轻量实现


  • 优点
    • 无锁设计 :使用数组 + AtomicInteger 指针,读写非常快。
    • 内存 友好 :只存 int 数组,且通过 current 自动复用空间,不会无限增长。
    • 容错性computeIfAbsent 和构造函数里的除数检查,体现了代码的健壮性。
  • 微小瑕疵
    • arr[current.get()]++ 并不是线程安全的(非原子操作)。但在热点探测场景,为了性能牺牲一点点准确度是完全正确的设计决策(Approximate Counting)。

一句话总结: 这个类是保护数据库的防弹衣,通过识别"谁是红人",让"红人"在缓存里待久一点。

复制代码
package com.xiaoce.zhiguang.cache.hotkey;

import com.xiaoce.zhiguang.cache.config.CacheProperties;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * HotKeyDetector
 * <p>
 * 热键探测器:工业级的高并发热点识别组件
 * @author 小策
 * @date 2026/2/7 10:52
 */
@Component
public class HotKeyDetector {
        // 定义热度等级:就像交通灯,绿色(NONE)到红色(HIGH)
        public enum Level { NONE, LOW, MEDIUM, HIGH }

        private final CacheProperties properties;

        /** * 核心存储:String(Key) -> int[](每个时间段的访问量)
         * 使用 ConcurrentHashMap 是为了保证在成千上万个线程同时访问时,内存数据不会崩坏
         */
        private final Map<String, int[]> counters = new ConcurrentHashMap<>();

        /** * 当前"指针":指向现在正在计数的那个时间格
         * AtomicInteger 保证了在多线程切换格子时,不会出现"两个人都以为自己在改格子 1"的冲突
         */
        private final AtomicInteger current = new AtomicInteger(0);

        /** 总共有多少个时间格(比如:观察 60 秒,每 10 秒一格,就是 6 格) */
        private final int segments;

        public HotKeyDetector(CacheProperties properties) {
            this.properties = properties;
            int segSeconds = properties.getHotkey().getSegmentSeconds();
            int winSeconds = properties.getHotkey().getWindowSeconds();
            // 健壮性设计:防止除数为 0 导致的程序崩溃
            this.segments = Math.max(1, winSeconds / Math.max(1, segSeconds));
        }

        /**
         * 记录访问:每当有人访问一次这个 Key,就调用一次这个方法
         */
        public void record(String key) {
            // computeIfAbsent:如果这个 Key 是第一次出现,就给它初始化一个长度为 segments 的数组
            int[] arr = counters.computeIfAbsent(key, k -> new int[segments]);
            // 对应当前时间格子的计数 +1(注意:这里在高并发下会有微小的计数偏差,但在热点探测场景下,性能比绝对准确更重要)
            arr[current.get()]++;
        }

        /**
         * 计算热度:把所有时间格子的计数加起来,就是最近一段时间的总访问量
         */
        public int heat(String key) {
            int[] arr = counters.get(key);
            if (arr == null) return 0;
            int sum = 0;
            for (int v : arr) {
                sum += v;
            }
            return sum;
        }

        /**
         * 判定等级:根据总热度对照配置中的阈值,判断它属于哪个级别
         */
        public Level level(String key) {
            int h = heat(key);
            // 从高到底判断,体现了"保护优先"的思想
            if (h >= properties.getHotkey().getLevelHigh()) return Level.HIGH;
            if (h >= properties.getHotkey().getLevelMedium()) return Level.MEDIUM;
            if (h >= properties.getHotkey().getLevelLow()) return Level.LOW;
            return Level.NONE;
        }

        /**
         * 核心价值:动态延长热点数据的生命(TTL)
         * 逻辑:如果一个数据很热,我们就让它在缓存里多待一会儿,减少查数据库的次数
         */
        public int ttlForPublic(int baseTtlSeconds, String key) {
            Level l = level(key);
            return baseTtlSeconds + extendSeconds(l);
        }

    /**
     * 计算"我的发布"页面的动态 TTL:基准 TTL + 等级扩展秒数。
     * @param baseTtlSeconds 基准 TTL 秒数
     * @param key 缓存键
     * @return 动态 TTL 秒数
     */
    public int ttlForMine(int baseTtlSeconds, String key) {
        Level l = level(key);
        return baseTtlSeconds + extendSeconds(l);
    }

        /**
         * 计算需要额外奖励的"寿命"(秒)
         */
        private int extendSeconds(Level l) {
            return switch (l) {
                case HIGH -> properties.getHotkey().getExtendHighSeconds();
                case MEDIUM -> properties.getHotkey().getExtendMediumSeconds();
                case LOW -> properties.getHotkey().getExtendLowSeconds();
                default -> 0;
            };
        }

        /**
         * 关键定时任务:每隔一段时间(如 10 秒),就把指针指向下一个格
         * 并把下一个格子的旧数据清零(因为它已经过时了)
         */
        @Scheduled(fixedRateString = "${cache.hotkey.segment-seconds:10}000")
        public void rotate() {
            //  计算下一个格子的位置
            // 假设数组长度 segments = 10,当前是 0
            // 下一步变成 1,... 到 9 之后,(9+1)%10 = 0,回到原点
            int next = (current.get() + 1) % segments;
            current.set(next); // 移动指针
            for (int[] arr : counters.values()) {
                // 因为这是一个环,即将转过去的那个格子可能存着 10 秒前的旧数据,必须清零,准备迎接新的统计
                arr[next] = 0; // 清理即将使用的"新格"中的"老数据"
            }
        }

        /**
         * 手动清理:比如某个商品下架了,或者配置变了,需要手动重置热度
         */
        public void reset(String key) {
            int[] arr = counters.get(key);
            if (arr != null) Arrays.fill(arr, 0);
        }
    }

3 Feed缓存服务的实现

这个类的作用是什么?

它的核心职责是处理 "缓存失效" (Cache Invalidation),具体做两件事:

  1. 清理公共流缓存 :当运营置顶文章、或者系统需要全站刷新时,把 feed:public:* 开头的缓存全删掉。
  2. 清理个人流缓存 :当用户张三发了新文章,把他自己的 feed:mine:张三:* 缓存删掉,让他自己能立马看到新发的帖。

但这只是表面,它真正的核心价值在于使用了 "延时双删" (Delayed Double Deletion) 策略。


为什么要写这个?(为什么要搞这么复杂的双删?)

你可能会问:"数据变了,我直接 redis.delete(key)**删一次不就行了吗?为什么要删两次,中间还要等几秒?"

这是为了解决高并发下的 "脏数据回填" 问题。

如果只删一次(普通模式)会出什么事?

想象这个场景:

  1. 线程 A(写请求) :准备改数据库。它先把缓存删了(delete)。
  2. 线程 B(读请求) :紧接着进来了,发现缓存是空的。于是它去查数据库
    1. 关键点 :此时线程 A 还没来得及把新数据写入数据库!
    2. 所以线程 B 查到的是 旧数据
  1. 线程 B :把这个旧数据写入了缓存。
  2. 线程 A :终于把新数据写入了数据库。

后果:数据库是新的,但缓存里却是线程 B 写进去的旧数据(脏数据)。在缓存过期前,所有用户看到的都是错的。

为什么"延时双删"能解决?

这个类的逻辑是:

  1. 先删deleteAllFeedCache() ------ 既然要改数据,先清场。
  2. 再删(延时)delayQueueUtil.addDelayMessage(...) ------ 告诉系统:"500毫秒后再来删一次"。

效果 : 即使发生了上面的"脏数据回填",500毫秒后,你的"第二次删除"任务启动,会把线程 B 刚才写入的那个脏缓存再砍一刀

这样,下一个用户 C 再来查,缓存又是空的了,就会去数据库查到最新的数据。


如果要自己写,怎么写出来?

如果你要从零实现这个类,可以分三步走:

第一步:写"删除逻辑"

你需要一个方法能找到并删除特定的 key。

  • 初级写法(你代码里的) :使用 keys("pattern*")
    • 缺点:性能差,会阻塞 Redis,生产环境禁用。
  • 高级写法 :使用 scan 命令或者维护一个 Set 集合索引(即我们在之前对话中优化的 feed:public:pages)。

第二步:引入"延时机制"

你需要一个能"过一会儿再执行"的工具。

  • 方案 A(最简陋) :开个新线程 Thread.sleep(500) 然后执行删除。
    • 缺点:浪费线程资源,不可靠。
  • 方案 B( MQ 消息队列 :发送一条 RocketMQ/RabbitMQ 的延时消息。
    • 缺点:依赖太重,还得部署 MQ。
  • 方案 C(Redis 延时队列,你的代码用的)
    • 利用 Redis 的 ZSet(有序集合)。
    • Score 存执行时间戳,Value 存任务内容。
    • 后台起个线程轮询 ZSet,时间到了就拿出来执行。

第三步:组装业务

把上面两步拼起来:


其中代码中的隐患与优化(必看,这里就不做了,实际开发建议维护索引)

你代码里有一段非常危险的注释和实现,我必须再次强调:

为什么不能用? KEYS 命令是 O(N) 复杂度的。如果你的 Redis 里有 1000 万个 key,执行这条命令可能需要几秒钟。在这几秒钟里,Redis 是单线程 的,它在全力找 key,导致其他所有请求(比如用户登录、支付)全部卡住超时。这叫 "Redis 阻塞",是严重的生产事故。

替代方案(优化后的写法):

  1. 维护索引(推荐) : 在写入缓存时,把生成的 key 记在一个 Set 里,比如 feed:public:index。 删除时,直接 SMEMBERS feed:public:index 拿到所有 key,然后删除。这是 O(1) 的,极快。
  2. 使用 SCAN 命令 : 如果没维护索引,必须用 scan 命令代替 keysscan 是分批扫描,不会卡死线程。

3.1 定义延迟删除事件

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

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.util.UUID;

/**
 * CacheDoubleDeleteEvent
 * <p>
 * 缓存延时双删事件
 *
 * @author 小策
 * @date 2026/2/6 20:59
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CacheDoubleDeleteEvent implements Serializable {
    /**
     * 缓存类型
     * "PUBLIC" - 代表公共缓存(feed:public:*)
     * "MINE"   - 代表个人缓存(feed:mine:userId:*)
     */
    private String type;

    /**
     * 用户ID
     * 如果 type 是 "PUBLIC",这里传 null
     * 如果 type 是 "MINE",这里传具体的 userId
     */
    private Long userId;

    /**
     * 唯一流水号
     * 作用:防止 Redis ZSet 将内容相同的消息去重(吞消息)。
     * 默认值:直接生成一个随机 UUID,不用业务层操心。
     */
    private String eventId = UUID.randomUUID().toString();

    public CacheDoubleDeleteEvent(String type, Long userId) {
        this.type = type;
        this.userId = userId;
    }
}

3.2 定义延迟删除工具类

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

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

/**
 * RedisDelayQueueUtil
 * <p>
 * TODO: 请在此处简要描述类的功能
 *
 * @author 小策
 * @date 2026/2/6 21:07
 */
@RequiredArgsConstructor
@Component
@Slf4j
public class RedisDelayQueueUtil {
    private final StringRedisTemplate redis;
    private final ObjectMapper objectMapper;
    private static final String DELAY_QUEUE_KEY = "cache:delay:queue";
    /**
     * 添加延时消息到 Redis
     * @param event 消息对象
     * @param delayMillis 延迟多少毫秒
     */
    public void addDelayMessage(CacheDoubleDeleteEvent event, long delayMillis) {
        try {
        // 将消息对象转换为 JSON 字符串
            String json = objectMapper.writeValueAsString(event);
            //计算延迟时间
            long expireTime = System.currentTimeMillis() + delayMillis;
            redis.opsForZSet().add(DELAY_QUEUE_KEY, json, expireTime);
            log.info("延时消息已入列 Redis,将在 {} ms 后投递", delayMillis);
        } catch (JsonProcessingException e) {
            log.error("延时消息入列失败", e);
        }
    }


}

3.2 定义延迟删除定时扫描器

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

import com.xiaoce.zhiguang.common.constant.Kafka.KafkaTopic;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * DelayMessageScheduler
 * <p>
 * 异步双删的定时任务
 *
 * @author 小策
 * @date 2026/2/6 21:21
 */
@Component
@RequiredArgsConstructor
@Slf4j
public class DelayMessageScheduler {
    private final StringRedisTemplate redis;
    private final KafkaTemplate<String, String> kafkaTemplate;
    private static final String DELAY_QUEUE_KEY = "cache:delay:queue";
    private static final String LOCK_KEY = "lock:scheduler:delay_msg";
    private final RedissonClient redissonClient;

    // 定义 Lua 脚本对象
    private DefaultRedisScript<List> luaScript;

    /**
     * 初始化 Lua 脚本
     * 修改点:删除了 redis.call('TIME'),改为从 ARGV[2] 获取时间
     */
    @PostConstruct
    public void initLua() {
        luaScript = new DefaultRedisScript<>();
        luaScript.setResultType(List.class);
        luaScript.setScriptText(
                "local queueKey = KEYS[1]; " +
                        "local limit = tonumber(ARGV[1]); " +  // 参数1:取多少条 (20)
                        "local now = tonumber(ARGV[2]); " +    // 参数2:当前时间戳 (Java传进来的)
                        // 1. 获取分数小于等于 now 的消息
                        "local messages = redis.call('ZRANGEBYSCORE', queueKey, 0, now, 'LIMIT', 0, limit); " +
                        "if #messages > 0 then " +
                        // 2. 如果有消息,原子性地移除它们 (防止多线程重复消费)
                        "    redis.call('ZREM', queueKey, unpack(messages)); " +
                        "    return messages; " +
                        "else " +
                        "    return {}; " +
                        "end"
        );
    }

    /**
     * 每 500 毫秒执行一次搬运
     */
    @Scheduled(fixedDelay = 500)
    public void sendDelayMessage() {
        RLock lock = redissonClient.getLock(LOCK_KEY);
        try {
        // 1. 获取分布式锁 (简单实现,防止多实例并发执行)
        // 参数1 (waitTime): 0 => 拿不到锁立刻返回,不等待(因为这是定时任务,这台机器没抢到,下一次或者别的机器会抢,不用阻塞等)
        // 参数2 (leaseTime): 5秒 => 锁的自动过期时间。如果 5秒 还没执行完,锁自动释放,防止死锁。
        // 注意:如果 leaseTime 设置为 -1,Redisson 会开启"看门狗"自动续期。这里任务简单,设个固定值更省事。
        boolean isLocked = lock.tryLock(0, 5, TimeUnit.SECONDS);
        if (!isLocked) {
            return;
        }


            // 2. 准备参数
            long now = System.currentTimeMillis();

            // 3. 执行 Lua 脚本
            // 参数说明:
            // KEYS[1]: DELAY_QUEUE_KEY
            // ARGV[1]: "20" (limit)
            // ARGV[2]: String.valueOf(now) (当前时间戳)
            List<String> messages = redis.execute(
                    luaScript,
                    Collections.singletonList(DELAY_QUEUE_KEY),
                    "20",
                    String.valueOf(now) // 把时间从这里传进去!
            );

            // 4. 处理结果
            if (messages == null || messages.isEmpty()) {
                return;
            }

            // 5. 发送 Kafka
            for (String message : messages) {
                try {
                    kafkaTemplate.send(KafkaTopic.DELAY_MESSAGE, message);
                    log.info("延迟消息投递成功: {}", message);
                } catch (Exception e) {
                    log.error("Kafka发送失败, message={}", message, e);
                    // 注意:这里如果发送失败,消息已经从Redis删了。
                    // 严格场景下需要做"死信队列"或"回滚重新入队"逻辑。
                }
            }
        } catch (Exception e) {
            log.error("定时任务执行异常", e);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
    //TODO 这里建议加一个重试机制,kafka出现问题了,能够补救,晚点有时间实现


}

3.3 定义延迟删除消费者

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

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xiaoce.zhiguang.common.constant.Kafka.KafkaTopic;
import com.xiaoce.zhiguang.knowpost.service.impl.FeedCacheServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

/**
 * CacheDeleteConsumer
 * <p>
 * 异步延迟双删的消费者
 *
 * @author 小策
 * @date 2026/2/6 22:03
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class CacheDeleteConsumer {

    private final FeedCacheServiceImpl feedCacheService;
    private final ObjectMapper objectMapper;


    @KafkaListener(topics = KafkaTopic.DELAY_MESSAGE , groupId = "delay-delete-cache")
    public void onMessage(String message){
        log.info("收到延时消息,开始删除缓存");
        try {
            CacheDoubleDeleteEvent event = objectMapper.readValue(message, CacheDoubleDeleteEvent.class);
            //  判断类型,执行"回马枪"(第二次删除)
            if ("PUBLIC".equals(event.getType())) {
                // 调用 Service 里的纯删除方法
                feedCacheService.deleteAllFeedCache();
                log.info("【双删完成】全站公共缓存已清理");

            } else if ("MINE".equals(event.getType())) {
                if (event.getUserId() != null) {
                    // 调用 Service 里的纯删除方法
                    feedCacheService.deleteMyFeedCache(event.getUserId());
                    log.info("【双删完成】用户 {} 缓存已清理", event.getUserId());
                }
            }
        } catch (JsonProcessingException e) {
            log.info("延迟删除失败:" + e);
        }
    }
}

这里简单的说一下实现逻辑:因为kafka不支持发送延迟消息。因此这里利用redis的zet来简单延迟消息的实现。score就是时间戳,至于为什么选择zet,因为这个能排序,可以根据时间来进行排序,然后选出过期的,去往kafka发送延迟删除消息,然后通过kafka的消费者去实现延迟删除逻辑。

3.3 Feed服务实现类

复制代码
package com.xiaoce.zhiguang.knowpost.service.impl;

import com.xiaoce.zhiguang.common.utils.CollectionUtil;
import com.xiaoce.zhiguang.knowpost.event.CacheDoubleDeleteEvent;
import com.xiaoce.zhiguang.knowpost.event.RedisDelayQueueUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.util.Set;

/**
 * FeedCacheServiceImpl
 * <p>
 * Feed 流缓存一致性管理服务
 * 该类负责处理 Feed 流相关的缓存清理工作,核心通过"延时双删"策略
 * 保证在数据库更新期间,缓存数据与数据库数据的最终一致性。
 * </p>
 * @author 小策
 * @date 2026/2/6 19:54
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class FeedCacheServiceImpl {

    private final StringRedisTemplate redis;
    private final RedisDelayQueueUtil delayQueueUtil;

    /**
     * 删除所有的公共 Feed 流缓存(危险操作)
     * 场景:比如运营置顶了一篇新文章,需要让所有人立刻刷出来,就得清理公共缓存。
     */
    public void deleteAllFeedCache() {
        // redis.keys(*) 这个命令就像在几百万人的电话本里,一行一行去对名字。
        // 也就是 O(N) 的复杂度。
        // 如果你的缓存里有 100万个 key,执行这行代码时,Redis 就会"卡住"几秒钟。
        // Redis 是单线程的,它卡住了,其他所有请求(登录、下单)全部都会超时报错!
        // 生产环境建议使用 SCAN 命令替代,或者维护一个专门的 Set 集合来存这些 key。
        Set<String> keys = redis.keys("feed:public:*");

        // 【小白注释】:如果找到了 key,就删掉;没找到就啥也不干
        if (CollectionUtil.isNotEmpty(keys)){
            redis.delete(keys);
        }
    }

    /**
     * 公共缓存的【延时双删】策略
     * @param delayMillis 等待时间(毫秒)
     */
    public void doubleDeleteAll(Long delayMillis){
        // 1. 立刻删除旧缓存
        deleteAllFeedCache();
        // 2. 实现异步删除
        CacheDoubleDeleteEvent event = new CacheDoubleDeleteEvent("PUBLIC", null);
        // 扔进 Redis 延时队列,方法立刻返回,不阻塞!
        delayQueueUtil.addDelayMessage(event, delayMillis);
        log.info("已发起全站缓存异步双删任务,延迟: {}ms", delayMillis);

    }

    /**
     * 删除指定用户的个人 Feed 缓存
     * 场景:用户发布了文章,或者取关了某人,需要刷新他自己的列表。
     * @param userId 用户ID
     */
    public void deleteMyFeedCache(Long userId) {
        // 拼装 key,比如 feed:mine:10086:* // 这里同样使用了 keys 命令,虽然比全量扫描好一点(范围小了),但依然有性能风险。
        // 最好是把该用户的所有缓存 key 存在一个 Set 结构里(比如 key叫 user:10086:feed_keys),直接取出来删。
        Set<String> keys = redis.keys("feed:mine:" + userId + ":*");

        if (CollectionUtil.isNotEmpty(keys)){
            redis.delete(keys);
        }
    }

    /**
     * 个人缓存的【延时双删】策略
     * @param userId 用户ID
     * @param delayMillis 等待时间
     */
    public void doubleDeleteMyFeedCache(Long userId, Long delayMillis) {
        // 1. 先删一次
        deleteMyFeedCache(userId);
        // 2. 再异步删一次
        CacheDoubleDeleteEvent event = new CacheDoubleDeleteEvent("MINE", userId);
        delayQueueUtil.addDelayMessage(event, delayMillis);
        log.info("已发起用户 {} 缓存异步双删任务,延迟: {}ms", userId, delayMillis);
    }

}

4 AI模块部分功能实现

因为知文发布需要往向量库中建立索引等等,因此这里先实现部分AI功能,保证运行时不出错

4.1 大模型的配置类

这里使用@Qualifier注解,是因为 Spring AI 能扫描到了两个可用的 ChatModel(聊天模型)Bean:

  1. deepSeekChatModel:来自 DeepSeek 的自动配置。

  2. openAiChatModel:来自 OpenAI 的自动配置。

    package com.xiaoce.zhiguang.llm.config;

    import org.springframework.ai.chat.client.ChatClient;
    import org.springframework.ai.chat.model.ChatModel;
    import org.springframework.beans.factory.annotation.Qualifier;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;

    /**

    • LlmConfig

    • 该类用于配置大语言模型相关的Bean,提供ChatClient实例

    • @author 小策

    • @date 2026/2/8 15:35
      */
      @Configuration
      public class LlmConfig {

      /**

      • 创建并配置ChatClient Bean
      • @param chatModel 通过@Qualifier注解指定使用名为"deepseekChatModel"的ChatModel实例
      • @return 返回一个配置好的ChatClient实例,用于与语言模型进行交互
        */
        @Bean // 将此方法的返回值注册为一个Spring Bean
        public ChatClient chatClient(@Qualifier("deepSeekChatModel")ChatModel chatModel){
        // 使用ChatModel构建器创建ChatClient实例
        return ChatClient.builder(chatModel).build();
        }

    }

4.2 Rag索引构建服务

这个类 RagIndexService 是整个 RAG(检索增强生成)系统 的"搬运工"和"图书管理员"。

简单来说,它的工作就是把数据库里的文章 ,搬运到向量数据库( Elasticsearch **)**里去,并且在搬运过程中把文章切成小块(Chunk),以便 AI 能够更精准地搜索到。

为什么要实现这个类?(核心痛点)

我们平时用的 MySQL 数据库,擅长做精确匹配(比如 WHERE id=1)或者简单的模糊搜索(LIKE %Java%)。

但是,当用户问 AI:"怎么解决 内存 溢出?"时:

  • 你的文章里可能根本没有"解决"、"内存"、"溢出"这几个字连在一起。
  • 文章标题可能是:"JVM OOM 排查实战"。
  • MySQL 搜不到这篇文章。
  • 向量数据库能搜到。因为它算出了"内存溢出"和"OOM"意思很像。

所以,我们需要把文章从 MySQL 同步到向量数据库,让 AI 能够"理解"并检索到这些知识。 这就是这个类的作用。


这个类的具体作用

  1. 知识搬运:把业务数据(KnowPosts)变成 AI 可读的向量数据(Vector)。
  2. 切片(Chunking)
    1. 一篇文章太长了(比如 1 万字),直接塞给 AI,AI 会"消化不良"(超过 Token 限制),而且很贵。
    2. 这个类把文章切成 800 字一段的小块。搜的时候,只把最相关的那一段给 AI,既省钱又精准。
  1. 增量更新(幂等性)
    1. 它不会傻傻地每次都把所有文章重写一遍。
    2. 它会对比指纹( SHA256 。如果文章没改过,它就直接跳过。省资源、省时间。
  1. 数据清洗
    1. 只有"已发布"且"公开"的文章才会被索引。草稿和私密文章会被自动过滤,保护隐私。

实现思路是什么

第一步:选品( Query

  • 动作:去数据库里查文章。
  • 代码逻辑select * from posts where id = ?
  • 过滤 :检查 status == 'published'

第二步:验货(Check)

  • 动作:看看向量库里是不是已经有了?是不是最新的?
  • 代码逻辑
    • 拿数据库里的 SHA256
    • 去 Elasticsearch 里查一下这篇文章的 metadata.contentSha256
    • 如果一样,直接 return(下班)。

第三步:进货(Fetch)

  • 动作:文章的正文通常存在 OSS(对象存储)或者 URL 里,不在数据库字段里。
  • 代码逻辑 :用 RestTemplate 发个 HTTP GET 请求,把 Markdown 文本下载下来。

第四步:切菜(Chunking)

  • 动作:把一大块文本切成适合入口的小块。
  • 核心逻辑
    • 先按**标题(#)**切分,保证一个知识点尽量在一个块里。
    • 如果一段太长(超过 800 字),就强制截断。
    • 关键技巧 :切分时要留一点重叠(Overlap)。比如第一块是 0~800 字,第二块是 700~1500 字。这样防止把一句话切断了,AI 读不懂。

第五步:上架(Indexing)

  • 动作:先把旧的下架,再把新的上架。
  • 代码逻辑
    • 先调用 es.deleteByQuerymetadata.postId == 1001 的所有旧切片删掉(防止搜出两份)。
    • 把切好的新块封装成 Document 对象。
    • 调用 vectorStore.add(docs),Spring AI 会自动把文本转成向量(Embedding)并存入 ES。

这里再说一下**"验货"** 步骤中的比对中里面使用到的**"指纹"**

在计算机领域,指纹(Fingerprint) 并不是真的手指印,而是数据的唯一摘要身份证

通俗解释: 想象你写了一篇 1 万字的文章。怎么判断这篇文章今天有没有被修改过?

  • 笨办法:把昨天的 1 万字和今天的 1 万字,一个字一个字地对比。太慢了!
  • 指纹法 :用一种算法(比如 SHA-256)把这 1 万字压缩成一串很短的乱码(比如 a3f9...b2)。
    • 只要文章改了一个标点符号,这串乱码就会变得完全不一样。
    • 如果乱码没变,说明文章内容绝对没变。

在该代码中,指纹有两种:

    • SHA-256:一种哈希算法。它非常精准,用来代表文章内容的唯一性。
    • ETag:通常是对象存储(如 AWS S3、阿里云 OSS)给文件打的标签。文件一变,ETag 就变。

代码里的指纹就是这两个字符串: currentShacurrentEtag

复制代码
package com.xiaoce.zhiguang.llm.rag;

import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Hit;
import com.xiaoce.zhiguang.common.config.Es.EsProperties;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPosts;
import com.xiaoce.zhiguang.knowpost.mapper.KnowPostsMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;

import java.io.IOException;
import java.util.*;

/**
 * RagIndexService
 * <p>
 * RAG 索引构建服务:
 * - 将公开且已发布的知文切片并写入向量库
 * - 通过指纹(SHA256/ETag)判断是否需要重建,保证幂等
 * - 采用 delete-by-query 清理旧切片,再批量 upsert 新切片
 * 作用是:把数据库里的文章,"搬运"到向量数据库(Elasticsearch)里去。
 * 为什么要搬运?因为数据库只能精确搜索(比如 SQL WHERE title LIKE %...%)
 * 而向量数据库可以进行语义搜索(比如搜"如何优化Java性能",能搜出这篇文章,即使标题里没有这几个字)。
 * @author 小策
 * @date 2026/2/7 11:22
 */
@Service
@Slf4j
@RequiredArgsConstructor
public class RagIndexService {
    // 【核心组件1】Spring AI 提供的向量库接口,用来把处理好的文本存进去
    private final VectorStore vectorStore;
    // 【核心组件2】查数据库的 Mapper,用来查文章的标题、状态、下载地址
    private final KnowPostsMapper knowPostMapper;
    // 【核心组件3】HTTP 客户端,用来根据 URL 下载 Markdown 正文
    private final RestTemplate http = new RestTemplate();
    // 【核心组件4】Elasticsearch 原生客户端。因为 Spring AI 的 delete 功能可能不够灵活,
    // 这里直接用原生客户端做"按条件删除"和"查重"
    private final ElasticsearchClient es;
    // 配置文件,里面存了索引的名字(比如 "know_post_index")
    private final EsProperties esProps;

    public void ensureIndexed(long postId) {
        // 外部调用的入口。只传一个文章 ID。
        // 实际上直接调用了下面的核心方法。
        reindexSinglePost(postId);
    }
    public int reindexSinglePost(long postId) {
        //1. 查询数据库是否有该文章
        KnowPosts row = knowPostMapper.selectById(postId);
        // 2. 数据库中没有
        if (row == null) {
            log.warn("Post {} not found", postId);
            return 0;
        }
        // 3. 业务校验:只有"已发布"且"公开"的文章才允许被 AI 搜到
        // 如果是草稿或私密文章,直接跳过。
        if (!"published".equalsIgnoreCase(row.getStatus()) || !"public".equalsIgnoreCase(row.getVisible())) {
            log.warn("Post {} is not public/published, skip indexing", postId);
            return 0;
        }
        // 4. 校验下载地址:没有 contentUrl 说明文章没有正文文件,跳过
        if (!StringUtils.hasText(row.getContentUrl())) {
            log.warn("Post {} missing contentUrl or not found", postId);
            return 0;
        }
        // 5. 【幂等性检查】这是为了省钱省资源!
        // 获取当前数据库里的指纹(SHA256 哈希值)
        String currentSha = row.getContentSha256();
        String currentEtag = row.getContentEtag();
        if (isUpToDate(postId, currentSha, currentEtag)) {
            log.info("Post {} already indexed with same fingerprint, skip", postId);
            return 0;
        }
        // 6. 下载正文:根据 URL 把 Markdown 文本下载下来
        String text = fetchContent(row.getContentUrl());
        if (!StringUtils.hasText(text)) {
            log.warn("Post {} content empty", postId);
            return 0;
        }
        // 7. 【切片】把长文本切成小段(Chunk)。
        // 先按 Markdown 的标题(#)切,再按固定长度切。
        List<String> chunks = chunkMarkdown(text);

        // 8. 清理旧数据:在写入新版之前,先把向量库里这个 postId 对应的旧切片全删了。
        // 防止搜出两个版本的同一篇文章。
        deleteExistingChunks(postId);
        // 9. 组装 Document 对象
        // Document 是 Spring AI 定义的标准对象,包含"文本内容"和"元数据(Metadata)"
        List<Document> docs = new ArrayList<>(chunks.size());
        for (int i = 0; i < chunks.size(); i++) {
            // 给每个切片生成唯一的 ID,格式是 "文章ID#片段序号"
            String cid = postId + "#" + i;
            // 准备元数据,这些数据会和向量一起存进去
            Map<String, Object> meta = new HashMap<>();
            meta.put("postId", String.valueOf(postId)); // 以后可以按 postId 删除
            meta.put("chunkId", cid);
            meta.put("position", i); // 记录是第几段
            meta.put("contentEtag", currentEtag);
            meta.put("contentSha256", currentSha); // 存入指纹,下次用来做比对
            meta.put("contentUrl", row.getContentUrl());
            meta.put("title", row.getTitle()); // 存标题,搜出来时能显示标题
            docs.add(new Document(chunks.get(i), meta));
        }
        try {
            // 批量写入向量库
            vectorStore.add(docs);
        } catch (Exception e) {
            log.error("VectorStore add failed: {}", e.getMessage());
            return 0;
        }
        // 返回本次写入的切片数量
        return docs.size();
    }

/**
 * 删除指定帖子ID关联的所有现有数据块
 * @param postId 要删除数据块的帖子ID
 */
    private void deleteExistingChunks(long postId) {
        try {
            // 检查索引名称是否为空,如果为空则直接返回
            if (!StringUtils.hasText(esProps.getIndex())) return;
            // 使用Elasticsearch的deleteByQuery API删除匹配的数据
            es.deleteByQuery(d -> d
                    // 指定要操作的索引
                    .index(esProps.getIndex())
                    // 设置查询条件,匹配metadata.postId字段等于指定postId的文档
                    .query(q -> q.term(t -> t
                            .field("metadata.postId")
                            .value(v -> v.stringValue(String.valueOf(postId))))));
        } catch (Exception e) {
            // 记录删除操作失败的警告日志
            log.warn("Delete old chunks failed for post {}: {}", postId, e.getMessage());
        }
    }
    /**
    * 将Markdown文本分割为块
    * @param text 输入的Markdown文本
    * @return 分割后的文本块列表
    */
    private List<String> chunkMarkdown(String text) {
        // 创建一个列表用于存储分割后的段落
        List<String> paras = new ArrayList<>();
        // 按行分割输入文本
        String[] lines = text.split("\r?\n");
        // 使用StringBuffer构建当前段落
        StringBuffer buf = new StringBuffer();
        // 遍历每一行
        for (String line : lines) {
            // 检查当前行是否是标题行(以#开头)
            boolean isHeader = line.startsWith("#");
            // 如果是标题行且缓冲区不为空,说明遇到了新的标题,需要收束上一段
            if (isHeader && !buf.isEmpty()) { // 遇到新的标题,收束上一段
                // 将缓冲区内容添加到段落列表中
                paras.add(buf.toString());
                // 清空缓冲区
                buf.setLength(0);
            }
            // 将当前行添加到缓冲区,并添加换行符
            buf.append(line).append('\n');
        }
        // 处理最后一段内容(如果缓冲区不为空)
        if (!buf.isEmpty()) paras.add(buf.toString()); //收尾工作
        // 调用getChunks方法进一步处理段落
        return getChunks(paras);
    }

/**
 * 将段落列表分割成较小的文本块,确保每个块不超过800字符,并在必要时保留部分重叠以保持语义连续性
 * @param paras 原始段落列表
 * @return 分割后的文本块列表,每个块长度不超过800字符
 */
    private List<String> getChunks(List<String> paras) {
    // 创建一个新的列表用于存储分割后的文本块
        List<String> chunks = new ArrayList<>();
    // 遍历输入的每个段落
        for (String para : paras) {
        // 如果段落长度小于800字符,直接添加到结果列表中
            if (para.length() < 800) chunks.add(para);
            else {
            // 对于较长的段落,需要分割成多个小块
                int start = 0;
            // 使用循环将长段落分割成小块
                while (start < para.length()) {
                // 计算当前块的结束位置,最多800字符
                    int end = Math.min(start + 800, para.length());
                // 添加当前块到结果列表
                    chunks.add(para.substring(start, end));
                    if (end >= para.length()) break;
                    start = Math.max(end - 100, start + 1); // 重叠 100 字符以保留语义连续
                }
            }
        }
        return chunks;
    }

    /**
 * 检查指定帖子是否为最新版本,通过比较当前内容的SHA256或ETag与索引中的值
 * @param postId 要检查的帖子ID
 * @param currentSha 当前内容的SHA256值
 * @param currentEtag 当前内容的ETag值
 * @return 如果内容是最新的返回true,否则返回false
 */
    private boolean isUpToDate(long postId, String currentSha, String currentEtag) {
        try {
            // 如果没配索引名,没法查,默认当做需要更新
            if (!StringUtils.hasText(esProps.getIndex())){
                return false;
            }
        // 在Elasticsearch中搜索指定postId的文档
            SearchResponse<Map> resp = es.search(s -> s.index(esProps.getIndex()) // 指定去哪个索引查
                    .size(1) // 只要查到一条切片就行
                            .query(q -> q.term(t -> t
                                    .field("metadata.postId") // 搜索条件:metadata.postId == 传入的 postId
                                    .value(v -> v.stringValue(String.valueOf(postId))))),
                    Map.class);
            // 如果没查到,说明是新文章,返回 false (需要索引)
            List<Hit<Map>> hits = resp.hits().hits();
            if (hits == null || hits.isEmpty()) return false;
            Map source = hits.getFirst().source();
            if (source == null) return false;
        // 获取metadata字段并进行类型检查
            Object metaObj = source.get("metadata");
            if (!(metaObj instanceof Map<?, ?> meta)) return false;
        // 从索引的文档中提取SHA256和ETag值
            String indexedSha = asString(meta.get("contentSha256"));
            String indexedEtag = asString(meta.get("contentEtag"));
            // 【核心比对】
            // 如果向量库里的 SHA256 和 数据库里现在的 SHA256 一样,说明内容没变。
            if (StringUtils.hasText(currentSha) && StringUtils.hasText(indexedSha)) {
                return Objects.equals(currentSha, indexedSha);
            }
            if (StringUtils.hasText(currentEtag) && StringUtils.hasText(indexedEtag)) {
                return Objects.equals(currentEtag, indexedEtag);
            }
            return false;
        } catch (IOException e) {
            log.warn("Fingerprint check failed for post {}: {}", postId, e.getMessage());
            return false;
        }
        }
/**
 * 将对象转换为字符串的静态方法
 * 该方法统一处理了对象为null的情况,避免了NullPointerException
 *
 * @param o 需要转换为字符串的对象
 * @return 如果输入对象为null,则返回null;否则返回对象的字符串表示形式
 */
    private static String asString(Object o) {
        // 统一处理 null → String 的转换
        return o == null ? null : java.lang.String.valueOf(o);
    }
    /**
     * 该方法通过HTTP请求获取指定URL的文本内容
     * @param url 需要拉取内容的网页地址
     * @return 返回从指定URL获取的Markdown文本内容,如果发生异常则返回null
     */
    private String fetchContent(String url) {
        try {
        // 使用HTTP客户端发送GET请求并获取响应内容
            return http.getForObject(url, String.class);
        } catch (Exception e) {
        // 记录错误日志,包含异常信息
            log.error("Fetch content failed: {}", e.getMessage());
        // 发生异常时返回null
            return null;
        }
    }

}

4.3 知文摘要生成接口

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

/**
 * KnowPostDescriptionService
 * <p>
 * 这是一个服务接口,用于处理和生成帖子描述信息的相关功能
 * 该接口定义了生成描述信息的方法规范
 *
 * @author 小策
 * @date 2026/2/8 15:38
 */
public interface IKnowPostDescriptionService {

/**
 * 生成描述信息的方法
 * 根据输入的内容生成相应的描述信息
 *  该方法接收一个字符串参数,处理后返回描述信息
 * @param content 输入的内容字符串,用于生成描述
 * @return 返回生成的描述信息字符串
 */
    String generateDescription(String content);
}

4.4 知文摘要实现类

复制代码
package com.xiaoce.zhiguang.llm.service.impl;

import com.xiaoce.zhiguang.llm.service.IKnowPostDescriptionService;
import org.springframework.stereotype.Service;

/**
 * KnowPostDescriptionServiceImpl
 * <p>
 *
 * 该类是知识帖描述服务的实现类,用于处理知识帖描述相关的业务逻辑
 *
 * @author 小策
 * @date 2026/2/8 15:39
 */
@Service
public class KnowPostDescriptionServiceImpl implements IKnowPostDescriptionService {
    /**
     * 生成知识帖摘要的方法
     * @param content 原始内容字符串
     * @return 返回生成的描述字符串,当前实现为返回空字符串
     */
    @Override
    public String generateDescription(String content) {

        return "";
    }
}

这里不实现的问题,还是因为目前来说只保证运行不出错,后续在AI模块实现

5 知文业务实现

5.1 知文业务实现接口

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

import com.baomidou.mybatisplus.extension.service.IService;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPosts;
import com.xiaoce.zhiguang.knowpost.domain.vo.KnowPostDetailResponse;

import java.util.List;

/**
 * <p>
 * 知识库文章表 服务类
 * 知文(KnowPost)业务逻辑接口。
 * * 该接口负责处理文章从创建草稿、内容确认、元数据编辑到最终发布的全生命周期管理。
 * 采用了"先创建草稿,后异步/分步完善内容"的设计模式。
 *
 *
 * @author 小策
 * @since 2026-01-20
 */
public interface IKnowPostsService extends IService<KnowPosts> {
        /**
         * 创建一个文章草稿。
         * @param creatorId 创建者用户ID。
         * @return 返回新生成的文章唯一主键 ID。
         */
        long createDraft(long creatorId);

        /**
         * 确认并关联文章的实际内容文件。
         * 通常用于文件上传到对象存储(如 OSS/S3)后的回调或确认环节,将文件元数据与文章记录绑定。
         * @param creatorId 操作者ID(校验权限)。
         * @param id        文章ID。
         * @param objectKey 文件在对象存储中的路径或键值。
         * @param etag      文件的 ETag(通常为文件 MD5 摘要,用于一致性校验)。
         * @param size      文件大小(单位:字节)。
         * @param sha256    文件的 SHA256 校验码,确保内容完整安全性。
         */
        void confirmContent(long creatorId, long id, String objectKey, String etag, Long size, String sha256);

        /**
         * 更新文章的元数据信息。
         * @param creatorId   操作者ID。
         * @param id          文章ID。
         * @param title       文章标题。
         * @param tagId       主标签/分类ID。
         * @param tags        副标签列表(字符串集合)。
         * @param imgUrls     文章配图或插图的 URL 列表。
         * @param visible     可见性状态(如:PUBLIC-公开, PRIVATE-私密, FRIEND-好友可见)。
         * @param isTop       是否置顶。
         * @param description 文章摘要或简介。
         */
        void updateMetadata(long creatorId, long id, String title, Long tagId, List<String> tags, List<String> imgUrls, String visible, Boolean isTop, String description);

        /**
         * 正式发布文章。
         * 将文章状态从"草稿"或其他中间状态变更为"已发布",使其对目标受众可见。
         * @param creatorId 操作者ID。
         * @param id        文章ID。
         */
        void publish(long creatorId, long id);

        /**
         * 更新文章的置顶状态。
         * @param creatorId 操作者ID。
         * @param id        文章ID。
         * @param isTop     true 表示置顶,false 表示取消置顶。
         */
        void updateTop(long creatorId, long id, boolean isTop);

        /**
         * 更新文章的可见性权限。
         * @param creatorId 操作者ID。
         * @param id        文章ID。
         * @param visible   可见性标识字符串。
         */
        void updateVisibility(long creatorId, long id, String visible);

        /**
         * 删除文章。
         * @param creatorId 操作者ID(通常需校验是否为作者或管理员)。
         * @param id        文章ID。
         */
        void delete(long creatorId, long id);

        /**
         * 获取文章详情。
         * @param id                  文章ID。
         * @param currentUserIdNullable 当前访问者的用户ID。如果为 null,表示匿名访问。
         * 用于判断当前用户是否有点赞、收藏该文章,或是否有权查看私密内容。
         * @return 包含文章全量信息的 DTO 响应对象。
         */
        KnowPostDetailResponse getDetail(long id, Long currentUserIdNullable);

}

5.2 实现知文业务实现类

5.2.1 定义延迟双删AOP切面

5.2.1.1 添加AOP依赖
复制代码
<dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
</dependencies>
5.2.1.2 定延迟双删注解
复制代码
package com.xiaoce.zhiguang.knowpost.annotation;

import java.lang.annotation.*;

/**
 * DelayedDoubleDelete 注解类
 * <p>

 * 该注解用于实现延迟双删机制,常用于缓存与数据库同步的场景
 * 1.先写延迟双删的注解,用于标记需要进行延迟双删的方法
 *
 * @author 小策
 * @date 2026/2/7 22:29
 */
@Target({ElementType.METHOD}) // 注解可以应用于方法上
@Retention(RetentionPolicy.RUNTIME) // 注解在运行时保留
@Documented // 注解会被包含在JavaDoc中
public @interface DelayedDoubleDelete {
    /**
     * 缓存 id 的 SpEL 表达式
     * 必须动态解析,因为每个文章 ID 不一样
     */
    String id();

    /**
     * 延迟时间(毫秒),默认 500ms
     */
    long delay() default 500;
}
5.2.1.3 定义切面拦截器
复制代码
package com.xiaoce.zhiguang.knowpost.annotation;

import com.github.benmanes.caffeine.cache.Cache;
import com.xiaoce.zhiguang.knowpost.domain.vo.KnowPostDetailResponse;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.expression.MethodBasedEvaluationContext;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.lang.reflect.Method;
import java.util.concurrent.Executor;

/**
 * CacheDelayDeleteAspect
 * <p>
 * 延迟双删切面:缓存一致性的"守门员"。
 * 作用:拦截所有加了 @DelayedDoubleDelete 注解的方法,在事务提交后,自动异步延迟删除缓存。
 *
 * @author 小策
 * @date 2026/2/7 22:30
 */
@Aspect // 标记这是一个切面类(拦截器)
@Component // 交给 Spring 管理
@Slf4j
public class CacheDelayDeleteAspect {

    @Resource(name = "stringRedisTemplate")
    private StringRedisTemplate redisTemplate;

    // 注入本地缓存 Caffeine,为了实现多级缓存的一致性清理
    @Resource
    @Qualifier("knowPostDetailCache")
    private Cache<String, KnowPostDetailResponse> localCache;

    // 注入我们之前配置好的自定义线程池
    // 作用:执行 sleep 和 delete 操作,避免阻塞主业务线程
    @Resource(name = "taskExecutor")
    private Executor taskExecutor;

    // SpEL 解析器:用于解析注解里的 "#id" 这种占位符
    private final ExpressionParser parser = new SpelExpressionParser();
    // 参数发现器:用于获取方法参数的名字
    private final ParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer();

    // 缓存的版本号(注意:如果 key 规则变了,这里要同步修改)
    private static final int DETAIL_LAYOUT_VER = 1;

    /**
     * 核心拦截逻辑
     * @param point 切点(原本要执行的方法)
     * @param delayedDelete 注解对象(能拿到注解里配的参数,如 delay 时间)
     */
    @Around("@annotation(delayedDelete)")
    public Object around(ProceedingJoinPoint point, DelayedDoubleDelete delayedDelete) throws Throwable {
        // 无前置操作

        // 【执行核心业务】
        // 让 Service 里的 update/delete 方法先执行,拿到返回值
        // 如果这里抛异常,后面的代码不会执行,保证了"更新失败不删缓存"
        Object result = point.proceed();

        // 【后置操作】业务执行成功后,开始准备删缓存
        try {
            //解析 SpEL,获取具体的 ID 值(例如:把 "#dto.id" 解析成 "10086")
            Object id = parseId(delayedDelete.id(), point);

            // 拼装 Redis 和 Caffeine 里的 Key
            // 注意:这里的 Key 规则必须和 Service 里查询缓存的规则完全一致!
            String finalCacheKey = "knowpost:detail:" + id + ":v" + DETAIL_LAYOUT_VER;

            //判断当前是否在数据库事务中
            if (TransactionSynchronizationManager.isSynchronizationActive()) {
                // 情况 A:在事务中 -> 注册回调,等事务提交后再干活
                TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
                    @Override
                    public void afterCommit() {
                        // 事务提交成功,交给线程池去异步删除
                        doAsyncDelayDelete(finalCacheKey, delayedDelete.delay());
                    }
                });
            } else {
                // 情况 B:不在事务中 -> 直接交给线程池干活
                // 这种场景较少,通常出现在非事务的写操作中,或者事务配置失效时
                log.warn("当前环境无事务,直接执行延迟删除: {}", finalCacheKey);
                doAsyncDelayDelete(finalCacheKey, delayedDelete.delay());
            }
        } catch (Exception e) {
            // 切面的异常绝不能影响主业务!
            // 比如解析 ID 失败了,或者线程池满了,只记录日志,不能让用户看到"保存失败"
            log.error("AOP 延迟双删处理失败,请手动检查缓存一致性", e);
        }

        return result;
    }

    /**
     * 执行异步延迟删除的实际动作
     * @param cacheKey 要删除的 Key
     * @param delayTime 延迟多少毫秒
     */
    private void doAsyncDelayDelete(String cacheKey, long delayTime) {
        taskExecutor.execute(() -> {
            try {
                // 1. 睡眠(延迟),等待数据库主从同步完成
                if (delayTime > 0) {
                    Thread.sleep(delayTime);
                }
                log.info("AOP 延迟双删执行,清理 Key: {}", cacheKey);
                // 2. 删除 Redis 分布式缓存
                redisTemplate.delete(cacheKey);
                // 3. 删除 Caffeine 本地进程缓存
                localCache.invalidate(cacheKey);

            } catch (InterruptedException e) {
                // 恢复中断状态,规范写法
                Thread.currentThread().interrupt();
            } catch (Exception e) {
                log.error("AOP 异步删除任务异常", e);
            }
        });
    }

    /**
     * 解析 SpEL 表达式的工具方法
     * 作用:比如注解写的是 @DelayedDoubleDelete(key = "#post.id")
     * 这个方法负责把 "#post.id" 变成具体的数字 "123"
     */
    private Object parseId(String keySpel, ProceedingJoinPoint point) {
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        Object[] args = point.getArgs();

        // 创建上下文,把方法的参数名和参数值对应起来
        EvaluationContext context = new MethodBasedEvaluationContext(
                point.getTarget(), method, args, nameDiscoverer);
        // 解析表达式并取值
        return parser.parseExpression(keySpel).getValue(context);
    }
}

5.2.2 获取知文详细方法逻辑图

这里说一下,为了防止占用太多内存, redis中存的知文信息都是不详细的,比如说缺少点赞数,收藏数,用户点赞状态等等。因此还需要实现丰富从redis中取出来的数据的方法。同时取出来了,还需要根据热点等阶写回缓存。

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

5.2.3 知文业务实现类实现

复制代码
package com.xiaoce.zhiguang.knowpost.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.xiaoce.zhiguang.cache.hotkey.HotKeyDetector;
import com.xiaoce.zhiguang.common.exception.BusinessException;
import com.xiaoce.zhiguang.common.exception.ErrorCode;
import com.xiaoce.zhiguang.common.utils.BeanCopyUtils;
import com.xiaoce.zhiguang.common.utils.CollectionUtil;
import com.xiaoce.zhiguang.counter.service.ICounterService;
import com.xiaoce.zhiguang.counter.service.IUserCounterService;
import com.xiaoce.zhiguang.knowpost.annotation.DelayedDoubleDelete;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPostDetailRow;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPosts;
import com.xiaoce.zhiguang.knowpost.domain.vo.KnowPostDetailResponse;
import com.xiaoce.zhiguang.knowpost.id.SnowflakeIdGenerator;
import com.xiaoce.zhiguang.knowpost.mapper.KnowPostsMapper;
import com.xiaoce.zhiguang.knowpost.service.IKnowPostsService;
import com.xiaoce.zhiguang.llm.rag.RagIndexService;
import com.xiaoce.zhiguang.oss.config.OssProperties;
import com.xiaoce.zhiguang.user.domain.po.Users;
import com.xiaoce.zhiguang.user.service.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;

/**
 * <p>
 * 知识库文章表 服务实现类
 * </p>
 *
 * @author 小策
 * @since 2026-01-20
 */
@Slf4j
@Service
public class KnowPostsServiceImpl extends ServiceImpl<KnowPostsMapper, KnowPosts> implements IKnowPostsService {

    private final SnowflakeIdGenerator idGen;
    private final ObjectMapper objectMapper;
    private final OssProperties ossProperties;
    private final ICounterService counterService;
    private final IUserCounterService userCounterService;
    private final StringRedisTemplate redis;
    private final IUserService userService;
    @Qualifier("knowPostDetailCache")
    private final Cache<String, KnowPostDetailResponse> knowPostDetailCache;
    private final HotKeyDetector hotKey;
    private static final int DETAIL_LAYOUT_VER = 1;
    private final ConcurrentHashMap<String, Object> singleFlight = new ConcurrentHashMap<>();
    private final RagIndexService ragIndexService;


    public KnowPostsServiceImpl(SnowflakeIdGenerator idGen, ObjectMapper objectMapper,
                                OssProperties ossProperties, ICounterService counterService,
                                IUserCounterService userCounterService, StringRedisTemplate redis,
                                @Qualifier("knowPostDetailCache") Cache<String, KnowPostDetailResponse> knowPostDetailCache,
                                HotKeyDetector hotKey, RagIndexService ragIndexService,
                                IUserService userService) {

        this.idGen = idGen;
        this.objectMapper = objectMapper;
        this.ossProperties = ossProperties;
        this.counterService = counterService;
        this.userCounterService = userCounterService;
        this.redis = redis;
        this.knowPostDetailCache = knowPostDetailCache;
        this.hotKey = hotKey;
        this.ragIndexService = ragIndexService;
        this.userService = userService;
    }

    /**
     * 使指定ID的缓存失效
     *
     * @param id 要使缓存失效的文章ID
     */
    private void invalidateCache(long id) {
        // 构建缓存键,包含文章ID和布局版本号
        String pageKey = "knowpost:detail:" + id + ":v" + DETAIL_LAYOUT_VER;
        // 从Redis中删除缓存
        redis.delete(pageKey);
        // 从本地缓存中删除
        knowPostDetailCache.invalidate(pageKey);
    }

    /**
     * 根据对象键生成公开访问的URL
     *
     * @param objectKey 对象在OSS中的键
     * @return 完整的公开访问URL
     */
    private String publicUrl(String objectKey) {
        // 从配置中获取公开域名
        String publicDomain = ossProperties.getPublicDomain();
        // 如果配置中设置了公开域名,则使用公开域名拼接URL
        if (publicDomain != null && !publicDomain.isBlank()) {
            // 移除域名末尾的斜杠(如果有),然后与对象键拼接
            return publicDomain.replaceAll("/$", "") + "/" + objectKey;
        }
        // 如果未配置公开域名,则使用默认的endpoint格式拼接URL
        return "https://" + ossProperties.getBucket() + "." + ossProperties.getEndpoint() + "/" + objectKey;
    }

    /**
     * 将字符串列表转换为JSON字符串,如果列表为空则返回null
     *
     * @param tagList 需要转换为JSON的字符串列表
     * @return 转换后的JSON字符串,如果输入列表为空则返回null
     * @throws BusinessException 当JSON处理失败时抛出业务异常
     */
    private String toJsonOrNull(List<String> tagList) {
        // 检查列表是否为空,如果为空则直接返回null
        if (CollectionUtil.isEmpty(tagList)) {
            return null;
        }
        try {
            // 使用ObjectMapper将列表转换为JSON字符串
            return objectMapper.writeValueAsString(tagList);
        } catch (JsonProcessingException e) {
            // 捕获JSON处理异常,并抛出业务异常
            throw new BusinessException(ErrorCode.BAD_REQUEST, "JSON 处理失败");
        }
    }

    /**
     * 验证可见性参数是否有效
     *
     * @param visible 可见性参数字符串,可能为null
     * @return 如果可见性参数有效则返回true,否则返回false
     */
    private boolean isValidVisible(String visible) {
        // 检查输入参数是否为null
        if (visible == null) {
            return false;
        }
        // 使用switch表达式检查可见性参数是否为允许的值之一
        return switch (visible) {
            // 允许的可见性值:public, followers, school, private, unlisted
            case "public", "followers", "school", "private", "unlisted" -> true;
            // 其他所有值都视为无效
            default -> false;
        };
    }

    @Override    // 标记重写父类方法
    @Transactional // 标记事务注解,确保方法在事务中执行
    public long createDraft(long creatorId) {
        // 生成唯一ID
        long id = idGen.nextId();
        // 获取当前时间戳
        Instant now = Instant.now();
        // 构建知识帖子对象
        KnowPosts knowPosts = KnowPosts.builder()
                .id(id)                    // 设置帖子ID
                .creatorId(creatorId)      // 设置创建者ID
                .status("draft")           // 设置帖子状态为"草稿"        // 设置帖子类型为"图文"
                .type("image_text")         // 设置帖子可见性为"公开"
                .visible("public")              // 设置是否置顶为"否"
                .isTop(false)           // 设置创建时间为当前时间
                .createTime(now)           // 设置更新时间为当前时间
                .updateTime(now)
                // 保存帖子对象到数据库
                .build();
        // 返回新创建的帖子ID
        this.save(knowPosts);
        return id;
    }

    /**
     * 确认并关联文章的实际内容文件。
     * <p>
     * 场景:前端直接将文件上传到 OSS 后,拿到 objectKey 等元数据,调用此接口通知后端。
     * 后端负责:
     * 1. 校验并更新数据库记录。
     * 2. 清除旧缓存,防止用户看到旧内容。
     * 3. 异步触发 AI 索引,为后续的 RAG(检索增强生成)做准备。
     * </p>
     *
     * @param creatorId 操作者ID(用于权限校验,确保是作者本人)。
     * @param id        文章草稿ID。
     * @param objectKey 文件在 OSS 中的路径(唯一标识)。
     * @param etag      文件的 ETag(通常是 MD5),用于校验文件一致性。
     * @param size      文件大小(字节)。
     * @param sha256    文件的 SHA-256 哈希值,用于后续可能的去重或完整性校验。
     */
    @Override
    @Transactional(rollbackFor = Exception.class) // 使用事务注解,确保方法执行过程中出现异常时事务会回滚
    @DelayedDoubleDelete(id = "#id", delay = 500)
    public void confirmContent(long creatorId, long id, String objectKey, String etag, Long size, String sha256) {
        //延迟双删策略:第一次删除缓存是在事务提交前,第二次是在事务提交后延迟500ms
        // 这样可以确保数据库主从同步完成后再删除缓存,避免缓存不一致问题
        invalidateCache(id);
        boolean success = lambdaUpdate().eq(KnowPosts::getId, id)
                .eq(KnowPosts::getCreatorId, creatorId)
                .set(objectKey != null, KnowPosts::getContentObjectKey, objectKey)
                .set(etag != null, KnowPosts::getContentEtag, etag)
                .set(size != null, KnowPosts::getContentSize, size)
                .set(sha256 != null, KnowPosts::getContentSha256, sha256)
                .set(KnowPosts::getContentUrl, publicUrl(objectKey)) // 生成公开访问URL
                .set(KnowPosts::getUpdateTime, Instant.now()) // 设置更新时间为当前时间
                .update();
        if (!success) {
            // 如果更新失败,抛出业务异常
            throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限");
        }
        // 【核心机制:注册监听器】
        // 翻译:等下如果数据库事务"盖章生效"了,请帮我做下面的事
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                // 触发 RAG(检索增强生成)预索引
                // 这是一个"附加动作",不是核心业务,所以用 try-catch 包裹。
                // 目的:将文章内容发送给向量数据库或 LLM 服务,提前生成 Embeddings。
                // 好处:当文章正式发布时,无需等待索引时间,用户立刻就能搜到。
                try {
                    // ensureIndexed 内部应该是幂等的:如果已索引则跳过,未索引则创建。
                    ragIndexService.ensureIndexed(id);
                } catch (Exception e) {
                    // 7. 容错处理
                    // 如果 AI 服务挂了,只记录警告日志,不要阻断用户保存文章的主流程。
                    // 此时文章已保存成功,索引可以在后续通过定时任务补偿。
                    log.warn("Pre-index after content confirm failed, post {}: {}", id, e.getMessage());
                }
            }
        });
    }

    /**
     * 更新文章元数据
     *
     * @param creatorId   创建者ID
     * @param id          文章ID
     * @param title       文章标题
     * @param tagId       标签ID
     * @param tags        标签列表
     * @param imgUrls     图片URL列表
     * @param visible     可见性设置
     * @param isTop       是否置顶
     * @param description 文章描述
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    @DelayedDoubleDelete(id = "#id", delay = 500)
    public void updateMetadata(long creatorId, long id, String title, Long tagId, List<String> tags, List<String> imgUrls, String visible, Boolean isTop, String description) {
        // 使指定ID的缓存失效,确保获取最新数据
        invalidateCache(id);
        // 构建KnowPosts对象,设置更新后的文章元数据
        boolean success = lambdaUpdate().set(title != null, KnowPosts::getTitle, title)
                .set(tagId != null, KnowPosts::getTagId, tagId)
                .set(tags != null, KnowPosts::getTags, toJsonOrNull(tags))
                .set(imgUrls != null, KnowPosts::getImgUrls, toJsonOrNull(imgUrls))
                .set(visible != null, KnowPosts::getVisible, visible)
                .set(isTop != null, KnowPosts::getIsTop, isTop)
                .set(description != null, KnowPosts::getDescription, description)
                .set(KnowPosts::getUpdateTime, Instant.now()) // 更新更新时间
                .eq(KnowPosts::getId, id) // 确保更新的是指定ID的文章
                .eq(KnowPosts::getCreatorId, creatorId) // 确保操作者是文章作者
                .update();
        if (!success) {
            // 如果更新失败,抛出业务异常,提示草稿不存在或无权限
            throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限");
        }
    }

    /**
     * 发布文章的方法
     * 使用了事务管理和延迟双删除的自定义注解
     *
     * @param creatorId 创建者ID
     * @param id        文章ID
     */
    @Override
    @Transactional
    @DelayedDoubleDelete(id = "#id", delay = 500) // 自定义注解,用于延迟双删除,指定id参数和500毫秒延迟
    public void publish(long creatorId, long id) {
        invalidateCache(id); // 清除指定ID的缓存
        // 执行更新操作,如果更新失败则抛出业务异常
        boolean success = lambdaUpdate().eq(KnowPosts::getId, id)
                .eq(KnowPosts::getCreatorId, creatorId)
                .set(KnowPosts::getStatus, "published")
                .set(KnowPosts::getPublishTime, Instant.now())
                .set(KnowPosts::getUpdateTime, Instant.now())
                .update();
        if (!success) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限");
        }
        // 注册事务同步回调,在事务提交后执行
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() { // 重写afterCommit方法,在事务提交后执行
                try {
                    // 触发 RAG(检索增强生成)预索引
                    // 这是一个"附加动作",不是核心业务,所以用 try-catch 包裹。
                    // 目的:将文章内容发送给向量数据库或 LLM 服务,提前生成 Embeddings。
                    // 好处:当文章正式发布时,无需等待索引时间,用户立刻就能搜到。
                    ragIndexService.ensureIndexed(id);
                } catch (Exception e) {
                    log.warn("Pre-index after publish failed, post {}: {}", id, e.getMessage());
                }
            }
        });
    }


    /**
     * 更新帖子置顶状态的方法
     *
     * @param creatorId 创建者ID,用于验证权限
     * @param id        帖子ID,需要更新的目标帖子
     * @param isTop     是否置顶,true表示置顶,false表示取消置顶
     * @throws BusinessException 当草稿不存在或无权限时抛出业务异常
     */
    @Override
    @DelayedDoubleDelete(id = "#id", delay = 500)
    @Transactional
    public void updateTop(long creatorId, long id, boolean isTop) {
        invalidateCache(id); // 清除缓存,确保数据一致性
        boolean success = lambdaUpdate().eq(KnowPosts::getId, id)  // 设置更新条件:帖子ID匹配
                .eq(KnowPosts::getCreatorId, creatorId)  // 设置更新条件:创建者ID匹配,验证权限
                .set(KnowPosts::getIsTop, isTop)  // 设置更新字段:置顶状态
                .set(KnowPosts::getUpdateTime, Instant.now())  // 设置更新字段:更新时间
                .update();  // 执行更新操作
        if (!success) {  // 如果更新失败
            throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限");  // 抛出业务异常
        }
    }

    /**
     * 更新帖子的可见性
     * 该方法使用事务管理,并带有延迟双删除功能
     *
     * @param creatorId 发帖者ID,用于验证权限
     * @param id        要更新的帖子ID
     * @param visible   可见性值,必须是合法的可见性选项
     * @throws BusinessException 如果可见性值不合法或帖子不存在/无权限
     */
    @Override
    @Transactional
    @DelayedDoubleDelete(id = "#id", delay = 500)
    public void updateVisibility(long creatorId, long id, String visible) {
        // 验证可见性值是否合法
        if (!isValidVisible(visible)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "可见性取值非法");
        }
        // 使相关缓存失效
        invalidateCache(id);
        // 执行数据库更新操作,设置新的可见性和更新时间
        boolean success = lambdaUpdate().eq(KnowPosts::getId, id)
                .eq(KnowPosts::getCreatorId, creatorId)  // 确保只有发帖者能修改
                .set(KnowPosts::getVisible, visible)      // 设置新的可见性
                .set(KnowPosts::getUpdateTime, Instant.now())  // 更新修改时间
                .update();  // 执行更新操作
        // 检查更新是否成功
        if (!success) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限");
        }
    }

    /**
     * 删除指定ID的草稿,带有事务支持和延迟双删功能
     *
     * @param creatorId 创建者ID,用于权限验证
     * @param id        要删除的草稿ID
     * @throws BusinessException 当草稿不存在或无权限时抛出
     */
    @Override
    @Transactional
    @DelayedDoubleDelete(id = "#id", delay = 500)
    public void delete(long creatorId, long id) {
        invalidateCache(id);  // 首先使缓存失效
        // 使用Lambda更新器更新草稿状态
        boolean success = lambdaUpdate()
                .eq(KnowPosts::getId, id)  // 设置条件:草稿ID等于传入的id
                .eq(KnowPosts::getCreatorId, creatorId)  // 设置条件:创建者ID等于传入的creatorId
                .set(KnowPosts::getStatus, "deleted")  // 设置状态为"deleted"
                .set(KnowPosts::getUpdateTime, Instant.now())  // 更新修改时间为当前时间
                .update();  // 执行更新操作
        // 如果更新失败,抛出业务异常
        if (!success)
            throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限");

    }

    /**
     * 获取知文详情(含作者信息、图片列表)。
     * <p>
     * 流程:
     * 1. 尝试读取 Redis 缓存。
     * 2. 若缓存命中,直接返回(需叠加实时计数与用户状态)。
     * 3. 若缓存未命中,使用 SingleFlight 锁机制防止缓存击穿。
     * 4. 锁内再次检查缓存(双重检查)。
     * 5. 若仍未命中,回源查询数据库。
     * 6. 校验内容状态与访问权限。
     * 7. 组装数据并写入 Redis 缓存(带随机过期时间与热点自动延期)。
     * 8. 返回最终结果(叠加用户维度状态)。
     * </p>
     *
     * @param id                    知文 ID
     * @param currentUserIdNullable 当前用户 ID(可空,用于判断权限与点赞状态)
     * @return 知文详情响应
     */
    @Override
    @Transactional(readOnly = true)
    public KnowPostDetailResponse getDetail(long id, Long currentUserIdNullable) {
        // 1. 构造缓存 Key:knowpost:detail:{id}:v{version}
        String pageKey = "knowpost:detail:" + id + ":v" + DETAIL_LAYOUT_VER;
        KnowPostDetailResponse local = knowPostDetailCache.getIfPresent(pageKey);
        //如果本地缓存不为空
        if (local != null) {
            // 如果是热点数据,则延长缓存时间 异步记录热点
            CompletableFuture.runAsync(() -> recordHotKeyAndExtendTtl(id, pageKey));
            log.info("detail source=local key={}", pageKey);
            return enrichDetailResponse(local, currentUserIdNullable, true);
        }
        String cached = redis.opsForValue().get(pageKey);
        // 2. 第一次尝试处理缓存命中
        // 如果缓存中有数据(且不是 "NULL"),则解析并返回
        KnowPostDetailResponse resp = tryProcessCacheHit(cached, id, pageKey, currentUserIdNullable, "page");
        if (resp != null) {
            return resp;
        }
        // 3. 缓存未命中,进入 SingleFlight 模式
        // 对同一个 pageKey 加锁,防止高并发下大量请求同时打到数据库(缓存击穿/惊群效应)
        Object lock = singleFlight.computeIfAbsent(pageKey, k -> new Object());
        try {
            synchronized (lock) {
                // 4. 双重检查(Double Check)
                // 在获取锁后,再次检查缓存,因为在排队等待锁的过程中,前一个请求可能已经把数据写入缓存了
                String again = redis.opsForValue().get(pageKey);
                try {
                    resp = tryProcessCacheHit(again, id, pageKey, currentUserIdNullable, "page(after-flight)");
                } catch (BusinessException e) {
                    // 如果缓存中明确记录了 "NULL"(即内容不存在),则直接抛出异常,不再查库
                    singleFlight.remove(pageKey);
                    throw e;
                }
                if (resp != null) {
                    // 缓存已由其他线程填充,直接返回
                    singleFlight.remove(pageKey);
                    return resp;
                }
                // 5. 回源查询数据库
                KnowPosts post = this.getById(id);
                // 增加判空,防止 copy 时空指针,并处理缓存穿透
                if (post == null || "deleted".equals(post.getStatus())) {
                    // 缓存空对象,防止攻击者一直查不存在的 ID
                    redis.opsForValue().set(pageKey, "NULL",
                            java.time.Duration.ofSeconds(30 + ThreadLocalRandom.current().nextInt(31)));
                    throw new BusinessException(ErrorCode.BAD_REQUEST, "内容不存在");
                }
                KnowPostDetailRow row = BeanCopyUtils.copy(post, KnowPostDetailRow.class);
                //补充缺失的属性
                Users user = userService.findById(row.getCreatorId()).orElseThrow(()-> new BusinessException(ErrorCode.BAD_REQUEST, "作者不存在"));
                if (user != null ) {
                    row.setAuthorAvatar(user.getAvatar());
                    row.setAuthorNickname(user.getNickname());
                } else {
                    row.setAuthorNickname("未知用户"); // 兜底
                    row.setAuthorAvatar("");
                }
                //6.防止缓存穿透和缓存雪崩
                if (row == null || "deleted".equals(row.getStatus())) {
                    redis.opsForValue().set(pageKey, "NULL",
                            java.time.Duration.ofSeconds(30 + ThreadLocalRandom.current().nextInt(31)));
                    singleFlight.remove(pageKey);
                    throw new BusinessException(ErrorCode.BAD_REQUEST, "内容不存在");
                }
                // 7. 权限校验
                // 公开策略:状态为 published 且可见性为 public 的内容可直接访问
                // 私有策略:否则仅作者本人可见
                boolean isPublic = "published".equals(row.getStatus()) && "public".equals(row.getVisible());
                //当前用户等于创建该知文的用户
                boolean isOwner = currentUserIdNullable != null && row.getCreatorId() != null
                        && currentUserIdNullable.equals(row.getCreatorId());
                if (!isPublic && !isOwner) {
                    singleFlight.remove(pageKey);
                    throw new BusinessException(ErrorCode.BAD_REQUEST, "无权限查看");
                }
                // 8. 组装响应对象
                // 解析图片和标签 JSON
                List<String> images = parseStringArray(row.getImgUrls());
                List<String> tags = parseStringArray(row.getTags());
                Map<String, Long> counts = counterService.getCounts("knowpost", String.valueOf(row.getId()), List.of("like", "fav"));

                Long likeCount = counts.getOrDefault("like", 0L);
                Long favoriteCount = counts.getOrDefault("fav", 0L);
                resp = new KnowPostDetailResponse(
                        String.valueOf(row.getId()),
                        row.getTitle(),
                        row.getDescription(),
                        row.getContentUrl(),
                        images,
                        tags,
                        String.valueOf(row.getCreatorId()),
                        row.getAuthorAvatar(),
                        row.getAuthorNickname(),
                        row.getAuthorTagJson(),
                        likeCount,
                        favoriteCount,
                        null, // liked 状态暂时留空,由 enrich 填充
                        null, // faved 状态暂时留空,由 enrich 填充
                        row.getIsTop(),
                        row.getVisible(),
                        row.getType(),
                        row.getPublishTime()
                );
                //写入缓存
                try {
                    String json = objectMapper.writeValueAsString(resp);
                    int baseTtl = 60;
                    // 增加随机抖动(Jitter),防止大量缓存同时过期(雪崩)
                    int jitter = ThreadLocalRandom.current().nextInt(30);
                    // 根据热度检测结果动态调整 TTL,热点内容缓存时间更长
                    int target = hotKey.ttlForPublic(baseTtl, pageKey);
                    redis.opsForValue().set(pageKey, json, java.time.Duration.ofSeconds(Math.max(target, baseTtl + jitter)));
                    // L1 填充 写入本地缓存
                    knowPostDetailCache.put(pageKey, resp);
                } catch (Exception ignore) {}

                    // 10. 释放锁并返回最终结果
                    // 返回前调用 enrich 填充用户维度的 liked/faved 状态
                    singleFlight.remove(pageKey);
                    return enrichDetailResponse(resp, currentUserIdNullable, false);
                }
        } finally {
            // 释放锁
            // 无论上面是 return 了,还是 throw Exception 了,这里一定会执行!
            // 这行代码是防止内存泄漏的"救命稻草"。
            singleFlight.remove(pageKey);
        }
    }

/**
 * 解析JSON字符串为字符串列表
 * @param json 需要解析的JSON格式字符串
 * @return 解析后的字符串列表,如果输入为空或解析失败则返回空列表
 */
    private List<String> parseStringArray(String json) {
    // 检查输入是否为空或空白字符串
        if (json == null || json.isBlank()) return Collections.emptyList();
        try {
        // 使用ObjectMapper将JSON字符串解析为List<String>类型
           return objectMapper.readValue(json, new TypeReference<List<String>>() {});
        }catch (Exception e){
        // 发生异常时返回空列表
            return Collections.emptyList();
        }
    }

    /**
     * 尝试处理缓存命中逻辑。
     * @param cached Redis 中读取的缓存字符串
     * @param id 内容 ID
     * @param pageKey 页面缓存 Key
     * @param uid 当前用户 ID
     * @param sourceLog 日志来源标识
     * @return 若成功处理命中则返回响应对象,否则返回 null
     */

    private KnowPostDetailResponse tryProcessCacheHit(String cached, long id, String pageKey,
                                                      Long uid, String sourceLog) {
        // 1. 缓存为空,未命中
        if (cached == null) {
            return null;
        }
        // 2. 命中空值缓存(防止穿透)
        if ("NULL".equals(cached)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "内容不存在");
        }
        //3. 缓存命中
        try {
            KnowPostDetailResponse base = objectMapper.readValue(cached, KnowPostDetailResponse.class);
            // L1 填充
            knowPostDetailCache.put(pageKey, base);
            // 4. 记录热度并尝试续期
            // 如果该内容正在被高频访问,自动延长其缓存 TTL
            recordHotKeyAndExtendTtl(id, pageKey);
            log.info("detail source={} key={}", sourceLog, pageKey);
            // 5. 叠加实时数据(计数与用户状态)并返回
            return enrichDetailResponse(base, uid, true);
        }catch (Exception e){
            // 反序列化失败等异常情况,视为未命中,回源修复
            return null;
        }
    }

    /**
     * 丰富详情响应:叠加实时计数与用户状态。
     * @param base 基础响应对象(来自缓存或 DB)
     * @param uid 当前用户 ID
     * @param refreshCounts 是否需要从 CounterService 刷新计数(缓存命中时需要,DB 回源时不需要)
     * @return 叠加了最新状态的响应对象
     */

    private KnowPostDetailResponse enrichDetailResponse(KnowPostDetailResponse base, Long uid
            , boolean refreshCounts) {

        Long likeCount = base.likeCount();
        Long favCount = base.favoriteCount();
        // 1. 刷新计数(仅在走缓存时执行)
        // 因为缓存中的计数可能是旧的,权威计数在 CounterService (Redis SDS)
        if (refreshCounts){
            Map<String, Long> counts = counterService.getCounts("knowpost", base.id(), List.of("like", "fav"));
            likeCount = counts.getOrDefault("like", likeCount == null ? 0L : likeCount);
            favCount = counts.getOrDefault("fav", favCount == null ? 0L : favCount);
        }
        // 2. 获取用户维度的状态(是否已点赞/收藏)
        // 这部分数据是个性化的,不能存入公共缓存
        Boolean liked = uid != null && counterService.isLiked("knowpost", base.id(), uid);
        Boolean faved = uid != null && counterService.isFaved("knowpost", base.id(), uid);
        // 3. 构造新的 Record 对象返回
        return new KnowPostDetailResponse(
                base.id(),
                base.title(),
                base.description(),
                base.contentUrl(),
                base.images(),
                base.tags(),
                base.authorId(),
                base.authorAvatar(),
                base.authorNickname(),
                base.authorTagJson(),
                likeCount,
                favCount,
                liked,
                faved,
                base.isTop(),
                base.visible(),
                base.type(),
                base.publishTime()
        );
    }

    /**
     * 记录内容热度,并根据热度等级延长相关缓存的 TTL。
     * 延长的缓存包括:
     * 1. 详情页整页缓存 (knowpost:detail:{id})
     * 2. Feed 流内容片段缓存 (feed:item:{id})
     * 这样可以确保热点内容在 Feed 流中也不会轻易过期,避免 Feed 流回源。
     * @param id 内容 ID
     * @param detailPageKey 详情页缓存 Key
     */

    private void recordHotKeyAndExtendTtl(long id, String detailPageKey) {
        // 统一使用 knowpost:{id} 作为热度统计 Key
        String hotKeyId = "knowpost:" + id;
        hotKey.record(hotKeyId);
        int baseTtl = 60;
        int target = hotKey.ttlForPublic(baseTtl, hotKeyId);
        Long expire = redis.getExpire(detailPageKey);
        if (expire < target) {
            redis.expire(detailPageKey, java.time.Duration.ofSeconds(target));
        }
        // 2. 延长 Feed 流内容片段缓存
        String itemKey = "feed:item:" + id;
        Long itemTtl = redis.getExpire(itemKey);
        if (itemTtl < target) {
            redis.expire(itemKey, java.time.Duration.ofSeconds(target));
        }
    }
}

5.4 实现知文Feed业务类

5.4.1 实现知文Feed业务接口

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

import com.baomidou.mybatisplus.extension.service.IService;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPosts;
import com.xiaoce.zhiguang.knowpost.domain.vo.FeedPageResponse;

/**
 * IKnowPostFeedService
 * <p>
 * 该接口定义了获取动态信息的相关服务方法
 * 主要用于获取公开动态和用户发布的动态信息
 *
 * @author 小策
 * @date 2026/2/7 10:08
 */
public interface IKnowPostFeedService extends IService<KnowPosts> {
    /**
     * 获取公开动态信息
     *
     * @param page 当前页码
     * @param size 每页大小
     * @param currentUserIdNullable 当前登录用户的ID(可为null)
     * @return 返回分页的动态信息响应对象
     */
    FeedPageResponse getPublicFeed(int page, int size, Long currentUserIdNullable);

    /**
     * 获取指定用户发布的动态信息
     *
     * @param userId 用户ID
     * @param page 当前页码
     * @param size 每页大小
     * @return 返回分页的动态信息响应对象
     */
    FeedPageResponse getMyPublished(long userId, int page, int size);
}

5.4.2 实现知文Feed业务实现类

5.4.2.1 完善count的查询所有用户点赞/收藏状态方法

查询所有用户点赞/收藏状态方法的接口

复制代码
Map<String, Boolean> getBatchIsLiked(String entityType, List<String> entityIds, long userId);
Map<String, Boolean> getBatchIsFaved(String entityType, List<String> entityIds, long userId);

查询所有用户点赞/收藏状态方法的实现

复制代码
@Override
    /**
     * 批量获取用户对多个实体的点赞状态
     * @param entityType 实体类型,如文章、评论等
     * @param entityIds 实体ID列表
     * @param userId 用户ID
     * @return 包含实体ID和对应点赞状态的Map
     */
    public Map<String, Boolean> getBatchIsLiked(String entityType, List<String> entityIds, long userId) {
        // 参数校验,确保输入参数不为空
        if (entityType == null || entityIds == null || entityIds.isEmpty()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST,"参数不能为空");
        }
        // 计算用户ID在位图中的分片位置
        long chunk = BitmapShard.chunkOf(userId);
        // 计算用户ID在位图中的偏移量
        long offset = BitmapShard.bitOf(userId);
        // 3. 使用 Pipeline 批量查询
        List<Object> results = redis.executePipelined(new RedisCallback<Object>() {
            @Override
            public Object doInRedis(RedisConnection connection) throws DataAccessException {
                for (String entityId : entityIds) {
                    // 构造每一个实体对应的 Redis Key
                    // 注意:这里要把 "like" 还是 "fav" 分清楚,你的方法名是 isLiked,但上面单条用的是 "fav"
                    // 假设这里是查点赞,那就是 "like"
                    String key = CounterKeys.bitmapKey("like", entityType, entityId, chunk);

                    // 发送 getBit 命令
                    connection.stringCommands().getBit(key.getBytes(), offset);
                }
                return null; // 在 Pipeline 中必须返回 null
            }
        });
        // 组装结果
        // results 里的顺序和 entityIds 的顺序是严格一一对应的
        Map<String, Boolean> resultMap = new HashMap<>(entityIds.size());
        for (int i = 0; i < entityIds.size(); i++) {
            String entityId = entityIds.get(i);
            Object res = results.get(i);
            // Redis 返回的 getBit 结果通常是 Boolean 或者 0/1,Spring Data Redis 会转为 Boolean
            boolean isLiked = res != null && (Boolean) res;
            resultMap.put(entityId, isLiked);
        }
        return resultMap;
    }

/**
 * 批量获取用户是否收藏了指定类型的实体
 * @param entityType 实体类型
 * @param entityIds 实体ID列表
 * @param userId 用户ID
 * @return 包含实体ID和是否收藏的映射关系
 * @throws BusinessException 当参数为空时抛出
 */
    @Override
    public Map<String, Boolean> getBatchIsFaved(String entityType, List<String> entityIds, long userId) {
    // 检查参数合法性
        if (entityType == null || entityIds == null || entityIds.isEmpty()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST,"参数不能为空");
        }
        // 计算用户ID在位图中的分片位置
        long chunk = BitmapShard.chunkOf(userId);
        // 计算用户ID在位图中的偏移量
        long offset = BitmapShard.bitOf(userId);
        // 3. 使用 Pipeline 批量查询
        List<Object> results = redis.executePipelined(new RedisCallback<Object>() {
            @Override
            public Object doInRedis(RedisConnection connection) throws DataAccessException{
                for (String entityId : entityIds){
                    // 构造每一个实体对应的 Redis Key
                    // 注意:这里要把 "like" 还是 "fav" 分清楚,你的方法名是 isLiked,但上面单条用的是 "fav"
                    // 假设这里是查点赞,那就是 "like"
                    String key = CounterKeys.bitmapKey("fav", entityType, entityId, chunk);
                    // 发送 getBit 命令
                    connection.stringCommands().getBit(key.getBytes(), offset);
                }
                return null;
            }
        });
        // 组装结果
        // results 里的顺序和 entityIds 的顺序是严格一一对应的
        Map<String, Boolean> resultMap = new HashMap<>(entityIds.size());
        for (int i = 0; i < entityIds.size(); i++) {
            String entityId = entityIds.get(i);
            Object res = results.get(i);
            // Redis 返回的 getBit 结果通常是 Boolean 或者 0/1,Spring Data Redis 会转为 Boolean
            boolean isFaved = res != null && (Boolean) res;
            resultMap.put(entityId, isFaved);
        }
        return resultMap;
    }
5.4.2.2 知文Feed流业务实现类
复制代码
package com.xiaoce.zhiguang.knowpost.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.xiaoce.zhiguang.cache.hotkey.HotKeyDetector;
import com.xiaoce.zhiguang.common.utils.BeanCopyUtils;
import com.xiaoce.zhiguang.common.utils.CollectionUtil;
import com.xiaoce.zhiguang.counter.service.impl.CounterService;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPostFeedRow;
import com.xiaoce.zhiguang.knowpost.domain.po.KnowPosts;
import com.xiaoce.zhiguang.knowpost.domain.vo.FeedItemResponse;
import com.xiaoce.zhiguang.knowpost.domain.vo.FeedPageResponse;
import com.xiaoce.zhiguang.knowpost.mapper.KnowPostsMapper;
import com.xiaoce.zhiguang.knowpost.service.IKnowPostFeedService;
import com.xiaoce.zhiguang.user.domain.po.Users;
import com.xiaoce.zhiguang.user.service.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * KnowPostFeedServiceImpl
 * <p>
 * 知文Feed流实现类
 *
 * @author 小策
 * @date 2026/2/7 10:09
 */
@Service
@Slf4j
public class KnowPostFeedServiceImpl extends ServiceImpl<KnowPostsMapper, KnowPosts>  implements IKnowPostFeedService {

    private final StringRedisTemplate redis;
    private final ObjectMapper objectMapper;
    private final CounterService counterService;
    private final Cache<String, FeedPageResponse> feedPublicCache;
    private final Cache<String, FeedPageResponse> feedMineCache;
    private final HotKeyDetector hotKey;
    private static final int LAYOUT_VER = 1;
    private final ConcurrentHashMap<String, Object> singleFlight = new ConcurrentHashMap<>();
    private final IUserService userService;

    /**
     * 构造函数:注入 Mapper、Redis、对象映射器、计数服务与本地缓存。
     * @param redis Redis 客户端
     * @param objectMapper JSON 序列化/反序列化器
     * @param counterService 点赞/收藏计数服务
     * @param feedPublicCache 首页公共 Feed 本地缓存
     * @param feedMineCache 我的发布 Feed 本地缓存
     * @param hotKey 热点 Key 检测器,用于动态延长 TTL
     */
    public KnowPostFeedServiceImpl(
            StringRedisTemplate redis,
            ObjectMapper objectMapper,
            CounterService counterService,
            @Qualifier("feedPublicCache") Cache<String, FeedPageResponse> feedPublicCache,
            @Qualifier("feedMineCache") Cache<String, FeedPageResponse> feedMineCache,
            HotKeyDetector hotKey,
            IUserService userService
            ) {
        this.redis = redis;
        this.objectMapper = objectMapper;
        this.counterService = counterService;
        this.feedPublicCache = feedPublicCache;
        this.feedMineCache = feedMineCache;
        this.hotKey = hotKey;
        this.userService = userService;
    }
    /**
     * 生成公共 Feed 页面的缓存 Key(包含分页与布局版本)。
     * @param page 页码(1 起)
     * @param size 每页大小
     * @return Redis/Page 缓存的 Key
     */
    private String cacheKey(int page, int size) {
        return "feed:public:" + size + ":" + page + ":v" + LAYOUT_VER;
    }
    private void recordItemHotKey(String itemId) {
        // 使用内容 ID 作为热点统计 Key,而不是页面 Key 只计数
        String hotKeyId = "knowpost:" + itemId;
        hotKey.record(hotKeyId);
        int baseTtl = 60;
        int target = hotKey.ttlForPublic(baseTtl, hotKeyId);
        // 延长该内容的详情片段缓存 页面具体缓存的key
        String itemKey = "feed:item:" + itemId;
        Long itemTtl = redis.getExpire(itemKey);
        if (itemTtl < target) {
            redis.expire(itemKey, Duration.ofSeconds(target));
        }
    }
    /**
     * 叠加用户维度状态,将 liked/faved 根据用户计算覆盖到列表上。
     * 不改写底层缓存,避免不同用户状态互相污染。
     * @param base 基础列表(含计数)
     * @param uid 用户 ID(可空)
     * @return 叠加 liked/faved 的列表,根据当前页判断用户是否点赞,收藏
     */
    private List<FeedItemResponse> enrich(List<FeedItemResponse> base, Long uid) {
        if (CollectionUtil.isEmpty(base)) {
            return Collections.emptyList();
        }
    // 创建一个新的列表,用于存储处理后的 FeedItemResponse 对象
        ArrayList<FeedItemResponse> out = new ArrayList<>(base.size());
        // 获取所有知文id
        List<String> feedIds = base.stream().map(FeedItemResponse::id).collect(Collectors.toList());
        // 准备状态 Map (默认为空,防止 NPE)
        Map<String, Boolean> isLikedMap = Collections.emptyMap();
        Map<String, Boolean> isFavedMap = Collections.emptyMap();
        //只有用户登录了 (uid != null) 才去查状态!
        if (uid != null) {
            // 建议:CounterService 内部要处理好异常,如果报错返回空 Map 而不是 null
            isLikedMap = counterService.getBatchIsLiked("knowpost", feedIds, uid);
            isFavedMap = counterService.getBatchIsFaved("knowpost", feedIds, uid);
            // 防御性编程:万一 Service 真的返回了 null,将其修正为空 Map,防止下面 crash
            if (isLikedMap == null) isLikedMap = Collections.emptyMap();
            if (isFavedMap == null) isFavedMap = Collections.emptyMap();
        }
        // 遍历基础列表中的每个 FeedItemResponse 对象
        for (FeedItemResponse feed : base) {
            // 从 isLikedMap 中获取当前 FeedItemResponse 对象的 isLike 值,如果不存在则默认为 false
            boolean isLike = isLikedMap.getOrDefault(feed.id(), false);
            // 从 isFavedMap 中获取当前 FeedItemResponse 对象的 isFaved 值,如果不存在则默认为 false
            boolean isFaved = isFavedMap.getOrDefault(feed.id(), false);
            // 创建一个新的 FeedItemResponse 对象,包含原有属性和用户点赞、收藏状态
            // 创建一个新的 FeedItemResponse 对象,包含原有属性和用户点赞、收藏状态
            out.add(new FeedItemResponse(
                    feed.id(),          // 条目 ID
                    feed.title(),       // 标题
                    feed.description(), // 描述
                    feed.coverImage(),  // 封面图片
                    feed.tags(),        // 标签
                    feed.authorAvatar(),// 作者头像
                    feed.authorNickname(), // 作者昵称
                    feed.tagJson(),     // 标签 JSON
                    feed.likeCount(),   // 点赞数
                    feed.favoriteCount(), // 收藏数
                    isLike,             // 当前用户是否点赞
                    isFaved,            // 当前用户是否收藏
                    feed.isTop()        // 是否置顶
            ));
        }
    // 返回处理后的列表
        return out;
    }
/**
 * 从缓存中组装Feed页面响应数据
 * @param idsKey 存储ID列表的Redis键
 * @param hasMoreKey 存储是否有更多数据的标记的Redis键
 * @param page 当前页码
 * @param size 每页大小
 * @param uid 用户ID
 * @return 组装好的Feed页面响应数据,如果缓存中没有数据则返回null
 */
    private FeedPageResponse assembleFromCache(String idsKey, String hasMoreKey, int page, int size, Long uid) {
        // 先取 hasMore 标记
        String hasMoreStr = redis.opsForValue().get(hasMoreKey);
        // // 从Redis中获取指定范围内的ID列表
        List<String> idList = redis.opsForList().range(idsKey, 0, size - 1);
        // 如果 Redis 里明确写着 hasMore="0",并且 idList 是空的。
        // 这说明:这是一个被我们特意标记的"空缓存页面"。
        // 动作:直接返回空对象,不要返回 null (返回 null 会触发数据库回源)。
        if ("0".equals(hasMoreStr) && (idList == null || idList.isEmpty())) {
            return new FeedPageResponse(new ArrayList<>(), page, size, false);
        }
        // 如果清单是空的,说明缓存过期了或者根本没生成过,直接返回 null。
        // 外层逻辑看到 null 后,会触发"回源"(去数据库查)。
        if (idList == null || idList.isEmpty()) {
            return null;
        }
        // 我们要根据 ID 列表,拼凑出每一篇文章的 Redis Key。
        // 比如 id="1001" -> key="feed:item:1001"
        List<String> itemKeys = new ArrayList<>(idList.size());
        for (String id : idList) {
            itemKeys.add("feed:item:" + id);
        }
        // 这里用 multiGet,相当于开了一辆大卡车,一次往返就把 10 个包裹都拉回来了。
        List<String> itemJsons = redis.opsForValue().multiGet(itemKeys);
        // 反序列化为对象列表
        List<FeedItemResponse> items = new ArrayList<>(idList.size());
    /**
    * 遍历ID列表,处理每个ID对应的JSON数据
    * 1. 从itemJsons列表中获取对应索引的JSON字符串,如果为null则直接返回null
    * 2. 使用objectMapper将JSON字符串转换为FeedItemResponse对象
    * 3. 将转换后的对象添加到items列表中
    */
        for (int i = 0; i < idList.size(); i++) {
    // 获取当前索引对应的JSON字符串,如果itemJsons为null或索引超出范围,则设为null
            String itemJson = (itemJsons != null && i < itemJsons.size()) ? itemJsons.get(i) : null;
    // 如果JSON字符串为null,直接返回null
            if (itemJson == null) {
                return null;
            }
            try {
        // 将JSON字符串转换为FeedItemResponse对象并添加到items列表
                FeedItemResponse item = objectMapper.readValue(itemJson, FeedItemResponse.class);
                items.add(item);
            } catch (JsonProcessingException e) {
        // JSON解析失败时返回null
                return null;
            }
        }
        // 提取所有项目的ID列表
        List<String> itemsIds = items.stream().map(FeedItemResponse::id).collect(Collectors.toList());
        // 批量获取每个项目的点赞和收藏数量
        Map<String, Map<String, Long>> batchCounts =
                counterService.getCountsBatch("knowpost", itemsIds, List.of("like", "fav"));
        // 3.3 【批量查询】用户状态(我是否点赞/收藏)
        Map<String, Boolean> batchLiked = new HashMap<>();
        Map<String, Boolean> batchFaved = new HashMap<>();
        // 如果用户ID不为null,则查询该用户对每个项目的点赞和收藏状态
        if (uid != null) {
             batchLiked = counterService.getBatchIsLiked("knowpost", itemsIds, uid);
            batchFaved = counterService.getBatchIsFaved("knowpost", itemsIds, uid);
        }
        // 创建结果列表,大小与ID列表相同
        List<FeedItemResponse> enriched = new ArrayList<>(idList.size());
        // 遍历处理后的项目列表
        for (FeedItemResponse base : items) {
        // 获取当前项目的点赞和收藏数量
            Map<String, Long> counts = batchCounts.getOrDefault(base.id(), Collections.emptyMap());
            long likeCount = counts.getOrDefault("like", 0L);
            long favCount = counts.getOrDefault("fav", 0L);
            // 获取当前项目的点赞和收藏状态
            Boolean isLike = batchLiked.getOrDefault(base.id(), false);
            Boolean isFav = batchFaved.getOrDefault(base.id(), false);
            // 重新组装FeedItemResponse对象,注入批量查询到的数据
            enriched.add(new FeedItemResponse(
                    base.id(),
                    base.title(),
                    base.description(),
                    base.coverImage(),
                    base.tags(),
                    base.authorAvatar(),
                    base.authorNickname(),
                    base.tagJson(),
                    likeCount,     // 注入批量查到的数据
                    favCount, // 注入批量查到的数据
                    isLike,         // 注入批量查到的状态
                    isFav,         // 注入批量查到的状态
                    base.isTop())
            );
        }
        // hasMore 优先使用软缓存值;若缺失,则以"满页"作为兜底判断
        boolean hasMore = hasMoreStr != null ? "1".equals(hasMoreStr) : (idList.size() == size);
        return new FeedPageResponse(enriched, page, size, hasMore);
    }
    /**
     * 将数据库行映射为响应条目。
     * 计数通过计数服务填充;liked/faved 按需计算;isTop 仅在个人列表返回计数。
     * @param rows 查询结果行
     * @param userIdNullable 当前用户 ID(可空)
     * @param includeIsTop 是否在响应中包含 isTop
     * @return 条目列表
     */
    private List<FeedItemResponse> mapRowsToItems(List<KnowPostFeedRow> rows, Long userIdNullable, boolean includeIsTop) {
       if (rows == null || rows.isEmpty()) {
           return new ArrayList<>();
       }
       //优化性能,批量查询计数,用户对帖子的状态
        List<String> postIds = rows.stream().map(r -> String.valueOf(r.getId())).collect(Collectors.toList());
        Map<String, Map<String, Long>> batchCounts =
                counterService.getCountsBatch("knowpost", postIds, List.of("like", "fav"));
        Map<String, Boolean> batchLiked = new HashMap<>();
        Map<String, Boolean> batchFaved = new HashMap<>();
        if (userIdNullable != null) {
            batchLiked = counterService.getBatchIsLiked("knowpost", postIds, userIdNullable);
            batchFaved = counterService.getBatchIsFaved("knowpost", postIds, userIdNullable);
        }
        // 准备一个空箱子,容量和原材料数量一样大(避免扩容浪费性能)
        List<FeedItemResponse> items = new ArrayList<>(rows.size());
        for (KnowPostFeedRow row : rows) {
            // --- 数据清洗 ---
            String postIdStr = String.valueOf(row.getId());
            // 数据库里存的是 "java,spring,redis" 这种字符串
            // 前端要的是 ["java", "spring", "redis"] 这种数组
            List<String> tags = parseStringArray(row.getTags());
            // 图片同理,数据库存的是逗号分隔的字符串,要转成列表
            List<String> imgs = parseStringArray(row.getImgUrls());
            // 封面图逻辑:如果有图,取第一张;没图就是 null
            String cover = imgs.isEmpty() ? null : imgs.getFirst();
            Map<String, Long> count = batchCounts.getOrDefault(row.getId().toString(), Collections.emptyMap());
            long likeCount = count.getOrDefault("like", 0L);
            long favCount = count.getOrDefault("fav", 0L);
            Boolean isLike = batchLiked.getOrDefault(row.getId().toString(), false);
            Boolean isFav = batchFaved.getOrDefault(row.getId().toString(), false);
            Boolean isTop = includeIsTop ? row.getIsTop() : null;
            // --- 组装对象 ---
            items.add(new FeedItemResponse(
                    postIdStr,
                    row.getTitle(),
                    row.getDescription(),
                    cover,
                    tags,
                    row.getAuthorAvatar(),
                    row.getAuthorNickname(),
                    row.getAuthorTagJson(),
                    likeCount,
                    favCount,
                    isLike,
                    isFav,
                    isTop
            ));
        }
        return items;
    }

/**
 * 解析JSON字符串为字符串列表
 * @param json 需要解析的JSON格式字符串
 * @return 解析后的字符串列表,如果解析失败或输入为空则返回空列表
 */
    private List<String> parseStringArray(String json) {
    // 检查输入是否为null或空白字符串
        if (json == null || json.isBlank()){
        // 返回空列表
            return Collections.emptyList();
        }
        try {
            // new TypeReference<List<String>>() {}它强行把 List<String> 这个泛型信息保留了下来,
            // 递给了 Jackson,让它知道:"嘿,别光给我一个 List,我要的是装着 String 的 List!
           return objectMapper.readValue(json, new TypeReference<List<String>>() {});
        } catch (JsonProcessingException e) {
        // 如果解析过程中发生异常,返回空列表
            return Collections.emptyList();
        }
    }
    /**
     * 缓存写入核心方法
     * 作用:将数据库查到的数据,拆解成"清单"、"信号灯"、"详情"、"索引"四份,存入 Redis。
     * @param pageKey   当前页面的 Key (如 feed:ids:10:46000:1)
     * @param idsKey    ID 清单 Key
     * @param hasMoreKey 是否有下一页的标记 Key
     * @param size      分页大小
     * @param rows      数据库查出来的原始行(用于提取 ID)
     * @param items     处理好的详情对象(用于存 JSON)
     * @param hasMore   数据库告诉我们后面还有没有数据
     * @param frTtl     缓存过期时间 (Time To Live)
     */
    private void writeCaches(String pageKey, String idsKey, String hasMoreKey, int size, List<KnowPostFeedRow> rows,
                             List<FeedItemResponse> items, boolean hasMore, Duration frTtl) {
        if (rows == null || rows.isEmpty()) {
            // 写入 hasMore = "0" (明确告诉读缓存逻辑:这里是尽头,别查库了)
            // TTL 是外面传进来的,空结果通常传 30秒 左右,不要太长。
            redis.opsForValue().set(hasMoreKey, "0", frTtl);
            return;
        }

        List<String> idVals = new ArrayList<>();
        for (KnowPostFeedRow row : rows) {
            idVals.add(String.valueOf(row.getId()));
        }
        if (CollectionUtil.isEmpty(idVals)){
            return;
        }
        // 计算当前小时的时间槽(用于索引)
        long hourSlot = System.currentTimeMillis() / 3600000L;
        // 计算 Jitter 随机时间(提取到循环外)
        long randomJitter = ThreadLocalRandom.current().nextInt(11);
        // 所有的 redis 操作不会立即执行,而是先放入队列,最后一次性打包发给 Redis Server。
        redis.executePipelined((RedisCallback<Object>) connection -> {
            // 1. 存 ID 清单。
            // 注意:这里需要将 String 转为 byte[],因为是底层 Connection 操作
            byte[][] idBytes = idVals.stream().map(String::getBytes).toArray(byte[][]::new);
            connection.listCommands().lPush(idsKey.getBytes(), idBytes);
            connection.keyCommands().expire(idsKey.getBytes(), frTtl.getSeconds());

            // 2. 存 HasMore 标记 setEx (SET with Expire):是给 字符串(String) 赋值,还是个独生子(只能存一个值)。
            String hasMoreVal = (idVals.size() == size && hasMore) ? "1" : "0";
            long hasMoreTtl = (idVals.size() == size && hasMore) ? (10 + randomJitter) : 10;
            connection.stringCommands().setEx(hasMoreKey.getBytes(), hasMoreTtl, hasMoreVal.getBytes());

            // 3. 存页面总索引 sAdd (Set Add):是给 集合(Set) 添加元素,是个大家庭(能存很多不重复的值)。
            // 把当前生成的 pageKey 记在一个 Set 里。
            // 作用:如果未来需要"一键清空所有信息流缓存",直接遍历这个 Set 删除即可。
            connection.setCommands().sAdd("feed:public:pages".getBytes(), pageKey.getBytes());
            // 4. 循环写入"详情"和"反向索引"
            for (FeedItemResponse item : items) {
                try {
                    // A. 反向索引 Key
                    String idxKey = "feed:public:index:" + item.id() + ":" + hourSlot;
                    connection.setCommands().sAdd(idxKey.getBytes(), pageKey.getBytes());
                    connection.keyCommands().expire(idxKey.getBytes(), frTtl.getSeconds());
                    // B. 详情 Key
                    String itemKey = "feed:item:" + item.id();
                    byte[] bytes = objectMapper.writeValueAsBytes(item);
                    connection.stringCommands().setEx(itemKey.getBytes(), frTtl.getSeconds(), bytes);
                }catch (Exception e){
                    // 序列化失败时不中断整个 Pipeline,只跳过这一条
                    log.error("缓存序列化失败 item={}", item.id(), e);
                }
            }
            return null;
        });
    }

    private String myCacheKey(long userId, int page, int size) {
        return "feed:mine:" + userId + ":" + size + ":" + page;
    }
/**
 * 根据热点key策略可能延长TTL时间
 * @param key 需要检查的Redis键
 */
    private void maybeExtendTtlMine(String key) {
        // 设置基础TTL时间为30秒
        int baseTtl = 30;
        // 根据热点key策略计算目标TTL时间
        int target = hotKey.ttlForMine(baseTtl, key);
        // 获取当前键的TTL时间
        Long currentTtl = redis.getExpire(key);
        // 如果当前TTL小于目标TTL,则更新TTL
        if (currentTtl < target) {
            redis.expire(key, Duration.ofSeconds(target));
        }
    }

    /**
         * 获取公开的首页 Feed(按发布时间倒序,不受置顶影响)。
         * 采用三级缓存:本地 Caffeine、Redis 页面缓存、Redis 片段缓存(ids/item/count)。
         * @param page 页码(≥1)
         * @param size 每页数量(1~50)
         * @param currentUserIdNullable 当前用户 ID(为空表示匿名)
         * @return 带分页信息的 Feed 列表(liked/faved 为用户维度)
         */
    @Override
    public FeedPageResponse getPublicFeed(int page, int size, Long currentUserIdNullable) {
        int safeSize = Math.min(Math.max(size, 1), 50);
        int safePage = Math.max(page, 1);
        // 这个 localPageKey 是本地缓存的页面 Key(非 Redis)
        String localPageKey = cacheKey(safePage, safeSize);
        // 按小时分片的片段缓存键:降低跨小时内容更新导致的大面积失效风险
        // 将分页维度(size/page)与时间维度(hourSlot)组合,避免热门页在整站失效时同时回源
        long hourSlot = System.currentTimeMillis() / 3600000L;
        String idsKey = "feed:public:ids:" + safeSize + ":" + hourSlot + ":" + safePage;
        //用来判断当前是否是最后一页,后面是否还有数据
        String hasMoreKey = "feed:public:ids:" + safeSize + ":" + hourSlot + ":" + safePage + ":hasMore";
        FeedPageResponse local = feedPublicCache.getIfPresent(localPageKey);
        if(local != null && local.items() != null){
            // 对返回列表中的每个条目进行热度统计,并根据统计热度,进行延长ttl
            for (FeedItemResponse item : local.items()) {
                recordItemHotKey(item.id());
            }
            log.info("feed.public source=local localPageKey={} page={} size={}", localPageKey, safePage, safeSize);
            List<FeedItemResponse> enrichedLocal = enrich(local.items(), currentUserIdNullable);
            return new FeedPageResponse(enrichedLocal, safePage, safeSize, local.hasMore());
        }
        // L2: 二级缓存,Redis 片段缓存,组装
        FeedPageResponse fromCache = assembleFromCache(idsKey, hasMoreKey, safePage, safeSize, currentUserIdNullable);
        if (fromCache != null) {
            feedPublicCache.put(localPageKey, fromCache);
            // 对返回列表中的每个条目进行热度统计,并延长缓存时间
            if (fromCache.items() != null) {
                for (FeedItemResponse item : fromCache.items()) {
                    recordItemHotKey(item.id());
                }
            }
            log.info("feed.public source=3tier localPageKey={} page={} size={}", localPageKey, safePage, safeSize);
            return fromCache;
        }
        // 当上述两级缓存都没有数据,说明需要回源查数据库
        // 为了防止高并发下(例如 1000 个请求同时访问同一页)
        // 所有请求同时打到数据库(造成 缓存击穿 ),这里使用了锁
        // 单航班机制:以 idsKey 作为"航班号"
        // 并发下同一页只允许一个请求回源数据库,其余在锁内优先重查缓存,避免击穿惊群
        Object lock = singleFlight.computeIfAbsent(idsKey, k -> new Object());
        try {
            synchronized (lock){
                // 双重检查,防止缓存被其他线程提前更新
                // L2: 二级缓存,Redis 片段缓存,组装
                FeedPageResponse again = assembleFromCache(idsKey, hasMoreKey, safePage, safeSize, currentUserIdNullable);
                if (again != null) {
                    feedPublicCache.put(localPageKey, again);
                    // 对返回列表中的每个条目进行热度统计,并延长缓存时间
                    if (again.items() != null) {
                        for (FeedItemResponse item : again.items()) {
                            recordItemHotKey(item.id());
                        }
                    }
                    log.info("feed.public source=3tier localPageKey={} page={} size={}", localPageKey, safePage, safeSize);
                    return again;
                }
                // 数据库回源:读取 size+1 以判断是否有下一页,后裁剪为当前页
                int offset = (safeSize * (safePage - 1));
                //知文信息
                List<KnowPosts> knowPosts = lambdaQuery().eq(KnowPosts::getStatus, "published")
                        .eq(KnowPosts::getVisible, "public")
                        .orderByDesc(KnowPosts::getPublishTime)
                        .last("limit " + (safeSize + 1) + " offset " + offset)
                        .list();
                boolean hasMore = knowPosts.size() > safeSize;
                if (hasMore){
                    knowPosts = knowPosts.subList(0,safeSize);
                }
                //如果查询出来的知文信息为空,缓存空值
                // 如果 knowPosts 为空(比如用户翻到了第 10000 页),如果不缓存空结果,
                // 下次请求还会打到数据库(缓存穿透)。
                // 建议:即使为空,也调用 writeCaches 写入一个空列表,TTL 设置短一点(如 30秒)。
                if (CollectionUtil.isEmpty(knowPosts)){
                    writeCaches(localPageKey, idsKey, hasMoreKey, safeSize,
                            Collections.emptyList(), // rows 为空
                            Collections.emptyList(), // items 为空
                            false,                   // hasMore 为 false
                            Duration.ofSeconds(30)); // TTL 短一点 (30秒)
                    return new FeedPageResponse(Collections.emptyList(), safePage, safeSize, false);
                }
                //用户信息,为后续做填充做准备
                Stream<Long> userId = knowPosts.stream().map(KnowPosts::getCreatorId);
                List<Users> users = userService.listByIds(userId.collect(Collectors.toList()));
                Map<Long, Users> userMap = users.stream().collect(Collectors.toMap(Users::getId, user -> user));
                // 构建基础列表(计数已填充),liked/faved 置为 null 以免污染用户维度缓存
                List<KnowPostFeedRow> rows = new ArrayList<KnowPostFeedRow>();

            //遍历知识帖子列表,为每个帖子创建一个KnowPostFeedRow对象,并设置作者相关信息
            //从userMap中获取作者信息,如果找不到作者则设置默认的错误信息
            //knowPosts 知识帖子列表,包含帖子基本信息
            // 用户ID到用户信息的映射表,用于快速查找作者信息
            //rows 用于存储处理后的KnowPostFeedRow对象的列表
                for (KnowPosts knowPost : knowPosts) { // 遍历知识帖子列表
                // 使用BeanCopyUtils将knowPost对象复制为KnowPostFeedRow对象
                    KnowPostFeedRow row = BeanCopyUtils.copy(knowPost, KnowPostFeedRow.class);
                    // 获取帖子创建者的ID
                    Long uid = knowPost.getCreatorId();
                    // 从用户映射表中获取用户信息
                    Users user = userMap.get(uid);
                    // 如果用户信息存在* @param
                    if (user!= null) {
                         // 设置作者头像
                        row.setAuthorAvatar(user.getAvatar());
                        // 设置作者昵称
                        row.setAuthorNickname(user.getNickname());
                        // 设置作者标签JSON
                        row.setAuthorTagJson(user.getTagsJson());
                    }else {
                        // 如果用户信息不存在
                        // 设置默认空头像
                        row.setAuthorAvatar(" ");
                        // 设置错误提示的作者昵称
                        row.setAuthorNickname("出错了!!未知用户!!");
                        row.setAuthorTagJson("出错了!!");
                    }
                    // 将处理后的行添加到结果列表中
                    rows.add(row);
                }
                List<FeedItemResponse> items = mapRowsToItems(rows, null, false);
                FeedPageResponse response = new FeedPageResponse(items, safePage, safeSize, hasMore);
                // 片段缓存(ids/item/count)TTL 更长并加入随机抖动,降低同一时刻大量过期
                int baseTtl = 60;
                int jitter = ThreadLocalRandom.current().nextInt(30);
                Duration frTtl = Duration.ofSeconds(baseTtl + jitter);
                // 写入片段缓存与本地缓存
                writeCaches(localPageKey, idsKey, hasMoreKey, safeSize, rows, items, hasMore, frTtl);
                feedPublicCache.put(localPageKey, response);
                // 返回时覆盖用户维度状态,不写回缓存
                List<FeedItemResponse> enriched = enrich(items, currentUserIdNullable);
                log.info("feed.public source=db localPageKey={} page={} size={} hasMore={}", localPageKey, safePage, safeSize, hasMore);
                // 释放单航班锁,允许后续请求正常进入
                singleFlight.remove(idsKey);
                return new FeedPageResponse(enriched, safePage, safeSize, hasMore);
            }
        } finally {
            // 保底方案,释放单航班锁,允许后续请求正常进入
            singleFlight.remove(idsKey);
        }
    }
    /**
     * 获取当前用户自己发布的知文列表(按发布时间倒序)。
     * 缓存策略:本地 Caffeine + Redis 页面缓存(TTL 更短)。
     * 返回的每条目包含 isTop 字段以表示是否置顶。
     * @param userId 当前用户 ID
     * @param page 页码(≥1)
     * @param size 每页数量(1~50)
     * @return 带分页信息的个人发布列表
     */
    @Override
    public FeedPageResponse getMyPublished(long userId, int page, int size) {
        // 参数校验:确保 size 在 1~50 范围内,page ≥ 1
        int safeSize = Math.min(Math.max(size, 1), 50);
        int safePage = Math.max(page, 1);
        // 生成缓存 key
        String key = myCacheKey(userId, safePage, safeSize);
        // 1. 从本地缓存去取
        FeedPageResponse local = feedMineCache.getIfPresent(key);
        if (local != null) {
            hotKey.record(key);
            maybeExtendTtlMine(key);
            log.info("feed.mine source=local key={} page={} size={} user={}", key, safePage, safeSize, userId);
            return local;
        }
        //2. 本地缓存没有,则去redis中去取
        String cache = redis.opsForValue().get(key);
        if (cache != null){
            try {
                //为什么这里填充点赞数,收藏数等等。那是因为个人流缓存是私有的,所以直接在查库转换时就把状态填好并缓存起来了
                FeedPageResponse cacheRes = objectMapper.readValue(cache, FeedPageResponse.class);
                boolean hasCounts = cacheRes.items() !=null && cacheRes.items().stream()
                        .allMatch(item -> item.liked() !=null && item.faved() != null);
                //如果数据没问题,需要写入本地缓存
                if (hasCounts){
                    feedMineCache.put(key, cacheRes);
                    hotKey.record(key);
                    maybeExtendTtlMine(key);
                    log.info("feed.mine source=page key={} page={} size={} user={}", key, safePage, safeSize, userId);
                   // 前面缓存中存了点赞数等数据,为什么这里又要填充数据,原因如下:
                    //在条件允许的情况下,我们尽量调用 enrich 用最新数据覆盖旧数据,给用户最好的体验。
                    List<FeedItemResponse> enriched = enrich(cacheRes.items(), userId);
                    return new FeedPageResponse(enriched, safePage, safeSize, cacheRes.hasMore());
                }
            } catch (JsonProcessingException ignore) {
                log.warn("个人流缓存解析失败,触发回源 key={} ", key, ignore);
            }
        }
        //3. 本地缓存和redis缓存都没有,则需要进行数据库中查询
        // 数据库回源:读取 size+1 以判断是否有下一页,后裁剪为当前页
        int offset = (safeSize * (safePage - 1));
        //知文信息
        List<KnowPosts> knowPosts = lambdaQuery().eq(KnowPosts::getStatus, "published")
                .eq(KnowPosts::getCreatorId, userId)
                .eq(KnowPosts::getVisible, "public")
                .orderByDesc(KnowPosts::getPublishTime)
                .last("limit " + (safeSize + 1) + " offset " + offset)
                .list();
        // 判断是否是最后一页
        boolean hasMore = knowPosts.size() > safeSize;
        if (hasMore){
            knowPosts = knowPosts.subList(0,safeSize);
        }
        //如果查询出来的知文信息为空,缓存空值
        // 如果 knowPosts 为空(比如用户翻到了第 10000 页),如果不缓存空结果,
        // 下次请求还会打到数据库(缓存穿透)。
        if (CollectionUtil.isEmpty(knowPosts)){
            FeedPageResponse emptyResp = new FeedPageResponse(Collections.emptyList(), safePage, safeSize, false);
            try {
                // 序列化成 JSON
                String emptyJson = objectMapper.writeValueAsString(emptyResp);
                // 入 Redis 【关键点:TTL 要短!】
                // 设置 30秒 过期。防止用户一会儿发了新帖,结果被这个"空缓存"挡住看不见。
                // key 是你在前面定义的:String key = myCacheKey(userId, safePage, safeSize);
                redis.opsForValue().set(key, emptyJson, Duration.ofSeconds(30));
                // 同步写入本地缓存 (Caffeine)
                feedMineCache.put(key, emptyResp);
            } catch (Exception e) {
                log.error("缓存空值序列化失败 key={}", key, e);
            }
            // 直接返回!
            // 后面查用户信息的逻辑不需要跑了,省一次数据库查询。
            return emptyResp;
        }
        //用户信息,为后续做填充做准备
        Users user = userService.getBaseMapper().selectById(userId);
        List<KnowPostFeedRow> rows = new ArrayList<>();
        for (KnowPosts knowPost : knowPosts) {
            KnowPostFeedRow row = BeanCopyUtils.copy(knowPost, KnowPostFeedRow.class);
            row.setTags(user == null ? "[]" : user.getTagsJson());
            row.setAuthorAvatar(user == null ? " " : user.getAvatar());
            row.setAuthorNickname(user == null ? "出错了!!" : user.getNickname());
            rows.add(row);
        }
        List<FeedItemResponse> items = mapRowsToItems(rows, userId, true);
        FeedPageResponse resp = new FeedPageResponse(items, safePage, safeSize, hasMore);
        // 数据库数据写入缓存
        try {
            String json = objectMapper.writeValueAsString(resp);
            int baseTtl = 30;
            int jitter = ThreadLocalRandom.current().nextInt(20);
            redis.opsForValue().set(key, json, Duration.ofSeconds(baseTtl + jitter));
            feedMineCache.put(key, resp);
            hotKey.record(key);
        }catch (Exception e){
            log.error("序列化失败 key={}", key, e);

        }
        log.info("feed.mine source=db key={} page={} size={} user={} hasMore={}", key, safePage, safeSize, userId, hasMore);
        return resp;
    }
}

6 知文业务controller实现

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


import com.xiaoce.zhiguang.auth.service.impl.JwtServiceImpl;
import com.xiaoce.zhiguang.knowpost.domain.dto.KnowPostContentConfirmRequest;
import com.xiaoce.zhiguang.knowpost.domain.dto.KnowPostPatchRequest;
import com.xiaoce.zhiguang.knowpost.domain.dto.KnowPostTopPatchRequest;
import com.xiaoce.zhiguang.knowpost.domain.dto.KnowPostVisibilityPatchRequest;
import com.xiaoce.zhiguang.knowpost.domain.vo.FeedPageResponse;
import com.xiaoce.zhiguang.knowpost.domain.vo.KnowPostDetailResponse;
import com.xiaoce.zhiguang.knowpost.domain.vo.KnowPostDraftCreateResponse;
import com.xiaoce.zhiguang.knowpost.service.IKnowPostFeedService;
import com.xiaoce.zhiguang.knowpost.service.IKnowPostsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
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.*;

/**
 * <p>
 * 知识库文章表 前端控制器
 * </p>
 *
 * @author 小策
 * @since 2026-01-20
 */
@RestController
@RequestMapping("/api/v1/knowposts")
@Validated
@Tag(name = "知文模块", description = "知文模块相关接口")
@RequiredArgsConstructor
public class KnowPostsController {
    private final IKnowPostsService knowPostsService;
    private final IKnowPostFeedService feedService;
    private final JwtServiceImpl jwtService;

    /**
     * 创建草稿,返回新 ID。默认类型为 image_text。
     */
    @PostMapping("/drafts")
    @Operation(description = "创建草稿,返回新 ID。默认类型为 image_text。")
    public KnowPostDraftCreateResponse createDraft(@AuthenticationPrincipal Jwt jwt) {
        long userId = jwtService.extractUserId(jwt);
        long id = knowPostsService.createDraft(userId);
        return new KnowPostDraftCreateResponse(String.valueOf(id));
    }

    /**
     * 上传内容成功后回传确认,写入对象存储信息。
     */
    @PostMapping("/{id}/content/confirm")
    @Operation(description = "写入对象存储信息")
    public ResponseEntity<Void> confirmContent(@PathVariable("id") long id,
                                               @Valid @RequestBody KnowPostContentConfirmRequest request,
                                               @AuthenticationPrincipal Jwt jwt) {
        long userId = jwtService.extractUserId(jwt);
        knowPostsService.confirmContent(userId, id, request.objectKey(), request.etag(), request.size(), request.sha256());
        return ResponseEntity.noContent().build();
    }
    /**
     * 更新元数据(标题、标签、可见性、置顶、图片列表等)。
     */
    @PatchMapping("/{id}")
    @Operation(description = "更新元数据(标题、标签、可见性、置顶、图片列表等)")
    public ResponseEntity<Void> patchMetadata(@PathVariable("id") long id,
                                              @Valid @RequestBody KnowPostPatchRequest request,
                                              @AuthenticationPrincipal Jwt jwt) {
        long userId = jwtService.extractUserId(jwt);
        knowPostsService.updateMetadata(userId, id, request.title(), request.tagId(), request.tags(), request.imgUrls(), request.visible(), request.isTop(), request.description());
        return ResponseEntity.noContent().build();
    }

    /**
     * 发布帖子(状态置为 published)。
     */
    @PostMapping("/{id}/publish")
    @Operation(description = "发布帖子(状态置为 published)")
    public ResponseEntity<Void> publish(@PathVariable("id") long id,
                                        @AuthenticationPrincipal Jwt jwt) {
        long userId = jwtService.extractUserId(jwt);
        knowPostsService.publish(userId, id);
        return ResponseEntity.noContent().build();
    }

    /**
     * 设置置顶状态。
     */
    @PatchMapping("/{id}/top")
    @Operation(description = "设置置顶状态")
    public ResponseEntity<Void> patchTop(@PathVariable("id") long id,
                                         @Valid @RequestBody KnowPostTopPatchRequest request,
                                         @AuthenticationPrincipal Jwt jwt) {
        long userId = jwtService.extractUserId(jwt);
        knowPostsService.updateTop(userId, id, request.isTop());
        return ResponseEntity.noContent().build();
    }

    /**
     * 设置可见性(权限)。
     */
    @PatchMapping("/{id}/visibility")
    @Operation(description = "设置可见性(权限)")
    public ResponseEntity<Void> patchVisibility(@PathVariable("id") long id,
                                                @Valid @RequestBody KnowPostVisibilityPatchRequest request,
                                                @AuthenticationPrincipal Jwt jwt) {
        long userId = jwtService.extractUserId(jwt);
        knowPostsService.updateVisibility(userId, id, request.visible());
        return ResponseEntity.noContent().build();
    }

    /**
     * 删除知文(软删除)。
     */
    @DeleteMapping("/{id}")
    @Operation(description = "删除知文(软删除)")
    public ResponseEntity<Void> delete(@PathVariable("id") long id,
                                       @AuthenticationPrincipal Jwt jwt) {
        long userId = jwtService.extractUserId(jwt);
        knowPostsService.delete(userId, id);
        return ResponseEntity.noContent().build();
    }

    /**
     * 首页 Feed(公开、已发布)分页查询;默认每页 20,最大 50。
     */
    @GetMapping("/feed")
    @Operation(description = "首页 Feed(公开、已发布)分页查询;默认每页 20,最大 50。")
    public FeedPageResponse feed(@RequestParam(value = "page", defaultValue = "1") int page,
                                 @RequestParam(value = "size", defaultValue = "20") int size,
                                 @AuthenticationPrincipal Jwt jwt) {
        Long userId = (jwt == null) ? null : jwtService.extractUserId(jwt);
        return feedService.getPublicFeed(page, size, userId);
    }

    /**
     * 我的知文(当前用户已发布)分页查询;默认每页 20,最大 50。
     */
    @GetMapping("/mine")
    @Operation(description = "我的知文(当前用户已发布)分页查询;默认每页 20,最大 50。")
    public FeedPageResponse mine(@RequestParam(value = "page", defaultValue = "1") int page,
                                 @RequestParam(value = "size", defaultValue = "20") int size,
                                 @AuthenticationPrincipal Jwt jwt) {
        long userId = jwtService.extractUserId(jwt);
        return feedService.getMyPublished(userId, page, size);
    }

    /**
     * 知文详情(公开:published+public;非公开需作者本人)。
     */
    @GetMapping("/detail/{id}")
    @Operation(description = "知文详情(公开:published+public;非公开需作者本人)")
    public KnowPostDetailResponse detail(@PathVariable("id") long id,
                                         @AuthenticationPrincipal Jwt jwt) {
        Long userId = (jwt == null) ? null : jwtService.extractUserId(jwt);
        return knowPostsService.getDetail(id, userId);
    }

}
相关推荐
赶路人儿6 小时前
Jsoniter(java版本)使用介绍
java·开发语言
Victor3566 小时前
MongoDB(9)什么是MongoDB的副本集(Replica Set)?
后端
Victor3566 小时前
MongoDB(8)什么是聚合(Aggregation)?
后端
探路者继续奋斗6 小时前
IDD意图驱动开发之意图规格说明书
java·规格说明书·开发规范·意图驱动开发·idd
消失的旧时光-19437 小时前
第十九课:为什么要引入消息队列?——异步系统设计思想
java·开发语言
yeyeye1117 小时前
Spring Cloud Data Flow 简介
后端·spring·spring cloud
A懿轩A7 小时前
【Java 基础编程】Java 面向对象入门:类与对象、构造器、this 关键字,小白也能写 OOP
java·开发语言
Tony Bai8 小时前
告别 Flaky Tests:Go 官方拟引入 testing/nettest,重塑内存网络测试标准
开发语言·网络·后端·golang·php
乐观勇敢坚强的老彭8 小时前
c++寒假营day03
java·开发语言·c++