SceneView Lite 9000 端实战:从快照循环到离线授权的全链路复盘

这篇文章要复盘的是 9000 端(SceneView Lite)一次密集的迭代过程。它不是架构大改,而是一轮把之前已经跑通的主版能力(8000 端口那条线)往 lite 版本迁移,并在迁移过程中被真实用户反复提出细粒度反馈,最终修出一个真正可用的预览控制联动、部件级材质隔离、场景切换、断线重连和离线机器授权系统的完整实战记录。文章里我会从问题现象出发,讲到根因分析、方案选定、具体代码和踩坑路径,不跳过中间的失败判断,也不省略那些看起来很小但直接影响交付体验的细节。

这次迭代里,我们修复了至少十五个关键问题:预览端无限刷新循环、控制端重连后丢控制权、切换场景导致快照重放、部件材质串色、编辑器默认场景加载、后端场景管理接口分页、编辑器顶部按钮裁切、预览端透明背景、`9999` 参数控制按钮可见性、控制页场景切换弹窗、WebSocket 断线 5 秒自动重连、保存场景语义从新增退回修改、以及从零搭建离线单机授权机制(含机器指纹提取、离线密钥生成、授权码校验和配置文件单字段)。这些问题每一个单独看都不算"惊天动地",但合在一起,才是 lite 版从"能跑"到"能用"的全部台阶。

一、lite 版的定位:不是主版的缩减,而是场景切换能力的入口

在开始这篇复盘之前,有必要先交代一下 lite 版在整个产品线中的位置。8000 主版是一套完整的 3D 编辑器 + 控制页 + 预览页系统,能力丰富,但启动成本也高。用户明确提出了一个更轻的需求:能不能有一个版本,不要求用户先去编辑器里创建 live 会话,而是直接给一个 URL 就能进控制页和预览页?而且在控制页里能切换场景,切换后所有跟随的预览端也要跟着切过去?这就是 lite 版的核心使命。

lite 版的代码仓库独立于主版,但共享大量公共组件和逻辑。它的前端是 Vue 3 + Vite,后端是 Go + GoFrame,通信走 WebSocket。整个架构并不复杂,真正复杂的地方在于把主版已经跑通的逻辑搬到 lite 版时,如何确保场景切换、控制权限、断线重连和快照恢复这些行为都能在新环境里正确工作。lite 版不是一个"删减版主版",而是一个"以场景切换和 URL 直接进入为核心入口"的轻量化系统。这个定位从一开始就决定了后面的很多实现不会和主版完全一样。

工程上最值得注意的一点是:lite 版的控制页和预览页共用同一个后端 WebSocket 频道。当控制端发出 `scene:switch` 时,后端需要把它广播到频道内所有预览端,同时处理快照历史,避免后续的连接重放造成问题。这条链路如果设计不当,任何一个环节都可能引发连锁反应。而我们在迭代过程中也确实碰到了这种连锁问题。

二、第一个循环:page:refresh 被写入快照历史,导致预览端重连后无限刷新

这是本次迭代中最早发现也最影响体验的一个问题。现象是:控制端点了"刷新页面"按钮之后,预览端如果因为网络波动或手动刷新重新连接 WebSocket,它会立刻被后端下发的快照消息触发又一次 `page:refresh`,然后再次刷新,再次重连,再次收到快照中的 `page:refresh`,循环往复,完全停不下来。只有在控制端自己退出(断开 WebSocket 连接)后,后端的频道历史才会被清理掉,这个循环才会消失。

这个问题的排查路径其实非常经典。第一直觉是"预览端是不是每次重连接受了不该接受的消息"。于是我检查了预览端的 WebSocket 消息处理逻辑,发现它在收到 `session:snapshot` 时,会把快照中的 `commands` 数组逐条执行。这个行为本身没问题,因为快照的目的就是让新连接者恢复到当前会话状态。但如果快照里残留了一条"刷新页面"的命令,那新连接者就会无条件执行它。

// PreviewPage.vue — 快照处理逻辑
ws.onmessage = (event) => {
  const cmd = JSON.parse(event.data)
  if (cmd.type === 'session:snapshot') {
    const commands = Array.isArray(cmd.commands) ? cmd.commands : []
    for (const item of commands) {
      applyCommand(item)
    }
    return
  }
  applyCommand(cmd)
}

function applyCommand(cmd) {
  if (cmd.type === 'page:refresh') {
    window.location.reload()
    return
  }
  // ...
}

这段代码本身是对的吗?是的。预览端收到快照后执行快照中的命令,这是正确行为。问题出在后端:`page:refresh` 不应该被写入频道历史。

在后端的 WebSocket 控制器里,所有控制端发出的命令默认都会被追加到 `hub.history[channelID]` 中。当新的预览端连接时,后端会把整个历史打包成 `session:snapshot` 发过去。如果历史里包含了 `page:refresh`,那每个新连接的预览端都会无条件刷新一次页面。而刷新后又重连,重连后又收到同一个快照,然后又刷新——这就是那个无限循环的完整闭环。

// control.go — 修改前的问题代码
default:
  if role != "control" {
    continue
  }
  if hub.currentController(channelID) != client.ID {
    _ = writeControlEvent(conn, "error",
      map[string]any{"code": "NOT_CONTROLLER", "message": "no permission"})
    continue
  }
  hub.remember(channelID, payload) // page:refresh 被写入了历史
  hub.broadcastExcept(channelID, conn, payload)
}

修复方案非常直接:在 `default` 分支里,对 `page:refresh` 这类瞬态命令,不走 `hub.remember()`:

// control.go — 修复后
default:
  if role != "control" {
    continue
  }
  if hub.currentController(channelID) != client.ID {
    _ = writeControlEvent(conn, "error",
      map[string]any{"code": "NOT_CONTROLLER", "message": "no permission"})
    continue
  }
  if messageType != "page:refresh" {
    hub.remember(channelID, payload)
  } else {
    // Skip transient refresh commands so reconnecting previews
    // do not loop on snapshot replay.
  }
  hub.broadcastExcept(channelID, conn, payload)
}

这个改动只有三行,但效果立竿见影。修复之后,`page:refresh` 只会被实时广播给当前在线的预览端,不会被记入频道历史。新的连接者收到快照时里面不会有 `page:refresh`,自然不会触发无限刷新循环。控制端自己刷新页面时,在线的预览端也会跟着刷新一次——但刷新后不会陷入循环,因为快照里已经不残留这个命令了。

