一、背景

Playedu的后端服务是一个基于 Java17 + SpringBoot3 开发的多模块API程序。

二、开发运行快速上手

2.1环境要求

2.2 运行系统

2.2.1配置文件

spring:
  # 修改数据库连接
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    # 数据库地址
    url: "jdbc:mysql://localhost:3306/playedu?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&allowPublicKeyRetrieval=true&useSSL=false"
    # 数据库账号
    username: "root"
    # 数据库密码
    password: "123456"
 # 修改redis连接
  data:
    redis:
      # redis地址
      host: "127.0.0.1"
      # redis端口
      port: 6379
      # redis密码
      password:
  # Kafka
   kafka: 
    bootstrap-servers: "kafka的连接地址"
您需要修改上述文件中的 mysqlrediskafka配置。

2.2.2 程序跑起来

运行启动类xyz.playedu.api.PlayeduApiApplicationmain函数
跑起来之后可以看到这个表示启动成功:
也可以通过再浏览器输入http://127.0.0.1:9898测试一下

三、生产部署快速上手

当我们程序二开完成之后,需要将程序部署更新到生产服务器,在部署之前,我们需要编译程序。下面将是编译的流程。

3.1 生产环境配置文件

编辑playedu-api模块中resources目录下的application.yml
spring:
  # 修改数据库连接
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    # 数据库地址
    url: "jdbc:mysql://localhost:3306/playedu?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&allowPublicKeyRetrieval=true&useSSL=false"
    # 数据库账号
    username: "root"
    # 数据库密码
    password: "123456"
 # 修改redis连接
  data:
    redis:
      # redis地址
      host: "127.0.0.1"
      # redis端口
      port: 6379
      # redis密码
      password:
  # Kafka
  kafka:
    bootstrap-servers: "kafka的连接地址"
您需要修改上述文件中的 mysqlrediskafka配置,将它们修改成为您的线上生产环境配置。

3.2 编译程序

在项目根目录执行下面打包命令:
./mvnw clean package 
这就是编译完成了。编译生成的jar包会在playedu-api模块下target文件夹。到这里,我们就可以将 playedu-api-xxx.jar 文件部署到生产环境服务器了。

3.3 项目启动

使用命令行执行:
java –jar playedu-api-xxx.jar

四、项目介绍

4.1文件目录结构

playedu
├── playedu-api                           // 接口模块
│       └── controller                    // controller层
│       └── event                         // 事件发布器
│       └── listener                      // 事件监听器
│       └── interceptor                   // 拦截器
│       └── request                       // 自定义请求实体
│       └── task                          // 定时任务
│
├── playedu-common                        // 公共模块
│       └── annotation                    // 自定义注解
│       └── bus                           // 全局权限数据
│       └── caches                        // 全局缓存
│       └── config                        // 全局配置
│       └── constant                      // 通用常量
│       └── context                       // 用户全局数据
│       └── exception                     // 通用异常
│       └── type                          // 自定义实体
│       └── utils                         // 工具类
│       └── domain                        // 数据实体层
│       └── mapper                        // mapper层
│       └── service                       // service层
│   ├── resource                          // 静态资源
│       └── mapper                        // sql映射文件
│       └── i18n                          // 多语言
│       └── ipdb                          // IP地址资源文件
│       └── lua                           // lua文件
│
├── playedu-course                        // 线上课模块
├── playedu-exam                          // 考试模块
├── playedu-resource                      // 资源模块
├── playedu-system                        // 系统模块
│       └── aspectj                       // 切面代码
│       └── checks                        // 内置数据

4.2核心概念

4.2.1 消息队列
PlayEdu引入消息队列kafka,用于业务的解耦

4.2.3 用户认证

PlayEdu 的用户认证方案用的是sa-token提供的。底层的技术原理的话其实就是JWT也就是 token校验方式。JWT 的认证原理是线下存储认证tokentoken内容经过对称加密之后分发给终端用户)。因此,要实现认证用户的主动下线和登出操作的话,需要配合Redis实现。之所以使用Redis存储token黑名单数据主要是考虑到应用的集群部署。
下面是sa-token配置说明:
sa-token:
  token-name: "Authorization" # 也就是对应HTTP头的属性名
  timeout: 1296000 #token有效期[单位:秒,默认15天]
  is-concurrent: false #限制同时登录,false就是一个账号只允许一台设备登录
  jwt-secret-key: "playeduxyz"  # jwt秘钥,用户加密分发的token内容,请尽量设置复杂
  token-prefix: "Bearer" # token前缀。ps:Authorization: Bearer {token}
  is-log: false # 是否输出操作日志
