
把共享自习室这种场景和“签到”绑在一起最早是几个朋友找我说他们开的小型自习室每天门口登记本厚厚一沓人工统计超时、占座全靠嗓子喊。我当时的第一反应是这根本不需要做什么太复杂的业务系统最核心的需求就两个一是让用户能自己选座、到店签到、离店签退二是让管理员能看到每个座位当时是什么状态App上剩余时长的账别算错。这个“NodejsVueElementUI的共享自习室签到管理系统express-mysql”项目本质上就是把这两个需求用最主流的前后端分离方式做出来。这套项目的技术栈看着很“全家桶”后端是Node.js生态里的Express框架数据库用MySQL前端是Vue 2配上ElementUI最后开发环境下用脚手架把前后端打通。整套东西跑起来之后你能看到一个相对完整的业务流程用户注册登录、浏览自习室和座位、选座、到店签到、中途暂离、签退结账管理员登录后台看总览、管座位、查流水。写这篇文章就是想把这个项目从数据库设计到接口实现再到前端页面的完整思路捋一遍包括我实际开发中踩过的坑给打算做同类型管理系统或者拿它当毕设、练手项目的朋友一份能直接照着走的经验。1. 项目整体设计与技术选型的真实考量1.1 为什么是Node.js Express MySQL而不是Spring Boot或PHP这个项目最容易被问的问题是为什么用Node写后端我的说辞一直很简单这类共享业务的管理系统核心操作其实就是“状态变更计费”接口量不大也没有特别重的计算压力用Node的Express这一层薄封装去写开发速度是最快的。一台服务器、一个npm install、一行node app.js就能起来不需要像Java那样折腾一堆配置和环境变量。Express的中间件模型也是我比较看重的一点。登录鉴权、CORS跨域、请求体解析、统一错误处理都可以通过中间件按顺序挂到请求链路里。比如所有需要登录的接口统一挂在authMiddleware后面这样就可以避免每个路由都重复写一遍token验证逻辑。MySQL在其中的作用是存“结果”不是算“过程”。它的强项是事务和索引正好适合座位状态更新和历史签到记录的写入。我第一次做这个系统的时候本来想用MongoDB看中的是文档模型写起来快。但后来我想明白一个问题签到记录、充值记录、座位占用记录这些本质上都是二维表结构而且报表查询通常要按日期、用户、教室做GROUP BYMySQL原生SQL支持得非常好。所以如果让我重新选一次仍然会回到MySQL。用mysql2这个驱动配合连接池简单直接配套的生态和排错文档也最丰富。1.2 功能模块怎么划分才能“看着小但五脏俱全”拿到需求后第一件事不是打开IDE建页面而是把功能边界划清楚。我在这个项目里把系统拆成了两个大端、五个模块。端模块关键功能用户端账户注册、登录、JWT校验用户端选座中心查看教室座位图、选座、取消选座用户端签到控制到店签到、暂离、返回、签退用户端个人中心历史订单、我的时长、个人资料管理端经营管理房态总览、座位管理、用户列表、签到流水这套划分不是说一次就要全部实现而是提醒大家做的时候要按“业务闭环”去切而不是按“页面”去切。比如签到控制这个模块如果只做一个“签到”按钮那显然不够用户中途出门买咖啡要不要计时这就要“暂离”回来自动解除暂离。座位被占着但人跑了管理员需不需要强制释放这就要后端有个定时任务扫状态。功能切到模块这一个粒度编码的时候才不会写了前面忘了后面。1.3 这套组合到底适合什么样的人来复现如果你是个刚学完Vue基础、准备接触真实项目的开发者或者正在为毕业设计选题目这套组合非常合适。它不涉及复杂的消息队列、分布式锁、微服务一个人能完全掌控它又足够“完整”有数据库、有鉴权、有状态流转、有前后端联调这些都是在真实项目中每天都要面对的事。做之前把Node 16以上、MySQL 5.7或8.0装好前端用Vue CLI生成工程或者用自己习惯的Vite初始化也行只是配ElementUI时需要留意版本兼容。这个后面会单独说。2. 数据模型怎么设计先想清楚数据怎么流转2.1 五张核心表就够跑通第一版写这类系统我喜欢从表结构开始因为建表是帮你把抽象需求变成具体落点的最好过程。第一版我规划了五张表用户表、教室表、座位表、签到流水表以及可选的会员套餐表。如果只是把核心流程跑通后面那张可以留空或者预留字段。用户表里我存了id、username、password用bcryptjs加密后存储、nickname、phone、roleuser/admin、balance余额/剩余分钟数、create_time。自习室是共享业务后面接支付或者办卡都离不开“账户余额”所以这里一开始就把balance字段建好后续计算剩余时长直接做整型减法就行。教室表和座位表是一对多的关系。自习室表字段相对简单id、name、address、open_time、close_time、status、total_seats。座位表需要重点设计里面有room_id、seat_no、status、current_user_id、lock_expire_time、last_check_in_time等。业务上座位有几种状态可用的、被其他人选中但还没有签到的、正在被签到的、管理员临时停用的。直接用字符串状态字段维护也可以但第一次做的时候很容易陷入状态爆炸所以我在第一版里只保留了三个主状态0表示空闲1表示占座或签到中2表示停用。为什么这么合并因为对用户来讲他没进来之前只关心这个座位能不能抢真正确认它是“占座未到”还是“正在使用中”其实要看流水表里的最新记录。一张表维护的状态越多并发更新的时候冲突概率越大查询复杂度也越高。签到流水表是最核心的一张表。它记录的不是传统意义上的“上班打卡时间点”而是一段带状态的时长记录。我用的字段有CREATE TABLE sign_record ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL COMMENT 用户ID, seat_id int(11) NOT NULL COMMENT 座位ID, room_id int(11) NOT NULL COMMENT 教室ID, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0进行中 1已完成 2已取消, start_time datetime DEFAULT NULL COMMENT 首次签到时间, end_time datetime DEFAULT NULL COMMENT 签退时间, total_minutes int(11) DEFAULT 0 COMMENT 累计有效分钟, break_minutes int(11) DEFAULT 0 COMMENT 暂离扣减分钟, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_seat_id (seat_id), KEY idx_room_id (room_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;索引这里我加了三个普通索引因为列表页查询基本都围绕user_id或room_id进行。至于暂离扣减的分钟数为什么单独存一个字段原因是计费规则可能随时调整今天政策是暂时离15分钟内不扣费明天可能改成10分钟。如果把扣减藏在计算逻辑里后面看数据会一头雾水所以我把规则计算后的结果也沉淀到表里出报表时直接能查。2.2 座位状态和流水状态的状态流转在写前端之前强烈建议大家把状态流转画出来否则极容易出现用户点了“签到”按钮后端代码却不知道当前状态应该怎么跳。我这里用文字来梳理一下座位状态的角度空闲(0) 被选中(1) 签到使用(1)。由于第一版不区分选中和签到我把这两个都归为1用一个current_user_id标识当前是谁。后台要区分时就要依赖last_check_in_time是否为空有值表示已经签到空值表示占座未到。更细致的业务节点我用签到流水status来表示用户选座后生成一条status为2未签到占座的流水用户到店点击签到status变成0同时把start_time写进去点击暂离新增一条“暂离事件”或者只更新座位状态由一个字段标识用户签退或管理员强制结束status变成1end_time写入按照规则算完总分钟数。这样做的好处是每次状态变化都有一条流水作为凭证座位状态表只保存“最新态”出了问题可以靠流水表回放。坏处是查询有点绕但对我这种规模的管理系统来说数据的可控性比代码省事更重要。2.3 抢座场景下的并发更新怎么防止超卖共享自习室有一个并发场景比普通CRUD要敏感得多两个用户同时看到一个空闲座位并提交选座如果后端先查状态、确认空闲再写占用这两个操作之间就存在竞态条件结果就是两个人同时成功。我以前写这种业务很容易用“先SELECT再UPDATE”的思路。如果项目并发不高可能没事但一旦有人绕过去就可能出现一个座位被两个用户签到的问题。正确做法是用MySQL的原子更新。const [result] await pool.execute( UPDATE seats SET status 1, current_user_id ? WHERE id ? AND status 0, [userId, seatId] ); if (result.affectedRows 1) { // 抢座成功插入流水记录 } else { // 抢座失败座位状态不对 }这里最关键的点是用了WHERE status 0条件。MySQL的行锁会保证同一时刻只有一个事务能成功更新这行数据依靠affectedRows判断就好了。我第一次做这个项目时偷懒没加事务后来补了mysql2的事务包裹以座位更新成功为标志再去插入流水万一插入失败就回滚座位状态也随之回滚。用事务把这两步包起来之后数据一致性基本就没有问题。以后就算你想把系统升级到企业级这个思路完全可以平移过去。3. Express后端接口组织和签到核心逻辑实现3.1 目录结构与RESTful接口列表接口设计方面我建议不要在一个app.js里堆路由。按模块拆文件看起来是习惯问题实际是后期维护成本的分水岭。我的目录结构大致这样server/ app.js config/ db.js middlewares/ auth.js errorHandler.js routes/ auth.js room.js sign.js admin.js services/ signService.js所有路由都挂在api前缀下面。接口列表整理起来大概是方法路径说明POST/api/auth/register用户注册POST/api/auth/login登录返回JWTGET/api/rooms教室列表GET/api/rooms/:id/seats获取教室座位图POST/api/rooms/:id/seats/:seatId/select选座POST/api/sign/checkin到店签到POST/api/sign/break暂离POST/api/sign/back暂离返回POST/api/sign/checkout主动签退GET/api/records我的签到记录分页GET/api/admin/overview管理端统计这套接口的命名遵循“动词后置、资源前置”的REST风格。比如选座这个动作很多新手会命名为/api/selectSeat虽然能跑但语义不统一。把它设计成“在某个教室中对某个座位执行select动作”路径就变成POST /rooms/:id/seats/:seatId/select读代码的人不需要额外文档也能明白。3.2 登录鉴权用JWT还是session我最终选了JWT因为前端是Vue后端是纯API二者分开部署是大概率事件。JWT是无状态的后端不用存会话记录在负载均衡的环境下不需要专门做session同步。注册时密码记得用bcryptjs加盐哈希别用明文。const jwt require(jsonwebtoken); const secretKey process.env.JWT_SECRET || shared-study-room-secret; // 登录成功后签发token有效期设成7天省得用户频繁登录 const token jwt.sign( { userId: user.id, role: user.role, username: user.username }, secretKey, { expiresIn: 7d } );前端拿到token之后每次请求把它放进Authorization头。中间件auth.js每次拦截请求时先取header里的token再用jwt.verify验证验证失败直接返回401这是截止目前最常规也最不容易踩坑的做法。当然JWT也有一个不太舒服的点无法主动让token失效。如果一个用户想退出登录前端只要把它存在本地的那份token删掉就好。但如果管理员要把某个用户踢下线JWT就做不到了这个在后台管理系统中不太重要先不展开。3.3 签到的核心业务时长计算与状态更新状态流转中最容易算错的是“累计时长”。比如一个用户下午2点到店签到3点外出暂离30分钟4点回到座位5点签退。他实际使用时长不是3小时而是3个小时减去30分钟这是共享自习室通常的计费逻辑。我当初写的时候天真地以为只需要在签退时用end_time减start_time后来发现不对暂离期间座位被占用状态是异常的如果不计费的话就必须把那段时间扣除。于是我在sign_record加了两个字段一个是total_minutes一个是break_minutes同时把“暂离”事件也写进流水表。具体到代码里// 签退时计算 const start record.start_time.getTime(); const now Date.now(); const totalMin Math.floor((now - start) / 60000); const finalMin totalMin - (record.break_minutes || 0);这里break_minutes在每次暂离和返回之间累加。暂离接口触发时先检查当前流水状态只有状态为进行中才允许暂离然后保存一个变量到缓存或额外字段中记录暂离开始时间。等返回接口触发时再把这段值加到break_minutes上。这个设计单独看没有任何难度真正难的地方是“状态机被打断”。比如用户暂离后直接跑路没点返回到了闭店时间怎么办所以后端需要一个定时任务每分钟扫一次所有进行中的流水发现当前座位状态是暂离且超过一定时长就自动把座位释放掉。这里要注意定时任务和用户请求会同时更新同一行数据所以更新语句也必须带状态条件防止用户正在“返回”时被定时任务误删。3.4 占座不签到要不要自动取消我在第一版里没有做自动取消结果出现一个问题用户选了座没付款或者说没到店座位被锁了几个小时真正来的人选不了。后来我加了lock_expire_time字段选座成功后给15分钟限制。后台定时器扫描到期且未签到的座位执行的SQL类似// 自动释放超过15分钟未到店签到的座位 await pool.execute( UPDATE seats SET status 0, current_user_id NULL, lock_expire_time NULL WHERE status 1 AND current_user_id IS NOT NULL AND lock_expire_time NOW() AND last_check_in_time IS NULL );同时把对应的流水记录状态置为取消这一步也要包在事务里做。这样虽然写起来稍微麻烦一点但从产品角度看避免了占座资源浪费是能真实改善用户口碑的功能。4. Vue ElementUI 前端页面怎么把后台做得像产品4.1 前端工程结构和路由守卫前端我用的Vue 2 Vue CLI因为ElementUI 2.x对应的是Vue 2如果用Vue 3就要换成Element Plus组件的写法有不少差异。为了避免一次踩两套生态的坑这个项目沿用Vue 2是非常稳妥的。工程结构大概是这样src/ api/ request.js auth.js sign.js room.js router/ index.js guard.js store/ modules/ views/ Login.vue RoomMap.vue MyRecord.vue AdminDashboard.vue路由守卫是前端鉴权的核心。用户刷新页面之后Vuex里的用户信息会丢失所以我每次在刷新后从localStorage重新获取token并调用后端接口获取用户信息。路由守卫的做法是先判断to.meta.public是否为true如果是登录页、注册页就直接放行否则检查本地有无token没有token就跳转登录页有token但store里没有用户信息就先拉取用户信息再放行。这样能避免登录状态刚过期时用户还能停留在页面里发一堆请求后端再统一打回401的尴尬。4.2 座位图可视化用CSS Grid做可点击区域ElementUI没有现成的可选座控件所以这块需要自己组合。我的方案是用CSS Grid把座位排成教室平面图。后端接口把每一个座位返回时带上row和col属性前端通过computed计算每个座位在Grid中的gridRow和gridColumn位置再加上一个点击事件即可。座位状态不同按钮的颜色和禁用状态也不同。空闲的显示墨绿色按钮且可点正在使用中的显示灰色调且禁用用户自己占用的显示主色调并配上“我的座位”标签。实测用下来这种方案比用Canvas或者大量绝对定位的div要省心很多而且天然支持不同尺寸的教室只要后端把seat的row/col配置好前端不需要改动。这个场景里有一个容易忽略的点用户点选了座位以后页面不能傻等后端响应会让人以为没点中。我处理的方式是点击后先把按钮禁用加上loading动画等接口返回成功后再整体刷新座位状态。如果接口失败就提示用户重新选。4.3 ElementUI里使用频率最高的基础配置ElementUI是一个组件库但很多人用的时候会踩到同一个坑组件很好用细节参数太多不查文档就默认值运行结果行为不符合预期。一个是DateTimePicker。共享自习室的经营时间通常是从早上9点到晚上10点管理员如果自己要手动补录一条签到记录日期选择组件就应该限制超出营业时间的时间不可选。解决方案是设置:picker-options动态绑定disabledDate和selectableRange。第二个是分页组件。我自己第一次用el-pagination时踩过坑current-change拿到的是新的页码但如果列表页搜索条件变化了必须手动把currentPage重置回1不然用户搜索第5页的数据时查询到的就是空列表。所以我在所有搜索操作里都统一先setPage(1)再接接口。再一个是ElementUI的表格需要设置row-key尤其当表格数据里每行都有操作按钮时。如果启用了多选列selection-change里拿到的数组顺序可能不稳定我一般会用row-key保证Vue的dom复用不出错。这些细节看起来小前端页面表现自然不自然就差在这里。4.4 axios封装拦截器、业务码与错误提示axios如果不做一次封装会出现每个页面都重复写loading和错误处理的代码。我习惯在request.js里做两件事请求拦截器统一加入token响应拦截器统一处理错误码。惯例的后端返回格式是{ code: 200, message: success, data: {} }响应拦截器会判断code是否是200。是的话直接返回response.data.data给页面不是的话使用ElementUI的Message组件弹出后端返回的message并reject一个错误。如果遇到HTTP 401则在拦截器里执行退出登录并跳转到登录页。这种统一封装对团队开发非常重要。否则前端每个人各写各的弹框有些接口超时了没有提示用户会以为卡死了。把它集中到拦截器之后出了问题只要改一个地方就行。5. 开发中绕不开的问题从环境到联调的坑5.1 npm.ps1 无法加载Node环境装不上的问题这个项目如果是在Windows上做最先遇到的大概率不是代码问题而是环境问题。使用npm时报错的内容类似npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本这个报错的原因是PowerShell的执行策略默认是Restricted不允许运行本地脚本。这不是Node本身没装好解决的思路有两个。一个是用管理员身份打开PowerShell执行Set-ExecutionPolicy -ExecutionPolicy RemoteSigned然后选择Y确认。这样只会允许本地脚本运行远程脚本仍然需要签名是相对安全的。另一个更随意的方式是直接用cmd或Git Bash绕开PowerShell。我个人的建议是执行第一个操作因为后面还会用到很多npm脚本命令迟早要遇到。5.2 MySQL 8认证方式与mysql2连接的问题用MySQL 8连接Node时经常报下面的错误ER_NOT_SUPPORTED_AUTH_MODE: Client does not support authentication protocol requested by server原因是MySQL 8默认的认证插件是caching_sha2_password而老版本客户端用的是mysql_native_password。解决办法有两种一种是改用户的认证插件在MySQL命令行中执行一句SQL。另一种是升级驱动到mysql2它对新认证的支持会更好。如果改动后连接还有问题检查一下连接配置里是否带了localhost而不是127.0.0.1有时候这是因为权限表问题。顺带提一个编码问题连接MySQL时url中database前面最好拼上?charsetutf8mb4不然中文用户名和时间字段很容易在写入时出现乱码或格式不对。5.3 Express跨域和后端重启的常见困扰前端开发服务器通常跑在8080端口后端跑在3000端口浏览器会拦截跨域请求。我见过不少新手第一反应是去前端配proxy其实后端也可以直接装cors中间件。开发阶段直接全局启用cors是最简单的它会在响应头写上Access-Control-Allow-Origin。如果后面生产环境要把前后端放在不同域名下则再把cors的origin配置成指定白名单避免任何人跨域调接口。另一个后端开发时会遇到的困扰是改了代码必须重启Node服务。建议装nodemon作为devDependency开发时用node src/app.js会直接泪奔。注意启动命令变成nodemon src/app.js改完代码会自动重启这个体验对调试这种状态流转频繁的业务特别重要。5.4 时间字段和时区问题的表现用MySQL做签到计时的时候时间是非常重要的基础数据。如果服务器的时区没有设置好可能出现明明数据库存的是14:00:00前端拿到却变成06:00:00的情况。Node读取MySQL的datetime字段时默认会把它转成Date对象而Date对象序列化到JSON时用的是UTC时区前端展示和用户本机时区一比对就有偏差。最简单的规避方案是在连接配置中加上timezone: 08:00这样的时区参数。另一个方案是后端把所有时间字段统一处理成字符串返回只存不管展示层用dayjs或moment格式化。哪种方案都行但最忌讳的是系统里一会儿用字符串一会儿用Date对象导致签退时计算时长总是差几个小时查问题极其痛苦。5.5 一张问题速查表方便排查现象可能原因处理办法npm命令无法执行PowerShell执行策略限制Set-ExecutionPolicy RemoteSignedNode连接MySQL报认证错误MySQL 8默认认证插件不支持修改用户插件或使用mysql2驱动前后端接口调用500后端有异常抛出却被全局捕获吞掉在errorHandler中打印堆栈座位并发抢占出现两个人抢到同一个座位抢座逻辑用了先查再改改成原子UPDATE并加事务选座页座位图铺不开座位坐标没有按row/col设置统一使用CSS Grid双轴布局表格分页查询后停留在旧页码搜索条件变化未重置页码搜索请求前将currentPage重置为1中文写入MySQL变成?字符集不统一表和连接都使用utf8mb46. 从能跑到能用收尾阶段的一些建议6.1 本地跑起来之后的发布思路这个项目结构上是前后端分离的如果直接部署可以把前端npm run build之后的dist目录交给Nginx托管Nginx再做反向代理把/api开头的请求转发给Node服务。Node服务端不需要额外安装太多东西生产环境用PM2管理进程是最普遍的一条pm2 start app.js --name study-room就能托管进程同时解决崩溃自动重启和开机自启的问题。环境变量上不要把数据库密码和JWT密钥写在代码里。我习惯在项目根目录放一个.env文件用dotenv把配置注入process.env然后在.gitignore里忽略它。很多人嫌麻烦忽略了这个习惯结果开源代码或者提仓库时把密码一起交了上去这个比代码bug更难处理。6.2 如果继续往后做我会优先补什么签到系统的第一版做出来之后可以继续在业务流程上增加扫码签到。现在用户到店后要自己在页面上点签到但真实场馆里的防重复和防代签还是要依赖物理手段比如每个桌角贴一个二维码二维码内容带seatId参数用户到店后用微信或浏览器扫码跳转到签到确认页面。只需要在后端加一个解析二维码参数的接口就行之前设计的sign/checkin接口完全不用动。另外一个比较现实的功能是会员储值和小程序支付。当前用的balance字段其实已经为这个功能留好了空间。你可以把余额当成“分钟数”或者“预付金额”签退的时候自动扣减余额不足时不允许开始新的会话。到这里这个系统就从“能做签到”变成“能经营”了。6.3 回到最初的体验我在实际把这个项目做完之后最大的体会是这种“小系统”最值钱的部分反而不是代码写得多漂亮而是业务场景和状态梳理得是否足够清楚。Node.js和Vue的上手门槛都不高真正让人掉头发的永远是数据一致性和边界条件两个人抢同一个座位怎么处理用户签到了但一直不签退怎么处理管理员改错状态怎么恢复。每解决掉一个这类问题再回头去看看那个报错或那条SQL你会有一种“系统终于在自己控制下”的踏实感。如果你也是想通过一个完整项目把Express和Vue串起来就从先把那张座位表设计好开始吧。