智慧乡村旅游服务平台:微信小程序+Java+MySQL毕设全栈解析

发布时间:2026/10/27 22:44:24
智慧乡村旅游服务平台:微信小程序+Java+MySQL毕设全栈解析

简介:面向毕业设计及课程设计场景,这套基于微信小程序的智慧乡村旅游服务平台源码,完整实现管理员、用户、商家三类角色权限闭环:用户端可完成旅游景点与景点路线浏览、购物车、充值、收藏及已发货订单管理;后台端覆盖旅游景点、景点类型、路线、订单、系统管理等模块,商家侧承担景点与订单的运营维护。压缩包共1178个文件,约17.01MB,包括vue、js、wxml、wxss等小程序与后台前端代码,Java后端工程,sql数据库脚本,以及png、svg等界面素材,并附有install/run/build批处理脚本,目录结构清晰,便于按端定位功能。已有86人浏览学习。下载后可直接对照搭建环境,梳理前后端交互、数据库表设计与三类角色的权限划分,尤其适合需要快速完成小程序+Java+MySQL整合开发与论文配套演示的本科毕业生、高职学生使用。

1. 智慧乡村旅游服务平台这套毕设:先说清楚它解决了什么问题

微信小程序加 Java 加 MySQL,这三个词凑在一起,基本就是每年毕业设计里最常见的“三件套”。这套智慧乡村旅游服务平台的源码,正好把三样东西串成了一个完整闭环:游客在小程序里浏览景点、看民宿、下单预订,后端用 Java 写接口管数据,MySQL 把用户、景点、订单全部落库。它解决的不是某个炫技问题,而是一套能演示、能答辩、能往下扩展的小程序全栈项目,从微信登录到订单状态流转都有现成实现。适合三类人:正在选毕设题目的学生、需要课程设计源码的初学者,以及想把前后端联调完整走一遍的开发者。这篇笔记我按拆项目的思路来写,从数据库表结构讲到小程序页面渲染,中间穿插实际踩过的坑。

2. 模块拆解与数据库设计:从业务边界到 MySQL 表结构落地

2.1 功能模块的三类角色边界

拿到源码先别急着跑,先看业务边界。这套平台按角色分三块:游客、商家、管理员。游客端的入口是小程序,功能围绕“浏览—决策—下单—售后”展开:景点列表、景点详情、民宿列表、在线预订、订单查询、收藏、评论。商家和管理员的操作集中在后台,负责景点上下架、民宿状态维护、订单审核与数据统计。

这三类角色如果权限混在一起,后期扩展必翻车。所以拿到源码第一步是看包名和 controller 分层,常见结构是user相关接口和admin相关接口分开,登录拦截器也分两套。毕设答辩时老师喜欢问“权限怎么控制的”,能答出这一层就说明你真的看过代码,而不只是把项目跑起来。

选型上,为什么是 Spring Boot + MySQL + 微信小程序而不是别的?对毕设来说,Spring Boot 社区资料多,报错基本能搜到答案;MySQL 是关系型数据库里上手成本最低的,可视化工具用 Navicat 或者 DBeaver 都能操作;微信小程序是现成的 C 端载体,不用自己做 App 签名、不用走应用商店审核。这套组合的性价比确实是最高的,源码里也基本不会出现冷门框架。

2.2 核心表结构:字段取舍与关联关系

源码里的表名不一定完全一样,但核心的几张表八九不离十:user、scenic、hotel、travel_order、comment、favorite。我整理了一下典型字段,你拿到源码后可以对照着看:

表名核心字段设计说明
userid, openid, nickname, avatar, phoneopenid 是微信用户的唯一标识,必须加唯一索引
scenicid, name, image, intro, price, address, statusstatus 控制上下架,1 上架 0 下架
hotelid, name, image, price, address, status民宿/农家乐,字段逻辑和景点表保持一致
travel_orderid, order_no, user_id, goods_type, goods_id, amount, stategoods_type 区分景点票还是民宿,state 管理状态流转
commentid, user_id, goods_type, goods_id, content, score评论挂在具体商品上,统一用 goods_type + goods_id
favoriteid, user_id, goods_type, goods_id收藏表,同样用通用关联方式

