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 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' }) } })