Spring Boot旅游小程序后端开发:登录、景点与订单全流程解析
2026/9/16 6:23:01 网站建设 项目流程

简介:这是一套基于Spring Boot与微信小程序的旅游类毕业设计/大作业源码包,主要面向Java后端开发初学者、小程序学习者和正在准备相关课题的毕业生,帮助解决从需求分析到功能落地中的典型难点。压缩包共有6个文件,其中包含Java源码、Markdown说明文档以及多张运行界面截图,整体体积仅897KB,体量虽小但模块划分清楚。源码中实现了登录注册、景区展示、门票预订、酒店预订、订单结算、攻略介绍、旅游社交、目的地信息检索和关键字检索等模块,技术栈涉及Spring Boot、MyBatis、MySQL、Redis与微信小程序,基本覆盖旅游类互联网应用的常见业务链。目前已有100人学习,借助源码、说明文档与运行截图,可快速理解后端接口设计、数据库表关系以及小程序端页面调用逻辑,也可对照截图检查项目运行效果。整套内容适合作为毕业设计、大作业的参考案例,并可在现有功能之上继续扩展。

1. 基于springboot的旅游小程序:先把后端边界想清楚

基于springboot的旅游小程序,核心工作不在小程序那一端,而在 springboot 后端能不能稳定输出一套接口:微信登录、景点列表、详情、下单,再往后是支付回调。搜索这个词的人,大多数是拿它做毕设原型、景区轻应用或二手项目改造,需求收敛成一句话:登录能过、列表能刷、订单能存。这四个点通了,项目就跑起来了。

真正让这类项目后期难改的,不是功能多少,而是版本和分层。springboot 版本太高会踩 javax 迁移、mybatis-plus 不兼容、微信 sdk 引用失败的连环坑;分层太薄则是不敢加新需求(比如讲解员预约)的根源。这里按我接手这类项目时的标准做法展开:从 idea 创建 springboot 项目开始,定依赖、配置、目录,再写微信登录、景点查询、下单三条链路,最后落到小程序端联调和部署排错。

想把这个标题变成可运行代码的人,照着往下走即可;拿到现成 zip 的,也能快速看懂目录、知道参数在哪改、上线前该检查什么。

2. 技术选型与工程骨架:springboot版本、依赖与目录规划

这类项目最常见的坑不在业务代码,而在脚手架阶段。先把 springboot 版本定住,再谈依赖、配置和目录,后面所有接口都长在同一套骨架上,改起来才不会到处打架。

2.1 springboot版本别追新:2.7.x 在小程序后端仍是主流选择

检索"springboot版本太高"出现频率已经说明问题:springboot 3.x 强制 JDK 17,把 javax.servlet 换成 jakarta.servlet,mybatis-plus 要升到 3.5.3.2+,swagger 得换 springdoc,网上大量现成示例和毕设代码直接跑不起来。基于springboot的旅游小程序属于典型的中小型 API 服务,没有虚拟线程、graalvm 这种新特性的强需求,稳定优先。

springboot 版本最低 JDK主要坑点适合场景
2.5.148安全补丁少,mybatis-plus 只能用 3.4.x老项目维护
2.7.188无明显坑,生态最成熟小程序后端、毕设、外包项目
3.2.x17javax 迁移、国产组件兼容差新团队无历史包袱

我的选择是 2.7.18,这是 2.x 的最后一个维护版本,依赖 mybatis-plus 3.5.5、jjwt 0.11.5 都验证过能一起跑。如果接手别人的 zip 发现 pom 里是 3.x,先别急着降级;只有 springfox swagger、老版 mybatis-generator 这类强依赖 javax 的组件,才需要回退到 2.x,否则动版本就是连环改代码。

2.2 从 idea 创建 springboot 项目到最小可运行骨架

idea 创建 springboot 项目最省事的路径是 New Project → Spring Initializr,左侧选 Java 8、Spring Boot 2.7.18,依赖勾 spring web、mysql driver、lombok、validation。mybatis-plus 和 jwt 这类第三方依赖不在初始列表里,直接在 pom 里补:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> </dependencies>

