B 端审批应用的第一诉求是「别让我漏了」。漏一个审批不是少看一条内容,而是卡住了别人的工作——同事的报销打不了款、采购单走不下去,最后会投诉到 IT 管理员那里,管理员再来找我们。
第一版的问题全都出在「状态不一致」上。
一是角标不准,这是最招人烦的。应用角标显示 3,点进去待办列表是空的(因为那三条在网页端已经审了);或者反过来,有新待办但角标没变。用户被骗几次之后就不信角标了,从此靠自己想起来去看,那这个功能等于白做。根因是角标的数字是客户端自己算的,而列表数据来自另一次请求,两个来源必然会不一致。
二是推送里带了数字,而这个数字是发推送那一刻的。推送发出后用户在网页端审了两条,等他点开推送,客户端直接用推送里的旧数字更新角标,又不一致了。
三是每次进来全量拉待办。用户有几十条待办,每次切前台、每次下拉都全量拉一遍,流量和延迟都浪费。
四是多端已读不同步。手机和网页同时登录,网页审完了手机上那条还在列表里,用户点进去发现「该单据已被处理」。
所以四个设计:未读数只有一个来源(服务端聚合)、推送只当信号不当数据、列表增量拉取、已读用会话级水位同步。
「未读数单一来源」的代价是多一次请求。本来列表接口顺便返回未读数就行,现在要单独调一次聚合接口。但这一次请求换来的是三处状态永远一致,而角标不准的体验代价远大于一次轻量请求。聚合接口要做得很轻(只返回几个数字,服务端走缓存计数),这样调用成本可以忽略。
推送不能作为唯一触达手段,必须有兜底。推送会丢——用户关了通知权限、系统限制后台唤醒、企业容器内的推送策略不同。所以「切前台时刷新」这个时机是必需的兜底,它保证用户只要打开应用就能看到真实状态。把推送当成加速而不是依赖,这个定位要说清。
踩过的坑:退出登录没清角标,下一个人看到了上一个人的数字。共用设备(比如车间的平板)上,A 退出后 B 登录,角标还是 A 的 5 条待办,B 点进去列表是空的,B 以为系统坏了。修法是退出登录时显式清零角标并清空本地缓存列表。这个坑提醒了另一件更严重的事:本地缓存的待办列表里有企业数据,退出登录必须彻底清理,否则是数据泄露。
踩过的坑二:增量同步的位点用了客户端本地时间。客户端时间不准(或者用户手动改过),导致位点算错,有一段时间的待办永远拉不到——用户漏审了几条,被投诉。修法是位点由服务端下发(服务端在每次响应里返回「本次同步位点」,客户端原样存下来下次带回),客户端完全不参与位点的计算。凡是需要服务端理解的时间或序号,都不能由客户端生成。
没做的部分:没做待办的智能排序(按紧急度、按发起人重要性排)。目前是按时间倒序。这需要业务规则和数据支撑,而且排序规则一旦复杂,用户会找不到某条待办(他记得是按时间排的),当时判断风险大于收益。
「角标不一致未再出现」怎么验证:构造场景演练——手机和网页同时登录,在网页处理掉几条,验证手机角标在切前台后正确更新;发一条推送后先在网页处理掉,再点开推送,验证角标是当前真实值而不是推送里的旧值;退出登录再登录另一个账号,验证角标清零。这三个场景正是我们踩过坑的地方,必须能说出演练步骤。
冷启动到待办首屏:在应用启动和列表首次渲染完成处打时间戳。约 600ms,要分「命中本地缓存」和「首次安装无缓存」两种情况报——前者几乎无等待(先渲染缓存),后者取决于网络。只报好的那个是选择性呈现。
增量拉取的效果:抓包对比全量与增量的响应体大小和条目数。要说明测试条件(本地已有多少条、期间变更了几条),因为「增量」的优势取决于变更比例——如果全变了增量就没优势。
不要报「审批及时率提升」或「漏审率下降」。这些取决于用户自己的工作习惯和工作量,客户端只能保证「不因为系统原因让他看不到」。可以报的是「因角标或列表不一致导致的支持工单数量」变化,这个是我们能归因的。
审批是一次性的、不可逆的、且会驱动流程前进的操作。它出错的后果比 C 端的点赞失败严重得多。
第一版的实现很朴素:点「通过」调接口,成功跳回列表,失败弹提示。三类事故。
一是重复审批导致流程跳步。用户在电梯里点「通过」,转圈很久没反应,又点了一次。服务端处理了两次,而流程引擎的逻辑是「收到通过就推进到下一节点」,于是单据一次跳了两步,跳过了本该有的一级审批。这个问题是最严重的——它绕过了审批规则。
二是超时被判定成失败,用户重新审。客户端设了 10 秒超时,超时后提示「审批失败,请重试」。但那个请求在第 20 秒成功了。用户看到失败又审了一次,又是重复。
三是批量审批不知道哪条失败了。用户勾选了 12 条一起通过,接口返回「部分失败」,但不知道是哪几条。用户只能一条条点开看,或者干脆全部重审一遍(重审又触发重复问题)。
第四个是体验问题但很招人烦:写了很长的审批意见,提交失败后意见没了,要重新写。
所以四个设计:客户端生成并持久化幂等键、结果分三态且未知态先查询、批量返回逐条结果、意见与附件本地暂存。这套逻辑和之前电商下单、本地生活核销的处理是同一个模式——凡是「一次操作对应一次不可逆业务变更」的场景,都要这么做。
未知态先查询会让用户多等几秒,这个代价必须接受。弱网下超时之后再发一次查询请求,可能又要等几秒。但对比重复审批导致流程跳步的后果——绕过了审批规则、要人工回滚流程、可能涉及资金——多等几秒完全不算什么。取舍方向和支付链路一致:在不可逆操作上,宁可慢,不可错。
幂等键持久化带来了「脏记录」的管理成本。本地会累积一些未确认的幂等记录(应用被杀、用户放弃操作)。处理办法是设保留期(比如 7 天),过期自动清理,并且启动时检查一次。不清理会让本地存储越来越大,也会在很久之后弹出莫名其妙的「上次审批未确认」提示。
为什么不做离线审批。用户一定会问「地铁里没网能不能先审,联网后自动提交」。技术上不该做:客户端无法验证「我现在还是不是当前审批人」(可能已被他人处理或流程已变更),如果允许离线记录后自动提交,联网后可能提交到一个已经不该由我审批的节点上,产生错误的审批记录。我们的做法是「离线时保存草稿但不提交」——意见和附件都存着,联网后引导用户确认后再提交。「操作不阻塞」和「离线也能生效」是两件事,这和本地生活核销的判断完全一致。
踩过的坑:重复提交导致流程跳两步。弱网下用户连点两次「通过」,服务端处理了两次,而流程引擎的逻辑是「收到通过就推进到下一节点」,单据一次跳了两步,跳过了本该有的一级审批,事后是财务对账时发现某笔报销没经过部门经理审批。这是最严重的一类问题——它不是数据错,是绕过了业务规则。修法是幂等键 + 服务端用「单据 ID + 节点 ID」做条件更新(where current_node = 期望节点),第二次请求因为节点已经变了而不生效。幂等和状态机条件更新要一起用,只有一个都不够。
踩过的坑二:幂等键漏了节点 ID,驳回重审时被误幂等。单据被驳回后重新提交,又走到同一个人这里,他第二次审批时用的幂等键和第一次一样,被服务端当成重复请求直接返回了上次的结果,实际上这次审批没生效,单据卡住。修法是幂等键里加节点 ID(或者加流转轮次)。幂等键的组成必须覆盖「什么情况下算同一次操作」的完整语义,少一个维度就会误判。
没做的部分:没做审批的撤回(审批通过后想撤回)。这需要流程引擎支持逆向流转,涉及已经产生的下游动作(比如已经打款了)怎么处理,业务规则复杂,当时是走「作废重新发起」。
重复审批验证:用工具限速模拟弱网,对同一单据连点 5 次「通过」,检查审批记录表里产生了几条、单据当前节点前进了几步。两个都要查——只查审批记录可能看不出流程跳步。改造前每轮可复现,改造后未再出现。还要测驳回重审场景(验证第二次审批不会被误幂等掉),这是我们踩过的第二个坑。
未知态处理验证:构造「请求实际成功但响应丢失」的场景(用代理工具丢弃响应),验证客户端不显示失败、转入查询、查到审批记录后当作成功。这类场景手工很难造,用抓包工具的响应丢弃功能可以稳定复现。
批量部分成功验证:勾选 12 条,其中 2 条在提交前用另一个账号先处理掉,验证返回逐条结果、界面正确区分成功与失败、「仅重试失败项」只重试那 2 条。
不要报「审批成功率 99.9%」。审批失败的主要原因是业务原因(单据已被他人处理、不是当前审批人),这是正常业务结果不是系统故障,算进成功率没有意义。可以报的是「因重复提交导致的错误审批记录数」——这个是我们能影响且口径清晰的,而且用绝对数。
也不要报「零重复审批」。绝对化断言。正确表述是「压测多轮未出现,且服务端有状态机条件更新作为第二道防线」。
where current_node = 期望节点,第二次请求因为节点已变而不生效)。两层都要有——幂等表可能因为并发或缓存问题漏判,条件更新是最后防线。另外客户端还要点击即锁定按钮并置全局标记,因为用户可能返回列表再进来点。
B 端移动应用有个 C 端不会遇到的部署形态:它经常不是独立 App,而是嵌在企业微信、钉钉这类企业容器里的一个工作台应用。用户点开工作台图标就要能用。
第一版是直接照独立应用做的,三个问题。
一是还要输账号密码,用户不接受。员工的认知是「我已经登录企业微信了,为什么还要再登一次」。而且他大概率不记得这个 SaaS 的密码(是 IT 管理员给他开的账号)。结果是应用的日活极低,IT 管理员来投诉「你们的东西员工不用」。
二是会话过期的时机极其伤人。用户写了几百字的审批意见、传了三张附件,点提交时弹出「登录已过期,请重新登录」,重新登录回来内容全没了。这个体验会让用户直接放弃在手机上审批。
三是企业数据的外泄风险是真实的。审批单里有报销金额、采购价格、人员薪资,员工截图发到外部群是真实发生过的事。客户的安全部门在采购评审时会直接问「你们怎么防截图」。
所以三个设计:容器内走免登、会话静默续期且过期时保住草稿、水印加审计提高泄露的追溯性。
第三点要特别说清一个认知:纯软件手段防不住截屏(用户可以用另一台手机拍屏)。所以目标不是「防住」而是「提高泄露成本并让泄露可追溯」——这个定位说不清楚,在面试里会被认为不懂安全。
getAuthCode(),内部按端条件编译。不支持免登的端返回「不支持」让业务层降级到账号登录,而不是静默失败。免登降低了登录门槛,但也把身份的信任链交给了容器。如果容器方的授权码机制被攻破,我们的免登就被绕过了。缓解手段是免登只用于「进入应用」,敏感操作仍要二次验证——比如查看薪资类单据、修改银行卡信息时要求输入密码或走容器的二次验证。「免登不等于免验证」这个分层要说出来。
水印会影响可读性,这是明确的取舍。水印太淡没有威慑和追溯价值(截图后看不清),太重影响阅读。我们的做法是只在含敏感信息的页面加水印(金额、人员信息),普通页面不加;并且水印文字用较大间距平铺而不是密集覆盖。不要全站无差别加水印——那会让整个应用看起来很难用,而大部分页面并不敏感。
踩过的坑:并发刷新凭证导致集体掉线。用户切回前台,页面同时发起了未读数、待办列表、用户信息三个请求,全部返回凭证过期,三个请求各自去刷新,后两次刷新用的是已被作废的刷新凭证,全部失败,应用直接跳到登录页。用户的反馈是「你们的应用老是自己退出登录」。修法是刷新逻辑做成单例:用一个模块级的变量存「正在刷新的 Promise」,并发请求都 await 同一个 Promise,刷新完成后统一重放。这是拦截器实现里最经典的坑,几乎每个项目都会踩一次。
踩过的坑二:免登时自动创建了账号,导致席位被超额占用。早期为了让用户「开箱即用」,容器用户标识找不到对应账号时就自动创建一个。结果客户只买了 50 个席位,但公司三百人都点开过工作台,系统里创建了三百个账号,计费和权限都乱了,而且这些人本来不该有访问权。修法是找不到账号就返回「未开通,请联系管理员」,账号必须由 IT 管理员显式开通。B 端的「便利」不能越过「授权」这条线。
没做的部分:没做设备绑定与异地登录管控(同一账号在陌生设备登录时要求二次验证)。客户的安全部门提过,需要设备指纹和风险评估,当时只做了会话记录与手动踢下线。
「免登后无需输入账号」怎么验证:在企业微信和钉钉两个容器里各走一遍完整流程,验证点开工作台图标后直接进入待办列表。还要测异常分支:容器里的人在我们系统里没有账号(应显示「未开通」而不是自动创建)、授权码过期(应重新获取而不是报错)、在浏览器里打开(应降级到账号登录)。这三个分支是免登功能真正容易出问题的地方。
「表单内容丢失未再出现」怎么验证:构造场景——在审批页写好意见并上传附件,手动让会话失效(后台踢掉会话),然后点提交,验证:先尝试静默刷新;刷新失败则保存草稿并引导重新授权;授权后回到原页面且意见和附件都在。这个链路要完整演练。
并发刷新的验证:让页面同时发起多个需要鉴权的请求并使会话过期,检查网络面板里刷新接口只被调用了一次,且所有原请求都被正确重放。这是个可以精确验证的点。
不要报「防止了数据泄露」或「杜绝截图外传」。纯软件手段防不住(用另一台手机拍屏就绕过了所有管控)。正确的表述是能力描述:「敏感页含可追溯到个人的水印」「App 端截屏有审计记录」「访问行为可审计并有异常检测」。并且要主动说明小程序端无法禁止截屏这个能力边界——如实说出局限,比声称做到了更可信。
没有匹配的内容,换个关键词试试。
项目拆解 · 移动审批端(uni-app)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据