- 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>
990 lines
29 KiB
Go
990 lines
29 KiB
Go
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))
|
||
}
|