怎么用 这是格式与深度的参考,不是让你照抄的模板。数字全部是示例,直接抄进简历一问就穿——必须换成你自己的真实数据,而且要能说清是怎么测出来的。下面每个模块都有「数字是怎么测的」和「面试追问」两段,写不出那两段的模块就不要往简历上写

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

实习级这一档是干什么的 供应商多源比价、库存与价格缓存一致性、打包产品组合定价这些核心台面,实习生大概率碰不到。这一档收的是预订履约周边里实习生真的会被分派、但仍然有明确技术含量的活。下面三个模块彼此独立——只做过一个就只写一个。
怎么讲才不显得 low 「我做了个凭证重发,调一下发送接口」和「退改规则是会变的——用户三个月前下的单,按今天的规则算退款金额就错了;所以下单时必须把当时适用的规则快照存下来,退款时按快照算而不是按当前配置算」——同一件事,后者面试官会顺着追问。这一档的破解办法是找到「事后再算就不一样了」这类问题:退改规则会变、汇率会变、密钥会轮换、凭证内容对应的是当时的行程,三者本质上是同一类。
三条自检 一、能说出不这么做会怎样(不快照退改规则会算错退款金额、凭证生成同步做会拖垮下单接口、证件明文存储一旦泄露无法补救);二、能说出你踩过的具体坑;三、能说出量级(日均多少张凭证、退改单多少、存了多少条证件)。三条都有就能写。
项目背景设定 在线旅游平台的订单后端,Spring Boot + MySQL + Redis + 消息队列 + 对象存储。我负责的是电子凭证、退改申请单、常用旅客信息这三块「不在下单与库存主链路上、但直接面向用户和客服」的功能。
为什么这三块值得写 它们共同的技术主题是「把当时的状态固化下来」:凭证是出行信息在某个时刻的快照、退改单必须按下单时适用的规则算而不是按当前配置算、证件密文必须记录用的是哪个密钥版本。不固化的后果是事后重算得到不同的结果,而这类问题在功能测试时完全不出现(测试是即时的,规则还没变、密钥还没轮换)——它只在时间跨度拉长之后暴露。

