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,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