凭证功能第一版是这么做的:支付成功后,在同一个流程里渲染凭证、上传到对象存储、发邮件给用户,然后返回下单成功。看起来很顺,四个问题都很实际。
一是拖慢下单主链路,而且渲染失败会影响下单结果。凭证要排版、要画二维码、要生成文件,这个过程比一次数据库写入慢得多。更严重的是有一次模板渲染因为某个特殊字符抛异常,导致支付成功之后的流程失败——用户的钱已经扣了,但订单状态没走完。这是把一个「附属功能」放进了「关键路径」造成的。
二是凭证内容是实时查的,行程变更后旧凭证的内容也变了。我的实现是每次访问凭证时都去查当前的订单和产品信息。结果是产品方改了政策描述、或者订单改签之后,用户手上那张「旧凭证」的链接打开会显示新内容——而他可能已经把旧凭证打印出来带在身上了,两份内容不一致,到了现场对不上。
三是凭证链接是长期有效的公开地址。凭证上有姓名、证件号后几位、联系方式、行程。这个链接一旦被转发或者出现在截图里,任何人都能打开。而且它永久有效——行程结束一年后还能访问。
四是重发没有幂等和频率限制。用户收不到邮件就反复点「重发」,我每点一次就真发一次,有用户连点了七八次收到七八封一样的邮件;还有一次因为前端重试导致同一个请求发了三遍。
所以四个改动:生成异步化并可查状态、内容快照化并带版本号、链接改私有读加时效凭据、重发加幂等与频率限制并支持渠道降级。
这个模块最想说的一句话是:凭证是「某个时刻的承诺」,它必须是一份快照而不是一个实时查询的视图。我最初把它当成「订单信息的另一种展示形式」,所以做成了实时查询——这个理解从根上就错了。用户拿着凭证去现场核销,他手上那张纸和系统里的记录必须是同一份东西,而实时查询做不到这一点。
凭证内容选快照而不是实时查询,代价是存储成本与「信息可能过时」。实时查询的好处是内容永远最新、不占存储。但它有一个根本问题:用户手上的凭证(打印出来的、截图的)和线上显示的会不一致——而凭证的用途恰恰是「拿着它去现场核验」。判据是「这份内容的用途是不是作为一次性的凭据」:是的话就必须快照。缓解「信息过时」的办法是版本化 + 作废机制:改签后生成新版本、旧版本明确作废,而不是让旧版本静默变成新内容。
生成异步化的代价是「用户支付完看不到凭证」这个中间态。产品最初担心这个体验。但同步生成的风险是渲染失败会阻断支付后的流程——我们真的发生过一次(某个特殊字符导致模板渲染抛异常),那次的影响远大于「等几秒」。判据是「这个操作失败会不会影响一件更重要的事」:会的话就必须解耦。缓解办法是把「生成中」这个状态明确展示出来,并且大多数情况下几秒内就完成了。
访问链接选时效凭据而不是长期公开地址。公开地址的实现最简单(直接给个网址)。但凭证含个人信息,而链接会被转发、会出现在截图和日志里,一旦泄露就是永久可访问。时效凭据的代价是每次访问要签发、链接不能被收藏(用户可能觉得不方便)。判据很直接:这份内容包含个人信息,那「难以猜到的长链接」不构成访问控制。
踩过的坑一:凭证生成同步做,模板渲染异常导致支付后流程失败。用户的钱已经扣了,但订单状态没有走完,需要人工介入修复。教训是:判断一段逻辑该不该放在关键路径上,标准是「它失败了会不会影响一件更重要的事」——凭证生成失败最多是用户晚几分钟拿到凭证,但它拖累了支付流程,就变成了资金问题。我当时的思路是「一起做完更简单」,完全没有考虑失败传播。
踩过的坑二:凭证内容实时查询,产品改了政策描述之后用户打印的旧凭证和系统显示的不一致。是客服反馈的——用户在现场出示凭证,工作人员按系统查是另一套信息,双方对不上。教训是:我最初把凭证理解成「订单信息的另一种展示形式」,这个理解从根上就错了。凭证是某个时刻的承诺,用户拿着它去核销,他手上那份和系统里的必须是同一份东西。
踩过的坑三:重发没有幂等,用户连点收到七八封一样的邮件。用户反而更困惑(以为系统出了问题),而且占用了邮件发送额度。教训是:任何「用户可能因为没得到反馈而重复点击」的操作,都必须有幂等——而「没收到邮件所以再点一次」正是最典型的重复点击场景,它的重复次数还特别高(因为邮件本身有延迟,他会一直点)。
没做的部分:没做凭证的离线可用(下载到本地 App 里,无网络也能出示)。它对出行场景很有价值(境外可能没网络),但涉及本地加密存储、失效同步(凭证作废了本地那份怎么处理),复杂度高出一个量级。我做的是「支持下载文件」这个最基础的版本,并明确它不会随线上作废而失效——把限制说清楚,比假装它是可靠的更安全。
主链路改善要报「支付成功流程的耗时对比」,并说明凭证生成的耗时去哪了。不是消失了,而是转移到了异步链路。诚实的报法是:主链路耗时下降 X,凭证从支付成功到可查看的时长为 Y(异步)——两个数一起报才完整。
异步生成的可靠性报「生成成功率与重试后成功率」,并报失败原因分布。失败原因分布很重要——如果集中在某类内容(特殊字符、超长字段),那是模板健壮性问题而不是系统问题。
快照的正确性用断言型用例:生成凭证后修改产品政策描述,断言已生成的凭证内容不变;执行改签,断言生成了新版本且旧版本被标记作废、旧版本仍可查询。这是踩坑的直接回归。
重发幂等:并发触发多次重发,断言只实际发送一次;超过频率限制时断言被拒绝并有明确提示。要真并发测,顺序调两次测不出竞态。
访问控制:断言未授权无法访问文件、时效凭据过期后失效、每次访问都产生了记录。
渠道降级:让邮件渠道不可用,断言自动尝试下一个渠道并记录了失败原因;让邮箱地址无效,断言不重试而是提示更新联系方式。后一条容易漏。
客服工单的报法要谨慎:可以报「凭证相关工单数下降」,但要说明工单是怎么分类的、同期有没有其他变更。不要把全部功劳归给这个改动。
不要报什么:不要报「凭证送达率 100%」——邮箱可能失效、短信可能被拦。该报的是「主链路耗时下降与异步生成时长两个数」「改配置后旧凭证内容不变、改签生成新版本」「并发重发只发一次」「确定性失败不重试」这几件可核对的事。
退改这块我接手的时候已经有一版实现:用户提交退改申请,系统查退改规则算出扣费金额,调供应商接口,回来之后更新状态。四个问题,其中两个直接涉及钱。
一是按当前规则算历史订单,金额算错了。退改规则是产品在后台配的,而它会被调整(比如某个产品从「出行前 24 小时免费退」改成「48 小时」)。我的实现是退改时读当前生效的规则。结果是用户三个月前下的单,按今天的新规则算出了更高的扣费——他截图了下单时的政策说明来投诉,我们理亏而且无法解释。
二是试算金额和实际扣费不一致。用户在申请页看到「预计扣费 50 元」,提交之后实际扣了 80 元。原因是试算和实扣走的是两套代码(试算在一个接口里,实扣在申请单处理逻辑里),两边的边界条件处理不同。这类投诉客服完全没法解释,只能特批全退。
三是跨时区行程的截止时点算错了。退改规则是「出行前 24 小时」,而我用服务端时区算「出行时间减 24 小时」。但出行时间是出行地的当地时间——一个下午出发的境外行程,按当地时间还在免费退的窗口内,按服务端时区算已经过了。费率档位直接算错,用户被多扣了钱。
四是申请单会长期停在「处理中」。供应商的回执是异步的,有的供应商几分钟回、有的几小时、有的干脆不回。我没有做超时处理,结果是一批申请单永远停在「处理中」,用户查不到结果、客服也不知道该怎么办。
所以四个改动:下单时固化规则快照、退改按快照算、试算与实扣统一到同一个计算入口并留档、截止时点按出行地时区判定、申请单收敛为状态机并加超时兜底与人工介入入口。
这个模块最想说的一句话是:只要一个计算依赖「配置」和「时间」,它就必须明确「用哪个版本的配置」和「用哪个时区的时间」。这两件事在功能测试里都不会暴露——测试是即时的(规则还没改)、而且测试数据通常是本地时区。它们只在时间跨度拉长、业务范围扩大之后才暴露,而那时候已经是资金差错了。
规则快照 vs 读当前配置。读当前配置的好处是「规则调整能立刻对所有订单生效」——产品最初就是这么要求的(他们希望改了规则马上生效)。但这在退改场景下是错的:用户下单时看到的政策就是我们对他的承诺,事后按新规则收更多钱,不管规则本身多合理,都是违背承诺。我的处理是:规则变更只对变更后的新订单生效,历史订单按快照;如果确实需要对历史订单放宽(比如疫情期间的特殊政策),那是一个显式的「政策豁免」操作而不是让规则默默生效。这个区分很重要:放宽是给用户好处,可以主动做;收紧不能追溯。
试算与实扣统一到一个入口,代价是这个入口要同时满足两种调用场景(试算是只读的、实扣有副作用)。做法是把「计算」和「执行」分开,计算部分共用。这比维护两套代码要好得多——两套代码的边界条件迟早会不一致,而不一致的表现是资金差错。判据是「两个地方算出的结果如果不同,后果有多严重」:这里的后果是用户认为被欺骗,所以必须统一。
提交时重新计算并提示差异,而不是「以试算为准」也不是「静默按新值」。「以试算为准」听起来对用户友好,但试算可能是几小时前做的,那时还在免费退窗口内、现在已经过了——按试算值退就是资损。「静默按新值」则会让用户认为被欺骗。所以只能显式提示并让他重新确认,多一步但两边都说得通。
踩过的坑一:按当前规则算历史订单,用户截图下单时的政策来投诉。我们理亏,只能特批。教训是:任何「用户在某个时刻看到并接受了的条款」,都必须在那个时刻固化下来——这不只是技术问题,是承诺的一致性问题。我当时完全没意识到「读配置」这个看起来最自然的写法隐含了「配置永不变化」这个假设。
踩过的坑二:跨时区行程的截止时点算错。我用服务端时区算「出行时间减 24 小时」,但出行时间是出行地的当地时间。一个下午出发的境外行程,按当地时间还在免费退窗口内、按服务端时区算已经过了,用户被多扣钱。教训是:只要业务涉及「某地的当地时间」,就必须把时区作为数据的一部分存下来,不能只存一个时间戳然后用服务器时区去解释它。这个问题在国内业务里永远不会暴露,扩展到境外行程时才爆发。
踩过的坑三:申请单长期停在「处理中」。供应商不回执,我也没有超时处理。用户查不到结果、客服也不知道该怎么办,只能一个个去找供应商问。教训是:任何依赖外部系统回执的流程,都必须假设「回执可能永远不来」并设计兜底——主动查询一次、查不到转人工、并且告警。「等对方回复」不是一个完整的设计。
没做的部分:没做自动化的退改审核(按规则自动通过或拒绝)。它能提升处理速度,但退改涉及资金,自动判错的代价高;而且很多退改需要看具体情形(不可抗力、供应商原因)。我做的是「规则明确可判的给出建议档位,最终由人确认」——和处置类功能同一个思路:机器给依据,人做决定。
金额争议类工单是这个模块最直接的指标。报「退改金额争议工单数下降」,要说明工单分类口径,并区分「规则快照」「试算一致」「时区」三类原因各贡献多少——分开报才说明你知道每个改动解决了什么。
规则快照的正确性用断言型用例:下单后修改产品的退改规则,断言该订单的退改计算仍按下单时的规则;新下的单按新规则。这是踩坑的直接回归。
试算与实扣一致性:覆盖各档位以及档位分界点,断言试算与实扣结果完全一致。分界点是重点——两套代码不一致最容易出现在边界上。
时区计算要专门构造跨时区用例:不同时区的出行地、临近截止时点的时间,断言档位判定按出行地时区。并且要包含「正好等于 N 小时」这个边界,断言结果与业务确认的口径一致。
超时兜底:模拟供应商不回执,断言超过阈值后触发主动查询,查不到则转人工并告警,申请单不会停在「处理中」。
幂等性:并发提交同一订单的退改申请,断言只产生一张申请单;重复投递供应商回执,断言状态与金额不被重复处理。
部分成功:构造多人行程中部分旅客退成功的场景,断言状态为「部分成功」且金额与实际退成功的人数对应。
不要报什么:不要报「退改成功率提升」——成功率主要由供应商决定。该报的是「改规则后历史订单计算不变」「档位分界点上试算与实扣一致」「跨时区用例按出行地时区判定」「无回执时超时兜底生效不再停在处理中」这几件可核对的事。
常用旅客这个功能本身很简单:用户保存常用出行人的姓名和证件信息,下次下单直接选。问题是证件号原来是明文存在数据库里的,安全评审时被要求整改。而「加密」这两个字说起来简单,落地时冒出四个问题。
一是加密之后查不了了。业务上有两个必须的查询:「这个证件号是否已经存在」(去重,同一旅客不该保存两条)、「按证件号查订单」(客服排查用)。而加密之后同一个明文每次加密出来的密文不同(因为有随机初始向量),无法做等值匹配。如果为了能查而使用固定初始向量,那又削弱了加密强度。
二是我最初想用哈希做索引,但用法是错的。我打算存一个证件号的哈希值用于等值查询。问题是证件号的取值空间是有限且结构化的——无盐哈希可以被批量枚举反查出明文,等于加密白做了。
三是没想过密钥怎么轮换。第一版我用了一个固定密钥。安全同学问「如果这个密钥泄露了怎么办」,我答不上来——因为全量数据都用它加密,换密钥意味着要重新加密全部数据,而我没有任何机制知道某条记录是用哪个密钥加密的。
四是脱敏口径混乱。有的页面显示全部证件号、有的显示后四位、有的完全不显示。而且每个页面各写一套掩码逻辑——新加的页面很容易忘记脱敏,直接把明文渲染出去。
所以四个改动:字段级加密、用带密钥的哈希(而非无盐哈希)做索引列、密文与哈希都记录密钥版本以支持轮换、脱敏收敛到统一出口并按场景分级,另外补了访问审计。
这个模块最想说的一句话是:「加密」不是一个动作,是一套需要配套设计的机制。只把字段加密了,会立刻遇到「查不了」「怎么去重」「密钥怎么换」「谁能解密」这四个问题,而这四个问题每一个都能让加密退化成形式主义——比如为了能查而用固定初始向量、为了方便而用无盐哈希,做完之后看起来加密了,实际上没有提供多少保护。
用带密钥的哈希做索引,而不是「固定初始向量让密文可比对」,也不是「无盐哈希」。三种做法都能实现等值查询,但前两种其实没有提供多少保护:固定初始向量削弱了加密本身;无盐哈希在证件号这种「取值空间有限且结构化」的数据上可以被批量枚举反查。带密钥哈希的代价是密钥管理更复杂(多一把密钥要管、轮换更麻烦),但它是这三者里唯一站得住的。判据是「攻击者拿到数据库之后能不能还原明文」——前两种能,第三种在密钥不泄露的前提下不能。
额外存一个掩码列,代价是多一份冗余数据。好处是列表展示完全不需要解密——而列表是访问量最大的场景。这既省了解密开销,更重要的是减少了明文在内存中出现的次数和范围。这个决策我最初没想到,是在压测发现列表接口因为逐条解密而变慢之后才补的,结果发现它的安全收益比性能收益更大。
密钥版本化的代价是每条记录多一个字段、解密逻辑要按版本分发。但不做的后果是密钥永远不敢换——因为换就要停机全量重加密。而「不能轮换的密钥」本身就是一个风险:它意味着一次泄露就是全量数据的永久暴露。判据是「这个系统要活多久」:只要它会长期运行,密钥就一定需要轮换能力。
脱敏收敛到统一出口,而不是让各页面自己处理。各自处理的灵活性更高(每个页面可以按需要显示不同位数),但代价是新页面必然会忘记脱敏——这类漏洞在代码评审里也很难发现(因为看起来就是正常地返回一个字段)。统一出口加场景分级,把「默认行为」变成了安全的那一侧:忘记声明场景就得到最严格的脱敏。这一条和权限收敛、屏蔽过滤收敛是同一个思路。
踩过的坑一:最初打算用无盐哈希做索引列。是安全同学在设计评审时指出的:证件号的结构是已知的(长度固定、部分位有规律),取值空间可以被批量枚举,无盐哈希等于明文。这件事让我明白「哈希不可逆」这个说法有前提——它在取值空间足够大时成立,而在结构化的短数据上完全不成立。幸好是在设计阶段被发现的。
踩过的坑二:第一版用固定密钥,被问「泄露了怎么办」时答不上来。我当时的理解是「加密了就安全了」,完全没有考虑密钥本身的生命周期。教训是:加密的安全性等于密钥管理的安全性,而密钥管理包括存放、获取、轮换、废弃四件事,缺一件这个方案就是不完整的。
踩过的坑三:脱敏最初是各页面自己写的,新加的一个导出功能忘了脱敏。是在一次数据抽查中发现的,导出的文件里证件号是完整的。教训是:横切的安全规则如果依赖「每个开发都记得处理」,它一定会在某个地方被漏掉——必须做成默认行为。
没做的部分:没做「用户自己也看不到完整证件号」这一步(目前用户本人可以查看自己保存的完整信息)。更严格的做法是连本人也只显示掩码,但这会影响「核对自己填的对不对」这个正常需求。取舍依据是「本人查看自己的信息」风险相对可控,而且有审计;不过我在文档里标注了这一点,因为如果风控要求提高,这是下一个要收紧的地方。
先报覆盖面:多少张表、多少个字段、多少条历史数据完成了迁移。这类改造的第一个问题就是「改全了没有」,所以要报「扫描确认无明文残留」以及扫描方式(按字段名模式匹配 + 抽样解码验证)。
性能影响必须报,因为加密一定有开销:报「按证件号查询(走哈希索引)的耗时」、「列表接口的耗时变化」。列表这一项要说明是因为用了掩码列所以基本无变化——这是那个决策的直接证据。
去重与查询的正确性用断言型用例:同一证件号重复保存,断言只保留一条;按证件号查询能命中;不同证件号的哈希不冲突(取一批真实格式的证件号验证)。
密钥轮换的验证是这个模块最有价值的一组用例:新增密钥版本后,断言新数据用新版本、旧数据仍可解密、后台任务能逐批重加密、迁移期间两个版本的数据都能正常查询。最后一条最容易漏。
脱敏的验证要覆盖全部输出路径:接口返回、列表、详情、导出文件、日志。报法是「N 个输出路径逐一验证脱敏生效」并列出清单——列清单本身就是证明你想全了。导出和日志这两条最容易漏。
审计完整性:断言每次解密都产生了审计记录且包含「查了谁」;批量导出产生了单独的留档。
不要报什么:不要说「数据现在很安全」——安全性取决于密钥管理和访问控制,不是加密算法本身。该报的是「明文残留扫描结果与扫描方式」「列表接口因掩码列而耗时基本不变」「轮换期间双版本共存可正常读写」「N 个输出路径的脱敏逐一验证」这几件可核对的事。也不要报「防止了泄露」——没发生泄露不等于方案有效。
没有匹配的内容,换个关键词试试。
项目拆解 · 预订履约周边(后端 · 实习级)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据