feat: 接入论坛评论树缓存
This commit is contained in:
@@ -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"),
|
||||||
|
|||||||
146
backend/services/taskclassforum/dao/cache.go
Normal file
146
backend/services/taskclassforum/dao/cache.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 联调与验收
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user