☰
共享单车小程序后端实战:数据模型、扫码开锁与计费锁控避坑指南
2026/10/9 21:11:30 网站建设 项目流程

简介:这份资源是面向微信小程序开发者与全栈学习者的共享单车项目实战代码包,包含小程序前端与后端服务两部分,适合想通过完整案例理解线上线下结合业务逻辑、提升全栈能力的中级开发者。压缩包共474个文件,约2.66MB,以gif动图、xml配置、png图片、js脚本、css样式、java类文件、json数据、html页面及wxml/wxss小程序页面为主,覆盖界面素材、业务逻辑、接口配置与后端实体类等模块。项目围绕用户注册登录、单车位置查询、租赁归还、费用计算等核心流程展开,涉及微信小程序API调用、RESTful接口设计、地理定位与地图服务集成、支付系统对接以及数据安全与隐私保护等知识点,后端可见用户、单车、区域等业务类与控制器实现。已有557人学习下载,可帮助读者对照源码梳理小程序与服务器通信机制,理解共享单车服务的运营逻辑,并积累接口设计与调试经验。

1. 共享单车小程序这套代码,真正难啃的是后端那半截

很多人拿到「基于小程序的共享单车项目(小程序+后端代码)」这类压缩包,第一反应是打开小程序端看页面,扫码开锁、地图找车、行程记录,界面跑起来挺像回事,就觉得这项目能交差。但真正决定它能不能落地、能不能改成自己的东西,全在后端那半截:车辆状态怎么流转、订单怎么防重复、锁控指令怎么下发、计费怎么算。前端只是壳,后端才是黑匣子。

这篇笔记面向三类人:想拿这套代码做课程设计或毕设的学生、想快速搭一套共享出行 Demo 验证商业逻辑的开发者、以及被「扫码开锁」四个字骗进来结果发现后端一团乱麻的倒霉蛋。我会按「先讲清数据模型和状态机 → 再动手把后端跑起来 → 然后接小程序端 → 最后讲计费和锁控这两个最容易翻车的点」的顺序推。不吹架构多先进,只讲怎么让它真能跑、参数怎么调、哪里会踩坑。

2. 先立住数据模型:车辆、订单、用户三张表怎么设计才不返工

共享单车的业务本质是「一辆车在某个时刻只能被一个订单占用」,这句话翻译成数据库约束,就是整套系统的地基。地基没打好,后面扫码开锁会出现同一辆车被两个人同时开走,或者订单结束了车还显示占用,这种问题排查起来极其痛苦,属于典型的后悔药没处买。

2.1 车辆表:状态字段是核心,别只存一个 status

车辆表看着简单,但状态设计直接决定并发安全。常见做法是给车辆一个状态字段,但只存「空闲/使用中」两态是不够的,因为中间还有「已预约未开锁」「故障」「调度中」这些过渡态。我一般会这样建:

CREATE TABLE bike ( id BIGINT PRIMARY KEY AUTO_INCREMENT, bike_no VARCHAR(32) NOT NULL UNIQUE COMMENT '车辆编号,扫码用', lock_id VARCHAR(64) NOT NULL COMMENT '锁硬件编号', status TINYINT NOT NULL DEFAULT 0 COMMENT '0空闲 1已预约 2使用中 3故障 4调度中', lat DECIMAL(10,7) COMMENT '当前纬度', lng DECIMAL(10,7) COMMENT '当前经度', battery TINYINT COMMENT '锁电池电量百分比', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_status (status), INDEX idx_geo (lat, lng) );

逻辑说明:status用数字枚举而不是字符串,是为了索引效率和比较速度;version是乐观锁字段,后面开锁时会用到;lat/lng建联合索引是为了「附近的车」查询,虽然精度有限,但 Demo 够用。参数上,battery建议低于 20% 就自动置为故障态,避免用户扫到没电的车。

2.2 订单表:唯一索引是防重复开锁的最后一道防线

订单表最容易出的问题是同一用户短时间重复下单,或者同一辆车被两个订单占用。光靠代码里判断「车是否空闲」不够,因为并发下两个请求可能同时读到空闲。稳妥做法是加唯一索引兜底:

CREATE TABLE ride_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '业务订单号', user_id BIGINT NOT NULL, bike_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME DEFAULT NULL, start_lat DECIMAL(10,7), start_lng DECIMAL(10,7), end_lat DECIMAL(10,7), end_lng DECIMAL(10,7), amount DECIMAL(10,2) DEFAULT 0 COMMENT '订单金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0进行中 1已完成 2已取消', UNIQUE KEY uk_bike_active (bike_id, status) COMMENT '同一辆车只能有一个进行中订单', INDEX idx_user (user_id, status) );

这里有个关键点:uk_bike_active (bike_id, status)这个唯一索引,在 MySQL 里对status=0的进行中订单生效,但已完成订单status=1会有多条,所以这个唯一索引其实不能直接这么建——MySQL 不支持部分索引。实际落地时,常见做法是加一个冗余字段active_flag,进行中为 1,结束为 NULL,然后对(bike_id, active_flag)建唯一索引,因为 NULL 不参与唯一性判断。这个坑我在下面避坑章节会再展开。

2.3 用户表与钱包:余额扣减必须走事务

用户表除了基础信息,还要有押金和余额字段。计费扣款时,扣余额和写订单必须在同一个事务里,否则会出现订单完成了钱没扣,或者钱扣了订单没结束。

CREATE TABLE app_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) NOT NULL UNIQUE COMMENT '小程序openid', nickname VARCHAR(64), deposit DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '押金', balance DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '余额', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0冻结', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );

参数说明:deposit和balance用DECIMAL而不是FLOAT,因为金额计算不能有浮点误差;openid唯一索引保证一个微信用户只对应一条记录。扣款逻辑建议用UPDATE app_user SET balance = balance - ? WHERE id = ? AND balance >= ?,靠数据库行锁保证不会扣成负数。

3. 把后端跑起来:从配置文件到扫码开锁接口的最小闭环

后端跑不起来,前端再漂亮都是摆设。这一章按「环境准备 → 配置改哪几个参数 → 启动 → 调通开锁接口」的顺序走,每一步都给出可抄的命令和代码。

3.1 环境与依赖:JDK、MySQL、Redis 三件套

这类项目后端常见是 Spring Boot + MyBatis + MySQL + Redis 的组合。Redis 用来存车辆实时状态和分布式锁,没有它并发开锁会出问题。环境版本建议:

组件建议版本说明
JDK8 或 11看 pom.xml 里 source 版本,别硬上 17
MySQL5.7 或 8.08.0 注意驱动类名和时区参数
Redis5.0+用于车辆状态缓存和锁
Maven3.6+构建用

启动前先确认 MySQL 和 Redis 都在跑:

# 检查 MySQL 是否可连,替换成自己的账号密码 mysql -h 127.0.0.1 -P 3306 -u root -p -e "SELECT 1;" # 检查 Redis redis-cli ping # 返回 PONG 才算正常

如果 Redis 返回NOAUTH Authentication required,说明设了密码,配置文件里要补上spring.redis.password。

3.2 配置文件:这五个参数不改一定连不上

后端项目的application.yml或application.properties里,下面几个参数是必须按自己环境改的,改错一个就启动失败:

spring: datasource: url: jdbc:mysql://127.0.0.1:3306/shared_bike?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: 127.0.0.1 port: 6379 password: # 没设密码就留空 database: 0 # 小程序相关,换成自己的 wx: appid: 你的小程序appid secret: 你的小程序secret

逻辑说明:serverTimezone不设会导致时间差 8 小时,订单时间全乱;useSSL=false在本地开发避免证书报错;driver-class-name在 MySQL 8.0 下必须是com.mysql.cj.jdbc.Driver,用老的com.mysql.jdbc.Driver会警告甚至连不上。wx.appid和wx.secret是换取用户 openid 用的,不填的话登录接口直接 500。

