从1家店到100家店代码一行不用改:多门店架构设计
从1家店到100家店代码一行不用改:多门店架构设计
上一篇我们聊了性能,这篇回到架构层面,聊一个餐饮SaaS的刚需——多门店。
一个餐饮品牌,从1家店开到10家、50家、100家,是很正常的增长路径。但很多SaaS系统在1家店时用得好好的,到了10家店就开始出问题:数据混在一起、价格各店不同但改不了、报表只能看单店……
龙讯餐饮V2从第一天就做了多门店架构。核心目标:从1家店到100家店,代码一行不用改。
数据模型:租户→门店→业务
多门店的数据层次是:租户(Tenant)→ 门店(Store)→ 业务数据(Order、Dish等)。
租户A(龙讯火锅)
├── 门店1(国贸店)
│ ├── 订单
│ ├── 菜品
│ └── 员工
├── 门店2(望京店)
│ ├── 订单
│ ├── 菜品
│ └── 员工
└── 门店3(三里屯店)
├── 订单
├── 菜品
└── 员工
数据库表设计:
type Tenant struct {
ID int64 `gorm:"primaryKey"`
Name string
Slug string // URL标识
OwnerUserID int64 // 所有者用户ID
Status string // active / suspended / trial
Plan string // free / pro / enterprise
MaxStores int // 最大门店数
MaxDishes int // 最大菜品数
MaxOrdersPerDay int // 每日最大订单数
ExpiresAt *time.Time // 过期时间
LicenseNumber *string // 营业执照号
LicenseVerified bool // 证照是否已验证
CreatedAt time.Time
}
type Store struct {
ID int64 `gorm:"primaryKey"`
TenantID int64 `gorm:"index"`
Name string
Address *string
Phone *string
Status string // active / closed
CreatedAt time.Time
UpdatedAt time.Time
}
type Order struct {
ID int64 `gorm:"primaryKey"`
TenantID int64 `gorm:"index"`
StoreID int64 `gorm:"index"`
OrderNo string
Amount Decimal // 元
Status string
CreatedAt time.Time
}
每条业务数据都有tenant_id和store_id两个维度。tenant_id用于租户隔离,store_id用于门店隔离。
索引策略
多维度查询需要复合索引:
-- 订单表的索引
CREATE INDEX idx_orders_tenant_store ON orders(tenant_id, store_id);
CREATE INDEX idx_orders_tenant_store_status ON orders(tenant_id, store_id, status);
CREATE INDEX idx_orders_tenant_store_created ON orders(tenant_id, store_id, created_at);
复合索引的顺序很重要:高基数字段在前,低基数字段在后。tenant_id和store_id是等值查询,created_at是范围查询,所以等值查询字段在前。
门店级数据隔离
门店数据隔离和租户隔离类似,但粒度更细。当前版本通过GORM Scope + 中间件实现,未使用PostgreSQL RLS策略。
RLS策略(设计预留)
注意:以下RLS策略为设计预留,当前版本通过GORM Callback(tenant_scope.go中的RegisterCallbacks)+ 中间件实现租户/门店级数据隔离,未使用PostgreSQL RLS策略。
-- 门店级RLS策略
CREATE POLICY store_isolation ON orders
USING (
tenant_id = current_setting('app.current_tenant_id')::BIGINT
AND (
current_setting('app.current_store_id', true) = ''
OR store_id = current_setting('app.current_store_id')::BIGINT
)
);
注意current_setting('app.current_store_id', true)的第二个参数true——表示如果变量未设置,不报错,返回空字符串。这样平台管理员和商户管理员(不限定门店)也能查询数据。
GORM Scope
func StoreScope(tenantID int64, storeID int64) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
db = db.Where("tenant_id = ?", tenantID)
if storeID > 0 {
db = db.Where("store_id = ?", storeID)
}
return db
}
}
storeID为0时,不添加门店过滤,表示查看该租户所有门店的数据。这对应商户管理员的视角。
门店级价格覆盖
这是多门店架构里最复杂的需求之一。同一个菜品,不同门店可能有不同的价格:
- 国贸店:牛肉88元/份
- 望京店:牛肉78元/份(周边竞品多,定价低)
- 三里屯店:牛肉98元/份(商圈溢价)
数据模型
// 菜品表(门店级)
type Dish struct {
ID int64 `gorm:"primaryKey"`
TenantID int64 `gorm:"index"`
StoreID int64 `gorm:"index"` // 门店级,非纯租户级
Name string
Price Decimal // 价格(元)
Category string
Status string
}
// 门店价格覆盖表
type StoreDishPrice struct {
ID int64 `gorm:"primaryKey"`
TenantID int64 `gorm:"index"`
StoreID int64 `gorm:"index"`
DishID int64 `gorm:"index"`
SpecID *int64 // 规格ID,支持规格级别定价
Price Decimal // 覆盖价格(元)
}
价格查询逻辑
func (s *DishService) GetDishPrice(ctx context.Context, storeID int64, dishID int64) (Decimal, error) {
tenantID := ctx.Value(CtxKeyTenantID).(int64)
// 1. 先查门店覆盖价格
var override StoreDishPrice
err := s.db.Where("tenant_id = ? AND store_id = ? AND dish_id = ?",
tenantID, storeID, dishID).First(&override).Error
if err == nil {
return override.Price, nil // 有覆盖价格,用覆盖的
}
// 2. 没有覆盖价格,用菜品价格
var dish Dish
if err := s.db.Where("tenant_id = ? AND id = ?", tenantID, dishID).First(&dish).Error; err != nil {
return decimal.Zero, err
}
return dish.Price, nil
}
这个逻辑用SQL表达更直观:
-- 获取门店菜品列表(含价格)
SELECT
d.id,
d.name,
COALESCE(sdp.price, d.price) AS price
FROM dishes d
LEFT JOIN store_dish_prices sdp
ON d.id = sdp.dish_id
AND sdp.store_id = :store_id
WHERE d.tenant_id = :tenant_id
AND d.status = 'active';
COALESCE函数:如果门店覆盖价格存在,用覆盖价格;否则用基础价格。一条SQL搞定,不需要两次查询。
批量设置门店价格
商户管理员可以批量设置某个菜品在所有门店的价格:
func (s *DishService) BatchSetStorePrice(ctx context.Context, req *BatchSetPriceRequest) error {
tenantID := ctx.Value(CtxKeyTenantID).(int64)
return s.db.Transaction(func(tx *gorm.DB) error {
for _, item := range req.Items {
// UPSERT:存在则更新,不存在则创建
result := tx.Where(
"tenant_id = ? AND store_id = ? AND dish_id = ?",
tenantID, item.StoreID, item.DishID,
).Assign(StoreDishPrice{
Price: item.Price,
}).FirstOrCreate(&StoreDishPrice{
TenantID: tenantID,
StoreID: item.StoreID,
DishID: item.DishID,
Price: item.Price,
})
if result.Error != nil {
return result.Error
}
}
return nil
})
}
跨门店聚合报表
商户管理员需要看到所有门店的汇总数据:总营业额、各门店对比、趋势分析。这是多门店架构的"最后一公里"。
报表查询
-- 各门店营业额汇总
SELECT
s.id AS store_id,
s.name AS store_name,
COUNT(o.id) AS order_count,
SUM(o.amount) AS total_amount,
AVG(o.amount) AS avg_amount
FROM stores s
LEFT JOIN orders o
ON s.id = o.store_id
AND o.tenant_id = :tenant_id
AND o.created_at >= :start_date
AND o.created_at < :end_date
AND o.status = 'completed'
WHERE s.tenant_id = :tenant_id
AND s.status = 'active'
GROUP BY s.id, s.name
ORDER BY total_amount DESC;
Go实现
type StoreSalesReport struct {
StoreID int64 `json:"store_id"`
StoreName string `json:"store_name"`
OrderCount int64 `json:"order_count"`
TotalAmount Decimal `json:"total_amount"` // 元
AvgAmount Decimal `json:"avg_amount"` // 元
}
func (s *ReportService) StoreSalesReport(ctx context.Context, startDate, endDate time.Time) ([]StoreSalesReport, error) {
tenantID := ctx.Value(CtxKeyTenantID).(int64)
var reports []StoreSalesReport
err := s.db.Raw(`
SELECT
s.id AS store_id,
s.name AS store_name,
COUNT(o.id) AS order_count,
COALESCE(SUM(o.amount), 0) AS total_amount,
COALESCE(AVG(o.amount), 0) AS avg_amount
FROM stores s
LEFT JOIN orders o
ON s.id = o.store_id
AND o.tenant_id = ?
AND o.created_at >= ?
AND o.created_at < ?
AND o.status = 'completed'
WHERE s.tenant_id = ?
AND s.status = 'active'
GROUP BY s.id, s.name
ORDER BY total_amount DESC
`, tenantID, startDate, endDate, tenantID).Scan(&reports).Error
return reports, err
}
前端展示
Vue3 + ECharts的组合,让报表展示非常直观:
// 门店营业额对比柱状图
function renderStoreComparison(reports: StoreSalesReport[]) {
const chart = echarts.init(chartRef.value)
chart.setOption({
title: { text: '门店营业额对比' },
tooltip: {
formatter: (params: any) => {
return `${params.name}<br/>营业额:¥${Number(params.value).toFixed(2)}`
}
},
xAxis: {
type: 'category',
data: reports.map(r => r.store_name)
},
yAxis: {
type: 'value',
axisLabel: {
formatter: (val: number) => `¥${val.toFixed(0)}`
}
},
series: [{
type: 'bar',
data: reports.map(r => r.total_amount),
itemStyle: { color: '#409EFF' }
}]
})
}
门店配置独立性
每个门店有自己的运营节奏和偏好。比如:
- 国贸店:营业时间10:00-22:00,支持外卖
- 望京店:营业时间11:00-23:00,不支持外卖
- 三里屯店:营业时间12:00-02:00,支持团购核销
这些配置不能写死在代码里,必须可配置、可独立修改。
门店配置模型(设计预留,当前版本未实现)
注意:以下StoreConfig相关代码为设计预留,当前版本Store模型中未包含Config字段,门店配置功能尚未实现。
type StoreConfig struct {
BusinessHours BusinessHours `json:"business_hours"`
DeliveryEnabled bool `json:"delivery_enabled"`
CouponEnabled bool `json:"coupon_enabled"`
PrinterID string `json:"printer_id"`
TaxRate float64 `json:"tax_rate"`
ServiceCharge Decimal `json:"service_charge"` // 元
}
type BusinessHours struct {
OpenTime string `json:"open_time"` // "10:00"
CloseTime string `json:"close_time"` // "22:00"
}
门店配置存储在stores表的config字段(JSONB类型):
func (s *StoreService) GetStoreConfig(ctx context.Context, storeID int64) (*StoreConfig, error) {
tenantID := ctx.Value(CtxKeyTenantID).(int64)
var store Store
if err := s.db.Where("tenant_id = ? AND id = ?", tenantID, storeID).First(&store).Error; err != nil {
return nil, err
}
var config StoreConfig
if err := json.Unmarshal(store.Config, &config); err != nil {
// 配置为空或格式错误,返回默认配置
return defaultStoreConfig(), nil
}
return &config, nil
}
func (s *StoreService) UpdateStoreConfig(ctx context.Context, storeID int64, config *StoreConfig) error {
tenantID := ctx.Value(CtxKeyTenantID).(int64)
configJSON, _ := json.Marshal(config)
return s.db.Model(&Store{}).
Where("tenant_id = ? AND id = ?", tenantID, storeID).
Update("config", configJSON).Error
}
配置继承
门店配置支持"继承+覆盖"模式:
- 先查门店级配置
- 门店级配置为空的字段,查租户级默认配置
- 租户级配置也为空,用系统默认值
func (s *StoreService) GetEffectiveConfig(ctx context.Context, storeID int64) *StoreConfig {
storeConfig := s.GetStoreConfig(ctx, storeID)
tenantConfig := s.GetTenantDefaultConfig(ctx)
effective := &StoreConfig{}
// 营业时间:门店级优先
if storeConfig.BusinessHours.OpenTime != "" {
effective.BusinessHours = storeConfig.BusinessHours
} else {
effective.BusinessHours = tenantConfig.BusinessHours
}
// 外卖开关:门店级优先
effective.DeliveryEnabled = storeConfig.DeliveryEnabled
// 税率:门店级优先,否则用租户级
if storeConfig.TaxRate > 0 {
effective.TaxRate = storeConfig.TaxRate
} else {
effective.TaxRate = tenantConfig.TaxRate
}
return effective
}
新增门店:零代码变更
新增门店的流程完全在管理后台完成,不需要改代码:
func (s *StoreService) CreateStore(ctx context.Context, req *CreateStoreRequest) (*Store, error) {
tenantID := ctx.Value(CtxKeyTenantID).(int64)
store := &Store{
TenantID: tenantID,
Name: req.Name,
Address: req.Address,
Status: "active",
}
if err := s.db.Create(store).Error; err != nil {
return nil, err
}
// 初始化门店的布隆过滤器
s.bloom.InitStoreBloom(ctx, tenantID, store.ID)
// 初始化门店的日流水号
// Redis的key是按门店隔离的,不需要额外初始化
return store, nil
}
新增门店后,所有功能自动可用:点菜、收银、报表、配置,全部按门店隔离。商户管理员在后台添加门店,店长在门店登录,收银员开始收银——全程零代码变更。
小结
多门店架构的核心设计:
| 设计点 | 方案 | 价值 |
|---|---|---|
| 数据隔离 | tenant_id + store_id 双维度 + GORM Scope | 门店数据互不干扰 |
| 价格覆盖 | COALESCE + 门店价格表 | 同菜品不同门店不同价 |
| 聚合报表 | LEFT JOIN + GROUP BY | 商户管理员看全局 |
| 配置独立 | JSONB + 继承覆盖 | 各门店独立运营 |
| 零代码扩展 | 管理后台自助开店 | 从1到100无需改代码 |
下一篇,我们聊合规——退款5年可追溯,餐饮SaaS的合规体系。
系列导航:[龙讯餐饮SaaS架构实战]
← 上一篇:3秒出单200ms响应:餐饮收银系统性能密码 | 下一篇 →退款5年可追溯:餐饮SaaS合规体系构建
- 从单体到多租户:餐饮SaaS架构演进之路
- 100家店的数据如何互不偷看:多租户隔离深度解析
- 你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
- 3秒出单200ms响应:餐饮收银系统性能密码
- 从1家店到100家店代码一行不用改:多门店架构设计
- 退款5年可追溯:餐饮SaaS合规体系构建
- 微信扫码→下单→支付→出票,30秒全链路
- Go + PostgreSQL + Redis:餐饮SaaS后端架构全景