几个容易踩的字段设计问题在源码里很常见。第一是 order_no 必须唯一,不能直接用自增 id 当订单号,后面订单状态机流转、售后查询都要靠这个业务编号;第二是 state 字段不要用散乱的字符串,最好约定数字状态码,并在代码里写常量类,比如 0 待支付、1 已支付、2 已完成、3 已取消;第三是 image 字段只存文件名或者相对路径,绝对路径由后端拼接,否则换域名就要改数据库,这种低级错误在答辩时被问到会很难看。

2.3 建库脚本与初始化数据

源码包里一般自带 sql 文件,可以直接导入。我拿到手第一件事就是看建表语句,确认字段类型、索引和字符集。以下是这个项目里常见的建库脚本结构:

-- 建库,utf8mb4 是必须的 CREATE DATABASE IF NOT EXISTS travel_db DEFAULT CHARACTER SET utf8mb4; USE travel_db; -- 用户表 CREATE TABLE `user` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `openid` VARCHAR(64) NOT NULL COMMENT '微信openid', `nickname` VARCHAR(50) DEFAULT '' COMMENT '昵称', `avatar` VARCHAR(200) DEFAULT '' COMMENT '头像地址', `phone` VARCHAR(20) DEFAULT '' COMMENT '手机号', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 景点表 CREATE TABLE `scenic` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL COMMENT '景点名称', `image` VARCHAR(200) DEFAULT '' COMMENT '图片文件名', `intro` TEXT COMMENT '景点介绍', `price` DECIMAL(10,2) DEFAULT '0.00' COMMENT '门票价格', `address` VARCHAR(200) DEFAULT '' COMMENT '地址', `status` TINYINT DEFAULT '1' COMMENT '1上架 0下架', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='景点表';

注意两个细节。utf8mb4是硬性要求,微信昵称里经常出现 emoji 表情,老项目用utf8存 emoji 直接报错,这是最典型的字符集翻车现场。uk_openid唯一索引保证同一个微信号只插入一条记录,这是登录接口幂等落库的关键,没有这个索引,高并发下重复注册会很麻烦。

字段加 COMMENT 的习惯也要养成,答辩时老师打开数据库看表结构,COMMENT 就是最好的解说词。初始化数据不用多,但必须覆盖所有场景。scenic 表至少插 3 到 5 条,其中一条 status=0 代表已下架,这样才能演示后台上下架功能;travel_order 表插几条不同 state 的记录,前端页面上的“待付款 / 已支付 / 已完成”标签才有对应数据可显示。

导入 SQL 的时候,用 Navicat 执行会经常遇到字符集问题,注意把连接编码也调成 utf8mb4,排序规则选 utf8mb4_general_ci。如果导入时报错,优先检查是不是连接串里没指定 characterEncoding。

3. Spring Boot 后端实现:登录、列表、订单三条链路的代码走读

3.1 工程结构与数据源配置

后端工程的结构大同小异,controller 接收小程序请求,service 写业务逻辑,mapper 做数据访问。包名通常是 com.travel 之类的结构。先打开 pom.xml 看依赖,重点确认有没有 spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java、lombok 这几个核心组件。有些源码用的 MyBatis-Plus,那就会多一个 mybatis-plus-boot-starter,用法上略有差别,但核心逻辑一样。

数据源配置在 application.yml 里,这是所有联调问题的源头,贴一份常见配置看一下:

server: port: 8080 servlet: context-path: /api spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/travel_db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4 username: root password: 123456 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.travel.entity # 自定义配置 wx: appid: 你的appid secret: 你的secret

这里有个血泪经验:JDBC URL 里的参数一个都不能省。useSSL=false避免 MySQL 8 的 SSL 握手报错,allowPublicKeyRetrieval=true解决 Public Key Retrieval is not allowed,serverTimezone=Asia/Shanghai防止时间字段出现 8 小时偏差,characterEncoding=utf8mb4保证 emoji 不会变成问号。这四个参数我几乎每次部署新项目都会用到,建议直接复制。

wx.appid 和 wx.secret 是微信小程序平台的凭证,在 mp.weixin.qq.com 后台的开发管理里能找到,测试阶段建议用小程序的测试号或者自己的账号。

