Series Article

Day08 · 权限安全 + SaaS 运营完整面试指南

系统概述

1.1 系统定位

权限安全+SaaS运营系统不是一个独立的业务模块,而是整个 SaaS 平台能够安全运行、商业化落地、生产部署的底座能力。

权限安全核心价值:

  1. 登录认证: 解决”你是谁”的问题,支持多租户登录、密码安全、Token 管理
  2. 权限控制: 解决”你能做什么”的问题,支持 RBAC、菜单权限、按钮权限、接口权限
  3. 数据权限: 解决”你能看哪些数据”的问题,支持租户隔离、店铺范围、仓库范围、本人数据
  4. 接口安全: 解决”如何防攻击”的问题,支持限流、防重放、SQL注入防护、XSS防护、审计日志

SaaS运营核心价值:

  1. 租户管理: 租户注册、初始化、试用、续费、到期、数据保留
  2. 套餐管理: 基础版、专业版、企业版功能开关和用量限制
  3. 计费管理: 套餐订阅、用量统计、账单生成、续费提醒
  4. 运营监控: 租户活跃度、功能使用率、用量预警

部署上线核心价值:

  1. 容器化部署: Docker 镜像、docker-compose 编排、环境一致性
  2. CI/CD 自动化: 代码提交、自动测试、构建镜像、自动部署
  3. HTTPS 接入: Nginx 反向代理、SSL 证书、安全响应头
  4. 监控告警: Prometheus、Grafana、业务指标、技术指标

1.2 业务规模

  • 支持租户数: 500+ 企业租户
  • 日均请求量: 100 万+ 接口请求
  • 用户角色: 租户管理员、采购专员、仓库管理员、运营专员、物流专员、财务专员、供应商用户
  • 权限点数: 200+ 个菜单和按钮权限
  • 套餐类型: 基础版、专业版、企业版
  • 部署环境: 开发环境、测试环境、生产环境

1.3 技术栈

  • 后端框架: Spring Boot 3.2、Spring Security / Sa-Token
  • 权限框架: Sa-Token 1.37、MyBatis-Plus 多租户插件
  • 数据库: MySQL 8.0(主从架构)
  • 缓存: Redis 7.0(权限缓存、限流、幂等)
  • 容器化: Docker 24.0、docker-compose 2.20
  • CI/CD: GitHub Actions、GitLab CI
  • Web服务器: Nginx 1.24(HTTPS、反向代理)
  • 监控: Prometheus 2.45、Grafana 10.0、AlertManager
  • 日志: ELK Stack(Elasticsearch、Logstash、Kibana)

核心业务流程

2.1 用户登录认证完整流程

用户登录认证是整个系统的入口,解决”你是谁”的问题。

业务背景:

供应链系统涉及采购价格、库存成本、平台订单、财务账单等敏感信息,用户必须先完成身份认证才能进入系统。

登录不是简单查用户名密码,还要处理租户编码、账号状态、密码加密、登录失败锁定、Token 签发、权限缓存等问题。

完整流程(8步):

第一步:前端提交登录请求

用户在登录页输入租户编码(tenantCode)、用户名(username)、密码(password),前端提交到后端认证接口。

技术上,SaaS 系统必须传 tenantCode,因为同一个用户名可能在不同租户下存在。

第二步:查询租户和用户

后端先根据 tenantCode 查询租户,判断租户状态是否正常。然后在该租户下查询用户,判断用户状态是否启用。

技术上,查询条件是 tenant_id = ? AND username = ?,不能只按 username 查询。

第三步:校验密码

使用 BCrypt 校验密码。BCrypt 是单向哈希,不能解密,只能验证输入密码是否匹配存储的哈希值。

技术上,使用 BCrypt.checkpw(inputPassword, storedHash) 验证密码。

第四步:处理登录失败

如果密码错误,记录登录失败次数。连续失败 5 次后锁定账号 30 分钟,防止暴力破解。

错误提示统一返回”用户名或密码错误”,不暴露”用户不存在”或”密码错误”的细节,避免攻击者枚举账号。

第五步:签发 Token

登录成功后,使用 Sa-Token 生成 AccessToken 和 RefreshToken。

AccessToken 用于访问业务接口,生命周期短(2小时);RefreshToken 用于刷新 AccessToken,生命周期长(7天)。

技术上,AccessToken 放在响应体返回,RefreshToken 可以放在 HttpOnly Cookie 中,降低 XSS 窃取风险。

第六步:查询并缓存权限

登录成功后,查询用户的角色和权限列表,缓存到 Redis。

权限列表包括菜单权限、按钮权限、接口权限。前端根据权限列表渲染菜单和按钮,后端根据权限注解做真实拦截。

第七步:记录登录日志

记录登录成功日志,包括用户ID、租户ID、登录时间、登录IP、设备信息。

同时更新用户表的最后登录时间和登录IP,清零登录失败次数。

第八步:返回登录结果

返回 AccessToken、RefreshToken、用户信息、权限列表给前端。

前端保存 Token,后续请求在 Authorization 头中携带 Bearer {AccessToken}

2.2 权限验证与数据隔离完整流程

权限验证解决”你能做什么”和”你能看哪些数据”的问题。

业务背景:

认证解决”你是谁”,授权解决”你能做什么”,数据权限解决”你能看哪些数据”。

比如采购专员可以查看供应商,但不能审核供应商;运营专员可以查看订单,但只能看自己负责店铺的订单;供应商 Portal 用户只能看自己的采购单和对账信息。

权限分层(4层):

第一层:登录认证

请求到达后,Sa-Token 先解析 Token。Token 无效或过期返回 401,需要重新登录。

第二层:功能权限

Controller 方法上使用 @SaCheckPermission("srm:supplier:audit") 声明所需权限。

后端从 Redis 查询用户权限列表,如果没有该权限,返回 403。

第三层:套餐权限

即使用户有某个功能权限,如果租户套餐没有开通该功能,也不能访问。

比如用户有高级 BI 权限,但租户是基础版,没有开通高级 BI,仍然返回 403 或套餐升级提示。

第四层:数据权限

所有业务表都有 tenant_id,MyBatis-Plus 多租户插件自动追加 tenant_id = 当前租户

更细粒度的数据权限通过 @DataScope 注解和 AOP 实现,比如只能看自己负责的店铺、仓库或本人数据。

数据隔离实现:

租户隔离通过 tenant_id 实现。用户登录后,从 Token 中提取 tenantId 放入 ThreadLocal。

MyBatis-Plus 多租户插件在执行 SQL 前自动追加 WHERE tenant_id = ?,保证 A 租户看不到 B 租户数据。

供应商 Portal 是特殊场景。供应商用户必须强制追加 supplier_id = 当前供应商ID,不能只依赖通用数据权限配置。

2.3 SaaS 租户注册与套餐生命周期流程

