Skip to content

fix: 重启后用户分组丢失 - #10

Merged
mzrodyu merged 1 commit into
mainfrom
feat/model-pull-button
Sep 10, 2026
Merged

mzrodyu merged 1 commit into
mainfrom
feat/model-pull-button

Conversation

@mzrodyu

@mzrodyu mzrodyu commented Sep 10, 2026

Copy link
Copy Markdown
Owner

问题

UserGroup 加进了 AppStatesaveStateLocked正确写入了,但 loadState(文件)和 loadPostgresState(Postgres)都没有读回来

后果:每次重启/部署分组列表都归零,管理员手动建的分组全部消失,只能重建。更糟的是重启后的第一次保存会把空列表写回覆盖持久化的数据,所以丢失是不可逆的。

修复

  • 两条加载路径补上 stored.Groups 恢复
  • normalizeStateCollections 现在会确保默认注册分组存在,控制台不会再出现无解释的空列表
  • 用户身上指向已不存在分组的 groupId 会被重置为未分组,而不是留着悬空 —— 悬空引用会让用户静默地只能用未限制分组的渠道,比分组缺失更难发现

验证

新增两个测试,都已在移除修复后确认会失败

  • TestUserGroupsSurviveRestart —— 走真实的文件持久化重启,验证分组、用户归属、渠道范围限定三者都保留
  • TestEveryAppStateCollectionRoundTrips —— 断言每一条集合里的样本记录都能在加载后存活

第二个测试刻意检查「具体记录存在」而不是「集合非空」:归一化会自己补出默认分组,只查非空会掩盖从未被加载的集合。这一点是我第一版测试写错后发现的 —— 当时的写法在这个 bug 下依然通过。

实测(文件持久化,真实重启):

重启前: 默认分组  VIP  Trial
重启后: 默认分组  VIP  Trial

go test ./... 全绿。

关于已有数据

已经被清掉的分组无法从当前数据库恢复(覆盖已经发生)。需要重建一次,之后就会正常保留了。如果你有导出的备份 JSON,可以用「设置 → 恢复」找回来。

UserGroup was added to AppState, and saveStateLocked wrote it correctly, but
neither loadState nor loadPostgresState ever read it back. Every restart
therefore came up with an empty group list, so a deploy silently discarded every
group an operator had created and forced them to be recreated by hand. The first
save after a restart then overwrote the persisted copy, making the loss
permanent.

Also hardening around the same area:

- normalizeStateCollections now materialises the default registration group, so
  the console never shows an unexplained empty list
- a user whose groupId no longer resolves is reset to ungrouped rather than left
  dangling: a dangling id silently confines the user to unrestricted channels,
  which is harder to notice than a missing group

Tests, both of which fail without the load fix:

- TestUserGroupsSurviveRestart round-trips groups, user assignment and channel
  scoping through a real file-backed restart
- TestEveryAppStateCollectionRoundTrips asserts a seeded entry from every
  collection survives a load. It checks for the specific entry rather than a
  non-empty collection, because normalization creates entries of its own and
  would otherwise mask a collection that was never loaded.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@mzrodyu
mzrodyu merged commit 26b4495 into main Sep 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant