图示是项目架构概览,真实产品截图可在后续接入线上演示后替换;本文重点展示可复用的工程设计。
我以前做了一个叫 GoToMarry 的备婚管理系统,给新郎新娘用 PIN 码登录、管理备婚任务和预算。这篇教程把它的核心思路压缩成一个最小可跑的全栈小应用:一个 PIN 登录 + 任务清单。你照着做完,就能掌握「前端 + 后端 + 数据库 + 登录鉴权 + 联调」一整套,之后再扩展预算、婚期等功能就有底了。
我们最终做出这样一个小应用:浏览器里输入 PIN 码登录,登录后看到任务列表,还能新增任务,数据存在后端的 SQLite 里。技术栈是 Vue 3 + Vite 做前端,Node.js + Express + SQLite 做后端,登录用 PIN + JWT。
第 1 步:装 Node.js
前后端都靠 Node.js 跑,先装它。去官网 nodejs.org 下载 LTS 版本(写这篇时是 20.x 或 22.x,选 LTS 最稳)。Windows / macOS 都有一键安装包,一路下一步即可。装完打开终端(Windows 用 PowerShell,macOS 用终端)输入:
node -v
npm -v
能打印出版本号就说明装好了。npm 是 Node 的包管理器,装 Node 时会自带,后面装第三方库全靠它。
小提示:建议每个项目放在单独的文件夹里。本教程我们在一个叫
gotomarry-demo的文件夹下,分别建backend和frontend两个子文件夹。
第 2 步:搭后端(Express + SQLite)
后端负责存数据、校验登录、提供接口。我们用 Express(一个轻量 Web 框架)和 SQLite(一个单文件数据库,不用单独装数据库服务)。
先建后端项目并装依赖:
mkdir -p gotomarry-demo/backend
cd gotomarry-demo/backend
npm init -y
npm install express better-sqlite3 jsonwebtoken cors
四个包分别是:express 做 Web 服务、better-sqlite3 操作 SQLite 数据库、jsonwebtoken 生成和校验登录凭证、cors 处理跨域(跨域的概念第 4 步解释)。better-sqlite3 是原生模块,安装时通常会自动下载预编译二进制,多数情况能直接成功;若报错提示缺编译工具,Windows 装「windows-build-tools」、macOS 装 Xcode Command Line Tools 即可。
接着在 backend 目录下新建 server.js,完整内容如下:
const express = require('express');
const Database = require('better-sqlite3');
const jwt = require('jsonwebtoken');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(express.json());
// 配置项:通过环境变量注入,方便个性化(不设也有默认值)
const PIN_CODE = process.env.PIN_CODE || '123456';
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
const PORT = process.env.PORT || 3000;
// 初始化 SQLite,建 tasks 表
const db = new Database('data.db');
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
// 表为空时插一条示例数据
const count = db.prepare('SELECT COUNT(*) AS c FROM tasks').get();
if (count.c === 0) {
db.prepare("INSERT INTO tasks (title) VALUES (?)").run('买婚戒');
}
// JWT 校验中间件:从请求头取 token 并验证
// JWT(JSON Web Token)是一种登录后发给用户的「电子通行证」,
// 之后每次请求带上它,服务端就知道你是谁,不用每次重输密码。
function auth(req, res, next) {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: '未登录' });
try {
req.user = jwt.verify(token, JWT_SECRET);
next();
} catch {
res.status(401).json({ error: 'token 无效' });
}
}
// PIN 码登录:校验通过就签发一个 7 天有效的 JWT
app.post('/api/login', (req, res) => {
const { pin } = req.body || {};
if (pin !== PIN_CODE) return res.status(401).json({ error: 'PIN 码错误' });
const token = jwt.sign({ role: 'couple' }, JWT_SECRET, { expiresIn: '7d' });
res.json({ token });
});
// 获取任务列表(需登录)
app.get('/api/tasks', auth, (req, res) => {
const rows = db.prepare('SELECT * FROM tasks ORDER BY id DESC').all();
res.json(rows);
});
// 新增任务(需登录)
app.post('/api/tasks', auth, (req, res) => {
const { title } = req.body || {};
if (!title) return res.status(400).json({ error: 'title 不能为空' });
const info = db.prepare('INSERT INTO tasks (title) VALUES (?)').run(title);
const created = db.prepare('SELECT * FROM tasks WHERE id = ?').get(info.lastInsertRowid);
res.json(created);
});
app.listen(PORT, () => console.log(`后端跑起来了: http://localhost:${PORT}`));
启动后端:
node server.js
看到 后端跑起来了: http://localhost:3000 就成功了。另开一个终端用 curl 验证三个接口(PIN 默认是 123456):
# 1. 登录,拿到 token
curl -s -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"pin":"123456"}'
# 2. 用 token 取任务列表(把 <TOKEN> 换成上一步返回的 token 值)
curl -s http://localhost:3000/api/tasks \
-H "Authorization: Bearer <TOKEN>"
# 3. 新增一条任务
curl -s -X POST http://localhost:3000/api/tasks \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"title":"订酒店"}'
第 1 步会返回类似 {"token":"eyJhbGci..."},第 2 步能看到 [{"id":1,"title":"买婚戒",...}],第 3 步会返回你刚新增的那条任务。后端到这里就通了。
第 3 步:搭前端(Vue 3 + Vite)
回到项目根目录,用官方脚手架起一个 Vue 3 项目:
cd gotomarry-demo
npm create vue@latest frontend
脚手架会问几个问题,新手建议这样选:TypeScript No、JSX No、Vue Router No、Pinia No、Vitest No、ESLint No、Prettier No,得到最简单的模板。然后进目录装依赖和 axios(用来发 HTTP 请求调后端接口):
cd frontend
npm install
npm install axios
把 src/App.vue 整个替换成下面这份,它实现了「PIN 登录 → 存 token → 拉任务列表 → 新增任务并刷新」:
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
// 指向后端 3000 端口
const api = axios.create({ baseURL: 'http://localhost:3000/api' })
const pin = ref('')
const token = ref(localStorage.getItem('token') || '')
const tasks = ref([])
const newTitle = ref('')
const error = ref('')
async function login() {
error.value = ''
try {
const { data } = await api.post('/login', { pin: pin.value })
token.value = data.token
localStorage.setItem('token', data.token)
await loadTasks()
} catch (e) {
error.value = '登录失败:' + (e.response?.data?.error || e.message)
}
}
async function loadTasks() {
error.value = ''
try {
const { data } = await api.get('/tasks', {
headers: { Authorization: 'Bearer ' + token.value }
})
tasks.value = data
} catch (e) {
error.value = '加载失败:' + (e.response?.data?.error || e.message)
}
}
async function addTask() {
error.value = ''
if (!newTitle.value.trim()) return
try {
const { data } = await api.post('/tasks', { title: newTitle.value }, {
headers: { Authorization: 'Bearer ' + token.value }
})
tasks.value.unshift(data)
newTitle.value = ''
} catch (e) {
error.value = '新增失败:' + (e.response?.data?.error || e.message)
}
}
onMounted(() => {
if (token.value) loadTasks()
})
</script>
<template>
<div class="app">
<h1>备婚任务清单</h1>
<p v-if="error" class="error">{{ error }}</p>
<div v-if="!token">
<input v-model="pin" type="password" placeholder="输入 PIN 码" @keyup.enter="login" />
<button @click="login">登录</button>
</div>
<div v-else>
<div class="add">
<input v-model="newTitle" placeholder="新增一条任务" @keyup.enter="addTask" />
<button @click="addTask">新增</button>
</div>
<ul>
<li v-for="t in tasks" :key="t.id">
{{ t.title }}
<span class="meta">{{ t.created_at }}</span>
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.app { max-width: 480px; margin: 40px auto; font-family: sans-serif; }
.error { color: #e53935; }
.add { display: flex; gap: 8px; margin-bottom: 16px; }
input { flex: 1; padding: 8px; }
button { padding: 8px 16px; }
li { padding: 8px 0; border-bottom: 1px solid #eee; }
.meta { color: #999; font-size: 12px; margin-left: 8px; }
</style>
启动前端开发服务器:
npm run dev
浏览器打开它提示的地址(一般是 http://localhost:5173)。
第 4 步:前后端联调
跨域是指浏览器出于安全,默认不允许网页去访问跟它不同源(不同端口也算不同源)的接口。我们前端在 5173、后端在 3000,属于跨域。解决方式有两种:
- 后端开 cors(我们在
server.js里已经写了app.use(cors())),最省事,本教程就靠它。 - 前端用 Vite 的 proxy 把请求转发到后端,让浏览器以为同源。如果你以后不想暴露后端端口,可以这样配
vite.config.js:
// vite.config.js(可选写法,本教程不强制)
export default defineConfig({
server: { proxy: { '/api': 'http://localhost:3000' } }
})
用 proxy 时,前端 baseURL 写成 /api 即可,请求会被 Vite 转发给后端。
确认后端还在跑(终端 1),前端也在跑(终端 2)。打开 http://localhost:5173:输入 PIN 123456 → 点登录 → 应看到任务列表里那条「买婚戒」 → 在输入框打「订酒店」点新增 → 列表里立刻多出一条。这一整套跑通,就说明前后端联调成功。
第 5 步(可选):用 Docker 打包
本地能跑了,部署到服务器时通常用 Docker 把它打包成镜像再运行。这里只讲思路和关键点,不展开完整配置。
多阶段构建是指在同一个 Dockerfile 里分几步:先用一个大而全的镜像把代码编译好,再把编译产物拷进一个小镜像运行,这样最终镜像又小又干净。对这个项目大致分三块:
- 前端构建阶段:用
node镜像,npm ci && npm run build,产出dist/静态文件。 - 后端阶段:用
node镜像,装better-sqlite3等依赖,拷入server.js,启动node server.js。 - Nginx 反代阶段:Nginx 是一个高性能 Web 服务器,这里用它托管前端
dist/静态文件,同时把/api开头的请求反向代理(转发)给后端容器。也就是说浏览器只跟 Nginx 对话,由 Nginx 决定静态文件直接返回、接口请求转给后端。
再用 Docker Compose 编排:写一个 docker-compose.yml,定义 frontend(nginx + dist)和 backend(node)两个服务,让它们在同一个网络里互通;敏感信息如 PIN_CODE、JWT_SECRET 通过 Compose 的 environment 注入,而不是写死在代码里。这样换一对新人、换个婚期预算,只改环境变量、重新部署即可,这正是 GoToMarry 做个性化的方式。
小结
我们从零走了一遍:装 Node → 用 Express + SQLite 写了带 PIN/JWT 登录的后端 → 用 Vue 3 + Vite 写了登录和任务页面 → 解决跨域跑通联调 → 了解了 Docker 多阶段打包思路。掌握这套骨架后,你可以继续加:任务打勾完成、预算收支记账、按婚期倒计时等——GoToMarry 本体也就是在这个骨架上长出来的。
项目架构:为什么拆成前端、API 和数据层
GoToMarry 不是把所有逻辑塞进一个页面,而是按职责拆成三层:
Vue 3 / Vite
│ HTTP + JSON
▼
Express API ── JWT middleware ── route/controller
│
├── service:任务、预算、宾客、座位等业务规则
├── repository:统一数据库访问
▼
SQLite + Docker volume
这样拆分有三个实际收益:
- 前端可以独立迭代:页面只关心状态和交互,不直接拼 SQL。
- 接口可以被多个端复用:Web 页面、后续小程序或移动端都可以调用同一组 API。
- 数据持久化边界明确:容器可以重建,但 SQLite 数据放在 volume 中,不随镜像删除。
一次请求的完整数据流
以“新增备婚任务”为例,请求链路是:
- Vue 表单先做空值和长度校验。
- Axios 将 token 放入
Authorization: Bearer <token>。 - Express 中间件验证 JWT 签名、过期时间和角色。
- Controller 解析 DTO,Service 检查业务规则。
- SQLite 使用参数绑定写入任务,避免字符串拼接带来的注入风险。
- API 返回统一 JSON,Pinia 更新前端状态,页面只刷新必要列表。
建议统一响应结构,而不是每个接口返回不同格式:
{
"code": 0,
"message": "ok",
"data": { "id": 12, "title": "预订酒店", "done": false }
}
数据库设计与一致性
项目中的任务、预算、宾客、座位、礼金和相册数据应按领域拆表,公共字段统一使用:
id:整数主键,避免业务字段作为主键。created_at/updated_at:便于审计和排序。deleted_at:需要恢复能力时使用软删除。- 外键字段统一加索引,避免列表页随着数据增多变慢。
涉及预算和礼金的写操作必须使用事务。例如“新增礼金记录 + 更新礼金汇总”要么同时成功,要么同时回滚,不能出现明细已经写入但汇总数字没有变化的中间状态。
鉴权与安全边界
原教程里的 cors() 和默认密钥只适合本地演示,正式部署需要收紧:
PIN_CODE、JWT_SECRET只通过环境变量注入,不提交到 Git。- 生产环境随机生成至少 32 字节的 JWT secret。
- CORS 只允许正式前端域名,不使用
*。 - JWT 设置合理过期时间,前端收到 401 后清理 token 并回到登录页。
- 生产环境使用 HTTPS,避免 PIN 和 token 在网络中明文传输。
- 所有写接口做长度、类型、范围校验,错误响应不要返回堆栈。
- 上传图片限制 MIME、大小和数量,文件名不能直接使用用户输入。
Docker 生产化配置
推荐使用三个容器职责:
services:
web:
image: nginx:alpine
depends_on: [api]
ports: ['80:80']
api:
build: ./backend
environment:
PIN_CODE: ${PIN_CODE}
JWT_SECRET: ${JWT_SECRET}
volumes:
- app-data:/app/data
# SQLite 文件只挂载到 api,web 不直接访问数据库
volumes:
app-data:
真正使用时要补充:健康检查、日志输出、数据库备份和镜像版本号。更新流程建议是“构建新镜像 → 健康检查 → 切换容器”,不要直接在运行容器里修改代码。
测试清单
| 层次 | 必测内容 |
|---|---|
| 单元测试 | PIN 校验、任务状态转换、金额计算、日期倒计时 |
| API 测试 | 登录、401、参数校验、增删改查、重复提交 |
| 数据库测试 | 事务回滚、并发写入、备份恢复 |
| E2E 测试 | 登录 → 新增任务 → 刷新页面 → 数据仍存在 |
| 安全测试 | 错误 token、过期 token、超长输入、非法文件上传 |
复盘:从 Demo 到可维护产品还要补什么
当前项目已经覆盖了一个完整全栈应用的主链路,但如果继续产品化,我会优先补:
- API 版本号,例如
/api/v1/tasks。 - OpenAPI 文档,让前后端接口可自动校验。
- 数据库迁移工具,而不是只依赖启动时
CREATE TABLE IF NOT EXISTS。 - 操作日志和错误追踪,方便定位线上问题。
- 图片对象存储和缩略图处理,避免 SQLite 文件无限增长。
- 定时备份和恢复演练,验证备份真正可用。
评论
还没有评论,来留第一条吧。