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,27 @@
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
import { getAppProductById } from '../../utils/app'
|
||||
import type { TenantContext } from '~/app/types'
|
||||
|
||||
/**
|
||||
* 获取当前应用信息
|
||||
* GET /api/app/info
|
||||
*
|
||||
* 优先返回中间件已识别的 AppProduct(event.context.tenant.appProduct)
|
||||
* 若中间件未查到(异常降级),则用 appId 实时补查
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const tenant = event.context?.tenant as TenantContext | undefined
|
||||
|
||||
// 中间件已查到直接返回
|
||||
if (tenant?.appProduct) {
|
||||
return tenant.appProduct
|
||||
}
|
||||
|
||||
// 兜底:实时查询
|
||||
const appProduct = await getAppProductById(ctx.appId, config)
|
||||
return appProduct || null
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
|
||||
/**
|
||||
* 文章详情
|
||||
* GET /api/article/detail?id=123
|
||||
* 代理到 CMS: /cms/cms-article/{id}
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const query = getQuery(event)
|
||||
const modulesApiBase = config.public.modulesApiBase as string
|
||||
|
||||
if (!query.id) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing id parameter' })
|
||||
}
|
||||
|
||||
let res: any
|
||||
try {
|
||||
res = await $fetch(`/cms/cms-article/${query.id}`, {
|
||||
baseURL: modulesApiBase,
|
||||
headers: {
|
||||
TenantId: ctx.tenantId
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to fetch article detail'
|
||||
})
|
||||
}
|
||||
|
||||
// C 端不展示草稿(1)/下架(2)/已删除文章:status 非 0(已发布) 或 deleted=1 视为不存在
|
||||
// 上游真实枚举:0=已发布 1=草稿 2=下架(与早期假设相反,已于 2026-07-29 校正)
|
||||
// 上游详情接口不校验状态,需在此拦截,否则可通过 /article/:id 直接访问未发布内容。
|
||||
// 上游对不存在/未发布文章常返回 {code:1, message:"文章ID不存在"}(无 data),
|
||||
// 此时 res.code !== 0,必须 404,否则扁平后的对象为 truthy,前端会误判为「文章存在」渲染空页。
|
||||
const detail = res?.data ?? res
|
||||
if (typeof res?.code === 'number' && res.code !== 0) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found or unpublished' })
|
||||
}
|
||||
if (!detail || detail.status === undefined || detail.status !== 0 || detail.deleted === 1) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found or unpublished' })
|
||||
}
|
||||
|
||||
// 文章附件:上游 files 可能是 JSON 字符串或数组,容错解析为数组后随扁平数据返回
|
||||
let files: any[] = []
|
||||
if (typeof detail.files === 'string' && detail.files.trim()) {
|
||||
try {
|
||||
const arr = JSON.parse(detail.files)
|
||||
if (Array.isArray(arr)) files = arr
|
||||
} catch {
|
||||
// 解析失败则忽略,不阻断详情返回
|
||||
}
|
||||
} else if (Array.isArray(detail.files)) {
|
||||
files = detail.files
|
||||
}
|
||||
|
||||
// 字段兼容归一化:上游 CMS 不同版本字段名不一致,统一补齐,
|
||||
// 避免前端模板的 <RichText :content> / 副标题取不到值而渲染空白。
|
||||
// - content(富文本正文):部分版本放在 description,兜底回退(content 优先)
|
||||
// - subtitle(副标题):上游多数版本返回 summary,兜底回退
|
||||
if (detail && typeof detail === 'object') {
|
||||
if (detail.content == null || detail.content === '') {
|
||||
detail.content = detail.description || ''
|
||||
}
|
||||
if (detail.subtitle == null || detail.subtitle === '') {
|
||||
detail.subtitle = detail.summary || ''
|
||||
}
|
||||
}
|
||||
|
||||
// 返回扁平数据,去掉上游 {code, message, data} 包装层
|
||||
// 前端 useFetch<Article> 直接访问 article.title / article.content 等字段
|
||||
return {
|
||||
...detail,
|
||||
files: files.length ? files : undefined,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
|
||||
/**
|
||||
* 文章列表
|
||||
* GET /api/article/list?navigationId=4273&page=1&limit=10
|
||||
* 代理到 CMS: /cms/cms-article/page
|
||||
*
|
||||
* 参数说明:
|
||||
* - navigationId: 导航栏目ID(从 getSiteInfo 的 topNavs 获取)
|
||||
* - page / limit: 分页
|
||||
* - keywords: 搜索关键词
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const query = getQuery(event)
|
||||
const modulesApiBase = config.public.modulesApiBase as string
|
||||
|
||||
try {
|
||||
// 前端传 navigationId;上游 cms-article 实际按 categoryId 过滤(文章无 navigationId 字段,只有 categoryId)。
|
||||
// 二者同值(文章分类 ID == 对应栏目 navigationId),透传 categoryId 以正确按栏目过滤;
|
||||
// 保留 navigationId 兼容旧租户。
|
||||
const categoryId = query.navigationId ?? query.categoryId
|
||||
// 聚合场景:前端递归收集父栏目下所有子栏目的 navigationId,以逗号串传入。
|
||||
// 上游 article 用 categoryIdsStr 字段接收(与现有 categoryIds Set 分支并存),有则走 IN 查询。
|
||||
const categoryIds = query.categoryIds as string | undefined
|
||||
|
||||
const res = await $fetch('/cms/cms-article/page', {
|
||||
baseURL: modulesApiBase,
|
||||
headers: {
|
||||
TenantId: ctx.tenantId
|
||||
},
|
||||
query: {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
...query,
|
||||
navigationId: categoryIds ? undefined : query.navigationId,
|
||||
categoryId: categoryIds ? undefined : categoryId,
|
||||
categoryIdsStr: categoryIds || undefined,
|
||||
// C 端只展示已发布文章:status 0=已发布 1=草稿 2=下架(与早期假设相反,已于 2026-07-29 校正)
|
||||
// 放在最后以覆盖前端可能传入的 status,避免泄露草稿/下架内容
|
||||
status: 0,
|
||||
// 同时排除软删除(deleted=1)的文章,兜底防止已删除内容出现在前台
|
||||
deleted: 0
|
||||
}
|
||||
})
|
||||
|
||||
return res
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to fetch article list'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../utils/tenant'
|
||||
|
||||
/**
|
||||
* 获取轮播图列表
|
||||
* GET /api/banner
|
||||
*
|
||||
* 真实数据结构为「分组(cms_banner_group) + 明细(cms_banner_item)」:
|
||||
* - 明细(item)内嵌在分组的 items[] 中返回,单独 /cms/cms-banner-item/page 接口不存在(404);
|
||||
* - 旧的扁平接口 /cms/cms-banner/page 对本仓库对接的租户无数据(已废弃)。
|
||||
*
|
||||
* 本接口代理 /cms/cms-banner-group/page,按「启用分组 + 未删除明细」扁平化后,
|
||||
* 映射成前端 CmsBanner 结构返回。支持:
|
||||
* - position 查询参数:只取对应位置的轮播(如 home_slider),缺省返回全部启用分组;
|
||||
* - 时间与状态过滤:分组 status=1、未删除、且在 [startTime,endTime] 窗口内(若设置)才展示。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const query = getQuery(event)
|
||||
const modulesApiBase = config.public.modulesApiBase as string
|
||||
|
||||
// position 由本接口自行过滤,不往上游透传(避免 CMS 无此参数导致异常)
|
||||
const position = (query.position as string | undefined) || undefined
|
||||
const { position: _omit, ...upstreamQuery } = query
|
||||
|
||||
try {
|
||||
const res: any = await $fetch('/cms/cms-banner-group/page', {
|
||||
baseURL: modulesApiBase,
|
||||
headers: {
|
||||
TenantId: ctx.tenantId
|
||||
},
|
||||
query: {
|
||||
page: 1,
|
||||
limit: 50,
|
||||
...upstreamQuery
|
||||
}
|
||||
})
|
||||
|
||||
const groups: any[] = res?.data?.list ?? res?.list ?? []
|
||||
const now = Date.now()
|
||||
|
||||
// 1) 筛选启用分组
|
||||
const enabledGroups = groups.filter((g) => {
|
||||
if (g.deleted === 1) return false
|
||||
if (g.status !== 1) return false
|
||||
if (position && g.position !== position) return false
|
||||
// 时间窗口:startTime/endTime 任一为空则视为不限;两者都有时需在窗口内
|
||||
// ⚠️ CMS 时间字符串为「中国时间(Asia/Shanghai, UTC+8)」,必须显式按 +08:00 解析,
|
||||
// 否则 dev/server 在 UTC 时区下会把窗口整体算错(实测曾把在用分组误判为未生效)。
|
||||
if (g.startTime && g.endTime) {
|
||||
const start = parseCmsTime(g.startTime) // 显式按 UTC+8 解析(中国时间)
|
||||
const end = parseCmsTime(g.endTime) // 显式按 UTC+8 解析(中国时间)
|
||||
if (start !== null && end !== null && (now < start || now > end)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// 2) 扁平化明细并过滤未删除项
|
||||
const list: any[] = []
|
||||
for (const g of enabledGroups) {
|
||||
const items: any[] = Array.isArray(g.items) ? g.items : []
|
||||
for (const it of items) {
|
||||
if (it.deleted === 1) continue
|
||||
list.push({
|
||||
// 兼容前端 id 字段
|
||||
id: it.itemId ?? it.id,
|
||||
itemId: it.itemId,
|
||||
groupId: it.groupId ?? g.groupId,
|
||||
// 图片地址(优先 image,兼容其它字段名)
|
||||
image: it.image || it.imageUrl || it.pic || it.url || '',
|
||||
imageUrl: it.imageUrl || it.image,
|
||||
pic: it.pic,
|
||||
url: it.url,
|
||||
// 文案
|
||||
title: it.title || g.title || '',
|
||||
subtitle: it.subtitle || '',
|
||||
// 跳转(linkType: 0=无 1=外链 2=站内)
|
||||
link: it.linkUrl || '',
|
||||
linkUrl: it.linkUrl || '',
|
||||
linkType: it.linkType ?? 0,
|
||||
linkTargetId: it.linkTargetId ?? null,
|
||||
// 排序(组内 sortNum);分组 sortNum 作为兜底
|
||||
sortNum: it.sortNum ?? g.sortNum ?? 0,
|
||||
sortOrder: it.sortNum ?? g.sortNum ?? 0,
|
||||
// 状态/删除标记:沿用分组状态,前端按「非 2 即显示、deleted!==1」处理
|
||||
status: it.status ?? g.status ?? 1,
|
||||
deleted: it.deleted ?? 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 按 sortNum 升序
|
||||
list.sort((a, b) => (a.sortNum ?? 0) - (b.sortNum ?? 0))
|
||||
|
||||
return {
|
||||
code: res?.code ?? 0,
|
||||
message: res?.message ?? '操作成功',
|
||||
data: {
|
||||
list,
|
||||
count: list.length
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to fetch banners'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 将 CMS 时间字符串(如 "2026-07-30 12:14:47",中国时间 UTC+8)解析为毫秒时间戳。
|
||||
* 显式按 Asia/Shanghai 解析,避免 server 处于 UTC 时区时窗口计算错误。
|
||||
* 解析失败返回 null。
|
||||
*/
|
||||
function parseCmsTime(s?: string | null): number | null {
|
||||
if (!s) return null
|
||||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/)
|
||||
if (!m) return null
|
||||
const [, Y, Mo, D, h, mi, sec] = m
|
||||
// 视为 UTC+8:先按本地组件构造,再减 8 小时换算成 UTC 时间戳
|
||||
return Date.UTC(+Y, +Mo - 1, +D, +h - 8, +mi, +sec)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { saveCaptcha } from '../../utils/guard'
|
||||
|
||||
const SLIDER_SIZE = 42
|
||||
const MIN_W = 240
|
||||
const MAX_W = 480
|
||||
|
||||
/**
|
||||
* 生成滑块拼图验证码 challenge。
|
||||
* 前端量取自身宽度后传入,后端在该宽度内随机生成缺口 X 坐标作为答案。
|
||||
* 返回 token + puzzleX(缺口位置,前端用于渲染) + width + size。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = (await readBody(event).catch(() => ({}))) as Record<string, any>
|
||||
const width = Math.min(Math.max(Number(body?.width) || 320, MIN_W), MAX_W)
|
||||
const token = globalThis.crypto.randomUUID()
|
||||
const answer = Math.floor(Math.random() * (width - SLIDER_SIZE * 2)) + SLIDER_SIZE
|
||||
await saveCaptcha(token, answer)
|
||||
return { token, puzzleX: answer, width, size: SLIDER_SIZE }
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineEventHandler, readBody, createError } from 'h3'
|
||||
import { verifyCaptcha } from '../../utils/guard'
|
||||
|
||||
/**
|
||||
* 校验滑块拼图位置。
|
||||
* body: { token, x } x 为用户拖动后拼图块中心 X 坐标。
|
||||
* 成功返回 { pass: true, ticket }(ticket 为一次性提交票据);失败返回 { pass: false }。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = (await readBody(event).catch(() => ({}))) as Record<string, any>
|
||||
const token = body?.token
|
||||
const x = Number(body?.x)
|
||||
|
||||
if (!token || !Number.isFinite(x)) {
|
||||
throw createError({ statusCode: 400, statusMessage: '参数缺失' })
|
||||
}
|
||||
|
||||
const ticket = await verifyCaptcha(token, x)
|
||||
if (!ticket) return { pass: false }
|
||||
|
||||
return { pass: true, ticket }
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
import { normalizeCaseItem } from '../../utils/case'
|
||||
|
||||
/**
|
||||
* 案例详情
|
||||
* GET /api/case/detail?id=1
|
||||
* 代理到 SaaS 后端 CMS 案例详情接口
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const query = getQuery(event)
|
||||
const modulesApiBase = config.public.modulesApiBase as string
|
||||
|
||||
if (!query.id) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing id parameter' })
|
||||
}
|
||||
|
||||
let res: any
|
||||
try {
|
||||
// 上游 cms-website 服务没有 caseDetail 这类路由(会被 /cms/cms-website/{id} 兜底捕获并因 id 非整数报错),
|
||||
// 正确的案例详情端点属于 cms-case 控制器,且 id 走路径参数:/cms/cms-case/{id}
|
||||
res = await $fetch(`/cms/cms-case/${query.id}`, {
|
||||
baseURL: modulesApiBase,
|
||||
headers: {
|
||||
TenantId: ctx.tenantId
|
||||
},
|
||||
// TenantId 同时放入 query 兜底(详情端点实测认 query 的 TenantId)
|
||||
query: { ...query, TenantId: ctx.tenantId }
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to fetch case detail'
|
||||
})
|
||||
}
|
||||
|
||||
// C 端不展示草稿(1)/下架(2)/已删除案例:status 非 0(已发布) 或 deleted=1 视为不存在
|
||||
// 上游真实枚举:0=已发布 1=草稿 2=下架(与早期假设相反,已于 2026-07-29 校正)
|
||||
// 上游对不存在/未发布案例常返回 {code:1, message:"...不存在"}(无 data),
|
||||
// 此时 res.code !== 0,必须 404,否则扁平后的对象为 truthy,前端会误判为「存在」渲染空页。
|
||||
const detail = res?.data ?? res
|
||||
if (typeof res?.code === 'number' && res.code !== 0) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Case not found or unpublished' })
|
||||
}
|
||||
if (!detail || detail.status === undefined || detail.status !== 0 || detail.deleted === 1) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Case not found or unpublished' })
|
||||
}
|
||||
|
||||
// 返回扁平数据,去掉上游 {code, message, data} 包装层,
|
||||
// 并归一化为前端模板约定的字段(id / title / clientName …)。
|
||||
return normalizeCaseItem(detail)
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
import { normalizeCaseItem } from '../../utils/case'
|
||||
|
||||
/**
|
||||
* 案例列表
|
||||
* GET /api/case/list?categoryId=1&page=1&limit=10
|
||||
* 代理到 SaaS 后端 CMS 案例列表接口
|
||||
* 注意:上游 cms-website 服务没有 caseList 这类路由(会被 /cms/cms-website/{id} 兜底捕获并因 id 非整数报错),
|
||||
* 正确的案例列表端点属于 cms-case 控制器:/cms/cms-case/page
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const query = getQuery(event)
|
||||
const modulesApiBase = config.public.modulesApiBase as string
|
||||
|
||||
try {
|
||||
// 前端按导航模型约定传 navigationId;上游 cms-case 实际按 categoryId 过滤,
|
||||
// 二者在 CMS 中同值(案例分类 ID == 对应栏目 navigationId),故透传 categoryId。
|
||||
// 同时保留 navigationId 以兼容少数按 navigationId 过滤的租户。
|
||||
const categoryId = query.navigationId ?? query.categoryId
|
||||
// 聚合场景:前端递归收集父栏目下所有子栏目的 navigationId,以逗号串传入。
|
||||
// 有 categoryIds 时优先走 IN 查询,忽略单值 categoryId,避免 AND 冲突。
|
||||
const categoryIds = query.categoryIds as string | undefined
|
||||
|
||||
const res = await $fetch('/cms/cms-case/page', {
|
||||
baseURL: modulesApiBase,
|
||||
headers: {
|
||||
TenantId: ctx.tenantId
|
||||
},
|
||||
// TenantId 同时放入 query 兜底(page 端点实测认 query 的 TenantId)
|
||||
// status 0=已发布 1=草稿 2=下架(与早期假设相反,已于 2026-07-29 校正),C 端只展示已发布,放最后覆盖前端传入
|
||||
query: {
|
||||
...query,
|
||||
navigationId: categoryIds ? undefined : query.navigationId,
|
||||
categoryId: categoryIds ? undefined : categoryId,
|
||||
categoryIds: categoryIds || undefined,
|
||||
TenantId: ctx.tenantId,
|
||||
status: 0,
|
||||
// 同时排除软删除(deleted=1)的案例,兜底防止已删除内容出现在前台
|
||||
deleted: 0
|
||||
}
|
||||
})
|
||||
|
||||
// 上游返回 {code, message, data: {list, count}} 包装层,
|
||||
// 前端 CaseList 直接读 data.value?.list,故在此去掉包装并把每条记录
|
||||
// 归一化为前端模板约定的字段(id / title / clientName …)。
|
||||
const envelopeData = (res && res.data !== undefined ? res.data : res) || {}
|
||||
const rawList = Array.isArray(envelopeData.list) ? envelopeData.list : []
|
||||
const count = envelopeData.count ?? envelopeData.total ?? rawList.length
|
||||
|
||||
return {
|
||||
list: rawList.map(normalizeCaseItem),
|
||||
count
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to fetch case list'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
|
||||
/**
|
||||
* 域名解析
|
||||
* GET /api/domain/resolve?domain=www.example.com
|
||||
* 查询自定义域名绑定的租户信息
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const query = getQuery(event)
|
||||
const modulesApiBase = config.public.modulesApiBase as string
|
||||
|
||||
if (!query.domain) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing domain parameter' })
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await $fetch('/cms/cms-website/resolveDomain', {
|
||||
baseURL: modulesApiBase,
|
||||
headers: {
|
||||
TenantId: ctx.tenantId
|
||||
},
|
||||
query
|
||||
})
|
||||
|
||||
return res
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to resolve domain'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineEventHandler, getHeader, getRequestURL, getRouterParam, proxyRequest } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
|
||||
function joinURL(base: string, path: string) {
|
||||
if (!path) return base
|
||||
return base.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件代理
|
||||
* /api/file/[...path] → SaaS 后端文件服务器
|
||||
* 隐藏真实文件服务器地址
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const fileServerBase = config.public.fileServerBase as string
|
||||
const path = getRouterParam(event, 'path') || ''
|
||||
const search = getRequestURL(event).search
|
||||
const target = joinURL(fileServerBase, path) + search
|
||||
|
||||
const authorization = getHeader(event, 'authorization')
|
||||
|
||||
return proxyRequest(event, target, {
|
||||
headers: {
|
||||
TenantId: ctx.tenantId,
|
||||
...(authorization ? { Authorization: authorization } : {})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, getHeader, readBody } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
import { getRealClientIp } from '../../utils/ip'
|
||||
import { rateLimitPass, dedupPass, clearDedup, checkCaptchaTicket } from '../../utils/guard'
|
||||
import { validatePhone } from '../../utils/validators'
|
||||
import { getContactCaptchaRequired } from '../../utils/site'
|
||||
|
||||
/**
|
||||
* 表单/留言提交
|
||||
* POST /api/form/submit
|
||||
* 代理到 SaaS 后端留言/表单接口
|
||||
*
|
||||
* 防护链路(顺序即短路顺序):
|
||||
* 1) 蜜罐:隐藏字段被填 → 直接拒绝(机器人)
|
||||
* 2) 电话号段校验:支持港澳台/海外 +区号
|
||||
* 3) 滑块验证码票据:仅当后台「网站设置」requireCaptcha=true 时校验(一次性,校验通过才消费)
|
||||
* 4) 频率限制:同 IP+租户 60s 内仅 1 次
|
||||
* 5) 未处理去重:7 天内同 IP+租户 仅 1 条(过期自动释放)
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const body = (await readBody(event)) as Record<string, any>
|
||||
const modulesApiBase = config.public.modulesApiBase as string
|
||||
|
||||
// 1) 蜜罐
|
||||
if (body?.website) {
|
||||
throw createError({ statusCode: 400, statusMessage: '提交失败' })
|
||||
}
|
||||
|
||||
// 2) 电话号段校验(支持港澳台/海外 +区号)
|
||||
if (!validatePhone(body?.phone)) {
|
||||
throw createError({ statusCode: 400, statusMessage: '请输入有效的联系电话(支持 +区号 海外/港澳台)' })
|
||||
}
|
||||
|
||||
// 3) 滑块验证码票据(仅当后台「网站设置」开启 requireCaptcha 时校验)
|
||||
const requireCaptcha = await getContactCaptchaRequired(ctx.tenantId, config)
|
||||
if (requireCaptcha && !(await checkCaptchaTicket(body?.captchaTicket))) {
|
||||
throw createError({ statusCode: 400, statusMessage: '验证码已失效,请重新完成验证' })
|
||||
}
|
||||
|
||||
const ip = getRealClientIp(event)
|
||||
|
||||
// 4) 频率限制:同 IP+租户 60s 内仅 1 次
|
||||
if (!(await rateLimitPass(ctx.tenantId, ip))) {
|
||||
throw createError({ statusCode: 429, statusMessage: '操作过于频繁,请 60 秒后再试' })
|
||||
}
|
||||
|
||||
// 5) 未处理去重:7 天内同 IP+租户 仅 1 条
|
||||
if (!(await dedupPass(ctx.tenantId, ip))) {
|
||||
throw createError({ statusCode: 429, statusMessage: '您已有待处理的留言,请耐心等待我们联系' })
|
||||
}
|
||||
|
||||
try {
|
||||
// 上游正确端点为 cms-contact-lead/submit。
|
||||
// 上游 ContactLeadSubmitForm 期望字段:name, phone, company, need(必填), delivery?, source?
|
||||
// 前端 ContactForm 发 { type, name, phone, content },这里统一做字段映射:
|
||||
// content → need(留言/需求描述),company 未收集时留空,source 取请求 referer。
|
||||
const submitBody = {
|
||||
name: body?.name || '',
|
||||
phone: body?.phone || '',
|
||||
company: body?.company || '',
|
||||
need: body?.need || body?.content || '',
|
||||
source: body?.source || (getHeader(event, 'referer') as string) || '/contact'
|
||||
}
|
||||
|
||||
const res = await $fetch('/cms/cms-contact-lead/submit', {
|
||||
method: 'POST',
|
||||
baseURL: modulesApiBase,
|
||||
headers: {
|
||||
TenantId: ctx.tenantId,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: submitBody
|
||||
})
|
||||
|
||||
return res
|
||||
} catch (error: any) {
|
||||
// 上游失败:释放去重锁,允许用户重试
|
||||
await clearDedup(ctx.tenantId, ip)
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to submit form'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
|
||||
/**
|
||||
* 获取页面列表
|
||||
* GET /api/page/all
|
||||
* 代理到 SaaS 后端 CMS 页面列表接口
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const query = getQuery(event)
|
||||
const modulesApiBase = config.public.modulesApiBase as string
|
||||
|
||||
try {
|
||||
const res = await $fetch('/cms/cms-website/pageAll', {
|
||||
baseURL: modulesApiBase,
|
||||
headers: {
|
||||
TenantId: ctx.tenantId
|
||||
},
|
||||
query
|
||||
})
|
||||
|
||||
return res
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to fetch pages'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
|
||||
/**
|
||||
* 父栏目聚合子栏目单页
|
||||
* GET /api/page/children?navigationIds=1,2,3
|
||||
*
|
||||
* 场景:父级 page 栏目(model=page)自身通常无内容,其下挂多个子 page 栏目。
|
||||
* 访问父栏目时,前端递归收集父栏目自身 + 所有后代 navigationId,以逗号串传入,
|
||||
* 后端按 navigation_id IN (...) 一次性取回该父栏目下所有单页。
|
||||
*
|
||||
* 代理到 SaaS 后端:/cms/cms-page/page?navigationIdsStr=...&status=1
|
||||
* 仅返回已发布(status=1)的单页,避免泄露草稿/已下线内容。
|
||||
*
|
||||
* 返回结构(与 /api/case/list 对齐,前端直接读 data.value?.list):
|
||||
* { list: CmsPage[], count: number }
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const query = getQuery(event)
|
||||
const modulesApiBase = config.public.modulesApiBase as string
|
||||
|
||||
const navigationIds = query.navigationIds as string | undefined
|
||||
if (!navigationIds) {
|
||||
return { list: [], count: 0 }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await $fetch('/cms/cms-page/page', {
|
||||
baseURL: modulesApiBase,
|
||||
headers: {
|
||||
TenantId: ctx.tenantId
|
||||
},
|
||||
query: {
|
||||
navigationIdsStr: navigationIds,
|
||||
// C 端只展示已发布单页:status 0草稿 1已发布 2已下线
|
||||
status: 1,
|
||||
page: 1,
|
||||
limit: 100,
|
||||
TenantId: ctx.tenantId
|
||||
}
|
||||
})
|
||||
|
||||
// 上游返回 {code, message, data: {list, count}} 包装层,
|
||||
// 去掉包装并把 list/count 透传给前端。
|
||||
const envelopeData = (res && res.data !== undefined ? res.data : res) || {}
|
||||
const rawList = Array.isArray(envelopeData.list) ? envelopeData.list : []
|
||||
const count = envelopeData.count ?? envelopeData.total ?? rawList.length
|
||||
|
||||
return { list: rawList, count }
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to fetch page children'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
|
||||
/**
|
||||
* 页面详情
|
||||
*
|
||||
* 支持两种入口:
|
||||
* 1. GET /api/page/detail?navigationId=4296 → CMS 导航节点(model=page)+ cms_design 内容(旧机制)
|
||||
* 2. GET /api/page/detail?path=about → 新版「单页管理」cms_page(按 slug 取已发布单页)
|
||||
*
|
||||
* 与 website-admin 的「单页管理」模块对应:后端 mp-java CmsPageController.getByPath。
|
||||
* cms-api 侧已按 status=1(已发布)过滤,未发布/不存在统一返回 data=null。
|
||||
*
|
||||
* 返回结构(始终为对象,前端用 status 区分):
|
||||
* {
|
||||
* status: 'ok' | 'empty' | 'error',
|
||||
* pageId, title, path, model,
|
||||
* content, layout, photo,
|
||||
* keywords, description,
|
||||
* hasContent, updateTime, message?
|
||||
* }
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const query = getQuery(event)
|
||||
const modulesApiBase = config.public.modulesApiBase as string
|
||||
|
||||
const navigationId = query.navigationId || query.id
|
||||
const path = query.path
|
||||
|
||||
const base = {
|
||||
status: 'empty' as const,
|
||||
pageId: 0,
|
||||
title: '',
|
||||
path: (typeof path === 'string' ? path : '') || '',
|
||||
model: 'page',
|
||||
parentId: 0,
|
||||
content: '',
|
||||
layout: null as any,
|
||||
photo: null as string | null,
|
||||
keywords: '',
|
||||
description: '',
|
||||
hasContent: false,
|
||||
updateTime: null as string | null,
|
||||
message: '该页面不存在或未发布'
|
||||
}
|
||||
|
||||
// ===== 分支2:按 path 取新版单页(cms_page) =====
|
||||
if (typeof path === 'string' && path.trim()) {
|
||||
try {
|
||||
const res = await $fetch<{
|
||||
code?: number
|
||||
message?: string
|
||||
data?: {
|
||||
pageId?: number
|
||||
title?: string
|
||||
path?: string
|
||||
content?: string | null
|
||||
keywords?: string | null
|
||||
description?: string | null
|
||||
image?: string | null
|
||||
status?: number
|
||||
updateTime?: string | null
|
||||
/** 附件列表:后端存 JSON 字符串(cms_page.attachments),也可能是已解析的数组 */
|
||||
attachments?: unknown
|
||||
} | null
|
||||
}>(`/cms/cms-page/getByPath/${encodeURIComponent(path.trim())}`, {
|
||||
baseURL: modulesApiBase,
|
||||
headers: { TenantId: ctx.tenantId },
|
||||
timeout: 10000,
|
||||
retry: 1
|
||||
})
|
||||
|
||||
const node = res?.data
|
||||
if (!node) {
|
||||
const code = res?.code
|
||||
const isBizError = typeof code === 'number' && code !== 0 && code !== 200
|
||||
if (isBizError) {
|
||||
return { ...base, status: 'error' as const, message: res?.message || '上游接口返回异常' }
|
||||
}
|
||||
return { ...base, status: 'empty' as const, message: '该单页不存在或未发布' }
|
||||
}
|
||||
|
||||
const content = typeof node.content === 'string' ? node.content : ''
|
||||
const hasContent = content.trim().length > 0
|
||||
|
||||
// 解析附件列表(cms_page.attachments):后端存 JSON 字符串,容错解析
|
||||
let attachments: Array<{ name: string; url: string; size?: number; ext?: string; sort?: number }> | undefined
|
||||
try {
|
||||
const raw = node.attachments
|
||||
if (raw) {
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
if (Array.isArray(parsed)) {
|
||||
attachments = parsed
|
||||
.filter((it: any) => it && typeof it.url === 'string' && typeof it.name === 'string')
|
||||
.map((it: any) => ({
|
||||
name: String(it.name),
|
||||
url: String(it.url),
|
||||
size: typeof it.size === 'number' ? it.size : undefined,
|
||||
ext: typeof it.ext === 'string' ? it.ext : undefined,
|
||||
sort: typeof it.sort === 'number' ? it.sort : undefined
|
||||
}))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[page/detail] parse attachments failed: path=${path}`, (e as any)?.message || e)
|
||||
}
|
||||
|
||||
return {
|
||||
status: hasContent ? ('ok' as const) : ('empty' as const),
|
||||
pageId: node.pageId ?? 0,
|
||||
title: node.title || '',
|
||||
path: node.path || path,
|
||||
model: 'page',
|
||||
parentId: 0,
|
||||
content,
|
||||
layout: null,
|
||||
photo: node.image ?? null,
|
||||
keywords: node.keywords || '',
|
||||
description: node.description || '',
|
||||
hasContent,
|
||||
updateTime: node.updateTime || null,
|
||||
attachments,
|
||||
message: hasContent ? undefined : '该页面正文尚未在 CMS 管理后台录入'
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[page/detail] upstream(cms-page) failed: path=${path} tenant=${ctx.tenantId}`, error?.message || error)
|
||||
return { ...base, status: 'error' as const, message: '内容接口暂时不可用,请稍后再试' }
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 分支1:按 navigationId 取导航节点(旧机制,保持原逻辑) =====
|
||||
if (!navigationId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing navigationId or path parameter' })
|
||||
}
|
||||
|
||||
const navId = Number(navigationId)
|
||||
try {
|
||||
const res = await $fetch<{
|
||||
code?: number
|
||||
message?: string
|
||||
data?: {
|
||||
navigationId?: number
|
||||
title?: string
|
||||
model?: string
|
||||
parentId?: number
|
||||
updateTime?: string | null
|
||||
design?: {
|
||||
content?: string | null
|
||||
layout?: any
|
||||
photo?: string | null
|
||||
updateTime?: string | null
|
||||
createTime?: string | null
|
||||
} | null
|
||||
} | null
|
||||
}>(`/cms/cms-navigation/${navId}`, {
|
||||
baseURL: modulesApiBase,
|
||||
headers: { TenantId: ctx.tenantId },
|
||||
timeout: 10000,
|
||||
retry: 1
|
||||
})
|
||||
|
||||
const node = res?.data
|
||||
if (!node) {
|
||||
const code = res?.code
|
||||
const isBizError = typeof code === 'number' && code !== 0 && code !== 200
|
||||
if (isBizError) {
|
||||
return { ...base, navigationId: navId, message: res?.message || '上游接口返回异常' }
|
||||
}
|
||||
return { ...base, status: 'empty' as const, navigationId: navId, message: '该导航节点在 CMS 中不存在或未发布' }
|
||||
}
|
||||
|
||||
const design = node.design || {}
|
||||
const content = typeof design.content === 'string' ? design.content : ''
|
||||
const hasContent = content.trim().length > 0
|
||||
const updateTime = design.updateTime || design.createTime || node.updateTime || null
|
||||
|
||||
return {
|
||||
status: hasContent ? ('ok' as const) : ('empty' as const),
|
||||
navigationId: node.navigationId ?? navId,
|
||||
title: node.title || '',
|
||||
model: node.model || 'page',
|
||||
parentId: node.parentId || 0,
|
||||
content,
|
||||
layout: design.layout ?? null,
|
||||
photo: design.photo ?? null,
|
||||
keywords: '',
|
||||
description: '',
|
||||
hasContent,
|
||||
updateTime,
|
||||
message: hasContent ? undefined : '该页面正文尚未在 CMS 管理后台录入'
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[page/detail] upstream(navigation) failed: navigationId=${navigationId} tenant=${ctx.tenantId}`, error?.message || error)
|
||||
return { ...base, status: 'error' as const, navigationId: navId, message: '内容接口暂时不可用,请稍后再试' }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
|
||||
/**
|
||||
* 产品详情
|
||||
* GET /api/product/detail?id=1
|
||||
* 代理到 SaaS 后端 CMS 产品详情接口
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const query = getQuery(event)
|
||||
const modulesApiBase = config.public.modulesApiBase as string
|
||||
|
||||
if (!query.id) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing id parameter' })
|
||||
}
|
||||
|
||||
let res: any
|
||||
try {
|
||||
// 上游 cms-website 服务没有 productDetail 这类路由(会被 /cms/cms-website/{id} 兜底捕获并因 id 非整数报错),
|
||||
// 正确的产品详情端点属于 cms-product 控制器,且 id 走路径参数:/cms/cms-product/{id}
|
||||
res = await $fetch(`/cms/cms-product/${query.id}`, {
|
||||
baseURL: modulesApiBase,
|
||||
headers: {
|
||||
TenantId: ctx.tenantId
|
||||
},
|
||||
// TenantId 同时放入 query 兜底(详情端点实测认 query 的 TenantId)
|
||||
query: { ...query, TenantId: ctx.tenantId }
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to fetch product detail'
|
||||
})
|
||||
}
|
||||
|
||||
// C 端不展示草稿(1)/下架(2)/已删除产品:status 非 0(已发布) 或 deleted=1 视为不存在
|
||||
// 上游真实枚举:0=已发布 1=草稿 2=下架(与早期假设相反,已于 2026-07-29 校正)
|
||||
// 上游对不存在/未发布产品常返回 {code:1, message:"...不存在"}(无 data),
|
||||
// 此时 res.code !== 0,必须 404,否则扁平后的对象为 truthy,前端会误判为「存在」渲染空页。
|
||||
const detail = res?.data ?? res
|
||||
if (typeof res?.code === 'number' && res.code !== 0) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Product not found or unpublished' })
|
||||
}
|
||||
if (!detail || detail.status === undefined || detail.status !== 0 || detail.deleted === 1) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Product not found or unpublished' })
|
||||
}
|
||||
|
||||
// 字段兼容归一化:上游 CMS 不同版本字段名不一致,统一补齐,
|
||||
// 避免前端 10 套模板的 <RichText :content> / subtitle 取不到值而渲染空白。
|
||||
// - content(富文本正文):部分版本放在 description,兜底回退(content 优先)
|
||||
// - subtitle(副标题):上游多数版本返回 summary,兜底回退
|
||||
// - navigationId:上游用 categoryId,组件用它拼「返回列表」链接
|
||||
if (detail && typeof detail === 'object') {
|
||||
if (detail.content == null || detail.content === '') {
|
||||
detail.content = detail.description || ''
|
||||
}
|
||||
if (detail.subtitle == null || detail.subtitle === '') {
|
||||
detail.subtitle = detail.summary || ''
|
||||
}
|
||||
if (detail.navigationId == null && detail.categoryId != null) {
|
||||
detail.navigationId = detail.categoryId
|
||||
}
|
||||
}
|
||||
|
||||
// 返回扁平数据,去掉上游 {code, message, data} 包装层
|
||||
return detail
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
|
||||
/**
|
||||
* 产品列表
|
||||
* GET /api/product/list?categoryId=1&page=1&limit=10
|
||||
* 代理到 SaaS 后端 CMS 产品列表接口
|
||||
* 注意:上游 cms-website 服务没有 productList 这类路由(会被 /cms/cms-website/{id} 兜底捕获并因 id 非整数报错),
|
||||
* 正确的产品列表端点属于 cms-product 控制器:/cms/cms-product/page
|
||||
*
|
||||
* 返回结构:与 case/list 对齐,去掉上游包装层,归一化为 { list, count },
|
||||
* 前端 ProductList 直接读 data.value?.list / data.value?.count,分页稳定可用。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const query = getQuery(event)
|
||||
const modulesApiBase = config.public.modulesApiBase as string
|
||||
|
||||
try {
|
||||
// 前端按导航模型约定传 navigationId;上游 cms-product 实际按 categoryId 过滤,
|
||||
// 二者在 CMS 中同值(产品分类 ID == 对应栏目 navigationId),故透传 categoryId。
|
||||
// 同时保留 navigationId 以兼容少数按 navigationId 过滤的租户。
|
||||
const categoryId = query.navigationId ?? query.categoryId
|
||||
// 聚合场景:前端递归收集父栏目下所有子栏目的 navigationId,以逗号串传入。
|
||||
// 有 categoryIds 时优先走 IN 查询,忽略单值 categoryId,避免 AND 冲突。
|
||||
const categoryIds = query.categoryIds as string | undefined
|
||||
|
||||
const res = await $fetch('/cms/cms-product/page', {
|
||||
baseURL: modulesApiBase,
|
||||
headers: {
|
||||
TenantId: ctx.tenantId
|
||||
},
|
||||
// TenantId 同时放入 query 兜底(page 端点实测认 query 的 TenantId)
|
||||
// status 0=已发布 1=草稿 2=下架(与早期假设相反,已于 2026-07-29 校正),C 端只展示已发布,放最后覆盖前端传入
|
||||
query: {
|
||||
...query,
|
||||
navigationId: categoryIds ? undefined : query.navigationId,
|
||||
categoryId: categoryIds ? undefined : categoryId,
|
||||
categoryIds: categoryIds || undefined,
|
||||
TenantId: ctx.tenantId,
|
||||
status: 0,
|
||||
// 同时排除软删除(deleted=1)的产品,兜底防止已删除内容出现在前台
|
||||
deleted: 0
|
||||
}
|
||||
})
|
||||
|
||||
// 与 case/list 对齐:去掉上游包装层,归一化为 { list, count }
|
||||
// 上游 /cms/cms-product/page 返回 { code, message, data: { list, count } } 或 { list, count },
|
||||
// 字段名可能为 count 或 total,统一兜底。
|
||||
const envelopeData = (res && res.data !== undefined ? res.data : res) || {}
|
||||
const rawList = Array.isArray(envelopeData.list) ? envelopeData.list : []
|
||||
const count = envelopeData.count ?? envelopeData.total ?? rawList.length
|
||||
|
||||
return { list: rawList, count }
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to fetch product list'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineEventHandler, setHeader } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
|
||||
/**
|
||||
* robots.txt
|
||||
* GET /api/robots.txt
|
||||
* 根据环境动态生成 robots.txt
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// 开发环境禁止抓取
|
||||
const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV
|
||||
|
||||
// canonical host 优先用真实访问域名;缺失时回退到「主域清单首个 + 默认租户号」,
|
||||
// 不再硬编码 site- 前缀(支持多前缀 + 多主域)
|
||||
const baseDomains = (config.public.baseDomains as string[]) || []
|
||||
const fallbackDomain = baseDomains[0] || (config.public.baseDomain as string) || 'shoplnk.cn'
|
||||
const host = event.node?.req?.headers?.host ||
|
||||
`${config.public.tenantId ? `site-${config.public.tenantId}.` : ''}${fallbackDomain}`
|
||||
const origin = `https://${host}`
|
||||
|
||||
let content: string
|
||||
|
||||
if (isDev) {
|
||||
content = `User-agent: *
|
||||
Disallow: /`
|
||||
} else {
|
||||
content = `User-agent: *
|
||||
Allow: /
|
||||
Disallow: /api/
|
||||
|
||||
Sitemap: ${origin}/api/sitemap.xml`
|
||||
}
|
||||
|
||||
setHeader(event, 'Content-Type', 'text/plain')
|
||||
return content
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
import { getTemplateCodeById } from '../../utils/template-map'
|
||||
|
||||
/**
|
||||
* 获取站点信息
|
||||
* GET /api/site/info
|
||||
* 代理到 CMS: /cms/cms-website/getSiteInfo
|
||||
* 返回站点基础信息 + 导航(topNavs/bottomNavs) + config + setting
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const query = getQuery(event)
|
||||
const modulesApiBase = config.public.modulesApiBase as string
|
||||
|
||||
try {
|
||||
const res = await $fetch('/cms/cms-website/getSiteInfo', {
|
||||
baseURL: modulesApiBase,
|
||||
headers: {
|
||||
TenantId: ctx.tenantId
|
||||
},
|
||||
query
|
||||
})
|
||||
|
||||
// 回填模板标识 templateCode(与 SSR 中间件 tenant.ts 保持一致的口径):
|
||||
// 上游只返回 templateId 主键,而主键存在跳号错位风险,前端需按 code 解析模板目录。
|
||||
const payload = (res as { data?: Record<string, unknown> })?.data ?? (res as Record<string, unknown>)
|
||||
if (payload && typeof payload === 'object' && !payload.templateCode && payload.templateId) {
|
||||
const code = await getTemplateCodeById(payload.templateId as number, config)
|
||||
if (code) payload.templateCode = code
|
||||
}
|
||||
|
||||
return res
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || error?.response?.status || 502,
|
||||
statusMessage: error?.statusMessage || 'Failed to fetch site info'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { defineEventHandler, getQuery, setHeader } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../utils/tenant'
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
/**
|
||||
* 动态站点地图
|
||||
* GET /api/sitemap.xml
|
||||
* 根据当前租户的页面列表动态生成 sitemap.xml
|
||||
*
|
||||
* 覆盖范围:
|
||||
* - 静态页:首页 + 三大模块列表根页(/article /product /case)
|
||||
* - CMS 单页(/page slug 对应的顶层路由,如 /about /contact)
|
||||
* - 文章 / 产品 / 案例 详情页(仅已发布 status=0、未删除 deleted=0)
|
||||
*/
|
||||
interface SitemapUrl {
|
||||
loc: string
|
||||
priority: string
|
||||
changefreq: string
|
||||
lastmod: string
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/** 从列表返回中提取 list 数组(兼容 article 原始包装与 product/case 归一化结构) */
|
||||
function extractList(res: any): any[] {
|
||||
if (!res) return []
|
||||
const data = res.data !== undefined ? res.data : res
|
||||
if (data && Array.isArray(data.list)) return data.list
|
||||
if (Array.isArray(res?.data?.list)) return res.data.list
|
||||
if (Array.isArray(data)) return data
|
||||
return []
|
||||
}
|
||||
|
||||
/** 从列表项中提取唯一 id(各模块主键字段名不同) */
|
||||
function pickId(item: any, keys: string[]): string | number | null {
|
||||
for (const k of keys) {
|
||||
if (item?.[k] != null) return item[k]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const query = getQuery(event)
|
||||
const modulesApiBase = config.public.modulesApiBase as string
|
||||
|
||||
const origin = `https://${ctx.host || ''}`
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const urls: SitemapUrl[] = [
|
||||
{ loc: '/', priority: '1.0', changefreq: 'daily', lastmod: now },
|
||||
{ loc: '/article', priority: '0.9', changefreq: 'daily', lastmod: now },
|
||||
{ loc: '/product', priority: '0.9', changefreq: 'daily', lastmod: now },
|
||||
{ loc: '/case', priority: '0.9', changefreq: 'daily', lastmod: now }
|
||||
]
|
||||
|
||||
// ===== 单页 slug(/about /contact 等顶层路由) =====
|
||||
try {
|
||||
const res = await $fetch<{ data?: { list?: any[] } } | any[]>('/cms/cms-website/pageAll', {
|
||||
baseURL: modulesApiBase,
|
||||
headers: { TenantId: ctx.tenantId }
|
||||
})
|
||||
const list = Array.isArray(res) ? res : res?.data?.list || res?.data || []
|
||||
for (const p of list) {
|
||||
if (p?.slug && p.slug !== 'index') {
|
||||
urls.push({
|
||||
loc: `/${p.slug}`,
|
||||
priority: '0.8',
|
||||
changefreq: 'weekly',
|
||||
lastmod: p.updateTime || now
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 单页接口异常不影响详情页收录
|
||||
}
|
||||
|
||||
// ===== 文章 / 产品 / 案例详情(分页拉取,仅已发布) =====
|
||||
const fetchDetailUrls = async (
|
||||
apiPath: string,
|
||||
urlPrefix: string,
|
||||
idKeys: string[]
|
||||
) => {
|
||||
const limit = 500
|
||||
let page = 1
|
||||
// 安全上限:单 sitemap 文件 URL 上限 50,000
|
||||
while (urls.length < 50000) {
|
||||
let list: any[] = []
|
||||
try {
|
||||
const res = await $fetch(apiPath, {
|
||||
baseURL: modulesApiBase,
|
||||
headers: { TenantId: ctx.tenantId },
|
||||
query: { page, limit, status: 0, deleted: 0 }
|
||||
})
|
||||
list = extractList(res)
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
|
||||
for (const item of list) {
|
||||
const idVal = pickId(item, idKeys)
|
||||
if (idVal == null) continue
|
||||
urls.push({
|
||||
loc: `/${urlPrefix}/${idVal}`,
|
||||
priority: '0.7',
|
||||
changefreq: 'weekly',
|
||||
lastmod: item.updateTime || item.publishTime || item.createTime || now
|
||||
})
|
||||
}
|
||||
|
||||
if (list.length < limit) break
|
||||
page++
|
||||
}
|
||||
}
|
||||
|
||||
await fetchDetailUrls('/cms/cms-article/page', 'article', ['id', 'articleId'])
|
||||
await fetchDetailUrls('/cms/cms-product/page', 'product', ['id', 'productId'])
|
||||
await fetchDetailUrls('/cms/cms-case/page', 'case', ['id', 'caseId', 'caseItemId'])
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls
|
||||
.map(
|
||||
(u) => ` <url>
|
||||
<loc>${escapeXml(origin + u.loc)}</loc>
|
||||
<lastmod>${u.lastmod}</lastmod>
|
||||
<changefreq>${u.changefreq}</changefreq>
|
||||
<priority>${u.priority}</priority>
|
||||
</url>`
|
||||
)
|
||||
.join('\n')}
|
||||
</urlset>`
|
||||
|
||||
setHeader(event, 'Content-Type', 'application/xml')
|
||||
return xml
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineEventHandler, getQuery } from 'h3'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { getTenantFromContext } from '../../utils/tenant'
|
||||
import { getSubscriptionStatus } from '../../utils/subscription'
|
||||
|
||||
/**
|
||||
* 订阅状态查询
|
||||
* GET /api/subscription/status
|
||||
* 返回当前租户/应用的订阅状态(带缓存)
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const ctx = getTenantFromContext(event, config)
|
||||
const query = getQuery(event)
|
||||
|
||||
const appId = (query.appId as string) || ctx.appId
|
||||
|
||||
const status = await getSubscriptionStatus(ctx.tenantId, appId, config)
|
||||
|
||||
return status
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { AppProduct, AppDomain, ApiEnvelope } from '~/app/types'
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
type RuntimeConfig = ReturnType<typeof useRuntimeConfig>
|
||||
|
||||
/**
|
||||
* 从统一响应封装中取出 data
|
||||
* 后端可能返回 { code, data } 也可能直接返回对象
|
||||
*
|
||||
* 关键:未找到时上游返回的是「无 data 字段的信封」,
|
||||
* 例如 { "code": 0, "message": "操作成功" }。
|
||||
* 旧实现在无 data 字段时会把整个信封当结果返回(truthy),
|
||||
* 导致调用方误判为「命中」,进而跳过了未授权 403 拦截。
|
||||
* 因此:无 data 字段的信封一律视为「未找到」返回 null。
|
||||
*/
|
||||
function unwrap<T>(res: ApiEnvelope<T> | T): T | null {
|
||||
if (!res || typeof res !== 'object') return null
|
||||
const obj = res as Record<string, unknown>
|
||||
// 标准信封:存在 data 字段则取 data(未找到时 data 为 null)
|
||||
if ('data' in obj) {
|
||||
return (obj.data as T) ?? null
|
||||
}
|
||||
// 信封但无 data(如 { code, message })→ 视为无结果
|
||||
if ('code' in obj || 'message' in obj) {
|
||||
return null
|
||||
}
|
||||
// 直接返回对象(无信封包裹)的情况
|
||||
return (res as T) ?? null
|
||||
}
|
||||
|
||||
/** 不应暴露到前端 / SSR payload 的敏感字段 */
|
||||
const SENSITIVE_KEYS = ['productSecret', 'apiUrl', 'reviewerId', 'rejectReason']
|
||||
|
||||
/**
|
||||
* 脱敏:移除应用密钥等敏感字段
|
||||
*/
|
||||
function sanitize(app: AppProduct | null): AppProduct | null {
|
||||
if (!app) return null
|
||||
const clean = { ...app }
|
||||
for (const key of SENSITIVE_KEYS) {
|
||||
delete clean[key]
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 productId 查询应用产品信息
|
||||
* 后端: GET {appApiBase}/api/app/product/detail/{productId}
|
||||
* 用于本地开发环境(.env 指定 NUXT_PUBLIC_APP_ID)
|
||||
*/
|
||||
export async function getAppProductById(
|
||||
productId: string | number,
|
||||
config: RuntimeConfig
|
||||
): Promise<AppProduct | null> {
|
||||
const appApiBase = config.public.appApiBase as string
|
||||
if (!productId) return null
|
||||
|
||||
try {
|
||||
const res = await $fetch<ApiEnvelope<AppProduct> | AppProduct>(
|
||||
`/api/app/product/detail/${productId}`,
|
||||
{
|
||||
baseURL: appApiBase,
|
||||
timeout: 5000,
|
||||
retry: 1
|
||||
}
|
||||
)
|
||||
return sanitize(unwrap<AppProduct>(res))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按域名查询应用产品信息
|
||||
* 后端: GET {appApiBase}/api/app/product/getByDomain?domain={domain}
|
||||
* 用于生产环境(根据当前访问域名解析应用)
|
||||
* 后端已改造为直接返回完整 AppProduct(含 tenantId / templateId)
|
||||
*/
|
||||
export async function getAppProductByDomain(
|
||||
domain: string,
|
||||
config: RuntimeConfig
|
||||
): Promise<AppProduct | null> {
|
||||
const appApiBase = config.public.appApiBase as string
|
||||
if (!domain) return null
|
||||
|
||||
try {
|
||||
const res = await $fetch<ApiEnvelope<AppProduct> | AppProduct>(
|
||||
'/api/app/product/getByDomain',
|
||||
{
|
||||
baseURL: appApiBase,
|
||||
query: { domain },
|
||||
timeout: 5000,
|
||||
retry: 1
|
||||
}
|
||||
)
|
||||
return sanitize(unwrap<AppProduct>(res))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按域名查询应用域名绑定(appDomain 表)
|
||||
* 后端: GET {appApiBase}/api/app/domain/getByDomain?domain={domain}
|
||||
* 用于生产环境兜底解析(主解析 app_product.domain 未命中时回退到此表)
|
||||
* 返回绑定关系(含 tenantId / productId),用于取出租户与回查完整应用信息
|
||||
*/
|
||||
export async function getAppDomainByDomain(
|
||||
domain: string,
|
||||
config: RuntimeConfig
|
||||
): Promise<AppDomain | null> {
|
||||
const appApiBase = config.public.appApiBase as string
|
||||
if (!domain) return null
|
||||
|
||||
try {
|
||||
const res = await $fetch<ApiEnvelope<AppDomain> | AppDomain>(
|
||||
'/api/app/domain/getByDomain',
|
||||
{
|
||||
baseURL: appApiBase,
|
||||
query: { domain },
|
||||
timeout: 5000,
|
||||
retry: 1
|
||||
}
|
||||
)
|
||||
return unwrap<AppDomain>(res)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { CaseItem } from '~/app/types'
|
||||
|
||||
/**
|
||||
* 将 CMS 后端案例对象(字段:caseId / caseName / customer …)
|
||||
* 映射为前端模板约定的 CaseItem(字段:id / title / clientName …)。
|
||||
*
|
||||
* 后端 cms-case 控制器返回的是 caseId / caseName / customer,
|
||||
* 而 website-template 的全部模板组件(template-01 ~ template-07)均按
|
||||
* id / title / clientName 渲染,故在此统一做一次字段归一化,
|
||||
* 避免 8 套模板各自散落做字段转换。
|
||||
*/
|
||||
export function normalizeCaseItem(raw: any): CaseItem {
|
||||
if (!raw) return raw
|
||||
|
||||
return {
|
||||
// 主键与标题
|
||||
id: raw.caseId ?? raw.id,
|
||||
title: raw.caseName ?? raw.title,
|
||||
// 封面 / 摘要 / 正文
|
||||
cover: raw.cover ?? '',
|
||||
summary: raw.summary ?? raw.subtitle ?? '',
|
||||
// 正文:部分 CMS 版本正文在 description,兜底回退(content 优先)
|
||||
content: raw.content ?? raw.description ?? '',
|
||||
// 分类
|
||||
categoryId: raw.categoryId ?? null,
|
||||
categoryName: raw.categoryName ?? '',
|
||||
// 客户(后端字段名 customer)→ 前端 clientName
|
||||
clientName: raw.customer ?? raw.clientName ?? '',
|
||||
// 行业 / 项目时间(后端无 projectTime 字段,回退到创建时间)
|
||||
industry: raw.industry ?? '',
|
||||
projectTime: raw.projectTime ?? raw.createTime ?? '',
|
||||
// 关联产品(后端 productUsed)作为标签兜底
|
||||
tags: raw.productUsed ? [raw.productUsed] : (raw.tags ?? []),
|
||||
// 排序 / 状态 / 浏览量
|
||||
sortNumber: raw.sortNum ?? raw.sortNumber ?? 0,
|
||||
status: raw.status,
|
||||
views: raw.views ?? 0,
|
||||
// 置顶 / 推荐(当前上游 cms-case 暂无此字段,透传以便后续版本自动生效;
|
||||
// 前端 useRecommend 检测到该字段后会将案例区块自动切换为「推荐」过滤)
|
||||
top: raw.top ?? 0,
|
||||
recommend: raw.recommend ?? 0,
|
||||
createTime: raw.createTime ?? '',
|
||||
updateTime: raw.updateTime ?? ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useStorage } from 'nitropack/runtime'
|
||||
|
||||
/**
|
||||
* 留言提交防护:频率限制 + 未处理去重 + 滑块验证码票据。
|
||||
* 基于 Nitro 内置 storage(默认内存驱动;生产多实例请换成 Redis 等共享驱动)。
|
||||
* 所有 key 均带 tenantId,确保多租户互不影响。
|
||||
*/
|
||||
const kv = useStorage('form-guard')
|
||||
|
||||
const RATE_WINDOW = 60 * 1000 // 60s 内同一 IP+租户 仅可提交 1 次
|
||||
const DEDUP_TTL = 7 * 24 * 60 * 60 * 1000 // 7 天内同一 IP+租户 仅允许 1 条未处理
|
||||
const CAPTCHA_TTL = 5 * 60 * 1000 // 滑块验证码 5 分钟内有效
|
||||
|
||||
/** 频率限制:放行返回 true */
|
||||
export async function rateLimitPass(tenantId: string, ip: string): Promise<boolean> {
|
||||
const key = `rate:${tenantId}:${ip}`
|
||||
const last = await kv.getItem<number>(key)
|
||||
if (last && Date.now() - last < RATE_WINDOW) return false
|
||||
await kv.setItem(key, Date.now())
|
||||
return true
|
||||
}
|
||||
|
||||
/** 未处理去重:允许提交返回 true */
|
||||
export async function dedupPass(tenantId: string, ip: string): Promise<boolean> {
|
||||
const key = `dedup:${tenantId}:${ip}`
|
||||
const raw = await kv.getItem<{ ts: number }>(key)
|
||||
if (raw && Date.now() - raw.ts < DEDUP_TTL) return false
|
||||
await kv.setItem(key, { ts: Date.now() })
|
||||
return true
|
||||
}
|
||||
|
||||
/** 上游受理成功后释放去重锁(或后台处理完成后调用),让该 IP 可再次提交 */
|
||||
export async function clearDedup(tenantId: string, ip: string): Promise<void> {
|
||||
await kv.removeItem(`dedup:${tenantId}:${ip}`)
|
||||
}
|
||||
|
||||
/** 保存滑块验证码答案(一次性) */
|
||||
export async function saveCaptcha(token: string, answer: number): Promise<void> {
|
||||
await kv.setItem(`cap:${token}`, { answer, ts: Date.now() })
|
||||
}
|
||||
|
||||
/** 取出并消费滑块验证码票据;不存在/已用则返回 null */
|
||||
export async function takeCaptcha(token: string): Promise<{ answer: number; ts: number } | null> {
|
||||
const v = await kv.getItem<{ answer: number; ts: number }>(`cap:${token}`)
|
||||
if (!v) return null
|
||||
await kv.removeItem(`cap:${token}`)
|
||||
return v
|
||||
}
|
||||
|
||||
/** 仅查看滑块答案(不消费),用于校验 */
|
||||
export async function peekCaptcha(token: string): Promise<{ answer: number; ts: number } | null> {
|
||||
return kv.getItem<{ answer: number; ts: number }>(`cap:${token}`)
|
||||
}
|
||||
|
||||
const CAPTCHA_TOLERANCE = 8 // 拼图对齐容差(px),兼顾手指触控精度
|
||||
|
||||
/**
|
||||
* 校验滑块位置并消费 challenge;成功返回一次性提交票据,失败返回 null。
|
||||
* @param token challenge token
|
||||
* @param x 用户拖动后拼图块中心 X 坐标
|
||||
*/
|
||||
export async function verifyCaptcha(token: string, x: number): Promise<string | null> {
|
||||
const v = await peekCaptcha(token)
|
||||
if (!v) return null
|
||||
if (Math.abs(x - v.answer) > CAPTCHA_TOLERANCE) return null
|
||||
await kv.removeItem(`cap:${token}`)
|
||||
const ticket = globalThis.crypto.randomUUID()
|
||||
await kv.setItem(`captcha-ticket:${ticket}`, { ts: Date.now() })
|
||||
return ticket
|
||||
}
|
||||
|
||||
/** 消费提交票据,成功返回 true(一次性) */
|
||||
export async function checkCaptchaTicket(ticket: string): Promise<boolean> {
|
||||
if (!ticket) return false
|
||||
const v = await kv.getItem(`captcha-ticket:${ticket}`)
|
||||
if (!v) return false
|
||||
await kv.removeItem(`captcha-ticket:${ticket}`)
|
||||
return true
|
||||
}
|
||||
|
||||
export const CAPTCHA_MAX_AGE = CAPTCHA_TTL
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getHeader, getRequestIP } from 'h3'
|
||||
|
||||
/**
|
||||
* 还原真实客户端 IP。
|
||||
* 优先级:CF-Connecting-IP > X-Real-IP > X-Forwarded-For(首段) > 连接地址
|
||||
* 适用于多层反代(Cloudflare / Nginx / 负载均衡)场景。
|
||||
*/
|
||||
export function getRealClientIp(event: any): string {
|
||||
const cf = getHeader(event, 'cf-connecting-ip')
|
||||
if (cf) return String(cf).split(',')[0].trim()
|
||||
|
||||
const real = getHeader(event, 'x-real-ip')
|
||||
if (real) return String(real).split(',')[0].trim()
|
||||
|
||||
const xff = getHeader(event, 'x-forwarded-for')
|
||||
if (xff) return String(xff).split(',')[0].trim()
|
||||
|
||||
return getRequestIP(event, { xForwardedFor: false }) || '0.0.0.0'
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import { useStorage } from 'nitropack/runtime'
|
||||
import type { ApiEnvelope, CmsSiteInfo } from '~/app/types'
|
||||
|
||||
type RuntimeConfig = ReturnType<typeof useRuntimeConfig>
|
||||
|
||||
/**
|
||||
* 直接调用 CMS 获取站点信息
|
||||
* CMS 接口:/cms/cms-website/getSiteInfo
|
||||
*
|
||||
* 供 SSR 中间件(tenant.ts)预取使用:结果写入 event.context.siteInfo,
|
||||
* 再由 site-init.server.ts 注入 useState('site-info'),保证 SSR 首屏
|
||||
* 渲染模板时 siteTemplateId 已可用(网站数据库优先于应用绑定/环境默认)。
|
||||
*
|
||||
* 与 /api/site/info handler 各自独立调用 CMS,互不依赖:
|
||||
* handler 面向直接 API 调用方(返回原始响应),本函数面向 SSR 预取(返回解包数据)。
|
||||
*
|
||||
* @returns 解包后的 CmsSiteInfo;失败或 tenantId 为空返回 null(调用方回退默认模板)
|
||||
*/
|
||||
export async function getCmsSiteInfo(
|
||||
tenantId: string,
|
||||
config: RuntimeConfig,
|
||||
query: Record<string, unknown> = {}
|
||||
): Promise<CmsSiteInfo | null> {
|
||||
if (!tenantId) return null
|
||||
const modulesApiBase = config.public.modulesApiBase as string
|
||||
try {
|
||||
const res = await $fetch<ApiEnvelope<CmsSiteInfo> | CmsSiteInfo>(
|
||||
'/cms/cms-website/getSiteInfo',
|
||||
{
|
||||
baseURL: modulesApiBase,
|
||||
headers: { TenantId: tenantId },
|
||||
query,
|
||||
timeout: 6000,
|
||||
retry: 1
|
||||
}
|
||||
)
|
||||
const envelope = res as ApiEnvelope<CmsSiteInfo>
|
||||
return envelope?.data ?? (res as CmsSiteInfo)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在线留言是否要求滑块验证码。
|
||||
* 来源优先级(从高到低):
|
||||
* 1) CMS 后台「网站设置」setting.requireCaptcha(权威,后台配置即生效)
|
||||
* 2) 运行时配置 public.contactCaptcha(本地调试/部署级覆盖,NUXT_PUBLIC_CONTACT_CAPTCHA=true|false)
|
||||
* 3) 默认 false(未配置则不显示滑块、不强制验证)
|
||||
*
|
||||
* 结果按租户缓存 5 分钟(仅缓存 CMS 解析出的 raw 值),避免每次提交都回源 CMS。
|
||||
*/
|
||||
const siteKv = useStorage('site-cache')
|
||||
const CAPTCHA_CONFIG_TTL = 5 * 60 * 1000
|
||||
|
||||
type CaptchaReqCache = { raw: boolean | null; ts: number }
|
||||
|
||||
function resolveCaptchaRequired(raw: boolean | null, envOverride: boolean | null): boolean {
|
||||
if (raw !== null) return raw
|
||||
if (envOverride !== null) return envOverride
|
||||
return false
|
||||
}
|
||||
|
||||
export async function getContactCaptchaRequired(
|
||||
tenantId: string,
|
||||
config: RuntimeConfig
|
||||
): Promise<boolean> {
|
||||
const envOverride =
|
||||
typeof config.public.contactCaptcha === 'boolean' ? config.public.contactCaptcha : null
|
||||
|
||||
const cacheKey = `captcha-req:${tenantId}`
|
||||
if (tenantId) {
|
||||
try {
|
||||
const cached = await siteKv.getItem<CaptchaReqCache>(cacheKey)
|
||||
if (cached && Date.now() - cached.ts < CAPTCHA_CONFIG_TTL) {
|
||||
return resolveCaptchaRequired(cached.raw, envOverride)
|
||||
}
|
||||
} catch {
|
||||
// 缓存读取失败则回源
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await getCmsSiteInfo(tenantId, config)
|
||||
const raw = (info?.setting?.requireCaptcha as boolean | undefined) ?? null
|
||||
if (tenantId) {
|
||||
await siteKv.setItem(cacheKey, { raw, ts: Date.now() })
|
||||
}
|
||||
return resolveCaptchaRequired(raw, envOverride)
|
||||
} catch {
|
||||
// CMS 不可达时,回退到环境变量覆盖(若也未设置则默认 false)
|
||||
return envOverride ?? false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import type { SubscriptionStatus } from '~/app/types'
|
||||
|
||||
type RuntimeConfig = ReturnType<typeof useRuntimeConfig>
|
||||
|
||||
// 服务端缓存:租户 → 订阅状态
|
||||
const subscriptionCache = new Map<string, { data: SubscriptionStatus; expireAt: number }>()
|
||||
|
||||
/**
|
||||
* 查询应用订阅状态
|
||||
* 带缓存,避免每次请求都调用后端
|
||||
*/
|
||||
export async function getSubscriptionStatus(
|
||||
tenantId: string,
|
||||
appId: string,
|
||||
config: RuntimeConfig
|
||||
): Promise<SubscriptionStatus> {
|
||||
const cacheKey = `${tenantId}:${appId}`
|
||||
const now = Date.now()
|
||||
const cacheTtl = (config.subscriptionCacheTtl || 60) * 1000
|
||||
|
||||
// 检查缓存
|
||||
const cached = subscriptionCache.get(cacheKey)
|
||||
if (cached && cached.expireAt > now) {
|
||||
return cached.data
|
||||
}
|
||||
|
||||
// 查询后端
|
||||
const appApiBase = config.public.appApiBase as string
|
||||
const defaultResult: SubscriptionStatus = {
|
||||
subscribed: true,
|
||||
status: 'active',
|
||||
expired: false
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await $fetch<{ code?: number; data?: SubscriptionStatus }>('/subscription/check', {
|
||||
baseURL: `${appApiBase}/api/app`,
|
||||
headers: { TenantId: tenantId },
|
||||
query: { appId },
|
||||
timeout: 5000,
|
||||
retry: 1
|
||||
})
|
||||
|
||||
const data = res.data || res
|
||||
|
||||
const result: SubscriptionStatus = {
|
||||
subscribed: data.subscribed ?? true,
|
||||
status: data.status || 'active',
|
||||
expireTime: data.expireTime,
|
||||
startTime: data.startTime,
|
||||
expired: data.expired ?? false,
|
||||
subscriptionId: data.subscriptionId,
|
||||
productId: data.productId,
|
||||
productName: data.productName
|
||||
}
|
||||
|
||||
// 写入缓存
|
||||
subscriptionCache.set(cacheKey, { data: result, expireAt: now + cacheTtl })
|
||||
|
||||
return result
|
||||
} catch {
|
||||
// 接口异常时降级:允许访问(避免后端故障导致所有站点不可用)
|
||||
return defaultResult
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除订阅缓存
|
||||
*/
|
||||
export function clearSubscriptionCache(tenantId?: string, appId?: string) {
|
||||
if (tenantId && appId) {
|
||||
subscriptionCache.delete(`${tenantId}:${appId}`)
|
||||
} else {
|
||||
subscriptionCache.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
import type { ApiEnvelope } from '~/app/types'
|
||||
|
||||
type RuntimeConfig = ReturnType<typeof useRuntimeConfig>
|
||||
|
||||
/** app_template 表行(仅取解析目录名所需字段) */
|
||||
interface AppTemplateRow {
|
||||
id?: number
|
||||
/** 模板标识,形如 'template-08',与前端 app/templates/<code>/ 目录一一对应 */
|
||||
code?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface AppTemplatePage {
|
||||
list?: AppTemplateRow[]
|
||||
count?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板主键 → 模板标识(code)映射
|
||||
*
|
||||
* 【为什么需要这层映射】
|
||||
* 上游 getSiteInfo 只返回 cms_website.template_id(app_template 自增主键),
|
||||
* 而前端模板目录名与 app_template.code 对应。历史上主键出现过跳号(id=8 缺失),
|
||||
* 导致「按主键补零推导目录名」整体错位一位(gxhrtc.shoplnk.cn 事故)。
|
||||
*
|
||||
* 因此 SSR 侧统一在此反查模板表,把权威的 code 回填进 siteInfo.templateCode,
|
||||
* 前端 resolveTemplateKey() 优先按 code 解析,主键仅兜底。
|
||||
*
|
||||
* 待上游 getSiteInfo 直接返回 templateCode 后,本模块会自动降级为「上游未返回时的兜底」,
|
||||
* 无需改动前端解析逻辑(tenant.ts 中已判断 templateCode 为空才查表)。
|
||||
*/
|
||||
|
||||
/** 缓存有效期:模板表极少变动,10 分钟足够,且避免每次 SSR 都打上游 */
|
||||
const CACHE_TTL = 10 * 60 * 1000
|
||||
|
||||
let cache: { map: Map<number, string>; expireAt: number } | null = null
|
||||
/** 并发去重:同一时刻多个 SSR 请求只发起一次上游查询 */
|
||||
let inflight: Promise<Map<number, string>> | null = null
|
||||
|
||||
/**
|
||||
* 拉取全量模板表并构建 id → code 映射
|
||||
* 后端: GET {appApiBase}/api/app/template/page(无需鉴权)
|
||||
*/
|
||||
async function fetchTemplateMap(config: RuntimeConfig): Promise<Map<number, string>> {
|
||||
const appApiBase = config.public.appApiBase as string
|
||||
const map = new Map<number, string>()
|
||||
|
||||
const res = await $fetch<ApiEnvelope<AppTemplatePage> | AppTemplatePage>(
|
||||
'/api/app/template/page',
|
||||
{
|
||||
baseURL: appApiBase,
|
||||
query: { page: 1, limit: 200 },
|
||||
timeout: 5000,
|
||||
retry: 1
|
||||
}
|
||||
)
|
||||
|
||||
const data = (res as ApiEnvelope<AppTemplatePage>)?.data ?? (res as AppTemplatePage)
|
||||
for (const row of data?.list || []) {
|
||||
if (row?.id && typeof row.code === 'string' && row.code.trim()) {
|
||||
map.set(Number(row.id), row.code.trim().toLowerCase())
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 id → code 映射(带缓存 + 并发去重 + 失败降级)
|
||||
* 上游异常时返回上一次的有效缓存(若有),否则返回空映射,由调用方回退主键推导。
|
||||
*/
|
||||
async function getTemplateMap(config: RuntimeConfig): Promise<Map<number, string>> {
|
||||
const now = Date.now()
|
||||
if (cache && cache.expireAt > now) return cache.map
|
||||
if (inflight) return inflight
|
||||
|
||||
inflight = fetchTemplateMap(config)
|
||||
.then((map) => {
|
||||
// 空结果不覆盖有效缓存,避免上游偶发返回空表导致全站回退主键推导
|
||||
if (map.size > 0) {
|
||||
cache = { map, expireAt: Date.now() + CACHE_TTL }
|
||||
}
|
||||
return cache?.map ?? map
|
||||
})
|
||||
.catch(() => cache?.map ?? new Map<number, string>())
|
||||
.finally(() => {
|
||||
inflight = null
|
||||
})
|
||||
|
||||
return inflight
|
||||
}
|
||||
|
||||
/**
|
||||
* 按模板主键反查模板标识(code)
|
||||
*
|
||||
* @returns 形如 'template-08';查不到返回 ''(调用方回退主键推导)
|
||||
*/
|
||||
export async function getTemplateCodeById(
|
||||
templateId: number | string | null | undefined,
|
||||
config: RuntimeConfig
|
||||
): Promise<string> {
|
||||
if (templateId === null || templateId === undefined || templateId === '') return ''
|
||||
|
||||
// 已经是目录名形式(上游直接返回 code),无需查表
|
||||
if (typeof templateId === 'string' && templateId.startsWith('template-')) {
|
||||
return templateId.trim().toLowerCase()
|
||||
}
|
||||
|
||||
const id = Number(templateId)
|
||||
if (!id || Number.isNaN(id) || id <= 0) return ''
|
||||
|
||||
try {
|
||||
const map = await getTemplateMap(config)
|
||||
return map.get(id) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空缓存(后台改动模板表后可通过重启或调用此方法立即生效,主要供测试使用) */
|
||||
export function clearTemplateMapCache(): void {
|
||||
cache = null
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { TenantContext } from '~/app/types'
|
||||
|
||||
type RuntimeConfig = ReturnType<typeof useRuntimeConfig>
|
||||
|
||||
/**
|
||||
* 从 Host 解析租户上下文
|
||||
*
|
||||
* 策略:
|
||||
* 1. 如果 host 匹配 `{prefix}-{tenantId}.{baseDomain}` 模式(前缀走白名单、
|
||||
* 主域走配置清单),从子域名提取 tenantId
|
||||
* - 白名单前缀:site / shop / store / mp / app / oa(由 subdomainPrefixes 配置)
|
||||
* - 主域清单:shoplnk.cn / sitelink.cn / wsdns.cn(由 baseDomains 配置)
|
||||
* 2. 否则返回环境变量默认值(后续可由域名绑定表查询覆盖)
|
||||
*
|
||||
* 注意:子域名解析仅为「兜底」来源(优先级低于 appDomain 绑定表与
|
||||
* app_product.domain),因此后台绑定的合法自定义域名天然优先,不会被子域名覆盖。
|
||||
*/
|
||||
export function resolveTenantFromHost(
|
||||
host: string,
|
||||
baseDomains: string[] | string,
|
||||
prefixes: string[] | string,
|
||||
defaultTenantId: string,
|
||||
defaultAppId: string,
|
||||
defaultTemplateId: string
|
||||
): TenantContext {
|
||||
const hostname = host.split(':')[0]
|
||||
|
||||
// 防御性类型保障:runtimeConfig 序列化可能将数组退化为字符串
|
||||
const safeDomains = Array.isArray(baseDomains)
|
||||
? baseDomains
|
||||
: String(baseDomains || '').split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
const safePrefixes = Array.isArray(prefixes)
|
||||
? prefixes
|
||||
: String(prefixes || '').split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
|
||||
const domains = (safeDomains && safeDomains.length
|
||||
? safeDomains
|
||||
: ['shoplnk.cn']
|
||||
).map(d => escapeRegex(d.trim())).filter(Boolean).join('|')
|
||||
|
||||
const prefixList = (safePrefixes && safePrefixes.length
|
||||
? safePrefixes
|
||||
: ['site']
|
||||
).map(p => escapeRegex(p.trim())).filter(Boolean).join('|')
|
||||
|
||||
// 匹配 {site|shop|store|mp|app|oa}-{tenantId}.{baseDomain}
|
||||
const subdomainPattern = new RegExp(
|
||||
`^(?:${prefixList})-(\\d+)\\.(?:${domains})$`,
|
||||
'i'
|
||||
)
|
||||
const match = hostname.match(subdomainPattern)
|
||||
|
||||
if (match && match[1]) {
|
||||
return {
|
||||
tenantId: match[1],
|
||||
appId: defaultAppId,
|
||||
templateId: defaultTemplateId,
|
||||
source: 'subdomain',
|
||||
host
|
||||
}
|
||||
}
|
||||
|
||||
// 默认使用环境变量
|
||||
return {
|
||||
tenantId: defaultTenantId,
|
||||
appId: defaultAppId,
|
||||
templateId: defaultTemplateId,
|
||||
source: 'env',
|
||||
host
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 event.context 获取租户上下文
|
||||
* 如果不存在则回退到运行时配置默认值
|
||||
*/
|
||||
export function getTenantFromContext(
|
||||
event: Parameters<Parameters<typeof defineEventHandler>[0]>[0],
|
||||
config: RuntimeConfig
|
||||
): TenantContext {
|
||||
const ctx = event.context?.tenant as TenantContext | undefined
|
||||
|
||||
if (ctx?.tenantId) {
|
||||
return ctx
|
||||
}
|
||||
|
||||
return {
|
||||
tenantId: config.public.tenantId as string,
|
||||
appId: config.public.appId as string,
|
||||
templateId: config.public.templateId as string,
|
||||
source: 'env'
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 表单/留言输入校验
|
||||
* 电话号段支持:中国大陆手机号 + 港澳台/海外(+区号 或 00 区号)。
|
||||
*/
|
||||
|
||||
/** 归一化:去除空格、括号、连字符;00 开头视为国际号 */
|
||||
export function normalizePhone(v: string): string {
|
||||
return (v || '').replace(/[\s()-]/g, '').replace(/^00/, '+')
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验电话是否有效。
|
||||
* - 含 + 或 00 前缀:按国际号码处理,国家码+号码总长 8~15 位即可(覆盖港澳台/海外)。
|
||||
* - 无前缀:按中国大陆手机号 1[3-9]\d{9} 处理。
|
||||
*/
|
||||
export function validatePhone(value: string): boolean {
|
||||
const v = normalizePhone(value)
|
||||
if (!v) return false
|
||||
if (v.startsWith('+')) {
|
||||
const digits = v.slice(1).replace(/[^\d]/g, '')
|
||||
return /^\d{8,15}$/.test(digits)
|
||||
}
|
||||
return /^1[3-9]\d{9}$/.test(v)
|
||||
}
|
||||
Reference in New Issue
Block a user