这几项依赖按顺序说。spring-boot-starter-web 提供 springmvc 和内置 tomcat,小程序端所有 http 接口都走它;mybatis-plus-boot-starter 用 3.5.5,对 springboot 2.7 的自动配置兼容最好,不用额外写 mybatis 配置类;jjwt-api 选 0.11.5,网上抄的 0.9.x 代码风格旧,builder 写法和 jjwt 自带工具类都不同,混着用容易编译不过;data-redis 用于缓存景点详情和登录态;mysql-connector-java 在 2.7 里默认是 8.0.33,注意 url 里不带时区会直接报错。

接着是 springboot 配置 application.yml,这是整个项目最容易被反复改的文件:

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/travel?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 redis: host: localhost port: 6379 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jwt: secret: travel-blog-demo-2024-change-me-please-32byte expire: 604800

参数说明:datasource.url 里 useSSL=false 本地开发可以省掉证书校验,生产走内网连接 MySQL 保持 false 问题也不大;serverTimezone=Asia/Shanghai 是 MySQL 8 的硬性要求,缺了直接抛时区异常。map-underscore-to-camel-case 打开后,数据库字段 order_no 自动映射到 Order 实体的 orderNo,否则每次查询结果对应字段全是 null。logic-delete-field 配 deleted 后,mybatis-plus 会把删除自动变成 update deleted=1,查询也要用框架自带方法,手写 sql 会把已删除数据查出来。jwt.secret 是这个文件里最容易踩的坑,jjwt 0.11.5 要求密钥不少于 32 字节,写个 short-secret 运行时会抛 WeakKeyException。

2.3 目录分层与统一响应体

目录按 controller / service / mapper / entity / common / config 六层切。小程序后端没有复杂领域逻辑,切太细反而找文件费劲,common 放统一响应体、业务异常和全局异常处理,config 放 jwt 拦截器和 redis 序列化配置:

com.example.travel ├── TravelApplication.java ├── common │ ├── Result.java │ ├── BusinessException.java │ └── GlobalExceptionHandler.java ├── config │ ├── JwtInterceptor.java │ └── WebMvcConfig.java ├── controller │ ├── AuthController.java │ ├── SpotController.java │ └── OrderController.java ├── service │ └── impl ├── mapper └── entity

统一响应体 Result 长这样:

@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 200; r.msg = "ok"; r.data = data; return r; } public static <T> Result<T> fail(int code, String msg) { Result<T> r = new Result<>(); r.code = code; r.msg = msg; return r; } }

code 约定 200 成功、401 未登录、500 系统错,业务错误用单独编码,比如 1001 表示库存不足。GlobalExceptionHandler 用 @RestControllerAdvice 统一捕获 BusinessException 和参数校验异常,转成 Result 返回,controller 里就不用到处 try-catch。小程序端的 request 封装只需认 code 是否等于 200,整条联调链路的面会小很多。

3. 核心业务接口:微信登录、景点查询与订单下单

骨架定完,开始写真正的业务。三条链路按依赖关系排:先有登录态,再有景点数据,最后才有订单。每条链路都给出可抄的代码和参数边界。

3.1 微信登录:小程序用 code 换 token 的正确姿势

微信小程序的登录链路在旅游类小程序里几乎一样:wx.login() 拿一个临时 code 给后端,后端拿 code 到微信服务器换 openid 和 session_key,再用自己签发的 token 维持登录态。最忌讳的做法是把 appSecret 写进小程序前端,打包后能被扒出来,secret 只能放在 springboot 配置里。

@Service @Slf4j public class WxAuthService { private final RestTemplate restTemplate = new RestTemplate(); @Value("${wx.appid}") private String appid; @Value("${wx.secret}") private String secret; public String code2Session(String code) { String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appid + "&secret=" + secret + "&js_code=" + code + "&grant_type=authorization_code"; String resp = restTemplate.getForObject(url, String.class); JSONObject json = JSON.parseObject(resp); if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) { throw new BusinessException("wx login fail: " + json.getString("errmsg")); } return json.getString("openid"); } }

