
这些年用RuoYi-Vue-Plus做过好几个SaaS项目每个项目都会遇到同一个问题租户到期了怎么办续费怎么续刚开始那会儿我直接在管理端手动改数据库的到期时间字段结果线上租户到期了还在用系统等发现时人家已经超期跑了半个月。后来才意识到租户续费和过期处理不是简单的改个日期它牵扯到登录校验、定时任务、接口拦截、数据保护、到期提醒一整条链路。这篇内容就把我在RuoYi-Vue-Plus里落地这套功能的完整思路和踩坑记录整理出来给正在做多租户系统的朋友一个可参考的落地版本。1. 租户续费功能从哪里入手先搞懂RuoYi-Vue-Plus的租户体系1.1 租户表里藏着的两个关键字段RuoYi-Vue-Plus框架本身已经内置了多租户模块管理端的租户管理菜单里能维护租户基本信息。但框架默认的租户体系主要解决的是数据隔离问题——一个租户的数据不会被其他租户看到它并没有真正解决租户有效期这个时间维度的需求。要实现续费和过期处理首先得弄清楚框架里跟租户相关的核心表。在RuoYi-Vue-Plus的数据库脚本中租户相关表主要有两张一张是sys_tenant租户表一张是sys_tenant_package租户套餐表。租户表里记录了租户的编号、名称、联系人、联系电话、状态等信息而租户套餐表则定义了套餐名称、菜单权限范围、状态等。这里要特别留意一个点框架默认的sys_tenant表里并没有专门存租户到期时间的字段。数据库里有create_time、create_by这类审计字段但不会替你考虑业务上这个租户的合同什么时候到期。所以第一件事就是在sys_tenant表上扩展到期时间字段我习惯使用下面三个字段组合expire_time租户到期时间datetime类型expire_notify_time上次发送到期提醒的时间用于防止重复提醒tenant_status租户运营状态0正常 1已到期 2已停用这里我建议不要直接用框架自带的status字段来存是否到期的状态。status在框架里是通用的逻辑删除或启停用标记很多地方会拿它做数据隔离过滤。如果直接改status可能会导致后台某些查询把到期租户的数据直接过滤掉反而掩盖了问题。额外增加一个运营状态字段能让控制逻辑更清晰。1.2 租户套餐与续费的关系RuoYi-Vue-Plus中租户是挂在套餐下的一个套餐对应一批菜单权限。续费这个动作在业务上通常有两种场景租户合同到期续费继续用原套餐只需延长 expire_time。租户升级套餐换了更多功能权限同时延长 expire_time。第二种场景比第一种复杂它涉及租户与套餐关联关系的更新。在框架的sys_tenant表里tenant_package_id字段记录了租户当前使用的套餐ID。升级套餐时不能只改这个ID还要考虑下次登录后菜单权限是否立即生效的问题这一点我在第3章里会单独说。所以续费的本质不是更新一个日期而是在保证数据隔离和权限切换不受影响的前提下安全地更新租户的可用期限。理解了这个定位后面所有的设计就都不会跑偏。提示在扩展字段前建议先看一遍框架自带的数据库变更脚本。RuoYi-Vue-Plus支持Flyway管理数据库脚本新增字段不要直接手动去生产库执行而是按框架约定新建V开头的SQL脚本这样才能保证各环境的表结构一致。2. 租户过期的自动识别拦截器与定时任务两条路2.1 登录时如何挡住已过期的租户租户过期后最直接的后果就是不能再用系统。这里有个先后顺序的问题如果定时任务还没来得及把过期租户的状态改成已过期租户用户依然可以输入账号密码登录。所以在登录环节必须做实时校验而不是依赖定时任务去兜底。登录时校验的代码逻辑并不复杂核心思路是在RuoYi-Vue-Plus的登录服务中找到租户信息校验的入口增加一个判断。我贴一下核心逻辑的思路// 登录校验租户时补充过期判断 Tenant tenant tenantService.getTenantByTenantId(loginBody.getTenantId()); if (Objects.nonNull(tenant)) { // 如果设置了运营状态且为停用直接拒绝登录 if (Objects.equals(tenant.getTenantStatus(), TenantStatus.DISABLED.getCode())) { throw new ServiceException(租户已停用请联系管理员); } // 到期时间不为空且当前时间晚于到期时间说明已过期 if (Objects.nonNull(tenant.getExpireTime()) DateUtil.compare(LocalDateTime.now(), tenant.getExpireTime()) 0) { throw new ServiceException(租户已到期请及时续费); } }注意这里的LocalDateTime.now()获取的是应用服务器的时间如果应用服务器时钟不准或者数据库服务器与应用服务器处于不同时区很容易出现明明没过期却被提示已过期的误判。安全做法是统一从同一个时间源取时间比如都在应用层取LocalDateTime.now()不要在SQL里写NOW()避免两套时间源不一致导致的偶发问题。2.2 定时任务自动扫描过期租户只做登录校验还不够因为很多系统允许用户登录后长期保持会话或者说部分场景下登录态不用重新校验。比如一个租户的账号之前已经登录令牌还没过期那他在到期时间之后依然能继续操作系统。所以必须配合定时任务定期扫描并处置过期租户。在RuoYi-Vue-Plus中实现定时任务很简单框架集成了Spring自带的定时调度能力。我通常会做一个名为SysTenantExpireTask的定时任务类使用Scheduled注解每天凌晨执行一次扫描Component public class SysTenantExpireTask { private static final Logger log LoggerFactory.getLogger(SysTenantExpireTask.class); Resource private SysTenantService tenantService; Resource private SysTenantExpireLogService expireLogService; /** * 每天凌晨2点执行一次租户到期扫描 */ Scheduled(cron 0 0 2 * * ?) public void checkExpiredTenants() { log.info([租户到期扫描] 开始执行); // 查询所有未到期的租户逐个判断是否到期 ListSysTenant tenantList tenantService.list( new LambdaQueryWrapperSysTenant() .eq(SysTenant::getStatus, TenantStatus.NORMAL.getCode()) .isNotNull(SysTenant::getExpireTime)); // 只处理到期时间小于当前时间、且状态还不是已到期的租户 ListSysTenant expiredTenants tenantList.stream() .filter(tenant - tenant.getExpireTime().isBefore(LocalDateTime.now())) .filter(tenant - !Objects.equals(tenant.getTenantStatus(), TenantStatus.EXPIRED.getCode())) .collect(Collectors.toList()); if (CollUtil.isEmpty(expiredTenants)) { log.info([租户到期扫描] 暂未发现到期租户); return; } for (SysTenant tenant : expiredTenants) { // 1. 更新租户运营状态 tenantService.updateTenantStatus(tenant.getTenantId(), TenantStatus.EXPIRED.getCode()); // 2. 强制踢掉该租户下所有在线用户 kickTenantUsers(tenant.getTenantId()); // 3. 记录审计日志 expireLogService.recordExpire(tenant); } log.info([租户到期扫描] 共处理 {} 个到期租户, expiredTenants.size()); } }这里有三个关键动作缺一不可状态更新、踢人下线、审计日志。踢人下线这一步很多新手会漏漏掉的后果就是租户虽然标记为过期了但已经登录的用户拿到过令牌依然能调到接口。RuoYi-Vue-Plus的令牌逻辑是基于Sa-Token的可以通过SaSession工具类按租户维度做会话处理或者直接按登录用户清除会话缓存。2.3 接口层如何做到提前拦截定时扫描存在一个天然的时间窗口那就是凌晨2点到下一次扫描之间如果租户恰好在白天到期这期间它的用户还是可以操作的。为了更严谨可以在接口层加一道租户有效性校验。RuoYi-Vue-Plus中接口的鉴权主要依赖Sa-Token的拦截器框架自带的SaInterceptor会校验登录状态但不会校验租户有效期。我采用的做法是扩展一个TenantExpireInterceptor注册到SpringMVC拦截器链中与SaToken拦截器配合使用。public class TenantExpireInterceptor implements HandlerInterceptor { Resource private SysTenantService tenantService; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 从请求上下文中拿到当前租户ID Long tenantId TenantHelper.getTenantId(); if (Objects.isNull(tenantId)) { return true; } // 查询租户到期时间走缓存避免每次请求都查数据库 LocalDateTime expireTime tenantService.getExpireTimeFromCache(tenantId); if (Objects.isNull(expireTime)) { return true; } if (LocalDateTime.now().isAfter(expireTime)) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\租户已到期请联系管理员续费\}); return false; } return true; } }按理说有了登录校验和定时任务接口层的拦截可以去掉。但我个人还是强烈建议加上这层因为它是响应最及时的兜底方案。SaaS系统中租户的到期时间随时可能被手工调整比如客户当天下午续费管理员手动延期定时的判断永远赶不上实时的变化。接口拦截虽然每次请求多一次缓存查询但对于租户数量在几千以内的系统来说性能损耗完全可以接受。不过要注意不是所有接口都需要做租户有效期拦截。比如租户续费回调、租户自助续费查询接口这类接口租户本身已经过期了你还拦截它那用户就永远无法自助续费了。所以要把这类允许过期租户访问的接口做白名单配置在拦截器中放行。3. 续费操作的完整实现从管理端到租户端的闭环3.1 管理端续费接口的设计续费的入口通常在管理端的租户列表页管理员选择一个租户点击续费设置新的到期时间或者选择续费时长。后端接口的设计不能只是简单更新expireTime字段我整理了以下几个要考虑的点第一续费操作必须是幂等防并发的。管理员可能同时打开两个窗口操作同一个租户尤其是使用不同浏览器时容易出现重复提交。数据库层面加乐观锁或者使用update语句中带上原到期时间条件都能避免这种情况。我在RuoYi-Vue-Plus的续费逻辑中这样处理boolean success tenantService.update( new LambdaUpdateWrapperSysTenant() .eq(SysTenant::getTenantId, tenantId) .eq(SysTenant::getExpireTime, oldExpireTime) // 乐观锁条件 .set(SysTenant::getExpireTime, newExpireTime) .set(SysTenant::getTenantStatus, TenantStatus.NORMAL.getCode()));只有当原到期时间和页面展示的一致时才执行成功否则说明有其他人先操作了返回数据已被修改请刷新后重试。第二要同时处理续费后重新启用的状态切换。很多租户到期后运营状态变成了已过期或已停用续费时要把状态改回正常。但这里有个联动问题直接修改状态是不是就够了从数据隔离的角度看如果租户套餐关联没有任何变化改状态就够了如果是升级套餐还需要关联更新套餐ID以及用户角色权限。第三续费操作要写审计日志。谁操作的、什么时候操作的、从哪个到期时间改到了哪个到期时间这些都要记录下来。RuoYi-Vue-Plus有操作日志模块接上它的注解就能自动记录。3.2 租户端如何展示剩余时间续费功能的另外一个入口是租户自己发起的自助续费。租户登录系统后在首页展示当前剩余的有效天数到期前通过站内信或管理端通知提醒。这部分的核心是计算剩余天数的算法不要简单地把两个时间戳相减因为很多客户对当天是否算一天非常敏感。我的处理方式是剩余天数 到期日期不含时分秒与当天0点之间的天数差。比如今天是7月10日到期时间是7月15日23:59:59那就显示还剩5天。用Java的ChronoUnit.DAYS.between可以很方便计算LocalDate today LocalDate.now(); LocalDate expireDate expireTime.toLocalDate(); long remainDays ChronoUnit.DAYS.between(today, expireDate);如果expireTime是当天但时间还没到比如7月15日上午10点到期当天算出来是0天这时候要提示今天到期请尽快续费而不是直接显示已过期。这种细节特别影响客户体验我最早做的时候没注意结果客户专门打电话来问为什么还剩一天却提示已到期。3.3 续费后的缓存与权限刷新续费动作完成后要解决一个权限菜单要不要刷新的问题。如果不做任何处理租户续费后之前因为套餐到期被限制的功能可能不会立即恢复因为用户的权限信息是存在缓存里的。RuoYi-Vue-Plus的用户权限信息用Redis实现了缓存cacheKey通常是login_tokens:Sa-Token或者类似的拼接。我建议在续费的接口中新增两步清除该租户下所有用户的权限缓存清除该租户在租户缓存中的到期时间快照清除权限缓存可以用RedisTemplate按租户ID前缀批量删除。比如SetString keys redisTemplate.keys(login_tokens: tenantId :*); if (CollUtil.isNotEmpty(keys)) { redisTemplate.delete(keys); }批量删除虽然方便但生产环境如果租户下的用户量大可能出现Redis阻塞。更稳妥的方案是借助Sa-Token的会话注销接口把指定租户下的在线会话逐个踢下线用户下次登录时自然拿到最新的权限状态。具体用哪种方案取决于你的租户规模我见过几千人的租户keys模式扫描也还好但超过1万用户就得谨慎了。3.4 自动续费与支付回调的扩展思路如果你的SaaS系统接入了在线支付租户可以自己在页面上扫码付款完成续费那还需要处理支付回调后的续费逻辑。这个场景比管理端手动续费复杂因为要处理支付成功但续费失败的异常情况。我建议在支付回调的处理逻辑里把续费设计成先记录流水再更新租户到期时间的顺序。流水表记录支付的订单号、支付金额、租户ID、期望续费时长等回调确认后先更新流水状态为已支付再执行到期时间更新。如果到期时间更新失败通过定时任务对齐流水表和租户表的到期时间保证数据库最终一致。这一节可能超出了一般RuoYi-Vue-Plus项目的需求范围但如果你的项目是真正的商业化SaaS系统提前把这个链路想清楚能省掉后续大量的对账工作。4. 过期之后的处理策略禁用、降级与数据隔离4.1 过期租户的登录限制与在线用户清理租户过了期系统不能让它继续产生新业务数据所以登录环节要挡住在线会话要清掉。这两件事我在前面已经提到了这里重点说说踢人下线在RuoYi-Vue-Plus里的具体实现细节。RuoYi-Vue-Plus使用Sa-Token做登录认证Sa-Token提供了登录会话管理和会话踢出功能。最直接的方式是用StpUtil.logout(loginId)把指定用户踢下线但这需要知道该租户下都有哪些用户ID才能逐个踢。如果租户的用户数量很大逐个踢效率太低。一个更优雅的做法是屏蔽该租户的token让它在调用接口时校验失败。Sa-Token支持自定义校验逻辑你可以注册一个SaTokenListener监听登录时的校验事件也可以直接用业务拦截器配合租户状态做判断。我的经验是定时任务负责标记状态接口拦截器负责实际拒绝两者配合就不再需要主动踢人下线了——因为已经被拦截的用户下次请求就会收到租户已过期的提示前端拿到响应后自动跳到登录页效果等同于踢下线。4.2 过期租户的数据保护租户过期后它的数据还留在库里这些数据不能丢但也不能让过期租户继续写入新数据。所以在数据库层面我建议用逻辑停止写入的方式而不是物理删除或清空租户数据。具体到RuoYi-Vue-Plus的架构里每个业务表通常都有tenant_id字段做数据隔离。对过期租户的数据保护我采用两个层面的措施应用层过期租户的接口请求会被拦截器拒绝自然无法写入数据。数据库层给租户对应的数据库账号设置只读权限或设置定时任务把过期租户的数据库连接池置为不可用。数据库层的处理一般中小型系统用不到但如果是金融、医疗等合规性要求高的行业必须保证过期后没有任何渠道能写入数据。我在一个项目里遇到过一种情况租户到期后虽然Web端被拦截了但对方通过第三方对接API绕过了前端限制直接调后台接口写入数据因为拦截器只拦截了页面请求没拦API白名单之外的接口。后来我把租户状态校验统一放到API网关层才彻底堵住了这个漏洞。4.3 到期提醒机制提前30天、7天、1天三级提醒与其等客户发现系统用不了了才来续费不如在到期前主动提醒这样能极大提高续费率。在RuoYi-Vue-Plus中提醒机制跟定时任务放在一起做即可。我常用的是三级提醒策略到期前30天提醒、7天提醒、1天提醒。每次提醒后把记录写到sys_tenant_expire_log表以最近提醒日期为条件保证同一级别的提醒不会每天重复发。if (remainDays 30 lastNotifyTime.isBefore(now.minusMonths(1))) { // 发站内信/短信/公众号通知 } if (remainDays 7 lastNotifyTime.isBefore(now.minusDays(7))) { // 通知 } if (remainDays 1 lastNotifyTime.isBefore(now.minusDays(1))) { // 通知 }框架内置了通知公告模块站内信可以用系统通知实现。短信通知则需要接入第三方短信服务商在RuoYi-Vue-Plus的扩展包中预留了相关配置。需要提醒的是节假日期间到期的租户最好提前到放假前最后一个工作日提醒不然假期里租户没人管等上班了才发现已经过期好几天。4.4 过期后的宽限期怎么设计有些SaaS系统考虑到用户体验不会在到期当天就直接封停系统而是留出几天的宽限期宽限期内只读不允许新增数据。这个策略在代码层面也就一段判断的事情LocalDateTime deadline expireTime.plusDays(graceDays); if (LocalDateTime.now().isAfter(expireTime) LocalDateTime.now().isBefore(deadline)) { // 宽限期只读模式拒绝写操作 } if (LocalDateTime.now().isAfter(deadline)) { // 彻底停用拒绝所有操作 }但要注意宽限期的只读必须在每个模块里落实才有意义。RuoYi-Vue-Plus的业务代码分散在各业务模块中如果只在网关层拦截业务模块内部的数据操作仍然可能绕过校验。我建议把写操作定义为对业务表的insert/update/delete方法通过MyBatis-Plus的拦截器统一控制。自定义一个SqlInterceptor在SQL执行前判断当前租户是否处于宽限期只读状态如果是且SQL类型不是select就抛出拒绝执行的异常。这个方案虽然有点暴力但覆盖面广不容易漏业务模块。注意宽限期方案只建议在有明确业务需求时使用不要为了显得贴心盲目加否则很容易因为某个业务模块没适配导致租户在宽限期内产生脏数据。5. 踩坑记录与排查思路从时间边界到缓存穿透5.1 时间比较的边界问题我踩过最大的坑就是当天到期算不算到期。一个租户的到期时间是2024-12-31 00:00:00那12月31日当天它应该算到期还是可用很多人的第一反应是还没到23:59:59就不算到期但要知道如果到期时间是从数据库配置的而配置人员通常只选了日期没选时间那存入的就是当天的0点跟你以为的当天结束差了整整一天。这里我统一了一个规范所有到期时间统一存到期日当天23:59:59。配置时页面只选日期提交时后端自动带上23:59:59。这样当天可用第二天0点不可用的效果就完全符合预期。判断是否过期时只需要简单比较当前时间是否晚于到期时间即可不再需要纠结那一天的误差。5.2 定时任务重复执行引发误处理RuoYi-Vue-Plus支持集群部署如果你部署了两个实例Scheduled定时任务会同时跑一遍结果就是两个实例同时扫描同一批租户同时去更新状态同时发提醒短信。带来最直接的问题就是租户可能收到重复的到期提醒短信。解决方案是引入分布式锁。RuoYi-Vue-Plus内置了Redisson拿Redisson的分布式锁包一圈定时任务即可RLock lock redissonClient.getLock(lock:tenant:expire:scan); if (lock.tryLock(3, TimeUnit.SECONDS)) { try { doScanExpiredTenants(); } finally { lock.unlock(); } }这块如果项目暂时还是单机部署可以不着急加但要在代码里预留好等上集群时自然用得上。5.3 缓存里的到期时间更新不及时租户的到期时间如果被直接改数据库或者管理端续费后没有清理缓存接口拦截器读到的是旧缓存可能出现过期租户还能继续访问系统的问题。排查这种问题时我一般先看Redis里的租户缓存是否更新再下结论。为了减少这种问题的出现我把到期时间的读取都收敛到一个方法里查询时先看Redis缓存没有就查库回填缓存的有效期设5分钟。续费成功后在事务提交后主动删除缓存。这样即使漏删了最坏情况下5分钟后也能自动恢复不会出现数据长期不一致。5.4 登录时校验租户有效期的并发问题登录校验时先查租户状态再校验账号密码但如果租户恰恰在查询租户状态和校验账号密码之间被续费了或者被停用了会出现短暂的不该过却过了或者该过却没过的情况。这种很小的并发窗口通常不用管因为系统整体判断只差毫秒级对实际业务没有影响。如果非要严谨可以把查询租户状态和校验账号密码放到同一个事务中或者把租户状态的校验挪到账号密码验证之后反正最终目的只是防止过期租户进入系统先验哪个都行。6. 实操经验总结这套功能上线前必须想清楚的三件事第一件事租户过期和停用是不是同一个概念。我见过不少项目把两者混在一起到期了就置为停用续费了就置为正常。表面上省事但当你需要统计本月到期未续费租户列表的时候你会发现很难区分因为违规被停用和因为到期被停用的租户。所以运营状态字段一定要单独加别省这个成本。第二件事别忘了给续费这个动作留扩展空间。有些租户可能在到期前就续费有些可能续费后又要退款并缩短日期还有的会换套餐升级。如果续费接口只做了更新到期时间这一件事后面遇到这些需求就得反复改接口。我建议接口参数设计为续费操作类型时长新到期时间备注操作类型预留几种枚举比如手动延期、自动续费、升级套餐续费这样后续扩展时不用动表结构。第三件事上线前把监控和告警做好。定时任务有没有跑、扫描失败的租户有没有人跟进、续费流程中断有没有告警这决定了这套功能在线上是不是真的可靠。我会在定时任务执行结束后把扫描结果写入日志并记录一个指标比如处理租户数、失败租户数再结合Grafana之类的监控工具做告警。这样哪天定时任务没跑运维能第一时间发现。整套租户续费和过期处理的方案本质上就是时间维度上的权限控制。RuoYi-Vue-Plus把数据隔离的基础搭好了剩下的业务时间规则得自己补。我在这几套项目中积累下来的经验就是先想清楚业务上过期到底意味着什么再决定代码怎么做。不要一上来就写定时任务改状态而是把登录校验、接口拦截、缓存刷新、到期提醒、审计日志这五条链路全部打通功能才算真正落地。如果你正在做这个功能建议按这五条链路逐个核对能省下不少返工的时间。