开通一个试用租户,需要做的事比我最初预想的多得多:建租户记录、建默认角色和权限、建一套组织结构、灌一批示例数据、初始化通知配置、调计费系统登记、发欢迎邮件——十几项。我第一版把它们全部放在一个事务里串行执行。四个问题。
一是事务里有外部调用,回滚回滚不掉。调计费系统登记、发邮件这些是外部动作。某一步失败,本地数据库回滚了,但计费系统那边已经登记了、邮件已经发出去了。用户收到了「开通成功」的邮件,登录进来发现什么都没有。
二是失败之后无法重试。整体回滚意味着「什么都没留下」听起来很干净,但实际上外部调用留下了痕迹,而本地又没有任何记录说明「这个租户尝试开通过」。用户重新提交,唯一性校验又撞上了外部系统里已存在的记录——彻底卡住,只能人工介入。
三是初始数据写死在代码里。示例数据、默认角色名称、预置的通知配置,全部是代码里的常量。运营想给不同行业的客户配不同的示例数据,每次都要改代码发版。
四是试用到期完全没处理。试用期字段存了,但没有任何逻辑检查它。结果是一批试用租户过期几个月了还在正常使用全部功能——而这直接影响转付费的动力。这和「到期这件事没有任何用户操作会触发它」是同一类问题。
所以四个改动:拆成可重入的步骤任务并记录每步完成状态、失败从断点续做、每步各自幂等、初始数据抽成可配置模板、同一企业重复提交幂等返回已有租户,另外补了到期前提醒与到期后自动降级。
这个模块最想说的一句话是:一个包含外部调用的多步流程,用「一个大事务 + 全部回滚」来保证一致性是行不通的,因为你回滚不了外部系统。正确的思路是把它当成一个可以「做到一半、下次接着做」的任务——记录每一步的完成状态、每一步都幂等、失败了从断点继续。我最初想用事务解决所有问题,是因为我把它当成了一次数据库写入,而它其实是一个流程。
放弃「一个大事务 + 全部回滚」,改成「可重入的步骤任务」。大事务的吸引力在于「要么全成功要么什么都没发生」,但这个承诺在有外部调用时根本兑现不了——你回滚不了别人的系统。而且整体回滚之后本地没有任何记录说明「尝试开通过」,用户重新提交又会撞上外部系统里已存在的记录,彻底卡住。可重入任务的代价是要维护步骤状态、每步都要幂等、可能存在「做到一半」的中间状态。但中间状态是可恢复的,而「半个回滚」是不可恢复的。判据是「这个流程里有没有你无法回滚的动作」:有,就不能靠事务。
初始数据模板变更只对新租户生效,不追溯已有租户。追溯能让所有租户都拿到最新的预置内容,但它会覆盖客户自己改过的配置——那是数据事故。判据是「这份数据在创建之后是否属于客户」:属于客户的东西我们不能单方面改。如果确实需要给已有租户补充新的预置项,那应该是一个显式的、可选择的「导入」动作,而不是模板变更的自动效果。
到期后选降级而不是停用。停用最能推动转化(用不了就得付钱),但它把客户的数据也一起锁住了——而数据是客户的,扣着不给违背基本的信任;而且客户拿不到数据的第一反应是投诉和差评,转化机会反而没了。降级(限制写入、保留查看与导出)的判据是:我们的目标是让他转付费,不是惩罚他。
踩过的坑一:事务里有外部调用,回滚之后外部副作用留下了。用户收到了「开通成功」的欢迎邮件,登录进来发现什么都没有,他的第一反应是「你们系统坏了」。教训是:事务边界要和「可回滚的范围」对齐——把不可回滚的动作放进事务,事务的保证就是假的。我当时的思路是「用事务把所有步骤包起来最安全」,完全没有区分哪些步骤是可回滚的。
踩过的坑二:整体回滚导致用户彻底卡住,只能人工介入。本地什么都没留,但外部系统已经有记录了,用户重新提交时唯一性校验直接失败。教训是:失败之后「什么都不留」并不等于「回到了初始状态」——只有当所有副作用都可回滚时这句话才成立。更实用的做法是留下失败记录,让它成为一个可以继续推进的状态。
踩过的坑三:试用到期字段存了但没有任何逻辑检查它。一批租户过期几个月还在用全部功能。这和资质有效期那类问题完全同源:到点该变的状态,没有任何用户操作会触发它。我后来把这一条固化成了自检:凡是数据里有「到期时间」「有效期」这类字段,就必须回答「谁来检查它、检查到了做什么」。
没做的部分:没做开通流程的可视化编排(让运营自己配步骤顺序)。它听起来灵活,但步骤之间有依赖(角色必须在组织结构之前建),让非开发配置顺序风险很高。我做的是「步骤清单在代码里显式声明、但每步的内容用模板配置」——把变化频繁的部分开放,把有依赖关系的部分固定。
核心指标是「需要人工介入的开通工单数」。它直接对应「失败无法重试」这个原始问题。报法是改造前后的对比,并说明统计区间与总开通量(分母很重要,开通量本身在增长)。
「停在中间状态的任务数」要作为长期监控指标报出来。它应该长期为零;报法是「上线后 N 天内该指标为零,期间有 M 次步骤失败均被自动重试恢复」——后半句才是这个设计真正的价值。
断点续做要用断言型用例:让第五步失败,断言前四步的数据保留、重跑时从第五步开始、前四步不被重复执行。「前四步不被重复执行」这一条最关键,要靠数据条数断言。
步骤幂等:对每一步单独重复执行,断言不产生重复数据。这是一组机械但必要的用例。
入口幂等:并发提交同一企业标识的开通请求,断言只产生一个租户、其余请求返回同一个租户。要真并发测。
模板不追溯的验证:修改初始数据模板后,断言已开通租户的配置不变、新开通的租户使用新模板。这条防的是数据事故。
到期处理:用可控时间点断言到期前触发提醒、到期后限制写入但仍可登录查看导出;转付费后恢复且重复触发不产生副作用。
不要报什么:不要报「开通成功率 100%」——外部系统会不可用。该报的是「人工介入工单数下降与总开通量」「中间状态任务数为零且失败被自动恢复的次数」「第 N 步失败后重跑不重复前面步骤」「模板变更不影响已有租户」这几件可核对的事。
邀请成员这个功能第一版做得很快:管理员填邮箱,系统插一条邀请记录,把记录的自增标识拼到链接里发出去,对方点开链接就加入租户。四个问题,其中两个是安全和正确性问题。
一是邀请链接可以被枚举。我用的是邀请记录的自增标识。把链接里的数字改一改就能拿到别人的邀请——而邀请一旦被使用,那个人就加入了别的公司的租户,能看到里面的数据。这是安全同学在评审时指出的,幸好没上线。
二是席位会超出配额。席位是租户级的配额(比如买了 10 个)。管理员可以发出多于剩余席位的邀请(这本身是合理的,因为不是每个人都会激活)。我的实现是激活时「先查剩余席位、够就插入成员」。三个人同时点激活,三个请求都查到「还剩 1 个」,然后都插入成功——租户变成了 12 个成员而只买了 10 个席位。
三是被邀请人已有账号的情况完全没考虑。SaaS 产品里同一个人可能同时属于多个租户(比如外部顾问)。我的实现是激活时直接建一个新账号——结果是同一个邮箱有了两个账号,两边的密码、设置、历史都不通。用户很困惑。
四是邀请撤不回来。管理员发错了邮箱、或者对方还没入职就先离职了,邀请链接依然有效。更严重的是成员被移除之后,他之前收到的那封邀请邮件还能用——点一下又回来了。
所以四个改动:令牌改成足够长度的随机值并只存哈希、加有效期与单次使用、席位改成激活时原子占用、处理已有账号与跨租户身份、支持撤回并与成员移除联动失效,另外补了邮箱规范化比较。
这个模块最想说的一句话是:凡是「发给用户、由用户点回来」的链接,都要当成不可信输入来设计——它会被转发、被截图、被枚举、被重复点击。我最初把邀请链接当成了「一个指向数据库记录的引用」,而它实际上是一个凭据。凭据的四个基本要求是:不可猜测、有有效期、只能用一次、可以被撤销——我一个都没做。
席位并发选「原子占用」而不是「加分布式锁」。分布式锁也能解决,但它的粒度是整个租户的激活操作(同一租户的激活会串行),而且要处理锁超时、锁失效这些复杂情况。原子占用(计数的原子操作,或数据库层的条件更新加唯一约束)更简单也更可靠。判据是「这个并发冲突的范围有多大」——只是一个计数的争用,用原子操作就够,不需要上锁。
允许发出超过剩余席位的邀请,而不是发邀请时就占席位。发邀请时占席位能彻底避免超配额,但实际情况是很多邀请永远不会被激活(人换了、忘了、不需要了),提前占席位会白白浪费配额,管理员还要手动去清理。所以我选择「发邀请不占、激活时才占」,代价是激活时可能失败。缓解办法是发邀请时明确提示席位状况——让管理员知道会不会超。判据是「哪种失败对用户的伤害更小」:白占席位是持续的浪费,激活失败是一次性的、且有明确提示和解决路径。
同一账号可属于多个租户,而不是「一个邮箱一个租户」。后者实现简单得多(不需要租户切换、不需要处理归属追加)。但外部顾问、集团多子公司、服务商这些场景是真实存在的,强制一个邮箱一个租户会逼用户注册小号——而小号带来的问题(找不回、权限混乱)比支持多租户归属更麻烦。代价是所有数据访问都必须带租户上下文,漏一处就是数据串租户,所以这一条必须收敛到统一的上下文获取入口,不能靠每个开发记得传。
踩过的坑一:邀请链接用了自增标识。是安全同学在设计评审时指出的——改一改链接里的数字就能拿到别人的邀请,用了它就会加入别的公司的租户、看到里面的数据。教训是:我把邀请链接当成了「一个指向数据库记录的引用」,而它实际上是一个凭据。凭据的四个基本要求是不可猜测、有有效期、只能用一次、可以被撤销——我一个都没做。幸好是在评审阶段被发现的。
踩过的坑二:席位「先查再插」,并发激活导致超配额。三个人同时点激活,都查到「还剩 1 个」然后都成功了。教训是:任何「检查配额然后消耗配额」的逻辑,检查和消耗必须是一个原子操作——这一类问题(库存、席位、优惠券、限流)的形态完全一样,而「先查再写」在单人测试时永远正确。
踩过的坑三:成员被移除后,他手里的旧邀请邮件还能用,点一下就回来了。是一个客户的管理员发现并反馈的——他移除了一个离职员工,几天后发现这个人又出现在成员列表里。教训是:撤销权限的操作要把「所有能重新获得权限的路径」都一起关掉——移除成员只关了「现有身份」,没关「邀请这条入口」。这和「拉黑要双向、要覆盖所有入口」是同一类思维:一个权限相关的状态变更,要列出它的全部影响面。
没做的部分:没做基于企业域名的自动加入(同域名邮箱注册即自动加入该租户)。它能大幅降低邀请成本,但风险很高——共享邮箱域名(公共邮箱后缀)、外包人员用了企业邮箱、以及域名所有权验证都要处理。我提了方案但结论是「必须先有域名所有权验证能力,否则这个功能会成为一个越权入口」。
席位并发必须真并发压测:剩余 1 个席位,并发发起 5 个激活请求,断言只有 1 个成功、其余 4 个收到「席位不足」的明确提示,且最终成员数不超过配额。这是这个模块最重要的一条用例,顺序调用测不出来。
席位对账要作为长期监控指标:报「席位使用数与配额的对账差异长期为零」。一旦不为零就说明并发控制有漏洞,这个监控比一次性测试更有价值。
令牌安全性可以报设计参数而不是测试结果:令牌长度、随机来源、存储方式(只存哈希)、有效期。这些是可核对的事实;同时断言「用被撤回或已使用的令牌激活会被拒绝」。
单次使用:同一令牌并发激活两次,断言只成功一次;成功后再次访问链接,断言提示已使用。
账号处理三种情况各写一条用例:未注册邮箱、已注册邮箱、已是其他租户成员,断言分别是引导注册、关联已有账号、追加租户归属而不重复建号。第二条是踩坑的直接回归。
联动失效:移除成员后,断言其名下未使用的邀请全部失效、用旧链接无法重新加入。这条是权限漏洞的回归。
邮箱规范化:用大小写不同、带首尾空白的同一邮箱重复邀请,断言被识别为同一人。
漏斗数据:报邀请发出、激活、过期、撤回的转化,并按失败原因分类。这些数字用于调整有效期和席位提醒策略。
不要报什么:不要报「邀请激活率提升」——它主要取决于客户内部的推动力度。该报的是「剩余 1 席并发 5 个激活只成功 1 个」「席位对账差异长期为零」「令牌长度与存储方式及撤回后即失效」「三种账号情况分别正确处理」「移除成员后旧邀请失效」这几件可核对的事。
通知文案原来是硬编码在代码里的。客户的需求很常见:把「您的审批已通过」改成带自己公司口径的说法、把签名换成自己的公司名、邮件里加上自己的联系方式。每来一个这样的需求就要改代码发版,而且不同客户要求不同,代码里开始出现按租户判断的分支。四个问题。
一是硬编码加租户分支很快就失控了。几个客户之后,一段文案里出现了好几层条件判断。新增一个客户的定制,要在十几个通知点各加一个分支——而漏改一处就是「有的通知定制了、有的没定制」,客户会认为我们做得很随意。
二是改成模板配置化之后,出现了新问题:保存时不校验变量。客户在模板里写了一个不存在的变量名(打错字、或者以为有这个字段)。保存成功,但真实发送时渲染报错——而那时通知已经发不出去了。更糟的是这个错误只在特定业务事件触发时才暴露,可能是几天之后。
三是渲染失败导致通知整条丢失。我最初的处理是渲染异常就抛出、消息进死信队列。结果是客户的一批审批通知完全没发出去——而通知丢失的影响远大于「文案不是定制的」。客户宁可收到默认文案,也不愿意收不到通知。
四是模板渲染有注入风险。模板里会插入用户输入的内容(比如申请单标题)。如果这段内容本身包含模板语法或标记语言的特殊字符,会被当成模板或标记去解析——最轻的表现是渲染出错,严重的会造成邮件内容被篡改。
所以四个改动:模板配置化并按租户到行业到默认三层回退、变量限定白名单并在保存时校验、渲染或发送失败降级到默认模板重试、渲染用受限模式并对变量值转义,另外补了预览与测试发送。
这个模块最想说的一句话是:把配置能力开放给客户,就必须同时提供「校验」和「兜底」——否则你只是把出错的机会转移给了客户,而后果还是由你承担。模板配置化本身很容易做,难的是保证「客户配错了不会导致通知发不出去」:保存时校验拦住可预见的错误,渲染失败降级兜住不可预见的,两者缺一个,这个功能就会从「解决问题」变成「制造事故」。
渲染选「受限模式」而不是完整模板引擎。完整引擎功能强、客户能做复杂的条件和循环。但客户配的模板是外部输入,开放完整引擎等于开放了一个可执行入口——这个风险不可接受。受限模式的代价是复杂需求做不了(比如按金额区间显示不同文案)。我的处理是把常见的复杂需求归纳成有限的几种条件形式内建支持,而不是开放通用表达式。判据是「这个输入是否可信」:不可信,那能力就必须受限。
渲染失败降级到默认模板,而不是让通知失败。让它失败看起来更「诚实」(不掩盖问题),但结果是客户的一批业务通知完全没发出去——而通知丢失的业务影响远大于「文案不是定制的」。降级的代价是可能掩盖问题,所以必须配告警加通知租户管理员——降级本身是兜底,但不能是静默的。判据是「哪种失败的业务影响更小」,同时保证失败可见。
保存时校验 + 渲染失败降级,两者都要有。只做保存校验挡不住不可预见的错误(变量值异常、渠道限制变化);只做降级则会让客户一直配错而不自知。这一条我认为是这个模块最核心的设计判断:把配置能力开放给客户,必须同时提供「校验」和「兜底」——否则你只是把出错的机会转移给了客户,而后果还是由你承担。
三层回退链而不是两层。只做「租户 → 默认」也能跑,但行业模板这一层的价值很大——同行业客户的口径需求相近,预置一份能让大部分客户不用自己配。代价是取用逻辑多一层、排查时要能说清用的是哪一层。所以回退发生要记日志。
踩过的坑一:模板保存时不校验变量,真实发送时才报错。客户在模板里写了一个不存在的变量名(打错字),保存成功,几天后某个审批事件触发时渲染失败、通知发不出去。而客户完全不知道是自己模板的问题,他的反馈是「你们的通知不发了」。教训是:校验的时机应该尽量靠近「用户做出错误动作的那一刻」——保存时校验的成本和发送时校验一样,但前者能让客户立刻改,后者已经造成了业务损失。
踩过的坑二:渲染异常直接进死信队列,一批通知全丢。我当时的想法是「失败就该暴露出来」。但暴露的方式不该是让业务通知消失——客户是在业务出问题之后(审批没人处理)才发现的。教训是:对于「附属能力失败」的处理,要问「有没有一个可用的降级结果」;有的话就该降级,同时告警让问题可见。「失败就抛出」在核心链路上是对的,在通知这类场景上代价太大。
踩过的坑三:变量值没有转义,用户输入的标题里含特殊字符导致邮件结构被破坏。表现是邮件排版乱掉、部分内容不显示。教训是:模板渲染里「模板」和「数据」必须严格区分——模板是我们和客户配置的,数据是用户输入的,数据代入模板时必须转义,否则数据就有机会变成模板的一部分。这和防注入是同一个原理。
没做的部分:没做模板的多语言版本管理。它对有海外团队的客户有价值,但需要在「租户 × 类型 × 渠道」之上再加一个语言维度,回退链会变成四层,复杂度明显上升。取舍依据是当前客户的需求集中在中文口径定制上;我在设计时把语言留成了模板的一个字段,这样将来扩展不需要改表结构。
核心价值指标是「模板类定制需求占用的开发排期」。改造前每个定制需求都要改代码发版。报法是「改造前 N 个月内处理了 M 个模板定制需求、平均耗时若干人天;改造后此类需求由客户自助完成」——这个对比很具体。
回退链的正确性用断言型用例:分别构造「有租户模板」「无租户模板有行业模板」「都没有」三种情况,断言取用的模板正确、且回退时记录了日志。
保存校验:提交含不存在变量的模板,断言被拒绝且错误信息指出具体位置;提交超长的短信模板,断言被拒绝。
转义防护:用包含模板语法与标记语言特殊字符的变量值渲染,断言这些内容被当作纯文本呈现、没有被二次解析、邮件结构完整。这条是安全性质的,必须有。
降级:人为让租户模板渲染失败,断言自动使用系统默认模板发送成功、同时产生了告警与管理员通知。「产生告警」这一条最容易漏,而它是降级不变成静默掩盖的关键。
默认模板的健壮性:用缺失全部可选变量的数据渲染系统默认模板,断言仍能成功渲染。它是兜底,不能自己也失败。
变量缺值:断言渲染出中性占位文案,而不是空白或变量名字面量。
测试发送:断言真实走渠道、发给操作人、且不计入配额与统计。
模板变更后的发送成功率要监控:报「按渠道的发送成功率」,模板变更后异常下降要能发现——这是保存校验之外的第二道观察。
不要报什么:不要报「模板渲染成功率 100%」——客户会配出各种意外情况。该报的是「模板定制需求从占用开发排期变为客户自助」「三种回退情况取用正确」「特殊字符被当作纯文本呈现」「渲染失败自动降级且产生告警」「默认模板在缺失全部可选变量时仍可渲染」这几件可核对的事。
没有匹配的内容,换个关键词试试。
项目拆解 · 租户开通与成员管理(后端 · 实习级)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据