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; + } /* 藏品行内容容器 */