返回文章列表
/ 架构

单库多租户改造方案

从「共享库 + 每店独立业务库」到单库行级隔离,拆解数据源模型、实体 storeId 与查询上下文过滤三类变化。

多租户数据隔离架构演进

背景

当前项目的店铺数据模型是“共享库 + 每店独立业务库”。

  • 共享库中的 Store 保存店铺元数据和 databasePath
  • 业务查询大多不显式带 storeId
  • 隔离主要依赖“按店铺切换不同 DataSource”来实现

因此,如果要把多个店铺改为放在同一个业务库中,同时仍然保持店铺间数据隔离,就不能只改数据库路径,还需要把现有代码改造成“单库行级隔离”。

本文档默认目标是:

  1. 所有店铺共用一个业务库文件
  2. 数据仍按店铺隔离
  3. 第一版不做跨店共享会员

总体结论

这不是简单的配置调整,而是一次中等偏大的结构性改造。

核心变化有三类:

  1. 数据源模型从“按店铺切库”改成“固定单库 + 请求上下文租户过滤”
  2. 业务实体从“天然分库隔离”改成“统一补 storeId 行级隔离”
  3. 前后台所有查询、写入、唯一约束、初始化脚本都要围绕 storeId 重构

建议按以下顺序实施:

  1. 先改数据访问基础设施
  2. 再改实体与数据库迁移
  3. 再改前后台查询逻辑
  4. 最后做联调、校验和灰度切换

前提假设

  1. Store 仍然是共享表,继续放在共享库
  2. 除 Store 外,现有业务表默认都视为店铺私有数据
  3. 第一版不做跨店共享会员
  4. 主键 id 第一版尽量保持不变,通过 storeId + 业务键 做隔离与唯一约束

一、逐文件改造清单

1. 数据源与租户上下文

admin/database/data-source.ts

职责变化:从“按店铺切换不同业务库”改为“始终返回同一个业务库,并保留当前店铺上下文”。

需要修改:

  1. 保留 runWithStoreContext
  2. getStoreDataSource 不再用 store.databasePath 创建独立 DataSource
  3. getCurrentStoreDataSource 始终返回同一个业务 DataSource
  4. 新增当前租户辅助能力,例如:
    • 当前店铺 id
    • 当前店铺 code
    • 当前租户 where 条件构造器
  5. SharedDataSource 继续只处理共享表 Store
  6. storeDataSourceCache 这类“按店铺缓存 DataSource”的逻辑可以删除,或退化成单实例缓存
  7. createStoreDataSource 改成返回同一个业务库连接,不能再根据 databasePath 决定库文件
  8. getStoreByCode 里默认店铺自动建档的逻辑可保留,但 Store.databasePath 将变成兼容字段

admin/decorators/data-source.ts

职责变化:当前只是读取 storeCode 后切库,后续要变成“绑定租户上下文”。

需要修改:

  1. 继续读取 query.storeCode 和 x-store-code
  2. 装饰器返回的仍是 ShopDataSource,但底层应为单库版实现
  3. 最好同时触发统一的“当前租户上下文绑定”
  4. 如果框架接口扩展性不足,至少要让请求级上下文能被后续 Repository 包装层读取

admin/index.ts

需要修改:

  1. 对外导出的 getStoreDataSource 和 getCurrentStoreDataSource 语义保持兼容
  2. 底层改成单库实现
  3. 如果新增 getCurrentStoreId 之类能力,也在这里导出

2. 共享实体与店铺元数据

admin/database/entities.ts

需要修改:

  1. Store 继续保留 code、name、theme、status、description
  2. databasePath 建议改成可空,或保留但标记为兼容字段
  3. 可选新增字段:
    • migrationStatus
    • tenantMode

3. 业务实体

admin/database/entities.ts

这是单库多租户改造的核心文件。

需要修改:

  1. 为所有店铺私有实体补 storeId
  2. 必要时增加索引和唯一约束
  3. 所有依赖唯一性的查询都改成 storeId + 业务键

建议第一批必须补 storeId 的实体:

  1. ProductCategory
  2. Product
  3. ProductSpecification
  4. ProductSpecificationValue
  5. ProductSKU
  6. Warehouse
  7. Inventory
  8. Order
  9. OrderItem
  10. BlogCategory
  11. BlogTag
  12. BlogArticle
  13. BlogComment
  14. Menu
  15. ProductVisit
  16. ProductFavorite
  17. Banner
  18. Coupon
  19. CouponUsage
  20. StorePage
  21. StoreMenuItem
  22. Dataset
  23. MemberLevel
  24. Member
  25. Address
  26. Region

4. 店铺页面与设计接口

store/middlewares/store-page.ts

需要修改:

  1. getStorePageRecords 不能再直接全表读取
  2. 页面记录查询必须按当前 storeId 过滤
  3. 页面匹配命中后仍要验证记录属于当前店铺
  4. 如果要支持默认页面回退,需要定义明确优先级:
    • 当前店铺页面
    • 通用页面
    • 文件页面

admin/controllers/admin/store.ts