模块一:电子凭证生成与重发

  1. 电子凭证生成与重发(生成异步化避免拖累下单 + 内容快照与版本化 + 访问链接时效化 + 重发幂等与渠道降级)★★★
    简历这样写 电子凭证服务(Spring Boot + 消息队列 + 模板渲染 + 对象存储私有读):凭证生成(含排版渲染与二维码)耗时较长,原先同步在支付成功流程中执行,拖慢主链路且渲染失败会影响下单结果,改为异步生成 + 状态可查 + 失败重试,主链路只负责发起;凭证内容改为下单时刻的快照并带版本号(行程与政策后续变更不影响已发出的凭证,改签则生成新版本并作废旧版本);凭证含证件与联系方式等信息,存储改为私有读 + 按需签发时效访问凭据,不再使用长期有效的公开链接;重发接口加幂等与频率限制(原实现被用户连点导致重复发送数封邮件),并支持多渠道与降级(站内、邮件、短信按可用性依次尝试并记录结果)。改造后支付链路不再受凭证渲染影响,凭证相关客服工单明显下降。
    展开完整拆解
    为什么要这么设计

    凭证功能第一版是这么做的:支付成功后,在同一个流程里渲染凭证、上传到对象存储、发邮件给用户,然后返回下单成功。看起来很顺,四个问题都很实际。

    一是拖慢下单主链路,而且渲染失败会影响下单结果。凭证要排版、要画二维码、要生成文件,这个过程比一次数据库写入慢得多。更严重的是有一次模板渲染因为某个特殊字符抛异常,导致支付成功之后的流程失败——用户的钱已经扣了,但订单状态没走完。这是把一个「附属功能」放进了「关键路径」造成的。

    二是凭证内容是实时查的,行程变更后旧凭证的内容也变了。我的实现是每次访问凭证时都去查当前的订单和产品信息。结果是产品方改了政策描述、或者订单改签之后,用户手上那张「旧凭证」的链接打开会显示新内容——而他可能已经把旧凭证打印出来带在身上了,两份内容不一致,到了现场对不上。

    三是凭证链接是长期有效的公开地址。凭证上有姓名、证件号后几位、联系方式、行程。这个链接一旦被转发或者出现在截图里,任何人都能打开。而且它永久有效——行程结束一年后还能访问。

    四是重发没有幂等和频率限制。用户收不到邮件就反复点「重发」,我每点一次就真发一次,有用户连点了七八次收到七八封一样的邮件;还有一次因为前端重试导致同一个请求发了三遍。

    所以四个改动:生成异步化并可查状态内容快照化并带版本号链接改私有读加时效凭据重发加幂等与频率限制并支持渠道降级

    这个模块最想说的一句话是:凭证是「某个时刻的承诺」,它必须是一份快照而不是一个实时查询的视图。我最初把它当成「订单信息的另一种展示形式」,所以做成了实时查询——这个理解从根上就错了。用户拿着凭证去现场核销,他手上那张纸和系统里的记录必须是同一份东西,而实时查询做不到这一点。

    整体链路
    生成(异步,不进主链路) │ ├─ 支付成功 → 发一条「生成凭证」的消息 → 主链路立即返回 │ 同步生成的两个问题 │ 渲染耗时拖慢下单 │ 渲染失败会导致支付后流程失败(钱扣了单没走完) │ ├─ 消费方:组装内容快照 → 渲染 → 上传 → 更新凭证状态 │ ├─ 状态可查:待生成 / 生成中 / 已就绪 / 生成失败 │ 前端据此展示「正在生成,稍后可查看」而不是空白 │ ├─ 失败重试(有限次 + 退避),仍失败则告警并允许人工触发 │ └─ 生成要幂等:同一订单同一版本重复消费不产生多份文件 内容快照(这是最关键的一点) │ ├─ 生成时把当时的信息固化:出行人 · 行程 · 政策摘要 · 联系方式 │ 不快照的后果:产品改了政策描述,用户手上的旧凭证内容也变了 │ 他打印出来带在身上,到现场和系统对不上 │ ├─ 带版本号:改签 / 换人 → 生成新版本,旧版本作废 │ 作废不删除 —— 历史要可查(现场出示旧版本时要能解释) │ ├─ 快照里要包含「生成时间」并在凭证上显示 │ 让持有者能判断自己手上这份是不是最新的 │ └─ 核销后凭证标记为已使用(防止重复核销) 访问控制(凭证含个人信息) │ ├─ 文件存私有读,不使用公开地址 │ ├─ 访问时按需签发时效凭据(短有效期) │ 长期公开链接的问题:转发 / 截图 / 日志泄露后永久可访问 │ ├─ 凭证上的证件号做部分掩码(保留可核验的最少位数) │ 现场核验需要能对上,所以不能全掩码 —— 要和业务确认口径 │ └─ 每次访问记录:谁 · 什么时候 · 哪个版本 客服说「用户说没收到」时,这个记录能判断是否真的没打开过 重发(用户最常用的操作) │ ├─ 幂等:同一订单同一版本在窗口内重复请求只发一次 │ 原实现被连点导致收到七八封一样的邮件 │ ├─ 频率限制:按订单与按用户两个维度都要限 │ ├─ 多渠道与降级:站内 → 邮件 → 短信(按配置与可用性依次尝试) │ 短信只发一个查看链接(内容长且含个人信息,不适合放正文) │ ├─ 每次发送记录渠道与结果(成功 / 失败原因) │ 客服排查「用户说没收到」时要能看到发了没、发到哪 │ └─ 邮件地址无效等确定性失败不重试,只提示用户更新联系方式 无脑重试的后果:一直失败还占用发送额度
    分步拆解
    1. 凭证生成必须移出主链路。渲染耗时长,更严重的是渲染失败会导致支付后的流程失败——钱扣了但订单状态没走完。这是把附属功能放进关键路径的典型错误。
    2. 异步之后必须提供状态查询。否则前端不知道该显示什么,用户看到空白会以为凭证丢了。「正在生成,稍后可查看」是必需的过渡态。
    3. 生成失败要有限重试加告警加人工触发入口。无限重试会积压;没有人工入口的话,个例失败就只能等下次发版修。
    4. 生成要幂等。消息可能重复消费,不幂等会产生多份文件(浪费存储,而且用户可能看到两个凭证)。
    5. 内容必须快照,这是整个模块最关键的一点。不快照的后果是产品改了政策描述,用户手上打印出来的旧凭证和系统里显示的不一致,到现场对不上。
    6. 快照要带版本号,改签或换人时生成新版本并作废旧版本。作废不删除——用户在现场出示旧版本时,客服要能查到「这是旧版本,已被某次改签作废」。
    7. 凭证上要显示生成时间。让持有者自己能判断手上这份是不是最新的,这一条成本几乎为零但很有用。
    8. 核销后要标记已使用,防止重复核销。凭证可以被截图转发,不标记的话同一张凭证可能被用两次。
    9. 凭证文件必须私有读,不能用长期公开链接。公开链接的问题是转发、截图、出现在日志里之后就永久可访问了——而凭证上有姓名、证件号、行程。
    10. 证件号要部分掩码,但不能全掩码。现场核验需要能对上,所以保留几位是必要的——具体口径要和业务确认,不能自己拍。
    11. 每次访问要记录谁、什么时候、哪个版本。客服处理「用户说没收到」时,这个记录能判断他是不是真的没打开过。
    12. 重发必须幂等。原实现被用户连点导致收到七八封一样的邮件,用户反而更困惑(以为系统出问题了)。
    13. 频率限制要按订单和按用户两个维度都做。只按订单限的话,一个用户对多个订单同时刷还是能打满发送额度。
    14. 多渠道要按可用性依次降级,并记录每次发送的渠道与结果。客服排查时要能看到「发了没、发到哪、失败原因是什么」。
    15. 短信只发查看链接,不发凭证正文。内容长且含个人信息,短信不适合承载。
    16. 确定性失败(邮箱地址无效)不要重试。重试也不会成功,只会一直占用发送额度;正确做法是提示用户更新联系方式。
    关键决策与取舍

    凭证内容选快照而不是实时查询,代价是存储成本与「信息可能过时」。实时查询的好处是内容永远最新、不占存储。但它有一个根本问题:用户手上的凭证(打印出来的、截图的)和线上显示的会不一致——而凭证的用途恰恰是「拿着它去现场核验」。判据是「这份内容的用途是不是作为一次性的凭据」:是的话就必须快照。缓解「信息过时」的办法是版本化 + 作废机制:改签后生成新版本、旧版本明确作废,而不是让旧版本静默变成新内容。

    生成异步化的代价是「用户支付完看不到凭证」这个中间态。产品最初担心这个体验。但同步生成的风险是渲染失败会阻断支付后的流程——我们真的发生过一次(某个特殊字符导致模板渲染抛异常),那次的影响远大于「等几秒」。判据是「这个操作失败会不会影响一件更重要的事」:会的话就必须解耦。缓解办法是把「生成中」这个状态明确展示出来,并且大多数情况下几秒内就完成了。

    访问链接选时效凭据而不是长期公开地址。公开地址的实现最简单(直接给个网址)。但凭证含个人信息,而链接会被转发、会出现在截图和日志里,一旦泄露就是永久可访问。时效凭据的代价是每次访问要签发、链接不能被收藏(用户可能觉得不方便)。判据很直接:这份内容包含个人信息,那「难以猜到的长链接」不构成访问控制。

    踩过的坑一:凭证生成同步做,模板渲染异常导致支付后流程失败。用户的钱已经扣了,但订单状态没有走完,需要人工介入修复教训是:判断一段逻辑该不该放在关键路径上,标准是「它失败了会不会影响一件更重要的事」——凭证生成失败最多是用户晚几分钟拿到凭证,但它拖累了支付流程,就变成了资金问题。我当时的思路是「一起做完更简单」,完全没有考虑失败传播。

    踩过的坑二:凭证内容实时查询,产品改了政策描述之后用户打印的旧凭证和系统显示的不一致。是客服反馈的——用户在现场出示凭证,工作人员按系统查是另一套信息,双方对不上教训是:我最初把凭证理解成「订单信息的另一种展示形式」,这个理解从根上就错了。凭证是某个时刻的承诺,用户拿着它去核销,他手上那份和系统里的必须是同一份东西。

    踩过的坑三:重发没有幂等,用户连点收到七八封一样的邮件。用户反而更困惑(以为系统出了问题),而且占用了邮件发送额度教训是:任何「用户可能因为没得到反馈而重复点击」的操作,都必须有幂等——而「没收到邮件所以再点一次」正是最典型的重复点击场景,它的重复次数还特别高(因为邮件本身有延迟,他会一直点)。

    没做的部分:没做凭证的离线可用(下载到本地 App 里,无网络也能出示)。它对出行场景很有价值(境外可能没网络),但涉及本地加密存储、失效同步(凭证作废了本地那份怎么处理),复杂度高出一个量级。我做的是「支持下载文件」这个最基础的版本,并明确它不会随线上作废而失效——把限制说清楚,比假装它是可靠的更安全。

    数字是怎么测的

    主链路改善要报「支付成功流程的耗时对比」,并说明凭证生成的耗时去哪了。不是消失了,而是转移到了异步链路诚实的报法是:主链路耗时下降 X,凭证从支付成功到可查看的时长为 Y(异步)——两个数一起报才完整。

    异步生成的可靠性报「生成成功率与重试后成功率」,并报失败原因分布。失败原因分布很重要——如果集中在某类内容(特殊字符、超长字段),那是模板健壮性问题而不是系统问题。

    快照的正确性用断言型用例:生成凭证后修改产品政策描述,断言已生成的凭证内容不变;执行改签,断言生成了新版本且旧版本被标记作废、旧版本仍可查询这是踩坑的直接回归。

    重发幂等:并发触发多次重发,断言只实际发送一次;超过频率限制时断言被拒绝并有明确提示。要真并发测,顺序调两次测不出竞态。

    访问控制:断言未授权无法访问文件、时效凭据过期后失效、每次访问都产生了记录。

    渠道降级:让邮件渠道不可用,断言自动尝试下一个渠道并记录了失败原因;让邮箱地址无效,断言不重试而是提示更新联系方式。后一条容易漏。

    客服工单的报法要谨慎:可以报「凭证相关工单数下降」,但要说明工单是怎么分类的、同期有没有其他变更。不要把全部功劳归给这个改动。

    不要报什么:不要报「凭证送达率 100%」——邮箱可能失效、短信可能被拦。该报的是「主链路耗时下降与异步生成时长两个数」「改配置后旧凭证内容不变、改签生成新版本」「并发重发只发一次」「确定性失败不重试」这几件可核对的事。

    面试追问
    Q:凭证不就是把订单信息渲染成一张图或者 PDF 吗?为什么要存快照?直接查最新数据不是更准? A:「更准」这个想法正是我最初的错误理解,而它在这个场景下会造成真实的问题。我第一版就是每次访问凭证时实时查当前的订单和产品信息。结果是产品方改了政策描述之后,用户手上打印出来的那份旧凭证和系统显示的不一致——他在现场出示凭证,工作人员按系统查是另一套信息,双方对不上。是客服反馈给我的。根本原因是我把凭证理解成了「订单信息的另一种展示形式」,这个理解从根上就错了:凭证是某个时刻的承诺,它的用途是「用户拿着它去现场核验」,所以他手上那份和系统里的必须是同一份东西,而实时查询做不到这一点——用户手上的纸不会跟着数据库一起更新。所以内容必须在生成时固化成快照。代价是占存储、以及「信息可能过时」。后者我用版本化加作废机制来解决:改签或换人时生成新版本、旧版本明确标记作废(但不删除,因为用户在现场出示旧版本时客服要能查到「这是旧版本,已被某次改签作废」),而不是让旧版本静默变成新内容。另外还在凭证上显示了生成时间,让持有者自己能判断手上这份是不是最新的。我总结的判据是:这份内容的用途如果是「作为一次性的凭据」,就必须快照;如果只是「查看当前状态」,实时查询才是对的。
    Q:凭证生成放在支付成功之后一起做,有什么问题? A:有一个我真实踩到的严重问题:某次模板渲染因为订单里的一个特殊字符抛了异常,导致支付成功之后的流程失败——用户的钱已经扣了,但订单状态没有走完,需要人工介入修复。这就是把「附属功能」放进「关键路径」的后果。我当时的思路是「一起做完更简单」,完全没有考虑失败传播。除此之外还有耗时问题:凭证要排版、画二维码、生成文件、上传,这个过程比一次数据库写入慢得多,直接拖慢了下单接口。所以改成支付成功只发一条消息,主链路立即返回,异步去生成异步之后必须补三件事,否则会引入新问题:一是状态可查(待生成/生成中/已就绪/失败),前端据此显示「正在生成,稍后可查看」而不是空白——不做的话用户会以为凭证丢了;二是失败有限重试加告警加人工触发入口,否则个例失败只能等下次发版修;三是生成要幂等,因为消息可能重复消费,不幂等会产生多份文件。产品最初担心「用户支付完看不到凭证」这个中间态,但我认为同步生成的风险大得多——凭证晚几秒是体验问题,阻断支付流程是资金问题。我形成的判据是:判断一段逻辑该不该放在关键路径上,标准是「它失败了会不会影响一件更重要的事」。

