☰
微信小程序智慧旅游平台:SSM后端与联调实战全解析
2026/10/10 14:21:45 网站建设 项目流程

简介:一份基于微信小程序的智慧旅游平台开发毕业论文文档,适用于计算机相关专业毕业设计参考,尤其适合采用SSM框架与MySQL实现前后端分离的选题。文档系统阐述微信小程序端与后台管理端的设计过程,涵盖可行性分析、功能设计、数据库设计及SSM框架整合应用,管理员侧包含用户管理、景点分类、景点购票、景区活动、留言板等模块,用户侧支持景点浏览、在线购票与留言。资源为单个doc文件,大小1.66MB,无需解压即可直接阅读。目前已有87人浏览学习。借助该文档可快速理解智慧旅游类小程序的完整开发流程、数据库表结构规划及SSM后台搭建思路,对撰写毕业论文和完成系统设计均有较高参考价值。

1. 智慧旅游小程序不是套壳页面:SSM 后端与微信端的联动才是重点

拿到“基于微信小程序的智慧旅游平台”这类资源,多数人的第一反应是“又是一个后台增删改查”,但这份东西真正值钱的地方在链路:微信小程序端通过 wx.request 调用后端 HTTP 接口,后端用 SSM(Spring + SpringMVC + MyBatis)处理业务,MySQL 落库,管理员再通过浏览器登录后台维护数据。三端串起来,才叫一个完整的智慧旅游小程序。适合两类人:一是要做课程设计或毕业设计、需要双端可演示的项目;二是后端熟了但没调通过小程序接口,想补上“从数据库到手机屏幕”这块短板的人。后面所有拆解都围绕这一条链路展开。

2. SSM 后端骨架与数据建模:先把 8 张核心表拆开看

2.1 数据模型:从 E-R 图到落库的字段取舍

原文档的 E-R 图只画了景点分类、旅游资讯、留言板几个实体,实际要落地一个能跑起来的“购票 + 活动 + 留言”系统,表至少要拆到 8 张。我按常见做法收敛成了下面这套,和文档里的功能结构一一对应:

表名对应功能模块核心字段
user用户管理id, username, password, nickname, avatar, phone, create_time
admin管理员登录id, username, password, role
scenic_category景点分类管理id, name, sort_order, create_time
scenic_spot旅游景点管理id, category_id, name, description, cover_image, price, location, opening_hours, ticket_stock, status
ticket_order景点购票管理id, order_no, user_id, scenic_id, visit_date, quantity, amount, status, create_time
scenic_activity景区活动管理id, scenic_id, title, content, start_time, end_time
message_board留言板管理id, user_id, content, reply_content, create_time
config系统管理id, key, value, remark

核心关联就两条:scenic_spot.category_id指向scenic_category.id,ticket_order.scenic_id指向scenic_spot.id。用户表独立,不做积分、会员等级这类扩展,避免把毕业设计做成电商系统。

scenic_spot里的ticket_stock字段很关键。很多仿品库存写死在代码里,这里必须落在数据库,购票时才能做扣减校验。status字段用于上下架,管理员把某个景点的状态改成 0,用户端立刻看不到,不需要删数据。

2.2 建表 SQL:注意 DECIMAL 和唯一索引

经费充足的项目会用到 MyBatis Generator 自动生成,但手写 SQL 能让你更清楚每个字段的用途。这是景点表和购票表的建表语句,注意我标了注释的地方:

