
去年年初我接到一个需求做一套智能旅游管家系统。用户出门旅行前最头疼的往往不是订机票酒店而是“到了目的地到底怎么玩、门票怎么买”。好几个朋友跟我抱怨过上午十点才到景区门口结果当天的票早卖完了只能对着电子屏干瞪眼。这个痛点很具体于是我们把产品定位成一句话把行程规划和景点购票揉到一个微信小程序里扫开就用不用下载安装包。项目代号内部叫“口袋导游”。从需求评审到第一版上线前后大概花了三个多月。整个系统是典型的“小程序前端 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 和 FileProvidergrantUriPermission 给临时读取权限自定义导航栏在个别机型顶部重叠状态栏高度获取方式错误统一用 getMenuButtonBoundingClientRect 反推不要写死 pxH5 页面唤起小程序提示无法访问域名未配置到业务域名白名单在公众平台配置业务域名首次唤起需要用户曾打开过小程序这个表看着简单每一条背后都是真实事故。尤其是动态二维码失效那条差点在五一假期造成大批游客堵在闸机口后来我们意识到景区验票的手机用的是一台多年前的老设备系统时间快了将近两分钟导致新码生成后旧码还在生效时间差内被判定异常。从那以后我们规定验票端每次启动都要做一次 NTP 时间同步。5.2 体验版分发与用户反馈收集微信开发者工具生成的小程序预览码虽然方便但只适合开发者在真机上快速验证。要给外部用户试用几天收集反馈必须走体验版流程在小程序管理后台添加体验成员把指定版本设为体验版然后把体验版二维码发给用户。体验版不受预览码的单会话限制多个用户可以同时使用但体验成员数量有上限需要定期清理不再活跃的账号。用户反馈收集我们做了一个很轻的方案小程序设置页面放一个“意见反馈”按钮点击后调用微信自带反馈入口并引导用户填写手机号和问题描述。产品侧还会通过 onShow 和 onHide 记录用户在行程页的停留时长用于判断功能的易用性。但要注意隐私合规任何位置数据的采集都必须在小程序隐私协议中明确说明用途不能悄悄收集、不能永久保存我们内部定的规矩是轨迹数据最多保留 30 天自动清理。5.3 可以继续扩展的几个方向这套系统如果继续做下去有几个方向是已经验证过可行性的。第一个是室内蓝牙定位导览在景区展馆内部署蓝牙信标Android 端扫描信标后上报位置小程序端展示“你现在在青铜器展厅距离下一展品 5 米”这个功能对博物馆类景区很有价值。第二个是 AI 行程话术生成根据用户每天的轨迹停留时长自动生成一篇带照片的游记降低用户发朋友圈的分享成本。第三个是门票的“动态定价”早鸟票和当天票价格分离结合库存数据实时调整折扣力度。这些扩展方向都不需要推翻现有架构因为核心的 SKU 库存体系、订单状态机、行程数据结构都还是那套只是在上面加新的业务规则而已。最后再分享一个我自己印象最深的小技巧动态二维码的短码一定要用 Redis 的 SETNX 来做防重复映射短码生成后不能直接存到 MySQL 里等查询否则高并发下会有一堆 Redis 穿透打到数据库上。把短码和 ticketId 的映射放在 Redis设置好过期时间核销时直接查缓存只有缓存没有命中的订单才回源数据库。这个改动让验票接口的平均响应时间从 300 毫秒降到了 50 毫秒左右景区工作人员拿扫码枪一扫就出结果体验完全不一样。技术选型一开始就考虑到“票务系统峰值集中在节假日的上午”所以防穿透从一开始就是必选项当时多花的这部分心思上线后回头看很值得。