feat: 新增余额不足不能铸造的功能提示,新增详细页面图片正常显示,docker修改配置

This commit is contained in:
zerosaturation 2026-05-18 18:32:58 +08:00
parent 02233db0ec
commit 5f4a443a93
19 changed files with 430 additions and 16 deletions

View File

@ -615,6 +615,7 @@ func (ctrl *GalleryController) GetMyExhibitedAssets(c *gin.Context) {
ExhibitedAt: item.ExhibitedAt, ExhibitedAt: item.ExhibitedAt,
ExpireAt: item.ExpireAt, ExpireAt: item.ExpireAt,
Earnings: item.Earnings, Earnings: item.Earnings,
SlotIndex: item.SlotIndex,
}) })
} }
@ -803,6 +804,7 @@ func (ctrl *GalleryController) GetUserExhibitedAssets(c *gin.Context) {
ExhibitedAt: item.ExhibitedAt, ExhibitedAt: item.ExhibitedAt,
ExpireAt: item.ExpireAt, ExpireAt: item.ExpireAt,
Earnings: item.Earnings, Earnings: item.Earnings,
SlotIndex: item.SlotIndex,
}) })
} }

View File

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

View File

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

View File

@ -80,6 +80,7 @@ type ExhibitedAssetItemDTO struct {
ExhibitedAt int64 `json:"exhibited_at"` // 展出开始时间(毫秒时间戳) ExhibitedAt int64 `json:"exhibited_at"` // 展出开始时间(毫秒时间戳)
ExpireAt int64 `json:"expire_at"` // 展出过期时间(毫秒时间戳) ExpireAt int64 `json:"expire_at"` // 展出过期时间(毫秒时间戳)
Earnings int64 `json:"earnings"` // 当前可领取收益 Earnings int64 `json:"earnings"` // 当前可领取收益
SlotIndex int32 `json:"slot_index"` // 展位序号
} }
// GetMyExhibitedAssetsResponseDTO 获取我展出的作品列表响应 // GetMyExhibitedAssetsResponseDTO 获取我展出的作品列表响应

View File

@ -2826,6 +2826,7 @@ type AssetMaterialRelation struct {
Rotation float64 `protobuf:"fixed64,10,opt,name=rotation,proto3" json:"rotation,omitempty"` 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"` 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -2944,6 +2945,13 @@ func (x *AssetMaterialRelation) GetScaleY() float64 {
return 0 return 0
} }
func (x *AssetMaterialRelation) GetOssKey() string {
if x != nil {
return x.OssKey
}
return ""
}
// 上传素材请求 // 上传素材请求
type UploadMaterialRequest struct { type UploadMaterialRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -3859,7 +3867,7 @@ const file_asset_proto_rawDesc = "" +
"\astar_id\x18\n" + "\astar_id\x18\n" +
" \x01(\x03R\x06starId\x12\x1d\n" + " \x01(\x03R\x06starId\x12\x1d\n" +
"\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" + "\x15AssetMaterialRelation\x12\x1f\n" +
"\vrelation_id\x18\x01 \x01(\x03R\n" + "\vrelation_id\x18\x01 \x01(\x03R\n" +
"relationId\x12\x19\n" + "relationId\x12\x19\n" +
@ -3876,7 +3884,8 @@ const file_asset_proto_rawDesc = "" +
"\brotation\x18\n" + "\brotation\x18\n" +
" \x01(\x01R\brotation\x12\x17\n" + " \x01(\x01R\brotation\x12\x17\n" +
"\ascale_x\x18\v \x01(\x01R\x06scaleX\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" + "\x15UploadMaterialRequest\x12\x17\n" +
"\aoss_key\x18\x01 \x01(\tR\x06ossKey\x12#\n" + "\aoss_key\x18\x01 \x01(\tR\x06ossKey\x12#\n" +
"\roriginal_name\x18\x02 \x01(\tR\foriginalName\x12\x1b\n" + "\roriginal_name\x18\x02 \x01(\tR\foriginalName\x12\x1b\n" +

View File

@ -1222,6 +1222,7 @@ type ExhibitedAssetItem struct {
ExhibitedAt int64 `protobuf:"varint,5,opt,name=exhibited_at,json=exhibitedAt,proto3" json:"exhibited_at,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"` // 展出过期时间(毫秒时间戳) 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"` // 当前可领取收益 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"` // 展位序号
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -1305,6 +1306,13 @@ func (x *ExhibitedAssetItem) GetEarnings() int64 {
return 0 return 0
} }
func (x *ExhibitedAssetItem) GetSlotIndex() int32 {
if x != nil {
return x.SlotIndex
}
return 0
}
// 获取灵感瀑布藏品列表请求 // 获取灵感瀑布藏品列表请求
type GetInspirationFlowRequest struct { type GetInspirationFlowRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -1801,7 +1809,7 @@ const file_gallery_proto_rawDesc = "" +
"\x04page\x18\x02 \x01(\x05R\x04page\x12\x1b\n" + "\x04page\x18\x02 \x01(\x05R\x04page\x12\x1b\n" +
"\tpage_size\x18\x03 \x01(\x05R\bpageSize\x12\x14\n" + "\tpage_size\x18\x03 \x01(\x05R\bpageSize\x12\x14\n" +
"\x05total\x18\x04 \x01(\x03R\x05total\x12\x19\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\"\xfa\x01\n" +
"\x12ExhibitedAssetItem\x12\x19\n" + "\x12ExhibitedAssetItem\x12\x19\n" +
"\basset_id\x18\x01 \x01(\x03R\aassetId\x12\x12\n" + "\basset_id\x18\x01 \x01(\x03R\aassetId\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" +
@ -1810,7 +1818,9 @@ const file_gallery_proto_rawDesc = "" +
"like_count\x18\x04 \x01(\x05R\tlikeCount\x12!\n" + "like_count\x18\x04 \x01(\x05R\tlikeCount\x12!\n" +
"\fexhibited_at\x18\x05 \x01(\x03R\vexhibitedAt\x12\x1b\n" + "\fexhibited_at\x18\x05 \x01(\x03R\vexhibitedAt\x12\x1b\n" +
"\texpire_at\x18\x06 \x01(\x03R\bexpireAt\x12\x1a\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\"\x9a\x01\n" +
"\x19GetInspirationFlowRequest\x12\x16\n" + "\x19GetInspirationFlowRequest\x12\x16\n" +
"\x06cursor\x18\x01 \x01(\tR\x06cursor\x12\x1c\n" + "\x06cursor\x18\x01 \x01(\tR\x06cursor\x12\x1c\n" +
"\tdirection\x18\x02 \x01(\tR\tdirection\x12\x14\n" + "\tdirection\x18\x02 \x01(\tR\tdirection\x12\x14\n" +

View File

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

View File

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

View File

@ -99,6 +99,7 @@ type ExhibitedAssetInfo struct {
ExhibitedAt int64 ExhibitedAt int64
ExpireAt int64 ExpireAt int64
Earnings int64 Earnings int64
SlotIndex int32
} }
// galleryRepository Repository实现 // galleryRepository Repository实现

View File

@ -216,6 +216,7 @@ func (s *exhibitionService) GetMyExhibitedAssets(ctx context.Context, userID, st
ExhibitedAt: item.ExhibitedAt, ExhibitedAt: item.ExhibitedAt,
ExpireAt: item.ExpireAt, ExpireAt: item.ExpireAt,
Earnings: item.Earnings, Earnings: item.Earnings,
SlotIndex: item.SlotIndex,
}) })
} }

