
基于婚纱摄影网站源码搭建作品展示与预约功能
婚纱摄影网站通常已有首页、套餐介绍、案例展示和联系方式,但“能看”不等于“能预约”。要让访客从浏览作品走到提交预约,至少需要解决两件事:作品内容如何组织与筛选;预约信息如何验证、保存,并避免同一时段被重复占用。
本文以一个常见的 Node.js + Express 项目为例,搭建一个可运行的最小版本:前端展示摄影作品并按风格筛选;访客填写姓名、联系方式、拍摄日期和时段后,后端验证并写入 SQLite。这个版本适合单门店、预约规则简单、正在验证流程的项目。它不是完整的客户管理系统,也不包含支付、短信通知或员工排班。
一、适用场景与边界
- 适合:现有网站源码使用常见静态页面或 Express 服务,希望先上线作品筛选与基础预约表单。
- 适合:预约规则简单,例如每天只有若干固定时段,且一间门店共享这些时段。
- 不适合直接照搬:多门店、多摄影师、化妆师与场地资源需要同时排班的系统。此类项目必须定义资源冲突规则,不能只用“日期 + 时段”判重。
- 不适合替代:正式的客户关系管理、订单支付、合同签署和生产级排班系统。示例只负责收集预约申请,并不代表预约已经确认。
开始前先检查源码的技术栈、路由方式和部署环境。若现有项目使用 PHP、Java 或其他框架,应把本文的校验与数据约束思路迁移到对应技术栈,而不是将 Express 服务硬塞进原项目。以下示例要求已安装 Node.js,使用 Node.js 20 或较新的 LTS 版本;依赖版本由 npm 安装时的项目配置确定。
二、确定作品与预约的数据边界
作品展示和预约数据是两类不同内容。作品是公开内容,可包含标题、风格标签、封面地址和简介;预约记录包含姓名、联系方式、日期和时段,属于个人信息,不应出现在公开作品接口或页面源代码中。
示例将作品数据放在前端常量中,便于先跑通功能。正式运营时,建议把作品迁移到内容管理系统或数据库,并通过管理端维护。封面图应使用经过压缩的 WebP、AVIF 或 JPEG 等合适格式,控制文件体积;不要把原始相机大图直接作为列表缩略图。
预约数据使用 SQLite 保存。它适用于单机、小流量和原型项目。若网站运行在多台服务器、使用无持久化的临时文件系统,或并发写入量较大,应使用具有持久化和备份方案的数据库服务。部署前还要确认 SQLite 文件所在目录可写,并纳入备份策略。
三、创建可运行项目
新建目录并初始化项目,安装 Express 和 SQLite 依赖:
mkdir wedding-studio
cd wedding-studio
npm init -y
npm install express better-sqlite3
创建以下结构:
wedding-studio/
├── server.js
└── public/
├── index.html
└── app.js
下面的服务端示例包含四项关键工作:创建预约表、对请求字段做服务端校验、通过数据库唯一约束处理重复时段、只返回预约结果而不公开个人信息。时段、套餐和日期范围都是示例业务规则,项目上线前应按真实营业安排调整。
四、编写预约接口与数据库约束
const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const app = express();
const db = new Database(path.join(__dirname, 'bookings.sqlite'));
// 接口只接受 JSON 请求体;limit 用于限制解析的请求体大小。
app.use(express.json({ limit: '10kb' }));
app.use(express.static(path.join(__dirname, 'public')));
db.exec(`
CREATE TABLE IF NOT EXISTS bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT NOT NULL,
email TEXT,
package_id TEXT NOT NULL,
booking_date TEXT NOT NULL,
slot TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS unique_active_booking_slot
ON bookings (booking_date, slot)
WHERE status != 'cancelled';
`);
const packages = new Set(['classic', 'garden', 'destination']);
const slots = new Set(['09:00', '13:00', '16:00']);
function isValidDate(value) {
if (typeof value !== 'string' || !/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) {
return false;
}
const date = new Date(`${value}T00:00:00.000Z`);
return !Number.isNaN(date.getTime()) &&
date.toISOString().slice(0, 10) === value;
}
function isWithinBookingWindow(value) {
if (!isValidDate(value)) return false;
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const selected = new Date(`${value}T00:00:00.000Z`);
const maxDate = new Date(today);
maxDate.setUTCDate(maxDate.getUTCDate() + 180);
return selected >= today && selected <= maxDate;
}
function text(value, maxLength) {
return typeof value === 'string'
? value.trim().slice(0, maxLength)
: '';
}
app.post('/api/bookings', (req, res) => {
const name = text(req.body?.name, 80);
const phone = text(req.body?.phone, 40);
const email = text(req.body?.email, 254);
const packageId = text(req.body?.packageId, 40);
const bookingDate = text(req.body?.date, 10);
const slot = text(req.body?.slot, 5);
const note = text(req.body?.note, 500);
if (name.length < 2) {
return res.status(400).json({ error: '请填写至少 2 个字符的姓名。' });
}
// 仅限制字符类别和长度,不验证号码是否真实或能否接收短信。
if (!/^[0-9+()\\-\\s]{6,40}$/.test(phone)) {
return res.status(400).json({ error: '请检查联系电话格式。' });
}
if (email && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)) {
return res.status(400).json({ error: '请检查邮箱格式。' });
}
if (!packages.has(packageId)) {
return res.status(400).json({ error: '请选择有效套餐。' });
}
if (!isWithinBookingWindow(bookingDate)) {
return res.status(400).json({ error: '日期须在今天至未来 180 天内。' });
}
if (!slots.has(slot)) {
return res.status(400).json({ error: '请选择有效拍摄时段。' });
}
try {
const result = db.prepare(`
INSERT INTO bookings
(name, phone, email, package_id, booking_date, slot, note)
VALUES
(@name, @phone, @email, @packageId, @bookingDate, @slot, @note)
`).run({
name,
phone,
email: email || null,
packageId,
bookingDate,
slot,
note
});
return res.status(201).json({
id: result.lastInsertRowid,
message: '预约申请已提交,门店确认后会与您联系。'
});
} catch (error) {
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return res.status(409).json({ error: '该时段已被预约,请选择其他时段。' });
}
console.error('创建预约失败');
return res.status(500).json({ error: '暂时无法提交,请稍后重试。' });
}
});
const port = Number(process.env.PORT) || 3000;
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
日期校验分成两步:先检查 YYYY-MM-DD 格式,并确认日期真实存在;再限制在今天至未来 180 天内。这里使用 UTC 日期做比较,避免直接解析不带时区的日期字符串时产生格式差异。若门店营业规则依赖当地时区,应该明确门店时区,并在服务端按该时区计算“今天”,不要默认服务器时区就是门店时区。
姓名、电话和备注长度限制是此接口的业务上限,不是浏览器或 Express 对所有应用统一规定的限制。电话正则只做输入格式筛选,不能证明号码真实、属于提交人或可以接收消息。需要验证可达性时,应设计单独的短信验证流程,并考虑验证码频率、有效期和滥用防护。
唯一索引是防止重复占用的关键。仅在 JavaScript 中先查一次“这个时段是否空闲”,再插入记录,会遇到并发竞争:两个请求可能同时查到空闲,随后都写入。数据库唯一约束能让最终只有一个请求成功。示例规定同一日期同一时段只能有一条非取消预约;取消状态的记录不再占用时段。若业务还要区分门店、摄影师或套餐资源,唯一索引必须相应调整。
该接口创建的是 pending(待确认)申请,不应在页面上误称为“预约成功”或“已锁定档期”。示例没有提供取消、确认、改期接口,也没有公开查询预约详情的接口。实际项目应由工作人员核实档期后,通过受控后台确认,并制定取消与改期规则。
五、制作作品列表与预约表单
在 public/index.html 中添加页面结构。作品图片使用真实项目中的文件路径替换示例路径;以下代码不包含图片文件本身。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>婚纱摄影作品与预约</title>
</head>
<body>
<main>
<section aria-labelledby="works-title">
<h1 id="works-title">婚纱摄影作品</h1>
<label for="style-filter">按风格筛选</label>
<select id="style-filter">
<option value="all">全部风格</option>
<option value="garden">森系</option>
<option value="city">城市</option>
<option value="classic">经典</option>
</select>
<div id="works" class="works" aria-live="polite"></div>
</section>
<section aria-labelledby="booking-title">
<h2 id="booking-title">提交预约申请</h2>
<form id="booking-form">
<label>
姓名
<input name="name" required minlength="2" maxlength="80" autocomplete="name">
</label>
<label>
联系电话
<input name="phone" required maxlength="40" autocomplete="tel">
</label>
<label>
邮箱(选填)
<input name="email" type="email" maxlength="254" autocomplete="email">
</label>
<label>
套餐
<select name="packageId" required>
<option value="">请选择套餐</option>
<option value="classic">经典</option>
<option value="garden">森系</option>
<option value="destination">旅拍</option>
</select>
</label>
<label>
拍摄日期
<input name="date" type="date" required>
</label>
<label>
时段
<select name="slot" required>
<option value="">请选择时段</option>
<option value="09:00">09:00</option>
<option value="13:00">13:00</option>
<option value="16:00">16:00</option>
</select>
</label>
<label>
备注
<textarea name="note" maxlength="500"></textarea>
</label>
<button type="submit">提交预约申请</button>
<p id="booking-result" role="status" aria-live="polite"></p>
</form>
</section>
</main>
<script src="/app.js" defer></script>
</body>
</html>
表单使用 required、maxlength 和 type="email" 改善填写体验,但这些属于客户端校验,用户可以绕过。因此服务端仍需自行限制长度、检查格式和确认选项是否有效。前后端的时段与套餐列表也应保持一致;若后续由后台动态维护,应改为从服务端读取可选项。
六、渲染作品并提交预约
在 public/app.js 中定义作品数据、按风格筛选,并发送预约请求。示例使用 DOM API 写入作品文字,避免把用户输入拼接进 innerHTML。
const works = [
{
title: '林间婚礼',
style: 'garden',
image: '/media/garden-session.webp',
alt: '林间自然光婚纱作品'
},
{
title: '城市夜景',
style: 'city',
image: '/media/city-session.webp',
alt: '城市夜景婚纱作品'
},
{
title: '经典棚拍',
style: 'classic',
image: '/media/classic-session.webp',
alt: '经典棚拍婚纱作品'
}
];
const worksNode = document.querySelector('#works');
const filterNode = document.querySelector('#style-filter');
function renderWorks(style) {
worksNode.replaceChildren();
const visibleWorks = style === 'all'
? works
: works.filter((work) => work.style === style);
for (const work of visibleWorks) {
const card = document.createElement('article');
const image = document.createElement('img');
const title = document.createElement('h2');
image.src = work.image;
image.alt = work.alt;
image.loading = 'lazy';
title.textContent = work.title;
card.append(image, title);
worksNode.append(card);
}
}
filterNode.addEventListener('change', () => {
renderWorks(filterNode.value);
});
renderWorks('all');
const form = document.querySelector('#booking-form');
const result = document.querySelector('#booking-result');
form.addEventListener('submit', async (event) => {
event.preventDefault();
result.textContent = '正在提交…';
const data = Object.fromEntries(new FormData(form).entries());
try {
const response = await fetch('/api/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const body = await response.json();
if (!response.ok) {
result.textContent = body.error || '提交失败,请检查填写内容。';
return;
}
result.textContent = body.message;
form.reset();
} catch {
result.textContent = '网络暂时不可用,请稍后重试。';
}
});
为了让作品列表正常显示,需要在 public/media/ 中放置与路径对应的图片文件。示例设置了 alt 替代文本和延迟加载;正式内容应为每张作品写准确描述,不要将关键词机械堆进替代文本。还应根据原站样式补充卡片布局、移动端适配和视觉状态,但这些样式不影响筛选与预约接口的基本流程。
作品信息写在浏览器端意味着访问者可以查看和修改它,不能用于保存秘密数据,也不适合频繁更新的内容管理。图片路径同样是公开地址。若作品涉及客户肖像,应在发布前确认取得适当授权,并提供撤下流程。
七、启动与验证
在项目根目录运行服务:
node server.js
浏览器打开 http://localhost:3000,检查作品筛选、表单必填提示和提交反馈。也可以用命令行测试接口;日期需替换为今天至未来 180 天内的真实日期:
curl -i http://localhost:3000/api/bookings \
-H 'Content-Type: application/json' \
-d '{
"name": "林女士",
"phone": "+86 13800000000",
"email": "[email protected]",
"packageId": "garden",
"date": "2026-09-20",
"slot": "09:00",
"note": "希望咨询外景安排"
}'
示例请求日期只是格式演示;如果运行时它不在允许范围内,接口会按规则拒绝。首次成功通常返回 HTTP 201;字段不合法返回 HTTP 400;同一日期和时段已有未取消记录时返回 HTTP 409。服务器错误返回 HTTP 500,但响应不应包含数据库错误细节。
再次提交同一日期和时段,验证唯一约束是否生效。然后用非法套餐、无效日期、超长备注和错误电话格式进行测试。还应检查 SQLite 文件确实产生,重启服务后记录仍存在。不要只测试前端提示,因为直接调用接口的请求不会经过表单校验。
八、常见错误与处理方式
1. 只在前端判断日期和时段
前端控件可以被绕过,甚至请求可以完全不经过页面。业务规则必须在服务端重新校验,数据库还要负责解决并发冲突。
2. 用“先查询、后插入”代替唯一约束
单纯查询空档再写入,在并发请求下可能重复占位。应由数据库唯一索引兜底,并在发生冲突时返回明确的 409。若采用不同数据库,需按该数据库的事务与唯一约束机制实现。
3. 把预约申请说成最终确认
接口成功只表示记录已接收,不代表门店已确认场地、摄影师和套餐。页面文案、短信和后台状态应明确区分“待确认”“已确认”“已取消”。
4. 直接用访客输入生成 HTML
将用户姓名、备注等内容拼接到 innerHTML,可能引入跨站脚本风险。页面展示内容优先使用 textContent;后台模板输出数据时也要进行上下文正确的转义。输入长度限制不能代替输出编码。
5. 把数据库文件和源码一起公开
SQLite 文件含有姓名、电话等预约信息,绝不能放进静态资源目录,也不能通过 Web 服务器直接下载。示例把数据库文件放在项目根目录,并由 Express 静态中间件只发布 public。生产环境仍应确认反向代理、备份目录和文件权限没有意外暴露该文件。
6. 认为示例已包含完整防滥用能力
示例没有实现登录后台、验证码、速率限制、CSRF 防护、邮件或短信发送。若部署在可跨站提交的场景,需要结合域名、Cookie 和认证方式评估 CSRF 风险;公开接口应增加适当的频率限制和滥用监测。使用 HTTPS,配置合理的代理与安全响应头,并避免在日志中记录完整联系方式。
7. 把前端选项当作权威数据
套餐与时段可以被用户自行修改。服务端通过允许列表确认提交值合法,但示例的允许列表写死在代码中。上线后若需要临时关闭时段、设置节假日或区分摄影师,应以服务端日历和资源数据为准,并在提交时重新判断当前可用性。
九、上线前的安全与运维检查
- 使用 HTTPS;不要通过明文连接传输联系方式。
- 为预约数据库配置访问权限和定期备份,测试恢复流程;备份文件也按敏感数据保护。
- 限制接口请求体大小,并根据业务增加请求频率控制、垃圾预约识别和告警。
- 制定个人信息用途、保存期限、访问人员范围和删除流程。只收集预约所必需的信息,不要在备注中索要身份证号等无关敏感资料。
- 管理端必须有身份验证与权限控制;不能因为示例未提供后台,就把数据库查询接口开放给公众。
- 将错误详情写入受控服务端日志,不把 SQL、文件路径和堆栈返回给浏览器;同时避免日志长期保留不必要的个人信息。
- 按真实资源关系设计冲突规则。多门店或多摄影师场景,唯一键可能需要包含门店或资源编号,复杂排班还需使用事务处理多项资源占用。
十、后续扩展建议
基础版本跑通后,可以分阶段扩展。第一步,将作品和套餐迁移到可管理的数据源,并保留服务端对套餐状态的校验。第二步,增加后台预约列表、状态流转、操作审计和取消规则,后台接口要经过认证并做权限检查。第三步,根据实际业务接入通知服务,但应处理发送失败、重复发送和消息模板中的个人信息。第四步,若要支持在线付款,应将支付状态和预约状态分开设计,通过可信的支付回调更新订单,不能只相信浏览器跳转结果。
对已有婚纱摄影源码而言,最稳妥的改造方式不是一次性重写全站,而是先确认原有页面结构、部署方式和内容来源,再把预约接口作为独立边界接入。作品筛选可以从静态数据开始;预约则必须由服务端校验并可靠保存。清楚区分“访客提交申请”和“门店确认档期”,再逐步补齐权限、备份与运营流程,才能避免页面看似完成、实际预约却重复或无法追踪的问题。









