diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 67d237e..443a56a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,8 @@ "Skill(superpowers:subagent-driven-development:*)", "Bash(go build:*)", "Bash(go vet:*)", - "mcp__code-review-graph__semantic_search_nodes_tool" + "mcp__code-review-graph__semantic_search_nodes_tool", + "Skill(superpowers:brainstorming)" ] }, "enableAllProjectMcpServers": true, diff --git a/backend/services/assetService/repository/asset_like_repository.go b/backend/services/assetService/repository/asset_like_repository.go index 5c707ea..daeba86 100644 --- a/backend/services/assetService/repository/asset_like_repository.go +++ b/backend/services/assetService/repository/asset_like_repository.go @@ -21,6 +21,9 @@ type AssetLikeRepository interface { // ExistsByAsset 检查用户是否对资产有点赞记录(不管 exhibition_id) ExistsByAsset(assetID, userID, starID int64) (bool, error) + // ExistsByAssetAndExhibition 检查用户是否对资产在指定展览中有点赞记录 + ExistsByAssetAndExhibition(assetID, userID, starID, exhibitionID int64) (bool, error) + // GetByAsset 获取资产的点赞记录列表(分页) GetByAsset(assetID int64, limit, offset int) ([]*models.AssetLike, error) @@ -142,8 +145,8 @@ func (r *assetLikeRepository) Exists(assetID, userID, starID, exhibitionID int64 return count > 0, nil } -// ExistsByAsset 检查用户是否对资产有点赞记录(不管 exhibition_id) -// 用于排行榜等场景,判断用户是否对某个藏品点赞过 +// ExistsByAsset 检查用户是否对资产在当前展览中有点赞记录 +// 用于排行榜等场景,判断用户是否在当前展览中对某个藏品点赞过 func (r *assetLikeRepository) ExistsByAsset(assetID, userID, starID int64) (bool, error) { if assetID <= 0 { return false, errors.New("asset_id must be greater than 0") @@ -169,6 +172,36 @@ func (r *assetLikeRepository) ExistsByAsset(assetID, userID, starID int64) (bool return count > 0, nil } +// ExistsByAssetAndExhibition 检查用户是否对资产在指定展览中有点赞记录 +func (r *assetLikeRepository) ExistsByAssetAndExhibition(assetID, userID, starID, exhibitionID int64) (bool, error) { + if assetID <= 0 { + return false, errors.New("asset_id must be greater than 0") + } + + if userID <= 0 { + return false, errors.New("user_id must be greater than 0") + } + + if starID <= 0 { + return false, errors.New("star_id must be greater than 0") + } + + if exhibitionID <= 0 { + return false, errors.New("exhibition_id must be greater than 0") + } + + var count int64 + err := r.db.Model(&models.AssetLike{}). + Where("asset_id = ? AND user_id = ? AND star_id = ? AND exhibition_id = ?", assetID, userID, starID, exhibitionID). + Count(&count).Error + + if err != nil { + return false, err + } + + return count > 0, nil +} + // GetByAsset 获取资产的点赞记录列表(分页) func (r *assetLikeRepository) GetByAsset(assetID int64, limit, offset int) ([]*models.AssetLike, error) { if assetID <= 0 { diff --git a/backend/services/assetService/service/ranking_service.go b/backend/services/assetService/service/ranking_service.go index d638d5e..fe235ff 100644 --- a/backend/services/assetService/service/ranking_service.go +++ b/backend/services/assetService/service/ranking_service.go @@ -86,7 +86,7 @@ func (s *rankingService) GetHotRanking(ctx context.Context, req *ranking.GetRank // 判断当前用户是否已点赞(只有登录用户才检查,且仅当藏品在展示中时) isLiked := false if req.UserId > 0 && item.ExhibitionID > 0 { - exists, err := s.assetLikeRepo.ExistsByAsset(item.AssetID, req.UserId, starID) + exists, err := s.assetLikeRepo.ExistsByAssetAndExhibition(item.AssetID, req.UserId, starID, item.ExhibitionID) if err == nil { isLiked = exists } @@ -211,7 +211,7 @@ func (s *rankingService) GetOriginalRanking(ctx context.Context, req *ranking.Ge // 判断当前用户是否已点赞(只有登录用户才检查,且仅当藏品在展示中时) isLiked := false if req.UserId > 0 && item.ExhibitionID > 0 { - exists, err := s.assetLikeRepo.ExistsByAsset(item.AssetID, req.UserId, starID) + exists, err := s.assetLikeRepo.ExistsByAssetAndExhibition(item.AssetID, req.UserId, starID, item.ExhibitionID) if err == nil { isLiked = exists } diff --git a/backend/services/galleryService/repository/gallery_repository.go b/backend/services/galleryService/repository/gallery_repository.go index f10c859..b274428 100644 --- a/backend/services/galleryService/repository/gallery_repository.go +++ b/backend/services/galleryService/repository/gallery_repository.go @@ -315,14 +315,54 @@ func (r *galleryRepository) DeleteExhibition(exhibitionID int64) error { // DeleteExhibitionByAsset 软删除展品展示记录(根据资产ID) func (r *galleryRepository) DeleteExhibitionByAsset(assetID int64) error { now := time.Now().UnixMilli() - return r.db.Model(&models.Exhibition{}). + + // 获取将被删除的展览ID列表 + var exhibitionIDs []int64 + if err := r.db.Model(&models.Exhibition{}). + Where("asset_id = ? AND deleted_at IS NULL", assetID). + Pluck("id", &exhibitionIDs).Error; err != nil { + return err + } + + // 软删除展览 + if err := r.db.Model(&models.Exhibition{}). Where("asset_id = ?", assetID). Updates(map[string]interface{}{ "deleted_at": now, "updated_at": now, - }).Error + }).Error; err != nil { + return err + } + + // 清理关联的待领取收益记录(防止孤儿记录) + if len(exhibitionIDs) > 0 { + r.db.Where("exhibition_id IN ? AND status = ?", exhibitionIDs, "claimable"). + Delete(&ExhibitionRevenueRecord{}) + } + + return nil } +// ExhibitionRevenueRecord 展览收益记录(本地定义,避免循环依赖) +type ExhibitionRevenueRecord struct { + ID int64 `gorm:"primaryKey;column:id;autoIncrement"` + UserID int64 `gorm:"column:user_id;not null"` + StarID int64 `gorm:"column:star_id;not null"` + ExhibitionID int64 `gorm:"column:exhibition_id;not null"` + AssetID int64 `gorm:"column:asset_id;not null"` + SlotID int64 `gorm:"column:slot_id;not null"` + SlotOwnerUID int64 `gorm:"column:slot_owner_uid;not null"` + SlotType string `gorm:"column:slot_type;size:20;not null"` + CrystalAmount int64 `gorm:"column:crystal_amount;not null"` + CycleStartTime int64 `gorm:"column:cycle_start_time;not null"` + CycleEndTime int64 `gorm:"column:cycle_end_time;not null"` + Status string `gorm:"column:status;size:20;default:claimable"` + ClaimedAt *int64 `gorm:"column:claimed_at"` + CreatedAt int64 `gorm:"column:created_at"` +} + +func (ExhibitionRevenueRecord) TableName() string { return "exhibition_revenue_records" } + // GetExpiredExhibitions 获取过期的展品展示记录(不含已删除且未处理) func (r *galleryRepository) GetExpiredExhibitions(beforeTime int64) ([]*models.Exhibition, error) { var exhibitions []*models.Exhibition diff --git a/backend/services/galleryService/service/cleanup_worker.go b/backend/services/galleryService/service/cleanup_worker.go index efe2c1d..c98d20a 100644 --- a/backend/services/galleryService/service/cleanup_worker.go +++ b/backend/services/galleryService/service/cleanup_worker.go @@ -40,7 +40,7 @@ func NewCleanupWorker(repo repository.GalleryRepository, assetClient client.Asse func (w *CleanupWorker) Start() { log.Println("清理Worker已启动,每小时扫描一次过期展品") - ticker := time.NewTicker(1 * time.Hour) + ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() // 立即执行一次清理 @@ -73,26 +73,30 @@ func (w *CleanupWorker) cleanup() { w.cleanupInvalidDisplayStatus() } -// cleanupExpiredExhibitions 清理过期的展品展示记录(使用 ZSET 驱动) +// cleanupExpiredExhibitions 清理过期的展品展示记录(使用 ZSET 驱动 + 数据库兜底) func (w *CleanupWorker) cleanupExpiredExhibitions(now int64) { ctx := context.Background() - // 使用 ZSET 获取已过期的 asset_id 列表 + // 1. 先尝试从 ZSET 获取过期展品 expiredAssetIDs, err := database.GetExpiredAssets(ctx, now) if err != nil { - log.Printf("从 ZSET 获取过期展品失败: %v", err) - // 降级:从数据库查询(兼容未初始化 Redis 的情况) + log.Printf("从 ZSET 获取过期展品失败: %v,降级到数据库查询", err) w.cleanupExpiredExhibitionsFromDB(now) return } - if len(expiredAssetIDs) == 0 { - log.Println("没有过期的展品需要清理") - return + // 2. ZSET 有数据时处理 ZSET 中的过期展品 + if len(expiredAssetIDs) > 0 { + log.Printf("ZSET 发现 %d 个过期展品,开始清理", len(expiredAssetIDs)) + w.cleanupAssetsFromZSET(ctx, expiredAssetIDs, now) } - log.Printf("ZSET 发现 %d 个过期展品,开始清理", len(expiredAssetIDs)) + // 3. 兜底:检查数据库中可能遗漏的过期展品(ZSET 可能在 Redis 重启或数据丢失时漏掉) + w.cleanupExpiredExhibitionsFromDB(now) +} +// cleanupAssetsFromZSET 从 ZSET 处理过期展品 +func (w *CleanupWorker) cleanupAssetsFromZSET(ctx context.Context, expiredAssetIDs []int64, now int64) { // 批量删除过期记录 successCount := 0 failedCount := 0 @@ -168,10 +172,10 @@ func (w *CleanupWorker) cleanupExpiredExhibitions(now int64) { e.ID, e.AssetID, e.SlotID, e.OccupierUID, revenue) } - log.Printf("过期展品清理完成: 成功 %d 个, 失败 %d 个", successCount, failedCount) + log.Printf("ZSET 过期展品清理完成: 成功 %d 个, 失败 %d 个", successCount, failedCount) } -// cleanupExpiredExhibitionsFromDB 降级方案:从数据库查询过期展览 +// cleanupExpiredExhibitionsFromDB 兜底方案:从数据库查询过期展览 func (w *CleanupWorker) cleanupExpiredExhibitionsFromDB(now int64) { expired, err := w.repo.GetExpiredExhibitions(now) if err != nil { diff --git a/backend/services/taskService/service/revenue_service.go b/backend/services/taskService/service/revenue_service.go index 290f933..a462d3c 100644 --- a/backend/services/taskService/service/revenue_service.go +++ b/backend/services/taskService/service/revenue_service.go @@ -177,30 +177,6 @@ func (s *revenueService) ClaimExhibitionRevenue(ctx context.Context, userID, sta return &pb.ClaimExhibitionRevenueResponse{Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_BAD_REQUEST}, Success: false}, nil } - // 增加用户累计上架时长(触发升级检查) - // 计算上架时长(毫秒转小时) - exhibitionHours := (record.CycleEndTime - record.CycleStartTime) / 3600000 - if s.userRPCClient != nil { - newLevel, levelDelta, crystalReward, err := s.userRPCClient.AddExhibitionHours(ctx, userID, starID, exhibitionHours, - fmt.Sprintf("%d", record.ID)) - if err != nil { - logger.Logger.Error("ClaimExhibitionRevenue: failed to add exhibition hours", - zap.Int64("user_id", userID), - zap.Int64("star_id", starID), - zap.Int64("exhibition_hours", exhibitionHours), - zap.Error(err)) - } else if levelDelta > 0 { - logger.Logger.Info("领取收益触发升级", - zap.Int64("user_id", userID), - zap.Int64("star_id", starID), - zap.Int64("revenue_record_id", record.ID), - zap.Int32("old_level", newLevel-levelDelta), - zap.Int32("new_level", newLevel), - zap.Int32("level_delta", levelDelta), - zap.Int64("crystal_reward", crystalReward)) - } - } - // 调用 Gallery Service 下架展品 if s.galleryRPCClient != nil && record.AssetID > 0 { if err := s.galleryRPCClient.RemoveExhibitionByAsset(ctx, record.AssetID); err != nil { @@ -267,41 +243,12 @@ func (s *revenueService) ClaimAllExhibitionRevenue(ctx context.Context, userID, if claimed { claimedCount++ - // 增加用户累计上架时长(触发升级检查) - exhibitionHours := (record.CycleEndTime - record.CycleStartTime) / 3600000 - if exhibitionHours < 1 { - exhibitionHours = 1 - } - if s.userRPCClient != nil { - newLevel, levelDelta, crystalReward, err := s.userRPCClient.AddExhibitionHours(ctx, userID, starID, exhibitionHours, - fmt.Sprintf("%d", record.ID)) - if err != nil { - logger.Logger.Error("ClaimAllExhibitionRevenue: failed to add exhibition hours", - zap.Int64("user_id", userID), - zap.Int64("star_id", starID), - zap.Int64("exhibition_hours", exhibitionHours), - zap.Error(err)) - } else if levelDelta > 0 { - logger.Logger.Info("一键领取触发升级", - zap.Int64("user_id", userID), - zap.Int64("star_id", starID), - zap.Int64("revenue_record_id", record.ID), - zap.Int32("old_level", newLevel-levelDelta), - zap.Int32("new_level", newLevel), - zap.Int32("level_delta", levelDelta), - zap.Int64("crystal_reward", crystalReward)) - } - } - // 调用 Gallery Service 下架展品 if s.galleryRPCClient != nil && record.AssetID > 0 { if err := s.galleryRPCClient.RemoveExhibitionByAsset(ctx, record.AssetID); err != nil { logger.Logger.Error("ClaimAllExhibitionRevenue: failed to remove exhibition", zap.Int64("asset_id", record.AssetID), zap.Error(err)) - } else { - logger.Logger.Info("ClaimAllExhibitionRevenue: exhibition removed", - zap.Int64("asset_id", record.AssetID)) } } } @@ -327,7 +274,15 @@ func (s *revenueService) OnExhibitionCompleted(ctx context.Context, req *pb.OnEx zap.Int32("like_count", req.LikeCount)) // 计算实际上架时长(毫秒转小时) + // 确保时间戳是毫秒级(防护:秒级时间戳通常 < 10000000000) startTime := req.StartTime + if startTime > 0 && startTime < 10000000000 { + startTime = startTime * 1000 + logger.Logger.Warn("OnExhibitionCompleted: converted start_time from seconds to milliseconds", + zap.Int64("exhibition_id", req.ExhibitionId), + zap.Int64("original_start_time", req.StartTime), + zap.Int64("converted_start_time", startTime)) + } expireAt := req.ExpireAt actualHours := (expireAt - startTime) / 3600000 @@ -353,6 +308,7 @@ func (s *revenueService) OnExhibitionCompleted(ctx context.Context, req *pb.OnEx // 收益归属资产主人(铸爱用户),无论展位是否为自己 now := time.Now().UnixMilli() + record := &model.ExhibitionRevenueRecord{ UserID: req.OccupierUid, // 收益归资产主人(铸爱用户) StarID: req.OccupierStarId, @@ -361,8 +317,8 @@ func (s *revenueService) OnExhibitionCompleted(ctx context.Context, req *pb.OnEx SlotID: req.SlotId, SlotOwnerUID: req.SlotOwnerUid, // 记录展位所有者信息(仅供参考) SlotType: "exhibition", // 上架展示收益 - CrystalAmount: finalRevenue, // 使用重新计算的收益 - CycleStartTime: req.StartTime, + CrystalAmount: finalRevenue, // 使用重新计算的收益 + CycleStartTime: startTime, CycleEndTime: req.ExpireAt, Status: "claimable", CreatedAt: now, diff --git a/frontend/pages/profile/myWorks.vue b/frontend/pages/profile/myWorks.vue index 8c9bde1..36e1da7 100644 --- a/frontend/pages/profile/myWorks.vue +++ b/frontend/pages/profile/myWorks.vue @@ -396,7 +396,7 @@ const handleClaimReward = async (item, _index) => { // 找到对应资产ID的收益记录 const revenueRecord = records.find(r => r.asset_id === item.id); - + console.log(item.id,records[1]) if (!revenueRecord) { uni.showToast({ title: '一分钟延迟领取', icon: 'none' }); return; diff --git a/frontend/pages/square/components/HotCategoryBlock.vue b/frontend/pages/square/components/HotCategoryBlock.vue index fae97ed..60134a6 100644 --- a/frontend/pages/square/components/HotCategoryBlock.vue +++ b/frontend/pages/square/components/HotCategoryBlock.vue @@ -278,7 +278,7 @@ onUnmounted(() => { .badge-rank { width: 80rpx; height: 32rpx; - color: #FFFABD33; + color: #FFFABD; font-size: 18rpx; font-weight: 600; border-radius: 16rpx;