# 后端手册

# 基础功能

# 数据权限 DataScope

@DataScope(deptAlias = "d")
@DataScope(deptAlias = "d", userAlias = "u")

基于 AOP 实现

核心思想:拿到当前用户角色的数据权限,拼接 sql 子串,放入注解所在方法的第一个参数对应的 BaseEntity 的 params 上,最后在 MyBatis 的 mapper.xml 中去使用

<select id="select" parameterType="..." resultMap="...Result">
    <include refid="select...Vo"/>
    <!-- 数据范围过滤 -->
    ${params.dataScope}
</select>
@Before("@annotation(controllerDataScope)")
public void doBefore(JoinPoint point, DataScope controllerDataScope) {
   LoginUser loginUser = SecurityUtils.getLoginUser();
   SysUser user = loginUser.getUser();
   String deptAlias = controllerDataScope.deptAlias();
   String userAlias = controllerDataScope.userAlias();
   StringBuilder sqlString = new StringBuilder();

   for (SysRole role : user.getRoles()) {
       String dataScope = role.getDataScope();
       if (DATA_SCOPE_ALL.equals(dataScope)) {
           sqlString = new StringBuilder();
           break;
       } else if (DATA_SCOPE_CUSTOM.equals(dataScope)) {
           sqlString.append(StringUtils.format(
               " OR {}.dept_id IN ( SELECT dept_id FROM sys_role_dept WHERE role_id = {} ) ", deptAlias,
               role.getRoleId()));
       } else if (DATA_SCOPE_DEPT.equals(dataScope)) {
           sqlString.append(StringUtils.format(" OR {}.dept_id = {} ", deptAlias, user.getDeptId()));
       } else if (DATA_SCOPE_DEPT_AND_CHILD.equals(dataScope)) {
           sqlString.append(StringUtils.format(
               " OR {}.dept_id IN ( SELECT dept_id FROM sys_dept WHERE dept_id = {} or ancestors like {} OR ancestors like {}} OR ancestors like {} )",
               deptAlias, user.getDeptId(), "," + user.getDeptId() + ",", "," + user.getDeptId(), user.getDeptId() + ","));
       } else if (DATA_SCOPE_SELF.equals(dataScope)) {
           if (StringUtils.isNotBlank(userAlias)) {
               sqlString.append(StringUtils.format(" OR {}.user_id = {} ", userAlias, user.getUserId()));
           } else {
               // 数据权限为仅本人且没有userAlias别名不查询任何数据
               sqlString.append(" OR 1=0 ");
           }
       }
   }

   if (StringUtils.isNotBlank(sqlString.toString())) {
       Object params = joinPoint.getArgs()[0];
       if (StringUtils.isNotNull(params) && params instanceof BaseEntity) {
           BaseEntity baseEntity = (BaseEntity) params;
           baseEntity.getParams().put(DATA_SCOPE, " AND (" + sqlString.substring(4) + ")");
       }
   }
}

# 多数据源 Master Slave

@Master
@Slave

重定义注解,简化对@DS("master")和@DS("slave")的使用

基于dynamic-datasource-spring-boot-starter (opens new window)来支持多数据源

# 单体版

支持 mysql、sqlite 两个数据库,采用手动注入多数据源并动态添加数据源的方式来实现。

spring:
  datasource:
    dynamic:
      primary: master
      strict: false
@Slf4j
@Configuration
public class DataSourceConfig {
    @Primary
    @Bean
    public DataSource dataSource(DynamicDataSourceProperties properties) {
        DynamicRoutingDataSource dataSource = new DynamicRoutingDataSource();
        dataSource.setPrimary(properties.getPrimary());
        dataSource.setStrict(properties.getStrict());
        dataSource.setStrategy(properties.getStrategy());
        dataSource.setP6spy(properties.getP6spy());
        dataSource.setSeata(properties.getSeata());

        // 动态添加数据源
        switch (dbType) {
            case "mysql":
                addMySQLDataSource(dataSource);
                break;
            default:
                addSQLiteDataSource(dataSource);
        }

        try {
            org.flywaydb.core.api.configuration.Configuration configuration = Flyway.configure().dataSource(dataSource).baselineDescription("initByServer").baselineOnMigrate(true).validateOnMigrate(false).locations(String.format("classpath:data/migration/%s", dbType));
            Flyway flyway = new Flyway(configuration);
            flyway.migrate();
        } catch (Exception e) {
            log.error("数据库迁移出现异常", e);
        }

        return dataSource;
    }
}

# 微服务版

支持主从数据库的配置,默认只配置了 master 数据源为 mysql。

spring:
  datasource:
    dynamic:
      datasource:
        # 主库数据源
        master:
          driver-class-name: com.mysql.cj.jdbc.Driver
          url: jdbc:mysql://mapgis-xxx-mysql:3306/mapgis-cloud-xxx?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
          username: root
          password: cloud123.mapgis
        # 从库数据源
        # slave:
        # username:
        # password:
        # url:
        # driver-class-name:

# 限流 RateLimiter

@RateLimiter(count = 100, time = 60)

核心思想:通过 Redis 实现,在 RedisConfig 中自定义限流脚本,统计以方法调用为 key 的缓存有效期内的执行次数,是否大于限制,大于则抛出异常

因在单体版本中未使用 Redis(采用 Ehcache),所以该注解要使用必须引入对 Redis 的依赖,在微服务版中可直接使用。

@Before("@annotation(rateLimiter)")
public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable {
  String key = rateLimiter.key();
  int time = rateLimiter.time();
  int count = rateLimiter.count();

  String combineKey = getCombineKey(rateLimiter, point);
  List<Object> keys = Collections.singletonList(combineKey);
  try {
      Long number = redisTemplate.execute(limitScript, keys, count, time);
      if (StringUtils.isNull(number) || number.intValue() > count) {
          throw new ServiceException("访问过于频繁,请稍候再试");
      }
      log.info("限制请求'{}',当前请求'{}',缓存key'{}'", count, number.intValue(), key);
  } catch (ServiceException e) {
      throw e;
  } catch (Exception e) {
      throw new RuntimeException("服务器限流异常,请稍候再试");
  }
}

# 防重复提交 RepeatSubmit

@RepeatSubmit

核心思路:基于过滤器和拦截器实现,在过滤器的 doFilter 中对用户的请求进行预处理,把请求体为"application/json"开头的转换成可重复读取 inputStream 的 request:RepeatedlyRequestWrapper,在拦截器的 preHandle 中去验证是否有重复提交,存在则返回 false 结束请求

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
  throws IOException, ServletException {
  ServletRequest requestWrapper = null;
  if (request instanceof HttpServletRequest
      && StringUtils.startsWithIgnoreCase(request.getContentType(), MediaType.APPLICATION_JSON_VALUE)) {
      requestWrapper = new RepeatedlyRequestWrapper((HttpServletRequest) request, response);
  }
  if (null == requestWrapper) {
      chain.doFilter(request, response);
  } else {
      chain.doFilter(requestWrapper, response);
  }
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  if (handler instanceof HandlerMethod) {
      HandlerMethod handlerMethod = (HandlerMethod) handler;
      Method method = handlerMethod.getMethod();
      RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
      if (annotation != null) {
          if (this.isRepeatSubmit(request, annotation)) {
              AjaxResult ajaxResult = AjaxResult.error(annotation.message());
              ServletUtils.renderString(response, JSONObject.toJSONString(ajaxResult));
              return false;
          }
      }
      return true;
  } else {
      return true;
  }
}

# 日志 Log

@Log(title = "通知公告", businessType = BusinessType.INSERT)

基于 AOP 实现

核心思想:在后置返回通知和异常通知中,获取当前用户信息(用户名、操作模块、业务类型、操作类别)和客户端信息(请求方法、请求方式、请求地址、操作地点、返回参数、状态、异常信息)等,以异步的方式在给定一定延迟后(10ms)写入数据库中(单体版)或调用系统的日志服务写入数据库中

/**
 * 处理完请求后执行
 *
 * @param joinPoint 切点
 */
@AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult) {
  handleLog(joinPoint, controllerLog, null, jsonResult);
}

/**
 * 拦截异常操作
 *
 * @param joinPoint 切点
 * @param e         异常
 */
@AfterThrowing(value = "@annotation(controllerLog)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e) {
  handleLog(joinPoint, controllerLog, e, null);
}

protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult) {
  try {

      // 获取当前的用户
      LoginUser loginUser = SecurityUtils.getLoginUser();

      // *========数据库日志=========*//
      SysOperLog operLog = new SysOperLog();
      operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
      // 请求的地址
      String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
      operLog.setOperIp(ip);
      operLog.setOperUrl(ServletUtils.getRequest().getRequestURI());
      if (loginUser != null) {
          operLog.setOperName(loginUser.getUsername());
      }

      if (e != null) {
          operLog.setStatus(BusinessStatus.FAIL.ordinal());
          operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000));
      }
      // 设置方法名称
      String className = joinPoint.getTarget().getClass().getName();
      String methodName = joinPoint.getSignature().getName();
      operLog.setMethod(className + "." + methodName + "()");
      // 设置请求方式
      operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
      // 处理设置注解上的参数
      getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult);
      // 保存数据库(单体版)
      AsyncManager.me().execute(AsyncFactory.recordOper(operLog));

      // 保存数据库(微服务版)
      // asyncLogService.saveSysLog(operLog);
  } catch (Exception exp) {
      // 记录本地异常日志
      log.error("==前置通知异常==");
      log.error("异常信息:{}", exp.getMessage());
      exp.printStackTrace();
  }
}

# Excel 导出 Excel、Excels

@Excel(name = "序号", cellType = ColumnType.NUMERIC)
@Excel(name = "角色状态", readConverterExp = "0=正常,1=停用")
@Excel(name = "部门编号", type = Type.IMPORT)
@Excel(name = "操作时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")

@Excels({
 @Excel(name = "部门名称", targetAttr = "deptName", type = Type.EXPORT),
 @Excel(name = "部门负责人", targetAttr = "leader", type = Type.EXPORT)
})

核心思想:基于 poi-ooxml 库,查看类的每个字段上是否有对应的注解,再解析注解,把字段的内容写到文档中

# 权限控制 RequiresPermissions、RequiresRoles、RequiresLogin

@RequiresPermissions("system:logininfor:list")

RequiresPermissions、RequiresRoles、RequiresLogin 这 3 个注解专用于微服务版。

因微服务版的权限控制是自定义的,单体版是基于 Spring Security 的,所以要保证一套代码支持单体和微服务切换,现阶段是通过在需要添加权限控制的方法上同时添加有@PreAuthorize 和@RequiresPermissions。

@ApiOperation("查询系统访问记录列表")
@PreAuthorize("@ss.hasPermi('system:logininfor:list')")
@RequiresPermissions("system:logininfor:list")
@GetMapping("/list")
public TableDataInfo list(SysLogininfor logininfor) {
    startPage();
    List<SysLogininfor> list = logininforService.selectLogininforList(logininfor);
    return getDataTable(list);
}

# 全局控制器前缀 ManagerRestController、ServicesRestController

@ServicesRestController("/auth")
@ManagerRestController("/system/user/profile")

用于快速给所有的控制器添加相应的前缀。

# API前缀
api:
  path:
    services-prefix: xxx/rest/services
    manager-prefix: xxx/rest/manager
@Configuration
public class ControllerPrefixWebConfiguration implements WebMvcConfigurer {
    @Resource
    private ApiPathProperties apiPathProperties;

    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        configurer
                .addPathPrefix(apiPathProperties.getServicesPrefix(), c -> c.isAnnotationPresent(ServicesRestController.class))
                .addPathPrefix(apiPathProperties.getManagerPrefix(), c -> c.isAnnotationPresent(ManagerRestController.class));
    }
}

# 防止 XSS 攻击

# 定义 XSS 注解来对需要的基本信息进行校验

@Xss(message = "用户账号不能包含脚本字符")

# 更多信息通过过滤器来处理

# 单体版
public class XssFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;
        if (handleExcludeURL(req, resp)) {
            chain.doFilter(request, response);
            return;
        }
        XssHttpServletRequestWrapper xssRequest = new XssHttpServletRequestWrapper((HttpServletRequest) request);
        chain.doFilter(xssRequest, response);
    }
}
# 微服务版
@Component
@ConditionalOnProperty(value = "security.xss.enabled", havingValue = "true")
public class XssFilter implements GlobalFilter, Ordered {
@Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        // GET DELETE 不过滤
        HttpMethod method = request.getMethod();
        if (method == null || method.matches("GET") || method.matches("DELETE")) {
            return chain.filter(exchange);
        }
        // 非json类型,不过滤
        if (!isJsonRequest(exchange)) {
            return chain.filter(exchange);
        }
        // excludeUrls 不过滤
        String url = request.getURI().getPath();
        if (StringUtils.matches(url, xss.getExcludeUrls())) {
            return chain.filter(exchange);
        }
        ServerHttpRequestDecorator httpRequestDecorator = requestDecorator(exchange);
        return chain.filter(exchange.mutate().request(httpRequestDecorator).build());

    }
}

# AOP 支持自调用

核心思路:采用 AOP 底层原理 JDK 动态代理方式,被代理对象可以通过 AopContext.currentProxy()获取到代理对象,通过调用代理对象的方法进而访问被代理对象的方法(不能直接通过 this 调用,这将调用到被代理对象的该方法,可能失去一些代理对象增强的逻辑)

# 单体版

// 表示通过aop框架暴露该代理对象,AopContext能够访问
@EnableAspectJAutoProxy(exposeProxy = true)
public class ApplicationConfig {
}

# 微服务版

// 表示通过aop框架暴露该代理对象,AopContext能够访问
@EnableAspectJAutoProxy(exposeProxy = true)
public @interface EnableMapConfig {
}

# 配置时区

核心思路:解决 Jackson 日期反序列化时间问题,配置一个 bean 实现整体修改

public class ApplicationConfig {
    /**
     * 时区配置
     */
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jacksonObjectMapperCustomization() {
        return jacksonObjectMapperBuilder -> jacksonObjectMapperBuilder.timeZone(TimeZone.getDefault());
    }
}

# 只读模式

# 安全配置
security:
  access:
    # 只读开关
    readonlyEnabled: ${SECURITY_ACCESS_READONLYENABLED:false}

基于@ModelAttribute 在控制器每个方法执行前被执行,检查当前的请求方法只要是增删改,就抛出 ReadonlyModeException,在 GlobalExceptionHandler 中处理

public class BaseController {
    @ModelAttribute
    public void init(HttpServletRequest httpServletRequest, HttpServletResponse response) throws IOException     {
        String url = ServletUtils.getRequest().getRequestURI();

        if (!readonlyEnabled) {
            return;
        }

        // 增删改 请求
        if ("DELETE".equals(httpServletRequest.getMethod()) || "POST".equals(httpServletRequest.getMethod())
                || "PUT".equals(httpServletRequest.getMethod())) {
            throw new ReadonlyModeException();
        }
    }
}

@RestControllerAdvice
public class GlobalExceptionHandler {
    /**
     * 只读模式异常
     */
    @ExceptionHandler(ReadonlyModeException.class)
    public AjaxResult handleDemoModeException(ReadonlyModeException e) {
        return AjaxResult.error("只读模式,不允许操作");
    }
}

# 单体和微服务自由切换

支持通过切换 profile 为 local 或 cloud 来实现单体和微服务的自由切换,可满足一个项目在不同环境下部署和应用的需求。

为了实现这套机制,项目中很多模块都包含 base、local、cloud 三个子模块,通过 maven 的 profile 来定义不同配置下具体的模块。

<profiles>
    <!-- 单体模式 -->
    <profile>
        <id>local</id>
        <activation>
            <!--默认激活配置-->
            <activeByDefault>true</activeByDefault>
        </activation>
        <properties>
            <mapgis.common.security.artifact>mapgis-common-local-security</mapgis.common.security.artifact>
        </properties>
    </profile>
    <!-- 微服务模式 -->
    <profile>
        <id>cloud</id>
        <activation>
            <!--默认激活配置-->
            <activeByDefault>false</activeByDefault>
        </activation>
        <properties>
            <mapgis.common.security.artifact>mapgis-common-cloud-security</mapgis.common.security.artifact>
        </properties>
    </profile>
</profiles>

对于一个微服务中的接口需要被其他微服务调用时,会将这些接口抽出来定义到-api的模块中,其中-cloud-api用于微服务间调用,-local-api用于模块间调用。这些接口都会在-biz中进行实现,而且-cloud-api的实现会调用-local-api的实现。

下面以 system 模块进行举例说明:

mapgis-module-system-local-api定义了接口ISysServiceApi

public interface ISysServiceApi {
}

mapgis-module-system-cloud-api也定义了接口ISysServiceApi

@FeignClient(contextId = "remoteSysServiceApi", value = ServiceNameConstants.SYSTEM_SERVICE, fallbackFactory = RemoteSysServiceApiFallbackFactory.class)
public interface ISysServiceApi {
}

mapgis-module-system-biz有以上 2 个接口的实现,分别是:

@Service("SysServiceApiImpl")
public class SysServiceApiImpl implements ISysServiceApi  {
}
@ManagerRestController("/system/api")
public class SysServiceApiController extends BaseController {

    @Autowired
    @Qualifier("SysServiceApiImpl")
    private ISysServiceApi sysServiceApi;
}

这样在任何其他模块中不管是否跨微服务,只要调用ISysServiceApi的接口,在微服务和单体模式下都能调用成功。

# 多数据库支持

MyBatis 可以根据不同的数据库厂商执行不同的语句,可参考Mybatis 多数据库支持 (opens new window)

# 配置

@Configuration
public class MyBatisConfig {
	@Bean
    public DatabaseIdProvider getDatabaseIdProvider() {
        DatabaseIdProvider databaseIdProvider = new VendorDatabaseIdProvider();
        Properties properties = new Properties();
        properties.setProperty("MySQL", "mysql");
        properties.setProperty("SQLite", "sqlite");
        databaseIdProvider.setProperties(properties);
        return databaseIdProvider;
    }
}

获取数据库品牌标识的示例代码:

@SpringBootTest
public class DataBaseProduct {
    @Test
    void getProductName() throws SQLException, ClassNotFoundException {
        String driver = "org.sqlite.JDBC";
        String url = "jdbc:sqlite::resource:static/dq.db?date_string_format=yyyy-MM-dd HH:mm:ss";
        String username = "";
        String password = "";
        Class.forName(driver);
        Connection con = (Connection) DriverManager.getConnection(url, username, password);
        DatabaseMetaData metaData = (DatabaseMetaData) con.getMetaData();
        System.out.println("数据库的产品名称:" + metaData.getDatabaseProductName());
    }
}

常见的品牌标识如下:

<property name="SQL Server" value="sqlserver"/>
<property name="DB2" value="db2"/>
<property name="Oracle" value="oracle" />
<property name="MySQL" value="mysql" />
<property name="SQLite" value="sqlite" />
<property name="PostgreSQL" value="postgre" />

# 属性 databaseId

Mybatis 支持给每个 statement 添加属性databaseId,可根据 databaseId 的取值来写不同的语句,未指明属性的为通用语句。

<update id="cleanJobLog">
    truncate table sys_job_log
</update>

<update id="cleanJobLog" databaseId="sqlite">
    delete
    from sys_job_log;
    update sqlite_sequence
    SET seq = 0
    where name = 'sys_job_log';
</update>

# 内置参数_databaseId

表示当前数据库的别名,可参考Mybatis 动态 sql 之内置参数_parameter 和_databaseId (opens new window)

<update id="updateJob" parameterType="SysJob">
    update sys_job
    <set>
        <if test="jobName != null and jobName != ''">job_name = #{jobName},</if>
        <if test="jobGroup != null and jobGroup != ''">job_group = #{jobGroup},</if>
        <if test="invokeTarget != null and invokeTarget != ''">invoke_target = #{invokeTarget},</if>
        <if test="cronExpression != null and cronExpression != ''">cron_expression = #{cronExpression},</if>
        <if test="misfirePolicy != null and misfirePolicy != ''">misfire_policy = #{misfirePolicy},</if>
        <if test="concurrent != null and concurrent != ''">concurrent = #{concurrent},</if>
        <if test="status !=null">status = #{status},</if>
        <if test="remark != null and remark != ''">remark = #{remark},</if>
        <if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
        <choose>
            <when test="_databaseId == 'sqlite'">
                update_time = (datetime(CURRENT_TIMESTAMP,'localtime'))
            </when>
            <otherwise>
                update_time = sysdate()
            </otherwise>
        </choose>
    </set>
    where job_id = #{jobId}
</update>

# flyway 数据库版本管理

单体版采用 flyway 来管理数据库版本,支持在部署的时候执行相应的数据库脚本,这里采用手动编写代码的方式进行,可参考SpringBoot 中使用 Flyway (opens new window)

# 配置(关闭自动配置)

spring:
  flyway:
    # flyway自动配置 true 开启
    enabled: true

# 通过 java 代码进行迁移

因需要支持 mysql 和 sqlite,所以在data/migration目录下分别有 2 个数据库的迁移脚本。

@Slf4j
@Configuration
public class DataSourceConfig {
    @Value("${DB_TYPE:sqlite}")
    private String dbType;

	@Primary
    @Bean
    public DataSource dataSource(DynamicDataSourceProperties properties) {
        // ...
    try {
            org.flywaydb.core.api.configuration.Configuration configuration = Flyway.configure().dataSource(dataSource).baselineDescription("initByServer").baselineOnMigrate(true).validateOnMigrate(false).locations(String.format("classpath:data/migration/%s", dbType));
            Flyway flyway = new Flyway(configuration);
            flyway.migrate();
        } catch (Exception e) {
            log.error("数据库迁移出现异常", e);
        }
    }
}