code2Session 接口的四个参数里,grant_type 固定 authorization_code,js_code 就是 wx.login 返回的一次性 code。code 有效期五分钟且只能用一次,后端日志出现 invalid code 时,优先怀疑小程序端 onShow 里重复调了 wx.login。返回的 openid 是用户在该小程序下的唯一标识,user 表拿它做唯一键;session_key 不要返回给前端,它只用于后续解密手机号这类敏感操作。

拿到 openid 之后的登录逻辑:

public LoginResult login(String code) { String openid = wxAuthService.code2Session(code); AppUser user = userMapper.selectOne( new LambdaQueryWrapper<AppUser>().eq(AppUser::getOpenid, openid)); if (user == null) { user = new AppUser(); user.setOpenid(openid); user.setNickname("游客" + openid.substring(0, 6)); user.setCreateTime(LocalDateTime.now()); userMapper.insert(user); } String token = jwtUtil.createToken(user.getId()); return new LoginResult(token, user); }

先按 openid 查再决定 insert,是幂等登录的关键,否则同一用户每次进来都会多一条记录。jwtUtil.createToken 把 userId 放进 subject,过期时间从 yml 读,毕设场景 7 天够用,上生产建议 2 小时并配 redis 续期。后续需要登录的接口在拦截器里解析 token 拿到 userId,放进 ThreadLocal 供 service 使用,比每个 controller 参数传 userId 干净,也比在接口里重复查表省一次 IO。

提示:配置里一定要有 wx.appid 和 wx.secret 两个占位项,不配或配错时,报错是 401 而不是 500,容易误判成 token 问题。

3.2 景点分页与详情:查询参数与缓存策略

景点列表是旅游小程序流量最大的接口,参数一般就三个:pageNo、pageSize、keyword。列表接口永远不要返回景区介绍这种超长文本,detail 接口单独给,列表只回 id、名称、封面、星级、最低价,首屏耗时能明显降下来。

@GetMapping("/page") public Result<IPage<Spot>> page(@RequestParam(defaultValue = "1") long pageNo, @RequestParam(defaultValue = "10") long pageSize, @RequestParam(required = false) String keyword, @RequestParam(defaultValue = "0") Integer star) { LambdaQueryWrapper<Spot> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Spot::getName, keyword) .eq(star != null && star > 0, Spot::getStar, star) .orderByDesc(Spot::getHot); return Result.ok(spotService.page(new Page<>(pageNo, pageSize), wrapper)); }

参数说明:like 第一个参数是 boolean,条件不成立时自动跳过,不用手写 if 拼 sql;eq 同理,star 传 0 时不参与过滤。orderByDesc(getHot) 让热门景点排前面,小程序端首次进入看到的列表质量直接决定这个功能去留。Page 对象直接返回,mybatis-plus 自动带出 total 和 pages,前端分页组件只看这两个值。keyword 为空时 like 条件失效,查询退化为普通分页,全程预编译,不存在 sql 注入问题。

详情接口加一层 redis 缓存,按 id 缓存 30 分钟:

public Spot detailWithCache(Long id) { String key = "travel:spot:detail:" + id; Object cache = redisTemplate.opsForValue().get(key); if (cache != null) { return (Spot) cache; } Spot spot = this.getById(id); if (spot != null) { redisTemplate.opsForValue().set(key, spot, 30, TimeUnit.MINUTES); } return spot; }

这里有一个参数细节:set 的第三个参数 30 和第四个参数 TimeUnit.MINUTES 必须一起出现,漏掉一个就是写入了永不过期的 key。key 用冒号分段,如 travel:spot:detail:12,方便在 redis-cli 里用 keys travel:spot:* 扫前缀排查。缓存穿透的简单处理是对不存在的 id 也缓存一个空对象并设 60 秒过期,否则有人拿不存在的 id 连续请求,redis 会被垃圾 key 塞满。

3.3 下单与订单状态机

下单接口是整套系统里唯一值得画状态机的地方,旅游类订单常见五个状态,表结构里直接放 status 整型字段,比字符串稳定也省空间:

