Compare commits

..

2 Commits

20 changed files with 670 additions and 51 deletions

View File

@ -608,13 +608,15 @@ func (ctrl *GalleryController) GetMyExhibitedAssets(c *gin.Context) {
items := make([]*dto.ExhibitedAssetItemDTO, 0, len(resp.Data.Items))
for _, item := range resp.Data.Items {
items = append(items, &dto.ExhibitedAssetItemDTO{
AssetID: item.AssetId,
AssetID: item.AssetId,
Name: item.Name,
CoverURL: item.CoverUrl,
LikeCount: item.LikeCount,
ExhibitedAt: item.ExhibitedAt,
ExpireAt: item.ExpireAt,
Earnings: item.Earnings,
SlotIndex: item.SlotIndex,
IsLenticular: item.IsLenticular,
})
}
@ -796,13 +798,15 @@ func (ctrl *GalleryController) GetUserExhibitedAssets(c *gin.Context) {
items := make([]*dto.ExhibitedAssetItemDTO, 0, len(resp.Data.Items))
for _, item := range resp.Data.Items {
items = append(items, &dto.ExhibitedAssetItemDTO{
AssetID: item.AssetId,
AssetID: item.AssetId,
Name: item.Name,
CoverURL: item.CoverUrl,
LikeCount: item.LikeCount,
ExhibitedAt: item.ExhibitedAt,
ExpireAt: item.ExpireAt,
Earnings: item.Earnings,
SlotIndex: item.SlotIndex,
IsLenticular: item.IsLenticular,
})
}

View File

@ -64,6 +64,7 @@ func ConvertAssetMaterialRelation(pbRel *pbAsset.AssetMaterialRelation) AssetMat
MaterialType: pbRel.MaterialType,
LayerOrder: pbRel.LayerOrder,
MaterialURLSigned: pbRel.MaterialUrlSigned,
OssKey: pbRel.OssKey,
}
if pbRel.PosX != 0 || pbRel.PosY != 0 {
px := pbRel.PosX

View File

@ -203,6 +203,7 @@ type AssetMaterialRelationDTO struct {
ScaleY *float64 `json:"scale_y"`
Width *int `json:"width"`
Height *int `json:"height"`
OssKey string `json:"oss_key,omitempty"`
}
// UploadMaterialRequestDTO 上传素材请求

View File

@ -73,13 +73,15 @@ type UnlockSlotResponseDTO struct {
// ExhibitedAssetItemDTO 展出的作品项
type ExhibitedAssetItemDTO struct {
AssetID int64 `json:"asset_id"` // 资产ID
Name string `json:"name"` // 藏品名称
CoverURL string `json:"cover_url"` // 封面图URL
LikeCount int32 `json:"like_count"` // 实时点赞数
ExhibitedAt int64 `json:"exhibited_at"` // 展出开始时间(毫秒时间戳)
ExpireAt int64 `json:"expire_at"` // 展出过期时间(毫秒时间戳)
Earnings int64 `json:"earnings"` // 当前可领取收益
AssetID int64 `json:"asset_id"` // 资产ID
Name string `json:"name"` // 藏品名称
CoverURL string `json:"cover_url"` // 封面图URL
LikeCount int32 `json:"like_count"` // 实时点赞数
ExhibitedAt int64 `json:"exhibited_at"` // 展出开始时间(毫秒时间戳)
ExpireAt int64 `json:"expire_at"` // 展出过期时间(毫秒时间戳)
Earnings int64 `json:"earnings"` // 当前可领取收益
SlotIndex int32 `json:"slot_index"` // 展位序号
IsLenticular bool `json:"is_lenticular"` // 是否为光栅卡
}
// GetMyExhibitedAssetsResponseDTO 获取我展出的作品列表响应

View File

@ -2826,6 +2826,7 @@ type AssetMaterialRelation struct {
Rotation float64 `protobuf:"fixed64,10,opt,name=rotation,proto3" json:"rotation,omitempty"`
ScaleX float64 `protobuf:"fixed64,11,opt,name=scale_x,json=scaleX,proto3" json:"scale_x,omitempty"`
ScaleY float64 `protobuf:"fixed64,12,opt,name=scale_y,json=scaleY,proto3" json:"scale_y,omitempty"`
OssKey string `protobuf:"bytes,13,opt,name=oss_key,json=ossKey,proto3" json:"oss_key,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -2944,6 +2945,13 @@ func (x *AssetMaterialRelation) GetScaleY() float64 {
return 0
}
func (x *AssetMaterialRelation) GetOssKey() string {
if x != nil {
return x.OssKey
}
return ""
}
// 上传素材请求
type UploadMaterialRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -3859,7 +3867,7 @@ const file_asset_proto_rawDesc = "" +
"\astar_id\x18\n" +
" \x01(\x03R\x06starId\x12\x1d\n" +
"\n" +
"created_at\x18\v \x01(\x03R\tcreatedAt\"\xfc\x02\n" +
"created_at\x18\v \x01(\x03R\tcreatedAt\"\x95\x03\n" +
"\x15AssetMaterialRelation\x12\x1f\n" +
"\vrelation_id\x18\x01 \x01(\x03R\n" +
"relationId\x12\x19\n" +
@ -3876,7 +3884,8 @@ const file_asset_proto_rawDesc = "" +
"\brotation\x18\n" +
" \x01(\x01R\brotation\x12\x17\n" +
"\ascale_x\x18\v \x01(\x01R\x06scaleX\x12\x17\n" +
"\ascale_y\x18\f \x01(\x01R\x06scaleY\"\xf6\x01\n" +
"\ascale_y\x18\f \x01(\x01R\x06scaleY\x12\x17\n" +
"\aoss_key\x18\r \x01(\tR\x06ossKey\"\xf6\x01\n" +
"\x15UploadMaterialRequest\x12\x17\n" +
"\aoss_key\x18\x01 \x01(\tR\x06ossKey\x12#\n" +
"\roriginal_name\x18\x02 \x01(\tR\foriginalName\x12\x1b\n" +

View File

@ -1215,13 +1215,15 @@ func (x *ExhibitedAssetsData) GetHasMore() bool {
// 展出作品项
type ExhibitedAssetItem struct {
state protoimpl.MessageState `protogen:"open.v1"`
AssetId int64 `protobuf:"varint,1,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"` // 资产ID
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // 藏品名称
CoverUrl string `protobuf:"bytes,3,opt,name=cover_url,json=coverUrl,proto3" json:"cover_url,omitempty"` // 封面图URL
LikeCount int32 `protobuf:"varint,4,opt,name=like_count,json=likeCount,proto3" json:"like_count,omitempty"` // 实时点赞数
ExhibitedAt int64 `protobuf:"varint,5,opt,name=exhibited_at,json=exhibitedAt,proto3" json:"exhibited_at,omitempty"` // 展出开始时间(毫秒时间戳)
ExpireAt int64 `protobuf:"varint,6,opt,name=expire_at,json=expireAt,proto3" json:"expire_at,omitempty"` // 展出过期时间(毫秒时间戳)
Earnings int64 `protobuf:"varint,7,opt,name=earnings,proto3" json:"earnings,omitempty"` // 当前可领取收益
AssetId int64 `protobuf:"varint,1,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"` // 资产ID
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // 藏品名称
CoverUrl string `protobuf:"bytes,3,opt,name=cover_url,json=coverUrl,proto3" json:"cover_url,omitempty"` // 封面图URL
LikeCount int32 `protobuf:"varint,4,opt,name=like_count,json=likeCount,proto3" json:"like_count,omitempty"` // 实时点赞数
ExhibitedAt int64 `protobuf:"varint,5,opt,name=exhibited_at,json=exhibitedAt,proto3" json:"exhibited_at,omitempty"` // 展出开始时间(毫秒时间戳)
ExpireAt int64 `protobuf:"varint,6,opt,name=expire_at,json=expireAt,proto3" json:"expire_at,omitempty"` // 展出过期时间(毫秒时间戳)
Earnings int64 `protobuf:"varint,7,opt,name=earnings,proto3" json:"earnings,omitempty"` // 当前可领取收益
SlotIndex int32 `protobuf:"varint,8,opt,name=slot_index,json=slotIndex,proto3" json:"slot_index,omitempty"` // 展位序号
IsLenticular bool `protobuf:"varint,9,opt,name=is_lenticular,json=isLenticular,proto3" json:"is_lenticular,omitempty"` // 是否为光栅卡(根据 tags 判断)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1305,6 +1307,20 @@ func (x *ExhibitedAssetItem) GetEarnings() int64 {
return 0
}
func (x *ExhibitedAssetItem) GetSlotIndex() int32 {
if x != nil {
return x.SlotIndex
}
return 0
}
func (x *ExhibitedAssetItem) GetIsLenticular() bool {
if x != nil {
return x.IsLenticular
}
return false
}
// 获取灵感瀑布藏品列表请求
type GetInspirationFlowRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -1801,7 +1817,7 @@ const file_gallery_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\"\xdb\x01\n" +
"\bhas_more\x18\x05 \x01(\bR\ahasMore\"\x9f\x02\n" +
"\x12ExhibitedAssetItem\x12\x19\n" +
"\basset_id\x18\x01 \x01(\x03R\aassetId\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" +
@ -1810,7 +1826,10 @@ const file_gallery_proto_rawDesc = "" +
"like_count\x18\x04 \x01(\x05R\tlikeCount\x12!\n" +
"\fexhibited_at\x18\x05 \x01(\x03R\vexhibitedAt\x12\x1b\n" +
"\texpire_at\x18\x06 \x01(\x03R\bexpireAt\x12\x1a\n" +
"\bearnings\x18\a \x01(\x03R\bearnings\"\x9a\x01\n" +
"\bearnings\x18\a \x01(\x03R\bearnings\x12\x1d\n" +
"\n" +
"slot_index\x18\b \x01(\x05R\tslotIndex\x12#\n" +
"\ris_lenticular\x18\t \x01(\bR\fisLenticular\"\x9a\x01\n" +
"\x19GetInspirationFlowRequest\x12\x16\n" +
"\x06cursor\x18\x01 \x01(\tR\x06cursor\x12\x1c\n" +
"\tdirection\x18\x02 \x01(\tR\tdirection\x12\x14\n" +

View File

@ -360,6 +360,7 @@ message AssetMaterialRelation {
double rotation = 10;
double scale_x = 11;
double scale_y = 12;
string oss_key = 13;
}
//

View File

@ -207,6 +207,8 @@ message ExhibitedAssetItem {
int64 exhibited_at = 5; //
int64 expire_at = 6; //
int64 earnings = 7; //
int32 slot_index = 8; //
bool is_lenticular = 9; // tags
}
// ==================== ====================

View File

@ -50,7 +50,7 @@ type ServiceURLs struct {
var (
// GalleryRules 展馆规则配置(硬编码)
GalleryRules = &GalleryRulesConfig{
InitialSlotCount: 6, // 3 个公开 + 3 个私有
InitialSlotCount: 6, // 3 个公开 + 3 个私有
GrabSlotDuration: 14400, // 4小时
// 按等级解锁第4个展位需要等级5第5个展位需要等级6以此类推

View File

@ -92,13 +92,15 @@ type InspirationFlowItem struct {
// ExhibitedAssetInfo 我展出的作品信息
type ExhibitedAssetInfo struct {
AssetID int64
Name string
CoverURL string
LikeCount int32
ExhibitedAt int64
ExpireAt int64
Earnings int64
AssetID int64
Name string
CoverURL string
LikeCount int32
ExhibitedAt int64
ExpireAt int64
Earnings int64
SlotIndex int32
IsLenticular bool
}
// galleryRepository Repository实现
@ -417,7 +419,8 @@ func (r *galleryRepository) GetMyExhibitedAssets(userID, starID int64, page, pag
err = r.db.Model(&models.Exhibition{}).
Raw(`
SELECT exhibitions.asset_id, a.name, a.cover_url, a.like_count,
exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index
exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index,
(a.tags @> '["craft:lenticular"]') as is_lenticular
FROM exhibitions
JOIN assets a ON a.id = exhibitions.asset_id
JOIN booth_slots bs ON bs.slot_id = exhibitions.slot_id
@ -461,7 +464,8 @@ func (r *galleryRepository) GetUserExhibitedAssets(userID, starID int64, page, p
err = r.db.Model(&models.Exhibition{}).
Raw(`
SELECT exhibitions.asset_id, a.name, a.cover_url, a.like_count,
exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index
exhibitions.start_time as exhibited_at, exhibitions.expire_at, bs.slot_index,
(a.tags @> '["craft:lenticular"]') as is_lenticular
FROM exhibitions
JOIN assets a ON a.id = exhibitions.asset_id
JOIN booth_slots bs ON bs.slot_id = exhibitions.slot_id

View File

@ -209,13 +209,15 @@ func (s *exhibitionService) GetMyExhibitedAssets(ctx context.Context, userID, st
pbItems := make([]*pb.ExhibitedAssetItem, 0, len(items))
for _, item := range items {
pbItems = append(pbItems, &pb.ExhibitedAssetItem{
AssetId: item.AssetID,
Name: item.Name,
CoverUrl: item.CoverURL,
LikeCount: item.LikeCount,
ExhibitedAt: item.ExhibitedAt,
ExpireAt: item.ExpireAt,
Earnings: item.Earnings,
AssetId: item.AssetID,
Name: item.Name,
CoverUrl: item.CoverURL,
LikeCount: item.LikeCount,
ExhibitedAt: item.ExhibitedAt,
ExpireAt: item.ExpireAt,
Earnings: item.Earnings,
SlotIndex: item.SlotIndex,
IsLenticular: item.IsLenticular,
})
}

View File

@ -341,8 +341,8 @@ func (s *starbookService) buildAssetItemsFromRegistries(registries []*models.Ass
if name, ok := assetNameMap[reg.AssetID]; ok {
item.Name = name
}
if coverURL, ok := assetCoverMap[reg.AssetID]; ok {
item.CoverUrlSigned = coverURL // 直接返回原始 URL
if coverURL, ok := assetCoverMap[reg.AssetID]; ok && coverURL != "" {
item.CoverUrlSigned = coverURL
}
if cat, ok := categoryMap[reg.AssetID]; ok {
item.Category = cat
@ -433,11 +433,19 @@ 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: items,
Items: filteredItems,
Total: totalCount,
Page: page,
PageSize: pageSize,

View File

@ -427,9 +427,9 @@ func (r *fanProfileRepository) UpdateCrystalBalance(userID, starID int64, delta
balanceBefore := profile.CrystalBalance
newBalance = balanceBefore + delta
// 确保不会小于 0
if newBalance < 0 {
newBalance = 0
// 扣除时检查余额是否充足
if delta < 0 && balanceBefore < -delta {
return appErrors.ErrInsufficientCrystal
}
// 3. 写入水晶流水(复式记账,包含余额快照)

View File

@ -24,3 +24,7 @@ OSS_TOKEN_EXPIRE_TIME=3600
# ==================== MiniMax API Configuration ====================
MINIMAX_API_KEY=sk-cp-Fffv8Bg8zeFD929_KUAZq9EKet64Nkxgu7t1ibZEngqmyPKaOOa7U8U_gtg3VICfUQyGPn8c5XR4hxmWzjKC4wO6DxKh5ipN36Yv5jsFzZWMEPh6NKV2qAE
MINIMAX_API_URL=https://api.minimaxi.com/v1/image_generation
# Redis Configuration
REDIS_HOST=topfans-redis
REDIS_PASSWORD=123456

View File

@ -216,6 +216,7 @@ services:
DB_NAME: topfans
USER_SERVICE_URL: tri://userservice:20000
ASSET_SERVICE_URL: tri://assetservice:20003
TASK_SERVICE_URL: tri://taskservice:20006
depends_on:
userservice:
condition: service_started
@ -290,6 +291,7 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-postgres123}
DB_NAME: topfans
USER_SERVICE_URL: tri://userservice:20000
GALLERY_SERVICE_URL: tri://galleryservice:20001
depends_on:
userservice:
condition: service_started
@ -369,6 +371,7 @@ services:
DUBBO_ACTIVITY_SERVICE_URL: tri://activityservice:20004
DUBBO_TASK_SERVICE_URL: tri://taskservice:20006
DUBBO_STARBOOK_SERVICE_URL: tri://starbookservice:20007
REDIS_HOST: topfans-redis
depends_on:
userservice:
condition: service_started

365
docs/slot-refactor.md Normal file
View File

@ -0,0 +1,365 @@
# 展位重构设计文档
## 背景与目标
### 当前问题
- 数据库每用户有 **6 个槽位**slot_index 1-6
- 前端 UI 只设计 **2 个卡片**显示
- 现有匹配逻辑有 bug空位显示与实际 slot_index 不对应
### 核心问题示例
```
数据库槽位状态用户A:
slot_index=1: 空
slot_index=2: 空
slot_index=3: 展出中 (asset X)
slot_index=4: 展出中 (asset Y)
slot_index=5: 空
slot_index=6: 展出中 (asset Z)
前端显示:
- 展出中卡片: asset X (slot_index=3), asset Y (slot_index=4)
- 空位: 1个因为 exhibitionWorks.length=2 < 2不对这里逻辑有问题
问题:前端不知道 slot_index=1,2,5 哪个是空的
```
### 目标
1. 每用户固定 **2 个展位**slot_index=1, 2
2. 前端只显示 **2 个卡片**,与后端 2 个槽位一一对应
3. **每个用户独立**,只能操作自己的展位
---
## 问题分析
### 当前前端空位逻辑(有问题)
```vue
<!-- exhibitionWorks.length === 2 时不显示空位 -->
<!-- exhibitionWorks.length === 1 时显示 1 个空位 -->
<!-- exhibitionWorks.length === 0 时显示 2 个空位 -->
<view v-if="exhibitionWorks.length < 2">
<view v-if="exhibitionWorks.length === 0" @tap="openAssetSelector(0)"> <!-- 左空位 -->
<view v-if="exhibitionWorks.length < 2" @tap="openAssetSelector(1)"> <!-- 右空位 -->
</view>
```
**问题**`openAssetSelector(position)` 传入的是 UI 位置0或1但不知道对应哪个 `slot_index`
### 现有 handleAssetSelect 逻辑
```javascript
// 原来:用 slot_index = position + 1 匹配
const desiredSlotIndex = currentSlotIndex.value + 1; // position=0 → slot_index=1
let targetSlot = slots.find(s => s.slot_index === desiredSlotIndex);
// 回退:如果 slot_index=1 被占用,用 operatableSlots[0]
```
**问题**:如果 slot_index=1 已被占用但 slot_index=2 是空的,会匹配错误
---
## 解决方案
### 方案:按 slot_index 计算空位
**核心思路**
1. 前端获取 `mySlots`(前 2 个,按 slot_index 排序)
2. 计算 `emptySlotIndices = [1, 2] - 已展出作品的 slot_index 集合`
3. 点击空位时,传入 `slot_index`(不是 position
### 前端改动
#### 1. 空位显示逻辑
```vue
<!-- 计算哪些 slot_index 是空的 -->
<view v-if="emptySlotIndices.length > 0" class="empty-exhibition">
<view
v-for="(slotIdx, uiIndex) in emptySlotIndices"
:key="'empty-' + uiIndex"
:class="uiIndex === 0 ? 'empty-card-left' : 'empty-card-right'"
@tap="openAssetSelector(slotIdx)">
<!-- 空位卡片 -->
</view>
</view>
```
#### 2. 添加计算属性
```javascript
// 加载展馆信息(用于确定空位)
const mySlots = ref([]);
const loadGalleryInfo = async () => {
const galleriesRes = await getMyGalleriesApi();
// 只取前2个可操作槽位按 slot_index 排序
mySlots.value = galleriesRes.data?.slots
.filter(s => s.can_operate)
.sort((a, b) => (a.slot_index ?? 0) - (b.slot_index ?? 0))
.slice(0, 2) || [];
};
// 计算空位:哪些 slot_index 没有展出中
const emptySlotIndices = computed(() => {
const occupiedSlots = exhibitionWorks.value.map(w => w.slot_index);
return mySlots.value
.filter(s => !occupiedSlots.includes(s.slot_index))
.map(s => s.slot_index);
});
```
#### 3. 修改 openAssetSelector 参数
```javascript
// openAssetSelector 现在接收 slot_index1 或 2
const openAssetSelector = (slotIndex = 0) => {
currentSlotIndex.value = slotIndex; // 现在是 slot_index 值
showAssetSelector.value = true;
};
```
#### 4. 修改 handleAssetSelect 匹配逻辑
```javascript
// 原来:用 position + 1 匹配,可能错误
// 现在:用 currentSlotIndex就是 slot_index直接匹配
const handleAssetSelect = async ({ asset, isReplace, oldAsset }) => {
// mySlots[0].slot_index = 1, mySlots[1].slot_index = 2
// currentSlotIndex.value 就是要放置的 slot_index
let targetSlotId = null;
if (isReplace && oldAsset) {
// 替换模式:根据旧藏品找到 slot_id
targetSlotId = slots.find(s => s.asset_id === oldAsset.asset_id)?.slot_id;
} else {
// 新放置模式:用 currentSlotIndex= slot_index直接找
targetSlotId = mySlots.value.find(s => s.slot_index === currentSlotIndex.value)?.slot_id;
}
};
```
---
## 后端改动
### 1. 配置 (gallery_config.go)
```go
GalleryRules = &GalleryRulesConfig{
InitialSlotCount: 2, // 6 → 2
MaxSlotCount: 2, // 10 → 2
UnlockLevelBySlot: map[int]int{}, // 清空
UnlockCrystalBySlot: map[int]int{}, // 清空
}
```
### 2. PlaceAsset 简化 (exhibition_service.go)
**位置**: `backend/services/galleryService/service/exhibition_service.go:46-129`
**改动**:移除"放到他人展馆"的逻辑,只允许放到自己的展位
```go
// 原来 (lines 87-97)
isOwner := slot.UserID == userID
if !isOwner {
// 如果不是自己的展位,必须是 public 且在同一个明星下
if slot.Visibility != "public" { ... }
if slot.StarID != starID { ... }
}
// 改为
if slot.UserID != userID {
return nil, errors.New("只能在自己的展位放置藏品")
}
```
### 3. calculateOperation 简化 (gallery_service.go)
**位置**: `backend/services/galleryService/service/gallery_service.go:221-280`
**问题**:当前逻辑允许在他人 public 空展位放置,与新设计冲突
```go
// 原来(有问题的逻辑)
if slot.Visibility == "public" {
if exhibition == nil {
if !isOwner {
return true, "place" // ← 允许放他人 public 展位
}
}
}
// 改为(新设计)
func (s *galleryService) calculateOperation(slot *models.BoothSlot, exhibition *models.Exhibition, viewerUID int64, isOwner bool) (bool, string) {
// 未解锁的展位不能操作
if !slot.IsEnabled {
return false, "none"
}
// 自己是展位所有者
if isOwner {
if exhibition == nil {
return true, "place" // 自己的空展位,可以放置
}
return true, "remove" // 有藏品,可以移除
}
// 不是展位所有者,不能操作
return false, "none"
}
```
### 4. GetSlotsByUser 添加 LIMIT (gallery_repository.go)
**位置**: `backend/services/galleryService/repository/gallery_repository.go:117-124`
**问题**:查询可能返回超过 2 个槽位
```go
// 原来
func (r *galleryRepository) GetSlotsByUser(userID, starID int64) ([]*models.BoothSlot, error) {
err := r.db.Where("user_id = ? AND star_id = ?", userID, starID).
Order("slot_index ASC").
Find(&slots).Error // 没有 LIMIT
return slots, err
}
// 改为(添加 LIMIT 2
func (r *galleryRepository) GetSlotsByUser(userID, starID int64) ([]*models.BoothSlot, error) {
var slots []*models.BoothSlot
err := r.db.Where("user_id = ? AND star_id = ?", userID, starID).
Order("slot_index ASC").
Limit(2). // ← 新增
Find(&slots).Error
return slots, err
}
```
### 5. GetUserGallery 浏览他人展馆 (gallery_service.go)
**位置**: `backend/services/galleryService/service/gallery_service.go:94-140`
**行为变化**
- 他人展馆 `can_operate: false`(不能操作,只能浏览)
- 前端不应显示"放置"按钮
### 3. 已有数据处理
| 情况 | 处理方式 |
|------|----------|
| 已有 6 槽用户 | 需要数据库迁移清理 |
| 数据库 migration | **需要**(见下方 SQL |
---
## 数据库迁移
### 迁移原因
- 已有用户每人有 6 个槽位slot_index 1-6
- 新设计只需 2 个槽位slot_index 1-2
- 需要清理多余数据,避免混淆
### 迁移 SQL
```sql
-- 1. 先删除 slot_index > 2 的展览记录(引用完整性)
DELETE FROM exhibitions
WHERE slot_id IN (
SELECT slot_id FROM booth_slots
WHERE slot_index > 2
);
-- 2. 删除 slot_index > 2 的槽位记录
DELETE FROM booth_slots
WHERE slot_index > 2;
-- 3. 验证清理结果
SELECT user_id, star_id, COUNT(*) as slot_count
FROM booth_slots
GROUP BY user_id, star_id
HAVING COUNT(*) > 2;
-- 应该返回空结果
```
---
## 实施步骤
### 数据库
1. [ ] **备份数据**(重要!)
2. [ ] 执行清理 SQL删除 slot_index > 2 的数据)
### 后端5 处改动)
3. [ ] 修改 `gallery_config.go` 配置值
- `InitialSlotCount: 2`
- `MaxSlotCount: 2`
- 清空 `UnlockLevelBySlot``UnlockCrystalBySlot`
4. [ ] 修改 `PlaceAsset` 逻辑(移除他人展位)
5. [ ] 修改 `calculateOperation` 逻辑(移除 public 放置权限)
6. [ ] 修改 `GetSlotsByUser` 添加 `LIMIT 2`
7. [ ] 重启后端服务
### 前端
8. [ ] 添加 `mySlots` ref 存储槽位信息
9. [ ] 添加 `loadGalleryInfo()` 获取展馆数据
10. [ ] 添加 `emptySlotIndices` 计算属性
11. [ ] 修改空位模板使用 `emptySlotIndices`
12. [ ] 修改 `handleAssetSelect` 直接用 `slot_index` 匹配
13. [ ] 测试:左右空位点击后正确放置
### 验证
14. [ ] 多账号测试(各自独立)
15. [ ] 空位放置测试
16. [ ] 替换测试
17. [ ] 浏览他人展馆(只读,不能放置)
---
## 风险评估
| 风险 | 级别 | 说明 |
|------|------|------|
| 数据库迁移 | **高** | 删除数据需谨慎,必须先备份 |
| `calculateOperation` 修改 | **高** | 影响展位操作权限逻辑 |
| `GetSlotsByUser` 添加 LIMIT | **低** | 仅影响返回值数量,调用方逻辑不受影响 |
| 前端改动较多 | 中 | 涉及空位逻辑重构 |
| PlaceAsset 简化 | 低 | 移除多余逻辑 |
| 向后兼容 | 无 | 新用户和老用户都是 2 槽 |
---
## 影响分析
### 会影响的现有功能
| 功能 | 影响 | 需测试 |
|------|------|--------|
| `calculateOperation` | 改变他人 public 空展位放置权限 | **是** |
| `GetSlotsByUser` 返回数量 | 可能影响其他使用该函数的地方 | **是** |
| 浏览他人展馆 | `can_operate` 始终为 false | 否(预期行为) |
### 需要检查的其他调用方
```bash
# 检查 GetSlotsByUser 的其他调用
grep -rn "GetSlotsByUser" backend/
# 检查 calculateOperation 的其他调用(无其他调用方,仅内部使用)
```
### 影响结论
| 函数 | 调用方 | 影响分析 |
|------|--------|----------|
| `GetSlotsByUser` | `GetMyGallery`, `GetUserGallery` | 添加 LIMIT 2 后,查询只返回前 2 个槽位(符合预期) |
| `calculateOperation` | 仅 `buildSlotInfos`(内部) | 修改后,他人 public 空展位不能放置(符合预期) |
| `PlaceAsset` | Gateway 暴露 | 修改后,只能放自己展位(符合预期) |
---
## 验证方法
1. **多账号测试**A 账号和 B 账号登录,分别操作自己的展位,不混淆
2. **空位放置测试**slot_index=1 空的、slot_index=2 占用时,点击左空位放到 slot_index=1
3. **替换测试**slot_index=1 展出 A点击 slot_index=1 的卡替换成 B
4. **解锁测试**:调用 UnlockSlot 应返回"已达到最大展位数"

View File

@ -39,11 +39,11 @@
<view class="vignette" />
</view>
<view v-if="showHint && tiltHintText" class="tilt-hint">
<!-- <view v-if="showHint && tiltHintText" class="tilt-hint">
<text class="tilt-hint-icon"></text>
<text class="tilt-hint-text">{{ tiltHintText }}</text>
<text v-if="approximatePreview" class="tilt-hint-sub">叠化近似预览倾斜或拖动体验光栅效果</text>
</view>
</view> -->
</view>
</view>
</template>

View File

@ -707,6 +707,12 @@ const selectAsset = async () => {
const handleConfirmMint = async () => {
showConfirmModal.value = false;
//
if (confirmCostInfo.value.currentBalance < confirmCostInfo.value.costCrystal) {
uni.showToast({ title: '水晶余额不足,无法铸造', icon: 'none' });
return;
}
const imagePath =
isLenticularDisplay.value
? lenticularLayers.value.find((l) => l.id === 'mid')?.src || ''
@ -717,9 +723,9 @@ const handleConfirmMint = async () => {
uni.showLoading({ title: '铸造中…', mask: true });
try {
//
updateLocalBalance(confirmCostInfo.value.balanceAfter);
await submitCraftMintFromPath({ imagePath, bgImagePath, formData: craftFormData.value });
//
updateLocalBalance(confirmCostInfo.value.balanceAfter);
uni.hideLoading();
uni.navigateTo({ url: '/pages/castlove/success' });
} catch (e) {

View File

@ -57,7 +57,7 @@
</view>
<!-- 顶粉水晶余额 -->
<view class="crystal-balance-new">
<view class="crystal-balance-new" @click="handleAvatarClick">
<!-- 1. 左侧钻石图标层级最高盖住右侧背景 -->
<view class="crystal-icon-box">
<image class="crystal-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>

View File

@ -28,7 +28,17 @@
<view v-for="(item, index) in exhibitionWorks" :key="item.id" class="exhibition-card"
:class="index % 2 === 0 ? 'card-tilt-left' : 'card-tilt-right'"
@tap="handleExhibitionCardTap(item, index)">
<image class="card-image" :src="item.cover_url || '/static/nft/placeholder.png'"
<LenticularCard
v-if="item.is_lenticular"
class="card-lenticular"
:layers="getLenticularLayers(item.id)"
:transforms="getLenticularTransforms(item.id)"
:gyro-source="gyroSourceLabel"
:skip-built-in-touch="false"
:shimmer-mid-opacity="0.16"
@simulate="(x, y) => onLenticularSimulate(item.id, x, y)"
/>
<image v-else class="card-image" :src="item.cover_url || '/static/nft/placeholder.png'"
mode="aspectFill"></image>
<!-- 领取收益按钮 -->
<view class="claim-reward-btn" v-if="isRewardClaimable(item.id)">
@ -167,12 +177,15 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { getMyExhibitedAssetsApi, getMyLikedAssetsApi, getMyTodayLikedAssetsApi, getMyWeekLikedAssetsApi, getMyGalleriesApi, placeAssetToGalleryApi } from '@/utils/api.js';
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { getMyExhibitedAssetsApi, getMyLikedAssetsApi, getMyTodayLikedAssetsApi, getMyWeekLikedAssetsApi, getMyGalleriesApi, placeAssetToGalleryApi, getAssetMaterialsApi } from '@/utils/api.js';
import { getExhibitionRevenue, claimExhibitionRevenue } from '@/utils/task-api.js';
import AssetSelector from '../components/AssetSelector.vue';
import { onShow } from '@dcloudio/uni-app';
import { doubleTapLike } from '@/utils/likeHelper.js';
import LenticularCard from '@/components/lenticular/LenticularCard.vue';
import { useLenticularCraftTiltPreview } from '@/composables/useLenticularCraftTiltPreview.js';
import { buildLenticularLayers } from '@/utils/castloveMintForm.js';
const goBack = () => {
//
@ -496,6 +509,157 @@ const likedWorks = ref([]);
// : current-, today-, week-
const likedTab = ref('current');
//
// transforms asset id
const lenticularTransformsMap = ref({});
const lenticularLayersByAsset = ref({});
const activeLenticularId = ref(null);
const gyroSourceLabel = ref('device');
// 使
const lenticularPhysics = ref(null);
const lenticularEngine = ref(null);
let lenticularRafId = null;
// 使 useLenticularPreview
const lenticularLayersRef = ref([]);
function getLenticularLayers(assetId) {
return lenticularLayersByAsset.value[assetId] || [];
}
function getLenticularTransforms(assetId) {
return lenticularTransformsMap.value[assetId] || {};
}
async function loadLenticularLayersForAsset(assetId) {
// bg + subject layers
// 使 buildLenticularLayers(coverUrl)
const item = exhibitionWorks.value.find(w => w.id === assetId);
if (!item) return;
// asset-detail.vue
try {
const materialsRes = await getAssetMaterialsApi(assetId);
if (materialsRes.code === 200 && materialsRes.data) {
const materialsList = Array.isArray(materialsRes.data) ? materialsRes.data : materialsRes.data.materials || [];
let subjectUrl = item.cover_url;
let bgUrl = '';
for (const mat of materialsList) {
if (mat.material_type === 'main' && mat.material_url_signed) {
subjectUrl = mat.material_url_signed;
}
if (mat.material_type === 'bg' && mat.material_url_signed) {
bgUrl = mat.material_url_signed;
}
}
// 使 buildLenticularLayersTwo buildLenticularLayers
if (bgUrl) {
const { buildLenticularLayersTwo } = await import('@/utils/castloveMintForm.js');
lenticularLayersByAsset.value[assetId] = buildLenticularLayersTwo(bgUrl, subjectUrl);
} else {
lenticularLayersByAsset.value[assetId] = buildLenticularLayers(subjectUrl);
}
// transforms
initTransformsForAsset(assetId);
} else {
lenticularLayersByAsset.value[assetId] = buildLenticularLayers(item.cover_url);
initTransformsForAsset(assetId);
}
} catch (e) {
console.error('[myWorks] 获取素材列表失败:', e);
lenticularLayersByAsset.value[assetId] = buildLenticularLayers(item.cover_url);
}
}
//
function onLenticularSimulate(assetId, x, y) {
simulateLenticularTilt(assetId, x, y);
}
//
function simulateLenticularTilt(assetId, x, y) {
if (!lenticularEngine.value) return;
const layers = lenticularLayersByAsset.value[assetId];
if (!layers || layers.length === 0) return;
//
lenticularEngine.value.setLayers(layers);
//
const renderState = lenticularEngine.value.feedSimulatedTilt(x, y ? y : 0);
const transforms = {};
for (const l of layers) {
const rs = renderState.layerOffsets.get(l.id);
const op = renderState.layerOpacities.get(l.id);
transforms[l.id] = {
x: rs ? rs.x : 0,
y: rs ? rs.y : 0,
opacity: op != null ? op : l.opacity,
};
}
// Vue
lenticularTransformsMap.value = { ...lenticularTransformsMap.value, [assetId]: transforms };
}
//
function initLenticularEngine() {
if (lenticularEngine.value) return;
import('@/utils/lenticular-engine.js').then(({ LenticularEngine, DEFAULT_PHYSICS }) => {
const physics = { ...DEFAULT_PHYSICS, parallaxDepth: 18 };
physics.gyroSimEnabled = false;
lenticularPhysics.value = physics;
lenticularEngine.value = new LenticularEngine(physics);
});
}
// transforms
function initTransformsForAsset(assetId) {
const layers = lenticularLayersByAsset.value[assetId];
if (!layers || layers.length === 0) return;
const transforms = {};
for (const l of layers) {
transforms[l.id] = { x: 0, y: 0, opacity: l.opacity || 1 };
}
lenticularTransformsMap.value = { ...lenticularTransformsMap.value, [assetId]: transforms };
}
// transforms
function startLenticularRenderLoop() {
if (lenticularRafId !== null) return;
const tick = () => {
// transforms
for (const assetId of Object.keys(lenticularLayersByAsset.value)) {
const layers = lenticularLayersByAsset.value[assetId];
if (!layers || layers.length === 0) continue;
// 使 sensorData gamma=0
lenticularEngine.value.setLayers(layers);
const renderState = lenticularEngine.value.feedSimulatedTilt(0, 0);
const transforms = {};
for (const l of layers) {
const rs = renderState.layerOffsets.get(l.id);
const op = renderState.layerOpacities.get(l.id);
transforms[l.id] = {
x: rs ? rs.x : 0,
y: rs ? rs.y : 0,
opacity: op != null ? op : l.opacity,
};
}
lenticularTransformsMap.value = { ...lenticularTransformsMap.value, [assetId]: transforms };
}
lenticularRafId = requestAnimationFrame(tick);
};
lenticularRafId = requestAnimationFrame(tick);
}
function stopLenticularRenderLoop() {
if (lenticularRafId !== null) {
cancelAnimationFrame(lenticularRafId);
lenticularRafId = null;
}
}
//
const switchLikedTab = async (tab) => {
if (likedTab.value === tab) return;
@ -519,8 +683,17 @@ const loadExhibitedAssets = async () => {
expire_at: item.expire_at,
name: item.name,
slot_index: item.slot_index ?? 0,
is_lenticular: item.is_lenticular ?? false,
}))
.sort((a, b) => (a.slot_index ?? 0) - (b.slot_index ?? 0));
//
for (const item of exhibitionWorks.value) {
if (item.is_lenticular) {
loadLenticularLayersForAsset(item.id);
}
}
console.log('展出作品:', exhibitionWorks.value);
}
} catch (err) {
@ -562,6 +735,8 @@ const loadLikedAssets = async () => {
};
onMounted(() => {
initLenticularEngine();
startLenticularRenderLoop();
loadExhibitedAssets();
loadLikedAssets();
@ -583,6 +758,7 @@ onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer);
}
stopLenticularRenderLoop();
uni.$off('userInfoUpdated');
uni.$off('assetLiked');
});
@ -792,6 +968,18 @@ onShow(() => {
z-index: 2;
}
.card-lenticular {
width: 88%;
height: 93%;
left: 5%;
top: 4%;
border-radius: 24rpx;
transform-origin: center center;
position: relative;
z-index: 3;
overflow: hidden;
}
/* 领取收益按钮 */
.claim-reward-btn {
position: absolute;