feat: 修改藏品展示等级和藏品没有同步问题

This commit is contained in:
zerosaturation 2026-05-29 12:18:28 +08:00
parent bfae6b9851
commit 3f58a82a91
6 changed files with 449 additions and 33 deletions

View File

@ -11,9 +11,11 @@ import (
)
const (
BlacklistKeyPrefix = "blacklist:token:"
InspirationFlowKeyPrefix = "inspiration_flow:"
AssetLikersKeyPrefix = "asset_likers:"
BlacklistKeyPrefix = "blacklist:token:"
InspirationFlowKeyPrefix = "inspiration_flow:"
AssetLikersKeyPrefix = "asset_likers:"
ExpiringAssetsKey = "expiring_assets"
ExpiringAssetsPerStarKey = "expiring_assets:star:"
)
// AssetLikersCache 缓存数据结构
@ -320,3 +322,113 @@ func InvalidateAssetLikersCache(ctx context.Context, assetID int64) error {
key := AssetLikersKey(assetID)
return RedisClient.Del(ctx, key).Err()
}
// AddExpiringAsset 添加到过期资产 ZSET (score = expire_at)
func AddExpiringAsset(ctx context.Context, assetID int64, expireAt int64) error {
if RedisClient == nil {
return nil
}
// 使用 ZADDscore 为过期时间戳
return RedisClient.ZAdd(ctx, ExpiringAssetsKey, redis.Z{
Score: float64(expireAt),
Member: assetID,
}).Err()
}
// AddExpiringAssetToStar 添加到指定 star 的过期资产 ZSET
func AddExpiringAssetToStar(ctx context.Context, starID int64, assetID int64, expireAt int64) error {
if RedisClient == nil {
return nil
}
key := fmt.Sprintf("%s%d", ExpiringAssetsPerStarKey, starID)
return RedisClient.ZAdd(ctx, key, redis.Z{
Score: float64(expireAt),
Member: assetID,
}).Err()
}
// RemoveExpiringAsset 从过期资产 ZSET 移除
func RemoveExpiringAsset(ctx context.Context, assetID int64) error {
if RedisClient == nil {
return nil
}
return RedisClient.ZRem(ctx, ExpiringAssetsKey, assetID).Err()
}
// RemoveExpiringAssetFromStar 从指定 star 的过期资产 ZSET 移除
func RemoveExpiringAssetFromStar(ctx context.Context, starID int64, assetID int64) error {
if RedisClient == nil {
return nil
}
key := fmt.Sprintf("%s%d", ExpiringAssetsPerStarKey, starID)
return RedisClient.ZRem(ctx, key, assetID).Err()
}
// GetExpiredAssets 获取已过期的资产ID列表 (score <= now)
func GetExpiredAssets(ctx context.Context, now int64) ([]int64, error) {
if RedisClient == nil {
return nil, fmt.Errorf("redis client not initialized")
}
// ZRANGEBYSCORE 获取 score 在 [0, now] 范围内的成员
results, err := RedisClient.ZRangeByScore(ctx, ExpiringAssetsKey, &redis.ZRangeBy{
Min: "0",
Max: fmt.Sprintf("%d", now),
}).Result()
if err != nil {
return nil, err
}
assetIDs := make([]int64, 0, len(results))
for _, r := range results {
var id int64
if _, err := fmt.Sscanf(r, "%d", &id); err == nil {
assetIDs = append(assetIDs, id)
}
}
return assetIDs, nil
}
// GetExpiredAssetsFromStar 获取指定 star 已过期的资产ID列表
func GetExpiredAssetsFromStar(ctx context.Context, starID int64, now int64) ([]int64, error) {
if RedisClient == nil {
return nil, fmt.Errorf("redis client not initialized")
}
key := fmt.Sprintf("%s%d", ExpiringAssetsPerStarKey, starID)
results, err := RedisClient.ZRangeByScore(ctx, key, &redis.ZRangeBy{
Min: "0",
Max: fmt.Sprintf("%d", now),
}).Result()
if err != nil {
return nil, err
}
assetIDs := make([]int64, 0, len(results))
for _, r := range results {
var id int64
if _, err := fmt.Sscanf(r, "%d", &id); err == nil {
assetIDs = append(assetIDs, id)
}
}
return assetIDs, nil
}
// GetAllExpiringAssets 获取所有待处理的资产ID未过期的也返回用于校验
func GetAllExpiringAssets(ctx context.Context) ([]int64, error) {
if RedisClient == nil {
return nil, fmt.Errorf("redis client not initialized")
}
results, err := RedisClient.ZRange(ctx, ExpiringAssetsKey, 0, -1).Result()
if err != nil {
return nil, err
}
assetIDs := make([]int64, 0, len(results))
for _, r := range results {
var id int64
if _, err := fmt.Sscanf(r, "%d", &id); err == nil {
assetIDs = append(assetIDs, id)
}
}
return assetIDs, nil
}