CREATE TABLE scenic_spot ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL COMMENT '关联景区分类表', name VARCHAR(100) NOT NULL COMMENT '景点名称', description TEXT COMMENT '景点描述,小程序详情页展示', cover_image VARCHAR(255) COMMENT '封面图URL', price DECIMAL(10, 2) NOT NULL DEFAULT 0.00 COMMENT '门票价格,用DECIMAL不用FLOAT', location VARCHAR(255) COMMENT '景点位置,用于地图展示', opening_hours VARCHAR(100) COMMENT '开放时间,如 08:00-17:00', ticket_stock INT NOT NULL DEFAULT 0 COMMENT '当日可售余票', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE ticket_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号,唯一索引防重复下单', user_id INT NOT NULL, scenic_id INT NOT NULL, visit_date DATE NOT NULL COMMENT '游玩日期', quantity INT NOT NULL DEFAULT 1 COMMENT '购票数量', amount DECIMAL(10, 2) NOT NULL COMMENT '总金额 = 单价 * 数量', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已核销 3已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_id (user_id), KEY idx_scenic_id (scenic_id) );

这里有两个设计上的取舍。order_no加UNIQUE是防止重复提交,小程序端用户手快点了两次“确认购票”,后端靠这个字段兜底。price和amount用DECIMAL(10,2)而不是FLOAT,涉及到钱一律不用浮点,否则 99.9 存进去可能变成 99.899999。PHP 项目里吃这个亏的不少,Java 后端配合BigDecimal才能保证金额展示一致。

status字段是购票订单的状态机核心,后面管理员核销也要靠它。建议初始化时给user_id和scenic_id建普通索引,因为用户查“我的订单”、管理员查某个景点的售出情况都很频繁,千万级数据量下没索引会全表扫描。

2.3 Controller-Service-Mapper:一个景点列表接口拆三层

SSM 的典型请求链路是“小程序页面 → Controller → Service → Mapper → MySQL”,每层各干各的事。以景点列表为例,Controller 只负责接收参数和封装返回,Service 做业务判断,Mapper 管 SQL。下面是一个简化但完整的 Controller:

@RestController @RequestMapping("/api/spot") public class ScenicSpotController { @Autowired private ScenicSpotService scenicSpotService; @GetMapping("/list") public Result list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer limit, @RequestParam(required = false) Integer categoryId) { // 分页参数交给Service处理,Controller不写业务逻辑 PageInfo<ScenicSpot> pageInfo = scenicSpotService.queryPage(page, limit, categoryId); return Result.success(pageInfo); } @GetMapping("/detail/{id}") public Result detail(@PathVariable Integer id) { ScenicSpot spot = scenicSpotService.getById(id); if (spot == null || spot.getStatus() == 0) { return Result.error("景点不存在或已下架"); } return Result.success(spot); } }

@RequestParam(defaultValue = "1")的意思是前端不传page时默认取第 1 页,避免小程序端少传参数导致报错。@PathVariable用于从 URL 路径里取景点 ID。Result是一个统一返回体,常见结构是{ code: 200, message: "ok", data: ... },小程序端拿到后先判断code再做渲染,比直接返回裸 JSON 好维护得多。

对应的 Mapper XML 里,重点是动态 SQL:

<select id="queryPage" resultType="com.example.entity.ScenicSpot"> SELECT id, name, cover_image, price, location, opening_hours, description FROM scenic_spot <where> <if test="categoryId != null"> AND category_id = #{categoryId} </if> AND status = 1 </where> ORDER BY id DESC LIMIT #{offset}, #{limit} </select>

<where>标签会自动去掉多余的AND,这样categoryId为空时 SQL 变成SELECT ... WHERE status = 1,不会因为拼接出错。#{categoryId}是预编译参数,防 SQL 注入,永远不要用${categoryId}拼字符串。LIMIT #{offset}, #{limit}是分页的常规写法,offset = (page - 1) * limit,这个计算通常在 Service 层完成。

2.4 购票接口:库存扣减与订单插入要放在同一个事务里

购票是本项目的核心业务,也是最容易出 bug 的地方。常见的翻车写法是:先查库存,够的话再 UPDATE 扣减,最后 INSERT 订单。如果中间任何一步失败,库存扣了订单没生成,用户钱花了票没拿到。

Spring 的@Transactional注解可以把三步包成一个原子操作:

@Transactional(rollbackFor = Exception.class) public OrderResult createOrder(CreateOrderParam param) { // 1. 查景点,判断状态和库存 ScenicSpot spot = spotMapper.selectById(param.getScenicId()); if (spot == null || spot.getStatus() != 1) { throw new BusinessException("景点不可购买"); } // 2. 扣减库存,UPDATE语句自带条件判断 int rows = spotMapper.deductStock(param.getScenicId(), param.getQuantity()); if (rows == 0) { throw new BusinessException("余票不足"); } // 3. 生成订单号并插入 String orderNo = generateOrderNo(); TicketOrder order = buildOrder(param, orderNo); orderMapper.insert(order); return new OrderResult(orderNo); }

deductStock对应的 SQL 是关键:

<update id="deductStock"> UPDATE scenic_spot SET ticket_stock = ticket_stock - #{quantity} WHERE id = #{scenicId} AND ticket_stock >= #{quantity} </update>

把“库存是否够”的判断下推到 SQL 的WHERE条件里,而不是先在 Java 代码里查出来再判断。理由很简单:多用户同时购票时,两个请求都读到库存剩 1,都通过 Java 判断,然后各自扣减,库存就变成 -1 了。MySQL 的UPDATE语句执行时会对行加锁,ticket_stock >= #{quantity}条件不满足时影响行数为 0,rows == 0就直接抛异常回滚。小规模项目这么做够用,不必上分布式锁。

rollbackFor = Exception.class的含义是任何异常都回滚,包括运行时异常和受检异常,不写这个参数 Spring 默认只回滚运行时异常,会出现“订单插入了但库存没扣”这类奇怪现象。订单号generateOrderNo()常见做法是时间戳 + 随机数 + 用户ID后缀,长度控制在 32 位以内。

3. 微信小程序端从零对接:首页渲染、购票提交与留言回显

3.1 页面骨架与 tabBar 配置:底部导航决定用户动线

小程序端的 pages 目录我一般这么规划:pages/index/index(首页)、pages/spot/list(景点列表)、pages/spot/detail(景点详情)、pages/activity/list(活动列表)、pages/order/list(我的订单)、pages/message/add(留言)、pages/user/index(我的)。底部导航通常放四个 tab:首页、景点、活动、我的。app.json 里对应配置如下:

{ "pages": [ "pages/index/index", "pages/spot/list", "pages/spot/detail", "pages/activity/list", "pages/order/list", "pages/message/add", "pages/user/index" ], "tabBar": { "color": "#999999", "selectedColor": "#1296db", "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/spot/list", "text": "景点" }, { "pagePath": "pages/activity/list", "text": "活动" }, { "pagePath": "pages/user/index", "text": "我的" } ] }, "window": { "navigationBarTitleText": "智慧旅游", "navigationBarBackgroundColor": "#1296db" } }

pages数组里第一个页面就是小程序启动时的首页。tabBar的pagePath必须和pages里的路径完全一致,否则编译报错。注意只有四个 tab 页能享受底部导航,spot/detail这些二级页面跳转后会隐藏 tabBar,需要在 detail 页面左上角加返回按钮,微信自带。

3.2 封装 wx.request:baseURL 和 token 统一管起来

小程序的wx.request和浏览器的fetch类似,但有一个很烦的限制:不校验合法域名只能用于开发调试,上线必须域名备案 + HTTPS。封装一层 request 的意义在于,以后改域名、加 token 只动一个文件。这是我在项目里的常规写法:

// utils/request.js const BASE_URL = 'https://yourdomain.com/api' // 开发时可换成 http://localhost:8080/api const request = (url, method, data) => { return new Promise((resolve, reject) => { wx.request({ url: `${BASE_URL}${url}`, method: method || 'GET', data: data || {}, header: { 'Content-Type': 'application/json', 'token': wx.getStorageSync('token') || '' }, success: (res) => { // 后端统一返回 { code, message, data } if (res.statusCode === 200 && res.data.code === 200) { resolve(res.data.data) } else if (res.data.code === 401) { // token 过期,跳转登录页 wx.navigateTo({ url: '/pages/login/index' }) reject(res.data) } else { wx.showToast({ title: res.data.message, icon: 'none' }) reject(res.data) } }, fail: (err) => reject(err) }) }) } module.exports = { request, BASE_URL }

