从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_idstore_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_idstore_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
}

配置继承

门店配置支持"继承+覆盖"模式:

  1. 先查门店级配置
  2. 门店级配置为空的字段,查租户级默认配置
  3. 租户级配置也为空,用系统默认值
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合规体系构建

  1. 从单体到多租户:餐饮SaaS架构演进之路
  2. 100家店的数据如何互不偷看:多租户隔离深度解析
  3. 你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
  4. 3秒出单200ms响应:餐饮收银系统性能密码
  5. 从1家店到100家店代码一行不用改:多门店架构设计
  6. 退款5年可追溯:餐饮SaaS合规体系构建
  7. 微信扫码→下单→支付→出票,30秒全链路
  8. Go + PostgreSQL + Redis:餐饮SaaS后端架构全景