模块二:退改申请单流转

  1. 退改申请单流转(下单时规则快照替代读当前配置 + 试算与实扣一致性留档 + 出行时区下的截止时点计算 + 供应商回执超时兜底)★★★
    简历这样写 退改申请单(Spring Boot + 状态机 + 规则快照 + 超时补偿任务):退改费计算原先读取当前生效的退改规则,而规则会被产品调整,导致按新规则计算历史订单的退款金额出错,改为下单时固化规则快照、退改时按快照计算并在申请单上留存计算明细;用户看到的试算金额与最终实扣改为同一套计算入口并留档比对,消除两者不一致引发的投诉;截止时点计算修正为按出行地时区判定(原按服务端时区计算,跨时区行程的「出行前 24 小时」判定出现偏差,直接影响费率档位);申请单收敛为显式状态机,供应商回执异步且可能延迟或缺失,增加超时兜底任务与人工介入入口,避免申请单长期停在「处理中」;提交做幂等,同一订单不产生重复申请单。改造后退改金额争议类工单明显下降。
    展开完整拆解
    为什么要这么设计

    退改这块我接手的时候已经有一版实现:用户提交退改申请,系统查退改规则算出扣费金额,调供应商接口,回来之后更新状态。四个问题,其中两个直接涉及钱。

    一是按当前规则算历史订单,金额算错了。退改规则是产品在后台配的,而它会被调整(比如某个产品从「出行前 24 小时免费退」改成「48 小时」)。我的实现是退改时读当前生效的规则。结果是用户三个月前下的单,按今天的新规则算出了更高的扣费——他截图了下单时的政策说明来投诉,我们理亏而且无法解释

    二是试算金额和实际扣费不一致。用户在申请页看到「预计扣费 50 元」,提交之后实际扣了 80 元。原因是试算和实扣走的是两套代码(试算在一个接口里,实扣在申请单处理逻辑里),两边的边界条件处理不同。这类投诉客服完全没法解释,只能特批全退。

    三是跨时区行程的截止时点算错了。退改规则是「出行前 24 小时」,而我用服务端时区算「出行时间减 24 小时」。但出行时间是出行地的当地时间——一个下午出发的境外行程,按当地时间还在免费退的窗口内,按服务端时区算已经过了。费率档位直接算错,用户被多扣了钱。

    四是申请单会长期停在「处理中」。供应商的回执是异步的,有的供应商几分钟回、有的几小时、有的干脆不回。我没有做超时处理,结果是一批申请单永远停在「处理中」,用户查不到结果、客服也不知道该怎么办。

    所以四个改动:下单时固化规则快照、退改按快照算试算与实扣统一到同一个计算入口并留档截止时点按出行地时区判定申请单收敛为状态机并加超时兜底与人工介入入口

    这个模块最想说的一句话是:只要一个计算依赖「配置」和「时间」,它就必须明确「用哪个版本的配置」和「用哪个时区的时间」。这两件事在功能测试里都不会暴露——测试是即时的(规则还没改)、而且测试数据通常是本地时区它们只在时间跨度拉长、业务范围扩大之后才暴露,而那时候已经是资金差错了。

    整体链路
    规则快照(下单时固化,退改时按快照算) │ ├─ 下单时把当时适用的退改规则完整复制到订单上 │ 含:费率档位 · 截止时点定义 · 免费退条件 · 不可退标记 │ ├─ 退改时读订单上的快照,不读当前配置 │ 读当前配置的后果:产品改了规则,历史订单的退款金额跟着变 │ 用户截图下单时的政策来投诉,我们理亏且无法解释 │ ├─ 快照要带规则版本号与生效时间(便于对账与排查) │ └─ 供应商侧规则变更(不受我们控制)→ 以供应商回执为准并记录差异 这类差异要能统计,用于和供应商对账 截止时点计算(时区是最容易错的地方) │ ├─ 出行时间是「出行地当地时间」,必须带时区信息存储 │ 只存一个时间戳而不存时区 → 后续无法正确还原当地时间 │ ├─ 「出行前 N 小时」要在出行地时区下计算 │ 用服务端时区算的后果:跨时区行程的费率档位直接算错 │ ├─ 边界要明确:正好等于 24 小时算不算「24 小时以内」 │ 这个边界必须和业务确认并写进用例,不能自己拍 │ └─ 判定结果要连同「判定依据」一起留档 当时时间 · 出行时间 · 时区 · 落在哪个档位 留档的意义:争议时能复现当时的判定,而不是重新算一遍 金额计算(试算与实扣必须一致) │ ├─ 试算与实扣走同一个计算入口(同一份代码) │ 两套代码的后果:边界处理不同,用户看到 50 实扣 80 │ 这类投诉客服无法解释,只能特批全退 │ ├─ 试算结果连同输入参数一起留档(带试算时间) │ ├─ 提交时重新计算并与试算比对 │ 有差异(比如跨过了档位分界)→ 明确提示用户重新确认 │ 静默按新值扣的后果:用户认为被欺骗 │ └─ 计算明细写进申请单:本金 · 费率 · 扣费 · 应退 不写明细的后果:客服解释不了「为什么扣这么多」 申请单状态机 │ ├─ 已提交 → 平台审核(若需要)→ 供应商处理中 │ → 成功 / 失败 / 部分成功 │ ├─ 提交幂等:同一订单在处理中不允许再建申请单 │ 不做的后果:用户连点产生多张申请单,多次退款 │ ├─ 部分成功要单独建模(多人行程中部分旅客退成功) │ 合并成「成功」或「失败」都会让金额和状态对不上 │ └─ 非法流转直接拒绝并告警 供应商回执(异步 · 可能延迟或缺失) │ ├─ 调用后立即置为「供应商处理中」,等回执 │ ├─ 回执可能:同步返回 · 异步回调 · 需要主动轮询查询 │ 三种都要支持(不同供应商能力不同) │ ├─ 超时兜底任务:超过阈值仍无回执 → 主动查询一次 │ 查不到 → 转人工处理并告警,不要让它停在「处理中」 │ ├─ 回执处理要幂等(回调可能重复、也可能和轮询结果同时到) │ └─ 阈值要按供应商分别配置(有的快有的慢,一个统一值不合理) 对用户可见的状态与预期 ├─ 给出「预计处理时长」而不是只显示「处理中」 ├─ 状态变更要通知用户(尤其失败要说明原因与下一步) └─ 失败原因要可读,不要直接透出供应商的原始错误码
    分步拆解
    1. 下单时必须把退改规则固化到订单上,退改时按快照算。读当前配置的后果是产品改了规则,历史订单的退款金额跟着变——用户截图下单时的政策说明来投诉,我们理亏且无法解释。
    2. 快照要完整(费率档位、截止时点定义、免费退条件、不可退标记)并带版本号。只存一个费率数字是不够的——档位和截止时点定义也会变。
    3. 供应商侧的规则变更不受我们控制,要以回执为准并记录差异。这类差异要能统计,用于和供应商对账——差异持续存在说明双方规则不同步。
    4. 出行时间必须带时区信息存储。只存一个时间戳的话,后续无法正确还原「出行地当地时间」,而退改规则是按当地时间定义的。
    5. 「出行前 N 小时」必须在出行地时区下计算。用服务端时区算的后果是跨时区行程的费率档位直接算错,用户被多扣钱。
    6. 边界要和业务确认并写进用例:正好等于 24 小时算哪一档。这类边界不能自己拍——它直接决定金额,拍错了就是资损。
    7. 判定结果要连同判定依据一起留档(当时时间、出行时间、时区、落在哪个档位)。争议时要能复现当时的判定,而不是重新算一遍——重新算可能得到不同结果。
    8. 试算与实扣必须走同一个计算入口。两套代码的后果是边界处理不同,用户看到 50 实扣 80,这类投诉客服无法解释只能特批全退。
    9. 试算结果要连同输入参数留档并带时间。因为时间推移可能跨过档位分界,留档才能解释「你试算的时候是这个档位」。
    10. 提交时要重新计算并与试算比对,有差异要明确提示用户重新确认。静默按新值扣的后果是用户认为被欺骗——即使新值在规则上是对的。
    11. 计算明细要写进申请单(本金、费率、扣费、应退)。不写明细的话客服解释不了「为什么扣这么多」。
    12. 提交要幂等,同一订单在处理中不允许再建申请单。不做的后果是用户连点产生多张申请单,可能导致多次退款。
    13. 「部分成功」要单独建模。多人行程中部分旅客退成功是真实情况,合并成「成功」或「失败」都会让金额和状态对不上。
    14. 供应商回执的三种形式(同步返回、异步回调、主动轮询)都要支持。不同供应商能力不同,只做一种会导致部分供应商的单永远没有结果。
    15. 必须有超时兜底任务:超阈值无回执则主动查询,查不到转人工并告警。不做的后果是一批申请单永远停在「处理中」,用户查不到结果、客服也不知道怎么办。
    16. 超时阈值要按供应商分别配置。有的几分钟有的几小时,一个统一值对快的供应商太宽松、对慢的又会误判。
    17. 回执处理要幂等。回调可能重复、也可能和轮询结果同时到达,不幂等会重复更新状态或重复退款。
    18. 对用户要给「预计处理时长」而不是只显示「处理中」。并且失败原因要转换成可读文案,不要直接透出供应商的原始错误码。
    关键决策与取舍

    规则快照 vs 读当前配置。读当前配置的好处是「规则调整能立刻对所有订单生效」——产品最初就是这么要求的(他们希望改了规则马上生效)。但这在退改场景下是错的:用户下单时看到的政策就是我们对他的承诺,事后按新规则收更多钱,不管规则本身多合理,都是违背承诺我的处理是:规则变更只对变更后的新订单生效,历史订单按快照;如果确实需要对历史订单放宽(比如疫情期间的特殊政策),那是一个显式的「政策豁免」操作而不是让规则默默生效这个区分很重要:放宽是给用户好处,可以主动做;收紧不能追溯。

    试算与实扣统一到一个入口,代价是这个入口要同时满足两种调用场景(试算是只读的、实扣有副作用)。做法是把「计算」和「执行」分开,计算部分共用这比维护两套代码要好得多——两套代码的边界条件迟早会不一致,而不一致的表现是资金差错。判据是「两个地方算出的结果如果不同,后果有多严重」:这里的后果是用户认为被欺骗,所以必须统一。

    提交时重新计算并提示差异,而不是「以试算为准」也不是「静默按新值」。「以试算为准」听起来对用户友好,但试算可能是几小时前做的,那时还在免费退窗口内、现在已经过了——按试算值退就是资损。「静默按新值」则会让用户认为被欺骗。所以只能显式提示并让他重新确认,多一步但两边都说得通。

    踩过的坑一:按当前规则算历史订单,用户截图下单时的政策来投诉。我们理亏,只能特批。教训是:任何「用户在某个时刻看到并接受了的条款」,都必须在那个时刻固化下来——这不只是技术问题,是承诺的一致性问题。我当时完全没意识到「读配置」这个看起来最自然的写法隐含了「配置永不变化」这个假设。

    踩过的坑二:跨时区行程的截止时点算错。我用服务端时区算「出行时间减 24 小时」,但出行时间是出行地的当地时间。一个下午出发的境外行程,按当地时间还在免费退窗口内、按服务端时区算已经过了,用户被多扣钱。教训是:只要业务涉及「某地的当地时间」,就必须把时区作为数据的一部分存下来,不能只存一个时间戳然后用服务器时区去解释它。这个问题在国内业务里永远不会暴露,扩展到境外行程时才爆发。

    踩过的坑三:申请单长期停在「处理中」。供应商不回执,我也没有超时处理。用户查不到结果、客服也不知道该怎么办,只能一个个去找供应商问教训是:任何依赖外部系统回执的流程,都必须假设「回执可能永远不来」并设计兜底——主动查询一次、查不到转人工、并且告警。「等对方回复」不是一个完整的设计。

    没做的部分:没做自动化的退改审核(按规则自动通过或拒绝)。它能提升处理速度,但退改涉及资金,自动判错的代价高;而且很多退改需要看具体情形(不可抗力、供应商原因)。我做的是「规则明确可判的给出建议档位,最终由人确认」——和处置类功能同一个思路:机器给依据,人做决定。

    数字是怎么测的

    金额争议类工单是这个模块最直接的指标。报「退改金额争议工单数下降」,要说明工单分类口径,并区分「规则快照」「试算一致」「时区」三类原因各贡献多少——分开报才说明你知道每个改动解决了什么。

    规则快照的正确性用断言型用例:下单后修改产品的退改规则,断言该订单的退改计算仍按下单时的规则;新下的单按新规则。这是踩坑的直接回归。

    试算与实扣一致性:覆盖各档位以及档位分界点,断言试算与实扣结果完全一致。分界点是重点——两套代码不一致最容易出现在边界上。

    时区计算要专门构造跨时区用例:不同时区的出行地、临近截止时点的时间,断言档位判定按出行地时区并且要包含「正好等于 N 小时」这个边界,断言结果与业务确认的口径一致。

    超时兜底:模拟供应商不回执,断言超过阈值后触发主动查询,查不到则转人工并告警,申请单不会停在「处理中」。

    幂等性:并发提交同一订单的退改申请,断言只产生一张申请单;重复投递供应商回执,断言状态与金额不被重复处理。

    部分成功:构造多人行程中部分旅客退成功的场景,断言状态为「部分成功」且金额与实际退成功的人数对应。

    不要报什么:不要报「退改成功率提升」——成功率主要由供应商决定。该报的是「改规则后历史订单计算不变」「档位分界点上试算与实扣一致」「跨时区用例按出行地时区判定」「无回执时超时兜底生效不再停在处理中」这几件可核对的事。

    面试追问
    Q:退改规则为什么要快照?直接读配置不是能保证规则永远是最新的吗? A:「规则永远最新」在退改场景下恰恰是错的,而且产品最初也是这么要求的——他们希望改了规则马上对所有订单生效。问题在于:用户下单时看到的退改政策,就是我们对他的承诺。我第一版读当前配置,结果是某个产品的退改政策从「出行前 24 小时免费退」改成了「48 小时」,用户三个月前下的单,按新规则算出了更高的扣费——他截图了下单时的政策说明来投诉,我们理亏,而且完全无法解释,只能特批。所以我的处理是:规则变更只对变更后的新订单生效,历史订单按下单时固化的快照计算。快照要完整(费率档位、截止时点定义、免费退条件、不可退标记)并带版本号,只存一个费率数字不够——档位和截止时点定义也会变但这里有一个我认为很重要的区分:如果确实需要对历史订单放宽(比如不可抗力期间的特殊政策),那应该是一个显式的「政策豁免」操作,而不是让新规则默默生效——放宽是给用户好处,可以主动做;收紧不能追溯我从这件事得到的更一般的教训是:「读配置」这个看起来最自然的写法,隐含了「配置永不变化」这个假设,而这个假设在任何有时间跨度的业务里都不成立。凡是「用户在某个时刻看到并接受了的条款」,都必须在那个时刻固化下来。
    Q:时区那个问题具体是怎么出错的? A:退改规则写的是「出行前 24 小时可免费退」,我的实现是「出行时间减 24 小时,和当前时间比」——听起来没问题,但我用的是服务端时区。出行时间是出行地的当地时间:一个下午出发的境外行程,按出行地当地时间用户还在免费退的窗口内,按服务端时区算已经过了截止点——档位直接判错,用户被多扣了钱。这个问题在国内业务里永远不会暴露(时区一致),扩展到境外行程时才爆发。修法有两层。一是出行时间必须带时区信息存储——只存一个时间戳的话,后续根本无法正确还原「当地时间是几点」;二是「出行前 N 小时」的判定要在出行地时区下做还有一个容易忽略的点:边界必须和业务确认——「正好等于 24 小时」算不算 24 小时以内?这个边界直接决定金额档位,不能自己拍,我把它写进了用例。另外我做了一件事后来证明很有用:把判定依据连同结果一起留档(当时时间、出行时间、时区、落在哪个档位)。因为争议发生时需要复现当时的判定,而不是重新算一遍——重新算的时候「当前时间」已经变了,可能得到不同结果。我总结的教训是:只要业务涉及「某地的当地时间」,时区就必须作为数据的一部分存下来,不能只存时间戳然后用服务器时区去解释它。这类问题的共性是它们只在业务范围扩大后暴露,而那时候已经是资金差错。

