上线一个多人扫码网页娱乐工具,我在并发、SEO、PWA 和打包脚本上踩的 8 个坑

Go WebSocket 并发编程 Vue3 Nginx PWA SEO PowerShell


前段时间做了个线下聚会用的网页小游戏合集(happy.3qtools.cn),核心形态是:房主开房间显示二维码,其他人扫码进来,每人一台手机看自己的私密信息------比如"谁是卧底"里每个人只看到自己的词。

技术栈是 Go + Gin + gorilla/websocket + GORM/MySQL,前端 Vue3 + TypeScript,纯 SPA,Nginx 发静态文件。

功能本身不复杂,但从"本地能跑"到"线上能用"这段路上踩的坑,密度远超预期。下面按领域记录,每个坑都尽量写清现象、原因、解法,方便被搜到。

一、房间状态的并发模型:一个房间一把锁

多人实时游戏的第一个设计决策是状态放哪、怎么保护。

我最初想过用 channel + 单 goroutine 串行处理所有房间的消息(Go 社区常见的"不要用锁,用 channel"路线)。实际写下来发现不合适:房间之间是完全独立的,用一个 goroutine 串行处理所有房间,等于人为制造了一个全局瓶颈,且一个房间的慢操作会阻塞所有房间。

最后的结构是每个房间一把 sync.Mutex

go 复制代码
type Room struct {
    mu       sync.Mutex
    ID       string
    Players  []*Player
    HostID   string
    GameKey  string
    state    *game.State   // 具体玩法的状态
    recorder chan record   // 落库任务队列
}

func (r *Room) Handle(playerID string, msg Message) {
    r.mu.Lock()
    defer r.mu.Unlock()

    // 所有玩法逻辑都在锁内调用,玩法代码本身不需要考虑并发
    out := game.Step(r.ctx(), playerID, msg)
    r.broadcast(out.Events)
    if out.Record != nil {
        r.recorder <- *out.Record   // 只投递,不落库
    }
}

这样并发粒度天然按房间隔离,而且带来一个很舒服的副作用:玩法逻辑完全不用写任何并发代码。玩法包里的所有函数都只在持锁的情况下被调用,可以当纯函数写、当纯函数测。这个约定我写进了包注释,因为它是一条靠人守的规则,编译器帮不上忙。

二、锁里绝对不能做 I/O:为什么要单独开一个落库 goroutine

上面代码里 r.recorder <- *out.Record 那一行是这一节的重点。

第一版我是直接在锁内写数据库的:

go 复制代码
// 错误示范
func (r *Room) Handle(...) {
    r.mu.Lock()
    defer r.mu.Unlock()
    out := game.Step(...)
    db.Create(&Record{...})   // 一次 MySQL 往返,几毫秒到几十毫秒
}

本地测试毫无问题------本机 MySQL 响应在 1ms 内。上到服务器就开始出现"点了没反应,过一会儿全部一起动"的现象。

原因很直白:锁的持有时间从微秒级变成了毫秒级。一局游戏里多人同时操作时,所有人的请求在这把锁上排队,而队列里每一个人都要等一次数据库往返。这是典型的"把 I/O 放进临界区"。

解法是每个房间起一个专属的落库 goroutine:

go 复制代码
func (r *Room) recorderLoop() {
    for rec := range r.recorder {
        // 这里已经不持有 r.mu,慢一点也只影响战绩落库
        if err := r.store.SaveRecord(rec); err != nil {
            log.Printf("save record room=%s: %v", r.ID, err)
        }
    }
}

recorder 是带缓冲的 channel,房间销毁时 close。这样临界区内只有内存操作,数据库慢不影响对局手感。战绩晚落库几十毫秒,用户完全感知不到。

可复用的判断:临界区里只允许有内存操作。 任何网络调用、磁盘写入、日志刷盘都应该移到锁外,通过 channel 异步做。

