一、背景
Playedu的后端服务是一个基于 Java17 + SpringBoot3 开发的多模块API程序。
二、开发运行快速上手
2.1环境要求
- 操作系统:Window,Mac,Linux均可以
- Openjdk ≥ 17
- Maven ≥ 3.0
- MySQL ≥ 5.6
- Redis ≥ 7.0
- Kafka ≥ 1.0
- 开发工具:IDEA
2.2 运行系统
2.2.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的连接地址"
您需要修改上述文件中的
mysql和redis和kafka配置。2.2.2 程序跑起来
运行启动类
xyz.playedu.api.PlayeduApiApplication的main函数
跑起来之后可以看到这个表示启动成功:


三、生产部署快速上手
当我们程序二开完成之后,需要将程序部署更新到生产服务器,在部署之前,我们需要编译程序。下面将是编译的流程。
3.1 生产环境配置文件
编辑
playedu-api模块中resources目录下的application.ymlspring: # 修改数据库连接 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的连接地址"
您需要修改上述文件中的
mysql和redis和kafka配置,将它们修改成为您的线上生产环境配置。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,用于业务的解耦- 通过avro插件初始化传参实体
- 定义schema文件,需要在playedu-api模块/src/main/avro目录里新增// 定义消息传递的参数
{
// 实体文件输出路径
"namespace": "xyz.playedu.api.avro.course",
// 类型record
"type": "record",
// 实体文件名称
"name": "CourseAvro",
"fields": [
// 参数字段及类型
{"name": "id", "type": "string"},
{"name": "adminId", "type": "int"},
{"name": "createdAt", "type": "string"},
{"name": "courseId", "type": "int"},
{"name": "depIds","type": {"type": "array","items": "int"}},
{"name": "userIds","type": {"type": "array","items": "int"}},
{"name": "groupIds","type": {"type": "array","items": "int"}}
]
}
2. 运行mvn avro:schema
生成的java类在项目文件在/target/generated-sources/avro/目录里,并且复制到需要用到的实体目录中
注意:avro插件生成实体文件禁止直接修改如果需要修改,需要修改schema文件,重新生成实体文件,并复制覆盖原文件 - 定义一个生产者
@Autowired private KafkaTemplate<String, Object> kafkaTemplate;
// 消息传递的参数 CreatedAvro替换成新增的实体对象
CreatedAvro avro =
CreatedAvro.newBuilder()
.setId(HelperUtil.randomString(6))// 必须入参消息随机id
.setAdminId(BCtx.getId())
.setCreatedAt(DateUtil.getTime())
.build();
// 消息发送到topic_test
kafkaTemplate.send(
"topic_test",
CreatedAvro.getEncoder().encode(avro).array());
- 定义一个消费者,实现批量消费、消费失败重试等功能
@Autowired private KafkaConsumerRetryHandleV2 kafkaConsumerRetryHandleV2;
// 监听消费topic_test
@KafkaListener(topics = {"topic_test"})
public void doKafkaConsumer(
List<ConsumerRecord<String, byte[]>> recordList, Acknowledgment ack) {
if (StringUtil.isNotEmpty(recordList)) {
for (ConsumerRecord<String, byte[]> consumerRecord : recordList) {
if (StringUtil.isNotNull(consumerRecord.value())) {
try {
// 解析参数
CreatedAvro avro = CreatedAvro.getDecoder().decode(consumerRecord.value());
Integer adminId = avro.getAdminId();
// !!!!!!!!执行业务开始!!!!!!
XXXXXXXXX
// !!!!!!!!执行业务结束!!!!!!
if (StringUtil.isNotEmpty(avro.getId())) {
// 重试成功
kafkaConsumerRetryHandleV2.doUpdateSuccess(avro.getId().toString());
}
} catch (Exception e) {
// 执行失败需要记录到重试表,等待定时任务重试
String topic = consumerRecord.topic();
kafkaConsumerRetryHandleV2.doRetryHandle(
topic, consumerRecord.value(), e.getMessage());
}
}
}
// 手动批量提交offset
ack.acknowledge();
}
}
- 确认消费失败的消息,再次重新消费步骤查询kafka_consumer_retry_log表state=2(重试状态[0:等待重试,1:重试成功,2:重试失败])的消息,将state设置为0即可再次重新消费
- PlayEdu项目现有的topic对应消费者以及触发时机
功能 topic 消费者 触发时机 登录 topic_user_login UserLoginListener 学员登录成功
topic_user_logout UserLogoutListener 学员登出成功
topic_admin_user_login AdminUserLoginListener 管理员登录:记录登录信息 学员 topic_user_destroy UserDestroyListener 学员删除,学员关联数据删除
topic_user_learn_course_updated UserLearnCourseUpdateListener 统计课程观看记录和时长
topic_department_destroy DepartmentDestroyListener 部门删除,关联删除:部门-学员关联信息删除 课程 topic_course_msg_send CourseListener 新增课程向学员推送课程消息
topic_course_hour_created CourseHourCreatedListener 课时新建:新增课时信息
topic_course_destroy CourseDestroyListener 课程删除,课程关联数据删除
topic_course_chapter_destroy CourseChapterDestroyListener 课程删除关联删除:课程-章节关联数据
topic_course_teacher_destroy TeacherDestroyListener 讲师删除,关联删除:课程-讲师关联数据 考试 topic_question_saved_or_updated QuestionListener 试题新建、修改:更新题库试题数量
topic_paper_record PaperListener 考试完成:生成课时记录、任务记录
topic_study_task_user_save StudyTaskListener 学习任务新建、修改:学员任务总进度汇总 通讯录 topic_wechatwork_synch WechatWorkListener 企业微信通讯录同步
topic_feishu_synch FeiShuListener 飞书通讯录同步
topic_dingtalk_synch DingtalkListener 钉钉通讯录同步
topic_ldap_synch LDAPSyncListener LDAP通讯录同步
topic_msg_send SynchSendMsgListener 推送指派通知、督促通知
4.2.2 国际化
PlayEdu支持文本内容(内置的文案模板、消息提醒等)国际化,目前支持简体中文和繁体中文。因为已经实现了国际化的流程,因此扩展英文等其它语言也比较方便(引入一份新语言的翻译内容即可)。- AcceptHeaderLocaleResolver 默认解析器,通过请求头的 Accept-Language 字段来判断当前请求所属的环境的,默认是简体中文
- 配置国际化资源文件路径
spring:
messages:
basename: i18n/messages
- 创建国际化资源文件messages.properties不带后缀为默认语言资源

- 获取国际化文本,下面是封装的获取工具类
public class MessageUtils {
/**
* 根据消息键和参数 获取消息 委托给spring messageSource
*
* @param code 消息键
* @param args 参数
* @return 获取国际化翻译值
*/
public static String message(String code, Object... args) {
try {
MessageSource messageSource = SpringUtil.getBean(MessageSource.class);
return messageSource.getMessage(code, args, LocaleContextHolder.getLocale());
} catch (Exception e) {
return String.format(code, args);
}
}
}
- 代码中实现国际化操作
MessageUtils.message("测试");
PlayEdu引入消息队列kafka,用于业务的解耦
- 定义schema文件,需要在playedu-api模块/src/main/avro目录里新增// 定义消息传递的参数
{ // 实体文件输出路径 "namespace": "xyz.playedu.api.avro.course", // 类型record "type": "record", // 实体文件名称 "name": "CourseAvro", "fields": [ // 参数字段及类型 {"name": "id", "type": "string"}, {"name": "adminId", "type": "int"}, {"name": "createdAt", "type": "string"}, {"name": "courseId", "type": "int"}, {"name": "depIds","type": {"type": "array","items": "int"}}, {"name": "userIds","type": {"type": "array","items": "int"}}, {"name": "groupIds","type": {"type": "array","items": "int"}} ] }
2. 运行mvn avro:schema

生成的java类在项目文件在/target/generated-sources/avro/目录里,并且复制到需要用到的实体目录中

注意:avro插件生成实体文件禁止直接修改
如果需要修改,需要修改schema文件,重新生成实体文件,并复制覆盖原文件
@Autowired private KafkaTemplate<String, Object> kafkaTemplate; // 消息传递的参数 CreatedAvro替换成新增的实体对象 CreatedAvro avro = CreatedAvro.newBuilder() .setId(HelperUtil.randomString(6))// 必须入参消息随机id .setAdminId(BCtx.getId()) .setCreatedAt(DateUtil.getTime()) .build(); // 消息发送到topic_test kafkaTemplate.send( "topic_test", CreatedAvro.getEncoder().encode(avro).array());
@Autowired private KafkaConsumerRetryHandleV2 kafkaConsumerRetryHandleV2; // 监听消费topic_test @KafkaListener(topics = {"topic_test"}) public void doKafkaConsumer( List<ConsumerRecord<String, byte[]>> recordList, Acknowledgment ack) { if (StringUtil.isNotEmpty(recordList)) { for (ConsumerRecord<String, byte[]> consumerRecord : recordList) { if (StringUtil.isNotNull(consumerRecord.value())) { try { // 解析参数 CreatedAvro avro = CreatedAvro.getDecoder().decode(consumerRecord.value()); Integer adminId = avro.getAdminId(); // !!!!!!!!执行业务开始!!!!!! XXXXXXXXX // !!!!!!!!执行业务结束!!!!!! if (StringUtil.isNotEmpty(avro.getId())) { // 重试成功 kafkaConsumerRetryHandleV2.doUpdateSuccess(avro.getId().toString()); } } catch (Exception e) { // 执行失败需要记录到重试表,等待定时任务重试 String topic = consumerRecord.topic(); kafkaConsumerRetryHandleV2.doRetryHandle( topic, consumerRecord.value(), e.getMessage()); } } } // 手动批量提交offset ack.acknowledge(); } }
查询kafka_consumer_retry_log表state=2(重试状态[0:等待重试,1:重试成功,2:重试失败])
的消息,将state设置为0即可再次重新消费
| 功能 | topic | 消费者 | 触发时机 |
| 登录 | topic_user_login | UserLoginListener | 学员登录成功 |
| topic_user_logout | UserLogoutListener | 学员登出成功 | |
| topic_admin_user_login | AdminUserLoginListener | 管理员登录:记录登录信息 | |
| 学员 | topic_user_destroy | UserDestroyListener | 学员删除,学员关联数据删除 |
| topic_user_learn_course_updated | UserLearnCourseUpdateListener | 统计课程观看记录和时长 | |
| topic_department_destroy | DepartmentDestroyListener | 部门删除,关联删除:部门-学员关联信息删除 | |
| 课程 | topic_course_msg_send | CourseListener | 新增课程向学员推送课程消息 |
| topic_course_hour_created | CourseHourCreatedListener | 课时新建:新增课时信息 | |
| topic_course_destroy | CourseDestroyListener | 课程删除,课程关联数据删除 | |
| topic_course_chapter_destroy | CourseChapterDestroyListener | 课程删除关联删除:课程-章节关联数据 | |
| topic_course_teacher_destroy | TeacherDestroyListener | 讲师删除,关联删除:课程-讲师关联数据 | |
| 考试 | topic_question_saved_or_updated | QuestionListener | 试题新建、修改:更新题库试题数量 |
| topic_paper_record | PaperListener | 考试完成:生成课时记录、任务记录 | |
| topic_study_task_user_save | StudyTaskListener | 学习任务新建、修改:学员任务总进度汇总 | |
| 通讯录 | topic_wechatwork_synch | WechatWorkListener | 企业微信通讯录同步 |
| topic_feishu_synch | FeiShuListener | 飞书通讯录同步 | |
| topic_dingtalk_synch | DingtalkListener | 钉钉通讯录同步 | |
| topic_ldap_synch | LDAPSyncListener | LDAP通讯录同步 | |
| topic_msg_send | SynchSendMsgListener | 推送指派通知、督促通知 |
4.2.2 国际化
PlayEdu支持文本内容(内置的文案模板、消息提醒等)国际化,目前支持简体中文和繁体中文。因为已经实现了国际化的流程,因此扩展英文等其它语言也比较方便(引入一份新语言的翻译内容即可)。
- AcceptHeaderLocaleResolver 默认解析器,通过请求头的 Accept-Language 字段来判断当前请求所属的环境的,默认是简体中文
- 配置国际化资源文件路径
spring: messages: basename: i18n/messages
- 创建国际化资源文件messages.properties不带后缀为默认语言资源

- 获取国际化文本,下面是封装的获取工具类
public class MessageUtils { /** * 根据消息键和参数 获取消息 委托给spring messageSource * * @param code 消息键 * @param args 参数 * @return 获取国际化翻译值 */ public static String message(String code, Object... args) { try { MessageSource messageSource = SpringUtil.getBean(MessageSource.class); return messageSource.getMessage(code, args, LocaleContextHolder.getLocale()); } catch (Exception e) { return String.format(code, args); } } }
- 代码中实现国际化操作
MessageUtils.message("测试");
4.2.3 用户认证
PlayEdu 的用户认证方案用的是sa-token提供的。底层的技术原理的话其实就是JWT也就是token校验方式。JWT的认证原理是线下存储认证token(token内容经过对称加密之后分发给终端用户)。因此,要实现认证用户的主动下线和登出操作的话,需要配合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 权限认证
- 权限类型:
- 操作权限(
action):用于行为操作(增删改查)的权限控制 - 数据权限(
data):用于业务数据(如:学员邮箱字段)的权限控制
- 操作权限(
- 表结构说明
内置的权限数据在playedu-system模块AdminPermissionCheck文件,系统每次启动都会自动将最新的权限数据同步到数据库中。具体请见本册后面介绍的内置数据章节。
- 权限表
admin_permissions
| 名称 | 数据类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| type | varchar | 类型[行为:action,数据:data] |
| group_name | varchar | 分组 |
| sort | int | 升序(控制在前台展示的排序) |
| name | varchar | 权限名 |
| slug | varchar | slug(唯一特征值) |
| created_at | timestamp | 创建时间 |
- 管理员(
admin_users)和角色(admin_roles)关联的中间表(多对多关联)admin_user_role
| 名称 | 数据类型 | 说明 |
|---|---|---|
| role_id | int | 角色ID |
| perm_id | int | 权限ID |
- 角色(
admin_roles)与权限(admin_permissions)关联的中间表(多对多关联)admin_role_permission
| 名称 | 数据类型 | 说明 |
|---|---|---|
| role_id | int | 角色ID |
| perm_id | int | 权限ID |
- 新增权限数据
在
AdminPermissionCheck文件,按照权限对象格式新增权限put( // 权限类型[行为:action,数据:data] BPermissionConstant.TYPE_DATA, // 权限对象 new HashMap<>() { { put( "权限分组", // 权限分组 new AdminPermission[] { new AdminPermission() { { setSort(0); // 升序 setName("权限名");// 权限名 setSlug("slug");// 权限slug } }, }); } });
- 通过角色编辑接口,来完成角色与权限数据的关联
roleService.updateWithPermissionIds(role, request.getName(), request.getPermissionIds());
- 行为权限校验使用:使用注解
@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
- 在 IDEA 中使用 Maven 插件来执行这些操作。只需点击一下,即可完成整个过程。

4.2.7 定时任务
Playedu使用quartz实现分布式定时任务框架,系统做好了封装,只需要简单的步骤即可完成定时任务功能
- 定义一个TestJob
// 禁止并发执行(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 定时任务的创建。