
就在上个月我带的一位同学在答辩前一周找到我说自己的单车租赁系统“所有页面都能打开但数据库里一塌糊涂租车还车对不上账”。我一看代码果然前端花里胡哨做了七八个页面数据库只有一张表租车逻辑全靠页面跳转硬撑。这种问题在课程设计和自学项目里太常见了所以我决定把单车租赁系统这个经典题目的完整思路写出来从数据库建模讲到前端交互中间穿插实操代码和踩坑记录帮你把这个项目做成“能答辩、能进简历、能延伸成毕设”的完整作品。这个项目表面上是一个增删改查系统实际上它的核心难点在于状态流转单车从空闲到已租出、从已租出到归还每一步都要保证前后端数据和数据库三方一致。适合有一定前端基础、想补上数据库设计这一课或者正在做课程设计、毕业设计、求职项目的开发者参考。1. 先别写代码把业务链路和角色理清楚1.1 谁在用系统他们各自要做什么单车租赁系统的业务闭环其实非常清晰我把角色拆成两类普通用户和管理员。普通用户的核心路径是注册登录 → 浏览单车列表 → 选择空闲车辆租车 → 骑行 → 归还 → 查看订单和费用。管理员的核心路径是登录后台 → 维护单车信息新增、下架、标记维修→ 查看所有租车订单 → 管理站点数据。很多初学者忽略的是这两类角色其实共用一套登录体系区别只在于登录后跳转到不同界面、拿到不同操作权限。所以设计数据库时用户表需要一个“角色字段”来区分身份而不是建两张用户表这是最省事的做法。1.2 状态字段这个系统真正的核心单车租赁系统和普通商品展示系统最大的不同在于单车是有限的共享资源每一辆车在同一时刻只能被一个人骑走。这就意味着“状态”是系统里最敏感的数据。单车维度有状态空闲、已租出、维修中、停用。订单维度也有状态租赁中、已完成、已取消。用户维度还要考虑一个用户同时只能有一个进行中的租赁订单。这些状态逻辑如果只靠前端控制刷新页面就失效了。真正可靠的方案是一切以数据库里的状态为准前端只是状态的展示器。1.3 为什么选MySQL加前端这套组合这类共享资源管理系统的数据天然带有强关联性用户拥有订单订单绑定单车单车属于站点。这种关系型数据场景MySQL这类关系型数据库是首选建表、查询、事务处理都很成熟。前端侧则可选范围很广用传统的HTML加JavaScript加Bootstrap能快速出界面用现成的后端语言PHP、Java、Node.js提供接口就能跑通全流程。对一个练手项目来说这个组合的学习性价比很高你会接触到数据库建模、接口设计、前后端联调、事务与并发这些恰恰是实际工作中最常用的基本功。2. 数据库设计四张核心表撑起整个系统2.1 用户表一个角色字段省掉两张表的麻烦用户表的核心字段包括用户ID、用户名、密码、手机号、角色、注册时间。这里有两个容易踩的坑。一是密码不要用明文存储就算这只是一个练习项目也要养成用哈希函数处理密码的习惯这是进真实的系统开发前必须养成的安全意识。二是角色字段用数字标识比用字符串更好比如0代表普通用户1代表管理员扩展新角色时加一个枚举值就行不用改表结构。CREATE TABLE user ( user_id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, phone VARCHAR(20), role TINYINT DEFAULT 0 COMMENT 0-普通用户 1-管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.2 单车表与站点表位置和状态都要可追踪单车表的字段要能回答这几个问题这辆车编号是多少、现在在哪、处于什么状态、能不能被租走。所以除了单车ID和编号关键字段有“状态”和“所属站点”。如果你还想做地图展示也可以加上经纬度字段不过课程设计阶段用站点字段就足够了。站点表和单车表是关联关系一辆车归属于一个站点。站点表可以记录站点名称、地址、容量。为什么需要站点因为真实的单车租赁讲究“定点取还”用户从A站取车必须还到B站或A站这样才能把物理位置管理起来对后续报表也有意义。CREATE TABLE bike ( bike_id INT AUTO_INCREMENT PRIMARY KEY, bike_number VARCHAR(20) NOT NULL UNIQUE, type VARCHAR(10) DEFAULT 普通, status TINYINT DEFAULT 0 COMMENT 0-空闲 1-已租出 2-维修中 3-停用, station_id INT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE station ( station_id INT AUTO_INCREMENT PRIMARY KEY, station_name VARCHAR(50) NOT NULL, address VARCHAR(100), capacity INT DEFAULT 30 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.3 订单表把“时间”和“费用”计算逻辑落在库里订单表是整个系统的记账本每一次租车还车的行为都应该生成一条不可变的历史记录。核心字段包括订单号、用户ID、单车ID、取车站点、还车站点、开始时间、结束时间、时长分钟、费用、状态。初学者最容易犯的错是订单表里不存费用等用户查询的时候再去算。看起来省了存储空间但实际上计费规则一变历史订单的金额就全乱了。正确的做法是在还车时就计算出费用并写入订单表让订单成为一份“当时结算的快照”。CREATE TABLE rent_order ( order_id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(30) NOT NULL UNIQUE, user_id INT NOT NULL, bike_id INT NOT NULL, start_station_id INT, end_station_id INT, start_time DATETIME NOT NULL, end_time DATETIME, duration_minutes INT, total_fee DECIMAL(10,2), status TINYINT DEFAULT 0 COMMENT 0-租赁中 1-已完成 2-已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, idx_user (user_id), idx_bike (bike_id), idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意这里的索引设置用户ID、单车ID、状态这三个字段都会频繁出现在查询条件里加索引能明显提升数据量变大后的查询速度。订单号设计成唯一索引防止重复提交订单时插入两条脏数据。2.4 外键要不要加这是一个值得讨论的点。纯教学场景建议加上外键约束强制保证数据的关联完整性比如删除用户时有未完成订单数据库会阻止你误删。等后续数据量变大、需要高并发写入的时候再去掉外键改用应用层逻辑控制那是另一个阶段的事情。我用外键踩过最深的坑是删除一辆单车时因为订单表还在引用它直接报错页面一片空白而我没做异常处理。所以如果你决定加外键就必须给所有可能的失败操作配上异常捕获否则数据库的严格约束会直接演变成线上事故。3. 前后端怎么握手接口设计和页面数据绑定3.1 统一返回格式不然前端会被你逼疯前后端联调的混乱一半来自接口格式不统一。一个稳的接口长这样{ code: 0, msg: success, data: { } }code为0代表成功非0代表业务失败msg给前端展示提示语data装真实数据。这样前端只用关注code而不是东一块西一块地判断各种空值和错误结构。我这里给一个实际的项目约定code约定4开头表示数据库查询异常5开头表示参数校验失败6开头表示业务冲突比如单车已被抢走。3.2 页面清单与对应接口规划一个完整但不过度设计的版本前端需要这些页面和接口对应关系页面核心接口关键操作登录注册页登录接口、注册接口登录后写入会话状态单车列表页获取单车列表接口展示所有空闲车辆及站点租车确认页租车接口提交租车请求跳转骑行中页骑行中页获取当前订单接口实时展示已骑行时长提供还车按钮还车结算页还车接口展示费用明细确认后写入订单个人中心页获取用户订单列表接口查看历史订单及状态管理后台单车上架/下架接口、订单列表接口维护数据前端拿到数据后做的事情就是把JSON里的字段渲染到HTML。比如单车列表接口返回一组单车数据前端用循环生成卡片状态字段显示成对应的中文标签和按钮颜色。3.3 租车和还车的完整时序每一步都在动数据库租车流程的后端逻辑是校验用户有没有进行中的订单 → 校验单车是否空闲 → 加锁查询单车状态 → 更新单车状态为已租用 → 插入一条租赁中状态的订单 → 提交事务。这个顺序很重要尤其是“校验后更新再插入”的顺序能极大程度避免并发问题。还车流程的逻辑是找到这个用户进行中的订单 → 更新订单的还车时间 → 计算时长和费用 → 更新订单状态为已完成 → 更新单车状态为空闲 → 更新还车站点 → 提交事务。还车逻辑里最需要注意的是先算费用再改状态最后写单车归属每一步都依赖上一步的结果。3.4 页面细节防重复点击是基本功前端交互一个常见的毛病是用户手快连点了两次“租车”按钮导致数据库里出现两条租赁订单。哪怕后端做了校验也应该在前端先装上“提交后按钮置灰”的逻辑。这不仅是体验问题也是为后端减负。记住一个原则前端做体验后端做安全两者缺一不可。4. 落地实操从建库到跑通一次完整租还流程4.1 初始化数据库把建表脚本一次性执行在实际开发中建议用一段统一的SQL脚本初始化整个数据库。前面给出了四张核心表的建表语句把它们合在一起再加上一个插入两条演示数据的语句就能快速进入开发状态。建库时把默认字符集设成utf8mb4一般不会出乱码。4.2 后端的数据库连接层换语言只是换写法后端语言的选择不影响设计思路这里我用最常见的PHP的PDO连接方式举例。配置环境时要注意PDO连接字符串里的charsetutf8mb4和建库时的字符集保持一致否则查询结果依然可能乱码。?php // db.php $host 127.0.0.1; $dbname bike_rental; $user root; $pass 123456; $dsn mysql:host$host;dbname$dbname;charsetutf8mb4; try { $pdo new PDO($dsn, $user, $pass); $pdo-setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (PDOException $e) { echo json_encode([code 500, msg 数据库连接失败]); exit; }4.3 登录接口的实现参数校验优先登录接口要做的第一步不是查数据库而是校验参数是否为空然后查用户表比对密码。用预处理语句防止SQL注入这是最低限度的安全要求。?php require db.php; $username $_POST[username] ?? ; $password $_POST[password] ?? ; if ($username || $password ) { echo json_encode([code 501, msg 用户名和密码不能为空]); exit; } $stmt $pdo-prepare(SELECT * FROM user WHERE username ?); $stmt-execute([$username]); $user $stmt-fetch(PDO::FETCH_ASSOC); if (!$user || !password_verify($password, $user[password])) { echo json_encode([code 401, msg 用户名或密码错误]); exit; } echo json_encode([ code 0, msg 登录成功, data [ user_id $user[user_id], username $user[username], role $user[role] ] ]);这里顺手提一句密码建议用PHP的password_hash(password, PASSWORD_DEFAULT)方式存入登录时用password_verify校验。数据库里存的是哈希串即使库被拖走用户密码不会立刻暴露。4.4 租车接口事务和状态锁是并发问题的解药租车接口是整个系统最核心的接口既要防并发也要防重复。我用事务加行锁的方式处理。开启事务后先对满足条件的单车行加锁再检查状态然后更新最后插入订单。这样一来两个同时提交的请求会依次排队执行后一个请求会因为查到状态已经不是空闲而失败。?php require db.php; $userId $_POST[user_id] ?? 0; $bikeId $_POST[bike_id] ?? 0; if (!$userId || !$bikeId) { echo json_encode([code 501, msg 参数错误]); exit; } try { $pdo-beginTransaction(); // 检查用户是否有进行中的订单 $stmt $pdo-prepare(SELECT COUNT(*) FROM rent_order WHERE user_id ? AND status 0); $stmt-execute([$userId]); if ($stmt-fetchColumn() 0) { $pdo-rollBack(); echo json_encode([code 601, msg 你有未完成的订单]); exit; } // 锁定单车行并检查状态 $stmt $pdo-prepare(SELECT status FROM bike WHERE bike_id ? FOR UPDATE); $stmt-execute([$bikeId]); $bike $stmt-fetch(PDO::FETCH_ASSOC); if (!$bike || $bike[status] ! 0) { $pdo-rollBack(); echo json_encode([code 602, msg 单车已被租走或不可用]); exit; } // 更新单车状态注意这里也带上了status条件双保险 $stmt $pdo-prepare(UPDATE bike SET status 1 WHERE bike_id ? AND status 0); $stmt-execute([$bikeId]); if ($stmt-rowCount() ! 1) { $pdo-rollBack(); echo json_encode([code 602, msg 单车状态已被修改]); exit; } // 插入订单 $orderNo date(YmdHis) . rand(1000, 9999); $stmt $pdo-prepare(INSERT INTO rent_order (order_no, user_id, bike_id, start_time, status) VALUES (?, ?, ?, NOW(), 0)); $stmt-execute([$orderNo, $userId, $bikeId]); $orderId $pdo-lastInsertId(); $pdo-commit(); echo json_encode([code 0, msg 租车成功, data [order_id $orderId, order_no $orderNo]]); } catch (Exception $e) { $pdo-rollBack(); echo json_encode([code 500, msg 系统繁忙请稍后再试]); }4.5 还车接口算出时间和费用并结算入库还车接口要拿订单的开始时间用当前时间去减算出分钟数。计费规则我在这版里定为“每15分钟1元不足15分钟按15分钟算”兼容各种奇葩骑行时间。?php require db.php; $orderId $_POST[order_id] ?? 0; $endStationId $_POST[end_station_id] ?? 0; if (!$orderId || !$endStationId) { echo json_encode([code 501, msg 参数错误]); exit; } try { $pdo-beginTransaction(); $stmt $pdo-prepare(SELECT * FROM rent_order WHERE order_id ? AND status 0 FOR UPDATE); $stmt-execute([$orderId]); $order $stmt-fetch(PDO::FETCH_ASSOC); if (!$order) { $pdo-rollBack(); echo json_encode([code 603, msg 订单不存在或已结束]); exit; } $start new DateTime($order[start_time]); $now new DateTime(); $minutes ($now-getTimestamp() - $start-getTimestamp()) / 60; $minutes max(1, ceil($minutes)); $fee ceil($minutes / 15) * 1.00; $stmt $pdo-prepare(UPDATE rent_order SET end_time NOW(), end_station_id ?, duration_minutes ?, total_fee ?, status 1 WHERE order_id ?); $stmt-execute([$endStationId, $minutes, $fee, $orderId]); $stmt $pdo-prepare(UPDATE bike SET status 0, station_id ? WHERE bike_id ?); $stmt-execute([$endStationId, $order[bike_id]]); $pdo-commit(); echo json_encode([code 0, msg 还车成功, data [duration_minutes $minutes, total_fee $fee]]); } catch (Exception $e) { $pdo-rollBack(); echo json_encode([code 500, msg 还车失败请检查站点数据]); }4.6 前端单车列表的渲染逻辑单车的列表页不用太复杂用JavaScript的fetch请求接口拿到数据后动态生成卡片。这里要注意的是不要直接用innerHTML拼未过滤的字符串避免XSS攻击课程设计阶段也建议养成这个习惯。fetch(api/getBikeList.php, { method: GET }) .then(res res.json()) .then(data { if (data.code ! 0) { alert(data.msg || 加载失败); return; } const list data.data; const container document.getElementById(bike-list); const statusMap { 0: 空闲, 1: 已租出, 2: 维修中, 3: 停用 }; container.innerHTML list.map(bike div classcol-md-4 mb-3 div classcard div classcard-body h5编号${bike.bike_number}/h5 p状态${statusMap[bike.status] || 未知}/p button classbtn btn-primary rent-btn >