三、分层边界:让编译器帮你守规矩

项目里有三个包容易互相污染:game(玩法逻辑)、ws(连接与广播)、store(持久化)。

我定了两条硬规则:

  • game 不 import ws
  • ws 不 import model / store

第一条的好处是玩法逻辑可以脱离网络单测。第二条的好处是连接层不知道数据库长什么样,换存储不用改 ws。

实现上靠一个边界结构体传递玩法需要的一切:

go 复制代码
package game

// Ctx 是玩法逻辑能看到的全部世界。
// 刻意不传 *Room、不传 db、不传 conn ------ 玩法不该知道这些东西存在。
type Ctx struct {
    Players []Player
    HostID  string
    Log     func(format string, args ...any)
    Punish  func(playerID, reason, outcome string)
    MVP     func(playerID string)
    After   func(d time.Duration, fn func())
    Bank    BankReader
}

wsstore 之间的适配器全部放在 main 包里组装。这样依赖方向是单向的,出现循环 import 会直接编译失败------规则由编译器执行,而不是靠 code review 记得

顺带一个细节:Punish 的第三个参数是个受限词表 safe / punished / mvp,不是自由字符串。字符串一自由,前端就会开始对着不同的拼写写 if,一年后没人知道 "punish""punished" 哪个是对的。

四、鉴权为什么没用 JWT

这个决定被问过几次,所以单独说。

需求是:手机号登录,180 天免登录,并且要能在改密码时把旧登录全部踢下线

JWT 是无状态的,最后一条需求它天然做不到------签发出去的 token 在过期前一直有效,除非你再维护一张黑名单表。而一旦有了黑名单表,每次校验都要查库,无状态的优势就没了,只剩下"token 很长"这个缺点。

所以我用的是最朴素的方案:服务端生成随机不透明 token,只存 sha256 后的哈希

go 复制代码
raw := randomHex(32)                       // 返回给客户端
hash := sha256.Sum256([]byte(raw))         // 入库的是这个

校验就是一次带索引的主键查询,改密码时在同一个事务里把该用户所有 token 删掉:

go 复制代码
func (s *Store) ResetPassword(userID int64, newHash string) error {
    return s.db.Transaction(func(tx *gorm.DB) error {
        if err := tx.Model(&User{}).Where("id = ?", userID).
            Update("password", newHash).Error; err != nil {
            return err
        }
        // 撤销该用户全部登录态,旧手机立刻掉线
        return tx.Where("user_id = ?", userID).Delete(&Token{}).Error
    })
}

只存哈希的意义在于:数据库被拖走,攻击者也拿不到能直接用的 token,和存密码只存 bcrypt 是同一个道理。这一步成本几乎为零,但很多项目会图省事直接存明文 token。

结论:当你需要"能吊销"的时候,有状态 token 比 JWT 更简单,不是更复杂。

五、SPA 的 SEO 死结:百度不执行 JS

前端是 Vue3 + history 路由。上线后发现一个问题:百度收录的页面只有首页,而且首页快照是空的。

原因是纯 SPA 的 index.html#app 是个空 div,内容全靠 JS 渲染。Google 会执行 JS,百度基本不会。对百度来说,这个站只有一个没有内容的页面。

我用了两层解法,都不需要引入 SSR。

第一层:在 #app 里放一份静态首屏

关键点是 Vue 挂载时会清空容器。我去翻了源码确认这件事(runtime-dom.esm-bundler.jsmount 前有一句 container.textContent = ''),所以放在 #app 内部的内容是安全的------它只在首帧存在,JS 一接管就消失,同时还顺便当了骨架屏。

html 复制代码
<div id="app">
  <!-- Vue 的 mount() 会清空这个容器,所以这段只对爬虫和首帧可见 -->
  <div class="seo-boot">
    <h1>酒桌派 · 聚会互动游戏</h1>
    <ul>
      <li><a href="/s/dice">摇骰子 · 猜大小</a></li>
      <li><a href="/s/wheel">惩罚大转盘</a></li>
      <!-- ... 真实的 <a> 链接,让爬虫能顺着爬 -->
    </ul>
  </div>
