Untitled

title: 把云开发迁到自建后端后,我给生产库写了个「删前快照、一键回滚」的清理工具
date: 2026-09-25
tags: 后端工程, 宝可猜谜挑战, Fastify, SQLite, 数据安全
slug: pokemon-back-reversible-clear-tool
description: 微信小程序后端从云开发迁到 Fastify+Prisma+SQLite 后,把"重置用户某天进度"固化成带快照、dry-run、回滚的可逆清理工具,聊生产破坏性操作的工程纪律。

从云开发到自建后端

做了十年前端,主力小程序「宝可问答助手」(宝可梦猜谜)原本跑在微信云开发上:9 个云函数 + 9 个 Records 集合。近期因为要接 QQ 端、且云开发调用量逼近 20 万次/月的套餐上限,我把后端整体迁到了自建服务 pokemon-back——Fastify 5 + Prisma 6 + SQLite,TypeScript,部署在腾讯云 Ubuntu(PORT=3200,PM2 常驻,Nginx 反代 + HTTPS,开了 gzip 后 JSON 体积约 -80%)。

迁移不是把云函数改成 HTTP 接口就完事。联调时经常要"重置某用户某天的进度",也就是删除 Record 表里 (userId, date) 这一个切片。直接在服务器敲裸 SQL 太危险:删错表、误伤别的用户,基本没有回头路。

把"清理"做成可逆操作

我把它固化成一个小工具 scripts/clear-day.ts,四道防线:

  1. 删前快照。删除前先把命中行完整写入 ~/backups/pokemon-back/clear-uid<uid>-<date>-<ts>.json,字段一字不落。
  2. dry-run 预览。--dry 只列出命中哪些、不动库,确认范围无误再真删。
  3. 删除后复核。删完立刻 count 复核该切片剩余数,并打出该用户总记录数做交叉验证。
  4. 一键回滚。--restore <快照> 把记录塞回。

回滚为什么用 upsert 而不是 insert

清库后用户可能又在同一天玩了同名玩法,(userId, quizType, date) 唯一键已被占用,盲插会撞 P2002。所以回滚走 upsert:缺的补上、已有的覆盖回快照值,幂等且可重复执行。快照只取白名单字段,回滚时不带原 id,交给数据库重新分配主键,避免和现存主键冲突。

await prisma.record.upsert({
  where: { userId_quizType_date: key },
  update: { solved: r.solved, tries: r.tries, attempts: r.attempts, extra: r.extra },
  create: r,
});

日期口径统一用 new Date(Date.now() + 8e3 * 3600).toISOString().slice(0, 10)——北京时区,与云函数、小程序端"今日"完全一致,避免"服务器时区差一天清错日期"这类经典坑。

生产实跑记录

2026-09-25 在服务器实跑:

ssh drew-server 'cd /var/www/pokemon-back && npx tsx scripts/clear-day.ts'
// dry-run 命中 9 条(type/dex/silhouette/stats/move/ability/item/wordle/pokedoku)
// 真删 9 条 → 校验剩余=0(该用户总记录=118)

整个过程先看清、再确认、留了后路,没有"手心出汗"的时刻。

两个边界漏洞

  • SprintRecord(冲刺模式成绩)不在清理范围:它无唯一约束、可重复玩,脚本有意不碰。要清需单独处理。
  • 服务端清完还不够:小程序本地缓存 dqc:<玩法>:common:<日期> 与 <玩法>.progress 仍会让页面显示"今天已完成"。这说明"清数据"是服务端 + 客户端两件事,缺一不可。

工程纪律的延伸

这次迁移还顺手把运维补齐:每日 04:30 SQLite 备份(cp + 文件头校验 + 按天保留 + 超期清理)、日志轮转兜底压缩、健康检查脚本(异常推 Server 酱、同问题 1 小时去重)。数据迁移则靠 POST /api/import + 一次性云函数从 CloudBase 搬运,零三方依赖。

我的体会是:生产环境凡是"删"都该默认可逆。快照、dry-run、复核、回滚这四件套,把一次高危动作拆成"先看清、再确认、留后路"的流程——这不是过度设计,是对线上数据基本的敬畏。