SaaS 系统和普通后台系统最大的区别是它要服务很多企业客户。每个企业客户就是一个租户。

业务背景:

租户运营不只是注册账号,还包括试用、开通、套餐限制、续费提醒、到期只读、数据保留和注销。

完整流程(10步):

第一步:企业注册

企业客户填写公司名称、联系人、手机号、邮箱,提交注册申请。

第二步:邮箱或短信验证

系统发送验证码到邮箱或手机,用户输入验证码完成验证。

第三步:创建租户记录

验证通过后,系统创建 sys_tenant 记录,生成唯一的 tenantCode。

第四步:创建租户管理员账号

系统自动创建租户管理员账号,用户名默认为 admin,密码通过邮件或短信发送。

第五步:初始化基础数据

系统为新租户初始化一套基础数据:

  • 预置角色(管理员、采购专员、仓库管理员、运营专员等)
  • 菜单权限
  • 系统字典
  • 编号规则
  • 默认仓库或引导配置
  • KPI 阈值
  • 套餐功能和用量记录

这些初始化操作必须放在事务里,避免出现租户创建成功但管理员账号创建失败的半成品数据。

第六步:选择套餐并进入试用期

租户选择基础版、专业版或企业版,系统分配对应的功能和用量限制。

新租户通常有 7-30 天试用期,试用期内可以使用所有功能。

第七步:试用到期判断

试用期结束后,系统判断租户是否续费。

如果续费,进入正式使用期;如果不续费,转为只读状态。

第八步:套餐到期前提醒

套餐到期前 7 天、3 天、1 天,系统发送续费提醒邮件或站内信。

第九步:到期后写操作拦截

套餐到期后,租户可以登录和查看历史数据,但所有新增、修改、删除操作被拦截。

这样既保护平台权益,也避免客户因为忘记续费导致数据突然不可用。

第十步:数据保留期后归档或清理

到期后有 30-90 天数据保留期。保留期内续费可以恢复正常使用。

超过保留期后,数据进入归档或清理流程。

套餐限制设计:

套餐限制和权限限制不一样。

权限限制是用户有没有资格做某个动作。套餐限制是这个租户购买的版本是否允许使用某功能或使用到多少数量。

比如基础版最多 20 个供应商、1 个仓库、每月 500 单;专业版支持更多供应商、更多平台和供应商 Portal;企业版支持开放 API 和更多定制能力。

2.4 生产部署与上线完整流程

一个项目能在本地跑起来,不代表它能上线。上线要解决环境一致、配置安全、镜像构建、服务编排、HTTPS、健康检查、日志、监控、回滚等问题。

业务背景:

Day08 的部署部分可以讲成一条完整链路:代码提交后通过 CI/CD 构建镜像,服务器用 Docker Compose 编排服务,Nginx 作为入口提供 HTTPS,Prometheus 和 Grafana 做监控。

完整流程(10步):

第一步:开发提交代码

开发人员完成功能开发,提交代码到 Git 仓库(GitHub 或 GitLab)。

第二步:触发 CI/CD 流水线

代码提交后,GitHub Actions 或 GitLab CI 自动触发流水线。

第三步:代码检查和单元测试

流水线先运行代码检查(Checkstyle、SonarQube)和单元测试。

测试失败则流水线中断,不允许构建镜像。

第四步:构建 Docker 镜像

测试通过后,使用 Dockerfile 构建 Docker 镜像。

后端使用多阶段构建:第一阶段用 Maven 打包,第二阶段用 JRE 镜像运行,减少镜像体积。

第五步:推送镜像仓库

构建完成的镜像推送到镜像仓库(Docker Hub、阿里云镜像仓库、Harbor)。

镜像使用 commit sha 或版本号打标签,便于追溯和回滚。

第六步:部署测试环境

测试环境可以自动部署,拉取最新镜像,使用 docker-compose 更新服务。

第七步:冒烟测试

部署完成后,自动执行冒烟测试,验证核心功能是否正常。

比如访问健康检查接口 /actuator/health,测试登录接口、查询接口等。

第八步:生产环境审批

生产环境部署需要负责人审批,避免误操作。

审批通过后,流水线继续执行生产部署。

第九步:生产服务器拉取镜像并更新服务

生产服务器拉取指定版本的镜像,使用 docker-compose 更新服务。

更新策略可以是滚动更新或蓝绿部署,保证服务不中断。

第十步:健康检查和监控

部署完成后,执行健康检查,确认服务启动成功。

同时监控接口错误率、P99 延迟、JVM、数据库、Redis 等指标,发现异常及时告警。


技术架构设计

3.1 整体架构

权限安全+SaaS运营系统是整个平台的横向支撑能力,为所有业务模块提供认证、授权、租户隔离、套餐限制、接口安全和监控能力。

核心模块:

  1. 认证模块: 登录、登出、Token 刷新、密码修改、账号锁定
  2. 权限模块: RBAC、菜单权限、按钮权限、接口权限、权限缓存
  3. 数据权限模块: 租户隔离、店铺范围、仓库范围、本人数据
  4. 租户运营模块: 租户注册、初始化、试用、续费、到期、数据保留
  5. 套餐管理模块: 套餐配置、功能开关、用量限制、套餐拦截
  6. 接口安全模块: 限流、防重放、SQL注入防护、XSS防护、审计日志
  7. 部署模块: Docker 镜像、docker-compose 编排、CI/CD 流水线
  8. 监控模块: Prometheus、Grafana、业务指标、技术指标、告警

技术架构图:

┌─────────────────────────────────────────────────────────┐
│                      前端层                              │
│         商家后台 / 供应商 Portal / 管理后台              │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                    Nginx 接入层                          │
│          HTTPS + 反向代理 + 安全响应头                   │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                 API 网关 + 认证拦截                      │
│          Token 解析 + 权限校验 + 限流                    │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                 业务服务层                               │
│  ┌──────────┬──────────┬──────────┬──────────┐         │
│  │SRM/PMS   │WMS/OMS   │TMS/FMS   │BI/运营   │         │
│  └──────────┴──────────┴──────────┴──────────┘         │
│         ↓ 租户隔离 + 数据权限 + 套餐限制                 │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                  基础设施层                              │
│  MySQL + Redis + RocketMQ + Nacos + Prometheus         │
└─────────────────────────────────────────────────────────┘

3.2 数据流转

登录认证数据流:

  1. 前端提交 tenantCode、username、password → 后端认证服务
  2. 查询租户和用户 → BCrypt 校验密码
  3. 签发 AccessToken 和 RefreshToken
  4. 查询权限列表 → 缓存到 Redis
  5. 返回 Token、用户信息、权限列表 → 前端

