From 5f4a443a93584084151f59fcb1a5da27b1054728 Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Mon, 18 May 2026 18:32:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=BD=99=E9=A2=9D?= =?UTF-8?q?=E4=B8=8D=E8=B6=B3=E4=B8=8D=E8=83=BD=E9=93=B8=E9=80=A0=E7=9A=84?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=8F=90=E7=A4=BA=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=AF=A6=E7=BB=86=E9=A1=B5=E9=9D=A2=E5=9B=BE=E7=89=87=E6=AD=A3?= =?UTF-8?q?=E5=B8=B8=E6=98=BE=E7=A4=BA=EF=BC=8Cdocker=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gateway/controller/gallery_controller.go | 2 + backend/gateway/dto/asset_converter.go | 1 + backend/gateway/dto/asset_dto.go | 1 + backend/gateway/dto/gallery_dto.go | 1 + backend/pkg/proto/asset/asset.pb.go | 13 +- backend/pkg/proto/gallery/gallery.pb.go | 14 +- backend/proto/asset.proto | 1 + backend/proto/gallery.proto | 1 + .../galleryService/config/gallery_config.go | 2 +- .../repository/gallery_repository.go | 1 + .../service/exhibition_service.go | 1 + .../service/starbook_service.go | 14 +- .../repository/fan_profile_repository.go | 6 +- docker/.env.prod | 4 + docker/docker-compose.prod.yml | 3 + docs/slot-refactor.md | 365 ++++++++++++++++++ .../castlove/lenticular/lenticular-result.vue | 10 +- frontend/pages/components/Header.vue | 2 +- frontend/utils/api.js | 4 +- 19 files changed, 430 insertions(+), 16 deletions(-) create mode 100644 docs/slot-refactor.md diff --git a/backend/gateway/controller/gallery_controller.go b/backend/gateway/controller/gallery_controller.go index dfca85f..80e3a1b 100644 --- a/backend/gateway/controller/gallery_controller.go +++ b/backend/gateway/controller/gallery_controller.go @@ -615,6 +615,7 @@ func (ctrl *GalleryController) GetMyExhibitedAssets(c *gin.Context) { ExhibitedAt: item.ExhibitedAt, ExpireAt: item.ExpireAt, Earnings: item.Earnings, + SlotIndex: item.SlotIndex, }) } @@ -803,6 +804,7 @@ func (ctrl *GalleryController) GetUserExhibitedAssets(c *gin.Context) { ExhibitedAt: item.ExhibitedAt, ExpireAt: item.ExpireAt, Earnings: item.Earnings, + SlotIndex: item.SlotIndex, }) } diff --git a/backend/gateway/dto/asset_converter.go b/backend/gateway/dto/asset_converter.go index 9efa59c..c97303f 100644 --- a/backend/gateway/dto/asset_converter.go +++ b/backend/gateway/dto/asset_converter.go @@ -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 diff --git a/backend/gateway/dto/asset_dto.go b/backend/gateway/dto/asset_dto.go index 92e6c8a..0b7b5f2 100644 --- a/backend/gateway/dto/asset_dto.go +++ b/backend/gateway/dto/asset_dto.go @@ -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 上传素材请求 diff --git a/backend/gateway/dto/gallery_dto.go b/backend/gateway/dto/gallery_dto.go index 1f414b6..e66ac66 100644 --- a/backend/gateway/dto/gallery_dto.go +++ b/backend/gateway/dto/gallery_dto.go @@ -80,6 +80,7 @@ type ExhibitedAssetItemDTO struct { ExhibitedAt int64 `json:"exhibited_at"` // 展出开始时间(毫秒时间戳) ExpireAt int64 `json:"expire_at"` // 展出过期时间(毫秒时间戳) Earnings int64 `json:"earnings"` // 当前可领取收益 + SlotIndex int32 `json:"slot_index"` // 展位序号 } // GetMyExhibitedAssetsResponseDTO 获取我展出的作品列表响应 diff --git a/backend/pkg/proto/asset/asset.pb.go b/backend/pkg/proto/asset/asset.pb.go index a8c994c..3f27348 100644 --- a/backend/pkg/proto/asset/asset.pb.go +++ b/backend/pkg/proto/asset/asset.pb.go @@ -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" + diff --git a/backend/pkg/proto/gallery/gallery.pb.go b/backend/pkg/proto/gallery/gallery.pb.go index 6c6b0aa..ce28950 100644 --- a/backend/pkg/proto/gallery/gallery.pb.go +++ b/backend/pkg/proto/gallery/gallery.pb.go @@ -1222,6 +1222,7 @@ type ExhibitedAssetItem struct { 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"` // 展位序号 unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1305,6 +1306,13 @@ func (x *ExhibitedAssetItem) GetEarnings() int64 { return 0 } +func (x *ExhibitedAssetItem) GetSlotIndex() int32 { + if x != nil { + return x.SlotIndex + } + return 0 +} + // 获取灵感瀑布藏品列表请求 type GetInspirationFlowRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1801,7 +1809,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\"\xfa\x01\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 +1818,9 @@ 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\"\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" + diff --git a/backend/proto/asset.proto b/backend/proto/asset.proto index 0d50599..224b767 100644 --- a/backend/proto/asset.proto +++ b/backend/proto/asset.proto @@ -360,6 +360,7 @@ message AssetMaterialRelation { double rotation = 10; double scale_x = 11; double scale_y = 12; + string oss_key = 13; } // 上传素材请求 diff --git a/backend/proto/gallery.proto b/backend/proto/gallery.proto index 80b5157..80fcd7f 100644 --- a/backend/proto/gallery.proto +++ b/backend/proto/gallery.proto @@ -207,6 +207,7 @@ message ExhibitedAssetItem { int64 exhibited_at = 5; // 展出开始时间(毫秒时间戳) int64 expire_at = 6; // 展出过期时间(毫秒时间戳) int64 earnings = 7; // 当前可领取收益 + int32 slot_index = 8; // 展位序号 } // ==================== 灵感瀑布相关消息 ==================== diff --git a/backend/services/galleryService/config/gallery_config.go b/backend/services/galleryService/config/gallery_config.go index 8842da3..5e5c91e 100644 --- a/backend/services/galleryService/config/gallery_config.go +++ b/backend/services/galleryService/config/gallery_config.go @@ -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,以此类推 diff --git a/backend/services/galleryService/repository/gallery_repository.go b/backend/services/galleryService/repository/gallery_repository.go index 12aa6b2..1e44def 100644 --- a/backend/services/galleryService/repository/gallery_repository.go +++ b/backend/services/galleryService/repository/gallery_repository.go @@ -99,6 +99,7 @@ type ExhibitedAssetInfo struct { ExhibitedAt int64 ExpireAt int64 Earnings int64 + SlotIndex int32 } // galleryRepository Repository实现 diff --git a/backend/services/galleryService/service/exhibition_service.go b/backend/services/galleryService/service/exhibition_service.go index 8806767..e9fdab6 100644 --- a/backend/services/galleryService/service/exhibition_service.go +++ b/backend/services/galleryService/service/exhibition_service.go @@ -216,6 +216,7 @@ func (s *exhibitionService) GetMyExhibitedAssets(ctx context.Context, userID, st ExhibitedAt: item.ExhibitedAt, ExpireAt: item.ExpireAt, Earnings: item.Earnings, + SlotIndex: item.SlotIndex, }) } diff --git a/backend/services/starbookService/service/starbook_service.go b/backend/services/starbookService/service/starbook_service.go index 07d5849..566c51f 100644 --- a/backend/services/starbookService/service/starbook_service.go +++ b/backend/services/starbookService/service/starbook_service.go @@ -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, diff --git a/backend/services/userService/repository/fan_profile_repository.go b/backend/services/userService/repository/fan_profile_repository.go index b41df0c..76821ca 100644 --- a/backend/services/userService/repository/fan_profile_repository.go +++ b/backend/services/userService/repository/fan_profile_repository.go @@ -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. 写入水晶流水(复式记账,包含余额快照) diff --git a/docker/.env.prod b/docker/.env.prod index 8099fc4..18684a6 100644 --- a/docker/.env.prod +++ b/docker/.env.prod @@ -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 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 0caec09..01fb9d3 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -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 diff --git a/docs/slot-refactor.md b/docs/slot-refactor.md new file mode 100644 index 0000000..0e01f5c --- /dev/null +++ b/docs/slot-refactor.md @@ -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 + + + + + + + + +``` + +**问题**:`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 + + + + + + +``` + +#### 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_index(1 或 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 应返回"已达到最大展位数" \ No newline at end of file diff --git a/frontend/pages/castlove/lenticular/lenticular-result.vue b/frontend/pages/castlove/lenticular/lenticular-result.vue index 71d1933..5f3c6fa 100644 --- a/frontend/pages/castlove/lenticular/lenticular-result.vue +++ b/frontend/pages/castlove/lenticular/lenticular-result.vue @@ -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) { diff --git a/frontend/pages/components/Header.vue b/frontend/pages/components/Header.vue index 244eadd..4606990 100644 --- a/frontend/pages/components/Header.vue +++ b/frontend/pages/components/Header.vue @@ -57,7 +57,7 @@ - + diff --git a/frontend/utils/api.js b/frontend/utils/api.js index 5b3988e..0a39c43 100644 --- a/frontend/utils/api.js +++ b/frontend/utils/api.js @@ -3,8 +3,8 @@ // 不需要手动注释! // #ifdef H5 -// const baseURL = 'http://192.168.110.60:8080' // H5 开发用本机 -const baseURL = 'http://101.132.250.62:8080' // H5 开发用本机 +const baseURL = 'http://192.168.110.60:8080' // H5 开发用本机 +// const baseURL = 'http://101.132.250.62:8080' // H5 开发用本机 // #endif // #ifdef APP-PLUS