#7.7

核心实现:数据导出与恢复(数据搬家)

约 2.4 千字 · 在 GitHub 上查看源码

概览

数据搬家统一为三种层级能力:

  • 平台级搬家:全库逻辑快照导出/清空/恢复
  • 租户级搬家:面向连锁租户的“克隆式恢复”(重建 ID、重建关联)
  • 门店级搬家:面向单店租户(tenantType='SINGLE')的“门店克隆”(重建 ID、重建关联、重建桌台二维码)

默认导出规则:

  • 排除软删除数据(deletedAt IS NOT NULL
  • 平台级仅排除基础设施表(migrationsstore_default_migration_log),审计/日志表(如 good_spec_change_logsorder_status_logs)纳入导出与清理范围,避免恢复后残留历史日志造成语义错位

导入策略:

  • 平台级:完全还原(保留 ID)
  • 租户级/门店级:克隆式恢复(重建 ID,重建关联)

维护模式

维护模式用于“恢复期间禁操作 + 断开连接”:

  • 平台维护模式(全局)
    • 禁止所有操作(除恢复/清空本身)
    • 关闭现有连接,阻止新连接
    • 强制 token 全部失效(通过全局 token version bump)
  • 租户/门店维护模式(作用域)
    • 禁止写操作(读请求放行)
    • 关闭现有连接,阻止新连接
    • 不强制 token 失效(仍可读)

实现与入口:

平台级搬家

能力与范围

  • 范围:整个数据库(所有租户、门店、配置)
  • 行为:导出/清空/恢复
  • 恢复:完全还原(保留 ID)

接口

  • 导出:GET /api/v1/platform/snapshot/export
  • 恢复:POST /api/v1/platform/snapshot/import(multipart,字段 file
  • 清空:POST /api/v1/platform/snapshot/reset

鉴权:

  • requireAuth + requireAdmin + requireAdminContext + requireAdminRole('SUPER_ADMIN')

实现:

快照格式

平台快照(V1)为逻辑导出:

  • version: 1
  • exportedAt: ISO8601
  • tables: [{ name, rows }]

默认排除:

  • migrations(TypeORM 迁移元数据)
  • store_default_migration_log(默认门店初始化迁移日志)

除上述基础设施表外,所有 TypeORM 注册实体(包括 good_spec_change_logsorder_status_logs 等审计/日志表以及 usersadmin_scopes)均被纳入导出与恢复范围,保证"完全还原"语义下历史日志与业务数据时间线对齐。

恢复事务与模式漂移容忍

恢复逻辑运行在单个数据库事务内,外围关闭 FOREIGN_KEY_CHECKS 以允许拓扑无关的顺序重建:

  • Phase 1:对所有本地已注册的业务表执行 DELETE FROM(而非 TRUNCATE——后者在 MySQL 中触发隐式 COMMIT,会破坏"部分失败整体回滚"的原子性)。
  • Phase 2:按快照表逐批 INSERT,每批插入时以"快照行键集合 ∩ 本地列集合"求交集作为实际写入列,并以 MAX_PLACEHOLDERS_PER_INSERT = 20000 做批大小切分以避开 MySQL 65535 个占位符上限。

对跨环境的模式漂移做三层兜底,并将偏差以结构化 warnings 数组随 200 响应返回:

  • DROPPED_COLUMNS:快照行中存在、但本地模式未知的列——直接丢弃,对应容忍生产侧残留的"僵尸列"。
  • UNKNOWN_TABLE:快照存在、但本地模式未知的表——整表跳过,附带 rowCount 让运维知道被忽略的数据规模。
  • MISSING_TABLE:本地已注册、但快照未覆盖的表——Phase 1 被清空、Phase 2 无数据回填,提醒运维当前上传的是一份不完整的跨版本或部分快照。
{
  "warnings": [
    { "kind": "DROPPED_COLUMNS", "table": "goods", "columns": ["imageUrl"] },
    { "kind": "UNKNOWN_TABLE", "table": "legacy_xxx", "rowCount": 12 },
    { "kind": "MISSING_TABLE", "table": "notifications" }
  ]
}

这一机制让生产侧因历史迁移遗漏而残留的"僵尸列"、或者跨版本/部分快照造成的"表覆盖缺口"都不再是恢复失败的硬阻塞——操作人员可以按 warnings 定位真正需要补齐的 migration 或快照内容,在下一次恢复前修正即可。

结果通过平台通知回传

恢复调用的副作用之一是触发 bumpGlobalTokenVersion()——包括发起方在内的所有登录态都被作废,HTTP 响应体(含 warnings)对前端已不可见。为了让结果仍能到达运维,恢复结束时(成功或失败)会向 notifications 表写入一条平台作用域通知(tenantId=null, storeId=null):

  • type=PLATFORM_RESTORE_SUCCEEDEDpayload.warnings 复刻 HTTP 响应里的 warnings 数组,payload.restoredAt 为恢复完成时间。
  • type=PLATFORM_RESTORE_FAILEDpayload.errorMessage 为错误信息(事务回滚后业务表保持恢复前状态,维护态在 finally 中被释放)。

调用方重新登录后在 Web 管理端 AppBar 的通知中心(GET /api/v1/admin/notifications)即可看到结果。通知写入本身做了"尽力而为"——即便通知表不可用也不会把一次成功的恢复误报为失败。

清空行为:

  • 导出/导入覆盖全部业务表,包括 usersadmin_scopes,平台级搬家为全量覆盖,不会遗漏顾客、管理员账号与授权绑定。
  • reset 是独立于导出/导入之外的清空动作,会清空大部分业务表,但刻意保留 usersadmin_scopes —— 否则执行操作的平台管理员自身的登录态与权限绑定会被一并清除,无法再发起后续的导入调用。

租户级搬家(连锁)

目标

  • 对指定连锁租户执行导出与克隆式恢复
  • 恢复必须重建(克隆式):
    • 业务配置类 ID:分类、菜品、SKU、规格组/选项、共享规格、桌台等
    • 所有关系:菜品-分类、多对多/关联表、规格/共享规格关系
    • 桌台二维码(注意频控,建议 QPS=1)
  • 不影响:
    • 平台用户体系(顾客)
    • 其他租户/门店
    • 本租户管理员账号

状态与前端提示

  • 恢复期间租户进入维护模式:禁写、断开连接、阻止新连接
  • Web 管理端 AppBar 展示「恢复中」
  • 恢复完成发送通知(租户级)

接口

  • 导出:GET /api/v1/tenant/snapshot/export?includeInactive=1
  • 恢复:POST /api/v1/tenant/snapshot/import(multipart,字段 file
  • 从单店租户导出恢复到连锁主店:POST /api/v1/tenant/snapshot/import-from-store(multipart,字段 file
  • 清空:POST /api/v1/tenant/snapshot/reset

鉴权:

  • requireAuth + requireAdmin + requireAdminContext + requireAdminRole('TENANT_ADMIN')

实现:

快照格式

租户快照(V1)本质为多个门店的 store export 组合:

  • version: 1
  • tenantId
  • stores: StoreExportV2[]

其中 StoreExportV2 为门店导出格式(version: 2),由 parseStoreExport 校验与解析。

二维码重建

桌台存在两种扫码渠道:小程序 qrcodeUrl 与 H5 h5QrcodeUrl。导出包将两者一并写入 StoreExportV2.store.tables[*],恢复流程按“每秒 1 次”节流分别重建:

  • 小程序二维码:需 config.wechatMiniProgram.appId/secret;未配置则跳过并保留 qrcodeUrl=null
  • H5 二维码:需 config.h5AppDomainconfig.cos.* COS 凭证;未配置则跳过并保留 h5QrcodeUrl=null

两种二维码互相独立,任一条件不具备均会单独跳过,便于后续补配后再次触发重建。

跨门店 templateId 映射

连锁子门店的分类/菜品/规格/共享规格通过 templateId 指向主门店对应实体。租户级恢复按“主店优先”顺序处理:先克隆主门店并汇总其 categoryIdMap / goodIdMap / specGroupIdMap / specOptionIdMap / sharedGroupIdMap / sharedOptionIdMap,再将这一组旧→新映射作为 externalTemplateIdMap 传入各子门店的克隆过程,使子门店插入时的 templateId 指向新主店对应行,避免同步链断裂。

从门店导出恢复为连锁主门店

使用场景:使用已有门店的导出数据作为 CHAIN 租户“新主门店”的初始配置。

约束与行为:

  • 仅支持在 CHAIN 租户下执行。
  • 执行流程(服务端自动完成):
    • 清空并软删当前租户下所有门店(含旧主店与所有子店)
    • 新建一个“干净的主门店”(生成新的 storeId,并更新 tenants.primaryStoreId
    • 将门店导出(StoreExportV2)克隆式导入到新主门店(重建配置类 ID、重建关联)
  • 该模式会改变主门店 storeId,前端需提示用户恢复完成后重新选择主门店上下文。

门店级搬家(单店租户)

目标

  • 对单店租户的主门店执行导出与克隆式恢复(重建 ID,重建关联)
  • 适用范围:单店租户(tenantType='SINGLE')的主门店
  • 通过克隆式重建避免跨租户 ID 冲突
  • 恢复完成发送通知(门店级)

接口

  • 导出:GET /api/v1/stores/export
  • 恢复:POST /api/v1/stores/import(multipart,字段 file
  • 清空:POST /api/v1/stores/reset

鉴权:

  • requireAuth + requireAdmin + requireAdminContext + requireAdminRole('STORE_ADMIN')

实现:

二维码重建(门店)

  • applyStoreConfigClone 会将新插入桌台的 qrcodeUrlh5QrcodeUrl 一并置空,随后在导入流程中按“每秒 1 次”节流分别重建小程序二维码与 H5 二维码(各自依赖对应的凭证/域名配置)。

测试流程:测试前备份平台、测试后恢复平台

后端测试覆盖率脚本已改为平台级快照:

  • 测试开始前:执行平台导出并写入 .test-platform-snapshot.json
  • 测试结束后:执行平台恢复,保证环境不污染

脚本入口(沿用原名字,但已切换为平台快照):

Web 管理端入口与提示