Compare commits
13 Commits
0e1ee56351
...
e545f41c3b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e545f41c3b | ||
|
|
0852f96436 | ||
|
|
7c5d5a7275 | ||
|
|
8dce6ae11a | ||
|
|
edfa5f1449 | ||
|
|
2a0db41dbf | ||
|
|
bee30a1824 | ||
|
|
c6a8c66e39 | ||
|
|
d88e7ac504 | ||
|
|
11a59b0632 | ||
|
|
0753f0b168 | ||
|
|
6f21c294e9 | ||
|
|
bd905e997c |
@ -408,6 +408,7 @@ start_watcher "gateway" "gateway:pkg/proto" "gateway/gateway
|
||||
start_watcher "userService" "services/userService" "services/userService/userService" 20000 1
|
||||
start_watcher "assetService" "services/assetService:pkg/proto/asset" "services/assetService/assetService" 20003 1
|
||||
start_watcher "socialService" "services/socialService" "services/socialService/socialService" 20002 1
|
||||
start_watcher "galleryService" "services/galleryService" "services/galleryService/galleryService" 20004 1
|
||||
start_watcher "activityService" "services/activityService" "services/activityService/activityService" 20005 1
|
||||
start_watcher "taskService" "services/taskService" "services/taskService/taskService" 20006 1
|
||||
start_watcher "starbookService" "services/starbookService" "services/starbookService/starbookService" 20007 1
|
||||
|
||||
@ -0,0 +1,91 @@
|
||||
-- 藏品等级配置表
|
||||
CREATE TABLE IF NOT EXISTS asset_levels (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
level VARCHAR(10) NOT NULL UNIQUE,
|
||||
level_order INT NOT NULL,
|
||||
hourly_revenue INT NOT NULL,
|
||||
require_hours INT NOT NULL,
|
||||
require_likes INT NOT NULL,
|
||||
is_initial BOOLEAN DEFAULT FALSE,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
-- 藏品等级记录表
|
||||
CREATE TABLE IF NOT EXISTS asset_level_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
asset_id BIGINT NOT NULL UNIQUE,
|
||||
current_level VARCHAR(10) NOT NULL DEFAULT 'N',
|
||||
season_exhibition_hours INT NOT NULL DEFAULT 0,
|
||||
season_likes INT NOT NULL DEFAULT 0,
|
||||
lifetime_exhibition_hours INT NOT NULL DEFAULT 0,
|
||||
lifetime_likes INT NOT NULL DEFAULT 0,
|
||||
season_id VARCHAR(50),
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_level_season ON asset_level_records(season_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_level_current_level ON asset_level_records(current_level);
|
||||
|
||||
-- 藏品等级变化日志表
|
||||
CREATE TABLE IF NOT EXISTS asset_level_change_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
asset_id BIGINT NOT NULL,
|
||||
from_level VARCHAR(10),
|
||||
to_level VARCHAR(10) NOT NULL,
|
||||
trigger_type VARCHAR(20) NOT NULL,
|
||||
trigger_hours INT DEFAULT 0,
|
||||
trigger_likes INT DEFAULT 0,
|
||||
change_reason VARCHAR(255),
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_level_log_asset ON asset_level_change_logs(asset_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_level_log_created ON asset_level_change_logs(created_at DESC);
|
||||
|
||||
-- 赛季配置表
|
||||
CREATE TABLE IF NOT EXISTS seasons (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
duration_days INT NOT NULL DEFAULT 84,
|
||||
start_time BIGINT NOT NULL,
|
||||
end_time BIGINT NOT NULL,
|
||||
reset_strategy VARCHAR(20) DEFAULT 'percentage_decay',
|
||||
reset_level BOOLEAN DEFAULT TRUE,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
-- 赛季降序百分比配置表
|
||||
CREATE TABLE IF NOT EXISTS season_decay_config (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
season_id VARCHAR(50) NOT NULL,
|
||||
level VARCHAR(10) NOT NULL,
|
||||
preserve_percent INT NOT NULL DEFAULT 100,
|
||||
updated_at BIGINT NOT NULL,
|
||||
CONSTRAINT uk_season_level UNIQUE (season_id, level)
|
||||
);
|
||||
|
||||
-- 初始化藏品等级配置数据
|
||||
INSERT INTO asset_levels (level, level_order, hourly_revenue, require_hours, require_likes, is_initial, created_at, updated_at) VALUES
|
||||
('N', 1, 5, 0, 0, TRUE, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
|
||||
('R', 2, 7, 24, 20, FALSE, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
|
||||
('SR', 3, 12, 120, 500, FALSE, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
|
||||
('SSR', 4, 18, 360, 10000, FALSE, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
|
||||
('UR', 5, 30, 720, 100000, FALSE, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000))
|
||||
ON CONFLICT (level) DO NOTHING;
|
||||
|
||||
-- 初始化赛季配置数据
|
||||
INSERT INTO seasons (id, name, duration_days, start_time, end_time, reset_strategy, reset_level, status, created_at, updated_at) VALUES
|
||||
('season_1', '第一赛季', 84, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000) + 86400000 * 84, 'percentage_decay', TRUE, 'active', ROUND(EXTRACT(EPOCH FROM NOW()) * 1000), ROUND(EXTRACT(EPOCH FROM NOW()) * 1000))
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 初始化赛季降序百分比配置
|
||||
INSERT INTO season_decay_config (season_id, level, preserve_percent, updated_at) VALUES
|
||||
('season_1', 'N', 100, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
|
||||
('season_1', 'R', 80, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
|
||||
('season_1', 'SR', 70, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
|
||||
('season_1', 'SSR', 60, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000)),
|
||||
('season_1', 'UR', 50, ROUND(EXTRACT(EPOCH FROM NOW()) * 1000))
|
||||
ON CONFLICT (season_id, level) DO NOTHING;
|
||||
@ -121,7 +121,7 @@ type MintOrder struct {
|
||||
MaterialURL *string `gorm:"type:varchar(500);column:material_url"` // 用户上传的素材URL(阶段一写入)
|
||||
Name *string `gorm:"type:varchar(100);column:name"` // 藏品名称(阶段一写入,可选)
|
||||
Description *string `gorm:"type:text;column:description"` // 藏品描述(阶段一写入,可选)
|
||||
MaterialType *string `gorm:"type:varchar(50);column:material_type"` // 素材类型(可选)
|
||||
MaterialType *string `gorm:"type:varchar(100);column:material_type"` // 素材类型(可选,多个用逗号分隔)
|
||||
Event *string `gorm:"type:varchar(100);column:event"` // 藏品事件(可选)
|
||||
Info *string `gorm:"type:text;column:info"` // 藏品信息(必填)
|
||||
CreatedAt int64 `gorm:"not null;index:idx_mint_orders_created_at,sort:desc;column:created_at"`
|
||||
|
||||
138
backend/pkg/models/asset_level.go
Normal file
138
backend/pkg/models/asset_level.go
Normal file
@ -0,0 +1,138 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AssetLevel 藏品等级配置
|
||||
type AssetLevel struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Level string `gorm:"type:varchar(10);unique;not null"`
|
||||
LevelOrder int `gorm:"not null"`
|
||||
HourlyRevenue int `gorm:"not null"`
|
||||
RequireHours int `gorm:"not null"`
|
||||
RequireLikes int `gorm:"not null"`
|
||||
IsInitial bool `gorm:"default:false"`
|
||||
CreatedAt int64 `gorm:"not null"`
|
||||
UpdatedAt int64 `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (AssetLevel) TableName() string { return "asset_levels" }
|
||||
|
||||
// AssetLevelRecord 藏品等级记录
|
||||
type AssetLevelRecord struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
AssetID int64 `gorm:"unique;not null"`
|
||||
CurrentLevel string `gorm:"type:varchar(10);not null;default:'N'"`
|
||||
SeasonExhibitionHours int `gorm:"default:0;not null"`
|
||||
SeasonLikes int `gorm:"default:0;not null"`
|
||||
LifetimeExhibitionHours int `gorm:"default:0;not null"`
|
||||
LifetimeLikes int `gorm:"default:0;not null"`
|
||||
SeasonID string `gorm:"type:varchar(50)"`
|
||||
UpdatedAt int64 `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (AssetLevelRecord) TableName() string { return "asset_level_records" }
|
||||
|
||||
// AssetLevelChangeLog 等级变化日志
|
||||
type AssetLevelChangeLog struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
AssetID int64 `gorm:"not null;index"`
|
||||
FromLevel string `gorm:"type:varchar(10)"`
|
||||
ToLevel string `gorm:"type:varchar(10);not null"`
|
||||
TriggerType string `gorm:"type:varchar(20);not null"`
|
||||
TriggerHours int `gorm:"default:0"`
|
||||
TriggerLikes int `gorm:"default:0"`
|
||||
ChangeReason string `gorm:"type:varchar(255)"`
|
||||
CreatedAt int64 `gorm:"not null;index"`
|
||||
}
|
||||
|
||||
func (AssetLevelChangeLog) TableName() string { return "asset_level_change_logs" }
|
||||
|
||||
// Season 赛季配置
|
||||
type Season struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(50)"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
DurationDays int `gorm:"not null;default:84"`
|
||||
StartTime int64 `gorm:"not null"`
|
||||
EndTime int64 `gorm:"not null"`
|
||||
ResetStrategy string `gorm:"type:varchar(20);default:'percentage_decay'"`
|
||||
ResetLevel bool `gorm:"default:true"`
|
||||
Status string `gorm:"type:varchar(20);default:'active'"`
|
||||
CreatedAt int64 `gorm:"not null"`
|
||||
UpdatedAt int64 `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (Season) TableName() string { return "seasons" }
|
||||
|
||||
// CalculateEndTime 计算赛季结束时间
|
||||
func (s *Season) CalculateEndTime() int64 {
|
||||
return s.StartTime + int64(s.DurationDays)*86400000
|
||||
}
|
||||
|
||||
// BeforeCreate 创建前钩子
|
||||
func (s *Season) BeforeCreate(tx *gorm.DB) error {
|
||||
now := time.Now().UnixMilli()
|
||||
s.CreatedAt = now
|
||||
s.UpdatedAt = now
|
||||
s.EndTime = s.CalculateEndTime()
|
||||
if s.Status == "" {
|
||||
s.Status = "active"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate 更新前钩子
|
||||
func (s *Season) BeforeUpdate(tx *gorm.DB) error {
|
||||
s.UpdatedAt = time.Now().UnixMilli()
|
||||
s.EndTime = s.CalculateEndTime()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeasonDecayConfig 赛季降序百分比配置
|
||||
type SeasonDecayConfig struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
SeasonID string `gorm:"type:varchar(50);not null;uniqueIndex:uk_season_level"`
|
||||
Level string `gorm:"type:varchar(10);not null;uniqueIndex:uk_season_level"`
|
||||
PreservePercent int `gorm:"not null;default:100"`
|
||||
UpdatedAt int64 `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (SeasonDecayConfig) TableName() string { return "season_decay_config" }
|
||||
|
||||
// 等级常量
|
||||
const (
|
||||
LevelN = "N"
|
||||
LevelR = "R"
|
||||
LevelSR = "SR"
|
||||
LevelSSR = "SSR"
|
||||
LevelUR = "UR"
|
||||
)
|
||||
|
||||
// LevelOrderMap 等级顺序映射
|
||||
var LevelOrderMap = map[string]int{
|
||||
LevelN: 1,
|
||||
LevelR: 2,
|
||||
LevelSR: 3,
|
||||
LevelSSR: 4,
|
||||
LevelUR: 5,
|
||||
}
|
||||
|
||||
// LevelToGradeMap 等级字符串到前端Grade的映射(1-5)
|
||||
var LevelToGradeMap = map[string]int{
|
||||
LevelN: 1,
|
||||
LevelR: 2,
|
||||
LevelSR: 3,
|
||||
LevelSSR: 4,
|
||||
LevelUR: 5,
|
||||
}
|
||||
|
||||
// LevelToGrade 将等级字符串转换为前端Grade
|
||||
func LevelToGrade(level string) int {
|
||||
if grade, ok := LevelToGradeMap[level]; ok {
|
||||
return grade
|
||||
}
|
||||
return 1 // 默认返回1
|
||||
}
|
||||
@ -2247,6 +2247,8 @@ func (x *UnlikeAssetResponse) GetLikeCount() int32 {
|
||||
type CheckAssetLikeRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AssetId int64 `protobuf:"varint,1,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"` // 资产ID
|
||||
UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` // 用户ID
|
||||
StarId int64 `protobuf:"varint,3,opt,name=star_id,json=starId,proto3" json:"star_id,omitempty"` // 明星ID
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -2288,6 +2290,20 @@ func (x *CheckAssetLikeRequest) GetAssetId() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *CheckAssetLikeRequest) GetUserId() int64 {
|
||||
if x != nil {
|
||||
return x.UserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *CheckAssetLikeRequest) GetStarId() int64 {
|
||||
if x != nil {
|
||||
return x.StarId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 检查是否已点赞响应
|
||||
type CheckAssetLikeResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -3828,9 +3844,11 @@ const file_asset_proto_rawDesc = "" +
|
||||
"\x13UnlikeAssetResponse\x120\n" +
|
||||
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12\x1d\n" +
|
||||
"\n" +
|
||||
"like_count\x18\x02 \x01(\x05R\tlikeCount\"2\n" +
|
||||
"like_count\x18\x02 \x01(\x05R\tlikeCount\"d\n" +
|
||||
"\x15CheckAssetLikeRequest\x12\x19\n" +
|
||||
"\basset_id\x18\x01 \x01(\x03R\aassetId\"e\n" +
|
||||
"\basset_id\x18\x01 \x01(\x03R\aassetId\x12\x17\n" +
|
||||
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" +
|
||||
"\astar_id\x18\x03 \x01(\x03R\x06starId\"e\n" +
|
||||
"\x16CheckAssetLikeResponse\x120\n" +
|
||||
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12\x19\n" +
|
||||
"\bis_liked\x18\x02 \x01(\bR\aisLiked\"\x87\x01\n" +
|
||||
|
||||
@ -1726,6 +1726,7 @@ type OnExhibitionCompletedRequest struct {
|
||||
CrystalAmount int64 `protobuf:"varint,7,opt,name=crystal_amount,json=crystalAmount,proto3" json:"crystal_amount,omitempty"`
|
||||
StartTime int64 `protobuf:"varint,8,opt,name=start_time,json=startTime,proto3" json:"start_time,omitempty"`
|
||||
ExpireAt int64 `protobuf:"varint,9,opt,name=expire_at,json=expireAt,proto3" json:"expire_at,omitempty"`
|
||||
LikeCount int32 `protobuf:"varint,10,opt,name=like_count,json=likeCount,proto3" json:"like_count,omitempty"` // 点赞数,用于在taskService中根据资产等级重新计算收益
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -1823,6 +1824,13 @@ func (x *OnExhibitionCompletedRequest) GetExpireAt() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *OnExhibitionCompletedRequest) GetLikeCount() int32 {
|
||||
if x != nil {
|
||||
return x.LikeCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type OnExhibitionCompletedResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"`
|
||||
@ -2001,7 +2009,7 @@ const file_task_proto_rawDesc = "" +
|
||||
"\astar_id\x18\x02 \x01(\x03R\x06starId\"c\n" +
|
||||
"\x15InitUserTasksResponse\x120\n" +
|
||||
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12\x18\n" +
|
||||
"\asuccess\x18\x02 \x01(\bR\asuccess\"\xcd\x02\n" +
|
||||
"\asuccess\x18\x02 \x01(\bR\asuccess\"\xec\x02\n" +
|
||||
"\x1cOnExhibitionCompletedRequest\x12#\n" +
|
||||
"\rexhibition_id\x18\x01 \x01(\x03R\fexhibitionId\x12\x19\n" +
|
||||
"\basset_id\x18\x02 \x01(\x03R\aassetId\x12\x17\n" +
|
||||
@ -2012,7 +2020,10 @@ const file_task_proto_rawDesc = "" +
|
||||
"\x0ecrystal_amount\x18\a \x01(\x03R\rcrystalAmount\x12\x1d\n" +
|
||||
"\n" +
|
||||
"start_time\x18\b \x01(\x03R\tstartTime\x12\x1b\n" +
|
||||
"\texpire_at\x18\t \x01(\x03R\bexpireAt\"}\n" +
|
||||
"\texpire_at\x18\t \x01(\x03R\bexpireAt\x12\x1d\n" +
|
||||
"\n" +
|
||||
"like_count\x18\n" +
|
||||
" \x01(\x05R\tlikeCount\"}\n" +
|
||||
"\x1dOnExhibitionCompletedResponse\x120\n" +
|
||||
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12*\n" +
|
||||
"\x11revenue_record_id\x18\x02 \x01(\x03R\x0frevenueRecordId2\xc6\f\n" +
|
||||
|
||||
@ -279,6 +279,8 @@ message UnlikeAssetResponse {
|
||||
// 检查是否已点赞请求(内部RPC,供Social Service调用)
|
||||
message CheckAssetLikeRequest {
|
||||
int64 asset_id = 1; // 资产ID
|
||||
int64 user_id = 2; // 用户ID
|
||||
int64 star_id = 3; // 明星ID
|
||||
}
|
||||
|
||||
// 检查是否已点赞响应
|
||||
|
||||
@ -195,6 +195,7 @@ message OnExhibitionCompletedRequest {
|
||||
int64 crystal_amount = 7;
|
||||
int64 start_time = 8;
|
||||
int64 expire_at = 9;
|
||||
int32 like_count = 10; // 点赞数,用于在taskService中根据资产等级重新计算收益
|
||||
}
|
||||
|
||||
message OnExhibitionCompletedResponse {
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
-- Drop the overly strict unique constraint that prevents users from liking
|
||||
-- the same asset across different exhibitions. The correct constraint is
|
||||
-- (user_id, asset_id, exhibition_id), not (user_id, asset_id, star_id).
|
||||
-- This allows a user to like the same asset once per exhibition.
|
||||
|
||||
ALTER TABLE asset_likes DROP CONSTRAINT IF EXISTS uk_asset_likes_user_asset_star;
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"dubbo.apache.org/dubbo-go/v3/client"
|
||||
_ "dubbo.apache.org/dubbo-go/v3/imports"
|
||||
@ -132,11 +133,16 @@ func main() {
|
||||
userClient := assetClient.NewUserServiceClient(userServiceClient)
|
||||
logger.Logger.Info("User Service RPC client initialized")
|
||||
|
||||
// 创建 Provider 层实例(用于获取 AssetLevelService)
|
||||
assetLevelProvider := provider.NewAssetLevelProvider(database.GetDB())
|
||||
assetLevelSvc := assetLevelProvider.GetLevelService()
|
||||
logger.Logger.Info("AssetLevelProvider initialized")
|
||||
|
||||
// 创建 Service 层实例
|
||||
registryRepo := starbookRepo.NewAssetRegistryRepository(database.GetDB())
|
||||
assetService := service.NewAssetService(assetRepo, mintOrderRepo, assetLikeRepo, userClient, database.GetDB(), registryRepo)
|
||||
mintService := service.NewMintService(assetRepo, mintOrderRepo, userClient, database.GetDB(), config.GlobalAssetConfig, registryRepo, mintCostRepo, userMintCountRepo)
|
||||
assetLikeService := service.NewAssetLikeService(assetRepo, assetLikeRepo, database.GetDB())
|
||||
mintService := service.NewMintService(assetRepo, mintOrderRepo, userClient, database.GetDB(), config.GlobalAssetConfig, registryRepo, mintCostRepo, userMintCountRepo, assetLevelSvc)
|
||||
assetLikeService := service.NewAssetLikeService(assetRepo, assetLikeRepo, database.GetDB(), assetLevelSvc)
|
||||
rankingService := service.NewRankingService(rankingRepo, userClient)
|
||||
materialService := service.NewMaterialService(materialRepo, relationRepo)
|
||||
logger.Logger.Info("Service layer initialized")
|
||||
@ -146,6 +152,18 @@ func main() {
|
||||
rankingProvider := provider.NewRankingProvider(rankingService)
|
||||
logger.Logger.Info("Provider layer initialized")
|
||||
|
||||
// 启动赛季重置 Worker(每小时检查一次)
|
||||
seasonResetWorker := assetLevelProvider.GetSeasonResetWorker()
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
<-ticker.C
|
||||
seasonResetWorker.Run()
|
||||
}
|
||||
}()
|
||||
logger.Logger.Info("Season reset worker started")
|
||||
|
||||
// 创建 Dubbo 服务器
|
||||
srv, err := server.NewServer(
|
||||
server.WithServerProtocol(
|
||||
@ -201,6 +219,10 @@ func autoMigrate() error {
|
||||
&models.AssetLike{},
|
||||
&models.Material{},
|
||||
&models.AssetMaterialRelation{},
|
||||
&models.AssetLevelRecord{},
|
||||
&models.AssetLevelChangeLog{},
|
||||
&models.Season{},
|
||||
&models.SeasonDecayConfig{},
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"github.com/topfans/backend/services/assetService/repository"
|
||||
"github.com/topfans/backend/services/assetService/service"
|
||||
"github.com/topfans/backend/services/assetService/worker"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AssetLevelProvider struct {
|
||||
LevelService service.AssetLevelService
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAssetLevelProvider(db *gorm.DB) *AssetLevelProvider {
|
||||
levelRepo := repository.NewAssetLevelRepository(db)
|
||||
seasonRepo := repository.NewSeasonRepository(db)
|
||||
decayConfigRepo := repository.NewSeasonDecayConfigRepository(db)
|
||||
|
||||
levelService := service.NewAssetLevelService(levelRepo, seasonRepo, decayConfigRepo)
|
||||
|
||||
return &AssetLevelProvider{
|
||||
LevelService: levelService,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AssetLevelProvider) GetLevelService() service.AssetLevelService {
|
||||
return p.LevelService
|
||||
}
|
||||
|
||||
func (p *AssetLevelProvider) GetSeasonResetWorker() *worker.SeasonResetWorker {
|
||||
seasonRepo := repository.NewSeasonRepository(p.db)
|
||||
return worker.NewSeasonResetWorker(seasonRepo, p.LevelService)
|
||||
}
|
||||
@ -392,13 +392,14 @@ func (p *AssetProvider) LikeAsset(ctx context.Context, req *pb.LikeAssetRequest)
|
||||
zap.Int64("asset_id", req.AssetId),
|
||||
zap.Error(err),
|
||||
)
|
||||
// 返回响应但不返回 error,让客户端检查 Base.Code
|
||||
return &pb.LikeAssetResponse{
|
||||
Base: &pbCommon.BaseResponse{
|
||||
Code: appErrors.ToStatusCode(err),
|
||||
Message: err.Error(),
|
||||
Timestamp: 0,
|
||||
},
|
||||
}, err
|
||||
}, nil
|
||||
}
|
||||
|
||||
logger.Logger.Info("LikeAsset successful",
|
||||
@ -475,13 +476,18 @@ func (p *AssetProvider) UnlikeAsset(ctx context.Context, req *pb.UnlikeAssetRequ
|
||||
func (p *AssetProvider) CheckAssetLike(ctx context.Context, req *pb.CheckAssetLikeRequest) (*pb.CheckAssetLikeResponse, error) {
|
||||
logger.Logger.Info("Received CheckAssetLike request",
|
||||
zap.Int64("asset_id", req.AssetId),
|
||||
zap.Int64("user_id", req.UserId),
|
||||
zap.Int64("star_id", req.StarId),
|
||||
)
|
||||
|
||||
// 从 Dubbo attachments 获取用户信息
|
||||
userID, starID, err := extractUserInfoFromDubboAttachments(ctx)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to extract user info from attachments",
|
||||
zap.Error(err),
|
||||
// 直接使用请求中的用户信息
|
||||
userID := req.UserId
|
||||
starID := req.StarId
|
||||
|
||||
if userID == 0 || starID == 0 {
|
||||
logger.Logger.Error("Invalid user info in request",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int64("star_id", starID),
|
||||
)
|
||||
return &pb.CheckAssetLikeResponse{
|
||||
Base: &pbCommon.BaseResponse{
|
||||
@ -489,7 +495,7 @@ func (p *AssetProvider) CheckAssetLike(ctx context.Context, req *pb.CheckAssetLi
|
||||
Message: "user authentication required",
|
||||
Timestamp: 0,
|
||||
},
|
||||
}, err
|
||||
}, fmt.Errorf("invalid user_id or star_id")
|
||||
}
|
||||
|
||||
// 调用Service层
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/topfans/backend/pkg/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AssetLevelRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAssetLevelRepository(db *gorm.DB) *AssetLevelRepository {
|
||||
return &AssetLevelRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *AssetLevelRepository) Create(record *models.AssetLevelRecord) error {
|
||||
return r.db.Create(record).Error
|
||||
}
|
||||
|
||||
func (r *AssetLevelRepository) Save(record *models.AssetLevelRecord) error {
|
||||
return r.db.Save(record).Error
|
||||
}
|
||||
|
||||
func (r *AssetLevelRepository) GetByAssetID(assetID int64) (*models.AssetLevelRecord, error) {
|
||||
var record models.AssetLevelRecord
|
||||
err := r.db.Where("asset_id = ?", assetID).First(&record).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (r *AssetLevelRepository) GetBySeason(seasonID string) ([]*models.AssetLevelRecord, error) {
|
||||
var records []*models.AssetLevelRecord
|
||||
err := r.db.Where("season_id = ?", seasonID).Find(&records).Error
|
||||
return records, err
|
||||
}
|
||||
|
||||
func (r *AssetLevelRepository) GetAllLevels() ([]*models.AssetLevel, error) {
|
||||
var levels []*models.AssetLevel
|
||||
err := r.db.Order("level_order ASC").Find(&levels).Error
|
||||
return levels, err
|
||||
}
|
||||
|
||||
func (r *AssetLevelRepository) GetLevelConfig(level string) (*models.AssetLevel, error) {
|
||||
var config models.AssetLevel
|
||||
err := r.db.Where("level = ?", level).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func (r *AssetLevelRepository) CreateChangeLog(log *models.AssetLevelChangeLog) error {
|
||||
return r.db.Create(log).Error
|
||||
}
|
||||
|
||||
func (r *AssetLevelRepository) GetDB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
func (r *AssetLevelRepository) GetChangeLogs(assetID int64, limit, offset int) ([]*models.AssetLevelChangeLog, error) {
|
||||
var logs []*models.AssetLevelChangeLog
|
||||
err := r.db.Where("asset_id = ?", assetID).
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&logs).Error
|
||||
return logs, err
|
||||
}
|
||||
@ -48,6 +48,9 @@ type AssetRepository interface {
|
||||
// DecrementLikeCount 减少点赞数
|
||||
DecrementLikeCount(assetID int64) error
|
||||
|
||||
// UpdateGradeByAssetID 根据asset_id更新藏品等级(用于同步AssetLevelRecord.CurrentLevel到AssetRegistry.Grade)
|
||||
UpdateGradeByAssetID(assetID int64, grade int32) error
|
||||
|
||||
// UpdateMaterialTypeByLikes 根据点赞数和创建时间更新素材类型
|
||||
UpdateMaterialTypeByLikes(assetID int64, likes int32, createdAt int64) error
|
||||
|
||||
@ -154,6 +157,17 @@ func (r *assetRepository) GetGradeByAssetID(assetID int64) (int32, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// UpdateGradeByAssetID 根据asset_id更新藏品等级(用于同步AssetLevelRecord.CurrentLevel到AssetRegistry.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
|
||||
}
|
||||
|
||||
// GetByIDs 批量查询资产
|
||||
func (r *assetRepository) GetByIDs(assetIDs []int64) ([]*models.Asset, error) {
|
||||
if len(assetIDs) == 0 {
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/topfans/backend/pkg/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SeasonDecayConfigRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSeasonDecayConfigRepository(db *gorm.DB) *SeasonDecayConfigRepository {
|
||||
return &SeasonDecayConfigRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *SeasonDecayConfigRepository) GetBySeason(seasonID string) ([]*models.SeasonDecayConfig, error) {
|
||||
var configs []*models.SeasonDecayConfig
|
||||
err := r.db.Where("season_id = ?", seasonID).Find(&configs).Error
|
||||
return configs, err
|
||||
}
|
||||
|
||||
func (r *SeasonDecayConfigRepository) GetBySeasonAndLevel(seasonID, level string) (*models.SeasonDecayConfig, error) {
|
||||
var config models.SeasonDecayConfig
|
||||
err := r.db.Where("season_id = ? AND level = ?", seasonID, level).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/topfans/backend/pkg/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SeasonRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSeasonRepository(db *gorm.DB) *SeasonRepository {
|
||||
return &SeasonRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *SeasonRepository) GetByID(seasonID string) (*models.Season, error) {
|
||||
var season models.Season
|
||||
err := r.db.Where("id = ?", seasonID).First(&season).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &season, nil
|
||||
}
|
||||
|
||||
func (r *SeasonRepository) GetActiveSeason() (*models.Season, error) {
|
||||
var season models.Season
|
||||
err := r.db.Where("status = ?", "active").First(&season).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &season, nil
|
||||
}
|
||||
|
||||
func (r *SeasonRepository) GetEndedSeasons() ([]*models.Season, error) {
|
||||
var seasons []*models.Season
|
||||
err := r.db.Where("status = ? AND end_time < ?", "active", gorm.Expr("NOW()")).Find(&seasons).Error
|
||||
return seasons, err
|
||||
}
|
||||
|
||||
func (r *SeasonRepository) Save(season *models.Season) error {
|
||||
return r.db.Save(season).Error
|
||||
}
|
||||
|
||||
func (r *SeasonRepository) Create(season *models.Season) error {
|
||||
return r.db.Create(season).Error
|
||||
}
|
||||
|
||||
func (r *SeasonRepository) GetOrCreate(seasonID string, durationDays int, resetStrategy string) (*models.Season, error) {
|
||||
season, err := r.GetByID(seasonID)
|
||||
if err == nil {
|
||||
return season, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
season = &models.Season{
|
||||
ID: seasonID,
|
||||
DurationDays: durationDays,
|
||||
ResetStrategy: resetStrategy,
|
||||
ResetLevel: true,
|
||||
Status: "active",
|
||||
}
|
||||
if err := r.Create(season); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return season, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
478
backend/services/assetService/service/asset_level_service.go
Normal file
478
backend/services/assetService/service/asset_level_service.go
Normal file
@ -0,0 +1,478 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
"github.com/topfans/backend/pkg/models"
|
||||
"github.com/topfans/backend/services/assetService/repository"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AssetLevelService interface {
|
||||
GetOrCreateRecord(assetID int64) (*models.AssetLevelRecord, error)
|
||||
GetRecordByAssetID(assetID int64) (*models.AssetLevelRecord, error)
|
||||
GetLevelConfig(level string) (*models.AssetLevel, error)
|
||||
GetAllLevels() ([]*models.AssetLevel, error)
|
||||
AddExhibitionHours(assetID int64, hours int) (string, bool, error)
|
||||
AddLikes(assetID int64, count int) (string, bool, error)
|
||||
RemoveLikes(assetID int64, count int) (string, bool, error)
|
||||
CalculateRevenue(assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error)
|
||||
SeasonReset(seasonID string) error
|
||||
GetCurrentSeason() (*models.Season, error)
|
||||
GetChangeLogs(assetID int64, page, pageSize int) ([]*models.AssetLevelChangeLog, error)
|
||||
}
|
||||
|
||||
type assetLevelService struct {
|
||||
levelRepo *repository.AssetLevelRepository
|
||||
seasonRepo *repository.SeasonRepository
|
||||
decayConfigRepo *repository.SeasonDecayConfigRepository
|
||||
assetRepo repository.AssetRepository // 用于同步等级到AssetRegistry.Grade(可选)
|
||||
}
|
||||
|
||||
func NewAssetLevelService(
|
||||
levelRepo *repository.AssetLevelRepository,
|
||||
seasonRepo *repository.SeasonRepository,
|
||||
decayConfigRepo *repository.SeasonDecayConfigRepository,
|
||||
) AssetLevelService {
|
||||
return &assetLevelService{
|
||||
levelRepo: levelRepo,
|
||||
seasonRepo: seasonRepo,
|
||||
decayConfigRepo: decayConfigRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *assetLevelService) GetOrCreateRecord(assetID int64) (*models.AssetLevelRecord, error) {
|
||||
record, err := s.levelRepo.GetByAssetID(assetID)
|
||||
if err == nil {
|
||||
return record, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
record = &models.AssetLevelRecord{
|
||||
AssetID: assetID,
|
||||
CurrentLevel: models.LevelN,
|
||||
}
|
||||
if err := s.levelRepo.Create(record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (s *assetLevelService) GetRecordByAssetID(assetID int64) (*models.AssetLevelRecord, error) {
|
||||
return s.levelRepo.GetByAssetID(assetID)
|
||||
}
|
||||
|
||||
func (s *assetLevelService) GetLevelConfig(level string) (*models.AssetLevel, error) {
|
||||
return s.levelRepo.GetLevelConfig(level)
|
||||
}
|
||||
|
||||
func (s *assetLevelService) GetAllLevels() ([]*models.AssetLevel, error) {
|
||||
return s.levelRepo.GetAllLevels()
|
||||
}
|
||||
|
||||
func (s *assetLevelService) GetCurrentSeason() (*models.Season, error) {
|
||||
return s.seasonRepo.GetActiveSeason()
|
||||
}
|
||||
|
||||
func (s *assetLevelService) getDefaultSeason() *models.Season {
|
||||
return &models.Season{
|
||||
ID: "season_1",
|
||||
Name: "第一赛季",
|
||||
DurationDays: 84,
|
||||
ResetStrategy: "percentage_decay",
|
||||
ResetLevel: true,
|
||||
Status: "active",
|
||||
}
|
||||
}
|
||||
|
||||
// CheckUpgrade 检查是否可以升级(使用赛季内累计)
|
||||
func (s *assetLevelService) CheckUpgrade(record *models.AssetLevelRecord) (string, bool) {
|
||||
levels, err := s.GetAllLevels()
|
||||
if err != nil {
|
||||
return record.CurrentLevel, false
|
||||
}
|
||||
|
||||
currentOrder := models.LevelOrderMap[record.CurrentLevel]
|
||||
|
||||
for i := len(levels) - 1; i >= 0; i-- {
|
||||
level := levels[i]
|
||||
if level.LevelOrder <= currentOrder {
|
||||
continue
|
||||
}
|
||||
if record.SeasonExhibitionHours >= level.RequireHours &&
|
||||
record.SeasonLikes >= level.RequireLikes {
|
||||
return level.Level, true
|
||||
}
|
||||
}
|
||||
|
||||
return record.CurrentLevel, false
|
||||
}
|
||||
|
||||
// CheckDowngrade 检查是否需要降级
|
||||
func (s *assetLevelService) CheckDowngrade(record *models.AssetLevelRecord) (string, bool) {
|
||||
levels, err := s.GetAllLevels()
|
||||
if err != nil {
|
||||
return record.CurrentLevel, false
|
||||
}
|
||||
|
||||
currentOrder := models.LevelOrderMap[record.CurrentLevel]
|
||||
|
||||
for _, level := range levels {
|
||||
if level.LevelOrder != currentOrder {
|
||||
continue
|
||||
}
|
||||
if record.SeasonExhibitionHours >= level.RequireHours &&
|
||||
record.SeasonLikes >= level.RequireLikes {
|
||||
return record.CurrentLevel, false
|
||||
}
|
||||
}
|
||||
|
||||
newLevel := models.LevelN
|
||||
for _, level := range levels {
|
||||
if record.SeasonExhibitionHours >= level.RequireHours &&
|
||||
record.SeasonLikes >= level.RequireLikes {
|
||||
newLevel = level.Level
|
||||
}
|
||||
}
|
||||
|
||||
return newLevel, newLevel != record.CurrentLevel
|
||||
}
|
||||
|
||||
func (s *assetLevelService) AddExhibitionHours(assetID int64, hours int) (string, bool, error) {
|
||||
record, err := s.GetOrCreateRecord(assetID)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
oldLevel := record.CurrentLevel
|
||||
|
||||
if record.SeasonID == "" {
|
||||
season, err := s.GetCurrentSeason()
|
||||
if err != nil {
|
||||
season = s.getDefaultSeason()
|
||||
}
|
||||
record.SeasonID = season.ID
|
||||
}
|
||||
|
||||
record.SeasonExhibitionHours += hours
|
||||
record.LifetimeExhibitionHours += hours
|
||||
|
||||
newLevel, upgraded := s.CheckUpgrade(record)
|
||||
if upgraded {
|
||||
record.CurrentLevel = newLevel
|
||||
}
|
||||
|
||||
if err := s.levelRepo.Save(record); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if upgraded && newLevel != oldLevel {
|
||||
s.logLevelChange(record.AssetID, oldLevel, newLevel,
|
||||
"exhibition_complete", record.SeasonExhibitionHours, record.SeasonLikes,
|
||||
fmt.Sprintf("展出完成,时长+%d小时", hours))
|
||||
// 同步等级到AssetRegistry.Grade
|
||||
s.syncGradeToAssetRegistry(record.AssetID, newLevel)
|
||||
}
|
||||
|
||||
return newLevel, upgraded, nil
|
||||
}
|
||||
|
||||
func (s *assetLevelService) AddLikes(assetID int64, count int) (string, bool, error) {
|
||||
record, err := s.GetOrCreateRecord(assetID)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
oldLevel := record.CurrentLevel
|
||||
|
||||
if record.SeasonID == "" {
|
||||
season, err := s.GetCurrentSeason()
|
||||
if err != nil {
|
||||
season = s.getDefaultSeason()
|
||||
}
|
||||
record.SeasonID = season.ID
|
||||
}
|
||||
|
||||
record.SeasonLikes += count
|
||||
record.LifetimeLikes += count
|
||||
|
||||
newLevel, upgraded := s.CheckUpgrade(record)
|
||||
if upgraded {
|
||||
record.CurrentLevel = newLevel
|
||||
}
|
||||
|
||||
if err := s.levelRepo.Save(record); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if upgraded && newLevel != oldLevel {
|
||||
s.logLevelChange(record.AssetID, oldLevel, newLevel,
|
||||
"like_update", record.SeasonExhibitionHours, record.SeasonLikes,
|
||||
fmt.Sprintf("点赞数达到%d触发升级", record.SeasonLikes))
|
||||
// 同步等级到AssetRegistry.Grade
|
||||
s.syncGradeToAssetRegistry(record.AssetID, newLevel)
|
||||
}
|
||||
|
||||
return newLevel, upgraded, nil
|
||||
}
|
||||
|
||||
func (s *assetLevelService) RemoveLikes(assetID int64, count int) (string, bool, error) {
|
||||
record, err := s.GetOrCreateRecord(assetID)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
oldLevel := record.CurrentLevel
|
||||
|
||||
record.SeasonLikes -= count
|
||||
if record.SeasonLikes < 0 {
|
||||
record.SeasonLikes = 0
|
||||
}
|
||||
record.LifetimeLikes -= count
|
||||
if record.LifetimeLikes < 0 {
|
||||
record.LifetimeLikes = 0
|
||||
}
|
||||
|
||||
newLevel, downgraded := s.CheckDowngrade(record)
|
||||
if downgraded {
|
||||
record.CurrentLevel = newLevel
|
||||
}
|
||||
|
||||
if err := s.levelRepo.Save(record); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if downgraded && newLevel != oldLevel {
|
||||
s.logLevelChange(record.AssetID, oldLevel, newLevel,
|
||||
"like_remove", record.SeasonExhibitionHours, record.SeasonLikes,
|
||||
fmt.Sprintf("取消点赞,点赞数降至%d触发降级", record.SeasonLikes))
|
||||
// 同步等级到AssetRegistry.Grade
|
||||
s.syncGradeToAssetRegistry(record.AssetID, newLevel)
|
||||
}
|
||||
|
||||
return newLevel, downgraded, nil
|
||||
}
|
||||
|
||||
// syncGradeToAssetRegistry 将等级同步到AssetRegistry.Grade
|
||||
// 直接使用gorm.DB更新,不依赖repository层
|
||||
func (s *assetLevelService) syncGradeToAssetRegistry(assetID int64, level string) {
|
||||
if s.assetRepo == nil {
|
||||
// 直接使用levelRepo的db进行更新
|
||||
grade := models.LevelToGrade(level)
|
||||
db := s.levelRepo.GetDB()
|
||||
if db != nil {
|
||||
if err := db.Table("public.asset_registry").
|
||||
Where("asset_id = ?", assetID).
|
||||
Update("grade", grade).Error; err != nil {
|
||||
logger.Logger.Warn("syncGradeToAssetRegistry failed",
|
||||
zap.Int64("asset_id", assetID),
|
||||
zap.String("level", level),
|
||||
zap.Int("grade", grade),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
logger.Logger.Info("syncGradeToAssetRegistry success",
|
||||
zap.Int64("asset_id", assetID),
|
||||
zap.String("level", level),
|
||||
zap.Int("grade", grade))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
grade := models.LevelToGrade(level)
|
||||
if err := s.assetRepo.UpdateGradeByAssetID(assetID, int32(grade)); err != nil {
|
||||
logger.Logger.Warn("syncGradeToAssetRegistry failed",
|
||||
zap.Int64("asset_id", assetID),
|
||||
zap.String("level", level),
|
||||
zap.Int("grade", grade),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
logger.Logger.Info("syncGradeToAssetRegistry success",
|
||||
zap.Int64("asset_id", assetID),
|
||||
zap.String("level", level),
|
||||
zap.Int("grade", grade))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *assetLevelService) CalculateRevenue(assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error) {
|
||||
record, err := s.GetRecordByAssetID(assetID)
|
||||
if err != nil || record == nil {
|
||||
return s.calculateDefaultRevenue(likeCount, startTime, endTime, revenueBoostBps)
|
||||
}
|
||||
|
||||
levelConfig, err := s.GetLevelConfig(record.CurrentLevel)
|
||||
if err != nil {
|
||||
return s.calculateDefaultRevenue(likeCount, startTime, endTime, revenueBoostBps)
|
||||
}
|
||||
|
||||
T := (endTime - startTime) / 3600000
|
||||
if T <= 0 {
|
||||
T = 1
|
||||
}
|
||||
|
||||
R0 := int64(levelConfig.HourlyRevenue)
|
||||
baseRevenue := R0 * T
|
||||
|
||||
buff := CalculateBuff(likeCount)
|
||||
buffedRevenue := baseRevenue * (100 + int64(buff)) / 100
|
||||
|
||||
if revenueBoostBps > 0 {
|
||||
boost := buffedRevenue * int64(revenueBoostBps) / 10000
|
||||
buffedRevenue += boost
|
||||
}
|
||||
|
||||
return buffedRevenue, nil
|
||||
}
|
||||
|
||||
func (s *assetLevelService) calculateDefaultRevenue(likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error) {
|
||||
R0 := int64(5)
|
||||
T := (endTime - startTime) / 3600000
|
||||
if T <= 0 {
|
||||
T = 1
|
||||
}
|
||||
|
||||
baseRevenue := R0 * T
|
||||
buff := CalculateBuff(likeCount)
|
||||
buffedRevenue := baseRevenue * (100 + int64(buff)) / 100
|
||||
|
||||
if revenueBoostBps > 0 {
|
||||
boost := buffedRevenue * int64(revenueBoostBps) / 10000
|
||||
buffedRevenue += boost
|
||||
}
|
||||
|
||||
return buffedRevenue, nil
|
||||
}
|
||||
|
||||
// CalculateBuff 根据点赞数计算Buff百分比
|
||||
func CalculateBuff(likeCount int) int {
|
||||
switch {
|
||||
case likeCount >= 30:
|
||||
return 30
|
||||
case likeCount >= 10:
|
||||
return 20
|
||||
case likeCount >= 5:
|
||||
return 10
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (s *assetLevelService) SeasonReset(seasonID string) error {
|
||||
season, err := s.seasonRepo.GetByID(seasonID)
|
||||
if err != nil || season == nil {
|
||||
return fmt.Errorf("season not found: %s", seasonID)
|
||||
}
|
||||
|
||||
if !season.ResetLevel {
|
||||
return nil
|
||||
}
|
||||
|
||||
decayConfigs, err := s.decayConfigRepo.GetBySeason(seasonID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
decayPercentMap := make(map[string]int)
|
||||
for _, cfg := range decayConfigs {
|
||||
decayPercentMap[cfg.Level] = cfg.PreservePercent
|
||||
}
|
||||
|
||||
records, err := s.levelRepo.GetBySeason(seasonID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nextSeasonID := s.calculateNextSeasonID(seasonID)
|
||||
_, err = s.seasonRepo.GetOrCreate(nextSeasonID, season.DurationDays, season.ResetStrategy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
oldLevel := record.CurrentLevel
|
||||
|
||||
preservePercent := decayPercentMap[record.CurrentLevel]
|
||||
if preservePercent <= 0 {
|
||||
preservePercent = 0
|
||||
} else if preservePercent >= 100 {
|
||||
preservePercent = 100
|
||||
}
|
||||
|
||||
record.SeasonExhibitionHours = record.LifetimeExhibitionHours * preservePercent / 100
|
||||
record.SeasonLikes = record.LifetimeLikes * preservePercent / 100
|
||||
|
||||
newLevel := s.recalculateLevelAfterDecay(record)
|
||||
record.CurrentLevel = newLevel
|
||||
record.SeasonID = nextSeasonID
|
||||
|
||||
if err := s.levelRepo.Save(record); err != nil {
|
||||
logger.Logger.Error("SeasonReset: failed to update record",
|
||||
zap.Int64("asset_id", record.AssetID),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
if oldLevel != newLevel {
|
||||
s.logLevelChange(record.AssetID, oldLevel, newLevel,
|
||||
"season_decay", record.SeasonExhibitionHours, record.SeasonLikes,
|
||||
fmt.Sprintf("赛季%s降序,保留%d%%", seasonID, preservePercent))
|
||||
// 同步等级到AssetRegistry.Grade
|
||||
s.syncGradeToAssetRegistry(record.AssetID, newLevel)
|
||||
}
|
||||
}
|
||||
|
||||
season.Status = "ended"
|
||||
return s.seasonRepo.Save(season)
|
||||
}
|
||||
|
||||
func (s *assetLevelService) calculateNextSeasonID(currentSeasonID string) string {
|
||||
if len(currentSeasonID) > 0 && currentSeasonID[:7] == "season_" {
|
||||
num := 0
|
||||
fmt.Sscanf(currentSeasonID[7:], "%d", &num)
|
||||
return fmt.Sprintf("season_%d", num+1)
|
||||
}
|
||||
return currentSeasonID + "_next"
|
||||
}
|
||||
|
||||
func (s *assetLevelService) recalculateLevelAfterDecay(record *models.AssetLevelRecord) string {
|
||||
levels, err := s.GetAllLevels()
|
||||
if err != nil {
|
||||
return record.CurrentLevel
|
||||
}
|
||||
|
||||
newLevel := models.LevelN
|
||||
for _, level := range levels {
|
||||
if record.SeasonExhibitionHours >= level.RequireHours &&
|
||||
record.SeasonLikes >= level.RequireLikes {
|
||||
newLevel = level.Level
|
||||
}
|
||||
}
|
||||
|
||||
return newLevel
|
||||
}
|
||||
|
||||
func (s *assetLevelService) logLevelChange(assetID int64, fromLevel, toLevel, triggerType string, triggerHours, triggerLikes int, reason string) {
|
||||
log := &models.AssetLevelChangeLog{
|
||||
AssetID: assetID,
|
||||
FromLevel: fromLevel,
|
||||
ToLevel: toLevel,
|
||||
TriggerType: triggerType,
|
||||
TriggerHours: triggerHours,
|
||||
TriggerLikes: triggerLikes,
|
||||
ChangeReason: reason,
|
||||
CreatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
if err := s.levelRepo.CreateChangeLog(log); err != nil {
|
||||
logger.Logger.Error("Failed to create level change log",
|
||||
zap.Int64("asset_id", assetID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *assetLevelService) GetChangeLogs(assetID int64, page, pageSize int) ([]*models.AssetLevelChangeLog, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
return s.levelRepo.GetChangeLogs(assetID, pageSize, offset)
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCalculateBuff(t *testing.T) {
|
||||
tests := []struct {
|
||||
likeCount int
|
||||
expected int
|
||||
}{
|
||||
{0, 0},
|
||||
{4, 0},
|
||||
{5, 10},
|
||||
{9, 10},
|
||||
{10, 20},
|
||||
{29, 20},
|
||||
{30, 30},
|
||||
{100, 30},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := CalculateBuff(tt.likeCount)
|
||||
if result != tt.expected {
|
||||
t.Errorf("CalculateBuff(%d) = %d, want %d", tt.likeCount, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -15,9 +15,10 @@ import (
|
||||
|
||||
// AssetLikeService 资产点赞业务逻辑层
|
||||
type AssetLikeService struct {
|
||||
assetRepo repository.AssetRepository
|
||||
assetLikeRepo repository.AssetLikeRepository
|
||||
db *gorm.DB
|
||||
assetRepo repository.AssetRepository
|
||||
assetLikeRepo repository.AssetLikeRepository
|
||||
db *gorm.DB
|
||||
assetLevelService AssetLevelService // 资产等级服务
|
||||
}
|
||||
|
||||
// NewAssetLikeService 创建资产点赞Service实例
|
||||
@ -25,11 +26,13 @@ func NewAssetLikeService(
|
||||
assetRepo repository.AssetRepository,
|
||||
assetLikeRepo repository.AssetLikeRepository,
|
||||
db *gorm.DB,
|
||||
assetLevelService AssetLevelService,
|
||||
) *AssetLikeService {
|
||||
return &AssetLikeService{
|
||||
assetRepo: assetRepo,
|
||||
assetLikeRepo: assetLikeRepo,
|
||||
db: db,
|
||||
assetRepo: assetRepo,
|
||||
assetLikeRepo: assetLikeRepo,
|
||||
db: db,
|
||||
assetLevelService: assetLevelService,
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,6 +47,10 @@ func (s *AssetLikeService) isAssetExhibiting(assetID int64) (int64, error) {
|
||||
Where("asset_id = ? AND expire_at > ?", assetID, nowMs).
|
||||
First(&exhibition).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 资产不在任何展出中,返回 0 表示未展出
|
||||
return 0, nil
|
||||
}
|
||||
return 0, fmt.Errorf("failed to check exhibition status: %w", err)
|
||||
}
|
||||
return exhibition.ID, nil
|
||||
@ -67,37 +74,18 @@ func shouldUpdateMaterialType(oldLikes, newLikes int32, createdAt int64, isIncre
|
||||
|
||||
// 点赞增加时的临界点
|
||||
if isIncrement {
|
||||
// new → hot (超过20)
|
||||
if oldLikes <= 20 && newLikes > 20 {
|
||||
// 超过20 → 添加 hot
|
||||
if newLikes > 20 {
|
||||
return true, "hot"
|
||||
}
|
||||
// new → potential (达到10且在1小时内)
|
||||
if oldLikes < 10 && newLikes >= 10 && isWithin1Hour {
|
||||
// 1小时内达到10 → 添加 potential
|
||||
if newLikes >= 10 && isWithin1Hour {
|
||||
return true, "potential"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 点赞减少时的临界点(降级检测)
|
||||
if !isIncrement {
|
||||
// hot → new/potential (降到20以下)
|
||||
if oldLikes > 20 && newLikes <= 20 {
|
||||
// 重新计算
|
||||
if newLikes >= 10 && isWithin1Hour {
|
||||
return true, "potential"
|
||||
}
|
||||
return true, "new"
|
||||
}
|
||||
// potential → new (降到10以下)
|
||||
if oldLikes >= 10 && newLikes < 10 {
|
||||
return true, "new"
|
||||
}
|
||||
// hot 降到 21-20 区间
|
||||
if oldLikes > 20 && newLikes > 20 {
|
||||
return false, "" // 仍在 hot 区间
|
||||
}
|
||||
}
|
||||
|
||||
// 点赞减少时不降级(保留原类型)
|
||||
return false, ""
|
||||
}
|
||||
|
||||
@ -184,15 +172,13 @@ func (s *AssetLikeService) LikeAsset(ctx context.Context, assetID, userID, starI
|
||||
return fmt.Errorf("failed to increment like count: %w", err)
|
||||
}
|
||||
|
||||
// 3.3 更新素材类型(只在临界点更新)
|
||||
// 3.3 更新素材类型(追加类型,不重复)
|
||||
oldLikes := asset.LikeCount
|
||||
newLikes := oldLikes + 1
|
||||
shouldUpdate, newMaterialType := shouldUpdateMaterialType(oldLikes, newLikes, asset.CreatedAt, true)
|
||||
if shouldUpdate {
|
||||
if err := tx.Model(&models.Asset{}).
|
||||
Where("id = ?", assetID).
|
||||
UpdateColumn("material_type", newMaterialType).
|
||||
Error; err != nil {
|
||||
if shouldUpdate && newMaterialType != "" {
|
||||
// 使用 PostgreSQL CONCAT 函数追加类型
|
||||
if err := tx.Raw("UPDATE assets SET material_type = CONCAT(COALESCE(material_type, ''), ',', ?) WHERE id = ?", newMaterialType, assetID).Error; err != nil {
|
||||
logger.Logger.Warn("Failed to update material type",
|
||||
zap.Error(err),
|
||||
zap.Int64("asset_id", assetID),
|
||||
@ -223,6 +209,19 @@ func (s *AssetLikeService) LikeAsset(ctx context.Context, assetID, userID, starI
|
||||
zap.Int32("like_count", asset.LikeCount),
|
||||
)
|
||||
|
||||
// 5. 更新资产等级记录(点赞数变化)
|
||||
if s.assetLevelService != nil {
|
||||
if newLevel, upgraded, err := s.assetLevelService.AddLikes(assetID, 1); err != nil {
|
||||
logger.Logger.Warn("Failed to add likes to asset level",
|
||||
zap.Int64("asset_id", assetID),
|
||||
zap.Error(err))
|
||||
} else if upgraded {
|
||||
logger.Logger.Info("Asset leveled up due to likes",
|
||||
zap.Int64("asset_id", assetID),
|
||||
zap.String("new_level", newLevel))
|
||||
}
|
||||
}
|
||||
|
||||
return asset.LikeCount, nil
|
||||
}
|
||||
|
||||
@ -338,6 +337,19 @@ func (s *AssetLikeService) UnlikeAsset(ctx context.Context, assetID, userID, sta
|
||||
zap.Int32("like_count", asset.LikeCount),
|
||||
)
|
||||
|
||||
// 4. 更新资产等级记录(取消点赞)
|
||||
if s.assetLevelService != nil {
|
||||
if newLevel, downgraded, err := s.assetLevelService.RemoveLikes(assetID, 1); err != nil {
|
||||
logger.Logger.Warn("Failed to remove likes from asset level",
|
||||
zap.Int64("asset_id", assetID),
|
||||
zap.Error(err))
|
||||
} else if downgraded {
|
||||
logger.Logger.Info("Asset downgraded due to like removal",
|
||||
zap.Int64("asset_id", assetID),
|
||||
zap.String("new_level", newLevel))
|
||||
}
|
||||
}
|
||||
|
||||
return asset.LikeCount, nil
|
||||
}
|
||||
|
||||
|
||||
@ -441,13 +441,13 @@ func (s *assetService) GetAsset(req *pb.GetAssetRequest, userID, starID int64) (
|
||||
var ownerNickname string
|
||||
profile, err := s.userClient.GetFanProfile(context.Background(), asset.OwnerUID, asset.StarID)
|
||||
if err != nil {
|
||||
logger.Logger.Warn("Failed to get owner fan profile, will return without nickname",
|
||||
logger.Logger.Warn("Failed to get owner fan profile, using fallback nickname",
|
||||
zap.Int64("owner_uid", asset.OwnerUID),
|
||||
zap.Int64("star_id", asset.StarID),
|
||||
zap.Error(err),
|
||||
)
|
||||
// 获取失败时,使用空字符串
|
||||
ownerNickname = ""
|
||||
// 获取失败时,使用 User{uid} 作为 fallback
|
||||
ownerNickname = fmt.Sprintf("User%d", asset.OwnerUID)
|
||||
} else {
|
||||
ownerNickname = profile.Nickname
|
||||
}
|
||||
|
||||
@ -67,6 +67,7 @@ type mintService struct {
|
||||
registryRepo starbookRepo.AssetRegistryRepository // 资产索引仓库(用于星册体系)
|
||||
localMintCostRepo repository.MintCostRepository // 铸造消耗配置仓库
|
||||
userMintCountRepo repository.UserMintCountRepository // 用户铸爱累计仓库
|
||||
assetLevelService AssetLevelService // 资产等级服务
|
||||
}
|
||||
|
||||
// NewMintService 创建铸造服务实例
|
||||
@ -79,6 +80,7 @@ func NewMintService(
|
||||
registryRepo starbookRepo.AssetRegistryRepository,
|
||||
localMintCostRepo repository.MintCostRepository,
|
||||
userMintCountRepo repository.UserMintCountRepository,
|
||||
assetLevelService AssetLevelService,
|
||||
) MintService {
|
||||
return &mintService{
|
||||
assetRepo: assetRepo,
|
||||
@ -89,6 +91,7 @@ func NewMintService(
|
||||
registryRepo: registryRepo,
|
||||
localMintCostRepo: localMintCostRepo,
|
||||
userMintCountRepo: userMintCountRepo,
|
||||
assetLevelService: assetLevelService,
|
||||
}
|
||||
}
|
||||
|
||||
@ -439,7 +442,16 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. 无需异步 AI 处理,cover_url 已在步骤 3.2 中直接设置
|
||||
// 4. 初始化资产等级记录
|
||||
if s.assetLevelService != nil && asset != nil {
|
||||
if _, err := s.assetLevelService.GetOrCreateRecord(asset.ID); err != nil {
|
||||
logger.Logger.Warn("Failed to create asset level record",
|
||||
zap.Int64("asset_id", asset.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 无需异步 AI 处理,cover_url 已在步骤 3.2 中直接设置
|
||||
|
||||
// 5. 获取所有者的昵称(创建时所有者就是当前用户)
|
||||
var ownerNickname string
|
||||
|
||||
52
backend/services/assetService/worker/season_reset_worker.go
Normal file
52
backend/services/assetService/worker/season_reset_worker.go
Normal file
@ -0,0 +1,52 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
"github.com/topfans/backend/services/assetService/repository"
|
||||
"github.com/topfans/backend/services/assetService/service"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type SeasonResetWorker struct {
|
||||
seasonRepo *repository.SeasonRepository
|
||||
levelService service.AssetLevelService
|
||||
}
|
||||
|
||||
func NewSeasonResetWorker(
|
||||
seasonRepo *repository.SeasonRepository,
|
||||
levelService service.AssetLevelService,
|
||||
) *SeasonResetWorker {
|
||||
return &SeasonResetWorker{
|
||||
seasonRepo: seasonRepo,
|
||||
levelService: levelService,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *SeasonResetWorker) Run() {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// 获取已结束的赛季
|
||||
seasons, err := w.seasonRepo.GetEndedSeasons()
|
||||
if err != nil {
|
||||
logger.Logger.Error("SeasonResetWorker: failed to get ended seasons", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
for _, season := range seasons {
|
||||
if season.EndTime > now {
|
||||
continue // 还未真正结束
|
||||
}
|
||||
|
||||
if err := w.levelService.SeasonReset(season.ID); err != nil {
|
||||
logger.Logger.Error("SeasonResetWorker: failed to reset season",
|
||||
zap.String("season_id", season.ID),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Logger.Info("SeasonResetWorker: season reset completed",
|
||||
zap.String("season_id", season.ID))
|
||||
}
|
||||
}
|
||||
@ -28,6 +28,7 @@ type OnExhibitionCompletedRequest struct {
|
||||
StartTime int64
|
||||
ExpireAt int64
|
||||
CrystalAmount int64
|
||||
LikeCount int32 // 点赞数,用于taskService根据资产等级重新计算收益
|
||||
}
|
||||
|
||||
// OnExhibitionCompletedResponse 展位完成响应
|
||||
@ -72,6 +73,7 @@ func (c *taskRPCClient) OnExhibitionCompleted(ctx context.Context, req *OnExhibi
|
||||
StartTime: req.StartTime,
|
||||
ExpireAt: req.ExpireAt,
|
||||
CrystalAmount: req.CrystalAmount,
|
||||
LikeCount: req.LikeCount,
|
||||
}
|
||||
|
||||
resp, err := c.client.OnExhibitionCompleted(ctx, pbReq)
|
||||
|
||||
@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/topfans/backend/pkg/models"
|
||||
@ -514,7 +515,7 @@ func (r *galleryRepository) CountValidExhibitions(starID int64, materialType str
|
||||
|
||||
if materialType != "" && materialType != "all" && materialType != "random" {
|
||||
query = query.Joins("JOIN assets a ON a.id = exhibitions.asset_id").
|
||||
Where("a.material_type = ?", materialType)
|
||||
Where("a.material_type LIKE ?", "%"+materialType+"%")
|
||||
}
|
||||
|
||||
err := query.Count(&count).Error
|
||||
@ -532,7 +533,7 @@ func (r *galleryRepository) GetRandomExhibitions(starID int64, materialType stri
|
||||
|
||||
if materialType != "" && materialType != "all" && materialType != "random" {
|
||||
baseQuery = baseQuery.Joins("JOIN assets a ON a.id = exhibitions.asset_id").
|
||||
Where("a.material_type = ?", materialType)
|
||||
Where("a.material_type LIKE ?", "%"+materialType+"%")
|
||||
}
|
||||
|
||||
// 排除已展示的ID
|
||||
@ -568,26 +569,42 @@ func (r *galleryRepository) GetRandomExhibitions(starID int64, materialType stri
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read-repair: 检查并修正 material_type(查询时自动修正数据不一致)
|
||||
// Read-repair: 检查并补充 material_type(多类型共存,不降级)
|
||||
for _, item := range items {
|
||||
item.Span = calcSpanByLikes(item.LikeCount)
|
||||
correctType := calcMaterialType(item.LikeCount, item.CreatedAt)
|
||||
if item.MaterialType != correctType {
|
||||
// 异步修正,不阻塞返回(使用 goroutine 避免影响响应时间)
|
||||
go func(assetID int64, correct string) {
|
||||
correctTypes := calcMaterialTypes(item.LikeCount, item.CreatedAt)
|
||||
|
||||
// 如果缺少任何类型,追加(不覆盖原有类型)
|
||||
missingTypes := []string{}
|
||||
for _, ct := range strings.Split(correctTypes, ",") {
|
||||
ct = strings.TrimSpace(ct)
|
||||
if ct != "" && !strings.Contains(item.MaterialType, ct) {
|
||||
missingTypes = append(missingTypes, ct)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingTypes) > 0 {
|
||||
newType := strings.Join(missingTypes, ",")
|
||||
// 异步修正
|
||||
go func(assetID int64, newType string) {
|
||||
r.db.Model(&models.Asset{}).
|
||||
Where("id = ?", assetID).
|
||||
Update("material_type", correct)
|
||||
}(item.AssetID, correctType)
|
||||
// 同时修正当前对象的值,保证返回给前端的是正确的
|
||||
item.MaterialType = correctType
|
||||
Update("material_type", gorm.Expr("CONCAT(COALESCE(material_type, ''), ',', ?)", newType))
|
||||
}(item.AssetID, newType)
|
||||
|
||||
// 修正当前返回对象
|
||||
if item.MaterialType == "" {
|
||||
item.MaterialType = correctTypes
|
||||
} else {
|
||||
item.MaterialType = item.MaterialType + "," + strings.Join(missingTypes, ",")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// calcMaterialType 根据点赞数计算素材类型
|
||||
// calcMaterialType 根据点赞数计算素材类型(单类型,互斥)
|
||||
func calcMaterialType(likes int32, createdAt int64) string {
|
||||
now := time.Now().UnixMilli()
|
||||
isWithin1Hour := createdAt > 0 && now-createdAt < 3600*1000
|
||||
@ -602,6 +619,27 @@ func calcMaterialType(likes int32, createdAt int64) string {
|
||||
return "new"
|
||||
}
|
||||
|
||||
// calcMaterialTypes 根据点赞数计算素材类型(多类型共存)
|
||||
func calcMaterialTypes(likes int32, createdAt int64) string {
|
||||
now := time.Now().UnixMilli()
|
||||
isWithin1Hour := createdAt > 0 && now-createdAt < 3600*1000
|
||||
|
||||
types := []string{}
|
||||
|
||||
// 超过20 → hot
|
||||
if likes > 20 {
|
||||
types = append(types, "hot")
|
||||
}
|
||||
// 1小时内达到10 → potential
|
||||
if isWithin1Hour && likes >= 10 {
|
||||
types = append(types, "potential")
|
||||
}
|
||||
// new 默认存在
|
||||
types = append(types, "new")
|
||||
|
||||
return strings.Join(types, ",")
|
||||
}
|
||||
|
||||
// calcSpanByLikes 根据点赞数计算卡片大小
|
||||
// 0-30 → span 1, 31-100 → span 2, 101-200 → span 3, 200+ → span 4
|
||||
func calcSpanByLikes(likes int32) int32 {
|
||||
|
||||
@ -132,6 +132,7 @@ func (w *CleanupWorker) cleanupExpiredExhibitions(now int64) {
|
||||
StartTime: e.StartTime,
|
||||
ExpireAt: now,
|
||||
CrystalAmount: revenue,
|
||||
LikeCount: int32(likeCount),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Logger.Error("调用TaskService记录收益失败,跳过标记已处理以便重试",
|
||||
|
||||
@ -62,6 +62,8 @@ func (s *AssetLikeService) LikeAsset(ctx context.Context, assetID, userID, starI
|
||||
// 2. 检查是否已点赞
|
||||
checkLikeReq := &assetPb.CheckAssetLikeRequest{
|
||||
AssetId: assetID,
|
||||
UserId: userID,
|
||||
StarId: starID,
|
||||
}
|
||||
checkLikeResp, err := s.assetClient.CheckAssetLike(ctx, checkLikeReq)
|
||||
if err != nil {
|
||||
@ -125,6 +127,8 @@ func (s *AssetLikeService) UnlikeAsset(ctx context.Context, assetID, userID, sta
|
||||
// 1. 检查是否已点赞
|
||||
checkLikeReq := &assetPb.CheckAssetLikeRequest{
|
||||
AssetId: assetID,
|
||||
UserId: userID,
|
||||
StarId: starID,
|
||||
}
|
||||
checkLikeResp, err := s.assetClient.CheckAssetLike(ctx, checkLikeReq)
|
||||
if err != nil {
|
||||
@ -187,6 +191,8 @@ func (s *AssetLikeService) CheckAssetLike(ctx context.Context, assetID, userID,
|
||||
|
||||
checkLikeReq := &assetPb.CheckAssetLikeRequest{
|
||||
AssetId: assetID,
|
||||
UserId: userID,
|
||||
StarId: starID,
|
||||
}
|
||||
checkLikeResp, err := s.assetClient.CheckAssetLike(ctx, checkLikeReq)
|
||||
if err != nil {
|
||||
|
||||
@ -17,6 +17,8 @@ import (
|
||||
pb "github.com/topfans/backend/pkg/proto/task"
|
||||
pbGallery "github.com/topfans/backend/pkg/proto/gallery"
|
||||
pbUser "github.com/topfans/backend/pkg/proto/user"
|
||||
assetLevelRepo "github.com/topfans/backend/services/assetService/repository"
|
||||
assetLevelSvc "github.com/topfans/backend/services/assetService/service"
|
||||
"github.com/topfans/backend/services/taskService/client"
|
||||
"github.com/topfans/backend/services/taskService/config"
|
||||
"github.com/topfans/backend/services/taskService/model"
|
||||
@ -105,9 +107,15 @@ func main() {
|
||||
logger.Logger.Info("Gallery RPC client initialized")
|
||||
|
||||
// 6. Init services
|
||||
// Create AssetLevelService for revenue calculations
|
||||
assetLevelRepository := assetLevelRepo.NewAssetLevelRepository(db)
|
||||
seasonRepository := assetLevelRepo.NewSeasonRepository(db)
|
||||
seasonDecayConfigRepository := assetLevelRepo.NewSeasonDecayConfigRepository(db)
|
||||
assetLevelService := assetLevelSvc.NewAssetLevelService(assetLevelRepository, seasonRepository, seasonDecayConfigRepository)
|
||||
|
||||
dailySvc := service.NewDailyTaskService(dailyRepo, userRPCClient)
|
||||
onboardingSvc := service.NewOnboardingService(onboardingRepo, dailyRepo, userRPCClient)
|
||||
revenueSvc := service.NewRevenueService(revenueRepo, userRPCClient, galleryRPCClient)
|
||||
revenueSvc := service.NewRevenueService(revenueRepo, userRPCClient, galleryRPCClient, assetLevelService)
|
||||
logger.Logger.Info("Services initialized")
|
||||
|
||||
// 7. Init worker(goroutine 中启动)
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
|
||||
pbCommon "github.com/topfans/backend/pkg/proto/common"
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
"github.com/topfans/backend/pkg/models"
|
||||
pb "github.com/topfans/backend/pkg/proto/task"
|
||||
"github.com/topfans/backend/services/taskService/client"
|
||||
"github.com/topfans/backend/services/taskService/model"
|
||||
@ -20,24 +21,39 @@ type RevenueService interface {
|
||||
ClaimExhibitionRevenue(ctx context.Context, userID, starID int64, revenueID int64) (*pb.ClaimExhibitionRevenueResponse, error)
|
||||
ClaimAllExhibitionRevenue(ctx context.Context, userID, starID int64) (*pb.ClaimAllExhibitionRevenueResponse, error)
|
||||
OnExhibitionCompleted(ctx context.Context, req *pb.OnExhibitionCompletedRequest) (*pb.OnExhibitionCompletedResponse, error)
|
||||
SetAssetLevelService(svc AssetLevelService)
|
||||
}
|
||||
|
||||
// AssetLevelService 资产等级服务接口(定义在assetService)
|
||||
type AssetLevelService interface {
|
||||
GetOrCreateRecord(assetID int64) (*models.AssetLevelRecord, error)
|
||||
AddExhibitionHours(assetID int64, hours int) (string, bool, error)
|
||||
CalculateRevenue(assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error)
|
||||
}
|
||||
|
||||
// revenueService 展示收益Service实现
|
||||
type revenueService struct {
|
||||
revenueRepo repository.RevenueRepository
|
||||
userRPCClient client.UserServiceClient
|
||||
revenueRepo repository.RevenueRepository
|
||||
userRPCClient client.UserServiceClient
|
||||
galleryRPCClient client.GalleryServiceClient
|
||||
assetLevelService AssetLevelService // 资产等级服务
|
||||
}
|
||||
|
||||
// NewRevenueService 创建收益Service实例
|
||||
func NewRevenueService(revenueRepo repository.RevenueRepository, userRPCClient client.UserServiceClient, galleryRPCClient client.GalleryServiceClient) RevenueService {
|
||||
func NewRevenueService(revenueRepo repository.RevenueRepository, userRPCClient client.UserServiceClient, galleryRPCClient client.GalleryServiceClient, assetLevelService AssetLevelService) RevenueService {
|
||||
return &revenueService{
|
||||
revenueRepo: revenueRepo,
|
||||
userRPCClient: userRPCClient,
|
||||
revenueRepo: revenueRepo,
|
||||
userRPCClient: userRPCClient,
|
||||
galleryRPCClient: galleryRPCClient,
|
||||
assetLevelService: assetLevelService,
|
||||
}
|
||||
}
|
||||
|
||||
// SetAssetLevelService 设置资产等级服务
|
||||
func (s *revenueService) SetAssetLevelService(svc AssetLevelService) {
|
||||
s.assetLevelService = svc
|
||||
}
|
||||
|
||||
// GetExhibitionRevenue 获取展示收益列表
|
||||
func (s *revenueService) GetExhibitionRevenue(ctx context.Context, userID, starID int64, status string, page, pageSize int32) (*pb.GetExhibitionRevenueResponse, error) {
|
||||
logger.Logger.Debug("GetExhibitionRevenue",
|
||||
@ -307,7 +323,33 @@ func (s *revenueService) OnExhibitionCompleted(ctx context.Context, req *pb.OnEx
|
||||
zap.Int64("slot_id", req.SlotId),
|
||||
zap.Int64("occupier_uid", req.OccupierUid),
|
||||
zap.Int64("slot_owner_uid", req.SlotOwnerUid),
|
||||
zap.Int64("crystal_amount", req.CrystalAmount))
|
||||
zap.Int64("crystal_amount", req.CrystalAmount),
|
||||
zap.Int32("like_count", req.LikeCount))
|
||||
|
||||
// 计算实际上架时长(毫秒转小时)
|
||||
startTime := req.StartTime
|
||||
expireAt := req.ExpireAt
|
||||
actualHours := (expireAt - startTime) / 3600000
|
||||
|
||||
// 重新计算收益(使用资产等级对应的R0值,而非galleryService传来的硬编码R0=5)
|
||||
var finalRevenue int64
|
||||
if s.assetLevelService != nil && req.AssetId > 0 {
|
||||
if calculatedRevenue, err := s.assetLevelService.CalculateRevenue(req.AssetId, int(req.LikeCount), startTime, expireAt, 0); err == nil {
|
||||
finalRevenue = calculatedRevenue
|
||||
logger.Logger.Info("OnExhibitionCompleted: recalculated revenue using asset level",
|
||||
zap.Int64("asset_id", req.AssetId),
|
||||
zap.Int64("original_revenue", req.CrystalAmount),
|
||||
zap.Int64("recalculated_revenue", finalRevenue))
|
||||
} else {
|
||||
// 计算失败,使用传来的值
|
||||
finalRevenue = req.CrystalAmount
|
||||
logger.Logger.Warn("OnExhibitionCompleted: failed to calculate revenue with asset level, using original",
|
||||
zap.Int64("asset_id", req.AssetId),
|
||||
zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
finalRevenue = req.CrystalAmount
|
||||
}
|
||||
|
||||
// 收益归属资产主人(铸爱用户),无论展位是否为自己
|
||||
now := time.Now().UnixMilli()
|
||||
@ -319,7 +361,7 @@ func (s *revenueService) OnExhibitionCompleted(ctx context.Context, req *pb.OnEx
|
||||
SlotID: req.SlotId,
|
||||
SlotOwnerUID: req.SlotOwnerUid, // 记录展位所有者信息(仅供参考)
|
||||
SlotType: "exhibition", // 上架展示收益
|
||||
CrystalAmount: req.CrystalAmount,
|
||||
CrystalAmount: finalRevenue, // 使用重新计算的收益
|
||||
CycleStartTime: req.StartTime,
|
||||
CycleEndTime: req.ExpireAt,
|
||||
Status: "claimable",
|
||||
@ -334,12 +376,6 @@ func (s *revenueService) OnExhibitionCompleted(ctx context.Context, req *pb.OnEx
|
||||
return &pb.OnExhibitionCompletedResponse{Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL_ERROR}}, err
|
||||
}
|
||||
|
||||
// 增加用户累计上架时长(展位主人获得上架时长累计)
|
||||
// 计算实际上架时长(毫秒转小时)
|
||||
startTime := req.StartTime
|
||||
expireAt := req.ExpireAt
|
||||
actualHours := (expireAt - startTime) / 3600000
|
||||
|
||||
// sourceID 用于去重,避免重复累计
|
||||
sourceID := fmt.Sprintf("exhibition_%d", req.ExhibitionId)
|
||||
|
||||
@ -367,6 +403,21 @@ func (s *revenueService) OnExhibitionCompleted(ctx context.Context, req *pb.OnEx
|
||||
zap.Int64("crystal_reward", crystalReward))
|
||||
}
|
||||
|
||||
// 增加资产累计展出时长(资产等级系统)
|
||||
if s.assetLevelService != nil && req.AssetId > 0 && actualHours > 0 {
|
||||
if newLevel, upgraded, err := s.assetLevelService.AddExhibitionHours(req.AssetId, int(actualHours)); err != nil {
|
||||
logger.Logger.Warn("OnExhibitionCompleted: failed to add exhibition hours to asset level",
|
||||
zap.Int64("asset_id", req.AssetId),
|
||||
zap.Int64("hours", actualHours),
|
||||
zap.Error(err))
|
||||
} else if upgraded {
|
||||
logger.Logger.Info("OnExhibitionCompleted: asset leveled up due to exhibition",
|
||||
zap.Int64("asset_id", req.AssetId),
|
||||
zap.String("new_level", newLevel),
|
||||
zap.Int64("hours", actualHours))
|
||||
}
|
||||
}
|
||||
|
||||
logger.Logger.Info("OnExhibitionCompleted: success",
|
||||
zap.Int64("exhibition_id", req.ExhibitionId),
|
||||
zap.Int64("revenue_record_id", createdRecord.ID))
|
||||
@ -393,15 +444,17 @@ func CalculateBuff(likeCount int) int {
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateExhibitionRevenue 计算单次上架收益
|
||||
// CalculateExhibitionRevenue 计算单次上架收益(参考实现,未被调用)
|
||||
// 注意:实际收益计算在 OnExhibitionCompleted 中通过 AssetLevelService.CalculateRevenue 实现
|
||||
// 此函数保留用于参考和测试场景
|
||||
// 设计文档公式:
|
||||
// R1 = R0 × T × [100% + Buff(n)]
|
||||
// R0 = 5 水晶/小时
|
||||
// R0 = 5 水晶/小时(默认,仅作参考)
|
||||
// T = 上架时长(小时)
|
||||
// Buff(n) 根据点赞数计算
|
||||
// 应用永久收益提升:revenueBoostBps (bps),如 500 = +5%
|
||||
func CalculateExhibitionRevenue(likeCount int, startTime, endTime int64, revenueBoostBps int) int64 {
|
||||
R0 := int64(5) // 水晶/小时
|
||||
R0 := int64(5) // 水晶/小时(默认参考值)
|
||||
|
||||
// 计算上架时长(毫秒转小时)
|
||||
T := (endTime - startTime) / 3600000
|
||||
|
||||
BIN
backend/socialService
Executable file
BIN
backend/socialService
Executable file
Binary file not shown.
1259
docs/superpowers/plans/2026-05-25-asset-level-system.md
Normal file
1259
docs/superpowers/plans/2026-05-25-asset-level-system.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -1012,7 +1012,7 @@ onUnmounted(() => {
|
||||
width: 352rpx;
|
||||
height: 520rpx;
|
||||
margin-bottom: 32rpx;
|
||||
/* animation: card-3d-flip 15s ease-in-out infinite; */
|
||||
animation: card-3d-flip 15s ease-in-out infinite;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
|
||||
@ -383,13 +383,13 @@ const handleAvatarClick = () => {
|
||||
if (pages.length > 0) {
|
||||
const currentPage = pages[pages.length - 1];
|
||||
// 检查当前页面是否是个人信息页面
|
||||
if (currentPage.route === 'pages/profile/myWorks') {
|
||||
if (currentPage.route === 'pages/profile/profile') {
|
||||
// 已经在个人信息页面,不执行跳转
|
||||
return;
|
||||
}
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: '/pages/profile/myWorks'
|
||||
url: '/pages/profile/profile'
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -10,9 +10,9 @@
|
||||
</view>
|
||||
<!-- <text class="nav-title">我的作品</text> -->
|
||||
<view class="nav-placeholder"></view>
|
||||
<view class="nav-settings" @tap="goToSettings">
|
||||
<!-- <view class="nav-settings" @tap="goToSettings">
|
||||
<image class="nav-settings-icon" src="/static/icon/settings.png" mode="aspectFit"></image>
|
||||
</view>
|
||||
</view> -->
|
||||
</view>
|
||||
|
||||
<view class="scroll-content">
|
||||
|
||||
@ -1296,8 +1296,8 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.close-icon-img {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
position: relative;
|
||||
top: 96rpx;
|
||||
left: 32rpx;
|
||||
@ -1372,15 +1372,11 @@ onShow(() => {
|
||||
|
||||
}
|
||||
|
||||
.uid-value {}
|
||||
|
||||
.toggle-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
|
||||
.address-value {}
|
||||
|
||||
.info-btn {
|
||||
font-size: 20rpx;
|
||||
color: #FFD700;
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: { type: String, default: 'random' }
|
||||
modelValue: { type: String, default: 'hot' }
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
@ -42,7 +42,7 @@ const tabs = [
|
||||
{ key: 'hot', label: '人气王者', emoji: null, icon: '/static/square/3.png',iconWidth: 80, iconHeight: 80 },
|
||||
{ key: 'new', label: '新鲜上架', emoji: null, icon: '/static/square/2.png',iconWidth: 80, iconHeight: 80 },
|
||||
{ key: 'potential', label: '潜力之星', emoji: null, icon: '/static/square/1.png',iconWidth: 96, iconHeight: 96 },
|
||||
{ key: 'random', label: '随机寻宝', emoji: null, icon: '/static/square/4.png',iconWidth: 80, iconHeight: 80 },
|
||||
{ key: 'myworks', label: '我的', emoji: null, icon: '/static/square/4.png',iconWidth: 80, iconHeight: 80 },
|
||||
]
|
||||
</script>
|
||||
|
||||
|
||||
@ -89,14 +89,18 @@ const currentUserNickname = computed(() => store.state.user?.userInfo?.nickname
|
||||
const currentStarId = ref(uni.getStorageSync('star_id') || null)
|
||||
|
||||
// ========== UI State ==========
|
||||
const activeContentTab = ref('random')
|
||||
const activeContentTab = ref('hot')
|
||||
const waterfallKey = ref(0) // 用于重新加载 WaterfallGrid
|
||||
const navExpanded = ref(false)
|
||||
const showRankingModal = ref(false)
|
||||
const isActive = ref(true)
|
||||
|
||||
// ========== Watch activeContentTab ==========
|
||||
watch(activeContentTab, () => {
|
||||
watch(activeContentTab, (newTab) => {
|
||||
if (newTab === 'myworks') {
|
||||
uni.navigateTo({ url: '/pages/profile/myWorks' })
|
||||
return
|
||||
}
|
||||
// 切换标签时重置 WaterfallGrid(iOS 需要重新挂载才能重置 CSS 动画)
|
||||
waterfallKey.value++
|
||||
})
|
||||
@ -191,6 +195,7 @@ onMounted(() => {
|
||||
|
||||
onShow(() => {
|
||||
isActive.value = true
|
||||
activeContentTab.value = 'hot'
|
||||
// 检查是否需要显示引导,如果需要则跳转到引导页面
|
||||
// if (shouldShowGuideStartModal()) {
|
||||
// uni.navigateTo({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user