This commit adds a complete tenant optimization system that eliminates the need for redundant tenant_id columns across user-related tables by introducing a centralized TenantUser domain object and service layer. Key changes: - Database migrations: tenant_info view, unique indexes on user tables - New domain model: TenantUser with user-tenant relationship management - Service layer: TenantUserService with caching and batch operations - Architecture documentation: design decisions, implementation guide, deployment checklist - Testing: comprehensive test scripts and simplified HTML report generator - V2 implementation: TCustomerConfigServiceImpl_V2 using new tenant architecture Benefits: - Reduced database redundancy (eliminated tenant_id from 5+ tables) - Improved query performance with optimized indexes - Enhanced maintainability with centralized tenant logic - Better scalability with caching layer 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
44 KiB
🚀 租户与用户管理模块优化实施计划
📋 文档概述
项目名称: LJWX租户与用户管理模块企业级改造 计划周期: 4-6周(按优先级分3个阶段) 负责团队: 后端团队 + DBA 最后更新: 2025-12-16
🎯 实施目标
- 数据安全:消除跨租户数据泄露风险,补强数据隔离机制
- 数据一致性:建立冗余字段自动修复机制,保证数据准确性
- 并发安全:解决用户创建并发冲突问题
- 性能优化:减少事务大小,提升用户操作响应速度
- 审计合规:补充操作审计日志,满足安全审计要求
- 数据治理:建立软删除数据归档机制,控制数据膨胀
📅 三阶段实施路线图
gantt
title 租户与用户管理模块改造路线图
dateFormat YYYY-MM-DD
section Phase 1 (P0)
数据库唯一索引 :done, p1-1, 2025-12-16, 1d
分布式锁防并发 :active, p1-2, 2025-12-17, 2d
租户隔离二次校验 : p1-3, 2025-12-19, 3d
冗余字段定时修复 : p1-4, 2025-12-22, 2d
section Phase 2 (P1)
软删除数据归档 : p2-1, 2025-12-24, 3d
事务拆分优化 : p2-2, 2025-12-27, 3d
操作审计日志 : p2-3, 2025-12-30, 4d
密码强度策略 : p2-4, 2026-01-03, 2d
section Phase 3 (P2)
批量导入优化 : p3-1, 2026-01-05, 4d
数据导出脱敏 : p3-2, 2026-01-09, 2d
权限缓存清理 : p3-3, 2026-01-11, 2d
租户管理完善 : p3-4, 2026-01-13, 3d
🔴 Phase 1: 核心安全与一致性保障(P0优先级,Week 1-2)
任务 1.1:数据库唯一索引建立 ⏱️ 0.5人日
目标: 防止用户名、卡号并发冲突
实施步骤
-- 1. 检查是否有重复数据(必须先清理)
SELECT user_name, customer_id, COUNT(*) as cnt
FROM sys_user
WHERE deleted = 0
GROUP BY user_name, customer_id
HAVING cnt > 1;
-- 2. 清理重复数据(如有)
-- 保留ID最小的记录,其他标记为删除
UPDATE sys_user u1
SET deleted = 1
WHERE deleted = 0
AND EXISTS (
SELECT 1 FROM sys_user u2
WHERE u2.user_name = u1.user_name
AND u2.customer_id = u1.customer_id
AND u2.id < u1.id
AND u2.deleted = 0
);
-- 3. 添加唯一索引(防止并发重复)
ALTER TABLE sys_user
ADD UNIQUE INDEX uk_username_customer (user_name, customer_id)
COMMENT '用户名租户内唯一';
-- 4. 添加其他性能索引
ALTER TABLE sys_user
ADD INDEX idx_customer_status (customer_id, status)
COMMENT '租户状态组合索引';
ALTER TABLE sys_user
ADD INDEX idx_org_deleted (org_id, deleted)
COMMENT '组织软删除组合索引';
ALTER TABLE sys_user_org
ADD INDEX idx_customer_user (customer_id, user_id)
COMMENT '租户用户组合索引';
-- 5. 验证索引创建
SHOW INDEX FROM sys_user WHERE Key_name = 'uk_username_customer';
验收标准
- 唯一索引创建成功
- 重复插入操作抛出
Duplicate entry异常 - 现有数据无重复
- 索引性能测试通过(查询性能提升 > 30%)
风险与回滚
- 风险:大表添加索引可能锁表较久
- 预案:在业务低峰期(凌晨)执行,准备回滚脚本
- 回滚:
ALTER TABLE sys_user DROP INDEX uk_username_customer;
任务 1.2:用户新增接口幂等性改造 ⏱️ 1.5人日
目标: 使用Redis分布式锁防止重复提交
实施步骤
1. 引入Redis工具类(已有,无需改动)
2. 修改Controller层
// 文件: ljwx-boot-modules/src/main/java/com/ljwx/modules/system/controller/SysUserController.java
@RestController
@RequestMapping("/api/v1/system/users")
@Tag(name = "用户管理")
@Slf4j
public class SysUserController {
@Autowired
private ISysUserService userService;
@Autowired
private RedisUtil redisUtil;
/**
* 新增用户(支持幂等性)
*
* @param requestId 前端生成的UUID,用于幂等控制(可选)
* @param dto 用户信息
*/
@Operation(summary = "新增用户", description = "创建新用户,支持幂等性控制")
@PostMapping
public Result<Long> addUser(
@Parameter(description = "请求ID,用于幂等性控制")
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody @Valid SysUserAddDTO dto
) {
// 幂等性控制
if (StringUtils.hasText(requestId)) {
String lockKey = "user:add:lock:" + requestId;
String resultKey = "user:add:result:" + requestId;
// 先检查是否已处理过
Long cachedUserId = redisUtil.get(resultKey);
if (cachedUserId != null) {
log.info("请求已处理,直接返回缓存结果: requestId={}, userId={}", requestId, cachedUserId);
return Result.success(cachedUserId);
}
// 获取分布式锁
boolean locked = redisUtil.setIfAbsent(lockKey, "1", 60, TimeUnit.SECONDS);
if (!locked) {
throw new BizException("请勿重复提交,请稍后重试");
}
try {
Long userId = userService.addUser(dto);
// 缓存结果5分钟
redisUtil.set(resultKey, userId, 300, TimeUnit.SECONDS);
return Result.success(userId);
} finally {
redisUtil.delete(lockKey);
}
}
// 无requestId则直接处理(不保证幂等性)
return Result.success(userService.addUser(dto));
}
}
3. 前端调用示例
// ljwx-admin/src/service/api/system/user.ts
import { v4 as uuidv4 } from 'uuid';
export function addUser(data: UserAddDTO) {
const requestId = uuidv4(); // 生成唯一请求ID
return request.post<Long>('/api/v1/system/users', data, {
headers: {
'X-Request-Id': requestId
}
});
}
4. Service层增强校验
// 文件: ljwx-boot-modules/src/main/java/com/ljwx/modules/system/service/impl/SysUserServiceImpl.java
@Override
@Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_COMMITTED)
public Long addUser(SysUserAddDTO dto) {
Long currentCustomerId = GlobalUserHolder.getCustomerId();
// 1. 用户名唯一性校验(依赖唯一索引,此处为友好提示)
boolean exists = this.count(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getUserName, dto.getUserName())
.eq(SysUser::getCustomerId, currentCustomerId)) > 0;
if (exists) {
throw new BizException("用户名已存在");
}
// ... 后续逻辑保持不变 ...
}
验收标准
- 同一requestId重复请求返回相同userId
- 并发创建同名用户触发唯一索引异常
- 分布式锁60秒后自动释放
- 结果缓存5分钟后过期
- 压力测试:100并发请求仅成功1次
测试用例
# 并发测试脚本
for i in {1..10}; do
curl -X POST http://localhost:9998/api/v1/system/users \
-H "Content-Type: application/json" \
-H "X-Request-Id: test-uuid-12345" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"userName": "testuser001",
"realName": "测试用户",
"orgIds": [1001]
}' &
done
wait
# 预期:仅1次成功,其余返回409或提示重复提交
任务 1.3:租户隔离二次校验(Mapper层) ⏱️ 2人日
目标: 在数据访问层增加租户校验,防止拦截器失效导致的数据泄露
实施步骤
1. 创建Mapper层租户校验注解
// 文件: ljwx-boot-common/src/main/java/com/ljwx/common/annotation/TenantCheck.java
package com.ljwx.common.annotation;
import java.lang.annotation.*;
/**
* Mapper层租户隔离校验注解
* 标注在Mapper方法上,自动注入customerId条件
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface TenantCheck {
/**
* 是否启用租户校验(默认启用)
*/
boolean enabled() default true;
/**
* 租户字段名(默认customer_id)
*/
String tenantIdColumn() default "customer_id";
}
2. 创建MyBatis拦截器
// 文件: ljwx-boot-common/src/main/java/com/ljwx/common/interceptor/TenantCheckInterceptor.java
package com.ljwx.common.interceptor;
import com.ljwx.common.annotation.TenantCheck;
import com.ljwx.infrastructure.holder.GlobalUserHolder;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.*;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Properties;
/**
* Mapper层租户隔离拦截器
*/
@Slf4j
@Component
@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, org.apache.ibatis.session.RowBounds.class, org.apache.ibatis.session.ResultHandler.class})
})
public class TenantCheckInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
Object parameter = invocation.getArgs()[1];
// 检查是否需要租户校验
String mapperId = ms.getId();
Class<?> mapperClass = Class.forName(mapperId.substring(0, mapperId.lastIndexOf(".")));
String methodName = mapperId.substring(mapperId.lastIndexOf(".") + 1);
// 查找方法上的TenantCheck注解
Method[] methods = mapperClass.getMethods();
for (Method method : methods) {
if (method.getName().equals(methodName) && method.isAnnotationPresent(TenantCheck.class)) {
TenantCheck annotation = method.getAnnotation(TenantCheck.class);
if (annotation.enabled()) {
validateTenant(ms, parameter, annotation);
}
break;
}
}
return invocation.proceed();
}
private void validateTenant(MappedStatement ms, Object parameter, TenantCheck annotation) {
Long currentCustomerId = GlobalUserHolder.getCustomerId();
if (currentCustomerId == null) {
log.warn("⚠️ 租户ID为空,跳过租户校验: {}", ms.getId());
return;
}
// 对于UPDATE/DELETE操作,强制校验租户ID
if (ms.getSqlCommandType() == SqlCommandType.UPDATE ||
ms.getSqlCommandType() == SqlCommandType.DELETE) {
// 检查参数中是否包含customerId
if (parameter instanceof java.util.Map) {
@SuppressWarnings("unchecked")
java.util.Map<String, Object> paramMap = (java.util.Map<String, Object>) parameter;
Object customerId = paramMap.get("customerId");
if (customerId != null && !currentCustomerId.equals(customerId)) {
log.error("🚨 租户校验失败: currentCustomerId={}, paramCustomerId={}, sql={}",
currentCustomerId, customerId, ms.getId());
throw new SecurityException("禁止操作其他租户的数据");
}
}
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 配置项(暂无)
}
}
3. 在关键Mapper方法上添加注解
// 文件: ljwx-boot-modules/src/main/java/com/ljwx/modules/system/repository/mapper/SysUserMapper.java
@Mapper
public interface SysUserMapper extends BaseMapper<SysUser> {
/**
* 分页查询用户(带租户校验)
*/
@TenantCheck(enabled = true)
List<SysUser> listSysUserPage(@Param("page") IPage<SysUser> page, @Param("query") SysUserBO query);
/**
* 根据ID查询用户(带租户校验)
*/
@TenantCheck(enabled = true)
SysUser selectById(@Param("id") Long id);
/**
* 更新用户(带租户校验)
*/
@TenantCheck(enabled = true)
int updateById(@Param("user") SysUser user);
/**
* 删除用户(带租户校验)
*/
@TenantCheck(enabled = true)
int deleteById(@Param("id") Long id, @Param("customerId") Long customerId);
}
4. XML中增加customerId条件
<!-- 文件: ljwx-boot-modules/src/main/resources/mapper/system/SysUserMapper.xml -->
<select id="listSysUserPage" resultType="com.ljwx.modules.system.domain.entity.SysUser">
SELECT * FROM sys_user
WHERE deleted = 0
AND customer_id = #{query.customerId} <!-- 强制租户隔离 -->
<if test="query.userName != null and query.userName != ''">
AND user_name LIKE CONCAT('%', #{query.userName}, '%')
</if>
ORDER BY create_time DESC
</select>
验收标准
- 所有用户查询SQL自动包含
customer_id条件 - 跨租户操作触发SecurityException异常
- 日志记录所有租户校验失败事件
- 性能影响 < 5ms/请求
任务 1.4:冗余字段定时修复任务 ⏱️ 1.5人日
目标: 建立自动修复机制,保证org_name、customerId等冗余字段一致性
实施步骤
1. 创建定时任务配置类
// 文件: ljwx-boot-modules/src/main/java/com/ljwx/modules/system/task/DataConsistencyTasks.java
package com.ljwx.modules.system.task;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ljwx.modules.system.domain.entity.SysOrgUnits;
import com.ljwx.modules.system.domain.entity.SysUser;
import com.ljwx.modules.system.service.ISysOrgUnitsService;
import com.ljwx.modules.system.service.ISysUserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 数据一致性定时任务
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DataConsistencyTasks {
private final ISysUserService userService;
private final ISysOrgUnitsService orgUnitsService;
/**
* 修复用户表的组织冗余字段
* 执行时间:每天凌晨3点
*/
@Scheduled(cron = "0 0 3 * * ?")
@Transactional(rollbackFor = Exception.class)
public void syncUserOrgRedundancy() {
log.info("========== 开始修复用户组织冗余字段 ==========");
long startTime = System.currentTimeMillis();
AtomicInteger totalCount = new AtomicInteger(0);
AtomicInteger fixedCount = new AtomicInteger(0);
AtomicInteger errorCount = new AtomicInteger(0);
// 分批处理,避免一次性加载过多数据
int pageSize = 500;
int currentPage = 0;
while (true) {
List<SysUser> users = userService.page(
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(currentPage, pageSize)
).getRecords();
if (users.isEmpty()) {
break;
}
for (SysUser user : users) {
totalCount.incrementAndGet();
try {
if (user.getOrgId() == null) {
log.warn("⚠️ 用户无组织ID: userId={}, userName={}", user.getId(), user.getUserName());
continue;
}
// 查询实际的组织信息
SysOrgUnits actualOrg = orgUnitsService.getById(user.getOrgId());
if (actualOrg == null) {
log.error("❌ 组织不存在: userId={}, orgId={}", user.getId(), user.getOrgId());
errorCount.incrementAndGet();
continue;
}
// 检查冗余字段是否一致
boolean needUpdate = false;
SysUser updateUser = new SysUser();
updateUser.setId(user.getId());
if (!Objects.equals(user.getOrgName(), actualOrg.getName())) {
log.warn("⚠️ 组织名称不一致: userId={}, 当前={}, 实际={}",
user.getId(), user.getOrgName(), actualOrg.getName());
updateUser.setOrgName(actualOrg.getName());
needUpdate = true;
}
if (!Objects.equals(user.getCustomerId(), actualOrg.getCustomerId())) {
log.warn("⚠️ 租户ID不一致: userId={}, 当前={}, 实际={}",
user.getId(), user.getCustomerId(), actualOrg.getCustomerId());
updateUser.setCustomerId(actualOrg.getCustomerId());
needUpdate = true;
}
if (needUpdate) {
userService.updateById(updateUser);
fixedCount.incrementAndGet();
log.info("✅ 已修复: userId={}, orgName={}, customerId={}",
user.getId(), actualOrg.getName(), actualOrg.getCustomerId());
}
} catch (Exception e) {
log.error("❌ 修复失败: userId={}, error={}", user.getId(), e.getMessage());
errorCount.incrementAndGet();
}
}
currentPage++;
}
long duration = System.currentTimeMillis() - startTime;
log.info("========== 修复完成 ==========");
log.info("总数: {}, 修复: {}, 错误: {}, 耗时: {}ms",
totalCount.get(), fixedCount.get(), errorCount.get(), duration);
// 发送告警(如果修复数量过多)
if (fixedCount.get() > 100) {
log.error("🚨 冗余字段修复数量异常: fixedCount={}, 可能存在数据一致性问题", fixedCount.get());
// TODO: 接入告警系统(钉钉/企业微信)
}
}
/**
* 检查并报告数据一致性问题
* 执行时间:每小时执行一次
*/
@Scheduled(cron = "0 0 * * * ?")
public void checkDataConsistency() {
log.info("========== 开始检查数据一致性 ==========");
// 检查用户-组织关系表的租户ID一致性
int inconsistentCount = userService.count(new LambdaQueryWrapper<SysUser>()
.apply("EXISTS (SELECT 1 FROM sys_org_units o WHERE o.id = sys_user.org_id AND o.customer_id != sys_user.customer_id)")
);
if (inconsistentCount > 0) {
log.error("🚨 发现 {} 条用户租户ID不一致记录", inconsistentCount);
} else {
log.info("✅ 数据一致性检查通过");
}
}
}
2. 启用定时任务配置
// 文件: ljwx-boot-admin/src/main/java/com/ljwx/LjwxBootApplication.java
@SpringBootApplication
@EnableScheduling // 启用定时任务
@EnableAsync // 启用异步任务
public class LjwxBootApplication {
public static void main(String[] args) {
SpringApplication.run(LjwxBootApplication.class, args);
}
}
3. 数据库触发器(可选,作为最后防线)
-- 创建触发器:组织名称变更时自动同步用户表
DELIMITER $$
CREATE TRIGGER trg_sync_user_org_name
AFTER UPDATE ON sys_org_units
FOR EACH ROW
BEGIN
IF OLD.name != NEW.name OR OLD.customer_id != NEW.customer_id THEN
UPDATE sys_user
SET org_name = NEW.name,
customer_id = NEW.customer_id,
update_time = NOW()
WHERE org_id = NEW.id AND deleted = 0;
-- 记录日志
INSERT INTO sys_data_sync_log (table_name, record_id, sync_type, sync_time)
VALUES ('sys_user', NEW.id, 'org_update', NOW());
END IF;
END$$
DELIMITER ;
验收标准
- 定时任务按时执行(日志可查)
- 模拟组织改名后,用户表自动同步
- 批量处理500条数据耗时 < 2秒
- 修复数量 > 100时触发告警
🟡 Phase 2: 性能优化与审计合规(P1优先级,Week 3-4)
任务 2.1:软删除数据归档机制 ⏱️ 2.5人日
目标: 定期归档已删除数据,控制主表数据量
实施步骤
1. 创建归档表
-- 用户归档表(结构同主表,增加归档字段)
CREATE TABLE sys_user_archive (
LIKE sys_user
);
ALTER TABLE sys_user_archive
ADD COLUMN archived_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '归档时间';
ALTER TABLE sys_user_archive
ADD COLUMN archived_by VARCHAR(50) COMMENT '归档操作人';
ALTER TABLE sys_user_archive
ADD INDEX idx_archived_at (archived_at);
-- 类似创建其他表的归档表
CREATE TABLE sys_user_org_archive (LIKE sys_user_org);
ALTER TABLE sys_user_org_archive ADD COLUMN archived_at DATETIME DEFAULT CURRENT_TIMESTAMP;
CREATE TABLE sys_user_role_archive (LIKE sys_user_role);
ALTER TABLE sys_user_role_archive ADD COLUMN archived_at DATETIME DEFAULT CURRENT_TIMESTAMP;
2. 创建归档存储过程
DELIMITER $$
CREATE PROCEDURE sp_archive_deleted_users(
IN archive_days INT -- 归档多少天前的删除数据
)
BEGIN
DECLARE affected_rows INT DEFAULT 0;
DECLARE archive_time DATETIME;
SET archive_time = DATE_SUB(NOW(), INTERVAL archive_days DAY);
-- 开启事务
START TRANSACTION;
-- 1. 归档用户主表
INSERT INTO sys_user_archive
SELECT *, NOW(), 'system'
FROM sys_user
WHERE deleted = 1
AND update_time < archive_time;
SET affected_rows = ROW_COUNT();
-- 2. 归档用户-组织关系表
INSERT INTO sys_user_org_archive
SELECT uo.*, NOW()
FROM sys_user_org uo
INNER JOIN sys_user u ON uo.user_id = u.id
WHERE u.deleted = 1
AND u.update_time < archive_time;
-- 3. 归档用户-角色关系表
INSERT INTO sys_user_role_archive
SELECT ur.*, NOW()
FROM sys_user_role ur
INNER JOIN sys_user u ON ur.user_id = u.id
WHERE u.deleted = 1
AND u.update_time < archive_time;
-- 4. 物理删除已归档数据
DELETE FROM sys_user_role
WHERE user_id IN (
SELECT id FROM sys_user
WHERE deleted = 1 AND update_time < archive_time
);
DELETE FROM sys_user_org
WHERE user_id IN (
SELECT id FROM sys_user
WHERE deleted = 1 AND update_time < archive_time
);
DELETE FROM sys_user
WHERE deleted = 1
AND update_time < archive_time;
-- 提交事务
COMMIT;
-- 返回归档数量
SELECT affected_rows AS archived_count, NOW() AS archived_at;
END$$
DELIMITER ;
3. Java定时任务调用
// 文件: ljwx-boot-modules/src/main/java/com/ljwx/modules/system/task/DataArchiveTasks.java
@Component
@Slf4j
@RequiredArgsConstructor
public class DataArchiveTasks {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 归档已删除用户数据
* 执行时间:每月1日凌晨2点
*/
@Scheduled(cron = "0 0 2 1 * ?")
public void archiveDeletedUsers() {
log.info("========== 开始归档已删除用户数据 ==========");
try {
// 归档365天前的删除数据
SimpleJdbcCall jdbcCall = new SimpleJdbcCall(jdbcTemplate)
.withProcedureName("sp_archive_deleted_users");
Map<String, Object> result = jdbcCall.execute(
Collections.singletonMap("archive_days", 365)
);
log.info("✅ 归档完成: {}", result);
} catch (Exception e) {
log.error("❌ 归档失败", e);
// TODO: 发送告警
}
}
/**
* 检查主表数据量,超过阈值时告警
*/
@Scheduled(cron = "0 0 0 * * ?")
public void checkTableSize() {
Long totalUsers = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM sys_user WHERE deleted = 0",
Long.class
);
Long deletedUsers = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM sys_user WHERE deleted = 1",
Long.class
);
log.info("📊 用户表数据量: 总数={}, 有效={}, 已删除={}",
totalUsers + deletedUsers, totalUsers, deletedUsers);
// 已删除数据超过10%时告警
if (deletedUsers > totalUsers * 0.1) {
log.warn("🚨 已删除用户数据过多: {}, 建议执行归档", deletedUsers);
}
}
}
验收标准
- 归档表创建成功
- 存储过程执行无误
- 归档后主表数据量下降
- 归档表可查询历史数据
- 定时任务日志正常
任务 2.2:事务拆分与异步优化 ⏱️ 2.5人日
目标: 将非核心操作(设备绑定、通知)移出主事务,提升性能
实施步骤
1. 拆分用户新增事务
// 文件: ljwx-boot-modules/src/main/java/com/ljwx/modules/system/service/impl/SysUserServiceImpl.java
@Service
@Slf4j
@RequiredArgsConstructor
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
implements ISysUserService {
@NonNull
private ApplicationEventPublisher eventPublisher;
/**
* 新增用户(优化后:核心事务缩小)
*/
@Override
@Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_COMMITTED)
public Long addUser(SysUserAddDTO dto) {
// 核心事务:仅包含数据库写入操作
Long userId = addUserCore(dto);
// 事务提交后执行非核心操作
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// 异步绑定设备
if (StringUtils.hasText(dto.getDeviceSn())) {
eventPublisher.publishEvent(new UserDeviceBindEvent(
userId, dto.getDeviceSn(), dto.getUserName()
));
}
// 异步发送通知
eventPublisher.publishEvent(new UserCreatedEvent(
userId, dto.getEmail(), dto.getPhone()
));
}
}
);
return userId;
}
/**
* 核心事务:数据库写入
*/
private Long addUserCore(SysUserAddDTO dto) {
// ... 原有逻辑,移除设备绑定和通知部分 ...
// 仅保留:
// 1. 校验
// 2. 密码生成
// 3. 插入sys_user
// 4. 插入sys_user_org
// 5. 插入sys_user_role
// 6. 更新userType/adminLevel
return user.getId();
}
}
2. 创建异步事件处理器
// 文件: ljwx-boot-modules/src/main/java/com/ljwx/modules/system/event/UserCreatedEvent.java
@Getter
@AllArgsConstructor
public class UserCreatedEvent {
private final Long userId;
private final String email;
private final String phone;
}
// 文件: ljwx-boot-modules/src/main/java/com/ljwx/modules/system/event/UserDeviceBindEvent.java
@Getter
@AllArgsConstructor
public class UserDeviceBindEvent {
private final Long userId;
private final String deviceSn;
private final String userName;
}
3. 异步事件监听器
// 文件: ljwx-boot-modules/src/main/java/com/ljwx/modules/system/listener/UserEventListener.java
@Component
@Slf4j
@RequiredArgsConstructor
public class UserEventListener {
@NonNull
private ITDeviceUserService deviceUserService;
@NonNull
private INotificationService notificationService;
/**
* 处理用户创建后的设备绑定
*/
@Async
@EventListener
@Order(1)
public void handleDeviceBind(UserDeviceBindEvent event) {
log.info("异步处理设备绑定: userId={}, deviceSn={}", event.getUserId(), event.getDeviceSn());
try {
boolean bound = deviceUserService.bindDevice(
event.getDeviceSn(),
event.getUserId().toString(),
event.getUserName()
);
if (bound) {
log.info("✅ 设备绑定成功: userId={}, deviceSn={}", event.getUserId(), event.getDeviceSn());
} else {
log.warn("⚠️ 设备绑定失败: userId={}, deviceSn={}", event.getUserId(), event.getDeviceSn());
}
} catch (Exception e) {
log.error("❌ 设备绑定异常: userId={}, deviceSn={}", event.getUserId(), event.getDeviceSn(), e);
// 补偿机制:记录失败,待重试
// TODO: 写入重试队列
}
}
/**
* 处理用户创建后的通知发送
*/
@Async
@EventListener
@Order(2)
public void handleNotification(UserCreatedEvent event) {
log.info("异步发送用户创建通知: userId={}", event.getUserId());
try {
notificationService.sendWelcomeEmail(event.getEmail());
notificationService.sendWelcomeSms(event.getPhone());
log.info("✅ 通知发送成功: userId={}", event.getUserId());
} catch (Exception e) {
log.error("❌ 通知发送失败: userId={}", event.getUserId(), e);
}
}
}
4. 配置异步线程池
// 文件: ljwx-boot-common/src/main/java/com/ljwx/common/config/AsyncConfig.java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Bean(name = "userEventExecutor")
public ThreadPoolTaskExecutor userEventExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("user-event-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Override
public Executor getAsyncExecutor() {
return userEventExecutor();
}
}
验收标准
- 用户创建接口响应时间 < 500ms(原 > 1s)
- 事务持有时间 < 200ms
- 设备绑定失败不影响用户创建
- 异步任务失败有日志记录
- 压力测试TPS提升 > 50%
任务 2.3:操作审计日志系统 ⏱️ 3人日
目标: 记录所有敏感操作,满足安全审计要求
实施步骤
1. 创建审计日志表
CREATE TABLE sys_audit_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
customer_id BIGINT NOT NULL COMMENT '租户ID',
operator_id BIGINT COMMENT '操作人ID',
operator_name VARCHAR(50) COMMENT '操作人姓名',
operation_type VARCHAR(20) NOT NULL COMMENT '操作类型:CREATE/UPDATE/DELETE',
resource_type VARCHAR(50) NOT NULL COMMENT '资源类型:USER/ROLE/ORG',
resource_id BIGINT COMMENT '资源ID',
resource_name VARCHAR(100) COMMENT '资源名称',
operation_desc VARCHAR(500) COMMENT '操作描述',
before_data JSON COMMENT '变更前数据',
after_data JSON COMMENT '变更后数据',
ip_address VARCHAR(50) COMMENT '操作IP',
user_agent VARCHAR(500) COMMENT 'User-Agent',
request_id VARCHAR(50) COMMENT '请求ID',
execute_time INT COMMENT '执行耗时(ms)',
result VARCHAR(20) COMMENT '操作结果:SUCCESS/FAILURE',
error_msg TEXT COMMENT '错误信息',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_customer_time (customer_id, create_time),
INDEX idx_operator (operator_id, create_time),
INDEX idx_resource (resource_type, resource_id),
INDEX idx_request_id (request_id)
) COMMENT '操作审计日志表';
2. 创建审计注解
// 文件: ljwx-boot-common/src/main/java/com/ljwx/common/annotation/AuditLog.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AuditLog {
/**
* 操作类型
*/
OperationType type();
/**
* 资源类型
*/
ResourceType resource();
/**
* 操作描述(支持SpEL表达式)
*/
String description() default "";
/**
* 是否记录变更前数据
*/
boolean recordBefore() default false;
/**
* 是否记录变更后数据
*/
boolean recordAfter() default true;
enum OperationType {
CREATE, UPDATE, DELETE, QUERY, EXPORT, IMPORT
}
enum ResourceType {
USER, ROLE, ORG, PERMISSION, TENANT, DEVICE
}
}
3. 审计AOP切面
// 文件: ljwx-boot-common/src/main/java/com/ljwx/common/aspect/AuditLogAspect.java
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class AuditLogAspect {
@NonNull
private ISysAuditLogService auditLogService;
@Around("@annotation(auditLog)")
public Object around(ProceedingJoinPoint joinPoint, AuditLog auditLog) throws Throwable {
long startTime = System.currentTimeMillis();
SysAuditLog logEntity = new SysAuditLog();
logEntity.setOperationType(auditLog.type().name());
logEntity.setResourceType(auditLog.resource().name());
logEntity.setCustomerId(GlobalUserHolder.getCustomerId());
logEntity.setOperatorId(GlobalUserHolder.getUserId());
logEntity.setOperatorName(GlobalUserHolder.getUserName());
logEntity.setIpAddress(ServletHolderUtil.getClientIP());
logEntity.setUserAgent(ServletHolderUtil.getUserAgent());
// 记录变更前数据
if (auditLog.recordBefore()) {
Object beforeData = getBeforeData(joinPoint);
logEntity.setBeforeData(JSON.toJSONString(beforeData));
}
try {
// 执行方法
Object result = joinPoint.proceed();
// 记录变更后数据
if (auditLog.recordAfter()) {
logEntity.setAfterData(JSON.toJSONString(result));
}
logEntity.setResult("SUCCESS");
logEntity.setExecuteTime((int) (System.currentTimeMillis() - startTime));
// 异步保存审计日志
auditLogService.saveAsync(logEntity);
return result;
} catch (Exception e) {
logEntity.setResult("FAILURE");
logEntity.setErrorMsg(e.getMessage());
logEntity.setExecuteTime((int) (System.currentTimeMillis() - startTime));
auditLogService.saveAsync(logEntity);
throw e;
}
}
private Object getBeforeData(ProceedingJoinPoint joinPoint) {
// 从方法参数中提取ID,查询原始数据
// ...实现略...
return null;
}
}
4. 在关键方法上添加审计注解
// 文件: ljwx-boot-modules/src/main/java/com/ljwx/modules/system/service/impl/SysUserServiceImpl.java
@Override
@AuditLog(
type = AuditLog.OperationType.CREATE,
resource = AuditLog.ResourceType.USER,
description = "创建用户: #{#dto.userName}",
recordAfter = true
)
public Long addUser(SysUserAddDTO dto) {
// ... 原有逻辑 ...
}
@Override
@AuditLog(
type = AuditLog.OperationType.UPDATE,
resource = AuditLog.ResourceType.USER,
description = "更新用户: #{#dto.id}",
recordBefore = true,
recordAfter = true
)
public boolean updateUser(SysUserBO dto) {
// ... 原有逻辑 ...
}
@Override
@AuditLog(
type = AuditLog.OperationType.DELETE,
resource = AuditLog.ResourceType.USER,
description = "删除用户: #{#ids}",
recordBefore = true
)
public boolean removeBatchByIds(List<Long> ids) {
// ... 原有逻辑 ...
}
验收标准
- 所有CRUD操作生成审计日志
- 日志包含操作人、时间、IP、变更内容
- 审计日志异步写入,不影响主流程性能
- 日志可按租户、操作人、时间范围查询
- 日志保留期 ≥ 1年
任务 2.4:密码强度策略 ⏱️ 1.5人日
目标: 增强密码安全性,支持密码复杂度策略
实施步骤
1. 创建密码策略配置
// 文件: ljwx-boot-common/src/main/java/com/ljwx/common/config/PasswordPolicyConfig.java
@Configuration
@ConfigurationProperties(prefix = "ljwx.security.password")
@Data
public class PasswordPolicyConfig {
/**
* 最小长度
*/
private int minLength = 12;
/**
* 是否必须包含大写字母
*/
private boolean requireUppercase = true;
/**
* 是否必须包含小写字母
*/
private boolean requireLowercase = true;
/**
* 是否必须包含数字
*/
private boolean requireDigit = true;
/**
* 是否必须包含特殊字符
*/
private boolean requireSpecialChar = false;
/**
* 密码有效期(天)
*/
private int expiryDays = 90;
/**
* 密码历史记录数(防止重复使用)
*/
private int historyCount = 5;
}
2. 密码校验工具类
// 文件: ljwx-boot-common/src/main/java/com/ljwx/common/util/PasswordValidator.java
@Component
@RequiredArgsConstructor
public class PasswordValidator {
@NonNull
private PasswordPolicyConfig policyConfig;
/**
* 校验密码强度
*
* @throws BizException 密码不符合要求时抛出异常
*/
public void validate(String password) {
if (password == null || password.length() < policyConfig.getMinLength()) {
throw new BizException("密码长度不能少于" + policyConfig.getMinLength() + "位");
}
if (policyConfig.isRequireUppercase() && !password.matches(".*[A-Z].*")) {
throw new BizException("密码必须包含大写字母");
}
if (policyConfig.isRequireLowercase() && !password.matches(".*[a-z].*")) {
throw new BizException("密码必须包含小写字母");
}
if (policyConfig.isRequireDigit() && !password.matches(".*\\d.*")) {
throw new BizException("密码必须包含数字");
}
if (policyConfig.isRequireSpecialChar() && !password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?].*")) {
throw new BizException("密码必须包含特殊字符");
}
}
/**
* 生成符合策略的随机密码
*/
public String generateSecurePassword() {
String upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
String lower = "abcdefghijklmnopqrstuvwxyz";
String digits = "0123456789";
String special = "!@#$%^&*";
StringBuilder password = new StringBuilder();
SecureRandom random = new SecureRandom();
// 确保包含各类字符
if (policyConfig.isRequireUppercase()) {
password.append(upper.charAt(random.nextInt(upper.length())));
}
if (policyConfig.isRequireLowercase()) {
password.append(lower.charAt(random.nextInt(lower.length())));
}
if (policyConfig.isRequireDigit()) {
password.append(digits.charAt(random.nextInt(digits.length())));
}
if (policyConfig.isRequireSpecialChar()) {
password.append(special.charAt(random.nextInt(special.length())));
}
// 填充剩余长度
String allChars = upper + lower + digits + (policyConfig.isRequireSpecialChar() ? special : "");
int remaining = policyConfig.getMinLength() - password.length();
for (int i = 0; i < remaining; i++) {
password.append(allChars.charAt(random.nextInt(allChars.length())));
}
// 打乱顺序
return shuffleString(password.toString());
}
private String shuffleString(String input) {
List<Character> chars = input.chars()
.mapToObj(c -> (char) c)
.collect(Collectors.toList());
Collections.shuffle(chars);
return chars.stream()
.map(String::valueOf)
.collect(Collectors.joining());
}
}
3. 应用配置文件
# 文件: ljwx-boot-admin/src/main/resources/application.yml
ljwx:
security:
password:
min-length: 12
require-uppercase: true
require-lowercase: true
require-digit: true
require-special-char: false
expiry-days: 90
history-count: 5
4. 修改用户新增逻辑
// 文件: ljwx-boot-modules/src/main/java/com/ljwx/modules/system/service/impl/SysUserServiceImpl.java
@Autowired
private PasswordValidator passwordValidator;
@Override
public Long addUser(SysUserAddDTO dto) {
// 生成符合策略的密码
String rawPassword = passwordValidator.generateSecurePassword();
String salt = RandomStringUtils.randomAlphabetic(6);
String password = DigestUtils.sha256Hex(
DigestUtils.sha256Hex(rawPassword) + salt
);
// ... 后续逻辑 ...
}
验收标准
- 生成的密码符合策略要求
- 密码重置时校验强度
- 配置可通过yml文件调整
- 提供密码强度检测API
🟢 Phase 3: 功能完善与用户体验(P2优先级,Week 5-6)
任务 3.1:批量导入优化 ⏱️ 3人日
(详细步骤略,主要包括:异步任务、进度WebSocket推送、错误详情导出)
任务 3.2:数据导出脱敏 ⏱️ 1.5人日
(详细步骤略,主要包括:手机号/邮箱脱敏、导出权限控制)
任务 3.3:权限缓存清理 ⏱️ 1.5人日
(详细步骤略,主要包括:角色变更时清理关联用户缓存)
任务 3.4:租户管理完善 ⏱️ 2.5人日
(详细步骤略,主要包括:租户CRUD接口、租户状态管理、租户数据导出)
📊 实施进度跟踪表
| 任务编号 | 任务名称 | 负责人 | 预估工时 | 优先级 | 状态 | 实际工时 | 备注 |
|---|---|---|---|---|---|---|---|
| 1.1 | 数据库唯一索引 | DBA | 0.5人日 | P0 | 待开始 | - | - |
| 1.2 | 幂等性改造 | 后端开发 | 1.5人日 | P0 | 待开始 | - | - |
| 1.3 | 租户隔离二次校验 | 后端开发 | 2人日 | P0 | 待开始 | - | - |
| 1.4 | 冗余字段定时修复 | 后端开发 | 1.5人日 | P0 | 待开始 | - | - |
| 2.1 | 软删除数据归档 | DBA+后端 | 2.5人日 | P1 | 待开始 | - | - |
| 2.2 | 事务拆分优化 | 后端开发 | 2.5人日 | P1 | 待开始 | - | - |
| 2.3 | 操作审计日志 | 后端开发 | 3人日 | P1 | 待开始 | - | - |
| 2.4 | 密码强度策略 | 后端开发 | 1.5人日 | P1 | 待开始 | - | - |
| 3.1 | 批量导入优化 | 后端+前端 | 3人日 | P2 | 待开始 | - | - |
| 3.2 | 数据导出脱敏 | 后端开发 | 1.5人日 | P2 | 待开始 | - | - |
| 3.3 | 权限缓存清理 | 后端开发 | 1.5人日 | P2 | 待开始 | - | - |
| 3.4 | 租户管理完善 | 后端+前端 | 2.5人日 | P2 | 待开始 | - | - |
总计: ~24人日(约1人-月,或2人并行2周完成)
⚠️ 风险与应对
| 风险 | 影响 | 概率 | 应对措施 |
|---|---|---|---|
| 数据库索引添加锁表 | 业务中断 | 中 | 业务低峰期执行,准备回滚脚本 |
| 定时任务影响性能 | 响应变慢 | 低 | 分批处理,监控CPU/内存 |
| 异步任务失败无感知 | 数据不一致 | 中 | 补偿机制+告警通知 |
| 触发器影响写入性能 | TPS下降 | 中 | 可选方案,优先使用定时任务 |
| 审计日志表膨胀 | 存储告警 | 高 | 定期归档,保留1年热数据 |
✅ 验收标准总览
Phase 1验收标准
- 用户名并发创建测试通过(100并发仅1次成功)
- 跨租户操作被拦截(403错误)
- 冗余字段定时任务执行正常
- 数据一致性检查无异常
Phase 2验收标准
- 已删除数据按月归档
- 用户创建响应时间 < 500ms
- 审计日志完整记录CRUD操作
- 密码强度符合策略
Phase 3验收标准
- 批量导入支持进度查询
- 导出数据手机号/邮箱脱敏
- 角色变更后权限立即生效
📝 上线检查清单
上线前
- 所有数据库脚本在测试环境验证
- 代码通过Code Review
- 单元测试覆盖率 > 70%
- 性能测试通过(TPS达标)
- 安全测试通过(无SQL注入、XSS)
- 准备回滚方案
上线时
- 数据库变更在低峰期执行
- 监控系统就绪(CPU、内存、慢SQL)
- 告警通道测试通过
- 值班人员到位
上线后
- 监控7天内无异常
- 审计日志抽样检查
- 用户反馈收集
- 性能指标对比
📞 联系方式
项目负责人: [填写] 技术负责人: [填写] DBA: [填写] 紧急联系电话: [填写]
文档版本: v1.0 最后更新: 2025-12-16 下次评审: Phase 1完成后