Compare commits

...

16 Commits

Author SHA1 Message Date
zerosaturation
3ef3510303 feat:修改展示收益 2026-05-15 11:49:00 +08:00
zerosaturation
2a0faeb835 feat: 实现灵感瀑布流双向滚动 Redis 缓存
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 11:49:00 +08:00
zerosaturation
2b4077c0cd feat: 添加灵感瀑布流 Redis 会话缓存操作
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 11:49:00 +08:00
zerosaturation
29becda3bc docs: 添加灵感瀑布流 Redis 会话缓存实现计划
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 11:49:00 +08:00
zerosaturation
7d807d395b feat: JWT 中间件添加 Token 黑名单检查
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 11:49:00 +08:00
zerosaturation
5c46ae660f feat: 初始化 Redis 连接
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 11:49:00 +08:00
zerosaturation
e599f8a349 feat: 添加 Redis 配置项
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 11:49:00 +08:00
zerosaturation
2ebb4a3a4d deps: 添加 redis go-redis/v9 依赖
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 11:49:00 +08:00
zerosaturation
07b3520e6e feat: 添加 Redis 客户端和 Token 黑名单模块
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 11:49:00 +08:00
zerosaturation
f4f987d068 docs: 修复实现计划中的问题
- 修改 Redis 错误时 fail-closed(安全策略)
- 添加 Task 7 说明后续封禁 API 的扩展方向
- 添加 go.mod replace 说明

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 11:49:00 +08:00
zerosaturation
9978310e12 docs: 添加 Redis Token 黑名单实现计划
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 11:49:00 +08:00
zerosaturation
ac0eb55bc0 docs: 优化 Redis Token 黑名单设计
- 使用 SHA256 哈希代替原始 token 作为 Key
- 使用 JSON 格式存储 value,避免解析问题
- 添加输入验证

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 11:49:00 +08:00
zerosaturation
342beb5f17 docs: 添加 JWT Token 黑名单设计文档
设计 Redis Token 黑名单功能,用于账号封禁和强制下线场景。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-15 11:49:00 +08:00
281e5b5c59 style:修改样式 2026-05-15 11:49:00 +08:00
375816ea79 feat: 添加 ActivityRankingModal 活动榜单弹窗组件
- 新建 ActivityRankingModal.vue 组件,实现单活动排名展示
- TOP3 展示使用 TOP3Card 组件
- 排名列表使用 RankingListItem 组件
- 支持下拉刷新和滚动加载更多
- 当前用户栏固定底部显示
- ThemeBanner 添加 @tap 事件触发弹窗
- index.vue 集成 ActivityRankingModal

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 11:49:00 +08:00
e106a490ce docs: 添加活动榜单弹窗设计文档
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 11:49:00 +08:00
29 changed files with 4462 additions and 160 deletions

View File

@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Skill(superpowers:subagent-driven-development)",
"Skill(superpowers:subagent-driven-development:*)",
"Bash(go build:*)",
"Bash(go vet:*)"
]
}
}

View File

@ -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),
},
}
}

View File

@ -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

View File

@ -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=

View File

@ -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...")

View File

@ -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)

View File

@ -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=

View 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]
}

View File

@ -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" +

View File

@ -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
}
//

View File

@ -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 {

View File

@ -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
}

View 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
}

View File

@ -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
}

View File

@ -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 更新头像

View File

@ -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) {

View File

@ -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`

View File

@ -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 | 初始实现计划 |

View File

@ -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 层集成 RedisJWT 中间件每次请求检查 Token 是否在黑名单。Redis Key 使用 SHA256 哈希存储Value 使用 JSON 格式。
**Tech Stack:** Go, github.com/redis/go-redis/v9, gin middleware
---
## 文件结构
```
backend/pkg/database/
├── database.go # 现有 PostgreSQLGORM
└── 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 | 初始实现计划 |

View File

@ -677,15 +677,284 @@ onHide(() => {
---
## 10. 待确认
---
- [x] 后端接口由我方实现
- [x] 道具图标由后端提供(冗余字段直接存储)
- [x] 仅显示当前页面实时数据(页面切换时暂停)
- [x] 后端框架Go + GinGateway+ Dubbo-go微服务+ GORM
- [x] 数据库MySQL
- [x] ORMGORM
- [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

View File

@ -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`

View File

@ -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 | 初始设计 |

View File

@ -78,8 +78,6 @@
</view>
</view>
<!-- 用户昵称在头像下方 -->
<view class="nickname-container">
<text class="user-nickname">{{'用户 :'}}

View File

@ -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 {

File diff suppressed because it is too large Load Diff

View File

@ -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;
}

View File

@ -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)

View File

@ -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