feat: 接入论坛评论树缓存

This commit is contained in:
Losita
2026-05-05 11:10:13 +08:00
parent c42f0c5b8c
commit 2204fac84e
5 changed files with 280 additions and 25 deletions

View File

@@ -6,6 +6,7 @@ import (
"github.com/LoveLosita/smartflow/backend/bootstrap" "github.com/LoveLosita/smartflow/backend/bootstrap"
legacydao "github.com/LoveLosita/smartflow/backend/dao" legacydao "github.com/LoveLosita/smartflow/backend/dao"
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
"github.com/LoveLosita/smartflow/backend/inits"
"github.com/LoveLosita/smartflow/backend/services/taskclassforum/adapter" "github.com/LoveLosita/smartflow/backend/services/taskclassforum/adapter"
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
forumrpc "github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc" forumrpc "github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc"
@@ -32,10 +33,17 @@ func main() {
// 3. 后续 task-class 独立成服务后,只替换这里的 adapter 注入点。 // 3. 后续 task-class 独立成服务后,只替换这里的 adapter 注入点。
taskClassPort := adapter.NewLegacyTaskClassAdapter(legacydao.NewTaskClassDAO(db)) taskClassPort := adapter.NewLegacyTaskClassAdapter(legacydao.NewTaskClassDAO(db))
eventPublisher := outboxinfra.NewRepositoryPublisher(outboxinfra.NewRepository(db), viper.GetInt("kafka.maxRetry")) eventPublisher := outboxinfra.NewRepositoryPublisher(outboxinfra.NewRepository(db), viper.GetInt("kafka.maxRetry"))
commentTreeCache := forumsv.CommentTreeCachePort(nil)
if rdb, redisErr := inits.OpenRedisFromConfig(); redisErr != nil {
log.Printf("taskclassforum 评论树缓存已降级关闭Redis 连接失败: %v", redisErr)
} else {
commentTreeCache = forumdao.NewCommentTreeCache(rdb)
}
svc := forumsv.New(forumsv.Options{ svc := forumsv.New(forumsv.Options{
DB: db, DB: db,
TaskClassPort: taskClassPort, TaskClassPort: taskClassPort,
EventPublisher: eventPublisher, EventPublisher: eventPublisher,
CommentTreeCache: commentTreeCache,
}) })
forumrpc.Start(forumrpc.ServerOptions{ forumrpc.Start(forumrpc.ServerOptions{
ListenOn: viper.GetString("taskclassforum.rpc.listenOn"), ListenOn: viper.GetString("taskclassforum.rpc.listenOn"),

View File

@@ -0,0 +1,146 @@
package dao
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
"github.com/go-redis/redis/v8"
)
const commentTreeCacheTTL = 2 * time.Minute
type commentTreeCachePayload struct {
Items []forumcontracts.ForumCommentNode `json:"items"`
Page forumcontracts.PageResult `json:"page"`
}
// CommentTreeCache 承载计划广场评论树的 Redis 缓存能力。
//
// 职责边界:
// 1. 只负责评论树读模型的 JSON 缓存和版本号失效,不读写 MySQL
// 2. 不计算当前用户是否可删除评论,避免把用户视角写进共享缓存;
// 3. Redis 异常向上返回,由 service 层决定是否降级回源 DB。
type CommentTreeCache struct {
client *redis.Client
ttl time.Duration
}
func NewCommentTreeCache(client *redis.Client) *CommentTreeCache {
return &CommentTreeCache{
client: client,
ttl: commentTreeCacheTTL,
}
}
func commentTreeVersionKey(postID uint64) string {
return fmt.Sprintf("forum:comments:%d:version", postID)
}
func commentTreeDataKey(postID uint64, version int64, sort string, page int, pageSize int) string {
return fmt.Sprintf(
"forum:comments:%d:v%d:sort:%s:page:%d:size:%d",
postID,
version,
strings.TrimSpace(sort),
page,
pageSize,
)
}
// GetCommentTree 读取指定帖子、排序和分页维度下的评论树缓存。
//
// 返回值语义:
// 1. hit=true 表示命中缓存items/page 可直接用于返回前的用户视角补全;
// 2. hit=false 且 error=nil 表示未命中,调用方应回源 DB
// 3. error 非空表示 Redis 或 JSON 异常,调用方应记录日志并回源 DB。
func (c *CommentTreeCache) GetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, bool, error) {
if c == nil || c.client == nil {
return nil, forumcontracts.PageResult{}, false, errors.New("评论树缓存未初始化")
}
version, err := c.currentCommentTreeVersion(ctx, postID)
if err != nil {
return nil, forumcontracts.PageResult{}, false, err
}
raw, err := c.client.Get(ctx, commentTreeDataKey(postID, version, sort, page, pageSize)).Result()
if errors.Is(err, redis.Nil) {
return nil, forumcontracts.PageResult{}, false, nil
}
if err != nil {
return nil, forumcontracts.PageResult{}, false, err
}
var payload commentTreeCachePayload
if err = json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, forumcontracts.PageResult{}, false, err
}
if payload.Items == nil {
payload.Items = []forumcontracts.ForumCommentNode{}
}
return payload.Items, payload.Page, true, nil
}
// SetCommentTree 写入指定帖子、排序和分页维度下的评论树缓存。
//
// 步骤说明:
// 1. 先读取当前版本号,保证写入 key 与后续读取 key 一致;
// 2. 再序列化去个性化后的评论树,避免缓存里带入某个用户的 can_delete
// 3. 最后写入短 TTL让版本失效失败时也能靠自然过期兜底。
func (c *CommentTreeCache) SetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string, items []forumcontracts.ForumCommentNode, pageResult forumcontracts.PageResult) error {
if c == nil || c.client == nil {
return errors.New("评论树缓存未初始化")
}
version, err := c.currentCommentTreeVersion(ctx, postID)
if err != nil {
return err
}
if items == nil {
items = []forumcontracts.ForumCommentNode{}
}
data, err := json.Marshal(commentTreeCachePayload{
Items: items,
Page: pageResult,
})
if err != nil {
return err
}
return c.client.Set(ctx, commentTreeDataKey(postID, version, sort, page, pageSize), data, c.ttl).Err()
}
// BumpCommentTreeVersion 递增帖子评论树版本号,让旧分页缓存自然失效。
//
// 职责边界:
// 1. 只做版本递增,不扫描删除旧 data key避免写评论时阻塞 Redis
// 2. 旧 data key 依赖短 TTL 自动回收;
// 3. 当 version key 不存在时 INCR 会从 1 开始,能够让默认 v0 缓存失效。
func (c *CommentTreeCache) BumpCommentTreeVersion(ctx context.Context, postID uint64) error {
if c == nil || c.client == nil {
return errors.New("评论树缓存未初始化")
}
return c.client.Incr(ctx, commentTreeVersionKey(postID)).Err()
}
func (c *CommentTreeCache) currentCommentTreeVersion(ctx context.Context, postID uint64) (int64, error) {
raw, err := c.client.Get(ctx, commentTreeVersionKey(postID)).Result()
if errors.Is(err, redis.Nil) {
return 0, nil
}
if err != nil {
return 0, err
}
version, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil {
return 0, err
}
if version < 0 {
return 0, nil
}
return version, nil
}

View File

@@ -2,6 +2,7 @@ package sv
import ( import (
"context" "context"
"log"
"strings" "strings"
"time" "time"
@@ -17,7 +18,7 @@ import (
// 职责边界: // 职责边界:
// 1. P0 按根评论分页,避免一次把超大评论区全部暴露给前端; // 1. P0 按根评论分页,避免一次把超大评论区全部暴露给前端;
// 2. 数据库存储仍是扁平 parent_comment_id树结构由 commenttree 包组装; // 2. 数据库存储仍是扁平 parent_comment_id树结构由 commenttree 包组装;
// 3. 不做评论缓存,新增、回复、删除后直接读库保持语义简单 // 3. 采用 cache-aside 缓存去个性化评论树,返回前再补当前用户的删除权限
func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sortBy string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, error) { func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID uint64, page int, pageSize int, sortBy string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, error) {
if err := s.Ready(); err != nil { if err := s.Ready(); err != nil {
return nil, forumcontracts.PageResult{}, err return nil, forumcontracts.PageResult{}, err
@@ -26,10 +27,15 @@ func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID u
return nil, forumcontracts.PageResult{}, respond.MissingParam return nil, forumcontracts.PageResult{}, respond.MissingParam
} }
page, pageSize = normalizePage(page, pageSize) page, pageSize = normalizePage(page, pageSize)
sortBy = normalizeCommentSort(sortBy)
if _, err := s.forumDAO.FindPublishedPost(ctx, postID); err != nil { if _, err := s.forumDAO.FindPublishedPost(ctx, postID); err != nil {
return nil, forumcontracts.PageResult{}, normalizeRecordNotFound(err, respond.UserTaskClassNotFound) return nil, forumcontracts.PageResult{}, normalizeRecordNotFound(err, respond.UserTaskClassNotFound)
} }
if cachedItems, cachedPage, hit := s.getCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy); hit {
return personalizeCommentNodesForActor(cachedItems, actorUserID), cachedPage, nil
}
total, err := s.forumDAO.CountRootComments(ctx, postID) total, err := s.forumDAO.CountRootComments(ctx, postID)
if err != nil { if err != nil {
return nil, forumcontracts.PageResult{}, err return nil, forumcontracts.PageResult{}, err
@@ -38,15 +44,19 @@ func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID u
if err != nil { if err != nil {
return nil, forumcontracts.PageResult{}, err return nil, forumcontracts.PageResult{}, err
} }
resultPage := pageResult(page, pageSize, total)
if len(roots) == 0 { if len(roots) == 0 {
return []forumcontracts.ForumCommentNode{}, pageResult(page, pageSize, total), nil emptyItems := []forumcontracts.ForumCommentNode{}
s.setCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy, emptyItems, resultPage)
return emptyItems, resultPage, nil
} }
allComments, err := s.forumDAO.ListCommentsByPostID(ctx, postID) allComments, err := s.forumDAO.ListCommentsByPostID(ctx, postID)
if err != nil { if err != nil {
return nil, forumcontracts.PageResult{}, err return nil, forumcontracts.PageResult{}, err
} }
nodes := commenttree.BuildForumCommentTree(filterCommentsForRoots(allComments, roots), actorUserID) sharedNodes := commenttree.BuildForumCommentTree(filterCommentsForRoots(allComments, roots), 0)
return nodes, pageResult(page, pageSize, total), nil s.setCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy, sharedNodes, resultPage)
return personalizeCommentNodesForActor(sharedNodes, actorUserID), resultPage, nil
} }
// CreateComment 创建帖子评论或多层回复。 // CreateComment 创建帖子评论或多层回复。
@@ -100,6 +110,7 @@ func (s *Service) CreateComment(ctx context.Context, req forumcontracts.CreateFo
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
s.bumpCommentTreeVersionBestEffort(req.PostID)
return commentModelToNode(created, req.ActorUserID), nil return commentModelToNode(created, req.ActorUserID), nil
} }
@@ -113,6 +124,7 @@ func (s *Service) DeleteComment(ctx context.Context, actorUserID uint64, comment
} }
var deletedAt *string var deletedAt *string
var changedPostID uint64
status := forummodel.ForumCommentStatusDeleted status := forummodel.ForumCommentStatusDeleted
if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error { if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error {
comment, err := txDAO.LockCommentByID(ctx, commentID) comment, err := txDAO.LockCommentByID(ctx, commentID)
@@ -133,11 +145,15 @@ func (s *Service) DeleteComment(ctx context.Context, actorUserID uint64, comment
if err := txDAO.AddPostCounter(ctx, comment.PostID, "comment_count", -1); err != nil { if err := txDAO.AddPostCounter(ctx, comment.PostID, "comment_count", -1); err != nil {
return err return err
} }
changedPostID = comment.PostID
deletedAt = formatTimePtr(&now) deletedAt = formatTimePtr(&now)
return nil return nil
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
if changedPostID != 0 {
s.bumpCommentTreeVersionBestEffort(changedPostID)
}
return &forumcontracts.DeleteForumCommentResult{ return &forumcontracts.DeleteForumCommentResult{
CommentID: commentID, CommentID: commentID,
Status: status, Status: status,
@@ -200,3 +216,69 @@ func collectDescendantCommentIDs(parentID uint64, comments []forummodel.ForumCom
collectDescendantCommentIDs(comment.ID, comments, result) collectDescendantCommentIDs(comment.ID, comments, result)
} }
} }
func normalizeCommentSort(sortBy string) string {
if strings.TrimSpace(sortBy) == "latest" {
return "latest"
}
return "oldest"
}
func (s *Service) getCommentTreeCacheBestEffort(ctx context.Context, postID uint64, page int, pageSize int, sortBy string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, bool) {
if s == nil || s.commentTreeCache == nil {
return nil, forumcontracts.PageResult{}, false
}
items, resultPage, hit, err := s.commentTreeCache.GetCommentTree(ctx, postID, page, pageSize, sortBy)
if err != nil {
log.Printf("评论树缓存读取失败,已降级回源 DB post_id=%d page=%d page_size=%d sort=%s err=%v", postID, page, pageSize, sortBy, err)
return nil, forumcontracts.PageResult{}, false
}
return items, resultPage, hit
}
func (s *Service) setCommentTreeCacheBestEffort(ctx context.Context, postID uint64, page int, pageSize int, sortBy string, items []forumcontracts.ForumCommentNode, resultPage forumcontracts.PageResult) {
if s == nil || s.commentTreeCache == nil {
return
}
if err := s.commentTreeCache.SetCommentTree(ctx, postID, page, pageSize, sortBy, items, resultPage); err != nil {
log.Printf("评论树缓存写入失败,已保持 DB 结果返回 post_id=%d page=%d page_size=%d sort=%s err=%v", postID, page, pageSize, sortBy, err)
}
}
func (s *Service) bumpCommentTreeVersionBestEffort(postID uint64) {
if s == nil || s.commentTreeCache == nil || postID == 0 {
return
}
// 1. 写库事务已经成功,缓存失效不应再反向影响评论发布/删除结果。
// 2. 使用独立短超时 context避免客户端取消请求后漏掉版本递增。
// 3. 失败时只记录日志,旧缓存依靠短 TTL 自然过期作为兜底。
cacheCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
if err := s.commentTreeCache.BumpCommentTreeVersion(cacheCtx, postID); err != nil {
log.Printf("评论树缓存版本递增失败,等待短 TTL 自然过期 post_id=%d err=%v", postID, err)
}
}
func personalizeCommentNodesForActor(nodes []forumcontracts.ForumCommentNode, actorUserID uint64) []forumcontracts.ForumCommentNode {
if nodes == nil {
return []forumcontracts.ForumCommentNode{}
}
result := make([]forumcontracts.ForumCommentNode, 0, len(nodes))
for _, node := range nodes {
result = append(result, personalizeCommentNodeForActor(node, actorUserID))
}
return result
}
func personalizeCommentNodeForActor(node forumcontracts.ForumCommentNode, actorUserID uint64) forumcontracts.ForumCommentNode {
children := make([]forumcontracts.ForumCommentNode, 0, len(node.Children))
for _, child := range node.Children {
children = append(children, personalizeCommentNodeForActor(child, actorUserID))
}
node.Children = children
node.CanDelete = actorUserID != 0 &&
node.Author.UserID == actorUserID &&
node.Status == forummodel.ForumCommentStatusVisible
return node
}

View File

@@ -9,6 +9,7 @@ import (
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events" sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -19,6 +20,18 @@ type transactionalEventPublisher interface {
PublishWithTx(ctx context.Context, tx *gorm.DB, req outboxinfra.PublishRequest) error PublishWithTx(ctx context.Context, tx *gorm.DB, req outboxinfra.PublishRequest) error
} }
// CommentTreeCachePort 是计划广场评论树缓存端口。
//
// 职责边界:
// 1. 只暴露“读分页树、写分页树、递增版本”三个能力,避免 service 依赖 Redis 细节;
// 2. 缓存内容必须是去个性化读模型,不能带入当前用户的 can_delete
// 3. Redis 异常不应影响主链路service 层会降级回源 DB。
type CommentTreeCachePort interface {
GetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string) ([]forumcontracts.ForumCommentNode, forumcontracts.PageResult, bool, error)
SetCommentTree(ctx context.Context, postID uint64, page int, pageSize int, sort string, items []forumcontracts.ForumCommentNode, pageResult forumcontracts.PageResult) error
BumpCommentTreeVersion(ctx context.Context, postID uint64) error
}
// TaskClassSnapshotPort 是计划广场读取和写入 TaskClass 快照的端口。 // TaskClassSnapshotPort 是计划广场读取和写入 TaskClass 快照的端口。
// //
// 职责边界: // 职责边界:
@@ -70,6 +83,7 @@ type Options struct {
DB *gorm.DB DB *gorm.DB
TaskClassPort TaskClassSnapshotPort TaskClassPort TaskClassSnapshotPort
EventPublisher outboxinfra.EventPublisher EventPublisher outboxinfra.EventPublisher
CommentTreeCache CommentTreeCachePort
} }
// Service 承载计划广场服务内部业务编排。 // Service 承载计划广场服务内部业务编排。
@@ -83,6 +97,7 @@ type Service struct {
forumDAO *forumdao.ForumDAO forumDAO *forumdao.ForumDAO
taskClassPort TaskClassSnapshotPort taskClassPort TaskClassSnapshotPort
eventPublisher outboxinfra.EventPublisher eventPublisher outboxinfra.EventPublisher
commentTreeCache CommentTreeCachePort
} }
func New(opts Options) *Service { func New(opts Options) *Service {
@@ -91,6 +106,7 @@ func New(opts Options) *Service {
forumDAO: forumdao.NewForumDAO(opts.DB), forumDAO: forumdao.NewForumDAO(opts.DB),
taskClassPort: opts.TaskClassPort, taskClassPort: opts.TaskClassPort,
eventPublisher: opts.EventPublisher, eventPublisher: opts.EventPublisher,
commentTreeCache: opts.CommentTreeCache,
} }
} }