View File

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

View File

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

View File

@ -24,3 +24,7 @@ OSS_TOKEN_EXPIRE_TIME=3600
# ==================== MiniMax API Configuration ==================== # ==================== MiniMax API Configuration ====================
MINIMAX_API_KEY=sk-cp-Fffv8Bg8zeFD929_KUAZq9EKet64Nkxgu7t1ibZEngqmyPKaOOa7U8U_gtg3VICfUQyGPn8c5XR4hxmWzjKC4wO6DxKh5ipN36Yv5jsFzZWMEPh6NKV2qAE MINIMAX_API_KEY=sk-cp-Fffv8Bg8zeFD929_KUAZq9EKet64Nkxgu7t1ibZEngqmyPKaOOa7U8U_gtg3VICfUQyGPn8c5XR4hxmWzjKC4wO6DxKh5ipN36Yv5jsFzZWMEPh6NKV2qAE
MINIMAX_API_URL=https://api.minimaxi.com/v1/image_generation 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 DB_NAME: topfans
USER_SERVICE_URL: tri://userservice:20000 USER_SERVICE_URL: tri://userservice:20000
ASSET_SERVICE_URL: tri://assetservice:20003 ASSET_SERVICE_URL: tri://assetservice:20003
TASK_SERVICE_URL: tri://taskservice:20006
depends_on: depends_on:
userservice: userservice:
condition: service_started condition: service_started
@ -290,6 +291,7 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-postgres123} DB_PASSWORD: ${DB_PASSWORD:-postgres123}
DB_NAME: topfans DB_NAME: topfans
USER_SERVICE_URL: tri://userservice:20000 USER_SERVICE_URL: tri://userservice:20000
GALLERY_SERVICE_URL: tri://galleryservice:20001
depends_on: depends_on:
userservice: userservice:
condition: service_started condition: service_started
@ -369,6 +371,7 @@ services:
DUBBO_ACTIVITY_SERVICE_URL: tri://activityservice:20004 DUBBO_ACTIVITY_SERVICE_URL: tri://activityservice:20004
DUBBO_TASK_SERVICE_URL: tri://taskservice:20006 DUBBO_TASK_SERVICE_URL: tri://taskservice:20006
DUBBO_STARBOOK_SERVICE_URL: tri://starbookservice:20007 DUBBO_STARBOOK_SERVICE_URL: tri://starbookservice:20007
REDIS_HOST: topfans-redis
depends_on: depends_on:
userservice: userservice:
condition: service_started 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

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

View File

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

View File

@ -3,8 +3,8 @@
// 不需要手动注释! // 不需要手动注释!
// #ifdef H5 // #ifdef H5
// const baseURL = 'http://192.168.110.60:8080' // H5 开发用本机 const baseURL = 'http://192.168.110.60:8080' // H5 开发用本机
const baseURL = 'http://101.132.250.62:8080' // H5 开发用本机 // const baseURL = 'http://101.132.250.62:8080' // H5 开发用本机
// #endif // #endif
// #ifdef APP-PLUS // #ifdef APP-PLUS