topfans/backend/services/assetService/service/asset_service.go
zerosaturation 8a767fb400 fix(backend): service stability — batch 3 accumulated (bcrypt off-txn / login anti-enum / MQ stub / aiChat / event reliability / gateway aggregate)
- 3.1 bcrypt 移出事务 (Register): repository.HashPassword 前移到 db.Transaction 之前。
- 3.2 Login 消除用户枚举 + 限流 + timing 抹平: pkg/errors 加 ErrInvalidCredential / ErrTooManyLoginAttempts;
  user-not-found 跑 dummy bcrypt 抹平 ~100ms 时序差; mobile 5次/ip 20次 per 15min 限流 (Redis, fail-open 降级)。
- 3.3 MQ streams adapter 停用 → stub: 0 业务调用方, noop EventProducer.Publish; Init 不再装配 streams;
  11 处硬编码 'gallery'/'default' 抽常量到 pkg/queue/consts (值不变, 消漂移)。
- 3.5 JWT 密钥治理: pkg/jwt MustInit fail-fast + atomic.Value, 50-goroutine race_test 零告警;
  MustInit 调用点 gateway main + auth_provider + loadgen 同步更新。
- 3.6 aiChat 健壮性: SaveContext 用 persona.ID(非 req.PersonaId); Redis/memory 错误 记 WARN 不静默;
  Dify err 映射稳定用户文案。
- 3.7 statistic.Client 重构: TrackEvent 改 buffered channel (cap 1024) + dispatchLoop worker。
- 3.8 网关聚合: StarCache (60s TTL, singleflight) 替换 GetFanIdentities 链式调用;
  DeleteAccount 改网关直调 userService.DeleteAccount(避免改 hand-written triple.go);
  铸造双写改异步 channel+consumer (3 retry)。
- 大量单测: 各子项 TDD (RED→GREEN), 关键并发 race_test (50 goroutine)。
- .env.example JWT_SECRET 改为 ≥32 字节 base64 示例(原为空, 被 MustInit 立即拒)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 14:03:21 +08:00

