Compare commits
12 Commits
e500bff6ff
...
61921372fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61921372fb | ||
|
|
7574d38e96 | ||
|
|
f154555c4c | ||
|
|
c026e3b8e7 | ||
|
|
a1b42b9ccd | ||
|
|
962142c4a5 | ||
|
|
eafe6c2f6b | ||
|
|
848908775a | ||
|
|
17674e3d54 | ||
|
|
a7249110fe | ||
|
|
c38ad5a654 | ||
|
|
00a39e1819 |
@ -12,9 +12,18 @@ type Config struct {
|
||||
Dubbo DubboConfig
|
||||
JWT JWTConfig
|
||||
OSS OSSConfig
|
||||
Redis RedisConfig // 新增
|
||||
Root string
|
||||
}
|
||||
|
||||
// RedisConfig Redis 配置
|
||||
type RedisConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Password string
|
||||
DB int
|
||||
}
|
||||
|
||||
// ServerConfig 服务器配置
|
||||
type ServerConfig struct {
|
||||
Port string
|
||||
@ -92,6 +101,12 @@ func Load() *Config {
|
||||
AssetDir: getEnv("OSS_ASSET_DIR", "asset/"),
|
||||
TokenExpireTime: getEnvInt("OSS_TOKEN_EXPIRE_TIME", 3600),
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
Host: getEnv("REDIS_HOST", "127.0.0.1"),
|
||||
Port: getEnvInt("REDIS_PORT", 6379),
|
||||
Password: getEnv("REDIS_PASSWORD", ""),
|
||||
DB: getEnvInt("REDIS_DB", 0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -89,7 +89,7 @@ require (
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/k0kubun/pp v3.0.1+incompatible // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/knadh/koanf v1.5.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
@ -122,6 +122,7 @@ require (
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.52.0 // indirect
|
||||
github.com/redis/go-redis/v9 v9.19.0 // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.22.2 // indirect
|
||||
github.com/sirupsen/logrus v1.8.1 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
@ -154,7 +155,7 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.0.0 // indirect
|
||||
go.uber.org/atomic v1.10.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
|
||||
@ -557,6 +557,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs=
|
||||
github.com/knadh/koanf v1.5.0/go.mod h1:Hgyjp4y8v44hpZtPzs7JZfRAW5AhN7KfZcwv1RYggDs=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
@ -765,6 +767,8 @@ github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1
|
||||
github.com/quic-go/quic-go v0.52.0 h1:/SlHrCRElyaU6MaEPKqKr9z83sBg2v4FLLvWM+Z47pA=
|
||||
github.com/quic-go/quic-go v0.52.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
|
||||
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/rhnvrm/simples3 v0.6.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
@ -962,6 +966,8 @@ go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/topfans/backend/gateway/config"
|
||||
"github.com/topfans/backend/gateway/router"
|
||||
"github.com/topfans/backend/pkg/database"
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
|
||||
@ -66,6 +67,19 @@ func main() {
|
||||
// 3. 设置 Gin 模式
|
||||
gin.SetMode(cfg.Server.Mode)
|
||||
|
||||
// 3.5 初始化 Redis
|
||||
logger.Logger.Info("Connecting to Redis...")
|
||||
if err := database.InitRedis(database.RedisConfig{
|
||||
Host: cfg.Redis.Host,
|
||||
Port: cfg.Redis.Port,
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
}); err != nil {
|
||||
logger.Logger.Fatal("Failed to connect to Redis", zap.Error(err))
|
||||
}
|
||||
logger.Logger.Info("Redis connected successfully")
|
||||
defer database.CloseRedis()
|
||||
|
||||
// 4. 初始化 Dubbo Clients
|
||||
logger.Logger.Info("Connecting to Dubbo services...")
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/topfans/backend/gateway/pkg/response"
|
||||
"github.com/topfans/backend/pkg/database"
|
||||
"github.com/topfans/backend/pkg/jwt"
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
@ -46,7 +47,30 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 将用户信息存入 gin.Context
|
||||
// 4. 检查 Token 是否在黑名单
|
||||
isBlacklisted, bannedUserID, banReason, err := database.IsBlacklisted(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
// Redis 错误时 fail-closed(安全策略),拒绝请求
|
||||
logger.Logger.Error("Failed to check blacklist, rejecting request for security",
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.Error(err),
|
||||
)
|
||||
response.Unauthorized(c, "认证服务异常,请稍后重试")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if isBlacklisted {
|
||||
logger.Logger.Warn("Token is blacklisted",
|
||||
zap.Int64("banned_user_id", bannedUserID),
|
||||
zap.String("ban_reason", banReason),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
)
|
||||
response.Unauthorized(c, "账号已被封禁")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 将用户信息存入 gin.Context
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("star_id", claims.StarID)
|
||||
c.Set("token_updated_at", claims.UpdatedAt)
|
||||
|
||||
251
backend/pkg/database/redis.go
Normal file
251
backend/pkg/database/redis.go
Normal file
@ -0,0 +1,251 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
BlacklistKeyPrefix = "blacklist:token:"
|
||||
InspirationFlowKeyPrefix = "inspiration_flow:"
|
||||
)
|
||||
|
||||
// RedisClient Redis 客户端单例
|
||||
var RedisClient *redis.Client
|
||||
|
||||
// Config Redis 配置
|
||||
type RedisConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Password string
|
||||
DB int
|
||||
}
|
||||
|
||||
// InitRedis 初始化 Redis 连接
|
||||
func InitRedis(cfg RedisConfig) error {
|
||||
RedisClient = redis.NewClient(&redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := RedisClient.Ping(ctx).Err(); err != nil {
|
||||
return fmt.Errorf("failed to connect to redis: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseRedis 关闭 Redis 连接
|
||||
func CloseRedis() error {
|
||||
if RedisClient != nil {
|
||||
return RedisClient.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRedis 获取 Redis 客户端实例
|
||||
func GetRedis() *redis.Client {
|
||||
return RedisClient
|
||||
}
|
||||
|
||||
// RedisHealthCheck 健康检查
|
||||
func RedisHealthCheck() error {
|
||||
if RedisClient == nil {
|
||||
return fmt.Errorf("redis client is not initialized")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return RedisClient.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
// BlacklistEntry 黑名单条目
|
||||
type BlacklistEntry struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// tokenToHash 将 Token 转换为 SHA256 哈希作为 Key
|
||||
func tokenToHash(token string) string {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// AddToBlacklist 添加 Token 到黑名单
|
||||
func AddToBlacklist(ctx context.Context, token string, userID int64, banReason string, ttl time.Duration) error {
|
||||
if token == "" {
|
||||
return fmt.Errorf("token is empty")
|
||||
}
|
||||
if RedisClient == nil {
|
||||
return fmt.Errorf("redis client is not initialized")
|
||||
}
|
||||
|
||||
key := BlacklistKeyPrefix + tokenToHash(token)
|
||||
entry := BlacklistEntry{UserID: userID, Reason: banReason}
|
||||
value, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal blacklist entry: %w", err)
|
||||
}
|
||||
|
||||
return RedisClient.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
|
||||
// IsBlacklisted 检查 Token 是否在黑名单
|
||||
func IsBlacklisted(ctx context.Context, token string) (bool, int64, string, error) {
|
||||
if token == "" {
|
||||
return false, 0, "", nil
|
||||
}
|
||||
if RedisClient == nil {
|
||||
return false, 0, "", fmt.Errorf("redis client is not initialized")
|
||||
}
|
||||
|
||||
key := BlacklistKeyPrefix + tokenToHash(token)
|
||||
value, err := RedisClient.Get(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
return false, 0, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, 0, "", err
|
||||
}
|
||||
|
||||
var entry BlacklistEntry
|
||||
if err := json.Unmarshal([]byte(value), &entry); err != nil {
|
||||
return false, 0, "", fmt.Errorf("failed to unmarshal blacklist entry: %w", err)
|
||||
}
|
||||
|
||||
return true, entry.UserID, entry.Reason, nil
|
||||
}
|
||||
|
||||
// RemoveFromBlacklist 从黑名单移除 Token(用于解封)
|
||||
func RemoveFromBlacklist(ctx context.Context, token string) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
if RedisClient == nil {
|
||||
return fmt.Errorf("redis client is not initialized")
|
||||
}
|
||||
|
||||
key := BlacklistKeyPrefix + tokenToHash(token)
|
||||
return RedisClient.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// 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"` // 历史数据详情
|
||||
}
|
||||
|
||||
// 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]
|
||||
}
|
||||
489
backend/services/galleryService/service/gallery_service.go
Normal file
489
backend/services/galleryService/service/gallery_service.go
Normal file
@ -0,0 +1,489 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/topfans/backend/pkg/database"
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
"github.com/topfans/backend/pkg/models"
|
||||
pb "github.com/topfans/backend/pkg/proto/gallery"
|
||||
"github.com/topfans/backend/services/galleryService/client"
|
||||
"github.com/topfans/backend/services/galleryService/config"
|
||||
"github.com/topfans/backend/services/galleryService/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GalleryService 展馆服务接口
|
||||
type GalleryService interface {
|
||||
GetMyGallery(userID, starID int64) (*pb.GalleryData, error)
|
||||
GetUserGallery(userID, starID, targetUID int64) (*pb.GalleryData, error)
|
||||
GetInspirationFlow(userID, starID int64, cursor, direction string, limit int32, materialType, sessionID string) (*pb.InspirationFlowData, string, error)
|
||||
}
|
||||
|
||||
// galleryService 展馆服务实现
|
||||
type galleryService struct {
|
||||
repo repository.GalleryRepository
|
||||
assetClient client.AssetRPCClient
|
||||
userClient client.UserRPCClient
|
||||
}
|
||||
|
||||
// NewGalleryService 创建展馆服务实例
|
||||
func NewGalleryService(repo repository.GalleryRepository, assetClient client.AssetRPCClient, userClient client.UserRPCClient) GalleryService {
|
||||
return &galleryService{
|
||||
repo: repo,
|
||||
assetClient: assetClient,
|
||||
userClient: userClient,
|
||||
}
|
||||
}
|
||||
|
||||
// GetMyGallery 获取我的展馆(包含懒加载逻辑)
|
||||
func (s *galleryService) GetMyGallery(userID, starID int64) (*pb.GalleryData, error) {
|
||||
// 1. 获取粉丝档案信息(包含真实ID用于外键)
|
||||
profile, err := s.userClient.GetFanProfile(userID, starID)
|
||||
if err != nil {
|
||||
logger.Logger.Warn("Failed to get fan profile",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int64("star_id", starID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hostProfileID := profile.ID
|
||||
|
||||
// 2. 查询展位列表
|
||||
slots, err := s.repo.GetSlotsByUser(userID, starID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. 如果不存在展位,懒加载创建初始展位
|
||||
if len(slots) == 0 {
|
||||
if err := s.repo.CreateInitialSlots(userID, starID, hostProfileID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 重新查询
|
||||
slots, err = s.repo.GetSlotsByUser(userID, starID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 填充展位信息和展品信息
|
||||
slotInfos, err := s.buildSlotInfos(slots, userID, starID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. 构造响应(使用 userID 而不是 hostProfileID,以便前端正确判断是否是自己的展馆)
|
||||
galleryData := &pb.GalleryData{
|
||||
GalleryOwnerId: userID,
|
||||
SlotTotal: int32(len(slots)),
|
||||
Slots: slotInfos,
|
||||
Nickname: profile.Nickname,
|
||||
}
|
||||
|
||||
return galleryData, nil
|
||||
}
|
||||
|
||||
// GetUserGallery 获取他人展馆
|
||||
func (s *galleryService) GetUserGallery(userID, starID, targetUID int64) (*pb.GalleryData, error) {
|
||||
// 1. 获取目标用户的粉丝档案信息(包含真实ID用于外键)
|
||||
profile, err := s.userClient.GetFanProfile(targetUID, starID)
|
||||
if err != nil {
|
||||
logger.Logger.Warn("Failed to get fan profile",
|
||||
zap.Int64("target_uid", targetUID),
|
||||
zap.Int64("star_id", starID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hostProfileID := profile.ID
|
||||
|
||||
// 2. 查询目标用户的展位列表
|
||||
slots, err := s.repo.GetSlotsByUser(targetUID, starID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. 如果不存在展位:为目标用户创建初始展位(访问他人展馆也应返回真实展馆结构)
|
||||
if len(slots) == 0 {
|
||||
if err := s.repo.CreateInitialSlots(targetUID, starID, hostProfileID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 重新查询
|
||||
slots, err = s.repo.GetSlotsByUser(targetUID, starID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 填充展位信息和展品信息
|
||||
slotInfos, err := s.buildSlotInfos(slots, userID, starID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. 构造响应(使用 targetUID 而不是 hostProfileID,保持与 GetMyGallery 一致)
|
||||
galleryData := &pb.GalleryData{
|
||||
GalleryOwnerId: targetUID,
|
||||
SlotTotal: int32(len(slots)),
|
||||
Slots: slotInfos,
|
||||
Nickname: profile.Nickname,
|
||||
}
|
||||
|
||||
return galleryData, nil
|
||||
}
|
||||
|
||||
// buildSlotInfos 构建展位信息列表
|
||||
func (s *galleryService) buildSlotInfos(slots []*models.BoothSlot, viewerUID, viewerStarID int64, isOwner bool) ([]*pb.SlotInfo, error) {
|
||||
slotInfos := make([]*pb.SlotInfo, 0, len(slots))
|
||||
|
||||
for _, slot := range slots {
|
||||
slotInfo := &pb.SlotInfo{
|
||||
SlotId: slot.SlotID,
|
||||
SlotIndex: int32(slot.SlotIndex),
|
||||
IsEnabled: slot.IsEnabled,
|
||||
Visibility: slot.Visibility,
|
||||
}
|
||||
|
||||
var exhibition *models.Exhibition
|
||||
|
||||
// 确定展位状态
|
||||
if !slot.IsEnabled {
|
||||
// 未解锁
|
||||
slotInfo.Status = "LOCKED"
|
||||
// 添加解锁条件
|
||||
slotInfo.UnlockCondition = s.getUnlockCondition(slot.SlotIndex)
|
||||
} else {
|
||||
// 已解锁,查询是否有展品
|
||||
var err error
|
||||
exhibition, err = s.repo.GetExhibitionBySlot(slot.SlotID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if exhibition == nil {
|
||||
// 空展位
|
||||
slotInfo.Status = "EMPTY"
|
||||
} else {
|
||||
// 已占用
|
||||
slotInfo.Status = "OCCUPIED"
|
||||
slotInfo.OccupierUid = exhibition.OccupierUID
|
||||
slotInfo.OccupiedAt = exhibition.StartTime
|
||||
slotInfo.ExpireAt = exhibition.ExpireAt
|
||||
|
||||
// 填充资产信息(从Asset Service获取)
|
||||
// 使用资产的真正所有者(occupier_uid)来查询资产信息
|
||||
// 因为 Asset Service 的 GetAsset 需要验证所有权
|
||||
assetInfo, err := s.getAssetInfo(exhibition.AssetID, exhibition.OccupierUID, exhibition.OccupierStarID)
|
||||
if err != nil {
|
||||
// 如果获取资产信息失败,记录日志但不中断流程
|
||||
logger.Logger.Warn("Failed to get asset info for exhibition",
|
||||
zap.Int64("asset_id", exhibition.AssetID),
|
||||
zap.Int64("slot_id", slot.SlotID),
|
||||
zap.Int64("occupier_uid", exhibition.OccupierUID),
|
||||
zap.Int64("occupier_star_id", exhibition.OccupierStarID),
|
||||
zap.Error(err),
|
||||
)
|
||||
slotInfo.Asset = &pb.AssetInfo{
|
||||
AssetId: exhibition.AssetID,
|
||||
Name: "未知资产",
|
||||
}
|
||||
} else {
|
||||
slotInfo.Asset = assetInfo
|
||||
// 计算剩余时间
|
||||
now := time.Now().UnixMilli()
|
||||
remainTime := (exhibition.ExpireAt - now) / 1000 // 转换为秒
|
||||
if remainTime < 0 {
|
||||
remainTime = 0
|
||||
}
|
||||
slotInfo.Asset.RemainTime = remainTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算操作权限
|
||||
slotInfo.CanOperate, slotInfo.Operation = s.calculateOperation(slot, exhibition, viewerUID, isOwner)
|
||||
|
||||
slotInfos = append(slotInfos, slotInfo)
|
||||
}
|
||||
|
||||
return slotInfos, nil
|
||||
}
|
||||
|
||||
// calculateOperation 计算展位操作权限
|
||||
// 返回 (canOperate bool, operation string)
|
||||
// operation 取值: "place" | "remove" | "none"
|
||||
func (s *galleryService) calculateOperation(slot *models.BoothSlot, exhibition *models.Exhibition, viewerUID int64, isOwner bool) (bool, string) {
|
||||
logger.Logger.Info("=== calculateOperation DEBUG ===",
|
||||
zap.Int64("slot_id", slot.SlotID),
|
||||
zap.String("visibility", slot.Visibility),
|
||||
zap.Bool("is_owner", isOwner),
|
||||
zap.Int64("viewer_uid", viewerUID),
|
||||
)
|
||||
if exhibition != nil {
|
||||
logger.Logger.Info("=== exhibition info ===",
|
||||
zap.Int64("exhibition_id", exhibition.ID),
|
||||
zap.Int64("occupier_uid", exhibition.OccupierUID),
|
||||
)
|
||||
}
|
||||
|
||||
// 未解锁的展位不能操作
|
||||
if !slot.IsEnabled {
|
||||
return false, "none"
|
||||
}
|
||||
|
||||
// 私有展位 (我的/他的展位)
|
||||
if slot.Visibility == "private" {
|
||||
// 所有者访问自己展馆
|
||||
if isOwner {
|
||||
if exhibition == nil {
|
||||
return true, "place" // 空展位,可以放置
|
||||
}
|
||||
// 有藏品时,可以主动结束展览
|
||||
return true, "remove"
|
||||
}
|
||||
// 访问别人展馆 - 不能操作
|
||||
return false, "none"
|
||||
}
|
||||
|
||||
// 公共展位 (共享展位)
|
||||
if slot.Visibility == "public" {
|
||||
if exhibition == nil {
|
||||
// 空展位 - 只有访问别人展馆时可以放置
|
||||
if !isOwner {
|
||||
return true, "place"
|
||||
}
|
||||
return false, "none" // 自己的展馆空展位不能操作
|
||||
}
|
||||
|
||||
// 有藏品时
|
||||
if exhibition.OccupierUID == viewerUID {
|
||||
return true, "remove" // 有自己的藏品,可以主动结束展览
|
||||
}
|
||||
|
||||
// 有别人的藏品 - 只有所有者可以踢出
|
||||
if isOwner {
|
||||
return true, "remove"
|
||||
}
|
||||
return false, "none"
|
||||
}
|
||||
|
||||
return false, "none"
|
||||
}
|
||||
|
||||
// getUnlockCondition 获取解锁条件
|
||||
func (s *galleryService) getUnlockCondition(slotIndex int) *pb.UnlockCondition {
|
||||
// 从配置中获取解锁条件
|
||||
requiredLevel, hasLevel := config.GalleryRules.UnlockLevelBySlot[slotIndex]
|
||||
requiredCrystal, hasCrystal := config.GalleryRules.UnlockCrystalBySlot[slotIndex]
|
||||
|
||||
// 优先等级解锁
|
||||
if hasLevel {
|
||||
return &pb.UnlockCondition{
|
||||
Type: "level",
|
||||
Value: int32(requiredLevel),
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有等级要求,返回水晶购买
|
||||
if hasCrystal {
|
||||
return &pb.UnlockCondition{
|
||||
Type: "crystal",
|
||||
Value: int32(requiredCrystal),
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都没有配置,返回nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAssetInfo 获取资产信息(从Asset Service)
|
||||
func (s *galleryService) getAssetInfo(assetID, userID, starID int64) (*pb.AssetInfo, error) {
|
||||
if s.assetClient == nil {
|
||||
return nil, errors.New("asset client not initialized")
|
||||
}
|
||||
|
||||
// 调用Asset Service RPC获取资产信息(传递用户信息用于权限验证)
|
||||
asset, err := s.assetClient.GetAssetInfo(assetID, userID, starID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &pb.AssetInfo{
|
||||
AssetId: asset.AssetId,
|
||||
Name: asset.Name,
|
||||
CoverUrl: asset.CoverUrl,
|
||||
LikeCount: asset.LikeCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
// generateHostProfileID 生成 host_profile_id
|
||||
func generateHostProfileID(userID, starID int64) int64 {
|
||||
return userID*1000000 + starID
|
||||
}
|
||||
|
||||
// ==================== 灵感瀑布相关 ====================
|
||||
|
||||
// GetInspirationFlow 获取灵感瀑布藏品列表
|
||||
func (s *galleryService) GetInspirationFlow(userID, starID int64, cursor, direction string, limit int32, materialType, sessionID string) (*pb.InspirationFlowData, string, error) {
|
||||
// 默认值处理
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 20 {
|
||||
limit = 20
|
||||
}
|
||||
if direction == "" {
|
||||
direction = "right"
|
||||
}
|
||||
if materialType == "" {
|
||||
materialType = "all"
|
||||
}
|
||||
|
||||
// 解析游标获取limit
|
||||
decodedLimit := int(limit)
|
||||
if cursor != "" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(cursor)
|
||||
if err == nil {
|
||||
var cursorData map[string]int
|
||||
if json.Unmarshal(decoded, &cursorData) == nil {
|
||||
if l, ok := cursorData["limit"]; ok {
|
||||
decodedLimit = l
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 向右滚动:从仓库随机查询新数据(排除已展示)
|
||||
if direction == "right" {
|
||||
// Get excludeIDs from cache
|
||||
var excludeIDs []int64
|
||||
if sessionID != "" {
|
||||
cache, err := database.GetInspirationFlowCache(context.Background(), 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,
|
||||
})
|
||||
|
||||
// Add items to cache
|
||||
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(context.Background(), 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
|
||||
}
|
||||
|
||||
// 向左滚动:从缓存获取历史数据
|
||||
if sessionID == "" {
|
||||
return &pb.InspirationFlowData{
|
||||
Items: []*pb.InspirationFlowItem{},
|
||||
Cursor: cursor,
|
||||
HasMore: false,
|
||||
SessionId: sessionID,
|
||||
}, sessionID, nil
|
||||
}
|
||||
|
||||
cache, err := database.GetInspirationFlowCache(context.Background(), 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
|
||||
}
|
||||
|
||||
// Parse offset from 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)
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@ -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 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 验证构建
|
||||
|
||||
- [ ] **Step 1: 运行 go build 验证代码编译**
|
||||
|
||||
```bash
|
||||
cd /Users/liulujian/Documents/code/TopFansByGithub/backend && go build ./...
|
||||
```
|
||||
|
||||
Expected: 编译成功,无错误
|
||||
|
||||
---
|
||||
|
||||
## 变更记录
|
||||
|
||||
| 日期 | 变更内容 |
|
||||
|------|---------|
|
||||
| 2026-05-14 | 初始实现计划 |
|
||||
@ -0,0 +1,418 @@
|
||||
# Redis Token 黑名单实现计划
|
||||
|
||||
> **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:** 实现 JWT Token 黑名单功能,用于账号封禁和强制下线场景
|
||||
|
||||
**Architecture:** 在 gateway 层集成 Redis,JWT 中间件每次请求检查 Token 是否在黑名单。Redis Key 使用 SHA256 哈希存储,Value 使用 JSON 格式。
|
||||
|
||||
**Tech Stack:** Go, github.com/redis/go-redis/v9, gin middleware
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
backend/pkg/database/
|
||||
├── database.go # 现有 PostgreSQL(GORM)
|
||||
└── redis.go # 新建:Redis 客户端 + Token 黑名单
|
||||
|
||||
backend/gateway/config/config.go # 修改:添加 Redis 配置
|
||||
backend/gateway/middleware/ # 修改:JWT 中间件检查黑名单
|
||||
backend/gateway/main.go # 修改:初始化 Redis
|
||||
```
|
||||
|
||||
> **注意:** gateway 的 go.mod 使用 `replace github.com/topfans/backend => ../`,因此 `github.com/topfans/backend/pkg/database` 映射到 `backend/pkg/database/`。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 创建 Redis 客户端和 Token 黑名单模块
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/pkg/database/redis.go`
|
||||
|
||||
- [ ] **Step 1: 创建 redis.go 文件**
|
||||
|
||||
```go
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
BlacklistKeyPrefix = "blacklist:token:"
|
||||
)
|
||||
|
||||
// RedisClient Redis 客户端单例
|
||||
var RedisClient *redis.Client
|
||||
|
||||
// Config Redis 配置
|
||||
type RedisConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Password string
|
||||
DB int
|
||||
}
|
||||
|
||||
// InitRedis 初始化 Redis 连接
|
||||
func InitRedis(cfg RedisConfig) error {
|
||||
RedisClient = redis.NewClient(&redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := RedisClient.Ping(ctx).Err(); err != nil {
|
||||
return fmt.Errorf("failed to connect to redis: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseRedis 关闭 Redis 连接
|
||||
func CloseRedis() error {
|
||||
if RedisClient != nil {
|
||||
return RedisClient.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRedis 获取 Redis 客户端实例
|
||||
func GetRedis() *redis.Client {
|
||||
return RedisClient
|
||||
}
|
||||
|
||||
// RedisHealthCheck 健康检查
|
||||
func RedisHealthCheck() error {
|
||||
if RedisClient == nil {
|
||||
return fmt.Errorf("redis client is not initialized")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return RedisClient.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
// BlacklistEntry 黑名单条目
|
||||
type BlacklistEntry struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// tokenToHash 将 Token 转换为 SHA256 哈希作为 Key
|
||||
func tokenToHash(token string) string {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// AddToBlacklist 添加 Token 到黑名单
|
||||
func AddToBlacklist(ctx context.Context, token string, userID int64, banReason string, ttl time.Duration) error {
|
||||
if token == "" {
|
||||
return fmt.Errorf("token is empty")
|
||||
}
|
||||
if RedisClient == nil {
|
||||
return fmt.Errorf("redis client is not initialized")
|
||||
}
|
||||
|
||||
key := BlacklistKeyPrefix + tokenToHash(token)
|
||||
entry := BlacklistEntry{UserID: userID, Reason: banReason}
|
||||
value, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal blacklist entry: %w", err)
|
||||
}
|
||||
|
||||
return RedisClient.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
|
||||
// IsBlacklisted 检查 Token 是否在黑名单
|
||||
func IsBlacklisted(ctx context.Context, token string) (bool, int64, string, error) {
|
||||
if token == "" {
|
||||
return false, 0, "", nil
|
||||
}
|
||||
if RedisClient == nil {
|
||||
return false, 0, "", fmt.Errorf("redis client is not initialized")
|
||||
}
|
||||
|
||||
key := BlacklistKeyPrefix + tokenToHash(token)
|
||||
value, err := RedisClient.Get(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
return false, 0, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, 0, "", err
|
||||
}
|
||||
|
||||
var entry BlacklistEntry
|
||||
if err := json.Unmarshal([]byte(value), &entry); err != nil {
|
||||
return false, 0, "", fmt.Errorf("failed to unmarshal blacklist entry: %w", err)
|
||||
}
|
||||
|
||||
return true, entry.UserID, entry.Reason, nil
|
||||
}
|
||||
|
||||
// RemoveFromBlacklist 从黑名单移除 Token(用于解封)
|
||||
func RemoveFromBlacklist(ctx context.Context, token string) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
if RedisClient == nil {
|
||||
return fmt.Errorf("redis client is not initialized")
|
||||
}
|
||||
|
||||
key := BlacklistKeyPrefix + tokenToHash(token)
|
||||
return RedisClient.Del(ctx, key).Err()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交代码**
|
||||
|
||||
```bash
|
||||
git add backend/pkg/database/redis.go
|
||||
git commit -m "feat: 添加 Redis 客户端和 Token 黑名单模块
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 添加 Redis 配置到 config.go
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/gateway/config/config.go`
|
||||
|
||||
- [ ] **Step 1: 添加 RedisConfig 结构体到 Config 结构体**
|
||||
|
||||
在 `Config` 结构体中添加 `Redis` 字段:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Dubbo DubboConfig
|
||||
JWT JWTConfig
|
||||
OSS OSSConfig
|
||||
Redis RedisConfig // 新增
|
||||
Root string
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 RedisConfig 结构体定义**
|
||||
|
||||
在 `ServerConfig` 之前添加:
|
||||
|
||||
```go
|
||||
// RedisConfig Redis 配置
|
||||
type RedisConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Password string
|
||||
DB int
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 Load() 函数中添加 Redis 配置加载**
|
||||
|
||||
在 return 语句的 OSS 配置后添加:
|
||||
|
||||
```go
|
||||
Redis: RedisConfig{
|
||||
Host: getEnv("REDIS_HOST", "127.0.0.1"),
|
||||
Port: getEnvInt("REDIS_PORT", 6379),
|
||||
Password: getEnv("REDIS_PASSWORD", ""),
|
||||
DB: getEnvInt("REDIS_DB", 0),
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交代码**
|
||||
|
||||
```bash
|
||||
git add backend/gateway/config/config.go
|
||||
git commit -m "feat: 添加 Redis 配置项
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 在 main.go 中初始化 Redis
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/gateway/main.go`
|
||||
|
||||
- [ ] **Step 1: 添加 database 包导入**
|
||||
|
||||
在导入部分添加:
|
||||
|
||||
```go
|
||||
"github.com/topfans/backend/pkg/database"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在加载配置后初始化 Redis**
|
||||
|
||||
在 `cfg.Validate()` 之后,初始化 Dubbo clients 之前添加:
|
||||
|
||||
```go
|
||||
// 3.5 初始化 Redis
|
||||
logger.Logger.Info("Connecting to Redis...")
|
||||
if err := database.InitRedis(database.RedisConfig{
|
||||
Host: cfg.Redis.Host,
|
||||
Port: cfg.Redis.Port,
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
}); err != nil {
|
||||
logger.Logger.Fatal("Failed to connect to Redis", zap.Error(err))
|
||||
}
|
||||
logger.Logger.Info("Redis connected successfully")
|
||||
defer database.CloseRedis()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交代码**
|
||||
|
||||
```bash
|
||||
git add backend/gateway/main.go
|
||||
git commit -m "feat: 初始化 Redis 连接
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 在 JWT 中间件中添加黑名单检查
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/gateway/middleware/auth_middleware.go`
|
||||
|
||||
- [ ] **Step 1: 添加 database 包导入**
|
||||
|
||||
```go
|
||||
"github.com/topfans/backend/pkg/database"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 ParseToken 成功后、检查用户信息存入 gin.Context 之前添加黑名单检查**
|
||||
|
||||
在 `// 4. 将用户信息存入 gin.Context` 之前添加:
|
||||
|
||||
```go
|
||||
// 4. 检查 Token 是否在黑名单
|
||||
isBlacklisted, bannedUserID, banReason, err := database.IsBlacklisted(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
// Redis 错误时 fail-closed(安全策略),拒绝请求
|
||||
logger.Logger.Error("Failed to check blacklist, rejecting request for security",
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.Error(err),
|
||||
)
|
||||
response.Unauthorized(c, "认证服务异常,请稍后重试")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if isBlacklisted {
|
||||
logger.Logger.Warn("Token is blacklisted",
|
||||
zap.Int64("banned_user_id", bannedUserID),
|
||||
zap.String("ban_reason", banReason),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
)
|
||||
response.Unauthorized(c, "账号已被封禁")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交代码**
|
||||
|
||||
```bash
|
||||
git add backend/gateway/middleware/auth_middleware.go
|
||||
git commit -m "feat: JWT 中间件添加 Token 黑名单检查
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 添加 Redis go.mod 依赖
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/gateway/go.mod`
|
||||
|
||||
- [ ] **Step 1: 添加 redis 依赖**
|
||||
|
||||
```bash
|
||||
cd backend/gateway && go get github.com/redis/go-redis/v9@latest
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交代码**
|
||||
|
||||
```bash
|
||||
git add backend/gateway/go.mod backend/gateway/go.sum
|
||||
git commit -m "deps: 添加 redis go-redis/v9 依赖
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 验证构建
|
||||
|
||||
- [ ] **Step 1: 运行 go build 验证代码编译**
|
||||
|
||||
```bash
|
||||
cd backend/gateway && go build ./...
|
||||
```
|
||||
|
||||
Expected: 编译成功,无错误
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 添加封禁用户的 Admin API(后续扩展)
|
||||
|
||||
> **说明:** 此任务为后续扩展任务,本次实现暂不包含。Token 黑名单的基础设施已建立,后续需要封禁用户时调用 `database.AddToBlacklist` 即可。
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/gateway/controller/admin_controller.go`(新建或修改)
|
||||
- Modify: `backend/gateway/router/router.go`
|
||||
|
||||
**API 设计:**
|
||||
|
||||
```
|
||||
POST /api/v1/admin/ban
|
||||
Authorization: Bearer {admin_token}
|
||||
Content-Type: application/json
|
||||
|
||||
Request:
|
||||
{
|
||||
"user_id": 123,
|
||||
"token": "user_jwt_token_to_ban",
|
||||
"reason": "违规发言"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"code": 200,
|
||||
"message": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
**调用示例:**
|
||||
|
||||
```go
|
||||
// 计算 Token 剩余 TTL
|
||||
ttl := jwt.GetExpiresIn() * time.Second
|
||||
|
||||
// 添加到黑名单
|
||||
err := database.AddToBlacklist(ctx, token, userID, reason, ttl)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 变更记录
|
||||
|
||||
| 日期 | 变更内容 |
|
||||
|------|---------|
|
||||
| 2026-05-14 | 初始实现计划 |
|
||||
@ -0,0 +1,256 @@
|
||||
# JWT Token 黑名单设计文档
|
||||
|
||||
> **创建日期:** 2026-05-14
|
||||
> **更新日期:** 2026-05-14
|
||||
> **项目:** TopFans Redis Token 黑名单
|
||||
> **服务:** Gateway / 通用
|
||||
> **状态:** 设计中
|
||||
|
||||
---
|
||||
|
||||
## 一、设计目标
|
||||
|
||||
实现 JWT Token 黑名单功能,用于:
|
||||
1. **账号封禁时** - 管理员封禁用户,Token 立即加入黑名单
|
||||
2. **强制下线时** - 用户在多个设备登录,需要让某个 token 失效
|
||||
|
||||
---
|
||||
|
||||
## 二、技术方案
|
||||
|
||||
### 2.1 技术选型
|
||||
|
||||
- **客户端**: `github.com/redis/go-redis/v9`
|
||||
- **连接信息**: 通过环境变量配置
|
||||
|
||||
### 2.2 Redis Key 设计
|
||||
|
||||
| Key 格式 | Value | TTL |
|
||||
|---------|-------|-----|
|
||||
| `blacklist:token:{tokenHash}` | `{"user_id":123,"reason":"封禁原因"}` | 与 Token 剩余有效期一致 |
|
||||
|
||||
**Key 结构说明:**
|
||||
- `tokenHash`: 使用 SHA256 对 Token 进行哈希,避免使用长字符串作为 Key
|
||||
- 这样做既保证了唯一性,又节省 Redis 内存
|
||||
|
||||
**Value 结构说明:**
|
||||
- JSON 格式存储,包含 `user_id` 和 `reason` 字段
|
||||
- 使用 JSON 格式避免 value 中包含特殊字符导致的解析问题
|
||||
|
||||
**TTL 说明:**
|
||||
- TTL 与 Token 剩余有效期一致,Token 过期后自动清理黑名单记录
|
||||
|
||||
### 2.3 核心接口
|
||||
|
||||
```go
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RedisClient Redis 客户端
|
||||
var RedisClient *redis.Client
|
||||
|
||||
// Config Redis 配置
|
||||
type RedisConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Password string
|
||||
DB int
|
||||
}
|
||||
|
||||
// Init 初始化 Redis 连接
|
||||
func InitRedis(cfg RedisConfig) error {
|
||||
RedisClient = redis.NewClient(&redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := RedisClient.Ping(ctx).Err(); err != nil {
|
||||
return fmt.Errorf("failed to connect to redis: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭 Redis 连接
|
||||
func CloseRedis() error {
|
||||
if RedisClient != nil {
|
||||
return RedisClient.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRedis 获取 Redis 客户端实例
|
||||
func GetRedis() *redis.Client {
|
||||
return RedisClient
|
||||
}
|
||||
|
||||
// HealthCheck 健康检查
|
||||
func RedisHealthCheck() error {
|
||||
if RedisClient == nil {
|
||||
return fmt.Errorf("redis client is not initialized")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return RedisClient.Ping(ctx).Err()
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Token 黑名单操作
|
||||
|
||||
```go
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
BlacklistKeyPrefix = "blacklist:token:"
|
||||
)
|
||||
|
||||
// BlacklistEntry 黑名单条目
|
||||
type BlacklistEntry struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// tokenToHash 将 Token 转换为哈希作为 Key
|
||||
func tokenToHash(token string) string {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// AddToBlacklist 添加 Token 到黑名单
|
||||
// token: JWT Token 字符串
|
||||
// userID: 用户 ID
|
||||
// banReason: 封禁原因
|
||||
// ttl: Token 剩余有效期
|
||||
func AddToBlacklist(ctx context.Context, token string, userID int64, banReason string, ttl time.Duration) error {
|
||||
if token == "" {
|
||||
return fmt.Errorf("token is empty")
|
||||
}
|
||||
if RedisClient == nil {
|
||||
return fmt.Errorf("redis client is not initialized")
|
||||
}
|
||||
|
||||
key := BlacklistKeyPrefix + tokenToHash(token)
|
||||
entry := BlacklistEntry{UserID: userID, Reason: banReason}
|
||||
value, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal blacklist entry: %w", err)
|
||||
}
|
||||
|
||||
return RedisClient.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
|
||||
// IsBlacklisted 检查 Token 是否在黑名单
|
||||
// 返回: (是否在黑名单, 用户ID, 封禁原因, error)
|
||||
func IsBlacklisted(ctx context.Context, token string) (bool, int64, string, error) {
|
||||
if token == "" {
|
||||
return false, 0, "", nil
|
||||
}
|
||||
if RedisClient == nil {
|
||||
return false, 0, "", fmt.Errorf("redis client is not initialized")
|
||||
}
|
||||
|
||||
key := BlacklistKeyPrefix + tokenToHash(token)
|
||||
value, err := RedisClient.Get(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
return false, 0, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, 0, "", err
|
||||
}
|
||||
|
||||
var entry BlacklistEntry
|
||||
if err := json.Unmarshal([]byte(value), &entry); err != nil {
|
||||
return false, 0, "", fmt.Errorf("failed to unmarshal blacklist entry: %w", err)
|
||||
}
|
||||
|
||||
return true, entry.UserID, entry.Reason, nil
|
||||
}
|
||||
|
||||
// RemoveFromBlacklist 从黑名单移除 Token(用于解封)
|
||||
func RemoveFromBlacklist(ctx context.Context, token string) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
if RedisClient == nil {
|
||||
return fmt.Errorf("redis client is not initialized")
|
||||
}
|
||||
|
||||
key := BlacklistKeyPrefix + tokenToHash(token)
|
||||
return RedisClient.Del(ctx, key).Err()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、配置项
|
||||
|
||||
### 3.1 环境变量
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|-------|
|
||||
| `REDIS_HOST` | Redis 主机地址 | 127.0.0.1 |
|
||||
| `REDIS_PORT` | Redis 端口 | 6379 |
|
||||
| `REDIS_PASSWORD` | Redis 密码 | (空) |
|
||||
| `REDIS_DB` | Redis 数据库编号 | 0 |
|
||||
|
||||
---
|
||||
|
||||
## 四、调用位置
|
||||
|
||||
| 场景 | 位置 | 触发时机 |
|
||||
|------|------|---------|
|
||||
| **账号封禁** | 管理员操作 → 更新账号状态 → **调用 AddToBlacklist** | 封禁时 |
|
||||
| **强制下线** | 用户操作 → **调用 AddToBlacklist** | 触发下线时 |
|
||||
| **网关校验** | JWT 校验层 → **调用 IsBlacklisted** | 每次请求 |
|
||||
| **账号解封** | 管理员操作 → **调用 RemoveFromBlacklist** | 解封时 |
|
||||
|
||||
---
|
||||
|
||||
## 五、文件结构
|
||||
|
||||
```
|
||||
backend/pkg/database/
|
||||
├── database.go # 现有 PostgreSQL
|
||||
└── redis.go # 新建:Redis 客户端 + Token 黑名单
|
||||
|
||||
backend/gateway/config/config.go # 修改:新增 Redis 配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、实现步骤
|
||||
|
||||
1. 新建 `pkg/database/redis.go`,实现 Redis 客户端初始化 + 黑名单操作
|
||||
2. 修改 `gateway/config/config.go`,添加 Redis 配置项和环境变量读取
|
||||
3. 在 gateway 初始化时调用 `database.InitRedis`
|
||||
4. 在 JWT 中间件中调用 `database.IsBlacklisted` 检查黑名单
|
||||
5. 在账号封禁/强制下线时调用 `database.AddToBlacklist`
|
||||
|
||||
---
|
||||
|
||||
## 七、变更记录
|
||||
|
||||
| 日期 | 变更内容 |
|
||||
|------|---------|
|
||||
| 2026-05-14 | 初始设计 |
|
||||
Loading…
Reference in New Issue
Block a user