一、单商户跑通后,多商户 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 密钥加密,部署即可开通子商户;需要私有化定制,也可以直接在此基础上扩展。