这个 bug 给的经验教训是:在 WebSocket 快照系统里,不是所有命令都适合被持久化到历史。瞬态命令(如刷新、一次性提示、临时状态切换)通常只需要实时广播,不应该被快照携带给后续连接者。如果你的系统有快照回放机制,一定要对每种命令类型判断它是否应该出现在快照里。

三、第二个循环:scene:switch 同样被写入快照历史,控制端在线时预览端持续刷新

修复了 `page:refresh` 之后,我们发现还有一个类似的循环。这次的主角是 `scene:switch`。场景切换的逻辑是:控制端在场景选择弹窗里选中一个场景,发出 `scene:switch` 命令,后端广播给所有预览端,预览端收到后把 URL 里的 `sceneId` 改成新场景的 ID,然后刷新页面加载新场景。

问题出在:控制端在线时,预览端如果因为某种原因重连(可能是短暂网络抖动、可能是用户手动刷新),它会从后端收到包含 `scene:switch` 的快照。预览端执行快照中的 `scene:switch`,跳转到新场景,页面刷新后又重连,又收到快照,又跳转——只要控制端还连着(频道历史还没清空),这个循环就不会停。而当控制端退出后,后端的 `hub.remove()` 会删除整个频道历史,循环才会消失。

这和 `page:refresh` 的问题一模一样,但影响范围更大。因为场景切换是 lite 版的核心能力之一,只要用户用过场景切换,就可能触发这个循环。而且 `scene:switch` 的刷新行为不仅重新加载页面,还会重新发起 WebSocket 连接,这让循环的发生概率更高。

修复 `scene:switch` 循环时,我选择了比 `page:refresh` 更激进的处理方式。`page:refresh` 只是不写入历史但保留之前的其他命令,而 `scene:switch` 不仅不写入历史,还应该清空当前频道历史。原因是:场景切换是一个"边界事件"。切换前的所有命令都属于旧场景,切换到新场景后,旧场景的操作命令对新场景没有任何意义。如果不做清空,新场景的预览端会收到一堆旧场景的相机位置、模型选中、热点打开等命令,这些命令要么无效,要么会产生意外行为。

// control.go — scene:switch 的处理逻辑
if messageType == "scene:switch" {
  // Scene switch is a one-shot navigation event. Do not keep it in
  // snapshot history, otherwise reconnecting previews will replay it
  // and trigger repeated reload loops.
  hub.resetHistory(channelID)
} else if messageType != "page:refresh" {
  hub.remember(channelID, payload)
} else {
  // Skip transient refresh commands so reconnecting previews
  // do not loop on snapshot replay.
}
hub.broadcastExcept(channelID, conn, payload)

这里 `hub.resetHistory()` 的实现也非常简单,就是把频道的历史置为 nil:

func (h *controlHub) resetHistory(channelID string) {
  h.mu.Lock()
  defer h.mu.Unlock()
  h.history[channelID] = nil
}

这个改动解决了两个问题。第一,`scene:switch` 不会出现在快照里,所以新连接的预览端不会因为快照回放而跳转到另一个场景。第二,切换场景时清空频道历史,新场景从一张白纸开始记录命令,不会收到旧场景的操作残留。

但我后来意识到,只修后端还不够。即使 `scene:switch` 不进历史了,如果控制端在切换场景时连续发了多个不同的场景切换命令(比如快速切换 3 次),后端会依次清空历史并广播 3 次。预览端如果网络稍慢,可能会先后收到这 3 次广播。每次都会跳转刷新。所以还需要在预览端加一层保护。

四、预览端防重复跳转:同场景不跳、同用户不刷新

预览端收到 `scene:switch` 时的行为是:构造新的 URL(把 `sceneId` 参数更新为目标场景 ID),然后 `window.location.href = newUrl` 触发页面跳转。这个跳转本身是对的,但如果收到的场景 ID 和当前场景 ID 完全一样,那这次跳转就是多余的。它不仅浪费时间,还会打断用户的浏览体验。

所以在 `PreviewPage.vue` 的 `reloadToScene()` 函数里,我加了一个前置判断:如果目标场景 ID 已经等于当前场景 ID,并且目标 userId 也和当前一致,那就直接 return,不做任何操作。

function reloadToScene(nextSceneId, nextUserId) {
  if (!nextSceneId) {
    return
  }

  const currentUserId = String(route.query.userId || '')
  if (String(nextSceneId) === String(sceneId.value) &&
      String(nextUserId || '') === currentUserId) {
    return
  }

  window.location.href = buildSceneLocation(nextSceneId, nextUserId)
}

这个看似不起眼的判断,实际上同时防住了好几个场景的问题。第一,如果后端因为某种原因重发了重复的 `scene:switch`,预览端不会反复跳转。第二,如果控制端切换到的场景本身就是预览端已经在看的场景(比如控制端也在同一个场景里操作),预览端不会刷新。第三,如果控制端在切换场景后,后端快照中仍然残留着旧 `scene:switch`(在某些竞态条件下),预览端的同场景保护也能兜住。

这种"前后端双重保险"的思路在这次迭代里反复出现。后端负责保证快照历史的正确性,前端负责在收到消息时做最后一道合理性校验。两者缺一不可。如果只靠后端,一旦后端逻辑出现竞态或 bug,前端没有兜底,用户体验会立刻恶化。如果只靠前端,后端仍然在往快照里塞不该塞的东西,历史会越来越大,内存会越来越多重播行为,最终仍然会出问题。

五、控制端重连后丢控制权:自动补申请

后端重启或者网络重连后,另一个常见问题是控制端失去了"控制者"身份。这是因为后端的服务端控制器是无状态的,频道内的"当前控制者"完全保存在内存中。一旦 WebSocket 断开重连(不论是因为后端重启还是网络波动),频道内的控制权状态就丢失了。

控制页的消息发送逻辑里有一个关键保护:

function sendCommand(payload) {
  if (!canControl.value) return
  applyLocalCommand(payload)
  if (!ws || ws.readyState !== WebSocket.OPEN) return
  ws.send(JSON.stringify(payload))
}