wx.getStorageSync('token')是同步读取本地缓存,每次请求都带上,后端拦截器靠这个 header 判断登录状态。用 Promise 包装是因为页面里可以用async/await,比回调层层嵌套清晰得多。res.statusCode === 200是 HTTP 层状态,res.data.code === 200是业务层状态,两层必须分开判断——HTTP 200 不代表业务成功,比如“余票不足”业务失败时后端也会返回 HTTP 200。

页面里调用时再包一层业务封装,比如获取景点详情:

const { request } = require('../../utils/request') Page({ data: { detail: {}, loading: true }, onLoad(options) { this.loadDetail(options.id) }, async loadDetail(id) { try { const detail = await request(`/spot/detail/${id}`, 'GET') this.setData({ detail, loading: false }) } catch (e) { this.setData({ loading: false }) } } })

options.id来自上一页跳转时带的参数:wx.navigateTo({ url: '/pages/spot/detail?id=' + item.id })。setData更新数据后 WXML 会自动响应重新渲染。

3.3 购票流程:从选日期到提交订单的完整数据流

购票不是简单的“用户点一下按钮,后端 insert 一条记录”。一个合格流程至少要经过四个界面状态:景点详情页展示价格和余票、用户选游玩日期和数量、二次确认弹窗、提交后跳转订单列表。前端的关键在提交前的参数校验:

// pages/spot/detail.js 购票提交 async submitOrder() { const { detail, visitDate, quantity } = this.data if (!visitDate) { wx.showToast({ title: '请选择游玩日期', icon: 'none' }) return } if (quantity < 1) { wx.showToast({ title: '数量最少为1', icon: 'none' }) return } try { const res = await request('/order/create', 'POST', { scenicId: detail.id, visitDate, quantity }) wx.showToast({ title: '购票成功', icon: 'success' }) setTimeout(() => { wx.switchTab({ url: '/pages/order/list' }) }, 1500) } catch (e) { // 错误信息已在request里统一toast } }

注意这里传给后端的是scenicId而不是price。价格必须以数据库为准,前端传的价格只能做展示,不能参与计算。攻击者改一下请求体就能用 0.01 元买票。visitDate建议格式化为YYYY-MM-DD再提交,用小程序原生picker组件的bindchange事件取到日期后转一下即可。

订单列表页拿到的数据由后端联表查询返回,SQL 大致是:

<select id="selectOrderWithSpot" resultType="map"> SELECT o.order_no, o.visit_date, o.quantity, o.amount, o.status, s.name AS spot_name, s.cover_image FROM ticket_order o LEFT JOIN scenic_spot s ON o.scenic_id = s.id WHERE o.user_id = #{userId} ORDER BY o.create_time DESC </select>

前端根据status数字显示对应文案:0 待支付、1 已支付、2 已核销、3 已取消。

3.4 留言板提交与回复展示

留言模块相对简单,但有一个界面细节容易被忽略。用户提交留言后,最好立刻清空输入框并重新拉取列表,不要等用户手动刷新。提交接口:

async submitMessage() { const content = this.data.message if (!content.trim()) { wx.showToast({ title: '请输入留言内容', icon: 'none' }) return } await request('/message/add', 'POST', { content }) this.setData({ message: '' }) this.loadMessages() // 重新请求列表 }

后端管理员回复后,用户端看到的是reply_content字段有值,展示时加一个“景区回复”的样式区分即可。

4. 常见问题排查:登录态、跨域、金额与图片,四处致命坑

4.1 后端拿 wx.login 的 code 直接查用户表,永远查不到

现象:小程序端调用wx.login拿到 code,传给后端。后端拿这个 code 去 user 表 SELECT,结果查不到用户,每次都提示“用户不存在”。

原因:wx.login返回的 code 是临时凭证,5 分钟有效且只能用一次,需要后端拿这个 code 加上小程序自己的 appid 和 secret,去微信服务器换取 openid。openid 才是用户在小程序里的唯一身份标识。code 本身不是用户身份。

解决:后端要有一个接口专门处理登录,逻辑是:

@PostMapping("/login") public Result login(@RequestBody LoginParam param) { // 1. 调用微信接口,用 code 换 openid String openid = wechatService.code2Session(param.getCode()); // 2. 拿 openid 查 user 表,不存在则自动注册 User user = userMapper.selectByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); userMapper.insert(user); } // 3. 生成本系统 token 返回给小程序端 String token = JwtUtil.createToken(user.getId()); return Result.success(token); }

注意 appid 和 secret 不要写在前端代码里,必须放在后端。密文泄露的后果是任何人都能冒充你的小程序调用接口。本地调试时后端还要配置微信开发者工具里“不校验合法域名”,否则请求直接白屏。

4.2 开发工具能通、真机不通,接口超时或 404

现象:微信开发者工具里请求后端一切正常,一上真机预览就请求失败,报request:fail或超时。

原因:真机上小程序会校验请求域名是否在公众平台配置的合法域名列表里,同时要求 HTTPS。开发者工具默认勾选了“不校验合法域名”,所以本地能通。真机没有这个豁免。

解决:开发调试阶段,在开发者工具右上角“详情 → 本地设置”里勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。如果要发布上线,必须在小程序后台配置 request 合法域名,域名要备案且支持 HTTPS。后端接口若是http://localhost:8080,真机无法访问,需要换成局域网 IP 或用内网穿透工具,后者仅限开发阶段。

4.3 购票金额用 FLOAT 库,多条订单金额加起来对不上

现象:一张票 19.9 元,买 3 张理论上 59.7 元,数据库里amount字段存成了 59.6999999。用户和管理员对账时发现差几分钱。

原因:MySQL 的FLOAT和DOUBLE是近似值存储,二进制无法精确表示 0.1。Java 端如果用Float或Double做乘法,同样会引入精度误差。这不是 bug,是浮点数的固有缺陷。

解决:数据库字段用DECIMAL(10,2),Java 端用BigDecimal,前端传参数按“分”传整数。计算金额在后端完成:

BigDecimal price = new BigDecimal("19.90"); BigDecimal total = price.multiply(new BigDecimal(quantity));

别用什么Double.parseDouble再去乘。字符串构造BigDecimal才能精确表达 19.90,new BigDecimal(19.9)反而会得到一个近似值。这个坑踩一次就记住了,从那以后凡是涉及金额的字段,一律String构造 +DECIMAL存储。

4.4 图片上传成功,但小程序页面显示空白

现象:管理员在后台上传景点封面图,提示成功,小程序端cover_image字段也有值,但<image>组件加载不出来。

原因:上传后的图片保存到了后端服务器的本地磁盘路径,比如C:/upload/xxx.jpg,这个路径只有后端服务器自己能访问。小程序端拿这个路径去请求,自然找不到。缺少的是静态资源映射,后端没有把磁盘目录暴露成 HTTP 可访问路径。

解决:SpringMVC 里配置一个资源映射器:

@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceHandler("file:" + "D:/upload/"); } }

前端存储的图片地址必须是完整的 URL,比如https://yourdomain.com/upload/xxx.jpg,而不是D:/upload/xxx.jpg。还有一个更省事的做法:直接把图片传到云存储或对象存储,返回 CDN 地址,本地开发时我用http://localhost:8080/upload/xxx.jpg测试。不管哪种方案,记住一条原则——数据库里存的可访问 URL,一定要能直接在浏览器地址栏打开。