3.2 微信登录:code2Session 与用户落库

小程序端 wx.login 会拿到一个临时 code,后端拿这个 code 去微信接口换 openid。这是整套系统的登录入口,核心代码如下:

@PostMapping("/wx/login") public Result login(@RequestBody LoginRequest req) { // 1. 用 code 换 session_key 和 openid String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appId + "&secret=" + appSecret + "&js_code=" + req.getCode() + "&grant_type=authorization_code"; String res = httpUtil.get(url); JSONObject obj = JSON.parseObject(res); String openid = obj.getString("openid"); // 2. 查用户,不存在则插入 User user = userMapper.findByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); user.setNickname("微信用户" + openid.substring(20)); userMapper.insert(user); } // 3. 生成 token 返回给前端 String token = JwtUtil.createToken(user.getId()); return Result.ok(token); }

这段代码有三个关键点。一是 code 是一次性的,前端必须在 wx.login 回调里立刻传给后端,不能存起来复用,否则第二次请求会报 invalid code。二是 openid 唯一索引兜底了并发场景下的重复插入问题,如果两个请求同时到达,一个 insert 会失败,但 select 能查到,所以更稳的写法是先查后插再 catch 唯一键冲突。三是 token 用的是 JWT,把 user_id 放进去,后续接口在拦截器里解析,小程序端每次请求在 header 里带 Authorization 字段。

微信接口地址是固定的https://api.weixin.qq.com/sns/jscode2session,如果 appid 和 secret 不匹配,返回的 errcode 是 40013,调试时先看这个错误码,别一上来就怀疑自己的代码。

3.3 景点列表:分页、过滤与图片路径拼接

小程序首页是分页列表,接口设计上常见的参数是 page 和 pageSize,默认 page=1、pageSize=10。返回结构除了列表还要带 total,前端才能算总页数。核心实现:

@GetMapping("/scenic/list") public Result list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) String keyword) { PageParams params = new PageParams(); params.setOffset((page - 1) * pageSize); params.setPageSize(pageSize); params.setKeyword(keyword); List<Scenic> list = scenicMapper.pageList(params); list.forEach(s -> s.setImage("/upload/" + s.getImage())); Long total = scenicMapper.count(params); return Result.ok(new PageResult<>(list, total)); }

两个设计细节值得注意。接口只返回 status=1 的景点,下架数据在 SQL 里直接过滤掉,where status = 1,这样前端不用做额外的判断。图片路径由后端拼成/upload/xxx.jpg再返回,前端拿到的就是可以直接访问的完整路径,避免在小程序里出现../../upload/xxx.jpg这种相对路径问题。

