feat(app): 添加多模板关于我们页面及相关路由和404页面

- 新增404页面,优化未找到页面体验,避免被搜索引擎索引
- 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理
- 实现/article、/case、/product及/page动态路由兼容列表与详情展示
- 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置
- 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持
- 模板增强支持CMS单页内容加载及SEO信息动态设置
- 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
This commit is contained in:
2026-09-08 12:13:44 +08:00
commit 2b69686795
381 changed files with 59891 additions and 0 deletions
+204
View File
@@ -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_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
})
@@ -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)
}
}
})