diff --git a/backend/services/starbookService/repository/asset_registry_repository.go b/backend/services/starbookService/repository/asset_registry_repository.go
index 16c25eb..70dfd05 100644
--- a/backend/services/starbookService/repository/asset_registry_repository.go
+++ b/backend/services/starbookService/repository/asset_registry_repository.go
@@ -146,8 +146,10 @@ func (r *assetRegistryRepository) GetByOwner(ownerUID, starID int64) ([]*models.
return nil, errors.New("star_id must be greater than 0")
}
var registries []*models.AssetRegistry
- if err := r.db.Where("owner_uid = ? AND star_id = ?", ownerUID, starID).
- Order("created_at DESC").
+ if err := r.db.
+ Joins("JOIN assets ON assets.id = asset_registry.asset_id AND assets.deleted_at IS NULL").
+ Where("asset_registry.owner_uid = ? AND asset_registry.star_id = ?", ownerUID, starID).
+ Order("asset_registry.created_at DESC").
Find(®istries).Error; err != nil {
return nil, err
}
@@ -163,8 +165,10 @@ func (r *assetRegistryRepository) GetByOwnerAndType(ownerUID, starID int64, asse
return nil, errors.New("star_id must be greater than 0")
}
var registries []*models.AssetRegistry
- query := r.db.Where("owner_uid = ? AND star_id = ? AND asset_type = ?", ownerUID, starID, assetType).
- Order("created_at DESC")
+ query := r.db.
+ Joins("JOIN assets ON assets.id = asset_registry.asset_id AND assets.deleted_at IS NULL").
+ Where("asset_registry.owner_uid = ? AND asset_registry.star_id = ? AND asset_registry.asset_type = ?", ownerUID, starID, assetType).
+ Order("asset_registry.created_at DESC")
if limit > 0 {
query = query.Limit(limit)
}
@@ -186,8 +190,10 @@ func (r *assetRegistryRepository) GetByOwnerAndTypeAndGrade(ownerUID, starID int
return nil, errors.New("star_id must be greater than 0")
}
var registries []*models.AssetRegistry
- query := r.db.Where("owner_uid = ? AND star_id = ? AND asset_type = ? AND grade = ?", ownerUID, starID, assetType, grade).
- Order("created_at DESC")
+ query := r.db.
+ Joins("JOIN assets ON assets.id = asset_registry.asset_id AND assets.deleted_at IS NULL").
+ Where("asset_registry.owner_uid = ? AND asset_registry.star_id = ? AND asset_registry.asset_type = ? AND asset_registry.grade = ?", ownerUID, starID, assetType, grade).
+ Order("asset_registry.created_at DESC")
if limit > 0 {
query = query.Limit(limit)
}
@@ -209,8 +215,10 @@ func (r *assetRegistryRepository) GetByOwnerAndTypeAndCategory(ownerUID, starID
return nil, errors.New("star_id must be greater than 0")
}
var registries []*models.AssetRegistry
- query := r.db.Where("owner_uid = ? AND star_id = ? AND asset_type = ? AND collection_category = ?", ownerUID, starID, assetType, category).
- Order("created_at DESC")
+ query := r.db.
+ Joins("JOIN assets ON assets.id = asset_registry.asset_id AND assets.deleted_at IS NULL").
+ Where("asset_registry.owner_uid = ? AND asset_registry.star_id = ? AND asset_registry.asset_type = ? AND asset_registry.collection_category = ?", ownerUID, starID, assetType, category).
+ Order("asset_registry.created_at DESC")
if limit > 0 {
query = query.Limit(limit)
}
@@ -232,8 +240,10 @@ func (r *assetRegistryRepository) GetByOwnerAndTypeAndActivity(ownerUID, starID
return nil, errors.New("star_id must be greater than 0")
}
var registries []*models.AssetRegistry
- query := r.db.Where("owner_uid = ? AND star_id = ? AND asset_type = ? AND activity_id = ?", ownerUID, starID, assetType, activityID).
- Order("created_at DESC")
+ query := r.db.
+ Joins("JOIN assets ON assets.id = asset_registry.asset_id AND assets.deleted_at IS NULL").
+ Where("asset_registry.owner_uid = ? AND asset_registry.star_id = ? AND asset_registry.asset_type = ? AND asset_registry.activity_id = ?", ownerUID, starID, assetType, activityID).
+ Order("asset_registry.created_at DESC")
if limit > 0 {
query = query.Limit(limit)
}
@@ -256,7 +266,8 @@ func (r *assetRegistryRepository) CountByOwner(ownerUID, starID int64) (int64, e
}
var count int64
if err := r.db.Model(&models.AssetRegistry{}).
- Where("owner_uid = ? AND star_id = ?", ownerUID, starID).
+ Joins("JOIN assets ON assets.id = asset_registry.asset_id AND assets.deleted_at IS NULL").
+ Where("asset_registry.owner_uid = ? AND asset_registry.star_id = ?", ownerUID, starID).
Count(&count).Error; err != nil {
return 0, err
}
@@ -273,7 +284,8 @@ func (r *assetRegistryRepository) CountByOwnerAndType(ownerUID, starID int64, as
}
var count int64
if err := r.db.Model(&models.AssetRegistry{}).
- Where("owner_uid = ? AND star_id = ? AND asset_type = ?", ownerUID, starID, assetType).
+ Joins("JOIN assets ON assets.id = asset_registry.asset_id AND assets.deleted_at IS NULL").
+ Where("asset_registry.owner_uid = ? AND asset_registry.star_id = ? AND asset_registry.asset_type = ?", ownerUID, starID, assetType).
Count(&count).Error; err != nil {
return 0, err
}
@@ -290,7 +302,8 @@ func (r *assetRegistryRepository) CountByOwnerAndTypeAndGrade(ownerUID, starID i
}
var count int64
if err := r.db.Model(&models.AssetRegistry{}).
- Where("owner_uid = ? AND star_id = ? AND asset_type = ? AND grade = ?", ownerUID, starID, assetType, grade).
+ Joins("JOIN assets ON assets.id = asset_registry.asset_id AND assets.deleted_at IS NULL").
+ Where("asset_registry.owner_uid = ? AND asset_registry.star_id = ? AND asset_registry.asset_type = ? AND asset_registry.grade = ?", ownerUID, starID, assetType, grade).
Count(&count).Error; err != nil {
return 0, err
}
@@ -307,7 +320,8 @@ func (r *assetRegistryRepository) CountByOwnerAndTypeAndCategory(ownerUID, starI
}
var count int64
if err := r.db.Model(&models.AssetRegistry{}).
- Where("owner_uid = ? AND star_id = ? AND asset_type = ? AND collection_category = ?", ownerUID, starID, assetType, category).
+ Joins("JOIN assets ON assets.id = asset_registry.asset_id AND assets.deleted_at IS NULL").
+ Where("asset_registry.owner_uid = ? AND asset_registry.star_id = ? AND asset_registry.asset_type = ? AND asset_registry.collection_category = ?", ownerUID, starID, assetType, category).
Count(&count).Error; err != nil {
return 0, err
}
@@ -324,7 +338,8 @@ func (r *assetRegistryRepository) CountByOwnerAndTypeAndActivity(ownerUID, starI
}
var count int64
if err := r.db.Model(&models.AssetRegistry{}).
- Where("owner_uid = ? AND star_id = ? AND asset_type = ? AND activity_id = ?", ownerUID, starID, assetType, activityID).
+ Joins("JOIN assets ON assets.id = asset_registry.asset_id AND assets.deleted_at IS NULL").
+ Where("asset_registry.owner_uid = ? AND asset_registry.star_id = ? AND asset_registry.asset_type = ? AND asset_registry.activity_id = ?", ownerUID, starID, assetType, activityID).
Count(&count).Error; err != nil {
return 0, err
}
diff --git a/backend/services/starbookService/service/starbook_service.go b/backend/services/starbookService/service/starbook_service.go
index 566c51f..065e6f0 100644
--- a/backend/services/starbookService/service/starbook_service.go
+++ b/backend/services/starbookService/service/starbook_service.go
@@ -433,19 +433,11 @@ func (s *starbookService) GetStarbookItems(req *pb.GetStarbookItemsRequest, owne
// 构建 items
items := s.buildAssetItemsFromRegistries(registries, assetType)
- // 过滤掉没有图片的藏品
- filteredItems := make([]*pb.AssetItem, 0, len(items))
- for _, item := range items {
- if item.CoverUrlSigned != "" {
- filteredItems = append(filteredItems, item)
- }
- }
-
hasMore := int64(page*pageSize) < totalCount
return &pb.GetStarbookItemsResponse{
Data: &pb.AssetListData{
- Items: filteredItems,
+ Items: items,
Total: totalCount,
Page: page,
PageSize: pageSize,
diff --git a/docs/superpowers/specs/2026-05-21-asset-likers-design.md b/docs/superpowers/specs/2026-05-21-asset-likers-design.md
new file mode 100644
index 0000000..1b0eddb
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-21-asset-likers-design.md
@@ -0,0 +1,357 @@
+# 查询藏品点赞用户列表接口设计
+
+## 1. 需求概述
+
+**接口名称**: `GET /api/v1/social/assets/:asset_id/likers`
+
+**功能描述**: 查询指定藏品的所有点赞用户列表
+
+**权限控制**: 仅登录用户可访问
+
+**返回内容**: 点赞用户的基本信息(昵称、头像、粉丝等级、点赞时间)
+
+---
+
+## 2. 现有代码分析
+
+### 2.1 相关数据模型
+
+**AssetLike 模型** (`pkg/models/asset.go`):
+```go
+type AssetLike struct {
+ ID int64 // 点赞记录ID
+ AssetID int64 // 资产ID
+ UserID int64 // 用户ID
+ StarID int64 // 明星ID
+ ExhibitionID int64 // 展览ID
+ CreatedAt int64 // 点赞时间
+}
+```
+
+**关联关系**:
+- `AssetLike.Asset` -> Asset
+- `AssetLike.User` -> User
+- `AssetLike.Star` -> Star
+
+### 2.2 现有 Repository 层
+
+`asset_like_repository.go` 已有的方法:
+- `GetByAsset(assetID, limit, offset)` - 分页获取点赞记录
+- `CountByAsset(assetID)` - 统计点赞数
+
+**问题**: 没有 JOIN 用户表,需要新增或扩展方法
+
+### 2.3 现有 Service 层
+
+`asset_like_service.go` 已有的方法:
+- `GetAssetLikes(ctx, assetID, page, pageSize)` - 分页获取点赞列表
+
+**问题**: 只返回 AssetLike 记录,没有返回用户信息
+
+### 2.4 现有 Gateway 层
+
+`social_controller.go` 已有点赞相关端点:
+- `POST /api/v1/social/assets/:asset_id/like` - 点赞
+- `DELETE /api/v1/social/assets/:asset_id/like` - 取消点赞
+
+**缺失**: 没有查询点赞用户的端点
+
+### 2.5 Proto 定义
+
+需要检查 `pkg/proto/social/` 是否有现成的消息定义
+
+---
+
+## 3. 架构设计
+
+### 3.1 整体架构
+
+```
+HTTP Request
+ │
+ ▼
+Gateway (social_controller.go)
+ │ GET /api/v1/social/assets/:asset_id/likers
+ │
+ ▼
+Dubbo RPC Call
+ │
+ ▼
+SocialService (Dubbo Provider)
+ │
+ ├──▶ Redis Cache (asset_likers:{asset_id})
+ │
+ └──▶ SocialRepository ──► asset_likes + users + fan_profiles JOIN
+```
+
+### 3.2 数据流
+
+1. Gateway 接收请求,验证登录态
+2. 通过 Dubbo 调用 SocialService
+3. SocialService 先查 Redis 缓存
+4. 缓存未命中 → 调用 SocialRepository 查询(直接访问 DB)
+5. 组装用户信息返回,写入 Redis 缓存
+
+---
+
+## 4. 性能优化设计
+
+### 4.1 分页设计(必须)
+
+**游标分页 vs OFFSET 分页**:
+
+| 方案 | 优点 | 缺点 | 适用场景 |
+|------|------|------|----------|
+| OFFSET | 简单,支持跳页 | 大 OFFSET 性能差 | 数据量小(<1万) |
+| 游标 | 大数据量性能稳定 | 不能跳页 | 数据量大(>1万) |
+
+**决策**:**采用游标分页**
+- 理由:藏品点赞列表通常不会很大,但游标分页更健壮
+- 实现:`cursor` =上一页最后一条的 `liked_at` 值
+
+### 4.2 索引优化
+
+**已有索引(无需新增)**:
+```sql
+-- asset_likes 表
+INDEX idx_asset_likes_asset (asset_id, created_at DESC) -- 覆盖查询+排序
+
+-- fan_profiles 表(已存在)
+UNIQUE INDEX uk_fan_profiles_user_star (user_id, star_id) -- 覆盖 JOIN
+```
+
+**JOIN 查询计划**:
+```
+asset_likes ──(idx_asset_likes_asset)──▶ 扫描 asset_id
+ │
+ └──▶ users ──(主键 id)──▶ avatar_url
+ └──▶ fan_profiles ──(uk_fan_profiles_user_star)──▶ nickname, level
+```
+**注意**:昵称 nickname 来自 fan_profiles 表,不是 users 表
+
+### 4.3 Redis 缓存策略(必须)
+
+#### 缓存 Key 设计
+```
+asset_likers:{asset_id} → 完整点赞用户列表(JSON)
+```
+
+**为什么不按 cursor 分页存储**:
+- 游标分页的 cursor 值是动态的(最后一条的 `liked_at`)
+- 如果缓存每一页的 cursor,key 会很多且难以管理
+- 更好的方案:**缓存完整列表,按 cursor 在内存中切片**
+
+#### 缓存数据结构
+```go
+// AssetLikersCache 缓存数据结构
+type AssetLikersCache struct {
+ Users []AssetLikerWithTotal `json:"users"` // 完整用户列表(按 liked_at DESC)
+ Total int64 `json:"total"` // 总数
+ UpdatedAt int64 `json:"updated_at"` // 缓存更新时间
+}
+
+// AssetLikerWithTotal 用户+点赞时间
+type AssetLikerWithTotal struct {
+ UserID int64 `json:"user_id"`
+ Nickname string `json:"nickname"`
+ Avatar string `json:"avatar"`
+ FanLevel int32 `json:"fan_level"`
+ LikedAt int64 `json:"liked_at"`
+}
+```
+
+#### 查询流程
+```
+1. GET asset_likers:{asset_id}
+2. 命中 → 从 users 列表中找 liked_at < cursor 的前 page_size 条
+3. 未命中 → 查 DB,LIMIT 1000(最多缓存1000条),写入 Redis
+4. 返回数据(users, total, has_more, next_cursor)
+```
+
+#### 缓存失效
+点赞/取消点赞时删除缓存(应在 `LikeAsset` 和 `UnlikeAsset` 方法末尾调用):
+```go
+// InvalidateAssetLikersCache 删除藏品点赞用户列表缓存
+func InvalidateAssetLikersCache(ctx context.Context, assetID int64) error {
+ if database.RedisClient == nil {
+ return nil // Redis 未初始化时跳过
+ }
+ key := fmt.Sprintf("asset_likers:%d", assetID)
+ return database.RedisClient.Del(ctx, key).Err()
+}
+```
+
+#### TTL 设计
+- TTL = **60 秒**(1分钟)
+- 理由:藏品点赞延迟 1 分钟可接受,且能有效抗住热点压力
+
+#### 容量规划
+| 藏品热度 | 点赞数 | 缓存大小(估算) |
+|---------|--------|-----------------|
+| 普通 | 0-500 | ~50KB |
+| 热门 | 500-5000 | ~500KB |
+| 顶流 | 5000+ | ~5MB(截断到1000条)|
+
+**截断策略**:最多缓存 1000 条,超出部分不缓存(直接查 DB)
+
+**缓存雪崩防护**:
+- TTL = 60 秒,保证缓存会自然过期
+- 即使热点藏品频繁更新,最坏情况延迟也只有 60 秒
+
+#### 需要修改的文件
+1. `pkg/database/redis.go` - 添加 `GetAssetLikersCache` 和 `SetAssetLikersCache` 方法
+2. `services/socialService/service/asset_like_service.go` - `GetAssetLikers` 方法:查缓存 → 缓存未命中查DB → 写入缓存
+3. `services/socialService/service/asset_like_service.go` - `LikeAsset` / `UnlikeAsset` 方法末尾调用 `InvalidateAssetLikersCache`
+
+---
+
+## 5. Proto 定义 (pkg/proto/social/)
+
+**新增请求消息**:
+```protobuf
+message GetAssetLikersRequest {
+ int64 asset_id = 1;
+ int32 page_size = 2; // 每页数量(默认20,最大100)
+ int64 cursor = 3; // 游标(上一页最后一条的 liked_at)
+}
+
+message GetAssetLikersResponse {
+ CommonPB base = 1;
+ repeated AssetLiker users = 2;
+ int64 total = 3;
+ bool has_more = 4; // 是否有更多
+ int64 next_cursor = 5; // 下一页游标
+}
+
+message AssetLiker {
+ int64 user_id = 1;
+ string nickname = 2;
+ string avatar = 3;
+ int32 fan_level = 4;
+ int64 liked_at = 5;
+}
+```
+
+### 5.1 Repository 层
+
+```go
+// GetByAssetWithUsers 获取资产的点赞用户列表(带用户信息,游标分页)
+GetByAssetWithUsers(assetID int64, cursor int64, limit int) ([]*AssetLikeWithUser, error)
+
+// AssetLikeWithUser 点赞记录+用户信息
+type AssetLikeWithUser struct {
+ UserID int64
+ Nickname string // 来自 fan_profiles.nickname
+ Avatar string // 来自 users.avatar_url
+ FanLevel int32 // 来自 fan_profiles.level
+ LikedAt int64
+}
+```
+
+**SQL 查询(游标分页)**:
+```sql
+SELECT
+ al.user_id,
+ COALESCE(fp.nickname, '匿名用户') as nickname, -- 昵称来自 fan_profiles
+ u.avatar_url, -- 头像来自 users
+ COALESCE(fp.level, 1) as fan_level, -- 粉丝等级来自 fan_profiles
+ al.created_at as liked_at
+FROM asset_likes al
+JOIN users u ON al.user_id = u.id
+LEFT JOIN fan_profiles fp ON al.user_id = fp.user_id AND al.star_id = fp.star_id
+WHERE al.asset_id = ? AND al.created_at < ? -- 游标条件
+ORDER BY al.created_at DESC
+LIMIT ? -- page_size
+```
+**注意**:
+- `users` 表无 nickname 字段,昵称来自 `fan_profiles.nickname`
+- 需要 `COALESCE` 处理用户可能没有 fan_profile 的情况
+
+### 5.2 Service 层
+
+```go
+func (s *AssetLikeService) GetAssetLikers(ctx context.Context, assetID int64, cursor int64, pageSize int32) (*GetAssetLikersResponse, error)
+```
+
+**校验逻辑**:
+1. 验证资产是否存在(调用 `assetClient.GetAssetForRPC`)
+2. 资产不存在返回错误
+3. 查询缓存 → 命中则直接切片返回
+4. 未命中 → 查 DB → 写入缓存 → 返回
+
+### 5.3 Gateway 层
+
+**路由**: `GET /api/v1/social/assets/:asset_id/likers`
+
+**请求参数**:
+- `asset_id`: 路径参数
+- `page_size`: 查询参数(默认20,最大100)
+- `cursor`: 查询参数(上一页返回的 next_cursor,首次请求传0)
+
+**响应格式**:
+```json
+{
+ "code": 0,
+ "message": "success",
+ "data": {
+ "users": [
+ {
+ "user_id": 123,
+ "nickname": "用户昵称", // 来自 fan_profiles.nickname
+ "avatar": "https://...", // 来自 users.avatar_url
+ "fan_level": 5, // 来自 fan_profiles.level
+ "liked_at": 1716200000000
+ }
+ ],
+ "total": 100,
+ "has_more": true,
+ "next_cursor": 1716199999000
+ }
+}
+```
+
+---
+
+## 6. 路由注册
+
+在 `router.go` 中添加:
+```go
+social.GET("/assets/:asset_id/likers", socialCtrl.GetAssetLikers)
+```
+
+---
+
+## 7. 错误处理
+
+| 错误场景 | HTTP 状态码 | 错误码 | 错误信息 |
+|---------|------------|--------|---------|
+| 未登录 | 401 | - | 未授权 |
+| 资产不存在 | 404 | - | 资产不存在 |
+| 服务调用失败 | 500 | - | 服务调用失败 |
+
+---
+
+## 8. 文件清单
+
+需要修改的文件:
+1. `backend/proto/social.proto` - 新增 GetAssetLikersRequest/GetAssetLikersResponse/AssetLiker 消息
+2. `backend/pkg/proto/social/*.go` - 重新生成(需执行 protoc 编译)
+3. `backend/pkg/database/redis.go` - 新增 GetAssetLikersCache / SetAssetLikersCache / InvalidateAssetLikersCache 方法
+4. `backend/services/socialService/repository/social_repository.go` - 新增 GetAssetLikersWithUsers 接口方法和实现
+5. `backend/services/socialService/service/asset_like_service.go` - 新增 GetAssetLikers 方法(含缓存逻辑),修改 LikeAsset/UnlikeAsset 添加缓存失效
+6. `backend/services/socialService/provider/social_provider.go` - 在 SocialServiceHandler 接口添加 GetAssetLikers 方法注册
+7. `backend/gateway/controller/social_controller.go` - 新增 GetAssetLikers Handler
+8. `backend/gateway/router/router.go` - 注册路由 `GET /assets/:asset_id/likers`
+
+---
+
+## 9. 实现顺序
+
+1. Proto 定义(消息结构)
+2. Redis 缓存方法(`pkg/database/redis.go`)
+3. Repository 层(数据查询)
+4. Service 层(业务逻辑 + 缓存读取)
+5. Provider 层(注册 RPC 方法)
+6. Controller 层(HTTP 处理)
+7. 路由注册
+8. 点赞/取消点赞时触发缓存失效
diff --git a/frontend/pages/components/StarbookContent.vue b/frontend/pages/components/StarbookContent.vue
index e0da17b..82e8284 100644
--- a/frontend/pages/components/StarbookContent.vue
+++ b/frontend/pages/components/StarbookContent.vue
@@ -58,7 +58,7 @@
:src="getGradeBackground(gradeItem.grade)"
mode="aspectFill"
>
- {{ formatGrade(gradeItem.grade) }}
+
@@ -220,19 +220,20 @@ const cardSize = computed(() => {
});
// grade 转换
-function formatGrade(grade) {
- return `V${grade}`;
-}
+// function formatGrade(grade) {
+// return `V${grade}`;
+// }
+
+const gradeMap = {
+ 1: '/static/starbookcontent/grade/Ndengji.png',
+ 2: '/static/starbookcontent/grade/Rdengji.png',
+ 3: '/static/starbookcontent/grade/SRdengji.png',
+ 4: '/static/starbookcontent/grade/SSRdengji.png',
+ 5: '/static/starbookcontent/grade/URengji.png',
+};
-// 获取等级背景图
function getGradeBackground(grade) {
- if (grade <= 2) {
- return '/static/starbookcontent/V1dengji.png';
- } else if (grade <= 4) {
- return '/static/starbookcontent/V2dengji.png';
- } else {
- return '/static/starbookcontent/V3dengji.png';
- }
+ return gradeMap[grade] || gradeMap[1];
}
// 判断是否有数据
@@ -504,30 +505,20 @@ watch(() => props.isActive, (newVal) => {
/* 等级区块 */
.grade-section {
- background: #f0839960;
border-radius: 16rpx;
- padding: 20rpx;
+ padding:0 20rpx;
}
/* 分组标题 */
.group-header {
position: relative;
- margin-bottom: 16rpx;
- padding: 16rpx 24rpx;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 160rpx;
- height: 32rpx;
+ /* width: 160rpx; */
+ height: 80rpx;
}
.group-header-bg {
- position: absolute;
- top: 0;
- left: 0;
- width: 160rpx;
- height: 64rpx;
- z-index: 0;
+ width: 80rpx;
+ height: 80rpx;
}
.group-title {
@@ -544,6 +535,8 @@ watch(() => props.isActive, (newVal) => {
height: 288rpx;
white-space: nowrap;
padding: 0.5rem;
+ background: #f0839960;
+
}
/* 藏品行内容容器 */