Compare commits
16 Commits
74182ad662
...
3ef3510303
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ef3510303 | ||
|
|
2a0faeb835 | ||
|
|
2b4077c0cd | ||
|
|
29becda3bc | ||
|
|
7d807d395b | ||
|
|
5c46ae660f | ||
|
|
e599f8a349 | ||
|
|
2ebb4a3a4d | ||
|
|
07b3520e6e | ||
|
|
f4f987d068 | ||
|
|
9978310e12 | ||
|
|
ac0eb55bc0 | ||
|
|
342beb5f17 | ||
| 281e5b5c59 | |||
| 375816ea79 | |||
| e106a490ce |
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Skill(superpowers:subagent-driven-development)",
|
||||
"Skill(superpowers:subagent-driven-development:*)",
|
||||
"Bash(go build:*)",
|
||||
"Bash(go vet:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -23,13 +32,13 @@ type ServerConfig struct {
|
||||
|
||||
// DubboConfig Dubbo 服务配置
|
||||
type DubboConfig struct {
|
||||
UserServiceURL string
|
||||
SocialServiceURL string
|
||||
AssetServiceURL string
|
||||
GalleryServiceURL string
|
||||
ActivityServiceURL string
|
||||
TaskServiceURL string
|
||||
StarbookServiceURL string
|
||||
UserServiceURL string
|
||||
SocialServiceURL string
|
||||
AssetServiceURL string
|
||||
GalleryServiceURL string
|
||||
ActivityServiceURL string
|
||||
TaskServiceURL string
|
||||
StarbookServiceURL string
|
||||
}
|
||||
|
||||
// JWTConfig JWT 配置
|
||||
@ -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", "123456"),
|
||||
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)
|
||||
|
||||
@ -48,6 +48,8 @@ github.com/aws/smithy-go v1.8.0 h1:AEwwwXQZtUwP5Mz506FeXXrKBe0jA8gVM+1gEcSRooc=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY=
|
||||
github.com/bketelsen/crypt v0.0.4 h1:w/jqZtC9YD4DS/Vp9GhWfWcCpuAL58oTnLoI8vE9YHU=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/casbin/casbin/v2 v2.1.2 h1:bTwon/ECRx9dwBy2ewRVr5OiqjeXSGiTUY74sDPQi/g=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk=
|
||||
@ -289,6 +291,7 @@ github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/X
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
|
||||
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts=
|
||||
|
||||
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]
|
||||
}
|
||||
@ -1748,6 +1748,7 @@ type AddExhibitionHoursRequest struct {
|
||||
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` // 用户ID
|
||||
StarId int64 `protobuf:"varint,2,opt,name=star_id,json=starId,proto3" json:"star_id,omitempty"` // 明星ID
|
||||
ExhibitionHours int64 `protobuf:"varint,3,opt,name=exhibition_hours,json=exhibitionHours,proto3" json:"exhibition_hours,omitempty"` // 本次展出的时长(小时)
|
||||
SourceId string `protobuf:"bytes,4,opt,name=source_id,json=sourceId,proto3" json:"source_id,omitempty"` // 关联业务ID,用于升级奖励流水的溯源
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -1803,6 +1804,13 @@ func (x *AddExhibitionHoursRequest) GetExhibitionHours() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *AddExhibitionHoursRequest) GetSourceId() string {
|
||||
if x != nil {
|
||||
return x.SourceId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 增加累计上架时长响应
|
||||
type AddExhibitionHoursResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -2987,11 +2995,12 @@ const file_user_proto_rawDesc = "" +
|
||||
"\x05delta\x18\x03 \x01(\x05R\x05delta\"j\n" +
|
||||
"\x19UpdateAssetsCountResponse\x120\n" +
|
||||
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12\x1b\n" +
|
||||
"\tnew_count\x18\x02 \x01(\x05R\bnewCount\"x\n" +
|
||||
"\tnew_count\x18\x02 \x01(\x05R\bnewCount\"\x95\x01\n" +
|
||||
"\x19AddExhibitionHoursRequest\x12\x17\n" +
|
||||
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x17\n" +
|
||||
"\astar_id\x18\x02 \x01(\x03R\x06starId\x12)\n" +
|
||||
"\x10exhibition_hours\x18\x03 \x01(\x03R\x0fexhibitionHours\"\xb3\x01\n" +
|
||||
"\x10exhibition_hours\x18\x03 \x01(\x03R\x0fexhibitionHours\x12\x1b\n" +
|
||||
"\tsource_id\x18\x04 \x01(\tR\bsourceId\"\xb3\x01\n" +
|
||||
"\x1aAddExhibitionHoursResponse\x120\n" +
|
||||
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12\x1b\n" +
|
||||
"\tnew_level\x18\x02 \x01(\x05R\bnewLevel\x12\x1f\n" +
|
||||
|
||||
@ -218,6 +218,7 @@ message AddExhibitionHoursRequest {
|
||||
int64 user_id = 1; // 用户ID
|
||||
int64 star_id = 2; // 明星ID
|
||||
int64 exhibition_hours = 3; // 本次展出的时长(小时)
|
||||
string source_id = 4; // 关联业务ID,用于升级奖励流水的溯源
|
||||
}
|
||||
|
||||
// 增加累计上架时长响应
|
||||
|
||||
@ -27,11 +27,15 @@ type UserRPCClient interface {
|
||||
GetFanProfile(userID, starID int64) (*FanProfile, error)
|
||||
|
||||
// UpdateCrystalBalance 更新水晶余额(返回更新后的余额)
|
||||
UpdateCrystalBalance(userID, starID int64, delta int64) (int64, error)
|
||||
// changeType: 变化类型,如 task_reward/mint_cost/mint_reward/exhibition_revenue/level_up_bonus/manual_adjust
|
||||
// sourceID: 关联业务ID
|
||||
// description: 可读描述
|
||||
UpdateCrystalBalance(userID, starID int64, delta int64, changeType string, sourceID string, description string) (int64, error)
|
||||
|
||||
// AddExhibitionHours 增加用户累计上架时长
|
||||
// sourceID: 关联业务ID,用于升级奖励流水的溯源
|
||||
// 返回: newLevel, levelDelta, crystalReward, error
|
||||
AddExhibitionHours(userID, starID int64, hours int64) (int32, int32, int64, error)
|
||||
AddExhibitionHours(userID, starID int64, hours int64, sourceID string) (int32, int32, int64, error)
|
||||
}
|
||||
|
||||
// userRPCClient User Service RPC客户端实现
|
||||
@ -101,18 +105,22 @@ func (c *userRPCClient) GetFanProfile(userID, starID int64) (*FanProfile, error)
|
||||
}
|
||||
|
||||
// UpdateCrystalBalance 更新水晶余额(返回更新后的余额)
|
||||
func (c *userRPCClient) UpdateCrystalBalance(userID, starID int64, delta int64) (int64, error) {
|
||||
func (c *userRPCClient) UpdateCrystalBalance(userID, starID int64, delta int64, changeType string, sourceID string, description string) (int64, error) {
|
||||
logger.Logger.Debug("Calling UserService.UpdateCrystalBalance",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int64("star_id", starID),
|
||||
zap.Int64("delta", delta),
|
||||
zap.String("change_type", changeType),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
resp, err := c.client.UpdateCrystalBalance(ctx, &pbUser.UpdateCrystalBalanceRequest{
|
||||
UserId: userID,
|
||||
StarId: starID,
|
||||
Delta: delta,
|
||||
UserId: userID,
|
||||
StarId: starID,
|
||||
Delta: delta,
|
||||
ChangeType: changeType,
|
||||
SourceId: sourceID,
|
||||
Description: description,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@ -150,7 +158,8 @@ func (c *userRPCClient) UpdateCrystalBalance(userID, starID int64, delta int64)
|
||||
}
|
||||
|
||||
// AddExhibitionHours 增加用户累计上架时长
|
||||
func (c *userRPCClient) AddExhibitionHours(userID, starID int64, hours int64) (int32, int32, int64, error) {
|
||||
// sourceID: 关联业务ID,用于升级奖励流水的溯源(本参数暂未透传到RPC,仅Go层保留)
|
||||
func (c *userRPCClient) AddExhibitionHours(userID, starID int64, hours int64, sourceID string) (int32, int32, int64, error) {
|
||||
logger.Logger.Debug("Calling UserService.AddExhibitionHours",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int64("star_id", starID),
|
||||
@ -162,6 +171,7 @@ func (c *userRPCClient) AddExhibitionHours(userID, starID int64, hours int64) (i
|
||||
UserId: userID,
|
||||
StarId: starID,
|
||||
ExhibitionHours: hours,
|
||||
SourceId: sourceID,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
@ -385,15 +385,12 @@ func (r *galleryRepository) GetMyExhibitedAssets(userID, starID int64, page, pag
|
||||
err = r.db.Model(&models.Exhibition{}).
|
||||
Raw(`
|
||||
SELECT exhibitions.asset_id, a.name, a.cover_url, a.like_count,
|
||||
exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index,
|
||||
COALESCE(CAST(SUM(err.crystal_amount) / 10 AS bigint), 0) as earnings
|
||||
exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index
|
||||
FROM exhibitions
|
||||
JOIN assets a ON a.id = exhibitions.asset_id
|
||||
JOIN booth_slots bs ON bs.slot_id = exhibitions.slot_id
|
||||
LEFT JOIN exhibition_revenue_records err ON err.asset_id = a.id AND err.status = 'claimable'
|
||||
WHERE exhibitions.occupier_uid = ? AND exhibitions.occupier_star_id = ?
|
||||
AND exhibitions.deleted_at IS NULL AND exhibitions.expire_at > ?
|
||||
GROUP BY exhibitions.asset_id, a.name, a.cover_url, a.like_count, exhibitions.start_time, exhibitions.expire_at, bs.slot_index
|
||||
ORDER BY bs.slot_index ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`, userID, starID, now, pageSize, offset).Scan(&items).Error
|
||||
@ -402,6 +399,12 @@ func (r *galleryRepository) GetMyExhibitedAssets(userID, starID int64, page, pag
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 实时计算展示收益:R1 = R0 × T × [100% + Buff(n)]
|
||||
// R0 = 5 水晶/小时
|
||||
for _, item := range items {
|
||||
item.Earnings = calculateRealtimeEarnings(item.LikeCount, item.ExhibitedAt, now)
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
@ -576,3 +579,37 @@ func generateHostProfileID(userID, starID int64) int64 {
|
||||
// 实际项目中应该使用与 User Service 一致的逻辑
|
||||
return userID*1000000 + starID
|
||||
}
|
||||
|
||||
// calculateRealtimeEarnings 实时计算展示收益
|
||||
// 公式:R1 = R0 × T × [100% + Buff(n)]
|
||||
// R0 = 5 水晶/小时,T = 上架时长(小时),Buff(n) 根据点赞数计算
|
||||
func calculateRealtimeEarnings(likeCount int32, startTime, now int64) int64 {
|
||||
R0 := int64(5) // 水晶/小时
|
||||
|
||||
// 计算上架时长(毫秒转小时)
|
||||
T := (now - startTime) / 3600000
|
||||
if T <= 0 {
|
||||
T = 1 // 最少1小时
|
||||
}
|
||||
|
||||
// 计算Buff
|
||||
var buff int
|
||||
switch {
|
||||
case likeCount >= 30:
|
||||
buff = 30
|
||||
case likeCount >= 10:
|
||||
buff = 20
|
||||
case likeCount >= 5:
|
||||
buff = 10
|
||||
default:
|
||||
buff = 0
|
||||
}
|
||||
|
||||
// 基础收益
|
||||
baseRevenue := R0 * T
|
||||
|
||||
// 应用Buff加成:R1 = R0 × T × (100% + Buff)
|
||||
buffedRevenue := baseRevenue * (100 + int64(buff)) / 100
|
||||
|
||||
return buffedRevenue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@ -12,6 +12,10 @@ import (
|
||||
type UserServiceClient interface {
|
||||
UpdateCrystalBalance(ctx context.Context, userID, starID int64, delta int64, changeType string, sourceID string, description string) (int64, error)
|
||||
GetFanProfile(ctx context.Context, userID, starID int64) (*pbUser.FanProfile, error)
|
||||
// AddExhibitionHours 增加用户累计上架时长
|
||||
// sourceID: 关联业务ID,用于升级奖励流水的溯源
|
||||
// 返回: newLevel, levelDelta, crystalReward, error
|
||||
AddExhibitionHours(ctx context.Context, userID, starID int64, hours int64, sourceID string) (int32, int32, int64, error)
|
||||
}
|
||||
|
||||
type userServiceClient struct {
|
||||
@ -56,3 +60,24 @@ func (c *userServiceClient) GetFanProfile(ctx context.Context, userID, starID in
|
||||
}
|
||||
return resp.Profile, nil
|
||||
}
|
||||
|
||||
// AddExhibitionHours 增加用户累计上架时长
|
||||
func (c *userServiceClient) AddExhibitionHours(ctx context.Context, userID, starID int64, hours int64, sourceID string) (int32, int32, int64, error) {
|
||||
logger.Logger.Debug("Calling UserService.AddExhibitionHours",
|
||||
zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Int64("hours", hours))
|
||||
resp, err := c.client.AddExhibitionHours(ctx, &pbUser.AddExhibitionHoursRequest{
|
||||
UserId: userID,
|
||||
StarId: starID,
|
||||
ExhibitionHours: hours,
|
||||
SourceId: sourceID,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Logger.Error("UserService.AddExhibitionHours failed", zap.Error(err))
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
if resp.Base.Code != pbCommon.StatusCode_STATUS_OK {
|
||||
logger.Logger.Warn("AddExhibitionHours non-zero code", zap.Int32("code", int32(resp.Base.Code)))
|
||||
return 0, 0, 0, fmt.Errorf("AddExhibitionHours failed with code: %d", resp.Base.Code)
|
||||
}
|
||||
return resp.NewLevel, resp.LevelDelta, resp.CrystalReward, nil
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ package repository
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -21,31 +20,6 @@ func contains(s, substr string) bool {
|
||||
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
|
||||
}
|
||||
|
||||
// CalculateLevel 根据经验值计算等级
|
||||
// 公式: 升级到等级L需要的累计经验 = (L-1) * L * 50
|
||||
// Level 1: 0经验, Level 2: 100经验, Level 3: 300经验, Level 4: 600经验...
|
||||
func CalculateLevel(experience int64) int32 {
|
||||
if experience < 0 {
|
||||
return 1
|
||||
}
|
||||
// 使用公式: (L-1) * L * 50 <= experience
|
||||
// 解方程: L^2 - L - experience/50 <= 0
|
||||
// L = (1 + sqrt(1 + 4*experience/50)) / 2
|
||||
level := int32((1 + math.Sqrt(1+4*float64(experience)/50)) / 2)
|
||||
if level < 1 {
|
||||
level = 1
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
// GetExperienceForLevel 获取指定等级需要的经验值
|
||||
func GetExperienceForLevel(level int32) int64 {
|
||||
if level <= 1 {
|
||||
return 0
|
||||
}
|
||||
return int64((level-1) * level * 50)
|
||||
}
|
||||
|
||||
// FanProfileRepository 粉丝档案Repository接口
|
||||
type FanProfileRepository interface {
|
||||
// Create 创建粉丝档案
|
||||
@ -72,9 +46,6 @@ type FanProfileRepository interface {
|
||||
// IncrementAssetsCount 增加资产计数
|
||||
IncrementAssetsCount(userID, starID int64, delta int32) error
|
||||
|
||||
// SyncLevelFromExperience 根据经验值同步等级(只升级不降级)
|
||||
SyncLevelFromExperience(userID, starID int64) (int32, error)
|
||||
|
||||
// DecrementAssetsCount 减少资产计数
|
||||
DecrementAssetsCount(userID, starID int64, delta int32) error
|
||||
|
||||
@ -91,11 +62,9 @@ type FanProfileRepository interface {
|
||||
UpdateCrystalBalance(userID, starID int64, delta int64, changeType string, sourceID string, description string) (int64, error)
|
||||
|
||||
// AddExhibitionHours 增加用户累计上架时长并同步等级(事务性)
|
||||
// sourceID: 关联业务ID,用于升级奖励流水的溯源
|
||||
// 返回: newLevel, levelDelta, crystalReward, error
|
||||
AddExhibitionHours(userID, starID int64, hours int64) (int32, int32, int64, error)
|
||||
|
||||
// UpdateExperience 更新经验值
|
||||
UpdateExperience(userID, starID int64, delta int64) (int64, error)
|
||||
AddExhibitionHours(userID, starID int64, hours int64, sourceID string) (int32, int32, int64, error)
|
||||
|
||||
// UpdateAvatar 更新头像
|
||||
UpdateAvatar(userID, starID int64, avatarURL string) error
|
||||
@ -517,8 +486,9 @@ func GetLevelCap() int32 {
|
||||
}
|
||||
|
||||
// AddExhibitionHours 增加用户累计上架时长并同步等级(事务性)
|
||||
// sourceID: 关联业务ID,用于升级奖励流水的溯源
|
||||
// 返回: newLevel, levelDelta, crystalReward, error
|
||||
func (r *fanProfileRepository) AddExhibitionHours(userID, starID int64, hours int64) (int32, int32, int64, error) {
|
||||
func (r *fanProfileRepository) AddExhibitionHours(userID, starID int64, hours int64, sourceID string) (int32, int32, int64, error) {
|
||||
var result struct {
|
||||
OldLevel int32
|
||||
NewLevel int32
|
||||
@ -607,7 +577,7 @@ func (r *fanProfileRepository) AddExhibitionHours(userID, starID int64, hours in
|
||||
Delta: crystalReward,
|
||||
BalanceBefore: balanceBefore,
|
||||
BalanceAfter: balanceAfter,
|
||||
SourceID: "",
|
||||
SourceID: sourceID,
|
||||
Description: fmt.Sprintf("升级到%d级奖励", newLevel),
|
||||
CreatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
@ -664,63 +634,6 @@ func (r *fanProfileRepository) getLevelUpRewards(tx *gorm.DB, level int32) ([]*m
|
||||
return rewards, err
|
||||
}
|
||||
|
||||
// UpdateExperience 更新经验值(同时自动更新等级)
|
||||
// UpdateExperience 更新经验值(同时自动更新等级)
|
||||
// delta: 变化量,正数表示增加,负数表示减少
|
||||
// 返回: 更新后的经验值
|
||||
func (r *fanProfileRepository) UpdateExperience(userID, starID int64, delta int64) (int64, error) {
|
||||
if userID <= 0 {
|
||||
return 0, errors.New("user_id must be greater than 0")
|
||||
}
|
||||
|
||||
if starID <= 0 {
|
||||
return 0, errors.New("star_id must be greater than 0")
|
||||
}
|
||||
|
||||
// 使用事务确保原子性
|
||||
var newExperience int64
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 先查询当前的 experience 值
|
||||
var profile models.FanProfile
|
||||
if err := tx.Where("user_id = ? AND star_id = ?", userID, starID).
|
||||
First(&profile).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return appErrors.ErrFanProfileNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 计算新经验值
|
||||
newExperience = profile.Experience + delta
|
||||
|
||||
// 确保不会小于 0
|
||||
if newExperience < 0 {
|
||||
newExperience = 0
|
||||
}
|
||||
|
||||
// 根据新经验值计算新等级
|
||||
newLevel := CalculateLevel(newExperience)
|
||||
|
||||
// 更新 experience 和 level 字段
|
||||
if err := tx.Model(&models.FanProfile{}).
|
||||
Where("user_id = ? AND star_id = ?", userID, starID).
|
||||
Updates(map[string]interface{}{
|
||||
"experience": newExperience,
|
||||
"level": newLevel,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return newExperience, nil
|
||||
}
|
||||
|
||||
// UpdateAvatar 更新头像
|
||||
func (r *fanProfileRepository) UpdateAvatar(userID, starID int64, avatarURL string) error {
|
||||
if userID <= 0 {
|
||||
@ -750,23 +663,4 @@ func (r *fanProfileRepository) UpdateAvatar(userID, starID int64, avatarURL stri
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncLevelFromExperience 根据经验值同步等级(只升级不降级)
|
||||
// 在获取用户信息时调用,确保等级与经验值匹配
|
||||
func (r *fanProfileRepository) SyncLevelFromExperience(userID, starID int64) (int32, error) {
|
||||
var profile models.FanProfile
|
||||
if err := r.db.Where("user_id = ? AND star_id = ?", userID, starID).First(&profile).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
newLevel := CalculateLevel(profile.Experience)
|
||||
|
||||
// 只升级,不降级
|
||||
if newLevel > profile.Level {
|
||||
if err := r.db.Model(&profile).Update("level", newLevel).Error; err != nil {
|
||||
return profile.Level, err
|
||||
}
|
||||
return newLevel, nil
|
||||
}
|
||||
|
||||
return profile.Level, nil
|
||||
}
|
||||
// UpdateAvatar 更新头像
|
||||
|
||||
@ -353,7 +353,6 @@ func TestFanProfileRepository_Update(t *testing.T) {
|
||||
|
||||
// 更新粉丝档案
|
||||
profile.Level = 2
|
||||
profile.Experience = 100
|
||||
err := repo.Update(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
@ -368,10 +367,6 @@ func TestFanProfileRepository_Update(t *testing.T) {
|
||||
if retrieved.Level != 2 {
|
||||
t.Errorf("Level mismatch: expected 2, got %d", retrieved.Level)
|
||||
}
|
||||
|
||||
if retrieved.Experience != 100 {
|
||||
t.Errorf("Experience mismatch: expected 100, got %d", retrieved.Experience)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanProfileRepository_UpdateNickname(t *testing.T) {
|
||||
|
||||
@ -0,0 +1,716 @@
|
||||
# ActivityRankingModal 实现计划
|
||||
|
||||
> **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:** 创建 `ActivityRankingModal.vue` 弹窗组件,从 `ThemeBanner` 点击触发,显示单活动的排名数据
|
||||
|
||||
**Architecture:** 复用 `RankingModal.vue` 的现有样式主题和数据转换逻辑,创建专用于活动排名的简化弹窗组件
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API, uni-app, 复用现有 RankingModal/TOP3Card/RankingListItem 组件
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
frontend/pages/support-activity/
|
||||
├── components/
|
||||
│ ├── ActivityRankingModal.vue ← 新建
|
||||
│ ├── ThemeBanner.vue ← 修改:添加点击事件触发弹窗
|
||||
│ ├── TOP3Card.vue ← 复用(来自 pages/components/)
|
||||
│ └── RankingListItem.vue ← 复用(来自 pages/components/)
|
||||
└── index.vue ← 修改:引入并使用 ActivityRankingModal
|
||||
|
||||
frontend/utils/api.js ← 无需修改(getActivityRankingApi 已存在)
|
||||
docs/superpowers/specs/2026-05-14-activity-ranking-modal-design.md ← 已存在
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务分解
|
||||
|
||||
### Task 1: 创建 ActivityRankingModal.vue 组件
|
||||
|
||||
**文件:**
|
||||
- 创建: `frontend/pages/support-activity/components/ActivityRankingModal.vue`
|
||||
|
||||
- [ ] **Step 1: 创建基础模板和样式结构**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view v-if="visible" class="modal-wrapper">
|
||||
<transition name="fade">
|
||||
<view v-if="visible" class="modal-mask" @tap="handleClose"></view>
|
||||
</transition>
|
||||
|
||||
<transition name="slide-up">
|
||||
<view v-if="visible" class="modal-container" @tap.stop>
|
||||
<!-- 背景 -->
|
||||
<view class="modal-background"></view>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<view class="modal-content">
|
||||
<!-- 头部 -->
|
||||
<view class="header-section">
|
||||
<text class="activity-title">{{ activityTitle }}</text>
|
||||
<view class="close-btn" @tap="handleClose">
|
||||
<text class="close-icon">×</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 滚动内容区 -->
|
||||
<scroll-view
|
||||
class="scrollable-content"
|
||||
scroll-y="true"
|
||||
:show-scrollbar="false"
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="isRefreshing"
|
||||
@refresherrefresh="handleRefresh"
|
||||
@scrolltolower="handleScrollToLower"
|
||||
:lower-threshold="100"
|
||||
>
|
||||
<!-- 加载中 -->
|
||||
<view v-if="isLoading && !isRefreshing" class="loading-container">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<view v-else-if="errorMessage" class="error-container">
|
||||
<text class="error-text">{{ errorMessage }}</text>
|
||||
<button class="retry-button" @tap="loadRankingData">重试</button>
|
||||
</view>
|
||||
|
||||
<!-- TOP3 区域 -->
|
||||
<view v-else-if="top3Users.length > 0" class="top3-section">
|
||||
<TOP3Card
|
||||
v-for="user in top3Users"
|
||||
:key="user.userId"
|
||||
:rank="user.rank"
|
||||
:avatar="user.avatar"
|
||||
:nickname="user.nickname"
|
||||
:popularityScore="user.popularityScore"
|
||||
:artworkImage="user.artworkImage"
|
||||
:userId="user.userId"
|
||||
:isCurrentUser="isCurrentUser(user.userId)"
|
||||
@visit="handleVisit"
|
||||
@view-profile="handleViewProfile"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-if="!isLoading && !errorMessage && rankingData.length === 0" class="empty-data">
|
||||
<text class="empty-text">暂无排名数据</text>
|
||||
</view>
|
||||
|
||||
<!-- 排名列表 -->
|
||||
<view v-if="listUsers.length > 0" class="ranking-list-section">
|
||||
<RankingListItem
|
||||
v-for="item in listUsers"
|
||||
:key="item.userId"
|
||||
:rank="item.rank"
|
||||
:userId="item.userId"
|
||||
:avatar="item.avatar"
|
||||
:nickname="item.nickname"
|
||||
:popularityScore="item.popularityScore"
|
||||
:artworkImage="item.artworkImage"
|
||||
:artworkId="item.artworkId"
|
||||
:showVisitButton="!isCurrentUser(item.userId)"
|
||||
:isCurrentUser="isCurrentUser(item.userId)"
|
||||
@visit="handleVisit"
|
||||
@view-profile="handleViewProfile"
|
||||
@artwork-click="handleArtworkClick"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view v-if="isLoadingMore" class="loading-more-container">
|
||||
<view class="loading-spinner-small"></view>
|
||||
<text class="loading-more-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 底部占位 -->
|
||||
<view class="bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 当前用户栏 -->
|
||||
<view v-if="!isLoading" class="current-user-bar">
|
||||
<view class="current-user-content">
|
||||
<image
|
||||
class="current-user-avatar"
|
||||
:src="currentUserInfo.avatar"
|
||||
mode="aspectFill"
|
||||
@error="handleCurrentUserAvatarError"
|
||||
/>
|
||||
<view class="current-user-info">
|
||||
<view class="current-user-score">
|
||||
<image class="flame-icon" src="/static/rank/spark.png" mode="aspectFit"></image>
|
||||
<text class="score-text">{{ formatPopularityScore(currentUserInfo.popularityScore) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="current-user-rank">
|
||||
<text class="rank-text">{{ formatCurrentUserRank(currentUserInfo.rank) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</transition>
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 script 部分**
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getActivityRankingApi } from '@/utils/api.js'
|
||||
import TOP3Card from '../../components/TOP3Card.vue'
|
||||
import RankingListItem from '../../components/RankingListItem.vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
activityId: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
},
|
||||
starId: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
activityTitle: {
|
||||
type: String,
|
||||
default: '活动排名'
|
||||
},
|
||||
currentUser: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'visit', 'view-profile', 'view-artwork'])
|
||||
|
||||
// 状态管理
|
||||
const rankingData = ref([])
|
||||
const isLoading = ref(false)
|
||||
const isRefreshing = ref(false)
|
||||
const isLoadingMore = ref(false)
|
||||
const hasNoMoreData = ref(false)
|
||||
const errorMessage = ref(null)
|
||||
const currentUserInfo = ref({
|
||||
userId: 'currentUser',
|
||||
avatar: '/static/avatar/1.jpeg',
|
||||
nickname: '我',
|
||||
popularityScore: 0,
|
||||
rank: null
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pageInfo = ref({ currentPage: 1, hasMore: true })
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
// 计算属性:TOP3 用户
|
||||
const top3Users = computed(() => {
|
||||
return rankingData.value
|
||||
.filter(user => user.rank >= 1 && user.rank <= 3)
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
})
|
||||
|
||||
// 计算属性:列表用户(第4名及以后)
|
||||
const listUsers = computed(() => {
|
||||
return rankingData.value
|
||||
.filter(user => user.rank >= 4)
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
})
|
||||
|
||||
// 数据转换函数(复用 RankingModal 逻辑)
|
||||
const transformActivityRankingData = async (apiResponse) => {
|
||||
if (!apiResponse || !apiResponse.data) return []
|
||||
|
||||
const { items } = apiResponse.data
|
||||
if (!Array.isArray(items)) return []
|
||||
|
||||
return items.map(item => ({
|
||||
rank: item.rank,
|
||||
userId: String(item.user_id),
|
||||
avatar: item.avatar_url || '/static/avatar/1.jpeg',
|
||||
nickname: item.nickname || '未知用户',
|
||||
popularityScore: item.total_contribution || 0,
|
||||
artworkImage: '',
|
||||
artworkId: null
|
||||
}))
|
||||
}
|
||||
|
||||
// 转换当前用户数据
|
||||
const transformMyActivityContribution = async (myContribution) => {
|
||||
return {
|
||||
userId: 'currentUser',
|
||||
avatar: myContribution?.avatar_url || '/static/avatar/1.jpeg',
|
||||
popularityScore: myContribution?.total_contribution || 0,
|
||||
rank: myContribution?.rank > 0 ? myContribution.rank : null
|
||||
}
|
||||
}
|
||||
|
||||
// 加载排名数据
|
||||
const loadRankingData = async (page = 1, isRefreshAction = false) => {
|
||||
try {
|
||||
if (page === 1 && !isRefreshAction) {
|
||||
isLoading.value = true
|
||||
} else if (page > 1) {
|
||||
isLoadingMore.value = true
|
||||
}
|
||||
errorMessage.value = null
|
||||
|
||||
const starId = props.starId || uni.getStorageSync('star_id')
|
||||
const apiResponse = await getActivityRankingApi(props.activityId, starId, page, PAGE_SIZE)
|
||||
|
||||
if (apiResponse && apiResponse.code === 200 && apiResponse.data) {
|
||||
const transformedData = await transformActivityRankingData(apiResponse)
|
||||
|
||||
if (page === 1) {
|
||||
rankingData.value = transformedData
|
||||
pageInfo.value = { currentPage: 1, hasMore: transformedData.length >= PAGE_SIZE }
|
||||
} else {
|
||||
rankingData.value.push(...transformedData)
|
||||
pageInfo.value.currentPage = page
|
||||
pageInfo.value.hasMore = transformedData.length >= PAGE_SIZE
|
||||
}
|
||||
|
||||
hasNoMoreData.value = !pageInfo.value.hasMore
|
||||
|
||||
// 处理当前用户数据
|
||||
if (page === 1 && apiResponse.data.my_contribution) {
|
||||
currentUserInfo.value = await transformMyActivityContribution(apiResponse.data.my_contribution)
|
||||
}
|
||||
|
||||
return true
|
||||
} else {
|
||||
throw new Error(apiResponse?.message || '获取排名失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load ranking:', error)
|
||||
errorMessage.value = error.message || '加载失败'
|
||||
return false
|
||||
} finally {
|
||||
if (page === 1 && !isRefreshAction) {
|
||||
isLoading.value = false
|
||||
} else if (page > 1) {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = async () => {
|
||||
if (isRefreshing.value) return
|
||||
|
||||
isRefreshing.value = true
|
||||
const success = await loadRankingData(1, true)
|
||||
|
||||
if (success) {
|
||||
uni.showToast({ title: '刷新成功', icon: 'success', duration: 1500 })
|
||||
} else {
|
||||
uni.showToast({ title: '刷新失败', icon: 'none', duration: 2000 })
|
||||
}
|
||||
|
||||
setTimeout(() => { isRefreshing.value = false }, 500)
|
||||
}
|
||||
|
||||
// 滚动加载更多
|
||||
const handleScrollToLower = async () => {
|
||||
if (isLoadingMore.value || hasNoMoreData.value) return
|
||||
|
||||
isLoadingMore.value = true
|
||||
await loadRankingData(pageInfo.value.currentPage + 1)
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
const formatPopularityScore = (score) => {
|
||||
if (typeof score !== 'number' || isNaN(score) || score < 0) return '0'
|
||||
if (score >= 1000000) return (score / 1000000).toFixed(1) + 'M'
|
||||
if (score >= 1000) return (score / 1000).toFixed(1) + 'K'
|
||||
return score.toString()
|
||||
}
|
||||
|
||||
const formatCurrentUserRank = (rank) => {
|
||||
if (!rank || rank <= 0) return '未上榜'
|
||||
return `第${rank}名`
|
||||
}
|
||||
|
||||
const isCurrentUser = (userId) => userId === currentUserInfo.value.userId
|
||||
|
||||
const handleCurrentUserAvatarError = (e) => {
|
||||
e.target.src = '/static/avatar/1.jpeg'
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
const handleClose = () => emit('update:visible', false)
|
||||
|
||||
const handleVisit = (userId, nickname) => emit('visit', { userId, nickname })
|
||||
|
||||
const handleViewProfile = (userId) => emit('view-profile', userId)
|
||||
|
||||
const handleArtworkClick = (data) => emit('view-artwork', data)
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(() => props.visible, async (newVisible) => {
|
||||
if (newVisible && props.activityId) {
|
||||
rankingData.value = []
|
||||
pageInfo.value = { currentPage: 1, hasMore: true }
|
||||
hasNoMoreData.value = false
|
||||
await loadRankingData(1)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 添加样式**
|
||||
|
||||
```vue
|
||||
<style scoped>
|
||||
/* 动画 */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
.slide-up-enter-active, .slide-up-leave-active { transition: transform 0.3s ease; }
|
||||
.slide-up-enter-from, .slide-up-leave-to { transform: translateY(100%); }
|
||||
|
||||
.modal-wrapper {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
border-radius: 48rpx 48rpx 0 0;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
||||
}
|
||||
|
||||
.modal-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image: url('/static/rank/paihangbang.png');
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 40rpx 20rpx 20rpx;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
padding: 0 20rpx 30rpx;
|
||||
}
|
||||
|
||||
.activity-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
right: 20rpx;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
font-size: 40rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.scrollable-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 200rpx;
|
||||
}
|
||||
|
||||
/* 复用 RankingModal 样式 */
|
||||
.loading-container, .error-container, .empty-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400rpx;
|
||||
padding: 40rpx;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border: 4rpx solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text, .error-text, .empty-text {
|
||||
margin-top: 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
margin-top: 30rpx;
|
||||
padding: 16rpx 40rpx;
|
||||
background: linear-gradient(135deg, #ff6b9d, #ffa06b);
|
||||
border-radius: 40rpx;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.top3-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 40rpx;
|
||||
padding: 0 48rpx;
|
||||
margin-bottom: 60rpx;
|
||||
}
|
||||
|
||||
.ranking-list-section {
|
||||
padding: 0 48rpx;
|
||||
}
|
||||
|
||||
.loading-more-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.loading-spinner-small {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border: 3rpx solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-more-text {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.bottom-spacer {
|
||||
height: 20rpx;
|
||||
}
|
||||
|
||||
.current-user-bar {
|
||||
position: absolute;
|
||||
bottom: 120rpx;
|
||||
left: 40rpx;
|
||||
right: 40rpx;
|
||||
padding: 24rpx;
|
||||
background: linear-gradient(135deg, rgba(255, 107, 157, 0.9), rgba(255, 177, 153, 0.9));
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 -4rpx 16rpx rgba(255, 107, 157, 0.35);
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.current-user-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.current-user-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.current-user-info {
|
||||
flex: 1;
|
||||
margin-left: 34rpx;
|
||||
}
|
||||
|
||||
.current-user-score {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flame-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
}
|
||||
|
||||
.score-text {
|
||||
font-size: 26rpx;
|
||||
margin-left: 8rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.current-user-rank {
|
||||
padding: 16rpx 28rpx;
|
||||
}
|
||||
|
||||
.rank-text {
|
||||
font-size: 30rpx;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 修改 ThemeBanner.vue 添加点击事件
|
||||
|
||||
**文件:**
|
||||
- 修改: `frontend/pages/support-activity/components/ThemeBanner.vue:1-36`(template 部分)
|
||||
|
||||
- [ ] **Step 1: 在 banner-content 上添加点击事件**
|
||||
|
||||
在 `<view class="banner-content">` 上添加 `@tap="handleBannerClick"`
|
||||
|
||||
- [ ] **Step 2: 添加 emit 定义**
|
||||
|
||||
在 script 部分添加:
|
||||
```javascript
|
||||
const emit = defineEmits(['tap'])
|
||||
|
||||
// 添加点击处理函数
|
||||
const handleBannerClick = () => {
|
||||
emit('tap')
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 修改 support-activity/index.vue 引入和使用组件
|
||||
|
||||
**文件:**
|
||||
- 修改: `frontend/pages/support-activity/index.vue:14-21`(template)
|
||||
- 修改: `frontend/pages/support-activity/index.vue:105-122`(script import)
|
||||
|
||||
- [ ] **Step 1: 引入 ActivityRankingModal**
|
||||
|
||||
```javascript
|
||||
import ActivityRankingModal from './components/ActivityRankingModal.vue'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加弹窗状态和引用**
|
||||
|
||||
```javascript
|
||||
const rankingModalVisible = ref(false)
|
||||
const currentActivityTitle = ref('')
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 template 中添加组件**
|
||||
|
||||
```vue
|
||||
<!-- ThemeBanner 添加 @tap 打开弹窗 -->
|
||||
<ThemeBanner
|
||||
v-if="config"
|
||||
:title="config.title"
|
||||
:banner-image="config.bannerImage"
|
||||
:current="progressData.current"
|
||||
:target="progressData.target"
|
||||
:is-stale-data="isStaleData"
|
||||
@tap="openRankingModal"
|
||||
/>
|
||||
|
||||
<!-- 添加 ActivityRankingModal -->
|
||||
<ActivityRankingModal
|
||||
v-model:visible="rankingModalVisible"
|
||||
:activity-id="activityId"
|
||||
:activity-title="currentActivityTitle"
|
||||
@visit="handleVisitUser"
|
||||
@view-profile="handleViewUserProfile"
|
||||
@view-artwork="handleViewArtwork"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 添加打开弹窗方法**
|
||||
|
||||
```javascript
|
||||
const openRankingModal = () => {
|
||||
currentActivityTitle.value = config.value?.title || '活动排名'
|
||||
rankingModalVisible.value = true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 提交代码
|
||||
|
||||
- [ ] **Step 1: 提交所有更改**
|
||||
|
||||
```bash
|
||||
git add frontend/pages/support-activity/components/ActivityRankingModal.vue \
|
||||
frontend/pages/support-activity/components/ThemeBanner.vue \
|
||||
frontend/pages/support-activity/index.vue
|
||||
git commit -m "feat: 添加 ActivityRankingModal 活动榜单弹窗组件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. 启动开发服务器:`cd frontend && npm run dev`
|
||||
2. 进入活动页面,点击 ThemeBanner 区域
|
||||
3. 验证弹窗正常显示TOP3和排名列表
|
||||
4. 验证下拉刷新和滚动加载更多功能
|
||||
5. 验证当前用户栏正确显示
|
||||
|
||||
---
|
||||
|
||||
## 依赖项
|
||||
|
||||
- `TOP3Card.vue` - 来自 `frontend/pages/components/TOP3Card.vue`
|
||||
- `RankingListItem.vue` - 来自 `frontend/pages/components/RankingListItem.vue`
|
||||
- `getActivityRankingApi` - 来自 `frontend/utils/api.js`
|
||||
@ -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 | 初始实现计划 |
|
||||
@ -677,15 +677,284 @@ onHide(() => {
|
||||
|
||||
---
|
||||
|
||||
## 10. 待确认
|
||||
---
|
||||
|
||||
- [x] 后端接口由我方实现
|
||||
- [x] 道具图标由后端提供(冗余字段直接存储)
|
||||
- [x] 仅显示当前页面实时数据(页面切换时暂停)
|
||||
- [x] 后端框架:Go + Gin(Gateway)+ Dubbo-go(微服务)+ GORM
|
||||
- [x] 数据库:MySQL
|
||||
- [x] ORM:GORM
|
||||
- [x] 冗余字段:user_nickname、user_avatar、item_name、item_icon 直接存在表中
|
||||
- [x] 不需要"几秒前"等相对时间显示,只显示实时贡献
|
||||
- [x] 轮询使用时间戳(since_timestamp)而非 ID,避免重复拉取
|
||||
- [ ] 列表视觉样式是否有设计稿?
|
||||
## 11. Redis 缓存 + 时间窗口合并 + 分布式锁优化
|
||||
|
||||
### 11.1 背景与目标
|
||||
|
||||
#### 背景
|
||||
- 前端已实现每秒轮询 `GET /api/v1/activity/:activityId/contributions/latest`
|
||||
- 如果 10,000 用户同时轮询,每秒 10,000 次 DB 查询,存在性能瓶颈
|
||||
|
||||
#### 目标
|
||||
- **秒级合并**:10s/12s/13s 的查询请求,统一在 10s 窗口执行一次 DB 查询
|
||||
- **有新数据立即返回**:如果缓存中检测到新记录,直接返回最新数据
|
||||
- **无新数据合并查询**:窗口内无写入时,后续请求复用缓存结果
|
||||
|
||||
#### 性能指标
|
||||
- 10k 并发轮询 → 实际 DB 查询频率 ≤ 1次/秒(正常状态)
|
||||
- 缓存未命中时首个请求有 100-300ms 延迟,后续请求 < 5ms
|
||||
- 缓存 TTL 5秒,滚动窗口
|
||||
|
||||
### 11.2 整体流程
|
||||
|
||||
```
|
||||
用户轮询请求 (10k 并发)
|
||||
│
|
||||
▼
|
||||
Gateway API
|
||||
│
|
||||
▼
|
||||
查询 Redis 缓存
|
||||
activity:{id}:contributions:latest
|
||||
│
|
||||
├── 有缓存 + 窗口有效(now_ms - updated_at < 1000ms)
|
||||
│ │
|
||||
│ ▼
|
||||
│ 检查 sinceTimestamp:
|
||||
│ - sinceTimestamp <= updated_at → 缓存数据够新,直接返回
|
||||
│ - sinceTimestamp > updated_at → 继续查 DB(数据可能不够新)
|
||||
│
|
||||
└── 缓存不存在 / 过期
|
||||
│
|
||||
▼
|
||||
SETNX 加分布式锁
|
||||
lock: activity:{id}:contributions:lock
|
||||
(5秒自动释放,防止死锁)
|
||||
│
|
||||
├── 获取锁成功
|
||||
│ │
|
||||
│ ▼
|
||||
│ 查询 PostgreSQL
|
||||
│ 回填 Redis (TTL=5秒)
|
||||
│ 释放锁
|
||||
│ │
|
||||
│ ▼
|
||||
│ 返回数据
|
||||
│
|
||||
└── 获取锁失败
|
||||
│
|
||||
▼
|
||||
等待 100ms 重试
|
||||
(最多 3 次,避免长时间等待)
|
||||
```
|
||||
|
||||
### 11.3 Redis Key 设计
|
||||
|
||||
| Key | 类型 | 说明 | TTL |
|
||||
|-----|------|------|-----|
|
||||
| `activity:{activityId}:contributions:latest` | Hash | 最新贡献记录缓存 | 5秒 |
|
||||
| `activity:{activityId}:contributions:lock` | String | 分布式锁 | 5秒 |
|
||||
|
||||
### 11.4 缓存数据结构
|
||||
|
||||
```json
|
||||
{
|
||||
"records": [
|
||||
{
|
||||
"id": 12345,
|
||||
"user_id": 1001,
|
||||
"user_nickname": "用户昵称",
|
||||
"user_avatar": "https://...",
|
||||
"item_id": 1,
|
||||
"item_type": "gift_flower",
|
||||
"item_name": "玫瑰花",
|
||||
"item_icon": "https://...",
|
||||
"quantity": 10,
|
||||
"crystal_spent": 100,
|
||||
"contribution_points": 50,
|
||||
"combo_count": 2,
|
||||
"created_at": 1747133400000
|
||||
}
|
||||
],
|
||||
"updated_at": 1747133400000,
|
||||
"latest_id": 12345
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
- `records`: 最新贡献记录数组(最多 5 条),**每条记录的 combo_count 已合并**
|
||||
- `updated_at`: 窗口时间戳(毫秒级 Unix 时间戳),用于判断是否在有效窗口内
|
||||
- `latest_id`: 最新记录的 ID,用于增量检测
|
||||
|
||||
**时间戳单位**:统一使用**毫秒**,与前端 `sinceTimestamp` 参数单位一致
|
||||
|
||||
### 11.5 查询流程(伪代码)
|
||||
|
||||
```go
|
||||
func GetContributionsLatest(activityId int64, sinceTimestamp int64, sinceId int64, limit int) ([]*ContributionRecord, error) {
|
||||
ctx := context.Background()
|
||||
cacheKey := fmt.Sprintf("activity:%d:contributions:latest", activityId)
|
||||
lockKey := fmt.Sprintf("activity:%d:contributions:lock", activityId)
|
||||
nowMs := time.Now().UnixMilli()
|
||||
|
||||
// 1. 尝试获取缓存
|
||||
cached := redis.Get(ctx, cacheKey)
|
||||
if cached != nil {
|
||||
cache := parseCache(cached)
|
||||
// 缓存有效(1秒窗口内)→ 检查 sinceTimestamp 是否在窗口内
|
||||
if cache != nil && nowMs-cache.UpdatedAt < 1000 {
|
||||
// sinceTimestamp <= updated_at,说明请求的数据在缓存窗口内,直接返回
|
||||
if sinceTimestamp <= cache.UpdatedAt {
|
||||
return cache.Records, nil
|
||||
}
|
||||
// sinceTimestamp > updated_at,缓存数据可能不够新,继续查 DB
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 缓存不存在或过期或数据不够新,尝试加锁
|
||||
locked := redis.SetNX(ctx, lockKey, "1", 5*time.Second)
|
||||
if !locked {
|
||||
// 3. 获取锁失败,等待重试
|
||||
for i := 0; i < 3; i++ {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
cached := redis.Get(ctx, cacheKey)
|
||||
if cached != nil {
|
||||
cache := parseCache(cached)
|
||||
if cache != nil && nowMs-cache.UpdatedAt < 1000 && sinceTimestamp <= cache.UpdatedAt {
|
||||
return cache.Records, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// 重试 3 次后仍失败,返回错误或旧缓存
|
||||
return nil, errors.New("cache unavailable after retry")
|
||||
}
|
||||
|
||||
// 4. 获取锁成功,查 DB
|
||||
defer redis.Del(ctx, lockKey)
|
||||
|
||||
records, err := db.QueryContributions(activityId, sinceTimestamp, sinceId, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. 回填缓存(TTL 5秒)
|
||||
if len(records) > 0 {
|
||||
// 为每条记录获取最新的 combo_count
|
||||
for _, record := range records {
|
||||
comboCount, _ := redis.Get(ctx, fmt.Sprintf("combo:%d:%s", record.UserId, record.ItemType)).Int64()
|
||||
if comboCount > 0 {
|
||||
record.ComboCount = comboCount
|
||||
}
|
||||
}
|
||||
|
||||
cache := &ContributionCache{
|
||||
Records: records,
|
||||
UpdatedAt: nowMs,
|
||||
LatestId: records[0].Id,
|
||||
}
|
||||
redis.Set(ctx, cacheKey, cache, 5*time.Second)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 11.6 写入流程(使缓存失效)
|
||||
|
||||
在 `PurchaseItem` 成功写入 `activity_contributions` 后:
|
||||
|
||||
```go
|
||||
func (s *activityService) PurchaseItem(...) error {
|
||||
// ... 原有的购买逻辑 ...
|
||||
|
||||
// 1. 写数据库
|
||||
err := s.repo.CreateContribution(contribution)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 使缓存失效(不是更新,是删除)
|
||||
cacheKey := fmt.Sprintf("activity:%d:contributions:latest", activityId)
|
||||
redis.Del(ctx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**为什么不更新缓存而是删除?**
|
||||
- 如果更新缓存,需要处理并发写入的 race condition
|
||||
- 删除缓存让下次查询触发重建,逻辑更简单且正确
|
||||
|
||||
### 11.7 窗口合并策略
|
||||
|
||||
#### 时间窗口对齐
|
||||
- 窗口粒度:**1 秒**(1000 毫秒,可调整)
|
||||
- 查询时:如果 `now_ms - cache.updated_at < 1000ms`,认为是同一窗口
|
||||
- 超过 1 秒:缓存过期,下次查询触发重建
|
||||
|
||||
#### sinceTimestamp 过滤逻辑
|
||||
- 前端传 `sinceTimestamp`(毫秒)进行增量查询
|
||||
- 缓存命中时,判断 `sinceTimestamp <= cache.updated_at`:
|
||||
- `true` → 请求的数据在缓存窗口内,直接返回缓存
|
||||
- `false` → 缓存数据不够新,继续查 DB
|
||||
|
||||
#### 有新数据时的处理
|
||||
- 后端对比 `sinceId`:
|
||||
- `sinceId > cache.latest_id` → 有新数据,返回最新记录
|
||||
- `sinceId <= cache.latest_id` → 无新数据,返回缓存数据
|
||||
|
||||
#### combo_count 合并
|
||||
- 缓存回填时,从 Redis 获取每条记录的 `combo:{user_id}:{item_type}` 值
|
||||
- 合并到 `record.combo_count` 字段
|
||||
- 如果 Redis 无值,视为 1
|
||||
|
||||
### 11.8 分布式锁设计
|
||||
|
||||
#### 锁 Key
|
||||
```
|
||||
lock: activity:{activityId}:contributions:lock
|
||||
```
|
||||
|
||||
#### 锁参数
|
||||
- **TTL**: 5 秒(防止进程崩溃导致死锁)
|
||||
- **重试**: 获取失败后等待 100ms 重试,最多 3 次
|
||||
- **释放**: 使用后立即删除(`DEL` 命令)
|
||||
|
||||
#### 可靠性说明
|
||||
- 锁仅用于防止**缓存击穿**(cache stampede)
|
||||
- 锁持有时间极短(一次 DB 查询,约 10-50ms)
|
||||
- 单实例 Redis 足够,无需 Redlock
|
||||
|
||||
### 11.9 错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| Redis 不可用 | 回退到直接查 DB(降级) |
|
||||
| 获取锁失败 + 重试 3 次后仍失败 | 返回 503 Service Unavailable 或返回旧缓存 |
|
||||
| DB 查询失败 | 返回错误,前端显示重试 |
|
||||
| 缓存为空(无数据) | 返回空数组,不缓存 |
|
||||
|
||||
### 11.10 影响范围
|
||||
|
||||
#### 需要修改的文件
|
||||
1. **Gateway 层**
|
||||
- `gateway/controller/activity_controller.go` — 添加 `GetContributionsLatest` 方法(如果尚未添加)
|
||||
|
||||
2. **Service 层**
|
||||
- `services/activityService/provider/activity_provider.go` — 实现缓存逻辑 + 锁逻辑
|
||||
|
||||
3. **Repository 层**
|
||||
- `services/activityService/repository/activity_repository.go` — `GetLatestContributions` 查询方法(如果尚未添加)
|
||||
|
||||
4. **Proto 定义**(如使用 Dubbo RPC)
|
||||
- `pkg/proto/activity/activity.proto` — 添加 `GetContributionsLatestRequest/Response`
|
||||
- 重新生成 `activity.pb.go`
|
||||
|
||||
#### 不需要修改的文件
|
||||
- 前端 `ContributionList.vue` 和 `useContributionPolling.js` 无需改动(接口兼容)
|
||||
|
||||
### 11.11 测试要点
|
||||
|
||||
1. **并发测试**:10k 请求同时发起,验证 DB 只查询 1 次
|
||||
2. **缓存失效测试**:Purchase 后,验证缓存被正确删除
|
||||
3. **锁竞争测试**:缓存失效瞬间,多个请求抢锁,验证只有一个请求查 DB
|
||||
4. **降级测试**:Redis 不可用时,验证服务能回退到直连 DB
|
||||
5. **增量查询测试**:传入 `sinceId`,验证只返回增量数据
|
||||
|
||||
### 11.12 后续优化(可选)
|
||||
|
||||
1. **多级缓存**:引入本地内存缓存(如 Go 的 `sync.Map`),减少 Redis 请求
|
||||
2. **窗口动态调整**:根据并发量动态调整窗口大小
|
||||
3. **监控告警**:监控缓存命中率、锁等待时间、DB 查询 QPS
|
||||
@ -0,0 +1,77 @@
|
||||
# 活动榜单弹窗设计
|
||||
|
||||
**日期**: 2026-05-14
|
||||
**状态**: 已批准
|
||||
|
||||
## 概述
|
||||
|
||||
新建 `ActivityRankingModal.vue` 弹窗组件,从 `ThemeBanner` 点击触发,专门显示单活动的排名数据。
|
||||
|
||||
## 核心功能
|
||||
|
||||
1. **TOP3 展示** - 排名前三用户卡片(头像、昵称、人气值)
|
||||
2. **排名列表** - 第4名及以后,支持滚动加载更多
|
||||
3. **当前用户栏** - 固定底部,显示当前用户排名和贡献值
|
||||
4. **下拉刷新** - 支持 pull-to-refresh 刷新数据
|
||||
5. **单活动模式** - 直接使用传入的 `activityId` 获取数据,无需活动切换
|
||||
|
||||
## 组件接口
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| visible | Boolean | 是 | 弹窗显示状态 |
|
||||
| activityId | String \| Number | 是 | 活动ID |
|
||||
| starId | String \| Number | 否 | 明星ID,默认从 storage 获取 |
|
||||
| currentUser | Object | 否 | 当前用户数据 |
|
||||
|
||||
### Emits
|
||||
|
||||
| 事件 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| update:visible | Boolean | 关闭弹窗 |
|
||||
| visit | { userId, nickname } | 拜访按钮点击 |
|
||||
| view-profile | userId | 查看用户资料 |
|
||||
| view-artwork | { artworkId, userId } | 查看作品 |
|
||||
|
||||
## API 调用(独立实现)
|
||||
|
||||
使用 `getActivityRankingApi(activityId, starId, page, pageSize)` 获取活动排名数据。
|
||||
|
||||
响应数据格式复用 RankingModal 的 `transformActivityRankingData` 逻辑:
|
||||
|
||||
```javascript
|
||||
{
|
||||
rank: Number,
|
||||
userId: String,
|
||||
avatar: String,
|
||||
nickname: String,
|
||||
popularityScore: Number, // total_contribution
|
||||
artworkImage: String
|
||||
}
|
||||
```
|
||||
|
||||
## 页面结构
|
||||
|
||||
```
|
||||
ActivityRankingModal
|
||||
├── 遮罩层(点击关闭)
|
||||
├── 弹窗容器
|
||||
│ ├── 背景
|
||||
│ ├── 头部(活动名称 + 关闭按钮)
|
||||
│ ├── 滚动内容区
|
||||
│ │ ├── 加载中/错误/空状态
|
||||
│ │ ├── TOP3 卡片区
|
||||
│ │ ├── 排名列表
|
||||
│ │ └── 加载更多
|
||||
│ └── 当前用户栏(固定底部)
|
||||
```
|
||||
|
||||
## 样式
|
||||
|
||||
复用 `RankingModal.vue` 的现有样式主题(渐变背景、卡片样式等)。
|
||||
|
||||
## 触发方式
|
||||
|
||||
在 `ThemeBanner` 组件上添加点击事件,打开 `ActivityRankingModal`。
|
||||
@ -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 | 初始设计 |
|
||||
@ -78,8 +78,6 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
|
||||
<!-- 用户昵称(在头像下方) -->
|
||||
<view class="nickname-container">
|
||||
<text class="user-nickname">{{'用户 :'}}
|
||||
|
||||
@ -638,8 +638,16 @@ defineExpose({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
/* background: rgba(0, 0, 0, 0.2); */
|
||||
padding: 8rpx 20rpx;
|
||||
background: linear-gradient(to bottom right,
|
||||
#F0E4B1 0%,
|
||||
#F08399 50%,
|
||||
#B94E73 100%);
|
||||
border-radius: 24rpx;
|
||||
box-shadow:
|
||||
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
|
||||
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
|
||||
border-radius: 30rpx;
|
||||
}
|
||||
|
||||
@ -715,7 +723,6 @@ defineExpose({
|
||||
gap: 24rpx;
|
||||
padding-top: 24rpx;
|
||||
border-top: 1rpx solid rgba(255, 255, 255, 0.3);
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.quantity-selector {
|
||||
|
||||
1386
frontend/pages/support-activity/components/ActivityRankingModal.vue
Normal file
1386
frontend/pages/support-activity/components/ActivityRankingModal.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<view class="contribution-list" v-if="visible">
|
||||
<view class="list-header">
|
||||
<!-- <view class="list-header">
|
||||
<text class="header-title">实时贡献</text>
|
||||
</view>
|
||||
</view> -->
|
||||
<scroll-view class="list-content" scroll-y>
|
||||
<view
|
||||
v-for="(record, index) in records"
|
||||
@ -73,12 +73,12 @@ defineExpose({
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5);
|
||||
/* text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5); */
|
||||
}
|
||||
|
||||
.list-content {
|
||||
height: 200rpx;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
/* background: rgba(0, 0, 0, 0.3); */
|
||||
border-radius: 16rpx;
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
/>
|
||||
|
||||
<!-- 内容层 -->
|
||||
<view class="banner-content">
|
||||
<view class="banner-content" @tap="handleBannerClick">
|
||||
|
||||
<!-- 上半部分:标题区域 -->
|
||||
<view class="title-section">
|
||||
@ -74,6 +74,12 @@ watch(() => props.target, (newVal) => {
|
||||
formattedTarget.value = newVal.toLocaleString()
|
||||
}, { immediate: false })
|
||||
|
||||
const emit = defineEmits(['tap'])
|
||||
|
||||
const handleBannerClick = () => {
|
||||
emit('tap')
|
||||
}
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (props.target === 0) return 0
|
||||
return Math.min((props.current / props.target) * 100, 100)
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
:current="progressData.current"
|
||||
:target="progressData.target"
|
||||
:is-stale-data="isStaleData"
|
||||
@tap="openRankingModal"
|
||||
/>
|
||||
|
||||
<!-- 实时贡献列表 -->
|
||||
@ -99,6 +100,16 @@
|
||||
<button class="retry-button" @click="retryLoad">重试</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 排行榜弹窗 -->
|
||||
<ActivityRankingModal
|
||||
v-model:visible="rankingModalVisible"
|
||||
:activity-id="activityId"
|
||||
:activity-title="currentActivityTitle"
|
||||
@visit="handleVisitUser"
|
||||
@view-profile="handleViewUserProfile"
|
||||
@view-artwork="handleViewArtwork"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@ -118,6 +129,7 @@ import ThemeBanner from './components/ThemeBanner.vue'
|
||||
import ContributionList from './components/ContributionList.vue'
|
||||
import StageArea from './components/StageArea.vue'
|
||||
import FloatingBubbles from './components/FloatingBubbles.vue'
|
||||
import ActivityRankingModal from './components/ActivityRankingModal.vue'
|
||||
import ActionBar from './components/ActionBar.vue'
|
||||
|
||||
const activityType = ref('birthday')
|
||||
@ -139,10 +151,35 @@ const navExpanded = ref(false)
|
||||
// ActionBar 弹出框状态
|
||||
const actionBarVisible = ref(false)
|
||||
|
||||
// 排行榜弹窗状态
|
||||
const rankingModalVisible = ref(false)
|
||||
const currentActivityTitle = ref('')
|
||||
|
||||
function toggleActionBar() {
|
||||
actionBarVisible.value = !actionBarVisible.value
|
||||
}
|
||||
|
||||
// 打开排行榜弹窗
|
||||
function openRankingModal() {
|
||||
currentActivityTitle.value = config.value?.title || '活动排名'
|
||||
rankingModalVisible.value = true
|
||||
}
|
||||
|
||||
// 处理拜访用户
|
||||
function handleVisitUser(data) {
|
||||
console.log('拜访用户:', data)
|
||||
}
|
||||
|
||||
// 处理查看用户资料
|
||||
function handleViewUserProfile(data) {
|
||||
console.log('查看用户资料:', data)
|
||||
}
|
||||
|
||||
// 处理查看作品
|
||||
function handleViewArtwork(data) {
|
||||
console.log('查看作品:', data)
|
||||
}
|
||||
|
||||
let progressManager = null
|
||||
|
||||
const isCompleted = computed(() => progressData.value.current >= progressData.value.target)
|
||||
@ -732,5 +769,7 @@ onUnload(() => {
|
||||
.contribution-list-wrapper {
|
||||
width: 100%;
|
||||
padding: 0 24rpx;
|
||||
position: relative;
|
||||
top: 192rpx;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 630 KiB After Width: | Height: | Size: 496 KiB |
Loading…
Reference in New Issue
Block a user