# 缓存管理

项目中存在用户信息、字典数据等需要缓存的内容,为使单体版更加轻量化,采用了 ehcache 来实现,微服务版采用的是 redis(需要单独启动 redis 服务)。

# ehcache 与 redis 在实现上存在差异

获取对象列表时,redis 原生支持模糊匹配,ehcache 不支持,现有实现只支持完全匹配和带有*后缀的模糊匹配

@Component
public class EhcacheCacheServiceImpl implements CacheService {
	/**
     * 获得缓存的基本对象列表
     *
     * @param pattern 字符串前缀
     * @return 对象列表
     */
    @Override
    public Collection<String> keys(final String pattern) {
        Collection<String> keys = cache.getKeys();
        final String query = pattern.endsWith("*") ? pattern.substring(0, pattern.length() - 2) : pattern;

        return keys.stream().
                filter(k -> k.indexOf(query) == 0).
                collect(Collectors.toList());
    }
}
@Component
public class RedisCacheServiceImpl implements CacheService {
    /**
     * 获得缓存的基本对象列表
     *
     * @param pattern 字符串前缀
     * @return 对象列表
     */
    @Override
    public Collection<String> keys(final String pattern) {
        return redisTemplate.keys(pattern);
    }
}

# 验证码开启和关闭

目前后端支持开启和关闭,单体版在mapgis-server模块的配置文件application.yml中:

# 安全配置
security:
  # 验证码
  captcha:
    enabled: true
    # 验证码类型 math 数组计算 char 字符验证
    type: math

微服务版在网关模块的配置文件mapgis-xxx-gateway-server-xxx.yml中(位于 nacos):

# 安全配置
security:
  # 验证码
  captcha:
    enabled: true
    # 验证码类型 math 数组计算 char 字符验证
    type: math

前端目前验证码是否开启是固定的,因为后端没有提供获取是否验证码开启和关闭的接口。

# 多终端登录开启和关闭

系统采用token标识用户身份,默认用户允许在多终端同时登录,提供配置token.soloLogin用来限制多终端同时登录

核心思路:将userid和token(一个用户对应一个token,userid唯一)关联起来存储在缓存中,登录时判断用户是否在别的终端在线,存在就清除缓存信息

# 配置

# token配置
token:
  # 是否允许账户多终端同时登录(true允许 false不允许)
  soloLogin: ${SOLO_LOGIN_ENABLED:true}
@Component
public class TokenService {
    /**
     * 根据用户Id踢出登录用户,用于不允许多终端登录时,清除用户Id关联的用户信息
     */
    public void kickoutLoginUser(Long userId) {
        if (!soloLogin) {
            // 如果用户不允许多终端同时登录,清除缓存信息
            String userIdKey = getUserIdKey(userId);
            String userKey = cacheService.getCacheObject(userIdKey);
            if (StringUtils.isNotEmpty(userKey)) {
                cacheService.deleteObject(userIdKey);
                cacheService.deleteObject(userKey);
            }
        }
    }
}

# 配置密码最大错误次数/锁定时间

通过控制用户登录最大错误次数来锁定用户,可以在需要的时候防止暴力破解

核心思路:将密码错误登录次数记录到日志和缓存中,每次验证登录信息时判断错误次数是否达到最大次数,达到即提示已经用户已锁定,要解除锁定时,从缓存中清除即可。

# 配置

# 安全配置
security:
  # 用户配置
  user:
    # 密码
    password:
      # 是否开启锁定
      lock-enabled: true
      # 最大错误次数
      max-retry-count: 5
      # 锁定时间(默认10分钟)
      lock-time: 10

下面用于登录时验证

@Component
public class SysPasswordService {
    @Autowired
    private CacheService cacheService;

    @Value(value = "${security.user.password.lock-enabled:false}")
    private boolean lockEnabled;

    @Value(value = "${security.user.password.max-retry-count:5}")
    private int maxRetryCount;

    @Value(value = "${security.user.password.lock-time:10}")
    private int lockTime;

    /**
     * 登录账号密码错误次数缓存键名
     *
     * @param username 用户名
     * @return 缓存键key
     */
    private String getCacheKey(String username) {
        return CacheConstants.PWD_ERR_CNT_KEY + username;
    }

    public void validate(SysUser user) {
        if (!lockEnabled) {
            return;
        }

        Authentication usernamePasswordAuthenticationToken = AuthenticationContextHolder.getContext();

        if (usernamePasswordAuthenticationToken == null) {
            return;
        }

        String username = usernamePasswordAuthenticationToken.getName();
        String password = usernamePasswordAuthenticationToken.getCredentials().toString();

        Integer retryCount = cacheService.getCacheObject(getCacheKey(username));

        if (retryCount == null) {
            retryCount = 0;
        }

        if (retryCount >= Integer.valueOf(maxRetryCount).intValue()) {
            throw new UserPasswordRetryLimitExceedException(maxRetryCount, lockTime);
        }

        if (!matches(user, password)) {
            retryCount = retryCount + 1;
            cacheService.setCacheObject(getCacheKey(username), retryCount, (long) lockTime, TimeUnit.MINUTES);
            throw new UserPasswordNotMatchException();
        } else {
            clearLoginRecordCache(username);
        }
    }

    public boolean matches(SysUser user, String rawPassword) {
        return SecurityUtils.matchesPassword(rawPassword, user.getPassword());
    }

    public void clearLoginRecordCache(String loginName) {
        if (cacheService.hasKey(getCacheKey(loginName))) {
            cacheService.deleteObject(getCacheKey(loginName));
        }
    }
}

解锁用户

public class SysLogininforController extends BaseController {
    private final CacheService cacheService;
    
    @Operation(summary = "账号解锁")
    @PreAuthorize("@ss.hasPermi('system:logininfor:unlock')")
    @RequiresPermissions("system:logininfor:unlock")
    @Log(title = "账号解锁", businessType = BusinessType.OTHER)
    @GetMapping("/unlock/{userName}")
    public AjaxResult unlock(@PathVariable("userName") String userName) {
        cacheService.deleteObject(CacheConstants.PWD_ERR_CNT_KEY + userName);
        return success();
    }
}

# 代码生成

通过代码生成可以快速生成通用的增删改查的功能,极大地提高我们的开发效率,目前支持基于MySQL数据库中生成兼容MySQL和SQLite的代码

核心思路:将数据库表的基本信息保存到gen_table表中,而该表的列保存在gen_table_column表中,通过模板引擎velocity生成java、sql等代码

# 默认配置

# 代码生成
gen:
  # 作者
  author: mapgis
  # 默认生成包路径 system 需改成自己的模块名称 如 system monitor tool
  packageName: com.zondy.mapgis.system
  # 自动去除表前缀,默认是false
  autoRemovePre: false
  # 表前缀(生成类名不会包含表前缀,多个用逗号分隔)
  tablePrefix: sys_

# 支持表结构

  • 单表
  • 树表
  • 主子表

# 使用

  1. 准备表
  2. 进入系统工具-代码生成页面,导入对应表,做相应编辑配置后,生成代码
  3. 将生成的代码应用到项目
    • 将生成代码中的main目录直接拷贝到后端相应模块的src目录下
    • 将生成代码中的vue目录直接拷贝到前端src目录下
    • 在数据库下执行sql代码,用于创建菜单和按钮权限

# 代码生成器原理

# Velocity

Velocity是一个基于Java的模板引擎,其提供了一个Context容器,在java代码里面我们可以往容器中存值,然后在vm文件中使用特定的语法获取,这是velocity基本的用法,其与jsp、freemarker并称为三大视图展现技术。作为一个模块引擎,除了作为前后端分离的MVC展现层,Velocity还有一些其他用途,比如源代码生成。

在MapGIS Boot系统中,正是使用了Velocity技术实现的源代码生成。大体上,源代码生成只需三步走:

  1. 创建模板文件
  2. 准备上下文(变量值)
  3. 替换模板文件中的变量

三步走完之后源代码就生成了,说起来是很简单的,但是实际上做起来会比较麻烦,特别是第一步创建模板文件是最复杂的,以下为index.vue.vm模板文件部分源代码:

#foreach($column in $columns)
#if($column.insert)
#set($isInsert=1)
#end
#if($column.edit)
#set($isEdit=1)
#end
#if($column.query)
#set($queryCount=$queryCount+1)
#set($dictType=$column.dictType)
#set($AttrName=$column.javaField.substring(0,1).toUpperCase() + ${column.javaField.substring(1)})
#set($parentheseIndex=$column.columnComment.indexOf("("))
#if($parentheseIndex != -1)
#set($comment=$column.columnComment.substring(0, $parentheseIndex))
#else
#set($comment=$column.columnComment)
#end
...

可以看到,该vue模板文件中充斥着大量Velocity的if-else语法,嵌套在一起更是显得无比复杂。

# information_schema 数据库

mysql数据库中有一个information_schema数据库,它是mysql的系统数据库之一,它里面存储着两个表TABLES以及COLUMNS,这两个表分别存储着所有的表信息以及所有表中的列信息,代码生成器正是以两张表的信息为核心实现的。

# 代码生成器源码分析

首先看com.zondy.mapgis.gen.controller.GenController#importTableSave 接口,它做了以下这些事情:

  1. 从information_schema数据库的tables表中查询目标表的表明、标注释、创建时间和更新时间,但是忽略掉定时任务的表和已经生成过的表。
  2. 初始化表数据并将数据插入数据库的gen_table表
  3. 从information_schema数据库的columns表中查询目标表的列信息,包含字段名、字段注释、字段类型、是否允许为null等详细信息
  4. 初始化列信息并将数据插入数据库的gen_table_column表

接下来看下 com.zondy.mapgis.gen.controller.GenController#batchGenCode 接口,它做了以下这些事情

  1. 从数据库的gen_table、gen_table_column表查询出生成代码需要的表和列信息。
  2. 初始化Velocity
  3. 准备Velocity上下文信息(变量值信息)
  4. 读取模板、渲染模板,然后将渲染后的模板内容添加进如压缩流,之后前端就可以下载zip压缩文件了

# 高级功能

# 基于OAuth2的第三方用户登录

第三方平台在互联网行业多用户社交

核心思路:借助JustAuth实现了对第三方平台的OAuth2登录,目前默认集成了对GitHub、Gitee和Gitlab的支持

通过sys_auth_user的user_id字段建立起与sys_user的联系,当第三方平台初次登录后,会检测本平台内部是否有同名账号,存在同名时,则提示用户直接可以绑定或创建新账号,不存在同名时,直接提示创建新创号,同时,平台已有用户也可以后台设置页面手动进行绑定和解绑。

核心流程:

  • 已绑定第三方平台用户:请求第三方平台登录->第三方平台登录回调结果->响应token到前端->获取第三方登录用户信息
  • 未绑定第三方平台用户:请求第三方平台登录->第三方平台登录回调结果->响应绑定前的第三方登录用户信息到前端->提示绑定或创建新账户->响应token到前端->获取绑定后的第三方登录用户信息