状态值含义触发动作前端展示
0待支付用户提交订单去支付
1已支付微信支付回调成功待使用
2已取消用户取消或超时未支付已取消
3已使用景区扫码核销已完成
4已退款退款单处理完成已退款

创建订单的 service 代码:

@Transactional(rollbackFor = Exception.class) public Order createOrder(Long userId, Long spotId, Integer ticketTypeId, Integer count) { SpotTicket ticket = ticketService.lockAndGet(ticketTypeId); if (ticket.getStock() < count) { throw new BusinessException(1001, "库存不足"); } Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setSpotId(spotId); order.setAmount(ticket.getPrice() * count); order.setStatus(0); order.setTicketSnapshot(ticket.getName() + "x" + count); orderService.save(order); ticketService.deductStock(ticketTypeId, count); return order; }

几个容易忽略的参数和边界。ticketTypeId 对应库存必须在事务里用 select for update 锁住再判断,否则高并发下会超卖,mybatis-plus 的 selectById 拿到的是快照,兜底逻辑要手写 update stock = stock - #{count} where stock >= #{count},影响行数为 0 就回滚。amount 以服务器计算为准,小程序端传的金额只能当展示参考,绝不能直接入库。ticketSnapshot 把票名称和数量冗余进订单,之后门票改价改描述不影响历史订单。generateOrderNo 用 yyyyMMddHHmmss + 4 位随机数,单机下够用,分布式要加机器位。@Transactional 保证保存订单和扣库存同生共死,任一异常全部回滚。

防重复提交是下单接口的隐性需求:用户双击按钮会同时发两个请求,进入 createOrder 前用 redis setnx 加锁,key 是 userId + spotId + ticketTypeId,成功才继续,finally 里删锁并设置 2 秒过期,比前端 disable 按钮可靠得多。

4. 小程序端联调:请求封装、登录时序与动态标题

后端接口写完,联调阶段的痛点在另一端:每个页面裸写 wx.request,401 要处理、loading 要手动关、公共 header 要重复写。这一章把小程序端三个最常用的改造点讲清楚。

4.1 wx.request 封装与 token 处理

标准做法是抽一个 request.js 统一收口,所有页面只调封装后的方法:

// utils/request.js const BASE_URL = 'https://yourdomain.com/api'; function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method: method, data: data, header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (wx.getStorageSync('token') || '') }, success(res) { if (res.data.code === 200) { resolve(res.data.data); } else if (res.data.code === 401) { wx.removeStorageSync('token'); reject(res.data); } else { wx.showToast({ title: res.data.msg, icon: 'none' }); reject(res.data); } }, fail(err) { reject(err); } }); }); } module.exports = { request };

参数说明:header 里的 Authorization 是 Bearer 加空格加 token,和后端拦截器的解析规则严格对应,空格漏了会一直 401。code 200 时只把 data 字段 resolve 出去,页面代码不用再 res.data.data 套三层。业务错误用 wx.showToast 统一提示,每个页面省掉重复逻辑。BASE_URL 开发环境可指 http://localhost:8080,真机调试必须换 https 域名,否则 wx.request 直接报 url not in domain list,这个报错在 4.2 里和登录问题并列第一高频。

4.2 小程序端登录时序与 code2session 联调

登录时序在小程序端就一个原则:app.js 的 onLaunch 里发一次 wx.login,拿到 token 存 storage,后续请求靠 header 里的 token 维持。不要在 onLoad 里重复登录,code 是一次性的,第二次用必失败。

// app.js App({ onLaunch() { this.login(); }, login() { wx.login({ success: (res) => { wx.request({ url: 'https://yourdomain.com/api/auth/login', method: 'POST', data: { code: res.code }, success: (r) => { if (r.data.code === 200) { wx.setStorageSync('token', r.data.data.token); } } }); } }); } });

wx.login 的成功回调里 res.code 就是后端的 js_code。这里不弹授权框,wx.getUserProfile 是另一回事,只在展示头像昵称时才调用,且必须在用户点击事件里触发,onLoad 里直接调会静默失败。联调时两个高频现象:后端日志报 code 已被使用,检查页面 onShow 是否又触发了一次 login;报 appid 与 secret 不匹配,检查小程序后台 appid 和配置是否一致,测试号与生产号经常混用。