权限验证数据流:

  1. 请求到达 → Token 解析 → 提取 userId 和 tenantId
  2. 写入 TenantContext 和 UserContext
  3. 读取接口权限注解 → 从 Redis 查询用户权限
  4. 判断功能权限 → 判断套餐权限
  5. 追加 tenant_id 和数据权限条件 → 执行业务查询

租户注册数据流:

  1. 企业注册 → 邮箱或短信验证
  2. 创建租户记录 → 创建管理员账号
  3. 初始化角色、菜单、字典、配置 → 分配套餐
  4. 进入试用期 → 试用到期判断 → 续费或只读

部署上线数据流:

  1. 提交代码 → 触发 CI/CD → 运行测试
  2. 构建 Docker 镜像 → 推送镜像仓库
  3. 部署测试环境 → 冒烟测试
  4. 生产审批 → 拉取镜像 → 更新服务
  5. 健康检查 → 监控告警

核心技术亮点

4.1 Sa-Token 登录认证与双 Token 机制

问题背景:

登录认证是所有后台系统的入口。面试官不只关心你会不会调 login 方法,还会追问密码怎么存、Token 怎么续期、Token 泄露怎么办、用户禁用后 Token 怎么失效。

技术方案:

使用 Sa-Token + Redis 管理分布式登录态,配合 BCrypt 密码加密、账号锁定、随机 AccessToken + RefreshToken 双 Token 机制。

核心实现:

@Service
public class AuthService {
    
    @Autowired
    private TenantService tenantService;
    
    @Autowired
    private SysUserMapper userMapper;
    
    @Autowired
    private LoginFailService loginFailService;
    
    @Autowired
    private PermissionService permissionService;
    
    @Autowired
    private RefreshTokenService refreshTokenService;
    
    public LoginVO login(LoginCommand command) {
        // 1. 查询租户
        Tenant tenant = tenantService.getByCode(command.getTenantCode());
        if (tenant == null || !tenant.isNormal()) {
            throw new BusinessException("用户名或密码错误");
        }
        
        // 2. 查询用户
        SysUser user = userMapper.selectByTenantAndUsername(
            tenant.getId(), command.getUsername()
        );
        if (user == null || !user.isEnabled()) {
            throw new BusinessException("用户名或密码错误");
        }
        
        // 3. 校验密码
        if (!BCrypt.checkpw(command.getPassword(), user.getPassword())) {
            loginFailService.recordFail(user.getId());
            throw new BusinessException("用户名或密码错误");
        }
        
        // 4. 签发 Token
        StpUtil.login(user.getId());
        String accessToken = StpUtil.getTokenValue();
        String refreshToken = refreshTokenService.create(user.getId());
        
        // 5. 查询并缓存权限
        List<String> permissions = permissionService.listPermissionCodes(user.getId());
        permissionCacheService.cachePermissions(user.getId(), permissions);
        
        // 6. 记录登录日志
        loginLogService.recordSuccess(user.getId(), command.getIp());
        
        return LoginVO.of(accessToken, refreshToken, user, permissions);
    }
}

BCrypt 密码加密:

// 注册或修改密码时加密
String hashedPassword = BCrypt.hashpw(plainPassword, BCrypt.gensalt());

// 登录时校验
boolean matched = BCrypt.checkpw(inputPassword, storedHash);

登录失败锁定:

@Service
public class LoginFailService {
    
    private static final int MAX_FAIL_COUNT = 5;
    private static final int LOCK_MINUTES = 30;
    
    public void recordFail(Long userId) {
        String key = "login:fail:" + userId;
        Long failCount = redisTemplate.opsForValue().increment(key);
        
        if (failCount == 1) {
            redisTemplate.expire(key, Duration.ofMinutes(LOCK_MINUTES));
        }
        
        if (failCount >= MAX_FAIL_COUNT) {
            userMapper.lockAccount(userId, LocalDateTime.now().plusMinutes(LOCK_MINUTES));
        }
    }
    
    public boolean isLocked(Long userId) {
        SysUser user = userMapper.selectById(userId);
        return user.getLockedUntil() != null && 
               user.getLockedUntil().isAfter(LocalDateTime.now());
    }
}

实施效果:

  1. 认证逻辑清晰,支持多租户登录
  2. 降低密码泄露和暴力破解风险
  3. 用户短期 Token 过期后可无感续签
  4. 登录失败自动锁定,防止暴力破解

4.2 RBAC 权限模型与权限缓存

问题背景:

系统里有采购、仓库、运营、物流、财务、供应商、租户管理员等角色。如果直接给用户分配权限,用户多了会非常混乱。

RBAC 用”用户 - 角色 - 权限”三层关系解决这个问题。

技术方案:

设计用户、角色、权限三层 RBAC 模型,权限标识采用模块、资源、动作三段式。

权限表设计:

-- 用户表
CREATE TABLE sys_user (
    id BIGINT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    username VARCHAR(64) NOT NULL,
    password VARCHAR(128) NOT NULL,
    real_name VARCHAR(64),
    mobile VARCHAR(32),
    email VARCHAR(128),
    status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0禁用',
    locked_until DATETIME COMMENT '锁定到期时间',
    last_login_time DATETIME,
    last_login_ip VARCHAR(64),
    create_time DATETIME NOT NULL,
    UNIQUE KEY uk_tenant_username (tenant_id, username)
);

-- 角色表
CREATE TABLE sys_role (
    id BIGINT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    role_code VARCHAR(64) NOT NULL,
    role_name VARCHAR(128) NOT NULL,
    sort INT DEFAULT 0,
    status TINYINT NOT NULL DEFAULT 1,
    create_time DATETIME NOT NULL,
    UNIQUE KEY uk_tenant_code (tenant_id, role_code)
);

-- 菜单权限表
CREATE TABLE sys_menu (
    id BIGINT PRIMARY KEY,
    parent_id BIGINT DEFAULT 0,
    menu_name VARCHAR(128) NOT NULL,
    menu_type TINYINT NOT NULL COMMENT '1目录 2菜单 3按钮',
    permission_code VARCHAR(128) COMMENT '权限标识',
    path VARCHAR(256),
    component VARCHAR(256),
    icon VARCHAR(128),
    sort INT DEFAULT 0,
    status TINYINT NOT NULL DEFAULT 1,
    create_time DATETIME NOT NULL
);

-- 用户角色关联表
CREATE TABLE sys_user_role (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    create_time DATETIME NOT NULL,
    UNIQUE KEY uk_user_role (user_id, role_id)
);

-- 角色菜单关联表
CREATE TABLE sys_role_menu (
    id BIGINT PRIMARY KEY,
    role_id BIGINT NOT NULL,
    menu_id BIGINT NOT NULL,
    create_time DATETIME NOT NULL,
    UNIQUE KEY uk_role_menu (role_id, menu_id)
);

权限缓存实现:

@Service
public class PermissionCacheService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private SysMenuMapper menuMapper;
    
    public List<String> getUserPermissions(Long userId) {
        String cacheKey = "auth:perm:" + userId;
        
        // 先查 Redis
        List<String> cached = (List<String>) redisTemplate.opsForValue().get(cacheKey);
        if (cached != null && !cached.isEmpty()) {
            return cached;
        }
        
        // 查数据库
        List<String> permissions = menuMapper.selectPermissionCodesByUserId(userId);
        if (!permissions.isEmpty()) {
            redisTemplate.opsForValue().set(cacheKey, permissions, Duration.ofHours(2));
        }
        
        return permissions;
    }
    
    public void deleteUserPermissions(Long userId) {
        String cacheKey = "auth:perm:" + userId;
        redisTemplate.delete(cacheKey);
    }
    
    public void deleteRolePermissions(Long roleId) {
        // 查询该角色下的所有用户
        List<Long> userIds = userRoleMapper.selectUserIdsByRoleId(roleId);
        for (Long userId : userIds) {
            deleteUserPermissions(userId);
        }
    }
}

权限注解使用:

@RestController
@RequestMapping("/srm/supplier")
public class SupplierController {
    
    @GetMapping("/list")
    @SaCheckPermission("srm:supplier:list")
    public Result<PageVO<SupplierVO>> list(SupplierQuery query) {
        // 业务逻辑
    }
    
    @PostMapping("/add")
    @SaCheckPermission("srm:supplier:add")
    public Result<Long> add(@RequestBody SupplierAddCommand command) {
        // 业务逻辑
    }
    
    @PutMapping("/audit")
    @SaCheckPermission("srm:supplier:audit")
    public Result<Void> audit(@RequestBody SupplierAuditCommand command) {
        // 业务逻辑
    }
}

实施效果:

  1. 权限控制精确到按钮和接口级别
  2. 权限查询性能提升,避免每次请求都查数据库
  3. 角色权限集中管理,维护成本低
  4. 权限变更后主动删除缓存,及时生效

4.3 多租户隔离与数据权限设计

问题背景:

多租户隔离解决 A 公司不能看到 B 公司数据的问题。数据权限解决同一个公司内部,不同员工只能看自己负责范围的数据。

这两个不是一回事。租户隔离是底线,数据权限是租户内部的精细化控制。

技术方案:

MyBatis-Plus 多租户插件 + @DataScope 注解 + AOP 数据权限。

租户插件配置:

@Configuration
public class MybatisPlusConfig {
    
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 多租户插件
        TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor();
        tenantInterceptor.setTenantLineHandler(new TenantLineHandler() {
            @Override
            public Expression getTenantId() {
                Long tenantId = TenantContext.getTenantId();
                if (tenantId == null) {
                    throw new BusinessException("租户上下文为空");
                }
                return new LongValue(tenantId);
            }
            
            @Override
            public boolean ignoreTable(String tableName) {
                // 这些表不需要租户隔离
                return Set.of("sys_tenant", "sys_plan_feature", 
                             "finance_exchange_rate", "sys_dict")
                    .contains(tableName);
            }
        });
        
        interceptor.addInnerInterceptor(tenantInterceptor);
        return interceptor;
    }
}

租户上下文管理:

public class TenantContext {
    
    private static final ThreadLocal<Long> TENANT_ID = new ThreadLocal<>();
    
    public static void setTenantId(Long tenantId) {
        TENANT_ID.set(tenantId);
    }
    
    public static Long getTenantId() {
        return TENANT_ID.get();
    }
    
    public static void clear() {
        TENANT_ID.remove();
    }
}

数据权限注解:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataScope {
    DataScopeType type() default DataScopeType.ALL;
    String field() default "";
}

public enum DataScopeType {
    ALL,        // 全部数据
    CUSTOM,     // 自定义数据范围
    DEPT,       // 部门数据
    DEPT_AND_CHILD, // 部门及子部门数据
    SELF        // 仅本人数据
}

数据权限 AOP:

@Aspect
@Component
public class DataScopeAspect {
    
    @Around("@annotation(dataScope)")
    public Object around(ProceedingJoinPoint point, DataScope dataScope) throws Throwable {
        Long userId = UserContext.getUserId();
        SysUser user = userService.getById(userId);
        
        // 管理员看全部数据
        if (user.isAdmin()) {
            return point.proceed();
        }
        
        // 构建数据权限条件
        DataScopeCondition condition = buildCondition(user, dataScope);
        DataScopeContext.set(condition);
        
        try {
            return point.proceed();
        } finally {
            DataScopeContext.clear();
        }
    }
    
    private DataScopeCondition buildCondition(SysUser user, DataScope dataScope) {
        DataScopeCondition condition = new DataScopeCondition();
        
        switch (dataScope.type()) {
            case SELF:
                condition.addCondition("create_user_id", user.getId());
                break;
            case DEPT:
                condition.addCondition("dept_id", user.getDeptId());
                break;
            case CUSTOM:
                // 查询用户自定义数据范围(店铺、仓库等)
                List<Long> scopeIds = dataScopeService.getUserScopeIds(user.getId(), dataScope.field());
                condition.addInCondition(dataScope.field(), scopeIds);
                break;
        }
        
        return condition;
    }
}

供应商 Portal 强制隔离:

@RestController
@RequestMapping("/supplier/portal/purchase")
public class SupplierPortalPurchaseController {
    
    @GetMapping("/list")
    public Result<PageVO<PurchaseOrderVO>> list(PurchaseOrderQuery query) {
        // 供应商用户只能看自己的采购单
        Long supplierId = SupplierContext.getSupplierId();
        query.setSupplierId(supplierId);
        
        PageVO<PurchaseOrderVO> page = purchaseOrderService.page(query);
        return Result.success(page);
    }
}

实施效果:

  1. 租户数据完全隔离,A 租户看不到 B 租户数据
  2. 运营、仓库、采购、供应商等角色只能看授权范围
  3. 数据权限逻辑统一,减少业务代码重复
  4. 供应商 Portal 强制隔离,防止越权访问

4.4 SaaS 套餐限制与租户生命周期管理

问题背景:

SaaS 平台要支持基础版、专业版、企业版,不同套餐能用的功能和数量不同。如果没有套餐限制,商业化就无法落地。

技术方案:

设计套餐功能开关表和套餐限制 AOP,支持功能开关和用量限制。

套餐表设计:

-- 套餐配置表
CREATE TABLE sys_plan (
    id BIGINT PRIMARY KEY,
    plan_code VARCHAR(64) NOT NULL COMMENT '套餐编码',
    plan_name VARCHAR(128) NOT NULL COMMENT '套餐名称',
    plan_type TINYINT NOT NULL COMMENT '1基础版 2专业版 3企业版',
    price DECIMAL(10,2) COMMENT '价格',
    duration_days INT COMMENT '时长(天)',
    sort INT DEFAULT 0,
    status TINYINT NOT NULL DEFAULT 1,
    create_time DATETIME NOT NULL,
    UNIQUE KEY uk_code (plan_code)
);