keyword 参数做名称模糊搜索,对应 SQL 是where name like concat('%', #{keyword}, '%')。分页用的是手算 offset,没有引入 PageHelper,原因是少一个依赖就少一个坑。如果源码里已经集成了 PageHelper,那直接PageHelper.startPage(page, pageSize)更省事,两种方式都能跑通。

3.4 订单提交:幂等与状态流转

订单是答辩时最容易被追问的模块。下单接口的核心是防重复提交和状态流转,常见实现如下:

@PostMapping("/order/create") public Result createOrder(@RequestBody OrderReq req) { // 1. 生成唯一订单号,时间戳 + 用户id,毕设场景够用 String orderNo = "TR" + System.currentTimeMillis() + req.getUserId(); TravelOrder order = new TravelOrder(); order.setOrderNo(orderNo); order.setUserId(req.getUserId()); order.setGoodsType(req.getGoodsType()); // 1景点 2民宿 order.setGoodsId(req.getGoodsId()); order.setAmount(req.getAmount()); order.setState(0); // 0待支付 1已支付 2已完成 3已取消 // 2. 先查重,再插入 TravelOrder exist = orderMapper.findByNo(orderNo); if (exist != null) { return Result.error("订单已存在"); } orderMapper.insert(order); return Result.ok(orderNo); }

订单号用时间戳拼 userId 是最简单的方案,量大的场景要上雪花算法,但毕设演示足够了。状态机流转上,0 到 1 到 2 是正向流程,0 到 3 是取消,后端在更新状态时要校验当前状态能否跳转,比如已取消的订单不能改成已支付。如果源码里这部分写到位,老师问订单状态怎么管理时可以直接把状态机讲清楚,顺便还能展示一下代码里的常量类。

4. 微信小程序端对接:从登录到页面渲染的完整流程

4.1 登录:wx.login 把 code 交给后端

小程序端登录是整套系统的入口,代码写法比较固定:

wx.login({ success: (res) => { wx.request({ url: 'http://localhost:8080/api/wx/login', method: 'POST', data: { code: res.code }, success: (resp) => { wx.setStorageSync('token', resp.data.data); } }); } });

两个点需要注意。url 在开发阶段用 localhost 或者本机 IP,真机预览时必须改成电脑的局域网 IP,比如http://192.168.1.100:8080/api/wx/login,否则手机访问不到电脑上的后端服务。token 拿到后存到 Storage,后续所有业务请求都要在 header 里带上这个 token,后端拦截器统一解析。

4.2 请求封装与 Token 管理

小程序没有 axios 那种现成的库,但可以自己封装一个 request 方法,把公共逻辑全部收拢:自动带 token、统一处理 401、统一弹错误提示。源码里一般以 utils/request.js 的形式存在:

function request(url, method, data) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method: method, data: data, header: { 'Authorization': wx.getStorageSync('token') || '' }, success: (res) => { if (res.data.code === 401) { wx.redirectTo({ url: '/pages/login/login' }); return; } resolve(res.data); }, fail: (err) => reject(err) }); }); }

BASE_URL 建议单独放在 config.js 里,本地联调用 localhost,真机测试改成局域网 IP,线上环境换成 HTTPS 域名,只需改一个文件。后端接口返回结构统一成{ code, data, msg },前端判断 code 而不是 HTTP 状态码,因为很多后端框架对业务错误也返回 HTTP 200,只看状态码会把业务错误当成成功处理,这是前后端联调时最常见的认知差。

4.3 景点列表分页渲染与触底加载

首页列表用页面自身的 onReachBottom 做触底加载,实现起来很直接:

onLoad() { this.loadList(); }, onReachBottom() { if (this.data.page < this.data.totalPage) { this.setData({ page: this.data.page + 1 }); this.loadList(); } }, loadList() { request('/scenic/list', 'GET', { page: this.data.page, pageSize: 10 }) .then(res => { this.setData({ list: this.data.list.concat(res.data.list), totalPage: Math.ceil(res.data.total / 10) }); }); }

新手最容易写错的地方是触底加载必须用 concat 追加数据,而不是直接赋值,否则每次翻页都覆盖掉上一页的内容,列表永远只有一页。totalPage 的计算是Math.ceil(total / pageSize),不是Math.floor,否则最后一页剩一两条数据时会被直接丢掉。这两个细节如果在答辩时主动讲出来,老师会认为你真的跑通了分页逻辑。

4.4 订单提交与模拟支付

预订页面把景点 id、价格、数量传到订单确认页,提交时调用/order/create。支付在毕设里一般是模拟实现的,常见做法是做一个“确认支付”按钮,点击后调用支付接口把订单状态从 0 改成 1,不真正接入微信支付——因为个人主体注册的小程序没有微信支付权限,这也是答辩时会被问到的问题,提前想好措辞就行。

订单状态回显用 wx.switchTab 跳转到订单列表页,onShow 里重新拉取订单列表,保证每次切 tab 都能看到最新状态。这里有个小习惯:订单列表页不要用 onLoad 拉数据,用 onShow,否则从支付页返回时列表不会刷新。

5. 联调与部署避坑:五条真实踩坑记录

5.1 小程序请求连不上本地后端

现象:开发者工具里 wx.request 报url not in domain list,或者直接显示 request:fail。

原因:开发者工具默认校验合法域名,localhost 不在白名单里;另一种可能是后端服务没启动,或者端口和 BASE_URL 不一致。

解决:开发阶段在开发者工具右上角“详情—本地设置”里勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。真机调试时这个方法无效,要么用备案域名配 HTTPS,要么在演示环境用局域网 IP。我一般会在本地联调阶段直接把“不校验域名”勾上,等要部署了再改回来,这个开关就是给开发调试用的。

