Go + PostgreSQL + Redis:餐饮SaaS后端架构全景
这是"龙讯餐饮SaaS架构实战"系列的最后一篇。前面七篇,我们分别聊了架构演进、多租户隔离、支付加密、性能优化、多门店设计、合规体系、支付全链路。这篇做一个全景回顾,把后端架构的每个组件都摊开来聊。
整体架构
┌─────────────┐
│ Nginx │
│ TLS终止 │
└──────┬──────┘
│
┌────────────┼────────────┐
│ │ │
┌─────┴─────┐┌────┴─────┐┌─────┴─────┐
│ API-1 ││ API-2 ││ API-3 │
│ (Go) ││ (Go) ││ (Go) │
└─────┬─────┘└────┬─────┘└─────┬─────┘
│ │ │
└────────────┼────────────┘
│
┌────────────┼────────────┐
│ │ │
┌─────┴─────┐┌────┴─────┐┌─────┴─────┐
│PostgreSQL ││ Redis ││ 打印机 │
│ 16 ││ 7 ││ (网络) │
└───────────┘└──────────┘└───────────┘
每个Go实例是无状态的——状态全部在PostgreSQL和Redis里。水平扩展只需要加实例,Nginx自动负载均衡。
Chi中间件管线:15个中间件
chi是一个轻量级的Go HTTP路由库,它的中间件机制非常优雅。我们的请求处理管线由15个中间件组成,分为全局中间件和认证路由组中间件,按顺序执行:
r := chi.NewRouter()
// 全局中间件
r.Use(chimw.RealIP) // 1. 真实IP(从X-Forwarded-For提取)
r.Use(chimw.Recoverer) // 2. Panic恢复
r.Use(middleware.Logger) // 3. 请求日志
r.Use(middleware.Metrics()) // 4. Prometheus指标
r.Use(middleware.CORS(cfg.Security.CORSAllowedOrigin)) // 5. 跨域
r.Use(middleware.SecurityHeaders) // 6. 安全头
r.Use(middleware.MaxBodySize(2 << 20)) // 7. 请求体大小限制
r.Use(middleware.RateLimit(rdb, 100, time.Second)) // 8. 限流
// 认证路由组中间件
r.Route("/", func(r chi.Router) {
r.Use(middleware.JWTAuth(rdb, cfg.JWT.PublicKeyPath)) // 9. JWT认证
r.Use(middleware.TenantMiddleware(db, rdb)) // 10. 租户上下文注入
r.Use(middleware.StoreIDOverride(db)) // 11. 门店ID覆盖
r.Use(middleware.StoreBelongsToTenant(db)) // 12. 门店归属校验
r.Use(middleware.SanitizeMiddleware) // 13. 输入清洗
r.Use(middleware.AuditMiddleware(svc.Audit)) // 14. 审计日志
r.Use(middleware.MustChangePwdMiddleware()) // 15. 强制改密检查
})
逐个解析
1. 真实IP(RealIP)
chi自带的RealIP中间件,从X-Forwarded-For或X-Real-IP头中提取真实客户端IP,用于限流和审计。Nginx做TLS终止后,后端拿到的IP是Nginx的IP,需要这个中间件还原真实IP。
2. Panic恢复(Recoverer)
chi自带的Recoverer中间件,捕获handler中的panic,返回500而不是让整个进程崩溃。餐饮收银系统不能因为一个Bug就挂掉。
3. 请求日志(Logger)
自定义日志中间件,记录请求方法、路径、状态码、耗时。
4. Prometheus指标(Metrics)
记录每个请求的处理时间和状态码,暴露Prometheus指标端点,用于监控和告警。
5. 跨域(CORS)
开发环境前后端分离,需要CORS支持。生产环境Nginx处理CORS,这个中间件只在开发环境生效。
6. 安全头(SecurityHeaders)
注入安全相关的HTTP响应头:X-Content-Type-Options: nosniff、X-Frame-Options: DENY、X-XSS-Protection: 1; mode=block等,防止常见的Web攻击。
7. 请求体大小限制(MaxBodySize)
限制请求体最大2MB,防止恶意大请求消耗服务器资源。
8. 限流(RateLimit)
基于Redis的滑动窗口限流,每IP每秒100次。
9. JWT认证(JWTAuth)
验证JWT Token,提取用户ID、租户ID、角色等信息,注入Context。
10. 租户上下文注入(TenantMiddleware)
从JWT中提取的tenant_id,查询租户信息并注入Context,供后续中间件和handler使用。
11. 门店ID覆盖(StoreIDOverride)
允许商户管理员通过请求头X-Store-ID指定当前操作的门店,用于商户管理员查看特定门店数据。
12. 门店归属校验(StoreBelongsToTenant)
验证请求中的门店ID确实属于当前租户,防止越权访问其他租户的门店数据。
13. 输入清洗(SanitizeMiddleware)
对请求参数进行HTML标签清洗,防止XSS攻击。
14. 审计日志(AuditMiddleware)
记录所有API请求,用于合规审计。只记录写操作(POST/PUT/DELETE),不记录读操作(GET),减少日志量。
15. 强制改密检查(MustChangePwdMiddleware)
如果用户的must_change_pwd标记为true,则只允许访问改密接口,其他接口一律拒绝。用于初始密码强制修改场景。
中间件顺序的重要性
中间件的顺序不是随便排的。比如:
Recoverer必须在Logger之后,这样panic时才能记录日志JWTAuth必须在TenantMiddleware之前,因为租户上下文需要JWT中的用户信息StoreIDOverride必须在StoreBelongsToTenant之前,先确定门店再校验归属
Repository模式:数据访问的抽象层
Repository模式是领域驱动设计(DDD)中的经典模式,核心思想是把数据访问逻辑从业务逻辑中分离出来。
接口定义
type OrderRepository interface {
Create(ctx context.Context, order *Order) error
FindByID(ctx context.Context, tenantID int64, id int64) (*Order, error)
GetByOrderNo(ctx context.Context, tenantID int64, orderNo string) (*Order, error)
List(ctx context.Context, filter OrderFilter) ([]Order, int64, error)
Update(ctx context.Context, tenantID int64, order *Order) error
UpdateStatus(ctx context.Context, tenantID int64, id int64, status string) error
UpdatePaymentStatus(ctx context.Context, tenantID int64, id int64, status string) error
SetHidden(ctx context.Context, tenantID int64, id int64, hidden bool) error
FindByDailySeq(ctx context.Context, tenantID int64, storeID int64, dailySeq int, date string) (*Order, error)
ListSuspended(ctx context.Context, tenantID int64, storeID int64) ([]SuspendedOrder, error)
}
实现
type orderRepository struct {
db *gorm.DB
}
func NewOrderRepository(db *gorm.DB) OrderRepository {
return &orderRepository{db: db}
}
func (r *orderRepository) Create(ctx context.Context, order *Order) error {
return r.db.WithContext(ctx).Create(order).Error
}
func (r *orderRepository) GetByID(ctx context.Context, id int64) (*Order, error) {
tenantID := ctx.Value(CtxKeyTenantID).(int64)
var order Order
err := r.db.WithContext(ctx).
Scopes(TenantScope(tenantID)).
First(&order, id).Error
return &order, err
}
func (r *orderRepository) List(ctx context.Context, filter OrderFilter) ([]Order, int64, error) {
tenantID := ctx.Value(CtxKeyTenantID).(int64)
storeID := ctx.Value(CtxKeyStoreID).(int64)
query := r.db.WithContext(ctx).Scopes(TenantScope(tenantID))
if storeID > 0 {
query = query.Where("store_id = ?", storeID)
}
if filter.Status != "" {
query = query.Where("status = ?", filter.Status)
}
if !filter.StartDate.IsZero() {
query = query.Where("created_at >= ?", filter.StartDate)
}
if !filter.EndDate.IsZero() {
query = query.Where("created_at < ?", filter.EndDate)
}
var total int64
query.Model(&Order{}).Count(&total)
var orders []Order
query.Offset(filter.Offset).Limit(filter.Limit).
Order("created_at DESC").
Find(&orders)
return orders, total, nil
}
为什么不用GORM直接操作?
Repository模式的好处:
- 可测试——Service层依赖Repository接口,测试时可以Mock
- 可替换——如果以后换数据库,只需要实现新的Repository
- 关注点分离——Service层只关心业务逻辑,不关心SQL怎么写
Service层:业务逻辑的核心
Service层是业务逻辑的核心,协调Repository、缓存、支付SDK等组件:
type OrderService struct {
orderRepo *OrderRepository
orderItemRepo *OrderItemRepository
dishRepo *DishRepository
dishSpecRepo *DishSpecRepository
toppingRepo *ToppingRepository
suspendedRepo *SuspendedOrderRepository
dailySeqRepo *DailySeqRepository
storePriceRepo *StoreDishPriceRepository
tenantRepo *TenantRepository
cache *CacheService
rdb *redis.Client
}
func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
// 1. 参数校验
if err := s.validateCreateRequest(req); err != nil {
return nil, err
}
// 2. 幂等检查
idempotencyKey := s.generateIdempotencyKey(req)
ok, _ := s.rdb.SetNX(ctx, fmt.Sprintf("idem:%s", idempotencyKey), 1, 30*time.Second).Result()
if !ok {
return s.findOrderByIdempotencyKey(ctx, idempotencyKey)
}
// 3. 计算金额
totalAmount, items, err := s.calculateAmount(ctx, req)
if err != nil {
return nil, err
}
// 4. 生成订单号
orderNo := s.GenerateOrderNo(ctx, req.TenantID, req.StoreID)
// 5. 创建订单
order := &Order{
TenantID: req.TenantID,
StoreID: req.StoreID,
OrderNo: orderNo,
TotalAmount: totalAmount,
OriginalAmount: totalAmount,
PaymentStatus: "pending",
Status: "pending",
OrderType: "normal",
IsHidden: false,
Items: items,
}
if err := s.orderRepo.Create(ctx, order); err != nil {
s.rdb.Del(ctx, fmt.Sprintf("idem:%s", idempotencyKey))
return nil, err
}
// 6. 审计日志
s.auditLog.Log(ctx, "create_order", "orders", order.ID, nil, nil)
// 7. 清除相关缓存
s.cache.Del(ctx, fmt.Sprintf("t:%d:orders:pending", req.TenantID))
return order, nil
}
Service层的设计原则:
- 一个方法做一件事——
CreateOrder只创建订单,不做支付 - 依赖注入——所有依赖通过构造函数注入
- 错误冒泡——底层错误向上传递,由handler统一处理
- 事务边界——需要事务的操作在Service层管理
decimal.Decimal元精度:金额处理的铁律
金额处理有一个铁律:永远用Decimal,永远用元。
// 错误示范
price := 29.90 // float64,0.1+0.2=0.30000000000000004
// 正确做法
price := decimal.NewFromString("29.90") // decimal.Decimal,精确到分
项目使用shopspring/decimal包,通过类型别名简化使用:
type Decimal = decimal.Decimal
type Order struct {
TotalAmount Decimal `gorm:"column:total_amount;type:decimal(10,2)"`
OriginalAmount Decimal `gorm:"column:original_amount;type:decimal(10,2)"`
DiscountAmount *Decimal `gorm:"column:discount_amount;type:decimal(10,2)"`
}
数据库列类型为decimal(10,2),Go中统一使用decimal.Decimal,避免浮点精度问题:
func (s *OrderService) calculateTax(amount Decimal, taxRate float64) Decimal {
rate := decimal.NewFromFloat(taxRate)
return amount.Mul(rate).Round(2)
}
金额展示直接输出元:
// decimal.Decimal → "29.90"
amount.StringFixed(2)
前端也用元:
function formatYuan(amount: string): string {
return Number(amount).toFixed(2)
}
分布式锁:Redis实现
分布式锁在多个场景中使用:订单防重复、桌台结账互斥、库存扣减等。
type DistributedLock struct {
rdb *redis.Client
}
func (l *DistributedLock) TryLock(ctx context.Context, key string, ttl time.Duration) (LockHandle, error) {
value := uuid.New().String() // 唯一标识,防止误解锁
ok, err := l.rdb.SetNX(ctx, fmt.Sprintf("lock:%s", key), value, ttl).Result()
if err != nil {
return LockHandle{}, err
}
if !ok {
return LockHandle{}, ErrLockConflict
}
return LockHandle{Key: key, Value: value}, nil
}
func (l *DistributedLock) Unlock(ctx context.Context, handle LockHandle) error {
// Lua脚本:只有值匹配时才删除,防止误解锁
script := redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`)
_, err := script.Run(ctx, l.rdb, []string{fmt.Sprintf("lock:%s", handle.Key)}, handle.Value).Result()
return err
}
为什么用Lua脚本释放锁?因为GET+DEL不是原子操作。如果GET返回匹配值,但在DEL之前锁过期了,另一个客户端获取了锁,此时DEL会错误地释放别人的锁。Lua脚本在Redis中原子执行,避免了这个问题。
幂等键:支付请求的去重保障
支付请求必须幂等——同一笔支付请求,无论提交多少次,只扣一次钱。
func (s *PaymentService) PayWithIdempotency(ctx context.Context, req *PayRequest) (*PayResponse, error) {
// 客户端提供的幂等键
idempotencyKey := req.IdempotencyKey
if idempotencyKey == "" {
idempotencyKey = uuid.New().String()
}
// 检查幂等键
resultKey := fmt.Sprintf("idem_pay:%s", idempotencyKey)
existing, err := s.rdb.Get(ctx, resultKey).Result()
if err == nil {
// 已处理过,返回之前的结果
var resp PayResponse
json.Unmarshal([]byte(existing), &resp)
return &resp, nil
}
// 执行支付
resp, err := s.doPay(ctx, req)
if err != nil {
return nil, err
}
// 保存结果(5分钟有效)
respJSON, _ := json.Marshal(resp)
s.rdb.Set(ctx, resultKey, respJSON, 5*time.Minute)
return resp, nil
}
项目结构
longxunpos/
├── cmd/
│ └── api/
│ └── main.go # 入口
├── internal/
│ ├── config/ # 配置
│ ├── middleware/ # 15个中间件
│ ├── handler/ # HTTP处理器
│ ├── service/ # 业务逻辑层
│ ├── repository/ # 数据访问层
│ ├── model/ # 数据模型
│ ├── pkg/
│ │ ├── encryptor/ # AES-256-GCM加密
│ │ ├── payment/ # 支付SDK封装
│ │ ├── coupon/ # 团购核销SDK
│ │ ├── printer/ # 小票打印
│ │ └── lock/ # 分布式锁
│ └── router/ # 路由配置
├── migrations/ # 数据库迁移
├── deploy/
│ ├── docker-compose.yml
│ ├── docker-compose.prod.yml
│ └── nginx/
└── scripts/
架构的权衡与取舍
没有完美的架构,只有合适的架构。龙讯餐饮V2的架构做了一些明确的取舍:
| 取舍 | 我们的选择 | 放弃的 | 原因 |
|---|---|---|---|
| 单体 vs 微服务 | 模块化单体 | 微服务 | 500商户以内不需要微服务的复杂度 |
| RLS vs Schema隔离 | RLS | Schema隔离 | 运维成本更低 |
| Docker Compose vs K8s | Docker Compose | Kubernetes | 当前规模不需要K8s |
| decimal vs int64 | decimal.Decimal元精度 | int64分精度 | 精度更可靠,避免分/元转换错误 |
| 轮询 vs WebSocket | 轮询+回调 | WebSocket | 支付平台限制,非技术选择 |
| 哈希链审计 vs 区块链 | 哈希链 | 区块链 | 哈希链够用,不需要区块链的复杂度 |
这些取舍不是一成不变的。当商户数量超过500家,我们可能会拆微服务;当合规要求更高,我们可能会用区块链做审计。但现在,这些选择是最务实的。
回顾整个系列
八篇文章,从架构演进到全景回顾,我们覆盖了龙讯餐饮SaaS后端的核心设计:
- 架构演进——从V1单体的痛点到V2多租户SaaS的选型
- 多租户隔离——JWT + GORM Callback + 中间件 + 缓存四层防线
- 支付加密——AES-256-GCM + 密钥轮换 + 审计日志
- 性能优化——连接池 + Redis原子操作 + 缓存三策略
- 多门店架构——门店隔离 + 价格覆盖 + 聚合报表
- 合规体系——非金融定位 + PIPL + 数据保留 + 哈希链审计
- 支付全链路——扫码→支付→轮询→回调→出票
- 架构全景——中间件管线 + Repository + Service + 元精度
这些设计不是纸上谈兵,每一个都是在实际业务中踩过坑、做过权衡后的选择。希望这个系列能给正在做餐饮SaaS或者类似多租户系统的同学一些参考。
如果你有任何问题或者想讨论某个细节,欢迎留言交流。
系列导航:[龙讯餐饮SaaS架构实战]
← 上一篇:微信扫码→下单→支付→出票,30秒全链路 | 下一篇 →无
- 从单体到多租户:餐饮SaaS架构演进之路
- 100家店的数据如何互不偷看:多租户隔离深度解析
- 你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
- 3秒出单200ms响应:餐饮收银系统性能密码
- 从1家店到100家店代码一行不用改:多门店架构设计
- 退款5年可追溯:餐饮SaaS合规体系构建
- 微信扫码→下单→支付→出票,30秒全链路
- Go + PostgreSQL + Redis:餐饮SaaS后端架构全景
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 jaytp@qq.com