4.5 数据库乱码:接口返回 JSON 正常,控制台打印乱码

现象:后端接口返回的数据在开发者工具 Network 面板看着正常,但 IDEA 控制台打印的中文全是问号,或者 SQL 查询结果里的中文变成???。

原因:三处编码不一致——数据库连接 URL 没指定字符集、数据库表默认字符集不是 utf8mb4、IDEA 控制台编码不对。最常见的是第一条,连接串里少了characterEncoding=utf8。

解决:JDBC 连接串固定带上参数:

jdbc.url=jdbc:mysql://localhost:3306/travel?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai

utf8mb4比utf8多支持 Emoji 字符,留言板里用户发个表情不会变成问号。数据库建库时也显式指定:

CREATE DATABASE travel DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

如果已经建完库,用ALTER TABLE xxx CONVERT TO CHARACTER SET utf8mb4批量修改。最后检查服务端server.xml里的 URIEncoding,Tomcat 8 以上默认 UTF-8,低版本要手动配。

5. 管理员端的功能闭环:分类、审核、订单核销与留言管控

5.1 登录与权限拦截:一个拦截器挡住大部分越权

管理员端跑在浏览器里,本质是一个带登录校验的 Web 管理后台,常见的路径前缀是/admin/。用 SpringMVC 拦截器统一做权限判断,避免每个 Controller 重复写登录检查:

public class AdminAuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Admin admin = (Admin) session.getAttribute("admin"); if (admin == null) { // 未登录,重定向到登录页 response.sendRedirect("/admin/login.html"); return false; } // 可在这里扩展角色判断,如 admin.getRole() 区分超级管理员和普通管理员 return true; } }

注册拦截器时注意排除登录接口本身:

@Configuration public class AdminWebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AdminAuthInterceptor()) .addPathPatterns("/admin/**") .excludePathPatterns("/admin/login", "/admin/logout"); } }

addPathPatterns("/admin/**")的意思是只拦截/admin/开头的路径,用户端小程序接口不受影响。excludePathPatterns排除登录和退出,否则管理员永远无法登录。按钮级权限可以在拦截器里根据admin.getRole()做判断,比如普通管理员不能删除分类,只有超级管理员可以。项目规模小的话,这个粒度够用。

5.2 景点分类与景点内容的级联关系:别急着删分类

后台管理里最典型的操作是“新增景点 → 选择分类 → 上传封面 → 设置价格和库存”,这里有一个数据一致性陷阱:删除某个景点分类时,如果该分类下还有景点,直接 DELETE 会导致景点表出现孤儿记录,用户端按分类查景点时这些景点会消失。

常见做法是软删除或禁用。分类表加一个status字段,删除时 UPDATE 成 0,不物理删除。同时,删除前先统计该分类下景点数量:

@PostMapping("/category/delete") public Result deleteCategory(@RequestParam Integer id) { int count = scenicSpotMapper.countByCategoryId(id); if (count > 0) { return Result.error("该分类下还有景点,不能删除"); } categoryMapper.deleteById(id); return Result.success(); }

更稳妥的方式是把删除改成“禁用”,也就是把scenic_category.status置为 0,这样历史数据完整保留,用户端查询时加AND status = 1过滤即可。我见过不少项目在这个地方图省事直接物理删,后面统计报表数据对不上,再来后悔。记住:涉及层级关系的表,能禁删,不硬删。

景点管理的常用操作还包括上下架,对应scenic_spot.status的 0/1 切换。下架不影响已生成的订单,订单依然可以正常核销,这点要在业务逻辑里明确。

5.3 订单管理状态机:待支付、已支付、已核销、已取消

订单状态是这个系统的核心状态机,先明确四个状态的流转方向,再写代码逻辑:

状态值含义可流转到
0待支付1 已支付 / 3 已取消
1已支付2 已核销 / 3 已取消(仅未到游玩日期)
2已核销终态
3已取消终态

管理员在后台看到已支付订单时,可以执行“核销”操作,也就是验票入园。核销接口的核心逻辑是:

@Transactional(rollbackFor = Exception.class) @PostMapping("/order/verify") public Result verifyOrder(@RequestParam String orderNo) { int rows = orderMapper.updateStatusByOrderNo(orderNo, 1, 2); if (rows == 0) { return Result.error("订单状态异常,无法核销"); } // 可记录核销操作日志 return Result.success(); }

updateStatusByOrderNo(orderNo, 1, 2)对应 SQL 是:

UPDATE ticket_order SET status = 2 WHERE order_no = #{orderNo} AND status = 1

把“当前状态必须是 1”放在 UPDATE 的 WHERE 条件里,防止同一个订单被核销两次。这里的思路和购票扣库存一样,靠条件更新保证原子性,而不是先查再改。订单列表查询时,管理员可以按订单号、用户手机号、景点名称三个维度筛选。手机号需要 JOIN user 表——这一点要在 Service 层完成,Controller 里不要出现这类多表拼接。

5.4 留言板管理与景区活动发布:内容审核不可省

留言板模块看起来简单,管理端操作却有几个容易忽略的点。留言默认带状态字段,比如status 0待审核 1已通过 2已驳回。用户提交后先进入待审核队列,管理员审核通过后前台可见。原文档里没有明说这个流程,但“监控和回复游客留言”是管理端的职责描述,加一个审核状态能防止恶意内容直接展示到前台。

回复留言时直接在message_board表更新reply_content字段,不要新建一条回复记录再关联,否则查询时多一次 JOIN,而且容易查出重复数据。回复后把status改成已通过,用户端就能同时看到留言内容和景区回复。

景区活动发布相对独立,关联scenic_id表示活动归属哪个景点,管理员填写活动标题、内容、开始时间和结束时间。用户端在活动列表页按时间倒序展示,过期活动自动排在后面或直接过滤。日期字段用DATETIME,不要用字符串,否则排序会按字典序出错。

6. 验证这套双端系统的正确姿势:模拟数据优先,真机兜底

拿到这套源码后,别急着改功能,先把数据灌进去跑通一条完整链路。我习惯的做法是先执行一段演示数据 SQL,把分类、景点、活动、留言一次性造好:

INSERT INTO scenic_category (id, name) VALUES (1, '自然风光'), (2, '历史文化'); INSERT INTO scenic_spot (category_id, name, description, price, ticket_stock, status) VALUES (1, '云山风景区', '以日出和云海著称', 60.00, 200, 1), (2, '古镇老街', '保存完整的明清建筑群', 40.00, 150, 1); INSERT INTO scenic_activity (scenic_id, title, content, start_time, end_time) VALUES (1, '云山摄影节', '秋季摄影作品征集活动', '2025-10-01 00:00:00', '2025-10-31 23:59:59');

启动后端后,先用接口测试工具或浏览器直接访问/api/spot/list,确认后端通,再去开发者工具里跑小程序。我踩过的教训是:直接打开小程序发现页面空白,排查半天才发现是后端没启动,白费二十分钟。正确的排查顺序永远是从底层往上——数据库能查 → 后端接口能通 → 小程序能调通 → 真机预览,每一步确认完再进下一步。

微信开发者工具里两个关键配置要提前确认:详情面板勾选“不校验合法域名”;本地请求地址写http://localhost:8080时确保工具是用“调试”模式打开的。真机预览时连同一个局域网,把 baseURL 改成电脑的局域网 IP。验证完一条“用户注册 → 浏览景点 → 购票 → 管理员核销”的完整链路,再开始改代码。从那以后,我每次拿到双端项目都会强制走一遍这条链路再谈加需求,顺序反了只会越调越乱。希望帮到你。

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

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

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

立即咨询