简介:校园跑腿生活服务微信小程序设计与实现,是一套面向计算机相关专业毕业设计或课程设计的完整项目资料。围绕微信小程序用户端与后端服务展开,覆盖微信一键登录、个人资料管理、跑腿服务分类展示、在线下单与实时报价、微信零钱支付、订单跟踪、跑腿员申请与调度等核心业务,可有效帮助学习者理解移动端服务类平台的开发流程与实现思路。资源包含2000个文件,压缩包约17.93MB,以HTML/CSS/JS前端页面、Java后端逻辑、SQL数据库脚本、XML配置文件等为主,其中大量jQuery Mobile相关组件可支撑移动端界面快速搭建。已有62人学习浏览,适合毕业设计选题、课程设计实践或需要参考前后端交互与订单状态机设计的开发者。资料涵盖前端页面、后端服务、数据库建表脚本及项目配置文件,并附有可运行的项目结构,便于快速导入开发工具进行二次开发或论文答辩演示。
1. 校园跑腿小程序:先把三条业务链路理清
下午四点的菜鸟驿站排队到门口,你在实验室又跑不开——代取快递这种需求在校园里几乎每天发生。这套校园跑腿生活服务小程序的做法很直接:微信小程序端负责下单、接单、支付,后端用 Java Spring Boot 出接口,核心链路是「登录—下单—报价—支付—接单—配送—完单」。对做毕业设计或简历项目的同学来说,页面数量不是重点,真正值得抠的是三条暗线:微信登录态怎么维护、订单状态怎么保证不跳位、微信支付 v3 的签名和回调怎么接才不出资损。这篇拆解顺着三条暗线把表结构和关键代码摊开。另外注意,资源包里那批 jQuery Mobile 文件不是给小程序端用的,小程序运行环境没有 DOM 和 jQuery,那套 CSS 是后台管理页或原型演示的素材,别放错位置。
2. 订单表与服务分类表设计:数据库先定义业务边界
跑腿业务的复杂度不在页面,在订单。一张订单同时牵扯用户、跑腿员、价格、配送地址、状态五个维度,表结构没定好,后续接口写起来全是补丁。
2.1 订单表为什么拆 status 和 pay_status 两个字段
先看订单核心表结构,这是我建议的最小可用版本:
CREATE TABLE `orders` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单主键', `order_no` VARCHAR(32) NOT NULL COMMENT '商户订单号,规则:yyyyMMddHHmmss+6位随机数', `user_id` BIGINT NOT NULL COMMENT '下单用户ID', `runner_id` BIGINT DEFAULT NULL COMMENT '接单跑腿员ID,待接单时为NULL', `category_id` INT NOT NULL COMMENT '服务分类ID,对应service_category表', `pickup_name` VARCHAR(32) NOT NULL COMMENT '取件联系人', `pickup_phone` VARCHAR(20) NOT NULL COMMENT '取件联系电话,列表页脱敏显示', `pickup_address` VARCHAR(255) NOT NULL COMMENT '取件地址', `delivery_address` VARCHAR(255) NOT NULL COMMENT '送达地址', `goods_desc` VARCHAR(500) DEFAULT NULL COMMENT '物品描述,如快递单号/餐品备注', `expect_time` DATETIME DEFAULT NULL COMMENT '期望送达时间', `distance_km` DECIMAL(5,2) DEFAULT '0.00' COMMENT '取件到送达的距离,单位公里', `estimate_price` DECIMAL(8,2) NOT NULL COMMENT '下单时的报价', `actual_price` DECIMAL(8,2) DEFAULT NULL COMMENT '最终结算价,完单时写入', `status` TINYINT NOT NULL DEFAULT '0' COMMENT '业务状态:0待支付,1待接单,2已接单,3配送中,4已完成,5已取消,6退款中', `pay_status` TINYINT NOT NULL DEFAULT '0' COMMENT '支付状态:0未支付,1已支付,2已退款', `pay_time` DATETIME DEFAULT NULL COMMENT '支付成功时间', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_created` (`user_id`, `created_at`), KEY `idx_runner_status` (`runner_id`, `status`), KEY `idx_status_created` (`status`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跑腿订单表';为什么不把「待支付」直接塞进 status 一个字段里完事?因为退款、超时取消、支付成功但无人接单这三种场景下,业务状态和支付状态的变化时刻不一致。比如用户下单后一直没人接单,用户申请取消,此时业务状态变成已取消,但钱还要走退款流程,支付状态从已支付变成退款中;如果合在一个字段,每次支付回调都要连业务状态一起改,状态机直接被搅浑。拆出 pay_status 之后,微信支付回调只动 pay_status 和 pay_time,业务状态流转不被打扰。
索引也对应三个最常执行的查询:idx_user_created服务小程序端「我的订单」列表,idx_runner_status服务跑腿员端「我接的订单」,idx_status_created服务「待接单新单池」按时间排序。答辩被问索引怎么设计时,这个对应关系就讲得清。
2.2 服务分类表与跑腿员申请表的落表方式
服务分类不需要设计成树形结构,校园场景下四到六个分类就够,一层平铺最省事。关键是计价规则要跟着分类走:
CREATE TABLE `service_category` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(32) NOT NULL COMMENT '分类名,如代取快递', `icon_url` VARCHAR(255) DEFAULT NULL COMMENT '分类图标', `base_price` DECIMAL(8,2) NOT NULL COMMENT '该分类基础服务费', `per_km_price` DECIMAL(8,2) NOT NULL DEFAULT '1.50' COMMENT '超出起步里程后的每公里单价', `free_km` DECIMAL(4,2) NOT NULL DEFAULT '2.00' COMMENT '起步里程,公里', `sort_order` INT NOT NULL DEFAULT '0' COMMENT '展示排序', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务分类表';初始化数据就按校园里最高频的四类跑腿服务来:
| id | name | base_price | free_km | per_km_price |
|---|---|---|---|---|
| 1 | 代取快递 | 3.00 | 2.00 | 1.50 |
| 2 | 送餐 | 3.00 | 2.00 | 1.50 |
| 3 | 代购 | 5.00 | 2.00 | 2.00 |
| 4 | 代排队 | 10.00 | 0.00 | 0.00 |
把计价规则放进表而不是写死在 Java 里,好处是运营改价不用发版。代排队把 per_km_price 设为 0,计价代码里统一写成price = base_price + max(0, distance - free_km) * per_km_price,自然退化成固定价,不需要在代码里为代排队单独写分支。顺带一个反直觉的点:价格必须后端算,前端传过来的任何金额字段都不能信,否则有人改请求体就能白嫖跑腿。
跑腿员申请表的门槛在于「一个人只能申请一次」和「审核状态独立」:
CREATE TABLE `runner_apply` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL COMMENT '申请人', `student_no` VARCHAR(20) NOT NULL COMMENT '学号', `real_name` VARCHAR(32) NOT NULL COMMENT '真实姓名', `phone` VARCHAR(20) NOT NULL, `cert_image` VARCHAR(255) DEFAULT NULL COMMENT '学生证照片上传路径', `status` TINYINT NOT NULL DEFAULT '0' COMMENT '0待审核 1通过 2驳回', `audit_remark` VARCHAR(255) DEFAULT NULL COMMENT '驳回原因', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跑腿员申请表';应用层经常漏掉重复申请校验,数据库唯一键uk_user直接把这条路堵死。用户主表里再放一个is_runner标记位,跑腿员端接单接口每次进来先查这个标记,没必要每次去 runner_apply 里扫状态。
2.3 订单分页查询:别把全部数据查出来再过滤
小程序端「我的订单」页面通常要拆成全部/待处理/进行中/已完成四个 tab,后端的接口设计成一个带 status 入参的分页查询就够了。基于 MyBatis-Plus 的常规写法:
Page<Orders> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Orders> wrapper = new LambdaQueryWrapper<Orders>() .eq(Orders::getUserId, userId) .eq(status != null, Orders::getStatus, status) .orderByDesc(Orders::getCreatedAt); ordersMapper.selectPage(page, wrapper);注意eq(status != null, ...)这个重载:status 传 null 时不拼接该条件,四个 tab 复用同一接口。分页走的是 MySQL 物理分页 LIMIT,别在代码里先selectList全量查出再内存分页,数据过万必卡。列表接口再单独写一个 VO,只查 id、order_no、category_name、estimate_price、status、created_at 这几个展示字段,减少传输量。
3. 微信登录态与用户身份体系:从 wx.login 到 token 续期
小程序的登录不能照搬 Web 那套账号密码体系,必须走微信的 openid 体系。但 openid 不能直接拿来做接口鉴权,每次请求都拿 openid 去调微信接口不现实,而且微信只认 code 换 session 这一条路。
3.1 wx.login 拿 code,后端换 openid
前端触发登录的代码很薄:
wx.login({ success: async (res) => { if (res.code) { const { data } = await request('/api/auth/login', 'POST', { code: res.code }) wx.setStorageSync('token', data.token) wx.setStorageSync('userInfo', data.userInfo) } } })后端拿到 code 后,拼一个 jscode2session 请求换 openid:
@PostMapping("/api/auth/login") public Result login(@RequestBody LoginReq req) { String url = "https://api.weixin.qq.com/sns/jscode2session" + "?appid=" + appid + "&secret=" + secret + "&js_code=" + req.getCode() + "&grant_type=authorization_code"; String resp = restTemplate.getForObject(url, String.class); JSONObject obj = JSON.parseObject(resp); String openid = obj.getString("openid"); if (openid == null) { return Result.fail("code无效,错误码: " + obj.getString("errcode")); } // 查user表,不存在则插入新用户;存在则刷新最近登录时间 // 生成自定义token,响应前端 }几个参数含义要拎清:appid 和 secret 从小程序后台拿,secret 是后端用的,绝不能出现在前端代码里,否则别人拿你的 secret 可以换任意用户的 openid。code 有效期 5 分钟且只能消费一次,前端不要做自动重试,失败就重新调一次 wx.login。token 生成我一般直接用 UUID 存 Redis,设 7 天过期,毕业设计足够;讲究一点就签 JWT 把 userId 放进去,但 JWT 的续期逻辑比 Redis 麻烦,需要自己处理失效。
提示:secret 一旦泄露,请立刻到小程序后台重置。调试时也别把 secret 打到日志里,很多事故都是日志里搜出来的。
3.2 getUserProfile 失效后的头像昵称处理
从基础库 2.27.1 开始,wx.getUserProfile返回的是匿名头像和「微信用户」默认昵称,不能再作为真实资料采集手段。现在官方推荐的是「头像昵称填写能力」,用 button 的open-type="chooseAvatar"和input的type="nickname"组合:
<button class="avatar" open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar"> <image src="{{avatarUrl || '/assets/default-avatar.png'}}" /> </button> <input type="nickname" value="{{nickname}}" bind:input="onNicknameInput" placeholder="请输入昵称" />onChooseAvatar(e) { // e.detail.avatarUrl 是临时路径,提交表单时先 wx.uploadFile 传到自己的存储 this.setData({ avatarUrl: e.detail.avatarUrl }) }, onNicknameInput(e) { this.setData({ nickname: e.detail.value }) }注意 chooseAvatar 拿到的是临时文件路径,换页就失效,所以要在表单提交时先调wx.uploadFile把图片传到后端,后端存 OSS 或本地目录后回传 URL,再把 URL 存库。编辑收货地址时我也习惯用 radio-group 做「宿舍/教学楼/快递点」常用地址切换,数据模型就是一个 addresses 数组,选中项在 submit 时取出。这里用原生 radio 而不是自绘组件,是为了少维护一套选中态样式。
3.3 统一请求封装与登录态过期
所有接口请求统一走一个封装,好处是登录态失效、错误提示、加载状态只写一遍。这也是以后如果要把项目平移成 uniapp 微信小程序版本时,唯一需要整体替换的文件。
const request = (url, method = 'GET', data = {}) => { const token = wx.getStorageSync('token') return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method, data, header: { 'Authorization': token ? `Bearer ${token}` : '' }, success: (res) => { if (res.data.code === 401) { // 登录态过期,清理本地缓存并跳登录页;加防抖避免多个请求同时触发跳转 wx.removeStorageSync('token') wx.navigateTo({ url: '/pages/login/login' }) return } if (res.data.code !== 0) { wx.showToast({ title: res.data.msg, icon: 'none' }) reject(res.data) return } resolve(res.data) }, fail: reject }) }) }首次启动的加载体验也在这里统一处理:app.json 的 window 配置里设置navigationBarTitleText和backgroundColor,这就是用户「刚进入的加载页面」看到的视觉层。白屏多半是首页 onLoad 里串行调了太多个接口,把分类列表和 banner 并行请求,首屏渲染能快一截。
4. 订单状态机与跑腿员接单:让状态流转可控可追溯
订单状态如果散落成一堆 if-else,后面接支付回调、退款、异常单时会改出暗病:A 处允许的状态流转,B 处没拦,线上就会出现「已取消的订单被接单」这种事。状态机要做的就是把每一条合法路径预先定义好。
4.1 用枚举加流转校验替代散落的 if
订单业务状态的边界按这个枚举定义:
public enum OrderStatus { UNPAID(0, "待支付"), WAITING(1, "待接单"), ACCEPTED(2, "已接单"), DELIVERING(3, "配送中"), COMPLETED(4, "已完成"), CANCELED(5, "已取消"), REFUNDING(6, "退款中"); private final int code; private final String desc; }各状态间的合法流转整理成一张转移表,写代码时对着它来:
| 当前状态 | 操作 | 下一状态 | 触发方 |
|---|---|---|---|
| 待支付 | 支付成功 | 待接单 | 微信支付回调 |
| 待支付 | 用户取消或超时 | 已取消 | 用户/定时任务 |
| 待接单 | 跑腿员接单 | 已接单 | 跑腿员 |
| 已接单 | 跑腿员标记取件 | 配送中 | 跑腿员 |
| 配送中 | 确认送达 | 已完成 | 跑腿员/用户 |
| 待接单 | 用户申请取消 | 已取消 | 用户 |
| 已完成/已取消 | 发起退款 | 退款中 | 用户/管理员 |
注意几个刻意不允许的路径:已接单状态不允许用户直接取消,必须走申请取消由管理员审核,防的是恶意取消浪费跑腿员时间;配送中不允许用户单方面取消,防止货品已在路上却收不到钱。这些约束写成一张转移表放在内存里:
private static final Map<Integer, Set<Integer>> TRANSITION = new HashMap<>(); static { TRANSITION.put(OrderStatus.UNPAID.getCode(), Set.of(OrderStatus.WAITING.getCode(), OrderStatus.CANCELED.getCode())); TRANSITION.put(OrderStatus.WAITING.getCode(), Set.of(OrderStatus.ACCEPTED.getCode(), OrderStatus.CANCELED.getCode())); // 其余流转按表逐个 put } public void transit(Orders order, int target) { Set<Integer> allowed = TRANSITION.get(order.getStatus()); if (allowed == null || !allowed.contains(target)) { throw new BizException("非法状态流转: " + order.getStatus() + " -> " + target); } }真正更新订单时,SQL 要带上当前状态作为条件,防止并发下两个请求同时读到旧状态、同时更新成功:
UPDATE orders SET status = 3, runner_id = ? WHERE id = ? AND status = 1更新行数为 0 说明订单状态已被别人改过,直接返回「手慢了,订单已被接走」。这是跑腿抢单场景并发控制的最小实现,比在应用层加锁干净。
4.2 跑腿员端新单发现:轮询比长连接更省事
跑腿员端要看到待接单的新订单,最直接的是轮询接口。毕业设计阶段的并发量,10 秒一次轮询完全够用,也不需要维护 WebSocket 连接。
pollNewOrders() { this.timer = setInterval(async () => { const res = await request('/api/runner/orders/waiting', 'GET', { onlyNew: true }) if (res.data.list.length > 0) { this.setData({ hasNew: true }) wx.showToast({ title: '有新的跑腿单', icon: 'none' }) } }, 10000) }, onUnload() { clearInterval(this.timer) // 页面卸载必须清定时器,否则接口会一直打 }后端对应接口就是第 2 章那张idx_status_created索引在发挥作用:WHERE status = 1 ORDER BY created_at DESC LIMIT 20。定时器别忘了在 onUnload 里清掉,这是小程序页面最常见的内存泄漏来源。如果导师要求「实时性」再强一点,再上微信订阅消息,用户下单时申请一次订阅授权,跑腿员接单后给用户推一条模板通知,实现成本和轮询不冲突。接单页的自定义导航栏高度也别写死 64px,不同机型胶囊位置不一样,用wx.getMenuButtonBoundingClientRect()拿到胶囊的 top 和 bottom 再算标题居中偏移。
4.3 报价计算:距离与费用引擎
实时报价在用户填写完取件和送达地址后触发,后端用两个经纬度算距离,这里用 Haversine 公式,球面距离误差对校园场景来说可以忽略:
private double distance(double lng1, double lat1, double lng2, double lat2) { double r = 6371.0; // 地球半径,公里 double rad = Math.PI / 180; double dLng = (lng2 - lng1) * rad; double dLat = (lat2 - lat1) * rad; double a = Math.pow(Math.sin(dLat / 2), 2) + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.pow(Math.sin(dLng / 2), 2); return 2 * r * Math.asin(Math.sqrt(a)); }计价引擎回到第 2 章的 service_category 表,按分类的基础价、起步里程、每公里单价计算:
BigDecimal price = category.getBasePrice(); BigDecimal extraKm = distance.subtract(category.getFreeKm()); if (extraKm.compareTo(BigDecimal.ZERO) > 0) { price = price.add(extraKm.multiply(category.getPerKmPrice())); } // 夜间 22:00 - 次日 6:00 加 5 元 LocalTime now = LocalTime.now(); if (now.isAfter(LocalTime.of(22, 0)) || now.isBefore(LocalTime.of(6, 0))) { price = price.add(new BigDecimal("5")); }价格用 BigDecimal 而不是 double,原因是 double 在十进制小数运算上会有精度误差,金额相关字段一律用 BigDecimal。后端算出 estimate_price 后写入订单表,前端只负责展示,用户确认后直接进入支付流程,杜绝前端改价。
5. 微信支付 v3 对接与发布前的几个硬坑
支付是校园跑腿项目里最容易暴露问题的一环,很多同学在开发者工具里点通了支付,一上真机就各种签名报错。这里把 v3 对接的链路和排错顺序讲透。
5.1 v2 与 v3 别混着看
微信支付 API v3 和 v2 是两套完全不同的协议。v2 用 MD5 签名和 XML 报文,v3 用 RSA-SHA256 签名和 JSON 报文。新项目直接走 v3,别再看老的 v2 教程。v3 体系里有三个密钥要分清:商户 API 私钥(商户后台生成,用来给请求签名)、商户号(mchid,下单接口要传)、APIv3 密钥(用于解密回调报文,与签名无关)。很多「验签失败」都是把 APIv3 密钥当成签名私钥在用。
5.2 JSAPI 下单与小程序端拉起支付
后端先调微信支付的 JSAPI 下单接口,拿到 prepay_id 再返回给小程序端:
Map<String, Object> body = new HashMap<>(); body.put("appid", appid); body.put("mchid", mchid); body.put("description", "校园跑腿-代取快递"); body.put("out_trade_no", orderNo); body.put("notify_url", notifyUrl); Map<String, String> amount = new HashMap<>(); amount.put("total", String.valueOf(price.multiply(new BigDecimal("100")).intValue())); // 单位:分 body.put("amount", amount); Map<String, String> payer = new HashMap<>(); payer.put("openid", openid); body.put("payer", payer); // 用商户API私钥对请求签名后 POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi最典型的坑就是金额单位:微信支付收的是「分」,但业务库里存的是「元」。price.multiply(new BigDecimal("100"))这步必须做,而且要转成整数,否则你会发现支付的金额比订单金额大一百倍。签名时注意请求头需要带Authorization、Wechatpay-Serial(商户证书序列号)和Accept三个字段,漏一个都会报错。
小程序端拿到 prepay_id 后拉起原生收银台:
const { prepayId, timeStamp, nonceStr, paySign } = res.data wx.requestPayment({ timeStamp, nonceStr, package: `prepay_id=${prepayId}`, signType: 'RSA', paySign, success: () => { /* 支付成功,但以回调为准,不要在这里更新订单状态 */ } })注意这个package字段名是保留字,传参时不能漏掉prepay_id=前缀。小程序内拉起支付用wx.requestPayment就好,不要自己去拼weixin://dl/business这种 scheme 跳转,真机调试十次有八次出问题,而且业务上完全没必要。
5.3 回调验签与幂等:这是最容易挂的一环
支付结果以微信服务器的异步回调为准,前端 success 回调只能用来弹提示。收到回调后的处理顺序必须是先验签、再解密、后处理:
// 1. 从请求头取 Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial // 2. 用 Wechatpay-Serial 对应的微信平台证书验签 // 3. 验签通过后用 APIv3 密钥解密 resource.ciphertext // 4. 解密出 out_trade_no、trade_state、transaction_id // 5. 幂等处理:查本地订单,已支付则直接返回成功;否则更新支付状态顺序不能反。先解密再验签等于把不可信数据先放进业务逻辑,任何一步失败都必须返回非 200 状态码,微信才会重试通知。解密失败九成是 APIv3 密钥配错,或者忘记配置平台证书。验签时的「微信平台证书」和商户证书是两码事:商户证书是商户自己生成的,平台证书是微信下发的,两个序列号都要能对上。平台证书会定期轮换,线上收到Wechatpay-Serial不匹配时,先检查是不是轮换后没拉新证书。
幂等处理是回调接口的生命线:微信的回调在超时或网络抖动时会重发,同一个支付成功的通知可能到达多次。处理方式是先按 out_trade_no 查本地订单,发现 pay_status 已经是已支付就直接返回成功,不再重复走发单逻辑。
5.4 本地联调与「支付功能暂时无法使用」的排查
开发者工具里有一个「模拟支付」按钮,点它可以直接模拟支付成功回调,后端联调阶段用它验证订单状态流转最方便,不需要真实扣款。
注意:模拟支付只走通小程序端到后端的链路,微信服务器不会真的给你发回调,所以本地联调时,回调地址的验签部分要单独验证。
验证回调处理逻辑时,可以直接用 curl 向本地 notify 接口打一条模拟报文,先把解密和验签解开,确认字段结构是对的:
curl -X POST http://localhost:8080/api/pay/notify \ -H "Content-Type: application/json" \ -d '{"event_type":"TRANSACTION.SUCCESS", "resource":{"ciphertext":"密文","nonce":"随机串","associated_data":"附加数据"}}'真机预览时如果提示「由于小程序违规,支付功能暂时无法使用」,这不是改代码能解决的,去小程序后台看站内信,通常原因是服务类目与支付场景不匹配或资质材料缺失,按指引整改后重新提审,审核通过后支付能力会自动恢复。还有一种常见情况是小程序的 appid 没开通微信支付,或者开通后没在商户平台关联该小程序 appid,这两处配置都是后台操作,代码里看不出来。最终验证标准只有一条:控制台能打出解密后的 out_trade_no,订单表 pay_status 从 0 变 1,且回调重发不产生重复数据。
本文还有配套的精品资源,点击获取