一、单商户跑通后,多商户 SaaS 是必选项

转卡码平台从单商户走向多商户,几乎是业务增长的必然路径:每个代理、每个下游商家都想要自己的店铺、自己的收款码、自己的卡密库存和独立结算,而你不可能为每个商家单独部署一套系统——服务器成本翻倍、升级要逐个打补丁、数据还互相隔离无法统一管理。多商户 SaaS 化改造,就是让"一套系统服务一个商家"变成"一套系统服务几十上百个商家"。这也是商城在售的转卡码系统 V3 的核心架构,本文把它的改造思路完整拆开讲。

二、租户隔离三选一:先想清楚再动手

多商户改造第一个要决策的就是租户隔离级别,业界主流有三种方案,各有取舍:

方案隔离强度成本适用场景
独立数据库最强最高金融级客户、数据合规要求极高
共享库 + 独立 Schema中大型 SaaS,租户数量少
共享库 + tenant_id 字段弱(靠代码保证)最低转卡码这类中小业务量平台

转卡码平台单商户日订单量通常在几千到几万笔,用共享库 + tenant_id 性价比最高:一套库、一套代码、一次升级全部商户生效。只要把"每一行 SQL 都带租户条件"当成铁律,隔离强度完全够用。本文就按这个方案展开。

三、表结构改造:给核心表加上 tenant_id

改造从订单表、卡密表、商品表开始,原则是:所有业务表都必须有 tenant_id,且联合索引第一列必须是它。以订单表和卡密表为例:

# 订单表:原有字段基础上增加租户列
ALTER TABLE orders ADD COLUMN tenant_id INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '租户ID';
ALTER TABLE orders ADD KEY idx_tenant_status (tenant_id, status, created_at);

# 卡密表:库存查询、锁定、核销全部按租户走
ALTER TABLE card_stock ADD COLUMN tenant_id INT UNSIGNED NOT NULL DEFAULT 1;
ALTER TABLE card_stock ADD KEY idx_tenant_product (tenant_id, product_id, status);

注意 idx_tenant_status 这类联合索引必须把 tenant_id 放最前面。否则查询"某个租户的待支付订单"时,索引走不到租户条件,数据量一大就会出现跨租户扫描,既慢又危险。存量数据迁移用一条 UPDATE 按商户归属回填即可:

# 存量数据回填:把老商户的订单、卡密标记到对应租户
UPDATE orders SET tenant_id=2 WHERE merchant_code='shop-a';
UPDATE card_stock SET tenant_id=2 WHERE merchant_code='shop-a';

四、域名级租户解析:一个 Nginx 配置搞定

多商户平台最常见的形态是每个商户一个独立域名或子域名:shop-a.example.com、shop-b.example.com。前端根据域名渲染对应商户的店铺、收款码和商品,后端则从 Host 头解析出 tenant_id。Nginx 层做一层映射:

# 用 map 把子域名映射成租户 ID,统一转发给后端
map $host $tenant_id {
    default            1;
    shop-a.example.com 2;
    shop-b.example.com 3;
}
server {
    listen 80;
    server_name *.example.com;
    location / {
        proxy_set_header X-Tenant-Id $tenant_id;
        proxy_pass http://127.0.0.1:8000;
    }
}

后端用 FastAPI 中间件统一解析,所有视图函数都从请求上下文取租户,代码里禁止硬编码:

async def resolve_tenant(request: Request):
    tenant_id = request.headers.get("X-Tenant-Id", "1")
    request.state.tenant_id = int(tenant_id)

# 业务代码统一从 request.state 取,禁止硬编码租户
orders = db.execute(text(
    "SELECT * FROM orders WHERE tenant_id=:tid AND status='PENDING'"
), {"tid": request.state.tenant_id}).fetchall()

五、商户独立支付配置:一张配置表 + AES 加密