这里的 `canControl` 是一个 computed 属性,当 `myRole` 为 `'controller'` 时才为 true。重连后如果控制端收到 `session:snapshot` 里 `controllerClientId` 为空(意味着当前没有人持有控制权),控制端会变成 `viewer`,`canControl` 为 false,所有消息都会被静默吞掉——用户点击拖动模型、切视角、开关动画,预览端全部收不到。

这看起来像是控制端"坏了",但实际上是因为它没有拿到控制权。修复方式是在收到 `session:snapshot` 后,如果发现当前频道没有控制者,并且 WebSocket 已连接,控制端自动发一次 `control:request`:

function applySessionSnapshot(snapshot = {}) {
  myClientId.value = snapshot.myClientId || snapshot.my_client_id || myClientId.value
  controllerClientId.value = snapshot.controllerClientId || snapshot.controller_client_id || ''
  const role = snapshot.myRole || snapshot.my_role
  myRole.value = role || (controllerClientId.value &&
    controllerClientId.value === myClientId.value ? 'controller' : 'viewer')

  // If nobody currently holds control and we're connected, auto-request it
  if (!controllerClientId.value && wsConnected.value) {
    requestControl()
  }

  if (!canControl.value) {
    resetInteractiveModes()
  }
}

function requestControl() {
  if (!ws || ws.readyState !== WebSocket.OPEN) return
  ws.send(JSON.stringify({ type: 'control:request' }))
}

这个修复的精妙之处在于:它不会无脑抢控制权。只有在当前频道没有任何人持有控制权(`!controllerClientId.value`)的情况下,才自动申请。如果已经有另一个控制者在操作,新连接的控制端仍然保持 `viewer` 角色,遵循正常的"申请-同意"流程。这个设计同时保证了"重连后自动恢复"和"不干扰已有控制者"两个目标。

为了验证这个修复,我甚至写了一个小型的本地调试工具 `ws-debug`。它用 Go 模拟控制端和预览端的 WebSocket 客户端,直接连到本地 9000 端口,验证快照返回和消息广播链路是否正确。

// 模拟 control 角色连接
u := url.URL{Scheme: "ws", Host: "127.0.0.1:9000", Path: "/ws/control"}
q := u.Query()
q.Set("id", "debug-channel")
q.Set("role", "control")
u.RawQuery = q.Encode()

conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
// 读取第一条消息 — 应该是 session:snapshot
_, payload, _ := conn.ReadMessage()
var msg map[string]any
json.Unmarshal(payload, &msg)
// msg["myRole"] 应该是 "controller"
// msg["controllerClientId"] 应该等于 msg["myClientId"]

验证结果确认:控制端连接后,后端返回 `myRole: "controller"` 和 `control:granted(reason: "free")`。说明后端控制权分配逻辑本身是正确的。问题确实出在前端重连后的状态恢复链路上。修复后的自动申请机制能够确保控制端在重连后自动拿回控制权,不需要用户手动点"申请控制"按钮。

六、WebSocket 断线重连:5 秒自动重试机制

在修复完控制权丢失问题后,我们发现还需要一个更基础的机制:WebSocket 断线后自动重连。之前的实现中,WebSocket 一旦断开,页面不会尝试重连。用户只能手动刷新页面。这在网络不稳定的场景下非常不舒服。

控制页和预览页都统一加入了断线自动重连机制:WS 断开后启动 5 秒定时器,每 5 秒尝试重连一次,直到连接成功:

let reconnectTimer = null
const reconnectDelay = 5000

function clearReconnectTimer() {
  if (!reconnectTimer) return
  window.clearTimeout(reconnectTimer)
  reconnectTimer = null
}

function scheduleReconnect() {
  if (destroyed || reconnectTimer) return
  reconnectTimer = window.setTimeout(() => {
    reconnectTimer = null
    connectWs()
  }, reconnectDelay)
}

ws.onclose = () => {
  wsConnected.value = false
  myRole.value = 'viewer'
  controllerClientId.value = ''
  ws = null
  scheduleReconnect()
}

这里的几个细节值得注意。第一,`destroyed` 标志在组件卸载时置为 true,确保 `onUnmounted` 后不再发起重连。第二,`scheduleReconnect` 里检查 `reconnectTimer` 防止同时发起多个重连定时器。第三,`onclose` 里把控制权状态重置为 `viewer`,确保重连期间控制端不会显示错误的控制状态。第四,`clearReconnectTimer` 在成功连接时调用,防止重连成功后仍然有挂着的定时器。

预览端也用完全相同的重连策略。这样即使网络波动导致 WebSocket 断开,两端都会在 5 秒后自动恢复连接。加上前面提到的自动申请控制权机制,整个链路现在能够在断线后自动恢复到可操作状态,不需要任何人工干预。

七、场景切换:控制端的场景选择弹窗与 URL 跳转

lite 版的场景切换是这个版本的核心能力之一。控制端需要在场景列表里选择一个场景,然后通知所有预览端跟着切换过来。这个功能的完整链路包含:场景列表 API 请求 → 弹窗 UI 展示 → 选中后发送 `scene:switch` → 后端广播 → 预览端跳转 → 控制端自己也跳转。

控制端的场景切换弹窗有几个 UI 和交互上的特殊要求。第一,缩略图+名称卡片,每行 5 个、最多 2 行,不出现滚动条。第二,搜索框和按钮同一行,搜索框与列表之间有间隙。第三,分页样式和主版编辑器的场景管理弹窗一致。第四,分页按钮背景改成黑色。第五,分页时总数必须走后台接口 `count` 字段,不接受前端自行计算。

场景切换的发送逻辑:

function switchSceneByOption(scene) {
  if (!scene?.id) return
  sceneDialogVisible.value = false
  sendCommand({
    type: 'scene:switch',
    sceneId: String(scene.id),
    userId: userId.value || '',
  })
  reloadToScene(scene.id)
}

这里 `sendCommand` 会先把命令发给后端(广播给所有预览端),然后控制端自己也执行 URL 跳转。预览端收到后也会跳转。两边跳转后各自会重新加载页面并重建 WebSocket 连接。后端在收到 `scene:switch` 时会清空频道历史,然后只广播这条切换命令(不记入历史),确保新连接的预览端不会收到旧的切换命令。

场景列表的数据请求:

