结合权限管理、数据隔离、定时任务等模块进行改造。
一、数据库设计
1. 新增费用相关表
-- 租户套餐表(存储不同套餐价格)
CREATE TABLE `sys_tenant_package` (
`package_id` BIGINT PRIMARY KEY COMMENT '套餐ID',
`tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`package_name` VARCHAR(50) NOT NULL COMMENT '套餐名称',
`price` DECIMAL(10,2) NOT NULL COMMENT '月费(元)',
`valid_period` INT DEFAULT 30 COMMENT '有效期(天)',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP
) COMMENT '租户套餐表';
-- 租户订阅记录表
CREATE TABLE `sys_tenant_subscription` (
`subscription_id` BIGINT PRIMARY KEY COMMENT '订阅ID',
`tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`package_id` BIGINT NOT NULL COMMENT '关联套餐ID',
`start_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '生效时间',
`end_time` DATETIME GENERATED ALWAYS AS (start_time + INTERVAL valid_period DAY) COMMENT '到期时间',
`status` TINYINT DEFAULT 1 COMMENT '状态(1:有效 0:过期)',
`is_auto_renew` TINYINT DEFAULT 1 COMMENT '是否自动续费'
) COMMENT '租户订阅记录表';
2. 修改租户主表
-- 在 sys_tenant 表中添加字段
ALTER TABLE `sys_tenant`
ADD COLUMN `remaining_fee` DECIMAL(10,2) DEFAULT 0 COMMENT '剩余费用',
ADD COLUMN `current_package_id` BIGINT COMMENT '当前套餐ID';
二、后端实现
1. 多租户数据隔离配置
在 MyBatisPlusConfig
中启用租户过滤器(参考):
@Configuration
public class MyBatisPlusConfig {
@Bean
public TenantLineInnerInterceptor tenantLineInnerInterceptor() {
return new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
Long tenantId = SecurityUtils.getLoginUser().getTenantId();
return new LongValue(tenantId);
}
});
}
}
2. 费用管理 Service
@Service
public class TenantSubscriptionService {
@Autowired
private SysTenantSubscriptionMapper subscriptionMapper;
// 计算剩余费用
public BigDecimal calculateRemainingFee(Long tenantId) {
SysTenantSubscription subscription = subscriptionMapper.selectByTenantId(tenantId);
if (subscription == null || subscription.getStatus() == 0) {
return BigDecimal.ZERO;
}
LocalDateTime now = LocalDateTime.now();
long daysUsed = ChronoUnit.DAYS.between(subscription.getStartTime(), now);
BigDecimal dailyCost = subscription.getPrice().divide(BigDecimal.valueOf(subscription.getValidPeriod()), 2, RoundingMode.DOWN);
return subscription.getPrice().subtract(dailyCost.multiply(BigDecimal.valueOf(daysUsed)));
}
// 自动续费定时任务
@Scheduled(cron = "0 0 0 * * ?") // 每天0点执行
public void autoRenew() {
List<SysTenantSubscription> list = subscriptionMapper.selectAutoRenewList();
list.forEach(sub -> {
if (sub.isAutoRenew() && sub.getStatus() == 1) {
// 调用支付接口扣款逻辑
// 更新 end_time 和 remaining_fee
}
});
}
}
三、前端实现
1. 租户信息页面改造
<!-- src/views/tenant/tenant-info.vue -->
<template>
<el-card>
<template #header>
<div class="card-header">租户套餐信息</div>
</template>
<el-descriptions :column="1" border>
<el-descriptions-item label="当前套餐">
{{ currentPackage.packageName }}
</el-descriptions-item>
<el-descriptions-item label="剩余费用">
¥{{ remainingFee }}
</el-descriptions-item>
<el-descriptions-item label="有效期至">
{{ formatTime(currentPackage.endTime) }}
</el-descriptions-item>
<el-descriptions-item label="自动续费">
{{ currentPackage.autoRenew ? '是' : '否' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { getTenantSubscription } from '@/api/tenant';
const remainingFee = ref(0);
const currentPackage = ref({});
onMounted(async () => {
const res = await getTenantSubscription();
remainingFee.value = res.remainingFee;
currentPackage.value = res.subscription;
});
</script>
四、关键功能实现
1. 支付回调处理
@RestController
@RequestMapping("/api/tenant/pay")
public class PayController {
@PostMapping("/callback")
public AjaxResult payCallback(@RequestBody PayCallbackDTO dto) {
// 验证签名
if (!verifySignature(dto)) {
return AjaxResult.error("非法请求");
}
// 更新订阅状态
sysTenantSubscriptionService.updateStatus(dto.getTenantId(), true);
return AjaxResult.success();
}
}
2. 租户套餐管理 API
@RestController
@RequestMapping("/api/tenant/package")
public class PackageController {
@PostMapping("/subscribe")
public AjaxResult subscribe(@RequestBody SubscribeDTO dto) {
// 校验租户状态
SysTenant tenant = sysTenantService.getById(dto.getTenantId());
if (tenant == null) {
return AjaxResult.error("租户不存在");
}
// 创建订阅记录
SysTenantSubscription subscription = new SysTenantSubscription();
subscription.setTenantId(dto.getTenantId());
subscription.setPackageId(dto.getPackageId());
subscription.setStartTime(LocalDateTime.now());
subscriptionMapper.insert(subscription);
return AjaxResult.success();
}
}
五、数据隔离与安全
-
MyBatis-Plus 多租户拦截
确保所有租户数据查询自动添加tenant_id
过滤条件(参考):@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new TenantLineInnerInterceptor()); return interceptor; }
-
敏感字段加密
对tenant_id
和支付信息使用 AES 加密存储:public class EncryptUtils { private static final String KEY = "ruoyi-tenant-pay-2025"; public static String encrypt(Long tenantId) { return AESUtil.encrypt(tenantId.toString(), KEY); } }
六、测试用例
@SpringBootTest
public class TenantSubscriptionTest {
@Autowired
private TenantSubscriptionService service;
@Test
public void testCalculateRemainingFee() {
BigDecimal fee = service.calculateRemainingFee(1L);
Assert.assertTrue(fee.compareTo(BigDecimal.ZERO) >= 0);
}
@Test
public void testAutoRenew() {
service.autoRenew();
// 验证续费后的 end_time 是否延长
}
}
七、部署注意事项
-
数据库迁移
使用 Flyway 执行 SQL 脚本:flyway migrate -url=jdbc:mysql://localhost:3306/ruoyi_v6 -user=root -password=123456
-
Redis 缓存优化
对高频访问的租户费用信息添加缓存:@Cacheable(value = "tenant_fee", key = "#tenantId") public BigDecimal getRemainingFee(Long tenantId) { // 数据库查询逻辑 }