每个商户都有自己的支付宝/微信商户号、应用私钥和回调地址,绝不能写死在代码里。建一张 tenant_config 表,支付网关按租户动态加载配置:

CREATE TABLE tenant_config (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  tenant_id INT UNSIGNED NOT NULL UNIQUE,
  alipay_app_id VARCHAR(64) NOT NULL,
  alipay_private_key VARBINARY(512) NOT NULL,  # AES 加密存储
  alipay_public_key VARBINARY(512) NOT NULL,
  notify_url VARCHAR(255) NOT NULL,            # 该商户自己的回调地址
  status TINYINT NOT NULL DEFAULT 1,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;

商户密钥属于最高敏感级数据:落库前用 AES-256-GCM 加密,应用启动时从环境变量加载主密钥,即使数据库泄露也拿不到明文:

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os

MASTER_KEY = os.environ["PAY_MASTER_KEY"].encode()  # 环境变量注入,不进代码库

def encrypt_secret(plain: str) -> bytes:
    nonce = os.urandom(12)
    ct = AESGCM(MASTER_KEY).encrypt(nonce, plain.encode(), None)
    return nonce + ct   # nonce 拼在密文前面一起存

def decrypt_secret(blob: bytes) -> str:
    nonce, ct = blob[:12], blob[12:]
    return AESGCM(MASTER_KEY).decrypt(nonce, ct, None).decode()

回调地址也要按租户区分:支付网关把异步通知转发到 notify_url 时带上 X-Tenant-Id 头,回调处理程序据此找到对应商户的密钥验签,再更新该租户的订单。这样 A 商户的回调永远不会串到 B 商户的订单上。

六、越权防护:三条必须守住的底线

多租户系统 90% 的安全事故是越权访问(IDOR):用户改一下 URL 里的订单号,就查到了别人的订单。守住三条底线:

  • 查询必带租户条件:所有 SELECT/UPDATE/DELETE 的 WHERE 里必须有 tenant_id=当前租户,条件更新影响行数为 0 直接 404。
  • ID 用不透明标识:对外暴露的订单号、卡密 ID 用随机串(如 ORD-8f3a9c),不要用自增 ID,杜绝遍历。
  • 网关层统一校验:在中间件里把 request.state.tenant_id 和登录态绑定,商户后台接口一律校验"该商户是否有权操作此资源"。
# 条件更新 + 行数校验:越权请求自然落空
n = db.execute(text(
    "UPDATE card_stock SET status='SOLD', buyer=:u "
    "WHERE id=:id AND tenant_id=:tid AND status='UNSOLD'"
), {"id": card_id, "tid": request.state.tenant_id, "u": user}).rowcount
if n == 0: raise HTTPException(404)

七、Redis 库存与缓存:key 必须带租户前缀

转卡码系统的库存热数据在 Redis 里,多租户后 key 必须带上租户前缀,否则 A 商户的库存会和 B 商户的互相覆盖:

# 所有 Redis key 统一加租户前缀
def stock_key(tenant_id: int, product_id: int) -> str:
    return f"t{tenant_id}:stock:p{product_id}"

# 下单扣减:Lua 保证原子性,且只操作本租户的 key
lua = """
local left = redis.call('DECR', KEYS[1])
if left < 0 then redis.call('INCR', KEYS[1]) return -1 end
return left
"""
left = r.eval(lua, 1, stock_key(tid, pid))

同理,延迟队列、限流计数、验证码等所有 Redis key 都遵循 t{tenant_id}:业务:对象 的命名规范。迁移时用 SCAN 批量重写旧 key,注意别用 KEYS,会阻塞实例。

八、总结

多商户 SaaS 化改造的核心就三件事:表结构加租户列、每一行 SQL 带租户条件、所有缓存和密钥按租户隔离。想省事的话,商城在售的转卡码系统 V3 已内置多商户租户体系、独立支付配置与 AES 密钥加密,部署即可开通子商户;需要私有化定制,也可以直接在此基础上扩展。