详见thirdLoginMixin.js (opens new window)ThirdLoginController.java (opens new window)

目前后端支持开启和关闭,单体版在mapgis-server模块的配置文件application.yml中:

# 第三方登录
justauth:
  enabled: true
  type:
    GITHUB:
      client-id: ${JUSTAUTH_GITHUB_CLIENT_ID}
      client-secret: ${JUSTAUTH_GITHUB_CLIENT_SECRET}
      redirect-uri: ${JUSTAUTH_GITHUB_REDIRECT_URI:http://127.0.0.1:8080/xxx/rest/services/auth/thirdLogin/callback/github}
    GITEE:
      client-id: ${JUSTAUTH_GITEE_CLIENT_ID}
      client-secret: ${JUSTAUTH_GITEE_CLIENT_SECRET}
      redirect-uri: ${JUSTAUTH_GITEE_REDIRECT_URI:http://127.0.0.1:8080/xxx/rest/services/auth/thirdLogin/callback/gitee}
  cache:
    type: default
  extend:
    enum-class: ${JUSTAUTH_CUSTOM_ENUM_CLASS:com.zondy.mapgis.auth.justauth.source.AuthCustomSource}
    authorize: ${JUSTAUTH_CUSTOM_AUTHORIZE_URL}
    access-token: ${JUSTAUTH_CUSTOM_ACCESS_TOKEN_URL}
    user-info: ${JUSTAUTH_CUSTOM_USER_INFO_URL}
    config:
      CUSTOM:
        request-class: ${JUSTAUTH_CUSTOM_REQUEST_CLASS:com.zondy.mapgis.auth.justauth.request.AuthCustomRequest}
        client-id: ${JUSTAUTH_CUSTOM_CLIENT_ID}
        client-secret: ${JUSTAUTH_CUSTOM_CLIENT_SECRET}
        redirect-uri: ${JUSTAUTH_CUSTOM_REDIRECT_URI:http://127.0.0.1:8080/xxx/rest/services/auth/thirdLogin/callback/custom}
        scopes: ${JUSTAUTH_CUSTOM_SCOPES}

微服务版在网关模块的配置文件mapgis-xxx-auth-server-xxx.yml中(位于 nacos):

# 第三方登录
justauth:
  enabled: true
  type:
    GITHUB:
      client-id: f770f675866957c53ce6
      client-secret: 79fdc00cb1fc6b4b4bf9bfc5738b56b14529ca7b
      redirect-uri: http://127.0.0.1:8080/xxx/rest/services/auth/thirdLogin/callback/github
    GITEE:
      client-id: a3641bbe80e305d7810d1e848e4e1a9b338066ec16de19792fba1d1a304c8a2f
      client-secret: f4123a258799dc60284e1e30241ba7276d141aaddea777716f7e675f167d2e4b
      redirect-uri: http://127.0.0.1:8080/xxx/rest/services/auth/thirdLogin/callback/gitee
  cache:
    type: default
  extend:
    enum-class: com.zondy.mapgis.auth.justauth.source.AuthCustomSource
    authorize: http://192.168.200.88/oauth/authorize
    access-token: http://192.168.200.88/oauth/token
    user-info: http://192.168.200.88/api/v4/user
    config:
      CUSTOM:
        request-class: com.zondy.mapgis.auth.justauth.request.AuthCustomRequest
        client-id: dcaf95ad5e5cc3d7f5ff8ef06960c3d57a7b18582b5d12b2367388cbf7cd7db5
        client-secret: 83dc146f7bbca52e14b5eaa6f1963739035c0f625e9cc9c99b43ee846dd90c5b
        redirect-uri: http://127.0.0.1:8080/xxx/rest/services/auth/thirdLogin/callback/custom
        scopes: read_user openid

# CAS单点登录

单点登录SSO(Single Sign-On) 是一种统一认证和授权机制,指访问同一服务器不同应用中的受保护资源的同一用户,只需要登录一次,即通过一个应用中的安全验证后,再访问其他应用中的受保护资源时,不再需要重新登录验证。

# CAS单点登录服务端准备

需要搭建单独的服务端应用,可参考官网地址https://github.com/apereo/cas-overlay-template (opens new window)

一般情况下需要基于JDBC提供对动态用户的支持

配置application.properties参考:

# http端口
server.port=8888
# https
server.ssl.enabled=false
# 证书路径
server.ssl.key-store=config/ldkeystore.p12
# 别名密码
server.ssl.key-store-password=123456
server.ssl.key-password=123456
# 证书类型
server.ssl.key-store-type=PKCS12
# 别名
server.ssl.key-alias=undertow
# 设置安全为false
cas.tgc.secure=false
# 开启识别json文件,默认false
cas.serviceRegistry.initFromJson=true
cas.logout.follow-service-redirects=true

##
# CAS Authentication Credentials
#
cas.authn.jdbc.query[0].url=jdbc:mysql://localhost:3306/mapgis-xxx?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
cas.authn.jdbc.query[0].user=root
cas.authn.jdbc.query[0].password=cloud123.mapgis
cas.authn.jdbc.query[0].sql=select * from sys_user where user_name=?
cas.authn.jdbc.query[0].fieldPassword=password
cas.authn.jdbc.query[0].driverClass=com.mysql.cj.jdbc.Driver
#开启自定义密码验证
cas.authn.jdbc.query[0].passwordEncoder.type=com.cas.password.MyPasswordEncoder
cas.authn.jdbc.query[0].fieldDisabled=status

启动脚本startup.bat参考:

@echo off
cd /d %~dp0
cd ..
@echo on
java.exe -jar lib/cas.war --cas.standalone.configuration-directory=config
pause

# 单点登录流程

在本平台前后端分离模式下,单点登录中service采用的是后端接口,而非前端,当后端验证通过后,会携带token重定向到前端,前端基于token校验用户信息再跳转到去除token参数的前端页面

核心思想:基于spring-security-cas,在单体版和微服务版中均提供了对cas的对接支持

后端关键代码CasSecurityConfig.java (opens new window)

@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
@ConditionalOnProperty(prefix = "cas", name = "enabled", havingValue = "true")
public class CasSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        // ...
        // 添加CAS 认证filter
        httpSecurity.addFilter(casAuthenticationFilter());
        // 添加JWT filter
        httpSecurity.addFilterBefore(authenticationTokenFilter, CasAuthenticationFilter.class);
        httpSecurity.addFilterBefore(casLogoutFilter(), LogoutFilter.class);
        httpSecurity.addFilterBefore(singleSignOutFilter(), CasAuthenticationFilter.class);

        // 添加CORS filter
        httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
        httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
        // ...
    }

    /**
     * 身份认证接口
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        super.configure(auth);
        auth.authenticationProvider(casAuthenticationProvider());
    }

    /**
     * 指定service相关信息
     */
    @Bean
    public ServiceProperties serviceProperties() {
        ServiceProperties serviceProperties = new ServiceProperties();
        // 设置cas客户端登录完整的url
        serviceProperties.setService(casProperties.getCasServiceUrl() + casProperties.getCasServiceLoginUrl());
        serviceProperties.setAuthenticateAllArtifacts(true);
        return serviceProperties;
    }

    /**
     * CAS认证过滤器
     */
    @Bean
    public CasAuthenticationFilter casAuthenticationFilter() throws Exception {
        CasAuthenticationFilter casAuthenticationFilter = new CasAuthenticationFilter();
        casAuthenticationFilter.setAuthenticationManager(authenticationManager());
        casAuthenticationFilter.setFilterProcessesUrl(casProperties.getCasServiceLoginUrl());
        casAuthenticationFilter.setAuthenticationSuccessHandler(casAuthenticationSuccessHandler);
        return casAuthenticationFilter;
    }

    /**
     * CAS认证Provider
     */
    @Bean
    public CasAuthenticationProvider casAuthenticationProvider() {
        CasAuthenticationProvider casAuthenticationProvider = new CasAuthenticationProvider();
        casAuthenticationProvider.setAuthenticationUserDetailsService(casUserDetailsService);
        casAuthenticationProvider.setServiceProperties(serviceProperties());
        casAuthenticationProvider.setTicketValidator(cas20ServiceTicketValidator());
        casAuthenticationProvider.setKey("casAuthenticationProviderKey");
        return casAuthenticationProvider;
    }

    /**
     * CAS票证验证器
     */
    @Bean
    public Cas20ServiceTicketValidator cas20ServiceTicketValidator() {
        return new Cas20ServiceTicketValidator(casProperties.getCasServerUrl());
    }

    /**
     * 单点注销过滤器
     * 用于接收cas服务端的注销请求
     */
    @Bean
    public SingleSignOutFilter singleSignOutFilter() {
        SingleSignOutFilter singleSignOutFilter = new SingleSignOutFilter();
        singleSignOutFilter.setIgnoreInitConfiguration(true);
        return singleSignOutFilter;
    }

    /**
     * 单点退出过滤器
     * 用于跳转到cas服务端
     */
    @Bean
    public LogoutFilter casLogoutFilter() {
        LogoutFilter logoutFilter = new LogoutFilter(casProperties.getCasServerLogoutUrl(), new SecurityContextLogoutHandler());
        logoutFilter.setFilterProcessesUrl(casProperties.getCasServiceLogoutUrl());
        return logoutFilter;
    }

    @Bean
    public ServletListenerRegistrationBean<SingleSignOutHttpSessionListener> singleSignOutHttpSessionListener() {
        ServletListenerRegistrationBean<SingleSignOutHttpSessionListener> servletListenerRegistrationBean = new ServletListenerRegistrationBean<>();
        servletListenerRegistrationBean.setListener(new SingleSignOutHttpSessionListener());
        return servletListenerRegistrationBean;
    }
}

前端关键代码permission.js (opens new window)

router.beforeEach((to, from, next) => {
  NProgress.start() // start progress bar
  to.meta && typeof to.meta.title !== 'undefined' && setDocumentTitle(`${i18nRender(to.meta.title)} - ${domTitle}`)
  /* has token */
  if (storage.get(ACCESS_TOKEN)) {
    // ...
  } else {
    if (allowList.includes(to.name)) {
      // 在免登录名单,直接进入
      next()
    } else {
      if (window._CONFIG['enableSSO']) {
        const queryParams = qs.parse(document.location.toString().split('?')[1])
        const token = queryParams.token

        // 判断来源是不是cas的地址
        if (
          token &&
          (window._CONFIG['casLoginUrl'].includes(document.referrer) ||
            document.referrer.includes(document.location.host))
        ) {
          validateToken(token, to, from, next)
        } else {
          window.location.href = window._CONFIG['casLoginUrl']
        }
      } else {
        next({ path: loginRoutePath, query: { redirect: to.fullPath } })
      }
      NProgress.done() // if current page is login will not trigger afterEach hook, so manually handle it
    }
  }
})

function validateToken(token, to, from, next) {
  store
    .dispatch('ValidateLogin', token)
    .then(res => {
      const url = document.location.toString().split('?')[0]
      window.location.href = url
    })
    .catch(() => {
      window.location.href = window._CONFIG['casLoginUrl']
    })
}

# 前后端配置

前端配置

public/static/config.js

注意:service后面url,单体版为单体服务的主机端口,微服务版为授权服务的主机端口

/**
 * 存放配置常量
 */
window._CONFIG = {
  // 单点登录地址
  VUE_APP_CAS_LOGIN_URL: 'http://localhost:8888/cas/login?service=http://localhost:8080/login/cas',
  // 单点登出地址
  VUE_APP_CAS_LOGOUT_URL: 'http://localhost:8888/cas/logout?service=http://localhost:8080/login/cas',
  // 开启单点登录
  VUE_APP_SSO: false
}

后端配置

单体版在mapgis-server模块的配置文件application.yml中:

# cas配置
cas:
  enabled: ${CAS_ENABLED:false}
  server:
    host:
      # cas服务端地址
      url: ${CAS_SERVER_HOST_URL:http://localhost:8888/cas}
      #cas服务端登录地址
      login_url: ${cas.server.host.url}/login
      #cas服务端登出地址 service参数后面跟就是需要跳转的页面/接口 这里指定的是cas客户端登录接口
      logout_url: ${cas.server.host.url}/logout?service=${cas.service.host.url}${cas.service.host.login_url}
  service:
    host:
      # cas客户端地址
      url: ${CAS_SERVICE_HOST_URL:http://localhost:${server.port}}
      # cas客户端地址登录地址
      login_url: ${CAS_SERVICE_LOGIN_URL:/login/cas}
      # cas客户端地址登出地址
      logout_url: ${CAS_SERVICE_LOGOUT_URL:/logout/cas}
    web:
      url: ${CAS_SERVICE_WEB_RUL:http://localhost:8000}

微服务版在网关模块的配置文件mapgis-xxx-auth-server-xxx.yml中(位于 nacos):

# cas配置
cas:
  enabled: true
  server:
    host:
      # cas服务端地址
      url: http://localhost:8888/cas
      #cas服务端登录地址
      login_url: /login
      #cas服务端登出地址 service参数后面跟就是需要跳转的页面/接口 这里指定的是cas客户端登录接口
      logout_url: /logout?service=${cas.service.host.url}${cas.service.host.login_url}
  service:
    host:
      # cas客户端地址
      url: http://localhost:${server.port}
      # cas客户端地址登录地址
      login_url: /login/cas
      # cas客户端地址登出地址
      logout_url: /logout/cas
    web:
      url: http://localhost:8000

# 微应用路由管理

# 什么是微前端

微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。

# qiankun

qiankun 是蚂蚁金服开源的一款框架,它是基于 single-spa 的。他在 single-spa 的基础上,实现了开箱即用,除一些必要的修改外,子项目只需要做很少的改动,就能很容易的接入。如果说 single-spa 是自行车的话,qiankun 就是个汽车。

# 为什么不是 iframe

看这里 Why Not Iframe (opens new window)

# 微应用改造

微前端分为主应用和微应用,MapGIS Boot默认已经集成了qiankun,并传递了相关数据给微应用,更多配置请参考微前端配置

微应用改造请参考微应用 (opens new window)

提示

默认主应用路由base为/xxx/manager/,在微应用内部设置路由base的时候,需要添加该前缀,比如下面的完整base是/xxx/manager/app-vue,那么注册微应用时activeRule就为/xxx/manager/app-vue

router = new VueRouter({
  base: window.__POWERED_BY_QIANKUN__ ? '/xxx/manager/app-vue/' : '/',
  mode: 'history',
  routes,
});

# 微应用注册

支持前端微应用路由的动态注册,同registerMicroApps (opens new window)的RegistrableApp类型一样,配置微应用名称name、微应用入口entry和微应用路由activeRule即可,默认container为#micro-page

# 通过菜单加载微应用

支持通过菜单绑定微应用路由地址,实现微应用的加载,配置微应用页面菜单时,在组件路径上需要使用微应用组件,可结合菜单目录实现微应用嵌套路由的跳转。


使用微应用组件

一个包含Vue微应用和React微应用的菜单配置如下:


使用微应用组件

# 单体版核心功能

# 安全配置

允许登录/xxx/rest/services/auth/login、注册/xxx/rest/services/auth/register、验证码/xxx/rest/services/auth/captchaImage请求路径

对于退出/xxx/rest/services/auth/logout请求设置处理类LogoutSuccessHandlerImpl

@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	@Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
    	httpSecurity
        // CSRF禁用,因为不使用session
        .csrf().disable()
        // 认证失败处理类
        .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
        // 基于token,所以不需要session
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
        // 过滤请求
        .authorizeRequests()
        // 对于登录login 注册register 验证码captchaImage 允许匿名访问
        .antMatchers("/xxx/rest/services/auth/login", "/xxx/rest/services/auth/register", "/xxx/rest/services/auth/captchaImage").anonymous();
        httpSecurity.logout().logoutUrl("/xxx/rest/services/auth/logout")
        .logoutSuccessHandler(logoutSuccessHandler);
    }
}

# 登录流程

# 1、获取验证码/xxx/rest/services/auth/captchaImage

直接由 mapgis-xxx-auth-server 中 CaptchaController 控制器/auth/captchaImage 接口对应的方法处理。

@ApiOperation("生成验证码")
@GetMapping("/captchaImage")
public AjaxResult createCaptcha() throws IOException {
    return validateCodeService.createCaptcha();
}

# 2、AuthController 控制器方法 login 进行处理

@ApiOperation("登录方法")
@PostMapping("/login")
public AjaxResult login(@RequestBody LoginBody loginBody) {
    AjaxResult ajax = AjaxResult.success();
    // 生成令牌
    String token = loginService.login(loginBody);
    ajax.put(TokenConstants.TOKEN, token);
    return ajax;
}

# 3、验证登录信息的有效性

校验验证码、验证用户

@Component
public class SysLoginService {
    /**
     * 登录验证
     */
    public String login(LoginBody loginBody) {
        String username = loginBody.getUsername(), password = loginBody.getPassword();

        // 校验验证码
        validateCodeService.checkCaptcha(loginBody.getCode(), loginBody.getUuid());

        // 用户验证
        Authentication authentication = null;
        try {
            // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
            authentication = authenticationManager
                    .authenticate(new UsernamePasswordAuthenticationToken(username, password));
        } catch (Exception e) {
            if (e instanceof BadCredentialsException) {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
                throw new UserPasswordNotMatchException();
            } else {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
                throw new ServiceException(e.getMessage());
            }
        }
        // ...
    }
}

UserDetailsServiceImpl.loadUserByUsername

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
	@Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser user = userService.selectUserByUserName(username);
        if (StringUtils.isNull(user)) {
            log.info("登录用户:{} 不存在.", username);
            throw new ServiceException("登录用户:" + username + " 不存在");
        } else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
            log.info("登录用户:{} 已被删除.", username);
            throw new ServiceException("对不起,您的账号:" + username + " 已被删除");
        } else if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
            log.info("登录用户:{} 已被停用.", username);
            throw new ServiceException("对不起,您的账号:" + username + " 已停用");
        }

        return createLoginUser(user);
    }

    public UserDetails createLoginUser(SysUser user) {
        return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user.getUserId()));
    }
}

