ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

SSM+微信小程序打造乡村旅游预订平台:村游网开发实战

SSM+微信小程序打造乡村旅游预订平台:村游网开发实战 接手村游网这个需求的时候客户那边拿来的描述只有两句话村里有山有水但游客来了不知道去哪玩村民有土特产、有民宿空房却找不到客人。他们要做一个挂载在微信上的小程序让外面的人能看见村里有什么。当时我脑子里冒出的第一个判断是这不是一个做个App的需求而是一个把散装信息变成可浏览、可预订、可联系的本地生活项目。后来越做越发现这类需求在乡镇文旅项目里非常普遍——SSM后端搭配微信小程序正是这种量级项目里性价比很高的组合方式。这篇开发记录围绕村游网微信小程序与SSM后台的完整落地过程展开涉及需求梳理、数据库设计、接口联调、部署上线和真机排坑适合正在做乡村旅游、本地生活类小程序的开发者参考。1. 村游网立项景区发愁、游客找不到玩法的双重痛点1.1 用户故事三个角色的使用场景先捋需求。对接人把话说得很宏观给村里做个网站挂微信上能看景点、能订房。这句话听起来简单但往细里拆里面藏着三种完全不同的用户每一类人的诉求都不一样。第一类是游客。他们要的其实不是一个介绍页而是今天去这个村能干嘛。有没有地方停车、吃饭怎么解决、民宿长什么样、能不能提前订下来、到了之后走哪条路线、离开时找谁买点土特产。游客在微信小程序里最怕的是点开一个页面全是静态图文没有电话、没有价格、没有可操作的入口看完还得自己去搜别的地方。第二类是运营人员通常是村干部或者乡镇负责文旅的专员。他们手头没有专职IT需要有人能快速把景点信息、活动公告、促销内容更新上去最好简单到培训半小时就会用。第三类是商家也就是民宿老板、农家乐老板、果园主。他们完全不关心技术只关心两件事有没有人来以及我的房间/桌位/果品有没有被订掉。这三类人对着同一个系统诉求天然有冲突。游客要信息透明、越全越好商家怕麻烦、不想天天维护信息运营方要数据汇总、要能汇报成果。所以第一版村游网的功能我划成了三大块信息公开类景点介绍、村内导览、活动公告、交易类民宿预订、农特产展示、管理类后台内容发布、订单处理、基础统计。形态上先按一号一村做一个村子一个小程序后台统一管理多个村时再加一层区域字段就行。1.2 功能边界第一版砍掉什么、保留什么功能取舍上我划了一条非常清晰的界线第一版不做社区发帖不做在线支付只做浏览 预订 电话确认。这个决定很多人会觉得奇怪但放在真实的乡村场景里是经过考虑的。不做在线支付是因为牵连太多。一旦涉及微信支付就要申请商户号、配置退款流程、处理账单异常开发周期至少多两周还会牵扯到商家提现、手续费谁来承担这类运营问题。乡村场景下很多游客的支付习惯还没有完全转移到线上更常见的是看完信息后直接打电话预订。所以第一版把支付砍掉订单状态只做待确认、已确认、已取消三种商家在小程序后台看到订单以后用自己的手机回个电话就能完成确认。这样既降低了开发风险也让商家觉得这系统不添乱。不做社区发帖是因为审核成本太高。村民发帖说白了就是发广告、发问路信息、发随手拍如果没有专人管理很容易出现过期信息误导游客。与其做出来没人维护不如在信息发布上收拢权限只允许运营后台发内容保证小程序里的每一条信息都是有效的。项目上线后的数据也印证了这个判断真正产生价值的订单大部分是游客在小程序上看到信息后主动电话咨询产生的在线下单是辅助而信息触达本身成了最大价值。2. 为什么选SSM搭配微信小程序基于团队现状的选型2.1 SSM框架在中小型项目里的底气技术选型是这个项目里值得展开聊的一件事。现在很多人一上来就Spring Boot看到SSM会问这都什么年代了还用SSM但放到真实的中小型项目语境里SSM并不是过时而是合适。理由有三条。第一是维护和上手成本。接手这个系统的可能不是我而是乡镇上的外包维护人员或者刚培训出来的初级开发者。SSMSpring 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: {} }code200表示成功其他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和村信息表关联这样未来加第四个村、第五个村也不会改表结构。景点表字段设计如下字段类型说明idbigint主键village_idbigint所属村IDnamevarchar(100)景点名称cover_imagevarchar(255)封面图URLimagestext详情多图JSON数组存储introtext景点介绍ticket_pricedecimal(10,2)门票价格0表示免费open_timevarchar(50)开放时间visit_durationint建议游玩时长分钟statustinyint上架状态1上架0下架view_countint浏览量用于推荐排序create_timedatetime创建时间民宿这块比景点复杂一点。如果只是挂一个民宿名称价格后续做房型管理时表结构就会返工。所以拆成三张表民宿表、房型表、订单表。民宿表存基础信息地址、联系电话、封面图、简介房型表存具体可订的房间房型名、价格、可住人数、剩余房量订单表记录用户、民宿、房型、入住日期、离店日期、状态。这样设计的价值在于以后要支持周六加价连住折扣日历房价直接在房型表和订单表上扩展字段就行不需要推翻重来。特产表在结构上跟景点表类似多了一个price和unit_price字段按斤/按盒计价。但特产在村游网第一版里只做展示电话询价不做购物车和在线结算所以不需要库存模块。3.2 搜索与推荐的实现方案搜索功能看起来不起眼却是游客使用频率最高的入口之一。第一版我的实现非常朴素单表LIKE查询配合一个联合索引(status, name)。select idsearchByName resultTypecom.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_typeauthorization_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; ListSpot 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字左右的介绍、标注了交通路线和联系电话民宿和农家乐则一一打电话核实了营业时间和可接待人数。这件事的工程量不亚于开发代码但它直接决定用户打不打开、留不留下。后续迭代的优先级排名我会这样排第一是订房成功后的微信订阅消息提醒游客不习惯主动刷新订单状态推送通知能明显降低取消率第二是评价系统游客住完、玩完能给村里留下真实反馈倒逼商家提高服务质量第三是地图导览和入村路线规划对自驾游客来说是刚需第四是扩展到多村同一个后台同时管理几个村小程序端按村切换让系统的规模效应逐渐显现。我在实际操作中最大的体会是这类乡镇文旅项目在技术上并没有多高深真正的难点在于把村里有什么翻译成游客想看什么。代码能跑通只是及格线内容是否对路、电话是否打得通、信息是否更新及时这些不性感的细节决定了小程序能不能在这个村子活下去。如果你正在做类似的项目建议把至少一半的精力放在跟商家沟通和整理种子内容上这个投入比任何一行代码都值钱。
RELATED READING

延伸阅读

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