1. 项目概述
RuoYi-Vue-Plus作为一款基于SpringBoot和Vue.js的企业级开发框架,其多租户功能是许多SaaS应用的核心需求。在实际业务场景中,租户续费与过期处理是保障系统可持续运营的关键环节。本文将详细解析如何在该框架中实现完整的租户生命周期管理。
提示:本文假设读者已具备RuoYi-Vue-Plus基础开发经验,熟悉SpringBoot和Vue.js的基本使用。
2. 数据模型设计解析
2.1 核心表结构设计
租户管理的核心在于systenant表的设计,该表需要包含以下关键字段:
sql复制CREATE TABLE `sys_tenant` (
`id` bigint NOT NULL COMMENT '租户ID',
`name` varchar(64) DEFAULT NULL COMMENT '租户名称',
`expire_time` datetime DEFAULT NULL COMMENT '过期时间',
`status` char(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
`package_id` bigint DEFAULT NULL COMMENT '套餐ID',
`account_count` int DEFAULT NULL COMMENT '用户数上限',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户表';
2.2 字段设计考量
- expire_time:采用datetime类型而非date,可以精确到时分秒,适用于需要精确控制访问权限的场景
- status:使用char(1)而非varchar,固定长度字段在索引效率上更优
- package_id:外键关联套餐表,支持灵活的套餐变更
- account_count:整数类型,控制租户最大用户数
注意:在实际生产环境中,建议为expire_time和status字段添加联合索引,提高定时任务查询效率。
3. 后端实现详解
3.1 续费服务实现
租户续费服务的核心在于对到期时间的更新计算。以下是完整的服务层实现:
java复制@Service
@RequiredArgsConstructor
public class TenantRenewServiceImpl implements TenantRenewService {
private final SysTenantMapper tenantMapper;
private final SysOperLogMapper operLogMapper;
private final NotificationService notificationService;
private final SysPackageMapper packageMapper;
@Override
@Transactional
public void renewTenant(TenantRenewDTO renewDTO) {
// 1. 参数校验
SysTenant tenant = tenantMapper.selectById(renewDTO.getTenantId());
if (tenant == null) {
throw new ServiceException("租户不存在");
}
if (renewDTO.getPackageId() != null) {
SysPackage sysPackage = packageMapper.selectById(renewDTO.getPackageId());
if (sysPackage == null) {
throw new ServiceException("套餐不存在");
}
}
// 2. 计算新到期时间
Date newExpireTime = calculateNewExpireTime(
tenant.getExpireTime(),
renewDTO.getRenewMonths()
);
// 3. 更新租户信息
SysTenant updateTenant = new SysTenant();
updateTenant.setId(renewDTO.getTenantId());
updateTenant.setExpireTime(newExpireTime);
updateTenant.setStatus("0"); // 确保状态为正常
if (renewDTO.getPackageId() != null) {
updateTenant.setPackageId(renewDTO.getPackageId());
}
if (renewDTO.getAccountCount() != null) {
updateTenant.setAccountCount(renewDTO.getAccountCount());
}
tenantMapper.updateById(updateTenant);
// 4. 记录操作日志
operLogMapper.insert(SysOperLog.builder()
.title("租户续费")
.businessType(BusinessType.UPDATE)
.operParam(JSON.toJSONString(renewDTO))
.status(0)
.build());
// 5. 发送通知
notificationService.sendRenewSuccessNotice(tenant, newExpireTime);
}
private Date calculateNewExpireTime(Date currentExpireTime, int renewMonths) {
if (currentExpireTime == null || new Date().after(currentExpireTime)) {
// 如果当前已过期或未设置过期时间,则从当前时间开始计算
return DateUtils.addMonths(new Date(), renewMonths);
} else {
// 否则从原过期时间开始计算
return DateUtils.addMonths(currentExpireTime, renewMonths);
}
}
}
3.2 定时任务实现
过期检查定时任务需要考虑分布式环境下的执行问题:
java复制@Component
@RequiredArgsConstructor
public class TenantExpireTask {
private final ISysTenantService tenantService;
private final RedisLockHelper redisLockHelper;
private static final String LOCK_KEY = "tenant:expire:check:lock";
@Scheduled(cron = "0 0 1 * * ?")
public void checkExpireTenants() {
// 获取分布式锁,防止多实例重复执行
boolean locked = redisLockHelper.tryLock(LOCK_KEY, 60, TimeUnit.SECONDS);
if (!locked) {
return;
}
try {
tenantService.checkAndHandleExpiredTenants();
} finally {
redisLockHelper.unlock(LOCK_KEY);
}
}
}
4. 访问控制实现
4.1 登录拦截增强
在原有登录逻辑基础上增加租户状态检查:
java复制public class TenantLoginService {
private final SysTenantMapper tenantMapper;
public LoginUser checkTenantStatus(String tenantId) {
SysTenant tenant = tenantMapper.selectById(tenantId);
if (tenant == null) {
throw new ServiceException("租户不存在");
}
// 状态检查
if ("1".equals(tenant.getStatus())) {
throw new ServiceException("租户已停用");
}
// 过期时间检查
if (tenant.getExpireTime() != null && new Date().after(tenant.getExpireTime())) {
// 自动更新状态为停用
tenantMapper.updateById(new SysTenant()
.setId(tenantId)
.setStatus("1"));
throw new ServiceException("租户已过期");
}
// 用户数检查
if (tenant.getAccountCount() != null) {
int currentUserCount = getUserCount(tenantId);
if (currentUserCount >= tenant.getAccountCount()) {
throw new ServiceException("租户用户数已达上限");
}
}
return buildLoginUser(tenant);
}
}
4.2 API拦截器实现
使用Spring拦截器实现全局租户状态检查:
java复制public class TenantStatusInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String tenantId = TenantContext.getCurrentTenant();
if (StringUtils.isBlank(tenantId)) {
return true;
}
SysTenant tenant = tenantService.getById(tenantId);
if (tenant != null && "1".equals(tenant.getStatus())) {
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(JSON.toJSONString(
Result.error("租户已停用,请联系管理员")));
return false;
}
return true;
}
}
5. 前端实现细节
5.1 续费表单组件
vue复制<template>
<el-dialog title="租户续费" :visible.sync="visible">
<el-form :model="form" label-width="120px">
<el-form-item label="当前到期时间">
<el-input :value="formatDate(tenant.expireTime)" disabled />
</el-form-item>
<el-form-item label="续费时长" prop="renewMonths">
<el-select v-model="form.renewMonths" placeholder="请选择">
<el-option label="1个月" :value="1" />
<el-option label="3个月" :value="3" />
<el-option label="6个月" :value="6" />
<el-option label="1年" :value="12" />
<el-option label="2年" :value="24" />
</el-select>
</el-form-item>
<el-form-item label="变更套餐" prop="packageId">
<el-select v-model="form.packageId" placeholder="请选择">
<el-option
v-for="item in packageList"
:key="item.id"
:label="item.name"
:value="item.id" />
</el-select>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确认</el-button>
</div>
</el-dialog>
</template>
<script>
export default {
props: {
tenant: {
type: Object,
required: true
}
},
data() {
return {
visible: false,
form: {
renewMonths: 12,
packageId: null
},
packageList: []
}
},
methods: {
async loadPackages() {
const { data } = await getPackageList();
this.packageList = data;
this.form.packageId = this.tenant.packageId;
},
async handleSubmit() {
try {
await renewTenant({
tenantId: this.tenant.id,
...this.form
});
this.$message.success('续费成功');
this.$emit('success');
this.visible = false;
} catch (error) {
this.$message.error(error.message);
}
}
}
}
</script>
5.2 租户列表状态展示
在租户列表中添加状态标签和操作按钮:
vue复制<el-table-column label="状态" width="100">
<template slot-scope="{row}">
<el-tag :type="row.status === '0' ? 'success' : 'danger'">
{{ row.status === '0' ? '正常' : '停用' }}
</el-tag>
<el-tag v-if="isExpired(row)" type="warning" style="margin-left:5px">
已过期
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template slot-scope="{row}">
<el-button
v-if="row.status === '0'"
type="text"
@click="handleRenew(row)">
续费
</el-button>
<el-button
v-else
type="text"
@click="handleEnable(row)">
启用
</el-button>
</template>
</el-table-column>
6. 高级策略实现
6.1 多级提醒机制
在定时任务中增加到期前提醒功能:
java复制public void checkExpireTenants() {
// 检查已过期租户
handleExpiredTenants();
// 到期前提醒
sendPreExpireNotices();
}
private void sendPreExpireNotices() {
// 7天提醒
Date sevenDaysLater = DateUtils.addDays(new Date(), 7);
List<SysTenant> sevenDayNoticeTenants = tenantMapper.selectList(
new LambdaQueryWrapper<SysTenant>()
.eq(SysTenant::getStatus, "0")
.between(SysTenant::getExpireTime, new Date(), sevenDaysLater)
);
sevenDayNoticeTenants.forEach(tenant -> {
notificationService.sendPreExpireNotice(tenant, "7");
});
// 3天提醒
Date threeDaysLater = DateUtils.addDays(new Date(), 3);
List<SysTenant> threeDayNoticeTenants = tenantMapper.selectList(
new LambdaQueryWrapper<SysTenant>()
.eq(SysTenant::getStatus, "0")
.between(SysTenant::getExpireTime, new Date(), threeDaysLater)
);
threeDayNoticeTenants.forEach(tenant -> {
notificationService.sendPreExpireNotice(tenant, "3");
});
}
6.2 数据归档策略
对于长期未续费的租户,实现数据归档:
java复制@Scheduled(cron = "0 0 2 * * ?")
public void archiveInactiveTenants() {
// 查找停用超过30天的租户
Date thirtyDaysAgo = DateUtils.addDays(new Date(), -30);
List<SysTenant> inactiveTenants = tenantMapper.selectList(
new LambdaQueryWrapper<SysTenant>()
.eq(SysTenant::getStatus, "1")
.lt(SysTenant::getUpdateTime, thirtyDaysAgo)
);
inactiveTenants.forEach(tenant -> {
// 1. 归档用户数据
archiveUserData(tenant.getId());
// 2. 归档业务数据
archiveBusinessData(tenant.getId());
// 3. 更新归档标记
tenantMapper.updateById(new SysTenant()
.setId(tenant.getId())
.setArchiveStatus(1)
.setArchiveTime(new Date()));
});
}
7. 性能优化建议
-
数据库索引优化:
- 为
expire_time和status字段添加复合索引 - 为常用查询条件添加适当索引
- 为
-
定时任务优化:
- 对大租户数据进行分页处理
- 考虑使用批处理减少数据库压力
-
缓存策略:
- 对租户基本信息使用Redis缓存
- 实现多级缓存策略
-
异步处理:
- 将通知发送等非核心操作异步化
- 使用消息队列解耦
8. 常见问题排查
8.1 租户状态未及时更新
现象:租户已过期但状态仍显示正常
排查步骤:
- 检查定时任务是否正常执行
- 查看任务日志是否有错误
- 确认服务器时间是否正确
- 检查数据库连接是否正常
8.2 续费后访问仍被拦截
现象:续费成功后,租户仍无法访问系统
排查步骤:
- 确认数据库
expire_time和status字段已更新 - 检查缓存是否已刷新
- 验证拦截器逻辑是否正确
- 查看是否有分布式会话问题
8.3 定时任务重复执行
现象:在集群环境下,定时任务被多次执行
解决方案:
- 使用分布式锁控制任务执行
- 配置Quartz等专业调度框架
- 设置任务执行标记
9. 安全注意事项
-
权限控制:
- 确保只有管理员能执行续费操作
- 前端隐藏续费按钮的同时,后端必须进行权限校验
-
数据安全:
- 续费操作需记录详细日志
- 敏感操作需要二次确认
-
防篡改:
- 对续费接口进行防重放处理
- 关键参数进行签名验证
10. 扩展功能建议
-
套餐折扣系统:
- 实现季节性促销折扣
- 支持优惠券系统
-
自动化续费:
- 支持信用卡自动扣款
- 实现支付宝/微信自动续费
-
试用期功能:
- 为新租户提供试用期
- 试用到期前提醒
-
多租户数据隔离增强:
- 支持Schema级别隔离
- 实现行级安全策略
在实际项目中,我们根据业务需求对租户续费功能进行了多次迭代。一个重要的经验是:在初期设计时就要考虑字段的可扩展性,比如expire_time使用datetime而非date,为后续精确控制留出空间。另外,定时任务的执行时间需要避开业务高峰期,我们选择凌晨1点执行过期检查,2点执行数据归档,这样既不影响白天业务,又能保证数据的及时处理。