topfans/docs/slot-refactor.md

11 KiB
Raw Blame History

展位重构设计文档

背景与目标

当前问题

  • 数据库每用户有 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. 每个用户独立,只能操作自己的展位

问题分析

当前前端空位逻辑(有问题)

<!-- 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 逻辑

// 原来:用 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. 空位显示逻辑

<!-- 计算哪些 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. 添加计算属性

// 加载展馆信息(用于确定空位)
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 参数

// openAssetSelector 现在接收 slot_index1 或 2
const openAssetSelector = (slotIndex = 0) => {
    currentSlotIndex.value = slotIndex;  // 现在是 slot_index 值
    showAssetSelector.value = true;
};

4. 修改 handleAssetSelect 匹配逻辑

// 原来:用 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;
    }
};

后端改动

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

改动:移除"放到他人展馆"的逻辑,只允许放到自己的展位

// 原来 (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("只能在自己的展位放置藏品")
}

位置: backend/services/galleryService/service/gallery_service.go:221-280

问题:当前逻辑允许在他人 public 空展位放置,与新设计冲突

// 原来(有问题的逻辑)
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"
}

位置: backend/services/galleryService/repository/gallery_repository.go:117-124

问题:查询可能返回超过 2 个槽位

// 原来
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
}

位置: 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

-- 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 处改动)

  1. 修改 gallery_config.go 配置值
    • InitialSlotCount: 2
    • MaxSlotCount: 2
    • 清空 UnlockLevelBySlotUnlockCrystalBySlot
  2. 修改 PlaceAsset 逻辑(移除他人展位)
  3. 修改 calculateOperation 逻辑(移除 public 放置权限)
  4. 修改 GetSlotsByUser 添加 LIMIT 2
  5. 重启后端服务

前端

  1. 添加 mySlots ref 存储槽位信息
  2. 添加 loadGalleryInfo() 获取展馆数据
  3. 添加 emptySlotIndices 计算属性
  4. 修改空位模板使用 emptySlotIndices
  5. 修改 handleAssetSelect 直接用 slot_index 匹配
  6. 测试:左右空位点击后正确放置

验证

  1. 多账号测试(各自独立)
  2. 空位放置测试
  3. 替换测试
  4. 浏览他人展馆(只读,不能放置)

风险评估

风险 级别 说明
数据库迁移 删除数据需谨慎,必须先备份
calculateOperation 修改 影响展位操作权限逻辑
GetSlotsByUser 添加 LIMIT 仅影响返回值数量,调用方逻辑不受影响
前端改动较多 涉及空位逻辑重构
PlaceAsset 简化 移除多余逻辑
向后兼容 新用户和老用户都是 2 槽

影响分析

会影响的现有功能

功能 影响 需测试
calculateOperation 改变他人 public 空展位放置权限
GetSlotsByUser 返回数量 可能影响其他使用该函数的地方
浏览他人展馆 can_operate 始终为 false 否(预期行为)

需要检查的其他调用方

# 检查 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 应返回"已达到最大展位数"