990 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"context"
"errors"
"fmt"
"sort"
"time"
appErrors "github.com/topfans/backend/pkg/errors"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
pb "github.com/topfans/backend/pkg/proto/asset"
pbCommon "github.com/topfans/backend/pkg/proto/common"
"github.com/topfans/backend/pkg/validator"
"github.com/topfans/backend/services/assetService/client"
"github.com/topfans/backend/services/assetService/repository"
"go.uber.org/zap"
"gorm.io/gorm"
"google.golang.org/grpc/codes"
)
// AssetService 资产服务接口
type AssetService interface {
// GetMyAssets 获取我的藏品列表
GetMyAssets(req *pb.GetMyAssetsRequest, userID, starID int64) (*pb.GetMyAssetsResponse, error)
// GetAssetsByType 按类型/分类/等级过滤查询星册藏品starbook 分类页)
GetAssetsByType(req *pb.GetAssetsByTypeRequest, userID, starID int64) (*pb.GetAssetsByTypeResponse, error)
// GetAsset 获取资产详情
GetAsset(req *pb.GetAssetRequest, userID, starID int64) (*pb.GetAssetResponse, error)
// GetAssetStatus 查询上链状态
GetAssetStatus(req *pb.GetAssetStatusRequest, userID, starID int64) (*pb.GetAssetStatusResponse, error)
// GetAssetForRPC 获取资产信息内部RPC调用
GetAssetForRPC(req *pb.GetAssetForRPCRequest) (*pb.GetAssetForRPCResponse, error)
// GetAssetQrcode 生成分享二维码(spec § 3)
// Dubbo-style 签名: 内部由 shareService 委托
GetAssetQrcode(ctx context.Context, req *pb.GetAssetQrcodeRequest) (*pb.GetAssetQrcodeResponse, error)
// TrackShare 记录分享归因事件(spec § 3.5)
TrackShare(ctx context.Context, req *pb.TrackShareRequest) (*pb.TrackShareResponse, error)
}
// RegistryRepository 资产注册表Repository接口用于分组查询
type RegistryRepository interface {
// GetByOwner 查询用户的注册记录
GetByOwner(ownerUID, starID int64) ([]*models.AssetRegistry, error)
}
// assetService 资产服务实现
type assetService struct {
assetRepo repository.AssetRepository
mintOrderRepo repository.MintOrderRepository
assetLikeRepo repository.AssetLikeRepository
userClient client.UserServiceClient
db *gorm.DB
registryRepo RegistryRepository
shareService *ShareService
}
// NewAssetService 创建资产服务实例
func NewAssetService(
assetRepo repository.AssetRepository,
mintOrderRepo repository.MintOrderRepository,
assetLikeRepo repository.AssetLikeRepository,
userClient client.UserServiceClient,
db *gorm.DB,
registryRepo RegistryRepository,
shareService *ShareService,
) AssetService {
return &assetService{
assetRepo: assetRepo,
mintOrderRepo: mintOrderRepo,
assetLikeRepo: assetLikeRepo,
userClient: userClient,
db: db,
registryRepo: registryRepo,
shareService: shareService,
}
}
// GetMyAssets 获取我的藏品列表分组格式与星册home一致
func (s *assetService) GetMyAssets(req *pb.GetMyAssetsRequest, userID, starID int64) (*pb.GetMyAssetsResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(userID) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", userID),
)
return nil, appErrors.ErrInvalidUserID
}
if !validator.ValidateStarID(starID) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrInvalidStarID
}
// 设置默认分页参数
page := req.Page
if page <= 0 {
page = 1
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
// 2. 查询注册记录(用于获取类型和展示状态)
var registries []*models.AssetRegistry
if s.registryRepo != nil {
regs, err := s.registryRepo.GetByOwner(userID, starID)
if err != nil {
logger.Logger.Error("Failed to get registries",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err),
)
// 继续执行,使用空注册列表
registries = []*models.AssetRegistry{}
} else {
registries = regs
}
} else {
registries = []*models.AssetRegistry{}
}
// 3. 创建 assetID -> registry 映射
registryMap := make(map[int64]*models.AssetRegistry)
for _, reg := range registries {
registryMap[reg.AssetID] = reg
}
// 4. 查询资产列表(不分页,获取所有资产用于分组)
allAssets, err := s.assetRepo.GetByOwner(userID, starID, 1000, 0) // 最多获取1000个
if err != nil {
logger.Logger.Error("Failed to get assets",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get assets: %w", err)
}
// 5. 按 type 分组
typeGroups := make(map[string][]*models.AssetRegistry)
for _, reg := range registries {
typeGroups[reg.AssetType] = append(typeGroups[reg.AssetType], reg)
}
// 6. 构建响应分组
groups := make([]*pb.AssetGroup, 0)
// 处理原创藏品 (regular)
if regs, ok := typeGroups[models.AssetTypeRegular]; ok {
group := s.buildRegularGroupForAssets(allAssets, registryMap, regs)
if group != nil {
groups = append(groups, group)
}
}
// 处理典藏藏品 (collection)
if regs, ok := typeGroups[models.AssetTypeCollection]; ok {
group := s.buildCollectionGroupForAssets(allAssets, registryMap, regs)
if group != nil {
groups = append(groups, group)
}
}
// 处理活动藏品 (activity)
if regs, ok := typeGroups[models.AssetTypeActivity]; ok {
group := s.buildActivityGroupForAssets(allAssets, registryMap, regs)
if group != nil {
groups = append(groups, group)
}
}
// 7. 查询总数
total, err := s.assetRepo.CountByOwner(userID, starID)
if err != nil {
logger.Logger.Error("Failed to count assets",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to count assets: %w", err)
}
hasMore := (page * pageSize) < int32(total)
// 8. 构建响应
response := &pb.GetMyAssetsResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
Data: &pb.AssetListData{
Groups: groups,
Total: total,
Page: page,
PageSize: pageSize,
HasMore: hasMore,
},
}
logger.Logger.Debug("Get my assets successful",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Int("asset_count", len(allAssets)),
zap.Int64("total", total),
)
return response, nil
}
// GetAssetsByType 按类型/分类/等级过滤查询星册藏品。
//
// 复用 GetMyAssets 的分组骨架(注册表 + 资产联合分组),在此基础上叠加请求侧过滤:
// - req.Type 非空时只保留该类型(regular/collection/activity)
// - req.Grade >0 时按 registry.grade 过滤(仅 regular 类型有效,其它类型 grade 恒为 nil 会被过滤空)
// - req.Category 非空时按逻辑分类过滤:
// * regular -> 恒为 "castlove"
// * collection -> registry.collection_category
// * activity -> registry.activity_type
//
// 返回结构与 GetMyAssets 一致(AssetListData 分组),便于 gateway 复用旧 starbook 契约。
func (s *assetService) GetAssetsByType(req *pb.GetAssetsByTypeRequest, userID, starID int64) (*pb.GetAssetsByTypeResponse, error) {
// 1. 参数验证(身份由 provider 从 ctx 注入,这里做防御性校验)
if !validator.ValidateUserID(userID) {
logger.Logger.Warn("GetAssetsByType invalid user_id", zap.Int64("user_id", userID))
return nil, appErrors.ErrInvalidUserID
}
if !validator.ValidateStarID(starID) {
logger.Logger.Warn("GetAssetsByType invalid star_id", zap.Int64("star_id", starID))
return nil, appErrors.ErrInvalidStarID
}
page := req.Page
if page <= 0 {
page = 1
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
// 2. 查询注册记录 + 资产列表
var registries []*models.AssetRegistry
if s.registryRepo != nil {
regs, err := s.registryRepo.GetByOwner(userID, starID)
if err != nil {
logger.Logger.Error("GetAssetsByType failed to get registries",
zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err))
registries = []*models.AssetRegistry{}
} else {
registries = regs
}
} else {
registries = []*models.AssetRegistry{}
}
allAssets, err := s.assetRepo.GetByOwner(userID, starID, 1000, 0)
if err != nil {
logger.Logger.Error("GetAssetsByType failed to get assets",
zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err))
return nil, fmt.Errorf("failed to get assets: %w", err)
}
registryMap := make(map[int64]*models.AssetRegistry)
for _, reg := range registries {
registryMap[reg.AssetID] = reg
}
// 3. 请求侧过滤
filtered := make([]*models.AssetRegistry, 0, len(registries))
for _, reg := range registries {
if req.Type != "" && reg.AssetType != req.Type {
continue
}
if req.Grade > 0 {
// grade 仅 regular 类型有效;非 regular 的 reg.Grade 为 nil,直接排除
if reg.Grade == nil || *reg.Grade != req.Grade {
continue
}
}
if req.Category != "" && registryLogicalCategory(reg) != req.Category {
continue
}
filtered = append(filtered, reg)
}
// 4. 按 type 分组并复用既有分组构建器
typeGroups := make(map[string][]*models.AssetRegistry)
for _, reg := range filtered {
typeGroups[reg.AssetType] = append(typeGroups[reg.AssetType], reg)
}
groups := make([]*pb.AssetGroup, 0)
if regs, ok := typeGroups[models.AssetTypeRegular]; ok {
if group := s.buildRegularGroupForAssets(allAssets, registryMap, regs); group != nil {
groups = append(groups, group)
}
}
if regs, ok := typeGroups[models.AssetTypeCollection]; ok {
if group := s.buildCollectionGroupForAssets(allAssets, registryMap, regs); group != nil {
groups = append(groups, group)
}
}
if regs, ok := typeGroups[models.AssetTypeActivity]; ok {
if group := s.buildActivityGroupForAssets(allAssets, registryMap, regs); group != nil {
groups = append(groups, group)
}
}
total := int64(len(filtered))
response := &pb.GetAssetsByTypeResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
Data: &pb.AssetListData{
Groups: groups,
Total: total,
Page: page,
PageSize: pageSize,
HasMore: false, // 分组返回全部,不做二次分页(与 GetMyAssets 一致)
},
}
logger.Logger.Debug("GetAssetsByType successful",
zap.Int64("user_id", userID), zap.Int64("star_id", starID),
zap.String("type", req.Type), zap.String("category", req.Category),
zap.Int32("grade", req.Grade), zap.Int64("total", total))
return response, nil
}
// registryLogicalCategory 返回 registry 对应的逻辑分类值,与 AssetItem.Category 的填充口径一致:
// - regular -> "castlove"(原创统一归类)
// - collection -> collection_category
// - activity -> activity_type
func registryLogicalCategory(reg *models.AssetRegistry) string {
switch reg.AssetType {
case models.AssetTypeRegular:
return "castlove"
case models.AssetTypeCollection:
if reg.CollectionCategory != nil {
return *reg.CollectionCategory
}
case models.AssetTypeActivity:
if reg.ActivityType != nil {
return *reg.ActivityType
}
}
return ""
}
// buildRegularGroupForAssets 构建原创藏品分组
func (s *assetService) buildRegularGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry) *pb.AssetGroup {
// 创建 assetID -> asset 映射
assetMap := make(map[int64]*models.Asset)
for _, asset := range allAssets {
assetMap[asset.ID] = asset
}
// 按 grade 分组
gradeGroups := make(map[int32][]*models.AssetRegistry)
for _, reg := range registries {
if reg.Grade != nil {
gradeGroups[*reg.Grade] = append(gradeGroups[*reg.Grade], reg)
}
}
// 构建 GradeSection
grades := make([]*pb.GradeSection, 0)
for grade, regs := range gradeGroups {
// 构建 items返回所有不限制数量
items := make([]*pb.AssetItem, 0)
for _, reg := range regs {
asset := assetMap[reg.AssetID]
if asset == nil {
continue
}
item := &pb.AssetItem{
AssetId: asset.ID,
Name: asset.Name,
CoverUrlSigned: asset.CoverURL,
LikeCount: asset.LikeCount,
CreatedAt: asset.CreatedAt,
Category: "castlove",
Grade: grade,
DisplayStatus: reg.DisplayStatus,
}
items = append(items, item)
}
gradeSection := &pb.GradeSection{
Grade: grade,
Items: items,
TotalCount: int32(len(regs)),
HasMore: false, // 返回所有,不限制
}
grades = append(grades, gradeSection)
}
// 按 grade 降序排序
sort.Slice(grades, func(i, j int) bool {
return grades[i].Grade > grades[j].Grade
})
// 计算 total_count
totalCount := int32(0)
for _, g := range grades {
totalCount += g.TotalCount
}
return &pb.AssetGroup{
Type: models.AssetTypeRegular,
Category: "castlove",
CategoryName: "原创",
Grades: grades,
TotalCount: totalCount,
HasMore: false,
}
}
// buildCollectionGroupForAssets 构建典藏藏品分组
func (s *assetService) buildCollectionGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry) *pb.AssetGroup {
// 创建 assetID -> asset 映射
assetMap := make(map[int64]*models.Asset)
for _, asset := range allAssets {
assetMap[asset.ID] = asset
}
// 构建 items
items := make([]*pb.AssetItem, 0)
for _, reg := range registries {
asset := assetMap[reg.AssetID]
if asset == nil {
continue
}
category := ""
if reg.CollectionCategory != nil {
category = *reg.CollectionCategory
}
item := &pb.AssetItem{
AssetId: asset.ID,
Name: asset.Name,
CoverUrlSigned: asset.CoverURL,
LikeCount: asset.LikeCount,
CreatedAt: asset.CreatedAt,
Category: category,
Grade: 0,
DisplayStatus: reg.DisplayStatus,
}
items = append(items, item)
}
return &pb.AssetGroup{
Type: models.AssetTypeCollection,
Category: "",
CategoryName: "典藏",
Items: items,
TotalCount: int32(len(registries)),
HasMore: false,
}
}
// buildActivityGroupForAssets 构建活动藏品分组
func (s *assetService) buildActivityGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry) *pb.AssetGroup {
// 创建 assetID -> asset 映射
assetMap := make(map[int64]*models.Asset)
for _, asset := range allAssets {
assetMap[asset.ID] = asset
}
// 构建 items
items := make([]*pb.AssetItem, 0)
for _, reg := range registries {
asset := assetMap[reg.AssetID]
if asset == nil {
continue
}
activityType := ""
if reg.ActivityType != nil {
activityType = *reg.ActivityType
}
item := &pb.AssetItem{
AssetId: asset.ID,
Name: asset.Name,
CoverUrlSigned: asset.CoverURL,
LikeCount: asset.LikeCount,
CreatedAt: asset.CreatedAt,
Category: activityType,
Grade: 0,
DisplayStatus: reg.DisplayStatus,
}
items = append(items, item)
}
return &pb.AssetGroup{
Type: models.AssetTypeActivity,
Category: "",
CategoryName: "活动",
Items: items,
TotalCount: int32(len(registries)),
HasMore: false,
}
}
// GetAsset 获取资产详情
func (s *assetService) GetAsset(req *pb.GetAssetRequest, userID, starID int64) (*pb.GetAssetResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(userID) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", userID),
)
return nil, appErrors.ErrInvalidUserID
}
if !validator.ValidateStarID(starID) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrInvalidStarID
}
if req.AssetId <= 0 {
logger.Logger.Warn("Invalid asset_id",
zap.Int64("asset_id", req.AssetId),
)
return nil, fmt.Errorf("invalid asset_id: %d", req.AssetId)
}
// 2. 查询资产(验证所有权)
// 首先尝试通过所有者查询(验证是否为资产所有者)
asset, err := s.assetRepo.GetByIDAndOwner(req.AssetId, userID, starID)
if err != nil {
if errors.Is(err, appErrors.ErrAssetNotFound) {
// 所有者验证失败,检查资产是否正在展出中
// 如果正在展出,则允许访问(即使不是所有者)
isExhibiting, checkErr := s.assetRepo.IsExhibiting(req.AssetId)
if checkErr != nil {
logger.Logger.Warn("Failed to check exhibition status",
zap.Int64("asset_id", req.AssetId),
zap.Error(checkErr),
)
}
if isExhibiting {
// 资产正在展出中,通过 ID 查询(不验证所有权)
asset, err = s.assetRepo.GetByID(req.AssetId)
if err != nil {
if errors.Is(err, appErrors.ErrAssetNotFound) {
logger.Logger.Warn("Asset not found",
zap.Int64("asset_id", req.AssetId),
)
return nil, appErrors.ErrAssetNotFound
}
logger.Logger.Error("Failed to get asset by ID",
zap.Int64("asset_id", req.AssetId),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get asset: %w", err)
}
logger.Logger.Info("Allowing access to exhibited asset",
zap.Int64("asset_id", req.AssetId),
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
)
} else {
logger.Logger.Warn("Asset not found or access denied",
zap.Int64("asset_id", req.AssetId),
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrAssetAccessDenied
}
} else {
logger.Logger.Error("Failed to get asset",
zap.Int64("asset_id", req.AssetId),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get asset: %w", err)
}
}
// 3. 获取所有者的昵称和头像在该star下的nickname
var ownerNickname string
var ownerAvatar string
profile, err := s.userClient.GetFanProfile(context.Background(), asset.OwnerUID, asset.StarID)
if err != nil {
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),
)
// 获取失败时,使用 User{uid} 作为 fallback
ownerNickname = fmt.Sprintf("User%d", asset.OwnerUID)
} else {
ownerNickname = profile.Nickname
ownerAvatar = profile.AvatarUrl
}
// 4. 检查当前用户是否已点赞(需要获取当前展出中的 exhibition_id
exhibitionID, _ := s.assetRepo.GetExhibitingID(asset.ID)
isLiked := false
if exhibitionID > 0 {
isLiked, err = s.assetLikeRepo.Exists(asset.ID, userID, starID, exhibitionID)
if err != nil {
logger.Logger.Warn("Failed to check like status, will return is_liked as false",
zap.Int64("asset_id", asset.ID),
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err),
)
// 检查失败时,默认为未点赞
isLiked = false
}
}
// 5. 从 asset_registry 表获取 display_status
displayStatus, err := s.assetRepo.GetDisplayStatusByAssetID(asset.ID)
if err != nil {
logger.Logger.Warn("Failed to get display status, will return 0",
zap.Int64("asset_id", asset.ID),
zap.Error(err),
)
displayStatus = 0
}
// 6. 计算每小时收益(始终计算,基于当前点赞数)
hourlyEarnings := calculateHourlyEarnings(asset.LikeCount)
// 7. 计算当前展出收益和过期时间(仅展出中时有值)
earnings := int64(0)
exhibitionExpireAt := int64(0)
if displayStatus == 1 {
// 获取展出开始时间,用于计算展出时长
exhibitionStartTime, _ := s.assetRepo.GetExhibitionStartTime(asset.ID)
if exhibitionStartTime == 0 {
exhibitionStartTime = asset.CreatedAt // 兜底
}
// 获取展出过期时间(先获取,用于计算收益)
exhibitionExpireAt, _ = s.assetRepo.GetExhibitionExpireTime(asset.ID)
earnings = calculateRealtimeEarnings(asset.LikeCount, exhibitionStartTime, time.Now().UnixMilli(), exhibitionExpireAt)
}
// 6.5 从 asset_registry 表获取 grade
grade, err := s.assetRepo.GetGradeByAssetID(asset.ID)
if err != nil {
logger.Logger.Warn("Failed to get grade, will return 0",
zap.Int64("asset_id", asset.ID),
zap.Error(err),
)
grade = 0
}
// 7. 构建响应
response := &pb.GetAssetResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
Asset: ModelToProtoAssetDetail(asset, ownerNickname, ownerAvatar, isLiked, displayStatus, earnings, hourlyEarnings, exhibitionExpireAt, grade),
}
logger.Logger.Debug("Get asset successful",
zap.Int64("asset_id", req.AssetId),
zap.Int64("user_id", userID),
zap.String("owner_nickname", ownerNickname),
zap.Bool("is_liked", isLiked),
)
return response, nil
}
// GetAssetStatus 查询上链状态
func (s *assetService) GetAssetStatus(req *pb.GetAssetStatusRequest, userID, starID int64) (*pb.GetAssetStatusResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(userID) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", userID),
)
return nil, appErrors.ErrInvalidUserID
}
if !validator.ValidateStarID(starID) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrInvalidStarID
}
if req.AssetId <= 0 {
logger.Logger.Warn("Invalid asset_id",
zap.Int64("asset_id", req.AssetId),
)
return nil, fmt.Errorf("invalid asset_id: %d", req.AssetId)
}
// 2. 查询资产(验证所有权)
asset, err := s.assetRepo.GetByIDAndOwner(req.AssetId, userID, starID)
if err != nil {
if errors.Is(err, appErrors.ErrAssetNotFound) {
logger.Logger.Warn("Asset not found or access denied",
zap.Int64("asset_id", req.AssetId),
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrAssetAccessDenied
}
logger.Logger.Error("Failed to get asset",
zap.Int64("asset_id", req.AssetId),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get asset: %w", err)
}
// 3. 构建响应
response := &pb.GetAssetStatusResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
AssetId: asset.ID,
Status: getStatusString(asset.Status),
TxHash: getStringValue(asset.TxHash),
BlockNumber: getInt64Value(asset.BlockNumber),
MintedAt: getInt64Value(asset.MintedAt),
}
logger.Logger.Debug("Get asset status successful",
zap.Int64("asset_id", req.AssetId),
zap.Int32("status", asset.Status),
)
return response, nil
}
// GetAssetForRPC 获取资产信息内部RPC调用供Social Service使用
func (s *assetService) GetAssetForRPC(req *pb.GetAssetForRPCRequest) (*pb.GetAssetForRPCResponse, error) {
// 1. 参数验证
if req.AssetId <= 0 {
logger.Logger.Warn("Invalid asset_id in RPC call",
zap.Int64("asset_id", req.AssetId),
)
return &pb.GetAssetForRPCResponse{
Base: appErrors.BuildBaseResponse(fmt.Errorf("invalid asset_id")),
}, nil
}
// 2. 查询资产
asset, err := s.assetRepo.GetByID(req.AssetId)
if err != nil {
if errors.Is(err, appErrors.ErrAssetNotFound) {
logger.Logger.Warn("Asset not found in RPC call",
zap.Int64("asset_id", req.AssetId),
)
return &pb.GetAssetForRPCResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrAssetNotFound),
}, nil
}
logger.Logger.Error("Failed to get asset in RPC call",
zap.Int64("asset_id", req.AssetId),
zap.Error(err),
)
return &pb.GetAssetForRPCResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInternalServer),
}, nil
}
// 3. 构建响应
// 注意cover_url 暂不预签名,与现有 GetAsset / GetMyAssets 接口的 CoverUrl / CoverUrlSigned
// 处理方式保持一致(直接使用 asset.CoverURL未走 OSS 签名)。
response := &pb.GetAssetForRPCResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "",
Timestamp: time.Now().UnixMilli(),
},
AssetId: asset.ID,
OwnerUid: asset.OwnerUID,
StarId: asset.StarID,
Status: int32(asset.Status),
IsActive: asset.Status == 1,
Name: asset.Name,
CoverUrl: asset.CoverURL,
}
logger.Logger.Debug("GetAssetForRPC successful",
zap.Int64("asset_id", req.AssetId),
)
return response, nil
}
// GetAssetQrcode 生成分享二维码(spec § 3) — 委托给 ShareService
func (s *assetService) GetAssetQrcode(ctx context.Context, req *pb.GetAssetQrcodeRequest) (*pb.GetAssetQrcodeResponse, error) {
if s.shareService == nil {
logger.Logger.Error("GetAssetQrcode called but shareService is not wired",
zap.Int64("asset_id", req.AssetId),
)
return &pb.GetAssetQrcodeResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.Unavailable),
Message: "share service is not configured",
Timestamp: time.Now().UnixMilli(),
},
}, nil
}
return s.shareService.GetAssetQrcode(ctx, req)
}
// TrackShare 记录分享归因事件(spec § 3.5) — 委托给 ShareService
func (s *assetService) TrackShare(ctx context.Context, req *pb.TrackShareRequest) (*pb.TrackShareResponse, error) {
if s.shareService == nil {
logger.Logger.Error("TrackShare called but shareService is not wired",
zap.Int64("asset_id", req.AssetId),
)
return &pb.TrackShareResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.Unavailable),
Message: "share service is not configured",
Timestamp: time.Now().UnixMilli(),
},
}, nil
}
return s.shareService.TrackShare(ctx, req)
}
// ========== 辅助函数 ==========
// ModelToProtoAsset 将数据库模型转换为Proto格式AssetListItem
func ModelToProtoAsset(asset *models.Asset) *pb.AssetListItem {
if asset == nil {
return nil
}
return &pb.AssetListItem{
AssetId: asset.ID,
Name: asset.Name,
CoverUrl: asset.CoverURL,
Status: getStatusString(asset.Status),
TxHash: getStringValue(asset.TxHash),
CreatedAt: asset.CreatedAt,
MintedAt: getInt64Value(asset.MintedAt),
LikeCount: asset.LikeCount,
}
}
// ModelToProtoAssetDetail 将数据库模型转换为Proto格式Asset详情
func ModelToProtoAssetDetail(asset *models.Asset, ownerNickname string, ownerAvatar string, isLiked bool, displayStatus int32, earnings int64, hourlyEarnings float64, exhibitionExpireAt int64, grade int32) *pb.Asset {
if asset == nil {
return nil
}
// 构建持有者信息
var ownerInfo *pb.OwnerInfo
if ownerAvatar != "" || ownerNickname != "" {
ownerInfo = &pb.OwnerInfo{
UserId: asset.OwnerUID,
Nickname: ownerNickname,
Avatar: ownerAvatar,
}
}
return &pb.Asset{
AssetId: asset.ID,
OwnerUid: asset.OwnerUID,
StarId: asset.StarID,
Name: asset.Name,
CoverUrl: asset.CoverURL,
MaterialUrl: getStringValue(asset.MaterialURL),
Description: getStringValue(asset.Description),
Grade: grade,
Tags: []string(asset.Tags),
Visibility: asset.Visibility,
Status: asset.Status,
TxHash: getStringValue(asset.TxHash),
BlockNumber: getInt64Value(asset.BlockNumber),
LikeCount: asset.LikeCount,
CreatedAt: asset.CreatedAt,
UpdatedAt: asset.UpdatedAt,
MintedAt: getInt64Value(asset.MintedAt),
Owner: ownerInfo,
OwnerNickname: ownerNickname,
IsLiked: isLiked,
Info: asset.Info,
DisplayStatus: displayStatus,
Earnings: earnings,
HourlyEarnings: hourlyEarnings,
ExhibitionExpireAt: exhibitionExpireAt,
}
}
// getStringValue 获取字符串指针的值
func getStringValue(ptr *string) string {
if ptr == nil {
return ""
}
return *ptr
}
// getInt32Value 获取int32指针的值
func getInt32Value(ptr *int32) int32 {
if ptr == nil {
return 0
}
return *ptr
}
// getInt64Value 获取int64指针的值
func getInt64Value(ptr *int64) int64 {
if ptr == nil {
return 0
}
return *ptr
}
// getStatusString 将状态码转换为字符串
func getStatusString(status int32) string {
switch status {
case models.AssetStatusPending:
return "pending"
case models.AssetStatusActive:
return "minted"
default:
return "unknown"
}
}
// calculateHourlyEarnings 计算每小时收益
// 公式R0 × [100% + Buff(n)]
// R0 = 5 水晶/小时Buff(n) 根据点赞数计算
func calculateHourlyEarnings(likeCount int32) float64 {
R0 := float64(5) // 水晶/小时
// 计算Buff
var buff int
switch {
case likeCount >= 30:
buff = 30
case likeCount >= 10:
buff = 20
case likeCount >= 5:
buff = 10
default:
buff = 0
}
// 应用Buff加成R1 = R0 × (100% + Buff)
return R0 * (100 + float64(buff)) / 100
}
// calculateRealtimeEarnings 实时计算展示收益
// 公式R1 = R0 × T × [100% + Buff(n)]
// R0 = 5 水晶/小时T = 上架时长小时Buff(n) 根据点赞数计算
// 注意:使用 min(now, expireAt) 确保过期后收益不再增长
func calculateRealtimeEarnings(likeCount int32, startTime, now, expireAt int64) int64 {
// 计算有效截止时间(展览结束时间 vs 当前时间,取较小值)
endTime := now
if expireAt > 0 && expireAt < now {
endTime = expireAt
}
// 计算上架时长(毫秒转小时)
T := (endTime - startTime) / 3600000
if T <= 0 {
T = 1 // 最少1小时
}
// 总收益 = 每小时收益 × 时长转int64取整
return int64(calculateHourlyEarnings(likeCount) * float64(T))
}