去年接手学校图书馆的座位管理系统改造项目时,我惊讶地发现许多高校仍在使用传统的纸质登记本管理自习座位。这种低效的管理方式直接导致了占座现象泛滥、座位利用率低下等问题。于是我们团队决定开发一套基于Vue3+Node.js的智能选座系统,上线后使座位周转率提升了210%。本文将完整还原这个项目的技术实现细节,特别适合需要开发类似场馆预约系统的同行参考。
这个全栈系统采用前后端分离架构,前端使用Vue3组合式API开发响应式界面,Element Plus提供专业的UI组件库,后端基于Express框架构建RESTful API。系统最核心的实时座位状态功能通过WebSocket实现,确保所有终端能在300ms内同步最新座位状态。数据库方面我们最终选择了MySQL而非MongoDB,主要是考虑到事务处理和数据一致性的需求。
在技术选型阶段我们对比了多种方案,最终确定的技术组合经过了严密的性能测试:
前端选择Vue3+Element Plus的原因:
后端选择Node.js+Express的考量:
数据库选型对比:
| 特性 | MySQL | MongoDB |
|---|---|---|
| 事务支持 | ACID完备 | 有限支持 |
| 查询性能 | 优(索引优化) | 优 |
| 数据结构 | 固定表结构 | 灵活文档 |
| 扩展性 | 垂直扩展 | 水平扩展 |
最终选择MySQL是因为:
系统采用经典的三层架构,但加入了实时通信层:
code复制[前端层]
├── Vue3 + Vite
├── Element Plus
├── Vue Router
└── Pinia状态管理
[API网关层]
├── Express路由
├── JWT认证
└── 请求限流
[业务逻辑层]
├── 预约服务
├── 座位管理
└── 用户服务
[数据访问层]
├── Sequelize ORM
├── MySQL连接池
└── Redis缓存
[实时通信层]
└── WebSocket服务
关键设计决策:
在最初的数据库设计中,我们忽略了座位状态的历史追踪,导致无法统计座位利用率。改进后的完整DDL如下:
sql复制CREATE TABLE `users` (
`id` int NOT NULL AUTO_INCREMENT,
`student_id` varchar(20) NOT NULL COMMENT '学号',
`password` varchar(255) NOT NULL,
`name` varchar(50) NOT NULL,
`college` varchar(100) DEFAULT NULL,
`role` enum('admin','user') DEFAULT 'user',
`credit_score` tinyint DEFAULT 100 COMMENT '信用分',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_student_id` (`student_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `study_rooms` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`floor` tinyint NOT NULL COMMENT '所在楼层',
`open_time` time NOT NULL,
`close_time` time NOT NULL,
`total_seats` smallint NOT NULL,
`status` enum('open','closed','maintenance') DEFAULT 'open',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `seats` (
`id` int NOT NULL AUTO_INCREMENT,
`room_id` int NOT NULL,
`number` varchar(10) NOT NULL COMMENT '如A01',
`type` enum('normal','standing','outlet') DEFAULT 'normal',
`x_position` smallint NOT NULL COMMENT '前端坐标X',
`y_position` smallint NOT NULL COMMENT '前端坐标Y',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_room_seat` (`room_id`,`number`),
CONSTRAINT `fk_seat_room` FOREIGN KEY (`room_id`) REFERENCES `study_rooms` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `reservations` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`seat_id` int NOT NULL,
`start_time` datetime NOT NULL,
`end_time` datetime NOT NULL,
`actual_end_time` datetime DEFAULT NULL COMMENT '实际离开时间',
`status` enum('reserved','in_use','completed','cancelled','expired') DEFAULT 'reserved',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_reservations` (`user_id`,`status`),
KEY `idx_seat_time` (`seat_id`,`start_time`,`end_time`),
CONSTRAINT `fk_reservation_seat` FOREIGN KEY (`seat_id`) REFERENCES `seats` (`id`),
CONSTRAINT `fk_reservation_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `seat_status_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`seat_id` int NOT NULL,
`status` enum('available','occupied','maintenance') NOT NULL,
`changed_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`reservation_id` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_seat_log` (`seat_id`,`changed_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
索引策略:
分表设计:
缓存方案:
javascript复制// Redis缓存座位状态
const cacheSeats = async (roomId) => {
const seats = await Seat.findAll({ where: { room_id: roomId } });
await redis.setex(`room:${roomId}:seats`, 3600, JSON.stringify(seats));
};
// 使用管道批量处理
const pipeline = redis.pipeline();
seats.forEach(seat => {
pipeline.hset(`seat:${seat.id}`, 'status', seat.status);
});
await pipeline.exec();
我们放弃了传统的表格展示方式,改用SVG实现真实的座位布局:
vue复制<template>
<div class="seat-map">
<svg :viewBox="`0 0 ${width} ${height}`">
<g v-for="seat in seats" :key="seat.id">
<rect
:x="seat.x_position"
:y="seat.y_position"
width="40"
height="40"
rx="5"
:class="['seat', seat.status]"
@click="handleSelect(seat)"
/>
<text
:x="seat.x_position + 20"
:y="seat.y_position + 25"
class="seat-number"
>
{{ seat.number }}
</text>
</g>
</svg>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useSeatStore } from '@/stores/seat';
const seatStore = useSeatStore();
const seats = ref([]);
const width = ref(800);
const height = ref(600);
onMounted(async () => {
seats.value = await seatStore.fetchSeats(roomId.value);
// WebSocket监听状态更新
socket.on('seatUpdate', (data) => {
const seat = seats.value.find(s => s.id === data.seatId);
if (seat) seat.status = data.status;
});
});
</script>
<style>
.seat {
fill: #eee;
stroke: #ccc;
cursor: pointer;
transition: all 0.3s;
}
.seat.available {
fill: #67c23a;
}
.seat.occupied {
fill: #f56c6c;
}
.seat:hover {
opacity: 0.8;
}
</style>
我们实现了三步预约流程,并添加了防重复提交机制:
选择时间段:
javascript复制const disabledHours = (date) => {
const hour = date.getHours();
return hour < 8 || hour >= 22; // 只允许8:00-22:00
};
座位锁定机制:
javascript复制const lockSeat = async (seatId) => {
const result = await redis.set(`seat:${seatId}:lock`, '1', 'EX', 30, 'NX');
return result === 'OK';
};
预约确认:
javascript复制const confirmReservation = async () => {
if (!await lockSeat(selectedSeat.value.id)) {
ElMessage.error('该座位正在被其他用户预约');
return;
}
try {
const res = await reserveSeat({
seatId: selectedSeat.value.id,
startTime: selectedTime.value[0],
endTime: selectedTime.value[1]
});
socket.emit('newReservation', { seatId: selectedSeat.value.id });
} finally {
await redis.del(`seat:${seatId}:lock`);
}
};
基础认证中间件增强版:
javascript复制const jwtAuth = async (req, res, next) => {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ code: 1001, message: '请提供认证令牌' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findByPk(decoded.userId);
if (!user || user.tokenVersion !== decoded.version) {
throw new Error('令牌已失效');
}
req.user = user;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ code: 1002, message: '令牌已过期' });
}
return res.status(401).json({ code: 1003, message: '无效令牌' });
}
};
// 令牌刷新接口
router.post('/refresh-token', async (req, res) => {
const { refreshToken } = req.body;
try {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
const user = await User.findByPk(decoded.userId);
if (!user || user.refreshVersion !== decoded.version) {
throw new Error('刷新令牌无效');
}
const newAccessToken = generateAccessToken(user);
res.json({ accessToken: newAccessToken });
} catch (err) {
res.status(401).json({ code: 1004, message: '刷新令牌失败' });
}
});
核心冲突检测逻辑:
javascript复制const checkAvailability = async (seatId, startTime, endTime) => {
const overlapping = await Reservation.findAll({
where: {
seat_id: seatId,
status: ['reserved', 'in_use'],
[Op.or]: [
{ start_time: { [Op.lt]: endTime }, end_time: { [Op.gt]: startTime } },
{ start_time: { [Op.between]: [startTime, endTime] } },
{ end_time: { [Op.between]: [startTime, endTime] } }
]
}
});
return overlapping.length === 0;
};
javascript复制const WebSocket = require('ws');
const { verify } = require('jsonwebtoken');
const wss = new WebSocket.Server({ noServer: true });
// 房间-客户端映射
const roomClients = new Map();
wss.on('connection', (ws, request) => {
const { roomId, token } = request.query;
try {
const decoded = verify(token, process.env.JWT_SECRET);
ws.userId = decoded.userId;
if (!roomClients.has(roomId)) {
roomClients.set(roomId, new Set());
}
roomClients.get(roomId).add(ws);
ws.on('close', () => {
roomClients.get(roomId)?.delete(ws);
});
// 心跳检测
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
} catch (err) {
ws.close(1008, '认证失败');
}
});
// 心跳检测
setInterval(() => {
wss.clients.forEach(ws => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping(null, false, true);
});
}, 30000);
// 广播座位更新
function broadcastSeatUpdate(roomId, seatId, status) {
const clients = roomClients.get(roomId);
if (!clients) return;
const message = JSON.stringify({
type: 'seatUpdate',
seatId,
status,
timestamp: Date.now()
});
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
封装健壮的WebSocket客户端:
javascript复制class SocketService {
constructor() {
this.socket = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.listeners = new Map();
}
connect(token, roomId) {
return new Promise((resolve, reject) => {
const params = new URLSearchParams({ token, roomId });
this.socket = new WebSocket(`wss://example.com/ws?${params}`);
this.socket.onopen = () => {
this.reconnectAttempts = 0;
this.heartbeat();
resolve();
};
this.socket.onmessage = (event) => {
const data = JSON.parse(event.data);
const handlers = this.listeners.get(data.type) || [];
handlers.forEach(handler => handler(data));
};
this.socket.onclose = () => {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
setTimeout(() => {
this.reconnectAttempts++;
this.connect(token, roomId);
}, this.reconnectDelay * Math.pow(2, this.reconnectAttempts));
}
};
});
}
on(eventType, handler) {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, []);
}
this.listeners.get(eventType).push(handler);
}
heartbeat() {
if (!this.socket) return;
this.pingInterval = setInterval(() => {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: 'ping' }));
}
}, 25000);
}
disconnect() {
clearInterval(this.pingInterval);
if (this.socket) {
this.socket.close();
this.socket = null;
}
}
}
export const socketService = new SocketService();
输入验证:
javascript复制const { body } = req;
const schema = Joi.object({
seatId: Joi.number().integer().min(1).required(),
startTime: Joi.date().iso().min('now').required(),
endTime: Joi.date().iso().min(Joi.ref('startTime')).required()
});
const { error } = schema.validate(body);
if (error) return res.status(400).json({ code: 2001, message: error.details[0].message });
防刷单策略:
SQL注入防护:
javascript复制const [results] = await sequelize.query(
'SELECT * FROM reservations WHERE user_id = ? AND status = ?',
{ replacements: [userId, 'active'] }
);
Nginx配置优化:
nginx复制# 前端静态资源
location / {
gzip on;
gzip_types text/plain text/css application/json application/javascript;
try_files $uri $uri/ /index.html;
}
# API反向代理
location /api {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header X-Real-IP $remote_addr;
}
# WebSocket代理
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
数据库连接池配置:
javascript复制const sequelize = new Sequelize(/* ... */, {
pool: {
max: 50,
min: 5,
acquire: 30000,
idle: 10000
},
define: {
timestamps: true,
paranoid: true,
underscored: true
}
});
完整的docker-compose.yml配置:
yaml复制version: '3.8'
services:
frontend:
build: ./frontend
ports:
- "3000:80"
environment:
- VITE_API_BASE_URL=https://api.example.com
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
backend:
build: ./backend
ports:
- "4000:4000"
environment:
- NODE_ENV=production
- DB_HOST=mysql
- REDIS_HOST=redis
depends_on:
- mysql
- redis
deploy:
resources:
limits:
cpus: '1'
memory: 1G
mysql:
image: mysql:8.0
environment:
- MYSQL_ROOT_PASSWORD=securepassword
- MYSQL_DATABASE=studyroom
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:6
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./certs:/etc/ssl/certs
depends_on:
- frontend
- backend
volumes:
mysql_data:
redis_data:
Prometheus监控指标:
javascript复制const client = require('prom-client');
const collectDefaultMetrics = client.collectDefaultMetrics;
collectDefaultMetrics({ timeout: 5000 });
const httpRequestDuration = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'code'],
buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10]
});
app.use((req, res, next) => {
const end = httpRequestDuration.startTimer();
res.on('finish', () => {
end({ method: req.method, route: req.route?.path || req.path, code: res.statusCode });
});
next();
});
app.get('/metrics', async (req, res) => {
res.set('Content-Type', client.register.contentType);
res.end(await client.register.metrics());
});
日志收集方案:
javascript复制const { createLogger, transports, format } = require('winston');
const { combine, timestamp, printf } = format;
const logFormat = printf(({ level, message, timestamp, stack }) => {
return `${timestamp} [${level}]: ${stack || message}`;
});
const logger = createLogger({
level: 'info',
format: combine(
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.errors({ stack: true }),
logFormat
),
transports: [
new transports.File({ filename: 'logs/error.log', level: 'error' }),
new transports.File({ filename: 'logs/combined.log' }),
new transports.Console({
format: combine(format.colorize(), logFormat)
})
]
});
// 使用示例
app.use((err, req, res, next) => {
logger.error(`${req.method} ${req.url}`, { stack: err.stack });
res.status(500).json({ code: 5000, message: '服务器错误' });
});
在实际开发中我们遇到了几个关键问题:
WebSocket内存泄漏:
高并发下的座位冲突:
javascript复制const acquireLock = async (key, ttl = 10) => {
const lock = await redis.set(key, '1', 'NX', 'EX', ttl);
return lock === 'OK';
};
性能实测数据:
| 场景 | 请求量 | 平均响应时间 | 错误率 |
|---|---|---|---|
| 座位查询(无缓存) | 1000 | 78ms | 0% |
| 预约提交(带锁) | 500 | 120ms | 1.2% |
| WebSocket消息广播 | - | 200ms延迟 | - |
前端性能优化成果:
这套系统最终在上线后稳定支撑了日均5000+的预约量,最令我自豪的是通过技术手段解决了长期存在的占座问题。如果你正在开发类似系统,我的建议是:前期重点设计好数据模型和状态流转,中期专注解决并发冲突问题,后期做好监控和性能优化。