2b69686795
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
205 lines
9.6 KiB
TypeScript
205 lines
9.6 KiB
TypeScript
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
|
||
})
|