From 2204fac84eed35fb68180d4c00dcbd06a98cce08 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Tue, 5 May 2026 11:10:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E8=AE=BA=E5=9D=9B?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E6=A0=91=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/taskclassforum/main.go | 14 +- backend/services/taskclassforum/dao/cache.go | 146 ++++++++++++++++++ backend/services/taskclassforum/sv/comment.go | 90 ++++++++++- backend/services/taskclassforum/sv/service.go | 38 +++-- .../计划广场与Token商店后端实施方案.md | 17 +- 5 files changed, 280 insertions(+), 25 deletions(-) create mode 100644 backend/services/taskclassforum/dao/cache.go diff --git a/backend/cmd/taskclassforum/main.go b/backend/cmd/taskclassforum/main.go index 8f98b31..ea0f66e 100644 --- a/backend/cmd/taskclassforum/main.go +++ b/backend/cmd/taskclassforum/main.go @@ -6,6 +6,7 @@ import ( "github.com/LoveLosita/smartflow/backend/bootstrap" legacydao "github.com/LoveLosita/smartflow/backend/dao" outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" + "github.com/LoveLosita/smartflow/backend/inits" "github.com/LoveLosita/smartflow/backend/services/taskclassforum/adapter" forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" forumrpc "github.com/LoveLosita/smartflow/backend/services/taskclassforum/rpc" @@ -32,10 +33,17 @@ func main() { // 3. 后续 task-class 独立成服务后,只替换这里的 adapter 注入点。 taskClassPort := adapter.NewLegacyTaskClassAdapter(legacydao.NewTaskClassDAO(db)) 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{ - DB: db, - TaskClassPort: taskClassPort, - EventPublisher: eventPublisher, + DB: db, + TaskClassPort: taskClassPort, + EventPublisher: eventPublisher, + CommentTreeCache: commentTreeCache, }) forumrpc.Start(forumrpc.ServerOptions{ ListenOn: viper.GetString("taskclassforum.rpc.listenOn"), diff --git a/backend/services/taskclassforum/dao/cache.go b/backend/services/taskclassforum/dao/cache.go new file mode 100644 index 0000000..736db78 --- /dev/null +++ b/backend/services/taskclassforum/dao/cache.go @@ -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 +} diff --git a/backend/services/taskclassforum/sv/comment.go b/backend/services/taskclassforum/sv/comment.go index 6f1333b..e12dda5 100644 --- a/backend/services/taskclassforum/sv/comment.go +++ b/backend/services/taskclassforum/sv/comment.go @@ -2,6 +2,7 @@ package sv import ( "context" + "log" "strings" "time" @@ -17,7 +18,7 @@ import ( // 职责边界: // 1. P0 按根评论分页,避免一次把超大评论区全部暴露给前端; // 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) { if err := s.Ready(); err != nil { 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 } page, pageSize = normalizePage(page, pageSize) + sortBy = normalizeCommentSort(sortBy) if _, err := s.forumDAO.FindPublishedPost(ctx, postID); err != nil { 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) if err != nil { return nil, forumcontracts.PageResult{}, err @@ -38,15 +44,19 @@ func (s *Service) ListComments(ctx context.Context, actorUserID uint64, postID u if err != nil { return nil, forumcontracts.PageResult{}, err } + resultPage := pageResult(page, pageSize, total) 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) if err != nil { return nil, forumcontracts.PageResult{}, err } - nodes := commenttree.BuildForumCommentTree(filterCommentsForRoots(allComments, roots), actorUserID) - return nodes, pageResult(page, pageSize, total), nil + sharedNodes := commenttree.BuildForumCommentTree(filterCommentsForRoots(allComments, roots), 0) + s.setCommentTreeCacheBestEffort(ctx, postID, page, pageSize, sortBy, sharedNodes, resultPage) + return personalizeCommentNodesForActor(sharedNodes, actorUserID), resultPage, nil } // CreateComment 创建帖子评论或多层回复。 @@ -100,6 +110,7 @@ func (s *Service) CreateComment(ctx context.Context, req forumcontracts.CreateFo }); err != nil { return nil, err } + s.bumpCommentTreeVersionBestEffort(req.PostID) return commentModelToNode(created, req.ActorUserID), nil } @@ -113,6 +124,7 @@ func (s *Service) DeleteComment(ctx context.Context, actorUserID uint64, comment } var deletedAt *string + var changedPostID uint64 status := forummodel.ForumCommentStatusDeleted if err := s.forumDAO.Transaction(ctx, func(txDAO *forumdao.ForumDAO) error { 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 { return err } + changedPostID = comment.PostID deletedAt = formatTimePtr(&now) return nil }); err != nil { return nil, err } + if changedPostID != 0 { + s.bumpCommentTreeVersionBestEffort(changedPostID) + } return &forumcontracts.DeleteForumCommentResult{ CommentID: commentID, Status: status, @@ -200,3 +216,69 @@ func collectDescendantCommentIDs(parentID uint64, comments []forummodel.ForumCom 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 +} diff --git a/backend/services/taskclassforum/sv/service.go b/backend/services/taskclassforum/sv/service.go index b1daa55..3a3d668 100644 --- a/backend/services/taskclassforum/sv/service.go +++ b/backend/services/taskclassforum/sv/service.go @@ -9,6 +9,7 @@ import ( outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" 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" "gorm.io/gorm" ) @@ -19,6 +20,18 @@ type transactionalEventPublisher interface { 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 快照的端口。 // // 职责边界: @@ -67,9 +80,10 @@ type CreatedTaskClass struct { // Options 是计划广场服务的依赖注入参数。 type Options struct { - DB *gorm.DB - TaskClassPort TaskClassSnapshotPort - EventPublisher outboxinfra.EventPublisher + DB *gorm.DB + TaskClassPort TaskClassSnapshotPort + EventPublisher outboxinfra.EventPublisher + CommentTreeCache CommentTreeCachePort } // Service 承载计划广场服务内部业务编排。 @@ -79,18 +93,20 @@ type Options struct { // 2. 不负责 HTTP 参数绑定,也不直接返回 respond.Response; // 3. 不持有 TaskClass 原表,只通过 TaskClassSnapshotPort 读取和创建副本。 type Service struct { - db *gorm.DB - forumDAO *forumdao.ForumDAO - taskClassPort TaskClassSnapshotPort - eventPublisher outboxinfra.EventPublisher + db *gorm.DB + forumDAO *forumdao.ForumDAO + taskClassPort TaskClassSnapshotPort + eventPublisher outboxinfra.EventPublisher + commentTreeCache CommentTreeCachePort } func New(opts Options) *Service { return &Service{ - db: opts.DB, - forumDAO: forumdao.NewForumDAO(opts.DB), - taskClassPort: opts.TaskClassPort, - eventPublisher: opts.EventPublisher, + db: opts.DB, + forumDAO: forumdao.NewForumDAO(opts.DB), + taskClassPort: opts.TaskClassPort, + eventPublisher: opts.EventPublisher, + commentTreeCache: opts.CommentTreeCache, } } diff --git a/docs/backend/计划广场与Token商店后端实施方案.md b/docs/backend/计划广场与Token商店后端实施方案.md index 2d1bd8e..b08c39a 100644 --- a/docs/backend/计划广场与Token商店后端实施方案.md +++ b/docs/backend/计划广场与Token商店后端实施方案.md @@ -794,14 +794,17 @@ Token 侧: ### 10.7 缓存策略 -P0 不引入复杂缓存,优先靠表结构、索引和分页控制复杂度: +P0 不引入复杂缓存,但评论区读多写少,评论树需要接短 TTL 缓存: -1. 评论树 P0 不做整树缓存。评论是强互动数据,新增、回复、删除都会影响树结构,缓存失效成本高;当前场景多数用户看完即切,直接查库并组树更简单。 -2. 评论接口按根评论分页,后端读取当前页根评论及其子孙评论后组树,避免一次拉完整帖子全部评论。 -3. 帖子列表和详情 P0 可先不缓存;如果出现热点,再对列表首屏或详情头部做短 TTL 缓存,并在点赞、评论、导入后按帖子维度失效。 -4. 点赞数、评论数、导入数优先存 `forum_posts` 计数字段,写操作事务内增减,避免每次列表都聚合统计。 -5. `token_products` 读取频率高、变化少,可做短 TTL 缓存;但 P0 直接读表也可以接受。 -6. 后续若上 Elasticsearch,只缓存搜索索引,不改变前端接口和论坛业务编排。 +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. 缓存内容是“当前页根评论 + 子孙评论”组装后的去个性化评论树 JSON,并连同分页结果一起缓存;`can_delete` 这类当前用户视角字段不进共享缓存,返回前由 service 按 `actor_user_id` 补齐。 +3. 新增评论、回复或删除评论的 DB 事务成功后,递增 `forum:comments:{post_id}:version`。旧 data key 不扫描删除,依赖短 TTL 自然回收,避免写评论时阻塞 Redis。 +4. Redis 读取、写入或版本递增失败都不影响主链路:读失败直接回源 DB,写失败保持 DB 结果返回,版本递增失败则等待短 TTL 兜底。 +5. 评论接口仍按根评论分页,后端只读取当前页根评论及其子孙评论后组树,避免一次拉完整帖子全部评论。 +6. 帖子列表和详情 P0 可先不缓存;如果出现热点,再对列表首屏或详情头部做短 TTL 缓存,并在点赞、评论、导入后按帖子维度失效。 +7. 点赞数、评论数、导入数优先存 `forum_posts` 计数字段,写操作事务内增减,避免每次列表都聚合统计。 +8. `token_products` 读取频率高、变化少,可做短 TTL 缓存;但 P0 直接读表也可以接受。 +9. 后续若上 Elasticsearch,只缓存搜索索引,不改变前端接口和论坛业务编排。 ### 10.8 联调与验收