1. 旅游定制不是选套餐:需求拆解先把人搞明白
去年有个做旅行社的朋友找我,说想做一个微信小程序。一开始需求写得很简单:"用户选个目的地,我们推几条线路,用户下单。"听起来跟携程、马蜂窝上的标准产品没区别,但聊到第二轮他就抛了个真问题:他们社里大部分业务来自企业团建、家庭结伴游、小众深度游,这类用户根本不满足于现成线路,而是"我要去川西但不想走传统环线,最好能骑马进山,住一晚藏寨,吃一顿地道藏餐"——这种需求没法靠固定SKU解决。
所以这篇内容的核心价值就在这:微信小程序不是重点,"定制"才是难点。小程序只是载体,真正要说清楚的是——怎么把一堆碎片化、口语化的旅行需求,变成一条可执行、可报价、可售卖的线路,再通过微信生态里的小程序把它落地。
如果你正在做旅游类小程序、OTA类平台,或者手里有个想做定制化业务的旅行社客户,这篇从头到尾都是实操过程,包括需求拆解、技术选型、核心模块实现、踩坑记录和优化思路。没有代码基础的人也能看懂大方向,想动手的人可以直接抄部分设计。
我先把需求拆解的结果列出来。当时我们把"旅游线路定制"拆分成了四层:
- 目的地偏好:用户想去哪。这是最粗粒度,决定了后续所有推荐的地理范围。
- 行程节奏:天数、出发时间、每天移动距离的容忍度。同样是云南,5天和12天的玩法完全不是一回事。
- 体验标签:美食、徒步、摄影、亲子、人文、轻奢住宿等,这里是最能体现"定制"价值的部分。
- 预算区间与成团人数:决定了交通方式(包车还是拼车)、住宿等级、领队配置。
这四层不是简单堆在界面上让用户填,而是每层之间有关联逻辑。比如用户选了"摄影"标签,那目的地选项里就不该推城市商圈游;选了"亲子",行程节奏就得自动降低每日车程,住宿偏好偏亲子酒店。定制系统本质上是一套规则引擎,不是一堆筛选框。
这套拆解做完,我和朋友都松了一口气。因为后面所有页面设计、接口设计、算法设计,都围绕这四层数据模型展开。如果一开始就奔着"功能多"去做,做出来的只会是一个普通订票工具。
2. 技术选型:我为什么从原生小程序切到uniapp
接下来是技术选型。这个环节我踩了一个比较明显的坑,值得单独拿出来说。
2.1 原生开发到一半,发现不对劲
项目启动初期,我图省事直接用微信小程序原生语法开发,WXML + WXSS + JS 那一套。原因很简单:团队里两个人对原生语法最熟,而且小程序这种轻量场景,原生上手快。
做到第三个页面的时候问题开始暴露。我们这个项目除了小程序端,后台管理端要用 Vue,未来还可能要出 App 端或鸿蒙端。原生小程序代码没法复用到后台管理项目里,等于两套代码库、两套接口联调、两套维护成本。更要命的是,小程序的模板语法写多了之后,状态管理很别扭,复杂页面(比如定制行程的多步骤表单)来回 setData,性能和代码可读性都掉得厉害。
后来我们做了一个决定:切到 uniapp 重新写。原因不是uniapp有多完美,而是Vue对团队来说太熟了——后台管理是Vue,小程序端也用Vue语法,一套人才能通用。这个取舍在热词里正好有对应:大到"uniapp开发微信小程序 vs android/ios/鸿蒙",小到"vue项目如何发布微信小程序",说明现在很多团队都在做这个方向的选型比较。
2.2 uniapp的几项硬性优势(针对本项目)
从实际落地效果来看,有五点感受最直接:
- 代码复用率:线路定制的核心逻辑、表单校验、接口请求封装,全部写成公共模块。以后要做App端,样式和业务代码大部分能迁过去。
- 状态管理顺手:uniapp里能用Vuex/Pinia管理跨页面状态,比原生的全局变量和事件传参稳太多。
- UI库可用性:uni-ui、uView等组件库帮了大忙,单选框、日期选择器、步骤条直接拿来改,省了造轮子的时间。
- 条件编译:碰到平台差异化需求,用条件编译写平台分支,不用整个项目分叉。
- 社区流量:遇到问题搜"uniapp xxx"基本都有答案,比搜原生小程序踩坑经验容易得多。
但注意,uniapp不是银弹。如果你只做微信小程序、团队又完全不熟Vue,那没必要硬上uniapp。我见过有人用uniapp跑原生项目,结果在生命周期差异上卡了半个月。选型永远看团队和长期维护成本,不看社区热度。
2.3 开发工具链和真机调试
项目跑起来之后,有几个工具链问题是必然遇到的:
- HBuilderX:我们用的是HBuilderX进行编译和真机调试。这个工具对Vue语法的支持比微信开发者工具好,但调试H5端时偶尔有白屏问题,需要刷新几次。
- 微信开发者工具:作为最终预览和上传的工具,基本上天天开着。记得把本地设置里的"自动热重载"勾上,不然改一行代码要手动编译,效率极差。热词里"微信小程序能做热刷新吗"就是这个问题的真实写照——原生小程序开发时热刷新支持不完美,uniapp的HBuilderX热重载体验好很多。
- 版本管理:微信开发者工具有两套环境——测试号和自己注册的AppID。早点注册正式AppID,很多接口(比如支付、获取手机号)测试号根本调不通。
3. 线路定制引擎:表单收集、规则匹配与推荐生成的完整链路
这是整个项目的核心。我详细说明这个模块是怎么从零开始设计和实现的,中间包括"为什么这么设计"的逻辑拆解。
3.1 第一步:多步骤表单设计——怎么把"想旅游"变成结构化数据
用户不是来填写表格的,是来"描述愿望"的。所以我们把定制页面做成了一个含有多个步骤的表单流程,每一步对应需求拆解中的一层。
具体来说,一共五个步骤:
- 第一步:选择目的地类型(国内/国外、热门城市列表、地图选点)
- 第二步:选择出行日期和天数(日期范围组件 + 滑块选天数)
- 第三步:选择同行人类型(单人、情侣、亲子、朋友结伴、企业团建)——这一步会影响后面的推荐策略
- 第四步:勾选体验标签(美食、摄影、徒步、景点打卡、人文历史、购物、夜生活、亲子娱乐、轻奢住宿、温泉等)
- 第五步:填写预算区间和特殊需求备注(自由文本,比如"不想去网红点"、"需要轮椅通道")
这个顺序是刻意设计的。前几步让用户越做越轻松,如果第一步就让用户填一堆标签,很多人会在中途流失。一个生活化的类比:你去餐厅点菜,服务员先问"几位、有没有忌口",再递菜单,没人一上来就问你"对菜品的镬气有什么要求"。
3.2 第二步:规则匹配——推荐算法不能只靠"等于"
拿到表单数据后,推荐逻辑分三层:
第一层:硬性过滤。目的地类型、出行日期、预算区间,这三个是硬条件。比如用户预算上限2000,系统绝对不会给推单日就1800的定制方案。代码层面就是简单的filter,但要注意边界值——"预算区间"用左闭右开区间存,否则2000预算会把恰好2000的方案排除掉,这是个很隐性但容易惹用户投诉的问题。
第二层:标签权重匹配。这里我没有用复杂的机器学习模型(项目初创期没必要),而是设计了一套可解释的加权评分机制。每条线路在后台录入时都打上了标签,比如:
// 线路标签权重示意 { "lineId": "line_88912", "title": "稻城亚丁摄影徒步6日", "tags": ["摄影", "徒步", "自然风景", "人文"], "tagWeights": { "摄影": 3, "徒步": 2, "自然风景": 2, "人文": 1, "美食": 0.5 } }用户的标签选择记为1,未选记0,计算线路得分:
function calculateScore(line, userTags) { let score = 0; for (const tag of userTags) { if (line.tags.includes(tag)) { score += line.tagWeights[tag] || 1; } } return score; }第三层:协同过滤的轻量变体。当用户数量和下单数据积累到一定量后,我们加了一个非常轻量的"相似人群扩量"逻辑:找到历史订单中与当前用户标签重合度超过60%的订单,把这些订单中用户选择的线路提高权重,作为补充推荐。不需要维护太复杂的模型,存一张用户-标签矩阵就行。
3.3 第三步:线路结果页与行程编辑
推荐结果页也不是简单展示固定线路。用户点进某条推荐线路后,能看到一个"行程概览"页,包括:
- 每日行程时间轴(Day 1 / Day 2 ... 每个节点有地点、交通方式、餐饮安排)
- 可编辑的时间轴节点(用户可以拖拽调整顺序,或者点击"替换"按钮换成同类型备选)
- 右下角浮动按钮"更新报价",点击后根据修改结果实时重新计算价格
这里有个技术实现细节值得说一下:时间轴节点的拖拽排序,当初在uniapp里用movable-area做过,体验不好;后来改用简单的上下移按钮,反而稳定。小屏幕上的复杂拖拽交互,在微信生态里要尤其谨慎,用户误触概率太高,不如把操作拆成明确的"上移/下移/删除/替换"四个按钮。
3.4 报价实时计算逻辑
报价这块是旅行社老板最关心的。我们的做法是给每个线路定义"基础单价 + 浮动因子"结构:
- 基础价:根据线路的名称、天数、标准住宿等级确定一个基准价
- 浮动因子:节假日、旅游旺季、成团人数、定制标签
实时报价的伪代码:
function calculatePrice(line, params) { let base = line.basePrice; let factor = 1.0; if (params.isHoliday) factor *= 1.3; // 节假日上浮30% if (params.groupSize >= 4) factor *= 0.9; // 四人以上打九折 if (params.customTags.length > 3) factor *= 1.1; // 高端定制加分 return Math.round(base * factor); }算完之后,页面上会用大字号展示"预估价格",并注明"最终价格以客服确认单为准"。旅行产品的实时报价不能直接锁定成订单价格,因为机票、酒店的实时库存波动太大,页面上的价格更多是建立用户心理预期,所以文案里一定加上"以客服确认单为准",不然纠纷很多。
4. 请求封装与接口设计:一次封装管三年维护
热词里有一条"微信小程序 请求封装",这几乎是每个小程序开发者必碰的活。在我们这个旅游定制项目里,请求层做得好不好,直接关系到后续迭代效率。
4.1 为什么不能直接用wx.request
小程序原生提供的wx.request能用,但有几个硬伤:
- 没有统一的拦截器机制,每个页面都要写loading和错误处理
- 登录态过期、token刷新这些逻辑没法集中处理
- 接口返回结构不规范的话,出错信息没法统一展示
- 没有请求取消机制,页面跳走之后请求回调还在执行,容易触发setData报错
在uniapp里,官方推荐用uni.request,但同样存在上述问题。所以我们的做法是基于Promise封装一个request工具模块。
4.2 封装设计思路与核心代码
封装的核心目标就六个字:统一入口,集中处理。
// utils/request.js const BASE_URL = 'https://api.example.com/v1'; function request(path, options = {}) { const token = uni.getStorageSync('token'); return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + path, method: options.method || 'GET', data: options.data || {}, header: { 'Content-Type': 'application/json', 'Authorization': token ? 'Bearer ' + token : '', ...options.header }, timeout: options.timeout || 15000, success: (res) => { // 业务约定:code=0表示成功,非0表示业务错误 if (res.statusCode === 200 && res.data.code === 0) { resolve(res.data.data); } else if (res.statusCode === 401) { // 登录过期处理 uni.removeStorageSync('token'); uni.redirectTo({ url: '/pages/login/login' }); reject(new Error('登录已过期')); } else { uni.showToast({ title: res.data.msg || '请求失败', icon: 'none' }); reject(new Error(res.data.msg || 'error')); } }, fail: (err) => { uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' }); reject(err); } }); }); } export const get = (path, data) => request(path, { method: 'GET', data }); export const post = (path, data) => request(path, { method: 'POST', data });这段封装有几个细节值得展开:
401处理:旅游定制类接口涉及用户登录态,登录过期是个高频场景。集中处理401,统一跳转登录页,避免每个页面各自判断、各自跳转,代码会整洁很多。
业务码与HTTP码分离:很多团队前期图省事直接用HTTP状态码表达业务结果,结果就是后端返回200但data里带着错误信息,前端还得多处判断。我们约定code=0为成功、非0为业务错误,统一在封装层拦截,页面代码里就能少写大量if-else。
网络错误提示:这个封装在fail回调里统一弹toast。这样页面里调用接口时,基本只需要关心成功的数据处理,错误分支都收敛了。
4.3 缓存策略:为什么只缓存四大类数据
旅游类小程序的缓存设计比普通工具类复杂,因为数据既要新鲜又要快。我们最终定了四条缓存规则:
- 目的地列表:基本不变,缓存7天
- 线路标签库:运营偶尔调整,缓存24小时
- 推荐线路列表:依赖实时库存和季节活动,不缓存,每次请求
- 定制表单草稿:本地持久化,用户在填写过程中随时退出,下次进入自动恢复
热词里"微信小程序设置缓存时间"这个关键词,说明很多人搞不清缓存时长怎么设。我认为核心原则是:数据变更频率决定缓存时间,不是拍脑袋定7天或24小时。目的地列表可能一季都不变,没必要每次启动都拉;推荐线路包含实时价格,缓存了反而会让用户看到过时报价,干脆不缓存。
实现上用uniapp的uni.setStorageSync和uni.getStorageSync,附带一个时间戳校验:
function getCache(key, maxAge) { const cached = uni.getStorageSync(key); if (!cached || !cached.timestamp) return null; if (Date.now() - cached.timestamp > maxAge) { uni.removeStorageSync(key); return null; } return cached.data; } function setCache(key, data) { uni.setStorageSync(key, { data: data, timestamp: Date.now() }); }这样封装之后,页面里调目的地接口时,先读缓存,没有再请求网络,体验和服务器压力都兼顾了。
4.4 接口层设计:把"定制流程"拆成几个原子接口
后端接口设计上,我们没有做一个大而全的"submitCustomTravelPlan"接口,而是拆成多个原子接口:
POST /api/custom/draft:保存定制表单草稿POST /api/custom/recommend:传入表单数据,返回推荐线路列表GET /api/custom/line/{id}:获取单条线路详情(含每日行程)POST /api/custom/plan:提交定制方案(用户编辑后的最终方案)POST /api/custom/order:对定制方案下单
这样的好处是:前端每个步骤都能独立调试、独立上报错误,不会因为一步填错导致整个流程崩溃。而且草稿接口的存在,让"填写到一半退出"的用户也能被保存下来,对转化率帮助很大。
5. 微信小程序独有的坑:导航栏高度、缓存时序和组件怪癖
做微信小程序一定绕不开一些平台特有的坑。热词列表里至少有五条直接对应这个主题——"微信小程序顶部导航栏高度"、"微信小程序设置缓存时间"、"微信小程序单选框"、"微信小程序 list-builder"、"h5唤起微信小程序链接无法访问"。
5.1 自定义导航栏的适配问题
旅游定制小程序的核心页面(定制表单、线路详情)都用了自定义导航栏,这样视觉更好看、按钮位更灵活。但自定义导航栏有个经典问题:顶部安全区域高度在不同的手机上不一样。
iPhone X系列有底部小黑条、刘海屏有顶部传感器区域,安卓各种厂商的全面屏手势区域又各不相同。如果导航栏高度写死,到了真机上就是内容上下错位、按钮被状态栏挡住。
最终方案:使用uniapp提供的uni.getSystemInfoSync()获取状态栏高度,动态计算导航栏高度。
const systemInfo = uni.getSystemInfoSync(); const statusBarHeight = systemInfo.statusBarHeight || 20; // px const navBarHeight = 44; // 默认导航栏内容高度页面最外层容器预留padding-top: statusBarHeight + navBarHeight,按钮定位按这个高度计算。这个代码在新机型和旧机型上都要测,光在开发者工具里看是没用的。
5.2 缓存时序:getStorageSync拿到undefined的真相
热词里"微信小程序设置缓存时间"我前文已经讲了策略,但还有一个更隐蔽的时序坑。
在uniapp里,如果用uni.getStorageSync('key')去拿一个不存在的key,返回的是空字符串而不是null。很多新人写:
if (uni.getStorageSync('token')) { ... }初看没问题,但如果你要判断"缓存是否真的设置了",空字符串和"值为空"的语义会混淆。建议统一用封装函数判断:
function hasCache(key) { const value = uni.getStorageSync(key); return value !== '' && value !== null && value !== undefined; }还有更坑的:某些版本的uniapp在真机上对Storage的写入是异步的,理论上会有"写入后立即读取读不到"的偶发情况。我们最终在新版本用uni.setStorage(异步带回调)处理关键数据写入后的后续逻辑,避免竞态。这个比较小众,但真遇到了会让人怀疑人生。
5.3 单选/多选组件:原生view模拟还是用UI库
热词里有"微信小程序单选框",可见这是个高频踩到点。旅游定制的体验标签选择,本质上是一个可以多选的标签云,不是严格的单选框。
我尝试过三种方案:
- 原生checkbox:样式老气,定制样式还得改组件的原生阴影,非常麻烦
- uView的checkbox:能用,但标签多时布局不够灵活
- 自己用view实现标签云:成本最低,动态渲染一个flex-wrap容器,点击切换active状态
最后选了第三种,代码看着平平无奇:
<view class="tag-list"> <view v-for="tag in tags" :key="tag.id" class="tag-item" :class="{ active: selectedTags.includes(tag.id) }" @tap="toggleTag(tag.id)" > {{ tag.name }} </view> </view>好处有两个:一是样式完全可控,想做成圆形、多行、带颜色过渡都行;二是响应式好,手机屏幕宽度不够时flex-wrap自动换行,不用操心原生组件的适配问题。
凡是"看起来像标签云"的选择场景,我强烈建议不要用radio或checkbox,直接view模拟。
5.4 H5唤起小程序失败:链接规范和场景值
热词里"h5唤起微信小程序链接无法访问"也值得一说。旅游定制项目的分享功能很重,用户把定制好的线路分享给同伴,同伴如果从H5页面点击打开小程序,需要走微信的"URL Link"或"URL Scheme"机制。
这里最容易踩的坑有两处:
- URL Link必须在微信公众平台后台配置好域名白名单,而且域名不能带路径参数,很多团队在这里卡半天,结果发现是域名配置漏了
- 唤起小程序时一定要带scene参数,用来标记来源。我们所有从H5唤起小程序的链接都带上了
scene=share_h5,小程序端通过App.onShow(options.query.scene)解析来源,再做对应的路由跳转。
如果省略scene,用户从H5跳进来只会落在首页,体验非常脱节。这个细节在技术上不难,但非常影响转化。
5.5 list-builder:列表性能调优
热词里有"微信小程序 list-builder",这大概是社区里一个列表构建库的话题。我们在定制结果页的推荐列表上也有过性能问题,总结下来的经验是:
- 列表项不要一次性全部渲染,用分页加载或虚拟列表
- 图片懒加载必须开启,
lazy-load属性直接加在image上 - 公共列表容器的滚动区域独立设置,不要让页面整体滚动的同时列表内部还滚动,会出现滚动冲突
- 不要用太深的v-for嵌套,时间轴里套评分、评分里套标签,改动一次setData数据量就很大
把这些规则落到项目里之后,低端安卓机上列表页的卡顿明显改善了。
6. 从"能跑"到"好用":支付、审核和上线后的迭代思路
主体功能全部实现之后,还有三个绕不开的坎:支付接入、微信审核、上线后的数据迭代。每一个我都踩过坑,把这些经验放出来比代码本身更值钱。
6.1 支付:微信支付如何接入,以及支付宝渠道的问题
热词里有一条"微信小程序可以加入支付宝支付渠道吗,如何设计",我直接说结论:微信小程序生态里只能使用微信支付,不能直接在微信小程序内唤起支付宝支付。若确实需要支付宝,只能引导用户跳转到支付宝App,或者走"H5支付 + 浏览器中转"这类折中方案,但微信官方对这类跳转的限制比较严格,不建议依赖。
微信支付的接入流程是这样的:
- 申请微信支付商户号:小程序主体和商户号主体必须是同一个
- 后端统一下单:后端调微信支付接口获取prepay_id
- 小程序端调起支付
uni.requestPayment({ provider: 'wxpay', timeStamp: res.timeStamp, nonceStr: res.nonceStr, package: res.package, signType: 'MD5', paySign: res.paySign, success: (res) => { ... }, fail: (err) => { ... } });注意点:timeStamp是字符串类型,后端传过来是什么就传什么,不要自己转数字再拼回去,很容易出现签名错误。我们在这个坑上花了一个下午,最后发现就是类型转换导致签名串和官方算法不一致。
6.2 微信审核:旅游类目需要额外资质
旅游类小程序和个人开发者的普通工具不一样,在微信公众平台提交审核时,类目选择"旅游"会要求提供《旅行社业务经营许可证》等资质文件。这个如果没提前准备,审核会在一个工作日左右被驳回,而且驳回理由写得很笼统,你搜半天才知道是资质问题。
建议的流程是:
- 先注册社会组织主体或企业主体账号,不要用个人主体
- 提前准备营业执照、旅行社资质等扫描件
- 在小程序后台先配置好类目和服务范围再提审
- 测试账号准备一套完整的可用数据(不能是写着"测试"的假数据),审核人员点开页面时如果看到空数据或报错,大概率被打回
我们第一次提审就被打了,原因是"定制表单页面在未登录状态下无法使用",审核人员要看到完整的流程,必须是可直接演示的状态。后来我们在未登录状态下也能浏览线路和标签,仅在下单时要求登录,才过审。
6.3 上线后的迭代:用户行为告诉我AI推荐该怎么做
上线后第一个月,除了常规的bug修复,我关注的三个核心指标是:
- 定制表单完成率:用户从进入定制页到完成提交的比例
- 推荐线路点击率:推荐结果里被点进详情页的占比
- 定制方案下单转化率:从方案确认页到支付完成的比例
数据出来之后有几个发现,直接驱动了后续迭代:
第一,完成率只有34%,一半以上的用户卡在第三步"同行人类型"。后来把第三步和第二步合并了,出行日期、天数和同行人放一个页面,完成率提到了51%。这验证了前面说的"步骤越少越好"的设计原则。
第二,推荐线路点击率分布极不均匀,前两条线路占了80%的点击。这不是用户的问题,是我们评分算法把分数拉得太开了。后来给推荐数量做了限制,最多展示5条,并在推荐理由里说明"为什么推荐这条线路"(用标签匹配的逻辑生成一段文案),点击率反而涨了。
第三,下单转化率只有7%,但客服系统的咨询量翻了倍。用户对"定制方案"心里没底,要反复问清楚才敢付钱。后来在方案确认页加入了"微信客服"按钮,引导用户先加客服微信再下单。这个改动不算技术活,但对交易类产品来说极其重要。
6.4 一个续篇的方向:从"定制"走向"邀约共创"
项目稳定运行后,我们开始规划下一个版本:让用户不只是一个表单的填写者,而是可以和旅行规划师在站内直接沟通、共同编辑一份行程。技术上的核心是从"静态方案确认"升级为"共享编辑状态",预计会引入类似于协作文档里"操作记录"的能力。这部分工作还在进行中,将来跑通后再专门写一篇。
最后说点实在的
我最大的感受是,旅游线路定制小程序的难点从来不在某个单一技术上,而在需求建模和流程设计。技术选型、请求封装、组件适配这些都是可以靠经验快速解决的;真正的分水岭是你愿不愿意把用户当成人而不是流量去理解——他们不是要一个筛选器,而是想要一个懂旅行的助手在耳边给建议。
如果你现在正好在做一个类似的项目,我建议第一步别急着写代码。找三个真实用户,让他们用纸面原型走一遍定制流程,你会收获比任何技术调研都多的信息量。我当时就是拿着便利贴在朋友公司楼下堵了五个准备去旅游的顾客,聊了一下午,才把表单从七步砍成五步。这个投入,比后期改架构划算得多。