async function fetchSceneOptions(page = scenePage.value) {
  const params = new URLSearchParams({
    page: String(page),
    limit: String(scenePageSize),
  })
  if (sceneKeyword.value.trim()) {
    params.set('name', sceneKeyword.value.trim())
  }
  if (userId.value) {
    params.set('userId', userId.value)
  }

  const res = await fetch(buildApiUrl(`/3d/api/userScene/pb/page?${params}`))
  const payload = await res.json()
  const rawData = payload.data ?? payload
  const list = Array.isArray(rawData) ? rawData :
    Array.isArray(rawData?.records) ? rawData.records :
    Array.isArray(rawData?.list) ? rawData.list :
    Array.isArray(rawData?.rows) ? rawData.rows : []

  sceneOptions.value = list.map(item => ({
    id: item.id,
    name: item.name || item.title || '',
    image: normalizeUrl(item.image || item.thumbnail || ''),
  }))

  // 总数从后端 count 字段获取
  const totalCount = payload.count ?? rawData?.count ??
    rawData?.total ?? rawData?.length ?? list.length ?? 0
  sceneTotal.value = Number(totalCount)
}

这个请求走的是后端真实的分页接口,不是前端本地过滤。这样能保证数据量大时查询效率可控,也符合用户对"搜索必须走后台真实查询"的明确要求。分页的总数直接取后端返回的 `count` 字段,确保前端不需要自己统计。

八、控制页顶部按钮:只在特定 URL 参数下显示

lite 版控制页有一个特殊的显示控制需求:左上角的"刷新页面"和"切换场景"两个浮动按钮,只有在 URL 参数为 `9999` 时才显示,否则隐藏。这是为了让最终用户看到的控制页是纯净的,只有运维或演示人员需要快速刷新场景或切换场景时才看到这些按钮。

实现方式是读取 URL 中 `action`、`controlMode`、`mode` 三个参数的值,任意一个为 `9999` 都视为"运维模式":

const isDebugMode = computed(() => {
  const mode1 = String(route.query.action || '')
  const mode2 = String(route.query.controlMode || '')
  const mode3 = String(route.query.mode || '')
  return mode1 === '9999' || mode2 === '9999' || mode3 === '9999'
})

模板中:

<div class="floating-action-group" v-if="isDebugMode">
  <el-button @click="refreshAllPages">刷新页面</el-button>
  <el-button @click="openSceneSwitcher">切换场景</el-button>
</div>

这个参数兼容 3 个名字是为了和已有的多种进入方式兼容。lite 版的链接可能来自不同的入口(编辑器跳转、直接访问、第三方嵌入),参数名字未必统一。兼容 `action`、`controlMode`、`mode` 三个名字,确保无论哪种方式进入,只要参数值正确,按钮都能正常显示。

同时还有一个额外的控制:如果控制页是从编辑器跳转进来的(不带场景切换意图),也不显示"切换场景"按钮。这是通过判断 URL 是否来自编辑器路径来实现的。只有真正需要场景切换能力的页面,才会展示这个入口。

九、预览端透明背景:从黑底到透明

预览端最初使用的是黑色背景,和编辑器、控制页保持一致。但用户提出:预览页需要透明背景。这个需求背后的原因是:预览端可能被嵌入到其他页面中(比如作为 iframe 放入官网、产品页或演示 PPT 中),黑色背景会在浅色页面上显得非常突兀。

预览端透明背景的实现需要同时修改 Three.js 的渲染器和 CSS:

// EditorViewport.vue — 新增 transparentBackground 属性
const props = defineProps({
  readonly: Boolean,
  transparentBackground: Boolean,
  readonlyWebpageInteractive: Boolean,
})

// WebGL 渲染器配置
renderer = new THREE.WebGLRenderer({
  antialias: true,
  alpha: props.transparentBackground,
})
renderer.setClearColor(0x000000, props.transparentBackground ? 0 : 1)

CSS 侧:

.editor-viewport.is-transparent {
  background: transparent;
}

.preview-page {
  width: 100%;
  height: 100%;
  position: relative;
  background: transparent;
}
<!-- PreviewPage.vue -->
<EditorViewport
  ref="viewportRef"
  readonly
  transparent-background
  :readonly-webpage-interactive="webpageInteractive"
/>

这里有四个关键点。第一,WebGLRenderer 的 `alpha` 选项必须为 true 才支持透明画布。第二,`setClearColor(0x000000, 0)` 的第二个参数是透明度 alpha 值,为 0 时表示完全透明,每帧清屏后画布是空的而不是黑的。第三,CSS 的 `.preview-page` 和 `.editor-viewport.is-transparent` 也需要配合设为透明,否则 DOM 容器本身会有背景色。第四,控制页和编辑器的默认背景不受影响,它们不传 `transparent-background` 属性,保持原有的深色背景。

这个修改虽然不大,但它让 lite 版预览端能够真正作为嵌入式组件使用。透明背景意味着预览端在任何浅色网页里都不会有一层突兀的黑框,而是只渲染模型和灯光本身,周围区域完全透明。

十、部件材质串色问题:cloneObjectMaterials 隔离

这是从主版迁移到 lite 版时碰到的另一个经典问题。现象是:在 lite 编辑器中修改一个部件的颜色时,整个模型的所有部件都会一起变色。这显然不是期望的行为:用户只想改一个部件,却影响了整个模型。

这个问题的根因非常经典。Three.js 的 GLTF 加载器在加载一个模型时,模型内所有 mesh 可能会共享同一个 `material` 实例。GLTF 格式为了减少体积,会在多个 part 引用相同材质时只创建一份 material。这本身是合理的优化。但如果用户想单独修改某个 part 的颜色,直接修改 `mesh.material.color` 就会影响到所有共享这个 material 的其他 part。

修复方案是:在模型加载后,克隆场景中每一个 mesh 的 `material`,确保每个 mesh 持有独立的材质实例:

// EditorViewport.vue — 材质实例隔离
function cloneObjectMaterials(root) {
  root.traverse((child) => {
    if (child.isMesh && child.material) {
      child.material = child.material.clone()
    }
  })
}

// 模型加载后调用
const clonedScene = skeletonUtils.clone(gltf.scene)
cloneObjectMaterials(clonedScene)
scene.add(clonedScene)

