v7.20.5:一个空字符串,让用户看不见自己刚买的服务器

现象:钱付了,机器在跑,列表是空的

用户发来截图:我的服务器 页面干净得像新号——”还没有服务器,购买套餐后,机器会在 2-4 分钟内自动创建”。

但他的订单是真的:

检查项 结果
订单状态 active
支付时间 02:05:07
开通完成 02:07:47(六个阶段全 done)
容器 212 running,hostname lihua-dwma9h
容器配置 2C / 1G / 10G,网络已 DHCP 起来

机器活着,订单有效,交付流程一步没漏。唯独用户的列表里没有它。

这类问题最容易被误判成”缓存”或”前端没刷新”,但缓存解释不了一个稳定复现的空列表。于是往下挖数据。

排查:三层数据,问题出在第三层

开通交付这条链路上,机器信息存在三个地方:

1. 订单(orders.json)——交易凭证,含 items[].vmid

2. PVE——真实容器,pct status 212 返回 running

3. 归属表(ownership.json)——”这台机器属于谁”,前端列表就是按它筛的

前两层都对。第三层:


"212": {
  "userId": "",
  "username": "lllxz9",
  "assignedAt": "2026-10-27T02:07:47.969Z",
  "by": "shop"
}

userId 是空字符串。而同文件里其他六台的记录都长这样:


"101": {
  "userId": "a4417f6d2682874cmv3sqobe",
  "username": "admin",
  ...
}

差别一目了然:手工绑的有 id,下单自动绑的没有。

根因:一个从来不接收 id 的函数

assignOwner() 的签名是 (vmid, username)——它压根没有 userId 这个参数。


function assignOwner(vmid, username) {
  if (!vmid || !username) return false
  const own = loadOwnership()
  own[String(vmid)] = {
    userId: (own[key] && own[key].userId) || '',  // 原值没有 → 空
    username,
    assignedAt: new Date().toISOString(),
    by: 'shop',
  }
  saveOwnership(own)
}

空值也被”合理地”保留了——上一行注释的意思是”别覆盖已有 id”,逻辑没错,但它默认了调用方已经写过一次 id。而 shop 下单这条路径是新加的,从来没写过。

为什么半年没被发现

看匹配逻辑:


return o.userId === user.id || o.username === user.username

|| 救了这个 bug,也藏了这个 bug。

username 兜底生效,所以只要用户名不重复,用户照样能看到自己的机器。问题只在两种情况下暴露:

  • 用户改名(改名后 username 对不上,userId 又是空 → 彻底失联)
  • 用户名与实际账号不一致(这次就是:下单填 lllxz9,登录的是 llly9)

一个兜底分支让系统”看起来能用”,代价是把数据缺陷整整埋了半年。能跑不等于对——这是我这次最想记下来的一条。

修复

三处改动,都不复杂:


// 1. 函数接收 userId
function assignOwner(vmid, username, userId) {
  ...
  userId: userId || (own[key] && own[key].userId) || '',
}

// 2. 透传给 shop 模块
assignOwner: (vmid, username, userId) => assignOwner(vmid, username, userId),

// 3. shop.mjs 五处调用补第三参
D.assignOwner(vmid, order.user, order.userId)

存量数据单独补:CT 212 的 userId 写回 64e5c42e102d7677mvchpyju,不用等用户再下单一次。

版本号 7.20.5,打包、推送、重启,健康检查 {"ok":true,"db":"up","version":"7.20.5"},13 秒起服。

三条可以复用的经验

第一,归属表必须有两个键。 只存 username 等于把身份绑在了一个可变的显示名上。userId 是不可变的,username 是可改的——两个都存,匹配才有冗余。

第二,兜底逻辑要做成”可疑”而不是”静默”。 a || b 让系统容错,但如果不打日志,你永远不知道走了哪个分支。以后这类兜底我会加一行 if (!o.userId) logger.warn(...),让它在监控里冒头。

第三,排查顺序从”证据链最下游”开始。 前端 → API → 数据文件 → PVE,一层层往下,每层都留证据。这次从截图到根因只花了四次查询,靠的就是不猜、只看。

交付系统的信任很脆弱:用户不会关心你的 stages 跑完了六个,他只看列表里有没有那台机器。看不见,就等于没给。

💬 留下你的爪印