打开网络购票系统那一刻,用户不会关心你后端用了什么框架、数据库设计了几个表、接口是 RESTful 还是 RPC。他只知道——我要在 30 秒内选好座位、下单、看到出票成功。而你作为开发者,最怕的恰恰是 30 秒之后,系统在并发下出现重复卖座、订单数据错乱、前端页面白屏。
这篇文章不聊虚的,直接拆一个完整的在线电影票购买系统。技术栈固定为后端 Node.js + Express + MySQL,前端 Vue + ElementUI,题目标签叫 express-mysql。我尽量把项目里能遇到的高频问题和设计思路全部过一遍,从环境搭建到数据库表设计,从 JWT 登录鉴权到选座事务,从 Vue 路由到 m3u8 预告片播放,最后附一份实测过的报错排查清单。适合正在做课程设计、毕业设计,或者准备从前端转全栈的朋友参考,照着这套逻辑走完,你不仅能跑通项目,还能在答辩或者面试时把每一个技术选型的原因说清楚。
1. 项目整体设计与技术选型思路
1.1 先想清楚系统要做哪些事
很多人一上来就写代码,写到一半发现模块之间互相纠缠,这是最浪费时间的事。做电影票购买系统,第一步不是选框架,而是盘点业务边界。
一个标准的在线购票系统,用户端至少要覆盖这些路径:用户注册登录、首页电影列表、电影详情、选择场次、选择座位、确认订单、模拟支付、个人订单列表。管理端就是反过来:维护电影信息、配置场次和影厅、查看订单。我把范围控制在这几条主线上,刻意没做支付回调、退票流程、会员积分这些附加功能,原因是作为一个教学型全栈项目,核心链路越清晰,越能体现你对业务和技术栈的掌控力。等后面你想扩展,加功能也容易,但一开始范围太散反而会让代码结构变得混乱。
这个系统最核心的难点只有一个——选座并发问题。也就是两个用户同时在同一个场次买同一个座位,系统必须保证只有一个能成功。这里会用到 MySQL 事务和行锁,这是我在后面会重点展开的部分。
1.2 为什么是 Node.js + Express + MySQL + Vue + ElementUI
技术选型不是越新越好,而是越合适越好。
后端用 Node.js + Express,原因很简单:前端同学本来就会 JavaScript,再去学 Java 和 Spring Boot 的学习成本太高。而且 Node.js 在处理高 I/O 并发场景下表现不差,购票系统的请求基本都是读多写少,Node 的异步模型完全扛得住。Express 作为 Node 生态里最成熟的 Web 框架,中间件机制简单清晰,路由写法直观,对中小型项目来说足够稳定。如果你用过 Koa,也不冲突,Koa 的洋葱模型更灵活,但 Express 的生态和资料更全,课程设计和技术选型答辩的时候,Express 几乎不会被人质疑。
数据库方面我坚持选 MySQL 而不是 MongoDB。订单和座位这种强事务数据,必须保证 ACID 特性,尤其卖座这种操作,一旦用文档型数据库做非原子更新,出错的概率极高。MySQL 在关系型数据持久化上的成熟度无需多言,加上 InnoDB 引擎默认支持行级锁,做事务控制非常顺手。
前端用 Vue + ElementUI 是另一个省心组合。Vue 的响应式数据流让页面状态管理变得直观,ElementUI 则把表格、表单、弹窗、消息提示这些高频组件全都封装好了。你不需要从头写一个日期选择器,也不用自己调样式调半天。选它最大的理由是效率,一天能完成的界面,你不需要花三天。
相比之下,如果你用 React + Antd 也能实现同样效果,但如果你以前没写过 React,临阵学习 JSX 和 Hooks 的时间成本会明显拉高。技术没有绝对好坏,关键是在有限时间里把系统做出来、说清楚。
1.3 前后端分离的项目目录结构
这套系统我采用前后端分离,后端只提供 JSON 接口,前端通过 axios 调用。分离的好处是职责边界清晰,你启动两个工程互不干扰,将来部署时可以把前端打包后的 dist 目录托管到 Nginx,后端单独跑一个 Node 服务。
后端项目按 MVC 思路分层,我整理的目录结构大致是这样:
server/ ├── app.js # Express 入口 ├── config/ │ └── db.js # MySQL 连接池配置 ├── routes/ # 路由层 │ ├── user.routes.js │ ├── movie.routes.js │ ├── session.routes.js │ └── order.routes.js ├── controllers/ # 控制器层,处理请求参数、调用 service、返回响应 ├── services/ # 业务逻辑层,如选座事务、订单生成 ├── models/ # 数据访问层,封装 SQL 查询 ├── middlewares/ # 中间件,如 JWT 校验、错误处理 ├── utils/ # 工具函数,如统一响应格式 └── uploads/ # 静态资源,如电影海报、预告片前端项目以 Vue 单页应用组织,核心目录:
web/ ├── src/ │ ├── api/ # axios 接口封装 │ ├── router/ # Vue Router 配置 │ ├── views/ # 页面组件 │ ├── components/ # 公共组件,如电影卡片、座位图 │ ├── stores/ # Pinia 或 Vuex 状态管理 │ └── utils/ # 请求拦截器、工具函数 ├── vite.config.js # Vite 配置,含代理 └── package.json有读者可能会问,Controller、Service、Model 三层是不是过度设计?我的体会是:如果一个接口只有两行 SQL,三层确实显得繁琐。但一旦出现选座这样的逻辑,你要在一个接口里完成查场次、锁座位、插入订单、更新座位状态等多个步骤,没有 Service 层,这段逻辑就会全部堆在 Controller 里,后期想维护几乎等于重写。分层是为了给复杂业务留出收容空间,代价是一点代码量,这个投入值得。
2. 开发环境准备与高频踩坑实录
2.1 Node.js 安装与环境变量配置
开发前第一件事就是装 Node.js。很多人对这个环节不以为然,实际上我见过太多同学在安装环节就卡了两小时,所以这里单独拎出来说。
去 Node.js 官网下载 LTS 版本安装包,注意是 LTS 而不是 Current。LTS 代表长期维护版,稳定性有保障,Current 版本虽然新特性多,但可能存在兼容性问题,不一定适合项目开发。下载 msi 安装包后,双击安装,一路默认,但有一个关键勾选必须确认——"Add to PATH" 这项必须选中,否则安装完后你在命令行执行 node -v 会提示找不到命令。
安装完成后,打开命令行(Windows 推荐用 cmd 或者 PowerShell)输入:
node -v npm -v能正常输出版本号,说明 Node 装好了。npm 会随着 Node 一起安装,它是 Node 的包管理器,后面所有依赖都靠它下载。如果你发现 node 能执行但 npm 报错,大概率不是安装问题,而是环境变量里 npm 路径没配好,检查一下 PATH 是否包含 Node 的安装目录。
如果你以前装过低版本 Node,现在想升级,最省心的方式不是卸载重装,而是用 nvm(Node Version Manager)做版本管理。nvm 能在同一台机器上安装多个 Node 版本,随时切换,对于以后需要维护多个项目的人来说非常实用。
2.2 npm.ps1 无法加载脚本的解决方案
这是 Windows 上出现频率最高的报错之一,完整信息如下:
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。 有关详细信息,请参阅 about_Execution_Policies。原因不是 npm 没装好,而是 PowerShell 的执行策略默认是 Restricted,禁止运行 .ps1 脚本文件。npm 命令在 PowerShell 里实际是由 npm.ps1 这个脚本执行的,所以被拦截了。
解决办法有三种,你按自己的情况选一个就好。
第一种,管理员身份打开 PowerShell,执行:
Set-ExecutionPolicy RemoteSigned输入 Y 确认。这个命令允许本机脚本运行,但要求从网上下载的脚本必须有数字签名,对正常开发没有任何影响。
第二种,直接用 CMD 代替 PowerShell。打开命令提示符,输入 npm -v,你会发现正常得很。因为 CMD 不执行 .ps1 脚本,它执行的是 npm.cmd,完全绕过了 PowerShell 的限制。
第三种,如果你是用 VS Code 的内置终端遇到这个问题,直接把默认终端切换为 CMD 或 Git Bash,效果一样。
我自己的习惯是直接把 PowerShell 执行策略改成 RemoteSigned,一次性解决问题。改完后重启终端,npm 就能正常使用了。
2.3 MySQL 安装配置与 Workbench 使用
数据库端建议使用 MySQL 8.0 版本。官网下载有 msi 安装包和免安装版 zip 两种类型,对新手来说 msi 安装包最友好,图形化向导一步一步来。
安装时选 Server only 就够用了,不必勾选 Development Default,后者会把一堆用不到的 MySQL 相关组件全部装进来,白白占用磁盘。设置 root 密码时一定要记住,后面连接数据库全靠它。字符集选择默认的 utf8mb4 就行,能正确存储中文和 emoji。MySQL 8.0 默认使用 caching_sha2_password 认证插件,如果你遇到连接工具连不上的情况,优先排查这里是否兼容。
安装完成后在命令行验证:
mysql -u root -p输入密码进入 MySQL 命令行,说明服务正常。接着用 Workbench 创建数据库,或者直接在命令行执行:
CREATE DATABASE movie_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;这里我强烈建议你装一个 MySQL Workbench,它是官方免费的可视化工具,建表、写 SQL、查看数据都方便。命令行虽然也能操作,但需要你记住所有语句,可视化工具能大幅减少低级错误。
免安装版 zip 适合想更深入理解 MySQL 初始化流程的同学。下载 zip 包后解压,在解压目录里新建 my.ini 配置文件,然后执行 mysqld --initialize-insecure 初始化数据目录,最后通过 net start mysql 启动服务。这个过程稍麻烦,但会强迫你理解 MySQL 的目录结构和启动逻辑。
2.4 安装 Node.js 报错 2203 的排查记录
热词里有一个高频问题——安装 Node.js 报错 2203。我在帮朋友排查时遇到过一次,错误提示为 "The installer has encountered an unexpected error installing this package. This may indicate a problem with this package. The error code is 2203."。
这个错误通常是 Windows Installer 服务异常或者临时目录权限不对导致的。排查思路是:先用管理员身份运行命令提示符,执行 services.msc 确认 Windows Installer 服务是启动状态;然后清理 C:\Windows\Installer 临时文件目录,注意不要删除扩展名为 .msi 的缓存文件;最后重点检查 C:\Program Files\nodejs 目录是否残留了旧版本文件的只读权限,之前装过 Node 但没卸载干净,也容易触发这个错误。
最直接的笨办法是把旧 Node 彻底卸载干净,然后清理注册表残留后再装一次。整个过程比较费时间,但能一次排查到底。关于 Node.js 的环境配置,我整理了一个自查清单,你在安装前过一遍:
| 检查项 | 操作 | 验证方式 |
|---|---|---|
| PATH 是否包含 Node 目录 | 系统环境变量里手动添加 | cmd 执行node -v |
| npm 可用性 | 安装完成后测试 | cmd 执行npm -v |
| 镜像源是否稳定 | 设置淘宝镜像 | npm config set registry https://registry.npmmirror.com |
| 权限问题 | 以管理员身份运行安装器和终端 | 确认不报 2203 错误 |
镜像源那一步建议先配好,不然 npm install 下载依赖会非常慢,尤其国内网络环境下。
3. 数据库设计与 Express 后端实现
3.1 MySQL 表结构设计与字段意图
数据库设计是整个系统的基础,表结构不合理,后面代码写得再优雅也白搭。这个电影票系统我按业务拆分出 5 张核心表:用户表、电影表、场次表、座位表和订单表。
用户表 users:
CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, nickname VARCHAR(50) DEFAULT '', avatar VARCHAR(255) DEFAULT '', phone VARCHAR(20) DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;username 设置了唯一索引,登录注册时按用户名查询会走索引,不用再做用户名重复校验。password 字段长度设置成 255,是因为后面要存 bcryptjs 加密后的结果,加密串长度远超过明文密码的长度范围。
电影表 movies:
CREATE TABLE movies ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(100) NOT NULL, poster VARCHAR(255) DEFAULT '', trailer_url VARCHAR(255) DEFAULT '', description TEXT, duration INT DEFAULT 0 COMMENT '时长(分钟)', release_date DATE, rating DECIMAL(3,1) DEFAULT 0.0, status TINYINT DEFAULT 1 COMMENT '1-上映中 0-下架' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;我加了一个 trailer_url 字段,用来存 m3u8 格式的电影预告片地址,这个字段对应前端用 hls.js 播放 m3u8 视频的需求。rating 字段用 DECIMAL(3,1),比如 8.5 分,用 FLOAT 可能会存在精度误差,评分这类数据用 DECIMAL 更严谨。
场次表 sessions:
CREATE TABLE sessions ( id INT AUTO_INCREMENT PRIMARY KEY, movie_id INT NOT NULL, hall_name VARCHAR(50) NOT NULL COMMENT '影厅名称', start_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, total_seats INT DEFAULT 80, remaining_seats INT DEFAULT 80, FOREIGN KEY (movie_id) REFERENCES movies(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;total_seats 和 remaining_seats 是冗余字段,我故意保留的。购票成功后,remaining_seats 减一,前端列表页直接读这个字段就可以显示余票状态,不需要临时连表 count 座位表。这类冗余在场次这种读多写少场景下,利大于弊。
座位表 seats:
CREATE TABLE seats ( id INT AUTO_INCREMENT PRIMARY KEY, session_id INT NOT NULL, row_no INT NOT NULL COMMENT '行号', col_no INT NOT NULL COMMENT '列号', status TINYINT DEFAULT 0 COMMENT '0-可选 1-已锁定 2-已售出', order_id INT DEFAULT NULL, FOREIGN KEY (session_id) REFERENCES sessions(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;status 字段是系统防止超卖的核心。0 代表空闲,1 代表用户正在锁定但还没下单,2 代表已经支付成功。每次购票动作都会对 status 做条件更新,这是后面的关键逻辑。
订单表 orders:
CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(64) NOT NULL UNIQUE, user_id INT NOT NULL, session_id INT NOT NULL, seat_id INT NOT NULL, movie_title VARCHAR(100) NOT NULL, hall_name VARCHAR(50) NOT NULL, start_time DATETIME NOT NULL, seat_label VARCHAR(20) NOT NULL COMMENT '座位号,如 5排3座', amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT '0-待支付 1-已支付 2-已取消', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_id (user_id), KEY idx_session_id (session_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;movie_title、hall_name、start_time 这几个字段看似冗余,实际非常必要。因为它们来自多张关联表,如果只存 id,下单后如果电影信息被改,订单里显示的内容就会跟着变,对用户来说这是不可接受的。把关键快照存进订单表,是电商项目的通用做法。
3.2 Express 路由分层与数据库连接池
后端入口文件我推荐用 app.js 把一个 Express 应用组装起来:
const express = require('express'); const cors = require('cors'); const path = require('path'); const app = express(); app.use(cors()); app.use(express.json()); app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.use('/api/user', require('./routes/user.routes')); app.use('/api/movie', require('./routes/movie.routes')); app.use('/api/session', require('./routes/session.routes')); app.use('/api/order', require('./routes/order.routes')); app.use((err, req, res, next) => { console.error(err); res.status(500).json({ code: 500, message: '服务器内部错误' }); }); app.listen(3000, () => { console.log('Server running at http://localhost:3000'); });跨域用 cors 中间件直接解决,不需要自己手写 response header。上传的海报图、预告片放到 uploads 目录下,通过 express.static 托管成静态资源,前端可以直接用 URL 访问。
数据库连接这块要重点说。很多人初学时直接每次请求都创建一条新连接,用完就关闭,这样在开发环境看着没问题,一旦并发量上来,数据库会频繁创建连接导致性能急剧下降,甚至报 Too many connections 错误。正确的做法是使用连接池。我用 mysql2 的 promise 版本,它能配合 async/await 写出非常清爽的异步代码:
const mysql = require('mysql2/promise'); const pool = mysql.createPool({ host: 'localhost', user: 'root', password: '你的密码', database: 'movie_ticket', waitForConnections: true, connectionLimit: 10, queueLimit: 0, charset: 'utf8mb4' }); module.exports = pool;connectionLimit 设成 10,queueLimit 设成 0 表示不限制排队数量。waitForConnections 表示连接不够用时请求进入等待队列,而不是直接报错。这套配置对课程设计和中小型项目完全够用。
3.3 登录注册与 JWT 鉴权的完整流程
用户密码绝不能明文存储,这是底线。我使用 bcryptjs 哈希,它内部自动处理加盐逻辑,安全性比直接 MD5 高得多。
注册接口的 service 层核心代码:
const bcrypt = require('bcryptjs'); const pool = require('../config/db'); async function register(username, password) { const hashedPassword = await bcrypt.hash(password, 10); const [result] = await pool.execute( 'INSERT INTO users (username, password) VALUES (?, ?)', [username, hashedPassword] ); return result.insertId; }bcrypt.hash 的第二个参数 10 是 saltRounds,意思是哈希计算的迭代次数。数字越大越安全,但耗时也越长,10 是通行配置,兼顾安全和性能。
登录校验成功之后,签发 JWT。我选择 jsonwebtoken:
const jwt = require('jsonwebtoken'); const token = jwt.sign( { userId: user.id, username: user.username }, process.env.JWT_SECRET, { expiresIn: '7d' } );为什么不选 session 保存登录态?核心原因是前后端分离后,后端不维护会话状态,JWT 天然无状态,适合分布式部署。token 在有效期内不需要查数据库验证,除非你要做 token 黑名单。
后面前端每次请求都会在 Authorization 头带上 token,后端用一个中间件统一校验:
function authMiddleware(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader) return res.status(401).json({ code: 401, message: '未登录' }); const token = authHeader.split(' ')[1]; try { const payload = jwt.verify(token, process.env.JWT_SECRET); req.userId = payload.userId; next(); } catch (err) { return res.status(401).json({ code: 401, message: '登录已过期' }); } }这里有一个容易被忽略的细节——JWT 密钥绝对不能硬编码在源码里。用环境变量管理,创建一个 .env 文件,通过 dotenv 加载。项目提交到代码仓库时一定要把 .env 加入 .gitignore。密钥泄露意味着任何人都能伪造登录身份,这是常识性安全底线的错误。
3.4 选座与下单的 MySQL 事务处理
选座是整个系统最核心的接口,也是技术答辩时最值得讲的一个点。我在这个接口里把查询场次、锁定座位、创建订单这系列操作全部放进一个事务里,保证要么全部成功要么全部失败。
首先说为什么需要事务。想象一下,一场电影只剩最后一个座位,小明和小红同时点击购票。如果没有事务,两个人可能同时读到座位状态是空闲,然后同时更新成已售出,最后两个人都下单成功了。这在实际业务中是绝对不能发生的。MySQL InnoDB 引擎通过事务和行锁能解决这个问题。
我的下单逻辑代码是这样:
const conn = await pool.getConnection(); try { await conn.beginTransaction(); // 锁定座位,条件更新:仅当该座位当前为空闲状态才更新为已锁定 const [lockResult] = await conn.execute( `UPDATE seats SET status = 1, order_id = NULL WHERE id = ? AND session_id = ? AND status = 0`, [seatId, sessionId] ); if (lockResult.affectedRows === 0) { await conn.rollback(); return { code: 1, message: '该座位已被选走' }; } // 插入订单 const [orderResult] = await conn.execute( `INSERT INTO orders (order_no, user_id, session_id, seat_id, movie_title, hall_name, start_time, seat_label, amount, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)`, [orderNo, userId, sessionId, seatId, movieTitle, hallName, startTime, seatLabel, amount] ); // 更新场次余票 await conn.execute( 'UPDATE sessions SET remaining_seats = remaining_seats - 1 WHERE id = ?', [sessionId] ); await conn.commit(); } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); }关键在于 UPDATE seats ... WHERE status = 0 这个条件更新,它利用了 InnoDB 的行锁机制。当一个事务执行这条 UPDATE 并锁定该行时,另一个事务对同一行执行相同更新会阻塞,直到第一个事务提交或回滚。两个并发请求最终只有一个 affectedRows 为 1,另一个为 0,被判定为选座失败。这就从数据库层面杜绝了超卖。
这个接口里我不推荐把整个业务封装成一个 MySQL 存储过程,因为 Node 层做事务已经足够清晰易维护,存过过程反而把逻辑隐藏在数据库层,排查问题时需要切换上下文。存储过程适合那些大量数据搬运的定时任务,在这种实时性要求高的场景里,靠事务加行锁反而更可控。
订单号生成我用了时间戳加随机数的方式:
const orderNo = Date.now().toString(36) + Math.random().toString(36).substring(2, 8).toUpperCase();生成结果类似 m8xj1aK2XpD,唯一性足够。如果担心极端并发下重复,可以在 order_no 字段上加唯一索引,插入时捕获冲突异常再做一次重试。
3.5 接口统一响应格式与全局错误处理
后端返回给前端的数据格式如果不统一,前端写接口封装时会非常痛苦。我固定使用这样一套响应结构:
// 成功 { code: 0, message: 'success', data: {} } // 失败 { code: 1, message: '具体错误信息', data: null }所有控制器都遵循这个格式,前端 axios 拦截器里只处理 code 为 0 的情况,其他情况直接弹错误提示。这个约定能大量减少前端对异常场景的重复判断。
全局错误处理中间件放在所有路由之后、监听之前。具体代码在 3.2 的入口文件里已经写过,这里不再重复。有一点要强调的是:生产环境下不要把 err.stack 直接返回给前端,这等于把后端源码结构泄露给了攻击者。控制台里打印完整堆栈,返回给前端的只给一个通用提示。
4. Vue + ElementUI 前端实现
4.1 工程初始化与 ElementUI 按需引入
前端我建议用 Vite 而不是 Vue CLI。Vite 启动速度比 Webpack 快一个量级,配置文件也更简洁,现在 Vue 官方生态已经完全转向 Vite。执行下面的命令创建项目:
npm create vite@latest web -- --template vue安装依赖:
cd web npm install npm install vue-router@4 axios element-ui@2 pinia这里要认真说一个坑:ElementUI 有两个大版本存在兼容性差异。ElementUI(2.x)对应 Vue 2,Element Plus(1.x/2.x)对应 Vue 3。这套系统用的 Vue 3 + Vite,需要的是 Element Plus,但 ElementUI 这个旧名在网络教程里出现频率太高,导致很多人装错包。如果你按我上面的命令装 element-ui@2,配合 Vue 3 运行时会直接报错。
正确的做法是安装 Element Plus:
npm install element-plus @element-plus/icons-vue在 main.js 里完整引入:
import { createApp } from 'vue'; import ElementPlus from 'element-plus'; import 'element-plus/dist/index.css'; import App from './App.vue'; import router from './router'; const app = createApp(App); app.use(ElementPlus); app.use(router); app.mount('#app');完整引入的优点是省心,但打包体积会偏大。如果你在意首屏加载速度,可以用 unplugin-vue-components 做按需引入:
npm install -D unplugin-vue-components然后在 vite.config.js 中配置。按需引入能让打包产物体积减少约 30%,但如果你只是做课程设计,完全没必要折腾这个优化,完整引入即可。我见过太多同学在这个环节花了一下午配按需引入,最后打包体积优化效果也没那么明显,性价比不高。
axios 请求封装放在 src/utils/request.js,这一步很关键。要把 baseURL 统一掉,请求拦截器里自动带上 token,响应拦截器里统一处理错误码:
import axios from 'axios'; import { ElMessage } from 'element-plus'; const request = axios.create({ baseURL: '/api', timeout: 10000 }); request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); request.interceptors.response.use( response => { if (response.data.code === 0) { return response.data; } ElMessage.error(response.data.message || '请求失败'); return Promise.reject(new Error(response.data.message)); }, error => { ElMessage.error(error.response?.data?.message || '网络异常'); return Promise.reject(error); } ); export default request;这样封装一次,后面所有接口调用代码都会非常简洁。跨域开发环境问题通过 Vite 代理解决,在 vite.config.js 里配置:
server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } }这个配置的意思是前端开发服务器把 /api 开头的请求转发到后端 3000 端口,规避了浏览器跨域限制。
4.2 路由设计、导航守卫与参数传值
路由配置我按页面拆分,核心路由是这样:
const routes = [ { path: '/', component: Home }, { path: '/movie/:id', component: MovieDetail }, { path: '/checkout', component: Checkout, meta: { requiresAuth: true } }, { path: '/orders', component: OrderList, meta: { requiresAuth: true } }, { path: '/login', component: Login }, { path: '/admin', component: AdminLayout, meta: { requiresAuth: true, requiresAdmin: true } } ];路由守卫处理登录校验:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next('/login'); return; } next(); });这个守卫的逻辑很简单:需要登录的页面没有 token 就跳登录页。管理端页面我再叠加一层管理员判断,通过 token 解析出的角色字段决定是否放行。
关于路由参数,Vue Router 4 里 query 和 params 的用法要分清。query 适合可选的筛选条件,比如按类型过滤电影,刷新页面参数还会保留在 URL 中;params 配合路由路径中的动态段使用,比如 /movie/12 里的 12。params 有坑,它不会出现在 URL 里,刷新页面后参数会丢失,所以不要把重要数据放在 params 里传递。传电影详情页 id 用 query 或动态段都行,我自己习惯用动态段,路径语义更清晰。
还有一类问题常被忽视:组件复用导致的生命周期不触发。从 /movie/1 跳转到 /movie/2 时,Vue 会复用同一个 MovieDetail 组件实例,不会重新执行 created 或 mounted,也就是新电影数据不会刷新。解决办法是监听路由参数变化:
watch( () => route.params.id, (newId) => { fetchMovieDetail(newId); }, { immediate: true } );这个监听逻辑写在 setup 里,能同时覆盖首次加载和后续切换两种场景。
4.3 核心页面开发:首页、详情页与选座组件
首页用 ElementUI 布局组件快速拼装。轮播图用 el-carousel 播放大图推荐位,下面电影列表用 el-row 和 el-col 做栅格布局,通过 el-card 展示海报、片名、评分和购票入口。接口调用封装在 src/api/movie.js 里,请求结束后把 response.data.data 赋值给响应式变量 movies,Vue 模板自动渲染。
电影详情页除了展示基本信息,还要实现场次列表。这部分我用 el-table 展示当天所有场次,每一行的核心列是场次时间、影厅名称、票价、剩余座位数和一个"选座购票"按钮。点按钮跳转选座页,路由参数带上 sessionId。el-table 自带的分页、排序用起来很顺手,但要注意:如果表格列里的数据是格式化之后的,不要直接在模板里写一堆三元表达式,最好抽一个 computed 函数处理,模板会干净很多,也方便后面测试。
选座组件是整个前端最有意思的地方。我用二维数组代表座位布局,例如 8 排 10 座,数据结构是:
const seatMap = Array.from({ length: 8 }, () => Array.from({ length: 10 }, () => ({ status: 0, // 0可选 1已锁定 2已售出 selected: false })) );渲染时用 CSS Grid 布局,每个座位是一个 el-button。点击可选座位时切换选中状态,计算总价并显示在下单按钮旁边。座位状态的视觉区分靠不同按钮 type 实现,可选为 primary,选中为 success,已售出为 info 禁用状态。选中一组座位后,点击"提交订单",遍历选中的座位 id 逐个调用下单接口。
这里要注意一个前端的细节:如果用户一次选了多个座位,后端接口应该设计成支持多座位批量下单,而不是循环调用单座接口。循环调用多个接口,中间任何一个失败都会产生部分订单成功的脏数据,处理起来很麻烦。我建议前端攒齐座位 id 数组,后端在一个事务里批量处理,整体成功或失败,前端只接一次结果。
4.4 进阶实战:播放 m3u8 预告片与 ElementUI 弹窗加载 PDF
这两个需求不是电影购票系统的必备功能,但热词里搜索量很高,而且面试官挺吃这一套,所以我在项目里也加上了。
m3u8 是苹果推出的流媒体播放格式,本质是一个文本索引文件,里面记录了多个 ts 视频切片的地址。浏览器原生 video 标签并不支持直接播放 m3u8,需要用 hls.js 库处理。安装方式:
npm install hls.js在电影详情页为 trailer_url 字段做判断,如果是 .m3u8 结尾,就使用播放器逻辑:
import Hls from 'hls.js'; const video = ref(null); const playTrailer = () => { if (!props.movie.trailerUrl) return; const hls = new Hls(); hls.loadSource(props.movie.trailerUrl); hls.attachMedia(video.value); hls.on(Hls.Events.MANIFEST_PARSED, () => { video.value.play(); }); };原理是 hls.js 先拉取这个 m3u8 索引文件,解析出所有 ts 切片地址,然后按顺序逐个加载、拼接成视频流,给到 video 元素播放。这样用户在详情页就能看预告片,交互效果比一张静态海报好得多。
如果 trailer 是 mp4 格式,直接用原生 video 的 src 就行,做个格式兼容判断:
if (src.endsWith('.m3u8') && Hls.isSupported()) { // hls.js 逻辑 } else { video.value.src = src; }弹窗加载 PDF 用 el-dialog 配合 iframe 实现。el-dialog 设置 width 为较大值,比如 800px,iframe 占满弹窗内容区,src 指向 PDF 文件地址。这样点击"观影须知"就能在弹窗里预览协议内容,不用跳网页。常规实现是这样:
<el-dialog v-model="pdfVisible" title="观影须知" width="800px"> <iframe :src="pdfUrl" style="width:100%;height:500px;border:none;"></iframe> </el-dialog>用户协议、发票说明这类文档都可以用这个方案。要注意的是 iframe 默认没有边框,设置 border:none 让视觉更干净,弹窗高度要配合 PDF 页面比例设置一个合适的值。
ElementUI 还有一个高频交互——文字超出隐藏,鼠标悬浮显示完整内容。比如电影剧情简介超过两行,被 CSS 截断成省略号,用户想看到全文,最自然的交互是 hover 浮现完整文字。实现分两步,先用样式控制超出隐藏:
.ellipsis-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }再套一个 el-tooltip:
<el-tooltip :content="movie.description" placement="top"> <span class="ellipsis-text">{{ movie.description }}</span> </el-tooltip>这样鼠标悬浮在省略号文本上时,会浮现一个提示框显示完整内容。如果文本特别长,给 el-tooltip 设置 width 属性能限制提示框宽度,配合 style 里的换行样式,浏览体验更好。
4.5 打包后布局异常与资源路径问题
热词里提到了"vue 打包后 布局异常",我第一次遇到这个问题的时候也排查了很久。开发环境一切正常,执行 npm run build 后把 dist 目录扔到 Nginx,页面居然空白或者样式全部丢失,控制台报一堆资源 404。
核心原因出在 Vite 的 base 配置。Vite 打包默认资源路径是绝对路径 /assets/xxx.js,如果你的项目部署在服务器根目录下没问题,但如果你部署在子目录,比如 www.example.com/movie/,那绝对路径会指向 www.example.com/assets/,当然找不到资源。
解决办法是把 vite.config.js 里的 base 配置成 './':
export default defineConfig({ base: './', plugins: [vue()] });这样打包后的资源路径全变成相对路径,不管部署在哪一层目录都能正常加载。这个配置虽然简单,但没踩过坑的人很容易忽视。另外还有一类"布局异常"是 CSS 被压缩后,某些浏览器对特定属性兼容性不同导致的,比如 flex 布局在旧版 Safari 上表现不一样。这就是另一层的问题了,需要加各浏览器兼容前缀,这个通过 autoprefixer 自动处理即可。
5. 常见问题排查与性能优化技巧
5.1 开发环境高频报错速查表
把项目开发和部署过程中容易踩到的坑整理成一张表,按图索骥排查,比事后搜百度快很多。
| 报错信息 | 原因 | 解决方案 |
|---|---|---|
| npm.ps1 无法加载脚本 | PowerShell 执行策略限制 | 管理员身份运行 Set-ExecutionPolicy RemoteSigned |
| EADDRINUSE: address already in use :::3000 | 端口被占用 | 换端口,或netstat -ano查占用进程并结束 |
| ER_ACCESS_DENIED_ERROR: Access denied for user 'root'@'localhost' | MySQL 密码错误 | 检查 db.js 配置,用 root 密码测试能否登录 |
| Client does not support authentication protocol | MySQL 8 认证插件不兼容 | ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '密码'; |
| Cannot find module 'xxx' | 依赖未安装 | 在项目根目录执行 npm install xxx |
| vue-router.esm-bundler.js:42 [Vue Router warn]: No match found for location with path "/xxx" | 路由配置不匹配 | 检查路由 path 是否写错,或页面路由是否注册 |
| ERR_CONNECTION_REFUSED 网络错误 | 后端未启动或代理配置错误 | 确认后端已监听,确认 vite proxy target 端口正确 |
| AxiosError: Network Error | 跨域或后端启动失败 | 检查后端启没启动,检查 CORS 的 origin 配置 |
其中 MySQL 8 的认证插件问题我特别强调一下。MySQL 8.0 默认采用 caching_sha2_password 插件,而一些旧版客户端或 Node 驱动版本不支持这个插件,会报 "Client does not support authentication protocol requested by server"。遇到这个错,一是升级 mysql2 到最新版本,Node 环境用它完全可以支持;二是如果你必须用旧客户端,可以按上表修改认证方式为 mysql_native_password,但不推荐把它当默认做法,因为 caching_sha2_password 的安全性更高。
5.2 接口联调与前后端调试技巧
接口联调阶段最烦的不是写代码,而是接口通了数据却不对。我的调试习惯是把问题分三段排查:请求是否正确发出、后端是否收到并处理、响应数据格式是否被正确解析。
后端排查先看控制台日志。用 morgan 中间件记录每次请求的方法、路径、状态码和耗时:
npm install morganconst morgan = require('morgan'); app.use(morgan('dev'));这样每个接口调用都会输出类似POST /api/order/create 200 12.345 ms - 67的日志,状态码一眼就知道问题在哪。
前端排查打开浏览器开发者工具的 Network 面板,检查请求头、请求体、响应体。重点确认三件事:Authorization 头是否携带了正确 token,请求体 JSON 格式是否符合后端预期,响应结构 code 字段是否为 0。
最常遇到的一类问题是后端接口测试没问题,但前端调用却 404。这种情况大半是 URL 拼写不一致,比如后端定义 /api/movie/list,前端封装成了 /api/movie/list/ ,多了个斜杠路由匹配就直接失败。用上面的 Network 面板能看到实际请求的完整 URL,对照后端路由文件检查,秒级定位。
5.3 数据查询优化:索引、慢查询与事务隔离
数据量不大时感觉不到 MySQL 优化的意义,但量一旦上来,一条没走索引的查询就能把接口拖慢好几倍。我的优化思路是优先看哪些字段在 WHERE、JOIN、ORDER BY 子句里高频出现,然后针对性建索引。
订单列表接口按用户 id 查询,user_id 必须建索引;场次列表按 movie_id 查,movie_id 也要建;首页按 title 搜索电影,给 title 建普通索引。创建索引的 SQL:
ALTER TABLE orders ADD INDEX idx_user (user_id); ALTER TABLE sessions ADD INDEX idx_movie (movie_id); ALTER TABLE movies ADD INDEX idx_title (title);建立索引不是越多越好,因为每次插入和更新都要同步维护索引树,索引过多会拖慢写操作。一个几百条记录的商品表你建十个索引毫无必要,反而浪费存储空间。课程设计场景下,每张表保持 2 到 3 个关键索引就够了。
MySQL 里面还有一个容易被忽略的重要知识点——事务隔离级别。InnoDB 默认是 REPEATABLE READ,这个隔离级别通过 MVCC(多版本并发控制)和间隙锁机制,基本解决了脏读和幻读问题。在选座场景里,当我们 UPDATE seats SET status = 1 WHERE id = ? AND status = 0,这条语句会锁定该行索引记录。REPEATABLE READ 下,并发事务都能读到一致的快照,但写操作仍然会被行锁串行化。所以这个场景实际是用行锁,而不是 MVCC,前后端的响应由 InnoDB 的锁机制保障。
有些同学问,要不要为了这个系统专门去调 low 事务隔离级别?我的回答是没必要。InnoDB 默认级别已经覆盖绝大多数业务需求,强行调成 READ COMMITTED 或 SERIALIZABLE,前者可能产生幻读,后者性能下降明显。除非你的系统有明确的业务诉求要调整隔离别,否则保持默认配置就好。
5.4 前端避坑:ElementUI 升级到 Element Plus 与组件兼容
在写这套系统时,ElementUI 需要根据 Vue 版本选型。如果你在维护旧项目用的是 Vue 2,继续用 ElementUI 2.x 没问题;但如果你在 Vue 3 项目里误装了 element-ui,运行时会报错 "Cannot read properties of null (reading 'install')",这基本就是版本不兼容。
从 ElementUI 升级到 Element Plus 的迁移清单里有几个高频改动:
- 组件名前缀:el-dialog、el-button 等保持不变,但有些属性名变了,比如 el-dialog 的 visible 属性改为 v-model。
- 尺寸属性:size="small" 在 Element Plus 里改成 size="default" 或 size="large",small 变 medium 这类变化要注意。
- 图标:ElementUI 的图标类使用方式在 Element Plus 中需要显式导入组件,而不是直接用 class 名。
- 表单校验:rules 校验规则基本兼容,但 validate 回调风格有变化,建议统一用 async/await。
这个升级流程如果是在已有项目中做,建议进行一次全量回归测试。优先检查表单、弹窗、表格这三个高频组件,因为它们占到了 80% 的页面交互量。如果是新项目,直接从 Element Plus 起步就行,不用纠结要不要兼容旧版。
6. 个人实操经验与后续扩展建议
整套系统跑通之后,我复盘了一下,有几个细节是普通教程不会告诉你的,但对项目体验影响很大的地方。
第一个是演示数据的准备。在开发完成后,别急着截图或录视频交差,先往数据库里插入 5 到 10 条电影数据,场次覆盖今天的各个时间段,座位状态里准备几个已售出座位,这样演示购票流程时,页面上有真实感,选座时能看到哪些座位不可选,而不是每次都是空座空空如也。用一条 SQL 批量插入这些数据,或者写一个 seed.js 脚本,能省下大量手工造数的时间。
第二个是选座时要给用户一个明确的反馈闭环。当用户提交订单后,前端应该立即展示一个"下单成功"的反馈,同时跳转到订单页面,订单列表里的状态要立刻变成待支付。我用 ElementUI 的 ElMessageBox 做了一个下单成功的弹出确认,用户点击确定后跳转订单页。如果按钮加载慢或者没有任何提示,用户会以为下单失败了,操作体验会瞬间变差。
第三个是订单超时未支付的体验问题。很多人做毕设或练习系统不会考虑这一点,但实际业务里,用户锁座后如果一直不支付,座位会被锁死。我后来用 Node 定时任务做了个简易方案,每五分钟把超过 15 分钟仍未支付的订单取消,同时把 seats 表的 status 改回 0。这是一个很容易扩展的加分项,写进项目总结里也能显示你考虑了完整业务流程而不是只是接口。
后续如果想继续深入,我有三个方向可以参考。一是把前端状态管理全面切到 Pinia,把用户信息和购物车类的状态统一管理,代码会更清晰。二是给管理端增加一个影厅和场次的批量管理页面,用 ElementUI 的日期时间选择器配置场次,比手动写 SQL 高效得多。三是完善接口文档,用 swagger-ui-express 自动从代码注释生成接口文档,前端联调时不必再反复问后端接口参数结构。
最后提醒一句,系统跑通了不等于万事大吉。我实际测试时发现,如果用户在下单接口还没返回时连续点击提交按钮,会创建出多条重复订单。前端在提交后要立刻禁用按钮,后端在接口入口处对同一用户同一座位的重复下单做去重判断,双保险才能把这个坑堵住。前端体验和后端健壮性两手都要抓,这才是全栈工程师的做事方式。