返回用户信息 LoginUser,除包含 SysUser 外,还包含了角色、权限信息。

# 4、记录登录日志,更新用户登录信息

@Component
public class SysLoginService {
    /**
     * 登录验证
     */
    public String login(LoginBody loginBody) {
    	// ...
    	AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        recordLoginInfo(loginUser.getUserId());
        // 生成token
        return tokenService.createToken(loginUser);
    }
}

# 5、返回登录令牌

设置用户登录的更多信息,缓存用户登录信息(login_tokens:开头),生成 JWT Token

@Component
public class TokenService {
	/**
     * 创建令牌
     *
     * @param loginUser 用户信息
     * @return 令牌
     */
    public String createToken(LoginUser loginUser) {
        String token = IdUtils.fastUUID();
        loginUser.setToken(token);
        setUserAgent(loginUser);
        refreshToken(loginUser);

        // Jwt存储信息
        Map<String, Object> claimsMap = new HashMap<String, Object>();
        claimsMap.put(SecurityConstants.USER_KEY, token);

        // 生成token
        return JwtUtils.createToken(claimsMap);
    }
}

# 登出流程

# 1、携带 Token 访问服务/xxx/rest/services/auth/logout

# 2、LogoutSuccessHandlerImplonLogoutSuccess

# 3、删除用户缓存记录

TokenService->delLoginUser->CacheService.deleteObject

tokenService.delLoginUser(SecurityUtils.getToken());

# 4、记录登出日志

// 记录用户退出日志
AsyncManager.me().execute(AsyncFactory.recordLogininfor(userName, Constants.LOGOUT, "退出成功"));

# 注册流程

# 1、带上请求体 RegisterBody 访问/xxx/rest/services/auth/register

public class RegisterBody extends LoginBody {

}

# 2、AuthController 控制器方法 register 进行处理

@ApiOperation("用户注册")
@PostMapping("/register")
public AjaxResult register(@RequestBody RegisterBody registerBody) {
	// 用户注册
	loginService.register(registerBody);
	return AjaxResult.success();
}

# 3、验证注册信息的有效性,进行注册

@Component
public class SysLoginService {
public void register(RegisterBody registerBody) {
    String username = registerBody.getUsername(), password = registerBody.getPassword();

    // 校验验证码
    validateCodeService.checkCaptcha(registerBody.getCode(), registerBody.getUuid());
    // 注册用户信息
    SysUser sysUser = new SysUser();
    sysUser.setUserName(username);
    sysUser.setNickName(username);
    sysUser.setPassword(SecurityUtils.encryptPassword(password));
    R<?> registerResult = sysServiceApi.registerUserInfo(sysUser, SecurityConstants.INNER);

    if (R.FAIL == registerResult.getCode()) {
    throw new ServiceException(registerResult.getMsg());
    }
}

# 4、记录注册登录日志

@Component
public class SysLoginService {
public void register(RegisterBody registerBody) {
	// ...
	AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.REGISTER,
                MessageUtils.message("user.register.success")));
}

# 服务访问流程

# 1、携带令牌 Token 访问服务

# 2、进入到 Token 过滤器

@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	/**
     * token认证过滤器
     */
    @Autowired
    private JwtAuthenticationTokenFilter authenticationTokenFilter;

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
    	// ...
    	// 添加JWT filter
        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // ...
    }
}

# 3、Token 过滤器中通过 Token 从缓存中获取用户

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        LoginUser loginUser = tokenService.getLoginUser(request);

        // ...
    }
}

@Component
public class TokenService {
    public LoginUser getLoginUser(HttpServletRequest request) {
        // 获取请求携带的令牌
        String token = SecurityUtils.getToken(request);
        return getLoginUser(token);
    }