View File

@@ -794,14 +794,17 @@ Token 侧:
### 10.7 缓存策略 ### 10.7 缓存策略
P0 不引入复杂缓存,优先靠表结构、索引和分页控制复杂度 P0 不引入复杂缓存,但评论区读多写少,评论树需要接短 TTL 缓存
1. 评论树 P0 不做整树缓存。评论是强互动数据,新增、回复、删除都会影响树结构,缓存失效成本高;当前场景多数用户看完即切,直接查库并组树更简单 1. 评论树采用 cache-aside + 版本号失效,缓存粒度为 `post_id + sort + page + page_size + version`。版本 key 为 `forum:comments:{post_id}:version`,数据 key 为 `forum:comments:{post_id}:v{version}:sort:{sort}:page:{page}:size:{page_size}`
2. 评论接口按根评论分页,后端读取当前页根评论及其子孙评论后组树,避免一次拉完整帖子全部评论 2. 缓存内容是“当前页根评论 + 子孙评论”组装后的去个性化评论树 JSON并连同分页结果一起缓存`can_delete` 这类当前用户视角字段不进共享缓存,返回前由 service 按 `actor_user_id` 补齐
3. 帖子列表和详情 P0 可先不缓存;如果出现热点,再对列表首屏或详情头部做短 TTL 缓存,并在点赞、评论、导入后按帖子维度失效 3. 新增评论、回复或删除评论的 DB 事务成功后,递增 `forum:comments:{post_id}:version`。旧 data key 不扫描删除,依赖短 TTL 自然回收,避免写评论时阻塞 Redis
4. 点赞数、评论数、导入数优先存 `forum_posts` 计数字段,写操作事务内增减,避免每次列表都聚合统计 4. Redis 读取、写入或版本递增失败都不影响主链路:读失败直接回源 DB写失败保持 DB 结果返回,版本递增失败则等待短 TTL 兜底
5. `token_products` 读取频率高、变化少,可做短 TTL 缓存;但 P0 直接读表也可以接受 5. 评论接口仍按根评论分页,后端只读取当前页根评论及其子孙评论后组树,避免一次拉完整帖子全部评论
6. 后续若上 Elasticsearch只缓存搜索索引不改变前端接口和论坛业务编排 6. 帖子列表和详情 P0 可先不缓存;如果出现热点,再对列表首屏或详情头部做短 TTL 缓存,并在点赞、评论、导入后按帖子维度失效
7. 点赞数、评论数、导入数优先存 `forum_posts` 计数字段,写操作事务内增减,避免每次列表都聚合统计。
8. `token_products` 读取频率高、变化少,可做短 TTL 缓存;但 P0 直接读表也可以接受。
9. 后续若上 Elasticsearch只缓存搜索索引不改变前端接口和论坛业务编排。
### 10.8 联调与验收 ### 10.8 联调与验收