概览
数据搬家统一为三种层级能力:
- 平台级搬家:全库逻辑快照导出/清空/恢复
- 租户级搬家:面向连锁租户的“克隆式恢复”(重建 ID、重建关联)
- 门店级搬家:面向单店租户(
tenantType='SINGLE')的“门店克隆”(重建 ID、重建关联、重建桌台二维码)
默认导出规则:
- 排除软删除数据(
deletedAt IS NOT NULL) - 平台级仅排除基础设施表(
migrations、store_default_migration_log),审计/日志表(如good_spec_change_logs、order_status_logs)纳入导出与清理范围,避免恢复后残留历史日志造成语义错位
导入策略:
- 平台级:完全还原(保留 ID)
- 租户级/门店级:克隆式恢复(重建 ID,重建关联)
维护模式
维护模式用于“恢复期间禁操作 + 断开连接”:
- 平台维护模式(全局)
- 禁止所有操作(除恢复/清空本身)
- 关闭现有连接,阻止新连接
- 强制 token 全部失效(通过全局 token version bump)
- 租户/门店维护模式(作用域)
- 禁止写操作(读请求放行)
- 关闭现有连接,阻止新连接
- 不强制 token 失效(仍可读)
实现与入口:
- HTTP 拦截:maintenanceGuard
- 维护态存储与事件总线:maintenance.ts
- WS 断连/拒绝新连:
- 前端查询维护态(用于 AppBar「恢复中」):adminMaintenance.ts
平台级搬家
能力与范围
- 范围:整个数据库(所有租户、门店、配置)
- 行为:导出/清空/恢复
- 恢复:完全还原(保留 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')
实现:
- platformSnapshot.ts
- 维护模式:全局维护态 + 全局 token version bump
快照格式
平台快照(V1)为逻辑导出:
version: 1exportedAt: ISO8601tables: [{ name, rows }]
默认排除:
migrations(TypeORM 迁移元数据)store_default_migration_log(默认门店初始化迁移日志)
除上述基础设施表外,所有 TypeORM 注册实体(包括 good_spec_change_logs、order_status_logs 等审计/日志表以及 users、admin_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_SUCCEEDED:payload.warnings复刻 HTTP 响应里的warnings数组,payload.restoredAt为恢复完成时间。type=PLATFORM_RESTORE_FAILED:payload.errorMessage为错误信息(事务回滚后业务表保持恢复前状态,维护态在finally中被释放)。
调用方重新登录后在 Web 管理端 AppBar 的通知中心(GET /api/v1/admin/notifications)即可看到结果。通知写入本身做了"尽力而为"——即便通知表不可用也不会把一次成功的恢复误报为失败。
清空行为:
- 导出/导入覆盖全部业务表,包括
users与admin_scopes,平台级搬家为全量覆盖,不会遗漏顾客、管理员账号与授权绑定。 reset是独立于导出/导入之外的清空动作,会清空大部分业务表,但刻意保留users与admin_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')
实现:
- tenantSnapshot.ts
- 核心克隆函数复用门店级:applyStoreConfigClone
快照格式
租户快照(V1)本质为多个门店的 store export 组合:
version: 1tenantIdstores: StoreExportV2[]
其中 StoreExportV2 为门店导出格式(version: 2),由 parseStoreExport 校验与解析。
二维码重建
桌台存在两种扫码渠道:小程序 qrcodeUrl 与 H5 h5QrcodeUrl。导出包将两者一并写入 StoreExportV2.store.tables[*],恢复流程按“每秒 1 次”节流分别重建:
- 小程序二维码:需
config.wechatMiniProgram.appId/secret;未配置则跳过并保留qrcodeUrl=null。 - H5 二维码:需
config.h5AppDomain与config.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')
实现:
- stores.ts
- 核心克隆函数:applyStoreConfigClone
二维码重建(门店)
applyStoreConfigClone会将新插入桌台的qrcodeUrl与h5QrcodeUrl一并置空,随后在导入流程中按“每秒 1 次”节流分别重建小程序二维码与 H5 二维码(各自依赖对应的凭证/域名配置)。
测试流程:测试前备份平台、测试后恢复平台
后端测试覆盖率脚本已改为平台级快照:
- 测试开始前:执行平台导出并写入
.test-platform-snapshot.json - 测试结束后:执行平台恢复,保证环境不污染
脚本入口(沿用原名字,但已切换为平台快照):
Web 管理端入口与提示
- AppBar「恢复中」提示:轮询
GET /api/v1/admin/maintenance,任一 scope 处于 enabled 即展示。 - 搬家入口页: