
简介这份资源是面向微信小程序开发者与全栈学习者的共享单车项目实战代码包包含小程序前端与后端服务两部分适合想通过完整案例理解线上线下结合业务逻辑、提升全栈能力的中级开发者。压缩包共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 里对status0的进行中订单生效但已完成订单status1会有多条所以这个唯一索引其实不能直接这么建——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 版本别硬上 17MySQL5.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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse 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 小时订单时间全乱useSSLfalse在本地开发避免证书报错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?, versionversion1 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?bikeNo1001userId1 # 期望返回 {code:0,msg:ok,data:订单号}如果返回 500先看控制台异常栈如果返回「车辆不存在」检查数据库里有没有对应bike_no的记录如果一直「操作太频繁」说明 Redis 里锁没释放检查 finally 块是否执行。这一步跑通说明后端核心链路是活的再去做小程序端对接才有意义。4. 小程序端对接登录、扫码、地图三个页面的关键参数小程序端看着是前端活但对接后端时参数传错、域名没配、openid 拿不到一样卡住。这一章讲三个核心页面怎么和后端对上。4.1 登录换 openidcode 只能用一次小程序登录流程是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% 的并发问题比上线后用户投诉再查强太多。希望帮到你。本文还有配套的精品资源点击获取