
先说我为什么会想写这个项目。这几年学校周边的零食需求一直很旺盛一到晚上宿舍楼里就有人喊着“谁要辣条、谁要泡面”但线下小店能覆盖的范围有限配送也谈不上及时。我去年就带着几个学生做了一套校园零食商城购物配送APP后端用SpringBoot管理后台用Vue3用户端和骑手端都跑在Android上。做完以后从下单、支付、商家接单到骑手配送整条闭环都能跑通。这篇博文直接把项目的核心思路、模块划分、关键代码和踩坑记录都摊开来讲适合正打算做类似校园电商项目的开发者参考无论是毕业设计、课程设计还是真想在校内落地运营都可以照着这套方案去搭。整个项目最值钱的地方不是某个单独的页面写得多花哨而是三端之间如何配合、订单状态如何流转、库存和支付怎么保持一致。这些才是最磨人、也最体现功力的部分。1. 三端架构与业务闭环设计项目最核心的一张图先别急着写代码把“谁在用这个系统”想清楚后面会省掉大量返工。这个校园零食商城的用户角色分三种普通学生用户、商家或运营方、骑手配送员。对应的客户端也自然分成三块学生端Android APP、骑手端APP以及商家和管理员共用的Vue3后台管理系统。后端统一由SpringBoot对外提供接口。我见过很多项目把三个端的功能塞进一个APP里用角色切换来区分结果代码耦合严重改一个端就要重新编译整个工程。实际开发时还是拆分得更干净一些。用户端和骑手端虽然都是Android工程但两者功能边界完全不同建议直接建两个独立的模块工程共享网络层和基础组件的部分用Android的library模块抽取出来。1.1 用户端、骑手端、管理后台各管哪些事用户端负责的事情非常直观注册登录、浏览零食分类和商品列表、查看商品详情、加入购物车、提交订单、选择配送地址、在线支付这里通常用模拟支付或接入微信/支付宝的学生支付场景、查看订单状态、确认收货、售后申请。骑手端核心是配送任务的闭环在线接单、查看配送列表、导航到取货点、确认取货、送达确认。骑手端不需要管理商品也不需要处理支付页面少但状态流转频繁后台推送或者轮询的实时性要求高。Vue3管理后台最复杂因为它要同时服务商家和管理员两类人商家管理自己的商品上下架、库存数量、订单处理接单/拒单、店铺信息管理员管理用户列表、审核商家入驻、查看平台订单流水、处理售后、管理配送员。如果有公告、优惠券、Banner轮播图这类营销功能也都在管理后台维护。三端关系画成一句话就是用户在APP创建订单商家在后台接单骑手在APP配送最后用户在APP确认收货。整个链路通过订单状态串起来。1.2 订单状态机整个系统的心脏订单状态是这个项目里最核心的一张状态流转图。没有状态机的设计三端之间各写各的逻辑联调时肯定乱成一锅粥。我在这里用枚举把状态定死前端的按钮显隐、后端的接口校验全部只认这一套枚举值。订单状态我设计成八个枚举待支付用户提交订单后未支付已支付/待接单支付成功等待商家确认已接单/备货中商家看到新订单后接单配货完成/待取货商家出餐打包完成配送中骑手取货后开始配送已完成用户确认收货订单闭环已取消用户支付前取消或超时未支付自动取消售后中/退款中用户申请退款或物流异常状态流转的规则只有几条待支付可以取消或支付支付后只能退款不能直接取消商家接单后进入备货配送完成前用户不能确认收货退款在任何一方未完成前都可以发起但完成后退款通道要单独走。public enum OrderStatus { PENDING_PAY(0, 待支付), PAID(1, 已支付), ACCEPTED(2, 已接单), READY(3, 待取货), DELIVERING(4, 配送中), COMPLETED(5, 已完成), CANCELLED(6, 已取消), AFTER_SALE(7, 售后中); }这个枚举前后端各维护一份接口直接传数字谁都不能自己改状态值。上线前我跟学生对过一遍所有状态转换的路径列出合法转换表前后端对照着写联调时的扯皮少了一大半。1.3 库存和支付容易出事的两个支线零食商城的库存单位是“包/瓶/袋”不是按重量卖所以库存管理相对简单就是整数的增减。但并发下单时很容易超卖尤其是搞秒杀活动的时候。这个项目里我先用数据库乐观锁兜底再配合Redis预扣减库存避免同一件商品被两个用户同时买走的数量超过库存量。支付这块受限于校园场景我没有选择真正接入支付宝/微信企业商户号而是做了模拟支付和充值余额两种方式。学生账号可以充值虚拟余额管理员后台手动充值或对接校园卡系统也可以用“模拟微信支付”弹窗模拟支付成功回调。这样既演示了完整支付流程又省去企业资质和商户号申请的麻烦。如果有人想真正上线商用替换支付回调接口即可订单模块的逻辑完全复用。2. SpringBoot后端接口服务拆分、表结构设计与关键防超卖逻辑后端的核心是接口服务不是页面。SpringBoot 3.x JDK 17 MyBatis-Plus MySQL 8.0 Redis是这一版选型。有人可能会问为什么不用JPA我的习惯是校园商城这类查询条件多、表格列表多的项目MyBatis-Plus的直接写SQL和分页插件用起来更顺手排查慢查询也更容易定位到底拼了什么SQL。接口按业务域划分成四大类用户域注册、登录、个人信息、地址管理、商品域分类、商品、库存、Banner、交易域购物车、订单、支付、退款、配送域配送单、骑手接单、物流轨迹。每个域一组Controller接口路径统一以/api/{domain}/开头前后端接口文档直接离线导出YAPI或者Apifox。2.1 表结构设计时最不能省的那几张表核心表包括用户表、地址表、商品表、商品分类表、购物车表、订单表、订单明细表、配送表、支付流水表、售后表、Banner表。订单表和订单明细表必须分表因为一个订单包含多个商品如果只存一个JSON字段后面统计销量、对账、售后拆分商品时全部抓瞎。订单表的关键字段要包含订单号用日期随机数生成不用自增ID做订单号避免被遍历、用户ID、商家ID、订单总金额、实付金额、优惠金额、配送费、状态对应上面的枚举、收货人姓名、电话、地址快照、备注、支付时间、接单时间、送达时间。订单明细表要冗余一份商品名称、商品图片、单价快照。为什么冗余因为商家改价或者下架商品后历史订单的展示信息不能被牵连用户翻历史订单时要看到下单那一刻的商品状态。很多新手容易忽略这个细节导致商品被删后订单页显示异常。2.2 前后端接口规范统一返回体、全局异常、Token校验前后端联调时最大的痛点是返回格式不统一。有的接口返回{code:0, data:{}}有的直接返回数组Android端的解析就要写一堆if else。我这边统一用ResultT作为所有接口的返回体。{ code: 200, message: success, data: { } }code为200时业务成功其他code对应各种异常401表示未登录或Token过期403表示无权限500表示服务端异常业务层面的校验失败码从1001往后排。前端只认code不看HTTP状态码。这样拦截器统一处理401跳转登录页后端GlobalExceptionHandler把异常统一包装。Android端解析时也只需要判断code分支不需要针对每个接口单独写异常捕获。Token校验我用的是JWT 拦截器。用户在登录接口拿到token后续请求在Header里带Authorization: Bearer token。拦截器里解析token、判断角色权限用户端和管理后台的接口权限用注解区分。为什么不用SessionAndroid端没有Cookie机制多端登录和后台管理系统也要共享同一套登录态JWT天然适合。JWT自身的坑是token一旦签发在有效期内很难主动失效。校园项目规模小我设置了2小时过期用户修改密码后强制重新登录这已经够用。如果要做到更稳可以引入Redis存token黑名单刷新token时把旧token拉黑。2.3 订单提交中的防超卖先扣Redis再落库订单提交是并发压力最大的接口。学生晚上下课高峰期可能几十个人同时对同一包辣条下单。如果只是查库存-判断库存扣库存两步之间没有加锁数据库层面的并发就会导致超卖。我的做法是两层防护第一层是Redis预扣。用户提交订单时先用Redis的DECR命令对商品库存key做扣减。如果扣减后小于0说明库存不足直接返回失败同时设置一个预扣的TTL我设置为15分钟用户在这段时间内若未支付定时任务会把扣减量加回去这叫预占库存。第二层是数据库乐观锁。真正生成订单落库时更新商品表的库存字段需要带上库存版本号或者条件判断UPDATE product SET stock stock - #{buyNum} WHERE id #{productId} AND stock #{buyNum}返回影响行数为1说明扣减成功为0说明库存已经被抢完抛出库存不足异常同时回滚Redis预扣数。这行SQL非常关键等于把“查询再判断”变成一个原子操作从根本上堵住超卖的口子。2.4 为什么选了Redis做购物车而不是纯数据库购物车我用Redis Hash结构key是cart:userIdfield是商品IDvalue是购买数量。用户在APP端加购、改数量、删商品全部走Redis响应速度很快服务器也不用频繁写数据库。结算时一次性把Hash里的商品拉到后端校验库存和价格生成订单后删除对应field。这个设计在规模不大的情况下非常实用。购物车本身不是核心交易数据即使Redis数据丢失最坏的结果是用户购物车空了重新加一遍即可。真正不能丢的是订单和支付流水必须落MySQL。3. Vue3管理后台商品管理、订单处理与权限控制的关键实现管理后台我用Vue3 Vite Element Plus Pinia这套组合现在非常主流。Vue3的组合式API写业务逻辑比Options API清晰得多同一个功能的响应式状态、计算属性和方法可以聚在一起不用split到不同option里来回跳。状态管理选了Pinia比Vuex的TypeScript支持和写法都要顺滑。管理后台的核心页面有登录页、仪表盘展示今日订单数、销售额、待处理订单、商品管理列表、新增/编辑弹窗、上架下架切换、分类管理、订单管理列表、详情、接单、拒单、查看物流、用户管理、骑手管理、售后管理、Banner管理、系统设置。3.1 角色权限同一套后台怎么区分商家和管理员后台登录后返回的用户信息里带一个role字段。我设置了三个角色ADMIN平台管理员、MERCHANT商家、RIDER骑手骑手可以通过后台分配也可以在APP端操作。管理员拥有所有菜单和操作权限商家只能看到自己的商品、订单和数据统计骑手只看到配送任务页。前端的路由守卫是权限的第一道关卡router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token to.path ! /login) { next(/login) return } if (token to.meta.roles !to.meta.roles.includes(store.userInfo.role)) { next(/403) return } next() })路由表里meta字段写清楚roles不符合角色直接踢到403页。但这只是用户体验层面的控制真正的安全控制还是在后端接口上。每次请求后端拦截器会从token解析出角色商家调接口时只允许操作merchantId等于自己ID的数据。前端隐藏入口只是减少干扰后端必须再守一道门。3.2 Element Plus表格和表单的使用细节商品管理的核心场景是表格 搜索 分页。表格列展示商品图片、名称、价格、库存、销量、状态、操作。Element Plus的el-table直接用图片列用el-image加preview-src-list实现点击预览大图价格列我用formatter格式化保留两位小数避免数据库浮点数直接显示成一长串。新增商品表单最容易踩的坑是图片上传。Element Plus的Upload组件默认把文件传到当前前端工程的服务里但前后端分离部署时上传接口应该指向后端的/api/common/upload。上传成功后后端返回文件URL前端把这个URL作为表单字段提交。注意上传组件要设置limit1商品主图只允许一张如果需要多图另外搞一个图片列表字段。3.3 订单处理的实时性轮询还是WebSocket管理后台需要“有新订单弹窗提醒”的效果。我的方案是每隔10秒轮询一次/api/orders/unreadCount接口有未读订单就弹通知 声音提醒。为什么不直接用WebSocket管理后台的使用者是商家和管理员数量非常少轮询的10秒延迟完全能接受而且轮询实现简单、部署无负担。用户多人并发下的实时性要求主要在学生端、骑手端看单抢单场景那个用WebSocket。技术选型要按场景需要来不能什么功能都上Socket。后续如果要优化可以给SpringBoot配上SSEServer-Sent Events替代轮询前端用EventSource监听。SSE比WebSocket实现简单也支持失败重连适合后台这种低并发的消息推送场景。4. Android客户端分层架构、网络层封装与购物车本地缓存策略Android端用的是Kotlin。Kotlin的协程写异步逻辑相比Java的回调地狱舒服太多空安全机制也能规避最常见的空指针问题。网络库选Retrofit OkHttp Gson图片加载用Glide。这几个库在Android生态里非常成熟文档全、踩坑记录多出问题好排查。4.1 分包结构按功能模块切不按层切Android工程内部我建议按模块分包而不是按照activity/adapter/fragment这种技术层分。技术层分包在项目小的时候看着整齐功能一多就会变成几百个文件的垃圾堆找起来非常痛苦。我的分包思路com.campus.snack.app ├── base // 基类Activity、基类Fragment、BaseAdapter ├── network // Retrofit配置、统一返回体、拦截器 ├── ui │ ├── login // 登录注册 │ ├── home // 首页、分类、Banner │ ├── product // 商品列表、详情 │ ├── cart // 购物车 │ ├── order // 订单确认、订单列表、订单详情 │ ├── mine // 个人中心、地址管理、设置 │ └── rider // 骑手端专属页面 ├── data // 数据模型、本地数据库、SP存储 ├── widget // 自定义控件 └── utils // 工具类一个功能的所有页面和Adapter放在同一个package里改功能时只需要进一个目录直观高效。4.2 网络层封装返回体、协程、异常处理一次写全Retrofit创建时加上日志拦截器、Token拦截器和统一错误提示拦截器。接口返回的是统一返回体BaseResponseT用泛型包装data class BaseResponseT( val code: Int, val message: String, val data: T? )所有接口都返回BaseResponseT调用处用协程封装一个suspend fun T BaseResponseT.handle(): T的扩展函数code为200就返回data否则抛出业务异常。页面里的Loading、错误提示、接口回调全部统一到BaseViewModel里处理Activity/Fragment只关心成功后的数据展示。做这个封装最大的收益是调试效率。后端同事返回字段里多了一个null前端解析data为null时因为空安全机制页面不至于崩溃闪退而是走统一的异常提示逻辑。这在联调期间帮了大忙。加密和代理抓包问题正式打包Release时OkHttp的日志拦截器要关掉避免敏感信息通过日志漏出。Android 9以后默认禁止明文HTTP通信如果后端暂时没有配HTTPS证书开发阶段要在network_security_config.xml里暂时允许cleartext流量上线前再改回来。4.3 购物车本地缓存先改本地再同步后端购物车页面的体验优化我用了一个思路加购、改数量、删除操作先更新本地界面和本地存储同时异步调后端接口同步数据。如果接口失败则回滚本地状态并弹出Toast提示。这个体验比“每次操作都要转圈等网络返回成功再刷新”要好得多。本地存储用Room数据库存购物车数据。Room比SharedPreferences适合存这种结构化数据支持查询条件还能配合Flow做响应式更新。每次冷启动进购物车页时先从本地读数据秒开页面然后调后端接口比较购物车和Redis中的版本号有变化再刷新。这样即使因为断网导致本地和后端不一致也不会出现页面白屏或者加载失败的情况。4.4 版本兼容和真机适配的坑Android版本碎片化是绕不开的问题。适配时重点注意三个地方一是通知渠道Android 8.0以后必须创建NotificationChannel否则通知不显示二是运行时权限定位和通知权限都需要动态申请权限回调要处理好用户拒绝的情况三是Android 13targetSdk 33之后的POST_NOTIFICATIONS通知权限如果没申请骑手端的取单提醒完全失效。真机调试时如果手机和后端不在同一个网段需要把后端地址改成电脑的局域网IP不要写localhost。Android模拟器访问宿主机用10.0.2.2但真机调试是直接用局域网IP的这个当时组里好几个学生都卡了半天。5. 三端联调的顺序安排与状态同步的常见坑联调阶段最容易出问题的地方不在单端功能而在“A端改了状态B端不知道”。所以联调前要先定好状态同步方式和接口顺序。我的建议是先拉通最常用的主流程用户注册登录 → 浏览商品 → 加购 → 提交订单 → 支付 → 商家接单 → 骑手接单 → 配送 → 确认收货。这个链路走通后再逐个补丁地处理异常分支比如取消订单、退款、超时未支付、骑手拒单、商家库存不足拒单等。5.1 数字和金额BigDecimal统一绝不使用Float订单金额相关字段数据库用DECIMAL(10,2)后端Java对象用BigDecimal前端管理后台和Android端的显示用字符串格式化保留两位小数。前后端联调时金额以“分”还是“元”为单位必须提前约定好我这边统一用“元”作为单位后端返回BigDecimal序列化后是数字前端用toFixed(2)展示。很多项目在金额上踩过精度丢失的坑价格字段用Float数据库减库存计算总价时出现0.30000000000000004。这种问题在订单金额上绝对不可接受。所以一开始就用BigDecimal做所有运算加法用add乘法用multiply不能直接用和*。5.2 时间格式传时间戳别传字符串接口的时间字段统一用毫秒时间戳long传输前端展示时再格式化成想要的样子。为什么不用字符串不同机器上yyyy-MM-dd HH:mm:ss的时区可能不同Andorid端的SimpleDateFormat解析时容易因为时区偏移多出8个小时。时间戳不涉及时区问题接收端new Date(timestamp)之后本地时区格式化结果永远是对的。Android端显示“几分钟前”“刚刚”这种相对时间也是基于时间戳计算的秒杀字符串格式要省事得多。5.3 联调中的真实错误案例分享几个我在这个项目联调中遇到过的典型问题第一个是后端接口把订单状态返回为1已支付但Android端的状态枚举还没跟上新增的枚举值时会出现用户看到订单状态为空白的bug。解决办法是前端枚举匹配不上时默认显示“状态更新中”并拉取一次订单详情刷新。第二个是商品图片在Android端加载不出来。原因是后端的图片URL是相对路径比如/uploads/xxx.jpgAndroid端直接加载时没有拼接域名。后来后端统一返回完整URL拼接后的地址彻底根治。如果前端团队自己拼每个用到图片的地方都要写一遍很容易漏。第三个是骑手端接单时出现“接了别人的单”。原因是多个骑手同时抢同一订单后端接单接口没有做并发控制。后端用Redis的SETNX锁订单ID抢单成功的返回成功失败的返回“手慢了”锁的key设置30秒自动过期。Boolean locked redisTemplate.opsForValue() .setIfAbsent(order:lock: orderId, riderId, 30, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { // 更新订单骑手ID和状态 }这个锁是用来防重复抢单的不需要分布式锁组件那么重Redis原子性的setIfAbsent就够了。5.4 后端自测Mock数据的重要性联调效率低下很多时候是因为后端接口没准备好前端干等。我的做法是后端每个接口都预先写好Mock返回数据Swagger/knife4j里直接可测前端也可以按接口文档的Mock数据提前开发。Apifox的Mock服务很好用可以自动根据字段类型生成随机值不用owner手动维护前后端并行开发时效率直接翻倍。6. 部署与环境配置从本地启动到上线前的检查清单项目最后要真正跑起来部署环节比写代码更容易踩坑。这里列一下我实测过能稳定运行的完整流程。6.1 SpringBoot后端的启动配置后端要装的环境JDK 17、MySQL 8.0、Redis。配置文件里注意几个关键点数据库连接URL要加useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai否则中文乱码。Redis连接设置好密码不要用默认空密码公网环境必被扫描破解。文件上传的本地路径要配置成绝对路径并且把该路径映射成静态资源URL否则上传图片后前端访问不到。生产环境的日志级别设为INFO开发环境设为DEBUG不要混。配置好跨域Vue3管理后台和后端服务端口不同CORS配置如果漏了前端所有请求都失败这个非常常见。启动顺序是先把MySQL和Redis跑起来再去启动SpringBoot应用。如果直接启SpringBoot连不上数据库检查MySQL账号密码和网络权限不要一上来就改代码加超时时间。6.2 Vue3后台的构建与部署Vue3项目npm run build后生成dist静态文件。部署方式有两种一是直接用Nginx托管dist目录同时配一层反向代理把/api开头的请求转发到后端服务。二是把dist文件复制到SpringBoot的static目录下打成jar包一体运行。校园项目规模小我推荐第二种方式一台服务器、一个Java进程就全搞定运维负担最轻。如果后期并发上来了再分离前后端也不迟。Nginx方式需要在配置文件里处理history路由重写避免刷新404location / { try_files $uri $uri/ /index.html; }6.3 Android安装包的签名与发布Android APP要装到别人手机上不能直接用Debug包。在Android Studio里生成Signed APK需要创建keystore签名文件。注意签名文件一旦丢失应用后续更新只能换包名重新发布用户数据全部丢失。这个文件务必备份到本地和云端各一份。正式发布的Release包要做几个处理移除调试日志输出、关闭允许明文HTTP、把后端地址从开发环境IP换成正式域名或HTTPS地址。我的习惯是在build.gradle里配置不同的buildConfigFieldDebug和Release自动替换服务器地址避免手动改来改去改漏了。上架应用商店时还需要准备隐私政策、个人信息保护说明、软件著作权证书App备案时可能需要、App备案号。如果只是在校园内部分发也可以走内部分发平台Android包在非官方渠道安装需要用户允许“安装未知来源应用”打包时注意不要把allowBackup设为false否则应用备份恢复功能异常。6.4 联调全流程回顾与交付清单我最后给参与项目的学生列过一份上线前自测表格这里也分享给大家可以对照检查新用户注册流程是否走通验证码是否正常密码加密存储BCrypt用户登录后修改头像上传图片后URL是否可访问商品上下架后用户端是否实时生效商家新增商品后用户端能否立刻看到购物车加购、改数量、删除、清空的并发场景下数量是否错乱订单提交后库存是否正确扣减取消订单后库存是否加回支付成功后订单状态是否从待支付变成待接单支付回调是否只执行一次幂等性商家接单、拒单、骑手抢单、送达确认每一步之后用户端的订单状态是否同步更新断网、弱网环境下提交订单会不会出现重复提交后端有没有做幂等处理用户端和骑手端在低版本Android手机上运行是否崩溃UI是否错位每个项目做完全部自测后内部的演示验收才算通过。这个习惯一直保留到现在每次做完整项目交付时都会强制走一遍类似清单毕竟线上翻车不丢人交付后用户来反馈问题才真的尴尬。一些个人体会这种多端项目怎么做才能少走弯路做这类三端联动的项目最容易犯的错误是一上来就写代码写到一半发现接口对不上、状态流转对不上、权限控制有漏洞然后再返工。我这个项目最大的经验是花了整整三天画业务流程图和接口文档后面两个月开发基本是照着文档填代码联调只用了一周这个时间投入非常值得。另外一个建议是Android端如果能先用H5原型把页面结构和交互跑通再转原生实现团队成员对业务流程的理解会快很多。但正式开发时原生代码的稳定性和性能体验确实比H5好尤其购物车滑动和订单列表的流畅度。如果要在这套结构上继续扩展可以做校园二手交易、校园跑腿送货、社团订水因为订单状态机、多角色权限、骑手配送的底座都是一样的换一下商品模型和交易规则就能复用。这也是为什么我在这个项目里坚持把订单状态机和接口层抽象出来而不是和零食业务高度耦合。最后分享一个小技巧给Android端配一个全局的“开发环境切换”入口在设置页里长按版本号五次弹出调试面板可以随时切换服务端地址。模拟器用10.0.2.2真机用电脑局域网IP正式环境用HTTPS域名。这样开发、演示、测试之间切换不用重新打包省了不少事。