feat:修改星册的过滤条件和等级图标

This commit is contained in:
zerosaturation 2026-05-21 13:25:06 +08:00
parent bb797ebf87
commit ff22b29ede
4 changed files with 408 additions and 51 deletions

View File

@ -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(&registries).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
}

View File

@ -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,

View File

@ -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`
- 如果缓存每一页的 cursorkey 会很多且难以管理
- 更好的方案:**缓存完整列表,按 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. 未命中 → 查 DBLIMIT 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. 点赞/取消点赞时触发缓存失效

View File

@ -58,7 +58,7 @@
:src="getGradeBackground(gradeItem.grade)"
mode="aspectFill"
></image>
<text class="group-title">{{ formatGrade(gradeItem.grade) }}</text>
<!-- <text class="group-title">{{ formatGrade(gradeItem.grade) }}</text> -->
</view>
<scroll-view class="nft-row" scroll-x :show-scrollbar="false" :enable-flex="true">
<view class="nft-row-content">
@ -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;
}
/* 藏品行内容容器 */