【Bug已解决】New updates remove pinned chats 解决方案原始报错New updates remove pinned chats 场景桌面/移动客户端在自动或手动更新到新版本后用户此前置顶/固定pin的会话列表被清空所有固定项丢失。 关键词本地持久化、存储迁移、schema 版本化、用户偏好保护、升级不丢数据。一、现象长什么样用户在前一天把 5 个常用会话固定pin在列表顶部第二天客户端推送了一次小版本更新重启后发现固定区pinned section整个消失设置里固定会话数量为 0本地数据库/配置文件里对应的pinned字段或表不存在重新固定可以工作但一旦再次更新又会被清掉。 这是一个典型的升级破坏本地用户数据问题而不是网络同步问题——因为离线环境下复现率一样高而且新固定的数据在下次更新前一直稳定。二、背景为什么一次正常更新会动到本地数据现代客户端通常把用户偏好主题、窗口布局、固定会话、快捷键和领域数据聊天记录、草稿混在同一个本地存储里。存储形态可能是一个 SQLite 数据库文件如app.db一个 JSON 配置文件如state.json、preferences.json一个平台特定目录如~/Library/Application Support/App/prefs.sqlite。 更新有两种常见做法覆盖式更新新版本直接把可执行文件和内置的初始数据库复制到安装目录。如果初始数据库里没有用户的固定项且迁移逻辑没跑用户数据就被空白初始库覆盖。懒初始化更新新版本启动后发现本地没有符合新 schema 的表于是执行CREATE TABLE或重置整个存储。如果旧数据的表名/字段名和新的不一致且代码用不存在就重建的策略旧表被丢弃。 第二种最隐蔽代码逻辑是为了兼容新版本我重建一张干净的表但它没有先尝试迁移旧表于是旧表连同里面的固定项一起被DROP后重建。三、为什么固定项会丢根因分析把问题拆开看通常有四类根因存储无版本号本地文件/库没有记录我是哪个 schema 版本创建的。新代码无法判断这是旧数据需要迁移还是这是全新安装可以直接初始化。迁移逻辑缺失或顺序错误新版本新增了pinned_chats表但onUpgrade里只写了DROP CREATE没有ALTER TABLE或数据搬运。固定项存错位置固定项被存在了缓存目录或临时目录而更新过程会清理这些目录例如安装器把Cache/当可丢弃物清掉。并发写覆盖更新后首次启动迁移线程和默认初始化线程竞争默认初始化先写完空文件迁移线程读到的是已经被清空的存储。 下面用一个最小可运行模型复现第 2 类根因并给出正确的迁移实现。四、最小可运行复现下面这段脚本模拟没有版本号 重建即丢数据的错误写法。运行后会看到固定项在更新后变成空。import json import os import tempfile # ---------- 模拟本地存储 ---------- def store_path(): return os.path.join(tempfile.gettempdir(), bad_app_state.json) def first_launch_seed(): 旧版本首次启动写入用户固定项。 state { # 注意没有 schema_version 字段 pinned_chats: [chat_8821, chat_1043, chat_5572], theme: dark, } with open(store_path(), w, encodingutf-8) as f: json.dump(state, f, ensure_asciiFalse, indent2) def bad_upgrade(): 错误的新版本启动逻辑 发现 pinned_chats_v2 不存在就当作全新安装重置整个 state。 path store_path() try: with open(path, r, encodingutf-8) as f: state json.load(f) except FileNotFoundError: state {} # 新版本希望用新的结构 pinned_chats_v2 if pinned_chats_v2 not in state: # 错误直接覆盖整个 state旧 pinned_chats 被丢弃 state {pinned_chats_v2: [], theme: dark} with open(path, w, encodingutf-8) as f: json.dump(state, f, ensure_asciiFalse, indent2) if __name__ __main__: first_launch_seed() with open(store_path(), r, encodingutf-8) as f: before json.load(f) print(更新前固定项:, before.get(pinned_chats)) bad_upgrade() with open(store_path(), r, encodingutf-8) as f: after json.load(f) print(更新后固定项(v2):, after.get(pinned_chats_v2)) # 输出更新后固定项(v2): [] —— 用户的固定项没了跑这段你会看到更新前还有 3 个固定项更新后变成[]。根因就是bad_upgrade把整个 state 重置了而不是迁移。五、方案给存储加上 schema 版本号第一步也是最关键的一步给本地存储一个明确的版本字段让新代码能判断这是旧数据需要迁移。import json import os import tempfile from typing import Any, Dict CURRENT_SCHEMA_VERSION 3 def store_path() - str: return os.path.join(tempfile.gettempdir(), good_app_state.json) def load_state() - Dict[str, Any]: try: with open(store_path(), r, encodingutf-8) as f: return json.load(f) except FileNotFoundError: return {schema_version: CURRENT_SCHEMA_VERSION}版本号是迁移的锚点每次结构变化CURRENT_SCHEMA_VERSION加一并写对应的迁移函数migrate_from_1_to_2、migrate_from_2_to_3等。六、方案幂等迁移函数迁移必须是幂等的——重复执行不能破坏数据也不能重复搬运。下面是正确写法def migrate(state: Dict[str, Any]) - Dict[str, Any]: 把任意旧版本 state 逐步升级到当前版本。幂等。 version state.get(schema_version, 1) # v1 - v2引入 pinned_chats_v2并把旧的 pinned_chats 搬过去 if version 2: old_pinned state.get(pinned_chats, []) # 新结构用 list[dict]保留原始 id 与顺序 state[pinned_chats_v2] [ {id: cid, order: i} for i, cid in enumerate(old_pinned) ] # 注意不要删除旧字段避免回滚时数据丢失可选保留一段时间 version 2 state[schema_version] version # v2 - v3固定项支持分组新增 group 字段缺省为 default if version 3: for item in state.get(pinned_chats_v2, []): item.setdefault(group, default) version 3 state[schema_version] version return state def save_state(state: Dict[str, Any]) - None: state[schema_version] CURRENT_SCHEMA_VERSION tmp store_path() .tmp with open(tmp, w, encodingutf-8) as f: json.dump(state, f, ensure_asciiFalse, indent2) # 原子替换先写临时文件再 rename避免写到一半崩溃导致文件损坏 os.replace(tmp, store_path())要点逐步升级从version当前值一步步走到最新而不是不是最新就重置。不丢旧字段迁移时保留pinned_chats一段时间方便回滚。原子写用临时文件 os.replace防止写入中途崩溃把存储写成半截 JSON。七、方案升级流程加保护备份 回滚真正的产品级更新还要再加两层保险import shutil from datetime import datetime def backup_before_upgrade() - str: 升级前对本地存储做一次时间戳备份。 path store_path() if not os.path.exists(path): return stamp datetime.now().strftime(%Y%m%d_%H%M%S) backup path f.bak.{stamp} shutil.copy2(path, backup) return backup def restore_from_backup(backup: str) - None: if backup and os.path.exists(backup): shutil.copy2(backup, store_path()) def safe_upgrade(): 带迁移 备份 异常回滚的启动流程。 backup backup_before_upgrade() try: state load_state() state migrate(state) # 幂等迁移 save_state(state) # 原子写 except Exception as exc: # 任何迁移失败都要回滚 print(f[warn] 迁移失败回滚: {exc}) restore_from_backup(backup) raise这样即使迁移脚本在某台机器上因奇怪数据抛异常用户也只是回到更新前状态而不是数据没了。八、验证写一个迁移回归测试把迁移逻辑用测试锁死避免未来某个版本又把它写坏def test_migrate_preserves_pinned(): old { schema_version: 1, pinned_chats: [a, b, c], theme: dark, } new migrate(old) ids [it[id] for it in new[pinned_chats_v2]] assert ids [a, b, c], ids assert all(it[group] default for it in new[pinned_chats_v2]) assert new[schema_version] 3 # 幂等对已是最新的 state 再跑一次不应改变结果 again migrate(new) assert again new if __name__ __main__: test_migrate_preserves_pinned() print(迁移回归测试通过固定项在升级后被完整保留。)把这类测试放进 CI每次改存储结构都必须更新对应迁移函数和测试从流程上杜绝更新丢固定项。九、排查清单遇到更新后固定项没了按顺序查看本地存储文件是否还在路径是否被安装器当缓存清掉固定项是否误存进Cache/目录看存储是否有schema_version字段没有就说明迁移逻辑无法判断新旧极易被重置。看onUpgrade/migrate是否执行了DROP或整体覆盖应该改成读旧数据→转换→写新结构。看写入是否原子非原子写中途崩溃会留下半截文件下次启动被当作损坏/空从而重置。看是否有并发首次启动的默认初始化线程和迁移线程是否抢同一文件导致空初始化覆盖迁移结果。看备份机制更新前是否对本地存储做了时间戳备份便于回滚验证。看平台差异macOS 的~/Library/Containers沙盒、Windows 的AppData\Local\Temp、Linux 的$XDG_CACHE_HOME是否被当临时目录清理。十、小结更新后固定项丢失表面是产品 bug根因几乎都在本地存储的版本化与迁移缺失没有 schema 版本号、迁移用覆盖代替搬运、写入不原子、位置选错。修复路径很清晰给存储加schema_version用逐步、幂等的migrate()代替整体重置用临时文件 os.replace做原子写升级前做时间戳备份失败即回滚用回归测试把迁移行为锁死在 CI。 做到这四点无论发多少个版本用户的置顶会话、收藏、固定项都不会再被一次正常更新悄悄抹掉