diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 76631db..929b633 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,9 @@ "permissions": { "allow": [ "Skill(superpowers:subagent-driven-development)", - "Skill(superpowers:subagent-driven-development:*)" + "Skill(superpowers:subagent-driven-development:*)", + "Bash(go build:*)", + "Bash(go vet:*)" ] } } diff --git a/backend/gateway/config/config.go b/backend/gateway/config/config.go index eed32c3..8d7e235 100644 --- a/backend/gateway/config/config.go +++ b/backend/gateway/config/config.go @@ -32,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 配置 @@ -104,7 +104,7 @@ func Load() *Config { Redis: RedisConfig{ Host: getEnv("REDIS_HOST", "127.0.0.1"), Port: getEnvInt("REDIS_PORT", 6379), - Password: getEnv("REDIS_PASSWORD", ""), + Password: getEnv("REDIS_PASSWORD", "123456"), DB: getEnvInt("REDIS_DB", 0), }, } diff --git a/backend/go.work.sum b/backend/go.work.sum index 89ad232..f897d0d 100644 --- a/backend/go.work.sum +++ b/backend/go.work.sum @@ -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= diff --git a/backend/pkg/proto/user/user.pb.go b/backend/pkg/proto/user/user.pb.go index 88b961d..ec0c859 100644 --- a/backend/pkg/proto/user/user.pb.go +++ b/backend/pkg/proto/user/user.pb.go @@ -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" + diff --git a/backend/proto/user.proto b/backend/proto/user.proto index a08ee6a..063288f 100644 --- a/backend/proto/user.proto +++ b/backend/proto/user.proto @@ -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,用于升级奖励流水的溯源 } // 增加累计上架时长响应 diff --git a/backend/services/galleryService/client/user_rpc_client.go b/backend/services/galleryService/client/user_rpc_client.go index 772bf34..6ba1b6d 100644 --- a/backend/services/galleryService/client/user_rpc_client.go +++ b/backend/services/galleryService/client/user_rpc_client.go @@ -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 { diff --git a/backend/services/galleryService/repository/gallery_repository.go b/backend/services/galleryService/repository/gallery_repository.go index f871713..a85a1d5 100644 --- a/backend/services/galleryService/repository/gallery_repository.go +++ b/backend/services/galleryService/repository/gallery_repository.go @@ -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 +} diff --git a/backend/services/taskService/client/user_rpc_client.go b/backend/services/taskService/client/user_rpc_client.go index 389cb8d..2134dcf 100644 --- a/backend/services/taskService/client/user_rpc_client.go +++ b/backend/services/taskService/client/user_rpc_client.go @@ -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 +} diff --git a/backend/services/userService/repository/fan_profile_repository.go b/backend/services/userService/repository/fan_profile_repository.go index e450d98..7950400 100644 --- a/backend/services/userService/repository/fan_profile_repository.go +++ b/backend/services/userService/repository/fan_profile_repository.go @@ -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 更新头像 diff --git a/backend/services/userService/repository/fan_profile_repository_test.go b/backend/services/userService/repository/fan_profile_repository_test.go index 6d50fd5..4a93d1a 100644 --- a/backend/services/userService/repository/fan_profile_repository_test.go +++ b/backend/services/userService/repository/fan_profile_repository_test.go @@ -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) {