-- 套餐功能表
CREATE TABLE sys_plan_feature (
    id BIGINT PRIMARY KEY,
    plan_type TINYINT NOT NULL,
    feature_code VARCHAR(64) NOT NULL COMMENT '功能编码',
    feature_name VARCHAR(128) NOT NULL COMMENT '功能名称',
    is_enabled TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用',
    limit_type TINYINT COMMENT '1无限制 2数量限制',
    limit_value INT COMMENT '限制值',
    create_time DATETIME NOT NULL,
    UNIQUE KEY uk_plan_feature (plan_type, feature_code)
);

-- 租户套餐订阅表
CREATE TABLE sys_tenant_subscription (
    id BIGINT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    plan_id BIGINT NOT NULL,
    start_date DATE NOT NULL,
    end_date DATE NOT NULL,
    status TINYINT NOT NULL COMMENT '1试用 2正式 3到期 4已取消',
    create_time DATETIME NOT NULL,
    KEY idx_tenant (tenant_id)
);

-- 租户用量统计表
CREATE TABLE sys_tenant_usage (
    id BIGINT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    feature_code VARCHAR(64) NOT NULL,
    used_count INT NOT NULL DEFAULT 0,
    stat_date DATE NOT NULL,
    create_time DATETIME NOT NULL,
    update_time DATETIME NOT NULL,
    UNIQUE KEY uk_tenant_feature_date (tenant_id, feature_code, stat_date)
);

套餐限制注解:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PlanLimit {
    String feature();
}

套餐限制 AOP:

@Aspect
@Component
public class PlanLimitAspect {
    
    @Autowired
    private TenantService tenantService;
    
    @Autowired
    private PlanFeatureService planFeatureService;
    
    @Autowired
    private TenantUsageService usageService;
    
    @Around("@annotation(planLimit)")
    public Object checkLimit(ProceedingJoinPoint point, PlanLimit planLimit) throws Throwable {
        Long tenantId = TenantContext.getTenantId();
        Tenant tenant = tenantService.getById(tenantId);
        
        // 检查套餐是否支持该功能
        PlanFeature feature = planFeatureService.getFeature(
            tenant.getPlanType(), planLimit.feature()
        );
        if (feature == null || !feature.isEnabled()) {
            throw new BusinessException("当前套餐不支持该功能,请升级套餐");
        }
        
        // 检查用量限制
        if (feature.hasLimit()) {
            int used = usageService.getUsedCount(tenantId, planLimit.feature());
            if (used >= feature.getLimitValue()) {
                throw new BusinessException("当前套餐用量已达上限,请升级套餐");
            }
        }
        
        return point.proceed();
    }
}

使用示例:

@RestController
@RequestMapping("/srm/supplier")
public class SupplierController {
    
    @PostMapping("/add")
    @SaCheckPermission("srm:supplier:add")
    @PlanLimit(feature = "supplier.max")
    public Result<Long> add(@RequestBody SupplierAddCommand command) {
        Long supplierId = supplierService.add(command);
        
        // 增加用量统计
        usageService.increment(TenantContext.getTenantId(), "supplier.max");
        
        return Result.success(supplierId);
    }
}

租户生命周期管理:

@Service
public class TenantLifecycleService {
    
    public void checkExpiredTenants() {
        LocalDate today = LocalDate.now();
        
        // 查询即将到期的租户(提前7天、3天、1天提醒)
        List<Tenant> expiringSoon = tenantMapper.selectExpiringSoon(today);
        for (Tenant tenant : expiringSoon) {
            sendRenewalReminder(tenant);
        }
        
        // 查询已到期的租户,转为只读状态
        List<Tenant> expired = tenantMapper.selectExpired(today);
        for (Tenant tenant : expired) {
            tenant.setStatus(TenantStatus.READONLY.getCode());
            tenantMapper.updateById(tenant);
            sendExpiredNotice(tenant);
        }
        
        // 查询超过保留期的租户,归档数据
        LocalDate retentionDeadline = today.minusDays(90);
        List<Tenant> overRetention = tenantMapper.selectOverRetention(retentionDeadline);
        for (Tenant tenant : overRetention) {
            archiveTenantData(tenant);
        }
    }
}

实施效果:

  1. 支持基础版、专业版、企业版差异化售卖
  2. 套餐限制在后端强制执行,前端无法绕过
  3. 租户试用、续费、到期流程闭环
  4. 到期租户自动转只读,保护平台权益

4.5 接口安全防护体系

问题背景:

后台接口面临 SQL 注入、XSS、恶意刷接口、重复提交和越权操作风险。需要构建多层防护体系。

技术方案:

限流、输入校验、幂等、脱敏、审计多层防护。

Redis 滑动窗口限流:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimiter {
    int count() default 10;
    int windowSeconds() default 60;
}
@Aspect
@Component
public class RateLimiterAspect {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private static final String RATE_LIMIT_LUA = """
        local key = KEYS[1]
        local windowStart = tonumber(ARGV[1])
        local now = tonumber(ARGV[2])
        local limit = tonumber(ARGV[3])
        
        redis.call('zremrangebyscore', key, 0, windowStart)
        local current = redis.call('zcard', key)
        
        if current < limit then
            redis.call('zadd', key, now, now)
            redis.call('expire', key, 60)
            return 1
        else
            return 0
        end
        """;
    
    @Around("@annotation(rateLimiter)")
    public Object limit(ProceedingJoinPoint point, RateLimiter rateLimiter) throws Throwable {
        Long userId = UserContext.getUserId();
        String path = getRequestPath();
        String key = "rate:" + userId + ":" + path;
        
        long now = System.currentTimeMillis();
        long windowStart = now - rateLimiter.windowSeconds() * 1000L;
        
        Long allowed = redisTemplate.execute(
            new DefaultRedisScript<>(RATE_LIMIT_LUA, Long.class),
            List.of(key),
            String.valueOf(windowStart),
            String.valueOf(now),
            String.valueOf(rateLimiter.count())
        );
        
        if (allowed == null || allowed == 0) {
            throw new BusinessException("请求过于频繁,请稍后再试");
        }
        
        return point.proceed();
    }
}