View File

@ -157,15 +157,23 @@ func (r *assetRepository) GetGradeByAssetID(assetID int64) (int32, error) {
return 0, nil
}
// UpdateGradeByAssetID 根据asset_id更新藏品等级用于同步AssetLevelRecord.CurrentLevel到AssetRegistry.Grade
// UpdateGradeByAssetID 根据asset_id更新藏品等级用于同步AssetLevelRecord.CurrentLevel到AssetRegistry.Grade和Assets.Grade
func (r *assetRepository) UpdateGradeByAssetID(assetID int64, grade int32) error {
if assetID <= 0 {
return errors.New("asset_id must be greater than 0")
}
return r.db.Table("public.asset_registry").
Where("asset_id = ?", assetID).
Update("grade", grade).Error
// 同时更新 assets.grade 和 asset_registry.grade
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.Asset{}).
Where("id = ?", assetID).
Update("grade", grade).Error; err != nil {
return err
}
return tx.Table("public.asset_registry").
Where("asset_id = ?", assetID).
Update("grade", grade).Error
})
}
// GetByIDs 批量查询资产

View File

@ -23,6 +23,7 @@ type GalleryRepository interface {
// 展品相关
GetExhibitionByAsset(assetID int64) (*models.Exhibition, error)
GetExhibitionByAssetID(assetID int64) (*models.Exhibition, error)
GetExhibitionBySlot(slotID int64) (*models.Exhibition, error)
GetActiveExhibitionBySlot(slotID, now int64) (*models.Exhibition, error)
GetExhibitionsByUser(userID, starID int64) ([]*models.Exhibition, error)
@ -228,6 +229,19 @@ func (r *galleryRepository) GetExhibitionByAsset(assetID int64) (*models.Exhibit
return &exhibition, nil
}
// GetExhibitionByAssetID 根据资产ID获取活跃展品展示记录未删除且未处理
func (r *galleryRepository) GetExhibitionByAssetID(assetID int64) (*models.Exhibition, error) {
var exhibition models.Exhibition
err := r.db.Where("asset_id = ? AND deleted_at IS NULL AND is_processed = false", assetID).First(&exhibition).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &exhibition, nil
}
// GetExhibitionBySlot 根据展位ID获取展品展示记录不含已删除
func (r *galleryRepository) GetExhibitionBySlot(slotID int64) (*models.Exhibition, error) {
var exhibition models.Exhibition

View File

@ -6,6 +6,7 @@ import (
"math"
"time"
"github.com/topfans/backend/pkg/database"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/services/galleryService/client"
"github.com/topfans/backend/services/galleryService/repository"
@ -37,9 +38,9 @@ func NewCleanupWorker(repo repository.GalleryRepository, assetClient client.Asse
// Start 启动清理Worker
func (w *CleanupWorker) Start() {
log.Println("清理Worker已启动分钟扫描一次过期展品")
log.Println("清理Worker已启动小时扫描一次过期展品")
ticker := time.NewTicker(1 * time.Minute)
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
// 立即执行一次清理
@ -72,37 +73,46 @@ func (w *CleanupWorker) cleanup() {
w.cleanupInvalidDisplayStatus()
}
// cleanupExpiredExhibitions 清理过期的展品展示记录
// cleanupExpiredExhibitions 清理过期的展品展示记录(使用 ZSET 驱动)
func (w *CleanupWorker) cleanupExpiredExhibitions(now int64) {
// 获取过期的展品展示记录
expired, err := w.repo.GetExpiredExhibitions(now)
ctx := context.Background()
// 使用 ZSET 获取已过期的 asset_id 列表
expiredAssetIDs, err := database.GetExpiredAssets(ctx, now)
if err != nil {
log.Printf("获取过期展品失败: %v", err)
log.Printf("从 ZSET 获取过期展品失败: %v", err)
// 降级:从数据库查询(兼容未初始化 Redis 的情况)
w.cleanupExpiredExhibitionsFromDB(now)
return
}
if len(expired) == 0 {
if len(expiredAssetIDs) == 0 {
log.Println("没有过期的展品需要清理")
return
}
log.Printf("发现 %d 个过期展品,开始清理", len(expired))
log.Printf("ZSET 发现 %d 个过期展品,开始清理", len(expiredAssetIDs))
// 批量删除过期记录
successCount := 0
failedCount := 0
for _, e := range expired {
for _, assetID := range expiredAssetIDs {
// 从数据库查询该 asset_id 对应的有效展览
e, err := w.repo.GetExhibitionByAssetID(assetID)
if err != nil || e == nil {
// 展览不存在或已处理,从 ZSET 移除
database.RemoveExpiringAsset(ctx, assetID)
continue
}
// 1. 获取点赞数用于计算收益
likeCount := 0
if w.assetClient != nil {
likeCount = w.assetClient.GetAssetLikeCount(e.AssetID)
}
// 2. 计算展示收益(使用 Buff 公式)
// R1 = R0 × T × [100% + Buff(n)]
// R0 = 5 水晶/小时
// Buff(n): n<5→0%, 5≤n<10→10%, 10≤n<30→20%, n≥30→30%
// 2. 计算展示收益
revenue := calculateExhibitionRevenue(likeCount, e.StartTime, now)
logger.Logger.Info("计算展出收益",
@ -114,10 +124,8 @@ func (w *CleanupWorker) cleanupExpiredExhibitions(now int64) {
zap.Int64("revenue", revenue))
// 3. 调用 TaskService 记录收益
// 注意:仅当展位所有者和占位者不同时才创建收益记录
if w.taskClient != nil {
// 获取展位所有者的实际用户ID而非profile_id
slotOwnerUID := e.HostProfileID // 默认使用HostProfileID
slotOwnerUID := e.HostProfileID
if ownerUID, err := w.repo.GetSlotOwnerUserID(e.SlotID); err == nil {
slotOwnerUID = ownerUID
}
@ -129,37 +137,116 @@ func (w *CleanupWorker) cleanupExpiredExhibitions(now int64) {
OccupierUid: e.OccupierUID,
OccupierStarId: e.OccupierStarID,
SlotOwnerUid: slotOwnerUID,
StartTime: e.StartTime,
ExpireAt: now,
StartTime: e.StartTime,
ExpireAt: now,
CrystalAmount: revenue,
LikeCount: int32(likeCount),
})
if err != nil {
logger.Logger.Error("调用TaskService记录收益失败,跳过标记已处理以便重试",
logger.Logger.Error("调用TaskService记录收益失败",
zap.Int64("exhibition_id", e.ID),
zap.Error(err))
failedCount++
continue // 不标记为已处理,让下次重试
continue
}
}
// 4. 不再删除展品 - 用户领取收益时才会下架
// 此处只更新展品状态为过期(可选),展品记录保留供用户领取
successCount++
// 4.5 标记展品已处理,防止重复生成收益记录
// 4. 标记展品已处理
if err := w.repo.SetExhibitionProcessed(e.ID, true); err != nil {
logger.Logger.Error("标记展品已处理失败",
zap.Int64("exhibition_id", e.ID),
zap.Error(err))
}
// 5. 从 ZSET 移除
database.RemoveExpiringAsset(ctx, assetID)
database.RemoveExpiringAssetFromStar(ctx, e.OccupierStarID, assetID)
log.Printf("展品已到期并生成领取记录: ExhibitionID=%d, AssetID=%d, SlotID=%d, OccupierUID=%d, Revenue=%d",
e.ID, e.AssetID, e.SlotID, e.OccupierUID, revenue)
}
// 5. 保留点赞记录,允许用户在下次展出时再次点赞(每次展览可点赞一次)
// 注意:点赞记录现在按 (asset_id, user_id, exhibition_id) 去重,不会与历史点赞冲突
log.Printf("过期展品清理完成: 成功 %d 个, 失败 %d 个", successCount, failedCount)
}
// cleanupExpiredExhibitionsFromDB 降级方案:从数据库查询过期展览
func (w *CleanupWorker) cleanupExpiredExhibitionsFromDB(now int64) {
expired, err := w.repo.GetExpiredExhibitions(now)
if err != nil {
log.Printf("从数据库获取过期展品失败: %v", err)
return
}
if len(expired) == 0 {
log.Println("没有过期的展品需要清理")
return
}
log.Printf("数据库发现 %d 个过期展品,开始清理", len(expired))
successCount := 0
failedCount := 0
ctx := context.Background()
for _, e := range expired {
likeCount := 0
if w.assetClient != nil {
likeCount = w.assetClient.GetAssetLikeCount(e.AssetID)
}
revenue := calculateExhibitionRevenue(likeCount, e.StartTime, now)
logger.Logger.Info("计算展出收益",
zap.Int64("exhibition_id", e.ID),
zap.Int64("asset_id", e.AssetID),
zap.Int("like_count", likeCount),
zap.Int64("start_time", e.StartTime),
zap.Int64("end_time", now),
zap.Int64("revenue", revenue))
if w.taskClient != nil {
slotOwnerUID := e.HostProfileID
if ownerUID, err := w.repo.GetSlotOwnerUserID(e.SlotID); err == nil {
slotOwnerUID = ownerUID
}
_, err := w.taskClient.OnExhibitionCompleted(context.Background(), &client.OnExhibitionCompletedRequest{
ExhibitionId: e.ID,
AssetId: e.AssetID,
SlotId: e.SlotID,
OccupierUid: e.OccupierUID,
OccupierStarId: e.OccupierStarID,
SlotOwnerUid: slotOwnerUID,
StartTime: e.StartTime,
ExpireAt: now,
CrystalAmount: revenue,
LikeCount: int32(likeCount),
})
if err != nil {
logger.Logger.Error("调用TaskService记录收益失败",
zap.Int64("exhibition_id", e.ID),
zap.Error(err))
failedCount++
continue
}
}
successCount++
if err := w.repo.SetExhibitionProcessed(e.ID, true); err != nil {
logger.Logger.Error("标记展品已处理失败",
zap.Int64("exhibition_id", e.ID),
zap.Error(err))
}
// 降级方案也需要清理 ZSET
database.RemoveExpiringAsset(ctx, e.AssetID)
database.RemoveExpiringAssetFromStar(ctx, e.OccupierStarID, e.AssetID)
log.Printf("展品已到期并生成领取记录: ExhibitionID=%d, AssetID=%d, SlotID=%d, OccupierUID=%d, Revenue=%d",
e.ID, e.AssetID, e.SlotID, e.OccupierUID, revenue)
}
log.Printf("过期展品清理完成: 成功 %d 个, 失败 %d 个", successCount, failedCount)

View File

@ -5,6 +5,7 @@ import (
"errors"
"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"
@ -109,6 +110,11 @@ func (s *exhibitionService) PlaceAsset(userID, starID int64, req *pb.PlaceAssetR
return nil, err
}
// 6.5 添加到 ZSET 用于过期清理
ctx := context.Background()
database.AddExpiringAsset(ctx, exhibition.AssetID, exhibition.ExpireAt)
database.AddExpiringAssetToStar(ctx, starID, exhibition.AssetID, exhibition.ExpireAt)
// 7. 发布事件(不再强依赖同步更新 Asset Service 状态)
// TODO: 实现事件发布逻辑
// go s.publishEvent("gallery.exhibit", exhibition)
@ -152,6 +158,11 @@ func (s *exhibitionService) RemoveFromSlot(userID, starID int64, req *pb.RemoveF
return err
}
// 4.5 从 ZSET 移除
ctx := context.Background()
database.RemoveExpiringAsset(ctx, exhibition.AssetID)
database.RemoveExpiringAssetFromStar(ctx, exhibition.OccupierStarID, exhibition.AssetID)
// 5. 保留点赞记录,允许下次展出时用户重新点赞
// 点赞记录用于历史查询,每次展出通过 exhibition_id 区分
// 6. 发布事件
@ -303,6 +314,10 @@ func (s *exhibitionService) RemoveExhibitionByAsset(ctx context.Context, assetID
return err
}
// 2.5 从 ZSET 移除
database.RemoveExpiringAsset(ctx, exhibition.AssetID)
database.RemoveExpiringAssetFromStar(ctx, exhibition.OccupierStarID, exhibition.AssetID)
// 3. 保留点赞记录,允许下次展出时用户重新点赞
// 点赞记录用于历史查询,每次展出通过 exhibition_id 区分

View File

@ -0,0 +1,180 @@
# TOPFANS 排行榜页面结构分析
## 页面概述
- **页面名称**: HOME
- **尺寸**: 375 x 1588 (移动端)
- **风格**: 梦幻甜美、饭圈审美、粉色系渐变
---
## 一、页面布局结构(从上到下)
```
┌─────────────────────────────────┐
│ 1. 状态栏 (iPhone Status Bar) │ 高度: ~20px
├─────────────────────────────────┤
│ 2. 顶部用户区 │ y: 45px
│ - 积分: 2713 │
│ - 每日任务 | 巴士应援 图标 │
├─────────────────────────────────┤
│ 3. Banner 横幅区域 │ y: 264px
│ - 背景装饰 (模糊渐变) │
│ - "TOPFANS 排行榜" 主标题 │
│ - "日榜" 标签 │
├─────────────────────────────────┤
│ 4. 藏品内容区 (3x4 Grid) │ y: 293px
│ - 12 个藏品卡片 │
│ - 每个卡片带 TOP 1-12 排名 │
│ - 点赞数和文案 │
│ - "查看更多" 按钮 │
├─────────────────────────────────┤
│ 5. 分类导航区 │ y: 950px
│ - 星卡 | 吧唧 | 海报 │
│ - 3 个大图标导航 │
├─────────────────────────────────┤
│ 6. 筛选 Tab 栏 │
│ - 热门作品 | 最新作品 │
├─────────────────────────────────┤
│ 7. 瀑布流作品展示区 │
│ - 多张卡片,含图片+点赞+文案 │
└─────────────────────────────────┘
```
---
## 二、核心组件详解
### 2.1 状态栏 (1:29 -> 1:7)
- iPhone 状态栏组件,包含时间、信号、电池图标
### 2.2 顶部用户区 (活动栏位)
| 元素 | 类型 | 说明 |
|------|------|------|
| 每日任务 | 文字 | 粉水晶样式 |
| 巴士应援 | 文字 | 粉水晶样式 |
| 用户头像 | 图片 | 圆形 |
| 积分显示 | 文字 (2713) | 白色带阴影 |
### 2.3 Banner 横幅 (banner条)
```
- 背景: 模糊渐变 (粉红到透明)
- "TOPFANS 排行榜" 主标题
- "日榜" 标签 (右上角)
- 装饰性圆形图案
```
### 2.4 藏品内容区 (Grid)
- **布局**: 3列 x 4行
- **卡片结构**:
```
┌─────────────┐
│ 图片 │ ← 带圆角的主图
│ ❤️ 9653 │ ← 点赞图标 + 数字
├─────────────┤
│ 新铸爱的小卡也太绝了 │ ← 文案
├─────────────┤
│ TOP 1 │ ← 排名标签 (金色)
└─────────────┘
```
- **卡片样式**:
- 图片圆角: 8px
- 背景: 白色圆角卡片带阴影
- 点赞数: 带文字阴影
- TOP 标签: 金色/红色渐变
### 2.5 分类导航区 (顶部金刚区)
| 图标 | 名称 | 样式 |
|------|------|------|
| 星卡 | 星卡 | 粉色水晶风格opacity 0.75 |
| 吧唧 | 吧唧 | 同上 |
| 海报 | 海报 | 同上 |
### 2.6 筛选 Tab 栏
- **热门作品**: 选中状态 (opacity 0.75)
- **最新作品**: 未选中状态 (opacity 0.52)
- 样式: 胶囊形状,半透明背景
### 2.7 瀑布流展示区
- 每张卡片包含:
- 图片 (带圆角)
- 点赞按钮 + 数字
- 文案标题
---
## 三、颜色主题
| 用途 | 颜色 |
|------|------|
| 主背景渐变 | linear-gradient(126deg, #FFE5E5, #FF9C9C, #6AC5D3) |
| 卡片背景 | #FFFFFF |
| 强调/排名 | #FC6466, #F45668, #FFF60F |
| 点赞红心 | #FF5A5D |
| 文字主色 | #FFFFFF |
| 文字深色 | #000000 |
| Tab 未选中 | opacity 0.52 |
| Tab 选中 | opacity 0.75 |
---
## 四、阴影与效果
| 效果 | 参数 |
|------|------|
| 粉色卡片阴影 | box-shadow: 2px 2px 4px rgba(242, 21, 21, 0.47) |
| 应援图标阴影 | box-shadow: 0px 4px 4px rgba(238, 38, 38, 0.33) |
| 模糊背景 | blur(4px), blur(9.3px) |
| 状态栏背景 | blur(4px) |
---
## 五、文字样式
| 用途 | 样式 |
|------|------|
| TOP 排名 | 金色带阴影 (style_GT3LFV) |
| 日榜标签 | 白色带阴影 |
| 藏品文案 | 白色带阴影 (style_S0XHUK) |
| 瀑布发文案 | 同上 |
| 分类名称 | 白色带阴影 (style_0R0H6H) |
| 积分数字 | 白色 (style_F5RO1M) |
---
## 六、数据内容示例
### 排行榜藏品 (12个)
| 排名 | 点赞数 | 文案 |
|------|--------|------|
| TOP 1 | 9653 | 新铸爱的小卡也太绝了 |
| TOP 2 | 9430 | 今日翻牌:这张海报… |
| TOP 3 | 7933 | 细节控狂喜 |
| TOP 4 | 8034 | 海报墙又添新成员了 |
| TOP 5 | 9223 | 这张小卡光影感绝了 |
| TOP 6 | 6420 | 展开那一刻,忍不住… |
| TOP 7 | 6342 | 必须单独发一条 |
| TOP 8 | 9070 | 这个角度我直接封神 |
| TOP 9 | 5943 | 这套效果每张都想单独说一次 |
| TOP 10 | 5270 | 新进一位门面 |
| TOP 11 | 4766 | 压箱底的海报 |
| TOP 12 | 4326 | 铸爱进度又往前了 |
### 瀑布流内容 (示例)
| 点赞数 | 文案 |
|--------|------|
| 3244 | 来给大家看看我新到手的宝贝呀~~~ |
| 7189 | マイドラだゆううううう |
| 147 | 咕咕嘎嘎 |
---
## 七、结论
这是一个**粉丝应援类排行榜页面**,核心功能:
1. 展示用户积分和应援任务入口
2. TOP 12 藏品排行榜 (3x4 grid)
3. 分类导航 (星卡/吧唧/海报)
4. 热门/最新作品筛选
5. 瀑布流UGC内容展示
**开发优先级建议**: 高 (核心排行榜功能)