</div>

注意必须是真实的 <a href>,写成 <div @click> 对爬虫等于不存在。

第二层:构建期为每个落地页生成独立的静态 HTML

第一层只解决了首页。后来我加了一批工具页(随机决定转盘、今天吃什么之类),需要每个页面有自己的 title、description 和结构化数据。

做法是在 index.html 里埋注释标记,构建后用脚本替换:

html 复制代码
<!--SEO:PAGE-->
<title>...</title>
<meta name="description" content="..." />
<link rel="canonical" href="..." />
<script type="application/ld+json">...</script>
<!--/SEO:PAGE-->
js 复制代码
// scripts/gen-landing.mjs,挂在 npm run build 之后
const template = await readFile('dist/index.html', 'utf8')
for (const page of pages) {
  let html = replaceBlock(template, '<!--SEO:PAGE-->', '<!--/SEO:PAGE-->', headOf(page))
  html = replaceBlock(html, '<!--SEO:BOOT-->', '<!--/SEO:BOOT-->', bodyOf(page))
  await writeFile(`dist/${page.slug}.html`, html)   // 注意这里
}

标记找不到就直接抛错退出,避免有人改 index.html 时把标记删了、脚本静默产出错页面。

这里有个容易做错的选择:输出 xxx.html 还是 xxx/index.html

我一开始写的是 wheel/eat/index.html,靠 Nginx 的目录索引命中。实测发现只有带尾斜杠的 /wheel/eat/ 能命中文件,不带斜杠的 /wheel/eat 会落回 SPA 的 index.html------而 canonical、sitemap 和站内链接用的全是不带斜杠的形式,等于静态化白做。

改成平铺的 wheel/eat.html,配合下面这条 Nginx 规则,两种形式都对:

nginx 复制代码
try_files $uri $uri.html $uri/ /index.html;

六、Nginx 的三个坑

6.1 types {} 写在 server 层会导致白屏

需求是给 .webmanifest 指定 MIME 类型(1.21.5 之前的 mime.types 里没有它,类型不对安卓会忽略整份 manifest,PWA 装不上)。

我一开始这么写:

nginx 复制代码
server {
    types {
        application/manifest+json  webmanifest;
    }
}

结果整站白屏。

原因是 types 指令在 server 层会整体替换 掉 http 层 include mime.types 的结果,不是追加。于是 css/js 全部退化成 application/octet-stream,再配上我加的 X-Content-Type-Options: nosniff,浏览器直接拒绝执行。

正确写法是在 location 里用 default_type

nginx 复制代码
location = /manifest.webmanifest {
    default_type application/manifest+json;
    expires 1h;
    try_files $uri =404;
}

6.2 location = /index.html 匹配不到首页

想给 HTML 加 no-cache,很自然会写:

nginx 复制代码
location = /index.html {
    add_header Cache-Control "no-cache";
}

但用户访问的是 https://example.com/$uri/,精确匹配的 = /index.html 在多数路径下不会命中。结果就是发版后一部分用户还拿着旧的 HTML、引用着旧的 JS 文件名。

我最后把它放进 location /,因为走到那里的响应本来就都是 HTML(静态资源已被前面的 location 拦掉):

nginx 复制代码
location / {
    add_header Cache-Control "no-cache";
    try_files $uri $uri.html $uri/ /index.html;
}

6.3 /sw.js 必须 no-store

Service Worker 一旦被长缓存,你改了也推不下去,用户会长期停在旧版本。

nginx 复制代码
location = /sw.js {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    try_files $uri =404;
}