5.2 JDBC 连接报错:Public Key Retrieval 与时区

现象:后端启动时报Public Key Retrieval is not allowed,或者查询时间字段比实际少了 8 小时。

原因:MySQL 8.x 默认的 caching_sha2_password 认证方式需要额外的公钥获取流程,驱动默认不允许;时区问题则是连接串没指定 serverTimezone。

解决:JDBC URL 后面追加?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai,三个参数缺一不可。这条可以说是 MySQL 8 联调的第一坑,搜一下全是血泪帖,但基本都是同一个解法。

5.3 微信 code 只能用一次

现象:登录接口偶发报错invalid code,或者联调时重放请求失败。

原因:wx.login 返回的临时 code 五分钟有效,且只能使用一次。前端代码在 onShow 和 onLoad 里重复触发登录,或者后端不小心把同一个 code 请求了两次,都会导致这个问题。

解决:前端在 wx.login 的成功回调里立刻调用后端登录接口,不要在页面多个生命周期里重复触发。联调时想重放请求,就重新执行一次 wx.login 拿新 code。这个坑排查起来有点玄学,因为不是每次必现,但只要代码里存在重复调用,迟早会触发。

5.4 真机预览图片裂图

现象:开发者工具里图片显示正常,手机预览时图片全部裂图。

原因:开发者工具的 localhost 指向电脑本机,手机的 localhost 指向手机自己,当然访问不到。另外微信小程序正式环境会拦截 HTTP 明文请求,虽然开发调试模式放行了,但真机预览时用 IP 访问 HTTP 接口属于灰色地带,不稳定。

解决:真机测试把 BASE_URL 和图片域名都换成电脑的局域网 IP,保证手机和电脑在同一个 WiFi 下,同时确认电脑防火墙放行了 8080 端口。做线上演示时,图片域名必须走 HTTPS,这是小程序的硬性要求。

5.5 端口占用与 BASE_URL 不一致

现象:后端能启动但小程序请求全部超时,或者部分接口通部分接口不通。

原因:8080 端口被其他进程占用,Spring Boot 自动切到了随机端口,但小程序里的 BASE_URL 还写着 8080。还有一种是 context-path 和 URL 对不上,比如后端配置了/api前缀,小程序请求却没带。

解决:启动后先看控制台日志里Tomcat started on port(s)这一行确认实际端口,再和小程序 config.js 里的 BASE_URL 比对。context-path 的问题更隐蔽,如果配了/api,小程序请求路径就要写/api/scenic/list,而不是/scenic/list。

6. 演示与答辩验收:一个能让评委跟住的演示顺序

源码跑通只是第一步,答辩演示的节奏同样重要。我建议按“数据库—后端—小程序—后台”这个顺序来展示,和系统真实的数据流动方向一致。先打开 Navicat 展示核心表结构和预置数据,让评委知道数据的来龙去脉;再启动后端,控制台日志能证明接口在正常对外服务;接着打开小程序开发者工具,先从景点列表浏览开始,再走一遍“登录—浏览详情—下单—模拟支付—查看订单”的完整链路;最后切到后台管理页,展示一次上下架操作,然后回到小程序刷新列表,让评委看到状态变化实时生效。

演示中最怕的是接口突然挂掉。我一般会在演示前强制走一遍完整的自检流程:启动后端,访问一个接口确认返回 JSON;用开发者工具清缓存重新登录,确认 code 正常换取 token;检查数据库连接,执行一次查询;最后确认小程序里的 BASE_URL 是当前这台电脑的 IP。这套流程大概两分钟,能规避掉九成以上的现场翻车。

如果演示时某个接口真的超时了,先看后端控制台有没有报错堆栈,再看 MySQL 是否还活着。这三个组件里 MySQL 最先挂的可能性最大,所以演示前重启一下 MySQL 服务也算是个保险动作。从那以后我每次演示都会强制走一遍“数据库通—后端通—小程序通”的三连通检查,再开始录屏或现场操作,这个习惯帮我避免了好几次尴尬场面。希望帮到你。

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