幂等防重复提交:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    int expireSeconds() default 5;
}
@Aspect
@Component
public class IdempotentAspect {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Around("@annotation(idempotent)")
    public Object checkIdempotent(ProceedingJoinPoint point, Idempotent idempotent) throws Throwable {
        // 从请求头或参数获取 requestId
        String requestId = getRequestId();
        if (StringUtils.isBlank(requestId)) {
            throw new BusinessException("缺少请求唯一标识");
        }
        
        String key = "idempotent:" + requestId;
        Boolean success = redisTemplate.opsForValue().setIfAbsent(
            key, "1", Duration.ofSeconds(idempotent.expireSeconds())
        );
        
        if (Boolean.FALSE.equals(success)) {
            throw new BusinessException("请勿重复提交");
        }
        
        try {
            return point.proceed();
        } catch (Exception e) {
            // 业务失败删除幂等标记,允许重试
            redisTemplate.delete(key);
            throw e;
        }
    }
}

审计日志:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuditLog {
    String module();
    String action();
}
@Aspect
@Component
public class AuditLogAspect {
    
    @Autowired
    private SysAuditLogMapper auditLogMapper;
    
    @Around("@annotation(auditLog)")
    public Object record(ProceedingJoinPoint point, AuditLog auditLog) throws Throwable {
        SysAuditLog log = new SysAuditLog();
        log.setTenantId(TenantContext.getTenantId());
        log.setUserId(UserContext.getUserId());
        log.setModule(auditLog.module());
        log.setAction(auditLog.action());
        log.setRequestParams(getRequestParams(point));
        log.setIp(getClientIp());
        log.setStartTime(LocalDateTime.now());
        
        try {
            Object result = point.proceed();
            log.setStatus(1);
            log.setResult("成功");
            return result;
        } catch (Exception e) {
            log.setStatus(0);
            log.setErrorMsg(e.getMessage());
            throw e;
        } finally {
            log.setEndTime(LocalDateTime.now());
            log.setCostTime(calculateCost(log.getStartTime(), log.getEndTime()));
            
            // 异步写入审计日志
            auditLogExecutor.execute(() -> auditLogMapper.insert(log));
        }
    }
}

实施效果:

  1. 登录、短信、导出等敏感接口被有效限流
  2. 重复提交不会产生重复业务数据
  3. 高风险操作可追溯,支持审计和合规
  4. SQL 注入、XSS 等常见攻击被防护

4.7 监控告警与生产可运维性

问题背景:

系统上线后需要及时发现故障,否则接口慢、订单失败、库存同步失败很难第一时间定位。

技术方案:

Spring Boot Actuator + Prometheus + Grafana + AlertManager。

监控指标体系:

技术指标:

  • JVM 内存、GC、线程池
  • 接口 P99 延迟、错误率
  • 数据库连接池、慢查询
  • Redis 内存、命中率
  • 磁盘使用率、网络流量

业务指标:

  • 订单处理失败率
  • 库存同步失败
  • 账单解析失败
  • 物流轨迹拉取失败
  • 登录失败率

Actuator 配置:

management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus
      base-path: /actuator
  endpoint:
    health:
      show-details: when-authorized
  metrics:
    export:
      prometheus:
        enabled: true

Prometheus 配置:

scrape_configs:
  - job_name: 'supply-chain-backend'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['backend:8080']

告警规则:

groups:
  - name: application
    rules:
      - alert: HighErrorRate
        expr: rate(http_server_requests_seconds_count{status=~"5.."}[5m]) > 0.05
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "接口错误率过高"
          description: "{{ $labels.instance }} 错误率 {{ $value }}"
      
      - alert: HighP99Latency
        expr: histogram_quantile(0.99, http_server_requests_seconds_bucket) > 2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "接口 P99 延迟过高"

实施效果:

  1. 技术故障和业务异常都能及时发现
  2. 线上问题定位效率更高
  3. 系统具备基础生产运维能力

数据库设计

5.1 核心表结构

审计日志表:

CREATE TABLE sys_audit_log (
    id BIGINT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    username VARCHAR(64),
    module VARCHAR(64) NOT NULL COMMENT '模块',
    action VARCHAR(128) NOT NULL COMMENT '操作',
    request_params TEXT COMMENT '请求参数',
    ip VARCHAR(64),
    status TINYINT NOT NULL COMMENT '1成功 0失败',
    result VARCHAR(256),
    error_msg TEXT,
    start_time DATETIME NOT NULL,
    end_time DATETIME,
    cost_time INT COMMENT '耗时(毫秒)',
    create_time DATETIME NOT NULL,
    
    KEY idx_tenant_user (tenant_id, user_id),
    KEY idx_module_action (module, action),
    KEY idx_create_time (create_time)
);

业务面试题

Q1: 什么是 RBAC?为什么不直接给用户分配权限?

A1: RBAC 是基于角色的访问控制,核心关系是用户绑定角色,角色绑定权限。

如果直接给用户分配权限,用户数量一多就很难维护。比如采购专员有 20 个权限,新来 10 个采购就要配置 200 次。RBAC 把权限收敛到角色上,新员工只要分配采购角色即可。

追问:权限标识怎么设计?

答:用三段式:模块、资源、动作,比如 srm:supplier:audit,表示 SRM 模块供应商审核权限。

Q2: 登录时为什么要传 tenantCode?

A2: 因为这是 SaaS 系统,同一个用户名可能存在于不同租户。

比如 A 公司和 B 公司都可以有 admin 账号。如果只按 username 查询,就可能查错租户用户。正确做法是先根据 tenantCode 定位租户,再按 tenantId + username 查询用户。

Q3: BCrypt 和 MD5 有什么区别?

A3: MD5 速度快,相同密码结果相同,容易被彩虹表和暴力破解攻击。

BCrypt 会自动加盐,并且可以设置计算成本。相同密码每次加密结果不同,但可以验证是否匹配。它比 MD5 更适合密码存储。

Q4: AccessToken 和 RefreshToken 为什么要分开?

A4: AccessToken 用于访问业务接口,生命周期短,降低泄露风险。

RefreshToken 用于刷新 AccessToken,生命周期长,但不能直接访问业务接口。这样既保证安全,也避免用户 Token 短期过期后频繁登录。

Q5: 权限缓存到 Redis 后,权限变更怎么生效?

A5: 角色权限变更、用户角色变更、用户禁用、套餐变更时,要删除相关用户的权限缓存。

下次请求时缓存未命中,系统重新从数据库加载权限。这样既提升正常访问性能,也能保证权限变更及时生效。

Q6: 多租户隔离怎么实现?

A6: 所有业务表都有 tenant_id 字段。用户登录后,从 Token 中取出 tenantId 放入 ThreadLocal。

MyBatis-Plus 多租户插件执行 SQL 前自动追加 tenant_id = 当前租户,保证不同租户数据隔离。

请求结束后必须清理 ThreadLocal,避免线程复用导致租户串用。

Q7: 数据权限和租户隔离有什么区别?

A7: 租户隔离解决公司之间不能互相看数据。

数据权限解决同一公司内部不同员工看不同范围的数据。比如美国站运营只能看美国店铺订单,广州仓管理员只能看广州仓库存。

租户隔离是底线,数据权限是更细粒度控制。