但只做克隆还不够。lite 版的材质编辑功能需要记住每部分材质的原始状态,以便用户点击"重置"时能恢复。所以在克隆之后,还需要捕获完整的原始材质快照:

function captureOriginalMaterialState() {
  // 为每个部件记录原始材质属性
  // 包括 color, map, envMap, metalness, roughness, opacity 等
  // 同时记录 reset 恢复逻辑
}

function applyMaterialConfigToMaterial(material, config, reset = false) {
  if (reset) {
    // 恢复到原始快照中的值
    const original = originalMaterialStates.get(material)
    if (original) {
      material.color.copy(original.color)
      material.opacity = original.opacity
      material.metalness = original.metalness
      material.roughness = original.roughness
      material.map = original.map
      material.envMap = original.envMap
    }
    return
  }

  // 应用新的材质配置
  if (config.color) material.color.set(config.color)
  if (config.map) material.map = config.map
  if (config.envMap) material.envMap = config.envMap
  if (Object.prototype.hasOwnProperty.call(config, 'opacity'))
    material.opacity = config.opacity
  if (Object.prototype.hasOwnProperty.call(config, 'metalness'))
    material.metalness = config.metalness
  if (Object.prototype.hasOwnProperty.call(config, 'roughness'))
    material.roughness = config.roughness

  material.needsUpdate = true
}

颜色修改的立即写入逻辑:

function updateSelectedModelMaterialImmediately(partName, config) {
  const model = selectedModel.value
  if (!model) return
  applyMaterialConfigToMaterial(
    model.getMaterialByPartName(partName),
    config
  )
}

这次修复参考了 8000 主版中对齐的实现。核心思路就两条:第一条是加载后立即做 `cloneObjectMaterials`,把共享材质拆开。第二条是补齐原始材质快照 + reset 恢复逻辑,确保每个部件的修改和还原都能正确工作。缺少任何一条,效果都会打折。

十一、保存语义回归:从新增退回修改

在之前的迭代中,`sceneStore.saveSceneData()` 不小心走了 `sceneApi.saveData()`,而 `saveData` 对应的是 `POST /3d/api/userScene`——新增场景的 API 语义。这意味着每次点"保存"都会创建一个新的场景记录,而不是更新已有的场景。

修复方案非常直接:改回 `sceneApi.update()`:

// sceneStore.js — 修复后
async function saveSceneData(sceneId) {
  const scene = scenes.find(s => s.id === sceneId || s.id === currentSceneId.value)
  if (!scene) return

  const jsonData = serializeScene(scene)

  await sceneApi.update({
    id: scene.serverId || scene.id,
    name: scene.name,
    json_data: JSON.stringify(jsonData),
    thumbnail: scene.thumbnail || '',
  })
}

这个 bug 看起来很小,但它的影响很大。如果用户在编辑器里反复修改同一个场景并保存,后台会创建大量重复的场景记录。对于数据量大的系统,这种"隐性新增"会逐渐积累成真实的数据问题。修复后,保存操作正确地更新已有场景,不再产生新记录。

十二、编辑器默认场景加载:无 sceneId 时加载最新的场景

当用户通过编辑器 URL 进入但没有显式指定 `sceneId` 时,编辑器应该默认加载最新创建的一个场景,而不是显示空白页面。这是因为编辑器的核心用户是创作者,他们通常想继续编辑最近的项目,或者从最新的模板开始工作。

// EditorLayout.vue — 默认场景加载逻辑
async function resolveInitialScene() {
  const sceneIdFromUrl = getSceneIdFromRoute(route)
  if (sceneIdFromUrl) {
    // 有显式 sceneId,优先加载指定场景
    currentSceneId.value = String(sceneIdFromUrl)
    return
  }

  // 无显式 sceneId,获取最新的场景(按 serverId/id 最大值)
  const list = await sceneApi.list({ page: 1, size: 1 })
  if (list && list.length > 0) {
    const latest = list[0]
    currentSceneId.value = String(latest.id)
    return
  }

  // 没有任何场景,提示创建新场景
}

这个逻辑同时保证了两个行为:有 `sceneId` 时优先加载指定场景;没有时加载最新的。加载最新的方式是按 `serverId/id` 最大值选择,确保时间上最新的场景排在最前面。

十三、编辑器 UI 裁剪:撤销、重做、投影、快速模板和场景统计

这次迭代中编辑器做了几处 UI 裁剪。第一,移除顶部"撤销"和"重做"按钮。lite 版定位是轻量编辑器,撤销重做功能在当前阶段暂时不启用。第二,右侧面板灯光区域移除"投影"开关。这个功能在当前版本中不需要展示给用户。第三,右侧面板移除"快速模板"入口。第四,右侧面板移除"场景统计"面板。这些裁剪都是为了让 lite 版的界面更清爽,只保留当前核心场景需要的功能。

裁剪 UI 时需要注意一点:不能删掉功能的同时留下残留的 DOM 节点或 CSS 类。否则用户虽然看不见,但仍然可能通过这些残留元素产生意外交互。所以每次裁剪时,我都把模板、逻辑和数据定义一起清理,不留死角。

十四、新增模型的"镜头飞走"问题:focusOnModel 实时计算包围盒

lite 版编辑器新增模型时,相机镜头会"飞走"到一个很远的位置。这个问题的根因是:`focusOnModel()` 函数在聚焦模型时,使用了缓存的包围盒数据,而这个数据在模型刚加载时尚未更新。

修复方案是每次聚焦时都实时计算 `Box3`:

function focusOnModel(modelGroup) {
  if (!modelGroup) return
  const box = new THREE.Box3().setFromObject(modelGroup)
  const center = box.getCenter(new THREE.Vector3())
  const size = box.getSize(new THREE.Vector3())

  const maxDim = Math.max(size.x, size.y, size.z)
  const fov = camera.fov * (Math.PI / 180)
  let distance = maxDim / (2 * Math.tan(fov / 2))
  distance *= 1.2 // 留一点余量

  camera.position.set(
    center.x + distance * 0.5,
    center.y + distance * 0.3,
    center.z + distance
  )
  camera.lookAt(center)
  camera.updateProjectionMatrix()
  controls.target.copy(center)
  controls.update()
}

改用 `new THREE.Box3().setFromObject(group)` 实时计算包围盒后,新增模型时相机会正确地定位到模型附近,不再飞走。这也是一个典型的"缓存数据过期"问题:模型刚加载时,之前缓存的包围盒值是旧的(或者干脆不存在的),直接使用会导致错误的相机定位。