    /**
     * 获取用户身份信息
     *
     * @return 用户信息
     */
    public LoginUser getLoginUser(String token) {
        LoginUser user = null;
        try {
            if (StringUtils.isNotEmpty(token)) {
                String userkey = JwtUtils.getUserKey(token);
                user = cacheService.getCacheObject(getTokenKey(userkey));
                return user;
            }
        } catch (Exception e) {
        }
        return user;
    }
}

public class SecurityUtils {
	/**
     * 根据request获取请求token
     */
    public static String getToken(HttpServletRequest request) {
        // 从header获取token标识
        String token = request.getHeader(TokenConstants.AUTHENTICATION);
        return replaceTokenPrefix(token);
    }
}

# 4、将登录用户记录到安全上下文中,便于本线程后面获取

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Autowired
    private TokenService tokenService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        LoginUser loginUser = tokenService.getLoginUser(request);
        if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())) {
            tokenService.verifyToken(loginUser);
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        chain.doFilter(request, response);
    }
}
public class SecurityUtils {
    /**
     * 获取Authentication
     */
    public static Authentication getAuthentication() {
        return SecurityContextHolder.getContext().getAuthentication();
    }
}

# 5、进入到各控制器相应的方法中处理

有需要可以通过 SecurityUtils 获取用户 ID、用户名称、用户 Key、登录用户信息等。

# 微服务版核心功能

# Nacos 持久化

nacos 自身提供了 mysql 的脚本,位于 conf 目录下,创建数据库,导入 sql 脚本后,需要修改 conf/application.properties 配置文件,添加 mysql 的数据源信息。

# db mysql
spring.datasource.platform=mysql
db.num=1
db.url.0=jdbc:mysql://localhost:3306/mapgis-server-config?characterEncoding=utf8&connectTimeout=30000&socketTimeout=60000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
db.user=root
db.password=password

# 认证

核心思路:

  • 登录:网关模块的 RouterFunctionConfiguration 提供/captchaImage 获取验证码和 UUID,前端将认证信息包装成 LoginBody 调用/auth/login 请求,在网关的过滤器 ValidateCodeFilter 中进行验证码的检查,通过后,进到认证授权模块的 AuthController 的 login 方法,通过远程调用sysServiceApi.getUserInfo获取用户信息,当用户存在的时候比较密码,匹配就继续调用sysServiceApi.saveLogininfor记录成功登录,同时创建令牌tokenService.createToken(userInfo)返回 LoginUser,失败就记录访问失败。

    用户 token(随机 UUID)、userId、userName 生成 jwt 的 token,返回的是这个 token,会在缓存中记录这个用户 token 跟 LoginUser 的关系并设定过期时间,每次外部访问携带 jwt 的 token 进来后,要获取用户时tokenSerivice.getLoginUser(token),都会先拿到用户 token,进而去缓存中拿到 LoginUser。

  • 登出:通过 jwt 的 token 删除用户缓存记录cacheService.deleteObject(getTokenKey(JwtUtils.getUserKey(token))),并拿到用户名JwtUtils.getUserName(token)后记录退出登录sysServiceApi.saveLogininfor

  • 在线用户:获取特定缓存“login_tokens:”前缀的缓存列表,转换成用户信息,当强退时,直接删除用户 token 对应的缓存即可,继续访问时,虽然访问令牌存在,但是因为对应的用户缓存被清除,所以会直接抛出异常

# 接口调用

核心思路:

  • 网关全局过滤器 AuthFilter 会验证令牌的有效期,通过后会把用户信息设置到请求头上(user_key、user_id、username),同时移除内部请求参数来源(防止外部伪造)
  • Feign 的请求拦截器会在调用时,传递请求头上的用户信息设置到 requestTemplate 请求头上(authorization、user_id、username),目前 Fengin 的拦截器只存在与使用了 EnableCustomConfig 的应用
  • 拦截器 HeaderInterceptor 会把用户信息从请求头上拿到后设置到线程变量中方便获取,在请求完成后从线程变量中清除,这样实际请求过程中,不依赖 token 就可以便捷地从 SecurityUtils 中获取用户名或用户

# 异步执行

@EnableASync

@ASync

# 网关

  • 网关限流,使用的是路由 id
  • 限流规则持久化

# 登录流程

# 1、获取验证码/xxx/rest/services/auth/captchaImage

基于网关的 RouterFunctionConfiguration 提供了/xxx/rest/services/auth/captchaImage的路由功能,可用于获取验证码 validateCodeService.createCapcha()。

@Configuration
public class RouterFunctionConfiguration {
    @Autowired
    private ValidateCodeHandler validateCodeHandler;

    @SuppressWarnings("rawtypes")
    @Bean
    public RouterFunction routerFunction() {
        return RouterFunctions.route(
                RequestPredicates.GET("/xxx/rest/services/auth/captchaImage").and(RequestPredicates.accept(MediaType.TEXT_PLAIN)),
                validateCodeHandler);
    }
}

@Component
public class ValidateCodeHandler implements HandlerFunction<ServerResponse> {
    @Autowired
    private ValidateCodeService validateCodeService;

    @Override
    public Mono<ServerResponse> handle(ServerRequest serverRequest) {
        AjaxResult ajax;
        try {
            ajax = validateCodeService.createCaptcha();
        } catch (CaptchaException | IOException e) {
            return Mono.error(e);
        }
        return ServerResponse.status(HttpStatus.OK).body(BodyInserters.fromValue(ajax));
    }
}

@Service
public class ValidateCodeServiceImpl implements ValidateCodeService {
	/**
     * 生成验证码
     */
    @Override
    public AjaxResult createCaptcha() throws IOException, CaptchaException {

    }
}

# 2、网关验证码过滤器 ValidateCodeFilter 拿到 body 中的 code 和 uuid 后进行验证

认证中心路由 id 配置了 2 个过滤器,其中 CacheRequestFilter 实现 body 请求数据流可重复读,ValidateCodeFilter 专门用于验证验证码。

spring:
  cloud:
    gateway:
      routes:
        # 认证中心
        - id: mapgis-xxx-auth-server
          uri: lb://mapgis-xxx-auth-server
          predicates:
            - Path=/xxx/rest/services/auth/**
          filters:
            # 验证码处理
            - CacheRequestFilter
            - ValidateCodeFilter
@Component
public class ValidateCodeFilter extends AbstractGatewayFilterFactory<Object> {
	@Override
    public GatewayFilter apply(Object config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();

            // 非登录/注册请求或验证码关闭,不处理
            if (!StringUtils.containsAnyIgnoreCase(request.getURI().getPath(), VALIDATE_URL) || !captchaProperties.getEnabled()) {
                return chain.filter(exchange);
            }

            try {
                String rspStr = resolveBodyFromRequest(request);
                JSONObject obj = JSONObject.parseObject(rspStr);
                validateCodeService.checkCaptcha(obj.getString(CODE), obj.getString(UUID));
            } catch (Exception e) {
                return ServletUtils.webFluxResponseWriter(exchange.getResponse(), e.getMessage());
            }
            return chain.filter(exchange);
        };
    }
}

# 3、网关鉴权全局过滤器 AuthFilter 验证

网关全局过滤器 AuthFilter 首先校验请求路径,位于白名单中的全部跳过,正好/xxx/rest/services/auth/login在白名单中。

# 安全配置
security:
  # 不校验白名单
  ignore:
    whites:
      - /xxx/rest/services/auth/logout
      - /xxx/rest/services/auth/login
      - /xxx/rest/services/auth/register
      - /*/v2/api-docs
      - /csrf
@Component
public class AuthFilter implements GlobalFilter, Ordered {
	@Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpRequest.Builder mutate = request.mutate();

        String url = request.getURI().getPath();
        // 跳过不需要验证的路径
        if (StringUtils.matches(url, ignoreWhite.getWhites())) {
            return chain.filter(exchange);
        }
        String token = getToken(request);
        if (StringUtils.isEmpty(token)) {
            return unauthorizedResponse(exchange, "令牌不能为空");
        }
        Claims claims = JwtUtils.parseToken(token);
        if (claims == null) {
            return unauthorizedResponse(exchange, "令牌已过期或验证不正确!");
        }
        String userkey = JwtUtils.getUserKey(claims);
        boolean islogin = cacheService.hasKey(getTokenKey(userkey));
        if (!islogin) {
            return unauthorizedResponse(exchange, "登录状态已过期");
        }
        String userid = JwtUtils.getUserId(claims);
        String username = JwtUtils.getUserName(claims);
        if (StringUtils.isEmpty(userid) || StringUtils.isEmpty(username)) {
            return unauthorizedResponse(exchange, "令牌验证失败");
        }

        // 设置用户信息到请求
        addHeader(mutate, SecurityConstants.USER_KEY, userkey);
        addHeader(mutate, SecurityConstants.DETAILS_USER_ID, userid);
        addHeader(mutate, SecurityConstants.DETAILS_USERNAME, username);
        // 内部请求来源参数清除
        removeHeader(mutate, SecurityConstants.FROM_SOURCE);
        return chain.filter(exchange.mutate().request(mutate.build()).build());
    }
}

# AuthController 控制器方法 login 进行处理

经由网关进入到了微服务 mapgis-xxx-auth-server 中 AuthController 控制器/auth/login 接口对应的方法后。

spring:
  cloud:
    gateway:
      routes:
        # 认证中心
        - id: mapgis-xxx-auth-server
          uri: lb://mapgis-xxx-auth-server
          predicates:
            - Path=/xxx/rest/services/auth/**
          filters:
            # 验证码处理
            - CacheRequestFilter
            - ValidateCodeFilter
@ServicesRestController("/auth")
public class AuthController {
	@ApiOperation("登录方法")
    @PostMapping("/login")
    public AjaxResult login(@RequestBody LoginBody loginBody) {
        AjaxResult ajax = AjaxResult.success();
        // 生成令牌
        String token = loginService.login(loginBody);
        ajax.put(TokenConstants.TOKEN, token);
        return ajax;
    }
}

# 5、通过系统模块的用户服务获取登录用户的信息 LoginUser

在认证中心微服务中调用系统模块的用户服务,采用 OpenFeign 来进行调用,系统模块提供了一个对应的系统接口模块,把其他微服务要用到的接口通过 OpenFeign 进行了包装,这样可以像调用本地服务一样来调用远程服务。

在微服务模块的入口需要添加@EnableFeignClients,这里有自定义注解@EnableMapFeignClients,在服务消费者的访问接口上需要添加@FeignClient。

/**
 * 系统服务API,提供其他独立模块调用
 *
 */
@FeignClient(contextId = "remoteSysServiceApi", value = ServiceNameConstants.SYSTEM_SERVICE, fallbackFactory = RemoteSysServiceApiFallbackFactory.class)
public interface ISysServiceApi {
    /**
     * 通过用户名查询用户信息
     *
     * @param username 用户名
     * @param source   请求来源
     * @return 结果
     */
    @GetMapping("/xxx/rest/manager/system/api/user/info/{username}")
    public R<LoginUser> getUserInfo(@PathVariable("username") String username, @RequestHeader(SecurityConstants.FROM_SOURCE) String source);

