topfans/docs/slot-refactor.md

365 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 展位重构设计文档
## 背景与目标
### 当前问题
- 数据库每用户有 **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 应返回"已达到最大展位数"