十五、场景管理分页和搜索:后端接口支撑

编辑器和控制页的场景管理弹窗都进行了分页和搜索改造。分页固定每页 10 个、5 列 2 行布局。搜索框旁边添加搜索按钮。搜索结果必须走后台真实查询,不接受仅前端筛选。

编辑器场景管理:

// EditorRightPanel.vue — 场景搜索改为后台查询
async function searchScenes(keyword, page = 1) {
  const params = { page, size: 10 }
  if (keyword) {
    params.keyword = keyword
  }
  const result = await sceneApi.list(params)
  sceneList.value = result.records || []
  sceneCount.value = result.count || 0
}

lite 版本的场景搜索也采用同样的模式。搜索按钮点击后触发后端查询,参数中包含 `name`(搜索关键词)和分页信息。后端根据关键词过滤,返回匹配的记录和总数。

十六、场景管理弹窗样式:5 列 2 行,无滚动条,分页按钮黑色

场景切换弹窗的样式有几个关键约束:5 列、最多 2 行、无滚动条、分页按钮背景黑色。这是为了在有限的弹窗空间内展示最多的场景选项,同时保持分页按钮与整体暗色主题的一致性。

CSS 实现思路:

.scene-dialog-grid {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  grid-template-rows: repeat(2, 1fr);
  gap: 10px;
  max-height: none; /* 不允许滚动 */
  overflow: hidden;
}

.scene-dialog-grid .page-btn {
  background: #1c1c1c;
  color: #ececec;
  border: 1px solid #474747;
  border-radius: 4px;
}

.scene-dialog-grid .page-btn:hover {
  background: #2a2a2a;
}

.scene-dialog-grid .page-btn.active {
  background: #000000;
  border-color: #667eea;
}

这个布局确保了用户在一个屏幕上就能看到最多 10 个场景的缩略图,不需要滚动就能做出选择。分页按钮使用黑色背景,与控制页和操作工具栏的整体暗色风格保持一致。

十七、控制页场景切换按钮位置:移到"刷新页面"旁边

lite 版控制页最初的"切换场景"按钮放在右侧菜单顶部。但用户反馈说希望它和"刷新页面"按钮在一起,放在左上角浮动按钮区。这个改动让用户能够在一个固定的位置找到所有页面级操作(刷新和切换场景),不需要在右侧菜单和顶部按钮之间来回找。

同时,当 URL 参数不是 `9999` 时,这两个按钮都隐藏。只有在运维模式下,控制页的顶部才会出现这两个快捷操作按钮。

<!-- ControlPage.vue — 顶部浮动按钮区 -->
<div class="floating-action-group" v-if="isDebugMode">
  <el-button class="floating-refresh-btn" @click="refreshAllPages">
    刷新页面
  </el-button>
  <el-button class="floating-scene-switch-btn" @click="openSceneSwitcher">
    切换场景
  </el-button>
</div>

右侧控制条顶部的原"切换场景"按钮已被移除。现在场景切换能力完全集中到左上角,只在运维模式下可见。

十八、EditorHeader.vue 联动预览改为新控制页

编辑器的"联动预览"功能原来跳到主版的 live 控制地址。但 lite 版有自己的控制页(路由 `/control/:id`),所以 `EditorHeader.vue` 中联动预览的链接需要指向新的控制页。

// EditorHeader.vue — 联动预览改为 /control/:id
function openLiveControl() {
  const sceneId = currentSceneId.value
  const id = channelId.value || 'test001'
  window.open(`/control/${id}?sceneId=${sceneId}`, '_blank')
}

这个改动确保了编辑器中的联动预览功能能够正确跳转到 lite 版的控制页,而不是跳到主版的旧地址。

十九、从编辑器进入控制页时不显示切换场景能力

lite 版控制页还有一个交互细节:当控制页是从编辑器跳转进来时(即没有场景切换意图),不应该显示场景切换能力。这是因为编辑器的跳转通常是为了查看某个特定场景的效果,而不是让用户在这里随意切换场景。场景切换能力只在运维模式(`mode=9999`)下对需要管理多个场景的用户开放。

这个判断和控制按钮的显示逻辑是统一的:都基于 `isDebugMode` computed。如果不是运维模式,两个按钮都不出现。用户看到的是纯净的控制页面,没有任何运维操作的入口。

二十、离线单机授权:机器指纹、密钥生成与配置文件单字段

这是本次迭代中最复杂也最有意思一个模块。用户明确要求:软件应绑定单台机器运行,拷贝到另一台机器后不能直接启动。另一台机器必须重新授权后才能运行。配置文件里只保留一个授权码字段。未填写授权码时,程序启动应在控制台打印机器指纹信息并退出。

这套需求翻译到代码层面,需要解决以下几个关键问题:(1)如何生成唯一且稳定的机器指纹;(2)如何离线生成对应的授权码;(3)如何在程序启动时验证授权码;(4)如何保证配置文件简洁只保留一个字段。

20.1 机器指纹生成

机器指纹需要包含足够多的系统特征,使得不同机器产生的指纹不同。同时指纹应该是确定性的,同一台机器每次启动产生的指纹应该相同。实现方案是收集 CPU 信息、主机名等系统标识,经过 SHA256 哈希后生成 64 字符的十六进制指纹。

// license.go — 机器指纹收集
func CollectFingerprint() (string, error) {
  hw := gatherHardwareInfo()
  data := make([]byte, 0, 256)
  for _, field := range hw {
    data = append(data, []byte(field)...)
    data = append(data, '|')
  }
  h := sha256.New()
  h.Write(data)
  return fmt.Sprintf("%x", h.Sum(nil)), nil
}

如果 `license.code` 为空,程序在启动时会打印机器指纹并退出:

// main.go — 启动前授权校验
licenseCode := cfg.License.Code
if licenseCode == "" {
  fp, err := license.CollectFingerprint()
  if err != nil {
  	panic(err)
  }
  fmt.Println("license.code is empty")
  fmt.Printf("fingerprint=%s\n", fp)
  fmt.Println("please generate a license code for this fingerprint")
  os.Exit(1)
}

20.2 离线授权码生成工具链

