背景
当前项目的店铺数据模型是“共享库 + 每店独立业务库”。
- 共享库中的
Store保存店铺元数据和databasePath - 业务查询大多不显式带
storeId - 隔离主要依赖“按店铺切换不同 DataSource”来实现
因此,如果要把多个店铺改为放在同一个业务库中,同时仍然保持店铺间数据隔离,就不能只改数据库路径,还需要把现有代码改造成“单库行级隔离”。
本文档默认目标是:
- 所有店铺共用一个业务库文件
- 数据仍按店铺隔离
- 第一版不做跨店共享会员
总体结论
这不是简单的配置调整,而是一次中等偏大的结构性改造。
核心变化有三类:
- 数据源模型从“按店铺切库”改成“固定单库 + 请求上下文租户过滤”
- 业务实体从“天然分库隔离”改成“统一补
storeId行级隔离” - 前后台所有查询、写入、唯一约束、初始化脚本都要围绕
storeId重构
建议按以下顺序实施:
- 先改数据访问基础设施
- 再改实体与数据库迁移
- 再改前后台查询逻辑
- 最后做联调、校验和灰度切换
前提假设
Store仍然是共享表,继续放在共享库- 除
Store外,现有业务表默认都视为店铺私有数据 - 第一版不做跨店共享会员
- 主键
id第一版尽量保持不变,通过storeId + 业务键做隔离与唯一约束
一、逐文件改造清单
1. 数据源与租户上下文
admin/database/data-source.ts
职责变化:从“按店铺切换不同业务库”改为“始终返回同一个业务库,并保留当前店铺上下文”。
需要修改:
- 保留
runWithStoreContext getStoreDataSource不再用store.databasePath创建独立DataSourcegetCurrentStoreDataSource始终返回同一个业务DataSource- 新增当前租户辅助能力,例如:
- 当前店铺 id
- 当前店铺 code
- 当前租户
where条件构造器
SharedDataSource继续只处理共享表StorestoreDataSourceCache这类“按店铺缓存 DataSource”的逻辑可以删除,或退化成单实例缓存createStoreDataSource改成返回同一个业务库连接,不能再根据databasePath决定库文件getStoreByCode里默认店铺自动建档的逻辑可保留,但Store.databasePath将变成兼容字段
admin/decorators/data-source.ts
职责变化:当前只是读取 storeCode 后切库,后续要变成“绑定租户上下文”。
需要修改:
- 继续读取
query.storeCode和x-store-code - 装饰器返回的仍是
ShopDataSource,但底层应为单库版实现 - 最好同时触发统一的“当前租户上下文绑定”
- 如果框架接口扩展性不足,至少要让请求级上下文能被后续 Repository 包装层读取
admin/index.ts
需要修改:
- 对外导出的
getStoreDataSource和getCurrentStoreDataSource语义保持兼容 - 底层改成单库实现
- 如果新增
getCurrentStoreId之类能力,也在这里导出
2. 共享实体与店铺元数据
admin/database/entities.ts
需要修改:
Store继续保留code、name、theme、status、descriptiondatabasePath建议改成可空,或保留但标记为兼容字段- 可选新增字段:
migrationStatustenantMode
3. 业务实体
admin/database/entities.ts
这是单库多租户改造的核心文件。
需要修改:
- 为所有店铺私有实体补
storeId - 必要时增加索引和唯一约束
- 所有依赖唯一性的查询都改成
storeId + 业务键
建议第一批必须补 storeId 的实体:
ProductCategoryProductProductSpecificationProductSpecificationValueProductSKUWarehouseInventoryOrderOrderItemBlogCategoryBlogTagBlogArticleBlogCommentMenuProductVisitProductFavoriteBannerCouponCouponUsageStorePageStoreMenuItemDatasetMemberLevelMemberAddressRegion
4. 店铺页面与设计接口
store/middlewares/store-page.ts
需要修改:
getStorePageRecords不能再直接全表读取- 页面记录查询必须按当前
storeId过滤 - 页面匹配命中后仍要验证记录属于当前店铺
- 如果要支持默认页面回退,需要定义明确优先级:
- 当前店铺页面
- 通用页面
- 文件页面
admin/controllers/admin/store.ts
需要修改:
getPageData查询StorePage时改成按id + storeIdsavePageData更新StorePage时改成按id + storeIdgetPageRecord查询时改成按id + storeId- 页面不存在时若允许创建,必须写入
storeId - 最好给
StorePage增加storeId + path唯一约束
admin/public/services/store-service.ts
需要修改:
- 当前已经会附加
storeCode,机制可保留 - 一般无需大改,但前端要准备处理更严格的多租户校验错误
admin/public/store-context.ts
需要修改:
- 基本可不改
- 可选增加“未选择店铺时禁用店铺私有操作”的前端保护
admin/public/index.ts
需要关注:
- 当前全局
fetch会给/api/data、/api/data-source、/api/page自动附加storeCode - 机制不一定要改,但服务端必须把
storeCode真正消费成租户过滤条件
5. 前台数据装载与业务 API
store/modules/data-loaders.ts
需要修改:
storeMenuItem查询加storeIdbanner查询加storeIddataset查询加storeIdproduct查询加storeIdfavorites查询加storeIdcoupon和couponUsage查询加storeIdmember查询加storeIdaddress查询加storeIdorder查询加storeId- 所有创建、保存逻辑写入
storeId - 所有按
id查询的逻辑都改成id + storeId或显式附带storeId
store/middlewares/member-favorites-api.ts
需要修改:
- 收藏列表按
storeId + memberId查询 - 商品校验按
storeId + productId - 收藏去重按
storeId + memberId + productId - 新建 favorite 时写入
storeId - 自动创建游客 member 时写入
storeId - “读取第一个 member” 的逻辑必须限制当前店铺
store/middlewares/paypal-checkout-api.ts
需要修改:
- 购物车商品查询改成按
storeId + productId - 创建订单时写入
storeId order_item最好也写入storeId- 支付回调定位订单时,改成按
storeId + paymentProviderOrderId - 自动创建游客 member 时写入
storeId - 所有订单查找、更新、补偿都带当前店铺限制
6. 初始化与脚本
scripts/init-store.ts
当前逻辑是复制模板数据库并登记店铺,单库后需要改成:
- 去掉复制整库文件
- 新建店铺时只在共享
Store表登记 - 如果需要默认内容,改成显式插入默认
StorePage、Banner、Menu等 - 输出结果不再强调
databasePath - 若保留兼容参数,可以继续接受
databasePath但忽略它
7. 测试
admin/database/data-source.test.ts
建议补充:
- 当前租户上下文测试
- 单库下不同店铺互不串数据的测试
- 默认店铺行为测试
admin/database/migrate-page-imports.test.ts
建议补充:
StorePage在不同店铺下相同 path 不冲突的测试- 页面对当前
storeId的读取和更新测试
store/middlewares/resolve-store-request-context.test.ts
一般不需要大改,但可新增一类集成测试:
- 路由解析出的
storeCode能进入租户过滤链路
8. 后台通用数据接口
这是改造中风险最高的部分。
从 admin/public/index.ts 可以看到,后台会自动给这些接口附加 storeCode:
/api/data/api/data-source/api/page
这意味着很多后台页面可能不是走自定义 controller,而是走通用数据接口。
这里必须确保:
- 通用数据接口也能读取当前租户上下文
- 所有列表、详情、保存、删除都会自动带
storeId - 如果外部框架无法自动扩展,需要在项目中增加包裹层或拦截层
如果这一层没处理好,后台列表页和详情页会直接串店。
二、数据库迁移设计
目标结构原则
- 保留共享库:继续承载
Store - 新建单业务库:承接原来所有店铺业务表
- 每张店铺私有表都增加
storeId - 高频查询字段增加索引
- 唯一约束改成“
storeId + 业务键”
第一阶段:结构迁移
在单业务库中为以下表新增 storeId,建议类型为 varchar:
product_categoryproductproduct_specificationproduct_specification_valueproduct_skuwarehouseinventoryorderorder_itemblog_categoryblog_tagblog_articleblog_commentmenuproduct_visitproduct_favoritebannercouponcoupon_usagestore_pagestore_menu_itemdatasetmember_levelmemberaddressregion
建议迁移分两步:
- 先新增为可空字段
- 数据回填完成后再改为非空
同时建议:
- 每张租户表先加
storeId普通索引 - 高频查询表再补复合索引,例如:
storeId + createdAtstoreId + statusstoreId + sortNumber
第二阶段:唯一约束调整
建议改成以下形式:
store_page
- 唯一键:
storeId + path - 如页面名称也需要隔离,可加:
storeId + name
product_favorite
- 唯一键:
storeId + memberId + productId
product
如果 code 有业务唯一语义:
- 唯一键:
storeId + code
product_category
如果 code 或层级名称需要唯一:
- 唯一键:
storeId + code - 或
storeId + parentId + name
coupon
如果券码要求店铺内唯一:
- 唯一键:
storeId + code
order
- 订单号建议唯一键:
storeId + orderNumber - 支付回调定位建议索引或唯一键:
storeId + paymentProviderOrderId
dataset
如果名称需要避免重复:
- 唯一键:
storeId + type + name
menu、store_menu_item、banner
按业务需要决定是否加:
storeId + namestoreId + position + sortNumber
第三阶段:数据迁移脚本设计
建议单独实现一个一次性迁移脚本,流程如下:
- 从共享库读取全部启用店铺
- 对每个店铺读取其原业务库路径
- 对每张业务表执行:
- 读取旧库全部数据
- 为每条记录补
storeId = 当前店铺 id 或 code - 写入新单库对应表
- 每张表写入后做计数校验
- 生成迁移报告
推荐迁移顺序:
regionmember_levelmemberaddressproduct_categoryproductproduct_specificationproduct_specification_valueproduct_skuwarehouseinventoryblog_categoryblog_tagblog_articleblog_commentmenubannerdatasetcouponstore_pagestore_menu_itemproduct_visitproduct_favoriteorderorder_itemcoupon_usage
每张表迁移完成后,至少校验:
- 旧库行数
- 新库中对应
storeId的行数 - 是否一致
建议输出迁移报告内容:
- 店铺 code
- 旧库路径
- 每张表迁移条数
- 失败记录数
- 冲突记录数
第四阶段:冲突处理策略
主键 id 冲突
- 如果旧库之间
id基本为 GUID,可先直接保留 - 如果发现人工编码
id冲突:- 优先记录冲突
- 对冲突记录重新分配
id - 建立旧新 id 映射表
业务唯一键冲突
- 不同店铺都存在
/home页面路径是正常情况 - 改成
storeId + path后即可并存
支付订单号冲突
- 如果
orderNumber不是全局唯一,要改成storeId + orderNumber定位
第五阶段:回填策略
迁移落库后执行两步回填:
- 检查所有新增
storeId字段是否还有空值 - 确认无空值后,再把字段改为非空
第六阶段:切换策略
推荐分三步切换:
- 新单库建立并完成历史迁移,代码仍跑旧逻辑
- 灰度切换页面配置类表:
StorePageStoreMenuItemBannerDataset
- 全量切换商品、收藏、订单、会员等主业务链路
这样一旦出现问题,回滚范围更小。
三、建议先做的最小落地包
为了降低风险,建议第一批先只改下面这些文件和表,验证路线是否成立。
文件
admin/database/data-source.tsadmin/decorators/data-source.tsadmin/database/entities.tsadmin/controllers/admin/store.tsstore/middlewares/store-page.tsstore/modules/data-loaders.tsscripts/init-store.ts
表
store_pagestore_menu_itembannerdatasetproductproduct_favorite
这一批完成后可验证的问题
- 店铺页面是否串店
- 首页菜单和 Banner 是否串店
- 商品页是否串店
- 收藏是否串店
- 后台页面设计器是否按当前店铺工作
四、风险最高点
风险最高点不是实体补字段,而是后台通用数据接口这一层。
因为后台前端会自动给 /api/data、/api/data-source、/api/page 加上 storeCode,但当前服务端这层大概率只是用 storeCode 来选择数据源,而不是统一转成 storeId 过滤。
如果这一层不先解决,就会出现:
- 后台列表页串店
- 后台详情页串店
- 后台通用编辑页串店
- 你即使修完自定义 controller,后台仍然不安全
因此,实施时必须优先确认:
- 通用数据接口是否可扩展租户过滤
- 若不可扩展,是否需要在项目中加一层包裹或拦截机制
五、工作量评估
在“不做跨店共享会员”的前提下,合理估算如下:
- 基础设施改造:1 到 2 天
- 实体补字段与迁移脚本:1 到 2 天
- 前后台查询改造:2 到 4 天
- 联调与数据校验:1 到 2 天
总体大致为:5 到 10 天。
如果后台通用数据接口来自外部框架且扩展困难,时间还会增加。
六、推荐下一步
建议从以下两个方向中先做一个:
- 先实现“最小落地包”,验证单库多租户方案在页面、菜单、Banner、商品、收藏链路上是否可行
- 先输出数据库迁移脚本或 TypeORM 迁移草案,把数据结构和迁移路径锁定下来