    /**
     * 注册用户信息
     *
     * @param sysUser 用户信息
     * @param source  请求来源
     * @return 结果
     */
    @PostMapping("/xxx/rest/manager/system/api/user/register")
    public R<Boolean> registerUserInfo(@RequestBody SysUser sysUser, @RequestHeader(SecurityConstants.FROM_SOURCE) String source);
}

# 6、验证登录信息的有效性

从系统模块拿到的登录用户信息 LoginUser,除包含 SysUser 外,还包含了角色、权限信息。

@ManagerRestController("/system/api")
public class SysServiceApiController extends BaseController {
    /**
     * 获取当前用户信息
     */
    @InnerAuth
    @GetMapping("/user/info/{username}")
    public R<LoginUser> getUserInfo(@PathVariable("username") String username) {
        return sysServiceApi.getUserInfo(username, "");
    }
}

@Service("SysServiceApiImpl")
public class SysServiceApiImpl implements ISysServiceApi {
    @Override
    public R<LoginUser> getUserInfo(String username, String source) {
        SysUser sysUser = userService.selectUserByUserName(username);
        if (StringUtils.isNull(sysUser)) {
            return R.fail("用户名或密码错误");
        }
        // 角色集合
        Set<String> roles = permissionService.getRolePermission(sysUser.getUserId());
        // 权限集合
        Set<String> permissions = permissionService.getMenuPermission(sysUser.getUserId());
        LoginUser sysUserVo = new LoginUser();
        sysUserVo.setUser(sysUser);
        sysUserVo.setRoles(roles);
        sysUserVo.setPermissions(permissions);
        return R.ok(sysUserVo);
    }
}

调用安全模块进行密码匹配验证。

if (!SecurityUtils.matchesPassword(password, user.getPassword())) {
            recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match"));
            throw new ServiceException("用户不存在/密码错误");
}

# 7、调用系统模块的访问日志服务记录成功和失败的登录操作

访问日志服务是由系统模块微服务提供的,在认证中心模块内调用,需要和获取系统模块的用户信息一样,定义服务接口,采用 OpenFeign 的方式进行调用。

/**
 * 系统服务API,提供其他独立模块调用
 *
 */
@FeignClient(contextId = "remoteSysServiceApi", value = ServiceNameConstants.SYSTEM_SERVICE, fallbackFactory = RemoteSysServiceApiFallbackFactory.class)
public interface ISysServiceApi {
    /**
     * 保存系统日志
     *
     * @param sysOperLog 日志实体
     * @param source     请求来源
     * @return 结果
     */
    @PostMapping("/xxx/rest/manager/system/api/operlog")
    public R<Boolean> saveLog(@RequestBody SysOperLog sysOperLog, @RequestHeader(SecurityConstants.FROM_SOURCE) String source);

    /**
     * 保存访问记录
     *
     * @param sysLogininfor 访问实体
     * @param source        请求来源
     * @return 结果
     */
    @PostMapping("/xxx/rest/manager/system/api/logininfor")
    public R<Boolean> saveLogininfor(@RequestBody SysLogininfor sysLogininfor, @RequestHeader(SecurityConstants.FROM_SOURCE) String source);
}

@Component
public class SysLoginService {
    /**
     * 记录登录信息
     *
     * @param username 用户名
     * @param status   状态
     * @param message  消息内容
     * @return
     */
    public void recordLogininfor(String username, String status, String message) {
        SysLogininfor logininfor = new SysLogininfor();
        logininfor.setUserName(username);
        logininfor.setIpaddr(IpUtils.getIpAddr(ServletUtils.getRequest()));
        logininfor.setMsg(message);
        // 日志状态
        if (StringUtils.equalsAny(status, Constants.LOGIN_SUCCESS, Constants.LOGOUT, Constants.REGISTER)) {
            logininfor.setStatus(Constants.LOGIN_SUCCESS_STATUS);
        } else if (Constants.LOGIN_FAIL.equals(status)) {
            logininfor.setStatus(Constants.LOGIN_FAIL_STATUS);
        }
        sysServiceApi.saveLogininfor(logininfor, SecurityConstants.INNER);
    }
}

# 8、返回登录信息,包括令牌

基于获取到的 LoginUser,设置用户 key、用户 id、用户名、登录 ip、登录时间、令牌过期时间,并通过用户 key、用户 id 和用户名调用 jjwt 生成令牌,返回给外部。

过期原理:依靠的是 redis 的TTL(Time To Live,剩余生存时间)失效机制实现。

{
  "code": 200,
  "msg": null,
  "data": {
    "access_token": "...",
    "expires_in": 720
  }
}
@Component
public class TokenService {
    /**
     * 创建令牌
     *
     * @param loginUser 用户信息
     * @return 令牌
     */
    public String createToken(LoginUser loginUser) {
        String token = IdUtils.fastUUID();
        Long userId = loginUser.getUser().getUserId();
        String userName = loginUser.getUser().getUserName();
        loginUser.setToken(token);
        loginUser.setUserId(userId);
        loginUser.setUsername(userName);
        loginUser.setIpaddr(IpUtils.getIpAddr(ServletUtils.getRequest()));
        refreshToken(loginUser);

        // Jwt存储信息
        Map<String, Object> claimsMap = new HashMap<String, Object>();
        claimsMap.put(SecurityConstants.USER_KEY, token);
        claimsMap.put(SecurityConstants.DETAILS_USER_ID, userId);
        claimsMap.put(SecurityConstants.DETAILS_USERNAME, userName);

        // 生成token
        return JwtUtils.createToken(claimsMap);
    }

    /**
     * 刷新令牌有效期
     *
     * @param loginUser 登录信息
     */
    public void refreshToken(LoginUser loginUser) {
        loginUser.setLoginTime(System.currentTimeMillis());
        loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
        // 根据uuid将loginUser缓存
        String userKey = getTokenKey(loginUser.getToken());
        cacheService.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
    }
}

# 登出流程

# 1、携带 Token 访问服务/xxx/rest/services/auth/logout

# 2、网关过滤器跳过(验证码过滤器、全局过滤器)

位于验证码的忽略名单和全局过滤器的白名单中,直接跳过。

@Component
public class ValidateCodeFilter extends AbstractGatewayFilterFactory<Object> {
	@Override
    public GatewayFilter apply(Object config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();

            // 非登录/注册请求或验证码关闭,不处理
            if (!StringUtils.containsAnyIgnoreCase(request.getURI().getPath(), VALIDATE_URL) || !captchaProperties.getEnabled()) {
                return chain.filter(exchange);
            }
            //...
		}
	}
}

# 3、网关路由到 LoginController 的/logout 映射的方法

# 4、删除用户缓存记录

AuthUtil.logoutByToken->AuthLogic.logoutByToken->TokenService->delLoginUser->CacheService.deleteObject

@ApiOperation("用户退出")
@DeleteMapping("logout")
public AjaxResult logout(HttpServletRequest request) {
    String token = SecurityUtils.getToken(request);
    if (StringUtils.isNotEmpty(token)) {
        String username = JwtUtils.getUserName(token);
        // 删除用户缓存记录
        AuthUtil.logoutByToken(token);
        // 记录用户退出日志
        loginService.logout(username);
    }
    return AjaxResult.success();
}

# 5、记录登出日志

@Component
public class SysLoginService  {
	public void logout(String loginName) {
        recordLogininfor(loginName, Constants.LOGOUT, "退出成功");
    }
}

# 注册流程

# 1、带上请求体 RegisterBody 访问/xxx/rest/services/auth/register

public class RegisterBody extends LoginBody {

}

# 2、网关过滤器跳过(验证码过滤器、全局过滤器)

# 3、网关路由到 AuthController 的/register 映射的方法

# 4、调用系统模块的用户注册服务

@Component
public class SysLoginService {
    /**
     * 注册
     */
    public void register(RegisterBody registerBody) {
        String username = registerBody.getUsername(), password = registerBody.getPassword();
        // 注册用户信息
        SysUser sysUser = new SysUser();
        sysUser.setUserName(username);
        sysUser.setNickName(username);
        sysUser.setPassword(SecurityUtils.encryptPassword(password));
        R<?> registerResult = sysServiceApi.registerUserInfo(sysUser, SecurityConstants.INNER);

        if (R.FAIL == registerResult.getCode()) {
            throw new ServiceException(registerResult.getMsg());
        }
        recordLogininfor(username, Constants.REGISTER, MessageUtils.message("user.register.success"));
    }
}

# 5、记录注册日志

recordLogininfor(username, Constants.REGISTER, "注册成功");

# 服务调用流程

# 1、服务提供者添加自定义注解@EnableMapServerFeignClients

服务提供者需要@EnableFeignClients 注解,这里有自定义注解@EnableMapServerFeignClients,指定了@FeignClient 所在的包路径。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@EnableFeignClients
public @interface EnableMapFeignClients {
    String[] value() default {};

    String[] basePackages() default {"com.zondy.mapgis"};

    Class<?>[] basePackageClasses() default {};

    Class<?>[] defaultConfiguration() default {};

    Class<?>[] clients() default {};
}

# 2、服务提供者添加自定义注解@EnableMapConfig

配置 Feign 拦截器,实现服务间调用时,请求头信息的修改。

实现 RequestInterceptor 接口的 apply 方法,feign 在发送请求之前都会调用该接口的 apply 方法。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
// 表示通过aop框架暴露该代理对象,AopContext能够访问
@EnableAspectJAutoProxy(exposeProxy = true)
// 指定要扫描的Mapper类的包的路径
@MapperScan("com.zondy.mapgis.**.mapper")
// 开启线程异步执行
@EnableAsync
// 自动加载类
@Import({ApplicationConfig.class, FeignAutoConfiguration.class})
public @interface EnableMapConfig {

}

/**
 * Feign 配置注册
 **/
@Configuration
public class FeignAutoConfiguration {
    @Bean
    public RequestInterceptor requestInterceptor() {
        return new FeignRequestInterceptor();
    }
}

/**
 * feign 请求拦截器
 */
@Component
public class FeignRequestInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate requestTemplate) {
        HttpServletRequest httpServletRequest = ServletUtils.getRequest();
        if (StringUtils.isNotNull(httpServletRequest)) {
            Map<String, String> headers = ServletUtils.getHeaders(httpServletRequest);
            // 传递用户信息请求头,防止丢失
            String userId = headers.get(SecurityConstants.DETAILS_USER_ID);
            if (StringUtils.isNotEmpty(userId)) {
                requestTemplate.header(SecurityConstants.DETAILS_USER_ID, userId);
            }
            String userName = headers.get(SecurityConstants.DETAILS_USERNAME);
            if (StringUtils.isNotEmpty(userName)) {
                requestTemplate.header(SecurityConstants.DETAILS_USERNAME, userName);
            }
            String authentication = headers.get(SecurityConstants.AUTHORIZATION_HEADER);
            if (StringUtils.isNotEmpty(authentication)) {
                requestTemplate.header(SecurityConstants.AUTHORIZATION_HEADER, authentication);
            }

            // 配置客户端IP
            requestTemplate.header("X-Forwarded-For", IpUtils.getIpAddr(ServletUtils.getRequest()));
        }
    }
}

