Files
ljwx-health/ljwx-boot/docs/architecture/tenant-user-management-implementation-plan.md
T
brunoandClaude Sonnet 4.5 cbe30d0849 feat: implement comprehensive tenant user management optimization
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>
2025-12-19 00:53:09 +08:00

44 KiB
Raw Blame History

🚀 租户与用户管理模块优化实施计划

📋 文档概述

项目名称: LJWX租户与用户管理模块企业级改造 计划周期: 4-6周(按优先级分3个阶段) 负责团队: 后端团队 + DBA 最后更新: 2025-12-16


🎯 实施目标

  1. 数据安全:消除跨租户数据泄露风险,补强数据隔离机制
  2. 数据一致性:建立冗余字段自动修复机制,保证数据准确性
  3. 并发安全:解决用户创建并发冲突问题
  4. 性能优化:减少事务大小,提升用户操作响应速度
  5. 审计合规:补充操作审计日志,满足安全审计要求
  6. 数据治理:建立软删除数据归档机制,控制数据膨胀

📅 三阶段实施路线图

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完成后