feat:修改展示收益

This commit is contained in:
zerosaturation 2026-05-15 11:48:46 +08:00
parent 2a0faeb835
commit 3ef3510303
10 changed files with 115 additions and 139 deletions

View File

@ -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:*)"
]
}
}

View File

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

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

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

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