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