import { getHeader, getRequestHost, createError } from 'h3' import type { TenantContext, AppProduct, AppDomain } from '~/app/types' import { resolveTenantFromHost } from '../utils/tenant' import { getAppProductById, getAppProductByDomain, getAppDomainByDomain } from '../utils/app' import { getCmsSiteInfo } from '../utils/site' import { getTemplateCodeById } from '../utils/template-map' import { useRuntimeConfig } from '#imports' // 调试日志开关:排查域名→租户解析优先级时置 true,稳定后改 false const DEBUG_TENANT = true const logT = (...args: unknown[]) => { if (DEBUG_TENANT) console.log('[tenant]', ...args) } /** * 租户 / 应用识别中间件 * 每次请求执行,识别当前租户与应用产品信息(AppProduct) * 结果写入 event.context.tenant,供 SSR 和 Server API 使用 * * 识别规则(优先级从高到低): * 1. Header 显式指定(tenantid / appid)—— 调试 / 特殊场景,跳过域名解析 * 2. 本地开发环境(localhost / IP):按 .env 的 NUXT_PUBLIC_APP_ID(productId)查 app_product 详情 * 3. 生产环境(非本地域名):按「当前访问域名」解析租户与应用,顺序为: * a. 最高优先级:查 appDomain 表(自定义域名绑定,getAppDomainByDomain)→ 显式绑定表优先, * 命中后以 appDomain.tenantId 为准、回查产品仅取模板/品牌 * b. 兜底:查 app_product.domain = 当前域名(getAppProductByDomain) * c. 兜底:子域名 site-{tenantId}.shoplnk.cn 提取租户 * d. 以上均未命中 → 视为「域名未授权」,抛 403(由 error.vue 渲染品牌化未授权页) * 命中后 event.context.tenant.tenantId 会被作为 TenantId 请求头传给下游所有 server/api 接口 */ export default defineEventHandler(async (event) => { const config = useRuntimeConfig() const host = getRequestHost(event) || '' const hostname = host.split(':')[0] const isLocal = hostname === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(hostname) const tenantIdHeader = getHeader(event, 'tenantid') const appIdHeader = getHeader(event, 'appid') logT('==== 请求 host=%s hostname=%s isLocal=%s tenantIdHeader=%s appIdHeader=%s ====', host, hostname, isLocal, tenantIdHeader || '-', appIdHeader || '-') // 防御性类型保障:runtimeConfig 序列化可能将数组退化为字符串 const _baseDomains = Array.isArray(config.public.baseDomains) ? (config.public.baseDomains as string[]) : String(config.public.baseDomains || '').split(',').map((s: string) => s.trim()).filter(Boolean) const _subdomainPrefixes = Array.isArray(config.public.subdomainPrefixes) ? (config.public.subdomainPrefixes as string[]) : String(config.public.subdomainPrefixes || '').split(',').map((s: string) => s.trim()).filter(Boolean) logT('配置 baseDomains=%s prefixes=%s defaultTenantId=%s defaultAppId=%s', _baseDomains.join(','), _subdomainPrefixes.join(','), config.public.tenantId, config.public.appId) let ctx: TenantContext let appProduct: AppProduct | null = null if (tenantIdHeader) { logT('走 [Header] 分支 (tenantid/appid 显式指定)') // 优先级 1: Header 显式指定(调试 / 特殊场景),跳过域名解析 ctx = { tenantId: tenantIdHeader, appId: appIdHeader || (config.public.appId as string), templateId: config.public.templateId as string, source: 'header', host } // 仍按 appId 取应用信息用于 SSR if (ctx.appId) { appProduct = await getAppProductById(ctx.appId, config) } } else if (host && !isLocal) { logT('走 [生产] 分支,开始解析 hostname=%s', hostname) // 优先级 3: 生产环境(非本地域名)按访问域名解析租户与应用 // 最高优先级:appDomain 表(自定义域名绑定)—— 显式绑定表优先于 app_product.domain, // 避免两者 tenantId 不一致时串号,且让域名绑定成为域名→租户的唯一权威来源 const appDomain: AppDomain | null = await getAppDomainByDomain(hostname, config) logT('[Step a] appDomain 绑定表查询 hostname=%s => %s', hostname, appDomain ? `命中(tenantId=${appDomain.tenantId}, productId=${appDomain.productId}, domain=${appDomain.domain})` : '未命中(null)') if (appDomain) { // 命中绑定表:强制以 appDomain.tenantId 为准,回查产品仅用于取模板/品牌 let lookedUp: AppProduct | null = null if (appDomain.productId) { lookedUp = await getAppProductById(appDomain.productId, config) } logT('[Step a] 命中绑定表,回查产品 productId=%s => %s', appDomain.productId, lookedUp ? `成功(产品自身tenantId=${lookedUp.tenantId})` : '回查失败(用绑定表字段兜底)') appProduct = lookedUp ? { ...lookedUp, tenantId: appDomain.tenantId } : ({ tenantId: appDomain.tenantId, productId: appDomain.productId, templateId: appDomain.templateId } as AppProduct) } else { // 兜底:app_product.domain = 当前域名 appProduct = await getAppProductByDomain(hostname, config) logT('[Step b] app_product.domain 查询 hostname=%s => %s', hostname, appProduct ? `命中(tenantId=${appProduct.tenantId}, productId=${appProduct.productId})` : '未命中(null)') } if (appProduct) { ctx = { tenantId: String(appProduct.tenantId ?? config.public.tenantId), appId: String(appProduct.productId ?? config.public.appId), templateId: appProduct.templateId || (config.public.templateId as string), source: appDomain ? 'appDomain' : 'domain', host } logT('解析命中 → ctx: tenantId=%s appId=%s templateId=%s source=%s', ctx.tenantId, ctx.appId, ctx.templateId, ctx.source) } else { // Step c(兜底):子域名 {prefix}-{tenantId}.{baseDomain} 提取租户 → 视为授权 const subCtx = resolveTenantFromHost( host, _baseDomains, _subdomainPrefixes, config.public.tenantId as string, config.public.appId as string, config.public.templateId as string ) logT('[Step c] 子域名解析 host=%s => source=%s (baseDomains=%s)', host, subCtx.source, _baseDomains.join(',')) if (subCtx.source === 'subdomain') { ctx = subCtx logT('[Step c] 命中子域名,提取 tenantId=%s', subCtx.tenantId) if (ctx.appId) { appProduct = await getAppProductById(ctx.appId, config) } } else { // Step d:app_product / appDomain / 子域名均未命中 → 域名未授权 logT('[Step d] appDomain/app_product/子域名均未命中 → 抛 403 (host=%s)', host) throw createError({ statusCode: 403, statusMessage: '该域名未授权,请联系管理员完成域名绑定', data: { unauthorizedDomain: true, host } }) } } } else { // 优先级 2: 本地开发(localhost / IP):按 .env 的 appId 查询,跳过上游域名解析 logT('走 [本地开发] 分支 (localhost/IP)') ctx = resolveTenantFromHost( host, config.public.baseDomains as string[], config.public.subdomainPrefixes as string[], config.public.tenantId as string, config.public.appId as string, config.public.templateId as string ) if (ctx.appId) { appProduct = await getAppProductById(ctx.appId, config) } } // 用 AppProduct 中的模板 ID 覆盖(若存在) if (appProduct?.templateId) { ctx.templateId = appProduct.templateId } // 强制模板 ID(本地调试用):一旦设置即拥有最高优先级,覆盖应用/站点绑定 const forceTemplateId = config.public.forceTemplateId as string if (forceTemplateId) { logT('forceTemplateId 覆盖: %s -> %s', ctx.templateId, forceTemplateId) ctx.templateId = forceTemplateId } logT('最终 ctx: tenantId=%s appId=%s templateId=%s source=%s', ctx.tenantId, ctx.appId, ctx.templateId, ctx.source) // 预取 CMS 站点信息(仅页面请求,跳过 /api 与静态资源,避免无谓开销) // 结果写入 event.context.siteInfo,由 site-init.server.ts 注入 useState('site-info'), // 保证 SSR 首屏渲染模板时 siteTemplateId 已可用(网站数据库优先) const reqPath = event.path || '' const isApiOrAsset = reqPath.startsWith('/api/') || reqPath.startsWith('/_') || /\.(?:ico|png|jpe?g|gif|svg|css|js|map|woff2?|ttf|txt)$/i.test(reqPath) if (ctx.tenantId && !isApiOrAsset) { try { const siteInfoData = await getCmsSiteInfo(ctx.tenantId, config) if (siteInfoData) { // 回填模板标识 templateCode:前端按 code 解析模板目录(主键仅兜底)。 // 上游 getSiteInfo 目前只返回 templateId 主键,而主键存在跳号错位风险, // 因此这里反查 app_template 表拿权威 code;一旦上游直接返回 templateCode, // 本分支自动跳过(不再查表),无需改动。 if (!siteInfoData.templateCode && siteInfoData.templateId) { const code = await getTemplateCodeById(siteInfoData.templateId, config) if (code) { siteInfoData.templateCode = code logT('模板标识回填: templateId=%s -> templateCode=%s', siteInfoData.templateId, code) } } event.context.siteInfo = siteInfoData } } catch { // 站点信息预取失败不阻断请求,模板解析回退到应用绑定/默认值 } } ctx.appProduct = appProduct || undefined event.context.tenant = ctx })