diff --git a/docs/superpowers/plans/2026-05-14-inspiration-flow-cache-implementation-plan.md b/docs/superpowers/plans/2026-05-14-inspiration-flow-cache-implementation-plan.md new file mode 100644 index 0000000..e004440 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-inspiration-flow-cache-implementation-plan.md @@ -0,0 +1,346 @@ +# 灵感瀑布流 Redis 会话缓存实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现灵感瀑布流双向滚动的 Redis 会话级缓存,支持向左滚动时加载历史数据 + +**Architecture:** 使用 Redis Hash 存储会话级已展示数据,Key 格式 `inspiration_flow:{star_id}:{session_id}`,TTL 30分钟 + +**Tech Stack:** Go, github.com/redis/go-redis/v9, gin middleware + +--- + +## 文件结构 + +``` +backend/pkg/database/ +└── redis.go # 修改:添加灵感瀑布流缓存操作函数 + +backend/services/galleryService/ +├── service/gallery_service.go # 修改:使用 Redis 缓存实现双向滚动 +└── cache/inspiration_cache.go # 新建:灵感瀑布流缓存操作(可选) +``` + +--- + +### Task 1: 添加灵感瀑布流缓存操作到 Redis 模块 + +**Files:** +- Modify: `backend/pkg/database/redis.go` + +- [ ] **Step 1: 添加灵感瀑布流缓存 Key 前缀和结构体** + +```go +const ( + BlacklistKeyPrefix = "blacklist:token:" + InspirationFlowKeyPrefix = "inspiration_flow:" +) + +// InspirationFlowCacheEntry 单个展品缓存数据 +type InspirationFlowCacheEntry struct { + AssetID int64 `json:"asset_id"` + Name string `json:"name"` + CoverURL string `json:"cover_url"` + LikeCount int32 `json:"like_count"` + OwnerNickname string `json:"owner_nickname"` + Span int32 `json:"span"` + MaterialType string `json:"material_type"` +} + +// InspirationFlowCache 会话缓存结构 +type InspirationFlowCache struct { + DisplayedIDs []int64 `json:"displayed_ids"` // 已展示ID列表 + History map[int64]InspirationFlowCacheEntry `json:"history"` // 历史数据详情 +} +``` + +- [ ] **Step 2: 添加灵感瀑布流缓存操作函数** + +```go +// InspirationFlowKey 生成灵感瀑布流缓存 Key +func InspirationFlowKey(starID int64, sessionID string) string { + return fmt.Sprintf("%s%d:%s", InspirationFlowKeyPrefix, starID, sessionID) +} + +// GetInspirationFlowCache 获取灵感瀑布流会话缓存 +func GetInspirationFlowCache(ctx context.Context, starID int64, sessionID string) (*InspirationFlowCache, error) { + if RedisClient == nil { + return nil, fmt.Errorf("redis client is not initialized") + } + + key := InspirationFlowKey(starID, sessionID) + data, err := RedisClient.Get(ctx, key).Result() + if err == redis.Nil { + return &InspirationFlowCache{ + DisplayedIDs: []int64{}, + History: make(map[int64]InspirationFlowCacheEntry), + }, nil + } + if err != nil { + return nil, err + } + + var cache InspirationFlowCache + if err := json.Unmarshal([]byte(data), &cache); err != nil { + return nil, err + } + return &cache, nil +} + +// SaveInspirationFlowCache 保存灵感瀑布流会话缓存 +func SaveInspirationFlowCache(ctx context.Context, starID int64, sessionID string, cache *InspirationFlowCache, ttl time.Duration) error { + if RedisClient == nil { + return fmt.Errorf("redis client is not initialized") + } + + key := InspirationFlowKey(starID, sessionID) + data, err := json.Marshal(cache) + if err != nil { + return err + } + + return RedisClient.Set(ctx, key, data, ttl).Err() +} + +// AddToInspirationFlowCache 添加展品到会话缓存 +func AddToInspirationFlowCache(ctx context.Context, starID int64, sessionID string, entry InspirationFlowCacheEntry, ttl time.Duration) error { + cache, err := GetInspirationFlowCache(ctx, starID, sessionID) + if err != nil { + return err + } + + // 检查是否已存在 + for _, id := range cache.DisplayedIDs { + if id == entry.AssetID { + return nil // 已存在,跳过 + } + } + + // 添加到已展示列表 + cache.DisplayedIDs = append(cache.DisplayedIDs, entry.AssetID) + // 添加到历史详情 + if cache.History == nil { + cache.History = make(map[int64]InspirationFlowCacheEntry) + } + cache.History[entry.AssetID] = entry + + return SaveInspirationFlowCache(ctx, starID, sessionID, cache, ttl) +} + +// GetHistoryPage 获取历史数据的某一页 +func GetHistoryPage(cache *InspirationFlowCache, offset, limit int) []InspirationFlowCacheEntry { + if cache == nil || cache.History == nil { + return []InspirationFlowCacheEntry{} + } + + // 按展示顺序反向遍历(最新展示的在前面) + items := make([]InspirationFlowCacheEntry, 0) + for i := len(cache.DisplayedIDs) - 1; i >= 0; i-- { + if entry, ok := cache.History[cache.DisplayedIDs[i]]; ok { + items = append(items, entry) + } + } + + // 分页 + start := offset + end := offset + limit + if start >= len(items) { + return []InspirationFlowCacheEntry{} + } + if end > len(items) { + end = len(items) + } + return items[start:end] +} +``` + +- [ ] **Step 3: 提交代码** + +```bash +git add backend/pkg/database/redis.go +git commit -m "feat: 添加灵感瀑布流 Redis 会话缓存操作 + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +### Task 2: 修改 gallery_service.go 实现双向滚动 + +**Files:** +- Modify: `backend/services/galleryService/service/gallery_service.go` + +- [ ] **Step 1: 添加 database 包导入** + +确认已导入 `database` 包(如果没有则添加) + +- [ ] **Step 2: 修改 GetInspirationFlow 函数实现双向滚动** + +修改 `GetInspirationFlow` 函数,在向右滚动时更新缓存,在向左滚动时从缓存读取历史数据: + +**向右滚动(direction=right)修改后:** +```go +// 向右滚动:从仓库随机查询新数据(排除已展示),并更新缓存 +if direction == "right" { + // 获取缓存的已展示ID列表 + var excludeIDs []int64 + if sessionID != "" { + cache, err := database.GetInspirationFlowCache(ctx, starID, sessionID) + if err == nil && cache != nil { + excludeIDs = cache.DisplayedIDs + } + } + + items, err := s.repo.GetRandomExhibitions(starID, materialType, excludeIDs, int(limit), 0) + if err != nil { + logger.Logger.Warn("GetInspirationFlow failed", + zap.Int64("star_id", starID), + zap.String("direction", direction), + zap.Error(err), + ) + return nil, "", err + } + + // 转换为pb并更新缓存 + pbItems := make([]*pb.InspirationFlowItem, 0, len(items)) + for _, item := range items { + pbItems = append(pbItems, &pb.InspirationFlowItem{ + AssetId: item.AssetID, + Name: item.Name, + CoverUrl: item.CoverURL, + LikeCount: item.LikeCount, + OwnerNickname: item.OwnerNickname, + Span: item.Span, + MaterialType: item.MaterialType, + }) + + // 更新缓存 + if sessionID != "" { + cacheEntry := database.InspirationFlowCacheEntry{ + AssetID: item.AssetID, + Name: item.Name, + CoverURL: item.CoverURL, + LikeCount: item.LikeCount, + OwnerNickname: item.OwnerNickname, + Span: item.Span, + MaterialType: item.MaterialType, + } + // 忽略错误,不影响主流程 + _ = database.AddToInspirationFlowCache(ctx, starID, sessionID, cacheEntry, 30*time.Minute) + } + } + + // 生成新游标 + newCursor := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"limit":%d}`, decodedLimit))) + + // 检查是否还有更多数据 + total, err := s.repo.CountValidExhibitions(starID, materialType) + hasMore := int64(len(items)) < total + + return &pb.InspirationFlowData{ + Items: pbItems, + Cursor: newCursor, + HasMore: hasMore, + SessionId: sessionID, + }, sessionID, nil +} +``` + +**向左滚动(direction=left)修改后:** +```go +// 向左滚动:从缓存的历史数据中分页返回 +if direction == "left" { + if sessionID == "" { + return &pb.InspirationFlowData{ + Items: []*pb.InspirationFlowItem{}, + Cursor: cursor, + HasMore: false, + SessionId: sessionID, + }, sessionID, nil + } + + // 获取缓存 + cache, err := database.GetInspirationFlowCache(ctx, starID, sessionID) + if err != nil || cache == nil || len(cache.DisplayedIDs) == 0 { + return &pb.InspirationFlowData{ + Items: []*pb.InspirationFlowItem{}, + Cursor: cursor, + HasMore: false, + SessionId: sessionID, + }, sessionID, nil + } + + // 解析 offset 从 cursor 中 + offset := 0 + if cursor != "" { + decoded, err := base64.StdEncoding.DecodeString(cursor) + if err == nil { + var cursorData map[string]interface{} + if json.Unmarshal(decoded, &cursorData) == nil { + if o, ok := cursorData["offset"].(float64); ok { + offset = int(o) + } + } + } + } + + // 获取分页数据 + historyItems := database.GetHistoryPage(cache, offset, int(limit)) + hasMore := offset+len(historyItems) < len(cache.DisplayedIDs) + + // 转换为 pb + pbItems := make([]*pb.InspirationFlowItem, 0, len(historyItems)) + for _, item := range historyItems { + pbItems = append(pbItems, &pb.InspirationFlowItem{ + AssetId: item.AssetID, + Name: item.Name, + CoverUrl: item.CoverURL, + LikeCount: item.LikeCount, + OwnerNickname: item.OwnerNickname, + Span: item.Span, + MaterialType: item.MaterialType, + }) + } + + // 生成新游标(包含 offset) + newCursor := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"limit":%d,"offset":%d}`, decodedLimit, offset+len(historyItems)))) + + return &pb.InspirationFlowData{ + Items: pbItems, + Cursor: newCursor, + HasMore: hasMore, + SessionId: sessionID, + }, sessionID, nil +} +``` + +- [ ] **Step 3: 添加 time 包导入(如果尚未导入)** + +- [ ] **Step 4: 提交代码** + +```bash +git add backend/services/galleryService/service/gallery_service.go +git commit -m "feat: 实现灵感瀑布流双向滚动 Redis 缓存 + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +### Task 3: 验证构建 + +- [ ] **Step 1: 运行 go build 验证代码编译** + +```bash +cd /Users/liulujian/Documents/code/TopFansByGithub/backend && go build ./... +``` + +Expected: 编译成功,无错误 + +--- + +## 变更记录 + +| 日期 | 变更内容 | +|------|---------| +| 2026-05-14 | 初始实现计划 | \ No newline at end of file