
简介一份基于微信小程序的智慧旅游平台开发毕业论文文档适用于计算机相关专业毕业设计参考尤其适合采用SSM框架与MySQL实现前后端分离的选题。文档系统阐述微信小程序端与后台管理端的设计过程涵盖可行性分析、功能设计、数据库设计及SSM框架整合应用管理员侧包含用户管理、景点分类、景点购票、景区活动、留言板等模块用户侧支持景点浏览、在线购票与留言。资源为单个doc文件大小1.66MB无需解压即可直接阅读。目前已有87人浏览学习。借助该文档可快速理解智慧旅游类小程序的完整开发流程、数据库表结构规划及SSM后台搭建思路对撰写毕业论文和完成系统设计均有较高参考价值。1. 智慧旅游小程序不是套壳页面SSM 后端与微信端的联动才是重点拿到“基于微信小程序的智慧旅游平台”这类资源多数人的第一反应是“又是一个后台增删改查”但这份东西真正值钱的地方在链路微信小程序端通过 wx.request 调用后端 HTTP 接口后端用 SSMSpring SpringMVC MyBatis处理业务MySQL 落库管理员再通过浏览器登录后台维护数据。三端串起来才叫一个完整的智慧旅游小程序。适合两类人一是要做课程设计或毕业设计、需要双端可演示的项目二是后端熟了但没调通过小程序接口想补上“从数据库到手机屏幕”这块短板的人。后面所有拆解都围绕这一条链路展开。2. SSM 后端骨架与数据建模先把 8 张核心表拆开看2.1 数据模型从 E-R 图到落库的字段取舍原文档的 E-R 图只画了景点分类、旅游资讯、留言板几个实体实际要落地一个能跑起来的“购票 活动 留言”系统表至少要拆到 8 张。我按常见做法收敛成了下面这套和文档里的功能结构一一对应表名对应功能模块核心字段user用户管理id, username, password, nickname, avatar, phone, create_timeadmin管理员登录id, username, password, rolescenic_category景点分类管理id, name, sort_order, create_timescenic_spot旅游景点管理id, category_id, name, description, cover_image, price, location, opening_hours, ticket_stock, statusticket_order景点购票管理id, order_no, user_id, scenic_id, visit_date, quantity, amount, status, create_timescenic_activity景区活动管理id, scenic_id, title, content, start_time, end_timemessage_board留言板管理id, user_id, content, reply_content, create_timeconfig系统管理id, key, value, remark核心关联就两条scenic_spot.category_id指向scenic_category.idticket_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。下面是一个简化但完整的 ControllerRestController 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不写业务逻辑 PageInfoScenicSpot 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 里重点是动态 SQLselect idqueryPage resultTypecom.example.entity.ScenicSpot SELECT id, name, cover_image, price, location, opening_hours, description FROM scenic_spot where if testcategoryId ! null AND category_id #{categoryId} /if AND status 1 /where ORDER BY id DESC LIMIT #{offset}, #{limit} /selectwhere标签会自动去掉多余的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 iddeductStock 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}条件不满足时影响行数为 0rows 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.requestbaseURL 和 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 idselectOrderWithSpot resultTypemap 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.90new 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 控制台编码不对。最常见的是第一条连接串里少了characterEncodingutf8。解决JDBC 连接串固定带上参数jdbc.urljdbc:mysql://localhost:3306/travel?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/Shanghaiutf8mb4比utf8多支持 Emoji 字符留言板里用户发个表情不会变成问号。数据库建库时也显式指定CREATE DATABASE travel DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;如果已经建完库用ALTER TABLE xxx CONVERT TO CHARACTER SET utf8mb4批量修改。最后检查服务端server.xml里的 URIEncodingTomcat 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。验证完一条“用户注册 → 浏览景点 → 购票 → 管理员核销”的完整链路再开始改代码。从那以后我每次拿到双端项目都会强制走一遍这条链路再谈加需求顺序反了只会越调越乱。希望帮到你。本文还有配套的精品资源点击获取