去年年初我接到一个需求:做一套智能旅游管家系统。用户出门旅行前最头疼的往往不是订机票酒店,而是“到了目的地到底怎么玩、门票怎么买”。好几个朋友跟我抱怨过,上午十点才到景区门口,结果当天的票早卖完了,只能对着电子屏干瞪眼。这个痛点很具体,于是我们把产品定位成一句话:把行程规划和景点购票揉到一个微信小程序里,扫开就用,不用下载安装包。
项目代号内部叫“口袋导游”。从需求评审到第一版上线,前后大概花了三个多月。整个系统是典型的“小程序前端 + Android 原生辅助 + 服务端大脑”结构,核心功能就两块:基于用户偏好和时间约束的智能行程规划,以及从选票、支付到入园核销的完整购票链路。这篇文章想把整个设计过程、踩坑经历、代码决策背后的原因都摊开来讲,适合正在做旅游类小程序、毕业设计选了这个方向,或者单纯想了解一套真实业务系统怎么落地的朋友参考。
1. 整体设计复盘:为什么是“小程序 + Android”的组合
先说一个容易误解的点:标题里的“基于 Android”不是说做一个原生 App,而是指这套系统里有 Android 原生能力作为底层支撑,小程序负责直接触达用户。这种组合在旅游场景里非常合适,因为小程序解决了分发和支付,Android 模块解决定位、轨迹采集、离线缓存这些小程序不容易做深的问题。
1.1 微信小程序做主入口的真实理由
微信小程序最大的优势是零安装成本。用户在规划群里看到朋友分享的“明天的杭州一日游路线”,点开卡片就能进入系统,不用跳转应用商店,不看下载进度条。对旅游这种低频但强社交分享的场景来说,这个优势是决定性的。App 产品用户旅行结束很容易卸载,但小程序用完即走,下次打开微信还能找到,留存成本低很多。
支付闭环也是重要原因。微信小程序里直接用 wx.requestPayment 就能拉起微信支付,免去了接入银联、支付宝的额外工作,用户也熟悉这个流程,支付转化率明显比 H5 高。技术团队还对比过 uni-app 跨端方案,最后选择原生小程序开发,原因很实际:核心页面不多但交互复杂,原生方式更容易控制包体积,避免出现“source size 超过 2MB 无法上传”的问题。真机预览、体验版发布也都更方便。
当然小程序不是万能的。它的后台能力弱,App 退到后台后没法持续定位;蓝牙、传感器接口在不同机型上表现差异很大;基础库限制也比较多。所以我们需要一块 Android 原生的辅助模块补位,而这也是很多旅游项目里容易被忽略的部分。
1.2 Android 原生模块:定位、缓存与离线轨迹
我们让 Android 端承担了三件事:前台定位服务、景点数据离线缓存、蓝牙信标打卡。
定位这块,小程序里 wx.getLocation 只能拿到单次位置,而且需要用户授权,用户一拒绝就没辙。而做智能行程规划时,我们需要知道用户实际在景点 A 停留了多久、什么时候移动到景点 B,这些数据能让推荐算法越来越准。Android 端用一个前台 Service 跑定位采集,界面上常驻一条通知,写着“口袋导游正在记录你的游览轨迹”,用户能感知到,不会在系统里被杀死。轨迹数据每五分钟聚合一次,上传到服务端,再折算成景点停留时长和路线顺路程度,回填到推荐评分体系里。
离线缓存也为景区信号差的场景兜底。小程序下载一个城市的离线景点包后,即使手机在深山、地宫里没信号,打开景点介绍和当日行程仍然能看。这个体验用户感知非常强,我们上线后收到过好几条评价说“在山里也能看到路线”。Android 模块的另一个职责是处理蓝牙信标数据,用于室内场馆的导览定位,这部分虽然后来只做了一个场馆的试点,但技术验证已经跑通。
1.3 核心数据模型:一张行程表的字段是这样设计的
行程规划业务听起来不复杂,但建模时有个容易踩的坑:把“行程”设计成一张大宽表,结果想调整某一天某个景点时非常痛苦。我最终拆成了四张表:
行程主表 trip 记录用户、城市、开始日期、总天数;行程天表 trip_day 记录第几天、当天的出发时间、结束时间;行程景点项表 trip_item 记录某一天的某个景点、序号、计划开始与结束时间、交通耗时;独立一张附件表保存“用户对某个景点的备注或必去标记”。
订单和行程的关系也值得注意。用户买了某一日的门票后,如果后续他自己调整了行程顺序,订单不应该跟着乱,所以订单表里必须做快照字段:下单那一刻的景点名称、日期、单价、场次全部冗余存一份,不能只存一个 trip_item_id 就去 join 行程表。这个设计在上线后救了我们很多次,因为用户改行程的频次远高于我们的预估。
2. 智能行程规划:推荐算法、时间轴与前端交互
行程规划的难点不是“把景点列出来”,而是“在有限一天里排出真正走得通的路线”。我们每天按 8:00 出发、18:00 结束来算,扣除午餐 60 分钟和景点间交通,真正可用的游览时间也就 8 个小时左右。怎么在这 8 小时里选景点、排序、留出冗余,才是核心。
2.1 景点打分算法:不靠玄学,靠可计算的分值
我们最初想直接按热门景点排,试了一次发现不行:所有用户拿到的路线几乎一模一样,而且热门景点扎堆在市中心,第二天的路线和第一天严重重复。后来改成四维打分模型,每个候选景点按公式算分:
score = 0.35 × 热度分 + 0.35 × 顺路分 + 0.2 × 时间契合分 + 0.1 × 偏好分
热度分来自近一个月门票销量,做过归一化处理,月销量最高的景点得 1 分,其余按比例映射。顺路分很关键:从当前所处位置到候选景点的驾车时间,越短分越高,超过 40 分钟直接打五折。这里有个经验,顺路分权重不能超过 0.4,否则所有路线都会趋同成“围着酒店转”,用户反而觉得不够智能。时间契合分考虑的是景点建议游览时长和当天剩余时间的匹配度,如果某个景点需要 4.5 小时而下午只剩 3 小时,这个分值会直接降得很低。
举个例子,用户上午在西湖边,下午候选雷峰塔、灵隐寺、宋城三个景点。雷峰塔月销量 3.2 万,热度分 0.82;灵隐寺 2.1 万,热度分 0.64;宋城 1.5 万,热度分 0.52。从当前位置到雷峰塔 10 分钟,顺路分 0.95;到灵隐寺 20 分钟,顺路分 0.8;到宋城 45 分钟,顺路分只剩 0.4。如果用户偏好里勾选了“人文历史”,偏好分灵隐寺最高。综合算下来灵隐寺往往胜出,这个结果既合理又能解释给用户看。
2.2 一天 4 个景点怎么排:贪心算法与时间约束
路线生成没有用全排列穷举,因为候选池一天可能有 10 个景点,全排列是 3628800 种,虽然单次算也不算致命,但多天、多用户并发时完全不现实。我们用了一种带约束的贪心思路,每一步都选当前综合分数最高的景点,而不是全局最优。
具体流程是这样的:先从用户选择的出发点(一般是酒店)开始,候选池按到达时间预筛,车程超过两小时的一律淘汰;对剩余候选按公式打分,选最高分那个;然后算“当前时间 + 交通时间 + 游览时长”是否超过当天结束时间,如果超了就跳过换下一个;加入路线后更新当前时间,重复直到候选池为空或剩余时间不够。如果用户手动标了“必去景点”,算法会优先安排,但剩余时间排不下时会直接给出提示:“今天的行程已经满了,这个景点建议换到明天,或者删掉一个现有景点。”
交通时间的估算比想象中麻烦,我们调的是地图 API 的驾车时长,但在结果上额外加了 25% 的冗余系数、15 分钟的景区排队入场时间,周末还要再乘 1.2。这些系数是我们对比了 30 条真实用户轨迹后调出来的,宁可让用户觉得“时间有点松”,也不能让路线排得跟军训一样。后端返回路线时还会带上每段交通的起止地点,前端渲染出来以后,用户能直观看到“从雷峰塔到灵隐寺开车 15 分钟”,可信度就上去了。
2.3 前端时间轴与列表加载更多
前端最复杂的部分是时间轴视图。每个景点卡片按时间顺序纵向排列,上午、下午、晚间三个区域用浅灰色分割线区分。卡片上展示景点照片、建议游览时长、交通缓冲时间,右侧提供两个小按钮:上移、下移。拖动排序我们也做了一版,但真机测试时发现部分安卓机型对长按拖动的响应不稳定,容易误触发滚动,最后还是保留了按钮式排序作为主操作,拖动只作为辅助。
景点搜索结果列表的加载更多是另一个容易写崩的地方。列表页刚打开时只有第一页数据,往下滚动触底后要用 onReachBottom 加载第二页。我们分页参数没有用传统 page 页码,而是用游标 lastId,因为页码分页在景点数据发生新增时会出现重复或漏数据。加载时用一个 isLoading 锁防止重复请求,每次请求成功把新数组 concat 到旧列表后面,同时更新 hasMore 标志。WXML 中 wx:for 的 key 一定要用景点 id,不能用索引,否则已展开的心动按钮状态会在复用节点时串掉。空状态和加载失败重试也不能省,景点列表在弱网环境下失败率很高,没有重试按钮用户会直接划走。
3. 景点购票系统:从选票到入园的全链路实现
购票这条链路看起来就是“选票、下单、支付、验票”,但每个环节都有细节。我们踩过的坑包括库存超卖、支付回调重复处理、二维码被截屏转卖,这节把关键设计拆开说。
3.1 SKU 与库存:先把票的种类设计清楚
景点票务系统里最基础的是 SKU 设计。票的种类远比想象中多:成人票、学生票、老人票,单景点票、联票,还有提前两天预订的早鸟票。如果数据库里只设计成一张“门票表”,后面加一个票种就要改表,非常被动。
我们用 ticket_sku 表来存放所有可售的票种,字段包括关联的景点、名称、售价、票种类型、限购数量、适用日期。库存设计则区分了两种模式:总量库存和日历库存。总量库存适合淡季,只要总票数没卖完就随便买;节假日前几天必须切到日历库存,因为某一天爆满不代表其他日期也满,用户能看到“10月2日已售罄,10月3日有票”这样的信息才愿意继续下单。
高并发压测时,数据库行锁成为瓶颈,所以我们改成 Redis 预扣库存。下单请求先走一段 Lua 脚本,原子性地扣减某个日期的库存,扣成功才允许创建订单;15 分钟后如果订单仍未支付,延时任务自动关单并把库存加回来。数据库里的库存是最终事实,Redis 只承担挡并发这个角色,每天凌晨有对账任务检查两者是否一致,不一致时以数据库为准修正。这个方案在国庆节那几天扛住了单日两千多单的量,没有出现超卖。
3.2 订单状态机与支付回调幂等处理
订单状态不能随便跳。我们设计了五个状态:待支付、已支付、已使用、退款中、已退款。待支付订单只能流向已支付或取消,已支付订单只能流向已使用或退款中,中间不允许乱跳。
支付回调是最容易出 bug 的地方。微信支付服务器会异步通知后端支付结果,而且同一个订单可能通知多次,如果不做幂等,用户付一次钱可能拿到两张票。处理逻辑很简单但必须严格执行:先按商户订单号 out_trade_no 查订单,如果订单状态已经是已支付,直接返回 success,不再执行任何业务操作;验签失败直接返回 fail;验签通过后还要比对金额,防止中间环节把金额改掉。
登录链路也顺便说一句。小程序前端先调用 wx.login 拿 code,把 code 传给后端,后端用 code2Session 接口换 openid,再和用户手机号绑定。支付时前端拿到的是后端生成的一系列参数,包括 timeStamp、nonceStr、package、signType、paySign,这些参数必须由后端统一生成,不能让前端自己拼,否则签名校验这关就形同虚设。
3.3 入园验票:动态二维码与防转卖设计
用户买完票后,小程序里会生成一张动态二维码,景区工作人员扫码就能核销。这里有个安全细节:二维码内容不是一个完整的 ticketToken,而是一个随机短码,比如九位字符串,后端把短码和订单明细存在 Redis 里,过期时间 15 分钟。验票端扫码后拿短码请求后端,后端校验存在性和有效性,再返回景点名称、日期、票种信息给核销界面。
动态刷新也很重要。二维码每 60 秒刷新一次,刷新后旧码立刻失效,这样即使有人截屏发给朋友,几分钟后这个码也作废了。联票场景下,一张订单可能包含三个景点的门票,每张票必须单独核销,所以核销接口要按 ticket_id 而不是整单处理,返回结果也要区分“这张已核销”“当前订单还有两张未核销”。退款中的订单禁止核销,这个状态判断必须在服务端做,不能相信前端传过来的状态字段,因为前端状态很容易被改。
4. 小程序端与 Android 端协作中的高频问题实录
这套系统前前后后跑了快一年,大部分时间不是在做新功能,而是在跟各种环境问题搏斗。这节整理几个最典型、最容易在开发阶段卡壳的问题,顺序基本就是我们踩坑的时间线。
4.1 顶部导航栏高度与安全区适配
自定义导航栏是旅游类小程序很常见的选择,因为我们要把景点大图直接顶到屏幕顶部,沉浸感强很多。但自定义导航栏最烦的是不同机型的高度完全不一样,尤其安卓厂商对状态栏高度的处理五花八门。
我们用微信官方提供的胶囊按钮信息来反推导航栏高度,这是目前最稳的方式:
const info = wx.getSystemInfoSync() const rect = wx.getMenuButtonBoundingClientRect() const statusBarHeight = info.statusBarHeight const menuTop = rect.top const menuHeight = rect.height const navBarHeight = (menuTop - statusBarHeight) * 2 + menuHeight这个公式的意思是:胶囊按钮的高度加上胶囊上边界到状态栏下边缘的距离乘以 2,得到一个上下对称的导航栏高度。不同机型算出来不一样,所以导航栏容器不能用 rpx 写死,要用 px 动态设置。全面屏底部也要处理,涉及底部操作按钮的页面要加 padding-bottom: env(safe-area-inset-bottom),否则在 iPhone 上按钮会被手势条挡住。
4.2 进度条与加载状态:不要什么都用 loading 弹窗
很多新手开发一遇到异步操作就调 wx.showLoading,结果用户看什么都带一个转圈,网络一慢就像卡死。我们的原则是区分场景:页面初始加载用骨架屏,景点列表、行程详情这种多区块的页面,骨架屏比转圈显得流畅很多;按钮级别的操作只用按钮本身的 loading 状态,同时禁用按钮防止连续点击。
购票提交按钮就做成了“点击后按钮变为不可用 + 文案变成提交中”,而不是弹全局 loading。因为购票流程可能因为库存不足、价格变动等原因失败,弹全局 loading 会让用户感觉系统崩溃了。上传图片这块有个坑:小程序 uploadFile 没有进度回调,我们摸索出来的方案是后端按分片接收,前端轮询后端接口获取已接收的分片数,再折算成百分比更新进度条。跟 Android 原生模块的进度条体验相比,小程序这个方案算是无奈但实用。
4.3 真机调试与 Charles 排错
开发阶段我们大量使用了真机预览和体验版分发。真机预览在开发者工具里点“预览”就能生成二维码,但二维码有时效,超过十五分钟就失效,而且同一时间只支持一个调试会话,多人同时扫码会互相干扰。后来我们让测试人员统一走体验版:在小程序后台添加体验成员,他们扫码后进入的版本长期有效,收集反馈也更方便。
联调接口时,Charles 帮了大忙。把手机和电脑连到同一个局域网,在电脑端启动 SSL 解密,手机上安装并信任证书后,小程序发出的 HTTPS 请求就能在电脑上看到完整的请求头和响应体。我们排查过一个很隐蔽的问题:安卓线上版偶尔出现列表请求返回空,开发者工具里一切正常。用 Charles 一抓,发现线上返回的数据里某个字段名被后端从 camelCase 改成了 snake_case,前端解析不到这个字段,整段渲染逻辑直接跳过。这种问题不抓包看原始响应根本定位不到。
Android 11 之后还有一个和 content:// 有关的坑。用户从企业微信里打开文件再转发给小程序客服时,如果 Android 模块需要读取这个附件上传到服务端,不能直接用文件路径去访问 android/data 目录,必须用 ContentResolver 配合 FileProvider 的 external_path 配置,并做好 URI 临时授权。否则系统权限直接拒绝,表现就是上传图片一直失败,但崩溃日志里什么都看不出来。
5. 上线后的常见问题与避坑清单
上线不是结束,反而是各种边界问题的开始。这节把高频故障整理成一张速查表,后面再聊聊体验版分发、用户反馈收集等比较容易被忽略的运营侧问题。
5.1 高频故障速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 支付成功后订单仍是待支付 | 支付回调没收到或处理失败 | 检查回调地址公网可达性、日志是否打印验签信息,回调接口要返回 success 字符串 |
| 用户反映某天购票提示有票但下单失败 | Redis 库存与数据库库存不一致 | 触发对账脚本,以数据库为准修正缓存,并检查过期未支付订单释放逻辑 |
| 动态二维码在验票设备上报失效 | 验票设备系统时间和服务端偏差超过 60 秒 | 验票端做时间校准,或把短码有效期从 15 分钟放宽到 20 分钟 |
| 安卓真机上传图片失败且无报错 | Android 11 后文件路径访问受限 | 改用 ContentResolver 和 FileProvider,grantUriPermission 给临时读取权限 |
| 自定义导航栏在个别机型顶部重叠 | 状态栏高度获取方式错误 | 统一用 getMenuButtonBoundingClientRect 反推,不要写死 px |
| H5 页面唤起小程序提示无法访问 | 域名未配置到业务域名白名单 | 在公众平台配置业务域名,首次唤起需要用户曾打开过小程序 |
这个表看着简单,每一条背后都是真实事故。尤其是动态二维码失效那条,差点在五一假期造成大批游客堵在闸机口,后来我们意识到景区验票的手机用的是一台多年前的老设备,系统时间快了将近两分钟,导致新码生成后旧码还在生效时间差内被判定异常。从那以后我们规定验票端每次启动都要做一次 NTP 时间同步。
5.2 体验版分发与用户反馈收集
微信开发者工具生成的小程序预览码虽然方便,但只适合开发者在真机上快速验证。要给外部用户试用几天收集反馈,必须走体验版流程:在小程序管理后台添加体验成员,把指定版本设为体验版,然后把体验版二维码发给用户。体验版不受预览码的单会话限制,多个用户可以同时使用,但体验成员数量有上限,需要定期清理不再活跃的账号。
用户反馈收集我们做了一个很轻的方案:小程序设置页面放一个“意见反馈”按钮,点击后调用微信自带反馈入口,并引导用户填写手机号和问题描述。产品侧还会通过 onShow 和 onHide 记录用户在行程页的停留时长,用于判断功能的易用性。但要注意隐私合规,任何位置数据的采集都必须在小程序隐私协议中明确说明用途,不能悄悄收集、不能永久保存,我们内部定的规矩是轨迹数据最多保留 30 天自动清理。
5.3 可以继续扩展的几个方向
这套系统如果继续做下去,有几个方向是已经验证过可行性的。第一个是室内蓝牙定位导览,在景区展馆内部署蓝牙信标,Android 端扫描信标后上报位置,小程序端展示“你现在在青铜器展厅,距离下一展品 5 米”,这个功能对博物馆类景区很有价值。第二个是 AI 行程话术生成,根据用户每天的轨迹停留时长,自动生成一篇带照片的游记,降低用户发朋友圈的分享成本。第三个是门票的“动态定价”,早鸟票和当天票价格分离,结合库存数据实时调整折扣力度。
这些扩展方向都不需要推翻现有架构,因为核心的 SKU 库存体系、订单状态机、行程数据结构都还是那套,只是在上面加新的业务规则而已。
最后再分享一个我自己印象最深的小技巧:动态二维码的短码一定要用 Redis 的 SETNX 来做防重复映射,短码生成后不能直接存到 MySQL 里等查询,否则高并发下会有一堆 Redis 穿透打到数据库上。把短码和 ticketId 的映射放在 Redis,设置好过期时间,核销时直接查缓存,只有缓存没有命中的订单才回源数据库。这个改动让验票接口的平均响应时间从 300 毫秒降到了 50 毫秒左右,景区工作人员拿扫码枪一扫就出结果,体验完全不一样。技术选型一开始就考虑到“票务系统峰值集中在节假日的上午”,所以防穿透从一开始就是必选项,当时多花的这部分心思,上线后回头看很值得。