首页 / 资讯中心 / 文章详情

单车租赁系统全栈实战:MySQL数据库设计与前端开发整合指南

单车租赁系统全栈实战:MySQL数据库设计与前端开发整合指南 ★ FEATURED ARTICLE
做了好几套课程设计的项目之后我的一个明显感受是很多同学不是不会写代码而是不知道一个完整的业务系统究竟该怎么串起来。就拿单车租赁这个题目来说需求听起来很直白——用户租车、还车、结算管理员管车、管订单——但真动手做的时候前端页面、MySQL数据库、中间的数据交互三条线一交汇就乱套了。这篇博客我就以“单车租赁系统MySQL数据库结合前端开发”这个项目为例把从数据库建模、前端页面规划到前后端联调、踩坑记录的完整过程都拆开讲一遍。无论你是正在做课程设计、想练手全栈开发还是单纯想看看别人怎么设计一套完整系统这份内容应该都能给你一些参考。1. 为啥“租车系统”是个好练兵场先理清需求和选型1.1 需求先别急着写代码这个系统到底要给谁用动手之前先把角色想清楚。一套单车租赁系统至少有两种使用者。第一类是普通用户注册账号、查看当前可租的车辆、发起租车、还车结算、查看自己的历史订单。第二类是管理员维护车辆信息上下架、修改状态、查看所有用户订单、处理车辆故障标记。很多同学一上来就埋头建表结果做到一半发现用户想看的“我租了多久、花了多少钱”没地方存管理员想看的“哪些车在外面跑着”也查不出来。这就是需求没拆清楚。我当时的第一版功能清单是这么列的用户端注册、登录、车辆列表、按状态筛选、租车、还车、结算、我的订单管理端车辆列表、新增/编辑/下架车辆、订单总览、汇总某辆车的使用次数这个清单不复杂但已经够把一张数据库的“骨架”逼出来了用户要有车辆要有订单要有而且订单必须同时关联用户和车辆。1.2 技术选型的三个关键判断再说选型。标题里写的是“MySQL数据库结合前端开发”那么最朴素的理解就是前端负责界面、MySQL负责存数据。但这里必须有一个意识——浏览器不能直接连数据库。MySQL是有网络协议也能监听端口但如果你让浏览器直接把请求打到数据库上相当于把你家的大门钥匙直接复制给每一个路人数据库账号密码必须暴露在JS代码里随便一个懂点技术的人就能从页面上找到你的数据库地址和账号然后直接连上去删表。所以中间必须有一层服务端程序前端把请求交给它它去操作数据库再把结果返回前端。后端语言我没有选花哨的框架而是用最直白的PHP脚本配合MySQL原生API理由是它和MySQL配合最直观、学习成本最低适合展示完整链路。后面所有接口逻辑你完全可以用Java、Node.js、Python等照抄思路关键不在语言而在“前端 — 后端接口 — 数据库”这条链路要清晰。前端我推荐用原生三件套HTML CSS JavaScript暂时不引入Vue、React这类框架。原因很实际这个项目的主要复杂点在于数据库设计与前后端交互逻辑先把原生fetch请求和DOM操作搞明白后面再上框架你会轻松很多。如果一上来就套框架很多同学其实是在抄框架代码反而不知道自己写的页面数据是从哪来的。1.3 项目目录怎么组织我给这个项目的目录结构做了很明确的划分后面所有开发都围绕这个结构来bike-system/ ├── frontend/ # 前端页面 │ ├── index.html # 车辆列表页 │ ├── login.html # 登录页 │ ├── register.html # 注册页 │ ├── order.html # 我的订单页 │ ├── admin.html # 管理后台页 │ ├── css/style.css # 公共样式 │ └── js/ │ ├── api.js # 统一封装接口请求 │ ├── login.js │ ├── index.js │ └── admin.js ├── server/ # 后端服务 │ ├── config.php # 数据库连接配置 │ ├── db.php # PDO连接单例 │ └── api/ │ ├── user.php # 注册/登录接口 │ ├── bike.php # 车辆列表/状态接口 │ ├── order.php # 租车/还车/查询接口 │ └── admin.php # 管理操作接口 └── sql/init.sql # 建库建表脚本这个结构的好处是前端、后端、数据库三块被物理隔离出问题时很容易定位是哪一层的锅。2. 数据库不是“建几张表”那么简单单车租赁的表设计与建模思路2.1 三张核心表每一张表为什么这样设计用户表 user我见过很多人第一版用户表就三个字段用户名、密码、昵称。但真正到了做订单系统的时候就会发现缺东西。CREATE TABLE user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录用户名, password VARCHAR(255) NOT NULL COMMENT 密码哈希结果不要存明文, nickname VARCHAR(50) DEFAULT COMMENT 显示昵称, phone VARCHAR(20) DEFAULT COMMENT 手机号便于联系与找回密码, balance DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 账户余额元, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;这里面有几个不那么起眼但很重要的设计。password用VARCHAR(255)而不是VARCHAR(32)是因为MD5只产出32位而现在的推荐做法是用password_hash()生成更长的哈希串长度会超过32所以字段给足余量。balance用DECIMAL(10,2)而不是FLOAT或DOUBLE这个我后面讲金额精度时还要专门说总之用小数金额千万别用浮点类型。username加了唯一索引意味着注册时会出现“用户名已存在”的冲突也意味着登录查询可以用索引快速命中。车辆表 bike车辆表看似简单但有一个字段很容易漏——车辆的“位置”或“编号”。在真实租赁场景里每辆车都要有唯一可识别的编号不然管理员无法区分路边两辆同款车。CREATE TABLE bike ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, bike_code VARCHAR(20) NOT NULL COMMENT 车辆编号例如 B001, name VARCHAR(50) NOT NULL COMMENT 车型名称, image VARCHAR(255) DEFAULT COMMENT 车辆图片URL, location VARCHAR(100) DEFAULT COMMENT 车辆所在位置描述, price_per_hour DECIMAL(10,2) NOT NULL DEFAULT 10.00 COMMENT 每小时租金元, status TINYINT NOT NULL DEFAULT 1 COMMENT 1空闲 2租出 3维修/下架, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_bike_code (bike_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT单车表;status我用了整型而不是字符串“空闲/租出/维修”。原因是字符串容易随着业务变化越写越长、越写越乱而且在程序里判断相等、做筛选都会很别扭。用TINYINT配合注释等于把状态字典固化在表结构里。这个表里最核心的字段其实是status。用户发起租车请求时系统要做的第一件事不是创建订单而是确认这辆车的status确实等于“空闲”。这看起来简单但当两个用户同时盯着同一辆车时这个字段就成了并发控制的关键。订单表 orders订单表是整个系统的“事实记录”所有业务纠纷最终都要靠它来对账。因此它的字段设计原则是能完整还原当时发生的事。CREATE TABLE orders ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单编号业务唯一, user_id INT UNSIGNED NOT NULL COMMENT 下单用户ID, bike_id INT UNSIGNED NOT NULL COMMENT 租用车辆ID, bike_code VARCHAR(20) NOT NULL COMMENT 冗余车辆编号防止车辆信息被改后无法追溯, start_time DATETIME NOT NULL COMMENT 租车时间, end_time DATETIME DEFAULT NULL COMMENT 还车时间未还为NULL, duration_minutes INT UNSIGNED DEFAULT 0 COMMENT 用时分钟数, cost DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 应付金额, status TINYINT NOT NULL DEFAULT 1 COMMENT 1进行中 2已完成 3已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_bike_id (bike_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT租车订单表;order_no这个字段是很多人容易忽略的。用自增ID作为订单对外编号看起来省事但会带来两个问题一是别人可以通过订单ID差推算出你的平台每天产生多少订单二是业务上经常需要“订单号唯一、可被用户复述”自增ID太短太容易撞号。所以我用time()加随机后缀生成一个唯一的32位订单号单独加唯一索引。bike_code在orders表里是冗余字段。为什么要冗余因为车辆信息以后可以被修改甚至删除但订单作为历史记录必须保持当时的样子。如果只有bike_id外键关联车辆信息一变历史订单就无法还原了。这就是数据库设计里的“适度冗余”思想。2.2 外键要不要加我的取舍与理由传统教材很喜欢强调外键约束但实际项目中我一般不加物理外键只在应用层维护逻辑关联。原因很实际物理外键会让很多批量操作变得笨重比如删除某辆车时如果它还有历史订单外键约束会直接报错让你的系统“不能删东西”。而现实业务里车辆下架后历史订单应该保留车辆记录本身甚至可以留作归档。所以我用“逻辑外键”orders表里保存user_id、bike_id但不声明FOREIGN KEY。查询时的JOIN依然可以做数据的完整性由后端代码保证。这套做法更适合真实业务开发尤其在项目后期要加复杂的统计查询时物理外键的限制会多于保护。2.3 建库时推荐顺便写好基础数据建完表之后很多同学直接开始写前端结果页面上一辆自行车都看不到空荡荡的列表根本没法开发。我的建议是初始化脚本里就带几条测试车INSERT INTO bike (bike_code, name, location, price_per_hour, status) VALUES (B001, 城市通勤车, 地铁A口, 8.00, 1), (B002, 山地变速车, 公园北门, 15.00, 1), (B003, 折叠便携车, 学校图书馆, 12.00, 2);有数据之后再写前端你才能真正看到卡片布局长什么样、状态筛选有没有生效。这也是一个很容易被忽略的开发技巧开发期的测试数据也是一种“接口文档”没有它前后端联调基本是盲人摸象。3. 前端页面规划顺着用户的操作路径倒推界面设计3.1 把用户故事画成页面草图我这次设计前端不是按照“有什么页面就写什么页面”的思路而是先列用户路径没登录的用户打开首页 → 看到车辆列表但点击租车时被引导去登录登录后重新点租车 → 弹窗确认车型、价格、开始时间 → 确认租车租车成功后页面出现“进行中订单”提示对应车辆状态变成“租出”用户再次扫码/选择该车 → 弹出还车结算信息 → 确认还车用户进入“我的订单” → 按状态筛选所有历史记录这个路径拆完需要哪些页面、哪些交互组件就全浮出来了登录页、注册页、车辆列表页、订单页、还车弹窗。一个统一的页面骨架导航栏、内容区就够了。3.2 车辆列表页卡片式布局加状态标识列表页我用卡片展示每辆车卡片中包含车辆图片、名称、位置、每小时价格和状态标签。状态标签的配色做一个约定空闲绿色、租出灰色、维修橙色。这个页面看起来只是前端的事但它的所有数据其实都来自数据库的bike表。页面上我需要一个加载态、一个空态、一个错误态。加载态对应“正在从服务器获取车辆数据”空态对应“当前没有可用车辆”错误态对应“服务器挂了或者网络断了”。没有数据时用户看到的不是白屏而是“暂无车辆请稍后再来”这个体验差别很大。JS里我习惯把所有接口请求都集中到一个api.js文件里统一封装async function apiGet(url) { const res await fetch(url); const data await res.json(); if (data.code ! 0) { throw new Error(data.message || 请求失败); } return data.data; }这样页面里调接口就很干净不用每个页面都写一遍fetch加状态判断。3.3 租车和还车的交互核心是“确认-反馈”租车交互我用了一个简单形式点击“立即租车”按钮弹出一个确认框显示车辆编号、车型、价格、预计开始时间用户点确认后前端给后端发“创建订单”请求。成功后页面立刻刷新车辆状态并把按钮改成“租出”。还车交互同理点击“还车并结算”确认弹出框里根据后端返回的最终费用展示金额用户确认后完成结算。这个过程中有一个关键点前端展示的费用只是一个“预估”最终费用以后端按数据库里的时间计算为准。因为前端时钟可能不准用户也可能挂机很久才点还车无论如何不能让前端直接决定扣多少钱。3.4 管理后台一切从简但并不简单管理后台我只有一个页面顶部是车辆管理表格展示所有车支持新增/编辑/下架操作下面是订单总览表格展示租车订单按时间倒序。管理后台和用户端的区别在于操作密度高表格比卡片更合适。这个页面需要的接口也直接对应数据库操作新增车辆、修改车辆信息、修改状态、查看所有订单。不复杂但涉及的表操作更直接是练习后端接口设计的好材料。4. 前端与MySQL的对话数据是如何在页面和数据库之间流动的4.1 三层结构谁在中间干活这里必须把结构再强调一次因为很多新手栽在这里。整个系统是三层前端浏览器页面用户看到和操作的地方后端API脚本接收前端请求、处理业务逻辑、读写数据库、返回JSONMySQL数据库真正存数据的地方浏览器不能直接访问数据库。它在页面上发一个fetch请求到后端api地址后端脚本连接数据库执行SQL再把结果返回给前端。理解这三层各自的职责后面所有联调问题都能按这个分层去排查。4.2 数据库连接与预处理语句防SQL注入的第一道防线我用PDO做数据库连接配置写在独立的config文件里?php $host 127.0.0.1; $dbname bike_system; $user root; $pass your_password_here; $charset utf8mb4; $dsn mysql:host$host;dbname$dbname;charset$charset; $options [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, ]; try { $pdo new PDO($dsn, $user, $pass, $options); } catch (PDOException $e) { exit(json_encode([code 500, message 数据库连接失败])); }这里有两个容易犯的错。第一个是charset必须显式指定为utf8mb4只写utf8会遇到生僻字和Emoji相关的乱码因为MySQL里的utf8是旧版别名实际只支持最多三字节的字符。第二个是生产环境下真实的连接配置绝不能暴露在前端代码里这个配置文件只能放在服务器端目录不能放进前端可访问的路径。查询时不建议用拼接字符串的方式传入用户输入而是用预处理语句$stmt $pdo-prepare(INSERT INTO user (username, password, nickname) VALUES (?, ?, ?)); $stmt-execute([$username, $password_hash, $nickname]);有人会觉得“我的项目这么简单没人会攻击”但SQL注入恰恰专挑这种不设防的小项目练习。用预处理语句只是多打了两行字属于“花半小时能省一辈子麻烦”的硬习惯。4.3 一次完整租车请求的链路复盘租车这个动作值得完整复盘一遍因为它是整个系统里链路最长、最容易出错的操作。第一步前端JS拿到车辆ID和当前时间调用接口const body { bike_id: bikeId, user_id: currentUserId }; const res await fetch(/server/api/order.php?actioncreate, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(body) }); const data await res.json();第二步后端收到请求后按顺序做四件事每一件都对应一个业务规则校验参数bike_id是否存在、user_id是否合法检查车辆状态必须是“空闲”否则直接拒绝插入订单生成订单号、写入订单记录、状态设为“进行中”更新车辆状态把这辆车的status改成“租出”四件事有先后顺序而且不能“做一半”。比如订单创建成功了但车辆状态没来得及改用户会看到订单存在但车还是空的下一个用户还能租这辆车。所以我说租车接口必须用事务包起来要么全成功要么全失败$pdo-beginTransaction(); try { // 检查并更新车辆状态 $stmt $pdo-prepare(UPDATE bike SET status 2 WHERE id ? AND status 1); $stmt-execute([$bikeId]); if ($stmt-rowCount() 0) { throw new Exception(车辆已被租出请选择其他车辆); } // 创建订单 $orderNo date(YmdHis) . rand(1000, 9999); $stmt $pdo-prepare(INSERT INTO orders (order_no, user_id, bike_id, bike_code, start_time, status) VALUES (?, ?, ?, ?, NOW(), 1)); // ... $pdo-commit(); } catch (Exception $e) { $pdo-rollBack(); exit(json_encode([code 1, message $e-getMessage()])); }注意这里更新车辆状态的SQL是带有status 1条件的这用的是乐观锁思想只有在我更新之前车辆确实还是“空闲”的我才能把状态改掉如果已经在事务过程中被别的事务改走了这次UPDATE会影响0行业务层就此拒绝。一个UPDATE语句结合事务就把并发问题挡在了门外比先SELECT再判断更稳。4.4 联调时用浏览器开发者工具Debug的思路前后端联调阶段前端页面看不到数据绝大多数情况不是“代码写错了”而是请求压根没出去或者出去了没到对应接口。我的排查顺序很固定先看开发者工具里的Network面板找到那条请求看状态码。如果404八成是接口路径写错了。如果403可能是跨域或者权限问题。如果500看响应体里的message这是后端业务逻辑抛出的异常。如果状态码200但页面没数据那一定是前端的渲染逻辑问题——数据可能拿到了只是没正确插入DOM。这一套流程虽然简单但能省下大量互相“猜”的时间。开发项目七分在写三分在排错排错的方法论甚至比写的代码更重要。5. 业务逻辑里的暗坑计费、状态机与并发还车5.1 计费规则不是“每小时10元”五个字那么简单车辆表里我设计了price_per_hour但计费实际要解决的问题比“单价乘时长”多一层时长怎么算不足一小时怎么算还车瞬间的费用怎么保证准确。我的方案是这样前端记录开始时间还车时后端读取当前时间用TIMESTAMPDIFF(MINUTE, start_time, NOW())算出分钟数。金额计算我不直接按分钟算而是先向上取整到计费周期。如果产品设定最小计费单位为半小时那么59分钟按1小时算61分钟按1.5小时算$hours ceil($minutes / 30) * 0.5; $cost round($hours * $pricePerHour, 2);ceil向上取整避免出现“租了59分钟只收0.98小时钱”的尴尬。这种规则需要和产品沟通清楚但代码上只要一行就能切换。金额计算全程用PHP的浮点数运算然后round(, 2)可能在极端情况下出现浮点误差所以更稳妥的做法是金额一律换算成“分”来算即整数运算$costFen (int)ceil($minutes / 30) * 50 * (int)round($pricePerHour * 100); $costFen intdiv($costFen, 100);课程设计阶段用round问题不大但如果后面打算做成真实产品强烈建议直接用“分”为单位存整数比较两个金额时才不会出现0.10.2不等于0.3的问题。5.2 订单状态机别让状态随便乱跳订单状态我定义了三个1进行中、2已完成、3已取消。管理端还可以有异常单但课程设计阶段三个足够。状态之间要约束跳转规则进行中 → 只能到已完成或已取消已完成 → 不能再改已取消 → 不能回到进行中。这些规则看起来明显但没有约束时后端接口很容易被调用方“乱传状态”导致数据错乱。所以还车接口应该只做一件事把“进行中”的订单变为“已完成”并带上结束时间和费用。如果用一条UPDATE把不该动的已完成订单也改掉那就要惹事了。5.3 并发还车两个人同时还同一辆车怎么办这个场景在课程设计里很难被“演示”出来但在真实系统里每天都在发生。还车接口的并发风险在于两辆车同时调用还车都读到同一辆车的订单还是“进行中”然后都去更新后一个覆盖前一个的数据。解决办法和租车时候一样用条件更新加事务UPDATE orders SET end_time NOW(), duration_minutes ?, cost ?, status 2 WHERE id ? AND status 1如果影响行数为0说明这个订单已经不是进行中状态了直接返回“订单状态已变化请刷新页面”。这不只是在防“恶意重复提交”更是在保护业务数据的一致性。5.4 事务到底该包住哪些操作我见过最典型的错误是把“查订单、算费用、更新订单、更新车辆、扣余额”全部写在一个事务里然后事务里夹了一个远程接口调用或者一个慢查询导致整个事务锁表时间过长系统性能直线下降。设计事务有一个朴素原则事务只保护需要一起成功或一起失败的数据写操作。租车事务里包含“插入订单、更新车辆状态”还车事务里包含“更新订单、更新车辆状态、扣用户余额”。查询操作、日志记录、通知发送等都不需要在事务里。这样既保证一致性又不阻塞无关操作。6. 上线前避坑我踩过的数据库与联调层面的五个坑6.1 中文乱码三层charset不一致一切白搭乱码是数据库项目里出现频率最高的问题。查来查去发现根因往往是三层字符集不一致数据库表是utf8mb4PHP连接串没有带charset页面没有声明编码。正确做法是三位一体的建表时显式DEFAULT CHARSETutf8mb4PDO的DSN里显式带上charsetutf8mb4前端HTML的meta charsetUTF-8写上三处一致之后基本再也不会遇到中文和特殊字符变“问号”的问题。6.2 时间字段时区不对所有统计都差八小时MySQL连接字符串里如果不指定时区默认可能用系统时区。本地开发和服务器部署环境时区不一致时可能出现入库时间是当前时间查出来却慢了八小时。我习惯在建库时统一SET time_zone 08:00;或者在后端连接后执行SET time_zone 08:00确保所有时间操作在同一时区下。比较start_time和NOM()之间差值的逻辑才不会在部署后突然失真。6.3 金额不用FLOAT被0.10.2教育过才会长记性金额类的字段建议统一用DECIMAL(10,2)后端运算时如果要严谨就用整数“分”计算。FLOAT在运算过程中会产生微小的误差金额一旦错误用户投诉、对账不平都是连锁反应。这个坑我见得太多次了属于“不是会不会踩而是踩了之后多痛”的问题。6.4 删除数据要克制历史订单永远不能跟着用户一起消失管理端可能会提供“删除用户”功能但当你删除一个还有订单的用户时如果把orders表里的记录也删掉管理者将无法追溯任何历史交易。所以我的做法是用户和车辆都采用“软删除”——加一个status字段控制是否显示而不是真实DELETE掉记录。这既能防止物理外键带来的删除限制也能保留完整的历史数据。6.5 接口路径为什么404本地服务根目录和前端路径不一致很多同学在本地用的是类似http://localhost/bike-system/frontend/index.html来访问页面然后接口请求写成了/server/api/order.php?actioncreate结果浏览器会去请求http://localhost/server/api/order.php——少了一层bike-system目录。这个问题当时查了很久。解决办法是在api.js里用一个BASE_URL全局变量统一配置接口前缀const BASE_URL /bike-system/server; // 部署后改成正式域名/路径所有请求都拼上这个前缀换环境时只改一处避免一百个地方手写路径然后逐个排查。7. 这套系统还能怎么长从“能跑”到“真能用”的几个升级方向这个项目的价值不在于它是个“完整的商业产品”而在于它把全栈开发的基础链条完整走了一遍。做完之后我建议你再往前推几步哪怕只是部分实现收获都会大很多。第一步是给登录加上会话管理。课程设计里的登录经常只是验证一下用户名密码就完事但真实产品必须有登录态用户登录后后端签发一个会话标识前端保存在Cookie或本地存储中后续接口请求自动带上后端识别这个标识知道“当前操作者是谁”。这能解决一个实际问题把user_id从页面参数传参改成从会话中读取避免接口被伪造请求乱调用。第二步是接入支付逻辑的模拟版。租车订单创建后先不扣钱还车时结算并扣减用户余额如果余额不足则显示“请先充值”。你可以加一个充值接口模拟支付回调。这个过程非常锻炼对“业务状态”的把控。第三步是往车辆上加一个GPS定位字段把location从文本变成经纬度然后用地图组件展示实时车辆分布。前端到这一步才开始有“产品感”也是从课程设计走向简历项目的重要转折点。在我个人看来做这类全栈项目最有价值的收获不是某个知识点而是你开始用“数据会流向哪里”的眼光看整个系统一个按钮点下去请求走哪条路数据落在哪张表状态怎么变化哪个环节出错会有什么后果。这套思维方式比多会一个框架有用得多。
阅读完成 · 觉得有帮助?
咨询建站