授权码的生成需要一个完整的工具链。首先是密钥生成工具 `license-keygen`,用于创建测试公钥和私钥:

# 生成一对测试密钥
go run ./cmd/license-keygen -write-files -out-dir ./tmp-license-keys

# 根据机器指纹和私钥生成授权码
go run ./cmd/license-tool -fingerprint <fingerprint> \
  -seed-file ./tmp-license-keys/license-private.key

程序内置公钥,私钥仅在离线生成授权码时使用。这个设计保证了:即使代码仓库被公开,私钥也只存在于本地生成工具的运行环境中,不会被嵌入到发布版本中。

20.3 授权码校验

程序启动时,使用内置公钥验证 `license.code`:

func ValidateLicense(code, fingerprint string) error {
  if code == "" {
    return ErrEmptyLicense
  }

  // 用公钥验证签名
  sig, err := decodeSignature(code)
  if err != nil {
    return ErrInvalidSignature
  }

  msg := []byte(fingerprint)
  if !rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, sig, msg) {
    return ErrLicenseMismatch
  }

  return nil
}

配置文件只保留一个 `license.code` 字段:

# config-9000.yaml
license:
  code: "test-license-code-placeholder"

如果授权码无效或机器指纹不匹配,程序启动时会直接报错退出。如果用户把程序拷贝到另一台机器上运行,新的机器指纹会生成不同的指纹,原有的授权码不再有效。用户必须拿新指纹重新生成授权码,程序才能正常启动。

20.4 -print-fingerprint 参数

除了空授权码自动打印指纹外,程序还支持通过命令行参数主动打印机器指纹:

go run main.go -c manifest/config/config-9000.yaml -print-fingerprint

这个命令在任何时候都可以执行,不依赖授权状态。用户在部署新机器时可以通过这个参数快速获取指纹,然后离线生成授权码。

二十一、Git 构建产物排除:构建目录从提交中移除

lite 版本的构建产物(HTML、CSS、JS)输出到 `editor-backend-lite/resource/public/`,Go 二进制文件(`sceneview-editor`)输出到 `editor-backend-lite/` 根目录。这些文件不应提交到 Git 仓库。

`.gitignore` 配置:

# Lite frontend build outputs (generated by vite build)
resource/public/assets/
resource/public/index.html

# Go binary
sceneview-editor

如果文件已经被跟踪,需要用 `git rm --cached` 从跟踪范围内移除:

# 从 Git 跟踪范围移除但不删除物理文件
git rm --cached editor-backend-lite/resource/public/assets/*
git rm --cached editor-backend-lite/resource/public/index.html
git rm --cached editor-backend-lite/sceneview-editor

这样确保后续构建产生的文件都不会出现在提交中,保持仓库整洁且避免大文件拖慢 Git 操作。

二十二、前后端构建和重启的完整链路

每次修复完问题后,都需要执行一个完整的验证流程:(1)前端修改代码后执行 `npm run build` 生成 `resource/public/` 下的 HTML、CSS、JS。(2)检查 `go build ./...` 确保后端编译通过。(3)停掉旧的后端进程,启动新的后端进程。(4)打开控制页和预览页验证修复效果。

# 1. 前端构建
cd 3d-editor-lite && npm run build

# 2. 后端编译验证
cd editor-backend-lite && go build ./...

# 3. 重启后端
kill $(pgrep -f "go run main.go")
cd editor-backend-lite && go run main.go -c manifest/config/config-9000.yaml

# 4. 验证
# 控制页: https://9000-xxx/preview/test001?sceneId=4
# 预览页: https://9000-xxx/preview/test001?sceneId=4

构建和重启链路看起来简单,但在频繁迭代中需要特别注意顺序。先构建前端再重启后端,确保后端服务的是最新的前端代码。如果顺序反了(先重启后端,再构建前端),后端的静态文件服务会提供旧的前端资源,可能导致修复效果看起来不生效。

二十三、本地 WebSocket 调试工具:ws-debug

在排查"控制端控制不了"的问题时,为了精确确认后端是否正确地返回了控制权状态,我写了一个小型的 Go 工具 `ws-debug`,直接模拟 WebSocket 客户端连接到后端,查看第一条消息(快照)的内容。

// ws-debug — 模拟 control 角色连接
func main() {
  channel := flag.String("channel", "test001", "channel id")
  role := flag.String("role", "control", "role")
  host := flag.String("host", "127.0.0.1:9000", "ws host")
  sendType := flag.String("send-type", "", "msg type to send")
  sendModelID := flag.String("send-model-id", "", "model id")
  flag.Parse()

  u := url.URL{Scheme: "ws", Host: *host, Path: "/ws/control"}
  q := u.Query()
  q.Set("id", *channel)
  q.Set("role", *role)
  u.RawQuery = q.Encode()

  conn, _, _ := websocket.DefaultDialer.Dial(u.String(), nil)
  defer conn.Close()

  conn.SetReadDeadline(time.Now().Add(3 * time.Second))
  _, payload, _ := conn.ReadMessage()

  var msg map[string]any
  json.Unmarshal(payload, &msg)

  enc := json.NewEncoder(os.Stdout)
  enc.SetIndent("", "  ")
  fmt.Println("snapshot:")
  enc.Encode(msg)
}

这个工具的使用非常简单。运行 `go run ./cmd/ws-debug -channel test001 -role control` 就能看到快照中 `myRole` 是否是 `controller`,`controllerClientId` 是否等于 `myClientId`。

snapshot:
{
  "channelId": "test001",
  "commands": null,
  "controllerClientId": "client_1_debug-channel",
  "myClientId": "client_1_debug-channel",
  "myRole": "controller",
  "takeoverMode": "force",
  "type": "session:snapshot"
}
next:
{"payload":{"controllerClientId":"client_1_test001","reason":"free"},
 "type":"control:granted"}

通过这个工具,可以快速确认:**后端控制权分配是正确的**。问题不在后端,而在于前端页面可能运行着旧代码,或者用户进错了频道(channel ID 不一致)。

二十四、本次修复的完整代码清单

这次迭代涉及的全部修改文件如下:

3d-editor-lite/src/views/PreviewPage.vue
  - reloadToScene() 增加同场景同用户保护,不重复跳转

3d-editor-lite/src/views/ControlPage.vue
  - applySessionSnapshot() 增加无人控制时自动申请逻辑
  - 断线重连 5 秒重试机制
  - 场景切换弹窗 5x2 布局,无滚动条
  - 场景搜索走后台分页接口
  - scene:switch 发送包含 userId

3d-editor-lite/src/components/viewport/EditorViewport.vue
  - transparentBackground 属性,支持透明清屏
  - cloneObjectMaterials() 隔离材质实例
  - focusOnModel() 实时计算 Box3
  - applyMaterialConfigToMaterial() 补齐 reset

3d-editor-lite/src/components/layout/EditorRightPanel.vue
  - updateSelectedModelMaterialImmediately() 立即写入颜色
  - 移除灯光投影、快速模板和场景统计 UI

3d-editor-lite/src/stores/sceneStore.js
  - saveSceneData() 改回 sceneApi.update() 修改语义
  - 场景列表分页 size=10

3d-editor-lite/src/views/EditorLayout.vue
  - 无 sceneId 时默认加载最新场景
  - 联动预览改为 /control/:id

editor-backend-lite/internal/controller/ws/control.go
  - page:refresh 不写入频道历史快照
  - scene:switch 清空频道历史
  - resetHistory 签名简化(移除 payload 参数)

editor-backend-lite/.gitignore
  - 忽略 resource/public/assets/
  - 忽略 resource/public/index.html
  - 忽略 sceneview-editor 二进制

editor-backend-lite/cmd/ws-debug/main.go
  - 本地 WebSocket 调试工具(已清理)

editor-backend-lite/internal/license/license.go
  - 机器指纹收集、授权码校验

editor-backend-lite/main.go
  - 启动前授权校验、空授权码打印指纹

二十五、如果再做一次,我会哪些事情提前做

写完这篇复盘后回头想,如果让我重来一次,有几件事会更早开始:

第一,快照回放机制的准入规则从第一天就定下来。不是所有命令都应该被记入频道历史。瞬态命令(刷新、一次性导航、临时状态)只实时广播,不记入历史。边界事件(场景切换、会话重置)记入时清空旧历史。如果有这个规则从一开始就在,`page:refresh` 和 `scene:switch` 这两个循环问题都不会出现。或者说即使出现,排查路径也会短得多。因为你可以直接去问"这个命令有没有进历史",而不需要逐层排查。

第二,控制端重连后的角色恢复机制从一开始就有。现在的自动申请控制权是一个兜底,但更理想的状态是后端能持久化控制角色状态(比如存到 Redis),这样重连后不需要重新申请,直接恢复原有角色。但这需要引入额外的存储依赖,在当前 lite 版的定位下暂时不做。所以当前的自动申请机制已经是在"无存储"约束下的最优解。

第三,材质实例隔离应该在首次加载模型时就做,而不是等到发现串色问题后才补。这是个典型的"防御性编码"问题。GLTF 加载器的共享材质行为是一个已知特性,如果你打算让用户修改部件级材质,就应该一开始就做 `cloneObjectMaterials`,而不是等到 bug 出现后才补救。

第四,离线授权机制的实现其实比预想的简单。核心就是 RSA 公私钥 + SHA256 指纹 + 配置文件单字段。如果早确定这个方案,后面的迭代就不会在授权逻辑上浪费太多猜测时间。但反过来说,正是因为用户需求是逐步明确的(从"需要授权"到"单机绑定"到"配置文件只保留一个字段"),最终方案才会这么干净。如果一开始就定死了复杂方案,后面反而会改得更辛苦。

二十六、总结:lite 版的价值在于入口更短、能力足够

这次 9000 端的密集迭代,最大的收获不是某个具体功能,而在于验证了一条重要的产品假设:当用户需要的是一个可以直接给的 3D 控制与展示入口时,lite 版比主版更适合。它不需要先去编辑器建会话、不需要平台感、不需要管理后台。它只需要一个 URL、一个场景列表、一个控制端和一个预览端。场景切换、断线重连、离线授权这些能力都是在这个简洁入口之上叠加的真实需求。

它真正有价值的地方在于几个痛点都被认真压下去了。快照回放不会造成死循环,预览端透明后在任何页面上都不会有突兀黑框,部件改颜色不再影响整个模型,后端重启后不需要手点申请控制就能恢复操作,断线 5 秒自动重连不用手动刷新页面,机器绑定授权让发布出去的副本不会被人随便拷贝再用。这些点每一个单看都不惊天动地,但合在一起,就把"一个能跑的 lite 页面"抬升成了"一套能拿去交付的轻量系统"。

如果你也在做类似的三维控制、远程讲解、数字孪生演示或多端联动项目,希望这篇文章能给你带来的不是几段可复制的代码,而是一种更务实的判断标准:不要在架构层做过度的预设,先让最基本的能力跑通(URL 进入、WS 同步、场景切换、快照恢复),然后在真实联调里一层层把边界画清楚。边界越清,系统越稳。

二十六、总结:lite 版的价值在于入口更短、能力足够

这次 9000 端的密集迭代,最大的收获不是某个具体功能,而在于验证了一条重要的产品假设:当用户需要的是一个可以直接给的 3D 控制与展示入口时,lite 版比主版更适合。它不需要先去编辑器建会话、不需要平台感、不需要管理后台。它只需要一个 URL、一个场景列表、一个控制端和一个预览端。场景切换、断线重连、离线授权这些能力都是在这个简洁入口之上叠加的真实需求。

它真正有价值的地方在于几个痛点都被认真压下去了。快照回放不会造成死循环,预览端透明后在任何页面上都不会有突兀黑框,部件改颜色不再影响整个模型,后端重启后不需要手点申请控制就能恢复操作,断线 5 秒自动重连不用手动刷新页面,机器绑定授权让发布出去的副本不会被人随便拷贝再用。这些点每一个单看都不惊天动地,但合在一起,就把"一个能跑的 lite 页面"抬升成了"一套能拿去交付的轻量系统"。

如果你也在做类似的三维控制、远程讲解、数字孪生演示或多端联动项目,希望这篇文章能给你带来的不是几段可复制的代码,而是一种更务实的判断标准:不要在架构层做过度的预设,先让最基本的能力跑通(URL 进入、WS 同步、场景切换、快照恢复),然后在真实联调里一层层把边界画清楚。边界越清,系统越稳。