
这段时间跟着尚硅谷的Java全栈项目把小谷充电宝从零到一搭了起来之前两篇聊完了项目选型、数据库设计和后端骨架这篇算是一个承上启下的节点。小谷充电宝本身是一个贴近真实商业场景的共享充电宝业务系统技术栈是以 Spring Boot MyBatis MySQL 为主配了 Redis、微信小程序端和运营后台。它解决的问题很典型扫码借充电宝、地图找桩、订单计费、支付退款整套流程和线下真实投放运营基本对齐。如果你正在学 Java 项目或者准备面试想找一个能讲清楚业务闭环的项目这个系列非常适合拿来反复啃。这篇我重点讲用户端从登录到下单的完整链路以及我在跟着敲代码时想明白的、还有踩过的坑。1. 整体业务梳理第三篇该从哪里切入1.1 从登录授权到地图选桩的用户闭环小谷充电宝的用户端不是简单的一个 CRUD 练习它模拟的是这样一条真实链路用户打开小程序微信授权登录进入地图页面看附近有哪些充电宝机柜点开某个机柜查看剩余电量、可借数量、计价规则然后扫码或确认租借系统生成订单并扣除相应费用最后用户归还充电宝系统根据租借时长结算账单多退少补。第三篇的内容正好卡在链路的前半段登录授权、地图数据、绑定设备、生成订单。这些都是后续计费和支付的前提。我在前两篇已经搭好了用户表、机柜表、充电宝表、订单表和基础的分层架构所以这篇会非常吃业务逻辑每一行代码的取舍都直接影响真实场景下的并发和数据一致性。1.2 服务端架构里我坚持的几个选型跟着项目走的时候我发现尚硅谷这套代码对新手比较友好的一点是它没有一上来就引入 Spring Cloud 那套分布式全家桶而是先用单体架构把业务跑通再在关键位置引入 Redis、MQ 等中间件。这样的好处是你能把精力集中在 Java 本身的功底上比如事务控制、接口设计、并发处理、MyBatis 的写法这些 Java 面试高频考点。小谷充电宝整体是经典的分层架构controller 层负责接收参数和返回结果service 层承载业务规则mapper 层负责数据库交互。看似简单但业务写多了你会发现真正难的从来不是框架怎么调而是订单状态怎么流转、一笔钱怎么保证不错账、并发请求来了怎么不超卖。这也是我为什么推荐你用这个项目去刷 Java 面试里的项目经验它几乎覆盖了面向对象设计、接口幂等、分布式锁、缓存一致性这些高频问题。1.3 这篇先立两个主线为了避免越写越散这一篇我只围绕两条主线展开。第一条是用户从授权登录到建立有效会话第二条是从地图加载到创建租借订单。这两条线走完用户端最核心的闭环就等于打通了。运营后台、营销活动这些内容我会放到下一篇单独讲。主线确定之后后面所有的代码逻辑和技术方案都围绕这两个场景来落地。这样我的笔记是连贯的你跟着看也能一直抓住重点不会迷失在细节里。2. 用户端登录与权限控制2.1 微信登录的三次握手与登录态建立小谷充电宝的用户端登录不是传统的用户名密码登录而是微信小程序特有的登录方式。整体机制是这样的小程序端调用 wx.login 获取一个临时 code然后后端拿着这个 code 去微信服务器换取 openid 和 session_key。openid 是用户在微信体系下的唯一标识session_key 主要用于解密用户手机号等敏感数据。真正落地的时候我们不能每次请求都去调微信服务器这样既慢又不稳定。所以正确的做法是用 openid 去查本地用户表如果用户不存在就帮他自动注册一条记录然后生成一个属于自己系统的 token 返回给小程序。后续所有请求都带着这个 token后端通过 token 认出用户身份而不是每次都和微信打交道。项目里 token 用的是自定义 UUID存进 Redis 并设置过期时间。这个设计很经典也很实用。你把它讲清楚比背十个八股文都有说服力。2.2 登录态过期与续期设计登录态不能永久有效这是安全常识。但小程序的使用场景是用户可能一周才打开一次如果登录态太短用户每次打开都要重新授权体验会很差。项目里我采用了滑动续期的策略用户每次发起请求时如果 token 的剩余有效时间低于总时长的一半就自动续期一次。这样做的本质是保证活跃用户永远不会被登出不活跃用户则在长时间未操作后自然过期。这个策略在真实项目里非常常见面试时讲出来是加分项。简单算一笔账如果设置 token 有效期是 7 天那么用户只要 3 天内有操作就会继续保持 7 天的完整登录态。不需要频繁掉登录也不需要担心 token 永久有效导致的安全问题。2.3 登录态在接口层的落地方式后端拦截器是落地登录态校验的主要方式。我会在 Spring Boot 里写一个 WebMvcConfigurer 的实现类配置拦截器要拦截哪些路径、放行哪些路径。比如登录接口本身要放行不然用户没 token 根本没法登录前端静态资源如果存在后端也要放行而订单、地图、钱包这类业务接口则全部拦下来统一从 request header 里取 token再做校验。我在自己项目里会把用户对象塞进 ThreadLocal这样 service 层在任意位置都能拿到当前登录用户。但这里有个很坑的细节进程内线程池会复用线程如果请求处理完不清理 ThreadLocal下一个请求就可能拿到上一个用户的身份造成数据越权。所以每次请求结束一定要在 finally 里 remove 掉。这种问题在测试环境不容易暴露等到线上压力一大就会时不时冒出用户看到别人订单的诡异 bug排查起来极其痛苦。3. 地图服务与充电宝机柜绑定3.1 地图数据背后的定位与距离计算用户登录进来第一眼看到的就是地图页iOS 或 Android 原生应用可以直接用高德腾讯地图 SDK小程序端则通常用腾讯地图的 JavaScript SDK。服务端要做的事情是提供附近机柜的列表而不是把全表数万条数据一次返回给前端那样性能必崩。我在这步里做的是小程序上传当前经纬度后端以该坐标为中心按距离排序并做分页查询。由于项目用的是 MySQL没有引入 MongoDB 或 ES 这类天然的坐标检索工具所以距离计算是核心问题。真实场景下最常用的做法有两种一种是简单的 geo 编码方案按经纬度网格过滤另一种是自己写 Haversine 公式做球面距离计算。这个项目用的是后者Mysql 中可以直接写 SQL 带公式查询但如果你数据量到了几十万条这个方案会有性能瓶颈需要考虑通过范围编码缩小扫描集。项目示例 SQL 大致长这样SELECT id, name, lng, lat, ROUND(6371 * acos( cos(radians(用户纬度)) * cos(radians(lat)) * cos(radians(lng) - radians(用户经度)) sin(radians(用户纬度)) * sin(radians(lat)) ), 2) AS distance FROM cabinet WHERE status 1 ORDER BY distance ASC LIMIT 20;这个 SQL 能跑但你要清楚它的计算开销。如果机柜表数据量大这个查询会变成接口瓶颈。实际项目中通常会有两级策略先用矩形或网格粗筛一个候选集合再用 Haversine 精排。这样既能保证正确性也能把查询范围控制在几百条以内。3.2 绑定充电宝与机柜的码牌逻辑地图上看到的机柜点背后还有一个业务闭环要处理——充电宝和机柜的绑定关系。真实共享充电宝的每个宝都对应一个唯一编号用户借出后这个宝跟着用户走归还时必须归到某一个机柜的某一个槽位。所以机柜下面会有多槽位的概念每个槽位要么空闲要么绑定了一个充电宝。这个项目里机柜表的业务编号通常是一个类似 cabinet_0001 的标识充电宝则是 power_bank_xxxx它们在入库时就要做绑定。要注意的是充电宝的绑定状态必须和订单流程联动。比如用户借出后充电宝状态要从可借改成租借中机柜的可用槽位数量要减一归还后状态再反转。如果不控制并发就会出现同一个充电宝同时被两个人借走的脏数据。这里我吃过一个亏最初只用 update 语句做状态变更前端连续快速点击借出按钮时两个请求同时读到可借状态都生成了订单。后来我把机柜编号和充电宝编号加进乐观锁的版本号控制里同时在数据库层面也做了唯一索引约束双保险之后才彻底堵住这个并发漏洞。面试的时候你可以把这个场景当成一个典型的 Java 并发控制案例去讲。3.3 数据一致性的兜底手段状态类字段的变更项目里最常见的手段是数据库层增加唯一性约束代码层用乐观锁或 Redis 分布式锁来保护关键路径。小谷充电宝的绑定场景我用了两种兜底并行一是数据库层给关键字段加联合唯一索引。比如同一个机柜同一个槽位不能同时绑定两个充电宝这个约束写成唯一索引后即使代码有 bug数据库也会拒绝脏数据。二是 Redis 分布式锁。以 cabinet:bind:slot_3 或者 power_bank:xxx 作为锁 key用 setnx 命令加锁处理完后释放。Redis 的锁要设置合理的过期时间防止业务处理时间过长导致锁被提前释放引发并发问题。过期时间设短了长事务还没结束锁就没了设长了Redis 宕机期间锁无法自动恢复。我一般会在项目里设置为业务超时时间的 2-3 倍同时用 Lua 脚本保证判断和删除锁的原子性。4. 订单流程与支付回调4.1 发起借充电宝的订单流程订单是整个小谷充电宝的业务核心。用户从机柜详情页点租借后端要做的事情不只是 insert 一条订单记录那么简单。完整的步骤应该是先校验机柜和充电宝的状态是否可借再生成订单号然后扣减机柜可用宝数更新充电宝状态写入订单表最后返回订单相关信息给前端。这四步必须是一个原子操作任何一步失败都不能留下半成品数据。项目里用的是 Spring 的 Transactional 来管理事务。但这里有一个很多人忽略的点事务方法不能吞掉异常否则事务不会回滚。如果你在事务方法内部 catch 住异常并且没有重新抛出那这一步就悄悄提交了状态就脏了。我自己的经验是事务方法里尽量不写 try-catch异常交给外层统一处理这样事务边界最清晰。订单号生成也有讲究。业务上订单号要具备唯一性和可读性。常见方案是时间戳随机数也可以引入 Redis 自增序号。我比较推荐用雪花算法既保证了全局唯一又不会太长。小谷项目里用了时间戳加随机序列的方式应付当前量级足够但如果你准备把它讲成生产级项目雪花算法会更严谨一些。4.2 事务与 Redis 分布式锁的配合有了 Transactional 之后是不是就高枕无忧了并不是。分布式系统里多个实例同时运行本地事务无法跨进程锁住共享资源。比如两个用户同时借同一个充电宝如果请求落在两个不同的服务实例上每个实例的本地事务都无法感知对方最终两个事务都提交成功充电宝被借了两次。实际项目中解决这类问题就是在事务外层包一层分布式锁。我用 Redis 的 setnx 命令实现分布式锁核心思路是在真正执行业务逻辑之前先尝试获取锁获取成功的才能继续执行获取失败的直接返回正在处理中。业务执行完finally 里释放锁。String lockKey lock:powerBank: powerBankId; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (!locked) { throw new BusinessException(该充电宝正在被借出请稍后再试); } try { // 执行业务逻辑 } finally { redisTemplate.delete(lockKey); }这个代码看起来简单但要注意setIfAbsent 同时加过期时间这个操作必须是原子的。如果你分开两步写——先 setnx再 expire——那在中间这几十毫秒如果 Redis 崩溃或服务重启锁没有过期时间就成了死锁。这个坑面试问得很频繁很多人都在这里翻车。4.3 支付回调的幂等处理订单创建之后用户需要支付押金或租借费用。小程序支付走的是微信支付的统一下单接口支付成功后微信服务器会异步回调你配置的通知地址。回调必须保证幂等因为微信的支付通知机制是只要没收到成功应答就会重复通知可能几次、几十次直到你明确返回成功才停。我第一次写回调时只判断了订单状态是未支付就更新成已支付结果第二天测试时发现同一条订单被更新了三四次金额没多扣但订单状态被覆盖得很乱。后来我改成先查订单当前状态如果是已支付直接返回成功应答只有未支付才继续处理。同时数据库里给订单表加了一个 out_trade_no 的唯一索引从物理层面防止重复插入或重复更新。回调处理还有一个隐藏点就是金额校验。回调里的 transaction_id、total_fee 要和本地订单的应付金额做比对一分钱都不能差。虽然支付平台已经校验过一次但后端拿到回调后必须自己再校一次。这是做支付系统的底线不做等于裸奔。4.4 退款链路里的坑用户归还充电宝后如果之前交了押金平台要把押金扣掉使用费后退还用户。这里最容易出问题的不是退款失败而是退款重复。微信支付的退款接口支持传入退单号 out_request_no这个参数必须唯一同一笔退单号重复提交会被微信拒绝。我最初没有为退款单做单独的表直接在订单表加了一个退款状态字段结果运营反馈有个别订单退款单号重复生成。后来我补了一张 refund_order 表每条退款请求生成唯一退单号并且在数据库层面加了唯一索引。经历这次之后我对资金类业务变得极其保守凡是涉及钱的表能加唯一索引的地方绝不省。退款状态流转也要清晰。比如用户发起退款后订单状态进入退款中微信异步通知成功后变成退款成功失败则变回可退款。每种状态的变化最好都有时间字段记录方便后续对账排查。5. 常见问题与排查技巧实录5.1 微信登录 code 重复使用导致会话异常小程序端如果把 code 缓存下来反复用后端第二次拿着同一个 code 去换 openid 时微信服务器会返回错误。解决方法是后端一定要对 code 的时效性有预期同时前端每次打开小程序都必须重新调用 wx.login 获取新 code。我在调试时发现微信的 code 有效期只有五分钟而且只能使用一次所以任何尝试复用 code 的思路都是错的。5.2 用户定位跨城市导致地图空白地图接口如果只按距离排序用户在某个城市边缘或基站定位漂移时可能拉到几公里之外的机柜但附近 500 米内啥都没有页面一片空白。后来我加了城市编码字段做粗筛优先展示同城的机柜。同时前端可以展示一个可借范围提示避免用户误以为附近没有网点。这个细节在业务上很小但对用户体验影响非常大。5.3 并发下单导致的库存负数因为一开始没有把机柜的宝数扣减放在事务和锁的保护下测试环境压测时出现过库存负数的离谱数据。后来想了三层方案第一层 Redis 分布式锁拦并发第二层数据库乐观锁 version 字段防止脏写第三层在 service 层下单前再次校验库存余量。三层叠上去之后再压测库存就是正常扣减了。真实项目里库存扣减还可以引入数据库行级锁也就是 select ... for update。但这个方法要想清楚锁的范围范围太大会把别的机柜的下单请求也锁住性能下降。我在项目里只在单个充电宝或单个槽位的场景下用它范围控制得很小。5.4 排查思路速查表现象可能原因排查步骤解决方案用户登录失败code 过期或被复用检查后端日志中微信接口返回码前端每次登录重新获取 codetoken 失效频繁续期逻辑没触发查看 Redis 中 token 的剩余过期时间调整续期阈值统一在拦截器处理地图数据稀疏距离计算范围限制打印用户坐标与机柜坐标增加城市粗筛放宽分页大小同一充电宝被借两次并发下单未加锁查看同一时刻订单表的创建时间加分布式锁 乐观锁回调重复处理幂等逻辑缺失查订单更新时间与回调次数状态判断 唯一索引双兜底退款重复发起退款单缺少唯一标识查退款记录是否存在相同退单号建退款单表并加唯一索引这些坑不是我在背文档而是跟着小谷充电宝这个项目一步步走下来真实踩过的。很多细节在视频课里只是一笔带过但到了自己写代码的时候才懂得为什么要设计得这么绕。尚硅谷这套项目的价值也正在这里——它的代码不是炫技而是把生产环境最常见的业务问题用 Java 技术栈老老实实地解决了一遍。我个人在实际项目中最受用的一个习惯是每写完一个模块就回到数据库把表和表之间的关联捋一遍拿几条脏数据模拟一下并发场景看自己的代码能不能兜住。这个习惯让我在第三篇里提前堵掉了好几个隐患。做业务系统把正常链路跑通只是及格能做到把异常链路也兜住才算是真正吃透了项目。