Q8: 供应商 Portal 为什么要强制 supplier_id 隔离?

A8: 供应商不是租户内部员工,它只能看自己的采购单、送货单、对账单。

如果只靠前端传 supplierId,供应商可以篡改参数查看其他供应商数据。所以后端必须根据当前登录账号绑定的 supplierId 强制追加过滤条件。

Q9: 套餐权限和功能权限有什么区别?

A9: 功能权限看的是用户有没有被授权使用某功能。

套餐权限看的是租户购买的版本是否包含这个功能。

比如财务专员有 BI 查看权限,但租户是基础版,没有开通高级 BI,那么仍然不能访问。

Q10: 套餐数量限制怎么实现?

A10:sys_plan_feature 配置每个套餐的功能和限制值,比如基础版最多 20 家供应商。

接口上加 @PlanLimit(feature = "supplier.max"),AOP 执行前查询当前租户套餐、限制值和当前使用量。如果达到上限,就返回升级套餐提示。


技术面试题

Q1: 如何防止 SQL 注入?

A1: 核心是不要拼接 SQL。

MyBatis 中使用 #{} 参数绑定,而不是 ${} 字符串替换。动态排序字段必须使用白名单映射,不能直接把前端传来的字段拼进 SQL。

Q2: 如何防止 XSS?

A2: 后端对用户输入做过滤或转义,前端展示文本时使用 v-text 或普通插值,不随便使用 v-html

富文本场景必须做白名单过滤,只允许安全标签和属性。RefreshToken 放 HttpOnly Cookie,也能降低 XSS 窃取风险。

Q3: 接口限流怎么实现?

A3: 可以用 Redis 滑动窗口。

Key 可以按用户 ID、IP、接口路径组合。每次请求记录当前时间戳,删除窗口外的旧时间戳,再统计窗口内请求数。超过阈值就返回 429。

登录、短信、导出、上传等接口要单独配置更严格的阈值。

Q4: 如何防重复提交?

A4: 前端生成 requestId,后端用 Redis SETNX 做幂等。

第一次请求设置成功,执行业务;重复请求设置失败,直接返回”请勿重复提交”。适合创建订单、付款申请、库存调整等接口。

Q5: 审计日志记录哪些内容?

A5: 审计日志主要记录高风险写操作。

字段包括租户、操作人、IP、模块、动作、请求参数、结果状态、错误信息、耗时、操作时间。密码、银行卡号、Token 等敏感字段必须脱敏或不记录。

Q6: Docker 和虚拟机有什么区别?

A6: 虚拟机有完整操作系统,隔离强,但资源占用大、启动慢。

Docker 容器共享宿主机内核,镜像轻、启动快、部署方便。对于 Java 微服务项目,Docker 更适合交付和弹性扩展。

Q7: docker-compose 里为什么要用 volume?

A7: 容器本身是可以删除重建的。如果 MySQL、Redis、RocketMQ 数据只存在容器内部,容器删除后数据就没了。

volume 用来持久化数据。即使容器重建,数据库文件、Redis 数据、消息存储和日志仍然保留。

Q8: CI/CD 流程怎么设计?

A8: 代码提交后触发 GitHub Actions。

先运行代码检查和测试,测试通过后构建 Docker 镜像,推送镜像仓库。测试环境可以自动部署,生产环境需要负责人审批。部署后做健康检查,失败则回滚或告警。

Q9: Nginx 在系统里起什么作用?

A9: Nginx 是接入层。

它负责 HTTPS、前端静态资源、API 反向代理、安全响应头、上传大小限制、访问日志。后端服务不直接暴露公网,外部请求先进 Nginx,再转发到网关。

Q10: 系统上线后监控哪些指标?

A10: 技术指标包括接口 P99、错误率、JVM 内存、GC、数据库连接池、Redis 内存、磁盘。

业务指标包括订单处理失败率、库存同步失败、账单解析失败、物流轨迹拉取失败。

技术指标正常不代表业务正常,所以业务指标也必须监控。


简历描述

8.1 项目描述

项目名称:跨境电商 SaaS 柔性供应链管理平台
负责模块:权限安全系统 + SaaS 租户运营 + 项目部署上线
项目时间:2024.06 - 2024.12
技术栈:Spring Boot 3.2、Sa-Token、MyBatis-Plus、MySQL 8.0、Redis 7.0、
       Docker、GitHub Actions、Nginx、Prometheus、Grafana

项目描述:
该项目面向跨境电商卖家,提供供应商、采购、仓储、商品、订单、物流、财务和 BI 的一体化管理能力。
我负责权限安全、SaaS 租户运营和部署上线模块,设计并实现登录认证、RBAC 权限、数据权限、
套餐限制、租户生命周期、接口安全防护、审计日志、Docker 容器化部署、CI/CD 自动化流水线和监控告警。

业务规模:
- 支持 500+ 企业租户
- 日均处理接口请求 100 万+
- 覆盖 8 大业务模块和多角色权限控制
- 支持基础版、专业版、企业版三种套餐

核心难点:
1. 多租户登录认证,同一用户名可能在不同租户下存在
2. RBAC 权限模型,权限控制精确到按钮和接口级别
3. 租户隔离和数据权限,保证数据安全和精细化控制
4. SaaS 套餐限制,支持功能开关和用量限制
5. 接口安全防护,防止 SQL 注入、XSS、恶意刷接口、重复提交
6. 容器化部署和 CI/CD,保证环境一致和自动化交付
7. 监控告警体系,及时发现技术故障和业务异常

8.2 核心亮点

亮点1:Sa-Token 登录认证 + 双 Token 机制

问题:SaaS 后台涉及大量敏感数据,需要安全可靠的登录认证和 Token 续期机制。

方案:使用 Sa-Token + Redis 管理分布式登录态,配合 BCrypt、账号锁定、随机 AccessToken + RefreshToken。

具体实现:

  • 登录时按 tenantCode + username 查询用户
  • BCrypt 校验密码,连续失败锁定账号
  • 生成 AccessToken 访问接口,生成 RefreshToken 做无感续签
  • 登录成功后缓存用户权限到 Redis

效果:认证逻辑清晰,支持多租户登录,降低密码泄露和暴力破解风险,用户短期 Token 过期后可无感续签。

亮点2:RBAC 权限模型 + Redis 权限缓存

问题:不同岗位权限不同,直接给用户分配权限难以维护。

方案:设计用户、角色、权限三层 RBAC 模型,权限标识采用模块、资源、动作三段式。

具体实现:

  • 用户绑定角色,角色绑定菜单和按钮权限
  • Controller 使用 @SaCheckPermission 做接口鉴权
  • 用户权限列表缓存到 Redis,TTL 2小时
  • 角色权限变更时主动删除权限缓存

