ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信小程序旅游服务软件:Java后端与数据库设计实战

微信小程序旅游服务软件:Java后端与数据库设计实战

简介:这是一份面向计算机毕业设计或小程序开发学习的微信旅游服务软件完整工程,后端基于Java与SSM框架开发,前端为微信小程序,支持直接导入IDEA和微信开发者工具运行,无需额外改配置。项目围绕景区旅游业务展开,涵盖旅游攻略、旅游资讯、景点与酒店信息检索、门票预订、酒店预订、论坛中心、推荐路线、我要发帖以及用户管理等模块,把游客浏览、搜索、预订、互动与后台管理串成完整闭环,适合作为课程设计、毕业设计或SSM+小程序全栈实战的参考范本。整个资源包共22个文件,包含19张png界面运行截图、2个zip工程压缩包和1个sql数据库初始化脚本,整体大小约61.15MB;截图便于核对页面效果,两个压缩包分别对应后端工程与小程序前端,SQL脚本用于初始化MySQL 8.0数据库,能够明显缩短环境准备时间。已有2604人学习下载,读者可对照源码理解旅游类小程序的业务建模、前后端接口联调与后台管理设计,也能在现有基础上扩展推荐算法、支付接口等功能,是一份可运行、可改造的落地项目资料。

1. 基于微信小程序旅游服务软件:省心的是源码和数据库文件都到位

做过小程序的人都知道,最磨人的不是写页面,而是后端接口和数据库对不上。这套「微信小程序旅游服务软件」的定位很明确:前端小程序负责展示、搜索、下单,后端用 Java 全家桶提供接口,数据库文件直接导入就能跑,整个旅游业务闭环——景点列表、关键词搜索、景区详情、路线推荐、订单管理——全部串起来。Java 后端在这个场景里是最稳的选择,生态成熟、招人好招、部署资料多,跟小程序做前后端分离也顺。适合两类人:一是拿它做毕业设计或课程项目的学生,二是想快速搭一个旅游类小程序原型、验证业务逻辑的开发者。下面我从架构到落地,把每一步讲清楚。

2. 从页面到后端:旅游小程序的整体链路设计与技术选型

2.1 小程序端的页面结构与数据流

先看前端。旅游服务小程序通常由五个核心页面组成:首页、景点列表页、景区详情页、个人中心页、订单列表页。业务闭环是用户进入首页看到推荐景点,点进详情页了解信息,决定后下单,订单状态在个人中心里追踪。页面之间通过wx.navigateTo跳转,数据则统一走wx.request从后端拉。

每个页面的结构都遵循微信小程序的固定模式:.wxml负责结构,.wxss负责样式,.js负责逻辑和数据处理。以景点列表页为例,核心逻辑是onLoad生命周期里根据关键词请求后端接口,拿到 JSON 数据后用setData绑定到页面,视图层自动渲染。下面这段代码是列表页最常见的请求写法:

Page({ data: { spotList: [], keyword: '', loading: false, page: 1 }, onLoad(options) { // options 里可能携带从首页跳转过来的关键词 this.setData({ keyword: options.keyword || '' }); this.fetchSpots(); }, fetchSpots() { if (this.data.loading) return; this.setData({ loading: true }); wx.request({ url: 'https://yourdomain.com/api/spot/search', method: 'GET', data: { keyword: this.data.keyword, page: this.data.page, size: 10 }, success: (res) => { // 约定 code 为 0 表示业务成功,后面章节会讲统一返回结构 if (res.data.code === 0) { const list = res.data.data || []; this.setData({ spotList: this.data.page === 1 ? list : this.data.spotList.concat(list) }); } }, fail: (err) => { console.error('请求失败', err); wx.showToast({ title: '网络异常', icon: 'none' }); }, complete: () => { this.setData({ loading: false }); } }); } });

几个参数要说明。keyword是搜索关键词,page和size控制分页——旅游类列表通常数据量大,一次性拉全量数据会让首屏白屏好几秒,分页是必须的。loading标志位防止用户快速滚动时重复触发请求,这在 onReachBottom 触底加载时尤其关键。值得注意的一个坑是success回调里的res.data.code,这里的code是我们后端自定义的业务状态码,跟 HTTP 状态码是两回事。如果后端返回 HTTP 200 但业务失败,前端要单独判断code而不是只看statusCode。

2.2 Java 后端框架选型:Spring Boot 为什么是默认答案

后端 Java 生态里,现在做小程序接口,Spring Boot 几乎就是默认选择,不需要犹豫。原因有三:第一,内置 Tomcat,打包成 jar 直接java -jar就能跑,部署成本极低;第二,Spring MVC 的注解开发方式天然适合 RESTful 接口,一个@RestController就能暴露 HTTP 接口,不用像传统 SSM 那样写大量 XML 配置;第三,生态完整,MyBatis、MySQL、Redis、JWT 这些小程序后端常用的组件都有成熟的 starter 集成。

相比早期的 Servlet + JSP 方案,Spring Boot 把“接口开发”这件事简化到了极致。你不需要关心 DispatcherServlet 怎么配置,不需要写 web.xml,甚至不需要手动管理 Bean 生命周期。对于旅游服务这种以 CRUD 为主的业务系统,Spring Boot 的默认配置覆盖了绝大多数场景,框架帮你省下的时间可以全部投入到业务逻辑上。社区资料也多,真遇到问题,搜解决方案的效率远高于冷门框架。

2.3 前后端接口约定:RESTful 设计与统一返回结构

前后端联调最容易出问题的不是接口功能,而是返回格式不统一。有的接口返回{status: 1},有的返回{code: "success"},前端每接一个接口就要写一套解析逻辑,改起来想打人。所以做小程序后端,第一件事就是定统一返回结构。我通常定义一个泛型类Result<T>,所有接口强制返回这个类型:

@Data public class Result<T> { private int code; // 业务状态码,0 表示成功,非 0 表示失败 private String message; // 提示信息 private T data; // 真正的业务数据 public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 0; result.message = "ok"; result.data = data; return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.code = 1; result.message = message; return result; } }

接口路径按照资源名来组织。景点资源是/api/spot,订单资源是/api/order,用户是/api/user,配合 HTTP 方法表达操作语义:GET /api/spot/list获取景点列表,GET /api/spot/detail?id=1获取详情,POST /api/order/create创建订单。这里有个设计细节:有的团队喜欢把所有接口都写成POST,省得考虑参数传递,但 GET/POST 语义分开在排查问题时更直观——GET 请求可以直接在浏览器里打开调试,POST 请求还得借助工具。旅游服务这类读多写少的系统,GET 接口占了多数,语义分开的收益很明显。

3. 后端 Java 核心模块实现:景点搜索、微信登录与订单状态控制

3.1 景点搜索模块:Controller-Service-Mapper 三层的标准写法

后端代码的组织方式直接决定了后续可维护性。旅游系统里最简单的景点查询,也值得按三层结构来写。Controller 层只负责参数接收和结果返回,不写任何业务逻辑;Service 层处理业务规则,比如关键词为空时返回默认推荐列表;Mapper 层跟数据库打交道,SQL 写在 XML 里跟 Java 代码分离,方便后续调优。

先看 Controller:

@RestController @RequestMapping("/api/spot") public class SpotController { @Autowired private SpotService spotService; @GetMapping("/search") public Result<PageResult<SpotVO>> search(@RequestParam(required = false) String keyword, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) { // 关键词为空时走默认推荐逻辑 if (keyword == null || keyword.trim().isEmpty()) { return Result.success(spotService.recommend(page, size)); } return Result.success(spotService.searchByName(keyword, page, size)); } }

再看 Service 层。这里分页和关键词过滤放在一起处理,避免LIKE拼接的 SQL 注入风险:

@Service public class SpotService { @Autowired private SpotMapper spotMapper; public PageResult<SpotVO> searchByName(String keyword, int page, int size) { // 计算 offset,MySQL 分页使用的是 limit offset, size int offset = (page - 1) * size; List<SpotVO> records = spotMapper.selectByName(keyword.trim(), offset, size); Long total = spotMapper.countByName(keyword.trim()); PageResult<SpotVO> pageResult = new PageResult<>(); pageResult.setRecords(records); pageResult.setTotal(total); pageResult.setPage(page); pageResult.setSize(size); return pageResult; } }

最后 Mapper 接口和 XML:

public interface SpotMapper { List<SpotVO> selectByName(@Param("keyword") String keyword, @Param("offset") int offset, @Param("size") int size); Long countByName(@Param("keyword") String keyword); }
<select id="selectByName" resultType="com.example.vo.SpotVO"> SELECT id, name, city, summary, cover_image, price, hot FROM t_spot WHERE name LIKE CONCAT('%', #{keyword}, '%') ORDER BY hot DESC LIMIT #{offset}, #{size} </select> <select id="countByName" resultType="long"> SELECT COUNT(*) FROM t_spot WHERE name LIKE CONCAT('%', #{keyword}, '%') </select>

参数说明:LIMIT #{offset}, #{size}是 MySQL 的分页语法,offset从 0 开始,所以前端传的page=1在 Service 层要转成offset=0。分页必须搭配COUNT(*)查询返回总条数,否则前端无法计算总页数,也就没法知道什么时候触发触底加载。这里有个小细节:selectByName和countByName两条 SQL 的WHERE条件必须保持完全一致,否则会出现总条数和列表对不上的问题,这是分页最容易翻车的点。

3.2 微信登录链路:code 换 session 到生成 Token 的完整流程

旅游系统涉及下单和订单查询,必须有用户身份。微信小程序的标准登录方式是wx.login()获取临时code,后端拿这个code调用微信接口换取openid,再用openid作为用户的唯一标识。这套流程不复杂,但有一个容易被忽略的坑:code只能用一次,五分钟过期,所以后端拿到code后要立刻调用微信接口,不能落库也不能重放。

Controller 层处理登录请求:

@RestController @RequestMapping("/api/user") public class UserController { @Autowired private UserService userService; @PostMapping("/login") public Result<LoginVO> login(@RequestBody LoginDTO loginDTO) { // loginDTO 里只有两个字段:code 和 nickname String code = loginDTO.getCode(); if (code == null || code.isEmpty()) { return Result.error("code不能为空"); } return Result.success(userService.login(code, loginDTO.getNickname())); } }

Service 层的核心是调微信接口解析openid。这里用 Spring 的RestTemplate发起 GET 请求:

@Service public class UserService { private static final String WX_URL = "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code"; @Value("${wx.appid}") private String appid; @Value("${wx.secret}") private String secret; @Autowired private UserMapper userMapper; public LoginVO login(String code, String nickname) { // 1. 用 code 换 openid 和 session_key String url = String.format(WX_URL, appid, secret, code); String responseText = new RestTemplate().getForObject(url, String.class); // 2. 解析 JSON 响应 JSONObject json = JSON.parseObject(responseText); String openid = json.getString("openid"); if (openid == null) { // 这里一定是 code 无效或过期,微信会返回 errcode 和 errmsg throw new BusinessException("微信登录失败: " + responseText); } // 3. 查数据库,不存在则新注册 User user = userMapper.selectByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); user.setNickname(nickname); userMapper.insert(user); } // 4. 生成 Token,后续请求带着 Token 识别用户 String token = JwtUtil.generateToken(user.getId(), openid); LoginVO vo = new LoginVO(); vo.setToken(token); vo.setUserId(user.getId()); vo.setNickname(user.getNickname()); return vo; } public User getUserByToken(String token) { Long userId = JwtUtil.parseToken(token); return userId == null ? null : userMapper.selectById(userId); } }

这个流程里有一个安全细节:微信接口返回的openid是用户的唯一身份标识,但session_key不要返回给前端,更不能存到自己数据库里。session_key是解密用户手机号等敏感信息的密钥,暴露给前端等于把钥匙交出去了。JWT 的generateToken我通常把userId和openid放进去,过期时间设 7 天,旅游类应用用户登录频率不高,太短会让用户体验变差。

3.3 订单模块:状态机设计与事务控制

旅游订单跟电商订单不一样,它的状态流转相对简单,但状态枚举必须设计清楚。常见的是四态:待支付(0)→ 已支付(1)→ 已完成(2),以及从待支付可以走到已取消(3)。这个状态机不允许越级跳转,比如已取消的订单不能再变成已支付,已完成的订单不能再取消。实现方式就是 Service 层每个方法开头检查当前状态,不合法就抛出异常。

订单创建涉及多个表的写操作,必须加事务。比如购买一条旅游路线,要插入订单表、扣减路线库存、记录订单日志,任何一个步骤失败都要整体回滚,否则就会出现订单存在但库存没扣的脏数据:

@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private RouteMapper routeMapper; @Transactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderDTO dto) { // 1. 校验用户是否存在(从 Token 解析出的 userId) // 2. 校验路线是否存在且可预订 Route route = routeMapper.selectById(dto.getRouteId()); if (route == null || route.getStock() <= 0) { throw new BusinessException("该路线暂无余位"); } // 3. 生成订单号:时间戳 + 用户ID + 随机数,避免并发冲突 String orderNo = "T" + System.currentTimeMillis() + dto.getUserId() + (int) (Math.random() * 9000 + 1000); // 4. 插入订单记录,初始状态为待支付 0 Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(dto.getUserId()); order.setRouteId(dto.getRouteId()); order.setStatus(0); order.setTotalPrice(route.getPrice()); orderMapper.insert(order); // 5. 扣减库存 int updated = routeMapper.decreaseStock(dto.getRouteId()); if (updated == 0) { throw new BusinessException("库存扣减失败,请重试"); } // 6. 返回订单信息给前端,前端引导用户去支付 OrderVO vo = new OrderVO(); vo.setOrderNo(orderNo); vo.setTotalPrice(route.getPrice()); vo.setStatus(0); return vo; } }

事务注解@Transactional(rollbackFor = Exception.class)里的rollbackFor很重要——Spring 默认只在遇到 RuntimeException 时回滚,如果业务异常是自定义的BusinessException,要确保它继承 RuntimeException,否则事务不回滚。库存扣减用UPDATE ... SET stock = stock - 1 WHERE id = ? AND stock > 0,通过受影响行数是否为 0 判断是否超卖,这是避免并发下库存变负数的最简单有效的办法。

4. 数据库文件设计:核心表结构、初始化 SQL 与索引优化

4.1 旅游业务的核心表设计与字段说明

数据库是整个系统最不能糊弄的部分。旅游服务软件的数据库文件,我建议至少包含五张核心表:用户表、景点表、路线表、订单表、收藏表。表名用t_前缀区分业务表,字段用全小写下划线风格,MySQL 里大小写敏感配置不一致时这种命名最安全。

表名职责关键字段
t_user微信用户信息openid(唯一索引)、nickname、avatar、phone、create_time
t_spot景点/景区信息name、city、summary、detail、cover_image、price、hot
t_route旅游路线title、spot_ids(逗号分隔景点ID)、days、price、stock
t_order订单记录order_no(唯一索引)、user_id、route_id、status、total_price
t_favorite用户收藏user_id、spot_id、create_time,联合唯一索引

字段类型有几个细节值得注意。openid用 VARCHAR(64) 就行,微信返回的 openid 通常是 28 位,但留点余量。price用 DECIMAL(10,2) 而不是 FLOAT——金额用浮点数在累加和比较时会出现 0.1 + 0.2 != 0.3 的经典问题。status用 TINYINT 就够,配合 Java 侧的状态枚举使用,不要直接用中文存状态,查起来慢不说,改状态名还要刷数据。create_time默认值设CURRENT_TIMESTAMP,这样插入语句不写时间字段也能自动填充。

4.2 直接能导入的初始化 SQL 脚本

项目交付的数据库文件,形态是.sql脚本。拿到手在 Navicat 或者命令行里执行一遍,库、表、基础数据就全有了。下面是最小可用的建库建表脚本:

-- 创建数据库,utf8mb4 才能完整保存 emoji 表情 CREATE DATABASE IF NOT EXISTS travel_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE travel_db; -- 用户表 CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) NOT NULL, nickname VARCHAR(64) DEFAULT '', avatar VARCHAR(255) DEFAULT '', phone VARCHAR(20) DEFAULT '', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_openid (openid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 景点表 CREATE TABLE t_spot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(128) NOT NULL, city VARCHAR(64) DEFAULT '', summary VARCHAR(255) DEFAULT '', detail TEXT, cover_image VARCHAR(255) DEFAULT '', price DECIMAL(10,2) DEFAULT 0.00, hot INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_city (city), KEY idx_hot (hot) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 路线表,spot_ids 用逗号分隔关联景点ID,简单场景下避免建关联表 CREATE TABLE t_route ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL, spot_ids VARCHAR(255) NOT NULL, days INT DEFAULT 1, price DECIMAL(10,2) DEFAULT 0.00, stock INT DEFAULT 100, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表 CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, route_id BIGINT NOT NULL, status TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2已完成 3已取消', total_price DECIMAL(10,2) DEFAULT 0.00, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_status (user_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

插入一些景点演示数据,方便小程序端直接展示:

INSERT INTO t_spot (name, city, summary, price, hot) VALUES ('龙脊梯田', '桂林', '梯田层层叠叠,四季风景各不相同', 80.00, 120), ('鼓浪屿', '厦门', '海岛风情,老建筑与海滩的完美结合', 100.00, 156), ('黄山风景区', '黄山', '奇松、怪石、云海、温泉四绝', 190.00, 230), ('西湖景区', '杭州', '三面云山一面城,四季玩法不同', 0.00, 300);

执行 SQL 时要注意:如果之前已经导过一遍,再导会报Table already exists,先DROP TABLE IF EXISTS还是直接忽略,取决于你想保留哪份数据。另外TEXT类型的detail字段不适合建索引,MySQL 对 TEXT 字段做索引有长度限制,常规做法是只给name和city加索引,详情内容靠全文搜索引擎(比如 Elasticsearch)解决,小项目拿 LIKE 凑合就行。

4.3 索引设计:搜索场景最容易踩的慢查询坑

给表加索引不是越多越好,而是要看查询模式。旅游系统最常见的查询是用户按城市筛选景点和搜索景点名称。对应到 SQL,就是WHERE city = ?和WHERE name LIKE '%keyword%'。前者用普通索引就能加速,后者却有天然缺陷。

LIKE '%keyword%'这种前后都带百分号的写法,MySQL 无法使用 B+ Tree 索引加速,只能全表扫描。数据量在几百条时感觉不出来,到了几万条就能看出区别。解决思路有几个:一是改成LIKE 'keyword%'右模糊匹配,前面不带百分号,索引能用,但语义受限;二是给name字段加前缀索引,比如ALTER TABLE t_spot ADD INDEX idx_name (name(10)),只对前 10 个字符建索引,能在一定程度上加速;三是系统做大后引入全文索引或者搜索引擎,但这超出了项目源码的范畴。

我的建议是:课程设计和小型项目阶段,保留idx_city和idx_hot两个索引,搜索功能用右模糊或者干脆先全表扫,等用户量上来再优化。过早引入搜索引擎对初学者反而是负担,MySQL 本身能承载的量级在旅游类原型阶段完全够用。

5. 微信小程序与 Java 后端联调的避坑指南:真机白屏、Token 过期与 8 小时时差

5.1 开发工具正常但真机白屏:域名和 HTTPS 的配置问题

现象:在微信开发者工具里一切正常,数据加载流畅;一用手机预览,页面要么白屏要么一直转圈,控制台报request:fail。

原因:开发者工具默认勾选了“不校验合法域名”,所有请求直接放行。真机上微信强制要求请求地址必须是 HTTPS 且域名已备案,并且要在小程序后台的「开发设置 - 服务器域名」里配置。如果直接拿http://192.168.1.100:8080这种局域网地址去真机调试,必挂。

解决:本地调试阶段不必立刻申请域名,开发者工具里勾选不校验合法域名就行。但真要上线或者给演示用,两条路:一是买域名和云服务器,配 Nginx 做 HTTPS 反代,后端 Java 服务监听内网端口;二是用内网穿透工具把本地 8080 端口映射成一个 HTTPS 域名,适合临时演示。注意,小程序后台配置的域名不能带端口号,https://api.example.com可以,https://api.example.com:8443不行。这是个隐蔽的坑,很多人配了半天发现是端口惹的祸。

5.2 Token 过期失效:小程序端 401 自动重登

现象:用户一段时间没操作,再点某个功能时报“登录已过期”,必须重新进入小程序才能用。开发时为了省事,不少人的做法是让用户退出重进,这体验很拉胯。

原因:Token 过期后,后端接口返回业务码非 0(比如 401),小程序端每个请求都做一次拦截判断,才能统一处理重新登录。

解决:封装wx.request,统一处理响应。拦截到 Token 失效时,静默调用wx.login()重新走一遍登录流程,拿到新 Token 后把原来的请求重放一遍。这样用户无感知,不用手动重进:

function requestWithAuth(options) { const token = wx.getStorageSync('token'); return new Promise((resolve, reject) => { wx.request({ ...options, url: options.url, header: { 'Content-Type': 'application/json', 'Authorization': token ? 'Bearer ' + token : '' }, success: (res) => { // 判断业务码,Token 失效时 res.data.code 定义成 401 if (res.data.code === 401) { // 静默重新登录,成功后重放当前请求 wx.login({ success: (loginRes) => { wx.request({ url: 'https://yourdomain.com/api/user/login', method: 'POST', data: { code: loginRes.code }, success: (loginRel) => { wx.setStorageSync('token', loginRel.data.data.token); // 重放原请求 wx.request({ ...options, header: { 'Authorization': 'Bearer ' + loginRel.data.data.token }, success: (retryRes) => resolve(retryRes.data), fail: reject }); } }); } }); return; } resolve(res.data); }, fail: reject }); }); }

这段封装代码的逻辑是:所有请求统一走requestWithAuth,携带 Token 发出去;收到 401 业务码时说明 Token 失效,先静默重新登录拿新 Token,再重放原请求。有一个细节要注意:重放逻辑里必须判断code而不是 HTTPstatusCode,因为我们的接口即使业务失败也返回 HTTP 200,只有 Token 失效这个业务场景返回code=401。

5.3 数据库时间与本地时间差了 8 小时

现象:数据库里存的create_time是凌晨 2 点,页面上显示的是前一天 18 点。或者反过来,页面上传的时间到库里多了 8 小时。

原因:MySQL 连接串里没有指定时区,服务器时间用的是 UTC,而本地是中国标准时间 UTC+8。Java 后端读取 DATETIME 时按服务器默认时区解释,存入时又按另一个时区转换,来回一折腾就差了 8 小时。

解决:JDBC 连接串显式指定时区,这是最省事的方案:

spring: datasource: url: jdbc:mysql://localhost:3306/travel_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

serverTimezone=Asia/Shanghai这个参数必须在连接串里出现。此外,MySQL 的DATETIME类型本身不存时区信息,全靠连接的时区做解释,所以统一时区是唯一的解法。Java 应用服务器的启动参数最好也加上-Duser.timezone=Asia/Shanghai,双保险。排查这类问题,一条命令就能定位:在 MySQL 里执行SELECT NOW();,如果返回的是 UTC 时间,那就是连接时区配错了。

5.4 图片加载失败和冷启动白屏:资源路径与缓存策略

现象:景点列表能正常显示文字,但图片全部裂掉;或者小程序首次打开时白屏时间特别长。

原因:图片路径写成了相对路径或者本地路径,比如cover_image存的是/upload/xxx.jpg。小程序端上,相对路径会拼到小程序的代码包路径下,而不是你的服务器路径上,图片自然加载不出来。冷启动白屏则是首屏数据加载慢,请求串行导致。

解决:cover_image字段必须存完整的 URL,比如https://yourdomain.com/images/spot/xxx.jpg。后端返回数据时如果发现字段是相对路径,要么改数据库数据,要么在接口层拼接域名前缀。冷启动优化方面,首页的景点列表接口可以跟wx.login并行发起,不要串行等登录完成才拉数据。另外一个常见技巧是:优先加载文字和骨架屏,图片用懒加载,image组件的lazy-load属性设为true,列表滚动到可视区域才去拉图,对弱网环境提升非常明显。

6. 部署验证的最后一公里:把整套系统跑通并确认每个环节都合格

拿到项目源码和数据库文件后,第一步别急着改代码,先把原封不动的系统跑起来,确认基线是正常的。我有一次直接改了数据库字段,结果自己把自己搞晕,分不清是原项目 bug 还是我改出来的问题。正确的顺序是走完下面四步再动任何代码。

第一步,导入数据库文件。用 Navicat 连接 MySQL,执行travel_db.sql,确认表数量和数据条数跟 README 描述一致。第二步,改后端配置。打开application.yml,改数据库账号密码,再改wx.appid和wx.secret,没有真实小程序账号时可以先在微信公众平台注册一个测试号,AppID 和密钥在开发设置里拿。第三步,启动 Java 服务。项目根目录执行mvn spring-boot:run或用 IDE 直接跑启动类,看到 Tomcat started 日志后,浏览器访问http://localhost:8080/api/spot/list,能返回 JSON 数组说明后端通了。第四步,微信开发者工具导入小程序端代码,改utils/config.js里的接口地址为本机http://localhost:8080,勾选不校验合法域名,真机预览走通登录、搜索、详情、下单全流程。

这几步跑通了,整个项目的核心链路就验证完了,剩下的就是业务层面的调整。我的习惯是把“登录 → 搜索景点 → 查看详情 → 下单 → 查看订单”这五步操作在手机上录个屏,跑一遍就知道前端页面和后端接口有没有割裂。这里还想强调一个跟代码无关但很实际的事:这种带数据库文件的项目,数据库脚本是整个交付物的灵魂。别人拿到手能不能一键跑起来,全看建表语句和执行顺序是否干净。把编码统一成utf8mb4、字段注释写全、索引命名规范,这些细节看着不起眼,但真正维护过的人才知道它们多值钱。希望这篇笔记帮到你,少走几步已经踩过的弯路。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进