需要修改:

  1. getPageData 查询 StorePage 时改成按 id + storeId
  2. savePageData 更新 StorePage 时改成按 id + storeId
  3. getPageRecord 查询时改成按 id + storeId
  4. 页面不存在时若允许创建,必须写入 storeId
  5. 最好给 StorePage 增加 storeId + path 唯一约束

admin/public/services/store-service.ts

需要修改:

  1. 当前已经会附加 storeCode,机制可保留
  2. 一般无需大改,但前端要准备处理更严格的多租户校验错误

admin/public/store-context.ts

需要修改:

  1. 基本可不改
  2. 可选增加“未选择店铺时禁用店铺私有操作”的前端保护

admin/public/index.ts

需要关注:

  1. 当前全局 fetch 会给 /api/data、/api/data-source、/api/page 自动附加 storeCode
  2. 机制不一定要改,但服务端必须把 storeCode 真正消费成租户过滤条件

5. 前台数据装载与业务 API

store/modules/data-loaders.ts

需要修改:

  1. storeMenuItem 查询加 storeId
  2. banner 查询加 storeId
  3. dataset 查询加 storeId
  4. product 查询加 storeId
  5. favorites 查询加 storeId
  6. coupon 和 couponUsage 查询加 storeId
  7. member 查询加 storeId
  8. address 查询加 storeId
  9. order 查询加 storeId
  10. 所有创建、保存逻辑写入 storeId
  11. 所有按 id 查询的逻辑都改成 id + storeId 或显式附带 storeId

store/middlewares/member-favorites-api.ts

需要修改:

  1. 收藏列表按 storeId + memberId 查询
  2. 商品校验按 storeId + productId
  3. 收藏去重按 storeId + memberId + productId
  4. 新建 favorite 时写入 storeId
  5. 自动创建游客 member 时写入 storeId
  6. “读取第一个 member” 的逻辑必须限制当前店铺

store/middlewares/paypal-checkout-api.ts

需要修改:

  1. 购物车商品查询改成按 storeId + productId
  2. 创建订单时写入 storeId
  3. order_item 最好也写入 storeId
  4. 支付回调定位订单时,改成按 storeId + paymentProviderOrderId
  5. 自动创建游客 member 时写入 storeId
  6. 所有订单查找、更新、补偿都带当前店铺限制

6. 初始化与脚本

scripts/init-store.ts

当前逻辑是复制模板数据库并登记店铺,单库后需要改成:

  1. 去掉复制整库文件
  2. 新建店铺时只在共享 Store 表登记
  3. 如果需要默认内容,改成显式插入默认 StorePage、Banner、Menu 等
  4. 输出结果不再强调 databasePath
  5. 若保留兼容参数,可以继续接受 databasePath 但忽略它

7. 测试

admin/database/data-source.test.ts

建议补充:

  1. 当前租户上下文测试
  2. 单库下不同店铺互不串数据的测试
  3. 默认店铺行为测试

admin/database/migrate-page-imports.test.ts

建议补充:

  1. StorePage 在不同店铺下相同 path 不冲突的测试
  2. 页面对当前 storeId 的读取和更新测试

store/middlewares/resolve-store-request-context.test.ts

一般不需要大改,但可新增一类集成测试:

  1. 路由解析出的 storeCode 能进入租户过滤链路

8. 后台通用数据接口

这是改造中风险最高的部分。

从 admin/public/index.ts 可以看到,后台会自动给这些接口附加 storeCode:

  1. /api/data
  2. /api/data-source
  3. /api/page

这意味着很多后台页面可能不是走自定义 controller,而是走通用数据接口。

这里必须确保:

  1. 通用数据接口也能读取当前租户上下文
  2. 所有列表、详情、保存、删除都会自动带 storeId
  3. 如果外部框架无法自动扩展,需要在项目中增加包裹层或拦截层

如果这一层没处理好,后台列表页和详情页会直接串店。


二、数据库迁移设计

目标结构原则

  1. 保留共享库:继续承载 Store
  2. 新建单业务库:承接原来所有店铺业务表
  3. 每张店铺私有表都增加 storeId
  4. 高频查询字段增加索引
  5. 唯一约束改成“storeId + 业务键”

第一阶段:结构迁移

在单业务库中为以下表新增 storeId,建议类型为 varchar:

  1. product_category
  2. product
  3. product_specification
  4. product_specification_value
  5. product_sku
  6. warehouse
  7. inventory
  8. order
  9. order_item
  10. blog_category
  11. blog_tag
  12. blog_article
  13. blog_comment
  14. menu
  15. product_visit
  16. product_favorite
  17. banner
  18. coupon
  19. coupon_usage
  20. store_page
  21. store_menu_item
  22. dataset
  23. member_level
  24. member
  25. address
  26. region

建议迁移分两步:

  1. 先新增为可空字段
  2. 数据回填完成后再改为非空

同时建议:

  1. 每张租户表先加 storeId 普通索引
  2. 高频查询表再补复合索引,例如:
    • storeId + createdAt
    • storeId + status
    • storeId + sortNumber

第二阶段:唯一约束调整

建议改成以下形式:

store_page

  1. 唯一键:storeId + path
  2. 如页面名称也需要隔离,可加:storeId + name