效果:权限控制精确到按钮和接口级别,权限查询性能提升,角色权限集中管理,维护成本低。

亮点3:多租户隔离 + 数据权限自动过滤

问题:SaaS 系统要保证租户之间数据隔离,租户内部还要按店铺、仓库、本人数据过滤。

方案:MyBatis-Plus 多租户插件 + @DataScope 注解 + AOP 数据权限。

具体实现:

  • 所有业务表带 tenant_id,登录后将 tenantId 放入 ThreadLocal
  • MyBatis-Plus 自动追加 tenant_id = 当前租户
  • @DataScope 控制本人、部门、自定义数据范围
  • 供应商 Portal 强制追加 supplier_id

效果:租户数据完全隔离,运营、仓库、采购、供应商等角色只能看授权范围,数据权限逻辑统一,减少业务代码重复。

亮点4:SaaS 套餐限制与租户生命周期

问题:SaaS 产品需要按套餐控制功能和用量,支持试用、续费、到期和数据保留。

方案:设计套餐功能开关表和套餐限制 AOP。

具体实现:

  • sys_plan_feature 配置套餐功能和限制值
  • @PlanLimit 注解拦截新增供应商、仓库、平台等操作
  • 租户注册后初始化管理员、角色、菜单、字典和默认配置
  • 到期后切换只读,拦截写操作,数据保留期后归档或清理

效果:支持基础版、专业版、企业版差异化售卖,套餐限制在后端强制执行,租户试用、续费、到期流程闭环。

亮点5:接口安全防护体系

问题:后台接口面临 SQL 注入、XSS、恶意刷接口、重复提交和越权操作风险。

方案:构建限流、输入校验、幂等、脱敏、审计多层防护。

具体实现:

  • MyBatis 参数绑定防 SQL 注入,XSS Filter 过滤危险脚本
  • Redis 滑动窗口实现接口限流
  • Redis SETNX 实现幂等防重复提交
  • 敏感字段返回脱敏、存储加密
  • AOP 记录高风险操作审计日志

效果:登录、短信、导出等敏感接口被有效限流,重复提交不会产生重复业务数据,高风险操作可追溯。

亮点6:Docker + CI/CD + Nginx 生产部署体系

问题:手动部署容易环境不一致、漏执行测试、配置出错。

方案:Docker 容器化 + GitHub Actions 自动化流水线 + Nginx HTTPS 接入。

具体实现:

  • 后端 Dockerfile 使用多阶段构建,减少镜像体积
  • docker-compose 编排 MySQL、Redis、RocketMQ、Nacos、业务服务、Nginx
  • GitHub Actions 自动测试、构建镜像、推送镜像、部署测试环境
  • 生产环境使用审批保护,Nginx 配置 HTTPS、反向代理、安全响应头

效果:部署流程标准化,环境一致性更好,发布过程可追溯、可回滚,HTTPS 保证传输安全。

亮点7:监控告警与生产可运维性

问题:系统上线后需要及时发现故障,否则接口慢、订单失败、库存同步失败很难第一时间定位。

方案:Spring Boot Actuator + Prometheus + Grafana + AlertManager。

具体实现:

  • 采集 JVM、接口、数据库、Redis、磁盘等技术指标
  • 采集订单失败率、库存同步失败、账单解析失败等业务指标
  • 设置分级告警阈值,告警通过邮件、企业微信或钉钉通知
  • Actuator 端点加白名单或独立管理端口

效果:技术故障和业务异常都能及时发现,线上问题定位效率更高,系统具备基础生产运维能力。


关键代码示例

由于前面章节已经展示了大量核心代码,这里补充几个关键场景的完整实现。

9.1 租户注册与初始化

@Service
public class TenantRegisterService {
    
    @Autowired
    private SysTenantMapper tenantMapper;
    
    @Autowired
    private SysUserMapper userMapper;
    
    @Autowired
    private SysRoleMapper roleMapper;
    
    @Autowired
    private TenantInitService tenantInitService;
    
    @Transactional(rollbackFor = Exception.class)
    public Long register(TenantRegisterCommand command) {
        // 1. 创建租户
        SysTenant tenant = new SysTenant();
        tenant.setTenantCode(generateTenantCode());
        tenant.setTenantName(command.getCompanyName());
        tenant.setContactName(command.getContactName());
        tenant.setContactMobile(command.getMobile());
        tenant.setContactEmail(command.getEmail());
        tenant.setPlanType(PlanType.BASIC.getCode());
        tenant.setStatus(TenantStatus.TRIAL.getCode());
        tenant.setTrialEndDate(LocalDate.now().plusDays(30));
        tenantMapper.insert(tenant);
        
        // 2. 创建管理员账号
        SysUser admin = new SysUser();
        admin.setTenantId(tenant.getId());
        admin.setUsername("admin");
        admin.setPassword(BCrypt.hashpw(generateRandomPassword(), BCrypt.gensalt()));
        admin.setRealName(command.getContactName());
        admin.setMobile(command.getMobile());
        admin.setEmail(command.getEmail());
        admin.setStatus(1);
        userMapper.insert(admin);
        
        // 3. 初始化基础数据
        tenantInitService.initRoles(tenant.getId());
        tenantInitService.initMenus(tenant.getId());
        tenantInitService.initDicts(tenant.getId());
        tenantInitService.initConfigs(tenant.getId());
        tenantInitService.initPlanFeatures(tenant.getId(), tenant.getPlanType());
        
        // 4. 分配管理员角色
        Long adminRoleId = roleMapper.selectAdminRoleId(tenant.getId());
        userRoleMapper.insert(admin.getId(), adminRoleId);
        
        // 5. 发送欢迎邮件
        emailService.sendWelcomeEmail(admin.getEmail(), tenant.getTenantCode(), admin.getPassword());
        
        return tenant.getId();
    }
}

9.2 Token 刷新机制

@Service
public class TokenRefreshService {
    
    @Autowired
    private RefreshTokenMapper refreshTokenMapper;
    
    public TokenRefreshVO refresh(String refreshToken) {
        // 1. 验证 RefreshToken
        RefreshToken token = refreshTokenMapper.selectByToken(refreshToken);
        if (token == null || token.isExpired()) {
            throw new BusinessException("RefreshToken 无效或已过期");
        }
        
        // 2. 检查用户状态
        SysUser user = userMapper.selectById(token.getUserId());
        if (user == null || !user.isEnabled()) {
            throw new BusinessException("用户不存在或已禁用");
        }
        
        // 3. 签发新的 AccessToken
        StpUtil.login(user.getId());
        String newAccessToken = StpUtil.getTokenValue();
        
        // 4. 更新 RefreshToken 使用时间
        token.setLastUsedTime(LocalDateTime.now());
        refreshTokenMapper.updateById(token);
        
        return TokenRefreshVO.of(newAccessToken, refreshToken);
    }
}