@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* POST /api/contact
|
||||
* 联系表单提交接口(通用留言)
|
||||
*
|
||||
* 请求体:
|
||||
* {
|
||||
* name: string // 姓名(必填)
|
||||
* phone: string // 手机号(必填)
|
||||
* subject?: string // 咨询主题
|
||||
* message: string // 留言内容(必填)
|
||||
* }
|
||||
*/
|
||||
|
||||
import { checkRateLimit } from '../utils/rateLimit'
|
||||
import { appendRecord } from '../utils/storage'
|
||||
import { notifyWecom } from '../utils/notify'
|
||||
|
||||
const PHONE_RE = /^1[3-9]\d{9}$/
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// ── 速率限制 ──────────────────────────────────────────
|
||||
const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
|
||||
const { allowed, remaining, resetAt } = checkRateLimit(`contact:${ip}`)
|
||||
|
||||
setResponseHeader(event, 'X-RateLimit-Remaining', String(remaining))
|
||||
|
||||
if (!allowed) {
|
||||
const waitMin = Math.ceil((resetAt - Date.now()) / 60000)
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
message: `提交过于频繁,请 ${waitMin} 分钟后再试`,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 读取请求体 ─────────────────────────────────────────
|
||||
const body = await readBody(event)
|
||||
|
||||
// ── 字段校验 ──────────────────────────────────────────
|
||||
const errors = {}
|
||||
|
||||
if (!body?.name?.trim()) {
|
||||
errors.name = '请填写您的姓名'
|
||||
}
|
||||
|
||||
if (!body?.phone || !PHONE_RE.test(body.phone.trim())) {
|
||||
errors.phone = '请填写正确的手机号码'
|
||||
}
|
||||
|
||||
if (!body?.message?.trim() || body.message.trim().length < 5) {
|
||||
errors.message = '请填写留言内容(至少5个字)'
|
||||
}
|
||||
|
||||
if (body?.message?.trim().length > 1000) {
|
||||
errors.message = '留言内容不超过1000字'
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
throw createError({
|
||||
statusCode: 422,
|
||||
data: { errors },
|
||||
message: '提交内容有误,请检查后重试',
|
||||
})
|
||||
}
|
||||
|
||||
// ── 构建记录 ──────────────────────────────────────────
|
||||
const record = {
|
||||
id: `CT${Date.now()}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
name: body.name.trim(),
|
||||
phone: body.phone.trim(),
|
||||
subject: body.subject?.trim() ?? '',
|
||||
message: body.message.trim(),
|
||||
ip,
|
||||
source: 'contact-form',
|
||||
}
|
||||
|
||||
// ── 持久化 ────────────────────────────────────────────
|
||||
await appendRecord('contact.json', record)
|
||||
|
||||
// ── 企业微信通知(配置 WECOM_WEBHOOK_KEY 后生效)───────
|
||||
await notifyWecom('📩 新联系留言 - 呼籁旅行', {
|
||||
'编号': record.id,
|
||||
'姓名': record.name,
|
||||
'手机': record.phone,
|
||||
'咨询主题': record.subject || '(未填)',
|
||||
'留言内容': record.message,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id: record.id,
|
||||
message: '留言已收到,我们会尽快与您联系',
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* POST /api/customize
|
||||
* 定制表单提交接口
|
||||
*
|
||||
* 请求体:
|
||||
* {
|
||||
* name: string // 姓名(必填)
|
||||
* phone: string // 手机号(必填)
|
||||
* wechat?: string // 微信号(选填)
|
||||
* adults: number // 成人数(必填)
|
||||
* children: number // 儿童数
|
||||
* childAges: number[] // 儿童年龄列表
|
||||
* date?: string // 出行日期 YYYY-MM-DD
|
||||
* duration?: string // 出行天数
|
||||
* activities: string[]// 特殊需求标签
|
||||
* budget?: string // 预算范围
|
||||
* remarks?: string // 补充说明
|
||||
* }
|
||||
*/
|
||||
|
||||
import { checkRateLimit } from '../utils/rateLimit'
|
||||
import { appendRecord } from '../utils/storage'
|
||||
import { notifyWecom } from '../utils/notify'
|
||||
|
||||
// 手机号正则
|
||||
const PHONE_RE = /^1[3-9]\d{9}$/
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// ── 速率限制 ──────────────────────────────────────────
|
||||
const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
|
||||
const { allowed, remaining, resetAt } = checkRateLimit(`customize:${ip}`)
|
||||
|
||||
setResponseHeader(event, 'X-RateLimit-Remaining', String(remaining))
|
||||
|
||||
if (!allowed) {
|
||||
const waitMin = Math.ceil((resetAt - Date.now()) / 60000)
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
message: `提交过于频繁,请 ${waitMin} 分钟后再试`,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 读取请求体 ─────────────────────────────────────────
|
||||
const body = await readBody(event)
|
||||
|
||||
// ── 字段校验 ──────────────────────────────────────────
|
||||
const errors = {}
|
||||
|
||||
if (!body?.name?.trim()) {
|
||||
errors.name = '请填写您的姓名'
|
||||
}
|
||||
|
||||
if (!body?.phone || !PHONE_RE.test(body.phone.trim())) {
|
||||
errors.phone = '请填写正确的手机号码(11位大陆手机号)'
|
||||
}
|
||||
|
||||
if (!Number.isInteger(body?.adults) || body.adults < 1 || body.adults > 20) {
|
||||
errors.adults = '成人人数不合法'
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
throw createError({
|
||||
statusCode: 422,
|
||||
data: { errors },
|
||||
message: '提交内容有误,请检查后重试',
|
||||
})
|
||||
}
|
||||
|
||||
// ── 构建记录 ──────────────────────────────────────────
|
||||
const record = {
|
||||
id: `CZ${Date.now()}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
name: body.name.trim(),
|
||||
phone: body.phone.trim(),
|
||||
wechat: body.wechat?.trim() ?? '',
|
||||
adults: body.adults,
|
||||
children: body.children ?? 0,
|
||||
childAges: Array.isArray(body.childAges) ? body.childAges : [],
|
||||
date: body.date ?? '',
|
||||
duration: body.duration ?? '',
|
||||
activities: Array.isArray(body.activities) ? body.activities : [],
|
||||
budget: body.budget ?? '',
|
||||
remarks: body.remarks?.trim() ?? '',
|
||||
ip,
|
||||
source: 'customize-form',
|
||||
}
|
||||
|
||||
// ── 持久化 ────────────────────────────────────────────
|
||||
await appendRecord('customize.json', record)
|
||||
|
||||
// ── 企业微信通知(配置 WECOM_WEBHOOK_KEY 后生效)───────
|
||||
const childInfo = record.children > 0
|
||||
? `成人 ${record.adults} 人、儿童 ${record.children} 人(${record.childAges.join('、')}岁)`
|
||||
: `成人 ${record.adults} 人`
|
||||
|
||||
await notifyWecom('🌿 新定制需求 - 呼籁旅行', {
|
||||
'编号': record.id,
|
||||
'姓名': record.name,
|
||||
'手机': record.phone,
|
||||
'微信': record.wechat || '(同手机号)',
|
||||
'出行人数': childInfo,
|
||||
'出行日期': record.date || '未填写',
|
||||
'出行天数': record.duration || '未选择',
|
||||
'特殊需求': record.activities.length ? record.activities.join('、') : '无',
|
||||
'预算范围': record.budget || '未选择',
|
||||
'补充说明': record.remarks || '无',
|
||||
})
|
||||
|
||||
// ── 返回 ──────────────────────────────────────────────
|
||||
return {
|
||||
success: true,
|
||||
id: record.id,
|
||||
message: '需求已收到,定制师将在2小时内联系您',
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const pages = [
|
||||
{
|
||||
url: '/', priority: '1.0', changefreq: 'weekly',
|
||||
images: [{ loc: '/images/team/hero-grassland.jpg', title: '呼伦贝尔草原风光 - 呼籁旅行' }],
|
||||
},
|
||||
{
|
||||
url: '/faq', priority: '0.95', changefreq: 'weekly',
|
||||
images: [{ loc: '/images/team/hero-grassland.jpg', title: '呼伦贝尔草原 - 呼籁旅行常见问答' }],
|
||||
},
|
||||
{
|
||||
url: '/products', priority: '0.9', changefreq: 'weekly',
|
||||
images: [{ loc: '/images/mascot/xiaomengma.png', title: '小蒙马夏令营IP形象' }],
|
||||
},
|
||||
{
|
||||
url: '/about', priority: '0.85', changefreq: 'monthly',
|
||||
images: [
|
||||
{ loc: '/images/team/hero-family.jpg', title: '呼伦贝尔家庭游合影' },
|
||||
{ loc: '/images/team/guide-group.jpg', title: '呼籁旅行领队团队合影' },
|
||||
{ loc: '/images/mascot/mascot-front.png', title: '呼籁旅行吉祥物' },
|
||||
],
|
||||
},
|
||||
{
|
||||
url: '/reviews', priority: '0.8', changefreq: 'weekly',
|
||||
images: [
|
||||
{ loc: '/images/reviews/review-001.jpg', title: '呼籁旅行客户好评截图' },
|
||||
{ loc: '/images/reviews/review-002.jpg', title: '呼伦贝尔亲子游客户评价' },
|
||||
],
|
||||
},
|
||||
{
|
||||
url: '/contact', priority: '0.8', changefreq: 'monthly',
|
||||
images: [{ loc: '/images/contact/qrcode-wechat.jpg', title: '呼籁旅行微信客服二维码' }],
|
||||
},
|
||||
{
|
||||
url: '/pricing', priority: '0.9', changefreq: 'monthly',
|
||||
images: [{ loc: '/images/mascot/mascot-front.png', title: '呼籁旅行 额吉的故乡V9系列定制游价格参考' }],
|
||||
},
|
||||
{
|
||||
url: '/selector', priority: '0.85', changefreq: 'monthly',
|
||||
images: [],
|
||||
},
|
||||
{
|
||||
url: '/guides', priority: '0.8', changefreq: 'monthly',
|
||||
images: [],
|
||||
},
|
||||
{
|
||||
url: '/news', priority: '0.85', changefreq: 'weekly',
|
||||
images: [],
|
||||
},
|
||||
{
|
||||
url: '/calendar', priority: '0.85', changefreq: 'monthly',
|
||||
images: [],
|
||||
},
|
||||
{
|
||||
url: '/courses', priority: '0.85', changefreq: 'monthly',
|
||||
images: [],
|
||||
},
|
||||
{
|
||||
url: '/summer-camp', priority: '0.85', changefreq: 'monthly',
|
||||
images: [
|
||||
{ loc: '/images/summer-camp/river-banner.jpg', title: '小蒙马夏令营河畔活动' },
|
||||
{ loc: '/images/summer-camp/grassland-group.jpg', title: '草原亲子夏令营合影' },
|
||||
{ loc: '/images/summer-camp/birch-group.jpg', title: '白桦林研学活动' },
|
||||
{ loc: '/images/summer-camp/camp-graduation.jpg', title: '夏令营结营仪式' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// 目的地页面
|
||||
pages.push({
|
||||
url: '/destinations', priority: '0.9', changefreq: 'weekly',
|
||||
images: [],
|
||||
})
|
||||
const destinationIds = [
|
||||
'moriguole', 'eergunashire', 'heishantou', 'manzhouli',
|
||||
'hulunhu', 'aershan', 'shiwei', 'genhe',
|
||||
]
|
||||
for (const id of destinationIds) {
|
||||
pages.push({
|
||||
url: `/destinations/${id}`, priority: '0.85', changefreq: 'monthly',
|
||||
images: [],
|
||||
})
|
||||
}
|
||||
|
||||
// 博客攻略页面
|
||||
const blogArticles = [
|
||||
'hulunbuir-travel-guide-2026',
|
||||
'hulunbuir-family-travel-guide',
|
||||
'hulunbuir-best-month-to-visit',
|
||||
'hulunbuir-self-drive-vs-chartered-bus',
|
||||
'hulunbuir-travel-cost-breakdown',
|
||||
'hulunbuir-travel-with-toddlers-4-6',
|
||||
'hulunbuir-travel-tips-avoid-pitfalls',
|
||||
'grassland-photography-outfit-guide',
|
||||
]
|
||||
pages.push({ url: '/blog', priority: '0.85', changefreq: 'weekly', images: [] })
|
||||
for (const id of blogArticles) {
|
||||
pages.push({ url: `/blog/${id}`, priority: '0.8', changefreq: 'monthly', images: [] })
|
||||
}
|
||||
|
||||
// 旅拍作品展示页
|
||||
pages.push({
|
||||
url: '/gallery',
|
||||
priority: '0.85',
|
||||
changefreq: 'monthly',
|
||||
images: [
|
||||
{ loc: '/images/gallery/gallery-001.jpg', title: '莫日格勒河畔蒙古袍亲子旅拍' },
|
||||
{ loc: '/images/gallery/gallery-002.jpg', title: '额尔古纳湿地情侣旅拍' },
|
||||
{ loc: '/images/gallery/gallery-003.jpg', title: '白桦林间闺蜜写真' },
|
||||
{ loc: '/images/gallery/gallery-004.jpg', title: '黑山头草原全家福' },
|
||||
],
|
||||
})
|
||||
|
||||
// 攻略指南详情页
|
||||
pages.push({ url: '/guide-custom', priority: '0.8', changefreq: 'monthly', images: [] })
|
||||
|
||||
// 冬季营
|
||||
pages.push({ url: '/winter-camp', priority: '0.85', changefreq: 'monthly', images: [] })
|
||||
|
||||
// 在线定制表单
|
||||
pages.push({ url: '/customize', priority: '0.9', changefreq: 'monthly', images: [] })
|
||||
|
||||
// 信任度页面
|
||||
pages.push({
|
||||
url: '/stories', priority: '0.85', changefreq: 'weekly',
|
||||
images: [{ loc: '/images/team/hero-family.jpg', title: '呼籁旅行真实客户故事 - 家庭呼伦贝尔旅行记录' }],
|
||||
})
|
||||
const storyIds = [
|
||||
'story-3',
|
||||
'story-4',
|
||||
'story-5',
|
||||
'story-6',
|
||||
'story-7',
|
||||
]
|
||||
for (const id of storyIds) {
|
||||
pages.push({ url: `/stories/${id}`, priority: '0.8', changefreq: 'monthly', images: [] })
|
||||
}
|
||||
|
||||
// 产品独立页面(与 data/products.json versions 对齐)
|
||||
const productVersions = [
|
||||
{ id: 'v9-4d3n', name: '呼伦贝尔4天3晚精华版亲子游' },
|
||||
{ id: 'v9-5d4n', name: '呼伦贝尔5天4晚经典版亲子游' },
|
||||
{ id: 'v9-6d5n-family', name: '呼伦贝尔6天5晚亲子版亲子游' },
|
||||
{ id: 'v9-6d5n-comfort', name: '呼伦贝尔6天5晚舒适版亲子游' },
|
||||
{ id: 'v9-7d6n-family', name: '呼伦贝尔7天6晚亲子版亲子游' },
|
||||
{ id: 'v9-7d6n-wild', name: '呼伦贝尔7天6晚旷野版亲子游' },
|
||||
]
|
||||
for (const v of productVersions) {
|
||||
pages.push({
|
||||
url: `/products/${v.id}`, priority: '0.85', changefreq: 'monthly',
|
||||
images: [],
|
||||
})
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const base = 'https://1814.love'
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
|
||||
${pages
|
||||
.map(
|
||||
(p) => ` <url>
|
||||
<loc>${base}${p.url}</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<changefreq>${p.changefreq}</changefreq>
|
||||
<priority>${p.priority}</priority>${p.images.map(img => `
|
||||
<image:image>
|
||||
<image:loc>${base}${img.loc}</image:loc>
|
||||
<image:title>${img.title}</image:title>
|
||||
</image:image>`).join('')}
|
||||
</url>`
|
||||
)
|
||||
.join('\n')}
|
||||
</urlset>`
|
||||
|
||||
setResponseHeader(event, 'content-type', 'application/xml')
|
||||
return xml
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"id": "CT1774007263178",
|
||||
"createdAt": "2026-03-20T11:47:43.178Z",
|
||||
"name": "李女士",
|
||||
"phone": "13711112222",
|
||||
"subject": "询问秋季团期",
|
||||
"message": "请问9月底有哪些团期可以选择?",
|
||||
"ip": "::1",
|
||||
"source": "contact-form"
|
||||
},
|
||||
{
|
||||
"id": "CT1774023293117",
|
||||
"createdAt": "2026-03-20T16:14:53.117Z",
|
||||
"name": "测试用户",
|
||||
"phone": "13800138000",
|
||||
"subject": "行程咨询",
|
||||
"message": "请问七月份有空档吗,想带家人来",
|
||||
"ip": "::1",
|
||||
"source": "contact-form"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,87 @@
|
||||
[
|
||||
{
|
||||
"id": "CZ1774007128348",
|
||||
"createdAt": "2026-03-20T11:45:28.348Z",
|
||||
"name": "测试用户",
|
||||
"phone": "13800138000",
|
||||
"wechat": "",
|
||||
"adults": 2,
|
||||
"children": 1,
|
||||
"childAges": [
|
||||
5
|
||||
],
|
||||
"date": "2026-07-01",
|
||||
"duration": "5d4n",
|
||||
"activities": [
|
||||
"骑马",
|
||||
"露营"
|
||||
],
|
||||
"budget": "1-2万",
|
||||
"remarks": "这是测试提交",
|
||||
"ip": "::1",
|
||||
"source": "customize-form"
|
||||
},
|
||||
{
|
||||
"id": "CZ1774007220100",
|
||||
"createdAt": "2026-03-20T11:47:00.100Z",
|
||||
"name": "王小明",
|
||||
"phone": "13912345678",
|
||||
"wechat": "wxid_test",
|
||||
"adults": 2,
|
||||
"children": 2,
|
||||
"childAges": [
|
||||
6,
|
||||
9
|
||||
],
|
||||
"date": "2026-08-15",
|
||||
"duration": "6d5n",
|
||||
"activities": [
|
||||
"骑马",
|
||||
"露营",
|
||||
"捕鱼"
|
||||
],
|
||||
"budget": "2-3万",
|
||||
"remarks": "希望住蒙古包",
|
||||
"ip": "::1",
|
||||
"source": "customize-form"
|
||||
},
|
||||
{
|
||||
"id": "CZ1774023293092",
|
||||
"createdAt": "2026-03-20T16:14:53.092Z",
|
||||
"name": "测试用户",
|
||||
"phone": "13800138000",
|
||||
"wechat": "",
|
||||
"adults": 2,
|
||||
"children": 1,
|
||||
"childAges": [
|
||||
5
|
||||
],
|
||||
"date": "2026-07-15",
|
||||
"duration": "6d5n",
|
||||
"activities": [],
|
||||
"budget": "8000-12000",
|
||||
"remarks": "",
|
||||
"ip": "::1",
|
||||
"source": "customize-form"
|
||||
},
|
||||
{
|
||||
"id": "CZ1774057315225",
|
||||
"createdAt": "2026-03-21T01:41:55.225Z",
|
||||
"name": "测试用户",
|
||||
"phone": "13800138000",
|
||||
"wechat": "",
|
||||
"adults": 2,
|
||||
"children": 1,
|
||||
"childAges": [],
|
||||
"date": "",
|
||||
"duration": "6天5晚",
|
||||
"activities": [
|
||||
"骑马",
|
||||
"旅拍"
|
||||
],
|
||||
"budget": "品质优先",
|
||||
"remarks": "",
|
||||
"ip": "::1",
|
||||
"source": "customize-form"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 企业微信群机器人 Webhook 通知
|
||||
* 在 .env 中配置 WECOM_WEBHOOK_KEY 即可启用
|
||||
* 不配置则静默跳过
|
||||
*
|
||||
* 文档:https://developer.work.weixin.qq.com/document/path/91770
|
||||
*/
|
||||
|
||||
const WECOM_API = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send'
|
||||
|
||||
/**
|
||||
* 发送企业微信群机器人 markdown 消息
|
||||
* @param {string} title - 消息标题(用作 markdown 一级标题)
|
||||
* @param {Record<string, string>} fields - { 字段名: 字段值 }
|
||||
*/
|
||||
export async function notifyWecom(title, fields) {
|
||||
const key = process.env.WECOM_WEBHOOK_KEY
|
||||
if (!key) return
|
||||
|
||||
const lines = Object.entries(fields)
|
||||
.map(([k, v]) => `> **${k}**:${v}`)
|
||||
.join('\n')
|
||||
|
||||
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
|
||||
|
||||
const content = `## ${title}\n${lines}\n> **提交时间**:${now}`
|
||||
|
||||
try {
|
||||
await $fetch(`${WECOM_API}?key=${key}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
msgtype: 'markdown',
|
||||
markdown: { content },
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
// 通知失败不影响主流程,静默忽略
|
||||
console.error('[notify] 企业微信通知失败:', err?.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 简单内存速率限制
|
||||
* 每个 IP 每小时最多允许 MAX_REQUESTS 次提交
|
||||
*/
|
||||
|
||||
const WINDOW_MS = 60 * 60 * 1000 // 1 小时
|
||||
const MAX_REQUESTS = 5
|
||||
|
||||
// Map<ip, { count: number, resetAt: number }>
|
||||
const store = new Map()
|
||||
|
||||
// 每 2 小时清理一次过期记录,防止内存泄漏
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [ip, record] of store.entries()) {
|
||||
if (now > record.resetAt) {
|
||||
store.delete(ip)
|
||||
}
|
||||
}
|
||||
}, 2 * 60 * 60 * 1000)
|
||||
|
||||
/**
|
||||
* 检查并记录一次请求
|
||||
* @param {string} ip
|
||||
* @returns {{ allowed: boolean, remaining: number, resetAt: number }}
|
||||
*/
|
||||
export function checkRateLimit(ip) {
|
||||
const now = Date.now()
|
||||
const record = store.get(ip)
|
||||
|
||||
if (!record || now > record.resetAt) {
|
||||
store.set(ip, { count: 1, resetAt: now + WINDOW_MS })
|
||||
return { allowed: true, remaining: MAX_REQUESTS - 1, resetAt: now + WINDOW_MS }
|
||||
}
|
||||
|
||||
if (record.count >= MAX_REQUESTS) {
|
||||
return { allowed: false, remaining: 0, resetAt: record.resetAt }
|
||||
}
|
||||
|
||||
record.count++
|
||||
return { allowed: true, remaining: MAX_REQUESTS - record.count, resetAt: record.resetAt }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 文件存储工具
|
||||
* 将表单数据追加写入 server/storage/ 目录下的 JSON 文件
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
const STORAGE_DIR = resolve(process.cwd(), 'server/storage')
|
||||
|
||||
/**
|
||||
* 确保存储目录存在
|
||||
*/
|
||||
async function ensureDir() {
|
||||
try {
|
||||
await fs.mkdir(STORAGE_DIR, { recursive: true })
|
||||
} catch {
|
||||
// 目录已存在,忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将记录追加到指定文件
|
||||
* @param {string} filename - 不含路径,如 'customize.json'
|
||||
* @param {object} record
|
||||
*/
|
||||
export async function appendRecord(filename, record) {
|
||||
await ensureDir()
|
||||
const filepath = resolve(STORAGE_DIR, filename)
|
||||
|
||||
let records = []
|
||||
try {
|
||||
const raw = await fs.readFile(filepath, 'utf-8')
|
||||
records = JSON.parse(raw)
|
||||
} catch {
|
||||
// 文件不存在或解析失败,从空数组开始
|
||||
}
|
||||
|
||||
records.push(record)
|
||||
await fs.writeFile(filepath, JSON.stringify(records, null, 2), 'utf-8')
|
||||
return record
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取所有记录(管理员用)
|
||||
* @param {string} filename
|
||||
*/
|
||||
export async function readRecords(filename) {
|
||||
const filepath = resolve(STORAGE_DIR, filename)
|
||||
try {
|
||||
const raw = await fs.readFile(filepath, 'utf-8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户