product_favorite

  1. 唯一键:storeId + memberId + productId

product

如果 code 有业务唯一语义:

  1. 唯一键:storeId + code

product_category

如果 code 或层级名称需要唯一:

  1. 唯一键:storeId + code
  2. 或 storeId + parentId + name

coupon

如果券码要求店铺内唯一:

  1. 唯一键:storeId + code

order

  1. 订单号建议唯一键:storeId + orderNumber
  2. 支付回调定位建议索引或唯一键:storeId + paymentProviderOrderId

dataset

如果名称需要避免重复:

  1. 唯一键:storeId + type + name

按业务需要决定是否加:

  1. storeId + name
  2. storeId + position + sortNumber

第三阶段:数据迁移脚本设计

建议单独实现一个一次性迁移脚本,流程如下:

  1. 从共享库读取全部启用店铺
  2. 对每个店铺读取其原业务库路径
  3. 对每张业务表执行:
    • 读取旧库全部数据
    • 为每条记录补 storeId = 当前店铺 id 或 code
    • 写入新单库对应表
  4. 每张表写入后做计数校验
  5. 生成迁移报告

推荐迁移顺序:

  1. region
  2. member_level
  3. member
  4. address
  5. product_category
  6. product
  7. product_specification
  8. product_specification_value
  9. product_sku
  10. warehouse
  11. inventory
  12. blog_category
  13. blog_tag
  14. blog_article
  15. blog_comment
  16. menu
  17. banner
  18. dataset
  19. coupon
  20. store_page
  21. store_menu_item
  22. product_visit
  23. product_favorite
  24. order
  25. order_item
  26. coupon_usage

每张表迁移完成后,至少校验:

  1. 旧库行数
  2. 新库中对应 storeId 的行数
  3. 是否一致

建议输出迁移报告内容:

  1. 店铺 code
  2. 旧库路径
  3. 每张表迁移条数
  4. 失败记录数
  5. 冲突记录数

第四阶段:冲突处理策略

主键 id 冲突

  1. 如果旧库之间 id 基本为 GUID,可先直接保留
  2. 如果发现人工编码 id 冲突:
    • 优先记录冲突
    • 对冲突记录重新分配 id
    • 建立旧新 id 映射表

业务唯一键冲突

  1. 不同店铺都存在 /home 页面路径是正常情况
  2. 改成 storeId + path 后即可并存

支付订单号冲突

  1. 如果 orderNumber 不是全局唯一,要改成 storeId + orderNumber 定位

第五阶段:回填策略

迁移落库后执行两步回填:

  1. 检查所有新增 storeId 字段是否还有空值
  2. 确认无空值后,再把字段改为非空

第六阶段:切换策略

推荐分三步切换:

  1. 新单库建立并完成历史迁移,代码仍跑旧逻辑
  2. 灰度切换页面配置类表:
    • StorePage
    • StoreMenuItem
    • Banner
    • Dataset
  3. 全量切换商品、收藏、订单、会员等主业务链路

这样一旦出现问题,回滚范围更小。


三、建议先做的最小落地包

为了降低风险,建议第一批先只改下面这些文件和表,验证路线是否成立。

文件

  1. admin/database/data-source.ts
  2. admin/decorators/data-source.ts
  3. admin/database/entities.ts
  4. admin/controllers/admin/store.ts
  5. store/middlewares/store-page.ts
  6. store/modules/data-loaders.ts
  7. scripts/init-store.ts

表

  1. store_page
  2. store_menu_item
  3. banner
  4. dataset
  5. product
  6. product_favorite

这一批完成后可验证的问题

  1. 店铺页面是否串店
  2. 首页菜单和 Banner 是否串店
  3. 商品页是否串店
  4. 收藏是否串店
  5. 后台页面设计器是否按当前店铺工作

四、风险最高点

风险最高点不是实体补字段,而是后台通用数据接口这一层。

因为后台前端会自动给 /api/data、/api/data-source、/api/page 加上 storeCode,但当前服务端这层大概率只是用 storeCode 来选择数据源,而不是统一转成 storeId 过滤。

如果这一层不先解决,就会出现:

  1. 后台列表页串店
  2. 后台详情页串店
  3. 后台通用编辑页串店
  4. 你即使修完自定义 controller,后台仍然不安全

因此,实施时必须优先确认:

  1. 通用数据接口是否可扩展租户过滤
  2. 若不可扩展,是否需要在项目中加一层包裹或拦截机制

五、工作量评估

在“不做跨店共享会员”的前提下,合理估算如下:

  1. 基础设施改造:1 到 2 天
  2. 实体补字段与迁移脚本:1 到 2 天
  3. 前后台查询改造:2 到 4 天
  4. 联调与数据校验:1 到 2 天

总体大致为:5 到 10 天。

如果后台通用数据接口来自外部框架且扩展困难,时间还会增加。


六、推荐下一步

建议从以下两个方向中先做一个:

  1. 先实现“最小落地包”,验证单库多租户方案在页面、菜单、Banner、商品、收藏链路上是否可行
  2. 先输出数据库迁移脚本或 TypeORM 迁移草案,把数据结构和迁移路径锁定下来