接手"村游网"这个需求的时候,客户那边拿来的描述只有两句话:村里有山有水,但游客来了不知道去哪玩;村民有土特产、有民宿空房,却找不到客人。他们要做一个挂载在微信上的小程序,让外面的人能看见村里有什么。当时我脑子里冒出的第一个判断是:这不是一个"做个App"的需求,而是一个"把散装信息变成可浏览、可预订、可联系"的本地生活项目。后来越做越发现,这类需求在乡镇文旅项目里非常普遍——SSM后端搭配微信小程序,正是这种量级项目里性价比很高的组合方式。这篇开发记录围绕"村游网"微信小程序与SSM后台的完整落地过程展开,涉及需求梳理、数据库设计、接口联调、部署上线和真机排坑,适合正在做乡村旅游、本地生活类小程序的开发者参考。
1. 村游网立项:景区发愁、游客找不到玩法的双重痛点
1.1 用户故事:三个角色的使用场景
先捋需求。对接人把话说得很宏观:"给村里做个网站,挂微信上,能看景点、能订房。"这句话听起来简单,但往细里拆,里面藏着三种完全不同的用户,每一类人的诉求都不一样。
第一类是游客。他们要的其实不是"一个介绍页",而是"今天去这个村能干嘛"。有没有地方停车、吃饭怎么解决、民宿长什么样、能不能提前订下来、到了之后走哪条路线、离开时找谁买点土特产。游客在微信小程序里最怕的是点开一个页面全是静态图文,没有电话、没有价格、没有可操作的入口,看完还得自己去搜别的地方。第二类是运营人员,通常是村干部或者乡镇负责文旅的专员。他们手头没有专职IT,需要有人能快速把景点信息、活动公告、促销内容更新上去,最好简单到培训半小时就会用。第三类是商家,也就是民宿老板、农家乐老板、果园主。他们完全不关心技术,只关心两件事:有没有人来,以及我的房间/桌位/果品有没有被订掉。
这三类人对着同一个系统,诉求天然有冲突。游客要信息透明、越全越好,商家怕麻烦、不想天天维护信息,运营方要数据汇总、要能汇报成果。所以第一版村游网的功能我划成了三大块:信息公开类(景点介绍、村内导览、活动公告)、交易类(民宿预订、农特产展示)、管理类(后台内容发布、订单处理、基础统计)。形态上先按"一号一村"做,一个村子一个小程序,后台统一管理多个村时再加一层区域字段就行。
1.2 功能边界:第一版砍掉什么、保留什么
功能取舍上我划了一条非常清晰的界线:第一版不做社区发帖,不做在线支付,只做"浏览 + 预订 + 电话确认"。这个决定很多人会觉得奇怪,但放在真实的乡村场景里是经过考虑的。
不做在线支付,是因为牵连太多。一旦涉及微信支付,就要申请商户号、配置退款流程、处理账单异常,开发周期至少多两周,还会牵扯到商家提现、手续费谁来承担这类运营问题。乡村场景下很多游客的支付习惯还没有完全转移到线上,更常见的是看完信息后直接打电话预订。所以第一版把"支付"砍掉,订单状态只做"待确认、已确认、已取消"三种,商家在小程序后台看到订单以后,用自己的手机回个电话就能完成确认。这样既降低了开发风险,也让商家觉得"这系统不添乱"。
不做社区发帖,是因为审核成本太高。村民发帖说白了就是发广告、发问路信息、发随手拍,如果没有专人管理,很容易出现过期信息误导游客。与其做出来没人维护,不如在信息发布上收拢权限,只允许运营后台发内容,保证小程序里的每一条信息都是有效的。项目上线后的数据也印证了这个判断:真正产生价值的订单,大部分是游客在小程序上看到信息后主动电话咨询产生的,在线下单是辅助,而"信息触达"本身成了最大价值。
2. 为什么选SSM搭配微信小程序:基于团队现状的选型
2.1 SSM框架在中小型项目里的底气
技术选型是这个项目里值得展开聊的一件事。现在很多人一上来就Spring Boot,看到SSM会问:这都什么年代了还用SSM?但放到真实的中小型项目语境里,SSM并不是"过时",而是"合适"。
理由有三条。第一是维护和上手成本。接手这个系统的可能不是我,而是乡镇上的外包维护人员或者刚培训出来的初级开发者。SSM(Spring + SpringMVC + MyBatis)的层次非常直觉化:Controller接请求、Service写业务、Mapper操作数据库,任何人打开项目都能在三分钟之内说出这个项目是怎么流转的。第二是部署资源占用。很多乡镇项目买的是低配云服务器,SSM跑在Tomcat上,1G内存的机器完全带得动。虽然这点差距在现代云环境里越来越不明显,但对预算有限的小项目来说,能省一点是一点。第三,SSM并不代表"老古董"。代码里照样用注解,MyBatis照样可以用代码生成器,项目照样能打成WAR包扔进Tomcat,开发体验并没有很多人想象中那么糟糕。
小程序端的选择更直接。微信官方生态下,原生小程序语法适合这种页面数量不超过20个的项目,不需要引入额外的跨端编译框架。跨端框架(比如uni-app、Taro)的优势在于"一次编写多端运行",但如果目标很明确就是微信生态,原生开发反而少一层依赖、少一层编译风险,排查问题也更快。所以最终技术栈定为:原生微信小程序 + SSM后端 + MySQL 5.7 + Tomcat,前后端通过JSON接口通信。
2.2 小程序端页面结构与路由设计
小程序端的页面结构我按"Tab页 + 二级页面"来组织。底部Tab有三个:首页、发现、我的。首页聚焦推荐景点和最新活动,用轮播图撑起门面;发现页负责承载所有列表和搜索入口,信息密度高;我的页面展示个人订单和账号信息。页面路径规划如下:
/pages/index/index:首页,轮播、推荐景点、热门民宿/pages/list/list:列表页,景点、民宿、特产三类内容可以切换,支持关键词搜索/pages/detail/detail:详情页,展示景点介绍/民宿房型/特产说明/pages/order/order:下单页,选择房型、日期、填联系人/pages/mine/mine:个人中心/pages/orders/orders:订单列表
tabBar的配置有一个细节容易忽略:选中态图标必须用PNG格式并且大小不能超过40KB,否则上传代码时会报错。另外,tabBar页面不允许使用wx.navigateTo跳转,只能用wx.switchTab,这个限制在开发时容易卡一下。
2.3 接口约定与统一返回格式
前后端接口格式在项目没有中期就定下来,这给联调省了大量时间。统一约定:所有接口返回JSON,结构固定为:
{ "code": 200, "message": "success", "data": {} }code=200表示成功,其他code对应具体错误类型(比如401登录失效、404资源不存在、500服务器异常)。所有列表接口统一携带page、pageSize、total三个参数。小程序端封装统一请求工具:
const request = (url, data = {}, method = 'GET') => { return new Promise((resolve, reject) => { wx.request({ url: baseUrl + url, data, method, header: { 'Content-Type': 'application/json', 'token': wx.getStorageSync('token') }, success(res) { if (res.data.code === 200) { resolve(res.data.data) } else { wx.showToast({ title: res.data.message, icon: 'none' }) reject(res.data) } }, fail: reject }) }) }后端对应的返回结构就是一个简单的泛型类,三个字段:code、message、data。这种约定看起来朴素,但胜在稳定,前端拿到data字段之后直接处理业务逻辑,不需要每次接口都单独做错误解析。
3. 数据建模:景点、民宿、订单核心表怎么连
3.1 核心表结构与字段解读
数据库设计决定了这个系统后续能走多远。核心结论是:把"村"作为一级归属维度,所有业务表都通过village_id和村信息表关联,这样未来加第四个村、第五个村也不会改表结构。
景点表字段设计如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| village_id | bigint | 所属村ID |
| name | varchar(100) | 景点名称 |
| cover_image | varchar(255) | 封面图URL |
| images | text | 详情多图,JSON数组存储 |
| intro | text | 景点介绍 |
| ticket_price | decimal(10,2) | 门票价格,0表示免费 |
| open_time | varchar(50) | 开放时间 |
| visit_duration | int | 建议游玩时长(分钟) |
| status | tinyint | 上架状态,1上架0下架 |
| view_count | int | 浏览量,用于推荐排序 |
| create_time | datetime | 创建时间 |
民宿这块比景点复杂一点。如果只是挂一个"民宿名称+价格",后续做房型管理时表结构就会返工。所以拆成三张表:民宿表、房型表、订单表。民宿表存基础信息(地址、联系电话、封面图、简介),房型表存具体可订的房间(房型名、价格、可住人数、剩余房量),订单表记录用户、民宿、房型、入住日期、离店日期、状态。这样设计的价值在于:以后要支持"周六加价""连住折扣""日历房价",直接在房型表和订单表上扩展字段就行,不需要推翻重来。
特产表在结构上跟景点表类似,多了一个price和unit_price字段(按斤/按盒计价)。但特产在村游网第一版里只做"展示+电话询价",不做购物车和在线结算,所以不需要库存模块。
3.2 搜索与推荐的实现方案
搜索功能看起来不起眼,却是游客使用频率最高的入口之一。第一版我的实现非常朴素:单表LIKE查询,配合一个联合索引(status, name)。
<select id="searchByName" resultType="com.cunyou.entity.Spot"> SELECT * FROM spot WHERE status = 1 AND name LIKE CONCAT('%', #{keyword}, '%') ORDER BY view_count DESC LIMIT #{offset}, #{pageSize} </select>这里有两个细节:一是使用CONCAT('%', #{keyword}, '%'),而不是直接拼接SQL字符串,MyBatis的#{}占位符能避免SQL注入问题;二是排序上直接用了view_count,数据量小的时候不需要额外引入搜索引擎,等超过10万条再考虑全文索引。推荐功能同理,排序规则是:置顶位优先,然后浏览量倒序,最后按创建时间排序。
ORDER BY is_top DESC, view_count DESC, create_time DESC考虑到后续迭代,我给景点表预留了tags字段(JSON数组),存取"亲子""徒步""采摘""夜景"这类标签,前台的筛选就可以按标签走。多一个字段的成本很低,但能给运营方提供很实在的筛选能力。
3.3 图片资源的存取策略
乡村类项目的图片是刚需:民宿实拍图、景点风景图、特产细节图,量大且体积不小。我的做法很简单——不在数据库里存图片二进制,只存URL路径。图片上传后落到服务器的某个目录,生产环境用nginx做静态映射,或者挂到云存储上。
后端接收上传文件时,有一个容易踩的坑:文件名可能包含中文和空格,直接使用会出乱码或者路径解析异常。稳妥做法是用UUID重命名:
String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String dateDir = new SimpleDateFormat("yyyyMMdd").format(new Date()); String realPath = uploadDir + "/" + dateDir; File dir = new File(realPath); if (!dir.exists()) { dir.mkdirs(); } String newFileName = UUID.randomUUID().toString().replace("-", "") + ext;小程序端上传时通过wx.chooseImage选择图片,然后wx.uploadFile发送。这里务必注意wx.uploadFile的name参数要和后端接收参数名完全一致,否则后端拿不到文件。
4. 开发与联调:登录、上传、分页三个核心流程
4.1 微信登录与Token鉴权
微信小程序的登录流程有一套固定链路,但很多第一次做的人容易把"获取code"和"获取用户信息"混在一起。正确的链路是:小程序端wx.login拿到临时code,把code发给后端;后端用code去微信接口换openid和session_key;拿到openid后查用户表,存在就更新最近登录时间,不存在就创建新用户;随后后端生成自定义token返回给小程序端,小程序端存到storage,之后所有请求的header里带上这个token。
后端核心代码:
@PostMapping("/login") public Result login(@RequestBody LoginRequest req) { String code = req.getCode(); String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appid + "&secret=" + secret + "&js_code=" + code + "&grant_type=authorization_code"; // 使用RestTemplate发起请求 // 解析返回中的openid和session_key // 查询用户表,不存在则创建 // 生成token(含随机串+过期时间),存Redis并返回 return Result.success(token); }这里提醒一点:appid和secret绝对不能写死在代码里,也不能以明文形式出现在前端。放在后端配置文件中,部署时通过环境变量注入,避免泄露后被人拿去调用微信接口。
4.2 图片上传与压缩处理经验
上传流程第一版跑通之后,真机上暴露出一个问题:手机直拍的图常常超过5MB,后端虽然能接收,但加载速度非常慢,页面在弱网环境下直接卡死。解决办法是前后端配合优化。
小程序端在wx.chooseImage时指定sizeType: ['compressed'],并且对图片做基础压缩:
wx.chooseImage({ count: 9, sizeType: ['compressed'], sourceType: ['album', 'camera'], success(res) { const filePath = res.tempFilePaths[0] // 用 wx.compressImage 进一步压缩 wx.compressImage({ src: filePath, quality: 80, success(compressResult) { // 上传 compressResult.tempFilePath } }) } })后端收到文件之后,可以考虑再用Thumbnailator做一次缩略图处理。比如封面图强制输出为750x450的尺寸,详情图限制长边不超过1280,这样既控制体积又不明显损失清晰度。乡村旅游场景的图往往是手机直拍,能做到"上传小图+加载快"比"原图高清"更符合实际体验。
4.3 列表分页与触底加载
列表分页的模型统一为"前端传page和pageSize,后端返回当前页数据和总数"。后端DAO层写法:
public Result list(int page, int pageSize) { int offset = (page - 1) * pageSize; List<Spot> list = spotMapper.selectList(offset, pageSize); int total = spotMapper.count(); // 封装为PageResult:{ list, total, page, pageSize } return Result.success(new PageResult(list, total)); }小程序端配合生命周期函数onReachBottom做触底加载:
onReachBottom() { const { currentPage, totalPage, loading } = this.data if (loading || currentPage >= totalPage) return this.setData({ currentPage: currentPage + 1 }) this.loadList() }需要注意的坑:onReachBottom在页面滚动到底部附近时触发,但如果在data里没有维护好loading状态,用户快速上滑时轻轻松松触发十几次请求。我的处理方式是:加载中置一个loading标记,请求结束再解开,同时判断当前页是否已经大于等于总页数,满足条件就直接return。
5. 上真机后踩过的坑:域名、并发、缓存三类问题
5.1 合法域名与开发环境调试
这个坑基本是每个做小程序的人都要经历一遍的:开发者工具里一切正常,点击预览用真机打开,白屏加报错request:fail url not in domain list。原因是真机环境强制校验request和uploadFile的域名,必须走HTTPS,域名还必须在小程序后台配置到合法域名列表里。
处理流程如下:先在服务器上解析一个域名,用nginx配置SSL证书,然后在小程序管理后台的"开发管理-服务器域名"里添加request合法域名和uploadFile合法域名。注意这里有个坑:域名必须备案成功,没有备案的域名在小程序后台是过不了校验的,所以在项目早期就要先确认好域名的备案状态。
调试阶段为了效率,可以在前端代码里区分环境:
// config.js const ENV = 'dev' // 切换为 'prod' module.exports = { baseUrl: ENV === 'dev' ? 'http://localhost:8080' : 'https://yourdomain.com/api' }开发工具里可以勾选"不校验合法域名"来访问本地后端,但上线前一定要切回正式环境的https地址。
5.2 民宿预订的库存扣减问题
村游网虽然量级不大,但民宿预订同样会遇到并发问题:两个游客同时看中同一间房的同一天,如果都下单成功了,那就超卖了。经典的错误做法是"先查再扣"——先查剩余房量,判断大于0执行扣减,中间隔着网络和CPU调度,两条并发请求完全可能同时通过检查,然后把deliver扣成负数。
我的解决办法是改用条件更新,让数据库自己保证原子性:
UPDATE room_type SET deliver = deliver - 1 WHERE id = #{roomTypeId} AND deliver > 0执行这条语句后,如果受影响的行数为0,说明当前房量不足,直接提示用户"房间已满",不做后续的订单插入。如果受影响行数为1,再插入订单记录。这种方式省去了引入Redis分布式锁的复杂度,对这个项目来说够用且可靠。
5.3 真机与开发者工具的缓存差异
另一个容易被忽视的问题是缓存。开发过程中发现,开发者工具里修改代码后刷新能看到最新效果,但真机上打开却一直是旧页面。原因有几层:小程序代码包本身有缓存,后端接口响应如果设置了强缓存头(Cache-Control: max-age),微信也会命中缓存不重新请求。解决办法是给所有查询接口增加响应头Cache-Control: no-cache,同时给关键接口带上版本号参数绕过缓存。
还有一个跟缓存相邻的真实问题:用户在真机上切换账号后,居然能看到上一个账号的订单数据。排查之后发现,是退出登录时只调了wx.navigateBack,没有清storage里的token,导致新账号带着旧token访问接口,后端鉴权拿到旧身份。修改方案是在退出登录时调用:
wx.clearStorageSync()同时在后端订单查询接口里强制校验token对应的用户ID和订单中的userId一致,双保险兜底。
6. 部署与运营:从源码到能跑的网站
6.1 服务器环境搭建与项目发布
部署流程走一遍之后,其实可以发现它不是黑盒。以一台1核2G的云服务器为例,具体步骤如下:
- 安装JDK 8,配置
JAVA_HOME环境变量; - 安装Tomcat 8.5,修改默认端口为8080;
- 安装MySQL 5.7,创建数据库,导入项目的初始化SQL脚本;
- 修改项目的数据库连接配置、上传路径配置;
- 用Maven打包,把WAR包复制到Tomcat的
webapps目录,启动Tomcat; - 安装nginx,配置静态资源映射和HTTPS反向代理;
- 小程序端代码上传到微信平台,填写版本号和备注;
- 在小程序后台配置服务器域名,提交审核。
由于村游网的后端接口统一以/api前缀开头,nginx反向代理配置也非常直观:
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://localhost:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /uploads/ { alias /data/cunyou/uploads/; expires 7d; } }6.2 上线之后的内容运营与迭代方向
系统上线只是开始,接下来真正决定这个项目价值的是内容。我当时替运营方整理了一批种子数据:每个景点配了5张图、一段100字左右的介绍、标注了交通路线和联系电话,民宿和农家乐则一一打电话核实了营业时间和可接待人数。这件事的工程量不亚于开发代码,但它直接决定用户打不打开、留不留下。
后续迭代的优先级排名,我会这样排:第一是订房成功后的微信订阅消息提醒,游客不习惯主动刷新订单状态,推送通知能明显降低取消率;第二是评价系统,游客住完、玩完能给村里留下真实反馈,倒逼商家提高服务质量;第三是地图导览和入村路线规划,对自驾游客来说是刚需;第四是扩展到多村,同一个后台同时管理几个村,小程序端按村切换,让系统的规模效应逐渐显现。
我在实际操作中最大的体会是,这类乡镇文旅项目在技术上并没有多高深,真正的难点在于把"村里有什么"翻译成"游客想看什么"。代码能跑通只是及格线,内容是否对路、电话是否打得通、信息是否更新及时,这些"不性感"的细节决定了小程序能不能在这个村子活下去。如果你正在做类似的项目,建议把至少一半的精力放在跟商家沟通和整理种子内容上,这个投入比任何一行代码都值钱。