状态:设计中 关联:本仓库多租户为 database-per-tenant,门店前台为「按商户部署」。
1. 背景与现状
「店铺二级域名」(如 mystore.myshop.com 直接进对应店铺)所需的数据模型已经存在,但请求链路里尚未接入。
- 多租户(按库隔离):商户库连接由请求头
x-merchant-id→ portal 的user.connection解析(src/common/server/middlewares/tenant-connection-middleware.ts:33),放进AsyncLocalStorage;mall / store 上下文只连当前商户库,无平台默认库可回落(fail-closed)。 - 按商户部署:store / backoffice 在启动期就把
x-merchant-id按 admin 商户身份注入(src/store/modules/data-context-scope.ts注释),即每个商户跑自己一份实例,商户在启动期已确定。 - 门店前台当前的店铺识别方式:只认 URL 里的
/{store}段(src/store/shared/store-code-request.ts的resolveStoreCodeFromUrl,匹配/{store}/api/**、/open-api/v1/{store}/**),取不到就 fail-closed(src/store/middlewares/store-id-context.ts)。backoffice 则靠store-codecookie(src/common/server/middlewares/store-code-middleware.ts)。 Domain实体已建好但未接入:src/common/data/mall/entities.ts:201定义Domain { name / requestHeader / language / storeId },src/common/data/mall/mappings.ts:48已映射domain表,mallDataContext.domains(src/common/data/mall/mall-data-context.ts:44)可访问。但全仓没有任何请求链路查询它——它是沉睡模型。- portal 已有嵌入代理:portal 内部通过
config.mountPath+openUrl(src/portal/public/index.tsx一带)把 backoffice/store 等反代嵌入壳内,那是「壳内模块嵌入」,与本文的「对外门店子域网关」是两回事(见第 7 节)。
2. 目标
让 *.myshop.com 这类二级域名直接访问对应店铺的门店前台,且:
- 不破坏现有「按 URL
/{store}段」的访问方式(兼容 API / 跨店访问)。 - 复用现有
x-merchant-id→ 商户库、以及Domain表(requestHeader→storeId)的数据模型。 - 改动最小,契合当前「按商户部署」架构(即模型 A)。
3. 部署模型 A 的定位
模型 A = 纯路由层(host → upstream):reverse-proxy 只按主机名把请求转发到「该商户自己的 store 实例」;「这个域名落在哪个 store」仍由各商户实例内部的 store-id-context + Domain 表解析。
proxy 不需要感知 store 粒度,只需一份「子域 → 商户上游」的映射。这与现状「按商户部署、启动期注入 merchant」完全兼容,无需把系统改成共享多租户部署。
对照模型 B(未采用):所有商户共用一份 store 服务,proxy 在边缘解析
域名 → merchant + store并注入头,上游无状态。更省资源但是更大的架构迁移,本期不做。
4. 整体架构
DNS 通配 *.myshop.com → proxy 公网 IP
│
浏览器 ── mystore.myshop.com ──────┤
── other.myshop.com ────────┤
▼
┌─────────────────────┐
│ reverse-proxy │ ← 新增项目(src/reverse-proxy)
│ · TLS 终止 │
│ · host → upstream │
│ · 注入 x-merchant-id │
└──────────┬──────────┘
按"子域→商户"映射分流 │
┌───────────────┬────────┴─────────┬──────────────┐
▼ ▼ ▼ ▼
merchantA 的 merchantB 的 portal 壳 (其它服务)
store 实例 store 实例
(x-merchant-id (x-merchant-id
已注入) 已注入)
│
▼ store-id-context 读 Host → 查 Domain 表 → storeId
渲染该商户下对应 store 的门店前台
层级区分:外网 → reverse-proxy(入口网关)→ 各服务(portal / store / …)。reverse-proxy 是比 portal 内部「嵌入代理」更外层的入口。
5. 域名 → 商户 的单一真相源
proxy 在转发前必须知道「这个子域属于哪个商户、打到哪个上游」。难点:Domain 表是按商户库隔离的,proxy 解析前还不知道商户,不能直接查。候选来源:
-
静态配置(采用 / 起步):在
config-portal.yaml(或新建config-reverse-proxy.yaml)里列出子域 → 上游 URL,例如:proxy: routes: - host: "mystore.myshop.com" upstream: "http://localhost:9001" # merchantA 的 store 实例 - host: "*.merchant-a.com" upstream: "http://localhost:9001"最简单、零额外查询,适合初期与小规模;配合 proxy 进程内缓存即可。
-
平台级 registry(后续可演进):新增平台库表
domain → merchantId + upstream,与现有 per-merchantDomain表分离/互补;或复用 portal 库的 merchant 注册信息加domain字段,proxy 启动时/定期拉取。动态自助绑域时再上。 -
直接问 portal:proxy 调 portal 接口解析 domain,耦合更强,一般不作为首选。
6. proxy 与现有中间件的关系
- proxy 注入
x-merchant-id后,tenant-connection-middleware(src/common/server/middlewares/tenant-connection-middleware.ts)原样工作,无需改动——它本来就从x-merchant-id头解析商户库连接。 - 子域 → store 的解析留在各商户 store 实例内:在
src/store/middlewares/store-id-context.ts中,resolveStoreCodeFromUrl(req.path)取不到店铺时,读req.headers.host(去端口、小写),查dc.domains.singleOrDefault({ requestHeader: host })得到storeId写storeIdStorage。因为该实例的 mall 上下文已绑到本商户库,查询天然落在正确库,无「先定位商户」的鸡生蛋问题。- 优先级:
/{store}URL 段 > Host 子域(兼容现有 API 与跨店访问)。 - 回落:页面请求(无扩展名、非
/api)解析不到时回落默认店铺DefaultStoreCode(src/common/shared/constants);API 请求保持 fail-closed(不回落,避免读错店数据)。 domain.language命中时注入 storefront 的x-lang。
- 优先级:
- 不要把子域解析写进
common的 store-code 中间件(它只认 cookie、服务 backoffice,会污染店铺偏好)。
7. 与 portal 嵌入代理的区别
| 维度 | portal 内部嵌入代理 | 本文 reverse-proxy |
|---|---|---|
| 位置 | 壳内(portal 进程内) | 最外层入口网关(独立进程) |
| 作用 | 把 backoffice/store 等模块反代进 portal 壳 | 把外网 *.域名 按 host 路由到对应商户服务 |
| 受众 | 已登录的商户/管理员 | 公开门店访客(无 x-merchant-id) |
两者互补,不冲突。
8. 技术选型与落点
-
新增
src/reverse-proxy模块:用express+http-proxy(如http-proxy/http-proxy-middleware)实现 host 路由与头注入。 -
纳入现有工具链:复用
scripts/build/build-app.ts(各 app 单独构建)与npm run start:<name>启动体系;运行方式与现有一致——node --import ./scripts/esm/register.mjs …(项目的 ESM loader)。 -
结构建议:
src/reverse-proxy/ start.ts # 进程入口,挂载 express + 代理路由 proxy.ts # host → upstream 匹配 + http-proxy 转发 config.ts # 读 config-reverse-proxy.yaml 的 routes app.module.ts # 若沿用 Nest 风格(可选) -
路由匹配支持精确 host 与通配(
*.merchant-a.com);命中后proxy.web(req, res, { target: upstream, changeOrigin: true })并补x-merchant-id。
9. TLS / DNS(基础设施)
- 通配符 DNS:
*.myshop.com→ proxy 公网 IP(A / CNAME)。 - 通配符证书:Let’s Encrypt DNS-01 或托管证书;proxy 终止 TLS(或 passthrough 到上游,需上游也持证书)。
- 本地开发:
*.localhost或 hosts 文件 + 自签通配证书;config-reverse-proxy.yaml里配localhost上游即可联调。
10. 落地顺序(最小改动)
- 新增
src/reverse-proxy:host 路由 + 注入x-merchant-id(来源先用静态配置),TLS 终止。 - store 实例接入 Host→Domain→store 解析:改
src/store/middlewares/store-id-context.ts(第 6 节方案),并把解析结果暂存到req供 SSR 复用。 - store 页面 SSR 复用:
src/store/page-render/*当前也按 URL/referer 取店铺,需支持「路径无 store 段时改用req上已解析的 storeId」(避免mystore.myshop.com/直访失败)。 - DNS 通配 + 证书。
- 域名绑定 UI(后续):在 backoffice 商店管理里提供「绑定子域名」表单,写
Domain.requestHeader;平台级 registry(第 5 节方案 2)成熟后再让 proxy 动态拉取,替代静态配置。
11. 待确认的关键决策
- 模型:本期确定走 A(纯路由层、沿用按商户部署),不改成共享多租户部署(B)。
- proxy 形态:确定为 Node 模块(
src/reverse-proxy),而非 nginx / Caddy 配置生成器。 - 域名→商户来源:起步用静态配置,后续是否接平台级 registry / 动态绑定。
- 多 store 同商户:一个商户实例下多个 store 用不同子域时,A 模型下 proxy 仍按「子域→该商户上游」转发,store 实例内由
Domain表区分具体 store(已覆盖)。 - 默认/兜底域名:商户主域名(非子域)访问时,store 实例回落默认店铺还是 404,需与产品确认。
12. 风险与注意
Domain.requestHeader的存储格式需与 proxy / store 解析时归一(小写、去端口、可去www.),避免大小写/端口不匹配漏匹配。- proxy 注入
x-merchant-id必须可信(仅来自配置映射,不被客户端头覆盖),否则存在越权访问别商户库的风险——proxy 应强制覆盖该头,忽略客户端传入值。 - 静态配置改动需重启 proxy;若走 registry 则需 proxy 支持热加载 / 短 TTL 缓存。
13. 落地状态(2026-11-02)
已实现 src/reverse-proxy(express + http-proxy-middleware,端口 8080 与 config.yaml 的 host: localhost:8080 对齐,
npm run start:reverse-proxy),
但第 10 节的第 2、3 步没有做:store 仍未接 Host→Domain→store 解析。落地时改用「网关路径改写」达成同样的效果:
浏览器 http://vip-pet.localhost:8090/cart?x=1
网关 → http://localhost:8400/store/vip-pet/cart?x=1
依据:store 只按 URL 路径段识别店铺(src/store/shared/store-code-request.ts,实际形态是
/store/{store}/api/... 与 /store/{store}/{页面路径}),且 SSR 生成的内部链接、静态资源都是站根绝对路径
(src/store/shared/rewrite-store-paths.ts)。
改写规则(src/reverse-proxy/routes.ts):
| 外部路径 | 转发到 store |
|---|---|
/ | /store/{子域名首段} |
/cart、/product/1 | /store/{子域名首段}/cart、.../product/1 |
/store/...(SSR 生成的内部链接) | 原样 |
/content、/node_modules、/common、/static、/shared、/favicon.ico | 原样(站根绝对资源) |
配置 config-reverse-proxy.yaml 只需要一项:
backendServices:与config-portal.yaml/config-store.yaml同构(http://host:port/<mountPath>, 路径段既是挂载点也是out/<dirName>产物目录),本地地址由网关在本进程内拉起(端口已占用则复用), 所以只起网关即可把 store 一起带起来。
x-merchant-id 没有全局配置:多店铺分属不同商户,写死一个值必然把请求打到错误的租户库。
改为查平台级店铺索引(portal 库的 store_registry 表):
store_registry(id PK, store_code, store_name, merchant_id)
id = 商户库 store.id
store_code = 店铺 code(小写,= 一级子域名)
store_name = 店铺显示名(照抄 store.name,portal 侧展示不连商户库)
merchant_id = 持有该店铺租户库的 portal user.id(即 x-merchant-id)
- 为什么需要这张表:
x-merchant-id的唯一用途是反查user.connection,而店铺标识在 URL 上、 商户信息在商户库内部,中间这一跳没有其它来源;没有索引就只能遍历所有商户库(O(商户数) 建连)。 merchant_id取库所属的 user.id,不是store.ownerId(业务归属,未必持有 connection)。- 写入点(缺一处即漂移):建店 / 改 code / 删店(
src/backoffice/modules/store/controller.ts)、 开通流程(src/portal/modules/auth/user-controller.ts);存量与修复用npm run store:registry:sync。 - 读取:网关启动时加载,之后按
registry.ttlSeconds(默认 60s)惰性后台刷新; 未命中(新店刚建)按 5s 限频重试,当次按「不注入」处理;刷新失败保留旧表。 - 约定「子域名 = 店铺 code」:索引键与路径改写都用 code,
store.subdomain与 code 不一致的店铺 其子域访问会失败(store:registry:sync会给出 ⚠ 提示)。
根域首页(http://localhost:8080/)直接列出 store_registry 里的全部店铺,每行给一个
{code}.localhost:8080 入口(src/reverse-proxy/store-index-page.ts,数据取网关内存索引的快照,
不额外查库;可用 rootIndex: false 关闭)。只拦「根域 + /」,子域请求照旧走路由改写。
店铺子域的路由是自动生成的(src/reverse-proxy/config.ts 的 defaultStoreRoutes):取
backendServices 里 dirName=store 的条目(可用 storeService 改名)作上游与 storePathPrefix(其 mountPath),
host 模式为 *.{config.yaml 的 subdomain.baseDomain}(当前 localhost),店铺 code 取子域名首段。
因此 http://vip-pet.localhost:8080/ 开箱可用,不需要配 routes / upstream;要固定店铺或反代其它服务时,
才用可选的 routes(service: <dirName> 引用 backendServices 条目,或直接写 upstream)。
与本节原设计的关系:
- 优点:store 零改动,
/{store}URL 形态与子域形态共用同一套路由;cookiePath=/store/{store}也天然对齐。 - 代价:外部 URL 与内部路径不一致(页面内链接仍是
/store/{store}/...),因此后续若要做「干净的vip-pet.localhost/cart链接」,仍需回到第 10 节第 2、3 步,在 store 内接 Host→Domain 解析。 x-merchant-id由网关按店铺关联解析后注入,并强制覆盖客户端值(src/reverse-proxy/proxy.ts的 dispatcher); 路由级routes[].merchant仍保留,作为少数「固定 host」场景的显式例外。
14. 自定义域名验证(已实现,2026-11-03)
后台「店铺 → 域名配置」的「验证」按钮此前只把状态置为 pending,TXT 值还是硬编码的
ns-verify=abc123xyz;现已接上真实校验。
两条记录各管一件事
| 记录 | 证明 | 校验 |
|---|---|---|
TXT _node-shop-verify.<域名> = ns-verify=<token> | 域名所有权(别人配不了你的 DNS) | dns.resolveTxt,把 string[][] 拼整串后与期望值精确比对 |
CNAME <域名> → {store.code}.{baseDomain} | 流量已指向平台 | dns.resolveCname,去尾点后比对 |
令牌:store.custom_domain_verify_token(varchar 64)。首次验证或更换域名时重新生成
(旧 TXT 记录随之失效),之后稳定不变。索引与状态都由同一个接口写回。
状态机(store.customDomainStatus)
| TXT | CNAME | 状态 | 含义 |
|---|---|---|---|
| ✗ | — | unverified | 所有权未确认 |
| ✓ | ✗ | pending | 所有权已确认,等解析生效(可反复点验证重试) |
| ✓ | ✓ | verified | 绑定完成 |
接口:POST {mountPath}/api/store/verify-domain/:id(apiPaths.store.verifyCustomDomain),
body 可带 customDomain(未保存时先校验输入)。返回
{ status, verified, ownershipVerified, cnameVerified, codes[], token, expectedTxt, expectedCname },
codes 是失败原因代码(txt_not_found / txt_mismatch / cname_not_found / cname_mismatch / dns_timeout),
前端按代码取 i18n 文案,后端不返回中文。校验实现见 src/common/subdomain/custom-domain-verify.ts
(单条查询 3s 超时,避免接口被解析器拖死)。
本地开发:/etc/hosts 只能写 IP,配不了 TXT,所以 config.yaml 的
subdomain.verifyMode 支持 always-allow(跳过 DNS 直接放行);开发机当前为 always-allow,
生产必须改回 dns。
仍未覆盖:验证通过后网关还不认这个自定义域名(store_registry 只有 store_code → merchant_id,
*.baseDomain 通配也匹配不到自定义域名)。要让自定义域名真正可访问,需把已 verified 的域名
写进索引(或网关按 Host 查域名表),这一步留待下一轮。