这条我写进配置文件了,但服务器上那份配置没跟着更新 ,线上 sw.js 实际返回的是 max-age=43200。这类"仓库里的配置 ≠ 线上的配置"是最难发现的一类问题,第九节说怎么防。

七、PWA 安装能力:一半的用户根本装不了

做"添加到桌面"时的认知修正:beforeinstallprompt 不是 Web 标准里人人都有的东西。

实际情况是:

  • Chromium 系独立浏览器 + HTTPS + manifest + Service Worker 能返回离线页面 → 才会触发 beforeinstallprompt,能一键安装
  • iOS Safari → 没有任何 API,只能引导用户手动点"添加到主屏幕"
  • 微信 / QQ / 钉钉 / 支付宝内置 WebView → 完全没有安装能力,只能引导"用浏览器打开"

所以正确做法不是"写个安装按钮",而是先判断环境,再给不同的引导

ts 复制代码
const ua = navigator.userAgent

export const isIOS = /iPad|iPhone|iPod/.test(ua) ||
  (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)

// 别用 QQBrowser 匹配:独立版 QQ 浏览器的 UA 里是 MQQBrowser,会误伤
export const isInApp = /MicroMessenger|QQ\/|Weibo|DingTalk|AlipayClient/i.test(ua)

const deferred = ref<BeforeInstallPromptEvent | null>(null)
window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault()
  deferred.value = e as BeforeInstallPromptEvent
})

export const installEnv = computed(() => {
  if (isInApp) return 'inapp'               // 引导去浏览器打开
  if (deferred.value) return 'installable'  // 能一键装
  if (isIOS) return 'ios'                   // 教手动添加
  return 'manual'
})

注释里那个 MQQBrowser 是我实际踩的坑:用 QQBrowser 做正则会把独立版 QQ 浏览器也判成"App 内置 WebView",导致一个明明能一键安装的浏览器被引导去"用浏览器打开"。

Service Worker 的缓存策略也顺便提一下,这块很容易做出"发版推不下去"的效果:

js 复制代码
// 导航请求 network-first:保证用户拿到的 HTML 是新的,只在断网时回落缓存
// /assets/ cache-first:文件名带哈希,不会串版本
if (req.mode === 'navigate') {
  event.respondWith(shellNetworkFirst(req))
} else if (url.pathname.startsWith('/assets/')) {
  event.respondWith(cacheFirst(req))
}
// /api/ 和 /ws 直接 return,不拦 ------ 房间状态必须实时

判断依据很简单:文件名带内容哈希的才能 cache-first,其余一律 network-first。

八、页面切换动画把滚动位置搞丢了

需求:从首页进子页面,子页面置顶;返回首页时恢复到之前的滚动位置。

标准做法是 scrollBehavior 加自己记一份:

ts 复制代码
let homeScroll = 0

const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, _from, savedPosition) {
    const top = to.name === 'home' ? (savedPosition?.top ?? homeScroll) : 0
    return top <= 0 ? { top: 0 } : { top }
  },
})

router.beforeEach((_to, from) => {
  if (from.name === 'home') homeScroll = window.scrollY
})

写完发现现象很怪:点进子页面的瞬间,首页会明显跳一下,记录到的 window.scrollY 比实际值小,返回后位置错开。

排查了一圈,根因在 App.vue 里那个页面切换动画:

vue 复制代码
<router-view v-slot="{ Component }">
  <transition name="fade" mode="out-in">
    <component :is="Component" />
  </transition>
</router-view>

mode="out-in"先卸载旧页面,再挂载新页面 。滚动容器是 document 本身,旧页面一卸载,文档高度立刻塌回一屏,浏览器为了不超出范围会把 scrollY 夹到一个更小的值。我的 beforeEach 恰好在这个窗口期读取,读到的就是被夹过的值。

我一开始试图用 requestAnimationFrame 延后读取,没用------高度塌缩是同步发生的。最后直接去掉了这个动画:

