简介:本资源是一套完整的智慧乡村旅游服务平台毕业设计项目,面向计算机专业本科生及Java全栈初学者,聚焦乡村旅游数字化管理场景,提供从需求分析、系统开发到部署演示的全流程实践范例。压缩包共895个文件,40.48MB,涵盖128个Vue前端页面组件、119个Java后端业务逻辑与控制器类、103个JS交互脚本、177个PNG/SVG界面图标及3个MP4视频演示文件,完整支撑小程序+SSM+MySQL技术栈的学习与复现;另有SQL建表语句、毕业论文文档及多套批处理脚本(如install.bat/run.bat),便于快速搭建本地环境。目前已有313人学习下载,资源包含管理员、用户、商家三端功能模块,支持景点管理、路线规划、订单交易与充值等核心业务,结构清晰、注释规范,适合作为课程设计参考、毕设开题原型或微信小程序+Java后端整合开发的实战训练材料。
1. 这不是又一个“旅游小程序Demo”:它是一套能跑通真实角色流转、带完整后台管理闭环的毕业级全栈工程
你可能已经点开过十几个标着“乡村旅游”“微信小程序”的毕业设计资源,点进去发现只有前端页面轮播图+几个空接口,或者后端连登录校验都写在 Controller 里硬编码。但这次不一样——这个源码包里真有三个角色(管理员/用户/商家)在 MySQL 里各自走完注册、权限隔离、订单状态机、收藏联动、充值流水等完整业务链路;SSM 层不是只贴了 Spring Boot 启动类就收工,而是把 MyBatis 的动态 SQL 写到了OrderMapper.xml里用<choose>处理多条件查询,Controller 层真做了@Valid校验 + 全局异常处理器;微信小程序端不是拿wx.request硬怼 URL,而是封装了api/index.js统一拦截 token 过期并跳转登录页。它不追求高并发或微服务,但每一步都踩在 SSM + 小程序开发的真实协作边界上:比如商家上传景点图片时,后端用MultipartFile接收,存到本地upload/目录,再把相对路径写进数据库;小程序端则用wx.uploadFile配合wx.chooseImage完成闭环。如果你正卡在“毕设怎么让三个角色数据不串”“SSM 怎么和小程序 token 对齐”“MySQL 表字段命名怎么兼顾可读性和外键约束”,这份资源就是为你拆解过的、能直接git clone跑起来的参照系。
2. 从零启动:三步拉起完整环境,看清前后端如何握手
2.1 后端部署:SSM 工程结构解析与关键配置项定位
这个项目采用标准 Maven 结构,根目录下pom.xml明确声明了 Spring 4.3.28.RELEASE + SpringMVC 4.3.28.RELEASE + MyBatis 3.4.6 + MySQL Connector/J 5.1.47。注意:不要升级 Spring 到 5.x,否则@Controller类中ModelAndView返回逻辑会因视图解析器变更而报 404(这是血泪经验)。核心配置集中在src/main/resources下:
applicationContext.xml:定义了数据源(DruidDataSource)、事务管理器(DataSourceTransactionManager)、MyBatis 工厂(SqlSessionFactoryBean)及扫描路径(com.example.**.mapper)spring-mvc.xml:启用了注解驱动(<mvc:annotation-driven />),配置了静态资源映射(/static/**→classpath:/static/),并指定了视图解析器前缀/WEB-INF/jsp/、后缀.jspjdbc.properties:包含jdbc.url=jdbc:mysql://localhost:3306/tourism?useUnicode=true&characterEncoding=utf8——必须确保你的 MySQL 服务已启用utf8mb4字符集,否则景点名称中的 emoji 或生僻字会变问号
提示:
webapp/WEB-INF/web.xml中contextConfigLocation指向applicationContext.xml,而DispatcherServlet的contextConfigLocation指向spring-mvc.xml,这是 SSM 分层加载的关键。若启动时报No mapping found for HTTP request,先检查此处是否错配。
2.2 数据库初始化:建库、建表、填初始数据三连击
项目提供tourism.sql(位于根目录或doc/子目录),执行前需手动创建数据库:
CREATE DATABASE tourism CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;然后执行 SQL 文件。重点观察以下 5 张核心表结构:
| 表名 | 关键字段说明 | 业务意义 |
|---|---|---|
users | role(tinyint,0=用户,1=商家,2=管理员),status(tinyint,0=禁用,1=启用) | 角色与状态分离,避免用字符串判别 |
scenic_spots | type_id(外键关联spot_types),route_id(外键关联spot_routes),is_hot(tinyint) | 景点与类型、路线强关联,支持首页热榜筛选 |
orders | user_id,spot_id,order_status(0=待支付,1=已支付,2=已发货,3=已完成,4=已取消),pay_time | 状态机字段明确,pay_time为datetime类型,非timestamp(避免时区自动转换) |
collections | user_id,spot_id,collect_time | 用户收藏表,无冗余字段,靠联合索引(user_id, spot_id)防重复 |
system_config | config_key(如'site_name'),config_value(如'智慧乡村旅游平台') | 全局配置表,避免硬编码 |
执行后,用 Navicat 或命令行验证:
SELECT COUNT(*) FROM users WHERE role = 2; -- 应返回至少 1 条管理员记录(账号 admin / 密码 123456) SELECT * FROM system_config WHERE config_key = 'site_name';2.3 微信小程序端:开发者工具配置与云开发兼容性处理
项目未使用云开发,所有接口均指向本地后端。打开微信开发者工具,导入miniprogram/目录(注意不是整个项目根目录),关键修改在project.config.json和app.js:
project.config.json中"appid"字段需替换为你自己的测试号 AppID(可在微信公众平台申请),否则无法调用wx.login;app.js中全局配置globalData包含baseUrl: 'http://localhost:8080/tourism/'——若后端部署在其他机器,需改为 IP 地址(如http://192.168.1.100:8080/tourism/),不能用localhost;utils/api.js封装了请求拦截:wx.getStorageSync('token')读取本地 token,header: { 'Authorization': 'Bearer ' + token }发送;若 token 过期,fail回调中会wx.navigateTo({ url: '/pages/login/login' })。
注意:微信开发者工具默认开启「不校验合法域名」,但真机调试时必须在「开发管理」中将
http://localhost:8080加入「request 合法域名」(实际生产需换为 HTTPS 域名)。若真机报request:fail net::ERR_CONNECTION_REFUSED,先确认后端 Tomcat 是否已启动且监听 8080 端口。
2.4 一键脚本实操:1-install.bat、2-run.bat、3-build.bat的真相
项目提供了三个.bat脚本,但它们并非全自动魔法:
1-install.bat:本质是mvn clean install -Dmaven.test.skip=true,作用是编译 Java 代码、运行 MyBatis Generator(若配置了)、打包成tourism.war放入target/。执行前需确保pom.xml中<plugin>里的mybatis-generator-maven-plugin版本与generatorConfig.xml兼容(本项目用 1.3.2);2-run.bat:调用mvn tomcat7:run启动内嵌 Tomcat(端口 8080),适合开发调试。若报Address already in use: bind,改pom.xml中<port>为 8081;3-build.bat:执行mvn clean package -Dmaven.test.skip=true,生成tourism.war,可部署到独立 Tomcat。部署后访问http://localhost:8080/tourism/admin/login.jsp即进入后台登录页(账号 admin / 密码 123456)。
提示:
.bak文件(如main.css.bak)是原始备份,可忽略;org.eclipse.wst.common.component是 Eclipse 项目配置,IntelliJ 用户无需关注。
3. 角色权限落地:三层角色如何通过 Filter + Session + SQL 实现数据隔离
3.1 后台权限控制:基于 Session 的角色路由拦截
管理员、商家、普通用户的菜单入口完全不同,系统未用 Shiro 或 Spring Security,而是用原生Filter+HttpSession实现轻量级控制。关键文件是src/main/java/com/example/filter/RoleFilter.java:
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(); // 从 session 获取用户信息(登录时存入) Object userObj = session.getAttribute("user"); if (userObj == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } User user = (User) userObj; String uri = req.getRequestURI(); // 根据角色放行对应路径 if (user.getRole() == 2 && uri.contains("/admin/")) { // 管理员访问 /admin/** chain.doFilter(request, response); } else if (user.getRole() == 1 && uri.contains("/merchant/")) { // 商家访问 /merchant/** chain.doFilter(request, response); } else if (user.getRole() == 0 && (uri.contains("/user/") || uri.contains("/index.jsp"))) { // 用户访问 /user/** 或首页 chain.doFilter(request, response); } else { // 角色越权,重定向到无权限页 req.setAttribute("msg", "无访问权限"); req.getRequestDispatcher("/error/noAuth.jsp").forward(req, resp); } }逻辑说明:
Filter在每次请求时触发,通过session.getAttribute("user")获取登录态,再根据user.getRole()值判断 URI 前缀是否匹配。参数说明:user.getRole()是tinyint类型,值 0/1/2 严格对应数据库users.role字段,不可用字符串"admin"比较。
3.2 小程序端权限分流:App.js 全局路由守卫
小程序没有传统 Session,权限控制依赖wx.setStorageSync('token')和后端返回的role字段。app.js中onLaunch方法完成首次鉴权:
App({ onLaunch: function () { const token = wx.getStorageSync('token'); if (token) { wx.request({ url: 'http://localhost:8080/tourism/user/info', method: 'GET', header: { 'Authorization': 'Bearer ' + token }, success: (res) => { if (res.data.code === 200) { const userInfo = res.data.data; wx.setStorageSync('userInfo', userInfo); // 存入用户信息,含 role 字段 // 根据 role 跳转不同首页 if (userInfo.role === 0) { wx.switchTab({ url: '/pages/index/index' }); } else if (userInfo.role === 1) { wx.switchTab({ url: '/pages/merchant/home' }); } else if (userInfo.role === 2) { wx.navigateTo({ url: '/pages/admin/login' }); // 管理员走独立登录页 } } } }); } } });参数说明:
userInfo.role由后端UserController.info()接口返回,该接口从JwtUtil.parseToken(token)解析出用户 ID,再查users表获取role字段。JWT 密钥在JwtUtil.java中硬编码为"tourism_secret",生产环境必须替换为随机密钥。
3.3 数据库层隔离:同一张表如何让不同角色看到不同数据
最典型的场景是scenic_spots表:管理员能看到全部景点,商家只能看到自己发布的,用户只能看到status=1(审核通过)的。后端通过动态 SQL 实现:
<!-- ScenicSpotMapper.xml --> <select id="listByRole" resultType="ScenicSpot"> SELECT * FROM scenic_spots <where> <if test="role == 0"> <!-- 用户 --> status = 1 </if> <if test="role == 1"> <!-- 商家 --> merchant_id = #{userId} AND status IN (1,2) <!-- 商家可看自己待审核和已发布 --> </if> <if test="role == 2"> <!-- 管理员 --> 1=1 <!-- 查全部 --> </if> </where> ORDER BY create_time DESC </select>逻辑说明:MyBatis 的
<if>标签根据传入的role参数动态拼接 WHERE 条件。参数说明:#{userId}是商家 ID,从SecurityUtil.getCurrentUserId()获取(该工具类从ThreadLocal中取当前登录用户 ID,避免 Controller 层重复传参)。
3.4 订单状态机:从下单到完成的 5 个状态如何被三方协同更新
订单流程是检验权限设计的试金石。orders表的order_status字段驱动整个生命周期:
| 状态值 | 触发方 | 触发条件 | 后端逻辑 |
|---|---|---|---|
| 0(待支付) | 用户 | 提交订单后 | OrderService.createOrder()插入记录,order_status=0 |
| 1(已支付) | 用户 | 支付成功回调 | PayNotifyController.notify()校验签名后,update order_status=1, pay_time=now() |
| 2(已发货) | 商家 | 点击“发货”按钮 | MerchantOrderController.ship()校验role==1且order_status==1,更新order_status=2, ship_time=now() |
| 3(已完成) | 用户 | 确认收货 | UserOrderController.confirm()校验role==0且order_status==2,更新order_status=3, finish_time=now() |
| 4(已取消) | 用户/商家/管理员 | 任一角色调用取消接口 | OrderController.cancel()根据角色判断:用户仅能取消order_status==0,商家可取消0/1,管理员可取消全部 |
关键约束:
OrderService.cancelOrder()方法中,对商家取消操作加了双重校验:if (order.getStatus() != 0 && order.getStatus() != 1) { throw new ServiceException("商家只能取消待支付或已支付的订单"); }
4. 避坑指南:那些让你调试到凌晨三点的典型问题与解法
4.1 现象:小程序登录后wx.request返回 401,但后端日志显示 token 解析成功
原因:微信小程序wx.request默认不携带 Cookie,而本项目后端JwtUtil从request.getHeader("Authorization")读取 token,但小程序端header中未设置Authorization字段。
解决:检查utils/api.js中request方法,确认header是否包含Authorization。常见错误是wx.getStorageSync('token')返回undefined(因登录接口未正确存储),应在login.js的success回调中显式写入:
wx.setStorageSync('token', res.data.data.token); wx.setStorageSync('userInfo', res.data.data.user);4.2 现象:Tomcat 启动后访问http://localhost:8080/tourism报 404,但http://localhost:8080/tourism/login.jsp可访问
原因:web.xml中<welcome-file-list>未配置index.jsp,或index.jsp不在webapp/根目录下。本项目index.jsp位于webapp/,但web.xml缺少欢迎页配置。
解决:在webapp/WEB-INF/web.xml的<web-app>标签下添加:
<welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list>4.3 现象:商家上传景点图片后,页面显示空白,数据库scenic_spots.image_path字段存的是upload/20230501/abc.jpg,但浏览器访问http://localhost:8080/tourism/upload/20230501/abc.jpg报 404
原因:Tomcat 默认不提供upload/目录的静态资源服务,需在web.xml中配置DefaultServlet映射。
解决:在webapp/WEB-INF/web.xml中添加:
<servlet-mapping> <servlet-name>default</servlet-name> <url-pattern>/upload/*</url-pattern> </servlet-mapping>并确认scenic_spots.image_path存储的是相对路径(不含upload/前缀),前端拼接时用http://localhost:8080/tourism/upload/+image_path。
4.4 现象:管理员后台点击“删除景点”,浏览器弹出确认框后页面无反应,F12 查看 Network 发现DELETE请求返回 500
原因:ScenicSpotController.delete()方法中,scenicSpotService.removeById(id)调用前未校验景点是否已被订单引用,导致外键约束失败抛出SQLIntegrityConstraintViolationException。
解决:在ScenicSpotService.removeById()中增加前置检查:
// 先查是否有订单关联此景点 Long orderCount = orderMapper.countBySpotId(id); if (orderCount > 0) { throw new ServiceException("该景点已有订单,无法删除"); }4.5 现象:MySQL 执行tourism.sql时,scenic_spots表创建失败,报错Specified key was too long; max key length is 767 bytes
原因:scenic_spots.name字段为varchar(200),使用utf8mb4字符集时,单字符最多占 4 字节,200×4=800 > 767。
解决:修改tourism.sql中scenic_spots.name字段长度为varchar(191)(191×4=764 ≤ 767),或在 MySQL 5.7+ 中执行:
SET GLOBAL innodb_file_format=Barracuda; SET GLOBAL innodb_file_per_table=ON; SET GLOBAL innodb_large_prefix=ON; ALTER TABLE scenic_spots ROW_FORMAT=DYNAMIC;5. 真实业务缝合:如何把“景点路线”模块从静态列表变成可交互的导航闭环
5.1 路线数据结构设计:为什么用spot_routes表而非 JSON 字段
很多初学者会把路线点存成JSON字符串(如{"points":[{"lat":30.1,"lng":120.2,"name":"入口"},{"lat":30.2,"lng":120.3,"name":"观景台"}]}),但这会导致无法按坐标范围查询、无法建立空间索引。本项目采用关系型设计:
CREATE TABLE spot_routes ( id BIGINT PRIMARY KEY AUTO_INCREMENT, route_name VARCHAR(100) NOT NULL, description TEXT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE route_points ( id BIGINT PRIMARY KEY AUTO_INCREMENT, route_id BIGINT NOT NULL, point_name VARCHAR(50) NOT NULL, latitude DECIMAL(10,8) NOT NULL, -- 纬度 longitude DECIMAL(11,8) NOT NULL, -- 经度 sort_order INT NOT NULL DEFAULT 0, -- 排序序号 FOREIGN KEY (route_id) REFERENCES spot_routes(id) ON DELETE CASCADE );优势:
route_points表可对latitude/longitude建复合索引,支持“查找距离某坐标 1km 内的所有路线点”;sort_order字段保证前端渲染顺序;ON DELETE CASCADE确保删除路线时自动清理点数据。
5.2 小程序端路线展示:Canvas 绘制折线 + Marker 标注的实战代码
pages/route/detail.js中,onLoad获取路线数据后,调用drawRoute()方法:
drawRoute(points) { const query = wx.createSelectorQuery(); query.select('#routeCanvas').fields({ node: true, size: true }).exec((res) => { const canvas = res[0].node; const ctx = canvas.getContext('2d'); const dpr = wx.getSystemInfoSync().pixelRatio; canvas.width = res[0].width * dpr; canvas.height = res[0].height * dpr; ctx.scale(dpr, dpr); // 计算画布坐标(将经纬度映射到画布像素) const bounds = this.getBounds(points); const scaleX = res[0].width / (bounds.maxLng - bounds.minLng); const scaleY = res[0].height / (bounds.maxLat - bounds.minLat); // 绘制折线 ctx.beginPath(); ctx.setStrokeStyle('#1890ff'); ctx.setLineWidth(3); points.forEach((point, index) => { const x = (point.longitude - bounds.minLng) * scaleX; const y = res[0].height - (point.latitude - bounds.minLat) * scaleY; // Y轴翻转 if (index === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } }); ctx.stroke(); // 绘制 Marker points.forEach((point, index) => { const x = (point.longitude - bounds.minLng) * scaleX; const y = res[0].height - (point.latitude - bounds.minLat) * scaleY; ctx.beginPath(); ctx.arc(x, y, 6, 0, 2 * Math.PI); ctx.setFillStyle(index === 0 ? '#52c418' : index === points.length - 1 ? '#faad14' : '#1890ff'); ctx.fill(); ctx.setFontSize(12); ctx.setFillStyle('#000'); ctx.setTextAlign('center'); ctx.setTextBaseline('middle'); ctx.fillText(point.point_name, x, y - 15); }); }); }逻辑说明:
getBounds()计算所有点的经纬度极值,用于归一化;scaleX/scaleY将地理坐标转为画布像素;Y 轴翻转是因为地图纬度北增南减,而 Canvas Y 轴下增上减。参数说明:dpr(设备像素比)确保高清屏显示清晰;ctx.arc()绘制圆形 Marker,起点绿色、终点橙色、中间蓝色,符合用户认知。
5.3 后端路线推荐:基于用户历史订单的协同过滤简易实现
当用户查看某景点时,后端SpotController.recommendRoutes()接口会返回“常与该景点一同被订购的路线”。不用复杂算法,用 SQL 关联即可:
SELECT sr.id, sr.route_name, COUNT(*) as freq FROM spot_routes sr JOIN route_points rp ON sr.id = rp.route_id JOIN orders o ON rp.point_name IN ( SELECT ss.name FROM scenic_spots ss WHERE ss.id = o.spot_id ) WHERE o.user_id = #{userId} AND o.order_status >= 3 -- 已完成订单 GROUP BY sr.id, sr.route_name ORDER BY freq DESC LIMIT 3;说明:该 SQL 通过
route_points.point_name关联scenic_spots.name,找出用户历史订单中景点名称匹配的路线点,再按路线分组计数。虽简单,但比纯随机推荐更贴近真实行为。
5.4 从“能跑”到“能用”:我每次交付前必做的三件事
第一,强制走一遍「用户下单→商家发货→用户确认→管理员查看统计」全流程,用 Postman 模拟每个角色的请求头(特别是Authorization),验证orders.order_status字段是否按预期流转;第二,导出tourism数据库,用mysqldump --skip-triggers --no-create-info tourism > data_only.sql清除建表语句,只留INSERT,作为交付给导师的“纯净数据”;第三,在小程序project.config.json中把appid改为空字符串,这样即使别人拿到代码也无法直接上线,倒逼他们理解配置项含义。
希望帮到你。
本文还有配套的精品资源,点击获取