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

205 lines
9.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_IDproductId)查 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 dapp_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
})