我会先明确一个前提:前端在支付链路里不是决策者,只是发起者和状态展示者。所有金额、库存、订单状态的判定必须在服务端,前端拿到的一切都只是「服务端告诉我的当前状态」。想清楚这一点,方案的边界就清楚了。
链路拆成五步:选商品和币种 → 创建订单 → 创建支付单并唤起渠道 → 支付结果确认 → 发货展示。
创建订单这一步,前端只传商品 ID、数量、币种和一个前端生成的幂等号,绝对不传金额。价格由服务端按当前汇率和地区策略计算,返回给前端展示确认。这样即使有人改了前端请求,也影响不到实际扣款金额。订单创建成功后返回订单号和「已锁定的价格」,同时带一个价格有效期(比如 15 分钟),因为国际业务的汇率是浮动的,必须锁价,不然用户停留半小时后支付,按哪个汇率算就成了纠纷。
创建支付单是独立的一步,不要和创建订单合并。因为一个订单可能换渠道重试支付多次(先用信用卡失败了,再换 PayPal),订单只有一个,支付单可以有多个。合并的话重试逻辑会非常难写。这一步返回渠道所需的参数,前端拿去唤起支付。
唤起支付要按端区分:小程序端调 uni.requestPayment;App 端可能是跳原生 SDK 或者跳外部浏览器;H5 端是跳转到渠道页面。这里的关键是各端返回结果的可信度都不一样,所以下一步才是重点。
结果确认是整条链路里最容易做错的地方:不能相信渠道 SDK 的回调结果来判定业务成功。渠道回调说成功,只代表用户完成了付款动作,不代表你的服务端已经收到通知、验签通过、订单已发货。渠道回调说失败或取消,也可能实际上钱已经扣了(比如用户付完之后 App 崩了)。所以正确做法是:无论渠道返回什么,都以查询自己服务端的订单状态为准。
发货展示:虚拟商品通常是即时发货(卡密、账号充值),但服务端发货也可能有延迟或失败,所以订单状态要区分「已支付」和「已发货」两个阶段,前端要能展示「支付成功,正在发货」这个中间态,而不是只有成功和失败两种。
最后是异常路径的设计,这块的完整度最能体现水平:支付过程中用户杀掉 App、切后台、网络断掉、返回键退出,每一种情况回到 App 时都要能恢复到正确状态——做法是把「有一笔待确认的支付」记在本地,应用启动和从后台恢复时主动去查一次,避免用户看到一个卡住的中间态。
这题的根子在一个认知错误上:前端超时不等于业务失败,它只等于「结果未知」。分布式系统里请求超时有三种可能——请求没到服务端、到了但还在处理、处理完了响应丢在回来的路上。前端无法区分这三种,所以任何「超时就当失败」的处理都是错的。国际业务跨洋链路 RTT 高、丢包重传,这个问题会被放大得非常明显。
我会分四层来解决。
第一层:把「失败」和「未知」在状态机里分开。支付按钮的状态不能只有 idle 和 loading,至少要有四个:idle(可点击)、submitting(请求在飞)、pending(结果未知,需要确认)、settled(已确认成功或失败)。超时之后进入的是 pending 而不是 idle——按钮绝对不能恢复成「立即支付」,而应该变成「正在确认支付结果」并且禁用,同时给一个「刷新状态」的手动入口。这一条是最关键的修复,光改这一点就能挡掉绝大部分重复支付。
第二层:幂等号必须在用户点击前生成并固定下来。同一笔支付意图从头到尾用同一个幂等号,超时重试也用它。服务端拿这个号做去重,重复请求直接返回第一次的结果,而不是新建一笔。
// 错误:每次点击都新生成,重试等于新请求
const submit = () => api.pay({ orderId, requestId: uuid() })
// 正确:一笔支付意图对应一个固定的 requestId
let payToken = null
const submit = () => {
if (!payToken) payToken = uuid() // 只生成一次
return api.pay({ orderId, requestId: payToken })
}
// 只有在用户明确放弃、重新下单时才清空 payToken
第三层:结果以查询为准,不以请求返回为准。进入 pending 之后启动轮询订单状态接口,间隔递增(1s、2s、3s、5s…),总时长给到一两分钟。查到终态就更新 UI,一直查不到就提示用户「支付结果确认中,请稍后在订单列表查看」,并且把这笔待确认支付写进本地存储,下次进入应用时继续确认。这样即使用户当场关掉了页面,状态也不会丢。
第四层:兜底。前面三层都是降低概率,但不能保证百分之百。所以服务端必须有能力识别并处理重复支付:同一订单收到第二笔成功支付时,自动对多余的那笔发起退款,并且记录告警。同时对账系统要能发现「一个订单对应多笔渠道流水」的情况。这一层必须有,因为前端的任何防护都可能被绕过——用户开两个设备、清缓存重进、或者干脆手动构造请求。
另外几个配套细节。超时时间要按国际网络放宽,支付类接口不要设成和普通接口一样的 5 秒或 10 秒,可以给到 30 到 60 秒,并且区分连接超时和读取超时。不要对支付创建接口做自动重试,自动重试会成倍放大问题,重试的决定权应该交给带幂等号的显式逻辑。UI 文案要谨慎,超时时说「结果确认中」而不是「支付失败」,用户看到「失败」的第一反应就是再点一次,文案本身就是一道防线。
先明确目标:拿到服务端确认过的最终状态,而不是尽快给用户一个反馈。这两个目标冲突时,正确性优先。
轮询是必须有的基础方案,因为它最可靠、不依赖任何额外基础设施,各端行为一致。做法是查订单状态接口,间隔递增退避而不是固定 1 秒——固定短间隔在高并发下会给服务端造成明显压力(想象大促时几万人同时轮询)。同时要设总时长上限,超时后转成「稍后查看」而不是无限查下去。
长连接(WebSocket)体验最好,服务端收到渠道通知后主动推送,延迟最低。但它有几个现实问题:小程序端 WebSocket 有连接数限制、后台运行会被断开、跨国网络下连接稳定性差、需要额外维护心跳和重连。所以它只能作为优化项而不是唯一手段——推送到了就快速响应,没到就靠轮询兜底。这种「推拉结合」是实践中比较稳的组合。
渠道 SDK 的同步返回只能用来做UI 提示的加速,不能作为业务判定依据。它返回成功时,可以立刻显示「支付成功,正在确认」并加快一次轮询;返回失败或取消时,也不能直接判定失败,仍然要查一次服务端——因为用户可能在渠道页面完成了支付但回跳失败,或者 SDK 的取消回调触发了但钱实际扣了。这个细节很多实现都漏了,导致「明明付了却显示取消」的客诉。
还有一个容易忽略的路径:用户从支付页直接退出,去了别的页面甚至杀了应用。这时候前面那些机制都断了。所以需要两个补充:订单列表和详情页每次进入都拉最新状态;应用启动和从后台恢复时,检查本地是否有待确认的支付,有就补一次查询。
我的最终组合是:SDK 返回加速 UI + 轮询保证正确 + 长连接可选优化 + 启动补偿兜底。四层里轮询和补偿是必需的,另外两个是体验优化。这个分层的思路比单选一种方案更有说服力。
金额绝对不在前端做运算和转换。这是第一原则。前端只负责按服务端给的数据展示,任何汇率换算、税费计算、优惠计算都在服务端完成。原因很直接:前端算的结果不可信,而且浮点运算会有精度问题,跨货币场景下差一分钱就是对账事故。
金额的传输格式用「整数最小单位加货币代码」,比如 { amount: 1999, currency: "USD" } 表示 19.99 美元。不要用浮点数传金额。这里有个国际业务特有的坑:不同货币的小数位数不一样——美元欧元是 2 位,日元韩元是 0 位,科威特第纳尔是 3 位。所以不能硬编码「除以 100」,要按货币代码查小数位数。这个细节没处理过的人一定会踩。
展示格式要用平台的国际化 API 而不是手写。Intl.NumberFormat 能正确处理货币符号位置、千分位分隔符、小数位——注意有些地区用逗号做小数点(德国是 1.234,56),符号有时在前有时在后。小程序端如果 Intl 支持不完整,就要引轻量的格式化库或者让服务端直接下发格式化好的展示字符串(这个方案最省事也最不容易错)。
汇率和锁价是核心业务点。用户看到的价格必须和最终扣款一致,所以下单时服务端要锁定汇率并给出有效期,前端要展示剩余时间,过期后重新拉价格并让用户重新确认。绝对不能出现「确认页显示 19.99,扣款 20.15」的情况,这在国际支付里会直接引发投诉和拒付。
多语言方面,文案用 i18n 方案(vue-i18n),语言包按需加载而不是全部打进包——十几种语言全打进主包会明显拖慢启动。语言的确定优先级是:用户手动设置 > 账号偏好 > 系统语言 > 地区默认。
另外几个真实会遇到的点:布局要能容忍文案长度变化,德语法语通常比中文长 50% 以上,按中文文案设计的按钮会被撑破,所以不能写死宽度。阿拉伯语希伯来语是从右到左的(RTL),需要整体镜像布局,这个如果一开始没考虑,后期改造成本很高——早期就用逻辑属性(padding-inline-start 而不是 padding-left)会省很多事。时间要按用户时区显示,服务端统一传 UTC 时间戳,前端本地化展示;订单时间、活动截止时间如果搞错时区,用户会觉得你在骗他。
先摆正定位:前端做的一切都是「提高攻击成本」和「提供风控信号」,不承担安全责任。任何认为前端能保证安全的设计都是错的,因为前端代码完全在用户手里,请求可以任意构造。
金额防篡改的根本做法在服务端:创建订单时前端只传商品 ID 和数量,价格由服务端算;支付时服务端拿订单里存的金额去和渠道交互,不使用前端传来的任何金额。前端能做的是展示服务端返回的金额,以及在确认页做一次「展示金额和订单金额一致」的校验(这只能防显示错误,不防攻击)。
前端提供的风控信号是有实际价值的部分,尤其国际业务的盗刷和欺诈风险远高于国内。可以采集:设备指纹(设备型号、系统版本、屏幕参数等组合出的稳定标识)、IP 和地区、语言时区(时区和 IP 地区不一致是个典型可疑信号)、行为特征(页面停留时长、是否有正常的浏览路径、输入速度)、是否越狱或模拟器环境。这些数据传给服务端做综合评分,命中规则就触发二次验证或者人工审核。
敏感信息不落前端:银行卡号、CVV 这类绝对不能经过自己的服务器,要用渠道提供的 SDK 或者托管页面直接收集(PCI-DSS 的合规要求),前端只拿到一个 token。这一点在国际支付里是硬性要求,不是选择题。
接口层面的防护:请求签名(防篡改和重放)、时间戳加随机数(防重放)、关键接口加图形验证或行为验证(防脚本刷单)。签名的密钥不能硬编码在前端——虽然完全藏住做不到,但至少不要明文写在代码里让人一眼找到。
还有个业务侧的实际问题值得提:虚拟商品的欺诈成本极低,盗刷者拿到卡密立刻就能变现,之后发起拒付(chargeback),平台既损失了商品又要承担手续费。所以前端要配合的还有发货前的风控拦截展示——高风险订单支付成功后不立即发货,页面要能展示「订单审核中」这个状态,而不是让用户以为出了 bug 反复刷新或者重复下单。这类状态的完整设计往往比技术手段更能体现对业务的理解。
虚拟商品的退款比实物麻烦,因为商品已经被消费了就无法收回,所以流程设计上必须先判定「能不能退」,再走退款。
入口和状态:订单详情里根据服务端返回的可退状态和可退金额决定是否展示退款按钮,不要在前端自己算规则(比如「未使用且 7 天内可退」)。规则会变,而且各地区法规不同(欧盟有 14 天无理由退货权,但数字商品一旦交付可以排除),写在前端就意味着改规则要发版,还可能和服务端判定不一致。
申请流程:选择退款原因(原因要影响后续走向,比如「卡密无效」需要走客服核实、「重复购买」可以自动通过)、上传凭证、提交。提交要幂等,防止用户连点产生多条退款申请——这个和支付的幂等是同一个问题。
状态展示是这题的重点。退款是异步且多阶段的:申请中 → 审核通过 → 已提交渠道 → 渠道处理中 → 退款成功/失败。国际支付的退款到账时间很长,信用卡通道常见 5 到 15 个工作日,所以前端必须明确展示预计到账时间和当前所处阶段,否则用户三天没收到钱就会来投诉,甚至直接找发卡行发起拒付——那对平台的伤害比退款大得多。这个「用清晰的状态展示降低客诉和拒付率」的思路是业务价值点,值得说出来。
部分退款要支持:多个商品的订单只退其中一件、或者只退部分金额。前端要能展示明细级别的退款状态,不能只有订单级别。
几个实际细节:退款金额可能和支付金额不同,因为汇率变动、手续费不退、或者优惠券分摊,所以必须展示服务端给的准确金额并说明差异,不能自己用原价算。退款失败要有清晰的后续路径(比如原卡已失效需要用户提供新的收款方式),不能只显示一个「失败」让用户无从下手。退款进度变更要有消息通知,用户不会一直盯着页面。
核心矛盾是流畅需要预加载,内存要求少加载,方案就是在这两者之间找平衡点。
渲染层面用「窗口 + 复用」。不管数据有几百条,同时只保留少量视频组件实例,一般是当前页加上前后各一到两个,一共三到五个。滑出窗口的实例要销毁或者复用,绝对不能只是隐藏——隐藏的视频组件仍然占内存,而且在小程序里原生视频组件的数量本身有限制,堆多了会直接播不出来。实现上可以用 swiper 配合动态渲染窗口内的项,或者自己实现虚拟列表。
播放控制:只有当前项播放,其他全部暂停并释放播放器资源。切换时立刻暂停上一个,避免出现两个视频同时有声音(这个 bug 在快速滑动时特别容易出现)。判断「当前项」用 IntersectionObserver 而不是滚动事件计算,前者在渲染层判断、不需要频繁跨线程通信,这在小程序里是明显的性能差异。
预加载策略要分层,这是体验的关键。数据预加载:滑到倒数第三条时就请求下一页,不要等到最后一条。封面预加载:下一个视频的封面图提前加载,这样切过去立刻有画面而不是黑屏。视频预加载:只预加载下一个视频的前几秒,不要整段预载——跨国带宽贵且不稳定,整段预载既浪费流量又占内存,用户可能压根不看。
跨国场景的额外考虑:首屏视频的清晰度要根据实测网速动态选择,弱网先给低清让它能播起来,再无感切换到高清。因为「等三秒看高清」的体验远差于「立刻看到低清」,这是短视频的核心体验指标。CDN 要按地区就近调度,域名和线路要能动态配置,出问题时可以远程切换而不用发版。
其他实际细节:数据去重,翻页时后端推荐结果可能重复,前端要按视频 ID 去重,否则用户会看到重复内容;播放进度上报要节流合并,不要每秒发一次请求;点赞等交互用乐观更新,先改 UI 再发请求,失败回滚,因为等接口返回再变化的体验很迟钝。
关键约束先摆出来:视频文件动辄几十上百兆,跨国网络下一次性上传几乎必然失败,而且移动端随时可能切网、锁屏、被系统回收。所以必须分片。
整体流程:本地选择并校验 → 初始化上传任务拿到唯一 ID → 分片上传 → 通知合并 → 服务端转码 → 轮询转码结果 → 发布。
分片大小要按网络调整,一般 1 到 5MB。太小则请求数太多、握手开销占比高;太大则单片失败重传的代价高。跨国弱网倾向小一点。分片要并发上传但限制并发数(三到四个),既利用带宽又不至于全部超时——注意小程序有并发请求上限,这里必须自己控。
断点续传的做法是:初始化时服务端返回上传 ID,前端把上传 ID 和已完成的分片索引存进本地。中断后重进,先拿上传 ID 去问服务端「哪些分片已经收到了」,然后只传缺的。要以服务端的记录为准而不是本地记录,因为本地记为成功的分片可能实际没落盘。
秒传是顺手能拿的优化:上传前先算文件的哈希(大文件可以只算首尾若干块加文件大小的组合哈希,全量算太慢),问服务端有没有相同文件,有就直接关联,不用传。国际平台上同一个视频被多人搬运上传的情况很常见,这个优化的实际命中率不低。
转码必须理解成异步的、可能失败的一步:上传完成不等于可以播放。所以前端要展示「上传完成,正在处理」,并轮询转码状态。转码失败要给出可理解的原因(格式不支持、时长超限、分辨率过高),而不是笼统的「失败」。
各端差异要注意:小程序有单个文件大小限制和上传接口限制,超大文件可能压根传不了,需要引导用户用 App 或 Web;App 端可以做后台上传,锁屏切后台也继续(要处理系统的后台执行时长限制);选择文件时可以先做本地压缩,把码率和分辨率降到合理范围,能大幅减少上传量和失败率——这一步对弱网用户的成功率提升最明显。
最后是体验细节:进度要真实(按已完成分片算,不要假进度条)、失败要能单片重试而不是整体重来、要能取消并清理服务端的临时分片、退出页面时要提示「上传中,确定离开吗」。
跨国场景和国内的差别是:RTT 高(几百毫秒是常态)、丢包率高、网络质量波动大、还可能遇到区域性的链路故障。所以策略要围绕「容忍慢」和「容忍失败」来设计,而不是假设网络可靠。
超时要分级而不是全局统一。普通查询接口可以 10 秒,视频上传可以几分钟,支付类接口要放宽到 30 到 60 秒。全局一个超时值必然出问题:设短了支付被误判失败(前面那道题的根源),设长了普通接口卡住用户几十秒。另外要区分连接超时和读取超时,连接超时可以短一些,因为连不上通常重试也没用。
重试要区分接口的幂等性。GET 和明确幂等的接口可以自动重试,写操作尤其支付创建绝对不能自动重试,只能靠带幂等号的显式逻辑。重试要用指数退避加随机抖动,避免所有客户端在同一时刻一起重试把服务端压垮(这个「重试风暴」在区域性故障恢复时很致命)。重试次数控制在两三次。
降级和兜底。关键页面要有本地缓存,请求失败时先展示上次的数据加一个「数据可能不是最新」的提示,比直接白屏或者报错好得多。非关键请求(埋点、推荐、广告)失败就静默放弃,不要影响主流程,更不要弹错误提示。
链路层面的优化:静态资源和视频走 CDN 就近节点;API 域名支持多个备用域名和动态配置,某个域名或线路出问题时可以远程切换、客户端自动探测择优,这在跨国业务里是必备能力,因为你控制不了中间链路;有条件的话上 HTTP/2 甚至 HTTP/3,后者的连接迁移和抗丢包能力在弱网和网络切换场景下提升很明显。
请求量本身也要优化:合并接口减少往返次数(高 RTT 下减少一次往返比减小报文更有价值)、开启压缩、避免串行请求链(前一个返回才发下一个,在 RTT 300ms 的情况下三个串行就是接近一秒)。
最后是可观测性:要采集各地区、各运营商的接口成功率和耗时分布,否则「某个国家的用户支付失败率很高」这种问题你压根发现不了,只能等客诉。这个监控是跨国业务的基础设施,不是可选项。
用乐观更新:点击立刻改本地状态和计数,同时发请求,失败再回滚并提示。因为这类操作的成功率极高,等接口返回再变化会让交互显得很迟钝,而短视频场景对反馈速度极敏感。
几个必须处理的问题。
连点和抖动:用户快速点五下,不能发五个请求。做法是状态合并加防抖——记录「用户期望的最终状态」,延迟几百毫秒后只发一次请求,把最终状态提交上去。注意这里用防抖而不是节流,因为我们只关心最后的状态。同时要处理「点了又取消」的情况,最终状态和服务端一致时压根不用发请求。
回滚要精确:失败时要回滚到请求发起前的状态,而不是简单取反——因为期间用户可能又点了几次。所以要把请求和它对应的状态版本关联起来,过期的响应直接丢弃。这个和「请求乱序」是同一类问题:先发的请求后返回,如果不做版本判断,旧响应会覆盖新状态。
幂等:服务端的点赞接口要设计成幂等的「设置为某个状态」而不是「切换状态」。如果是切换语义,网络重试会导致状态翻转——用户点了赞,超时重试了一次,结果变成取消赞。这个坑很隐蔽。
计数的展示要接受不精确。高并发计数在服务端往往是异步聚合的,前端拿到的数字可能有延迟。所以本地自增的数字和服务端返回的数字可能不一致,处理原则是:自己的操作立刻在本地体现,服务端返回的权威值到了就覆盖,但要避免数字来回跳变(可以在短时间内以本地值为准)。大数字用「1.2万」这类缩写展示,也能掩盖小的不一致。
登录态:未登录用户点赞要能先记住意图,登录成功后自动补上这个操作,而不是让他登录完再点一次——这是转化率相关的细节。
先把「秒开」拆成用户能感知的三个指标:多久看到骨架、多久看到主要内容、多久能交互。优化要分别针对这三段,笼统地说「优化性能」是没法落地的。
第一段:立刻有反应。骨架屏是必须的,而且要在数据请求之前就渲染出来,不能等请求回来再切。这一步不减少实际耗时,但把白屏变成了有结构的等待,感知上的差别很大。
第二段:主要内容尽快到。几个手段:请求提前发起,从列表页跳详情页时,如果列表已经有了标题、价格、封面,就把这些数据带过去先渲染,详情请求回来再补全剩余部分——用户几乎感觉不到等待。接口拆分,把首屏必需的数据和次要数据(评论、推荐、库存明细)拆成两个接口,首屏那个尽量瘦,次要的延后请求。本地缓存,商品基础信息缓存一段时间,进页面先渲染缓存再静默更新,这个对回访用户效果最明显。
第三段:能交互。把非首屏的组件延后渲染(v-if 控制或者滚动到可视区再渲染),减少初始节点数。图片按显示尺寸加载并懒加载,首图可以用小尺寸模糊图占位再换高清。
跨国场景的额外重点:减少请求往返次数比减小报文更重要,因为 RTT 可能是几百毫秒。所以宁可一个接口返回稍大的数据,也不要为了「接口职责单一」拆成三个串行请求。另外静态资源和图片必须走 CDN 就近节点,域名要预连接。
小程序特有的:主包体积直接影响启动速度,详情页如果在分包里,要配分包预下载——在用户还在列表页的时候就把详情页所在的分包下好。还可以用平台的数据预拉取能力,让平台服务器在小程序启动前就帮你请求接口。
最后强调先测量:用真机的性能面板和线上监控看具体每一段的耗时分布,特别要看各地区的分位数而不是平均值。很多时候瓶颈压根不在前端,而是某个接口在特定地区慢,那优化渲染完全是白费功夫。
先支持游客态,这是国际业务里的转化率关键——强制注册才能浏览会流失掉大量用户。做法是首次启动生成一个设备级的游客标识并向服务端换取一个游客 token,可以浏览、可以加购物车、可以点赞,只在支付和发布这些必须实名的动作上才引导登录。
游客数据的迁移是重点:登录成功后要把游客期间产生的数据(购物车、浏览历史、点赞)合并到正式账号。这一步要在服务端做,前端只传游客标识。要处理冲突(正式账号已有购物车怎么合并)和幂等(合并接口可能重试)。这个细节做不好,用户会发现「登录之后购物车空了」,直接影响下单转化。
登录方式国际业务通常要支持多种:邮箱密码、手机号、第三方(Google、Apple、Facebook)。注意 Apple 登录在 iOS 上是强制要求——如果你提供了其他第三方登录却没提供 Apple 登录,App Store 会拒审。第三方登录要处理同一个人用不同方式登录如何识别为同一账号的问题,一般靠邮箱匹配加上手动绑定流程。
token 方案用双 token:短效的 accessToken 加长效的 refreshToken,前端在请求拦截器里做无感刷新,并处理好并发请求同时 401 时只刷新一次(用一个刷新中的标志加等待队列,否则会并发调多次刷新接口,后面几次还可能因为 refreshToken 已被使用而失败)。
多端同步:同一账号在多设备登录,要考虑是否允许并发登录(视频平台一般允许,但要限制数量防止账号共享),以及被顶下线的处理——收到踢出通知要清理本地状态并跳登录页,不能让用户在一个已失效的状态里继续操作到处报错。
安全细节:token 存储要用平台的安全存储;退出登录要调服务端接口作废 token而不只是删本地(否则 token 在有效期内仍可用);敏感操作(改密码、绑卡、大额支付)要二次验证。
目标分两类:技术监控(发现和定位问题)和业务埋点(分析用户行为和转化)。两者的采集方式和关注点不同,但可以共用上报通道。
技术监控要采集:JS 错误和未捕获的 Promise 异常(带上堆栈和用户操作路径)、接口成功率和耗时(按接口、地区、网络类型分组)、页面性能(启动耗时、首屏时间、页面切换耗时)、白屏和崩溃、以及关键业务链路的分步转化——比如支付链路的「点击支付 → 创建订单成功 → 唤起渠道 → 支付成功」每一步的到达率,哪一步掉得多就知道问题在哪。
这里最有价值的是链路埋点。报错监控只能告诉你「有异常」,链路埋点能告诉你「用户在哪一步流失了」,后者才能定位那种「不报错但用户付不了款」的问题。国际业务尤其需要按地区维度看这些数据,因为某个国家的支付渠道或网络出问题,全局指标上看不出来。
业务埋点方面,尽量用声明式的方案(比如给元素加属性,由框架统一采集曝光和点击),而不是在业务代码里到处手写上报调用——后者会让代码越来越脏,而且容易漏埋和重复埋。曝光埋点用 IntersectionObserver 做,不要用滚动事件。
上报策略要注意成本和性能:批量合并加延迟上报,不要每个事件一个请求;用队列在本地攒着,达到一定数量或时间间隔再发;页面关闭前要尽力发出(各端有不同的机制);失败要有本地重试,但要限制存储量避免无限堆积。埋点请求失败绝对不能影响主流程。
采样:错误全量上报,性能和行为数据可以按比例采样,但要保证错误和慢请求必采,否则最需要的现场恰好没采到。
最后是数据要能定位到人:上报要带用户 ID、设备信息、版本号、以及一个能串起整条链路的 traceId,并且这个 traceId 要透传给后端。这样用户来投诉时,凭订单号或用户 ID 就能拉出他当时的完整操作路径和每个接口的响应,这是排查线上问题效率的分水岭。
要按端分开说,因为各端的更新机制完全不同,这也是跨端项目的一个管理难点。
小程序端:平台会自动在后台更新,但当次启动用的还是旧版本。所以要用更新检查 API,发现有新版本时提示用户重启应用。要注意强制更新的时机——如果新版本改了接口协议,旧版本会直接出错,这种情况必须强更;否则应该温和提示,避免打断用户操作。另外小程序有按比例灰度发布的能力,新版本先放 10% 流量观察指标再全量,这是最重要的风险控制手段。
App 端:分整包更新和资源热更新。热更新只能改 JS 和资源,改了原生插件必须发整包,这个边界要判断准确,判断错了会导致新代码跑在旧的原生壳上出现诡异问题。热更新要有包完整性校验和失败回滚,否则一个坏包推下去会造成大面积白屏,而且用户没法自己恢复。
合规边界必须知道:苹果不允许通过热更新改变应用的主要功能,热更新的正当用途是修 bug 和小改动。用它绕审核上新功能会有下架风险。这条不是技术问题但比技术问题严重。
灰度的维度:可以按用户 ID 取模、按地区、按设备类型、按版本号。国际业务我会倾向先在小市场灰度再推主力市场,出问题影响面小。灰度期间要盯住关键指标——崩溃率、接口成功率、支付成功率、核心链路转化率,任何一个明显下跌就立刻回滚。
配置化和开关是配套的重要能力:新功能上线时用远程开关包起来,出问题可以立刻关掉而不用发版。这个能力在 App 端尤其关键,因为发版要过审核,等不起。
最后是多端版本协同:同一个功能在小程序、App、Web 上的发布节奏不同(App 要审核,小程序可以随时发),所以后端接口必须兼容旧版本前端,不能假设所有用户都升级了。这一点在跨端项目里经常出事——改了接口字段,小程序端好了,App 端老版本用户全崩。
直播间的前端难点是高频消息和资金操作混在一个页面里,两者的可靠性要求完全不同,必须分开处理。
资金链路(打赏)要求正确:点击送礼要防重复提交(幂等号)、余额不足要提前拦截、扣款结果以服务端返回为准。连击要在前端聚合——用户连点 20 下,不能发 20 个请求,要在本地累加后合并成一次请求带数量,否则既打爆接口又可能重复扣款。
消息链路(弹幕、礼物特效、进场提示)要求流畅,可以丢可以合并。关键设计是限流和降级:热门直播间每秒可能有几百条消息,全部渲染必然卡死。做法是本地维护一个消息队列,按固定速率消费渲染(比如每秒最多渲染 10 条),超出的丢弃或者只保留高价值的(大额礼物、房管消息)。弹幕列表要限制最大条数(保留最近 50 条),超出的从 DOM 里移除,否则节点无限增长。
特效动画是性能大头:大额礼物的全屏动画要排队播放而不是同时播,用 CSS 动画或者 Lottie 而不是频繁的 JS 计算,动画元素用 transform 触发 GPU 加速。小程序端要特别注意——原生组件层级问题会让特效盖不住视频,可能需要 cover-view 或者同层渲染。
长连接管理:进入直播间建连、离开断开;要处理切后台被断开后回来重连;重连后要拉一次最新状态(在线人数、礼物榜)而不是等推送。心跳和重连要有指数退避,避免服务端故障时所有客户端疯狂重连。
播放器:直播流的首帧速度直接影响留存,要预加载和低延迟配置;弱网要能自动降码率;断流要有重试和友好提示。国际业务要注意就近选择拉流节点。
合规相关的前端配合:未成年人消费限制——需要年龄验证入口和消费提示,部分地区法规强制要求;消费提醒(累计消费达到阈值时提示);充值和打赏的金额展示要清晰(虚拟币和法币的换算关系要明示,这也是法规要求)。这几条不是技术难点但优先级很高。
核心是「本地数据库 + 增量同步」的思路,不要每次进会话都从服务端拉全量。
本地存储:消息存本地(小程序用 storage 有容量限制,App 端可以用 SQLite 插件),每个会话记录本地最大消息序列号。进入会话时先渲染本地消息(瞬间打开),再按序列号拉增量。这是 IM 体验好坏的分水岭——从服务端拉全量的方案在网络差时会一直转圈。
消息发送要本地回显:点发送立刻在列表里显示(状态为「发送中」),拿到服务端确认后改成「已发送」,失败标红并提供重发。客户端要生成唯一消息 ID,这样重发时服务端能去重,也能把服务端返回的正式消息和本地那条对应上(不然会出现重复显示)。
顺序和去重:消息按序列号排序而不是按接收时间,因为推送可能乱序到达。插入前用消息 ID 去重。如果发现序列号有缺口(收到 105 但本地只到 100),要补拉中间的,不能直接显示导致消息缺失。
未读数:不要每次都去服务端算,用「会话最大序列号减去本地已读序列号」本地计算,进入会话时上报已读。要处理多端已读同步——在手机上读了,小程序端的未读数也要清掉,这靠服务端推送已读位点。
列表性能:会话列表和消息列表都可能很长,消息列表要虚拟滚动或者分页加载历史(向上滚动加载更早的消息,注意加载后要保持滚动位置不跳)。图片和视频消息要懒加载并限制缓存,否则聊天记录一多内存就爆。
长连接:进入应用建连,切后台断开(小程序端切后台会被强制断开),回到前台重连并拉增量补齐离线消息。不能只依赖推送——推送丢了消息就不显示了,必须有「进入会话拉增量」和「前台恢复拉增量」两个兜底。
内容安全的前端配合:发送前的本地敏感词提示、举报入口、拉黑后的会话处理。国际业务还要注意多语言的输入法兼容(阿拉伯语的 RTL 输入、表情和特殊字符的显示)。
SKU 选择的核心是「可选性计算」:用户选了「面额 100 元」之后,哪些「地区」选项还可选(有库存的组合)?这个逻辑写错会让用户选到一个不存在的组合然后下单失败。
实现方式:后端返回所有可用 SKU 组合的列表(每个组合的属性值、价格、库存)。前端根据当前已选的属性,反向计算每个属性值是否还有可达的组合——不可达的置灰。这个计算在属性维度多的时候要注意性能,可以预先建立索引(用属性值组合的位图或者 Map 加速)。
虚拟商品的特殊性:属性通常是面额、地区、有效期这类,而且地区限制很关键——某个游戏点卡只能在特定区服使用,选错了用户买了用不了会引发大量客诉和退款。所以要明确展示适用地区并做二次确认,甚至根据用户的 IP 归属给出提示。
价格展示:多币种下要展示用户所在地区的货币和价格,由服务端返回格式化好的价格或者前端按币种规则格式化(注意日元没有小数位)。有优惠时要展示原价和优惠后价格,以及优惠的来源(活动、券)。价格必须是服务端算的,前端不参与计算。
库存展示的策略:虚拟商品的库存是卡密数量。不要展示精确库存(暴露经营数据,而且高并发下精确库存意义不大),用「库存紧张」「充足」这类模糊表达。但要处理「显示有货实际没了」的情况——下单时才是真正的库存校验,前端要能友好处理这个失败。
首屏性能:从列表页跳进来时把已有的数据带过来先渲染(标题、价格、封面),详情接口回来再补全,这样几乎没有等待感。详情接口要拆分——首屏必需的和次要的(评价、推荐、说明文档)分开请求。
其他实用细节:规格选择要能通过 URL 传入(分享带规格的链接、从活动页直接跳到指定 SKU);切换 SKU 时价格和库存要重新拉(不能只用本地缓存的数据,价格可能变了);要展示清楚这是虚拟商品、不可退(合规要求,也减少纠纷)。
瀑布流是内容社区的标志性布局,难点全在高度不确定上:图片没加载完不知道高度,不知道高度就没法决定放在哪一列。
核心算法很简单:维护每一列的累计高度,新的卡片插入到当前最短的那一列。这样各列高度自然趋于均衡。
关键是怎么提前知道高度,三种做法。
最佳方案是后端返回图片的宽高。上传时就把尺寸存下来,列表接口带上。前端根据「容器宽度除以原始宽高比」直接算出卡片高度,压根不用等图片加载。这样一次布局到位、没有跳动、也不需要测量 DOM。这是唯一能做到零抖动的方案,所以设计接口时就要把这个字段加上——很多性能问题的最优解在后端而不是前端。
次选是先渲染再测量:用 uni.createSelectorQuery 异步获取节点高度再决定分列。问题是小程序端的节点查询是跨线程异步的,一批卡片要等一次往返,而且渲染完再调整位置会有可见的跳动。数据量大时这个开销很明显。
兜底方案是给图片固定宽高比(比如统一 3:4,超出部分裁剪)。牺牲了内容展示的完整性,但布局最简单最稳。很多产品实际就是这么做的。
长列表还要配合虚拟化:瀑布流滑几十屏之后节点数会爆掉。但瀑布流的虚拟化比普通列表难得多——因为每列高度不同,回收和复用要按列分别计算可视区间,而且滚动到某个位置时要能算出各列该显示哪些项。所以实现上通常只做「回收远处的节点」而不做完整的双向虚拟化,或者干脆限制加载页数、超过一定量引导用户用筛选。
其他实践细节:图片必须懒加载并按显示尺寸请求(用 CDN 的裁剪参数,不要下原图);加载中要有占位骨架并且占位高度要和实际一致,否则加载完会跳动;下拉刷新要重置列高度;不要用 CSS 的 column-count 实现——它的填充顺序是先填满一列再下一列,和瀑布流「按顺序从上往下」的语义不符,而且没法做虚拟化。
发布页是社区产品里交互最复杂、最容易丢数据的页面,用户可能花十几分钟编辑,一旦丢失投诉极其强烈。
多图上传的要点。选图后立刻开始上传而不是等用户点发布——这样用户写文案的时间被利用起来,点发布时图片已经传完,体感是「秒发」。每张图独立上传、独立展示进度、失败可以单独重试,不能因为一张失败就整个重来。
上传前必须本地压缩。手机拍的照片动辄几兆到十几兆,直接传在弱网下几乎必然失败。按最大边长和质量参数压到几百 KB,肉眼几乎无差别但成功率天差地别。这是提升发布成功率最有效的单一手段。
图片顺序要能拖动调整,而且顺序要和上传完成的顺序解耦——不能因为第三张先传完就排到前面。所以要用本地的临时 ID 维护顺序,上传完成后把返回的 URL 填回对应位置。
草稿是必须做的,不是可选项。触发时机:输入时防抖自动保存(几秒一次,存本地)、离开页面时保存、切到后台时保存。恢复时提示用户「有未完成的草稿,是否继续编辑」。草稿要包含已上传成功的图片 URL,这样恢复后不用重新上传——这个细节对体验影响很大。
本地草稿还要考虑:多草稿管理(用户可能有好几篇没发完的)、容量限制(storage 有上限,草稿不能无限存,要有数量上限和清理策略)、以及是否同步到云端(换设备能继续编辑,但要处理冲突)。
离开拦截:有未保存内容时用路由守卫和页面卸载事件提示确认。但不要过度拦截——已经自动存了草稿的情况下,拦截反而烦人,可以只提示「已存草稿」然后放行。
发布提交的可靠性和支付是同一类问题:提交要幂等(带客户端生成的唯一 ID,防止重复发布出两篇一样的笔记)、超时不能当失败(可能已经发布成功了,要去查状态而不是让用户重发)、提交中要禁用按钮但给明确的进度反馈。
其他要处理的:话题和商品的联想选择、位置选择(社区笔记常带地点)、@ 用户、可见范围设置、以及发布后的审核状态展示——先审后发的话要明确告诉用户「审核中」,否则他会以为发布失败反复重发。
权限是第一道坎,而且各端差异很大。小程序要先 getSetting 查是否已授权,未授权时通过 getLocation 触发系统弹窗;用户拒绝之后再调不会再弹窗,只能引导他去设置页手动开(openSetting)。App 端还要处理系统级权限和「仅本次允许」这类新的权限模式。
被拒绝之后必须有降级路径,这是最容易漏的:不能因为没定位就让页面不可用。降级方案是让用户手动选择城市和地址,或者用 IP 粗定位(精度到城市级)。本地生活业务里没有位置几乎什么都做不了,所以降级体验要认真设计,而不是弹个「请开启定位」就完事。
权限申请的时机很关键:不要一进 App 就申请,那时用户不知道为什么要给,拒绝率很高。要在用户明确需要的时候申请(点「附近的店」、点「定位到当前位置」),并且先用一句话说明用途再触发系统弹窗。这个前置说明能显著提高授权率,是标准做法。
地图选点的交互:可以用平台内置的 chooseLocation(微信小程序有,开箱可用、体验一致),或者自己用地图组件实现(可定制但工作量大)。自己做的话要处理:拖动地图时中心点即选中点(比让用户点击更精确)、逆地理编码把坐标转成地址文本展示、周边 POI 推荐列表让用户直接选。
坐标系是个隐蔽的坑。国内地图有 WGS84(GPS 原始)、GCJ02(国测局,高德腾讯用)、BD09(百度)三套。混用会导致位置偏移几百米,而且偏移是系统性的,看起来「一直偏一点点」。所以要明确各端返回和各服务要求的坐标系,需要时做转换。这个问题在对接不同地图服务时几乎必然遇到。
定位精度和耗电:高精度定位(GPS)耗电大且室内不准,网络定位快但精度低。按场景选——选收货地址要高精度,展示「附近」只需要城市级或几百米精度。不要持续监听位置变化,除非是骑手端这类必须实时的场景,而且要在页面卸载时停止监听。
合规必须提:位置是敏感个人信息,隐私政策要明确说明采集目的,不能在后台偷偷采集。国际业务里 GDPR 明确把位置列为个人数据,违规采集的处罚很实际。iOS 上还要在 Info.plist 里写清用途说明,写得不清楚会被审核拒绝。
先接受一个前提:追求百分之百复用是错的目标。跨端框架能解决的是大部分业务逻辑复用,剩下百分之十几的平台差异必须正面处理。把这部分差异管理好(收敿、可见、可测),比强行统一要现实得多。
分层是核心手段。从下到上:平台适配层(用条件编译封装各端差异的 API)→ 基础能力层(请求、存储、路由、埋点的统一封装)→ 业务逻辑层(纯逻辑,不碰平台 API,这层复用率应该接近 100%)→ UI 层(差异最大,允许分端实现)。
关键原则是「差异只出现在适配层」。业务代码里不应该出现条件编译——一旦业务代码里散落着几十处 #ifdef,维护成本就失控了。做法是把差异封装成统一接口:业务调 storage.set(),适配层内部处理小程序和 App 的差异。
UI 层的差异要务实:能用统一组件的就统一;差异大的(比如导航栏、弹窗、原生控件)按端提供不同实现但保持相同的接口,业务侧调用方式一致。文件级条件编译(index.mp.vue 和 index.app.vue)在这种场景下比在一个文件里写满条件编译要清晰。
要重点管理的差异清单(写进文档,新人必读):样式(小程序不支持部分选择器、组件样式隔离)、原生组件层级(video/map 压不住)、页面栈限制(10 层)、存储容量限制、登录鉴权流程(各平台完全不同)、支付方式、web-view 通信能力(小程序受限、App 完整)、后台运行能力、权限申请流程。
工程上的保障:每端都要有真机测试,不能只测一个端就发;CI 要能构建所有目标端,编译失败立刻发现(有些差异只在编译期暴露);差异相关的代码要有注释说明为什么,否则半年后没人敢动。
还有个现实建议:如果某个端的需求差异特别大(比如 App 端要做很重的原生功能),不要强行塞进同一套代码,拆出来独立开发反而更省事。跨端的价值在于「大部分场景省事」,不在于「所有场景都统一」。这个判断力比技术细节更重要。
关键是把原则定成「假设网络不可用」,而不是「网络不可用时降级」——这个顺序不是措辞差异。如果原则是后者,实现上就是在正常请求流程上加一层 catch、失败时读缓存,而这条路径很少被测试、缓存内容也不完整(它只是按需缓存的副产物);如果原则是前者,缓存是主数据源、网络只是刷新手段,离线路径就是主路径、天天在跑。
第一是主动预缓存而不是按需缓存。按需缓存在这个场景里等于没缓存——用户的行为恰恰是「到了酒店才第一次点开凭证」,那时已经没网。所以要在出行前还有网络的时候(订单确认后、出行前一天)把关键数据下载好:行程详情、地址与联系电话、凭证的码内容、退改政策。而且预热要优先在 Wi-Fi 下执行——境外漫游流量很贵,「有网络」不等于「可以随便用网络」。
第二是凭证要保存「码内容」而不是「码图片」,端上离线渲染。这是最关键的一步:二维码的本质是一串内容加一套编码规则,把内容存下来端上就能自己画,这把凭证从「需要网络的资源」变成了「本地可生成的东西」。代价是要保证端上渲染的码与服务端一致(编码参数、纠错级别要对齐),而且必须真的用扫码设备验,只检查「码显示出来了」是不够的。
第三是渲染一律本地优先(先读缓存渲染、同时后台刷新),不是「先请求失败了读缓存」——后者要等请求超时才有内容,弱网下可能十几秒。
第四是本地优先必须配新鲜度显式标注,两者是一个整体。因为行程数据会变(航班改时间),用户看到旧数据而没有任何提示的后果是误机。要明确显示「更新于 X」与「当前为离线展示」。只做本地优先不做标注,是把风险转移给了用户。
还有两个出示现场的细节:进入出示态要自动提升屏幕亮度并保持常亮(低亮度或强光下扫码设备读不出来,而用户在排队时手忙脚乱调亮度很难受),退出后要恢复原设置;一屏聚合姓名、订单号、电话、当地语言地址——因为扫码失败时工作人员的常规做法是改为手工核验这些字段,只设计成功路径的界面在失败时会把用户推入慌乱。
先指出问题:「明天早上 8 点的航班」这句话本身就有歧义——出发地的 8 点还是目的地的 8 点?如果界面上只写「08:00」,用户会用自己所在地的时间去理解它。
存储上时间一律用「绝对时刻 + 时区标识」,绝不用本地时间字符串。本地时间字符串会随设备时区漂移——用户在出发地看是 8 点,飞到目的地后同一条数据渲染成另一个时间,而航班时间压根没变,用户会以为航班改时间了。而只存绝对时刻(时间戳)也不够:展示时不知道该按哪个时区渲染,按用户当前时区是错的(「航班 8 点起飞」指的是出发机场当地时间)。时间的语义不只是「哪一瞬间」,还包括「按谁的时钟表达」,两者都要存。
展示上做双标注消歧义:跨时区场景同时显示「当地 08:00(你所在地 14:00)」。这只是展示层改动、不改任何逻辑,但它直接消掉了歧义,性价比极高;而且只在跨时区时才双标注,同城行程不加这个噪音。
提醒必须基于绝对时刻调度,不能用「本地时间到点」触发——设备时区会变,按本地时间调度的提醒跨时区后可能提前几小时响或者已经过了才响。一般规律是:调度类逻辑用绝对时间,展示类逻辑才用本地时间,把两者混用是最常见的时区 bug 来源。
通知权限缺失这一条最容易被忽略而后果最重。用户没开通知权限时,我们的提醒系统在后台正常运行、日志显示「已发送」,但他压根没收到——而我们以为已经提醒过他了,而出行提醒失效的后果可能是误机。所以要应用内也做醒目提醒(打开应用时明确展示「2 小时后的航班」)加关键行程前主动引导开启权限并说明理由,两者是叠加不是替代。依赖系统能力的功能必须检查该能力是否可用,「不可用」要被当成一种需要处理的状态。
地理围栏类提醒(临近酒店提示办入住)要按行程阶段动态注册与注销——围栏持续监听位置很耗电,而且各端有注册数量上限、超了会静默失败(表现是「有些地点的提醒从来不响」),必须主动检查注册结果。
还有一条容易完全忽略的:夏令时。部分地区有,切换日的计算必须用标准时区库,不要自己算偏移。
这题的价值在于它是两个硬约束的叠加:打卡场所经常没网(地下车库、电梯、厂区车间——这些恰恰是很多员工的实际打卡地点),而打卡时刻直接关系工资、不能允许失败;同时设备本地时间可被用户修改,在打卡场景里这不是理论风险,是有人真的会做的事(把时间改到八点五十打卡,实际九点半到)。
核心矛盾是:无网时必须能打卡(所以时刻只能从本地取),但本地时间不可信(所以不能直接用本地时间)。
解法是「服务端时间校准 + 设备单调时钟推算」:有网络时定期拉服务端时间、算出并保存本地时钟的偏移量;打卡时不读设备墙钟,而是用「最近一次校准的服务端时刻 + 单调时钟走过的时长」推算——单调时钟不受用户改系统时间影响,所以推算值可信,而且完全无网时也能算。代价是长时间不联网有累积漂移,缓解手段是每次有网就校准、并把「距上次校准多久」也上报让服务端知道可信程度。
另外一个几乎零成本的设计:把设备墙钟与偏移量一起上报——服务端就能看出「这台设备的时间被改过很多」,这本身就是防作弊的一个输入信号,把一个本来会被丢弃的信息变成了风控数据。
无网时的打卡落为本地暂存记录、网络恢复后自动补传。补传要处理三件事:幂等(用本地记录 ID 作键,否则网络抖动的重复补传会变成两条打卡记录)、保序(多条暂存要按采集顺序提交,否则下班卡先到、上班卡后到,考勤计算会出错)、退避重试且有次数上限(无限重试会耗电,超上限后要提示转补卡申请而不是让记录永远悬着)。
而最容易被当成「体验优化」实际是正确性要求的一条是:暂存状态必须在界面上明确可见。如果只是静默暂存,员工看到「点了打卡但好像没成功」会反复点,或者跑到有信号的地方再打一次——那就产生了两条记录。所以要明确显示「已保存,待联网提交」并展示待提交条数。状态不可见会转化成用户的重复操作,而重复操作在有副作用的场景里会产生脏数据。
这题我会先给一个反直觉的判断:最重要的决策不是「怎么防住作弊」,而是「防不住的时候怎么办」。
我们第一版偏向防作弊——检测到疑似模拟定位就直接拒绝打卡,上线后投诉激增,而绝大多数投诉者是正常员工:有些机型的定位接口返回信息容易被判成可疑、有些员工装了工具类应用被误判、还有单纯是定位精度差。他们打不了卡直接影响工资和考勤记录,情绪极其强烈。
核心判断是两类错误的代价严重不对称:误判正常员工影响他的工资、而他完全无辜、还无法自证(他没法证明自己没用模拟定位);放过一次可疑打卡只是一次可能的作弊,而且事后仍可追溯。所以处理方式必须是「标记并转人工复核」而不是「直接拒绝」——标记是给 HR 的信息,不是给员工的拒绝。一般化的判断是:当误判的受害者无法自证、且代价直接落在他身上时,系统就不该做终局判决。
判断依据要多信源交叉,单一信号误判率太高:定位来源类型(卫星还是网络定位)、定位精度值(精度几百米的定位压根不能做精确围栏判定)、与上一次打卡的位移与时间是否物理可达(十分钟前在另一个城市,物理上不可能——这个信号最强也最难伪造)、设备与账号的常用特征。汇总成可信度评分后分三档,而三档都是「通过」:高则正常、中则标记进复核、极低则强标记加要求补充说明。
还有一个投诉量最大的单一原因值得单独说:围栏判定必须带定位精度容差。按「定位点是否在多边形内」判的话,员工站在公司门口、定位精度五十米,定位点可能落在围栏外几米处,被判为不在范围内。正确做法是精度差时用「定位点加精度半径与围栏是否有交集」;而精度特别差时压根不做围栏判定,改为标记交复核——宁可标记也不要用一个不可靠的定位去做二元判断。用有误差的测量值做二元判断时必须把误差纳入判定,因为用户恰恰经常正好在边界上。
最后两条配套:必须有申诉与补卡路径并保留完整证据供 HR 复核(误判一定会发生,把纠错路径留在产品外意味着成本极高);规则强度由客户配置(销售团队考勤关联提成要求严格、研发团队希望宽松,而「怎么处罚作弊」是客户的管理决定不是我们的产品决定)。
先说这类需求的根本张力,它在 C 端产品里压根不存在:我们采集的是员工的位置,而付钱的是他的雇主。客户(企业)的诉求和被采集者(员工)的利益不一致——客户提过「记录员工全天轨迹」「上班时间每小时采一次」「离开办公区就告警」,技术上都不难,但做了就是问题。
我们踩过:借用骑手端的持续定位能力做了「外勤模式持续上报位置」,员工发现后在公司内部群里讨论「公司在监控我们」,最后客户 HR 来要求我们关掉。伤害是双向的:员工不信任这个应用(会想办法卸载或禁权限),客户也觉得我们给他带来了管理麻烦。教训是:技术能力可以复用,但产品边界不能复用——骑手端持续上报位置是履约必需且骑手对此有明确预期,员工端没有这个前提,同样的技术在不同的关系结构里性质完全不同。
所以核心是把「采集的边界」当成产品设计的一部分,而不是技术能力的自然延伸。五条:只做动作触发的单点采集(打卡、外勤签到、提交拜访记录时各采一次,「用户主动发起」让采集有了明确的、用户可感知的边界,而持续后台跟踪没有这个边界);在采集那一刻界面显式告知用途(告知成本几乎为零,但它把「偷偷采集」变成了「明示采集」);非工作时段与非外勤状态不采集(即使客户配了外勤模式,下班后也不采);字段最小化(只存坐标、精度、时刻,不存周边信息、不存移动轨迹、不存停留时长)加留存期限可配且到期自动清理;员工可查询自己被采集的全部记录(既是合规要求也是建立信任的手段,比任何隐私政策都有说服力)。
合规审查有一份标准问题清单,值得记住:采集了什么、存多久、员工知不知道、能不能删。我们第一次被客户法务问到时答不上来,采购评估被卡了一段时间。这些问题应该在设计阶段就被回答,而且它们的答案不是文档,是产品能力。
对客户要求的更强能力,做法是「显式确认加责任转移」而不是直接拒绝或默默提供:能力可以有,但必须由客户在管理端显式开启并确认由他负责告知员工——因为这确实在客户的管理权范围内,但责任必须落到他身上。
最后一条:员工拒绝位置权限时不能死在这里,降级为「无位置打卡并标记」、交客户策略决定是否接受。不要用「不给权限就不能用」去强迫用户。
这类问题的排查原则是顺着资金流和状态流各查一遍,先确定钱在哪、单在哪,而不是先看代码。我会按下面的顺序走。
第一步:确认渠道那边到底扣款成功没有。拿用户的订单号或者支付流水号去渠道后台(或者调渠道的查询接口)查这笔交易的真实状态。这一步能立刻把问题分成两类完全不同的方向——如果渠道显示未支付或已退款,那是用户误解或者银行预授权,问题不在系统;如果渠道显示已支付,继续往下。
第二步:确认自己的服务端有没有收到渠道通知。查支付回调的接收日志。这里有几种典型情况:压根没收到(渠道通知失败、我们的回调地址不可达、被防火墙拦了、跨国网络问题);收到了但验签失败(密钥配置错误、渠道换了证书);收到了但处理抛异常(业务逻辑报错,比如库存扣减失败导致整个事务回滚,但钱已经收了);收到了且处理成功,那问题在下游。
第三步:如果回调处理成功,查订单状态和发货状态。虚拟商品的链路是「支付成功 → 扣减卡密库存 → 发货 → 通知用户」,每一步都可能断。常见的是支付成功了但卡密池空了发不出货,订单卡在「已支付未发货」;或者发货成功但通知失败,用户没收到卡密。
第四步:如果服务端一切正常,那就是前端展示问题。可能是订单列表读了从库有延迟、缓存没更新、或者用户看的是另一个账号(多端登录、游客态下单后登录了别的账号——这个在支持游客下单的平台上很常见)。
处理和长期改进比定位更重要。当场处理:确认钱到了就手动补发货或者退款,不要让用户等。补偿机制:这类问题不应该靠客诉发现,必须有主动补偿的定时任务——扫描「渠道已支付但本地未支付」和「已支付但未发货」的订单,自动重试或者告警。对账:每天和渠道对账,把差异单捞出来,这是最后一道防线。回调要重试:渠道通知失败会重试,我们自己的处理失败也要能重试,所以回调处理必须幂等而且要把接收和处理解耦(先落库再异步处理,避免处理慢导致渠道判定超时重发)。
这题答得好不好的分水岭是:有没有意识到「必须有不依赖客诉的主动发现机制」。只讲怎么查日志,说明只做过被动响应。
复现不了的问题,思路是放弃复现,转而收集足够的现场信息。我会分三步。
第一步:从数据里找规律。白屏一定要有监控上报(页面渲染完成打点,超时未打点即视为白屏)。有了上报数据就去做维度切分:集中在某个机型或系统版本吗(大概率是兼容性问题)?集中在某个地区吗(网络或 CDN 问题)?集中在某个版本发布后吗(新代码引入)?集中在某个入口进来吗(分享链接参数异常)?是首次打开还是后台恢复?切一遍维度往往就能锁定方向,这比盲猜代码有效得多。
第二步:按可能性排查具体原因。常见的几类:JS 报错导致渲染中断——最常见的是接口返回了意外的数据结构(某个字段为 null 而代码直接取了它的属性),这在多地区多版本接口下特别容易发生;资源加载失败——CDN 在某个地区不可达、或者分包下载失败;渲染层异常——小程序的渲染层崩溃,通常和一次性渲染节点过多、或者原生组件使用不当有关;内存不足被系统回收——低端机上尤其明显,表现为页面突然变白或者应用重启;路由参数异常——从分享链接进来带了脏参数,页面初始化直接抛错。
第三步:补齐可观测性,让下次能查。如果现在的数据不足以定位,先做的不是猜,而是把埋点补上:页面生命周期各阶段打点、接口返回的关键字段校验失败时上报、全局错误捕获带上用户操作路径(面包屑)、以及记录白屏发生前几步的用户行为。补完之后等下一次发生,就能直接看到现场。
防御性改造同时做:接口数据要做兜底,取值用可选链和默认值,不要假设服务端一定返回完整结构(这一条能消掉相当大比例的白屏);组件级错误边界,一个模块出错不要连带整页白掉,降级成局部占位;白屏自愈,检测到长时间未渲染出内容时自动重试加载一次或者引导用户刷新,同时上报。
最后一个实用手段:给用户一个能主动上报的入口(摇一摇反馈、设置里的问题反馈),提交时自动带上日志和环境信息。偶发问题最难的是抓现场,让用户帮你抓是成本最低的办法。
先确认卡在哪一层,不要一上来就改代码。小程序的架构是逻辑层和渲染层分离,卡顿的原因分三类,解法完全不同。
一是 setData 太重(逻辑层到渲染层的通信瓶颈)。判断方法是看开发者工具的 setData 面板,关注调用频率和单次数据量。滑动时如果每次滚动事件都触发 setData,或者一次传了整个几百条的列表数据,就是这个问题。解法是:滚动事件节流、改用 IntersectionObserver 在渲染层判断可见性而不是回传坐标计算、追加数据时不要整体替换数组、data 里剔掉不参与渲染的字段(接口返回的完整对象直接塞进 data 是常见错误,长列表里这部分序列化开销很可观)。
二是节点数量太多(渲染层压力)。列表滑了几十屏之后节点累积到几千个,必然卡。解法是虚拟列表——只渲染窗口内的项,滑出去的销毁。短视频场景更简单,因为一屏只有一个视频,保留前后各一两个就够了。同时要简化单项的结构,减少嵌套层级。
三是视频组件本身的开销。这是短视频场景特有的:同时存在的视频实例过多会直接导致卡顿甚至播不出来(原生组件有数量限制)。要确认滑走的视频是真的被销毁了还是只是隐藏了。另外预加载过于激进也会卡——一次预载好几个视频,带宽和解码资源被抢光。
排查工具:开发者工具的 Performance 面板看帧率和长任务,Trace 面板看 setData 的耗时分布,真机调试的体验评分会直接指出节点数和 setData 的问题。必须在真机上测,而且要用低端机——开发者工具和高端机上根本测不出问题,这是最容易犯的错。
另外要区分「一直卡」和「偶尔卡一下」:一直卡是上面这些结构性问题;偶尔卡一下要看是不是加载新数据时的渲染抖动(一次追加太多条)、图片解码(大图在滑动时解码)、或者其他任务抢占(埋点上报、日志写入在主线程做了重活)。
先确认是持续增长还是峰值过高,这是两个不同的问题。峰值过高是某个操作一次性占用太多(比如加载了一张超大图),用完会释放;持续增长是泄漏,用得越久越危险,最终必然闪退。判断方法是反复进出同一个页面,观察内存是否回落到基线——不回落就是泄漏。
泄漏的常见原因,按出现频率排:
页面栈堆积。这是 uni-app 里最常见的。用 navigateTo 一路往下跳而不 redirectTo,页面栈里的页面全都活着,每个页面的数据、定时器、监听都在。尤其详情页跳详情页的场景,用户点几次就堆了好几层。查的方法是打印 getCurrentPages() 看栈深度。
该销毁的没销毁。定时器(setInterval 忘了 clear)、事件监听(uni.$on 忘了 $off)、IntersectionObserver 忘了 disconnect、视频和音频实例没释放。这些都会通过闭包把整个页面的数据一起拖住。反复进出页面时监听器成倍增加是个明显信号——表现为一个事件触发了 N 次回调。
全局数据只增不减。把接口数据往全局 store 或者 globalData 里塞而从不清理,缓存的图片和视频数据没有淘汰策略。长列表的数据数组一直 concat,用户刷了几百条全在内存里——这个要配合虚拟列表连数据一起裁剪,只保留窗口附近的数据。
图片和视频。这类资源占内存远超预期:一张图占的内存是解码后的像素数乘以 4 字节,跟文件大小无关。所以要按显示尺寸加载、要限制缓存数量、列表滑出后要释放。
排查手段:Android 上用 Android Studio 的 Profiler 或者 adb shell dumpsys meminfo 观察趋势,iOS 上用 Xcode Instruments;小程序端开发者工具有内存面板。核心方法是做对比实验——固定操作路径重复 N 次,看内存曲线,然后逐个注释掉可疑代码块定位。
长期预防:养成「创建即写销毁」的习惯,在写 onLoad 里注册的同一刻就把 onUnload 里的清理写上;封装统一的定时器和事件管理工具,页面卸载时自动清理;上线后把内存和崩溃率作为监控指标,按机型分组看,低端机的问题会先暴露出来。
按「参数是否拿到 → 唤起是否成功 → 渠道是否响应」三段查,每段的原因完全不同。
第一段:创建支付单的接口有没有正常返回。如果接口失败或超时,压根就没有唤起参数。这时要注意不能判定为支付失败(可能后端已创建成功),按钮要进「确认中」状态而不是恢复可点击。跨国网络下这个接口超时是高频事件。
第二段:拿到参数但唤起失败。这一段的原因最多。
参数错误或过期:签名不对、参数字段少了、时间戳过期(渠道的支付参数通常有几分钟有效期,用户拿到参数后放了很久才点确认就会失效)。查渠道返回的错误码,这类错误码通常很明确。
环境限制:小程序端要求支付的商户号和小程序 appid 必须绑定,没绑定会直接报错;个人主体的小程序不能开通支付;测试环境用了生产参数或者反之。这类问题在初次接入或者换环境时集中出现。
用户环境问题:没装对应的支付 App(App 端跳转失败)、系统版本太低不支持某个 API、被安全软件拦截。App 端跳转外部应用还要注意各平台的跳转白名单配置(iOS 的 LSApplicationQueriesSchemes),没配会静默失败。
第三段:唤起了但一直转圈。通常是渠道那边慢或者故障(尤其是跨国渠道),也可能是回调没回来——用户在渠道界面已经完成了,但回跳丢失,前端还在等。所以前端不能无限等待,要设超时并转入「查询订单状态」的流程。
排查手段:关键点必须有埋点——点击、接口返回、唤起调用、唤起结果回调,每一步都上报。没有这些埋点你只能听用户描述,什么都查不到。要带上机型、系统版本、平台版本、地区、以及渠道返回的原始错误码。「唤起成功率」应该是一个常态监控指标,按渠道和地区拆分,掉了立刻能发现。
处理和改进:给用户明确的失败原因和替代方案(「该支付方式暂时不可用,请尝试其他方式」),而不是一个转圈或者「失败」;支持切换支付方式重试(注意要创建新的支付单,不要复用失败的那个);超时要转查询而不是判失败;参数过期要能重新获取而不是让用户从头下单。
先区分是「数据重复」还是「显示错乱」,这是两类完全不同的问题。
数据重复(同一条内容出现两次)的原因:
翻页时数据变动。用 offset/page 分页时,如果期间有新数据插入,第二页的开头会包含第一页末尾的内容。这是分页机制的固有问题,正确解法是游标分页(用上一页最后一条的 ID 或时间戳作为起点),而不是在前端去重打补丁。推荐流还要用会话级的已下发集合过滤。
重复请求。触底加载被触发了多次(滚动事件没节流、或者加载中没加锁),同一页数据被追加两次。要用加载状态标志拦住并发请求,而且要在请求完成或失败时正确复位(漏了复位会导致再也加载不了)。
后端返回重复。推荐系统多路召回没去重、或者数据本身有重复。前端可以按 ID 去重兜底,但要反馈给后端修。
显示错乱(内容和位置对不上、勾选状态串位)的原因几乎都是 key 问题:
用了 index 做 key,或者压根没写 key。列表增删或重排后,框架按位置复用节点,导致组件内部状态(勾选、输入内容、展开状态、播放进度)留在了原位置。特征是纯展示列表正常、一加交互就错乱。解法是用数据的唯一 ID 做 key。也不能用随机值做 key,那会导致每次渲染都全部销毁重建,性能极差还会丢状态。
虚拟列表的复用问题:如果自己实现了虚拟滚动,节点复用时没有正确重置状态,滚动后会看到上一个 item 的残留内容。
数据和视图不同步:直接改数组下标(Vue 2 的响应式限制)、或者修改了对象但视图绑的是旧引用。
排查手段:把列表数据打印出来看数据本身有没有重复(这一步能立刻分开两类问题);检查 key 的取值;用 Vue DevTools 看组件实例的复用情况。还要看用户的操作路径——错乱通常是在「删除某一项之后」「快速滚动时」「切换筛选条件后」这些特定操作后出现的,能复现操作序列就好查了。
上传链路长,要分段确认在哪一步断的:本地选择 → 校验 → 初始化任务 → 分片上传 → 合并 → 转码。每一步的失败原因和处理都不同。
本地选择和校验阶段:文件超过平台限制(小程序对单个文件大小有硬限制,超了压根传不了,要引导用户用 App 或者先压缩);格式不支持;权限被拒(相册权限没给,要有引导去设置页的提示);时长超限。这些要在选择后立刻校验并给明确提示,不要等传到一半才失败。
分片上传阶段是失败最集中的地方:
网络问题——跨国上传本来就容易断,要看是全部分片都失败(网络完全不通、或者上传域名不可达)还是部分分片失败(正常的网络抖动,应该重试)。特征区分开了才知道是不是要修代码。
并发超限——小程序有并发请求数限制,如果分片并发数设得太高,超出的直接失败。这个错误很隐蔽,因为它不是网络错误。
凭证过期——上传凭证(STS 临时凭证或预签名 URL)有有效期,大文件上传时间长可能中途过期,后续分片全部 403。要能检测到并自动续期重新获取凭证。这是大文件上传的经典坑。
分片大小不合适——太大在弱网下单片就超时,太小则请求数过多。
合并和转码阶段:分片缺失或校验失败(某片实际没落盘但客户端记成成功了,所以要以服务端返回的已收分片列表为准);转码失败(文件损坏、编码不支持、分辨率过高),这一步要给用户可理解的原因而不是笼统的失败。
排查手段:每个阶段都要有埋点(含分片级别的成功率),带上文件大小、网络类型、地区、错误码。上传成功率按地区和文件大小分桶统计——通常会发现「大文件在某些地区成功率极低」,这就指向了网络和分片策略。用户侧要有日志上报入口,因为上传问题很依赖具体环境。
改进方向:断点续传是必需的(失败后只重传缺的分片,而不是整个重来);失败自动重试单片(指数退避);上传前本地压缩——这一条对弱网用户的成功率提升最明显,把 100MB 压到 20MB,成功率会有质的变化;凭证自动续期;清晰的进度和错误提示,以及「退出页面时提示上传中」避免用户误操作。
先从数据确认「部分」的具体范围,这一步能极大缩小方向。用监控数据按机型、系统版本、平台版本(微信版本或基础库版本)、屏幕尺寸切一遍,看问题集中在哪个维度。集中在系统版本上通常是 API 兼容性;集中在机型上可能是硬件能力或厂商定制系统;集中在屏幕尺寸上是布局适配;集中在基础库版本上是平台能力差异。
几类典型问题和它们的特征。
API 兼容性:某个 API 在低版本平台不存在或者行为不同。所以调用新 API 前要用能力检测(uni.canIUse 或者判断方法是否存在)并准备降级方案,不能假设都支持。这类问题往往是「新功能上线后老设备用户全报错」。
布局适配:刘海屏和挖孔屏的安全区域没处理(内容被遮挡)、折叠屏展开后布局错乱、平板上按 750rpx 等比放大导致元素过大、超长屏或超宽屏的极端比例。这些要用安全区域变量和响应式断点处理,而不是写死尺寸。
输入法和键盘:键盘遮挡输入框、收起键盘后页面没复位、某些厂商输入法的行为异常。这类问题各端各机型差异极大,只能靠真机验证加上适配代码。
性能差异:低端机内存小、CPU 弱,在高端机上流畅的页面在低端机上直接卡死或者被系统杀掉。所以性能测试必须覆盖低端机,用高端机测出来的结论没有意义。
厂商定制系统:省电策略杀后台(影响后台上传和长连接)、权限管理差异(定位、相册权限的申请流程不同)、WebView 内核版本差异(Android 上各厂商的 WebView 差别很大,某些 CSS 特性表现不一致)。
定位手段:有对应机型就真机调试;没有就靠远程日志——让上报带上完整的设备环境信息,并且在可疑代码路径上加日志,等用户触发后看日志。还可以用云真机平台租设备。灰度发布也是重要手段:新版本先小比例放量,按机型维度看崩溃率和错误率,能在影响面还小的时候发现兼容性问题。
这个问题的排查要沿着「状态从哪来、怎么传到页面」这条链走,每一环都可能断。
先确认服务端的状态对不对。直接调订单查询接口看返回的状态。如果服务端本身还是未支付,那问题在支付回调链路(属于上一道题的范畴),不是前端问题。如果服务端已经是已支付,继续往前端查。
然后确认前端有没有去查、查到了有没有用上。常见断点有这么几个。
压根没触发查询。依赖渠道 SDK 的回调来触发查询,而回调没执行——用户在渠道页面完成支付后没有正常回跳、或者 App 被切到后台后回调丢了。这就是为什么不能只依赖回调,必须有页面显示时主动查询和启动时补偿查询。
查了但读到了旧数据。典型原因是读写分离的主从延迟——支付回调写主库,查询走从库,几百毫秒内读不到最新状态。或者接口有缓存没失效。解法是支付后的查询走主库,或者服务端在写入后主动删缓存。这个原因很常见但容易被忽略,因为它「过一会儿就好了」,看起来像偶发。
查到了但页面没刷新。这是纯前端问题:页面被缓存了(keep-alive 或者页面栈里的旧页面),onShow 里没有重新拉数据,用户从支付页返回时看到的是进入时的旧状态。解法是需要每次显示都刷新的数据放 onShow 而不是 onLoad。或者是响应式没生效——直接改了数组下标、或者替换了整个对象的引用但视图绑的是旧引用。
轮询逻辑本身有问题。轮询次数用完就停了但没有给出正确的兜底提示、或者页面切后台时轮询被暂停回来没恢复、或者组件销毁时没清理定时器导致查询结果更新到了已销毁的页面。
长期改进:这条链路要设计成多重保障而不是单点——SDK 回调触发一次查询、页面 onShow 查一次、启动时检查待确认支付、订单列表进入必刷新。任何一路通了状态就是对的。另外状态展示要能表达中间态,「支付成功,正在确认」比停留在「待支付」要好得多,用户至少知道系统在处理,不会重复支付或者立刻投诉。
先分清是「码没显示出来」还是「码显示了但扫不出」,两者的原因完全不同。
如果码压根没显示:查是不是依赖了联网下载图片——如果凭证是服务端生成的图片,无网时必然打不开,而用户恰恰经常在没网时需要它。这不是 bug 是设计缺陷,正确做法是保存码内容并在端上离线渲染。也要查动态码是不是「刷新失败就不显示」——那时用户最需要的是能出示任何一种有效凭证,应该降级为长效静态码并明确标注。
如果码显示了但扫不出,按这几条查。
一、屏幕亮度,这是最高频的原因。用户为省电把亮度调很低、或在户外强光下,扫码设备读不出来。正确做法是进入出示态自动提升亮度并保持常亮、退出后恢复——涉及硬件交互的界面要主动控制设备状态,不能假设用户的设备处于合适状态。
二、端上渲染的码与服务端不一致。如果改成了端上离线渲染,编码参数或纠错级别没和服务端对齐,就可能渲染出一个「看起来正常但设备识别不了」的码。这是改端上渲染带来的新风险,必须真的用扫码设备验,只检查「码显示出来了」是不够的。
三、显示尺寸与形变。码被其他信息挤压、或横竖屏切换后被压扁——有些闸机是横向扫的。
四、码本身已失效:动态码过期、订单状态已变(已核销、已取消)。这时要给明确的原因而不是让用户以为是设备问题。
五、对方系统的问题:扫码设备故障、他们的系统对不上单。这一类我们查不了,所以必须提供备用路径。
而备用路径是这类问题里最值得做的事:出示页一屏聚合姓名、订单号、联系电话、当地语言地址并提供复制——因为扫码失败时工作人员的常规做法就是改为手工核验这些字段;客服电话必须离线可见(需要它的时候通常就是出问题的时候)。关键流程的界面要考虑「这一步失败之后现场会发生什么」,只设计成功路径的界面在失败时会把用户推入慌乱。
这类问题的排查要沿着「端上有没有产生记录 → 有没有提交 → 服务端有没有接收 → 考勤有没有计入」四段走,而且每一段都有各自的高频原因。
第一段:端上有没有产生记录。如果是离线打卡,本地暂存记录还在不在——本地存储被清理、应用被卸载重装、或者存储写入超限静默失败都会让记录压根没落盘。有配额上限的资源,写入结果必须检查,静默失败比明确报错危险得多。
第二段:有没有提交成功。查暂存队列的状态。这里有几个高频原因:补传一直失败(客户的网络策略拦了我们的域名是真实发生过的);退避重试到上限后停止而没有告知用户——那条记录就永远悬着而他不知道,所以「放弃」之后必须给明确的下一步(转补卡申请)。
第三段:服务端有没有接收但被判为无效。这一段最容易被误解成「没记录」。查三类:被防作弊规则拒绝(如果客户配了严格模式);围栏判定不通过——而这一类要特别注意定位精度,员工站在门口但精度五十米、定位点落在围栏外几米,被判为不在范围内,这是投诉量最大的单一原因;时刻被判为不可信(设备墙钟与校准值偏差过大)。这三类的共同点是:服务端收到了但没算作有效打卡,而员工的感受是「我打了卡但没记录」——所以给用户的反馈必须区分这两种情况。
第四段:考勤计算。查补传是否保序——我们踩过:员工在无网环境先打上班卡、下班再打下班卡,两条暂存记录并发补传、下班卡先到达,考勤系统按到达顺序处理,把下班卡当成了上班卡,算出一个荒谬的工时。有先后语义的离线记录必须保序提交。另外查是否重复记录被去重成了错的那一条,以及排班与班次配置是否匹配。
长期防护:暂存状态在界面明确可见并展示待提交条数(否则员工会重复打卡产生脏数据);补传幂等加保序;被判无效时给员工明确原因与申诉入口——会产生误判的功能必须自带申诉闭环;以及为客户 HR 提供可核查的判定依据,否则一律采信员工说法,标记功能形同虚设。
没有匹配的题目,换个关键词试试。
场景题 · uni-app · 共 37 题 · 设计题看方案与权衡,排查题看思路与分层