模块三:常用旅客证件加密存储

  1. 常用旅客证件加密存储(字段级加密 + 带密钥哈希索引支撑等值查询与去重 + 密钥版本化便于轮换 + 分级脱敏与访问审计)★★★
    简历这样写 常用旅客信息存储改造(Spring Boot + 字段级加密 + 带密钥哈希索引 + 访问审计):证件号原为明文存储,改为字段级加密后入库;加密后无法做等值查询与去重,因此额外维护带密钥的哈希索引列(不使用无盐哈希——证件号取值空间有限,无盐哈希可被枚举反查),支撑「按证件号查重」与「同一旅客只保留一条」的业务需求;密文与哈希均记录密钥版本,支持密钥轮换与按版本重加密,避免一次泄露导致全量数据不可挽回;展示按场景分级脱敏(列表仅显示末位若干、核验场景经审批展示更多、后台导出需高权限并留档);所有解密与查看操作写入审计日志(谁、何时、查了谁的信息、用途)。改造后敏感字段不再以明文落库,访问路径可追溯。
    展开完整拆解
    为什么要这么设计

    常用旅客这个功能本身很简单:用户保存常用出行人的姓名和证件信息,下次下单直接选。问题是证件号原来是明文存在数据库里的,安全评审时被要求整改。而「加密」这两个字说起来简单,落地时冒出四个问题。

    一是加密之后查不了了。业务上有两个必须的查询:「这个证件号是否已经存在」(去重,同一旅客不该保存两条)「按证件号查订单」(客服排查用)。而加密之后同一个明文每次加密出来的密文不同(因为有随机初始向量),无法做等值匹配。如果为了能查而使用固定初始向量,那又削弱了加密强度。

    二是我最初想用哈希做索引,但用法是错的。我打算存一个证件号的哈希值用于等值查询。问题是证件号的取值空间是有限且结构化的——无盐哈希可以被批量枚举反查出明文,等于加密白做了。

    三是没想过密钥怎么轮换。第一版我用了一个固定密钥。安全同学问「如果这个密钥泄露了怎么办」,我答不上来——因为全量数据都用它加密,换密钥意味着要重新加密全部数据,而我没有任何机制知道某条记录是用哪个密钥加密的。

    四是脱敏口径混乱。有的页面显示全部证件号、有的显示后四位、有的完全不显示。而且每个页面各写一套掩码逻辑——新加的页面很容易忘记脱敏,直接把明文渲染出去。

    所以四个改动:字段级加密用带密钥的哈希(而非无盐哈希)做索引列密文与哈希都记录密钥版本以支持轮换脱敏收敛到统一出口并按场景分级,另外补了访问审计

    这个模块最想说的一句话是:「加密」不是一个动作,是一套需要配套设计的机制。只把字段加密了,会立刻遇到「查不了」「怎么去重」「密钥怎么换」「谁能解密」这四个问题,而这四个问题每一个都能让加密退化成形式主义——比如为了能查而用固定初始向量、为了方便而用无盐哈希,做完之后看起来加密了,实际上没有提供多少保护。

    整体链路
    存储结构 │ ├─ 证件号明文列删除,新增 │ 密文列 随机初始向量 + 对称加密后的结果 │ 哈希索引列 带密钥的哈希(用于等值查询与去重) │ 掩码列 预先算好的展示用掩码(避免展示也要解密) │ 密钥版本列 记录本条用的是哪个版本的密钥 │ └─ 掩码列这一项收益很大 列表展示不需要解密 —— 解密次数大幅减少,也减少了泄露面 为什么需要哈希索引列 │ ├─ 加密后同一明文每次密文不同(随机初始向量)→ 无法等值匹配 │ 而业务上必须支持两件事 │ 去重:同一证件号不该保存两条常用旅客 │ 排查:客服按证件号查订单 │ ├─ 用固定初始向量让密文可比对 → 削弱加密强度,不可取 │ ├─ 用无盐哈希做索引 → 证件号取值空间有限且结构化 │ 可以被批量枚举反查出明文,加密等于白做 │ └─ 用带密钥的哈希(密钥不在库里) 没有密钥就无法枚举验证,同时同一明文哈希稳定、可建索引 这是「能查」和「不可反查」之间唯一站得住的做法 密钥管理与轮换 │ ├─ 密钥不写在代码与配置文件里,从密钥管理服务获取 │ ├─ 每条记录存密钥版本号 → 解密时按版本取对应密钥 │ 不存版本的后果:换密钥就要停机全量重加密,实际上做不到 │ ├─ 轮换流程 │ 新增版本 → 新数据用新版本 → 后台任务逐批重加密旧数据 │ → 旧版本仅保留解密能力 → 全部迁完后停用旧版本 │ └─ 哈希索引列换密钥更麻烦(要重算全部哈希且期间查询要兼容两版) 所以哈希密钥的轮换周期与加密密钥分开考虑 脱敏(收敛到统一出口,按场景分级) │ ├─ 级别一 列表 / 概览 仅末位若干(用预算好的掩码列) ├─ 级别二 核验 / 客服协助 经审批展示更多位,单次有效并留档 ├─ 级别三 完整明文 仅特定流程(提交给供应商)由服务内部使用 │ 不向任何界面输出 └─ 关键点:脱敏必须在统一的序列化出口做,不由各页面自己写 各自写的后果:新加页面忘记脱敏,明文直接渲染出去 访问审计 ├─ 每次解密与查看记录:操作人 · 时间 · 查了谁 · 用途 · 来源接口 ├─ 批量导出单独留档并需要更高权限 └─ 异常访问告警(短时间大量解密 · 非工作时间访问) 其他配套 ├─ 日志与异常堆栈中禁止输出证件号(要有统一的日志脱敏) ├─ 接口返回体默认脱敏,需要明文的场景显式声明 └─ 测试数据不使用真实证件号
    分步拆解
    1. 证件号明文列必须删除,不是「保留明文再加一列密文」。保留明文的话整改等于没做——而这是最常见的偷懒做法(怕出问题留个后路)。
    2. 加密要用随机初始向量,同一明文每次密文不同。用固定初始向量能让密文直接比对(方便查询),但那会削弱加密强度,不可取。
    3. 等值查询靠单独的哈希索引列,而不是让密文可比对。这是「能查」和「加密强度」之间的正确分工。
    4. 哈希必须带密钥,不能用无盐哈希。证件号的取值空间有限且结构化,无盐哈希可以被批量枚举反查出明文,加密就白做了这是我最初想错的地方。
    5. 哈希密钥不能存在数据库里。否则拖库时密钥和数据一起泄露,带密钥哈希就退化成了无盐哈希。
    6. 要额外存一个预先算好的掩码列。列表展示直接用它,不需要解密——这既减少了解密开销,也减少了明文出现的次数。这个收益比我预期的大。
    7. 每条记录必须存密钥版本号。不存的后果是换密钥就要停机全量重加密,实际上做不到,于是密钥永远不敢换。
    8. 轮换流程要支持「新数据用新版本、旧数据后台逐批重加密」。旧版本在迁移期间保留解密能力,全部迁完再停用。
    9. 哈希索引列的密钥轮换比加密密钥更麻烦。要重算全部哈希,而且迁移期间查询要同时兼容两个版本的哈希。所以两者的轮换周期要分开考虑。
    10. 密钥不能写在代码或配置文件里。从密钥管理服务获取,否则代码仓库或配置泄露就等于密钥泄露。
    11. 脱敏必须收敛到统一的序列化出口。各页面自己写掩码的后果是新加的页面忘记脱敏,明文直接渲染出去——这类漏洞很难通过评审发现。
    12. 脱敏要按场景分级,不是一个统一规则。列表只需末位若干;客服协助核验需要更多位(否则核验不了),但要审批、单次有效、留档;完整明文只在提交给供应商时由服务内部使用,不向任何界面输出。
    13. 接口返回体要默认脱敏,需要明文的场景显式声明。默认安全比默认开放要好——忘记声明的后果是脱敏(安全),而不是泄露。
    14. 每次解密与查看都要写审计日志(操作人、时间、查了谁、用途、来源接口)。「查了谁」这一项最关键,否则只知道有人解密过,不知道涉及哪些人。
    15. 批量导出要更高权限并单独留档。批量的风险远大于单次查看。
    16. 要有异常访问告警(短时间大量解密、非工作时间访问)。审计日志只有在被监控时才有防护作用,否则它只是事后追责的材料。
    17. 日志与异常堆栈中禁止输出证件号。需要统一的日志脱敏——我见过的实际泄露路径里,异常堆栈打印参数是很常见的一种。
    18. 测试数据不要用真实证件号。测试环境的防护通常更弱。
    关键决策与取舍

    用带密钥的哈希做索引,而不是「固定初始向量让密文可比对」,也不是「无盐哈希」。三种做法都能实现等值查询,但前两种其实没有提供多少保护:固定初始向量削弱了加密本身;无盐哈希在证件号这种「取值空间有限且结构化」的数据上可以被批量枚举反查带密钥哈希的代价是密钥管理更复杂(多一把密钥要管、轮换更麻烦),但它是这三者里唯一站得住的。判据是「攻击者拿到数据库之后能不能还原明文」——前两种能,第三种在密钥不泄露的前提下不能。

    额外存一个掩码列,代价是多一份冗余数据。好处是列表展示完全不需要解密——而列表是访问量最大的场景。这既省了解密开销,更重要的是减少了明文在内存中出现的次数和范围这个决策我最初没想到,是在压测发现列表接口因为逐条解密而变慢之后才补的,结果发现它的安全收益比性能收益更大。

    密钥版本化的代价是每条记录多一个字段、解密逻辑要按版本分发。但不做的后果是密钥永远不敢换——因为换就要停机全量重加密。而「不能轮换的密钥」本身就是一个风险:它意味着一次泄露就是全量数据的永久暴露。判据是「这个系统要活多久」:只要它会长期运行,密钥就一定需要轮换能力。

    脱敏收敛到统一出口,而不是让各页面自己处理。各自处理的灵活性更高(每个页面可以按需要显示不同位数),但代价是新页面必然会忘记脱敏——这类漏洞在代码评审里也很难发现(因为看起来就是正常地返回一个字段)。统一出口加场景分级,把「默认行为」变成了安全的那一侧:忘记声明场景就得到最严格的脱敏。这一条和权限收敛、屏蔽过滤收敛是同一个思路。

    踩过的坑一:最初打算用无盐哈希做索引列。是安全同学在设计评审时指出的:证件号的结构是已知的(长度固定、部分位有规律),取值空间可以被批量枚举,无盐哈希等于明文这件事让我明白「哈希不可逆」这个说法有前提——它在取值空间足够大时成立,而在结构化的短数据上完全不成立。幸好是在设计阶段被发现的。

    踩过的坑二:第一版用固定密钥,被问「泄露了怎么办」时答不上来。我当时的理解是「加密了就安全了」,完全没有考虑密钥本身的生命周期教训是:加密的安全性等于密钥管理的安全性,而密钥管理包括存放、获取、轮换、废弃四件事,缺一件这个方案就是不完整的。

    踩过的坑三:脱敏最初是各页面自己写的,新加的一个导出功能忘了脱敏。是在一次数据抽查中发现的,导出的文件里证件号是完整的教训是:横切的安全规则如果依赖「每个开发都记得处理」,它一定会在某个地方被漏掉——必须做成默认行为。

    没做的部分:没做「用户自己也看不到完整证件号」这一步(目前用户本人可以查看自己保存的完整信息)。更严格的做法是连本人也只显示掩码,但这会影响「核对自己填的对不对」这个正常需求。取舍依据是「本人查看自己的信息」风险相对可控,而且有审计;不过我在文档里标注了这一点,因为如果风控要求提高,这是下一个要收紧的地方。

    数字是怎么测的

    先报覆盖面:多少张表、多少个字段、多少条历史数据完成了迁移。这类改造的第一个问题就是「改全了没有」,所以要报「扫描确认无明文残留」以及扫描方式(按字段名模式匹配 + 抽样解码验证)。

    性能影响必须报,因为加密一定有开销:报「按证件号查询(走哈希索引)的耗时」、「列表接口的耗时变化」列表这一项要说明是因为用了掩码列所以基本无变化——这是那个决策的直接证据。

    去重与查询的正确性用断言型用例:同一证件号重复保存,断言只保留一条;按证件号查询能命中;不同证件号的哈希不冲突(取一批真实格式的证件号验证)。

    密钥轮换的验证是这个模块最有价值的一组用例:新增密钥版本后,断言新数据用新版本、旧数据仍可解密、后台任务能逐批重加密、迁移期间两个版本的数据都能正常查询最后一条最容易漏。

    脱敏的验证要覆盖全部输出路径:接口返回、列表、详情、导出文件、日志。报法是「N 个输出路径逐一验证脱敏生效」并列出清单——列清单本身就是证明你想全了。导出和日志这两条最容易漏。

    审计完整性:断言每次解密都产生了审计记录且包含「查了谁」;批量导出产生了单独的留档。

    不要报什么:不要说「数据现在很安全」——安全性取决于密钥管理和访问控制,不是加密算法本身。该报的是「明文残留扫描结果与扫描方式」「列表接口因掩码列而耗时基本不变」「轮换期间双版本共存可正常读写」「N 个输出路径的脱敏逐一验证」这几件可核对的事。也不要报「防止了泄露」——没发生泄露不等于方案有效。

    面试追问
    Q:证件号加密之后你怎么按证件号查?直接存个哈希不就行了? A:存哈希这个方向是对的,但「直接存哈希」这个做法是错的,我最初也是这么想,在设计评审时被安全同学指出来了。问题在于证件号的取值空间有限且结构化——长度固定、部分位有规律(比如出生日期、地区码),可以被批量枚举。所以无盐哈希可以被枚举反查出明文,等于加密白做这件事让我明白「哈希不可逆」这个说法是有前提的:它在取值空间足够大时成立,在结构化的短数据上完全不成立。正确做法是用带密钥的哈希,而且密钥不能存在数据库里——没有密钥就无法枚举验证,同时同一明文的哈希是稳定的、可以建索引做等值查询和去重。另外两个候选方案我也想过,都不行用固定初始向量让密文可以直接比对——这削弱了加密本身,同一明文永远得到同一密文,本质上和确定性加密的问题一样;保留明文列另外加一列密文——那整改等于没做(这是最常见的偷懒做法)。我判断这类方案的标准是:攻击者拿到整个数据库之后,能不能还原出明文?无盐哈希能、固定初始向量能,带密钥哈希在密钥不泄露的前提下不能。代价是多一把密钥要管,而且哈希密钥的轮换比加密密钥更麻烦(要重算全部哈希,迁移期间查询还要兼容两个版本),所以两者的轮换周期我是分开考虑的。
    Q:你说「加密不是一个动作而是一套机制」,除了加解密本身还有什么? A:我把字段加密完之后立刻遇到四个问题,每一个都能让加密退化成形式主义。第一是查询:加密后同一明文每次密文不同,等值查询和去重都做不了——而这两个是硬需求(同一旅客不该存两条、客服要能按证件号排查)。解法是带密钥的哈希索引列。第二是密钥生命周期:我第一版用固定密钥,被问「泄露了怎么办」时答不上来——因为全量数据都用它加密,而我没有任何机制知道某条记录用的是哪个密钥,换密钥就要停机全量重加密,实际上做不到,于是密钥永远不敢换。而「不能轮换的密钥」本身就是风险:一次泄露就是全量数据的永久暴露。解法是每条记录存密钥版本号,支持「新数据用新版本、旧数据后台逐批重加密」。第三是脱敏出口:我最初让各页面自己写掩码,结果新加的一个导出功能忘了脱敏,导出文件里证件号是完整的(数据抽查时发现的)。横切的安全规则如果依赖「每个开发都记得处理」,一定会在某处被漏掉——所以要收敛到统一的序列化出口,并且默认脱敏、需要明文的场景显式声明,让忘记声明的后果是安全的那一侧。第四是访问审计:谁解密了、查了谁、用什么用途——没有「查了谁」这一项,审计日志就只能证明有人解密过,无法判断影响范围另外还有两个容易漏的配套:日志和异常堆栈里禁止输出证件号(异常打印参数是很常见的泄露路径),以及测试数据不用真实证件号。我还额外存了一个预先算好的掩码列,本来是为了解决列表逐条解密导致的性能问题,后来发现它的安全收益更大——列表展示完全不需要解密,明文在内存中出现的次数大幅减少。

没有匹配的内容,换个关键词试试。

项目拆解 · 预订履约周边(后端 · 实习级)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据