vue 复制代码
<template>
  <!-- 刻意不做页面切换动画。滚动容器是 document 本身,而 <transition mode="out-in">
       会先卸载旧页面导致文档高度塌回一屏,浏览器会把滚动位置夹到 0,
       首页的滚动记忆就废了。要动画的话得用 fixed 定位的覆盖层,不能影响文档流。 -->
  <router-view />
</template>

可复用的判断:任何会改变文档高度的过渡效果,都会和滚动位置恢复打架。 如果一定要动画,用 position: fixed 的覆盖层做,别让它参与文档流。

九、别相信浏览器:部署后用 curl 自检

这一节是这次最有价值的教训。

前端改完、打包完、上传解压重启,打开浏览器一看------新功能没有。第一反应是排查代码、怀疑缓存、怀疑 Service Worker,折腾了一会儿。

后来直接 curl 了一下:

bash 复制代码
$ curl -sI https://happy.3qtools.cn/ | grep -i last-modified
Last-Modified: Mon, 31 Aug 2026 13:14:18 GMT

三天前的文件。包根本没部署上去。

浏览器这一层混入了太多变量:HTTP 缓存、Service Worker、页面本身的渲染逻辑。用它来判断"服务器上是哪一版",等于在一条有三个缓存层的链路末端做诊断。

所以现在我的部署自检是固定几条命令,全部绕开浏览器:

bash 复制代码
# 1. 文件真的换了吗(最重要,先看这个)
curl -sI https://example.com/ | grep -i last-modified

# 2. 入口 JS 的哈希对不对(和本地构建产物比)
curl -s https://example.com/ | grep -o '/assets/index-[A-Za-z0-9_-]*\.js'

# 3. 新页面的 canonical 对不对(能验证 Nginx 的 try_files 生效了)
curl -s https://example.com/wheel/eat | grep -o 'canonical[^>]*'

# 4. 缓存头和配置文件一致吗(能发现"线上配置 ≠ 仓库配置")
curl -sI https://example.com/sw.js | grep -i cache-control

第 4 条就是我发现服务器上的 Nginx 配置不是仓库当前版本的方式------配置文件里写的是 no-store,线上返回 max-age=43200,一眼就能看出没同步。

把这几条写成脚本,每次部署后跑一遍。 它能在 10 秒内区分"代码问题"和"部署问题",而这两者的排查方向完全不同。

十、附赠:Windows 打包脚本的编码地狱

这一节和游戏无关,但如果你在 Windows 上写 .bat + .ps1 的自动化脚本,大概率会撞上,而且现象诡异到很难搜。

10.1 .ps1 没有 BOM,PowerShell 5.1 会把中文读成乱码

PowerShell 5.1 对没有 BOM.ps1 文件按系统 ANSI 编码(中文系统是 GBK)解码。文件是 UTF-8 存的,里面的中文输出就全是乱码。PowerShell 7 默认 UTF-8,所以在装了 pwsh 的机器上看不出问题。

解法:给 .ps1 加 UTF-8 BOM。

