ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RuoYi-Vue-Plus多租户续费与过期处理实战:从数据隔离到生命周期闭环

RuoYi-Vue-Plus多租户续费与过期处理实战:从数据隔离到生命周期闭环 去年年末我们基于RuoYi-Vue-Plus做的SaaS平台第一次遇到客户追着问“我们合同到期了为什么系统还能登录”的时候我就意识到租户续费与过期处理这种听起来不起眼的功能真正落地远比想象中麻烦。RuoYi-Vue-Plus自带的多租户能力本质上是给数据隔离服务的并不关心租户有没有付费、会不会过期。你要想在业务层面做“到期自动停用、续费后恢复、给运营留提醒入口”得靠自己把这条生命周期链路补完。这篇文章我把踩过的坑和最终落地的方案整体梳理一遍涉及到数据表设计、续费的时间计算边界、定时任务扫描、登录拦截、在线会话清理这些环节。适合正在用RuoYi-Vue-Plus做SaaS产品、并且需要给租户加“有效期”概念的团队参考。如果你用的是原版若依或其它微服务版本里面的思路同样可以平移。1. 先搞清业务边界框架管数据隔离不管租户生死1.1 RuoYi-Vue-Plus自带多租户到底做了什么事RuoYi-Vue-Plus的租户插件本质上是MyBatis-Plus的TenantLineInnerInterceptor它的核心能力是在SQL执行时自动追加tenant_id条件、在插入时自动填tenant_id。它解决的是“A租户的数据不能被B租户看到”这个基础隔离问题仅此而已。这一点很多刚接触的人会误判以为框架里既然有“租户套餐”“租户管理”这些菜单那续费生命周期肯定也自带了。实际情况是系统管理里的租户管理只提供了增删改查租户套餐也只维护了权限菜单范围。你翻遍官方文档找不到“到期时间”“自动冻结”“续费延长”这些概念。它们属于业务层需求必须自己动手。我在第一版设计时还偷懒过既然租户插件能自动带条件那我直接给sys_tenant表加个过期时间字段不就行了后来发现不行因为好几张核心表在租户插件的忽略列表里管理端对sys_tenant的操作根本不会受租户上下文限制。也就是说如果前段逻辑稍不注意你以为是“在某个租户上下文里做操作”实际上SQL跑出来是全局范围的这会让续费记录、状态修改出现严重的数据错乱。1.2 续费、提醒、冻结、恢复这条链路要怎么闭环想清楚“做什么”之后我把需求拆成了下面这六步也是后面设计落地的核心脉络环节触发时机关键动作开通租户新增租户时根据套餐时长初始化到期时间到期前提醒距到期7天/3天/1天给租户管理员发站内信通知续费收到款项后延长到期时间记录续费流水已过期冻结定时任务扫描租户状态置为停用记录停用原因登录拦截用户登录时停用租户下所有账号禁止登录续费恢复续费成功后如果是过期停用则恢复状态、清理会话缓存这套链路里最容易被忽略的是“状态表达”这件事。一个租户被停用了到底是管理员手动停用还是到期自动停用这两者的恢复逻辑完全不一样手动停用的租户不该因为一次续费就悄悄恢复上线到期自动停用的租户续完费应该立刻恢复。所以状态字段不能只用一个status硬扛必须搭配一个停用原因字段。这个设计在后面接口实现时帮了大忙。2. 数据模型改造到期时间放哪张表、续费记录怎么留痕2.1 sys_tenant表增加到期时间字段我用的RuoYi-Vue-Plus版本里sys_tenant表已经有租户编号、联系人、企业名称、状态这些字段但没有到期时间。补一个字段是最直接的做法ALTER TABLE sys_tenant ADD COLUMN expire_time datetime NULL COMMENT 到期时间 AFTER status, ADD COLUMN stop_reason varchar(32) NULL COMMENT 停用原因MANUAL-手动停用 EXPIRED-到期停用 AFTER expire_time;如果你的版本里sys_tenant表自带expire_time字段那更好只需要补一个stop_reason就行。这里有一个细节stop_reason的默认值我没设置为NULL而是要求代码里严格按“手动停用”“到期停用”两种枚举写入。为什么这么强调因为后续定时任务扫描时判断“哪些租户需要被冻结”要看状态和原因混合条件如果历史数据里全是脏值一次批量更新可能把手动停用的租户也恢复掉那就不只是技术事故而是资损了。2.2 续费记录表流水式的账单不是简单的日志续费这个动作必须留痕。我见过有人直接在更新记录里写“修改了expire_time字段”这个颗粒度太粗了。续费单笔操作虽然简单但查询维度很多运营想看某段时间内有多少租户续费、每个租户续了几次、续费前到期时间是多少。所以最终我落了一张独立的续费流水表CREATE TABLE sys_tenant_renewal_log ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, tenant_id bigint NOT NULL COMMENT 租户ID, old_expire_time datetime DEFAULT NULL COMMENT 续费前到期时间, new_expire_time datetime NOT NULL COMMENT 续费后到期时间, renewal_days int NOT NULL COMMENT 续费天数, amount decimal(10,2) DEFAULT NULL COMMENT 续费金额(元), remark varchar(500) DEFAULT NULL COMMENT 备注, create_by varchar(64) DEFAULT NULL COMMENT 操作人, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), KEY idx_tenant_id (tenant_id), KEY idx_create_time (create_time) ) ENGINEInnoDB COMMENT租户续费记录表;这里说明几个设计意图old_expire_time和new_expire_time必须同时记录。运营在后台核对账单时经常要回答“这个客户上次续费到什么时候这次又加了多少天”如果没有旧值只能靠猜。renewal_days单独存一个字段是给统计报表用的。不要指望通过new_expire_time - old_expire_time去算日期相减容易因为时区、天数进位出偏差。create_by存操作人不存操作人ID。因为续费可能是平台运营手工操作也可能后面接线上支付系统自动触发操作人可能是“系统”纯用户ID表不够用。2.3 跟租户套餐表的关系套餐给时长租户持有到期时间在RuoYi-Vue-Plus里租户套餐表是sys_tenant_package管理端新建租户时会关联一个套餐套餐决定这个租户能看哪些菜单。但套餐和到期时间之间没有强关联。我在设计时做了一个约定新增租户时根据套餐的“时长配置”初始化租户的expire_time后续续费时不再依赖套餐而是直接给租户延长指定天数。也就是说套餐表可以加一个package_days字段纯业务扩展不强制但核心判断永远以sys_tenant.expire_time为准。这么做的好处是租户中途换套餐不会影响到期时间续费也跟套餐解耦不用每次续费都去改菜单权限。逻辑简单清晰运营也好解释给客户听。3. 续费接口实现时间计算、状态恢复与租户插件的“自伤”3.1 续费入参和计算规则过期后续费要从哪天算起续费接口的入参很简单租户ID、续费天数、金额、备注。难的是到期时间的计算规则。我第一次实现时就写错了以为“续费就是在原到期时间上加天数”后来被运营怼了有个客户已经过期一个月了才来续费结果按原到期时间加了90天客户实际只享受了60天投诉说我们套路他。正确逻辑应该是分两种情况SaCheckPermission(system:tenant:renew) public void renewTenant(RenewalDTO dto) { SysTenant tenant tenantMapper.selectById(dto.getTenantId()); Assert.notNull(tenant, 租户不存在); Date now DateUtils.getNowDate(); Date oldExpireTime tenant.getExpireTime(); Date newExpireTime; // 判断是否已过期原到期时间为空或早于当前时间 boolean isExpired oldExpireTime null || !oldExpireTime.after(now); if (isExpired) { // 已过期从当前时间开始计算不能让客户白赚过期空档期 newExpireTime DateUtils.addDays(now, dto.getRenewalDays()); } else { // 未过期在原到期时间基础上累加 newExpireTime DateUtils.addDays(oldExpireTime, dto.getRenewalDays()); } // 更新租户 tenant.setExpireTime(newExpireTime); // 只有“到期停用”的租户才自动恢复手动停用的不恢复 if (1.equals(tenant.getStatus()) StopReason.EXPIRED.name().equals(tenant.getStopReason())) { tenant.setStatus(0); tenant.setStopReason(null); } tenantMapper.updateById(tenant); // 写续费流水 TenantRenewalLog log new TenantRenewalLog(); log.setTenantId(tenant.getTenantId()); log.setOldExpireTime(oldExpireTime); log.setNewExpireTime(newExpireTime); log.setRenewalDays(dto.getRenewalDays()); log.setAmount(dto.getAmount()); log.setRemark(dto.getRemark()); log.setCreateBy(SecurityUtils.getUsername()); tenantRenewalLogMapper.insert(log); // 清理该租户的缓存信息 refreshTenantCache(tenant.getTenantId()); }这段代码里有两个值得注意的判断过期续费从当前时间算这是“账期公平”的底线。代码注释里也写了不要让客户白拿过期空档期。状态恢复不是无脑恢复。stop_reason字段此时就发挥作用了只有因为欠费到期被系统停用的租户续费后才恢复如果是人工停用的说明业务上有别的纠纷续费也只延长到期时间不改变状态由运营人工处理。3.2 租户插件的“反噬”操作日志表时自动带了当前租户ID这是我实际开发中踩得最深的一个坑。RuoYi-Vue-Plus的租户插件默认行为是任何SQL语句只要操作的表不在忽略列表里并且表里存在tenant_id字段插件就自动追加条件。续费流水表sys_tenant_renewal_log里有tenant_id字段于是问题来了。场景是这样的平台超管登录后台时为了方便管理切换到某个租户的上下文下操作。此时调用续费接口给另一个租户续费。tenantMapper.updateById(tenant)不受影响因为sys_tenant在忽略列表里。但插入续费流水时租户插件拦截SQL自动给insert into sys_tenant_renewal_log补充了当前上下文的tenant_id结果续费记录记到了“当前上下文租户”头上而不是实际被续费的租户头上。这个问题的排查过程很痛苦因为日志流水看起来只是“多了一条记录”但数据归属错了。我当时用全局SQL日志打印才发现插件把tenant_id自动填成了自己菜单栏里切换的那个租户。解决方案我选的是把续费流水表放进租户插件的ignoreTables配置里mybatis-plus: tenant: ignore-tables: - sys_tenant_renewal_log这样续费流水表不做租户隔离。业务上也说得通续费流水本质上是平台级的账务数据它记录的是“平台跟某个租户之间的交易”不是租户自己产生的业务数据。平台方需要全量看到所有流水做账务核算租户侧如果需要查看自己的续费记录靠tenant_id字段过滤查询即可不必依赖租户插件。同类问题也要检查凡是平台级管理操作涉及的扩展表比如账单表、操作记录表、接口调用计费表都建议评估是否应该放进忽略列表而不是默认享受租户隔离。这条经验同样适用于别的租户体系。3.3 续费成功后的缓存刷新数据库改了内存里还陈着呢RuoYi-Vue-Plus里登录用户信息、租户信息会有缓存。光改数据库不刷新缓存会有一段时间的“假正常”或“假停用”。我遇到过的情况是租户续费了状态也恢复了但用户端登录还是被拦截提示“租户已停用”——因为认证信息是在登录时拼装并缓存下来的里面的租户状态还是旧的。在续费接口里补一句refreshTenantCache(tenant.getTenantId())只是第一步完整的处理还包括清理这个租户下所有正在登录用户的会话缓存。这块放到过期处理章节细说因为定时任务冻结租户时也需要同样的操作。4. 过期处理机制定时任务扫描、登录拦截与在线会话清理4.1 定时任务扫描别在线上库一次性捞全量数据过期处理的主流程是定时任务。RuoYi-Vue-Plus自带Quartz调度组件我直接在任务里注册了一个处理器类每天凌晨2点执行。核心逻辑是三步扫描已到期且仍正常的租户、将状态置为停用并标记原因、清理相关缓存与会话。public void handleExpiredTenants() { DateTime now DateUtils.getNowDate(); // 分批扫描一次只查2000个防止大表全量查询拖垮数据库 long lastId 0; int batchSize 2000; while (true) { ListSysTenant expiredList tenantMapper.selectList(new LambdaQueryWrapperSysTenant() .eq(SysTenant::getStatus, 0) .lt(SysTenant::getExpireTime, now) .gt(SysTenant::getTenantId, lastId) .orderByAsc(SysTenant::getTenantId) .last(LIMIT batchSize)); if (CollUtil.isEmpty(expiredList)) { break; } for (SysTenant tenant : expiredList) { tenant.setStatus(1); tenant.setStopReason(StopReason.EXPIRED.name()); tenantMapper.updateById(tenant); // 清理该租户下的会话和缓存 clearTenantSession(tenant.getTenantId()); lastId tenant.getTenantId(); } // 避免continuous loop时内存里堆积分批推进lastId } }这里专门说一下为什么用“以主键ID游标分批”而不是一次select * where expire_time now真实生产环境租户量可能只有几千家分批意义不大但如果这个平台是开放注册的SaaS租户量到几万十几万的时候一次性把所有过期租户加载到内存再逐条更新慢查询和内存压力同时出现。用主键游标可以保证每次查询走索引内存里始终只有一批数据。定时任务的触发频率我调的是每天一次。如果你想做“到期当天就冻结”可以改成每小时扫描一次但务必配合上面这种分批写法。不建议把频率调到秒级或分钟级因为过期冻结本身不是强一致场景差一个小时对业务几乎没有影响没必要给数据库施加额外压力。4.2 登录拦截冻结的租户要从源头挡住定时任务把租户状态改成停用了但如果登录逻辑不校验已经知道账号密码的用户依然能进来。登录侧需要加一道判断在RuoYi-Vue-Plus登录认证成功后拿到租户信息时检查状态和到期时间。我是在LoginHelper里加的租户校验逻辑很简单// 登录成功后加载租户信息 TenantDO tenant tenantService.getTenantByTenantId(user.getTenantId()); if (tenant ! null 1.equals(tenant.getStatus()) StopReason.EXPIRED.name().equals(tenant.getStopReason())) { throw new ServiceException(当前租户已到期请联系管理员续费); }这里只拦截“到期停用”的租户不拦截“手动停用”的不对。实际上手动停用的租户也应该拒绝登录。上面代码直接判断status为1就拦截这样最稳。stop_reason在这里不参与判断因为不管什么原因只要status是1这个租户就不该进来。更细的场景是租户管理端的登录页一般先让用户选租户再输账号密码。如果租户列表接口不做过滤前端下拉框会把已停用的租户也展示出来用户选完才发现登不进去体验很差。所以我顺手把租户列表接口也改了状态为停用的租户不再出现在登录选择列表中。4.3 清理在线会话数据库停了登录态还在就白搭这是另一个容易漏的点。RuoYi-Vue-Plus的会话是基于Redis保存的用户登录后拿到一个token后续请求带着token就能访问接口。定时任务把租户状态改成了停用但用户浏览器里已经登录Redis里的会话仍然有效如果不主动清理他依然能操作接口直到token过期。清理方案简单直接按租户维度删除该租户下所有用户的token。private void clearTenantSession(Long tenantId) { // 根据你的redis key设计这里举例用login_tokens:租户ID:*前缀 SetString keys redisTemplate.keys(login_tokens: tenantId :*); if (CollUtil.isNotEmpty(keys)) { redisTemplate.delete(keys); } }注意生产环境慎用keys *量大时有阻塞风险最好用scan实现。租户量少时问题不大但如果你的Redis里session key有几十万个keys可能会导致卡顿。我在线上就是改成了scan的方式按前缀分页删除。会话清理后用户下次请求带着旧token会被判定未登录重新登录时又走到登录拦截的校验发现租户停用被挡在外面。整个闭环就通了。另外一个相关细节RuoYi-Vue-Plus的管理端登录和租户管理端登录最好都走同一套会话清理逻辑。如果平台管理员在后台切换租户上下文并继续访问租户数据这些会话也需要被清掉否则后台管理员还能以该租户的身份继续操作。5. 运营后门宽限期、提醒通知与手动恢复5.1 宽限期到期和冻结之间留一个缓冲严格按到期时间冻结客户体验会非常硬“我前一天还在用第二天突然登录不上连报表都没导出来”。运营侧通常会在到期和冻结之间留一个宽限期比如到期后3天内仍能正常访问第4天才冻结。用数据库实现宽限期不需要额外字段只需要在定时任务的查询条件里做一个时间偏移// 实际冻结时间 到期时间 宽限天数 DateTime freezeTime DateUtils.addDays(now, -graceDays); ListSysTenant expiredList tenantMapper.selectList(... .lt(SysTenant::getExpireTime, freezeTime) ...意思就是以当前时间往回推3天如果租户的到期时间早于这个时间点说明已经超过了宽限期该冻结了如果只是刚刚到期还在宽限期内暂不处理。宽限期配置放在数据字典或系统配置表里让运营可以动态调整不用改代码。这个设计很轻但实用性很强。我在一期里就把它做了进去运营后续调整活动政策完全不用找开发。5.2 到期前提醒站内信加通知别等冻结了客户才知道到期提醒的时机我放在了“到期前7天”“到期前3天”和“到期前1天”三个节点。实现方式是在定时任务里增加一个“提醒”分支扫描到期时间在对应时间范围内的租户往租户管理员账号发站内信。RuoYi-Vue-Plus有现成的通知表和相关服务直接复用即可。提醒消息至少要包含三要素当前到期时间、续费方式说明、过期后影响范围。我在消息正文里还会附上到期时间格式化后的具体日期因为很多客户只记“我们大概月底到期”给他们一个准确日期能减少大量电话咨询。需要注意重复发送问题定时任务每天跑如果今天发过“7天提醒”明天扫描时不能再发一次。我的处理是建了一张sys_tenant_notice_log表记录租户ID、提醒类型、发送时间查询时先判断是否已发过。这个表很小也放在了租户隔离忽略列表里因为它属于平台级运营数据。5.3 手动恢复和续费后恢复的边界运营后台需要支持“手动恢复租户”。我加了一个接口允许有权限的运营人员直接修改租户状态恢复启用同时记录操作日志。但这里遵循一条原则手动恢复前系统检查该租户到底是不是欠费冻结如果是提示运营先去确认续费款项如果不是直接恢复即可。恢复动作和续费动作解耦后运营操作起来非常顺手客户说“我已经打款了”运营先看付款截图然后在后台点“续费”系统自动完成延长到期时间和恢复状态如果是客户误操作导致租户被停用运营直接点“恢复”就行不需要走续费流程。这套设计从根本上避免了“恢复状态”“延长到期时间”两个动作必须同时发生的强耦合让运营可以灵活处置各种边界情况。6. 实测踩坑清单与后续扩展思路6.1 我在实施过程中遇到的几个典型问题第一租户套餐时长和续费天数的单位不统一。套餐表里存的可能是“月”续费接口入参却用“天”。我后来统一了一个折算工具类以天为最小单位套餐配置按天存储前端展示时再换算成月避免SQL里到处做*30的魔法数字。第二续费金额和时长写入流水表后应不应该做“幂等控制”。一旦后续接线上支付客户付了款支付回调如果重复触发两次续费接口可能被调用两遍给客户加双倍时长。我当时在流水表加了唯一约束(tenant_id, create_by, amount, renewal_days, create_time)用支付回调的订单号做业务唯一键同一笔订单只能生成一条续费流水。第一批做的时候没想这么远直接被人薅了羊毛后来补上的。第三定时任务处理租户冻结时如果租户量特别大一次事务里全量提交会导致主从延迟。我改成小批量提交后主从压力明显缓解。6.2 后续扩展自动续费、套餐升降级和账单联动租户续费和过期处理做成基础模块后延伸功能可以接着做。自动续费的核心是“预扣费支付回调幂等续费”把上面续费接口包一层即可到期前3天检查租户是否绑定了自动续费绑定则发起扣款支付成功后调用续费接口。这个扩展非常自然因为你已经有续费流水和到期时间的完整模型了。套餐升降级和续费联动时需要设计好时间边界是立即生效新套餐还是到期后生效我的建议是做成“到期后生效”这样账单清晰客户也不会有“我花钱了怎么菜单没变”的疑惑。具体实现只需要在流水表里增加target_package_id字段定时任务扫描到期时间时同步处理套餐切换。账单联动这块比较有想象力通过续费流水表的数据可以统计分析出每个租户的续费周期、平均续费天数、流失预警名单。这些都是商业化运营很看重的数据而数据底座在续费功能落地那一刻就已经具备了。6.3 这套方案在别的项目里怎么平移如果项目不是RuoYi-Vue-Plus而是普通Spring Boot MyBatis-Plus或者别的租户体系核心思路依然成立租户表增加到期时间和停用原因字段续费流水独立建表平台级数据不走租户隔离续费计算区分“未过期累加”和“已过期重算”冻结靠定时任务别靠用户请求时顺带检查冻结后必须清理会话缓存否则等于没冻结我个人的体会是租户续费这类需求看似简单真正落地时牵扯的永远是状态一致性、数据归属、缓存清理这些基本功。RuoYi-Vue-Plus把多租户的数据隔离做好了但业务生命周期的闭环还得靠业务开发自己去搭。如果你正在做类似模块希望这篇记录能帮你少踩几个坑。
返回列表