# 3、对于内部服务(不能通过网关调用)在服务提供者的控制器方法上添加@InnerAuth 注解

用于验证内部服务调用的有效性,内部调用时,会在请求头上添加“from-source”为"inner",假如用户从网关层调用该服务,会在网关的全局过滤器中移除请求头上的“from-source”标识,让其不能访问。

/**
 * 内部服务调用验证处理
 */
@Aspect
@Component
public class InnerAuthAspect implements Ordered {
    @Around("@annotation(innerAuth)")
    public Object innerAround(ProceedingJoinPoint point, InnerAuth innerAuth) throws Throwable {
        String source = ServletUtils.getRequest().getHeader(SecurityConstants.FROM_SOURCE);
        // 内部请求验证
        if (!StringUtils.equals(SecurityConstants.INNER, source)) {
            throw new InnerAuthException("没有内部访问权限,不允许访问");
        }

        String userid = ServletUtils.getRequest().getHeader(SecurityConstants.DETAILS_USER_ID);
        String username = ServletUtils.getRequest().getHeader(SecurityConstants.DETAILS_USERNAME);
        // 用户信息验证
        if (innerAuth.isUser() && (StringUtils.isEmpty(userid) || StringUtils.isEmpty(username))) {
            throw new InnerAuthException("没有设置用户信息,不允许访问 ");
        }
        return point.proceed();
    }

    /**
     * 确保在权限认证aop执行前执行
     */
    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE + 1;
    }
}

/**
 * 网关鉴权
 */
@Component
public class AuthFilter implements GlobalFilter, Ordered {
	@Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    	//...
    	// 内部请求来源参数清除
        removeHeader(mutate, SecurityConstants.FROM_SOURCE);
        return chain.filter(exchange.mutate().request(mutate.build()).build());
    }
}

# 4、服务消费者定义服务访问接口添加@FeignClient

对于只能内部访问的服务,会在定义服务访问接口时添加参数@RequestHeader(SecurityConstants.FROM_SOURCE) String source,对于 Feign 的调用添加容错处理,定义 fallbackFactory。当微服务出错时,进行提示。

/**
 * 系统服务API,提供其他独立模块调用
 */
@FeignClient(contextId = "remoteSysServiceApi", value = ServiceNameConstants.SYSTEM_SERVICE, fallbackFactory = RemoteSysServiceApiFallbackFactory.class)
public interface ISysServiceApi {
    /**
     * 通过用户名查询用户信息
     *
     * @param username 用户名
     * @param source   请求来源
     * @return 结果
     */
    @GetMapping("/xxx/rest/manager/system/api/user/info/{username}")
    public R<LoginUser> getUserInfo(@PathVariable("username") String username, @RequestHeader(SecurityConstants.FROM_SOURCE) String source);
}

# 网关访问流程

# 1、携带令牌 Token 访问服务

# 2、网关全局过滤器 AuthFilter 验证

验证 Token 的有效性,令牌不能为空、令牌需要符合规范(能通过 jjwt 获取数据声明)、能从 cacheService 中得到用户 key(通过 token 获取)对应的信息(表示用户登录),通过后将用户信息添加到请求头上,这样网关路由后的服务可以从头上获取解析后的用户 key、用户 id 和用户名。

/**
 * 网关鉴权
 */
@Component
public class AuthFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpRequest.Builder mutate = request.mutate();

        String url = request.getURI().getPath();
        // 跳过不需要验证的路径
        if (StringUtils.matches(url, ignoreWhite.getWhites())) {
            return chain.filter(exchange);
        }
        String token = getToken(request);
        if (StringUtils.isEmpty(token)) {
            return unauthorizedResponse(exchange, "令牌不能为空");
        }
        Claims claims = JwtUtils.parseToken(token);
        if (claims == null) {
            return unauthorizedResponse(exchange, "令牌已过期或验证不正确!");
        }
        String userkey = JwtUtils.getUserKey(claims);
        boolean islogin = cacheService.hasKey(getTokenKey(userkey));
        if (!islogin) {
            return unauthorizedResponse(exchange, "登录状态已过期");
        }
        String userid = JwtUtils.getUserId(claims);
        String username = JwtUtils.getUserName(claims);
        if (StringUtils.isEmpty(userid) || StringUtils.isEmpty(username)) {
            return unauthorizedResponse(exchange, "令牌验证失败");
        }

        // 设置用户信息到请求
        addHeader(mutate, SecurityConstants.USER_KEY, userkey);
        addHeader(mutate, SecurityConstants.DETAILS_USER_ID, userid);
        addHeader(mutate, SecurityConstants.DETAILS_USERNAME, username);
        // 内部请求来源参数清除
        removeHeader(mutate, SecurityConstants.FROM_SOURCE);
        return chain.filter(exchange.mutate().request(mutate.build()).build());
    }
}

# 3、网关路由到相应的微服务中,被 HeaderInterceptor 拦截

只要依赖了 mapgis-common-cloud-security 模块,会自动进行 WebMvcConfigurer 配置,添加 HeaderInterceptor 拦截器,HeaderInterceptor 负责从请求头上拿到在网关上添加的用户信息进行保存,并通过 SecurityUtils.getToken 获取到令牌,进而通过 AuthUtil.getLoginUser 获取到 LoginUser,验证 AuthUtil.verifyLoginUserExpire 有效后,会将 LoginUser 也保存到当前的线程变量中,这样微服务本身就可以直接通过 SecurityUtils 获取到用户和用户信息了。

方便获取,在请求头拦截器上做了此操作,其他地方可以非常简单地获取这些信息。

/**
 * 自定义请求头拦截器,将Header数据封装到线程变量中方便获取
 * 注意:此拦截器会同时验证当前用户有效期自动刷新有效期
 */
public class HeaderInterceptor implements AsyncHandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }

        SecurityContextHolder.setUserId(ServletUtils.getHeader(request, SecurityConstants.DETAILS_USER_ID));
        SecurityContextHolder.setUserName(ServletUtils.getHeader(request, SecurityConstants.DETAILS_USERNAME));
        SecurityContextHolder.setUserKey(ServletUtils.getHeader(request, SecurityConstants.USER_KEY));

        String token = SecurityUtils.getToken();
        if (StringUtils.isNotEmpty(token)) {
            LoginUser loginUser = AuthUtil.getLoginUser(token);
            if (StringUtils.isNotNull(loginUser)) {
                AuthUtil.verifyLoginUserExpire(loginUser);
                SecurityContextHolder.set(SecurityConstants.LOGIN_USER, loginUser);
            }
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
            throws Exception {
        SecurityContextHolder.remove();
    }
}
/**
 * 安全服务工具类
 */
public class SecurityUtils {
    /**
     * 获取用户ID
     */
    public static Long getUserId() {
        return SecurityContextHolder.getUserId();
    }

    /**
     * 获取用户名称
     */
    public static String getUsername() {
        return SecurityContextHolder.getUserName();
    }

    /**
     * 获取用户key
     */
    public static String getUserKey() {
        return SecurityContextHolder.getUserKey();
    }

    /**
     * 获取登录用户信息
     */
    public static LoginUser getLoginUser() {
        return SecurityContextHolder.get(SecurityConstants.LOGIN_USER, LoginUser.class);
    }

    /**
     * 获取请求token
     */
    public static String getToken() {
        return getToken(ServletUtils.getRequest());
    }

    /**
     * 根据request获取请求token
     */
    public static String getToken(HttpServletRequest request) {
        // 从header获取token标识
        String token = request.getHeader(TokenConstants.AUTHENTICATION);
        return replaceTokenPrefix(token);
    }
}

# 4、路由到相应的微服务控制器中

有需要可以通过 SecurityUtils 获取用户 ID、用户名称、用户 Key、登录用户信息等。

# 网关限流流程

# 1、配置各微服务采用 sentinel 进行限流

# feign 配置
feign:
  sentinel:
    enabled: true
<!-- SpringCloud Alibaba Sentinel -->
<dependency>
	<groupId>com.alibaba.cloud</groupId>
	<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>

# 2、网关模块限流(支持根据 Route ID 进行规则设置)

<!-- SpringCloud Alibaba Sentinel Gateway -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId>
</dependency>
sentinel:
  # 取消控制台懒加载
  eager: true
  transport:
    # 控制台地址
    dashboard: 127.0.0.1:8718

# 3、配置限流规则持久化

spring:
  cloud:
    sentinel:
      # nacos配置持久化
      datasource:
        ds1:
          nacos:
            server-addr: 127.0.0.1:8848
            dataId: sentinel-mapgis-xxx-gateway-server
            data-type: json
            rule-type: flow

sentinel-mapgis-xxx-gateway-server 是 json 格式。

[
  {
    "resource": "mapgis-xxx-auth-server",
    "count": 500,
    "grade": 1,
    "limitApp": "default",
    "strategy": 0,
    "controlBehavior": 0
  },
	{
    "resource": "mapgis-xxx-system-server",
    "count": 1000,
    "grade": 1,
    "limitApp": "default",
    "strategy": 0,
    "controlBehavior": 0
  },
	{
    "resource": "mapgis-xxx-job-server",
    "count": 300,
    "grade": 1,
    "limitApp": "default",
    "strategy": 0,
    "controlBehavior": 0
  }
]

# 4、自定义限流异常

@Configuration
public class GatewayConfig {
    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SentinelFallbackHandler sentinelGatewayExceptionHandler() {
        return new SentinelFallbackHandler();
    }
}

public class SentinelFallbackHandler implements WebExceptionHandler {
    private Mono<Void> writeResponse(ServerResponse response, ServerWebExchange exchange) {
        return ServletUtils.webFluxResponseWriter(exchange.getResponse(), "请求超过最大数,请稍候再试");
    }

    @Override
    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
        if (exchange.getResponse().isCommitted()) {
            return Mono.error(ex);
        }
        if (!BlockException.isBlockException(ex)) {
            return Mono.error(ex);
        }
        return handleBlockedRequest(exchange, ex).flatMap(response -> writeResponse(response, exchange));
    }

    private Mono<ServerResponse> handleBlockedRequest(ServerWebExchange exchange, Throwable throwable) {
        return GatewayCallbackManager.getBlockHandler().handleRequest(exchange, throwable);
    }
}

# 网关路由管理

网关路由支持从数据库中加载,可动态配置微服务路由,用于后端微服务扩展,控制微服务发布和下线。

# 微服务断言信息配置


微服务断言信息配置

# 微服务过滤器信息配置


微服务过滤器信息配置