powershell 复制代码
$p = 'deploy\dev.ps1'
$t = [System.IO.File]::ReadAllText($p, [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($p, $t, [System.Text.UTF8Encoding]::new($true))  # true = 带 BOM

顺带一个相关的坑:Set-Content -Encoding utf8 在 PowerShell 5.1 下会写 BOM 。如果你在生成 YAML / JSON / Linux 上要读的配置,BOM 会让解析失败。要无 BOM 就得用 [System.IO.File]::WriteAllText

10.2 .bat 里的中文注释被当成命令执行

这个现象最离谱。我的 打包.bat 长这样:

bat 复制代码
@echo off
chcp 65001 >nul
rem 路径全部基于本文件所在位置,从任何地方双击都能跑

运行时报:

kotlin 复制代码
'锟斤拷鍏ㄩ儴鍩轰簬鏈枃浠舵墍鍦ㄤ綅缃紝浠庝换浣曞湴鏂瑰弻鍑婚兘鑳借窇'
is not recognized as an internal or external command

rem 注释里的内容被当成命令执行了。

原因:cmd.exe 解析 .bat 用的是"打开这个文件那一刻"的代码页 ,所以第 2 行的 chcp 65001 对这个文件自身的解析不生效。UTF-8 的中文字节按 GBK 解码后,行在错误的位置被截断,剩下的部分就被当成新的一条命令。

解法有两个方向,我选了后者:

  1. .bat 存成 GBK(治标,不可移植)
  2. .bat 保持纯 ASCII,所有中文输出交给 .ps1.ps1 有 BOM,PowerShell 能正确解码)

于是 .bat 退化成一个纯英文的启动器:

bat 复制代码
@echo off
chcp 65001 >nul
rem KEEP THIS FILE ASCII-ONLY.
rem cmd.exe decodes a .bat using the code page active when the file was opened,
rem so the "chcp 65001" above does NOT apply to this file itself.
cd /d "%~dp0"
set "PS=powershell"
where pwsh >nul 2>nul && set "PS=pwsh"
"%PS%" -NoProfile -ExecutionPolicy Bypass -File "%~dp0deploy\pack.ps1" %*

中文的错误提示和排查建议全部搬进 pack.ps1trap 块里。

另外提一个小陷阱:.bat 末尾如果有 start 命令,它可能留下非零的 ERRORLEVEL,让调用方以为打包失败。显式 exit /b 0 收尾。

小结

回头看,这些坑里只有第一、二个是"游戏"相关的,剩下六个都是Web 应用上线的通用问题。也就是说,业务逻辑写完的时候,工作量大概只走了一半。

如果只能带走几条,我会选这几个:

  • 临界区里只放内存操作,I/O 一律异步化,否则本地正常上线就排队
  • 用编译器守分层边界,靠人记的规则一定会破
  • 需要吊销就别用 JWT,有状态 token 更简单
  • 文件名带哈希的才能 cache-first,其余 network-first
  • 任何改变文档高度的过渡动画,都会和滚动位置恢复冲突
  • 部署后用 curl 自检,别用浏览器判断线上是哪一版

项目本身是那个聚会游戏站(happy.3qtools.cn),浏览器打开就能用,不用下载注册。如果你也在做类似的多人实时小项目,上面这些坑希望能帮你省掉几个晚上。

评论区欢迎交流,尤其是房间状态管理和 SPA SEO 这两块,我这套方案不见得是最优的。

相关推荐
用户298698530142 小时前
在 React 中实现 RTF 与 PDF、HTML 的文档转换实践
javascript·react.js·html
catastrophe_zy2 小时前
如何用 WebCodecs 在浏览器里实现高清录屏 —— 无插件、无水印、直接导出 MP4
前端·javascript·录屏
Moment3 小时前
太好了!NestJS 12 大版本转向 ESM,新项目默认构建换 Rspack
前端·javascript·后端
平头哥~4 小时前
Day 15 | 不改一行 HTML,给页面加上引号、角标和标签
前端·javascript·css·html·css3·学习资料
Fluxart.ai5 小时前
GPT Image 2 国内怎么用?在 Flux Art 完成图片生成与编辑
前端·javascript·gpt
circuitsosk13 小时前
任务规划器的三种范式对比:ReAct、Plan-and-Execute 与 Tree-of-Thought 在真实业务中的取舍
前端·javascript·python·react.js·llm·ai agent
小磊哥er14 小时前
深入解构Claude Code - 第 12 篇 · 整体串起来
javascript·ai编程
小磊哥er14 小时前
深入解构Claude Code - 第 11 篇 · 工程上的讲究
javascript·ai编程
独立开发之道17 小时前
【three.js教程】Three.js 运行时更新:矩阵、几何体、材质怎么改才不卡
javascript·矩阵·材质