4.2.3.1后台管理员认证流程
管理员的请求/backend/**由自定义拦截器AdminInterceptor拦截。下面是一些常用的方法:
// 1.判断管理员是否登录
 if (!authService.check()) {
    return responseTransform(response, 401, "请登录");
}
// 1.具体实现
@Override
public boolean check(String prv) {
    // 获取当前会话是否已经登录, 返回true或false
    if (!StpUtil.isLogin()) {
        return false;
    }
    // 
    String tokenPrv = (String) StpUtil.getExtra("prv");
    return prv.equals(tokenPrv);
}

// 2.获取当前登录管理员的ID
authService.userId();
// 2.具体实现 
 @Override
public Integer userId() {
    return StpUtil.getLoginIdAsInt();
}

// 3.根据管理员ID获取管理员信息,包含邮箱,名称等信息
AdminUser adminUser = adminUserService.findById(authService.userId());

// 4.根据管理员ID获取所有权限信息
backendBus.adminUserPermissions(adminUser.getId())
// 4.具体实现
public HashMap<String, Boolean> adminUserPermissions(Integer userId) {
    // 读取超级管理角色
    AdminRole superRole = adminRoleService.getBySlug(BackendConstant.SUPER_ADMIN_ROLE);

    HashMap<String, Boolean> permissions = new HashMap<>();
    // 根据管理员ID获取所属角色
    List<Integer> roleIds = adminUserService.getRoleIdsByUserId(userId);
    if (roleIds.isEmpty()) {
        return permissions;
    }

    List<Integer> permissionIds;

    if (roleIds.contains(superRole.getId())) { // 包含超级管理角色的话返回全部权限
        permissionIds = permissionService.allIds();
    } else { // 根据相应的roleIds读取权限
        permissionIds = adminRoleService.getPermissionIdsByRoleIds(roleIds);
        if (permissionIds.isEmpty()) {
            return permissions;
        }
    }

    return permissionService.getSlugsByIds(permissionIds);
}

// 5.当前请求上下文下,获取已登录管理员的id和详细信息
BCtx.getId();//获取id
BCtx.getAdminUser();//获取adminUser
4.2.3.2 学员用户认证流程
学员端口的请求 /api/v1/**由自定义拦截器FrontInterceptor拦截,下面是常用的方法:
// 1.判断学员是否登录,未登录返回错误信息
 if (!authService.check()) {
    return responseTransform(response, 401, "请登录");
}
// 1.具体实现
@Override
public boolean check(String prv) {
    // 获取当前会话是否已经登录, 返回true或false
    if (!StpUtil.isLogin()) {
        return false;
    }
    // 
    String tokenPrv = (String) StpUtil.getExtra("prv");
    return prv.equals(tokenPrv);
}

// 2.获取当前登录学员的ID
authService.userId();
// 2.具体实现
@Override
public Integer userId() {
    return StpUtil.getLoginIdAsInt();
}

// 3.根据学员ID获取学员信息,包含邮箱,名称等信息
User user = userService.find(authService.userId());

// 4.当前请求上下文下,获取已登录管理员的id和详细信息
FCtx.getId();//获取id
FCtx.getAdminUser();//获取user

4.2.4 权限认证

  1. 权限类型:
    1. 操作权限(action):用于行为操作(增删改查)的权限控制
    2. 数据权限(data):用于业务数据(如:学员邮箱字段)的权限控制
  2. 表结构说明
内置的权限数据在playedu-system模块AdminPermissionCheck文件,系统每次启动都会自动将最新的权限数据同步到数据库中。具体请见本册后面介绍的内置数据章节。
名称数据类型说明
idint主键
typevarchar类型[行为:action,数据:data]
group_namevarchar分组
sortint升序(控制在前台展示的排序)
namevarchar权限名
slugvarcharslug(唯一特征值)
created_attimestamp创建时间
名称数据类型说明
role_idint角色ID
perm_idint权限ID
名称数据类型说明
role_idint角色ID
perm_idint权限ID
  1. 新增权限数据
AdminPermissionCheck文件,按照权限对象格式新增权限
   put(
      // 权限类型[行为:action,数据:data]                    
      BPermissionConstant.TYPE_DATA,
      // 权限对象
      new HashMap<>() {
          {
              put(
                "权限分组", // 权限分组
                new AdminPermission[] {
                    new AdminPermission() {
                        {
                            setSort(0); // 升序
                            setName("权限名");// 权限名
                            setSlug("slug");// 权限slug
                        }
                    },
                });
          }
      });
  1. 通过角色编辑接口,来完成角色与权限数据的关联
roleService.updateWithPermissionIds(role, request.getName(), request.getPermissionIds());
  1. 行为权限校验使用:使用注解@BackendPermission(slug = "slug")标记控制器方法,会校验调用者是否具有指定权限
// 下面注解可以加到 Controller 的 method 上实现行为权限的控制
@BackendPermission(slug = BPermissionConstant.ADMIN_ROLE)

// 行为权限控制的具体实现原理
@Around("doPointcut()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    BackendPermission middleware = signature.getMethod().getAnnotation(BackendPermission.class);
    Integer adminUserId = BCtx.getId();
    HashMap<String, Boolean> permissions = backendBus.adminUserPermissions(adminUserId);
    if (permissions.get(middleware.slug()) == null) {
        return JsonResponse.error("权限不足", 403);
    }
    return joinPoint.proceed();
}
6.数据权限控制:通过给Model增加@JsonGetter注解方法,在该方法内部实现判断当前管理员是否具有指定的数据权限,如果没有的话则返回打码后的数据。
@TableName(value = "admin_users")
@Data
@Slf4j
public class AdminUser implements Serializable {

    @JsonGetter("email")
    public String transformEmail() {
        return BackendBus.valueHidden(
                BPermissionConstant.DATA_ADMIN_EMAIL,
                BackendConstant.PRIVACY_FIELD_TYPE_EMAIL,
                email,
                CommonConstant.ZERO);
    }
}

4.2.5 系统初始化

PlayEdu 利用 SpringBoot 的 CommandLineRunner 机制实现了在系统启动的时候完成数据库表的自动更新和系统初始化数据自动同步到数据库中。通过 @Order注解实现各个 CommandLineRunner的执行顺序,这样确保数据库表的初始化是最先执行的。下面是 PlayEdu 内置的几个 CommandLineRunner的启动那个顺序:
注意,这些 Checks 的文件位置在 playedu-system 模块下的 checks目录。
Check说明
MigrationCheck数据库表同步
AppConfigCheck系统默认配置同步
AdminPermissionCheck系统管理角色权限同步
SystemDataCheck系统初始化数据同步
UpgradeCheck系统版本升级逻辑

4.2.6 代码格式化

Spotless 是支持多种语言的代码格式化工具(自动或手动方式均可),自动为代码添加 licenseHeader 和格式化代码。在项目的pom文件中添加配置
<plugin>
    <groupId>com.diffplug.spotless</groupId>
    <artifactId>spotless-maven-plugin</artifactId>
    <version>2.36.0</version>
    <configuration>
        <java>
            <googleJavaFormat>
                <version>1.16.0</version>
                <style>AOSP</style>
                <reflowLongStrings>true</reflowLongStrings>
            </googleJavaFormat>

            <licenseHeader>
                <file>license-header.txt</file>
            </licenseHeader>
        </java>
    </configuration>
</plugin>
我们就可以执行以下命令来检查 Java 代码是否符合规范并进行格式化:
# 查看哪些代码不符合代码格式
./mvnw spotless:check

# 代码格式化
./mvnw spotless:apply

4.2.7 定时任务

Playedu使用quartz实现分布式定时任务框架,系统做好了封装,只需要简单的步骤即可完成定时任务功能
// 禁止并发执行(Quartz不要并发地执行同一个job定义(这里指一个job类的多个实例))
@DisallowConcurrentExecution
@Slf4j
public class TestJob extends QuartzJobBean {

    @Override
    protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
        // 执行业务逻辑
    }
}
QuartzTasks文件中的addQuartzJobs()方法新增上面创建的TestJob
// 添加定时任务
        addJob(
                scheduler, // 任务调度器
                TestJob.class, // 上面创建的job
                "test",  // 分组名称
                "TestJob", // 任务名称
                "0/30 * * * * ?"); // 调度频率 每30秒执行一次
这样我们就实现了TestJob 定时任务的创建。

4.3 程序依赖信息

插件地址说明
spring-boot-starter-webhttps://spring.io/projects/spring-boot/Web场景启动器
spring-boot-starter-aophttps://spring.io/projects/spring-boot/实现切面编程
spring-boot-starter-data-redishttps://spring.io/projects/spring-data-redis/集成redis
spring-boot-starter-testhttps://spring.io/projects/spring-boot/单元测试
mybatis-plus-boot-starterhttps://github.com/baomidou/mybatis-plus/集成mybatis-plus
spring-boot-starter-validationhttps://spring.io/projects/spring-boot/表单验证
gsonhttps://mvnrepository.com/artifact/com.google.code.gson/gson/JSON序列化及解析
lombokhttps://projectlombok.orgJava 类库
mysql-connector-jhttps://www.mysql.com集成mysql
spotlesshttps://github.com/diffplug/spotless/tree/main/plugin-maven/代码格式化
sa-token-spring-boot3-starterhttps://sa-token.cc权限认证
sa-token-dao-redis-jacksonhttps://sa-token.ccSa-Token 整合 Redis
sa-token-jwthttps://sa-token.ccSa-Token 整合 jwt
poi-ooxmlhttps://poi.apache.org文档处理
commons-lang3https://commons.apache.org/proper/commons-lang/常用类库
commons-collections4https://commons.apache.org/proper/commons-collections/集合类库
commons-iohttps://commons.apache.org/proper/commons-io/文件 IO类库
miniohttps://github.com/minio/minio-java/分布式存储
aws-java-sdk-s3https://aws.amazon.com/sdkforjava/对象存储
spring-boot-starter-quartzhttps://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-quartz定时任务
spring-kafkahttps://mvnrepository.com/artifact/org.springframework.kafka/spring-kafka消息队列