联调阶段还有个建议:后端 AuthController 的 /auth/login 接口不要挂拦截器,否则前端还没拿到 token 就被 401 挡掉。拦截器只注册到 /api/order/** 这类需要登录的路径,这就是拦截器要显式配置 excludePathPatterns 的原因。ant 匹配规则里 /api/** 和 /api/* 的区别也在这里体现,前者匹配多层路径,后者只匹配一层,配错就会把详情接口漏放行或误拦截。

4.3 动态设置标题与首页加载页

小程序动态设置标题的接口是 wx.setNavigationBarTitle,典型场景是详情页标题跟随景区名变化:

// pages/spot/detail.js onLoad(options) { const { id } = options; request('/spot/' + id).then((spot) => { wx.setNavigationBarTitle({ title: spot.name }); this.setData({ spot: spot, loaded: true }); }); }

onLoad 时页面还没数据,拿到接口返回值后再调 setNavigationBarTitle,不会闪跳;不要用这个接口实现页面上方的自定义导航,那部分要改的是 navigationStyle 配置。另一个关联点是刚进入页面的加载体验——不要用 wx.showLoading 扛所有接口,景点首屏拆成"列表先渲染骨架、图片懒加载",wxml 里用 loaded 字段切换骨架屏和真实内容的 wx:if,接口 500ms 内能回来就感知不到白屏。

给一个小技巧:页面跳转传参只用字符串,object 参数会被序列化成 [object Object]。列表跳详情前用 JSON.stringify 把查询条件压成字符串传过去,进入页面再 JSON.parse,这个坑在景点列表到详情的跳转里基本必踩一次。

5. 上线前检查:小程序备案、HTTPS 与 springboot 配置排错

项目能跑和能上线之间隔着一层配置检查。这一章把发布前最容易被卡的三件事集中收一遍。

5.1 小程序备案与服务器域名配置

小程序备案备注信息按实际经营范围填,旅游类的写"旅游信息展示、在线预订服务"即可,不要写"商城"这类会触发额外资质审查的词。备案完成后,在微信公众平台的开发管理里配置 request 合法域名,域名必须完成 ICP 备案且有有效 https 证书,开发时用的 localhost 在这里不生效,真机预览会直接拦截。

5.2 nginx 转发与 400/401 定位

后端跑在 8080,线上常规做法是 nginx 挂在 443 上做反向代理,小程序只认 https 域名:

server { listen 443 ssl; server_name yourdomain.com; ssl_certificate /etc/nginx/ssl/yourdomain.pem; ssl_certificate_key /etc/nginx/ssl/yourdomain.key; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 30s; } }

配置说明:location /api/ 与 proxy_pass 不带路径时是原样透传,后端接口路由保持 /api 前缀,小程序端 BASE_URL 不用拆。上线后遇到 401,优先看 token 是否过期,jwt.expire 单位是秒,604800 是 7 天;遇到 400,多为 json 字段名不匹配或日期格式不对,先看小程序端传参和后端实体字段是否一致,再看 jackson 的 date-format 是否覆盖了 LocalDateTime——2.7 里 LocalDateTime 默认不按 yml 的 date-format 输出,需要额外配 Jackson 的 JavaTimeModule。

5.3 springboot 安全项与健康检查

如果 pom 里引入了 actuator,springboot 默认会把 /actuator/heapdump 暴露出来,任何人访问就能下载堆内存,里面可能有数据库密码和微信 secret。这类敏感信息泄露在 springboot 项目里是排查重点,务必将 web 端点收窄:

management: endpoints: web: exposure: include: health,info

这样设置后,只有 /actuator/health 和 /actuator/info 可访问。把 health 端点挂进 nginx 的上游健康检查,后端服务假死时负载均衡能自动踢掉实例,比上线后靠用户截图报 bug 可靠,这也是接手任何 springboot 项目时第一个该看的配置项。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询