3.3 扫码开锁接口:一个接口串起校验、锁、状态流转

开锁是整个系统最核心的接口,它要完成:校验用户 → 校验车 → 加分布式锁 → 改车辆状态 → 创建订单 → 下发锁控指令。下面是一个精简后的实现:

@PostMapping("/bike/unlock") @Transactional(rollbackFor = Exception.class) public Result unlock(@RequestParam String bikeNo, @RequestParam Long userId) { // 1. 查车辆,走缓存 Bike bike = bikeCache.getByNo(bikeNo); if (bike == null) { return Result.fail("车辆不存在"); } if (bike.getStatus() != 0) { return Result.fail("车辆当前不可用"); } // 2. Redis 分布式锁,key 用车辆编号,防止并发开同一辆车 String lockKey = "lock:bike:" + bikeNo; String lockVal = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockVal, 10, TimeUnit.SECONDS); if (Boolean.FALSE.equals(locked)) { return Result.fail("操作太频繁,请稍后再试"); } try { // 3. 乐观锁更新车辆状态,version 不匹配说明被别人改了 int rows = bikeMapper.updateStatus(bike.getId(), 0, 2, bike.getVersion()); if (rows == 0) { return Result.fail("车辆状态已变化,请重新扫码"); } // 4. 创建订单 RideOrder order = new RideOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setBikeId(bike.getId()); order.setStartTime(new Date()); order.setStatus(0); orderMapper.insert(order); // 5. 下发开锁指令,失败则回滚 boolean sent = lockClient.sendUnlock(bike.getLockId()); if (!sent) { throw new BizException("锁控指令下发失败"); } return Result.ok(order.getOrderNo()); } finally { // 6. 释放锁,比对 value 防止误删别人的锁 if (lockVal.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } }

逻辑说明:第 2 步的分布式锁保证同一辆车同一时刻只有一个请求在处理;第 3 步的乐观锁updateStatus对应的 SQL 是UPDATE bike SET status=?, version=version+1 WHERE id=? AND status=? AND version=?,双重保险;第 5 步锁控指令失败要抛异常触发事务回滚,否则会出现订单创建了但车没开。参数上,锁过期时间 10 秒是经验值,太长会阻塞,太短可能业务没跑完锁就没了。

3.4 启动与验证:用 curl 先跑通再连小程序

后端启动后,别急着开小程序,先用 curl 验证接口通不通:

# 启动,看控制台有没有 Started Application mvn spring-boot:run # 验证开锁接口,替换成实际参数 curl -X POST "http://127.0.0.1:8080/bike/unlock?bikeNo=1001&userId=1" # 期望返回 {"code":0,"msg":"ok","data":"订单号"}

如果返回 500,先看控制台异常栈;如果返回「车辆不存在」,检查数据库里有没有对应bike_no的记录;如果一直「操作太频繁」,说明 Redis 里锁没释放,检查 finally 块是否执行。这一步跑通,说明后端核心链路是活的,再去做小程序端对接才有意义。

4. 小程序端对接:登录、扫码、地图三个页面的关键参数

小程序端看着是前端活,但对接后端时参数传错、域名没配、openid 拿不到,一样卡住。这一章讲三个核心页面怎么和后端对上。

4.1 登录换 openid:code 只能用一次

小程序登录流程是wx.login拿 code,传给后端换 openid。这里最常见的坑是 code 被重复使用,第二次就失效。

// 小程序端登录 wx.login({ success: (res) => { if (!res.code) { console.error('登录失败', res.errMsg); return; } wx.request({ url: 'https://你的域名/api/user/login', method: 'POST', data: { code: res.code }, success: (r) => { // 后端返回 token 和用户信息,存本地 wx.setStorageSync('token', r.data.data.token); wx.setStorageSync('userId', r.data.data.userId); } }); } });

逻辑说明:res.code有效期很短且只能用一次,所以拿到后要立刻发给后端,不要缓存。后端拿 code 调微信接口换 openid 时,appid和secret必须和小程序后台一致,否则报invalid code。参数上,wx.request的url必须是 HTTPS 且在小程序后台配置了合法域名,本地调试可以在开发者工具里勾选「不校验合法域名」。

4.2 扫码开锁:扫码结果要解析出车辆编号

小程序扫码用的是wx.scanCode,扫到的内容可能是纯编号,也可能是带参数的 URL,解析逻辑要兼容。

wx.scanCode({ onlyFromCamera: true, // 只允许相机扫码,防止从相册选图作弊 scanType: ['qrCode', 'barCode'], success: (res) => { // 兼容纯编号和 URL 两种形式 let bikeNo = res.result; if (bikeNo.includes('bikeNo=')) { bikeNo = bikeNo.split('bikeNo=')[1].split('&')[0]; } wx.request({ url: 'https://你的域名/api/bike/unlock', method: 'POST', header: { 'token': wx.getStorageSync('token') }, data: { bikeNo: bikeNo, userId: wx.getStorageSync('userId') }, success: (r) => { if (r.data.code === 0) { wx.showToast({ title: '开锁成功' }); } else { wx.showToast({ title: r.data.msg, icon: 'none' }); } } }); } });

逻辑说明:onlyFromCamera: true是防止用户从相册选一张别人的二维码来开锁,这个参数在真实运营里很重要。bikeNo的解析要兼容后端生成二维码的格式,如果后端二维码是 URL,就要按分隔符提取。参数上,scanType指定二维码和一维码,按实际锁上的码类型配。

4.3 地图找车:经纬度坐标系别搞混

地图页要显示附近的车,后端返回的经纬度如果是 GPS 坐标(WGS84),直接丢给微信地图会偏移几百米,因为微信地图用的是 GCJ02。

// 后端返回车辆列表后,转换坐标再渲染 const bikes = res.data.data; const markers = bikes.map(b => ({ id: b.id, latitude: b.lat, longitude: b.lng, iconPath: '/images/bike.png', width: 32, height: 32 })); this.setData({ markers });

逻辑说明:如果后端存的是 WGS84,需要先做坐标转换再渲染,否则用户按地图找车永远找不到。常见做法是后端存 GCJ02,或者前端引入转换函数。参数上,markers的id要唯一,iconPath图片别太大,否则地图卡顿。

5. 避坑与排查:这五个问题我踩过,你别再踩

这一章全是血泪经验,每条按「现象 → 原因 → 解决」写,遇到对应症状直接对号入座。

5.1 同一辆车被两个人同时开锁

现象:两个用户几乎同时扫码,都提示开锁成功,但实际只有一把锁开了,订单却生成了两条。原因:只靠代码里「查状态再更新」有并发窗口,两个请求都读到空闲。解决:Redis 分布式锁 + 数据库乐观锁双保险,如 3.3 节所示;同时给订单表加(bike_id, active_flag)唯一索引兜底,active_flag进行中为 1,结束置 NULL。

5.2 订单结束后车辆状态没变回空闲

现象:用户还车了,订单显示已完成,但车辆还是「使用中」,别人扫不了。原因:还车接口里更新订单和更新车辆不在同一事务,或者更新车辆时 version 不匹配被静默失败。解决:还车逻辑加@Transactional,更新车辆状态时检查影响行数,为 0 就抛异常回滚;同时加定时任务扫描超过 24 小时的进行中订单,强制结束并释放车辆。

5.3 计费金额出现 0.30000000000000004

现象:订单金额算出来一堆小数位,对不上账。原因:用了float或double做金额运算。解决:数据库用DECIMAL(10,2),Java 里用BigDecimal,且BigDecimal比较用compareTo不用equals。计费公式建议按「起步价 + 时长费」分段算,每段单独BigDecimal运算后相加。

5.4 小程序请求后端报「不在以下 request 合法域名列表中」

现象:开发者工具里接口能通,真机预览就报域名不合法。原因:小程序后台没配 request 合法域名,或者配了但没生效。解决:登录小程序后台,在「开发管理 → 开发设置 → 服务器域名」里把后端域名加进 request 合法域名,必须是 HTTPS 且已备案。本地调试可在开发者工具「详情 → 本地设置」勾选不校验合法域名,但真机必须配。

5.5 锁控指令下发成功但锁没开

现象:接口返回开锁成功,订单也创建了,但用户那边锁没弹开。原因:锁控指令是异步的,接口返回的「成功」只代表指令发出去了,不代表锁执行了。解决:锁控改成「下发指令 → 等待锁回调 → 回调成功才返回开锁成功」,或者接口返回「开锁中」,前端轮询订单状态。参数上,锁回调超时时间建议 5 秒,超时则自动取消订单并释放车辆。

6. 计费与锁控的进阶处理:让 Demo 更接近能用的状态

前面把主链路跑通了,但真要用起来,计费和锁控这两块还得再打磨。这一章讲两个具体技巧,都是我在改这类项目时反复用到的。

6.1 计费规则做成配置,别写死在代码里

计费规则经常要调,写死在代码里每次改都要重新部署。常见做法是抽成配置表,运营后台可改:

CREATE TABLE price_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, city_code VARCHAR(16) NOT NULL COMMENT '城市编码', start_price DECIMAL(10,2) NOT NULL COMMENT '起步价', start_minutes INT NOT NULL COMMENT '起步时长(分钟)', per_minute DECIMAL(10,2) NOT NULL COMMENT '超出后每分钟单价', daily_cap DECIMAL(10,2) NOT NULL COMMENT '单日封顶', effective_at DATETIME NOT NULL COMMENT '生效时间', INDEX idx_city (city_code, effective_at) );

计费时按订单开始时间匹配当时生效的规则,算出金额。参数上,daily_cap是防止用户骑一整天费用爆炸,真实运营里很常见。计算逻辑用BigDecimal,起步价覆盖start_minutes分钟,超出部分按per_minute乘分钟数,最后和daily_cap取小。

6.2 锁控回调用消息队列削峰

如果车辆多,锁控回调集中上报会把接口打挂。常见做法是回调先写消息队列,后端异步消费更新订单和车辆状态。没有消息队列的话,至少用 Redis 列表做缓冲:

// 锁回调入口,只做入队,快速返回 @PostMapping("/lock/callback") public String lockCallback(@RequestBody LockCallback cb) { // 写入 Redis 列表,key 按锁编号分片 String key = "lock:callback:" + (cb.getLockId().hashCode() % 8); redisTemplate.opsForList().leftPush(key, JSON.toJSONString(cb)); return "ok"; // 立刻返回,避免锁端重试 } // 定时任务消费 @Scheduled(fixedDelay = 200) public void consumeCallback() { for (int i = 0; i < 8; i++) { String key = "lock:callback:" + i; String json = redisTemplate.opsForList().rightPop(key); if (json == null) continue; LockCallback cb = JSON.parseObject(json, LockCallback.class); // 根据回调结果更新订单和车辆状态 orderService.handleLockCallback(cb); } }

逻辑说明:回调入口只入队不处理,保证锁端快速拿到响应,不会因为后端处理慢而重试;消费端按分片 key 并行消费,避免单 key 热点。参数上,fixedDelay = 200是消费间隔,按回调量调,量大就调小或加消费者。这个方案比直接同步处理稳得多,属于我踩过「回调把接口打挂」之后的固定做法。

最后说个习惯:这类项目我改完主链路后,一定会写一个「并发开同一辆车」的测试脚本,用多线程同时打 20 次开锁接口,看是不是只有一次成功。这个测试能提前暴露 90% 的并发问题,比上线后用户投诉再查强太多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询