做这类"小程序+Spring Boot"的文旅宣传项目,我前后接触过好几个,河湟文化旅游景点宣传系统算是里面比较典型的一个。河湟地区的历史底蕴和民族文化特色非常鲜明,景点分散、类型多样,面向的游客群体又偏年轻化,用微信小程序做宣传入口,比纯网页或者公众号H5都要顺手得多。这个项目我在实际开发中完整走过一遍,从需求梳理、技术选型、数据库设计到前后端联调、部署上线,踩了不少坑,也沉淀了不少经验。这篇就把整个系统的设计思路、核心实现和调试经验一次讲透,给要做类似文旅小程序的朋友一个可以直接参考的完整方案。
1. 项目设计拆解:河湟旅游宣传为什么选小程序这套组合
1.1 景区宣传的痛点与小程序的优势
河湟地区不是单一景点,而是由多个文化遗址、民俗村落、宗教建筑、自然风光组成的集合型旅游区域。做宣传系统之前,我们和当地的文旅部门做过一轮调研,发现传统宣传方式有几个绕不开的问题:
第一,信息更新慢。纸质手册、官网专题页这些形式,景点开放时间一变、临时闭园或者新增体验项目,很难及时同步,游客到了现场才发现跑空。
第二,展示形式单薄。河湟文化里最有价值的是历史故事、民族手工艺、建筑细节,光靠几张图和几段文字根本讲不透,游客扫码看个简介就走了,留不下深度印象。
第三,互动能力几乎为零。传统宣传是单向信息输出,游客没有渠道收藏感兴趣的景点、提交游览反馈,文旅部门也拿不到用户偏好数据,后续运营没有抓手。
微信小程序恰好能补齐这些短板。它的分发逻辑是"即用即走",用户扫个码、搜个名字就能打开,不需要下载安装,十分贴合游客在景区现场的碎片化使用场景。而且小程序的开放能力很成熟,定位、地图导航、微信支付、订阅消息都能直接调用,省去了很多自建底层功能的成本。在项目里,我们最后确定的技术路线是:前端用原生微信小程序,后端用 Java Spring Boot,数据库用 MySQL,接口通过 RESTful API 对接,管理端做成网页后台。
为什么不用前后端分离的 Vue + Spring Boot 或者直接用 uni-app 跨端?我们讨论过这个问题。原生小程序虽然写起来啰嗦,但胜在稳定,各种 API 调用最直接,不需要处理多端兼容的额外复杂度。河湟宣传系统本身页面数量有限,核心就是展示、搜索、收藏、留言几件事,原生语法完全够用,没必要引入跨端框架增加心智负担。管理端因为操作频率高、信息密度大,用网页实现最合理,小程序里只做游客面向的内容。
1.2 系统功能边界:用户端能做什么,管理端管什么
这个系统不是单纯的景点信息展示,而是围绕游客游览前、游览中、游览后三个环节设计功能闭环。
游客在小程序端能做的事包括:浏览轮播推荐位和景点分类入口;按文化遗址、民俗体验、自然风光等分类查看景点列表;进入景点详情页查看图文介绍、开放时间、门票信息、交通指引和语音讲解;收藏感兴趣的景点,形成自己的"心愿单";在详情页底部提交留言或游览感受;在个人中心看到自己的收藏记录和浏览足迹;通过搜索框快速查找景点或关键词。
管理端对应的是内容维护和运营能力:管理员登录后可以维护景点分类和景点信息,管理轮播图,发布文化活动公告,处理游客留言,查看收藏量、浏览量的基础统计数据。这个后台做得不算特别重,但足够管理人员日常更新内容了,毕竟宣传系统的核心是"内容保鲜",后台好不好用直接影响运营效率。
这里有个设计上的取舍值得说一下:我们没有在小程序里做完整的在线购票和酒店预订功能,只保留了门票信息的展示和咨询入口。原因很简单,河湟区域内不同的景点分属不同运营主体,票务系统各自独立,短时间不可能统一接口。强行做购票功能,反而会让系统背上复杂的订单和支付逻辑,拖慢上线节奏。宣传系统的定位就是把游客"引过来、看得懂、想去看",购票是后续生态的事,不能一步到位。
2. 技术框架与核心架构解析
2.1 后端技术选型与层级设计
后端用 Java Spring Boot,版本我们选了 2.7.x 系列。这个版本兼容性强,社区资源多,遇到问题在网上基本都能查到解决方案,对中小型项目来说是很稳妥的选择。项目结构按经典的三层架构来组织:
src/main/java/com/huanghe/travel/ ├── controller/ // 接口层,接收请求、参数校验 ├── service/ // 业务逻辑层,处理具体业务流程 ├── mapper/ // 数据访问层,MyBatis-Plus 接口 ├── entity/ // 数据库实体类 ├── dto/ // 数据传输对象,避免直接暴露实体 ├── config/ // 配置类,比如跨域、拦截器、WebMvc ├── common/ // 通用类,统一返回结果、异常处理 └── utils/ // 工具类,JWT、日期处理等这种分层的好处是职责单一,controller 只负责接收参数和返回结果,业务判断都收敛在 service 层,后续改需求的时候不会牵一发动全身。比如景点详情的浏览量加一这个操作,在 service 里做就很自然,controller 不用关心。
ORM 选择了 MyBatis-Plus,主要看中它的代码生成和条件构造器。这个项目里单表查询占了绝大多数,MyBatis-Plus 的 LambdaQueryWrapper 写起来非常简洁,比如按分类查景点列表:
List<ScenicSpot> list = scenicSpotMapper.selectList( new LambdaQueryWrapper<ScenicSpot>() .eq(ScenicSpot::getCategoryId, categoryId) .eq(ScenicSpot::getStatus, 1) .orderByAsc(ScenicSpot::getSort) );省去了大量手写 XML 的工作。遇到多表联查的情况,比如景点详情要同时带出图片和标签,我们就在 mapper 里写自定义 SQL,结合 PageHelper 做分页,性能足够用。
JWT 做身份令牌是当前的主流做法。用户在小程序端授权登录后,后端生成 token 返回给前端,小程序把 token 存在 storage 里,后续请求放到 header 中。服务端写一个拦截器统一校验 token,除了登录和首页列表这两个接口放行,其余都要求携带合法 token。这种无状态鉴权方式非常适合这种轻量级系统,不需要在服务端维护 session,也方便后续做接口扩展。
2.2 小程序端架构和目录组织
小程序端没有用任何第三方框架,就是微信官方原生的 WXML、WXSS、JS,配合小程序原生组件和 API。目录结构是这样的:
miniprogram/ ├── pages/ │ ├── index/ // 首页,轮播+分类+推荐 │ ├── spotList/ // 景点列表页 │ ├── spotDetail/ // 景点详情页 │ ├── food/ // 美食推荐页 │ ├── culture/ // 民俗文化活动页 │ ├── mine/ // 个人中心 │ ├── favorite/ // 我的收藏 │ └── search/ // 搜索页 ├── components/ // 自定义组件,如图片轮播 ├── utils/ │ ├── request.js // 封装 wx.request,统一处理 token │ └── config.js // 接口地址配置 ├── app.js ├── app.json └── app.wxssutils/request.js这个小封装很有必要,它统一处理了请求头拼接、token 过期跳转、错误码提示这些逻辑,页面里不用每次重复写 wx.request 的那一坨回调。基础版本大概长这样:
const request = (url, method = 'GET', data = {}) => { return new Promise((resolve, reject) => { const token = wx.getStorageSync('token') wx.request({ url: baseUrl + url, method, data, header: { 'Authorization': token ? `Bearer ${token}` : '' }, success: (res) => { if (res.statusCode === 200) { if (res.data.code === 200) resolve(res.data.data) else if (res.data.code === 401) { wx.removeStorageSync('token') wx.navigateTo({ url: '/pages/login/login' }) reject(res.data.msg) } else { wx.showToast({ title: res.data.msg, icon: 'none' }) reject(res.data.msg) } } else { reject('请求失败') } }, fail: (err) => reject(err) }) }) }首页的轮播图、分类宫格、推荐景点列表都通过页面 onLoad 里并行发请求拉取。这里有个小技巧:首页多个接口同时请求时,可以用Promise.all合并等待,减少白屏等待时间。不过如果项目数据量大了,建议还是单独维护一个聚合接口,减少请求往返。
2.3 数据库设计要点与表结构关系
数据库设计是这个项目里最需要提前想清楚的环节。文旅景点宣传系统的核心实体是景点,围绕景点延伸出分类、图片、评论、收藏这些关联数据。表结构设计如下(去掉了无关字段):
t_admin:管理员表,字段包括 id、username、password(MD5 加密存储)、real_name、last_login_time。
t_user:用户表,字段包括 id、openid(微信唯一标识)、nickname、avatar、phone、create_time。openid 是最关键的字段,小程序每次登录都会拿到不同的 code,后端用 code 换 openid,如果查不到就自动注册。
t_category:景点分类表,字段包括 id、name、icon、sort、status。对应文化遗址、民俗体验、自然风光、美食小吃等分类。
t_spot:景点表,这是整个系统的主表,字段相对多:
id category_id // 关联分类 name // 景点名称 cover_image // 封面图 intro // 简短介绍 content // 富文本详情 address // 详细地址 open_time // 开放时间 ticket_price // 门票价格 traffic_info // 交通指引 audio_url // 语音讲解地址 longitude // 经度(配合地图定位) latitude // 纬度 status // 上下线状态 sort // 排序权重 view_count // 浏览量 create_timet_spot_image:景点图集表,字段包括 id、spot_id、image_url、sort。一个景点对应多张图片,方便详情页图片画廊展示。
t_favorite:收藏表,字段包括 id、user_id、spot_id、create_time。联合唯一索引uk_user_spot防止重复收藏,后续统计收藏量直接 count 这个表。
t_comment:评论留言表,字段包括 id、spot_id、user_id、content、parent_id(支持回复)、create_time、status。
t_banner:轮播图表,字段包括 id、image_url、link_type、link_param、sort、status。运营人员可以随时调整首页轮播,不用改代码。
t_announcement:公告表,字段包括 id、title、content、publish_time、status。
在设计景点表时我特别加了一个audio_url字段。河湟文化中有大量口述史、民间传说、民俗仪式,光用文字展示不够生动,语音讲解能显著提升游客的沉浸感。这个音频文件可以存放在云存储上,数据库只存 URL,这样表结构保持简洁,资源扩展也不受限制。
另外关于经纬度字段,看起来不起眼,但实际价值很高。景区小程序如果能提供地图上的景点分布和定位导航,游客会方便很多。项目里虽然第一版没做地图集成,但这个字段提前留了,后面接地图 SDK 就不用改表。
3. 核心功能与交互细节实现
3.1 微信登录与 Token 鉴权完整流程
微信小程序的登录逻辑是这个系统的入口,也是很多新手容易搞混的地方。正确流程是:
第一步,小程序端调用wx.login获取临时 code。这个 code 五分钟内有效,且只能使用一次,必须立即发给后端。
wx.login({ success: async (res) => { const code = res.code const loginRes = await request('/api/user/login', 'POST', { code }) wx.setStorageSync('token', loginRes.token) } })第二步,后端收到 code 后,调用微信的接口凭证服务,用 code 换取 openid 和 session_key。这里需要 appid 和 appsecret,配置在 application.yml 里。
第三步,根据 openid 查用户表,存在则直接登录生成 token,不存在则自动创建新用户。这里注意,新用户第一次进入小程序时,头像和昵称是拿不到的,需要后续引导用户在小程序内手动授权填写,不能依赖登录时返回的数据。
Token 的生成我用的是 JWT,把 userId 和 openid 放进 token 里,设置 7 天有效期。游客使用小程序的频率不会特别高,有效期太短会导致频繁重新登录,影响体验;太长又有安全风险,7 天是折中值。
有一个容易忽略的细节:接口层统一接收 token 后,拦截器里解析出 userId 存入 ThreadLocal,后续业务逻辑直接使用。这样 controller 和 service 不用反复传 userId 参数,代码干净很多。项目里 ThreadLocal 的清理一定要在 finally 里做,否则 Tomcat 线程复用时会串数据。
3.2 景点列表与详情页的数据联动
景点列表页面对的典型场景是:游客点了一个分类,看到该分类下所有上线状态的景点卡片;往下滑动可以加载更多;点击卡片跳转到详情页。
这里有个交互细节值得强调:小程序页面间传参用 URL 拼接是最简单的方式,但景点 id 这种参数传过去了,详情页再回头重新发起请求获取最新数据,才能保证数据第一时间准确。有些偷懒的写法会把景点标题和封面图片直接通过事件传过去,页面会闪一下又跳回,体验很差,而且如果列表数据更新了,详情页拿到的还是旧数据。我的建议是列表页只传 spotId,所有展示数据都靠详情页自己拉取。
景点详情页是内容展示的核心阵地。为了把河湟文化的内容讲透,详情页的布局分了几个区块:
基本信息区展示名称、地址、开放时间和门票价格。图片画廊区用 swiper 组件轮播景点多角度的实拍图,并在图片底部增加小圆点指示器。介绍内容区展示富文本图文内容,这个富文本从小程序端展示有讲究,服务端返回的内容如果是 HTML,需要用rich-text组件解析,但rich-text不支持部分标签样式,图片自适应宽度、段间距这些都要靠内联样式兜底。音频导览区展示语音讲解按钮,点击播放音频文件,这个功能对体现文化旅游深度帮助极大。
交互按钮区是收藏和留言入口。收藏按钮的状态要和当前用户是否已收藏数据进行联动,页面加载时同时拉取景点详情和收藏状态两个接口。
onLoad(options) { const spotId = options.id this.getSpotDetail(spotId) this.checkIsFavorite(spotId) }其中checkIsFavorite接口返回布尔值,用于给收藏按钮切换样式。收藏动作本身做成防抖,避免用户手滑连续点击导致重复请求。
3.3 收藏与评论模块的业务逻辑
收藏功能虽然简单,但在代码设计上有几个容易出错的地方:
第一,收藏接口要校验景点是否存在且处于上线状态,否则用户收藏了一个已经下架的景点,后续访问详情会得到不友好的空页面。第二,收藏接口建议设计成幂等操作,即用户重复点击收藏,只插入一次,通过唯一索引兜底。第三,取消收藏返回的数据结构要和收藏时保持一致,方便前端统一处理逻辑。
评论功能相对复杂一点。游客可以在详情页提交评论,也可以对别人的评论进行回复。数据库设计上通过parent_id区分根评论和子评论。评论展示的时候,默认查根评论并按时间倒序,每条根评论下面嵌套查它的子评论。
这里有一个性能和体验的平衡问题:如果一次性把某个景点下的所有评论包括子评论全查出来,数据量大了页面会卡;但如果每条根评论都单独查一次子评论,数据库请求次数又多。我们这个项目采用两步方案,第一步查根评论分页,第二步根据这一页根评论的 id 集合用IN查询把所有子评论一次性查出来,然后在内存中组装成树形结构。这样数据库请求次数控制在两次,代码复杂度也可以接受。
评论内容需要做关键词过滤吗?严格来说,作为开放的游客留言功能,还是加一层简单的敏感词过滤更好,我们在后端维护了一个关键词列表,评论提交时做包含判断,命中的词替换成星号,避免审核压力。
3.4 后台管理的核心页面与接口
管理端是一个独立网页,用 Vue + Element UI 快速搭建。它和小程序端共用同一套后端接口,但接口路径上做了区分,小程序端用/api/user/开头,管理端用/api/admin/开头,后端有对应的拦截器做不同的权限校验。
后台最核心的页面是景点管理页:左侧是景点分类树,右侧是对应分类下的景点表格。表格里的操作按钮包括编辑、上架/下架、删除。编辑页面是一个大表单,字段比较多,分块组织:基础信息、图文详情、图片上传、音频上传。图片上传使用云存储的直传方式,前端拿到文件后先传给后端接口换取临时凭证,再直传到对象存储,避免所有文件都经过后端转发,减轻后端带宽压力。
轮播图管理页做了一套简单的可视化排序,通过拖拽调整轮播顺序,排序值实时保存到数据库。这个功能看似简单,但运营人员非常依赖,因为首页效果直接影响游客的第一印象。
公告管理页用于发布文化活动通知、临时闭园公告等。发布后,小程序首页的公告栏就会展示最新的三条。这个模块逻辑不复杂,但要注意公告的时效性,后台列表里要有状态标识,过期的公告自动置为失效,不再展示。
数据统计页展示了基础的数据看板:今日新增用户、总用户数、景点收藏总数、评论总数,以及每个景点的浏览量排行 Top 10。在第一版里只做柱状图和表格展示,不涉及复杂的报表分析,但已经能给运营决策提供有效参考。
4. 运行调试与部署实录
4.1 本地环境搭建与启动流程
本地运行这套系统需要准备的基础环境是:JDK 1.8 或 11、Maven 3.6+、MySQL 5.7 及以上版本、微信开发者工具。整体启动流程我整理成了一份操作顺序,照着做基本不会有问题。
第一步,用前端脚手架生成一个 Spring Boot 项目,Java 版本选 1.8,依赖引入 Spring Web、MyBatis-Plus、MySQL Driver、Lombok、JWT 相关包。Spring Boot 2.7 搭配 MyBatis-Plus 3.5.x 是成熟稳定的组合。
第二步,创建数据库hehuang_travel,字符集用 utf8mb4,因为要存储表情符号和生僻字,河湟地区的民族词汇里部分字在 utf8 下会乱码。执行项目里的 SQL 脚本,自动建表并插入初始数据。
第三步,改application.yml,重点核对数据库账号密码、微信小程序配置的 appid 和 secret。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/hehuang_travel?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 20MB max-request-size: 50MB mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 wx: appid: 你的小程序appid secret: 你的小程序secret第四步,启动后端服务,访问http://localhost:8080/admin/index确认管理端可以正常打开。第五步,用微信开发者工具导入小程序前端项目,在utils/config.js里把 baseUrl 改成http://localhost:8080,前提是开发者工具里开启了"不校验合法域名"选项,否则本地调试时请求会被拦截。
第六步,在小程序端模拟登录,检查控制台有无报错。如果能正常拉取首页数据并完成登录,说明整个链路已经打通。
4.2 常见问题排查与解决方案
开发过程中我踩过的坑不少,挑几个最有代表性的讲。
第一个是跨域问题。小程序真机调试时请求的是 HTTPS 正式域名,而本地开发时请求的是 localhost 或局域网 IP。Spring Boot 后端必须配置适合小程序的跨域策略,否则小程序端会报url not in domain list或者请求被浏览器拦截。小程序真机的合法域名校验一定要在开发期间就设置正确,或者在开发者工具里临时关闭校验。后端跨域配置用 WebMvcConfigurer:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }第二个是图片加载不出来。小程序端的图片对域名有要求,正式上线必须使用 HTTPS,且域名要配置到小程序后台的 downloadFile 合法域名里。如果是本地开发,图片 URL 是带有端口的 http 地址,开发者工具里需要关闭合法域名校验才能显示。我在实际开发里建议把图片统一放到对象存储,开发阶段用测试域名,上线前换正式域名,这样图片切换只改一个配置,不用动代码。
第三个是 MyBatis-Plus 的分页失效。这是因为分页拦截器没有注册。MyBatis-Plus 3.4 之后,分页插件需要手动配置:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }如果没加这个配置,Page查询返回的总数永远是 0,列表页的加载更多功能直接失效。
第四个是微信登录时后端请求微信接口超时。微信接口请求需要用专门的服务端调用,不能让它走正常的业务超时设置。我们在项目中单独维护一个微信 HTTP 客户端,设了单独的连接超时和读超时参数,连不上时给出明确提示,避免阻塞整个登录接口。
第五个是富文本内容在小程序端显示格式错乱。这个问题的根因是rich-text组件对标签支持有限,像<section>、自定义的 class 样式都会被忽略。我们的解决方案是后端在录入内容时严格控制格式,只允许基础标签段落、图片、链接,图片全部转成 base64 或完整 URL,避免相对路径在移动端打不开。
4.3 部署上线的一些经验
项目从开发到上线部署,环境差异带来的问题往往比开发阶段更隐蔽。我们用的是云服务器加域名的方式部署,微信浏览器要求小程序请求的接口域名必须是 HTTPS 且备案过的,这一步前置条件不解决好,后面所有功能都白搭。
部署流程大致是这样:先在服务器上配置好 Java 环境和 MySQL 数据库,导入正式环境的 SQL 脚本。然后把项目打成 jar 包,用java -jar启动。为了管理方便,推荐使用 Systemd 写一个服务文件,这样进程崩溃后能自动重启,日志也有统一的地方看。
域名和证书配置方面,在 Nginx 里做反向代理,把 443 端口的 HTTPS 流量转发到后端服务。HTTP 请求全部 301 重定向到 HTTPS,同时配置好静态资源的缓存策略。小程序端正式环境的 baseUrl 必须是https://你的域名/,注意这个 URL 末尾的斜杠很有讲究,后端如果做了上下文路径或者 Nginx 做了路径转发,斜杠缺失会导致路径拼接错误。
数据库上线前要做一次完整的数据清理,开发期间的测试数据全部清掉,并且给关键表加上合适的索引。比如景点表的 category_id 和 status 字段组合索引,评论表的 spot_id 和 create_time 索引。不加索引的情况下,数据量一上来,详情页的评论加载就会明显变慢。
另外备份策略不能少。我习惯每天凌晨用 crontab 自动备份数据库,保留最近 7 天的备份文件。小程序系统的内容维护频率高,万一运营误删数据或者代码升级出了兼容性问题,备份能救命。
5. 实际操作中的一些体会
这套系统从立项到交付,我最深的体感是:文旅宣传系统看起来功能简单,但"内容运营"的分量远比"功能开发"重。景点、文化活动、民俗故事这些信息是持续变化的,系统把展示位、管理流程、审核机制都搭好了,才有后面的内容保鲜。谁也不想看到一个宣传景区的小程序里,最新一条公告还是半年前的。
开发过程中有一个环节值得反复确认,就是小程序端的审核细节。微信小程序的类目选择和信息录入要提前查清楚,文旅宣传类有没有额外的资质要求,不同类目对应不同的审核规则,如果等开发完再提交审核,发现类目不对,改起来会非常被动。
再说一个容易被低估的功能点:搜索。景区宣传系统的搜索不能只做景点名称精确匹配,用户往往是记不全名字才来搜的。比如用户搜"青稞",他可能想找的是青稞酒坊或青稞文化体验馆;搜"古堡",可能对应的是好几个景点。在搜索接口里加一层关键词模糊匹配加同义词联想,用小表存搜索词库,效果提升很明显。第一版别急着上 ES,数据库 LIKE 加词库扩展,中小规模完全够用。
最后再分享一个小技巧:景点详情的浏览量统计,不要每次都去更新数据库的 count 字段,这样高并发下会有锁竞争。可以在 Redis 里维护一个键值对,每次访问详情就INCR一次,定时任务每十分钟把累计值同步回 MySQL。项目规模小,不用做得特别重,但这样顺手的设计能避免以后流量上来后的隐患。
这个项目做完之后,我最大的收获不是技术上的突破,而是理解了"技术要为内容服务"这句话的含义。小程序只是一种媒介形式,真正吸引游客的是里面呈现的文化深度和游览体验。后面如果继续迭代,我倾向在语音讲解的互动性上做文章,比如加入地图导览点位的自动触发,或者根据游客当前位置推荐附近的景点和特色美食。遇到同样在做文旅宣传项目的朋友,欢迎一起交流实际经验,这套系统里可以扩展的玩法还有不少。