feat(app): 添加多模板关于我们页面及相关路由和404页面
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
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
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { sendRedirect } from 'h3'
|
||||
|
||||
/**
|
||||
* 旧模块名 → 新模块名(按模块名单数命名)301 重定向
|
||||
*
|
||||
* 2026-07-20 起 URL 规范统一为模块名单数:
|
||||
* 新闻 /article、产品 /product、案例 /case(详情与栏目同一词根,靠 navId 判定)。
|
||||
* 改名前的旧链接需兼容,避免已收录 / 外链失效:
|
||||
* /news、/newss → /article
|
||||
* /products → /product
|
||||
* /cases → /case
|
||||
* 新规范路径(/article、/product、/case)不在映射内,不重定向。
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
const path = event.path || ''
|
||||
if (
|
||||
path.startsWith('/api/') ||
|
||||
path.startsWith('/_') ||
|
||||
/\.(?:ico|png|jpe?g|gif|svg|css|js|map|woff2?|ttf|txt)$/i.test(path)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const OLD_TO_NEW: Array<[string, string]> = [
|
||||
['/news', '/article'],
|
||||
['/newss', '/article'],
|
||||
['/products', '/product'],
|
||||
['/cases', '/case']
|
||||
]
|
||||
|
||||
for (const [oldPrefix, newPrefix] of OLD_TO_NEW) {
|
||||
if (path === oldPrefix || path.startsWith(oldPrefix + '/')) {
|
||||
const rest = path.slice(oldPrefix.length) // '' 或 '/xxx'
|
||||
return sendRedirect(event, newPrefix + rest, 301)
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user