feat:修复点赞收益的问题

This commit is contained in:
zerosaturation 2026-07-01 11:03:50 +08:00
parent 3006726679
commit 9c3fe6d695
8 changed files with 109 additions and 62 deletions

View File

@ -2568,6 +2568,7 @@ type LikedAssetItem struct {
ExpireAt int64 `protobuf:"varint,9,opt,name=expire_at,json=expireAt,proto3" json:"expire_at,omitempty"` // 展示结束时间(毫秒时间戳)
StatusText string `protobuf:"bytes,10,opt,name=status_text,json=statusText,proto3" json:"status_text,omitempty"` // 动态状态标签(默认「潜力待挖」)
LikeBetRevenueId int64 `protobuf:"varint,11,opt,name=like_bet_revenue_id,json=likeBetRevenueId,proto3" json:"like_bet_revenue_id,omitempty"` // 关联的点赞押注收益记录ID0=无前端领取时直接传此ID
LikeBetStatus string `protobuf:"bytes,12,opt,name=like_bet_status,json=likeBetStatus,proto3" json:"like_bet_status,omitempty"` // 点赞押注收益状态claimable=可领取 / claimed=已领取 / ""=无记录
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -2679,6 +2680,13 @@ func (x *LikedAssetItem) GetLikeBetRevenueId() int64 {
return 0
}
func (x *LikedAssetItem) GetLikeBetStatus() string {
if x != nil {
return x.LikeBetStatus
}
return ""
}
// 获取我今日点赞的作品列表请求(暂不实现)
type GetMyTodayLikedAssetsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -3201,7 +3209,7 @@ const file_social_proto_rawDesc = "" +
"\x04page\x18\x02 \x01(\x05R\x04page\x12\x1b\n" +
"\tpage_size\x18\x03 \x01(\x05R\bpageSize\x12\x14\n" +
"\x05total\x18\x04 \x01(\x03R\x05total\x12\x19\n" +
"\bhas_more\x18\x05 \x01(\bR\ahasMore\"\xed\x02\n" +
"\bhas_more\x18\x05 \x01(\bR\ahasMore\"\x95\x03\n" +
"\x0eLikedAssetItem\x12\x19\n" +
"\basset_id\x18\x01 \x01(\x03R\aassetId\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" +
@ -3216,7 +3224,8 @@ const file_social_proto_rawDesc = "" +
"\vstatus_text\x18\n" +
" \x01(\tR\n" +
"statusText\x12-\n" +
"\x13like_bet_revenue_id\x18\v \x01(\x03R\x10likeBetRevenueId\"O\n" +
"\x13like_bet_revenue_id\x18\v \x01(\x03R\x10likeBetRevenueId\x12&\n" +
"\x0flike_bet_status\x18\f \x01(\tR\rlikeBetStatus\"O\n" +
"\x1cGetMyTodayLikedAssetsRequest\x12\x12\n" +
"\x04page\x18\x01 \x01(\x05R\x04page\x12\x1b\n" +
"\tpage_size\x18\x02 \x01(\x05R\bpageSize\"\x86\x01\n" +

View File

@ -322,6 +322,7 @@ message LikedAssetItem {
int64 expire_at = 9; //
string status_text = 10; //
int64 like_bet_revenue_id = 11; // ID0=ID
string like_bet_status = 12; // claimable= / claimed= / ""=
}
//

View File

@ -188,9 +188,10 @@ type LikedAssetInfo struct {
IsLenticular bool // 是否为光栅卡
ExpireAt int64 // 展出过期时间
StatusText string // 动态状态标签
LikeBetRevenueID int64 // 关联的点赞押注收益记录ID0=无可领取收益)
ExhibitionID int64 // 所属展览ID用于计算押注收益
CrystalAmount int64 // 从 like_bet_revenue_records 读取的已结算金额0=未结算)
LikeBetRevenueID int64 // 关联的点赞押注收益记录ID0=无可领取收益)
ExhibitionID int64 // 所属展览ID用于计算押注收益
CrystalAmount int64 // 从 like_bet_revenue_records 读取的已结算金额0=未结算)
LikeBetStatus string // like_bet_revenue_records 状态claimable / claimed / ""
}
// RandomUserInfo 随机用户信息
@ -633,36 +634,42 @@ func (r *socialRepositoryImpl) GetMyLikedAssets(userID, starID int64, page, page
orderClause = "asset_likes.created_at DESC"
}
// 计数查询(使用 DISTINCT 因为一个资产可能在多个展位展出)
// 显示展品未下架deleted_at IS NULL无论是否过期
// 不显示已领取下架的deleted_at IS NOT NULL或从未展出过的
// 使用 asset_likes.exhibition_id 关联,确保只返回有效展览的点赞记录
// 计数查询:返回两类资产
// 1. 展览未下架deleted_at IS NULL且 LBR 未被用户领取status != 'claimed'
// 2. 有 claimable 点赞押注收益 — 即使展览已被藏品主人领取收益后软删除
// 关键修复:使用 lbr.like_id = asset_likes.id 关联,而非 exhibition_id + user_id + asset_id。
// 因为藏品重新展出后 asset_likes.exhibition_id 仍是旧展览ID但 lbr 是新展览的记录。
countQuery := r.db.Model(&models.AssetLike{}).
Joins("JOIN assets a ON a.id = asset_likes.asset_id").
Joins("JOIN exhibitions e ON e.id = asset_likes.exhibition_id AND e.deleted_at IS NULL").
Joins("JOIN exhibitions e ON e.id = asset_likes.exhibition_id").
Joins("LEFT JOIN like_bet_revenue_records lbr ON lbr.like_id = asset_likes.id").
Where("asset_likes.user_id = ? AND asset_likes.star_id = ?", userID, starID).
Where("a.deleted_at IS NULL AND a.is_active = ?", true)
Where("a.deleted_at IS NULL AND a.is_active = ?", true).
Where("(e.deleted_at IS NULL AND COALESCE(lbr.status, '') != 'claimed') OR lbr.status = 'claimable'")
if err := countQuery.Distinct("asset_likes.asset_id").Count(&total).Error; err != nil {
return nil, 0, err
}
// 数据查询:只过滤已下架的,不过滤过期(用户领取收益后才下架)
// 使用 asset_likes.exhibition_id 关联,确保只返回当前有效展览的点赞记录
// LEFT JOIN like_bet_revenue_records 查询可领取的收益记录ID
// 数据查询LEFT JOIN 用 lbr.like_id = asset_likes.id 精准关联
// like_bet_revenue_id: 只返回 claimable 状态的 ID前端领取用0=不可领取)
// like_bet_crystal_amount: 只返回 claimable 状态的金额
// like_bet_status: 返回实际状态claimable/claimed/空)
offset := (page - 1) * pageSize
err := r.db.Model(&models.AssetLike{}).
Select(`asset_likes.asset_id, a.name, a.cover_url, a.like_count,
asset_likes.created_at as liked_at,
(a.tags @> '["craft:lenticular"]') as is_lenticular,
e.expire_at, e.id as exhibition_id,
COALESCE(MAX(lbr.id), 0) as like_bet_revenue_id,
COALESCE(MAX(lbr.crystal_amount), 0) as like_bet_crystal_amount`).
COALESCE(MAX(CASE WHEN lbr.status = 'claimable' THEN lbr.id END), 0) as like_bet_revenue_id,
COALESCE(MAX(CASE WHEN lbr.status = 'claimable' THEN lbr.crystal_amount END), 0) as like_bet_crystal_amount,
COALESCE(MAX(lbr.status), '') as like_bet_status`).
Joins("JOIN assets a ON a.id = asset_likes.asset_id").
Joins("JOIN exhibitions e ON e.id = asset_likes.exhibition_id AND e.deleted_at IS NULL").
Joins("LEFT JOIN like_bet_revenue_records lbr ON lbr.exhibition_id = e.id AND lbr.user_id = asset_likes.user_id AND lbr.asset_id = asset_likes.asset_id AND lbr.status = 'claimable'").
Joins("JOIN exhibitions e ON e.id = asset_likes.exhibition_id").
Joins("LEFT JOIN like_bet_revenue_records lbr ON lbr.like_id = asset_likes.id").
Where("asset_likes.user_id = ? AND asset_likes.star_id = ?", userID, starID).
Where("a.deleted_at IS NULL AND a.is_active = ?", true).
Where("(e.deleted_at IS NULL AND COALESCE(lbr.status, '') != 'claimed') OR lbr.status = 'claimable'").
Group("asset_likes.asset_id, a.name, a.cover_url, a.like_count, asset_likes.created_at, is_lenticular, e.expire_at, e.id").
Order(orderClause).
Limit(pageSize).
@ -742,16 +749,19 @@ func fillLikeBetEarnings(db *gorm.DB, userID, starID int64, items []*LikedAssetI
var betRows []betRow
// 用窗口函数一次查出所有 user 在每个 exhibition 下的 bet_order 和 total_likes
// 注意:窗口函数必须在全量数据上计算(不按 user_id 过滤),否则 total_likes 和 bet_order
// 都只反映当前用户自己的点赞数,导致 earnings 恒为 1。
// user_id / star_id 过滤移到外层子查询之后。
err := db.Raw(`
SELECT asset_id, bet_order, total_likes FROM (
SELECT al.asset_id,
SELECT al.asset_id, al.user_id, al.star_id,
ROW_NUMBER() OVER (PARTITION BY al.exhibition_id ORDER BY al.created_at ASC, al.id ASC)::int as bet_order,
COUNT(*) OVER (PARTITION BY al.exhibition_id)::int as total_likes
FROM asset_likes al
WHERE al.user_id = ? AND al.star_id = ?
AND al.exhibition_id IN ?
WHERE al.exhibition_id IN ?
) sub
`, userID, starID, exhibitionIDs).Scan(&betRows).Error
WHERE sub.user_id = ? AND sub.star_id = ?
`, exhibitionIDs, userID, starID).Scan(&betRows).Error
if err != nil {
return err
}
@ -843,11 +853,12 @@ func (r *socialRepositoryImpl) GetMyTodayLikedAssets(userID, starID int64, page,
Select(`asset_likes.asset_id, a.name, a.cover_url, a.like_count,
asset_likes.created_at as liked_at,
e.id as exhibition_id,
COALESCE(MAX(lbr.id), 0) as like_bet_revenue_id,
COALESCE(MAX(lbr.crystal_amount), 0) as like_bet_crystal_amount`).
COALESCE(MAX(CASE WHEN lbr.status = 'claimable' THEN lbr.id END), 0) as like_bet_revenue_id,
COALESCE(MAX(CASE WHEN lbr.status = 'claimable' THEN lbr.crystal_amount END), 0) as like_bet_crystal_amount,
COALESCE(MAX(lbr.status), '') as like_bet_status`).
Joins("JOIN assets a ON a.id = asset_likes.asset_id").
Joins("JOIN exhibitions e ON e.id = asset_likes.exhibition_id").
Joins("LEFT JOIN like_bet_revenue_records lbr ON lbr.exhibition_id = e.id AND lbr.user_id = asset_likes.user_id AND lbr.asset_id = asset_likes.asset_id AND lbr.status = 'claimable'").
Joins("LEFT JOIN like_bet_revenue_records lbr ON lbr.like_id = asset_likes.id").
Where("asset_likes.user_id = ? AND asset_likes.star_id = ?", userID, starID).
Where("asset_likes.created_at >= ?", startOfDay).
Where("a.deleted_at IS NULL AND a.is_active = ?", true).
@ -900,11 +911,12 @@ func (r *socialRepositoryImpl) GetMyWeekLikedAssets(userID, starID int64, page,
Select(`asset_likes.asset_id, a.name, a.cover_url, a.like_count,
asset_likes.created_at as liked_at,
e.id as exhibition_id,
COALESCE(MAX(lbr.id), 0) as like_bet_revenue_id,
COALESCE(MAX(lbr.crystal_amount), 0) as like_bet_crystal_amount`).
COALESCE(MAX(CASE WHEN lbr.status = 'claimable' THEN lbr.id END), 0) as like_bet_revenue_id,
COALESCE(MAX(CASE WHEN lbr.status = 'claimable' THEN lbr.crystal_amount END), 0) as like_bet_crystal_amount,
COALESCE(MAX(lbr.status), '') as like_bet_status`).
Joins("JOIN assets a ON a.id = asset_likes.asset_id").
Joins("JOIN exhibitions e ON e.id = asset_likes.exhibition_id").
Joins("LEFT JOIN like_bet_revenue_records lbr ON lbr.exhibition_id = e.id AND lbr.user_id = asset_likes.user_id AND lbr.asset_id = asset_likes.asset_id AND lbr.status = 'claimable'").
Joins("LEFT JOIN like_bet_revenue_records lbr ON lbr.like_id = asset_likes.id").
Where("asset_likes.user_id = ? AND asset_likes.star_id = ?", userID, starID).
Where("asset_likes.created_at >= ?", startOfWeekMillis).
Where("a.deleted_at IS NULL AND a.is_active = ?", true).
@ -925,7 +937,8 @@ func (r *socialRepositoryImpl) GetMyWeekLikedAssets(userID, starID int64, page,
return items, total, nil
}
// GetUserLikedAssets 获取他人点赞的作品列表(只返回展出中且未过期的)
// GetUserLikedAssets 获取他人点赞的作品列表
// 同 GetMyLikedAssets活跃展览且 LBR 未被领取)+ 有 claimable 点赞押注收益的展览
func (r *socialRepositoryImpl) GetUserLikedAssets(userID, starID int64, page, pageSize int) ([]*LikedAssetInfo, int64, error) {
var items []*LikedAssetInfo
var total int64
@ -935,9 +948,10 @@ func (r *socialRepositoryImpl) GetUserLikedAssets(userID, starID int64, page, pa
countQuery := r.db.Model(&models.AssetLike{}).
Joins("JOIN assets a ON a.id = asset_likes.asset_id").
Joins("JOIN exhibitions e ON e.id = asset_likes.exhibition_id").
Joins("LEFT JOIN like_bet_revenue_records lbr ON lbr.like_id = asset_likes.id").
Where("asset_likes.user_id = ? AND asset_likes.star_id = ?", userID, starID).
Where("a.deleted_at IS NULL AND a.is_active = ?", true).
Where("e.deleted_at IS NULL AND e.expire_at > ?", now)
Where("(e.deleted_at IS NULL AND COALESCE(lbr.status, '') != 'claimed') OR lbr.status = 'claimable'")
if err := countQuery.Distinct("asset_likes.asset_id").Count(&total).Error; err != nil {
return nil, 0, err
@ -946,13 +960,18 @@ func (r *socialRepositoryImpl) GetUserLikedAssets(userID, starID int64, page, pa
offset := (page - 1) * pageSize
err := r.db.Model(&models.AssetLike{}).
Select(`asset_likes.asset_id, a.name, a.cover_url, a.like_count,
asset_likes.created_at as liked_at`).
asset_likes.created_at as liked_at,
e.expire_at, e.id as exhibition_id,
COALESCE(MAX(CASE WHEN lbr.status = 'claimable' THEN lbr.id END), 0) as like_bet_revenue_id,
COALESCE(MAX(CASE WHEN lbr.status = 'claimable' THEN lbr.crystal_amount END), 0) as like_bet_crystal_amount,
COALESCE(MAX(lbr.status), '') as like_bet_status`).
Joins("JOIN assets a ON a.id = asset_likes.asset_id").
Joins("JOIN exhibitions e ON e.id = asset_likes.exhibition_id").
Joins("LEFT JOIN like_bet_revenue_records lbr ON lbr.like_id = asset_likes.id").
Where("asset_likes.user_id = ? AND asset_likes.star_id = ?", userID, starID).
Where("a.deleted_at IS NULL AND a.is_active = ?", true).
Where("e.deleted_at IS NULL AND e.expire_at > ?", now).
Group("asset_likes.asset_id, a.name, a.cover_url, a.like_count, asset_likes.created_at").
Where("(e.deleted_at IS NULL AND COALESCE(lbr.status, '') != 'claimed') OR lbr.status = 'claimable'").
Group("asset_likes.asset_id, a.name, a.cover_url, a.like_count, asset_likes.created_at, e.expire_at, e.id").
Order("asset_likes.created_at DESC").
Limit(pageSize).
Offset(offset).
@ -962,8 +981,14 @@ func (r *socialRepositoryImpl) GetUserLikedAssets(userID, starID int64, page, pa
return nil, 0, err
}
// 实时计算每个资产的收益
// 1. 先填充点赞押注收益LBR 已结算的 + 可计算的)
_ = fillLikeBetEarnings(r.db, userID, starID, items)
// 2. 对于活跃展览(无 LBR 结算),实时计算展示收益
for _, item := range items {
if item.Earnings > 0 {
continue // 已由 fillLikeBetEarnings 处理
}
var exhibition models.Exhibition
if err := r.db.Where("asset_id = ? AND deleted_at IS NULL AND expire_at > ?", item.AssetID, now).
First(&exhibition).Error; err == nil {

View File

@ -350,6 +350,7 @@ func (s *AssetLikeService) GetMyLikedAssets(ctx context.Context, req *pb.GetMyLi
}
items, total, err := s.socialRepo.GetMyLikedAssets(userID, starID, int(page), int(pageSize), req.OrderBy)
logger.Logger.Info("DEBUG GetMyLikedAssets", zap.Int("items_count", len(items)), zap.Int64("total", total), zap.Error(err))
if err != nil {
logger.Logger.Error("Failed to get my liked assets",
zap.Error(err),
@ -395,6 +396,7 @@ func (s *AssetLikeService) GetMyLikedAssets(ctx context.Context, req *pb.GetMyLi
pbItems = append(pbItems, &pb.LikedAssetItem{
AssetId: item.AssetID,
LikeBetRevenueId: item.LikeBetRevenueID,
LikeBetStatus: item.LikeBetStatus,
Name: item.Name,
CoverUrl: item.CoverURL,
LikeCount: item.LikeCount,
@ -500,6 +502,7 @@ func (s *AssetLikeService) GetMyTodayLikedAssets(ctx context.Context, req *pb.Ge
pbItems = append(pbItems, &pb.LikedAssetItem{
AssetId: item.AssetID,
LikeBetRevenueId: item.LikeBetRevenueID,
LikeBetStatus: item.LikeBetStatus,
Name: item.Name,
CoverUrl: item.CoverURL,
LikeCount: item.LikeCount,
@ -562,6 +565,7 @@ func (s *AssetLikeService) GetMyWeekLikedAssets(ctx context.Context, req *pb.Get
pbItems = append(pbItems, &pb.LikedAssetItem{
AssetId: item.AssetID,
LikeBetRevenueId: item.LikeBetRevenueID,
LikeBetStatus: item.LikeBetStatus,
Name: item.Name,
CoverUrl: item.CoverURL,
LikeCount: item.LikeCount,
@ -624,6 +628,7 @@ func (s *AssetLikeService) GetUserLikedAssets(ctx context.Context, req *pb.GetUs
pbItems = append(pbItems, &pb.LikedAssetItem{
AssetId: item.AssetID,
LikeBetRevenueId: item.LikeBetRevenueID,
LikeBetStatus: item.LikeBetStatus,
Name: item.Name,
CoverUrl: item.CoverURL,
LikeCount: item.LikeCount,

View File

@ -1119,7 +1119,7 @@ onUnmounted(() => {
.card-wrapper {
position: relative;
width: 352rpx;
height: 520rpx;
height: 552rpx;
margin-bottom: 32rpx;
animation: card-3d-flip 15s ease-in-out infinite;
transform-origin: center center;

View File

@ -181,7 +181,7 @@
</image>
<text class="reward-amount">{{ item.reward }}</text>
</view>
<text v-if="likedCountdowns[item.id]?.expired" class="reward-text" @tap.stop="handleClaimReward(item, 0, 'like-bet')">领取收益</text>
<text v-if="likedCountdowns[item.id]?.expired && item.revenue_id" class="reward-text" @tap.stop="handleClaimReward(item, 0, 'like-bet')">领取收益</text>
</view>
<view class="liked-reward" v-else>
<image class="reward-token-icon" mode="aspectFit" src="/static/assetDetail/time.png">

View File

@ -1,5 +1,10 @@
<template>
<view class="podium-card" :class="['podium-' + rank]" @click="handleClick">
<view
v-if="item.cover_url || item.cover_image"
class="podium-card"
:class="['podium-' + rank]"
@click="handleClick"
>
<!-- 藏品主图不规则圆角 -->
<view class="cover-wrap">
<!-- 相框最底层 -->
@ -9,6 +14,7 @@
mode="aspectFit"
/>
<image
class="cover-image"
:src="item.cover_url || item.cover_image || ''"
mode="aspectFill"

View File

@ -19,31 +19,32 @@
</svg> -->
<!-- 9 个散落 item -->
<view
v-for="(p, i) in positions"
:key="p.rank"
class="ring-item"
:class="'r' + (p.rank - 4)"
:style="ringItemStyle(p)"
@click="handleClick(items[i])"
>
<!-- 相框中间层 -->
<view class="top-label">{{ formatLabel(p.rank) }}</view>
<image class="ring-frame" :class="{ [`ring-frame-${i}`]: true }" :src="ringFrameSrc(p.rank)" mode="aspectFit" />
<image
class="cover-image"
:class="{ [`cover-image-${i}`]: true }"
:src="items[i]?.cover_url || items[i]?.cover_image || ''"
mode="aspectFill"
/>
<!-- 底座最底层 -->
<image
v-if="baseSrc(p.rank)"
class="base-image"
:src="baseSrc(p.rank)"
mode="aspectFit"
/>
</view>
<template v-for="(p, i) in positions" :key="p.rank">
<view
v-if="items[i]?.cover_url || items[i]?.cover_image"
class="ring-item"
:class="'r' + (p.rank - 4)"
:style="ringItemStyle(p)"
@click="handleClick(items[i])"
>
<!-- 相框中间层 -->
<view class="top-label">{{ formatLabel(p.rank) }}</view>
<image class="ring-frame" :class="{ [`ring-frame-${i}`]: true }" :src="ringFrameSrc(p.rank)" mode="aspectFit" />
<image
class="cover-image"
:class="{ [`cover-image-${i}`]: true }"
:src="items[i]?.cover_url || items[i]?.cover_image || ''"
mode="aspectFill"
/>
<!-- 底座最底层 -->
<image
v-if="baseSrc(p.rank)"
class="base-image"
:src="baseSrc(p.rank)"
mode="aspectFit"
/>
</view>
</template>
</view>
</template>